feat: 客服 Agent 端到端跑通(知识直返 + 答不了引导人工客服)

按业务方确定的取向实现:金融场景确定性优先,能溯源到公司资料的才答,答不了就
引导客户拨打客服热线,绝不用模型猜答案。端到端验收 8/8 通过。

新增:
- app/service/knowledge_search_service.py:知识检索。未复用记忆的 VectorMemoryAdapter
  是因为它只返回 (memory_uuid, score),会丢掉知识块的标题与正文,而客服回答必须能把
  原文与出处一起交付。检索失败一律返回 degraded 而不抛异常,由 Agent 走兜底。
- app/service/knowledge_tool.py + app/core/knowledge_contracts.py:只读工具 search_knowledge。
  走 ToolExecutor 而不是让 Agent 直接持有检索服务,是为了让白名单、权限、审计、超时
  都归基座统一管理;工具只读也符合 ToolRegistry 的硬约束。复用既有权限码
  knowledge:reference:read(customer 角色已具备),不新增权限点。
- app/service/agent/implementations/customer_service.py:Agent 本体,刻意保持薄——
  意图分发 + 四条出口(faq/产品/政策直返、闲聊走模型、其余与异常引导人工)。
  直接返回知识原文而不经模型改写,答案的字面内容全部来自公司已发布资料。
- tools/publish_customer_service_config.py:发布意图工具白名单。
- tools/customer_service_check.py:端到端验收(8 个用例,含越界请求与知识库外问题)。

装配:
- bootstrap 新增 get_knowledge_search_service 工厂,注册 search_knowledge 工具与
  customer_service Agent。
- runtime_config_service 新增 load_active_prompt:提示词绑定 release_id,按当前生效
  版本读取,未发布时回落代码默认值。闲聊话术因此可审核、可回滚,不必改代码发版。

过程中发现并处理的三个问题:
1. 自造 source_references 被基座合规闸门拒绝。governance.review_output 只接受
   「本次召回的记忆」与「本次成功调用的工具」两类引用(用于防止伪造来源),
   knowledge 类型会被判非法并使整个 run 失败。处理方式是**不放开那道校验**,
   而把知识出处(文件标题与内部编号)写进正文,source_references 交给基座自动附加。
2. 发布配置是整版本替换语义:新版本会清空旧版本的全部配置项。若只发客服白名单,
   示例 Agent 的 fund_query_demo:fund_quote 会被静默清空。故发布脚本先读取当前生效
   版本的全部配置项并原样继承,再追加新增项。
3. 验收脚本自身两处自伤:打印 emoji 触发 GBK UnicodeEncodeError、以及读错结果字段
   (RunQueryService 返回的答案键是 content 不是 text)。

已知缺口(未修,已记录):
- CoreResult.transfer_required 未持久化:conversation_message 不存该标记,
  API 读不到"本次是否引导了人工"。当前靠正文里的固定话术判断。
- 知识块引用(source_type=knowledge)尚未启用,需先让 ToolExecutor 把工具返回的
  doc_id 登记为本次可引用来源。

验证:ruff 通过、mypy 107 文件无错、unit+contract 447 passed;
tools/customer_service_check.py 8/8 通过(含越界请求、投诉、知识库外问题三类
必须引导人工的场景,以及 7 个零容忍负面词零命中)。
This commit is contained in:
2026-09-10 20:22:42 +08:00
parent d2aff7c129
commit 13bab7c3d0
8 changed files with 890 additions and 0 deletions
+41
View File
@@ -7,14 +7,18 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import get_settings
from app.core.errors import RecoverableAgentError
from app.core.fund_contracts import FundQuoteQuery
from app.core.knowledge_contracts import KnowledgeSearchInput
from app.infrastructure.fund_quote_cache import FundQuoteCache
from app.infrastructure.memory_cache import MemoryCacheAdapter
from app.infrastructure.vector_memory import VectorMemoryAdapter
from app.service.agent.factory import AgentFactory
from app.service.agent.governance import PlatformGovernance
from app.service.agent.implementations.customer_service import CustomerServiceAgent
from app.service.agent.implementations.fund_query_demo import FundQueryDemoAgent
from app.service.fund_quote_service import query_fund_quote_tool
from app.service.intent_classifier import IntentClassifier
from app.service.knowledge_search_service import KnowledgeSearchService
from app.service.knowledge_tool import knowledge_search_tool
from app.service.memory_recall_service import MemoryRecallService
from app.service.model_gateway import (
DatabaseModelEndpointResolver,
@@ -103,6 +107,25 @@ async def _embed_text(text: str) -> list[float]:
return execution.vector
@lru_cache(maxsize=1)
def get_knowledge_search_service() -> KnowledgeSearchService:
"""客服知识检索装配:Milvus 客户端 + 向量化入口。
与记忆的语义通道同一取向:Milvus 不可达或缺少 embedding 端点时**不抛异常**,
而是返回 `available=False` 的实例,检索结果标记 `degraded`,由客服 Agent 走
「引导客户致电人工客服」。基础设施故障不该表现成客户可见的错误。
"""
client = None
try:
from pymilvus import MilvusClient
settings = get_settings()
client = MilvusClient(uri=settings.milvus_uri, token=settings.milvus_token or None)
except Exception:
logger.warning("knowledge vector client unavailable; search degrades", exc_info=True)
return KnowledgeSearchService(client, _embed_text)
def build_memory_recall_service(session: AsyncSession) -> MemoryRecallService:
"""记忆召回组装:结构化召回始终可用,Redis 缓存与语义通道可用时叠加。
@@ -143,6 +166,17 @@ def get_agent_factory() -> AgentFactory:
# (12s)与重试预算,多代码查询必然先撞工具超时。
timeout_seconds=15,
))
registry.register(ToolDefinition(
name="search_knowledge",
input_model=KnowledgeSearchInput,
handler=cast(Any, knowledge_search_tool),
# 复用既有权限码(customer 角色已具备),不新增权限点
required_permission="knowledge:reference:read",
allowed_roles=("customer", "advisor", "operator", "admin"),
# 检索含一次 embedding 调用 + 三次集合检索;embedding 实测 0.42s,
# 10s 覆盖冷启动与 Milvus 抖动,又不至于让客户等太久
timeout_seconds=10,
))
model_service = get_model_service()
endpoint_resolver = DatabaseModelEndpointResolver()
factory = AgentFactory(
@@ -172,3 +206,10 @@ def register_business_agents(factory: AgentFactory) -> None:
FundQueryDemoAgent.definition,
lambda _context: FundQueryDemoAgent(FundQueryDemoAgent.definition),
)
# 客服 Agent:只回答能溯源到公司资料的问题,答不了引导客户致电人工客服。
# 它声明了 5 个意图,但真正能调用 search_knowledge 的范围由发布配置逐意图收窄
# (`agent_tools` 里 `customer_service:<intent>`),未发布的意图工具失败关闭。
factory.register(
CustomerServiceAgent.definition,
lambda _context: CustomerServiceAgent(CustomerServiceAgent.definition),
)
@@ -0,0 +1,251 @@
"""客服 Agent:只回答能溯源到公司资料的问题,答不了就引导客户致电人工客服。
设计取向(金融场景,由业务方确定):
- **确定性优先**:命中知识块后**直接返回原文**,不经模型改写。答案的字面内容来自公司
已发布的资料,模型不参与事实生成,因此不存在"编一个看起来合理的答案"的通路。
- **答不了就引导**:检索降级、未命中、置信度不足、意图未覆盖、模型异常——一律返回
引导客户拨打客服热线的固定话术,并置 `transfer_required=True` 留痕;
绝不用模型猜测答案。
- **本体保持薄**:只有意图分发与四条出口,不做多轮推理、不自主决策。这是业务方明确
要求的取向——"客服 Agent 本身不会很多内容,不会的就转人工"。
四条出口:
`faq` → 检索直返(不经模型)|`product_inquiry` / `policy_explain` → 检索直返 + 来源引用
|`chitchat` → 模型生成(提示词走发布配置)|其余与异常 → 引导人工客服
"""
from app.core.contracts import (
AgentDefinition,
AgentRequest,
CoreResult,
RequestContext,
SourceReference,
)
from app.core.errors import ForbiddenAgentError
from app.service.agent.base import BaseAgent
from app.service.model_gateway import DatabaseModelEndpointResolver
from app.service.runtime_config_service import load_active_prompt
AGENT_TYPE = "customer_service"
# 意图码必须三处对齐:AgentDefinition.supported_intents、agent_intent_config 的
# (agent_type, intent_code)、以及发布版 config_release 里 agent_tools 的
# `customer_service:<intent>` 白名单 key。缺任一处即失败关闭,这是底座的有意设计。
INTENT_FAQ = "faq"
INTENT_PRODUCT = "product_inquiry"
INTENT_POLICY = "policy_explain"
INTENT_CHITCHAT = "chitchat"
INTENT_TRANSFER = "transfer_human"
BUSINESS_INTENTS = (INTENT_FAQ, INTENT_PRODUCT, INTENT_POLICY)
TOOL_NAME = "search_knowledge"
# 三档置信阈值(方案 §2.3,业务方已确认先按此跑通、后续用语料校准)。
# 分数为 COSINE 相似度,落在 [0, 1]。
HIGH_SCORE = 0.75 # ≥ 直接答
MID_SCORE = 0.60 # ≥ 且 < 高置信:答 + 提示可能不完整;< 该值:不硬答,引导人工
TOP_K = 5
MAX_ANSWER_CHARS = 1200
REFERENCE_LIMIT = 3
COMPANY = "南方科技"
# 客服热线:正式号码确定后改这里(或改为读配置项,避免改代码)
HOTLINE = "400-XXX-XXXX"
SERVICE_HOURS = "每日 7:00-22:00"
FALLBACK_TEMPLATE = (
"抱歉,这个问题我暂时无法给出准确答复。为避免给您错误信息,"
f"建议您拨打客服热线 {HOTLINE}({SERVICE_HOURS})转人工客服咨询。"
)
INCOMPLETE_NOTICE = "(以上信息可能不完整,具体以产品说明书与公司制度为准)"
DISCLAIMER = "(以上内容由智能客服依据公司公开资料整理,不构成投资建议)"
CHITCHAT_PROMPT_CODE = "customer_service_chitchat"
CHITCHAT_TASK_TYPE = "chat"
DEFAULT_CHITCHAT_SYSTEM = (
f"你是{COMPANY}的智能客服助手。回应要简短、礼貌,并自然引导用户提出与基金、理财、"
"账户相关的问题。禁止承诺收益,禁止出现「保本」「稳赚」「无风险」「保证收益」"
"「预期收益率」「年化收益率」「安全」等表述。"
)
DEFAULT_CHITCHAT_TEMPLATE = "用户说:{message}\n请用不超过 40 字回应,并把话题引导到业务上。"
class CustomerServiceAgent(BaseAgent):
definition = AgentDefinition(
agent_type=AGENT_TYPE,
version="1.0.0",
allowed_roles=("customer",),
allowed_portals=("api",),
# 代码上限:实际可用范围由发布配置的意图白名单收窄(两者取交集)
allowed_tools=(TOOL_NAME,),
supported_intents=(
INTENT_FAQ, INTENT_PRODUCT, INTENT_POLICY, INTENT_CHITCHAT, INTENT_TRANSFER,
),
)
async def handle(self, request: AgentRequest, context: RequestContext) -> CoreResult:
intent = self._intent_code()
if intent == INTENT_CHITCHAT:
return await self._chitchat(request)
if intent == INTENT_TRANSFER:
return self._guide_to_human("客户主动要求转人工")
if intent not in BUSINESS_INTENTS:
# 分类失败或意图未覆盖:不猜,直接引导人工
return self._guide_to_human(f"意图未覆盖:{intent or '未识别'}")
return await self._answer_from_knowledge(request, context, intent)
# ---- 出口一:知识直返(faq / 产品 / 政策) ----
async def _answer_from_knowledge(
self, request: AgentRequest, context: RequestContext, intent: str
) -> CoreResult:
try:
output = await self.call_tool(
TOOL_NAME,
{"query": request.message[:500], "top_k": TOP_K},
intent=intent,
context=context,
)
except ForbiddenAgentError:
# 白名单/权限类失败必须冒泡:那是配置错误,若被兜底话术吞掉,
# 运维会看到"客服一直引导人工"却查不出原因。
raise
except Exception:
return self._guide_to_human("知识检索调用失败")
if not isinstance(output, dict):
return self._guide_to_human("知识检索返回格式异常")
if output.get("degraded"):
reason = str(output.get("reason") or "未知")
return self._guide_to_human(f"知识检索降级:{reason}")
hits = output.get("hits")
if not isinstance(hits, list) or not hits:
return self._guide_to_human("知识库未命中")
best = hits[0]
if not isinstance(best, dict):
return self._guide_to_human("命中内容格式异常")
score = self._score(best.get("score"))
if score < MID_SCORE:
return self._guide_to_human(f"置信度不足:{score:.3f}")
content = str(best.get("content") or "").strip()
if not content:
return self._guide_to_human("命中内容为空")
answer = content[:MAX_ANSWER_CHARS]
if score < HIGH_SCORE:
answer = f"{answer}\n{INCOMPLETE_NOTICE}"
# 知识出处写进正文,而不是塞进 source_references:
# `governance.review_output` 只接受「本次召回的记忆」与「本次成功调用的工具」两类引用
# (用于防止 Agent 伪造来源),知识块的 doc_id 不属于这两类,会被判为非法引用。
# 把文件标题与内部文件编号写进正文,客户与人工同样能核对,且不必放开那道校验。
source_note = self._source_note(best)
text = (
f"{answer}\n{source_note}\n{DISCLAIMER}"
if source_note
else f"{answer}\n{DISCLAIMER}"
)
return CoreResult(text=text, intent=self._classified_intent)
# ---- 出口二:闲聊(提示词走发布配置) ----
async def _chitchat(self, request: AgentRequest) -> CoreResult:
system, template = await self._chitchat_prompt()
message = request.message[:500]
try:
prompt = template.format(message=message, company=COMPANY)
except (KeyError, IndexError, ValueError):
# 发布配置里的占位符与代码不一致时回落默认模板:配置写错不应该让运行期崩
system = DEFAULT_CHITCHAT_SYSTEM
prompt = DEFAULT_CHITCHAT_TEMPLATE.format(message=message, company=COMPANY)
full_prompt = f"{system}\n\n{prompt}" if system else prompt
try:
endpoints = await DatabaseModelEndpointResolver().resolve(
agent_type=AGENT_TYPE, task_type="text_generation"
)
# 显式转成 list[object]:generate_with_model 形参是 list[object],
# 而 list 不变型(invariant),直接传 list[ModelEndpointConfig] 过不了类型检查
endpoint_list: list[object] = list(endpoints)
execution = await self.generate_with_model(endpoint_list, full_prompt)
except Exception:
return self._guide_to_human("模型不可用")
text = (execution.text or "").strip()
if not text:
return self._guide_to_human("模型返回为空")
return CoreResult(
text=f"{text[:MAX_ANSWER_CHARS]}\n{DISCLAIMER}",
intent=self._classified_intent,
)
async def _chitchat_prompt(self) -> tuple[str, str]:
"""读取当前发布版本的闲聊提示词;未发布或读取失败时回落代码内置默认值。"""
try:
row = await load_active_prompt(CHITCHAT_PROMPT_CODE, CHITCHAT_TASK_TYPE, AGENT_TYPE)
except Exception:
row = None
if row is None:
return DEFAULT_CHITCHAT_SYSTEM, DEFAULT_CHITCHAT_TEMPLATE
return (
row.system_prompt or DEFAULT_CHITCHAT_SYSTEM,
row.user_prompt_template or DEFAULT_CHITCHAT_TEMPLATE,
)
# ---- 出口三:引导人工客服(不做工单,只回话并留痕) ----
def _guide_to_human(self, reason: str) -> CoreResult:
return CoreResult(
text=FALLBACK_TEMPLATE,
intent=self._classified_intent,
transfer_required=True,
transfer_reason=reason[:200],
)
# ---- 辅助 ----
def _intent_code(self) -> str:
classified = self._classified_intent
return classified.intent if classified is not None else ""
@staticmethod
def _score(value: object) -> float:
"""把命中分数夹到 [0,1]:SourceReference.score 有 ge=0/le=1 约束。"""
try:
number = float(value) # type: ignore[arg-type]
except (TypeError, ValueError):
return 0.0
return min(1.0, max(0.0, number))
@staticmethod
def _source_note(hit: dict[str, object]) -> str:
"""把知识出处写成一行正文(文件标题 + 内部文件编号),便于客户与人工核对。"""
title = str(hit.get("title") or "").strip()
doc_no = str(hit.get("doc_no") or "").strip()
if not title:
return ""
return f"(依据:{title}{',' + doc_no if doc_no else ''})"
def _references(self, hits: list[object]) -> tuple[SourceReference, ...]:
"""保留待用:等基座支持 knowledge 类型引用后再启用。
目前 `governance.review_output` 只认可 memory / tool 两类来源,直接返回
knowledge 引用会被判为「引用未来自本次已授权召回结果」而让整个 run 失败。
启用前提是让 ToolExecutor 把工具返回的知识 doc_id 登记为本次可引用来源。
"""
references: list[SourceReference] = []
for hit in hits[:REFERENCE_LIMIT]:
if not isinstance(hit, dict):
continue
doc_id = str(hit.get("doc_id") or "").strip()
if not doc_id:
continue # 没有标识的命中无法回溯,丢弃而不是造一个假来源
title = str(hit.get("title") or "").strip()
references.append(SourceReference(
source_type="knowledge",
source_id=doc_id,
title=title[:200] or None,
score=self._score(hit.get("score")),
))
return tuple(references)
+188
View File
@@ -0,0 +1,188 @@
"""知识库检索:把客户问题向量化后在三个知识集合里检索(只读)。
为什么不复用记忆那套 `VectorMemoryAdapter`:它只把命中折叠成 `(memory_uuid, score)`,
会把知识块的标题与正文丢掉。而客服回答必须能把**原文与来源**一起交给客户——金融场景
里「答案出自哪份文件的哪一条」本身就是交付物的一部分,丢了正文等于没法给来源引用。
失败语义与基座一致:**任何一步失败都不抛异常给主链路**,而是返回 `degraded=True`
的空结果,由调用方(客服 Agent)据此走「引导客户致电人工客服」的兜底路径。
金融场景下"答不了"是可接受的结果,"答错"不是。
"""
from collections.abc import Awaitable, Callable, Sequence
from dataclasses import dataclass, field
from typing import Any, Protocol
# 三个知识集合(方案 §2.4.4 / §4.1)
FAQ_COLLECTION = "fin_faq_collection"
PRODUCT_COLLECTION = "fin_product_collection"
POLICY_COLLECTION = "fin_policy_collection"
DEFAULT_COLLECTIONS: tuple[str, ...] = (FAQ_COLLECTION, PRODUCT_COLLECTION, POLICY_COLLECTION)
# 检索输出字段:与灌库脚本写入的 schema 对齐
OUTPUT_FIELDS = (
"doc_id", "title", "content", "chapter", "section",
"tags", "doc_no", "version", "source_file", "visibility",
)
class VectorSearcher(Protocol):
"""只依赖用到的两个方法,便于测试替身注入。"""
def search(self, **kwargs: Any) -> Any: ...
Embedder = Callable[[str], Awaitable[list[float]]]
@dataclass(frozen=True)
class KnowledgeHit:
"""一条知识命中;`score` 为 COSINE 相似度(越大越相似)。"""
doc_id: str
title: str
content: str
score: float
source_file: str = ""
visibility: str = "public"
doc_no: str = ""
version: str = ""
chapter: str = ""
@property
def reference_title(self) -> str:
"""给客户看的来源标题:优先带内部文件编号,便于人工核对。"""
return f"{self.title}({self.doc_no})" if self.doc_no else self.title
@dataclass(frozen=True)
class KnowledgeSearchOutcome:
"""检索结果;`degraded=True` 表示检索链路故障,调用方必须走兜底而非当'没找到'。"""
hits: tuple[KnowledgeHit, ...] = ()
degraded: bool = False
reason: str = ""
searched_collections: tuple[str, ...] = field(default_factory=tuple)
@property
def best(self) -> KnowledgeHit | None:
return self.hits[0] if self.hits else None
@property
def top_score(self) -> float:
return self.hits[0].score if self.hits else 0.0
class KnowledgeSearchService:
def __init__(
self,
client: VectorSearcher | None,
embedder: Embedder | None,
*,
collections: Sequence[str] = DEFAULT_COLLECTIONS,
) -> None:
self._client = client
self._embedder = embedder
self._collections = tuple(collections)
@property
def available(self) -> bool:
"""向量库与向量化能力是否都在位;缺任一项都不做检索,直接走兜底。"""
return self._client is not None and self._embedder is not None
async def search(
self,
query: str,
*,
collections: Sequence[str] | None = None,
top_k: int = 5,
include_internal: bool = False,
) -> KnowledgeSearchOutcome:
"""检索知识库。
`include_internal=False`(默认)时在 Milvus 侧就过滤掉 `visibility=internal` 的块,
内部资料不进入面向客户的答案——这是检索层的硬隔离,不依赖提示词约束。
"""
text = query.strip()
if not text:
return KnowledgeSearchOutcome(reason="empty_query")
if not self.available:
return KnowledgeSearchOutcome(degraded=True, reason="vector_backend_unavailable")
client, embedder = self._client, self._embedder
if client is None or embedder is None:
# 与 available 重复,但这里需要类型收窄(mypy 不跨属性判断 Optional)
return KnowledgeSearchOutcome(degraded=True, reason="vector_backend_unavailable")
try:
vector = await embedder(text)
except Exception:
return KnowledgeSearchOutcome(degraded=True, reason="embedding_failed")
if not vector:
return KnowledgeSearchOutcome(degraded=True, reason="embedding_empty")
targets = tuple(collections or self._collections)
expression = None if include_internal else 'visibility == "public"'
collected: list[KnowledgeHit] = []
failures = 0
for collection in targets:
try:
raw = client.search(
collection_name=collection,
data=[vector],
limit=max(1, min(top_k, 20)),
output_fields=list(OUTPUT_FIELDS),
filter=expression,
)
except Exception:
failures += 1
continue
collected.extend(self._parse(raw, collection))
if not collected and failures == len(targets) and targets:
# 三个集合全查失败:是链路故障,不是"知识库里没有"
return KnowledgeSearchOutcome(
degraded=True, reason="search_failed", searched_collections=targets
)
collected.sort(key=lambda hit: hit.score, reverse=True)
# 同一内容可能同时存在于产品手册与问答对里,按 doc_id 去重保留最高分
deduped: list[KnowledgeHit] = []
seen: set[str] = set()
for hit in collected:
if hit.doc_id in seen:
continue
seen.add(hit.doc_id)
deduped.append(hit)
return KnowledgeSearchOutcome(
hits=tuple(deduped[: max(1, top_k)]),
degraded=failures > 0,
reason="partial_collection_failure" if failures else "",
searched_collections=targets,
)
@staticmethod
def _parse(raw: Any, collection: str) -> list[KnowledgeHit]:
"""把 pymilvus 的 `[[{id, distance, entity}]]` 折叠成命中列表(纯函数,不抛异常)。"""
hits: list[KnowledgeHit] = []
groups = raw if isinstance(raw, (list, tuple)) else [raw]
for group in groups:
rows = group if isinstance(group, (list, tuple)) else [group]
for row in rows:
entity = row.get("entity") if isinstance(row, dict) else None
if not isinstance(entity, dict):
continue
content = str(entity.get("content") or "")
if not content:
continue # 没有正文的命中无法作为答案来源,直接丢弃而不是猜造
hits.append(KnowledgeHit(
doc_id=str(entity.get("doc_id") or ""),
title=str(entity.get("title") or ""),
content=content,
score=float(row.get("distance") or 0.0),
source_file=str(entity.get("source_file") or ""),
visibility=str(entity.get("visibility") or "public"),
doc_no=str(entity.get("doc_no") or ""),
version=str(entity.get("version") or ""),
chapter=str(entity.get("chapter") or ""),
))
return hits
+49
View File
@@ -0,0 +1,49 @@
"""知识检索工具:注册给 Agent 的**只读**公共工具。
为什么把知识检索做成工具,而不是让 Agent 直接持有检索服务:走 `ToolExecutor` 就同时
得到四件由基座保证的事——工具白名单(发布配置可收窄、缺配置即失败关闭)、权限校验、
调用审计(`agent.tool_executed`)、超时保护。Agent 拿到的 `source_references` 也由基座
统一附加,业务代码不能伪造来源引用。
工具只读是硬约束(`ToolRegistry.register` 会拒绝 `read_only=False`),本工具确实只查库。
"""
from typing import Any
from app.core.contracts import RequestContext
from app.core.knowledge_contracts import KnowledgeSearchInput
async def knowledge_search_tool(
arguments: KnowledgeSearchInput, context: RequestContext
) -> dict[str, Any]:
"""检索三个知识集合,返回命中原文与来源信息。
检索链路(向量化 / Milvus)任一环节失败都**不抛异常**,而是以 `degraded=True` 返回:
客服 Agent 据此走「引导客户致电人工客服」,而不是把基础设施故障暴露成客户可见的错误。
"""
del context # 检索本身不区分身份;权限与白名单已在 ToolExecutor 中校验
# 延迟导入:bootstrap 会导入本模块完成工具注册,模块级导入会形成循环依赖。
from app.service.agent.bootstrap import get_knowledge_search_service
outcome = await get_knowledge_search_service().search(
arguments.query,
collections=(arguments.collection,) if arguments.collection else None,
top_k=arguments.top_k,
)
return {
"degraded": outcome.degraded,
"reason": outcome.reason,
"hits": [
{
"doc_id": hit.doc_id,
"title": hit.title,
"content": hit.content,
"score": round(hit.score, 4),
"source_file": hit.source_file,
"doc_no": hit.doc_no,
"visibility": hit.visibility,
}
for hit in outcome.hits
],
}
+22
View File
@@ -125,3 +125,25 @@ async def load_active_intent_configs(agent_type: str) -> tuple[IntentConfigEntry
"""
async with SessionFactory() as session:
return await RuntimeConfigService(session).active_intents(agent_type)
async def load_active_prompt(
prompt_code: str, task_type: str, agent_type: str
) -> PromptTemplateVersion | None:
"""读取当前生效版本里的提示词;没有 active 版本或没有该提示词时返回 None。
`prompt_template_version` 绑定 `release_id`,所以必须先定位 active 的 `config_release`
——这也正是「提示词变更要经过审核与发布」的落地方式:改话术走发布流程,而不是改代码。
返回 None 是**正常路径**而非异常:调用方(Agent)据此回落到代码内置的默认提示词,
保证即使配置中心还没发布过这条提示词,功能也能工作。
"""
async with SessionFactory() as session:
release = await session.scalar(
select(ConfigRelease).where(ConfigRelease.status == "active")
)
if release is None:
return None
return await RuntimeConfigService(session).prompt(
release.id, prompt_code, task_type, agent_type
)