[작업내용] :
- 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>
118 lines
3.6 KiB
JavaScript
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' }
|
|
}
|