nalpari 8c650b2010 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>
2026-07-02 11:15:53 +09:00

118 lines
3.6 KiB
JavaScript

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