feat: add real PDF ingestion workflow
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""Application services for document ingestion and workflow execution."""
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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("下方")
|
||||
@@ -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"},
|
||||
),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user