Files

178 lines
7.9 KiB
Python

"""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 or metadata.get("analysis_project_mode") != project["mode"] or metadata.get("analysis_project_name") != project["name"]:
metadata = {
**metadata,
"system": False,
"analysis_project_id": project_id,
"analysis_project_mode": project["mode"],
"analysis_project_name": project["name"],
}
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,
"analysis_project_mode": project["mode"],
"analysis_project_name": project["name"],
"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