wip: 客服Agent + RAG + 画像收尾(基于 6516ccb)

This commit is contained in:
2026-09-11 14:46:40 +08:00
parent 870fd0d44e
commit e4c4099aaa
82 changed files with 13901 additions and 2426 deletions
+319
View File
@@ -0,0 +1,319 @@
"""把已审核的 QA 源文件一次性导入 `fin_knowledge_meta` + `agent_faq_synonym`,并投递向量同步事件。
为什么是一个独立的种子脚本、而不是"跑一次 SQL":这 105 条(+1 条验收补充)是客服 Agent
RAG 检索的**全部**知识来源。它们必须能被重复导入——Task 2 改治理层、Task 4 建文档解析、
Task 5 写 Outbox Worker 都可能要重放这次导入;一旦脚本不幂等,重放就会产生重复知识、
重复同义问法,并在 Milvus 里留下同一段文本的多个向量(检索结果会重复命中同一条)。
幂等口径(控制者裁定 4):
1. 知识行按 `tags.qa_id` 定位——存在则 UPDATE、不存在则 INSERT。`qa_id` 是源文件的稳定编号,
不依赖自增主键,因此重复导入不会新增行。
2. 同义问法先按 `(knowledge_id, source_type='import')` 删除再插入:`import` 来源的整批
由本脚本独占,删除-重插能得到源文件的精确镜像;手工/会话/工单来源的同义问法不受影响。
3. 去重按 `phrase_hash`(SHA-256(归一化问法))而非原文字符串——`uk_faq_synonym
(knowledge_id, phrase_hash)` 就是这么定义的,且「问题」本身也作为一个 phrase 写入
(控制者裁定 5),所以标准问法与相似问法之间的重复也在同一套哈希下去重。
本脚本**不写 Milvus**:向量由 `knowledge.vector_sync_requested` 事件的 Worker(Task 5)落库。
这里只负责把事件投进 `domain_event_outbox`。
写入的固定值(控制者裁定 1/2/3,全部有据可查):
- `milvus_collection='fin_faq_collection'`(与 Task 6 的三集合白名单一致)。
- `effective_date`/`expire_date` 留空 = 长期有效;读路径对空值按"长期有效"处理。
- `review_status='published'` + `status='active'`(源文件本身即 `approved_candidate` 且已人工审核);
同义问法 `status='approved'` + `source_type='import'`(`docs/02` §7.3 DDL 的合法枚举,带 CHECK)。
- `created_by`/`reviewer_id` = `sys_user.id`(现库 `9003`)——`agent_faq_synonym.created_by`
非空且带外键 `fk_synonym_created_by`,写不存在的用户会直接失败。
执行(可重复执行):
.\\.venv\\Scripts\\python.exe tools\\import_knowledge_seed.py \\
--source "C:\\...\\客服Agent知识库_QA问答对_v5_RAG发布候选版.txt" --created-by 9003
"""
from __future__ import annotations
import argparse
import asyncio
import json
import sys
from datetime import UTC, datetime
from pathlib import Path
from uuid import uuid4
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from sqlalchemy import text # noqa: E402
from sqlalchemy.ext.asyncio import AsyncSession # noqa: E402
from app.infrastructure.db import SessionFactory # noqa: E402
from tools.qa_source_parser import ( # noqa: E402
QaRecord,
normalize_phrase,
parse_qa_source,
phrase_hash,
)
#: 三个 Milvus 集合里的 FAQ 集合(Task 6 的白名单口径)。
FAQ_COLLECTION = "fin_faq_collection"
#: `fin_knowledge_meta.knowledge_type`:FAQ 文本知识。
KNOWLEDGE_TYPE = "faq"
#: 源文件版本,写入 `version` 与 `tags.source_version`,便于回溯"这条来自哪一版"。
SOURCE_VERSION = "v5.8"
#: 向量同步事件类型(Task 5 的 Worker 消费它)。
VECTOR_SYNC_EVENT = "knowledge.vector_sync_requested"
#: 本脚本独占的 `agent_faq_synonym.source_type`;重导时只删这一来源的行。
IMPORT_SOURCE_TYPE = "import"
#: 老师 Phase 1 验收点名的测试问题「基金申购后多久确认」**不在 105 条内**
#: (源文件里只有 `RAG-CONFIG-006`「申购和赎回什么时候确认、到账?」,措辞不同),
#: 这里补一条同义的验收条目,使验收用例能真正命中知识库而不是落到兜底话术。
ACCEPTANCE_SUPPLEMENT = QaRecord(
qa_id="SUP-001",
question="基金申购后多久确认?",
synonyms=("申购多久确认份额?", "买基金几天能确认?", "基金申购确认时间"),
answer=(
"交易日 15:00 前提交的申购申请,T+1 日确认份额(QDII 基金为 T+2 日);"
"15:00 后提交则顺延至下一交易日。非交易日(周末及法定节假日)提交的申请顺延至"
"下一交易日处理。确认后即可查看持仓。具体以产品说明书和交易页面显示为准。"
),
)
SELECT_KNOWLEDGE_ID_BY_QA_ID = text(
"SELECT id FROM fin_knowledge_meta"
" WHERE JSON_UNQUOTE(JSON_EXTRACT(tags, '$.qa_id')) = :qa_id"
" ORDER BY id LIMIT 1"
)
INSERT_KNOWLEDGE_META = text(
"INSERT INTO fin_knowledge_meta"
" (knowledge_type, title, source_file, milvus_collection, version, content_text,"
" tags, reviewer_id, review_status, status, created_at, updated_at)"
" VALUES (:knowledge_type, :title, :source_file, :collection, :version, :content_text,"
" :tags, :reviewer_id, 'published', 'active', :now, :now)"
)
UPDATE_KNOWLEDGE_META = text(
"UPDATE fin_knowledge_meta SET title=:title, source_file=:source_file,"
" milvus_collection=:collection, version=:version, content_text=:content_text,"
" tags=:tags, reviewer_id=:reviewer_id, review_status='published', status='active',"
" updated_at=:now WHERE id=:id"
)
SELECT_LAST_INSERT_ID = text("SELECT LAST_INSERT_ID()")
DELETE_IMPORTED_SYNONYMS = text(
"DELETE FROM agent_faq_synonym WHERE knowledge_id=:knowledge_id"
" AND source_type=:source_type"
)
INSERT_SYNONYM = text(
"INSERT INTO agent_faq_synonym"
" (knowledge_id, phrase, normalized_phrase, phrase_hash, language_code, source_type,"
" hit_count, status, created_by, reviewer_id, reviewed_at, created_at, updated_at)"
" VALUES (:knowledge_id, :phrase, :normalized_phrase, :phrase_hash, 'zh-CN',"
" :source_type, 0, 'approved', :created_by, :created_by, :now, :now, :now)"
)
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, 'knowledge_meta', :aggregate_id, :trace_id, :payload,"
" 'pending', 0, :now, :now, :now)"
)
def _knowledge_tags(record: QaRecord) -> str:
return json.dumps(
{"qa_id": record.qa_id, "phase": "phase_1", "source_version": SOURCE_VERSION},
ensure_ascii=False,
)
async def upsert_knowledge_meta(
session: AsyncSession, record: QaRecord, *, source_file: str,
created_by: int, now: datetime,
) -> int:
"""按 `tags.qa_id` 幂等写入知识行,返回 `fin_knowledge_meta.id`。"""
params = {
"knowledge_type": KNOWLEDGE_TYPE,
"title": record.question,
"source_file": source_file,
"collection": FAQ_COLLECTION,
"version": SOURCE_VERSION,
"content_text": record.answer,
"tags": _knowledge_tags(record),
"reviewer_id": created_by,
"now": now,
}
existing = await session.scalar(SELECT_KNOWLEDGE_ID_BY_QA_ID, {"qa_id": record.qa_id})
if existing is None:
await session.execute(INSERT_KNOWLEDGE_META, params)
inserted = await session.scalar(SELECT_LAST_INSERT_ID)
if inserted is None: # pragma: no cover - 只在非 MySQL 后端上发生
raise RuntimeError(f"{record.qa_id} 插入后取不到 LAST_INSERT_ID()")
return int(inserted)
knowledge_id = int(existing)
await session.execute(UPDATE_KNOWLEDGE_META, {**params, "id": knowledge_id})
return knowledge_id
async def replace_imported_synonyms(
session: AsyncSession, knowledge_id: int, record: QaRecord, *,
created_by: int, now: datetime,
) -> int:
"""重建该知识的 `import` 来源同义问法(含标准问法本身),返回写入行数。"""
await session.execute(
DELETE_IMPORTED_SYNONYMS,
{"knowledge_id": knowledge_id, "source_type": IMPORT_SOURCE_TYPE},
)
digests: set[str] = set()
written = 0
for phrase in (record.question, *record.synonyms):
digest = phrase_hash(phrase)
if digest in digests:
continue
digests.add(digest)
await session.execute(INSERT_SYNONYM, {
"knowledge_id": knowledge_id,
"phrase": phrase,
"normalized_phrase": normalize_phrase(phrase),
"phrase_hash": digest,
"source_type": IMPORT_SOURCE_TYPE,
"created_by": created_by,
"now": now,
})
written += 1
return written
async def enqueue_vector_sync(
session: AsyncSession, knowledge_id: int, *, now: datetime,
) -> None:
"""投递向量同步事件;Milvus 的写入由 Task 5 的 Outbox Worker 负责。"""
await session.execute(INSERT_OUTBOX_EVENT, {
"event_id": str(uuid4()),
"event_type": VECTOR_SYNC_EVENT,
"aggregate_id": str(knowledge_id),
"trace_id": str(uuid4()),
"payload": json.dumps({"knowledge_id": str(knowledge_id)}),
"now": now,
})
async def import_records(
records: list[QaRecord], *, source_file: str, created_by: int, now: datetime,
) -> tuple[int, int]:
"""单事务导入全部记录,返回 `(知识行数, 同义问法行数)`。"""
synonyms_written = 0
async with SessionFactory() as session, session.begin():
for record in records:
knowledge_id = await upsert_knowledge_meta(
session, record, source_file=source_file, created_by=created_by, now=now,
)
synonyms_written += await replace_imported_synonyms(
session, knowledge_id, record, created_by=created_by, now=now,
)
await enqueue_vector_sync(session, knowledge_id, now=now)
return len(records), synonyms_written
async def verify(expected_knowledge: int, expected_synonyms: int) -> None:
"""按读路径的**同一口径**复查落库结果:任一项不符就 `SystemExit`(非零退出码)。
四项断言,全部是"== 期望值":知识行数、同义问法行数、被向量同步事件覆盖的
`aggregate_id` 数、验收补充条目可查。**不打印了事**——见函数体内的说明。
"""
async with SessionFactory() as session:
published = await session.scalar(text(
"SELECT COUNT(*) FROM fin_knowledge_meta"
" WHERE review_status='published' AND status='active'"
" AND milvus_collection=:collection"
" AND (effective_date IS NULL OR effective_date <= UTC_DATE())"
" AND (expire_date IS NULL OR expire_date > UTC_DATE())"
), {"collection": FAQ_COLLECTION})
imported_synonyms = await session.scalar(text(
"SELECT COUNT(*) FROM agent_faq_synonym"
" WHERE source_type=:source_type AND status='approved'"
), {"source_type": IMPORT_SOURCE_TYPE})
events = await session.scalar(text(
"SELECT COUNT(*) FROM domain_event_outbox WHERE event_type=:event_type"
), {"event_type": VECTOR_SYNC_EVENT})
covered = await session.scalar(text(
"SELECT COUNT(DISTINCT aggregate_id) FROM domain_event_outbox"
" WHERE event_type=:event_type"
), {"event_type": VECTOR_SYNC_EVENT})
supplement = await session.scalar(text(
"SELECT id FROM fin_knowledge_meta WHERE title=:title"
" AND knowledge_type=:knowledge_type AND milvus_collection=:collection"
" AND review_status='published' AND status='active'"
), {"title": ACCEPTANCE_SUPPLEMENT.question,
"knowledge_type": KNOWLEDGE_TYPE, "collection": FAQ_COLLECTION})
# 断言而不是打印:`verify()` 存在的意义就是"每轮导入都确认真的能被读路径查到"。
# 只打印不断言的话,一轮投递 0 个事件、知识行被别的流程删掉,脚本照样打印
# `verified:` 并退出 0——「静默失效」比报错难查得多(本 Task 审查时真的发生过:
# 底座配置发布测试的 `finally` 清理删 Outbox 时漏了 `event_type` 条件,
# `aggregate_id` 是字符串列而 release_id 也是小整数,数值碰撞把知识事件误删了)。
print(f" published+active+有效 知识行: {published}(本次导入 {expected_knowledge} 条)")
print(f" approved+import 同义问法行: {imported_synonyms}(本次写入 {expected_synonyms} 条)")
print(f" {VECTOR_SYNC_EVENT} 事件行: {events},覆盖 {covered} 个 aggregate_id"
f"(本次投递 {expected_knowledge} 条)")
# 用 `==` 而不是 `>=`:多出来的行同样是坏消息(别的流程往同一集合里写了不在
# 本次导入范围内的知识),放过去等于把这个集合的"精确镜像"性质丢掉。
if published is None or int(published) != expected_knowledge:
raise SystemExit(f"知识行不符:期望 == {expected_knowledge},实际 {published}")
if imported_synonyms is None or int(imported_synonyms) != expected_synonyms:
raise SystemExit(f"同义问法行不符:期望 {expected_synonyms},实际 {imported_synonyms}")
# 本脚本对 `import_records` 的每条知识都投一条事件(见 `import_records` 循环),
# 所以"事件覆盖的 aggregate_id 数 == 知识行数"在本设计下必须成立:
# 少一个就说明有知识行没进入向量同步队列(就是那个被误删的场景)。
if covered is None or int(covered) != expected_knowledge:
raise SystemExit(
f"向量同步事件未覆盖全部知识行:期望 {expected_knowledge} 个 aggregate_id,"
f"实际 {covered}"
)
if supplement is None:
raise SystemExit(
f"验收补充条目查不到:{ACCEPTANCE_SUPPLEMENT.question}"
f"(knowledge_type={KNOWLEDGE_TYPE}, collection={FAQ_COLLECTION}, published+active)"
)
print(f" verified: 验收补充条目 id={supplement} 可查;"
f"{expected_knowledge} 个知识行全部有向量同步事件")
async def main() -> None:
parser = argparse.ArgumentParser(description="导入已审核 QA 知识库(幂等,可重复执行)")
parser.add_argument("--source", required=True, type=Path, help="已审核 QA 源文件")
parser.add_argument("--created-by", required=True, type=int, help="sys_user.id(外键)")
parser.add_argument("--dry-run", action="store_true", help="只解析、不写库")
args = parser.parse_args()
records = parse_qa_source(args.source.read_text(encoding="utf-8"))
records.append(ACCEPTANCE_SUPPLEMENT)
print(f"parsed {len(records)} records"
f" (含 1 条验收补充:{ACCEPTANCE_SUPPLEMENT.qa_id})")
if args.dry_run:
print("dry-run: 未写库")
return
now = datetime.now(UTC).replace(tzinfo=None)
knowledge_count, synonym_count = await import_records(
records, source_file=str(args.source), created_by=args.created_by, now=now,
)
print(f"imported {knowledge_count} knowledge rows, {synonym_count} synonym rows;"
f" {knowledge_count} vector sync events queued")
await verify(knowledge_count, synonym_count)
if __name__ == "__main__":
asyncio.run(main())