feat(roof-shape): Gemini API 에 지붕형상 descriptor 산출 추가

[작업내용] :
- 응답 스키마에 roofShape{type,ridge,flows,confidence?} 필드
- BASE_PROMPT 에 [지붕형상 판독] 섹션(棟/軒先 → type/ridge/flows, 화면축)
- buildPrompt 입면도 활용을 棟방향·경사 판별 보조로 확대(외곽선 추출은 여전히 평면도만)
- 반환에 normalizeRoofShape(parsed.roofShape) 배선
- 런타임(실제 Gemini roofShape 응답) 수동 검증 잔여

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
sangwook yoo 2026-06-24 14:47:13 +09:00
parent 3f8ead0a3e
commit 9d60948e64

View File

@ -7,6 +7,7 @@ import { PDFDocument } from 'pdf-lib'
import { sessionOptions } from '@/lib/session' import { sessionOptions } from '@/lib/session'
import { logger } from '@/util/logger' import { logger } from '@/util/logger'
import { normalizeRoofShape } from '@/common/roofShapePattern'
const MAX_FILE_BYTES = 20 * 1024 * 1024 // 20MB const MAX_FILE_BYTES = 20 * 1024 * 1024 // 20MB
const MAX_VERTICES = 500 const MAX_VERTICES = 500
@ -51,6 +52,16 @@ const FLOOR_PLAN_SCHEMA = {
confidence: { type: SchemaType.NUMBER }, confidence: { type: SchemaType.NUMBER },
notes: { type: SchemaType.STRING }, notes: { type: SchemaType.STRING },
selectedDrawingType: { type: SchemaType.STRING }, selectedDrawingType: { type: SchemaType.STRING },
roofShape: {
type: SchemaType.OBJECT,
properties: {
type: { type: SchemaType.STRING },
ridge: { type: SchemaType.STRING },
flows: { type: SchemaType.ARRAY, items: { type: SchemaType.STRING } },
confidence: { type: SchemaType.NUMBER },
},
required: ['type', 'ridge', 'flows'],
},
}, },
required: ['outerline', 'unit'], required: ['outerline', 'unit'],
} }
@ -81,6 +92,19 @@ const BASE_PROMPT = `당신은 일본 주택 건축 도면에서 "지붕 투영
6. 분리된 복수 () 있으면 가장 건물 하나만 반환하고, 별동은 notes 기재합니다. 6. 분리된 복수 () 있으면 가장 건물 하나만 반환하고, 별동은 notes 기재합니다.
7. 지붕 투영을 추출할 없으면 outerline 배열로 반환하고 notes 사유를 적습니다. 7. 지붕 투영을 추출할 없으면 outerline 배열로 반환하고 notes 사유를 적습니다.
[지붕형상 판독 屋根伏図 ·軒先 + 立面図 보조]
- 屋根伏図의 (용마루) 위치·개수와 軒先(처마) 흘러내리는 방향으로 지붕 유형을 판별합니다.
- type: 寄棟'hip', 切妻'gable', 片流れ(단일경사)'shed', 판별 불가/복합형'manual'.
- ridge(용마루 방향): 寄棟'hip', 切妻 가로 용마루'horizontal', 切妻 세로 용마루'vertical', 片流れ'shed', 없음/복합'none'.
- flows: 처마가 흘러내리는 방향을 화면축 집합으로 기재합니다. 좌표계는 외곽선과 동일(좌상단 원점, =top·아래=bottom·왼쪽=left·오른쪽=right).
· 寄棟: ['top','bottom','left','right'] (사방 흐름)
· 切妻 가로 용마루(용마루가 좌우로 길게): ['top','bottom']
· 切妻 세로 용마루(용마루가 상하로 길게): ['left','right']
· 片流れ: 처마가 향하는 단일 방향 1개만, : ['bottom']
· 복합형/판별 불가: [] ( 경우 type='manual', ridge='none')
- 立面図(입면도) 있으면 棟방향·경사 방향 판별의 보조로만 활용하세요(외곽선/정점 추출에는 절대 사용 금지).
- 확신이 없으면 roofShape.confidence 낮게 주거나 roofShape 자체를 생략하세요(틀린 추정보다 누락이 낫습니다).
[출력] [출력]
- 응답은 반드시 단일 JSON 객체만 포함하며, 다른 텍스트, 마크다운, 코드펜스를 포함하지 마세요. - 응답은 반드시 단일 JSON 객체만 포함하며, 다른 텍스트, 마크다운, 코드펜스를 포함하지 마세요.
- 좌표는 정수 또는 소수 첫째 자리까지 허용합니다. - 좌표는 정수 또는 소수 첫째 자리까지 허용합니다.
@ -93,7 +117,8 @@ const BASE_PROMPT = `당신은 일본 주택 건축 도면에서 "지붕 투영
"scale": number (선택, 축척 분모. 1/100 이면 100), "scale": number (선택, 축척 분모. 1/100 이면 100),
"confidence": number (0~1), "confidence": number (0~1),
"notes": string (선택), "notes": string (선택),
"selectedDrawingType": string (선택) "selectedDrawingType": string (선택),
"roofShape": { "type": "hip|gable|shed|manual", "ridge": "hip|horizontal|vertical|shed|none", "flows": ["top|bottom|left|right", ...], "confidence": number } (선택)
}` }`
const buildPrompt = ({ pageMode, facadePages, floorPages, splitInfo } = {}) => { const buildPrompt = ({ pageMode, facadePages, floorPages, splitInfo } = {}) => {
@ -103,13 +128,13 @@ const buildPrompt = ({ pageMode, facadePages, floorPages, splitInfo } = {}) => {
if (splitInfo.floorCount > 0) segs.push(`평면도/지붕평면도 ${splitInfo.floorCount}`) if (splitInfo.floorCount > 0) segs.push(`평면도/지붕평면도 ${splitInfo.floorCount}`)
if (splitInfo.facadeCount > 0) segs.push(`입면도 ${splitInfo.facadeCount}`) if (splitInfo.facadeCount > 0) segs.push(`입면도 ${splitInfo.facadeCount}`)
const compo = `첨부 PDF 는 ${segs.join('과 ')}으로 구성됩니다(평면도가 앞).` const compo = `첨부 PDF 는 ${segs.join('과 ')}으로 구성됩니다(평면도가 앞).`
return `${BASE_PROMPT}\n\n${compo} 외곽선은 평면도/지붕평면도 페이지에서만 추출하고, 입면도 페이지는 외곽선 추출에 쓰지 말고 높이·층수 참고로만 사용하세요.` return `${BASE_PROMPT}\n\n${compo} 외곽선은 평면도/지붕평면도 페이지에서만 추출하세요. 입면도 페이지는 외곽선 추출에 쓰지 말고, 높이·층수와 지붕 棟방향·경사 방향(지붕형상 판별) 참고로만 사용하세요.`
} }
if (pageMode === 'specify' && (facadePages || floorPages)) { if (pageMode === 'specify' && (facadePages || floorPages)) {
const hints = [] const hints = []
if (floorPages) hints.push(`평면도(平面図)·지붕평면도(屋根伏図) 페이지: ${floorPages}`) if (floorPages) hints.push(`평면도(平面図)·지붕평면도(屋根伏図) 페이지: ${floorPages}`)
if (facadePages) hints.push(`입면도(立面図) 페이지: ${facadePages}`) if (facadePages) hints.push(`입면도(立面図) 페이지: ${facadePages}`)
return `${BASE_PROMPT}\n\n페이지 힌트: ${hints.join(' / ')}. 입면도 페이지는 외곽선 추출에 쓰지 말고 높이·층수 참고로만 사용하세요. 지붕 투영 외곽선은 지정된 평면도/지붕평면도 페이지 기준으로 추출하세요.` return `${BASE_PROMPT}\n\n페이지 힌트: ${hints.join(' / ')}. 입면도 페이지는 외곽선 추출에 쓰지 말고 높이·층수와 지붕 棟방향·경사 방향(지붕형상 판별) 참고로만 사용하세요. 지붕 투영 외곽선은 지정된 평면도/지붕평면도 페이지 기준으로 추출하세요.`
} }
return BASE_PROMPT return BASE_PROMPT
} }
@ -202,10 +227,7 @@ export async function POST(req) {
return NextResponse.json({ error: { code: 'UNAUTHORIZED', message: '로그인이 필요합니다.' } }, { status: 401 }) return NextResponse.json({ error: { code: 'UNAUTHORIZED', message: '로그인이 필요합니다.' } }, { status: 401 })
} }
if (isRateLimited(session.userId || 'unknown')) { if (isRateLimited(session.userId || 'unknown')) {
return NextResponse.json( return NextResponse.json({ error: { code: 'RATE_LIMITED', message: '분석 요청이 너무 잦습니다. 잠시 후 다시 시도해주세요.' } }, { status: 429 })
{ error: { code: 'RATE_LIMITED', message: '분석 요청이 너무 잦습니다. 잠시 후 다시 시도해주세요.' } },
{ status: 429 },
)
} }
if (!apiKey) { if (!apiKey) {
@ -328,6 +350,7 @@ export async function POST(req) {
confidence: parsed.confidence ?? null, confidence: parsed.confidence ?? null,
notes: parsed.notes ?? null, notes: parsed.notes ?? null,
selectedDrawingType: parsed.selectedDrawingType ?? null, selectedDrawingType: parsed.selectedDrawingType ?? null,
roofShape: normalizeRoofShape(parsed.roofShape),
empty: validation.empty, empty: validation.empty,
}) })
} catch (error) { } catch (error) {