"""镜树 API(api-contracts.md §3.3)。树级操作挂 /trees,节点级挂 /nodes 与 /citations。""" from __future__ import annotations from fastapi import APIRouter, Depends, Query, Request from pydantic import BaseModel, Field from app.api.v1.deps import get_current_user from app.core.errors import AppError from app.domains import mirror_tree from app.kernel import Kernel from app.schemas.auth import CurrentUser router = APIRouter(prefix="/trees", tags=["mirror-tree"]) node_router = APIRouter(prefix="/nodes", tags=["mirror-tree"]) class TreeCreate(BaseModel): character_id: str title: str = Field(min_length=1, max_length=300) default_realm: str = "reality" class TreePatch(BaseModel): title: str | None = Field(default=None, min_length=1, max_length=300) status: str | None = Field(default=None, description="active / archived") class QuestionCreate(BaseModel): content: str = Field(min_length=1, max_length=20_000) realm: str | None = None parent_answer_id: str | None = None citations: list[dict] = Field(default_factory=list) class AnswerRequest(BaseModel): instructions: str | None = Field(default=None, max_length=8000) class AdoptRequest(BaseModel): node_id: str class CanonizeRequest(BaseModel): endpoint_node_id: str canonical_scope: str world_id: str | None = None class EditForkRequest(BaseModel): new_content: str = Field(min_length=1, max_length=20_000) class TransplantRequest(BaseModel): node_id: str new_title: str = Field(min_length=1, max_length=300) class CitationCreate(BaseModel): target_type: str = "text_range" locator: dict = Field(default_factory=dict) snapshot: str = "" source_id: str = Field(min_length=1, max_length=200) source_version: int = 1 def _get_kernel(request: Request) -> Kernel: kernel: Kernel | None = getattr(request.app.state, "kernel", None) if kernel is None: raise AppError("service_unavailable", "Kernel 运行时未就绪。", status_code=503) return kernel @router.get("") async def list_trees_api( character_id: str | None = Query(default=None), user: CurrentUser = Depends(get_current_user), ) -> list[dict]: return [mirror_tree.tree_view(t) for t in await mirror_tree.list_trees(user.owner_scope, character_id)] @router.post("", status_code=201) async def create_tree_api( payload: TreeCreate, user: CurrentUser = Depends(get_current_user) ) -> dict: tree = await mirror_tree.create_tree( character_id=payload.character_id, owner_scope=user.owner_scope, title=payload.title, default_realm=payload.default_realm, ) return mirror_tree.tree_view(tree) @router.get("/{tree_id}") async def get_tree_api(tree_id: str, user: CurrentUser = Depends(get_current_user)) -> dict: return await mirror_tree.get_tree_view(tree_id, user.owner_scope) @router.patch("/{tree_id}") async def patch_tree_api( tree_id: str, payload: TreePatch, user: CurrentUser = Depends(get_current_user) ) -> dict: if payload.status == "archived": return await mirror_tree.archive_tree(tree_id, user.owner_scope) if payload.title is not None: return await mirror_tree.rename_tree(tree_id, user.owner_scope, payload.title) return await mirror_tree.get_tree_view(tree_id, user.owner_scope) @router.get("/{tree_id}/path") async def tree_path_api( tree_id: str, entry: str | None = Query(default=None), user: CurrentUser = Depends(get_current_user), ) -> dict: return await mirror_tree.path_view(tree_id, user.owner_scope, entry) @router.get("/{tree_id}/local-graph") async def local_graph_api( tree_id: str, node: str | None = Query(default=None), depth: int = Query(default=2, ge=0, le=6), user: CurrentUser = Depends(get_current_user), ) -> dict: return await mirror_tree.local_graph(tree_id, user.owner_scope, node, depth) @router.post("/{tree_id}/nodes", status_code=201) async def create_question_api( tree_id: str, payload: QuestionCreate, user: CurrentUser = Depends(get_current_user) ) -> dict: node = await mirror_tree.create_question( tree_id=tree_id, owner_scope=user.owner_scope, content=payload.content, realm=payload.realm, parent_answer_id=payload.parent_answer_id, citations=payload.citations, ) return mirror_tree.node_view(node) @router.post("/{tree_id}/adopt") async def adopt_api( tree_id: str, payload: AdoptRequest, user: CurrentUser = Depends(get_current_user) ) -> dict: return await mirror_tree.adopt(tree_id=tree_id, owner_scope=user.owner_scope, node_id=payload.node_id) @router.post("/{tree_id}/canonize") async def canonize_api( tree_id: str, payload: CanonizeRequest, user: CurrentUser = Depends(get_current_user) ) -> dict: return await mirror_tree.canonize( tree_id=tree_id, owner_scope=user.owner_scope, endpoint_node_id=payload.endpoint_node_id, scope=payload.canonical_scope, world_id=payload.world_id, ) @router.post("/{tree_id}/transplant", status_code=201) async def transplant_api( tree_id: str, payload: TransplantRequest, user: CurrentUser = Depends(get_current_user) ) -> dict: return await mirror_tree.transplant( tree_id=tree_id, owner_scope=user.owner_scope, node_id=payload.node_id, new_title=payload.new_title, ) # ── 节点级 ────────────────────────────────────────────── @node_router.post("/{node_id}/answers", status_code=202) async def spawn_answer_api( node_id: str, payload: AnswerRequest, request: Request, user: CurrentUser = Depends(get_current_user), ) -> dict: result = await mirror_tree.spawn_answer( question_id=node_id, owner_scope=user.owner_scope, kernel=_get_kernel(request), instructions=payload.instructions, ) return result @node_router.post("/{node_id}/continue", status_code=202) async def continue_api( node_id: str, payload: AnswerRequest, request: Request, user: CurrentUser = Depends(get_current_user), ) -> dict: return await mirror_tree.continue_node( node_id=node_id, owner_scope=user.owner_scope, kernel=_get_kernel(request), instructions=payload.instructions, ) @node_router.post("/{node_id}/resume", status_code=202) async def resume_api( node_id: str, payload: AnswerRequest, request: Request, user: CurrentUser = Depends(get_current_user), ) -> dict: return await mirror_tree.resume_node( node_id=node_id, owner_scope=user.owner_scope, kernel=_get_kernel(request), instructions=payload.instructions, ) @node_router.post("/{node_id}/edit-fork", status_code=201) async def edit_fork_api( node_id: str, payload: EditForkRequest, user: CurrentUser = Depends(get_current_user) ) -> dict: return await mirror_tree.edit_fork( node_id=node_id, owner_scope=user.owner_scope, new_content=payload.new_content ) @node_router.post("/{node_id}/citations", status_code=201) async def create_citation_api( node_id: str, payload: CitationCreate, user: CurrentUser = Depends(get_current_user) ) -> dict: return await mirror_tree.create_citation( node_id=node_id, owner_scope=user.owner_scope, data=payload.model_dump() ) @node_router.get("/{node_id}/citations") async def list_citations_api( node_id: str, user: CurrentUser = Depends(get_current_user) ) -> list[dict]: return await mirror_tree.list_citations(node_id, user.owner_scope)