Files
group_fqcd_jr/app/service/knowledge_ingest_service.py
T
张胜宇 5d0becb67d 客服 Agent 重构收口:五出口决策链 + 知识库档位隔离 + 前端入参边界(答辩演示版本)
一、客服 Agent 智能增强(正面回应"不智能、动不动就转人工")
- 决策链由 2 个出口扩到 5 个:E1 澄清 / E2 计算型 / E3 知识直返 / E4 证据约束生成 / E5 分级回退
- 转人工从"默认动作"降为最后一档 E5c,只保留 4 类白名单:
  P0 反诈 / P1 账户与个人数据 / P2 写操作与争议 / 用户明确要求人工
- 46 条金标实测(修复前 → 修复后):
  转人工率 43.5% → 10.9%;出口准确率 45.7% → 100%;事实正确率 69.6% → 100%
  禁忌违反 1 → 0;档位越权 / 无出处数字 / 误拒 四项零容忍全 0
- 安全不变量 INV-1~INV-5;零容忍规则未删,改的是挂载点
  (输出侧字面黑名单 → 检索层档位隔离 + 判定层合规词表 + 输出守护)

二、知识库:档位单点化与物理隔离
- 新增 app/core/knowledge_tier.py 作为档位规则唯一落点(G-03),
  knowledge_contracts.py 原定义块改为显式再导出(X as X,非副本)
- 档位过滤由 bool 默认值(fail-open)改为 tiers 必填集合(缺参即 TypeError)
- Milvus 侧四集合按 visibility 分区键物理隔离;双 schema 收敛为一套
- 新增 app/core/actor.py:访客三元组与匿名判定的唯一构造/判定点(G-01/G-01b)
- 新增 app/core/fund_fee_rules.py:费率计算纯函数

三、前端入参边界对齐(本轮 W11 新修,4 处"校验宽于存储")
- message 加 max_length=8000(与浮窗 widget.js 的 maxlength 一致)
- session_id 加 1—64;idempotency_key 上限 128 → 64(对齐列宽 String(64))
- feedback_type 加 max_length=32(对齐列宽 String(32))
- 8 条路径参数补 min_length=1 + max_length=64 + 字符集正则
  ({session_id} / {run_id} / {handover_id})
- 改前超限值会落到 MySQL 才失败(500);改后一律 422 AGENT_INPUT_INVALID + 字段级定位
- 新增 tests/unit/api/test_frontend_boundaries.py(33 例),含"端点表 ↔ OpenAPI 全量对照"

四、投顾模块整体清除(D4.4 / D4.5)
- 删除投顾相关 controller / schema / model / repository / service 及门户页面
- tools/portal_api_check.py 同步作废 AD003/AD005/AD011/A047 四条用例与 advisor_t 登录
  (端点与账号均已不存在,此前稳定报 3 条假红)

五、验证(提交前实测)
- pytest -q:1856 passed / 2 skipped / 0 failed
- ruff check app tools tests:19(= 基线);mypy app:2(= 基线)
- 前端接口契约体检 portal_api_check.py:38 项,通过 34,失败 0,跳过 4
- 全链路冒烟 e2e_smoke_test.py --read-only:31/31
- HTTP 全链路探针 http_probe.py:11/11 succeeded
- 跨文档一致性 _consistency.py:GATE PASS
- 真机边界复验 12 条:12/12 符合预期

六、纪律与文档
- 可改文件白名单 A-09(docs/46)与底座会签申请单 A-10(docs/47,组 1—组 4 全部受理)
- 零 DDL:未新增/修改任何表结构,89 张业务表与基线一致
- 证据留痕:docs/evidence/**(含 46 条金标 score、快照、清除与重建记录)
- 未提交(刻意排除,见提交说明):仓库内 客服agent/ 与 开发文档/ 是 2026-09-16 前的
  过期副本(Todolist 440 行 vs 权威 D2.1 1167 行),权威正本在仓库外;
  _chunks_report.txt 是 tools/build_knowledge_chunks.py 生成的本地产物
2026-09-20 14:33:30 +08:00

349 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""知识入库服务:文件落存储 → 元数据落库(切块逐行)→ 投递向量同步事件。
## 分层边界
Service 层(MVC+S):使用**传入的** `AsyncSession`,不自建 Session、不直连 MySQL/Redis/Milvus,
也不调用 embedding —— Milvus 的写入与向量化全部由 `knowledge.vector_sync_requested` 事件的
Outbox handler(`app/worker/knowledge_vector_worker.py`)完成。这里只把"这件事需要做"写进
`domain_event_outbox`,与知识行处于**同一个事务**(调用方提交才一起生效),因此不存在
"知识入库了但向量同步事件丢了"的中间态。
构造器收 `embedder` / `endpoint_resolver` 不是摆设:它们是**后续对账自愈**要用的依赖
(裁定 3 —— 自愈补投的事件最终要靠它们落地向量),由调用方在组合根一次性注入,
避免 Service 以后为了自愈去偷偷 new 一个模型网关。
## 为什么 `ingest` 返回 `list[int]` 而不是单个 `int`
一份文档会被切分成**多个 chunk**(`DocumentParser`:512 字符/块、overlap 64,且 overlap 会在
相邻块间重复出现,见该模块 docstring),每个 chunk 是 `fin_knowledge_meta` 里**独立的一行**
(独立 `knowledge_id`、独立向量)。返回单个 int 无法表达"这次入库产生了哪些知识",
调用方也没法为多行知识建同义问法或回滚。所以返回**按 chunk 顺序排列的 `knowledge_id` 列表**;
空文档返回空列表。
## 尚未接入生产 worker(**已知缺口,不在 Task 5 范围**)
本服务投出的事件(`knowledge.vector_sync_requested`)**目前没有生产消费者**:
`WorkerRuntime.dispatch_one`(`app/worker/runtime.py`)的 handlers 是硬编码白名单,里面没有这两个
知识事件,需由组装层(`app/service/agent/bootstrap.py`,Task 7/9 范围)调用
`app/worker/knowledge_vector_worker.py` 的 `build_knowledge_handlers(...)` 并**注册进
`WorkerRuntime.dispatch_one` 的 handlers 字典**才能被领取。在那之前事件只会堆在
`domain_event_outbox`(现库已有 408 条),"入库成功"只代表**知识行与事件同一事务落库**,
**不代表向量已同步**。
## 校验口径
- `knowledge_type` 必须属于白名单;
- 集合名**不得由调用方随意指定**:只允许取 `TYPE_TO_COLLECTION[knowledge_type]`,
显式传入就必须**等于**它,否则 `ValidationAgentError`(全库只有三个知识集合,
写错集合等于让检索永远查不到这条知识);
- `created_by` 解析顺序:`ingest(created_by=...)` > 构造器 `created_by`;两者都没有则
`ValidationAgentError`(`fin_knowledge_meta.reviewer_id` 是"谁导入的"唯一线索,不能静默写空)。
"""
import hashlib
import json
import logging
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from uuid import uuid4
from sqlalchemy import select, text, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.errors import ValidationAgentError
from app.core.knowledge_contracts import ALLOWED_COLLECTIONS
from app.model.knowledge import KnowledgeMeta
from app.service.document_parser import DocumentParser
# 裁定 5:事件常量只有一份真相,在 worker 模块里;这里 import 而不是另写字面量。
from app.worker.knowledge_vector_worker import (
KNOWLEDGE_ACTIVE_STATUS,
KNOWLEDGE_AGGREGATE_TYPE,
VECTOR_DELETE_EVENT,
VECTOR_SYNC_EVENT,
)
logger = logging.getLogger(__name__)
#: 下线状态取值。与 `knowledge_management_service.EXPIRED_STATUS` 同值;两个服务
#: 互相 import 会成环,故各自声明(DB 侧该列只有 'active' / 'expired' 两种取值)。
EXPIRED_STATUS = "expired"
ACTIVE_STATUS = KNOWLEDGE_ACTIVE_STATUS
#: 允许入库的知识类型与它们**唯一**对应的 Milvus 集合(`ALLOWED_COLLECTIONS` 是契约层白名单)。
TYPE_TO_COLLECTION: dict[str, str] = {
"basic": "fin_basic_collection",
"faq": "fin_faq_collection",
"product": "fin_product_collection",
"policy": "fin_policy_collection",
}
ALLOWED_KNOWLEDGE_TYPES = frozenset(TYPE_TO_COLLECTION)
#: 入库默认版本号(同一份文档重导不会改变它;版本治理由后续任务的审核流程负责)。
DEFAULT_VERSION = "v1"
#: 标题列是 `String(256)`,chunk 正文可能远超;标题取正文前若干字符即可。
TITLE_MAX_LENGTH = 120
INSERT_KNOWLEDGE_META = text(
"INSERT INTO fin_knowledge_meta"
" (knowledge_type, title, source_file, minio_path, milvus_collection, version,"
" content_text, tags, reviewer_id, review_status, status, created_at, updated_at)"
" VALUES (:knowledge_type, :title, :source_file, :minio_path, :milvus_collection, :version,"
" :content_text, :tags, :reviewer_id, 'published', 'active', :now, :now)"
)
SELECT_LAST_INSERT_ID = text("SELECT LAST_INSERT_ID()")
INSERT_OUTBOX_EVENT = text(
"INSERT INTO domain_event_outbox"
" (event_id, event_type, aggregate_type, aggregate_id, trace_id, payload, status,"
" retry_count, occurred_at, created_at, updated_at)"
" VALUES (:event_id, :event_type, :aggregate_type, :aggregate_id, :trace_id, :payload,"
" 'pending', 0, :now, :now, :now)"
)
class KnowledgeIngestService:
def __init__(
self,
*,
session: AsyncSession,
parser: DocumentParser,
storage: Any,
embedder: Any,
endpoint_resolver: Any,
created_by: int | None = None,
) -> None:
self._session = session
self._parser = parser
self._storage = storage
self._embedder = embedder
self._endpoint_resolver = endpoint_resolver
self._created_by = created_by
async def ingest(
self,
*,
filename: str,
content: bytes,
knowledge_type: str,
collection: str | None = None,
created_by: int | None = None,
) -> list[int]:
"""把一份文档切成多块写入知识表,并为每一块投一条向量同步事件。
返回**该文档产生的 `knowledge_id` 列表**(按 chunk 顺序;空文档返回 `[]`)。
不 commit:事务归调用方(通常是与审核/审计记录一起提交,或整体回滚)。
"""
expected_collection = self._collection_for(knowledge_type, collection)
reviewer_id = self._resolve_created_by(created_by)
key = self._storage_key(knowledge_type, filename, content)
await self._storage.save(
key=key, content=content, content_type="application/octet-stream"
)
# 切分在落存储之后、写库之前:解析失败(UnsupportedDocumentError)时库里不留半截知识,
# 只在存储里留一个未引用的对象,比留一堆无事件的孤儿知识行安全。
chunks = self._parser.parse(filename=filename, content=content)
now = datetime.now(UTC).replace(tzinfo=None)
# 同一份文档**重新入库 = 覆盖上一版**:先把上一版下线(并清掉它的向量),再写新版。
# 见 `_supersede_previous_version` 里记录的后果。
# **必须先判 chunks 非空**:解析出 0 块(空文档、或全部是不支持的扩展名)时若照旧
# 下线上一版,结果是"旧版被下架、新版一行没写" —— 这份文档在检索侧就凭空消失了。
if chunks:
await self._supersede_previous_version(
filename=filename, collection=expected_collection, now=now
)
knowledge_ids: list[int] = []
for chunk in chunks:
knowledge_id = await self._insert_knowledge_row(
chunk=chunk,
filename=filename,
knowledge_type=knowledge_type,
collection=expected_collection,
storage_key=key,
reviewer_id=reviewer_id,
now=now,
)
knowledge_ids.append(knowledge_id)
await self._enqueue_vector_sync(knowledge_id, now=now)
return knowledge_ids
def _collection_for(self, knowledge_type: str, collection: str | None) -> str:
"""白名单校验:类型必须已知,显式给定的集合必须等于该类型的唯一集合。"""
if knowledge_type not in ALLOWED_KNOWLEDGE_TYPES:
raise ValidationAgentError(f"knowledge_type 非法:{knowledge_type}")
expected = TYPE_TO_COLLECTION[knowledge_type]
if collection is not None and collection != expected:
raise ValidationAgentError(
f"集合名只能是 {expected},不接受调用方指定的 {collection}"
)
if expected not in ALLOWED_COLLECTIONS: # pragma: no cover - 契约层与本地映射的护栏
raise ValidationAgentError(f"集合不在契约白名单内:{expected}")
return expected
def _resolve_created_by(self, created_by: int | None) -> int:
resolved = created_by if created_by is not None else self._created_by
if resolved is None:
raise ValidationAgentError("缺少 created_by:入库必须记录导入人")
return resolved
@staticmethod
def _storage_key(knowledge_type: str, filename: str, content: bytes) -> str:
"""`kb/<type>/<内容前 16 位 sha256>-<文件名>`:同一份文档重导落到同一个 key。"""
digest = hashlib.sha256(content).hexdigest()
return f"kb/{knowledge_type}/{digest[:16]}-{Path(filename).name}"
async def _insert_knowledge_row(
self,
*,
chunk: Any,
filename: str,
knowledge_type: str,
collection: str,
storage_key: str,
reviewer_id: int,
now: datetime,
) -> int:
"""插入一行知识并返回它的自增 `id`。
## 与 `LAST_INSERT_ID()` 的硬耦合(已知脆弱点,**不要**在中间插语句)
`LAST_INSERT_ID()` 是 **MySQL 连接级的隐式状态**,不是"上一次 INSERT 的返回值":
它只在**同一个连接**上、且**中间没有其它 INSERT** 时才等于本行 id。
因此本方法把 `INSERT` 与紧随其后的 `SELECT LAST_INSERT_ID()` 放在**同一方法体内成对**执行,
中间的语句数是 **0**,并删掉了 `sqlalchemy.text` 之外的任何额外查询
(`test_last_insert_id_is_read_once_per_chunk_in_the_same_statement_order`
把"成对且相邻"的语句序列固定下来)。调用方在本方法返回前不得插入别的写操作。
**为什么不用"显式回读"替代**(本方案评估过、明确否决):
`fin_knowledge_meta` 的唯一键只有主键 `id`(DDL 见
`docs/evidence/20260909-before-constraint-fix.sql`:
`PRIMARY KEY (id)` + `KEY idx_knowledge_status(status)`),
`(knowledge_type, source_file, minio_path)` 上**没有唯一约束**。回读只能靠这几列近似匹配,
而同一份文档用同一 key 重导会产出**完全相同的行** → 回读 `LIMIT 1` 可能拿到上次导入的
旧行 id(`ORDER BY id` 也救不了:新行 id 更大,反而更易混淆),会静默写坏 outbox 事件。
"先算好外部 id 再插入"同样不可行:`id` 是 `bigint unsigned AUTO_INCREMENT`,要显式指定就得
自己分配全局唯一整数,等于引入新表/新序列(触碰基线),且要额外处理并发分配的唯一性。
所以在本 Schema 下 `LAST_INSERT_ID()` 是**唯一**无需改基线的可行做法。
## 失败路径:两个分支的文案必须区分真相
- `rowcount == 0`(INSERT 没生效):此时 `LAST_INSERT_ID()` 保持**上一次**的值、
新连接上是 0,所以真相是"插入没生效",
**不能**报成"取不到 id"(那会把排障引向错误方向)。
- `LAST_INSERT_ID() == 0` 而 INSERT 影响了 1 行:说明连接上还没有过自增插入就拿到了 0,
文案直接点出 `LAST_INSERT_ID=0`,并提示不要对它取整。
"""
result = await self._session.execute(INSERT_KNOWLEDGE_META, {
"knowledge_type": knowledge_type,
"title": chunk.text[:TITLE_MAX_LENGTH],
"source_file": filename,
"minio_path": storage_key,
"milvus_collection": collection,
"version": DEFAULT_VERSION,
"content_text": chunk.text,
# `fin_knowledge_meta.tags` 是 JSON 列:这里写 json 字符串(与
# `tools/import_knowledge_seed.py` 同口径),读回来是 dict。
"tags": json.dumps(
{"heading_path": list(chunk.heading_path), "chunk_index": chunk.index},
ensure_ascii=False,
),
"reviewer_id": reviewer_id,
"now": now,
})
# `rowcount` 可能是 -1(驱动不报告行数),所以只在"明确为 0"时判失败,不把 -1 当失败。
affected = getattr(result, "rowcount", None)
if isinstance(affected, int) and affected == 0:
raise ValidationAgentError(
"知识行插入未生效(INSERT 影响 0 行),未继续取 LAST_INSERT_ID()"
)
inserted = await self._session.scalar(SELECT_LAST_INSERT_ID)
if inserted is None: # pragma: no cover - 只在非 MySQL 后端上发生
raise ValidationAgentError("插入知识行后取不到 LAST_INSERT_ID()(返回 NULL)")
if int(inserted) == 0: # pragma: no cover - 真机上的异常连接状态
raise ValidationAgentError(
"插入知识行后 LAST_INSERT_ID=0:取不到本次自增 id,不能拿它当 knowledge_id"
)
return int(inserted)
async def _supersede_previous_version(
self, *, filename: str, collection: str, now: datetime
) -> list[int]:
"""把同一份文档的上一版下线,**并投出向量删除事件**;返回被下线的 knowledge_id。
## 为什么必须在这里做
知识写入侧只认 active(`knowledge_vector_worker.KNOWLEDGE_ACTIVE_STATUS`),
但**检索侧不看 status**(`knowledge_search_service` 只过滤 `visibility`)。
于是"重新入库一份同名文档"会留下旧版的向量,它们继续参与排序 —— 表现就是
**同一份内容有 N 个副本互相抢答**:top1 与 top2 都是同一主题的近似块,
分数天然贴近,把客服的"领先次优 ≥0.07"门槛永远卡死(实测:产品手册被重复入库 7 次,
175 条历史副本的向量让「风险等级 R1–R5」这类问题只能转人工)。
## 口径
- 只下线**同一 `source_file` + 同一集合**的 active 行:不同文档互不影响;
- 下线 = `status='expired'` + 每行投一条 `knowledge.vector_delete_requested`
(消费侧 `knowledge_vector_worker.remove` 按 `knowledge_id` 删向量);
- 历史遗留的 expired 行(当年没投过删除事件、向量还在)不在本方法范围内,
用管理端口的"向量残留清理"动作单独处理(`cleanup_vector`)。
"""
previous = list(await self._session.scalars(
select(KnowledgeMeta.id).where(
KnowledgeMeta.source_file == filename,
KnowledgeMeta.milvus_collection == collection,
KnowledgeMeta.status == ACTIVE_STATUS,
)
))
if not previous:
return []
ids = [int(row) for row in previous]
await self._session.execute(
update(KnowledgeMeta)
.where(KnowledgeMeta.id.in_(ids))
.values(status=EXPIRED_STATUS, updated_at=now)
)
for knowledge_id in ids:
await self._enqueue_vector_delete(knowledge_id, now=now)
logger.info(
"知识覆盖入库:下线上一版 %s 个块(source_file=%s)并投出向量删除事件",
len(ids), filename,
)
return ids
async def _enqueue_vector_sync(self, knowledge_id: int, *, now: datetime) -> None:
"""投一条向量同步事件;`aggregate_id` 与 payload 都用 `str(knowledge_id)`。
`domain_event_outbox` 上没有唯一约束,所以**重复导入会产生多条同 `aggregate_id` 的事件**:
这是已知且被接受的 —— 消费侧按 `aggregate_id` 覆盖写入而非跳过
(见 `knowledge_vector_worker` 的幂等口径),因此重复投递不会在 Milvus 里留下重复向量。
"""
await self._session.execute(INSERT_OUTBOX_EVENT, {
"event_id": str(uuid4()),
"event_type": VECTOR_SYNC_EVENT,
"aggregate_type": KNOWLEDGE_AGGREGATE_TYPE,
"aggregate_id": str(knowledge_id),
"trace_id": str(uuid4()),
"payload": json.dumps({"knowledge_id": str(knowledge_id)}),
"now": now,
})
async def _enqueue_vector_delete(self, knowledge_id: int, *, now: datetime) -> None:
"""投一条向量**删除**事件(覆盖入库时清掉上一版用)。
与同步事件同一套装配:消费侧 `knowledge_vector_worker.remove` 按 `knowledge_id`
把该块从 Milvus 里删掉。事件与本次入库**同事务**,因此不会出现"库里有新版、
向量库里旧版还在"的中间态。
"""
await self._session.execute(INSERT_OUTBOX_EVENT, {
"event_id": str(uuid4()),
"event_type": VECTOR_DELETE_EVENT,
"aggregate_type": KNOWLEDGE_AGGREGATE_TYPE,
"aggregate_id": str(knowledge_id),
"trace_id": str(uuid4()),
"payload": json.dumps({"knowledge_id": str(knowledge_id)}),
"now": now,
})