diff --git a/AGENTS.md b/AGENTS.md index 97fe9fdb..3881147f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ ## 1. 文档引用 ### 1.1 AI相关 - 当涉及 AI 架构、主流程或 human-only runtime 时,必须阅读 `doc/ai-design/architecture.md` -- 当涉及 AI 实现、运行入口、跨层边界、LLM 执行入口、流式传输、runtime settings、运行数据、`execution.watchHint` / `toolUsePlan` / `execution.lastLearningOutcome` / `memory.history.roundHistory` / `history_recall` / `recent_progress` / `earlier_attempts` / `recalled_history` 契约或工作区 KB 范围时,必须阅读 `doc/ai-design/implementation-spec.md` 与 `doc/ai-design/data-definition-and-flow.md` +- 当涉及 AI 实现、运行入口、跨层边界、LLM 执行入口、流式传输、runtime settings、运行数据、`execution.watchHint` / `toolUsePlan` / `execution.lastLearningOutcome` / `memory.history.roundHistory` / `history_recall` / `mouth` 统一 `history` prompt 输入契约或工作区 KB 范围时,必须阅读 `doc/ai-design/implementation-spec.md` 与 `doc/ai-design/data-definition-and-flow.md` - 当涉及 AI 最小运行样例、默认链路或想快速理解 human 各环节如何衔接时,必须阅读 `doc/ai-design/example.md` - 当涉及 human 的 skill 分类、拆分边界、builtin/external 区分或当前 skill 基线时,必须阅读 `doc/ai-design/human-skill-design.md` - 当涉及 prompt 的通用结构、输入组织、输出规则、约束表达或风格一致性时,必须阅读 `doc/ai-design/prompt-design.md` @@ -16,6 +16,7 @@ ### 1.3 测试相关 - 当涉及 AI 测试,必须阅读 `doc/test-plan/ai-test.md` +- 当涉及 human live 的 docx / KB 混合场景测试设计时,必须阅读 `doc/test-plan/ai-test.md` - 当涉及 Workbench 测试时,必须阅读 `doc/test-plan/workbench-test.md` - 当涉及 Settings 测试时,必须阅读 `doc/test-plan/settings-test.md` - 当涉及测试用例编写,测试规范时,必须阅读 `doc/test.md` diff --git a/doc/ai-design/data-definition-and-flow.md b/doc/ai-design/data-definition-and-flow.md index c69f6575..71f1e474 100644 --- a/doc/ai-design/data-definition-and-flow.md +++ b/doc/ai-design/data-definition-and-flow.md @@ -1100,20 +1100,21 @@ flowchart LR flowchart LR T[("task")] SM[("memory.stable")] - PM[("memory.history.roundHistory")] + PM[("memory.history.roundHistory / loadedHistoryContext")] CD[("execution.currentDecision")] A[("execution.artifacts")] - WH[("execution.watchHint")] M["mouth"] R[("result")] TR[("trace.steps")] T -->|读取 taskId + userInput| M SM -->|读取任务笔记| M - PM -->|读取上一轮完整闭环| M + PM -->|读取统一 history 事实视图| M CD -->|读取当前最终决策| M A -->|读取已收集的结构化 artifacts| M - WH -->|必要时整理为交付提示| M M -->|输出 deliveryMarkdown;node 派生 summary、回写 completed result,并透传 artifacts| R M -->|追加 mouth 执行轨迹| TR ``` + +- `mouth` 不再依赖 `recent_progress / earlier_attempts / recalled_history` 这类拆分块;统一基于 `roundHistory + loadedHistoryContext` 渲染单一 `history` 视图。 +- `mouth` 的 prompt 输入仍以 `your_notes` 为优先事实来源;当 `your_notes` 不够具体时,再从统一 `history` 与 `runtime_artifacts` 补足用户回答所需细节,而不是依赖额外的 `watchHint` 说明。 diff --git a/doc/ai-design/implementation-spec.md b/doc/ai-design/implementation-spec.md index aefee433..30f28776 100644 --- a/doc/ai-design/implementation-spec.md +++ b/doc/ai-design/implementation-spec.md @@ -94,10 +94,10 @@ stateDiagram-v2 ### 5.4 `mouth` -- 职责:把当前已经确定的状态整理成最终用户交付,并回写 `HumanResult`。 -- 主要读取:`task`、`memory.stable`、`execution.currentDecision`、`execution.artifacts`。 +- 职责:围绕 `task_goal` 把当前已经确定的状态组织成最终用户交付,并回写 `HumanResult`。 +- 主要读取:`task`、`memory.stable`、`memory.history.roundHistory`、`memory.history.loadedHistoryContext`、`execution.currentDecision`、`execution.artifacts`。 - 主要回写:`result`、`execution.status`、`execution.stage`、`trace.steps`。 -- 约束:模型输出必须通过 `ctx.chatText`;输出固定面向最终用户;若开启 `emitAnswerEvents`,增量文本通过 `answer_token` 发出,完成后通过 `answer_completed` 收口。 +- 约束:模型输出必须通过 `ctx.chatText`;输出面向最终用户,优先直接完成用户原始诉求;可使用自由段落或列表组织,不要求固定报告模板;若开启 `emitAnswerEvents`,增量文本通过 `answer_token` 发出,完成后通过 `answer_completed` 收口。 ## 6. 事件与交付 diff --git a/doc/prd/workbench.md b/doc/prd/workbench.md index caa70d7d..5e5388fc 100644 --- a/doc/prd/workbench.md +++ b/doc/prd/workbench.md @@ -99,13 +99,15 @@ - 验收: - `WB-CORE-005-AC1` `terminal` 执行成功后,后续用户可见输出会体现关键命令结果,而不是只表现为“命令执行过”。 - `WB-CORE-005-AC2` `terminal` 输出不足或执行失败时,assistant 会明确说明无法从当前结果得到所需信息,而不是编造结论或文档内容。 + - `WB-CORE-005-AC3` 用户可见输出优先回答用户原始诉求,不要求固定报告体;若引用执行结果,应使用用户能理解的表达,而不是暴露内部工具或过程标签。 #### WB-CORE-006 标准任务应能闭环到可交付结果 -- 系统行为:当用户提供了足够上下文的标准任务,且当前不需要额外阻塞或确认时,workbench 应支持 AI 沿同一条任务链完成资料使用、必要执行和最终交付,而不是只停留在搜索结果、工具调用或过程提示。 +- 系统行为:当用户提供了足够上下文的标准任务,且当前不需要额外阻塞或确认时,workbench 应支持 AI 沿同一条任务链完成资料使用、必要执行和最终交付,而不是只停留在搜索结果、工具调用或过程提示;最终收口应围绕用户原始诉求组织,而不是强制套用固定报告模板。 - 验收: - `WB-CORE-006-AC1` 用户已提供足够上下文且无需额外阻塞时,系统能把标准任务推进到可消费的最终结果或明确产物,而不是停留在中间态。 - `WB-CORE-006-AC2` 最终结果会体现用户显式给定的资料范围与执行得到的关键信息,不会把范围、执行结果和最终交付割裂开。 + - `WB-CORE-006-AC3` 最终结果默认直接回答用户显式提出的问题;若同时需要说明产物、依据或限制,也应以用户可理解的语言表达,而不是依赖内部 block id、tool 名或固定章节标题。 ### 8.2 会话管理 diff --git a/doc/test-plan/ai-test.md b/doc/test-plan/ai-test.md index 09d6807f..79578cce 100644 --- a/doc/test-plan/ai-test.md +++ b/doc/test-plan/ai-test.md @@ -247,10 +247,10 @@ - 测试类型:`backend` - 实现状态:`已实现` -- What:验证 `hand` 的并行工具执行与聚合守卫。 +- What:验证 `hand` 的并行工具执行上限、只读安全约束与结果聚合守卫。 - Why:工具执行器本身必须在真实结果下稳定收敛。 -- How:通过真实 `hand` 执行多工具调用并检查状态收敛。 -- Oracle:并行工具结果被正确聚合,运行状态无串扰。 +- How:通过真实 `hand` 执行安全并行批次、`6` 个只读安全并行批次、超过上限的 `7` 个并行批次以及包含 mutating 调用的不安全批次。 +- Oracle:`6` 个只读且 `parallelSafe` 的工具调用会被真实执行并正确聚合;`7` 个并行调用会在执行前被 `parallel_limit_exceeded` 拦截;包含 mutating 或非并行安全工具的批次会在执行前被 `parallel_requires_read_only` 拦截。 - Not:不验证 `brain` 决策来源。 ##### `hand-serial-docx-chain` @@ -416,12 +416,42 @@ - What:验证 live 模式下 `human` 会在一个真实长任务里串起模板定位、资料取证、docx 编辑与预览交付。 - Why:`human/live` 需要至少一条长期高价值复杂样例,覆盖 workspace、KB、docx 与最终交付链路。 - How:注入真实 live LLM 配置;workspace 中动态写入带 `RISK_TODO / OWNER_TODO` 占位符的周报模板和干扰文件;seed 两份真实 KB 文档分别提供风险结论与负责人说明并限制在已选范围内;直接输入“找到上周 Word 周报模板,按已选资料补全风险结论与负责人,生成预览且不要覆盖原文件”的自然语言诉求,让真实 `human` 自主完成。 -- Oracle:任务最终完成;事件流中至少出现一次 `terminal_run_command`、一次以 `kb_` 开头的检索工具调用、一次 `docx_open`、一次 `docx_apply_ops` 与一次 `docx_render_preview`;存在 preview/draft artifact,且原模板未被覆盖。 +- Oracle:任务最终完成;事件流中至少出现一次 `terminal_run_command`、一次以 `kb_` 开头的检索工具调用、一次 `docx_open`、一次 `docx_apply_ops` 与一次 `docx_render_preview`;存在 preview/draft artifact,且原模板未被覆盖;最终答复能从用户视角说明改动的是“风险结论”和“负责人说明”两处、依据分别来自 `Weekly Risk Summary.md` 与 `Owner Note.md`,并明确已经生成预览且未覆盖原文件。 - Not:不绑定最终答案措辞,也不要求固定检索工具顺序。 +##### `human-docx-cause-remedy-reading-live` + +- 测试类型:`backend` +- 实现状态:`已实现` +- What:验证 live 模式下 `human` 会基于两份已选 `docx` 做跨文件精读,并准确回答“过拟合 / 反走样”的成因与处理思路。 +- Why:真实用户在复习场景里经常已经选中了资料,但仍会把两个概念混在一起;这条 case 要长期守住“已选资料范围 + 跨文件准确回答”的正式能力,对齐 `WB-CORE-002-AC2`、`WB-CORE-004-AC1`、`WB-CORE-006-AC1`、`WB-CORE-006-AC2`。 +- How:注入真实 live LLM 配置;把 `tests/fixtures/ai/docx/深度学习.docx` 与 `tests/fixtures/ai/docx/图形学.docx` 作为已选资料复制进 workspace;直接输入“我做题老把这两个点混在一起。你就按我刚选的这两份资料,帮我说清楚:过拟合和反走样各自是什么、为什么会出现、一般怎么处理。别补充课外知识,哪份文档没写你就直接说没写。”的自然语言诉求,让真实 `human` 自主完成。 +- Oracle:任务最终完成;事件流中至少两次 `docx_open` 覆盖两份目标文档,且至少出现一次 `docx_search`、`docx_outline` 或 `docx_get_block`;最终回答同时命中“过拟合”的成因/处理事实组和“反走样”的成因/处理事实组;不出现 `docx_apply_ops` 或 `docx_save`。 +- Not:不要求固定回答结构,也不对最终文案做逐字断言。 + +##### `human-docx-contextual-comparison-draft-live` + +- 测试类型:`backend` +- 实现状态:`已实现` +- What:验证 live 模式下 `human` 会基于两份已选 `docx`,在目标 `docx` 的相关位置追加解释性段落和对照表,并生成不覆盖原文件的预览稿。 +- Why:真实用户常见诉求不是“改占位符”,而是“在这块后面补一段更好懂的说明”;这条 case 覆盖跨文件取材、相关位置补写、结构化新增与 preview-only 交付,对齐 `WB-CORE-002-AC2`、`WB-CORE-003-AC1`、`WB-CORE-004-AC1`、`WB-CORE-006-AC1`、`WB-CORE-006-AC2`。 +- How:注入真实 live LLM 配置;把 `tests/fixtures/ai/docx/深度学习.docx` 与 `tests/fixtures/ai/docx/图形学.docx` 作为已选资料复制进 workspace;直接输入“《图形学》里‘反走样’这一节我还是记不住。你参考我刚选的这两份 Word,直接在这一节最后补一小段更好理解的说明,再加个简单对照表,一列写反走样,一列写《深度学习》里的过拟合。直接给我可预览的版本就行,别改原文件,也不用先回我文字草稿。”的自然语言诉求,让真实 `human` 自主完成。 +- Oracle:任务最终完成;事件流中至少两次 `docx_open` 覆盖两份目标文档;`docx_apply_ops` 前至少出现一次针对“反走样”的读取定位;至少出现一次 `docx_apply_ops` 与一次 `docx_render_preview`;存在 preview/draft artifact;不出现 `docx_save`;原始两份 `docx` 文件未被覆盖;写入请求里能看到新增说明段落与表格结构。 +- Not:不要求固定 op 组合,也不对最终说明文案做逐字断言。 + +##### `human-kb-guided-docx-comparison-draft-live` + +- 测试类型:`backend` +- 实现状态:`已实现` +- What:验证 live 模式下 `human` 在本地资料较多时,会先用 KB 缩圈定位相关资料,再回到目标 `docx` 做跨文件补写并交付预览。 +- Why:真实用户常常记得主题、不记得文件名;这条 case 要长期守住“工作区默认范围 + KB 缩圈 + docx 继续执行”的正式能力,对齐 `WB-CORE-002-AC2`、`WB-CORE-004-AC1`、`WB-CORE-006-AC1`、`WB-CORE-006-AC2`。 +- How:注入真实 live LLM 配置;workspace 中动态写入 `深度学习.docx`、`图形学.docx` 和若干干扰文件,并把目标 `docx` 与干扰资料同时 seed 到真实 KB;不传 `selectedReferences`,只绑定 workspace;直接输入“我工作区资料有点多,我忘了是哪份课件在讲反走样了。你先帮我找到那份,再参考讲过拟合的那份资料,直接在‘反走样’那一节最后补一段更好记的说明,再加个简单对照表,一列写反走样,一列写过拟合。直接给我可预览的版本就行,别改原文件,也不用先回我文字草稿。”的自然语言诉求,让真实 `human` 自主完成。 +- Oracle:任务最终完成;事件流中至少出现一次 KB 工具调用;后续出现 `docx_open` 并覆盖最终参与编辑的目标 `docx`;至少出现一次 `docx_apply_ops` 与一次 `docx_render_preview`;存在 preview/draft artifact;不出现 `docx_save`;目标原始 `docx` 未被覆盖;新增内容同时涉及“反走样”和“过拟合”。 +- Not:不强制固定执行路径,也不要求 KB 缩圈后必须再打开所有候选资料。 + ## 5. 验收口径 - `tool` suite 必须持续守住真实工具输入输出、审批边界和 artifact 契约。 - `human/mock-llm` 必须覆盖 `AiRunService -> human` 入口、`brain / learning / hand / mouth` 主链、memory 延续、watch hint 收敛与复杂工具协作。 - `human/live` 必须至少保留 1 条简单任务和 1 条复杂任务正式 case。 -- `human` suite 必须继续支撑 `WB-CORE-002`、`WB-CORE-005`、`WB-CORE-006`。 +- `human` suite 必须继续支撑 `WB-CORE-002`、`WB-CORE-003`、`WB-CORE-004`、`WB-CORE-005`、`WB-CORE-006`。 diff --git a/src/main/services/ai/human/constants.ts b/src/main/services/ai/human/constants.ts index 37a3561b..315fdd65 100644 --- a/src/main/services/ai/human/constants.ts +++ b/src/main/services/ai/human/constants.ts @@ -63,6 +63,7 @@ export const HUMAN_WATCH_HINT_CODES = [ export const HUMAN_MAX_ACTIVE_TOOLS = 20 export const HUMAN_DEFAULT_TOOL_CALL_LIMIT = 50 +export const HUMAN_MAX_PARALLEL_TOOL_CALLS = 6 export const HUMAN_ROUND_EXECUTION_KINDS = [ 'pre_execution_blocked', 'learning', diff --git a/src/main/services/ai/human/node/handNode/index.ts b/src/main/services/ai/human/node/handNode/index.ts index d3550645..f273fb87 100644 --- a/src/main/services/ai/human/node/handNode/index.ts +++ b/src/main/services/ai/human/node/handNode/index.ts @@ -1,5 +1,5 @@ import type { JsonObject } from '../../../../../../shared/types' -import { HUMAN_DEFAULT_TOOL_CALL_LIMIT } from '../../constants' +import { HUMAN_DEFAULT_TOOL_CALL_LIMIT, HUMAN_MAX_PARALLEL_TOOL_CALLS } from '../../constants' import type { AiRunContext } from '../../../types' import { extractArtifactRefsFromToolResults, mergeArtifactRefs } from '../../../utils/artifacts' import { @@ -14,8 +14,6 @@ import type { HumanRuntimeCache, HumanState, HumanWatchHint, ToolCallResult, Too import { executeToolCall } from './toolExecutor' import { returnToBrain, returnToBrainAfterTrace, returnToBrainWithWatchHint } from '../utils/stateTransitions' -const MAX_PARALLEL_TOOL_CALLS = 4 - export const getToolExecutionMeta = (input: { toolName: string args: JsonObject @@ -70,13 +68,13 @@ const buildToolLimitWatchHint = (toolCallCount: number, limit: number): HumanWat const validateToolUsePlan = (toolUsePlan: ToolUsePlan): HumanWatchHint | undefined => { if (toolUsePlan.mode === 'serial') return undefined - if (toolUsePlan.calls.length > MAX_PARALLEL_TOOL_CALLS) { + if (toolUsePlan.calls.length > HUMAN_MAX_PARALLEL_TOOL_CALLS) { return createHandPlanInvalidWatchHint({ mode: 'parallel', reason: 'parallel_limit_exceeded', offendingToolNames: toolUsePlan.calls.map((item) => item.toolName), callCount: toolUsePlan.calls.length, - limit: MAX_PARALLEL_TOOL_CALLS + limit: HUMAN_MAX_PARALLEL_TOOL_CALLS }) } diff --git a/src/main/services/ai/human/prompts/brain.ts b/src/main/services/ai/human/prompts/brain.ts index 2023c7af..5993ea5d 100644 --- a/src/main/services/ai/human/prompts/brain.ts +++ b/src/main/services/ai/human/prompts/brain.ts @@ -13,10 +13,9 @@ import { listExternalSkillDefinitions } from '../skills' import { renderHumanStableNotes } from '../shared/memoryRenderers' +import { renderLoadedHistoryText, renderRoundSection } from '../shared/historyRenderers' import { getToolPromptSpec } from '../tools/registry' import type { - HumanLoadedHistoryContext, - HumanRoundRecord, HumanState, ToolPromptSpec } from '../types' @@ -43,168 +42,6 @@ const renderLearnedToolSpecLine = (spec: ToolPromptSpec): string => { return `- 工具 ${spec.name}:用途=${spec.purpose};必填参数=[${requiredArgs || 'none'}];${example}` } -const renderDecisionNextAction = (record: HumanRoundRecord): string => { - switch (record.decision.nextAction.action) { - case 'learning': - return `- 你准备下一步做:准备学习 ${renderQuotedNames( - record.decision.nextAction.wantLearnSkillIds - )}。` - case 'hand': - return `- 你准备下一步做:准备进入 hand,${ - record.decision.nextAction.toolUsePlan.mode === 'parallel' - ? `以 parallel 模式并行执行 ${renderQuotedNames( - record.decision.nextAction.toolUsePlan.calls.map((call) => call.toolName) - )}。` - : `以 serial 模式按顺序执行 ${renderQuotedNames( - record.decision.nextAction.toolUsePlan.calls.map((call) => call.toolName) - )}。` - }` - case 'mouth': - return '- 你准备下一步做:准备进入 mouth 组织最终交付。' - } -} - -const renderRoundDecision = (record: HumanRoundRecord): string => - ` -- 你的判断:${record.decision.decision.summary} -- 你这样判断的原因:${record.decision.decision.reason} -- 你当时期待的结果:${record.decision.decision.expectedOutcome} -- ${record.decision.readyToDeliver ? '你当时认为已经可以交付。' : '你当时认为还不能直接交付。'} -${renderDecisionNextAction(record)} -`.trim() - -const renderRoundExecution = ( - record: HumanRoundRecord, - detail: 'focus' | 'chain' | 'loaded' -): string => { - switch (record.execution.kind) { - case 'pre_execution_blocked': - return '- 没有进入真实执行,系统在执行前阻断了这轮动作。' - case 'learning': - return '- 进入 learning,按请求尝试补齐外置技能。' - case 'hand': - return ` -- 进入 hand,执行模式是 ${record.execution.toolUsePlan.mode}。 -- ${ - record.execution.toolUsePlan.mode === 'parallel' - ? `实际并行执行了 ${renderQuotedNames(record.execution.toolUsePlan.calls.map((call) => call.toolName))}。` - : `实际按顺序执行了 ${renderQuotedNames(record.execution.toolUsePlan.calls.map((call) => call.toolName))}。` - } -${detail !== 'chain' ? `- 本轮计划包含 ${record.execution.toolUsePlan.calls.length} 个工具调用。` : ''} -` - .trim() - .replace(/\n\n+/g, '\n') - case 'mouth': - return '- 进入 mouth,组织最终说明。' - } -} - -const renderRoundResult = (record: HumanRoundRecord): string => { - switch (record.execution.kind) { - case 'pre_execution_blocked': - return renderWatchHintForBrain(record.execution.watchHint) - case 'learning': - return ` -- 已加载的技能:${renderQuotedNames(record.execution.learningOutcome.loadedSkillIds)} -- 已跳过的重复技能:${renderQuotedNames(record.execution.learningOutcome.skippedDuplicateSkillIds)} -- 未识别的技能:${renderQuotedNames(record.execution.learningOutcome.skippedUnknownSkillIds)} -- 当前 active tool 数:${String(record.execution.learningOutcome.activeToolCount)} -${record.execution.watchHint ? `- 收敛提示:${renderWatchHintForBrain(record.execution.watchHint)}` : ''} -` - .trim() - .replace(/\n\n+/g, '\n') - case 'hand': { - const toolResultLines = record.execution.toolResults - .map((result) => - [ - `- \`${result.toolName}\`:${result.summary}`, - result.dataPreview ? `结果:${result.dataPreview}` : '', - result.diagnosticText ? `诊断:${result.diagnosticText}` : '' - ] - .filter(Boolean) - .join(';') - ) - .join('\n') - const artifactLine = - record.execution.artifactRefs && record.execution.artifactRefs.length > 0 - ? `- 产物:${renderInlineList( - record.execution.artifactRefs.map((artifact) => - artifact.kind === 'file' - ? `\`${artifact.fileName}\`` - : `draft review ${artifact.review.reviewId}` - ), - { - separator: '、', - fallback: 'none' - } - )}` - : '' - const watchHintLine = record.execution.watchHint - ? `- 收敛提示:${renderWatchHintForBrain(record.execution.watchHint)}` - : '' - - return [toolResultLines, artifactLine, watchHintLine].filter(Boolean).join('\n') - } - case 'mouth': - return ` -- 输出摘要:${record.execution.summary} -${record.execution.completed ? '- 任务已完成。' : '- 任务未完成。'} -`.trim() - } -} - -const renderRoundRecord = ( - record: HumanRoundRecord, - detail: 'focus' | 'chain' | 'loaded' -): string => - ` -- 第 ${record.roundId} 轮 - -决策了什么: -${renderRoundDecision(record)} - -做了什么: -${renderRoundExecution(record, detail)} - -结果是什么: -${renderRoundResult(record)} -`.trim() - -const renderRoundSection = (input: { - records: HumanRoundRecord[] - detail: 'focus' | 'chain' | 'loaded' -}): string => - input.records.length > 0 - ? input.records.map((record) => renderRoundRecord(record, input.detail)).join('\n\n') - : 'none' - -const renderLoadedHistoryText = ( - loadedHistoryContext: HumanLoadedHistoryContext | undefined -): string => - loadedHistoryContext - ? ` -过滤条件:roundIds=${renderInlineList((loadedHistoryContext.filters.roundIds || []).map(String), { - separator: '、', - fallback: 'none' - })};executionKinds=${renderInlineList(loadedHistoryContext.filters.executionKinds || [], { - separator: '、', - fallback: 'none' - })};toolNames=${renderInlineList(loadedHistoryContext.filters.toolNames || [], { - separator: '、', - fallback: 'none' - })};watchHintCodes=${renderInlineList(loadedHistoryContext.filters.watchHintCodes || [], { - separator: '、', - fallback: 'none' - })};limit=${String(loadedHistoryContext.filters.limit || loadedHistoryContext.rounds.length)} - -${renderRoundSection({ - records: loadedHistoryContext.rounds, - detail: 'loaded' -})} -` - .trim() - : 'none' - const renderAvailableSkillsSection = (state: HumanState): string => { const effectiveSkillIds = getEffectiveLearnedSkillIds(state.skills.learnedSkillIds) const effectiveSkillDefinitions = effectiveSkillIds.map((skillId) => getSkillDefinition(skillId)) diff --git a/src/main/services/ai/human/prompts/mouth.ts b/src/main/services/ai/human/prompts/mouth.ts index 0de3adf4..65ef2eaa 100644 --- a/src/main/services/ai/human/prompts/mouth.ts +++ b/src/main/services/ai/human/prompts/mouth.ts @@ -1,132 +1,41 @@ -import { renderInlineList } from '../../utils/render' -import { getLastRoundFocus } from '../memory/roundHistory' +import { getLoadedHistoryContext } from '../memory/roundHistory' +import { renderUnifiedHistoryText } from '../shared/historyRenderers' import { renderHumanStableNotes } from '../shared/memoryRenderers' import { renderHumanArtifactLines } from '../shared/runtimeRenderers' -import type { HumanRoundRecord, HumanState } from '../types' -import { renderWatchHintForMouth } from './watchHints' - -const renderRecentProgressSection = (record: HumanRoundRecord | undefined): string => { - if (!record) { - return 'none' - } - - const progressText = - record.execution.kind === 'pre_execution_blocked' - ? `- 执行前阻断:${renderWatchHintForMouth(record.execution.watchHint)}` - : record.execution.kind === 'learning' - ? `- learning 结果:已加载 ${renderInlineList( - record.execution.learningOutcome.loadedSkillIds.map((skillId) => `\`${skillId}\``), - { - separator: '、', - fallback: 'none' - } - )};重复 ${renderInlineList( - record.execution.learningOutcome.skippedDuplicateSkillIds.map( - (skillId) => `\`${skillId}\`` - ), - { - separator: '、', - fallback: 'none' - } - )};未知 ${renderInlineList( - record.execution.learningOutcome.skippedUnknownSkillIds.map( - (skillId) => `\`${skillId}\`` - ), - { - separator: '、', - fallback: 'none' - } - )}。` - : record.execution.kind === 'hand' - ? ` -- hand 执行模式:${record.execution.toolUsePlan.mode} -${record.execution.toolResults.map((result) => `- \`${result.toolName}\`:${result.summary}`).join('\n')} -` - .trim() - .replace(/\n\n+/g, '\n') - : `- mouth 输出摘要:${record.execution.summary}` - - return ` -- 第 ${record.roundId} 轮判断:${record.decision.decision.summary} -- 原因:${record.decision.decision.reason} -- 预期结果:${record.decision.decision.expectedOutcome} -${progressText} -`.trim() -} +import type { HumanState } from '../types' const buildFinalDecisionText = (state: HumanState): string => state.execution.currentDecision ? `最终决定(readyToDeliver=${String(state.execution.currentDecision.readyToDeliver)}):“${state.execution.currentDecision.decision.summary}”;原因:${state.execution.currentDecision.decision.reason};预期结果:${state.execution.currentDecision.decision.expectedOutcome}` : 'none' -const OUTPUT_EXAMPLE = ` -# 处理结果 - -已完成当前任务,下面是基于现有事实和结果整理出的最终结论。 - -## 关键依据 -- 依据一 - -## 产物与引用 -- /path/to/file.docx - -## 当前限制 -无 -`.trim() - export const buildMouthPrompt = (input: { state: HumanState }) => { const system = ` - 你现在扮演一个负责对外表达最终结果的人。 - 你的任务是把已经确定下来的判断、任务笔记和产物整理成最终交付。 + 你现在负责根据已有事实回答用户这次任务。 + 你的目标不是复述内部过程,而是直接完成 task_goal 里用户真正要的回复。 - - 请像一个表达清晰、判断克制的人一样输出结果: - 不夸大、不补编、不引入新的结论, - 只把已经确认的信息组织成最终交付。 - - - - - final_decision 是 brain 已经确认的最终判断;你负责表达,不负责重新推翻或改写它。 - - your_notes 是当前任务的稳定事实来源,包括已确认事实、关键依据和未决问题;正文中的事实与依据优先从这里取。 - - recent_progress 只用于补充最近完成到哪里、最近结果如何,不必把内部过程完整复述出来。 - - runtime_artifacts 只允许准确引用已经给出的路径、文件名、状态和摘要,不要补编额外元数据。 - - correction_hint 只用于如实说明限制、阻断或未完成点,不要把它扩写成新的事实。 - - - - - task_goal:用户原始诉求与限制。 - - final_decision:brain 已经确认的最终判断与收口方向。 - - your_notes:稳定任务笔记,按“已确认 / 关键依据 / 未决问题”组织。 - - recent_progress:最近一轮过程记录,用于补充当前完成到哪里。 - - runtime_artifacts:运行中已经产生的真实产物列表。 - - correction_hint:当前额外的收敛或纠偏提醒;若无则为 none。 - - - - 1. 如果任务已经完成,先直接给出结论,再给关键依据,再给结果或产物说明。 - 2. 如果当前结果有限,先说明已经完成到哪里,再说明限制和继续需要什么。 - 3. 只使用已经给你的任务笔记、最终判断和产物,不要补充新的事实或推理。 - 4. 语言要清楚、克制、可执行,不要堆内部过程。 - 5. 如果存在运行时产物或最近过程记录,只能准确引用已经给出的路径、状态和摘要。 - 6. 你不会调用工具,也不会继续推进未完成步骤;如果仍有步骤没做,只能如实说明当前停在哪。 - + + - task_goal:用户最初要你完成什么,以及用户显式要求你回答哪些点。 + - final_decision:brain 最后确认的收口方向。 + - your_notes:当前稳定事实来源。其中: + - 已确认:当前稳定成立、可以直接告诉用户的事实。 + - 关键依据:支撑这些事实的证据、来源或命中结果。 + - 未决问题:仍会影响结论、或需要用户补充确认的点。 + - history:任务过程中真实发生过的决策、执行和结果,可用于补足 your_notes 不够具体的地方。 + - runtime_artifacts:运行中已经产生的真实产物,可按给定路径、文件名、状态和摘要引用。 + - - 你必须只输出最终 markdown 正文,不要输出任何额外说明、JSON 或代码块。 - - 不要引入新的事实、依据、步骤或承诺。 - - 不要暴露内部字段名、节点名或系统术语。 - - 如果依据不足,就保持保守表达,不要写成确定结论。 - - 不要把未来可能执行的工具动作写成“后续会自动完成”或“已经在进行中”。 - - 输出必须是完整结果,而不是零散句子或局部片段。 - - 默认使用最终用户答复的组织方式:先给结论,再给关键依据,再给产物或当前限制。 + - 只输出最终 markdown 正文,不要输出 JSON 或额外说明。 + - 直接回答 task_goal,优先覆盖用户显式提出的问题。 + - your_notes 优先;如果 your_notes 不够具体,可结合 history 与 runtime_artifacts 补足。 + - 只基于提供的信息回答,不要补编新的事实。 + - 标题、分节、列表都按需要使用,不固定。 + - 默认使用用户能理解的语言,不原样照抄内部标签、block id、tool 名,除非用户明确要求这些内部细节。 - - -${OUTPUT_EXAMPLE} - `.trim() @@ -144,17 +53,16 @@ ${buildFinalDecisionText(input.state)} ${renderHumanStableNotes(input.state, 'mouth_prompt')} - -${renderRecentProgressSection(getLastRoundFocus(input.state))} - + +${renderUnifiedHistoryText({ + roundHistory: input.state.memory.history.roundHistory, + loadedHistoryContext: getLoadedHistoryContext(input.state) +})} + ${renderHumanArtifactLines(input.state, 'prompt')} - - -${input.state.execution.watchHint ? renderWatchHintForMouth(input.state.execution.watchHint) : 'none'} - `.trim() diff --git a/src/main/services/ai/human/prompts/watchHints.ts b/src/main/services/ai/human/prompts/watchHints.ts index d39e743a..6db03e50 100644 --- a/src/main/services/ai/human/prompts/watchHints.ts +++ b/src/main/services/ai/human/prompts/watchHints.ts @@ -78,8 +78,4 @@ export const renderWatchHintForBrain = (hint: HumanWatchHint): string => { } } -export const renderWatchHintForMouth = (hint: HumanWatchHint): string => { - return summarizeWatchHint(hint) -} - export const renderWatchHintDetail = (hint: HumanWatchHint): JsonObject => asJsonObject(hint.detail) diff --git a/src/main/services/ai/human/shared/historyRenderers.ts b/src/main/services/ai/human/shared/historyRenderers.ts new file mode 100644 index 00000000..ec18e134 --- /dev/null +++ b/src/main/services/ai/human/shared/historyRenderers.ts @@ -0,0 +1,191 @@ +import { renderInlineList } from '../../utils/render' +import type { HumanLoadedHistoryContext, HumanRoundRecord } from '../types' +import { renderWatchHintForBrain } from '../prompts/watchHints' + +const renderQuotedNames = (items: readonly string[]): string => + renderInlineList( + items.map((item) => `\`${item}\``), + { + separator: '、', + fallback: 'none' + } + ) + +const renderDecisionNextAction = (record: HumanRoundRecord): string => { + switch (record.decision.nextAction.action) { + case 'learning': + return `- 你准备下一步做:准备学习 ${renderQuotedNames( + record.decision.nextAction.wantLearnSkillIds + )}。` + case 'hand': + return `- 你准备下一步做:准备进入 hand,${ + record.decision.nextAction.toolUsePlan.mode === 'parallel' + ? `以 parallel 模式并行执行 ${renderQuotedNames( + record.decision.nextAction.toolUsePlan.calls.map((call) => call.toolName) + )}。` + : `以 serial 模式按顺序执行 ${renderQuotedNames( + record.decision.nextAction.toolUsePlan.calls.map((call) => call.toolName) + )}。` + }` + case 'mouth': + return '- 你准备下一步做:准备进入 mouth 组织最终交付。' + } +} + +const renderRoundDecision = (record: HumanRoundRecord): string => + ` +- 你的判断:${record.decision.decision.summary} +- 你这样判断的原因:${record.decision.decision.reason} +- 你当时期待的结果:${record.decision.decision.expectedOutcome} +- ${record.decision.readyToDeliver ? '你当时认为已经可以交付。' : '你当时认为还不能直接交付。'} +${renderDecisionNextAction(record)} +`.trim() + +const renderRoundExecution = ( + record: HumanRoundRecord, + detail: 'focus' | 'chain' | 'loaded' +): string => { + switch (record.execution.kind) { + case 'pre_execution_blocked': + return '- 没有进入真实执行,系统在执行前阻断了这轮动作。' + case 'learning': + return '- 进入 learning,按请求尝试补齐外置技能。' + case 'hand': + return ` +- 进入 hand,执行模式是 ${record.execution.toolUsePlan.mode}。 +- ${ + record.execution.toolUsePlan.mode === 'parallel' + ? `实际并行执行了 ${renderQuotedNames(record.execution.toolUsePlan.calls.map((call) => call.toolName))}。` + : `实际按顺序执行了 ${renderQuotedNames(record.execution.toolUsePlan.calls.map((call) => call.toolName))}。` + } +${detail !== 'chain' ? `- 本轮计划包含 ${record.execution.toolUsePlan.calls.length} 个工具调用。` : ''} +` + .trim() + .replace(/\n\n+/g, '\n') + case 'mouth': + return '- 进入 mouth,组织最终说明。' + } +} + +const renderRoundResult = (record: HumanRoundRecord): string => { + switch (record.execution.kind) { + case 'pre_execution_blocked': + return renderWatchHintForBrain(record.execution.watchHint) + case 'learning': + return ` +- 已加载的技能:${renderQuotedNames(record.execution.learningOutcome.loadedSkillIds)} +- 已跳过的重复技能:${renderQuotedNames(record.execution.learningOutcome.skippedDuplicateSkillIds)} +- 未识别的技能:${renderQuotedNames(record.execution.learningOutcome.skippedUnknownSkillIds)} +- 当前 active tool 数:${String(record.execution.learningOutcome.activeToolCount)} +${record.execution.watchHint ? `- 收敛提示:${renderWatchHintForBrain(record.execution.watchHint)}` : ''} +` + .trim() + .replace(/\n\n+/g, '\n') + case 'hand': { + const toolResultLines = record.execution.toolResults + .map((result) => + [ + `- \`${result.toolName}\`:${result.summary}`, + result.dataPreview ? `结果:${result.dataPreview}` : '', + result.diagnosticText ? `诊断:${result.diagnosticText}` : '' + ] + .filter(Boolean) + .join(';') + ) + .join('\n') + const artifactLine = + record.execution.artifactRefs && record.execution.artifactRefs.length > 0 + ? `- 产物:${renderInlineList( + record.execution.artifactRefs.map((artifact) => + artifact.kind === 'file' + ? `\`${artifact.fileName}\`` + : `draft review ${artifact.review.reviewId}` + ), + { + separator: '、', + fallback: 'none' + } + )}` + : '' + const watchHintLine = record.execution.watchHint + ? `- 收敛提示:${renderWatchHintForBrain(record.execution.watchHint)}` + : '' + + return [toolResultLines, artifactLine, watchHintLine].filter(Boolean).join('\n') + } + case 'mouth': + return ` +- 输出摘要:${record.execution.summary} +${record.execution.completed ? '- 任务已完成。' : '- 任务未完成。'} +`.trim() + } +} + +const renderRoundRecord = ( + record: HumanRoundRecord, + detail: 'focus' | 'chain' | 'loaded' +): string => + ` +- 第 ${record.roundId} 轮 + +决策了什么: +${renderRoundDecision(record)} + +做了什么: +${renderRoundExecution(record, detail)} + +结果是什么: +${renderRoundResult(record)} +`.trim() + +export const renderRoundSection = (input: { + records: HumanRoundRecord[] + detail: 'focus' | 'chain' | 'loaded' +}): string => + input.records.length > 0 + ? input.records.map((record) => renderRoundRecord(record, input.detail)).join('\n\n') + : 'none' + +export const renderLoadedHistoryText = ( + loadedHistoryContext: HumanLoadedHistoryContext | undefined +): string => + loadedHistoryContext + ? ` +过滤条件:roundIds=${renderInlineList((loadedHistoryContext.filters.roundIds || []).map(String), { + separator: '、', + fallback: 'none' + })};executionKinds=${renderInlineList(loadedHistoryContext.filters.executionKinds || [], { + separator: '、', + fallback: 'none' + })};toolNames=${renderInlineList(loadedHistoryContext.filters.toolNames || [], { + separator: '、', + fallback: 'none' + })};watchHintCodes=${renderInlineList(loadedHistoryContext.filters.watchHintCodes || [], { + separator: '、', + fallback: 'none' + })};limit=${String(loadedHistoryContext.filters.limit || loadedHistoryContext.rounds.length)} + +${renderRoundSection({ + records: loadedHistoryContext.rounds, + detail: 'loaded' +})} +` + .trim() + : 'none' + +export const renderUnifiedHistoryText = (input: { + roundHistory: HumanRoundRecord[] + loadedHistoryContext: HumanLoadedHistoryContext | undefined +}): string => + ` +过程历史: +${renderRoundSection({ + records: input.roundHistory, + detail: 'loaded' +})} + +主动回看历史: +${renderLoadedHistoryText(input.loadedHistoryContext)} +` + .trim() + .replace(/\n{3,}/g, '\n\n') diff --git a/tests/ai/human/live/human-docx-cause-remedy-reading-live.test.ts b/tests/ai/human/live/human-docx-cause-remedy-reading-live.test.ts new file mode 100644 index 00000000..85830510 --- /dev/null +++ b/tests/ai/human/live/human-docx-cause-remedy-reading-live.test.ts @@ -0,0 +1,121 @@ +import { describe, it } from 'vitest' +import { loadLiveLlmConfigFromEnv, hasLiveLlmConfigInEnv } from '../../../harness/backend/llmConfig' +import { createHumanTestRunner } from '../../../harness/backend/humanHarness' +import { runBackendCase } from '../../../harness/backend/run-backend-case' +import { + buildSelectedDocxFixtureReferences, + DOCX_CROSS_FILE_FIXTURE_PATHS, + HUMAN_DOCX_CAUSE_REMEDY_READING_USER_INPUT, + resolveSelectedDocxFixtures +} from '../../../harness/backend/aiLiveTestData' +import { + collectStartedToolStringArgs, + filterAiRunEventsByType, + runWithStructuredOutputRetry +} from '../testUtils' + +const openedExpectedDoc = ( + openedSourcePaths: readonly string[], + expectedRelativePath: string, + expectedName: string +): boolean => + openedSourcePaths.some( + (sourcePath) => + sourcePath.endsWith(expectedRelativePath) || sourcePath.endsWith(`/${expectedName}`) + ) + +describe('ai human live backend', () => { + it('human-docx-cause-remedy-reading-live', async () => { + await runWithStructuredOutputRetry(async () => { + await runBackendCase({ + caseId: 'human-docx-cause-remedy-reading-live', + harnessMode: 'live', + domain: 'ai', + suite: 'human', + groups: ['live'], + description: '验证 live 模式下 human 会基于两份已选 docx 准确回答过拟合与反走样的成因和处理思路。', + fixtures: DOCX_CROSS_FILE_FIXTURE_PATHS, + runMode: 'single', + runtimeModelName: hasLiveLlmConfigInEnv('primary') + ? loadLiveLlmConfigFromEnv('primary').model_name + : null, + execute: async (runtime) => { + if (!hasLiveLlmConfigInEnv('primary')) { + return { + skipped: true, + output: null, + fixtureDocs: [], + events: [] + } + } + + await runtime.seedLlmConfig(loadLiveLlmConfigFromEnv('primary')) + const fixtureDocs = resolveSelectedDocxFixtures(runtime) + const runner = createHumanTestRunner(runtime, { + selectedReferences: buildSelectedDocxFixtureReferences(runtime) + }) + const output = await runner.run({ + taskId: runtime.taskId, + userInput: HUMAN_DOCX_CAUSE_REMEDY_READING_USER_INPUT + }) + + return { + skipped: false, + output, + fixtureDocs, + events: runtime.eventCollector.snapshot() + } + }, + assert: async (result, runtime) => { + if (result.skipped) { + runtime.assertThat(true, { + name: 'live-config-not-provided', + message: '未提供 live 模型配置,已跳过 human live docx cause/remedy reading case。' + }) + return + } + + const toolResults = filterAiRunEventsByType(result.events, 'tool_call_result') + const toolNames = toolResults.map((event) => event.toolName) + const openedSourcePaths = collectStartedToolStringArgs( + result.events, + 'docx_open', + 'sourcePath' + ) + const deliveryText = `${result.output?.summary || ''}\n${result.output?.deliveryMarkdown || ''}` + + runtime.assertThat( + result.output?.status === 'completed' && + result.fixtureDocs.every((fixtureDoc) => + openedExpectedDoc(openedSourcePaths, fixtureDoc.relativePath, fixtureDoc.name) + ) && + toolNames.some( + (toolName) => + toolName === 'docx_search' || + toolName === 'docx_outline' || + toolName === 'docx_get_block' + ) && + !toolNames.includes('docx_apply_ops') && + !toolNames.includes('docx_save') && + /(过拟合|过度拟合)/.test(deliveryText) && + /(记住过多训练数据中的特征值|训练时间过久)/.test(deliveryText) && + /(数据增强|未使用过的数据|验证数据)/.test(deliveryText) && + /反走样/.test(deliveryText) && + /(采样频率低于信号频率|像素.*边界)/.test(deliveryText) && + /(低通滤波|多个采样点|多个像素点|多次采样)/.test(deliveryText), + { + name: 'human-live-reads-both-selected-docx-and-explains-cause-and-remedy', + message: 'live human 读 case 应打开两份已选 docx,并准确回答过拟合与反走样的成因和处理思路。', + data: { + openedSourcePaths, + toolNames, + summary: result.output?.summary, + deliveryMarkdown: result.output?.deliveryMarkdown + } + } + ) + } + }) + }) + }, 600_000) +}) diff --git a/tests/ai/human/live/human-docx-contextual-comparison-draft-live.test.ts b/tests/ai/human/live/human-docx-contextual-comparison-draft-live.test.ts new file mode 100644 index 00000000..b6d4f4ad --- /dev/null +++ b/tests/ai/human/live/human-docx-contextual-comparison-draft-live.test.ts @@ -0,0 +1,160 @@ +import fs from 'node:fs/promises' +import { describe, it } from 'vitest' +import type { AiRunEvent } from '../../../../src/shared/types' +import { loadLiveLlmConfigFromEnv, hasLiveLlmConfigInEnv } from '../../../harness/backend/llmConfig' +import { createHumanTestRunner } from '../../../harness/backend/humanHarness' +import { runBackendCase } from '../../../harness/backend/run-backend-case' +import { + buildSelectedDocxFixtureReferences, + DOCX_CROSS_FILE_FIXTURE_PATHS, + hasUnchangedFileBytes, + HUMAN_DOCX_CONTEXTUAL_COMPARISON_DRAFT_USER_INPUT, + resolveSelectedDocxFixtures +} from '../../../harness/backend/aiLiveTestData' +import { + collectDocxApplyOpsContentKinds, + collectStartedToolStringArgs, + filterAiRunEventsByType, + runWithStructuredOutputRetry +} from '../testUtils' + +const openedExpectedDoc = ( + openedSourcePaths: readonly string[], + expectedRelativePath: string, + expectedName: string +): boolean => + openedSourcePaths.some( + (sourcePath) => + sourcePath.endsWith(expectedRelativePath) || sourcePath.endsWith(`/${expectedName}`) + ) + +const hasTargetReadBeforeApply = (events: readonly AiRunEvent[]): boolean => { + const firstApplyIndex = events.findIndex( + (event) => event.type === 'tool_call_started' && event.toolName === 'docx_apply_ops' + ) + if (firstApplyIndex <= 0) { + return false + } + + return events.slice(0, firstApplyIndex).some((event) => { + if (event.type !== 'tool_call_started') { + return false + } + if (event.toolName === 'docx_search') { + const query = event.input.query + return typeof query === 'string' && query.includes('反走样') + } + return event.toolName === 'docx_outline' || event.toolName === 'docx_get_block' + }) +} + +describe('ai human live backend', () => { + it('human-docx-contextual-comparison-draft-live', async () => { + await runWithStructuredOutputRetry(async () => { + await runBackendCase({ + caseId: 'human-docx-contextual-comparison-draft-live', + harnessMode: 'live', + domain: 'ai', + suite: 'human', + groups: ['live'], + description: '验证 live 模式下 human 会在图形学相关位置后补写说明和对照表,并生成预览。', + fixtures: DOCX_CROSS_FILE_FIXTURE_PATHS, + runMode: 'single', + runtimeModelName: hasLiveLlmConfigInEnv('primary') + ? loadLiveLlmConfigFromEnv('primary').model_name + : null, + execute: async (runtime) => { + if (!hasLiveLlmConfigInEnv('primary')) { + return { + skipped: true, + output: null, + fixtureDocs: [], + originalFixtureBytes: [], + events: [] + } + } + + await runtime.seedLlmConfig(loadLiveLlmConfigFromEnv('primary')) + const fixtureDocs = resolveSelectedDocxFixtures(runtime) + const originalFixtureBytes = await Promise.all( + fixtureDocs.map(async (fixtureDoc) => ({ + name: fixtureDoc.name, + relativePath: fixtureDoc.relativePath, + workspacePath: fixtureDoc.workspacePath, + bytes: await fs.readFile(fixtureDoc.workspacePath) + })) + ) + const runner = createHumanTestRunner(runtime, { + selectedReferences: buildSelectedDocxFixtureReferences(runtime) + }) + const output = await runner.run({ + taskId: runtime.taskId, + userInput: HUMAN_DOCX_CONTEXTUAL_COMPARISON_DRAFT_USER_INPUT + }) + + return { + skipped: false, + output, + fixtureDocs, + originalFixtureBytes, + events: runtime.eventCollector.snapshot() + } + }, + assert: async (result, runtime) => { + if (result.skipped) { + runtime.assertThat(true, { + name: 'live-config-not-provided', + message: '未提供 live 模型配置,已跳过 human live contextual docx comparison draft case。' + }) + return + } + + const events = result.events + const toolResults = filterAiRunEventsByType(events, 'tool_call_result') + const toolNames = toolResults.map((event) => event.toolName) + const openedSourcePaths = collectStartedToolStringArgs(events, 'docx_open', 'sourcePath') + const changedContentKinds = collectDocxApplyOpsContentKinds(events) + const docxArtifacts = + result.output?.artifacts?.filter( + (artifact) => + artifact.kind === 'file' && + artifact.capability === 'docx' && + (artifact.artifactRole === 'preview' || artifact.artifactRole === 'draft') + ) || [] + const unchangedFixtures = await Promise.all( + result.originalFixtureBytes.map(async (fixture) => + await hasUnchangedFileBytes(fixture.workspacePath, fixture.bytes) + ) + ) + + runtime.assertThat( + result.output?.status === 'completed' && + result.fixtureDocs.every((fixtureDoc) => + openedExpectedDoc(openedSourcePaths, fixtureDoc.relativePath, fixtureDoc.name) + ) && + hasTargetReadBeforeApply(events) && + toolNames.includes('docx_apply_ops') && + toolNames.includes('docx_render_preview') && + !toolNames.includes('docx_save') && + docxArtifacts.length > 0 && + changedContentKinds.includes('paragraph') && + changedContentKinds.includes('table') && + unchangedFixtures.every(Boolean), + { + name: 'human-live-contextual-docx-write-produces-preview-without-overwriting-source', + message: 'live human 定位补写 case 应在读取定位后补写说明和表格,并仅生成预览稿。', + data: { + openedSourcePaths, + toolNames, + changedContentKinds, + summary: result.output?.summary, + deliveryMarkdown: result.output?.deliveryMarkdown, + artifacts: result.output?.artifacts + } + } + ) + } + }) + }) + }, 600_000) +}) diff --git a/tests/ai/human/live/human-kb-guided-docx-comparison-draft-live.test.ts b/tests/ai/human/live/human-kb-guided-docx-comparison-draft-live.test.ts new file mode 100644 index 00000000..c91303e0 --- /dev/null +++ b/tests/ai/human/live/human-kb-guided-docx-comparison-draft-live.test.ts @@ -0,0 +1,128 @@ +import path from 'node:path' +import { describe, it } from 'vitest' +import { loadLiveLlmConfigFromEnv, hasLiveLlmConfigInEnv } from '../../../harness/backend/llmConfig' +import { createHumanTestRunner } from '../../../harness/backend/humanHarness' +import { runBackendCase } from '../../../harness/backend/run-backend-case' +import { + hasUnchangedFileBytes, + HUMAN_KB_GUIDED_DOCX_COMPARISON_DRAFT_USER_INPUT, + seedKbGuidedDocxKb, + seedKbGuidedDocxWorkspace +} from '../../../harness/backend/aiLiveTestData' +import { + collectDocxApplyOpsContentKinds, + collectStartedToolStringArgs, + filterAiRunEventsByType, + hasKbToolCall, + runWithStructuredOutputRetry +} from '../testUtils' + +describe('ai human live backend', () => { + it('human-kb-guided-docx-comparison-draft-live', async () => { + await runWithStructuredOutputRetry(async () => { + await runBackendCase({ + caseId: 'human-kb-guided-docx-comparison-draft-live', + harnessMode: 'live', + domain: 'ai', + suite: 'human', + groups: ['live'], + description: '验证 live 模式下 human 会先用 KB 缩圈定位课件,再继续对目标 docx 做预览型补写。', + fixtures: [], + runMode: 'single', + runtimeModelName: hasLiveLlmConfigInEnv('primary') + ? loadLiveLlmConfigFromEnv('primary').model_name + : null, + execute: async (runtime) => { + if (!hasLiveLlmConfigInEnv('primary')) { + return { + skipped: true, + output: null, + workspaceSeed: null, + events: [] + } + } + + await runtime.seedLlmConfig(loadLiveLlmConfigFromEnv('primary')) + const workspaceSeed = await seedKbGuidedDocxWorkspace(runtime) + await seedKbGuidedDocxKb(runtime, workspaceSeed) + + const runner = createHumanTestRunner(runtime) + const output = await runner.run({ + taskId: runtime.taskId, + userInput: HUMAN_KB_GUIDED_DOCX_COMPARISON_DRAFT_USER_INPUT + }) + + return { + skipped: false, + output, + workspaceSeed, + events: runtime.eventCollector.snapshot() + } + }, + assert: async (result, runtime) => { + if (result.skipped || !result.workspaceSeed) { + runtime.assertThat(true, { + name: 'live-config-not-provided', + message: '未提供 live 模型配置,已跳过 human live KB-guided docx comparison draft case。' + }) + return + } + + const events = result.events + const toolResults = filterAiRunEventsByType(events, 'tool_call_result') + const toolNames = toolResults.map((event) => event.toolName) + const openedSourcePaths = collectStartedToolStringArgs(events, 'docx_open', 'sourcePath') + const changedContentKinds = collectDocxApplyOpsContentKinds(events) + const docxArtifacts = + result.output?.artifacts?.filter( + (artifact) => + artifact.kind === 'file' && + artifact.capability === 'docx' && + (artifact.artifactRole === 'preview' || artifact.artifactRole === 'draft') + ) || [] + const deepLearningUnchanged = await hasUnchangedFileBytes( + result.workspaceSeed.deepLearningPath, + result.workspaceSeed.originalDeepLearningBytes + ) + const graphicsUnchanged = await hasUnchangedFileBytes( + result.workspaceSeed.graphicsPath, + result.workspaceSeed.originalGraphicsBytes + ) + const deliveryText = `${result.output?.summary || ''}\n${result.output?.deliveryMarkdown || ''}` + + runtime.assertThat( + result.output?.status === 'completed' && + hasKbToolCall(events) && + toolNames.includes('docx_apply_ops') && + toolNames.includes('docx_render_preview') && + !toolNames.includes('docx_save') && + docxArtifacts.length > 0 && + changedContentKinds.includes('paragraph') && + changedContentKinds.includes('table') && + openedSourcePaths.some( + (sourcePath) => + sourcePath.endsWith(result.workspaceSeed.graphicsRelativePath) || + sourcePath.endsWith(`/${path.basename(result.workspaceSeed.graphicsRelativePath)}`) + ) && + /(过拟合|过度拟合)/.test(deliveryText) && + /反走样/.test(deliveryText) && + deepLearningUnchanged && + graphicsUnchanged, + { + name: 'human-live-kb-guided-docx-write-uses-kb-and-preserves-source-files', + message: 'live human KB-guided 写 case 应先触发 KB 缩圈,再对目标 docx 生成预览稿且不覆盖原文件。', + data: { + openedSourcePaths, + toolNames, + changedContentKinds, + summary: result.output?.summary, + deliveryMarkdown: result.output?.deliveryMarkdown, + artifacts: result.output?.artifacts + } + } + ) + } + }) + }) + }, 600_000) +}) diff --git a/tests/ai/human/live/human-risk-report-draft-live.test.ts b/tests/ai/human/live/human-risk-report-draft-live.test.ts index df92f8ca..1d39796e 100644 --- a/tests/ai/human/live/human-risk-report-draft-live.test.ts +++ b/tests/ai/human/live/human-risk-report-draft-live.test.ts @@ -83,8 +83,12 @@ describe('ai human live backend', () => { toolNames.includes('docx_render_preview') && docxArtifacts.length > 0 && templateUnchanged && - /(待跟进|合规签署)/.test(deliveryText) && - /Alice/.test(deliveryText), + /风险结论/.test(deliveryText) && + /负责人(说明)?/.test(deliveryText) && + /Weekly Risk Summary\.md/.test(deliveryText) && + /Owner Note\.md/.test(deliveryText) && + /(预览|preview)/i.test(deliveryText) && + /(未覆盖原文件|原文件未被覆盖|未覆盖原件)/.test(deliveryText), { name: 'human-live-completes-risk-report-draft-through-real-tool-chain', message: 'live human complex case 应通过 terminal、KB 与 docx 真实工具链完成周报草稿任务。', diff --git a/tests/ai/human/testUtils.ts b/tests/ai/human/testUtils.ts index 77061b62..63ac920a 100644 --- a/tests/ai/human/testUtils.ts +++ b/tests/ai/human/testUtils.ts @@ -4,7 +4,7 @@ import type { HumanStableMemoryPatch, RuntimeSkillId } from '../../../src/main/services/ai/human/types' -import type { AiRunEvent, JsonObject, JsonValue } from '../../../src/shared/types' +import type { AiRunEvent, JsonArray, JsonObject, JsonValue } from '../../../src/shared/types' export const filterAiRunEventsByType = ( events: readonly AiRunEvent[], @@ -98,6 +98,83 @@ export const filterToolCallStartedEventsByName = ( ): Extract[] => filterAiRunEventsByType(events, 'tool_call_started').filter((event) => event.toolName === toolName) +export const collectStartedToolInputs = ( + events: readonly AiRunEvent[], + toolName: string +): JsonObject[] => filterToolCallStartedEventsByName(events, toolName).map((event) => event.input) + +export const collectStartedToolStringArgs = ( + events: readonly AiRunEvent[], + toolName: string, + argName: string +): string[] => + collectStartedToolInputs(events, toolName) + .map((input) => input[argName]) + .filter((value): value is string => typeof value === 'string') + +export const hasKbToolCall = (events: readonly AiRunEvent[]): boolean => + filterAiRunEventsByType(events, 'tool_call_result').some((event) => event.toolName.startsWith('kb_')) + +const isJsonObject = (value: JsonValue | undefined): value is JsonObject => + typeof value === 'object' && value !== null && !Array.isArray(value) + +const isJsonArray = (value: JsonValue | undefined): value is JsonArray => Array.isArray(value) + +const collectDocxBlockKindsFromValue = ( + value: JsonValue | undefined, + kinds: Set<'paragraph' | 'table'> +): void => { + if (isJsonArray(value)) { + value.forEach((item) => collectDocxBlockKindsFromValue(item, kinds)) + return + } + + if (!isJsonObject(value)) { + return + } + + const kind = value.kind + if (kind === 'paragraph' || kind === 'table') { + kinds.add(kind) + } + + collectDocxBlockKindsFromValue(value.content, kinds) + collectDocxBlockKindsFromValue(value.ops, kinds) + collectDocxBlockKindsFromValue(value.rows, kinds) +} + +export const collectDocxApplyOpsContentKinds = ( + events: readonly AiRunEvent[] +): Array<'paragraph' | 'table'> => { + const kinds = new Set<'paragraph' | 'table'>() + collectStartedToolInputs(events, 'docx_apply_ops').forEach((input) => { + collectDocxBlockKindsFromValue(input, kinds) + }) + return [...kinds] +} + +const isStructuredOutputEmptyResponseError = (error: unknown): boolean => + error instanceof Error && error.message.includes('structured_output_empty_response') + +export const runWithStructuredOutputRetry = async ( + action: () => Promise, + maxAttempts = 2 +): Promise => { + let lastError: unknown = null + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + try { + await action() + return + } catch (error) { + lastError = error + if (!isStructuredOutputEmptyResponseError(error) || attempt >= maxAttempts) { + throw error + } + } + } + throw lastError +} + export const readToolCallResultPreview = ( events: readonly AiRunEvent[], toolName: string, diff --git a/tests/ai/tool/hand/hand-parallel-safety-contract.test.ts b/tests/ai/tool/hand/hand-parallel-safety-contract.test.ts index e0c5b737..2646a57f 100644 --- a/tests/ai/tool/hand/hand-parallel-safety-contract.test.ts +++ b/tests/ai/tool/hand/hand-parallel-safety-contract.test.ts @@ -126,18 +126,182 @@ describe('ai tool hand backend', () => { } }) + const safeLimitState = await handNode({ + ...safeState, + skills: { + learnedSkillIds: ['docx-read'] + }, + execution: { + ...safeState.execution, + currentDecision: { + decision: { + summary: '执行 6 个只读并行调用', + reason: '验证新的并行上限允许 6 个安全调用。', + expectedOutcome: '六个 read_only 且 parallelSafe 的调用全部真正执行。' + }, + readyToDeliver: false, + nextAction: { + action: 'hand', + toolUsePlan: { + mode: 'parallel', + calls: [ + { + toolName: 'docx_outline', + args: { + docId: openResult.docId, + maxBlocks: 20 + } + }, + { + toolName: 'docx_search', + args: { + docId: openResult.docId, + query: 'Safety', + mode: 'literal' + } + }, + { + toolName: 'docx_search', + args: { + docId: openResult.docId, + query: 'Risk', + mode: 'literal' + } + }, + { + toolName: 'docx_search', + args: { + docId: openResult.docId, + query: 'monitor', + mode: 'literal' + } + }, + { + toolName: 'docx_search', + args: { + docId: openResult.docId, + query: 'approval', + mode: 'literal' + } + }, + { + toolName: 'docx_search', + args: { + docId: openResult.docId, + query: 'edge', + mode: 'literal' + } + } + ] + } + } + } + } + }) + + const toolStartedBeforeLimitExceeded = filterEventsByType( + runtime.eventCollector.snapshot(), + 'tool_call_started' + ).length + + const limitExceededState = await handNode({ + ...safeLimitState, + skills: { + learnedSkillIds: ['docx-read'] + }, + execution: { + ...safeLimitState.execution, + currentDecision: { + decision: { + summary: '执行 7 个只读并行调用', + reason: '验证超过并行上限时会直接回到 brain。', + expectedOutcome: '写入 parallel_limit_exceeded watchHint,且不继续执行工具。' + }, + readyToDeliver: false, + nextAction: { + action: 'hand', + toolUsePlan: { + mode: 'parallel', + calls: [ + { + toolName: 'docx_outline', + args: { + docId: openResult.docId, + maxBlocks: 20 + } + }, + { + toolName: 'docx_search', + args: { + docId: openResult.docId, + query: 'Safety', + mode: 'literal' + } + }, + { + toolName: 'docx_search', + args: { + docId: openResult.docId, + query: 'Risk', + mode: 'literal' + } + }, + { + toolName: 'docx_search', + args: { + docId: openResult.docId, + query: 'monitor', + mode: 'literal' + } + }, + { + toolName: 'docx_search', + args: { + docId: openResult.docId, + query: 'approval', + mode: 'literal' + } + }, + { + toolName: 'docx_search', + args: { + docId: openResult.docId, + query: 'edge', + mode: 'literal' + } + }, + { + toolName: 'docx_search', + args: { + docId: openResult.docId, + query: 'cases', + mode: 'literal' + } + } + ] + } + } + } + } + }) + + const toolStartedAfterLimitExceeded = filterEventsByType( + runtime.eventCollector.snapshot(), + 'tool_call_started' + ).length + const toolStartedBeforeUnsafe = filterEventsByType( runtime.eventCollector.snapshot(), 'tool_call_started' ).length const unsafeState = await handNode({ - ...safeState, + ...limitExceededState, skills: { learnedSkillIds: ['docx-read', 'docx-write', 'docx-delivery'] }, execution: { - ...safeState.execution, + ...limitExceededState.execution, currentDecision: { decision: { summary: '并行写入和 validate', @@ -183,7 +347,11 @@ describe('ai tool hand backend', () => { return { safeState, + safeLimitState, + limitExceededState, unsafeState, + toolStartedBeforeLimitExceeded, + toolStartedAfterLimitExceeded, toolStartedBeforeUnsafe, toolStartedAfterUnsafe, events: runtime.eventCollector.snapshot() @@ -201,6 +369,33 @@ describe('ai tool hand backend', () => { } ) + runtime.assertThat( + result.safeLimitState.execution.lastToolResults?.length === 6 && + result.safeLimitState.execution.lastToolResults.every((item) => item.code === 'ok') && + !result.safeLimitState.execution.watchHint, + { + name: 'six-safe-parallel-calls-are-allowed', + message: '6 个只读且 parallelSafe 的工具调用应真正执行并写回结果。', + data: result.safeLimitState.execution.lastToolResults + } + ) + + runtime.assertThat( + result.limitExceededState.execution.watchHint?.code === 'hand_plan_invalid' && + result.limitExceededState.execution.watchHint.detail.reason === 'parallel_limit_exceeded' && + result.limitExceededState.execution.watchHint.detail.limit === 6 && + result.toolStartedAfterLimitExceeded === result.toolStartedBeforeLimitExceeded, + { + name: 'seven-parallel-calls-stop-before-execution', + message: '超过 6 个并行工具调用时应直接返回 brain 并写入 parallel_limit_exceeded。', + data: { + watchHint: result.limitExceededState.execution.watchHint, + toolStartedBeforeLimitExceeded: result.toolStartedBeforeLimitExceeded, + toolStartedAfterLimitExceeded: result.toolStartedAfterLimitExceeded + } + } + ) + runtime.assertThat( result.unsafeState.execution.watchHint?.code === 'hand_plan_invalid' && result.unsafeState.execution.watchHint.detail.reason === 'parallel_requires_read_only' && diff --git a/tests/harness/backend/aiLiveTestData.ts b/tests/harness/backend/aiLiveTestData.ts index 077bde75..47a10a79 100644 --- a/tests/harness/backend/aiLiveTestData.ts +++ b/tests/harness/backend/aiLiveTestData.ts @@ -1,5 +1,6 @@ import fs from 'node:fs/promises' import path from 'node:path' +import type { SelectedReference } from '../../../src/shared/types' import { writeTestDocxFile } from './aiToolHarness' import type { BackendTestRuntime, SeedKbDocumentInput } from './test-types' @@ -9,6 +10,23 @@ export const HUMAN_FILE_RETENTION_USER_INPUT = export const HUMAN_RISK_REPORT_DRAFT_USER_INPUT = '我下午要把本周风险周报发出去。工作区里应该有一份上周用过的 Word 周报模板,但我忘了文件名。你先帮我找到那份模板,再按我刚才选的资料,把本周风险结论和负责人补进去,给我一个可预览的版本,先别覆盖原文件。最后告诉我你改了哪几处,依据分别来自哪里。' +export const DOCX_CROSS_FILE_FIXTURE_PATHS = [ + 'tests/fixtures/ai/docx/深度学习.docx', + 'tests/fixtures/ai/docx/图形学.docx' +] as const + +export const HUMAN_DOCX_CAUSE_REMEDY_READING_USER_INPUT = + '我做题老把这两个点混在一起。你就按我刚选的这两份资料,帮我说清楚:过拟合和反走样各自是什么、为什么会出现、一般怎么处理。别补充课外知识,哪份文档没写你就直接说没写。' + +export const HUMAN_DOCX_CONTEXTUAL_COMPARISON_DRAFT_USER_INPUT = + '《图形学》里“反走样”这一节我还是记不住。你参考我刚选的这两份 Word,直接在这一节最后补一小段更好理解的说明,再加个简单对照表,一列写反走样,一列写《深度学习》里的过拟合。直接给我可预览的版本就行,别改原文件,也不用先回我文字草稿。' + +export const HUMAN_KB_GUIDED_DOCX_COMPARISON_DRAFT_USER_INPUT = + '我工作区资料有点多,我忘了是哪份课件在讲反走样了。你先帮我找到那份,再参考讲过拟合的那份资料,直接在“反走样”那一节最后补一段更好记的说明,再加个简单对照表,一列写反走样,一列写过拟合。直接给我可预览的版本就行,别改原文件,也不用先回我文字草稿。' + +export const HUMAN_KB_GUIDED_DOCX_READING_USER_INPUT = + '我工作区里应该有两份课件分别讲过过拟合和反走样,但我忘了文件名。你先帮我找出来,再告诉我这两个问题一般怎么处理。' + export const FILE_RETENTION_DOCUMENTS: readonly SeedKbDocumentInput[] = [ { id: 'doc-retention', @@ -51,6 +69,22 @@ export type RiskReportWorkspaceSeed = { originalTemplateBytes: Uint8Array } +export type SelectedDocxFixture = { + sourcePath: string + workspacePath: string + relativePath: string + name: string +} + +export type KbGuidedDocxWorkspaceSeed = { + deepLearningPath: string + deepLearningRelativePath: string + graphicsPath: string + graphicsRelativePath: string + originalDeepLearningBytes: Uint8Array + originalGraphicsBytes: Uint8Array +} + export const seedFileRetentionKb = async (runtime: BackendTestRuntime): Promise => { await runtime.seedKb(FILE_RETENTION_DOCUMENTS) return FILE_RETENTION_DOCUMENTS.map((document) => document.id) @@ -61,6 +95,236 @@ export const seedRiskReportKb = async (runtime: BackendTestRuntime): Promise document.id) } +export const resolveSelectedDocxFixtures = ( + runtime: BackendTestRuntime, + fixturePaths: readonly string[] = DOCX_CROSS_FILE_FIXTURE_PATHS +): SelectedDocxFixture[] => + fixturePaths.map((fixturePath) => { + const workspacePath = runtime.getFixtureWorkspacePath(fixturePath) + return { + sourcePath: fixturePath, + workspacePath, + relativePath: path.relative(runtime.workspaceRoot, workspacePath), + name: path.basename(workspacePath) + } + }) + +export const buildSelectedDocxFixtureReferences = ( + runtime: BackendTestRuntime, + fixturePaths: readonly string[] = DOCX_CROSS_FILE_FIXTURE_PATHS +): SelectedReference[] => + resolveSelectedDocxFixtures(runtime, fixturePaths).map((fixture, index) => ({ + kind: 'file', + workspaceId: 'test-workspace', + nodeId: `fixture-docx-${String(index + 1)}`, + name: fixture.name, + relativePath: fixture.relativePath, + absolutePath: fixture.workspacePath, + fileExt: '.docx', + kbDocumentIds: [], + kbAvailability: 'not_indexed' + })) + +const copyDocxFixtureToWorkspaceRoot = async (input: { + runtime: BackendTestRuntime + sourcePath: string + targetName: string + summary: string +}): Promise => { + const absoluteSourcePath = path.resolve(input.sourcePath) + const targetPath = path.join(input.runtime.workspaceRoot, input.targetName) + await fs.cp(absoluteSourcePath, targetPath) + input.runtime.recordSeedWorkspaceFile({ + relativePath: input.targetName, + absolutePath: targetPath, + summary: input.summary, + metadata: { + format: 'docx' + } + }) + return targetPath +} + +export const seedKbGuidedDocxWorkspace = async ( + runtime: BackendTestRuntime +): Promise => { + const deepLearningRelativePath = '深度学习.docx' + const graphicsRelativePath = '图形学.docx' + const deepLearningPath = await copyDocxFixtureToWorkspaceRoot({ + runtime, + sourcePath: DOCX_CROSS_FILE_FIXTURE_PATHS[0], + targetName: deepLearningRelativePath, + summary: '目标课件:深度学习' + }) + const graphicsPath = await copyDocxFixtureToWorkspaceRoot({ + runtime, + sourcePath: DOCX_CROSS_FILE_FIXTURE_PATHS[1], + targetName: graphicsRelativePath, + summary: '目标课件:图形学' + }) + + const scheduleRelativePath = '课程安排.docx' + const schedulePath = await writeTestDocxFile(runtime.workspaceRoot, scheduleRelativePath, [ + { + type: 'heading', + text: '课程安排', + level: 1 + }, + { + type: 'paragraph', + text: '这里记录的是考试时间、教室和分组安排。' + } + ]) + runtime.recordSeedWorkspaceFile({ + relativePath: scheduleRelativePath, + absolutePath: schedulePath, + summary: '干扰项:课程安排', + metadata: { + format: 'docx' + } + }) + + const labNoteRelativePath = '机器学习实验记录.docx' + const labNotePath = await writeTestDocxFile(runtime.workspaceRoot, labNoteRelativePath, [ + { + type: 'heading', + text: '机器学习实验记录', + level: 1 + }, + { + type: 'paragraph', + text: '本周实验主要关注学习率和 batch size 的调参过程。' + } + ]) + runtime.recordSeedWorkspaceFile({ + relativePath: labNoteRelativePath, + absolutePath: labNotePath, + summary: '干扰项:机器学习实验记录', + metadata: { + format: 'docx' + } + }) + + const readingListRelativePath = '参考书目.md' + const readingListPath = path.join(runtime.workspaceRoot, readingListRelativePath) + const readingListText = '- 线性代数导引\n- 计算机图形学基础\n' + await fs.writeFile(readingListPath, readingListText, 'utf8') + runtime.recordSeedWorkspaceFile({ + relativePath: readingListRelativePath, + absolutePath: readingListPath, + summary: '干扰项:参考书目', + metadata: { + format: 'text', + text: readingListText + } + }) + + const progressRelativePath = '复习进度.txt' + const progressPath = path.join(runtime.workspaceRoot, progressRelativePath) + const progressText = '今天先看图形学,再看深度学习。\n' + await fs.writeFile(progressPath, progressText, 'utf8') + runtime.recordSeedWorkspaceFile({ + relativePath: progressRelativePath, + absolutePath: progressPath, + summary: '干扰项:复习进度', + metadata: { + format: 'text', + text: progressText + } + }) + + return { + deepLearningPath, + deepLearningRelativePath, + graphicsPath, + graphicsRelativePath, + originalDeepLearningBytes: await fs.readFile(deepLearningPath), + originalGraphicsBytes: await fs.readFile(graphicsPath) + } +} + +export const seedKbGuidedDocxKb = async ( + runtime: BackendTestRuntime, + workspaceSeed: KbGuidedDocxWorkspaceSeed +): Promise => { + const kbDocuments: readonly SeedKbDocumentInput[] = [ + { + id: 'doc-deep-learning', + name: '深度学习.docx', + filePath: workspaceSeed.deepLearningPath, + chunks: [ + { + id: 'chunk-deep-learning-1', + text: '过度拟合是模型记住了过多训练数据中的特征值,使训练后的模型只对这部分训练数据有效,不太具有展望性。' + }, + { + id: 'chunk-deep-learning-2', + text: '当训练时间过久时,会逐渐记住过多训练数据中的特征值,从而出现过度拟合。' + }, + { + id: 'chunk-deep-learning-3', + text: '缓解过度拟合的思路包括:使用训练时未使用过的数据做验证,以及通过数据增强把同一图像拆成不同亮度、旋转方向等多张图像来训练。' + } + ] + }, + { + id: 'doc-graphics', + name: '图形学.docx', + filePath: workspaceSeed.graphicsPath, + chunks: [ + { + id: 'chunk-graphics-1', + text: '反走样出现的原因是采样频率低于信号频率。' + }, + { + id: 'chunk-graphics-2', + text: '由于一个像素可能同时落在图形边界内和边界外,边缘会出现锯齿。' + }, + { + id: 'chunk-graphics-3', + text: '反走样的处理思路包括:在一个像素内模拟多个采样点,或者先把图形转换成信号后做低通滤波。' + } + ] + }, + { + id: 'doc-course-schedule', + name: '课程安排.docx', + filePath: path.join(runtime.workspaceRoot, '课程安排.docx'), + chunks: [ + { + id: 'chunk-course-schedule-1', + text: '课程安排只记录考试时间、教室和分组信息。' + } + ] + }, + { + id: 'doc-ml-lab', + name: '机器学习实验记录.docx', + filePath: path.join(runtime.workspaceRoot, '机器学习实验记录.docx'), + chunks: [ + { + id: 'chunk-ml-lab-1', + text: '实验记录主要关注学习率和 batch size 的调参过程。' + } + ] + }, + { + id: 'doc-reading-list', + name: '参考书目.md', + filePath: path.join(runtime.workspaceRoot, '参考书目.md'), + chunks: [ + { + id: 'chunk-reading-list-1', + text: '参考书目里只有课程参考资料列表,没有过拟合或反走样的讲解。' + } + ] + } + ] + + await runtime.seedKb(kbDocuments) + return kbDocuments.map((document) => document.id) +} + export const seedRiskReportWorkspace = async ( runtime: BackendTestRuntime ): Promise => { diff --git a/tests/temp/ai/human/live/human-kb-guided-docx-reading-live.test.ts b/tests/temp/ai/human/live/human-kb-guided-docx-reading-live.test.ts new file mode 100644 index 00000000..5608e7a3 --- /dev/null +++ b/tests/temp/ai/human/live/human-kb-guided-docx-reading-live.test.ts @@ -0,0 +1,94 @@ +import { describe, it } from 'vitest' +import { loadLiveLlmConfigFromEnv, hasLiveLlmConfigInEnv } from '../../../../harness/backend/llmConfig' +import { createHumanTestRunner } from '../../../../harness/backend/humanHarness' +import { runBackendCase } from '../../../../harness/backend/run-backend-case' +import { + HUMAN_KB_GUIDED_DOCX_READING_USER_INPUT, + seedKbGuidedDocxKb, + seedKbGuidedDocxWorkspace +} from '../../../../harness/backend/aiLiveTestData' +import { + collectStartedToolStringArgs, + filterAiRunEventsByType, + hasKbToolCall, + runWithStructuredOutputRetry +} from '../../../../ai/human/testUtils' + +describe('temp ai human live backend', () => { + it('human-kb-guided-docx-reading-live', async () => { + await runWithStructuredOutputRetry(async () => { + await runBackendCase({ + caseId: 'human-kb-guided-docx-reading-live', + harnessMode: 'live', + domain: 'temp', + suite: 'ai', + groups: ['human', 'live'], + description: '观察 live 模式下 human 在工作区资料较多时,会如何用 KB 缩圈并回答过拟合与反走样的处理思路。', + fixtures: [], + runMode: 'single', + runtimeModelName: hasLiveLlmConfigInEnv('primary') + ? loadLiveLlmConfigFromEnv('primary').model_name + : null, + execute: async (runtime) => { + if (!hasLiveLlmConfigInEnv('primary')) { + return { + skipped: true, + output: null, + events: [] + } + } + + await runtime.seedLlmConfig(loadLiveLlmConfigFromEnv('primary')) + const workspaceSeed = await seedKbGuidedDocxWorkspace(runtime) + await seedKbGuidedDocxKb(runtime, workspaceSeed) + + const runner = createHumanTestRunner(runtime) + const output = await runner.run({ + taskId: runtime.taskId, + userInput: HUMAN_KB_GUIDED_DOCX_READING_USER_INPUT + }) + + return { + skipped: false, + output, + events: runtime.eventCollector.snapshot() + } + }, + assert: async (result, runtime) => { + if (result.skipped) { + runtime.assertThat(true, { + name: 'live-config-not-provided', + message: '未提供 live 模型配置,已跳过 temp human live KB-guided docx reading case。' + }) + return + } + + const toolResults = filterAiRunEventsByType(result.events, 'tool_call_result') + const deliveryText = `${result.output?.summary || ''}\n${result.output?.deliveryMarkdown || ''}` + const openedSourcePaths = collectStartedToolStringArgs( + result.events, + 'docx_open', + 'sourcePath' + ) + + runtime.assertThat( + result.output?.status === 'completed' && + hasKbToolCall(result.events) && + /(数据增强|未使用过的数据|验证数据)/.test(deliveryText) && + /(低通滤波|多个采样点|多个像素点|多次采样)/.test(deliveryText), + { + name: 'temp-human-live-kb-guided-reading-covers-kb-and-answer-facts', + message: 'temp KB-guided 读 case 应至少触发 KB,并回答过拟合与反走样的处理思路。', + data: { + openedSourcePaths, + toolNames: toolResults.map((event) => event.toolName), + summary: result.output?.summary, + deliveryMarkdown: result.output?.deliveryMarkdown + } + } + ) + } + }) + }) + }, 600_000) +}) diff --git a/tests/unit/ai/human-history-renderers.test.ts b/tests/unit/ai/human-history-renderers.test.ts new file mode 100644 index 00000000..1a015622 --- /dev/null +++ b/tests/unit/ai/human-history-renderers.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it } from 'vitest' +import type { AiRunContext } from '../../../src/main/services/ai/types' +import { + appendRoundRecord, + buildHandExecution, + buildLearningExecution +} from '../../../src/main/services/ai/human/memory/roundHistory' +import { buildBrainPrompt } from '../../../src/main/services/ai/human/prompts/brain' +import { buildMouthPrompt } from '../../../src/main/services/ai/human/prompts/mouth' +import { + renderRoundSection, + renderUnifiedHistoryText +} from '../../../src/main/services/ai/human/shared/historyRenderers' +import { buildReferenceScope } from '../../../src/main/services/ai/prompts/referenceScope' +import { createInitialHumanState } from '../../../src/main/services/ai/human/state/schema' +import type { HumanDecision } from '../../../src/main/services/ai/human/types' + +const createStubAiRunContext = (referenceScope: AiRunContext['referenceScope']): AiRunContext => ({ + runId: 'run-history-render', + referenceScope, + checkCancelled: () => {}, + chatStructured: async () => { + throw new Error('chatStructured should not be called in prompt unit tests') + }, + chatText: async () => { + throw new Error('chatText should not be called in prompt unit tests') + }, + sendEvent: () => {}, + requestApproval: async () => ({ + approved: false + }), + loadHistory: async () => ({ + recentTurnsText: '', + sessionSummaryText: '' + }), + trackDocxOpen: () => {}, + trackDocxChanges: () => {}, + resolveDocxOpenTarget: async () => { + throw new Error('resolveDocxOpenTarget should not be called in prompt unit tests') + }, + documentsStore: undefined as never, + embeddingService: undefined as never +}) + +const buildDecision = (input: { + summary: string + reason: string + expectedOutcome: string + nextAction: HumanDecision['nextAction'] +}): HumanDecision => ({ + decision: { + summary: input.summary, + reason: input.reason, + expectedOutcome: input.expectedOutcome + }, + readyToDeliver: false, + nextAction: input.nextAction +}) + +describe('ai unit human history renderers', () => { + it('renders shared history facts for both brain and mouth prompts', () => { + const initialState = createInitialHumanState({ + taskId: 'task-history-shared', + userInput: '请总结这次任务做了什么' + }) + + const learningState = { + ...initialState, + skills: { + learnedSkillIds: ['workspace-cli'] + }, + memory: { + ...initialState.memory, + history: appendRoundRecord({ + state: initialState, + decision: buildDecision({ + summary: '补齐 workspace 能力', + reason: '需要 terminal 工具', + expectedOutcome: '拿到 workspace 读取能力', + nextAction: { + action: 'learning', + wantLearnSkillIds: ['workspace-cli'] + } + }), + execution: buildLearningExecution({ + learningOutcome: { + requestedSkillIds: ['workspace-cli'], + loadedSkillIds: ['workspace-cli'], + skippedDuplicateSkillIds: [], + skippedUnknownSkillIds: [], + evictedSkillIds: [], + activeToolCount: 1 + } + }), + createdAt: 1 + }) + } + } + + const handState = { + ...learningState, + memory: { + ...learningState.memory, + history: appendRoundRecord({ + state: learningState, + decision: buildDecision({ + summary: '读取资料并生成预览', + reason: '已经具备需要的能力', + expectedOutcome: '拿到 docx 预览产物', + nextAction: { + action: 'hand', + toolUsePlan: { + mode: 'serial', + calls: [ + { + toolName: 'kb_hybrid_search_chunks', + args: { + queryText: 'approval denied' + } + }, + { + toolName: 'docx_render_preview', + args: { + format: 'html' + } + } + ] + } + } + }), + execution: buildHandExecution({ + toolUsePlan: { + mode: 'serial', + calls: [ + { + toolName: 'kb_hybrid_search_chunks', + args: { + queryText: 'approval denied' + } + }, + { + toolName: 'docx_render_preview', + args: { + format: 'html' + } + } + ] + }, + toolResults: [ + { + toolName: 'kb_hybrid_search_chunks', + ok: true, + code: 'ok', + data: { + items: [ + { + document_name: 'Weekly Risk Summary.md', + text: '审批未通过时原文件保留' + } + ] + } + }, + { + toolName: 'docx_render_preview', + ok: true, + code: 'ok', + data: { + previewPath: '/tmp/preview.docx', + summary: 'preview generated (html)' + } + } + ] + }), + createdAt: 2 + }) + } + } + + const state = { + ...handState, + memory: { + ...handState.memory, + history: { + ...handState.memory.history, + loadedHistoryContext: { + filters: { + executionKinds: ['learning'], + limit: 1 + }, + rounds: [handState.memory.history.roundHistory[0]] + } + } + } + } + + const focusHistory = renderRoundSection({ + records: [state.memory.history.roundHistory[0]], + detail: 'focus' + }) + const unifiedHistory = renderUnifiedHistoryText({ + roundHistory: state.memory.history.roundHistory, + loadedHistoryContext: state.memory.history.loadedHistoryContext + }) + + const brainPrompt = buildBrainPrompt({ + state, + ctx: createStubAiRunContext( + buildReferenceScope({ + workspace: { + workspaceId: 'workspace-1', + workspaceName: 'workspace', + rootPath: '/tmp/workspace-root' + } + }) + ) + }) + const mouthPrompt = buildMouthPrompt({ state }) + + expect(focusHistory).toContain('决策了什么:') + expect(focusHistory).toContain('做了什么:') + expect(focusHistory).toContain('结果是什么:') + expect(unifiedHistory).toContain('过程历史:') + expect(unifiedHistory).toContain('主动回看历史:') + expect(unifiedHistory).toContain('Weekly Risk Summary.md') + expect(unifiedHistory).toContain('preview generated (html)') + expect(brainPrompt.user).toContain(focusHistory) + expect(mouthPrompt.user).toContain(unifiedHistory) + }) +}) diff --git a/tests/unit/ai/mouth-ready-to-deliver.test.ts b/tests/unit/ai/mouth-ready-to-deliver.test.ts index 901bc521..82210e39 100644 --- a/tests/unit/ai/mouth-ready-to-deliver.test.ts +++ b/tests/unit/ai/mouth-ready-to-deliver.test.ts @@ -71,16 +71,16 @@ describe('ai unit mouth readyToDeliver guard', () => { }) }).system - expect(prompt).toContain('') - expect(prompt).toContain('') + expect(prompt).toContain('') expect(prompt).toContain('') - expect(prompt).toContain('final_decision 是 brain 已经确认的最终判断') - expect(prompt).toContain('runtime_artifacts 只允许准确引用已经给出的路径') - expect(prompt).toContain('你不会调用工具,也不会继续推进未完成步骤') - expect(prompt).toContain('不要把未来可能执行的工具动作写成“后续会自动完成”') - expect(prompt).toContain('你必须只输出最终 markdown 正文') - expect(prompt).not.toContain('"deliveryMarkdown"') - expect(prompt).not.toContain('"summary"') + expect(prompt).toContain('直接回答 task_goal') + expect(prompt).toContain('your_notes 优先') + expect(prompt).toContain('history:任务过程中真实发生过的决策、执行和结果') + expect(prompt).toContain('只输出最终 markdown 正文') + expect(prompt).toContain('标题、分节、列表都按需要使用,不固定') + expect(prompt).toContain('不原样照抄内部标签、block id、tool 名') + expect(prompt).not.toContain('# 处理结果') + expect(prompt).not.toContain('## 关键依据') }) it('keeps mouth user-context blocks data-only and moves explanations into system', () => { @@ -91,14 +91,21 @@ describe('ai unit mouth readyToDeliver guard', () => { }) }) - expect(result.system).toContain('final_decision:brain 已经确认的最终判断') - expect(result.system).toContain('默认使用最终用户答复的组织方式') + expect(result.system).toContain('task_goal:用户最初要你完成什么') + expect(result.system).toContain('已确认:当前稳定成立、可以直接告诉用户的事实') + expect(result.system).toContain('关键依据:支撑这些事实的证据') + expect(result.system).toContain('未决问题:仍会影响结论') expect(result.user).not.toContain('这是当前交付模式。') expect(result.user).not.toContain('这是用户这次原始诉求与限制。') expect(result.user).not.toContain('这是 brain 已经做出的最终决定。') expect(result.user).not.toContain('这是任务的稳定笔记') expect(result.user).not.toContain('这是最近一轮最相关的过程记录。') + expect(result.user).not.toContain('这是更早几轮的过程链。') expect(result.user).not.toContain('这是运行中已经产生的真实产物列表。') - expect(result.user).not.toContain('这是当前额外的收敛或纠偏提醒。') + expect(result.user).toContain('') + expect(result.user).not.toContain('') + expect(result.user).not.toContain('') + expect(result.user).not.toContain('') + expect(result.user).not.toContain('') }) })