feat: add real PDF ingestion workflow

This commit is contained in:
Codex
2026-08-02 00:29:40 +08:00
parent 0eb2a86f36
commit 4d97a30616
18 changed files with 986 additions and 93 deletions
+177 -5
View File
@@ -1,23 +1,37 @@
from pathlib import Path
from typing import Any
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile, status
from pydantic import BaseModel
from app.config import Settings, get_settings
from app.api.settings import require_workflow_ready
from app.domain.models import CommandRequest, ProjectSnapshot, WorkflowDefinition
from app.domain.models import (
CommandRequest,
PlanIssue,
PlanLayer,
PlanState,
ProjectSnapshot,
WorkflowDefinition,
WorkflowStage,
)
from app.domain.workflow import (
InvalidTransitionError,
WorkflowConflictError,
apply_command,
available_commands,
workflow_definition,
)
from app.integrations.model_router import ModelRouter
from app.integrations.ocr import BaiduOcrAdapter
from app.integrations.storage import S3Storage
from app.repositories.memory import repository
from app.repositories.postgres import postgres_repository
from app.runtime_settings import EncryptedSettingsStore, get_runtime_store
from app.services.plan_ingestion import inspect_pdf
router = APIRouter(prefix="/v1")
MAX_UPLOAD_BYTES = 25 * 1024 * 1024
class UploadRequest(BaseModel):
@@ -25,6 +39,10 @@ class UploadRequest(BaseModel):
content_type: str
def get_project_repository(settings: Settings = Depends(get_settings)):
return postgres_repository(settings.database_url)
@router.get("/health")
def health(
settings: Settings = Depends(get_settings),
@@ -61,19 +79,31 @@ def get_workflow() -> WorkflowDefinition:
@router.get("/projects/{project_id}", response_model=ProjectSnapshot)
def get_project(project_id: str) -> ProjectSnapshot:
def get_project(
project_id: str,
repository: Any = Depends(get_project_repository),
) -> ProjectSnapshot:
project = repository.get(project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found.")
return project
@router.get("/projects", response_model=list[ProjectSnapshot])
def list_projects(repository: Any = Depends(get_project_repository)) -> list[ProjectSnapshot]:
return repository.list()
@router.post(
"/projects/{project_id}/commands",
response_model=ProjectSnapshot,
dependencies=[Depends(require_workflow_ready)],
)
def execute_command(project_id: str, request: CommandRequest) -> ProjectSnapshot:
def execute_command(
project_id: str,
request: CommandRequest,
repository: Any = Depends(get_project_repository),
) -> ProjectSnapshot:
project = repository.get(project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found.")
@@ -86,6 +116,148 @@ def execute_command(project_id: str, request: CommandRequest) -> ProjectSnapshot
return repository.save(updated)
@router.post(
"/projects/ingest",
response_model=ProjectSnapshot,
dependencies=[Depends(require_workflow_ready)],
)
async def ingest_project(
file: UploadFile = File(...),
project_name: str = Form(""),
runtime_store: EncryptedSettingsStore = Depends(get_runtime_store),
repository: Any = Depends(get_project_repository),
) -> ProjectSnapshot:
filename = (file.filename or "floor-plan.pdf").strip()
if Path(filename).suffix.lower() != ".pdf":
raise HTTPException(status_code=415, detail="当前阶段只支持 PDF 户型图。")
content = await file.read(MAX_UPLOAD_BYTES + 1)
if not content:
raise HTTPException(status_code=422, detail="上传文件为空。")
if len(content) > MAX_UPLOAD_BYTES:
raise HTTPException(status_code=413, detail="PDF 不能超过 25 MB。")
try:
inspection = inspect_pdf(content)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
values = runtime_store.merged_values()
storage = S3Storage(values)
project_id = str(uuid4())
safe_name = filename.replace("/", "_").replace("\\", "_")
source_key = f"projects/{project_id}/source/{safe_name}"
preview_key = f"projects/{project_id}/derived/page-1-preview.png"
try:
storage.put_input(source_key, content, file.content_type or "application/pdf")
storage.put_output(preview_key, inspection.preview_png, "image/png")
except Exception as exc:
raise HTTPException(status_code=503, detail=f"图纸写入 MinIO 失败:{exc}") from exc
ocr_labels: list[str] = []
ocr_used = False
ocr = BaiduOcrAdapter(values)
if inspection.text_is_corrupted and ocr.configured:
try:
ocr_result = await ocr.recognize(inspection.preview_png)
ocr_labels = [str(row.get("words", "")).strip() for row in ocr_result if row.get("words")]
ocr_used = bool(ocr_labels)
except Exception:
ocr_labels = []
notes = [
f"首页检测到 {inspection.vector_element_count} 个矢量元素。"
if inspection.vector_based
else "首页主要由像素图像组成,后续需要视觉分割。",
f"检测到 {len(inspection.regions)} 个候选平面区域,请由用户确认目标户型。",
]
if inspection.text_is_corrupted:
notes.append("原 PDF 文字层不可可靠读取,已调用百度 OCR。" if ocr_used else "原 PDF 文字层不可可靠读取,需要 OCR 或人工确认。")
else:
notes.append("原 PDF 文字层可读取,尺寸文字仍需和图形标注交叉校验。")
issues = [
PlanIssue(
id="region-selection",
kind="region",
message=f"检测到 {len(inspection.regions)} 个候选区域,请选择需要设计的户型。",
confidence=max((region.confidence for region in inspection.regions), default=0.5),
),
PlanIssue(
id="scale-check",
kind="scale",
message="比例需要结合至少两个尺寸标注交叉确认。",
confidence=0.55,
),
]
if inspection.text_is_corrupted and not ocr_used:
issues.append(
PlanIssue(
id="text-recovery",
kind="ocr",
message="房间名称与尺寸文字尚未可靠识别。",
confidence=0.35,
)
)
stage = WorkflowStage.REGION_SELECTION
project = ProjectSnapshot(
project_id=project_id,
name=project_name.strip() or f"{Path(filename).stem} 概念方案",
stage=stage,
plan=PlanState(
source_name=filename,
source_kind="vector_pdf" if inspection.vector_based else "scanned_pdf",
source_object_key=source_key,
preview_object_key=preview_key,
preview_width=inspection.preview_width,
preview_height=inspection.preview_height,
page_count=inspection.page_count,
vector_based=inspection.vector_based,
vector_element_count=inspection.vector_element_count,
regions=inspection.regions,
layers=[
PlanLayer(
id="pdf-vector",
label="PDF 矢量线稿",
category="raw_vector",
visible=True,
element_count=inspection.vector_element_count,
),
PlanLayer(
id="pdf-text",
label="尺寸与房间文字",
category="annotation",
visible=True,
element_count=len(ocr_labels) if ocr_used else len(inspection.extracted_text),
),
],
issues=issues,
ocr_used=ocr_used,
ocr_labels=ocr_labels[:300],
ingestion_notes=notes,
),
available_commands=available_commands(stage),
)
return repository.save(project)
@router.get("/projects/{project_id}/preview")
def get_project_preview(
project_id: str,
runtime_store: EncryptedSettingsStore = Depends(get_runtime_store),
repository: Any = Depends(get_project_repository),
) -> Response:
project = repository.get(project_id)
if project is None:
raise HTTPException(status_code=404, detail="Project not found.")
if not project.plan.preview_object_key:
raise HTTPException(status_code=404, detail="Project preview is not available.")
try:
content, content_type = S3Storage(runtime_store.merged_values()).get_output(project.plan.preview_object_key)
except Exception as exc:
raise HTTPException(status_code=503, detail=f"读取图纸预览失败:{exc}") from exc
return Response(content=content, media_type=content_type, headers={"Cache-Control": "private, max-age=60"})
@router.post("/uploads/presign", dependencies=[Depends(require_workflow_ready)])
def create_upload(
request: UploadRequest,
+9
View File
@@ -50,6 +50,7 @@ class PlanLayer(BaseModel):
category: str
visible: bool = True
source_names: list[str] = Field(default_factory=list)
element_count: int | None = None
class PlanIssue(BaseModel):
@@ -63,9 +64,17 @@ class PlanIssue(BaseModel):
class PlanState(BaseModel):
source_name: str
source_kind: str
source_object_key: str | None = None
preview_object_key: str | None = None
preview_width: int | None = None
preview_height: int | None = None
page_count: int = 1
vector_based: bool = False
vector_element_count: int = 0
cad_layer_count: int = 0
ocr_used: bool = False
ocr_labels: list[str] = Field(default_factory=list)
ingestion_notes: list[str] = Field(default_factory=list)
regions: list[PlanRegion] = Field(default_factory=list)
selected_region_id: str | None = None
layers: list[PlanLayer] = Field(default_factory=list)
+7 -1
View File
@@ -80,7 +80,13 @@ def apply_command(project: ProjectSnapshot, request: CommandRequest) -> ProjectS
updated.failure_reason = None
if request.command == WorkflowCommand.SELECT_REGION:
updated.plan.selected_region_id = request.payload.get("region_id")
region_id = str(request.payload.get("region_id", ""))
if not any(region.id == region_id for region in updated.plan.regions):
raise InvalidTransitionError(f"Region '{region_id}' does not exist in this project.")
updated.plan.selected_region_id = region_id
for issue in updated.plan.issues:
if issue.kind == "region":
issue.resolved = True
elif request.command == WorkflowCommand.CONFIRM_PLAN:
updated.plan.ceiling_height_mm = int(
request.payload.get("ceiling_height_mm", updated.plan.ceiling_height_mm)
+26
View File
@@ -1,5 +1,6 @@
from dataclasses import dataclass
from collections.abc import Mapping
from io import BytesIO
from typing import Any
import boto3
@@ -70,3 +71,28 @@ class S3Storage:
object_key=object_key,
expires_in=int(self._value("s3_presigned_url_ttl_seconds", 900)),
)
def put_input(self, object_key: str, content: bytes, content_type: str) -> None:
self._put(self._value("s3_bucket_inputs"), object_key, content, content_type)
def put_output(self, object_key: str, content: bytes, content_type: str) -> None:
self._put(self._value("s3_bucket_derived"), object_key, content, content_type)
def get_output(self, object_key: str) -> tuple[bytes, str]:
if not self.configured:
raise RuntimeError("MinIO/S3 is not configured.")
response = self._client().get_object(
Bucket=self._value("s3_bucket_derived"),
Key=object_key,
)
return response["Body"].read(), response.get("ContentType", "application/octet-stream")
def _put(self, bucket: str, object_key: str, content: bytes, content_type: str) -> None:
if not self.configured:
raise RuntimeError("MinIO/S3 is not configured.")
self._client().upload_fileobj(
BytesIO(content),
bucket,
object_key,
ExtraArgs={"ContentType": content_type},
)
+3
View File
@@ -123,6 +123,9 @@ class InMemoryProjectRepository:
def get(self, project_id: str) -> ProjectSnapshot | None:
return self._items.get(project_id)
def list(self) -> list[ProjectSnapshot]:
return sorted(self._items.values(), key=lambda item: item.updated_at, reverse=True)
def save(self, project: ProjectSnapshot) -> ProjectSnapshot:
self._items[project.project_id] = project
return project
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
import json
from functools import lru_cache
import psycopg
from psycopg.types.json import Jsonb
from app.domain.models import ProjectSnapshot
from app.repositories.memory import create_demo_project
class PostgresProjectRepository:
def __init__(self, database_url: str) -> None:
self.database_url = database_url
self.ensure_schema()
if self.get("demo-apartment") is None:
self.save(create_demo_project())
def _connect(self):
return psycopg.connect(self.database_url)
def ensure_schema(self) -> None:
with self._connect() as connection, connection.cursor() as cursor:
cursor.execute(
"""
CREATE TABLE IF NOT EXISTS projects (
project_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
stage TEXT NOT NULL,
revision INTEGER NOT NULL,
snapshot JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
)
"""
)
def get(self, project_id: str) -> ProjectSnapshot | None:
with self._connect() as connection, connection.cursor() as cursor:
cursor.execute("SELECT snapshot FROM projects WHERE project_id = %s", (project_id,))
row = cursor.fetchone()
if row is None:
return None
payload = json.loads(row[0]) if isinstance(row[0], str) else row[0]
return ProjectSnapshot.model_validate(payload)
def list(self) -> list[ProjectSnapshot]:
with self._connect() as connection, connection.cursor() as cursor:
cursor.execute("SELECT snapshot FROM projects ORDER BY updated_at DESC")
rows = cursor.fetchall()
return [
ProjectSnapshot.model_validate(json.loads(row[0]) if isinstance(row[0], str) else row[0])
for row in rows
]
def save(self, project: ProjectSnapshot) -> ProjectSnapshot:
payload = project.model_dump(mode="json")
with self._connect() as connection, connection.cursor() as cursor:
cursor.execute(
"""
INSERT INTO projects (project_id, name, stage, revision, snapshot, updated_at)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (project_id) DO UPDATE SET
name = EXCLUDED.name,
stage = EXCLUDED.stage,
revision = EXCLUDED.revision,
snapshot = EXCLUDED.snapshot,
updated_at = EXCLUDED.updated_at
""",
(
project.project_id,
project.name,
project.stage.value,
project.revision,
Jsonb(payload),
project.updated_at,
),
)
return project
@lru_cache(maxsize=4)
def postgres_repository(database_url: str) -> PostgresProjectRepository:
return PostgresProjectRepository(database_url)
+1
View File
@@ -0,0 +1 @@
"""Application services for document ingestion and workflow execution."""
+166
View File
@@ -0,0 +1,166 @@
from __future__ import annotations
from dataclasses import dataclass
from io import BytesIO
from math import prod
from typing import Any
import pdfplumber
import pypdfium2 as pdfium
from PIL import Image, ImageFilter
from app.domain.models import PlanRegion
@dataclass(frozen=True)
class PdfIngestionResult:
page_count: int
vector_based: bool
vector_element_count: int
extracted_text: str
text_is_corrupted: bool
preview_png: bytes
preview_width: int
preview_height: int
regions: list[PlanRegion]
def inspect_pdf(pdf_bytes: bytes) -> PdfIngestionResult:
if not pdf_bytes.startswith(b"%PDF-"):
raise ValueError("上传内容不是有效的 PDF 文件。")
try:
with pdfplumber.open(BytesIO(pdf_bytes)) as document:
if not document.pages:
raise ValueError("PDF 没有可解析的页面。")
page = document.pages[0]
vector_count = len(page.lines) + len(page.rects) + len(page.curves)
extracted_text = page.extract_text() or ""
page_count = len(document.pages)
except ValueError:
raise
except Exception as exc:
raise ValueError(f"PDF 解析失败:{exc}") from exc
preview = _render_first_page(pdf_bytes)
preview_png = BytesIO()
preview.save(preview_png, format="PNG", optimize=True)
return PdfIngestionResult(
page_count=page_count,
vector_based=vector_count >= 80,
vector_element_count=vector_count,
extracted_text=extracted_text,
text_is_corrupted=_text_looks_corrupted(extracted_text),
preview_png=preview_png.getvalue(),
preview_width=preview.width,
preview_height=preview.height,
regions=detect_plan_regions(preview),
)
def _render_first_page(pdf_bytes: bytes) -> Image.Image:
try:
document = pdfium.PdfDocument(pdf_bytes)
page = document[0]
width, _ = page.get_size()
scale = max(0.75, min(2.0, 1600 / max(width, 1)))
image = page.render(scale=scale).to_pil().convert("RGB")
page.close()
document.close()
return image
except Exception as exc:
raise ValueError(f"PDF 首页渲染失败:{exc}") from exc
def _text_looks_corrupted(text: str) -> bool:
compact = [character for character in text if not character.isspace()]
if len(compact) < 8:
return True
cjk = sum("\u3400" <= character <= "\u9fff" for character in compact)
ascii_useful = sum(character.isascii() and (character.isalnum() or character in "-./") for character in compact)
non_cjk_letters = sum(character.isalpha() and not character.isascii() and not ("\u3400" <= character <= "\u9fff") for character in compact)
useful_ratio = (cjk + ascii_useful) / len(compact)
return useful_ratio < 0.45 or (cjk == 0 and non_cjk_letters >= 6)
def detect_plan_regions(image: Image.Image) -> list[PlanRegion]:
analysis = image.convert("L")
analysis.thumbnail((420, 640), Image.Resampling.LANCZOS)
width, height = analysis.size
ink = analysis.point(lambda value: 255 if value < 235 else 0)
connected = ink.filter(ImageFilter.MaxFilter(9)).filter(ImageFilter.MaxFilter(7))
pixels = connected.load()
visited = bytearray(width * height)
components: list[dict[str, Any]] = []
for y in range(height):
for x in range(width):
index = y * width + x
if visited[index] or pixels[x, y] == 0:
continue
stack = [(x, y)]
visited[index] = 1
min_x = max_x = x
min_y = max_y = y
count = 0
while stack:
current_x, current_y = stack.pop()
count += 1
min_x = min(min_x, current_x)
max_x = max(max_x, current_x)
min_y = min(min_y, current_y)
max_y = max(max_y, current_y)
for next_y in range(max(0, current_y - 1), min(height, current_y + 2)):
for next_x in range(max(0, current_x - 1), min(width, current_x + 2)):
next_index = next_y * width + next_x
if not visited[next_index] and pixels[next_x, next_y] != 0:
visited[next_index] = 1
stack.append((next_x, next_y))
box_width = max_x - min_x + 1
box_height = max_y - min_y + 1
if box_width < width * 0.14 or box_height < height * 0.11:
continue
if count < prod((width, height)) * 0.003:
continue
components.append(
{
"box": (min_x, min_y, max_x + 1, max_y + 1),
"count": count,
"density": _ink_density(ink, (min_x, min_y, max_x + 1, max_y + 1)),
}
)
if not components:
components = [{"box": (0, 0, width, height), "count": width * height, "density": 0.0}]
components.sort(key=lambda component: (component["box"][1], component["box"][0]))
components = components[:6]
best = max(range(len(components)), key=lambda index: components[index]["density"] * components[index]["count"])
regions: list[PlanRegion] = []
for index, component in enumerate(components):
x0, y0, x1, y1 = component["box"]
pad_x = width * 0.015
pad_y = height * 0.015
bounds = [
round(max(0.0, (x0 - pad_x) / width), 4),
round(max(0.0, (y0 - pad_y) / height), 4),
round(min(1.0, (x1 + pad_x) / width), 4),
round(min(1.0, (y1 + pad_y) / height), 4),
]
vertical_hint = "上方" if (y0 + y1) / 2 < height / 2 else "下方"
confidence = min(0.96, 0.64 + component["density"] * 2.4)
regions.append(
PlanRegion(
id=f"region-{index + 1}",
name=f"{vertical_hint}候选区域 {index + 1}",
bounds=bounds,
recommended=index == best,
confidence=round(confidence, 2),
)
)
return regions
def _ink_density(image: Image.Image, box: tuple[int, int, int, int]) -> float:
crop = image.crop(box)
histogram = crop.histogram()
ink_pixels = sum(histogram[1:])
return ink_pixels / max(crop.width * crop.height, 1)
+3
View File
@@ -12,7 +12,10 @@ dependencies = [
"cryptography>=44,<47",
"fastapi>=0.115,<1",
"httpx>=0.28,<1",
"pdfplumber>=0.11,<1",
"pillow>=11,<13",
"psycopg[binary]>=3.2,<4",
"pypdfium2>=5,<6",
"pydantic>=2.10,<3",
"pydantic-settings>=2.7,<3",
"python-multipart>=0.0.20,<1",
+4
View File
@@ -3,7 +3,9 @@ import pytest
from pathlib import Path
from app.config import Settings
from app.api.routes import get_project_repository
from app.main import app
from app.repositories.memory import InMemoryProjectRepository
from app.runtime_settings import EncryptedSettingsStore, get_runtime_store
@@ -17,7 +19,9 @@ def transport(tmp_path: Path) -> httpx.ASGITransport:
redis_url="redis://:secret@redis:6379/0",
)
)
repository = InMemoryProjectRepository()
app.dependency_overrides[get_runtime_store] = lambda: store
app.dependency_overrides[get_project_repository] = lambda: repository
return httpx.ASGITransport(app=app)
+19
View File
@@ -0,0 +1,19 @@
from pathlib import Path
from app.services.plan_ingestion import inspect_pdf
def test_sample_pdf_detects_two_plan_regions() -> None:
workspace = Path(__file__).resolve().parents[3]
sample = workspace / "11-2-104-模型.pdf"
result = inspect_pdf(sample.read_bytes())
assert result.page_count == 1
assert result.vector_based is True
assert result.vector_element_count > 10_000
assert result.preview_width == 1600
assert len(result.regions) == 2
assert result.regions[0].name.startswith("上方")
assert result.regions[0].recommended is True
assert result.regions[1].name.startswith("下方")
+14
View File
@@ -43,3 +43,17 @@ def test_invalid_command_is_rejected() -> None:
expected_revision=project.revision,
),
)
def test_unknown_region_is_rejected() -> None:
project = create_demo_project()
project.stage = WorkflowStage.REGION_SELECTION
with pytest.raises(InvalidTransitionError):
apply_command(
project,
CommandRequest(
command=WorkflowCommand.SELECT_REGION,
expected_revision=project.revision,
payload={"region_id": "missing-region"},
),
)