Merge branch 'openrouter-feature' into feature/pv-btob
This commit is contained in:
commit
0a7d174a4a
@ -38,3 +38,7 @@ NEXT_PUBLIC_ENABLE_LOGGING=true
|
|||||||
#Gemini API Key
|
#Gemini API Key
|
||||||
GEMINI_API_KEY="AIzaSyDDmMkTkNt12QTj7I3SUygYkOoeG_Sc28Q"
|
GEMINI_API_KEY="AIzaSyDDmMkTkNt12QTj7I3SUygYkOoeG_Sc28Q"
|
||||||
GEMINI_MODEL="gemini-3.1-pro-preview"
|
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-fable-5
|
||||||
|
|||||||
@ -34,3 +34,7 @@ S3_PROFILE="dev"
|
|||||||
#Gemini API Key
|
#Gemini API Key
|
||||||
GEMINI_API_KEY="AIzaSyDDmMkTkNt12QTj7I3SUygYkOoeG_Sc28Q"
|
GEMINI_API_KEY="AIzaSyDDmMkTkNt12QTj7I3SUygYkOoeG_Sc28Q"
|
||||||
GEMINI_MODEL="gemini-3.1-pro-preview"
|
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-fable-5
|
||||||
|
|||||||
@ -39,3 +39,8 @@ NEXT_PUBLIC_ENABLE_LOGGING=true
|
|||||||
#Gemini API Key
|
#Gemini API Key
|
||||||
GEMINI_API_KEY="AIzaSyDDmMkTkNt12QTj7I3SUygYkOoeG_Sc28Q"
|
GEMINI_API_KEY="AIzaSyDDmMkTkNt12QTj7I3SUygYkOoeG_Sc28Q"
|
||||||
GEMINI_MODEL="gemini-3.1-pro-preview"
|
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-fable-5
|
||||||
|
|||||||
@ -37,3 +37,7 @@ NEXT_PUBLIC_ENABLE_LOGGING=false
|
|||||||
#Gemini API Key
|
#Gemini API Key
|
||||||
GEMINI_API_KEY="AIzaSyDDmMkTkNt12QTj7I3SUygYkOoeG_Sc28Q"
|
GEMINI_API_KEY="AIzaSyDDmMkTkNt12QTj7I3SUygYkOoeG_Sc28Q"
|
||||||
GEMINI_MODEL="gemini-3.1-pro-preview"
|
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-fable-5
|
||||||
|
|||||||
@ -56,7 +56,7 @@ A modal under `components/floor-plan/modal/<feature>` pairs with a hook under `h
|
|||||||
|
|
||||||
**Plan persistence** (serialize/restore) is in `src/lib/canvas.js` and `src/util/canvas-util.js`. Recent commits (`a5e794b3`, `2aef32e0`) added a three-layer guard (entry / cache / save) and `SizeSetting` input guards against 0-area apertures/shadows/dormers — when touching plan load/save or size inputs, do not weaken or remove these guards without understanding the failure mode (`InvalidStateError` from corrupted plans).
|
**Plan persistence** (serialize/restore) is in `src/lib/canvas.js` and `src/util/canvas-util.js`. Recent commits (`a5e794b3`, `2aef32e0`) added a three-layer guard (entry / cache / save) and `SizeSetting` input guards against 0-area apertures/shadows/dormers — when touching plan load/save or size inputs, do not weaken or remove these guards without understanding the failure mode (`InvalidStateError` from corrupted plans).
|
||||||
|
|
||||||
**PDF 도면 임포트** — 배치면 초기설정 모달의 PDF 입력 모드 → `src/app/api/gemini/floor-plan/route.js`(Gemini 분석, 세션 인증 + 사용자별 분당 5회 레이트리밋) → `src/hooks/pdf-import/usePdfImport.js`. **분석 직후 외벽선은 항상 미확정으로만 그린다**(`PlacementShapeSetting.handleSaveBtn` → `applyOuterline(mmPoints, { autoFix:false })` — `useLine.addLine` 경유 outerLine 생성(attributes/planeSize/fontSize) + `outerLinePoint` 정점 circle, `outerLineFix=false`). 그 후 `handleSaveBtn` 은 `setSelectedMenu('outline')` + `setCurrentMenu(MENU.ROOF_COVERING.AUTO_OUTERLINE_FIX)` 단일 전환만 한다 — `MenuDepth01` 이 `currentMenu` 변화를 감지해 `useMenu.handleMenu` 의 `AUTO_OUTERLINE_FIX` case 로 **'외벽선 자동 확정' 팝업(`AutoOuterLineFixSetting`)** 을 자동으로 띄운다. **확정 여부 분기는 이 팝업의 스위치(`autoOuterLineFixState`, 기본 ON) + [확인]** 에서 한다(`handleConfirm`): **ON** — 외벽선/정점/points 는 이미 `applyOuterline` 이 그려둔 상태라 `canvas.outerLineFix=true` + `setOuterLineFix(true)` 플래그만 켜면 수동 확정(`useOuterLineWall.handleFix`) 직후와 동일해진다(WALL/ROOF 폴리곤은 지붕형상 설정 적용 시점에 생성). 미확정으로 저장된 직전 상태를 덮어쓰도록 `saveCanvas` 한 뒤 `setCurrentMenu(ROOF_SHAPE_SETTINGS)`. **OFF** — 미확정 유지 + `setCurrentMenu(EXTERIOR_WALL_LINE)` 로 외벽선 확정 팝업(`WallLineSetting`)으로 보낸다. **팝업 전환은 `currentMenu` 메커니즘으로만** — 직접 `addPopup` 하지 말 것(MenuDepth01 의 `closeAll` 과 충돌). 신규 메뉴 `AUTO_OUTERLINE_FIX` 는 `subMenusState` 에 노출하지 않는 일회성 팝업이다. ⚠️ `basicSettingSave({ skipSideEffects:true })` 는 메뉴를 건드리지 않아야 한다 — `setMenuByRoofSize` 가 `if (!skipSideEffects)` 밖에 있으면 async 저장(`await post`) 직후 `currentMenu` 를 `EXTERIOR_WALL_LINE` 로 덮어써, AUTO_OUTERLINE_FIX 팝업 대신 WallLineSetting 으로 되돌아간다(2026-06-21 버그). **좌표에 축소 scale 을 곱하지 말 것** — 'QLine 길이×10 = 표시 mm' 불변식이 깨진다. 화면맞춤은 `viewportTransform` 줌 + `canvasZoomState` 동기화로만 처리한다. **지붕형상 자동 선택 + 설정완료 자동 적용** — Gemini 응답은 `roofShape`(RoofShapeDescriptor `{type,ridge,flows,confidence?}`, route.js 가 `normalizeRoofShape` 로 어휘 정규화)를 추가로 반환한다. `analyzePdfAndApply` 가 이를 `pdfRoofShapeDescriptorState`(outerLineAtom)에 저장(분석 시작 시 null 리셋)하고, ROOF_SHAPE_SETTINGS 진입 시 `useRoofShapeSetting` 마운트 effect 가 `matchRoofShapePattern`(`@/common/roofShapePattern`, flows 집합+type/ridge guard)으로 매칭해 **버튼을 `setShapeNum` 으로 선택**하고, `shapeNum` 이 matched 로 커밋된 뒤 별도 effect(`autoSaveShapeNumRef` 가드, deps `[shapeNum]`)가 **`handleSave`(설정완료)를 자동 클릭**해 지붕형상까지 적용한다(2026-06-25 사용자 지시로 'pre-select만 → 설정완료 자동 적용'으로 전환). ⚠️ **마운트 effect 에서 `setShapeNum(matched)` 직후 같은 틱에 `handleSave` 를 호출하지 말 것** — `handleSave` 는 `shapeNum` 을 클로저로 읽어 stale(기본값 1) 로 잘못된 패턴이 적용된다. 반드시 `[shapeNum]` effect 로 커밋 후 실행. consume-once 로 atom 리셋(비-PDF 재진입 stale 방지). 매처 null(복합형)·roofShape 누락은 자동선택/적용 없이 graceful(수동 선택). 매칭 결과는 콘솔에 `✅ N번 버튼 「…」 매칭 → 자동 선택 + 설정완료` / `❌ 매칭되는 버튼 없음`(NEXT_PUBLIC_ENABLE_LOGGING=true)으로 명시. ⚠️ Gemini 의 실제 `roofShape` 출력 품질(특히 片流れ flows 방향)은 런타임 수동 QA·프롬프트 튜닝 영역.
|
**PDF 도면 임포트** — 배치면 초기설정 모달의 PDF 입력 모드 → `src/app/api/gemini/floor-plan/route.js`(세션 인증 + 사용자별 분당 5회 레이트리밋) → `src/hooks/pdf-import/usePdfImport.js`. 분석 호출은 `src/lib/pdf-analyzer/` provider 모듈이 담당 — env `PDF_ANALYZER_PROVIDER`(gemini|openrouter, 기본 gemini)로 서버측 선택, 클라이언트 무변경. gemini.js 는 Files-API fallback + `MEDIA_RESOLUTION_HIGH`, openrouter.js 는 openai SDK(baseURL=openrouter.ai) + `OPENROUTER_MODEL`(기본 anthropic/claude-fable-5) + `response_format: json_schema(strict)` + `plugins: file-parser/native` 명시 고정(OCR 엔진으로의 조용한 전환 방지). OpenRouter 경로는 Files-API 대체가 없어 항상 inline base64(대용량은 provider 에러로 표면화). **분석 직후 외벽선은 항상 미확정으로만 그린다**(`PlacementShapeSetting.handleSaveBtn` → `applyOuterline(mmPoints, { autoFix:false })` — `useLine.addLine` 경유 outerLine 생성(attributes/planeSize/fontSize) + `outerLinePoint` 정점 circle, `outerLineFix=false`). 그 후 `handleSaveBtn` 은 `setSelectedMenu('outline')` + `setCurrentMenu(MENU.ROOF_COVERING.AUTO_OUTERLINE_FIX)` 단일 전환만 한다 — `MenuDepth01` 이 `currentMenu` 변화를 감지해 `useMenu.handleMenu` 의 `AUTO_OUTERLINE_FIX` case 로 **'외벽선 자동 확정' 팝업(`AutoOuterLineFixSetting`)** 을 자동으로 띄운다. **확정 여부 분기는 이 팝업의 스위치(`autoOuterLineFixState`, 기본 ON) + [확인]** 에서 한다(`handleConfirm`): **ON** — 외벽선/정점/points 는 이미 `applyOuterline` 이 그려둔 상태라 `canvas.outerLineFix=true` + `setOuterLineFix(true)` 플래그만 켜면 수동 확정(`useOuterLineWall.handleFix`) 직후와 동일해진다(WALL/ROOF 폴리곤은 지붕형상 설정 적용 시점에 생성). 미확정으로 저장된 직전 상태를 덮어쓰도록 `saveCanvas` 한 뒤 `setCurrentMenu(ROOF_SHAPE_SETTINGS)`. **OFF** — 미확정 유지 + `setCurrentMenu(EXTERIOR_WALL_LINE)` 로 외벽선 확정 팝업(`WallLineSetting`)으로 보낸다. **팝업 전환은 `currentMenu` 메커니즘으로만** — 직접 `addPopup` 하지 말 것(MenuDepth01 의 `closeAll` 과 충돌). 신규 메뉴 `AUTO_OUTERLINE_FIX` 는 `subMenusState` 에 노출하지 않는 일회성 팝업이다. ⚠️ `basicSettingSave({ skipSideEffects:true })` 는 메뉴를 건드리지 않아야 한다 — `setMenuByRoofSize` 가 `if (!skipSideEffects)` 밖에 있으면 async 저장(`await post`) 직후 `currentMenu` 를 `EXTERIOR_WALL_LINE` 로 덮어써, AUTO_OUTERLINE_FIX 팝업 대신 WallLineSetting 으로 되돌아간다(2026-06-21 버그). **좌표에 축소 scale 을 곱하지 말 것** — 'QLine 길이×10 = 표시 mm' 불변식이 깨진다. 화면맞춤은 `viewportTransform` 줌 + `canvasZoomState` 동기화로만 처리한다. **지붕형상 자동 선택 + 설정완료 자동 적용** — Gemini 응답은 `roofShape`(RoofShapeDescriptor `{type,ridge,flows,confidence?}`, route.js 가 `normalizeRoofShape` 로 어휘 정규화)를 추가로 반환한다. `analyzePdfAndApply` 가 이를 `pdfRoofShapeDescriptorState`(outerLineAtom)에 저장(분석 시작 시 null 리셋)하고, ROOF_SHAPE_SETTINGS 진입 시 `useRoofShapeSetting` 마운트 effect 가 `matchRoofShapePattern`(`@/common/roofShapePattern`, flows 집합+type/ridge guard)으로 매칭해 **버튼을 `setShapeNum` 으로 선택**하고, `shapeNum` 이 matched 로 커밋된 뒤 별도 effect(`autoSaveShapeNumRef` 가드, deps `[shapeNum]`)가 **`handleSave`(설정완료)를 자동 클릭**해 지붕형상까지 적용한다(2026-06-25 사용자 지시로 'pre-select만 → 설정완료 자동 적용'으로 전환). ⚠️ **마운트 effect 에서 `setShapeNum(matched)` 직후 같은 틱에 `handleSave` 를 호출하지 말 것** — `handleSave` 는 `shapeNum` 을 클로저로 읽어 stale(기본값 1) 로 잘못된 패턴이 적용된다. 반드시 `[shapeNum]` effect 로 커밋 후 실행. consume-once 로 atom 리셋(비-PDF 재진입 stale 방지). 매처 null(복합형)·roofShape 누락은 자동선택/적용 없이 graceful(수동 선택). 매칭 결과는 콘솔에 `✅ N번 버튼 「…」 매칭 → 자동 선택 + 설정완료` / `❌ 매칭되는 버튼 없음`(NEXT_PUBLIC_ENABLE_LOGGING=true)으로 명시. ⚠️ Gemini 의 실제 `roofShape` 출력 품질(특히 片流れ flows 방향)은 런타임 수동 QA·프롬프트 튜닝 영역.
|
||||||
|
|
||||||
### State — Recoil, split by domain
|
### State — Recoil, split by domain
|
||||||
|
|
||||||
|
|||||||
@ -37,6 +37,7 @@
|
|||||||
"mssql": "^11.0.1",
|
"mssql": "^11.0.1",
|
||||||
"next": "14.2.28",
|
"next": "14.2.28",
|
||||||
"next-international": "^1.2.4",
|
"next-international": "^1.2.4",
|
||||||
|
"openai": "^6.45.0",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-chartjs-2": "^5.2.0",
|
"react-chartjs-2": "^5.2.0",
|
||||||
|
|||||||
@ -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,48 +28,16 @@ const isRateLimited = (key) => {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const FLOOR_PLAN_SCHEMA = {
|
const BASE_PROMPT = `당신은 일본 주택 건축 도면에서 1층 외벽(外壁) 외곽선을 추출하는 전문가입니다.
|
||||||
type: SchemaType.OBJECT,
|
첨부된 PDF 에서 건물을 위에서 내려다본 1층 외벽 외주 폴리곤 좌표를 추출하여 JSON 으로만 응답하세요.
|
||||||
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 으로만 응답하세요.
|
|
||||||
|
|
||||||
[도면 종류 식별 — 매우 중요]
|
[도면 종류 식별 — 매우 중요]
|
||||||
- 추출 대상: 屋根伏図(지붕평면도)가 있으면 그 지붕 외곽선(처마/軒先·케라바/ケラバ 끝선 포함)을 사용합니다.
|
- 추출 대상: 平面図(1층/1階 평면도)의 외벽 외주 폴리곤입니다. 치수선(寸法線)에 적힌 숫자(통상 壁芯 기준)와 일치하도록 좌표를 구성하세요.
|
||||||
- 屋根伏図 가 없으면 平面図(1층/1階 평면도)의 외벽 외주에 처마 출폭(軒の出)을 더한 지붕 투영 외곽을 추정합니다.
|
- ⚠️ 처마(軒の出/軒先)·케라바(ケラバ) 등 지붕 돌출분을 절대 더하지 마세요. 지붕 투영면이 아니라 벽면 외곽을 추출합니다.
|
||||||
|
- 平面図 가 없고 屋根伏図(지붕평면도)만 있으면, 지붕 외곽의 각 변에서 도면에 적힌 처마 출폭(軒の出)을 빼서 외벽 외주를 추정합니다.
|
||||||
- 절대 사용 금지: 立面図(입면도)·配置図(배치도)·求積図(면적도)·断面図(단면도). 이 도면들에서는 윤곽을 추출하지 마세요.
|
- 절대 사용 금지: 立面図(입면도)·配置図(배치도)·求積図(면적도)·断面図(단면도). 이 도면들에서는 윤곽을 추출하지 마세요.
|
||||||
- 도머(ドーマー)·천창·개구부(開口部) 등 지붕 위 돌출/구멍은 외곽선에 포함하지 마세요(외곽 윤곽이 아닙니다).
|
- 현관 포치·バルコニー 등 외벽 몸체 바깥의 부속 돌출과 도머(ドーマー)·천창 등 지붕 위 요소는 외곽선에 포함하지 마세요.
|
||||||
- 평면도가 여러 층이면 1층(1F) 기준으로 지붕 투영을 판단합니다.
|
- 평면도가 여러 층이면 1층(1F) 외벽을 기준으로 합니다.
|
||||||
|
|
||||||
[축척·치수 결정 — 우선순위]
|
[축척·치수 결정 — 우선순위]
|
||||||
1. 도면에 적힌 寸法線(치수선)의 실제 mm 숫자를 최우선 근거로 좌표를 환산합니다.
|
1. 도면에 적힌 寸法線(치수선)의 실제 mm 숫자를 최우선 근거로 좌표를 환산합니다.
|
||||||
@ -90,7 +53,7 @@ const BASE_PROMPT = `당신은 일본 주택 건축 도면에서 "지붕 투영
|
|||||||
4. 자기교차가 없는 단순 폴리곤이어야 하며, 정점은 3개 이상이어야 합니다.
|
4. 자기교차가 없는 단순 폴리곤이어야 하며, 정점은 3개 이상이어야 합니다.
|
||||||
5. L자·凹凸(요철) 평면의 모든 꺾임을 정점으로 보존하고, 직각(直角)에 가까운 코너는 직각으로 간주합니다(비스듬히 근사 금지).
|
5. L자·凹凸(요철) 평면의 모든 꺾임을 정점으로 보존하고, 직각(直角)에 가까운 코너는 직각으로 간주합니다(비스듬히 근사 금지).
|
||||||
6. 분리된 복수 동(棟)이 있으면 가장 큰 주 건물 하나만 반환하고, 별동은 notes 에 기재합니다.
|
6. 분리된 복수 동(棟)이 있으면 가장 큰 주 건물 하나만 반환하고, 별동은 notes 에 기재합니다.
|
||||||
7. 지붕 투영을 추출할 수 없으면 outerline 을 빈 배열로 반환하고 notes 에 사유를 적습니다.
|
7. 외벽 외주를 추출할 수 없으면 outerline 을 빈 배열로 반환하고 notes 에 사유를 적습니다.
|
||||||
|
|
||||||
[지붕형상 판독 — 屋根伏図 棟·軒先 + 立面図 보조]
|
[지붕형상 판독 — 屋根伏図 棟·軒先 + 立面図 보조]
|
||||||
- 屋根伏図의 棟(용마루) 위치·개수와 軒先(처마)가 흘러내리는 방향으로 지붕 유형을 판별합니다.
|
- 屋根伏図의 棟(용마루) 위치·개수와 軒先(처마)가 흘러내리는 방향으로 지붕 유형을 판별합니다.
|
||||||
@ -128,13 +91,13 @@ const buildPrompt = ({ pageMode, facadePages, floorPages, splitInfo } = {}) => {
|
|||||||
if (splitInfo.floorCount > 0) segs.push(`평면도/지붕평면도 ${splitInfo.floorCount}장`)
|
if (splitInfo.floorCount > 0) segs.push(`평면도/지붕평면도 ${splitInfo.floorCount}장`)
|
||||||
if (splitInfo.facadeCount > 0) segs.push(`입면도 ${splitInfo.facadeCount}장`)
|
if (splitInfo.facadeCount > 0) segs.push(`입면도 ${splitInfo.facadeCount}장`)
|
||||||
const compo = `첨부 PDF 는 ${segs.join('과 ')}으로 구성됩니다(평면도가 앞).`
|
const compo = `첨부 PDF 는 ${segs.join('과 ')}으로 구성됩니다(평면도가 앞).`
|
||||||
return `${BASE_PROMPT}\n\n${compo} 외곽선은 평면도/지붕평면도 페이지에서만 추출하세요. 입면도 페이지는 외곽선 추출에 쓰지 말고, 높이·층수와 지붕 棟방향·경사 방향(지붕형상 판별) 참고로만 사용하세요.`
|
return `${BASE_PROMPT}\n\n${compo} 외벽 외곽선은 평면도 페이지에서 추출하세요(지붕평면도 페이지는 지붕형상 판별·처마 출폭 확인용). 입면도 페이지는 외곽선 추출에 쓰지 말고, 높이·층수와 지붕 棟방향·경사 방향(지붕형상 판별) 참고로만 사용하세요.`
|
||||||
}
|
}
|
||||||
if (pageMode === 'specify' && (facadePages || floorPages)) {
|
if (pageMode === 'specify' && (facadePages || floorPages)) {
|
||||||
const hints = []
|
const hints = []
|
||||||
if (floorPages) hints.push(`평면도(平面図)·지붕평면도(屋根伏図) 페이지: ${floorPages}`)
|
if (floorPages) hints.push(`평면도(平面図)·지붕평면도(屋根伏図) 페이지: ${floorPages}`)
|
||||||
if (facadePages) hints.push(`입면도(立面図) 페이지: ${facadePages}`)
|
if (facadePages) hints.push(`입면도(立面図) 페이지: ${facadePages}`)
|
||||||
return `${BASE_PROMPT}\n\n페이지 힌트: ${hints.join(' / ')}. 입면도 페이지는 외곽선 추출에 쓰지 말고 높이·층수와 지붕 棟방향·경사 방향(지붕형상 판별) 참고로만 사용하세요. 지붕 투영 외곽선은 지정된 평면도/지붕평면도 페이지 기준으로 추출하세요.`
|
return `${BASE_PROMPT}\n\n페이지 힌트: ${hints.join(' / ')}. 입면도 페이지는 외곽선 추출에 쓰지 말고 높이·층수와 지붕 棟방향·경사 방향(지붕형상 판별) 참고로만 사용하세요. 외벽 외곽선은 지정된 평면도 페이지 기준으로 추출하세요(지붕평면도 페이지는 지붕형상 판별·처마 출폭 확인용).`
|
||||||
}
|
}
|
||||||
return BASE_PROMPT
|
return BASE_PROMPT
|
||||||
}
|
}
|
||||||
@ -201,27 +164,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 +178,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 +238,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 +262,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 +276,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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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