Files
group_fqcd_jr/app/service/knowledge_management_service.py
T
张胜宇 e239eb778b docs: 品牌全量口径统一为「南方基金」+ 作废文档清理
1) 客服 Agent 四份交付文档 + 构建脚手架:品牌由包装占位 XX科技 / 旧名 南方财富
   统一为南方基金(热线 400-889-8899 / 官网 nffund.com),系统名改为「智能服务系统」;
   同步追加 §0.4 修订记录行,工程记录行保留原占位字面以支撑硬编码扫描验收。
2) 开发文档:清理 28 份已作废/残留文档(14 份移出归档 + 14 份仓库副本),
   新增《文档规整方案与开发前待决事项-2026-09-17》。
3) 客服agent 四份交付文档首次纳入本分支。
2026-09-17 15:15:22 +08:00

351 lines
17 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.
"""知识库管理服务(Task 11):上传 / 查询 / 删除文档 / 补投向量清理。
## 为什么单独一个 Service,而不是塞进 `KnowledgeIngestService`
`KnowledgeIngestService.ingest(...)` 是**切分与入库的活动链**(一个 chunk 一行知识 + 一条
向量同步事件),但它**不 commit**(事务归调用方)、不认识权限、不认识删除语义。接口层需要的
是"一个文档维度的管理用例":鉴权 → 开事务 → 复用入库链 → 提交。那一层就是本模块,
`KnowledgeIngestService` 一行不改(它的 `LAST_INSERT_ID()` 成对约束与事件形状都保持在原处)。
## 事务边界(谁 commit)
本模块**拥有事务**:`async with session.begin()` 包住整段写入并提交;入库链中途抛错
(不支持的文件类型、非法 knowledge_type)时事务整体回滚,不会留下半截知识行。
`ingest` 内部投的 `knowledge.vector_sync_requested` 与知识行同事务,因此"知识入库了但
向量同步事件丢了"这个中间态在接口层同样不成立。
## 删除语义(老师原文:标记 + 投删除事件)
一次删除在**同一个事务**里做两件事:`fin_knowledge_meta.status = 'expired'` 与投
`knowledge.vector_delete_requested`(消费侧见 `app/worker/knowledge_vector_worker.py`
的 `remove`)。**不做物理删除**:知识行是审计与对账的锚点,删掉就再也说不清"这条知识
什么时候被谁下线过"。文件字节的归档(`LocalDocumentStorage.archive()`)在提交之后做,
且**尽力而为**:归档失败只记日志,不回滚已经提交的删除 —— 否则会出现"库里还是 active、
存储里已经归档"的不一致,比"库已 expired、文件还在"危险得多(后者只浪费磁盘)。
删除只覆盖"**本次调用**造成的 active→expired"。历史上已经过期、但从未投过删除事件的行
(早期脚本改库、导入侧幂等上线前的重复副本)需要 `cleanup_vector` 补投一次 —— 为什么不能
让 `delete_document` 顺带兼容它们:那会让"重复删除"变成静默成功,调用方再也分不清
"这一次真的下线了"和"它早就过期了,我什么都没改"。
## 权限
四个端点统一要求 `knowledge:manage`(admin 角色持有;权限码在 `tools/seed_test_rbac.py`
的 `PERMISSIONS` 里,id 9019)。复用既有 `knowledge:query`(9018)会让**客户角色**看见
整库文档清单与正文摘要,知识库的后台维护面不该向客户开放。
"""
import base64
import binascii
import logging
from collections.abc import Callable
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from uuid import uuid4
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.contracts import RequestContext
from app.core.errors import GenericResourceNotFoundError, ValidationAgentError
from app.infrastructure.db import SessionFactory
from app.model.knowledge import KnowledgeMeta
from app.model.platform import DomainEventOutbox
from app.service.authorization_service import AuthorizationService
from app.service.document_parser import DocumentParser
from app.service.knowledge_ingest_service import (
ALLOWED_KNOWLEDGE_TYPES,
KnowledgeIngestService,
)
from app.worker.knowledge_vector_worker import (
KNOWLEDGE_AGGREGATE_TYPE,
VECTOR_DELETE_EVENT,
)
logger = logging.getLogger(__name__)
#: 管理面的唯一权限码(见模块 docstring)。
REQUIRED_PERMISSION = "knowledge:manage"
#: 删除后的状态取值。`expired` 表示已过期/已下线的知识,与
#: `KnowledgeReferenceService` 的 `ACTIVE_STATUS = "active"` 相互排斥。
EXPIRED_STATUS = "expired"
#: 列表返回的正文预览长度:管理面需要能分辨同名文档,但接口不该整篇回吐正文
#: (正文是检索侧素材,全量回吐等于绕开引用接口的脱敏口径)。
CONTENT_PREVIEW_LENGTH = 200
#: 上传时 `filename` 的最大长度(`fin_knowledge_meta.source_file` 是 `String(256)`)。
FILENAME_MAX_LENGTH = 256
#: 按"当次事务的 session"构造入库链的工厂:事务归本服务,入库链**不持有** session,
#: 否则一个 Service 实例被两次请求复用时会串事务。
IngestFactory = Callable[[AsyncSession], Any]
#: 本地文档存储根目录(MinIO 无实例时的一期实现,见 `app/infrastructure/document_storage.py`)。
#: 只影响字节落盘位置,不影响 `fin_knowledge_meta.minio_path` 里记录的相对 key。
KNOWLEDGE_STORAGE_ROOT = Path("data") / "knowledge_documents"
def decode_content(content_base64: str) -> bytes:
"""严格 base64 解码:非法输入直接失败,不做"忽略错误字符"的宽容解码。
宽容解码(`validate=False`)会把 `!!!` 之类解码成空字节,最终表现为
"上传成功但文档入库为空"——错误被推到很远的地方才暴露。
"""
try:
return base64.b64decode(content_base64, validate=True)
except (binascii.Error, ValueError) as exc:
raise ValidationAgentError("content_base64 不是合法的 base64 内容") from exc
class KnowledgeManagementService:
"""知识文档管理用例:上传(切分+入库)、列表查询、删除(标记+投递向量删除)。"""
def __init__(
self,
*,
ingest_factory: IngestFactory,
session_factory: Callable[[], Any] | None = None,
storage: Any = None,
archive_on_delete: bool = True,
) -> None:
self._ingest_factory = ingest_factory
self._session_factory: Callable[[], Any] = session_factory or SessionFactory
self._storage = storage
self._archive_on_delete = archive_on_delete
async def upload(
self,
context: RequestContext,
*,
filename: str,
content: bytes,
knowledge_type: str,
) -> dict[str, Any]:
"""上传一份文档并自动入库,返回本次产生的 `knowledge_id` 列表。
`created_by` **只**来自认证上下文(`context.user_id`),方法不提供该参数:
允许调用方传就等于允许伪造导入人,而 `fin_knowledge_meta.reviewer_id` 是
"谁导入了这条知识"的唯一线索。
"""
await AuthorizationService.require(context, REQUIRED_PERMISSION)
self._assert_filename(filename)
async with self._session_factory() as session, session.begin():
knowledge_ids = await self._ingest_factory(session).ingest(
filename=filename,
content=content,
knowledge_type=knowledge_type,
created_by=int(context.user_id),
)
return {
"knowledge_ids": list(knowledge_ids),
"filename": filename,
"knowledge_type": knowledge_type,
"created_by": context.user_id,
"chunk_count": len(knowledge_ids),
}
async def list_documents(
self,
context: RequestContext,
*,
limit: int,
offset: int,
knowledge_type: str | None = None,
) -> dict[str, Any]:
"""列出未过期(`status != 'expired'`)的知识行,按 id 倒序。
默认口径是**只返回未过期行**:被删除的知识仍在库里(删除是标记而非物理删除),
管理面默认视图不应该再列出它们。
"""
await AuthorizationService.require(context, REQUIRED_PERMISSION)
if knowledge_type is not None and knowledge_type not in ALLOWED_KNOWLEDGE_TYPES:
raise ValidationAgentError(f"knowledge_type 非法:{knowledge_type}")
statement = (
select(KnowledgeMeta)
.where(KnowledgeMeta.status != EXPIRED_STATUS)
.order_by(KnowledgeMeta.id.desc())
.limit(limit)
.offset(offset)
)
if knowledge_type is not None:
statement = statement.where(KnowledgeMeta.knowledge_type == knowledge_type)
async with self._session_factory() as session:
rows = list((await session.scalars(statement)).all())
return {"items": [self._to_item(row) for row in rows], "count": len(rows)}
async def delete_document(
self, context: RequestContext, knowledge_id: int
) -> dict[str, Any]:
"""删除一份文档:标记 `expired` + 投递向量删除事件(+ 尽力归档文件字节)。
不存在的 id(以及已经删除过的 id)一律抛 `SESSION_NOT_FOUND` 语义的 404,
**不静默成功**:静默成功会让调用方以为向量已清理,而实际上什么都没发生。
"""
await AuthorizationService.require(context, REQUIRED_PERMISSION)
async with self._session_factory() as session, session.begin():
row = await session.get(KnowledgeMeta, knowledge_id)
if row is None or row.status == EXPIRED_STATUS:
raise GenericResourceNotFoundError("知识文档不存在")
storage_key = row.minio_path
now = datetime.now(UTC).replace(tzinfo=None)
await session.execute(
update(KnowledgeMeta)
.where(KnowledgeMeta.id == knowledge_id)
.values(status=EXPIRED_STATUS, updated_at=now)
)
self._enqueue_vector_delete(
session, knowledge_id, trace_id=context.trace_id, now=now
)
await self._archive(storage_key)
return {
"knowledge_id": knowledge_id,
"status": EXPIRED_STATUS,
"vector_delete_event": VECTOR_DELETE_EVENT,
}
async def cleanup_vector(
self, context: RequestContext, knowledge_id: int
) -> dict[str, Any]:
"""给**已经过期**的历史行补投一次向量删除事件(清掉向量侧残留)。
## 为什么需要它
`delete_document` 只在"active → expired"这**一次状态转换**上投事件。历史上有一批行
是**别的途径**变成 `expired` 的(早期脚本直接改库、重复入库被覆盖、导入侧幂等上线前的
重复副本),它们**从来没投过**删除事件,向量还留在 Milvus 里继续参与排序;
而 `delete_document` 对这些行**刻意返回 404**("不静默成功"),于是管理端口上
根本没有一个能清理它们的动作 —— 这是行为与运维需求之间的缺口,本方法就是补这个缺口。
## 口径(三条,都是"宁可报错也不假装成功")
1. id 不存在 → 404(`SESSION_NOT_FOUND` 语义),与删除接口一致;
2. 行**仍是 active** → 校验失败(422):在用的文档不该走"只清向量不标状态"这条路,
那会让检索侧读不到正文而知识行看起来还好好的,请改用 DELETE(它同时做两件事);
3. 成功 → 投出一条**新的** `knowledge.vector_delete_requested`(`event_id` 是新 uuid4,
消费侧删除幂等,重复清理同一行只是多删一次空集,不会报错)。
返回 `status`(当前状态)与 `vector_delete_event`(投出的事件名),
调用方据此能确认"事件确实被投出去了",而不是只看一个 200。
"""
await AuthorizationService.require(context, REQUIRED_PERMISSION)
async with self._session_factory() as session, session.begin():
row = await session.get(KnowledgeMeta, knowledge_id)
if row is None:
raise GenericResourceNotFoundError("知识文档不存在")
status = str(row.status)
if status != EXPIRED_STATUS:
raise ValidationAgentError(
f"知识文档当前状态为 {status},只有已过期/已下线的文档才需要补投向量清理;"
"在用的文档请改用 DELETE 接口(它同时标记过期并投出删除事件)"
)
now = datetime.now(UTC).replace(tzinfo=None)
self._enqueue_vector_delete(
session, knowledge_id, trace_id=context.trace_id, now=now
)
logger.info(
"补投向量清理事件:knowledge_id=%s status=%s trace_id=%s",
knowledge_id, status, context.trace_id,
)
return {
"knowledge_id": knowledge_id,
"status": status,
"vector_delete_event": VECTOR_DELETE_EVENT,
}
# --- 内部 ---------------------------------------------------------------
@staticmethod
def _assert_filename(filename: str) -> None:
"""文件名不得为空、不得超列宽、不得带路径:`source_file` 只存文件名本身。
带路径的文件名会被 `KnowledgeIngestService._storage_key` 剥成 basename 落存储,
但 `source_file` 列的取值同样要干净,否则管理面列表里会出现调用方的本地路径。
"""
if not filename.strip():
raise ValidationAgentError("filename 不得为空")
if len(filename) > FILENAME_MAX_LENGTH:
raise ValidationAgentError(f"filename 超过 {FILENAME_MAX_LENGTH} 字符")
if filename != Path(filename).name or "\\" in filename:
raise ValidationAgentError("filename 只能是文件名,不得包含路径")
@staticmethod
def _enqueue_vector_delete(
session: AsyncSession, knowledge_id: int, *, trace_id: str, now: datetime
) -> None:
"""投一条向量删除事件;`aggregate_id` 与 payload 都是 `str(knowledge_id)`(投递同口径)。
`event_id` 是唯一键,显式给 uuid4;`aggregate_type` 沿用投递侧常量,
保证消费侧按同一聚合维度幂等覆盖。
"""
session.add(DomainEventOutbox(
event_id=str(uuid4()),
event_type=VECTOR_DELETE_EVENT,
aggregate_type=KNOWLEDGE_AGGREGATE_TYPE,
aggregate_id=str(knowledge_id),
trace_id=trace_id or str(uuid4()),
payload={"knowledge_id": str(knowledge_id)},
status="pending",
retry_count=0,
occurred_at=now,
created_at=now,
updated_at=now,
))
async def _archive(self, storage_key: str | None) -> None:
"""提交之后把文件字节移入 `archive/`;失败只记日志(见模块 docstring 的取舍)。"""
if not self._archive_on_delete or not storage_key or self._storage is None:
return
try:
await self._storage.archive(key=storage_key)
except Exception: # noqa: BLE001 - 归档尽力而为,任何失败都不影响已提交的删除
logger.warning("知识文档归档失败,已提交的删除不回滚 storage_key=%s", storage_key)
@staticmethod
def _to_item(row: KnowledgeMeta) -> dict[str, Any]:
content = row.content_text or ""
tags = row.tags if isinstance(row.tags, dict) else {}
return {
"knowledge_id": row.id,
"knowledge_type": row.knowledge_type,
"title": row.title,
"source_file": row.source_file,
"collection": row.milvus_collection,
"version": row.version,
"status": row.status,
"review_status": row.review_status,
"created_by": row.reviewer_id,
"tags": tags,
"content_preview": content[:CONTENT_PREVIEW_LENGTH],
"content_length": len(content),
}
def build_knowledge_management_service() -> KnowledgeManagementService:
"""组合根:把接口层需要的依赖一次装好(Controller 只调它,不自己拼装配)。
`embedder` / `endpoint_resolver` 在 `KnowledgeIngestService` 里是**给后续对账自愈预留的**
依赖(入库链自己不调用 embedding,向量化由 Outbox Worker 做),因此直接用组装层
同一套工厂函数(`app.service.agent.bootstrap`)构造,不另写一套模型网关装配。
"""
from app.infrastructure.document_storage import LocalDocumentStorage
from app.service.agent.bootstrap import get_memory_embedding_service
from app.service.model_gateway import DatabaseModelEndpointResolver
def ingest_for(session: AsyncSession) -> KnowledgeIngestService:
return KnowledgeIngestService(
session=session,
parser=DocumentParser(),
storage=LocalDocumentStorage(KNOWLEDGE_STORAGE_ROOT),
embedder=get_memory_embedding_service(),
endpoint_resolver=DatabaseModelEndpointResolver(),
)
return KnowledgeManagementService(
ingest_factory=ingest_for,
session_factory=SessionFactory,
storage=LocalDocumentStorage(KNOWLEDGE_STORAGE_ROOT),
)