feat(pdf-analyzer): PDF 도면 분석 OpenRouter provider 토글 구현

[작업내용] :
- 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 <noreply@anthropic.com>
This commit is contained in:
nalpari 2026-07-02 11:15:53 +09:00
parent fe0eca3f4d
commit 8c650b2010
8 changed files with 277 additions and 113 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 })
}
}

View File

@ -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)
})
}
}
}

View File

@ -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)
}

View File

@ -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' }
}