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:
parent
fe0eca3f4d
commit
8c650b2010
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
123
src/lib/pdf-analyzer/gemini.js
Normal file
123
src/lib/pdf-analyzer/gemini.js
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
15
src/lib/pdf-analyzer/index.js
Normal file
15
src/lib/pdf-analyzer/index.js
Normal 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)
|
||||
}
|
||||
117
src/lib/pdf-analyzer/openrouter.js
Normal file
117
src/lib/pdf-analyzer/openrouter.js
Normal 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' }
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user