Files
hunjing/backend/app/memories/routes.py
T
chenxin e333dc5ea5 ✨ feat: WP-04 记忆与知识——候选版本闭环、检索管线与大周天(ADR-011/018/023)
- 迁移 9:memory_candidate(来源/敏感度/作用域/状态检查约束)、memory(resource_version 作 ETag 源、有效期、当前版本指针)、memory_version((memory_id, version) 唯一、取代链)、big_cycle(cycle_ 聚合根、phase_memory、产物版本引用)
- 候选闭环:手动提交;白名单自动批准仅限 auto+fact+低敏感(撤销窗 72h 记录于审计);敏感/高影响默认人工审核;批准同事务写 Memory+Version+Audit+Outbox(契约 4.4)
- 纠正:If-Match 校验 resource_version(缺失/过期 412),创建新版本不原地覆盖(ADR-011)
- 删除:立即阻断召回 + 清除全部版本原文(墓碑不存被删原文,契约 4.4)
- 检索管线:数据库层域/角色预过滤 → 状态/有效期过滤 → 文本匹配 → 响应级召回证据(语义检索待 embedding 端点)
- 大周天最小实现:手动触发 → 后台四阶段(回顾/对照/提议/提交)→ 模型真实评估产出 pending persona_candidate(候选先于写入);无端点 fail-closed
- 测试 60 项全绿(+6):审批流、白名单三例、ETag 正/缺失/过期、检索隔离与召回证据、删除传播与墓碑原文清除、大周天端到端
2026-09-11 10:54:20 +08:00

200 lines
6.4 KiB
Python

"""记忆域路由(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}