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(값 채우기 전까지 동작 불변) # [openrouter 스캐폴드] PDF 도면 판독 provider 토글 — 기본 gemini(값 채우기 전까지 동작 불변)
PDF_ANALYZER_PROVIDER=openrouter PDF_ANALYZER_PROVIDER=openrouter
OPENROUTER_API_KEY=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 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(값 채우기 전까지 동작 불변) # [openrouter 스캐폴드] PDF 도면 판독 provider 토글 — 기본 gemini(값 채우기 전까지 동작 불변)
PDF_ANALYZER_PROVIDER=openrouter PDF_ANALYZER_PROVIDER=openrouter
OPENROUTER_API_KEY=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 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(값 채우기 전까지 동작 불변) # [openrouter 스캐폴드] PDF 도면 판독 provider 토글 — 기본 gemini(값 채우기 전까지 동작 불변)
PDF_ANALYZER_PROVIDER=openrouter PDF_ANALYZER_PROVIDER=openrouter
OPENROUTER_API_KEY=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 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(값 채우기 전까지 동작 불변) # [openrouter 스캐폴드] PDF 도면 판독 provider 토글 — 기본 gemini(값 채우기 전까지 동작 불변)
PDF_ANALYZER_PROVIDER=openrouter PDF_ANALYZER_PROVIDER=openrouter
OPENROUTER_API_KEY=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 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 { NextResponse } from 'next/server'
import { cookies } from 'next/headers' import { cookies } from 'next/headers'
import { getIronSession } from 'iron-session' 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 { 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' import { normalizeRoofShape } from '@/common/roofShapePattern'
import { analyzeFloorPlan, getPdfAnalyzerProvider } from '@/lib/pdf-analyzer'
const MAX_FILE_BYTES = 20 * 1024 * 1024 // 20MB const MAX_FILE_BYTES = 20 * 1024 * 1024 // 20MB
const MAX_VERTICES = 500 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). // Gemini 호출은 건당 과금이므로 사용자별 호출 빈도를 제한한다(프로세스 단위 in-memory).
const RATE_LIMIT_WINDOW_MS = 60 * 1000 const RATE_LIMIT_WINDOW_MS = 60 * 1000
@ -33,39 +28,6 @@ const isRateLimited = (key) => {
return false 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)" 외곽선을 추출하는 전문가입니다. const BASE_PROMPT = `당신은 일본 주택 건축 도면에서 "지붕 투영면(屋根伏図 footprint)" 외곽선을 추출하는 전문가입니다.
첨부된 PDF 에서 지붕을 위에서 내려다본 외곽(처마 끝선 포함) 폴리곤 좌표를 추출하여 JSON 으로만 응답하세요. 첨부된 PDF 에서 지붕을 위에서 내려다본 외곽(처마 끝선 포함) 폴리곤 좌표를 추출하여 JSON 으로만 응답하세요.
@ -201,27 +163,11 @@ const toValidIndices = (pages, total) => {
return [...set].sort((a, b) => a - b) 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) { export async function POST(req) {
const apiKey = process.env.GEMINI_API_KEY const provider = getPdfAnalyzerProvider()
const modelName = process.env.GEMINI_MODEL || DEFAULT_MODEL 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) const session = await getIronSession(cookies(), sessionOptions)
if (!session?.isLoggedIn) { if (!session?.isLoggedIn) {
return NextResponse.json({ error: { code: 'UNAUTHORIZED', message: '로그인이 필요합니다.' } }, { status: 401 }) return NextResponse.json({ error: { code: 'UNAUTHORIZED', message: '로그인이 필요합니다.' } }, { status: 401 })
@ -231,12 +177,10 @@ export async function POST(req) {
} }
if (!apiKey) { 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 { try {
const formData = await req.formData() const formData = await req.formData()
const file = formData.get('file') const file = formData.get('file')
@ -293,43 +237,16 @@ export async function POST(req) {
splitInfo = { floorCount: floorIdx.length, facadeCount: facadeIdx.length } splitInfo = { floorCount: floorIdx.length, facadeCount: facadeIdx.length }
} }
// 분할본은 대부분 인라인 한도 이하 → 업로드·폴링 없이 즉시 전송. 초과 시에만 Files API 로 fallback. // provider(gemini|openrouter) 가 내부적으로 인라인/업로드/폴링을 처리한다.
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',
},
})
const prompt = buildPrompt({ pageMode, facadePages, floorPages, splitInfo }) const prompt = buildPrompt({ pageMode, facadePages, floorPages, splitInfo })
const generateStarted = Date.now() const analyzeStarted = Date.now()
const result = await model.generateContent([pdfPart, { text: prompt }]) const { raw, usage, provider: usedProvider } = await analyzeFloorPlan({
pdfBuffer: sendBuffer,
const raw = result.response.text() fileName: file.name,
logger.debug('[gemini/floor-plan] timing(ms)', { fileReady: fileReadyMs, generate: Date.now() - generateStarted }) prompt,
logger.debug('[gemini/floor-plan] raw response', raw) })
logger.debug('[gemini/floor-plan] usageMetadata', result.response.usageMetadata) logger.debug('[floor-plan] analyze done', { provider: usedProvider, generate: Date.now() - analyzeStarted, usage })
let parsed let parsed
try { try {
@ -344,8 +261,8 @@ export async function POST(req) {
} }
const roofShape = normalizeRoofShape(parsed.roofShape) const roofShape = normalizeRoofShape(parsed.roofShape)
// 수동 QA 용 — Gemini 가 판독한 지붕형상 원본과 정규화 결과를 함께 남긴다(NEXT_PUBLIC_ENABLE_LOGGING=true 시). // 수동 QA 용 — provider 가 판독한 지붕형상 원본과 정규화 결과를 함께 남긴다(NEXT_PUBLIC_ENABLE_LOGGING=true 시).
logger.debug('[gemini/floor-plan] roofShape 판독', { raw: parsed.roofShape ?? null, normalized: roofShape }) logger.debug('[floor-plan] roofShape 판독', { raw: parsed.roofShape ?? null, normalized: roofShape })
return NextResponse.json({ return NextResponse.json({
outerline: parsed.outerline, outerline: parsed.outerline,
@ -358,15 +275,7 @@ export async function POST(req) {
empty: validation.empty, empty: validation.empty,
}) })
} catch (error) { } catch (error) {
logger.error('[gemini/floor-plan] error', error?.message || error) logger.error('[floor-plan] error', error?.message || error)
return NextResponse.json({ error: { code: 'GEMINI_FAILED', message: error?.message || 'Gemini 호출에 실패했습니다.' } }, { status: 500 }) return NextResponse.json({ error: { code: 'ANALYZE_FAILED', message: error?.message || 'PDF 분석에 실패했습니다.' } }, { 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)
})
}
} }
} }

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