feat(pdf-import): 지정 페이지만 분할·인라인 전송으로 분석 속도 개선
- '읽기 페이지 설정'의 평면도/입면도 페이지 번호가 프롬프트 힌트로만 쓰이고 PDF 전체가 Gemini 로 전송되던 문제 해결 — pdf-lib 로 지정 페이지만 잘라 전송 - 분할본이 12MB 이하면 inlineData(base64) 즉시 전송 → Files API 업로드·폴링 제거, 초과 시에만 Files API 로 fallback - 분할 시 buildPrompt 를 페이지번호 힌트 대신 평면도/입면도 순서 매핑으로 전환 - 유효 페이지 0개 → 422 INVALID_PAGE_RANGE, 분할 실패 → 422 PDF_SPLIT_FAILED - 설계 문서 추가(docs/superpowers/specs/2026-06-21-pdf-page-split-design.md) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
f46dc788cc
commit
ce295dd8ff
99
docs/superpowers/specs/2026-06-21-pdf-page-split-design.md
Normal file
99
docs/superpowers/specs/2026-06-21-pdf-page-split-design.md
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
# PDF 도면 임포트 — 지정 페이지 분할 + 인라인 전송 설계
|
||||||
|
|
||||||
|
- 날짜: 2026-06-21
|
||||||
|
- 브랜치: feature/pv-btob
|
||||||
|
- 상태: 승인됨 (구현 대기)
|
||||||
|
- 관련 파일: `src/app/api/gemini/floor-plan/route.js`, `src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx`
|
||||||
|
|
||||||
|
## 배경 / 문제
|
||||||
|
|
||||||
|
배치면 초기설정의 "읽기 페이지 설정"에서 평면도/입면도 페이지를 지정할 수 있으나, 현재 페이지 번호는 **프롬프트 텍스트 힌트로만** 쓰이고 PDF 는 **전체가 Gemini 에 전송**된다(`buildPrompt` 에만 사용, `file` 버퍼 전체를 `uploadFile`). 따라서:
|
||||||
|
|
||||||
|
- Gemini 가 전 페이지를 비전 토큰화 → 입력 토큰·추론 시간·비용이 페이지 수에 비례(`mediaResolution: HIGH` 로 페이지당 토큰이 큼).
|
||||||
|
- 페이지 지정은 정확도엔 도움이 되나 속도엔 거의 효과가 없다.
|
||||||
|
|
||||||
|
## 목표
|
||||||
|
|
||||||
|
지정한 페이지만 실제로 잘라서 전송하여 분석 속도·비용을 절감한다. 추가로 Files API 업로드 + 처리 폴링(최대 60초 대기) 단계를 인라인 전송으로 제거한다.
|
||||||
|
|
||||||
|
## 범위
|
||||||
|
|
||||||
|
### In scope
|
||||||
|
- `specify` 모드에서 지정한 평면도 + 입면도 페이지만 `pdf-lib` 로 추출한 새 PDF 생성.
|
||||||
|
- 분할본을 `inlineData`(base64) 로 Gemini 에 전송 — Files API 업로드·폴링·`deleteFile` 제거.
|
||||||
|
- 페이지 번호 파싱(`"1,3-5"`, 1-based) 및 PDF 범위 검증.
|
||||||
|
- 분할 시 프롬프트를 "페이지 번호 힌트" → "평면도/입면도 순서 매핑"으로 전환.
|
||||||
|
|
||||||
|
### Out of scope (후속 작업)
|
||||||
|
- **입면도를 지붕 형상 판별에 실제 활용** — 응답 스키마에 높이/지붕형태 필드 추가, 프롬프트 재설계, 결과를 캔버스 지붕형상에 연결하는 별도 작업. 이번엔 입면도 **페이지를 전송**하는 데까지만(토대 마련).
|
||||||
|
- 서버 에러 메시지 i18n (기존 잔여 과제, 기존 한국어 하드코딩 패턴 유지).
|
||||||
|
- 클라이언트 측 PDF 가공/미리보기.
|
||||||
|
|
||||||
|
## 설계
|
||||||
|
|
||||||
|
### 데이터 흐름
|
||||||
|
|
||||||
|
클라이언트(`PlacementShapeSetting.jsx`) — **변경 없음**: 기존대로 `pageMode`/`floorPages`/`facadePages` 텍스트 + 원본 PDF 전체를 multipart 로 전송.
|
||||||
|
|
||||||
|
서버(`route.js`):
|
||||||
|
|
||||||
|
```
|
||||||
|
formData 수신 (file, pageMode, floorPages, facadePages)
|
||||||
|
│
|
||||||
|
├─ pageMode === 'specify' AND (floorPages 또는 facadePages 비어있지 않음)
|
||||||
|
│ ├─ pdf-lib 로 원본 로드, getPageCount()
|
||||||
|
│ ├─ floorPages/facadePages 파싱 → 1-based → 0-based 인덱스
|
||||||
|
│ ├─ PDF 범위로 검증, 유효 인덱스만 (평면도 먼저, 입면도 뒤, 그룹 내 오름차순·중복제거)
|
||||||
|
│ ├─ 유효 페이지 0개 → 422 INVALID_PAGE_RANGE
|
||||||
|
│ ├─ 분할 중 예외(손상 PDF 등) → 422 PDF_SPLIT_FAILED
|
||||||
|
│ └─ 유효 페이지 ≥1 → copyPages 로 새 PDF 생성 → splitBuffer
|
||||||
|
│
|
||||||
|
└─ else (all 모드 또는 입력 둘 다 빈값) → 원본 buffer 사용
|
||||||
|
|
||||||
|
전송 PDF(splitBuffer 또는 원본) 크기 판정:
|
||||||
|
├─ raw 크기 ≤ INLINE_MAX_BYTES → inlineData(base64) 경로
|
||||||
|
└─ 초과 → Files API 경로 (기존: uploadFile → waitForFileActive → fileData)
|
||||||
|
|
||||||
|
generateContent([ <pdf part>, { text: prompt } ])
|
||||||
|
```
|
||||||
|
|
||||||
|
### 페이지 번호 파싱
|
||||||
|
- 형식: 쉼표 구분 + 하이픈 범위. 예) `"1,3-5"` → `[1,3,4,5]`. **1-based**.
|
||||||
|
- 공백/빈 토큰/숫자 아닌 토큰은 무시.
|
||||||
|
- `floorPages`, `facadePages` 각각 파싱 → PDF 총 페이지 수로 필터 → 0-based 변환.
|
||||||
|
- 분할본 페이지 순서: `[...floorIdx, ...facadeIdx]`(평면도 먼저). 각 그룹 내 오름차순·중복 제거, 그룹 간 동일 페이지가 겹치면 평면도 우선.
|
||||||
|
|
||||||
|
### 인라인 / Files API 분기
|
||||||
|
- `INLINE_MAX_BYTES` 상수: 요청 총 20MB 한도 + base64 약 +33% 를 감안해 보수적으로 raw 기준 약 12MB.
|
||||||
|
- 분할본은 거의 항상 한도 이하 → 대부분 인라인. 초과(고해상도 스캔 등) 시 Files API 로 자동 fallback(기존 경로 재사용).
|
||||||
|
- 인라인 경로: `waitForFileActive`/`deleteFile` 불필요(임시 파일 없음).
|
||||||
|
|
||||||
|
### 프롬프트 보정
|
||||||
|
- `buildPrompt` 에 분할 컨텍스트 전달: `{ splitMode: true, floorCount, facadeCount }`.
|
||||||
|
- 분할 시: 페이지 번호 힌트 대신 순서 매핑 안내. 예)
|
||||||
|
> "첨부 PDF 는 평면도/지붕평면도 {floorCount}장과 입면도 {facadeCount}장으로 구성됩니다(평면도가 앞). 외곽선은 평면도/지붕평면도에서만 추출하고 입면도는 사용하지 마세요."
|
||||||
|
- `facadeCount === 0` 이면 입면도 문구 생략.
|
||||||
|
- 비분할(all/fallback) 경로는 기존 `buildPrompt` 동작 유지.
|
||||||
|
|
||||||
|
### 에러 처리 (정책 A — 명시적 에러)
|
||||||
|
- 유효 페이지 0개(범위 밖 번호만 입력) → `422 { code: 'INVALID_PAGE_RANGE' }`. 클라이언트는 기존 `payload.error.message` 분기로 표시.
|
||||||
|
- 분할 단계 예외 → `422 { code: 'PDF_SPLIT_FAILED' }`. 조용한 전체 fallback 하지 않음(A 정책 일관).
|
||||||
|
- 입력 둘 다 빈값 + `specify` → `all` 처럼 전체 전송(자연스러운 기본값, 에러 아님).
|
||||||
|
|
||||||
|
### 의존성
|
||||||
|
- `pdf-lib` 추가(`yarn add pdf-lib`). 서버 라우트에서만 import. 순수 JS → `next.config.mjs` externals 수정 불필요.
|
||||||
|
|
||||||
|
## 변경 파일
|
||||||
|
- `src/app/api/gemini/floor-plan/route.js` — 분할·인라인·프롬프트 분기(핵심).
|
||||||
|
- `package.json` — `pdf-lib` 의존성.
|
||||||
|
- 클라이언트 변경 없음.
|
||||||
|
|
||||||
|
## 검증 (테스트 러너 없음 → 수동)
|
||||||
|
- `NEXT_PUBLIC_ENABLE_LOGGING=true` 로 `timing`(fileReady/generate) 로그 확인 — 인라인 경로에서 fileReady 단계 소멸 확인.
|
||||||
|
- 시나리오: (a) 평면도 1p 지정 (b) 평면도+입면도 지정 (c) 범위 밖 페이지 → 422 (d) all 모드 회귀 (e) 대용량 → Files API fallback.
|
||||||
|
- `yarn lint`, `yarn build` 통과(eslint 미설치 시 build 가 lint 를 스킵하는 점 주의).
|
||||||
|
|
||||||
|
## 위험 / 완화
|
||||||
|
- 인라인 한도 초과 → Files API fallback 으로 흡수.
|
||||||
|
- 분할본 순서와 프롬프트 매핑 불일치 → 순서를 `[평면도, 입면도]` 로 고정하고 프롬프트도 동일 순서로 명시.
|
||||||
|
- 입면도가 외곽선 추출을 오염 → 프롬프트에서 "입면도 미사용" 명시(기존 `BASE_PROMPT` 에도 존재).
|
||||||
@ -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",
|
||||||
|
"pdf-lib": "^1.17.1",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-chartjs-2": "^5.2.0",
|
"react-chartjs-2": "^5.2.0",
|
||||||
"react-colorful": "^5.6.1",
|
"react-colorful": "^5.6.1",
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { cookies } from 'next/headers'
|
|||||||
import { getIronSession } from 'iron-session'
|
import { getIronSession } from 'iron-session'
|
||||||
import { GoogleGenerativeAI, SchemaType } from '@google/generative-ai'
|
import { GoogleGenerativeAI, SchemaType } from '@google/generative-ai'
|
||||||
import { GoogleAIFileManager, FileState } from '@google/generative-ai/server'
|
import { GoogleAIFileManager, FileState } from '@google/generative-ai/server'
|
||||||
|
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'
|
||||||
@ -10,6 +11,9 @@ import { logger } from '@/util/logger'
|
|||||||
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'
|
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
|
||||||
@ -92,7 +96,15 @@ const BASE_PROMPT = `당신은 일본 주택 건축 도면에서 "지붕 투영
|
|||||||
"selectedDrawingType": string (선택)
|
"selectedDrawingType": string (선택)
|
||||||
}`
|
}`
|
||||||
|
|
||||||
const buildPrompt = ({ pageMode, facadePages, floorPages } = {}) => {
|
const buildPrompt = ({ pageMode, facadePages, floorPages, splitInfo } = {}) => {
|
||||||
|
// 분할 전송: 잘린 PDF 는 페이지가 1,2,3… 으로 재번호되므로 원본 페이지 번호 힌트 대신 순서 매핑을 안내한다.
|
||||||
|
if (splitInfo) {
|
||||||
|
const segs = []
|
||||||
|
if (splitInfo.floorCount > 0) segs.push(`평면도/지붕평면도 ${splitInfo.floorCount}장`)
|
||||||
|
if (splitInfo.facadeCount > 0) segs.push(`입면도 ${splitInfo.facadeCount}장`)
|
||||||
|
const compo = `첨부 PDF 는 ${segs.join('과 ')}으로 구성됩니다(평면도가 앞).`
|
||||||
|
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}`)
|
||||||
@ -133,6 +145,37 @@ const validateOuterline = (outerline) => {
|
|||||||
return { ok: true, empty: false }
|
return { ok: true, empty: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "1,3-5" → [1,3,4,5] (1-based). 공백·빈 토큰·숫자 아닌 토큰은 무시한다.
|
||||||
|
const parsePageList = (str) => {
|
||||||
|
const out = []
|
||||||
|
if (!str) return out
|
||||||
|
for (const token of String(str).split(',')) {
|
||||||
|
const t = token.trim()
|
||||||
|
if (!t) continue
|
||||||
|
const range = t.split('-')
|
||||||
|
if (range.length === 2) {
|
||||||
|
const a = parseInt(range[0], 10)
|
||||||
|
const b = parseInt(range[1], 10)
|
||||||
|
if (Number.isInteger(a) && Number.isInteger(b)) {
|
||||||
|
for (let i = Math.min(a, b); i <= Math.max(a, b); i++) out.push(i)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const n = parseInt(t, 10)
|
||||||
|
if (Number.isInteger(n)) out.push(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1-based 페이지 번호 배열을 PDF 범위 내 0-based 인덱스로. 범위 밖 제거, 오름차순·중복 제거.
|
||||||
|
const toValidIndices = (pages, total) => {
|
||||||
|
const set = new Set()
|
||||||
|
for (const p of pages) {
|
||||||
|
if (Number.isInteger(p) && p >= 1 && p <= total) set.add(p - 1)
|
||||||
|
}
|
||||||
|
return [...set].sort((a, b) => a - b)
|
||||||
|
}
|
||||||
|
|
||||||
const waitForFileActive = async (fileManager, fileName, { timeoutMs = 60000, intervalMs = 1500 } = {}) => {
|
const waitForFileActive = async (fileManager, fileName, { timeoutMs = 60000, intervalMs = 1500 } = {}) => {
|
||||||
const started = Date.now()
|
const started = Date.now()
|
||||||
let file = await fileManager.getFile(fileName)
|
let file = await fileManager.getFile(fileName)
|
||||||
@ -195,15 +238,54 @@ export async function POST(req) {
|
|||||||
|
|
||||||
const buffer = Buffer.from(await file.arrayBuffer())
|
const buffer = Buffer.from(await file.arrayBuffer())
|
||||||
|
|
||||||
const uploadStarted = Date.now()
|
// 지정 페이지만 잘라낸다(specify + 페이지 입력 시). 입력이 둘 다 비어있으면 원본 전체를 그대로 보낸다.
|
||||||
const uploadResult = await fileManager.uploadFile(buffer, {
|
let sendBuffer = buffer
|
||||||
|
let splitInfo = null
|
||||||
|
if (pageMode === 'specify' && (floorPages || facadePages)) {
|
||||||
|
let src
|
||||||
|
try {
|
||||||
|
src = await PDFDocument.load(buffer)
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json({ error: { code: 'PDF_SPLIT_FAILED', message: 'PDF 를 읽지 못했습니다.' } }, { status: 422 })
|
||||||
|
}
|
||||||
|
const total = src.getPageCount()
|
||||||
|
const floorIdx = toValidIndices(parsePageList(floorPages), total)
|
||||||
|
const floorSet = new Set(floorIdx)
|
||||||
|
// 평면도가 앞, 입면도는 뒤. 그룹 간 동일 페이지가 겹치면 평면도 우선.
|
||||||
|
const facadeIdx = toValidIndices(parsePageList(facadePages), total).filter((i) => !floorSet.has(i))
|
||||||
|
const order = [...floorIdx, ...facadeIdx]
|
||||||
|
if (order.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: { code: 'INVALID_PAGE_RANGE', message: '지정한 페이지 번호가 PDF 범위를 벗어났습니다. 페이지 번호를 확인해주세요.' } },
|
||||||
|
{ status: 422 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const out = await PDFDocument.create()
|
||||||
|
const copied = await out.copyPages(src, order)
|
||||||
|
copied.forEach((p) => out.addPage(p))
|
||||||
|
sendBuffer = Buffer.from(await out.save())
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json({ error: { code: 'PDF_SPLIT_FAILED', message: 'PDF 페이지 분할에 실패했습니다.' } }, { status: 422 })
|
||||||
|
}
|
||||||
|
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',
|
mimeType: 'application/pdf',
|
||||||
displayName: file.name || 'floor-plan.pdf',
|
displayName: file.name || 'floor-plan.pdf',
|
||||||
})
|
})
|
||||||
uploadedFileName = uploadResult.file.name
|
uploadedFileName = uploadResult.file.name
|
||||||
|
|
||||||
await waitForFileActive(fileManager, uploadedFileName)
|
await waitForFileActive(fileManager, uploadedFileName)
|
||||||
const fileReadyMs = Date.now() - uploadStarted
|
pdfPart = { fileData: { fileUri: uploadResult.file.uri, mimeType: uploadResult.file.mimeType } }
|
||||||
|
}
|
||||||
|
const fileReadyMs = Date.now() - fileStarted
|
||||||
|
|
||||||
const genAI = new GoogleGenerativeAI(apiKey)
|
const genAI = new GoogleGenerativeAI(apiKey)
|
||||||
const model = genAI.getGenerativeModel({
|
const model = genAI.getGenerativeModel({
|
||||||
@ -217,18 +299,10 @@ export async function POST(req) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const prompt = buildPrompt({ pageMode, facadePages, floorPages })
|
const prompt = buildPrompt({ pageMode, facadePages, floorPages, splitInfo })
|
||||||
|
|
||||||
const generateStarted = Date.now()
|
const generateStarted = Date.now()
|
||||||
const result = await model.generateContent([
|
const result = await model.generateContent([pdfPart, { text: prompt }])
|
||||||
{
|
|
||||||
fileData: {
|
|
||||||
fileUri: uploadResult.file.uri,
|
|
||||||
mimeType: uploadResult.file.mimeType,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ text: prompt },
|
|
||||||
])
|
|
||||||
|
|
||||||
const raw = result.response.text()
|
const raw = result.response.text()
|
||||||
logger.debug('[gemini/floor-plan] timing(ms)', { fileReady: fileReadyMs, generate: Date.now() - generateStarted })
|
logger.debug('[gemini/floor-plan] timing(ms)', { fileReady: fileReadyMs, generate: Date.now() - generateStarted })
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user