"""Project CRUD and project-owned conversations (mode chosen once).""" from __future__ import annotations import asyncio from typing import Annotated, Literal from uuid import uuid4 from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel, ConfigDict, Field, StringConstraints, field_validator from app.gateway.analysis_projects import actor, owned_project, project_store, validate_knowledge, validate_project_agent from app.gateway.deps import get_checkpointer, get_thread_store from app.gateway.routers.threads import ThreadCreateRequest, create_thread from deerflow.agents.analysis_project import SOLUTION_OUTLINE, SOLUTION_TEMPLATE_ID router = APIRouter(prefix="/api/analysis-projects", tags=["analysis-projects"]) KnowledgeId = Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=128)] DEFAULT_STRATEGY_LENSES = ["增长视角", "风险视角", "竞争视角", "资源视角", "长期价值视角"] class ProjectReportConfig(BaseModel): model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) custom_outline: str = Field(default="", max_length=60000) structure_mode: Literal["fixed", "adaptive"] = "fixed" retrieval_directions: list[Annotated[str, StringConstraints(max_length=200)]] = Field(default_factory=list, max_length=12) retrieval_skills: list[ Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=64, pattern=r"^\S+$")] ] = Field(default_factory=list, max_length=8) class ProjectStrategyConfig(BaseModel): model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) lenses: list[Annotated[str, StringConstraints(strip_whitespace=True, min_length=1, max_length=40)]] = Field( default_factory=lambda: list(DEFAULT_STRATEGY_LENSES), min_length=1, max_length=10 ) @field_validator("lenses") @classmethod def unique_lenses(cls, value: list[str]) -> list[str]: return list(dict.fromkeys(value)) class ProjectFields(BaseModel): model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) name: str = Field(min_length=1, max_length=160) background: str = Field(default="", max_length=12000) agent_id: KnowledgeId | None = None report_config: ProjectReportConfig = Field(default_factory=ProjectReportConfig) strategy_config: ProjectStrategyConfig = Field(default_factory=ProjectStrategyConfig) knowledge_base_ids: list[KnowledgeId] = Field(default_factory=list, max_length=30) assistant_knowledge_base_ids: list[KnowledgeId] = Field(default_factory=list, max_length=30) @field_validator("knowledge_base_ids", "assistant_knowledge_base_ids") @classmethod def unique_ids(cls, value: list[str]) -> list[str]: return list(dict.fromkeys(value)) class ProjectCreate(ProjectFields): mode: Literal["qa", "strategy", "solution"] def public_project(project: dict) -> dict: return { **{key: value for key, value in project.items() if key != "user_id"}, "solution_template": { "id": SOLUTION_TEMPLATE_ID, "name": "当前项目报告结构", "outline": (project.get("report_config") or {}).get("custom_outline") or SOLUTION_OUTLINE, } if project["mode"] == "solution" else None, } @router.get("") async def list_projects(request: Request): user_id, _ = await actor(request) return {"projects": [public_project(p) for p in await project_store(request).list(user_id)]} @router.post("", status_code=201) async def new_project(body: ProjectCreate, request: Request): user_id, _ = await actor(request) await validate_knowledge(request, body.model_dump()) await validate_project_agent(request, body.agent_id) return public_project(await project_store(request).create(user_id, **body.model_dump())) @router.get("/{project_id}") async def get_project(project_id: str, request: Request): return public_project(await owned_project(request, project_id)) @router.put("/{project_id}") async def update_project(project_id: str, body: ProjectFields, request: Request): project = await owned_project(request, project_id) await validate_knowledge(request, {**project, **body.model_dump()}) await validate_project_agent(request, body.agent_id) updated = await project_store(request).update(project_id, project["user_id"], **body.model_dump()) if updated is None: raise HTTPException(404, "项目不存在") return public_project(updated) @router.get("/{project_id}/threads") async def project_threads(project_id: str, request: Request): project = await owned_project(request, project_id) ids = await project_store(request).thread_ids(project_id, project["user_id"]) threads = [] for thread_id in ids: row = await get_thread_store(request).get(thread_id, user_id=project["user_id"]) if row: metadata = row.get("metadata") or {} if metadata.get("system") or metadata.get("analysis_project_id") != project_id: metadata = {**metadata, "system": False, "analysis_project_id": project_id} await get_thread_store(request).update_metadata(thread_id, metadata, user_id=project["user_id"]) row = {**row, "metadata": metadata} threads.append({**row, "title": row.get("display_name") or (row.get("values") or {}).get("title") or "新对话"}) threads.sort(key=lambda row: str(row.get("updated_at") or ""), reverse=True) return {"threads": threads} class ProjectThreadCreate(BaseModel): model_config = ConfigDict(extra="forbid") agent_id: KnowledgeId | None = None @router.post("/{project_id}/threads", status_code=201) async def new_project_thread(project_id: str, request: Request, body: ProjectThreadCreate | None = None): project = await owned_project(request, project_id) store = project_store(request) thread_id = str(uuid4()) agent_id = body.agent_id if body and "agent_id" in body.model_fields_set else project.get("agent_id") await validate_project_agent(request, agent_id) await store.bind_thread(project_id, project["user_id"], thread_id, agent_id=agent_id) try: # Binding is server-only; this endpoint never accepts an arbitrary # existing thread id, so conversations cannot be moved between projects. body = ThreadCreateRequest(thread_id=thread_id, assistant_id=agent_id or "lead_agent", metadata={"thread_type": "analysis_project", "system": False, "analysis_project_id": project_id, "agent_id": agent_id}) result = await create_thread(body=body, request=request) except BaseException: async def cleanup(): await get_thread_store(request).delete(thread_id) await get_checkpointer(request).adelete_thread(thread_id) await store.unbind_failed_creation(thread_id) await asyncio.shield(cleanup()) raise return result @router.get("/{project_id}/threads/{thread_id}") async def get_project_thread(project_id: str, thread_id: str, request: Request): project = await owned_project(request, project_id) bound = await project_store(request).project_for_thread(thread_id) if not bound or bound["id"] != project["id"]: raise HTTPException(404, "该对话不属于当前项目") row = await get_thread_store(request).get(thread_id, user_id=project["user_id"]) if not row: raise HTTPException(404, "对话不存在") return row