From 8c650b201007ed4a4856112838f578195d0c6d5c Mon Sep 17 00:00:00 2001 From: nalpari Date: Thu, 2 Jul 2026 11:15:53 +0900 Subject: [PATCH] =?UTF-8?q?feat(pdf-analyzer):=20PDF=20=EB=8F=84=EB=A9=B4?= =?UTF-8?q?=20=EB=B6=84=EC=84=9D=20OpenRouter=20provider=20=ED=86=A0?= =?UTF-8?q?=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' } +}