相对第一版 46fc976 的完整变更。组员迁移对照表见 docs/20。
一、对外契约对齐 docs/05(破坏性,共 4 处,组员需按 docs/20 调整)
1) 配置发布端点改为文档规定的复数资源名:submit→validations、
approve→reviews(需 body decision)、activate→activations、
rollback→rollbacks;第一版这 4 个动词式路径 docs/05 从未定义过。
2) 错误码由 8 个笼统码改为 15 个具体语义码(FORBIDDEN→AGENT_PERMISSION_DENIED、
UNAUTHORIZED→AUTHENTICATION_REQUIRED、CONFLICT→RESOURCE_VERSION_CONFLICT、
RESOURCE_NOT_FOUND→RUN_NOT_FOUND/SESSION_NOT_FOUND 等),
输入类错误状态码 400→422。
3) POST /api/v1/agent-runs 与 GET /api/v1/agent-runs/{run_id} 统一为
{data, meta} 信封(data 内字段名与语义未变)。
4) 错误响应体统一为 {error:{code,message,retryable,field_errors}, meta:{trace_id}},
不再返回 FastAPI 默认的 {"detail": ...}。
二、数据库基线与约束
新增 39 张表的基线迁移(链根)与联合唯一键纠偏(4 张表、删 8 增 4,幂等收敛);
撤下 config_release 的双人复核 CHECK(应用层已允许自审,审核节点保留,
自审如实写入 reviewer_id);记忆 active key 生成列与唯一键;
activate 开始记录 supersedes_release_id 使版本链可追溯。
docs/00 基线未修改,未重命名或删除任何表与字段。
三、修复会静默出错或无报错的缺陷
- 跑完集成测试后平台会静默失去生效配置:清理只删自己创建的版本,却没有恢复被它
顶成 superseded 的原生效版本,且审计一并删除因而完全无痕,表现为所有工具被拒
但没有任何报错。已修清理逻辑并加恢复。
- Worker 单轮异常导致进程退出;记忆抽取调用方的“事务已开始”异常;
召回缓存丢失 degraded 标记;连接时区未生效导致 created_at/updated_at 差 8 小时;
.env 与 os.getenv 密钥来源分裂导致“没有可用的已批准模型端点”。
- 记忆信号识别漏判与跨键误命中;SSE 未带 Accept 的协商行为。
四、功能补齐
记忆链路 P1/P2/P3(抽取、受控词表、召回与缓存、生命周期级联及投影事件)、
fin_* 场内交易只读 ORM 层、agent_intent_config 状态流转并在运行期真正生效、
限流(Redis 固定窗口、故障一律放行)、游标校验、trace_id 中间件、
示例业务 Agent fund_query_demo 与一键端到端验证脚本,以及审计/指纹/迁移状态工具。
五、文档与验证
新增 docs/19(业务 Agent 接入实操)、docs/20(第一版迁移指南)与 docs/evidence 证据;
docs/01/02/06/08/09/17 同步实现现状。
验证结果:ruff 通过、mypy 103 文件无错、unit+contract 447 passed、
integration 29 passed、acceptance_check --production 7 PASS、
demo_agent_e2e 9/9 PASS(含失败关闭反证)。
163 lines
6.5 KiB
Python
163 lines
6.5 KiB
Python
"""知识引用解析服务(05-接口文档 K001)。
|
||
|
||
token 形态:``kr1.{user_id}.{knowledge_id}.{expires_at_epoch}.{signature}``。
|
||
|
||
- 签名:HMAC-SHA256(hex)覆盖 token 前四段,防止伪造与越权拼接;
|
||
- 密钥:只从环境变量 ``KNOWLEDGE_REFERENCE_SIGNING_SECRET`` 读取,缺失时失败关闭,
|
||
不提供默认值、不硬编码;
|
||
- 解析:按 knowledge_id 只读回查 ``fin_knowledge_meta``,仍要求
|
||
``review_status='published' AND status='active'`` 且在有效期内;
|
||
- 返回:脱敏元数据(标题/版本/类型/集合/标签/有效期),**绝不返回 ``content_text`` 全文**,
|
||
避免绕过工具审计直读知识正文。
|
||
"""
|
||
|
||
import hashlib
|
||
import hmac
|
||
import json
|
||
import os
|
||
from collections.abc import Callable, Mapping
|
||
from datetime import UTC, date, datetime
|
||
from typing import Any
|
||
|
||
from sqlalchemy import text
|
||
|
||
from app.core.contracts import RequestContext
|
||
from app.core.errors import RecoverableAgentError, ReferenceNotFoundError
|
||
from app.infrastructure.db import SessionFactory
|
||
from app.service.authorization_service import AuthorizationService
|
||
|
||
REFERENCE_TOKEN_PREFIX = "kr1"
|
||
REFERENCE_TOKEN_PARTS = 5
|
||
SIGNING_SECRET_ENV = "KNOWLEDGE_REFERENCE_SIGNING_SECRET"
|
||
PUBLISHED_REVIEW_STATUS = "published"
|
||
ACTIVE_STATUS = "active"
|
||
|
||
_REFERENCE_SQL = text(
|
||
"""
|
||
SELECT id, knowledge_type, title, version, milvus_collection, tags,
|
||
effective_date, expire_date, review_status, status
|
||
FROM fin_knowledge_meta
|
||
WHERE id = :knowledge_id
|
||
"""
|
||
)
|
||
|
||
|
||
def signing_secret() -> str:
|
||
"""签名密钥只来自环境变量;缺失即失败关闭(可识别的配置错误)。"""
|
||
secret = os.environ.get(SIGNING_SECRET_ENV, "").strip()
|
||
if not secret:
|
||
raise RecoverableAgentError(f"知识引用签名密钥未配置:{SIGNING_SECRET_ENV}")
|
||
return secret
|
||
|
||
|
||
def _signature(secret: str, head: str) -> str:
|
||
return hmac.new(secret.encode("utf-8"), head.encode("utf-8"), hashlib.sha256).hexdigest()
|
||
|
||
|
||
def build_reference_token(
|
||
*, user_id: str, knowledge_id: int, expires_at: datetime, secret: str | None = None
|
||
) -> str:
|
||
"""签发侧入口:与解析侧共用同一算法,供引用落库时同步签发 token。
|
||
|
||
``expires_at`` 必须带时区;无时区视为 UTC 以保持与解析侧一致。
|
||
"""
|
||
resolved = secret if secret is not None else signing_secret()
|
||
moment = expires_at if expires_at.tzinfo is not None else expires_at.replace(tzinfo=UTC)
|
||
head = f"{REFERENCE_TOKEN_PREFIX}.{user_id}.{knowledge_id}.{int(moment.timestamp())}"
|
||
return f"{head}.{_signature(resolved, head)}"
|
||
|
||
|
||
def _not_found() -> ReferenceNotFoundError:
|
||
"""所有解析失败对外表现一致,不泄露失败原因。"""
|
||
return ReferenceNotFoundError("引用不存在")
|
||
|
||
|
||
def _as_date(value: object) -> date | None:
|
||
return value if isinstance(value, date) else None
|
||
|
||
|
||
def _as_iso(value: date | None) -> str | None:
|
||
return value.isoformat() if value is not None else None
|
||
|
||
|
||
def _as_tags(value: object) -> list[str]:
|
||
if isinstance(value, str):
|
||
try:
|
||
value = json.loads(value)
|
||
except ValueError:
|
||
return []
|
||
if isinstance(value, list | tuple):
|
||
return [str(item) for item in value]
|
||
return []
|
||
|
||
|
||
class KnowledgeReferenceService:
|
||
"""只读解析知识引用 token,返回脱敏元数据。"""
|
||
|
||
def __init__(self, *, session_factory: Callable[[], Any] | None = None) -> None:
|
||
self._session_factory: Callable[[], Any] = session_factory or SessionFactory
|
||
|
||
async def resolve(self, context: RequestContext, token: str) -> dict[str, Any]:
|
||
await AuthorizationService.require(context, "knowledge:reference:read")
|
||
knowledge_id, expires_at = self._verify_token(context, token)
|
||
metadata = await self._load_metadata(knowledge_id)
|
||
return self._redact(knowledge_id, expires_at, self._assert_resolvable(metadata))
|
||
|
||
def _verify_token(self, context: RequestContext, token: str) -> tuple[int, datetime]:
|
||
secret = signing_secret()
|
||
pieces = token.split(".")
|
||
if len(pieces) != REFERENCE_TOKEN_PARTS or pieces[0] != REFERENCE_TOKEN_PREFIX:
|
||
raise _not_found()
|
||
_, token_user, knowledge_raw, expires_raw, signature = pieces
|
||
if not hmac.compare_digest(_signature(secret, ".".join(pieces[:4])), signature):
|
||
raise _not_found()
|
||
if token_user != context.user_id:
|
||
raise _not_found()
|
||
if not knowledge_raw.isdecimal() or not expires_raw.isdecimal():
|
||
raise _not_found()
|
||
expires_at = datetime.fromtimestamp(int(expires_raw), tz=UTC)
|
||
if expires_at <= datetime.now(UTC):
|
||
raise _not_found()
|
||
return int(knowledge_raw), expires_at
|
||
|
||
async def _load_metadata(self, knowledge_id: int) -> Mapping[str, Any] | None:
|
||
async with self._session_factory() as session:
|
||
result = await session.execute(_REFERENCE_SQL, {"knowledge_id": knowledge_id})
|
||
return result.mappings().first() # type: ignore[no-any-return]
|
||
|
||
@staticmethod
|
||
def _assert_resolvable(metadata: Mapping[str, Any] | None) -> Mapping[str, Any]:
|
||
"""校验发布状态与有效期;不满足一律 404,且不泄露失败原因。"""
|
||
if metadata is None:
|
||
raise _not_found()
|
||
if str(metadata["review_status"]) != PUBLISHED_REVIEW_STATUS:
|
||
raise _not_found()
|
||
if str(metadata["status"]) != ACTIVE_STATUS:
|
||
raise _not_found()
|
||
today = datetime.now(UTC).date()
|
||
effective_date = _as_date(metadata["effective_date"])
|
||
if effective_date is not None and effective_date > today:
|
||
raise _not_found()
|
||
expire_date = _as_date(metadata["expire_date"])
|
||
if expire_date is not None and expire_date <= today:
|
||
raise _not_found()
|
||
return metadata
|
||
|
||
@staticmethod
|
||
def _redact(
|
||
knowledge_id: int, expires_at: datetime, metadata: Mapping[str, Any]
|
||
) -> dict[str, Any]:
|
||
version = metadata["version"]
|
||
return {
|
||
"knowledge_id": knowledge_id,
|
||
"title": str(metadata["title"]),
|
||
"knowledge_type": str(metadata["knowledge_type"]),
|
||
"version": version if isinstance(version, str) else None,
|
||
"collection": str(metadata["milvus_collection"]),
|
||
"tags": _as_tags(metadata["tags"]),
|
||
"effective_date": _as_iso(_as_date(metadata["effective_date"])),
|
||
"expire_date": _as_iso(_as_date(metadata["expire_date"])),
|
||
"reference_expires_at": expires_at.isoformat(),
|
||
"content_included": False,
|
||
}
|