Files
group_fqcd_jr/app/service/runtime_config_service.py
张胜宇 2a55269e20 feat(W34-W36): 客服双通道口径分离收口 + 会签 20/21/22 落地 + 演示启动器修复
W34 · 会签 20/21/22 三项落地(先立单、经授权、后动手)
- 会签 20(白名单外):runtime_config_service 新增 load_collection_routes() /
  collection_routes() / _first_collection_name(),首次消费既有 JSON 列 collection_routes;
  消费方 customer_service 走「配置优先、缺失回落代码常量」。零 DDL;该列当前全为 None
  ⇒ 实际走回落路径,行为与改动前一致。
- 会签 21(类 3 + 融合层):retrieval_fusion 新增 fuse_rrf() + RRF_K(排名融合,只吃名次
  不吃分数 ⇒ 异质分数不可能污染判定分,best_vector_score 仍只取向量路原始 cosine);
  knowledge_search_service::search() 新增 literal_parallel: bool = False(默认值使行为
  逐字等同现状)。工具层透传未做 —— 那需改 KnowledgeSearchInput 契约(extra="forbid"),
  超出本单范围。
- 会签 22(发布配置 + bootstrap):customer_service 新增 INTENT_MARKET_QUOTE 常量 +
  TREND_WHITELIST_INTENT_CANDIDATES(按优先级回落)+ _trend_whitelist_intent()
  (运行时自检 + 自动回落,强于「仅报错」)。发布配置 customer_service:market_quote
  (release 260,allowed_tools=['query_fund_trend'])已写入并回读校验(9 → 10 行)。
  刻意未加入 supported_intents:授权维度与判定维度解耦,不动判定分布。
- 阶段 0:customer_service_rules 新增 normalize_query() + QUERY_SYNONYMS + 等级代号大写
  (纯函数;同义表只收纯书写差异,语义类同义留待金标 A/B 后逐条加;调用方默认不启用)。

W35 · 判定口径与融合口径分离(修 A-01 / C-04 / I-02 / E-04 四条)
- _dual_route_output 返回值新增 vector_order(向量路原始 doc_id 顺序、去重);
- 新增 _vector_decision_hits() 据此还原「判定序列」(带向量分的 basic 补位块回补首位;
  无 vector_order / 空 / id 全对不上 ⇒ 返回 None 回落原分支);
- _answer_from_knowledge 的 score / gap 与原文直返的 best 改从向量路原始序列取。
  语义边界(刻意):_evidence_pack / _exit_clarify / _answer_from_evidence 仍吃融合序列
  —— 融合的收益只留在「给哪些块、什么顺序」,符合三层分数分离约束。

W36 · 选块口径归一(收口最后一条 E-01)
- 新增 _pack_order():order 命中的块排前,其余按原相对顺序追加在后;
- _evidence_pack 新增 order= 参数,三处遍历 hits → ordered,top 由 hits[0] → ordered[0];
- _answer_from_knowledge 传入 order=[judge 的 doc_id 序列];judge is None ⇒ None
  (开关关闭时逐字零改动)。order 只当排序键、不当过滤器 ⇒ 证据包成员集合不变。

演示环境与文档
- start.ps1 / demo.ps1 默认端口 8000 → 8099(与 README / docs/06,07,09,14,15,32 /
  tools/smoke_check.py / login_console.py 的全仓口径对齐;字节级定长替换,保住
  UTF-8 BOM + CRLF,字节数不变);
- portal/README.md 更正 fin_nav_history 过期口径(「0 行」→ 实测 2494 行 / 20 个产品 /
  nav_date 覆盖 2026-03-18—2026-09-13)。

测试(新增 3 个文件、补强 2 个)
- 新增 tests/unit/service/test_decision_scope_w35.py(10 条)、
  tests/unit/service/test_evidence_pack_order_w36.py(13 条)、
  tests/unit/service/test_customer_service_trend_chart_inv8.py(INV-8 字面级判定,
  纳入 pytest 门禁,此前只在 jsdom 脚本里覆盖);
- 补强 tests/unit/core/test_customer_service_rules.py(normalize_query 7 条)与
  tests/integration/test_customer_service_trend_chart_persistence.py(图内每个数字
  都必须在答复正文出现过,判定口径与 INV-8 单测一致)。

验证
- 全量 pytest:2642 passed / 3 skipped / 0 failed(基线 2629 + 新增 13);
- 55 条金标真实链路 A/B 四组:off / norm / dual / on 均 55/55 = 100%
  (改前 dual 92.7%、on 90.9%);M-4 事实正确率恒 100%、M-7—M-10 全 0;
- 红线四条守住:融合/精排层仍不持 Milvus 客户端(INV-1)、阈值一字未动、零 DDL;
- 三个实验开关 CS_DUAL_ROUTE / CS_RERANK / CS_QUERY_NORM 仍默认关闭。
2026-09-22 18:05:59 +08:00

222 lines
9.9 KiB
Python
Raw Permalink 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.
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.infrastructure.db import SessionFactory
from app.model.configuration import (
AgentIntentConfig,
ConfigRelease,
PlatformConfigItem,
PromptTemplateVersion,
)
from app.service.intent_classifier import IntentConfigEntry
class RuntimeConfigService:
"""Reads only configuration rows bound to the caller's released version."""
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def prompt(
self, release_id: int, prompt_code: str, task_type: str, agent_type: str | None
) -> PromptTemplateVersion | None:
result = await self.session.scalar(
select(PromptTemplateVersion).where(
PromptTemplateVersion.release_id == release_id,
PromptTemplateVersion.prompt_code == prompt_code,
PromptTemplateVersion.task_type == task_type,
(PromptTemplateVersion.agent_type == agent_type)
| PromptTemplateVersion.agent_type.is_(None),
)
)
return result
async def intent(self, agent_type: str, intent_code: str) -> AgentIntentConfig | None:
result = await self.session.scalar(
select(AgentIntentConfig).where(
AgentIntentConfig.agent_type == agent_type,
AgentIntentConfig.intent_code == intent_code,
AgentIntentConfig.status == "active",
)
)
return result
async def active_intents(self, agent_type: str) -> tuple[IntentConfigEntry, ...]:
"""读取某 Agent 当前生效的意图配置,供意图分类链路使用。
生效判定与 `intent()` 一致:`status='active'`;另外尊重 `effective_at/expire_at`
的有效期窗口(未填写视为立即生效、不过期)。该表通过生成列唯一键
`uk_intent_config_active_one` 保证同一 `agent_type:intent_code` 最多一个 active
版本,因此按 intent_code 匹配不会歧义;仍按 `priority` 排序以保证结果稳定。
"""
now = datetime.now(UTC).replace(tzinfo=None)
rows = await self.session.scalars(
select(AgentIntentConfig)
.where(
AgentIntentConfig.agent_type == agent_type,
AgentIntentConfig.status == "active",
(AgentIntentConfig.effective_at.is_(None))
| (AgentIntentConfig.effective_at <= now),
(AgentIntentConfig.expire_at.is_(None)) | (AgentIntentConfig.expire_at > now),
)
.order_by(AgentIntentConfig.priority, AgentIntentConfig.intent_code)
)
return tuple(self._entry(row) for row in rows)
async def collection_routes(self, agent_type: str) -> dict[str, str]:
"""读取某 Agent 当前生效的「意图 → 收窄集合」路由。
**与 `active_intents()` 使用同一套生效判定**(`status='active'` + 有效期窗口),
因此"配置里生效的意图"与"路由里生效的意图"不会变成两套口径。
`collection_routes` 是**既有 JSON 列**(`app/model/configuration.py:94`),本方法
是对它的**首次消费**(此前全仓零消费,属"有列无消费")。列里允许是字符串、
字符串数组或 `{"collection": ...}` 形态,统一投影成 `{intent_code: collection}`。
**拿不到可用集合名的行直接跳过** —— 缺失时由调用方**回落代码常量**,
而不是塞一个空值把整条路由打断。这是本函数唯一的降级口径。
"""
now = datetime.now(UTC).replace(tzinfo=None)
rows = await self.session.scalars(
select(AgentIntentConfig)
.where(
AgentIntentConfig.agent_type == agent_type,
AgentIntentConfig.status == "active",
(AgentIntentConfig.effective_at.is_(None))
| (AgentIntentConfig.effective_at <= now),
(AgentIntentConfig.expire_at.is_(None)) | (AgentIntentConfig.expire_at > now),
AgentIntentConfig.collection_routes.is_not(None),
)
.order_by(AgentIntentConfig.priority, AgentIntentConfig.intent_code)
)
routes: dict[str, str] = {}
for row in rows:
name = _first_collection_name(row.collection_routes)
if name:
# 同一 intent_code 理论上只有一个 active 版本(唯一键保证);
# `setdefault` 是防御性的:真出现两条也只认优先级最高的那条。
routes.setdefault(row.intent_code, name)
return routes
@staticmethod
def _entry(row: AgentIntentConfig) -> IntentConfigEntry:
return IntentConfigEntry(
intent_code=row.intent_code,
intent_name=row.intent_name or "",
description=row.description,
examples=tuple(str(item) for item in (row.examples or ())),
classifier_instruction=row.classifier_instruction,
confidence_threshold=float(row.confidence_threshold),
)
async def allowed_tools(
self, release_id: int, agent_type: str, intent_code: str
) -> tuple[str, ...]:
config_key = f"{agent_type}:{intent_code}"
item = await self.session.scalar(
select(PlatformConfigItem).where(
PlatformConfigItem.release_id == release_id,
PlatformConfigItem.namespace == "agent_tools",
PlatformConfigItem.config_key == config_key,
)
)
if item is None:
return ()
raw_tools = item.value_json.get("allowed_tools", [])
if not isinstance(raw_tools, list) or not all(isinstance(tool, str) for tool in raw_tools):
raise ValueError("invalid tool whitelist configuration")
return tuple(raw_tools)
async def fund_quote(self, release_id: int) -> dict[str, object]:
"""读取已发布行情配置;缺失时返回空映射,由 Service 使用安全默认值。"""
item = await self.session.scalar(
select(PlatformConfigItem).where(
PlatformConfigItem.release_id == release_id,
PlatformConfigItem.namespace == "fund_market",
PlatformConfigItem.config_key == "default",
)
)
return item.value_json if item is not None else {}
async def load_fund_quote_config() -> dict[str, object]:
"""读取当前激活行情配置;配置中心不可用时交给调用方使用默认值。"""
async with SessionFactory() as session:
release = await session.scalar(
select(ConfigRelease).where(ConfigRelease.status == "active")
)
if release is None:
return {}
return await RuntimeConfigService(session).fund_quote(release.id)
def _first_collection_name(value: object) -> str:
"""从 `collection_routes` 的 JSON 值里取**第一个可用集合名**;取不到返回空串。
兼容三种已见过的写法,避免把列格式钉死(列是既有的,历史/人工写入的形态不可控):
- `"fin_faq_collection"` —— 裸字符串
- `["fin_faq_collection", ...]` —— 数组(取首个非空)
- `{"collection": "fin_faq_collection"}`
"""
if isinstance(value, str):
return value.strip()
if isinstance(value, (list, tuple)):
for item in value:
name = _first_collection_name(item)
if name:
return name
return ""
if isinstance(value, dict):
for key in ("collection", "name", "target"):
if key in value:
name = _first_collection_name(value[key])
if name:
return name
return ""
return ""
async def load_collection_routes(agent_type: str) -> dict[str, str]:
"""「意图 → 收窄集合」路由的运行期装载器。
与 `load_active_intent_configs()` 并列、互不影响;**读不到(无 active 行、列全空、
配置中心不可用)一律返回空映射**,由调用方回落代码常量
(`customer_service.NARROW_COLLECTION_BY_INTENT`)—— 即"配置优先、缺失回落"。
"""
async with SessionFactory() as session:
return await RuntimeConfigService(session).collection_routes(agent_type)
async def load_active_intent_configs(agent_type: str) -> tuple[IntentConfigEntry, ...]:
"""意图分类链路的运行期配置装载器:只读 `agent_intent_config` 的 active 行。
该表不绑定 `config_release`(没有 `release_id` 外键),因此这里按 Agent 直接读取;
没有配置时返回空元组,分类退化为代码声明的意图清单——**行为与接入前一致**。
"""
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
)