"""记忆域路由(WP-04)。""" from typing import Annotated, Any from fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException, Request, status from pydantic import BaseModel, ConfigDict, Field from app.auth.dependencies import get_current_user, require_state_change from app.characters.service import require_character from app.conversations.routes import _domain_of from app.db.session import DbSession from app.memories import service from app.models import Memory, MemoryCandidate, MemoryVersion, User router = APIRouter(prefix="/v1/memories", tags=["memories"]) cycle_router = APIRouter(prefix="/v1/characters", tags=["big-cycle"]) class CandidateIn(BaseModel): model_config = ConfigDict(extra="forbid") character_id: str | None = None kind: str sensitivity: str = "none" realm: str = "reality" payload: dict[str, Any] source: str = "manual" source_ref: dict[str, Any] = Field(default_factory=dict) def _candidate_out(c: MemoryCandidate, memory: Memory | None = None) -> dict[str, Any]: return { "id": c.id, "kind": c.kind, "sensitivity": c.sensitivity, "realm": c.realm, "payload": c.payload, "source": c.source, "status": c.status, "created_memory_id": memory.id if memory else None, "created_at": c.created_at.isoformat(), } def _memory_out(m: Memory, v: MemoryVersion | None) -> dict[str, Any]: return { "id": m.id, "character_id": m.character_id, "kind": m.kind, "sensitivity": m.sensitivity, "realm": m.realm, "status": m.status, "payload": v.payload if v else None, "version": v.version if v else None, "resource_version": m.resource_version, "expires_at": m.expires_at.isoformat() if m.expires_at else None, "created_at": m.created_at.isoformat(), } @router.post("/candidates", status_code=201) async def submit_candidate( db: DbSession, user: Annotated[User, Depends(require_state_change)], request: Request, body: CandidateIn, ) -> dict[str, Any]: domain = await _domain_of(db, user) candidate, memory = await service.submit_candidate( db, domain.id, body.model_dump(), getattr(request.state, "trace_id", None) ) return _candidate_out(candidate, memory) @router.get("/candidates") async def list_candidates( db: DbSession, user: Annotated[User, Depends(get_current_user)], status_filter: str = "pending", ) -> dict[str, Any]: from sqlalchemy import select domain = await _domain_of(db, user) stmt = ( select(MemoryCandidate) .where( MemoryCandidate.owner_personal_domain_id == domain.id, MemoryCandidate.status == status_filter, ) .order_by(MemoryCandidate.created_at.asc()) ) rows = list((await db.scalars(stmt)).all()) return {"data": [_candidate_out(c) for c in rows]} @router.post("/candidates/{candidate_id}/decisions", status_code=201) async def decide_candidate( candidate_id: str, db: DbSession, user: Annotated[User, Depends(require_state_change)], request: Request, body: dict[str, bool], ) -> dict[str, Any]: domain = await _domain_of(db, user) candidate, memory = await service.decide_candidate( db, domain.id, candidate_id, bool(body.get("approve")), getattr(request.state, "trace_id", None), ) return _candidate_out(candidate, memory) @router.get("") async def search_memories( db: DbSession, user: Annotated[User, Depends(get_current_user)], q: str | None = None, character_id: str | None = None, limit: int = 50, cursor: str | None = None, ) -> dict[str, Any]: if not 1 <= limit <= 200: raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "limit 取值范围 1-200") domain = await _domain_of(db, user) page, next_cursor, has_more = await service.search_memories( db, domain.id, character_id, q, limit, cursor ) # 召回证据:进入结果即附(ContextSnapshot 的响应级最小形态) return { "data": [ {**_memory_out(m, v), "recall": {"matched": bool(q), "channel": "text"}} for m, v in page ], "next_cursor": next_cursor, "has_more": has_more, } @router.patch("/{memory_id}") async def correct_memory( memory_id: str, db: DbSession, user: Annotated[User, Depends(require_state_change)], request: Request, if_match: Annotated[str | None, Header(alias="If-Match")] = None, body: dict[str, Any] | None = None, ) -> dict[str, Any]: domain = await _domain_of(db, user) memory = await service.correct_memory( db, domain.id, memory_id, if_match, (body or {}).get("payload", {}), getattr(request.state, "trace_id", None), ) version = await db.get(MemoryVersion, memory.current_version_id) return _memory_out(memory, version) @router.delete("/{memory_id}", status_code=204) async def delete_memory( memory_id: str, db: DbSession, user: Annotated[User, Depends(require_state_change)], request: Request, ) -> None: domain = await _domain_of(db, user) await service.delete_memory( db, domain.id, memory_id, getattr(request.state, "trace_id", None) ) @cycle_router.post("/{character_id}/big-cycles", status_code=201) async def trigger_big_cycle( character_id: str, db: DbSession, user: Annotated[User, Depends(require_state_change)], request: Request, background_tasks: BackgroundTasks, ) -> dict[str, Any]: """手动触发大周天:评估经模型网关,产物为待审核 persona_candidate。""" from app.models import BigCycle domain = await _domain_of(db, user) character = await require_character(db, domain.id, character_id) if character.status not in {"active", "resting"}: raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "当前状态不可触发大周天") await db.commit() cycle = BigCycle( character_id=character.id, status="pending", trigger={"type": "manual"} ) async with db.begin(): db.add(cycle) await db.refresh(cycle) from app.memories.big_cycle import execute_big_cycle background_tasks.add_task( execute_big_cycle, request.app.state.session_factory, cycle.id, transport=getattr(request.app.state, "gw_transport", None), ) return {"id": cycle.id, "character_id": character.id, "status": cycle.status}