feat: add real PDF ingestion workflow
This commit is contained in:
@@ -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)。
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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`。
|
||||||
|
|||||||
@@ -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:确定下一步、追问缺失条件、调度任务、处理失败和回滚。
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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},
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
"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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
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