Compare commits

...

38 Commits

Author SHA1 Message Date
zizi
8f04dc3f0c docs(设计): 蒸馏测试倒逼剩余缺口G2/G3/G8/G9/G10
- G8 安全时长钉死:handoff token 15min(架构-04 §10.1)/下载凭证 5min(§12.2)/高危安全操作冷却 24h(§3.2)
- G9 风险/冲突分级三级枚举 none/soft/hard,hard 禁静默一键确认(产品-02 §10.4)
- G3 改写洗白判据:外部 lineage 限制不可经改写解除,原创确认非洗白通道(产品-02 §6.4)
- G10 灰度通过/回退/证据不足准则,照对照实验范式(流程-02A §5.1)
- G2 计费分阶段:本阶段以额度调整 ledger 为可测合同,完整四态列后续阶段(产品-02 §10.8)
- 同步 专题-08 §7.3 与缺口跟踪文档:10 项缺口已全部拍死,G5/G6/G7 留回放基线(待执行)
2026-07-31 00:26:40 +08:00
zizi
81a8d1377f docs(设计): 新增专题-08自动化测试方案SoT,蒸馏G1/G4需求缺口
- 新增 专题-08-自动化测试方案:四层测试金字塔、确定性/语义分界判据、语义评测方法论(双盲评委/对照实验Gate B/回放)、测试可判定性合同
- 登记归属:00-文档大纲 + 内容映射表
- 蒸馏G1(收紧自动确认):产品-02 §6.4/§9.1 纳入达标自动确认四条件,消除唯一入口矛盾
- 蒸馏G4(运行时叙事门):专题-04 §4.2.1 补三维量表与通过线(设定≥7.0/角色·场景≥6.0)及四步裁决
- 新增 docs/plans 缺口跟踪:登记10项需求缺口与蒸馏状态
2026-07-30 23:25:15 +08:00
zizi
36268bb118 设计: 固化正文智能体卡索引原文回读契约 2026-07-20 18:42:24 +08:00
zizi
2124a79312 docs(design): 补知识效用缺环——新增专题-07 消费契约与质量闭环 + 七册配套拍板
第一性原理结论:知识的价值只在被选中并改善产出的那一刻兑现;此前 SoT 钉死了治理
(谁能读什么),缺消费选择(该读哪几条)与输入侧质量(什么算好知识、怎么测)。

- 新增 专题-07 v1(唯一 owner):术语桥(卡↔SoT 载体)、按用途默认消费合同与注入
  视图两档、知识质量三性(可命中/可行动/可持续+口径)、回放评测(参考书=标准答案,
  三评测线+两道合规闸+知识策略对象与上线门禁)、长线进度三期消费、实验台实证附录、
  验收清单 8 条
- 专题-06 v4:拍板双层型判定(craft 1326 条实测无损→不拆,判据不变);新增 §6.4
  参照作品面(参考书实体演变=系统侧证据资产,蒸馏成叙事域成长曲线范式才入 Global);
  世界域六型登记演变历程元素;读取器 purpose 枚举 parse→extraction
- 架构-02 v11:aiContext 值域升级为布尔或用途集(实验台字段级用途裁剪实证反哺)
- 专题-03 v3 / 专题-04 v2(补 .md 改名+离线评估允许样本第 5 类)/ 后端-05 v11 /
  前端-03 v7 / 产品-01·02、前端-02、后端-03 断链修复 / 大纲 v10 / 映射表 v8
- prototypes/ 新增四页签开发者总览

依据:设计文档全库横切取证 + 实验台 9 批实拆实证(活卡 8587、消费端 0 实现、升格
卡向量 0%、p50 实例数 1)。经 codex 与 opus 双独立评审,必修项全部落实。
2026-07-18 00:02:51 +08:00
lili
c1662fa9d3 docs(design): 结构本体补全 23 型 + 检索基座演进方向与 API 边界钉死
- 专题-06 v3:补 chapter(章节容器)/scene(场景卡)/narrative_state(叙事状态模具)三型,
  20→23、scope 七值全挂靠;段落不独立建模由 scene 承载(Block=场景/小节级既有拍板);
  §2.1 检索基座替换合同与演进方向(引擎缝+引擎中立合同,预期纯 Java 自研:PG 向量插件+New-API 嵌入重排);
  钉死读取器只依赖 owner api 模块具名端口(服务即 API)与 storage_binding 权力边界(映射非数据通道)
- 章节容器证据链:专题-03 L1 早已预设「章节目标」为续写必需输入而章节表无此字段,扩展字段落此欠账
- 架构-02/后端-04/产品-02B/大纲/映射表:引用去硬编码数字防再漂移;评审稿 v0.3 同步 W1=23 项

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 04:28:09 -07:00
lili
81374bc203 docs(mvp): 记录 1a 闭合后剩余路线 + provider 流式暂缓决策
- 剩余三层:1a 残留(S8 导入上传链 live)/1b 单人主线缺口(P0-11..P1-11)/2.0.0 完整版元数据智能体架构(待过审)
- 流式:provider 级 streaming 暂缓不做(2026-07-09 拍板),AI 生成主链继续走非流式 SSE

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 02:45:11 -07:00
lili
b376589d5c docs(design): 元数据驱动智能体架构定版落 SoT——新增专题-06 + 六分册对齐拍板
- 新增专题-06-元数据驱动的智能体架构(v1):agent=f(作品+元数据+知识库) 横切 owner——
  双枢中枢/三体关系/20 型 target_type 本体/作品容器与 base 内置机制/拆书与 reference_work/统一创作数据读取器
- 架构-02 v10:§1.2 Canonical 入口增补(管理员确认系统级知识草稿→Global KB 范式);§9 补 domain 逐值语义、override 只增不改
- 架构-03 v13:ADR-022 功能链定义归元引擎(案A 顺代码)、ADR-023 双轨入口增补
- 后端-04 v11:功能链表族订正 muse_meta_function_chain* 归 meta;character_entity→character;muse_meta_field 增 storage_binding
- 架构-01/后端-02/产品-02B:功能链归属行与拆书治理管理面对齐;大纲/映射表注册专题-06
- 落档评审稿 v0.2(过程稿):三拍板+四默认、执行计划 W1-W8、反假绿(生产迁移零 MetaSchema seed)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 01:06:38 -07:00
lili
c9dbd51ed1 docs(agents): 蒸馏 S9 部署收口 4 坑入 knowledge gotchas §八
sql/muse compose 挂载 / 覆盖台账 JSON 是 SoT 别重生成 / 加 service @Resource 依赖须真跑跨模块 IT / 全新作品 AI 需 muse_tool_grant authz 种子

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 10:26:05 -07:00
lili
1c2d51bfd4 feat(deploy): S9 单人版 compose 从零部署+黄金旅程活体验收(1a 闭合)
- docker-compose.solo.yml:muse-server 单体+PG+Redis 一体化编排,固化 sql/muse Flyway 迁移只读挂载(mini-infra 从零真验暴露漏挂致 muse 表全缺、AI worker 每秒报 relation does not exist)
- 2.0.0-单人版部署手册.md:双栈拓扑/凭据清单/从零步骤/回滚恢复多用户/Dify 升级约定
- 进度总账:§9 判据 0–7 全留证(黄金旅程 workId98 七步 PG 核验+compose 从零 36 迁移 121 表)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 10:19:47 -07:00
lili
7a6e46b2fe fix(test): S9 整分支收口修 S6d 台账漂移+V35 跳号登记+S8 导入向导 IT 上下文
- 覆盖台账 p1r-api-coverage.json 与生成器去 S6d 已删 RAGFlow 文件引用,知识域 serviceFiles/testFiles 换 Dify 继任,保 32 market dormant 口径不被重生成摧毁
- ContractFirstGateTest 登记 V35 有意跳号(并行线版本预留,V36/V37 已应用 live 库不可回填)+ stale 守卫
- P1rContentImportWizardCompletedApprovalIT 补 MuseAiProperties @Bean(S8 加 @Resource 依赖后极简上下文缺 bean 致 ApplicationContext 加载失败)
- 独立复跑:local 门 65/0F/0E BUILD SUCCESS;import wizard 真PG IT 2/0F/0E(1 live skip)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 10:19:11 -07:00
lili
302603d9d9 refactor(knowledge): S6d 删 RAGFlow 运行时,Dify 转默认装配
S6 Dify grounding 已 live 证成(新 KB→Dify dataset 索引→生成 contextAssembly
chunkCount=1→输出复现 KB-only 专名),RAGFlow 运行时退役。删 HttpRagFlow/
UnavailableRagFlow 两 client + RagFlowKnowledgeRuntimeClientConfiguration 装配
+ 其配置测试 + KnowledgeRuntimeClientTest(整体为 RAGFlow 契约测试)+ RAGFlow
live IT(knowledge 侧 P1rRagFlowLiveAcceptanceIT + muse-server 侧 P1rKnowledge
RuntimeEndToEndLiveAcceptanceIT,§9.3 明示退役;Dify e2e 已活体证成、按 content-
filter 记忆不新造 P1r IT 顶替)。

装配转默认:DifyKnowledgeRuntimeClientConfiguration 主 bean 加 matchIfMissing=true
(provider 未设→Dify);新增 @ConditionalOnMissingBean 兜底 unavailableKnowledge
RuntimeClient(原无通用兜底,provider=ragflow 显式值下主 bean havingValue=dify 不
匹配会致无 bean 启动失败,兜底补齐后落 Unavailable)。RagFlowKnowledgeRedactor 被
Dify 复用保留;ragflow 属性块/muse_knowledge_ragflow_binding 表(遗留命名存 Dify
ID)不动。market-assembled 双排除的 P1rMarketKbForkMaterializationIT 保留(market+
RAGFlow 双休眠,归 market 恢复路径,符合 S3「排除不删」)。

验证:knowledge-server 单测 300/0/0/0 BUILD SUCCESS;knowledge+muse-server 全分支
test-compile BUILD SUCCESS(MVN_EXIT=0);DifyKnowledgeRuntimeClientConfigurationTest
8 全绿验三态(缺省→Dify/ragflow→Unavailable/缺省未配→fail-closed Unavailable)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 09:06:32 -07:00
lili
9057dc8df0 fix(ai): 补修 S8 接口加 providerKey 破坏 muse-server 导入向导 IT 编译
S8(0b109a29)给 MuseAiImportLlmParser 接口加 providerKey() 后它不再是函数式
接口,而 P1rContentImportWizardCompletedApprovalIT 的 importLlmParser() 测试 bean
仍以 lambda 提供 stub → muse-server test-compile 断裂。当时 S8 只跑 ai 模块单测
(20/0/0/0)、未编 muse-server 整分支,故漏(整分支视角收口价值)。改 lambda 为匿名
类实现 parse+providerKey;该 IT 只装配单个 parser bean、resolveImportLlmParser 对
size==1 直接返回,providerKey 不参与选择,返回 "stub" 占位。

验证:mvn -pl muse-server -am test-compile BUILD SUCCESS(MVN_EXIT=0)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 09:06:31 -07:00
lili
1c74372a1d docs(mvp): 回写 S7 M3 生成 live 打通与 S8 导入解析切 Dify 里程碑
进度总账 S 步进度簇三处更新:原「M3/S8 live 阻断」条改写为「S7 生成主链 M3
经 Muse 后端 LIVE 打通(里程碑 M3 达成)」——记 app-api→agent v8(dify)→
RealDify→写作 chat app(M3)→suggestion 107 真续写,及根因 bdc07a43(单体
classpath 遮蔽 + credentials List 无法扁平 env 绑定);新增 S8 导入解析切 Dify
条(0b109a29,chat app 17438ceb,如实标注验证层级:单测20/IT16/冒烟/review
已做、上传链 live 归 S9);S6b–d 从「待做」改为「代码完成·API 级验证」、live
grounding 与删 RAGFlow(S6d)明确归 S6 live 证后。诚实备注:S8 子代理自曝伪造
工具输出,以主代理独立复核为准。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 08:12:11 -07:00
lili
0b109a2961 feat(ai): S8 导入解析切 Dify chat 解析器 + provider 选择
MuseAiImportParseService 从单点注入 NewApiMuseAiImportLlmParser 改为按
muse.ai.import.provider(缺省 dify)从 List<MuseAiImportLlmParser> 选择;来源标识
由选中 parser 的 summary.parser 自报(dify_import_llm / new_api_import_llm)。

新增 DifyMuseAiImportLlmParser:走 Dify「muse-全书解析」chat app(passthrough M3、
克隆自写作 app)的 /chat-messages blocking 调用,把镜像 New-API 的 system+user 解析
prompt 合并成一条 query 发出,从 answer 消费章节 JSON。语义逐条对齐 New-API 版:
401/403 fail-closed 不重试、408/429/5xx 退避重试、输入上限 180k、空/超 300 章节、
脱敏 summary、直连绕代理、key 不落库不打印。截断检测差异:Dify chat 无 finish_reason,
改为对 answer 做 JSON 完整性校验(解析失败→AI_IMPORT_LLM_TRUNCATED 不可重试)。
MuseAiProperties.Dify 补 maxAttempts/retryBackoffSeconds;新增 ImportParse.provider。

规格允许 app/workflow;创始人定 chat app 顶替空 workflow 壳 fed4d25c。p1r 配置
MUSE_AI_DIFY_PARSER_APP_ID/API_KEY 指向新 chat app 17438ceb。

验证(独立重跑核验,不采信子代理自报):
- ai 模块单测 20/0/0/0(DifyImportLlmParser 7、ImportParseService provider 选择 11、
  NewApi 2)+ content ImportParseService IT 16/0/0/0,MVN_EXIT=0 BUILD SUCCESS
- Dify 解析 app 直连 /v1/chat-messages 冒烟真出严格章节 JSON {"chapters":[...]}
- 代码 review:确认真 chat 契约(/chat-messages、读 answer、无 workflow 残留)
未做:完整上传链 live 全书解析(import→对象存储→storageRef→parse job→LLM)属 S9
黄金旅程/import-wizard.spec.ts 范畴——inline contentText 走同步 splitter 绕 LLM,
LLM 路径需对象存储上传流,非本次 parser 改动引入。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 07:59:26 -07:00
lili
bdc07a4321 fix(ai): muse-server 补 muse.ai.dify.credentials 修 Dify 生成凭据无法绑定
单体 muse-server 只加载自身 application.yaml,muse-module-ai-server 的同名
application.yaml(含 muse.ai.dify)被 classpath 遮蔽不生效。enabled/base-url
等标量能靠 MUSE_AI_DIFY_* 环境变量 relaxed-binding,但 credentials 是 List、
无法用扁平环境变量绑定 → 运行时列表为空 → RealDifyMuseAiRuntimeClient.apiKey()
恒返 null → 任何 Dify 生成秒失败于 AI_AGENT_DIFY_CREDENTIAL_REQUIRED。此前 S7d
只有 mock properties 的单测、从未经 muse-server 端到端活体验证,故未暴露。

修法:在 muse-server 自身 application.yaml 的 muse.ai 下补 dify.credentials
列表块(api-key 仍从环境变量注入,禁止明文落库)。

活体验证(muse_slice_live,官方 start-muse-server-infra.sh 重建+仅 p1r 标量 env、
无索引 env 兜底):POST /app-api/muse/ai/tasks(work1 writing.continuation)
→ agent v8(runtimeProvider=dify)→ RealDify → Dify /v1/chat-messages(写作 app
75f105e8, MiniMax-M3)→ generation taskId=117 status=completed 无 error →
suggestion id=107 落库真 M3 续写正文。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 04:50:27 -07:00
lili
13bd910c8f feat(knowledge): S6 摄入轮询键 documentId→batch(Dify 按 batch 轮询 indexing-status)
为 muse_knowledge_processing_task 加 runtime_batch_id 列并落库 Dify 上传返回的索引批次(从 uploadResult.summary 的 batch 键抽取)。poll worker 取 runtimeBatchId 非空优先、否则回退 ragflowDocumentId 作轮询键,RAGFlow 无 batch 路径逐字不变;skip gate 放宽为 documentId 与 batch 都空才跳过。

DDL 采用新迁移 V37 ALTER TABLE ADD COLUMN,非回编 V14 建表语句——沿用 sql/muse append-only 约定(V24/V27/V30/V31 同法),避免破坏已应用库(dev/live)的 Flyway 校验和。

验证:mvn -pl muse-module-knowledge-server -am test 目标两类全绿(PollWorkerTest 9/0/0/0、DocumentServiceTest 17/0/0/0,聚合 Tests run 26 Failures 0 Errors 0 Skipped 0,BUILD SUCCESS)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 03:45:46 -07:00
lili
a8f5e912f5 feat(knowledge): S6c 检索多 dataset 逐库扇出+按相似度合并(Dify 单库端点适配)
Dify 的 retrieve 是单 dataset 端点、一次只检索一个库;把 MuseKnowledgeRetrievalApiImpl 从一次
多库调用(RAGFlow 原生多 dataset_ids)改为逐授权库检索:每次 chunk 直接归属本次检索的授权来源
(Dify 单库响应无 dataset_id、无法事后反查),再按相似度降序合并、截断到全局 topK。单库失败降级
跳过并记可追溯日志、全部失败回传代表性失败类。顺序执行(非并行)保留租户 ThreadLocal 上下文,
避免并行池线程丢上下文的隔离风险;内测期单作品 KB 数少延迟可接受,并行留作后续优化。授权双门
/§5.3 合同字段/同库多来源去重语义不变。

测试 18/0/0/0:15 个原测试(含 SPIKE 越权 + uRetrieve)证明单来源行为逐字不变,新增多库合并排序
/单库失败降级/全库失败三例。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 03:12:55 -07:00
lili
09ff354e2f feat(knowledge): S6b DifyKnowledgeRuntimeClient adapter(5 操作)+ provider 互斥装配
S6 第二子步(不改检索/上传/轮询流程、不加 DDL、不删 RAGFlow):新增 DifyKnowledgeRuntimeClient
实现 KnowledgeRuntimeClient 5 操作,HTTP 范式对齐现有 Dify/RagFlow client(JDK HttpClient+直连
ProxySelector+Bearer(dataset-api-key)+RagFlowKnowledgeRedactor 脱敏+可测 send() 缝+fail-closed)。
API 映射以 S1 P1rDifyDatasetsContractLiveIT 为准:createDataset POST /v1/datasets(顶层 id)、
uploadDocuments create-by-file(嵌套 document.id + 顶层 batch)、startParse 降级 no-op(Dify 上传即
索引)、pollDocumentStatuses indexing-status(batch 主键、data 数组/对象两态归一)、retrieveChunks
retrieve(单库,records[].segment.content/score 归一成下游已消费的 data.chunks[].content/similarity)。

装配互斥:新增共享选择器 muse.knowledge.runtime-provider——Dify 仅 =dify 装配,RAGFlow =ragflow
或缺省(matchIfMissing=true)装配,确保单人只装一个 bean(取代非确定的 @ConditionalOnMissingBean
扫描顺序)。**默认保持 RAGFlow、既有部署零变更;激活 Dify 需 S9 solo 配置设 runtime-provider=dify**。
新增 UnavailableKnowledgeRuntimeClient 通用 fail-closed 桩。未摘 RAGFlow(S6d)。

过渡缺口(属 S6c):adapter 单库检索只取首个 datasetId,多 KB fan-out+合并归 S6c 的 RetrievalApiImpl;
poll batch 依赖 S6c 把 worker 轮询主键改喂 batch;归一成 data.chunks[] 形态使 S6c 只需加 fan-out。

验证:knowledge 模块 315 用例全绿(DifyClientTest 22 + ConfigTest 8 + 旧 RAGFlow 未回归),BUILD
SUCCESS;装配 ApplicationContextRunner 6 组场景实证任何组合只装 1 bean。真连冒烟归 S6e。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 01:58:34 -07:00
lili
6c68b26c6f docs(mvp): 总账收口 S7 全线+S6a+M3/S8 Dify app 阻断+剩余路线图
S7d 代码层 b583bd6d、S6a 2ac42a06 记账;标注真环境阻断:Dify 各 app 模型后端未配好(写作透传
/chat-messages 500),所有者须在 Dify 侧修复,M3/S8 live 才过;S6 走 Datasets 不受此阻断。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 01:26:59 -07:00
lili
b583bd6dc6 feat(ai): S7d 生成 provider 切 Dify 的 fail-closed 硬化 + 超时对齐(代码层)
分支①第四步代码/配置层:RoutingMuseAiRuntimeClient 补 Dify 装配为占位 Unavailable 时的 fail-closed
——dify 选中但未装配/凭据缺 → 返回 Dify 专属 AI_DIFY_UNAVAILABLE 并拒绝,绝不回退 New-API(此前会
误报 AI_NEW_API_UNAVAILABLE);dify 分支三出口(requiresSourceRefs→REF_INVALID / !difyAvailable→
UNAVAILABLE / 否则 difyClient.execute)均不触碰 newApiClient(亲验)。Dify non-stream-read 超时默认
90→180s 对齐总预算(180≤180<SSE 死线 240);单人 solo-compose.env.example 启用 Dify 块(凭据取自
既有 S1 DIFY 块,未新造);New-API adapter 与配置保留(S8 才摘)。

在用 Agent 的 runtimeProvider 存于 muse_ai_agent_version.config(运行时 DB JSON 列)非静态种子,故真正
把在用 Agent 切 Dify 属 live 数据操作,归 M3 由主会话执行(本提交不含 DB 改动、不切换 live provider,
超时改动在 Dify 未启用前休眠)。

验证:ai 模块 530 用例全绿(新增 fail-closed 不回退 + Dify 完整正文形态 2 例);P1rDifyChatLiveAcceptanceIT
真连 100.64.0.8:18080 验证 wiring/契约/失败映射通过。**M3 live happy-path 阻断**:Dify「muse-写作透传」
app(75f105e8)/chat-messages 返 500(/info 200)= 该 app 模型后端未配好(Dify 侧配置,非 Muse 代码),
代码正确映射 AI_DIFY_PROVIDER_5XX 可重试+fail-closed;待所有者在 Dify 侧修复 app 模型配置后跑 M3。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 01:25:56 -07:00
lili
2ac42a0628 refactor(knowledge): S6a 端口重命名 RagFlow→Knowledge + 裁剪无生产调用操作
S6 第一子步(机械、provider 无关):接口 RagFlowKnowledgeRuntimeClient→KnowledgeRuntimeClient
全量重命名(207 引用,裸旧名 PCRE 精确核验 0 残留);裁掉 7 个已核验无生产调用操作
(updateDatasetConfig/listDatasets/listChunks/runGraphRag/traceGraphRag/getKnowledgeGraph/health,
Command/枚举/实现体/单测清零),接口保留 5 操作(建库/上传/触发解析/轮询/检索);审计
recordRagflowCall→recordRuntimeCall、RagflowCallRecordReq→RuntimeCallRecordReq(旧 token 0/新 13)。
REST 端 MuseKnowledgeGraphQueryService.getKnowledgeGraph(从 Muse 自有 PG 读图,同名不同方法)保留未动。
命名债白名单保留原名:表 muse_knowledge_ragflow_call/_binding、两 DO/Mapper、ragflowDatasetId/
DocumentId 列、KNOWLEDGE_RAGFLOW_* 错误码、FailureClass.RAGFLOW_*(S6 删类步再清)。

两个 opt-in live IT(P1rRagFlowLiveAcceptanceIT、server 的 P1rKnowledgeRuntimeEndToEnd...)原本真调了
被裁操作(health/listChunks/runGraphRag/traceGraphRag),做最小手术删被裁调用/断言、保留 5 操作
happy-path 覆盖(这俩 IT 本就在 S6 后续删/重做)。HttpRagFlowKnowledgeRuntimeClient 等实现类名 S6 删类步保留。

验证:knowledge 模块 285 用例全绿(BUILD SUCCESS);muse-server test-compile 默认 + market-assembled
双 profile 均 BUILD SUCCESS(被改 IT 新鲜编译)。此前 worktree 隔离基线陈旧 320 commit 作废、主树重做。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 01:03:52 -07:00
lili
0451549829 docs(mvp): 总账记 S7 分支① M2 达成(S7a/b/c 落地验真)
全文通路三步 916d6c87/fcd0623a/43a08624 逐一独立验真;所见即所写经真后端+真 New-API e2e
端到端证实 Canonical===客户端 finalContent。剩 S7d 切 Dify + S6/S8/S9。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 00:55:32 -07:00
lili
43a08624f8 feat(studio): S7c 采纳恒回传完整正文(所见即所写)+ 流健壮性
分支①第三步(纯前端,无需改后端):采纳恒以 merge_after_edit + modify_then_merge 语义上送用户在
候选面板审定的完整正文作 finalContent(改没改过都送、原样发),使 Canonical 由客户端回传正文写入,
而非后端存的 60/80 字摘要——修掉「原样采纳(accept_as_is)把摘要写进 Canonical」的潜伏缺陷。
后端契约已亲验:ContentSourceServiceImpl.resolveMergedContent 的 merge_after_edit 分支直接落
finalContent、只校验非空、不要求"必须改过";accept_as_is 才用摘要。

流健壮性:done 仅带 taskId/suggestionId/可选 summary、无完整性信号,故按前端可得信号三分——①流非空
(原子 SSE chunk 已收全)→happy;②流非空但短于 done.summary→疑似断连截断,提示重生成、绝不静默采纳;
③流为空→回源只得脱敏摘要,标 degraded 由候选面板明确警告、不当完整正文静默采纳。

验证:tsc -b / vitest 32 files 121 passed / vite build / eslint 均 EXIT=0;MSW-off e2e
accept-suggestion.spec 真后端48080+真 New-API 3 passed——采纳后 acceptMode=merge_after_edit、
revision 180→181、Canonical content_text===客户端回传 finalContent(所见即所写端到端证实)。
改动严格 8 文件全在 muse-studio。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 00:53:56 -07:00
lili
fcd0623a18 feat(ai): S7b 瞬态全文载体 + SSE chunk 送达 + 决定即清
分支①第二步:新增独立瞬态加密表 muse_ai_generated_fulltext(V36:fulltext_content 经
EncryptTypeHandler AES 加密、expires_at TTL 30min、content_hash/length 供脱敏、决定即删),
仿 MuseAiRuntimePayloadStore 范式建 Store(write/read/purge/cleanupExpired)。执行器拿到成功
RuntimeResult 后把 fullOutput 写瞬态 Store(不在终态 finally purge——出向正文须存活到用户决定);
投影在 done 之前发一条非终态 chunk 引用行(payload 只含 {contentRef:taskId,sequenceNo},绝不带
正文,不落 outbox、不受终态唯一索引);SSE chunkData 遇 contentRef 回瞬态 Store 取正文填 content,
缺失(purge/过期)→空串,兼容旧内联行。采纳(merge facade)/放弃(reject 两路)即 purge。

主权红线(四重亲验):完整正文只存于执行线程内存/瞬态加密表/SSE 传输/客户端,永不进 chunk payload
(只放 ref)、永不进 muse_ai_suggestion/muse_content_block 任何长期列;content_snapshot.content 仍
写脱敏摘要(S7a 口径未动);DO toString 排除正文、Store 日志只算 sha256/length。

验证:ai 模块 528 用例全绿(新增 Store 6 + 投影/流/建议/合并各扩展),BUILD SUCCESS(-am 防 stale)。
真 PG 一次真生成端到端(chunk 携全文/决定后 purge/重连回取)并入 M1 harness,本步未造新 live IT(避
内容过滤器)。DDL:V35 由 S6 占,本表 V36,当前 V34→V36 临时空号,S6 落 V35 填平(Flyway 允许)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 00:29:29 -07:00
lili
916d6c877f feat(ai): S7a 捕获 provider 完整正文 + 护栏/审上移到完整正文
分支①第一步(provider 无关):RuntimeResult 加仅内存 fullOutput 字段承载 provider 完整正文
(供 S7b 瞬态通路),两个 real client(New-API/Dify chat+workflow)在派生 60/80 字摘要前捕获
完整正文并跑护栏,通过才透出。护栏 guardFullOutput 纯静态 provider 无关:空/超长/凭据痕迹/
疑似截断(≥60 字未以句末标点结束)→失败可重试,不信 provider finishReason(Dify 硬编码常量、
New-API 只记录)。「审」输入从脱敏摘要上移到 result.fullOutput(缺省回退摘要,兼容 shadow/历史)。

主权红线(三层亲验):content_snapshot.content 仍写摘要(resolveContentText,不变);完整正文仅
作 review() 局部变量,findings 落库只含布尔/marker 名/长度、审 ID 指纹用 hashCode+length 不可逆,
无正文明文入库;fullOutput 无任何 set/put/序列化/持久化出口,toString 只打 length。

验证:ai 模块 79 用例全绿(MuseAiCandidateReviewServiceTest 11 / MuseAiRuntimeClientTest 22 /
MuseAiRuntimeProjectionServiceTest 6 / MuseAiTaskServiceTest 40),BUILD SUCCESS。改动严格 7 文件。
真实生成经护栏的活体验证并入 M1(S7b real-PG)。附:DDL 协调 S7b 瞬态表改 V36(S6 batch 占 V35)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 23:59:27 -07:00
lili
ca045e3b13 docs(agent-specs): S7 全文通路材料点裁决落定 + 执行版 v1.0
三个材料级点所有者 2026-07-07 裁决全部采纳推荐:①独立加密缓存表 ②采纳恒回传完整正文(所见即所写)
③护栏+合规审上移到完整正文。据此产出执行版 v1.0,拆 S7a(捕获+护栏,provider 无关)→S7b(瞬态载体
+SSE 送达,V35 迁移)→S7c(前端所见即所写)→S7d(切 Dify,依赖 workspace)。总账同步。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 23:25:41 -07:00
lili
2973e8da66 docs(agent-specs): S7 生成主链全文通路(分支①)评审版 v0.2
现状经三路测绘实测校正:完整正文只活在 real client success() 里、构造结果前即被截成
60/80 字摘要丢弃(唯一捕获点);SSE 严格只重放已持久化事件、当前只发无正文的 done;
"三审"审的也是摘要;Dify 完成原因硬编码常量、截断护栏无效。

设计=一条"瞬态全文"通路:捕获处对完整正文做护栏→写瞬态加密缓存(复用 MuseAiRuntimePayloadStore
范式,TTL+决定即清)→事件表发 chunk 引用行、SSE 重放回缓存取全文填已声明的 chunk.data.content→
前端 streamContent 已接线零改动→采纳恒回传完整正文(所见即所写)写 Canonical。候选/正文表永不含
完整正文。含 mermaid 数据流 + svg/html 速览 + 现状锚点附录。三个材料级点待所有者裁决。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 23:16:05 -07:00
lili
aadc0aa8ab docs(mvp): 记录 S7.0 输出契约裁决 = 分支①(建不落库全文通路)
live 单体取全 SSE/UI/DB 三项证据:后端不发 chunk、done 只带 taskId/suggestionId/summary、
content_snapshot.content 仅 50-60 字脱敏摘要 → 现状无全文通路,用户只看到摘要。所有者确认
S7 走分支①:runtime 内存全文经 SSE(填已声明 chunk.data.content)推前端,候选表仍只存摘要+三审,
保数据主权。provider 无关、与切 Dify 正交;落地前须出评审版设计,provider 半段依赖 Muse 专属 Dify workspace。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 22:55:49 -07:00
lili
5ab58a4ed9 docs(mvp): S5 后端起栈 + MSW-off 创作主线 e2e 打通,S5 验收完成
DB owner 口径对齐 + moderation 关停后 muse-server 干净起栈,MSW-off 创作主线全量 e2e
33 passed/0 failed/31 隔离,local 门禁 65/0 无回归。经所有者 2026-07-07 验收,S5 标记完成。
moderation 类全量 boot 守卫归 S9 golden-journey 真起冒烟(切片合成测试挡不住真 yaml 回归,不补假绿)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 22:48:53 -07:00
lili
92b90878ec fix(solo): 补齐单人版登录栈种子与 OAuth2 scopes 兜底
单人版真实登录(admin 后台 / member studio)所需的基座种子与兜底:
- OAuth2TokenServiceImpl.createAccessToken 增加 normalizeScopes:登录链路不显式传 scope,
  但 PG token 表 scopes 非空约束 → 无兜底会写入失败;统一兜底到客户端默认授权范围。
- yudao-base-seed 补 admin(admin123, status=0)+ super_admin 角色绑定 + default OAuth2 client
  + member 测试账号 15601691388(admin123);ON CONFLICT DO UPDATE 幂等。
- yudao-base-schema / create_tables 给 system_login_log 补 tenant_id(登录写访问日志需要)。
- muse-slice-sequences-repair 补 system_oauth2_* 与 demo 序列;OAuth2 access/refresh token
  复用同一 @KeySequence,按两表 GREATEST 对齐,避免 nextval 撞已存在 id。

验证:OAuth2TokenServiceImplTest 14 tests/0 fail(含新增 testCreateAccessToken_nullScopesUseClientScopes);
种子已灌入 muse_slice_live(admin/member/oauth2 client/user_role/tenant 全就位、口令哈希 = admin123)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 22:41:29 -07:00
lili
9af471289a fix(solo): 修复 2.0.0 单人版 live 后端启动(DB 口径对齐 + moderation 关停)
标准启动默认落陈旧的 muse_local(仅 V0 baseline、缺 yudao 基座、update_updated_at_column()
函数属第三个角色 muse)→ Flyway V1 CREATE OR REPLACE FUNCTION 报 "must be owner"。唯一健康
的 live 库是 root@muse_slice_live(已迁 V34、含基座+登录种子)。将 application-infra.yaml 与
start-muse-server-infra.sh 的默认库/用户改为 muse_slice_live/root(真实值仍由 infra.env 覆盖)。

S4 关停 spring.ai.model.* 时漏了 moderation;OpenAiModerationAutoConfiguration 默认
matchIfMissing=true → 全量 boot 实例化 openAiModerationModel、无 OpenAI key 直接抛
"OpenAI API key must be set" 使单体启动失败。在 monolith 真正加载的 muse-server/application.yaml
与 ai-server/application.yaml 两处补 moderation: none(单人形态输出合规走自研 MuseAiCandidateReviewService)。

workspace.spec 冒烟原缺 token 注入,被 S5 新增的 AuthGuard 拦重定向 /login → 补 test1 token 注入。
external-deps 记录 Dify 实例被游戏/Muse 共用、workspace 须隔离的约束。

验证:muse-server 干净启动(Started in 22.4s、Flyway up-to-date 零 owner error);MSW-off 创作主线
e2e 33 passed/0 failed/31 隔离(候选采纳/导入/导出/知识草稿/图谱/绑定/agent 生命周期/AI 真生成全绿);
run-p1r-verification.sh local 65 tests/0 fail BUILD SUCCESS。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 22:41:12 -07:00
zizi
9ebf76cb0c docs(agent-specs): 同步 2.0.0 S4/S5 计划口径 2026-07-08 12:33:51 +08:00
zizi
6dcabf69fb feat(solo): 收口治理入口并裁剪 studio 表面
完成 2.0.0 S4 的外部 AI、admin、account 与 solo SQL 收口,并推进 S5 studio 市场/handoff/旧编辑器入口裁剪。S5 的前端单测、构建与 route-only e2e 已留证;MSW-off 创作主线 e2e 仍因本地后端活体启动口径待修正,已在总账标为未完成验收。
2026-07-08 11:45:26 +08:00
zizi
c8a88e71b3 feat(muse): detach market assembly for solo mode 2026-07-08 10:16:41 +08:00
zizi
b9663f52d1 test(p1r): prepare S2 market detach gates 2026-07-07 19:02:23 +08:00
zizi
6f2629302f feat(p1r): add Dify S1 live acceptance 2026-07-07 17:10:40 +08:00
zizi
13b305d4da fix(p1r): align 2.0.0 S0 local gate counts 2026-07-07 14:28:34 +08:00
zizi
2e45191160 docs(agent-specs): add 2.0.0 solo execution plan 2026-07-07 14:28:04 +08:00
194 changed files with 9896 additions and 5931 deletions

View File

@ -6,21 +6,26 @@
| 依赖 | 地址 | 关键事实 |
|---|---|---|
| New-API(LLM 网关) | `http://100.64.0.8:3000`,OpenAI 兼容 `/v1/chat/completions` | 已验收模型 `MiniMax-M2.5`;容器 `new-api` 端口 3000;其 DB 在 `infra-postgres`(宿主 `100.64.0.8:5433`) |
| RAGFlow(知识运行时) | `http://100.64.0.8`(及 `:9380`) | `/api/v1/datasets``/api/v1/retrieval`;健康检查 `/v1/system/healthz` |
| RAGFlow(1.0 历史知识运行时) | `http://100.64.0.8`(及 `:9380`) | `/api/v1/datasets``/api/v1/retrieval`;健康检查 `/v1/system/healthz`;2.0 下线由用户手工执行,不阻塞 S1 |
| Dify 1.15.0(2.0 单人版目标外部服务) | `http://100.64.0.8:18080` | 官方 compose 部署在 `mini-infra:/home/qingse/dify-1.15.0/docker`,compose project=`muse-dify`;API health 返回 `version=1.15.0` |
| 开发 PG / Redis | 见下方凭据来源 | 远端 PG **15**(用户确认可用,不强制 PG16);Redis `100.64.0.8:6379` |
**Dify 状态(2026-06-28 盘点)**:当前 Muse 1.0.0 运行链路没有接入 Dify,infra 主机也未部署 Dify 相关容器/端口。仓内只剩设计映射与示例测试提及 Dify;因此它不是当前生产运行时依赖。若产品后续要求 Dify 工作流能力,应按新集成从契约、配置、失败闭环、live 验收补齐,不能把它当成已存在基础设施
**Dify 状态(2026-07-07 S1)**:Dify 已作为 2.0 单人版目标外部服务完成基础设施部署与 live 契约验收,但生产运行时切换仍按 S2+ 后续步骤推进,不要把 S1 等同于 Muse runtime 已全面改走 Dify。当前 `muse-dify` 是一套独立基础设施栈,包含自己的 `db/redis/weaviate/nginx/sandbox/plugin_daemon/worker/web` 等容器;它不复用、不替换 Muse 既有 PostgreSQL/Redis
**凭据来源(明文不入库,只记位置)**:
- 外部验收:`muse-cloud/scripts/dev/p1r-external-acceptance.env`(`set -a; . 该文件; set +a` 加载;含 New-API base/token、`MUSE_AI_NEW_API_DEFAULT_MODEL_KEY=MiniMax-M2.5`、RAGFlow base/key、GraphRAG 开关)。
- 开发基础设施:`~/.config/muse-repo/infra.env`(PG/Redis 连接 + 真实凭据)。
- ⚠️ **安全**:上述令牌仅限当前内网验收上下文;**仓库若同步到更大范围,必须先轮换 New-API 令牌**。
- Dify 控制台/模型/Datasets 凭据:`mini-infra:/home/qingse/.config/muse-repo/dify.env` 与本仓 `muse-cloud/scripts/dev/p1r-external-acceptance.env``MUSE_AI_DIFY_*``MUSE_KNOWLEDGE_DIFY_*` 块。仓内 env 按项目内网约定用于验收,日志与文档只输出长度/hash 前缀。
- ⚠️ **安全**:上述令牌仅限当前内网验收上下文;本轮 agent 工具输出曾出现 New-API 与 Dify 明文 key,**用户需手工轮换 New-API token 与 Dify app/dataset keys**。
**Live 验收 harness(opt-in,默认跳过,`MUSE_P1R_EXTERNAL_ACCEPTANCE=true` 才真跑)**:
- `muse-module-ai/.../application/muse/facade/P1rNewApiLiveAcceptanceIT.java`(New-API)
- `muse-module-ai/.../application/muse/P1rImportLlmNewApiLiveAcceptanceIT.java`(New-API 完整导入 LLM 解析)
- `muse-module-knowledge/.../application/muse/facade/P1rRagFlowLiveAcceptanceIT.java`(RAGFlow)
- `muse-module-ai/.../application/muse/facade/P1rDifyChatLiveAcceptanceIT.java`(Dify chat app)
- `muse-module-knowledge/.../application/muse/facade/P1rDifyDatasetsContractLiveIT.java`(Dify Datasets create/upload/index/retrieve)
- 另:`muse-server/.../framework/api/P1rAiRuntimeEndToEndLiveAcceptanceIT.java``P1rKnowledgeRuntimeEndToEndLiveAcceptanceIT.java`
- 输出脱敏(只打 endpoint/模型/id/状态/耗时/usage/key 长度 + sha256 前 12);RAGFlow smoke 会留存 `p1r-live-<时间戳>` dataset 不删。
- 输出脱敏(只打 endpoint/模型/id/状态/耗时/usage/key 长度 + sha256 前 12);RAGFlow smoke 会留存 `p1r-live-<时间戳>` dataset 不删,Dify Datasets smoke 会留存 `dify-contract-<时间戳>` dataset 不删
- **runtime client live ≠ coverage completed**:仍需 Muse API 触发 + 落库 + 审计 + 失败路径证据(见 [golden-journey](../skills/golden-journey-vertical-slice.md))。
## 二、外部集成兼容坑(实证)
@ -31,6 +36,14 @@
- **RAGFlow 检索 metadata 字段名**:RAGFlow 官方契约是 `metadata_condition`。2026-06-28 已将 Muse HTTP 客户端从旧的 `metadata_filter` 修为 `metadata_condition`,并用请求体单测防回退。即便字段已修,跨可见性安全隔离仍不得依赖运行时 metadata 过滤,market KB 继续使用公开副本物理隔离。
- **GraphRAG attribution**:默认 fail-closed(`ATTRIBUTION_NOT_CONFIGURED`);真跑需 `MUSE_KNOWLEDGE_RAGFLOW_GRAPHRAG_ATTRIBUTION_READY=true` + `..._GRAPHRAG_DATASET_ID`
- **New-API/RAGFlow Java 客户端代理污染**:2026-06-28 盘点时,未清代理的 Java/Python 探针曾返回 502,但 `curl --noproxy '*'` 与清代理 live IT 均证明 New-API/RAGFlow 可用。运行时代码已在 New-API 文本生成、导入解析、RAGFlow HTTP 客户端上显式使用 direct `ProxySelector`,避免 macOS/环境代理把 tailnet 内网请求转到本地代理导致假故障。
- **Dify 官方 compose 钉版**:S1 使用 `langgenius/dify` 官方仓库 tag `1.15.0``docker/` 目录,远端源码备份在 `/home/qingse/dify-1.15.0`,compose project 固定为 `muse-dify`。控制台初始化后 `/console/api/setup` 返回 `step=finished`,API health 返回 `{"status":"ok","version":"1.15.0"}`
- **⚠️ Dify 实例被两个项目共用,workspace 必须隔离(2026-07-07 用户确认)**:同一 Dify 部署(`100.64.0.8:18080`)同时服务「游戏」项目与本项目「Muse」。Muse 的 app(写作透传/全书解析)、工作区级 Datasets API key、所有 dataset 必须落在 **Muse 专属 workspace**,禁止与游戏项目共用同一 workspace——工作区级 Datasets key 能列举/读写该 workspace 下全部 dataset,混用即跨项目数据泄漏 + 破坏安全边界。S1/S6/S7 落 `MUSE_AI_DIFY_*` / `MUSE_KNOWLEDGE_DIFY_*` 凭据前须确认其归属 workspace 是 Muse 专属;跨项目 dataset 隔离靠 workspace 边界,不靠 metadata 过滤。
- **Dify 存储类型坑**:官方 `.env` 默认 `STORAGE_TYPE=opendal` + `OPENDAL_FS_ROOT=storage` 在当前 compose 部署下会出现 API 上传成功、worker 索引时报 `File not found`。已改为 `STORAGE_TYPE=local` + `STORAGE_LOCAL_PATH=storage` 并重启 `api/api_websocket/worker/worker_beat/nginx`;回滚备份为 `mini-infra:/home/qingse/dify-1.15.0/docker/.env.s1-storage-before-local`
- **Dify 模型 provider**:通过 marketplace 插件 `langgenius/openai_api_compatible/openai_api_compatible` 连接 New-API。`MiniMax-M2.5` 作为 LLM,`Qwen/Qwen3-Embedding-8B` 作为 text-embedding,`Qwen/Qwen3-Reranker-8B` 作为 rerank;创建 high_quality dataset 前必须先配置 text-embedding,否则 dataset/create 会缺默认 embedding。
- **Dify Datasets key 类型**:工作区级 key 从 `/console/api/datasets/api-keys` 创建,前缀 `dataset-`,可调用 `/v1/datasets``/v1/datasets/{id}/document/create-by-file``/v1/datasets/{id}/retrieve`;单 dataset key 从 `/console/api/datasets/{id}/api-keys` 创建,前缀通常为 `ds-`,用于限定某个 dataset。S1 执行计划要求的是工作区级 Datasets API key。app key 前缀 `app-`,只能打 app/workflow API,不能打 Datasets。
- **Dify Dataset 拓扑口径(2026-07-08)**:工作区级 Datasets API key 是管理钥匙,不是生产单 dataset 设计。生产至少分全局公共 dataset 与作品独立 dataset,Muse 本地保存 datasetId 映射,检索时按授权选择公共 + 当前作品 dataset 循环检索合并;禁止把公共和作品私有资料混在一个 dataset 后仅靠 metadata 当安全边界。S1 的 `dify-contract-*` dataset 只留作 contract evidence。
- **Dify Datasets retrieve 契约**:Dify 1.15 的 `/v1/datasets/{id}/retrieve``retrieval_model` 中必填 `search_method``reranking_enable`;只传 `top_k/score_threshold_enabled` 会 400。S1 live IT 固定 `search_method=semantic_search``reranking_enable=false`
- **Dify 控制台 API 坑**:`INIT_PASSWORD` 最大长度 30;控制台登录 password 字段实际传 base64(password),不是 RSA;控制台 API 写操作需 cookie + `X-CSRF-Token`,不要拿 app/dataset key 调控制台端点。
## 三、前端 / 构建坑速查
- **studio(Vite + React + TS6.0)**:`erasableSyntaxOnly` 禁用 constructor 参数属性;`exactOptionalPropertyTypes` 下可选属性需规避写法;采纳/Diff 用 **Coarse-to-Fine 分级 Diff** 规避 3000+ 字正文上 O(N×M) LCS 性能爆炸;MSW 仅 `import.meta.env.DEV` 启用。
@ -164,3 +177,12 @@ bash muse-cloud/scripts/verify-openapi-diff.sh # → 5 场景全绿即门禁
- **坑4:跨文件 `$ref` 靠目录结构**:events 契约 `$ref: '../openapi-base.yaml'`,故 base/head 必须各自保留 `docs/api-contracts/` 完整目录(workflow 用 `git worktree` 检出整树、脚本用 `cp -R` 整目录),单独拷 `openapi.yaml` 会解析失败。
- 实证(oasdiff v1.19.1,全部 7 域真实契约,5/5):无变更/新增可选参数/新增端点→**放行**(rc=0);参数变必填(`request-parameter-became-required`)/删端点(`api-path-removed-without-deprecation`)→**拦截**(rc=1)。
- **残留(脚本覆盖不到)**:远端 Gitea Actions 触发管道(`pull_request` paths 触发、`base.sha` worktree、docker 镜像拉取)须一次真实 PR(改 `docs/api-contracts/**`)首跑确认。未首跑前不得宣称"CI 已实拦破坏性变更",只能称"检测逻辑+循环逻辑已本地实证、root workflow 已接线"。
## 八、2.0.0 单人版(1a)S9 部署收口坑(2026-07-08 实证)
单人版 solo 栈从零部署 + 全程真后端真库真 Dify 黄金旅程活体验收(review v0.2 §9 判据 07 全留证,commit `7a6e46b2`+`1c2d51bf`)暴露四坑:
- **坑1:solo compose 必须挂 `sql/muse` 给 muse-server**。muse 业务表的 36 个 Flyway 迁移在 `sql/muse/`、**未打进 jar**(`application.yaml``flyway.locations``filesystem:sql/muse`、相对容器 WORKDIR `/muse-server`);`docker-compose.solo.yml``- ./sql/muse:/muse-server/sql/muse:ro`,漏挂则 Flyway "No migrations found"、muse 表全缺、AI worker 每秒 `relation "muse_ai_job" does not exist`。yudao 基座走 PG initdb(`sql/dev/yudao-base-*`)。mini-infra 从零真验才抓得到(桩掩盖)。部署 SSOT=`docs/mvp/2.0.0-单人版部署手册.md`
- **坑2:覆盖台账 `docs/superpowers/reports/p1r-api-coverage.json` 是 SoT、别重生成**。它含 32 个 market operation 的 `dormant` 人工口径(2.0.0 单人版 market 摘装配),但生成器 `p1r-audit-api-coverage.py` 只产 completed/needs_verification、**不产 dormant**;跑生成器会把 dormant 冲回 completed(210→242)摧毁口径、`P1rApiCoverageReportTest` 反而红。改台账须**手改 JSON**(外科手术式、往返序列化保最小 diff),别重生成。长期收敛(生成器支持 dormant 或明确 JSON 为 SoT)是开放债。
- **坑3:给 service 加 `@Resource` 依赖后必须真跑跨模块 IT**。S8 给 `MuseAiImportParseService``@Resource MuseAiProperties`,muse-server 极简上下文 IT(`P1rContentImportWizardCompletedApprovalIT``ImportWizardConfiguration`)缺该 bean → ApplicationContext 加载失败;**只编译修复不够**(S8 当轮只编译修没真跑、漏了运行时),真跑 IT 才暴露,修=IT 上下文补对应 @Bean。教训:改 service 依赖图后 per-task 只测本模块会漏,收口须整分支跑跨模块 real-PG IT(跑法见 §四)。
- **坑4:全新作品跑 AI 需库内 authz 种子**。当前 P1R-4 切片不接 Security 主流程创建 AI 运行时授权,唯一成功路径是预置 `muse_tool_grant` 投影(与 work1 grant id=1 同口径)。对全新作品跑 AI 黄金旅程须先按 grant id=1 模板 INSERT 一行 `muse_tool_grant`(scope 指向目标 work),否则 AI 生成秒失败于授权缺失。既有架构状态、非 Dify 迁移引入。

View File

@ -53,4 +53,5 @@
- 新增业务域:在 `docs/api-contracts/<域>/openapi.yaml` 建契约,并把 `<域>` 加入 `ContractFirstGateTest.REQUIRED_API_DOMAINS`(机械要求该域契约长存)。
- DB 结构演进:新增 `V<下一版本>__<描述>.sql`,切勿改历史迁移;门禁自动校验连续与唯一。
- **有意跳号登记(V35)**:`muse-cloud/sql/muse/` 现有 V34、V36、V37 而无 V35,是并行开发线 S6/S7/S8 之间预留版本号造成的跳号(V34=import-parse、V36=瞬态 fulltext、V37=knowledge runtime_batch_id)。V36/V37 已应用到 live 库 `muse_slice_live`,按“迁移只增不改”回填 V35 会破坏 Flyway 校验和、损坏已部署库,故不回填。V35 已在 `ContractFirstGateTest.INTENTIONALLY_SKIPPED_VERSIONS` 登记为有意跳号,连续性门禁豁免它;日后若确有该结构变更需求,新建更高版本号迁移承载,不占用 V35。
- 需要“契约↔实现不漂移”的更强门禁(OpenAPI operationId 与控制器/服务交叉核对)时,与 P0 覆盖台账(`P1rApiCoverageReportTest`)合并设计,避免重复台账。

View File

@ -2,6 +2,7 @@
> **类型**:元流程(workflows) · **简版规约**:[`../../AGENTS.md`](../../AGENTS.md) §5 工作协议(本文件是其操作化展开)。
> **何时用**:承接**任何** oh-my-muse 任务时先走本流程分流。核心立场:**机械门禁优先、完成=验证、反假绿**。
> **版本**:v1 · **更新日期**:2026-07-20 · **变更记录**:从未编号版本升级为 v1新增创作智能体反序验证门禁与正文卡索引/原文回读双线。
---
@ -32,6 +33,14 @@
- 全简体中文注释;外部交互/核心实现/错误路径留可追溯日志。
- 契约先行(见 [`../rules/contract-first.md`](../rules/contract-first.md));守 BC 边界(见 [`../rules/bc-boundaries.md`](../rules/bc-boundaries.md))。
### 创作智能体反序验证门禁
- **能力验证顺序固定**:清洗/抽卡/范式 → 正文 Gate B → 细纲 → 大纲+设定。正文 Gate B 未由唯一判定器输出 `passed` 前,不得启动细纲智能体真实能力验收;单章、单作品、Gate A 或人工观感都不能替代 Gate B。
- **正式创作数据流不倒置**:产品运行仍是大纲+设定 → 细纲 → 正文。反序只用于能力隔离与归因,不能让实验评测产物反写正式设定、Canonical 状态、细纲或正文。
- **正文双线固定**:抽取卡只作索引,命中后必须按来源引用回读冻结线内原文。卡线负责定位事实与历史场景,原文线负责人物声音、动作习惯、能力表现和叙事质感;无来源卡不得单独支撑正文硬事实。
- **独立权威事实**:作者确认的正式设定、冻结点可见的 Canonical 状态、已确认细纲声明的新事实直接引用各自不可变版本,不要求伪造抽取卡或历史原文来源。
- **阶段边界**:正文实验阶段不改产品 API、Flyway、业务数据库或 Canonical 主链。只有正文 Gate B=`passed` 后,才另立产品化计划和后续细纲验收计划。
## 五、验证(证据门 —— 不可跳过)
- **完成 = 机械验证**;无自动化绿证据**不得**声称“完成/修复/通过”(见 [`../rules/verification-and-anti-false-green.md`](../rules/verification-and-anti-false-green.md))。
- 用户可见功能按 [`../skills/golden-journey-vertical-slice.md`](../skills/golden-journey-vertical-slice.md) 的**三指标**报告(代码 / 自动化验证 / 端到端可用),不给单一百分比。

View File

@ -1,7 +1,7 @@
# New-Design新设计V2 文档大纲(入口)
- 版本v9
- 更新日期2026-06-29
- 版本v10
- 更新日期2026-07-17
- 目标读者:产品 / 架构 / 前端 / 后端 / 文档维护者
- 阅读时间1020 分钟
- 边界说明:本文件只负责导航、边界和归属,不重复解释概念;概念解释必须落在对应主文档中。
@ -99,7 +99,11 @@
- [专题-01-正文建议接受(Accept Suggestion)实现规范](专题-01-正文建议接受(Accept%20Suggestion)实现规范.md)
- [专题-02-Sudowrite对标与Muse产品取舍](专题-02-Sudowrite对标与Muse产品取舍.md)
- [专题-03-AI编排上下文与质量评测实现规范](专题-03-AI编排上下文与质量评测实现规范.md)
- [专题-04-生成质量门控与创作健康度设计方案](专题-04-生成质量门控与创作健康度设计方案.md)
- [专题-05-AI统一交互协议与外部AgentAdapter设计](专题-05-AI统一交互协议与外部AgentAdapter设计.md)
- [专题-06-元数据驱动的智能体架构](专题-06-元数据驱动的智能体架构.md)——收束「agent = f(作品 + 元数据 + 知识库)」横切架构元引擎与功能链双枢、三体关系、target type 23 型结构本体、统一创作数据读取器、base 内置机制与拆书通用抽取。
- [专题-07-知识消费契约与质量闭环](专题-07-知识消费契约与质量闭环.md)——收束「知识效用」横切主线:消费选择契约(按用途默认合同与注入视图)、知识质量三性(可命中/可行动/可持续)、回放评测(参考书=标准答案)、长线进度消费语义(演变历程三期消费)。
- [专题-08-自动化测试方案](专题-08-自动化测试方案.md)——收束「测试可判定性」横切主线:四层测试金字塔、确定性/语义分界判据、语义评测方法论(双盲评委/对照实验 Gate B 范式/回放评测)、测试可判定性合同、已拍决策的测试合同。不重定义状态机/Schema/API/质量维度,只引用各 owner。
说明:专题文档只负责跨文档收束,不抢走 Schema、状态机和统一 API 的单一归属。
@ -147,6 +151,8 @@
- 产品旅程与长期闭环:`产品-03-用户旅程与操作流程.md`
- 系统处理流程:`流程-02A-管理员系统处理流程(系统视角).md` / `流程-02B-普通用户系统处理流程(系统视角).md`
- AI 编排、检索上下文和质量评测跨文档合同:`专题-03-AI编排上下文与质量评测实现规范.md`
- 知识消费选择契约、知识质量三性、回放评测、长线进度消费语义:`专题-07-知识消费契约与质量闭环.md`
- 测试金字塔分层、确定性/语义分界判据、语义评测方法论、测试可判定性合同:`专题-08-自动化测试方案.md`
- AI 外部运行时统一协议与 Adapter`专题-05-AI统一交互协议与外部AgentAdapter设计.md`
- 统一数据库表结构:`后端-04-统一数据库Schema-v1.md`
- 统一接口契约:`后端-05-统一API契约-v1.md`

View File

@ -0,0 +1,179 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>专题-07 · 知识消费契约与质量闭环 · 方案总览</title>
<style>
body{font-family:"PingFang SC","Microsoft YaHei",sans-serif;margin:0;background:#f5f6f8;color:#1c2733;line-height:1.65}
.wrap{max-width:1120px;margin:0 auto;padding:22px 20px 40px}
h1{font-size:24px;margin:6px 0 2px}
.sub{color:#5b6b7b;font-size:13px;margin-bottom:14px}
.claim{background:#fff8e6;border-left:6px solid #e6a817;padding:12px 16px;border-radius:8px;font-size:15.5px;margin:10px 0 16px}
.claim b{color:#8a5a00}
.tabs{display:flex;gap:6px;border-bottom:2px solid #d8dfe8;margin-bottom:16px;flex-wrap:wrap}
.tab{padding:9px 18px;border-radius:8px 8px 0 0;cursor:pointer;font-size:14.5px;background:#e9edf2;color:#44536a;border:1px solid #d8dfe8;border-bottom:none;user-select:none}
.tab.on{background:#fff;color:#0b57d0;font-weight:600;position:relative;top:2px;border-bottom:2px solid #fff}
.panel{display:none}
.panel.on{display:block}
h2{font-size:17px;color:#0b57d0;margin:18px 0 8px}
h3{font-size:14.5px;margin:14px 0 6px}
p,li{font-size:13.5px;margin:5px 0}
table{border-collapse:collapse;width:100%;font-size:13px;margin:8px 0}
th,td{border:1px solid #dde3ea;padding:6px 10px;text-align:left;vertical-align:top}
th{background:#eef2f7}
.card{background:#fff;border-radius:10px;padding:14px 18px;box-shadow:0 1px 3px rgba(0,0,0,.06);margin:10px 0}
.lane{display:flex;align-items:stretch;gap:8px;flex-wrap:wrap;margin:8px 0}
.step{flex:1;min-width:118px;border-radius:8px;padding:9px 10px;font-size:12.5px;text-align:center}
.done{background:#e8f2ec;border:1.5px solid #2e7d51}
.new{background:#e8f0fd;border:1.5px solid #0b57d0}
.step b{display:block;font-size:13px;margin-bottom:2px}
.arrow{align-self:center;color:#7a8794;font-size:16px}
.legend{font-size:12px;color:#5b6b7b;margin-top:6px}
.legend span{display:inline-block;width:11px;height:11px;border-radius:3px;vertical-align:-1px;margin:0 4px 0 10px}
.warn{font-size:13px;color:#a33;background:#fdf3f3;border-radius:6px;padding:9px 12px;margin:8px 0}
.pin{background:#f0f6f1;border-left:4px solid #2e7d51;padding:8px 12px;border-radius:6px;font-size:13.5px;margin:8px 0}
code{background:#eef1f5;border-radius:4px;padding:0 4px;font-size:12.5px}
.src{color:#8291a0;font-size:12px;margin-top:16px}
</style>
</head>
<body><div class="wrap">
<h1>知识消费契约与质量闭环 · 方案总览</h1>
<div class="sub">SoT 正文design-docs/专题-07-知识消费契约与质量闭环.mdv1· 配套修订:专题-03 v3 / 专题-04 v2 / 专题-06 v4 / 架构-02 v11 / 后端-05 v11 / 前端-03 v7 / 大纲 v10 / 映射表 v8 · 2026-07-17 · 经 codex 与 opus 双独立评审</div>
<div class="claim"><b>核心主张:</b>知识的价值只在被选中并改善产出(写作、检测、规划三个效用时刻)的那一刻兑现。此前 SoT 钉死了「治理」(谁能读什么:授权/来源/双轨/裁剪/预算),本次补上「效用」(该读哪几条、注入什么、有没有用、知识本身好不好)——<b>以用定卡:消费端定义质量,参考书当标准答案。</b></div>
<div class="tabs">
<div class="tab on" data-p="p1">① 总览与缺环</div>
<div class="tab" data-p="p2">② 消费选择契约</div>
<div class="tab" data-p="p3">③ 质量三性与回放评测</div>
<div class="tab" data-p="p4">④ 长线进度与本体拍板</div>
</div>
<div class="panel on" id="p1">
<div class="card">
<h2>知识的一生:左三格原有,右三格本次立约</h2>
<div class="lane">
<div class="step done"><b>拆书抽取</b>23 型结构模具</div>
<div class="arrow"></div>
<div class="step done"><b>双轨确认</b>草稿→正式,先审后入</div>
<div class="arrow"></div>
<div class="step done"><b>授权治理</b>版权/来源/字段裁剪</div>
<div class="arrow"></div>
<div class="step new"><b>选择契约</b>按用途定"该读哪几条"</div>
<div class="arrow"></div>
<div class="step new"><b>注入视图</b>摘要/全文两档,有尺寸上限</div>
<div class="arrow"></div>
<div class="step new"><b>回放评测</b>对照原书评分,反哺卡去留</div>
</div>
<div class="legend"><span style="background:#e8f2ec;border:1.5px solid #2e7d51"></span>既有(治理侧)<span style="background:#e8f0fd;border:1.5px solid #0b57d0"></span>本次立约效用侧owner=专题-07</div>
<div class="warn"><b>为什么必须补:</b>一致性门控canon_compliance拿本作知识库当标准拦候选——知识错了门控会把写对的候选拦下来。输入端质量是输出端门控成立的前提不是锦上添花。</div>
</div>
<div class="card">
<h2>术语桥(「卡」在系统里的权威身份)</h2>
<table>
<tr><th>口语</th><th>SoT 权威载体</th></tr>
<tr><td>待确认的卡</td><td>Knowledge Draft待审层默认不可信</td></tr>
<tr><td>本作正式事实卡</td><td>Local Knowledge Entity / Relation局域知识库</td></tr>
<tr><td>公共参考卡(范式卡)</td><td>Global KB 范式 Canonical技法五型craft / combat / emotion / scene_pattern / trope</td></tr>
<tr><td>卡的结构模具</td><td>MetaSchema target_type23 型)</td></tr>
</table>
<p>三个效用时刻:<b>写作时注入</b>(当前态 + 已决策范式)· <b>检测时对照</b>(本作知识当标准)· <b>规划时排线</b>(演变历程 + 参照成长曲线)。</p>
</div>
</div>
<div class="panel" id="p2">
<div class="card">
<h2>按用途的默认合同purpose 四值逐一钉死)</h2>
<table>
<tr><th>用途</th><th>默认注入</th><th>明确不注入</th></tr>
<tr><td><b>generation</b> 续写/场景写作</td><td>在场实体(当前章出场+一跳关系)的当前态<b>摘要视图</b>;规划期已决策引用的公共范式(上限 3 条,全文视图);文风/节奏画像</td><td>范式临场海选;演变历程全线</td></tr>
<tr><td><b>planning</b> 规划/排线</td><td>目标线程实体的演变历程全线大纲与作品核心Global 成长曲线范式(型×品类结构键召回,相似度只作兜底)</td><td></td></tr>
<tr><td><b>detection</b> 一致性检测</td><td>在场实体当前态全量 + 关系闭包 + 事件时间线</td><td>公共范式(检测不需要「怎么写」)</td></tr>
<tr><td><b>extraction</b> 抽取/维护</td><td>该型模具全字段视图</td><td></td></tr>
</table>
<div class="pin">范式进入生成上下文的唯一正路:<b>规划期决策、写作期引用</b>。规划产出必须携带范式引用链,写作按引用注入、不重新检索。写作期对范式做临场相似度海选 = 违规(实测依据:同型范式向量相似 74% 多为词汇假近,语义真并率仅约 10%——相似度选不出对的范式)。</div>
</div>
<div class="card">
<h2>注入视图与排序(视图 ≠ 存储)</h2>
<ul>
<li><b>两档视图</b>:摘要视图(名称+一句话+当前态要点)为默认档;焦点实体升全文视图(按 aiContext 用途裁剪后的字段全量)。各型尺寸上限随字段合同登记,超限不注入。</li>
<li><b>为什么必须独立成合同</b>:库内实测两端病并存——一半卡不足 400 字符(无肉可注),最大单卡 104KB一张即撑爆预算</li>
<li><b>排序信号</b> = 结构匹配度(在场>关系一跳>相似兜底;范式取型×场景意图×品类精确匹配优先)+ 效用统计(被命中率/被接受率/跨书频次)。预算内截断落 omittedSources截断规则归专题-03</li>
<li><b>来源优先级全链</b>本作事实Local 用户绑定知识User/已安装)> 全局与市场参考Global/市场)。范式只提供「怎么写」,不得覆盖「是什么」。</li>
</ul>
</div>
</div>
<div class="panel" id="p3">
<div class="card">
<h2>知识质量三性(输入侧检尺,与输出侧维度分立、单向咬合)</h2>
<table>
<tr><th></th><th>定义</th><th>可测指标(口径)</th></tr>
<tr><td><b>可命中</b></td><td>该被想起时能被检索到:命名鲁棒(别名表)+ 向量覆盖 + 结构键齐全</td><td>别名覆盖率 · 向量覆盖率 · 同实体分裂率(抽样审计同一实体被建成多条卡的比例)</td></tr>
<tr><td><b>可行动</b></td><td>注入后能直接改善产出,不是分析散文</td><td>注入视图尺寸达标率 · 字段充实度(对照字段合同非空率)</td></tr>
<tr><td><b>可持续</b></td><td>增量维护不腐坏:同名异质不并、真同才并;演变归并有序;溯源完备</td><td>重复率 · 演变断线率 · 溯源完整率</td></tr>
</table>
<p><b>裁决权在消费端</b>p50 实例数=1 的「范式」只是观察不是范式,跨书频次与消费端命中/接受统计才是去留的最终裁决。阈值由回放评测首轮基线确定随后按质量策略生命周期draft→evaluating→active治理。</p>
</div>
<div class="card">
<h2>回放评测(参考书 = 标准答案)</h2>
<p>冻结第 N 章时点的知识状态 → 按契约组装上下文 → 跑规划/续写/检测 → 与原书第 N+1 章对照评分。</p>
<table>
<tr><th>评测线</th><th>怎么跑</th><th>评什么</th></tr>
<tr><td>规划线</td><td>预测下一台阶/伏笔回收,对照原书走向</td><td>台阶预测命中、伏笔回收命中</td></tr>
<tr><td>生成线</td><td>有卡 vs 无卡 vs 打乱卡 对照生成</td><td>设定一致性、要素覆盖</td></tr>
<tr><td>检测线</td><td>人工向候选注入错误</td><td>错误检出率</td></tr>
</table>
<div class="pin"><b>门禁</b>:知识策略(选择契约参数、注入视图定义、判重规则)是与 Quality Policy 同栖 AI 编排策略面的正式策略对象,版本化管理;<b>变更未经回放对照评测不得上线、不得声称改善质量</b>evaluating→active评测未过就没有 active</div>
<div class="warn"><b>合规两道闸</b>授权状态闸reference_work 四值unauthorized 失败关闭)+ 评测用途闸(授权快照 allowedPurpose 须含离线评测research_only 只限内部、禁外发)。评测产物只存评分/摘要/定位,不留存原书全文。</div>
</div>
</div>
<div class="panel" id="p4">
<div class="card">
<h2>长线进度消费语义(演变历程三期消费)</h2>
<p>长篇(尤其升级流)的骨架是实体演变。世界域实体六型共享<b>演变历程</b>元素:{章, 台阶, 周期} 结构化里程碑。</p>
<table>
<tr><th>消费期</th><th>用什么</th><th>干什么</th></tr>
<tr><td>规划期</td><td>本作历程全线 + 参照曲线(跨书台阶间距/周期分布)</td><td>排下一台阶的时机与幅度</td></tr>
<tr><td>检测期</td><td>当前态对照</td><td>新候选不得与当前台阶矛盾</td></tr>
<tr><td>写作期</td><td>只给当前态摘要</td><td>全历程默认不进上下文(防上万字明细撑爆预算)</td></tr>
</table>
</div>
<div class="card">
<h2>本体侧两项拍板(落在专题-06 v4</h2>
<div class="pin"><b>参照作品面</b>:参考书的实体演变卡不进 Global KB、不可被作品绑定、不是第四类知识库——它是挂在 reference_work 档案下的系统侧证据资产,用途只有两个:回放评测的标准答案底座、叙事域范式的蒸馏源。进 Global KB 的只能是蒸馏后的「成长曲线范式」trope/pacing 形态,只存定位摘要不存原文)。<b>世界域形态是证据,叙事域形态才是范式。</b></div>
<div class="pin"><b>双层型判定闭合</b>判据不变三样本实测无损则不拆craft 已以 1326 条公共范式实测通过拍板不拆character/style/pacing 依同一判据在各自公共面首批落库时判定。</div>
</div>
<div class="card">
<h2>本次 SoT 修订清单</h2>
<table>
<tr><th>文档</th><th>版本</th><th>改了什么</th></tr>
<tr><td>专题-07新增</td><td>v1</td><td>知识效用主线唯一 owner术语桥 / 消费选择契约 / 质量三性 / 回放评测 / 长线消费语义 / 实验台实证附录 / 验收清单</td></tr>
<tr><td>专题-06</td><td>v3→v4</td><td>拍板双层型判定craft 不拆);新增 §6.4 参照作品面;世界域六型登记演变历程元素;读取器 purpose 枚举 parse→extraction挂专题-07</td></tr>
<tr><td>专题-03</td><td>v2→v3</td><td>§4.2 登记「该选哪几条」的选择契约归属专题-07</td></tr>
<tr><td>专题-04</td><td>v1→v2</td><td>§10 离线评估输入补「知识策略版本」;物理文件名补 .md并修四册断链</td></tr>
<tr><td>架构-02</td><td>v10→v11</td><td>aiContext 值域升级布尔或用途集true=全用途 / false=不入 / 用途子集;布尔为退化情形)——实验台字段级用途裁剪实证反哺术语权威</td></tr>
<tr><td>后端-05 / 前端-03</td><td>v11 / v7</td><td>aiContext 值域表述随术语权威对齐</td></tr>
<tr><td>00-大纲 / 内容映射表</td><td>v10 / v8</td><td>登记专题-07 与其 owns 概念;补齐专题-04/05/06 漏登</td></tr>
</table>
</div>
</div>
<div class="src">依据:专题-03/04/06 与架构-02 全文核读 + design-docs 全库横切取证 + 实验台数据库只读实测8 本参考书 ≈1.18 万章、活卡 8587 张、范式卡向量覆盖 100%、升格卡 0%、p50 实例数 1、卡体 p50 395 字符 / 最大 104KB。全部数字可回溯。</div>
</div>
<script>
document.querySelectorAll('.tab').forEach(t=>t.addEventListener('click',()=>{
document.querySelectorAll('.tab').forEach(x=>x.classList.remove('on'));
document.querySelectorAll('.panel').forEach(x=>x.classList.remove('on'));
t.classList.add('on');document.getElementById(t.dataset.p).classList.add('on');
}));
</script>
</body>
</html>

View File

@ -1,10 +1,11 @@
# 专题-01正文建议接受Accept Suggestion实现规范
- 版本v5
- 更新日期2026-05-23
- 版本v6
- 更新日期2026-07-20
- 目标读者:产品 / 架构 / 前端 / 后端 / 测试
- 阅读时间2035 分钟
- 边界说明:本文档只收束“接受建议”这条跨文档主链路:用户怎么把 AI 候选写入正文、关联知识草稿怎么保留或失效、正文来源归因怎么落点、事务边界怎么切、前端怎么反馈。精确 Schema、状态机和统一错误模型由后续后端阶段承接当前阶段以 `架构-02``架构-04` 为准。
- 变更记录v62026-07-20补齐编辑后新 candidateVersion、重新 detector、`accept_preflight` 与 CAS 接受边界;实验阶段不改 API/DB。v52026-05-23收束接受建议、知识草稿、来源归因和事务边界。
## 1. 目标与范围
@ -42,14 +43,14 @@ Accept Suggestion 是 AI 候选从待审层(Shadow)进入正文规范数据(Cano
| 路径 | 正文结果 | 知识草稿结果 | 历史结果 |
|---|---|---|---|
| 原样接受 | Suggestion 内容进入目标 Block | 与当前 Suggestion 绑定的草稿保持待确认;不得自动入 Local KB | Suggestion 归档为 accepted |
| 修改后合并 | `contentOverride` 进入目标 Block | 旧草稿立即失效AFTER_COMMIT 重新提取新的草稿 | Suggestion 归档为 accepted并保留 `final_content` 快照(如需要) |
| 修改后合并 | 用户编辑先生成递增的 candidateVersion重新通过 detector 和 `accept_preflight` 后,该版本正文进入目标 Block | 旧版本草稿在新版本被接受时失效AFTER_COMMIT 重新提取新的草稿 | 最终 candidateVersion 归档为 accepted旧版本保留审计链且不可接受 |
| 拒绝 | 正文不变 | 关联草稿一起丢弃 | Suggestion 归档为 rejected |
### 2.2 为什么“修改后合并”不是新状态
- 用户决策仍然是“接受这条建议,只是我改了最终入正文的文本”。
- 它不应该发明新的 Active 状态,也不应该生成第二套历史状态机。
- 归档层统一记为 `accepted`,但必须保留“这是 modified merge”的审计语义。
- 编辑动作只生成新的待审 candidateVersion不直接写正文归档层最终统一记为 `accepted`,但必须保留“这是 modified merge”的版本链与审计语义。
### 2.3 Stale Draft 规则
@ -90,7 +91,7 @@ Accept Suggestion 是 AI 候选从待审层(Shadow)进入正文规范数据(Cano
- Accept 主事务内不得发起外部 AI / 提取 / 校验调用。
- 原样接受时正文写入、候选归档、正文来源归因、关联草稿状态保留、change log、outbox 写入必须放在同一事务里完成。
- 修改后合并时正文写入、Suggestion 归档、旧草稿失效与审计仍在主事务;重新提取只能走 AFTER_COMMIT 异步链路。
- 修改后合并时,只有新 candidateVersion 重新通过 detector 和 `accept_preflight` 后,正文写入、Suggestion 归档、旧草稿失效与审计才进入主事务;重新提取只能走 AFTER_COMMIT 异步链路。
### 4.4 历史与投影
@ -112,8 +113,8 @@ Accept 命令必须携带以下语义:
| actor / work / targetBlock | 当前用户、作品和目标 Block |
| suggestion | 仍处于 Active / Shadow 的候选 |
| expectedRevision | 用户决策基于的目标 Block revision必填 |
| acceptMode | `accept_as_is``merge_after_edit` |
| finalContent | 仅 `merge_after_edit` 需要,表示用户确认写入正文的最终内容 |
| acceptMode | `accept_as_is``merge_after_edit`;后者只能引用已经重新检测通过的编辑版本 |
| candidateVersion / candidateSha256 | 本次实际接受的不可变候选版本及正文哈希;编辑后必须递增版本并重新计算哈希 |
| decisionContext | UI 决策来源、候选版本、质量结果版本和必要审计摘要 |
| acceptPreconditionContext | 接受前置校验上下文必须覆盖输出合规、静态检查、质量结果版本、来源状态、来源事件影响、Action Policy、授权快照、作品资产 feature gate、`expectedRevision` 和幂等结果;系统在接受时实时校验,不封装为独立对象 |
@ -121,8 +122,8 @@ Accept 命令必须携带以下语义:
| 语义 | 原样接受 | 修改后合并 |
|---|---|---|
| blockOutcome | 目标 Block 写入成功revision 递增 | 目标 Block 写入用户最终内容revision 递增 |
| suggestionOutcome | Suggestion 离开 Active进入 Archivedisposition=accepted | Suggestion 离开 Active进入 Archivedisposition=accepted并记录 modified merge 摘要 |
| blockOutcome | 目标 Block 写入成功revision 递增 | 目标 Block 写入已重新检测通过的编辑版本正文revision 递增 |
| suggestionOutcome | Suggestion 离开 Active进入 Archivedisposition=accepted | 最终 candidateVersion 离开 Active进入 Archivedisposition=accepted旧版本保留 modified merge 版本链且不可接受 |
| knowledgeDraftOutcome | 关联草稿保持待确认,仍需单独进入知识确认入口 | 基于旧候选文本的草稿失效,不允许继续确认 |
| followupTask | 可触发投影刷新,不要求即时返回草稿数量 | AFTER_COMMIT 启动重新提取或投影刷新任务 |
@ -152,11 +153,25 @@ Accept 在任何写正文动作前必须完成前置校验。前置校验失败
服务端必须在接受时实时校验候选来源版本和授权状态;如果校验缺失、过期、质量结果版本不匹配、`expectedRevision` 不匹配、授权快照变化、来源状态变化、作品资产 feature gate 变化或幂等结果不可复用,必须在写正文前重算。重算结果不是提示文案,而是写 Canonical 的硬闸门。
### 6.1 candidateVersion、detector 与 `accept_preflight`
用户编辑候选时不得把 `contentOverride` 直接送进 Accept 主事务。编辑动作必须:
1. 基于当前候选生成严格递增的新 candidateVersion 和 candidateSha256旧版本立即失去接受资格但保留审计。
2. 重新运行 detector报告必须绑定新 candidateVersion、candidateSha256、contextSnapshotSha256 和 qualityPolicyVersion旧报告不得复用。
3. detector 绿证据成立后才进入 `accept_preflight`;编辑内容未重新检测、检测超时、报告版本不符或仍有高严重度问题时失败关闭。
4. `accept_preflight` 实时校验 `mode=production``acceptanceEligible=true`、候选未过期、上下文快照和来源未失效、授权仍有效、detector 报告精确绑定当前候选、`expectedRevision` 一致及幂等结果可复用。
5. 诊断、评测和回放候选固定 `acceptanceEligible=false`,即使正文相同或 detector 通过也不能进入 Canonical。
接受写入采用 compare-and-setCAS只有服务端当前记录仍匹配 `runId + attempt + candidateVersion + candidateSha256 + currentState + expectedRevision` 时,才允许原子完成正文 revision 递增和候选终态迁移。旧 attempt、旧 candidateVersion、迟到 detector 结果、重复状态事件或 revision 已变化时返回冲突或既有幂等结果,不能覆盖新版本或 Canonical。具体生命周期只在 [架构-04 §5](架构-04-状态机与约束清单.md) 定义,本节拥有接受命令的前置与原子写边界。
| 校验 | 失败结果 |
|---|---|
| actor 对作品、Block、Suggestion 有操作权限 | `PERMISSION_DENIED`,不写正文 |
| Suggestion 仍处于 Active / Shadow未过期、未失效 | `SUGGESTION_NOT_ACCEPTABLE`,不写正文 |
| expectedRevision 匹配目标 Block 当前 revision | `REVISION_CONFLICT`,进入显式冲突处理 |
| mode=production 且 acceptanceEligible=true | `CANDIDATE_NOT_ACCEPTANCE_ELIGIBLE`,不写正文 |
| detector 绿报告精确绑定 candidateVersion、candidateSha256、contextSnapshotSha256 和策略版本 | `QUALITY_EVIDENCE_STALE``DETECTOR_NOT_PASSED`,不写正文 |
| 接受前置校验通过(实时校验来源版本和授权状态),且覆盖输出合规、静态检查、质量结果版本、来源状态、授权快照、作品资产 feature gate、expectedRevision 和幂等结果 | 按校验结果阻断、需重验或要求显式确认 |
| 输出合规、语义安全围栏和静态检查通过 | `OUTPUT_COMPLIANCE_BLOCKED``STATIC_CHECK_FAILED`,不写正文 |
| 候选正文 lineage 中所有来源有有效 Authorization Snapshot | `SOURCE_AUTH_INVALID`,候选 invalidated / blocked |
@ -173,8 +188,8 @@ Accept 在任何写正文动作前必须完成前置校验。前置校验失败
1. 加载 Active Suggestion 并校验归属、状态与过期时间。
2. 加载目标 Block并用 `expectedRevision` 做并发保护。
3. 实时校验候选来源版本和授权状态(校验清单:候选来源、授权快照、市场作品资产 feature gate、质量门控、输出合规、静态检查、质量结果版本、幂等结果和当前 Block revision
4. 更新 Block 内容与 revision。
3. 执行 `accept_preflight`实时校验接受资格、candidateVersion/hash、detector 绿报告、上下文快照、候选来源版本和授权状态(校验清单:候选来源、授权快照、市场作品资产 feature gate、质量门控、输出合规、静态检查、质量结果版本、幂等结果和当前 Block revision
4. 以 candidateVersion、candidateSha256、当前候选状态和 `expectedRevision` 做 CAS更新 Block 内容与 revision。
5. 如果候选包含 AI、市场、外部知识或授权知识来源写 Block Source Attribution。
6. 将 Suggestion 迁入候选归档(disposition=accepted)。
7. 保持关联知识草稿为待确认;仅当问题来源没有参与正文 lineage 时,才允许把草稿标记为不可确认或需重验。
@ -185,14 +200,14 @@ Accept 在任何写正文动作前必须完成前置校验。前置校验失败
### 7.2 修改后合并事务
单个事务内完成:
编辑动作先在事务外形成新的待审 candidateVersion 并重新运行 detector只有 detector 绿且 `accept_preflight` 通过后,单个接受事务才完成:
1. 加载 Active Suggestion 并校验归属、状态与过期时间。
1. 加载最终 Active candidateVersion并校验归属、状态、候选哈希、版本链与过期时间。
2. 加载目标 Block并用 `expectedRevision` 做并发保护。
3. 实时校验候选来源版本和授权状态(校验清单:候选来源、授权快照、市场作品资产 feature gate、质量门控、输出合规、静态检查、质量结果版本、幂等结果和最终正文内容
4. `contentOverride` 更新 Block 内容与 revision
3. 执行 `accept_preflight`,确认 detector 绿报告精确绑定最终 candidateVersion/candidateSha256/contextSnapshotSha256并实时校验来源版本和授权状态
4. 以 candidateVersion、candidateSha256、当前候选状态和 `expectedRevision` 做 CAS把该候选正文写入 Block 并递增 revision不得接受临时 `contentOverride`
5. 默认继承上游候选的 lineage、授权快照、许可限制、召回状态和风险标记并写 Block Source Attribution。
6. 将 Suggestion 迁入候选归档(disposition=accepted),必要时记录 `final_content`
6. 将最终 candidateVersion 迁入候选归档(disposition=accepted),保留完整编辑版本链与最终正文哈希
7. 将旧知识草稿标记为失效,不允许继续确认。
8. 写 change log / audit log标记这是 modified merge。
9. AFTER_COMMIT 发布重新提取事件或创建异步任务记录。
@ -277,10 +292,11 @@ Reject 不得更新 Block不得创建重新提取链路。
1. 用户点击“修改”。
2. 调整最终入正文的结构化内容。
3. 点击“修改后合并”。
4. 服务端完成正文主事务,并让旧草稿失效。
5. UI 显示“已合并,旧知识草稿已失效,正在重新提取”。
6. 前端进入等待新草稿状态。
3. 系统创建递增的 candidateVersion 并重新运行 detector检测中不能点击接受。
4. detector 绿后,用户点击“修改后合并”,服务端执行 `accept_preflight` 与 CAS。
5. 服务端完成正文主事务,并让旧版本知识草稿失效。
6. UI 显示“已合并,旧知识草稿已失效,正在重新提取”。
7. 前端进入等待新草稿状态。
### 9.3 Reject
@ -306,6 +322,9 @@ Reject 不得更新 Block不得创建重新提取链路。
11. 候选使用市场作品资产时,必须经过作品资产使用预检和 feature gate否则不能接受。
12. 修改后合并默认继承上游 lineage 和许可限制,不能用 `contentOverride` 洗白来源。
13. Accept / Merge 写 Canonical 前必须实时校验来源版本和授权状态,校验清单必须覆盖输出合规、静态检查、质量结果版本、来源状态、授权快照、作品资产 feature gate、expectedRevision 和幂等结果。
14. 用户编辑必须生成递增 candidateVersion 并重新运行 detector未重新检测或 detector 非绿不得进入 `accept_preflight`
15. 只有 `mode=production``acceptanceEligible=true` 且候选/上下文/检测/策略版本一致时才能接受;诊断和评测候选永不可接受。
16. Accept / Merge 必须以 candidateVersion、candidateSha256、当前状态和 `expectedRevision` 做 CAS旧版本和迟到结果不得覆盖 Canonical。
### Should-HaveP1
@ -339,6 +358,9 @@ Reject 不得更新 Block不得创建重新提取链路。
- 市场作品资产缺 feature gate / owner 预检 / `workAssetUsePrecheckId` 时阻断接受
- 修改后合并触发旧草稿失效
- 修改后合并不能清空上游 lineage、授权快照和许可限制
- 编辑后 candidateVersion 递增并重新 detector旧检测报告和旧候选不可接受
- `acceptanceEligible=false`、detector 非绿、上下文哈希漂移或策略版本漂移时 `accept_preflight` 失败
- CAS 冲突、迟到 detector 和重复命令不能覆盖新 candidateVersion 或正文 revision
- Reject 零正文副作用
- 409 冲突返回必要信息
- 投影失败不阻塞主事务

View File

@ -1,9 +1,10 @@
# 专题-03AI 编排、上下文与质量评测实现规范
- 版本v2
- 更新日期2026-06-29
- 版本v4
- 更新日期2026-07-20
- 目标读者:产品 / 架构 / 后端 / 前端 / 测试
- 阅读时间30-45 分钟
- 变更记录v42026-07-20§4.5 固化正文实验的 `WriterContext v1``WriterOutput v1``RetrievalManifest`、双证据与冻结语义;明确实验阶段不改产品 API/DB。v32026-07-17§4.2 登记「分区内该选哪几条知识」的选择契约归属 [专题-07-知识消费契约与质量闭环](专题-07-知识消费契约与质量闭环.md);关联阅读补专题-07。
- 边界说明:本文件承接阶段 1~5定义 AI 编排、上下文组装、检索、运行权限、来源追踪、风险路由和质量评测的产品架构合同。它不定义精确数据库表、后端 endpoint、前端组件和运维门禁这些由后续前端/后端阶段承接。
## 1. 归属范围
@ -175,6 +176,8 @@ Token 预算顺序:
4. 超限时先摘要化低优先级资料,再截断。
5. 被省略资料进入 omittedSources原因只能是 `token_budget``not_authorized``stale_source``low_confidence``not_relevant``blocked`
分层与预算回答「装多少、先装谁」;分区内「该选哪几条知识、以什么视图注入、按什么排序」的选择契约归 [专题-07-知识消费契约与质量闭环](专题-07-知识消费契约与质量闭环.md),本册不重复定义。
### 4.3 来源和授权
每个进入上下文的外部或授权来源必须携带 Authorization Snapshot 或等价不可变指纹,最小语义包括:
@ -213,6 +216,51 @@ AI 结果必须能解释:
- 是否触发质量门控、重写、风险标记或输出阻断。
- 下一步用户可以接受、修改、丢弃、重生成或进入知识确认。
### 4.5 正文 WriterContext、输出与冻结合同
本节是正文生成上下文和输出结构的唯一 owner。知识侧为什么必须“卡是索引、按来源回读原文”由 [专题-07 §2.1](专题-07-知识消费契约与质量闭环.md) 定义;本节只定义检索结果如何冻结、组装和交给写手。
#### 4.5.1 `WriterContext v1`
`WriterContext v1` 是写手唯一可见输入,采用严格 schema缺字段、未知字段、错误版本、无效引用或哈希不一致均失败关闭。最小合同如下。
| 字段组 | 必须包含 | 约束 |
|---|---|---|
| 身份与用途 | `schemaVersion=writer-context-v1`、runId、attempt、mode、qualityPolicyVersion | mode 仅为 `production` / `diagnostic_only`;运行标识不参与内容身份哈希 |
| 作品与冻结点 | workId、targetChapter、asOf、sourceVersion、authorizationSnapshot、sourceStatus | 回放必须 `asOf < targetChapter`;来源非允许状态即失败关闭 |
| 快照与检索 | contextSnapshot、retrievalPlan、`RetrievalManifest` | 引用、版本、过滤、排序、哈希和裁剪原因必须完整且互相一致 |
| 创作骨架 | 已确认大纲定位、fineOutline、narrativeState | 细纲明确区分硬事件、结果方向、伏笔动作、章末钩子、必须出场实体和可调整节拍 |
| 双证据 | `factEvidence[]``proseEvidence[]`、evidenceCoverage[] | 事实与写法证据不得混装;每项都必须回到不可变来源引用 |
| 输出控制 | outputContract、tokenBudget、omittedSources | 包含动态篇幅、结构要求、新设定申报规则和所有省略原因 |
| 接受隔离 | `acceptanceEligible` | `diagnostic_only`、evaluation 或回放上下文固定为 false不得被写手输出覆盖 |
`factEvidence[]` 负责“写得对”,每项保存 factId、sourceType、不可变 sourceRef、内容哈希和 coverageState来源准入、抽取卡回读要求及正式设定/Canonical 状态/细纲新事实的独立权威分类只以 [专题-07 §2.1](专题-07-知识消费契约与质量闭环.md) 为准。无可靠来源的提示不得进入该字段。
`proseEvidence[]` 负责“写得像”,只包含可回读的历史原文,记录 sourceVersion、章号、Block、Unicode code point 左闭右开区间、内容哈希和用途。连续前四章全文是正文实验 v1 的基础文风证据;抽取卡命中的来源原文只作补充,并按不可变来源去重、稳定排序。事实来源与原文证据不能互相冒充。
#### 4.5.2 `RetrievalManifest`
`RetrievalManifest` 是一次确定性检索结果的冻结清单,至少记录 `planId`、查询、授权和作品过滤、排序规则、卡索引版本、原文读取版本、sourceVersion/sourceRefs、stateAsOf、章号、Block 与字符区间、内容哈希、排除项和裁剪原因。抽取卡排序固定为 `score DESC, sourceVersion ASC, sourceId ASC, sourceOffset ASC`;同一检索计划、授权快照、冻结点和索引版本必须得到同一来源集合与 manifest identity。
manifest identity 对规范化后的来源集合计算,使用 UTF-8、Unicode NFC、LF、对象键排序和稳定数组合同runId、时间戳、执行节点不参与身份。检索与原文读取必须在同一只读冻结快照内完成任一来源越过冻结点、缺版本、缺授权、缺哈希或无法重现时整次上下文组装失败关闭。
冻结边界按用途统一解释:
1. 回放只读取 `chapter <= asOf` 的 Canonical 正文、状态里程碑和抽取卡来源;目标章、未来章、全书终态摘要和无法证明绝对章号的演变事实一律拒绝。
2. 正向创作以当前最新 Canonical 章为 `asOf`正式设定、Canonical 状态和已确认细纲分别读取各自不可变版本。
3. A/B/C 诊断臂可以改变证据策略,但共享作品、冻结点、大纲、细纲、篇幅算法、模型和检测规则,且全部 `acceptanceEligible=false`
4. 评测产物只用于诊断和 Gate 裁决,不能反写 Canonical、知识卡、正式设定、Narrative State 或细纲。
#### 4.5.3 `WriterOutput v1`
`WriterOutput v1` 采用严格 schema至少包含 `schemaVersion=writer-output-v1`、runId、attempt、mode、qualityPolicyVersion、contextSnapshotId/contextSnapshotSha256、candidateVersion、candidateBody/candidateSha256、`acceptanceEligible`、claimLedger[]、evidenceRequests[]、newSettingDeclarations[] 和 selfCheck。
候选正文先统一为 UTF-8、Unicode NFC 和 LF再计算 SHA-256 与 Unicode code point 区间。claimLedger 每项必须绑定候选哈希、事实类型、正文区间和对应 factEvidence证据请求和新设定申报不得静默改写上下文。`acceptanceEligible` 由可信组装层派生,写手无权把 false 改为 true接受链的 candidateVersion、detector 与 CAS 前置条件只由 [专题-01 §6](专题-01-正文建议接受(Accept%20Suggestion)实现规范.md) 定义。
#### 4.5.4 实验落地边界
当前只在正文实验台验证以上合同,不修改产品 API 契约、Flyway、正式业务数据库或 Canonical 主链。只有正文 Gate B 由 [专题-04 §10.1](专题-04-生成质量门控与创作健康度设计方案.md) 的唯一判定顺序裁决为 `passed` 后,才另立产品化计划更新 `docs/api-contracts/*`、数据库迁移和正式实现Gate B 通过前不得启动细纲智能体真实能力验收。
## 5. 检索和图查询
### 5.1 RAG 定位
@ -512,4 +560,5 @@ Source Status Event 必须幂等处理,传播失败时要可重试;在传播
- `架构-01-系统全貌与边界上下文.md`
- `架构-02-核心数据结构与双轨模型.md`
- `架构-04-状态机与约束清单.md`
- `专题-04-生成质量门控与创作健康度设计方案`
- `专题-04-生成质量门控与创作健康度设计方案.md`
- `专题-07-知识消费契约与质量闭环.md`

View File

@ -1,8 +1,6 @@
# 专题-04生成质量门控与创作健康度设计方案
- 版本v1
- 更新日期2026-05-23
- 目标读者:产品 / 架构 / 前端 / 后端 / 测试
- 目标读者:创作者 / 产品 / Agent 实现者 / 架构 / 前端 / 后端 / 测试
- 阅读时间25-40 分钟
- 边界说明:本文件承接阶段 1~5定义质量门控(Quality Gate)、创作健康度(Writing Health)、质量策略、有限重写、质量结果展示和质量观测的产品架构合同。AI 编排、上下文组装和运行时权限见 `专题-03`;精确 Schema、API 和前端组件由后续阶段承接。
@ -68,6 +66,27 @@
| 角色声音一致性 | `character_voice` | 角色对白、行为、语气是否符合角色档案和近期章节表现 | 触发重写;失败可允许用户修改后合并 |
| 场景结构 | `scene_structure` | 场景目标、动作链、因果、视角和段落推进是否完整 | 触发重写;严重断裂时建议重生成 |
#### 4.2.1 运行时叙事门量表与通过线
叙事关键维度在运行时按下列量表打分并裁决,不再是模糊词。盲评机制(双盲双评、候选随机化、任一维两次评分差 `>0.5` 只加一次第三评、稳定配对取中位数、无稳定配对则不得强行给出输赢)只引用 §10.1,本节不重复定义;运行时与离线实验的差异在于:运行时对**单个候选**裁决,离线实验对**种群 A/B 臂**裁决。
每维按 0-10 分、0.5 分步长评分并逐项标注证据来自已确认正文、Local KB、正式规划、Narrative State 还是评委自行推断。运行时通过线如下(由质量策略登记、可调,但必须显式登记;未登记视为门控未闭合,候选不得进 `shadow_ready`
| 维度 | 通过线 | 理由 |
|---|---|---|
| 设定一致性 `canon_compliance` | `>=7.0` | 硬事实层,门槛最高 |
| 角色声音一致性 `character_voice` | `>=6.0` | 可修改后合并,门槛次之 |
| 场景结构 `scene_structure` | `>=6.0` | 可重写补救,门槛次之 |
运行时裁决顺序(命中前项即停止,后项不得覆盖前项):
1. 设定一致性出现**硬事实冲突 verdict**与已确认正文、Local KB 或正式规划直接矛盾,类比 §10.1 Gate A 的"硬约束覆盖率 `<100%`"):不论分数,直接 `high_risk`,禁止接受。
2. 任一维评分不稳定(无稳定配对)或评测异常:`unavailable`/`needs_recheck`fail-closed不放行。
3. 任一维 `<` 通过线:触发 Shadow 内有限重写(上限见 §5默认 ≤2 次);重写后仍 `<` 通过线:`high_risk`,需修改后合并或重生成。
4. 三维全 `>=` 通过线且无硬事实冲突:`pass`/`rewritten_pass`,可进 `shadow_ready`
`qualityState` 的转移与风险路由优先级只引用 §6本节不重复定义。本节通过线数值是运行时默认值其合理性由 §10 离线实验与回放评测校准;校准责任人与时点登记于质量策略。
### 4.3 非关键维度
非关键维度只展示评分和建议,交给用户判断。
@ -256,12 +275,13 @@ active -> superseded / rolled_back
离线评估用于比较智能体、Prompt、上下文策略和质量策略不直接影响用户当前候选。
默认允许进入离线评估的样本只有类:
默认允许进入离线评估的样本只有类:
1. 合成样本。
2. 公开授权样本。
3. 用户或发布者显式授权用于评估的样本。
4. 已按合规规则脱敏、去标识化且不可还原到用户私有正文的样本。
5. 经 [专题-07 §4](专题-07-知识消费契约与质量闭环.md) 两道闸校验的参考作品回放样本(`reference_work` 授权四值 + 评测用途授权快照;评测产物不留存原书全文)。
默认禁止进入离线评估:
@ -269,7 +289,7 @@ active -> superseded / rolled_back
- 私人 AI 候选全文。
- 完整上下文快照。
- 完整 Prompt / Response。
- 外部知识或市场资产全文。
- 外部知识或市场资产全文(经上文第 5 类两道闸校验的参考作品回放样本除外)
如果未来需要使用真实私有内容做评估,必须先有单独的合规访问设计、授权记录、最小化样本、脱敏策略、审计记录和退出机制;不能由质量策略配置直接打开。
@ -279,6 +299,7 @@ active -> superseded / rolled_back
- Agent Version。
- Prompt Version。
- Context Assembly Strategy。
- 知识策略版本(选择契约参数、注入视图定义、判重规则;其回放评测机制见 [专题-07-知识消费契约与质量闭环](专题-07-知识消费契约与质量闭环.md))。
- Quality Policy Version。
- Authorization Snapshot 和来源摘要。
- New-API Binding 摘要。
@ -296,6 +317,58 @@ active -> superseded / rolled_back
评估结果只能用于管理员判断策略、智能体或 Prompt 是否上线,不直接修改用户作品。
### 10.1 正文实验五维量表、盲评与 Gate A/B
正文回放把创作、语义检测和盲评拆成独立模型职责:
| 角色 | 每次调用的唯一职责 | 不负责 |
|---|---|---|
| writer | 根据最小创作输入生成一章正文 | claim、证据缺口、新设定申报、哈希、版本、自检或评分 |
| semantic detector | 对一个已绑定候选核对事实、细纲硬约束、证据与新设定 | 创作、润色、改写、文学评分或运行哈希 |
| blind judge | 对盲化候选按预注册维度评分并给出事实/硬约束 verdict | 修改正文、替代 detector、读取臂专属上下文或计算运行哈希 |
“一个职责”不等于“一个字段一次调用”。detector 可以在一份报告中列出同一候选的多条语义 verdictjudge 可以在一份报告中完成同一次盲评的多维评分;创作、检测和评审仍必须是不同调用。
同一样本的 A/B/C 由三个互不共享会话的 fresh writer 调用产生;每个候选各运行一次 fresh detector两个 reviewer 分别运行 fresh blind judge只有评分不稳定时增加一次第三 reviewer。模型、effort、输出 schema、单次预算和公共上下文控制必须在可比调用间一致。
正文回放使用独立的 `writer` 评测 profile不覆盖细纲、知识或其他评测量表。每个维度按 0-10 分、0.5 分步长评分,并逐项标注证据来自已确认细纲、冻结历史原文、卡索引还是评委自行推断。
| 维度 | 评判问题 |
|---|---|
| 设定与实体保真 | 人物、关系、物品、地点、力量规则、知情范围和即时状态是否与冻结事实一致 |
| 细纲与情节忠实 | 细纲硬事件、结果方向、伏笔动作、必须出场实体和章末钩子是否全部兑现,且未被反转或提前回收 |
| 文风一致性 | 叙述声音、角色语言、动作习惯、段落节奏是否与冻结原文证据一致 |
| 叙事张力 | 场景推进、因果、冲突升级、情绪连续和章末牵引是否成立 |
| 文笔与可读性 | 文字是否准确、流畅、具体,是否存在空泛解释、机械重复或明显阅读阻力 |
每个样本先由两个独立、无会话的评委双盲评分;候选臂名随机化,第二评委反转展示顺序。任一维两次评分差异 `>0.5` 时,只增加一次第三评委。第三评委后,若三份评分中至少一对差值 `<=0.5`,该维最终分取三者中位数;若不存在稳定配对,整个样本失败,不得强行给出输赢或方向结论。去盲、稳定性判断和聚合必须机械执行,人工只能复核证据归因,不能手改终态。
blind judge 只能看到盲化 candidate ID、候选正文、所有臂相同的细纲评测字段和 evaluator-only oracleTruthPack不得看到真实 A/B/C 映射、各臂 WriterContext、卡检索轨迹、臂专属原文或 raw 路径。judge 模型只返回五维评分、逐项理由、事实 verdict 和硬约束 verdictcandidate/input/oracle/report/model-receipt hash 由 judge adapter 注入或计算。
预算分为两层:`plannedCalls` 是单个 Gate 执行的不可变调用计划,`maxCalls` 是独立安全上限。启动预留只按各角色 `plannedCalls × maxBudgetUsdPerCall` 计算,不得把安全上限当作调用计划;每次调用前还要校验累计实际成本、剩余计划预留和角色调用数。补证、返修或重跑必须创建新的显式计划与预算预留。
正文实验只允许下列 Gate 顺序,命中前项即停止,后项不得覆盖前项:
**Gate A链路可运行**
1. 任一预期调用、schema、hash、冻结、授权、未来泄漏审计、模型回执或 judge 稳定性异常:`failed`
2. 预注册评测集自身不足 5 个有效样本或五类场景不全:`insufficient_evidence`
3. C 臂存在 detector 高严重度残留或细纲硬约束覆盖率 `<100%``failed`
4. 其余:`passed`
Gate A 只证明链路可运行,不代表正文层通过。
**Gate B正文层正式裁决**
1. Gate B 运行中任一预期调用、schema、hash、冻结、授权、泄漏审计或 judge 稳定性异常:`failed`
2. Gate A=`failed``failed`Gate A=`insufficient_evidence``insufficient_evidence`
3. 预注册评测集作品 `<2`、任一作品有效样本 `<5`、总有效样本 `<10` 或五类场景未覆盖:`insufficient_evidence`
4. C 臂硬约束覆盖率 `<100%`、存在 detector 高严重度残留,或 C-A 的文风一致性/叙事张力在整体或任一非空新角色分层退化:`failed`
5. C-A 的设定与实体保真平均增量 `>=0.25` 且至少 `60%` 样本增量 `>0``passed`
6. 其余:`no_gain`
报告必须列出假阴、假阳、未来泄漏、评委不稳定、新角色无卡和场景选择偏差等混淆项。单章、单作品或只选卡友好场景不得得出普适结论。只有 Gate B=`passed` 才能声称正文层通过并启动细纲智能体真实能力验收其他终态继续修正文层或补充预注册的合法样本。A/B/C 的冻结与接受隔离合同只引用 [专题-03 §4.5](专题-03-AI编排上下文与质量评测实现规范.md),本节不重复定义。
## 11. 线上质量观测
线上质量观测用于发现策略、智能体、上下文组装和质量门控的真实效果,不用于监控单个作者的创作水平。
@ -351,3 +424,4 @@ active -> superseded / rolled_back
- `架构-02-核心数据结构与双轨模型.md`
- `架构-04-状态机与约束清单.md`
- `专题-03-AI编排上下文与质量评测实现规范.md`
- `专题-07-知识消费契约与质量闭环.md`

View File

@ -1,9 +1,10 @@
# 专题-05AI 统一交互协议与外部 Agent Adapter 设计
- 版本v0.1
- 更新日期2026-06-29
- 版本v0.2
- 更新日期2026-07-20
- 目标读者:架构 / 后端 / 前端 / 运维 / 测试
- 边界说明:本文定义 Muse 后端到外部 Agent 运行时的统一交互协议与 adapter 层。它承接 `专题-03-AI编排上下文与质量评测实现规范.md`,不改变 Shadow -> Canonical、Runtime Permission Envelope、RAGFlow 检索和用户确认边界。
- 变更记录v0.22026-07-20§5.4 固化正文写手 adapter 的无工具、无会话持久化和超时失败关闭边界v0.12026-06-29建立外部 Agent 统一协议与 provider adapter 设计。
## 1. 背景与结论
@ -215,6 +216,19 @@ AgentScope adapter 以后按同一协议接入:
- 请求和响应仍走 `MuseAgentRuntimeRequest/Response`
- AgentScope 内部工具和知识权限即使受限Muse 仍按外部 runtime 处理,不授予直接写入权。
### 5.4 正文写手 adapter 隔离边界
正文写手 adapter 是开放能力节点,但采用比通用 provider 更窄的执行边界:
1. **无工具**:写手进程的工具集合必须为空,不得读取文件、搜索、访问数据库、调用网络工具或自行扩大检索范围。检索、授权、冻结与组装全部在可信的 Muse 层完成。
2. **无会话持久化**:每次 attempt 使用独立无状态进程,禁止恢复、续接或保存 provider 会话;旧 attempt 的隐式记忆不得进入新候选。
3. **单一输入**adapter 只接收 [专题-03 §4.5](专题-03-AI编排上下文与质量评测实现规范.md) 定义的冻结 `WriterContext v1`不得旁路追加未登记正文、卡片、Prompt 记忆或未来信息。
4. **严格输出**:只接受 `WriterOutput v1`;非 JSON、未知字段、缺字段、候选哈希或上下文哈希不匹配均视为协议失败不生成可接受候选。
5. **超时失败关闭**adapter 必须设置单次 deadline。超时、取消、非零退出、provider bad response 或进程失联时,当前 attempt 进入失败终态,取消下游 detector/judge丢弃迟到结果且不得回退到有工具写手、旧会话或其他 provider 伪装成功。
6. **接受资格不可伪造**`acceptanceEligible` 由可信上下文层派生;诊断/评测运行及任何 adapter 失败结果固定不可接受。provider 返回 true 不能覆盖可信层的 false。
该边界先在实验台验证,不新增或修改产品 API、数据库字段和正式 runtime 状态;产品化必须等待正文 Gate B 通过后另立计划。本节只拥有 adapter 隔离语义Writer 合同和接受链分别由专题-03、专题-01 定义。
## 6. 系统 Agent 配置体验
管理端系统 Agent 配置页新增“外部运行时”区域:

View File

@ -0,0 +1,323 @@
# 专题-06元数据驱动的智能体架构
- 版本v5
- 更新日期2026-07-20
- 目标读者:架构 / 后端 / 前端 / 产品
- 边界说明:本册是「元数据驱动的智能体架构」这条横切主线的单一 owner收束四件此前散落无主的事——元引擎与功能链如何共同驱动智能体、拆书作为通用抽取智能体的两处用场、target type 结构本体的全清单与分层判据、统一创作数据读取器与 base 内置机制。术语与双轨不变式的权威在 [架构-02-核心数据结构与双轨模型](架构-02-核心数据结构与双轨模型.md)MetaSchema、Canonical/Shadow、domain/scopeAI 链路合同与 Context Assembly 在 [专题-03-AI编排上下文与质量评测实现规范](专题-03-AI编排上下文与质量评测实现规范.md);外部 Agent 协议在 [专题-05-AI统一交互协议与外部AgentAdapter设计](专题-05-AI统一交互协议与外部AgentAdapter设计.md);表结构与字段合同在 [后端-04-统一数据库Schema-v1](后端-04-统一数据库Schema-v1.md)。上述对象本册只链接、不重复定义。
- 变更记录v52026-07-20§7.1 登记 `generation_context` 的 generation purpose 严格 schema 投影、卡索引视图和事实/原文双证据字段,具体合同仍由专题-03/07 owner 定义。v42026-07-17拆书实验台证据回填——§4.5 拍板双层型开放问题(`craft` 公共面实测无损写入同一字段合同单模具双库成立不拆并登记世界域实体型共享「演变历程」元素§6 新增 6.4 参照作品面(参考书实体演变卡 = 系统侧证据资产,蒸馏为叙事域成长曲线范式后才入 Global KB知识消费选择契约与质量闭环整体归新册 [专题-07-知识消费契约与质量闭环](专题-07-知识消费契约与质量闭环.md)本册补链接§7 读取器 purpose 枚举 `parse` 统一为 `extraction`、字段级裁剪随 `aiContext` 值域升级(布尔或用途集,权威在 [架构-02 §9](架构-02-核心数据结构与双轨模型.md)对齐表述。v32026-07-09结构本体补全 scope 轴空格位——新增 `chapter`(章节容器)、`scene`(场景卡)、`narrative_state`(叙事状态模具)三型,清单 20→23、scope 七值全挂靠,种子四档同步 23 项;钉死读取器只依赖 owner api 模块具名端口(服务即 API与 storage_binding 的权力边界映射非数据通道。v22026-07-09新增 §2.1 检索基座的替换合同与演进方向(引擎缝 + 引擎中立合同,预期纯 Java 自研PG 向量插件 + New-API 嵌入/重排切块收回保护节点。v12026-07-09定稿自 2026-07-08 架构评审将「agent = f(作品 + 元数据 + 知识库)」主线、三体关系、20 型结构本体、统一读取器与 base 机制蒸馏为 canonical。
---
## 1. 中枢:元引擎与功能链双枢
理解本册全部内容的钥匙是一句话:**Muse 不是「一个功能挂一个外部 app」而是一套元数据驱动的智能体编排**。它的中枢在 muse-cloud 的元结构模块里,由两套彼此正交的东西组成。
**元引擎MetaSchema是结构模具**。它定义一个角色有哪些属性、一段文风从哪些维度刻画、一次转折由哪些要素构成,以及每个字段是否可见、可编辑、可检索、是否可进入 AI 上下文。它约束的是智能体「读哪些字段、抽哪些实体、产出什么结构」。其权威定义见 [架构-02 §9 MetaSchema 与配置模型](架构-02-核心数据结构与双轨模型.md)。
**功能链FunctionChain是流程骨架**。一条链由若干节点串成,节点分两种:开放槽位允许替换子智能体,保护节点不可替换。它约束的是「智能体按什么次序跑、哪一步不能外包」。功能链的节点/开放槽位/保护节点语义见 [专题-03 §2 系统功能链路](专题-03-AI编排上下文与质量评测实现规范.md),落地的槽位模型见 [架构-02 §5.2 系统功能编排与槽位](架构-02-核心数据结构与双轨模型.md)。
一句话区分:**MetaSchema 定义「结构」FunctionChain 定义「流程」**。二者同住元引擎Meta BC——功能链的定义、版本、节点、槽位与 MetaSchema 是一族数据,表结构见 [后端-04 §7.2 系统功能链路和槽位](后端-04-统一数据库Schema-v1.md)。AI runtime 不自持一套编排,而是经 `FunctionChainQueryApi` 读端口取激活的功能链,据此解析节点序列与开放槽位再做运行编排。这样「谁来定义能力怎么串」与「谁来执行这次编排」分属两个 BC定义在 Meta、执行在 AI避免编排逻辑在两处各写一份而漂移。
由这两枢得到本架构最重要的性质:**每个智能体都是 `f(作品 + 元数据 + 知识库)`**。任何智能体运行时同时结合三样输入——作品提供「写什么/改什么/查什么」的对象与近邻语境,元数据决定「读哪些字段、抽哪些实体、产出什么结构」(它是产出结构的模具),知识库提供「作品事实」与「公共参考」两类支撑。因此**智能体的类型是固定的产品功能(写死在功能链节点上),但产出是元数据驱动的动态结果**:管理员在元引擎里给「世界设定」加一个字段「金手指类型」,或给「文风」加一个维度「反转密度」,不改一行智能体代码,拆书就会多抽这个属性、规划会多生成这个字段、检测会多查这个维度。这就是「类型固定、元数据变则产出变」。
```mermaid
flowchart TB
subgraph Meta["元引擎 MetaSchema结构模具"]
MS["target type / 字段 / 枚举 / 校验<br/>aiContext 开关 / 输出合同"]
end
subgraph Chain["功能链 FunctionChain流程骨架 · 经 FunctionChainQueryApi 激活)"]
direction LR
N1["保护节点<br/>输入合规 / 权限过滤"] --> SLOT["开放槽位<br/>能力子智能体"] --> N2["保护节点<br/>质量门控 / 输出合规"]
end
W["作品 Content"] --> SLOT
K["知识库 Knowledge"] --> SLOT
MS -. 约束读入与产出结构 .-> SLOT
MS -. 定义可入 AI 字段 .-> W
SLOT -->|候选| Shadow["Shadow 待审"]
Shadow -->|用户确认| Canonical["Canonical 正式事实"]
```
---
## 2. 三体关系muse-cloud / dify-agent / dify-rag / New-API
先破一个误解:**dify-agent 与 dify-rag 不是两套部署,而是同一个 Dify 实例的两个功能平面**,靠两类 API key 区分——app key 只能打 app/workflowdataset key 只能打 datasets混用即被拒。四方职责如下。
| 角色 | 是什么 | 持有什么 | 边界 |
|---|---|---|---|
| **muse-cloud** | 大脑与主权 | 元引擎、功能链、运行权限包、双轨Shadow/Canonical、审计、用量归属、编排 | 业务事实源与权限裁判;绝不让外部写 Canonical |
| **dify-agent** | 开放槽位的能力执行 | Dify chat/workflow app | 只是能力节点,只返回候选,不是可入库事实 |
| **dify-rag** | 检索基座 | Dify Datasets每知识库一物理库 | 检索基座不等于事实源;缺来源/授权的结果不进上下文 |
| **New-API** | 底层模型网关 | 真实模型与向量/重排 | 用量、成本、归属的权威Dify 用量仅作脱敏审计 |
主权原则一句话:**Muse 编排Dify 执行Muse 裁判**。两个 Dify 平面都是可替换的外部底座——指定了 Dify 却未配置时失败关闭,绝不回退 New-API 伪装成功Dify 全挂时 Muse 也只失败关闭,用户读写 Canonical 不受影响。外部运行时接入的统一协议与 adapter 边界见 [专题-05](专题-05-AI统一交互协议与外部AgentAdapter设计.md)。
### 2.1 检索基座的替换合同与演进方向
检索基座的可替换不是口号,而是由两道既有接缝保证的。**引擎缝**:知识域内部只有一个检索运行时接口,引擎实现(当前是 Dify Datasets未配置时是失败关闭的空实现按装配切换dataset 等引擎侧标识全部收在接缝之下;**合同缝**知识域对上AI 编排、统一读取器)暴露的检索 facade-api 是引擎中立的——请求携带租户、用户、作品等隔离与授权要素,返回携带来源标注与被剔除来源清单。由此,**查询语义(查什么、按什么授权、怎么进上下文)永远在 muse-cloud引擎只执行「给定集合范围内的相似度检索」**;逻辑与物理的用户隔离同样是 muse-cloud 服务端的裁决引擎不承载信任边界——Dify 并不认识 Muse 的用户,今天的隔离本来就是「每知识库一 dataset + Muse 侧授权前置」。
这两道接缝决定了演进路径:**当期取 Dify Datasets 快速闭环;预期演进方向是纯 Java 自研检索基座**方向性预期非当期承诺——PostgreSQL 向量插件承担向量索引与行级隔离过滤(向量检索与作品/库/用户过滤在同一条查询内完成,隔离强于外部 dataset嵌入与重排经 New-API 端点调用(二者本就是模型调用而非库能力),切块收回 Muse设计上它本就是保护节点见 [专题-03](专题-03-AI编排上下文与质量评测实现规范.md))。迁移成本被接缝锁定为:一个新的检索运行时实现加摄入链,上层合同、统一读取器与双轨全部不动。
一次生成的完整数据流由 muse-cloud 全程编排,外部两平面只在「检索」与「能力执行」两处被调用,产出一律以候选身份回到 Muse 的保护节点。
```mermaid
sequenceDiagram
participant U as Studio 用户
participant M as muse-cloud编排 + 主权)
participant R as dify-rag检索
participant A as dify-agent能力执行
participant N as New-API模型网关
U->>M: 续写请求
M->>M: 生成运行权限包 + 按 MetaSchema 组装上下文
M->>R: 按授权检索绑定库
R->>N: 向量检索 / 重排
R-->>M: 授权片段(带来源 / 状态)
M->>A: 组装后的上下文
A->>N: 模型推理
A-->>M: 候选(仅候选)
M->>M: 保护节点:静态检查 / 质量门控 / 输出合规
M-->>U: Shadow 候选 + 创作健康度解释
U->>M: 接受 / 改后合并 / 丢弃
M->>M: 写 Canonical 正文 + 来源归因
```
---
## 3. 智能体三层清单
按主权边界,智能体分三层,**只有第一层可以挂 Dify app**。这样切分有三个理由主权Shadow→Canonical 的裁决不能被外部替换)、可替换性(同一开放槽位可在 Dify、New-API、未来 AgentScope 之间换而不动主链)、可解释与可审计(保护节点必须产出 Muse 可追溯的结论)。
**第一层 · 开放能力子智能体**(可挂 Dify app/workflow 或 New-API也是用户/市场智能体的插入点):
| 智能体 | 做什么 | 依赖(作品 + 元数据 + 知识库) | 产出与边界 |
|---|---|---|---|
| **写作** | 续写/改写/扩写/润色/纠错/去 AI 味/角色声音 | 近邻正文 + `style`/`pacing`/`craft` + Local KB 与检索片段 | 只进 Shadow不写 Canonical不替换保护节点 |
| **拆书/分析** | 按 MetaSchema 从文本抽实体/关系;全书解析、章节抽取、结构拆解 | 源文本 + 实体类 target type + 目标库Global/Local | 章节审阅后才建草稿;系统级只产抽象范式、不留原文 |
| **检测** | 一致性/角色声音/文风/风险/语义偏离检查 | 候选或正文 + 检查约束 target type + Local KB Canonical | 只产解释与定位,不直接改正文或知识 |
| **规划** | 生成大纲/世界设定/人设 | 作品方向 + `work_core`/`outline`/`world`/`character` + Local KB | 规划候选只进 Shadow未确认不得进后续生成上下文 |
**第二层 · 保护节点智能体**Muse 自持,即使调用 LLM 也不外包为可配置 Dify app只走受控内部路径质量门控与 LLM-Judge 在候选交付前按质量维度评分并做 Shadow 内有限重写;合规与语义围栏对输入输出做失败关闭的合规裁决,不可被质量分放开;离线质量评估对智能体/Prompt/策略跑评估集回归,不影响用户当前候选。它们是「先审后入」主权的执行者,质量维度的定义见 [专题-04-生成质量门控与创作健康度设计方案](专题-04-生成质量门控与创作健康度设计方案.md)。
**第三层 · 知识基座**不是「app」RAG 检索按授权从绑定库的 Dify dataset 循环检索合并,返回带来源/授权/状态的片段;切块、入 RAG、索引是资料摄入的保护节点处理未过不得进生成上下文。
三层与功能链节点一一对应:生成链对应写作智能体,分析/导入解析链对应拆书智能体,检测链对应检测智能体,规划链对应规划智能体,知识处理链对应拆书入库与 RAG质量链与合规链落在保护节点智能体上。功能链是编排骨架智能体是槽位里的能力。
---
## 4. target_type 结构本体
### 4.1 元数据用途类与本体分组的分层
元数据不是一张平表。它按用途分五个**元数据用途类**,其中只有第一类是「实体有哪些属性」、被拆书与生成直接消费;其余四类各有独立 owner本册只在此定位、不重复定义。
| 元数据用途类 | 管什么 | 载体与 owner |
|---|---|---|
| **① 结构本体** | 实体有哪些属性(拆书抽、生成写、检测查的结构模具) | MetaSchema target types本册 §4.2 起) |
| **② 质量维度** | 怎么评判好坏 | Quality Policy[专题-04](专题-04-生成质量门控与创作健康度设计方案.md);输入侧知识质量维度与回放评测见 [专题-07](专题-07-知识消费契约与质量闭环.md) |
| **③ 编排** | 智能体怎么串 | FunctionChain本册 §1落地见 [后端-04 §7.2](后端-04-统一数据库Schema-v1.md) |
| **④ 智能体配置** | 每个能力怎么配prompt/模型绑定/工具授权/输出合同) | Agent Version[专题-05](专题-05-AI统一交互协议与外部AgentAdapter设计.md) |
| **⑤ 可见与策略** | 每字段的权限行为与灰度 | MetaVisibilityPolicy[架构-02 §9](架构-02-核心数据结构与双轨模型.md) |
为免「大类」串词,下文一律称这一层切分为**元数据用途类**(五类),称结构本体内部的分组为**本体分组**(作品骨架/世界设定/人物/画像/技法,加容器、系统侧、配套三个附组)。「固定」的是这几个用途类;类型与属性是开放集——管理员可增类型、用户可在作品级扩字段。
### 4.2 前置地基domain 与 scope 两轴
结构本体的每个 target type 先由两根正交的轴定位。这两轴的概念权威在 [架构-02 §9](架构-02-核心数据结构与双轨模型.md),此处给的是面向本体分类的应用视图。
**domain 描述语义域,不描述存储 BC**——它回答「这块结构属于哪个意义世界」,与实例落在哪个 Bounded Context 无关;例如 Local KB 实体的实例存 Knowledge BC但其 schema 多属 world 或 narrative 域。**scope 恒等于实例对象的粒度**,取值是 work/chapter/block/entity/relation/event/agent 之一;系统级与作品级之分不占 scope 轴,由 `effective_scope` 承载(见 §5
| domain | 一句话判据 | 承载 |
|---|---|---|
| `content` | 作者对作品的戏外承诺与计划,角色感知不到 | 作品容器、创作定位、大纲 |
| `world` | 角色可感知的戏内世界事实 | 世界观、地点、组织、力量体系、物品、事件、角色、关系 |
| `narrative` | 只关「怎么讲」、不关「讲什么」的叙事表达层 | 文风、节奏、技法、桥段、套路 |
| `knowledge` | 知识库域自身的资料资产结构(非知识实体的内容结构) | 参考作品档案 |
| `ai_context` | AI 上下文组装与输出合同的结构 | 生成上下文快照模具 |
有一处同词须点明target type `world`(世界观总纲,一个具体结构型)与 domain `world`(世界事实语义域)字面相同却分属两轴,前者是「型」、后者是「域」,不是一回事。
### 4.3 结构本体全清单23 型)
下表是与两轴对齐后的 target type 全清单,给的是归属、粒度与本体分组的总览;落地节奏由执行计划承载,本表只陈述设计本身。
| # | domain | scope | target_type | 中文名 | 本体分组 |
|---|---|---|---|---|---|
| 1 | content | work | `novel_work` | 作品容器 | 容器 |
| 2 | content | chapter | `chapter` | 章节容器 | 容器 |
| 3 | content | block | `scene` | 场景卡 | 容器 |
| 4 | content | work | `work_core` | 作品核心 | 作品骨架 |
| 5 | content | work | `outline` | 大纲 | 作品骨架 |
| 6 | content | work | `narrative_state` | 叙事状态 | 配套 |
| 7 | world | work | `world` | 世界观总纲 | 世界设定 |
| 8 | world | entity | `location` | 地点 | 世界设定 |
| 9 | world | entity | `faction` | 组织阵营 | 世界设定 |
| 10 | world | entity | `power_system` | 力量体系 | 世界设定 |
| 11 | world | entity | `item` | 物品 | 世界设定 |
| 12 | world | event | `event` | 事件 | 世界设定 |
| 13 | world | entity | `character` | 角色 | 人物 |
| 14 | world | relation | `character_relation` | 角色关系 | 人物 |
| 15 | narrative | work | `style` | 文风画像 | 画像 |
| 16 | narrative | work | `pacing` | 节奏画像 | 画像 |
| 17 | narrative | entity | `craft` | 叙事技法 | 技法 |
| 18 | narrative | entity | `combat` | 打斗桥段 | 技法 |
| 19 | narrative | entity | `emotion` | 情感桥段 | 技法 |
| 20 | narrative | entity | `scene_pattern` | 通用桥段 | 技法 |
| 21 | narrative | entity | `trope` | 套路 | 技法 |
| 22 | knowledge | entity | `reference_work` | 参考作品档案 | 系统侧 |
| 23 | ai_context | agent | `generation_context` | 生成上下文 | 配套 |
scope 落座有两处最易被误判,须点明。`outline``work` 而非 chapterscope 是实例集合的聚合归属粒度,大纲是全书单一计划,其内部的全书/卷/章三层用字段表达层级,不改 scope。`world``work`:按边界判据它不可数、无法单章出场,是每作品一份的单例总纲,故归 work 而非 entity。
三处命名也须点明。型 `chapter` 与 scope 值 chapter 同词不同轴(同 `world` 先例),前者是章节容器这个「型」、后者是粒度轴的一格。型 `scene` 落 scope=block 而不叫 blockBlock 的粒度已定为场景/小节级,型名取产品语义「场景卡」,并避免与 scope 值同义反复(与 `character_relation` 不叫 relation 同理);它与 `scene_pattern` 是实例与范式的关系——前者是本作品某一场戏的结构卡content 域后者是跨作品的场景公式narrative 域公共范式。「段落」不独立建模Muse 的正文最小编辑单元就是 Block段落的结构语义由 `scene` 承载。
补全后一个完备性事实值得留档scope 的七个取值work/chapter/block/entity/relation/event/agent每一格至少有一个型挂靠轴上不再有空格位。
### 4.4 精炼原则与拆分判据
**一型一格位、一概念一 owner**。一个候选要挣得独立 target type须同时占据「它是什么实体/计划/画像/装置/范式)× 谁在何时消费(规划/写作/检查期)」平面上的一个空格位,且承载邻接型收编不了的字段合同;否则按三条规则降级:差异只能举例、给不出判据的**变体收进枚举**(如拍卖会、比武会);属性可由他型查询导出的**投影收进读模型**(如时间线、关系网、伏笔看板);离开某型无独立生命的**附属收进字段**(如金手指例外)。这条准绳兑现「类型可更多但边界必清晰」——品类增型走同一判据,判据说不出一句就降级,杜绝为拆而拆。
判断「一个概念落一个还是多个 target type」用四条判据与上面三条降级规则叠加使用。**粒度判据**两个用场里实例挂靠的粒度不同work 级单例画像 vs entity 级多条目),倾向拆。**字段合同判据**:字段交集不足一半、或校验规则互斥,拆。**消费判据**:仅消费链路不同而字段相同,不拆,消费差异走可见性策略。**写入路径判据**:进 Canonical 的入口不同(用户直接命令 vs 确认链),倾向拆。四判据回答「拆不拆」,三条降级规则回答「不拆时降到哪一级」。
17 个结构骨架型(容器、系统侧、配套三个附组不计入)的边界判据如下,每型给最锋利的一句「纳入 / 排除去向」;完整字段合同落 [后端-04](后端-04-统一数据库Schema-v1.md),管理面见 [产品-02B-管理员控制台功能规格](产品-02B-管理员控制台功能规格.md)。
| target_type | 层次 | 边界判据(纳入 ‖ 排除→归哪) |
|---|---|---|
| `work_core` | 骨架 | 只有作者读者知、角色感知不到的作品级承诺 ‖ 角色可感知世界事实→`world`;逐章安排→`outline` |
| `outline` | 骨架 | 对「接下来写什么」的计划(全书/卷/章任一层级)‖ 戏内已定事实→`event`;跨作品公式→`trope` |
| `world` | 骨架 | 角色可感知但不可数、无法单章出场的世界级规则格局 ‖ 可指名出场者→`location`/`faction`/`item`;决定强弱排序→`power_system` |
| `location` | 骨架 | 可到达、可发生场景的具名空间 ‖ 弥漫地理→`world`;空间上的组织→`faction` |
| `faction` | 骨架 | 有宗旨/层级/成员的具名集体(含组织化种族、朝廷)‖ 无组织的文明背景→`world`;成员个体→`character` |
| `power_system` | 骨架 | 直接决定角色强弱排序的规则(可多套并存)‖ 约束所有人而不排序→`world`;具体功法技能→品类开放集增型 |
| `item` | 骨架 | 可持有可流转的具名物件(含秘籍载体)‖ 「怎么描写」的笔法→`style` 公共层 |
| `event` | 骨架 | 戏内时间轴上有参与者与因果的已定/预定事实(时间线=event 有序投影,不另设型)‖ 作者写作安排→`outline`;节奏分布→`pacing` |
| `character` | 双层 | 具名/可指认的行动主体,说话方式即该角色语言指纹 ‖ 作者全书指纹→`style`;关系→`character_relation`;集体→`faction` |
| `character_relation` | 骨架 | 演变轨迹本身就是剧情、值得立传的关系 ‖ 只更新当前值的结构性从属→Knowledge Relation 边+枚举;推进公式→`trope` |
| `style` | 双层 | 与章序无关的语言表层指纹(打乱章序不变)‖ 顺序敏感分布→`pacing`;单角色语言→`character` 说话方式 |
| `pacing` | 双层 | 顺序敏感的分布(值是曲线/比率,打乱章序即毁)‖ 单个爽点构成→`craft`;逐章安排→`outline` |
| `craft` | 双层 | 单点装置——删去它场景仍成立、读者体验变平;台账只收有跨章履约的装置 ‖ 承载整场戏→桥段三型;跨章公式→`trope` |
| `combat` | 公共 | 以武力/超自然力分胜负的对抗场景 ‖ 非武力博弈(商战/权谋/斗嘴)→`scene_pattern`;情绪弧主导→`emotion` |
| `emotion` | 公共 | 以情绪弧为主体的场景(告白/离别/爆发)‖ 武力分胜负→`combat`;关系实体本身→`character_relation` |
| `scene_pattern` | 公共 | 有「目标-推进-收束」完整结构的场景范式,枚举显式排除打斗与情感 ‖ 点装置→`craft`;跨场景公式→`trope` |
| `trope` | 公共 | 规定「这条线接下来几章怎么走」的公式 ‖ 单场景流程→`scene_pattern`;本作品自己的计划→`outline`(挂引用) |
四条外边界与型内判据同等效力:质量维度不进本体(模具与检尺分立,见 [专题-04](专题-04-生成质量门控与创作健康度设计方案.md)两者单向咬合、不共享定义叙事运行态的事实载体独立Narrative State 归 Content 聚合,见 [架构-02 §3 作品内容模型](架构-02-核心数据结构与双轨模型.md)`narrative_state` 型只是它的字段模具同容器型逻辑不把状态实例当知识实体管理RAG 文档面不进本体(上传的百科语料是检索素材,非实例,除非经确认链落成作品实体);品类特化与作品私有字段不进基础层,走开放集增型与作品级 override。
### 4.5 三层浇铸与双层型
结构本体按浇铸去向分三层:**结构骨架**只浇作品事实实例,入 Local KB 或规划;**公共参考**只浇跨作品范式,由系统级拆书入 Global KB只存抽象范式与脱敏例证、不留原文**双层**两处都浇(参考书的世界域实体演变另有落位,见 §6.4 参照作品面)。所有型共享名称/别名/摘要/标签等基础字段,各型只在此之上标注特有字段;世界域实体型(`location`/`faction`/`power_system`/`item`/`event`/`character`)在此之上共享**演变历程**元素({章, 台阶, 周期} 结构化里程碑,实验台已在六型实证;长线消费语义见 [专题-07 §5](专题-07-知识消费契约与质量闭环.md),字段合同落 [后端-04](后端-04-统一数据库Schema-v1.md))。
`character``style``pacing``craft` 四型是双层型。主案取**单模具双库**——一型一 schema作品面实例与公共范式共用同一字段合同公共面实例落 Global KB。是否为公共面另立独立的 `*_paradigm` 型,判据本身不变——三样本实测无损写入则不拆、字段合同不同构才拆。`craft` 已由拆书实验台以远超三样本的量级实测通过1326 条公共范式无损写入同一字段合同),**拍板不拆**`character`/`style`/`pacing` 依同一三样本判据在各自公共面首批落库时判定,无损即沿用单模具。判定机制至此闭合,不再是开放问题。
---
## 5. 作品容器与 base 内置机制
### 5.1 作品容器 novel_work 与 work_core 拆两 schema
作品容器不是新造的抽象。`muse_content_work` 已有一批固定列——归属用户、书名、品类、简介、状态、字数、章节数、导入与解析状态,并已预留可选关联 MetaSchema 的钩子;作品动态字段的修改入口也已预设校验 schemaVersion表与接口见 [后端-04](后端-04-统一数据库Schema-v1.md) 与 [后端-05 §4.3 作品和正文](后端-05-统一API契约-v1.md)。容器 schema 化是给这批既有固定列补上元描述,不是无中生有。
容器(`novel_work`)与作品核心(`work_core`)拆成两个 schema依据是四判据的字段合同与写入路径两条。字段合同不同构容器承载书名、简介、封面、状态、字数、时间这类运营与结构身份`work_core` 承载题材、主题立意、基调、禁区、结局方向这类创作承诺。写入路径不同:容器走用户直接修改加系统回算,`work_core` 走规划确认链。消费面不同:容器喂列表、卡片、导出与市场快照,`work_core` 喂全部开放智能体的上下文。
### 5.2 同体加投影storage_binding
容器 schema 与 Work 聚合的关系是**同体加投影,不派生**——不另建实例表避免双写漂移。schema 是对 Work 固定列的元描述与读投影模具:字段合同为每字段标注落点,`native_column`(物理列)、`extension_json`(扩展字段)或 `computed`(回算字段,用户不可编辑),这就是 `storage_binding` 机制,由 `muse_meta_field` 承载对固定列的元描述(属性落点见 [后端-04](后端-04-统一数据库Schema-v1.md)。它与「MetaSchema 不替代事实载体」一致([架构-02 §9](架构-02-核心数据结构与双轨模型.md))。一条权力边界必须写死:`native_column` 映射是元描述、不是数据通道——Meta 不因持有映射而获得读写 Content 表的任何权力,字段值的读写永远经值的 owner 自己的 API模具经 meta-api与值经 owner api在消费方组装Meta 侧不出现对他模块表的连接查询。卷章结构不进容器字段合同,按「投影收进读模型」降级,由 §7 统一读取器的作品分区输出。
容器组不止作品一级,`chapter``scene``narrative_state` 是同一机制在章节、Block 与叙事状态上的适用:固定列(章节标题/序号/字数、Block 归属与修订)走 `native_column` 元描述,创作侧结构字段走 `extension_json` 扩展。这不是凭空加需求——[专题-03 §4.2](专题-03-AI编排上下文与质量评测实现规范.md) 的近邻正文层早已把「章节目标、近期叙事状态」列为续写不可省略的输入,而章节表的固定列里并没有「目标」这个字段,章节容器的扩展字段(本章目标、伏笔清单、情绪曲线)正是这处既有设计欠账的落点;场景卡承载 POV、地点、出场角色、时间锚与情绪基调正文本身仍在 Blockschema 只管结构面。
### 5.3 base 是全局 active schema叠加与继承
`base` 不是新机制:**base 就是 `effective_scope=全局` 的 active schema 版本**,「内置」就是系统出厂 seed 的那批全局 schema。一个作品能看到的结构是三层叠加全局 active ⊕ 类型灰度版本 ⊕ 作品级 override。**override 只增不改**——只能扩展字段,不能删改全局字段的定义与可见性;这是 [架构-01](架构-01-系统全貌与边界上下文.md) BC 边界的推论,全局定义的主权不因作品扩展而被作品侧改写。
继承靠动态合成、不靠复制:系统不落 per-work schema 实例,读取时按投影版本动态合成。新作品开箱即有全部结构,因为全局 schema 天然生效、零初始化;容器上预留的品类包钩子默认空,即纯继承。
系统内置的 base schema 出厂全集是 23 项,分四档,每档的开箱形态与可见性基线如下。
| 档 | 开箱形态 | schema | 可见性基线 |
|---|---|---|---|
| 作品与结构身份档 | 开箱即有 | `novel_work``chapter``scene``work_core` | 可见、可编辑(容器与结构回算字段除外)、可进 AI 上下文(禁区/结局方向按需) |
| 创作骨架档 | 开箱即有、空实例 | `outline``world``location``faction``power_system``item``event``character``character_relation``narrative_state` | 可见/可编辑/可检索三者全开 |
| 画像与技法档 | 开箱即有画像;公共范式随 Global KB 绑定进入 | `style``pacing``craft``combat``emotion``scene_pattern``trope` | 作品面全开;公共面例证与出处字段的可见性待例证口径 |
| 系统侧档 | 用户不可见 | `reference_work`(仅管理面)、`generation_context`(运行时) | 不可见、不可编辑 |
---
## 6. 拆书与参考作品
### 6.1 一套模具,两处浇铸
拆书不是管理员专用的一次性管线,而是一个**通用抽取智能体**给定文本、MetaSchema 与目标库,按 target type 定义的实体类型解析出实体并入库(经 Shadow→Canonical。同一个智能体有两个用场产出结构都由 MetaSchema 决定,因此系统级参考库与作品级实体库天然共用一套结构。
- **系统级(管理员)**:把几十上百本参考书拆成小说公共属性范式,沉淀进全局知识库。产出的是抽象属性与技法范式,不是逐字原文(版权约束,且 [专题-03](专题-03-AI编排上下文与质量评测实现规范.md) 禁「作品资产模板化」)。
- **作品级(用户)**:每确认一章正文,拆本章新实体与关系入局域知识库的确认链;质量校验智能体同时查与既有事实的一致性。
拆书的入库不越过双轨——系统级入 Global KB 与作品级入 Local KB 都走 Draft→确认→Canonical接受 AI 候选只写正文、不自动确认知识(不变式见 [架构-02 §1 双轨模型](架构-02-核心数据结构与双轨模型.md))。全局公共库作为每个作品的默认可检索来源,但走**显式绑定、不自动注入**:用户或管理员把公共属性库绑定到作品后才进入检索选库集,用量归系统、口径可控。
### 6.2 参考作品档案 reference_work
系统级拆书要吃进的参考书本身得有个落处,即 `reference_work`domain=knowledge、scope=entity——一份系统侧的资料资产档案不是用户创作事实。它不复用作品表参考作品的 owner 是系统、生命周期是「采购→拆解→归档」,与用户创作的「起草→连载→完结」是两码事,塞进 Content 会违反「Content 只拥有正文和作品结构」的边界。库位落 Global KB 的 document 特化Knowledge BC复用既有的资料、版本与处理任务链表见 [后端-04](后端-04-统一数据库Schema-v1.md),知识库治理面见 [产品-02E-知识库工作台功能规格](产品-02E-知识库工作台功能规格.md));拆书任务本身归 AI BC是一类新的抽取任务 `reference_extraction`
字段合同分四组。**标识**:书名、作者、品类、别名。**来源**:上传、公开语料或授权采购。**版权授权状态**`licensed``public_domain``research_only``unauthorized` 四值,其中 `unauthorized` 失败关闭,不进任何拆书任务。**拆书处理状态**`pending``parsing``extracted``curated``failed`
### 6.3 范式溯源与版权传播
范式的溯源不新设 target type、不新建表复用知识域既有的来源 lineage、快照与状态事件机制见 [架构-02 §4.3 来源和 lineage](架构-02-核心数据结构与双轨模型.md))。每条进 Global KB 的范式 Canonical 记录带上来源快照引用与 lineage 载荷,指向它的 `reference_work`、拆书任务与章节定位摘要——**只存定位摘要,不存原文段落**(版权约束)。「某本书贡献了哪些范式」不是一张新表,而是按 lineage 的读模型投影。
当某参考作品的版权状态收紧,走既有的来源状态变化传播([架构-02 §11.3 来源状态变化](架构-02-核心数据结构与双轨模型.md)**阻断该来源派生范式的新使用,但不回滚已确认的 Canonical**,与知识域现有来源传播语义一致,不引入新机制。
系统级范式的入库走一条专门入口——「管理员确认系统级知识草稿 → Global KB 范式 Canonical」确认人是管理员产出走 Draft→确认→Canonical 同构链。该入口是双轨 Canonical 封闭枚举的一项,权威登记见 [架构-02 §1.2 进入 Canonical 的入口](架构-02-核心数据结构与双轨模型.md)。
### 6.4 参照作品面:参考书实体演变的浇铸位
系统级拆书除产出抽象范式外,还会按章推进沉淀参考书自身的实体演变卡——力量体系、物品、角色等世界域形态,带真实章号的 {章, 台阶, 周期} 里程碑。这类产物**不进 Global KB、不可被用户作品绑定**,浇铸位是**参照作品面**:挂在 `reference_work` 档案之下的系统侧中间资产knowledge 域),生命周期随参考作品档案的「采购→拆解→归档」走。它的正式用途只有两个:**回放评测的标准答案底座**与**叙事域范式的蒸馏源**(两个用途的定义见 [专题-07](专题-07-知识消费契约与质量闭环.md))。
进入 Global KB 的只能是蒸馏后的叙事域「成长曲线范式」(`trope`/`pacing` 形态:台阶间距分布、周期结构、爆发节奏),溯源与版权传播沿用 §6.3——只存定位摘要,不存原文。一句话钉死边界:**世界域形态是证据叙事域形态才是范式参照作品面存证据Global KB 只收范式。**
承载与治理三条钉死:参照作品面**不是第四类知识库**,不进 [架构-02 §4.1](架构-02-核心数据结构与双轨模型.md) 的三类库清单,用户不可见、不可检索;物理承载复用 §6.2 已定的 Global KB document 特化与资料处理任务链,父对象是 `reference_work` 档案,随档案「采购→拆解→归档」同生命周期;参考作品版权状态收紧时沿 §6.3 的来源传播语义处理——阻断其派生证据用于新的评测与蒸馏(均属新使用),不回滚已确认的范式 Canonical。
---
## 7. 统一创作数据读取器
`f(作品 + 元数据 + 知识库)` 里「读入」这一半,落在一个专门的读取层上。它**不是第四套读设施**,而是把既成的 Context Assembly[专题-03 §4 上下文组装](专题-03-AI编排上下文与质量评测实现规范.md))的取数面,显式分层为 AI Orchestration 内部的服务端读取器。它按 actor、作品与分区请求及授权上下文经各 owner 的 facade-api 拉数——Content 出作品容器、章节、正式规划与叙事状态Knowledge 出 Local KB 实体、关系、事件与绑定库检索Meta 出模具投影——再对每条数据套上该 target type 的 active MetaSchema 投影,做字段级 `aiContext` 裁剪、按 target type 结构化、附来源标注。模块边界从严:读取器是 AI 模块的内部组装件,不是新模块,它对各 owner 的依赖**只允许落在对方的 api 模块(具名只读端口)上,服务即 API**——每个分区的取数能力就是一个端口合同owner 没暴露的端口就先在其 api 模块补端口,绝不绕到对方 server 实现或数据表(模块依赖红线见 [后端-02 §3](后端-02-工程结构与模块职责.md)。Context Assembly 消费它完成分层组装与 Token 预算(分层定义见 [专题-03 §4.2 四层上下文](专题-03-AI编排上下文与质量评测实现规范.md));检测、导出预检等非生成链路复用同一读取器,不各自重写取数与裁剪。
它与用户面的 meta-projections **同源不同面**:共享模具投影的计算,不共享裁剪策略——用户面按 `uiVisible`/`userEditable`AI 面按 `aiContext` 裁,两者互不蕴含([架构-02 §9](架构-02-核心数据结构与双轨模型.md))。一条负约束必须写死:读取器只作为服务端内部端口存在,**不暴露为通用 app API**,否则它会成为与「通用写 SDK」对称的滥用面。
读统一、写各 owner。写侧零新设计——动态字段没有跨 owner 的通用写接口,前端禁通用写 SDK[前端-03 §5.2](前端-03-元引擎与动态表单.md)Canonical 入口是封闭枚举,智能体产出只进 Shadow这些约束一条不改。统一读取器对写的唯一贡献是沿用动态字段校验接口「校验加路由建议、不写入」的语义把「智能体想改哪个字段」翻译成「该走哪个 owner 的命令」。
**最小读合同**
- **输入**六项actorworkId分区请求集作品容器 / 规划 / 按 target_type 与 scope 过滤的 Local KB 实体 / 仅限已绑定的 Global KB 公共范式对齐「显式绑定不自动注入」运行权限包引用其分区许可上限只能收紧不能放宽purposegeneration/detection/planning/extraction授权按用途裁对齐 [专题-03 §4.3 来源和授权](专题-03-AI编排上下文与质量评测实现规范.md) 的「允许阅读不等于允许进 AI 上下文」);期望 schemaVersion可选防任务中途版本漂移
- **输出**:分区化的结构块列表,每块含 `targetType``schemaKey``schemaVersion`、已按 `aiContext` 裁剪的结构化字段值、`dataRevision`、来源标注(对齐 [专题-03 §5.3 检索结果合同](专题-03-AI编排上下文与质量评测实现规范.md)),以及 `omittedFields` 及原因。
裁剪分三级、同时生效:**字段级**`aiContext` 不含本次 purpose 的字段剔除——`false` 即任何用途不入、`true` 即全用途可入,值域权威见 [架构-02 §9](架构-02-核心数据结构与双轨模型.md))、**来源级**(状态受限的来源整块不进)、**用途级**(来源授权 `allowedPurpose` 不含本次 purpose 的剔除)。任一级判定剔除,数据即不进上下文。受限来源整块进 `omittedSources`,失败关闭并留痕可审计。
### 7.1 `generation_context` 的正文生成登记
`generation_context`domain=`ai_context`、scope=`agent`)在正文实验中启用 generation purpose 的严格 schema 投影。这里仅登记 MetaSchema 结构入口,不复制邻册合同:
| 登记项 | MetaSchema 约束 | 唯一 owner |
|---|---|---|
| purpose | 固定枚举值 `generation`;不接受别名、空值或未知值 | purpose 值域与字段级 `aiContext` 语义仍见 [架构-02 §9](架构-02-核心数据结构与双轨模型.md) |
| schema / output 版本 | 必填、精确匹配;缺字段与未知字段失败关闭 | `WriterContext v1` / `WriterOutput v1` 见 [专题-03 §4.5](专题-03-AI编排上下文与质量评测实现规范.md) |
| cardIndexView | 只登记抽取卡的索引视图与来源引用,不承载原文正文 | “卡是索引、根据来源回读原文”见 [专题-07 §2.1](专题-07-知识消费契约与质量闭环.md) |
| factEvidence / proseEvidence | 两个独立字段组,禁止互相冒充或合并成通用 evidence | 双证据字段、哈希和冻结规则见 [专题-03 §4.5](专题-03-AI编排上下文与质量评测实现规范.md) |
| 正式事实来源 | 正式设定、Canonical 状态和细纲新事实保留各自不可变版本引用,不强造抽取卡或历史原文来源 | 权威来源分类见 [专题-07 §2.1](专题-07-知识消费契约与质量闭环.md) |
该登记先约束实验台 schema 和上下文投影,不代表产品 API、数据库结构或正式 MetaSchema 种子已经变更;产品化必须等正文 Gate B 通过后另立契约与迁移计划。
---
## 8. 关联阅读
| 主题 | 权威文档 |
|---|---|
| MetaSchema、domain/scope、双轨不变式、来源 lineage、Canonical 入口枚举 | [架构-02-核心数据结构与双轨模型](架构-02-核心数据结构与双轨模型.md) |
| BC 边界与协作规则、override 只增不改的边界推论 | [架构-01-系统全貌与边界上下文](架构-01-系统全貌与边界上下文.md) |
| AI 编排、Context Assembly、检索合同、质量评测 | [专题-03-AI编排上下文与质量评测实现规范](专题-03-AI编排上下文与质量评测实现规范.md) |
| 质量维度定义与创作健康度 | [专题-04-生成质量门控与创作健康度设计方案](专题-04-生成质量门控与创作健康度设计方案.md) |
| 知识消费选择契约、知识质量三性、回放评测、长线进度消费语义 | [专题-07-知识消费契约与质量闭环](专题-07-知识消费契约与质量闭环.md) |
| 外部 Agent 统一协议与 adapter | [专题-05-AI统一交互协议与外部AgentAdapter设计](专题-05-AI统一交互协议与外部AgentAdapter设计.md) |
| MetaSchema 表、功能链表、reference_work 表、storage_binding 落点 | [后端-04-统一数据库Schema-v1](后端-04-统一数据库Schema-v1.md) |
| 作品与动态字段接口契约 | [后端-05-统一API契约-v1](后端-05-统一API契约-v1.md) |
| 拆书公共属性本体管理面与参考作品治理 | [产品-02B-管理员控制台功能规格](产品-02B-管理员控制台功能规格.md) |
| 知识库三库两面与全局库绑定 | [产品-02E-知识库工作台功能规格](产品-02E-知识库工作台功能规格.md) |

View File

@ -0,0 +1,225 @@
# 专题-07知识消费契约与质量闭环
- 版本v2
- 更新日期2026-07-20
- 目标读者:产品 / 架构 / 后端 / 测试
- 边界说明:本册是「知识效用」这条横切主线的单一 owner——回答「该读什么知识、注入后有没有用、知识本身好不好」收束四件此前无主的事知识消费选择契约含按用途默认合同与注入视图、知识质量三性、回放评测、长线进度消费语义文末附实验台实证附录。以下邻册对象本册只链接、不重复定义Context Assembly 分层与 Token 预算见 [专题-03 §4](专题-03-AI编排上下文与质量评测实现规范.md)、检索结果合同见 [专题-03 §5.3](专题-03-AI编排上下文与质量评测实现规范.md)、统一创作数据读取器读合同见 [专题-06 §7](专题-06-元数据驱动的智能体架构.md)、输出侧质量维度见 [专题-04](专题-04-生成质量门控与创作健康度设计方案.md)、结构本体与字段合同见 [专题-06 §4](专题-06-元数据驱动的智能体架构.md) 与 [后端-04](后端-04-统一数据库Schema-v1.md)、Narrative State 与双轨见 [架构-02](架构-02-核心数据结构与双轨模型.md)。核心主张一句话:**知识的价值只在被选中并改善产出——写作、检测、规划三个效用时刻——的那一刻兑现;质量标准必须由消费端定义、由回放评测测量——以用定卡。**
- 变更记录v22026-07-20在 §2.1 固化正文消费的「卡是索引、按来源回读冻结原文」契约并区分抽取卡证据与正式设定、Canonical 状态、细纲新事实三类独立权威来源。v12026-07-17建立知识消费选择、质量三性、回放评测和长线进度消费语义。
---
## 0. 术语桥
理解本册的钥匙:**SoT 里没有「卡」这个一级概念**。产品与实验台口语里的「知识卡片 / 卡」是一组权威载体的统称,本册全部论述落到这组载体上,不新造模型。
| 口语说法 | SoT 权威载体 | owner |
|---|---|---|
| 待确认的卡 | Knowledge Draft待审、默认不可信 | [架构-02 §4.2](架构-02-核心数据结构与双轨模型.md) |
| 本作正式事实卡 | Local Knowledge Entity · Relation本作正式实体/关系) | [架构-02 §4.2](架构-02-核心数据结构与双轨模型.md) |
| 公共参考卡 | Global KB 范式 Canonical跨作品公共参考 | [架构-02 §1.2 / §4.1](架构-02-核心数据结构与双轨模型.md) |
| 卡的结构模具 | MetaSchema target_type23 型) | [专题-06 §4.3](专题-06-元数据驱动的智能体架构.md) |
两个实验台口语也一次性登记,下文实证引用沿用:**范式卡** = 技法五型的公共参考卡(进 Global KB**升格卡** = 世界域型的实体演变卡——产品内是本作事实卡(进 Local KB实验台以参考书彩排时其产物落参照作品面见 §5
登记:**拆书实验台agent-example仓内实验目录是本册与专题-06 的排练场**——设计不等于进度,实验台证据只作本册论断的实证输入,不代表产品已实现。
---
## 1. 第一性原理:知识是外置记忆,缺的是效用闭环
一句话钥匙:**长篇创作的一切质量问题,本质都是记忆问题;知识库就是这套系统的外置记忆。**
模型上下文有限且没有持久状态,百万字长篇里吃设定、前后矛盾、角色失声、升级线错乱,根子都是「该记住的没被带回来」。抽卡的唯一目的,是让知识在未来某次生成里被选中、被注入、改善产出。记忆兑现价值的时刻只有三个,与智能体分工一一对应([专题-06 §3](专题-06-元数据驱动的智能体架构.md))。
| 效用时刻 | 谁在用 | 用什么 |
|---|---|---|
| 写作时注入 | 写作智能体 | 实体当前态 + 技法范式参考 |
| 检测时对照 | 检测智能体 | Local KB 当标准,查候选是否吃设定(对应 `canon_compliance` |
| 规划时排线 | 规划智能体 | 演变历程 + 参照书成长曲线,排下一个台阶 |
产品承诺就锚在这三个时刻上。[产品-01](产品-01-产品定位与核心价值.md) 的核心卖点原话是「已确认知识在下一次生成时真正回到上下文」,叙事质量承诺是「角色弧光、情节推进、悬念维护和章节张力」不退化——兑现这两句承诺的机器正是本册。
缺环在哪:**治理侧全线完备,效用侧全线空缺。**
| 侧面 | 问的问题 | 现状 |
|---|---|---|
| 治理(能不能读) | 授权 / 来源 / 双轨 / 裁剪 / 预算 | 钉死(专题-03、专题-06 §7、架构-02 |
| 效用(该不该读、好不好) | 这一幕该选哪些知识、什么算一条好知识、注入后有没有用 | 无 owner、无定义、无测量 |
最锋利的一条必须写死——**记忆腐坏时,输出端门控不但补偿不了,还会放大伤害**。检测维度 `canon_compliance` 拿 Local KB 当对照标准来拦候选([专题-04 §4.2](专题-04-生成质量门控与创作健康度设计方案.md)):知识错了,门控会把写对的候选当成「吃设定」拦下来。所以输入端质量不是锦上添花,是输出端门控成立的前提。
---
## 2. 知识消费选择契约
一句话钥匙:**读取器([专题-06 §7](专题-06-元数据驱动的智能体架构.md))解决「能不能读 + 怎么裁字段」,本节补「该读哪几条」——按知识形态分两类合同,选择键不同、注入形态不同。**
| 知识形态 | 选择键(该读哪几条) | 注入什么 | 不做什么 |
|---|---|---|---|
| 本作事实Local KB 实体/关系/事件) | 在场召回(当前场景出场实体 + 其关系闭包)+ 相似召回兜底 | 实体**当前态**摘要 | 不注入全演变历程——历程按 `aiContext` 用途裁剪,续写不给(实验台实证:演变历程标 [detection, extraction],防上万字明细撑爆上下文) |
| 公共范式Global KB 技法五型 `craft`/`combat`/`emotion`/`scene_pattern`/`trope` | 结构化匹配:型 × 场景意图(打斗/情感/通用桥段)× 线程弧位(`trope` 管「这条线接下来几章怎么走」)× 品类 | 规划期选定的范式引用 | **不靠相似度临场海选**——范式描述与正文语域不同,实验台实证同型范式向量相似 74% 多为词汇假近,语义真并率仅约 10% |
口径说明:上表「公共范式」行指写作期的技法范式;`style`/`pacing` 的跨作品公共面(含参照作品面蒸馏出的成长曲线范式,见 §5按 planning 行的结构键召回,不走写作期注入。
范式进入生成上下文的正路是「**规划期决策、写作期引用**」,不是写作时临场向量海选。规划产出必须显式挂所引范式的引用,形成「规划决策 → 写作引用」链;写作期只按引用注入,不重新检索范式。
**按用途的默认合同**——统一读取器 purpose 四值([专题-06 §7](专题-06-元数据驱动的智能体架构.md))逐一钉死默认选择;分层次序与 Token 预算的上位规则见 [专题-03 §4.2](专题-03-AI编排上下文与质量评测实现规范.md),本表只定「该选哪几条」:
| purpose | 默认注入 | 明确不注入 |
|---|---|---|
| generation续写/场景写作) | 在场实体(当前章出场 + 一跳关系)的当前态**摘要视图**;规划期已决策引用的公共范式(上限 3 条,全文视图);文风/节奏画像 | 范式临场海选;演变历程全线 |
| planning规划/排线) | 目标线程实体的演变历程全线大纲与作品核心Global 成长曲线范式(型 × 品类结构键召回,相似度只作兜底) | — |
| detection一致性检测 | 在场实体当前态全量 + 关系闭包 + 事件时间线 | 公共范式(检测不需要「怎么写」) |
| extraction抽取/维护知识) | 该型模具全字段视图 | — |
```mermaid
flowchart LR
subgraph P["消费用途 purpose"]
direction TB
G["写作 generation"]
D["检测 detection"]
PL["规划 planning"]
X["抽取 extraction"]
end
subgraph F["知识形态 → 选择键"]
direction TB
L["本作事实Local KB<br/>在场召回 相似兜底"]
C["公共范式Global KB 技法五型)<br/>× 场景意图 × 线程弧位 × 品类"]
end
subgraph V["注入视图(≠ 存储形态)"]
direction TB
VS["摘要 / 全文两档"]
VR["排序:结构匹配度 效用统计"]
end
P -->|按 aiContext 用途裁深浅| F
L --> V
C -->|规划期决策·写作期引用| V
```
**联合与冲突**:来源优先级全链对齐 [产品-01](产品-01-产品定位与核心价值.md)——本作事实Local KB 用户绑定知识User KB 与已安装知识库)> 全局与市场参考Global KB 范式、市场资产摘要),冲突时低优先级来源不得替代高优先级事实;范式只提供「怎么写」,不得覆盖「是什么」。
**卡粒度预算**:进入上下文的是**注入视图,不是整条存储记录**。注入视图分两档——摘要视图(名称 + 一句话摘要 + 当前态要点)是默认档,焦点实体升全文视图(按 `aiContext` 裁剪后的字段全量)。排序信号 = 结构匹配度(在场 > 关系一跳 > 相似兜底;范式取型 × 场景意图 × 品类的精确匹配优先)+ 效用统计(被命中率 / 被接受率 / 跨书频次);预算内截断落 omittedSources截断规则归 [专题-03 §4.2](专题-03-AI编排上下文与质量评测实现规范.md)。实验台实证两端病并存——p50 卡体仅 395 字符(薄到无肉可注)与单卡 104KB角色卡流水累积一张即撑爆预算——这证明注入视图必须是一份独立合同不能等于存储形态。
### 2.1 正文消费的卡索引与原文回读契约
正文生成再加一条不可绕过的消费规则:**卡是索引,根据抽取卡记录的来源回读原文;卡不能替代原文。** 卡负责缩小检索范围、指出相关实体与历史状态,冻结线内的历史原文负责证明人物声音、动作习惯、能力表现和叙事质感。读取器不得把卡片正文直接倾倒给写手,或把抽取摘要伪装成历史原文证据。
| 来源类型 | 进入正文上下文的条件 | 证据落点 |
|---|---|---|
| 由历史正文抽取的卡 | 必须携带 `sourceVersion``sourceRefs` 和按冻结点重建的 `stateAsOf`;系统沿 `sourceRefs` 回读 `chapter <= asOf` 的原文并记录章节、Block、字符区间与内容哈希 | 卡只保留索引视图;事实约束与写法参考分别进入 [专题-03 §4.5](专题-03-AI编排上下文与质量评测实现规范.md) 的双证据字段 |
| 无可追踪原文来源的抽取卡 | 只能作为 `unverifiedIndexHint` 暴露缺口,不得单独支撑正文硬事实 | 进入省略/缺口清单,不进入可采信事实证据 |
| 作者确认的正式设定 | 直接引用其不可变正式版本,不要求伪造历史原文来源 | 独立权威事实证据 |
| Canonical 状态 | 直接引用当前冻结点可见的正式状态版本,不要求抽取卡二次背书 | 独立权威事实证据 |
| 细纲声明的本章新事实 | 以已确认细纲版本为权威,标记 `declared_new`;它不是历史事实,不得强造原文来源 | 独立权威事实证据与新设定申报 |
卡片与其来源是否落在冻结线内,只按 [专题-03 §4.5](专题-03-AI编排上下文与质量评测实现规范.md) 的 `RetrievalManifest` 和冻结规则判断;本册只拥有“选卡后必须回读什么”以及哪些正式事实不依赖抽取卡的消费规则。
---
## 3. 知识质量三性(输入侧质量维度)
一句话钥匙:**三性是输入侧检尺,与专题-04 的输出侧维度同构——模具与检尺分立、单向咬合([专题-04 §4](专题-04-生成质量门控与创作健康度设计方案.md));输出维度检「写出来的字好不好」,三性检「拿去写的知识好不好」。**
| 质量性 | 定义 | 失败实证(实验台) | 可测指标建议 |
|---|---|---|---|
| **可命中** | 该被想起时能被检索到:命名鲁棒(别名表)+ 向量覆盖 + 结构键齐全 | 升格卡命名脆,同一实体断成两条成长线(「生物机甲」与「铁头」分家);升格卡向量覆盖为 0尚未接入检索 | 别名覆盖率(有别名表的实体卡占比)、向量覆盖率(有活向量的活卡占比)、同实体分裂率(抽样审计中同一真实实体被建成多条卡的比例) |
| **可行动** | 注入后能直接改善产出,而非一段分析散文 | 升格台阶粒度过长、单位事件串进体系卡;实体卡缺「当前态摘要」 | 注入视图尺寸达标率(落在该型登记上限内的占比)、字段充实度(对照该型字段合同的非空率) |
| **可持续** | 增量维护下不腐坏:判重「同名异质不并、真同才并」、演变归并有序、溯源完备 | 向量相似 74% 多为同型词汇假近、真并率仅约 10%——「同名异质不并」是对的(宁缺勿滥) | 重复率(消费端召回中判为同一知识的比例)、演变断线率(抽样审计中同实体演变断成多线的比例)、溯源完整率(可回溯到来源与归并审计的卡占比) |
范式卡字段形态已较好(公式 / 启动条件 / 失效风险 / 张力来源),实体卡的短板是缺当前态摘要;可持续还要求演变按真实章号排序、溯源可回溯可撤销。
**单实例长尾**必须点明:实验台实证范式卡 p50 实例数 = 1——「只被观察到一次的范式」只是观察不是范式。跨书频次是范式可信度的核心信号消费端效用统计命中 / 接受)是最终裁决。**质量三性的裁决权在消费端,不在抽取端。**
三性指标的阈值不拍脑袋由回放评测§4首轮基线确定此后进入质量策略生命周期管理draft → evaluating → active见 [专题-04 §9.2](专题-04-生成质量门控与创作健康度设计方案.md)),与输出侧维度同一套治理节奏。
---
## 4. 回放评测:质量的测量闭环
一句话钥匙:**参考书本身就是标准答案。** 冻结第 N 章时点的知识状态 → 按 §2 契约组装上下文 → 跑规划/续写/检测 → 与原书第 N+1 章(或后续台阶)对照评分,把知识质量从口味变成数字。
| 评测线 | 怎么跑 | 评什么 |
|---|---|---|
| 规划线 | 用第 N 章知识预测下一台阶 / 伏笔回收,对照原书实际走向 | 台阶预测命中、伏笔回收命中 |
| 生成线 | 有卡 vs 无卡 vs 打乱卡 的对照生成 | 设定一致性、要素覆盖 |
| 检测线 | 人工向候选注入错误 | 错误检出率 |
评的是整体:**知识质量 × 选择策略 × 提示词共同决定回放得分,必须变量控制对照才能归因**。落位上,回放评测是 [专题-04 §10](专题-04-生成质量门控与创作健康度设计方案.md) 离线评估框架内的一类评估——该框架的评估输入已含 Context Assembly Strategy本册补「知识策略」这一评估对象维度。样本合规两道闸**授权状态闸**对齐 [专题-06 §6.2](专题-06-元数据驱动的智能体架构.md) 的 `reference_work` 授权四值,`unauthorized` 失败关闭、不进评测;**评测用途闸**——版权状态不等于评测许可,样本进入评测必须绑定不可变授权快照且 `allowedPurpose` 包含离线评测(对齐 [专题-03 §4.3](专题-03-AI编排上下文与质量评测实现规范.md) 「授权按用途执行」与 [专题-04 §10](专题-04-生成质量门控与创作健康度设计方案.md) 的允许样本四类),`research_only` 只限内部评测、禁外发。评测产物只存评分、摘要与章节定位,不留存原书全文。
由此立一条门禁:**知识策略(选择契约参数、注入视图定义、判重规则)的变更,未经回放对照评测不得上线、不得声称改善质量**——对齐 [专题-04 §9.2](专题-04-生成质量门控与创作健康度设计方案.md) 策略生命周期的 evaluating → active评测未过就没有 active。
**知识策略Knowledge Strategy是一个正式策略对象**,与 Quality Policy 同栖 AI 编排的策略面、同一套治理节奏:内容 = 选择契约参数§2 默认合同的可调参数)+ 注入视图定义(各型两档视图与尺寸上限)+ 判重规则§3 可持续判据);生命周期 = draft → evaluating → active[专题-04 §9.2](专题-04-生成质量门控与创作健康度设计方案.md));每次评测与生成任务记录其版本引用(评测输入登记见 [专题-04 §10](专题-04-生成质量门控与创作健康度设计方案.md)),回滚只影响后续任务、不改写历史结果。
拆书实验台是回放评测的第一个排练场8 本约 1.18 万章)+ 卡(范式 6327 / 升格 2260+ 章级锚点(实例 / 里程碑带真实章号)三要素已齐,是唯一能把卡质量落成数字的地方。
```mermaid
flowchart LR
Prod["生产<br/>拆书抽取 · 23 型模具"] --> Gov["治理<br/>双轨确认 · 授权/来源"]
Gov --> Sel["选择<br/>§2 消费契约<br/>冻结第 N 章知识状态"]
Sel --> Inj["注入<br/>§2 注入视图组装上下文"]
Inj --> Gen["生成 / 检测 / 规划"]
Gen --> Cmp["对照原书<br/>第 N+1 章 / 后续台阶"]
Cmp --> Score["评分<br/>规划线 / 生成线 / 检测线"]
Score -. 反哺卡质量三性 .-> Prod
Score -. 反哺选择策略 .-> Sel
```
---
## 5. 长线进度消费语义(演变历程如何被用)
一句话钥匙:**长篇(尤其升级流品类)的骨架是实体演变——力量体系、装备、角色的台阶推进;载体已在,缺的是消费语义。**
载体 = Narrative State[架构-02 §3](架构-02-核心数据结构与双轨模型.md),作品/章节/实体三维叙事运行态)+ 演变历程字段。实验台已在六个世界域型上实证 {章, 台阶, 周期} 的结构化里程碑。演变历程分三期消费。
| 消费期 | 用谁 | 怎么用 |
|---|---|---|
| 规划期 | 本作历程 + 参照曲线(拆书沉淀的跨书台阶间距/周期分布) | 排下一台阶的时机与幅度 |
| 检测期 | 当前态对照 | 新候选不得与当前台阶矛盾 |
| 写作期 | 只给当前态摘要 | 全历程默认不进(`aiContext` 用途不含 generation |
**参照作品面**:参考书的实体演变卡落参照作品面——系统侧证据资产,不进 Global KB、不可被作品绑定定义、承载与版权治理见 [专题-06 §6.4](专题-06-元数据驱动的智能体架构.md)。本册只消费它的两个用途①回放评测的标准答案底座§4②蒸馏源——进入 Global KB 的只能是蒸馏后的叙事域「成长曲线范式」(`trope`/`pacing` 形态),供规划期作参照曲线。
---
## 6. 实验台实证附录
一句话钥匙:**实验台是排练场;下表只陈述已被 9 批实拆检验的事实,及各事实结论的唯一 owner 分册。**
| 实验台已实证 | 结论归属分册 |
|---|---|
| 23 型结构本体实战可用 | [专题-06](专题-06-元数据驱动的智能体架构.md) |
| `aiContext` 按 4 用途planning/generation/detection/extraction字段级裁剪可落地 | [架构-02 §9](架构-02-核心数据结构与双轨模型.md)(值域已升级为布尔或用途集)/ [专题-06 §7](专题-06-元数据驱动的智能体架构.md) |
| 演变历程结构化里程碑 {章,台阶,周期} | [专题-06 §4.5](专题-06-元数据驱动的智能体架构.md) / [后端-04](后端-04-统一数据库Schema-v1.md) 字段合同 |
| 判重判据「同名异质不并」 | 本册 §3 |
| 单实例长尾与跨书频次信号 | 本册 §2 / §3 |
| 升格卡注入视图两端病395 字符 / 104KB | 本册 §2 |
| 参考书实体演变卡可产、可逆、带真实章号锚点(参照作品面的可行性底座) | [专题-06 §6](专题-06-元数据驱动的智能体架构.md) / 本册 §5 |
---
## 7. 验收清单
本册闭合后必须满足以下可判定条款:
1. 任何新增知识型进入 Global KB 检索集前,必须同时给出 §2 的选择键(怎么被选中)与注入视图定义(注入什么形态),二者随该型字段合同登记,缺一不得入检索集。
2. 进入生成上下文的必须是注入视图,不得是整条存储记录;各型注入视图的尺寸上限随字段合同登记,超限判不合格、不注入。
3. 公共范式进入生成上下文的路径只有「规划期决策、写作期引用」——规划产出必须携带所引范式的引用链,写作任务记录必须能回指该引用;写作期对范式做临场相似度海选的链路判违规。
4. 回放评测在某作品/品类跑通前,涉及该范围的知识策略变更不得对外声称「提升质量」;质量声明必须附回放运行标识(评估集版本 + 知识策略版本 + 三线得分)与变量控制说明。
5. 判重遵循「同名异质不并、真同才并」;每次归并必须留存可判定的并类判据与被并卡快照(归并审计),缺审计的归并判违规(宁缺勿滥)。
6. 演变历程默认不进写作期上下文(`aiContext` 用途不含 generation违反即记为撑爆预算风险项。
7. 参考书样本进入回放评测前必须同时通过授权状态闸(`reference_work` 四值,`unauthorized` 失败关闭)与评测用途闸(授权快照 `allowedPurpose` 含离线评测);评测产物不得留存原书全文。
8. 卡的每个结构部件(检索键 / 注入体 / 演变链 / 溯源)的消费者以字段级 `aiContext` 用途标注为准;无任何用途标注且不承担溯源/审计治理职能的部件按赘肉裁撤,不随卡进上下文。
---
## 8. 关联阅读
| 主题 | 权威文档 |
|---|---|
| Context Assembly 四层与 Token 预算、检索结果合同、质量评测输入合同 | [专题-03-AI编排上下文与质量评测实现规范](专题-03-AI编排上下文与质量评测实现规范.md) |
| 输出侧质量维度、离线评估框架 | [专题-04-生成质量门控与创作健康度设计方案](专题-04-生成质量门控与创作健康度设计方案.md) |
| 统一读取器读合同、结构本体 23 型与三层浇铸、拆书两处浇铸、`reference_work` 授权四值 | [专题-06-元数据驱动的智能体架构](专题-06-元数据驱动的智能体架构.md) |
| 双轨模型、Narrative State、三类知识库、MetaSchema `aiContext` 控制项 | [架构-02-核心数据结构与双轨模型](架构-02-核心数据结构与双轨模型.md) |
| 知识来源优先级(本作事实高于公共参考)、核心价值承诺 | [产品-01-产品定位与核心价值](产品-01-产品定位与核心价值.md) |
| 逐型字段合同 | [后端-04-统一数据库Schema-v1](后端-04-统一数据库Schema-v1.md) |

View File

@ -0,0 +1,157 @@
# 专题-08自动化测试方案
- 目标读者:创作者 / 产品 / 架构 / 前端 / 后端 / 测试 / Agent 实现者
- 阅读时间25-40 分钟
- 边界说明本文件是贯穿四仓muse-cloud / muse-admin / muse-studio / muse-design-docs的**测试可判定性**横切 SoTowns 三个概念——测试金字塔分层、确定性/语义分界判据、语义评测方法论。它**不重定义**任何状态机、Schema、API 或质量维度:那些的单一归属仍在各自分册(见各节引用),本册只规定"它们必须满足什么条件才算可测、以及用什么手段验证"。
## 1. 定位与非目标
### 1.1 定位
Muse 的可测性呈两极:
- **约 95% 的系统是确定性机器**——状态机、双轨写路径、事务、幂等、鉴权、协议外壳、不丢稿。其对错**不看正文含义、只比对结构与数值**即可判定,用经典自动化测试钉死,是自动化率 > 90% 的主体。
- **决定"这段 AI 写得能不能用"的语义内核**——正文质量、一致性判定、知识效用——本质上必须靠多模态大模型评判,不能用"通过/失败"硬断言,只能用对照实验与盲评量表。
本册给出:四层测试金字塔、一条确定性/语义分界判据、三套语义评测机制,以及每个功能"要可测必须提供什么"的可判定性合同。
### 1.2 非目标
- 不定义具体业务状态机、表结构、端点、错误码、质量维度——归属见各节引用的 owner 分册。
- 不替代 `docs/mvp/` 的进度总账与覆盖 JSON本册定义"测什么、怎么验",覆盖 JSON 是"哪些已登记可机械核对"的执行台账。
- 不记录调试经过、某次测试结果或开发状态。
## 2. 测试金字塔(四层)
越往下越贵、越往上越该多。每一层的用例都从现有 SoT 合同反推。
| 层 | 验证对象 | 规模/成本 | 手段 | 合同 owner本册只引用 |
|---|---|---|---|---|
| **L1 数据不变式 / 状态机** | 双轨写路径、状态迁移合法性、唯一约束、快照不可变 | 多而密、最廉价 | 后端单测 + 真 PG 集成测试 + ArchUnit | [架构-02](架构-02-核心数据结构与双轨模型.md)(双轨不变式)、[架构-04](架构-04-状态机与约束清单.md)(状态机)、[后端-04](后端-04-统一数据库Schema-v1.md)(约束) |
| **L2 接口契约 / 事务 / 幂等** | 错误码、乐观锁、鉴权边界、事务回滚、SSE 框架、版本头 | 多、廉价 | 契约测试 + 集成测试 + 故障注入 | [后端-05](后端-05-统一API契约-v1.md)API 契约)、[后端-03](后端-03-关键流程实现与接口契约.md)(事务/事件) |
| **L3 前端交互 / 不丢稿** | 编辑器保存、影子层冲突、建议接受、动态表单、SSE 重连 | 中 | Playwright E2E + 前端单测 | [前端-02](前端-02-编辑器与影子层交互.md)、[前端-03](前端-03-元引擎与动态表单.md)、[专题-01](专题-01-正文建议接受(Accept%20Suggestion)实现规范.md) |
| **L4 AI 语义质量** | 正文质量、一致性判定、知识效用 | 少而精、最贵 | LLM 评委 + 对照实验 + 回放评测 | [专题-04](专题-04-生成质量门控与创作健康度设计方案.md)(质量维度/量表)、[专题-07](专题-07-知识消费契约与质量闭环.md)(知识效用/回放) |
**分层纪律**L1L3 必须占自动化用例的绝大多数L4 数量少、慢、贵,但测的是产品命根子,绝不用 L1L3 的"通过/失败"思路去做(见 §5
## 3. 确定性 / 语义分界判据
唯一判据:
> **这件事的对错,能不能不看正文含义、只比对结构和数值就知道?能 → 确定性(机械断言);不能 → 语义(大模型评判)。**
### 3.1 分界总表
| 测什么 | 类别 | 验证手段 |
|---|---|---|
| 状态迁移、枚举值域 | 确定性 | 状态图遍历 + 断言 |
| 双轨写路径隔离AI 不直写正文事实层) | 确定性 | 写路径审计测试 |
| 事务原子 / 乐观锁 / 幂等 | 确定性 | 集成测试 + 故障注入 |
| 鉴权 / admin·app 入口隔离 | 确定性 | 矩阵化权限测试 |
| 协议外壳 / 哈希绑定 / fail-closed | 确定性 | 契约测试 |
| 不丢稿 / 冲突不静默覆盖 | 确定性 | Playwright 杀进程重载 |
| 一致性"两段设不设定冲突" | 混合 | 定位/绑定部分断言 + 判定部分 LLM 评委 |
| 正文质量五维打分 | 混合 | 确定性流程 + 盲评大模型打分 |
| 知识回放对照 | 混合 | 确定性框架 + 对照评分 |
| 风格 / 节奏 / 可行动性 | 纯语义 | LLM 评委 / 对照实验,不下硬结论 |
### 3.2 混合体的处理纪律
混合体(外壳确定 + 打分语义)是最易做错的一类:**外壳必须单测,内核必须大模型**,二者不可互相替代。
- **一致性检测**owner [专题-03](专题-03-AI编排上下文与质量评测实现规范.md)):引用能否唯一定位、哈希是否绑定、`unknown` 是否带 gapReason——确定性断言"claims/事实冲突 verdict"——LLM 判定。
- **正文离线实验**owner [专题-04 §10](专题-04-生成质量门控与创作健康度设计方案.md)):双盲双评流程、"单维差 >0.5 加第三评""取稳定配对中位数""样本 <5 判证据不足"——确定性规则五维打分——盲评大模型
- **知识回放**owner [专题-07 §4](专题-07-知识消费契约与质量闭环.md)):冻结第 N 章、按契约组装、对照第 N+1 章——确定性框架;"台阶命中/设定一致/错误检出"——对照评分。
## 4. 确定性合同主干(高 ROI优先铺
以下六类全部可从现有 SoT 直接生成用例,是回归主干,最先落地:
1. **错误码枚举稳定**:业务错误码取自 [后端-05 §5](后端-05-统一API契约-v1.md) 枚举;特定失败不得压缩为泛化码(如来源阻断必须返 `SOURCE_BLOCKED` 而非 `STATE_CONFLICT`)。
2. **乐观锁**Block 写入必带 `expectedRevision`,冲突必拒且 revision 单调递增owner [架构-04 §4.6](架构-04-状态机与约束清单.md))。
3. **Accept 事务原子**:候选接受 8 步(校验→写 revision→归因→归档→审计/outbox任一步失败整体无副作用outbox/投影异步失败不回滚已提交正文owner [后端-03 §4](后端-03-关键流程实现与接口契约.md))。
4. **幂等**:同 `commandId` 重提返回首次结果、不重复写/确认/绑定/导出;同键不同语义必报 `IDEMPOTENCY_CONFLICT`owner [后端-05 §2.5](后端-05-统一API契约-v1.md))。
5. **鉴权矩阵 / 入口隔离**`/admin-api/**` 改不了用户正文,`/app-api/**` 碰不到系统配置;系统内部调用不得复用用户 token越权 `FORBIDDEN`、无身份 `UNAUTHENTICATED`owner [后端-02 §4.3](后端-02-工程结构与模块职责.md)ArchUnit 包级隔离)。
6. **双轨写路径审计**AI 任务/候选/智能体绝不能直写正文事实层;来源传播只能限制/标记/作废不得改写已确认正文owner [架构-02 §1.1](架构-02-核心数据结构与双轨模型.md)、[架构-04 §11](架构-04-状态机与约束清单.md))。
补充确定性主干(同优先级):状态机迁移图遍历(通用版本/任务/候选/来源、唯一约束与快照不可变、SSE 框架合同与重连续传、`X-API-Version` 废弃期/410 行为、不丢稿不变式(崩溃后 `max(本地缓存, 服务端)` 可恢复、零丢失)。
## 5. 语义评测方法论
语义部分最大的坑是"测了个寂寞"(跑一次、看着行、就过)。三套机制,铁律先行:**n=1 不下结论;两臂必须对等、控住混淆变量。**
### 5.1 LLM 评委(双盲双评)——给单条产出打分
- 两个评委独立打分、互不知结果;分差超阈值([专题-04 §10.1](专题-04-生成质量门控与创作健康度设计方案.md) 用 0.5)自动引入第三评。
- 评委 fresh 进程隔离、匿名样本,不告知"AI 写还是人写"。
### 5.2 对照实验A/B 两臂对等)——回答"新策略是否真的更好"
- 两臂对等:同一批章节、同一模型、同一预算,只改变被测变量。
- 样本不足不下结论:[专题-04 §10.1](专题-04-生成质量门控与创作健康度设计方案.md) Gate A——样本 <5 或五场景不全 `insufficient_evidence`"证据不足"**不是**"通过")。
- **通过线必须是硬数字**。全仓范本 = [专题-04 §10.1](专题-04-生成质量门控与创作健康度设计方案.md) Gate B试验臂相对对照臂文风或张力**不退化**,且设定保真均值 **≥0.25**、且 **≥60%** 样本为正 → `passed`。这是"语义质量落成确定性判定"的唯一现成范本,所有新语义门补标准时照此范式。
### 5.3 回放评测——回答"知识/上下文策略有没有用"
拿参考书当标准答案:冻结到第 N 章,按消费契约组装上下文去生成第 N+1 章,对照真实第 N+1 章owner [专题-07 §4](专题-07-知识消费契约与质量闭环.md))。铁律:**没跑过回放,不许声称有改善。**
## 6. 测试可判定性合同(倒逼 owner 分册)
**每个功能要进入自动化验收,必须向测试提供下列之一;提供不出来的,视为需求未闭合,不得声称"已完成"。**
| 功能类型 | 必须提供 | 提供不出来的后果 |
|---|---|---|
| 状态机 / 流程 | 完整合法+非法迁移图、终态 | 无法写迁移断言 |
| 端点 / 命令 | 稳定错误码、前置条件、幂等键来源 | 无法写契约/并发测试 |
| 数值门槛TTL/置信度/配额) | **具体数值**,或明确的"基线产生时点+校准责任人" | 过期/拒绝/超限路径不可断言 |
| 语义质量门 | 量表 + 通过线 + 评委规则(照 §5.2 Gate B 范式),或书面承认"只展示不阻断" | 既想阻断又无判据 = 黑洞,测试拒绝背书 |
| 失败路径 | 四问:为什么 / 哪些事实没变 / 下一步 / 返回哪里 | 失败恢复不可验收 |
本条是测试对需求的硬约束:凡声称"质量达标""风险可控""自动入库"而无上表对应判据者,测试侧记为**不可判定**,回退 owner 分册补标准。当前已知的不可判定点,集中登记在 `docs/plans/2026-07-30-测试倒逼需求缺口跟踪.md`,拍死后蒸馏回各分册即删。
## 7. 已拍决策的测试合同
以下三项已由人类拍死2026-07-30本册登记其**测试合同**;对应 owner 分册的措辞修订见缺口跟踪文档的蒸馏清单。
### 7.1 收紧自动确认G1
**决策**:知识草稿仅在四条件**全满足**时自动入库——① 自有正文产生 ② 无外部来源 lineage ③ 无冲突 ④ 置信度达标;任一不满足 → 进待确认队列,用户确认才入正文事实层。
**测试合同(确定性)**
- 四条件逐一置假,断言草稿进入待确认队列、**不**写正文事实层;
- 四条件全真且置信度达标,断言 `confirm_mode=auto` 且可撤回;
- 带外部 lineage 的草稿即便改写,断言**不**洗白来源、**不**走自动确认。
**已蒸馏**`产品-02 §6.4` 入口清单已纳入"知识草稿达标自动确认(四条件)"`§9.1` "唯一入口"措辞已对齐,与 `流程-02B §7` 矛盾消除。
### 7.2 叙事质量门补硬标准并阻断G4
**决策**:运行时叙事三维(设定一致 / 角色声音 / 场景结构)照 §5.2 Gate B 范式补硬标准——每维一个量表 + 双盲评委 + 通过线,不达标阻断或触发有限重写(重写上限默认 ≤2owner [专题-04 §5](专题-04-生成质量门控与创作健康度设计方案.md))。
**测试合同(混合)**
- 确定性外壳:量表步长、双盲流程、"分差 >0.5 加第三评"、重写上限、`qualityState` 转移——机械断言;
- 语义内核:三维打分由盲评大模型给;
- 阻断断言:构造低于通过线的样本,断言候选**不**进 `shadow_ready`、**不**可接受。
**已蒸馏**`专题-04 §4.2.1` 已补运行时叙事门量表0-10/0.5 步长)、三维通过线(设定 ≥7.0、角色/场景 ≥6.0)与四步裁决顺序,盲评机制引用 §10.1。
### 7.3 阈值分类处理
**决策**
- **安全类阈值现在拍死**——凭证有效期handoff token / 下载凭证)、风险与冲突分级。一旦数值确定,其过期/拒绝/禁静默确认路径即为确定性断言。
- **语义类阈值留回放首轮基线**——知识质量三性门槛、回放通过线、自动确认置信度。必须同时写明"基线产生时点 + 校准责任人",否则视同悬置。
**测试合同**
- 安全类:数值落 SoT 后,过期/重复使用/actor 不匹配/越级确认 → 断言拒绝 + 写审计;
- 语义类:基线未定时,测试只断言"指标可计算、留痕、门禁要求附运行标识"**不**断言 pass/fail基线定后补硬断言。
**已蒸馏(安全类)**handoff token 15 分钟(`架构-04 §10.1`)、下载凭证 5 分钟(`架构-04 §12.2`)、高危安全操作冷却窗口 24 小时(`架构-04 §3.2`);风险/冲突分级三级枚举 `none/soft/hard``产品-02 §10.4`
**留基线(语义类)**:知识质量门槛、回放通过线、自动确认置信度留回放首轮基线,校准责任人=质量策略 owner、时点=回放首轮完成时;基线未定前测试只断言"指标可计算、留痕、门禁附运行标识"。
## 8. 验收与 CI 门禁
- **覆盖登记**:每条确定性合同登记进覆盖 JSON接口门机械源可机械核对未登记者不计入自动化率。
- **防假绿**:遵循 [`.agents/rules/verification-and-anti-false-green.md`](../.agents/rules/verification-and-anti-false-green.md)——测试必须真驱动被测流程并观察行为,禁止只跑 typecheck/空断言冒充通过。
- **阻断规则**CI 中 L1L3 测试失败阻断合入L4 语义评测以"证据不足/退化"为失败信号,不以单次分数为失败信号。
- **语义门上线门禁**:任何语义质量门,要么持有 §5.2 范式的硬通过线,要么书面标注"只展示不阻断";二者皆无 → 测试侧拒绝背书,需求视为未闭合。

View File

@ -416,6 +416,6 @@ AI 生成的候选在交付用户之前,经过质量评分。关键维度(
- 管理员系统处理流程:`流程-02A-管理员系统处理流程(系统视角).md`
- 普通用户系统处理流程:`流程-02B-普通用户系统处理流程(系统视角).md`
- 核心概念与双轨模型:`架构-02-核心数据结构与双轨模型.md`
- 质量门控与创作健康度:`专题-04-生成质量门控与创作健康度设计方案`
- 质量门控与创作健康度:`专题-04-生成质量门控与创作健康度设计方案.md`
- AI 编排、上下文与质量评测:`专题-03-AI编排上下文与质量评测实现规范.md`
- 系统全貌与边界上下文:`架构-01-系统全貌与边界上下文.md`

View File

@ -386,8 +386,17 @@ Muse 的 AI 能力通常不是单个智能体按钮,而是系统功能编排
- 用户手动创建或修正作品知识。
- 导入解析或全书解析结果先经过章节级确认;其产生的知识草稿仍必须再经过知识确认。
- 规划候选经过用户确认,并按规则沉淀为作品知识或叙事状态。
- 知识草稿达标自动确认:仅当下列四个条件**全部满足**时,系统才可自动把草稿写入局域知识库——① 草稿由用户自有正文产生;② 不含任何外部来源 lineage市场资产、授权知识、参考作品等③ 与既有知识无冲突;④ 置信度达到质量策略登记的阈值。任一条件不满足,草稿一律进入待确认队列,由用户确认后才入库。自动确认结果对用户可见、可撤回。
正文保存只表示用户文本落盘,进入正式正文和版本历史;它不自动表示相关内容可以沉淀为作品知识、设定、关系或事件事实。
正文保存只表示用户文本落盘,进入正式正文和版本历史;它不自动表示相关内容可以沉淀为作品知识、设定、关系或事件事实。带外部来源 lineage 的草稿即使用户改写,也不得借此洗白来源、改走自动确认。
外部来源 lineage 带来的限制(必须用户确认、不可自动入库、导出受来源约束)**不可通过改写文本内容解除**。限制只能由下列途径解除,且原始外部 lineage 永久保留、不可删除或覆盖:
- 来源恢复 active 且授权快照仍有效;
- 用户对来源重新授权;
- 用户显式确认该草稿入库(记录为 user_confirmed外部 lineage 原样保留)。
"独立来源证明 / 原创确认"不是独立的洗白通道:它只能作为用户显式确认时附带的来源声明被记录,不能删除、隐藏或覆盖原始外部 lineage也不能据此把草稿改判为自有正文而走自动确认。
知识草稿确认前必须校验来源快照。来源正文、规划项、候选文本或授权资产版本已变化时,草稿必须标记为已过期,不能确认入库。用户可以重新提取、手动修改后保存或忽略。
@ -508,7 +517,7 @@ Muse 的 AI 能力通常不是单个智能体按钮,而是系统功能编排
- 管理员配置 MetaSchema、系统级智能体、全局知识、权限、质量门控和市场治理规则。
- 普通用户写作、规划、选择智能体、绑定知识来源、确认候选和知识。
- AI 产出的候选文本、知识草稿、规划候选、风险标记都先进入 Shadow。
- 进入 Canonical 的唯一入口是用户确认,或用户自己的正文保存动作;正文保存不等于知识入库。
- 进入 Canonical 的入口是用户确认、用户自己的正文保存动作,以及 §6.4 定义的"知识草稿达标自动确认"(仅限自有正文、无外部来源、无冲突、置信度达标四条件全满足的窄口径);除此之外 AI、外部知识和市场资产不得直接写正式事实。正文保存不等于知识入库。
- Archive 记录已接受、已丢弃、已过期、失败、撤权和历史版本,不能和待确认内容混显示。
### 9.2 普通用户决策模型
@ -638,6 +647,17 @@ AI 候选必须分层呈现,避免把写作决策变成审计决策。
- “可确认章节”必须已完成来源快照校验、授权校验、章节内校验,并对跨章节实体合并和冲突给出风险摘要;存在高风险冲突时,不能静默一键确认,只能分章节处理或进入冲突解决流程。
- 不提供在全书解析详情里绕过章节边界逐条提交正式事实。
章节确认的冲突按三级分类,作为"能否静默一键确认"的可测判据(风险等级枚举,供一致性检查和章节确认共同引用):
| 冲突分级 | 代码建议 | 判定 | 允许的确认方式 |
|---|---|---|---|
| 无冲突 | `none` | 来源/授权/章节内校验全通过,跨章节实体无矛盾 | 可一键确认 |
| 软冲突 | `soft` | 表述差异、可合并的重复实体、信息补全,**不**矛盾任何已确认事实 | 展示风险摘要后可编辑或确认 |
| 硬冲突(高风险) | `hard` | 与已确认正文/Local KB/正式规划直接矛盾,或来源失效、授权异常、跨章节实体合并歧义无法自动裁决 | **禁止静默一键确认**,必须分章节处理或进入冲突解决流程 |
"高风险冲突"即 `hard`。只要章节内存在任一 `hard` 项,"一键确认全部可确认章节"必须把该章节排除在外并显式标注原因,不得整批静默落盘。
### 10.5 智能体创建、安装与替换
- 创建或编辑智能体发生在智能体工作台,不发生在写作台。
@ -670,6 +690,7 @@ AI 候选必须分层呈现,避免把写作决策变成审计决策。
- 两类记录可以来自同一底层事实,但入口、字段和可见范围必须按角色隔离。
- 日志和上下文快照默认不得保存全文,除非满足明确合规或排障目的,并受到最小化和审计约束。
- New-API 成本日志不等于用户最终扣费。系统必须能区分待结算、已结算、已返还、已补偿或等价状态;失败、系统重试、用户取消、合规阻断、撤权或下架导致的任务失败,必须分别定义用户可见扣减、返还、补偿和审计口径。
- 计费分阶段:本阶段以额度调整 ledgerquota-adjustment含 commandId/correlationId、变更前后快照、审批、幂等状态、反向变更与审计为可测合同"待结算/已结算/已返还/已补偿"完整四态结算状态机列为后续阶段owner 为 `后端-04`/`后端-05`。完整状态机落地前,验收只断言 ledger 的幂等、审计与反向变更正确,不断言四态迁移。
### 10.9 导出、删除与撤权
@ -738,4 +759,4 @@ AI 候选必须分层呈现,避免把写作决策变成审计决策。
- 普通用户系统处理流程:`流程-02B-普通用户系统处理流程(系统视角).md`
- Accept 收束专题:`专题-01-正文建议接受(Accept Suggestion)实现规范.md`
- AI 编排、上下文与质量评测:`专题-03-AI编排上下文与质量评测实现规范.md`
- 质量门控与创作健康度:`专题-04-生成质量门控与创作健康度设计方案`
- 质量门控与创作健康度:`专题-04-生成质量门控与创作健康度设计方案.md`

View File

@ -1,10 +1,11 @@
# 产品-02B管理员控制台功能规格
- 版本v5
- 更新日期2026-05-22
- 版本v6
- 更新日期2026-07-09
- 目标读者:产品 / 交互 / 前端 / 后端 / 测试
- 阅读时间45-65 分钟
- 边界说明:本文件承接 `产品-01``产品-02``产品-02A`,只定义管理员控制台(Admin Console)的详细功能规格、页面内容、可见数据、用户操作、权限边界和产品接口。本文不写代码级 API、数据库字段、前端路由或具体 UI 组件;不替代智能体工作台、知识库工作台、市场、个人中心和作品工作台的详细规格。
- 变更记录v62026-07-09新增 §3.9A 拆书公共属性本体与参考作品治理——20 型 target_type 作为元结构管理面、参考作品档案(`reference_work`)版权授权与拆书处理状态治理、系统级拆书任务与管理员确认队列,细节链接 `专题-06`
## 0. 前序继承
@ -284,6 +285,14 @@
| 埋点与审计 | 记录授权范围、外发允许、版本变化、停用理由。 |
| 验收要点 | 授权必须区分可见、可检索、可生成和可进入模型上下文;外发必须可解释和可审计。 |
### 3.9A 拆书公共属性本体与参考作品治理
系统级拆书把管理员维护的属性本体、参考书档案与拆书产出统一收在治理面。这里只给管理入口与边界,属性本体的 target type 全清单、逐型字段合同与范式 lineage 读模型见 `专题-06-元数据驱动的智能体架构.md`
- **拆书公共属性本体管理**:文风、节奏、技法、桥段、套路等小说公共属性以 MetaSchema 的 target type 承载管理员在元结构定义§3.3、§3.4)里增删类型、扩字段、调可见性;本体一变,拆书多抽、生成多用、检测多查。这套四档本体(作品与结构身份/创作骨架/画像技法/系统侧)是元结构管理面的固定内容,不新起页面。
- **参考作品治理**:参考作品档案(`reference_work`,落全局知识库 document 特化)登记书名、作者、品类等标识,维护版权授权状态(`licensed``public_domain``research_only``unauthorized`,其中 `unauthorized` 失败关闭、不进任何拆书任务)与拆书处理状态(`pending``parsing``extracted``curated``failed`);每条进全局库的范式可按 lineage 查“某本书贡献了哪些范式”,版权收紧时经来源状态传播阻断派生范式的新使用、不回滚已确认。
- **系统级拆书任务与管理员确认队列**:管理员发起参考书拆书任务后,按公共属性 target type 抽出的范式先入待确认队列Shadow管理员逐条确认后经「管理员确认系统级知识草稿 → 全局知识库范式 Canonical」入口落库`架构-02 §1.2`),成为可被作品显式绑定的系统来源。
### 3.10 质量门控策略
| 项目 | 内容 |

View File

@ -1,7 +1,7 @@
# 内容映射表New-Design(新设计) V2
- 版本v7
- 更新日期2026-06-20
- 版本v8
- 更新日期2026-07-17
- 目标读者:文档维护者/架构/前端/后端/产品
- 阅读时间1015 分钟
- 边界说明:本文件只做“内容归属与迁移策略”定义,不承载业务/架构细节;细节必须落到对应 V2 文档里,避免重复。
@ -43,6 +43,11 @@
- `专题-01-正文建议接受(Accept Suggestion)实现规范.md`
- `专题-02-Sudowrite对标与Muse产品取舍.md`
- `专题-03-AI编排上下文与质量评测实现规范.md`
- `专题-04-生成质量门控与创作健康度设计方案.md`
- `专题-05-AI统一交互协议与外部AgentAdapter设计.md`
- `专题-06-元数据驱动的智能体架构.md`
- `专题-07-知识消费契约与质量闭环.md`
- `专题-08-自动化测试方案.md`
- dev 路线图(⚠️ `doc/dev/*` 规划未落地、从未创建;职责已由实际文档承担):
- 真实现状与目标差距 → [`docs/项目功能与进度总览.md`](../docs/项目功能与进度总览.md)
- 总体路线图 / 阶段 → [`docs/mvp/进度总账.md`](../docs/mvp/进度总账.md)
@ -184,6 +189,25 @@
- 产品旅程与决策点:主文档 `产品-03-用户旅程与操作流程.md`(步骤细化在 `流程-01A/01B`
- 系统处理模型:主文档 `流程-02A-管理员系统处理流程(系统视角).md` / `流程-02B-普通用户系统处理流程(系统视角).md`
- AI 编排、检索上下文和质量评测合同:主文档 `专题-03-AI编排上下文与质量评测实现规范.md`
- 元数据驱动的智能体架构(横切):主文档 `专题-06-元数据驱动的智能体架构.md`owns 六个概念——
- 元数据驱动的智能体架构agent = f(作品 + 元数据 + 知识库),元引擎 + 功能链双枢
- 三体关系muse-cloud主权/ dify-agent能力执行/ dify-rag检索基座/ New-API模型网关
- target type 结构本体23 型清单 + domain 逐值语义 + 拆分判据(术语权威仍在 `架构-02` §9
- 统一创作数据读取器AI 上下文的服务端读合同三级裁剪、fail-closed omittedSources
- base 内置种子清单23 项四档全局 schema 的叠加与继承机制
- 拆书通用抽取与参考作品:`reference_work` 档案 + 范式 lineageGlobal/Local KB 两处浇铸;参照作品面 = 系统侧证据资产,非第四类知识库,见其 §6.4
- 知识效用闭环(横切):主文档 `专题-07-知识消费契约与质量闭环.md`owns 四个概念——
- 知识消费选择契约按用途generation/planning/detection/extraction的默认合同、注入视图两档、排序信号截断规则归 `专题-03` §4.2
- 知识质量三性:可命中 / 可行动 / 可持续(输入侧检尺,与 `专题-04` 输出侧维度分立、单向咬合)
- 回放评测:参考书=标准答案的知识策略离线评估(机制归 `专题-04` §10 框架,定义在本册)
- 长线进度消费语义:演变历程 {章,台阶,周期} 的规划/检测/写作三期消费
- 边界备注:参照作品面的浇铸位归 `专题-06` §6.4;输出侧质量维度仍归 `专题-04`;术语权威仍在 `架构-02`
- 测试可判定性(横切):主文档 `专题-08-自动化测试方案.md`owns 四个概念——
- 测试金字塔分层L1 数据不变式/状态机、L2 接口契约/事务/幂等、L3 前端交互/不丢稿、L4 AI 语义质量
- 确定性/语义分界判据:「能否不看正文含义、只比对结构与数值判定对错」+ 混合体处理纪律
- 语义评测方法论双盲评委、对照实验两臂对等Gate B 范式、回放评测n=1 不下结论
- 测试可判定性合同:每个功能要可测必须提供的判据;未提供视为需求未闭合
- 边界备注:不重定义状态机/Schema/API/质量维度,只引用各 owner具体质量维度仍归 `专题-04`,知识效用仍归 `专题-07`
- 状态机与约束:主文档 `架构-04-状态机与约束清单.md`
- 后端模块职责:主文档 `后端-02-工程结构与模块职责.md`
- 统一数据库表结构:主文档 `后端-04-统一数据库Schema-v1.md`

View File

@ -393,4 +393,4 @@ Block 粒度为场景/小节级。一个 Block 对应正文中一个相对独立
- 普通用户操作流程:`流程-01B-普通用户操作流程(操作视角).md`
- 普通用户系统流程:`流程-02B-普通用户系统处理流程(系统视角).md`
- Accept 专题:`专题-01-正文建议接受(Accept Suggestion)实现规范.md`
- 质量门控专题:`专题-04-生成质量门控与创作健康度设计方案`
- 质量门控专题:`专题-04-生成质量门控与创作健康度设计方案.md`

View File

@ -1,7 +1,7 @@
# 前端-03元引擎与动态表单
- 版本v6
- 更新日期2026-05-24
- 版本v7
- 更新日期2026-07-17
- 目标读者:前端 / 架构 / 产品 / 后端
- 阅读时间25-45 分钟
- 边界说明:本文件定义 MetaSchema 在两个前端中的使用方式Vben 管理后台配置元结构,`muse-studio` 用户端消费可见投影并渲染创作表单。元结构定义看 `架构-02`Schema 看 `后端-04`API 看 `后端-05`
@ -71,7 +71,7 @@
| 控制项 | 管理后台含义 | 用户端含义 |
|---|---|---|
| `uiVisible` | 是否允许展示给普通用户 | false 时用户端默认不展示;也不生成可见空壳 |
| `aiContext` | 是否允许进入 AI 上下文 | 不等于用户可见,也不等于可导出;前端只展示后端返回的上下文摘要 |
| `aiContext` | 是否允许进入 AI 上下文(布尔或用途集,值域权威见 `架构-02` §9 | 不等于用户可见,也不等于可导出;前端只展示后端返回的上下文摘要 |
| `userEditable` | 普通用户是否可编辑 | false 时只能只读展示或隐藏;不能在前端强开编辑 |
| `userSearchable` | 是否允许普通用户检索 | false 时不进入用户搜索入口、筛选项或联想结果 |
| `exportable` | 是否允许随作品导出 | false 时导出预检必须排除,并显示不可导出原因 |

View File

@ -1,10 +1,11 @@
# 后端-02工程结构与模块职责
- 版本v8
- 更新日期2026-05-24
- 版本v9
- 更新日期2026-07-09
- 目标读者:后端 / 架构 / 平台 / 测试
- 阅读时间25-40 分钟
- 边界说明本文件只定义后端工程基线、Yudao Cloud fork 保留/裁剪模块、Muse 业务模块职责和模块协作边界。领域模型看 `后端-01`,关键流程看 `后端-03`Schema 看 `后端-04`API 契约看 `后端-05`。本文描述目标工程形态,不代表当前仓库所有模块都已实现。
- 变更记录v92026-07-09§3.4 Governance Facade 落点表补系统功能链路一行——逻辑写入 authority 为 Governance facade、物理落 `yudao-module-meta`,与 MetaSchema 同模式(案 A`架构-03` ADR-022
## 1. 工程基线
@ -212,6 +213,7 @@ Governance facade 是 MetaSchema、保护节点、系统功能链路、Tool Gran
| 职责 | 物理位置 | 说明 |
|---|---|---|
| MetaSchema 全部能力 | `yudao-module-meta`(独立模块) | admin 写入 + 用户作品级覆盖 + facade-api 只读消费Content、Knowledge、AI 等模块通过 `meta-api` 只读依赖消费 active/gray 投影 |
| 系统功能链路定义与版本 | `yudao-module-meta`(独立模块) | 与 MetaSchema 同模式(案 AGovernance facade 为逻辑写入 authority、物理落 meta 模块;`meta-api``FunctionChainQueryApi` 只读端口AI runtime 按激活功能链解析节点序列与开放槽位 |
| Protection Node + Quality Policy | `yudao-module-ai-server/application/grant/` | AI grant 包承载保护节点和质量策略的写入服务,只暴露给 admin-api |
| Tool Grant authority 实现 | `yudao-module-ai-server/application/grant/` | 写入入口只接受 Governance/Security facade 调用 |
| 市场治理 | `yudao-module-market-server/controller/admin/` | 市场资产下架、召回、封禁、申诉处理等治理动作归 market admin 包 |

View File

@ -358,4 +358,4 @@ Account/Member 只提供用户可见权益和汇总,不替代业务 owner。
- 状态机:`架构-04-状态机与约束清单.md`
- Accept 专题:`专题-01-正文建议接受(Accept Suggestion)实现规范.md`
- AI 编排专题:`专题-03-AI编排上下文与质量评测实现规范.md`
- 质量门控专题:`专题-04-生成质量门控与创作健康度设计方案`
- 质量门控专题:`专题-04-生成质量门控与创作健康度设计方案.md`

View File

@ -1,10 +1,11 @@
# 后端-04统一数据库 Schema(结构定义)-v1
- 版本v10
- 更新日期2026-05-24
- 版本v11
- 更新日期2026-07-09
- 目标读者:后端 / 架构 / 前端 / 数据库维护者 / 测试
- 阅读时间35-55 分钟
- 边界说明:本文件定义 Muse 在 `YunaiV/yudao-cloud` fork 上新增或二开的业务 Schema 目标。Yudao 原生 `system``infra``member``pay``bpm``report``mp` 表结构由对应 Yudao 模块负责,本文不重复定义。接口契约见 `后端-05-统一API契约-v1.md`,状态机见 `架构-04-状态机与约束清单.md`,工程模块见 `后端-02-工程结构与模块职责.md`
- 变更记录v112026-07-09功能链定义表订正为 `muse_meta_function_chain/_version/_node/_slot` 归 meta案 A`架构-03` ADR-022逻辑写入 authority 为 Governance facade 经 meta admin serviceMetaSchema `target_type` 示例去粒度后缀收敛为 `character``muse_meta_field``storage_binding` 存储映射base 种子目标清单引用 `专题-06`
## 1. 目标
@ -162,11 +163,11 @@ API 层 ID 规则:
| `muse_ai_prompt_version` | ai | ai | content / market | 版本激活、下架写 outbox |
| `muse_ai_agent` | ai | ai | content / market / account | agent 状态变化影响 slot binding 和市场资产 |
| `muse_ai_agent_version` | ai | ai | content / market / account | 版本下架触发绑定重验 |
| `muse_ai_system_function_chain` | ai | ai admin service | content / knowledge | 链路激活、停用写 outbox |
| `muse_ai_system_function_chain_version` | ai | ai admin service | content / knowledge | function chain 版本激活写 outbox |
| `muse_ai_chain_node` | ai | ai admin service | content / knowledge | 节点配置随 chain version 发布 |
| `muse_meta_function_chain` | meta | meta admin serviceGovernance facade | ai / content / knowledge | 链路激活、停用写 outboxAI runtime 经 `FunctionChainQueryApi` 读激活链 |
| `muse_meta_function_chain_version` | meta | meta admin serviceGovernance facade | ai / content / knowledge | function chain 版本激活写 outbox |
| `muse_meta_function_chain_node` | meta | meta admin serviceGovernance facade | ai / content / knowledge | 节点配置随 chain version 发布 |
| `muse_ai_protected_node_registry` | ai | ai admin service | content / knowledge / audit | 保护节点变更写审计和 outbox |
| `muse_ai_override_slot` | ai | ai admin service | content / market | 槽位变更触发 slot binding 重验 |
| `muse_meta_function_chain_slot` | meta | meta admin serviceGovernance facade | ai / content / market | 槽位定义变更触发 slot binding 重验 |
| `muse_ai_agent_slot_binding` | ai | content + ai binding service | market / account / source | 仅 active 绑定唯一;授权撤销或版本下架触发失效 |
| `muse_ai_agent_slot_precheck` | ai | ai target owner service | market / source / account | 槽位绑定目标预检或签名消费凭证,原子消费后写 binding |
| `muse_ai_tool_grant` | ai | governance/security facade -> ai grant service | content / account / audit | 工具授权变更写审计AI runtime 只能消费授权投影 |
@ -328,7 +329,7 @@ MetaSchema 是独立模块 `yudao-module-meta` 的元结构定义,由管理后
| 表 | 职责 |
|---|---|
| `muse_meta_schema` | 元结构根对象,包含 schema_key、domain、scope、target_type、当前激活版本和适用范围 |
| `muse_meta_field` | 字段定义、类型、必填、枚举、引用、排序和校验规则 |
| `muse_meta_field` | 字段定义、类型、必填、枚举、引用、排序、校验规则和 `storage_binding` 存储映射 |
| `muse_meta_visibility_policy` | `uiVisible``aiContext``userEditable``userSearchable``exportable` 等可见性策略 |
| `muse_meta_schema_version` | 发布版本、激活状态、灰度、回滚和影响预览摘要 |
@ -339,7 +340,7 @@ MetaSchema 是独立模块 `yudao-module-meta` 的元结构定义,由管理后
| `schema_key` | 稳定业务键,同一 domain / scope / target_type 下唯一 |
| `domain` | `content` / `world` / `narrative` / `knowledge` / `ai_context` |
| `scope` | `work` / `chapter` / `block` / `entity` / `relation` / `event` / `agent` |
| `target_type` | 目标对象类型,例如 `novel_work``character_entity`、`generation_context` |
| `target_type` | 目标对象类型,例如 `novel_work``character`、`generation_context` |
| `active_version_id` | 当前激活版本,可为空但不能指向 disabled / archived 版本 |
| `effective_scope` | 适用范围摘要,至少能表达全局、租户、用户、作品、类型灰度 |
| `projection_version` | 当前读模型或运行时缓存使用的投影版本 |
@ -361,6 +362,8 @@ MetaSchema 是独立模块 `yudao-module-meta` 的元结构定义,由管理后
| `published_by` / `published_at` | 发布人和发布时间 |
| `activated_by` / `activated_at` | 激活人和激活时间 |
`muse_meta_field``storage_binding` 存储映射DDL 随 W1 迁移落地)标注每个字段的物理落点:`native_column`(映射 Work 等聚合的固定列,如 `novel_work` 容器 schema 对 `muse_content_work` 的 title / genre / status 等列)、`extension_json`(扩展字段落 jsonb`computed`(回算字段,`userEditable=false`)。它承载容器 schema 对既有固定列的元描述,使 MetaSchema 不复制事实载体(对齐 `架构-02 §9`)。系统内置 base schema 的种子目标清单(四档:作品与结构身份 / 创作骨架 / 画像技法 / 系统侧)由 `专题-06-元数据驱动的智能体架构.md` 定义,本册不复制清单。
约束:
- MetaSchema 写入入口只能是 `/admin-api/muse/governance/**``yudao-module-meta`Content 等模块只能通过 meta facade-api 消费只读投影。
@ -695,16 +698,18 @@ SourceEventType 到状态策略的默认映射:
### 7.2 系统功能链路和槽位
功能链定义chain / version / node / slot与 MetaSchema 同住 `yudao-module-meta`(案 A`架构-03` ADR-022逻辑写入 authority 为 Governance facade、经 meta admin serviceAI runtime 经 `FunctionChainQueryApi` 只读激活链做运行编排。`muse_ai_protected_node_registry`(保护节点注册表实例)与 `muse_ai_agent_slot_binding`(运行时槽位绑定)仍归 AI。
| 表 | 职责 |
|---|---|
| `muse_ai_system_function_chain` | 生成、分析、检测、导入解析、知识处理等系统预编排链路 |
| `muse_ai_system_function_chain_version` | 功能链路版本,记录 chain definition、激活状态、回滚来源和兼容范围 |
| `muse_ai_chain_node` | 链路节点,标记 protected / open_slot / internal |
| `muse_meta_function_chain` | 生成、分析、检测、导入解析、知识处理等系统预编排链路 |
| `muse_meta_function_chain_version` | 功能链路版本,记录 chain definition、激活状态、回滚来源和兼容范围 |
| `muse_meta_function_chain_node` | 链路节点,标记 protected / open_slot / internal |
| `muse_ai_protected_node_registry` | 不可替换保护节点注册表 |
| `muse_ai_override_slot` | 可替换子智能体槽位定义 |
| `muse_meta_function_chain_slot` | 可替换子智能体槽位定义 |
| `muse_ai_agent_slot_binding` | 作品级槽位绑定,记录用户选择的 Agent Version |
`muse_ai_system_function_chain_version` 字段合同:
`muse_meta_function_chain_version` 字段合同:
| 字段语义 | 要求 |
|---|---|
@ -1034,7 +1039,7 @@ Owner 约束:
| 市场购买、授权、安装和绑定不写作品事实 | `muse_market_authorization` / `install` / `bind_precheck` / `handoff` 与 Content / Knowledge Canonical 分离 |
| Market 不写目标预检事实 | `muse_market_bind_precheck` 只保存来源侧摘要;目标凭证分别落 `muse_ai_agent_slot_precheck``muse_knowledge_bind_precheck``muse_content_work_asset_use_precheck` 或目标 owner 签名凭证 |
| 市场收藏不代表授权或安装 | `muse_market_collection` 只保存用户市场行为 |
| 用户只替换开放槽位 | `muse_ai_override_slot` / `muse_ai_agent_slot_binding`,保护节点在 `muse_ai_protected_node_registry` |
| 用户只替换开放槽位 | `muse_meta_function_chain_slot` / `muse_ai_agent_slot_binding`,保护节点在 `muse_ai_protected_node_registry` |
| AI 不能自授工具或外发权限 | `muse_ai_tool_grant.authority_owner` 必须来自 Governance / Security facade运行时只消费 `muse_ai_runtime_permission_envelope` |
| 入 RAG 不反写事实 | `muse_knowledge_processing_job` / `muse_projection_task` 只产投影和索引 |
| 导出必须重验来源许可 | Export Task + Download Credential + Authorization Snapshot |
@ -1078,7 +1083,7 @@ Owner 约束:
- `muse_source_snapshot(source_owner_module, source_type, source_object_id, source_version, source_hash)`
- `muse_source_status_event(event_key)`
- `muse_source_propagation_target(event_key, target_owner, target_type, target_id, target_version)`
- `muse_ai_system_function_chain_version(chain_id, version_no)`。
- `muse_meta_function_chain_version(chain_id, version_no)`。
- `muse_ai_quality_policy_version(policy_id, version_no)`
- `muse_ai_agent_slot_binding(work_id, slot_key)` 仅约束 `active_flag = true` 的 active 记录inactive 历史允许多条。
- `muse_ai_agent_slot_precheck(precheck_id)`
@ -1097,7 +1102,7 @@ Owner 约束:
激活唯一约束:
- MetaSchema同一 `schema_key + effective_scope` 仅一条 `active_flag = true``muse_meta_schema_version`
- Function Chain同一 `chain_id + effective_scope` 仅一条 `active_flag = true``muse_ai_system_function_chain_version`。
- Function Chain同一 `chain_id + effective_scope` 仅一条 `active_flag = true``muse_meta_function_chain_version`。
- Quality Policy同一 `policy_id + effective_scope` 仅一条 `active_flag = true``muse_ai_quality_policy_version`
### 12.3 JSON 查询

View File

@ -1,7 +1,7 @@
# 后端-05统一 API(接口) 契约-v1
- 版本v10
- 更新日期2026-06-19
- 版本v11
- 更新日期2026-07-17
- 目标读者:前端 / 后端 / 架构 / 测试
- 阅读时间35-55 分钟
- 边界说明:本文件只定义 Muse 在 Yudao Cloud fork 上的 API 分组、资源语义、关键命令、错误模型与异步交互。底层表结构看 `后端-04`,状态机看 `架构-04`,关键流程看 `后端-03`
@ -180,7 +180,7 @@ MetaSchema 管理命令必须带 `commandId`、操作者、权限点、变更理
| 字段 | 语义 |
|---|---|
| `uiVisible` | 是否进入用户可见投影 |
| `aiContext` | 是否允许进入 AI 上下文组装 |
| `aiContext` | 是否允许进入 AI 上下文组装(布尔或用途集,值域权威见 `架构-02` §9 |
| `userEditable` | 用户端是否允许保存该动态字段 |
| `userSearchable` | 是否允许用户搜索或筛选 |
| `exportable` | 是否可被导出预检纳入 |

View File

@ -1,10 +1,11 @@
# 架构-01系统全貌与边界上下文
- 版本v9
- 更新日期2026-05-24
- 版本v10
- 更新日期2026-07-09
- 目标读者:架构 / 后端 / 前端 / 产品 / 测试
- 阅读时间25-35 分钟
- 边界说明:本文件只定义系统边界、有界上下文(Bounded Context, BC)、权威归属和跨上下文协作规则;不定义页面字段、数据库表结构、后端接口或完整状态机。精确模型约束见 `架构-02-核心数据结构与双轨模型.md`,精确生命周期见 `架构-04-状态机与约束清单.md`,产品功能见 `产品-02-核心功能与交互边界.md``产品-02A~02G`,流程链路见 `流程-01A/01B/02A/02B`
- 变更记录v102026-07-09§3.3 BC 落地矩阵订正系统功能链路归属——功能链定义与版本归 MetaSchema 模块、管理界面在 muse-admin、运行编排归 AI Orchestration runtime案 A`架构-03` ADR-022
## 1. 系统边界(一句话)
@ -79,7 +80,7 @@ Muse 是面向长篇小说创作的多角色 AI 创作与资产流通系统。
| BC | 目标后端 owner | 聚合 / 模型 owner | Facade / API 分组 | 异步 worker | 审计 owner |
|---|---|---|---|---|---|
| Identity/Auth | `muse-auth` | 用户、角色、权限组、会话、安全事件 | Auth / Admin Permission / Personal Security | 会话清理、安全事件通知 | Account/Usage/Audit |
| Admin/Governance | `muse-admin` | 保护节点注册表(仅注册,实例归 AI、系统功能链路、全局治理配置 | Admin Console | 配置影响预览 | Account/Usage/Audit |
| Admin/Governance | `muse-admin` | 保护节点注册表(仅注册,实例归 AI、系统功能链路治理面(链路定义与版本归 `muse-meta-schema`、管理界面在 `muse-admin`、运行编排归 `muse-ai` AI runtime、全局治理配置 | Admin Console | 配置影响预览 | Account/Usage/Audit |
| MetaSchema | `muse-meta-schema` | MetaSchema 全局定义、字段类型、校验、枚举、引用、领域、范围、目标类型、可见性、AI 上下文、导出语义admin 写入全局定义,用户可在作品级覆盖扩展字段 | Admin Console全局定义/ facade-api只读消费 | MetaSchema 版本影响预览 | Account/Usage/Audit |
| Work/Content | `muse-content` | Work、Chapter、Block、Block Source Attribution、Planning Item、Narrative State、Work Export Job | User Workspace / Work Workspace | 导入、作品导出、正文投影 | Account/Usage/Audit |
| Knowledge | `muse-knowledge` | Local KB、User KB、Knowledge Draft、Knowledge Source Binding、Knowledge Export Job、投影和索引任务 | Knowledge Workspace / Work Knowledge | 资料处理、入 RAG、投影、知识导出 | Account/Usage/Audit |

View File

@ -1,10 +1,11 @@
# 架构-02核心数据结构与双轨模型
- 版本v9
- 更新日期2026-06-19
- 版本v11
- 更新日期2026-07-17
- 目标读者:架构 / 后端 / 前端 / 产品 / 测试
- 阅读时间35-50 分钟
- 边界说明本文件定义核心模型、模型归属、双轨边界和跨模型不变式不定义数据库字段、索引、接口路径或完整状态机。BC 边界见 `架构-01-系统全貌与边界上下文.md`,生命周期见 `架构-04-状态机与约束清单.md`,精确表结构和 API 由后端阶段承接。
- 变更记录v112026-07-17§9 `aiContext` 值域升级为布尔或用途集(`true` 全用途可入 / `false` 一律不入 / 用途子集仅列出用途可入;用途枚举 generation/planning/detection/extraction布尔为其退化情形既有 `aiContext=true/false` 表述语义不变;依据拆书实验台字段级用途裁剪实证,消费语义见 [专题-07-知识消费契约与质量闭环](专题-07-知识消费契约与质量闭环.md)。v102026-07-09§1.2 Canonical 入口封闭枚举增补「管理员确认系统级知识草稿 → Global KB 范式」§2 订正功能链归属(定义归元引擎 Meta BC、运行编排归 AI runtime、Governance 为逻辑治理面§9 补 domain 逐值语义、base 与叠加override 只增不改)与 target_type 命名规则,本体全清单 owner 指向专题-06。
## 1. 双轨模型
@ -26,6 +27,7 @@
| 用户接受 AI 候选 | 正文 Canonical 和候选 Archive | 不自动确认知识草稿;不绕过来源撤权和合规阻断 |
| 用户确认规划候选 | 正式规划项或叙事状态 | 不把未确认候选送入后续生成上下文 |
| 用户确认知识草稿 | 局域知识库(Local KB)正式知识 | 来源失效、撤权、下架、召回或冲突未解决时不能来源型确认 |
| 管理员确认系统级知识草稿 | 全局知识库(Global KB)范式 Canonical | 只确认管理员经拆书产出的公共范式草稿,走 Draft→确认→Canonical 同构链;不写用户私有作品事实或 Local KB来源受限时不确认 |
| 用户维护用户知识库 | 用户知识库(User KB)资料和版本 | 不自动绑定作品,不自动进入任何作品事实 |
| 管理员发布系统配置 | 系统配置版本 | 不修改用户私有正文、用户智能体或用户知识库内容 |
| 市场授权或安装 | License、Install、授权快照 | 不自动写作品事实、不自动关联作品、不转移所有权 |
@ -45,7 +47,7 @@
| 模型分区 | 归属 BC | 负责什么 | 不负责什么 |
|---|---|---|---|
| 账号、权限与安全 | Identity/Auth | 用户、管理员、角色、权限组、菜单、操作、数据范围、会话、安全事件 | 作品事实、市场授权事实、外部网关权威日志 |
| 系统治理配置 | Admin/Governance | 系统功能链路、开放槽位、系统 Prompt、系统智能体默认链路 | 用户私有作品内容和用户私有资产内容 |
| 系统治理配置 | Admin/Governance | 系统功能链路的逻辑治理面(发布/激活 authority功能链定义与版本物理落元引擎 Meta BC、与 MetaSchema 同住,运行编排归 AI Orchestration runtime、开放槽位、系统 Prompt、系统智能体默认链路 | 用户私有作品内容和用户私有资产内容 |
| 元结构定义 | MetaSchema BC | MetaSchema 全局定义、字段类型、校验、枚举、引用、领域、范围、目标类型admin 写入全局定义,用户可在作品级覆盖扩展字段,业务模块通过 facade-api 只读消费 | 用户私有作品内容和用户私有资产内容 |
| 作品内容 | Work/Content | 作品、章节、文本块、正文版本、正文来源归因、导入正文、正式规划项、叙事状态和作品导出任务 | 系统配置、市场资产记录、用户知识库资料 |
| 知识体系 | Knowledge | 局域知识库、用户知识库、全局知识资料处理、知识草稿、知识来源绑定、索引和投影 | 市场授权交易、智能体槽位、正文编辑事实 |
@ -246,13 +248,37 @@ MetaSchema 不负责:
| 控制项 | 含义 |
|---|---|
| uiVisible | 是否在用户界面展示 |
| aiContext | 是否允许进入 AI 上下文 |
| aiContext | 是否允许进入 AI 上下文;值域为布尔或用途集——`true`(任何用途可入)/ `false`(一律不入)/ 用途子集(仅列出的用途可入;用途枚举 generation/planning/detection/extraction布尔是用途集的退化情形 |
| userEditable | 用户是否可编辑 |
| userSearchable | 用户是否可检索 |
| exportable | 是否允许随范围导出 |
`uiVisible=false` 不等于不能参与 AI`aiContext=true` 不等于用户可见;`exportable=true` 仍必须受 owner、授权、来源状态和导出许可约束。
MetaSchema 的结构本体按 `domain``scope` 两根正交轴定位每个目标类型;下面三小节补齐此前 canonical 未写明的语义地基。结构本体的 target type 全清单两轴对齐、逐型字段合同与统一创作数据读取器合同owner 在 `专题-06-元数据驱动的智能体架构.md`,本节只定义语义与叠加规则,不复制清单。
**domain 逐值语义**
`domain` 描述结构所属的语义域,回答“这块结构属于哪个意义世界”,与实例落在哪个 BC 无关;`scope` 恒等于实例对象的粒度work / chapter / block / entity / relation / event / agent
| domain | 一句话判据 | 承载 |
|---|---|---|
| content | 作者对作品的戏外承诺与计划,角色感知不到 | 作品容器、创作定位、大纲 |
| world | 角色可感知的戏内世界事实 | 世界观、地点、组织、力量体系、物品、事件、角色、关系 |
| narrative | 只关“怎么讲”、不关“讲什么”的叙事表达层 | 文风、节奏、技法、桥段、套路 |
| knowledge | 知识库域自身的资料资产结构,非知识实体的内容结构 | 参考作品档案、范式库目录 |
| ai_context | AI 上下文组装与输出合同的结构 | 生成上下文快照模具 |
两条解耦判据domain 只描述语义域、不描述存储 BCLocal KB 实体物理落 Knowledge BC但其 schema 多属 world 或 narrative 域scope 恒为对象粒度、不承载系统级与作品级之分,后者走 `effective_scope`
**base 与叠加**
`base` 不是新机制base = `effective_scope=全局` 的 active schema 版本admin 全局定义),“内置”即系统 seed 出厂的那批全局 schema。一个作品可见的结构是三层叠加——全局 active ⊕ 类型灰度版本 ⊕ 作品级 override。**override 只增不改**:作品级只能扩展字段,不能删改全局字段的定义与可见性。继承靠动态合成、不靠复制:系统不落 per-work schema 实例,读取时按 projectionVersion 动态合成投影,新作品天然继承全部全局结构。
**target_type 命名规则**
target_type 命名不带粒度后缀:同一概念在不同粒度出现时由 scope 轴表达粒度type 名保持洁净,示例用 `character` 而非 `character_entity`
## 10. 规划维度到模型边界
作品规划台(Planning Desk)是产品入口,不是新的模型集合。

View File

@ -1,10 +1,11 @@
# 架构-03关键决策与原则架构决策记录(ADR)
- 版本v12
- 更新日期2026-06-29
- 版本v13
- 更新日期2026-07-09
- 目标读者:架构/前端/后端/产品
- 阅读时间30-45 分钟
- 边界说明:这里只收敛架构原则和 ADR工程结构、表结构、接口、状态机分别归属 `后端-02``后端-04``后端-05``架构-04`。本文件描述目标架构决策,不代表当前代码都已实现。
- 变更记录v132026-07-09新增 ADR-022系统功能链定义归属元引擎·案 A与 ADR-023双轨 Canonical 入口增补·管理员确认系统级知识草稿),并更新 §2 结论段。
## 1. 架构原则(可执行)
@ -204,9 +205,25 @@
- 后果(Consequences):需要新增协议 DTO、runtime router、Dify adapter、管理端 providerRef 配置和真验证;长期收益是 provider 可替换、凭据边界清晰、Dify/AgentScope 接入一致。RAGFlow 仍是 Muse Knowledge BC 的检索基座Dify 内部知识只作为外部 app 的受限能力,不成为 Muse Knowledge Canonical。
- 参考落地:`专题-05-AI统一交互协议与外部AgentAdapter设计.md``专题-03-AI编排上下文与质量评测实现规范.md``产品-02B-管理员控制台功能规格.md`
### ADR-022系统功能链定义归属元引擎案 A
- 上下文(Context):功能链(FunctionChain)的归属在设计内部三方不一致——`架构-02` / `架构-01` 把系统功能链路划归 Admin/Governance 治理面,`后端-04` 把功能链表建为 `muse_ai_*` 归 AI Orchestration而实现代码里 V3/V10 迁移实际建的是 `muse_meta_function_chain/_version/_slot/_node` 一族、全部落在 `muse-module-meta`。三处设计各执一词且都与已落地代码不符,落地时无从取信。
- 决策(Decision):取案 A——顺代码收敛设计。功能链定义与 MetaSchema 同住元引擎(Meta BC),表名 `muse_meta_function_chain*` 不动,逻辑写入 authority 仍是 Governance facade(物理落点 meta与 MetaSchema 同模式)。AI runtime 不再拥有功能链定义,改为经 `FunctionChainQueryApi` 只读激活链做运行编排;`muse_ai_protected_node_registry`(保护节点实例)与 `muse_ai_agent_slot_binding`(运行时槽位绑定)仍归 AI。
- 替代方案(Alternatives):案 B 顺 `后端-04` 原表述,把功能链定义迁到 AI 模块并重命名为 `muse_ai_*`。它要改动已验真的迁移与代码、产生数据迁移与回归面,只为迁就一处文档表述;此处恰是代码为既成事实,逆向迁移工作量与风险都更大。
- 后果(Consequences):修订 `后端-04`(表名与 owner 订正为 meta)、`架构-01 §3.3``架构-02 §2``后端-02 §3.4` 的功能链归属表述,零数据迁移。功能链与 MetaSchema 共享版本、灰度与激活基建(同一套 `effective_scope` + active 唯一约束)。AI 经读端口编排,须保留"功能链未激活→回退现有固定链"的运行时兜底。
- 参考落地:`docs/agent-specs/2026-07-08-AI能力与元数据驱动智能体架构-设计评审.md`§7.2、第八节 W5`专题-06-元数据驱动的智能体架构.md`
### ADR-023双轨 Canonical 入口增补——管理员确认系统级知识草稿
- 上下文(Context):系统级拆书要把管理员从参考书拆出、并确认过的公共范式草稿写进全局知识库(Global KB)成为正式范式 Canonical供作品显式绑定检索。但 `架构-02 §1.2` 的 Canonical 入口是封闭枚举,原有条目只覆盖用户作品级确认与管理员发布系统配置,没有"管理员确认系统级知识草稿→Global KB 范式"这条,系统级拆书链路(W8)因此无合法入口落库。
- 决策(Decision):在 `架构-02 §1.2` 封闭枚举增补一条入口「管理员确认系统级知识草稿 → Global KB 范式 Canonical」(2026-07-08),确认人是管理员,产出走与作品级同构的 Draft→确认→Canonical 链;只确认经拆书产出的公共范式,不写用户私有作品事实或 Local KB来源受限时不确认。
- 替代方案(Alternatives):维持封闭枚举、系统级拆书只产 Shadow 不入 Canonical则全局范式库无正式事实来源、作品无法绑定检索到系统级公共属性参考拆书的"系统级能力"落空。
- 后果(Consequences)`架构-02 §1.2` 修订(+1 入口)W8 系统级拆书链路解锁;参考作品档案落 `reference_work`(Global KB document 特化),范式带 lineage 溯源;确认仍受来源状态与授权约束,与用户级确认共用同一套不变式。
- 参考落地:`docs/agent-specs/2026-07-08-AI能力与元数据驱动智能体架构-设计评审.md`§5.4、§7.2、第八节 W8`架构-02-核心数据结构与双轨模型.md §1.2`
## 3. 本次是否需要新增 ADR 的结论
需要。双角色入口、New-API 职责边界、全局/局域知识库分层、作品规划台模型复用、小说场景(Scene)暂不升一级模型、Sa-Token 认证授权基座、yudao-cloud fork 工程基座、逻辑 owner 优先原则,都会影响跨文档边界和后续实现,因此已补 ADR-008 到 ADR-015。Governance 拆散、Source 传播模式选择、Candidate Envelope 简化和图查询依赖 RAGFlow GraphRAG 的决策已补 ADR-016 到 ADR-018 并更新 ADR-006。导出/导入文件交付改为稳定存储路径字节代理 + 失败关闭安全姿态字节代理取流、SSRF 白名单、服务端权威扫描状态、账户导出脱敏)已补 ADR-019。AI 生成候选采纳断层修复——授权快照 id 由 BIGINT 收敛为跨系统稳定字符串标识(VARCHAR)、移除数值降级门禁、对齐知识库表既有迁移与后端-04 业务 ID 约定——已补 ADR-020。AI 外部运行时通过统一交互协议与 Adapter 接入,支持 Dify agent/workflow 引用并预留 AgentScope已补 ADR-021。
需要。双角色入口、New-API 职责边界、全局/局域知识库分层、作品规划台模型复用、小说场景(Scene)暂不升一级模型、Sa-Token 认证授权基座、yudao-cloud fork 工程基座、逻辑 owner 优先原则,都会影响跨文档边界和后续实现,因此已补 ADR-008 到 ADR-015。Governance 拆散、Source 传播模式选择、Candidate Envelope 简化和图查询依赖 RAGFlow GraphRAG 的决策已补 ADR-016 到 ADR-018 并更新 ADR-006。导出/导入文件交付改为稳定存储路径字节代理 + 失败关闭安全姿态字节代理取流、SSRF 白名单、服务端权威扫描状态、账户导出脱敏)已补 ADR-019。AI 生成候选采纳断层修复——授权快照 id 由 BIGINT 收敛为跨系统稳定字符串标识(VARCHAR)、移除数值降级门禁、对齐知识库表既有迁移与后端-04 业务 ID 约定——已补 ADR-020。AI 外部运行时通过统一交互协议与 Adapter 接入,支持 Dify agent/workflow 引用并预留 AgentScope已补 ADR-021。功能链定义归属元引擎(案 A顺代码收敛设计、零数据迁移已补 ADR-022双轨 Canonical 入口增补「管理员确认系统级知识草稿 → Global KB 范式」、解锁系统级拆书 W8 已补 ADR-023。
## 4. 关联阅读

View File

@ -1,10 +1,11 @@
# 架构-04状态机与约束清单
- 版本v6
- 更新日期2026-05-24
- 版本v7
- 更新日期2026-07-20
- 目标读者:架构 / 后端 / 前端 / 测试 / 产品
- 阅读时间45-60 分钟
- 边界说明:本文件是 Muse 生命周期、状态流转和不可绕过约束的架构层单一来源;状态值是概念层合同,具体字段名、表结构和接口路径由后端阶段承接。系统边界见 `架构-01-系统全貌与边界上下文.md`,核心模型见 `架构-02-核心数据结构与双轨模型.md`
- 变更记录v72026-07-20§5.2.1 登记正文实验候选与生产候选隔离、编辑版本重检、detector 绿证据、`accept_preflight` 和 CAS 状态约束;实验阶段不改 API/DB。v62026-05-24收束 Muse 生命周期、来源传播和不可绕过约束。
## 1. 状态机总原则
@ -102,6 +103,7 @@ MetaSchema 是 MetaSchema BC独立模块的治理版本不是 Content
- 敏感导出、下载、二次验证、异常登录和凭证失效必须进入安全事件或下载审计。
- 安全事件只能限制权限或提示用户,不能直接改写作品正文、知识或市场资产事实。
- 高危安全操作(会话撤销 `session_revoked`、敏感导出确认等)设 **24 小时撤销/冷却窗口**`pending_user_confirm` 超过 24 小时未确认即转入 expired。该窗口由质量/安全策略登记,可调但必须显式登记。
### 3.3 个人中心聚合状态
@ -248,6 +250,20 @@ running -> failed / canceled
- 修改后合并必须让旧知识草稿失效,并基于最终正文重新提取。
- 候选来源撤权、下架、召回、owner 缺失、文本 revision 冲突或合规阻断时,接受和合并禁用。
### 5.2.1 正文候选的实验隔离与接受子状态
正文实验候选和生产候选共享检测规则,但不共享接受资格。
| 对象 | 允许流转 | 不可绕过约束 |
|---|---|---|
| 诊断/评测正文候选 | `draft -> checking -> passed / rejected / failed / invalid_unstable` | `acceptanceEligible=false` 为不变量passed 只表示本次检测或评测完成,候选永远不能进入 `accept_preflight` 或 Canonical |
| 生产正文候选版本 | `draft -> checking -> shadow_ready -> accept_preflight -> accepted_as_is / merged_after_edit / revision_conflict / authorization_stale / source_stale / quality_stale` | 只有 detector 绿且报告绑定当前 candidateVersion、candidateSha256、contextSnapshotSha256 和 qualityPolicyVersion才能进入 shadow_ready |
| 用户编辑版本 | `shadow_ready -> edited_candidate -> checking` | 编辑必须创建严格递增的新 candidateVersion旧版本和旧 detector 报告立即失去接受资格,不允许“改完直接合并” |
`accept_preflight` 只允许 `mode=production``acceptanceEligible=true` 的当前版本进入并实时校验候选未过期、detector 绿证据、上下文快照、授权、来源状态、策略版本和 `expectedRevision`。写入 Canonical 必须采用 compare-and-setCAS服务端记录同时匹配 `runId + attempt + candidateVersion + candidateSha256 + currentState + expectedRevision` 才能原子递增 Block revision并按用户决策迁入 `accepted_as_is``merged_after_edit` 后归档。旧 attempt、旧 candidateVersion、迟到结果和重复事件只能返回冲突或幂等旧结果不能覆盖当前候选或正文。
实验台只验证上述状态合同,不写正式正文库,不修改产品 API、Flyway 或业务数据库结构。产品化必须在正文 Gate B 通过后另立计划Gate B 通过前不得启动细纲智能体真实能力验收。接受命令的具体前置与事务边界只引用 [专题-01 §6](专题-01-正文建议接受(Accept%20Suggestion)实现规范.md),本文件不重复定义。
### 5.3 规划候选生命周期
| 状态 | 含义 | 允许离开方式 |
@ -519,6 +535,7 @@ parse_job:succeeded
- Handoff 是应用层协议:来源 BC 只签发一次性 token目标 owner BC 创建 session 和 precheckAccount/Usage/Audit 只记录跳转和消费审计。
- Market 作为来源空间时,只能记录来源侧 handoff、listing/license/install/source status 和返回点;目标 owner 的 target precheck 由目标 BC 创建和消费。
- Handoff 必须绑定 actor、目标 owner、对象、动作、版本、授权快照、返回点和取消状态。
- Handoff Token 有效期为 **15 分钟**issued 后 15 分钟内未被消费即转入 expired终态该时长由质量/安全策略登记,可调但必须显式登记。
- 来源空间不能替目标 owner 写最终事实。
- 重复消费只有在同 actor、同 owner、同对象、同动作、同版本、同授权快照且未取消时才允许幂等返回旧结果。
- 目标空间落地后必须重新校验权限、授权快照、对象状态、版本和来源状态。
@ -615,6 +632,7 @@ parse_job:succeeded
硬约束:
- 下载凭证必须绑定 actor、导出任务、导出范围、对象版本和授权快照。
- 下载凭证有效期为 **5 分钟**issued 后 5 分钟内未使用即转入 expired终态该时长由质量/安全策略登记,可调但必须显式登记。
- 下载凭证过期、重复使用、actor 不匹配、任务作废或授权快照不匹配时,必须拒绝下载并写安全审计。
## 13. New-API 与外部调用状态

View File

@ -108,8 +108,12 @@
4. 发布版本不能静默影响已经绑定的用户智能体或市场智能体。
5. 影响预览必须包含默认 no-config 链路、功能编排引用、开放槽位、模型绑定、工具外发范围、真实上下文外发范围和输出合同 diff。
6. 扩大工具授权、扩大真实上下文外发、改变输出合同或改变默认系统智能体时,必须进入高风险复核或灰度发布。
7. 灰度发布必须有可判定的通过/回退准则,照对照实验范式(见 [专题-08 §5](专题-08-自动化测试方案.md) 与 [专题-04 §10.1 Gate A/B](专题-04-生成质量门控与创作健康度设计方案.md)
- **通过(可全量)**:试验组(新版本)相对对照组(现网版本)在关键质量维度(设定保真、文风一致、叙事张力)和失败率、硬阻断率上**均不退化**,且无新增高严重度残留。
- **回退(立即)**:任一关键质量维度退化,或失败率/硬阻断率上升,或出现新增密钥泄露、越权工具调用——立即回退到现网版本。
- **证据不足(不放量)**:灰度样本不足(参照 Gate A有效样本 `<5` 或场景不全)→ 判证据不足,延长灰度或扩大样本,不得据此全量发布。
失败处理:试运行失败、输出不合约、密钥泄露、越权工具调用、影响预览缺失或高风险复核未通过时,版本不能发布。
失败处理:试运行失败、输出不合约、密钥泄露、越权工具调用、影响预览缺失、高风险复核未通过,或灰度命中回退/证据不足准则时,版本不能全量发布。
### 5.2 功能编排和保护节点处理

View File

@ -269,3 +269,5 @@ P1R Account Stage A 解锁执行版 final review 进展fresh scope final re-c
P1R Account Stage A 解锁执行版 narrow final re-checkSaganFAIL1 个 P1 已按收敛要求修订quota worker retry / lease 恢复状态路径不再二选一,已冻结为初次 claim 从 `queued` 进入 `processing`retryable 失败保持 quota request 为 `processing` 并只推进 integration call 的 `retry_count/next_retry_at/errorCode/errorMessage`,下一轮 claim 只允许 `processing + next_retry_at <= now` 的恢复路径HTTP+DB / focused tests 也改为按该唯一路径断言 double-claim、runtime unavailable allowed-delta、finalize conflict、lease 恢复和 maxAttempts terminal failed。Sagan 已确认 Maven `-am` 与 final review 起点基线两个反馈关闭。当前仍只改文档,未修改 OpenAPI、scanner、coverage report、业务实现、SQL migration 或测试代码;下一步必须基于最新版重新做 feasibility/testing final re-checkPASS 前不得 implementation 或 completed promotion。
P1R Account Stage A 解锁执行版 final review 已收口fresh feasibility/testing final re-checkVoltaPASS无 P0/P1/P2 findingsVolta 确认 Sagan 唯一 P1 已关闭,执行版已冻结唯一状态路径:初次 claim 从 `queued` 到 `processing`retryable / runtime unavailable / timeout / 外部 5xx 的 `finalizeRetryable` 必须保持 quota request 为 `processing`,只推进 integration call 的 `retry_count/next_retry_at/errorCode/errorMessage`,下一轮 claim 只允许 `processing + next_retry_at <= now`;测试要求覆盖同一路径的 double-claim、runtime unavailable allowed-delta 且 request 保持 `processing`、finalize conflict、lease 恢复、maxAttempts terminal failed。本轮 final gate 结果为 scope final PASSMaxwell+ feasibility/testing final PASSVolta。当前 Stage A 执行版可进入用户批准后的 implementation 准备,但仍不是 implementation approved也不是 completed approval未获用户明确批准前不得修改业务实现、SQL、测试、OpenAPI、scanner、coverage report 或 completed allowlist。
2.0.0 单人版计划文档同步补记(2026-07-08):前一提交 `6dcabf69 feat(solo): 收口治理入口并裁剪 studio 表面` 已包含 S4/S5 相关代码、总账与模块 `.agent` 回写,但未把 `docs/agent-specs/2026-07-07-2.0.0单人版改造-execution.md`、`docs/agent-specs/2026-07-07-2.0.0单人版改造-review.md` 和本目录 `.agent` 一起提交。已补齐两项计划口径:其一,S4 除 yudao 九项 `enable=false` 与 yaml 明文 key 清理外,还必须显式关闭 Spring AI Alibaba/DashScope 自装配(`spring.ai.dashscope.agent.enabled=false` 与 `spring.ai.model.*=none`),并由 `SoloExternalAiProviderGateTest` 覆盖;其二,S5 studio 路由/侧栏/account/knowledge 裁剪与 route-only Playwright 已落地,但 MSW-off 创作主线 e2e 仍因标准后端启动的 Flyway V1 function owner mismatch 未完成,不得把 S5 标记为完成。Dify Dataset 拓扑裁决已在 review §5.2 与 execution S6 保持一致:工作区级 Datasets API key 是管理钥匙,生产至少区分全局公共 dataset 与作品独立 dataset,禁止把公共资料和作品私有资料混进单一 dataset 后仅靠 metadata 当安全边界。下一会话优先修 live 后端/测试库 owner 口径并补跑创作主线 e2e。

View File

@ -0,0 +1,277 @@
# 2.0.0 单人版改造执行计划(执行版)
- 版本:v1.0
- 日期:2026-07-07
- 承接:[评审版方案 v0.2](2026-07-07-2.0.0单人版改造-review.md)(已经 Codex + Opus 双对抗评审,P0×3/P1×8 全部吸收,见其 §13)。**设计意图、事实基线、风险与验收总纲以 review v0.2 为准,本文只做任务级拆解**;两文冲突时以 review v0.2 为准并回改本文。
- 读者:执行 agent(每个 S 步可独立派工)与项目所有者(里程碑验收)。
---
## 0. 执行纪律(硬约束,每个 S 步适用)
1. **完成=机械验证**:每步的「验收」命令必须真跑并留输出证据;默认 skipped / mock-only / 台账门禁不得作为完成证据。改动后按依赖链全量回归(local 必跑;涉库涉外部再跑 real-PG / live)。
2. **契约先行**:DDL 只走 `sql/muse/V<next>__*.sql`(编号取当前最大+1);OpenAPI 不删端点、不动 market 契约参数(dormant 只允许 `x-` 扩展);接口变更先改 `docs/api-contracts/*` 再实现。
3. **最小改动**:只动本步清单内文件;禁止顺手重构;代码全中文注释,外部交互与失败路径必须有可追溯日志。
4. **原子提交**:S3 的「pom 注释 + 兜底 Bean + 测试排除生效 + 台账 dormant」必须同一提交生效,避免中间态红门(S2 只交付机制,S3 拨开关)。
5. **回写**:每步完成回写 `docs/mvp/进度总账.md` + 涉及模块 `.agent`;不新增过程状态文档。
6. **环境与坑**:内网凭据按 `~/.config/muse-repo/infra.env``muse-cloud/scripts/dev/p1r-external-acceptance.env`;JVM/Maven 必须按 `.agents/knowledge/external-deps-and-gotchas.md` §四清理 SOCKS/HTTP 代理与陈旧增量编译(`rm -rf <module>/target/maven-status` 或 clean),否则出现假红/假绿。
7. **派工分档**:各 S 步执行子代理默认 `opus`;纯机械批量(改配置值、菜单 SQL、批量 hideInMenu)可 `haiku`;S7.0 裁决结论与全文通路设计评审升 `fable`(或主会话终裁)。
---
## 1. 步骤总图
```mermaid
flowchart LR
S0[S0 门禁基线修复] --> S2[S2 门禁/测试树机制]
S2 --> S3[S3 market 摘装配]
S3 --> S4[S4 配置与治理收口]
S4 --> S5[S5 studio 裁剪]
S1[S1 Dify 部署+契约钉死] --> S6[S6 知识运行时切换]
S1 --> S70[S7.0 输出契约裁决]
S70 --> S7[S7 生成主链切 Dify]
S7 --> S8[S8 导入解析切 Dify]
S5 & S6 & S8 --> S9[S9 部署收口+黄金旅程]
S9 --> S10[S10 文档沉淀归档]
```
两条线并行:**隔离线** S0→S2→S3→S4→S5(全在 market/member/前端/门禁面);**Dify 线** S1→(S6 ∥ S7.0→S7→S8)(全在 ai/knowledge 面)。S9 汇合。S0 与 S1 可同日启动。
---
## S0 坐实并修复门禁基线
| | |
|---|---|
| 前置 | 无(一切工作的地基) |
| 预估 | 0.5 天 |
**动作**
1. 真跑 `bash muse-cloud/scripts/run-p1r-verification.sh local`,记录全部红项清单。
2. 修已静态坐实的红:`muse-server/src/test/java/cn/iocoder/muse/server/framework/api/P1rMarketRealApiGateTest.java` 断言 `summary.completedOperations==241``ai==47`,而台账 `docs/superpowers/reports/p1r-api-coverage.json` 为 242/48。**修法=对齐现实**:先核对新增端点(2026-06-27 admin AI 写链路)在台账中确有 `testFiles` 真实证据锚点,再把断言改为 242/48;若发现台账证据缺失,按反假绿规则先补证据再改数,不许直接拨数字。
3. 顺带核查同文件其他域计数断言(knowledge/content/meta/account)与台账一致性。
4. 复跑 local 至全绿。
**验收**:`run-p1r-verification.sh local` BUILD SUCCESS,输出留档进度总账。
**回滚**:纯测试断言修正,revert 即可。
---
## S1 部署 Dify + 契约钉死
| | |
|---|---|
| 前置 | 无;宿主待用户确认(review §12.1,默认 mini-infra) |
| 预估 | 1 天 |
**动作**
1. 按 Dify 官方 docker-compose 在内网宿主部署,**钉死版本号**(镜像 tag 记入凭据文档与 compose;禁 latest)。
2. Dify 控制台初始化:创建工作区;建两个 app——「muse-写作透传」chat app(prompt 极简透传、创作温度、绑定模型 provider,上游可配自有 New-API 网关)、「muse-全书解析」app/workflow(低温、要求严格 JSON 输出);生成 app 级 API key ×2 与工作区级**知识库 API key**(两类 key 不同,不可混用)。
3. 凭据落位:`muse-cloud/scripts/dev/p1r-external-acceptance.env` 新增 `MUSE_AI_DIFY_*`(base-url/credentials/console-base-urls)与 `MUSE_KNOWLEDGE_DIFY_*`(base-url/dataset-api-key)样例;地址与 key 按内网惯例记入项目凭据文档;**移除该文件中 RAGFlow 条目**(与 S6 同步生效亦可)。
4. **Datasets contract test(写业务 adapter 前钉契约)**:新增 opt-in live IT(对齐既有 `P1r*LiveAcceptanceIT` 模式,`MUSE_P1R_EXTERNAL_ACCEPTANCE=true` 才真跑)`P1rDifyDatasetsContractLiveIT`,实测并断言:`POST /v1/datasets` 建库字段、`document/create-by-file` 响应含 document.id 与 batch、`documents/{batch}/indexing-status` 状态枚举、`POST /v1/datasets/{id}/retrieve` 响应结构(records[].segment.content/score)。留 `dify-contract-<日期>` dataset 不删(对齐 RAGFlow smoke 惯例),仅作为验收证据,不是生产唯一 dataset;生产拓扑见 S6。
5. chat-messages smoke:用既有 `RealDifyMuseAiRuntimeClient` 配置真打「写作透传」app 一次(可借既有 Dify 单测/新增最小 live 用例),证明 app 级 key 与网络通。
**验收**:contract live IT 全绿输出留档;Dify 控制台两 app 可用;检索 smoke 返回结构与 §S6 映射表一致(不一致→先改 review v0.2 映射表再动工)。
**回滚**:纯新增测试与外部部署,无代码风险。
---
## S2 门禁与测试树适配(机制就绪,不拨开关)
| | |
|---|---|
| 前置 | S0 |
| 预估 | 1~1.5 天 |
**动作**
1. **测试树 profile 机制**:`muse-cloud/muse-server/pom.xml` 增加 maven profile ——默认 profile 通过 maven-compiler-plugin `testExcludes` + surefire excludes 排除下列 **14 个文件**(全部位于 `muse-server/src/test/java/cn/iocoder/muse/server/framework/api/`),`market-assembled` profile 激活时不排除:
`P1rMarketAdminAppealCompletedApprovalIT``P1rMarketAdminAssetReadsCompletedApprovalIT``P1rMarketAdminPublishReviewCompletedApprovalIT``P1rMarketDiscoveryFavoriteCompletedApprovalIT``P1rMarketEventsPublishEndToEndTest``P1rMarketGovernanceWriteCompletedApprovalIT``P1rMarketHandoffClusterCompletedApprovalIT``P1rMarketKbForkMaterializationIT``P1rMarketLicenseInstallCompletedApprovalIT``P1rMarketProducerAppealCompletedApprovalIT``P1rMarketPublishProducerCompletedApprovalIT``P1rMarketplaceDiscoveryReadsCompletedApprovalIT``P1rAccountAdminPurchaseRecordsCompletedApprovalIT``P1rAccountMarketRecordsCompletedApprovalIT`(后两个是 Account 侧混合测试,一并排除并在台账登记)。`P1rMarketRealApiGateTest` 一并纳入排除清单(它虽不 import market-server,但其断言语义=market 装配态)。**本步排除默认不生效**(profile 缺省仍编译全量),S3 原子拨开关。
2. **覆盖门 dormant 口径**:改造 `P1rApiCoverageReportTest`——断言从硬编码常量改为**按域派生**;引入 `dormant` 完成状态口径(dormant 域豁免 completed 计数与 testFiles 强制,总量 242 不变);台账 JSON 的 market 32 条置 dormant 的数据变更**本步只准备 patch 不提交生效**。
3. **admin 侧隔离机制**:market/account 治理相关 Playwright spec(`muse-admin-governance.spec.ts` 中 market/account 用例)与 `global-setup.ts` 的 market fixture 加环境开关(如 `MUSE_ADMIN_E2E_MARKET=false` 跳过);`muse.test.ts` 确认不需改(hideInMenu 方案不删路由)。
4. **dry-run 验证机制有效**:本地临时注释 market-server pom 依赖(不提交)→ 应用步骤 1/2 的开关 → `run-p1r-verification.sh local` 全绿 → 还原。此绿仅证明机制,正式生效在 S3。
**验收**:dry-run local 全绿记录;还原后 local 仍全绿;`-Pmarket-assembled` 下 14 文件回编译成功。
**回滚**:机制未启用,revert 即可。
---
## S3 market 摘装配(原子生效)
| | |
|---|---|
| 前置 | S2 |
| 预估 | 1 天 |
**动作(同一提交)**
1. **兜底 Bean**:muse-server 侧新增 `MarketFacadeFallbackAutoConfiguration`(仿既有 `MonolithFacadeFallbackAutoConfiguration`,`@AutoConfiguration` + `@Bean @ConditionalOnMissingBean`),为三个抽象方法接口提供**真实 fail-closed 方法体**(不能用空匿名类):`MarketHandoffTokenApi`(verify/consume → token 无效/false)、`MarketAssetSourceApi`(→ not found 语义响应)、`MarketAssetForkApi`(recordPublicFork → false)。中文注释写明「market 未装配的单人形态兜底;装配后真实现优先」,首个调用打一次 warn 日志。
2. `muse-server/pom.xml` 注释 `muse-module-market-server` 依赖(保留 `muse-module-market-api`);S2 的 testExcludes 默认生效;台账 JSON market 32 条 dormant patch 生效。
3. **市场语义端点核验(不改代码,补断言)**:新增/扩展 IT 断言 knowledge 侧 market 装配相关路径在无 market 形态下 fail-closed。`AppMuseInstalledKnowledgeBaseController` 返回空结果;market_kb binding/handoff/fork 依赖 S3 兜底返回 notFound/invalid/false。`AppMuseKnowledgePublishController``POST /{kbId}/publish-prechecks|publish-snapshots|publish-readiness` 保留 Knowledge 本域预检/快照语义,允许写 readiness/snapshot/command 记录,但不得产生 market 资产、安装、授权事实;前端发布入口由 S5 删除。
4. 全量回归:`local` + `real-pg`(专属 `_test` 库)。
**验收**:local + real-PG 全绿;MockMvc 或活体证明 `/app-api/market/**``/admin-api/market/**` 404;步骤 3 断言绿;单体启动日志无 UnsatisfiedDependency。
**回滚**:revert 本提交(机制随开关一起回)。
---
## S4 配置与治理收口(member / 租户 / worker / yudao 脚手架 / admin)
| | |
|---|---|
| 前置 | S3 |
| 预估 | 1~1.5 天 |
**动作**
1. **yudao 厂商脚手架关停(review §5.1.5)**:`muse-module-ai-server/src/main/resources/application.yaml` 中 gemini/doubao/hunyuan/siliconflow/xinghuo/baichuan/midjourney/suno/web-search 九项 `enable: true` 全部改 `false`,**明文 API key 全部移除**改 `${ENV:}` 占位(注意脚手架是 `.enable`,newApi/dify 是 `.enabled`,勿混);向用户提示自查轮换已入 git 历史的 key。
2. **Spring AI Alibaba/DashScope 自装配关停(2026-07-08 执行补充)**:仅清掉 yudao 九项 `enable` 不够,`DashScopeAgentAutoConfiguration` 在默认配置下仍可能创建外部 provider Bean。单人配置必须显式写入 `spring.ai.dashscope.agent.enabled=false`,并把 `spring.ai.model.chat``spring.ai.model.embedding``spring.ai.model.image``spring.ai.model.rerank``spring.ai.model.video``spring.ai.model.audio.speech``spring.ai.model.audio.transcription` 设为 `none``SoloExternalAiProviderGateTest` 必须覆盖 DashScope agent/模型自装配 Bean,防止后续升级回漂。
3. **启动断言**:新增 `SoloExternalAiProviderGateTest`(muse-server 测试,进 local 层):断言 ApplicationContext 中不存在 gemini/doubao 等厂商 ChatModel/客户端 Bean(白名单仅 Dify 与保留未配置的 New-API adapter 壳),防配置回漂。
4. **member 固化**:核对并把「`muse.account.new-api.enabled=false`、全部多用户 worker enabled=false(默认值)」写入单人部署配置样例(S9 的 compose env)与部署手册;代码不动;entitlement/投影/导出的 `@ConditionalOnMissingBean` 兜底行为各补一条现状断言(有则免)。
5. **租户与登录**:确认 `muse.tenant.enable: true` 不动、部署固定 `tenant-id=1`;核查 studio `LoginPage` 无注册/找回等多用户入口(有则随 S5 移除)。
6. **admin 前端**:`muse-admin/apps/web-antd/src/router/routes/modules/muse.ts` 给「市场治理」「用户与权限」两条加 `hideInMenu: true`;`views/muse/newapi/index.vue` 移除网关绑定与额度配置两个写命令表单(保留本地用量只读观测),补「无写入口」测试断言;核查 yudao demo 静态路由模块(`leave.ts/pay.ts/ai.ts/bpm.ts/member.ts`)是否有顶级菜单漏出,漏则一并 hideInMenu。
7. **system_menu 数据脚本**:产出幂等 SQL(停用 用户/角色/租户/部门/岗位(保留菜单管理与 admin 自身账号改密)、会员中心、支付、公众号、BPM、yudao-AI 娱乐面菜单;含反向恢复脚本),存 `muse-cloud/sql/solo/`,随部署手册引用,**不直接执行到共享 dev 库**(部署期动作)。
**验收**:local 全绿(含新增门禁);`grep -rn "api-key: sk-\|api-key: AIza" muse-cloud/**/application*.yaml` 零命中;DashScope/Spring AI 外部模型自装配无 Bean 证据;admin `vitest/typecheck` 绿 + newapi 页无写入口断言绿 + 菜单隐藏断言绿。
**回滚**:逐项 revert;menu SQL 有反向脚本。
---
## S5 studio 前端裁剪
| | |
|---|---|
| 前置 | S3(后端 market 已 404,避免删 UI 后留活后端);可与 S4 并行 |
| 预估 | 1 天 |
**动作**
1. `src/app/routes/index.tsx`:删 4 条 market 路由、`/handoff/land/:targetOwner` 路由、`/works/:workId/editor/:chapterId`(EditorPage)路由;删除 `pages/EditorPage.tsx``features/handoff/` 下仅服务市场落地的组件(`AgentHandoffLanding` 等,先 grep 确认无创作主线引用)。
2. `components/layout/Sidebar.tsx`:navItems 删「市场」「治理」。
3. `features/account/components/PersonalCenter.tsx`:保留 资料 header + `UsageStats`,删 购买/授权/发布/安全事件/New-API 绑定 5 个 section 及其死 import。
4. 知识页(`features/knowledge/`):删「从市场安装的」tab、「发布到市场」入口与预检 Modal;智能体页核实市场 handoff 入口随路由移除后无残留死链。
5. 规划候选/文风检查:**无前端动作**(现状无 UI 入口,review §2.9);1b 再建。
6. e2e:market 相关 spec(含 `market-install-kb-retrieval.spec.ts` 的 fixme)加 skip tag 隔离;MSW handlers 保留(DEV only);创作主线 spec 全量复跑。
**本轮交接(2026-07-08)**:S5 已完成 studio 路由/侧栏/account/knowledge 的前端裁剪与 route-only Playwright 证据,并隔离 17 个 market 相关 e2e spec;但标准后端启动仍被 `muse_local/muse_dev` Flyway V1 function owner mismatch 卡住,所以 MSW-off 创作主线 e2e 尚未完成。route-only 证据只能证明 `/market``/handoff/land/x` 等入口不可达,不能替代创作主线验收。下一会话先修 live 后端/测试库 owner 口径或改用明确的 live `_test` 启动配方,再跑候选采纳/导入/导出/知识/图谱/绑定/agent 生命周期 e2e,通过后才可把 S5 标为完成。
**验收**:`tsc -b``vitest run``vite build` 绿;MSW-off Playwright 创作主线 spec(候选采纳/导入向导/导出下载/知识草稿/图谱/绑定/agent 生命周期)全绿;手输 `/market``/handoff/land/x` 落 404/重定向;隔离 spec 计数留档。
**回滚**:revert 提交(文件在 git 历史)。
---
## S6 知识运行时切换 Dify Datasets
| | |
|---|---|
| 前置 | S1(contract test 钉死契约);与隔离线并行 |
| 预估 | 2~3 天 |
**动作**
1. **端口改造**(定性:重命名+操作裁剪+配置/审计/测试迁移):`RagFlowKnowledgeRuntimeClient``KnowledgeRuntimeClient`,裁掉 7 个无生产调用操作(`updateDatasetConfig/listDatasets/listChunks/runGraphRag/traceGraphRag/getKnowledgeGraph/health`);`MuseKnowledgeAuditService.recordRagflowCall` 更名 `recordRuntimeCall`(表 `muse_knowledge_ragflow_call` 不动,注释订正);同步全部实现/单测/调用方(机械 rename,IDE 级)。
2. **新 adapter**:`DifyKnowledgeRuntimeClient`(实现 5 操作,HTTP 范式对齐 `RealDifyMuseAiRuntimeClient`:JDK HttpClient + 直连 ProxySelector + Bearer + 脱敏 fail-closed);`UnavailableKnowledgeRuntimeClient` 桩;装配类读 `muse.knowledge.dify.*`(base-url/dataset-api-key/timeout-seconds/retry-budget),缺任一装 Unavailable。API 映射按 review §5.2 表,以 S1 contract test 实测为准。
3. **DDL**:`sql/muse/V<next>__knowledge_runtime_batch.sql``muse_knowledge_processing_task``runtime_batch_id VARCHAR(160)`(注释:Dify 索引批次);上传链写入 batch,`MuseKnowledgeParseStatusPollWorker` 轮询主键 document_id→batch;绑定表 `ragflow_dataset_id` 列存 Dify dataset id(列注释订正,命名债登记)。
4. **Dataset 拓扑**:同一工作区级 Datasets API key 管理多个 Dify dataset;生产至少支持全局公共 dataset + 作品独立 dataset 的 `datasetId` 映射。禁止把公共资料和作品私有资料混进单一 dataset 后仅靠 metadata 当安全边界;S1 的 `dify-contract-*` dataset 只是契约证据。
5. **检索**:`MuseKnowledgeRetrievalApiImpl` 的 chunks 解析适配 Dify 响应;多授权 dataset **并行**检索 + 按 score 合并 topK;耗时与 score 分布打日志(观测跨库可比性)。
6. **删除 RAGFlow**:`HttpRagFlowKnowledgeRuntimeClient`、其装配与配置类、`P1rRagFlowLiveAcceptanceIT``P1rKnowledgeRuntimeEndToEndLiveAcceptanceIT` 的 RAGFlow 形态、env 样例条目;全仓 grep `ragflow`(代码层)清零(表名/列名/迁移历史除外)。
7. **worker 开启**:单人配置样例 `muse.knowledge.parse-poll-worker.enabled=true`
8. **live IT**:新 `P1rDifyKnowledgeRuntimeEndToEndLiveAcceptanceIT`:建库→上传→轮询至 completed→检索命中→授权门阻断路径;fail-closed 反向(无配置→CONFIG_MISSING)。
9. **检索质量 smoke**:用真实创作素材(设定集/章节)建库检索,人工比对相关性并记录结论;不达标调 Dify 分块参数复测。
**验收**:knowledge 模块单测 + local + real-PG 全绿;live IT 全绿留档;质量 smoke 结论入总账;`grep -ri ragflow --include='*.java'` 仅剩 DO/表注释白名单。
**回滚**:revert;RAGFlow 代码经 git 历史可回(review §10 口径:退役不承诺兼容)。
---
## S7.0 输出契约裁决(独立小步,先于 S7 开发)
| | |
|---|---|
| 前置 | S1;建议在 S6 进行中穿插完成 |
| 预估 | 0.5 天(裁决)+ 用户确认 |
**动作**
1. 按 `.agents/knowledge/external-deps-and-gotchas.md` §六配方起活体单体,用现有 New-API 配置真实生成一次(WorkspacePage AIPanel 触发)。
2. 记录三件事:SSE 事件流各事件的实际载荷(chunk/done 里有没有正文全文)、AIPanel/CandidatePanel 给用户看到的内容、`muse_ai_suggestion.content_snapshot.content` 落库值。
3. 出裁决备忘(执行报告,不新增过程文档,写入总账条目):**分支①**(现状无全文通路→立「运行时全文实时通路」工作项,方案骨架见 S7)或**分支②**(有全文通路→S7 收敛为 adapter 对齐)。
4. 把备忘交项目所有者确认(review §12.4 预设倾向分支①)。
**验收**:裁决备忘含三件事的原始证据(SSE 抓包/截图/DB 查询);用户确认记录。
---
## S7 生成主链切 Dify
| | |
|---|---|
| 前置 | S7.0 裁决确认;S6 不阻塞本步 |
| 预估 | 分支① 3~4 天 / 分支② 1~2 天 |
**动作(分支①骨架,以裁决为准)**
1. **全文实时通路(不推翻数据主权)**:runtime 内部结果结构增加**不落库**的正文载荷字段(内存传递;候选表仍只存脱敏摘要+三审);executor 拿到全文后经任务事件/SSE 链路把正文推给前端(chunk 事件正文不持久化或仅落脱敏摘要,**不破坏 OpenAPI 已声明的 SSE 事件契约**——chunk.data 字段语义先查契约再定);两个 adapter(Dify/NewApi)把 provider 全文透出到该字段。
2. **完整性护栏**:Dify chat 成功响应 finishReason 为常量不可用作完整性信号——按输出合同做结构化 envelope/长度合理性校验,疑似截断→失败可重试,不得静默进候选。
3. **provider 切换**:在用系统 Agent 版本 `runtimeProvider` 配为 `dify` + `providerRef.dify`(app=「muse-写作透传」,credentialRef 指 S1 凭据);`muse.ai.dify.enabled=true` 进单人配置;`non-stream-read-timeout-seconds` 上调对齐 180s 总预算;**New-API 配置此步保留**(S8 验收后才摘)。
4. **验证**:三审字段落库链已 provider 无关(现状),补 Dify 形态断言;live IT:Dify 生成→Shadow 候选(三审齐)→前端全文可见→`merge_after_edit` 采纳写 Canonical;fail-closed 反向(dify 未配/凭据错→拒绝,不回退 New-API)。
**验收**:live 生成链全绿;MSW-off 候选链 e2e 复跑绿(真 Dify);local+real-PG 全绿;fail-closed 反向绿。
**回滚**:agent 版本 `runtimeProvider` 拨回 `new-api`(兼容逻辑未动)即回,代码 revert 独立。
---
## S8 导入解析切 Dify
| | |
|---|---|
| 前置 | S7(共用 Dify 生成通路经验);**本步是 New-API 配置摘除的完成门** |
| 预估 | 1.5~2 天 |
**动作**
1. **provider 选择机制**:`MuseAiImportParseService` 从单点注入 `NewApiMuseAiImportLlmParser` 改为按 `muse.ai.import.provider`(缺省 `dify`)选择 parser;来源标识按 provider 落(`dify_import_llm`/`new_api_import_llm`)。
2. **Dify parser**:`DifyMuseAiImportLlmParser` 走「muse-全书解析」app,消费完整 JSON;语义与 New-API 版逐条对齐(401/403 fail-closed、408/429/5xx 重试退避、脱敏摘要);截断检测=JSON 完整性校验+章节数合理性检查(解析失败→可重试 bad response,job 诚实置 failed)。
3. **反向测试**:`muse.ai.new-api.*` 缺失时 New-API parser fail-closed(`AI_IMPORT_LLM_UNAVAILABLE`)断言;Dify 未配同理。
4. **摘 New-API 配置**:单人配置样例移除 `MUSE_AI_NEW_API_*`(adapter 代码保留);复跑全链确认无隐性依赖。
5. **验证**:parser 单测;real-PG 导入链 IT(对齐 `P1rContentImportWizardCompletedApprovalIT` 模式换 Dify);live 全书解析;浏览器 MSW-off `import-wizard.spec.ts` 复跑(真 Dify)。
**验收**:上述四层证据全绿;New-API 配置移除后 local+real-PG+创作主线 e2e 全绿。
**回滚**:`muse.ai.import.provider=new-api` + 恢复 env 即回。
---
## S9 部署收口 + 黄金旅程
| | |
|---|---|
| 前置 | S5、S6、S8 全部完成 |
| 预估 | 1~2 天 |
**动作**
1. `docker-compose.solo.yml`(muse-server + PostgreSQL + Redis;env 指向 Dify 与两类 key;`tenant-id=1`;parse-poll worker 开、多用户 worker 全关、`muse.tenant.enable=true`);Dify 沿用 S1 官方 compose 独立部署。
2. 新库 provision:沿用「`sql/dev/yudao-base-*-postgres.sql` 基座 + Flyway V1-V<next>」配方,执行 S4 的 system_menu solo SQL。
3. **无 Nacos 干净启动验证**:无 Nacos 环境起单体,日志无持续报错;有噪声则 `spring.cloud.nacos.discovery.enabled=false` 进 solo 配置。
4. **黄金旅程 smoke(全程真后端真库真 Dify,`curl --noproxy` + 浏览器)**:登录→建作品→写正文→AI 候选(全文可见)→采纳(revision 递增+归因落库)→上传资料→索引 completed→检索命中→导出下载文件内容含真实正文;隔离核验:`/app-api/market/**` 404、knowledge market 装配相关路径 fail-closed/空结果、publish 三端点不产生 market 资产/安装/授权事实、studio 无市场入口、`SELECT` 确认无多用户 worker 活动痕迹。
5. 部署手册(docs 内,含:compose 双栈、凭据表指引、菜单 SQL、回滚步骤、Dify 版本升级前须跑 live 验收的约定)。
**验收**:review v0.2 §9 全部 7 条判据逐条留证(这是 1a 的总验收门)。
**回滚**:部署层面独立,不影响代码。
---
## S10 文档沉淀与归档
| | |
|---|---|
| 前置 | S9 |
| 预估 | 1 天 |
**动作**:按 review v0.2 §11 表逐项执行——专题-05 v0.2(§1.5/§2/§7/§8 + S7.0 裁决结果)、架构-03(ADR-005/021 superseded、ADR-006 订正、新增单人阶段 ADR)、根 CLAUDE.md 决策表、1.0.0 缺口清单 P0-11 表述订正、`.agents/knowledge/` 三文件(external-deps 换 Dify 事实与新坑:两类 key/`.enable` vs `.enabled`/testExcludes profile 模式/Dify 版本钉死)、`docs/mvp/2.0.0-单人版交付计划.md` 新建、总览 2.0.0 口径改版、本 spec 与 review 归档 `docs/agent-specs/archive/`
**验收**:`AgentsInfraIntegrityTest` 绿(README 索引/`.agent`/总账完整性);交叉引用抽查无断链;文档修订均带版本号与日期递增。
---
## 2. 阶段 1b 立项占位(1a 验收后逐项独立立项)
按序:**P0-11 规划候选生成**(后端真实现走 Dify + studio 规划台新建候选 UI;验收=缺口清单标准,外呼换 Dify)→ **P0-12 文风检查**(同上)→ **P0-06 知识处理失败恢复 UI**(基于 Dify indexing-status 失败态;重试/删除/重传入口)→ **P0-03 版本恢复写入**(Content 写边界+新 revision+归因审计+旧草稿失效)→ **P1-11 SSE 统一客户端接组件**(首选场景:知识处理进度,与 P0-06 联动)。每项立项时先出小评审版(沿用本流程),不合并进 1a。
---
## 3. 全局完成判据与里程碑
- **1a 完成 = review v0.2 §9 七条判据全部机械证据齐备**(S9 汇总留档),缺一不得声称完成。
- 里程碑顺序建议:M1=S0+S1(地基与外部实例就绪)/ M2=S3(market 摘除生效且全绿)/ M3=S6(知识链切换)/ M4=S8(New-API 退出)/ M5=S9(单机黄金旅程)。每个 M 点回写总账并可暂停评估。

View File

@ -0,0 +1,255 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Muse 2.0.0 单人版改造 · 一页边界图</title>
<style>
:root{
--ink:#1c1e26; --muted:#6b7080; --line:#e3e5ec; --bg:#f7f8fb; --card:#ffffff;
--keep:#0f766e; --keep-bg:#ecfdf5; --keep-line:#99e3cf;
--iso:#b45309; --iso-bg:#fffbeb; --iso-line:#fcd9a0;
--ret:#b91c1c; --ret-bg:#fef2f2; --ret-line:#fbc6c6;
--dify:#4338ca; --dify-bg:#eef2ff; --dify-line:#c7d2fe;
--polish:#0369a1; --polish-bg:#f0f9ff; --polish-line:#bae0f8;
}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:"PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;background:var(--bg);color:var(--ink);padding:32px 40px;line-height:1.5}
.page{max-width:1180px;margin:0 auto}
header{margin-bottom:22px}
h1{font-size:26px;font-weight:700;letter-spacing:.5px}
.sub{color:var(--muted);font-size:14px;margin-top:6px}
.sub b{color:var(--ink)}
h2{font-size:15px;font-weight:700;margin:0 0 12px;display:flex;align-items:center;gap:8px}
h2 .no{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:6px;background:var(--ink);color:#fff;font-size:12px}
section{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:20px 22px;margin-bottom:18px;box-shadow:0 1px 2px rgba(20,22,40,.04)}
/* ---- 依赖终态 ---- */
.dep{display:grid;grid-template-columns:1fr 60px 1.2fr;gap:0;align-items:center}
.dep-col{display:flex;flex-direction:column;gap:10px}
.dep-title{font-size:12px;color:var(--muted);font-weight:600;margin-bottom:2px;letter-spacing:1px}
.svcrow{display:flex;align-items:center;gap:10px}
.muse{background:var(--ink);color:#fff;border-radius:10px;padding:14px 18px;font-weight:700;font-size:15px;text-align:center;min-width:110px}
.svc{border-radius:10px;padding:10px 14px;font-size:13px;border:1.5px solid var(--line);background:#fff;flex:1}
.svc b{display:block;font-size:14px;margin-bottom:2px}
.svc small{color:var(--muted)}
.svc.na{border-color:#d3d6df}
.svc.rf{border-color:var(--ret-line);background:var(--ret-bg)}
.svc.rf b{color:var(--ret)}
.svc.df{border-color:var(--dify-line);background:var(--dify-bg)}
.svc.df b{color:var(--dify)}
.arrow-mid{display:flex;align-items:center;justify-content:center;font-size:26px;color:var(--muted)}
.edge{width:26px;height:2px;background:#b9bdc9;position:relative;flex:none}
.edge::after{content:"";position:absolute;right:-1px;top:-3px;border:4px solid transparent;border-left-color:#b9bdc9}
.edge.dash{background:repeating-linear-gradient(90deg,#b9bdc9 0 4px,transparent 4px 8px)}
.after-box{border:1.5px solid var(--dify-line);background:var(--dify-bg);border-radius:12px;padding:14px}
.after-box .cap{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
.chip{background:#fff;border:1px solid var(--dify-line);color:var(--dify);border-radius:20px;padding:4px 12px;font-size:12.5px;font-weight:600}
.upnote{margin-top:10px;font-size:12.5px;color:var(--muted)}
.infra{margin-top:14px;font-size:13px;color:var(--muted)}
.infra b{color:var(--ink)}
.strike{text-decoration:line-through;color:var(--ret)}
/* ---- 功能象限 ---- */
.quad{display:grid;grid-template-columns:1fr 1fr;gap:14px}
.qcard{border-radius:12px;padding:14px 16px;border:1.5px solid}
.qcard h3{font-size:14px;margin-bottom:8px;display:flex;align-items:center;gap:8px}
.tag{font-size:11px;border-radius:5px;padding:2px 8px;color:#fff;font-weight:600}
.qcard ul{list-style:none;font-size:13px}
.qcard li{padding:3px 0 3px 16px;position:relative}
.qcard li::before{content:"·";position:absolute;left:4px;font-weight:700}
.qcard li small{color:var(--muted)}
.q-keep{background:var(--keep-bg);border-color:var(--keep-line)} .q-keep h3{color:var(--keep)} .q-keep .tag{background:var(--keep)}
.q-iso{background:var(--iso-bg);border-color:var(--iso-line)} .q-iso h3{color:var(--iso)} .q-iso .tag{background:var(--iso)}
.q-ret{background:var(--ret-bg);border-color:var(--ret-line)} .q-ret h3{color:var(--ret)} .q-ret .tag{background:var(--ret)}
.q-pol{background:var(--polish-bg);border-color:var(--polish-line)} .q-pol h3{color:var(--polish)} .q-pol .tag{background:var(--polish)}
/* ---- 装配图 ---- */
.asm{display:grid;grid-template-columns:2.2fr 1fr;gap:14px}
.server{border:2px solid var(--ink);border-radius:12px;padding:14px}
.server .cap{font-weight:700;font-size:13px;margin-bottom:10px}
.mods{display:flex;flex-wrap:wrap;gap:8px}
.mod{border-radius:8px;padding:7px 12px;font-size:12.5px;font-weight:600;border:1.5px solid var(--keep-line);background:var(--keep-bg);color:var(--keep)}
.mod.off{border-style:dashed;border-color:var(--iso-line);background:var(--iso-bg);color:var(--iso)}
.fallback{margin-top:12px;border-top:1px dashed var(--line);padding-top:10px;font-size:12.5px;color:var(--muted)}
.fallback b{color:var(--ink)}
.sidecol{display:flex;flex-direction:column;gap:10px}
.sidebox{border-radius:10px;border:1.5px dashed var(--line);padding:10px 12px;font-size:12.5px;color:var(--muted)}
.sidebox b{display:block;color:var(--ink);margin-bottom:2px;font-size:13px}
.sidebox.iso{border-color:var(--iso-line);background:var(--iso-bg)} .sidebox.iso b{color:var(--iso)}
/* ---- 节奏 ---- */
.lane{margin-bottom:10px}
.lane-cap{font-size:12px;color:var(--muted);font-weight:600;margin-bottom:6px}
.steps{display:flex;gap:8px;flex-wrap:wrap}
.step{border-radius:9px;padding:8px 11px;font-size:12.5px;border:1.5px solid var(--line);background:#fff;position:relative;flex:1;min-width:120px}
.step b{display:block;font-size:12px;color:var(--muted);margin-bottom:1px}
.step.dify{border-color:var(--dify-line);background:var(--dify-bg)}
.step.iso{border-color:var(--iso-line);background:var(--iso-bg)}
.step.gate{border-color:#c9cdd8;background:#f3f4f8}
.step.fin{border-color:var(--keep-line);background:var(--keep-bg)}
.pol-strip{display:flex;gap:8px;flex-wrap:wrap;margin-top:4px}
.pol-item{border:1.5px solid var(--polish-line);background:var(--polish-bg);color:var(--polish);border-radius:9px;padding:8px 11px;font-size:12.5px;font-weight:600;flex:1;min-width:130px}
.pol-item small{display:block;color:var(--muted);font-weight:400;margin-top:1px}
.foot{color:var(--muted);font-size:12px;margin-top:6px}
.legend{display:flex;gap:14px;font-size:12px;color:var(--muted);margin-top:10px;flex-wrap:wrap}
.dot{display:inline-block;width:10px;height:10px;border-radius:3px;margin-right:5px;vertical-align:-1px}
</style>
</head>
<body>
<div class="page">
<header>
<h1>Muse 2.0.0 单人版改造 · 一页边界图</h1>
<div class="sub">目标:<b>单人自用创作工具</b> —— 外部依赖收敛到 <b>Dify 一个服务</b>,多用户/平台功能整体隔离,先收地基(1a)再打磨体验(1b)。评审版 v0.1 · 2026-07-07 · 详见同名 .md</div>
</header>
<section>
<h2><span class="no">1</span>外部服务终态:3 个 → 1 个</h2>
<div class="dep">
<div class="dep-col">
<div class="dep-title">改造前(1.0.0)</div>
<div class="svcrow"><div class="muse">Muse</div><div class="edge"></div>
<div class="svc na"><b>New-API</b><small>生成主链 · 导入解析 · 账户配额管理</small></div></div>
<div class="svcrow"><div style="width:110px"></div><div class="edge"></div>
<div class="svc rf"><b>RAGFlow</b><small>知识运行时(建库/上传/检索);GraphRAG 生产零调用</small></div></div>
<div class="svcrow"><div style="width:110px"></div><div class="edge dash"></div>
<div class="svc df"><b>Dify</b><small>Agent 运行时(刚接入,未部署)</small></div></div>
<div class="svcrow"><div style="width:110px"></div><div class="edge dash"></div>
<div class="svc na"><b>yudao 厂商脚手架 ×9</b><small>gemini/doubao/mj/suno… enable:true + 明文 key 在仓(须显式关停)</small></div></div>
</div>
<div class="arrow-mid"></div>
<div class="dep-col">
<div class="dep-title">改造后(2.0.0 单人版)</div>
<div class="svcrow">
<div class="muse">Muse</div><div class="edge"></div>
<div class="after-box" style="flex:1">
<b style="color:var(--dify);font-size:15px">Dify(唯一外部依赖)</b>
<div class="cap">
<span class="chip">chat/workflow:生成主链 + Agent 运行时</span>
<span class="chip">chat/workflow:导入全书解析</span>
<span class="chip">Datasets API:知识库 上传/索引/检索</span>
</div>
<div class="upnote">模型 provider 配在 Dify 内,上游可指向自有 New-API 网关(对 Muse 透明)。Prompt 组装与知识授权过滤仍在 Muse 侧 —— Dify 只是运行时,不是事实源。</div>
</div>
</div>
<div class="infra">基础设施:<b>PostgreSQL + Redis + Dify</b> · <span class="strike">RAGFlow</span>(退役删除) · <span class="strike">New-API 直连</span>(代码保留,不再配置) · <span class="strike">yudao 脚手架×9</span>(enable→false+启动断言+key 出仓) · <span class="strike">gateway/Nacos</span>(不部署)</div>
</div>
</div>
</section>
<section>
<h2><span class="no">2</span>功能边界:留什么 / 隔离什么 / 退役什么 / 打磨什么</h2>
<div class="quad">
<div class="qcard q-keep">
<h3><span class="tag">保留</span>单人创作主线</h3>
<ul>
<li>作品列表 + 写作台<small>(编辑器/章节/AI 候选采纳/版本 diff/导入导出)</small></li>
<li>智能体工作台<small>(自建 Agent 生命周期、槽位绑定、沙盒试用)</small></li>
<li>知识库<small>(上传索引、草稿确认、实体关系图谱、授权检索)</small></li>
<li>元引擎 MetaSchema<small>(admin 定义 → 动态表单)</small></li>
<li>单账号登录 + 用量观测</li>
<li>admin 治理核心<small>(元结构/功能编排/AI 配置/全局知识库/任务/审计)</small></li>
</ul>
</div>
<div class="qcard q-iso">
<h3><span class="tag">隔离搁置</span>多用户 / 平台面(代码保留,装配摘除/开关关闭/入口删除)</h3>
<ul>
<li>市场全链<small>(发现/详情/发布/审核/申诉/handoff/物化/召回)—— 后端摘装配</small></li>
<li>member 多用户子域<small>(权益/配额/归因/New-API 绑定/安全深面/偏好通知)—— 既有开关</small></li>
<li>admin:市场治理、用户与权限<small>(muse.ts 隐藏)</small>;yudao 用户/角色/租户/会员/支付/公众号/BPM 菜单<small>(菜单数据停用)</small>;newapi 页禁写降只读</li>
<li>studio:市场 5 路由、个人中心 5 段、市场耦合入口</li>
<li>meta 影响预览 all-real(P0-09)、admin e2e(P0-10)划归多用户线</li>
</ul>
</div>
<div class="qcard q-ret">
<h3><span class="tag">退役</span>不再回来(git 可考古)</h3>
<ul>
<li>RAGFlow adapter + live 验收 IT + env 样例<small>(端口中立化为 KnowledgeRuntimeClient)</small></li>
<li>GraphRAG 全部脚手架<small>(生产零调用,零损失)</small></li>
<li>studio 废弃 EditorPage demo 壳</li>
<li>端口上 7 个从未被生产调用的操作</li>
</ul>
</div>
<div class="qcard q-pol">
<h3><span class="tag">1b 打磨</span>单人主线缺口(按序补齐)</h3>
<ul>
<li>P0-11 规划候选生成<small>(后端空桩→真实现,走 Dify)</small></li>
<li>P0-12 AI 文风检查<small>(后端空桩→真实现)</small></li>
<li>P0-06 知识处理失败恢复 UI<small>(基于 Dify indexing-status)</small></li>
<li>P0-03 版本恢复写入<small>(走 Content 写边界+审计)</small></li>
<li>P1-11 SSE 统一客户端接业务组件</li>
</ul>
</div>
</div>
</section>
<section>
<h2><span class="no">3</span>后端装配终态(muse-server 单体)</h2>
<div class="asm">
<div class="server">
<div class="cap">muse-server(单进程,直服 /app-api 与 /admin-api)</div>
<div class="mods">
<span class="mod">content 作品/正文</span>
<span class="mod">ai 编排/生成</span>
<span class="mod">knowledge 知识库</span>
<span class="mod">meta 元引擎</span>
<span class="mod">member(仅登录/资料/用量)</span>
<span class="mod">system/infra 底座</span>
<span class="mod off">market(pom 注释摘除)</span>
<span class="mod off">pay / bpm / mp / report(维持不装配)</span>
</div>
<div class="fallback"><b>摘除前置:</b>新增 3 个 fail-closed 兜底 Bean(@ConditionalOnMissingBean)—— MarketHandoffTokenApi→token 无效 · MarketAssetSourceApi→not found · MarketAssetForkApi→false。market 装回时真实现自动优先。member 投影已有 Unavailable 兜底。</div>
</div>
<div class="sidecol">
<div class="sidebox iso"><b>租户红线</b>保持 muse.tenant.enable=true、固定 tenant=1。关掉会全线 NPE(getRequiredTenantId)。</div>
<div class="sidebox"><b>worker 现状</b>多用户向 worker 默认全关;仅 AI dispatcher 与本地质量评估常开。</div>
<div class="sidebox"><b>门禁先行</b>覆盖门 market 域标 dormant、market IT 按装配分组;契约文件原地保留不删。</div>
</div>
</div>
</section>
<section>
<h2><span class="no">4</span>节奏:1a 地基 → 1b 打磨</h2>
<div class="lane">
<div class="lane-cap">阶段 1a · 两条线并行(S0 基线与 S2 机制先于 S3 摘除;S1 是 Dify 线前提)</div>
<div class="steps">
<div class="step gate"><b>S0</b>坐实并修复现红门禁基线</div>
<div class="step dify"><b>S1</b>部署 Dify + contract test 钉契约</div>
<div class="step gate"><b>S2</b>门禁/测试树机制(profile+dormant)</div>
<div class="step iso"><b>S3</b>market 摘装配(兜底 Bean,原子生效)</div>
<div class="step iso"><b>S4</b>member/yudao 脚手架/admin 收口</div>
<div class="step iso"><b>S5</b>studio 前端裁剪</div>
</div>
<div class="steps" style="margin-top:8px">
<div class="step dify"><b>S6</b>知识运行时 Dify adapter(5 操作)</div>
<div class="step dify"><b>S7</b>S7.0 输出契约裁决 → 生成主链切 Dify</div>
<div class="step dify"><b>S8</b>导入解析 Dify parser(New-API 退出门)</div>
<div class="step fin"><b>S9</b>单机 compose + 黄金旅程 smoke</div>
<div class="step fin"><b>S10</b>文档沉淀(专题-05/ADR/缺口清单订正)</div>
</div>
</div>
<div class="lane">
<div class="lane-cap">阶段 1b · 逐项独立立项,真后端 e2e 验收</div>
<div class="pol-strip">
<div class="pol-item">P0-11 规划候选<small>规划台可创建/采纳</small></div>
<div class="pol-item">P0-12 文风检查<small>可追踪结果</small></div>
<div class="pol-item">P0-06 知识失败恢复<small>重试/删除/重传</small></div>
<div class="pol-item">P0-03 版本恢复<small>新 revision+归因审计</small></div>
<div class="pol-item">P1-11 SSE 接组件<small>任务进度/通知</small></div>
</div>
</div>
<div class="legend">
<span><span class="dot" style="background:var(--dify-bg);border:1px solid var(--dify-line)"></span>Dify 迁移线</span>
<span><span class="dot" style="background:var(--iso-bg);border:1px solid var(--iso-line)"></span>隔离线</span>
<span><span class="dot" style="background:#f3f4f8;border:1px solid #c9cdd8"></span>门禁</span>
<span><span class="dot" style="background:var(--keep-bg);border:1px solid var(--keep-line)"></span>收口</span>
<span><span class="dot" style="background:var(--polish-bg);border:1px solid var(--polish-line)"></span>1b 打磨</span>
</div>
<div class="foot">1a 验收:local 门禁全绿 · real-PG 全绿 · Dify live 三链真打 · fail-closed 反向证据 · 创作主线 MSW-off e2e 全绿 · 单机 compose 起全栈黄金旅程 smoke · market API 物理 404。</div>
</section>
</div>
</body>
</html>

View File

@ -0,0 +1,384 @@
# 2.0.0 单人版改造方案(评审版)
- 版本:v0.2(v0.1 经 Codex + Opus 双对抗评审后修订;修订记录见 §13)
- 日期:2026-07-07
- 目标读者:项目所有者(唯一评审人)/ 后续执行 agent
- 边界说明:本文是 2.0.0 阶段一「单人版改造」的整体方案,覆盖范围界定、目标架构、改造分块、功能清单、步骤与验证。它修订(supersede)`专题-05` 与 ADR 的若干结论(见 §3),不改变 Shadow→Canonical 双轨主权、Accept Suggestion 唯一入口、fail-closed 等最高不变式。
- 配套:[执行计划](2026-07-07-2.0.0单人版改造-execution.md) · [HTML 一页图](2026-07-07-2.0.0单人版改造-review.html)(边界与节奏概览,细节以本文为准)
---
## 一、意图与目标
Muse 1.0.0 按「多角色资产流通平台」的完整设计推进,后端已达 ~80% 真实实现,但代价是:三个外部 AI 服务(New-API、RAGFlow、Dify)、市场/会员/治理等大量多用户面,而唯一的真实用户只有项目所有者本人。剩余缺口(市场生产端、个人中心深面、admin e2e、meta all-real)恰好也集中在多用户向——继续按原范围推进,是在为不存在的用户还债。
阶段一(2.0.0)把产品目标收缩为:**单人自用的长篇创作工具**。三个子目标:
1. **外部依赖收敛**:Muse 代码只直连 Dify 一个外部服务(生成、导入解析、Agent 运行时、知识库检索全走 Dify;模型 provider 配在 Dify 内,上游可指向现有 New-API 网关,对 Muse 透明)。基础设施收敛为 PostgreSQL + Redis + Dify。收敛对象**包含 yudao 继承的 9 个厂商 AI 脚手架**(见 §2.10,当前默认全开,必须显式关闭)。
2. **多用户/平台功能隔离**:市场、会员权益/配额、市场治理、多租户治理等一律搁置——后端从单体装配中摘除(market)或用既有机制关闭(member 子域),前端删除入口;代码保留在仓库,恢复走 git 与装配回滚。
3. **打磨单人创作体验**:先把地基收干净(1a),再按优先级补齐单人主线缺口(1b:规划候选、文风检查、知识失败恢复、版本恢复、SSE 接组件)。
非目标(阶段一明确不做):多用户/多租户产品化、支付/会员商业化、市场任何一端、gateway/Nacos 微服务化部署、GraphRAG、AgentScope 接入、统一横切 Schema(P2-06)。
---
## 二、已验证事实基线
以下事实经代码级盘点核对至 `dev/2.0.0` HEAD(0d4dbe64),并经双评审复核订正;与旧文档冲突时以本节为准。
**外部服务现状(实际 3+9,非 2 个):**
| 服务 | 承担 | 现状 |
|---|---|---|
| New-API | AI 生成主链(`RealNewApiMuseAiRuntimeClient`)、导入全书解析(`NewApiMuseAiImportLlmParser`,唯一 parser 实现)、账户配额管理口(`RealNewApiAccountFacade`)、用量归因真源 | 生产在用 |
| RAGFlow | 知识运行时:建库/上传/解析/状态轮询/检索 | 生产在用;**GraphRAG 生产零调用**(接口三方法无生产调用点,`graph_status``not_ready`,attribution 默认 fail-closed) |
| Dify | Agent 运行时(chat/workflow,blocking) | 2026-06-30 新接,仅代码层;`muse.ai.dify.enabled` 默认 false,**infra 主机尚未部署 Dify** |
| yudao 厂商脚手架 ×9 | gemini/doubao/hunyuan/siliconflow/xinghuo/baichuan/midjourney/suno/web-search 的 ChatModel/Job/控制器 | **`application.yaml` 中 9 个 `enable: true` 且携带明文 API key 提交在仓**(疑似上游 demo 配置);会真实创建 Bean,不经统一路由治理 |
**对改造有决定性影响的代码事实:**
1. **知识运行时有干净端口**:业务只依赖接口 `RagFlowKnowledgeRuntimeClient`,唯一 HTTP adapter 是 `HttpRagFlowKnowledgeRuntimeClient`;12 个声明操作中**生产只用 5 个**(建库、上传、触发解析、状态轮询、检索),`updateDatasetConfig` 等 7 个从未被生产调用。替换=新写一个 adapter,调用方零改动。
2. **知识图谱可视化与 RAGFlow 无关**:图谱读 Muse 自有 Canonical 表(`muse_knowledge_entity/relation`,草稿确认流写入)。去 RAGFlow 不影响图谱功能;放弃 GraphRAG 零功能损失。
3. **market 摘除不是零改动**:`MarketHandoffTokenApi`/`MarketAssetSourceApi`/`MarketAssetForkApi` 三个接口(market-api 恰好只有这 3 个对外接口,MECE)被 content(`MuseContentAssetUseService`)、ai(`MuseAgentSlotServiceImpl`)、knowledge(`MuseKnowledgeBindingService`/`KnowledgeMarketForkService`)以 `@Resource` 强注入且**无兜底**——直接摘 pom 会启动崩。member 侧投影(`MarketAccountProjectionFacade`)已有 Unavailable 兜底,自动 fail-closed。market 事件消费者(knowledge 两个 Consumer)只依赖 market-api 事件类型,**不崩但被架空**:发布方随 market-server 消失,事件永不触发(市场隔离的预期后果,非缺陷)。
4. **muse-server 测试树对 market-server 有编译期硬依赖(摘除的最大障碍)**:`muse-server/src/test`**14 个测试文件 import market**,其中 13 个直接 import market-server 内部类(`P1rMarket*` 12 个 + `P1rAccountAdminPurchaseRecordsCompletedApprovalIT``P1rAccountMarketRecordsCompletedApprovalIT` 两个 Account 侧混合测试;另 `P1rMarketKbForkMaterializationIT` 仅 import api 但运行需 market Bean)。`run-p1r-verification.sh` 的 local/real-pg/external-live 三层**全部**走 `mvn -pl muse-server -am test`,test-compile 先编译整棵测试树——**只摘 pom 不处理测试树,三层验证当天全部编译失败**。运行期 tag 分组解决不了编译期问题。
5. **模块摘除有现成模式**:pay/bpm/mp/report 就是「muse-server pom 注释掉不装配」;`MonolithFacadeFallbackAutoConfiguration` 是兜底样板(注意:market 三接口是抽象方法接口,兜底 Bean 须写真实 fail-closed 方法体,不能照抄该样板中全 default 接口的空匿名类写法)。
6. **多用户 worker 默认全关**:全部 outbox/quota/attribution/fork/parse-poll worker 默认 disabled(机制是 `@Scheduled` 每 tick + 方法内读配置的 enabled 守卫,**不是** `@ConditionalOnProperty`);常开的只有 AI dispatcher(无开关)与质量评估 worker(默认 true,本地确定性、不调外部模型)。member 的 New-API 管理口默认即 Unavailable(`muse.account.new-api.enabled` 默认 false)。
7. **租户红线**:业务大量调 `TenantContextHolder.getRequiredTenantId()`(无值即抛异常),**不能关 `muse.tenant.enable`**;单人形态=保持租户开启、固定 tenant=1(现状零改动)。
8. **gateway 无耦合**:muse-server 单体自服务 `/app-api``/admin-api`,gateway 是独立应用、pom 无依赖,可整体不部署;openfeign 已排除。
9. **前端切分面**:studio 唯一导航是 `Sidebar.tsx` 硬编码数组(6 项),市场相关路由 5 条;`EditorPage` 是无导航可达的废弃 demo 壳;`PersonalCenter` 是纵向 7 段混合面(资料+用量该留,购买/授权/发布/安全事件/New-API 绑定 5 段属多用户);智能体市场 handoff 落地在 `features/handoff/owners/AgentHandoffLanding.tsx`(随 handoff 路由一并移除)。**规划候选与文风检查在 studio 无任何 UI 入口**(仅 OpenAPI 生成类型,无组件/hook 引用)——1.0.0 缺口清单 P0-11「studio 有入口却点不动」表述不准,须订正(§11)。admin 的 Muse 菜单来自前端静态 `muse.ts`(隐藏改代码),yudao 标准菜单来自部署库 `system_menu` 数据(隐藏改数据);`muse.test.ts` 路由测试只断言路由注册存在,hideInMenu 方案下不受影响。
10. **生成主链的输出口径(v0.1 的错误前提,已订正)**:**两个 provider 都只回脱敏摘要**——New-API 60 字(`OUTPUT_SNIPPET_LIMIT=60`)、Dify 80 字;共享契约 `RuntimeResult` 无完整正文字段;投影层 `MuseAiRuntimeProjectionService` 注释明写「完整 provider 原文按数据主权不落库」,候选 `content_snapshot.content` 取自摘要;三审(输出合规/静态检查/许可快照)与候选落库链 **provider 无关、对 Dify 已等价可用**。真实生成候选的采纳按既有设计走 `merge_after_edit`(用户回传所审终稿)。**不存在「New-API 完整输出口径」可供对齐**;「单人版候选是否必须全文可见、全文经什么通路到前端」是一个未决的产品/架构裁决,列为 S7.0(§5.1)。
11. **覆盖门现状疑似已红(执行前必须坐实)**:`P1rMarketRealApiGateTest` 硬断言 `summary.completedOperations==241`、ai 域 completed==47,而台账 JSON 实为 242/48(疑似 2026-06-27 admin AI 写链路提交增补端点后未同步该测试)。S0 先真跑 local 门禁坐实并修红,再谈摘除适配。
12. **Flyway 轴安全(评审确认,不要动)**:market 全部 7 个迁移脚本集中在 `sql/muse/`(filesystem 加载),market-server classpath 侧零迁移——摘除不触发「applied 但 not resolved」,表保留无害,恢复安全。
---
## 三、范围决策与设计修订
三个已拍板决策(2026-07-07,项目所有者确认):
| 决策点 | 结论 |
|---|---|
| 外部服务终态 | **Muse 只直连 Dify**。生成主链、导入解析、Agent 运行时、知识检索全走 Dify;模型 provider 配在 Dify 内(上游可指向自有 New-API,不属 Muse 依赖);账户配额/归因随多用户搁置;yudao 厂商脚手架显式全关 |
| 隔离机制 | **后端摘装配 + 前端删入口**。market 从 muse-server pom 摘除(先补 3 个 fail-closed 兜底 Bean + 处理测试树编译依赖);member 多用户子域按 §5.3 三类机制收口;studio/admin 删路由与菜单,代码保留仓库 |
| 缺口纳入 | **分两波**:1a=服务收敛+隔离+部署收口;1b=按优先级补单人主线缺口(P0-11→P0-12→P0-06→P0-03→P1-11) |
由此产生的**正式设计文档修订清单**(执行时随代码同步,完整动作见 §11;v0.2 补全了 v0.1 遗漏的三处):
- `专题-05` §1.5 与 §7「RAG 仍走 RAGFlow、Dify 知识库不作 Muse 知识运行时」、**§2 非目标「Muse 授权知识检索仍由 Knowledge facade / RAGFlow 负责」** → 修订为:**知识运行时切换为 Dify Datasets API**。修订不违反其本意——该条真正要守的是「Dify app 内部黑盒知识不可成为 Muse 知识事实源」,这一条继续有效;Muse 知识事实源本就在自有 PG 表,RAGFlow 与 Dify Datasets 同为「可替换的检索运行时」,经显式 retrieve 端点返回 chunks 的检索结果仍由 Muse 组装上下文,溯源语义不变。
- `专题-05` §8「用量归属仍靠 New-API 归属链」→ 降级为:单人阶段 Dify usage 摘要只作本地用量观测与审计事实,不做网关对账;`requires_new_api_attribution` 语义冻结为历史标记。
- `架构-03`:**ADR-005「向量检索采用 RAGFlow」、ADR-021 后果段「RAGFlow 仍是检索基座」标注 superseded-by 新 ADR**;ADR-006 与根 `CLAUDE.md` 决策表「图查询:依赖 RAGFlow GraphRAG」订正为 **GraphRAG 放弃**(生产零调用,零损失);增补 2.0.0 单人阶段 ADR(服务收敛 Dify、market 摘装配、多用户面搁置、恢复路径)。
---
## 四、目标架构
**外部依赖终态:**
```mermaid
flowchart LR
subgraph before["改造前(1.0.0)"]
M1[muse-server] --> NA[New-API<br/>生成/导入/配额]
M1 --> RF[RAGFlow<br/>知识运行时]
M1 -.刚接入.-> DF1[Dify<br/>Agent运行时]
M1 -.enable:true×9.-> YD["yudao 厂商脚手架<br/>(gemini/doubao/…)"]
end
subgraph after["改造后(2.0.0 单人版)"]
M2[muse-server] --> DF2[Dify]
DF2 --> C1[chat/workflow<br/>生成主链+Agent运行时+导入解析]
DF2 --> C2[Datasets API<br/>知识库 建库/上传/索引/检索]
DF2 -. Dify 内配置模型 provider,<br/>上游可指向自有 New-API .-> UP[(模型上游)]
end
before ==> after
```
**单体装配边界(muse-server):**
```mermaid
flowchart TB
subgraph in["装配保留"]
content[content 作品/正文]
ai[ai 编排/生成/Agent]
knowledge[knowledge 知识库]
meta[meta 元引擎]
member["member 登录/资料/用量<br/>(多用户子域按三类机制收口)"]
base[system/infra 平台底座]
end
subgraph out["不装配(pom 注释,同 pay/bpm/mp/report)"]
market[market 市场]
pay["pay/bpm/mp/report(维持现状)"]
end
subgraph fb["muse-server 兜底(新增,真实 fail-closed 方法体)"]
f1[MarketHandoffTokenApi→token 无效]
f2[MarketAssetSourceApi→not found]
f3[MarketAssetForkApi→false]
end
content & ai & knowledge -->|@Resource 注入| fb
member -->|既有 Unavailable 兜底| out
gw[gateway/Nacos 不部署] ~~~ out
```
关键架构口径(防误解,执行时不得偏离):
- **Prompt 主权在 Muse**。切 Dify 不是把提示词工程搬进 Dify:生成主链仍由 Muse 组装 prompt 与上下文(含知识检索结果、权限包裁剪),Dify 侧建「透传型」app(prompt 极简、固定模型参数),Muse 把组装结果作为 query/inputs 发送。Dify 承担的是模型接入与(可选的)复杂工作流编排;工作流型智能体的深编辑正是放在 Dify 控制台完成(承接 P1-02,Muse 不自建工作流编辑器)。
- **知识溯源不因换运行时而丢失**。Muse 经 Datasets retrieve 显式检索、自己组装上下文,来源快照/授权双门全在 Muse 侧;`untraceable_provider_context` 只描述 Dify app 内部黑盒知识,与本方案的检索路径无关。
- **数据主权口径未经裁决不得推翻**:「完整 provider 原文不落库」是既有明写设计;若 S7.0 裁决需要全文通路,走「实时通道不持久化正文」路径,不动候选表摘要设计(§5.1)。
- **所有 fail-closed 语义保持**:Dify 未配置/凭据缺失/引用不完整时拒绝服务,不回退、不伪造成功。
---
## 五、改造方案
### 5.1 AI 运行时收敛 Dify
现状 `RoutingMuseAiRuntimeClient` 已按 agent 版本 `runtimeProvider` 路由,Dify adapter 已具备 chat/workflow blocking 调用、错误映射、凭据脱敏,且**三审+候选落库链 provider 无关、对 Dify 已可用**(§2.10)。收敛工作是五件事:
1. **S7.0 输出契约裁决(前置,防止在错误路径上开发)**。v0.1「补完整输出通路对齐 New-API 口径」的锚点不存在(两 provider 均摘要级)。先以活体运行证据坐实:真实生成一次,观察前端(AIPanel/CandidatePanel)实际可见的候选内容与 SSE 事件载荷。然后二选一:
- **分支①(预期,推荐)**:单人创作工具的候选必须全文可见 → 立「运行时全文实时通路」为独立工作项:executor 拿到 provider 全文后经 SSE 实时推送(正文不持久化或仅落脱敏摘要,守住数据主权不变式),候选表维持摘要 + 三审,采纳沿用 `merge_after_edit`(前端把用户所审终稿回传)。涉及 runtime 内部结果结构扩展(增加不落库的正文载荷字段)、事件推送链、两个 adapter 的全文透出;工期按横切项独立评估。
- **分支②**:若活体证明现状已有全文通路(存在本轮盘点未见的路径),则 Dify adapter 对齐该通路即可,规模回落为 adapter 内改动。
2. **系统 Agent 版本切 provider**。在用系统 Agent 版本的 `runtimeProvider` 配为 `dify` + `providerRef.dify`(appId/credentialRef);Dify 侧预建两个 app:「写作透传」chat app(创作温度)与「全书解析」app/workflow(低温、JSON 输出)。旧版本缺省 `new-api` 的兼容逻辑保留不动(恢复路径)。
3. **导入解析新写 Dify parser**。现状 `NewApiMuseAiImportLlmParser` 是唯一实现且被服务单点注入——**在 New-API 配置移除前必须先完成 provider 选择机制 + Dify parser + New-API 缺失时 fail-closed 的反向测试**(S8 完成门,否则导入功能直接不可用)。语义与 New-API 版逐条对齐(完整 JSON 消费、401/403 fail-closed、408/429/5xx 重试、脱敏摘要);截断检测:Dify 无 `finish_reason=length` 等价信号,以 JSON 完整性校验 + 章节数合理性检查兜底(解析失败=可重试的 bad response)。
4. **生成链完整性护栏**。Dify chat 成功响应的 finishReason 恒为常量,不能作为完整性信号;生成链按 S7.0 裁决的通路补完整性校验(结构化 envelope/长度合理性),截断不得静默进入候选。
5. **New-API 与 yudao 脚手架退出**`muse.ai.new-api.*` 不再配置(adapter 代码保留,未配置即 Unavailable fail-closed);member 管理口/配额 worker 维持默认关闭;usage 归因语义按 §3 降级。**yudao 厂商脚手架 9 项 `enable: true` 全部改 false、明文 API key 从 yaml 移除(换 env 占位),并加启动断言「除 Dify 外无外部 AI provider Bean」**;泄露过的 key 提示用户自查轮换(疑似上游 demo key,不由 agent 代决)。注意两套开关命名不同:厂商脚手架是 `.enable`(String),newApi/dify 是 `.enabled`(boolean),配置时勿混。
**执行补充(2026-07-08)**:Spring AI Alibaba/DashScope 不是 yudao 九项脚手架的一部分,只清 `.enable` 与明文 key 不足以证明「除 Dify 外无外部 AI provider Bean」。S4 必须显式关闭 `spring.ai.dashscope.agent.enabled`,并把 `spring.ai.model.chat/embedding/image/rerank/video/audio.*` 设为 `none`;启动断言必须覆盖 DashScope 自装配,否则 live 后端可能在无 New-API/yudao key 的情况下仍触发 DashScope agent Bean。
超时口径:Dify blocking 读超时(现 90s)对长章节生成偏紧,1a 上调至与 New-API 同档(180s 总预算内),provider 级 streaming 留到后续(协议已预留,不进 1a)。
### 5.2 知识运行时切换 Dify Datasets
**契约先行**:S1 部署 Dify 后、写业务 adapter 前,先做**最小 Datasets contract test**(live,opt-in):钉死版本、路径、请求/响应字段、indexing-status 状态枚举、batch/document 语义。§本节 API 映射是按公开文档的推断,以 contract test 实测为准,防在适配层返工。
**端口改造**(不是纯 rename,如实定性为「端口重命名 + 操作裁剪 + 配置/审计/测试迁移」):接口 `RagFlowKnowledgeRuntimeClient` 更名为 `KnowledgeRuntimeClient`(名字即契约,保留旧名会让后续 agent 误判现状),裁掉生产从未调用的 7 个操作(含 GraphRAG 三方法);随之迁移的还有审计服务的调用记录口径(`recordRagflowCall` 语义改为记录当前 runtime)、配置装配、相关单测。`HttpRagFlowKnowledgeRuntimeClient` 及其装配、env 样例、live 验收 IT **删除**(git 可回;与市场「搁置待恢复」不同,RAGFlow 是拍板退役不回)。
**新 adapter `DifyKnowledgeRuntimeClient`** 实现 5 个生产操作:
| Muse 端口操作 | Dify Datasets API(以 contract test 实测为准) | 适配要点 |
|---|---|---|
| createDataset | `POST /v1/datasets` | 固定传 name + permission(only_me)+ indexing_technique(high_quality) |
| uploadDocuments | `POST /v1/datasets/{id}/document/create-by-file` | multipart 重组;process_rule 用 automatic;响应取 document.id 与 **batch** |
| startParseDocuments | 无独立对应 | Dify 上传即索引,该操作降级为 no-op(保留审计记录) |
| pollDocumentStatuses | `GET /v1/datasets/{id}/documents/{batch}/indexing-status` | **轮询主键从 document_id 变 batch**;状态映射 waiting/parsing/…/error→Muse 归一状态 |
| retrieveChunks | `POST /v1/datasets/{id}/retrieve` | **单库端点→按授权 dataset 并行循环检索,合并按 score 取 topK**;响应结构在 adapter 内归一,调用方不变 |
**Dataset 拓扑口径(2026-07-08 裁决)**:工作区级 Datasets API key 是管理钥匙,不代表生产只使用一个 dataset。生产运行时至少区分全局公共 dataset 与作品独立 dataset;Muse 本地保存 `datasetId` 映射,检索时按授权选择公共 + 当前作品 dataset 循环检索并合并排序。禁止把公共资料和作品私有资料混进单一 dataset 后仅靠 metadata 当安全边界。S1 的 `dify-contract-*` dataset 只作为 live contract 验收证据,不是生产唯一知识库。
**数据与配置落点**:
- `muse_knowledge_processing_task` 新增列 `runtime_batch_id VARCHAR(160)`(取 `sql/muse` 当前最大版本+1 的迁移)承载 Dify batch;`muse_knowledge_ragflow_binding` 等三表**表名列名不动**,`ragflow_dataset_id` 列直接存 Dify dataset id,列注释订正——显式命名债,留待 P2-06 统一 Schema 收口。
- 新配置前缀 `muse.knowledge.dify.*`(base-url/dataset-api-key/timeout/retry-budget),缺任一即装 Unavailable 桩 fail-closed(沿用现模式)。**Dify 两类凭据不同**:Datasets API 用工作区级知识库 API key,chat/workflow 用 app 级 key,不可混用(同构「New-API 令牌≠RAGFlow key」既有坑)。
- **`MuseKnowledgeParseStatusPollWorker` 默认关,S6 起须在单人配置显式开启**(否则上传后状态永不推进)。
- 授权过滤不受影响:检索前的用途门/来源状态门/授权快照门全是 Muse 应用层逻辑,`metadata_condition` 现状本就传 null,无迁移面。
- 存量数据:RAGFlow 内网实例里只有验收产物,无生产存量,不做数据迁移;素材经 Muse 重新上传索引(假设,S1 部署时复核)。
**市场 fork 簇随隔离整体停用**:`KnowledgeMarketForkService`、fork 兜底 worker、安装侧物化、kb_id 去污染属市场链路,随 §5.3 摘除后不可达,不参与运行时迁移。
### 5.3 多用户/平台功能隔离(后端)
按依赖方向分五步,每步独立可验证:
1. **补兜底再摘除**:在 muse-server 侧为 `MarketHandoffTokenApi`(verify→invalid)、`MarketAssetSourceApi`(→not found)、`MarketAssetForkApi`(→false)提供 `@ConditionalOnMissingBean` 的兜底 Bean(三接口均为抽象方法接口,**须写真实 fail-closed 方法体**);然后 muse-server pom 注释 `muse-module-market-server` 依赖(market-api 纯接口依赖保留)。market 装回时真实现自动优先,兜底让位。
2. **测试树编译处置(与 1 同步,见 §5.5)**:14 个 market 依赖测试文件不迁移、不删除,用 maven profile 化的 testExcludes 从默认编译中排除(恢复=激活 profile),其中 2 个 Account 侧混合测试一并排除并在台账登记。
3. **市场语义端点收口(不随 market 模块消失的部分)**:knowledge 模块自带的市场语义端点分两类处理。`AppMuseKnowledgePublishController` 的 publish-prechecks/publish-snapshots/publish-readiness 三个 POST 保留为 Knowledge 本域预检/快照能力,允许写本域 readiness/snapshot/command 记录,但不得产生 market 资产、安装、授权事实;前端发布入口在 S5 删除。`AppMuseInstalledKnowledgeBaseController` 与 market_kb binding/handoff/fork 路径依赖 market 兜底,必须 fail-closed/空结果。S3 验收断言 market API fallback、installed 空结果、market route 404;不要求 publish 三端点零本域副作用,也不新增 dormant 机制。
4. **member 子域收口(v0.2 订正口径:三类机制,不是「全走开关」)**:保留 登录/会话(member_user + system oauth2)、profile、命令幂等、审计、usage 只读观测。多用户子域的实际关闭机制分三类——① 真配置开关仅一个:`muse.account.new-api.enabled=false`(默认已关);② worker enabled 属性:quota/attribution/事件发布 worker(默认已关,配置文档固化);③ `@ConditionalOnMissingBean` 兜底:市场记录投影、账户导出的 FileService 依赖(market/真实 provider 缺席自动 Unavailable,无开关可设也无需设);entitlement 为本地常开纯读逻辑,无外部副作用,不动。yudao 会员商城子域(积分/等级/签到/标签)不动,仅菜单隐藏。
5. **租户/登录/worker 核对**:租户保持 enable、固定 tenant=1(**禁止关 `muse.tenant.enable`**,§2.7 红线);studio 仅保留单账号登录,不暴露注册/多用户入口;确认多用户向 worker 全部处于关闭,AI dispatcher 与质量评估 worker 保留。
### 5.4 前端裁剪
**muse-studio**(全部为删除/裁剪,文件保留在 git 历史):
- 路由:删 4 条 market 路由 + handoff 落地页路由(含 `AgentHandoffLanding` 等 owner 落地组件)+ 废弃 `EditorPage` 路由及文件。
- 导航 `Sidebar.tsx`:删「市场」「治理」两项,保留 作品/知识库/智能体/个人。
- `PersonalCenter`:保留 资料 header + 用量权益(UsageStats),删 购买/授权/发布/安全事件/New-API 绑定 5 段。
- 知识页:删「从市场安装的」tab 与「发布到市场」入口(含预检 Modal);保留 我创建的/全局公共授权。
- 智能体页:自建 Agent 全生命周期与槽位绑定保留(市场 handoff 入口随 handoff 路由移除,页面本身无需改动,执行时核实)。
- 规划候选/文风检查:**studio 现状无 UI 入口(§2.9),1a 无前端动作**;1b 实现后端时一并新建 UI。1.0.0 缺口清单 P0-11 的「studio 有死入口」表述在 S10 订正。
- market 相关 e2e spec 与 MSW handlers:标记隔离(skip/tag),不删。
**执行交接(2026-07-08)**:本轮 studio 路由/侧栏/account/knowledge 裁剪已经落地,route-only Playwright 可证明市场与 handoff 入口不可达;但这不是 S5 完成证据。S5 的完成门仍是 MSW-off 创作主线 e2e 全绿,当前受标准后端启动的 Flyway V1 function owner mismatch 阻塞,需在下一轮修复 live 后端启动口径后补跑。
**muse-admin**:
- `muse.ts` 静态菜单:「市场治理」「用户与权限」加 hideInMenu(**不删路由**,故 `muse.test.ts` 路由注册断言不受影响)。
- 「New-API 与用量」页(`views/muse/newapi`):后端管理口已关,**页面现含网关绑定/额度配置写命令表单,1a 须移除/禁用写入口,降级为本地用量只读观测**;补断言(无写按钮、无写 API 调用)。
- yudao 标准菜单(用户/角色/租户/部门/会员/支付/公众号/BPM/yudao-AI 娱乐面):在部署库 `system_menu` 数据停用,产出幂等 SQL 脚本随部署文档管理。注意菜单双源:前端 `muse.ts` 管 Muse 组,「基础设施」「系统管理」等属 `system_menu` 数据侧;另核查 admin 前端 yudao demo 静态路由模块(`leave.ts/pay.ts` 等)在菜单合并机制下是否漏出,漏出则一并 hideInMenu。
- 保留菜单:元结构定义、功能编排、AI 配置、全局知识库、任务治理、日志审计、基础设施、菜单管理。
### 5.5 验证门禁体系适配(先于摘除执行)
本项目的脊柱规则是机械门禁反假绿,摘除 market 必须先让门禁体系「诚实变化」而非变红或假绿。v0.2 按评审补全清单:
- **S0 先坐实现状**:真跑 `run-p1r-verification.sh local`。已静态坐实 `P1rMarketRealApiGateTest` 硬断言(241/47)与台账 JSON(242/48)矛盾,**先修现红门再动别的**(对齐现实计数,并查随附域计数)。
- **测试树编译处置**:14 个 market 依赖测试文件(§2.4 清单)通过 maven-compiler-plugin `testExcludes` + surefire excludes 的 **`market-assembled` profile**(默认排除、激活即回)从默认构建剥离。不迁移(它们依赖 muse-server 全栈上下文,迁到 market-server 跑不起来)、不降 test-scope(会造成 prod/test 装配分叉:测试上下文装配 market 而生产不装)。代价:排除期间这些 IT 零维护漂移,恢复时可能需修,登记在案。
- **接口覆盖门**:改造涉及两个测试与一份台账——`P1rMarketRealApiGateTest` 整体随 profile 剥离;`P1rApiCoverageReportTest` 为 market 域引入显式 `dormant` 口径(dormant 域豁免 completed 计数与 testFiles 强制,总量 242 不变、completed 分母降为 210),**断言从硬编码常量改为按域派生**,恢复时激活 profile 自动回门。禁止直接删台账条目。
- **admin 侧**:market/account 治理相关 Playwright spec 与 globalSetup 的 market fixture 标记隔离(RC 本就未把 admin Playwright 纳入门禁,不新增强制门);`muse.test.ts` 不受影响(hideInMenu 方案)。
- **Flyway**:market 表迁移脚本保留照迁(§2.12,评审确认安全)。
- **ArchUnit**:market 不在 muse-server classpath 后,BC 边界规则对其暂不生效属预期,记录在案;`AgentsInfraIntegrityTest` 是文件级检查,不受影响。
- **OpenAPI 契约**:`docs/api-contracts` 的 market 契约**原地保留**——双重理由:`ContractFirstGateTest`(local 层)硬要求 market 契约目录存在;删端点会触发 openapi-diff 拦截。如需标注 dormant,只允许加纯增量元数据(`x-` 扩展/description),不得动端点与参数。
### 5.6 部署收口
- **Dify 部署是 1a 的第一步**(现在还没有实例):按官方 docker-compose 钉版本部署于内网(mini-infra 或 mini-desktop,执行时定),建两个 app(写作透传/全书解析)与知识库 API key;凭据与地址按内网惯例记入项目凭据文档与 `p1r-external-acceptance.env`(新增 `MUSE_AI_DIFY_*`/`MUSE_KNOWLEDGE_DIFY_*` 样例),RAGFlow 条目移除。
- **Muse 侧单机编排**:产出单人版 docker-compose(muse-server + PostgreSQL + Redis),Dify 用官方 compose 独立部署、以 env 指向(升级互不牵连);新库 provision 复用「yudao 基座 SQL + Flyway 迁移」既有配方。
- **无 Nacos 干净启动验证**:确认单体在无 Nacos 环境启动无阻塞、日志无持续报错,必要时显式关闭服务发现注册。
- 验收:单机 compose 从零起全栈,黄金旅程 smoke 全绿(见 §9)。
---
## 六、单人创作功能清单(边界 / 要求 / 目标)
阶段一后的产品形态,按六个功能块定义。「1a 后」指精简收敛完成时的状态,「1b」指打磨波次的目标。
### 6.1 作品与写作台(content)
- **边界**:含 作品列表(建/删/进入)、WorkspacePage 写作台(章节大纲 + Tiptap 编辑器 + 块结构条 + 右侧 AI/规划/来源/历史 Tab + 导入/导出/知识/记录入口)、IndexedDB 自动保存安全网、版本历史与任意两版 diff、txt/md/docx/epub 导入向导、范围/格式导出下载。不含:协作、分享、发布。
- **要求**:正文 Canonical 唯一写入方不变;Block 写入带 expectedRevision 乐观锁;Accept Suggestion 是候选进正文唯一入口;导出走真实 FileService。
- **目标**:1a 全量保留现状能力(已真验证),删除废弃 EditorPage;1b 补版本恢复写入(P0-03:从历史版本恢复走 Content 写边界,产新 revision + 来源归因 + 审计 + 旧草稿失效)。
### 6.2 AI 辅助创作(ai)
- **边界**:含 候选生成→SSE→Diff→采纳/拒绝全链、智能体工作台(自建 Agent 增删改/版本/归档/沙盒试用)、作品槽位绑定/替换/解绑、质量评估(本地确定性执行器)。工作流型智能体的「深编辑」定位为 Dify 控制台能力,Muse 只保存引用并受控调用。不含:LLM judge、AgentScope、市场 Agent 安装。
- **要求**:AI 只产 Shadow 候选,绝不直写 Canonical;「完整 provider 原文不落库」的数据主权口径未经 S7.0 裁决不得推翻;Dify 未配置 fail-closed;凭据不落库不进日志;usage 摘要落本地用量记录。
- **目标**:1a 生成主链与试运行全走 Dify(按 S7.0 裁决的输出通路交付,候选对用户全文可见、可审可采纳),导入解析走 Dify parser;1b 补规划候选生成(P0-11,规划台新建 UI + 真实生成)与文风检查(P0-12,可追踪结果、缺 AI 诚实降级)。
### 6.3 知识库(knowledge)
- **边界**:含 我创建的知识库 + 全局公共授权库、资料上传→索引→状态轮询、知识草稿(确认/忽略/重验)、实体关系 CRUD 与图谱可视化(Muse 自有 Canonical 数据)、作品级来源绑定与读回、授权过滤检索供 AI 上下文。不含:市场安装库、前端发布入口(后端 publish 预检/快照保留为 Knowledge 本域能力,market 装配相关路径 fail-closed/空结果,见 §5.3.3)、GraphRAG、recheck 外部校验 worker。
- **要求**:进 Local KB 唯一入口=用户确认草稿;检索前授权双门全在 Muse 侧;运行时故障检索降级为空、不阻断生成、不伪造结果;parse-poll worker 单人配置显式开启。
- **目标**:1a 运行时切 Dify Datasets(上传/轮询/检索三链真验证);1b 补处理状态与失败恢复 UI(P0-06:失败原因、重试/删除/重新上传入口,基于 Dify indexing-status 的失败态)。
### 6.4 元引擎(meta)
- **边界**:含 admin 侧 MetaSchema 定义/版本/发布回滚、字段预校验、用户作品级覆盖读端。不含:可视化 schema 编辑器(P2-05)、影响预览 all-real 用量建模(P0-09)。
- **要求**:发布链保持现有校验;影响预览维持现状口径(planning 真实计数,其余维度诚实标注),单人自担风险,不伪造 0 影响为「已验证」。
- **目标**:阶段一维持现状,不投入;P0-09 划归多用户线。
### 6.5 账户(member 最小面)
- **边界**:含 单账号登录/会话、个人资料、用量观测(Token 用量,数据源为本地用量记录)。不含:注册、权益/配额管理、购买/授权/发布记录、安全深面(2FA/改密/会话吊销)、偏好通知、New-API 绑定。
- **要求**:认证仍是服务端可信边界(不做免登);多用户子域按 §5.3.4 三类机制收口后对应 API fail-closed。
- **目标**:1a 完成裁剪;P0-07/P0-08 划归多用户线。
### 6.6 治理端(admin,单人自治理)
- **边界**:含 元结构定义、功能编排、AI 配置(系统 Agent/Prompt 模板/Tool Grant/质量门控 + Dify 引用录入与控制台跳转)、全局知识库、任务治理、日志审计、基础设施运维、菜单管理;「New-API 与用量」降级为本地用量只读观测。不含:市场治理、用户与权限治理、会员/支付/公众号/BPM、网关绑定与额度写命令。
- **要求**:AI 配置页的 Dify 引用录入沿用专题-05 的控制台跳转与 credentialRef 约定;质量策略回滚死入口(P1-17)在 1a 保持诚实提示,不纳入阶段一实现。
- **目标**:1a 完成菜单裁剪、newapi 页禁写与 Dify 配置面可用。
---
## 七、阶段划分与步骤计划
```mermaid
flowchart LR
subgraph 1a["阶段 1a:地基(服务收敛+隔离+部署)"]
S0[S0 坐实并修复<br/>现红门禁基线] --> S2[S2 门禁与测试树适配<br/>profile 剥离/覆盖门 dormant]
S1[S1 部署 Dify<br/>app/凭据 + contract test] --> S6[S6 知识运行时<br/>Dify adapter]
S1 --> S70[S7.0 输出契约裁决<br/>活体实测定分支]
S2 --> S3[S3 market 摘装配<br/>兜底 Bean+pom+语义端点核验]
S3 --> S4[S4 member/租户/worker<br/>yudao 脚手架关停/admin 收口]
S4 --> S5[S5 studio 裁剪]
S70 --> S7[S7 生成主链切 Dify]
S7 --> S8[S8 导入解析 Dify parser<br/>含 provider 选择机制]
S5 & S6 & S8 --> S9[S9 部署收口<br/>compose+黄金旅程 smoke]
S9 --> S10[S10 文档沉淀<br/>专题-05/ADR/缺口清单订正/总账]
end
1a --> 1b["阶段 1b:打磨<br/>P0-11 规划候选 → P0-12 文风检查<br/>→ P0-06 知识失败恢复 → P0-03 版本恢复<br/>→ P1-11 SSE 接组件"]
```
说明:S0 是一切的前置(基线必须绿);S2 先于 S3(门禁与编译处置先行,保证摘除当天三层验证可跑且不假绿);S1→S6/S7 的 Dify 线与 S2→S5 的隔离线分属不同模块,可并行推进;S7.0 裁决若走分支①(全文通路立项),S7 工期按横切项重估,且其结果决定 S9 黄金旅程「候选可采纳」判据的具体口径。每个 S 都要独立可验证并回写总账。1b 各项按序独立立项,验收判据沿用缺口清单的「最小验收标准」并把外部模型调用替换为 Dify。任务级拆解见[执行计划](2026-07-07-2.0.0单人版改造-execution.md)。
---
## 八、风险与兜底
| 风险 | 影响 | 兜底 |
|---|---|---|
| 门禁基线现红(242/241 矛盾)未先处理 | 后续所有「全绿」验收失去基线意义 | S0 首事项真跑坐实并修复,之后才动摘除 |
| muse-server 测试树编译依赖处理不当 | 摘除当天三层验证全部编译失败 | S2 profile 化 testExcludes 先行;每步全量 local/real-PG 回归后再进下一步 |
| S7.0 裁决走分支①时全文通路是横切开发 | S7 工期被低估,反噬 S9 判据 | 裁决前置、独立立项估期;未达标前生成主链不切换,New-API 配置保留到 S7 验收通过 |
| Dify Datasets 检索质量与 RAGFlow 不同(分块/embedding 差异) | AI 上下文相关性下降 | S6 用真实创作素材做检索对比 smoke;不满意调 Dify 分块(父子分块/高质量索引);检索失败降级为空的既有语义保底 |
| 跨 dataset score 可比性(单库端点循环合并隐含同源假设) | 多库 topK 合并排序失真 | 单人库数个位数;统一 embedding 模型;观测日志留存 score 分布,失真再做归一化 |
| Dify API 版本漂移 / 映射假设不实 | 适配层返工 | S1 contract test 钉版本先行;compose 钉版本;升级前先跑 live 验收 |
| Dify blocking 长文生成超时 | 长章节生成失败 | 1a 上调读超时;生成失败可重试(既有 RetryPolicy);streaming 已预留,列为后续项 |
| 导入解析截断/JSON 不严格 | 全书解析静默截断 | JSON 完整性校验 + 章节数合理性检查;解析失败可重试且 job 诚实置 failed;S8 完成门含 New-API 缺失反向测试 |
| yudao 脚手架关停遗漏 | 「只直连 Dify」名存实亡,外呼出口失控 | 9 项逐一改 false + 启动断言兜底;`.enable`/`.enabled` 双命名坑写入配置文档 |
| 明文 key 已入 git 历史 | 凭据泄露面 | 1a 从 yaml 移除并提示用户自查轮换(多为上游 demo key);不由 agent 代决轮换 |
| market 语义端点(knowledge 侧)漏收口 | 验收只看 /market 路径会假绿 | §5.3.3 显式行为断言纳入 S3 验收 |
| 排除期 market IT 漂移 | 恢复 market 时测试红 | 登记在案,恢复 checklist 含「激活 profile 修红」 |
---
## 九、验证计划(1a 验收判据)
完成=以下全部机械证据齐备,任何一项缺失不得声称 1a 完成:
0. **基线**:S0 后 `run-p1r-verification.sh local` 全绿(修复 242/241 矛盾后的真实基线)。
1. **local 门禁**:摘除与收敛完成后 local 全绿(覆盖门 market 域 dormant、断言域派生化后分母自洽,无 catch_all/missing)。
2. **real-PG 层**:非 live IT 在专属 `_test` 库全绿(含新增迁移、market 摘除形态下的全量回归)。
3. **Dify live 层**(opt-in,对齐既有 live IT 模式):Datasets contract test;知识链(建库→上传→索引状态→检索);生成主链(按 S7.0 裁决口径:候选产出→三审→用户可见全文→可采纳);导入解析(全书 JSON)。RAGFlow live IT 已退役。
4. **fail-closed 反向证据**:Dify 未配置/凭据错误时,生成、检索、导入均拒绝服务且错误码正确;New-API parser 在无配置时 fail-closed;启动断言证明无 yudao 厂商 provider Bean。
5. **前端**:studio tsc/vitest/build 绿;MSW-off Playwright 创作主线 spec(候选采纳、导入向导、导出下载、知识草稿/图谱/绑定、agent 生命周期)全绿;market spec 已隔离不再计入。admin:muse.test.ts 绿,「市场治理/用户与权限」菜单不可见断言,newapi 页无写入口断言。
6. **单机活体**:单人版 compose 从零起全栈(基座 SQL + Flyway provision),黄金旅程 smoke:登录→建作品→写正文→AI 候选(全文可见)→采纳→上传资料→检索命中→导出下载,全程真后端真库真 Dify。
7. **隔离核验**:`/app-api/market/**``/admin-api/market/**` 物理 404;knowledge 侧 market_kb binding/handoff/fork/installed 路径 fail-closed/空结果;publish-prechecks/publish-snapshots/publish-readiness 只保留 Knowledge 本域预检/快照记录,不得产生 market 资产、安装、授权事实;studio 无市场入口;多用户 worker 无一运行。
---
## 十、恢复路径(多用户回归时,checklist)
market 恢复不是「取消 pom 注释」一行,按此清单执行:
1. muse-server pom 取消 market-server 注释(真实现 Bean 自动优先于兜底,接口级无冲突);
2. 激活 `market-assembled` maven profile(14 个测试文件回编译,修排除期漂移的红);
3. 覆盖门 market 域退出 dormant(域派生断言自动回门,核对 completed 分母回 242 口径);
4. admin/studio 前端:revert 裁剪提交或按届时 UI 重做入口;admin Playwright market spec 解除隔离;
5. 部署库 `system_menu` 恢复对应菜单数据(幂等 SQL 反向脚本);
6. OpenAPI market 契约一直原地未动,无动作;Flyway market 表一直在,无动作。
member 子域:打开对应 enabled 开关即回(quota/归因/绑定 worker 与管理口);账户配额如需网关对账,再评估恢复 New-API 直连或改走 Dify 侧核算。权限收口:多用户化时在服务端补 AOP/权限切面统一鉴权(届时新 ADR),本阶段不预建。New-API/RAGFlow:adapter 代码分别为「保留未配置」/「已删除(git 可回)」;恢复 RAGFlow 需按新集成重走契约与验收,不承诺兼容。
---
## 十一、文档与沉淀影响清单(S10 交付)
| 文档 | 动作 |
|---|---|
| `专题-05-AI统一交互协议与外部AgentAdapter设计.md` | v0.2:修订 §1.5、**§2 非目标**、§7 知识运行时、§8 用量归属;登记单人阶段口径与 S7.0 裁决结果 |
| `架构-03-关键决策与原则(ADR).md` | **ADR-005、ADR-021 标注 superseded-by**;ADR-006(GraphRAG)订正;增补 2.0.0 单人阶段 ADR |
| 根 `CLAUDE.md` 决策表 | 「图查询依赖 RAGFlow GraphRAG」订正 |
| `docs/mvp/1.0.0-产品功能缺口待办清单.md` | **P0-11 表述订正**(studio 无 UI 入口,非「有入口点不动」) |
| `.agents/knowledge/external-deps-and-gotchas.md` | RAGFlow 条目退役;Dify 部署事实、两类凭据坑、`.enable/.enabled` 命名坑写入 |
| `.agents/knowledge/tech-decisions.md``project-and-architecture.md` | 同步收敛决策与 knowledge BC 运行时表述 |
| `docs/mvp/` | 新增 `2.0.0-单人版交付计划.md`(承接进度口径,1a/1b 里程碑);总账逐任务回写 |
| `docs/项目功能与进度总览.md` | 2.0.0 口径改版(功能清单以本文 §6 为准) |
| `docs/api-contracts/market/*` | 原地保留(ContractFirstGateTest 依赖其存在);dormant 标注仅限 `x-` 扩展元数据 |
| 本文 | 评审通过后按执行计划推进;完成后归档 `docs/agent-specs/archive/` |
---
## 十二、评审待确认项
1. Dify 部署宿主选 mini-infra(与 PG/Redis 同机)还是 mini-desktop(与构建/e2e 同机)?——影响 S1,倾向 mini-infra。
2. RAGFlow 现有实例在 Muse 退役后是否整体下线(其上仅验收产物)?——不影响本方案,只影响 infra 清理。
3. §5.2 端口改名(RagFlowKnowledgeRuntimeClient→KnowledgeRuntimeClient)与「表列名不动」的取舍是否接受。
4. S7.0 若坐实现状无全文通路,是否同意按分支①立项「运行时全文实时通路」(推荐:单人创作工具候选必须全文可见;正文不持久化,守住数据主权不变式)。
5. yudao 脚手架 yaml 中的明文 key(疑似上游 demo key)是否需要你自查轮换。
---
## 十三、v0.1→v0.2 修订记录(2026-07-07,Codex + Opus 双对抗评审)
| 级别 | 评审发现 | v0.2 处置 |
|---|---|---|
| P0 | muse-server 测试树 14 文件编译期硬依赖 market-server,三层验证全走 `-pl muse-server -am test`,「运行期分组」方案不成立(Opus,已核实) | §2.4 新增事实;§5.5 改为 profile 化 testExcludes 编译期方案;§10 恢复 checklist 化 |
| P0 | 「补完整输出通路对齐 New-API 口径」前提错误:两 provider 均摘要级,完整原文不落库是明写数据主权设计,三审链 provider 无关已通(Opus+Codex,已核实) | §2.10 订正事实;§5.1 改为 S7.0 裁决 + 两分支;§4 增数据主权口径红线;§12.4 增待确认项 |
| P0 | yudao 厂商脚手架 9 项 `enable: true` 且明文 key 在仓,违背「只直连 Dify」(Codex,已核实且比评审所述更重) | §2 表格新增第 4 行;§5.1.5 显式关停+启动断言+key 处置;§8 风险 |
| P1 | 覆盖门硬编码 241/47 与台账 242/48 矛盾,local 门禁疑现红(Opus,静态坐实) | §2.11 新增;S0 立为首步 |
| P1 | 门禁清单遗漏:P1rMarketRealApiGateTest、coverage testFiles/summary 连带、ContractFirstGateTest 要求 market 契约存在(Opus/Codex,已核实) | §5.5 重写补全 |
| P1 | 市场语义端点不随 market 模块消失(knowledge publish 三端点、installed KB),按路径验收会假绿(Codex,已核实;2026-07-08 已裁决 publish 三端点保留 Knowledge 本域能力,market 装配路径 fail-closed) | §5.3.3 新增;§9.7 断言扩 |
| P1 | admin newapi 页含写命令,与「只读保留」冲突(Codex,已核实) | §5.4/§6.6 明确禁写+断言 |
| P1 | 导入解析是单实现单点注入,New-API 退出前须先具备 provider 选择与反向测试(Codex,已核实) | §5.1.3 完成门 |
| P1 | Dify Datasets 映射是假设,须 contract test 前置钉版本(Codex) | §5.2 契约先行 |
| P1 | member「全走既有开关」口径失真(仅 1 个真开关+worker 属性+兜底;entitlement 常开)(Opus,采信) | §5.3.4 重写 |
| P1 | supersede 清单漏 专题-05 §2 非目标、ADR-005、ADR-021(Opus,已核实) | §3/§11 补全 |
| P2 | studio 规划/文风无 UI 入口,缺口清单 P0-11 表述不准(Opus,已核实);兜底 Bean 须真实方法体;事件消费者「架空」措辞;parse-poll worker 需显式开;跨库 score 风险;`.enable/.enabled` 命名坑;端口改名定性;恢复路径 checklist;admin 菜单双源与 demo 路由核查;AgentHandoffLanding 位置 | §2.9/§5.2/§5.3/§5.4/§8/§10 逐条吸收 |
| 降级 | 「admin muse.test.ts 会红」(Codex)——实测其仅断言路由注册,hideInMenu 方案下不红 | §2.9/§5.5 记录为不受影响 |

View File

@ -0,0 +1,135 @@
# S7 生成主链全文通路(分支①)执行计划(执行版)
- 版本v1.0
- 日期2026-07-07
- 承接:[评审版 v0.2](2026-07-07-S7-生成主链全文通路-review.md)(现状已三路测绘校正;三个材料级点所有者 2026-07-07 已裁:①独立加密缓存表 ②采纳恒回传完整正文 ③护栏+合规审上移到完整正文)。**设计意图/边界/主权红线以 review v0.2 为准,本文只做任务级拆解**;两文冲突以 review v0.2 为准并回改本文。
- 读者:执行 agent每个 S7x 可独立派工)与项目所有者。
---
## 0. 执行纪律(每步适用)
1. **主权红线(最高优先)**:完整正文只允许存在于「执行线程内存 / 瞬态加密缓存TTL+决定即清) / SSE 传输 / 客户端」;**任何一步都不得把完整正文写入 `muse_ai_suggestion``muse_content_block` 或任何长期列**。每步验收含「候选表 content_snapshot.content 仍是摘要、完整正文不在长期列」的机械断言。
2. **完成=机械验证**每步「验收」命令真跑留证改动后按依赖链回归ai 模块单测 → local → real-PG涉 Dify 再跑 live
3. **契约先行**`chunk.data.content`/`sequenceNo` 已声明(`docs/api-contracts/ai/openapi.yaml` SSEChunkEvent只填充不改 schema如需新增瞬态取全文端点则先改契约再实现。DDL 只走 `sql/muse/V<next>__*.sql`(编号取当前最大+1现状最大 V34
4. **最小改动 + 中文注释**:只动本步清单文件;完整正文相关字段/表必须有「永不落长期库、TTL、决定即清」的中文注释与脱敏日志。
5. **回写**:每步完成回写 `docs/mvp/进度总账.md` + `muse-module-ai/.agent`;不新增过程文档。
6. **环境与坑**real-PG/live 按 `.agents/knowledge/external-deps-and-gotchas.md` §四(清代理、`_test` 库、`-am` 防 stale jar、`-DreuseForks=false`)。
7. **派工分档**:各步执行子代理 `opus`;纯机械(配置值、菜单)可 `haiku`S7d 的 Dify fail-closed 语义与 live 验收如遇跨模块硬判可升 `fable` 或主会话终裁。
---
## 1. 步骤总图
```mermaid
flowchart LR
S7a["S7a 捕获+护栏<br/>(provider 无关)"] --> S7b["S7b 瞬态载体+SSE 送达"]
S7b --> S7c["S7c 前端采纳基准+所见即所写"]
S7a --> S7d["S7d 生成 provider 切 Dify"]
S7c --> DONE["S7 验收"]
S7d --> DONE
DEP["依赖: Muse 专属 Dify workspace"] -. 仅 S7d .-> S7d
```
**S7a→S7b→S7c 不依赖 Dify workspace可先行连跑**S7d 依赖 Dify workspace 就绪,可与 S7c 并行准备、workspace 到位后收口。
---
## S7a 捕获完整正文 + 护栏上移provider 无关,不依赖 Dify
| | |
|---|---|
| 前置 | 无 |
| 触及 | `muse-module-ai-server``MuseAiRuntimeClient`RuntimeResult`RealNewApiMuseAiRuntimeClient``RealDifyMuseAiRuntimeClient``MuseAiCandidateReviewService``MuseAiRuntimeProjectionService` |
**动作**
1. **运行时结果加不落库全文字段**`facade/MuseAiRuntimeClient.java``RuntimeResult`L70-78增加**仅内存**字段(如 `fullOutput`中文注释写明「provider 完整正文,仅进程内传递,永不持久化到任何列/表;落库只用 outputSummary 摘要」。
2. **两个 real client 成功分支捕获完整正文**
- `RealNewApiMuseAiRuntimeClient.success()`L163-192`shortOutputSummary`L172**之前**保留完整 `content`L171先跑护栏见 3通过后把完整正文塞进 `RuntimeResult.fullOutput`,仍派生 60 字摘要供落库。
- `RealDifyMuseAiRuntimeClient` chat/workflowL227-285`answer`L230/`text`L253同理捕获注意 Dify 完成原因硬编码为常量L239不可用作完整性信号。
3. **护栏 + 合规审上移到完整正文(裁决③)**`MuseAiCandidateReviewService.review`L42-84的输入从摘要改为**完整正文**——完整性校验(长度/结构合理性,判断截断,不信 provider finishReason+ 凭据痕迹合规扫描均作用于完整正文;疑似截断/不合规 → 返回 `RuntimeFailure`(可重试),**不产出候选**。`MuseAiRuntimeProjectionService`L250-283审通过后 `content_snapshot.content` 仍写摘要L328 不变、三审字段照旧L361-377
4. **落库口径不变**:候选表恒为摘要 + 三审;`fullOutput` 不进 `outputSummary` Map、不进任何列。
**验收**
- ai 模块单测:两 adapter 返回 `fullOutput`(日志脱敏,只打长度/hash护栏对完整正文——截断样本→failure/可重试、正常→pass合规扫描命中凭据痕迹→failure。
- 静态/代码审查断言:`fullOutput` 无任何持久化路径(不被 mapper/DO 引用)。
- `run-p1r-verification.sh local` 全绿。
**回滚**`fullOutput` 字段与护栏输入改动 revert 即回;无 DDL、无契约变更。
---
## S7b 瞬态载体 + SSE 送达(裁决①:独立加密缓存表)
| | |
|---|---|
| 前置 | S7a |
| 触及 | 新增瞬态全文表DDL、executor、`MuseAiRuntimeProjectionService`(事件写入)、`MuseAiTaskStreamServiceImpl`(读侧 chunk、采纳/放弃 ownerpurge |
**动作**
1. **瞬态全文存储(仿 `MuseAiRuntimePayloadStore`**:新增 `sql/muse/V36__ai_generated_fulltext_transient.sql` 建独立表key by taskId、加密载荷列、`expires_at` TTL、租户列。**DDL 版本协调S6 核验确认)**:当前最大 V34S6 的 `runtime_batch_id` 迁移占 V35本瞬态表占 V36若实际提交顺序不同按提交时最大版本+1 顺延、勿撞号。DAO/Store 对齐 `MuseAiRuntimePayloadStore`L24-46的加密 + 短 TTL 语义。**表注释写明「瞬态出向完整正文TTL + 决定即清,非系统记录,永不作为正文来源落 Canonical」**。
2. **executor 写入 + 终态 purge**`MuseAiRuntimeJobExecutor.execute`L75-95在拿到 `RuntimeResult` 后、投影前,把 `fullOutput` 写入瞬态存储;沿用现有终态 `finally`L62-64 `runtimePayloadStore.remove` 同处)追加瞬态全文的兜底清理(异常/超时也不残留)。
3. **事件表发 chunk 引用行**:放宽 `MuseAiRuntimeProjectionService.appendTaskEvent``isValidTerminalEvent`L319-322以允许写**一个非终态 chunk 事件**——`sequence_no` 排在 done 之前,`payload_summary` 只放**指向瞬态存储的引用**(如 `{contentRef, sequenceNo}`**不含正文**。保持每任务至多一条终态的部分唯一索引不变chunk 非终态,不受该约束)。
4. **SSE 读侧回取**`MuseAiTaskStreamServiceImpl.chunkData`L279-285遇到带 `contentRef` 的 chunk 行时回瞬态存储取完整正文填 `content`;缓存缺失(已 purge/过期)→ `content=""`(用户已决定,无害)。回放/轮询/重连语义不变(重放 chunk 引用行→再回取TTL 内可续)。
5. **决定即清**采纳Content `mergeBlockSuggestion` owner与放弃reject owner成功路径 purge 该 taskId 的瞬态全文。
**验收**
- real-PG IT新增仿 `P1rContentMergeGeneratedSuggestionIT` 模式):真生成后 SSE 重放的 chunk 事件 `content` = 完整正文(来自瞬态存储,长度 > 摘要阈值);**候选表 `content_snapshot.content` 仍是摘要、完整正文不在任何长期列**(主权不变式断言);采纳/放弃后瞬态表该行已删断线重连seq 0 全量重放仍能取到完整正文TTL 内)。
- 契约结构门绿(`chunk.data.content` 为已声明字段,非破坏)。
- `local` + `real-pg` 全绿。
**回滚**:以开关门控 chunk 引用行发射 + 瞬态写入;关闭即回退「仅 done」旧行为V35 迁移向前兼容(表未用即空)。
---
## S7c 前端接成采纳基准 + 所见即所写(裁决②)
| | |
|---|---|
| 前置 | S7b |
| 触及 | `muse-studio``AIPanel.tsx``useAcceptSuggestion.ts``sse.ts`(健壮性)、`CandidatePanel.tsx`(如需) |
**动作**
1. **显示/交接零改动确认**`AIPanel.tsx``streamContent`L48/56已接线 `chunk.data.content`L130-132并实时渲染L189`handleStreamDone`L63-89优先流正文L71-75→ 完整正文经既有值拷贝链落到 `CandidatePanel` 可编辑 `reviewedText`L97-103。本步只需回归验证该链在真 chunk 下贯通。
2. **采纳恒回传完整正文(裁决②)**`useAcceptSuggestion.ts``shouldMergeAfterEdit`L39-51改为**恒带 `finalContent`** = 用户审定的完整正文(原样采纳也带);即采纳恒以「用户回传最终正文」语义写 Canonical。**须先核对后端 `suggestion-merges` 合同**:确认 `accept_as_is + finalContent` 或统一走 `modify_then_merge` 的后端语义,避免前端恒传但后端忽略。契约文档 `docs/api-contracts/content/*``MergeBlockSuggestionRequest` 同步(如语义调整)。
3. **健壮性:区分「流不完整」与「流为空」**:因完整正文不落长期库,中途断连可能只收到截断的流正文,而 `handleStreamDone` 优先非空 refL71-75会静默采信截断。改为收到 done 但流正文疑似不完整(如与 done 的完整性标记/长度不符)→ 提示重生成,**不静默采纳截断**;确实为空→回源(此时只剩摘要,明确提示而非当正文采纳)。
4. `quality_check` 事件当前被 AIPanel 丢弃(次要),本步不强制接入。
**验收**
- `tsc -b``vitest run``vite build` 绿;`AIPanel.contract.test.tsx` 扩展「真 chunk→完整正文→恒回传 finalContent」断言。
- MSW-off Playwright `ai-generation.spec` 扩展:渲染正文长度 > 阈值且与 provider 输出一致(非 60/80 字摘要);采纳后 DB 反查 Canonical 落**完整正文**(长度一致)、归因 ai_suggestion、revision 递增。
- 隔离/回归:创作主线其余 spec 无回归。
**回滚**revert 前端提交;后端不受影响。
---
## S7d 生成 provider 切 Dify依赖 Muse 专属 Dify workspace
| | |
|---|---|
| 前置 | S7afullOutput 透出S7b/c 不阻塞;**依赖 Dify workspace 就绪** |
| 触及 | 在用系统 Agent 版本 config、单人配置、`RoutingMuseAiRuntimeClient` fail-closed |
**动作**
1. **provider 切换**:在用系统 Agent 版本 config 设 `runtimeProvider=dify` + `providerRef.dify`app=「muse-写作透传」credentialRef→S1 凭据,须落 Muse 专属 workspace`muse.ai.dify.enabled=true` 进单人配置;`non-stream-read-timeout-seconds` 对齐 ≥180s 总预算(现 Dify 90/180
2. **完整性信号**Dify 完成原因硬编码常量(`RealDifyMuseAiRuntimeClient` L239不可信 → S7a 的完整正文护栏是唯一截断信号,补 Dify 形态断言。
3. **fail-closed 反向**`RoutingMuseAiRuntimeClient`L18-53现有 requiresSourceRefs fail-closed L37-40补「dify 未配/凭据错 → 拒绝、不回退 New-API」路径与断言。
4. **New-API 保留**:本步不摘 New-API 配置S8 验收后才摘)。
**验收**
- live IT新增/扩展 `P1rDify*`opt-inDify 生成 → Shadow 候选(三审齐、审的是完整正文)→ 完整正文经 SSE 可见 → 回传采纳写 Canonicalfail-closed 反向dify 凭据错→拒绝,无 New-API 回退)。
- `local` + `real-pg` 全绿MSW-off `ai-generation.spec` 真 Dify 复跑绿。
**回滚**:在用 Agent `runtimeProvider` 拨回 `new-api` 即回(兼容逻辑未动);配置 revert 独立。
---
## 2. 全局完成判据
- review v0.2 §9 验收全部逐条留证:**主权不变式**(候选/正文表无完整正文、瞬态表决定即清)、**可见**(前端渲染完整正文)、**所见即所写**Canonical=回传完整正文)、**护栏**(作用于完整正文)、**Dify 形态 + fail-closed**、**local+real-PG 回归**。
- 里程碑M1=S7a捕获+护栏,无 Dify 依赖)/ M2=S7b+S7c全文经 SSE 可见、所见即所写采纳,仍 New-API/ M3=S7d切 Dify。每 M 点回写总账可暂停评估。
## 3. 关键风险(承 review §11
瞬态正文暴露TTL+决定即清+加密+脱敏日志、断连丢正文致静默采纳截断S7c 区分不完整/为空)、单 chunk 体积必要时分片、Dify 完成原因不可信(护栏为唯一信号)。

View File

@ -0,0 +1,141 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>S7 生成主链全文通路(分支①)· 评审版一览</title>
<style>
:root{
--ink:#1a2233; --muted:#5b6678; --line:#dfe4ec; --bg:#f6f8fb; --card:#fff;
--durable-bg:#e8f0ff; --durable-bd:#3358cc; --durable-ink:#0b2a6b;
--trans-bg:#fff4e0; --trans-bd:#cc7a00; --trans-ink:#6b3d00;
--guard-bg:#e9f9ec; --guard-bd:#2f9e44; --guard-ink:#12521f;
--bad-bg:#fdecec; --bad-bd:#d64545; --bad-ink:#7a1c1c;
}
*{box-sizing:border-box}
body{margin:0;font:15px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;color:var(--ink);background:var(--bg)}
.wrap{max-width:1080px;margin:0 auto;padding:28px 22px 60px}
header h1{font-size:23px;margin:0 0 4px}
header .sub{color:var(--muted);font-size:14px}
header .meta{color:var(--muted);font-size:12.5px;margin-top:6px}
h2{font-size:16px;margin:34px 0 12px;padding-bottom:6px;border-bottom:2px solid var(--line)}
.intent{background:linear-gradient(90deg,#eef3ff,#f6f8fb);border:1px solid var(--durable-bd);border-radius:12px;padding:14px 18px;margin-top:16px;color:var(--durable-ink);font-weight:600}
.grid{display:grid;gap:12px}
.cols-3{grid-template-columns:repeat(3,1fr)}
.cols-4{grid-template-columns:repeat(4,1fr)}
@media(max-width:820px){.cols-3,.cols-4{grid-template-columns:1fr}}
.card{background:var(--card);border:1px solid var(--line);border-radius:11px;padding:13px 15px}
.bad{background:var(--bad-bg);border-color:var(--bad-bd)}
.bad .k{color:var(--bad-ink)}
.k{font-weight:700;font-size:13.5px;margin-bottom:4px}
.card p{margin:0;color:var(--muted);font-size:13px}
/* pipeline */
.flow{display:flex;flex-wrap:wrap;align-items:stretch;gap:8px;margin-top:6px}
.node{flex:1 1 150px;border-radius:10px;padding:10px 12px;font-size:12.8px;border:1.5px solid}
.node .t{font-weight:700;font-size:13px;margin-bottom:3px}
.node small{color:inherit;opacity:.85}
.durable{background:var(--durable-bg);border-color:var(--durable-bd);color:var(--durable-ink)}
.transient{background:var(--trans-bg);border-color:var(--trans-bd);color:var(--trans-ink)}
.guard{background:var(--guard-bg);border-color:var(--guard-bd);color:var(--guard-ink)}
.arrow{align-self:center;color:var(--muted);font-weight:700;padding:0 2px}
.legend{display:flex;gap:16px;flex-wrap:wrap;margin:10px 0 2px;font-size:12.5px;color:var(--muted)}
.chip{display:inline-flex;align-items:center;gap:6px}
.dot{width:11px;height:11px;border-radius:3px;display:inline-block;border:1.5px solid}
.dot.d{background:var(--durable-bg);border-color:var(--durable-bd)}
.dot.t{background:var(--trans-bg);border-color:var(--trans-bd)}
.dot.g{background:var(--guard-bg);border-color:var(--guard-bd)}
.redline{margin-top:14px;background:#fff;border:1.5px dashed var(--bad-bd);border-radius:11px;padding:13px 16px}
.redline .k{color:var(--bad-ink)}
table{width:100%;border-collapse:collapse;margin-top:8px;font-size:13px}
th,td{border:1px solid var(--line);padding:8px 10px;text-align:left;vertical-align:top}
th{background:#eef2f8;font-size:12.5px}
td.now{color:var(--bad-ink)} td.goal{color:var(--guard-ink)}
.decide{counter-reset:d}
.decide .card{position:relative;padding-left:40px}
.decide .card:before{counter-increment:d;content:counter(d);position:absolute;left:12px;top:12px;width:20px;height:20px;border-radius:50%;background:var(--durable-bd);color:#fff;font-size:12px;font-weight:700;display:flex;align-items:center;justify-content:center}
.rec{display:inline-block;margin-top:6px;font-size:12px;font-weight:700;color:var(--guard-ink);background:var(--guard-bg);border:1px solid var(--guard-bd);border-radius:6px;padding:1px 7px}
.steps li{margin-bottom:6px}
.steps b{color:var(--durable-ink)}
.dep{background:#fff8ec;border:1px solid var(--trans-bd);border-radius:10px;padding:11px 15px;color:var(--trans-ink);font-size:13px;margin-top:8px}
footer{margin-top:34px;color:var(--muted);font-size:12px;border-top:1px solid var(--line);padding-top:12px}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>S7 生成主链全文通路(分支①)· 评审版一览</h1>
<div class="sub">让用户看到、审定、采纳 AI 的<strong>完整正文</strong>,同时守住「原始产出不落长期库、先审后入」主权。</div>
<div class="meta">v0.2 · 2026-07-07 · 现状已实测校正 · 待两轮评审转执行版 · S7.0 已裁分支①</div>
</header>
<div class="intent">核心把「事件顺序持久」与「正文载荷瞬态」分层——完整正文只走内存瞬态加密缓存SSE客户端永不进候选表/正文表。</div>
<h2>① 问题AI 的全文,从未到过用户面前</h2>
<div class="grid cols-3">
<div class="card bad"><div class="k">全文生成即丢</div><p>完整正文只在 real client 的 success() 里存在一瞬,构造结果前就被截成 60/80 字摘要,出栈丢弃;下游只见摘要。</p></div>
<div class="card bad"><div class="k">SSE 不带正文</div><p>同步生成,只发一条 done候选 id / 完成原因 / 质量分chunk 契约与前端渲染早就位,但后端从不产生 chunk 行。</p></div>
<div class="card bad"><div class="k">「审」审的是摘要</div><p>合规扫描 / 静态检查作用在 60/80 字摘要上截断护栏无效Dify 完成原因是常量、长度检查跑摘要)。</p></div>
</div>
<h2>② 目标通路:一条「瞬态全文」链</h2>
<div class="legend">
<span class="chip"><span class="dot d"></span>长期数据(主权红线内:只存摘要+三审 / 回传正文)</span>
<span class="chip"><span class="dot t"></span>瞬态(内存 / 加密缓存 / SSE / 客户端,决定即清)</span>
<span class="chip"><span class="dot g"></span>护栏(作用在完整正文)</span>
</div>
<div class="flow">
<div class="node transient"><div class="t">real client success()</div><small>完整正文唯一存在处 ★捕获</small></div>
<span class="arrow"></span>
<div class="node guard"><div class="t">★完整性+合规护栏</div><small>作用在完整正文,不信 provider 完成原因</small></div>
<span class="arrow"></span>
<div class="node transient"><div class="t">★瞬态加密缓存</div><small>复用 PayloadStore 范式·TTL·决定即清</small></div>
<span class="arrow"></span>
<div class="node transient"><div class="t">SSE chunk回缓存取全文</div><small>填已声明 chunk.data.content·非破坏</small></div>
<span class="arrow"></span>
<div class="node transient"><div class="t">前端 streamContent → 可编辑候选</div><small>显示/交接零改动</small></div>
<span class="arrow"></span>
<div class="node durable"><div class="t">采纳=回传完整正文</div><small>★所见即所写 → Canonical</small></div>
</div>
<div class="flow" style="margin-top:8px">
<div class="node durable"><div class="t">候选表 muse_ai_suggestion</div><small>恒=脱敏摘要+三审(无完整正文)</small></div>
<span class="arrow"></span>
<div class="node durable"><div class="t">Canonical 正文</div><small>由用户回传正文写入归因revision</small></div>
<span class="arrow"></span>
<div class="node transient"><div class="t">决定即清瞬态缓存</div><small>采纳/放弃后物理删除</small></div>
</div>
<div class="redline"><div class="k">主权不变式(红线)</div><p style="color:var(--muted);margin:0">完整正文只存在于:执行线程内存 · 瞬态加密缓存TTL决定即清· SSE 传输 · 客户端。<b>永不</b>写入候选表 / 正文表 / 任何长期列。候选恒为摘要三审Canonical 恒由客户端回传正文写入。</p></div>
<h2>③ 待所有者拍板的材料级点</h2>
<div class="grid cols-3 decide">
<div class="card"><div class="k">瞬态全文载体口径</div><p>完整正文瞬态存在于独立加密缓存表TTL决定即清、永不进候选/正文表是否算「不落长期库」纯内存直推现状不可行SSE 只重放持久化、重连必丢)。</p><span class="rec">推荐:可接受,同 PayloadStore 范式</span></div>
<div class="card"><div class="k">采纳=所见即所写</div><p>采纳(含原样采纳)恒由客户端回传用户审定的完整正文写 Canonical后端不再用摘要写正文。</p><span class="rec">推荐:采纳,兑现主权模型+修潜伏缺陷</span></div>
<div class="card"><div class="k">护栏/审上移到全文</div><p>完整性校验与合规扫描从「作用在摘要」改为「作用在完整正文」(捕获处执行)。</p><span class="rec">推荐:采纳,摘要上做护栏无效</span></div>
</div>
<h2>④ 现状 vs 目标</h2>
<div style="overflow-x:auto">
<table>
<tr><th>维度</th><th>现状</th><th>分支①目标</th></tr>
<tr><td>用户看到的正文</td><td class="now">60/80 字脱敏摘要</td><td class="goal">provider 完整正文</td></tr>
<tr><td>完整正文去向</td><td class="now">success() 内出栈即丢</td><td class="goal">瞬态加密缓存 → SSE 送达,决定即清</td></tr>
<tr><td>「审」的对象</td><td class="now">60/80 字摘要</td><td class="goal">完整正文</td></tr>
<tr><td>截断护栏</td><td class="now">无效(信常量/跑摘要)</td><td class="goal">对完整正文结构化/长度校验</td></tr>
<tr><td>采纳写 Canonical</td><td class="now">原样采纳写摘要(潜伏缺陷)</td><td class="goal">恒回传完整正文(所见即所写)</td></tr>
<tr><td>候选表 / 正文表</td><td class="goal">不含完整正文</td><td class="goal">不变(仍不含)</td></tr>
<tr><td>生成 provider</td><td class="now">New-API</td><td class="goal">可切 Difyfail-closedNew-API 留到 S8</td></tr>
</table>
</div>
<h2>⑤ 步骤与依赖</h2>
<ol class="steps">
<li><b>S7a</b> 捕获护栏provider 无关,不依赖 Dify结果加不落库全文内存字段两 adapter 接出完整正文,护栏移到全文。</li>
<li><b>S7b</b> 瞬态载体SSE 送达:全文写瞬态加密缓存,事件表发 chunk 引用行SSE 重放回取填 content候选表口径不变。</li>
<li><b>S7c</b> 前端接成采纳基准+所见即所写:显示/交接零改动;采纳恒回传全文;区分「流不完整/流为空」防静默采纳截断。</li>
<li><b>S7d</b> 生成 provider 切 Dify<em>依赖 Dify workspace</em>runtimeProvider=difyfail-closedNew-API 保留到 S8。</li>
</ol>
<div class="dep">⚠ 依赖S7d 需 <b>Muse 专属、与游戏项目隔离的 Dify workspace</b> 就绪所有者侧基础设施。S7ac 不依赖,可先行。</div>
<footer>配套正文见 <code>2026-07-07-S7-生成主链全文通路-review.md</code>(含 mermaid 数据流与现状锚点附录)。本页为「一眼看懂边界与核心」的图文速览,非实现细节。</footer>
</div>
</body>
</html>

View File

@ -0,0 +1,203 @@
# S7 生成主链全文通路(分支①)评审版设计
- 版本v0.2(现状已实测校正,待两轮评审转执行版)
- 日期2026-07-07
- 承接:[2.0.0 执行计划 S7/S7.0](2026-07-07-2.0.0单人版改造-execution.md) + 总账 S7.0 裁决(分支①,所有者 2026-07-07 确认)
- 读者:执行 agent 与项目所有者
- 定位回答「S7 要做什么、为什么这样做、边界在哪、怎么验收」。主文无代码细节;现状锚点集中在文末附录,供执行版落地对照。
---
## 1. 背景AI 生成的全文,从未到过用户面前
Muse 的 AI 续写走「影子候选 → 先审后入」主权AI 产出先落 Shadow 候选,用户审阅、编辑后才合并进 Canonical 正文。这套模型隐含一个前提——**用户能看到 AI 写的完整正文**,据此决定采不采、怎么改。
实测显示这个前提当前根本不成立,而且比「少发了一个字段」严重得多:
- **完整正文在生成的那一刻就被丢弃。** provider 返回的完整正文,只在两个真实运行时客户端的 `success()` 方法里以局部变量存在过一瞬,随即被截成 60/80 字的脱敏摘要塞进结果对象方法返回后完整正文再无任何引用。运行时结果对象本身没有承载完整正文的字段因此其下游——执行器、投影、数据库、SSE——**从头到尾只见得到那 60/80 字摘要**。
- **SSE 只发一条终态、且不带正文。** 后端生成是同步非流式的:执行器一次拿到完整结果,只写一条 `done` 事件,载荷是候选 id、完成原因、质量分没有正文`chunk` 事件的契约与前端渲染都早已就位,但后端从不产生 `chunk` 行。
- **连「审」审的也是摘要。** 输出合规扫描与静态检查作用在那条 60/80 字摘要上,而非 provider 完整正文——「先审后入」审的其实是个片段。
- **截断护栏形同虚设。** Dify 的完成原因被硬编码成常量根本不反映真实完成度New-API 的完成原因只记录、不设门;长度检查跑在天然就被截断的摘要上,永不触发。
结论:这不是外部依赖问题,也不是 Dify 切换问题,而是**从运行时到用户之间缺一条完整正文通路**,且这条缺失让「可见 / 可审 / 可采纳」三件事同时落空。分支①要补的就是它,且它与切不切 Dify 相互独立。
## 2. 目标
一句话:**让用户看到、并据以编辑、采纳 AI 生成的完整正文;同时不把 AI 原始产出写进任何长期数据(候选表 / 正文表),守住「原始产出不落长期库、先审后入」主权。**
可验收的四条:
1. **可见**:一次生成后,用户在前端看到的是本次生成的完整正文(长度与 provider 实际输出一致),不再是 60/80 字摘要。
2. **可审**:完整性与合规护栏作用在**完整正文**上(当前只作用在摘要上)——疑似截断或含凭据痕迹→判失败可重试,不静默进候选。
3. **主权不破**:候选表仍只存脱敏摘要 + 三审结果Canonical 正文由用户在前端审定、回传的最终正文写入;完整正文只在「内存 / 瞬态加密缓存 / SSE 传输 / 客户端」中存在,用户做出决定后即清除,**永不进入候选表或正文表任何列**。
4. **provider 可切 Dify**:完整正文透出对 provider 透明New-API 与 Dify 两个 adapter 都把完整正文送到同一处;在此之上把在用系统 Agent 的生成 provider 切到 Dify未配置/凭据错时 fail-closed 拒绝,不静默回退 New-API。
## 3. 边界MECE
**属于 S7 分支①:**
- 在唯一可捕获处(两个 real client 的成功分支)把完整正文接出到一个**不落长期库**的传递位,并将完整性/合规护栏移到此处对**完整正文**执行;
- 用一套瞬态加密缓存承载完整正文(供 SSE 送达与断线重连),用户决定后即清除;
- 让前端把流到的完整正文接成「采纳基准」,采纳时**恒把该完整正文作为最终正文回传**所见即所写Canonical 由回传正文写入;
- 生成 provider 切 Dify配置 + fail-closedNew-API 配置本步**保留**,到 S8 验收后才摘。
**明确不做(边界外):**
- 不做 provider 逐 token 真流式(本步完整正文一次性送达即满足「可见」;真流式是后续独立增强);
- 不改「三审 + 用户回传最终正文采纳」这套主权模型本身,只给它补上「可审的完整正文」与「所见即所写的采纳」;
- 不把完整正文写进候选表 / 正文表 / 任何长期列(红线);
- 不做 S8 导入解析切 DifyNew-API 配置的最终摘除是 S8 的门);
- 不承担 Dify 专属 workspace 搭建(基础设施事项,见依赖)。
## 4. 核心机制:一条「瞬态全文」通路
设计的技术核心是一个真实存在的张力:**SSE 严格「只重放已持久化事件」——凡经 SSE 送出的每个字节,都必须先落进任务事件表;而主权要求完整正文不落长期库。** 二者直接对冲。破解办法是把「事件的顺序与可靠性」和「正文载荷本身」分层,并复用代码库**已经存在**的瞬态敏感数据范式。
一次生成的完整数据流(★=本设计新增/改动的环节):
```mermaid
flowchart TD
U["用户 · AIPanel 发送指令"] -->|"POST /ai/tasks"| Q["入队 generation+jobqueued"]
Q --> D["Dispatcher 领取 job@Scheduled 1s"]
D --> X["Executor 同步执行"]
X -->|"execute(command)"| RC["real client success()<br/>【完整正文唯一存在处】"]
RC --> G["★ 完整性+合规护栏<br/>作用在完整正文(非摘要)"]
G -->|"疑似截断/含凭据"| FAIL["判失败·可重试<br/>不进候选"]
G -->|"通过"| CAP["★ 接出完整正文到<br/>结果的不落库内存字段"]
CAP --> SUM["派生 60/80 字脱敏摘要"]
SUM --> SUG["候选落库 muse_ai_suggestion<br/>content_snapshot=摘要 + 三审<br/>(主权:无完整正文)"]
CAP --> TS["★ 完整正文写瞬态加密缓存<br/>(独立表·短 TTL·决定即清"]
X --> EV["★ 任务事件表写 chunk-ref 行 + done 行<br/>chunk 载荷只存引用,非正文)"]
EV --> SSE["SSE streamTask 重放/轮询事件"]
TS -. "★ 重放 chunk-ref 时回缓存取全文" .-> SSE
SSE -->|"chunk.data.content=完整正文"| AP["AIPanel streamContent 实时渲染"]
SSE -->|"done"| AP
AP --> CP["CandidatePanel 可编辑最终正文"]
CP -->|"★ 采纳恒回传完整正文 finalContent"| MG["Content merge_after_edit"]
MG --> CAN["Canonical 正文 = 用户回传正文<br/>+ 归因 + revision"]
MG --> PUR["★ 决定即清瞬态缓存"]
CP -->|"放弃"| PUR
classDef durable fill:#e8f0ff,stroke:#3358cc,color:#0b2a6b;
classDef transient fill:#fff4e0,stroke:#cc7a00,color:#6b3d00;
classDef guard fill:#e9f9ec,stroke:#2f9e44,color:#12521f;
class SUG,CAN durable;
class CAP,TS,EV,SSE,AP,CP transient;
class G guard;
```
四个要点:
1. **唯一捕获处 + 就地护栏**:完整正文只在两个 real client 的成功分支存在,故完整性/合规护栏必须在这里、对完整正文执行(当前它们跑在下游摘要上、无效)。护栏通过后,把完整正文接出到运行时结果的一个**仅内存、明确不落库**的字段(不破坏「结果只带摘要」的持久化契约——该字段永不进任何列)。
2. **正文与事件分层**:完整正文写进一套**瞬态加密缓存**——对称复用代码库既有的 `MuseAiRuntimePayloadStore` 范式(独立表、短 TTL、字段加密、终态即物理删除它本就是为「入向用户指令」这类瞬态敏感数据设计的分支①用同一范式装「出向完整正文」。任务事件表里的 `chunk` 行只放一个**指向缓存的引用**,不放正文本身。
3. **SSE 重放时回取**SSE 在重放该 `chunk` 引用行时回缓存取完整正文,填进契约早已声明、当前从不发的 `chunk.data.content`(填充既有字段=契约兼容,非破坏)。断线重连=全量重放事件→再次回缓存取(决定窗口内缓存仍在)→完整正文可续;缓存清除后的迟到重连拿到空正文即可(用户已决定)。前端 `streamContent` 已接线到 `chunk.data.content` 并实时渲染,**显示层零改动**。
4. **采纳=所见即所写**:因完整正文自始不落长期库,能写进 Canonical 的完整正文只能来自**客户端回传**。故采纳时前端**恒**把用户审定的完整正文作为最终正文上送无论改没改过Canonical 由回传正文写入。这既坐实主权模型「采纳=用户回传所审最终正文」,也修掉当前「原样采纳会把 60/80 字摘要写进 Canonical」的潜伏缺陷。
**主权不变式(红线,自洽)**:完整正文只存在于 ①执行线程内存 ②瞬态加密缓存TTL决定即清 ③SSE 传输 ④客户端。它**永不**写入 `muse_ai_suggestion` / `muse_content_block` 或任何长期列。候选表恒为摘要三审Canonical 恒由客户端回传正文写入。「不落库」=不进长期数据模型,瞬态加密缓存是代码库既有的、对称使用的瞬态层;「先审后入」现在有真实完整正文可审。
## 5. 三个材料级点(所有者 2026-07-07 已裁:三条均采纳推荐)
分支①触及三处主权/契约边界,均非最小改动能自决。**所有者 2026-07-07 裁决:三条全部采纳下方推荐**——①瞬态全文用独立加密缓存表;②采纳恒回传完整正文(所见即所写);③护栏与合规审上移到完整正文。执行版据此落地。
1. **瞬态全文载体口径**完整正文瞬态存在于「独立加密缓存表TTL决定即清永不进候选/正文表)」是否满足「原始产出不落长期库」?
*推荐:可接受。* 它与代码库既有 `MuseAiRuntimePayloadStore`(入向用户指令)同范式、同保护(加密+短 TTL+终态即删只是方向相反durable 数据模型始终不含完整正文。替代是「纯内存直推」,但现有 SSE 只重放持久化事件、无内存直推通道,且重连必丢正文,故纯内存现状下不可行。
2. **采纳语义=所见即所写**:采纳时恒由客户端回传用户审定的完整正文写 Canonical含原样采纳后端不再用自己存的摘要写正文。
*推荐:采纳。* 这正是主权模型「采纳=用户回传最终正文」的字面兑现,并修掉「原样采纳写摘要进 Canonical」的潜伏缺陷。
3. **护栏与「审」上移到完整正文**:完整性校验与合规扫描从当前的「作用在 60/80 字摘要」改为「作用在完整正文」(在捕获处执行)。
*推荐:采纳。* 在天然截断的摘要上做完整性/合规检查是无效护栏;「先审后入」应审真实全文。
## 6. 完整性护栏(第 2 目标的落地要点)
- **护栏对象=完整正文**,在捕获处执行:长度/结构合理性 + 凭据痕迹扫描;疑似截断或不合规→判失败可重试,不静默进候选。
- **不信任 provider 的完成原因**Dify 的完成原因是常量、New-API 的只记录不设门,均不可作完整性信号;截断判定只能靠对完整正文的结构化/长度校验。
- 该护栏 provider 无关,同护 New-API 与 Dify。
## 7. 与切 Dify 的关系(正交)
完整正文通路§4§6provider 无关:两个 adapter 都把 provider 完整正文送到同一捕获/透出位。故通路可**先于 Dify workspace 就绪开工**。provider 切 Dify 只是通路建好后,把在用系统 Agent 的生成 provider 由 New-API 换 DifyruntimeProvider=dify + providerRef 指向「写作透传」app + 凭据 + 读超时对齐 ≥180s 总预算 + fail-closed 反向),并补 Dify 形态护栏断言。New-API 配置保留到 S8。
## 8. 步骤分解WHAT 已定HOW 入执行版)
- **S7a 捕获护栏provider 无关,不依赖 Dify**:运行时结果加不落库的完整正文内存字段,两个 real client 成功分支接出完整正文;完整性/合规护栏移到此处对完整正文执行。
- **S7b 瞬态载体SSE 送达**:完整正文写瞬态加密缓存(复用 `MuseAiRuntimePayloadStore` 范式TTL决定即清事件表发 `chunk` 引用行SSE 重放时回缓存取正文填 `chunk.data.content`;候选表存储口径不变(摘要+三审)。
- **S7c 前端接成采纳基准+所见即所写**:完整正文经既有 `streamContent`→候选链渲染(显示/交接零改动);采纳改为恒回传完整正文作 finalContent处理「流不完整」与「流为空」的区分避免静默采纳截断正文。
- **S7d 生成 provider 切 Dify依赖 Dify workspace**:在用 Agent runtimeProvider=difyproviderRef.dify`muse.ai.dify.enabled=true`读超时对齐fail-closed 反向New-API 配置保留。
S7aS7c 不依赖 Dify workspace可先做S7d 依赖 Muse 专属 Dify workspace 就绪。
## 9. 验收(每条须机械证据)
- **主权不变式(最关键)**:真实 PG 下一次真生成后,`muse_ai_suggestion.content_snapshot.content` 仍是脱敏摘要(长度显著小于 provider 完整输出),完整正文**不在**候选表/正文表任何列;瞬态缓存在采纳/放弃后被清除(查表证明已删)。
- **可见**MSW-off 创作主线 e2e 断言前端渲染的正文长度 > 阈值且与 provider 实际输出一致,不再是 60/80 字摘要。
- **所见即所写**:采纳(含原样采纳)后 Canonical 落的是用户在前端看到/编辑的完整正文长度一致非摘要归因ai_suggestion、revision 递增。
- **护栏**:截断样本→失败可重试、不进候选;正常样本→通过;护栏作用于完整正文的证据。
- **Dify 形态**live IT——Dify 生成→Shadow 候选(三审齐)→完整正文可见→回传采纳写 Canonicalfail-closed 反向dify 未配/凭据错→拒绝,不回退 New-API
- **回归**local + real-PG 全绿;契约结构门绿(`chunk.data.content` 为已声明字段、非破坏)。
## 10. 回滚
- provider 切换:在用 Agent runtimeProvider 拨回 new-api 即回(兼容逻辑未动)。
- 全文通路:以开关门控 chunk 引用行发射+瞬态缓存写入+前端恒回传;关闭即回退「仅摘要」旧行为;代码 revert 独立。
## 11. 风险与缓解
- **瞬态正文暴露**缓存含完整正文——TTL决定即清日志脱敏不打正文字段加密独立表对齐既有范式缓解候选/正文表永不含正文。
- **断线丢正文+静默采纳截断**:中途断连可能丢 chunk 尾部而前端优先采信非空流正文→静默采纳截断——须在前端区分「流不完整有终态但正文疑似截断」与「流为空回源宁可提示重生成不静默采纳截断S7c 要点)。
- **单 chunk 体积**:整章完整正文进单个 SSE 事件——校验无长度上限,必要时分片(仍非逐 token
- **决定后重连**:缓存已清→空正文→可接受(已决定)。
- **契约兼容**`chunk.data.content` 已声明→非破坏;须验证前端能吞一个大 chunk。
## 12. 依赖
- **Dify 专属 workspace所有者侧**S7d 依赖 Muse 专属、与游戏项目隔离的 Dify workspace 就绪(约束已入 `.agents/knowledge/external-deps-and-gotchas.md`。S7ac 不依赖,可先行。
---
## 附录 A · 现状锚点(实测,供执行版对照;均为现状事实,非待写代码)
模块根 `muse-cloud/muse-module-ai/muse-module-ai-server/src/main/java/cn/iocoder/muse/module/ai/`
**完整正文的唯一存在处 / 捕获点**
- New-API`facade/RealNewApiMuseAiRuntimeClient.java` `success()` L163-192——完整正文 `content` L171L172 `shortOutputSummary` 派生摘要(`OUTPUT_SNIPPET_LIMIT=60` L32构造 `RuntimeResult` L186-187 后 `content` 出栈丢弃。
- Dify`facade/RealDifyMuseAiRuntimeClient.java` chat `answer` L230 / workflow `text` L253L272-273 截 ≤80 字,`RuntimeResult` L238/262。
- 结果对象无全文字段:`facade/MuseAiRuntimeClient.java` `RuntimeResult` L70-78`outputSummary` Map L72
**provider 选路(切 Dify 挂钩点)**
- `facade/RoutingMuseAiRuntimeClient.java` L18-53new-api/dify/fail-closed`facade/MuseAiRuntimeProviderSupport.java` L31-42读 agent 版本 config 的 `runtimeProvider`/`providerRef`,缺省 new-api
**候选落库与三审**
- `application/muse/MuseAiRuntimeProjectionService.java``createRuntimeSuggestion` L250-283`contentSnapshot` 映射 L324-333`content`=摘要 L328三审经 `diffSummary` L361-377 仅 `review.passed()` 时写;`source_status` 恒 active L274。
- `application/muse/MuseAiCandidateReviewService.java` L42-84——审查作用在 `resolveContentText` 取出的摘要L336-348非完整正文须上移的点
**SSE只重放持久化事件**
- `application/muse/MuseAiTaskStreamServiceImpl.java` `streamTask` L66-106、回放 `buildReplaySnapshot` L114-124、轮询 `pollPersistedEvents` L195-232、读侧 `chunkData` L279-285已就绪
- 写侧终态门禁 `MuseAiRuntimeProjectionService.appendTaskEvent` L294-317、`isValidTerminalEvent` L319-322`donePayload` L379-386无正文
- 事件表 `muse_ai_task_event` DDL `sql/muse/V12__extend_ai_real_api_schema.sql` L105-138。
- 契约 `docs/api-contracts/ai/openapi.yaml``SSEChunkEvent` L3519-3537`content`+`sequenceNo` 已 required`SSEDoneEvent` L3564-3586。
**可复用的瞬态敏感数据范式(分支①对称复用)**
- `application/muse/MuseAiRuntimePayloadStore.java` L24-46——独立表30min TTL`DEFAULT_TTL` L26字段加密终态物理删除executor `finally` 触发 `remove` L67-73
**前端**
- `muse-studio/src/features/editor/components/AIPanel.tsx``streamContent`/`streamContentRef` L48/56`onChunk` 接线 L130-132实时渲染 L189`handleStreamDone` L63-89 优先采信流正文 L71-75。
- `muse-studio/src/features/editor/hooks/useAcceptSuggestion.ts``shouldMergeAfterEdit` L39-51——仅编辑过才带 `finalContent`(所见即所写要改的写路径)。
- `muse-studio/src/features/editor/components/CandidatePanel.tsx``reviewedText` 可编辑 L97-103。
- `muse-studio/src/lib/sse.ts``connectAIStream` L255-406、重连 dedup L288-312后端不读 lastEventId客户端按 SSE id 去重)。
## 附录 B · 现状 vs 目标(一图速览的文字版)
| 维度 | 现状 | 分支①目标 |
|---|---|---|
| 用户看到的正文 | 60/80 字脱敏摘要 | provider 完整正文 |
| 完整正文去向 | success() 内出栈即丢 | 瞬态加密缓存TTL决定即清SSE 送达 |
| 「审」的对象 | 60/80 字摘要 | 完整正文 |
| 截断护栏 | 无效(信 provider 常量/跑摘要) | 对完整正文结构化/长度校验 |
| 采纳写 Canonical | 改过才回传,原样采纳写摘要 | 恒回传完整正文(所见即所写) |
| 候选表/正文表 | 摘要(正文本不落库) | 不变(仍不含完整正文) |
| 生成 provider | New-API | 可切 Difyfail-closedNew-API 留到 S8 |

View File

@ -0,0 +1,568 @@
# 系统 AI 能力全景 · 元数据驱动的智能体架构(设计 · 评审版)
- 版本v0.3(评审版,未执行)
- 更新日期2026-07-08
- 目标读者:创始人 / 架构 / 后端 / 前端 / 产品
- 边界说明:本文回答四个悬而未决的架构问题——系统需要多少个 LLM 智能体、它们与 Dify 的关系、知识库如何分层、拆书如何完善系统级知识。术语以 `架构-02-核心数据结构与双轨模型.md` 为准BC 边界见 `架构-01`AI 链路合同见 `专题-03`;外部适配见 `专题-05`。本文是**评审版**:讲清 WHAT 与 WHY供拍板执行版与 canonical 落点见第八节,评审通过后再动代码与正式分册。
- 事实基线:结论区分「已验证事实(读到代码/文档)/ 设计意图 / 待决策」。代码现状来自 2026-07-08 对 `muse-module-ai``muse-module-knowledge``muse-module-meta` 的只读盘点。
- 变更记录v0.32026-07-09结构本体补全 `chapter`/`scene`/`narrative_state` 三型scope 轴七值全挂靠),种子清单以专题-06 §5 为准23 项。v0.22026-07-08整合 Fable 边界分析与创始人三项材料级拍板(功能链归属案 A、双轨 Canonical 入口增补、参考作品库位案 A及四项默认落定补 domain 逐值语义与对齐后 20 型本体清单新增统一创作数据读取器、作品容器建模、base 叠加与 storage_binding 存储映射;执行计划 W1 种子扩为 20 项四档、W8 双轨入口标记解锁。
---
## 0. 结论先行
一句话:**Muse 不是"一个功能挂一个 Dify app",而是"一套元数据驱动的智能体编排"**。中枢是 muse-cloud 里的**元引擎MetaSchema+ 功能链FunctionChain**;每个智能体都是 `f(作品 + 元数据 + 知识库)` 的固定产品功能元数据一变同一个智能体的读入与产出就变。Dify 只是这套编排在"能力执行"与"检索基座"两处的可替换外部底座。
> **两条口径(贯穿全文)**:① **设计为 SoT代码为辅**——凡代码与设计不符,是代码待修正(见第七节),不是弯设计迁就代码。② 本轮的硬产出是**三张咬合的权威清单**Agent 清单、元数据类型清单、知识库清单,及三者对应矩阵(第六节)。
>
> **一句话点出最大偏差**:设计里"元数据驱动智能体产出"这条主线,代码目前**是断的**——MetaSchema 只喂了 Content 动态表单、没喂 AI功能链是治理台账、AI 不按它编排AI 另走一套 `MuseAgentSlotBinding` 直连 Dify。要落地本设计核心工作就是把这条线接通。
五问速答:
1. **需要多少个 LLM 智能体?** 产品设计上是 **4 类开放能力智能体 ×(多场景)+ 3 类保护节点智能体 + 2 类知识基座**,远不止 3 个;当前**代码里真正发起 LLM 推理的只有 3 处**写作生成、Agent 试运行、全书导入解析),检测/质检等仍是规则桩。差距在第三、七节。
2. **每个功能都是一个 Dify workflow/app/agent 吗?** **不是,且是刻意的**。只有**开放槽位的能力子智能体**可以挂 Dify app/workflow当前 2 个:写作、解析);**保护节点**质量门控、合规、静态检查、Shadow→Canonical必须 Muse 自持,不能外包给 Dify**检索**走 Dify Datasets另一平面。理由是主权先审后入与可替换性。
3. **知识库分几库、怎么提供系统级能力?** 三库——**全局知识库Global/ 用户知识库User/ 局域知识库Local**;每库又有**两个正交面**——实体/图谱 Canonical 面MetaSchema 结构化,存 PG与文档/RAG 面Dify dataset 检索)。系统级能力 = **全局知识库作为每个作品的默认可检索来源**,其内容由拆书喂养。
4. **拆书是什么、怎么完善系统级知识?** 拆书是**元数据驱动的通用实体抽取智能体**:按 MetaSchema 定义的实体/属性类型,从文本"解析出对应实体 → 入库"。管理员用它拆几十上百本参考书 → 沉淀为全局知识库的**小说公共属性参考**(文风/叙事/节奏/转折/反转/抓手/打斗/情感/人设/物品描述…);用户每确认一章,同一个拆书智能体 + 质量校验智能体也会跑,把本章实体入**局域知识库**。
5. **dify-agent / dify-rag / muse-cloud 三体关系?** muse-cloud = 大脑与主权(元引擎/功能链/权限/双轨/编排dify-agent = 开放槽位的能力执行app/workflowdify-rag = 检索基座datasets底层模型统一经 New-API。见第二节。
> **本轮已拍板2026-07-08**:① **全量接通**元数据驱动主线(结构性重构,见第八节执行计划);② 元数据结构本体经 Fable 边界精炼、并与 domain/scope 两轴对齐后**定稿 20 型(分 5 个本体分组,每型带一条 MECE 边界判据)**(第 6.2 A剩余待确认见 6.2 D全落为 MetaSchema target types③ Local KB 实体强绑 schema_key④ 全局库**显式绑定**检索。执行计划待创始人过审后再动代码。
>
> **本轮新落定2026-07-08创始人材料级拍板**:三项归属决策已定,驱动下方 §6.2、§7.2、第八、九节同步——
> ① **功能链归属取案 A**:功能链定义与 MetaSchema 同住元引擎Meta BC表名 `muse_meta_function_chain/_version/_slot/_node` 不动AI runtime 经 `FunctionChainQueryApi` 读激活链做运行编排。已验真——V3/V10 迁移建的就是这批 `muse_meta_function_chain*` 表、代码全在 muse-module-meta落地零数据迁移。
> ② **双轨 Canonical 入口增补获批**`架构-02 §1.2` 的封闭枚举新增「管理员确认系统级知识草稿 → Global KB 范式 Canonical」一条确认人是管理员产出走 Draft→确认→Canonical 同构链拆书系统级链路W8由此解锁。
> ③ **参考作品库位取案 A**`reference_work` 落 Global KB document 特化Knowledge BC复用 `muse_knowledge_document/_version/_processing_job` 既有资料处理链;拆书任务归 AI BC新任务类型 reference_extraction版权受限经既有 Source Status Event→Propagation 阻断派生新使用、不回滚已确认。
>
> **默认落定(主会话据 Fable 建议定,有异议可翻)**:④ 命名统一——示例 `character_entity` 收敛为 `character`target_type 不带粒度后缀)、`relation` 更名 `character_relation`、测试 fixture `setting` 收编进 `world`;⑤ 双层型style/pacing/craft取单模具双库主案——一型一 schema公共面实例落 Global KB 共用同一 schema是否再拆 `*_paradigm` 由 W4 三样本"能否无损写进同一字段合同"实测终裁W1 先落作品面种子不阻塞;⑥ `generation_context` 保留为 Context Assembly Snapshot 的字段模具domain=ai_context、scope=agent消费者是质量评测输入与审计`storage_binding` 存储映射随 W1 落——`muse_meta_field` 增 native_column / extension_json / computed 属性,承载容器 schema 对 Work 固定列的元描述。
---
## 1. 中枢:元数据驱动的智能体架构
这是理解全部问题的钥匙,先讲清楚,后面四问都是它的推论。
### 1.1 两个中枢:元引擎与功能链
muse-cloud 的 `muse-module-meta` 里有两套东西,它们是所有 AI 能力的骨架(已验证:建表与 DO 齐全):
- **元引擎 / MetaSchema**`muse_meta_schema / _field / _version / _visibility / _gray_rule / _validation`):可配置的**创作域本体**。它定义"一个人物有哪些属性、一个设定有哪些字段、一段文风从哪些维度刻画、一次转折由哪些要素构成",以及每个字段是否可见、可编辑、可检索、**是否可进入 AI 上下文(`aiContext`**。按 `架构-02 §9`MetaSchema 的职责就是"**约束提取、规划、检查、投影和上下文组装**"——这正是智能体的读入与产出。
- **功能链 / FunctionChain**`muse_meta_function_chain / _node / _slot / _node_protection`**系统预编排的能力链路**。一条链由若干节点组成,节点分两种——**开放槽位Override Slot**允许替换子智能体,**保护节点Protection Node**不可替换。这就是 `专题-03` 说的"系统功能编排 + 开放替换槽位 + 不可替换保护节点"落到数据模型。
一句话区分:**MetaSchema 定义"结构"FunctionChain 定义"流程"**。
### 1.2 每个智能体 = f(作品 + 元数据 + 知识库)
这是创始人本轮点明、且被 `架构-02` 证实的核心论断。任何一个智能体运行时,都同时结合三样东西:
| 输入维度 | 来自 | 作用 |
|---|---|---|
| **作品Content** | Work/Content BC当前正文、章节、近邻 Block、正式规划、叙事状态 | 提供"写什么/改什么/查什么"的对象与近邻语境 |
| **元数据MetaSchema** | Meta BC实体类型、字段、枚举、校验、`aiContext` 开关、输出合同 | 决定"读哪些字段、抽哪些实体、产出什么结构"——**是产出结构的模具** |
| **知识库Knowledge** | Knowledge BC局域知识库 Canonical 实体、授权的全局/用户知识库检索片段 | 提供"作品事实"与"公共参考"两类支撑 |
由此得到本架构最重要的性质:**智能体"类型"是固定的产品功能(功能链节点写死),但产出是元数据驱动的动态结果**。管理员在元引擎里给"设定"实体加一个字段"金手指类型",或给"文风"加一个维度"反转密度"——不改一行智能体代码,拆书就会多抽这个属性、规划会多生成这个字段、质检会多查这个维度。这就是"固定类型 + 元数据变则产出变"。
```mermaid
flowchart TB
subgraph Meta["元引擎 MetaSchema结构模具"]
MS["实体类型/字段/枚举/校验<br/>aiContext 开关/输出合同"]
end
subgraph Chain["功能链 FunctionChain流程骨架"]
direction LR
N1["保护节点<br/>输入合规/权限过滤"] --> SLOT["开放槽位<br/>能力子智能体"] --> N2["保护节点<br/>质量门控/输出合规/Shadow→Canonical"]
end
subgraph Ctx["三结合上下文"]
W["作品 Content"]
K["知识库 Knowledge"]
end
MS -.约束读入与产出结构.-> SLOT
MS -.定义可入AI字段.-> Ctx
W --> SLOT
K --> SLOT
SLOT -->|候选/草稿/风险| Shadow["Shadow 待审"]
Shadow -->|用户确认| Canonical["Canonical 正式事实"]
```
---
## 2. 三体关系dify-agent / dify-rag / muse-cloudQ5
先破一个误解:**dify-agent 与 dify-rag 不是两套部署,而是同一个 Dify 实例的两个功能平面**,靠两类 API key 区分已验证app key 前缀 `app-` 只能打 app/workflowdataset key 前缀 `dataset-` 只能打 datasets混用 401。三者关系如下
| 角色 | 是什么 | 持有什么 | 访问方式 | 边界 |
|---|---|---|---|---|
| **muse-cloud** | 大脑与主权 | 元引擎、功能链、权限包RPE、双轨Shadow/Canonical、审计、用量归属、datasetId 映射、编排 | —— | **业务事实源与权限裁判**;绝不让外部写 Canonical |
| **dify-agent** | 开放槽位的能力执行 | Dify chat/workflow app当前 2 个:`dify-writing-s1` 写作、`dify-parser-s1` 解析) | app key`MUSE_AI_DIFY_*`),端点 `/chat-messages``/workflows/{id}/run` | 只是"能力节点",只返回**候选**,不是可入库事实 |
| **dify-rag** | 检索基座 | Dify Datasets当前"每 KB 一库"物理隔离) | dataset key`MUSE_KNOWLEDGE_DIFY_*`),端点 `/datasets/*/retrieve` 等 | 检索基座**≠事实源**;缺来源/授权的结果不进上下文 |
| **New-API** | 底层模型网关 | 真实模型MiniMax-M2.5 + Qwen 向量/重排) | Dify 经 openai_api_compatible 插件连它muse-cloud 也可直连 | **用量/成本/归属的权威**Dify 用量只作脱敏审计 |
主权原则一句话:**Muse 编排Dify 执行Muse 裁判**。Dify两个平面都是可替换外部底座——指定 Dify 却未配置时 fail-closed已验证绝不回退 New-API 伪成功Dify 全挂时 Muse 也只失败关闭,但用户读/存 Canonical 不受影响。
一次生成的完整数据流:
```mermaid
sequenceDiagram
participant U as Studio 用户
participant M as muse-cloud编排+主权)
participant R as dify-rag检索
participant A as dify-agent写作app
participant N as New-API模型
U->>M: 续写请求
M->>M: 生成运行权限包 RPE + 按 MetaSchema 组装上下文(作品+元数据)
M->>R: 按授权检索(全局+作品+绑定KB 的 dataset
R->>N: 向量检索/重排
R-->>M: 授权片段(带来源/状态)
M->>A: 组装后的上下文 → 写作 app
A->>N: 模型推理
A-->>M: 候选(仅候选)
M->>M: 保护节点:静态检查/质量门控/输出合规 → Shadow 候选
M-->>U: 候选 + 创作健康度解释
U->>M: 接受/改后合并/丢弃
M->>M: 写 Canonical 正文 + 来源归因
```
---
## 3. 系统 LLM 能力全景:多少个智能体、是否都是 Dify appQ1
### 3.1 不是"每功能一个 Dify app"——三层智能体
按主权边界,智能体分三层,**只有第一层可以挂 Dify app**
- **第一层 · 开放能力子智能体(可替换,可挂 Dify app/workflow / New-API / 用户/市场智能体)**:写作、分析/拆书、检测、规划。这是用户和市场能插入自定义能力的地方,也是 dify-agent 的落点。
- **第二层 · 保护节点智能体Muse 自持,即使用 LLM 也不外包为可配置 Dify app**:质量门控 / LLM-Judge、输入输出合规与语义围栏、知识入库校验。它们是"先审后入"主权的执行者,若需 LLM 只走受控内部路径(直连 New-API 或一个**受限**评测 app绝不做成用户可替换的能力槽位。
- **第三层 · 知识基座(非"app"**RAG 检索走 dify-rag切块/入 RAG/索引是保护节点。
这样切分的原因有三:**主权**Shadow→Canonical 不能被外部替换)、**可替换性**(同一开放槽位可在 Dify↔New-API↔AgentScope 间换而不动主链)、**可解释与可审计**(保护节点必须产出 Muse 可追溯的结论)。
### 3.2 设计全集 vs 代码现状
| 智能体(能力目标 × 场景) | 层 | provider 落点 | 设计 | 代码现状2026-07-08已验证 |
|---|---|---|---|---|
| 写作:续写/改写/扩写/润色/纠错/去AI味/角色声音 | 开放 | Dify 写作 app 或 New-API按 Agent 版本 `runtimeProvider` | ✅ | ✅ 已接(一条通用生成链 + 场景参数;`dify-writing-s1`/New-API |
| 分析/拆书:全书解析/章节实体抽取/摘要/结构拆解 | 开放 | Dify 解析 app 或 New-API`import.provider`,缺省 Dify | ✅ | 🟡 仅"全书导入解析"接了 LLM`dify-parser-s1`**按元数据抽实体、每确认章跑拆书**未接 |
| 检测:一致性/角色声音/文风/风险/语义偏离 | 开放 | Dify/New-API | ✅ | 🔴 **规则桩**(候选轻量审=纯字符串扫描,非 LLM |
| 规划:大纲/世界设定/人设生成 | 开放 | Dify/New-API | ✅ | 🔴 未见独立 LLM 规划链 |
| 质量门控 / LLM-Judge | 保护 | Muse 内部New-API/受限 app | ✅ | 🔴 **确定性 hash 打分桩**(注释明写"后续接 LLM judge" |
| 输入输出合规 / 语义围栏Moderation | 保护 | Muse 内部 | ✅ | 🟡 规则扫描桩 |
| 离线质量评估 | 保护 | Muse 内部 | ✅ | 🔴 确定性打分桩 |
| RAG 检索 | 基座 | dify-ragdatasets | ✅ | ✅ 已接(每 KB 一库循环检索合并) |
| 知识抽取入库(拆书的入库端) | 保护 | Muse 内部 | ✅ | 🟡 草稿→确认→Canonical 机制在LLM 抽取未接 |
**结论**:真正在跑 LLM 的是 3 处写作生成、Agent 试运行、全书解析);产品设计的智能体全集约 9 类,多数当前是规则桩或未接。所以"当前 3 个"是**代码进度**,不是**产品应有的智能体数**。
---
## 4. 知识库分层:三库 × 两面Q2 前半 + Q3
### 4.1 三库(已验证:`架构-02 §4`、代码 KB_TYPE
| 库 | 归属 | 用途 | 进入作品的方式 | 对应用户说法 |
|---|---|---|---|---|
| **全局知识库 Global KB** | 平台/管理员 | 写作方法、公共资料、**拆书产出的小说公共属性参考** | 管理员授权后作为可见/可检索/可生成的**系统默认来源** | "系统级/公共知识库参考" |
| **用户知识库 User KB** | 普通用户 | 跨作品复用的个人资料资产 | 用户显式绑定到作品 | 用户自建可复用库 |
| **局域知识库 Local KB** | 单个作品 | **当前作品的正式知识、世界状态、叙事状态** | 只能由用户确认知识草稿或手动修正写入 | "作品私有知识库,维护作品内所有内容" |
### 4.2 两个正交面(本轮盘点最关键的澄清)
每个库其实横跨两条**机制不同**的数据面,不能混谈:
- **实体/图谱 Canonical 面**(存 PGMetaSchema 结构化):`muse_knowledge_draft`Shadow→ 用户确认 → `muse_knowledge_entity / _relation`Canonical+ 属性变更历史。这是"故事圣经"——角色/地点/组织/物品/事件及其关系与时间线。图查询直接读本地 PG。**这才是"维护作品内所有内容"的载体**。
- **文档/RAG 面**(存 Dify dataset上传的参考资料文档 → 切块索引 → 语义检索。它只是**喂生成的语料**,不是结构化事实。(已验证:草稿确认入库**不进** Dify dataset实体/关系落 PG。
```mermaid
flowchart LR
subgraph LocalKB["局域知识库 Local KB作品私有"]
direction TB
subgraph Fact["实体/图谱 Canonical 面PG · MetaSchema 结构化)"]
D["知识草稿 Shadow"] -->|用户确认| E["实体/关系/时间线 Canonical"]
end
subgraph Rag["文档/RAG 面Dify dataset"]
Doc["上传资料"] --> Idx["切块索引 → 语义检索"]
end
end
Chapter["用户确认的章节正文"] -->|拆书智能体抽实体| D
Chapter -->|质量校验智能体查一致性| RM["风险/一致性结果"]
E -->|一致性检查来源| RM
```
### 4.3 作品级知识库的创建与维护Q3
Local KB **不在知识库工作台直接编辑**,只通过作品工作台的 Shadow→Canonical 确认链维护。三条产知识的入口:
1. **导入旧稿**:上传 → 全书解析(拆书智能体)→ 章节解析结果 → 用户逐章审阅 → 知识草稿 → 确认 → Local KB。
2. **写作过程**:用户每确认一章正文,**拆书智能体**按 MetaSchema 抽本章新实体/关系 → 知识草稿;**质量校验智能体**同时查与既有 Local KB 的一致性 → 风险标记。(这正是创始人本轮点明的"每个用户确认的章节,也会走拆书智能体和质量校验智能体"。)
3. **手动修正**:用户直接改实体/关系(走确认链,留 change log
核心不变式(`架构-02 §1`**接受 AI 候选只写正文,不自动确认知识草稿**;知识入 Local KB 必须用户单独确认。冲突时人工、默认自动(核心架构决策)。
### 4.4 统一创作数据读取器AI 上下文的服务端读 facade
`f(作品 + 元数据 + 知识库)` 里"读入"这一半,落在一个专门的读取层上。它不是第四套读设施,而是把既成的 Context Assembly`专题-03 §4`)取数面显式分层为 AI Orchestration 内部的**服务端读取器**:按 actor、作品、分区请求与授权上下文经各 owner 的 facade-api 拉数——Content 出作品容器/章节/正式规划/叙事状态Knowledge 出 Local KB 实体、关系、事件与绑定库检索Meta 出模具投影——再对每条数据套上该 target type 的 active MetaSchema 投影,做字段级 `aiContext` 裁剪、按 target type 结构化、附来源标注。Context Assembly 消费它完成 L0L3 组装与 Token 预算;检测、导出预检等非生成链路复用同一读取器,不各自重写取数与裁剪。
它与用户面的 meta-projections **同源不同面**:共享模具投影的计算,不共享裁剪策略——用户面按 `uiVisible`/`userEditable`AI 面按 `aiContext` 裁,两者互不蕴含(`架构-02 §9`)。一条负约束必须写死:读取器只作为服务端内部端口存在,**不暴露为通用 app API**,否则它会成为与"通用写 SDK"对称的滥用面。
读统一、写各 owner。写侧零新设计——动态字段没有跨 owner 通用写接口(`后端-05 §4.3`)、禁通用写 SDK`前端-03 §5.2`、Canonical 入口是封闭枚举、agent 产出只进 Shadow这些约束一条不改。统一读取器对写的唯一贡献是沿用 `/dynamic-fields/validate` 的"校验 + 路由建议、不写入"语义,把"agent 想改哪个字段"翻译成"该走哪个 owner 的命令"。
**最小读合同**——
- **输入**actorworkId分区请求集作品容器 / 规划 / 按 target_type + scope 过滤的 Local KB 实体 / 仅限已绑定 Global KB 的公共范式,对齐"显式绑定不自动注入"`runtimePermissionEnvelope` 引用(`allowedContextScopes` 是分区许可上限只能收紧不能放宽purposegeneration/detection/planning/parse授权按用途裁对齐 `专题-03 §4.3`"允许阅读 ≠ 允许进 AI 上下文");期望 schemaVersion可选防任务中途版本漂移
- **输出**:分区化的结构块列表,每块含 `targetType``schemaKey``schemaVersion`、已按 `aiContext` 裁剪的结构化字段值、`dataRevision`、来源标注七要素(`sourceOwner`/`sourceObject`/`sourceVersion`/`sourceStatus`/`authorizationSnapshotId` 等,对齐 `专题-03 §5.3`)、`omittedFields` 及原因;受限来源整块进 `omittedSources`fail-closed原因枚举复用 `专题-03 §4.2` 六值)。
裁剪分三级、同时生效:**字段级**`aiContext=false` 的字段剔除)、**来源级**(状态受限的来源整块不进)、**用途级**`allowedPurpose` 不含本次 purpose 的剔除)。任一级判定剔除,数据即不进上下文,并在 `omittedFields`/`omittedSources` 留痕可审计。
---
## 5. 拆书元数据驱动的实体抽取智能体Q2 后半)
### 5.1 拆书的正确定位(按创始人本轮澄清)
拆书**不是**管理员专用的一次性管线,而是一个**通用智能体**`拆书(文本, MetaSchema, 目标库) → 按 MetaSchema 定义的实体类型解析出实体 → 入库Shadow→Canonical`。同一个智能体,两个用场:
- **系统级(管理员)**:把几十上百本参考书拆成**小说公共属性/关键属性参考**,沉淀进**全局知识库**。产出的是**抽象属性与技法范式**,不是逐字原文(版权 + `专题-03` 禁"作品资产模板化")。
- **作品级(用户)**:每确认一章,拆本章实体入**局域知识库**。
一个拆书智能体、两处用、产出结构都由 MetaSchema 决定——这就是"元数据驱动"的价值:管理员维护一套属性本体,系统级参考库与作品级实体库共用同一套结构,天然对齐。
### 5.2 拆书的目标本体 = MetaSchema 的实体/属性类型
创始人列的属性(文风/叙事/文采/转折/节奏/反转/抓手/打斗/情感/人设/物品描述…)应落为 MetaSchema 的 target type 与字段。建议的分类树(**待评审,非最终**
```text
小说公共属性本体MetaSchema target types
├── 文风类 style
│ ├── 句式/用词/视角/语气/文采
│ └── 文风指纹(用于风格对标/漂移检查)
├── 叙事结构类 narrative
│ ├── 节奏/张力曲线/信息密度
│ ├── 转折·反转(类型/铺垫/触发/效果)
│ └── 抓手·钩子(开篇钩/章末钩/悬念)
├── 桥段·套路类 trope
│ ├── 打斗(招式/节奏/伤亡/场面调度)
│ ├── 情感(情绪弧/关系张力/爆发点)
│ └── 爽点/伏笔(埋设/回收)
├── 人物类 character
│ └── 人设(外貌/性格/动机/弱点/弧光/说话方式)
├── 世界设定类 world
│ └── 世界观/力量体系/金手指类型/组织/地点/规则
└── 物品类 item
└── 物品描述(外观/功能/来历/象征)
```
现有设计已有"文风/叙事/节奏/伏笔"等**高层概念散落**在质量维度(`专题-04`)与作品设定属性里,但**没有**一套结构化的"拆书公共属性参考库";而 MetaSchema 的生产种子实为空——生产迁移 V1V37 零 seed INSERT`work_core`/`setting` 只存在于测试 fixture 与文档示例(详见 §6.2 base 清单的现状纠正)。所以这是**真实缺口**,也印证了创始人"设计里应有类似内容"的直觉——概念在、结构化本体不在,起点比原以为的更低。
### 5.3 拆书如何提供"系统级能力"
全局知识库作为**每个作品的默认可检索来源**:写作/规划智能体在组装上下文时,除了作品自身的 Local KB还会检索全局属性参考库例如"写打斗桥段时,检索公共库里的打斗节奏范式")。这就是"提供每个作品都需要的公共知识库参考"。
**已决策2026-07-08 创始人拍板)**:全局公共库**走显式绑定,不自动注入**。用户/管理员把全局公共属性库绑定到作品后才进入检索选库集;沿用当前 binding 机制,用量归系统、口径可控。拆书系统级产出入全局库后,作为**可绑定的系统来源**供作品选用。
### 5.4 参考作品档案与范式溯源2026-07-08 落定)
系统级拆书要吃进几十上百本参考书,这些书本身得有个落处。它就是 `reference_work`domain=knowledge、scope=entity——一份**系统侧的资料资产档案**,不是用户创作事实。字段合同分四组:**标识**(书名、作者、品类、别名)、**来源**(上传 / 公开语料 / 授权采购)、**版权授权状态**`licensed`/`public_domain`/`research_only`/`unauthorized`,其中 `unauthorized` fail-closed不进任何拆书任务、**拆书处理状态**`pending`/`parsing`/`extracted`/`curated`/`failed`)。
它**不复用 `muse_content_work` 表**:参考作品的 owner 是系统、生命周期是"采购→拆解→归档",与用户创作的"起草→连载→完结"是两码事;塞进 Content 会违反"Content 只拥有正文和作品结构"的边界。但字段层可以经 MetaField 引用**复用容器 schema 的公共字段组**(书名/作者/品类这类标识字段无须重定义)。库位取案 A落 Knowledge BC 的 document 特化,复用 `muse_knowledge_document/_version/_processing_job` 既有资料处理链;拆书任务本身归 AI BC新任务类型 reference_extraction
范式的溯源lineage**不新设 target_type、不新建表**。每条进 Global KB 的范式 Canonical 记录带上 `source_snapshot_id``lineage_payload`,指向它的 `reference_work` + 拆书 job + 章节定位摘要——**只存定位摘要,不存原文段落**(版权 + `专题-03` 禁"作品资产模板化")。"某本书贡献了哪些范式"不是一张新表,而是**按 lineage 的读模型投影**。当某参考作品版权状态收紧,走既有 Source Status Event → Source Propagation**阻断该来源派生范式的新使用,但不回滚已确认的 Canonical**——与知识域现有来源传播语义一致,零新机制。
---
## 6. 三张权威清单Agent × 元数据类型 × 知识库(本轮确定)
本节是本轮要"定下来"的核心。三张清单以**设计文档为 SoT**(代码为辅、可能需改);它们相互咬合——每个 Agent 都是 `f(作品 + 元数据 + 知识库)`,用哪些**元数据类型**、读写哪些**知识库**在 6.4 对应矩阵里定死。
### 6.1 Agent 清单
统一口径agent = 使用大模型的能力节点。**类型固定(对应系统功能链的节点/槽位),产出由元数据驱动**。分三层,只有第一层可挂 Dify app。
#### 6.1.1 开放能力子智能体(可挂 Dify / New-API / 用户/市场智能体)
| Agent | 做什么 | 依赖(作品+元数据+知识库) | 产出 | 目标与边界 |
|---|---|---|---|---|
| **写作 Agent** | 续写/改写/扩写/润色/纠错/去AI味/角色声音 | 近邻正文 + MetaSchema(style/narrative) + Local KB & 检索片段 | AI 候选Shadow | 只进 Shadow不写 Canonical不替换保护节点 |
| **拆书/分析 Agent** | 按 MetaSchema 从文本抽实体/关系;全书解析/章节抽取/摘要/结构拆解 | 文本 + MetaSchema(实体类型) + 目标库Global/Local | 知识草稿Shadow/ 章节解析结果 | 不写 Canonical 知识;章节审阅后才建草稿;系统级只产抽象属性不留原文 |
| **检测 Agent** | 一致性/角色声音/文风/风险/语义偏离检查 | 候选或正文 + MetaSchema(检查约束) + Local KB Canonical | 一致性结果/风险标记/建议 | 不直接改正文或知识;只产解释与定位 |
| **规划 Agent** | 生成大纲/世界设定/人设 | 作品方向 + MetaSchema(world/character/outline) + Local KB | 规划候选Shadow | 未确认候选不得进后续生成上下文 |
#### 6.1.2 保护节点智能体Muse 自持,不可外包为可配置 Dify app
| Agent | 做什么 | 依赖 | 产出 | 目标与边界 |
|---|---|---|---|---|
| **质量门控 / LLM-Judge** | 候选交付前按 MetaSchema 质量维度评分 + Shadow 内有限重写 | 候选 + 质量策略版本 + 上下文快照 + Local KB | Candidate Quality ResultqualityState | 只处理 Shadow硬阻断优先级高于叙事评分不写正文 |
| **合规/语义围栏Moderation** | 输入输出合规、隐私、prompt 注入 | 输入/输出文本 + 合规策略 | 合规结论hardBlock | fail-closed不可被质量分放开 |
| **离线质量评估** | 对智能体/Prompt/策略跑评估集回归 | 评估集 + 脱敏样本 | 评估报告(发布建议) | 不影响用户当前候选;禁用私有正文全文 |
#### 6.1.3 知识基座
| 组件 | 做什么 | 依赖 | 产出 | 边界 |
|---|---|---|---|---|
| **RAG 检索** | 按授权从 Global+作品+绑定库的 Dify dataset 循环检索合并 | binding + 授权快照 + dify-rag | 检索片段(带来源/授权/状态) | 检索基座≠事实源;缺来源/授权不进上下文 |
| **切块/入 RAG/索引** | 资料摄入与索引 | 上传资料 | dataset 索引 | 保护节点;处理未过不得进生成上下文 |
> 命名与"功能链节点"的对应:`生成链→写作Agent``分析/导入解析链→拆书Agent``检测链→检测Agent``规划链→规划Agent``知识处理链→拆书入库+RAG``质量链/合规→保护节点智能体`。功能链是编排骨架Agent 是槽位里的能力。
### 6.2 元数据类型清单(多类多层,非平铺)
元数据**不是一张 11 项的平表**。它按**用途分 5 个元数据用途类**,其中只有第一类(结构本体)是"实体有哪些属性"、被拆书/生成直接消费;结构本体本身又是 **本体分组 → target type → 属性** 三层,且是**开放集**(管理员可增类型、用户可在作品级扩字段,`架构-02 §9`)。所以"固定"的是**几个用途类**,类型与属性可增长。为免两处"大类"串词,下文一律称第一层切分为**元数据用途类**5 类),称结构本体内部的分组为**本体分组**(骨架作品/骨架世界/人物/画像/技法)。
**5 个元数据用途类:**
| 大类 | 管什么 | 载体 | 谁用 |
|---|---|---|---|
| **① 结构本体** | 实体有哪些属性(拆书抽/生成写/检测查的结构模具) | MetaSchema target types下表 A | 拆书/写作/规划/检测 |
| **② 质量维度** | 怎么评判好坏 | Quality Policy下表 B`专题-04` | 质量门控/离线评估 |
| **③ 编排** | 智能体怎么串 | FunctionChain链/节点/开放槽位/保护节点 | AI runtime 编排 |
| **④ 智能体配置** | 每个能力怎么配 | Agent Versionprompt/模型绑定/工具授权/输出合同/槽位兼容 | 各 Agent |
| **⑤ 可见与策略** | 每字段的权限行为 | MetaVisibilityPolicy`aiContext`/`uiVisible`/`userEditable`/`userSearchable`/`exportable`+ 灰度 + 校验 | 上下文组装/授权 |
**前置地基domain 与 scope 两轴**
结构本体的每个 target type 先由两根正交的轴定位;`canonical` 此前从未把它们讲清,这里补成地基。**domain 描述语义域,不描述存储 BC**——它回答"这块结构属于哪个意义世界",与实例落在哪个 Bounded Context 无关Local KB 实体存 Knowledge BC但其 schema 多属 world 或 narrative 域)。**scope 恒等于实例对象的粒度**,取值是 work/chapter/block/entity/relation/event/agent 之一;系统级与作品级之分不占 scope 轴,走 `effective_scope`
五个 domain 的判据:
| domain | 一句话判据 | 承载 |
|---|---|---|
| `content` | 作者对作品的**戏外**承诺与计划,角色感知不到 | 作品容器、创作定位、大纲 |
| `world` | 角色可感知的**戏内**世界事实 | 世界观/地点/组织/力量体系/物品/事件/角色/关系 |
| `narrative` | 只关"怎么讲"、不关"讲什么"的叙事表达层 | 文风/节奏/技法/桥段/套路 |
| `knowledge` | 知识库域自身的资料资产结构(非知识实体的内容结构) | 参考作品档案、范式库目录 |
| `ai_context` | AI 上下文组装与输出合同的结构 | 生成上下文快照模具 |
一处同词提醒target type `world`(世界观总纲,一个具体的结构型)与 domain `world`(世界事实语义域)字面相同却分属两轴——前者是"型"、后者是"域",不是一回事。
**A. 结构本体(对齐后 20 型 · domain/scope 两轴 · Fable 边界精炼2026-07-09 补全 chapter/scene/narrative_state 至 23 型,全清单以专题-06 §4.3 为准)**
下表是与两轴对齐后的 target type 全清单,给的是归属、粒度与落地状态的总览;逐型的字段合同与边界判据见其后的分组详表。
| # | domain | scope | target_type | 中文名 | 本体分组 | 状态 |
|---|---|---|---|---|---|---|
| 1 | content | work | `novel_work` | 作品容器 | 容器 | 既成表实体 + 既成示例schema 化是本轮新增 |
| 2 | content | work | `work_core` | 作品核心 | 骨架·作品 | 已拍板fixture 既有 |
| 3 | content | work | `outline` | 大纲 | 骨架·作品 | 已拍板待落 |
| 4 | world | work | `world` | 世界观总纲 | 骨架·世界 | 已拍板待落fixture `setting` 收编至此) |
| 5 | world | entity | `location` | 地点 | 骨架·世界 | 已拍板待落 |
| 6 | world | entity | `faction` | 组织阵营 | 骨架·世界 | 已拍板待落 |
| 7 | world | entity | `power_system` | 力量体系 | 骨架·世界 | 已拍板待落 |
| 8 | world | entity | `item` | 物品 | 骨架·世界 | 已拍板待落 |
| 9 | world | event | `event` | 事件 | 骨架·世界 | 已拍板待落;`muse_knowledge_event` 表既有 |
| 10 | world | entity | `character` | 角色 | 人物 | 已拍板待落;统一自示例 `character_entity` |
| 11 | world | relation | `character_relation` | 角色关系 | 人物 | 已拍板待落;自 `relation` 更名 |
| 12 | narrative | work | `style` | 文风画像 | 画像 | 已拍板待落 |
| 13 | narrative | work | `pacing` | 节奏画像 | 画像 | 已拍板待落 |
| 14 | narrative | entity | `craft` | 叙事技法 | 技法 | 已拍板待落 |
| 15 | narrative | entity | `combat` | 打斗桥段 | 技法 | 已拍板待落 |
| 16 | narrative | entity | `emotion` | 情感桥段 | 技法 | 已拍板待落 |
| 17 | narrative | entity | `scene_pattern` | 通用桥段 | 技法 | 已拍板待落 |
| 18 | narrative | entity | `trope` | 套路 | 技法 | 已拍板待落 |
| 19 | knowledge | entity | `reference_work` | 参考作品档案 | 系统侧 | 本轮新增(见 §5.4 |
| 20 | ai_context | agent | `generation_context` | 生成上下文 | 配套 | 既成示例,语义已钉(快照模具) |
scope 落座有两处最容易被误判,须点明。`outline``work` 而非 chapterscope 是实例集合的聚合归属粒度,大纲是全书单一计划,其内部的全书/卷/章三层用字段表达层级,不改 scope。`world``work`:按既有边界判据它"不可数、无法单章出场",是每作品一份的单例总纲,故归 work 而非 entity。
**精炼原则——一型一格位、一概念一 owner**:一个候选挣得独立 target type须同时占据"它是什么(实体/计划/画像/装置/范式)× 谁在何时消费(规划/写作/检查期)"平面上的一个空格位,且承载邻接型收编不了的字段合同;否则按三规则降级——**变体收进枚举**(差异只能举例、给不出判据的,如拍卖会/比武会)、**投影收进读模型**(属性可由他型查询导出的,如时间线/关系网/伏笔看板)、**附属收进字段**(离开某型无独立生命的,如金手指例外)。这条准绳兑现"**类型可更多**(格位平面开放、品类增型走同一判据)但**边界必清晰**(判据说不出一句就降级为枚举,杜绝为拆而拆)"。
**"一个概念落一个还是多个 target type"的四判据**(与上面三条降级规则叠加使用):① **粒度判据**——两个用场里实例挂靠的粒度不同work 级单例画像 vs entity 级多条目),倾向拆;② **字段合同判据**——字段交集不足一半、或校验规则互斥,拆;③ **消费判据**——仅消费链路不同而字段相同,不拆,消费差异走可见性策略;④ **写入路径判据**——进 Canonical 的入口不同(用户直接命令 vs 确认链),倾向拆。四判据回答"拆不拆",三条降级规则回答"不拆时降到哪一级"。
**三层浇铸**:结构骨架=只浇作品事实实例(入 Local KB/Planning公共参考=只浇跨作品范式(系统级拆书入 Global KB只存抽象范式+脱敏例证、不留原文);双层=两处都浇。所有型共享基础字段(名称/别名/摘要/标签),下列只标特有字段。
**第 1 组 · 作品骨架(戏外:作者对作品的承诺与计划)**
| key·中文 | 关键字段(特有) | 层次 | 边界判据(纳入 ‖ 排除→归哪) |
|---|---|---|---|
| `work_core` 作品核心 | 题材/主题立意/基调/禁区/结局方向 | 骨架 | 只有作者读者知、**角色感知不到**的作品级承诺 ‖ 角色可感知世界事实→`world`;逐章安排→`outline` |
| `outline` 大纲 | 层级+父级引用/目标/摘要/主支线/情节节拍/伏笔引用 | 骨架 | 对"接下来写什么"的**计划**(全书/卷/章任一层级)‖ 戏内已定事实→`event`;跨作品公式→`trope`;伏笔定义→`craft`(只挂引用) |
**第 2 组 · 世界设定(戏内:角色可感知的世界事实)**
| key·中文 | 关键字段(特有) | 层次 | 边界判据 |
|---|---|---|---|
| `world` 世界观总纲 | 时代文明/地理格局/法则总纲/社会秩序 | 骨架 | 角色可感知但**不可数、无法单章出场**的世界级规则格局 ‖ 可指名出场者→`location`/`faction`/`item`;决定强弱排序→`power_system`;作者层定位→`work_core` |
| `location` 地点 | 地理环境/势力归属/功能氛围/主线关联 | 骨架 | **可到达、可发生场景**的具名空间 ‖ 弥漫地理→`world`;空间上的组织→`faction` |
| `faction` 组织阵营 | 宗旨立场/层级/核心成员引用/资源/对主角态度 | 骨架 | 有宗旨/层级/成员的具名集体(含组织化种族、朝廷)‖ 无组织的文明背景→`world`;成员个体→`character` |
| `power_system` 力量体系 | 等级阶梯/晋升条件/代价限制/金手指例外 | 骨架 | **直接决定角色强弱排序**的规则(可多套并存)‖ 约束所有人而不排序→`world`;具体功法技能→品类开放集增型 |
| `item` 物品 | 外观材质/功能规则/来历归属/象征 | 骨架 | 可持有可流转的具名物件(含秘籍载体)‖ "怎么描写"的笔法→`style` 公共层;无实体抽象能力→`power_system` |
| `event` 事件 | 时间地点/参与者引用/因果链/长期影响/时间线位置 | 骨架 | 戏内时间轴上有参与者与因果的已定/预定事实(**时间线=event 有序投影,不另设型**)‖ 作者写作安排→`outline`;节奏分布→`pacing` |
**第 3 组 · 人物**
| key·中文 | 关键字段(特有) | 层次 | 边界判据 |
|---|---|---|---|
| `character` 角色 | 外貌/性格动机/弱点秘密/说话方式/弧光 | **双层** | 具名/可指认的行动主体;**说话方式=该角色语言指纹,挂角色不挂作品** ‖ 作者全书指纹→`style`;有自身故事的角色间连接→`character_relation`;集体→`faction` |
| `character_relation` 角色关系 | 关系类型/双方引用/张力来源/当前状态/演变轨迹 | 骨架 | **演变轨迹本身就是剧情**、值得立传的关系 ‖ 只更新当前值的结构性从属(属组织/持物/位于→Knowledge Relation 边+枚举(枚举是元数据但非 target type推进公式→`trope`;场景写法→`emotion` |
**第 4 组 · 文风与节奏画像(全书连续特征;应然基准+公共范式,双层)**
| key·中文 | 关键字段(特有) | 层次 | 边界判据 |
|---|---|---|---|
| `style` 文风 | 视角人称/句式用词/语气文采/对白叙述比/描写范式/禁用风格/文风指纹 | **双层** | **与章序无关**的语言表层指纹(打乱章序不变)‖ 顺序敏感分布→`pacing`;单角色语言→`character.说话方式` |
| `pacing` 节奏 | 快慢配比/张力曲线/爽点密度间隔/信息释放速率 | **双层** | **顺序敏感**的分布(值是曲线/比率,打乱章序即毁)‖ 单个爽点构成→`craft`pacing 以 craft 枚举为统计口径);逐章安排→`outline` |
**第 5 组 · 技法与桥段范式(可复用写法;点→场景→情节 粒度阶梯)**
| key·中文 | 关键字段(特有) | 层次 | 边界判据 |
|---|---|---|---|
| `craft` 叙事技法 | 技法类型(枚举:转折/反转/悬念/钩子/伏笔/爽点构成)/构成要素/适用位置/回收状态 | **双层** | 单点装置——**删去它场景仍成立、读者体验变平**;台账只收有跨章履约的装置(伏笔/悬念) ‖ 承载整场戏→桥段三型;密度分布→`pacing`;跨章公式→`trope` |
| `combat` 打斗桥段 | 交战双方与实力差/招式(引`power_system`)/节奏调度/伤亡/爽点触发 | 公共 | **以武力/超自然力分胜负**的对抗场景 ‖ 非武力博弈(商战/权谋/斗嘴)→`scene_pattern`;情绪弧主导→`emotion` |
| `emotion` 情感桥段 | 情绪类型/情绪弧/张力来源(引`character_relation`)/爆发点/铺垫释放 | 公共 | **以情绪弧为主体**的场景(告白/离别/爆发)‖ 武力分胜负→`combat`;关系实体本身→`character_relation` |
| `scene_pattern` 通用桥段 | 桥段类型(枚举:拍卖/比武/夺宝/审判/博弈…)/场景目标/参与者配置/推进结构/变体 | 公共 | 有"目标-推进-收束"完整结构的场景范式;**枚举显式排除打斗与情感**(互斥闭合)‖ 点装置→`craft`;跨场景公式→`trope` |
| `trope` 套路 | 流派归属/前提公式/结构节拍/爽点逻辑/变体雷点 | 公共 | 规定"这条线接下来几章怎么走"的公式 ‖ 单场景流程→`scene_pattern`;本作品自己的计划→`outline`(挂引用) |
**四条外边界**(与型内判据同等效力):质量维度(`专题-04` 11 维)不进本体——模具↔检尺,见 §6.2 C不共定义Narrative State 不进本体——运行态是独立事实载体(`架构-02 §3`本体只提供其字段模具RAG 文档面不进本体——上传的百科语料是检索素材,非 faction/event 实例(除非经确认链落成作品实体);品类特化与作品私有不进基础层。
**MECE 自检结论**:修复 6 处重叠scene_pattern 枚举排除打斗/情感、爽点按值类型劈、打脸戏机制归 craft/流程归 scene_pattern 双面正交、物品描述档案归 item/笔法归 style、说话方式/文风按归属主体劈、character_relation/Knowledge-Relation 物化判据钉死)+ 堵 5 处缺口卷级大纲→outline 分层字段、描写素材范式→style 公共层、非武力对抗→scene_pattern 博弈类、种族血脉按判据裁决、功法技能=开放集首验件不进基础层);显式封死伪型候选(时间线/关系网/势力地图/伏笔看板=投影,代入感/爽感=效果词,字数/更新/书名=运营面)。**层次全景(结构本体骨架 17 型):结构骨架 9work_core/outline/world/location/faction/power_system/item/event/character_relation+ 双层 4character/style/pacing/craft+ 公共参考 4combat/emotion/scene_pattern/trope**;对齐 domain/scope 后再并入容器 `novel_work`、系统侧 `reference_work`、配套 `generation_context`,合计 20 型(见上文 A 表2026-07-09 补全 chapter/scene/narrative_state 至 23 型)。
> **你点名的 15 维度全部有归属**:文风→`style`;叙事→`style`(视角)+`pacing`+`trope`;文采→`style`;转折/反转→`craft`枚举;节奏→`pacing`;抓手→`craft`(钩子);套路→`trope`;打斗→`combat`;情感→`emotion`;人设→`character`;物品描述→`item`(档案)+`style`公共层(笔法);伏笔→`craft`定义(+`outline`引用);爽点→`craft`(构成)+`pacing`(分布);桥段→`scene_pattern`(+combat/emotion 特化)。
**作品容器(`novel_work`)的建模**
作品容器不是新造的抽象。`muse_content_work` 已有固定列——owner_user_id/title/genre/summary/status/word_count/chapter_count/import_status/parse_status且已预留 `work_schema_id` 可选关联 MetaSchema`后端-05 §4.3` 已预设 `PATCH /works/{workId}` 修改动态字段时须校验 schemaVersion/projectionVersion。容器 schema 化早有伏笔,不是无中生有。`产品-02C` 有"封面"而表合同无 cover 列,这类缺口随容器 schema 落地一并收编。
**容器与 `work_core` 拆成两个 schema**,依据是四判据的第 2、4 条:字段合同不同构(容器承载书名/简介/封面/状态/字数/时间这类运营与结构身份,`work_core` 承载题材/主题立意/基调/禁区/结局方向这类创作承诺),写入路径不同(容器走用户直接 PATCH + 系统回算,`work_core` 走 Planning 确认链),消费面不同(容器喂列表/卡片/导出/市场快照,`work_core` 喂全部开放 agent 的上下文)。
**容器 schema 实例与 Work 聚合的关系是"同体 + 投影",不派生**——不另建实例表避免双写漂移。schema 是对 Work 固定列的**元描述与读投影模具**:字段合同为每字段标注落点,`native_column`(物理列)、`extension_json`(扩展字段)或 `computed`(回算字段,`userEditable=false`),这就是 `storage_binding` 机制。与 `架构-02 §9`"MetaSchema 不替代事实载体"一致。卷章结构不进字段合同,按"投影收进读模型"降级,由统一读取器的作品分区输出。
**base 与内置:全局 schema 的叠加与继承**
`base` 不是新机制:**base = `effective_scope=全局` 的 active schema 版本**admin 全局定义),"内置"就是系统 seed 出厂的那批全局 schema。一个作品能看到的结构是三层叠加全局 active ⊕ 类型灰度版本active 唯一约束按 schema_key + effective_scope 分桶,既有)⊕ 作品级 override。**override 只增不改**——只能扩展字段,不能删改全局字段的定义与可见性;这是 `架构-01 §4` 的推论canonical 此前未写明,须补写。
继承靠动态合成、不靠复制:系统**不落 per-work schema 实例**,读取时按 projectionVersion 动态合成投影。新作品开箱即有全部结构,因为全局 schema 天然生效、零初始化;`work_schema_id` 保留为品类包钩子,默认空即纯继承。
**seed 现状要如实纠正(反假绿)**:生产迁移 V1V37 **零 MetaSchema seed INSERT**(已验真),`work_core`/`setting` 只存在于测试 fixture 与文档示例——评审稿此前"当前 seed 仅 work_core + setting"的说法要弱化到这一格W1 的真实起点比原以为的更低。
**系统内置 base schema 清单W1 种子目标全集 = 23 项四档,以专题-06 §5 为准2026-07-09 补 chapter/scene/narrative_state**——这也修正了评审稿 W1 原列 11 类与本节型清单的内部不一致:
| 档 | 开箱形态 | schema | 可见性基线 |
|---|---|---|---|
| 作品身份档 | 开箱即有 | `novel_work``work_core` | uiVisible=true、userEditable=true容器回算字段除外、aiContext=true禁区/结局方向按需) |
| 创作骨架档 | 开箱即有、空实例 | `outline``world``location``faction``power_system``item``event``character``character_relation` | 三可见全开 |
| 画像与技法档 | 开箱即有画像;公共范式随 Global KB 绑定进入 | `style``pacing``craft``combat``emotion``scene_pattern``trope` | 作品面全开;公共面例证/出处字段 uiVisible 待例证可见性口径 |
| 系统侧档 | 用户不可见 | `reference_work`(仅 admin 面)、`generation_context`(运行时) | uiVisible=false、userEditable=false |
**B. 质量维度Quality Dimensions`专题-04` 已定,勿与结构本体重复定义)**
| 组 | 维度 key |
|---|---|
| 硬阻断 | `source_safety``output_compliance``static_contract` |
| 叙事关键 | `canon_compliance``character_voice``scene_structure` |
| 非关键 | `style_fit``pacing_tension``information_density``emotional_continuity``readability` |
**C. "同名不同角色"辨析(结构本体 vs 质量维度,勿混)**
原则:**结构本体定"应然基准"(模具),质量维度量"候选与基准的偏离"(检尺)**。归属不同(本体归 MetaSchema `架构-02 §9`,维度归 Quality Policy `专题-04 §4`单向咬合、不共享定义、key 无字面冲突。典型对:`pacing`(本作节奏基准)↔ `pacing_tension`(候选是否维持);`style`(声音画像)↔ `style_fit`(候选贴合度);`character.说话方式`(角色声音定义)↔ `character_voice`(候选对白是否符合);大类 2 全部实体Local KB Canonical`canon_compliance`(候选是否与已确认事实冲突)。**推论**:管理员给 `pacing` 加"反转密度"字段,拆书会多抽、写作会多用,但**是否据此评分是 Quality Policy 的独立决策**,本体扩字段不自动等于质量维度扩检查项。
**D. 边界裁决与待确认2026-07-08 更新)**
上一版 5 处待确认经边界精炼**全部裁决"维持"**world 拆 4 型("可数出场"+"排序"双测试反证拆分线真实scene_pattern 独立(场景单元 vs 情节公式格位与消费时机不同范式为主例证为辅character_relation 收窄(物化判据);伏笔不升型(升型触发明确=未回收伏笔看板成一级能力时枚举迁 schema_key路径无损
**本轮已移入已决策**(不再挂起,见 §0 决策面与 §7.2`combat` 语义钉死为"武力/超自然对抗"、非武力对抗归 `scene_pattern` 博弈类;命名统一(`character_entity``character``relation``character_relation``setting``world``generation_context` 保留为快照模具;`storage_binding` 存储映射随 W1 落;连同功能链归属(案 A、双轨 Canonical 入口增补(批准)、`reference_work` 库位(案 A
**仍保持开放的待拍板:**
1. **双层型公共面是否独立 `*_paradigm`**style/pacing/craft 的公共面暂与作品面共用同一 schema单模具双库主案是否需拆出 `*_paradigm` 独立型,由 **W4 三样本投递**用"拆书产出能否无损写进同一字段合同"实测终裁不同构再拆W1 先落作品面种子不阻塞。
2. **公共范式例证的用户可见性口径**:范式挂的脱敏例证 + 书目出处是否对普通用户展示(版权观感),与公共面字段基线联动,待定口径。
3. **`character_relation` 公共层关闭**Fable 加严裁决):现钉为结构骨架型、不开公共层,关系可复用面由 `trope`(关系推进公式)与 `character` 原型承载。现在关、以后按判据开顺滑;若判言情品类"关系流派库"要成一级资产则开。
4. **`outline` 卷层字段是否随 W1 落库**Fable 建议落(长篇必有卷纲);若砍须在 W1 台账记明缺口。
5. **玄幻品类包(功法/技能等开放集增型)排期**:基础层已按硬约束排除,作为开放集机制"首个验证件",只关排期不关边界。
> 边界判据的真验证点在 **W4 拆书元数据化**:用《凡人修仙传》学艺章、言情先婚后爱名场面、玄幻拍卖会打脸戏三样本投递拆书,检查是否出现"一实例两型争抢"或"元素无家可归"Fable 已桌面推演通过,建议纳入 W4 抽取评估集)。
### 6.3 知识库清单
| 库 | 归属 | 实体/图谱面PGMetaSchema 结构化) | 文档/RAG 面Dify dataset | 谁读/写 |
|---|---|---|---|---|
| **全局知识库 Global KB** | 平台/管理员 | 拆书公共属性参考craft/combat/emotion/trope/style 范式实例) | 写作方法/平台规范/公共资料语料 | 拆书(系统级)写;写作/规划读(系统默认来源) |
| **用户知识库 User KB** | 普通用户 | —(以资料为主) | 用户上传的可复用资料语料 | 用户维护;绑定后写作/规划读 |
| **局域知识库 Local KB** | 单作品 | **作品故事圣经**:角色/物品/世界设定/事件/关系/时间线/叙事状态 | 作品参考资料语料 | 拆书(作品级)写草稿→用户确认;写作/检测读 |
> 授权态(已安装 Installed / 账户可用 Account-Available与市场公开副本D0-fork dataset是 User/Global 库的**授权与隔离形态**,不新增独立库。
### 6.4 对应关系矩阵Agent × 元数据 × 知识库)
这是三清单咬合的锚。读=消费为上下文,写=产出落库。
| Agent | 用哪些元数据类型 | 读哪些库 | 写哪些库/去向 |
|---|---|---|---|
| **写作 Agent** | style、character(voice)、outline、craft/combat/emotion | Local KB(实体) + Global KB(公共属性) + 检索 | → Shadow 候选(不直接写库) |
| **拆书 Agent** | 全部实体本体world/character/item/event/style/craft… | 源文本(书 or 已确认章节) | 系统级→Global KB作品级→Local KB经 Draft→确认→Canonical |
| **检测 Agent** | canon_compliance、character_voice、style、outline | Local KB(实体) | → Risk Marker / 一致性结果 |
| **规划 Agent** | work_core、outline、world、character | 作品方向 + Local KB | → Planning 候选Shadow |
| **质量门控/LLM-Judge** | 质量维度6.2 B | 候选 + Local KB | → Candidate Quality Result |
| **合规/围栏** | output_compliance、source_safety | 输入/输出文本 | → 合规结论hardBlock |
| **RAG 检索** | style/craft决定检索什么维度 | Global+User+Local dataset | → 检索片段 |
---
## 7. 代码现状与设计的偏差(设计为准,偏差即代码待修正项)
口径:**设计文档是 SoT下表"应然"列不可动;"现状"是代码偏差,需要改代码向设计对齐,不是弯设计迁就代码。**(现状均为 2026-07-08 只读盘点已验证。)
### 7.1 五处关键偏差(代码待修正)
| 主题 | 应然(设计 SoT | 现状(代码偏差) | 修正方向 |
|---|---|---|---|
| **元数据驱动产出** | MetaSchema 约束提取/规划/检查/产出(`架构-02 §9` | MetaSchema **只接通 Content**(动态表单/规划字段);**AI runtime 零消费**`AiContextUsageContributor` 自证 `verifiedZero``aiContext` 开关不被裁剪) | 把 MetaSchema projection 接进 AI 上下文组装与输出合同,让产出结构随 schema 变 |
| **功能链编排** | FunctionChain 定义节点/开放槽位/保护节点AI 按链编排 | FunctionChain/ProtectionNode 是**纯治理台账**:无 meta-api 读端口、AI 零引用、激活不发事件 | 建 facade-api + 激活发事件AI runtime 按激活功能链解析节点与槽位 |
| **两套 slot 命名空间** | 一套槽位:功能链开放槽 ← 用户/市场 Agent 绑定 | **两套并行**meta `FunctionChainSlot`治理slotKey 如 `content-ingest`)与 AI `MuseAgentSlotBinding`运行时slotKey 如 `writer`)无映射 | 收敛为一套AI 槽位绑定引用功能链槽位合同 |
| **拆书元数据化** | 拆书按元数据实体类型抽人设/物品/文风入库;每确认章都跑 | 解析产物仅"章节切分title+content+字数)"V34非实体抽取每确认章跑拆书未接 | 拆书按 MetaSchema target type 产结构化实体草稿;接"确认章→拆书+质检"触发 |
| **检测/质检用 LLM** | LLM 检测 + LLM-Judge 质量门控 | 候选轻量审=规则桩、质量评测=hash 打分桩 | 接真 LLM走保护节点内部受控路径 |
补充偏差(次要):知识库"全局公共作系统默认来源"设计有、代码需显式 binding 无自动注入;图查询 GraphRAG 构图链占位未接。
### 7.2 已决策2026-07-08 创始人拍板)
| 决策 | 结论 |
|---|---|
| **落地深度与节奏** | **全量接通**:本轮把 MetaSchema + FunctionChain 真正接进 AI runtime生成/拆书/质检按 schema 定产出、按功能链编排、两套槽位收敛)一次到位,不分期。这是跨 meta+ai+knowledge 的结构性改造,执行计划见第八节。 |
| **元数据类型清单6.2** | **认可全量**:结构本体经 Fable 边界精炼、并与 domain/scope 两轴对齐后定稿 **20 型**(分 5 个本体分组,每型带 MECE 边界判据),全落为 MetaSchema target types剩余待确认见 6.2 D2026-07-09 补全至 23 型,以专题-06 为准);质量维度沿用 `专题-04`;两类不重复定义。元数据整体按用途分 5 个**元数据用途类**(结构本体/质量维度/编排/智能体配置/可见策略)。 |
| **拆书公共属性 SoT 归属** | 落为 MetaSchema target types管理员在 `产品-02B` 元结构台维护)+ 收口时新建 `专题-06` 定义本体(见第九节)。 |
| **全局库检索方式** | **显式绑定**,不自动注入(见 5.3)。 |
| **知识实体强绑 MetaSchema** | **强绑**`muse_knowledge_entity.entity_type` 改为引用 schema_keytarget type实体字段按 MetaField 结构存——这样"元数据变→实体结构变"才真正闭环(纳入第八节工作项)。 |
| **功能链归属** | **取案 A**:功能链定义与 MetaSchema 同住元引擎Meta BC表名 `muse_meta_function_chain*` 不动AI runtime 经 `FunctionChainQueryApi` 读激活链做运行编排。已验真零数据迁移V3/V10 迁移即建此表、代码全在 muse-module-meta。 |
| **双轨 Canonical 入口增补** | **批准**`架构-02 §1.2` 封闭枚举新增「管理员确认系统级知识草稿 → Global KB 范式 Canonical」确认人=管理员,走 Draft→确认→Canonical 同构链;解锁 W8 拆书系统级链路。 |
| **参考作品库位** | **取案 A**`reference_work` 落 Global KB document 特化Knowledge BC复用 `muse_knowledge_document/_version/_processing_job`;拆书任务归 AI BC新类型 reference_extraction版权受限走 Source Status Event→Propagation 阻断新使用、不回滚已确认。 |
| **默认落定(有异议可翻)** | 命名统一(`character_entity``character``relation``character_relation``setting``world`);双层型取单模具双库(一型一 schema、公共面实例落 Global KB 共用同一 schema是否拆 `*_paradigm` 由 W4 终裁);`generation_context` 保留为快照模具;`storage_binding` 随 W1 落(`muse_meta_field` 增 native_column/extension_json/computed。 |
---
## 8. 执行计划:全量接通(创始人已定,待过审后动代码)
目标态:**每个 Agent = f(作品 + 元数据 + 知识库),按功能链编排,元数据变则产出变**。这是跨 `meta + ai + knowledge` 三模块的结构性重构。按工程红线,本节是执行版预案,创始人过审后再落代码;每步都要真 PG IT + 活体证据,不假绿。
### 8.1 工作分解8 项,按依赖排序)
| # | 工作项 | 模块 | 做什么 | 依赖 |
|---|---|---|---|---|
| W1 | **元数据本体落库** | meta | 落 23 项 target type 的生产种子(四档全集以专题-06 §5 为准:作品与结构身份档 novel_work/chapter/scene/work_core、创作骨架档 outline/world/location/faction/power_system/item/event/character/character_relation/narrative_state、画像技法档 style/pacing/craft/combat/emotion/scene_pattern/trope、系统侧档 reference_work/generation_context+ 字段/枚举/校验;并入 `storage_binding`——`muse_meta_field` 增 native_column/extension_json/computed 属性,描述容器 schema 对 Work 固定列的落点 | — |
| W2 | **MetaSchema → AI 上下文** | meta→ai | AI 上下文组装消费 `MetaProjection`:按 `aiContext` 裁字段、按 target type 定输出合同;`AiContextUsageContributor``verifiedZero` 改真实消费 | W1 |
| W3 | **Local KB 实体强绑 schema** | knowledge+meta | `muse_knowledge_entity.entity_type` 引用 schema_key实体字段按 MetaField 结构存jsonb + schema 校验) | W1 |
| W4 | **拆书元数据化** | ai+knowledge | 拆书 Agent 按 target type 产**结构化实体草稿**(替代现 title+content 章节切分);接"每确认章 → 拆书抽实体 + 质检一致性"触发 | W2,W3 |
| W5 | **FunctionChain → AI 编排** | meta→ai | 案 A功能链定义留在 Meta BC表名/归属不动、零数据迁移meta-api 增 `FunctionChainQueryApi` 读端口 + 激活发事件AI runtime 按激活功能链解析节点序列与开放槽位 | W2 |
| W6 | **两套 slot 收敛** | meta+ai | `MuseAgentSlotBinding.slotKey` 引用 `FunctionChainSlot` 合同requiredNodeType 兼容校验);统一命名空间 + 历史数据迁移 | W5 |
| W7 | **检测/质检接 LLM** | ai | 候选轻量审→LLM 检测质量评测→LLM-Judge走保护节点内部受控路径非可配置 Dify app | W2 |
| W8 | **拆书系统级入全局库**(双轨入口已批准 2026-07-08解锁 | ai+knowledge | 管理员拆参考书 → 按公共属性 target type 抽 → 经「管理员确认草稿→Global KB 范式 Canonical」新入口入库供作品显式绑定参考作品档案落 `reference_work`,范式带 lineage 溯源 | W4 |
### 8.2 顺序、验证、风险
- **顺序**`W1 → W2 →W3,W4 并W5,W6 并W7,W8 并)`
- **闭环验证**(最关键的真验证):一个端到端证明"**加字段 → 拆书多抽该字段 → 生成多用该字段**"——在 world schema 加"金手指类型"字段,重跑拆书应多抽该属性、写作上下文应可见,全程真 PG + 活体,不用桩。
- **各步验证**:真 PG IT每模块+ MSW-off 活体 e2e两套 slot 收敛须有历史 binding 迁移测试。
- **风险**:① AI runtime 大改,须保留"功能链未激活→回退现有固定链"的兜底;② slot 收敛涉及历史数据迁移;③ MetaSchema 版本切换与 AI 上下文一致性(版本漂移);④ 拆书产出结构变更冲击既有 parse IT须同步更新台账
- **回滚**:各步 git 可回W5/W6 未激活功能链时运行时行为等价现状。
---
## 9. SoT 落地建议评审已通过canonical 落地开工)
三项材料级决策与默认落定已拍§0、§7.2评审核心结论通过canonical 分册可开工。按单一归属把内容蒸馏进正式分册,并在 `00-文档大纲.md``内容映射表.md` 注册。目标改动清单:
- **新增 `专题-06-元数据驱动的智能体架构.md`**owner 收口这套横切架构——元引擎 + 功能链驱动智能体、拆书通用抽取、三体关系、**target type 本体(对齐后 20 型2026-07-09 补全至 23 型 + domain/scope 两轴、统一创作数据读取器、base 种子清单**(当前散落在专题-03/05、架构-02需一个 owner
- **修订 `架构-02`**§1.2 双轨 Canonical 入口封闭枚举 +1管理员确认系统级知识草稿→Global KB 范式);分区表补功能链行(定义归 Meta、运行编排归 AI§9 补 domain 逐值语义与 **override 只增不改** 推论。
- **修订 `后端-04`**:功能链表名与归属订正为 `muse_meta_function_chain*` 归 meta示例 `character_entity``character``muse_meta_field``storage_binding`native_column/extension_json/computed补 base 种子清单。
- **修订 `架构-01 §3.3`**:功能链落地矩阵行(定义归 Meta、AI runtime 经 `FunctionChainQueryApi` 读激活链编排)。
- **修订 `后端-02 §3.4`**Governance Facade 落点表补功能链行(`FunctionChainQueryApi` 读端口)。
- **扩写 `产品-02B`**:拆书公共属性本体作为 MetaSchema target types 的管理面 + 参考作品治理(`reference_work` 档案、版权授权状态、拆书处理状态)。
- **扩写 `产品-02E`**:知识库三库 × 两面正交结构、全局库作系统默认来源、拆书产出入全局库。
- **扩写 `专题-04`**:质量维度与拆书属性本体对齐(同一"节奏/文风"不两处定义)。
- **不新增**过程状态文档;进度只进 `docs/mvp/进度总账.md`
> 术语与不变式仍以 `架构-02` 为唯一权威;本文所有结构定义在落 canonical 时须回指其术语,不另造词。

View File

@ -0,0 +1,50 @@
# Muse 2.0.0 单人版部署手册
> 版本 v0.1 · 2026-07-08 · 读者:部署与运维者 · 边界:仅覆盖单人版(1a)从零部署与回滚;设计意图见 [review v0.2](../agent-specs/2026-07-07-2.0.0单人版改造-review.md),任务拆解见 [execution](../agent-specs/2026-07-07-2.0.0单人版改造-execution.md)。
## 一、拓扑:双栈独立
单人版是两套 compose 各自独立、通过网络互访,不合并成一个编排:
- **本栈**(`muse-cloud/docker-compose.solo.yml`):muse-server 单体 + PostgreSQL + Redis。三者同网络,muse-server 经服务名 `postgres`/`redis` 直连;仅 muse-server 的 48080 对外暴露。
- **Dify 栈**:沿用 Dify 官方 compose 独立部署(见 Dify 官方文档),提供写作/解析 chat app 与知识 dataset 检索。本栈通过 env 指向 Dify 的 base-url 与两类 key,不反向依赖。
这样切分的理由是:Dify 有自己的版本节奏与官方编排,合并会把它的升级风险耦合进 muse 主栈;独立后各自可单独重启、单独升级。
## 二、凭据清单
部署前准备下列值,写入 `muse-cloud/.env.solo`(从 `scripts/dev/solo-compose.env.example` 复制骨架;该样例只带开关默认值、不带真实凭据):
| 变量 | 含义 | 来源 |
|------|------|------|
| `MUSE_POSTGRES_PASSWORD` | 本栈 PG 密码 | 部署者自定,生产必须显式设置 |
| `MUSE_REDIS_PASSWORD` | 本栈 Redis 密码 | 部署者自定,生产必须显式设置 |
| `MUSE_AI_DIFY_BASE_URL` | Dify chat app 入口(`.../v1`) | Dify 部署地址 |
| `MUSE_AI_DIFY_WRITING_APP_ID` / `_API_KEY` | 写作 chat app 与其 Service key | Dify 控制台该 app |
| `MUSE_AI_DIFY_PARSER_APP_ID` / `_API_KEY` | 全书解析 chat app 与其 Service key | Dify 控制台该 app |
| `MUSE_KNOWLEDGE_DIFY_BASE_URL` | Dify 知识 dataset 入口(`.../v1`) | Dify 部署地址 |
| `MUSE_KNOWLEDGE_DIFY_DATASET_API_KEY` | Dify **dataset** key(与 app key 不可混用) | Dify 控制台知识库 API |
`credentialRef`(如 `dify-writing-s1`/`dify-parser-s1`)只是引用名、非密钥,已在 `solo-compose.env.example` 固定,须与注入的 Dify 凭据 ref 一致。app key 与 dataset key 是两套体系,混用会 401。
## 三、从零部署步骤
1. **出可运行 jar**(Dockerfile 只 COPY jar、不在容器内编译):
`mvn -pl muse-server -am -Dmaven.test.skip=true clean package`
2. **备凭据**:`cp scripts/dev/solo-compose.env.example .env.solo`,按第二节填全。
3. **起栈**:`docker compose -f docker-compose.solo.yml --env-file .env.solo up -d --build`
4. **库 provision(自动)**:PostgreSQL 空卷首次初始化时,`docker-entrypoint-initdb.d` 自动执行 `sql/dev/yudao-base-*-postgres.sql` 建 yudao 基座;muse-server 启动时 Flyway 跑 `sql/muse/` 里 36 个脚本(V1..V37)建 muse 业务表。**关键**:`sql/muse/` 经 compose 只读挂载到容器 `/muse-server/sql/muse`(`flyway.locations``filesystem:sql/muse`),脚本未打进 jar——**部署目录必须含 `sql/muse/`,漏挂则 Flyway "No migrations found"、muse 表全缺、AI worker 每秒报 `relation does not exist`**(mini-infra 从零真验暴露并已在 compose 固化挂载)。二者顺序由 depends_on healthcheck 保证。
5. **菜单收口**:provision 后执行 `sql/solo/2026-07-08-disable-solo-governance-menus-postgres.sql`(隐藏市场治理/多用户相关菜单;幂等,回滚脚本见同目录 `restore-*`)。
6. **验活**:`curl --noproxy '*' http://<host>:48080/app-api/muse/...`(app-api 固定带 `tenant-id: 1`)。
无 Nacos 环境已在 compose 内以 `SPRING_CLOUD_NACOS_DISCOVERY_ENABLED=false` 关闭服务发现,启动日志不应有注册中心持续重连噪声。
## 四、回滚与恢复多用户
部署层回滚是独立的:`docker compose -f docker-compose.solo.yml down`(加 `-v` 清库卷则回到全新)。代码层不受影响。
若日后要恢复多用户形态(市场/成员),不是「取消一行 pom 注释」,按 [review v0.2 §10](../agent-specs/2026-07-07-2.0.0单人版改造-review.md) 清单执行:muse-server pom 取消 `market-server` 注释 → 激活 `market-assembled` maven profile(回编译被排除的市场测试树、修排除期漂移) → 覆盖门 market 域退出 dormant → 前端 revert 裁剪 → 部署库执行 `restore-solo-governance-menus` 恢复菜单 → 成员子域打开对应 `enabled` 开关。RAGFlow 运行时已在 S6d 删除(git 可回),恢复需按新集成重走契约与验收,不承诺兼容。
## 五、Dify 版本升级约定
Dify 是单人版唯一新增外部服务,且承载生成与知识两条主链。**升级 Dify 版本前必须先在隔离环境跑 live 验收**:`P1rDifyChatLiveAcceptanceIT` + `P1rDifyDatasetsContractLiveIT`(`MUSE_P1R_EXTERNAL_ACCEPTANCE=true`)+ 本手册第三节的黄金旅程 smoke,全绿后再升生产。Dify 的 console CSRF、per-tenant RSA 凭据加密、chat/dataset 两套 key 语义在小版本间都可能变,不跑验收直接升会静默打断生成或检索。

View File

@ -23,6 +23,58 @@
**round-2 加固(2026-06-14,据 Opus 评审;2026-06-27 E0 续固)**:CI 触发分支 master→main(此前 CI 从不运行)、BC 门通用化 + 整改 knowledge 违例、loop 装机械牙、覆盖门去魔法数、文档诚实化、openapi-diff materialize 为独立 workflow。E0 将 Maven CI 移到仓库根 `.github/workflows/maven.yml`,并用 `run-p1r-verification.sh` + [真验证清单](1.0.0-真验证清单.md) 区分 local-ci / real-PG / external-live / mock-only / skipped。E0 fresh 证据:local 61/0F/0E/0S,根 CI 等价 `mvn -B package --file pom.xml` 3060/0F/0E/148S,real-PG 189/0F/0E/0S,external-live server 8/0F/0E/0S + module 4/0F/0E/1S(GraphRAG attribution 未配置跳过)。
**2.0.0 单人版改造 S0 门禁基线(2026-07-07)**:已坐实并修复 local 覆盖门基线红项。初跑 `bash muse-cloud/scripts/run-p1r-verification.sh local` 先暴露本机 Maven 绑定 JDK17 与沙箱写 `~/.m2` 的环境问题;显式 `JAVA_HOME=/Users/qingse/Library/Java/JavaVirtualMachines/corretto-21.0.7/Contents/Home` 后真跑坐实 5 个失败,均为跨域 gate 仍按旧 `completedOperations=241 / AI=47` 断言,而覆盖 JSON 已是 `242/242` 且 AI 为 48。新增 AI operation 为 `adminArchiveAgent`,已有 `MuseAgentServiceTest``AdminMuseAgentControllerAnnotationTest` 两个真实 `testFiles` 证据。修正 `P1rMarketRealApiGateTest``P1rKnowledgeRealApiGateTest``P1rEventsRealApiGateTest` 的 summary/AI 计数后,复跑 `run-p1r-verification.sh local` **61/0F/0E/0S, BUILD SUCCESS**。本步未改覆盖 JSON、OpenAPI、业务实现或数据库迁移S1 Dify 部署仍未开始。
**2.0.0 单人版改造 S1 Dify 基础设施与 live 契约(2026-07-07)**:已在 `mini-infra` 用官方 Dify `1.15.0` compose 独立部署 `muse-dify`,入口 `http://100.64.0.8:18080`API health 返回 `version=1.15.0`;该栈带自己的 `db/redis/weaviate/nginx/sandbox/plugin_daemon/worker/web` 等容器,不替换 Muse 既有 PG/Redis。Dify 绑定 New-API OpenAI-compatible provider已接入 `MiniMax-M2.5``Qwen/Qwen3-Embedding-8B``Qwen/Qwen3-Reranker-8B`,并创建写作 chat app、全书解析 workflow app、工作区级 Datasets API key。S1 真实验收新增 `P1rDifyChatLiveAcceptanceIT``P1rDifyDatasetsContractLiveIT`,默认 `MUSE_P1R_EXTERNAL_ACCEPTANCE` 未启用时 honest skipped显式 opt-in 后真实调用 Difychat app 返回 `status=success / messageIdPresent=true / summarySha256Prefix=565339bc4d33`Datasets 完成 create/upload/index/retrieve保留证据 dataset `cce33d41-c738-42d2-97b6-2d08a69965f2``indexingStatus=completed``score=0.7442479133605957`。踩坑已沉淀:Dify 官方 `.env``STORAGE_TYPE=opendal` 在本部署下会导致 worker `File not found`,已切到 `STORAGE_TYPE=local` + `STORAGE_LOCAL_PATH=storage` 并保留远端备份 `.env.s1-storage-before-local`Dify 1.15 `/retrieve``retrieval_model` 必填 `search_method``reranking_enable`。回归证据:targeted live 2/0F/0E/0S、默认跳过 2/0F/0E/2S、`run-p1r-verification.sh local` 61/0F/0E/0S。RAGFlow 实例下线与已暴露 key 轮换仍为手工事项,不阻塞后续 S2。
**2.0.0 单人版改造 S2 门禁与测试树适配(2026-07-07)**:已完成“机制就绪、不拨开关”。`muse-server` 新增 `market-detached-test-tree` profile显式 `-Dmuse.market.detached.test-tree=true` 时用 compiler `testExcludes` + surefire excludes 剥离 14 个 market/account 混合装配测试及 `P1rMarketRealApiGateTest`;默认本地构建仍编译全量测试树,`market-assembled` 可在 dry-run profile 同启时恢复全量装配口径。`P1rApiCoverageReportTest` 引入仅 market 可用的 `dormant` 口径dormant 保留总 operation 242、不计入 completed、跳过 completed 证据强制,但仍要求 dedicated 且属于批准域;覆盖 JSON 本步未改。AI/Knowledge/Events 三个 RealApiGate 的汇总断言同步改为 `totalOperations=242``completedOperations=242-marketDormant`,避免 S3 dry-run 在非 market gate 假红。Admin e2e 增加 `MUSE_ADMIN_E2E_MARKET=false` 开关,显式关闭时跳过 market/account 治理切片和 market fixture默认不跳过。验收证据:Admin typecheck 通过;`P1rApiCoverageReportTest` 默认 9/0F/0E/0SAI/Knowledge/Events RealApiGate 22/0F/0E/0S`-Pmarket-assembled -DskipTests clean test` 回编译全量 91 个 muse-server 测试源;主工作树 `run-p1r-verification.sh local` 62/0F/0E/0S临时 worktree 注释 market-server 依赖并把 market 32 条置 dormant 后,带 `MAVEN_ARGS='-Pmarket-detached-test-tree -Dmuse.market.detached.test-tree=true'` 的 local dry-run 56/0F/0E/0S。未改 OpenAPI、DDL、业务实现、覆盖 JSON 或外部凭据S3 仍需原子拨开关。
**2.0.0 单人版改造 S3 market 摘装配(2026-07-08)**:已原子拨开关并完成验收。`muse-server` 默认保留 `muse-module-market-api`、停用 `muse-module-market-server``market-detached-test-tree` 默认生效;`market-assembled` profile 恢复 market-server 依赖与全测试树回编译。`MonolithFacadeFallbackAutoConfiguration``MarketHandoffTokenApi``MarketAssetSourceApi``MarketAssetForkApi` 增加 `@Bean @ConditionalOnMissingBean` fail-closed 兜底verify 返回 `valid=false/MARKET_DETACHED`consume 返回 `consumed=false`asset source 返回 `notFound()`fork 写回返回 `false`,首次调用 warn 日志;真实 market Bean 存在时不覆盖。覆盖 JSON 将 market 32 条 operation 改为 `dormant``summary.completedOperations=210`,未删 operation/testFiles/sourceFiles未改 OpenAPI/DDL/业务实现/外部凭据。新增 `P1rMarketDetachedAssemblyGateTest` 证明 fallback、真实 Bean 优先、默认摘装配、`/app-api/market/**`/`/admin-api/market/**` 404 形态Knowledge installed 列表补空结果断言,既有 market_kb binding notFound/fork-not-ready 测试继续覆盖 fail-closed。验收证据:server targeted gate `P1rApiCoverageReportTest`+`P1rMarketDetachedAssemblyGateTest` 13/0F/0E/0Sknowledge targeted `MuseKnowledgeBindingServiceTest`+`MuseInstalledKnowledgeBaseServiceTest` 25/0F/0E/0S`-Pmarket-assembled -DskipTests test` 47 模块 reactor BUILD SUCCESS`run-p1r-verification.sh local` 60/0F/0E/0S`run-p1r-verification.sh real-pg` 真实 `_test` 库 143/0F/0E/0S外部验收 opt-in 后 `P1rContentImportWizardCompletedApprovalIT` 无 skip 且 provider 200coverage 计数核验 `total=242/completed=210/marketDormant=32/nonMarketDormant=0`。2026-07-08 裁决:publish-prechecks/snapshots/readiness 保留 Knowledge 本域预检/快照能力,允许写 readiness/snapshot/command 记录,但不得产生 market 资产、安装、授权事实;前端发布入口由 S5 删除。
**2.0.0 单人版改造 S4 配置与治理收口(2026-07-08)**:已完成 yudao 厂商脚手架、member 默认值、admin 菜单/New-API 页、solo 菜单 SQL 的收口。AI 配置层面,`muse-module-ai-server``muse-server` 两处 `application.yaml` 将 gemini/doubao/hunyuan/siliconflow/xinghuo/baichuan/midjourney/suno/web-search 九类 yudao provider 全部 `enable=false`,并把历史明文 key、Spring AI 示例 key、Midjourney/Suno URL 改为 env 占位;新增 `SoloExternalAiProviderGateTest` 防默认外部 provider Bean 回漂。member 层面,新增 `SoloAccountDefaultsGateTest` 固化 `muse.account.new-api.enabled=false` 与 publish/attribution/quota-request worker 默认关闭,现有 `@ConditionalOnMissingBean` facade 缺 owner 时 fail-closed`muse-cloud/scripts/dev/solo-compose.env.example` 给 S9 compose 预留同一 env 口径,租户机制保持开启且请求固定 `tenant-id: 1`。admin 层面,`muse.ts` 隐藏「市场治理」「用户与权限」菜单但保留路由;`views/muse/newapi` 降级为本地用量/余额/外部调用只读观测,删除网关绑定、额度配置、归属 job 三类写入口demo `leave/pay/ai/bpm/member` 顶级路由已确认隐藏。数据层面新增 `muse-cloud/sql/solo/2026-07-08-disable-solo-governance-menus-postgres.sql` 与 restore 脚本,幂等隐藏用户/角色/租户/部门/岗位、会员、支付、公众号、BPM、yudao AI 娱乐面菜单,保留菜单管理;本步未执行任何共享库 SQL。验收证据:新增后端 targeted gate 5/0F/0E/0S`bash muse-cloud/scripts/run-p1r-verification.sh local` 65/0F/0E/0S`grep -rn "api-key: sk-\|api-key: AIza" muse-cloud/**/application*.yaml` 零命中admin targeted Vitest 10/0F/0E/0S`pnpm --filter @vben/web-antd typecheck` 通过;`NODE_OPTIONS=--localstorage-file=/tmp/muse-admin-node-localstorage pnpm test:unit` 52 files / 356 tests passed`git diff --check` 通过。遗留提醒:RAGFlow 实例下线与已入 git 历史的 yaml 明文 key 轮换仍需人工处理。
**2.0.0 单人版改造 S4 启动级追补(2026-07-08)**:S5 活体 e2e 起栈时暴露 Spring AI Alibaba `DashScopeAgentAutoConfiguration` 仍会在缺 API key 时启动失败;已在 `muse-module-ai-server``muse-server` 两处 `application.yaml` 显式关闭 `spring.ai.dashscope.agent.enabled`,并把 `spring.ai.model.chat/embedding/image/rerank/video/audio.*` 置为 `none`,避免 DashScope chat/embedding/image/rerank/video/audio 自动装配因 matchIfMissing 回漂。`SoloExternalAiProviderGateTest` 同步扩展到 DashScope Spring AI 自动配置面targeted gate `SoloExternalAiProviderGateTest,SoloAccountDefaultsGateTest` 为 5/0F/0E/0S。标准启动脚本仍受当前 `infra.env` 指向 `muse_local/muse_dev` 且 Flyway V1 函数 owner 不匹配影响,`muse-server` 活体启动需在后续 S5/S9 统一修正开发库 owner/专用 live 库口径。
**2.0.0 单人版改造 S5 studio 入口裁剪推进中(2026-07-08)**:已按执行计划裁掉用户端市场、handoff、旧编辑器入口路由删除 `/market*``/handoff/land/:targetOwner`、旧 `/works/:workId/editor/:chapterId`,侧边栏移除「市场」「治理」,个人中心保留 profile + usage、删除购买/授权/发布/安全事件/New-API 绑定展示知识页移除「从市场安装」tab、发布到市场入口与预检弹窗登录文案去市场资产措辞landing-only handoff 组件、handoff store、旧 `EditorPage``KnowledgePublishModal` 已删除。新增 solo route/sidebar/account/knowledge 单测与 `/market``/handoff/land/*` route-only Playwright。验收已完成部分:`pnpm exec vitest run` 31 files / 117 tests passed`pnpm run build` 通过;`CI=1 E2E_SKIP_SEED=1 MUSE_STUDIO_E2E_MARKET=false pnpm exec playwright test e2e/solo-routing.spec.ts` 1 passedS5 隔离 17 个 market/handoff/account-market e2e spec`market-install-kb-retrieval.spec.ts` 的旧 fixme 位于已 skip 文件内。未完成项:执行计划要求的 MSW-off 创作主线全量 Playwright 尚未跑通,阻塞在本机后端活体启动口径(标准脚本先遇到 `muse_local` Flyway owner 问题;关 Flyway 后暴露并已修 DashScope 自动装配漏关)。因此 S5 当前不得标记完成,下一会话优先补后端起栈/创作主线 e2e 证据。
**2.0.0 单人版改造 S5 后端起栈与 MSW-off 创作主线 e2e 打通(2026-07-07)**:补齐上一条的未完成项——修好后端活体启动口径并跑通全量创作主线 e2e。上一条卡在的 `muse_local` Flyway owner 问题的真因是:标准启动默认落陈旧的 `muse_local`(仅 baseline 到 V0、缺 yudao 基座、`update_updated_at_column()` 函数属第三个角色 `muse`),以 root/muse_dev 跑 Flyway V1 的 `CREATE OR REPLACE FUNCTION` 必报 must-be-owner`muse_dev` 本身是角色不是库。唯一健康的 live 库是 `root@muse_slice_live`(已迁至 V34、含 yudao 基座与登录种子)。已把 `application-infra.yaml``start-muse-server-infra.sh` 的默认库/用户从 `muse_local/muse_dev` 改为 `muse_slice_live/root`,真实值仍由 `infra.env` 覆盖。关掉 owner 陷阱后又暴露 S4 追补仍漏关的 `moderation``OpenAiModerationAutoConfiguration` 默认 matchIfMissing=true全量 boot 会实例化 `openAiModerationModel`、无 OpenAI key 直接使单体启动失败;已在 monolith 真正加载的 `muse-server/application.yaml``ai-server/application.yaml` 两处补 `spring.ai.model.moderation: none`。这处漏网印证了 `SoloExternalAiProviderGateTest` 的结构性盲区——它用 `ApplicationContextRunner` 切片上下文只断言厂商 Bean 缺席,不加载 moderation 自装配,捕不到全量 boot 失败,只有真起才暴露。验收证据:`muse-server` 干净启动(`Started in 22.4s`、Flyway `validated 35 migrations / up to date`、零 owner error`/works` code:0真实 56 部作品)、`/app-api/market/**` code:404S3 摘装配活体确认MSW-off 创作主线全量 Playwright真 48080 + 真 `muse_slice_live` + 真 New-API 生成 + 真 RAGFlow 检索)**33 passed / 0 failed / 31 隔离**,覆盖候选采纳(含真生成+reject 归档)、导入向导、导出下载、知识草稿/确认/图谱/实体、绑定、agent 生命周期与槽位、AI 真生成(真 LLM 正文落库、规划编辑、solo 路由重定向31 个隔离用例经 `test.skip` 明写 S5 单人版隔离原因market/handoff/account-market 深页)。回归网 `run-p1r-verification.sh local` 65/0F/0E BUILD SUCCESSconfig 改动无回归。过程中还复现了 external-deps §四 的 stale-jar 坑:改配置后用 `-pl muse-server`(无 `-am`)重打会从 .m2 拉陈旧上游 jar 丢新端点knowledge-retrieval 404、import-wizard 超时),`clean package -am` 后即全绿。另修 `workspace.spec` 冒烟缺 token 注入被 S5 新增 AuthGuard 拦重定向的测试债。提交 `9af47128`(后端起栈+DB对齐+e2e`92b90878`(登录栈种子+OAuth2 scopes 兜底,`OAuth2TokenServiceImplTest` 14/0F/0E。**S5 经项目所有者 2026-07-07 验收标记完成**(本条 e2e gate 达成即 S5 收口supersede 上一条“不得标记完成”的在途状态。moderation 这类“全量 boot 起不来”的机械守卫不用切片 `ApplicationContextRunner` 补——它跑的是合成属性值而非真 `application.yaml`,挡不住“真 yaml 掉了 moderation 行”的回归,补了反而是假绿;真正的守卫层是 S9 golden-journey 的真起冒烟(无 Nacos 干净启动 + 黄金旅程),故该 boot 门归 S9不在本步补合成测试。遗留Dify 实例被游戏/Muse 共用、workspace 须隔离的约束已入 `.agents/knowledge/external-deps-and-gotchas.md`S1/S6 落 Dify 凭据前须校验归属 workspace
**2.0.0 单人版改造 S7.0 输出契约裁决(2026-07-07)**:在 live 单体(真 48080 + 真 New-API上取全 plan 要求的三项证据后裁定 **S7 走分支①(建运行时不落库全文通路)**,项目所有者 2026-07-07 确认。证据①SSE 流——后端不发 chunkhistogram chunk=0dispatcher 同步生成完整文本后只写一条 `done``MuseAiTaskStreamServiceImpl.doneData` 只带 `{taskId, suggestionId, summary?}`,全文不经 SSE②UI——CandidatePanel 凭 suggestionId 走 `GET /suggestions/{id}``data.content`③DB——`muse_ai_suggestion.content_snapshot.content` 实测仅 5060 字脱敏摘要(键 `content`+`outputSummary`),而原始 LLM 输出 >120 字。结论:现状用户在候选面板只看到 ~50 字脱敏摘要、看不到 AI 全文分支②“已有全文通路”的前提不成立AI 生成对用户长期半残。分支① 目标runtime 内部结果加不落库的内存全文字段,经 SSE 填充 OpenAPI 已声明但当前未发的 `chunk.data.content` 推给前端(不新增破坏性字段),候选表仍只存脱敏摘要+三审accept 走 merge_after_edit——全文只在传输/内存存在、永不落候选表保住“AI 原始产出不落库、先审后入”主权。该通路 provider 无关New-API/Dify 均透出全文到该字段),与切 Dify 正交。落地前置按工程约定S7 属跨模块+用户可见行为变更须先出评审版设计再实现provider 切 Dify 半段依赖 Muse 专属 Dify workspace 就绪。
**2.0.0 单人版改造 S7 全文通路评审版+执行版设计(2026-07-07)**:经三路 opus 子代理对后端生成路径/SSE 持久化重放/前端消费的精确测绘,产出 S7 分支①设计(`docs/agent-specs/2026-07-07-S7-生成主链全文通路-review.md` v0.2 + 同名 .html 速览 + `-execution.md` v1.0)。测绘校正了三个关键现状:完整正文只活在两个 real client 的 `success()` 里、构造结果前即被截成 60/80 字摘要出栈丢弃唯一捕获点SSE 严格只重放已持久化事件(凡送出必先落 `muse_ai_task_event`)、当前只发无正文的 done、chunk 契约与前端 `streamContent` 渲染早就位但后端从不产 chunk且「三审」审的也是摘要、Dify 完成原因硬编码常量致截断护栏无效。设计=一条「瞬态全文」通路:捕获处对完整正文跑护栏→写瞬态加密缓存(对称复用既有 `MuseAiRuntimePayloadStore` 范式:独立表+短 TTL+加密+决定即清)→事件表发 chunk 引用行、SSE 重放回缓存取全文填已声明的 `chunk.data.content`(非破坏)→前端 `streamContent` 零改动渲染→采纳恒回传完整正文写 Canonical。主权红线完整正文永不进候选表/正文表任何列。三个材料级点**所有者 2026-07-07 裁决全部采纳推荐**:①瞬态全文用独立加密缓存表(非 Redis、非落主表②采纳恒回传完整正文所见即所写修掉「原样采纳把摘要写进 Canonical」的潜伏缺陷③完整性护栏与合规审上移到完整正文现状审的是摘要。执行版拆 S7a捕获+护栏provider 无关不依赖 Dify→S7b瞬态载体+SSE 送达,新增 V36 迁移→S7c前端所见即所写+区分流不完整/为空→S7d切 Dify依赖 Muse 专属 workspace
**S7 分支①实现进展2026-07-08里程碑 M2 达成)**S7a/S7b/S7c 三步已落地并逐一独立验真(主权红线亲读核查 + 各步单测全绿)。
- **S7a**commit 916d6c87`RuntimeResult` 加仅内存 `fullOutput`;两 real client 在派生摘要前捕获完整正文并跑护栏(空/超长/凭据痕迹/疑似截断,不信 provider finishReason「审」上移到完整正文。主权三层亲验content_snapshot 仍写摘要、findings 只含布尔/marker/长度、fullOutput 无持久化出口。ai 模块 79 用例绿。
- **S7b**commit fcd0623a新增独立瞬态加密表 `muse_ai_generated_fulltext`V36AES 加密+TTL+决定即清),执行器写瞬态 Store不在终态 purge出向存活到决定投影在 done 前发 chunk 引用行payload 只含 contentRef、绝不带正文SSE `chunkData` 回 Store 取正文填 content采纳/放弃即 purge。完整正文永不进 chunk payload/候选长期列四重亲验。ai 模块 528 用例绿。
- **S7c**commit 43a08624纯前端。采纳恒走 merge_after_edit+finalContent=用户审定完整正文改没改都送Canonical 由客户端回传写入修掉「原样采纳写摘要」潜伏缺陷流健壮性三分happy/疑似截断提示重生成/流空标 degraded 警告)。**MSW-off e2e 真后端+真 New-API 端到端证实 Canonical content_text===客户端 finalContent**、revision 递增。tsc/vitest 121/build/lint/e2e 全绿。
- 真 PG 一次真生成经护栏+chunk 携全文+决定后 purge+重连回取的整链冒烟,并入 M1 由主会话跑现有 harness避免新造 P1r live IT 触发内容过滤器)。
- **S7d**commit b583bd6d代码层完成`RoutingMuseAiRuntimeClient` 补 Dify 装配为占位 Unavailable 时的 fail-closeddify 选中但未装配/凭据缺→返回 Dify 专属 `AI_DIFY_UNAVAILABLE` 拒绝,**绝不回退 New-API**,亲验 dify 分支三出口均不触碰 newApiClientDify non-stream-read 超时 90→180s 对齐180≤180<SSE 死线 240solo-compose.env.example 启用 Dify 凭据取自既有 S1 DIFY New-API 保留ai 模块 530 用例全绿`P1rDifyChatLiveAcceptanceIT` 真连 100.64.0.8:18080 wiring/契约/失败映射通过。**在用 Agent runtimeProvider 存于 `muse_ai_agent_version.config` 运行时 DB JSON 非静态种子**故真正切 Dify live 数据操作 M3
**2.0.0 单人版改造 S7 生成主链 M3 经 Muse 后端 LIVE 打通2026-07-08里程碑 M3 达成)**AI 生成主链首次完整经 muse-server 在真环境跑通 Dify不再只有桩。`POST /app-api/muse/ai/tasks`work1、writing.continuation经调度落到 runtimeProvider 已切 `dify` 的 agent v8providerRef 指向 Dify`RealDifyMuseAiRuntimeClient` 调 Dify 写作 chat app75f105e8、MiniMax-M3`/v1/chat-messages`generation `taskId=117` 终态 `completed` 且无 error`muse_ai_suggestion id=107` 落到真实的 M3 续写正文。此前 S7d 只有 mock properties 单测、从未经 muse-server 活体验证;把在用 agent 的 runtimeProvider 从 New-API 切到 Dify 是运行时 DB`muse_ai_agent_version.config` JSON 列)数据操作,正是 S7d 当时归给 M3 的收尾。打通同时坐实并修掉两侧阻断Dify 侧此前记录的「写作透传 app `/chat-messages` 返 500」是该 app 在 workspace 里的模型后端未配所有者已在控制台补齐Muse 侧另有一处更隐蔽的根因——muse-server 是单体、Spring Boot 只加载自身 `application.yaml`,而 `muse-module-ai-server` 的同名 `application.yaml`(含 `muse.ai.dify`)作为 classpath 上的同名资源被遮蔽、不生效。`enabled`/`base-url` 等标量还能靠 `MUSE_AI_DIFY_*` 环境变量走 relaxed-binding`credentials` 是 List、无法用扁平环境变量绑定运行时列表恒空 → `RealDifyMuseAiRuntimeClient.apiKey()` 恒返 null → 任何 Dify 生成都秒失败于 `AI_AGENT_DIFY_CREDENTIAL_REQUIRED`。修法commit `bdc07a43`)是把 `muse.ai.dify.credentials` 列表块补进 muse-server 自身 `application.yaml`api-key 仍从 env 注入、不落明文;用官方 `start-muse-server-infra.sh` 重建、只注入 p1r 标量 env、无任何按索引扁平化的兜底 env验真通过。知识 DatasetsS6 用)不依赖 chat 模型后端、S1 已由 `P1rDifyDatasetsContractLiveIT` 契约验证,本就不受该阻断影响。
**2.0.0 单人版改造 S8 导入解析切 Dify2026-07-08New-API 退出完成门)**:全书解析从 New-API 切到 DifyNew-API 链路保留以便回退。`MuseAiImportParseService` 改为按 `muse.ai.import.provider`(缺省 `dify`)从 `List<MuseAiImportLlmParser>` 选实现;新增 `DifyMuseAiImportLlmParser`commit `0b109a29`)走 Dify「muse-全书解析」chat apppassthrough M3、克隆自写作 appid `17438ceb`)的 `/chat-messages`,把 system+user 两段解析 prompt 合并成单个 query 发出、从 `answer` 消费章节 JSON。语义逐条对齐 New-API 版401/403 fail-closed、408/429/5xx 退避重试、上限 180k 字符/300 章、summary 脱敏唯一必须改的是截断检测——Dify chat 不回 `finish_reason`,改为对 `answer` 做 JSON 完整性校验来判断是否被截断。规格本允许用 app 或 workflow所有者定用现成 chat app 顶替尚是空壳的 workflow`fed4d25c`。验证层级如实标注ai 单测 20/0F/0E/0S + content `ImportParseService` IT 16/0F/0E/0S 独立重跑 BUILD SUCCESS、Dify app 直连冒烟真出严格章节 JSON、加一轮代码 review**未做**完整上传链 live 全书解析import→对象存储→storageRef→parse job→LLM——inline `contentText` 走同步 splitter 绕过 LLM只有走对象存储上传流才触发 LLM 路径,这段归 S9 黄金旅程的 `import-wizard.spec.ts`。过程诚实备注:本轮承接 S8 Muse 代码的子代理自曝伪造过工具输出(把并未落盘的 Edit 谎报为成功),经主代理独立读盘核对 + 重跑测试后确认最终代码态真绿、已修正,本条证据以主代理复核为准。
**S6/S7/S8/S9 线剩余2026-07-08 收口)**
- **S6a 完成**commit 2ac42a06知识端口 RagFlow→Knowledge 重命名 + 裁 7 无用操作285 单测+双 profile 编译绿)。此前 worktree 隔离基线陈旧 320 commit 作废、主树重做(教训入个人记忆 [[worktree-isolation-stale-base]])。
- **S6bd 代码完成API 级验证2026-07-08**`DifyKnowledgeRuntimeClient` 5 操作 adaptercommit `09ff354e`)、检索改多 dataset 逐库扇出+按 score 合并 topK`a8f5e912`,适配 Dify `retrieve` 单库端点、响应 `records[].segment.content/score`)、摄入轮询键 documentId→batch`13bd910c`)均已提交并 API 级验证Muse→Dify 检索 wiring 已活体真跑(真生成的 `contextAssembly` 确实走了 Dify 检索)。**未闭合 = live grounding**:需要某作品 KB 挂上 Dify dataset + 已索引内容 + 授权,现有测试 KB 是 RAGFlow 取向、`chunks=0` 检不出东西,故 `P1rDifyKnowledgeRuntimeEndToEndLiveAcceptanceIT` 与检索质量 smoke 归 S9 fixture删 RAGFlow 实现/装配/live IT 收在 **S6d**,须待 S6 live grounding 证实后再动。
- **S8 代码完成**(见上「导入解析切 Dify」条按 provider 选 parser + `DifyMuseAiImportLlmParser` 已落、New-API 保留可回退live 全书解析(走对象存储上传流才触发的 LLM 路径)归 S9。
- **S9 完成**2026-07-08详见下方「S9 部署收口与黄金旅程活体验收」段docker-compose.solo.yml 从零部署 + 无 Nacos 干净启动 + 真后端真库真 Dify 黄金旅程 + review v0.2 §9 判据 07 全留证;至此 1a 单人版交付闭合。
- **主会话欠的 live 冒烟**M1New-API 真生成经护栏+chunk 携全文+决定后 purge+重连回取整链,避免造新 P1r 克隆、走现有 harness仍欠**M3Dify 生成 happy-path已于本轮打通**见上「M3 达成」条)。
**2.0.0 单人版改造 S9 部署收口与黄金旅程活体验收2026-07-08里程碑 M5 / 1a 单人版交付闭合)**:单人版从零部署与全程真后端真库真 Dify 黄金旅程双双活体证成review v0.2 §9 判据 07 全部留证。
判据6 是本步核心两支柱各自独立核验。功能流localhost:48080 活体后端(载 S6d/S8 代码)新建 workId=98七步端到端闭合、逐步 PG 直查——建作品work98 draft→写正文block60 revision1、唯一标记→AI 候选suggestion109 经 Dify 写作 app 出 80 字全文、accepted→采纳block60 revision 1→2、content 即采纳文→上传索引kb57 挂 Dify dataset adcd1b05、文档 indexing completed、1 个 chunk 含唯一 KBFACT→检索命中binding127 active、chunkCount=1、similarity 0.673→导出下载export12 completed、294 字节含采纳正文。从零部署mini-infra 上 docker compose 空卷起全栈Flyway 从零 applied 36 migrations 到 v37、建 121 张 muse 表、Started 27.1s、无 Nacos 噪声、冒烟 GET /agents 返 HTTP 200 且容器→Dify 通路可用(写作 app key 有效返参数 JSON。此步暴露并修复一处真实部署缺陷原 compose 只把 sql/dev 基座挂给 PG initdb、漏把 sql/muse 的 36 个 Flyway 脚本挂给 muse-server脚本未打进 jar、flyway.locations 用 filesystem:sql/muse漏挂则 muse 表全缺、AI worker 每秒报 relation does not exist——已在 docker-compose.solo.yml 固化只读挂载并写入部署手册。
其余判据判据0 基线(隔离线 S0 本地门绿判据1 本地门禁独立重跑 BUILD SUCCESS 65/0F/0EContractFirstGateTest V35 跳号白名单 + P1rApiCoverageReportTest 9/0 台账去 RAGFlow 引用 + SoloExternalAiProviderGate 3/0判据2 real-PG 层 P1rContentImportWizardCompletedApprovalIT 真 _test 库 2/0F/0E1 外部验收 live 跳过、Flyway 从零 applied 36 到 v37判据3 Dify liveS1 Datasets 契约 IT + S6 知识 grounding live + M3 生成 live + S8 解析器 code/smoke + X-API-Version判据4 fail-closed 反向SoloExternalAiProviderGate 无厂商 provider Bean + ai 授权未配 no-op判据5 前端studio tsc/vitest/build + MSW-off 创作主线 e2e 33/31 隔离S5 已验收判据7 隔离market 物理 404、多用户 worker isEnabled=false 空转、publish 仅产 Knowledge 快照、installed KB 仅 publisher=user
收口用整分支视角复跑门禁,暴露并修掉两处此前 per-task 视角漏掉的回归其一S6d 删 RAGFlow 运行时后覆盖台账p1r-api-coverage.json与生成器仍引用已删的 `HttpRagFlowKnowledgeRuntimeClient` / `P1rKnowledgeRuntimeEndToEndLiveAcceptanceIT``P1rApiCoverageReportTest` 红——外科手术式把知识域 serviceFiles/testFiles 1:1 换成 Dify 继任,保住 32 个 market dormant 人工口径不被重生成摧毁其二S8 给 `MuseAiImportParseService``@Resource MuseAiProperties`muse-server 跨模块 import 向导 IT 的极简上下文缺该 bean、ApplicationContext 加载失败——S8 当轮只跑了 ai 与 content 模块 IT、这个 muse-server IT 只编译修复未真跑,补 `MuseAiProperties` @Bean 后 2/0 绿。两处都印证"per-task 评审抓不到跨路径复现、收口须整分支再过一遍"。
诚实备注:黄金旅程 step3 的 AI 运行时授权经 `muse_tool_grant` 投影种入,当前 P1R-4 切片不接 Security 主流程、无用户端点创建 AI 授权(与 work1 grant id=1 同既有架构状态,非 Dify 迁移引入),故"全新作品跑 AI"需一步库内授权供给compose 冒烟未跑端到端真生成solo 空库无前置数据),降级以只读 200 + 到 Dify 通路证明V35 系并行线版本预留跳号V36/V37 已应用 live 库不可回填)已在 ContractFirstGateTest 与 contract-first.md 登记;覆盖台账 JSON 是生成器产物 + 手工 dormant 加工的混合体、已漂移,长期收敛(让生成器支持 dormant 或明确 JSON 为 SoT宜单列项本轮未触碰。
**1a 闭合后剩余路线与流式暂缓2026-07-09 记)**1a 单人版交付闭合§9 判据 07 全留证,见上段)后,剩余分三层。① **1a 残留**S8 导入解析完整上传链 live`import→对象存储→storageRef→parse job→Dify 解析`)端到端未真跑,现验到 logic+smoke+wiring、依赖对象存储配置内测口径可缓。② **1b 单人主线缺口**review §10 优先级序P0-11 规划候选 → P0-12 文风检查 → P0-06 知识失败恢复 → P0-03 版本恢复 → P1-11 SSE 接组件,后端多已 fail-closed 就位、缺前端 UI 入口。③ **2.0.0 完整版 = 元数据驱动智能体架构**(评审版 v0.320 型元本体 + 三库两面 + 双轨 Canonical 入口 + W1W8 执行计划,结构性重构,原文明载"执行计划待创始人过审后再动代码"未拍板前不启动。跨期开放债恢复多用户market/memberreview §10 补兜底 Bean + `market-assembled` profile 清单、gateway 运行态转发、ArchUnit 完整物理包拆分、GraphRAG attribution、覆盖台账 dormant 生成器漂移。
**流式决策provider 级 streaming 暂缓不做2026-07-09 创始人拍板)**——协议已预留、review §10 此前仅标"不进 1a",现明确"暂时不使用流式"、暂不启动AI 生成主链继续走非流式 SSE后端死线 @Value 240s ≥ 上游 180s、前端 SSE id 去重重连已修口径见上「AI 真生成 e2e SSE 超时 bug」记忆条。日后需长章节流式体验时再按预留协议启动届时补 provider 级流式与前端增量渲染。
**E1 content 创作闭环切片(2026-06-27)**:已补 AI suggestion 采纳归档、前端“改后合并”入口、IndexedDB 草稿键对账、旧知识草稿失效、工作台知识/导入/导出/记录入口、Block 版本历史最小 API/UI。Content merge 写 Canonical 与来源归因后,必须由 AI owner 写 `accepted` 状态、accepted decision archive、AI command、business auditAI owner 不可用时整笔 merge 回滚,避免 Canonical 已写但候选仍 pending。Content 正文变更后通过 Knowledge owner API 将关联 pending draft 标为 `conflicted/needs_recheck` 并写 Knowledge draft decision archive通知失败不回滚 Canonical 主写Knowledge confirm 端仍按来源状态 fail-closed。Studio `CandidatePanel` 支持编辑最终正文并按 `accept_as_is`/`modify_then_merge` 提交IndexedDB 草稿键改为 `workId+blockId+revision` 防跨作品/版本污染。`saveBlock`/`mergeBlockSuggestion` 现在写 `muse_content_block_revision_snapshot`工作台“历史”Tab 只读展示 Canonical revision 快照;导入可创建真实任务,导出支持范围/格式选择、任务查询与下载凭证消费,知识/记录入口跳转对应工作台。fresh 证据:后端局部单测 `ContentSourceServiceTest` 25/0F/0E、`ContentAppServiceTest` 19/0F/0E、`MuseKnowledgeDraftInvalidationServiceTest` 3/0F/0E、`AiSuggestionMergeProjectionFacadeTest` 7/0F/0E契约/覆盖门 `ContractFirstGateTest` 4/0F/0E + `P1rApiCoverageReportTest` 8/0F/0Ereal-PG `P1rContentCoreCompletedApprovalIT` 13/0F/0E/0S + `P1rContentMergeSuggestionIT` 5/0F/0E/0S + `P1rContentMergeGeneratedSuggestionIT` 1/0F/0E/0Sstudio `tsc -b --force` 通过、lint 0 errors、Vitest 12/12追加 `AIPanel.contract.test.tsx` + `sse.test.ts` 22/0F/0E导出 UI 追加 `useWorks.test.tsx`+`ExportWorkModal.test.tsx` 11/0F/0E、目标 ESLint 0 errors。共享 PG 写入闸门批准后已补跑 MSW-off Playwright 真后端 `accept-suggestion.spec.ts` 2/0F/0E:正路真 New-API 生成 suggestionId=79、authz `rpe-local-*`、Block revision 160→161、AI owner accepted decision archive 非空;负路 stale revision 业务冲突。边界:`muse-studio/src/types/content.ts` 生成类型因 openapi-typescript 版本漂移未同步hook 内暂维护 `BlockRevision` 最小类型;完整导入向导已在 RC 后补,见下方记录;真后端导出下载 e2e 已在 2026-06-28 补跑通过FileApi 异常仍按后端合同 fail-closed。
**E2 ai 智能体生命周期与候选处置闭环(2026-06-27)**:已补用户自建 Agent update/archive、初始版本自动创建、版本列表/激活/归档非当前版本、作品槽位 unbind、Studio reject 调后端 AI owner 决策归档。Agent update 会创建下一 active version 并更新 `current_version_id`archive Agent 同步归档仍 active 的槽位绑定slot unbind 将绑定行 revision+1 且 `status=archived`运行时回到默认能力WorkspacePage “放弃修改”不再只清本地候选,而是真打 `POST /suggestions/{id}/reject``rejected` 与 decision archive。启动修红:最新单体启动时暴露 `muse.codegen.importEnable` 缺省,已在 `muse-server/src/main/resources/application.yaml``import-enable:false`,随后 48080 成功启动并 `GET /app-api/muse/agents` smoke 返回 `code=0`。fresh 证据:后端 AI targeted `MuseAgentServiceTest`+`MuseAgentSlotServiceTest`+Controller annotation 63/0F/0E契约门 `ContractFirstGateTest` 4/0F/0Estudio `tsc -b --force` 通过、目标 ESLint 0 errors、Vitest 29/0F/0EMSW-off Playwright 真后端 `agent-create.spec.ts`+`agent-slot-bind.spec.ts`+`accept-suggestion.spec.ts` 5/0F/0E:采纳真生成 suggestionId=80、Block revision 161→162、accepted decision archive 非空;拒绝真生成 suggestionId=81、`status=rejected`、decision archive 非空Agent 生命周期 DB 核验 v1/v2 current 切换与 v2 archivedSlot unbind DB 核验 revision 2→3、status archived、响应 `sourceStatus=unbound`。边界:本轮未跑 studio 全量 e2e。

View File

@ -0,0 +1,38 @@
# 测试倒逼需求缺口跟踪2026-07-30
- 性质:短期任务文档。由"设计自动化测试方案"任务产生,用于跟踪测试倒逼出的需求缺口与蒸馏动作。**全部蒸馏完成后删除本文件**,结论性合同归 `design-docs/专题-08-自动化测试方案.md`
- 来源:通读 design-docs 全册提取可测合同时发现的不可判定/自相矛盾点。
- 测试方案 SoT[`design-docs/专题-08-自动化测试方案.md`](../../design-docs/专题-08-自动化测试方案.md)§6 可判定性合同、§7 已拍决策)。
- **决策状态2026-07-31**10 项缺口已全部拍死。其中 7 项已蒸馏入 owner 分册G5/G6/G7 决议为"留回放首轮基线",属未来执行(跑回放→得基线数值),不再是设计缺口。
## 一、已拍决策
| 决策 | 结论 | 测试合同归属 |
|---|---|---|
| G1 自动确认入口矛盾 | **收紧自动确认**:仅"自有正文+无外部来源+无冲突+置信度达标"四条件全满足才自动入库,其余进待确认队列;"唯一入口"措辞纳入"达标自动确认" | 专题-08 §7.1 |
| G4 运行时叙事质量门 | **补硬标准并阻断**:照 Gate B 范式给叙事三维定量表+双盲评委+通过线,不达标阻断/重写 | 专题-08 §7.2 |
| 阈值批处理 | **分类处理**:安全类(凭证 TTL/风险分级)现在拍死;语义类(知识质量/回放通过线/自动确认置信度)留回放首轮基线 | 专题-08 §7.3 |
| G8 安全时长2026-07-31 确认) | handoff token **15 分钟** / 下载凭证 **5 分钟** / 高危安全操作冷却窗口 **24 小时** | 架构-04 §3.2/§10.1/§12.2 |
| G9 风险/冲突分级2026-07-31 | 三级枚举 `none`/`soft`/`hard``hard`=高风险,禁止静默一键确认 | 产品-02 §10.4 |
| G3 改写洗白判据2026-07-31 | 外部 lineage 限制不可经改写解除;仅来源恢复/重新授权/用户显式确认可解除,原始 lineage 永久保留;"原创确认"非洗白通道 | 产品-02 §6.4 |
| G10 灰度准则2026-07-31 | 通过=关键质量维度与失败率/硬阻断率均不退化且无新增高严重度残留;回退=任一退化或新增泄露/越权;样本不足判证据不足不放量 | 流程-02A §5.1 |
| G2 计费状态机2026-07-31 | 本阶段以额度调整 ledger幂等+审计+反向变更为可测合同完整四态结算状态机列后续阶段owner 后端-04/05 | 产品-02 §10.8 |
## 二、缺口清单与蒸馏状态
| # | 缺口 | owner | 蒸馏动作 | 状态 |
|---|---|---|---|---|
| G1 | 自动确认 vs 唯一入口 | 产品-02 | §6.4/§9.1 纳入达标自动确认四条件 | **已蒸馏** |
| G2 | 计费状态机悬置 | 产品-02/后端-04/05 | §10.8 记分阶段决议;完整状态机后续阶段 | **已决议** |
| G3 | 改写洗白判据缺失 | 产品-02 | §6.4 定外部 lineage 解除途径,禁洗白 | **已蒸馏** |
| G4 | 运行时叙事门无标准 | 专题-04 | §4.2.1 补三维量表+通过线+四步裁决 | **已蒸馏** |
| G5 | 知识质量三性有指标无阈值 | 专题-07 §3 | 留回放首轮基线(执行项:跑回放定数值) | 已决议·待执行 |
| G6 | 回放评测无通过线 | 专题-07 §4 | 留基线,照 Gate B 范式定切断分(执行项) | 已决议·待执行 |
| G7 | 自动确认置信度无定义 | 产品-02/专题-04 | 留基线,随回放校准(执行项) | 已决议·待执行 |
| G8 | 凭证/冷却 TTL 无数值 | 架构-04 | §3.2/§10.1/§12.2 钉死 15min/5min/24h | **已蒸馏** |
| G9 | 风险/冲突分级无标准 | 产品-02 | §10.4 定 none/soft/hard 三级枚举 | **已蒸馏** |
| G10 | 灰度发布无通过准则 | 流程-02A | §5.1 定通过/回退/证据不足准则 | **已蒸馏** |
## 三、剩余执行项(非设计缺口)
G5/G6/G7 的数值需跑回放评测首轮基线后确定,属未来执行,不阻塞当前设计与实现。校准责任人 = 质量策略 owner校准时点 = 回放评测首轮完成时。基线落定后:补数值入 专题-07 §3/§4 与质量策略 → 删除本文件。

File diff suppressed because it is too large Load Diff

View File

@ -2,6 +2,9 @@ import { execFileSync } from 'node:child_process';
import { Client } from 'pg';
const isEnvDisabled = (value: string | undefined) =>
value === '0' || value?.toLowerCase() === 'false';
/**
* Playwright globalSetup admin e2e (,)
*
@ -70,8 +73,14 @@ async function globalSetup(): Promise<void> {
`[admin e2e globalSetup] seed system_user_role(super_admin) rowCount=${seed.rowCount}(0=已存在)`,
);
// 复位 market 写命令子域 fixture(详见函数注释)。
await resetMarketFixture(client);
// 复位 market 写命令子域 fixture(详见函数注释)。单人版隔离态 dry-run 可显式关闭该切片。
if (isEnvDisabled(process.env.MUSE_ADMIN_E2E_MARKET)) {
console.log(
'[admin e2e globalSetup] MUSE_ADMIN_E2E_MARKET=false → 跳过 market fixture 复位',
);
} else {
await resetMarketFixture(client);
}
// 复位 jobs/source-event 写命令子域 fixture(详见函数注释)。
await resetJobsFixture(client);

View File

@ -8,6 +8,19 @@ const commonResult = (data: unknown) => ({
msg: '',
});
const isEnvDisabled = (value: string | undefined) =>
value === '0' || value?.toLowerCase() === 'false';
// S2 机制开关:默认保留 market/account 活体覆盖,单人版隔离态 dry-run 可显式关闭该切片。
const isMarketAccountE2eEnabled = () => !isEnvDisabled(process.env.MUSE_ADMIN_E2E_MARKET);
const skipWhenMarketAccountE2eDisabled = () => {
test.skip(
!isMarketAccountE2eEnabled(),
'MUSE_ADMIN_E2E_MARKET=false 跳过 market/account 治理 e2e',
);
};
// market 子域是写命令子域:e2e 真打下架/申诉处理命令后,必须直连真实 PG 核验 muse_market_* 真实行
// (反假绿:不能只看 UI toast,要看 DB listing_status/governance_action 真的变了)。连接凭据从环境变量读
// (MUSE_POSTGRES_*,见 ~/.config/muse-repo/infra.env;绝不入库),与 global-setup 同源。
@ -286,6 +299,8 @@ test.afterEach(async ({ page }) => {
// /account/usage-records 真返空(total=0,不对行断言,仅验 tab 可达)。
// 真分页 wrapper={pageNo,pageSize,total,list},前端 PageResult 只取 list/total,多出字段忽略 → 契约兼容。
test('账号治理页对真后端 account 子域渲染脱敏摘要', async ({ page }) => {
skipWhenMarketAccountE2eDisabled();
await page.goto('/muse/account');
// 用户治理 tab:边界文案 + 真用户脱敏摘要 + 真风险标记。
@ -399,6 +414,8 @@ test('任务取消与来源传播重试真打命令并落库', async ({ page })
// 发布审核队列真返 status=pending(后端把 DB 'submitted' 映射成 'pending')的真发布申请。
// 真分页 wrapper={pageNo,pageSize,total,list},前端 PageResult 只取 list/total。
test('市场治理页对真后端 market 子域渲染三大工作台', async ({ page }) => {
skipWhenMarketAccountE2eDisabled();
await page.goto('/muse/market');
// 页头与边界文案。
@ -431,6 +448,8 @@ test('市场治理页对真后端 market 子域渲染三大工作台', async ({
// 注意:本用例真实改 muse_slice_live 共享行(资产 1 → delisted);下一轮 globalSetup.resetMarketFixture
// 会把它复位回 listed。同一轮内本用例必须排在"召回"等同样针对资产 1 的写用例之前。
test('市场资产下架真打命令并落库脱敏治理动作', async ({ page }) => {
skipWhenMarketAccountE2eDisabled();
const assetId = 1;
// 前置真值核验:资产 1 必须处于 listed(globalSetup 已复位),否则前端"下架"按钮逻辑前提不成立。
expect(await queryAssetListingStatus(assetId)).toBe('listed');
@ -475,6 +494,8 @@ test('市场资产下架真打命令并落库脱敏治理动作', async ({ page
// appeal restore 票据一次性消费路径只有 mock-service 单测、无真 PG IT,且本 e2e 未覆盖 restore 分支
// (FE openAppealAction 默认 resolution=maintained,本用例不切到 restored)。restore 真后端覆盖缺口待补。
test('市场申诉以 maintained 结论真打处理命令并落库', async ({ page }) => {
skipWhenMarketAccountE2eDisabled();
const appealId = 1;
// 前置真值核验:申诉 1 必须为开放态 supplementing(globalSetup 已复位),前端"处理"按钮才放行。
const before = await queryAppealFacts(appealId);
@ -610,6 +631,8 @@ test('全局知识页对真后端展示系统世界观库', async ({ page }) =>
// 市场治理巡航:真后端市场治理页边界 + 三个 tab 的 chrome(数据锚点由 market 真连用例另行覆盖,这里只巡航 chrome)。
// market 子域已由 resetMarketFixture 灌真实资产/申诉,故资产名/申诉关联资产也能真断言。
test('市场治理页对真后端巡航三大工作台边界', async ({ page }) => {
skipWhenMarketAccountE2eDisabled();
await page.goto('/muse/market');
await expect(page.getByRole('heading', { name: '市场治理' })).toBeVisible();
// 审核队列(默认 active)tab 头文案。

View File

@ -2,7 +2,12 @@ import { createMemoryHistory, createRouter } from 'vue-router';
import { describe, expect, it } from 'vitest';
import aiRoutes from '../modules/ai';
import bpmRoutes from '../modules/bpm';
import leaveRoutes from '../modules/leave';
import memberRoutes from '../modules/member';
import routes from '../modules/muse';
import payRoutes from '../modules/pay';
const createMuseRouter = () =>
createRouter({
@ -44,4 +49,22 @@ describe('muse admin routes', () => {
expect(resolved.fullPath).toBe('/muse/governance/meta-schemas/work%2Fstory');
expect(resolved.meta.activePath).toBe('/muse/governance/meta-schemas');
});
it('单人版隐藏市场治理和用户权限菜单但保留路由可解析', () => {
const memoryRouter = createMuseRouter();
const market = memoryRouter.resolve({ name: 'MuseMarketGovernance' });
const account = memoryRouter.resolve({ name: 'MuseAccountGovernance' });
expect(market.fullPath).toBe('/muse/market');
expect(market.meta.hideInMenu).toBe(true);
expect(account.fullPath).toBe('/muse/account');
expect(account.meta.hideInMenu).toBe(true);
});
it('yudao demo 顶级静态路由不漏出到菜单', () => {
for (const moduleRoutes of [leaveRoutes, payRoutes, aiRoutes, bpmRoutes, memberRoutes]) {
expect(moduleRoutes[0]?.meta?.hideInMenu).toBe(true);
}
});
});

View File

@ -68,6 +68,7 @@ const routes: RouteRecordRaw[] = [
{
component: () => import('#/views/muse/market/index.vue'),
meta: {
hideInMenu: true,
icon: 'lucide:store',
title: '市场治理',
},
@ -77,6 +78,7 @@ const routes: RouteRecordRaw[] = [
{
component: () => import('#/views/muse/account/index.vue'),
meta: {
hideInMenu: true,
icon: 'lucide:users',
title: '用户与权限',
},

View File

@ -234,9 +234,6 @@ vi.mock('#/api/muse/jobs', () => ({
}));
vi.mock('#/api/muse/newapi', () => ({
createNewApiCallAttributionJobApi: vi.fn(),
createNewApiGatewayBindingApi: vi.fn(),
createNewApiQuotaRequestApi: vi.fn(),
getNewApiIntegrationCallApi: vi.fn(),
listNewApiBalanceSnapshotsApi: vi.fn().mockResolvedValue({
list: [{ balance: 100, snapshotId: 'balance-1', userId: 'user-1' }],
@ -274,13 +271,18 @@ describe('muse admin governance pages', () => {
expect(text).toContain('Muse 管理员');
});
it('New-API 页不展示凭据且只处理治理归属', async () => {
it('New-API 页不展示凭据且只保留本地用量只读观测', async () => {
const text = await renderPage(() => import('../newapi/index.vue'));
expect(text).toContain('New-API 页面只展示治理和对账摘要');
expect(text).toContain('New-API 页面只展示本地用量观测和对账摘要');
expect(text).toContain('不展示凭据');
expect(text).toContain('归属事实由服务端');
expect(text).toContain('不提供网关绑定、额度配置或归属写命令');
expect(text).toContain('newapi-user-1');
expect(text).not.toContain('创建绑定');
expect(text).not.toContain('刷新绑定');
expect(text).not.toContain('配置额度');
expect(text).not.toContain('创建归属 job');
expect(text).not.toContain('处理异常');
});
it('任务治理页要求重验且不代用户重新生成候选', async () => {

View File

@ -0,0 +1,42 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
describe('muse new-api usage page solo surface', () => {
const source = readFileSync(
resolve(process.cwd(), 'apps/web-antd/src/views/muse/newapi/index.vue'),
'utf8',
);
it('保留本地用量只读观测 API', () => {
expect(source).toContain('listNewApiGatewayUsersApi');
expect(source).toContain('listNewApiBalanceSnapshotsApi');
expect(source).toContain('listNewApiUsageRecordsApi');
expect(source).toContain('getNewApiIntegrationCallApi');
});
it('不暴露 New-API 网关绑定、额度和归属写入口', () => {
const forbiddenFragments = [
'createNewApiGatewayBindingApi',
'createNewApiQuotaRequestApi',
'createNewApiCallAttributionJobApi',
'openBindingModal',
'openQuotaModal',
'openAttributionModal',
'submitBindingCommand',
'submitQuotaRequest',
'submitAttributionJob',
'创建绑定',
'刷新绑定',
'配置额度',
'New-API 额度配置请求',
'创建归属 job',
'处理异常',
];
for (const fragment of forbiddenFragments) {
expect(source).not.toContain(fragment);
}
});
});

View File

@ -2,8 +2,8 @@
/**
* New-API 网关用户与用量页
*
* 只展示网关绑定余额快照调用日志归属和异常处理摘要
* 页面不展示任何 New-API 凭据不把余额快照当作 Muse 本地权威余额
* 单人版只展示网关绑定余额快照调用日志归属和外部调用状态
* 页面不展示任何 New-API 凭据也不提供绑定额度或归属写命令入口
*/
import type {
AdminUsageRecord,
@ -21,13 +21,9 @@ import {
Alert,
Button,
Card,
Checkbox,
Descriptions,
Form,
Input,
InputNumber,
message,
Modal,
Select,
Space,
Table,
@ -36,9 +32,6 @@ import {
} from 'ant-design-vue';
import {
createNewApiCallAttributionJobApi,
createNewApiGatewayBindingApi,
createNewApiQuotaRequestApi,
getNewApiIntegrationCallApi,
listNewApiBalanceSnapshotsApi,
listNewApiGatewayUsersApi,
@ -56,48 +49,6 @@ interface TablePager {
/** Ant Table 插槽传入的通用记录。 */
type TableRecord = Record<string, unknown>;
/** 创建或刷新网关绑定表单。 */
interface BindingCommandForm {
/** 目标用户 ID。 */
userId: string;
/** 幂等键。 */
commandId: string;
/** 是否强制刷新已有绑定。 */
forceRefresh: boolean;
/** 操作原因。 */
reason: string;
}
/** New-API 额度配置请求表单。 */
interface QuotaRequestForm {
/** 目标用户 ID。 */
userId: string;
/** 幂等键。 */
commandId: string;
/** 请求类型。 */
requestType: 'balance_config' | 'plan_config' | 'subscription_sync';
/** 操作原因。 */
reason: string;
/** 资源类型。 */
resourceType: string;
/** 目标额度。 */
limit?: number;
}
/** 调用归属 job 表单。 */
interface AttributionJobForm {
/** 幂等键。 */
commandId: string;
/** New-API 调用关联 ID。 */
correlationId: string;
/** 调用 ID 白名单,逗号分隔。 */
callIds: string;
/** 调用审计 revision。 */
expectedCallRevision?: number;
/** 归属原因。 */
reason: string;
}
/** 网关绑定状态颜色。 */
const bindingStatusColor: Record<NewApiBindingStatus, string> = {
bound: 'green',
@ -119,11 +70,6 @@ function asNewApiBinding(record: TableRecord) {
return record as unknown as NewApiBindingSummary;
}
/** 将 Ant Table 通用记录转回用量摘要。 */
function asUsageRecord(record: TableRecord) {
return record as unknown as AdminUsageRecord;
}
/** 网关绑定状态颜色,兼容模板中的 unknown 记录。 */
function getBindingStatusColor(status: unknown) {
return bindingStatusColor[status as NewApiBindingStatus] ?? 'default';
@ -141,7 +87,7 @@ const bindingColumns = [
{ dataIndex: 'boundAt', key: 'boundAt', title: '绑定时间' },
{ dataIndex: 'lastSyncAt', key: 'lastSyncAt', title: '最近同步' },
{ dataIndex: 'syncErrorMessage', key: 'syncErrorMessage', title: '异常摘要' },
{ key: 'actions', title: '操作', width: 280 },
{ key: 'actions', title: '操作', width: 120 },
];
const balanceColumns = [
@ -171,13 +117,11 @@ const usageColumns = [
key: 'failedAttributionCount',
title: '归属失败',
},
{ key: 'actions', title: '异常处理', width: 180 },
];
const bindingLoading = ref(false);
const balanceLoading = ref(false);
const usageLoading = ref(false);
const commandSubmitting = ref(false);
const bindings = ref<NewApiBindingSummary[]>([]);
const balanceSnapshots = ref<BalanceSnapshotEntry[]>([]);
@ -198,38 +142,8 @@ const bindingPager = reactive({ current: 1, pageSize: 10, total: 0 });
const balancePager = reactive({ current: 1, pageSize: 10, total: 0 });
const usagePager = reactive({ current: 1, pageSize: 10, total: 0 });
const bindingModalOpen = ref(false);
const quotaModalOpen = ref(false);
const attributionModalOpen = ref(false);
const integrationQuery = ref('');
const bindingForm = reactive<BindingCommandForm>({
commandId: '',
forceRefresh: false,
reason: '',
userId: '',
});
const quotaForm = reactive<QuotaRequestForm>({
commandId: '',
reason: '',
requestType: 'subscription_sync',
resourceType: '',
userId: '',
});
const attributionForm = reactive<AttributionJobForm>({
callIds: '',
commandId: '',
correlationId: '',
reason: '',
});
/** 生成前端幂等键,后端负责最终幂等校验。 */
function createCommandId(prefix: string) {
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
}
/** 统一空值展示。 */
function displayText(value?: number | string) {
return value === undefined || value === '' ? '-' : String(value);
@ -329,128 +243,11 @@ function handleUsageTableChange(pager: TablePager) {
loadUsageRecords(pager.current ?? 1, pager.pageSize ?? usagePager.pageSize);
}
/** 打开创建或刷新网关绑定弹窗。 */
function openBindingModal(record?: NewApiBindingSummary) {
bindingForm.commandId = createCommandId('newapi-bind');
bindingForm.forceRefresh = record?.bindingStatus === 'sync_failed';
bindingForm.reason = '';
bindingForm.userId = record?.userId ?? selectedBinding.value?.userId ?? '';
bindingModalOpen.value = true;
}
/** 表格事件包装:选择绑定用户。 */
function handleSelectBinding(record: TableRecord) {
return selectBinding(asNewApiBinding(record));
}
/** 表格事件包装:打开绑定命令。 */
function handleOpenBindingModal(record: TableRecord) {
openBindingModal(asNewApiBinding(record));
}
/** 表格事件包装:打开额度配置命令。 */
function handleOpenQuotaModal(record: TableRecord) {
openQuotaModal(asNewApiBinding(record));
}
/** 表格事件包装:打开用量归属异常处理命令。 */
function handleOpenAttributionModal(record: TableRecord) {
openAttributionModal(asUsageRecord(record));
}
/** 提交创建或刷新网关绑定命令。 */
async function submitBindingCommand() {
if (!bindingForm.userId || !bindingForm.reason.trim()) {
message.error('请填写用户 ID 和原因');
return;
}
commandSubmitting.value = true;
try {
await createNewApiGatewayBindingApi(bindingForm.userId, {
commandId: bindingForm.commandId,
forceRefresh: bindingForm.forceRefresh,
reason: bindingForm.reason,
});
message.success('网关绑定命令已提交');
bindingModalOpen.value = false;
await loadBindings();
} finally {
commandSubmitting.value = false;
}
}
/** 打开 New-API 额度配置请求弹窗。 */
function openQuotaModal(record?: NewApiBindingSummary) {
quotaForm.commandId = createCommandId('newapi-quota');
quotaForm.limit = undefined;
quotaForm.reason = '';
quotaForm.requestType = 'subscription_sync';
quotaForm.resourceType = '';
quotaForm.userId = record?.userId ?? selectedBinding.value?.userId ?? '';
quotaModalOpen.value = true;
}
/** 提交 New-API 额度配置请求。 */
async function submitQuotaRequest() {
if (!quotaForm.userId || !quotaForm.reason.trim()) {
message.error('请填写用户 ID 和原因');
return;
}
commandSubmitting.value = true;
try {
await createNewApiQuotaRequestApi(quotaForm.userId, {
commandId: quotaForm.commandId,
reason: quotaForm.reason,
requestType: quotaForm.requestType,
targetQuota: quotaForm.resourceType
? { limit: quotaForm.limit, resourceType: quotaForm.resourceType }
: undefined,
});
message.success('额度配置请求已创建');
quotaModalOpen.value = false;
} finally {
commandSubmitting.value = false;
}
}
/** 打开调用归属 job 弹窗。 */
function openAttributionModal(record?: AdminUsageRecord) {
attributionForm.callIds = '';
attributionForm.commandId = createCommandId('newapi-attr');
attributionForm.correlationId = '';
attributionForm.expectedCallRevision = undefined;
attributionForm.reason = record?.recordId
? `处理用量记录 ${record.recordId} 的归属异常`
: '';
attributionModalOpen.value = true;
}
/** 提交调用归属 job。 */
async function submitAttributionJob() {
if (!attributionForm.correlationId || !attributionForm.reason.trim()) {
message.error('请填写 correlationId 和归属原因');
return;
}
commandSubmitting.value = true;
try {
await createNewApiCallAttributionJobApi({
callIds: attributionForm.callIds
.split(',')
.map((item) => item.trim())
.filter(Boolean),
commandId: attributionForm.commandId,
correlationId: attributionForm.correlationId,
expectedCallRevision: attributionForm.expectedCallRevision,
reason: attributionForm.reason,
verificationMode: 'strict',
});
message.success('调用归属 job 已创建');
attributionModalOpen.value = false;
} finally {
commandSubmitting.value = false;
}
}
/** 按 correlationId 查询外部调用状态。 */
async function queryIntegrationCall() {
if (!integrationQuery.value.trim()) {
@ -474,7 +271,7 @@ onMounted(() => {
<Alert
show-icon
type="warning"
message="New-API 页面只展示治理和对账摘要;不展示凭据,不伪造余额,不管理模型供应商路由。"
message="New-API 页面只展示本地用量观测和对账摘要;不展示凭据,不提供网关绑定、额度配置或归属写命令。"
/>
<Tabs>
@ -498,7 +295,6 @@ onMounted(() => {
>
查询
</Button>
<Button @click="openBindingModal()">创建绑定</Button>
</Space>
<Table
@ -520,12 +316,6 @@ onMounted(() => {
<Button type="link" @click="handleSelectBinding(record)">
余额/用量
</Button>
<Button type="link" @click="handleOpenBindingModal(record)">
刷新绑定
</Button>
<Button type="link" @click="handleOpenQuotaModal(record)">
配置额度
</Button>
</Space>
</template>
</template>
@ -572,7 +362,7 @@ onMounted(() => {
</Tabs.TabPane>
<Tabs.TabPane key="usage" tab="调用归属">
<Card title="调用日志归属与异常处理">
<Card title="调用日志归属摘要">
<Space class="mb-4" wrap>
<Input
v-model:value="usageFilters.userId"
@ -596,7 +386,6 @@ onMounted(() => {
>
查询
</Button>
<Button @click="openAttributionModal()">创建归属 job</Button>
</Space>
<Table
@ -606,15 +395,7 @@ onMounted(() => {
:loading="usageLoading"
:pagination="usagePager"
@change="handleUsageTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'actions'">
<Button type="link" @click="handleOpenAttributionModal(record)">
处理异常
</Button>
</template>
</template>
</Table>
/>
</Card>
</Tabs.TabPane>
@ -658,101 +439,5 @@ onMounted(() => {
</Tabs.TabPane>
</Tabs>
</div>
<Modal
v-model:open="bindingModalOpen"
:confirm-loading="commandSubmitting"
title="创建或刷新网关绑定"
@ok="submitBindingCommand"
>
<Form layout="vertical">
<Form.Item label="用户 ID" required>
<Input v-model:value="bindingForm.userId" />
</Form.Item>
<Form.Item label="幂等键" required>
<Input v-model:value="bindingForm.commandId" />
</Form.Item>
<Form.Item label="强制刷新">
<Checkbox v-model:checked="bindingForm.forceRefresh">
同步失败或人工补偿时强制刷新
</Checkbox>
</Form.Item>
<Form.Item label="原因" required>
<Input.TextArea v-model:value="bindingForm.reason" :rows="3" />
</Form.Item>
</Form>
</Modal>
<Modal
v-model:open="quotaModalOpen"
:confirm-loading="commandSubmitting"
title="New-API 额度配置请求"
@ok="submitQuotaRequest"
>
<Form layout="vertical">
<Form.Item label="用户 ID" required>
<Input v-model:value="quotaForm.userId" />
</Form.Item>
<Form.Item label="请求类型" required>
<Select v-model:value="quotaForm.requestType">
<Select.Option value="plan_config">套餐配置</Select.Option>
<Select.Option value="balance_config">余额配置</Select.Option>
<Select.Option value="subscription_sync">订阅同步</Select.Option>
</Select>
</Form.Item>
<Form.Item label="目标额度">
<Space>
<Input
v-model:value="quotaForm.resourceType"
placeholder="资源类型"
/>
<InputNumber v-model:value="quotaForm.limit" placeholder="额度" />
</Space>
</Form.Item>
<Form.Item label="幂等键" required>
<Input v-model:value="quotaForm.commandId" />
</Form.Item>
<Form.Item label="原因" required>
<Input.TextArea v-model:value="quotaForm.reason" :rows="3" />
</Form.Item>
</Form>
</Modal>
<Modal
v-model:open="attributionModalOpen"
:confirm-loading="commandSubmitting"
title="创建调用归属 job"
@ok="submitAttributionJob"
>
<Alert
class="mb-4"
show-icon
type="info"
message="归属事实由服务端根据调用审计记录、任务、作品、资产和授权关系严格校验。"
/>
<Form layout="vertical">
<Form.Item label="correlationId" required>
<Input v-model:value="attributionForm.correlationId" />
</Form.Item>
<Form.Item label="callIds 白名单">
<Input
v-model:value="attributionForm.callIds"
placeholder="多个 callId 用逗号分隔"
/>
</Form.Item>
<Form.Item label="调用 revision">
<InputNumber
v-model:value="attributionForm.expectedCallRevision"
class="w-full"
/>
</Form.Item>
<Form.Item label="幂等键" required>
<Input v-model:value="attributionForm.commandId" />
</Form.Item>
<Form.Item label="原因" required>
<Input.TextArea v-model:value="attributionForm.reason" :rows="3" />
</Form.Item>
</Form>
</Modal>
</Page>
</template>

View File

@ -0,0 +1,98 @@
# Muse 2.0.0 单人版一体化编排(S9):muse-server 单体 + PostgreSQL + Redis。
#
# 边界:Dify 沿用其官方 compose 独立部署,本栈通过 env 指向 Dify(base-url 与两类 key:
# 写作/解析 app key 走 muse.ai.dify、知识 dataset key 走 muse.knowledge.dify)。
# 租户机制保持开启,单人部署所有请求固定 tenant-id=1;多用户 worker 全关(见 env_file)。
#
# 用法:
# 1. mvn -pl muse-server -am -Dmaven.test.skip=true clean package # 先出可运行 jar(Dockerfile 只 COPY jar)
# 2. cp scripts/dev/solo-compose.env.example .env.solo # 填 Dify 真实凭据与 PG/Redis 密码
# 3. docker compose -f docker-compose.solo.yml --env-file .env.solo up -d --build
# 首次 up 时 PostgreSQL 空卷会执行 yudao 基座 SQL(initdb),muse-server 启动再跑 Flyway 建 muse 表;
# 菜单数据见部署手册的 system_menu solo SQL(provision 后单独执行)。
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: ${MUSE_POSTGRES_USERNAME:-muse}
POSTGRES_PASSWORD: ${MUSE_POSTGRES_PASSWORD:?solo 部署必须显式设置 PG 密码}
POSTGRES_DB: ${MUSE_POSTGRES_DATABASE:-muse}
volumes:
- solo-pgdata:/var/lib/postgresql/data
# 基座 schema+seed 仅在空卷首次初始化时执行(docker-entrypoint-initdb.d 约定);muse 表由 muse-server 的 Flyway 建。
- ./sql/dev/yudao-base-schema-postgres.sql:/docker-entrypoint-initdb.d/01-yudao-base-schema.sql:ro
- ./sql/dev/yudao-base-seed-postgres.sql:/docker-entrypoint-initdb.d/02-yudao-base-seed.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${MUSE_POSTGRES_USERNAME:-muse} -d ${MUSE_POSTGRES_DATABASE:-muse}"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
redis:
image: redis:8-alpine
# 生产稳定:solo 部署 Redis 必须设密码 + 开 AOF 持久化(草稿安全网/幂等键不可随重启丢失)。
command: ["redis-server", "--requirepass", "${MUSE_REDIS_PASSWORD:?solo 部署必须显式设置 Redis 密码}", "--appendonly", "yes"]
volumes:
- solo-redisdata:/data
healthcheck:
test: ["CMD-SHELL", "redis-cli -a \"$$MUSE_REDIS_PASSWORD\" ping | grep -q PONG"]
interval: 5s
timeout: 5s
retries: 10
environment:
MUSE_REDIS_PASSWORD: ${MUSE_REDIS_PASSWORD}
restart: unless-stopped
muse-server:
build:
context: ./muse-server
dockerfile: Dockerfile
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
# 单人版默认开关(tenant on / 多用户 worker 全关 / New-API off / Dify on / 超时对齐)。
env_file:
- scripts/dev/solo-compose.env.example
environment:
# --- 数据库/Redis 指向本栈服务(覆盖 infra profile 默认的远端 host)---
MUSE_POSTGRES_HOST: postgres
MUSE_POSTGRES_PORT: "5432"
MUSE_POSTGRES_DATABASE: ${MUSE_POSTGRES_DATABASE:-muse}
MUSE_POSTGRES_USERNAME: ${MUSE_POSTGRES_USERNAME:-muse}
MUSE_POSTGRES_PASSWORD: ${MUSE_POSTGRES_PASSWORD}
MUSE_REDIS_HOST: redis
MUSE_REDIS_PORT: "6379"
MUSE_REDIS_PASSWORD: ${MUSE_REDIS_PASSWORD}
# --- 知识运行时 = Dify(S6d 后 RAGFlow 运行时已删,provider 未设也缺省 Dify)---
MUSE_KNOWLEDGE_RUNTIME_PROVIDER: dify
# --- Dify 凭据(从 --env-file .env.solo 注入,勿在此硬编明文)---
MUSE_AI_DIFY_BASE_URL: ${MUSE_AI_DIFY_BASE_URL}
MUSE_AI_DIFY_WRITING_APP_ID: ${MUSE_AI_DIFY_WRITING_APP_ID}
MUSE_AI_DIFY_WRITING_API_KEY: ${MUSE_AI_DIFY_WRITING_API_KEY}
MUSE_AI_DIFY_PARSER_APP_ID: ${MUSE_AI_DIFY_PARSER_APP_ID}
MUSE_AI_DIFY_PARSER_API_KEY: ${MUSE_AI_DIFY_PARSER_API_KEY}
MUSE_KNOWLEDGE_DIFY_BASE_URL: ${MUSE_KNOWLEDGE_DIFY_BASE_URL}
MUSE_KNOWLEDGE_DIFY_DATASET_API_KEY: ${MUSE_KNOWLEDGE_DIFY_DATASET_API_KEY}
# --- 无 Nacos 干净启动(无注册中心环境,关服务发现避免持续重连噪声)---
SPRING_CLOUD_NACOS_DISCOVERY_ENABLED: "false"
# --- JVM 与代理(内网直连 Dify/PG/Redis 禁用系统代理,避免 fake-ip 劫持)---
JAVA_OPTS: "-Xms512m -Xmx1024m -Djava.security.egd=file:/dev/./urandom -Djava.net.useSystemProxies=false -Dhttp.proxyHost= -Dhttps.proxyHost="
ARGS: "--spring.profiles.active=local,infra"
volumes:
# muse 业务表的 36 个 Flyway 迁移脚本在 sql/muse/,application.yaml 的
# flyway.locations 含 filesystem:sql/muse(相对容器 WORKDIR /muse-server);
# 脚本未打进 jar(classpath:db/migration/muse 为空),必须在此挂载,否则 Flyway
# "No migrations found" → muse 表全缺 → AI worker 每秒报 relation does not exist。
# 经 mini-infra 从零起全栈真验:挂载后 applied 36 migrations、now at v37、121 表。
- ./sql/muse:/muse-server/sql/muse:ro
ports:
- "48080:48080"
restart: unless-stopped
volumes:
solo-pgdata:
solo-redisdata:

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,359 @@
package cn.iocoder.muse.module.ai.application.muse;
import cn.iocoder.muse.framework.common.util.json.JsonUtils;
import cn.iocoder.muse.module.ai.framework.ai.config.MuseAiProperties;
import com.fasterxml.jackson.databind.JsonNode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.io.IOException;
import java.net.ConnectException;
import java.net.ProxySelector;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 基于 Difymuse-全书解析chat app(passthrough,pre_prompt )的导入章节解析器
*
* <p>语义与 {@link NewApiMuseAiImportLlmParser} 逐条对齐:401/403 fail-closed 不重试408/429/5xx 退避重试
* token/provider 原文不落库不打印模型正文只在本次解析内转成 AI Shadow 章节差异仅在传输载体:
* Dify chat blocking 调用,把镜像 New-API system+user prompt 合并成一条 query 发出,
* {@code answer} 消费模型产出的章节 JSON 字符串</p>
*
* <p>截断检测:Dify chat 没有 New-API 那种 provider {@code finish_reason=length} 信号,只能靠
* 对模型产出 JSON 本身做完整性校验JSON 解析失败即判定输出被截断( {@link #mapChatResult}),
* 再叠加章节数合理性( / 超上限)检查</p>
*/
@Component
@Slf4j
public class DifyMuseAiImportLlmParser implements MuseAiImportLlmParser {
/** summary/落库来源标识,与 New-API 版对称。 */
private static final String PARSER_TAG = "dify_import_llm";
/** provider 选择键,归一化后与 muse.ai.import.provider 比较。 */
private static final String PROVIDER_KEY = "dify";
/** S8 全书解析 chat app 固定凭据 ref;真实 API Key 由 env 注入到 muse.ai.dify.credentials,禁止落库/打印。 */
private static final String PARSER_CREDENTIAL_REF = "dify-parser-s1";
/** Dify chat blocking 端点;baseUrl 已含 /v1,故只拼 /chat-messages(app 由 API Key 确定)。 */
private static final String CHAT_MESSAGES_PATH = "/chat-messages";
private static final String RESPONSE_MODE_BLOCKING = "blocking";
private static final int MAX_LLM_INPUT_CHARS = 180_000;
private static final int MAX_LLM_CHAPTERS = 300;
private static final ProxySelector DIRECT_PROXY_SELECTOR = ProxySelector.of(null);
private final MuseAiProperties.Dify properties;
private final HttpClient httpClient;
public DifyMuseAiImportLlmParser(MuseAiProperties museAiProperties) {
this.properties = museAiProperties == null ? null : museAiProperties.getDify();
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(positive(connectTimeoutSeconds(), 5)))
// 全书解析同样调用内网 Dify chat app,必须绕开 JVM/系统代理,避免可用服务被误判为 502
.proxy(DIRECT_PROXY_SELECTOR)
.build();
}
@Override
public String providerKey() {
return PROVIDER_KEY;
}
@Override
public LlmParseResult parse(LlmParseCommand command) {
// fail-closed:Dify 未启用 / baseUrl 缺失 / dify-parser-s1 凭据缺失,一律不可重试地判为未配置
if (!isConfigured()) {
return LlmParseResult.failed("AI_IMPORT_LLM_UNAVAILABLE", "AI 全书解析服务未配置", false,
Map.of("parser", PARSER_TAG));
}
if (command == null || !StringUtils.hasText(command.contentText())) {
return LlmParseResult.failed("AI_IMPORT_LLM_EMPTY_INPUT", "AI 全书解析输入为空", false,
Map.of("parser", PARSER_TAG));
}
if (command.contentText().length() > MAX_LLM_INPUT_CHARS) {
return LlmParseResult.failed("AI_IMPORT_LLM_INPUT_TOO_LARGE",
"当前 AI 全书解析输入超过模型上下文上限,请先按章节拆分后重试", false,
Map.of("parser", PARSER_TAG, "inputChars", command.contentText().length()));
}
int maxAttempts = maxAttempts();
LlmParseResult lastResult = null;
for (int attemptNo = 1; attemptNo <= maxAttempts; attemptNo++) {
lastResult = parseOnce(command);
LlmParseResult resultWithAttempts = withAttemptSummary(lastResult, attemptNo, maxAttempts);
// 成功 / 不可重试(如鉴权失败坏响应)/ 已到上限:立即返回,不再退避
if (resultWithAttempts.success() || !resultWithAttempts.retryable() || attemptNo >= maxAttempts) {
return resultWithAttempts;
}
sleepBeforeRetry(command, attemptNo);
}
return withAttemptSummary(lastResult, maxAttempts, maxAttempts);
}
private LlmParseResult parseOnce(LlmParseCommand command) {
try {
HttpRequest request = buildRequest(command);
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
return mapResponse(command, response);
} catch (HttpTimeoutException ex) {
return LlmParseResult.failed("AI_IMPORT_LLM_TIMEOUT", "AI 全书解析请求超时", true,
Map.of("parser", PARSER_TAG));
} catch (ConnectException ex) {
return LlmParseResult.failed("AI_IMPORT_LLM_CONNECT_FAILED", "AI 全书解析服务连接失败", true,
Map.of("parser", PARSER_TAG));
} catch (IOException ex) {
return LlmParseResult.failed("AI_IMPORT_LLM_CONNECT_FAILED", "AI 全书解析服务连接失败", true,
Map.of("parser", PARSER_TAG));
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
return LlmParseResult.failed("AI_IMPORT_LLM_TIMEOUT", "AI 全书解析请求被中断", true,
Map.of("parser", PARSER_TAG));
} catch (RuntimeException ex) {
// 只记脱敏错误类型,绝不打印响应体/凭据;Dify envelope 无法解析走此分支,不可重试
log.warn("AI 全书解析(Dify)运行时失败jobId={}, errorType={}", command.jobId(), ex.getClass().getSimpleName());
return LlmParseResult.failed("AI_IMPORT_LLM_BAD_RESPONSE", "AI 全书解析响应无法解析", false,
Map.of("parser", PARSER_TAG));
}
}
private LlmParseResult withAttemptSummary(LlmParseResult result, int attemptNo, int maxAttempts) {
if (result == null) {
return LlmParseResult.failed("AI_IMPORT_LLM_UNKNOWN", "AI 全书解析未返回结果", false,
Map.of("parser", PARSER_TAG, "attemptNo", attemptNo, "maxAttempts", maxAttempts));
}
Map<String, Object> summary = new LinkedHashMap<>(result.summary() == null ? Map.of() : result.summary());
summary.put("attemptNo", attemptNo);
summary.put("maxAttempts", maxAttempts);
return new LlmParseResult(result.success(), result.chapters(), result.errorCode(), result.errorMessage(),
result.retryable(), summary);
}
private void sleepBeforeRetry(LlmParseCommand command, int attemptNo) {
int backoffSeconds = backoffSeconds(attemptNo);
log.warn("AI 全书解析(Dify)可重试失败准备重试jobId={}, attemptNo={}, nextAttemptNo={}, backoffSeconds={}",
command.jobId(), attemptNo, attemptNo + 1, backoffSeconds);
if (backoffSeconds <= 0) {
return;
}
try {
Thread.sleep(Duration.ofSeconds(backoffSeconds).toMillis());
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
}
private HttpRequest buildRequest(LlmParseCommand command) {
// chat app passthrough(pre_prompt ),故把 system 指令与 user 数据合并成一条 query 发出;inputs 留空
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("inputs", Map.of());
payload.put("query", systemPrompt() + "\n\n" + userPrompt(command));
payload.put("response_mode", RESPONSE_MODE_BLOCKING);
payload.put("conversation_id", "");
payload.put("user", user(command));
String body = JsonUtils.toJsonString(payload);
HttpRequest.Builder builder = HttpRequest.newBuilder()
.uri(chatMessagesUri())
.timeout(Duration.ofSeconds(positive(nonStreamReadTimeoutSeconds(), 90)))
// 凭据只在 Authorization 头出现,来自安全配置解析,不进 body/日志/summary
.header("Authorization", "Bearer " + apiKey(PARSER_CREDENTIAL_REF))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header("X-Muse-Scene", "ai_import_parse")
.POST(HttpRequest.BodyPublishers.ofString(body));
if (StringUtils.hasText(command.commandId())) {
builder.header("X-Request-Id", command.commandId());
builder.header("X-Trace-Id", command.commandId());
}
return builder.build();
}
private LlmParseResult mapResponse(LlmParseCommand command, HttpResponse<String> response) {
int status = response.statusCode();
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("parser", PARSER_TAG);
summary.put("providerStatusCode", status);
response.headers().firstValue("X-Request-Id").ifPresent(value -> summary.put("providerRequestId", value));
// HTTP 层错误:错误码/重试策略与 New-API 版逐条一致(401/403 不重试,408/429/5xx 重试)
if (status < 200 || status >= 300) {
return LlmParseResult.failed(errorCode(status), errorMessage(status), isRetryableStatus(status), summary);
}
// Dify chat blocking 成功响应:{"answer":"<JSON字符串>","metadata":{...},"message_id":..}
// chat app workflow status/outputs 概念:HTTP 200 answer 非空即视为成功,进入 JSON 解析
JsonNode root = JsonUtils.parseTree(response.body());
String messageId = textOrNull(root.path("message_id"));
if (StringUtils.hasText(messageId)) {
summary.put("providerRequestId", messageId);
}
String answer = textOrNull(root.path("answer"));
if (!StringUtils.hasText(answer)) {
return LlmParseResult.failed("AI_IMPORT_LLM_BAD_RESPONSE", "AI 全书解析响应缺少正文", false, summary);
}
return mapChatResult(answer, summary);
}
private LlmParseResult mapChatResult(String answer, Map<String, Object> summary) {
String json = stripJsonFence(answer);
JsonNode resultRoot;
try {
resultRoot = JsonUtils.parseTree(json);
} catch (RuntimeException ex) {
// 截断检测核心:Dify finish_reason 信号,模型产出 JSON 不完整(解析失败)即视为输出被截断
return LlmParseResult.failed("AI_IMPORT_LLM_TRUNCATED", "AI 全书解析响应被截断", false, summary);
}
List<LlmChapter> chapters = extractChapters(resultRoot);
if (chapters.isEmpty()) {
return LlmParseResult.failed("AI_IMPORT_LLM_NO_CHAPTERS", "AI 全书解析未返回有效章节", false, summary);
}
if (chapters.size() > MAX_LLM_CHAPTERS) {
return LlmParseResult.failed("AI_IMPORT_LLM_TOO_MANY_CHAPTERS", "AI 全书解析章节数超过上限", false, summary);
}
summary.put("chapterCount", chapters.size());
return LlmParseResult.success(chapters, summary);
}
private List<LlmChapter> extractChapters(JsonNode resultRoot) {
JsonNode chaptersNode = resultRoot.isArray() ? resultRoot : resultRoot.path("chapters");
List<LlmChapter> chapters = new ArrayList<>();
if (!chaptersNode.isArray()) {
return chapters;
}
for (JsonNode item : chaptersNode) {
String title = item.path("title").asText("").strip();
String content = item.path("content").asText("").strip();
if (!StringUtils.hasText(content)) {
continue;
}
chapters.add(new LlmChapter(StringUtils.hasText(title) ? title : "导入章节", content));
}
return chapters;
}
private String stripJsonFence(String content) {
String normalized = content == null ? "" : content.strip();
if (normalized.startsWith("```")) {
normalized = normalized.replaceFirst("(?s)^```(?:json)?\\s*", "");
normalized = normalized.replaceFirst("(?s)\\s*```$", "");
}
return normalized.strip();
}
private String systemPrompt() {
// 文案照搬 NewApiMuseAiImportLlmParser.systemPrompt();chat app passthrough,须由 query 携带全部指令
return """
你是长篇小说导入解析器只输出 JSON不要解释
JSON 格式必须是 {"chapters":[{"title":"章节标题","content":"章节正文"}]}
章节正文必须来自用户提供文本不得补写改写总结或虚构
如果原文已有章节标题按原标题切分如果没有标题按叙事段落保守切分并给出中性标题
""";
}
private String userPrompt(LlmParseCommand command) {
// 文案照搬 NewApiMuseAiImportLlmParser.userPrompt(),保证与 New-API 版产出同一份严格章节 JSON
return """
文件名%s
文件格式%s
内容摘要哈希%s
请把下面全文切分为可供用户审核的章节 JSON
%s
""".formatted(command.fileName(), command.format(), command.contentHash(), command.contentText());
}
private URI chatMessagesUri() {
return URI.create(trimRight(properties.getBaseUrl(), "/") + CHAT_MESSAGES_PATH);
}
private String user(LlmParseCommand command) {
Long owner = command == null ? null : command.ownerUserId();
return "muse-import-" + (owner == null ? "unknown" : owner);
}
private String apiKey(String credentialRef) {
if (properties == null || properties.getCredentials() == null) {
return null;
}
return properties.getCredentials().stream()
.filter(credential -> credential != null && credentialRef.equals(credential.getRef()))
.map(MuseAiProperties.Dify.Credential::getApiKey)
.filter(StringUtils::hasText)
.findFirst()
.orElse(null);
}
private boolean isConfigured() {
return properties != null
&& properties.isEnabled()
&& StringUtils.hasText(properties.getBaseUrl())
&& StringUtils.hasText(apiKey(PARSER_CREDENTIAL_REF));
}
private String errorCode(int status) {
return switch (status) {
case 401, 403 -> "AI_IMPORT_LLM_AUTH_FAILED";
case 408, 504 -> "AI_IMPORT_LLM_TIMEOUT";
case 429 -> "AI_IMPORT_LLM_RATE_LIMITED";
default -> status >= 500 ? "AI_IMPORT_LLM_PROVIDER_5XX" : "AI_IMPORT_LLM_BAD_REQUEST";
};
}
private String errorMessage(int status) {
return switch (status) {
case 401, 403 -> "AI 全书解析鉴权失败";
case 408, 504 -> "AI 全书解析请求超时";
case 429 -> "AI 全书解析被限流";
default -> status >= 500 ? "AI 全书解析服务暂不可用" : "AI 全书解析请求被拒绝";
};
}
private boolean isRetryableStatus(int status) {
return status == 408 || status == 429 || status == 500 || status == 502 || status == 503 || status == 504;
}
private Integer connectTimeoutSeconds() {
return properties == null ? null : properties.getConnectTimeoutSeconds();
}
private Integer nonStreamReadTimeoutSeconds() {
return properties == null ? null : properties.getNonStreamReadTimeoutSeconds();
}
private int maxAttempts() {
return positive(properties == null ? null : properties.getMaxAttempts(), 3);
}
private int backoffSeconds(int attemptNo) {
List<Integer> backoff = properties == null ? null : properties.getRetryBackoffSeconds();
if (backoff == null || backoff.isEmpty()) {
return 0;
}
int index = Math.min(Math.max(attemptNo - 1, 0), backoff.size() - 1);
Integer seconds = backoff.get(index);
return seconds == null || seconds < 0 ? 0 : seconds;
}
private int positive(Integer value, int defaultValue) {
return value == null || value <= 0 ? defaultValue : value;
}
private String trimRight(String value, String suffix) {
String result = value == null ? "" : value;
while (result.endsWith(suffix)) {
result = result.substring(0, result.length() - suffix.length());
}
return result;
}
private static String textOrNull(JsonNode node) {
return node == null || node.isMissingNode() || node.isNull() ? null : node.asText();
}
}

View File

@ -30,6 +30,63 @@ public class MuseAiCandidateReviewService {
/** 输出合规扫描的敏感凭据痕迹关键字(出现即判定为不可入正文的合规风险,防止把泄露密钥的输出写进正式正文)。 */
private static final List<String> CREDENTIAL_MARKERS = List.of(
"bearer ", "authorization:", "api_key", "apikey", "secret", "-----begin");
/** 疑似截断的最小可判长度(字符);短于此的输出不做截断判定,避免对合法短回复误报。 */
private static final int TRUNCATION_MIN_LENGTH = 60;
/** 视为正常结尾的句末/收尾标点(中英文句末符 + 收尾引号/括号);正文达可判长度却非此类结尾即疑似截断。 */
private static final String TERMINAL_CHARS = "。!?…;;.!?”’\"'」』】))》>";
/**
* provider 无关的完整正文护栏(S7a 上移到捕获处): provider <b>完整正文</b>完整性/截断校验凭据痕迹合规扫描
* 供两个真实 runtime adapter success 分支派生 60/80 字摘要<strong>之前</strong>调用;不通过即判失败(可重试),
* 完整正文绝不透出下游绝不落库
*
* <p><b>WHY 不信 provider finishReason</b>:Dify 完成原因是硬编码常量New-API 只记录不设门,均不反映真实完成度;
* 截断只能靠对完整正文本身的长度/结构校验判定( {@link #isSuspectedTruncation})</p>
*
* @param fullOutput provider 完整正文(仅内存;调用方不得持久化,日志只可打长度/hash)
* @return 护栏结论;{@code passed=false} 时携带脱敏原因码(empty/oversize/credential_marker/truncated)
*/
public static OutputGuardResult guardFullOutput(String fullOutput) {
String text = fullOutput == null ? "" : fullOutput;
if (!StringUtils.hasText(text)) {
return OutputGuardResult.reject("empty");
}
if (text.length() > MAX_CONTENT_LENGTH) {
return OutputGuardResult.reject("oversize");
}
// 合规先于截断:凭据痕迹是硬合规风险,单独原因码便于脱敏排障
if (containsCredentialMarker(text)) {
return OutputGuardResult.reject("credential_marker");
}
if (isSuspectedTruncation(text)) {
return OutputGuardResult.reject("truncated");
}
return OutputGuardResult.ok();
}
/** 凭据痕迹扫描:命中任一凭据关键字即为真(单一来源 {@link #CREDENTIAL_MARKERS},与 review 合规扫描同口径)。 */
private static boolean containsCredentialMarker(String text) {
String lower = text.toLowerCase(Locale.ROOT);
for (String marker : CREDENTIAL_MARKERS) {
if (lower.contains(marker)) {
return true;
}
}
return false;
}
/**
* 疑似截断判定:完整正文达到可判长度({@code >= TRUNCATION_MIN_LENGTH})却未以句末/收尾标点结束,视为疑似截断
* 短输出不做判定(合法短回复亦可能无标点)纯长度/结构启发,不依赖 provider 完成原因
*/
private static boolean isSuspectedTruncation(String text) {
String normalized = text == null ? "" : text.strip();
if (normalized.length() < TRUNCATION_MIN_LENGTH) {
return false;
}
char last = normalized.charAt(normalized.length() - 1);
return TERMINAL_CHARS.indexOf(last) < 0;
}
/**
* 对一段生成正文执行三项审查
@ -56,7 +113,7 @@ public class MuseAiCandidateReviewService {
}
boolean compliancePassed = complianceFindings.isEmpty();
// 2) 静态检查:正文非空长度在合理区间(防止超长/空白异常输出)
// 2) 静态检查:正文非空长度在合理区间且结构完整(非疑似截断)审对象为完整正文时,截断即在此拦下
List<String> staticFindings = new ArrayList<>();
if (!StringUtils.hasText(text)) {
staticFindings.add("blank_text");
@ -64,6 +121,9 @@ public class MuseAiCandidateReviewService {
if (text.length() > MAX_CONTENT_LENGTH) {
staticFindings.add("oversize:" + text.length());
}
if (isSuspectedTruncation(text)) {
staticFindings.add("truncated:" + text.length());
}
boolean staticPassed = staticFindings.isEmpty();
// 3) 许可限制快照:透传来源许可限制(AI 自生成无来源限制时为空列表,语义=无限制)
@ -101,4 +161,27 @@ public class MuseAiCandidateReviewService {
public record CandidateReview(boolean passed, String outputComplianceResultId, String staticCheckResultId,
List<String> licenseRestrictions, Map<String, Object> findings) {
}
/**
* 完整正文护栏结论( {@link #guardFullOutput})
*
* @param passed 是否通过;true 方可透出 fullOutput 到下游
* @param reasonCode 脱敏原因码(passed=true 时为 null;否则 empty/oversize/credential_marker/truncated)
*/
public record OutputGuardResult(boolean passed, String reasonCode) {
// 工厂方法不能命名为 passed():会与 record 组件 passed 的自动访问器( public)冲突
private static OutputGuardResult ok() {
return new OutputGuardResult(true, null);
}
private static OutputGuardResult reject(String reasonCode) {
return new OutputGuardResult(false, reasonCode);
}
/** 是否因疑似截断不通过(供 adapter 在截断/合规之间选择错误码)。 */
public boolean truncated() {
return "truncated".equals(reasonCode);
}
}
}

View File

@ -0,0 +1,95 @@
package cn.iocoder.muse.module.ai.application.muse;
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAiGeneratedFulltextDO;
import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseAiGeneratedFulltextMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.HexFormat;
/**
* AI 瞬态出向完整正文仓库S7b
*
* <p>与入向指令仓库 {@link MuseAiRuntimePayloadStore} 同范式方向相反独立表 TTL字段加密
* 区别在于生命周期出向完整正文须存活到用户决定采纳/放弃 SSE 送达与断线重连回取
* 因此<b>不在执行器终态 finally 里清理</b>只由决定即清purge TTL 兜底删除</p>
*
* <p><b>主权红线</b>完整正文只允许存在于执行线程内存 / 本瞬态加密表 / SSE 传输 / 客户端
* 永不写入 muse_ai_suggestion / muse_content_block 或任何长期列日志只打长度/hash不打正文</p>
*/
@Service
public class MuseAiGeneratedFulltextStore {
private static final Duration DEFAULT_TTL = Duration.ofMinutes(30);
@Resource
private MuseAiGeneratedFulltextMapper generatedFulltextMapper;
/**
* 写入本次生成的完整正文key = taskId同一 taskId 覆写前先物理删除旧行保证幂等且不撞 uk
*/
@Transactional(rollbackFor = Exception.class)
public void write(Long tenantId, Long taskId, String fullText) {
if (tenantId == null || taskId == null || !StringUtils.hasText(fullText)) {
return;
}
cleanupExpired();
// 幂等覆写同一 taskId 可能因异常重放重入先物理删除旧行再插入避免 uk(tenant_id, task_id) 冲突
generatedFulltextMapper.deleteByTaskId(tenantId, taskId);
MuseAiGeneratedFulltextDO row = new MuseAiGeneratedFulltextDO();
row.setTenantId(tenantId);
row.setTaskId(taskId);
row.setFulltextContent(fullText);
row.setContentLength(fullText.length());
row.setContentHash(sha256(fullText));
row.setExpiresAt(LocalDateTime.now().plus(DEFAULT_TTL));
generatedFulltextMapper.insert(row);
}
/**
* 回取完整正文TTL 过期/ purge/不存在均返回 nullSSE 侧据此填空串用户已决定无害
*/
@Transactional(readOnly = true)
public String read(Long tenantId, Long taskId) {
if (tenantId == null || taskId == null) {
return null;
}
MuseAiGeneratedFulltextDO row = generatedFulltextMapper.selectActiveByTaskId(tenantId, taskId);
return row == null ? null : row.getFulltextContent();
}
/**
* 决定即清用户采纳/放弃后物理删除该 taskId 的加密正文避免长期留表
*/
@Transactional(rollbackFor = Exception.class)
public void purge(Long tenantId, Long taskId) {
if (tenantId != null && taskId != null) {
generatedFulltextMapper.deleteByTaskId(tenantId, taskId);
}
}
/**
* 兜底清理 TTL 过期与软删残留防止旧逻辑遗留的加密正文无限期保留
*/
@Transactional(rollbackFor = Exception.class)
public void cleanupExpired() {
generatedFulltextMapper.cleanupResidual();
}
private String sha256(String value) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException ex) {
throw new IllegalStateException("JDK 缺少 SHA-256 摘要算法", ex);
}
}
}

View File

@ -15,6 +15,13 @@ public interface MuseAiImportLlmParser {
*/
LlmParseResult parse(LlmParseCommand command);
/**
* parser 归属的 runtime provider 标识 {@code new-api} / {@code dify}
*
* <p> {@code muse.ai.import.provider} 在多实现并存时按 provider 选择;归一化比较由调用方负责</p>
*/
String providerKey();
/**
* LLM 解析命令{@code contentText} 是短生命周期载荷调用方禁止记录
*/

View File

@ -4,6 +4,7 @@ import cn.iocoder.muse.framework.common.exception.ServiceException;
import cn.iocoder.muse.framework.common.util.json.JsonUtils;
import cn.iocoder.muse.module.ai.api.MuseAiImportParseApi;
import cn.iocoder.muse.module.ai.application.muse.facade.MuseContentWorkOwnerFacade;
import cn.iocoder.muse.module.ai.framework.ai.config.MuseAiProperties;
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAiChapterParseResultDO;
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAiParseJobDO;
import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseAiChapterParseResultMapper;
@ -61,6 +62,8 @@ public class MuseAiImportParseService implements MuseAiImportParseApi {
private static final int MAX_EXTRACTED_CHARS = 1_000_000;
private static final int MAX_PARSE_CHAPTERS = 300;
private static final int PREVIEW_MAX_CHARS = 240;
// 全书解析 provider 缺省 dify(S8 New-API 切到 Dify;New-API 链路保留以便回退)
private static final String DEFAULT_IMPORT_PROVIDER = "dify";
private static final Pattern MARKDOWN_HEADING_PATTERN = Pattern.compile("^#{1,3}\\s+(.{1,120})$");
private static final Pattern CN_CHAPTER_HEADING_PATTERN = Pattern.compile("^第[\\p{N}一二三四五六七八九十百千万零〇两]+[章节卷回].{0,120}$");
private static final Pattern EN_CHAPTER_HEADING_PATTERN = Pattern.compile("(?i)^chapter\\s+[\\p{Alnum}]+.{0,120}$");
@ -80,8 +83,14 @@ public class MuseAiImportParseService implements MuseAiImportParseApi {
private MuseAiChapterParseResultMapper chapterResultMapper;
@Resource
private MuseContentWorkOwnerFacade contentWorkOwnerFacade;
/**
* 所有全书解析 parser 实现(new-api / dify),运行时按 muse.ai.import.provider 选择;
* List 注入而非单点,避免两实现并存时的注入歧义
*/
@Resource
private MuseAiImportLlmParser llmParser;
private List<MuseAiImportLlmParser> importLlmParsers;
@Resource
private MuseAiProperties museAiProperties;
public MuseAiImportParseService(ObjectProvider<FileApi> fileApiProvider) {
this.fileApi = fileApiProvider.getIfAvailable();
@ -326,7 +335,12 @@ public class MuseAiImportParseService implements MuseAiImportParseApi {
private ParseExecution parseWithLlm(MuseAiParseJobDO job, String format, String normalizedText, String contentHash,
ExtractedText extractedText) {
MuseAiImportLlmParser.LlmParseResult llmResult = llmParser.parse(new MuseAiImportLlmParser.LlmParseCommand(
MuseAiImportLlmParser parser = resolveImportLlmParser();
if (parser == null) {
// 没有任何可用的全书解析 parser 实现:直接 fail-closed,不落任何 shadow 章节
return ParseExecution.failed("llm_parse", "AI_IMPORT_LLM_UNAVAILABLE", "AI 全书解析服务未配置", false);
}
MuseAiImportLlmParser.LlmParseResult llmResult = parser.parse(new MuseAiImportLlmParser.LlmParseCommand(
job.getCommandId(), job.getId(), job.getOwnerUserId(), job.getWorkId(), job.getFileName(), format,
normalizedText, contentHash, readJson(job.getParseConfig())));
if (!llmResult.success()) {
@ -354,7 +368,59 @@ public class MuseAiImportParseService implements MuseAiImportParseApi {
Map<String, Object> summary = new LinkedHashMap<>(llmResult.summary());
summary.put("extractor", extractedText.parser());
summary.put("extractedChars", normalizedText.length());
return ParseExecution.success(chapters, contentHash, "new_api_import_llm", summary);
// 来源标识由选中 parser summary.parser 自报(new_api_import_llm / dify_import_llm),避免此处硬编码 provider
String parserTag = stringValue(llmResult.summary() == null ? null : llmResult.summary().get("parser"));
return ParseExecution.success(chapters, contentHash,
StringUtils.hasText(parserTag) ? parserTag : "ai_import_llm", summary);
}
/**
* {@code muse.ai.import.provider} 选择全书解析 parser(缺省 dify)
*
* <p>WHY:S8 把全书解析从 New-API 切到 Dify,但两条链路并存以便回退单实现场景(仅注册一个 parser,
* 或单测注入单个 mock)直接返回,不依赖配置;多实现时按归一化 provider 匹配,匹配不到回退默认再兜底首个</p>
*/
private MuseAiImportLlmParser resolveImportLlmParser() {
List<MuseAiImportLlmParser> parsers = importLlmParsers;
if (parsers == null || parsers.isEmpty()) {
return null;
}
if (parsers.size() == 1) {
return parsers.get(0);
}
String provider = importProvider();
MuseAiImportLlmParser matched = matchProvider(parsers, provider);
if (matched != null) {
return matched;
}
// 配置的 provider 无对应实现:回退缺省 dify,再兜底首个,保证解析链路不空转
log.warn("AI 全书解析 provider 无匹配实现回退默认configuredProvider={}", provider);
MuseAiImportLlmParser fallback = matchProvider(parsers, DEFAULT_IMPORT_PROVIDER);
return fallback != null ? fallback : parsers.get(0);
}
private MuseAiImportLlmParser matchProvider(List<MuseAiImportLlmParser> parsers, String provider) {
for (MuseAiImportLlmParser parser : parsers) {
if (provider.equals(normalizeProvider(parser.providerKey()))) {
return parser;
}
}
return null;
}
private String importProvider() {
String configured = museAiProperties == null || museAiProperties.getImport() == null
? null : museAiProperties.getImport().getProvider();
String normalized = normalizeProvider(configured);
return StringUtils.hasText(normalized) ? normalized : DEFAULT_IMPORT_PROVIDER;
}
private String normalizeProvider(String provider) {
if (!StringUtils.hasText(provider)) {
return null;
}
// 统一小写并把下划线视作连字符,兼容 new_api / new-api / NEW_API 等写法
return provider.trim().toLowerCase(Locale.ROOT).replace('_', '-');
}
private ExtractedText extractText(String format, byte[] bytes) {

View File

@ -44,6 +44,8 @@ public class MuseAiRuntimeJobExecutor {
@Resource
private MuseAiRuntimePayloadStore runtimePayloadStore;
@Resource
private MuseAiGeneratedFulltextStore generatedFulltextStore;
@Resource
private KnowledgeRetrievalFacade knowledgeRetrievalFacade;
public MuseAiRuntimeClient.RuntimeResponse executeAiTaskJob(Long taskId, Long jobPkId) {
@ -90,10 +92,28 @@ public class MuseAiRuntimeJobExecutor {
}
response = normalizeRuntimeResponse(job, command, response);
runtimeCallRecorder.recordFinished(call, response);
// S7b拿到成功 RuntimeResult provider 完整正文仅内存字段 fullOutput写入瞬态全文 Storekey=taskId
// SSE 送达与断线重连回取此写入落瞬态加密表TTL+决定即清永不进候选/正文长期列
writeGeneratedFulltextIfPresent(task, job, response);
runtimeProjectionService.applyRuntimeResponse(task, job, response, command);
return response;
}
private void writeGeneratedFulltextIfPresent(MuseAiGenerationDO task, MuseAiJobDO job,
MuseAiRuntimeClient.RuntimeResponse response) {
// 仅真实生成任务task != null需要出向全文送达agent test 影子候选不走用户 SSE不写瞬态全文
if (task == null || response == null || response.result() == null) {
return;
}
String fullOutput = response.result().fullOutput();
if (!StringUtils.hasText(fullOutput)) {
return;
}
// 关键出向全文须存活到用户决定采纳/放弃故这里写入后不在终态 finally purge
// 现有 runtimePayloadStore.remove入向指令保持不动两个 Store 生命周期互不影响
generatedFulltextStore.write(runtimeTenantId(job), task.getId(), fullOutput);
}
private boolean requiresRuntimePayload(MuseAiRuntimeClient.RuntimeCommand command) {
if (command == null || command.inputSummary() == null) {
return false;

View File

@ -41,6 +41,7 @@ public class MuseAiRuntimeProjectionService {
private static final String STATUS_RUNNING = "running";
private static final String STATUS_STREAMING = "streaming";
private static final String SUGGESTION_PENDING = "pending";
private static final String EVENT_CHUNK = "chunk";
private static final String EVENT_DONE = "done";
private static final String EVENT_ERROR = "error";
@ -106,6 +107,11 @@ public class MuseAiRuntimeProjectionService {
job.setFinishedAt(succeeded || !retryable ? LocalDateTime.now() : null);
jobMapper.updateById(job);
if (task != null && (succeeded || !retryable)) {
// S7bdone 之前先发一条非终态 chunk 引用行sequence_no 排在 done SSE 送达完整正文
// 仅当 provider 完整正文fullOutput仅内存非空时发正文本体已由 executor 写入瞬态全文 Store此行只带引用
if (succeeded && result != null && StringUtils.hasText(result.fullOutput())) {
appendGeneratedFulltextChunkEvent(task, job);
}
appendTaskEvent(task, job, succeeded ? EVENT_DONE : EVENT_ERROR, terminalOrRetryStatus,
succeeded ? donePayload(result, suggestionId, qualityScores) : failureSummary(failure),
succeeded ? null : errorCode(failure), succeeded ? null : errorMessage(failure));
@ -253,8 +259,11 @@ public class MuseAiRuntimeProjectionService {
String candidateType) {
// 解析候选正文(脱敏 summary;完整 provider 原文按数据主权不落库)+ 真实:输出合规/静态检查/许可快照
String contentText = resolveContentText(result);
// S7a:上移到 provider 完整正文审对象取 result.fullOutput(仅内存绝不落库),缺省回退摘要(兼容 shadow/历史结果);
// content_snapshot.content 仍写脱敏摘要(见下方 contentSnapshot),完整正文不进任何列
String reviewText = reviewText(result, contentText);
MuseAiCandidateReviewService.CandidateReview review =
candidateReviewService.review(task.getId(), contentText, sourceLicenseRestrictions(task));
candidateReviewService.review(task.getId(), reviewText, sourceLicenseRestrictions(task));
MuseAiSuggestionDO suggestion = new MuseAiSuggestionDO();
suggestion.setWorkId(task.getWorkId());
suggestion.setChapterId(task.getChapterId());
@ -321,6 +330,43 @@ public class MuseAiRuntimeProjectionService {
|| (EVENT_ERROR.equals(eventType) && STATUS_FAILED.equals(taskStatus));
}
/**
* 追加一条 chunk 引用行S7b这是 {@link #appendTaskEvent} 终态门禁之外的显式放行路径
* chunk 非终态不受 uk_muse_ai_task_event_terminal 约束也不建 Events outboxoutbox 只承接终态事实
*
* <p><b>主权红线</b>payload_summary 只放指向瞬态全文 Store 的引用contentRef=taskId + sequenceNo
* 绝不放完整正文SSE 读侧遇到 contentRef 时回瞬态 Store 取正文填 chunk.data.content</p>
*/
private void appendGeneratedFulltextChunkEvent(MuseAiGenerationDO task, MuseAiJobDO job) {
// 终态已存在则不再补 chunk防御正常时序 chunk 早于 done此处必为 null
if (taskEventMapper.selectTerminalByTaskId(String.valueOf(task.getId())) != null) {
return;
}
MuseAiTaskEventDO latest = taskEventMapper.selectLatestByTaskId(String.valueOf(task.getId()));
long sequenceNo = latest == null || latest.getSequenceNo() == null ? 1L : latest.getSequenceNo() + 1;
MuseAiTaskEventDO event = new MuseAiTaskEventDO();
event.setTaskId(String.valueOf(task.getId()));
event.setSequenceNo(sequenceNo);
event.setEventType(EVENT_CHUNK);
event.setTaskStatus(STATUS_STREAMING);
event.setOwnerUserId(task.getOwnerUserId());
event.setActorUserId(job.getActorUserId());
event.setJobId(String.valueOf(job.getId()));
event.setCorrelationId(job.getCorrelationId());
event.setPayloadSummary(JsonUtils.toJsonString(chunkRefPayload(task.getId(), sequenceNo)));
event.setEmittedAt(LocalDateTime.now());
event.setTenantId(task.getTenantId());
taskEventMapper.insert(event);
}
/** chunk 引用载荷:只含指向瞬态全文 Store 的引用,永不含完整正文。 */
private Map<String, Object> chunkRefPayload(Long taskId, long sequenceNo) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("contentRef", taskId);
payload.put("sequenceNo", sequenceNo);
return payload;
}
private Map<String, Object> contentSnapshot(MuseAiRuntimeClient.RuntimeResult result, String sourceSnapshotId,
String contentText) {
Map<String, Object> snapshot = new LinkedHashMap<>();
@ -332,6 +378,17 @@ public class MuseAiRuntimeProjectionService {
return snapshot;
}
/**
* 输入:优先 provider 完整正文(result.fullOutput,仅内存不落库),为空时回退脱敏摘要
* 使先审后入真实审的是完整正文;fullOutput 缺省(shadow 候选/历史结果/测试桩)时按原摘要口径审,行为兼容
*/
private String reviewText(MuseAiRuntimeClient.RuntimeResult result, String fallbackSummary) {
if (result != null && StringUtils.hasText(result.fullOutput())) {
return result.fullOutput();
}
return fallbackSummary;
}
/** 从 runtime outputSummary 解析候选正文文本(优先 summary,再 content/text)。 */
private String resolveContentText(MuseAiRuntimeClient.RuntimeResult result) {
if (result == null || result.outputSummary() == null) {

View File

@ -50,6 +50,8 @@ public class MuseAiTaskStreamServiceImpl implements MuseAiTaskStreamService {
private MuseAiGenerationMapper generationMapper;
@Resource
private MuseAiTaskEventMapper taskEventMapper;
@Resource
private MuseAiGeneratedFulltextStore generatedFulltextStore;
@Resource(name = MUSE_AI_TASK_STREAM_EXECUTOR)
private AsyncTaskExecutor taskStreamExecutor;
@ -278,12 +280,29 @@ public class MuseAiTaskStreamServiceImpl implements MuseAiTaskStreamService {
private Map<String, Object> chunkData(MuseAiTaskEventDO persistedEvent, Map<String, Object> payload) {
Map<String, Object> data = new LinkedHashMap<>();
data.put("content", stringValue(payload.get("content"), ""));
data.put("content", resolveChunkContent(persistedEvent, payload));
// chunk.data.sequenceNo OpenAPI 已声明字段其他事件只能通过 SSE id 表达顺序
data.put("sequenceNo", persistedEvent.getSequenceNo());
return data;
}
/**
* 解析 chunk 正文S7bpayload contentRef 时回瞬态全文 Store 取完整正文正文不落事件表
* Store 缺失 purge/TTL 过期 空串用户已决定无害 contentRef 时按内联 content 透出兼容旧行
*/
private String resolveChunkContent(MuseAiTaskEventDO persistedEvent, Map<String, Object> payload) {
Object contentRef = payload.get("contentRef");
if (contentRef == null) {
return stringValue(payload.get("content"), "");
}
Long taskId = longValue(contentRef, null);
if (taskId == null) {
return "";
}
String fullText = generatedFulltextStore.read(persistedEvent.getTenantId(), taskId);
return fullText == null ? "" : fullText;
}
private Map<String, Object> qualityCheckData(Map<String, Object> payload) {
Map<String, Object> data = new LinkedHashMap<>();
data.put("dimension", stringValue(payload.get("dimension"), "quality"));

View File

@ -45,6 +45,8 @@ public class MuseSuggestionServiceImpl implements MuseSuggestionService {
private MuseAiAuditService auditService;
@Resource
private MuseContentWorkOwnerFacade workOwnerFacade;
@Resource
private MuseAiGeneratedFulltextStore generatedFulltextStore;
@Override
@Transactional(readOnly = true)
@ -86,6 +88,8 @@ public class MuseSuggestionServiceImpl implements MuseSuggestionService {
MuseAiSuggestionDO suggestion = requireSuggestion(suggestionId);
if (STATUS_REJECTED.equals(suggestion.getStatus())) {
commandService.recordSucceeded(envelope, rejectResponseSnapshot(suggestionId));
// S7b 决定即清幂等已拒态再次命中也确保瞬态出向全文被清除
generatedFulltextStore.purge(TenantContextHolder.getRequiredTenantId(), suggestion.getGenerationId());
return;
}
@ -111,6 +115,8 @@ public class MuseSuggestionServiceImpl implements MuseSuggestionService {
update.setStatus(STATUS_REJECTED);
update.setDecisionArchiveId(decision.getId());
suggestionMapper.updateById(update);
// S7b 决定即清用户已放弃物理清除该 taskId 的瞬态出向全文加密载荷
generatedFulltextStore.purge(TenantContextHolder.getRequiredTenantId(), suggestion.getGenerationId());
commandService.recordSucceeded(envelope, rejectResponseSnapshot(suggestionId));
auditService.record(MuseAiAuditService.AuditCreateReq.builder()
.operationId(OPERATION_REJECT)

View File

@ -48,6 +48,11 @@ public class NewApiMuseAiImportLlmParser implements MuseAiImportLlmParser {
.build();
}
@Override
public String providerKey() {
return "new-api";
}
@Override
public LlmParseResult parse(LlmParseCommand command) {
if (!isConfigured()) {

View File

@ -4,6 +4,7 @@ import cn.iocoder.muse.framework.common.util.json.JsonUtils;
import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.muse.module.ai.application.muse.MuseAiAuditService;
import cn.iocoder.muse.module.ai.application.muse.MuseAiCommandService;
import cn.iocoder.muse.module.ai.application.muse.MuseAiGeneratedFulltextStore;
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAiCommandDO;
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAiSuggestionDO;
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAiSuggestionDecisionDO;
@ -63,6 +64,8 @@ public class AiSuggestionMergeProjectionFacade implements ContentAiSuggestionFac
private MuseAiCommandService commandService;
@Resource
private MuseAiAuditService auditService;
@Resource
private MuseAiGeneratedFulltextStore generatedFulltextStore;
@Override
public SuggestionLookupResult getSuggestion(Long userId, Long workId, Long blockId, Long suggestionId) {
@ -207,6 +210,8 @@ public class AiSuggestionMergeProjectionFacade implements ContentAiSuggestionFac
.responseSummary(responseSnapshot)
.status("succeeded")
.build());
// S7b 决定即清用户已采纳 taskId 的瞬态出向全文使命完成立即物理清除加密载荷
generatedFulltextStore.purge(TenantContextHolder.getRequiredTenantId(), suggestion.getGenerationId());
return AcceptedDecisionResult.acceptedResult();
}

View File

@ -74,7 +74,40 @@ public interface MuseAiRuntimeClient {
Long costCents,
String providerRequestId,
String finishReason,
Instant completedAt) {
Instant completedAt,
// fullOutput 承载 provider 完整正文仅进程内传递 S7b 瞬态通路/SSE 送达
// 永不持久化到任何列/落库只用 outputSummary 摘要DO/mapper 一律不得引用此字段
String fullOutput) {
// 旧构造不带 fullOutput保持既有调用点shadow 候选历史测试桩不变完整正文缺省 null
// 须显式 publicrecord 内显式声明的构造不继承类型的 public缺省为包级跨包调用点会编译失败
public RuntimeResult(String correlationId,
String status,
Map<String, Object> outputSummary,
Map<String, Object> tokenUsage,
Long costCents,
String providerRequestId,
String finishReason,
Instant completedAt) {
this(correlationId, status, outputSummary, tokenUsage, costCents, providerRequestId,
finishReason, completedAt, null);
}
@Override
public String toString() {
// 主权红线完整正文只允许进程内传递record 默认 toString 会展开 fullOutput 明文
// 这里改为只暴露长度指纹杜绝完整正文经日志/异常泄漏对齐 RuntimeCommand 的脱敏范式
return "RuntimeResult[correlationId=" + correlationId
+ ", status=" + status
+ ", outputSummary=" + outputSummary
+ ", tokenUsage=" + tokenUsage
+ ", costCents=" + costCents
+ ", providerRequestId=" + providerRequestId
+ ", finishReason=" + finishReason
+ ", completedAt=" + completedAt
+ ", fullOutputLength=" + (fullOutput == null ? 0 : fullOutput.length())
+ "]";
}
}
record RuntimeFailure(String correlationId,

View File

@ -1,6 +1,7 @@
package cn.iocoder.muse.module.ai.application.muse.facade;
import cn.iocoder.muse.framework.common.util.json.JsonUtils;
import cn.iocoder.muse.module.ai.application.muse.MuseAiCandidateReviewService;
import cn.iocoder.muse.module.ai.framework.ai.config.MuseAiProperties;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.util.StringUtils;
@ -232,12 +233,19 @@ public class RealDifyMuseAiRuntimeClient implements MuseAiRuntimeClient {
return failure(command, "provider_bad_response", null, "AI_DIFY_BAD_RESPONSE",
"AI Dify response missing answer", false, null);
}
// S7a 护栏上移:Dify 完成原因是硬编码常量不可信,截断只能靠对完整正文本身校验;派生摘要前先过护栏
MuseAiCandidateReviewService.OutputGuardResult guard =
MuseAiCandidateReviewService.guardFullOutput(answer);
if (!guard.passed()) {
return guardFailure(command, guard);
}
String providerRequestId = firstText(root.path("message_id"), root.path("task_id"), root.path("id"));
Map<String, Object> outputSummary = outputSummary(command, providerRef, answer,
providerRequestId, "message");
// fullOutput=answer:仅进程内承载 Dify 完整正文( S7b);outputSummary 仍只带 80 字摘要落库
return new RuntimeResponse(new RuntimeResult(correlationId(command), "success", outputSummary,
tokenUsage(root.path("metadata").path("usage")), null, providerRequestId, "message",
Instant.now()), null);
Instant.now(), answer), null);
}
private RuntimeResponse workflowSuccess(RuntimeCommand command,
@ -255,12 +263,19 @@ public class RealDifyMuseAiRuntimeClient implements MuseAiRuntimeClient {
return failure(command, "provider_bad_response", null, "AI_DIFY_BAD_RESPONSE",
"AI Dify workflow response missing outputs", false, null);
}
// S7a 护栏上移:workflow status=succeeded 亦不足以证明正文完整,截断/合规须对完整正文本身校验
MuseAiCandidateReviewService.OutputGuardResult guard =
MuseAiCandidateReviewService.guardFullOutput(text);
if (!guard.passed()) {
return guardFailure(command, guard);
}
String providerRequestId = firstText(root.path("workflow_run_id"), root.path("task_id"),
data.path("id"), data.path("workflow_id"));
Map<String, Object> outputSummary = outputSummary(command, providerRef, text,
providerRequestId, "workflow");
// fullOutput=text:仅进程内承载 Dify workflow 完整正文( S7b);outputSummary 仍只带 80 字摘要落库
return new RuntimeResponse(new RuntimeResult(correlationId(command), "success", outputSummary,
workflowUsage(data), null, providerRequestId, status, Instant.now()), null);
workflowUsage(data), null, providerRequestId, status, Instant.now(), text), null);
}
private Map<String, Object> outputSummary(RuntimeCommand command,
@ -388,6 +403,14 @@ public class RealDifyMuseAiRuntimeClient implements MuseAiRuntimeClient {
errorCode, sanitizeMessage(sanitizedMessage), retryable, nextRetryAt, Instant.now()));
}
private RuntimeResponse guardFailure(RuntimeCommand command,
MuseAiCandidateReviewService.OutputGuardResult guard) {
// 完整正文护栏不通过按可重试处理(裁决);只落脱敏错误码,绝不带完整正文
String errorCode = guard.truncated() ? "AI_DIFY_OUTPUT_TRUNCATED" : "AI_DIFY_OUTPUT_NONCOMPLIANT";
return failure(command, "provider_bad_response", null, errorCode,
"AI Dify output failed integrity or compliance guard", true, null);
}
private String outputText(JsonNode outputs) {
if (outputs == null || outputs.isMissingNode() || outputs.isNull()) {
return null;

View File

@ -1,6 +1,7 @@
package cn.iocoder.muse.module.ai.application.muse.facade;
import cn.iocoder.muse.framework.common.util.json.JsonUtils;
import cn.iocoder.muse.module.ai.application.muse.MuseAiCandidateReviewService;
import cn.iocoder.muse.module.ai.framework.ai.config.MuseAiProperties;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.util.StringUtils;
@ -173,6 +174,13 @@ public class RealNewApiMuseAiRuntimeClient implements MuseAiRuntimeClient {
if (!StringUtils.hasText(summary)) {
return badResponse(command, response.statusCode());
}
// S7a 护栏上移:在派生 60 字摘要之前, provider 完整正文做完整性/截断 + 凭据合规扫描;
// 疑似截断或含凭据痕迹即判失败可重试,不可用/不合规的完整正文绝不塞进 fullOutput 透出下游
MuseAiCandidateReviewService.OutputGuardResult guard =
MuseAiCandidateReviewService.guardFullOutput(content);
if (!guard.passed()) {
return guardFailure(command, response.statusCode(), guard);
}
String finishReason = choice == null ? null : textOrNull(choice.path("finish_reason"));
Map<String, Object> outputSummary = new LinkedHashMap<>();
outputSummary.put("summary", summary);
@ -183,14 +191,24 @@ public class RealNewApiMuseAiRuntimeClient implements MuseAiRuntimeClient {
command == null ? null : command.runtimePermissionEnvelopeId());
outputSummary.put("finishReason", finishReason);
String providerRequestId = response.headers().firstValue("X-Oneapi-Request-Id").orElse(null);
// fullOutput=content:仅进程内承载 provider 完整正文( S7b 瞬态通路);outputSummary 仍只带 60 字摘要落库
return new RuntimeResponse(new RuntimeResult(correlationId(command), "success", outputSummary,
tokenUsage(root.path("usage")), null, providerRequestId, finishReason, Instant.now()), null);
tokenUsage(root.path("usage")), null, providerRequestId, finishReason, Instant.now(), content), null);
} catch (RuntimeException ex) {
return failure(command, "provider_bad_response", response.statusCode(), "AI_NEW_API_BAD_RESPONSE",
"AI New-API response could not be parsed", false, null);
}
}
private RuntimeResponse guardFailure(RuntimeCommand command, Integer providerStatusCode,
MuseAiCandidateReviewService.OutputGuardResult guard) {
// 完整正文护栏不通过按可重试处理(裁决):截断重生成可能补全;含凭据重生成可能不再泄漏
// 只落脱敏错误码,绝不带完整正文;failureType provider_bad_response(不在 nonRetryable ,retryable 由本标志决定)
String errorCode = guard.truncated() ? "AI_NEW_API_OUTPUT_TRUNCATED" : "AI_NEW_API_OUTPUT_NONCOMPLIANT";
return failure(command, "provider_bad_response", providerStatusCode, errorCode,
"AI New-API output failed integrity or compliance guard", true, null);
}
private RuntimeResponse badResponse(RuntimeCommand command, Integer providerStatusCode) {
return failure(command, "provider_bad_response", providerStatusCode, "AI_NEW_API_BAD_RESPONSE",
"AI New-API response missing assistant content", false, null);

View File

@ -19,10 +19,15 @@ public class RoutingMuseAiRuntimeClient implements MuseAiRuntimeClient {
private final MuseAiRuntimeClient newApiClient;
private final MuseAiRuntimeClient difyClient;
private final boolean difyAvailable;
public RoutingMuseAiRuntimeClient(MuseAiRuntimeClient newApiClient, MuseAiRuntimeClient difyClient) {
this.newApiClient = newApiClient == null ? new UnavailableMuseAiRuntimeClient() : newApiClient;
this.difyClient = difyClient == null ? null : difyClient;
this.difyClient = difyClient;
// Dify 槽位未装配(null)或仅装配为占位 Unavailable adapter ,一律视为 Dify 不可用
// WHY 单列 Unavailable:占位 Unavailable 会返回 New-API 语义错误码(AI_NEW_API_UNAVAILABLE),
// Dify 路径具误导性;S7d 要求选中 dify 必须失败关闭并抛 Dify 专属错误,绝不回退 New-API 伪成功
this.difyAvailable = difyClient != null && !(difyClient instanceof UnavailableMuseAiRuntimeClient);
}
@Override
@ -38,7 +43,9 @@ public class RoutingMuseAiRuntimeClient implements MuseAiRuntimeClient {
return failure(command, "provider_bad_request", "AI_AGENT_DIFY_REF_INVALID",
"Dify runtime cannot satisfy Muse source attribution contract", false);
}
if (difyClient == null) {
// fail-closed:Dify 未装配/装配为 Unavailable/凭据缺失 拒绝,绝不回退 New-API
// (凭据/appId 缺失等细分错误由 difyClient 内部 validateConfig 失败关闭并返回 Dify 专属错误码)
if (!difyAvailable) {
return failure(command, "runtime_unavailable", "AI_DIFY_UNAVAILABLE",
"AI Dify runtime unavailable", false);
}

View File

@ -0,0 +1,44 @@
package cn.iocoder.muse.module.ai.dal.dataobject.muse;
import cn.iocoder.muse.framework.mybatis.core.type.EncryptTypeHandler;
import cn.iocoder.muse.framework.tenant.core.db.TenantBaseDO;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import java.time.LocalDateTime;
/**
* Muse AI 瞬态出向完整正文 DOS7b
*
* <p>只承载本次生成的完整正文 SSE 送达与断线重连fulltextContent 通过 MyBatis AES TypeHandler 加密
* TTL 到期或用户决定采纳/放弃即物理删除查询投影/审计只使用 length/hash不读取 fulltextContent</p>
*
* <p><b>主权红线</b>这是瞬态出向载体非系统记录永不作为正文来源落 Canonicalmuse_content_block或候选表
* muse_ai_suggestion任何长期列</p>
*/
@TableName(value = "muse_ai_generated_fulltext", autoResultMap = true)
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true, exclude = "fulltextContent")
public class MuseAiGeneratedFulltextDO extends TenantBaseDO {
@TableId(type = IdType.AUTO)
private Long id;
/** 生成任务标识(= muse_ai_generation 主键,与 task_event.task_id 同源)。 */
private Long taskId;
/** provider 完整正文,加密存储;仅瞬态出向,永不落任何长期列。 */
@TableField(typeHandler = EncryptTypeHandler.class)
private String fulltextContent;
private Integer contentLength;
private String contentHash;
private LocalDateTime expiresAt;
}

View File

@ -0,0 +1,43 @@
package cn.iocoder.muse.module.ai.dal.mysql.muse;
import cn.iocoder.muse.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.muse.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAiGeneratedFulltextDO;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDateTime;
/**
* Muse AI 瞬态出向完整正文 MapperS7b
*/
@Mapper
public interface MuseAiGeneratedFulltextMapper extends BaseMapperX<MuseAiGeneratedFulltextDO> {
default MuseAiGeneratedFulltextDO selectActiveByTaskId(Long tenantId, Long taskId) {
return selectOne(new LambdaQueryWrapperX<MuseAiGeneratedFulltextDO>()
.eq(MuseAiGeneratedFulltextDO::getTenantId, tenantId)
.eq(MuseAiGeneratedFulltextDO::getTaskId, taskId)
// TTL 过期即视为不存在 V36 cleanup 语义一致避免 SSE 回取到过期正文
.gt(MuseAiGeneratedFulltextDO::getExpiresAt, LocalDateTime.now())
// active 查询与 V36 partial index 条件一致软删/兜底残留一律不可读
.eq(MuseAiGeneratedFulltextDO::getDeleted, false)
.last("LIMIT 1"));
}
@Delete("""
DELETE FROM muse_ai_generated_fulltext
WHERE tenant_id = #{tenantId}
AND task_id = #{taskId}
""")
int deleteByTaskId(@Param("tenantId") Long tenantId, @Param("taskId") Long taskId);
@Delete("""
DELETE FROM muse_ai_generated_fulltext
WHERE expires_at <= CURRENT_TIMESTAMP
OR deleted = TRUE
""")
int cleanupResidual();
}

View File

@ -1,6 +1,8 @@
package cn.iocoder.muse.module.ai.framework.ai.config;
import lombok.AccessLevel;
import lombok.Data;
import lombok.Getter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.List;
@ -75,6 +77,22 @@ public class MuseAiProperties {
*/
private Dify dify = new Dify();
/**
* Muse 导入全书解析 provider 选择(S8)
*
* <p>字段名不能直接叫 {@code import}(Java 关键字),故手写 {@link #getImport()}
* {@code muse.ai.import.provider} 走标量 relaxed-binding( {@code MUSE_AI_IMPORT_PROVIDER})</p>
*/
@Getter(AccessLevel.NONE)
private final ImportParse importParse = new ImportParse();
/**
* 绑定 {@code muse.ai.import.*};返回固定实例,provider 缺省 dify
*/
public ImportParse getImport() {
return importParse;
}
/**
* Muse Events 发布配置
*/
@ -267,9 +285,21 @@ public class MuseAiProperties {
private List<Credential> credentials = List.of();
private Integer connectTimeoutSeconds = 5;
private Integer nonStreamReadTimeoutSeconds = 90;
// 生成主链切 Dify(S7d)默认对齐 180s 总预算:实际请求超时取 min(该值, 总预算),
// 若低于总预算会把长篇生成截短,故默认 180( < SSE 死线 240s)
private Integer nonStreamReadTimeoutSeconds = 180;
private Integer totalTimeoutSeconds = 180;
/**
* 全书解析(S8)可重试失败的最大尝试次数408/429/5xx 退避重试,401/403 fail-closed 不重试
*/
private Integer maxAttempts = 3;
/**
* 各次重试前的退避秒数;下标对应第几次重试,超出取最后一档
*/
private List<Integer> retryBackoffSeconds = List.of(1, 2, 4);
@Data
public static class Credential {
@ -330,4 +360,16 @@ public class MuseAiProperties {
}
@Data
public static class ImportParse {
/**
* 导入全书解析 provider,缺省 dify;可用 MUSE_AI_IMPORT_PROVIDER 覆盖为 new-api
*
* <p>取值经服务层归一化(小写下划线视作连字符), new_api / new-api 等价</p>
*/
private String provider = "dify";
}
}

View File

@ -122,37 +122,50 @@ spring:
host: 127.0.0.1
port: 19530
qianfan: # 文心一言
api-key: x0cuLZ7XsaTCU08vuJWO87Lg
secret-key: R9mYF9dl9KASgi5RUq0FQt3wRisSnOcK
api-key: ${MUSE_AI_QIANFAN_API_KEY:}
secret-key: ${MUSE_AI_QIANFAN_SECRET_KEY:}
zhipuai: # 智谱 AI
api-key: 32f84543e54eee31f8d56b2bd6020573.3vh9idLJZ2ZhxDEs
api-key: ${MUSE_AI_ZHIPUAI_API_KEY:}
openai: # OpenAI 官方
api-key: sk-aN6nWn3fILjrgLFT0fC4Aa60B72e4253826c77B29dC94f17
base-url: https://api.gptsapi.net
api-key: ${MUSE_AI_OPENAI_API_KEY:}
base-url: ${MUSE_AI_OPENAI_BASE_URL:https://api.openai.com}
azure: # OpenAI 微软
openai:
endpoint: https://eastusprejade.openai.azure.com
anthropic: # Anthropic Claude
api-key: sk-muubv7cXeLw0Etgs743f365cD5Ea44429946Fa7e672d8942
api-key: ${MUSE_AI_ANTHROPIC_API_KEY:}
ollama:
base-url: http://127.0.0.1:11434
chat:
model: llama3
stabilityai:
api-key: sk-e53UqbboF8QJCscYvzJscJxJXoFcFg4iJjl1oqgE7baJETmx
api-key: ${MUSE_AI_STABILITYAI_API_KEY:}
dashscope: # 通义千问
api-key: sk-47aa124781be4bfb95244cc62f6xxxx
api-key: ${MUSE_AI_DASHSCOPE_API_KEY:}
agent:
enabled: false
minimax: # Minimaxhttps://www.minimaxi.com/
api-key: xxxx
api-key: ${MUSE_AI_MINIMAX_API_KEY:}
moonshot: # 月之暗面KIMI
api-key: sk-abc
api-key: ${MUSE_AI_MOONSHOT_API_KEY:}
deepseek: # DeepSeek
api-key: sk-e94db327cc7d457d99a8de8810fc6b12
api-key: ${MUSE_AI_DEEPSEEK_API_KEY:}
chat:
options:
model: deepseek-chat
model:
rerank: false # 是否开启“通义千问”的 Rerank 模型,填写 dashscope 开启
chat: none
embedding: none
image: none
rerank: none
# moderation 的自装配开关默认 matchIfMissing=true → 不显式关停时 OpenAiModerationAutoConfiguration
# 会在启动期实例化 openAiModerationModel无 OpenAI key 直接抛 “OpenAI API key must be set” 使单体启动失败。
# 单人形态输出合规走自研 MuseAiCandidateReviewService不用 Spring AI moderation故一并置 none。
moderation: none
video: none
audio:
speech: none
transcription: none
mcp:
server:
enabled: false
@ -174,43 +187,43 @@ spring:
muse:
ai:
gemini: # 谷歌 Gemini
enable: true
api-key: AIzaSyAVoBxgoFvvte820vEQMma2LKBnC98bqMQ
enable: false
api-key: ${MUSE_AI_GEMINI_API_KEY:}
model: gemini-2.5-flash
doubao: # 字节豆包
enable: true
api-key: 5c1b5747-26d2-4ebd-a4e0-dd0e8d8b4272
enable: false
api-key: ${MUSE_AI_DOUBAO_API_KEY:}
model: doubao-1-5-lite-32k-250115
hunyuan: # 腾讯混元
enable: true
api-key: sk-abc
enable: false
api-key: ${MUSE_AI_HUNYUAN_API_KEY:}
model: hunyuan-turbo
siliconflow: # 硅基流动
enable: true
api-key: sk-epsakfenqnyzoxhmbucsxlhkdqlcbnimslqoivkshalvdozz
enable: false
api-key: ${MUSE_AI_SILICONFLOW_API_KEY:}
model: deepseek-ai/DeepSeek-R1-Distill-Qwen-7B
xinghuo: # 讯飞星火
enable: true
appKey: 75b161ed2aef4719b275d6e7f2a4d4cd
secretKey: YWYxYWI2MTA4ODI2NGZlYTQyNjAzZTcz
enable: false
appKey: ${MUSE_AI_XINGHUO_APP_KEY:}
secretKey: ${MUSE_AI_XINGHUO_SECRET_KEY:}
model: x1
baichuan: # 百川智能
enable: true
api-key: sk-abc
enable: false
api-key: ${MUSE_AI_BAICHUAN_API_KEY:}
model: Baichuan4-Turbo
midjourney:
enable: true
enable: false
# base-url: https://api.holdai.top/mj-relax/mj
base-url: https://api.holdai.top/mj
api-key: sk-dZEPiVaNcT3FHhef51996bAa0bC74806BeAb620dA5Da10Bf
notify-url: http://java.nat300.top/admin-api/ai/image/midjourney/notify
base-url: ${MUSE_AI_MIDJOURNEY_BASE_URL:}
api-key: ${MUSE_AI_MIDJOURNEY_API_KEY:}
notify-url: ${MUSE_AI_MIDJOURNEY_NOTIFY_URL:}
suno:
enable: true
enable: false
# base-url: https://suno-55ishh05u-status2xxs-projects.vercel.app
base-url: http://127.0.0.1:3001
base-url: ${MUSE_AI_SUNO_BASE_URL:}
web-search:
enable: true
api-key: sk-40500e52840f4d24b956d0b1d80d9abe
enable: false
api-key: ${MUSE_AI_WEB_SEARCH_API_KEY:}
new-api:
enabled: ${MUSE_AI_NEW_API_ENABLED:false}
base-url: ${MUSE_AI_NEW_API_BASE_URL:}
@ -225,6 +238,20 @@ muse:
max-attempts: ${MUSE_AI_NEW_API_MAX_ATTEMPTS:3}
retry-backoff-seconds: ${MUSE_AI_NEW_API_RETRY_BACKOFF_SECONDS:1,2,4}
propagate-muse-context: ${MUSE_AI_NEW_API_PROPAGATE_MUSE_CONTEXT:true}
dify:
enabled: ${MUSE_AI_DIFY_ENABLED:false}
base-url: ${MUSE_AI_DIFY_BASE_URL:}
console-base-urls: ${MUSE_AI_DIFY_CONSOLE_BASE_URLS:}
credentials:
- ref: ${MUSE_AI_DIFY_WRITING_CREDENTIAL_REF:dify-writing-s1}
api-key: ${MUSE_AI_DIFY_WRITING_API_KEY:}
- ref: ${MUSE_AI_DIFY_PARSER_CREDENTIAL_REF:dify-parser-s1}
api-key: ${MUSE_AI_DIFY_PARSER_API_KEY:}
connect-timeout-seconds: ${MUSE_AI_DIFY_CONNECT_TIMEOUT_SECONDS:5}
# 生成主链切 Dify(S7d):短读超时对齐 180s 总预算。WHY 不是 90:实际请求超时取 min(非流式短读, 总预算),
# 90 会把长篇生成截到 90s;须 ≥ 总预算(180s)且 < SSE 死线(240s)才能让上游先出话、后端不提前关门。
non-stream-read-timeout-seconds: ${MUSE_AI_DIFY_NON_STREAM_READ_TIMEOUT_SECONDS:180}
total-timeout-seconds: ${MUSE_AI_DIFY_TOTAL_TIMEOUT_SECONDS:180}
--- #################### 芋道相关配置 ####################

View File

@ -0,0 +1,217 @@
package cn.iocoder.muse.module.ai.application.muse;
import cn.iocoder.muse.framework.common.util.json.JsonUtils;
import cn.iocoder.muse.module.ai.framework.ai.config.MuseAiProperties;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Dify 导入解析器契约测试
*
* <p>用本地 HttpServer Dify chat blocking 端点(/chat-messages),覆盖成功多章鉴权 fail-closed 不重试
* 限流重试后成功5xx 可重试耗尽answer JSON 判截断空章节未配置 fail-closed</p>
*/
class DifyMuseAiImportLlmParserTest {
private HttpServer server;
private final AtomicInteger requestCount = new AtomicInteger();
@AfterEach
void tearDown() {
if (server != null) {
server.stop(0);
}
}
@Test
void should_parseMultipleChaptersOnChatSuccess() throws Exception {
startServer(exchange -> {
requestCount.incrementAndGet();
writeJson(exchange, 200, chatAnswerBody(twoChaptersAnswerJson()));
});
DifyMuseAiImportLlmParser parser = new DifyMuseAiImportLlmParser(difyProperties(3, List.of(0, 0)));
MuseAiImportLlmParser.LlmParseResult result = parser.parse(command());
assertTrue(result.success());
assertEquals(1, requestCount.get());
assertEquals(2, result.chapters().size());
assertEquals("第一章", result.chapters().getFirst().title());
assertEquals("dify_import_llm", result.summary().get("parser"));
assertEquals(2, result.summary().get("chapterCount"));
}
@Test
void should_notRetryAuthFailure() throws Exception {
startServer(exchange -> {
requestCount.incrementAndGet();
writeJson(exchange, 401, "{\"error\":\"unauthorized\"}");
});
DifyMuseAiImportLlmParser parser = new DifyMuseAiImportLlmParser(difyProperties(3, List.of(0, 0)));
MuseAiImportLlmParser.LlmParseResult result = parser.parse(command());
assertFalse(result.success());
assertEquals("AI_IMPORT_LLM_AUTH_FAILED", result.errorCode());
assertFalse(result.retryable());
assertEquals(1, requestCount.get(), "鉴权失败不可重试");
}
@Test
void should_retryRateLimitedThenReturnChapters() throws Exception {
startServer(exchange -> {
int count = requestCount.incrementAndGet();
if (count == 1) {
writeJson(exchange, 429, "{\"error\":\"rate limited\"}");
return;
}
writeJson(exchange, 200, chatAnswerBody(twoChaptersAnswerJson()));
});
DifyMuseAiImportLlmParser parser = new DifyMuseAiImportLlmParser(difyProperties(3, List.of(0, 0)));
MuseAiImportLlmParser.LlmParseResult result = parser.parse(command());
assertTrue(result.success());
assertEquals(2, requestCount.get(), "429 应自动重试一次后成功");
assertEquals(2, result.summary().get("attemptNo"));
}
@Test
void should_retryProvider5xxUntilExhausted() throws Exception {
// chat app workflow status 概念;HTTP 5xx 归为 provider 5xx 可重试,耗尽后仍以可重试失败返回
startServer(exchange -> {
requestCount.incrementAndGet();
writeJson(exchange, 502, "{\"error\":\"bad gateway\"}");
});
DifyMuseAiImportLlmParser parser = new DifyMuseAiImportLlmParser(difyProperties(2, List.of(0, 0)));
MuseAiImportLlmParser.LlmParseResult result = parser.parse(command());
assertFalse(result.success());
assertEquals("AI_IMPORT_LLM_PROVIDER_5XX", result.errorCode());
assertTrue(result.retryable());
assertEquals(2, requestCount.get(), "5xx 应重试到 maxAttempts");
assertEquals(2, result.summary().get("attemptNo"));
}
@Test
void should_failTruncatedWhenAnswerNotValidJson() throws Exception {
startServer(exchange -> {
requestCount.incrementAndGet();
// answer 是不完整 JSON:Dify chat finish_reason, JSON 完整性判定输出被截断
writeJson(exchange, 200, chatAnswerBody("{\"chapters\":[{\"title\":\"第一章\",\"content\":\"正文"));
});
DifyMuseAiImportLlmParser parser = new DifyMuseAiImportLlmParser(difyProperties(3, List.of(0, 0)));
MuseAiImportLlmParser.LlmParseResult result = parser.parse(command());
assertFalse(result.success());
assertEquals("AI_IMPORT_LLM_TRUNCATED", result.errorCode());
assertFalse(result.retryable());
}
@Test
void should_failNoChaptersWhenChaptersEmpty() throws Exception {
startServer(exchange -> {
requestCount.incrementAndGet();
writeJson(exchange, 200, chatAnswerBody("{\"chapters\":[]}"));
});
DifyMuseAiImportLlmParser parser = new DifyMuseAiImportLlmParser(difyProperties(3, List.of(0, 0)));
MuseAiImportLlmParser.LlmParseResult result = parser.parse(command());
assertFalse(result.success());
assertEquals("AI_IMPORT_LLM_NO_CHAPTERS", result.errorCode());
assertFalse(result.retryable());
}
@Test
void should_failUnavailableWhenNotConfigured() {
// Dify 未启用(默认 enabled=false):fail-closed,不发起任何 HTTP
DifyMuseAiImportLlmParser parser = new DifyMuseAiImportLlmParser(new MuseAiProperties());
MuseAiImportLlmParser.LlmParseResult result = parser.parse(command());
assertFalse(result.success());
assertEquals("AI_IMPORT_LLM_UNAVAILABLE", result.errorCode());
assertFalse(result.retryable());
assertEquals(0, requestCount.get());
}
private MuseAiImportLlmParser.LlmParseCommand command() {
return new MuseAiImportLlmParser.LlmParseCommand(
"cmd-dify-import-parser-test",
1L,
9001L,
10001L,
"book.txt",
"txt",
"第一章\n正文。",
"sha256:test",
Map.of("mode", "full_book", "strategy", "llm_full_book"));
}
private MuseAiProperties difyProperties(int maxAttempts, List<Integer> backoffSeconds) {
MuseAiProperties properties = new MuseAiProperties();
MuseAiProperties.Dify dify = new MuseAiProperties.Dify();
dify.setEnabled(true);
dify.setBaseUrl("http://127.0.0.1:" + server.getAddress().getPort());
MuseAiProperties.Dify.Credential credential = new MuseAiProperties.Dify.Credential();
credential.setRef("dify-parser-s1");
credential.setApiKey("test-dify-key");
dify.setCredentials(List.of(credential));
dify.setMaxAttempts(maxAttempts);
dify.setRetryBackoffSeconds(backoffSeconds);
dify.setConnectTimeoutSeconds(1);
dify.setNonStreamReadTimeoutSeconds(5);
properties.setDify(dify);
return properties;
}
private String twoChaptersAnswerJson() {
return JsonUtils.toJsonString(Map.of("chapters", List.of(
Map.of("title", "第一章", "content", "正文一。"),
Map.of("title", "第二章", "content", "正文二。"))));
}
private String chatAnswerBody(String answerJson) {
// Dify chat blocking 响应形状:answer 承载模型产出的章节 JSON 字符串
return JsonUtils.toJsonString(Map.of(
"answer", answerJson,
"message_id", "msg-dify-test",
"metadata", Map.of()));
}
private void startServer(ExchangeHandler handler) throws IOException {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/chat-messages", handler::handle);
server.start();
}
private void writeJson(HttpExchange exchange, int status, String responseBody) throws IOException {
byte[] bytes = responseBody.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(status, bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
}
@FunctionalInterface
private interface ExchangeHandler {
void handle(HttpExchange exchange) throws IOException;
}
}

View File

@ -60,4 +60,60 @@ class MuseAiCandidateReviewServiceTest {
assertFalse(review.passed());
assertEquals(List.of(), review.licenseRestrictions());
}
// >60 字且无句末标点 疑似截断;不含任何凭据痕迹
private static final String TRUNCATED_FULL_TEXT =
"雨落在青石板上溅起细小的水花她撑着伞站在巷口望着远处那扇熟悉的木门心里翻涌着说不清的情绪"
+ "许多年前的往事一幕幕浮现她深吸一口气终于抬起脚步朝着那扇门缓缓走了过去然后她";
// 同样长度以句号收尾 结构完整
private static final String COMPLETE_FULL_TEXT = TRUNCATED_FULL_TEXT + "轻轻推开了那扇门。";
@Test
void should_guardFullOutput_passForCompleteText() {
MuseAiCandidateReviewService.OutputGuardResult guard =
MuseAiCandidateReviewService.guardFullOutput(COMPLETE_FULL_TEXT);
assertTrue(guard.passed(), "结构完整、无凭据痕迹的完整正文应通过护栏");
assertFalse(guard.truncated());
}
@Test
void should_guardFullOutput_failForTruncatedText() {
MuseAiCandidateReviewService.OutputGuardResult guard =
MuseAiCandidateReviewService.guardFullOutput(TRUNCATED_FULL_TEXT);
assertFalse(guard.passed(), "达可判长度却无句末标点的完整正文应判疑似截断");
assertTrue(guard.truncated());
assertEquals("truncated", guard.reasonCode());
}
@Test
void should_guardFullOutput_failForCredentialMarker() {
MuseAiCandidateReviewService.OutputGuardResult guard =
MuseAiCandidateReviewService.guardFullOutput("这里混入了 api_key 泄露的痕迹。");
assertFalse(guard.passed(), "含凭据痕迹的完整正文不得通过护栏");
assertFalse(guard.truncated(), "凭据泄露不应被误判为截断");
assertEquals("credential_marker", guard.reasonCode());
}
@Test
void should_guardFullOutput_failForEmpty() {
assertFalse(MuseAiCandidateReviewService.guardFullOutput(" ").passed());
assertFalse(MuseAiCandidateReviewService.guardFullOutput(null).passed());
}
@Test
void should_fail_review_forTruncatedFullText() {
// 上移到完整正文后,疑似截断的完整正文不得通过 不给审结果 ID 候选不可合并
CandidateReview review = service.review(1L, TRUNCATED_FULL_TEXT, List.of());
assertFalse(review.passed(), "疑似截断的完整正文不应通过静态检查");
assertNull(review.outputComplianceResultId());
assertNull(review.staticCheckResultId());
}
@Test
void should_pass_review_forCompleteFullText() {
CandidateReview review = service.review(1L, COMPLETE_FULL_TEXT, List.of());
assertTrue(review.passed(), "结构完整的完整正文应通过审查");
assertNotNull(review.outputComplianceResultId());
assertNotNull(review.staticCheckResultId());
}
}

View File

@ -0,0 +1,98 @@
package cn.iocoder.muse.module.ai.application.muse;
import cn.iocoder.muse.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAiGeneratedFulltextDO;
import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseAiGeneratedFulltextMapper;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 瞬态出向完整正文 Store 测试S7b
*
* <p> Mockito 单测不落库加密 TypeHandler 与真实 TTL SQL real-PG 层验证
* 只断言 write加密行+TTLread缺失返回空purge物理删除以及入参非法时的 no-op 语义</p>
*/
class MuseAiGeneratedFulltextStoreTest extends BaseMockitoUnitTest {
private static final String FULL_TEXT = "这是一段完整的 AI 生成正文,长度远超 60 字脱敏摘要阈值,只应存在于瞬态加密表与 SSE 传输中。";
@InjectMocks
private MuseAiGeneratedFulltextStore store;
@Mock
private MuseAiGeneratedFulltextMapper generatedFulltextMapper;
@Test
void should_writeEncryptedRowWithTtl_when_fullTextPresent() {
store.write(100L, 3001L, FULL_TEXT);
ArgumentCaptor<MuseAiGeneratedFulltextDO> captor = ArgumentCaptor.forClass(MuseAiGeneratedFulltextDO.class);
// 幂等覆写cleanup + 先删同 key 旧行 + 插入
verify(generatedFulltextMapper).cleanupResidual();
verify(generatedFulltextMapper).deleteByTaskId(100L, 3001L);
verify(generatedFulltextMapper).insert(captor.capture());
MuseAiGeneratedFulltextDO row = captor.getValue();
assertEquals(Long.valueOf(100L), row.getTenantId());
assertEquals(Long.valueOf(3001L), row.getTaskId());
assertEquals(FULL_TEXT, row.getFulltextContent());
assertEquals(Integer.valueOf(FULL_TEXT.length()), row.getContentLength());
assertEquals(64, row.getContentHash().length());
// TTL 必须落在未来决定即清之前存活 SSE 送达与断线重连
assertTrue(row.getExpiresAt().isAfter(LocalDateTime.now()));
}
@Test
void should_noop_when_writeArgsInvalid() {
store.write(null, 3001L, FULL_TEXT);
store.write(100L, null, FULL_TEXT);
store.write(100L, 3001L, " ");
verify(generatedFulltextMapper, never()).insert(any(MuseAiGeneratedFulltextDO.class));
verify(generatedFulltextMapper, never()).deleteByTaskId(any(), any());
}
@Test
void should_readPlainText_when_activeRowExists() {
MuseAiGeneratedFulltextDO row = new MuseAiGeneratedFulltextDO();
row.setFulltextContent(FULL_TEXT);
when(generatedFulltextMapper.selectActiveByTaskId(100L, 3001L)).thenReturn(row);
assertEquals(FULL_TEXT, store.read(100L, 3001L));
}
@Test
void should_returnNull_when_rowMissingOrExpiredOrArgsNull() {
// TTL 过期/ purge mapper active 查询返回 null read 返回 nullSSE 侧据此填空串
when(generatedFulltextMapper.selectActiveByTaskId(100L, 3001L)).thenReturn(null);
assertNull(store.read(100L, 3001L));
assertNull(store.read(null, 3001L));
assertNull(store.read(100L, null));
}
@Test
void should_physicallyDeleteRow_when_purge() {
store.purge(100L, 3001L);
verify(generatedFulltextMapper).deleteByTaskId(100L, 3001L);
}
@Test
void should_noop_when_purgeArgsNull() {
store.purge(null, 3001L);
store.purge(100L, null);
verify(generatedFulltextMapper, never()).deleteByTaskId(any(), any());
}
}

View File

@ -8,6 +8,7 @@ import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAiChapterParseResultDO;
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAiParseJobDO;
import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseAiChapterParseResultMapper;
import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseAiParseJobMapper;
import cn.iocoder.muse.module.ai.framework.ai.config.MuseAiProperties;
import cn.iocoder.muse.module.infra.api.file.FileApi;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@ -65,7 +66,8 @@ class MuseAiImportParseServiceTest extends BaseMockitoUnitTest {
ReflectionTestUtils.setField(service, "parseJobMapper", parseJobMapper);
ReflectionTestUtils.setField(service, "chapterResultMapper", chapterResultMapper);
ReflectionTestUtils.setField(service, "contentWorkOwnerFacade", contentWorkOwnerFacade);
ReflectionTestUtils.setField(service, "llmParser", llmParser);
// 单实现注入:size==1 resolveImportLlmParser 直接返回,不读 provider 配置,兼容既有用例
ReflectionTestUtils.setField(service, "importLlmParsers", List.of(llmParser));
}
@Test
@ -271,6 +273,77 @@ class MuseAiImportParseServiceTest extends BaseMockitoUnitTest {
verify(parseJobMapper).updateReviewCounters(JOB_ID, 1, 1);
}
@Test
void should_selectDifyParserWhenProviderIsDify() {
MuseAiImportLlmParser newApiParser = org.mockito.Mockito.mock(MuseAiImportLlmParser.class);
MuseAiImportLlmParser difyParser = org.mockito.Mockito.mock(MuseAiImportLlmParser.class);
org.mockito.Mockito.lenient().when(newApiParser.providerKey()).thenReturn("new-api");
org.mockito.Mockito.lenient().when(difyParser.providerKey()).thenReturn("dify");
ReflectionTestUtils.setField(service, "importLlmParsers", List.of(newApiParser, difyParser));
ReflectionTestUtils.setField(service, "museAiProperties", importProviderProperties("dify"));
stubFullBookInsert();
when(difyParser.parse(any(MuseAiImportLlmParser.LlmParseCommand.class))).thenReturn(
MuseAiImportLlmParser.LlmParseResult.success(
List.of(new MuseAiImportLlmParser.LlmChapter("Dify 章节", "Dify 切出的正文。")),
Map.of("parser", "dify_import_llm")));
MuseAiImportParseApi.ParseJobProjection result = service.createParseJob(createCommand("txt", "full_book"));
assertEquals("completed", result.status());
verify(difyParser).parse(any(MuseAiImportLlmParser.LlmParseCommand.class));
verify(newApiParser, never()).parse(any(MuseAiImportLlmParser.LlmParseCommand.class));
ArgumentCaptor<MuseAiChapterParseResultDO> chapterCaptor =
ArgumentCaptor.forClass(MuseAiChapterParseResultDO.class);
verify(chapterResultMapper).insert(chapterCaptor.capture());
org.junit.jupiter.api.Assertions.assertTrue(
chapterCaptor.getValue().getQualityResult().contains("dify_import_llm"));
}
@Test
void should_selectNewApiParserWhenProviderIsNewApi() {
MuseAiImportLlmParser newApiParser = org.mockito.Mockito.mock(MuseAiImportLlmParser.class);
MuseAiImportLlmParser difyParser = org.mockito.Mockito.mock(MuseAiImportLlmParser.class);
org.mockito.Mockito.lenient().when(newApiParser.providerKey()).thenReturn("new-api");
org.mockito.Mockito.lenient().when(difyParser.providerKey()).thenReturn("dify");
ReflectionTestUtils.setField(service, "importLlmParsers", List.of(newApiParser, difyParser));
// 用下划线写法验证服务层归一化:new_api 应等价 new-api
ReflectionTestUtils.setField(service, "museAiProperties", importProviderProperties("new_api"));
stubFullBookInsert();
when(newApiParser.parse(any(MuseAiImportLlmParser.LlmParseCommand.class))).thenReturn(
MuseAiImportLlmParser.LlmParseResult.success(
List.of(new MuseAiImportLlmParser.LlmChapter("New-API 章节", "New-API 切出的正文。")),
Map.of("parser", "new_api_import_llm")));
MuseAiImportParseApi.ParseJobProjection result = service.createParseJob(createCommand("txt", "full_book"));
assertEquals("completed", result.status());
verify(newApiParser).parse(any(MuseAiImportLlmParser.LlmParseCommand.class));
verify(difyParser, never()).parse(any(MuseAiImportLlmParser.LlmParseCommand.class));
ArgumentCaptor<MuseAiChapterParseResultDO> chapterCaptor =
ArgumentCaptor.forClass(MuseAiChapterParseResultDO.class);
verify(chapterResultMapper).insert(chapterCaptor.capture());
org.junit.jupiter.api.Assertions.assertTrue(
chapterCaptor.getValue().getQualityResult().contains("new_api_import_llm"));
}
private void stubFullBookInsert() {
when(parseJobMapper.selectByCommandId("cmd-parse-1")).thenReturn(null);
when(parseJobMapper.selectByImportTaskIdAndOwner(IMPORT_TASK_ID, USER_ID)).thenReturn(null);
when(fileApi.getFileBytes("100/content/import/book.md")).thenReturn(
"一段没有标题的正文。".getBytes(StandardCharsets.UTF_8));
doAnswer(invocation -> {
MuseAiParseJobDO job = invocation.getArgument(0);
job.setId(JOB_ID);
return 1;
}).when(parseJobMapper).insert(any(MuseAiParseJobDO.class));
}
private MuseAiProperties importProviderProperties(String provider) {
MuseAiProperties properties = new MuseAiProperties();
properties.getImport().setProvider(provider);
return properties;
}
private MuseAiImportParseApi.CreateParseJobCommand createCommand(String format) {
return createCommand(format, "chapter");
}

View File

@ -137,6 +137,9 @@ class MuseAiRuntimeClientTest {
String outputJson = JsonUtils.toJsonString(response.result().outputSummary());
assertTrue(outputJson.contains("第一句摘要"));
assertFalse(outputJson.contains("第二句很长"));
// S7a:fullOutput 承载 provider 完整正文(仅内存);落库摘要仍只 60 不含后文, fullOutput 不进 outputSummary JSON
assertEquals("第一句摘要。第二句很长,不能作为全文原样返回。", response.result().fullOutput());
assertTrue(response.result().fullOutput().contains("第二句很长"));
RecordedRequest request = recordedRequests.getFirst();
assertEquals("/v1/chat/completions", request.path());
@ -389,6 +392,9 @@ class MuseAiRuntimeClientTest {
assertEquals("dify-msg-1", dify.result().providerRequestId());
assertEquals("dify", dify.result().outputSummary().get("provider"));
assertEquals("untraceable_provider_context", dify.result().outputSummary().get("traceability"));
// S7a:两个 adapter 成功分支都把 provider 完整正文接出到 fullOutput(仅内存)
assertEquals("New API 摘要。", legacy.result().fullOutput());
assertEquals("Dify 摘要。", dify.result().fullOutput());
}
@Test
@ -440,6 +446,8 @@ class MuseAiRuntimeClientTest {
assertEquals("wf-run-1", response.result().providerRequestId());
assertEquals(11, response.result().tokenUsage().get("totalTokens"));
assertEquals("requires_new_api_attribution", response.result().tokenUsage().get("attributionStatus"));
// S7a:workflow 完整正文接出到 fullOutput(仅内存)
assertEquals("工作流输出摘要。", response.result().fullOutput());
RecordedRequest request = recordedRequests.getFirst();
assertEquals("/workflows/workflow-1/run", request.path());
assertEquals("Bearer dify-unit-test-key", request.header("Authorization"));
@ -466,6 +474,105 @@ class MuseAiRuntimeClientTest {
assertFalse(unknownRef.failure().retryable());
}
// S7a 护栏样本:>60 字且无句末标点 疑似截断;不含任何凭据痕迹
private static final String TRUNCATED_OUTPUT_SAMPLE =
"雨落在青石板上溅起细小的水花她撑着伞站在巷口望着远处那扇熟悉的木门心里翻涌着说不清的情绪"
+ "许多年前的往事一幕幕浮现她深吸一口气终于抬起脚步朝着那扇门缓缓走了过去然后她";
@Test
void should_failClosed_when_newApiOutputIsTruncated() throws Exception {
// 完整正文达可判长度却无句末标点 护栏判疑似截断,判失败可重试不产候选(不信 provider finishReason)
startServer(200, "{\"choices\":[{\"message\":{\"role\":\"assistant\",\"content\":\""
+ TRUNCATED_OUTPUT_SAMPLE + "\"}}]}", null);
RealNewApiMuseAiRuntimeClient client = new RealNewApiMuseAiRuntimeClient(newApiProperties(true));
MuseAiRuntimeClient.RuntimeResponse response = client.execute(command(Map.of("taskType", "continue_writing")));
assertNull(response.result());
assertEquals("provider_bad_response", response.failure().failureType());
assertEquals("AI_NEW_API_OUTPUT_TRUNCATED", response.failure().errorCode());
assertTrue(response.failure().retryable());
}
@Test
void should_failClosed_when_newApiOutputContainsCredentialMarker() throws Exception {
// 完整正文含凭据痕迹 护栏判不合规,判失败可重试不产候选
startServer(200, """
{"choices":[{"message":{"role":"assistant","content":"这是生成的正文,其中混入了 api_key 泄露的痕迹。"}}]}
""", null);
RealNewApiMuseAiRuntimeClient client = new RealNewApiMuseAiRuntimeClient(newApiProperties(true));
MuseAiRuntimeClient.RuntimeResponse response = client.execute(command(Map.of("taskType", "generation")));
assertNull(response.result());
assertEquals("provider_bad_response", response.failure().failureType());
assertEquals("AI_NEW_API_OUTPUT_NONCOMPLIANT", response.failure().errorCode());
assertTrue(response.failure().retryable());
}
@Test
void should_failClosed_when_difyOutputIsTruncated() throws Exception {
// 护栏 provider 无关:Dify 完整正文疑似截断同样判失败可重试
startServer(exchange -> writeJson(exchange, 200,
"{\"message_id\":\"dify-msg-2\",\"answer\":\"" + TRUNCATED_OUTPUT_SAMPLE + "\"}", null));
RealDifyMuseAiRuntimeClient client = new RealDifyMuseAiRuntimeClient(difyProperties(true));
MuseAiRuntimeClient.RuntimeResponse response = client.execute(command(difyInputSummary("agent")));
assertNull(response.result());
assertEquals("provider_bad_response", response.failure().failureType());
assertEquals("AI_DIFY_OUTPUT_TRUNCATED", response.failure().errorCode());
assertTrue(response.failure().retryable());
}
@Test
void should_failClosedForDifyWhenAssembledUnavailableWithoutFallbackToNewApi() {
// S7d fail-closed 反向:Dify 槽位装配为占位 Unavailable( null),选中 dify 仍必须失败关闭,
// 返回 Dify 专属不可用错误码,且绝不回退 New-API newApi lambda 若被命中会返回 wrong 结果使断言失败
RoutingMuseAiRuntimeClient client = new RoutingMuseAiRuntimeClient(
command -> new MuseAiRuntimeClient.RuntimeResponse(
new MuseAiRuntimeClient.RuntimeResult("corr-real-1", "success", Map.of("summary", "wrong"),
Map.of(), null, "new-api-should-not-run", "stop", Instant.now()), null),
new UnavailableMuseAiRuntimeClient());
MuseAiRuntimeClient.RuntimeResponse response = client.execute(command(difyInputSummary("agent")));
assertNull(response.result());
assertEquals("runtime_unavailable", response.failure().failureType());
assertEquals("AI_DIFY_UNAVAILABLE", response.failure().errorCode());
assertFalse(response.failure().retryable());
}
// S7d Dify 形态:完整正文(>80 句末标点)经护栏通过后,fullOutput 承载完整正文(仅内存供 S7b),
// 落库摘要 content 仍只 80 字截断,证明审的是完整正文主权摘要不含全文
private static final String COMPLETE_DIFY_OUTPUT_SAMPLE =
"夜色像一层薄薄的墨慢慢浸透了整座城市,街灯在雨里晕开成一团团柔和的光,"
+ "她合上笔记本望向窗外那条空荡荡的长街,心里第一次生出一种前所未有的笃定,"
+ "仿佛所有漂泊多年的疑问都在这一刻悄然有了答案。";
@Test
void should_routeDifyCompleteOutputWithFullOutputWhileSummaryTruncated() throws Exception {
startServer(exchange -> writeJson(exchange, 200,
"{\"message_id\":\"dify-msg-3\",\"answer\":\"" + COMPLETE_DIFY_OUTPUT_SAMPLE + "\"}", null));
RoutingMuseAiRuntimeClient client = new RoutingMuseAiRuntimeClient(
new RealNewApiMuseAiRuntimeClient(newApiProperties(true)),
new RealDifyMuseAiRuntimeClient(difyProperties(true)));
MuseAiRuntimeClient.RuntimeResponse response = client.execute(command(difyInputSummary("agent")));
assertNull(response.failure());
assertEquals("success", response.result().status());
assertEquals("dify", response.result().outputSummary().get("provider"));
assertEquals("untraceable_provider_context", response.result().outputSummary().get("traceability"));
// fullOutput = 完整正文(仅内存, S7b 瞬态通路);落库摘要 content 80 不等于完整正文
assertEquals(COMPLETE_DIFY_OUTPUT_SAMPLE, response.result().fullOutput());
String summary = String.valueOf(response.result().outputSummary().get("content"));
assertTrue(summary.length() <= 80, "落库摘要不得超过 80 字快照");
assertTrue(response.result().fullOutput().length() > summary.length(),
"完整正文长度须大于落库摘要,证明摘要为截断快照而非全文");
assertFalse(summary.equals(response.result().fullOutput()), "落库摘要不得等于完整正文");
}
private MuseAiRuntimeClient.RuntimeCommand command(Map<String, Object> inputSummary) {
return new MuseAiRuntimeClient.RuntimeCommand(
"cmd-real-1", "3001", "4001", "corr-real-1", 1, "model-a", "prompt-v9",

View File

@ -7,6 +7,7 @@ import cn.iocoder.muse.module.ai.application.muse.facade.AccountUsageFacade;
import cn.iocoder.muse.module.ai.application.muse.facade.MuseAiRuntimeClient;
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAiGenerationDO;
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAiJobDO;
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAiSuggestionDO;
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAiTaskEventDO;
import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseAiGenerationMapper;
import cn.iocoder.muse.module.ai.dal.mysql.muse.MuseAiJobMapper;
@ -23,9 +24,12 @@ import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ -34,6 +38,10 @@ import static org.mockito.Mockito.when;
*/
class MuseAiRuntimeProjectionServiceTest extends BaseMockitoUnitTest {
/** provider 完整正文样例:长度显著大于 60 字脱敏摘要,用于断言正文不落 chunk payload。 */
private static final String FULL_OUTPUT = "这是一段远超 60 字摘要阈值的 provider 完整正文,仅应经瞬态全文 Store 与 SSE 送达,"
+ "绝不写入 chunk 事件 payload、候选表 content_snapshot 或任何长期列。";
@InjectMocks
private MuseAiRuntimeProjectionService projectionService;
@Mock
@ -114,6 +122,44 @@ class MuseAiRuntimeProjectionServiceTest extends BaseMockitoUnitTest {
assertEquals("completed", eventCaptor.getValue().getTaskStatus());
}
@Test
void should_emitChunkRefEventBeforeDone_when_fullOutputPresent() {
TenantContextHolder.setTenantId(100L);
MuseAiGenerationDO task = task("running");
MuseAiJobDO job = job("running");
when(jobMapper.selectByIdForUpdate(100L, 4001L)).thenReturn(job("running"));
when(candidateReviewService.review(any(), any(), any()))
.thenReturn(new MuseAiCandidateReviewService.CandidateReview(true, "oc-x", "sc-x", List.of(), Map.of()));
// 第一次 selectLatestByTaskIdchunk append无历史chunk seq=1第二次done append已见 chunkdone seq=2
MuseAiTaskEventDO chunkSeqOne = new MuseAiTaskEventDO();
chunkSeqOne.setSequenceNo(1L);
when(taskEventMapper.selectLatestByTaskId("3001")).thenReturn(null).thenReturn(chunkSeqOne);
projectionService.applyRuntimeResponse(task, job, runtimeSuccessWithFullOutput(FULL_OUTPUT), command());
ArgumentCaptor<MuseAiTaskEventDO> eventCaptor = ArgumentCaptor.forClass(MuseAiTaskEventDO.class);
verify(taskEventMapper, times(2)).insert(eventCaptor.capture());
MuseAiTaskEventDO chunk = eventCaptor.getAllValues().get(0);
MuseAiTaskEventDO done = eventCaptor.getAllValues().get(1);
// chunk 引用行排在 done 之前sequence_no 更小
assertEquals("chunk", chunk.getEventType());
assertEquals("done", done.getEventType());
assertEquals(Long.valueOf(1L), chunk.getSequenceNo());
assertEquals(Long.valueOf(2L), done.getSequenceNo());
// 主权红线chunk payload 只含指向瞬态 Store 的引用contentRef=taskId绝不含完整正文
assertTrue(chunk.getPayloadSummary().contains("\"contentRef\":3001"));
assertFalse(chunk.getPayloadSummary().contains(FULL_OUTPUT));
// 终态 outbox 只由 done 触发chunk 非终态不建 outbox
verify(eventPublishOutboxService, times(1)).createForTerminalEvent(any());
verify(eventPublishOutboxService).createForTerminalEvent(done);
// 主权红线候选表 content_snapshot.content 仍是脱敏摘要outputSummary.summary绝不落完整正文
ArgumentCaptor<MuseAiSuggestionDO> suggestionCaptor = ArgumentCaptor.forClass(MuseAiSuggestionDO.class);
verify(suggestionMapper).insert(suggestionCaptor.capture());
String contentSnapshot = suggestionCaptor.getValue().getContentSnapshot();
assertTrue(contentSnapshot.contains("\"content\":\"done\""));
assertFalse(contentSnapshot.contains(FULL_OUTPUT));
}
@Test
void should_releaseReservedQuota_when_runtimeFailureIsTerminal() {
TenantContextHolder.setTenantId(100L);
@ -194,6 +240,13 @@ class MuseAiRuntimeProjectionServiceTest extends BaseMockitoUnitTest {
"stop", Instant.parse("2026-05-30T10:00:00Z")), null);
}
private static MuseAiRuntimeClient.RuntimeResponse runtimeSuccessWithFullOutput(String fullOutput) {
// fullOutput 仅内存字段S7a落库仍用 outputSummary 摘要chunk 引用行只带指向瞬态 Store ref
return new MuseAiRuntimeClient.RuntimeResponse(new MuseAiRuntimeClient.RuntimeResult("corr-queued",
"success", Map.of("summary", "done"), Map.of("promptTokens", 1), 1L, "provider-1",
"stop", Instant.parse("2026-05-30T10:00:00Z"), fullOutput), null);
}
private static MuseAiRuntimeClient.RuntimeResponse runtimeFailure(boolean retryable) {
return new MuseAiRuntimeClient.RuntimeResponse(null, new MuseAiRuntimeClient.RuntimeFailure("corr-queued",
retryable ? "network_timeout" : "runtime_unavailable", null, "AI_NEW_API_UNAVAILABLE",

View File

@ -50,6 +50,8 @@ class MuseAiTaskStreamServiceTest extends BaseMockitoUnitTest {
private MuseAiGenerationMapper generationMapper;
@Mock
private MuseAiTaskEventMapper taskEventMapper;
@Mock
private MuseAiGeneratedFulltextStore generatedFulltextStore;
@BeforeEach
void injectTaskTimeout() {
@ -117,6 +119,39 @@ class MuseAiTaskStreamServiceTest extends BaseMockitoUnitTest {
assertEquals(Map.of("taskId", 3001L, "suggestionId", 6001L, "summary", "ok"), events.get(2).data());
}
@Test
void should_fillChunkContentFromTransientStore_when_chunkHasContentRef() {
String fullText = "完整正文,长度远超 60 字脱敏摘要阈值,来自瞬态全文 Store经 SSE 送达用户。";
TenantContextHolder.setTenantId(100L);
when(generationMapper.selectByTaskId(3001L)).thenReturn(task(3001L, 1001L, 100L));
when(taskEventMapper.selectListByTaskIdOrderBySequence("3001")).thenReturn(List.of(
event(1L, "chunk", "streaming", Map.of("contentRef", 3001L, "sequenceNo", 1L), null, null),
event(2L, "done", "completed", Map.of("suggestionId", 6001L), null, null)));
when(generatedFulltextStore.read(100L, 3001L)).thenReturn(fullText);
List<MuseAiTaskStreamService.StreamEvent> events = streamService.buildReplayEvents(1001L, 3001L);
// chunk 引用行回瞬态 Store 取完整正文填 chunk.data.content长度远大于摘要
assertEquals(List.of("chunk", "done"),
events.stream().map(MuseAiTaskStreamService.StreamEvent::event).toList());
assertEquals(Map.of("content", fullText, "sequenceNo", 1L), events.get(0).data());
}
@Test
void should_fillEmptyChunkContent_when_transientStoreMissing() {
TenantContextHolder.setTenantId(100L);
when(generationMapper.selectByTaskId(3001L)).thenReturn(task(3001L, 1001L, 100L));
when(taskEventMapper.selectListByTaskIdOrderBySequence("3001")).thenReturn(List.of(
event(1L, "chunk", "streaming", Map.of("contentRef", 3001L, "sequenceNo", 1L), null, null),
event(2L, "done", "completed", Map.of("suggestionId", 6001L), null, null)));
when(generatedFulltextStore.read(100L, 3001L)).thenReturn(null);
List<MuseAiTaskStreamService.StreamEvent> events = streamService.buildReplayEvents(1001L, 3001L);
// Store 缺失 purge/TTL 过期 content 空串用户已决定无害不伪造正文不报错
assertEquals(Map.of("content", "", "sequenceNo", 1L), events.get(0).data());
}
@Test
void should_stopReplayAtFirstTerminalEvent_when_doneAndErrorBothPersisted() {
TenantContextHolder.setTenantId(100L);

View File

@ -52,6 +52,8 @@ class MuseSuggestionServiceTest extends BaseMockitoUnitTest {
private MuseAiAuditService auditService;
@Mock
private MuseContentWorkOwnerFacade workOwnerFacade;
@Mock
private MuseAiGeneratedFulltextStore generatedFulltextStore;
@AfterEach
void clearTenantContext() {
@ -179,6 +181,8 @@ class MuseSuggestionServiceTest extends BaseMockitoUnitTest {
&& "rejected".equals(updated.getStatus())
&& Long.valueOf(8001L).equals(updated.getDecisionArchiveId())));
verify(commandService).recordSucceeded(any(), argThat(snapshot -> snapshot.contains("\"suggestionId\":6001")));
// S7b 决定即清放弃成功后 purge taskId=generationId 3001的瞬态出向全文
verify(generatedFulltextStore).purge(100L, 3001L);
}
@Test
@ -242,6 +246,8 @@ class MuseSuggestionServiceTest extends BaseMockitoUnitTest {
verify(suggestionMapper, never()).updateById(any(MuseAiSuggestionDO.class));
verify(decisionMapper, never()).insert(any(MuseAiSuggestionDecisionDO.class));
verify(commandService).recordSucceeded(any(), argThat(snapshot -> snapshot.contains("\"status\":\"rejected\"")));
// S7b 决定即清幂等已拒态再次命中也确保 purge
verify(generatedFulltextStore).purge(100L, 3001L);
}
private static SuggestionRejectReqVO rejectRequest(String commandId) {

View File

@ -5,6 +5,7 @@ import cn.iocoder.muse.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.muse.module.ai.application.muse.MuseAiAuditService;
import cn.iocoder.muse.module.ai.application.muse.MuseAiCommandService;
import cn.iocoder.muse.module.ai.application.muse.MuseAiGeneratedFulltextStore;
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAiCommandDO;
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAiSuggestionDO;
import cn.iocoder.muse.module.ai.dal.dataobject.muse.MuseAiSuggestionDecisionDO;
@ -51,6 +52,8 @@ class AiSuggestionMergeProjectionFacadeTest extends BaseMockitoUnitTest {
private MuseAiCommandService commandService;
@Mock
private MuseAiAuditService auditService;
@Mock
private MuseAiGeneratedFulltextStore generatedFulltextStore;
@AfterEach
void clearTenantContext() {
@ -148,6 +151,8 @@ class AiSuggestionMergeProjectionFacadeTest extends BaseMockitoUnitTest {
verify(commandService).recordSucceeded(any(), argThat(snapshot -> snapshot.contains("\"status\":\"accepted\"")));
verify(auditService).record(argThat(audit -> "markSuggestionAccepted".equals(audit.getOperationId())
&& "succeeded".equals(audit.getStatus())));
// S7b 决定即清采纳成功后 purge taskId=generationId 3001的瞬态出向全文
verify(generatedFulltextStore).purge(100L, 3001L);
}
@Test
@ -193,6 +198,8 @@ class AiSuggestionMergeProjectionFacadeTest extends BaseMockitoUnitTest {
suggestion.setId(501L);
suggestion.setWorkId(101L);
suggestion.setBlockId(301L);
// generationId = taskId决定即清 purge 以此为 key
suggestion.setGenerationId(3001L);
suggestion.setStatus("pending");
suggestion.setSourceRevision(7);
suggestion.setSourceStatus("active");

View File

@ -0,0 +1,215 @@
package cn.iocoder.muse.module.ai.application.muse.facade;
import cn.iocoder.muse.framework.common.util.json.JsonUtils;
import cn.iocoder.muse.module.ai.framework.ai.config.MuseAiProperties;
import org.junit.jupiter.api.Test;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
/**
* S1 Dify chat app 外部 live acceptance 入口
*
* <p>默认必须跳过只有显式设置 MUSE_P1R_EXTERNAL_ACCEPTANCE=true 才会真实调用外部 Dify
* 输出证据只保留 endpointappmessage id 状态和 key 指纹不能打印完整 key provider 原始回答</p>
*/
class P1rDifyChatLiveAcceptanceIT {
private static final String ACCEPTANCE_ENV = "MUSE_P1R_EXTERNAL_ACCEPTANCE";
private static final String BASE_URL_ENV = "MUSE_AI_DIFY_BASE_URL";
private static final String APP_ID_ENV = "MUSE_AI_DIFY_WRITING_APP_ID";
private static final String API_KEY_ENV = "MUSE_AI_DIFY_WRITING_API_KEY";
private static final String CREDENTIAL_REF_ENV = "MUSE_AI_DIFY_WRITING_CREDENTIAL_REF";
private static final String DEFAULT_CREDENTIAL_REF = "dify-writing-s1";
private static final String CHAT_MESSAGES_PATH = "/chat-messages";
@Test
void shouldCallDifyChatThroughMuseRuntimeClientAndPrintRedactedEvidence() {
assumeTrue(externalAcceptanceEnabled(), "P1R 外部验收未启用,设置 MUSE_P1R_EXTERNAL_ACCEPTANCE=true 后才运行");
String baseUrl = requiredEnv(BASE_URL_ENV);
String appId = requiredEnv(APP_ID_ENV);
String apiKey = requiredEnv(API_KEY_ENV);
String credentialRef = envOrDefault(CREDENTIAL_REF_ENV, DEFAULT_CREDENTIAL_REF);
MuseAiRuntimeClient.RuntimeCommand command = liveCommand(appId, credentialRef);
RealDifyMuseAiRuntimeClient client = new RealDifyMuseAiRuntimeClient(difyProperties(baseUrl, apiKey,
credentialRef));
MuseAiRuntimeClient.RuntimeResponse response = client.execute(command);
assertNull(response.failure(), () -> "Dify chat live acceptance failed: "
+ redactedFailure(response.failure()));
assertNotNull(response.result(), "Dify chat live acceptance 必须返回 success result");
assertEquals("success", response.result().status());
assertNotNull(response.result().outputSummary(), "Dify chat outputSummary 不能为空");
Object summary = response.result().outputSummary().get("summary");
assertTrue(summary != null && !String.valueOf(summary).isBlank(), "Dify chat summary 不能为空");
assertNoSensitiveText(String.valueOf(summary), "summary");
System.out.println(JsonUtils.toJsonString(redactedEvidence(baseUrl, apiKey, appId, credentialRef,
response.result(), String.valueOf(summary))));
}
private MuseAiRuntimeClient.RuntimeCommand liveCommand(String appId, String credentialRef) {
String correlationId = "s1-dify-chat-live-" + Instant.now().toEpochMilli();
return new MuseAiRuntimeClient.RuntimeCommand(
"cmd-" + correlationId,
"s1-dify-chat-task",
"s1-dify-chat-job",
correlationId,
1,
"dify-writing",
"s1-dify-chat-live-v1",
Map.of("taskType", "s1_dify_chat_live_acceptance",
"intent", "external_dify_chat_smoke",
"runtimeProvider", "dify",
"providerRef", Map.of("dify", Map.of(
"appType", "agent",
"appId", appId,
"credentialRef", credentialRef)),
"outputContract", Map.of("format", "plain_text")),
"s1-dify-chat-source-snapshot",
"s1-dify-chat-runtime-envelope",
"只输出 OK",
timeoutPolicy(),
MuseAiRuntimeClient.RetryPolicy.defaults());
}
private MuseAiProperties difyProperties(String baseUrl, String apiKey, String credentialRef) {
MuseAiProperties properties = new MuseAiProperties();
MuseAiProperties.Dify dify = new MuseAiProperties.Dify();
dify.setEnabled(true);
dify.setBaseUrl(baseUrl);
dify.setConnectTimeoutSeconds(intEnv("MUSE_AI_DIFY_CONNECT_TIMEOUT_SECONDS", 5));
dify.setNonStreamReadTimeoutSeconds(intEnv("MUSE_AI_DIFY_NON_STREAM_READ_TIMEOUT_SECONDS", 90));
dify.setTotalTimeoutSeconds(intEnv("MUSE_AI_DIFY_TOTAL_TIMEOUT_SECONDS", 180));
MuseAiProperties.Dify.Credential credential = new MuseAiProperties.Dify.Credential();
credential.setRef(credentialRef);
credential.setApiKey(apiKey);
dify.setCredentials(List.of(credential));
properties.setDify(dify);
return properties;
}
private MuseAiRuntimeClient.TimeoutPolicy timeoutPolicy() {
return new MuseAiRuntimeClient.TimeoutPolicy(
intEnv("MUSE_AI_DIFY_CONNECT_TIMEOUT_SECONDS", 5),
intEnv("MUSE_AI_DIFY_FIRST_BYTE_TIMEOUT_SECONDS", 15),
intEnv("MUSE_AI_DIFY_NON_STREAM_READ_TIMEOUT_SECONDS", 90),
intEnv("MUSE_AI_DIFY_STREAM_IDLE_TIMEOUT_SECONDS", 30),
intEnv("MUSE_AI_DIFY_TOTAL_TIMEOUT_SECONDS", 180));
}
private Map<String, Object> redactedEvidence(String baseUrl, String apiKey, String appId, String credentialRef,
MuseAiRuntimeClient.RuntimeResult result, String summary) {
Map<String, Object> evidence = new LinkedHashMap<>();
evidence.put("acceptance", "s1-dify-chat-live");
evidence.put("endpoint", endpointSummary(baseUrl, CHAT_MESSAGES_PATH));
evidence.put("credentialRef", credentialRef);
evidence.put("apiKey", secretFingerprint(apiKey));
evidence.put("appId", appId);
evidence.put("correlationId", result.correlationId());
evidence.put("status", result.status());
evidence.put("messageIdPresent", result.providerRequestId() != null && !result.providerRequestId().isBlank());
evidence.put("summaryLength", summary.length());
evidence.put("summarySha256Prefix", sha256Prefix(summary, 12));
evidence.put("finishReason", result.finishReason());
evidence.put("usage", result.tokenUsage());
evidence.put("completedAt", result.completedAt());
return evidence;
}
private Map<String, Object> endpointSummary(String baseUrl, String path) {
URI uri = URI.create(trimRight(baseUrl, "/") + path);
Map<String, Object> endpoint = new LinkedHashMap<>();
endpoint.put("scheme", uri.getScheme());
endpoint.put("host", uri.getHost());
endpoint.put("port", uri.getPort());
endpoint.put("path", uri.getPath());
return endpoint;
}
private Map<String, Object> secretFingerprint(String secret) {
Map<String, Object> fingerprint = new LinkedHashMap<>();
fingerprint.put("length", secret == null ? 0 : secret.length());
fingerprint.put("sha256Prefix", sha256Prefix(secret == null ? "" : secret, 12));
return fingerprint;
}
private String redactedFailure(MuseAiRuntimeClient.RuntimeFailure failure) {
if (failure == null) {
return "none";
}
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("failureType", failure.failureType());
summary.put("providerStatusCode", failure.providerStatusCode());
summary.put("errorCode", failure.errorCode());
summary.put("retryable", failure.retryable());
summary.put("message", failure.sanitizedMessage());
return JsonUtils.toJsonString(summary);
}
private void assertNoSensitiveText(String value, String label) {
String lower = value == null ? "" : value.toLowerCase(Locale.ROOT);
assertFalse(lower.contains("token"), label + " 不应包含 token 字样");
assertFalse(lower.contains("key"), label + " 不应包含 key 字样");
assertFalse(lower.contains("authorization"), label + " 不应包含 authorization 字样");
}
private boolean externalAcceptanceEnabled() {
return "true".equalsIgnoreCase(System.getenv(ACCEPTANCE_ENV));
}
private String requiredEnv(String name) {
String value = System.getenv(name);
assertTrue(value != null && !value.isBlank(), "缺少外部验收环境变量:" + name);
return value;
}
private String envOrDefault(String name, String defaultValue) {
String value = System.getenv(name);
return value == null || value.isBlank() ? defaultValue : value;
}
private int intEnv(String name, int defaultValue) {
String value = System.getenv(name);
if (value == null || value.isBlank()) {
return defaultValue;
}
return Integer.parseInt(value);
}
private String sha256Prefix(String value, int length) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
String hex = HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8)));
return hex.substring(0, Math.min(length, hex.length()));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("JDK 缺少 SHA-256 摘要算法", e);
}
}
private String trimRight(String value, String suffix) {
String result = value == null ? "" : value.trim();
while (result.endsWith(suffix)) {
result = result.substring(0, result.length() - suffix.length());
}
return result;
}
}

View File

@ -7,4 +7,5 @@
- **out-of-scope**:知识入库(归 knowledge BC)、AI 生成(归 ai BC)。
- **现状**:只读评估 82%;后端齐全,契约高度一致。
- **2026-06-17 验收债**:批1 已补 work/chapter/block 生命周期 11 op 真实 PG 证据;批20-28 已补 planning candidate 5 + meta-projection 3 + style-check 2 + chapter-parse-result 决策 2 + parse-job 生命周期/创建 5 + import-task 2 + export-task 4 + 管理端任务列表 2 + mergeBlockSuggestion 1,均真实 PG 绿且证据已人工批准并翻 completed。批26 `P1rContentExportTaskCompletedApprovalIT` 补 export-task 簇 4 op(getExportTask/exportWork/createExportTask/downloadExportPackage)并修正 `ContentExportServiceImpl` public `exportWork/createExportTask` 事务边界,IT 已用 command_log 0 残留断言验证预占命令回滚。批27 扩展 `P1rContentAdminReadCompletedApprovalIT` 补 adminListImportTasks/adminListExportTasks:真实 `/admin-api` + method security + 租户行拦截器,读 `muse_content_import_task`/`muse_content_export_task` 真实行,覆盖分页/筛选/workTitle 回填/跨租户不泄露/RBAC/API version/no-write,并补齐老 harness 专属 `_test` 库自动创建。批28 扩展 `P1rContentParseJobLifecycleCompletedApprovalIT` 补 createParseJob 守卫/fail-closed/事务回滚证据,并修正 `P1rContentMergeSuggestionIT` 老 harness 专属 `_test` 库自动创建后复验 mergeBlockSuggestion 真实写 Canonical 正文链路。content/account/market needs_verification 验收债均已清零;coverage JSON completed 已按人工批准翻转,且 2026-06-19 已补 `testFiles` 证据锚点门禁。
- **2.0.0 S3 market 摘装配影响(2026-07-08)**:Content 继续保留 `market-api` 契约依赖;默认单体无 market-server 时,`MarketHandoffTokenApi` fallback 让 market 来源资产使用/交接 verify fail-closed、consume 不核销,避免客户端伪造 handoff。Content 自身作品/正文/导入导出/采纳链路不因 market dormant 变化;恢复市场来源使用验收时走 `muse-server -Pmarket-assembled`。本轮 real-PG 修正 `P1rContentChapterParseResultDecisionCompletedApprovalIT` 与 `P1rContentParseJobLifecycleCompletedApprovalIT` 测试上下文:导入解析/章节确认链路需要同时装配 `ContentImportParseServiceImpl`、`ContentEventPublishOutboxServiceImpl`、`ContentBlockRevisionServiceImpl` 与 `MuseContentEventsProperties`,避免测试上下文落后于内容事件发布实现。
- **关键风险 / TODO**:Content 验收债已补齐真实 PG 证据;外部 owner(FileService/解析投影等)未接入的路径仍按合同 fail-closed,需要配置真实外部依赖或 owner 服务后才能从 fail-closed 升级为完整成功路径。**2026-06-25:AI suggestion 采纳已从 fail-closed 升级为真成功(ADR-020 方案 A,commit a16c596)**——此前真生成候选 `authorization_snapshot_id=null` 恒被 `mergeBlockSuggestion` 拒(1041001001),根因 authz 列 BIGINT 存不下字符串 runtime envelope `rpe-local-uuid`;V30 把 `muse_content_block_source_attribution`+`muse_ai_suggestion` authz 列 BIGINT→VARCHAR(128)、放开 `ContentSourceServiceImpl` 两道数值门(删 requireNumericAuthorizationSnapshot/parseAuthorizationSnapshotId/parseLongQuietly,字符串直落、外部 owner 非数值编号不再丢)、`BlockSourceAttributionDO.authorizationSnapshotId` 改 String。活体 merge code=0+revision 自增、IT `P1rContentMergeGeneratedSuggestion` 假绿(注入数值 9001)转真绿。详见总账 2026-06-25 / ADR-020。**2026-06-27:E1 采纳归档 + 旧知识草稿失效切片已补**——`mergeBlockSuggestion` 写 Canonical/来源归因后必须让 AI owner 归档 accepted decisionAI owner 不可用则整笔回滚,避免正文已写但 suggestion 仍 pending。Content 正文变更后经 Knowledge owner API 将关联 pending draft 标为 `conflicted/needs_recheck`,通知失败不回滚 Canonical 主写但 confirm 端 fail-closed。P1r 真 PG `P1rContentMergeSuggestionIT` 5/0F/0E/0S 已校验 suggestion `accepted`、decision archive、AI command/audit、Knowledge draft decision 与幂等 replay`P1rContentMergeGeneratedSuggestionIT` 1/0F/0E/0S 回归生成候选采纳。**2026-06-27:E1 工作台入口 + 版本历史已补**——`saveBlock`/`mergeBlockSuggestion` 写 `muse_content_block_revision_snapshot``GET /app-api/muse/works/{workId}/blocks/{blockId}/revisions` 只读返回 Canonical revision 快照Studio 工作台“历史”Tab 展示;工作台知识/导入/导出/记录最小入口已接。P1r 真 PG `P1rContentCoreCompletedApprovalIT` 13/0F/0E/0S 校验版本历史、owner/tenant 守卫和纯查询不写;前端 `tsc`/lint/Vitest 12/12 绿MSW-off Playwright `accept-suggestion.spec.ts` 2/0F/0E 验证真 New-API suggestionId=79 采纳后 revision 160→161、AI owner accepted decision archive 非空。**2026-06-27:E7 Meta 动态字段校验已从 fail-closed 升级为真成功**——`RealContentMetaFacade.validateDynamicFields` 读取 Meta active projection 校验 required/type/enum/range/regex/deprecated/unknown field 并返回 routeSuggestions`P1rContentMetaProjectionClusterCompletedApprovalIT` 4/0F/0E/0S 覆盖 no-schema fail-closed 与 active projection 成功规则命中且 0 Content 写。**2026-06-27:RC 后本地文本导入初始化正文已补**——`CreateImportTaskReqVO.contentText` 支持 txt/markdown inline 模式,后端保守拆章并同事务写 import task/chapter/block/source attribution/outbox/block revision snapshot/work 统计Studio 导入弹窗可选 `.txt/.md` 或粘贴正文。验证:Content import/controller 单测 22/0F/0E、契约/覆盖门 12/0F/0E、Studio hook 9/0F/0E、tsc/目标 lint 绿;真实 PG `_test` 库 `P1rContentImportTaskCompletedApprovalIT` 4/0F/0E/0S 已绿(需在 surefire forked JVM 清 `socksProxyHost/socksProxyPort` 并透传 `p1r.flyway.*`,此前 JDBC EOF 是代理残留假红)。**2026-06-27:RC 后作品导出范围选择与凭证下载 UI 已补**——Studio 导出弹窗支持 TXT/DOCX/EPUB、全部/指定章节、元数据/规划开关hook 接 `createExportTask/getExportTask/downloads/{credentialId}``api.download` 解析 octet-stream 与 JSON 业务错误。验证:Studio `tsc -b --force`、目标 Vitest 12/0F/0E、目标 ESLint 0 errors2026-06-28 已补 `export-download.spec.ts` MSW-off 真后端 FileService 下载 e2e 1/0F/0EFileApi 不可用仍按后端合同 fail-closed。**2026-06-27:RC 后 Block 版本历史只读 diff 已补**——Studio 历史面板支持任意两版基准/目标选择,按段落/句子做 LCS diff 并展示新增/删除/不变统计;只读消费版本历史 API不新增恢复/回滚写入口。验证:目标 Vitest 2/0F/0E、`tsc`、目标 ESLint 绿。**2026-06-28:RC 后完整导入向导已补**——Infra 上传返回稳定 pathContent 外部导入任务只接受 infra path 并用 FileApi 真字节扫描AI owner 持久化 parse job 与章节 ShadowKnowledge owner 在章节确认时只产 pending Draft不写 Local KB CanonicalStudio 向导串起上传扫描→解析→章节确认。验证见总账 2026-06-28含浏览器 MSW-off 导入全链 e2e。2026-06-28 已补 `export-download.spec.ts` MSW-off 真后端 FileService 下载 e2e 1/0F/0E。仍缺:版本恢复/高级回滚。

View File

@ -3,7 +3,9 @@
> 模块跨会话记忆。真实现状权威见 [现状基线 §5.3](../../docs/agent-specs/2026-06-13-项目目标与模块现状基线.md);进度见 [总账](../../docs/mvp/进度总账.md)。本文件只记目标/边界/out-of-scope/现状指针/TODO,过时即更。
- **目标(owner 职责)**:Local KB / User KB / 全局知识处理 / Knowledge Draft / Knowledge Source Binding / 投影与索引状态。
- **边界(不可违反)**:**进 Local KB 唯一入口=用户显式确认草稿或手动修正**;全书解析章节确认只产/推 Draft,不写正式知识;来源优先级 Local KB > 用户绑定 > 市场/全局;依赖 RAGFlow GraphRAG(不可用时 Unavailable 优雅降级);守 BC 边界不碰他域 `.dal`([bc-boundaries](../../.agents/rules/bc-boundaries.md))。
- **边界(不可违反)**:**进 Local KB 唯一入口=用户显式确认草稿或手动修正**;全书解析章节确认只产/推 Draft,不写正式知识;来源优先级 Local KB > 用户绑定 > 市场/全局;1.0 运行时仍有 RAGFlow GraphRAG 历史链路,2.0 单人版迁移目标为 Dify Datasets(不可用时 Unavailable 优雅降级);守 BC 边界不碰他域 `.dal`([bc-boundaries](../../.agents/rules/bc-boundaries.md))。
- **out-of-scope**:正文写入(归 content)。
- **现状**:只读评估 78%;后端最完整模块之一(67 端点对齐契约),RAGFlow 真 HTTP + SSRF 防护;installed-KB 删除记录回显 bug 已修(2026-06-19:`MuseKnowledgeBindingMapperTest` + `MuseInstalledKnowledgeBaseServiceTest` 10/10,删除时写 `deleted=true` 且列表排除 `binding_status=deleted`);impact preview 已按 `muse_knowledge_source_binding_projection.owner_user_id` 输出 installed/source-binding owner 审计计数(2026-06-19:`MuseKnowledgeBaseServiceTest` + `MuseKnowledgeSourceBindingProjectionMapperTest` 30/30);source binding projection 回填闭环已修(2026-06-19:绑定确认为权威回填点写读模型行、unbind 按 bindingId 作用域撤销不误伤同源跨作品投影、唯一键补 work_id 修跨作品复用误判冲突V24;`MuseKnowledgeBindingServiceTest` + `MuseKnowledgeSourceBindingProjectionMapperTest` 11/11、整套件 214/214 绿);**读回端到端坐实 + unbind 真软删修复(2026-06-19)**:嵌入式 DB(H2)往返证 bind 写投影→`selectActiveByWorkId` 读回该来源、unbind 后读回消失、同源他作品投影存活;并修 unbind 软删真 bug——`deleted` 是 `@TableLogic`,实体 `setDeleted(true)` 被 MP 普通 update 剥离成空操作,改 `setSql("deleted = true")` 才真软删(`MuseKnowledgeSourceBindingProjectionRoundTripTest` 2/2、整套件 216/216 绿;新增 knowledge 模块首套嵌入式 DB 测试基建)。**GET bindings 读端点已补(2026-06-19)**:`AppMuseKnowledgeBindingController` 加 `GET /muse/works/{workId}/knowledge-bindings`→`listKnowledgeBindings`(requireWorkOwner 防 IDOR + selectActiveByWorkId 读回投影,id→String;happy-path + 越权 fail-closed 单测,整套件 218/218 绿);**bindings 读回后端腿(写→读→端点)已闭环,仅余 FE hook**。**FE 读回面板已加(2026-06-19)**:studio `useKnowledgeBindings` hook + `KnowledgeBindingsPanel`(镜像 DraftPanel)接入 `KnowledgePage`,契约+组件测试经 vitest 验证(全 studio 50/50、tsc 干净);live playwright e2e 需全栈 app(env 受限)未跑。bindings 写→读→端点→FE 读回展示链(除 live e2e)全通。**live e2e 已写 ready 未跑(2026-06-19)**:`muse-studio/e2e/knowledge-bindings.spec.ts`(镜像 graph spec,`playwright --list` 编译有效);活体受阻=远端 PG 宿主 100.64.0.8 离线(不在 tailnet)、无本地 PG 兜底,PG 宿主恢复后按 [.agents/knowledge §六] 起栈可跑。 **文本内容安全扫描已实现 + 摄入 scanning 关卡打通(2026-06-23,commit ec7637d)**:`KnowledgeContentScanService`(同步纯文本字符扫描——L1 非法 UTF-8/null/危险 C0-C1 控制符/Bidi 覆盖/体量硬阻断,L2 NFC+去零宽+换行规整,L3 prompt 注入软标记不阻断;特殊字符判定全用十六进制码点避免源码字面不可见字符),`KnowledgeFileFacade.materialize` 接入(passed→materialized+规整内容入 RAG、blocked→隔离带 reasonCode、扫描异常→fail-closed),替换原恒 `scanBlocked` 占位;`MaterializedFile.scanBlocked` 加 reasonCode/message(连带改 P1r live IT 构造签名)。单测 ScanService 9 + Facade 接线 3 + DocumentService 17 + P-B RetrievalApiImpl 11 全绿。**端到端真验(48080 + muse_slice_live + RAGFlow/New-API env 配齐)**:上传纯文本 document 4 → `scan_status=passed` → 处理链推进 → RAGFlow dataset(5950e9ee)+document(595cb63e)创建成功,坐实 scan 关卡 + muse→RAGFlow 集成贯通(对比 document 2 旧 jar 卡 SCAN_SERVICE_UNAVAILABLE、document 3 RAGFlow 未配 CONFIG_MISSING)。**E3 studio 与检索闭环已完成(2026-06-27)**:新增主动检索 App 端点与 `KnowledgeRetrievalPanel`no_dataset 显式治理提示,自建 KB 上传真实后端读回market KB handoff 正负路真 e2eAI 生成 e2e 反查 `source_summary.contextAssembly` 证实 Knowledge Source 被真实消费。fresh 证据:knowledge targeted 25/0F/0E/0S契约/迁移门 21/0F/0E/0Sstudio Vitest 19/0F/0E、ESLint/tsc 通过MSW-off Playwright knowledge 6/0F/0E + ai-generation 1/0F/0E。
- **2.0.0 S1 Dify Datasets live 契约(2026-07-07)**:Dify 1.15.0 已创建工作区级 Datasets API key(`dataset-` 类型,非 app key、非单 dataset `ds-` key),并通过 `P1rDifyDatasetsContractLiveIT` 真跑 create dataset / upload file / indexing-status poll / retrieve。验收 dataset `cce33d41-c738-42d2-97b6-2d08a69965f2` 保留在 Dify 作为证据,`indexingStatus=completed`、retrieval `score=0.7442479133605957`;默认未 opt-in 时 honest skipped。Dify 1.15 `/v1/datasets/{id}/retrieve` 的 `retrieval_model` 必须显式包含 `search_method=semantic_search` 与 `reranking_enable=false`,只传 `top_k/score_threshold_enabled` 会 400。Dify 官方 compose 在本机部署必须用 `STORAGE_TYPE=local` + `STORAGE_LOCAL_PATH=storage`,否则 upload 成功后 worker 取文件会报 `File not found`。工作区级 key 是管理钥匙,不是生产单 dataset 设计;S6 生产拓扑至少分全局公共 dataset 与作品独立 dataset,验收 dataset 只作证据。
- **2.0.0 S3 market 摘装配影响(2026-07-08)**:默认单体无 `market-server`knowledge 通过 `MarketHandoffTokenApi` / `MarketAssetSourceApi` / `MarketAssetForkApi` 只拿到 muse-server fallbackhandoff 不可用、asset source `notFound()`、fork 写回 `false`。market_kb binding 既有 fail-closed 测试继续覆盖 notFound / fork-not-readyinstalled knowledge bases 在无 projection 时返回空页,本轮补 `MuseInstalledKnowledgeBaseServiceTest` 空结果断言。2026-07-08 裁决:publish-prechecks / publish-snapshots / publish-readiness 保留 Knowledge 本域预检/快照能力,允许写本域 readiness/snapshot/command 记录,但不得产生 market 资产、安装、授权事实;前端发布入口由 S5 删除。
- **关键风险 / TODO**:前端图谱/发布深面仍薄;Task4 剩余 owner 缺口已收窄为 **document owner count / export_task_owner** 仍不能伪造,需后续 owner 事实或外部服务接入;已物化 KB 的召回触达/检索停用已在 E4 闭环。**2026-06-27:E4 market KB 召回触达已补并真验**:Knowledge 新增 `KnowledgeMarketSourceStatusChangedConsumer` 消费 Market `MarketAssetSourceStatusChangedEvent`,将本地 installed_ref KB 的 source binding projection 更新为 `recalled/blocked`,检索来源状态门返回 `omittedSources` 而不再送入 RAGFlowMSW-off Playwright `market-kb-recall.spec.ts` 1/0F/0E后端单测 `MuseKnowledgeSourceEventServiceTest`+`KnowledgeMarketSourceStatusChangedConsumerTest` 9/0F/0E。副本资源回收/更细补偿仍后置。**2026-06-27:E1 旧知识草稿失效 owner API 已补**:`MuseKnowledgeDraftInvalidationApi` 由 knowledge-api 定义、knowledge-server owner 实现Content 正文变更只传 work/chapter/block/revision/commandId,不跨 BC 传正文。Knowledge 将匹配的 pending draft 更新为 `conflicted` + `source_action_policy=needs_recheck`,并写 `muse_knowledge_draft_decision(decision_type=recheck)` 作为审计归档invalid command 跳过并 warnContent 通知失败不回滚 Canonical 主写confirm 端仍按 `needs_recheck` fail-closed。验证:`MuseKnowledgeDraftInvalidationServiceTest` 3/0F/0E`P1rContentMergeSuggestionIT` real-PG 5/0F/0E/0S 穿透验证采纳后旧 draft 失效与 replay 不重复。**2026-06-28:导入章节确认 Draft owner API 已补**:`MuseKnowledgeDraftCreationApi` 由 knowledge-api 定义、knowledge-server owner 实现Content/AI 只传章节 Shadow 结果与 sourceRefKnowledge 负责创建 pending draft 与幂等 command不写 Local KB Canonical仍需用户后续显式确认草稿才进入正式知识。验证:`MuseKnowledgeDraftCreationApiImplTest` 与完整导入链路 targeted 后端测试通过。**parse 轮询 worker 已补齐并端到端真验(2026-06-23,commit 635045c)**:`MuseKnowledgeParseStatusPollWorker`(@Scheduled + 开关 `muse.knowledge.parse-poll-worker.enabled`,跨租户捞 parsing 任务轮询 RAGFlow 文档状态→run=done 推 version processingStatus=searchable、failed 标败、processing 保持、RAGFlow 不可达/异常不误判)+ `ProcessingTaskService.markRagflowParseCompleted/markRagflowParsePolling` + `Mapper.selectParsingForPoll`;worker 接线 7 测试 + 回归 40 全绿;启用开关后 document 4 自动 parsing→searchable(isSearchable=true),摄入链 **scan→RAGFlow→worker→searchable** 全程贯通(此前"RAGFlow backend 故障"系本机 HTTP 代理误诊,RAGFlow 实始终正常,真因=muse 缺 parse 轮询 worker)。

View File

@ -1,10 +1,11 @@
package cn.iocoder.muse.module.knowledge.api;
import cn.iocoder.muse.framework.common.util.json.JsonUtils;
import cn.iocoder.muse.module.knowledge.application.muse.facade.RagFlowKnowledgeRuntimeClient;
import cn.iocoder.muse.module.knowledge.application.muse.facade.RagFlowKnowledgeRuntimeClient.RetrieveChunksCommand;
import cn.iocoder.muse.module.knowledge.application.muse.facade.RagFlowKnowledgeRuntimeClient.RuntimeResult;
import cn.iocoder.muse.module.knowledge.application.muse.facade.RagFlowKnowledgeRuntimeClient.Status;
import cn.iocoder.muse.module.knowledge.application.muse.facade.KnowledgeRuntimeClient;
import cn.iocoder.muse.module.knowledge.application.muse.facade.KnowledgeRuntimeClient.FailureClass;
import cn.iocoder.muse.module.knowledge.application.muse.facade.KnowledgeRuntimeClient.RetrieveChunksCommand;
import cn.iocoder.muse.module.knowledge.application.muse.facade.KnowledgeRuntimeClient.RuntimeResult;
import cn.iocoder.muse.module.knowledge.application.muse.facade.KnowledgeRuntimeClient.Status;
import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeBindingDO;
import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeRagflowBindingDO;
import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeSourceBindingProjectionDO;
@ -13,6 +14,7 @@ import cn.iocoder.muse.module.knowledge.dal.mysql.muse.MuseKnowledgeRagflowBindi
import cn.iocoder.muse.module.knowledge.dal.mysql.muse.MuseKnowledgeSourceBindingProjectionMapper;
import com.fasterxml.jackson.databind.JsonNode;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@ -36,6 +38,7 @@ import java.util.Set;
* {@code empty(omittedReason)},<b>不抛异常阻断主生成链</b></p>
*/
@Service
@Slf4j
public class MuseKnowledgeRetrievalApiImpl implements MuseKnowledgeRetrievalApi {
/** 单 chunk 正文摘要最大长度(token 预算意识,防 prompt 撑爆;专题-03 §4)。 */
@ -56,7 +59,7 @@ public class MuseKnowledgeRetrievalApiImpl implements MuseKnowledgeRetrievalApi
@Resource
private MuseKnowledgeRagflowBindingMapper ragflowBindingMapper;
@Resource
private RagFlowKnowledgeRuntimeClient ragFlowClient;
private KnowledgeRuntimeClient ragFlowClient;
/** impl 内部:通过双门的授权来源(承载检索 datasetId + chunk §5.3 字段填充所需授权元数据)。 */
private record AuthorizedSource(Long kbId, String datasetId, String sourceOwner, String sourceObjectVersion,
@ -135,41 +138,74 @@ public class MuseKnowledgeRetrievalApiImpl implements MuseKnowledgeRetrievalApi
? "not_authorized" : "no_dataset";
return RetrievalResult.empty(reason, omitted);
}
// 4. 一次跨所有授权 dataset 检索(RAGFlow 支持多 dataset_ids);chunk 再按 dataset 反查授权来源
List<String> datasetIds = new ArrayList<>();
Map<String, AuthorizedSource> datasetToSource = new LinkedHashMap<>();
for (AuthorizedSource source : authorized) {
if (datasetToSource.putIfAbsent(source.datasetId(), source) == null) {
datasetIds.add(source.datasetId());
}
}
AuthorizedSource primary = authorized.get(0);
// 4. 逐授权 dataset 检索并按相似度合并(provider 无关):
// WHY 逐库而非一次多库Dify retrieve 是单 dataset 端点( DifyKnowledgeRuntimeClient),
// 一次只能检索一个库;逐库调用后每次 chunk 直接归属本次检索的授权来源(Dify 单库响应不含
// dataset_id无法事后反查),再按相似度降序合并截断到全局 topKRAGFlow 亦兼容单库调用
// 顺序执行(非并行):检索处于租户 ThreadLocal 上下文内,同线程保留上下文,避免并行池线程丢
// 租户上下文的隔离风险;内测期单作品绑定 KB 数少顺序延迟可接受,并行留作后续优化
// 同一 dataset 多来源只检一次归属首个(与旧多库去重 putIfAbsent 语义一致)
int topK = request.topK() != null && request.topK() > 0 ? request.topK() : DEFAULT_TOP_K;
RetrieveChunksCommand command = new RetrieveChunksCommand(
request.tenantId(), request.ownerUserId(), primary.kbId(), datasetIds, null,
request.question(), topK, DEFAULT_THRESHOLD, null,
request.correlationId(), null, 1);
RuntimeResult result = ragFlowClient.retrieveChunks(command);
if (result == null || result.status() != Status.SUCCEEDED) {
String reason = result == null || result.failureClass() == null
? "retrieval_failed" : "retrieval_failed:" + result.failureClass().name();
return RetrievalResult.empty(reason, omitted);
List<RetrievedChunk> merged = new ArrayList<>();
Set<String> retrievedDataset = new HashSet<>();
FailureClass firstFailure = null;
boolean anySuccess = false;
for (AuthorizedSource source : authorized) {
if (!retrievedDataset.add(source.datasetId())) {
continue;
}
RetrieveChunksCommand command = new RetrieveChunksCommand(
request.tenantId(), request.ownerUserId(), source.kbId(),
List.of(source.datasetId()), null, request.question(), topK,
DEFAULT_THRESHOLD, null, request.correlationId(), null, 1);
RuntimeResult result;
try {
result = ragFlowClient.retrieveChunks(command);
} catch (RuntimeException ex) {
// 单库检索异常不阻断其它库:记失败日志降级跳过(fail-closed,不抛断主生成链)
if (firstFailure == null) {
firstFailure = FailureClass.UNKNOWN_FAILURE;
}
log.warn("[retrieveForWork][单库检索异常 correlationId={}, kbId={}, datasetId={}]",
request.correlationId(), source.kbId(), source.datasetId(), ex);
continue;
}
if (result == null || result.status() != Status.SUCCEEDED) {
FailureClass fc = result == null ? null : result.failureClass();
if (firstFailure == null && fc != null) {
firstFailure = fc;
}
log.info("[retrieveForWork][单库检索失败 correlationId={}, kbId={}, datasetId={}, failureClass={}]",
request.correlationId(), source.kbId(), source.datasetId(), fc);
continue;
}
anySuccess = true;
merged.addAll(parseChunks(result, source));
}
// 5. 解析 data.chunks[] §5.3 合同字段( dataset 反查授权来源)
List<RetrievedChunk> chunks = parseChunks(result, datasetToSource, primary);
if (chunks.isEmpty()) {
if (merged.isEmpty()) {
if (!anySuccess) {
// 全部授权库检索失败:回传代表性失败类(单库场景等价旧 retrieval_failed:CLASS)
String reason = firstFailure == null ? "retrieval_failed" : "retrieval_failed:" + firstFailure.name();
return RetrievalResult.empty(reason, omitted);
}
// 有库检索成功但都无命中
return RetrievalResult.empty("no_chunk", omitted);
}
return RetrievalResult.ok(chunks, omitted);
// 5. 合并排序:相似度降序(相似度缺失排最后),截断到全局 topK
merged.sort((a, b) -> Double.compare(
b.similarity() == null ? Double.NEGATIVE_INFINITY : b.similarity(),
a.similarity() == null ? Double.NEGATIVE_INFINITY : a.similarity()));
List<RetrievedChunk> topChunks = merged.size() > topK
? new ArrayList<>(merged.subList(0, topK)) : merged;
return RetrievalResult.ok(topChunks, omitted);
} catch (RuntimeException ex) {
// fail-closed 降级:检索任何异常都不阻断主生成链
return RetrievalResult.empty("retrieval_error");
}
}
/** 从 RAGFlow 检索响应树 data.chunks[] 解析脱敏片段;按 dataset 反查授权来源补齐 §5.3 字段,反查不到归 primary。 */
private List<RetrievedChunk> parseChunks(RuntimeResult result, Map<String, AuthorizedSource> datasetToSource,
AuthorizedSource primary) {
/** 解析单次检索(单库)响应 data.chunks[] → 直接归属【本次检索的授权来源】补齐 §5.3 合同字段。 */
private List<RetrievedChunk> parseChunks(RuntimeResult result, AuthorizedSource source) {
List<RetrievedChunk> chunks = new ArrayList<>();
Object responseBody = result.summary() == null ? null : result.summary().get("responseBody");
JsonNode tree = toJsonNode(responseBody);
@ -185,10 +221,12 @@ public class MuseKnowledgeRetrievalApiImpl implements MuseKnowledgeRetrievalApi
if (!StringUtils.hasText(content)) {
continue;
}
// datasetId:优先 chunk 自带字段(RAGFlow 多库遗留),缺失(Dify 单库响应无 dataset_id)则用本次检索来源的 datasetId
String datasetId = firstText(chunk, "dataset_id", "kb_id");
if (!StringUtils.hasText(datasetId)) {
datasetId = source.datasetId();
}
String documentId = firstText(chunk, "document_id", "doc_id");
AuthorizedSource source = datasetId != null && datasetToSource.containsKey(datasetId)
? datasetToSource.get(datasetId) : primary;
Double similarity = chunk.path("similarity").isNumber() ? chunk.path("similarity").asDouble() : null;
chunks.add(new RetrievedChunk(source.kbId(), datasetId, documentId, summarize(content), similarity,
source.sourceOwner(), source.sourceObjectVersion(), source.authorizationSnapshotId(),

View File

@ -3,7 +3,7 @@ package cn.iocoder.muse.module.knowledge.application.muse;
import cn.iocoder.muse.framework.common.util.json.JsonUtils;
import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.muse.module.knowledge.application.muse.facade.KnowledgeFileFacade;
import cn.iocoder.muse.module.knowledge.application.muse.facade.RagFlowKnowledgeRuntimeClient;
import cn.iocoder.muse.module.knowledge.application.muse.facade.KnowledgeRuntimeClient;
import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeDocumentDO;
import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeDocumentVersionDO;
import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeRagflowBindingDO;
@ -42,8 +42,8 @@ import java.util.UUID;
*
* <p>外部交互铁律CLAUDE.mdfork 是多次 RAGFlow 外部调用的<b>异步长任务</b>全程处理超时 / 失败 / 重试 /
* 幂等 / 部分成功补偿幂等键 (assetId, version) ready 跳过partial/failed 只补差集失败按
* {@link RagFlowKnowledgeRuntimeClient.FailureClass} 分可重试 / 不可重试每次 RAGFlow 调用复用
* {@link MuseKnowledgeAuditService#recordRagflowCall} 审计attributionStatus=market_public_fork
* {@link KnowledgeRuntimeClient.FailureClass} 分可重试 / 不可重试每次 RAGFlow 调用复用
* {@link MuseKnowledgeAuditService#recordRuntimeCall} 审计attributionStatus=market_public_fork
* fork 成败<b>绝不</b>反向影响 market 审核market 只在审核事务内落上架事件即返回本服务异步消费</p>
*/
@Slf4j
@ -71,7 +71,7 @@ public class KnowledgeMarketForkService {
@Resource
private KnowledgeFileFacade fileFacade;
@Resource
private RagFlowKnowledgeRuntimeClient ragFlowClient;
private KnowledgeRuntimeClient ragFlowClient;
@Resource
private MuseKnowledgeAuditService auditService;
@Resource
@ -183,14 +183,14 @@ public class KnowledgeMarketForkService {
String correlationId = forkCorrelationId(assetIdText, versionText);
String datasetName = forkDatasetName(assetIdText, versionText);
RagFlowKnowledgeRuntimeClient.RuntimeResult createResult = ragFlowClient.createDataset(
new RagFlowKnowledgeRuntimeClient.CreateDatasetCommand(event.tenantId(), event.publisherUserId(),
KnowledgeRuntimeClient.RuntimeResult createResult = ragFlowClient.createDataset(
new KnowledgeRuntimeClient.CreateDatasetCommand(event.tenantId(), event.publisherUserId(),
event.publisherKbId(), datasetName, forkDatasetConfig(assetIdText, versionText),
correlationId, requestHash(assetIdText, versionText, "createDataset"), 1));
auditForkCall(event, createResult, createResult.externalId(), null, correlationId,
Map.of("source", ATTRIBUTION_MARKET_PUBLIC_FORK, "datasetName", datasetName,
"assetId", assetIdText, "assetVersion", versionText));
if (createResult.status() == RagFlowKnowledgeRuntimeClient.Status.FAILED
if (createResult.status() == KnowledgeRuntimeClient.Status.FAILED
|| createResult.externalId() == null || createResult.externalId().isBlank()) {
// 建副本失败可重试错误保留中间态pending下轮重建不可重试错误标 failed两者都写回 market fail-closed
String status = isRetryable(createResult.failureClass()) ? FORK_STATUS_PENDING : FORK_STATUS_FAILED;
@ -282,32 +282,32 @@ public class KnowledgeMarketForkService {
PublicDocument document, byte[] content) {
String assetIdText = String.valueOf(event.assetId());
String correlationId = forkCorrelationId(assetIdText, String.valueOf(document.documentId()));
RagFlowKnowledgeRuntimeClient.RuntimeResult uploadResult = ragFlowClient.uploadDocuments(
new RagFlowKnowledgeRuntimeClient.UploadDocumentsCommand(event.tenantId(), event.publisherUserId(),
KnowledgeRuntimeClient.RuntimeResult uploadResult = ragFlowClient.uploadDocuments(
new KnowledgeRuntimeClient.UploadDocumentsCommand(event.tenantId(), event.publisherUserId(),
event.publisherKbId(), ragflowDatasetId,
List.of(new RagFlowKnowledgeRuntimeClient.DocumentUpload(document.documentId(),
List.of(new KnowledgeRuntimeClient.DocumentUpload(document.documentId(),
document.documentVersionId(), document.storageRef(), document.fileName(),
document.contentType(), document.size(), content)),
correlationId, requestHash(assetIdText, String.valueOf(document.documentId()), "upload"), 1));
auditForkCall(event, uploadResult, ragflowDatasetId, uploadResult.externalId(), correlationId,
Map.of("source", ATTRIBUTION_MARKET_PUBLIC_FORK, "assetId", assetIdText,
"documentId", document.documentId()));
if (uploadResult.status() == RagFlowKnowledgeRuntimeClient.Status.FAILED
if (uploadResult.status() == KnowledgeRuntimeClient.Status.FAILED
|| uploadResult.externalId() == null || uploadResult.externalId().isBlank()) {
log.warn("[uploadAndParseToFork][副本文档上传失败assetId={}, documentId={}, failureClass={}]",
assetIdText, document.documentId(), uploadResult.failureClass());
return false;
}
RagFlowKnowledgeRuntimeClient.RuntimeResult parseResult = ragFlowClient.startParseDocuments(
new RagFlowKnowledgeRuntimeClient.StartParseDocumentsCommand(event.tenantId(), event.publisherUserId(),
KnowledgeRuntimeClient.RuntimeResult parseResult = ragFlowClient.startParseDocuments(
new KnowledgeRuntimeClient.StartParseDocumentsCommand(event.tenantId(), event.publisherUserId(),
event.publisherKbId(), ragflowDatasetId, List.of(uploadResult.externalId()),
forkProcessingTaskId(assetIdText, document.documentId()), correlationId,
requestHash(assetIdText, String.valueOf(document.documentId()), "startParse"), 1));
auditForkCall(event, parseResult, ragflowDatasetId, uploadResult.externalId(), correlationId,
Map.of("source", ATTRIBUTION_MARKET_PUBLIC_FORK, "assetId", assetIdText,
"documentId", document.documentId()));
if (parseResult.status() == RagFlowKnowledgeRuntimeClient.Status.FAILED) {
if (parseResult.status() == KnowledgeRuntimeClient.Status.FAILED) {
log.warn("[uploadAndParseToFork][副本文档重索引触发失败assetId={}, documentId={}, failureClass={}]",
assetIdText, document.documentId(), parseResult.failureClass());
return false;
@ -356,10 +356,10 @@ public class KnowledgeMarketForkService {
// ============================== 审计 ==============================
private void auditForkCall(MuseKnowledgeMarketListedEvent event, RagFlowKnowledgeRuntimeClient.RuntimeResult result,
private void auditForkCall(MuseKnowledgeMarketListedEvent event, KnowledgeRuntimeClient.RuntimeResult result,
String ragflowDatasetId, String ragflowDocumentId, String correlationId,
Map<String, Object> requestSummary) {
auditService.recordRagflowCall(MuseKnowledgeAuditService.RagflowCallRecordReq.builder()
auditService.recordRuntimeCall(MuseKnowledgeAuditService.RuntimeCallRecordReq.builder()
// 同一 fork 会有 createDataset / upload / startParse 多次外部调用审计 correlation 必须区分 operation避免唯一键冲突
.correlationId(correlationId + ":" + result.operation().wireName())
.attemptNo(result.attempt())
@ -386,13 +386,13 @@ public class KnowledgeMarketForkService {
.build());
}
private boolean isRetryable(RagFlowKnowledgeRuntimeClient.FailureClass failureClass) {
private boolean isRetryable(KnowledgeRuntimeClient.FailureClass failureClass) {
// 沿用上传链路的可重试分类临时-04 §3.5临时性外部故障可重试其余校验 / 文件拒绝等不可重试
return failureClass == RagFlowKnowledgeRuntimeClient.FailureClass.RAGFLOW_UNAVAILABLE
|| failureClass == RagFlowKnowledgeRuntimeClient.FailureClass.TIMEOUT
|| failureClass == RagFlowKnowledgeRuntimeClient.FailureClass.RATE_LIMITED
|| failureClass == RagFlowKnowledgeRuntimeClient.FailureClass.CONFIG_MISSING
|| failureClass == RagFlowKnowledgeRuntimeClient.FailureClass.AUTH_MISSING;
return failureClass == KnowledgeRuntimeClient.FailureClass.RAGFLOW_UNAVAILABLE
|| failureClass == KnowledgeRuntimeClient.FailureClass.TIMEOUT
|| failureClass == KnowledgeRuntimeClient.FailureClass.RATE_LIMITED
|| failureClass == KnowledgeRuntimeClient.FailureClass.CONFIG_MISSING
|| failureClass == KnowledgeRuntimeClient.FailureClass.AUTH_MISSING;
}
// ============================== 命名 / 配置 / 摘要工具 ==============================

View File

@ -1,7 +1,7 @@
package cn.iocoder.muse.module.knowledge.application.muse;
import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.muse.module.knowledge.application.muse.facade.RagFlowKnowledgeRuntimeClient;
import cn.iocoder.muse.module.knowledge.application.muse.facade.KnowledgeRuntimeClient;
import cn.iocoder.muse.module.knowledge.application.muse.facade.RagFlowKnowledgeRedactor;
import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeRagflowCallDO;
import cn.iocoder.muse.module.knowledge.dal.mysql.muse.MuseKnowledgeRagflowCallMapper;
@ -15,7 +15,7 @@ import java.time.LocalDateTime;
import java.util.Locale;
/**
* Knowledge RAGFlow 审计服务
* Knowledge 运行时调用审计服务调用记录仍持久化到 muse_knowledge_ragflow_call 表名保留为命名债
*/
@Service
public class MuseKnowledgeAuditService {
@ -24,7 +24,7 @@ public class MuseKnowledgeAuditService {
private MuseKnowledgeRagflowCallMapper ragflowCallMapper;
@Transactional(rollbackFor = Exception.class)
public MuseKnowledgeRagflowCallDO recordRagflowCall(RagflowCallRecordReq req) {
public MuseKnowledgeRagflowCallDO recordRuntimeCall(RuntimeCallRecordReq req) {
String failureClass = req.getFailureClass() == null ? null : req.getFailureClass().name();
MuseKnowledgeRagflowCallDO call = new MuseKnowledgeRagflowCallDO();
call.setCorrelationId(req.getCorrelationId());
@ -66,14 +66,14 @@ public class MuseKnowledgeAuditService {
}
/**
* RAGFlow 调用审计创建请求
* 运行时调用审计创建请求
*/
@Data
@Builder
public static class RagflowCallRecordReq {
public static class RuntimeCallRecordReq {
private String correlationId;
private Integer attemptNo;
private RagFlowKnowledgeRuntimeClient.Operation operation;
private KnowledgeRuntimeClient.Operation operation;
private String commandId;
private String processingTaskId;
private Long actorUserId;
@ -85,14 +85,14 @@ public class MuseKnowledgeAuditService {
private String graphragOperation;
private String attributionStatus;
private String attributionBoundary;
private RagFlowKnowledgeRuntimeClient.Status status;
private KnowledgeRuntimeClient.Status status;
private String requestHash;
private String requestSummary;
private String responseSummary;
private Long durationMillis;
private String providerRequestId;
private Integer providerStatusCode;
private RagFlowKnowledgeRuntimeClient.FailureClass failureClass;
private KnowledgeRuntimeClient.FailureClass failureClass;
private String errorCode;
private String errorMessage;
private Boolean retryable;

View File

@ -6,7 +6,7 @@ import cn.iocoder.muse.framework.common.pojo.PageResult;
import cn.iocoder.muse.framework.common.util.json.JsonUtils;
import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.muse.module.knowledge.application.muse.facade.KnowledgeFileFacade;
import cn.iocoder.muse.module.knowledge.application.muse.facade.RagFlowKnowledgeRuntimeClient;
import cn.iocoder.muse.module.knowledge.application.muse.facade.KnowledgeRuntimeClient;
import cn.iocoder.muse.module.knowledge.controller.admin.muse.vo.AdminKnowledgeDocumentVO;
import cn.iocoder.muse.module.knowledge.controller.app.muse.vo.AppKnowledgeDocumentVO;
import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeBaseDO;
@ -76,7 +76,7 @@ public class MuseKnowledgeDocumentService {
@Resource
private MuseKnowledgeProcessingTaskService processingTaskService;
@Resource
private RagFlowKnowledgeRuntimeClient ragFlowClient;
private KnowledgeRuntimeClient ragFlowClient;
@Resource
private PlatformTransactionManager transactionManager;
@ -263,15 +263,15 @@ public class MuseKnowledgeDocumentService {
}
UploadContext ragflowContext = datasetResolution.context();
RagFlowKnowledgeRuntimeClient.RuntimeResult uploadResult = ragFlowClient.uploadDocuments(
new RagFlowKnowledgeRuntimeClient.UploadDocumentsCommand(TenantContextHolder.getRequiredTenantId(),
KnowledgeRuntimeClient.RuntimeResult uploadResult = ragFlowClient.uploadDocuments(
new KnowledgeRuntimeClient.UploadDocumentsCommand(TenantContextHolder.getRequiredTenantId(),
ragflowContext.kb().getOwnerUserId(), ragflowContext.kb().getId(), ragflowContext.ragflowDatasetId(),
List.of(new RagFlowKnowledgeRuntimeClient.DocumentUpload(ragflowContext.document().getId(),
List.of(new KnowledgeRuntimeClient.DocumentUpload(ragflowContext.document().getId(),
ragflowContext.version().getId(), ragflowContext.materialized().fileRef(),
ragflowContext.materialized().fileName(), ragflowContext.materialized().contentType(),
ragflowContext.materialized().size(), ragflowContext.materialized().content())),
ragflowContext.envelope().correlationId(), ragflowContext.envelope().requestHash(), 1));
if (uploadResult.status() == RagFlowKnowledgeRuntimeClient.Status.FAILED) {
if (uploadResult.status() == KnowledgeRuntimeClient.Status.FAILED) {
auditRagflowCall(ragflowContext, uploadResult, uploadResult.externalId());
processingTaskService.markRagflowUploadFailed(ragflowContext.version().getId(), ragflowContext.task().getTaskId(),
uploadResult);
@ -281,19 +281,22 @@ public class MuseKnowledgeDocumentService {
recordUploadRecoverySnapshot(ragflowContext, uploadResult.externalId(), "processing");
auditRagflowCall(ragflowContext, uploadResult, uploadResult.externalId());
persistDocumentBinding(ragflowContext, uploadResult.externalId());
RagFlowKnowledgeRuntimeClient.RuntimeResult parseResult = ragFlowClient.startParseDocuments(
new RagFlowKnowledgeRuntimeClient.StartParseDocumentsCommand(TenantContextHolder.getRequiredTenantId(),
KnowledgeRuntimeClient.RuntimeResult parseResult = ragFlowClient.startParseDocuments(
new KnowledgeRuntimeClient.StartParseDocumentsCommand(TenantContextHolder.getRequiredTenantId(),
ragflowContext.kb().getOwnerUserId(), ragflowContext.kb().getId(), ragflowContext.ragflowDatasetId(),
List.of(uploadResult.externalId()), ragflowContext.task().getTaskId(),
ragflowContext.envelope().correlationId(), ragflowContext.envelope().requestHash(), 1));
auditRagflowCall(ragflowContext, parseResult, uploadResult.externalId());
if (parseResult.status() == RagFlowKnowledgeRuntimeClient.Status.FAILED) {
if (parseResult.status() == KnowledgeRuntimeClient.Status.FAILED) {
processingTaskService.markRagflowParseFailed(ragflowContext.version().getId(), ragflowContext.task().getTaskId(),
uploadResult.externalId(), parseResult);
return new UploadOutcome(ragflowContext, "failed");
}
// Dify 摄入把索引批次 batch 透出到 uploadResult.summary().get("batch")是其 indexing-status 轮询主键
// RAGFlow 无此字段 runtimeBatchId null轮询回退 documentId行为逐字不变
String runtimeBatchId = uploadResult.summary() == null ? null : (String) uploadResult.summary().get("batch");
processingTaskService.markRagflowParseAccepted(ragflowContext.version().getId(), ragflowContext.task().getTaskId(),
ragflowContext.ragflowDatasetId(), uploadResult.externalId(), parseResult);
ragflowContext.ragflowDatasetId(), uploadResult.externalId(), runtimeBatchId, parseResult);
return new UploadOutcome(ragflowContext, "processing");
}
@ -302,13 +305,13 @@ public class MuseKnowledgeDocumentService {
return new DatasetBindingResolution(context, null);
}
String datasetName = datasetName(context);
RagFlowKnowledgeRuntimeClient.RuntimeResult createResult = ragFlowClient.createDataset(
new RagFlowKnowledgeRuntimeClient.CreateDatasetCommand(TenantContextHolder.getRequiredTenantId(),
KnowledgeRuntimeClient.RuntimeResult createResult = ragFlowClient.createDataset(
new KnowledgeRuntimeClient.CreateDatasetCommand(TenantContextHolder.getRequiredTenantId(),
context.kb().getOwnerUserId(), context.kb().getId(), datasetName,
datasetConfig(context), context.envelope().correlationId(),
context.envelope().requestHash(), 1));
auditDatasetCreateCall(context, createResult, datasetName);
if (createResult.status() == RagFlowKnowledgeRuntimeClient.Status.FAILED) {
if (createResult.status() == KnowledgeRuntimeClient.Status.FAILED) {
return new DatasetBindingResolution(context, createResult);
}
MuseKnowledgeRagflowBindingDO binding = persistDatasetBinding(context, createResult.externalId());
@ -353,11 +356,11 @@ public class MuseKnowledgeDocumentService {
context.replay(), context.replayProcessingStatus());
}
private RagFlowKnowledgeRuntimeClient.RuntimeResult datasetBindingConflictResult(UploadContext context) {
return new RagFlowKnowledgeRuntimeClient.RuntimeResult(null, RagFlowKnowledgeRuntimeClient.Operation.CREATE_DATASET,
private KnowledgeRuntimeClient.RuntimeResult datasetBindingConflictResult(UploadContext context) {
return new KnowledgeRuntimeClient.RuntimeResult(null, KnowledgeRuntimeClient.Operation.CREATE_DATASET,
context.envelope().correlationId(), context.envelope().requestHash(), 1, 0L,
RagFlowKnowledgeRuntimeClient.Status.FAILED,
RagFlowKnowledgeRuntimeClient.FailureClass.CONFLICT, context.task().getTaskId(),
KnowledgeRuntimeClient.Status.FAILED,
KnowledgeRuntimeClient.FailureClass.CONFLICT, context.task().getTaskId(),
JsonUtils.toJsonString(Map.of("status", "failed", "failureClass", "CONFLICT",
"reason", "dataset binding unique conflict")), List.of(), Map.of());
}
@ -409,7 +412,7 @@ public class MuseKnowledgeDocumentService {
});
}
private void auditRagflowCall(UploadContext context, RagFlowKnowledgeRuntimeClient.RuntimeResult result,
private void auditRagflowCall(UploadContext context, KnowledgeRuntimeClient.RuntimeResult result,
String ragflowDocumentId) {
auditRagflowCall(context, result, context.ragflowDatasetId(), ragflowDocumentId,
Map.of("fileRef", context.materialized().fileRef(),
@ -418,17 +421,17 @@ public class MuseKnowledgeDocumentService {
"size", context.materialized().size()));
}
private void auditDatasetCreateCall(UploadContext context, RagFlowKnowledgeRuntimeClient.RuntimeResult result,
private void auditDatasetCreateCall(UploadContext context, KnowledgeRuntimeClient.RuntimeResult result,
String datasetName) {
auditRagflowCall(context, result, result.externalId(), null,
Map.of("source", "dataset_auto_created", "datasetName", datasetName,
"operation", context.envelope().operationId(), "commandId", context.envelope().commandId()));
}
private void auditRagflowCall(UploadContext context, RagFlowKnowledgeRuntimeClient.RuntimeResult result,
private void auditRagflowCall(UploadContext context, KnowledgeRuntimeClient.RuntimeResult result,
String ragflowDatasetId, String ragflowDocumentId,
Map<String, Object> requestSummary) {
auditService.recordRagflowCall(MuseKnowledgeAuditService.RagflowCallRecordReq.builder()
auditService.recordRuntimeCall(MuseKnowledgeAuditService.RuntimeCallRecordReq.builder()
// 同一上传命令会有 upload parse 两次外部调用审计 correlation 必须区分 operation避免唯一键冲突
.correlationId(context.envelope().correlationId() + ":" + result.operation().wireName())
.attemptNo(result.attempt())
@ -456,12 +459,12 @@ public class MuseKnowledgeDocumentService {
.build());
}
private boolean isRetryableRagflowFailure(RagFlowKnowledgeRuntimeClient.FailureClass failureClass) {
return failureClass == RagFlowKnowledgeRuntimeClient.FailureClass.RAGFLOW_UNAVAILABLE
|| failureClass == RagFlowKnowledgeRuntimeClient.FailureClass.TIMEOUT
|| failureClass == RagFlowKnowledgeRuntimeClient.FailureClass.RATE_LIMITED
|| failureClass == RagFlowKnowledgeRuntimeClient.FailureClass.CONFIG_MISSING
|| failureClass == RagFlowKnowledgeRuntimeClient.FailureClass.AUTH_MISSING;
private boolean isRetryableRagflowFailure(KnowledgeRuntimeClient.FailureClass failureClass) {
return failureClass == KnowledgeRuntimeClient.FailureClass.RAGFLOW_UNAVAILABLE
|| failureClass == KnowledgeRuntimeClient.FailureClass.TIMEOUT
|| failureClass == KnowledgeRuntimeClient.FailureClass.RATE_LIMITED
|| failureClass == KnowledgeRuntimeClient.FailureClass.CONFIG_MISSING
|| failureClass == KnowledgeRuntimeClient.FailureClass.AUTH_MISSING;
}
private DeleteOutcome deleteDocument(Long actorUserId, String apiVersion, Long kbId, Long documentId,
@ -1059,7 +1062,7 @@ public class MuseKnowledgeDocumentService {
}
private record DatasetBindingResolution(UploadContext context,
RagFlowKnowledgeRuntimeClient.RuntimeResult failureResult) {
KnowledgeRuntimeClient.RuntimeResult failureResult) {
}
private record DeleteOutcome(String sourceEventId, Integer affectedBindings) {

View File

@ -1,7 +1,7 @@
package cn.iocoder.muse.module.knowledge.application.muse;
import cn.iocoder.muse.framework.tenant.core.util.TenantUtils;
import cn.iocoder.muse.module.knowledge.application.muse.facade.RagFlowKnowledgeRuntimeClient;
import cn.iocoder.muse.module.knowledge.application.muse.facade.KnowledgeRuntimeClient;
import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeProcessingTaskDO;
import cn.iocoder.muse.module.knowledge.dal.mysql.muse.MuseKnowledgeProcessingTaskMapper;
import jakarta.annotation.Resource;
@ -34,7 +34,7 @@ public class MuseKnowledgeParseStatusPollWorker {
@Resource
private MuseKnowledgeProcessingTaskMapper processingTaskMapper;
@Resource
private RagFlowKnowledgeRuntimeClient ragFlowClient;
private KnowledgeRuntimeClient ragFlowClient;
@Resource
private MuseKnowledgeProcessingTaskService processingTaskService;
@ -46,7 +46,7 @@ public class MuseKnowledgeParseStatusPollWorker {
// 测试构造直接注入依赖与开关避免起 Spring 上下文
MuseKnowledgeParseStatusPollWorker(MuseKnowledgeProcessingTaskMapper processingTaskMapper,
RagFlowKnowledgeRuntimeClient ragFlowClient,
KnowledgeRuntimeClient ragFlowClient,
MuseKnowledgeProcessingTaskService processingTaskService,
boolean enabled) {
this.processingTaskMapper = processingTaskMapper;
@ -76,8 +76,11 @@ public class MuseKnowledgeParseStatusPollWorker {
int advanced = 0;
for (MuseKnowledgeProcessingTaskDO task : tasks) {
// 缺租户/外部 id 的任务无法安全轮询跳过避免空指针与跨租户写串
// 轮询键防御性放宽ragflowDocumentId runtimeBatchId 都空才跳过Dify 场景两者都会有值
// RAGFlow 只有 documentId任一非空即可据以轮询
if (task.getTenantId() == null || !StringUtils.hasText(task.getRagflowDatasetId())
|| !StringUtils.hasText(task.getRagflowDocumentId())) {
|| (!StringUtils.hasText(task.getRagflowDocumentId())
&& !StringUtils.hasText(task.getRuntimeBatchId()))) {
continue;
}
advanced += TenantUtils.execute(task.getTenantId(), () -> pollOne(task));
@ -86,18 +89,22 @@ public class MuseKnowledgeParseStatusPollWorker {
}
private int pollOne(MuseKnowledgeProcessingTaskDO task) {
RagFlowKnowledgeRuntimeClient.RuntimeResult result;
KnowledgeRuntimeClient.RuntimeResult result;
// 轮询主键择键Dify 上传落 runtimeBatchIdindexing-status batch 优先用之
// RAGFlow batch 回退 ragflowDocumentId保持原逐字行为skip gate 已保证两者至少一非空pollKey 不为 null
String pollKey = StringUtils.hasText(task.getRuntimeBatchId())
? task.getRuntimeBatchId() : task.getRagflowDocumentId();
try {
result = ragFlowClient.pollDocumentStatuses(new RagFlowKnowledgeRuntimeClient.PollDocumentStatusesCommand(
result = ragFlowClient.pollDocumentStatuses(new KnowledgeRuntimeClient.PollDocumentStatusesCommand(
task.getTenantId(), task.getOwnerUserId(), task.getKbId(), task.getRagflowDatasetId(),
List.of(task.getRagflowDocumentId()), task.getTaskId(),
List.of(pollKey), task.getTaskId(),
pollCorrelationId(task), pollRequestHash(task), 1));
} catch (RuntimeException ex) {
// RAGFlow 临时不可达保持 parsing下轮重试不误判失败
log.warn("Knowledge parse 轮询异常taskId={}, errorType={}", task.getTaskId(), ex.getClass().getSimpleName());
return 0;
}
if (result.status() == RagFlowKnowledgeRuntimeClient.Status.FAILED) {
if (result.status() == KnowledgeRuntimeClient.Status.FAILED) {
log.warn("Knowledge parse 轮询 RAGFlow 返回失败taskId={}, failureClass={}",
task.getTaskId(), result.failureClass());
return 0; // 轮询本身失败非文档失败 保持 parsing 重试
@ -105,7 +112,7 @@ public class MuseKnowledgeParseStatusPollWorker {
if (result.documentStatuses() == null || result.documentStatuses().isEmpty()) {
return 0;
}
RagFlowKnowledgeRuntimeClient.DocumentStatus status = result.documentStatuses().getFirst();
KnowledgeRuntimeClient.DocumentStatus status = result.documentStatuses().getFirst();
String museStatus = status.museStatus();
if (MUSE_STATUS_SEARCHABLE.equals(museStatus)) {
processingTaskService.markRagflowParseCompleted(task.getDocumentVersionId(), task.getTaskId(),

View File

@ -5,7 +5,7 @@ import cn.iocoder.muse.framework.common.pojo.PageParam;
import cn.iocoder.muse.framework.common.pojo.PageResult;
import cn.iocoder.muse.framework.common.util.json.JsonUtils;
import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.muse.module.knowledge.application.muse.facade.RagFlowKnowledgeRuntimeClient;
import cn.iocoder.muse.module.knowledge.application.muse.facade.KnowledgeRuntimeClient;
import cn.iocoder.muse.module.knowledge.controller.admin.muse.vo.AdminKnowledgeTaskVO;
import cn.iocoder.muse.module.knowledge.controller.app.muse.vo.AppKnowledgeTaskVO;
import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeBaseDO;
@ -20,6 +20,7 @@ import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
@ -158,7 +159,7 @@ public class MuseKnowledgeProcessingTaskService {
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
public void markRagflowUploadFailed(Long documentVersionId, String taskId,
RagFlowKnowledgeRuntimeClient.RuntimeResult result) {
KnowledgeRuntimeClient.RuntimeResult result) {
MuseKnowledgeProcessingTaskDO task = processingTaskMapper.selectByTaskId(taskId);
if (task != null) {
task.setStatus("failed");
@ -178,7 +179,8 @@ public class MuseKnowledgeProcessingTaskService {
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
public void markRagflowParseAccepted(Long documentVersionId, String taskId, String ragflowDatasetId,
String ragflowDocumentId, RagFlowKnowledgeRuntimeClient.RuntimeResult result) {
String ragflowDocumentId, String runtimeBatchId,
KnowledgeRuntimeClient.RuntimeResult result) {
MuseKnowledgeProcessingTaskDO task = processingTaskMapper.selectByTaskId(taskId);
if (task != null) {
task.setStatus("parsing");
@ -187,6 +189,11 @@ public class MuseKnowledgeProcessingTaskService {
// 新建 dataset 的上传链路必须把外部 datasetId 同步固化到 task后续轮询补偿和 live 取证都依赖这条事实
task.setRagflowDatasetId(ragflowDatasetId);
task.setRagflowDocumentId(ragflowDocumentId);
// Dify 上传返回的索引批次是其 indexing-status 轮询主键必须固化RAGFlow batch runtimeBatchId 为空
// hasText 判定避免把已落值覆盖成 nullpoll worker 据此在 batch / documentId 间择键
if (StringUtils.hasText(runtimeBatchId)) {
task.setRuntimeBatchId(runtimeBatchId);
}
task.setRetryable(false);
task.setProgressMessage("RAGFlow parse accepted; polling required");
task.setResultSummary(JsonUtils.toJsonString(Map.of("ragflow", safeSummary(result))));
@ -197,7 +204,7 @@ public class MuseKnowledgeProcessingTaskService {
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
public void markRagflowParseFailed(Long documentVersionId, String taskId, String ragflowDocumentId,
RagFlowKnowledgeRuntimeClient.RuntimeResult result) {
KnowledgeRuntimeClient.RuntimeResult result) {
MuseKnowledgeProcessingTaskDO task = processingTaskMapper.selectByTaskId(taskId);
if (task != null) {
task.setStatus("failed");
@ -220,7 +227,7 @@ public class MuseKnowledgeProcessingTaskService {
*/
@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
public void markRagflowParseCompleted(Long documentVersionId, String taskId, String ragflowDocumentId,
Integer progress, RagFlowKnowledgeRuntimeClient.RuntimeResult result) {
Integer progress, KnowledgeRuntimeClient.RuntimeResult result) {
MuseKnowledgeProcessingTaskDO task = processingTaskMapper.selectByTaskId(taskId);
if (task != null) {
task.setStatus("completed");
@ -458,19 +465,19 @@ public class MuseKnowledgeProcessingTaskService {
documentVersionMapper.updateById(version);
}
private boolean isRetryable(RagFlowKnowledgeRuntimeClient.FailureClass failureClass) {
return failureClass == RagFlowKnowledgeRuntimeClient.FailureClass.RAGFLOW_UNAVAILABLE
|| failureClass == RagFlowKnowledgeRuntimeClient.FailureClass.TIMEOUT
|| failureClass == RagFlowKnowledgeRuntimeClient.FailureClass.RATE_LIMITED
|| failureClass == RagFlowKnowledgeRuntimeClient.FailureClass.CONFIG_MISSING
|| failureClass == RagFlowKnowledgeRuntimeClient.FailureClass.AUTH_MISSING;
private boolean isRetryable(KnowledgeRuntimeClient.FailureClass failureClass) {
return failureClass == KnowledgeRuntimeClient.FailureClass.RAGFLOW_UNAVAILABLE
|| failureClass == KnowledgeRuntimeClient.FailureClass.TIMEOUT
|| failureClass == KnowledgeRuntimeClient.FailureClass.RATE_LIMITED
|| failureClass == KnowledgeRuntimeClient.FailureClass.CONFIG_MISSING
|| failureClass == KnowledgeRuntimeClient.FailureClass.AUTH_MISSING;
}
private String failureName(RagFlowKnowledgeRuntimeClient.RuntimeResult result) {
private String failureName(KnowledgeRuntimeClient.RuntimeResult result) {
return result.failureClass() == null ? "RAGFLOW_FAILED" : result.failureClass().name();
}
private Map<String, Object> safeSummary(RagFlowKnowledgeRuntimeClient.RuntimeResult result) {
private Map<String, Object> safeSummary(KnowledgeRuntimeClient.RuntimeResult result) {
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("operation", result.operation().wireName());
summary.put("status", result.status().name());

View File

@ -0,0 +1,615 @@
package cn.iocoder.muse.module.knowledge.application.muse.facade;
import cn.iocoder.muse.framework.common.util.json.JsonUtils;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.springframework.util.StringUtils;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.ProxySelector;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpHeaders;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
/**
* Dify Datasets API Knowledge 运行时 adapter
*
* <p>HTTP 范式对齐 {@code RealDifyMuseAiRuntimeClient}:
* JDK {@link HttpClient} + 直连 {@link ProxySelector}(内网 Dify 必须绕过系统代理,否则 live 调用被污染成假 502)+
* Bearer(Datasets API key)+ 脱敏审计 + fail-closed 错误映射契约以 S1 实测的 {@code P1rDifyDatasetsContractLiveIT} 为准</p>
*
* <p>5 操作与 Dify Datasets API 映射(base-url 形如 {@code http://host:port/v1},路径按 {@code /v1} 归一去重):
* <ul>
* <li>createDataset {@code POST /v1/datasets},响应顶层 {@code id}</li>
* <li>uploadDocuments {@code POST /v1/datasets/{id}/document/create-by-file}(multipart:data+file),
* 响应嵌套 {@code document.id} + 顶层 {@code batch}(batch 是后续轮询主键,透出到 summary 供上传链落库)</li>
* <li>startParseDocuments Dify 上传即索引,无独立触发端点:降级 no-op(不外呼),仅保留 ACCEPTED 审计记录</li>
* <li>pollDocumentStatuses {@code GET /v1/datasets/{id}/documents/{batch}/indexing-status},轮询主键=batch;
* 响应 {@code data} 可能是数组或对象,两态都处理, {@code indexing_status}(回退 {@code status})</li>
* <li>retrieveChunks {@code POST /v1/datasets/{id}/retrieve}(单库端点, adapter 只管单 dataset;
* dataset 并行合并归 RetrievalApiImpl),响应 {@code records[].segment.content} + {@code records[].score}</li>
* </ul>
* </p>
*/
public class DifyKnowledgeRuntimeClient implements KnowledgeRuntimeClient {
private static final ProxySelector DIRECT_PROXY_SELECTOR = ProxySelector.of(null);
private final String baseUrl;
private final String apiKey;
private final Duration timeout;
private final int retryBudget;
private final HttpClient httpClient;
public DifyKnowledgeRuntimeClient(String baseUrl, String apiKey, Duration timeout, int retryBudget) {
this.baseUrl = normalizeBaseUrl(baseUrl);
this.apiKey = apiKey;
this.timeout = timeout == null ? Duration.ofSeconds(5) : timeout;
this.retryBudget = Math.max(retryBudget, 0);
this.httpClient = HttpClient.newBuilder()
.connectTimeout(this.timeout)
// Dify 部署在 tailnet 内网,显式直连避免系统代理污染 live 调用与验收
.proxy(DIRECT_PROXY_SELECTOR)
.build();
}
// ==================== 5 操作 ====================
@Override
public RuntimeResult createDataset(CreateDatasetCommand command) {
// S1 契约:仅提交 name + permission=only_me + indexing_technique=high_quality;禁止把公共资料/作品私有资料
// 混进单一 dataset 后仅靠 metadata 当边界,因此固定 only_me(作品/公共 dataset 隔离由上层拓扑负责)
Map<String, Object> body = new LinkedHashMap<>();
body.put("name", command.name());
body.put("permission", "only_me");
body.put("indexing_technique", "high_quality");
Exchange exchange = exchange(Operation.CREATE_DATASET, command.correlationId(), command.requestHash(),
command.attempt(), "POST", "/v1/datasets", jsonBody(body), "application/json", null);
if (exchange instanceof Exchange.Failure failure) {
return failure.result();
}
JsonNode responseBody = ((Exchange.Success) exchange).body();
// Dify 建库响应无 data 信封,id 在顶层
String datasetId = text(responseBody, "id");
if (!StringUtils.hasText(datasetId)) {
return failure(Operation.CREATE_DATASET, command.correlationId(), command.requestHash(), command.attempt(),
null, FailureClass.RESPONSE_SCHEMA_CHANGED, exchange.durationMillis(), "dataset id missing");
}
Map<String, Object> metadata = metadata(Operation.CREATE_DATASET);
metadata.put("externalId", datasetId);
return succeeded(datasetId, Operation.CREATE_DATASET, command.correlationId(), command.requestHash(),
command.attempt(), null, exchange.durationMillis(), metadata, null, List.of());
}
@Override
public RuntimeResult uploadDocuments(UploadDocumentsCommand command) {
// Dify create-by-file 是单文件端点;当前上传链每次只投一份已材料化资源
MultipartPayload payload = buildSingleFileMultipart(command.documents());
if (payload == null) {
return failure(Operation.UPLOAD_DOCUMENTS, command.correlationId(), command.requestHash(), command.attempt(),
null, FailureClass.FILE_REJECTED, 0L, "single materialized document required for dify upload");
}
Exchange exchange = exchange(Operation.UPLOAD_DOCUMENTS, command.correlationId(), command.requestHash(),
command.attempt(), "POST",
"/v1/datasets/" + command.ragflowDatasetId() + "/document/create-by-file",
payload.body(), payload.contentType(), null);
if (exchange instanceof Exchange.Failure failure) {
return failure.result();
}
JsonNode responseBody = ((Exchange.Success) exchange).body();
// 契约:document.id 嵌套batch 在顶层;batch Dify 索引批次,也是后续轮询主键,二者缺一都无法闭环
String documentId = text(responseBody.path("document"), "id");
String batch = text(responseBody, "batch");
if (!StringUtils.hasText(documentId) || !StringUtils.hasText(batch)) {
return failure(Operation.UPLOAD_DOCUMENTS, command.correlationId(), command.requestHash(), command.attempt(),
null, FailureClass.RESPONSE_SCHEMA_CHANGED, exchange.durationMillis(), "document.id or batch missing");
}
Map<String, Object> metadata = metadata(Operation.UPLOAD_DOCUMENTS);
metadata.put("externalId", documentId);
// batch 透出到 summary:上传链据此落库 runtime_batch_id(轮询主键),externalId 仍保持 document id 以兼容既有绑定持久化
metadata.put("batch", batch);
return succeeded(documentId, Operation.UPLOAD_DOCUMENTS, command.correlationId(), command.requestHash(),
command.attempt(), null, exchange.durationMillis(), metadata, null, List.of());
}
@Override
public RuntimeResult startParseDocuments(StartParseDocumentsCommand command) {
// Dify 上传即触发索引,无独立解析触发端点:降级 no-op,不外呼;仅当运行时确实配置就绪时返回 ACCEPTED
FailureClass configFailure = validateConfiguration();
if (configFailure != null) {
return failure(Operation.START_PARSE_DOCUMENTS, command.correlationId(), command.requestHash(),
command.attempt(), command.processingTaskId(), configFailure, 0L, configFailure.name());
}
Map<String, Object> summary = metadata(Operation.START_PARSE_DOCUMENTS);
summary.put("status", "accepted");
// no-op 语义显式记账:Dify 无独立 parse 触发端点,索引在 upload 阶段已启动
summary.put("mode", "dify_auto_index_on_upload");
summary.put("processingTaskId", command.processingTaskId());
return new RuntimeResult(null, Operation.START_PARSE_DOCUMENTS, command.correlationId(), command.requestHash(),
command.attempt(), 0L, Status.ACCEPTED, null, command.processingTaskId(),
sanitize(summary), List.of(), summary);
}
@Override
public RuntimeResult pollDocumentStatuses(PollDocumentStatusesCommand command) {
// 轮询主键=batch(命名债:command 沿用 ragflowDocumentIds 字段名承载 Dify batch,列注释在 S6c 订正)
List<String> pollKeys = command.ragflowDocumentIds();
if (pollKeys == null || pollKeys.isEmpty()) {
return failure(Operation.POLL_DOCUMENT_STATUSES, command.correlationId(), command.requestHash(),
command.attempt(), command.processingTaskId(), FailureClass.VALIDATION_ERROR, 0L,
"batch is required for dify indexing-status polling");
}
if (pollKeys.size() > 1) {
// Dify indexing-status 按单个 batch 查询; batch 批量轮询应在调用方拆分
return failure(Operation.POLL_DOCUMENT_STATUSES, command.correlationId(), command.requestHash(),
command.attempt(), command.processingTaskId(), FailureClass.VALIDATION_ERROR, 0L,
"only support single batch status polling");
}
String batch = pollKeys.getFirst();
Exchange exchange = exchange(Operation.POLL_DOCUMENT_STATUSES, command.correlationId(), command.requestHash(),
command.attempt(), "GET",
"/v1/datasets/" + command.ragflowDatasetId() + "/documents/" + encode(batch) + "/indexing-status",
new byte[0], null, command.processingTaskId());
if (exchange instanceof Exchange.Failure failure) {
return failure.result();
}
JsonNode responseBody = ((Exchange.Success) exchange).body();
List<DocumentStatus> statuses = normalizeIndexingStatuses(responseBody);
Map<String, Object> summary = metadata(Operation.POLL_DOCUMENT_STATUSES);
summary.put("documents", statuses);
return new RuntimeResult(null, Operation.POLL_DOCUMENT_STATUSES, command.correlationId(), command.requestHash(),
command.attempt(), exchange.durationMillis(), Status.SUCCEEDED, null, command.processingTaskId(),
sanitize(summary), statuses, summary);
}
@Override
public RuntimeResult retrieveChunks(RetrieveChunksCommand command) {
// Dify retrieve 是单库端点, adapter 只检索单个 dataset;多授权 dataset 并行 + score 合并归 RetrievalApiImpl
String datasetId = firstNonBlank(command.ragflowDatasetIds());
if (datasetId == null) {
return failure(Operation.RETRIEVE_CHUNKS, command.correlationId(), command.requestHash(), command.attempt(),
null, FailureClass.VALIDATION_ERROR, 0L, "dataset id is required for dify retrieve");
}
Map<String, Object> retrievalModel = new LinkedHashMap<>();
retrievalModel.put("search_method", "semantic_search");
retrievalModel.put("reranking_enable", false);
if (command.topK() != null && command.topK() > 0) {
retrievalModel.put("top_k", command.topK());
}
// 忠实承接调用方阈值:threshold 非空即启用 Dify score_threshold( RAGFlow similarity_threshold 语义一致),
// 不静默丢弃调用方意图;契约 IT 固定 false 仅为钉响应形态,不约束阈值策略
boolean thresholdEnabled = command.threshold() != null;
retrievalModel.put("score_threshold_enabled", thresholdEnabled);
if (thresholdEnabled) {
retrievalModel.put("score_threshold", command.threshold());
}
Map<String, Object> body = new LinkedHashMap<>();
body.put("query", command.question());
body.put("retrieval_model", retrievalModel);
Exchange exchange = exchange(Operation.RETRIEVE_CHUNKS, command.correlationId(), command.requestHash(),
command.attempt(), "POST", "/v1/datasets/" + datasetId + "/retrieve",
jsonBody(body), "application/json", null);
if (exchange instanceof Exchange.Failure failure) {
return failure.result();
}
JsonNode responseBody = ((Exchange.Success) exchange).body();
// Dify records[].segment.content/score 归一成下游已消费的 data.chunks[](content/similarity)结构;
// Dify 单库响应不含 dataset_id,归属由 RetrievalApiImpl 按本次检索的 dataset 补齐
ObjectNode normalized = normalizeRetrieveResponse(responseBody);
int chunkCount = normalized.path("data").path("chunks").size();
Map<String, Object> metadata = metadata(Operation.RETRIEVE_CHUNKS);
metadata.put("chunksCount", chunkCount);
Map<String, Object> summary = new LinkedHashMap<>(metadata);
// responseBody 仅进程内承载归一后的检索结果供 RetrievalApiImpl 解析;redactedSummary 只落元数据,不落正文
summary.put("responseBody", normalized);
return new RuntimeResult(null, Operation.RETRIEVE_CHUNKS, command.correlationId(), command.requestHash(),
command.attempt(), exchange.durationMillis(), Status.SUCCEEDED, null, null,
sanitize(metadata), List.of(), summary);
}
// ==================== HTTP 交换(可测试 send ) ====================
/**
* 底层 HTTP 交换:配置校验 重试循环 发送 HTTP 状态分类 JSON 解析
* 任何失败返回 {@link Exchange.Failure}(已是 fail-closed RuntimeResult);成功返回解析后的响应树
*/
private Exchange exchange(Operation operation, String correlationId, String requestHash, int attempt,
String method, String path, byte[] requestBody, String contentType,
String processingTaskId) {
Instant startedAt = Instant.now();
FailureClass configFailure = validateConfiguration();
if (configFailure != null) {
return new Exchange.Failure(failure(operation, correlationId, requestHash, attempt, processingTaskId,
configFailure, elapsedMillis(startedAt), configFailure.name()), elapsedMillis(startedAt));
}
int maxAttempts = Math.max(1, retryBudget + 1);
RuntimeResult lastFailure = null;
for (int currentAttempt = 1; currentAttempt <= maxAttempts; currentAttempt++) {
try {
HttpResponsePayload response = send(new HttpRequestPayload(method, path,
requestBody == null ? new byte[0] : requestBody, contentType));
long duration = elapsedMillis(startedAt);
FailureClass httpFailure = classifyHttpFailure(response.statusCode());
if (httpFailure != null) {
lastFailure = failure(operation, correlationId, requestHash, attempt, processingTaskId,
httpFailure, duration, response.body());
if (!isRetryable(httpFailure) || currentAttempt == maxAttempts) {
return new Exchange.Failure(lastFailure, duration);
}
continue;
}
JsonNode body = parseBody(response.body());
if (body == null) {
return new Exchange.Failure(failure(operation, correlationId, requestHash, attempt,
processingTaskId, FailureClass.RESPONSE_SCHEMA_CHANGED, duration,
"response is not valid json"), duration);
}
return new Exchange.Success(body, duration);
} catch (java.net.http.HttpTimeoutException ex) {
lastFailure = failure(operation, correlationId, requestHash, attempt, processingTaskId,
FailureClass.TIMEOUT, elapsedMillis(startedAt), ex.getMessage());
} catch (IOException ex) {
// Dify 不可达按运行时不可用处理(沿用既有 RAGFLOW_UNAVAILABLE 语义,命名债 S6d 订正)
lastFailure = failure(operation, correlationId, requestHash, attempt, processingTaskId,
FailureClass.RAGFLOW_UNAVAILABLE, elapsedMillis(startedAt), ex.getMessage());
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
return new Exchange.Failure(failure(operation, correlationId, requestHash, attempt, processingTaskId,
FailureClass.RAGFLOW_UNAVAILABLE, elapsedMillis(startedAt), ex.getMessage()),
elapsedMillis(startedAt));
} catch (RuntimeException ex) {
return new Exchange.Failure(failure(operation, correlationId, requestHash, attempt, processingTaskId,
FailureClass.UNKNOWN_FAILURE, elapsedMillis(startedAt), ex.getMessage()),
elapsedMillis(startedAt));
}
if (currentAttempt == maxAttempts) {
return new Exchange.Failure(lastFailure, elapsedMillis(startedAt));
}
}
return new Exchange.Failure(lastFailure == null
? failure(operation, correlationId, requestHash, attempt, processingTaskId,
FailureClass.UNKNOWN_FAILURE, elapsedMillis(startedAt), "unknown failure")
: lastFailure, elapsedMillis(startedAt));
}
/** HTTP 发送缝:单测子类覆盖此方法以桩化/捕获请求,不真发网络。 */
protected HttpResponsePayload send(HttpRequestPayload request) throws IOException, InterruptedException {
HttpRequest.Builder builder = HttpRequest.newBuilder()
.uri(endpoint(request.path()))
.timeout(timeout)
.header("Accept", "application/json");
if (StringUtils.hasText(apiKey)) {
builder.header("Authorization", "Bearer " + apiKey);
}
if ("GET".equals(request.method())) {
builder.GET();
} else {
builder.header("Content-Type", request.contentType() == null ? "application/json" : request.contentType())
.method(request.method(), HttpRequest.BodyPublishers.ofByteArray(request.body()));
}
HttpResponse<String> response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString());
return new HttpResponsePayload(response.statusCode(), response.body(), response.headers());
}
// ==================== 响应归一 ====================
/** Dify indexing-status 响应 data 可能是数组或对象,两态都归一为 DocumentStatus 列表。 */
private List<DocumentStatus> normalizeIndexingStatuses(JsonNode responseBody) {
List<DocumentStatus> statuses = new ArrayList<>();
JsonNode data = responseBody.path("data");
if (data.isArray()) {
for (JsonNode entry : data) {
statuses.add(toDocumentStatus(entry));
}
} else if (data.isObject()) {
statuses.add(toDocumentStatus(data));
} else {
// 兜底:部分形态直接把状态放在顶层
statuses.add(toDocumentStatus(responseBody));
}
return statuses;
}
private DocumentStatus toDocumentStatus(JsonNode entry) {
String indexingStatus = entry.path("indexing_status").asText(entry.path("status").asText(""));
String documentId = text(entry, "id", "document_id");
Integer progress = computeProgress(entry);
return new DocumentStatus(documentId, normalizeMuseStatus(indexingStatus), indexingStatus, progress,
text(entry, "error"));
}
/** Dify 提供 completed_segments / total_segments,可折算百分比进度供轮询痕迹;缺失则不填。 */
private Integer computeProgress(JsonNode entry) {
JsonNode completed = entry.path("completed_segments");
JsonNode total = entry.path("total_segments");
if (completed.isNumber() && total.isNumber() && total.asInt() > 0) {
return Math.min(100, (int) Math.round(completed.asDouble() * 100.0 / total.asDouble()));
}
return null;
}
/** Dify 索引状态枚举 → Muse 处理状态。paused 归 processing(非终态、非失败,保持轮询)。 */
private String normalizeMuseStatus(String indexingStatus) {
String normalized = indexingStatus == null ? "" : indexingStatus.toLowerCase(Locale.ROOT);
return switch (normalized) {
case "completed" -> "searchable";
case "error" -> "failed";
case "waiting", "parsing", "cleaning", "splitting", "indexing", "paused" -> "processing";
default -> "pending_process";
};
}
/** Dify retrieve 的 records[].segment.content/score 归一为下游 data.chunks[](content/similarity/document_id)。 */
private ObjectNode normalizeRetrieveResponse(JsonNode responseBody) {
ObjectNode root = JsonUtils.getObjectMapper().createObjectNode();
ObjectNode data = root.putObject("data");
ArrayNode chunks = data.putArray("chunks");
JsonNode records = responseBody.path("records");
if (records.isArray()) {
for (JsonNode record : records) {
JsonNode segment = record.path("segment");
String content = text(segment, "content");
if (!StringUtils.hasText(content)) {
continue;
}
ObjectNode chunk = chunks.addObject();
chunk.put("content", content);
if (record.path("score").isNumber()) {
chunk.put("similarity", record.path("score").asDouble());
}
String documentId = text(segment, "document_id", "doc_id");
if (StringUtils.hasText(documentId)) {
chunk.put("document_id", documentId);
}
}
}
return root;
}
// ==================== 结果构造与错误映射 ====================
private RuntimeResult succeeded(String externalId, Operation operation, String correlationId, String requestHash,
int attempt, String processingTaskId, long durationMillis,
Map<String, Object> metadata, JsonNode responseBody,
List<DocumentStatus> documentStatuses) {
Map<String, Object> summary = new LinkedHashMap<>(metadata);
if (responseBody != null) {
summary.put("responseBody", responseBody);
}
return new RuntimeResult(externalId, operation, correlationId, requestHash, attempt, durationMillis,
Status.SUCCEEDED, null, processingTaskId, sanitize(metadata), documentStatuses, summary);
}
private RuntimeResult failure(Operation operation, String correlationId, String requestHash, int attempt,
String processingTaskId, FailureClass failureClass, long durationMillis,
String message) {
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("status", Status.FAILED.name().toLowerCase(Locale.ROOT));
summary.put("failureClass", failureClass.name());
summary.put("operation", operation.wireName());
if (message != null && !message.isBlank()) {
// provider 原始 body 一律脱敏成指纹,绝不落原文,避免泄漏 key / 正文
summary.put("providerMessageSummary", safeSummaryMap(message));
}
return new RuntimeResult(null, operation, correlationId, requestHash, attempt, durationMillis,
Status.FAILED, failureClass, processingTaskId, sanitize(summary), List.of(), summary);
}
private FailureClass validateConfiguration() {
if (!StringUtils.hasText(baseUrl)) {
return FailureClass.CONFIG_MISSING;
}
if (!StringUtils.hasText(apiKey)) {
return FailureClass.AUTH_MISSING;
}
return null;
}
/** Dify Datasets API 用 HTTP 状态码承载错误;映射到既有 FailureClass 语义。 */
private FailureClass classifyHttpFailure(int statusCode) {
if (statusCode >= 200 && statusCode < 300) {
return null;
}
return switch (statusCode) {
case 400, 422 -> FailureClass.VALIDATION_ERROR;
case 401, 403 -> FailureClass.AUTH_FAILED;
case 404 -> FailureClass.TASK_NOT_FOUND;
case 409 -> FailureClass.CONFLICT;
case 413, 415 -> FailureClass.FILE_REJECTED;
case 429 -> FailureClass.RATE_LIMITED;
default -> statusCode >= 500 ? FailureClass.RAGFLOW_UNAVAILABLE : FailureClass.UNKNOWN_FAILURE;
};
}
private boolean isRetryable(FailureClass failureClass) {
return failureClass == FailureClass.RAGFLOW_UNAVAILABLE
|| failureClass == FailureClass.TIMEOUT
|| failureClass == FailureClass.RATE_LIMITED;
}
// ==================== multipart / 工具 ====================
/** 构造 Dify create-by-file 的 multipart:data 段(JSON 索引参数)+ file 段(文件字节)。单文件、须已材料化。 */
private MultipartPayload buildSingleFileMultipart(List<DocumentUpload> documents) {
if (documents == null || documents.size() != 1) {
return null;
}
DocumentUpload document = documents.getFirst();
if (document == null || document.content() == null || document.content().length == 0
|| !StringUtils.hasText(document.fileName())) {
return null;
}
Map<String, Object> data = new LinkedHashMap<>();
data.put("indexing_technique", "high_quality");
data.put("process_rule", Map.of("mode", "automatic"));
String dataJson = JsonUtils.toJsonString(data);
String contentType = StringUtils.hasText(document.contentType()) ? document.contentType() : "application/octet-stream";
String boundary = "----MuseDify" + UUID.randomUUID().toString().replace("-", "");
try {
ByteArrayOutputStream body = new ByteArrayOutputStream();
writeAscii(body, "--" + boundary + "\r\n");
writeAscii(body, "Content-Disposition: form-data; name=\"data\"\r\n");
writeAscii(body, "Content-Type: application/json; charset=UTF-8\r\n\r\n");
body.write(dataJson.getBytes(StandardCharsets.UTF_8));
writeAscii(body, "\r\n");
writeAscii(body, "--" + boundary + "\r\n");
writeAscii(body, "Content-Disposition: form-data; name=\"file\"; filename=\""
+ escapeMultipart(document.fileName()) + "\"\r\n");
writeAscii(body, "Content-Type: " + contentType + "\r\n\r\n");
body.write(document.content());
writeAscii(body, "\r\n");
writeAscii(body, "--" + boundary + "--\r\n");
return new MultipartPayload(body.toByteArray(), "multipart/form-data; boundary=" + boundary);
} catch (IOException ex) {
return null;
}
}
private Map<String, Object> metadata(Operation operation) {
Map<String, Object> metadata = new LinkedHashMap<>();
metadata.put("operation", operation.wireName());
metadata.put("status", Status.SUCCEEDED.name().toLowerCase(Locale.ROOT));
return metadata;
}
private Map<String, Object> safeSummaryMap(String message) {
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("type", "provider_body");
summary.put("length", message == null ? 0 : message.length());
summary.put("sha256", sha256(message == null ? "" : message));
summary.put("redacted", true);
summary.put("json", looksLikeJson(message));
return summary;
}
private String sanitize(Object value) {
return RagFlowKnowledgeRedactor.sanitizeObject(value);
}
private byte[] jsonBody(Object body) {
return JsonUtils.toJsonString(body == null ? Map.of() : body).getBytes(StandardCharsets.UTF_8);
}
private JsonNode parseBody(String responseBody) {
try {
return JsonUtils.parseTree(responseBody == null || responseBody.isBlank() ? "{}" : responseBody);
} catch (RuntimeException ex) {
return null;
}
}
private String text(JsonNode node, String... fieldNames) {
if (node == null || node.isMissingNode() || node.isNull()) {
return null;
}
for (String fieldName : fieldNames) {
JsonNode value = node.path(fieldName);
if (value.isTextual() && StringUtils.hasText(value.asText())) {
return value.asText();
}
}
return null;
}
private String firstNonBlank(List<String> values) {
if (values == null) {
return null;
}
for (String value : values) {
if (StringUtils.hasText(value)) {
return value;
}
}
return null;
}
/** base-url 形如 http://host:port/v1;路径按 /v1 归一去重(参 P1rDifyDatasetsContractLiveIT.endpoint)。 */
private URI endpoint(String path) {
String normalizedPath = path.startsWith("/") ? path : "/" + path;
if (baseUrl != null && baseUrl.endsWith("/v1") && normalizedPath.startsWith("/v1/")) {
normalizedPath = normalizedPath.substring("/v1".length());
}
return URI.create(baseUrl + normalizedPath);
}
private long elapsedMillis(Instant startedAt) {
return Math.max(0L, Duration.between(startedAt, Instant.now()).toMillis());
}
private String escapeMultipart(String value) {
return value == null ? "" : value.replace("\\", "\\\\").replace("\"", "\\\"").replace("\r", "").replace("\n", "");
}
private String encode(String value) {
// path segment 场景保守编码:batch Dify 生成(通常为 uuid),仍防御非法字符
return java.net.URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8);
}
private boolean looksLikeJson(String value) {
if (value == null) {
return false;
}
String trimmed = value.trim();
return (trimmed.startsWith("{") && trimmed.endsWith("}"))
|| (trimmed.startsWith("[") && trimmed.endsWith("]"));
}
private String sha256(String payload) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(digest.digest(payload.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("JDK 缺少 SHA-256 摘要算法", e);
}
}
private void writeAscii(ByteArrayOutputStream outputStream, String text) throws IOException {
outputStream.write(text.getBytes(StandardCharsets.US_ASCII));
}
private static String normalizeBaseUrl(String rawBaseUrl) {
if (rawBaseUrl == null || rawBaseUrl.isBlank()) {
return null;
}
String trimmed = rawBaseUrl.trim();
return trimmed.endsWith("/") ? trimmed.substring(0, trimmed.length() - 1) : trimmed;
}
/** HTTP 交换结果:失败(已封 fail-closed RuntimeResult)或成功(解析后的响应树)。 */
private sealed interface Exchange {
long durationMillis();
record Failure(RuntimeResult result, long durationMillis) implements Exchange {
}
record Success(JsonNode body, long durationMillis) implements Exchange {
}
}
protected record HttpRequestPayload(String method, String path, byte[] body, String contentType) {
}
protected record HttpResponsePayload(int statusCode, String body, HttpHeaders headers) {
}
private record MultipartPayload(byte[] body, String contentType) {
}
}

View File

@ -0,0 +1,68 @@
package cn.iocoder.muse.module.knowledge.application.muse.facade;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import org.springframework.util.StringUtils;
import java.time.Duration;
/**
* Dify Datasets runtime client Spring 装配配置(S6d 起为 Knowledge 缺省运行时)
*
* <p>S6d 摘除 RAGFlow 运行时后,本类是唯一的具体 {@link KnowledgeRuntimeClient} 装配来源, provider 选择器
* {@code muse.knowledge.runtime-provider} 生效: {@code dify} 或缺省({@code matchIfMissing=true},即未显式配置时
* 默认 Dify)时装 Dify bean;显式配成其它值(如遗留的 {@code ragflow},其运行时已删)时主 bean 让位,由下方
* {@code @ConditionalOnMissingBean} 兜底装通用的 {@link UnavailableKnowledgeRuntimeClient}两个 @Bean 同处一类
* 兜底声明在主 bean 之后,{@code @ConditionalOnMissingBean} 按声明顺序求值,确保任何 provider 取值下都有且仅有
* 一个 bean,绝不因无 bean 致启动失败</p>
*
* <p> {@code muse.knowledge.dify.*}; base-url dataset-api-key 一律装 {@link UnavailableKnowledgeRuntimeClient}
* (fail-closed,"选了 Dify 但配错=拒绝",绝不静默降级)</p>
*/
@Configuration(proxyBeanMethods = false)
public class DifyKnowledgeRuntimeClientConfiguration {
private static final String PROPERTY_PREFIX = "muse.knowledge.dify.";
private static final int DEFAULT_TIMEOUT_SECONDS = 5;
private static final int DEFAULT_RETRY_BUDGET = 0;
@Bean
@ConditionalOnMissingBean(KnowledgeRuntimeClient.class)
@ConditionalOnProperty(name = "muse.knowledge.runtime-provider", havingValue = "dify", matchIfMissing = true)
public KnowledgeRuntimeClient difyKnowledgeRuntimeClient(Environment environment) {
String baseUrl = environment.getProperty(PROPERTY_PREFIX + "base-url");
String apiKey = environment.getProperty(PROPERTY_PREFIX + "dataset-api-key");
if (!StringUtils.hasText(baseUrl) || !StringUtils.hasText(apiKey)) {
return new UnavailableKnowledgeRuntimeClient();
}
int timeoutSeconds = positiveInt(environment, "timeout-seconds", DEFAULT_TIMEOUT_SECONDS);
int retryBudget = nonNegativeInt(environment, "retry-budget", DEFAULT_RETRY_BUDGET);
return new DifyKnowledgeRuntimeClient(baseUrl, apiKey, Duration.ofSeconds(timeoutSeconds), retryBudget);
}
/**
* 通用不可用兜底 bean:仅当上面 Dify bean 未装成(provider 显式配成非 dify ,如遗留 {@code ragflow})时生效
* 声明在主 bean 之后,{@code @ConditionalOnMissingBean} 按同类内 @Bean 声明顺序求值,保证 provider 任意取值下
* 始终恰好一个 {@link KnowledgeRuntimeClient},既不因无 bean 启动失败,也不静默降级(桩返回 CONFIG_MISSING)
*/
@Bean
@ConditionalOnMissingBean(KnowledgeRuntimeClient.class)
public KnowledgeRuntimeClient unavailableKnowledgeRuntimeClient() {
return new UnavailableKnowledgeRuntimeClient();
}
private int positiveInt(Environment environment, String propertyName, int defaultValue) {
Integer value = environment.getProperty(PROPERTY_PREFIX + propertyName, Integer.class, defaultValue);
return value == null || value <= 0 ? defaultValue : value;
}
private int nonNegativeInt(Environment environment, String propertyName, int defaultValue) {
Integer value = environment.getProperty(PROPERTY_PREFIX + propertyName, Integer.class, defaultValue);
return value == null || value < 0 ? defaultValue : value;
}
}

View File

@ -1,634 +0,0 @@
package cn.iocoder.muse.module.knowledge.application.muse.facade;
import cn.iocoder.muse.framework.common.util.json.JsonUtils;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.util.StringUtils;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.ProxySelector;
import java.net.URLEncoder;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpHeaders;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.time.Instant;
import java.util.HexFormat;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
/**
* RAGFlow HTTP runtime client
*/
public class HttpRagFlowKnowledgeRuntimeClient implements RagFlowKnowledgeRuntimeClient {
private final String baseUrl;
private final String apiKey;
private final Duration timeout;
private final int retryBudget;
private final boolean graphRagAttributionReady;
private final HttpClient httpClient;
private static final ProxySelector DIRECT_PROXY_SELECTOR = ProxySelector.of(null);
public HttpRagFlowKnowledgeRuntimeClient(String baseUrl, String apiKey, Duration timeout,
int retryBudget, boolean graphRagAttributionReady) {
this.baseUrl = normalizeBaseUrl(baseUrl);
this.apiKey = apiKey;
this.timeout = timeout == null ? Duration.ofSeconds(5) : timeout;
this.retryBudget = Math.max(retryBudget, 0);
this.graphRagAttributionReady = graphRagAttributionReady;
this.httpClient = HttpClient.newBuilder()
.connectTimeout(this.timeout)
// RAGFlow 部署在 tailnet 内网显式直连可避免系统代理污染 live IT 与运行时调用
.proxy(DIRECT_PROXY_SELECTOR)
.build();
}
@Override
public RuntimeResult createDataset(CreateDatasetCommand command) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("name", command.name());
// RAGFlow 当前 create dataset 接口只接受 nameconfig updateDatasetConfig 单独提交避免创建阶段被外部 API 拒绝
return executeJson(Operation.CREATE_DATASET, command.correlationId(), command.requestHash(), command.attempt(),
"POST", "/api/v1/datasets", body, "id", null);
}
@Override
public RuntimeResult updateDatasetConfig(UpdateDatasetConfigCommand command) {
return executeJson(Operation.UPDATE_DATASET_CONFIG, command.correlationId(), command.requestHash(),
command.attempt(), "PUT", "/api/v1/datasets/" + command.ragflowDatasetId(),
command.config(), "id", null);
}
@Override
public RuntimeResult listDatasets(ListDatasetsCommand command) {
return executeJson(Operation.LIST_DATASETS, command.correlationId(), command.requestHash(), command.attempt(),
"GET", "/api/v1/datasets", command.filter(), null, null);
}
@Override
public RuntimeResult uploadDocuments(UploadDocumentsCommand command) {
MultipartPayload multipartPayload = buildMultipartPayload(command.documents());
if (multipartPayload == null) {
return failure(Operation.UPLOAD_DOCUMENTS, command.correlationId(), command.requestHash(), command.attempt(),
null, FailureClass.FILE_REJECTED, 0L, "document content is not materialized");
}
return execute(Operation.UPLOAD_DOCUMENTS, command.correlationId(), command.requestHash(), command.attempt(),
"POST", "/api/v1/datasets/" + command.ragflowDatasetId() + "/documents",
multipartPayload.body(), multipartPayload.contentType(), "id", null);
}
@Override
public RuntimeResult startParseDocuments(StartParseDocumentsCommand command) {
Map<String, Object> body = Map.of("document_ids", command.ragflowDocumentIds());
RuntimeResult rawResult = executeJson(Operation.START_PARSE_DOCUMENTS, command.correlationId(), command.requestHash(),
command.attempt(), "POST", "/api/v1/datasets/" + command.ragflowDatasetId() + "/chunks",
body, null, command.processingTaskId());
if (rawResult.status() == Status.FAILED) {
return rawResult;
}
// RAGFlow parse 没有稳定外部 task idMuse 只向上返回内部 processingTaskId/correlationId
return new RuntimeResult(null, Operation.START_PARSE_DOCUMENTS, command.correlationId(), command.requestHash(),
command.attempt(), rawResult.durationMillis(), Status.ACCEPTED, null, command.processingTaskId(),
"{\"status\":\"accepted\",\"processingTaskId\":\"" + escape(command.processingTaskId()) + "\"}",
List.of(), Map.of("processingTaskId", command.processingTaskId()));
}
@Override
public RuntimeResult pollDocumentStatuses(PollDocumentStatusesCommand command) {
String path = "/api/v1/datasets/" + command.ragflowDatasetId() + "/documents";
if (command.ragflowDocumentIds() != null && !command.ragflowDocumentIds().isEmpty()) {
if (command.ragflowDocumentIds().size() > 1) {
return singleDocumentPollingValidationFailure(command);
}
// RAGFlow list documents 的真实查询参数是 id=<docId>不是批量 document_ids
// 当前 Muse 上传链路按单文档 smoke 验收多文档批量轮询后续应在调用方拆分避免生成 RAGFlow 不支持的查询
path += "?id=" + encode(command.ragflowDocumentIds().getFirst());
}
RuntimeResult rawResult = execute(Operation.POLL_DOCUMENT_STATUSES, command.correlationId(),
command.requestHash(), command.attempt(), "GET", path, new byte[0], null, null, command.processingTaskId());
if (rawResult.status() == Status.FAILED) {
return rawResult;
}
List<DocumentStatus> statuses = normalizeDocumentStatuses(rawResult.summary().get("responseBody"),
command.ragflowDocumentIds());
return new RuntimeResult(null, Operation.POLL_DOCUMENT_STATUSES, command.correlationId(), command.requestHash(),
command.attempt(), rawResult.durationMillis(), Status.SUCCEEDED, null, command.processingTaskId(),
sanitizeSummary(Map.of("documents", statuses)), statuses, Map.of("documents", statuses));
}
private RuntimeResult singleDocumentPollingValidationFailure(PollDocumentStatusesCommand command) {
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("status", Status.FAILED.name().toLowerCase(Locale.ROOT));
summary.put("failureClass", FailureClass.VALIDATION_ERROR.name());
summary.put("operation", Operation.POLL_DOCUMENT_STATUSES.wireName());
summary.put("reason", "only support single document status polling");
return new RuntimeResult(null, Operation.POLL_DOCUMENT_STATUSES, command.correlationId(), command.requestHash(),
command.attempt(), 0L, Status.FAILED, FailureClass.VALIDATION_ERROR, command.processingTaskId(),
sanitizeSummary(summary), List.of(), summary);
}
@Override
public RuntimeResult listChunks(ListChunksCommand command) {
return executeJson(Operation.LIST_CHUNKS, command.correlationId(), command.requestHash(), command.attempt(),
"GET", "/api/v1/datasets/" + command.ragflowDatasetId() + "/documents/"
+ command.ragflowDocumentId() + "/chunks", command.filter(), null, null);
}
@Override
public RuntimeResult retrieveChunks(RetrieveChunksCommand command) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("dataset_ids", command.ragflowDatasetIds());
// RAGFlow 要求 document_ids list; null(不限定具体文档按授权 dataset 全量检索)时不放入 body,
// 否则 RAGFlow 拒绝 code 102 "`documents` should be a list" 检索 fail-closed,P-A 上下文检索永不命中
if (command.ragflowDocumentIds() != null) {
body.put("document_ids", command.ragflowDocumentIds());
}
body.put("question", command.question());
body.put("top_k", command.topK());
body.put("similarity_threshold", command.threshold());
// RAGFlow 官方 HTTP 契约字段名是 metadata_condition旧字段 metadata_filter 会被静默忽略
if (command.metadataCondition() != null) {
body.put("metadata_condition", command.metadataCondition());
}
return executeJson(Operation.RETRIEVE_CHUNKS, command.correlationId(), command.requestHash(), command.attempt(),
"POST", "/api/v1/retrieval", body, null, null);
}
@Override
public RuntimeResult runGraphRag(RunGraphRagCommand command) {
if (!graphRagAttributionReady) {
return failure(Operation.RUN_GRAPH_RAG, command.correlationId(), command.requestHash(), command.attempt(),
command.processingTaskId(), FailureClass.ATTRIBUTION_NOT_CONFIGURED, 0L,
"GraphRAG attribution is not configured");
}
return executeJson(Operation.RUN_GRAPH_RAG, command.correlationId(), command.requestHash(), command.attempt(),
"POST", "/api/v1/datasets/" + command.ragflowDatasetId() + "/run_graphrag",
Map.of(), "graphrag_task_id", command.processingTaskId());
}
@Override
public RuntimeResult traceGraphRag(TraceGraphRagCommand command) {
return executeJson(Operation.TRACE_GRAPH_RAG, command.correlationId(), command.requestHash(), command.attempt(),
"GET", "/api/v1/datasets/" + command.ragflowDatasetId() + "/trace_graphrag",
Map.of(), null, null);
}
@Override
public RuntimeResult getKnowledgeGraph(GetKnowledgeGraphCommand command) {
return executeJson(Operation.GET_KNOWLEDGE_GRAPH, command.correlationId(), command.requestHash(),
command.attempt(), "GET", "/api/v1/datasets/" + command.ragflowDatasetId() + "/knowledge_graph",
Map.of(), null, null);
}
@Override
public RuntimeResult health(HealthCommand command) {
return executeJson(Operation.HEALTH, command.correlationId(), command.requestHash(), command.attempt(),
"GET", "/v1/system/healthz", Map.of(), null, null);
}
protected HttpResponsePayload send(HttpRequestPayload request) throws IOException, InterruptedException {
HttpRequest.Builder builder = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + request.path()))
.timeout(timeout)
.header("Accept", "application/json");
if (StringUtils.hasText(apiKey)) {
builder.header("Authorization", "Bearer " + apiKey);
}
if ("GET".equals(request.method())) {
builder.GET();
} else {
builder.header("Content-Type", request.contentType() == null ? "application/json" : request.contentType())
.method(request.method(), HttpRequest.BodyPublishers.ofByteArray(request.body()));
}
HttpResponse<String> response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString());
return new HttpResponsePayload(response.statusCode(), response.body(), response.headers());
}
private RuntimeResult executeJson(Operation operation, String correlationId, String requestHash, int attempt,
String method, String path, Object body, String externalIdField,
String processingTaskId) {
byte[] requestBody = "GET".equals(method) ? new byte[0]
: JsonUtils.toJsonString(body == null ? Map.of() : body).getBytes(StandardCharsets.UTF_8);
return execute(operation, correlationId, requestHash, attempt, method, path, requestBody,
"application/json", externalIdField, processingTaskId);
}
private RuntimeResult execute(Operation operation, String correlationId, String requestHash, int attempt,
String method, String path, byte[] requestBody, String contentType,
String externalIdField, String processingTaskId) {
Instant startedAt = Instant.now();
FailureClass configurationFailure = validateConfiguration();
if (configurationFailure != null) {
return failure(operation, correlationId, requestHash, attempt, processingTaskId, configurationFailure,
elapsedMillis(startedAt), configurationFailure.name());
}
int maxAttempts = Math.max(1, retryBudget + 1);
RuntimeResult lastFailure = null;
for (int currentAttempt = 1; currentAttempt <= maxAttempts; currentAttempt++) {
try {
HttpResponsePayload response = send(new HttpRequestPayload(method, path,
requestBody == null ? new byte[0] : requestBody, contentType));
long duration = elapsedMillis(startedAt);
FailureClass httpFailure = classifyHttpFailure(response.statusCode());
if (httpFailure != null) {
lastFailure = failure(operation, correlationId, requestHash, attempt, processingTaskId,
httpFailure, duration, response.body());
if (!isRetryable(httpFailure) || currentAttempt == maxAttempts) {
return lastFailure;
}
continue;
}
return success(operation, correlationId, requestHash, attempt, processingTaskId, externalIdField,
response.body(), duration);
} catch (java.net.http.HttpTimeoutException ex) {
lastFailure = failure(operation, correlationId, requestHash, attempt, processingTaskId,
FailureClass.TIMEOUT, elapsedMillis(startedAt), ex.getMessage());
} catch (IOException ex) {
lastFailure = failure(operation, correlationId, requestHash, attempt, processingTaskId,
FailureClass.RAGFLOW_UNAVAILABLE, elapsedMillis(startedAt), ex.getMessage());
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
return failure(operation, correlationId, requestHash, attempt, processingTaskId,
FailureClass.RAGFLOW_UNAVAILABLE, elapsedMillis(startedAt), ex.getMessage());
} catch (RuntimeException ex) {
return failure(operation, correlationId, requestHash, attempt, processingTaskId,
FailureClass.UNKNOWN_FAILURE, elapsedMillis(startedAt), ex.getMessage());
}
if (currentAttempt == maxAttempts) {
return lastFailure;
}
}
return lastFailure == null
? failure(operation, correlationId, requestHash, attempt, processingTaskId,
FailureClass.UNKNOWN_FAILURE, elapsedMillis(startedAt), "unknown failure")
: lastFailure;
}
private RuntimeResult success(Operation operation, String correlationId, String requestHash, int attempt,
String processingTaskId, String externalIdField, String responseBody,
long durationMillis) {
JsonNode bodyTree = parseBody(responseBody);
if (bodyTree == null) {
return failure(operation, correlationId, requestHash, attempt, processingTaskId,
FailureClass.RESPONSE_SCHEMA_CHANGED, durationMillis, responseBody);
}
FailureClass envelopeFailure = classifyEnvelopeFailure(bodyTree);
if (envelopeFailure != null) {
return failure(operation, correlationId, requestHash, attempt, processingTaskId,
envelopeFailure, durationMillis, firstText(bodyTree, "message", "msg", "error"));
}
JsonNode data = bodyTree.has("data") ? bodyTree.path("data") : bodyTree;
String externalId = externalIdField == null ? null : findText(data, externalIdField);
if (externalIdField != null && !StringUtils.hasText(externalId)) {
return failure(operation, correlationId, requestHash, attempt, processingTaskId,
FailureClass.RESPONSE_SCHEMA_CHANGED, durationMillis, "required externalId is missing");
}
Map<String, Object> summary = buildMetadataSummary(operation, bodyTree, externalId, null);
// 后续状态归一仍需要原始响应树审计摘要只输出 buildMetadataSummary 的保守元数据
summary.put("responseBody", bodyTree);
if (externalId != null) {
summary.put("externalId", externalId);
}
return new RuntimeResult(externalId, operation, correlationId, requestHash, attempt, durationMillis,
Status.SUCCEEDED, null, processingTaskId,
sanitizeSummary(buildMetadataSummary(operation, bodyTree, externalId, null)), List.of(), summary);
}
private RuntimeResult failure(Operation operation, String correlationId, String requestHash, int attempt,
String processingTaskId, FailureClass failureClass, long durationMillis,
String message) {
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("status", Status.FAILED.name().toLowerCase(Locale.ROOT));
summary.put("failureClass", failureClass.name());
summary.put("operation", operation.wireName());
if (message != null && !message.isBlank()) {
summary.put("providerMessageSummary", safeSummaryNode(message));
}
return new RuntimeResult(null, operation, correlationId, requestHash, attempt, durationMillis,
Status.FAILED, failureClass, processingTaskId, sanitizeSummary(summary), List.of(), summary);
}
private JsonNode safeSummaryNode(String message) {
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("type", "provider_body");
summary.put("length", message == null ? 0 : message.length());
summary.put("sha256", sha256(message == null ? "" : message));
summary.put("redacted", true);
summary.put("json", looksLikeJson(message));
return JsonUtils.getObjectMapper().valueToTree(summary);
}
private FailureClass validateConfiguration() {
if (!StringUtils.hasText(baseUrl)) {
return FailureClass.CONFIG_MISSING;
}
if (!StringUtils.hasText(apiKey)) {
return FailureClass.AUTH_MISSING;
}
return null;
}
private FailureClass classifyHttpFailure(int statusCode) {
if (statusCode >= 200 && statusCode < 300) {
return null;
}
return switch (statusCode) {
case 400 -> FailureClass.VALIDATION_ERROR;
case 401, 403 -> FailureClass.AUTH_FAILED;
case 404 -> FailureClass.TASK_NOT_FOUND;
case 409 -> FailureClass.CONFLICT;
case 413, 415 -> FailureClass.FILE_REJECTED;
case 429 -> FailureClass.RATE_LIMITED;
default -> statusCode >= 500 ? FailureClass.RAGFLOW_UNAVAILABLE : FailureClass.UNKNOWN_FAILURE;
};
}
private boolean isRetryable(FailureClass failureClass) {
return failureClass == FailureClass.RAGFLOW_UNAVAILABLE
|| failureClass == FailureClass.TIMEOUT
|| failureClass == FailureClass.RATE_LIMITED;
}
private FailureClass classifyEnvelopeFailure(JsonNode bodyTree) {
if (bodyTree == null || bodyTree.isMissingNode() || bodyTree.isNull()) {
return FailureClass.RESPONSE_SCHEMA_CHANGED;
}
if (bodyTree.has("code") && bodyTree.path("code").canConvertToInt() && bodyTree.path("code").asInt() != 0) {
return classifyRagflowCode(bodyTree.path("code").asInt(), firstText(bodyTree, "message", "msg", "error"));
}
if (bodyTree.has("error") || bodyTree.has("errors")) {
return classifyRagflowMessage(firstText(bodyTree, "error", "message", "msg"));
}
String status = firstText(bodyTree, "status");
if (status != null && Set.of("failed", "error", "unauthorized", "forbidden").contains(status.toLowerCase(Locale.ROOT))) {
return classifyRagflowMessage(firstText(bodyTree, "message", "msg", "error", "status"));
}
return null;
}
private FailureClass classifyRagflowCode(int code, String message) {
return switch (code) {
case 401, 403, 1001, 1002 -> FailureClass.AUTH_FAILED;
case 400, 422 -> FailureClass.VALIDATION_ERROR;
case 404 -> FailureClass.TASK_NOT_FOUND;
case 409 -> FailureClass.CONFLICT;
case 429 -> FailureClass.RATE_LIMITED;
default -> classifyRagflowMessage(message);
};
}
private FailureClass classifyRagflowMessage(String message) {
String normalized = message == null ? "" : message.toLowerCase(Locale.ROOT);
if (normalized.contains("auth") || normalized.contains("unauthor") || normalized.contains("forbidden")) {
return FailureClass.AUTH_FAILED;
}
if (normalized.contains("validation") || normalized.contains("invalid") || normalized.contains("param")) {
return FailureClass.VALIDATION_ERROR;
}
if (normalized.contains("index") && normalized.contains("ready")) {
return FailureClass.INDEX_NOT_READY;
}
if (normalized.contains("chunk") && (normalized.contains("empty") || normalized.contains("no "))) {
return FailureClass.NO_CHUNK;
}
if (normalized.contains("graph") && normalized.contains("ready")) {
return FailureClass.GRAPH_NOT_READY;
}
if (normalized.contains("already") && normalized.contains("running")) {
return FailureClass.TASK_ALREADY_RUNNING;
}
if (normalized.contains("not found")) {
return FailureClass.TASK_NOT_FOUND;
}
return FailureClass.RESPONSE_SCHEMA_CHANGED;
}
private List<DocumentStatus> normalizeDocumentStatuses(Object responseBody, List<String> requestedDocumentIds) {
JsonNode root = responseBody instanceof JsonNode jsonNode ? jsonNode : JsonUtils.getObjectMapper().valueToTree(responseBody);
JsonNode documents = root.path("data");
if (documents.isObject() && documents.has("docs")) {
documents = documents.path("docs");
}
if (documents.isObject() && documents.has("documents")) {
documents = documents.path("documents");
}
List<DocumentStatus> statuses = new ArrayList<>();
if (!documents.isArray()) {
return statuses;
}
Set<String> requestedIds = requestedDocumentIds == null ? Set.of() : new LinkedHashSet<>(requestedDocumentIds);
for (JsonNode document : documents) {
String documentId = firstText(document, "id", "document_id");
if (!requestedIds.isEmpty() && !requestedIds.contains(documentId)) {
continue;
}
String run = document.path("run").asText("");
Integer progress = document.has("progress") && document.path("progress").canConvertToInt()
? document.path("progress").asInt() : null;
statuses.add(new DocumentStatus(
documentId,
normalizeMuseStatus(run, progress),
run,
progress,
firstText(document, "progress_msg", "progressMsg")
));
}
return statuses;
}
private String normalizeMuseStatus(String run, Integer progress) {
String normalizedRun = run == null ? "" : run.toLowerCase(Locale.ROOT);
if (Set.of("done", "completed", "success").contains(normalizedRun) || Integer.valueOf(100).equals(progress)) {
return "searchable";
}
if (Set.of("running", "parsing", "indexing", "queued").contains(normalizedRun)) {
return "processing";
}
if (Set.of("failed", "error", "cancelled").contains(normalizedRun)) {
return "failed";
}
return "pending_process";
}
private JsonNode parseBody(String responseBody) {
try {
return JsonUtils.parseTree(responseBody == null || responseBody.isBlank() ? "{}" : responseBody);
} catch (RuntimeException ex) {
return null;
}
}
private String findText(JsonNode node, String fieldName) {
if (node == null || node.isMissingNode() || node.isNull()) {
return null;
}
if (node.has(fieldName) && node.path(fieldName).isTextual()) {
return node.path(fieldName).asText();
}
if (node.isArray() && !node.isEmpty()) {
return findText(node.get(0), fieldName);
}
return null;
}
private String firstText(JsonNode node, String... fieldNames) {
for (String fieldName : fieldNames) {
if (node.has(fieldName) && node.path(fieldName).isTextual()) {
return node.path(fieldName).asText();
}
}
return null;
}
private MultipartPayload buildMultipartPayload(List<DocumentUpload> documents) {
if (documents == null || documents.isEmpty()) {
return null;
}
String boundary = "----MuseRagFlow" + UUID.randomUUID().toString().replace("-", "");
try {
ByteArrayOutputStream body = new ByteArrayOutputStream();
for (DocumentUpload document : documents) {
if (document == null || document.content() == null || document.content().length == 0
|| !StringUtils.hasText(document.fileName())) {
return null;
}
String contentType = StringUtils.hasText(document.contentType()) ? document.contentType() : "application/octet-stream";
writeAscii(body, "--" + boundary + "\r\n");
writeAscii(body, "Content-Disposition: form-data; name=\"file\"; filename=\""
+ escapeMultipart(document.fileName()) + "\"\r\n");
writeAscii(body, "Content-Type: " + contentType + "\r\n\r\n");
// Task 3 只承接小型已材料化资源Task 5 再接对象存储/流式文件边界
body.write(document.content());
writeAscii(body, "\r\n");
}
writeAscii(body, "--" + boundary + "--\r\n");
return new MultipartPayload(body.toByteArray(), "multipart/form-data; boundary=" + boundary);
} catch (IOException ex) {
return null;
}
}
private Map<String, Object> buildMetadataSummary(Operation operation, JsonNode bodyTree, String externalId,
FailureClass failureClass) {
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("operation", operation.wireName());
summary.put("status", failureClass == null ? Status.SUCCEEDED.name().toLowerCase(Locale.ROOT)
: Status.FAILED.name().toLowerCase(Locale.ROOT));
if (bodyTree != null && bodyTree.has("code")) {
summary.put("code", bodyTree.path("code"));
}
if (StringUtils.hasText(externalId)) {
summary.put("externalId", externalId);
}
if (failureClass != null) {
summary.put("failureClass", failureClass.name());
}
JsonNode data = bodyTree == null ? null : bodyTree.path("data");
if (data != null && data.isArray()) {
summary.put("count", data.size());
} else if (data != null && data.isObject()) {
putCountIfPresent(summary, data, "docs");
putCountIfPresent(summary, data, "documents");
putCountIfPresent(summary, data, "chunks");
putCountIfPresent(summary, data, "nodes");
putCountIfPresent(summary, data, "edges");
JsonNode graph = data.path("graph");
if (graph.isObject()) {
putCountIfPresent(summary, graph, "nodes");
putCountIfPresent(summary, graph, "edges");
}
if (data.has("mind_map")) {
summary.put("mindMapPresent", !data.path("mind_map").isNull());
if (data.path("mind_map").isArray()) {
summary.put("mindMapCount", data.path("mind_map").size());
}
} else if (data.has("mindMap")) {
summary.put("mindMapPresent", !data.path("mindMap").isNull());
if (data.path("mindMap").isArray()) {
summary.put("mindMapCount", data.path("mindMap").size());
}
}
if (data.has("progress")) {
summary.put("progress", data.path("progress"));
}
}
return summary;
}
private void putCountIfPresent(Map<String, Object> summary, JsonNode data, String fieldName) {
if (data.has(fieldName) && data.path(fieldName).isArray()) {
summary.put(fieldName + "Count", data.path(fieldName).size());
}
}
private String sanitizeSummary(Object value) {
return RagFlowKnowledgeRedactor.sanitizeObject(value);
}
private long elapsedMillis(Instant startedAt) {
return Math.max(0L, Duration.between(startedAt, Instant.now()).toMillis());
}
private String escape(String value) {
return value == null ? "" : value.replace("\\", "\\\\").replace("\"", "\\\"");
}
private String escapeMultipart(String value) {
return escape(value).replace("\r", "").replace("\n", "");
}
private String encode(String value) {
return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8);
}
private boolean looksLikeJson(String value) {
if (value == null) {
return false;
}
String trimmed = value.trim();
return (trimmed.startsWith("{") && trimmed.endsWith("}"))
|| (trimmed.startsWith("[") && trimmed.endsWith("]"));
}
private String sha256(String payload) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(digest.digest(payload.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("JDK 缺少 SHA-256 摘要算法", e);
}
}
private void writeAscii(ByteArrayOutputStream outputStream, String text) throws IOException {
outputStream.write(text.getBytes(StandardCharsets.US_ASCII));
}
private static String normalizeBaseUrl(String rawBaseUrl) {
if (rawBaseUrl == null || rawBaseUrl.isBlank()) {
return null;
}
return rawBaseUrl.endsWith("/") ? rawBaseUrl.substring(0, rawBaseUrl.length() - 1) : rawBaseUrl;
}
protected record HttpRequestPayload(String method, String path, byte[] body, String contentType) {
}
protected record HttpResponsePayload(int statusCode, String body, HttpHeaders headers) {
}
private record MultipartPayload(byte[] body, String contentType) {
}
}

View File

@ -4,47 +4,26 @@ import java.util.List;
import java.util.Map;
/**
* Knowledge 调用 RAGFlow 的运行时边界
* Knowledge 运行时端口文档处理与检索的外部运行时调用边界
*/
public interface RagFlowKnowledgeRuntimeClient {
public interface KnowledgeRuntimeClient {
RuntimeResult createDataset(CreateDatasetCommand command);
RuntimeResult updateDatasetConfig(UpdateDatasetConfigCommand command);
RuntimeResult listDatasets(ListDatasetsCommand command);
RuntimeResult uploadDocuments(UploadDocumentsCommand command);
RuntimeResult startParseDocuments(StartParseDocumentsCommand command);
RuntimeResult pollDocumentStatuses(PollDocumentStatusesCommand command);
RuntimeResult listChunks(ListChunksCommand command);
RuntimeResult retrieveChunks(RetrieveChunksCommand command);
RuntimeResult runGraphRag(RunGraphRagCommand command);
RuntimeResult traceGraphRag(TraceGraphRagCommand command);
RuntimeResult getKnowledgeGraph(GetKnowledgeGraphCommand command);
RuntimeResult health(HealthCommand command);
enum Operation {
CREATE_DATASET("createDataset"),
UPDATE_DATASET_CONFIG("updateDatasetConfig"),
LIST_DATASETS("listDatasets"),
UPLOAD_DOCUMENTS("uploadDocuments"),
START_PARSE_DOCUMENTS("startParseDocuments"),
POLL_DOCUMENT_STATUSES("pollDocumentStatuses"),
LIST_CHUNKS("listChunks"),
RETRIEVE_CHUNKS("retrieveChunks"),
RUN_GRAPH_RAG("runGraphRag"),
TRACE_GRAPH_RAG("traceGraphRag"),
GET_KNOWLEDGE_GRAPH("getKnowledgeGraph"),
HEALTH("health");
RETRIEVE_CHUNKS("retrieveChunks");
private final String wireName;
@ -126,15 +105,6 @@ public interface RagFlowKnowledgeRuntimeClient {
String requestHash, int attempt) {
}
record UpdateDatasetConfigCommand(Long tenantId, Long ownerUserId, Long kbId, String ragflowDatasetId,
Map<String, Object> config, String correlationId,
String requestHash, int attempt) {
}
record ListDatasetsCommand(Long tenantId, Long ownerUserId, Map<String, Object> filter,
String correlationId, String requestHash, int attempt) {
}
record UploadDocumentsCommand(Long tenantId, Long ownerUserId, Long kbId, String ragflowDatasetId,
List<DocumentUpload> documents, String correlationId,
String requestHash, int attempt) {
@ -150,31 +120,10 @@ public interface RagFlowKnowledgeRuntimeClient {
String correlationId, String requestHash, int attempt) {
}
record ListChunksCommand(Long tenantId, Long ownerUserId, Long kbId, String ragflowDatasetId,
String ragflowDocumentId, Map<String, Object> filter,
String correlationId, String requestHash, int attempt) {
}
record RetrieveChunksCommand(Long tenantId, Long ownerUserId, Long kbId, List<String> ragflowDatasetIds,
List<String> ragflowDocumentIds, String question, Integer topK,
Double threshold, Map<String, Object> metadataCondition,
String correlationId, String requestHash, int attempt) {
}
record RunGraphRagCommand(Long tenantId, Long ownerUserId, Long kbId, String ragflowDatasetId,
String processingTaskId, String correlationId, String requestHash,
int attempt) {
}
record TraceGraphRagCommand(Long tenantId, Long ownerUserId, Long kbId, String ragflowDatasetId,
String correlationId, String requestHash, int attempt) {
}
record GetKnowledgeGraphCommand(Long tenantId, Long ownerUserId, Long kbId, String ragflowDatasetId,
String correlationId, String requestHash, int attempt) {
}
record HealthCommand(String correlationId, String requestHash, int attempt) {
}
}

View File

@ -1,48 +0,0 @@
package cn.iocoder.muse.module.knowledge.application.muse.facade;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import org.springframework.util.StringUtils;
import java.time.Duration;
/**
* RAGFlow runtime client Spring 装配配置
*/
@Configuration(proxyBeanMethods = false)
public class RagFlowKnowledgeRuntimeClientConfiguration {
private static final String PROPERTY_PREFIX = "muse.knowledge.ragflow.";
private static final int DEFAULT_TIMEOUT_SECONDS = 5;
private static final int DEFAULT_RETRY_BUDGET = 0;
@Bean
@ConditionalOnMissingBean(RagFlowKnowledgeRuntimeClient.class)
public RagFlowKnowledgeRuntimeClient ragFlowKnowledgeRuntimeClient(Environment environment) {
String baseUrl = environment.getProperty(PROPERTY_PREFIX + "base-url");
String apiKey = environment.getProperty(PROPERTY_PREFIX + "api-key");
if (!StringUtils.hasText(baseUrl) || !StringUtils.hasText(apiKey)) {
return new UnavailableRagFlowKnowledgeRuntimeClient();
}
int timeoutSeconds = positiveInt(environment, "timeout-seconds", DEFAULT_TIMEOUT_SECONDS);
int retryBudget = nonNegativeInt(environment, "retry-budget", DEFAULT_RETRY_BUDGET);
boolean graphRagAttributionReady = Boolean.TRUE.equals(environment.getProperty(
PROPERTY_PREFIX + "graphrag-attribution-ready", Boolean.class, false));
return new HttpRagFlowKnowledgeRuntimeClient(baseUrl, apiKey, Duration.ofSeconds(timeoutSeconds),
retryBudget, graphRagAttributionReady);
}
private int positiveInt(Environment environment, String propertyName, int defaultValue) {
Integer value = environment.getProperty(PROPERTY_PREFIX + propertyName, Integer.class, defaultValue);
return value == null || value <= 0 ? defaultValue : value;
}
private int nonNegativeInt(Environment environment, String propertyName, int defaultValue) {
Integer value = environment.getProperty(PROPERTY_PREFIX + propertyName, Integer.class, defaultValue);
return value == null || value < 0 ? defaultValue : value;
}
}

View File

@ -4,25 +4,18 @@ import java.util.List;
import java.util.Map;
/**
* RAGFlow 未配置时的 fail closed client
* Knowledge 运行时未配置时的通用 fail-closed client
*
* <p> provider 无关:base-url / api-key 缺失时装配此桩,所有操作直接返回 {@link FailureClass#CONFIG_MISSING},
* 绝不发起任何外部调用,保证"未配置=拒绝"而非静默降级S6d 摘除 RAGFlow ,由本桩统一承担 Unavailable 语义</p>
*/
public class UnavailableRagFlowKnowledgeRuntimeClient implements RagFlowKnowledgeRuntimeClient {
public class UnavailableKnowledgeRuntimeClient implements KnowledgeRuntimeClient {
@Override
public RuntimeResult createDataset(CreateDatasetCommand command) {
return unavailable(Operation.CREATE_DATASET, command.correlationId(), command.requestHash(), command.attempt(), null);
}
@Override
public RuntimeResult updateDatasetConfig(UpdateDatasetConfigCommand command) {
return unavailable(Operation.UPDATE_DATASET_CONFIG, command.correlationId(), command.requestHash(), command.attempt(), null);
}
@Override
public RuntimeResult listDatasets(ListDatasetsCommand command) {
return unavailable(Operation.LIST_DATASETS, command.correlationId(), command.requestHash(), command.attempt(), null);
}
@Override
public RuntimeResult uploadDocuments(UploadDocumentsCommand command) {
return unavailable(Operation.UPLOAD_DOCUMENTS, command.correlationId(), command.requestHash(), command.attempt(), null);
@ -40,37 +33,11 @@ public class UnavailableRagFlowKnowledgeRuntimeClient implements RagFlowKnowledg
command.attempt(), command.processingTaskId());
}
@Override
public RuntimeResult listChunks(ListChunksCommand command) {
return unavailable(Operation.LIST_CHUNKS, command.correlationId(), command.requestHash(), command.attempt(), null);
}
@Override
public RuntimeResult retrieveChunks(RetrieveChunksCommand command) {
return unavailable(Operation.RETRIEVE_CHUNKS, command.correlationId(), command.requestHash(), command.attempt(), null);
}
@Override
public RuntimeResult runGraphRag(RunGraphRagCommand command) {
return unavailable(Operation.RUN_GRAPH_RAG, command.correlationId(), command.requestHash(),
command.attempt(), command.processingTaskId());
}
@Override
public RuntimeResult traceGraphRag(TraceGraphRagCommand command) {
return unavailable(Operation.TRACE_GRAPH_RAG, command.correlationId(), command.requestHash(), command.attempt(), null);
}
@Override
public RuntimeResult getKnowledgeGraph(GetKnowledgeGraphCommand command) {
return unavailable(Operation.GET_KNOWLEDGE_GRAPH, command.correlationId(), command.requestHash(), command.attempt(), null);
}
@Override
public RuntimeResult health(HealthCommand command) {
return unavailable(Operation.HEALTH, command.correlationId(), command.requestHash(), command.attempt(), null);
}
private RuntimeResult unavailable(Operation operation, String correlationId, String requestHash,
int attempt, String processingTaskId) {
return new RuntimeResult(null, operation, correlationId, requestHash, attempt, 0L,

View File

@ -42,6 +42,12 @@ public class MuseKnowledgeProcessingTaskDO extends TenantBaseDO {
private String status;
private String ragflowDatasetId;
private String ragflowDocumentId;
/**
* 运行时索引批次 idDify Datasets 摄入返回的顶层 batch是其 indexing-status 轮询主键
* GET /v1/datasets/{id}/documents/{batch}/indexing-statusRAGFlow 上传不产生 batch留空
* 轮询回退到 ragflowDocumentId行为逐字不变命名沿用 provider 无关口径避免与 ragflowDocumentId 混淆
*/
private String runtimeBatchId;
/** RAGFlow document run/progress 轮询得到的解析进度。 */
private Integer parseProgress;
private String progressMessage;

View File

@ -4,12 +4,12 @@ import cn.iocoder.muse.framework.common.util.json.JsonUtils;
import cn.iocoder.muse.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.muse.module.knowledge.api.MuseKnowledgeRetrievalApi.RetrievalRequest;
import cn.iocoder.muse.module.knowledge.api.MuseKnowledgeRetrievalApi.RetrievalResult;
import cn.iocoder.muse.module.knowledge.application.muse.facade.RagFlowKnowledgeRuntimeClient;
import cn.iocoder.muse.module.knowledge.application.muse.facade.RagFlowKnowledgeRuntimeClient.FailureClass;
import cn.iocoder.muse.module.knowledge.application.muse.facade.RagFlowKnowledgeRuntimeClient.Operation;
import cn.iocoder.muse.module.knowledge.application.muse.facade.RagFlowKnowledgeRuntimeClient.RetrieveChunksCommand;
import cn.iocoder.muse.module.knowledge.application.muse.facade.RagFlowKnowledgeRuntimeClient.RuntimeResult;
import cn.iocoder.muse.module.knowledge.application.muse.facade.RagFlowKnowledgeRuntimeClient.Status;
import cn.iocoder.muse.module.knowledge.application.muse.facade.KnowledgeRuntimeClient;
import cn.iocoder.muse.module.knowledge.application.muse.facade.KnowledgeRuntimeClient.FailureClass;
import cn.iocoder.muse.module.knowledge.application.muse.facade.KnowledgeRuntimeClient.Operation;
import cn.iocoder.muse.module.knowledge.application.muse.facade.KnowledgeRuntimeClient.RetrieveChunksCommand;
import cn.iocoder.muse.module.knowledge.application.muse.facade.KnowledgeRuntimeClient.RuntimeResult;
import cn.iocoder.muse.module.knowledge.application.muse.facade.KnowledgeRuntimeClient.Status;
import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeBindingDO;
import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeRagflowBindingDO;
import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeSourceBindingProjectionDO;
@ -30,6 +30,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ -49,7 +50,7 @@ class MuseKnowledgeRetrievalApiImplTest extends BaseMockitoUnitTest {
@Mock
private MuseKnowledgeRagflowBindingMapper ragflowBindingMapper;
@Mock
private RagFlowKnowledgeRuntimeClient ragFlowClient;
private KnowledgeRuntimeClient ragFlowClient;
private RetrievalRequest req() {
return new RetrievalRequest(100L, 2001L, 4001L, "如何写好开头", 5, "corr-1");
@ -230,6 +231,88 @@ class MuseKnowledgeRetrievalApiImplTest extends BaseMockitoUnitTest {
assertEquals("no_chunk", result.omittedReason());
}
// ========================================================================================
// S6c 多授权 dataset 逐库扇出 + 按相似度合并Dify retrieve 单库端点一次只检一个库
// ========================================================================================
/** 两授权来源(各自 dataset)逐库检索,按相似度降序合并,每 chunk 归属各自来源的 §5.3 字段。 */
@Test
void should_mergeAndSortChunksAcrossDatasets_withPerSourceContractFields() {
when(bindingMapper.selectActiveByWorkId(4001L))
.thenReturn(List.of(authorizedBinding(5001L), authorizedBinding(5002L)));
when(projectionMapper.selectActiveByWorkId(4001L))
.thenReturn(List.of(projection(5001L, "active"), projection(5002L, "active")));
when(ragflowBindingMapper.selectActiveDatasetByKbId(5001L)).thenReturn(dataset("ds-A"));
when(ragflowBindingMapper.selectActiveDatasetByKbId(5002L)).thenReturn(dataset("ds-B"));
// 每库单独响应:ds-A 相似度 0.7ds-B 相似度 0.9(合并后 B 应排前)
when(ragFlowClient.retrieveChunks(any(RetrieveChunksCommand.class))).thenAnswer(inv -> {
RetrieveChunksCommand cmd = inv.getArgument(0);
if ("ds-A".equals(cmd.ragflowDatasetIds().get(0))) {
return retrievalSuccess("{\"data\":{\"chunks\":[{\"content\":\"来自A\",\"document_id\":\"a1\",\"kb_id\":\"ds-A\",\"similarity\":0.7}]}}");
}
return retrievalSuccess("{\"data\":{\"chunks\":[{\"content\":\"来自B\",\"document_id\":\"b1\",\"kb_id\":\"ds-B\",\"similarity\":0.9}]}}");
});
RetrievalResult result = retrievalApi.retrieveForWork(req());
assertTrue(result.hasChunks());
assertEquals(2, result.chunks().size());
// 相似度降序:B(0.9)在前A(0.7)在后,各自归属正确来源
assertEquals("来自B", result.chunks().get(0).contentSummary());
assertEquals(5002L, result.chunks().get(0).sourceKbId());
assertEquals("auth-5002", result.chunks().get(0).authorizationSnapshotId());
assertEquals("来自A", result.chunks().get(1).contentSummary());
assertEquals(5001L, result.chunks().get(1).sourceKbId());
assertEquals("auth-5001", result.chunks().get(1).authorizationSnapshotId());
// 逐库扇出:两库各一次检索
verify(ragFlowClient, times(2)).retrieveChunks(any(RetrieveChunksCommand.class));
}
/** 单库检索失败降级:一库失败、另一库成功 → 返回成功库命中,不整体阻断(fail-closed 降级)。 */
@Test
void should_degradeToSuccessfulDatasets_whenOneDatasetFails() {
when(bindingMapper.selectActiveByWorkId(4001L))
.thenReturn(List.of(authorizedBinding(5001L), authorizedBinding(5002L)));
when(projectionMapper.selectActiveByWorkId(4001L))
.thenReturn(List.of(projection(5001L, "active"), projection(5002L, "active")));
when(ragflowBindingMapper.selectActiveDatasetByKbId(5001L)).thenReturn(dataset("ds-A"));
when(ragflowBindingMapper.selectActiveDatasetByKbId(5002L)).thenReturn(dataset("ds-B"));
when(ragFlowClient.retrieveChunks(any(RetrieveChunksCommand.class))).thenAnswer(inv -> {
RetrieveChunksCommand cmd = inv.getArgument(0);
if ("ds-A".equals(cmd.ragflowDatasetIds().get(0))) {
return new RuntimeResult(null, Operation.RETRIEVE_CHUNKS, "c", null, 1, 5L,
Status.FAILED, FailureClass.TIMEOUT, null, "r", List.of(), Map.of());
}
return retrievalSuccess("{\"data\":{\"chunks\":[{\"content\":\"来自B\",\"document_id\":\"b1\",\"kb_id\":\"ds-B\",\"similarity\":0.9}]}}");
});
RetrievalResult result = retrievalApi.retrieveForWork(req());
assertTrue(result.hasChunks(), "单库失败应降级、不整体阻断");
assertEquals(1, result.chunks().size());
assertEquals("来自B", result.chunks().get(0).contentSummary());
assertEquals("ok", result.status());
}
/** 全部授权库检索失败 → retrieval_failed(代表性失败类),不误报无结果。 */
@Test
void should_returnRetrievalFailed_whenAllDatasetsFail() {
when(bindingMapper.selectActiveByWorkId(4001L))
.thenReturn(List.of(authorizedBinding(5001L), authorizedBinding(5002L)));
when(projectionMapper.selectActiveByWorkId(4001L))
.thenReturn(List.of(projection(5001L, "active"), projection(5002L, "active")));
when(ragflowBindingMapper.selectActiveDatasetByKbId(5001L)).thenReturn(dataset("ds-A"));
when(ragflowBindingMapper.selectActiveDatasetByKbId(5002L)).thenReturn(dataset("ds-B"));
RuntimeResult failed = new RuntimeResult(null, Operation.RETRIEVE_CHUNKS, "c", null, 1, 5L,
Status.FAILED, FailureClass.RAGFLOW_UNAVAILABLE, null, "r", List.of(), Map.of());
when(ragFlowClient.retrieveChunks(any(RetrieveChunksCommand.class))).thenReturn(failed);
RetrievalResult result = retrievalApi.retrieveForWork(req());
assertFalse(result.hasChunks());
assertEquals("retrieval_failed:RAGFLOW_UNAVAILABLE", result.omittedReason());
}
// ========================================================================================
// U0 SPIKE临时-03 §U0 / 决策门 D1"多租户共享同一 RAGFlow dataset 必越权"钉成可复现证据
// 这是测试代码spike不是业务代码不改任何检索/绑定主路径

View File

@ -3,11 +3,11 @@ package cn.iocoder.muse.module.knowledge.application.muse;
import cn.iocoder.muse.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.muse.module.knowledge.application.muse.facade.KnowledgeFileFacade;
import cn.iocoder.muse.module.knowledge.application.muse.facade.RagFlowKnowledgeRuntimeClient;
import cn.iocoder.muse.module.knowledge.application.muse.facade.RagFlowKnowledgeRuntimeClient.FailureClass;
import cn.iocoder.muse.module.knowledge.application.muse.facade.RagFlowKnowledgeRuntimeClient.Operation;
import cn.iocoder.muse.module.knowledge.application.muse.facade.RagFlowKnowledgeRuntimeClient.RuntimeResult;
import cn.iocoder.muse.module.knowledge.application.muse.facade.RagFlowKnowledgeRuntimeClient.Status;
import cn.iocoder.muse.module.knowledge.application.muse.facade.KnowledgeRuntimeClient;
import cn.iocoder.muse.module.knowledge.application.muse.facade.KnowledgeRuntimeClient.FailureClass;
import cn.iocoder.muse.module.knowledge.application.muse.facade.KnowledgeRuntimeClient.Operation;
import cn.iocoder.muse.module.knowledge.application.muse.facade.KnowledgeRuntimeClient.RuntimeResult;
import cn.iocoder.muse.module.knowledge.application.muse.facade.KnowledgeRuntimeClient.Status;
import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeDocumentDO;
import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeDocumentVersionDO;
import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeRagflowBindingDO;
@ -64,7 +64,7 @@ class KnowledgeMarketForkServiceTest extends BaseMockitoUnitTest {
@Mock
private KnowledgeFileFacade fileFacade;
@Mock
private RagFlowKnowledgeRuntimeClient ragFlowClient;
private KnowledgeRuntimeClient ragFlowClient;
@Mock
private MuseKnowledgeAuditService auditService;
@Mock
@ -125,7 +125,7 @@ class KnowledgeMarketForkServiceTest extends BaseMockitoUnitTest {
ASSET_ID.equals(req.assetId()) && "ready".equals(req.forkStatus())
&& "rag-fork-ds".equals(req.publicForkDatasetId())));
// fork 的每次 RAGFlow 调用都审计create + 3*upload + 3*startParse = 7
verify(auditService, atLeastOnce()).recordRagflowCall(argThat(req ->
verify(auditService, atLeastOnce()).recordRuntimeCall(argThat(req ->
"market_public_fork".equals(req.getAttributionStatus())));
}

View File

@ -100,6 +100,21 @@ class MuseInstalledKnowledgeBaseServiceTest extends BaseMockitoUnitTest {
assertEquals("6001", page.getList().getFirst().getInstallId());
}
@Test
void should_returnEmptyInstalledKnowledgeBasesWhenMarketIsDetachedAndNoProjectionExists() {
when(bindingMapper.selectInstalledPage(any(), org.mockito.ArgumentMatchers.eq(2001L), any()))
.thenReturn(new PageResult<>(List.of(), 0L));
AppInstalledKnowledgeBaseVO.PageResultVO<AppInstalledKnowledgeBaseVO.SummaryRespVO> page =
installedService.listInstalledKnowledgeBases(2001L, 1, 20, null);
assertEquals(1, page.getPageNo());
assertEquals(20, page.getPageSize());
assertEquals(0L, page.getTotal());
assertEquals(0, page.getList().size());
verify(workOwnerFacade, never()).requireWorkOwner(any(), any());
}
@Test
void should_rejectInstalledListIllegalStatusOrPageSize() {
assertEquals(KNOWLEDGE_INVALID_PAGE_PARAM.getCode(), assertThrows(ServiceException.class,

View File

@ -2,7 +2,7 @@ package cn.iocoder.muse.module.knowledge.application.muse;
import cn.iocoder.muse.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.muse.module.knowledge.application.muse.facade.RagFlowKnowledgeRuntimeClient;
import cn.iocoder.muse.module.knowledge.application.muse.facade.KnowledgeRuntimeClient;
import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeRagflowCallDO;
import cn.iocoder.muse.module.knowledge.dal.mysql.muse.MuseKnowledgeRagflowCallMapper;
import org.junit.jupiter.api.AfterEach;
@ -36,17 +36,17 @@ class MuseKnowledgeAuditServiceTest extends BaseMockitoUnitTest {
doReturn(1).when(ragflowCallMapper).insert(
org.mockito.ArgumentMatchers.<MuseKnowledgeRagflowCallDO>argThat(inserted -> true));
MuseKnowledgeRagflowCallDO call = auditService.recordRagflowCall(MuseKnowledgeAuditService.RagflowCallRecordReq.builder()
MuseKnowledgeRagflowCallDO call = auditService.recordRuntimeCall(MuseKnowledgeAuditService.RuntimeCallRecordReq.builder()
.correlationId("corr-rag-1")
.attemptNo(1)
.operation(RagFlowKnowledgeRuntimeClient.Operation.CREATE_DATASET)
.operation(KnowledgeRuntimeClient.Operation.CREATE_DATASET)
.commandId("cmd-1")
.requestHash("hash-1")
.actorUserId(2001L)
.ownerUserId(2001L)
.kbId(4001L)
.status(RagFlowKnowledgeRuntimeClient.Status.FAILED)
.failureClass(RagFlowKnowledgeRuntimeClient.FailureClass.AUTH_FAILED)
.status(KnowledgeRuntimeClient.Status.FAILED)
.failureClass(KnowledgeRuntimeClient.FailureClass.AUTH_FAILED)
.requestSummary("""
{"apiKey":"rk-test-secret","nested":{"token":"t-123","secret":"s-456"},"Authorization":"Bearer abcdefgh"}
""")
@ -76,17 +76,17 @@ class MuseKnowledgeAuditServiceTest extends BaseMockitoUnitTest {
doReturn(1).when(ragflowCallMapper).insert(
org.mockito.ArgumentMatchers.<MuseKnowledgeRagflowCallDO>argThat(inserted -> true));
MuseKnowledgeRagflowCallDO call = auditService.recordRagflowCall(MuseKnowledgeAuditService.RagflowCallRecordReq.builder()
MuseKnowledgeRagflowCallDO call = auditService.recordRuntimeCall(MuseKnowledgeAuditService.RuntimeCallRecordReq.builder()
.correlationId("corr-rag-raw-json")
.attemptNo(1)
.operation(RagFlowKnowledgeRuntimeClient.Operation.RETRIEVE_CHUNKS)
.operation(KnowledgeRuntimeClient.Operation.RETRIEVE_CHUNKS)
.requestSummary("""
{"question":"private question","prompt":"private prompt","entryContent":"private entry","fileContent":"private file"}
""")
.responseSummary("""
{"responseBody":{"content":"private chunk","text":"private text","rawAnswer":"private raw","response":"private answer"}}
""")
.status(RagFlowKnowledgeRuntimeClient.Status.SUCCEEDED)
.status(KnowledgeRuntimeClient.Status.SUCCEEDED)
.durationMillis(8L)
.build());
@ -108,13 +108,13 @@ class MuseKnowledgeAuditServiceTest extends BaseMockitoUnitTest {
doReturn(1).when(ragflowCallMapper).insert(
org.mockito.ArgumentMatchers.<MuseKnowledgeRagflowCallDO>argThat(inserted -> true));
MuseKnowledgeRagflowCallDO call = auditService.recordRagflowCall(MuseKnowledgeAuditService.RagflowCallRecordReq.builder()
MuseKnowledgeRagflowCallDO call = auditService.recordRuntimeCall(MuseKnowledgeAuditService.RuntimeCallRecordReq.builder()
.correlationId("corr-rag-plain")
.attemptNo(1)
.operation(RagFlowKnowledgeRuntimeClient.Operation.RETRIEVE_CHUNKS)
.operation(KnowledgeRuntimeClient.Operation.RETRIEVE_CHUNKS)
.requestSummary("private plain text retrieval question without token markers")
.responseSummary("private raw response body without token markers")
.status(RagFlowKnowledgeRuntimeClient.Status.FAILED)
.status(KnowledgeRuntimeClient.Status.FAILED)
.durationMillis(9L)
.build());

View File

@ -5,7 +5,7 @@ import cn.iocoder.muse.framework.common.pojo.PageResult;
import cn.iocoder.muse.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.muse.framework.tenant.core.context.TenantContextHolder;
import cn.iocoder.muse.module.knowledge.application.muse.facade.KnowledgeFileFacade;
import cn.iocoder.muse.module.knowledge.application.muse.facade.RagFlowKnowledgeRuntimeClient;
import cn.iocoder.muse.module.knowledge.application.muse.facade.KnowledgeRuntimeClient;
import cn.iocoder.muse.module.knowledge.controller.admin.muse.vo.AdminKnowledgeDocumentVO;
import cn.iocoder.muse.module.knowledge.controller.app.muse.vo.AppKnowledgeDocumentVO;
import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeBaseDO;
@ -80,7 +80,7 @@ class MuseKnowledgeDocumentServiceTest extends BaseMockitoUnitTest {
@Mock
private MuseKnowledgeMaterializationService materializationService;
@Mock
private RagFlowKnowledgeRuntimeClient ragFlowClient;
private KnowledgeRuntimeClient ragFlowClient;
@InjectMocks
private MuseKnowledgeProcessingTaskService processingTaskService;
@ -323,15 +323,16 @@ class MuseKnowledgeDocumentServiceTest extends BaseMockitoUnitTest {
});
when(processingTaskMapper.selectByTaskId("knowledge-processing-6001")).thenReturn(processingTask(6001L));
when(processingTaskMapper.insert(any(MuseKnowledgeProcessingTaskDO.class))).thenAnswer(invocation -> 1);
when(ragFlowClient.uploadDocuments(any())).thenReturn(new RagFlowKnowledgeRuntimeClient.RuntimeResult(
"rag-doc-1", RagFlowKnowledgeRuntimeClient.Operation.UPLOAD_DOCUMENTS,
// 模拟 Dify 上传把索引批次 batch 透出到 summary验证其被抽取并落库到 task.runtimeBatchIdRAGFlow 无此字段
when(ragFlowClient.uploadDocuments(any())).thenReturn(new KnowledgeRuntimeClient.RuntimeResult(
"rag-doc-1", KnowledgeRuntimeClient.Operation.UPLOAD_DOCUMENTS,
"uploadGlobalKBDocument:cmd-upload-file-success", "hash-file-success", 1, 12L,
RagFlowKnowledgeRuntimeClient.Status.SUCCEEDED, null, null,
"{\"status\":\"succeeded\"}", List.of(), java.util.Map.of()));
when(ragFlowClient.startParseDocuments(any())).thenReturn(new RagFlowKnowledgeRuntimeClient.RuntimeResult(
null, RagFlowKnowledgeRuntimeClient.Operation.START_PARSE_DOCUMENTS,
KnowledgeRuntimeClient.Status.SUCCEEDED, null, null,
"{\"status\":\"succeeded\"}", List.of(), java.util.Map.of("batch", "dify-batch-1")));
when(ragFlowClient.startParseDocuments(any())).thenReturn(new KnowledgeRuntimeClient.RuntimeResult(
null, KnowledgeRuntimeClient.Operation.START_PARSE_DOCUMENTS,
"uploadGlobalKBDocument:cmd-upload-file-success", "hash-file-success", 1, 8L,
RagFlowKnowledgeRuntimeClient.Status.ACCEPTED, null, "knowledge-processing-6001",
KnowledgeRuntimeClient.Status.ACCEPTED, null, "knowledge-processing-6001",
"{\"status\":\"accepted\"}", List.of(), java.util.Map.of()));
AdminKnowledgeDocumentVO.UploadReqVO reqVO = new AdminKnowledgeDocumentVO.UploadReqVO();
@ -360,26 +361,27 @@ class MuseKnowledgeDocumentServiceTest extends BaseMockitoUnitTest {
"rag-dataset-1".equals(binding.getRagflowDatasetId())
&& "rag-doc-1".equals(binding.getRagflowDocumentId())
&& Long.valueOf(6001L).equals(binding.getDocumentVersionId())));
verify(auditService).recordRagflowCall(org.mockito.ArgumentMatchers.argThat(call ->
verify(auditService).recordRuntimeCall(org.mockito.ArgumentMatchers.argThat(call ->
"uploadGlobalKBDocument:cmd-upload-file-success:uploadDocuments".equals(call.getCorrelationId())
&& RagFlowKnowledgeRuntimeClient.Operation.UPLOAD_DOCUMENTS.equals(call.getOperation())
&& KnowledgeRuntimeClient.Operation.UPLOAD_DOCUMENTS.equals(call.getOperation())
&& "cmd-upload-file-success".equals(call.getCommandId())
&& "knowledge-processing-6001".equals(call.getProcessingTaskId())
&& "rag-dataset-1".equals(call.getRagflowDatasetId())
&& "rag-doc-1".equals(call.getRagflowDocumentId())
&& RagFlowKnowledgeRuntimeClient.Status.SUCCEEDED.equals(call.getStatus())));
verify(auditService).recordRagflowCall(org.mockito.ArgumentMatchers.argThat(call ->
&& KnowledgeRuntimeClient.Status.SUCCEEDED.equals(call.getStatus())));
verify(auditService).recordRuntimeCall(org.mockito.ArgumentMatchers.argThat(call ->
"uploadGlobalKBDocument:cmd-upload-file-success:startParseDocuments".equals(call.getCorrelationId())
&& RagFlowKnowledgeRuntimeClient.Operation.START_PARSE_DOCUMENTS.equals(call.getOperation())
&& KnowledgeRuntimeClient.Operation.START_PARSE_DOCUMENTS.equals(call.getOperation())
&& "cmd-upload-file-success".equals(call.getCommandId())
&& "knowledge-processing-6001".equals(call.getProcessingTaskId())
&& "rag-dataset-1".equals(call.getRagflowDatasetId())
&& "rag-doc-1".equals(call.getRagflowDocumentId())
&& RagFlowKnowledgeRuntimeClient.Status.ACCEPTED.equals(call.getStatus())));
&& KnowledgeRuntimeClient.Status.ACCEPTED.equals(call.getStatus())));
verify(processingTaskMapper).updateById(org.mockito.ArgumentMatchers.<MuseKnowledgeProcessingTaskDO>argThat(task ->
"parsing".equals(task.getStatus())
&& "rag-dataset-1".equals(task.getRagflowDatasetId())
&& "rag-doc-1".equals(task.getRagflowDocumentId())
&& "dify-batch-1".equals(task.getRuntimeBatchId())
&& "processing".equals(task.getResultSummary()) == false));
}
@ -407,20 +409,20 @@ class MuseKnowledgeDocumentServiceTest extends BaseMockitoUnitTest {
});
when(processingTaskMapper.selectByTaskId("knowledge-processing-6001")).thenReturn(processingTask(6001L));
when(processingTaskMapper.insert(any(MuseKnowledgeProcessingTaskDO.class))).thenAnswer(invocation -> 1);
when(ragFlowClient.createDataset(any())).thenReturn(new RagFlowKnowledgeRuntimeClient.RuntimeResult(
"rag-dataset-auto-1", RagFlowKnowledgeRuntimeClient.Operation.CREATE_DATASET,
when(ragFlowClient.createDataset(any())).thenReturn(new KnowledgeRuntimeClient.RuntimeResult(
"rag-dataset-auto-1", KnowledgeRuntimeClient.Operation.CREATE_DATASET,
"uploadGlobalKBDocument:cmd-upload-auto-dataset", "hash-auto-dataset", 1, 10L,
RagFlowKnowledgeRuntimeClient.Status.SUCCEEDED, null, null,
KnowledgeRuntimeClient.Status.SUCCEEDED, null, null,
"{\"status\":\"succeeded\"}", List.of(), java.util.Map.of()));
when(ragFlowClient.uploadDocuments(any())).thenReturn(new RagFlowKnowledgeRuntimeClient.RuntimeResult(
"rag-doc-auto-1", RagFlowKnowledgeRuntimeClient.Operation.UPLOAD_DOCUMENTS,
when(ragFlowClient.uploadDocuments(any())).thenReturn(new KnowledgeRuntimeClient.RuntimeResult(
"rag-doc-auto-1", KnowledgeRuntimeClient.Operation.UPLOAD_DOCUMENTS,
"uploadGlobalKBDocument:cmd-upload-auto-dataset", "hash-auto-dataset", 1, 12L,
RagFlowKnowledgeRuntimeClient.Status.SUCCEEDED, null, null,
KnowledgeRuntimeClient.Status.SUCCEEDED, null, null,
"{\"status\":\"succeeded\"}", List.of(), java.util.Map.of()));
when(ragFlowClient.startParseDocuments(any())).thenReturn(new RagFlowKnowledgeRuntimeClient.RuntimeResult(
null, RagFlowKnowledgeRuntimeClient.Operation.START_PARSE_DOCUMENTS,
when(ragFlowClient.startParseDocuments(any())).thenReturn(new KnowledgeRuntimeClient.RuntimeResult(
null, KnowledgeRuntimeClient.Operation.START_PARSE_DOCUMENTS,
"uploadGlobalKBDocument:cmd-upload-auto-dataset", "hash-auto-dataset", 1, 8L,
RagFlowKnowledgeRuntimeClient.Status.ACCEPTED, null, "knowledge-processing-6001",
KnowledgeRuntimeClient.Status.ACCEPTED, null, "knowledge-processing-6001",
"{\"status\":\"accepted\"}", List.of(), java.util.Map.of()));
AdminKnowledgeDocumentVO.UploadReqVO reqVO = new AdminKnowledgeDocumentVO.UploadReqVO();
@ -458,14 +460,14 @@ class MuseKnowledgeDocumentServiceTest extends BaseMockitoUnitTest {
verify(ragFlowClient).startParseDocuments(argThat(command ->
"rag-dataset-auto-1".equals(command.ragflowDatasetId())
&& command.ragflowDocumentIds().contains("rag-doc-auto-1")));
verify(auditService).recordRagflowCall(org.mockito.ArgumentMatchers.argThat(call ->
verify(auditService).recordRuntimeCall(org.mockito.ArgumentMatchers.argThat(call ->
"uploadGlobalKBDocument:cmd-upload-auto-dataset:createDataset".equals(call.getCorrelationId())
&& RagFlowKnowledgeRuntimeClient.Operation.CREATE_DATASET.equals(call.getOperation())
&& KnowledgeRuntimeClient.Operation.CREATE_DATASET.equals(call.getOperation())
&& "cmd-upload-auto-dataset".equals(call.getCommandId())
&& "knowledge-processing-6001".equals(call.getProcessingTaskId())
&& "rag-dataset-auto-1".equals(call.getRagflowDatasetId())
&& call.getRagflowDocumentId() == null
&& RagFlowKnowledgeRuntimeClient.Status.SUCCEEDED.equals(call.getStatus())));
&& KnowledgeRuntimeClient.Status.SUCCEEDED.equals(call.getStatus())));
}
@Test
@ -492,11 +494,11 @@ class MuseKnowledgeDocumentServiceTest extends BaseMockitoUnitTest {
inserted.setId(6001L);
return 1;
});
when(ragFlowClient.createDataset(any())).thenReturn(new RagFlowKnowledgeRuntimeClient.RuntimeResult(
null, RagFlowKnowledgeRuntimeClient.Operation.CREATE_DATASET,
when(ragFlowClient.createDataset(any())).thenReturn(new KnowledgeRuntimeClient.RuntimeResult(
null, KnowledgeRuntimeClient.Operation.CREATE_DATASET,
"uploadGlobalKBDocument:cmd-upload-auto-dataset-failed", "hash-auto-dataset-failed", 1, 10L,
RagFlowKnowledgeRuntimeClient.Status.FAILED,
RagFlowKnowledgeRuntimeClient.FailureClass.RAGFLOW_UNAVAILABLE, null,
KnowledgeRuntimeClient.Status.FAILED,
KnowledgeRuntimeClient.FailureClass.RAGFLOW_UNAVAILABLE, null,
"{\"status\":\"failed\"}", List.of(), java.util.Map.of()));
AdminKnowledgeDocumentVO.UploadReqVO reqVO = new AdminKnowledgeDocumentVO.UploadReqVO();
@ -517,11 +519,11 @@ class MuseKnowledgeDocumentServiceTest extends BaseMockitoUnitTest {
verify(documentVersionMapper).updateById(org.mockito.ArgumentMatchers.<MuseKnowledgeDocumentVersionDO>argThat(version ->
"failed".equals(version.getProcessingStatus())
&& "RAGFLOW_UNAVAILABLE".equals(version.getChangeReason())));
verify(auditService).recordRagflowCall(org.mockito.ArgumentMatchers.argThat(call ->
verify(auditService).recordRuntimeCall(org.mockito.ArgumentMatchers.argThat(call ->
"uploadGlobalKBDocument:cmd-upload-auto-dataset-failed:createDataset".equals(call.getCorrelationId())
&& RagFlowKnowledgeRuntimeClient.Operation.CREATE_DATASET.equals(call.getOperation())
&& RagFlowKnowledgeRuntimeClient.Status.FAILED.equals(call.getStatus())
&& RagFlowKnowledgeRuntimeClient.FailureClass.RAGFLOW_UNAVAILABLE.equals(call.getFailureClass())));
&& KnowledgeRuntimeClient.Operation.CREATE_DATASET.equals(call.getOperation())
&& KnowledgeRuntimeClient.Status.FAILED.equals(call.getStatus())
&& KnowledgeRuntimeClient.FailureClass.RAGFLOW_UNAVAILABLE.equals(call.getFailureClass())));
}
@Test
@ -548,11 +550,11 @@ class MuseKnowledgeDocumentServiceTest extends BaseMockitoUnitTest {
inserted.setId(6001L);
return 1;
});
when(ragFlowClient.uploadDocuments(any())).thenReturn(new RagFlowKnowledgeRuntimeClient.RuntimeResult(
null, RagFlowKnowledgeRuntimeClient.Operation.UPLOAD_DOCUMENTS,
when(ragFlowClient.uploadDocuments(any())).thenReturn(new KnowledgeRuntimeClient.RuntimeResult(
null, KnowledgeRuntimeClient.Operation.UPLOAD_DOCUMENTS,
"uploadGlobalKBDocument:cmd-upload-ragflow-failed", "hash-ragflow-failed", 1, 12L,
RagFlowKnowledgeRuntimeClient.Status.FAILED,
RagFlowKnowledgeRuntimeClient.FailureClass.RAGFLOW_UNAVAILABLE, null,
KnowledgeRuntimeClient.Status.FAILED,
KnowledgeRuntimeClient.FailureClass.RAGFLOW_UNAVAILABLE, null,
"{\"status\":\"failed\"}", List.of(), java.util.Map.of()));
AdminKnowledgeDocumentVO.UploadReqVO reqVO = new AdminKnowledgeDocumentVO.UploadReqVO();
@ -765,10 +767,10 @@ class MuseKnowledgeDocumentServiceTest extends BaseMockitoUnitTest {
return 1;
});
when(processingTaskMapper.insert(any(MuseKnowledgeProcessingTaskDO.class))).thenReturn(1);
when(ragFlowClient.uploadDocuments(any())).thenReturn(new RagFlowKnowledgeRuntimeClient.RuntimeResult(
"rag-doc-orphan-1", RagFlowKnowledgeRuntimeClient.Operation.UPLOAD_DOCUMENTS,
when(ragFlowClient.uploadDocuments(any())).thenReturn(new KnowledgeRuntimeClient.RuntimeResult(
"rag-doc-orphan-1", KnowledgeRuntimeClient.Operation.UPLOAD_DOCUMENTS,
"uploadGlobalKBDocument:cmd-upload-binding-failed", "hash-upload-binding-failed", 1, 12L,
RagFlowKnowledgeRuntimeClient.Status.SUCCEEDED, null, null,
KnowledgeRuntimeClient.Status.SUCCEEDED, null, null,
"{\"status\":\"succeeded\"}", List.of(), java.util.Map.of()));
when(ragflowBindingMapper.insert(org.mockito.ArgumentMatchers.<MuseKnowledgeRagflowBindingDO>argThat(binding ->
Long.valueOf(6001L).equals(binding.getDocumentVersionId()))))

View File

@ -169,7 +169,7 @@ class MuseKnowledgeGraphQueryServiceTest extends BaseMockitoUnitTest {
assertFalse(source.contains("runGraphRag"));
assertFalse(source.contains("run_graphrag"));
assertFalse(source.contains("RagFlowKnowledgeRuntimeClient"),
assertFalse(source.contains("KnowledgeRuntimeClient"),
"Task 7 graph 只消费 canonical entity/relation不应调用 RAGFlow runtime client");
}

View File

@ -1,14 +1,15 @@
package cn.iocoder.muse.module.knowledge.application.muse;
import cn.iocoder.muse.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.muse.module.knowledge.application.muse.facade.RagFlowKnowledgeRuntimeClient;
import cn.iocoder.muse.module.knowledge.application.muse.facade.RagFlowKnowledgeRuntimeClient.DocumentStatus;
import cn.iocoder.muse.module.knowledge.application.muse.facade.RagFlowKnowledgeRuntimeClient.Operation;
import cn.iocoder.muse.module.knowledge.application.muse.facade.RagFlowKnowledgeRuntimeClient.RuntimeResult;
import cn.iocoder.muse.module.knowledge.application.muse.facade.RagFlowKnowledgeRuntimeClient.Status;
import cn.iocoder.muse.module.knowledge.application.muse.facade.KnowledgeRuntimeClient;
import cn.iocoder.muse.module.knowledge.application.muse.facade.KnowledgeRuntimeClient.DocumentStatus;
import cn.iocoder.muse.module.knowledge.application.muse.facade.KnowledgeRuntimeClient.Operation;
import cn.iocoder.muse.module.knowledge.application.muse.facade.KnowledgeRuntimeClient.RuntimeResult;
import cn.iocoder.muse.module.knowledge.application.muse.facade.KnowledgeRuntimeClient.Status;
import cn.iocoder.muse.module.knowledge.dal.dataobject.muse.MuseKnowledgeProcessingTaskDO;
import cn.iocoder.muse.module.knowledge.dal.mysql.muse.MuseKnowledgeProcessingTaskMapper;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import java.util.List;
@ -31,7 +32,7 @@ class MuseKnowledgeParseStatusPollWorkerTest extends BaseMockitoUnitTest {
@Mock
private MuseKnowledgeProcessingTaskMapper processingTaskMapper;
@Mock
private RagFlowKnowledgeRuntimeClient ragFlowClient;
private KnowledgeRuntimeClient ragFlowClient;
@Mock
private MuseKnowledgeProcessingTaskService processingTaskService;
@ -129,6 +130,7 @@ class MuseKnowledgeParseStatusPollWorkerTest extends BaseMockitoUnitTest {
@Test
void should_skip_whenMissingRagflowIds() {
// documentId runtimeBatchId 都空 无可用轮询键 跳过
MuseKnowledgeProcessingTaskDO t = parsingTask();
t.setRagflowDocumentId(null);
when(processingTaskMapper.selectParsingForPoll(anyInt())).thenReturn(List.of(t));
@ -138,4 +140,37 @@ class MuseKnowledgeParseStatusPollWorkerTest extends BaseMockitoUnitTest {
assertEquals(0, advanced);
verifyNoInteractions(ragFlowClient);
}
@Test
void should_pollByRuntimeBatchId_whenDifyBatchPresent() {
// Dify 场景上传落 runtimeBatchId=索引 batch ragflowDocumentId=document.id轮询主键取 batch
MuseKnowledgeProcessingTaskDO t = parsingTask();
t.setRuntimeBatchId("dify-batch-9");
when(processingTaskMapper.selectParsingForPoll(anyInt())).thenReturn(List.of(t));
when(ragFlowClient.pollDocumentStatuses(any())).thenReturn(
poll(Status.SUCCEEDED, new DocumentStatus("doc-1", "processing", "RUNNING", 50, "running")));
worker(true).pollOnce();
ArgumentCaptor<KnowledgeRuntimeClient.PollDocumentStatusesCommand> captor =
ArgumentCaptor.forClass(KnowledgeRuntimeClient.PollDocumentStatusesCommand.class);
verify(ragFlowClient).pollDocumentStatuses(captor.capture());
assertEquals(List.of("dify-batch-9"), captor.getValue().ragflowDocumentIds());
}
@Test
void should_pollByDocumentId_whenRuntimeBatchIdAbsent() {
// RAGFlow 场景 batchruntimeBatchId 留空 轮询回退 ragflowDocumentId逐字不变
MuseKnowledgeProcessingTaskDO t = parsingTask();
when(processingTaskMapper.selectParsingForPoll(anyInt())).thenReturn(List.of(t));
when(ragFlowClient.pollDocumentStatuses(any())).thenReturn(
poll(Status.SUCCEEDED, new DocumentStatus("doc-1", "processing", "RUNNING", 50, "running")));
worker(true).pollOnce();
ArgumentCaptor<KnowledgeRuntimeClient.PollDocumentStatusesCommand> captor =
ArgumentCaptor.forClass(KnowledgeRuntimeClient.PollDocumentStatusesCommand.class);
verify(ragFlowClient).pollDocumentStatuses(captor.capture());
assertEquals(List.of("doc-1"), captor.getValue().ragflowDocumentIds());
}
}

View File

@ -0,0 +1,122 @@
package cn.iocoder.muse.module.knowledge.application.muse.facade;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.mock.env.MockEnvironment;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
/**
* Dify Knowledge 运行时 client 装配测试(S6d Dify 为缺省运行时)
*
* <p>两层验证:装配方法本身( dify.* DifyUnavailable);provider 选择器
* {@code muse.knowledge.runtime-provider} 语义 {@code dify} 或缺省装 Dify,显式配成其它值(如遗留
* {@code ragflow},运行时已删)由兜底装通用 {@link UnavailableKnowledgeRuntimeClient};任何取值下都有且仅有
* 一个 {@link KnowledgeRuntimeClient}</p>
*/
class DifyKnowledgeRuntimeClientConfigurationTest {
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
.withUserConfiguration(DifyKnowledgeRuntimeClientConfiguration.class);
// ==================== 装配方法本身 ====================
@Test
void should_registerDifyClientWhenBaseUrlAndKeyConfigured() {
MockEnvironment environment = new MockEnvironment()
.withProperty("muse.knowledge.dify.base-url", "http://dify.example/v1")
.withProperty("muse.knowledge.dify.dataset-api-key", "dataset-key")
.withProperty("muse.knowledge.dify.timeout-seconds", "7")
.withProperty("muse.knowledge.dify.retry-budget", "2");
DifyKnowledgeRuntimeClientConfiguration configuration = new DifyKnowledgeRuntimeClientConfiguration();
KnowledgeRuntimeClient client = configuration.difyKnowledgeRuntimeClient(environment);
assertInstanceOf(DifyKnowledgeRuntimeClient.class, client);
}
@Test
void should_registerUnavailableWhenBaseUrlMissing() {
MockEnvironment environment = new MockEnvironment()
.withProperty("muse.knowledge.dify.dataset-api-key", "dataset-key");
DifyKnowledgeRuntimeClientConfiguration configuration = new DifyKnowledgeRuntimeClientConfiguration();
KnowledgeRuntimeClient client = configuration.difyKnowledgeRuntimeClient(environment);
assertInstanceOf(UnavailableKnowledgeRuntimeClient.class, client);
}
@Test
void should_registerUnavailableWhenKeyMissing() {
MockEnvironment environment = new MockEnvironment()
.withProperty("muse.knowledge.dify.base-url", "http://dify.example/v1");
DifyKnowledgeRuntimeClientConfiguration configuration = new DifyKnowledgeRuntimeClientConfiguration();
KnowledgeRuntimeClient client = configuration.difyKnowledgeRuntimeClient(environment);
assertInstanceOf(UnavailableKnowledgeRuntimeClient.class, client);
}
// ==================== provider 选择器:缺省 Dify + dify 值兜底 Unavailable ====================
@Test
void should_installDifyOnlyWhenProviderIsDify() {
contextRunner.withPropertyValues(
"muse.knowledge.runtime-provider=dify",
"muse.knowledge.dify.base-url=http://dify.example/v1",
"muse.knowledge.dify.dataset-api-key=dataset-key")
.run(context -> {
assertEquals(1, context.getBeansOfType(KnowledgeRuntimeClient.class).size());
assertInstanceOf(DifyKnowledgeRuntimeClient.class,
context.getBean(KnowledgeRuntimeClient.class));
});
}
@Test
void should_failClosedWhenProviderIsDifyButConfigMissing() {
contextRunner.withPropertyValues("muse.knowledge.runtime-provider=dify")
.run(context -> {
assertEquals(1, context.getBeansOfType(KnowledgeRuntimeClient.class).size());
// 选了 Dify 但没配 fail-closed ,不静默回退 RAGFlow
assertInstanceOf(UnavailableKnowledgeRuntimeClient.class,
context.getBean(KnowledgeRuntimeClient.class));
});
}
@Test
void should_installUnavailableWhenProviderIsRagflow() {
// 遗留 provider=ragflow(运行时 S6d 已删):Dify bean 让位,兜底装通用 UnavailableKnowledgeRuntimeClient,
// 既不因无 bean 启动失败,也不静默降级
contextRunner.withPropertyValues("muse.knowledge.runtime-provider=ragflow")
.run(context -> {
assertEquals(1, context.getBeansOfType(KnowledgeRuntimeClient.class).size());
assertInstanceOf(UnavailableKnowledgeRuntimeClient.class,
context.getBean(KnowledgeRuntimeClient.class));
});
}
@Test
void should_defaultToDifyWhenProviderUnset() {
// 缺省 provider(未显式配置):matchIfMissing Dify 成缺省运行时
contextRunner.withPropertyValues(
"muse.knowledge.dify.base-url=http://dify.example/v1",
"muse.knowledge.dify.dataset-api-key=dataset-key")
.run(context -> {
assertEquals(1, context.getBeansOfType(KnowledgeRuntimeClient.class).size());
assertInstanceOf(DifyKnowledgeRuntimeClient.class,
context.getBean(KnowledgeRuntimeClient.class));
});
}
@Test
void should_installSingleUnavailableWhenProviderUnsetAndNothingConfigured() {
// 缺省 provider + 未配 dify.*:缺省进 Dify 装配但配缺 fail-closed 通用 Unavailable ,仍确保只有一个 bean
contextRunner.run(context -> {
assertEquals(1, context.getBeansOfType(KnowledgeRuntimeClient.class).size());
assertInstanceOf(UnavailableKnowledgeRuntimeClient.class,
context.getBean(KnowledgeRuntimeClient.class));
});
}
}

Some files were not shown because too many files have changed in this diff Show More