feat(cfg): W-CFG-KB K3 知识包治理接线——knowledge 配置块+激活发布 Nacos+worker 订阅热物化(复用阶段二三·默认关)
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled

把切片一知识件外置接进受治理配置集,一条「改知识件→定版→激活→worker 物化生效」链,复用配置控制面阶段二/三治理机器零重造:

game-cloud:knowledge 作 content_json 第四内容块(仿 readiness),与 routeA/routeB/readiness 互斥、sanity(packageId/
  activeVersion 非空);走向相反=真下发(readiness 进程内空载荷,knowledge 跨进程),激活编成一条路 B 载荷
  {knowledge.packageId,knowledge.activeVersion}、经 KNOWLEDGE 档独立 dataId gen-hot-params-knowledge publish 给 worker;
  pathA=null→dispatch/reconcile 只走路 B、激活编排一行未改。加 AigcConfigTierEnum.KNOWLEDGE+KnowledgeConfigKeys+NO_ROUTE 文案。

worker:kb_store 加进程级激活版本源槽+attach/detach(对称 genconfig),resolve_active_version 取值链改为治理源(Nacos)>
  env 钉版>最新 committed;新 kb_nacos.py(KbActiveVersionSource 订阅 dataId、按 packageId 匹配、激活变更即重物化)+
  setup_knowledge_activation() 启动物化+(Nacos 启用时)attach,cheap_service_app 紧接 genconfig 热源挂它。复用 genconfig_nacos 启用旗+代理旁路。

默认关字节不变(TIER2_KB_ROOT 未配→不物化不订阅、read_file 回落仓根;无源→resolve 回落 env/最新);缺文件显式失败沿用
(materialize_active 照抛保留 kb_root、kb_nacos 只在 best-effort 边界接住不咬生成);全 best-effort。admin 本轮做前端契约点
sanity.ts,富编辑视图=紧接下一片(与 readiness 一致)。

主控终审:git 完整(HEAD 未动·无破坏操作)+11 文件精确+读码坐实互斥/routeB载荷/取值链/best-effort/默认关/缺文件失败没削弱
+亲跑 Java codec 34+activation 21(BUILD SUCCESS)+Python test_kb_externalize 21 全绿。真 Nacos 激活端到端排窗口。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
lili 2026-07-05 22:50:10 -07:00
parent 5b631e8d5d
commit c6d6d39a2a
11 changed files with 682 additions and 14 deletions

View File

@ -444,6 +444,12 @@ def main() -> None:
from worker import genconfig_nacos # noqa: PLC0415 —— 惰性 import,默认关时不牵 nacos 依赖 from worker import genconfig_nacos # noqa: PLC0415 —— 惰性 import,默认关时不牵 nacos 依赖
genconfig_nacos.build_and_attach_hot_source(tier="cheap") genconfig_nacos.build_and_attach_hot_source(tier="cheap")
# 知识包激活生效链接线(W-CFG-KB K3,与 genconfig 热源对称):TIER2_KB_ROOT 未配 → no-op(默认关、read_file
# 字节不变);已配 → 启动期按激活版物化知识根 +(Nacos 启用时)订阅知识包 dataId,激活推送即重新物化。best-effort:
# 任何异常只告警、不阻断 Service 启动 / 生成。
from worker import kb_nacos # noqa: PLC0415 —— 惰性 import,默认关时不牵 nacos 依赖
kb_nacos.setup_knowledge_activation()
uvicorn.run(build_cheap_app(), host=host, port=port) uvicorn.run(build_cheap_app(), host=host, port=port)

View File

@ -267,3 +267,173 @@ def test_kb_addressing_anchors():
from worker import store as _src_store from worker import store as _src_store
assert kb_store._KB_MYSQL_TABLE != _src_store._MYSQL_TABLE assert kb_store._KB_MYSQL_TABLE != _src_store._MYSQL_TABLE
assert kb_store._KB_BUCKET_DEFAULT != "tier2-src" assert kb_store._KB_BUCKET_DEFAULT != "tier2-src"
# ─────────────────────────── K3:治理激活版本源接线(resolve 读 Nacos 激活版 · env 回落 · 变更即物化)───────────────────────────
from worker import kb_nacos # noqa: E402
class _FakeSource:
"""满足 kb_store 激活版本源 get(package_id)->version|None 契约的假源(单测注入,不连 Nacos)。"""
def __init__(self, version, *, package_id="cheap-kb", raise_exc=None):
self._version = version
self._package_id = package_id
self._raise = raise_exc
def get(self, package_id):
if self._raise is not None:
raise self._raise
if self._package_id is not None and package_id != self._package_id:
return None
return self._version
def test_resolve_prefers_governance_source(tmp_path, monkeypatch):
"""K3 取值链:治理激活版本源(Nacos 下发)> env 钉版 > 最新 committed;detach 后回落。"""
store = _store(tmp_path)
latest = store.save([{"path": "a.md", "content": "A1"}], now_ts=_NOW, package_id="cheap-kb")
monkeypatch.setenv("TIER2_KB_ACTIVE_VERSION", "v-env-pinned") # env 钉了别的版
kb_store.attach_active_version_source(_FakeSource("v-gov-active"))
try:
# 源优先于 env 与最新 committed。
assert resolve_active_version(store, package_id="cheap-kb") == "v-gov-active"
finally:
kb_store.detach_active_version_source()
# detach 后回落 env 钉版。
assert resolve_active_version(store, package_id="cheap-kb") == "v-env-pinned"
monkeypatch.delenv("TIER2_KB_ACTIVE_VERSION", raising=False)
# 无源无 env → 最新 committed(切片一行为、字节不变)。
assert resolve_active_version(store, package_id="cheap-kb") == latest["versionId"]
def test_resolve_source_none_falls_back(tmp_path, monkeypatch):
"""源返回 None(dataId 尚无激活版)→ 回落 env,再回落最新 committed。"""
store = _store(tmp_path)
v = store.save([{"path": "a.md", "content": "A1"}], now_ts=_NOW, package_id="cheap-kb")
monkeypatch.delenv("TIER2_KB_ACTIVE_VERSION", raising=False)
kb_store.attach_active_version_source(_FakeSource(None))
try:
assert resolve_active_version(store, package_id="cheap-kb") == v["versionId"]
finally:
kb_store.detach_active_version_source()
def test_resolve_source_error_is_best_effort(tmp_path, monkeypatch):
"""源取值抛异常 → best-effort 静默回落 env/最新 committed,绝不因治理源拖垮解析(→拖垮生成)。"""
store = _store(tmp_path)
v = store.save([{"path": "a.md", "content": "A1"}], now_ts=_NOW, package_id="cheap-kb")
monkeypatch.delenv("TIER2_KB_ACTIVE_VERSION", raising=False)
kb_store.attach_active_version_source(_FakeSource(None, raise_exc=RuntimeError("nacos down")))
try:
assert resolve_active_version(store, package_id="cheap-kb") == v["versionId"]
finally:
kb_store.detach_active_version_source()
def test_resolve_source_package_mismatch_falls_back(tmp_path, monkeypatch):
"""源里 dataId 标的是别的包 → get 返 None → 回落(不冒充本包激活版)。"""
store = _store(tmp_path)
v = store.save([{"path": "a.md", "content": "A1"}], now_ts=_NOW, package_id="cheap-kb")
monkeypatch.delenv("TIER2_KB_ACTIVE_VERSION", raising=False)
kb_store.attach_active_version_source(_FakeSource("v-other", package_id="some-other-kb"))
try:
assert resolve_active_version(store, package_id="cheap-kb") == v["versionId"]
finally:
kb_store.detach_active_version_source()
class _FakeNacosClient:
"""假 v1 NacosClient:get_config 返预置内容,add_config_watcher 记回调供手动触发(不连真 Nacos)。"""
def __init__(self, initial=None):
self._content = initial
self._cb = None
def get_config(self, data_id, group):
return self._content
def add_config_watcher(self, data_id, group, cb):
self._cb = cb
def push(self, content):
"""模拟 Nacos 长轮询推送:更新内容 + 以 v1 回调 dict 形态触发回调。"""
self._content = content
if self._cb is not None:
self._cb({"data_id": "gen-hot-params-knowledge", "group": "DEFAULT_GROUP", "content": content})
def _kb_dataid_content(package_id, version):
"""组一份知识包 dataId 内容(与 game-cloud codec 产的复合键载荷逐字对齐)。"""
import json
return json.dumps({"knowledge.packageId": package_id, "knowledge.activeVersion": version})
def test_kb_source_parse_and_get(tmp_path):
"""KbActiveVersionSource 解析 dataId 复合键 → get 返激活版;包 id 不符返 None(初读不物化)。"""
store = _store(tmp_path)
kb_root = tmp_path / "kb-active"
client = _FakeNacosClient(initial=_kb_dataid_content("cheap-kb", "v0001-aaa"))
src = kb_nacos.KbActiveVersionSource(
client=client, data_id="gen-hot-params-knowledge", group="DEFAULT_GROUP",
store=store, kb_root=kb_root, package_id="cheap-kb")
src.start() # 初读 populate 版本,不物化
assert src.get("cheap-kb") == "v0001-aaa"
assert src.get("some-other-kb") is None # 包 id 不符不冒充
assert not kb_root.exists() # 初读不物化(启动物化由 setup 显式做)
def test_kb_source_change_triggers_materialize(tmp_path, monkeypatch):
"""激活推送(dataId 变更)→ 源 _on_change 触发重新物化 → read_file 读到新一版知识件。"""
store = _store(tmp_path)
kb_root = tmp_path / "kb-active"
v1 = store.save([{"path": "skill.md", "content": "GOV-V1"}], now_ts=_NOW, package_id="cheap-kb")
v2 = store.save([{"path": "skill.md", "content": "GOV-V2"}], now_ts=_NOW + 10, package_id="cheap-kb")
client = _FakeNacosClient(initial=_kb_dataid_content("cheap-kb", v1["versionId"]))
src = kb_nacos.KbActiveVersionSource(
client=client, data_id="gen-hot-params-knowledge", group="DEFAULT_GROUP",
store=store, kb_root=kb_root, package_id="cheap-kb")
src.start() # 初读 v1(不物化)
kb_store.attach_active_version_source(src)
monkeypatch.setenv("TIER2_KB_ROOT", str(kb_root))
try:
# 启动物化(setup 会做;这里显式模拟)→ resolve 读源 v1 → 物化 v1。
materialize_active(store, kb_root)
assert cheap_run.read_file("skill.md")["content"] == "GOV-V1"
# 激活推送 v2 → 源 _on_change 触发重新物化。
client.push(_kb_dataid_content("cheap-kb", v2["versionId"]))
assert src.get("cheap-kb") == v2["versionId"]
assert cheap_run.read_file("skill.md")["content"] == "GOV-V2"
finally:
kb_store.detach_active_version_source()
def test_kb_source_materialize_best_effort_on_missing(tmp_path, monkeypatch):
"""激活推送到一个缺文件的版本 → 物化【显式失败】被 best-effort 接住(不抛)、kb_root 保留上一份完整包。"""
store = _store(tmp_path)
kb_root = tmp_path / "kb-active"
v1 = store.save([{"path": "skill.md", "content": "GOOD-V1"}], now_ts=_NOW, package_id="cheap-kb")
v2 = store.save(
[{"path": "skill.md", "content": "V2"}, {"path": "extra.md", "content": "E2"}],
now_ts=_NOW + 10, package_id="cheap-kb")
client = _FakeNacosClient(initial=_kb_dataid_content("cheap-kb", v1["versionId"]))
src = kb_nacos.KbActiveVersionSource(
client=client, data_id="gen-hot-params-knowledge", group="DEFAULT_GROUP",
store=store, kb_root=kb_root, package_id="cheap-kb")
src.start()
kb_store.attach_active_version_source(src)
monkeypatch.setenv("TIER2_KB_ROOT", str(kb_root))
try:
materialize_active(store, kb_root) # 物化 v1
assert cheap_run.read_file("skill.md")["content"] == "GOOD-V1"
# 破坏 v2 的一个文件(manifest 列了但取不到)。
victim = tmp_path / "_kb-store" / "cheap-kb" / v2["versionId"] / "files" / "extra.md"
victim.unlink()
# 推送 v2 → _on_change 触发物化 → 显式失败被 best-effort 接住(不抛),kb_root 保留 v1。
client.push(_kb_dataid_content("cheap-kb", v2["versionId"])) # 绝不抛
assert cheap_run.read_file("skill.md")["content"] == "GOOD-V1" # 仍读 v1 完整包
finally:
kb_store.detach_active_version_source()

View File

@ -5,25 +5,48 @@
* 少一趟「定版→激活失败」往返。这是前端便利校验、非信任边界——真兜底仍是后端激活时的 codec 校验(走查断言 4 用真后端)。 * 少一趟「定版→激活失败」往返。这是前端便利校验、非信任边界——真兜底仍是后端激活时的 codec 校验(走查断言 4 用真后端)。
* *
* 校验项(逐条对 codec:build / buildPathA / validateChatModelConfig): * 校验项(逐条对 codec:build / buildPathA / validateChatModelConfig):
* 1. 至少一路:routeA 或 routeB(routeB 须非空对象); * 1. 至少一块:routeA / routeB(非空对象)/ readiness / knowledge(四者任一即有落点);
* 2. 有 routeA 则 agentId/sessionId 非空; * 2. 有 routeA 则 agentId/sessionId 非空;
* 3. 有 routeA 则 prompt 非空(system_prompt 是激活前置); * 3. 有 routeA 则 prompt 非空(system_prompt 是激活前置);
* 4. routeA.session.chat_model_config 存在且为对象,四字段 type/credential_id/model 非空、parameters 为对象; * 4. routeA.session.chat_model_config 存在且为对象,四字段 type/credential_id/model 非空、parameters 为对象;
* 5. max_tokens > thinking_budget(两者都给时;兼容扁平 thinking_budget 与嵌套 thinking.budget_tokens)。 * 5. max_tokens > thinking_budget(两者都给时;兼容扁平 thinking_budget 与嵌套 thinking.budget_tokens);
* 6. knowledge 块(W-CFG-KB K3,跨档知识包激活指针):packageId/activeVersion 非空、与其余块互斥。
*
* 说明:readiness(W-CFG-EXT 质量评分)与 knowledge(知识包激活)是两个跨档特例块,进程内/跨进程消费、不下发生成路。
* 本预检对 readiness 只认「块存在=有落点」(其十字段数值校验是 admin 后续片,非本前端便利校验职责);对 knowledge
* 给最小字段预检。真兜底仍是后端激活时的 codec.build(值域 sanity + 互斥)。
* *
* @param draftPrompt 草稿 prompt 正文 * @param draftPrompt 草稿 prompt 正文
* @param contentObj 已解析的 content_json 对象(routeA/routeB 嵌套信封) * @param contentObj 已解析的 content_json 对象(routeA/routeB/readiness/knowledge 信封)
* @returns null=校验通过;否则返回首个错误的中文说明 * @returns null=校验通过;否则返回首个错误的中文说明
*/ */
export function checkRouteASanity(draftPrompt: string | undefined, contentObj: any): string | null { export function checkRouteASanity(draftPrompt: string | undefined, contentObj: any): string | null {
const obj = contentObj && typeof contentObj === 'object' ? contentObj : {} const obj = contentObj && typeof contentObj === 'object' ? contentObj : {}
const routeA = obj.routeA const routeA = obj.routeA
const routeB = obj.routeB const routeB = obj.routeB
const readiness = obj.readiness
const knowledge = obj.knowledge
const hasA = routeA != null && typeof routeA === 'object' const hasA = routeA != null && typeof routeA === 'object'
const hasB = routeB != null && typeof routeB === 'object' && Object.keys(routeB).length > 0 const hasB = routeB != null && typeof routeB === 'object' && Object.keys(routeB).length > 0
const hasReadiness = readiness != null && typeof readiness === 'object'
const hasKnowledge = knowledge != null && typeof knowledge === 'object'
if (!hasA && !hasB) { if (!hasA && !hasB && !hasReadiness && !hasKnowledge) {
return 'content_json 无任何可下发路由(至少需要 routeA 或非空 routeB 一路)' return 'content_json 无任何可下发路由(至少需要 routeA / routeB / readiness / knowledge 一块)'
}
// knowledge 块(跨档知识包激活指针):与其余块互斥 + packageId/activeVersion 非空(镜像后端 codec.buildKnowledgePathB)。
if (hasKnowledge) {
if (hasA || hasB || hasReadiness) {
return 'knowledge 块与 routeA/routeB/readiness 互斥:知识包配置集只下发激活知识包版本'
}
if (!hasText(knowledge.packageId)) {
return 'knowledge.packageId 缺失或为空(知识包 id 必填)'
}
if (!hasText(knowledge.activeVersion)) {
return 'knowledge.activeVersion 缺失或为空(激活知识包须显式钉版本)'
}
return null
} }
if (hasA) { if (hasA) {

View File

@ -32,7 +32,17 @@ public enum AigcConfigTierEnum {
* 账本指针前移」的纯账本收口。故其配置集内容与 routeA/routeB 互斥(codec 激活前置校验),也不参与按 cheap/tier2 * 账本指针前移」的纯账本收口。故其配置集内容与 routeA/routeB 互斥(codec 激活前置校验),也不参与按 cheap/tier2
* 拼 Nacos dataId 的下发逻辑。 * 拼 Nacos dataId 的下发逻辑。
*/ */
READINESS("readiness", "质量评分"); READINESS("readiness", "质量评分"),
/**
* 知识包档(W-CFG-KB K3 · 跨档特例):承载便宜档生成 agent 的知识件(设计范式 skill/黄金脚手架/few-shot)
* 外置后的「当前激活知识包版本」指针,内容 = {@code {packageId, activeVersion}}。它与质量评分档一样不属任何
* 生成档,但<b>与质量评分档相反——要真下发到 Nacos</b>:激活时经 {@link GenConfigContentCodec} 编成一条路 B 载荷
* (复合键 {@code knowledge.packageId}/{@code knowledge.activeVersion}),按本档 dataId 模板 publish 到
* {@code gen-hot-params-knowledge};便宜档 worker 起本档专用热源订阅它,读到激活版本号后据 kb_store 物化知识根,
* 改一版知识件 = 定版 + 激活即生效、不重部署。故其配置集内容与 routeA/routeB/readiness 互斥(codec 激活前置校验),
* 走独立 dataId、不冲便宜档/富游戏档的 gen-hot-params-cheap/tier2 预算门阈值键。
*/
KNOWLEDGE("knowledge", "知识包");
/** 档位码(落库 tier 列) */ /** 档位码(落库 tier 列) */
private final String tier; private final String tier;

View File

@ -91,7 +91,7 @@ public interface ErrorCodeConstants {
/** 值域 sanity 校验不通过:负预算/空模型名/阈值出域/max_tokens≤thinking/chat_model_config 不完整等(设计 §3.6,激活前置,拒绝不发任何一路)。占位符带明细。 */ /** 值域 sanity 校验不通过:负预算/空模型名/阈值出域/max_tokens≤thinking/chat_model_config 不完整等(设计 §3.6,激活前置,拒绝不发任何一路)。占位符带明细。 */
ErrorCode AIGC_CONFIG_ACTIVATE_SANITY_FAILED = new ErrorCode(1_101_004_009, "配置值域校验不通过:{}"); ErrorCode AIGC_CONFIG_ACTIVATE_SANITY_FAILED = new ErrorCode(1_101_004_009, "配置值域校验不通过:{}");
/** 激活内容缺路由:content_json 未声明可激活的 routeA/routeB(无落点可下发)。 */ /** 激活内容缺路由:content_json 未声明可激活的 routeA/routeB(无落点可下发)。 */
ErrorCode AIGC_CONFIG_ACTIVATE_NO_ROUTE = new ErrorCode(1_101_004_010, "配置内容未声明任何可激活的下发路由(routeA/routeB/readiness 均缺)"); ErrorCode AIGC_CONFIG_ACTIVATE_NO_ROUTE = new ErrorCode(1_101_004_010, "配置内容未声明任何可激活的下发路由(routeA/routeB/readiness/knowledge 均缺)");
/** 双路下发失败:某路 PATCH/publish 失败 → 整批判未生效、已把成功路补偿重推回上一激活版、指针不前移、可重试(设计 §3.6)。占位符带明细。 */ /** 双路下发失败:某路 PATCH/publish 失败 → 整批判未生效、已把成功路补偿重推回上一激活版、指针不前移、可重试(设计 §3.6)。占位符带明细。 */
ErrorCode AIGC_CONFIG_ACTIVATE_DISPATCH_FAILED = new ErrorCode(1_101_004_011, "配置激活下发失败(已判未生效并补偿回上一激活版,可重试):{}"); ErrorCode AIGC_CONFIG_ACTIVATE_DISPATCH_FAILED = new ErrorCode(1_101_004_011, "配置激活下发失败(已判未生效并补偿回上一激活版,可重试):{}");

View File

@ -61,6 +61,18 @@ import static com.wanxiang.huijing.framework.common.exception.util.ServiceExcept
* 激活前置 readiness 值域 sanity(不过即拒):四维权重各 ∈[0,1] 且和 ≈1.0(±0.001)、中性/稳定性两级分 ∈[0,1]、 * 激活前置 readiness 值域 sanity(不过即拒):四维权重各 ∈[0,1] 且和 ≈1.0(±0.001)、中性/稳定性两级分 ∈[0,1]、
* 效率预算 > 0、修复轮 good_max ≤ ok_max;并与 routeA/routeB 互斥(readiness 存在时禁二者,反之亦然)。 * 效率预算 > 0、修复轮 good_max ≤ ok_max;并与 routeA/routeB 互斥(readiness 存在时禁二者,反之亦然)。
* *
* <p><b>第四块 knowledge(W-CFG-KB K3 · 知识包激活指针,与其余三块互斥)</b>:承载便宜档生成 agent 的知识件外置后的
* 「当前激活知识包版本」指针,契约:
* <pre>
* { "knowledge": { "packageId": "cheap-kb", "activeVersion": "v0001-abcdef123456" } }
* </pre>
* 它与 readiness 都是跨档特例,但走向相反:readiness 进程内消费产空载荷,knowledge 是<b>跨进程</b>(game-cloud 治理、
* 便宜档 worker 消费),故激活时编成一条路 B 载荷 {@code {"knowledge.packageId":..,"knowledge.activeVersion":..}}
* (复合键与 routeB 的 area.key 同扁平口径),经 KNOWLEDGE 档 {@code gen-hot-params-knowledge} dataId publish 给 worker。
* worker 读到激活版本号后据 kb_store 物化知识根,agent 的 read_file 即读到新一版设计范式——改一版知识件 = 定版 + 激活
* 即生效、不重部署。激活前置 sanity:packageId 与 activeVersion 非空(激活知识包须显式钉版);与 routeA/routeB/readiness
* 互斥(知识包配置集只下发激活版本,不混生成路/评分块)。
*
* @author 绘境AI * @author 绘境AI
*/ */
@Slf4j @Slf4j
@ -88,19 +100,33 @@ public final class GenConfigContentCodec {
JsonNode routeA = root.get("routeA"); JsonNode routeA = root.get("routeA");
JsonNode routeB = root.get("routeB"); JsonNode routeB = root.get("routeB");
JsonNode readiness = root.get(ReadinessConfigKeys.BLOCK); JsonNode readiness = root.get(ReadinessConfigKeys.BLOCK);
JsonNode knowledge = root.get(KnowledgeConfigKeys.BLOCK);
boolean hasA = routeA != null && routeA.isObject(); boolean hasA = routeA != null && routeA.isObject();
boolean hasB = routeB != null && routeB.isObject() && routeB.size() > 0; boolean hasB = routeB != null && routeB.isObject() && routeB.size() > 0;
boolean hasReadiness = readiness != null && readiness.isObject(); boolean hasReadiness = readiness != null && readiness.isObject();
if (!hasA && !hasB && !hasReadiness) { boolean hasKnowledge = knowledge != null && knowledge.isObject();
if (!hasA && !hasB && !hasReadiness && !hasKnowledge) {
// 无任何可下发路由:没有落点(设计 §4 步骤3 交付以「双路下发」为核心,至少一路) // 无任何可下发路由:没有落点(设计 §4 步骤3 交付以「双路下发」为核心,至少一路)
throw exception(AIGC_CONFIG_ACTIVATE_NO_ROUTE); throw exception(AIGC_CONFIG_ACTIVATE_NO_ROUTE);
} }
// 互斥(W-CFG-EXT):质量评分块是跨档、进程内消费、无外投影,不与生成下发路(routeA/routeB)共存于同一配置集。 // 互斥①(W-CFG-KB):knowledge 是跨进程、走独立 dataId 的知识包激活指针,不与生成下发路(routeA/routeB)或进程内
// 评分块(readiness)共存于同一配置集——混配会让激活按档拼错 dataId 或让投影语义打架,故激活前置拒(与 readiness
// 同纪律,让 KNOWLEDGE 档的 enum-value 建模对既有按档下发逻辑安全)。
if (hasKnowledge && (hasA || hasB || hasReadiness)) {
throw sanity("knowledge 块与 routeA/routeB/readiness 互斥:知识包配置集只下发激活知识包版本,不得混配生成路/评分块");
}
// 互斥②(W-CFG-EXT):质量评分块是跨档、进程内消费、无外投影,不与生成下发路(routeA/routeB)共存于同一配置集。
// 若一个质量评分配置集误混 routeB,激活会按 tier=readiness 拼出伪 dataId(resolveDataId 不认档但仍拼)真 publish; // 若一个质量评分配置集误混 routeB,激活会按 tier=readiness 拼出伪 dataId(resolveDataId 不认档但仍拼)真 publish;
// 误混 routeA 则 resolveServiceUrl 返 null 下发失败。故激活前置拒,让 enum-value 建模对既有按档下发逻辑安全。 // 误混 routeA 则 resolveServiceUrl 返 null 下发失败。故激活前置拒,让 enum-value 建模对既有按档下发逻辑安全。
if (hasReadiness && (hasA || hasB)) { if (hasReadiness && (hasA || hasB)) {
throw sanity("readiness 块与 routeA/routeB 互斥:质量评分配置集不下发 prompt/模型/预算/门阈值,不得混配生成路"); throw sanity("readiness 块与 routeA/routeB 互斥:质量评分配置集不下发 prompt/模型/预算/门阈值,不得混配生成路");
} }
if (hasKnowledge) {
// 知识包配置集:值域 sanity + 产一条路 B 载荷(复合键 knowledge.packageId/activeVersion),按 KNOWLEDGE 档
// dataId 模板 publish 到 gen-hot-params-knowledge;便宜档 worker 订阅它、读激活版本号后据 kb_store 物化知识根
// (激活即生效)。pathA=null → dispatch 天然只走路 B(reconcile 同理只对账路 B),激活编排一行不改。
return new GenConfigActivationPayload(null, buildKnowledgePathB(knowledge));
}
if (hasReadiness) { if (hasReadiness) {
// 质量评分配置集:只做值域 sanity,不产任何外投影载荷。空载荷(pathA=pathB=null)→ 现有 dispatch/reconcile // 质量评分配置集:只做值域 sanity,不产任何外投影载荷。空载荷(pathA=pathB=null)→ 现有 dispatch/reconcile
// 天然短路成「sanity + 账本收口、无投影、恒一致」(激活编排一行不改,见 GenConfigActivationServiceImpl.dispatch)。 // 天然短路成「sanity + 账本收口、无投影、恒一致」(激活编排一行不改,见 GenConfigActivationServiceImpl.dispatch)。
@ -177,6 +203,29 @@ public final class GenConfigContentCodec {
return v.asInt(); return v.asInt();
} }
// ==================== knowledge:知识包激活指针 → 路 B 载荷(W-CFG-KB K3)====================
/**
* 校验 content_json.knowledge 块并编成一条路 B 载荷(激活前置,不过即拒):packageId 与 activeVersion 均须非空——
* 激活知识包必须显式钉一个版本(不像预算旋钮可留默认),worker 据 activeVersion {@code fetch} + 物化,取不到即物化
* 显式失败、回落上一份完整包。产出 = {@code {"knowledge.packageId":"...","knowledge.activeVersion":"..."}} 扁平 JSON
* (与 routeB 的 area.key 复合键同口径),经 KNOWLEDGE 档 {@code gen-hot-params-knowledge} dataId publish 给 worker。
*/
private String buildKnowledgePathB(JsonNode knowledge) {
String packageId = textOrNull(knowledge, KnowledgeConfigKeys.PACKAGE_ID);
String activeVersion = textOrNull(knowledge, KnowledgeConfigKeys.ACTIVE_VERSION);
if (!StringUtils.hasText(packageId)) {
throw sanity("knowledge." + KnowledgeConfigKeys.PACKAGE_ID + " 缺失或为空(知识包 id 必填)");
}
if (!StringUtils.hasText(activeVersion)) {
throw sanity("knowledge." + KnowledgeConfigKeys.ACTIVE_VERSION + " 缺失或为空(激活知识包须显式钉版本)");
}
ObjectNode out = JSON.createObjectNode();
out.put(KnowledgeConfigKeys.NACOS_KEY_PACKAGE_ID, packageId);
out.put(KnowledgeConfigKeys.NACOS_KEY_ACTIVE_VERSION, activeVersion);
return writeJson(out);
}
// ==================== 路 A:/agent + /session 请求体组装 + 值域校验 ==================== // ==================== 路 A:/agent + /session 请求体组装 + 值域校验 ====================
private GenConfigActivationPayload.PathA buildPathA(String prompt, JsonNode routeA) { private GenConfigActivationPayload.PathA buildPathA(String prompt, JsonNode routeA) {

View File

@ -0,0 +1,51 @@
package com.wanxiang.huijing.game.module.aigc.service.config;
/**
* 知识包配置集(W-CFG-KB K3)的内容契约常量:content_json 里 {@code knowledge} 块的块名与字段名、
* 知识包配置集的约定业务键,以及下发到 Nacos 的复合键。
*
* <p><b>为何单独成类</b>:这批字段名是「配置集内容(doc)」「激活编解码(code)」「worker 读激活版(跨进程)」三方之间的
* 唯一约定——{@link GenConfigContentCodec} 激活前置的 knowledge 值域 sanity 与产路 B 载荷、便宜档 worker 侧
* {@code kb_nacos.KbActiveVersionSource} 解析 Nacos dataId 都引用它,把字段名收进一处,避免多处各写一份字符串字面量、
* 漂移无人对账。字段<b>值</b>(具体 packageId / versionId)不在这里,来自运营在配置集里填的当前激活知识包版本;
* 本类只管「用哪个键名读写」。
*
* <p><b>与质量评分(readiness)块的区别</b>:readiness 是进程内消费、无外投影(空载荷);knowledge 是<b>跨进程</b>——
* game-cloud 治理它、便宜档生成 worker 消费它,故必须经 Nacos 下发一条路 B 载荷把激活版本号送到 worker。承载 dataId =
* {@code gen-hot-params-knowledge}(由 KNOWLEDGE 档套 {@code gen-hot-params-{tier}} 模板得到),内容是激活版本指针、
* 不是预算门阈值热参,故与便宜档/富游戏档的生效 dataId 天然隔离、不相互冲刷。
*
* @author 绘境AI(W-CFG-KB · K3 知识包治理接线)
*/
public final class KnowledgeConfigKeys {
private KnowledgeConfigKeys() {
}
/** content_json 里承载知识包激活指针的块名(平级于 routeA/routeB/readiness,互斥)。 */
public static final String BLOCK = "knowledge";
/**
* 知识包稳定标识字段(一套便宜档知识语料一个 id;其下挂多版本)。与 worker 侧
* {@code kb_store.DEFAULT_PACKAGE_ID} 对齐,默认 {@code cheap-kb}。
*/
public static final String PACKAGE_ID = "packageId";
/**
* 当前激活的知识包版本号字段。取值 = worker 侧 {@code kb_store} 落库时派生的 versionId(形如
* {@code vXXXX-hash12});worker 据它 {@code fetch} + 物化,取不到即物化显式失败、回落上一份。
*/
public static final String ACTIVE_VERSION = "activeVersion";
/**
* 知识包配置集的约定业务键(set_key)。跨档单例:全租户内知识包配置集用这一个业务键,
* uk_set_key 保证至多一条,寻址无歧义。运营在配置中心新建知识包配置集时须用此业务键。
*/
public static final String KNOWLEDGE_SET_KEY = "knowledge-default";
// ---- 下发到 Nacos 的复合键(worker 侧 kb_nacos 读同名键;与 genconfig 的 area.key 复合键同扁平口径)----
/** publish 到知识包 dataId 的「包 id」键 = {@code knowledge.packageId}。 */
public static final String NACOS_KEY_PACKAGE_ID = BLOCK + "." + PACKAGE_ID;
/** publish 到知识包 dataId 的「激活版本」键 = {@code knowledge.activeVersion}。 */
public static final String NACOS_KEY_ACTIVE_VERSION = BLOCK + "." + ACTIVE_VERSION;
}

View File

@ -65,6 +65,9 @@ class GenConfigActivationServiceImplTest extends BaseMockitoUnitTest {
+ "\"neutral_score\":0.5,\"efficiency_budget_rmb\":0.6," + "\"neutral_score\":0.5,\"efficiency_budget_rmb\":0.6,"
+ "\"stability_repairs_good_max\":0,\"stability_repairs_ok_max\":2," + "\"stability_repairs_good_max\":0,\"stability_repairs_ok_max\":2,"
+ "\"stability_ok_score\":0.7,\"stability_bad_score\":0.4}}"; + "\"stability_ok_score\":0.7,\"stability_bad_score\":0.4}}";
/** 知识包配置集内容(knowledge-only,W-CFG-KB K3):codec 产一条路 B 载荷、激活只 publish 到 gen-hot-params-knowledge。 */
private static final String KNOWLEDGE_CONTENT =
"{\"knowledge\":{\"packageId\":\"cheap-kb\",\"activeVersion\":\"v0001-abcdef123456\"}}";
@BeforeEach @BeforeEach
void setUp() { void setUp() {
@ -239,6 +242,31 @@ class GenConfigActivationServiceImplTest extends BaseMockitoUnitTest {
verify(nacosClient, never()).publish(anyString(), any()); verify(nacosClient, never()).publish(anyString(), any());
} }
// ==================== 知识包配置集(knowledge-only,W-CFG-KB K3):只走路 B(publish gen-hot-params-knowledge) ====================
/**
* K3:知识包配置集(knowledge-only)激活 → codec 产一条路 B 载荷(knowledge.packageId/activeVersion)→ dispatch 只走路 B:
* tier=knowledge 经 {@link NacosConfigPublishClient#publish}("knowledge", ...) 下发到 gen-hot-params-knowledge dataId
* (便宜档 worker 订阅它读激活版物化知识根);路 A(Agent Service)全程零触碰;账本正常收口。
*/
@Test
void testActivate_knowledgeOnly_publishOnlyPathB() {
stubSet(1L, AigcConfigStatusEnum.PENDING_REVIEW, null, "knowledge"); // 跨档知识包档
stubVersion(1L, 4000L, null, KNOWLEDGE_CONTENT); // 无 prompt、只有 knowledge 块
when(genConfigService.beginActivating(1L, 4000L))
.thenReturn(new GenConfigActivationSnapshot(AigcConfigStatusEnum.PENDING_REVIEW.getStatus(), null));
when(nacosClient.publish(anyString(), any())).thenReturn(GenConfigDispatchResult.succeeded());
service.activate(1L, 4000L);
// 只走路 B:按 knowledge 档 publish(resolveDataId 拼 gen-hot-params-knowledge)、路 A 零触碰
verify(nacosClient).publish(eq("knowledge"), any());
verify(agentClient, never()).patch(anyString(), any());
// 账本正常收口(转 ACTIVE + 前移指针),不失败复位
verify(genConfigService).markVersionActivated(1L, 4000L);
verify(genConfigService, never()).abortActivating(any(), any());
}
// ==================== 前置拒绝:不写账本、不下发 ==================== // ==================== 前置拒绝:不写账本、不下发 ====================
/** 值域非法(负预算)→ 激活前置拒:不落 ACTIVATING、不发任何一路。 */ /** 值域非法(负预算)→ 激活前置拒:不落 ACTIVATING、不发任何一路。 */

View File

@ -305,4 +305,55 @@ class GenConfigContentCodecTest {
assertEquals(AIGC_CONFIG_ACTIVATE_SANITY_FAILED.getCode(), sanityOf("sys", c).getCode()); assertEquals(AIGC_CONFIG_ACTIVATE_SANITY_FAILED.getCode(), sanityOf("sys", c).getCode());
} }
// ==================== knowledge:知识包激活指针配置集(W-CFG-KB K3) ====================
/** 合法 knowledge 块(packageId + activeVersion)→ 一条路 B 载荷(复合键下发,worker 读激活版物化知识根)。 */
@Test
void testBuild_knowledgeOnly_pathBOnly() {
GenConfigActivationPayload p = codec().build(null,
"{\"knowledge\":{\"packageId\":\"cheap-kb\",\"activeVersion\":\"v0001-abcdef123456\"}}");
assertFalse(p.hasPathA(), "知识包配置集不下发路 A");
assertTrue(p.hasPathB(), "知识包配置集走路 B(publish gen-hot-params-knowledge)");
assertNull(p.pathA());
// 复合键与 area.key 同扁平口径,worker 侧 kb_nacos 读同名键
assertTrue(p.pathBJson().contains("knowledge.packageId"));
assertTrue(p.pathBJson().contains("knowledge.activeVersion"));
assertTrue(p.pathBJson().contains("cheap-kb"));
assertTrue(p.pathBJson().contains("v0001-abcdef123456"));
}
/** knowledge 缺 activeVersion → sanity 拒(激活知识包须显式钉版)。 */
@Test
void testKnowledgeSanity_missingActiveVersion() {
assertEquals(AIGC_CONFIG_ACTIVATE_SANITY_FAILED.getCode(),
sanityOf(null, "{\"knowledge\":{\"packageId\":\"cheap-kb\"}}").getCode());
}
/** knowledge packageId 空白 → sanity 拒。 */
@Test
void testKnowledgeSanity_blankPackageId() {
assertEquals(AIGC_CONFIG_ACTIVATE_SANITY_FAILED.getCode(),
sanityOf(null, "{\"knowledge\":{\"packageId\":\" \",\"activeVersion\":\"v1\"}}").getCode());
}
/** 互斥:knowledge 与 routeB 共存 → sanity 拒(知识包块不得混配生成下发路)。 */
@Test
void testKnowledge_mutexWithRouteB() {
String c = "{\"knowledge\":{\"packageId\":\"cheap-kb\",\"activeVersion\":\"v1\"},"
+ "\"routeB\":{\"budget.cheap_rmb_hard_limit\":10.0}}";
assertEquals(AIGC_CONFIG_ACTIVATE_SANITY_FAILED.getCode(), sanityOf(null, c).getCode());
}
/** 互斥:knowledge 与 readiness 共存 → sanity 拒(两个跨档特例块不得混配)。 */
@Test
void testKnowledge_mutexWithReadiness() {
String c = "{\"knowledge\":{\"packageId\":\"cheap-kb\",\"activeVersion\":\"v1\"},"
+ "\"readiness\":{\"playability_weight\":0.50,\"first_play_weight\":0.25,"
+ "\"stability_weight\":0.15,\"efficiency_weight\":0.10,"
+ "\"neutral_score\":0.5,\"efficiency_budget_rmb\":0.6,"
+ "\"stability_repairs_good_max\":0,\"stability_repairs_ok_max\":2,"
+ "\"stability_ok_score\":0.7,\"stability_bad_score\":0.4}}";
assertEquals(AIGC_CONFIG_ACTIVATE_SANITY_FAILED.getCode(), sanityOf(null, c).getCode());
}
} }

View File

@ -0,0 +1,241 @@
"""kb_nacos.py —— 知识包激活版本源:把 kb_store.resolve_active_version 的读取后端接到 Nacos(W-CFG-KB K3)。
【这份解决什么问题】
切片一(kb_store)已把便宜档生成 agent 的知识件外置成版本化知识包(存 MinIO + manifest 落 MySQL),并留了
「当前激活版本」接缝 resolve_active_version(切片一只会读 env 钉版 / 最新 committed)。K3 把这条接缝接到 game-cloud
的配置控制面:运营在配置控制台改一版知识件、定版、激活,game-cloud 把「当前激活知识包版本」publish 到一个专用
Nacos dataId(gen-hot-params-knowledge);本模块在 worker 启动期订阅它,读到激活版本号后据 kb_store 物化知识根——
激活推送到达即重新物化,agent 下次 read_file 就读到新一版设计范式,不重部署。
【与 genconfig 热源的关系:同范式、独立 dataId、独立槽】
genconfig 的 Nacos 热源(genconfig_nacos)承载预算/门阈值旋钮(area.key 复合键),订阅便宜档 gen-hot-params-cheap;
知识包激活版本是另一码事(激活版本指针,不是热参),故走【独立 dataId】gen-hot-params-knowledge(由 game-cloud
KNOWLEDGE 档套 gen-hot-params-{tier} 模板得到),publish 一个不冲另一个的键。两者各 attach 各自的进程级槽:预算旋钮
进 genconfig._hot_source,知识包激活版本进 kb_store._active_version_source。本模块复用 genconfig_nacos 的启用旗
(TIER2_GENCONFIG_NACOS,Nacos 接入总开关)与内网代理旁路,不另造一套连接逻辑。
【默认关 = 现状字节不变】
- TIER2_KB_ROOT 未配 → 知识件外置整体关闭:setup 直接返回,不物化、不订阅,read_file 回落仓根(切片一保证)。
- TIER2_KB_ROOT 已配但 Nacos 未启用 → 只在启动期物化一次(env 钉版 / 最新 committed),不接 Nacos 激活版本源。
- 两者都配 → 启动期物化 + 订阅 Nacos:激活推送即重新物化。
全程 best-effort:构造/订阅/解析/物化任一步异常都只告警、不抛、不 attach —— 治理与激活绝不拖垮 worker 启动或生成。
【缺文件显式失败沿用】
物化仍走 kb_store.materialize_active:激活版取不到 / manifest 列了但缺文件 / 指纹不匹配都【显式失败】,此时 kb_root
未被触碰 = 保留上一份已物化包,agent 仍读完整旧包。本模块把该显式失败在物化处 catch 成告警(best-effort 不咬生成),
但不削弱 materialize_active 内部的显式失败语义(它照抛,只是被这里的 best-effort 边界接住)。
"""
from __future__ import annotations
import json
import os
import threading
from pathlib import Path
from typing import Any, Optional
# genconfig_nacos 顶层零重依赖(不牵 agentscope / nacos),安全直接 import:复用其启用旗 + 内网代理旁路。
try:
from . import genconfig_nacos # 包内相对导入(worker.kb_nacos)
from . import kb_store
except ImportError: # pragma: no cover —— PYTHONPATH=gen-worker 直跑时的绝对导入兜底
from worker import genconfig_nacos # type: ignore
from worker import kb_store # type: ignore
# 知识包激活版本承载 dataId(与 game-cloud KNOWLEDGE 档 gen-hot-params-{tier} 模板产物逐字对齐)。
# env TIER2_KB_NACOS_DATAID 显式覆盖(演示脚本指演示专用 dataId,不碰生产)。
_DEFAULT_KB_DATAID = "gen-hot-params-knowledge"
_KB_DATAID_ENV = "TIER2_KB_NACOS_DATAID"
# 下发到 dataId 的复合键(与 game-cloud KnowledgeConfigKeys.NACOS_KEY_* 逐字对齐;doc↔code 契约点)。
_KEY_PACKAGE_ID = "knowledge.packageId"
_KEY_ACTIVE_VERSION = "knowledge.activeVersion"
class KbActiveVersionSource:
"""知识包激活版本源(K3):订阅 Nacos 知识包 dataId、热读当前激活版本、变更即触发重新物化。
对 kb_store.resolve_active_version 暴露 get(package_id)->version|None 契约(仅当 dataId 里的 packageId 与请求
一致才返版本,否则返 None 交回落);对 Nacos 侧订阅长轮询推送,激活版本变更即调 kb_store.materialize_active 把
知识根物化到新版(best-effort:物化异常保留上一份已物化包,不抛)。
刻意不复用 nacos_hotconfig.NacosHotConfig:后者是通用只读视图(get 键值),本源要在变更时额外触发物化、且按
packageId 匹配,语义不同;但订阅/回调/JSON 解析的骨架照 NacosHotConfig 同款(v1 watcher 后台线程 + 线程安全 +
best-effort),行为一致、无口径漂移。
"""
def __init__(self, *, client: Any, data_id: str, group: str,
store: Any, kb_root: Path, package_id: str) -> None:
"""注入已建好的 v1 nacos.NacosClient + 知识包 dataId + kb_store(物化用) + 知识根 + 目标包 id。
client 由工厂建并负责代理旁路(建 client 前把 Nacos host 塞进 NO_PROXY);本类只消费。
"""
self._client = client
self._data_id = data_id
self._group = group
self._store = store
self._kb_root = kb_root
self._package_id = package_id
self._lock = threading.RLock()
self._package: Optional[str] = None # 当前 dataId 里的 packageId
self._version: Optional[str] = None # 当前激活版本
def start(self) -> None:
"""拉初值(populate 版本,不在初读时物化——启动物化由工厂显式做一次,避免双物化)+ 订阅推送(变更即物化)。"""
self._load_once()
try:
# v1 add_config_watcher(data_id, group, cb):第 3 位是回调,内部自起后台守护轮询线程,变更即回调。
self._client.add_config_watcher(self._data_id, self._group, self._on_change)
print(f"[kb-nacos] 已订阅知识包激活版本 dataId={self._data_id} group={self._group}", flush=True)
except Exception as e: # noqa: BLE001 —— 订阅失败:留初值、不热更,但绝不炸(best-effort)
print(f"[kb-nacos] 订阅失败(留初值、不热更,best-effort):{type(e).__name__}: {e}", flush=True)
def _load_once(self) -> None:
"""经 get_config 拉一次初值(内建三级兜底);失败则版本停在 None、交 resolve 回落。初读不物化。"""
try:
content = self._client.get_config(self._data_id, self._group)
self._apply(content, materialize=False)
print(f"[kb-nacos] 初读完成 dataId={self._data_id} package={self._package} version={self._version}",
flush=True)
except Exception as e: # noqa: BLE001 —— Nacos 连兜底都没有:版本留 None,resolve 回落 env/最新
print(f"[kb-nacos] 初读失败(版本留空、resolve 回落 env/最新,best-effort):"
f"{type(e).__name__}: {e}", flush=True)
def _on_change(self, event: Any) -> None:
"""v1 watcher 回调(后台 ThreadPool 线程):event 是 dict,取 content 解析;版本变了就重新物化。"""
content = event.get("content") if isinstance(event, dict) else event
self._apply(content, materialize=True)
def _apply(self, content: Any, *, materialize: bool) -> None:
"""解析 dataId 内容(area.key 扁平 JSON),更新缓存版本;materialize=True 且版本变了则触发重新物化。
best-effort:空内容 / 脏 JSON / 非对象都保留旧值、不抛。
"""
if not content:
return # 空内容(get_config 三级全空返 None):dataId 尚无激活版,保留旧值(resolve 回落)
try:
parsed = json.loads(content)
except Exception as e: # noqa: BLE001 —— 脏内容不覆盖当前值,不抛
print(f"[kb-nacos] 解析激活版本内容失败(保留旧值,best-effort):{type(e).__name__}: {e}", flush=True)
return
if not isinstance(parsed, dict):
print(f"[kb-nacos] 忽略非对象激活版本内容(保留旧值):{type(parsed).__name__}", flush=True)
return
pkg = parsed.get(_KEY_PACKAGE_ID)
ver = parsed.get(_KEY_ACTIVE_VERSION)
pkg = str(pkg).strip() if pkg and str(pkg).strip() else None
ver = str(ver).strip() if ver and str(ver).strip() else None
changed = False
with self._lock:
if pkg != self._package or ver != self._version:
self._package, self._version = pkg, ver
changed = True
if changed:
print(f"[kb-nacos] 知识包激活版本更新:package={pkg} version={ver}", flush=True)
# 变更 + 有版本 + 属本包(或 dataId 未标包 id)才物化;非本包的推送不误触发本 worker 物化。
if materialize and changed and ver and (pkg is None or pkg == self._package_id):
self._materialize(ver)
def _materialize(self, version: str) -> None:
"""据激活版本把知识根物化到新版(显式传 version,不回查自身,避免 resolve 再绕一圈)。
best-effort 边界:materialize_active 内部对缺文件/取不到/指纹不符仍【显式失败】抛
KnowledgeMaterializeError(kb_root 保留上一份),这里 catch 成告警——不削弱其显式失败语义,只保证
「激活版物化失败绝不拖垮生成」(agent 继续读上一份完整包 / 仓根)。
"""
try:
res = kb_store.materialize_active(
self._store, self._kb_root, package_id=self._package_id, active_version=version)
print(f"[kb-nacos] 激活推送触发物化: {res}", flush=True)
except Exception as e: # noqa: BLE001 —— 含 KnowledgeMaterializeError:保留上一份,不抛(不咬生成)
print(f"[kb-nacos] ⚠ 激活版本物化失败(保留上一份已物化包/仓根,best-effort):"
f"{type(e).__name__}: {e}", flush=True)
def get(self, package_id: str) -> Optional[str]:
"""供 kb_store.resolve_active_version 查:返回当前激活版本;dataId 里的包 id 与请求不一致则返 None(交回落)。"""
with self._lock:
if self._package is not None and package_id != self._package:
return None # dataId 标了别的包 → 不冒充本包激活版
return self._version
def _resolve_data_id() -> str:
"""知识包 dataId:env TIER2_KB_NACOS_DATAID 显式覆盖 > 默认 gen-hot-params-knowledge。"""
override = os.environ.get(_KB_DATAID_ENV, "").strip()
return override or _DEFAULT_KB_DATAID
def build_and_attach_active_version_source(store: Any, kb_root: Path, *,
package_id: Optional[str] = None) -> Optional[KbActiveVersionSource]:
"""worker 启动期:若 Nacos 已启用(复用 genconfig_nacos 总开关),构造知识包激活版本源、start()、attach 进 kb_store。
Returns:
已 attach 的 KbActiveVersionSource;Nacos 未启用(默认)或构造失败 → None(resolve 回落 env/最新 committed)。
全程 best-effort:任何异常只告警、不抛、不 attach。
"""
package_id = package_id or kb_store.DEFAULT_PACKAGE_ID
if not genconfig_nacos.is_enabled():
# 默认关:不接 Nacos,resolve_active_version 走切片一链(env / 最新 committed)。
return None
nacos_addr = os.environ.get("NACOS_SERVER_ADDR", "100.64.0.8:8848")
namespace = os.environ.get("NACOS_NAMESPACE", "") # 默认公共空间 = 空串(勿写 public;前置任务口径)
username = os.environ.get("NACOS_USERNAME", "nacos")
password = os.environ.get("NACOS_PASSWORD", "")
group = os.environ.get("TIER2_GENCONFIG_NACOS_GROUP", "DEFAULT_GROUP")
data_id = _resolve_data_id()
# 内网铁律:必须在【建 client 前】把 Nacos host 塞进 NO_PROXY(复用 genconfig_nacos 的旁路,不另写一份)。
genconfig_nacos._bypass_proxy_for(nacos_addr.split(":")[0])
try:
import nacos # noqa: PLC0415 —— 惰性 import:默认关时不牵 nacos 依赖
client = nacos.NacosClient(nacos_addr, namespace=namespace, username=username, password=password)
src = KbActiveVersionSource(client=client, data_id=data_id, group=group,
store=store, kb_root=kb_root, package_id=package_id)
src.start() # 初读 populate 版本(不物化;启动物化由 setup 显式做一次)+ 订阅(变更即物化)
kb_store.attach_active_version_source(src)
print(f"[kb-nacos] 已接知识包激活版本源:dataId={data_id} group={group} "
f"namespace={namespace!r} package={package_id} nacos={nacos_addr}", flush=True)
return src
except Exception as e: # noqa: BLE001 —— best-effort:接线失败不 attach、不抛,resolve 回落 env/最新
print(f"[kb-nacos] 接知识包激活版本源失败(resolve 回落 env/最新 committed,best-effort):"
f"{type(e).__name__}: {e}", flush=True)
return None
def setup_knowledge_activation() -> Optional[KbActiveVersionSource]:
"""worker(便宜档 Service / tier2)启动期一键接线知识包激活生效链(K3),与 genconfig 热源接线对称。
做三件事(全 best-effort,任何异常只告警、绝不阻断 worker 启动 / 生成):
1. TIER2_KB_ROOT 未配 → 知识件外置关闭,直接返回 None(默认关、read_file 字节不变);
2. 已配 → 先(Nacos 启用时)attach 激活版本源,让启动物化就能读到治理激活版;
3. 启动期物化一次:接了源就物化 Nacos 激活版,否则物化 env 钉版 / 最新 committed。
cheap_service_app.main / tier2 service.app.main 在起 uvicorn 前调它即可。
"""
try:
kb_root = kb_store.kb_root_from_env()
if kb_root is None:
# 默认关:知识件外置未开,不物化、不订阅(现状字节不变)。
return None
store = kb_store.default_kb_store()
# 先 attach Nacos 激活版本源(若启用):下面这次启动物化就能读到治理激活版(经 resolve → 源)。
src = build_and_attach_active_version_source(store, kb_root)
# 启动期物化一次(源已 attach → 读 Nacos 激活版;否则 env 钉版 / 最新 committed)。
try:
res = kb_store.materialize_active(store, kb_root)
print(f"[kb-nacos] 启动期物化知识根: {res}", flush=True)
except Exception as e: # noqa: BLE001 —— 含 KnowledgeMaterializeError:保留上一份/仓根,不阻断启动
print(f"[kb-nacos] ⚠ 启动期物化失败(保留上一份已物化包/回落仓根,best-effort):"
f"{type(e).__name__}: {e}", flush=True)
return src
except Exception as e: # noqa: BLE001 —— 整条接线异常也不阻断 worker 启动
print(f"[kb-nacos] ⚠ 知识包激活接线异常(跳过,best-effort):{type(e).__name__}: {e}", flush=True)
return None
def detach() -> None:
"""卸载知识包激活版本源(K3 回退开关的显式入口;转调 kb_store.detach_active_version_source)。"""
kb_store.detach_active_version_source()

View File

@ -43,7 +43,7 @@ import os
import shutil import shutil
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Any, Optional
# 纯内容寻址工具复用 store.py 的单一实现(整包指纹 / 版本 id 派生 / 相对路径安全校验)——不另抄一份, # 纯内容寻址工具复用 store.py 的单一实现(整包指纹 / 版本 id 派生 / 相对路径安全校验)——不另抄一份,
# 保证知识包与源工程用同一套「同内容 → 同指纹」「改源 → 新 versionId」语义。store.py 顶层只 import 标准库, # 保证知识包与源工程用同一套「同内容 → 同指纹」「改源 → 新 versionId」语义。store.py 顶层只 import 标准库,
@ -627,17 +627,56 @@ def _minio_reader(client, bucket: str, prefix: str):
return _read return _read
# ── 当前激活版本解析(切片一简版;K3 的接缝点)──────────────────────────────────────────── # ── 当前激活版本解析 + 治理激活版本源(K3 接缝点落地)──────────────────────────────────────
# 进程级单例槽:worker 启动期由 kb_nacos.build_and_attach_active_version_source() 注入一个「激活版本源」
# (读 Nacos 下发的当前激活知识包版本);resolve_active_version 取值链最前端先查它。默认 None(未 attach)=
# 切片一行为:env 钉版 > 最新 committed(字节不变)。attach/detach 对称于 genconfig.attach_hot_source,
# 放本模块(纯 dict 槽、不牵 nacos 依赖,cheap venv 可 import);真正建 client + 订阅 + 变更即物化在 kb_nacos。
_active_version_source: Any = None
def attach_active_version_source(source: Any) -> None:
"""worker 启动期注入知识包激活版本源(K3);此后 resolve_active_version 最先查它。
source 只需满足 get(package_id)->version|None 契约(kb_nacos.KbActiveVersionSource 即是),本模块不依赖其
内部实现——故单测可注入任意 fake 源。幂等可重入:重复 attach 覆盖为最新源。
"""
global _active_version_source
_active_version_source = source
def detach_active_version_source() -> None:
"""卸载激活版本源,resolve_active_version 回落 env/最新 committed(K3 回退开关的进程内落点)。"""
global _active_version_source
_active_version_source = None
def resolve_active_version(store: KnowledgePackageStore, *, def resolve_active_version(store: KnowledgePackageStore, *,
package_id: str = DEFAULT_PACKAGE_ID) -> Optional[str]: package_id: str = DEFAULT_PACKAGE_ID) -> Optional[str]:
"""解析「当前激活的知识包版本」:env TIER2_KB_ACTIVE_VERSION 显式钉版 > 最新 committed 版本。 """解析「当前激活的知识包版本」:治理激活版本源(Nacos 下发) > env TIER2_KB_ACTIVE_VERSION 钉版 > 最新 committed。
这是 W-CFG-KB 治理面(K3)的接缝点:切片一用 env / 最新版这条简单来源;后续 K3 把它换成「读 yudao 配置集 W-CFG-KB 治理面(K3)已把接缝落地:worker 启动期若接了知识包配置集的 Nacos 激活版本源(kb_nacos),这里最先查它
当前激活的知识包版本」,并由激活推送触发 materialize_active。返回 None = 无激活版(物化 no-op,读回落仓根)。 ——改一版知识件经配置控制台定版 + 激活,激活推送到 Nacos,worker 读到新版本号即据它物化。未接源(默认 / TIER2_KB_ROOT
未配 / Nacos 未启用)则回落切片一行为:env 钉版 > 最新 committed(字节不变)。
治理源取值 best-effort:源不可达 / 无此包激活版 / 取值异常都静默回落 env/最新 committed,绝不让治理源拖垮物化
(→拖垮生成)。返回 None = 无激活版(物化 no-op,read_file 回落仓根)。
""" """
# ⓪ 治理激活版本源(K3 最高优先级):Nacos 下发的当前激活知识包版本。
src = _active_version_source
if src is not None:
try:
gov = src.get(package_id)
if gov and str(gov).strip():
return str(gov).strip()
except Exception as e: # noqa: BLE001 —— 治理源异常绝不连累物化:静默回落 env/最新 committed
print(f"[kb-store] ⚠ 读知识包激活版本源失败(回落 env/最新 committed): "
f"{type(e).__name__}: {e}", flush=True)
# ① env 显式钉版。
pinned = os.environ.get(KB_ACTIVE_VERSION_ENV) pinned = os.environ.get(KB_ACTIVE_VERSION_ENV)
if pinned and pinned.strip(): if pinned and pinned.strip():
return pinned.strip() return pinned.strip()
# ② 最新 committed 版本。
return store.latest_committed_version(package_id=package_id) return store.latest_committed_version(package_id=package_id)