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:
parent
3f8ead0a3e
commit
9d60948e64
@ -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) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user