From 4b9cf7c5cce7fe9b8f08f09db1ad1b5f959dd75e Mon Sep 17 00:00:00 2001 From: zhuyongxin Date: Mon, 20 Jul 2026 14:11:34 +0800 Subject: [PATCH] refactor(trace): enforce run-only diagnosis model --- mvp/README.md | 8 +- mvp/architecture/README.md | 28 +- mvp/architecture/agent-orchestration.md | 18 +- mvp/architecture/current-mvp-architecture.md | 37 ++- mvp/architecture/data-model.md | 31 ++- mvp/architecture/evolution-roadmap.md | 18 +- .../executor-evidence-pipeline-refactor.md | 2 +- mvp/architecture/feedback-architecture.md | 18 +- mvp/architecture/harness-quality-gates.md | 4 +- mvp/architecture/interview-one-pager.md | 21 +- mvp/architecture/retrieval-observability.md | 2 +- mvp/architecture/session-trace-lifecycle.md | 11 +- .../stategraph-runtime-architecture.md | 199 ++++++++++++++ mvp/archive/2026-07-20-doc-cleanup/README.md | 19 ++ .../database-design-backup-20240622.md | 2 + ...executor-self-evidence-loop-design-note.md | 2 + .../executor-structured-output-v2.md | 2 + .../tables/诊断会话表-diagnosis_session.md | 6 +- .../fixtures/audit-metadata-low-confid.json | 2 +- ...poser-fallback-no-raw-json-low-confid.json | 2 +- ...tekeeper-fabricated-invocation-reject.json | 2 +- ...no-evidence-negative-observation-pass.json | 2 +- .../fixtures/jvm-memory-risk-low-confid.json | 2 +- mvp/eval/fixtures/mysql-pool-low-confid.json | 2 +- .../narrow-highcpu-observation-pass.json | 2 +- mvp/eval/fixtures/payment-timeout-pass.json | 2 +- .../prompt-gatekeeper-audit-closure-pass.json | 2 +- .../fixtures/redis-timeout-low-confid.json | 2 +- mvp/eval/fixtures/slow-response-pass.json | 2 +- ...nsupported-claim-filtering-low-confid.json | 2 +- mvp/issues/README.md | 12 +- .../ISS-010-session-run-trace-isolation.md | 2 +- mvp/tables/Agent步骤表-agent_step.md | 6 +- mvp/tables/README.md | 7 +- .../旧诊断记录表-diagnosis_record.md | 2 +- mvp/tables/工具调用表-tool_invocation.md | 6 +- mvp/tables/案例库表-case_library.md | 8 +- mvp/tables/诊断运行表-diagnosis_run.md | 10 +- .../specs/mvp-demo-trace-acceptance/spec.md | 18 +- .../specs/session-run-trace-isolation/spec.md | 56 ++-- .../controller/DiagnosisTraceController.java | 2 +- .../agent/domain/entity/DiagnosisSession.java | 83 ------ .../agent/dto/DiagnosisTraceResponse.java | 23 -- .../superbiz/agent/dto/FeedbackResponse.java | 1 - .../agent/eval/DiagnosisTraceEvaluator.java | 8 +- .../DiagnosisSessionRepository.java | 12 - .../superbiz/agent/service/AiOpsService.java | 28 -- .../agent/service/CaseLibraryService.java | 6 - .../agent/service/DiagnosisTraceService.java | 105 +------ .../agent/service/EvaluationService.java | 21 -- .../service/ExecutorGatekeeperService.java | 15 - .../agent/service/FeedbackService.java | 55 +--- .../service/SelfEvaluationMergeService.java | 17 +- src/main/resources/static/app.js | 7 +- src/main/resources/static/trace.js | 35 ++- .../controller/FeedbackControllerTest.java | 1 - .../eval/DiagnosisTraceEvaluatorTest.java | 16 +- .../DiagnosisSessionRepositoryTest.java | 69 ----- .../agent/service/AiOpsServiceTest.java | 27 +- .../agent/service/CaseLibraryServiceTest.java | 19 -- .../service/DiagnosisTraceServiceTest.java | 258 ++++++------------ .../ExecutorGatekeeperServiceTest.java | 56 ++-- .../agent/service/FeedbackServiceTest.java | 49 +--- 63 files changed, 583 insertions(+), 909 deletions(-) create mode 100644 mvp/architecture/stategraph-runtime-architecture.md create mode 100644 mvp/archive/2026-07-20-doc-cleanup/README.md rename mvp/{.backup => archive/2026-07-20-doc-cleanup/database}/database-design-backup-20240622.md (99%) rename mvp/{ => archive/2026-07-20-doc-cleanup}/issues/design-notes/executor-self-evidence-loop-design-note.md (98%) rename mvp/{ => archive/2026-07-20-doc-cleanup}/issues/design-notes/executor-structured-output-v2.md (99%) rename mvp/{ => archive/2026-07-20-doc-cleanup}/tables/诊断会话表-diagnosis_session.md (84%) delete mode 100644 src/main/java/com/superbiz/agent/domain/entity/DiagnosisSession.java delete mode 100644 src/main/java/com/superbiz/agent/repository/DiagnosisSessionRepository.java delete mode 100644 src/test/java/com/superbiz/agent/repository/DiagnosisSessionRepositoryTest.java diff --git a/mvp/README.md b/mvp/README.md index 704c686..e8552b6 100644 --- a/mvp/README.md +++ b/mvp/README.md @@ -1,6 +1,6 @@ # SuperBizAgent MVP 文档 -**更新日期**:2026-07-10 +**更新日期**:2026-07-20 本目录保存 MVP 阶段的架构、问题、演示、评测和数据表说明。当前材料按“当前入口”和“历史归档”拆开,避免把早期设计稿当成当前实现。 @@ -10,6 +10,7 @@ |---|---| | [architecture/README.md](architecture/README.md) | 当前 MVP 架构入口 | | [architecture/current-mvp-architecture.md](architecture/current-mvp-architecture.md) | 当前可运行系统架构 | +| [architecture/stategraph-runtime-architecture.md](architecture/stategraph-runtime-architecture.md) | 复杂 Chat StateGraph 运行时架构 | | [architecture/interview-one-pager.md](architecture/interview-one-pager.md) | 面试一页式架构讲解 | | [architecture/agent-orchestration.md](architecture/agent-orchestration.md) | Agent 编排架构 | | [architecture/executor-evidence-pipeline-refactor.md](architecture/executor-evidence-pipeline-refactor.md) | Executor 证据链路改造记录 | @@ -39,6 +40,7 @@ mvp/ architecture/ README.md current-mvp-architecture.md + stategraph-runtime-architecture.md interview-one-pager.md agent-orchestration.md executor-evidence-pipeline-refactor.md @@ -55,7 +57,6 @@ mvp/ README.md active/ archived/ - design-notes/ rag/ tables/ README.md @@ -118,9 +119,10 @@ RAG ## 归档说明 -历史材料分两类: +历史材料分三类: - 旧架构文档:[architecture/archive/2026-07-05-legacy/](architecture/archive/2026-07-05-legacy/) - 本次文档清理归档:[archive/2026-07-09-doc-cleanup/](archive/2026-07-09-doc-cleanup/) +- Run-only v2 和 StateGraph 收口后的旧材料:[archive/2026-07-20-doc-cleanup/](archive/2026-07-20-doc-cleanup/) 归档文档只用于追溯设计历史。当前实现和后续规划以 `architecture/`、`issues/README.md`、`tables/README.md` 和 OpenSpec/devflow 的最新记录为准。 diff --git a/mvp/architecture/README.md b/mvp/architecture/README.md index 0de1bd0..0e81b5d 100644 --- a/mvp/architecture/README.md +++ b/mvp/architecture/README.md @@ -1,6 +1,6 @@ # MVP 架构文档 -**更新日期**:2026-07-17 +**更新日期**:2026-07-20 这里是 MVP 当前架构的唯一入口。旧版设计、早期拆解和已经被新实现替代的方案已归档到: @@ -13,6 +13,7 @@ | 文档 | 用途 | |---|---| | [current-mvp-architecture.md](current-mvp-architecture.md) | 当前可运行 MVP 的总体架构、链路、持久化和质量门禁 | +| [stategraph-runtime-architecture.md](stategraph-runtime-architecture.md) | 最新复杂 Chat StateGraph 运行时权威快照,覆盖 Node 路由、状态边界、Run/Trace 持久化和验收层次 | | [interview-one-pager.md](interview-one-pager.md) | 面试一页式架构讲解,包含总图、亮点、取舍和追问回答 | | [agent-orchestration.md](agent-orchestration.md) | Agent 编排细节,覆盖 Chat bounded StateGraph、AIOps SupervisorAgent、工具边界 | | [executor-evidence-pipeline-refactor.md](executor-evidence-pipeline-refactor.md) | Chat 证据链路当前数据契约,覆盖 Executor V2、Gatekeeper、Verifier、Composer、`evidence_refs` | @@ -34,15 +35,16 @@ SuperBizAgent MVP 是一个面向故障诊断的可追踪 Agent 系统:复杂 ## 阅读顺序 1. 先读 [current-mvp-architecture.md](current-mvp-architecture.md),理解系统边界和主链路。 -2. 面试前读 [interview-one-pager.md](interview-one-pager.md),准备 2-5 分钟讲解。 -3. 再读 [agent-orchestration.md](agent-orchestration.md),理解当前 Agent 如何协作。 -4. 接着读 [executor-evidence-pipeline-refactor.md](executor-evidence-pipeline-refactor.md),理解 Chat 证据链路的数据结构和验真边界。 -5. 然后读 [harness-quality-gates.md](harness-quality-gates.md),理解为什么系统可追踪、可验证。 -6. 再读 [rag-architecture.md](rag-architecture.md),理解当前 RAG 为什么保留显式 `lookup_knowledge`,以及 Spring AI VectorStore 如何接入。 -7. 继续读 [modular-rag-pipeline.md](modular-rag-pipeline.md),看 `lookup_knowledge` 的模块化落地和 evidence-first contract。 -8. 再读 [rag-eval-closure.md](rag-eval-closure.md),看 RAG baseline 如何形成质量闭环。 -9. 然后读 [retrieval-observability.md](retrieval-observability.md),看检索细节和质量回归方式。 -10. 再读 [feedback-architecture.md](feedback-architecture.md),理解 self_evaluation、用户反馈和案例沉淀。 -11. 按需读 [session-trace-lifecycle.md](session-trace-lifecycle.md)、[knowledge-base-authoring.md](knowledge-base-authoring.md)、[data-model.md](data-model.md),补齐运行生命周期、知识库维护和数据关系。 -12. 最后读 [evolution-roadmap.md](evolution-roadmap.md),区分后续演进和当前实现。 -13. 需要追溯旧方案时,再进入 `archive/2026-07-05-legacy/`。 +2. 再读 [stategraph-runtime-architecture.md](stategraph-runtime-architecture.md),理解复杂 Chat 的真实运行时和路由边界。 +3. 面试前读 [interview-one-pager.md](interview-one-pager.md),准备 2-5 分钟讲解。 +4. 再读 [agent-orchestration.md](agent-orchestration.md),理解当前 Agent 如何协作。 +5. 接着读 [executor-evidence-pipeline-refactor.md](executor-evidence-pipeline-refactor.md),理解 Chat 证据链路的数据结构和验真边界。 +6. 然后读 [harness-quality-gates.md](harness-quality-gates.md),理解为什么系统可追踪、可验证。 +7. 再读 [rag-architecture.md](rag-architecture.md),理解当前 RAG 为什么保留显式 `lookup_knowledge`,以及 Spring AI VectorStore 如何接入。 +8. 继续读 [modular-rag-pipeline.md](modular-rag-pipeline.md),看 `lookup_knowledge` 的模块化落地和 evidence-first contract。 +9. 再读 [rag-eval-closure.md](rag-eval-closure.md),看 RAG baseline 如何形成质量闭环。 +10. 然后读 [retrieval-observability.md](retrieval-observability.md),看检索细节和质量回归方式。 +11. 再读 [feedback-architecture.md](feedback-architecture.md),理解 self_evaluation、用户反馈和案例沉淀。 +12. 按需读 [session-trace-lifecycle.md](session-trace-lifecycle.md)、[knowledge-base-authoring.md](knowledge-base-authoring.md)、[data-model.md](data-model.md),补齐运行生命周期、知识库维护和数据关系。 +13. 最后读 [evolution-roadmap.md](evolution-roadmap.md),区分后续演进和当前实现。 +14. 需要追溯旧方案时,再进入 `archive/2026-07-05-legacy/`。 diff --git a/mvp/architecture/agent-orchestration.md b/mvp/architecture/agent-orchestration.md index 95c5321..3c1b21b 100644 --- a/mvp/architecture/agent-orchestration.md +++ b/mvp/architecture/agent-orchestration.md @@ -1,6 +1,6 @@ # Agent 编排架构 -**更新日期**:2026-07-17 +**更新日期**:2026-07-20 **状态**:当前可运行架构 **参考历史文档**:`archive/2026-07-05-legacy/agent-architecture.md` @@ -74,7 +74,7 @@ flowchart TB ## 3. Chat 编排 -Chat 复杂诊断采用 `ChatDiagnosisGraphRuntime` 编译的 bounded StateGraph。它有一条正常路径和显式条件边,不再依赖固定顺序 Agent 或 Verifier Hook: +Chat 复杂诊断采用 `ChatDiagnosisGraphRuntime` 执行、`DiagnosisGraphFactory` 编译的 bounded StateGraph。它有一条正常路径和显式条件边,不再依赖固定顺序 Agent 或隐式前置校验: ```text START -> PLANNER -> EXECUTOR -> GATEKEEPER -> VERIFIED_INPUT -> VERIFIER -> COMPOSER -> END @@ -139,7 +139,17 @@ sequenceDiagram |---|---| | `PASS` | 把 Verifier 允许表达的 claims 交给 Composer 输出 | | `LOW_CONFID` | 如果分数低于阈值且仍有轮次,构造 `retry_context` 补证据;否则输出低置信提示 | -| `REJECT` | 输出降级答复,只保留已确认信息和下一步建议 | +| `REJECT` | Verifier 完成后仍进入 Composer,但 Composer 只能表达允许材料和诊断限制;Gatekeeper REJECT 才直接进入 Fallback | + +### 3.1 运行时边界 + +- 外层 Graph 使用 `runId` 作为 `RunnableConfig.threadId`,metadata 只承载 `sessionId/runId` 等业务身份。 +- `ReactAgentDiagnosisInvoker` 为 nested Agent 创建独立 config,不传播外层 human-feedback、state-update、checkpoint/resume 控制 metadata。 +- Graph State 默认 replace,只有 `orchestration_events` append;事件使用 portable Map,避免 DevTools classloader 的 record identity 问题。 +- Planner、Verifier、Composer 各最多一次技术重试;evidence retry 独立计数且最多一次;Graph recursion limit 为 32。 +- `DiagnosisGraphResultMapper` 负责质量评估,`DiagnosisOrchestrationTraceBuilder` 负责路由摘要,两者分别写入 `self_evaluation` 和 `orchestration_trace`。 + +完整运行时拓扑、路由表和失败语义见 [stategraph-runtime-architecture.md](stategraph-runtime-architecture.md)。 ## 4. AIOps 编排 @@ -212,7 +222,7 @@ flowchart LR | Planner | 只看 skill name / description,并输出 `selected_skill` | 不暴露 `read_skill` | | Executor | 读取 Planner 选中的 skill 正文 | 暴露官方 `read_skill` 和证据工具 | | Gatekeeper | 不看 skill catalog,也不读 skill 正文 | 只读取 Executor 输出和 `tool_invocation.retrieval_details.evidence_refs` | -| Verifier | 不看 skill catalog,也不读 skill 正文 | 只读取 Gatekeeper 结果、结构化 claims 和 trace summary | +| Verifier | 不看 skill catalog,也不读 skill 正文 | 只读取 Verified Input 投影和 Gatekeeper audit | | Composer | 不看 skill catalog,也不读 skill 正文 | 只读取 Verifier 允许表达的内容 | ## 7. 与旧版设计的差异 diff --git a/mvp/architecture/current-mvp-architecture.md b/mvp/architecture/current-mvp-architecture.md index cdb1c96..61ce6d8 100644 --- a/mvp/architecture/current-mvp-architecture.md +++ b/mvp/architecture/current-mvp-architecture.md @@ -1,6 +1,6 @@ # 当前 MVP 架构 -**更新日期**:2026-07-17 +**更新日期**:2026-07-20 **状态**:当前可运行架构 **适用范围**:Demo、面试讲解、后续迭代规划 @@ -96,8 +96,8 @@ flowchart TB RAG --> Store Tools --> Invocation Agent --> Step - App --> Session - TraceService --> Session + App --> ChatSession + TraceService --> ChatSession TraceService --> Step TraceService --> Invocation ``` @@ -167,7 +167,8 @@ sequenceDiagram participant Planner as Planner Agent participant Executor as Executor Agent participant Tool as Evidence Tools - participant Gatekeeper as Gatekeeper Hook + participant Gatekeeper as Gatekeeper Node + participant Projection as Verified Input Node participant Verifier as Verifier Agent participant Composer as Composer Agent participant DB as Trace Tables @@ -184,11 +185,12 @@ sequenceDiagram Tool-->>Executor: 返回证据 Executor->>Gatekeeper: 输出 executor_evidence_v2 Gatekeeper->>DB: 读取 tool_invocation.evidence_refs 并校验引用 - Gatekeeper->>Verifier: 传入已验真的 claims / excerpts + Gatekeeper->>Projection: 输出通过验真的 bindings + Projection->>Verifier: 只传入 verified claims / evidence Verifier->>DB: 合并 diagnosis_run.self_evaluation.verifier_evaluation Verifier->>Composer: 传入 allowed_claims / missing_info / actions Composer->>Chat: 生成最终用户答复 - Chat->>DB: 保存 diagnosis_run.answer + Chat->>DB: 保存 answer / self_evaluation / orchestration_trace User->>Trace: GET /api/diagnosis/{sessionId}/trace?runId=... Trace->>DB: 聚合 run / step / tool Trace-->>User: 返回可回放诊断链路 @@ -204,19 +206,21 @@ POST /api/chat -> lookup_knowledge -> query_logs -> query_metrics - -> Gatekeeper 校验 Executor 证据引用真实性 - -> Verifier 判断 claim 是否能由已核验证据推出 + -> Gatekeeper Node 校验 Executor 证据引用真实性 + -> Verified Input Node 只投影通过验真的 claims/evidence + -> Verifier 判断 claim 是否能由已验真证据推出 -> Composer 生成最终用户答复 -> 保存 chat_session metadata -> 保存 diagnosis_run -> 保存 agent_step.run_id -> 保存 tool_invocation.run_id -> 合并 diagnosis_run.self_evaluation.verifier_evaluation + -> 保存 diagnosis_run.orchestration_trace ``` -Chat 链路的质量门禁由三段组成:Gatekeeper 先做代码级引用验真,Verifier 再做 LLM 可推导性判断,Composer 最后控制对用户的表达边界。Gatekeeper、Verifier、Composer 的输出合并到当前 `diagnosis_run.self_evaluation.verifier_evaluation`,Trace API 会展示该验证结果。 +Chat 链路的质量门禁由四段组成:Gatekeeper 先做代码级引用验真,Verified Input 再隔离未通过的 binding,Verifier 做 LLM 可推导性判断,Composer 最后控制对用户的表达边界。结构化质量结果合并到 `diagnosis_run.self_evaluation.verifier_evaluation`;Node 路由、重试和降级摘要独立写入 `diagnosis_run.orchestration_trace`。 -Agent 编排细节见 [agent-orchestration.md](agent-orchestration.md)。 +最新运行时、路由和状态边界见 [stategraph-runtime-architecture.md](stategraph-runtime-architecture.md),Agent 职责见 [agent-orchestration.md](agent-orchestration.md)。 关键代码: @@ -343,6 +347,7 @@ diagnosis_run -> run_id / session_id -> query / status / agent_flow / answer -> self_evaluation + -> orchestration_trace -> step_count / tool_call_count / duration agent_step @@ -365,7 +370,7 @@ tool_invocation 说明: - 旧的 `diagnosis_record` 已不是当前主模型。 -- `diagnosis_session` 已降级为历史兼容和回滚表,新执行写入 `chat_session + diagnosis_run`。 +- 当前运行时只使用 `chat_session + diagnosis_run`,不再映射、读取或写入 `diagnosis_session`。 - `api_document` 仍用于文档元数据管理。 - 文档向量内容存放在 Milvus/Zilliz collection 中。 @@ -384,7 +389,7 @@ Trace API 聚合: - Agent step 序列。 - 工具调用和检索细节。 - Chat Gatekeeper / Verifier / Composer 结果。 -- Chat `run.orchestrationTrace` 路由摘要,独立于 self-evaluation 和步骤/工具明细。 +- Chat `run.orchestrationTrace` 路由摘要,解析自 `diagnosis_run.orchestration_trace`,独立于 self-evaluation 和步骤/工具明细。 - AIOps rule evaluation 结果。 Trace 是本项目区别于普通问答系统的关键:答案不是孤立文本,而是可以追溯到 Agent 决策、工具调用和证据来源。 @@ -412,7 +417,7 @@ Prompt、StateGraph、Gatekeeper、Verifier、Composer 和评测门禁的完整 已经完成: - Chat 和 AIOps 两条入口链路。 -- Chat 复杂诊断已单轨切换到 bounded StateGraph,并持久化 Run-owned `orchestration_trace`。 +- Chat 复杂诊断已单轨切换到 bounded StateGraph,并持久化 Run-owned `orchestration_trace`;Graph event 使用 portable Map,nested ReactAgent config 与外层 checkpoint/resume 控制信息隔离。 - 显式 `lookup_knowledge` Agent Tool。 - L0 从最终决策降级为 domain/entity hint。 - `VectorSearchService` 作为稳定检索门面。 @@ -437,14 +442,16 @@ Prompt、StateGraph、Gatekeeper、Verifier、Composer 和评测门禁的完整 - VectorStore 写入路径全面迁移。 - 完整 LLM-based AIOps verifier。 -后续 Agent 拆分、Skill/Playbook、MCP 工具协议化和进程隔离等方向见 [evolution-roadmap.md](evolution-roadmap.md)。 +后续 Agent 拆分、Playbook 版本化增强、MCP 工具协议化和进程隔离等方向见 [evolution-roadmap.md](evolution-roadmap.md)。 ## 10. 关键代码索引 | 能力 | 代码 | |---|---| | Chat 入口与编排 | `ChatController`, `ChatService` | -| Chat StateGraph | `ChatDiagnosisGraphRuntime`, `DiagnosisGraphFactory`, `DiagnosisRealGraphActionsFactory` | +| Chat StateGraph runtime | `ChatDiagnosisGraphRuntime`, `DiagnosisGraphFactory`, `DiagnosisGraphRouter`, `DiagnosisGraphState` | +| Chat Node assembly/config isolation | `DiagnosisRealGraphActionsFactory`, `ReactAgentDiagnosisInvoker` | +| Graph result/audit mapping | `DiagnosisGraphResultMapper`, `DiagnosisOrchestrationTraceBuilder` | | AIOps 入口与编排 | `ChatController.aiOps`, `AiOpsService` | | AIOps 规则验证 | `AiOpsRuleEvaluationService` | | 知识库工具 | `LookupKnowledgeTool` | diff --git a/mvp/architecture/data-model.md b/mvp/architecture/data-model.md index ef023fd..9a25cf7 100644 --- a/mvp/architecture/data-model.md +++ b/mvp/architecture/data-model.md @@ -1,6 +1,6 @@ # 数据模型总览 -**更新日期**:2026-07-10 +**更新日期**:2026-07-20 **状态**:当前可运行架构 ## 1. 定位 @@ -13,7 +13,7 @@ - 知识库:`api_document`、`knowledge_domain`、Milvus/Zilliz metadata - 反馈沉淀:`case_library` -`diagnosis_session` 仍保留为历史兼容和回滚表,不再是新执行写入的主模型。 +当前运行时只使用 `chat_session + diagnosis_run`;旧 `diagnosis_session` 表不属于当前版本契约。 ## 2. 总体关系 @@ -44,6 +44,7 @@ erDiagram varchar agent_flow longtext answer json self_evaluation + json orchestration_trace varchar feedback } @@ -111,6 +112,7 @@ erDiagram | `agent_flow` | `CHAT` / `AI_OPS` | | `answer` | 本次运行最终答复或告警报告 | | `self_evaluation` | 本次运行的 rule/verifier/aiops 自评估容器 | +| `orchestration_trace` | nullable JSON;复杂 Chat 的 StateGraph 路由摘要,非 StateGraph Run 可为空 | | `feedback` | 本次运行的用户反馈 | 同一个 `sessionId` 可以有多个 `runId`。Trace、反馈、评测和案例沉淀都应优先使用 `runId`,避免多轮同 session 下的数据混合。 @@ -139,7 +141,19 @@ erDiagram `$.no_evidence` 只表示“本次工具查询未检索到匹配证据”,不能被解释为“问题不存在”或“根因已排除”。 -## 5. 反馈沉淀模型 +## 5. Run 级审计分层 + +一次 Run 的可审计信息分为三层,不能互相替代: + +| 层次 | 存储 | 语义 | +|---|---|---| +| 执行明细 | `agent_step`、`tool_invocation` | 模型步骤、工具输入输出、检索细节和证据事实 | +| 质量评估 | `diagnosis_run.self_evaluation` | rule/verifier/aiops 判断、Gatekeeper 审计、Prompt 版本和允许输出材料 | +| 编排摘要 | `diagnosis_run.orchestration_trace` | StateGraph transitions、final node、termination reason、degraded、evidence retry count | + +`orchestration_trace` 只通过 exact Run 的 `run.orchestrationTrace` 暴露,Trace 响应不再包含兼容 `session` 投影。Run 状态仍只表达执行生命周期:安全 Fallback 是 `SUCCESS + degraded=true`,只有无法生成安全响应或未处理失败才是 `FAILED`。 + +## 6. 反馈沉淀模型 `useful` 反馈会触发 `CaseLibraryService.createFromRun`。 @@ -148,7 +162,7 @@ erDiagram | 字段 | 来源 | |---|---| | `case_id` | UUID | -| `diagnosis_id` | 新数据为 `diagnosis_run.run_id`;历史数据可能为 `diagnosis_session.session_id` | +| `diagnosis_id` | `diagnosis_run.run_id` | | `source_type` | `AUTO` | | `fault_category` | 当前默认 `GENERAL` | | `title` | run query 前 100 字符 | @@ -156,7 +170,7 @@ erDiagram | `solution` | run answer | | `created_by` | `system` | -## 6. self_evaluation 结构 +## 7. self_evaluation 结构 `diagnosis_run.self_evaluation` 是运行级 JSON 容器: @@ -170,16 +184,19 @@ erDiagram Chat 通常写入 `rule_evaluation` 和 `verifier_evaluation`;AIOps 写入 `aiops_rule_evaluation`。 -## 7. 当前边界和后续 +`orchestration_trace` 不放入该 JSON,避免把答案质量和 Graph 路由混成同一审计维度。 + +## 8. 当前边界和后续 当前边界: - `chat_session` 只存会话元数据,不存完整正文历史。 - `diagnosis_run` 存一次运行的长期审计状态。 - `agent_step.run_id` 和 `tool_invocation.run_id` 是 Trace、Verifier、Eval 的运行边界。 +- `diagnosis_run.orchestration_trace` 是 nullable Run-owned Graph 摘要;非 StateGraph Run 可以为空。 - 当前实现主要使用逻辑关联,不依赖数据库外键。 - `case_library.diagnosis_id` 是过渡字段,新值按 `run_id` 解释,旧值可能按 `session_id` 解释。 -- `diagnosis_session` 只作为历史兼容和回滚表保留。 +- 当前 Java 运行时不存在 `diagnosis_session` entity/repository 或 fallback。 后续可增强: diff --git a/mvp/architecture/evolution-roadmap.md b/mvp/architecture/evolution-roadmap.md index ff99790..7192a44 100644 --- a/mvp/architecture/evolution-roadmap.md +++ b/mvp/architecture/evolution-roadmap.md @@ -1,12 +1,12 @@ # Agent 架构演进路线 -**更新日期**:2026-07-05 +**更新日期**:2026-07-20 **状态**:后续演进设计,不代表当前已实现 **参考历史文档**:`archive/2026-07-05-legacy/agent-architecture.md` ## 1. 为什么需要演进路线 -旧版 `agent-architecture.md` 包含很多生产级设想:专科 SubAgent、Skill 体系、进程隔离、回退路由、MCP 工具协议化、进化引擎。它们不应作为当前 MVP 事实写入主架构,但可以作为后续扩展路线。 +旧版 `agent-architecture.md` 包含很多生产级设想:专科 SubAgent、完整 Skill 治理、进程隔离、跨 Agent 回退、MCP 工具协议化、进化引擎。当前已经落地 bounded StateGraph、安全 Fallback 和基础 Skill/Playbook 接入;本文件只描述它们之上的后续增强。 当前原则: @@ -18,12 +18,12 @@ ```mermaid flowchart TD - MVP["Current MVP: Planner + Executor + Verifier"] --> Split{"Executor 是否过载?"} + MVP["Current MVP: bounded StateGraph + evidence gates"] --> Split{"Executor 是否过载?"} Split -->|是| SubAgents["专科 SubAgent"] Split -->|否| Keep["继续强化通用 Executor"] - SubAgents --> Skills["Skill / Playbook 体系"] - Skills --> Fallback["回退路由"] + SubAgents --> Skills["Skill / Playbook 版本化治理"] + Skills --> Fallback["跨 SubAgent 回退路由"] Fallback --> Isolation["进程或 Pod 隔离"] MVP --> ToolGrowth{"工具数量和来源是否增长?"} @@ -59,9 +59,9 @@ flowchart TD - 过早拆分会增加 Prompt、评测和 trace 分析成本。 - 没有足够分类评测前,拆分可能只是移动复杂度。 -## 4. Skill / Playbook 体系 +## 4. Skill / Playbook 版本化治理 -旧版设计中的 Skill 可以在当前项目中演进为可版本化的诊断 Playbook。 +当前已经通过 Planner metadata selection + Executor `read_skill` 接入诊断 Playbook。下一阶段不是重新建设 Skill 入口,而是增加版本、评测、回退和审计治理。 ```text fault_category @@ -73,14 +73,14 @@ fault_category -> evaluation checks ``` -优先落地方向: +当前已覆盖的方向: - AIOps 告警处理 Playbook。 - 支付超时 Playbook。 - MySQL 连接池风险 Playbook。 - Redis timeout Playbook。 -落地前提: +后续增强前提: - 每个 Playbook 至少有 3-5 个 eval case。 - Playbook 失败时可以回退到通用 Executor。 diff --git a/mvp/architecture/executor-evidence-pipeline-refactor.md b/mvp/architecture/executor-evidence-pipeline-refactor.md index 3b7cb77..95216e4 100644 --- a/mvp/architecture/executor-evidence-pipeline-refactor.md +++ b/mvp/architecture/executor-evidence-pipeline-refactor.md @@ -423,7 +423,7 @@ Trace API 可用于回放: - Composer 最终如何表达给用户。 - `run.orchestrationTrace` 如何经过条件边、有限重试并终止。 -历史 Run/fixture 的 `verifier_evaluation.tool_trace_summary` 仍可被 Trace UI 或离线评测只读解析,但它是旧链路兼容字段,不是当前 Verifier 输入,也不再由生产链路生成。 +当前版本不生成、读取或展示 `verifier_evaluation.tool_trace_summary`;Verifier 只消费 verified projection。 --- diff --git a/mvp/architecture/feedback-architecture.md b/mvp/architecture/feedback-architecture.md index 48296eb..ee65c21 100644 --- a/mvp/architecture/feedback-architecture.md +++ b/mvp/architecture/feedback-architecture.md @@ -56,7 +56,7 @@ flowchart TD ## 3. self_evaluation JSON -`SelfEvaluationMergeService` 统一维护当前运行的 `diagnosis_run.self_evaluation`。历史兼容数据可能仍存在于 `diagnosis_session.self_evaluation`,但新 Chat/AIOps 执行不再写旧表。 +`SelfEvaluationMergeService` 只维护当前运行的 `diagnosis_run.self_evaluation`,不再解析或写入旧 `diagnosis_session` 数据。 当前结构: @@ -89,10 +89,7 @@ flowchart TD } ``` -兼容逻辑: - -- 如果旧 JSON 根节点包含 `evidence_score`,会被包进 `rule_evaluation`。 -- 如果旧 JSON 根节点包含 `verdict` / `groundedness_score`,会被包进 `verifier_evaluation`。 +输入必须使用当前分层 JSON:`rule_evaluation`、`verifier_evaluation`、`aiops_rule_evaluation`。旧扁平 JSON 不再自动包装。 ## 4. 规则评分 @@ -175,7 +172,7 @@ ChatService 根据 verdict 决定: - `executor_final_answer` 只作为 debug/fallback 上下文;结构化输出有效时,Verifier 不得从中抽取额外确认事实。 - `$.no_evidence` 只能表达“当前查询未检索到匹配证据”,不能表达“已排除/确认没有”。 -- `run.orchestrationTrace` 是独立的 StateGraph 路由摘要,不属于 `self_evaluation`;历史 `tool_trace_summary` 仅用于旧 Run/fixture 只读兼容,不是当前 Verifier 输入。 +- `run.orchestrationTrace` 是独立的 StateGraph 路由摘要,不属于 `self_evaluation`;当前版本不生成或读取 `tool_trace_summary`。 ## 6. AIOps 规则自评估 @@ -210,7 +207,6 @@ Content-Type: application/json "success": true, "message": "反馈已记录", "runId": "run-xxx", - "fallbackToLatestRun": false, "caseId": "uuid 或 null" } ``` @@ -223,11 +219,7 @@ Content-Type: application/json | `not_useful` | 写入 `DiagnosisRun.feedback`,不改变 run status | | 其他值 | 返回 HTTP 400 | -兼容行为: - -- 请求带 `runId` 时,后端验证 `runId` 属于 `sessionId`。 -- 请求缺少 `runId` 且存在 run-backed 数据时,后端绑定 latest run,并返回 `fallbackToLatestRun=true` 和实际 `runId`。 -- 仅当没有 `diagnosis_run` 但存在历史 `diagnosis_session` 时,才使用历史 fallback;该路径不声明 latest-run fallback。 +新版本要求请求必须携带 `sessionId + runId`。后端验证 `runId` 属于 `sessionId`;缺少 `runId`、Run 不存在或归属错误时直接拒绝,不绑定 latest run,也不回退历史表。 ## 8. 案例沉淀 @@ -238,7 +230,7 @@ Content-Type: application/json | CaseLibrary 字段 | 来源 | |---|---| | `caseId` | UUID | -| `diagnosisId` | 新数据为 `DiagnosisRun.runId`;历史数据可能为 `DiagnosisSession.sessionId` | +| `diagnosisId` | `DiagnosisRun.runId` | | `sourceType` | `AUTO` | | `faultCategory` | 当前固定为 `GENERAL` | | `title` | `query` 前 100 字符 | diff --git a/mvp/architecture/harness-quality-gates.md b/mvp/architecture/harness-quality-gates.md index 1e7c586..5dea72b 100644 --- a/mvp/architecture/harness-quality-gates.md +++ b/mvp/architecture/harness-quality-gates.md @@ -128,7 +128,7 @@ sequenceDiagram - token count。 - Verifier 的 JSON 输出摘要。 -新写入必须带 `run_id`;`session_id` 仍保留用于粗粒度排查和历史兼容。 +新写入必须带 `run_id`;`session_id` 只用于会话归属和粗粒度排查,不能替代 Run 边界。 ## 5. Tool Invocation 门禁 @@ -218,7 +218,7 @@ Verifier 不再逐字核验 excerpt 真伪;这由 Gatekeeper 完成。Verifier diagnosis_run.self_evaluation.verifier_evaluation ``` -其中持久化 verified `executor_structured_output`、`verified_evidence`、`gatekeeper_result`、`prompt_audit` 和 `composer_output`,用于 Trace 回放。Graph 路由另存 `diagnosis_run.orchestration_trace`;历史 `tool_trace_summary` 只作为旧 Run/fixture 的读取兼容字段,不属于当前 Verifier 输入。 +其中持久化 verified `executor_structured_output`、`verified_evidence`、`gatekeeper_result`、`prompt_audit` 和 `composer_output`,用于 Trace 回放。Graph 路由另存 `diagnosis_run.orchestration_trace`;当前版本不生成或读取 `tool_trace_summary`。 ## 7. AIOps 规则门禁 diff --git a/mvp/architecture/interview-one-pager.md b/mvp/architecture/interview-one-pager.md index 2536eeb..daa5029 100644 --- a/mvp/architecture/interview-one-pager.md +++ b/mvp/architecture/interview-one-pager.md @@ -1,11 +1,12 @@ # 面试一页式架构讲解 +**更新日期**:2026-07-20 **用途**:面试现场 2-5 分钟讲清项目 **适合场景**:开场介绍、架构追问、Demo 前铺垫 ## 1. 一句话 -SuperBizAgent 是一个面向企业故障诊断的可追踪 Agent 系统:它把用户问题或 AIOps 告警转换成 Planner、Executor、Gatekeeper、Verifier、Composer 的诊断链路,`sessionId` 保留多轮上下文,`runId` 精确绑定一次诊断运行;所有工具证据、模型步骤、最终答案、自评估和用户反馈都能按 `sessionId + runId` 回放。 +SuperBizAgent 是一个面向企业故障诊断的可追踪 Agent 系统:复杂 Chat 通过 bounded StateGraph 编排 Planner、Executor、Gatekeeper、Verified Input、Verifier、Composer 和安全 Fallback,`sessionId` 保留多轮上下文,`runId` 精确绑定一次诊断运行;工具证据、模型步骤、Graph 路由、自评估、最终答案和用户反馈都能按 `sessionId + runId` 回放。 ## 2. 一张图 @@ -16,7 +17,7 @@ flowchart TB API --> Chat["ChatService"] API --> AiOps["AiOpsService"] - Chat --> ChatFlow["Chat: Planner -> Executor -> Gatekeeper -> Verifier -> Composer"] + Chat --> ChatFlow["Chat StateGraph: Planner -> Executor -> Gatekeeper -> Verified Input -> Verifier -> Composer/Fallback"] AiOps --> AiOpsFlow["AIOps: Supervisor -> Planner / Executor"] ChatFlow --> Tools["Evidence Tools"] @@ -39,10 +40,14 @@ flowchart TB Trace --> Step["agent_step"] Trace --> Invocation["tool_invocation"] - Invocation --> Verifier["Verifier / Rule Evaluation"] + Invocation --> Gate["Gatekeeper / Rule Evaluation"] + Gate --> Projection["Verified Input"] + Projection --> Verifier["Verifier"] Verifier --> SelfEval["self_evaluation"] + Run --> RouteTrace["orchestration_trace"] Run --> TraceAPI["GET /api/diagnosis/{sessionId}/trace?runId=..."] + RouteTrace --> TraceAPI Step --> TraceAPI Invocation --> TraceAPI SelfEval --> TraceAPI @@ -56,21 +61,21 @@ flowchart TB ```text 这个项目不是把问题直接丢给大模型,而是把诊断拆成可审计的执行链路。 -Chat 复杂问题走 Planner -> Executor -> Gatekeeper -> Verifier -> Composer: -Planner 负责拆解,Executor 只负责调用知识库、日志和指标工具并提炼带证据引用的微观事实;Gatekeeper 用代码核对 invocation、raw_path 和 excerpt 是否真实;Verifier 判断这些事实能否由已验真的证据推出;Composer 只把允许表达的结论写成最终答案。 +Chat 复杂问题走 bounded StateGraph: +Planner 负责拆解,Executor 调用知识库、日志和指标工具并提炼带证据引用的微观事实;Gatekeeper 用代码核对 invocation、raw_path 和 excerpt 是否真实;Verified Input 只投影通过的 binding;Verifier 判断这些事实能否由已验真的证据推出;Composer 只把允许表达的结论写成最终答案,不可恢复分支由 Fallback 生成安全答复。 AIOps 告警入口走 Supervisor 调度 Planner/Executor: 如果请求里有 alert payload,系统会进入 PAYLOAD_TARGETED 模式,报告必须聚焦这个告警,而不是被当前环境中的其他活跃告警带偏。 -会话元数据会落到 chat_session,每次诊断运行会落到 diagnosis_run,步骤和工具明细通过 run_id 关联。 -所以我可以用 sessionId + runId 精确回放:模型怎么规划、调了哪些工具、工具返回什么、Gatekeeper 怎么验真、Verifier 怎么判定、Composer 最后怎么表达、用户最后是否反馈有用。 +会话元数据会落到 chat_session,每次诊断运行会落到 diagnosis_run,步骤和工具明细通过 run_id 关联;证据质量写入 self_evaluation,Graph 路由独立写入 orchestration_trace。 +所以我可以用 sessionId + runId 精确回放:模型怎么规划、调了哪些工具、Gatekeeper 怎么验真、Graph 为什么重试或降级、Verifier 怎么判定、Composer/Fallback 如何结束、用户最后是否反馈有用。 ``` ## 4. 五个亮点 | 亮点 | 怎么讲 | |---|---| -| 可追踪 Agent | 每次诊断都有 `runId`,Trace API 可以回放 run、step、tool;同一 `sessionId` 可有多次独立 run | +| 可追踪 StateGraph | 每次诊断都有 `runId`,Trace API 可以回放 run、step、tool 和 `run.orchestrationTrace`;同一 `sessionId` 可有多次独立 run | | 显式工具证据链 | `lookup_knowledge`、日志、指标都记录到 `tool_invocation` | | RAG 工程化 | L0 降级为 hint,Spring AI VectorStore 做主检索,SDK fallback 保底 | | 质量门禁 | Chat Gatekeeper 验引用、Verifier 判可推导、Composer 控表达,AIOps rule evaluation 控制告警聚焦 | diff --git a/mvp/architecture/retrieval-observability.md b/mvp/architecture/retrieval-observability.md index 360b379..ee278b1 100644 --- a/mvp/architecture/retrieval-observability.md +++ b/mvp/architecture/retrieval-observability.md @@ -185,7 +185,7 @@ flowchart LR Invocation --> Eval["EvaluationService / RAG eval"] ``` -旧 Trace/fixture 中的 `tool_trace_summary` 只保留读取兼容;当前 StateGraph 不再生成它,也不会把完整工具调用摘要输入 Verifier。 +当前 StateGraph 不生成或读取 `tool_trace_summary`,也不会把完整工具调用摘要输入 Verifier。 `tool_invocation` 中与检索相关的字段: diff --git a/mvp/architecture/session-trace-lifecycle.md b/mvp/architecture/session-trace-lifecycle.md index 12c1dd1..66da5ad 100644 --- a/mvp/architecture/session-trace-lifecycle.md +++ b/mvp/architecture/session-trace-lifecycle.md @@ -18,7 +18,7 @@ chat_session(sessionId) - `sessionId` 表示多轮会话目录和 Redis 上下文。 - `runId` 表示一次可回放诊断执行。 - `DiagnosisTraceService` 聚合一个 run 的主记录、步骤和工具调用,形成可回放 Trace。 -- `diagnosis_session` 只保留为历史兼容和回滚表。 +- 运行时不再映射、读取或写入 `diagnosis_session`;数据库中的旧表不属于当前版本契约。 ## 2. 生命周期总图 @@ -60,8 +60,7 @@ flowchart TD - 同一个 `sessionId` 可以贯穿多轮 Chat。 - 每次有效 Chat/AIOps 执行都会创建新的 `runId`。 -- Trace 和 Feedback 新客户端应传 `runId`;只传 `sessionId` 时兼容解析 latest run。 -- latest run 排序使用 `diagnosis_run.created_at DESC, id DESC`,不使用 `updated_at`。 +- Trace 和 Feedback 必须同时传 `sessionId + runId`;后端不解析 latest run,也不回退旧表。 ## 4. 运行状态流转 @@ -137,9 +136,9 @@ diagnosis_run by sessionId + runId -> DiagnosisTraceResponse ``` -当 `runId` 缺失时,Trace API 为兼容旧客户端解析最新 run,并在响应中返回 resolved `runId`。当 `runId` 属于其他 `sessionId` 时,API 必须拒绝,不能泄漏其他会话的 Trace。 +当 `runId` 缺失时,Trace API 直接拒绝请求。当 `runId` 属于其他 `sessionId` 时,API 同样拒绝,不能泄漏其他会话的 Trace。 -`run.orchestrationTrace` 只属于精确 Run 投影,不复制到顶层或 `session`。它解释 Graph 路由;`selfEvaluation` 解释证据/答案质量;`steps` 和 `toolInvocations` 保存详细执行证据,三者职责互不替代。历史 Run 的该字段可以为空。 +`run.orchestrationTrace` 只属于精确 Run 投影,响应不再提供兼容 `session` 对象。它解释 Graph 路由;`selfEvaluation` 解释证据/答案质量;`steps` 和 `toolInvocations` 保存详细执行证据,三者职责互不替代。非 StateGraph Run 的该字段可以为空。 ## 8. Chat 与 AIOps 差异 @@ -164,4 +163,4 @@ Chat StateGraph 的权威自动化验收分三层:`DiagnosisGraphWorkflowTest` 1. Trace API 增加更结构化的 `self_evaluation` 展示。 2. `agent_step` 与 `tool_invocation.step_id` 建立更严格关联。 -3. 旧 `diagnosis_session` 只读观察期结束后,再评估数据库层面的约束收紧或归档策略。 +3. 数据库中的旧 `diagnosis_session` 表按独立数据治理任务决定是否物理删除;当前应用不再依赖它。 diff --git a/mvp/architecture/stategraph-runtime-architecture.md b/mvp/architecture/stategraph-runtime-architecture.md new file mode 100644 index 0000000..47192bb --- /dev/null +++ b/mvp/architecture/stategraph-runtime-architecture.md @@ -0,0 +1,199 @@ +# Chat StateGraph 运行时架构 + +**更新日期**:2026-07-20 +**状态**:当前复杂 Chat 诊断的权威运行时架构 +**适用范围**:`POST /api/chat` 的复杂诊断路径;简单 Chat 和 AIOps 使用各自链路 + +## 1. 架构定位 + +复杂 Chat 已单轨切换为 Spring AI Alibaba bounded `StateGraph`。`ChatService` 负责 Run 生命周期和持久化,`ChatDiagnosisGraphRuntime` 负责执行 Graph,`DiagnosisGraphFactory` 负责声明 Node 与条件边,Agent/Java Node 负责各自的语义任务或确定性校验。 + +```mermaid +flowchart LR + API["POST /api/chat"] --> Chat["ChatService.executeChatComplex"] + Chat --> Session["chat_session"] + Chat --> Run["diagnosis_run: RUNNING"] + Chat --> Actions["DiagnosisRealGraphActionsFactory"] + Actions --> Runtime["ChatDiagnosisGraphRuntime"] + Runtime --> Factory["DiagnosisGraphFactory"] + Factory --> Graph["Compiled StateGraph"] + + Graph --> Planner["PlannerNodeAdapter"] + Graph --> Executor["ExecutorNodeAdapter"] + Graph --> Gatekeeper["GatekeeperNode"] + Graph --> Projection["VerifiedInputNode"] + Graph --> Verifier["VerifierNodeAdapter"] + Graph --> Retry["EvidenceRetryPrepareNode"] + Graph --> Composer["ComposerNodeAdapter"] + Graph --> Fallback["FallbackNode"] + + Executor --> Tools["lookup_knowledge / logs / metrics"] + Tools --> Invocations["tool_invocation"] + Planner --> Steps["agent_step"] + Executor --> Steps + Verifier --> Steps + Composer --> Steps + + Graph --> Mapper["DiagnosisGraphResultMapper"] + Graph --> TraceBuilder["DiagnosisOrchestrationTraceBuilder"] + Mapper --> SelfEval["diagnosis_run.self_evaluation"] + TraceBuilder --> RouteTrace["diagnosis_run.orchestration_trace"] + Chat --> RunDone["diagnosis_run: SUCCESS / FAILED"] + RunDone --> TraceAPI["exact Run Trace API"] + SelfEval --> TraceAPI + RouteTrace --> TraceAPI + Steps --> TraceAPI + Invocations --> TraceAPI +``` + +## 2. 运行生命周期 + +一次复杂 Chat 运行按以下顺序执行: + +1. `ChatService` 解析或创建 `sessionId`,生成唯一 `runId`。 +2. 确保 `chat_session` 元数据存在,并创建 `diagnosis_run`,初始状态为 `RUNNING`、`agent_flow=CHAT`。 +3. 构建 Planner、Executor、Verifier、Composer 四个 `ReactAgent`,再由 `DiagnosisRealGraphActionsFactory` 组合 Java Nodes。 +4. `ChatDiagnosisGraphRuntime` 以 `runId` 作为 Graph `threadId`,将 `sessionId/runId` 放入 `RunnableConfig.metadata`。 +5. `DiagnosisGraphFactory` 编译 StateGraph 并执行,Graph recursion limit 固定为 32。 +6. Graph 返回非空 `final_answer` 后,`DiagnosisGraphResultMapper` 生成 verifier evaluation,`DiagnosisOrchestrationTraceBuilder` 压缩路由摘要。 +7. `ChatService` 保存答案、耗时、步骤数、工具数、自评估和编排摘要,将 Run 标记为 `SUCCESS`。 +8. 未处理异常会尽力保存 partial state/partial trace,再将 Run 标记为 `FAILED`;能够生成安全 Fallback 的路径仍是 `SUCCESS`,并通过 `degraded=true` 表达质量降级。 +9. `finally` 清理本轮检索追踪和 session/run ThreadLocal,避免跨 Run 污染。 + +## 3. Graph 拓扑 + +```mermaid +flowchart TD + Start([START]) --> Planner[Planner] + Planner -->|COMPLETED| Executor[Executor] + Planner -->|technical retry once| Planner + Planner -->|non-retryable / exhausted| Fallback[Fallback] + + Executor -->|COMPLETED| Gatekeeper[Gatekeeper] + Executor -->|INVALID_OUTPUT / TOOL_BLOCKED / FAILED| Fallback + + Gatekeeper -->|PASS| VerifiedInput[Verified Input] + Gatekeeper -->|LOW_CONFID + verified bindings| VerifiedInput + Gatekeeper -->|REJECT / no verified binding| Fallback + + VerifiedInput --> Verifier[Verifier] + Verifier -->|technical retry once| Verifier + Verifier -->|LOW_CONFID + critical gap + retry allowed| EvidenceRetry[Evidence Retry] + EvidenceRetry -->|EVIDENCE_GAP_ONLY| Planner + Verifier -->|completed and no retry| Composer[Composer] + Verifier -->|non-retryable / exhausted| Fallback + + Composer -->|COMPLETED| End([END]) + Composer -->|technical retry once| Composer + Composer -->|non-retryable / exhausted| Fallback + Fallback --> End +``` + +路由规则: + +| 节点 | 继续条件 | 重试 | 安全终止 | +|---|---|---|---| +| Planner | `COMPLETED` 进入 Executor | `INVALID_OUTPUT` / `RETRYABLE_FAILED` 最多一次技术重试 | `NON_RETRYABLE_FAILED` 或重试耗尽进入 Fallback | +| Executor | 只有 `COMPLETED` 进入 Gatekeeper | 不做 Graph 技术重试 | 非法输出、工具阻断或执行失败进入 Fallback | +| Gatekeeper | `PASS`,或 `LOW_CONFID` 且至少一个 verified binding | 不重试 | `REJECT` 或零 verified binding 进入 Fallback | +| Verifier | 完成后由 `effective_verdict` 决定 Composer 或补证据 | 技术失败最多一次;证据补查最多一次 | 不可重试失败或技术重试耗尽进入 Fallback | +| Composer | `COMPLETED` 结束 | 技术失败最多一次 | 不可重试失败或重试耗尽进入 Fallback | +| Fallback | 生成非空确定性安全答复 | 不重试 | 直接结束并标记 `degraded=true` | + +Evidence retry 只有同时满足以下条件才发生: + +- `evidence_retry_count < 1`。 +- Gatekeeper 给出的 verifier verdict ceiling 仍允许 `PASS`。 +- Verifier 输出包含可提取的 critical evidence gap。 + +补证据时 Planner 进入 `EVIDENCE_GAP_ONLY`,`planner_retry_count` 重置;Executor 只执行增量查询,但重新输出完整 `executor_evidence_v2` 快照。 + +## 4. 状态与执行边界 + +### Graph State + +Graph State 只保存跨 Node 的控制信息和结构化结果: + +- `diagnosis_context`、`planner_plan`、`executor_output`。 +- `gatekeeper_result`、`verified_executor_output`、`verified_evidence`。 +- `verifier_output`、`composer_output`、`final_answer`。 +- Planner/Verifier/Composer 技术重试计数和 `evidence_retry_count`。 +- `orchestration_events` 有界追加事件。 + +默认状态键使用 replace strategy,只有 `orchestration_events` 使用 append strategy。事件在 Graph 边界存为 classloader-neutral Map:`node/outcome/reason_code/attempt`,避免 DevTools restart classloader 造成 record 类型身份不一致。 + +### RunnableConfig + +外层 Graph config 使用: + +- `threadId = runId`。 +- metadata 包含 `sessionId` 和 `runId`。 + +调用 nested `ReactAgent` 时,`ReactAgentDiagnosisInvoker` 创建独立 config,只保留业务身份与 store,不向子 Agent 传播外层 Graph 的 human-feedback、state-update、checkpoint/resume 控制 metadata,避免父 Graph 恢复语义污染子 Graph。 + +## 5. 证据信任边界 + +```text +Executor output + -> source_invocation_id + raw_path + evidence_excerpt + -> GatekeeperNode / ExecutorGatekeeperService + -> 按当前 runId 读取 tool_invocation + -> 验证 invocation ownership、raw_path、excerpt + -> VerifiedInputNode + -> 只投影通过的 claims/bindings/evidence + -> VerifierNodeAdapter + -> 只判断已验真证据是否支持 claim + -> ComposerNodeAdapter + -> 只表达允许输出的结论、限制和建议 +``` + +Verifier 不读取完整工具 Trace,不执行新检索,也不读取 Skill 正文。当前版本不生成或读取 `tool_trace_summary`。 + +## 6. Run 级审计模型 + +| 审计层 | 存储/API | 回答的问题 | +|---|---|---| +| 执行明细 | `agent_step`、`tool_invocation` | 模型和工具实际做了什么? | +| 证据与答案质量 | `diagnosis_run.self_evaluation` | 引用是否真实、claim 是否可推导、Prompt/Gatekeeper 版本是什么? | +| Graph 路由 | `diagnosis_run.orchestration_trace` / `run.orchestrationTrace` | 走了哪些 Node、为何重试或降级、在哪里结束? | + +`orchestration_trace` 当前结构: + +```json +{ + "version": "stategraph-v1", + "transitions": [ + {"from": "planner", "to": "executor", "reason_code": "completed", "attempt": 1} + ], + "final_node": "composer", + "termination_reason": "composer_completed", + "degraded": false, + "evidence_retry_count": 0 +} +``` + +该字段只属于 exact Run 投影,响应不提供兼容 `session` 投影;非 StateGraph Run 可以为空。 + +## 7. 验收层次 + +| 测试层 | 权威测试 | 覆盖 | +|---|---|---| +| Workflow | `DiagnosisGraphWorkflowTest` | 全部分支、有限重试、补证据和 Fallback | +| Node Contract | `DiagnosisGraphNodeContractTest` | 真实 Node 的输入投影、输出状态和证据边界 | +| Runtime | `ChatDiagnosisGraphRuntimeTest` | config、最终状态、partial failure/trace | +| Chat Integration | `ChatServiceGraphIntegrationTest` | Run 生命周期、答案、自评估、编排摘要和失败持久化 | +| Trace Contract | `DiagnosisTraceServiceTest` | exact run ownership 和 `run.orchestrationTrace` 投影 | +| Demo Contract | `InterviewDemoScriptContractTest` | exact runId、Graph 字段和 summary 输出 | + +## 8. 关键代码 + +- `src/main/java/com/superbiz/agent/service/ChatService.java` +- `src/main/java/com/superbiz/agent/graph/diagnosis/ChatDiagnosisGraphRuntime.java` +- `src/main/java/com/superbiz/agent/graph/diagnosis/DiagnosisGraphFactory.java` +- `src/main/java/com/superbiz/agent/graph/diagnosis/DiagnosisGraphRouter.java` +- `src/main/java/com/superbiz/agent/graph/diagnosis/DiagnosisGraphState.java` +- `src/main/java/com/superbiz/agent/graph/diagnosis/DiagnosisRealGraphActionsFactory.java` +- `src/main/java/com/superbiz/agent/graph/diagnosis/ReactAgentDiagnosisInvoker.java` +- `src/main/java/com/superbiz/agent/graph/diagnosis/DiagnosisGraphResultMapper.java` +- `src/main/java/com/superbiz/agent/graph/diagnosis/DiagnosisOrchestrationTraceBuilder.java` +- `src/main/java/com/superbiz/agent/service/DiagnosisTraceService.java` diff --git a/mvp/archive/2026-07-20-doc-cleanup/README.md b/mvp/archive/2026-07-20-doc-cleanup/README.md new file mode 100644 index 0000000..413fc9d --- /dev/null +++ b/mvp/archive/2026-07-20-doc-cleanup/README.md @@ -0,0 +1,19 @@ +# 2026-07-20 MVP 文档清理归档 + +本目录保存已被当前 StateGraph、Executor Evidence Pipeline 和 Run-only v2 架构替代的历史材料。归档文件仅用于追溯,不代表当前运行时、接口或数据模型契约。 + +## 归档清单 + +| 原路径 | 归档文件 | 归档原因 | 当前依据 | +|---|---|---|---| +| `mvp/.backup/database-design-backup-20240622.md` | [database-design-backup-20240622.md](database/database-design-backup-20240622.md) | 早期数据库设计备份,模型和表关系已过时 | [data-model.md](../../architecture/data-model.md) | +| `mvp/issues/design-notes/executor-self-evidence-loop-design-note.md` | [executor-self-evidence-loop-design-note.md](issues/design-notes/executor-self-evidence-loop-design-note.md) | 早期问题分析已被可执行证据链契约吸收 | [executor-evidence-pipeline-refactor.md](../../architecture/executor-evidence-pipeline-refactor.md) | +| `mvp/issues/design-notes/executor-structured-output-v2.md` | [executor-structured-output-v2.md](issues/design-notes/executor-structured-output-v2.md) | 分阶段实施说明已完成并由当前契约和 OpenSpec 接管 | [executor-evidence-pipeline-refactor.md](../../architecture/executor-evidence-pipeline-refactor.md)、[OpenSpec 主规格](../../../openspec/specs/) | +| `mvp/tables/诊断会话表-diagnosis_session.md` | [诊断会话表-diagnosis_session.md](tables/诊断会话表-diagnosis_session.md) | 运行时已不再映射、读取或写入旧会话级诊断表 | [session-trace-lifecycle.md](../../architecture/session-trace-lifecycle.md)、[诊断运行表-diagnosis_run.md](../../tables/诊断运行表-diagnosis_run.md) | + +## 使用约束 + +- 当前架构从 `mvp/architecture/README.md` 进入。 +- 当前问题状态从 `mvp/issues/README.md` 进入。 +- 当前表模型从 `mvp/tables/README.md` 进入。 +- 归档中的兼容、回退和阶段状态描述均为历史快照,不得用于推导当前行为。 diff --git a/mvp/.backup/database-design-backup-20240622.md b/mvp/archive/2026-07-20-doc-cleanup/database/database-design-backup-20240622.md similarity index 99% rename from mvp/.backup/database-design-backup-20240622.md rename to mvp/archive/2026-07-20-doc-cleanup/database/database-design-backup-20240622.md index 714f753..a7763ce 100644 --- a/mvp/.backup/database-design-backup-20240622.md +++ b/mvp/archive/2026-07-20-doc-cleanup/database/database-design-backup-20240622.md @@ -1,5 +1,7 @@ # 数据库设计文档 +> 归档说明:这是 2024 年数据库设计备份,已被当前 `chat_session + diagnosis_run + run-scoped trace detail` 模型替代,仅用于历史追溯。 + ## 一、设计原则 ### 1.1 核心原则 diff --git a/mvp/issues/design-notes/executor-self-evidence-loop-design-note.md b/mvp/archive/2026-07-20-doc-cleanup/issues/design-notes/executor-self-evidence-loop-design-note.md similarity index 98% rename from mvp/issues/design-notes/executor-self-evidence-loop-design-note.md rename to mvp/archive/2026-07-20-doc-cleanup/issues/design-notes/executor-self-evidence-loop-design-note.md index 2f0bb9a..0f8fb91 100644 --- a/mvp/issues/design-notes/executor-self-evidence-loop-design-note.md +++ b/mvp/archive/2026-07-20-doc-cleanup/issues/design-notes/executor-self-evidence-loop-design-note.md @@ -1,5 +1,7 @@ # Executor 自证循环与证据摘要链路设计记录 +> 归档说明:本文是实施前的问题分析。当前实现以 `mvp/architecture/executor-evidence-pipeline-refactor.md` 和 OpenSpec 主规格为准。 + **状态**:已形成方向,待创建 OpenSpec change **严重程度**:高 **记录时间**:2026-07-07 diff --git a/mvp/issues/design-notes/executor-structured-output-v2.md b/mvp/archive/2026-07-20-doc-cleanup/issues/design-notes/executor-structured-output-v2.md similarity index 99% rename from mvp/issues/design-notes/executor-structured-output-v2.md rename to mvp/archive/2026-07-20-doc-cleanup/issues/design-notes/executor-structured-output-v2.md index 1355cb6..0d49399 100644 --- a/mvp/issues/design-notes/executor-structured-output-v2.md +++ b/mvp/archive/2026-07-20-doc-cleanup/issues/design-notes/executor-structured-output-v2.md @@ -1,5 +1,7 @@ # Executor Structured Output V2 可执行设计与实施 Issue +> 归档说明:本文记录旧的分阶段实施方案,其中的兼容期和待启动状态不再适用。当前实现以 `mvp/architecture/executor-evidence-pipeline-refactor.md` 和 OpenSpec 主规格为准。 + **状态**:阶段四待启动,前三阶段已归档并提交 **严重程度**:高 **创建日期**:2026-07-07 diff --git a/mvp/tables/诊断会话表-diagnosis_session.md b/mvp/archive/2026-07-20-doc-cleanup/tables/诊断会话表-diagnosis_session.md similarity index 84% rename from mvp/tables/诊断会话表-diagnosis_session.md rename to mvp/archive/2026-07-20-doc-cleanup/tables/诊断会话表-diagnosis_session.md index 94d371f..0e489ec 100644 --- a/mvp/tables/诊断会话表-diagnosis_session.md +++ b/mvp/archive/2026-07-20-doc-cleanup/tables/诊断会话表-diagnosis_session.md @@ -1,7 +1,9 @@ # 诊断会话表:diagnosis_session -**状态**:历史兼容和回滚表 -**来源**:`V005__create_session_storage.sql`、`V008__add_answer_to_diagnosis_session.sql`、`DiagnosisSession` +> 归档说明:本文记录 Run-only v2 之前的旧表语义。当前 Java 运行时不再映射、读取或写入 `diagnosis_session`;数据库中物理表是否保留由独立数据治理任务决定。 + +**状态**:历史快照,不属于当前版本契约 +**历史来源**:`V005__create_session_storage.sql`、`V008__add_answer_to_diagnosis_session.sql` ## 定位 diff --git a/mvp/eval/fixtures/audit-metadata-low-confid.json b/mvp/eval/fixtures/audit-metadata-low-confid.json index eef04f7..6c0e8f0 100644 --- a/mvp/eval/fixtures/audit-metadata-low-confid.json +++ b/mvp/eval/fixtures/audit-metadata-low-confid.json @@ -1,5 +1,5 @@ { - "session": { + "run": { "sessionId": "eval-audit-metadata-low-confid", "query": "订单超时是否可以确认由数据库主库故障导致,并检查审计元数据是否完整?", "status": "SUCCESS", diff --git a/mvp/eval/fixtures/composer-fallback-no-raw-json-low-confid.json b/mvp/eval/fixtures/composer-fallback-no-raw-json-low-confid.json index 6ed1379..feae55f 100644 --- a/mvp/eval/fixtures/composer-fallback-no-raw-json-low-confid.json +++ b/mvp/eval/fixtures/composer-fallback-no-raw-json-low-confid.json @@ -1,5 +1,5 @@ { - "session": { + "run": { "sessionId": "eval-composer-fallback-no-raw-json", "query": "库存服务慢响应是否可以直接输出 Executor JSON?", "status": "SUCCESS", diff --git a/mvp/eval/fixtures/gatekeeper-fabricated-invocation-reject.json b/mvp/eval/fixtures/gatekeeper-fabricated-invocation-reject.json index 31a13ec..f50da43 100644 --- a/mvp/eval/fixtures/gatekeeper-fabricated-invocation-reject.json +++ b/mvp/eval/fixtures/gatekeeper-fabricated-invocation-reject.json @@ -1,5 +1,5 @@ { - "session": { + "run": { "sessionId": "eval-gatekeeper-fabricated-invocation", "query": "支付失败是否能确认由日志中的连接池耗尽导致?", "status": "SUCCESS", diff --git a/mvp/eval/fixtures/hikari-no-evidence-negative-observation-pass.json b/mvp/eval/fixtures/hikari-no-evidence-negative-observation-pass.json index 41794a7..ca09dfc 100644 --- a/mvp/eval/fixtures/hikari-no-evidence-negative-observation-pass.json +++ b/mvp/eval/fixtures/hikari-no-evidence-negative-observation-pass.json @@ -1,5 +1,5 @@ { - "session": { + "run": { "sessionId": "eval-hikari-no-evidence-negative-observation", "query": "确认 inventory-service 当前是否有 HikariCP 连接池耗尽日志。", "status": "SUCCESS", diff --git a/mvp/eval/fixtures/jvm-memory-risk-low-confid.json b/mvp/eval/fixtures/jvm-memory-risk-low-confid.json index a9df727..f5283e2 100644 --- a/mvp/eval/fixtures/jvm-memory-risk-low-confid.json +++ b/mvp/eval/fixtures/jvm-memory-risk-low-confid.json @@ -1,5 +1,5 @@ { - "session": { + "run": { "sessionId": "eval-jvm-memory-risk", "query": "订单服务内存使用率过高,请判断是否存在 OOM 风险。", "status": "SUCCESS", diff --git a/mvp/eval/fixtures/mysql-pool-low-confid.json b/mvp/eval/fixtures/mysql-pool-low-confid.json index 93913f7..da762c8 100644 --- a/mvp/eval/fixtures/mysql-pool-low-confid.json +++ b/mvp/eval/fixtures/mysql-pool-low-confid.json @@ -1,5 +1,5 @@ { - "session": { + "run": { "sessionId": "eval-mysql-pool", "query": "订单服务大量请求超时,请判断是否和 MySQL 连接池有关。", "status": "SUCCESS", diff --git a/mvp/eval/fixtures/narrow-highcpu-observation-pass.json b/mvp/eval/fixtures/narrow-highcpu-observation-pass.json index 65f341e..b1483f6 100644 --- a/mvp/eval/fixtures/narrow-highcpu-observation-pass.json +++ b/mvp/eval/fixtures/narrow-highcpu-observation-pass.json @@ -1,5 +1,5 @@ { - "session": { + "run": { "sessionId": "eval-narrow-highcpu-observation", "query": "确认 payment-service 当前是否存在 HighCPUUsage 告警。", "status": "SUCCESS", diff --git a/mvp/eval/fixtures/payment-timeout-pass.json b/mvp/eval/fixtures/payment-timeout-pass.json index fe2bc97..7042c11 100644 --- a/mvp/eval/fixtures/payment-timeout-pass.json +++ b/mvp/eval/fixtures/payment-timeout-pass.json @@ -1,5 +1,5 @@ { - "session": { + "run": { "sessionId": "eval-payment-timeout", "query": "支付接口最近出现超时,请结合知识库、日志和指标判断可能原因,并给出修复建议。", "status": "SUCCESS", diff --git a/mvp/eval/fixtures/prompt-gatekeeper-audit-closure-pass.json b/mvp/eval/fixtures/prompt-gatekeeper-audit-closure-pass.json index 13e2bd5..4d20773 100644 --- a/mvp/eval/fixtures/prompt-gatekeeper-audit-closure-pass.json +++ b/mvp/eval/fixtures/prompt-gatekeeper-audit-closure-pass.json @@ -1,5 +1,5 @@ { - "session": { + "run": { "sessionId": "eval-prompt-gatekeeper-audit-closure", "query": "确认 payment-service 当前是否存在 HighCPUUsage 告警,并检查审计元数据是否完整。", "status": "SUCCESS", diff --git a/mvp/eval/fixtures/redis-timeout-low-confid.json b/mvp/eval/fixtures/redis-timeout-low-confid.json index 93ee8e5..938b314 100644 --- a/mvp/eval/fixtures/redis-timeout-low-confid.json +++ b/mvp/eval/fixtures/redis-timeout-low-confid.json @@ -1,5 +1,5 @@ { - "session": { + "run": { "sessionId": "eval-redis-timeout", "query": "支付服务出现 Redis 连接超时,请定位可能原因。", "status": "SUCCESS", diff --git a/mvp/eval/fixtures/slow-response-pass.json b/mvp/eval/fixtures/slow-response-pass.json index 6953db3..7360a02 100644 --- a/mvp/eval/fixtures/slow-response-pass.json +++ b/mvp/eval/fixtures/slow-response-pass.json @@ -1,5 +1,5 @@ { - "session": { + "run": { "sessionId": "eval-slow-response", "query": "用户服务 P99 响应时间升高,请结合指标和日志分析。", "status": "SUCCESS", diff --git a/mvp/eval/fixtures/unsupported-claim-filtering-low-confid.json b/mvp/eval/fixtures/unsupported-claim-filtering-low-confid.json index 9362385..29dc851 100644 --- a/mvp/eval/fixtures/unsupported-claim-filtering-low-confid.json +++ b/mvp/eval/fixtures/unsupported-claim-filtering-low-confid.json @@ -1,5 +1,5 @@ { - "session": { + "run": { "sessionId": "eval-unsupported-claim-filtering", "query": "订单超时是否可以确认由数据库主库故障导致?", "status": "SUCCESS", diff --git a/mvp/issues/README.md b/mvp/issues/README.md index 889c021..5a3f90d 100644 --- a/mvp/issues/README.md +++ b/mvp/issues/README.md @@ -1,14 +1,13 @@ # MVP Issues 索引 **更新日期**:2026-07-20 -**状态**:按活跃问题、设计笔记、RAG 问题集和已归档问题整理 +**状态**:按活跃问题、RAG 问题集和已归档问题整理 ## 目录约定 | 目录 | 用途 | |---|---| | [active/](active/) | 仍需要规划或实现的问题 | -| [design-notes/](design-notes/) | 已形成方向、用于指导后续实现的设计记录 | | [rag/](rag/) | RAG 子问题集合;多数已合并到 RAG 重构计划 | | [archived/](archived/) | 已修复、已实施或已归档的问题 | @@ -21,17 +20,12 @@ | executor-evidence-attribution-hallucination | Executor 证据归因幻觉 | 高 | 待规划 | [active/executor-evidence-attribution-hallucination.md](active/executor-evidence-attribution-hallucination.md) | | rag-refactor-plan | RAG 检索重构计划 | 高 | 待规划 | [active/rag-refactor-plan.md](active/rag-refactor-plan.md) | -## 设计笔记 - -| 名称 | 标题 | 状态 | 文件 | -|---|---|---|---| -| executor-self-evidence-loop-design-note | Executor 自证循环与证据摘要链路设计记录 | 已形成方向 | [design-notes/executor-self-evidence-loop-design-note.md](design-notes/executor-self-evidence-loop-design-note.md) | -| executor-structured-output-v2 | Executor 结构化输出 V2 阶段设计 | 部分已实施,保留为后续改造参考 | [design-notes/executor-structured-output-v2.md](design-notes/executor-structured-output-v2.md) | - ## RAG 问题集 这些问题已经收敛到 [active/rag-refactor-plan.md](active/rag-refactor-plan.md),单个文件保留用于追溯原始问题和设计背景。 +已被当前 Executor Evidence Pipeline 和 OpenSpec 替代的早期设计笔记已移至 [2026-07-20 文档清理归档](../archive/2026-07-20-doc-cleanup/README.md)。 + | 名称 | 标题 | 状态 | 文件 | |---|---|---|---| | breadcrumb-embedding-gap | RAG breadcrumb 未参与向量语义 | 已合并到重构计划 | [rag/rag-breadcrumb-embedding-gap.md](rag/rag-breadcrumb-embedding-gap.md) | diff --git a/mvp/issues/archived/ISS-010-session-run-trace-isolation.md b/mvp/issues/archived/ISS-010-session-run-trace-isolation.md index 995cc97..3ad7a2f 100644 --- a/mvp/issues/archived/ISS-010-session-run-trace-isolation.md +++ b/mvp/issues/archived/ISS-010-session-run-trace-isolation.md @@ -569,7 +569,7 @@ tool_invocation(run_id, id) - `mvp/architecture/data-model.md` - `mvp/tables/聊天会话表-chat_session.md` - `mvp/tables/诊断运行表-diagnosis_run.md` -- `mvp/tables/诊断会话表-diagnosis_session.md` +- `mvp/archive/2026-07-20-doc-cleanup/tables/诊断会话表-diagnosis_session.md` - `mvp/tables/Agent步骤表-agent_step.md` - `mvp/tables/工具调用表-tool_invocation.md` - `mvp/tables/案例库表-case_library.md` diff --git a/mvp/tables/Agent步骤表-agent_step.md b/mvp/tables/Agent步骤表-agent_step.md index 51cde2c..8bb53bf 100644 --- a/mvp/tables/Agent步骤表-agent_step.md +++ b/mvp/tables/Agent步骤表-agent_step.md @@ -12,7 +12,7 @@ | 字段 | 类型 | 必填 | 说明 | |---|---|---|---| | `id` | BIGINT | 是 | 自增主键 | -| `session_id` | VARCHAR(64) | 是 | 所属会话目录 ID,保留用于粗粒度过滤和兼容 | +| `session_id` | VARCHAR(64) | 是 | 所属会话目录 ID,作为粗粒度冗余筛选字段 | | `run_id` | VARCHAR(64) | 否 | 所属 `diagnosis_run.run_id`;新执行应写入 | | `step_index` | INT | 是 | 步骤序号,从 0 开始 | | `agent_name` | VARCHAR(32) | 是 | Agent 名称,例如 planner、executor、verifier、composer | @@ -28,14 +28,14 @@ | 索引 | 字段 | 用途 | |---|---|---| -| `idx_session_step` | `session_id, step_index` | 历史兼容和粗粒度排查 | +| `idx_session_step` | `session_id, step_index` | 粗粒度排查 | | `idx_agent_step_run_step` | `run_id, step_index` | 按运行筛选步骤并辅助顺序查询 | | `idx_agent_name` | `agent_name` | 按 Agent 类型筛选 | ## 关系 - `agent_step.run_id` 逻辑关联 `diagnosis_run.run_id`。 -- `agent_step.session_id` 保留为 `chat_session.session_id` 的冗余关联,便于粗粒度过滤和兼容查询。 +- `agent_step.session_id` 是 `chat_session.session_id` 的冗余关联,只用于粗粒度过滤。 - `tool_invocation.step_id` 可关联 `agent_step.id`,但当前允许为空且不强制外键。 ## 注意点 diff --git a/mvp/tables/README.md b/mvp/tables/README.md index ac5f147..bce0a77 100644 --- a/mvp/tables/README.md +++ b/mvp/tables/README.md @@ -1,6 +1,6 @@ # MVP 数据表索引 -**更新日期**:2026-07-10 +**更新日期**:2026-07-20 **状态**:当前表文档入口 本目录保存当前 MVP 使用的数据表说明。详细结构以 Flyway migration 和实体类为准;本目录用于面试讲解、排查索引和快速理解数据流。 @@ -16,13 +16,13 @@ | `api_document` | 知识库文档元数据,和向量库 chunk 通过 `doc_id` 关联 | [文档元数据表-api_document.md](文档元数据表-api_document.md) | | `knowledge_domain` | 知识域元数据,支撑 RAG domain hint 和检索策略 | [知识域表-knowledge_domain.md](知识域表-knowledge_domain.md) | | `case_library` | 用户反馈沉淀出的高质量诊断案例 | [案例库表-case_library.md](案例库表-case_library.md) | -| `diagnosis_session` | 历史兼容和回滚表,新执行写入不再依赖它 | [诊断会话表-diagnosis_session.md](诊断会话表-diagnosis_session.md) | ## 已归档表 | 表 | 归档原因 | 文档 | |---|---|---| | `diagnosis_record` | 已由 `V007` 删除,历史上被 `diagnosis_session + agent_step + tool_invocation` 替代;当前新模型是 `chat_session + diagnosis_run + trace detail` | [archive/2026-07-09-doc-cleanup/旧诊断记录表-diagnosis_record.md](archive/2026-07-09-doc-cleanup/旧诊断记录表-diagnosis_record.md) | +| `diagnosis_session` | 旧会话级诊断模型,当前 Java 运行时不再映射、读取或写入 | [../archive/2026-07-20-doc-cleanup/tables/诊断会话表-diagnosis_session.md](../archive/2026-07-20-doc-cleanup/tables/诊断会话表-diagnosis_session.md) | ## 核心关系 @@ -33,9 +33,6 @@ chat_session.session_id -> tool_invocation.run_id -> case_library.diagnosis_id (new AUTO cases use run_id) -diagnosis_session.session_id - -> historical compatibility / rollback only - api_document.doc_id -> vector chunk metadata.docId / doc_id diff --git a/mvp/tables/archive/2026-07-09-doc-cleanup/旧诊断记录表-diagnosis_record.md b/mvp/tables/archive/2026-07-09-doc-cleanup/旧诊断记录表-diagnosis_record.md index 37b89cc..3026376 100644 --- a/mvp/tables/archive/2026-07-09-doc-cleanup/旧诊断记录表-diagnosis_record.md +++ b/mvp/tables/archive/2026-07-09-doc-cleanup/旧诊断记录表-diagnosis_record.md @@ -1,6 +1,6 @@ # diagnosis_record - 旧诊断记录表 -> 归档说明:`diagnosis_record` 已在 `V007__drop_diagnosis_record.sql` 中删除,当前主模型是 `diagnosis_session + agent_step + tool_invocation`。本文只用于追溯早期设计。 +> 归档说明:`diagnosis_record` 已在 `V007__drop_diagnosis_record.sql` 中删除,该阶段由 `diagnosis_session + agent_step + tool_invocation` 替代。本文只用于追溯早期设计,不代表当前 Run-only v2 模型。 ## 表定位 diff --git a/mvp/tables/工具调用表-tool_invocation.md b/mvp/tables/工具调用表-tool_invocation.md index adb1499..e071afc 100644 --- a/mvp/tables/工具调用表-tool_invocation.md +++ b/mvp/tables/工具调用表-tool_invocation.md @@ -12,7 +12,7 @@ | 字段 | 类型 | 必填 | 说明 | |---|---|---|---| | `id` | BIGINT | 是 | 自增主键 | -| `session_id` | VARCHAR(64) | 是 | 所属会话目录 ID,保留用于粗粒度过滤和兼容 | +| `session_id` | VARCHAR(64) | 是 | 所属会话目录 ID,作为粗粒度冗余筛选字段 | | `run_id` | VARCHAR(64) | 否 | 所属 `diagnosis_run.run_id`;新执行应写入 | | `step_id` | BIGINT | 否 | 可关联 `agent_step.id` | | `tool_name` | VARCHAR(64) | 是 | 工具名称,例如 `lookup_knowledge`、日志查询、指标查询 | @@ -35,7 +35,7 @@ | 索引 | 字段 | 用途 | |---|---|---| -| `idx_session_id` | `session_id` | 历史兼容和粗粒度排查 | +| `idx_session_id` | `session_id` | 粗粒度排查 | | `idx_tool_invocation_run_id` | `run_id, id` | Trace、Verifier、评测按运行查询工具调用 | | `idx_tool_name` | `tool_name` | 按工具类型排查 | | `idx_retrieval_layer` | `retrieval_layer` | 观察 RAG L0/L1 行为 | @@ -43,7 +43,7 @@ ## 关系 - `tool_invocation.run_id` 逻辑关联 `diagnosis_run.run_id`。 -- `tool_invocation.session_id` 保留为 `chat_session.session_id` 的冗余关联,便于粗粒度过滤和兼容查询。 +- `tool_invocation.session_id` 是 `chat_session.session_id` 的冗余关联,只用于粗粒度过滤。 - `tool_invocation.step_id` 可关联 `agent_step.id`,但当前不强制。 ## 关键 JSON diff --git a/mvp/tables/案例库表-case_library.md b/mvp/tables/案例库表-case_library.md index 4641a9c..06cddc2 100644 --- a/mvp/tables/案例库表-case_library.md +++ b/mvp/tables/案例库表-case_library.md @@ -13,7 +13,7 @@ |---|---|---|---| | `id` | BIGINT | 是 | 自增主键 | | `case_id` | VARCHAR(64) | 是 | 案例唯一 ID | -| `diagnosis_id` | VARCHAR(64) | 否 | 关联诊断来源;新自动生成时存 `diagnosis_run.run_id`,历史数据可能是 `diagnosis_session.session_id` | +| `diagnosis_id` | VARCHAR(64) | 否 | 关联诊断来源;自动生成时存 `diagnosis_run.run_id` | | `source_type` | VARCHAR(16) | 否 | 来源类型:`AUTO` 或 `MANUAL` | | `fault_category` | VARCHAR(32) | 否 | 故障类别,实体侧使用 `FaultCategory` | | `fault_source` | VARCHAR(128) | 否 | 故障源,例如服务、系统或省份 | @@ -35,17 +35,17 @@ | `idx_error_code` | `error_code` | 按错误码精确匹配 | | `idx_fault_source` | `fault_source` | 按故障源筛选 | | `idx_fault_target` | `fault_target(100)` | 按故障目标筛选 | -| `idx_diagnosis_id` | `diagnosis_id` | 追溯来源运行或历史会话 | +| `idx_diagnosis_id` | `diagnosis_id` | 追溯来源运行 | | `idx_reference_count` | `reference_count` | 推荐排序 | | `idx_created_at` | `created_at` | 时间排序 | ## 关系 -- `case_library.diagnosis_id` 是过渡字段:新自动案例逻辑关联 `diagnosis_run.run_id`,历史自动案例可能仍是 `diagnosis_session.session_id`。 +- `case_library.diagnosis_id` 逻辑关联 `diagnosis_run.run_id`。 - 人工录入案例可以不填写 `diagnosis_id`。 ## 注意点 - 旧文档里提到的 `diagnosis_record` 已被 `V007` 删除,不再是当前主模型。 -- 查询新自动案例时优先按 `run_id` 追溯;遇到旧值时再按历史 `session_id` 解释。 +- 自动案例只按 `run_id` 追溯,不执行会话级兼容查询。 - 当前自动沉淀仍比较粗:`root_cause` 和 `solution` 都可能来自完整 answer。后续可从结构化结论中拆分根因、证据和修复建议。 diff --git a/mvp/tables/诊断运行表-diagnosis_run.md b/mvp/tables/诊断运行表-diagnosis_run.md index fb462b5..8e8af42 100644 --- a/mvp/tables/诊断运行表-diagnosis_run.md +++ b/mvp/tables/诊断运行表-diagnosis_run.md @@ -5,7 +5,7 @@ ## 定位 -`diagnosis_run` 表示一次可回放的 Chat 或 AIOps 诊断执行。`run_id` 是运行级边界,Trace、反馈、自评估、案例沉淀和统计都应优先按 `run_id` 绑定。 +`diagnosis_run` 表示一次可回放的 Chat 或 AIOps 诊断执行。`run_id` 是运行级边界,Trace、反馈、自评估、案例沉淀和统计都必须按 `run_id` 绑定。 ## 字段 @@ -32,7 +32,7 @@ | 索引 | 字段 | 用途 | |---|---|---| | `run_id` unique | `run_id` | 运行唯一约束 | -| `idx_diagnosis_run_session_created` | `session_id, created_at, id` | session 下最新运行解析和运行列表 | +| `idx_diagnosis_run_session_created` | `session_id, created_at, id` | session 下运行列表和排序 | | `idx_diagnosis_run_session_run` | `session_id, run_id` | exact trace / feedback ownership 校验 | | `idx_diagnosis_run_status` | `status` | 状态筛选 | | `idx_diagnosis_run_agent_flow` | `agent_flow` | 区分 Chat / AIOps | @@ -46,6 +46,6 @@ ## 注意点 -- `GET /api/diagnosis/{sessionId}/trace` 未带 `runId` 时只为兼容解析 latest run;新 demo 和新客户端应传 `runId`。 -- latest run 排序使用 `created_at DESC, id DESC`,避免 feedback 或自评估更新 `updated_at` 后改变回放目标。 -- 历史 `diagnosis_session` 会被迁移成兼容 run,但旧混合数据不能被还原成真实多轮边界。 +- Trace 查询必须同时提供 `sessionId + runId`,并验证 Run 归属;缺少 `runId` 时直接拒绝。 +- Run 列表按 `created_at DESC, id DESC` 排序,feedback 或自评估更新 `updated_at` 不改变运行顺序。 +- 当前 Java 运行时只使用 `chat_session + diagnosis_run`,不读取或写入旧会话级诊断表。 diff --git a/openspec/specs/mvp-demo-trace-acceptance/spec.md b/openspec/specs/mvp-demo-trace-acceptance/spec.md index a55a814..190b38a 100644 --- a/openspec/specs/mvp-demo-trace-acceptance/spec.md +++ b/openspec/specs/mvp-demo-trace-acceptance/spec.md @@ -2,27 +2,28 @@ Provide a repeatable MVP demo flow that can run a chat diagnosis, expose its persisted execution trace, and submit feedback for the same diagnosis run. ## Requirements -### Requirement: Diagnosis trace can be queried by session id -The system SHALL expose a read-only HTTP endpoint `GET /api/diagnosis/{sessionId}/trace` that returns the persisted diagnosis trace for the requested session id. When `runId` is omitted, the endpoint SHALL return the latest diagnosis run for compatibility. When `runId` is provided, the endpoint SHALL return that exact run after validating it belongs to the path `sessionId`. +### Requirement: Diagnosis trace requires an exact run +The system SHALL expose a read-only HTTP endpoint `GET /api/diagnosis/{sessionId}/trace?runId=...` that returns only the specified diagnosis run after validating ownership. -#### Scenario: Existing session latest trace is returned -- **WHEN** a caller requests trace data for a session id that has at least one `diagnosis_run` -- **THEN** the system returns a success response containing the resolved run id, session summary, run summary, ordered agent steps, ordered tool invocations, self-evaluation data, final answer, and feedback for the latest run +#### Scenario: Missing runId is rejected +- **WHEN** a caller omits `runId` +- **THEN** the request is rejected without inferring a latest run #### Scenario: Existing session exact trace is returned - **WHEN** a caller requests trace data with `GET /api/diagnosis/{sessionId}/trace?runId=run-xxx` - **THEN** the system validates that `runId` belongs to `sessionId` -- **AND** it returns a success response containing only the trace data for that run +- **AND** it returns chat-session metadata, the exact run, ordered steps, ordered tool invocations, self-evaluation, answer, and feedback +- **AND** it does not return a compatibility `session` projection #### Scenario: Missing session returns not found -- **WHEN** a caller requests trace data for a session id that does not exist in `chat_session`, `diagnosis_run`, or historical compatibility data +- **WHEN** a caller requests a sessionId/runId pair that does not exist in `diagnosis_run` - **THEN** the system returns a 404 response using the existing session-not-found error contract ### Requirement: Trace aggregation is read-only The system MUST build trace output from existing persisted diagnosis tables and MUST NOT mutate chat sessions, diagnosis runs, agent steps, tool invocations, feedback, or chat session state while serving the trace request. #### Scenario: Trace query does not change persisted state -- **WHEN** a caller requests `GET /api/diagnosis/{sessionId}/trace` +- **WHEN** a caller requests `GET /api/diagnosis/{sessionId}/trace?runId=run-xxx` - **THEN** the system reads `diagnosis_run`, `agent_step`, and `tool_invocation` records and returns an aggregate without saving any of those records #### Scenario: Exact trace query does not change persisted state @@ -150,4 +151,3 @@ The MVP demo SHALL provide stable scenarios that explain how to demonstrate posi - **WHEN** a demo scenario is fixture-backed rather than live-scripted - **THEN** the documentation SHALL say so explicitly - **AND** it SHALL avoid promising deterministic live LLM output for that scenario - diff --git a/openspec/specs/session-run-trace-isolation/spec.md b/openspec/specs/session-run-trace-isolation/spec.md index a441bfe..2b6a325 100644 --- a/openspec/specs/session-run-trace-isolation/spec.md +++ b/openspec/specs/session-run-trace-isolation/spec.md @@ -53,19 +53,19 @@ The system SHALL write and read `agent_step` and `tool_invocation` rows using `r - **THEN** its step and tool counts SHALL be calculated from rows matching that `run_id` - **AND** rows from other runs in the same `sessionId` SHALL NOT be counted -### Requirement: Trace API SHALL support latest-run and exact-run queries -The system SHALL allow callers to query a diagnosis trace by `sessionId` alone for compatibility or by `sessionId + runId` for exact run replay. +### Requirement: Trace API SHALL require an exact run +The system SHALL require `sessionId + runId` for every diagnosis trace query and SHALL NOT infer a latest or historical run. -#### Scenario: Trace without runId resolves latest run +#### Scenario: Trace without runId is rejected - **WHEN** a caller requests `GET /api/diagnosis/{sessionId}/trace` without `runId` -- **THEN** the system SHALL resolve the latest run for that session by `diagnosis_run.created_at DESC, id DESC` -- **AND** the response SHALL include the resolved `runId` +- **THEN** request validation SHALL reject the request +- **AND** the service SHALL NOT infer a run from current or historical data #### Scenario: Trace with runId returns exact run - **WHEN** a caller requests `GET /api/diagnosis/{sessionId}/trace?runId=run-xxx` - **THEN** the system SHALL validate that `runId` belongs to the path `sessionId` -- **AND** it SHALL return only the session summary, run summary, agent steps, tool invocations, self-evaluation, answer, and feedback for that run -- **AND** the session summary SHALL come from `chat_session` metadata when available, while the run summary SHALL come from `diagnosis_run` +- **AND** it SHALL return only chat-session metadata, run summary, agent steps, tool invocations, self-evaluation, answer, and feedback for that run +- **AND** it SHALL NOT expose a compatibility `session` projection #### Scenario: Trace rejects run from another session - **WHEN** a caller requests a `runId` that belongs to a different `sessionId` @@ -96,22 +96,11 @@ The system SHALL bind new feedback to a diagnosis run rather than an ambiguous m - **THEN** the system SHALL validate that the run belongs to the session - **AND** it SHALL update feedback on that run - **AND** the response SHALL include the actual bound `runId` -- **AND** the response SHALL include `fallbackToLatestRun=false` -#### Scenario: Feedback without runId falls back observably -- **WHEN** a legacy feedback request includes `sessionId` but omits `runId` -- **AND** at least one `diagnosis_run` exists for that session -- **THEN** the system SHALL bind feedback to the latest run for that session -- **AND** the response SHALL include `fallbackToLatestRun=true` -- **AND** the response SHALL include the actual bound `runId` - -#### Scenario: Historical feedback without run-backed data remains compatible -- **WHEN** a legacy feedback request includes `sessionId` but omits `runId` -- **AND** no `diagnosis_run` exists for that session -- **AND** a historical `diagnosis_session` row exists for that session -- **THEN** the system MAY bind feedback to the historical session row for migration compatibility -- **AND** the response SHALL NOT claim latest-run fallback -- **AND** the response MAY omit `runId` +#### Scenario: Feedback without runId is rejected +- **WHEN** a feedback request includes `sessionId` but omits `runId` +- **THEN** the system SHALL reject the request +- **AND** it SHALL NOT bind feedback to any run #### Scenario: Feedback rejects run from another session - **WHEN** a feedback request includes a `runId` that belongs to a different `sessionId` @@ -140,18 +129,13 @@ The system SHALL create and expose a diagnosis run for every valid `/api/ai_ops` - **AND** the metadata payload SHALL expose the created `runId` - **AND** report content SHALL continue to use the existing content message shape -### Requirement: Migration SHALL preserve historical trace access -The system SHALL migrate historical diagnosis data into compatibility runs without deleting the old `diagnosis_session` table. +### Requirement: Runtime SHALL use only run-based diagnosis storage +The system SHALL use `chat_session` and `diagnosis_run` as the only runtime diagnosis model and SHALL NOT read or write `diagnosis_session`. -#### Scenario: Historical session gets compatibility run -- **WHEN** migration runs on an existing `diagnosis_session` row -- **THEN** the system SHALL create a compatible `diagnosis_run` row for that session -- **AND** old `agent_step` and `tool_invocation` rows for that session SHALL be backfilled to that `run_id` when possible - -#### Scenario: Old table is retained -- **WHEN** the migration completes -- **THEN** the `diagnosis_session` table SHALL remain available for historical comparison and rollback -- **AND** new execution writes SHALL target `chat_session` and `diagnosis_run` +#### Scenario: Runtime components are inspected +- **WHEN** Trace, Feedback, Evaluation, AIOps persistence, Gatekeeper, and case creation execute +- **THEN** they SHALL resolve data by `runId` +- **AND** no executable entity, repository, service fallback, or test SHALL depend on `diagnosis_session` ### Requirement: Demo and Trace UI SHALL support runId The demo tooling and Trace UI SHALL support minimal run-aware workflows. @@ -214,14 +198,14 @@ The Trace API SHALL parse the current DiagnosisRun orchestration JSON and expose - **WHEN** a caller queries a successful new StateGraph Chat run - **THEN** `run.orchestrationTrace` SHALL be a non-empty parsed JSON object -- **AND** the response top level and compatibility `session` projection SHALL NOT duplicate the field +- **AND** the response SHALL NOT contain a compatibility `session` projection - **AND** no raw orchestration trace field SHALL be added -#### Scenario: Historical or non-StateGraph run is queried +#### Scenario: Non-StateGraph run is queried - **WHEN** the selected DiagnosisRun has null orchestration trace - **THEN** `run.orchestrationTrace` MAY be null -- **AND** the service SHALL NOT synthesize historical events or read another run's trace +- **AND** the service SHALL NOT synthesize events or read another run's trace ### Requirement: Orchestration trace migration SHALL be additive and nullable diff --git a/src/main/java/com/superbiz/agent/controller/DiagnosisTraceController.java b/src/main/java/com/superbiz/agent/controller/DiagnosisTraceController.java index c7d9474..ff286d2 100644 --- a/src/main/java/com/superbiz/agent/controller/DiagnosisTraceController.java +++ b/src/main/java/com/superbiz/agent/controller/DiagnosisTraceController.java @@ -21,7 +21,7 @@ public class DiagnosisTraceController { @GetMapping("/{sessionId}/trace") public ResponseEntity> getTrace( @PathVariable String sessionId, - @RequestParam(required = false) String runId + @RequestParam String runId ) { return ResponseEntity.ok(Result.success(diagnosisTraceService.getTrace(sessionId, runId))); } diff --git a/src/main/java/com/superbiz/agent/domain/entity/DiagnosisSession.java b/src/main/java/com/superbiz/agent/domain/entity/DiagnosisSession.java deleted file mode 100644 index c531dfb..0000000 --- a/src/main/java/com/superbiz/agent/domain/entity/DiagnosisSession.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.superbiz.agent.domain.entity; - -import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; -import org.hibernate.annotations.JdbcTypeCode; -import org.hibernate.type.SqlTypes; - -import java.time.LocalDateTime; - -/** - * 诊断会话实体 - * 对应表: diagnosis_session - */ -@Entity -@Table(name = "diagnosis_session", indexes = { - @Index(name = "idx_created_at", columnList = "created_at"), - @Index(name = "idx_status", columnList = "status"), - @Index(name = "idx_agent_flow", columnList = "agent_flow") -}) -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class DiagnosisSession { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(name = "session_id", unique = true, nullable = false, length = 64) - private String sessionId; - - @Column(name = "query", nullable = false, columnDefinition = "TEXT") - private String query; - - @Column(name = "status", length = 16) - private String status = "PENDING"; - - @Column(name = "agent_flow", length = 32) - private String agentFlow; - - @Column(name = "total_duration_ms") - private Integer totalDurationMs; - - @Column(name = "total_token_count") - private Integer totalTokenCount; - - @Column(name = "step_count") - private Integer stepCount; - - @Column(name = "tool_call_count") - private Integer toolCallCount; - - @Column(name = "answer", columnDefinition = "LONGTEXT") - private String answer; - - @JdbcTypeCode(SqlTypes.JSON) - @Column(name = "self_evaluation", columnDefinition = "JSON") - private String selfEvaluation; - - @Column(name = "feedback", length = 16) - private String feedback; - - @Column(name = "created_at", nullable = false, updatable = false) - private LocalDateTime createdAt; - - @Column(name = "updated_at") - private LocalDateTime updatedAt; - - @PrePersist - protected void onCreate() { - createdAt = LocalDateTime.now(); - updatedAt = LocalDateTime.now(); - } - - @PreUpdate - protected void onUpdate() { - updatedAt = LocalDateTime.now(); - } -} diff --git a/src/main/java/com/superbiz/agent/dto/DiagnosisTraceResponse.java b/src/main/java/com/superbiz/agent/dto/DiagnosisTraceResponse.java index da320db..fae168e 100644 --- a/src/main/java/com/superbiz/agent/dto/DiagnosisTraceResponse.java +++ b/src/main/java/com/superbiz/agent/dto/DiagnosisTraceResponse.java @@ -17,7 +17,6 @@ public class DiagnosisTraceResponse { private String runId; private ChatSessionTrace chatSession; - private SessionTrace session; private RunTrace run; private List steps; private List toolInvocations; @@ -37,28 +36,6 @@ public class DiagnosisTraceResponse { private LocalDateTime expiresAt; } - @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - public static class SessionTrace { - private Long id; - private String sessionId; - private String query; - private String status; - private String agentFlow; - private Integer totalDurationMs; - private Integer totalTokenCount; - private Integer stepCount; - private Integer toolCallCount; - private String answer; - private String selfEvaluationRaw; - private Map selfEvaluation; - private String feedback; - private LocalDateTime createdAt; - private LocalDateTime updatedAt; - } - @Data @Builder @NoArgsConstructor diff --git a/src/main/java/com/superbiz/agent/dto/FeedbackResponse.java b/src/main/java/com/superbiz/agent/dto/FeedbackResponse.java index 06d24c8..52306a0 100644 --- a/src/main/java/com/superbiz/agent/dto/FeedbackResponse.java +++ b/src/main/java/com/superbiz/agent/dto/FeedbackResponse.java @@ -10,5 +10,4 @@ public class FeedbackResponse { private String message; private String caseId; private String runId; - private boolean fallbackToLatestRun; } diff --git a/src/main/java/com/superbiz/agent/eval/DiagnosisTraceEvaluator.java b/src/main/java/com/superbiz/agent/eval/DiagnosisTraceEvaluator.java index c4f4c7e..4f10999 100644 --- a/src/main/java/com/superbiz/agent/eval/DiagnosisTraceEvaluator.java +++ b/src/main/java/com/superbiz/agent/eval/DiagnosisTraceEvaluator.java @@ -81,7 +81,7 @@ public class DiagnosisTraceEvaluator { public DiagnosisEvalResult evaluate(DiagnosisEvalCase evalCase, DiagnosisTraceResponse trace) { List failedChecks = new ArrayList<>(); - String answer = trace.getSession() == null ? "" : nullToEmpty(trace.getSession().getAnswer()); + String answer = trace.getRun() == null ? "" : nullToEmpty(trace.getRun().getAnswer()); String normalizedAnswer = answer.toLowerCase(Locale.ROOT); int requiredKeywordCount = size(evalCase.getExpectedRootCauseKeywords()); @@ -134,7 +134,7 @@ public class DiagnosisTraceEvaluator { gatekeeperStatus, gatekeeperRuleSetVersion, promptAuditVersion, composerStatus)); Integer toolCallCount = trace.getToolInvocations() == null ? 0 : trace.getToolInvocations().size(); - Integer durationMs = trace.getSession() == null ? null : trace.getSession().getTotalDurationMs(); + Integer durationMs = trace.getRun() == null ? null : trace.getRun().getTotalDurationMs(); return DiagnosisEvalResult.builder() .caseId(evalCase.getId()) @@ -407,10 +407,10 @@ public class DiagnosisTraceEvaluator { } private Object nestedValue(DiagnosisTraceResponse trace, String firstKey, String secondKey) { - if (trace.getSession() == null || trace.getSession().getSelfEvaluation() == null) { + if (trace.getRun() == null || trace.getRun().getSelfEvaluation() == null) { return null; } - Object first = trace.getSession().getSelfEvaluation().get(firstKey); + Object first = trace.getRun().getSelfEvaluation().get(firstKey); if (!(first instanceof Map map)) { return null; } diff --git a/src/main/java/com/superbiz/agent/repository/DiagnosisSessionRepository.java b/src/main/java/com/superbiz/agent/repository/DiagnosisSessionRepository.java deleted file mode 100644 index e8f2235..0000000 --- a/src/main/java/com/superbiz/agent/repository/DiagnosisSessionRepository.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.superbiz.agent.repository; - -import com.superbiz.agent.domain.entity.DiagnosisSession; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -import java.util.Optional; - -@Repository -public interface DiagnosisSessionRepository extends JpaRepository { - Optional findBySessionId(String sessionId); -} diff --git a/src/main/java/com/superbiz/agent/service/AiOpsService.java b/src/main/java/com/superbiz/agent/service/AiOpsService.java index 6525111..69bc070 100644 --- a/src/main/java/com/superbiz/agent/service/AiOpsService.java +++ b/src/main/java/com/superbiz/agent/service/AiOpsService.java @@ -15,13 +15,11 @@ import com.superbiz.agent.agent.tool.QueryLogsTools; import com.superbiz.agent.agent.tool.QueryMetricsTools; import com.superbiz.agent.domain.entity.AgentStep; import com.superbiz.agent.domain.entity.DiagnosisRun; -import com.superbiz.agent.domain.entity.DiagnosisSession; import com.superbiz.agent.dto.AIOpsRequest; import com.superbiz.agent.hook.AgentLoggingHook; import com.superbiz.agent.hook.PlannerSkillMetadataHook; import com.superbiz.agent.repository.AgentStepRepository; import com.superbiz.agent.repository.DiagnosisRunRepository; -import com.superbiz.agent.repository.DiagnosisSessionRepository; import com.superbiz.agent.repository.ToolInvocationRepository; import com.superbiz.agent.util.SessionContextHolder; import org.slf4j.Logger; @@ -65,9 +63,6 @@ public class AiOpsService { @Autowired private AiOpsPromptProperties promptProperties; - @Autowired - private DiagnosisSessionRepository diagnosisSessionRepository; - @Autowired private DiagnosisRunRepository diagnosisRunRepository; @@ -191,14 +186,6 @@ public class AiOpsService { return "run-" + UUID.randomUUID(); } - public void persistFinalReport(String sessionId, String finalReport) { - persistFinalReport(sessionId, finalReport, null); - } - - public void persistFinalReport(String sessionId, String finalReport, AIOpsRequest request) { - persistLegacyFinalReport(sessionId, finalReport, request); - } - public void persistFinalReport(String sessionId, String runId, String finalReport, AIOpsRequest request) { if (isBlank(sessionId) || isBlank(runId) || isBlank(finalReport)) { return; @@ -214,21 +201,6 @@ public class AiOpsService { }); } - private void persistLegacyFinalReport(String sessionId, String finalReport, AIOpsRequest request) { - if (isBlank(sessionId) || isBlank(finalReport)) { - return; - } - diagnosisSessionRepository.findBySessionId(sessionId.trim()).ifPresent(session -> { - session.setAnswer(finalReport); - List invocations = - toolInvocationRepository.findBySessionIdOrderByIdAsc(session.getSessionId()); - Map evaluation = aiOpsRuleEvaluationService.evaluate(request, finalReport, invocations); - session.setSelfEvaluation(selfEvaluationMergeService.mergeAiOpsRuleEvaluation( - session.getSelfEvaluation(), evaluation)); - diagnosisSessionRepository.save(session); - }); - } - String buildQuerySummary(AIOpsRequest request) { if (request == null) { return "AI Ops alert analysis"; diff --git a/src/main/java/com/superbiz/agent/service/CaseLibraryService.java b/src/main/java/com/superbiz/agent/service/CaseLibraryService.java index 9b94a52..5eb8b33 100644 --- a/src/main/java/com/superbiz/agent/service/CaseLibraryService.java +++ b/src/main/java/com/superbiz/agent/service/CaseLibraryService.java @@ -2,7 +2,6 @@ package com.superbiz.agent.service; import com.superbiz.agent.domain.entity.CaseLibrary; import com.superbiz.agent.domain.entity.DiagnosisRun; -import com.superbiz.agent.domain.entity.DiagnosisSession; import com.superbiz.agent.domain.enums.FaultCategory; import com.superbiz.agent.domain.enums.SourceType; import com.superbiz.agent.repository.CaseLibraryRepository; @@ -25,11 +24,6 @@ public class CaseLibraryService { return createFromSource(run.getRunId(), run.getQuery(), run.getAnswer(), "runId=" + run.getRunId()); } - public CaseLibrary createFromSession(DiagnosisSession session) { - return createFromSource(session.getSessionId(), session.getQuery(), session.getAnswer(), - "sessionId=" + session.getSessionId()); - } - private CaseLibrary createFromSource(String diagnosisId, String query, String answer, String logContext) { return caseLibraryRepository.findByDiagnosisId(diagnosisId) .orElseGet(() -> { diff --git a/src/main/java/com/superbiz/agent/service/DiagnosisTraceService.java b/src/main/java/com/superbiz/agent/service/DiagnosisTraceService.java index 29b25db..8092cda 100644 --- a/src/main/java/com/superbiz/agent/service/DiagnosisTraceService.java +++ b/src/main/java/com/superbiz/agent/service/DiagnosisTraceService.java @@ -5,14 +5,12 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.superbiz.agent.domain.entity.AgentStep; import com.superbiz.agent.domain.entity.ChatSession; import com.superbiz.agent.domain.entity.DiagnosisRun; -import com.superbiz.agent.domain.entity.DiagnosisSession; import com.superbiz.agent.domain.entity.ToolInvocation; import com.superbiz.agent.dto.DiagnosisTraceResponse; import com.superbiz.agent.exception.SessionNotFoundException; import com.superbiz.agent.repository.AgentStepRepository; import com.superbiz.agent.repository.ChatSessionRepository; import com.superbiz.agent.repository.DiagnosisRunRepository; -import com.superbiz.agent.repository.DiagnosisSessionRepository; import com.superbiz.agent.repository.ToolInvocationRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -28,27 +26,18 @@ public class DiagnosisTraceService { private static final TypeReference> JSON_MAP_TYPE = new TypeReference<>() { }; - private final DiagnosisSessionRepository diagnosisSessionRepository; private final ChatSessionRepository chatSessionRepository; private final DiagnosisRunRepository diagnosisRunRepository; private final AgentStepRepository agentStepRepository; private final ToolInvocationRepository toolInvocationRepository; private final ObjectMapper objectMapper; - public DiagnosisTraceResponse getTrace(String sessionId) { - return getTrace(sessionId, null); - } - public DiagnosisTraceResponse getTrace(String sessionId, String runId) { - if (runId != null && !runId.isBlank()) { - DiagnosisRun run = diagnosisRunRepository.findBySessionIdAndRunId(sessionId, runId) - .orElseThrow(() -> buildRunLookupException(sessionId, runId)); - return buildRunTraceResponse(sessionId, run); - } - - return diagnosisRunRepository.findFirstBySessionIdOrderByCreatedAtDescIdDesc(sessionId) - .map(run -> buildRunTraceResponse(sessionId, run)) - .orElseGet(() -> buildLegacyTraceResponse(sessionId)); + requireText(sessionId, "sessionId"); + requireText(runId, "runId"); + DiagnosisRun run = diagnosisRunRepository.findBySessionIdAndRunId(sessionId, runId) + .orElseThrow(() -> buildRunLookupException(sessionId, runId)); + return buildRunTraceResponse(sessionId, run); } public List listRunSummaries(String sessionId) { @@ -78,7 +67,6 @@ public class DiagnosisTraceService { .chatSession(chatSessionRepository.findBySessionId(sessionId) .map(this::toChatSessionTrace) .orElse(null)) - .session(toSessionTrace(runTrace)) .run(runTrace) .steps(steps.stream().map(this::toAgentStepTrace).toList()) .toolInvocations(toolInvocations.stream().map(this::toToolInvocationTrace).toList()) @@ -86,25 +74,6 @@ public class DiagnosisTraceService { .build(); } - private DiagnosisTraceResponse buildLegacyTraceResponse(String sessionId) { - DiagnosisSession session = diagnosisSessionRepository.findBySessionId(sessionId) - .orElseThrow(() -> new SessionNotFoundException(sessionId)); - List steps = orderStepsForTrace(agentStepRepository.findBySessionId(sessionId)); - List toolInvocations = toolInvocationRepository.findBySessionIdOrderByIdAsc(sessionId); - - return DiagnosisTraceResponse.builder() - .runId(null) - .chatSession(chatSessionRepository.findBySessionId(sessionId) - .map(this::toChatSessionTrace) - .orElse(null)) - .session(toSessionTrace(session)) - .run(null) - .steps(steps.stream().map(this::toAgentStepTrace).toList()) - .toolInvocations(toolInvocations.stream().map(this::toToolInvocationTrace).toList()) - .summary(toSummary(session, steps, toolInvocations)) - .build(); - } - private List orderStepsForTrace(List steps) { return steps.stream() .sorted(Comparator @@ -126,46 +95,6 @@ public class DiagnosisTraceService { .build(); } - private DiagnosisTraceResponse.SessionTrace toSessionTrace(DiagnosisSession session) { - return DiagnosisTraceResponse.SessionTrace.builder() - .id(session.getId()) - .sessionId(session.getSessionId()) - .query(session.getQuery()) - .status(session.getStatus()) - .agentFlow(session.getAgentFlow()) - .totalDurationMs(session.getTotalDurationMs()) - .totalTokenCount(session.getTotalTokenCount()) - .stepCount(session.getStepCount()) - .toolCallCount(session.getToolCallCount()) - .answer(session.getAnswer()) - .selfEvaluationRaw(session.getSelfEvaluation()) - .selfEvaluation(parseJsonObject(session.getSelfEvaluation())) - .feedback(session.getFeedback()) - .createdAt(session.getCreatedAt()) - .updatedAt(session.getUpdatedAt()) - .build(); - } - - private DiagnosisTraceResponse.SessionTrace toSessionTrace(DiagnosisTraceResponse.RunTrace run) { - return DiagnosisTraceResponse.SessionTrace.builder() - .id(run.getId()) - .sessionId(run.getSessionId()) - .query(run.getQuery()) - .status(run.getStatus()) - .agentFlow(run.getAgentFlow()) - .totalDurationMs(run.getTotalDurationMs()) - .totalTokenCount(run.getTotalTokenCount()) - .stepCount(run.getStepCount()) - .toolCallCount(run.getToolCallCount()) - .answer(run.getAnswer()) - .selfEvaluationRaw(run.getSelfEvaluationRaw()) - .selfEvaluation(run.getSelfEvaluation()) - .feedback(run.getFeedback()) - .createdAt(run.getCreatedAt()) - .updatedAt(run.getUpdatedAt()) - .build(); - } - private DiagnosisTraceResponse.RunTrace toRunTrace(DiagnosisRun run) { return DiagnosisTraceResponse.RunTrace.builder() .id(run.getId()) @@ -246,24 +175,6 @@ public class DiagnosisTraceService { .build(); } - private DiagnosisTraceResponse.TraceSummary toSummary( - DiagnosisSession session, - List steps, - List toolInvocations - ) { - Map selfEvaluation = parseJsonObject(session.getSelfEvaluation()); - return DiagnosisTraceResponse.TraceSummary.builder() - .resolvedRunId(null) - .persistedStepCount(defaultInt(session.getStepCount())) - .returnedStepCount(steps.size()) - .persistedToolCallCount(defaultInt(session.getToolCallCount())) - .returnedToolCallCount(toolInvocations.size()) - .hasVerifierEvaluation(selfEvaluation != null && selfEvaluation.containsKey("verifier_evaluation")) - .hasAiOpsRuleEvaluation(selfEvaluation != null && selfEvaluation.containsKey("aiops_rule_evaluation")) - .hasFeedback(session.getFeedback() != null && !session.getFeedback().isBlank()) - .build(); - } - private DiagnosisTraceResponse.TraceSummary toSummary( DiagnosisTraceResponse.RunTrace run, List steps, @@ -297,6 +208,12 @@ public class DiagnosisTraceService { return value == null ? 0 : value; } + private void requireText(String value, String name) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(name + " is required for the v2 trace contract"); + } + } + private String preview(String text, int maxLength) { if (text == null) { return null; diff --git a/src/main/java/com/superbiz/agent/service/EvaluationService.java b/src/main/java/com/superbiz/agent/service/EvaluationService.java index a87fa29..069dabb 100644 --- a/src/main/java/com/superbiz/agent/service/EvaluationService.java +++ b/src/main/java/com/superbiz/agent/service/EvaluationService.java @@ -1,10 +1,8 @@ package com.superbiz.agent.service; -import com.superbiz.agent.domain.entity.DiagnosisSession; import com.superbiz.agent.domain.entity.DiagnosisRun; import com.superbiz.agent.domain.entity.ToolInvocation; import com.superbiz.agent.repository.DiagnosisRunRepository; -import com.superbiz.agent.repository.DiagnosisSessionRepository; import com.superbiz.agent.repository.ToolInvocationRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -28,9 +26,6 @@ public class EvaluationService { private static final Logger logger = LoggerFactory.getLogger(EvaluationService.class); - @Autowired - private DiagnosisSessionRepository diagnosisSessionRepository; - @Autowired private DiagnosisRunRepository diagnosisRunRepository; @@ -40,22 +35,6 @@ public class EvaluationService { @Autowired private SelfEvaluationMergeService selfEvaluationMergeService; - @Async - public void evaluate(String sessionId, String answer) { - diagnosisSessionRepository.findBySessionId(sessionId).ifPresent(session -> { - try { - List toolInvocations = toolInvocationRepository.findBySessionId(sessionId); - Map ruleEvaluation = evaluateWithRules(session.getStatus(), toolInvocations); - String merged = selfEvaluationMergeService.mergeRuleEvaluation(session.getSelfEvaluation(), ruleEvaluation); - session.setSelfEvaluation(merged); - diagnosisSessionRepository.save(session); - logger.info("证据评分已写入: sessionId={}, result={}", sessionId, merged); - } catch (Exception e) { - logger.error("评分失败: sessionId={}", sessionId, e); - } - }); - } - @Async public void evaluateRun(String runId, String answer) { diagnosisRunRepository.findByRunId(runId).ifPresent(run -> { diff --git a/src/main/java/com/superbiz/agent/service/ExecutorGatekeeperService.java b/src/main/java/com/superbiz/agent/service/ExecutorGatekeeperService.java index 5c1e96b..a163fe4 100644 --- a/src/main/java/com/superbiz/agent/service/ExecutorGatekeeperService.java +++ b/src/main/java/com/superbiz/agent/service/ExecutorGatekeeperService.java @@ -55,21 +55,6 @@ public class ExecutorGatekeeperService { : ruleCatalog; } - public Map validate(String sessionId, - Map structuredOutput, - Map parseStatus) { - GatekeeperResult result = new GatekeeperResult(ruleCatalog); - validateSchema(structuredOutput, parseStatus, result); - if (structuredOutput != null) { - List invocations = sessionId == null || sessionId.isBlank() - ? List.of() - : toolInvocationRepository.findBySessionIdOrderByIdAsc(sessionId); - validateInvocationRefs("session_id", sessionId, invocations, structuredOutput, result); - importWarnings(structuredOutput, result); - } - return result.toMap(); - } - public Map validateRun(String runId, Map structuredOutput, Map parseStatus) { diff --git a/src/main/java/com/superbiz/agent/service/FeedbackService.java b/src/main/java/com/superbiz/agent/service/FeedbackService.java index ec914ef..bb51232 100644 --- a/src/main/java/com/superbiz/agent/service/FeedbackService.java +++ b/src/main/java/com/superbiz/agent/service/FeedbackService.java @@ -1,10 +1,8 @@ package com.superbiz.agent.service; import com.superbiz.agent.domain.entity.DiagnosisRun; -import com.superbiz.agent.domain.entity.DiagnosisSession; import com.superbiz.agent.dto.FeedbackResponse; import com.superbiz.agent.repository.DiagnosisRunRepository; -import com.superbiz.agent.repository.DiagnosisSessionRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -18,23 +16,19 @@ public class FeedbackService { private static final String FEEDBACK_USEFUL = "useful"; private static final String FEEDBACK_NOT_USEFUL = "not_useful"; - @Autowired - private DiagnosisSessionRepository diagnosisSessionRepository; - @Autowired private DiagnosisRunRepository diagnosisRunRepository; @Autowired private CaseLibraryService caseLibraryService; - public FeedbackResponse submitFeedback(String sessionId, String feedback) { - return submitFeedback(sessionId, null, feedback); - } - public FeedbackResponse submitFeedback(String sessionId, String runId, String feedback) { if (sessionId == null || sessionId.isBlank()) { return FeedbackResponse.builder().success(false).message("sessionId 不能为空").build(); } + if (runId == null || runId.isBlank()) { + return FeedbackResponse.builder().success(false).message("runId 不能为空").build(); + } if (!FEEDBACK_USEFUL.equals(feedback) && !FEEDBACK_NOT_USEFUL.equals(feedback)) { return FeedbackResponse.builder() .success(false) @@ -42,19 +36,12 @@ public class FeedbackService { .build(); } - if (runId != null && !runId.isBlank()) { - return submitRunFeedback(sessionId, runId, feedback, false); - } - - return diagnosisRunRepository.findFirstBySessionIdOrderByCreatedAtDescIdDesc(sessionId) - .map(run -> submitRunFeedback(sessionId, run.getRunId(), feedback, true)) - .orElseGet(() -> submitLegacySessionFeedback(sessionId, feedback)); + return submitRunFeedback(sessionId, runId, feedback); } private FeedbackResponse submitRunFeedback(String sessionId, String runId, - String feedback, - boolean fallbackToLatestRun) { + String feedback) { DiagnosisRun run = diagnosisRunRepository.findBySessionIdAndRunId(sessionId, runId).orElse(null); if (run == null) { if (diagnosisRunRepository.findByRunId(runId).isPresent()) { @@ -62,14 +49,12 @@ public class FeedbackService { .success(false) .message("runId does not belong to sessionId") .runId(runId) - .fallbackToLatestRun(fallbackToLatestRun) .build(); } return FeedbackResponse.builder() .success(false) .message("run 不存在") .runId(runId) - .fallbackToLatestRun(fallbackToLatestRun) .build(); } @@ -82,40 +67,14 @@ public class FeedbackService { } diagnosisRunRepository.save(run); - logger.info("反馈已记录: sessionId={}, runId={}, feedback={}, fallbackToLatestRun={}, caseId={}", - sessionId, runId, feedback, fallbackToLatestRun, caseId); + logger.info("反馈已记录: sessionId={}, runId={}, feedback={}, caseId={}", + sessionId, runId, feedback, caseId); return FeedbackResponse.builder() .success(true) .message("反馈已记录") .caseId(caseId) .runId(runId) - .fallbackToLatestRun(fallbackToLatestRun) - .build(); - } - - private FeedbackResponse submitLegacySessionFeedback(String sessionId, String feedback) { - DiagnosisSession session = diagnosisSessionRepository.findBySessionId(sessionId).orElse(null); - if (session == null) { - return FeedbackResponse.builder().success(false).message("会话不存在").build(); - } - - session.setFeedback(feedback); - - String caseId = null; - if (FEEDBACK_USEFUL.equals(feedback)) { - var caseLibrary = caseLibraryService.createFromSession(session); - caseId = caseLibrary.getCaseId(); - } - - diagnosisSessionRepository.save(session); - logger.info("历史反馈已记录: sessionId={}, feedback={}, caseId={}", sessionId, feedback, caseId); - - return FeedbackResponse.builder() - .success(true) - .message("反馈已记录") - .caseId(caseId) - .fallbackToLatestRun(false) .build(); } } diff --git a/src/main/java/com/superbiz/agent/service/SelfEvaluationMergeService.java b/src/main/java/com/superbiz/agent/service/SelfEvaluationMergeService.java index 42dfafa..71dca70 100644 --- a/src/main/java/com/superbiz/agent/service/SelfEvaluationMergeService.java +++ b/src/main/java/com/superbiz/agent/service/SelfEvaluationMergeService.java @@ -9,7 +9,7 @@ import java.util.LinkedHashMap; import java.util.Map; /** - * 统一维护 diagnosis_session.self_evaluation JSON 容器。 + * 统一维护 diagnosis_run.self_evaluation JSON 容器。 */ @Slf4j @Service @@ -48,21 +48,6 @@ public class SelfEvaluationMergeService { } Map parsed = objectMapper.readValue(existingJson, MAP_TYPE); - if (parsed.containsKey("rule_evaluation") - || parsed.containsKey("verifier_evaluation") - || parsed.containsKey("aiops_rule_evaluation")) { - return new LinkedHashMap<>(parsed); - } - - LinkedHashMap wrapped = new LinkedHashMap<>(); - if (parsed.containsKey("evidence_score") || parsed.containsKey("source") || parsed.containsKey("factors")) { - wrapped.put("rule_evaluation", parsed); - return wrapped; - } - if (parsed.containsKey("verdict") || parsed.containsKey("groundedness_score") || parsed.containsKey("facts_checked")) { - wrapped.put("verifier_evaluation", parsed); - return wrapped; - } return new LinkedHashMap<>(parsed); } diff --git a/src/main/resources/static/app.js b/src/main/resources/static/app.js index e31ee3c..4bc934c 100644 --- a/src/main/resources/static/app.js +++ b/src/main/resources/static/app.js @@ -995,7 +995,10 @@ class SuperBizAgentApp { // 提交反馈 async submitFeedback(feedback, barElement, sessionId, runId = '') { - if (!sessionId) return; + if (!sessionId || !runId) { + this.showNotification('反馈需要明确的运行 ID', 'error'); + return; + } barElement.querySelectorAll('.feedback-btn').forEach(btn => btn.disabled = true); @@ -1003,7 +1006,7 @@ class SuperBizAgentApp { const response = await fetch(`${this.apiBaseUrl}/feedback`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ sessionId, runId: runId || undefined, feedback }) + body: JSON.stringify({ sessionId, runId, feedback }) }); const data = await response.json(); if (data.success) { diff --git a/src/main/resources/static/trace.js b/src/main/resources/static/trace.js index 79cc5e9..00c57c4 100644 --- a/src/main/resources/static/trace.js +++ b/src/main/resources/static/trace.js @@ -36,7 +36,7 @@ class TraceWorkbench { bindEvents() { this.sessionForm.addEventListener('submit', (event) => { event.preventDefault(); - this.loadTrace(this.sessionIdInput.value.trim(), ''); + this.loadLatestRun(this.sessionIdInput.value.trim()); }); this.sessionIdInput.addEventListener('focus', () => { @@ -69,7 +69,11 @@ class TraceWorkbench { const runId = params.get('runId') || ''; if (sessionId) { this.sessionIdInput.value = sessionId; - this.loadTrace(sessionId, runId); + if (runId) { + this.loadTrace(sessionId, runId); + } else { + this.loadLatestRun(sessionId); + } return; } this.renderEmpty(); @@ -239,7 +243,7 @@ class TraceWorkbench { chooseSession(sessionId) { this.sessionIdInput.value = sessionId; this.closeSessionOptions(); - this.loadTrace(sessionId, ''); + this.loadLatestRun(sessionId); } shortSessionId(sessionId) { @@ -266,11 +270,30 @@ class TraceWorkbench { }); } - async loadTrace(sessionId, runId = '') { + async loadLatestRun(sessionId) { if (!sessionId) { this.setState('请先输入会话 ID。', 'error'); return; } + try { + const response = await fetch(`/api/chat/session/${encodeURIComponent(sessionId)}/runs`); + const runs = await this.handleResponse(response); + if (!Array.isArray(runs) || runs.length === 0 || !runs[0].runId) { + throw new Error('该会话没有可查询的运行记录。'); + } + await this.loadTrace(sessionId, runs[0].runId); + } catch (error) { + this.trace = null; + this.renderEmpty(); + this.setState(error.message, 'error'); + } + } + + async loadTrace(sessionId, runId) { + if (!sessionId || !runId) { + this.setState('Trace v2 需要会话 ID 和运行 ID。', 'error'); + return; + } const runSuffix = runId ? ` / ${runId}` : ''; this.setState(`正在加载 ${sessionId}${runSuffix}...`, 'loading'); @@ -279,9 +302,7 @@ class TraceWorkbench { try { const url = new URL(`/api/diagnosis/${encodeURIComponent(sessionId)}/trace`, window.location.origin); - if (runId) { - url.searchParams.set('runId', runId); - } + url.searchParams.set('runId', runId); const response = await fetch(url.toString()); const data = await this.handleResponse(response); this.trace = data; diff --git a/src/test/java/com/superbiz/agent/controller/FeedbackControllerTest.java b/src/test/java/com/superbiz/agent/controller/FeedbackControllerTest.java index 9b83889..0032868 100644 --- a/src/test/java/com/superbiz/agent/controller/FeedbackControllerTest.java +++ b/src/test/java/com/superbiz/agent/controller/FeedbackControllerTest.java @@ -30,7 +30,6 @@ class FeedbackControllerTest { .thenReturn(FeedbackResponse.builder() .success(true) .runId("run-1") - .fallbackToLatestRun(false) .build()); ResponseEntity response = controller.submitFeedback(request); diff --git a/src/test/java/com/superbiz/agent/eval/DiagnosisTraceEvaluatorTest.java b/src/test/java/com/superbiz/agent/eval/DiagnosisTraceEvaluatorTest.java index 2ea6880..65c01cb 100644 --- a/src/test/java/com/superbiz/agent/eval/DiagnosisTraceEvaluatorTest.java +++ b/src/test/java/com/superbiz/agent/eval/DiagnosisTraceEvaluatorTest.java @@ -92,7 +92,7 @@ class DiagnosisTraceEvaluatorTest { .allowedVerdicts(List.of("REJECT")) .build(); DiagnosisTraceResponse trace = DiagnosisTraceResponse.builder() - .session(DiagnosisTraceResponse.SessionTrace.builder() + .run(DiagnosisTraceResponse.RunTrace.builder() .answer("EXECUTOR_FINAL_ANSWER") .selfEvaluation(java.util.Map.of( "verifier_evaluation", java.util.Map.of("verdict", "REJECT"))) @@ -116,7 +116,7 @@ class DiagnosisTraceEvaluatorTest { .allowedVerdicts(List.of("LOW_CONFID", "PASS")) .build(); DiagnosisTraceResponse trace = DiagnosisTraceResponse.builder() - .session(DiagnosisTraceResponse.SessionTrace.builder() + .run(DiagnosisTraceResponse.RunTrace.builder() .answer("以下结论基于当前已获取证据,仍存在部分证据缺口,请谨慎参考。") .selfEvaluation(java.util.Map.of( "verifier_evaluation", java.util.Map.of( @@ -152,7 +152,7 @@ class DiagnosisTraceEvaluatorTest { .requireV2AuditClosure(true) .build(); DiagnosisTraceResponse trace = DiagnosisTraceResponse.builder() - .session(DiagnosisTraceResponse.SessionTrace.builder() + .run(DiagnosisTraceResponse.RunTrace.builder() .answer("安全回答") .selfEvaluation(java.util.Map.of( "verifier_evaluation", java.util.Map.of( @@ -186,7 +186,7 @@ class DiagnosisTraceEvaluatorTest { .expectedGatekeeperRuleSetVersion("gatekeeper-rules-v1") .build(); DiagnosisTraceResponse trace = DiagnosisTraceResponse.builder() - .session(DiagnosisTraceResponse.SessionTrace.builder() + .run(DiagnosisTraceResponse.RunTrace.builder() .answer("安全回答") .selfEvaluation(java.util.Map.of( "verifier_evaluation", java.util.Map.of( @@ -220,7 +220,7 @@ class DiagnosisTraceEvaluatorTest { .expectedPromptVersions(java.util.Map.of("chat_executor", "chat-executor-v2")) .build(); DiagnosisTraceResponse trace = DiagnosisTraceResponse.builder() - .session(DiagnosisTraceResponse.SessionTrace.builder() + .run(DiagnosisTraceResponse.RunTrace.builder() .answer("安全回答") .selfEvaluation(java.util.Map.of( "verifier_evaluation", java.util.Map.of( @@ -257,7 +257,7 @@ class DiagnosisTraceEvaluatorTest { .requireGatekeeperRules(true) .build(); DiagnosisTraceResponse trace = DiagnosisTraceResponse.builder() - .session(DiagnosisTraceResponse.SessionTrace.builder() + .run(DiagnosisTraceResponse.RunTrace.builder() .answer("安全回答") .selfEvaluation(java.util.Map.of( "verifier_evaluation", java.util.Map.of( @@ -290,7 +290,7 @@ class DiagnosisTraceEvaluatorTest { .forbiddenConfirmedClaimKeywords(List.of("主库故障")) .build(); DiagnosisTraceResponse trace = DiagnosisTraceResponse.builder() - .session(DiagnosisTraceResponse.SessionTrace.builder() + .run(DiagnosisTraceResponse.RunTrace.builder() .answer("已经确认主库故障。") .selfEvaluation(java.util.Map.of( "verifier_evaluation", java.util.Map.of( @@ -325,7 +325,7 @@ class DiagnosisTraceEvaluatorTest { .requireV2AuditClosure(true) .build(); DiagnosisTraceResponse trace = DiagnosisTraceResponse.builder() - .session(DiagnosisTraceResponse.SessionTrace.builder() + .run(DiagnosisTraceResponse.RunTrace.builder() .answer("answer_version=executor_evidence_v2") .selfEvaluation(java.util.Map.of( "verifier_evaluation", java.util.Map.of( diff --git a/src/test/java/com/superbiz/agent/repository/DiagnosisSessionRepositoryTest.java b/src/test/java/com/superbiz/agent/repository/DiagnosisSessionRepositoryTest.java deleted file mode 100644 index 199876d..0000000 --- a/src/test/java/com/superbiz/agent/repository/DiagnosisSessionRepositoryTest.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.superbiz.agent.repository; - -import com.superbiz.agent.domain.entity.DiagnosisSession; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import org.springframework.test.context.TestPropertySource; - -import java.util.Optional; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; - -/** - * DiagnosisSessionRepository 单元测试 - */ -@DataJpaTest -@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) -@TestPropertySource(properties = { - "spring.flyway.enabled=true", - "spring.jpa.hibernate.ddl-auto=validate", - "spring.jpa.show-sql=true" -}) -class DiagnosisSessionRepositoryTest { - - @Autowired - private DiagnosisSessionRepository repository; - - @Test - void testSaveAndFindBySessionId() { - String sessionId = UUID.randomUUID().toString().substring(0, 8); - DiagnosisSession session = DiagnosisSession.builder() - .sessionId(sessionId) - .query("测试查询") - .status("RUNNING") - .agentFlow("CHAT") - .build(); - - DiagnosisSession saved = repository.save(session); - assertNotNull(saved.getId()); - assertEquals(sessionId, saved.getSessionId()); - - Optional found = repository.findBySessionId(sessionId); - assertTrue(found.isPresent()); - assertEquals("测试查询", found.get().getQuery()); - assertEquals("CHAT", found.get().getAgentFlow()); - } - - @Test - void testUpdateStatus() { - String sessionId = UUID.randomUUID().toString().substring(0, 8); - DiagnosisSession session = DiagnosisSession.builder() - .sessionId(sessionId) - .query("更新测试") - .status("RUNNING") - .agentFlow("AI_OPS") - .build(); - - DiagnosisSession saved = repository.save(session); - saved.setStatus("SUCCESS"); - saved.setTotalDurationMs(1500); - repository.save(saved); - - DiagnosisSession updated = repository.findBySessionId(sessionId).orElseThrow(); - assertEquals("SUCCESS", updated.getStatus()); - assertEquals(1500, updated.getTotalDurationMs()); - } -} diff --git a/src/test/java/com/superbiz/agent/service/AiOpsServiceTest.java b/src/test/java/com/superbiz/agent/service/AiOpsServiceTest.java index 7a5d93d..ae719f5 100644 --- a/src/test/java/com/superbiz/agent/service/AiOpsServiceTest.java +++ b/src/test/java/com/superbiz/agent/service/AiOpsServiceTest.java @@ -5,12 +5,10 @@ import com.alibaba.cloud.ai.graph.agent.flow.agent.SupervisorAgent; import com.superbiz.agent.config.AiOpsPromptProperties; import com.superbiz.agent.domain.entity.AgentStep; import com.superbiz.agent.domain.entity.DiagnosisRun; -import com.superbiz.agent.domain.entity.DiagnosisSession; import com.superbiz.agent.domain.entity.ToolInvocation; import com.superbiz.agent.dto.AIOpsRequest; import com.superbiz.agent.repository.AgentStepRepository; import com.superbiz.agent.repository.DiagnosisRunRepository; -import com.superbiz.agent.repository.DiagnosisSessionRepository; import com.superbiz.agent.repository.ToolInvocationRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -25,7 +23,6 @@ import static org.mockito.Mockito.*; class AiOpsServiceTest { - private final DiagnosisSessionRepository diagnosisSessionRepository = mock(DiagnosisSessionRepository.class); private final DiagnosisRunRepository diagnosisRunRepository = mock(DiagnosisRunRepository.class); private final AgentStepRepository agentStepRepository = mock(AgentStepRepository.class); private final ToolInvocationRepository toolInvocationRepository = mock(ToolInvocationRepository.class); @@ -34,7 +31,6 @@ class AiOpsServiceTest { @BeforeEach void setUp() { - ReflectionTestUtils.setField(service, "diagnosisSessionRepository", diagnosisSessionRepository); ReflectionTestUtils.setField(service, "diagnosisRunRepository", diagnosisRunRepository); ReflectionTestUtils.setField(service, "agentStepRepository", agentStepRepository); ReflectionTestUtils.setField(service, "toolInvocationRepository", toolInvocationRepository); @@ -176,32 +172,13 @@ class AiOpsServiceTest { assertTrue(run.getSelfEvaluation().contains("aiops_rule_evaluation")); verify(toolInvocationRepository).findByRunIdOrderByIdAsc("run-aiops-001"); verify(diagnosisRunRepository).save(run); - verify(diagnosisSessionRepository, never()).save(any()); - } - - @Test - void legacyPersistFinalReportStillUpdatesHistoricalDiagnosisSession() { - DiagnosisSession session = DiagnosisSession.builder() - .sessionId("legacy-aiops-session") - .query("AI Ops alert analysis") - .status("SUCCESS") - .agentFlow("AI_OPS") - .build(); - when(diagnosisSessionRepository.findBySessionId("legacy-aiops-session")).thenReturn(Optional.of(session)); - when(toolInvocationRepository.findBySessionIdOrderByIdAsc("legacy-aiops-session")).thenReturn(List.of()); - - service.persistFinalReport("legacy-aiops-session", "# 告警分析报告\nLegacy analysis with evidence summary."); - - assertEquals("# 告警分析报告\nLegacy analysis with evidence summary.", session.getAnswer()); - assertTrue(session.getSelfEvaluation().contains("aiops_rule_evaluation")); - verify(diagnosisSessionRepository).save(session); } @Test void persistFinalReportSkipsBlankInput() { - service.persistFinalReport("aiops-session-001", " "); + service.persistFinalReport("aiops-session-001", "run-aiops-001", " ", null); - verifyNoInteractions(diagnosisSessionRepository, diagnosisRunRepository); + verifyNoInteractions(diagnosisRunRepository); } @Test diff --git a/src/test/java/com/superbiz/agent/service/CaseLibraryServiceTest.java b/src/test/java/com/superbiz/agent/service/CaseLibraryServiceTest.java index d0e065b..ec9d3de 100644 --- a/src/test/java/com/superbiz/agent/service/CaseLibraryServiceTest.java +++ b/src/test/java/com/superbiz/agent/service/CaseLibraryServiceTest.java @@ -2,12 +2,10 @@ package com.superbiz.agent.service; import com.superbiz.agent.domain.entity.CaseLibrary; import com.superbiz.agent.domain.entity.DiagnosisRun; -import com.superbiz.agent.domain.entity.DiagnosisSession; import com.superbiz.agent.domain.enums.SourceType; import com.superbiz.agent.repository.CaseLibraryRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; import org.springframework.test.util.ReflectionTestUtils; import java.util.Optional; @@ -70,21 +68,4 @@ class CaseLibraryServiceTest { verify(caseLibraryRepository, never()).save(any()); } - @Test - void createFromSessionPreservesLegacySessionIdSemantics() { - DiagnosisSession session = DiagnosisSession.builder() - .sessionId("legacy-session") - .query("legacy query") - .answer("legacy answer") - .build(); - when(caseLibraryRepository.findByDiagnosisId("legacy-session")).thenReturn(Optional.empty()); - when(caseLibraryRepository.save(any(CaseLibrary.class))).thenAnswer(invocation -> invocation.getArgument(0)); - - service.createFromSession(session); - - ArgumentCaptor captor = ArgumentCaptor.forClass(CaseLibrary.class); - verify(caseLibraryRepository).save(captor.capture()); - assertEquals("legacy-session", captor.getValue().getDiagnosisId()); - assertEquals("legacy answer", captor.getValue().getRootCause()); - } } diff --git a/src/test/java/com/superbiz/agent/service/DiagnosisTraceServiceTest.java b/src/test/java/com/superbiz/agent/service/DiagnosisTraceServiceTest.java index c37e633..d94a87b 100644 --- a/src/test/java/com/superbiz/agent/service/DiagnosisTraceServiceTest.java +++ b/src/test/java/com/superbiz/agent/service/DiagnosisTraceServiceTest.java @@ -1,18 +1,16 @@ package com.superbiz.agent.service; -import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.superbiz.agent.domain.entity.AgentStep; import com.superbiz.agent.domain.entity.ChatSession; import com.superbiz.agent.domain.entity.DiagnosisRun; -import com.superbiz.agent.domain.entity.DiagnosisSession; import com.superbiz.agent.domain.entity.ToolInvocation; import com.superbiz.agent.dto.DiagnosisTraceResponse; import com.superbiz.agent.exception.SessionNotFoundException; import com.superbiz.agent.repository.AgentStepRepository; import com.superbiz.agent.repository.ChatSessionRepository; import com.superbiz.agent.repository.DiagnosisRunRepository; -import com.superbiz.agent.repository.DiagnosisSessionRepository; import com.superbiz.agent.repository.ToolInvocationRepository; import org.junit.jupiter.api.Test; @@ -34,137 +32,61 @@ import static org.mockito.Mockito.when; class DiagnosisTraceServiceTest { - private final DiagnosisSessionRepository diagnosisSessionRepository = mock(DiagnosisSessionRepository.class); private final ChatSessionRepository chatSessionRepository = mock(ChatSessionRepository.class); private final DiagnosisRunRepository diagnosisRunRepository = mock(DiagnosisRunRepository.class); private final AgentStepRepository agentStepRepository = mock(AgentStepRepository.class); private final ToolInvocationRepository toolInvocationRepository = mock(ToolInvocationRepository.class); + private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); private final DiagnosisTraceService service = new DiagnosisTraceService( - diagnosisSessionRepository, chatSessionRepository, diagnosisRunRepository, agentStepRepository, toolInvocationRepository, - new ObjectMapper() - ); + objectMapper); @Test - void getTraceWithoutRunIdResolvesLatestRun() { - String sessionId = "trace-session-latest"; - LocalDateTime base = LocalDateTime.of(2026, 7, 10, 10, 0); - DiagnosisRun latest = run(2L, sessionId, "run-latest", "second question", base.plusMinutes(1)); - AgentStep step = step(20L, sessionId, "run-latest", 0, "composer", base.plusMinutes(1)); - ToolInvocation invocation = invocation(30L, sessionId, "run-latest", "query_metrics", base.plusMinutes(1)); - - when(diagnosisRunRepository.findFirstBySessionIdOrderByCreatedAtDescIdDesc(sessionId)) - .thenReturn(Optional.of(latest)); - when(chatSessionRepository.findBySessionId(sessionId)).thenReturn(Optional.of(chatSession(sessionId))); - when(agentStepRepository.findByRunIdOrderByStepIndex("run-latest")).thenReturn(List.of(step)); - when(toolInvocationRepository.findByRunIdOrderByIdAsc("run-latest")).thenReturn(List.of(invocation)); - - DiagnosisTraceResponse response = service.getTrace(sessionId); - - assertEquals("run-latest", response.getRunId()); - assertEquals("run-latest", response.getRun().getRunId()); - assertEquals("run-latest", response.getSummary().getResolvedRunId()); - assertEquals("second question", response.getSession().getQuery()); - assertEquals("run-latest", response.getSteps().get(0).getRunId()); - assertEquals("run-latest", response.getToolInvocations().get(0).getRunId()); - assertEquals(1, response.getChatSession().getMessagePairCount()); - } - - @Test - void getTraceWithRunIdReturnsExactFirstRun() { - String sessionId = "trace-session-exact"; - DiagnosisRun first = run(1L, sessionId, "run-first", "first question", - LocalDateTime.of(2026, 7, 10, 10, 0)); - AgentStep firstStep = step(10L, sessionId, "run-first", 0, "planner", first.getCreatedAt()); - - when(diagnosisRunRepository.findBySessionIdAndRunId(sessionId, "run-first")) - .thenReturn(Optional.of(first)); - when(agentStepRepository.findByRunIdOrderByStepIndex("run-first")).thenReturn(List.of(firstStep)); - when(toolInvocationRepository.findByRunIdOrderByIdAsc("run-first")).thenReturn(List.of()); - - DiagnosisTraceResponse response = service.getTrace(sessionId, "run-first"); - - assertEquals("run-first", response.getRunId()); - assertEquals("first question", response.getRun().getQuery()); - assertEquals(List.of("planner"), - response.getSteps().stream().map(DiagnosisTraceResponse.AgentStepTrace::getAgentName).toList()); - assertEquals("COMPOSER", - response.getRun().getOrchestrationTrace().get("final_node")); - verify(diagnosisRunRepository, never()).findFirstBySessionIdOrderByCreatedAtDescIdDesc(any()); - } - - @Test - void orchestrationTraceIsParsedOnlyOnRunProjection() { - String sessionId = "trace-session-orchestration"; - DiagnosisRun run = run(4L, sessionId, "run-orchestration", "question", - LocalDateTime.of(2026, 7, 17, 12, 0)); - + void exactTraceReturnsOnlyRunV2Projection() { + String sessionId = "trace-session"; + DiagnosisRun run = run(1L, sessionId, "run-exact", "question"); + AgentStep step = step(2L, sessionId, run.getRunId()); + ToolInvocation invocation = invocation(3L, sessionId, run.getRunId()); when(diagnosisRunRepository.findBySessionIdAndRunId(sessionId, run.getRunId())) .thenReturn(Optional.of(run)); - when(agentStepRepository.findByRunIdOrderByStepIndex(run.getRunId())).thenReturn(List.of()); - when(toolInvocationRepository.findByRunIdOrderByIdAsc(run.getRunId())).thenReturn(List.of()); + when(chatSessionRepository.findBySessionId(sessionId)) + .thenReturn(Optional.of(chatSession(sessionId))); + when(agentStepRepository.findByRunIdOrderByStepIndex(run.getRunId())) + .thenReturn(List.of(step)); + when(toolInvocationRepository.findByRunIdOrderByIdAsc(run.getRunId())) + .thenReturn(List.of(invocation)); DiagnosisTraceResponse response = service.getTrace(sessionId, run.getRunId()); - JsonNode json = new ObjectMapper().findAndRegisterModules() - .valueToTree(response); + JsonNode json = objectMapper.valueToTree(response); - assertEquals("stategraph-v1", - response.getRun().getOrchestrationTrace().get("version")); - assertFalse(json.has("orchestrationTrace")); - assertFalse(json.path("session").has("orchestrationTrace")); + assertEquals(run.getRunId(), response.getRunId()); + assertEquals(run.getRunId(), response.getRun().getRunId()); + assertEquals("stategraph-v1", response.getRun().getOrchestrationTrace().get("version")); + assertEquals(1, response.getSteps().size()); + assertEquals(1, response.getToolInvocations().size()); + assertFalse(json.has("session")); assertTrue(json.path("run").path("orchestrationTrace").isObject()); - assertFalse(json.path("run").has("orchestrationTraceRaw")); } @Test - void nullOrInvalidOrchestrationTraceFailsClosedOnRunProjection() { - String sessionId = "trace-session-invalid-orchestration"; - DiagnosisRun run = run(5L, sessionId, "run-invalid-orchestration", "question", - LocalDateTime.of(2026, 7, 17, 12, 1)); - run.setOrchestrationTrace("not-json"); + void traceRequiresRunId() { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> service.getTrace("session-1", null)); - when(diagnosisRunRepository.findBySessionIdAndRunId(sessionId, run.getRunId())) - .thenReturn(Optional.of(run)); - when(agentStepRepository.findByRunIdOrderByStepIndex(run.getRunId())).thenReturn(List.of()); - when(toolInvocationRepository.findByRunIdOrderByIdAsc(run.getRunId())).thenReturn(List.of()); - - DiagnosisTraceResponse invalid = service.getTrace(sessionId, run.getRunId()); - assertNull(invalid.getRun().getOrchestrationTrace()); - - run.setOrchestrationTrace(null); - DiagnosisTraceResponse historical = service.getTrace(sessionId, run.getRunId()); - assertNull(historical.getRun().getOrchestrationTrace()); + assertTrue(error.getMessage().contains("runId")); + verifyNoInteractions(diagnosisRunRepository, agentStepRepository, toolInvocationRepository); } @Test - void getTraceWithRunIdReturnsExactSecondRun() { - String sessionId = "trace-session-exact"; - DiagnosisRun second = run(2L, sessionId, "run-second", "second question", - LocalDateTime.of(2026, 7, 10, 10, 1)); - ToolInvocation secondTool = invocation(20L, sessionId, "run-second", "query_logs", second.getCreatedAt()); - - when(diagnosisRunRepository.findBySessionIdAndRunId(sessionId, "run-second")) - .thenReturn(Optional.of(second)); - when(agentStepRepository.findByRunIdOrderByStepIndex("run-second")).thenReturn(List.of()); - when(toolInvocationRepository.findByRunIdOrderByIdAsc("run-second")).thenReturn(List.of(secondTool)); - - DiagnosisTraceResponse response = service.getTrace(sessionId, "run-second"); - - assertEquals("run-second", response.getRunId()); - assertEquals("second question", response.getSession().getQuery()); - assertEquals(List.of("query_logs"), - response.getToolInvocations().stream().map(DiagnosisTraceResponse.ToolInvocationTrace::getToolName).toList()); - } - - @Test - void getTraceRejectsRunFromAnotherSession() { - String runId = "run-other-session"; - when(diagnosisRunRepository.findBySessionIdAndRunId("path-session", runId)).thenReturn(Optional.empty()); - when(diagnosisRunRepository.findByRunId(runId)).thenReturn(Optional.of(run( - 1L, "actual-session", runId, "query", LocalDateTime.of(2026, 7, 10, 10, 0)))); + void traceRejectsRunFromAnotherSession() { + String runId = "run-other"; + when(diagnosisRunRepository.findBySessionIdAndRunId("path-session", runId)) + .thenReturn(Optional.empty()); + when(diagnosisRunRepository.findByRunId(runId)) + .thenReturn(Optional.of(run(1L, "actual-session", runId, "question"))); IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> service.getTrace("path-session", runId)); @@ -174,88 +96,67 @@ class DiagnosisTraceServiceTest { } @Test - void getTraceThrowsWhenSessionMissing() { - String sessionId = "missing-session"; - when(diagnosisRunRepository.findFirstBySessionIdOrderByCreatedAtDescIdDesc(sessionId)) + void traceRejectsMissingRunWithoutLegacyFallback() { + when(diagnosisRunRepository.findBySessionIdAndRunId("missing-session", "missing-run")) .thenReturn(Optional.empty()); - when(diagnosisSessionRepository.findBySessionId(sessionId)).thenReturn(Optional.empty()); + when(diagnosisRunRepository.findByRunId("missing-run")).thenReturn(Optional.empty()); - assertThrows(SessionNotFoundException.class, () -> service.getTrace(sessionId)); + assertThrows(SessionNotFoundException.class, + () -> service.getTrace("missing-session", "missing-run")); - verify(diagnosisRunRepository).findFirstBySessionIdOrderByCreatedAtDescIdDesc(sessionId); - verify(diagnosisSessionRepository).findBySessionId(sessionId); verifyNoInteractions(agentStepRepository, toolInvocationRepository); } @Test - void getTraceIsReadOnly() { - String sessionId = "trace-session-readonly"; - DiagnosisRun latest = run(3L, sessionId, "run-readonly", "readonly question", - LocalDateTime.of(2026, 7, 10, 10, 2)); + void invalidOrMissingOrchestrationJsonReturnsNullForNonStateGraphRun() { + DiagnosisRun run = run(1L, "session-1", "run-aiops", "question"); + run.setAgentFlow("AI_OPS"); + run.setOrchestrationTrace("not-json"); + when(diagnosisRunRepository.findBySessionIdAndRunId("session-1", "run-aiops")) + .thenReturn(Optional.of(run)); + when(agentStepRepository.findByRunIdOrderByStepIndex("run-aiops")).thenReturn(List.of()); + when(toolInvocationRepository.findByRunIdOrderByIdAsc("run-aiops")).thenReturn(List.of()); - when(diagnosisRunRepository.findFirstBySessionIdOrderByCreatedAtDescIdDesc(sessionId)) - .thenReturn(Optional.of(latest)); + assertNull(service.getTrace("session-1", "run-aiops") + .getRun().getOrchestrationTrace()); + + run.setOrchestrationTrace(null); + assertNull(service.getTrace("session-1", "run-aiops") + .getRun().getOrchestrationTrace()); + } + + @Test + void exactTraceIsReadOnly() { + DiagnosisRun run = run(1L, "session-1", "run-readonly", "question"); + when(diagnosisRunRepository.findBySessionIdAndRunId("session-1", "run-readonly")) + .thenReturn(Optional.of(run)); when(agentStepRepository.findByRunIdOrderByStepIndex("run-readonly")).thenReturn(List.of()); when(toolInvocationRepository.findByRunIdOrderByIdAsc("run-readonly")).thenReturn(List.of()); - service.getTrace(sessionId); + service.getTrace("session-1", "run-readonly"); verify(chatSessionRepository, never()).save(any()); verify(diagnosisRunRepository, never()).save(any()); - verify(diagnosisSessionRepository, never()).save(any()); verify(agentStepRepository, never()).save(any()); verify(toolInvocationRepository, never()).save(any()); } @Test void listRunSummariesDoesNotExpandTraceDetails() { - String sessionId = "trace-session-runs"; - DiagnosisRun second = run(2L, sessionId, "run-second", "second question", - LocalDateTime.of(2026, 7, 10, 10, 1)); - second.setAnswer("answer ".repeat(40)); - DiagnosisRun first = run(1L, sessionId, "run-first", "first question", - LocalDateTime.of(2026, 7, 10, 10, 0)); - - when(diagnosisRunRepository.findBySessionIdOrderByCreatedAtDescIdDesc(sessionId)) + DiagnosisRun second = run(2L, "session-1", "run-second", "second"); + DiagnosisRun first = run(1L, "session-1", "run-first", "first"); + when(diagnosisRunRepository.findBySessionIdOrderByCreatedAtDescIdDesc("session-1")) .thenReturn(List.of(second, first)); - List summaries = service.listRunSummaries(sessionId); + List summaries = service.listRunSummaries("session-1"); assertEquals(List.of("run-second", "run-first"), summaries.stream().map(DiagnosisTraceResponse.RunSummary::getRunId).toList()); - assertEquals("second question", summaries.get(0).getQuery()); - assertTrue(summaries.get(0).getAnswerPreview().length() <= 160); verifyNoInteractions(agentStepRepository, toolInvocationRepository); } - @Test - void legacyTraceFallbackKeepsHistoricalSessionReadable() { - String sessionId = "legacy-session"; - DiagnosisSession legacy = DiagnosisSession.builder() - .id(1L) - .sessionId(sessionId) - .query("legacy question") - .status("SUCCESS") - .stepCount(0) - .toolCallCount(0) - .createdAt(LocalDateTime.of(2026, 7, 10, 9, 0)) - .updatedAt(LocalDateTime.of(2026, 7, 10, 9, 1)) - .build(); - - when(diagnosisRunRepository.findFirstBySessionIdOrderByCreatedAtDescIdDesc(sessionId)) - .thenReturn(Optional.empty()); - when(diagnosisSessionRepository.findBySessionId(sessionId)).thenReturn(Optional.of(legacy)); - when(agentStepRepository.findBySessionId(sessionId)).thenReturn(List.of()); - when(toolInvocationRepository.findBySessionIdOrderByIdAsc(sessionId)).thenReturn(List.of()); - - DiagnosisTraceResponse response = service.getTrace(sessionId); - - assertNull(response.getRunId()); - assertNull(response.getRun()); - assertEquals("legacy question", response.getSession().getQuery()); - } - - private DiagnosisRun run(Long id, String sessionId, String runId, String query, LocalDateTime createdAt) { + private DiagnosisRun run(Long id, String sessionId, String runId, String query) { + LocalDateTime now = LocalDateTime.of(2026, 7, 20, 10, 0).plusMinutes(id); return DiagnosisRun.builder() .id(id) .sessionId(sessionId) @@ -263,49 +164,44 @@ class DiagnosisTraceServiceTest { .query(query) .status("SUCCESS") .agentFlow("CHAT") - .answer("answer for " + runId) + .answer("answer") .selfEvaluation("{\"verifier_evaluation\":{\"verdict\":\"PASS\"}}") - .orchestrationTrace("{\"version\":\"stategraph-v1\",\"transitions\":[],\"final_node\":\"COMPOSER\",\"termination_reason\":\"composer_completed\",\"degraded\":false,\"evidence_retry_count\":0}") - .feedback("useful") + .orchestrationTrace("{\"version\":\"stategraph-v1\",\"transitions\":[],\"final_node\":\"composer\",\"termination_reason\":\"composer_completed\",\"degraded\":false,\"evidence_retry_count\":0}") .stepCount(1) .toolCallCount(1) - .createdAt(createdAt) - .updatedAt(createdAt.plusSeconds(1)) + .createdAt(now) + .updatedAt(now.plusSeconds(1)) .build(); } private ChatSession chatSession(String sessionId) { return ChatSession.builder() - .id(99L) + .id(9L) .sessionId(sessionId) .status("ACTIVE") .messagePairCount(1) - .createdAt(LocalDateTime.of(2026, 7, 10, 9, 0)) - .lastActiveAt(LocalDateTime.of(2026, 7, 10, 10, 0)) .build(); } - private AgentStep step(Long id, String sessionId, String runId, int stepIndex, String agentName, LocalDateTime createdAt) { + private AgentStep step(Long id, String sessionId, String runId) { return AgentStep.builder() .id(id) .sessionId(sessionId) .runId(runId) - .stepIndex(stepIndex) - .agentName(agentName) - .createdAt(createdAt) + .stepIndex(0) + .agentName("planner") + .createdAt(LocalDateTime.of(2026, 7, 20, 10, 1)) .build(); } - private ToolInvocation invocation(Long id, String sessionId, String runId, String toolName, LocalDateTime createdAt) { + private ToolInvocation invocation(Long id, String sessionId, String runId) { return ToolInvocation.builder() .id(id) .sessionId(sessionId) .runId(runId) - .toolName(toolName) - .inputParams("{\"query\":\"timeout\"}") - .retrievalDetails("{\"evidence_refs\":[]}") + .toolName("query_logs") .success(true) - .createdAt(createdAt) + .createdAt(LocalDateTime.of(2026, 7, 20, 10, 2)) .build(); } } diff --git a/src/test/java/com/superbiz/agent/service/ExecutorGatekeeperServiceTest.java b/src/test/java/com/superbiz/agent/service/ExecutorGatekeeperServiceTest.java index 04751ab..00d4bf9 100644 --- a/src/test/java/com/superbiz/agent/service/ExecutorGatekeeperServiceTest.java +++ b/src/test/java/com/superbiz/agent/service/ExecutorGatekeeperServiceTest.java @@ -49,13 +49,13 @@ class ExecutorGatekeeperServiceTest { @Test void validatePassesForExecutorEvidenceV2WithMatchingInvocation() { ToolInvocationRepository repository = mock(ToolInvocationRepository.class); - when(repository.findBySessionIdOrderByIdAsc("session-1")).thenReturn(List.of( + when(repository.findByRunIdOrderByIdAsc("run-1")).thenReturn(List.of( invocation(101L, "query_metrics", "$.alerts[0]", "HighCPUUsage firing, service=payment-service, current=92%, duration=25m") )); ExecutorGatekeeperService service = new ExecutorGatekeeperService(repository); - Map result = service.validate("session-1", + Map result = service.validateRun("run-1", validOutput(101L, "query_metrics", "$.alerts[0]", "HighCPUUsage firing, service=payment-service, current=92%"), Map.of("status", "valid")); @@ -69,13 +69,13 @@ class ExecutorGatekeeperServiceTest { @Test void validatePassesForNoEvidenceReference() { ToolInvocationRepository repository = mock(ToolInvocationRepository.class); - when(repository.findBySessionIdOrderByIdAsc("session-1")).thenReturn(List.of( + when(repository.findByRunIdOrderByIdAsc("run-1")).thenReturn(List.of( invocation(101L, "query_logs", "$.no_evidence", "query_logs returned no evidence; evidence_status=no_evidence; query=inventory-service HikariCP; topic=application-logs; total=0; message=未找到匹配的日志") )); ExecutorGatekeeperService service = new ExecutorGatekeeperService(repository); - Map result = service.validate("session-1", + Map result = service.validateRun("run-1", validOutput(101L, "query_logs", "$.no_evidence", "query_logs returned no evidence; query=inventory-service HikariCP; total=0; evidence_status=no_evidence"), Map.of("status", "valid")); @@ -89,7 +89,7 @@ class ExecutorGatekeeperServiceTest { @Test void validateBackfillsNoEvidenceInvocationByRawPathWhenToolHasMultipleCalls() { ToolInvocationRepository repository = mock(ToolInvocationRepository.class); - when(repository.findBySessionIdOrderByIdAsc("session-1")).thenReturn(List.of( + when(repository.findByRunIdOrderByIdAsc("run-1")).thenReturn(List.of( invocation(101L, "query_logs", "$.no_evidence", "query_logs returned no evidence; evidence_status=no_evidence; query=inventory-service HikariCP; total=0; message=未找到匹配的日志"), invocation(102L, "query_logs", "$.logs[0]", @@ -97,7 +97,7 @@ class ExecutorGatekeeperServiceTest { )); ExecutorGatekeeperService service = new ExecutorGatekeeperService(repository); - Map result = service.validate("session-1", + Map result = service.validateRun("run-1", validOutput(null, "query_logs", "$.no_evidence", "query_logs returned no evidence; query=inventory-service HikariCP; total=0; evidence_status=no_evidence", "negative_observation"), @@ -113,7 +113,7 @@ class ExecutorGatekeeperServiceTest { @Test void validateBackfillsNoEvidenceInvocationByExcerptWhenRawPathIsRepeated() { ToolInvocationRepository repository = mock(ToolInvocationRepository.class); - when(repository.findBySessionIdOrderByIdAsc("session-1")).thenReturn(List.of( + when(repository.findByRunIdOrderByIdAsc("run-1")).thenReturn(List.of( invocation(101L, "query_logs", "$.no_evidence", "query_logs returned no evidence; evidence_status=no_evidence; query=service:inventory-service AND HikariCP; total=0; message=未找到匹配的日志"), invocation(102L, "query_logs", "$.no_evidence", @@ -121,7 +121,7 @@ class ExecutorGatekeeperServiceTest { )); ExecutorGatekeeperService service = new ExecutorGatekeeperService(repository); - Map result = service.validate("session-1", + Map result = service.validateRun("run-1", validOutput(null, "query_logs", "$.no_evidence", "query_logs returned no evidence; query=service:inventory-service AND HikariCP; total=0; evidence_status=no_evidence", "negative_observation"), @@ -136,13 +136,13 @@ class ExecutorGatekeeperServiceTest { @Test void validateRejectsNoEvidenceReferenceWhenExcerptClaimsPositiveEvidence() { ToolInvocationRepository repository = mock(ToolInvocationRepository.class); - when(repository.findBySessionIdOrderByIdAsc("session-1")).thenReturn(List.of( + when(repository.findByRunIdOrderByIdAsc("run-1")).thenReturn(List.of( invocation(101L, "query_logs", "$.no_evidence", "query_logs returned no evidence; evidence_status=no_evidence; query=inventory-service HikariCP; total=0; message=未找到匹配的日志") )); ExecutorGatekeeperService service = new ExecutorGatekeeperService(repository); - Map result = service.validate("session-1", + Map result = service.validateRun("run-1", validOutput(101L, "query_logs", "$.no_evidence", "HikariCP active=50/50 waiting=32"), Map.of("status", "valid")); @@ -156,13 +156,13 @@ class ExecutorGatekeeperServiceTest { @Test void validateRejectsPositiveBindingOnNegativeObservation() { ToolInvocationRepository repository = mock(ToolInvocationRepository.class); - when(repository.findBySessionIdOrderByIdAsc("session-1")).thenReturn(List.of( + when(repository.findByRunIdOrderByIdAsc("run-1")).thenReturn(List.of( invocation(101L, "query_logs", "$.logs[0]", "order-service HikariCP active=50/50 waiting=32") )); ExecutorGatekeeperService service = new ExecutorGatekeeperService(repository); - Map result = service.validate("session-1", + Map result = service.validateRun("run-1", validOutput(101L, "query_logs", "$.logs[0]", "order-service HikariCP active=50/50 waiting=32", "negative_observation"), @@ -176,12 +176,12 @@ class ExecutorGatekeeperServiceTest { @Test void validateFailsWhenRemovedFieldsArePresent() { ToolInvocationRepository repository = mock(ToolInvocationRepository.class); - when(repository.findBySessionIdOrderByIdAsc("session-1")).thenReturn(List.of()); + when(repository.findByRunIdOrderByIdAsc("run-1")).thenReturn(List.of()); ExecutorGatekeeperService service = new ExecutorGatekeeperService(repository); Map output = validOutput(101L, "query_metrics", "$.alerts[0]", "cpu=92"); output.put("user_facing_answer", "旧版最终答案"); - Map result = service.validate("session-1", output, Map.of("status", "valid")); + Map result = service.validateRun("run-1", output, Map.of("status", "valid")); assertEquals("fail", result.get("status")); assertEquals("reject", result.get("severity")); @@ -191,12 +191,12 @@ class ExecutorGatekeeperServiceTest { @Test void validateFailsForFabricatedInvocationId() { ToolInvocationRepository repository = mock(ToolInvocationRepository.class); - when(repository.findBySessionIdOrderByIdAsc("session-1")).thenReturn(List.of( + when(repository.findByRunIdOrderByIdAsc("run-1")).thenReturn(List.of( invocation(101L, "query_metrics", "$.alerts[0]", "cpu=92") )); ExecutorGatekeeperService service = new ExecutorGatekeeperService(repository); - Map result = service.validate("session-1", + Map result = service.validateRun("run-1", validOutput(999L, "query_metrics", "$.alerts[0]", "cpu=92"), Map.of("status", "valid")); @@ -221,12 +221,12 @@ class ExecutorGatekeeperServiceTest { @Test void validateFailsForToolNameMismatch() { ToolInvocationRepository repository = mock(ToolInvocationRepository.class); - when(repository.findBySessionIdOrderByIdAsc("session-1")).thenReturn(List.of( + when(repository.findByRunIdOrderByIdAsc("run-1")).thenReturn(List.of( invocation(101L, "query_logs", "$.logs[0]", "cpu=92") )); ExecutorGatekeeperService service = new ExecutorGatekeeperService(repository); - Map result = service.validate("session-1", + Map result = service.validateRun("run-1", validOutput(101L, "query_metrics", "$.alerts[0]", "cpu=92"), Map.of("status", "valid")); @@ -238,13 +238,13 @@ class ExecutorGatekeeperServiceTest { @Test void validateDowngradesMissingRawPathToLowConfidence() { ToolInvocationRepository repository = mock(ToolInvocationRepository.class); - when(repository.findBySessionIdOrderByIdAsc("session-1")).thenReturn(List.of( + when(repository.findByRunIdOrderByIdAsc("run-1")).thenReturn(List.of( invocation(101L, "query_metrics", "$.alerts[0]", "cpu=92") )); ExecutorGatekeeperService service = new ExecutorGatekeeperService(repository); Map output = validOutput(101L, "query_metrics", null, "cpu=92"); - Map result = service.validate("session-1", output, Map.of("status", "valid")); + Map result = service.validateRun("run-1", output, Map.of("status", "valid")); assertEquals("fail", result.get("status")); assertEquals("low_confid", result.get("severity")); @@ -254,12 +254,12 @@ class ExecutorGatekeeperServiceTest { @Test void validateRejectsUnknownRawPath() { ToolInvocationRepository repository = mock(ToolInvocationRepository.class); - when(repository.findBySessionIdOrderByIdAsc("session-1")).thenReturn(List.of( + when(repository.findByRunIdOrderByIdAsc("run-1")).thenReturn(List.of( invocation(101L, "query_metrics", "$.alerts[0]", "cpu=92") )); ExecutorGatekeeperService service = new ExecutorGatekeeperService(repository); - Map result = service.validate("session-1", + Map result = service.validateRun("run-1", validOutput(101L, "query_metrics", "$.alerts[99]", "cpu=92"), Map.of("status", "valid")); @@ -271,14 +271,14 @@ class ExecutorGatekeeperServiceTest { @Test void validateDowngradesOldInvocationWithoutEvidenceRefsToLowConfidence() { ToolInvocationRepository repository = mock(ToolInvocationRepository.class); - when(repository.findBySessionIdOrderByIdAsc("session-1")).thenReturn(List.of( + when(repository.findByRunIdOrderByIdAsc("run-1")).thenReturn(List.of( ToolInvocation.builder().id(101L).sessionId("session-1").toolName("query_metrics") .retrievalDetails("{\"evidence_status\":\"supported\"}") .build() )); ExecutorGatekeeperService service = new ExecutorGatekeeperService(repository); - Map result = service.validate("session-1", + Map result = service.validateRun("run-1", validOutput(101L, "query_metrics", "$.alerts[0]", "cpu=92"), Map.of("status", "valid")); @@ -291,12 +291,12 @@ class ExecutorGatekeeperServiceTest { @Test void validateRejectsMismatchedExcerpt() { ToolInvocationRepository repository = mock(ToolInvocationRepository.class); - when(repository.findBySessionIdOrderByIdAsc("session-1")).thenReturn(List.of( + when(repository.findByRunIdOrderByIdAsc("run-1")).thenReturn(List.of( invocation(101L, "query_metrics", "$.alerts[0]", "HighCPUUsage firing service payment-service current 92") )); ExecutorGatekeeperService service = new ExecutorGatekeeperService(repository); - Map result = service.validate("session-1", + Map result = service.validateRun("run-1", validOutput(101L, "query_metrics", "$.alerts[0]", "HikariCP active=50/50 waiting=32"), Map.of("status", "valid")); @@ -308,7 +308,7 @@ class ExecutorGatekeeperServiceTest { @Test void validateFailsForRecommendedActionFabricatedInvocationId() { ToolInvocationRepository repository = mock(ToolInvocationRepository.class); - when(repository.findBySessionIdOrderByIdAsc("session-1")).thenReturn(List.of( + when(repository.findByRunIdOrderByIdAsc("run-1")).thenReturn(List.of( invocation(101L, "query_metrics", "$.alerts[0]", "cpu=92") )); ExecutorGatekeeperService service = new ExecutorGatekeeperService(repository); @@ -326,7 +326,7 @@ class ExecutorGatekeeperServiceTest { )) ))); - Map result = service.validate("session-1", output, Map.of("status", "valid")); + Map result = service.validateRun("run-1", output, Map.of("status", "valid")); assertEquals("fail", result.get("status")); assertEquals("reject", result.get("severity")); diff --git a/src/test/java/com/superbiz/agent/service/FeedbackServiceTest.java b/src/test/java/com/superbiz/agent/service/FeedbackServiceTest.java index 1a34cce..2e099d4 100644 --- a/src/test/java/com/superbiz/agent/service/FeedbackServiceTest.java +++ b/src/test/java/com/superbiz/agent/service/FeedbackServiceTest.java @@ -2,10 +2,8 @@ package com.superbiz.agent.service; import com.superbiz.agent.domain.entity.CaseLibrary; import com.superbiz.agent.domain.entity.DiagnosisRun; -import com.superbiz.agent.domain.entity.DiagnosisSession; import com.superbiz.agent.dto.FeedbackResponse; import com.superbiz.agent.repository.DiagnosisRunRepository; -import com.superbiz.agent.repository.DiagnosisSessionRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; @@ -15,7 +13,6 @@ import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertEquals; 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.mock; @@ -26,14 +23,12 @@ import static org.mockito.Mockito.when; class FeedbackServiceTest { - private final DiagnosisSessionRepository diagnosisSessionRepository = mock(DiagnosisSessionRepository.class); private final DiagnosisRunRepository diagnosisRunRepository = mock(DiagnosisRunRepository.class); private final CaseLibraryService caseLibraryService = mock(CaseLibraryService.class); private final FeedbackService service = new FeedbackService(); @BeforeEach void setUp() { - ReflectionTestUtils.setField(service, "diagnosisSessionRepository", diagnosisSessionRepository); ReflectionTestUtils.setField(service, "diagnosisRunRepository", diagnosisRunRepository); ReflectionTestUtils.setField(service, "caseLibraryService", caseLibraryService); } @@ -48,29 +43,18 @@ class FeedbackServiceTest { assertTrue(response.isSuccess()); assertEquals("run-1", response.getRunId()); - assertFalse(response.isFallbackToLatestRun()); assertEquals("not_useful", run.getFeedback()); verify(diagnosisRunRepository).save(run); verifyNoInteractions(caseLibraryService); - verifyNoInteractions(diagnosisSessionRepository); } @Test - void submitFeedbackWithoutRunIdFallsBackToLatestRunObservably() { - DiagnosisRun latest = run("session-1", "run-latest"); - when(diagnosisRunRepository.findFirstBySessionIdOrderByCreatedAtDescIdDesc("session-1")) - .thenReturn(Optional.of(latest)); - when(diagnosisRunRepository.findBySessionIdAndRunId("session-1", "run-latest")) - .thenReturn(Optional.of(latest)); - + void submitFeedbackRequiresRunId() { FeedbackResponse response = service.submitFeedback("session-1", null, "not_useful"); - assertTrue(response.isSuccess()); - assertEquals("run-latest", response.getRunId()); - assertTrue(response.isFallbackToLatestRun()); - assertEquals("not_useful", latest.getFeedback()); - verify(diagnosisRunRepository).save(latest); - verify(diagnosisSessionRepository, never()).findBySessionId(any()); + assertFalse(response.isSuccess()); + assertEquals("runId 不能为空", response.getMessage()); + verifyNoInteractions(diagnosisRunRepository, caseLibraryService); } @Test @@ -86,7 +70,6 @@ class FeedbackServiceTest { assertEquals("run-other", response.getRunId()); verify(diagnosisRunRepository, never()).save(any()); verifyNoInteractions(caseLibraryService); - verifyNoInteractions(diagnosisSessionRepository); } @Test @@ -106,30 +89,6 @@ class FeedbackServiceTest { verify(diagnosisRunRepository).save(run); } - @Test - void legacySessionFallbackPreservesOldDataCompatibility() { - DiagnosisSession session = DiagnosisSession.builder() - .sessionId("legacy-session") - .query("legacy query") - .answer("legacy answer") - .build(); - CaseLibrary caseLibrary = CaseLibrary.builder().caseId("legacy-case").build(); - when(diagnosisRunRepository.findFirstBySessionIdOrderByCreatedAtDescIdDesc("legacy-session")) - .thenReturn(Optional.empty()); - when(diagnosisSessionRepository.findBySessionId("legacy-session")).thenReturn(Optional.of(session)); - when(caseLibraryService.createFromSession(session)).thenReturn(caseLibrary); - - FeedbackResponse response = service.submitFeedback("legacy-session", null, "useful"); - - assertTrue(response.isSuccess()); - assertNull(response.getRunId()); - assertFalse(response.isFallbackToLatestRun()); - assertEquals("legacy-case", response.getCaseId()); - assertEquals("useful", session.getFeedback()); - verify(diagnosisSessionRepository).save(session); - verify(caseLibraryService).createFromSession(session); - } - private DiagnosisRun run(String sessionId, String runId) { return DiagnosisRun.builder() .sessionId(sessionId)