178 lines
7.9 KiB
Python
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
|