From fe0eca3f4d5181cc80e21b07a126ae481a233bf0 Mon Sep 17 00:00:00 2001 From: nalpari Date: Wed, 1 Jul 2026 14:20:39 +0900 Subject: [PATCH 1/4] =?UTF-8?q?chore(openrouter):=20OpenRouter=20provider?= =?UTF-8?q?=20=EC=A0=84=ED=99=98=20=ED=99=98=EA=B2=BD=20=EC=8A=A4=EC=BA=90?= =?UTF-8?q?=ED=8F=B4=EB=93=9C=20=E2=80=94=20openai=20SDK=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20+=20env=20=EC=8A=AC=EB=A1=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - package.json: openai@^6.45.0 추가 (OpenRouter를 OpenAI 호환 SDK로 호출, baseURL=openrouter.ai/api/v1) - .env.{development,local.dev,localhost,production}: PDF_ANALYZER_PROVIDER / OPENROUTER_API_KEY / OPENROUTER_MODEL(anthropic/claude-sonnet-5) 슬롯 추가 - 실제 호출 코드는 미착수(다음 세션) — 라우트가 아직 토글을 소비하지 않아 동작 불변 Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.development | 6 +++++- .env.local.dev | 6 +++++- .env.localhost | 5 +++++ .env.production | 6 +++++- package.json | 1 + 5 files changed, 21 insertions(+), 3 deletions(-) diff --git a/.env.development b/.env.development index 6b3844f8..a9af3611 100644 --- a/.env.development +++ b/.env.development @@ -37,4 +37,8 @@ NEXT_PUBLIC_ENABLE_LOGGING=true #Gemini API Key GEMINI_API_KEY="AIzaSyDDmMkTkNt12QTj7I3SUygYkOoeG_Sc28Q" -GEMINI_MODEL="gemini-3.1-pro-preview" \ No newline at end of file +GEMINI_MODEL="gemini-3.1-pro-preview" +# [openrouter 스캐폴드] PDF 도면 판독 provider 토글 — 기본 gemini(값 채우기 전까지 동작 불변) +PDF_ANALYZER_PROVIDER=openrouter +OPENROUTER_API_KEY=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 +OPENROUTER_MODEL=anthropic/claude-sonnet-5 diff --git a/.env.local.dev b/.env.local.dev index a53524ca..bce504fe 100644 --- a/.env.local.dev +++ b/.env.local.dev @@ -33,4 +33,8 @@ S3_PROFILE="dev" #Gemini API Key GEMINI_API_KEY="AIzaSyDDmMkTkNt12QTj7I3SUygYkOoeG_Sc28Q" -GEMINI_MODEL="gemini-3.1-pro-preview" \ No newline at end of file +GEMINI_MODEL="gemini-3.1-pro-preview" +# [openrouter 스캐폴드] PDF 도면 판독 provider 토글 — 기본 gemini(값 채우기 전까지 동작 불변) +PDF_ANALYZER_PROVIDER=openrouter +OPENROUTER_API_KEY=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 +OPENROUTER_MODEL=anthropic/claude-sonnet-5 diff --git a/.env.localhost b/.env.localhost index 7cab2757..811d8730 100644 --- a/.env.localhost +++ b/.env.localhost @@ -39,3 +39,8 @@ NEXT_PUBLIC_ENABLE_LOGGING=true #Gemini API Key GEMINI_API_KEY="AIzaSyDDmMkTkNt12QTj7I3SUygYkOoeG_Sc28Q" GEMINI_MODEL="gemini-3.1-pro-preview" + +# [openrouter 스캐폴드] PDF 도면 판독 provider 토글 — 기본 gemini(값 채우기 전까지 동작 불변) +PDF_ANALYZER_PROVIDER=openrouter +OPENROUTER_API_KEY=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 +OPENROUTER_MODEL=anthropic/claude-sonnet-5 diff --git a/.env.production b/.env.production index 50904369..c1d342c3 100644 --- a/.env.production +++ b/.env.production @@ -36,4 +36,8 @@ NEXT_PUBLIC_ENABLE_LOGGING=false #Gemini API Key GEMINI_API_KEY="AIzaSyDDmMkTkNt12QTj7I3SUygYkOoeG_Sc28Q" -GEMINI_MODEL="gemini-3.1-pro-preview" \ No newline at end of file +GEMINI_MODEL="gemini-3.1-pro-preview" +# [openrouter 스캐폴드] PDF 도면 판독 provider 토글 — 기본 gemini(값 채우기 전까지 동작 불변) +PDF_ANALYZER_PROVIDER=openrouter +OPENROUTER_API_KEY=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 +OPENROUTER_MODEL=anthropic/claude-sonnet-5 diff --git a/package.json b/package.json index 81c5c75b..75a28fc1 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "mssql": "^11.0.1", "next": "14.2.28", "next-international": "^1.2.4", + "openai": "^6.45.0", "pdf-lib": "^1.17.1", "react": "^18", "react-chartjs-2": "^5.2.0", From 8c650b201007ed4a4856112838f578195d0c6d5c Mon Sep 17 00:00:00 2001 From: nalpari Date: Thu, 2 Jul 2026 11:15:53 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat(pdf-analyzer):=20PDF=20=EB=8F=84?= =?UTF-8?q?=EB=A9=B4=20=EB=B6=84=EC=84=9D=20OpenRouter=20provider=20?= =?UTF-8?q?=ED=86=A0=EA=B8=80=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [작업내용] : - Gemini 호출을 src/lib/pdf-analyzer/{index,gemini,openrouter}.js provider 모듈로 분리, route 는 위임만 담당 (PDF_ANALYZER_PROVIDER env 로 서버측 선택, 클라이언트 무변경) - openrouter.js: openai SDK(baseURL=openrouter.ai) + response_format json_schema(strict) 강제 + file-parser native 엔진 명시 고정(OCR 전환 방지), 기본 모델 anthropic/claude-fable-5 - env 4파일 OPENROUTER_MODEL 을 claude-fable-5 로 확정 - 실호출 검증 + 미니 A/B(박공지붕_1.pdf): 양 provider 8점 폴리곤 bbox 동일, fable-5 $0.44/85s Co-Authored-By: Claude Fable 5 --- .env.development | 2 +- .env.local.dev | 2 +- .env.localhost | 2 +- .env.production | 2 +- src/app/api/gemini/floor-plan/route.js | 127 ++++--------------------- src/lib/pdf-analyzer/gemini.js | 123 ++++++++++++++++++++++++ src/lib/pdf-analyzer/index.js | 15 +++ src/lib/pdf-analyzer/openrouter.js | 117 +++++++++++++++++++++++ 8 files changed, 277 insertions(+), 113 deletions(-) create mode 100644 src/lib/pdf-analyzer/gemini.js create mode 100644 src/lib/pdf-analyzer/index.js create mode 100644 src/lib/pdf-analyzer/openrouter.js diff --git a/.env.development b/.env.development index a9af3611..ed44dce7 100644 --- a/.env.development +++ b/.env.development @@ -41,4 +41,4 @@ GEMINI_MODEL="gemini-3.1-pro-preview" # [openrouter 스캐폴드] PDF 도면 판독 provider 토글 — 기본 gemini(값 채우기 전까지 동작 불변) PDF_ANALYZER_PROVIDER=openrouter OPENROUTER_API_KEY=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 -OPENROUTER_MODEL=anthropic/claude-sonnet-5 +OPENROUTER_MODEL=anthropic/claude-fable-5 diff --git a/.env.local.dev b/.env.local.dev index bce504fe..86db8491 100644 --- a/.env.local.dev +++ b/.env.local.dev @@ -37,4 +37,4 @@ GEMINI_MODEL="gemini-3.1-pro-preview" # [openrouter 스캐폴드] PDF 도면 판독 provider 토글 — 기본 gemini(값 채우기 전까지 동작 불변) PDF_ANALYZER_PROVIDER=openrouter OPENROUTER_API_KEY=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 -OPENROUTER_MODEL=anthropic/claude-sonnet-5 +OPENROUTER_MODEL=anthropic/claude-fable-5 diff --git a/.env.localhost b/.env.localhost index 811d8730..1ea7bd7c 100644 --- a/.env.localhost +++ b/.env.localhost @@ -43,4 +43,4 @@ GEMINI_MODEL="gemini-3.1-pro-preview" # [openrouter 스캐폴드] PDF 도면 판독 provider 토글 — 기본 gemini(값 채우기 전까지 동작 불변) PDF_ANALYZER_PROVIDER=openrouter OPENROUTER_API_KEY=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 -OPENROUTER_MODEL=anthropic/claude-sonnet-5 +OPENROUTER_MODEL=anthropic/claude-fable-5 diff --git a/.env.production b/.env.production index c1d342c3..f27eff84 100644 --- a/.env.production +++ b/.env.production @@ -40,4 +40,4 @@ GEMINI_MODEL="gemini-3.1-pro-preview" # [openrouter 스캐폴드] PDF 도면 판독 provider 토글 — 기본 gemini(값 채우기 전까지 동작 불변) PDF_ANALYZER_PROVIDER=openrouter OPENROUTER_API_KEY=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 -OPENROUTER_MODEL=anthropic/claude-sonnet-5 +OPENROUTER_MODEL=anthropic/claude-fable-5 diff --git a/src/app/api/gemini/floor-plan/route.js b/src/app/api/gemini/floor-plan/route.js index 7b42072e..ee36c9b8 100644 --- a/src/app/api/gemini/floor-plan/route.js +++ b/src/app/api/gemini/floor-plan/route.js @@ -1,20 +1,15 @@ import { NextResponse } from 'next/server' import { cookies } from 'next/headers' import { getIronSession } from 'iron-session' -import { GoogleGenerativeAI, SchemaType } from '@google/generative-ai' -import { GoogleAIFileManager, FileState } from '@google/generative-ai/server' import { PDFDocument } from 'pdf-lib' import { sessionOptions } from '@/lib/session' import { logger } from '@/util/logger' import { normalizeRoofShape } from '@/common/roofShapePattern' +import { analyzeFloorPlan, getPdfAnalyzerProvider } from '@/lib/pdf-analyzer' const MAX_FILE_BYTES = 20 * 1024 * 1024 // 20MB const MAX_VERTICES = 500 -const DEFAULT_MODEL = 'gemini-3.1-pro-preview' -// 인라인 전송 한도 — Gemini 요청 총 20MB 한도에서 base64(+~33%) 를 감안한 보수값. -// 초과 시 Files API 경로로 자동 fallback 한다. -const INLINE_MAX_BYTES = 12 * 1024 * 1024 // 12MB (raw) // Gemini 호출은 건당 과금이므로 사용자별 호출 빈도를 제한한다(프로세스 단위 in-memory). const RATE_LIMIT_WINDOW_MS = 60 * 1000 @@ -33,39 +28,6 @@ const isRateLimited = (key) => { return false } -const FLOOR_PLAN_SCHEMA = { - type: SchemaType.OBJECT, - properties: { - outerline: { - type: SchemaType.ARRAY, - items: { - type: SchemaType.OBJECT, - properties: { - x: { type: SchemaType.NUMBER }, - y: { type: SchemaType.NUMBER }, - }, - required: ['x', 'y'], - }, - }, - unit: { type: SchemaType.STRING }, - scale: { type: SchemaType.NUMBER }, - confidence: { type: SchemaType.NUMBER }, - notes: { 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'], -} - const BASE_PROMPT = `당신은 일본 주택 건축 도면에서 "지붕 투영면(屋根伏図 footprint)" 외곽선을 추출하는 전문가입니다. 첨부된 PDF 에서 지붕을 위에서 내려다본 외곽(처마 끝선 포함) 폴리곤 좌표를 추출하여 JSON 으로만 응답하세요. @@ -201,27 +163,11 @@ const toValidIndices = (pages, total) => { return [...set].sort((a, b) => a - b) } -const waitForFileActive = async (fileManager, fileName, { timeoutMs = 60000, intervalMs = 1500 } = {}) => { - const started = Date.now() - let file = await fileManager.getFile(fileName) - while (file.state === FileState.PROCESSING) { - if (Date.now() - started > timeoutMs) { - throw new Error('Gemini 파일 처리 타임아웃') - } - await new Promise((r) => setTimeout(r, intervalMs)) - file = await fileManager.getFile(fileName) - } - if (file.state !== FileState.ACTIVE) { - throw new Error(`Gemini 파일 처리 실패: ${file.state}`) - } - return file -} - export async function POST(req) { - const apiKey = process.env.GEMINI_API_KEY - const modelName = process.env.GEMINI_MODEL || DEFAULT_MODEL + const provider = getPdfAnalyzerProvider() + const apiKey = provider === 'openrouter' ? process.env.OPENROUTER_API_KEY : process.env.GEMINI_API_KEY - // 페이지 인증(layout redirect)은 API 라우트에 적용되지 않으므로 세션을 직접 검사한다 — 익명 Gemini 과금 차단. + // 페이지 인증(layout redirect)은 API 라우트에 적용되지 않으므로 세션을 직접 검사한다 — 익명 과금 차단. const session = await getIronSession(cookies(), sessionOptions) if (!session?.isLoggedIn) { return NextResponse.json({ error: { code: 'UNAUTHORIZED', message: '로그인이 필요합니다.' } }, { status: 401 }) @@ -231,12 +177,10 @@ export async function POST(req) { } if (!apiKey) { - return NextResponse.json({ error: { code: 'NO_API_KEY', message: 'GEMINI_API_KEY 가 설정되지 않았습니다.' } }, { status: 500 }) + const keyName = provider === 'openrouter' ? 'OPENROUTER_API_KEY' : 'GEMINI_API_KEY' + return NextResponse.json({ error: { code: 'NO_API_KEY', message: `${keyName} 가 설정되지 않았습니다.` } }, { status: 500 }) } - let uploadedFileName = null - const fileManager = new GoogleAIFileManager(apiKey) - try { const formData = await req.formData() const file = formData.get('file') @@ -293,43 +237,16 @@ export async function POST(req) { splitInfo = { floorCount: floorIdx.length, facadeCount: facadeIdx.length } } - // 분할본은 대부분 인라인 한도 이하 → 업로드·폴링 없이 즉시 전송. 초과 시에만 Files API 로 fallback. - const fileStarted = Date.now() - let pdfPart - if (sendBuffer.length <= INLINE_MAX_BYTES) { - pdfPart = { inlineData: { data: sendBuffer.toString('base64'), mimeType: 'application/pdf' } } - } else { - const uploadResult = await fileManager.uploadFile(sendBuffer, { - mimeType: 'application/pdf', - displayName: file.name || 'floor-plan.pdf', - }) - uploadedFileName = uploadResult.file.name - await waitForFileActive(fileManager, uploadedFileName) - pdfPart = { fileData: { fileUri: uploadResult.file.uri, mimeType: uploadResult.file.mimeType } } - } - const fileReadyMs = Date.now() - fileStarted - - const genAI = new GoogleGenerativeAI(apiKey) - const model = genAI.getGenerativeModel({ - model: modelName, - generationConfig: { - responseMimeType: 'application/json', - responseSchema: FLOOR_PLAN_SCHEMA, - temperature: 0.1, - // Gemini 3 고fidelity 경로 — 얇은 외벽선/작은 寸法 텍스트 가독성 확보(미지원 시 무시됨). 적용 여부는 usageMetadata 토큰 증가로 검증. - mediaResolution: 'MEDIA_RESOLUTION_HIGH', - }, - }) - + // provider(gemini|openrouter) 가 내부적으로 인라인/업로드/폴링을 처리한다. const prompt = buildPrompt({ pageMode, facadePages, floorPages, splitInfo }) - const generateStarted = Date.now() - const result = await model.generateContent([pdfPart, { text: prompt }]) - - const raw = result.response.text() - logger.debug('[gemini/floor-plan] timing(ms)', { fileReady: fileReadyMs, generate: Date.now() - generateStarted }) - logger.debug('[gemini/floor-plan] raw response', raw) - logger.debug('[gemini/floor-plan] usageMetadata', result.response.usageMetadata) + const analyzeStarted = Date.now() + const { raw, usage, provider: usedProvider } = await analyzeFloorPlan({ + pdfBuffer: sendBuffer, + fileName: file.name, + prompt, + }) + logger.debug('[floor-plan] analyze done', { provider: usedProvider, generate: Date.now() - analyzeStarted, usage }) let parsed try { @@ -344,8 +261,8 @@ export async function POST(req) { } const roofShape = normalizeRoofShape(parsed.roofShape) - // 수동 QA 용 — Gemini 가 판독한 지붕형상 원본과 정규화 결과를 함께 남긴다(NEXT_PUBLIC_ENABLE_LOGGING=true 시). - logger.debug('[gemini/floor-plan] roofShape 판독', { raw: parsed.roofShape ?? null, normalized: roofShape }) + // 수동 QA 용 — provider 가 판독한 지붕형상 원본과 정규화 결과를 함께 남긴다(NEXT_PUBLIC_ENABLE_LOGGING=true 시). + logger.debug('[floor-plan] roofShape 판독', { raw: parsed.roofShape ?? null, normalized: roofShape }) return NextResponse.json({ outerline: parsed.outerline, @@ -358,15 +275,7 @@ export async function POST(req) { empty: validation.empty, }) } catch (error) { - logger.error('[gemini/floor-plan] error', error?.message || error) - return NextResponse.json({ error: { code: 'GEMINI_FAILED', message: error?.message || 'Gemini 호출에 실패했습니다.' } }, { status: 500 }) - } finally { - if (uploadedFileName) { - // temp 파일 삭제는 응답을 지연시키지 않도록 await 하지 않고 분리(fire-and-forget)한다. - // 정리용 네트워크 호출이 행(hang)되더라도 클라이언트 응답이 막히지 않는다(미삭제 시 Gemini 측에서 자동 만료). - fileManager.deleteFile(uploadedFileName).catch((deleteError) => { - logger.warn('[gemini/floor-plan] deleteFile failed', deleteError?.message || deleteError) - }) - } + logger.error('[floor-plan] error', error?.message || error) + return NextResponse.json({ error: { code: 'ANALYZE_FAILED', message: error?.message || 'PDF 분석에 실패했습니다.' } }, { status: 500 }) } } diff --git a/src/lib/pdf-analyzer/gemini.js b/src/lib/pdf-analyzer/gemini.js new file mode 100644 index 00000000..25aea67b --- /dev/null +++ b/src/lib/pdf-analyzer/gemini.js @@ -0,0 +1,123 @@ +import { GoogleGenerativeAI, SchemaType } from '@google/generative-ai' +import { GoogleAIFileManager, FileState } from '@google/generative-ai/server' +import { logger } from '@/util/logger' + +const DEFAULT_MODEL = 'gemini-3.1-pro-preview' +// 인라인 전송 한도 — Gemini 요청 총 20MB 한도에서 base64(+~33%) 를 감안한 보수값. +// 초과 시 Files API 경로로 자동 fallback 한다. +const INLINE_MAX_BYTES = 12 * 1024 * 1024 // 12MB (raw) + +const FLOOR_PLAN_SCHEMA = { + type: SchemaType.OBJECT, + properties: { + outerline: { + type: SchemaType.ARRAY, + items: { + type: SchemaType.OBJECT, + properties: { + x: { type: SchemaType.NUMBER }, + y: { type: SchemaType.NUMBER }, + }, + required: ['x', 'y'], + }, + }, + unit: { type: SchemaType.STRING }, + scale: { type: SchemaType.NUMBER }, + confidence: { type: SchemaType.NUMBER }, + notes: { 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'], +} + +const waitForFileActive = async (fileManager, fileName, { timeoutMs = 60000, intervalMs = 1500 } = {}) => { + const started = Date.now() + let file = await fileManager.getFile(fileName) + while (file.state === FileState.PROCESSING) { + if (Date.now() - started > timeoutMs) { + throw new Error('Gemini 파일 처리 타임아웃') + } + await new Promise((r) => setTimeout(r, intervalMs)) + file = await fileManager.getFile(fileName) + } + if (file.state !== FileState.ACTIVE) { + throw new Error(`Gemini 파일 처리 실패: ${file.state}`) + } + return file +} + +export async function analyzeFloorPlan({ pdfBuffer, fileName, prompt }) { + const apiKey = process.env.GEMINI_API_KEY + const modelName = process.env.GEMINI_MODEL || DEFAULT_MODEL + if (!apiKey) { + throw new Error('GEMINI_API_KEY 가 설정되지 않았습니다.') + } + + let uploadedFileName = null + const fileManager = new GoogleAIFileManager(apiKey) + + try { + const fileStarted = Date.now() + let pdfPart + if (pdfBuffer.length <= INLINE_MAX_BYTES) { + pdfPart = { inlineData: { data: pdfBuffer.toString('base64'), mimeType: 'application/pdf' } } + } else { + const uploadResult = await fileManager.uploadFile(pdfBuffer, { + mimeType: 'application/pdf', + displayName: fileName || 'floor-plan.pdf', + }) + uploadedFileName = uploadResult.file.name + await waitForFileActive(fileManager, uploadedFileName) + pdfPart = { fileData: { fileUri: uploadResult.file.uri, mimeType: uploadResult.file.mimeType } } + } + const fileReadyMs = Date.now() - fileStarted + + const genAI = new GoogleGenerativeAI(apiKey) + const model = genAI.getGenerativeModel({ + model: modelName, + generationConfig: { + responseMimeType: 'application/json', + responseSchema: FLOOR_PLAN_SCHEMA, + temperature: 0.1, + // Gemini 3 고fidelity 경로 — 얇은 외벽선/작은 寸法 텍스트 가독성 확보(미지원 시 무시됨). + mediaResolution: 'MEDIA_RESOLUTION_HIGH', + }, + }) + + const generateStarted = Date.now() + const result = await model.generateContent([pdfPart, { text: prompt }]) + + const raw = result.response.text() + const usageMetadata = result.response.usageMetadata + logger.debug('[pdf-analyzer/gemini] timing(ms)', { fileReady: fileReadyMs, generate: Date.now() - generateStarted }) + logger.debug('[pdf-analyzer/gemini] raw response', raw) + logger.debug('[pdf-analyzer/gemini] usageMetadata', usageMetadata) + + const usage = usageMetadata + ? { + prompt_tokens: usageMetadata.promptTokenCount, + completion_tokens: usageMetadata.candidatesTokenCount, + total_tokens: usageMetadata.totalTokenCount, + } + : undefined + + return { raw, usage, provider: 'gemini' } + } finally { + if (uploadedFileName) { + // temp 파일 삭제는 응답을 지연시키지 않도록 await 하지 않고 분리(fire-and-forget)한다. + fileManager.deleteFile(uploadedFileName).catch((deleteError) => { + logger.warn('[pdf-analyzer/gemini] deleteFile failed', deleteError?.message || deleteError) + }) + } + } +} diff --git a/src/lib/pdf-analyzer/index.js b/src/lib/pdf-analyzer/index.js new file mode 100644 index 00000000..3bdd4cce --- /dev/null +++ b/src/lib/pdf-analyzer/index.js @@ -0,0 +1,15 @@ +import { analyzeFloorPlan as analyzeGemini } from './gemini' +import { analyzeFloorPlan as analyzeOpenRouter } from './openrouter' + +export function getPdfAnalyzerProvider() { + const p = (process.env.PDF_ANALYZER_PROVIDER || 'gemini').toLowerCase() + return p === 'openrouter' ? 'openrouter' : 'gemini' +} + +export async function analyzeFloorPlan(opts) { + const provider = getPdfAnalyzerProvider() + if (provider === 'openrouter') { + return analyzeOpenRouter(opts) + } + return analyzeGemini(opts) +} diff --git a/src/lib/pdf-analyzer/openrouter.js b/src/lib/pdf-analyzer/openrouter.js new file mode 100644 index 00000000..51defb47 --- /dev/null +++ b/src/lib/pdf-analyzer/openrouter.js @@ -0,0 +1,117 @@ +import OpenAI from 'openai' +import { logger } from '@/util/logger' + +// A/B 테스트 기본 모델 — 변경하려면 OPENROUTER_MODEL env 설정. +// PDF 파일 입력을 지원하는 모델(anthropic/claude-fable-5 등)만 사용 가능하다. +const DEFAULT_MODEL = 'anthropic/claude-fable-5' + +// gemini.js 의 FLOOR_PLAN_SCHEMA(SchemaType)와 동일 구조의 표준 JSON Schema — A/B 조건 대등 유지. +const FLOOR_PLAN_JSON_SCHEMA = { + type: 'object', + properties: { + outerline: { + type: 'array', + items: { + type: 'object', + properties: { + x: { type: 'number' }, + y: { type: 'number' }, + }, + required: ['x', 'y'], + }, + }, + unit: { type: 'string' }, + scale: { type: 'number' }, + confidence: { type: 'number' }, + notes: { type: 'string' }, + selectedDrawingType: { type: 'string' }, + roofShape: { + type: 'object', + properties: { + type: { type: 'string' }, + ridge: { type: 'string' }, + flows: { type: 'array', items: { type: 'string' } }, + confidence: { type: 'number' }, + }, + required: ['type', 'ridge', 'flows'], + }, + }, + required: ['outerline', 'unit'], +} + +export async function analyzeFloorPlan({ pdfBuffer, fileName, prompt }) { + const apiKey = process.env.OPENROUTER_API_KEY + const model = process.env.OPENROUTER_MODEL || DEFAULT_MODEL + if (!apiKey) { + throw new Error('OPENROUTER_API_KEY 가 설정되지 않았습니다.') + } + + const client = new OpenAI({ + apiKey, + baseURL: 'https://openrouter.ai/api/v1', + defaultHeaders: { + 'HTTP-Referer': 'https://hanasys.jp', + 'X-Title': 'qcast', + }, + timeout: 120000, + }) + + const dataUrl = `data:application/pdf;base64,${pdfBuffer.toString('base64')}` + + const generateStarted = Date.now() + const completion = await client.chat.completions.create({ + model, + temperature: 0.1, + // Gemini responseSchema 와 대등한 스키마 강제 — OpenRouter 가 provider 별 변환을 관리한다. + response_format: { + type: 'json_schema', + json_schema: { name: 'floor_plan', strict: true, schema: FLOOR_PLAN_JSON_SCHEMA }, + }, + // PDF 파싱 엔진을 native 로 명시 고정 — 모델 교체 시 OCR(mistral-ocr 등)로 조용히 떨어지는 것 방지. + plugins: [{ id: 'file-parser', pdf: { engine: 'native' } }], + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: prompt }, + { + type: 'file', + file: { filename: fileName || 'floor-plan.pdf', file_data: dataUrl }, + }, + ], + }, + ], + }) + + const elapsed = Date.now() - generateStarted + + const choice = completion?.choices?.[0] + if (choice?.error) { + throw new Error(`OpenRouter provider error: ${choice.error.message}`) + } + + const content = choice?.message?.content + const raw = typeof content === 'string' + ? content + : Array.isArray(content) + ? content.map((p) => p?.text ?? '').join('') + : null + if (typeof raw !== 'string') { + logger.error('[pdf-analyzer/openrouter] no content', completion) + throw new Error('OpenRouter 응답에 content 가 없습니다.') + } + + const usage = completion?.usage + ? { + prompt_tokens: completion.usage.prompt_tokens, + completion_tokens: completion.usage.completion_tokens, + total_tokens: completion.usage.total_tokens, + } + : undefined + + logger.debug('[pdf-analyzer/openrouter] timing(ms)', { generate: elapsed }) + logger.debug('[pdf-analyzer/openrouter] raw response', raw) + logger.debug('[pdf-analyzer/openrouter] usage', completion?.usage) + + return { raw, usage, provider: 'openrouter' } +} From 22d690655b8f3e3e599d052c122b9b9a6a48e459 Mon Sep 17 00:00:00 2001 From: nalpari Date: Thu, 2 Jul 2026 11:16:01 +0900 Subject: [PATCH 3/4] =?UTF-8?q?docs(claude):=20PDF=20=EB=B6=84=EC=84=9D=20?= =?UTF-8?q?provider=20=ED=86=A0=EA=B8=80=20=EA=B5=AC=EC=A1=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [작업내용] : - PDF 도면 임포트 섹션에 src/lib/pdf-analyzer provider 모듈(PDF_ANALYZER_PROVIDER 토글, openrouter 경로의 json_schema/native 고정/inline base64 제약) 기술 Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index d8a4705d..b3297c9e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,7 @@ A modal under `components/floor-plan/modal/` pairs with a hook under `h **Plan persistence** (serialize/restore) is in `src/lib/canvas.js` and `src/util/canvas-util.js`. Recent commits (`a5e794b3`, `2aef32e0`) added a three-layer guard (entry / cache / save) and `SizeSetting` input guards against 0-area apertures/shadows/dormers — when touching plan load/save or size inputs, do not weaken or remove these guards without understanding the failure mode (`InvalidStateError` from corrupted plans). -**PDF 도면 임포트** — 배치면 초기설정 모달의 PDF 입력 모드 → `src/app/api/gemini/floor-plan/route.js`(Gemini 분석, 세션 인증 + 사용자별 분당 5회 레이트리밋) → `src/hooks/pdf-import/usePdfImport.js`. **분석 직후 외벽선은 항상 미확정으로만 그린다**(`PlacementShapeSetting.handleSaveBtn` → `applyOuterline(mmPoints, { autoFix:false })` — `useLine.addLine` 경유 outerLine 생성(attributes/planeSize/fontSize) + `outerLinePoint` 정점 circle, `outerLineFix=false`). 그 후 `handleSaveBtn` 은 `setSelectedMenu('outline')` + `setCurrentMenu(MENU.ROOF_COVERING.AUTO_OUTERLINE_FIX)` 단일 전환만 한다 — `MenuDepth01` 이 `currentMenu` 변화를 감지해 `useMenu.handleMenu` 의 `AUTO_OUTERLINE_FIX` case 로 **'외벽선 자동 확정' 팝업(`AutoOuterLineFixSetting`)** 을 자동으로 띄운다. **확정 여부 분기는 이 팝업의 스위치(`autoOuterLineFixState`, 기본 ON) + [확인]** 에서 한다(`handleConfirm`): **ON** — 외벽선/정점/points 는 이미 `applyOuterline` 이 그려둔 상태라 `canvas.outerLineFix=true` + `setOuterLineFix(true)` 플래그만 켜면 수동 확정(`useOuterLineWall.handleFix`) 직후와 동일해진다(WALL/ROOF 폴리곤은 지붕형상 설정 적용 시점에 생성). 미확정으로 저장된 직전 상태를 덮어쓰도록 `saveCanvas` 한 뒤 `setCurrentMenu(ROOF_SHAPE_SETTINGS)`. **OFF** — 미확정 유지 + `setCurrentMenu(EXTERIOR_WALL_LINE)` 로 외벽선 확정 팝업(`WallLineSetting`)으로 보낸다. **팝업 전환은 `currentMenu` 메커니즘으로만** — 직접 `addPopup` 하지 말 것(MenuDepth01 의 `closeAll` 과 충돌). 신규 메뉴 `AUTO_OUTERLINE_FIX` 는 `subMenusState` 에 노출하지 않는 일회성 팝업이다. ⚠️ `basicSettingSave({ skipSideEffects:true })` 는 메뉴를 건드리지 않아야 한다 — `setMenuByRoofSize` 가 `if (!skipSideEffects)` 밖에 있으면 async 저장(`await post`) 직후 `currentMenu` 를 `EXTERIOR_WALL_LINE` 로 덮어써, AUTO_OUTERLINE_FIX 팝업 대신 WallLineSetting 으로 되돌아간다(2026-06-21 버그). **좌표에 축소 scale 을 곱하지 말 것** — 'QLine 길이×10 = 표시 mm' 불변식이 깨진다. 화면맞춤은 `viewportTransform` 줌 + `canvasZoomState` 동기화로만 처리한다. **지붕형상 자동 선택 + 설정완료 자동 적용** — Gemini 응답은 `roofShape`(RoofShapeDescriptor `{type,ridge,flows,confidence?}`, route.js 가 `normalizeRoofShape` 로 어휘 정규화)를 추가로 반환한다. `analyzePdfAndApply` 가 이를 `pdfRoofShapeDescriptorState`(outerLineAtom)에 저장(분석 시작 시 null 리셋)하고, ROOF_SHAPE_SETTINGS 진입 시 `useRoofShapeSetting` 마운트 effect 가 `matchRoofShapePattern`(`@/common/roofShapePattern`, flows 집합+type/ridge guard)으로 매칭해 **버튼을 `setShapeNum` 으로 선택**하고, `shapeNum` 이 matched 로 커밋된 뒤 별도 effect(`autoSaveShapeNumRef` 가드, deps `[shapeNum]`)가 **`handleSave`(설정완료)를 자동 클릭**해 지붕형상까지 적용한다(2026-06-25 사용자 지시로 'pre-select만 → 설정완료 자동 적용'으로 전환). ⚠️ **마운트 effect 에서 `setShapeNum(matched)` 직후 같은 틱에 `handleSave` 를 호출하지 말 것** — `handleSave` 는 `shapeNum` 을 클로저로 읽어 stale(기본값 1) 로 잘못된 패턴이 적용된다. 반드시 `[shapeNum]` effect 로 커밋 후 실행. consume-once 로 atom 리셋(비-PDF 재진입 stale 방지). 매처 null(복합형)·roofShape 누락은 자동선택/적용 없이 graceful(수동 선택). 매칭 결과는 콘솔에 `✅ N번 버튼 「…」 매칭 → 자동 선택 + 설정완료` / `❌ 매칭되는 버튼 없음`(NEXT_PUBLIC_ENABLE_LOGGING=true)으로 명시. ⚠️ Gemini 의 실제 `roofShape` 출력 품질(특히 片流れ flows 방향)은 런타임 수동 QA·프롬프트 튜닝 영역. +**PDF 도면 임포트** — 배치면 초기설정 모달의 PDF 입력 모드 → `src/app/api/gemini/floor-plan/route.js`(세션 인증 + 사용자별 분당 5회 레이트리밋) → `src/hooks/pdf-import/usePdfImport.js`. 분석 호출은 `src/lib/pdf-analyzer/` provider 모듈이 담당 — env `PDF_ANALYZER_PROVIDER`(gemini|openrouter, 기본 gemini)로 서버측 선택, 클라이언트 무변경. gemini.js 는 Files-API fallback + `MEDIA_RESOLUTION_HIGH`, openrouter.js 는 openai SDK(baseURL=openrouter.ai) + `OPENROUTER_MODEL`(기본 anthropic/claude-fable-5) + `response_format: json_schema(strict)` + `plugins: file-parser/native` 명시 고정(OCR 엔진으로의 조용한 전환 방지). OpenRouter 경로는 Files-API 대체가 없어 항상 inline base64(대용량은 provider 에러로 표면화). **분석 직후 외벽선은 항상 미확정으로만 그린다**(`PlacementShapeSetting.handleSaveBtn` → `applyOuterline(mmPoints, { autoFix:false })` — `useLine.addLine` 경유 outerLine 생성(attributes/planeSize/fontSize) + `outerLinePoint` 정점 circle, `outerLineFix=false`). 그 후 `handleSaveBtn` 은 `setSelectedMenu('outline')` + `setCurrentMenu(MENU.ROOF_COVERING.AUTO_OUTERLINE_FIX)` 단일 전환만 한다 — `MenuDepth01` 이 `currentMenu` 변화를 감지해 `useMenu.handleMenu` 의 `AUTO_OUTERLINE_FIX` case 로 **'외벽선 자동 확정' 팝업(`AutoOuterLineFixSetting`)** 을 자동으로 띄운다. **확정 여부 분기는 이 팝업의 스위치(`autoOuterLineFixState`, 기본 ON) + [확인]** 에서 한다(`handleConfirm`): **ON** — 외벽선/정점/points 는 이미 `applyOuterline` 이 그려둔 상태라 `canvas.outerLineFix=true` + `setOuterLineFix(true)` 플래그만 켜면 수동 확정(`useOuterLineWall.handleFix`) 직후와 동일해진다(WALL/ROOF 폴리곤은 지붕형상 설정 적용 시점에 생성). 미확정으로 저장된 직전 상태를 덮어쓰도록 `saveCanvas` 한 뒤 `setCurrentMenu(ROOF_SHAPE_SETTINGS)`. **OFF** — 미확정 유지 + `setCurrentMenu(EXTERIOR_WALL_LINE)` 로 외벽선 확정 팝업(`WallLineSetting`)으로 보낸다. **팝업 전환은 `currentMenu` 메커니즘으로만** — 직접 `addPopup` 하지 말 것(MenuDepth01 의 `closeAll` 과 충돌). 신규 메뉴 `AUTO_OUTERLINE_FIX` 는 `subMenusState` 에 노출하지 않는 일회성 팝업이다. ⚠️ `basicSettingSave({ skipSideEffects:true })` 는 메뉴를 건드리지 않아야 한다 — `setMenuByRoofSize` 가 `if (!skipSideEffects)` 밖에 있으면 async 저장(`await post`) 직후 `currentMenu` 를 `EXTERIOR_WALL_LINE` 로 덮어써, AUTO_OUTERLINE_FIX 팝업 대신 WallLineSetting 으로 되돌아간다(2026-06-21 버그). **좌표에 축소 scale 을 곱하지 말 것** — 'QLine 길이×10 = 표시 mm' 불변식이 깨진다. 화면맞춤은 `viewportTransform` 줌 + `canvasZoomState` 동기화로만 처리한다. **지붕형상 자동 선택 + 설정완료 자동 적용** — Gemini 응답은 `roofShape`(RoofShapeDescriptor `{type,ridge,flows,confidence?}`, route.js 가 `normalizeRoofShape` 로 어휘 정규화)를 추가로 반환한다. `analyzePdfAndApply` 가 이를 `pdfRoofShapeDescriptorState`(outerLineAtom)에 저장(분석 시작 시 null 리셋)하고, ROOF_SHAPE_SETTINGS 진입 시 `useRoofShapeSetting` 마운트 effect 가 `matchRoofShapePattern`(`@/common/roofShapePattern`, flows 집합+type/ridge guard)으로 매칭해 **버튼을 `setShapeNum` 으로 선택**하고, `shapeNum` 이 matched 로 커밋된 뒤 별도 effect(`autoSaveShapeNumRef` 가드, deps `[shapeNum]`)가 **`handleSave`(설정완료)를 자동 클릭**해 지붕형상까지 적용한다(2026-06-25 사용자 지시로 'pre-select만 → 설정완료 자동 적용'으로 전환). ⚠️ **마운트 effect 에서 `setShapeNum(matched)` 직후 같은 틱에 `handleSave` 를 호출하지 말 것** — `handleSave` 는 `shapeNum` 을 클로저로 읽어 stale(기본값 1) 로 잘못된 패턴이 적용된다. 반드시 `[shapeNum]` effect 로 커밋 후 실행. consume-once 로 atom 리셋(비-PDF 재진입 stale 방지). 매처 null(복합형)·roofShape 누락은 자동선택/적용 없이 graceful(수동 선택). 매칭 결과는 콘솔에 `✅ N번 버튼 「…」 매칭 → 자동 선택 + 설정완료` / `❌ 매칭되는 버튼 없음`(NEXT_PUBLIC_ENABLE_LOGGING=true)으로 명시. ⚠️ Gemini 의 실제 `roofShape` 출력 품질(특히 片流れ flows 방향)은 런타임 수동 QA·프롬프트 튜닝 영역. ### State — Recoil, split by domain From 584c0a3238bf24673a64507df4349404b310ec7b Mon Sep 17 00:00:00 2001 From: nalpari Date: Thu, 2 Jul 2026 15:02:17 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix(floor-plan):=20PDF=20=EC=99=B8=EA=B3=BD?= =?UTF-8?q?=EC=84=A0=20=EC=B6=94=EC=B6=9C=20=EA=B8=B0=EC=A4=80=EC=9D=84=20?= =?UTF-8?q?=EC=A7=80=EB=B6=95=20=ED=88=AC=EC=98=81=EB=A9=B4=20=E2=86=92=20?= =?UTF-8?q?1=EC=B8=B5=20=EC=99=B8=EB=B2=BD=20=EC=99=B8=EC=A3=BC(=E5=A3=81?= =?UTF-8?q?=E8=8A=AF)=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [작업내용] : - 캔버스 '외벽선'은 벽면인데 프롬프트가 처마(軒の出) 포함 지붕 투영면을 추출해 평면도 치수보다 사방 처마 출폭만큼 길게 표시되던 문제 수정 (2026-05-29 지붕투영면 결정을 사용자 지시로 번복) - BASE_PROMPT: 平面図(1F) 외벽 외주를 치수선(壁芯) 기준으로 추출, 처마·케라바 가산 금지, 屋根伏図는 지붕형상 판별·처마 출폭 확인용(평면도 부재 시 軒の出 감산 fallback), 포치·발코니 제외 - buildPrompt 페이지 힌트 문구도 외벽 외곽선 기준으로 정합 - 실호출 검증(박공지붕_1.pdf): 양 provider(fable-5/gemini-3.1) 정점 단위 동일 10점 폴리곤, 면적 72.0㎡ = 求積図 구획 합계 일치, 처마 가산 소멸 Co-Authored-By: Claude Fable 5 --- src/app/api/gemini/floor-plan/route.js | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/app/api/gemini/floor-plan/route.js b/src/app/api/gemini/floor-plan/route.js index ee36c9b8..d90d2312 100644 --- a/src/app/api/gemini/floor-plan/route.js +++ b/src/app/api/gemini/floor-plan/route.js @@ -28,15 +28,16 @@ const isRateLimited = (key) => { return false } -const BASE_PROMPT = `당신은 일본 주택 건축 도면에서 "지붕 투영면(屋根伏図 footprint)" 외곽선을 추출하는 전문가입니다. -첨부된 PDF 에서 지붕을 위에서 내려다본 외곽(처마 끝선 포함) 폴리곤 좌표를 추출하여 JSON 으로만 응답하세요. +const BASE_PROMPT = `당신은 일본 주택 건축 도면에서 1층 외벽(外壁) 외곽선을 추출하는 전문가입니다. +첨부된 PDF 에서 건물을 위에서 내려다본 1층 외벽 외주 폴리곤 좌표를 추출하여 JSON 으로만 응답하세요. [도면 종류 식별 — 매우 중요] -- 추출 대상: 屋根伏図(지붕평면도)가 있으면 그 지붕 외곽선(처마/軒先·케라바/ケラバ 끝선 포함)을 사용합니다. -- 屋根伏図 가 없으면 平面図(1층/1階 평면도)의 외벽 외주에 처마 출폭(軒の出)을 더한 지붕 투영 외곽을 추정합니다. +- 추출 대상: 平面図(1층/1階 평면도)의 외벽 외주 폴리곤입니다. 치수선(寸法線)에 적힌 숫자(통상 壁芯 기준)와 일치하도록 좌표를 구성하세요. +- ⚠️ 처마(軒の出/軒先)·케라바(ケラバ) 등 지붕 돌출분을 절대 더하지 마세요. 지붕 투영면이 아니라 벽면 외곽을 추출합니다. +- 平面図 가 없고 屋根伏図(지붕평면도)만 있으면, 지붕 외곽의 각 변에서 도면에 적힌 처마 출폭(軒の出)을 빼서 외벽 외주를 추정합니다. - 절대 사용 금지: 立面図(입면도)·配置図(배치도)·求積図(면적도)·断面図(단면도). 이 도면들에서는 윤곽을 추출하지 마세요. -- 도머(ドーマー)·천창·개구부(開口部) 등 지붕 위 돌출/구멍은 외곽선에 포함하지 마세요(외곽 윤곽이 아닙니다). -- 평면도가 여러 층이면 1층(1F) 기준으로 지붕 투영을 판단합니다. +- 현관 포치·バルコニー 등 외벽 몸체 바깥의 부속 돌출과 도머(ドーマー)·천창 등 지붕 위 요소는 외곽선에 포함하지 마세요. +- 평면도가 여러 층이면 1층(1F) 외벽을 기준으로 합니다. [축척·치수 결정 — 우선순위] 1. 도면에 적힌 寸法線(치수선)의 실제 mm 숫자를 최우선 근거로 좌표를 환산합니다. @@ -52,7 +53,7 @@ const BASE_PROMPT = `당신은 일본 주택 건축 도면에서 "지붕 투영 4. 자기교차가 없는 단순 폴리곤이어야 하며, 정점은 3개 이상이어야 합니다. 5. L자·凹凸(요철) 평면의 모든 꺾임을 정점으로 보존하고, 직각(直角)에 가까운 코너는 직각으로 간주합니다(비스듬히 근사 금지). 6. 분리된 복수 동(棟)이 있으면 가장 큰 주 건물 하나만 반환하고, 별동은 notes 에 기재합니다. -7. 지붕 투영을 추출할 수 없으면 outerline 을 빈 배열로 반환하고 notes 에 사유를 적습니다. +7. 외벽 외주를 추출할 수 없으면 outerline 을 빈 배열로 반환하고 notes 에 사유를 적습니다. [지붕형상 판독 — 屋根伏図 棟·軒先 + 立面図 보조] - 屋根伏図의 棟(용마루) 위치·개수와 軒先(처마)가 흘러내리는 방향으로 지붕 유형을 판별합니다. @@ -90,13 +91,13 @@ const buildPrompt = ({ pageMode, facadePages, floorPages, splitInfo } = {}) => { if (splitInfo.floorCount > 0) segs.push(`평면도/지붕평면도 ${splitInfo.floorCount}장`) if (splitInfo.facadeCount > 0) segs.push(`입면도 ${splitInfo.facadeCount}장`) const compo = `첨부 PDF 는 ${segs.join('과 ')}으로 구성됩니다(평면도가 앞).` - return `${BASE_PROMPT}\n\n${compo} 외곽선은 평면도/지붕평면도 페이지에서만 추출하세요. 입면도 페이지는 외곽선 추출에 쓰지 말고, 높이·층수와 지붕 棟방향·경사 방향(지붕형상 판별) 참고로만 사용하세요.` + return `${BASE_PROMPT}\n\n${compo} 외벽 외곽선은 평면도 페이지에서 추출하세요(지붕평면도 페이지는 지붕형상 판별·처마 출폭 확인용). 입면도 페이지는 외곽선 추출에 쓰지 말고, 높이·층수와 지붕 棟방향·경사 방향(지붕형상 판별) 참고로만 사용하세요.` } if (pageMode === 'specify' && (facadePages || floorPages)) { const hints = [] if (floorPages) hints.push(`평면도(平面図)·지붕평면도(屋根伏図) 페이지: ${floorPages}`) if (facadePages) hints.push(`입면도(立面図) 페이지: ${facadePages}`) - return `${BASE_PROMPT}\n\n페이지 힌트: ${hints.join(' / ')}. 입면도 페이지는 외곽선 추출에 쓰지 말고 높이·층수와 지붕 棟방향·경사 방향(지붕형상 판별) 참고로만 사용하세요. 지붕 투영 외곽선은 지정된 평면도/지붕평면도 페이지 기준으로 추출하세요.` + return `${BASE_PROMPT}\n\n페이지 힌트: ${hints.join(' / ')}. 입면도 페이지는 외곽선 추출에 쓰지 말고 높이·층수와 지붕 棟방향·경사 방향(지붕형상 판별) 참고로만 사용하세요. 외벽 외곽선은 지정된 평면도 페이지 기준으로 추출하세요(지붕평면도 페이지는 지붕형상 판별·처마 출폭 확인용).` } return BASE_PROMPT }