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
+5 -1
View File
@@ -35,11 +35,15 @@ docs 架构与工作流设计
## 当前垂直切片 ## 当前垂直切片
- 显示样例户型与图层开关 - 从网页真实上传 25 MB 以内的 PDF,并把原文件与首页预览保存到 MinIO
- 判断矢量 PDF / 扫描 PDF,统计矢量元素,并检测同页中的多个候选平面区域。
- 在图纸上直接选择目标户型,选择结果与工作流修订号持久化到 PostgreSQL。
- 展示从导入、清洗、白模到风格方向和多轮修改的阶段状态。 - 展示从导入、清洗、白模到风格方向和多轮修改的阶段状态。
- 提供结构化 Style DNA 面板。 - 提供结构化 Style DNA 面板。
- 提供可验证的工作流状态机和命令接口。 - 提供可验证的工作流状态机和命令接口。
- 预留 MinIO、百度 OCR、GPU Worker 和多模型路由适配器。 - 预留 MinIO、百度 OCR、GPU Worker 和多模型路由适配器。
- 提供分类设置中心、可扩展模型池、逐模型测试、模型路由和工作流就绪门禁。 - 提供分类设置中心、可扩展模型池、逐模型测试、模型路由和工作流就绪门禁。
当前图纸解析先处理 PDF 首页并完成“导入图纸 → 选择户型 → 进入结构确认”的真实闭环。墙、门窗、尺寸比例和房间多边形的语义分离属于下一阶段。
详见 [架构设计](docs/architecture.md)、[工作流定义](docs/workflow.md) 与 [系统设置](docs/settings.md)。 详见 [架构设计](docs/architecture.md)、[工作流定义](docs/workflow.md) 与 [系统设置](docs/settings.md)。
+151
View File
@@ -434,6 +434,7 @@ textarea:focus-visible,
display: grid; display: grid;
grid-template-rows: auto minmax(0, 1fr) auto; grid-template-rows: auto minmax(0, 1fr) auto;
gap: 12px; gap: 12px;
overflow: auto;
} }
.canvas-labels, .canvas-labels,
@@ -465,6 +466,156 @@ textarea:focus-visible,
filter: contrast(1.02) saturate(0.72); filter: contrast(1.02) saturate(0.72);
} }
.plan-upload-empty {
min-height: 486px;
padding: 40px;
display: grid;
place-content: center;
justify-items: center;
gap: 14px;
text-align: center;
background:
radial-gradient(circle at 50% 30%, color-mix(in srgb, var(--accent) 10%, transparent), transparent 38%),
var(--surface-muted);
}
.plan-upload-empty h2 {
font-size: 20px;
letter-spacing: -0.025em;
}
.plan-upload-empty > p {
max-width: 530px;
color: var(--text-soft);
font-size: 12px;
line-height: 1.8;
}
.upload-emblem {
width: 62px;
height: 62px;
display: grid;
place-items: center;
color: var(--accent-strong);
border: 1px solid color-mix(in srgb, var(--accent) 32%, var(--line));
border-radius: 18px;
background: color-mix(in srgb, var(--accent) 9%, var(--surface-raised));
box-shadow: var(--shadow);
}
.plan-document-frame {
position: relative;
width: min(100%, 720px);
margin: 0 auto;
align-self: start;
overflow: hidden;
border: 1px solid var(--line-strong);
border-radius: var(--radius-surface);
background: #fff;
box-shadow: var(--shadow);
}
.plan-document-image {
display: block;
width: 100%;
height: auto;
filter: contrast(1.02) saturate(0.75);
}
.region-layer {
position: absolute;
inset: 0;
}
.detected-region {
position: absolute;
min-width: 0;
min-height: 0;
padding: 8px;
display: flex;
align-items: flex-start;
gap: 6px;
color: var(--accent-strong);
border: 2px solid color-mix(in srgb, var(--accent) 72%, transparent);
border-radius: 8px;
background: color-mix(in srgb, var(--accent) 8%, transparent);
box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.48);
text-align: left;
}
.detected-region > span {
width: 22px;
height: 22px;
flex: 0 0 22px;
display: grid;
place-items: center;
color: white;
border-radius: 50%;
background: var(--accent-strong);
font-size: 10px;
font-weight: 750;
}
.detected-region strong,
.detected-region small {
padding: 4px 6px;
border-radius: 5px;
background: rgb(255 255 255 / 0.9);
font-size: 10px;
line-height: 1.2;
}
.detected-region small {
margin-left: auto;
color: var(--text-soft);
}
.detected-region:hover,
.detected-region.recommended {
background: color-mix(in srgb, var(--accent) 14%, transparent);
}
.detected-region.selected {
border-color: var(--accent-strong);
background: color-mix(in srgb, var(--accent) 20%, transparent);
}
.detected-region:disabled {
cursor: default;
opacity: 0.78;
}
.ingestion-facts {
margin-top: 12px;
display: grid;
gap: 8px;
color: var(--text-soft);
font-size: 10px;
}
.ingestion-facts span,
.workflow-error {
display: flex;
align-items: center;
gap: 6px;
}
.workflow-error {
color: var(--warning) !important;
font-size: 11px !important;
}
.visually-hidden {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
.region-outline { .region-outline {
position: absolute; position: absolute;
inset: 8% 10% 9% 9%; inset: 8% 10% 9% 9%;
+230 -86
View File
@@ -21,36 +21,76 @@ import {
UploadSimpleIcon, UploadSimpleIcon,
WarningCircleIcon, WarningCircleIcon,
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import Image from "next/image"; import { ChangeEvent, FormEvent, useEffect, useMemo, useRef, useState } from "react";
import { FormEvent, useEffect, useMemo, useState } from "react";
import { SettingsCenter } from "@/components/settings-center"; import { SettingsCenter } from "@/components/settings-center";
import { import { styleDirections } from "@/lib/demo";
initialLayers, import type { ChatMessage, PlanLayer, ProjectSnapshot, WorkflowStage } from "@/types/workflow";
initialMessages,
styleDirections,
workflowStages,
} from "@/lib/demo";
import type { ChatMessage } from "@/types/workflow";
import type { SettingsReadiness } from "@/types/settings"; import type { SettingsReadiness } from "@/types/settings";
const iconWeight = "regular" as const; const iconWeight = "regular" as const;
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000"; const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8000";
const stageMeta: Array<Pick<WorkflowStage, "id" | "label" | "detail">> = [
{ id: "uploaded", label: "导入图纸", detail: "PDF 原文件" },
{ id: "region_selection", label: "选择户型", detail: "候选区域" },
{ id: "plan_review", label: "确认结构", detail: "墙、门窗与比例" },
{ id: "blockout", label: "生成白模", detail: "空间骨架" },
{ id: "style_brief", label: "明确风格", detail: "Style DNA" },
{ id: "direction_selection", label: "选择方向", detail: "三个方案" },
{ id: "render_review", label: "审阅效果", detail: "多视角" },
{ id: "editing", label: "多轮修改", detail: "局部编辑" },
];
export function WorkflowStudio() { export function WorkflowStudio() {
const [layers, setLayers] = useState(initialLayers); const [project, setProject] = useState<ProjectSnapshot | null>(null);
const [layers, setLayers] = useState<PlanLayer[]>([]);
const [selectedDirection, setSelectedDirection] = useState("quiet-modern"); const [selectedDirection, setSelectedDirection] = useState("quiet-modern");
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages); const [messages, setMessages] = useState<ChatMessage[]>([]);
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
const [furnitureMode, setFurnitureMode] = useState<"reference" | "remove">("reference"); const [furnitureMode, setFurnitureMode] = useState<"reference" | "remove">("reference");
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const [readiness, setReadiness] = useState<SettingsReadiness | null>(null); const [readiness, setReadiness] = useState<SettingsReadiness | null>(null);
const [uploading, setUploading] = useState(false);
const [workflowError, setWorkflowError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const currentDirection = useMemo( const currentDirection = useMemo(
() => styleDirections.find((item) => item.id === selectedDirection) ?? styleDirections[0], () => styleDirections.find((item) => item.id === selectedDirection) ?? styleDirections[0],
[selectedDirection], [selectedDirection],
); );
const workflowStages = useMemo<WorkflowStage[]>(() => {
const activeIndex = project ? stageMeta.findIndex((item) => item.id === project.stage) : -1;
return stageMeta.map((stage, index) => ({
...stage,
status: activeIndex < 0 ? "pending" : index < activeIndex ? "complete" : index === activeIndex ? "active" : "pending",
}));
}, [project]);
const selectedRegion = useMemo(
() => project?.plan.regions.find((region) => region.id === project.plan.selected_region_id) ?? null,
[project],
);
function applyProject(next: ProjectSnapshot) {
setProject(next);
setLayers(next.plan.layers.map((layer) => ({ ...layer, count: layer.element_count ?? undefined })));
setMessages([
...next.plan.ingestion_notes.map((body, index) => ({
id: `ingestion-${index}`,
role: "assistant" as const,
body,
meta: "图纸解析",
})),
...next.plan.issues.filter((issue) => !issue.resolved).map((issue) => ({
id: `issue-${issue.id}`,
role: "assistant" as const,
body: issue.message,
meta: "等待确认",
})),
]);
}
useEffect(() => { useEffect(() => {
async function loadReadiness() { async function loadReadiness() {
try { try {
@@ -67,8 +107,73 @@ export function WorkflowStudio() {
} }
} }
void loadReadiness(); void loadReadiness();
fetch(`${API_BASE}/v1/projects`, { cache: "no-store" })
.then((response) => response.ok ? response.json() as Promise<ProjectSnapshot[]> : [])
.then((projects) => {
const latest = projects.find((item) => item.project_id !== "demo-apartment");
if (latest) applyProject(latest);
})
.catch(() => undefined);
}, []); }, []);
async function readApiError(response: Response, fallback: string) {
try {
const payload = await response.json() as { detail?: string | { message?: string } };
if (typeof payload.detail === "string") return payload.detail;
if (payload.detail?.message) return payload.detail.message;
} catch {
// Keep the readable fallback.
}
return fallback;
}
async function uploadPlan(file: File) {
if (!readiness?.ready) {
setSettingsOpen(true);
setWorkflowError("请先完成系统设置和真实生图验证。");
return;
}
setUploading(true);
setWorkflowError(null);
const form = new FormData();
form.append("file", file);
form.append("project_name", `${file.name.replace(/\.pdf$/i, "")} 概念方案`);
try {
const response = await fetch(`${API_BASE}/v1/projects/ingest`, { method: "POST", body: form });
if (!response.ok) throw new Error(await readApiError(response, "图纸上传或解析失败。"));
applyProject(await response.json() as ProjectSnapshot);
} catch (error) {
setWorkflowError(error instanceof Error ? error.message : "图纸上传或解析失败。");
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
}
function chooseFile(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (file) void uploadPlan(file);
}
async function selectRegion(regionId: string) {
if (!project || project.stage !== "region_selection") return;
setWorkflowError(null);
const response = await fetch(`${API_BASE}/v1/projects/${project.project_id}/commands`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
command: "select_region",
expected_revision: project.revision,
payload: { region_id: regionId },
}),
});
if (!response.ok) {
setWorkflowError(await readApiError(response, "候选户型选择失败。"));
return;
}
applyProject(await response.json() as ProjectSnapshot);
}
function toggleLayer(id: string) { function toggleLayer(id: string) {
setLayers((items) => setLayers((items) =>
items.map((item) => (item.id === id ? { ...item, visible: !item.visible } : item)), items.map((item) => (item.id === id ? { ...item, visible: !item.visible } : item)),
@@ -102,7 +207,7 @@ export function WorkflowStudio() {
</div> </div>
<div> <div>
<p className="brand-name"></p> <p className="brand-name"></p>
<p className="brand-subtitle">11-2-104 </p> <p className="brand-subtitle">{project?.name ?? "新建住宅概念方案"}</p>
</div> </div>
</div> </div>
@@ -116,12 +221,13 @@ export function WorkflowStudio() {
<button <button
className="button button-primary" className="button button-primary"
type="button" type="button"
disabled={!readiness?.ready} disabled={uploading || Boolean(project)}
onClick={() => { if (!readiness?.ready) setSettingsOpen(true); }} onClick={() => readiness?.ready ? fileInputRef.current?.click() : setSettingsOpen(true)}
title={!readiness?.ready ? "请先完成系统设置" : undefined} title={!readiness?.ready ? "请先完成系统设置" : project ? "当前阶段请在图纸上完成候选区域确认" : undefined}
> >
{readiness?.ready ? "确认结构" : "配置后开始"} <ArrowRightIcon size={16} weight="bold" /> {uploading ? "正在解析" : project ? "按图确认" : readiness?.ready ? "导入图纸" : "配置后开始"} <ArrowRightIcon size={16} weight="bold" />
</button> </button>
<input ref={fileInputRef} className="visually-hidden" type="file" accept="application/pdf,.pdf" onChange={chooseFile} />
</div> </div>
</header> </header>
@@ -129,7 +235,7 @@ export function WorkflowStudio() {
<aside className="workflow-rail" aria-label="设计流程"> <aside className="workflow-rail" aria-label="设计流程">
<div className="rail-heading"> <div className="rail-heading">
<p></p> <p></p>
<span>3 / 8</span> <span>{project ? Math.max(1, stageMeta.findIndex((item) => item.id === project.stage) + 1) : 0} / 8</span>
</div> </div>
<nav className="stage-list"> <nav className="stage-list">
{workflowStages.map((stage) => ( {workflowStages.map((stage) => (
@@ -152,7 +258,11 @@ export function WorkflowStudio() {
<div className="rail-note"> <div className="rail-note">
<WarningCircleIcon size={18} weight={iconWeight} /> <WarningCircleIcon size={18} weight={iconWeight} />
<p> 2 </p> <p>{project
? project.plan.issues.filter((issue) => !issue.resolved).length > 0
? `还有 ${project.plan.issues.filter((issue) => !issue.resolved).length} 项图纸问题需要确认。`
: "当前阶段的信息已确认。"
: "请先导入一份 PDF 户型图。"}</p>
</div> </div>
</aside> </aside>
@@ -180,80 +290,107 @@ export function WorkflowStudio() {
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Portal><Tooltip.Content className="tooltip"></Tooltip.Content></Tooltip.Portal> <Tooltip.Portal><Tooltip.Content className="tooltip"></Tooltip.Content></Tooltip.Portal>
</Tooltip.Root> </Tooltip.Root>
<button className="button button-secondary compact" type="button"> <button className="button button-secondary compact" type="button" onClick={() => fileInputRef.current?.click()} disabled={uploading}>
<UploadSimpleIcon size={16} /> <UploadSimpleIcon size={16} /> {project ? "更换图纸" : "导入图纸"}
</button> </button>
</div> </div>
</div> </div>
<Tabs.Content value="plan" className="tab-content"> <Tabs.Content value="plan" className="tab-content">
<div className="plan-workspace"> {!project ? (
<div className="plan-canvas"> <div className="plan-upload-empty">
<div className="canvas-labels"> <div className="upload-emblem"><UploadSimpleIcon size={30} /></div>
<span></span> <h2>{uploading ? "正在读取和分析图纸" : "导入第一份真实户型图"}</h2>
<span> PDF · 43 CAD </span> <p> 25 MB PDF</p>
</div> <button className="button button-primary" type="button" onClick={() => fileInputRef.current?.click()} disabled={uploading || !readiness?.ready}>
<div className="plan-image-frame"> {uploading ? <CircleNotchIcon className="spin" size={16} /> : <UploadSimpleIcon size={16} />}
<Image {uploading ? "解析中,请稍候" : readiness?.ready ? "选择 PDF 图纸" : "完成设置后导入"}
src="/sample-plan.png" </button>
alt="从真实 PDF 中分离出的住宅建筑结构平面图" {workflowError ? <p className="workflow-error"><WarningCircleIcon size={16} /> {workflowError}</p> : null}
fill
priority
sizes="(max-width: 900px) 100vw, 60vw"
className="plan-image"
/>
<div className="region-outline" aria-hidden="true" />
<button className="issue-marker issue-one" type="button" aria-label="比例待确认">1</button>
<button className="issue-marker issue-two" type="button" aria-label="家具意图待确认">2</button>
</div>
<div className="canvas-status">
<span><CheckIcon size={14} weight="bold" /> </span>
<span></span>
</div>
</div> </div>
) : (
<aside className="layer-panel"> <div className="plan-workspace">
<div className="panel-title-row"> <div className="plan-canvas">
<div> <div className="canvas-labels">
<p className="panel-title"></p> <span>{selectedRegion?.name ?? `${project.plan.regions.length} 个候选平面区域`}</span>
<p className="panel-caption"></p> <span>{project.plan.vector_based ? "矢量 PDF" : "扫描 PDF"} · {project.plan.vector_element_count.toLocaleString()} </span>
</div> </div>
<button className="icon-button small" type="button" aria-label="添加图层"> <div className="plan-document-frame">
<PlusIcon size={16} /> {/* eslint-disable-next-line @next/next/no-img-element -- authenticated runtime preview URL. */}
</button> <img
</div> src={`${API_BASE}/v1/projects/${project.project_id}/preview`}
<div className="layer-list"> alt={`${project.name} 的 PDF 首页预览`}
{layers.map((layer) => ( className="plan-document-image"
<button />
className={`layer-row ${layer.visible ? "is-visible" : ""}`} <div className="region-layer" aria-label="候选户型区域">
key={layer.id} {project.plan.regions.map((region, index) => {
type="button" const [x0, y0, x1, y1] = region.bounds;
onClick={() => toggleLayer(layer.id)} const selected = region.id === project.plan.selected_region_id;
> return (
{layer.visible ? <EyeIcon size={17} /> : <EyeSlashIcon size={17} />} <button
<span>{layer.label}</span> className={`detected-region ${selected ? "selected" : ""} ${region.recommended ? "recommended" : ""}`}
{layer.count ? <small>{layer.count}</small> : null} key={region.id}
</button> type="button"
))} style={{ left: `${x0 * 100}%`, top: `${y0 * 100}%`, width: `${(x1 - x0) * 100}%`, height: `${(y1 - y0) * 100}%` }}
onClick={() => void selectRegion(region.id)}
disabled={project.stage !== "region_selection"}
aria-label={`选择${region.name}`}
>
<span>{index + 1}</span>
<strong>{region.name}</strong>
<small>{region.recommended ? "建议选择 · " : ""}{Math.round(region.confidence * 100)}%</small>
</button>
);
})}
</div>
</div>
<div className="canvas-status">
<span><CheckIcon size={14} weight="bold" /> MinIO</span>
<span>{project.stage === "region_selection" ? "请选择目标户型" : "目标区域已确认"}</span>
</div>
{workflowError ? <p className="workflow-error"><WarningCircleIcon size={16} /> {workflowError}</p> : null}
</div> </div>
<div className="panel-divider" /> <aside className="layer-panel">
<p className="panel-title"></p> <div className="panel-title-row">
<div className="segmented-control"> <div>
<button <p className="panel-title"></p>
type="button" <p className="panel-caption">PDF · {project.plan.page_count} </p>
className={furnitureMode === "reference" ? "active" : ""} </div>
onClick={() => setFurnitureMode("reference")} </div>
></button> <div className="layer-list">
<button {layers.map((layer) => (
type="button" <button
className={furnitureMode === "remove" ? "active" : ""} className={`layer-row ${layer.visible ? "is-visible" : ""}`}
onClick={() => setFurnitureMode("remove")} key={layer.id}
></button> type="button"
</div> onClick={() => toggleLayer(layer.id)}
<p className="helper-text"></p> >
</aside> {layer.visible ? <EyeIcon size={17} /> : <EyeSlashIcon size={17} />}
</div> <span>{layer.label}</span>
{layer.count ? <small>{layer.count.toLocaleString()}</small> : null}
</button>
))}
</div>
<div className="panel-divider" />
<p className="panel-title"></p>
<div className="ingestion-facts">
<span><CheckIcon size={13} /> {project.plan.vector_based ? "矢量信息可用" : "需要视觉分割"}</span>
<span><CheckIcon size={13} /> {project.plan.ocr_used ? "百度 OCR 已参与" : "PDF 文字层可读取"}</span>
<span><WarningCircleIcon size={13} /> </span>
</div>
<div className="panel-divider" />
<p className="panel-title"></p>
<div className="segmented-control">
<button type="button" className={furnitureMode === "reference" ? "active" : ""} onClick={() => setFurnitureMode("reference")}></button>
<button type="button" className={furnitureMode === "remove" ? "active" : ""} onClick={() => setFurnitureMode("remove")}></button>
</div>
<p className="helper-text"></p>
</aside>
</div>
)}
</Tabs.Content> </Tabs.Content>
<Tabs.Content value="blockout" className="tab-content empty-workspace"> <Tabs.Content value="blockout" className="tab-content empty-workspace">
@@ -271,6 +408,12 @@ export function WorkflowStudio() {
<section className="conversation" aria-label="设计对话"> <section className="conversation" aria-label="设计对话">
<div className="message-stream"> <div className="message-stream">
{!project ? (
<article className="message message-assistant">
<span className="assistant-icon"><SparkleIcon size={15} weight="fill" /></span>
<div><small></small><p> PDF </p></div>
</article>
) : null}
{messages.slice(-3).map((message) => ( {messages.slice(-3).map((message) => (
<article className={`message message-${message.role}`} key={message.id}> <article className={`message message-${message.role}`} key={message.id}>
{message.role === "assistant" ? ( {message.role === "assistant" ? (
@@ -290,9 +433,10 @@ export function WorkflowStudio() {
id="design-instruction" id="design-instruction"
value={draft} value={draft}
onChange={(event) => setDraft(event.target.value)} onChange={(event) => setDraft(event.target.value)}
placeholder="例如:保留床和窗的位置,客厅更松弛,不要冷灰" placeholder={project ? "例如:保留床和窗的位置,客厅更松弛,不要冷灰" : "导入图纸后开始对话"}
disabled={!project}
/> />
<button className="send-button" type="submit" aria-label="发送设计指令"> <button className="send-button" type="submit" aria-label="发送设计指令" disabled={!project}>
<PaperPlaneTiltIcon size={18} weight="fill" /> <PaperPlaneTiltIcon size={18} weight="fill" />
</button> </button>
</div> </div>
@@ -311,7 +455,7 @@ export function WorkflowStudio() {
<div className="style-section"> <div className="style-section">
<label htmlFor="style-concept"></label> <label htmlFor="style-concept"></label>
<textarea id="style-concept" defaultValue="温暖、克制、带自然材质感的现代住宅" /> <textarea key={project?.project_id ?? "empty"} id="style-concept" defaultValue={project?.style.concept ?? "导入图纸后建立 Style DNA"} disabled={!project} />
</div> </div>
<div className="style-section"> <div className="style-section">
+68
View File
@@ -19,8 +19,76 @@ export interface WorkflowStage {
export interface PlanLayer { export interface PlanLayer {
id: string; id: string;
label: string; label: string;
category?: string;
visible: boolean; visible: boolean;
count?: number; count?: number;
element_count?: number | null;
source_names?: string[];
}
export interface PlanRegion {
id: string;
name: string;
bounds: [number, number, number, number];
recommended: boolean;
confidence: number;
}
export interface PlanIssue {
id: string;
kind: string;
message: string;
confidence: number;
resolved: boolean;
}
export interface PlanState {
source_name: string;
source_kind: string;
source_object_key?: string | null;
preview_object_key?: string | null;
preview_width?: number | null;
preview_height?: number | null;
page_count: number;
vector_based: boolean;
vector_element_count: number;
cad_layer_count: number;
ocr_used: boolean;
ocr_labels: string[];
ingestion_notes: string[];
regions: PlanRegion[];
selected_region_id?: string | null;
layers: PlanLayer[];
issues: PlanIssue[];
scale_mm_per_unit?: number | null;
ceiling_height_mm: number;
}
export interface ProjectSnapshot {
project_id: string;
name: string;
stage: WorkflowStageId | "failed";
revision: number;
plan: PlanState;
scene: {
blockout_asset_key?: string | null;
cameras: Array<{ id: string; label: string; room: string; status: string }>;
locked_object_ids: string[];
furniture_mode: string;
};
style: {
concept: string;
keywords: string[];
palette: Array<{ name: string; hex: string; role: string }>;
materials: string[];
lighting: string;
forms: string;
density: string;
avoid: string[];
locked_decisions: string[];
};
available_commands: string[];
updated_at: string;
} }
export interface StyleDirection { export interface StyleDirection {
+8
View File
@@ -39,3 +39,11 @@
- `GET /v1/readiness`:供前端决定是否解锁设计工作流。 - `GET /v1/readiness`:供前端决定是否解锁设计工作流。
任何密钥字段都不会出现在 `values` 中,只会在 `configured` 中返回布尔值。必备配置未完成或测试未通过时,上传与命令接口返回 `503``SETTINGS_INCOMPLETE` 任何密钥字段都不会出现在 `values` 中,只会在 `configured` 中返回布尔值。必备配置未完成或测试未通过时,上传与命令接口返回 `503``SETTINGS_INCOMPLETE`
## Project Ingestion
- `GET /v1/projects`:按更新时间返回持久化项目。
- `POST /v1/projects/ingest`:以 `multipart/form-data` 上传 `file`,可选 `project_name`;当前接受 25 MB 以内 PDF。
- `GET /v1/projects/{project_id}/preview`:从 MinIO 派生文件桶读取 PDF 首页预览。
- 新项目初始阶段为 `region_selection``plan.regions` 提供归一化的 `[x0, y0, x1, y1]` 候选区域。
- `select_region` 必须引用真实存在的候选区域 ID,成功后修订号递增并进入 `plan_review`
+11
View File
@@ -30,6 +30,17 @@ uploaded
| `apply_edit` | 执行局部或全局修改 | 只变更受影响的状态与视角 | | `apply_edit` | 执行局部或全局修改 | 只变更受影响的状态与视角 |
| `complete_project` | 完成当前概念方案 | 输出仍标注为概念效果图 | | `complete_project` | 完成当前概念方案 | 输出仍标注为概念效果图 |
## 当前已实现的图纸导入
1. 网页通过 API 上传 PDF,API 同时将原文件保存到 MinIO 的原始图纸桶。
2. PDF 首页被渲染为 PNG 并保存到派生文件桶。
3. 解析器统计矢量线、矩形与曲线数量,判断矢量或扫描来源。
4. 基于页面墨迹连通区域检测一页中的多个平面候选区,不自动把整页当作唯一户型。
5. 用户在预览上明确选择目标区域后,工作流从 `region_selection` 进入 `plan_review`
6. 项目快照、修订号和选择结果写入 PostgreSQL,API 重启后仍可恢复。
第一版限制为 PDF、25 MB、首页候选区域检测;多页选择、墙门窗语义分割与比例求解继续在 `plan_review` 阶段实现。
## Agent 分工 ## Agent 分工
- Orchestrator:确定下一步、追问缺失条件、调度任务、处理失败和回滚。 - Orchestrator:确定下一步、追问缺失条件、调度任务、处理失败和回滚。
+177 -5
View File
@@ -1,23 +1,37 @@
from pathlib import Path
from typing import Any
from uuid import uuid4 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 pydantic import BaseModel
from app.config import Settings, get_settings from app.config import Settings, get_settings
from app.api.settings import require_workflow_ready 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 ( from app.domain.workflow import (
InvalidTransitionError, InvalidTransitionError,
WorkflowConflictError, WorkflowConflictError,
apply_command, apply_command,
available_commands,
workflow_definition, workflow_definition,
) )
from app.integrations.model_router import ModelRouter from app.integrations.model_router import ModelRouter
from app.integrations.ocr import BaiduOcrAdapter
from app.integrations.storage import S3Storage 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.runtime_settings import EncryptedSettingsStore, get_runtime_store
from app.services.plan_ingestion import inspect_pdf
router = APIRouter(prefix="/v1") router = APIRouter(prefix="/v1")
MAX_UPLOAD_BYTES = 25 * 1024 * 1024
class UploadRequest(BaseModel): class UploadRequest(BaseModel):
@@ -25,6 +39,10 @@ class UploadRequest(BaseModel):
content_type: str content_type: str
def get_project_repository(settings: Settings = Depends(get_settings)):
return postgres_repository(settings.database_url)
@router.get("/health") @router.get("/health")
def health( def health(
settings: Settings = Depends(get_settings), settings: Settings = Depends(get_settings),
@@ -61,19 +79,31 @@ def get_workflow() -> WorkflowDefinition:
@router.get("/projects/{project_id}", response_model=ProjectSnapshot) @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) project = repository.get(project_id)
if project is None: if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found.") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found.")
return project 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( @router.post(
"/projects/{project_id}/commands", "/projects/{project_id}/commands",
response_model=ProjectSnapshot, response_model=ProjectSnapshot,
dependencies=[Depends(require_workflow_ready)], 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) project = repository.get(project_id)
if project is None: if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found.") 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) 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)]) @router.post("/uploads/presign", dependencies=[Depends(require_workflow_ready)])
def create_upload( def create_upload(
request: UploadRequest, request: UploadRequest,
+9
View File
@@ -50,6 +50,7 @@ class PlanLayer(BaseModel):
category: str category: str
visible: bool = True visible: bool = True
source_names: list[str] = Field(default_factory=list) source_names: list[str] = Field(default_factory=list)
element_count: int | None = None
class PlanIssue(BaseModel): class PlanIssue(BaseModel):
@@ -63,9 +64,17 @@ class PlanIssue(BaseModel):
class PlanState(BaseModel): class PlanState(BaseModel):
source_name: str source_name: str
source_kind: 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 page_count: int = 1
vector_based: bool = False vector_based: bool = False
vector_element_count: int = 0
cad_layer_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) regions: list[PlanRegion] = Field(default_factory=list)
selected_region_id: str | None = None selected_region_id: str | None = None
layers: list[PlanLayer] = Field(default_factory=list) 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 updated.failure_reason = None
if request.command == WorkflowCommand.SELECT_REGION: 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: elif request.command == WorkflowCommand.CONFIRM_PLAN:
updated.plan.ceiling_height_mm = int( updated.plan.ceiling_height_mm = int(
request.payload.get("ceiling_height_mm", updated.plan.ceiling_height_mm) request.payload.get("ceiling_height_mm", updated.plan.ceiling_height_mm)
+26
View File
@@ -1,5 +1,6 @@
from dataclasses import dataclass from dataclasses import dataclass
from collections.abc import Mapping from collections.abc import Mapping
from io import BytesIO
from typing import Any from typing import Any
import boto3 import boto3
@@ -70,3 +71,28 @@ class S3Storage:
object_key=object_key, object_key=object_key,
expires_in=int(self._value("s3_presigned_url_ttl_seconds", 900)), 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: def get(self, project_id: str) -> ProjectSnapshot | None:
return self._items.get(project_id) 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: def save(self, project: ProjectSnapshot) -> ProjectSnapshot:
self._items[project.project_id] = project self._items[project.project_id] = project
return 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", "cryptography>=44,<47",
"fastapi>=0.115,<1", "fastapi>=0.115,<1",
"httpx>=0.28,<1", "httpx>=0.28,<1",
"pdfplumber>=0.11,<1",
"pillow>=11,<13",
"psycopg[binary]>=3.2,<4", "psycopg[binary]>=3.2,<4",
"pypdfium2>=5,<6",
"pydantic>=2.10,<3", "pydantic>=2.10,<3",
"pydantic-settings>=2.7,<3", "pydantic-settings>=2.7,<3",
"python-multipart>=0.0.20,<1", "python-multipart>=0.0.20,<1",
+4
View File
@@ -3,7 +3,9 @@ import pytest
from pathlib import Path from pathlib import Path
from app.config import Settings from app.config import Settings
from app.api.routes import get_project_repository
from app.main import app from app.main import app
from app.repositories.memory import InMemoryProjectRepository
from app.runtime_settings import EncryptedSettingsStore, get_runtime_store 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", redis_url="redis://:secret@redis:6379/0",
) )
) )
repository = InMemoryProjectRepository()
app.dependency_overrides[get_runtime_store] = lambda: store app.dependency_overrides[get_runtime_store] = lambda: store
app.dependency_overrides[get_project_repository] = lambda: repository
return httpx.ASGITransport(app=app) 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, 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"},
),
)