feat: scaffold agentic interior design workflow
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml ./
|
||||
COPY app ./app
|
||||
|
||||
RUN pip install --no-cache-dir .
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1 @@
|
||||
"""AI interior style studio API."""
|
||||
@@ -0,0 +1 @@
|
||||
"""HTTP API routes."""
|
||||
@@ -0,0 +1,88 @@
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.domain.models import CommandRequest, ProjectSnapshot, WorkflowDefinition
|
||||
from app.domain.workflow import (
|
||||
InvalidTransitionError,
|
||||
WorkflowConflictError,
|
||||
apply_command,
|
||||
workflow_definition,
|
||||
)
|
||||
from app.integrations.model_router import ModelRouter
|
||||
from app.integrations.storage import S3Storage
|
||||
from app.repositories.memory import repository
|
||||
|
||||
router = APIRouter(prefix="/v1")
|
||||
|
||||
|
||||
class UploadRequest(BaseModel):
|
||||
filename: str
|
||||
content_type: str
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health(settings: Settings = Depends(get_settings)) -> dict:
|
||||
router_state = ModelRouter(settings)
|
||||
return {
|
||||
"status": "ok",
|
||||
"environment": settings.app_env,
|
||||
"integrations": {
|
||||
"storage": S3Storage(settings).configured,
|
||||
"baidu_ocr": bool(
|
||||
settings.baidu_ocr_enabled
|
||||
and settings.baidu_ocr_api_key
|
||||
and settings.baidu_ocr_secret_key
|
||||
),
|
||||
"gpu": bool(settings.gpu_service_url and settings.gpu_service_token),
|
||||
"langfuse": bool(
|
||||
settings.langfuse_enabled
|
||||
and settings.langfuse_public_key
|
||||
and settings.langfuse_secret_key
|
||||
),
|
||||
"image_providers": [item.__dict__ for item in router_state.capabilities()],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/workflow", response_model=WorkflowDefinition)
|
||||
def get_workflow() -> WorkflowDefinition:
|
||||
return workflow_definition()
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}", response_model=ProjectSnapshot)
|
||||
def get_project(project_id: str) -> ProjectSnapshot:
|
||||
project = repository.get(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found.")
|
||||
return project
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/commands", response_model=ProjectSnapshot)
|
||||
def execute_command(project_id: str, request: CommandRequest) -> ProjectSnapshot:
|
||||
project = repository.get(project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Project not found.")
|
||||
try:
|
||||
updated = apply_command(project, request)
|
||||
except WorkflowConflictError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
||||
except InvalidTransitionError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
|
||||
return repository.save(updated)
|
||||
|
||||
|
||||
@router.post("/uploads/presign")
|
||||
def create_upload(
|
||||
request: UploadRequest,
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> dict:
|
||||
safe_name = request.filename.replace("/", "_").replace("\\", "_")
|
||||
object_key = f"projects/pending/{uuid4()}/{safe_name}"
|
||||
try:
|
||||
upload = S3Storage(settings).presign_input_upload(object_key, request.content_type)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)) from exc
|
||||
return upload.__dict__
|
||||
@@ -0,0 +1,74 @@
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
app_env: str = "development"
|
||||
app_name: str = "zhuangxiu-api"
|
||||
log_level: str = "INFO"
|
||||
api_host: str = "0.0.0.0"
|
||||
api_port: int = 8000
|
||||
cors_origins: str = "http://localhost:3000"
|
||||
|
||||
database_url: str = "postgresql+psycopg://zhuangxiu:zhuangxiu@localhost:5432/zhuangxiu"
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
|
||||
s3_endpoint: str = ""
|
||||
s3_public_endpoint: str = ""
|
||||
s3_access_key_id: str = ""
|
||||
s3_secret_access_key: str = ""
|
||||
s3_region: str = "us-east-1"
|
||||
s3_bucket_inputs: str = "renovation-inputs"
|
||||
s3_bucket_derived: str = "renovation-derived"
|
||||
s3_bucket_renders: str = "renovation-renders"
|
||||
s3_force_path_style: bool = True
|
||||
s3_presigned_url_ttl_seconds: int = 900
|
||||
|
||||
baidu_ocr_enabled: bool = False
|
||||
baidu_ocr_api_key: str = ""
|
||||
baidu_ocr_secret_key: str = ""
|
||||
baidu_ocr_token_url: str = "https://aip.baidubce.com/oauth/2.0/token"
|
||||
|
||||
llm_default_provider: str = "openai"
|
||||
openai_api_key: str = ""
|
||||
openai_base_url: str = "https://api.openai.com/v1"
|
||||
openai_text_model: str = "gpt-5"
|
||||
openai_image_model: str = "gpt-image-2"
|
||||
gemini_api_key: str = ""
|
||||
gemini_model: str = "gemini-3-pro"
|
||||
ark_api_key: str = ""
|
||||
seedream_model_endpoint: str = ""
|
||||
image_provider_priority: str = "seedream,openai,gemini"
|
||||
|
||||
gpu_service_url: str = "http://localhost:8100"
|
||||
gpu_service_token: str = ""
|
||||
|
||||
jwt_secret: str = ""
|
||||
session_secret: str = ""
|
||||
app_encryption_key: str = ""
|
||||
webhook_signing_secret: str = ""
|
||||
|
||||
langfuse_enabled: bool = False
|
||||
langfuse_host: str = "http://localhost:3001"
|
||||
langfuse_public_key: str = ""
|
||||
langfuse_secret_key: str = ""
|
||||
sentry_dsn: str = ""
|
||||
sentry_environment: str = "development"
|
||||
sentry_traces_sample_rate: float = 0
|
||||
|
||||
@property
|
||||
def cors_origin_list(self) -> list[str]:
|
||||
return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
@@ -0,0 +1 @@
|
||||
"""Domain models and workflow rules."""
|
||||
@@ -0,0 +1,138 @@
|
||||
from datetime import UTC, datetime
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class WorkflowStage(StrEnum):
|
||||
UPLOADED = "uploaded"
|
||||
REGION_SELECTION = "region_selection"
|
||||
PLAN_REVIEW = "plan_review"
|
||||
BLOCKOUT = "blockout"
|
||||
STYLE_BRIEF = "style_brief"
|
||||
DIRECTION_SELECTION = "direction_selection"
|
||||
RENDER_REVIEW = "render_review"
|
||||
EDITING = "editing"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class WorkflowCommand(StrEnum):
|
||||
START_INGESTION = "start_ingestion"
|
||||
SELECT_REGION = "select_region"
|
||||
CONFIRM_PLAN = "confirm_plan"
|
||||
BUILD_BLOCKOUT = "build_blockout"
|
||||
SUBMIT_STYLE_BRIEF = "submit_style_brief"
|
||||
SELECT_DIRECTION = "select_direction"
|
||||
REQUEST_RENDER = "request_render"
|
||||
APPLY_EDIT = "apply_edit"
|
||||
COMPLETE_PROJECT = "complete_project"
|
||||
RETRY = "retry"
|
||||
|
||||
|
||||
class Point2D(BaseModel):
|
||||
x: float
|
||||
y: float
|
||||
|
||||
|
||||
class PlanRegion(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
bounds: list[float] = Field(min_length=4, max_length=4)
|
||||
recommended: bool = False
|
||||
confidence: float = Field(ge=0, le=1)
|
||||
|
||||
|
||||
class PlanLayer(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
category: str
|
||||
visible: bool = True
|
||||
source_names: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PlanIssue(BaseModel):
|
||||
id: str
|
||||
kind: str
|
||||
message: str
|
||||
confidence: float = Field(ge=0, le=1)
|
||||
resolved: bool = False
|
||||
|
||||
|
||||
class PlanState(BaseModel):
|
||||
source_name: str
|
||||
source_kind: str
|
||||
page_count: int = 1
|
||||
vector_based: bool = False
|
||||
cad_layer_count: int = 0
|
||||
regions: list[PlanRegion] = Field(default_factory=list)
|
||||
selected_region_id: str | None = None
|
||||
layers: list[PlanLayer] = Field(default_factory=list)
|
||||
issues: list[PlanIssue] = Field(default_factory=list)
|
||||
scale_mm_per_unit: float | None = None
|
||||
ceiling_height_mm: int = 2800
|
||||
|
||||
|
||||
class CameraView(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
room: str
|
||||
status: str = "draft"
|
||||
|
||||
|
||||
class SceneState(BaseModel):
|
||||
blockout_asset_key: str | None = None
|
||||
cameras: list[CameraView] = Field(default_factory=list)
|
||||
locked_object_ids: list[str] = Field(default_factory=list)
|
||||
furniture_mode: str = "preserve_reference"
|
||||
|
||||
|
||||
class ColorToken(BaseModel):
|
||||
name: str
|
||||
hex: str
|
||||
role: str
|
||||
|
||||
|
||||
class StyleState(BaseModel):
|
||||
concept: str = "克制、自然、明亮的现代住宅"
|
||||
keywords: list[str] = Field(default_factory=lambda: ["轻盈", "自然材质", "留白"])
|
||||
palette: list[ColorToken] = Field(default_factory=list)
|
||||
materials: list[str] = Field(default_factory=list)
|
||||
lighting: str = "低对比度的自然光与柔和间接照明"
|
||||
forms: str = "低矮、水平延展、少量圆角"
|
||||
density: str = "适度留白"
|
||||
avoid: list[str] = Field(default_factory=list)
|
||||
locked_decisions: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ProjectSnapshot(BaseModel):
|
||||
project_id: str
|
||||
name: str
|
||||
stage: WorkflowStage
|
||||
revision: int = 1
|
||||
plan: PlanState
|
||||
scene: SceneState = Field(default_factory=SceneState)
|
||||
style: StyleState = Field(default_factory=StyleState)
|
||||
available_commands: list[WorkflowCommand] = Field(default_factory=list)
|
||||
last_stable_stage: WorkflowStage | None = None
|
||||
failure_reason: str | None = None
|
||||
updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
|
||||
|
||||
class CommandRequest(BaseModel):
|
||||
command: WorkflowCommand
|
||||
expected_revision: int = Field(ge=1)
|
||||
payload: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class WorkflowStageDefinition(BaseModel):
|
||||
id: WorkflowStage
|
||||
label: str
|
||||
purpose: str
|
||||
human_confirmation: bool
|
||||
|
||||
|
||||
class WorkflowDefinition(BaseModel):
|
||||
stages: list[WorkflowStageDefinition]
|
||||
transitions: dict[str, list[str]]
|
||||
@@ -0,0 +1,127 @@
|
||||
from copy import deepcopy
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.domain.models import (
|
||||
CommandRequest,
|
||||
ProjectSnapshot,
|
||||
WorkflowCommand,
|
||||
WorkflowDefinition,
|
||||
WorkflowStage,
|
||||
WorkflowStageDefinition,
|
||||
)
|
||||
|
||||
|
||||
class WorkflowConflictError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidTransitionError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
TRANSITIONS: dict[WorkflowStage, dict[WorkflowCommand, WorkflowStage]] = {
|
||||
WorkflowStage.UPLOADED: {
|
||||
WorkflowCommand.START_INGESTION: WorkflowStage.REGION_SELECTION,
|
||||
},
|
||||
WorkflowStage.REGION_SELECTION: {
|
||||
WorkflowCommand.SELECT_REGION: WorkflowStage.PLAN_REVIEW,
|
||||
},
|
||||
WorkflowStage.PLAN_REVIEW: {
|
||||
WorkflowCommand.CONFIRM_PLAN: WorkflowStage.BLOCKOUT,
|
||||
},
|
||||
WorkflowStage.BLOCKOUT: {
|
||||
WorkflowCommand.BUILD_BLOCKOUT: WorkflowStage.STYLE_BRIEF,
|
||||
},
|
||||
WorkflowStage.STYLE_BRIEF: {
|
||||
WorkflowCommand.SUBMIT_STYLE_BRIEF: WorkflowStage.DIRECTION_SELECTION,
|
||||
},
|
||||
WorkflowStage.DIRECTION_SELECTION: {
|
||||
WorkflowCommand.SELECT_DIRECTION: WorkflowStage.RENDER_REVIEW,
|
||||
},
|
||||
WorkflowStage.RENDER_REVIEW: {
|
||||
WorkflowCommand.REQUEST_RENDER: WorkflowStage.EDITING,
|
||||
WorkflowCommand.COMPLETE_PROJECT: WorkflowStage.COMPLETED,
|
||||
},
|
||||
WorkflowStage.EDITING: {
|
||||
WorkflowCommand.APPLY_EDIT: WorkflowStage.EDITING,
|
||||
WorkflowCommand.REQUEST_RENDER: WorkflowStage.EDITING,
|
||||
WorkflowCommand.COMPLETE_PROJECT: WorkflowStage.COMPLETED,
|
||||
},
|
||||
WorkflowStage.FAILED: {
|
||||
WorkflowCommand.RETRY: WorkflowStage.PLAN_REVIEW,
|
||||
},
|
||||
WorkflowStage.COMPLETED: {
|
||||
WorkflowCommand.APPLY_EDIT: WorkflowStage.EDITING,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def available_commands(stage: WorkflowStage) -> list[WorkflowCommand]:
|
||||
return list(TRANSITIONS.get(stage, {}).keys())
|
||||
|
||||
|
||||
def apply_command(project: ProjectSnapshot, request: CommandRequest) -> ProjectSnapshot:
|
||||
if request.expected_revision != project.revision:
|
||||
raise WorkflowConflictError(
|
||||
f"Expected revision {request.expected_revision}, current revision is {project.revision}."
|
||||
)
|
||||
|
||||
target = TRANSITIONS.get(project.stage, {}).get(request.command)
|
||||
if target is None:
|
||||
raise InvalidTransitionError(
|
||||
f"Command '{request.command}' is not allowed while project is in '{project.stage}'."
|
||||
)
|
||||
|
||||
updated = deepcopy(project)
|
||||
updated.last_stable_stage = project.stage
|
||||
updated.stage = target
|
||||
updated.revision += 1
|
||||
updated.updated_at = datetime.now(UTC)
|
||||
updated.failure_reason = None
|
||||
|
||||
if request.command == WorkflowCommand.SELECT_REGION:
|
||||
updated.plan.selected_region_id = request.payload.get("region_id")
|
||||
elif request.command == WorkflowCommand.CONFIRM_PLAN:
|
||||
updated.plan.ceiling_height_mm = int(
|
||||
request.payload.get("ceiling_height_mm", updated.plan.ceiling_height_mm)
|
||||
)
|
||||
elif request.command == WorkflowCommand.SUBMIT_STYLE_BRIEF:
|
||||
for field in ("concept", "lighting", "forms", "density"):
|
||||
if field in request.payload:
|
||||
setattr(updated.style, field, request.payload[field])
|
||||
for field in ("keywords", "materials", "avoid", "locked_decisions"):
|
||||
if field in request.payload:
|
||||
setattr(updated.style, field, list(request.payload[field]))
|
||||
|
||||
updated.available_commands = available_commands(updated.stage)
|
||||
return updated
|
||||
|
||||
|
||||
def workflow_definition() -> WorkflowDefinition:
|
||||
labels = {
|
||||
WorkflowStage.UPLOADED: ("文件已上传", "验证文件并准备解析", False),
|
||||
WorkflowStage.REGION_SELECTION: ("选择户型", "从复杂图纸中选择目标平面区域", True),
|
||||
WorkflowStage.PLAN_REVIEW: ("确认结构", "检查墙、门窗、比例和保留家具", True),
|
||||
WorkflowStage.BLOCKOUT: ("生成白模", "建立空间骨架、家具占位和相机", False),
|
||||
WorkflowStage.STYLE_BRIEF: ("明确风格", "形成结构化 Style DNA", True),
|
||||
WorkflowStage.DIRECTION_SELECTION: ("选择方向", "比较并锁定主设计方向", True),
|
||||
WorkflowStage.RENDER_REVIEW: ("审阅效果", "检查空间忠实度与审美一致性", True),
|
||||
WorkflowStage.EDITING: ("多轮修改", "通过结构化操作局部修改", True),
|
||||
WorkflowStage.COMPLETED: ("方案完成", "导出概念方案与版本记录", False),
|
||||
WorkflowStage.FAILED: ("需要处理", "展示错误并从稳定阶段恢复", True),
|
||||
}
|
||||
return WorkflowDefinition(
|
||||
stages=[
|
||||
WorkflowStageDefinition(
|
||||
id=stage,
|
||||
label=labels[stage][0],
|
||||
purpose=labels[stage][1],
|
||||
human_confirmation=labels[stage][2],
|
||||
)
|
||||
for stage in WorkflowStage
|
||||
],
|
||||
transitions={
|
||||
stage.value: [command.value for command in commands]
|
||||
for stage, commands in TRANSITIONS.items()
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""External service adapters."""
|
||||
@@ -0,0 +1,49 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
|
||||
def _configured(value: str) -> bool:
|
||||
return bool(value) and not value.startswith("replace_with_")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderCapability:
|
||||
provider: str
|
||||
configured: bool
|
||||
strengths: tuple[str, ...]
|
||||
|
||||
|
||||
class ModelRouter:
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self.settings = settings
|
||||
|
||||
def capabilities(self) -> list[ProviderCapability]:
|
||||
return [
|
||||
ProviderCapability(
|
||||
provider="seedream",
|
||||
configured=_configured(self.settings.ark_api_key)
|
||||
and bool(self.settings.seedream_model_endpoint),
|
||||
strengths=("中文风格指令", "快速方向探索"),
|
||||
),
|
||||
ProviderCapability(
|
||||
provider="openai",
|
||||
configured=_configured(self.settings.openai_api_key),
|
||||
strengths=("局部编辑", "多轮一致性", "遮罩修改"),
|
||||
),
|
||||
ProviderCapability(
|
||||
provider="gemini",
|
||||
configured=_configured(self.settings.gemini_api_key),
|
||||
strengths=("多参考图理解", "复杂视觉指令"),
|
||||
),
|
||||
]
|
||||
|
||||
def choose_image_provider(self, task: str) -> str:
|
||||
configured = {item.provider for item in self.capabilities() if item.configured}
|
||||
priorities = [item.strip() for item in self.settings.image_provider_priority.split(",")]
|
||||
if task == "masked_edit" and "openai" in configured:
|
||||
return "openai"
|
||||
for provider in priorities:
|
||||
if provider in configured:
|
||||
return provider
|
||||
raise RuntimeError("No image provider is configured.")
|
||||
@@ -0,0 +1,51 @@
|
||||
import base64
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
|
||||
class BaiduOcrAdapter:
|
||||
OCR_URL = "https://aip.baidubce.com/rest/2.0/ocr/v1/accurate_basic"
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self.settings = settings
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(
|
||||
self.settings.baidu_ocr_enabled
|
||||
and self.settings.baidu_ocr_api_key
|
||||
and self.settings.baidu_ocr_secret_key
|
||||
)
|
||||
|
||||
async def _access_token(self) -> str:
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
response = await client.post(
|
||||
self.settings.baidu_ocr_token_url,
|
||||
params={
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": self.settings.baidu_ocr_api_key,
|
||||
"client_secret": self.settings.baidu_ocr_secret_key,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()["access_token"]
|
||||
|
||||
async def recognize(self, image_bytes: bytes) -> list[dict]:
|
||||
if not self.configured:
|
||||
raise RuntimeError("Baidu OCR is not configured or enabled.")
|
||||
token = await self._access_token()
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
response = await client.post(
|
||||
self.OCR_URL,
|
||||
params={"access_token": token},
|
||||
data={
|
||||
"image": base64.b64encode(image_bytes).decode("ascii"),
|
||||
"detect_direction": "true",
|
||||
"paragraph": "false",
|
||||
},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json().get("words_result", [])
|
||||
@@ -0,0 +1,65 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
import boto3
|
||||
from botocore.client import Config
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PresignedUpload:
|
||||
method: str
|
||||
url: str
|
||||
object_key: str
|
||||
expires_in: int
|
||||
|
||||
|
||||
class S3Storage:
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self.settings = settings
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
values = (
|
||||
self.settings.s3_endpoint,
|
||||
self.settings.s3_access_key_id,
|
||||
self.settings.s3_secret_access_key,
|
||||
)
|
||||
return all(values) and not any(value.startswith("replace_with_") for value in values)
|
||||
|
||||
def _client(self, public: bool = False):
|
||||
endpoint = (
|
||||
self.settings.s3_public_endpoint
|
||||
if public and self.settings.s3_public_endpoint
|
||||
else self.settings.s3_endpoint
|
||||
)
|
||||
return boto3.client(
|
||||
"s3",
|
||||
endpoint_url=endpoint,
|
||||
aws_access_key_id=self.settings.s3_access_key_id,
|
||||
aws_secret_access_key=self.settings.s3_secret_access_key,
|
||||
region_name=self.settings.s3_region,
|
||||
config=Config(
|
||||
signature_version="s3v4",
|
||||
s3={"addressing_style": "path" if self.settings.s3_force_path_style else "auto"},
|
||||
),
|
||||
)
|
||||
|
||||
def presign_input_upload(self, object_key: str, content_type: str) -> PresignedUpload:
|
||||
if not self.configured:
|
||||
raise RuntimeError("MinIO/S3 is not configured.")
|
||||
url = self._client(public=True).generate_presigned_url(
|
||||
"put_object",
|
||||
Params={
|
||||
"Bucket": self.settings.s3_bucket_inputs,
|
||||
"Key": object_key,
|
||||
"ContentType": content_type,
|
||||
},
|
||||
ExpiresIn=self.settings.s3_presigned_url_ttl_seconds,
|
||||
)
|
||||
return PresignedUpload(
|
||||
method="PUT",
|
||||
url=url,
|
||||
object_key=object_key,
|
||||
expires_in=self.settings.s3_presigned_url_ttl_seconds,
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.routes import router
|
||||
from app.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
app = FastAPI(
|
||||
title="AI 空间风格工作台 API",
|
||||
version="0.1.0",
|
||||
description="户型清洗、3D 白模、Style DNA 与多轮编辑的工作流调度接口。",
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origin_list,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.include_router(router)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root() -> dict[str, str]:
|
||||
return {"service": settings.app_name, "docs": "/docs"}
|
||||
@@ -0,0 +1 @@
|
||||
"""Persistence ports and development repositories."""
|
||||
@@ -0,0 +1,131 @@
|
||||
from app.domain.models import (
|
||||
CameraView,
|
||||
ColorToken,
|
||||
PlanIssue,
|
||||
PlanLayer,
|
||||
PlanRegion,
|
||||
PlanState,
|
||||
ProjectSnapshot,
|
||||
SceneState,
|
||||
StyleState,
|
||||
WorkflowStage,
|
||||
)
|
||||
from app.domain.workflow import available_commands
|
||||
|
||||
|
||||
def create_demo_project() -> ProjectSnapshot:
|
||||
stage = WorkflowStage.PLAN_REVIEW
|
||||
project = ProjectSnapshot(
|
||||
project_id="demo-apartment",
|
||||
name="11-2-104 住宅概念方案",
|
||||
stage=stage,
|
||||
revision=3,
|
||||
plan=PlanState(
|
||||
source_name="11-2-104-模型.pdf",
|
||||
source_kind="vector_pdf",
|
||||
vector_based=True,
|
||||
cad_layer_count=43,
|
||||
regions=[
|
||||
PlanRegion(
|
||||
id="residence-upper",
|
||||
name="上方住宅户型",
|
||||
bounds=[0.08, 0.05, 0.92, 0.58],
|
||||
recommended=True,
|
||||
confidence=0.97,
|
||||
),
|
||||
PlanRegion(
|
||||
id="common-lower",
|
||||
name="下方公共区域",
|
||||
bounds=[0.12, 0.61, 0.88, 0.94],
|
||||
confidence=0.91,
|
||||
),
|
||||
],
|
||||
selected_region_id="residence-upper",
|
||||
layers=[
|
||||
PlanLayer(
|
||||
id="architecture",
|
||||
label="建筑结构",
|
||||
category="structure",
|
||||
source_names=["A-墙线", "WALL", "S-COLUMN"],
|
||||
),
|
||||
PlanLayer(
|
||||
id="openings",
|
||||
label="门窗",
|
||||
category="openings",
|
||||
source_names=["WINDOW", "A-普通门窗", "A-防火门窗"],
|
||||
),
|
||||
PlanLayer(
|
||||
id="furniture",
|
||||
label="原家具",
|
||||
category="furniture",
|
||||
source_names=["FF-FURN", "C-Chen_活动家具"],
|
||||
),
|
||||
PlanLayer(
|
||||
id="dimensions",
|
||||
label="尺寸标注",
|
||||
category="annotation",
|
||||
visible=False,
|
||||
source_names=["B-标注", "DIM_SYMB"],
|
||||
),
|
||||
PlanLayer(
|
||||
id="labels",
|
||||
label="房间文字",
|
||||
category="annotation",
|
||||
visible=False,
|
||||
source_names=["A-房间名称文字", "W-文字"],
|
||||
),
|
||||
],
|
||||
issues=[
|
||||
PlanIssue(
|
||||
id="scale-check",
|
||||
kind="scale",
|
||||
message="多个尺寸标注需要交叉确认比例",
|
||||
confidence=0.82,
|
||||
),
|
||||
PlanIssue(
|
||||
id="furniture-intent",
|
||||
kind="intent",
|
||||
message="请确认原家具是保留布局、参考方案还是噪声",
|
||||
confidence=1,
|
||||
),
|
||||
],
|
||||
ceiling_height_mm=2800,
|
||||
),
|
||||
scene=SceneState(
|
||||
cameras=[
|
||||
CameraView(id="living-entry", label="客厅入口", room="客厅"),
|
||||
CameraView(id="living-diagonal", label="客厅对角", room="客厅"),
|
||||
CameraView(id="dining-focus", label="餐厨主景", room="餐厅"),
|
||||
]
|
||||
),
|
||||
style=StyleState(
|
||||
concept="温暖、克制、带自然材质感的现代住宅",
|
||||
keywords=["清透", "低饱和", "自然纹理", "松弛"],
|
||||
palette=[
|
||||
ColorToken(name="雾白", hex="#E8E9E4", role="base"),
|
||||
ColorToken(name="浅橡木", hex="#B9A68A", role="secondary"),
|
||||
ColorToken(name="松针绿", hex="#50665C", role="accent"),
|
||||
],
|
||||
materials=["哑光涂料", "浅橡木", "亚麻", "浅色洞石"],
|
||||
avoid=["大面积冷灰", "高亮岩板", "过量灯带"],
|
||||
locked_decisions=["保留主要窗洞", "不改变客餐厅关系"],
|
||||
),
|
||||
)
|
||||
project.available_commands = available_commands(stage)
|
||||
return project
|
||||
|
||||
|
||||
class InMemoryProjectRepository:
|
||||
def __init__(self) -> None:
|
||||
demo = create_demo_project()
|
||||
self._items = {demo.project_id: demo}
|
||||
|
||||
def get(self, project_id: str) -> ProjectSnapshot | None:
|
||||
return self._items.get(project_id)
|
||||
|
||||
def save(self, project: ProjectSnapshot) -> ProjectSnapshot:
|
||||
self._items[project.project_id] = project
|
||||
return project
|
||||
|
||||
|
||||
repository = InMemoryProjectRepository()
|
||||
@@ -0,0 +1,32 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=75", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "zhuangxiu-api"
|
||||
version = "0.1.0"
|
||||
description = "Workflow orchestrator for the AI interior style studio"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"boto3>=1.35,<2",
|
||||
"fastapi>=0.115,<1",
|
||||
"httpx>=0.28,<1",
|
||||
"pydantic>=2.10,<3",
|
||||
"pydantic-settings>=2.7,<3",
|
||||
"python-multipart>=0.0.20,<1",
|
||||
"uvicorn[standard]>=0.34,<1"
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.3,<9",
|
||||
"pytest-asyncio>=0.25,<1"
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["app*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["."]
|
||||
testpaths = ["tests"]
|
||||
@@ -0,0 +1,27 @@
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def transport() -> httpx.ASGITransport:
|
||||
return httpx.ASGITransport(app=app)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health(transport: httpx.ASGITransport) -> None:
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/v1/health")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "ok"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_demo_project_contract(transport: httpx.ASGITransport) -> None:
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.get("/v1/projects/demo-apartment")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["stage"] == "plan_review"
|
||||
assert body["plan"]["cad_layer_count"] == 43
|
||||
@@ -0,0 +1,45 @@
|
||||
import pytest
|
||||
|
||||
from app.domain.models import CommandRequest, WorkflowCommand, WorkflowStage
|
||||
from app.domain.workflow import InvalidTransitionError, WorkflowConflictError, apply_command
|
||||
from app.repositories.memory import create_demo_project
|
||||
|
||||
|
||||
def test_confirm_plan_advances_revision_and_stage() -> None:
|
||||
project = create_demo_project()
|
||||
updated = apply_command(
|
||||
project,
|
||||
CommandRequest(
|
||||
command=WorkflowCommand.CONFIRM_PLAN,
|
||||
expected_revision=project.revision,
|
||||
payload={"ceiling_height_mm": 2900},
|
||||
),
|
||||
)
|
||||
|
||||
assert updated.stage == WorkflowStage.BLOCKOUT
|
||||
assert updated.revision == project.revision + 1
|
||||
assert updated.plan.ceiling_height_mm == 2900
|
||||
|
||||
|
||||
def test_stale_revision_is_rejected() -> None:
|
||||
project = create_demo_project()
|
||||
with pytest.raises(WorkflowConflictError):
|
||||
apply_command(
|
||||
project,
|
||||
CommandRequest(
|
||||
command=WorkflowCommand.CONFIRM_PLAN,
|
||||
expected_revision=project.revision - 1,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_invalid_command_is_rejected() -> None:
|
||||
project = create_demo_project()
|
||||
with pytest.raises(InvalidTransitionError):
|
||||
apply_command(
|
||||
project,
|
||||
CommandRequest(
|
||||
command=WorkflowCommand.REQUEST_RENDER,
|
||||
expected_revision=project.revision,
|
||||
),
|
||||
)
|
||||
Reference in New Issue
Block a user