feat(harness): add chat application use case
This commit is contained in:
@@ -41,3 +41,4 @@
|
|||||||
| 2026-07-21 | single-react-mysql-readonly-tool | Fail-closed read-only MySQL evidence Tool with AST allowlist, JDBC controls and bounded projection | Harness/MySQL security | ISS-014, MySQL, JSqlParser, allowlist, PreparedStatement, timeout, projection | openspec/changes/archive/2026-07-21-single-react-mysql-readonly-tool | archived |
|
| 2026-07-21 | single-react-mysql-readonly-tool | Fail-closed read-only MySQL evidence Tool with AST allowlist, JDBC controls and bounded projection | Harness/MySQL security | ISS-014, MySQL, JSqlParser, allowlist, PreparedStatement, timeout, projection | openspec/changes/archive/2026-07-21-single-react-mysql-readonly-tool | archived |
|
||||||
| 2026-07-21 | single-react-diagnosis-agent | Single internal Diagnosis ReactAgent with Harness-controlled model/tool loop, bounded context and typed Draft | Harness/Diagnosis Agent/ReAct | ISS-014, ReactAgent, DiagnosisDraft, PreviousTurn, ToolInterceptor, ModelInterceptor, budget | openspec/changes/archive/2026-07-21-single-react-diagnosis-agent | archived |
|
| 2026-07-21 | single-react-diagnosis-agent | Single internal Diagnosis ReactAgent with Harness-controlled model/tool loop, bounded context and typed Draft | Harness/Diagnosis Agent/ReAct | ISS-014, ReactAgent, DiagnosisDraft, PreviousTurn, ToolInterceptor, ModelInterceptor, budget | openspec/changes/archive/2026-07-21-single-react-diagnosis-agent | archived |
|
||||||
| 2026-07-21 | single-react-evidence-semantic-guards | Deterministic evidence validation, isolated semantic review and fail-closed diagnosis release | Harness/EvidenceGuard/SemanticGuard/Release | ISS-014, EvidenceGuard, verified snapshot, SemanticGuard, repair, fallback, release policy | openspec/changes/archive/2026-07-21-single-react-evidence-semantic-guards | archived |
|
| 2026-07-21 | single-react-evidence-semantic-guards | Deterministic evidence validation, isolated semantic review and fail-closed diagnosis release | Harness/EvidenceGuard/SemanticGuard/Release | ISS-014, EvidenceGuard, verified snapshot, SemanticGuard, repair, fallback, release policy | openspec/changes/archive/2026-07-21-single-react-evidence-semantic-guards | archived |
|
||||||
|
| 2026-07-21 | single-react-chat-application-usecase | Internal Chat application use case with isolated routing, fixed executors and safe PreviousTurn | Harness/Chat application/Run persistence | ISS-014, Intent Router, PreviousTurn, PublishedResult, V012, observer, cancellation | openspec/changes/archive/2026-07-21-single-react-chat-application-usecase | archived |
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Acceptance: single-react-chat-application-usecase
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
- Status: archived
|
||||||
|
- OpenSpec tasks: 14/14 complete
|
||||||
|
- Interface impact: L3 database/collaboration
|
||||||
|
- Public protocol: unchanged
|
||||||
|
|
||||||
|
## Static Verification
|
||||||
|
|
||||||
|
- V012 migration、`DiagnosisRun` 和 `DiagnosisRunRepository` 对齐 `intent/release_outcome/published_result` 及安全 PreviousTurn filter。
|
||||||
|
- 公开 Controller、前端和 endpoint diff 为空。
|
||||||
|
- Router/System executor 未检出 Tool、ReactAgent、ThreadLocal 或手写循环。
|
||||||
|
- `PublishedResult` 固定为 `user_query/published_conclusion/scope/limitations/source_documents`;序列化负向测试覆盖内部字段泄漏。
|
||||||
|
|
||||||
|
## Script Verification
|
||||||
|
|
||||||
|
- `mvn -q -DskipTests compile`:通过。
|
||||||
|
- Stage 6A focused `ApplicationExecutorsTest,PublishedResultPersistenceTest,ChatApplicationUseCaseTest`:13 tests,通过。
|
||||||
|
- Stage 2-5 与 6A regression selection:18 suites / 76 tests,0 failure/error/skipped。
|
||||||
|
- `openspec validate single-react-chat-application-usecase --strict`:通过。
|
||||||
|
|
||||||
|
## Browser or Manual Verification
|
||||||
|
|
||||||
|
- Not applicable。阶段 6A 没有 UI 或公开入口变化。
|
||||||
|
|
||||||
|
## Not Verified
|
||||||
|
|
||||||
|
- 未运行真实 LLM、Redis、日志和 MySQL live E2E;按 ISS-014 串行门禁统一留到阶段 7。
|
||||||
|
- V012 未在本阶段连接真实数据库执行;migration/entity/query 已由静态检查、focused persistence tests 和 compile 覆盖。
|
||||||
|
|
||||||
|
## Remaining Work
|
||||||
|
|
||||||
|
- 阶段 6B:唯一 `POST /api/chat` SSE 原子切换、旧 endpoint 删除和前端消费者迁移。
|
||||||
|
- 阶段 7:旧链路清理、全局 spec 格式修复和最终 live E2E。
|
||||||
|
|
||||||
|
## Archive
|
||||||
|
|
||||||
|
- `.archive-ready`: created
|
||||||
|
- OpenSpec archive: `openspec/changes/archive/2026-07-21-single-react-chat-application-usecase`
|
||||||
|
- Main spec sync: `openspec/specs/single-react-chat-application-usecase/spec.md`
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Brief: single-react-chat-application-usecase
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
阶段 2-5 已具备 RunContext、单一 Diagnosis Agent 和安全释放门禁,但没有统一应用用例拥有 Session/Run、意图路由、PreviousTurn、固定执行器和最终持久化,阶段 6B 因而无法只做协议切换。
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
在不改变公开 Chat/SSE 行为的前提下,建立内部 `ChatApplicationUseCase`,统一三类意图、同一 Run 生命周期、安全 PreviousTurn 和 typed public content。
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- 无 Tool、无记忆、无 ReAct 的三分类 Intent Router。
|
||||||
|
- SYSTEM_CHAT、KNOWLEDGE_QUERY、DIAGNOSIS 固定执行器。
|
||||||
|
- Knowledge exact invocation/document reference validation。
|
||||||
|
- 同 Session 最近安全 Diagnosis SUCCESS 的有界 PreviousTurn。
|
||||||
|
- `diagnosis_run` V012 字段、JPA store 和安全 `PublishedResult`。
|
||||||
|
- Protocol-neutral observer、Run control、终态持久化和 focused tests。
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- 不修改 Controller、`/api/chat`、`/api/chat_stream`、SSE schema 或前端消费者。
|
||||||
|
- 不删除旧 ChatService、多 Agent、ThreadLocal 或旧 session storage。
|
||||||
|
- 不运行真实模型、Redis、日志和 MySQL live E2E;统一留到阶段 7。
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
- Scale: complex
|
||||||
|
- Interface impact: L3 database/collaboration
|
||||||
|
- OpenSpec: `single-react-chat-application-usecase`
|
||||||
|
- Parent issue: `ISS-014`
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# Decisions: single-react-chat-application-usecase
|
||||||
|
|
||||||
|
## Discover Status
|
||||||
|
|
||||||
|
- Checkpoint: Discover
|
||||||
|
- Capability source: `sm-flow` + `grill-with-docs`;`codebase-retrieval`、LSP 和 GitNexus MCP 当前不可用,使用既有 GitNexus 结论、`rg` 引用核对和源码阅读降级。
|
||||||
|
- Scale: complex。跨模型路由、三类执行器、Run/session 生命周期、数据库 migration、PreviousTurn 和阶段 6B consumer boundary。
|
||||||
|
- `devflow/index.md` 命中阶段 0-5、session-run-trace-isolation、RAG contracts 和 release guards;无 ADR 冲突。
|
||||||
|
|
||||||
|
## Question Pool
|
||||||
|
|
||||||
|
| # | 维度 | 问题 | 模式 | 状态 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Q1 | 术语 | Chat Application Use Case 与 Controller、Harness、Agent 的职责边界是什么? | evidence-driven | 已解决 |
|
||||||
|
| Q2 | 路由 | Router 输入、输出和可重试失败范围是什么? | evidence-driven | 已解决 |
|
||||||
|
| Q3 | 路由 | Router 最终失败是否允许默认进入 Diagnosis? | evidence-driven | 已解决 |
|
||||||
|
| Q4 | 执行器 | 三种 intent 分别允许哪些模型和 Tool 行为? | evidence-driven | 已解决 |
|
||||||
|
| Q5 | Knowledge | 单次 RAG 调用如何验证模型引用且不公开 Tool Call ID? | evidence-driven | 已解决 |
|
||||||
|
| Q6 | PreviousTurn | 上一回合的真理源、筛选条件和截断边界是什么? | evidence-driven | 已解决 |
|
||||||
|
| Q7 | 生命周期 | 何时读取上一回合、创建当前 Run、记录 intent 和终态? | evidence-driven | 已解决 |
|
||||||
|
| Q8 | 持久化 | diagnosis_run 需要新增哪些字段,哪些内部内容禁止进入 published_result? | evidence-driven | 已解决 |
|
||||||
|
| Q9 | 6B 边界 | 如何让 Controller 切换时不重写应用用例? | evidence-driven | 已解决 |
|
||||||
|
| Q10 | 接口 | 数据库/内部接口影响等级和回滚要求是什么? | evidence-driven | 已解决 |
|
||||||
|
| Q11 | 验收 | 如何证明原始 Query、sessionId/runId 和失败终态一致传播? | evidence-driven | 已解决 |
|
||||||
|
|
||||||
|
## Evidence-driven
|
||||||
|
|
||||||
|
| 结论 | 证据来源 | 是否已汇报用户 |
|
||||||
|
|---|---|---|
|
||||||
|
| Controller 只负责协议;Application Use Case 拥有 session/run、routing、executor、persistence,Harness 拥有预算/取消/释放,Agent 拥有诊断语义。 | ISS-014 3、4.2、阶段 6A/6B | 已汇报 |
|
||||||
|
| Router 输入只含 query/last_intent/last_user_query,输出只允许三枚举;无 Tool/记忆/ReAct。 | ISS-014 4.5 | 已汇报 |
|
||||||
|
| timeout/transport/非法输出可重试一次;第二次失败返回安全入口错误,不进入 Diagnosis。 | ISS-014 重试策略、`HarnessRetryPolicies.intentRouter()` | 已汇报 |
|
||||||
|
| SYSTEM_CHAT 无 Tool;KNOWLEDGE_QUERY 只调用一次 lookup;DIAGNOSIS 进入 Agent + Guards。 | ISS-014 4.5 | 已汇报 |
|
||||||
|
| PreviousTurn 只来自同 Session 最近 `DIAGNOSIS + SUCCESS + published_result`,不使用 Redis 历史。 | ISS-014 4.6 | 已汇报 |
|
||||||
|
| `PublishedResult`/`PreviousTurn` 已冻结为 query/conclusion/scope/limitations/source_documents,不含 Tool ID/raw/Draft/reason。 | 阶段 0 contracts、`PublishedResult`、`PreviousTurn` | 已汇报 |
|
||||||
|
| 当前 `DiagnosisRun`/V011 尚无 intent/release_outcome/published_result,需要 V012 和 repository query。 | `DiagnosisRun.java`、`V011__add_session_run_isolation.sql` | 已汇报 |
|
||||||
|
| 上一回合必须在保存当前 PENDING Run 前读取,否则 latest query 会命中当前请求。 | repository 当前 latest method + 生命周期顺序推导 | 已汇报 |
|
||||||
|
| 6B 需要 metadata/status/cancel,6A 应提供 observer 和显式 RunContext,而不包含 SSE 类型。 | ISS-014 4.7、阶段 6B | 已汇报 |
|
||||||
|
|
||||||
|
## User-interview
|
||||||
|
|
||||||
|
- 无新增 user-interview。三类路由、数据库字段、PreviousTurn、失败语义、阶段边界和自动 Apply/Archive/commit 均由 ISS-014 与用户持续授权冻结。
|
||||||
|
|
||||||
|
## Key Decisions
|
||||||
|
|
||||||
|
- 在创建当前 Run 前读取 latest safe routing context 和 PreviousTurn,随后 `startRun -> persist RUNNING -> observer metadata -> route`。
|
||||||
|
- Application output 使用 typed content union,Diagnosis success 转为无 Tool ID 的 public report view;Fallback output 不携带完整 snapshot。
|
||||||
|
- Knowledge path 生成一次 direct canonical Tool Call ID(该路径没有框架 Tool Call),只调用 `lookup_knowledge`,严格验证 `KnowledgeAnswerDraft` 的 exact call ID 和 document subset,再移除 ID 发布。
|
||||||
|
- 所有普通单轮模型调用复用阶段 5 的受控 `GuardModelCall`,从而共享 Core 模型/Token/timeout/cancel 边界;不引入新模型路由。
|
||||||
|
- `PublishedResult` 仅在 Diagnosis SUCCESS 且 conclusion 非空时写入;Fallback/Failed/Cancelled/System/Knowledge 不生成 PreviousTurn 真理源。
|
||||||
|
- 数据库变更使用 V012 可前向迁移;回滚为先停止新应用用例,再删除新索引/列,不影响 V011 既有字段。
|
||||||
|
- 不创建 ADR:这些是 ISS-014 已冻结设计的落地,不是新的跨项目不可逆决策。
|
||||||
|
|
||||||
|
## OpenSpec Backfill
|
||||||
|
|
||||||
|
- 需进入 proposal/design/spec/tasks:路由隔离/重试、三执行器、original query、PreviousTurn filter/bounds、observer/cancel、Run terminal persistence、V012/L3、公开隔离。
|
||||||
|
- 非目标:Controller/SSE/前端切换、旧链路删除、live E2E。
|
||||||
|
|
||||||
|
## Cross-artifact Alignment
|
||||||
|
|
||||||
|
| 上游 -> 下游 | 检查内容 | 状态 |
|
||||||
|
|---|---|---|
|
||||||
|
| ISS-014/brief -> proposal | 三路由、previous turn、Run lifecycle、内部-only 和阶段 6B handoff | 已对齐 |
|
||||||
|
| proposal -> design | typed executors、observer、JPA/V012、异常/终态、L3 migration/rollback | 已对齐 |
|
||||||
|
| design -> specs/tasks | 每项所有权/安全边界均有可观察 requirement 和实现测试切片 | 已对齐 |
|
||||||
|
| specs -> tasks | 9 组 requirements 覆盖 Router/executors、store/policy、application、verification | 已对齐 |
|
||||||
|
|
||||||
|
## Architecture Audit
|
||||||
|
|
||||||
|
- 能力来源:`zoom-out`,使用 Chat Session、Diagnosis Run、RunContext、Diagnosis Agent、EvidenceGuard、SemanticGuard 和 PublishedResult 术语。
|
||||||
|
- 链路为 `request -> application use case -> run store/router -> fixed executor -> Harness/Agent/Tool -> typed public content -> run finish`;Controller 不拥有模型/工具/Run。
|
||||||
|
- ChatRunStore 拥有 MySQL 映射,Core 拥有运行状态,Application 拥有 dispatch/终态,path executor 拥有单一路径行为;PreviousTurn policy 是唯一安全历史投影。
|
||||||
|
- 最大风险是 prior/current Run 顺序和 DB/lifecycle 双终态,design/tasks 已固定 prior read before start、single finish path 和 focused failure/cancel tests。
|
||||||
|
- V012 是 L3 additive schema;migration、entity、repository、rollback 独立章节完整,公开入口阶段 6A 零变化。
|
||||||
|
|
||||||
|
## Interface Impact
|
||||||
|
|
||||||
|
- 级别:L3 database/collaboration interface。
|
||||||
|
- 新增 `diagnosis_run.intent/release_outcome/published_result` 和索引;修改 Entity/Repository,新增 internal application/store/output contract。
|
||||||
|
- 消费者:阶段 6B Controller/SSE adapter、MySQL/Flyway;旧 ChatService 在本阶段不消费新字段。
|
||||||
|
- 迁移/回滚:V012 nullable additive;回滚先切旧入口,再删除 index/columns。
|
||||||
|
|
||||||
|
## Commit Gate Preflight
|
||||||
|
|
||||||
|
- proposal、design、specs、tasks 完整,`openspec status` complete,change strict validation 通过。
|
||||||
|
- Question pool 全部已解决并汇报,无 user-interview、未判级接口或未接受架构风险。
|
||||||
|
- Cross-artifact 四段对齐无 gap;V012/L3、prior read ordering、terminal persistence 和 6B handoff 已进入 design/spec/tasks。
|
||||||
|
- Apply/Archive/commit 使用用户持续授权;公开协议和前端必须保持零 diff。
|
||||||
|
- `.committed` 已创建,可进入 Apply。
|
||||||
|
|
||||||
|
## Pre-apply Research
|
||||||
|
|
||||||
|
- `DiagnosisHarnessCore`/`RunContext`:Run ID、budget、cancel 和 first-terminal-wins。
|
||||||
|
- `GuardModelCall`/`HarnessRetryExecutor`:单轮模型 timeout/usage 和 Router 两次 attempt。
|
||||||
|
- `HarnessEvidenceTools`/`RagToolResult`:Knowledge 唯一 lookup 路径和有界 projection。
|
||||||
|
- `DiagnosisAgentUseCase`/`DiagnosisReleaseUseCase`:Diagnosis Draft 与安全 release boundary。
|
||||||
|
- `DiagnosisRun`/`DiagnosisRunRepository`/`V011`:现有 Run schema 和写入模式。
|
||||||
|
- `ChatService.ensureChatSession/startDiagnosisRun`:只参考 session metadata/JPA 写法,不复用旧 routing、多 Agent 或 ThreadLocal。
|
||||||
|
- 技术栈:Spring AI direct Prompt、Jackson strict JSON、JPA repository、Flyway additive migration、protocol-neutral observer;无 MQ/新依赖。
|
||||||
|
|
||||||
|
## Apply Progress
|
||||||
|
|
||||||
|
- Router/System/Knowledge contracts 与 executors 已完成,tasks 1.1-1.4 完成。
|
||||||
|
- 5 个 `ApplicationExecutorsTest` 通过:同输入 retry、最终 routing failure、System direct call、Knowledge exact references/no ID、NO_EVIDENCE/model skip。
|
||||||
|
- TODO:PublishedResult/JPA/V012、Diagnosis executor、总应用用例和综合验证。
|
||||||
|
- REVIEW:修正 `JpaChatRunStore` 多构造器 Spring 注入歧义;prior/start/intent/finish 持久化异常统一为稳定 `RUN_PERSISTENCE_FAILED`,并在安全完成时更新 ChatSession 活跃时间/消息对数。均为代码偏离修复,无需变更 OpenSpec。
|
||||||
|
- PublishedResult/JPA/V012、Diagnosis executor、protocol-neutral Run control 与总 ChatApplicationUseCase 已完成,tasks 2.1-3.4 完成。
|
||||||
|
- 13 个 stage 6A focused tests 通过;TODO 仅剩综合回归、static scope 和 OpenSpec verification。
|
||||||
|
- 综合回归曾在 `SemanticGuardTest.attemptTimeoutCancelsBothPermittedModelCalls` 出现负载相关失败。诊断确认生产代码对每次 timeout 均调用 `Future.cancel(true)`,但第二个 Future 可能在任务线程启动前已取消,此时不存在可接收 interrupt 的线程。分类为测试假设偏差,不是 OpenSpec 或生产代码偏离;回归断言改为两次 TIMEOUT attempt、两次模型预算预留,以及至少一个已运行调用收到 interrupt。
|
||||||
|
|
||||||
|
## Final Review
|
||||||
|
|
||||||
|
- Stage 6A focused tests 与阶段 2-5 regression 共 18 suites / 76 tests,0 failure/error/skipped;Maven compile 通过。
|
||||||
|
- OpenSpec strict validation 通过;V012、Entity、Repository 的三个字段和 previous-turn filter 对齐。
|
||||||
|
- 公开 Controller、前端和 endpoint 零 diff;Router/System executor 无 Tool、ReactAgent、ThreadLocal 或手写 loop。
|
||||||
|
- `PublishedResult` 只包含 `user_query/published_conclusion/scope/limitations/source_documents`,负向序列化测试通过。
|
||||||
|
- 本阶段不运行 live E2E,按 ISS-014 门禁留到阶段 7。
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Evidence: single-react-chat-application-usecase
|
||||||
|
|
||||||
|
## Code and Contract Evidence
|
||||||
|
|
||||||
|
- `DiagnosisHarnessCore`/`RunContext` 已提供 Run ID、预算、取消和 first-terminal-wins,应用用例无需创建第二套生命周期。
|
||||||
|
- `GuardModelCall`/`HarnessRetryExecutor` 提供单轮模型 timeout、Token 记账和两次 Router attempt。
|
||||||
|
- `HarnessEvidenceTools`/`RagToolResult` 提供 Knowledge 路径唯一 lookup 和有界 projection。
|
||||||
|
- `DiagnosisAgentUseCase`/`DiagnosisReleaseUseCase` 已形成 Diagnosis Draft 与安全发布边界。
|
||||||
|
- `DiagnosisRun`/`DiagnosisRunRepository`/V011 提供既有 Run 持久化,V012 以 nullable additive 字段扩展。
|
||||||
|
|
||||||
|
## Confirmed Boundaries
|
||||||
|
|
||||||
|
- Router 输入只含原始 Query、可选 last intent 和 last user query;最终失败不得默认进入 Diagnosis。
|
||||||
|
- 三类 executor 不互相调用,所有路径接收未改写 Query。
|
||||||
|
- PreviousTurn 只来自同 Session 最近 `DIAGNOSIS + SUCCESS + published_result`,且必须在保存当前 Run 前读取。
|
||||||
|
- Knowledge 公开内容只保留 stable document metadata,不发布 direct Tool Call ID。
|
||||||
|
- `PublishedResult` 不保存 Tool ID、raw evidence、完整 Draft 或 SemanticGuard reason;Fallback/Failed/Cancelled 不写安全历史。
|
||||||
|
- Controller/SSE/前端切换属于阶段 6B,本阶段保持公开协议不变。
|
||||||
|
|
||||||
|
## Diagnosis Finding
|
||||||
|
|
||||||
|
- 综合回归暴露 `SemanticGuardTest` 的负载竞态:第二个 Future 可能在获得线程前被取消,因而不会产生第二次 interrupt。
|
||||||
|
- 生产代码已对每次 timeout 调用 `Future.cancel(true)`;测试改为验证两次 TIMEOUT attempt、两次模型预算预留,以及至少一个运行中调用被中断。
|
||||||
|
- 分类为测试假设偏差,不是生产代码或 OpenSpec 偏离。
|
||||||
|
|
||||||
|
## Verification Evidence
|
||||||
|
|
||||||
|
- Stage 6A focused tests:13 tests 通过。
|
||||||
|
- Stage 2-5 与 6A 综合回归:18 suites / 76 tests,0 failure/error/skipped。
|
||||||
|
- Maven compile 与 change strict validation 通过。
|
||||||
|
- 公开 Controller/前端零 diff;Router/System executor 无 Tool、ReactAgent、ThreadLocal 或手写 loop。
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# ISS-014 单体 ReAct Agent、Harness 与 ACI 工具瘦身
|
# ISS-014 单体 ReAct Agent、Harness 与 ACI 工具瘦身
|
||||||
|
|
||||||
**状态**:实施中(阶段 0-5 已归档,下一阶段 6A)
|
**状态**:实施中(阶段 0-6A 已归档,下一阶段 6B)
|
||||||
**严重程度**:高
|
**严重程度**:高
|
||||||
**发现时间**:2026-07-20
|
**发现时间**:2026-07-20
|
||||||
**目标分支**:`refactor/chat-single-react-harness`
|
**目标分支**:`refactor/chat-single-react-harness`
|
||||||
@@ -1046,7 +1046,7 @@ ISS-014 是总设计 Issue,不创建跨阶段共享的 OpenSpec change。以
|
|||||||
| 3C | `single-react-mysql-readonly-tool` | Completed;已归档 |
|
| 3C | `single-react-mysql-readonly-tool` | Completed;已归档 |
|
||||||
| 4 | `single-react-diagnosis-agent` | Completed;已归档 |
|
| 4 | `single-react-diagnosis-agent` | Completed;已归档 |
|
||||||
| 5 | `single-react-evidence-semantic-guards` | Completed;已归档 |
|
| 5 | `single-react-evidence-semantic-guards` | Completed;已归档 |
|
||||||
| 6A | `single-react-chat-application-usecase` | Pending |
|
| 6A | `single-react-chat-application-usecase` | Completed;已归档 |
|
||||||
| 6B | `single-react-chat-sse-cutover` | Pending |
|
| 6B | `single-react-chat-sse-cutover` | Pending |
|
||||||
| 7 | `single-react-cleanup-e2e` | Pending |
|
| 7 | `single-react-cleanup-e2e` | Pending |
|
||||||
|
|
||||||
|
|||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ready
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
committed
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-07-21
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
阶段 2-5 已形成内部 Harness 核心:`RunContext`、canonical evidence tools、唯一 Diagnosis Agent、EvidenceGuard/SemanticGuard 和 release policy。当前生产 Chat 仍由旧 `ChatService`/Controller 创建 Run、选择 Agent、维护历史并生成答案,新的组件没有统一入口,也没有 `intent/release_outcome/published_result` 持久化,阶段 6B 无法只做协议切换。
|
||||||
|
|
||||||
|
阶段 6A 建立同步的内部 application use case 和 typed output。阶段 6B 将在受控任务执行器中调用它并把 observer 转成 SSE;本阶段不触碰 HTTP、前端或旧公开行为。
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- 应用用例拥有 session/run 创建、路由、固定 executor dispatch、最终终态和安全持久化。
|
||||||
|
- Router 只看最小输入并按 Harness policy 进行最多两次技术 attempt。
|
||||||
|
- SYSTEM_CHAT、KNOWLEDGE_QUERY、DIAGNOSIS 三条路径接收同一个未改写 Query,能力严格隔离。
|
||||||
|
- 从 MySQL `diagnosis_run` 确定性读取有界 PreviousTurn。
|
||||||
|
- 提供 protocol-neutral observer/run control,使阶段 6B 能发送 metadata/status 和处理断开取消。
|
||||||
|
- 新增可前向迁移、可回滚的 L3 数据库契约。
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- 不修改 Controller、SSE endpoint、前端消费者和 HTTP DTO。
|
||||||
|
- 不删除旧 ChatService、多 Agent、ThreadLocal 或旧 session storage。
|
||||||
|
- 不实现 token streaming、事件 schema 或 Controller 线程池。
|
||||||
|
- 不执行 live E2E;阶段 7 统一启动项目验证。
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. Application Use Case 是唯一业务入口
|
||||||
|
|
||||||
|
`ChatApplicationUseCase.execute(request, observer)` 固定流程:
|
||||||
|
|
||||||
|
```text
|
||||||
|
resolve session
|
||||||
|
-> read prior safe routing/diagnosis context
|
||||||
|
-> start one RunContext and persist RUNNING
|
||||||
|
-> observer.onStarted(run control)
|
||||||
|
-> IntentRouter
|
||||||
|
-> persist intent
|
||||||
|
-> fixed executor
|
||||||
|
-> complete Harness lifecycle
|
||||||
|
-> persist release outcome/public content/published result
|
||||||
|
-> typed ChatApplicationResult
|
||||||
|
```
|
||||||
|
|
||||||
|
它通过构造器接收 Router、三个 executor、Run store、Core 和 session ID supplier,不接收 Controller 注入的 ChatModel/Tool。Controller 在阶段 6B 只调用这一接口。
|
||||||
|
|
||||||
|
读取 prior context 必须早于当前 Run 持久化,避免 latest query 命中当前 PENDING/RUNNING 行。替代在 Controller 中先查历史;拒绝,因为会拆散生命周期所有权并制造 session/run 竞态。
|
||||||
|
|
||||||
|
### 2. Protocol-neutral observer 与取消控制
|
||||||
|
|
||||||
|
Observer 只接收 `ChatRunControl(sessionId, runId, cancelClientDisconnect)` 和固定 `ChatApplicationStatus`,不依赖 Spring MVC/SSE 类型。应用用例先成功写入 Run,再发送 started;阶段 6B 将 started 映射为 metadata,将 status 映射为安全状态事件。
|
||||||
|
|
||||||
|
Run control 只暴露客户端断开取消,不暴露 Core/RunContext。Observer 异常按执行失败处理并进入 Run 终态,避免协议层静默丢失连接后继续运行。
|
||||||
|
|
||||||
|
### 3. Router 是直接单轮模型调用
|
||||||
|
|
||||||
|
`IntentRouterInput` 只含原始 query、可选 last intent 和 last user query。Router 使用新的 system prompt 和共享 `GuardModelCall`,无 Tool/记忆/ReactAgent;严格解析唯一字段 `intent`。timeout/transport/任何非法输出映射到 `HarnessRetryPolicies.intentRouter()` 支持的失败类型,最多两次使用同一 JSON 输入。
|
||||||
|
|
||||||
|
两次失败抛出稳定 `ChatApplicationException(ROUTING_UNAVAILABLE)`;不得默认 DIAGNOSIS。取消/预算耗尽继续传播为 Run 终态。
|
||||||
|
|
||||||
|
### 4. 固定执行器与 typed public content
|
||||||
|
|
||||||
|
- SYSTEM_CHAT:一次 `GuardModelCall`,system prompt 只含受控产品能力说明;不接历史、Tool 或 evidence guard。
|
||||||
|
- KNOWLEDGE_QUERY:生成当前 direct invocation 的唯一 ID,调用 `HarnessEvidenceTools.lookup_knowledge` 一次。`NO_EVIDENCE` 返回固定安全说明;正证据只调用模型一次并严格解析 `KnowledgeAnswerDraft`,校验 exact call ID 和 document ID subset,再发布移除 Tool ID 的 `KnowledgeContent`。
|
||||||
|
- DIAGNOSIS:调用 `DiagnosisAgentUseCase(query, previousTurn)`,再调用 `DiagnosisReleaseUseCase`。SUCCESS 发布 `SemanticDraftView + verified sources`,Fallback 只发布 `SafeFallback`。
|
||||||
|
|
||||||
|
`ChatApplicationResult` 使用 `ChatContentType` 与 sealed typed content,不使用 Map/Object payload。阶段 6B 可直接序列化 content;Diagnosis public view 不含 Tool Call ID 或完整 evidence snapshot。
|
||||||
|
|
||||||
|
### 5. PreviousTurn policy 同时负责写入和读取边界
|
||||||
|
|
||||||
|
`PublishedResultPolicy` 从 SUCCESS Diagnosis 的 Query、Conclusion、Limitations/Scope 和 verified RAG metadata 构造有界 `PublishedResult`,并从持久化结果构造 `PreviousTurn`。每个字符串、list 和 source document 都按集中 limits 在字段边界截断;不调用 LLM。
|
||||||
|
|
||||||
|
只有 conclusion 非空才写 `published_result`。Repository 查询必须精确为同一 Session、`intent=DIAGNOSIS`、`release_outcome=SUCCESS`、JSON 非空,按 `created_at,id` 倒序一条。解析失败或字段无效时 fail closed 为 `previousTurn=null`。
|
||||||
|
|
||||||
|
### 6. Run store 和数据库契约
|
||||||
|
|
||||||
|
`DiagnosisRun` 新增:
|
||||||
|
|
||||||
|
- `intent VARCHAR(32)`,枚举 `SYSTEM_CHAT|KNOWLEDGE_QUERY|DIAGNOSIS`;
|
||||||
|
- `release_outcome VARCHAR(16)`,枚举 `SUCCESS|FALLBACK|FAILED|CANCELLED`;
|
||||||
|
- `published_result JSON`,只存固定安全结构。
|
||||||
|
|
||||||
|
V012 添加三列及索引 `session_id,intent,release_outcome,created_at,id`。`JpaChatRunStore` 负责 ensure ChatSession、start、markIntent、finish、latest routing context 和 safe PreviousTurn;JSON 使用注入的 ObjectMapper,禁止 Java serialization。
|
||||||
|
|
||||||
|
Run `status` 保持既有字符串:安全 SUCCESS/FALLBACK 都标记 `SUCCESS`,技术失败 `FAILED`,客户端/用户取消 `CANCELLED`;`release_outcome` 保留精确发布语义。finish 同时写安全 public content、budget tokens/tool count 和 duration。
|
||||||
|
|
||||||
|
### 7. 异常与 first-terminal-wins
|
||||||
|
|
||||||
|
应用用例 catch 所有执行异常后先读取 Run lifecycle:CANCELLED 映射 `release_outcome=CANCELLED`,其余未完成异常调用 `core.completeFailure` 并写 FAILED。成功/Fallback 都调用 `core.completeSuccess`。持久化错误不伪造成功;异常只通过稳定 `ChatFailureCode` 向阶段 6B 暴露,不携带供应商/JPA/Prompt 详情。
|
||||||
|
|
||||||
|
## Module and Ownership Audit
|
||||||
|
|
||||||
|
```text
|
||||||
|
ChatApplicationRequest
|
||||||
|
-> ChatApplicationUseCase (session/run/dispatch/terminal owner)
|
||||||
|
-> ChatRunStore (MySQL truth)
|
||||||
|
-> IntentRouter (minimal direct ChatModel)
|
||||||
|
-> SystemChatExecutor | KnowledgeQueryExecutor | DiagnosisChatExecutor
|
||||||
|
-> GuardModelCall | HarnessEvidenceTools | DiagnosisAgent/Release
|
||||||
|
-> ChatApplicationResult + Observer statuses
|
||||||
|
```
|
||||||
|
|
||||||
|
- Core owns deadline/cancel/budget/lifecycle;Application owns business dispatch and persistence;Store owns MySQL mapping;executors own path-specific behavior;Controller remains protocol-only.
|
||||||
|
- 最大风险是应用终态和 DB 写入分离;用例使用单一 finish path 和 first-terminal-wins lifecycle,focused tests 覆盖 success/fallback/failure/cancel。
|
||||||
|
- 最大兼容风险是 V012;Entity/migration/repository query 必须同步,阶段 6B 前不读取新入口不会改变公开行为。
|
||||||
|
- 不复用旧 `ChatService` 私有 routing/session helpers,避免新用例依赖旧多 Agent 结构。
|
||||||
|
|
||||||
|
## Interface Impact
|
||||||
|
|
||||||
|
- Level: L3 collaboration/database contract。
|
||||||
|
- Consumers: 阶段 6B Controller/SSE adapter、DiagnosisRunRepository、MySQL schema。
|
||||||
|
- Public HTTP/frontend: 本阶段无变化。
|
||||||
|
- Independent contract: 本 design 的 Database Contract/Migration Plan 章节作为独立接口说明。
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [Router hallucinated value] -> exact one-field JSON + enum parser + two-attempt policy + fail closed。
|
||||||
|
- [Knowledge model fabricates reference] -> exact invocation ID/document subset validation before public projection。
|
||||||
|
- [Current Run shadows previous context] -> prior reads before start/persist current Run。
|
||||||
|
- [PublishedResult leaks evidence] -> dedicated typed contract/policy and serialized-field negative tests。
|
||||||
|
- [Observer disconnect leaves work running] -> run control directly cancels shared Core context。
|
||||||
|
- [DB/entity drift] -> V012 migration + entity/repository/static schema tests + compile。
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
1. Apply V012 additive columns/index; all new columns nullable, old writers remain compatible。
|
||||||
|
2. Deploy stage 6A code while public Controller continues using old path;new fields remain unused except internal tests。
|
||||||
|
3. Stage 6B wires new application use case and begins writing fields atomically per Run。
|
||||||
|
4. Rollback: switch Controller back to old path, then optionally drop V012 index and three columns;V011 data and old writers remain valid。
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- None。具体模型/byte/PreviousTurn 数值由构造配置提供默认值,阶段 7 根据真实 Trace 校准。
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
阶段 2-5 已具备 RunContext、单一 Diagnosis Agent 和安全释放门禁,但仍没有统一的 Chat 应用用例负责创建 Run、三类意图路由、执行器选择、PreviousTurn 组装和最终持久化。阶段 6A 需要先完成这层内部应用边界,使阶段 6B 只做 HTTP/SSE 协议切换。
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- 新增内部 `ChatApplicationUseCase`,接收原始 Query 和可选 sessionId,创建并显式传播同一个 `RunContext`。
|
||||||
|
- 新增无 Tool、无记忆、无 ReAct 的三分类 Intent Router;输入只含 Query、可选 last intent 和 last user query,技术/非法输出最多两次 attempt,最终失败不默认进入 Diagnosis。
|
||||||
|
- 新增固定 SYSTEM_CHAT、KNOWLEDGE_QUERY、DIAGNOSIS 执行器映射;三条路径都接收未改写 Query,互不调用。
|
||||||
|
- SYSTEM_CHAT 使用受控单轮 ChatModel;KNOWLEDGE_QUERY 只调用一次 `lookup_knowledge`,基于有界 RAG projection 生成并验证引用;DIAGNOSIS 串联阶段 4 Agent 和阶段 5 release boundary。
|
||||||
|
- 从同一 Session 最近一个 `DIAGNOSIS + SUCCESS + published_result` 的 Run 确定性组装有界 PreviousTurn,不调用摘要模型、不复用历史日志/MySQL。
|
||||||
|
- 扩展 `diagnosis_run` 持久化 `intent`、`release_outcome` 和 JSON `published_result`,新增 JPA store 和 V012 Flyway migration。
|
||||||
|
- 新增应用执行 observer,使阶段 6B 能发送 metadata/status 并在断开时取消同一个 Run;本阶段只在内部测试入口使用。
|
||||||
|
- 不修改公开 Controller、`/api/chat`、`/api/chat_stream`、前端或现有 SSE 行为。
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `single-react-chat-application-usecase`: 定义 Run/session 生命周期、三类意图路由、固定执行器、PreviousTurn、知识回答引用验证、持久化和内部 observer 行为。
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- None. 公开 Chat/SSE capability 在阶段 6B 才改变。
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- 新增 `com.superbiz.agent.harness.application` 内部包、Router/System/Knowledge prompts、focused tests、JPA store 和 V012 migration。
|
||||||
|
- 修改 `DiagnosisRun`/`DiagnosisRunRepository`,新增数据库字段和安全 previous-turn 查询;复用 `DiagnosisHarnessCore`、`GuardModelCall`、`HarnessRetryExecutor`、`HarnessEvidenceTools`、`DiagnosisAgentUseCase` 和 `DiagnosisReleaseUseCase`。
|
||||||
|
- 接口影响为 L3:数据库 schema 和内部 application/store contract 变化,需要独立 migration/rollback 说明;HTTP/SSE/前端保持不变。
|
||||||
|
- 主要风险是当前 Run 污染上一回合查询、Router 失败误入 Diagnosis、Knowledge answer 引用伪造、PublishedResult 泄漏内部证据,以及异常路径没有持久化终态。
|
||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Application-owned session and Run lifecycle
|
||||||
|
The Chat Application Use Case SHALL resolve or generate the session ID, create exactly one RunContext, persist one matching Diagnosis Run, and propagate the same session ID, run ID, original Query, cancellation, budget, and terminal outcome through routing and the selected executor.
|
||||||
|
|
||||||
|
#### Scenario: Existing session request
|
||||||
|
- **WHEN** an internal request supplies a valid session ID
|
||||||
|
- **THEN** the use case preserves it, generates one run ID, and returns/persists the same identifiers
|
||||||
|
|
||||||
|
#### Scenario: New session request
|
||||||
|
- **WHEN** an internal request omits the session ID
|
||||||
|
- **THEN** the use case generates one valid session ID and uses it for all Run operations
|
||||||
|
|
||||||
|
### Requirement: Minimal isolated Intent Router
|
||||||
|
The Router SHALL execute a direct no-Tool, no-memory, no-ReAct ChatModel call whose input contains only the unchanged Query, optional last intent, and optional last user Query. It SHALL accept only `SYSTEM_CHAT`, `KNOWLEDGE_QUERY`, or `DIAGNOSIS`.
|
||||||
|
|
||||||
|
#### Scenario: Three valid routes
|
||||||
|
- **WHEN** the model returns each supported enum in the strict output schema
|
||||||
|
- **THEN** the use case dispatches to exactly the corresponding fixed executor
|
||||||
|
|
||||||
|
#### Scenario: New topic overrides history
|
||||||
|
- **WHEN** the current Query identifies a new topic while prior routing context exists
|
||||||
|
- **THEN** the model input still preserves the original current Query and prior fields are only optional context
|
||||||
|
|
||||||
|
### Requirement: Router technical retry fails closed
|
||||||
|
The Router SHALL use `HarnessRetryPolicies.intentRouter()` and SHALL retry timeout, transport, or invalid output at most once with identical input. A second failure MUST produce `ROUTING_UNAVAILABLE` and MUST NOT dispatch Diagnosis.
|
||||||
|
|
||||||
|
#### Scenario: Invalid output then valid route
|
||||||
|
- **WHEN** the first output is not a supported enum and the second output is valid
|
||||||
|
- **THEN** exactly two attempts use the same input and the valid route is executed
|
||||||
|
|
||||||
|
#### Scenario: Two invalid outputs
|
||||||
|
- **WHEN** both permitted attempts return invalid output
|
||||||
|
- **THEN** the Run ends FAILED and no intent executor is called
|
||||||
|
|
||||||
|
### Requirement: Fixed isolated executors
|
||||||
|
The Application Use Case SHALL map SYSTEM_CHAT to one no-Tool model response, KNOWLEDGE_QUERY to exactly one lookup-knowledge invocation plus one bounded answer model call, and DIAGNOSIS to the single Diagnosis Agent followed by the release boundary. Executors MUST NOT call one another or rewrite the Query.
|
||||||
|
|
||||||
|
#### Scenario: System Chat
|
||||||
|
- **WHEN** intent is SYSTEM_CHAT
|
||||||
|
- **THEN** no evidence Tool, Diagnosis Agent, EvidenceGuard, or SemanticGuard is invoked
|
||||||
|
|
||||||
|
#### Scenario: Knowledge Query
|
||||||
|
- **WHEN** intent is KNOWLEDGE_QUERY
|
||||||
|
- **THEN** only lookup_knowledge is invoked once and query_logs/query_mysql/Diagnosis ReAct are unavailable
|
||||||
|
|
||||||
|
#### Scenario: Diagnosis
|
||||||
|
- **WHEN** intent is DIAGNOSIS
|
||||||
|
- **THEN** the original Query and bounded PreviousTurn enter DiagnosisAgentUseCase and the Draft cannot publish before DiagnosisReleaseUseCase
|
||||||
|
|
||||||
|
### Requirement: Knowledge references are physically validated
|
||||||
|
The Knowledge executor SHALL accept only a bounded READY RAG projection, SHALL validate every model answer item against the exact direct invocation ID and returned document ID set, and SHALL remove Tool Call IDs from public content.
|
||||||
|
|
||||||
|
#### Scenario: Supported knowledge answer
|
||||||
|
- **WHEN** answer items reference the exact lookup call and only returned documents
|
||||||
|
- **THEN** public content contains answer text, stable document references, and limitations without Tool Call IDs
|
||||||
|
|
||||||
|
#### Scenario: Fabricated knowledge reference
|
||||||
|
- **WHEN** the model returns another call ID or an unknown document ID
|
||||||
|
- **THEN** the executor fails closed and no knowledge answer is published
|
||||||
|
|
||||||
|
#### Scenario: No knowledge evidence
|
||||||
|
- **WHEN** lookup returns READY `NO_EVIDENCE`
|
||||||
|
- **THEN** the executor returns a fixed bounded no-evidence answer without calling the answer model
|
||||||
|
|
||||||
|
### Requirement: Safe bounded PreviousTurn
|
||||||
|
The system SHALL load PreviousTurn only from the same Session's most recent Run with `intent=DIAGNOSIS`, `release_outcome=SUCCESS`, and non-null valid `published_result`. It SHALL deterministically bound fields and source documents without model summarization.
|
||||||
|
|
||||||
|
#### Scenario: Last safe Diagnosis exists
|
||||||
|
- **WHEN** a qualifying Run contains valid PublishedResult JSON
|
||||||
|
- **THEN** the next Diagnosis receives its bounded query, conclusion, scope, limitations, and RAG document metadata
|
||||||
|
|
||||||
|
#### Scenario: Last Run is fallback failed or cancelled
|
||||||
|
- **WHEN** newer Runs are not qualifying safe Diagnosis successes
|
||||||
|
- **THEN** they cannot become PreviousTurn and the query returns the most recent qualifying record or null
|
||||||
|
|
||||||
|
#### Scenario: Published result is corrupt
|
||||||
|
- **WHEN** stored JSON is invalid or required safe fields are blank
|
||||||
|
- **THEN** PreviousTurn is null and no raw stored value reaches a model
|
||||||
|
|
||||||
|
### Requirement: Diagnosis Run persistence contract
|
||||||
|
The `diagnosis_run` schema SHALL add nullable `intent`, `release_outcome`, and JSON `published_result`, and the JPA entity/repository/store SHALL write and query them consistently. PublishedResult MUST NOT contain Tool IDs, raw evidence, full Draft, or SemanticGuard reasons.
|
||||||
|
|
||||||
|
#### Scenario: Successful Diagnosis completion
|
||||||
|
- **WHEN** a Diagnosis report with non-null conclusion is safely released
|
||||||
|
- **THEN** the Run stores intent DIAGNOSIS, release outcome SUCCESS, safe public answer, bounded PublishedResult, duration and budget usage
|
||||||
|
|
||||||
|
#### Scenario: Fallback completion
|
||||||
|
- **WHEN** release returns SafeFallback
|
||||||
|
- **THEN** the Run status is SUCCESS, release outcome is FALLBACK, and published_result is null
|
||||||
|
|
||||||
|
#### Scenario: Failure or cancellation
|
||||||
|
- **WHEN** routing/execution fails or the client cancels the Run
|
||||||
|
- **THEN** the Run stores exactly one FAILED or CANCELLED release outcome and no PublishedResult
|
||||||
|
|
||||||
|
### Requirement: Protocol-neutral progress and cancellation
|
||||||
|
The use case SHALL notify a protocol-neutral observer after the Run is persisted, expose only session/run identifiers and client-disconnect cancellation, and emit only fixed safe application status codes.
|
||||||
|
|
||||||
|
#### Scenario: Observer start and status
|
||||||
|
- **WHEN** internal execution begins
|
||||||
|
- **THEN** observer start occurs once before routing status and contains the persisted session/run identifiers
|
||||||
|
|
||||||
|
#### Scenario: Client disconnect control
|
||||||
|
- **WHEN** the observer invokes client-disconnect cancellation
|
||||||
|
- **THEN** the same RunContext is cancelled and late executor results cannot complete successfully
|
||||||
|
|
||||||
|
### Requirement: Stage-six-A public isolation
|
||||||
|
Stage 6A SHALL NOT modify or switch public Chat Controller endpoints, SSE contracts, frontend consumers, or legacy ChatService behavior.
|
||||||
|
|
||||||
|
#### Scenario: Internal-only delivery
|
||||||
|
- **WHEN** stage 6A changes are inspected
|
||||||
|
- **THEN** Controller/frontend/public endpoint behavior has zero diff and stage 6B can consume the completed use case without rewriting it
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
## 1. Intent and single-turn executors
|
||||||
|
|
||||||
|
- [x] 1.1 Add typed Router input/limits/failure and strict three-enum direct ChatModel implementation with Harness retry auditing.
|
||||||
|
- [x] 1.2 Add fixed SYSTEM_CHAT executor and protocol-neutral application content/status contracts.
|
||||||
|
- [x] 1.3 Add KNOWLEDGE_QUERY executor with one lookup call, bounded RAG input, exact reference validation and ID-free public content.
|
||||||
|
- [x] 1.4 Add focused Router/System/Knowledge tests for mapping, identical retry input, fail-closed routing, one Tool call and fabricated references.
|
||||||
|
|
||||||
|
## 2. PreviousTurn and Run persistence
|
||||||
|
|
||||||
|
- [x] 2.1 Add PublishedResultPolicy and centralized limits for bounded write/read projection and RAG document extraction.
|
||||||
|
- [x] 2.2 Extend DiagnosisRun entity/repository and add V012 migration for intent, release_outcome, published_result and lookup index.
|
||||||
|
- [x] 2.3 Add ChatRunStore/JPA implementation for prior context, start, intent, safe finish and terminal metrics.
|
||||||
|
- [x] 2.4 Add focused policy/store/schema tests covering exact safe query, corrupt JSON, fallback exclusion and field leakage.
|
||||||
|
|
||||||
|
## 3. Application use case
|
||||||
|
|
||||||
|
- [x] 3.1 Add Diagnosis executor that composes stage 4 Agent and stage 5 release into ID-free typed content and optional PublishedResult.
|
||||||
|
- [x] 3.2 Implement ChatApplicationUseCase ordering prior reads, Run persistence, observer start/status, routing, dispatch and first-terminal-wins completion.
|
||||||
|
- [x] 3.3 Add protocol-neutral Run control whose client-disconnect cancellation targets the same RunContext.
|
||||||
|
- [x] 3.4 Add focused end-to-end use-case tests for all intents, original Query, previous turn, consistent IDs, success/fallback/failure/cancel persistence and observer order.
|
||||||
|
|
||||||
|
## 4. Verification and isolation
|
||||||
|
|
||||||
|
- [x] 4.1 Run stage 6A focused tests, relevant stage 2-5 regression tests and Maven compile.
|
||||||
|
- [x] 4.2 Validate strict OpenSpec, migration/entity alignment, no public Controller/frontend diff, no Router/ordinary executor Tool or Agent loop leakage, and no unsafe PublishedResult fields.
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# single-react-chat-application-usecase Specification
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
TBD - created by archiving change single-react-chat-application-usecase. Update Purpose after archive.
|
||||||
|
## Requirements
|
||||||
|
### Requirement: Application-owned session and Run lifecycle
|
||||||
|
The Chat Application Use Case SHALL resolve or generate the session ID, create exactly one RunContext, persist one matching Diagnosis Run, and propagate the same session ID, run ID, original Query, cancellation, budget, and terminal outcome through routing and the selected executor.
|
||||||
|
|
||||||
|
#### Scenario: Existing session request
|
||||||
|
- **WHEN** an internal request supplies a valid session ID
|
||||||
|
- **THEN** the use case preserves it, generates one run ID, and returns/persists the same identifiers
|
||||||
|
|
||||||
|
#### Scenario: New session request
|
||||||
|
- **WHEN** an internal request omits the session ID
|
||||||
|
- **THEN** the use case generates one valid session ID and uses it for all Run operations
|
||||||
|
|
||||||
|
### Requirement: Minimal isolated Intent Router
|
||||||
|
The Router SHALL execute a direct no-Tool, no-memory, no-ReAct ChatModel call whose input contains only the unchanged Query, optional last intent, and optional last user Query. It SHALL accept only `SYSTEM_CHAT`, `KNOWLEDGE_QUERY`, or `DIAGNOSIS`.
|
||||||
|
|
||||||
|
#### Scenario: Three valid routes
|
||||||
|
- **WHEN** the model returns each supported enum in the strict output schema
|
||||||
|
- **THEN** the use case dispatches to exactly the corresponding fixed executor
|
||||||
|
|
||||||
|
#### Scenario: New topic overrides history
|
||||||
|
- **WHEN** the current Query identifies a new topic while prior routing context exists
|
||||||
|
- **THEN** the model input still preserves the original current Query and prior fields are only optional context
|
||||||
|
|
||||||
|
### Requirement: Router technical retry fails closed
|
||||||
|
The Router SHALL use `HarnessRetryPolicies.intentRouter()` and SHALL retry timeout, transport, or invalid output at most once with identical input. A second failure MUST produce `ROUTING_UNAVAILABLE` and MUST NOT dispatch Diagnosis.
|
||||||
|
|
||||||
|
#### Scenario: Invalid output then valid route
|
||||||
|
- **WHEN** the first output is not a supported enum and the second output is valid
|
||||||
|
- **THEN** exactly two attempts use the same input and the valid route is executed
|
||||||
|
|
||||||
|
#### Scenario: Two invalid outputs
|
||||||
|
- **WHEN** both permitted attempts return invalid output
|
||||||
|
- **THEN** the Run ends FAILED and no intent executor is called
|
||||||
|
|
||||||
|
### Requirement: Fixed isolated executors
|
||||||
|
The Application Use Case SHALL map SYSTEM_CHAT to one no-Tool model response, KNOWLEDGE_QUERY to exactly one lookup-knowledge invocation plus one bounded answer model call, and DIAGNOSIS to the single Diagnosis Agent followed by the release boundary. Executors MUST NOT call one another or rewrite the Query.
|
||||||
|
|
||||||
|
#### Scenario: System Chat
|
||||||
|
- **WHEN** intent is SYSTEM_CHAT
|
||||||
|
- **THEN** no evidence Tool, Diagnosis Agent, EvidenceGuard, or SemanticGuard is invoked
|
||||||
|
|
||||||
|
#### Scenario: Knowledge Query
|
||||||
|
- **WHEN** intent is KNOWLEDGE_QUERY
|
||||||
|
- **THEN** only lookup_knowledge is invoked once and query_logs/query_mysql/Diagnosis ReAct are unavailable
|
||||||
|
|
||||||
|
#### Scenario: Diagnosis
|
||||||
|
- **WHEN** intent is DIAGNOSIS
|
||||||
|
- **THEN** the original Query and bounded PreviousTurn enter DiagnosisAgentUseCase and the Draft cannot publish before DiagnosisReleaseUseCase
|
||||||
|
|
||||||
|
### Requirement: Knowledge references are physically validated
|
||||||
|
The Knowledge executor SHALL accept only a bounded READY RAG projection, SHALL validate every model answer item against the exact direct invocation ID and returned document ID set, and SHALL remove Tool Call IDs from public content.
|
||||||
|
|
||||||
|
#### Scenario: Supported knowledge answer
|
||||||
|
- **WHEN** answer items reference the exact lookup call and only returned documents
|
||||||
|
- **THEN** public content contains answer text, stable document references, and limitations without Tool Call IDs
|
||||||
|
|
||||||
|
#### Scenario: Fabricated knowledge reference
|
||||||
|
- **WHEN** the model returns another call ID or an unknown document ID
|
||||||
|
- **THEN** the executor fails closed and no knowledge answer is published
|
||||||
|
|
||||||
|
#### Scenario: No knowledge evidence
|
||||||
|
- **WHEN** lookup returns READY `NO_EVIDENCE`
|
||||||
|
- **THEN** the executor returns a fixed bounded no-evidence answer without calling the answer model
|
||||||
|
|
||||||
|
### Requirement: Safe bounded PreviousTurn
|
||||||
|
The system SHALL load PreviousTurn only from the same Session's most recent Run with `intent=DIAGNOSIS`, `release_outcome=SUCCESS`, and non-null valid `published_result`. It SHALL deterministically bound fields and source documents without model summarization.
|
||||||
|
|
||||||
|
#### Scenario: Last safe Diagnosis exists
|
||||||
|
- **WHEN** a qualifying Run contains valid PublishedResult JSON
|
||||||
|
- **THEN** the next Diagnosis receives its bounded query, conclusion, scope, limitations, and RAG document metadata
|
||||||
|
|
||||||
|
#### Scenario: Last Run is fallback failed or cancelled
|
||||||
|
- **WHEN** newer Runs are not qualifying safe Diagnosis successes
|
||||||
|
- **THEN** they cannot become PreviousTurn and the query returns the most recent qualifying record or null
|
||||||
|
|
||||||
|
#### Scenario: Published result is corrupt
|
||||||
|
- **WHEN** stored JSON is invalid or required safe fields are blank
|
||||||
|
- **THEN** PreviousTurn is null and no raw stored value reaches a model
|
||||||
|
|
||||||
|
### Requirement: Diagnosis Run persistence contract
|
||||||
|
The `diagnosis_run` schema SHALL add nullable `intent`, `release_outcome`, and JSON `published_result`, and the JPA entity/repository/store SHALL write and query them consistently. PublishedResult MUST NOT contain Tool IDs, raw evidence, full Draft, or SemanticGuard reasons.
|
||||||
|
|
||||||
|
#### Scenario: Successful Diagnosis completion
|
||||||
|
- **WHEN** a Diagnosis report with non-null conclusion is safely released
|
||||||
|
- **THEN** the Run stores intent DIAGNOSIS, release outcome SUCCESS, safe public answer, bounded PublishedResult, duration and budget usage
|
||||||
|
|
||||||
|
#### Scenario: Fallback completion
|
||||||
|
- **WHEN** release returns SafeFallback
|
||||||
|
- **THEN** the Run status is SUCCESS, release outcome is FALLBACK, and published_result is null
|
||||||
|
|
||||||
|
#### Scenario: Failure or cancellation
|
||||||
|
- **WHEN** routing/execution fails or the client cancels the Run
|
||||||
|
- **THEN** the Run stores exactly one FAILED or CANCELLED release outcome and no PublishedResult
|
||||||
|
|
||||||
|
### Requirement: Protocol-neutral progress and cancellation
|
||||||
|
The use case SHALL notify a protocol-neutral observer after the Run is persisted, expose only session/run identifiers and client-disconnect cancellation, and emit only fixed safe application status codes.
|
||||||
|
|
||||||
|
#### Scenario: Observer start and status
|
||||||
|
- **WHEN** internal execution begins
|
||||||
|
- **THEN** observer start occurs once before routing status and contains the persisted session/run identifiers
|
||||||
|
|
||||||
|
#### Scenario: Client disconnect control
|
||||||
|
- **WHEN** the observer invokes client-disconnect cancellation
|
||||||
|
- **THEN** the same RunContext is cancelled and late executor results cannot complete successfully
|
||||||
|
|
||||||
|
### Requirement: Stage-six-A public isolation
|
||||||
|
Stage 6A SHALL NOT modify or switch public Chat Controller endpoints, SSE contracts, frontend consumers, or legacy ChatService behavior.
|
||||||
|
|
||||||
|
#### Scenario: Internal-only delivery
|
||||||
|
- **WHEN** stage 6A changes are inspected
|
||||||
|
- **THEN** Controller/frontend/public endpoint behavior has zero diff and stage 6B can consume the completed use case without rewriting it
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.superbiz.agent.domain.entity;
|
package com.superbiz.agent.domain.entity;
|
||||||
|
|
||||||
|
import com.superbiz.agent.harness.contract.IntentType;
|
||||||
|
import com.superbiz.agent.harness.contract.ReleaseOutcome;
|
||||||
import jakarta.persistence.*;
|
import jakarta.persistence.*;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
@@ -48,6 +50,18 @@ public class DiagnosisRun {
|
|||||||
@Column(name = "answer", columnDefinition = "LONGTEXT")
|
@Column(name = "answer", columnDefinition = "LONGTEXT")
|
||||||
private String answer;
|
private String answer;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "intent", length = 32)
|
||||||
|
private IntentType intent;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(name = "release_outcome", length = 16)
|
||||||
|
private ReleaseOutcome releaseOutcome;
|
||||||
|
|
||||||
|
@JdbcTypeCode(SqlTypes.JSON)
|
||||||
|
@Column(name = "published_result", columnDefinition = "JSON")
|
||||||
|
private String publishedResult;
|
||||||
|
|
||||||
@JdbcTypeCode(SqlTypes.JSON)
|
@JdbcTypeCode(SqlTypes.JSON)
|
||||||
@Column(name = "self_evaluation", columnDefinition = "JSON")
|
@Column(name = "self_evaluation", columnDefinition = "JSON")
|
||||||
private String selfEvaluation;
|
private String selfEvaluation;
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.superbiz.agent.harness.application;
|
||||||
|
|
||||||
|
public sealed interface ChatApplicationContent permits
|
||||||
|
SystemChatContent, KnowledgeContent, DiagnosisContent, FallbackContent {
|
||||||
|
|
||||||
|
ChatContentType contentType();
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.superbiz.agent.harness.application;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
public final class ChatApplicationException extends RuntimeException {
|
||||||
|
|
||||||
|
private final ChatFailureCode code;
|
||||||
|
|
||||||
|
public ChatApplicationException(ChatFailureCode code, String message) {
|
||||||
|
super(message);
|
||||||
|
this.code = Objects.requireNonNull(code, "code must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
public ChatApplicationException(ChatFailureCode code, String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
this.code = Objects.requireNonNull(code, "code must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
public ChatFailureCode code() {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.superbiz.agent.harness.application;
|
||||||
|
|
||||||
|
public interface ChatApplicationObserver {
|
||||||
|
|
||||||
|
void onStarted(ChatRunControl runControl);
|
||||||
|
|
||||||
|
void onStatus(ChatApplicationStatus status);
|
||||||
|
|
||||||
|
static ChatApplicationObserver noop() {
|
||||||
|
return new ChatApplicationObserver() {
|
||||||
|
@Override
|
||||||
|
public void onStarted(ChatRunControl runControl) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onStatus(ChatApplicationStatus status) {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.superbiz.agent.harness.application;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
|
||||||
|
public record ChatApplicationRequest(
|
||||||
|
@JsonProperty("query") String query,
|
||||||
|
@JsonProperty("session_id") String sessionId) {
|
||||||
|
|
||||||
|
public ChatApplicationRequest {
|
||||||
|
if (query == null || query.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("query must not be blank");
|
||||||
|
}
|
||||||
|
if (sessionId != null && sessionId.isBlank()) {
|
||||||
|
sessionId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.superbiz.agent.harness.application;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import com.superbiz.agent.harness.contract.IntentType;
|
||||||
|
import com.superbiz.agent.harness.contract.ReleaseOutcome;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
public record ChatApplicationResult(
|
||||||
|
@JsonProperty("session_id") String sessionId,
|
||||||
|
@JsonProperty("run_id") String runId,
|
||||||
|
@JsonProperty("intent") IntentType intent,
|
||||||
|
@JsonProperty("outcome") ReleaseOutcome outcome,
|
||||||
|
@JsonProperty("content_type") ChatContentType contentType,
|
||||||
|
@JsonProperty("content") ChatApplicationContent content) {
|
||||||
|
|
||||||
|
public ChatApplicationResult {
|
||||||
|
requireText(sessionId, "sessionId");
|
||||||
|
requireText(runId, "runId");
|
||||||
|
Objects.requireNonNull(intent, "intent must not be null");
|
||||||
|
Objects.requireNonNull(outcome, "outcome must not be null");
|
||||||
|
Objects.requireNonNull(content, "content must not be null");
|
||||||
|
if (outcome != ReleaseOutcome.SUCCESS && outcome != ReleaseOutcome.FALLBACK) {
|
||||||
|
throw new IllegalArgumentException("application result must be SUCCESS or FALLBACK");
|
||||||
|
}
|
||||||
|
if (contentType != content.contentType()) {
|
||||||
|
throw new IllegalArgumentException("contentType does not match content");
|
||||||
|
}
|
||||||
|
if (outcome == ReleaseOutcome.FALLBACK && contentType != ChatContentType.SAFE_FALLBACK) {
|
||||||
|
throw new IllegalArgumentException("fallback outcome requires safe fallback content");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void requireText(String value, String name) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
throw new IllegalArgumentException(name + " must not be blank");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.superbiz.agent.harness.application;
|
||||||
|
|
||||||
|
public enum ChatApplicationStatus {
|
||||||
|
ROUTING("正在识别请求类型"),
|
||||||
|
SYSTEM_RESPONDING("正在生成回答"),
|
||||||
|
KNOWLEDGE_SEARCHING("正在查询知识库"),
|
||||||
|
KNOWLEDGE_ANSWERING("正在整理知识答案"),
|
||||||
|
DIAGNOSIS_RUNNING("正在收集诊断证据"),
|
||||||
|
SAFETY_VALIDATING("正在进行安全校验");
|
||||||
|
|
||||||
|
private final String message;
|
||||||
|
|
||||||
|
ChatApplicationStatus(String message) {
|
||||||
|
this.message = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String message() {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
package com.superbiz.agent.harness.application;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.superbiz.agent.harness.application.persistence.ChatRunStore;
|
||||||
|
import com.superbiz.agent.harness.application.persistence.RoutingHistory;
|
||||||
|
import com.superbiz.agent.harness.application.routing.IntentRouterInput;
|
||||||
|
import com.superbiz.agent.harness.application.routing.IntentRoutingException;
|
||||||
|
import com.superbiz.agent.harness.contract.IntentType;
|
||||||
|
import com.superbiz.agent.harness.contract.PreviousTurn;
|
||||||
|
import com.superbiz.agent.harness.contract.PublishedResult;
|
||||||
|
import com.superbiz.agent.harness.contract.ReleaseOutcome;
|
||||||
|
import com.superbiz.agent.harness.core.DiagnosisHarnessCore;
|
||||||
|
import com.superbiz.agent.harness.core.RunCancellationReason;
|
||||||
|
import com.superbiz.agent.harness.core.RunContext;
|
||||||
|
import com.superbiz.agent.harness.core.RunState;
|
||||||
|
import com.superbiz.agent.harness.retry.RetryExecutionException;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
public final class ChatApplicationUseCase {
|
||||||
|
|
||||||
|
private static final Pattern SAFE_ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}");
|
||||||
|
|
||||||
|
private final DiagnosisHarnessCore core;
|
||||||
|
private final Supplier<String> sessionIdSupplier;
|
||||||
|
private final ChatRunStore runStore;
|
||||||
|
private final IntentRouting router;
|
||||||
|
private final SystemChatOperation systemChat;
|
||||||
|
private final KnowledgeQueryOperation knowledgeQuery;
|
||||||
|
private final DiagnosisOperation diagnosis;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
public ChatApplicationUseCase(DiagnosisHarnessCore core,
|
||||||
|
Supplier<String> sessionIdSupplier,
|
||||||
|
ChatRunStore runStore,
|
||||||
|
IntentRouting router,
|
||||||
|
SystemChatOperation systemChat,
|
||||||
|
KnowledgeQueryOperation knowledgeQuery,
|
||||||
|
DiagnosisOperation diagnosis,
|
||||||
|
ObjectMapper objectMapper) {
|
||||||
|
this.core = Objects.requireNonNull(core, "core must not be null");
|
||||||
|
this.sessionIdSupplier = Objects.requireNonNull(
|
||||||
|
sessionIdSupplier, "sessionIdSupplier must not be null");
|
||||||
|
this.runStore = Objects.requireNonNull(runStore, "runStore must not be null");
|
||||||
|
this.router = Objects.requireNonNull(router, "router must not be null");
|
||||||
|
this.systemChat = Objects.requireNonNull(systemChat, "systemChat must not be null");
|
||||||
|
this.knowledgeQuery = Objects.requireNonNull(knowledgeQuery, "knowledgeQuery must not be null");
|
||||||
|
this.diagnosis = Objects.requireNonNull(diagnosis, "diagnosis must not be null");
|
||||||
|
this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
public ChatApplicationResult execute(ChatApplicationRequest request) {
|
||||||
|
return execute(request, ChatApplicationObserver.noop());
|
||||||
|
}
|
||||||
|
|
||||||
|
public ChatApplicationResult execute(ChatApplicationRequest request,
|
||||||
|
ChatApplicationObserver observer) {
|
||||||
|
Objects.requireNonNull(request, "request must not be null");
|
||||||
|
Objects.requireNonNull(observer, "observer must not be null");
|
||||||
|
String sessionId = resolveSessionId(request.sessionId());
|
||||||
|
|
||||||
|
Optional<RoutingHistory> history;
|
||||||
|
Optional<PreviousTurn> previousTurn;
|
||||||
|
try {
|
||||||
|
history = runStore.findLatestRoutingHistory(sessionId);
|
||||||
|
previousTurn = runStore.findPreviousTurn(sessionId);
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
throw new ChatApplicationException(
|
||||||
|
ChatFailureCode.RUN_PERSISTENCE_FAILED,
|
||||||
|
"无法读取会话上下文,请稍后重试", exception);
|
||||||
|
}
|
||||||
|
RunContext context = core.startRun(sessionId);
|
||||||
|
long startedNanos = System.nanoTime();
|
||||||
|
IntentType intent = null;
|
||||||
|
try {
|
||||||
|
persistStart(context, request.query());
|
||||||
|
observer.onStarted(new CoreRunControl(core, context));
|
||||||
|
observer.onStatus(ChatApplicationStatus.ROUTING);
|
||||||
|
intent = router.route(context, new IntentRouterInput(
|
||||||
|
request.query(),
|
||||||
|
history.map(RoutingHistory::intent).orElse(null),
|
||||||
|
history.map(RoutingHistory::userQuery).orElse(null)));
|
||||||
|
persistIntent(context.runId(), intent);
|
||||||
|
|
||||||
|
PathResult path = executePath(
|
||||||
|
intent, context, request.query(), previousTurn.orElse(null), observer);
|
||||||
|
core.checkActive(context);
|
||||||
|
core.completeSuccess(context);
|
||||||
|
String safeJson = write(path.content());
|
||||||
|
persistFinish(context, intent, path.outcome(), safeJson,
|
||||||
|
path.publishedResult(), durationMillis(startedNanos));
|
||||||
|
return new ChatApplicationResult(
|
||||||
|
context.sessionId(), context.runId(), intent, path.outcome(),
|
||||||
|
path.content().contentType(), path.content());
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
ReleaseOutcome terminal = terminalOutcome(context);
|
||||||
|
try {
|
||||||
|
runStore.finish(context, intent, terminal, null, null,
|
||||||
|
durationMillis(startedNanos));
|
||||||
|
} catch (RuntimeException persistenceFailure) {
|
||||||
|
exception.addSuppressed(persistenceFailure);
|
||||||
|
}
|
||||||
|
throw safeFailure(exception, terminal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private PathResult executePath(IntentType intent,
|
||||||
|
RunContext context,
|
||||||
|
String query,
|
||||||
|
PreviousTurn previousTurn,
|
||||||
|
ChatApplicationObserver observer) {
|
||||||
|
return switch (intent) {
|
||||||
|
case SYSTEM_CHAT -> {
|
||||||
|
observer.onStatus(ChatApplicationStatus.SYSTEM_RESPONDING);
|
||||||
|
yield new PathResult(
|
||||||
|
ReleaseOutcome.SUCCESS, systemChat.execute(context, query), null);
|
||||||
|
}
|
||||||
|
case KNOWLEDGE_QUERY -> {
|
||||||
|
observer.onStatus(ChatApplicationStatus.KNOWLEDGE_SEARCHING);
|
||||||
|
observer.onStatus(ChatApplicationStatus.KNOWLEDGE_ANSWERING);
|
||||||
|
yield new PathResult(
|
||||||
|
ReleaseOutcome.SUCCESS, knowledgeQuery.execute(context, query), null);
|
||||||
|
}
|
||||||
|
case DIAGNOSIS -> {
|
||||||
|
DiagnosisExecutionResult result = diagnosis.execute(
|
||||||
|
context, query, previousTurn, observer::onStatus);
|
||||||
|
yield new PathResult(result.outcome(), result.content(), result.publishedResult());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void persistStart(RunContext context, String query) {
|
||||||
|
try {
|
||||||
|
runStore.start(context, query);
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
throw new ChatApplicationException(
|
||||||
|
ChatFailureCode.RUN_PERSISTENCE_FAILED,
|
||||||
|
"无法创建诊断运行记录,请稍后重试", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void persistIntent(String runId, IntentType intent) {
|
||||||
|
try {
|
||||||
|
runStore.markIntent(runId, intent);
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
throw new ChatApplicationException(
|
||||||
|
ChatFailureCode.RUN_PERSISTENCE_FAILED,
|
||||||
|
"无法记录请求类型,请稍后重试", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void persistFinish(RunContext context,
|
||||||
|
IntentType intent,
|
||||||
|
ReleaseOutcome outcome,
|
||||||
|
String safeJson,
|
||||||
|
PublishedResult publishedResult,
|
||||||
|
int durationMs) {
|
||||||
|
try {
|
||||||
|
runStore.finish(context, intent, outcome, safeJson, publishedResult, durationMs);
|
||||||
|
} catch (RuntimeException exception) {
|
||||||
|
throw new ChatApplicationException(
|
||||||
|
ChatFailureCode.RUN_PERSISTENCE_FAILED,
|
||||||
|
"无法记录运行终态,请稍后重试", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ReleaseOutcome terminalOutcome(RunContext context) {
|
||||||
|
RunState state = context.lifecycle().state();
|
||||||
|
if (state == RunState.CANCELLED) {
|
||||||
|
return ReleaseOutcome.CANCELLED;
|
||||||
|
}
|
||||||
|
if (!state.isTerminal()) {
|
||||||
|
core.completeFailure(context, "APPLICATION_EXECUTION_FAILED");
|
||||||
|
}
|
||||||
|
return context.lifecycle().state() == RunState.CANCELLED
|
||||||
|
? ReleaseOutcome.CANCELLED : ReleaseOutcome.FAILED;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ChatApplicationException safeFailure(RuntimeException exception,
|
||||||
|
ReleaseOutcome terminal) {
|
||||||
|
if (terminal == ReleaseOutcome.CANCELLED) {
|
||||||
|
return new ChatApplicationException(
|
||||||
|
ChatFailureCode.RUN_CANCELLED, "Chat Run was cancelled", exception);
|
||||||
|
}
|
||||||
|
if (exception instanceof IntentRoutingException) {
|
||||||
|
return new ChatApplicationException(
|
||||||
|
ChatFailureCode.ROUTING_UNAVAILABLE,
|
||||||
|
"当前暂时无法识别请求类型,请稍后重试", exception);
|
||||||
|
}
|
||||||
|
if (exception instanceof ChatApplicationException applicationFailure) {
|
||||||
|
return applicationFailure;
|
||||||
|
}
|
||||||
|
if (exception instanceof RetryExecutionException retry
|
||||||
|
&& retry.failure() == com.superbiz.agent.harness.retry.RetryFailure.CANCELLED) {
|
||||||
|
return new ChatApplicationException(
|
||||||
|
ChatFailureCode.RUN_CANCELLED, "Chat Run was cancelled", exception);
|
||||||
|
}
|
||||||
|
return new ChatApplicationException(
|
||||||
|
ChatFailureCode.INTERNAL_FAILURE, "当前暂时无法处理该请求,请稍后重试", exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveSessionId(String requested) {
|
||||||
|
String value = requested == null ? sessionIdSupplier.get() : requested;
|
||||||
|
if (value == null || !SAFE_ID.matcher(value).matches()) {
|
||||||
|
throw new IllegalArgumentException("sessionId is invalid");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String write(ChatApplicationContent content) {
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(content);
|
||||||
|
} catch (JsonProcessingException exception) {
|
||||||
|
throw new ChatApplicationException(
|
||||||
|
ChatFailureCode.INTERNAL_FAILURE, "Public content is not serializable", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int durationMillis(long startedNanos) {
|
||||||
|
long millis = Math.max(0L, (System.nanoTime() - startedNanos) / 1_000_000L);
|
||||||
|
return millis >= Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) millis;
|
||||||
|
}
|
||||||
|
|
||||||
|
private record PathResult(
|
||||||
|
ReleaseOutcome outcome,
|
||||||
|
ChatApplicationContent content,
|
||||||
|
PublishedResult publishedResult) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class CoreRunControl implements ChatRunControl {
|
||||||
|
|
||||||
|
private final DiagnosisHarnessCore core;
|
||||||
|
private final RunContext context;
|
||||||
|
|
||||||
|
private CoreRunControl(DiagnosisHarnessCore core, RunContext context) {
|
||||||
|
this.core = core;
|
||||||
|
this.context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String sessionId() {
|
||||||
|
return context.sessionId();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String runId() {
|
||||||
|
return context.runId();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean cancelClientDisconnect() {
|
||||||
|
return core.cancel(context, RunCancellationReason.CLIENT_DISCONNECTED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.superbiz.agent.harness.application;
|
||||||
|
|
||||||
|
public enum ChatContentType {
|
||||||
|
SYSTEM_CHAT,
|
||||||
|
KNOWLEDGE_ANSWER,
|
||||||
|
DIAGNOSIS_REPORT,
|
||||||
|
SAFE_FALLBACK
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.superbiz.agent.harness.application;
|
||||||
|
|
||||||
|
public enum ChatFailureCode {
|
||||||
|
ROUTING_UNAVAILABLE,
|
||||||
|
SYSTEM_CHAT_UNAVAILABLE,
|
||||||
|
KNOWLEDGE_UNAVAILABLE,
|
||||||
|
DIAGNOSIS_UNAVAILABLE,
|
||||||
|
RUN_PERSISTENCE_FAILED,
|
||||||
|
RUN_CANCELLED,
|
||||||
|
INTERNAL_FAILURE
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.superbiz.agent.harness.application;
|
||||||
|
|
||||||
|
public interface ChatRunControl {
|
||||||
|
|
||||||
|
String sessionId();
|
||||||
|
|
||||||
|
String runId();
|
||||||
|
|
||||||
|
boolean cancelClientDisconnect();
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.superbiz.agent.harness.application;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import com.superbiz.agent.harness.contract.SafeFallback;
|
||||||
|
import com.superbiz.agent.harness.guard.semantic.SemanticDraftView;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
public record DiagnosisContent(
|
||||||
|
@JsonProperty("report") SemanticDraftView report,
|
||||||
|
@JsonProperty("references") List<SafeFallback.VerifiedSource> references)
|
||||||
|
implements ChatApplicationContent {
|
||||||
|
|
||||||
|
public DiagnosisContent {
|
||||||
|
Objects.requireNonNull(report, "report must not be null");
|
||||||
|
references = references == null ? List.of() : List.copyOf(references);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ChatContentType contentType() {
|
||||||
|
return ChatContentType.DIAGNOSIS_REPORT;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.superbiz.agent.harness.application;
|
||||||
|
|
||||||
|
import com.superbiz.agent.harness.contract.PublishedResult;
|
||||||
|
import com.superbiz.agent.harness.contract.ReleaseOutcome;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
public record DiagnosisExecutionResult(
|
||||||
|
ReleaseOutcome outcome,
|
||||||
|
ChatApplicationContent content,
|
||||||
|
PublishedResult publishedResult) {
|
||||||
|
|
||||||
|
public DiagnosisExecutionResult {
|
||||||
|
Objects.requireNonNull(outcome, "outcome must not be null");
|
||||||
|
Objects.requireNonNull(content, "content must not be null");
|
||||||
|
if (outcome == ReleaseOutcome.SUCCESS && content.contentType() != ChatContentType.DIAGNOSIS_REPORT) {
|
||||||
|
throw new IllegalArgumentException("success requires diagnosis report");
|
||||||
|
}
|
||||||
|
if (outcome == ReleaseOutcome.FALLBACK && content.contentType() != ChatContentType.SAFE_FALLBACK) {
|
||||||
|
throw new IllegalArgumentException("fallback requires safe fallback");
|
||||||
|
}
|
||||||
|
if (outcome != ReleaseOutcome.SUCCESS && outcome != ReleaseOutcome.FALLBACK) {
|
||||||
|
throw new IllegalArgumentException("diagnosis execution must be SUCCESS or FALLBACK");
|
||||||
|
}
|
||||||
|
if (outcome != ReleaseOutcome.SUCCESS && publishedResult != null) {
|
||||||
|
throw new IllegalArgumentException("only success can contain PublishedResult");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.superbiz.agent.harness.application;
|
||||||
|
|
||||||
|
import com.superbiz.agent.harness.contract.PreviousTurn;
|
||||||
|
import com.superbiz.agent.harness.core.RunContext;
|
||||||
|
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface DiagnosisOperation {
|
||||||
|
|
||||||
|
DiagnosisExecutionResult execute(RunContext context,
|
||||||
|
String query,
|
||||||
|
PreviousTurn previousTurn,
|
||||||
|
Consumer<ChatApplicationStatus> statusSink);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.superbiz.agent.harness.application;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import com.superbiz.agent.harness.contract.SafeFallback;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
public record FallbackContent(@JsonProperty("fallback") SafeFallback fallback)
|
||||||
|
implements ChatApplicationContent {
|
||||||
|
|
||||||
|
public FallbackContent {
|
||||||
|
Objects.requireNonNull(fallback, "fallback must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ChatContentType contentType() {
|
||||||
|
return ChatContentType.SAFE_FALLBACK;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.superbiz.agent.harness.application;
|
||||||
|
|
||||||
|
import com.superbiz.agent.harness.application.routing.IntentRouterInput;
|
||||||
|
import com.superbiz.agent.harness.contract.IntentType;
|
||||||
|
import com.superbiz.agent.harness.core.RunContext;
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface IntentRouting {
|
||||||
|
|
||||||
|
IntentType route(RunContext context, IntentRouterInput input);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.superbiz.agent.harness.application;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import com.superbiz.agent.harness.contract.SourceDocument;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record KnowledgeContent(
|
||||||
|
@JsonProperty("answer") String answer,
|
||||||
|
@JsonProperty("references") List<SourceDocument> references,
|
||||||
|
@JsonProperty("limitations") List<String> limitations)
|
||||||
|
implements ChatApplicationContent {
|
||||||
|
|
||||||
|
public KnowledgeContent {
|
||||||
|
if (answer == null || answer.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("answer must not be blank");
|
||||||
|
}
|
||||||
|
references = references == null ? List.of() : List.copyOf(references);
|
||||||
|
limitations = limitations == null ? List.of() : List.copyOf(limitations);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ChatContentType contentType() {
|
||||||
|
return ChatContentType.KNOWLEDGE_ANSWER;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.superbiz.agent.harness.application;
|
||||||
|
|
||||||
|
import com.superbiz.agent.harness.core.RunContext;
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface KnowledgeQueryOperation {
|
||||||
|
|
||||||
|
KnowledgeContent execute(RunContext context, String query);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.superbiz.agent.harness.application;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
|
||||||
|
public record SystemChatContent(@JsonProperty("answer") String answer)
|
||||||
|
implements ChatApplicationContent {
|
||||||
|
|
||||||
|
public SystemChatContent {
|
||||||
|
requireText(answer, "answer");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ChatContentType contentType() {
|
||||||
|
return ChatContentType.SYSTEM_CHAT;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void requireText(String value, String name) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
throw new IllegalArgumentException(name + " must not be blank");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.superbiz.agent.harness.application;
|
||||||
|
|
||||||
|
import com.superbiz.agent.harness.core.RunContext;
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface SystemChatOperation {
|
||||||
|
|
||||||
|
SystemChatContent execute(RunContext context, String query);
|
||||||
|
}
|
||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
package com.superbiz.agent.harness.application.executor;
|
||||||
|
|
||||||
|
import com.superbiz.agent.harness.agent.DiagnosisAgentInput;
|
||||||
|
import com.superbiz.agent.harness.agent.DiagnosisAgentUseCase;
|
||||||
|
import com.superbiz.agent.harness.application.ChatApplicationStatus;
|
||||||
|
import com.superbiz.agent.harness.application.DiagnosisContent;
|
||||||
|
import com.superbiz.agent.harness.application.DiagnosisExecutionResult;
|
||||||
|
import com.superbiz.agent.harness.application.DiagnosisOperation;
|
||||||
|
import com.superbiz.agent.harness.application.FallbackContent;
|
||||||
|
import com.superbiz.agent.harness.application.persistence.PublishedResultPolicy;
|
||||||
|
import com.superbiz.agent.harness.contract.DiagnosisDraft;
|
||||||
|
import com.superbiz.agent.harness.contract.PreviousTurn;
|
||||||
|
import com.superbiz.agent.harness.contract.PublishedResult;
|
||||||
|
import com.superbiz.agent.harness.contract.ReleaseOutcome;
|
||||||
|
import com.superbiz.agent.harness.core.RunContext;
|
||||||
|
import com.superbiz.agent.harness.guard.semantic.SemanticDraftView;
|
||||||
|
import com.superbiz.agent.harness.release.DiagnosisReleaseResult;
|
||||||
|
import com.superbiz.agent.harness.release.DiagnosisReleaseUseCase;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
public final class DiagnosisChatExecutor implements DiagnosisOperation {
|
||||||
|
|
||||||
|
private final DiagnosisAgentUseCase diagnosisAgent;
|
||||||
|
private final DiagnosisReleaseUseCase releaseUseCase;
|
||||||
|
private final PublishedResultPolicy publishedPolicy;
|
||||||
|
|
||||||
|
public DiagnosisChatExecutor(DiagnosisAgentUseCase diagnosisAgent,
|
||||||
|
DiagnosisReleaseUseCase releaseUseCase,
|
||||||
|
PublishedResultPolicy publishedPolicy) {
|
||||||
|
this.diagnosisAgent = Objects.requireNonNull(diagnosisAgent, "diagnosisAgent must not be null");
|
||||||
|
this.releaseUseCase = Objects.requireNonNull(releaseUseCase, "releaseUseCase must not be null");
|
||||||
|
this.publishedPolicy = Objects.requireNonNull(publishedPolicy, "publishedPolicy must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public DiagnosisExecutionResult execute(RunContext context,
|
||||||
|
String query,
|
||||||
|
PreviousTurn previousTurn,
|
||||||
|
Consumer<ChatApplicationStatus> statusSink) {
|
||||||
|
Objects.requireNonNull(statusSink, "statusSink must not be null");
|
||||||
|
statusSink.accept(ChatApplicationStatus.DIAGNOSIS_RUNNING);
|
||||||
|
DiagnosisDraft draft = diagnosisAgent.execute(
|
||||||
|
context, new DiagnosisAgentInput(query, previousTurn));
|
||||||
|
statusSink.accept(ChatApplicationStatus.SAFETY_VALIDATING);
|
||||||
|
DiagnosisReleaseResult released = releaseUseCase.execute(context, query, draft);
|
||||||
|
if (released.outcome() == ReleaseOutcome.FALLBACK) {
|
||||||
|
return new DiagnosisExecutionResult(
|
||||||
|
ReleaseOutcome.FALLBACK,
|
||||||
|
new FallbackContent(released.fallback()),
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
PublishedResult published = publishedPolicy.create(
|
||||||
|
query, released.draft(), released.verifiedEvidence()).orElse(null);
|
||||||
|
return new DiagnosisExecutionResult(
|
||||||
|
ReleaseOutcome.SUCCESS,
|
||||||
|
new DiagnosisContent(
|
||||||
|
SemanticDraftView.from(released.draft()),
|
||||||
|
released.verifiedEvidence().verifiedSources()),
|
||||||
|
published);
|
||||||
|
}
|
||||||
|
}
|
||||||
+211
@@ -0,0 +1,211 @@
|
|||||||
|
package com.superbiz.agent.harness.application.executor;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectReader;
|
||||||
|
import com.superbiz.agent.harness.agent.HarnessEvidenceTools;
|
||||||
|
import com.superbiz.agent.harness.application.ChatApplicationException;
|
||||||
|
import com.superbiz.agent.harness.application.ChatFailureCode;
|
||||||
|
import com.superbiz.agent.harness.application.KnowledgeContent;
|
||||||
|
import com.superbiz.agent.harness.application.KnowledgeQueryOperation;
|
||||||
|
import com.superbiz.agent.harness.contract.EvidenceStatus;
|
||||||
|
import com.superbiz.agent.harness.contract.InvocationStatus;
|
||||||
|
import com.superbiz.agent.harness.contract.KnowledgeAnswerDraft;
|
||||||
|
import com.superbiz.agent.harness.contract.SourceDocument;
|
||||||
|
import com.superbiz.agent.harness.core.DiagnosisHarnessCore;
|
||||||
|
import com.superbiz.agent.harness.core.RunContext;
|
||||||
|
import com.superbiz.agent.harness.guard.semantic.GuardModelCall;
|
||||||
|
import com.superbiz.agent.harness.tool.boundary.ToolBoundaryResult;
|
||||||
|
import com.superbiz.agent.harness.tool.contract.AgentToolContracts;
|
||||||
|
import com.superbiz.agent.harness.tool.contract.RagEvidence;
|
||||||
|
import com.superbiz.agent.harness.tool.contract.RagToolRequest;
|
||||||
|
import com.superbiz.agent.harness.tool.contract.RagToolResult;
|
||||||
|
import org.springframework.ai.chat.messages.SystemMessage;
|
||||||
|
import org.springframework.ai.chat.messages.UserMessage;
|
||||||
|
import org.springframework.ai.chat.prompt.Prompt;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
public final class KnowledgeQueryExecutor implements KnowledgeQueryOperation {
|
||||||
|
|
||||||
|
private static final String SYSTEM_PROMPT = """
|
||||||
|
你只根据提供的有界知识库证据回答原始问题。每个 answer item 必须引用 exact tool_call_id 和实际 document_ids。
|
||||||
|
不要使用外部知识,不要改写问题,不要输出 markdown fence。返回严格 KnowledgeAnswerDraft JSON。
|
||||||
|
""";
|
||||||
|
|
||||||
|
private final DiagnosisHarnessCore core;
|
||||||
|
private final HarnessEvidenceTools tools;
|
||||||
|
private final GuardModelCall modelCall;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final ObjectReader ragReader;
|
||||||
|
private final ObjectReader answerReader;
|
||||||
|
private final Supplier<String> callIdSupplier;
|
||||||
|
private final KnowledgeQueryLimits limits;
|
||||||
|
|
||||||
|
public KnowledgeQueryExecutor(DiagnosisHarnessCore core,
|
||||||
|
HarnessEvidenceTools tools,
|
||||||
|
GuardModelCall modelCall,
|
||||||
|
ObjectMapper objectMapper,
|
||||||
|
Supplier<String> callIdSupplier,
|
||||||
|
KnowledgeQueryLimits limits) {
|
||||||
|
this.core = Objects.requireNonNull(core, "core must not be null");
|
||||||
|
this.tools = Objects.requireNonNull(tools, "tools must not be null");
|
||||||
|
this.modelCall = Objects.requireNonNull(modelCall, "modelCall must not be null");
|
||||||
|
this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper must not be null");
|
||||||
|
this.ragReader = objectMapper.readerFor(RagToolResult.class)
|
||||||
|
.with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
|
||||||
|
.with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
|
||||||
|
this.answerReader = objectMapper.readerFor(KnowledgeAnswerDraft.class)
|
||||||
|
.with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
|
||||||
|
.with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
|
||||||
|
this.callIdSupplier = Objects.requireNonNull(callIdSupplier, "callIdSupplier must not be null");
|
||||||
|
this.limits = Objects.requireNonNull(limits, "limits must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public KnowledgeContent execute(RunContext context, String query) {
|
||||||
|
if (query == null || query.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("query must not be blank");
|
||||||
|
}
|
||||||
|
String callId = requireId(callIdSupplier.get());
|
||||||
|
ToolBoundaryResult boundary = tools.invoke(
|
||||||
|
context,
|
||||||
|
AgentToolContracts.LOOKUP_KNOWLEDGE,
|
||||||
|
callId,
|
||||||
|
write(new RagToolRequest(query)));
|
||||||
|
if (boundary.status() != InvocationStatus.READY || boundary.agentResult() == null) {
|
||||||
|
throw unavailable("Knowledge lookup failed", null);
|
||||||
|
}
|
||||||
|
RagToolResult rag = readRag(boundary.agentResult());
|
||||||
|
validateRag(callId, boundary, rag);
|
||||||
|
if (rag.evidenceStatus() == EvidenceStatus.NO_EVIDENCE) {
|
||||||
|
return new KnowledgeContent(
|
||||||
|
"当前知识库中没有找到可用于回答该问题的资料。",
|
||||||
|
List.of(),
|
||||||
|
List.of("知识库查询范围内无匹配证据"));
|
||||||
|
}
|
||||||
|
|
||||||
|
String modelInput = write(new KnowledgeModelInput(query, rag));
|
||||||
|
long bytes = modelInput.getBytes(StandardCharsets.UTF_8).length;
|
||||||
|
if (bytes > limits.maxModelInputBytes()) {
|
||||||
|
throw unavailable("Knowledge model input exceeds limit", null);
|
||||||
|
}
|
||||||
|
core.reserveRunBytes(context, bytes);
|
||||||
|
String output = modelCall.call(
|
||||||
|
context,
|
||||||
|
new Prompt(List.of(new SystemMessage(SYSTEM_PROMPT), new UserMessage(modelInput))),
|
||||||
|
limits.modelTimeout(),
|
||||||
|
limits.maxModelOutputBytes());
|
||||||
|
KnowledgeAnswerDraft draft = readAnswer(output);
|
||||||
|
return publish(callId, rag, draft);
|
||||||
|
}
|
||||||
|
|
||||||
|
private KnowledgeContent publish(String callId, RagToolResult rag, KnowledgeAnswerDraft draft) {
|
||||||
|
if (draft.answerItems().isEmpty()) {
|
||||||
|
throw unavailable("Knowledge answer has no items", null);
|
||||||
|
}
|
||||||
|
Map<String, RagEvidence> evidenceById = new LinkedHashMap<>();
|
||||||
|
for (RagEvidence evidence : rag.evidence()) {
|
||||||
|
if (evidence == null || evidence.documentId() == null || evidence.documentId().isBlank()) {
|
||||||
|
throw unavailable("Knowledge projection is invalid", null);
|
||||||
|
}
|
||||||
|
evidenceById.put(evidence.documentId(), evidence);
|
||||||
|
}
|
||||||
|
List<String> answers = new ArrayList<>();
|
||||||
|
Set<String> referencedIds = new LinkedHashSet<>();
|
||||||
|
for (KnowledgeAnswerDraft.AnswerItem item : draft.answerItems()) {
|
||||||
|
if (item == null || item.text() == null || item.text().isBlank()
|
||||||
|
|| !Objects.equals(callId, item.toolCallId())
|
||||||
|
|| item.documentIds().isEmpty()) {
|
||||||
|
throw unavailable("Knowledge answer reference is invalid", null);
|
||||||
|
}
|
||||||
|
for (String documentId : item.documentIds()) {
|
||||||
|
if (!evidenceById.containsKey(documentId)) {
|
||||||
|
throw unavailable("Knowledge answer references unknown document", null);
|
||||||
|
}
|
||||||
|
referencedIds.add(documentId);
|
||||||
|
}
|
||||||
|
answers.add(item.text());
|
||||||
|
}
|
||||||
|
List<SourceDocument> references = referencedIds.stream()
|
||||||
|
.map(evidenceById::get)
|
||||||
|
.map(evidence -> new SourceDocument(
|
||||||
|
evidence.documentId(), firstText(evidence.title(), evidence.source(), evidence.documentId())))
|
||||||
|
.toList();
|
||||||
|
return new KnowledgeContent(String.join("\n\n", answers), references, draft.limitations());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateRag(String callId, ToolBoundaryResult boundary, RagToolResult rag) {
|
||||||
|
if (!Objects.equals(callId, boundary.toolCallId())
|
||||||
|
|| !Objects.equals(callId, rag.toolCallId())
|
||||||
|
|| rag.evidenceStatus() != boundary.evidenceStatus()
|
||||||
|
|| rag.returnedCount() != rag.evidence().size()) {
|
||||||
|
throw unavailable("Knowledge projection identity is invalid", null);
|
||||||
|
}
|
||||||
|
if (rag.evidenceStatus() == EvidenceStatus.EVIDENCE_FOUND && rag.evidence().isEmpty()) {
|
||||||
|
throw unavailable("Knowledge projection has no evidence", null);
|
||||||
|
}
|
||||||
|
if (rag.evidenceStatus() == EvidenceStatus.NO_EVIDENCE && !rag.evidence().isEmpty()) {
|
||||||
|
throw unavailable("No-evidence projection contains evidence", null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private RagToolResult readRag(String value) {
|
||||||
|
try {
|
||||||
|
return ragReader.readValue(value);
|
||||||
|
} catch (JsonProcessingException exception) {
|
||||||
|
throw unavailable("Knowledge projection is invalid", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private KnowledgeAnswerDraft readAnswer(String value) {
|
||||||
|
try {
|
||||||
|
return answerReader.readValue(value);
|
||||||
|
} catch (JsonProcessingException exception) {
|
||||||
|
throw unavailable("Knowledge answer is invalid", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String write(Object value) {
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(value);
|
||||||
|
} catch (JsonProcessingException exception) {
|
||||||
|
throw unavailable("Knowledge input is not serializable", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String requireId(String value) {
|
||||||
|
if (value == null || value.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("call ID must not be blank");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String firstText(String... values) {
|
||||||
|
for (String value : values) {
|
||||||
|
if (value != null && !value.isBlank()) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChatApplicationException unavailable(String message, Throwable cause) {
|
||||||
|
return new ChatApplicationException(
|
||||||
|
ChatFailureCode.KNOWLEDGE_UNAVAILABLE, message, cause);
|
||||||
|
}
|
||||||
|
|
||||||
|
private record KnowledgeModelInput(
|
||||||
|
String query,
|
||||||
|
RagToolResult evidence) {
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
package com.superbiz.agent.harness.application.executor;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
public record KnowledgeQueryLimits(
|
||||||
|
long maxModelInputBytes,
|
||||||
|
long maxModelOutputBytes,
|
||||||
|
Duration modelTimeout) {
|
||||||
|
|
||||||
|
public KnowledgeQueryLimits {
|
||||||
|
if (maxModelInputBytes <= 0 || maxModelOutputBytes <= 0) {
|
||||||
|
throw new IllegalArgumentException("byte limits must be positive");
|
||||||
|
}
|
||||||
|
Objects.requireNonNull(modelTimeout, "modelTimeout must not be null");
|
||||||
|
if (modelTimeout.isZero() || modelTimeout.isNegative()) {
|
||||||
|
throw new IllegalArgumentException("modelTimeout must be positive");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
package com.superbiz.agent.harness.application.executor;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
public record SingleTurnExecutorLimits(
|
||||||
|
long maxInputBytes,
|
||||||
|
long maxOutputBytes,
|
||||||
|
Duration timeout) {
|
||||||
|
|
||||||
|
public SingleTurnExecutorLimits {
|
||||||
|
if (maxInputBytes <= 0 || maxOutputBytes <= 0) {
|
||||||
|
throw new IllegalArgumentException("byte limits must be positive");
|
||||||
|
}
|
||||||
|
Objects.requireNonNull(timeout, "timeout must not be null");
|
||||||
|
if (timeout.isZero() || timeout.isNegative()) {
|
||||||
|
throw new IllegalArgumentException("timeout must be positive");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package com.superbiz.agent.harness.application.executor;
|
||||||
|
|
||||||
|
import com.superbiz.agent.harness.application.ChatApplicationException;
|
||||||
|
import com.superbiz.agent.harness.application.ChatFailureCode;
|
||||||
|
import com.superbiz.agent.harness.application.SystemChatContent;
|
||||||
|
import com.superbiz.agent.harness.application.SystemChatOperation;
|
||||||
|
import com.superbiz.agent.harness.core.DiagnosisHarnessCore;
|
||||||
|
import com.superbiz.agent.harness.core.RunContext;
|
||||||
|
import com.superbiz.agent.harness.guard.semantic.GuardModelCall;
|
||||||
|
import org.springframework.ai.chat.messages.SystemMessage;
|
||||||
|
import org.springframework.ai.chat.messages.UserMessage;
|
||||||
|
import org.springframework.ai.chat.prompt.Prompt;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
public final class SystemChatExecutor implements SystemChatOperation {
|
||||||
|
|
||||||
|
private static final String SYSTEM_PROMPT = """
|
||||||
|
你是 SuperBizAgent 的系统助手。只回答产品定位、能力范围、使用方式、简单问候和非业务闲聊。
|
||||||
|
系统可以查询内部知识文档,也可以对日志和授权只读数据库进行诊断;不会执行写操作。
|
||||||
|
不要声称已经查询任何实时数据,不要编造系统能力。直接给出简洁回答。
|
||||||
|
""";
|
||||||
|
|
||||||
|
private final DiagnosisHarnessCore core;
|
||||||
|
private final GuardModelCall modelCall;
|
||||||
|
private final SingleTurnExecutorLimits limits;
|
||||||
|
|
||||||
|
public SystemChatExecutor(DiagnosisHarnessCore core,
|
||||||
|
GuardModelCall modelCall,
|
||||||
|
SingleTurnExecutorLimits limits) {
|
||||||
|
this.core = Objects.requireNonNull(core, "core must not be null");
|
||||||
|
this.modelCall = Objects.requireNonNull(modelCall, "modelCall must not be null");
|
||||||
|
this.limits = Objects.requireNonNull(limits, "limits must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public SystemChatContent execute(RunContext context, String query) {
|
||||||
|
if (query == null || query.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("query must not be blank");
|
||||||
|
}
|
||||||
|
long bytes = query.getBytes(StandardCharsets.UTF_8).length;
|
||||||
|
if (bytes > limits.maxInputBytes()) {
|
||||||
|
throw new ChatApplicationException(
|
||||||
|
ChatFailureCode.SYSTEM_CHAT_UNAVAILABLE, "System Chat input exceeds limit");
|
||||||
|
}
|
||||||
|
core.reserveRunBytes(context, bytes);
|
||||||
|
String answer = modelCall.call(
|
||||||
|
context,
|
||||||
|
new Prompt(List.of(new SystemMessage(SYSTEM_PROMPT), new UserMessage(query))),
|
||||||
|
limits.timeout(),
|
||||||
|
limits.maxOutputBytes());
|
||||||
|
return new SystemChatContent(answer);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.superbiz.agent.harness.application.persistence;
|
||||||
|
|
||||||
|
import com.superbiz.agent.harness.contract.IntentType;
|
||||||
|
import com.superbiz.agent.harness.contract.PreviousTurn;
|
||||||
|
import com.superbiz.agent.harness.contract.PublishedResult;
|
||||||
|
import com.superbiz.agent.harness.contract.ReleaseOutcome;
|
||||||
|
import com.superbiz.agent.harness.core.RunContext;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface ChatRunStore {
|
||||||
|
|
||||||
|
Optional<RoutingHistory> findLatestRoutingHistory(String sessionId);
|
||||||
|
|
||||||
|
Optional<PreviousTurn> findPreviousTurn(String sessionId);
|
||||||
|
|
||||||
|
void start(RunContext context, String query);
|
||||||
|
|
||||||
|
void markIntent(String runId, IntentType intent);
|
||||||
|
|
||||||
|
void finish(RunContext context,
|
||||||
|
IntentType intent,
|
||||||
|
ReleaseOutcome outcome,
|
||||||
|
String safeContentJson,
|
||||||
|
PublishedResult publishedResult,
|
||||||
|
int durationMs);
|
||||||
|
}
|
||||||
+171
@@ -0,0 +1,171 @@
|
|||||||
|
package com.superbiz.agent.harness.application.persistence;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectReader;
|
||||||
|
import com.superbiz.agent.domain.entity.ChatSession;
|
||||||
|
import com.superbiz.agent.domain.entity.DiagnosisRun;
|
||||||
|
import com.superbiz.agent.harness.contract.IntentType;
|
||||||
|
import com.superbiz.agent.harness.contract.PreviousTurn;
|
||||||
|
import com.superbiz.agent.harness.contract.PublishedResult;
|
||||||
|
import com.superbiz.agent.harness.contract.ReleaseOutcome;
|
||||||
|
import com.superbiz.agent.harness.core.RunBudgetUsage;
|
||||||
|
import com.superbiz.agent.harness.core.RunContext;
|
||||||
|
import com.superbiz.agent.repository.ChatSessionRepository;
|
||||||
|
import com.superbiz.agent.repository.DiagnosisRunRepository;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class JpaChatRunStore implements ChatRunStore {
|
||||||
|
|
||||||
|
private final ChatSessionRepository chatSessions;
|
||||||
|
private final DiagnosisRunRepository runs;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final ObjectReader publishedReader;
|
||||||
|
private final PublishedResultPolicy publishedPolicy;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public JpaChatRunStore(ChatSessionRepository chatSessions,
|
||||||
|
DiagnosisRunRepository runs,
|
||||||
|
ObjectMapper objectMapper) {
|
||||||
|
this(chatSessions, runs, objectMapper,
|
||||||
|
new PublishedResultPolicy(PreviousTurnLimits.defaults()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public JpaChatRunStore(ChatSessionRepository chatSessions,
|
||||||
|
DiagnosisRunRepository runs,
|
||||||
|
ObjectMapper objectMapper,
|
||||||
|
PublishedResultPolicy publishedPolicy) {
|
||||||
|
this.chatSessions = Objects.requireNonNull(chatSessions, "chatSessions must not be null");
|
||||||
|
this.runs = Objects.requireNonNull(runs, "runs must not be null");
|
||||||
|
this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper must not be null");
|
||||||
|
this.publishedReader = objectMapper.readerFor(PublishedResult.class)
|
||||||
|
.with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
|
||||||
|
.with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
|
||||||
|
this.publishedPolicy = Objects.requireNonNull(publishedPolicy, "publishedPolicy must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Optional<RoutingHistory> findLatestRoutingHistory(String sessionId) {
|
||||||
|
return runs.findFirstBySessionIdAndReleaseOutcomeInOrderByCreatedAtDescIdDesc(
|
||||||
|
sessionId, List.of(ReleaseOutcome.SUCCESS, ReleaseOutcome.FALLBACK))
|
||||||
|
.filter(run -> run.getIntent() != null && hasText(run.getQuery()))
|
||||||
|
.map(run -> new RoutingHistory(run.getIntent(), run.getQuery()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Optional<PreviousTurn> findPreviousTurn(String sessionId) {
|
||||||
|
return runs.findFirstBySessionIdAndIntentAndReleaseOutcomeAndPublishedResultIsNotNullOrderByCreatedAtDescIdDesc(
|
||||||
|
sessionId, IntentType.DIAGNOSIS, ReleaseOutcome.SUCCESS)
|
||||||
|
.flatMap(this::readPreviousTurn);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void start(RunContext context, String query) {
|
||||||
|
Objects.requireNonNull(context, "context must not be null");
|
||||||
|
if (!hasText(query)) {
|
||||||
|
throw new IllegalArgumentException("query must not be blank");
|
||||||
|
}
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
ChatSession session = chatSessions.findBySessionId(context.sessionId())
|
||||||
|
.orElseGet(() -> ChatSession.builder()
|
||||||
|
.sessionId(context.sessionId())
|
||||||
|
.status("ACTIVE")
|
||||||
|
.build());
|
||||||
|
session.setStatus("ACTIVE");
|
||||||
|
session.setLastActiveAt(now);
|
||||||
|
chatSessions.save(session);
|
||||||
|
runs.save(DiagnosisRun.builder()
|
||||||
|
.runId(context.runId())
|
||||||
|
.sessionId(context.sessionId())
|
||||||
|
.query(query)
|
||||||
|
.status("RUNNING")
|
||||||
|
.agentFlow("CHAT")
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void markIntent(String runId, IntentType intent) {
|
||||||
|
DiagnosisRun run = requiredRun(runId);
|
||||||
|
run.setIntent(Objects.requireNonNull(intent, "intent must not be null"));
|
||||||
|
runs.save(run);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void finish(RunContext context, IntentType intent, ReleaseOutcome outcome,
|
||||||
|
String safeContentJson, PublishedResult publishedResult, int durationMs) {
|
||||||
|
Objects.requireNonNull(context, "context must not be null");
|
||||||
|
Objects.requireNonNull(outcome, "outcome must not be null");
|
||||||
|
DiagnosisRun run = requiredRun(context.runId());
|
||||||
|
run.setIntent(intent);
|
||||||
|
run.setReleaseOutcome(outcome);
|
||||||
|
run.setStatus(status(outcome));
|
||||||
|
run.setAnswer(safeContentJson);
|
||||||
|
run.setPublishedResult(outcome == ReleaseOutcome.SUCCESS && intent == IntentType.DIAGNOSIS
|
||||||
|
&& publishedResult != null ? write(publishedResult) : null);
|
||||||
|
run.setTotalDurationMs(Math.max(0, durationMs));
|
||||||
|
RunBudgetUsage usage = context.budget().snapshot();
|
||||||
|
run.setTotalTokenCount(saturatingInt(usage.totalTokens()));
|
||||||
|
run.setToolCallCount(saturatingInt(usage.toolCalls()));
|
||||||
|
runs.save(run);
|
||||||
|
if (outcome == ReleaseOutcome.SUCCESS || outcome == ReleaseOutcome.FALLBACK) {
|
||||||
|
chatSessions.findBySessionId(context.sessionId()).ifPresent(session -> {
|
||||||
|
session.setLastActiveAt(LocalDateTime.now());
|
||||||
|
session.setMessagePairCount(
|
||||||
|
session.getMessagePairCount() == null ? 1 : session.getMessagePairCount() + 1);
|
||||||
|
chatSessions.save(session);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<PreviousTurn> readPreviousTurn(DiagnosisRun run) {
|
||||||
|
try {
|
||||||
|
PublishedResult result = publishedReader.readValue(run.getPublishedResult());
|
||||||
|
return publishedPolicy.previousTurn(result);
|
||||||
|
} catch (Exception exception) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private DiagnosisRun requiredRun(String runId) {
|
||||||
|
return runs.findByRunId(runId)
|
||||||
|
.orElseThrow(() -> new IllegalStateException("Diagnosis Run is missing"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String write(PublishedResult result) {
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(result);
|
||||||
|
} catch (JsonProcessingException exception) {
|
||||||
|
throw new IllegalStateException("PublishedResult is not serializable", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String status(ReleaseOutcome outcome) {
|
||||||
|
return switch (outcome) {
|
||||||
|
case SUCCESS, FALLBACK -> "SUCCESS";
|
||||||
|
case FAILED -> "FAILED";
|
||||||
|
case CANCELLED -> "CANCELLED";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int saturatingInt(long value) {
|
||||||
|
return value >= Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) Math.max(0, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasText(String value) {
|
||||||
|
return value != null && !value.isBlank();
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
package com.superbiz.agent.harness.application.persistence;
|
||||||
|
|
||||||
|
public record PreviousTurnLimits(
|
||||||
|
int maxUserQueryChars,
|
||||||
|
int maxConclusionChars,
|
||||||
|
int maxScopeChars,
|
||||||
|
int maxLimitations,
|
||||||
|
int maxLimitationChars,
|
||||||
|
int maxSourceDocuments,
|
||||||
|
int maxDocumentIdChars,
|
||||||
|
int maxTitleChars) {
|
||||||
|
|
||||||
|
public PreviousTurnLimits {
|
||||||
|
if (maxUserQueryChars <= 0 || maxConclusionChars <= 0 || maxScopeChars <= 0
|
||||||
|
|| maxLimitations < 0 || maxLimitationChars <= 0
|
||||||
|
|| maxSourceDocuments < 0 || maxDocumentIdChars <= 0 || maxTitleChars <= 0) {
|
||||||
|
throw new IllegalArgumentException("PreviousTurn limits are invalid");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PreviousTurnLimits defaults() {
|
||||||
|
return new PreviousTurnLimits(2_000, 2_000, 1_000, 10, 500, 10, 256, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
package com.superbiz.agent.harness.application.persistence;
|
||||||
|
|
||||||
|
import com.superbiz.agent.harness.contract.DiagnosisDraft;
|
||||||
|
import com.superbiz.agent.harness.contract.PreviousTurn;
|
||||||
|
import com.superbiz.agent.harness.contract.PublishedResult;
|
||||||
|
import com.superbiz.agent.harness.contract.SourceDocument;
|
||||||
|
import com.superbiz.agent.harness.guard.evidence.VerifiedAnalysisEvidence;
|
||||||
|
import com.superbiz.agent.harness.guard.evidence.VerifiedEvidence;
|
||||||
|
import com.superbiz.agent.harness.guard.evidence.VerifiedEvidenceSnapshot;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public final class PublishedResultPolicy {
|
||||||
|
|
||||||
|
private final PreviousTurnLimits limits;
|
||||||
|
|
||||||
|
public PublishedResultPolicy(PreviousTurnLimits limits) {
|
||||||
|
this.limits = Objects.requireNonNull(limits, "limits must not be null");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<PublishedResult> create(String query,
|
||||||
|
DiagnosisDraft draft,
|
||||||
|
VerifiedEvidenceSnapshot snapshot) {
|
||||||
|
if (draft == null || draft.conclusion() == null
|
||||||
|
|| isBlank(query) || isBlank(draft.conclusion().text())
|
||||||
|
|| draft.limitations() == null || isBlank(draft.limitations().scope())) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
return sanitize(new PublishedResult(
|
||||||
|
query,
|
||||||
|
draft.conclusion().text(),
|
||||||
|
draft.limitations().scope(),
|
||||||
|
draft.limitations().missingInfo(),
|
||||||
|
sourceDocuments(snapshot)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<PublishedResult> sanitize(PublishedResult value) {
|
||||||
|
if (value == null || isBlank(value.userQuery())
|
||||||
|
|| isBlank(value.publishedConclusion()) || isBlank(value.scope())) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
List<String> limitations = value.limitations().stream()
|
||||||
|
.filter(item -> !isBlank(item))
|
||||||
|
.limit(limits.maxLimitations())
|
||||||
|
.map(item -> bound(item, limits.maxLimitationChars()))
|
||||||
|
.toList();
|
||||||
|
List<SourceDocument> documents = value.sourceDocuments().stream()
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.filter(document -> !isBlank(document.documentId()) && !isBlank(document.title()))
|
||||||
|
.limit(limits.maxSourceDocuments())
|
||||||
|
.map(document -> new SourceDocument(
|
||||||
|
bound(document.documentId(), limits.maxDocumentIdChars()),
|
||||||
|
bound(document.title(), limits.maxTitleChars())))
|
||||||
|
.toList();
|
||||||
|
return Optional.of(new PublishedResult(
|
||||||
|
bound(value.userQuery(), limits.maxUserQueryChars()),
|
||||||
|
bound(value.publishedConclusion(), limits.maxConclusionChars()),
|
||||||
|
bound(value.scope(), limits.maxScopeChars()),
|
||||||
|
limitations,
|
||||||
|
documents));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<PreviousTurn> previousTurn(PublishedResult value) {
|
||||||
|
return sanitize(value).map(PreviousTurn::from);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<SourceDocument> sourceDocuments(VerifiedEvidenceSnapshot snapshot) {
|
||||||
|
if (snapshot == null) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
Map<String, SourceDocument> documents = new LinkedHashMap<>();
|
||||||
|
for (VerifiedAnalysisEvidence analysis : snapshot.analyses()) {
|
||||||
|
for (VerifiedEvidence evidence : analysis.evidence()) {
|
||||||
|
if (!"RAG".equals(evidence.sourceType())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String id = stringValue(evidence.values().get("document_id"));
|
||||||
|
String title = stringValue(evidence.values().get("title"));
|
||||||
|
if (!isBlank(id)) {
|
||||||
|
documents.putIfAbsent(id, new SourceDocument(id, firstText(title, evidence.source(), id)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new ArrayList<>(documents.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String stringValue(Object value) {
|
||||||
|
return value == null ? null : String.valueOf(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String firstText(String... values) {
|
||||||
|
for (String value : values) {
|
||||||
|
if (!isBlank(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String bound(String value, int max) {
|
||||||
|
return value.length() <= max ? value : value.substring(0, max);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isBlank(String value) {
|
||||||
|
return value == null || value.isBlank();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.superbiz.agent.harness.application.persistence;
|
||||||
|
|
||||||
|
import com.superbiz.agent.harness.contract.IntentType;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
public record RoutingHistory(IntentType intent, String userQuery) {
|
||||||
|
|
||||||
|
public RoutingHistory {
|
||||||
|
Objects.requireNonNull(intent, "intent must not be null");
|
||||||
|
if (userQuery == null || userQuery.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("userQuery must not be blank");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package com.superbiz.agent.harness.application.routing;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.superbiz.agent.harness.contract.IntentType;
|
||||||
|
import com.superbiz.agent.harness.application.IntentRouting;
|
||||||
|
import com.superbiz.agent.harness.core.DiagnosisHarnessCore;
|
||||||
|
import com.superbiz.agent.harness.core.RunContext;
|
||||||
|
import com.superbiz.agent.harness.guard.semantic.GuardModelCall;
|
||||||
|
import com.superbiz.agent.harness.guard.semantic.GuardModelCallException;
|
||||||
|
import com.superbiz.agent.harness.retry.HarnessRetryExecutor;
|
||||||
|
import com.superbiz.agent.harness.retry.RetryAttempt;
|
||||||
|
import com.superbiz.agent.harness.retry.RetryExecutionException;
|
||||||
|
import com.superbiz.agent.harness.retry.RetryFailure;
|
||||||
|
import org.springframework.ai.chat.messages.SystemMessage;
|
||||||
|
import org.springframework.ai.chat.messages.UserMessage;
|
||||||
|
import org.springframework.ai.chat.prompt.Prompt;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
public final class IntentRouter implements IntentRouting {
|
||||||
|
|
||||||
|
private static final Set<String> OUTPUT_FIELDS = Set.of("intent");
|
||||||
|
|
||||||
|
private final DiagnosisHarnessCore core;
|
||||||
|
private final HarnessRetryExecutor retryExecutor;
|
||||||
|
private final GuardModelCall modelCall;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final IntentRouterLimits limits;
|
||||||
|
private final Consumer<RetryAttempt> attemptRecorder;
|
||||||
|
private final String systemPrompt;
|
||||||
|
|
||||||
|
public IntentRouter(DiagnosisHarnessCore core,
|
||||||
|
HarnessRetryExecutor retryExecutor,
|
||||||
|
GuardModelCall modelCall,
|
||||||
|
ObjectMapper objectMapper,
|
||||||
|
IntentRouterLimits limits,
|
||||||
|
Consumer<RetryAttempt> attemptRecorder) {
|
||||||
|
this.core = Objects.requireNonNull(core, "core must not be null");
|
||||||
|
this.retryExecutor = Objects.requireNonNull(retryExecutor, "retryExecutor must not be null");
|
||||||
|
this.modelCall = Objects.requireNonNull(modelCall, "modelCall must not be null");
|
||||||
|
this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper must not be null");
|
||||||
|
this.limits = Objects.requireNonNull(limits, "limits must not be null");
|
||||||
|
this.attemptRecorder = Objects.requireNonNull(attemptRecorder, "attemptRecorder must not be null");
|
||||||
|
this.systemPrompt = IntentRouterPrompt.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public IntentType route(RunContext context, IntentRouterInput input) {
|
||||||
|
Objects.requireNonNull(context, "context must not be null");
|
||||||
|
Objects.requireNonNull(input, "input must not be null");
|
||||||
|
String json = serialize(input);
|
||||||
|
long bytes = utf8Bytes(json);
|
||||||
|
if (bytes > limits.maxInputBytes()) {
|
||||||
|
throw new IntentRoutingException(new IllegalArgumentException("Router input exceeded limit"));
|
||||||
|
}
|
||||||
|
core.reserveRunBytes(context, bytes);
|
||||||
|
Prompt prompt = new Prompt(List.of(
|
||||||
|
new SystemMessage(systemPrompt), new UserMessage(json)));
|
||||||
|
long started = System.nanoTime();
|
||||||
|
try {
|
||||||
|
return retryExecutor.execute(
|
||||||
|
context,
|
||||||
|
context.retryPolicies().intentRouter(),
|
||||||
|
() -> parse(modelCall.call(
|
||||||
|
context, prompt, remaining(started), limits.maxOutputBytes())),
|
||||||
|
this::classify,
|
||||||
|
attemptRecorder);
|
||||||
|
} catch (RetryExecutionException exception) {
|
||||||
|
if (exception.failure() == RetryFailure.CANCELLED
|
||||||
|
|| exception.failure() == RetryFailure.BUDGET_EXHAUSTED) {
|
||||||
|
throw exception;
|
||||||
|
}
|
||||||
|
throw new IntentRoutingException(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private IntentType parse(String output) {
|
||||||
|
JsonNode root;
|
||||||
|
try {
|
||||||
|
root = objectMapper.readTree(output);
|
||||||
|
} catch (JsonProcessingException exception) {
|
||||||
|
throw invalidOutput(exception);
|
||||||
|
}
|
||||||
|
if (root == null || !root.isObject() || !fieldNames(root).equals(OUTPUT_FIELDS)
|
||||||
|
|| !root.path("intent").isTextual()) {
|
||||||
|
throw invalidOutput(null);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return IntentType.valueOf(root.path("intent").asText());
|
||||||
|
} catch (IllegalArgumentException exception) {
|
||||||
|
throw invalidOutput(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private GuardModelCallException invalidOutput(Throwable cause) {
|
||||||
|
return new GuardModelCallException(
|
||||||
|
RetryFailure.INVALID_OUTPUT, "Intent Router output is invalid", cause);
|
||||||
|
}
|
||||||
|
|
||||||
|
private RetryFailure classify(Exception exception) {
|
||||||
|
if (exception instanceof GuardModelCallException failure) {
|
||||||
|
return switch (failure.failure()) {
|
||||||
|
case TIMEOUT, TRANSPORT -> failure.failure();
|
||||||
|
default -> RetryFailure.INVALID_OUTPUT;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return RetryFailure.UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Duration remaining(long started) {
|
||||||
|
long remaining = limits.totalTimeout().toNanos()
|
||||||
|
- Math.max(0L, System.nanoTime() - started);
|
||||||
|
if (remaining <= 0) {
|
||||||
|
throw new GuardModelCallException(
|
||||||
|
RetryFailure.TIMEOUT, "Intent Router total timeout exhausted");
|
||||||
|
}
|
||||||
|
return Duration.ofNanos(Math.min(remaining, limits.perAttemptTimeout().toNanos()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String serialize(IntentRouterInput input) {
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(input);
|
||||||
|
} catch (JsonProcessingException exception) {
|
||||||
|
throw new IntentRoutingException(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Set<String> fieldNames(JsonNode node) {
|
||||||
|
Set<String> names = new HashSet<>();
|
||||||
|
Iterator<String> fields = node.fieldNames();
|
||||||
|
fields.forEachRemaining(names::add);
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long utf8Bytes(String value) {
|
||||||
|
return value.getBytes(StandardCharsets.UTF_8).length;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.superbiz.agent.harness.application.routing;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import com.superbiz.agent.harness.contract.IntentType;
|
||||||
|
|
||||||
|
public record IntentRouterInput(
|
||||||
|
@JsonProperty("query") String query,
|
||||||
|
@JsonProperty("last_intent") IntentType lastIntent,
|
||||||
|
@JsonProperty("last_user_query") String lastUserQuery) {
|
||||||
|
|
||||||
|
public IntentRouterInput {
|
||||||
|
if (query == null || query.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("query must not be blank");
|
||||||
|
}
|
||||||
|
if (lastUserQuery != null && lastUserQuery.isBlank()) {
|
||||||
|
lastUserQuery = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.superbiz.agent.harness.application.routing;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
public record IntentRouterLimits(
|
||||||
|
long maxInputBytes,
|
||||||
|
long maxOutputBytes,
|
||||||
|
Duration perAttemptTimeout,
|
||||||
|
Duration totalTimeout) {
|
||||||
|
|
||||||
|
public IntentRouterLimits {
|
||||||
|
if (maxInputBytes <= 0 || maxOutputBytes <= 0) {
|
||||||
|
throw new IllegalArgumentException("byte limits must be positive");
|
||||||
|
}
|
||||||
|
requirePositive(perAttemptTimeout, "perAttemptTimeout");
|
||||||
|
requirePositive(totalTimeout, "totalTimeout");
|
||||||
|
if (totalTimeout.compareTo(perAttemptTimeout) < 0) {
|
||||||
|
throw new IllegalArgumentException("totalTimeout must not be shorter than perAttemptTimeout");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void requirePositive(Duration value, String name) {
|
||||||
|
Objects.requireNonNull(value, name + " must not be null");
|
||||||
|
if (value.isZero() || value.isNegative()) {
|
||||||
|
throw new IllegalArgumentException(name + " must be positive");
|
||||||
|
}
|
||||||
|
value.toNanos();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.superbiz.agent.harness.application.routing;
|
||||||
|
|
||||||
|
import org.springframework.core.io.ClassPathResource;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
final class IntentRouterPrompt {
|
||||||
|
|
||||||
|
private static final String PATH = "prompts/intent-router-prompt.md";
|
||||||
|
|
||||||
|
private IntentRouterPrompt() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static String load() {
|
||||||
|
try (InputStream input = new ClassPathResource(PATH).getInputStream()) {
|
||||||
|
return new String(input.readAllBytes(), StandardCharsets.UTF_8);
|
||||||
|
} catch (IOException exception) {
|
||||||
|
throw new IllegalStateException("Failed to load Intent Router prompt", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
@@ -0,0 +1,8 @@
|
|||||||
|
package com.superbiz.agent.harness.application.routing;
|
||||||
|
|
||||||
|
public final class IntentRoutingException extends RuntimeException {
|
||||||
|
|
||||||
|
public IntentRoutingException(Throwable cause) {
|
||||||
|
super("Intent routing is unavailable", cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package com.superbiz.agent.repository;
|
package com.superbiz.agent.repository;
|
||||||
|
|
||||||
import com.superbiz.agent.domain.entity.DiagnosisRun;
|
import com.superbiz.agent.domain.entity.DiagnosisRun;
|
||||||
|
import com.superbiz.agent.harness.contract.IntentType;
|
||||||
|
import com.superbiz.agent.harness.contract.ReleaseOutcome;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
@@ -16,6 +18,11 @@ public interface DiagnosisRunRepository extends JpaRepository<DiagnosisRun, Long
|
|||||||
|
|
||||||
Optional<DiagnosisRun> findFirstBySessionIdOrderByCreatedAtDescIdDesc(String sessionId);
|
Optional<DiagnosisRun> findFirstBySessionIdOrderByCreatedAtDescIdDesc(String sessionId);
|
||||||
|
|
||||||
|
Optional<DiagnosisRun> findFirstBySessionIdAndReleaseOutcomeInOrderByCreatedAtDescIdDesc(
|
||||||
|
String sessionId, List<ReleaseOutcome> outcomes);
|
||||||
|
|
||||||
|
Optional<DiagnosisRun> findFirstBySessionIdAndIntentAndReleaseOutcomeAndPublishedResultIsNotNullOrderByCreatedAtDescIdDesc(
|
||||||
|
String sessionId, IntentType intent, ReleaseOutcome outcome);
|
||||||
|
|
||||||
List<DiagnosisRun> findBySessionIdOrderByCreatedAtDescIdDesc(String sessionId);
|
List<DiagnosisRun> findBySessionIdOrderByCreatedAtDescIdDesc(String sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- V012: persist intent routing and only the bounded result eligible for PreviousTurn.
|
||||||
|
|
||||||
|
ALTER TABLE diagnosis_run
|
||||||
|
ADD COLUMN intent VARCHAR(32) NULL COMMENT 'SYSTEM_CHAT/KNOWLEDGE_QUERY/DIAGNOSIS' AFTER agent_flow,
|
||||||
|
ADD COLUMN release_outcome VARCHAR(16) NULL COMMENT 'SUCCESS/FALLBACK/FAILED/CANCELLED' AFTER intent,
|
||||||
|
ADD COLUMN published_result JSON NULL COMMENT 'Bounded safe PreviousTurn source; no tool ids or raw evidence' AFTER answer,
|
||||||
|
ADD INDEX idx_diagnosis_run_previous_turn (
|
||||||
|
session_id, intent, release_outcome, created_at, id
|
||||||
|
);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
Classify one unchanged user query into exactly one route.
|
||||||
|
|
||||||
|
SYSTEM_CHAT: product capabilities, usage, greetings, or non-business small talk.
|
||||||
|
KNOWLEDGE_QUERY: internal documentation, process, API, error code, configuration, or runbook questions.
|
||||||
|
DIAGNOSIS: troubleshooting, root cause, current runtime state, or multi-source evidence analysis.
|
||||||
|
|
||||||
|
The optional last_intent and last_user_query only help resolve follow-ups. A clearly new current topic wins.
|
||||||
|
You have no tools, memory, or permission to rewrite the query.
|
||||||
|
|
||||||
|
Return exactly one JSON object with no markdown or extra fields:
|
||||||
|
{"intent":"SYSTEM_CHAT|KNOWLEDGE_QUERY|DIAGNOSIS"}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
package com.superbiz.agent.harness.application;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.superbiz.agent.harness.agent.HarnessEvidenceTools;
|
||||||
|
import com.superbiz.agent.harness.application.executor.KnowledgeQueryExecutor;
|
||||||
|
import com.superbiz.agent.harness.application.executor.KnowledgeQueryLimits;
|
||||||
|
import com.superbiz.agent.harness.application.executor.SingleTurnExecutorLimits;
|
||||||
|
import com.superbiz.agent.harness.application.executor.SystemChatExecutor;
|
||||||
|
import com.superbiz.agent.harness.application.routing.IntentRouter;
|
||||||
|
import com.superbiz.agent.harness.application.routing.IntentRouterInput;
|
||||||
|
import com.superbiz.agent.harness.application.routing.IntentRouterLimits;
|
||||||
|
import com.superbiz.agent.harness.application.routing.IntentRoutingException;
|
||||||
|
import com.superbiz.agent.harness.contract.EvidenceStatus;
|
||||||
|
import com.superbiz.agent.harness.contract.IntentType;
|
||||||
|
import com.superbiz.agent.harness.core.DiagnosisHarnessCore;
|
||||||
|
import com.superbiz.agent.harness.core.RunBudgetLimits;
|
||||||
|
import com.superbiz.agent.harness.core.RunContext;
|
||||||
|
import com.superbiz.agent.harness.guard.semantic.GuardModelCall;
|
||||||
|
import com.superbiz.agent.harness.retry.HarnessRetryExecutor;
|
||||||
|
import com.superbiz.agent.harness.retry.HarnessRetryPolicies;
|
||||||
|
import com.superbiz.agent.harness.retry.RetryAttempt;
|
||||||
|
import com.superbiz.agent.harness.tool.boundary.ToolBoundaryErrorCode;
|
||||||
|
import com.superbiz.agent.harness.tool.boundary.ToolBoundaryResult;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.ai.chat.messages.AssistantMessage;
|
||||||
|
import org.springframework.ai.chat.metadata.ChatResponseMetadata;
|
||||||
|
import org.springframework.ai.chat.metadata.DefaultUsage;
|
||||||
|
import org.springframework.ai.chat.model.ChatModel;
|
||||||
|
import org.springframework.ai.chat.model.ChatResponse;
|
||||||
|
import org.springframework.ai.chat.model.Generation;
|
||||||
|
import org.springframework.ai.chat.prompt.Prompt;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class ApplicationExecutorsTest {
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
private final ExecutorService executor = Executors.newCachedThreadPool();
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void shutdown() {
|
||||||
|
executor.shutdownNow();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void routerRetriesInvalidOutputWithSameMinimalInput() {
|
||||||
|
DiagnosisHarnessCore core = core();
|
||||||
|
RunContext context = core.startRun("session-router", "run-router");
|
||||||
|
ScriptedModel model = new ScriptedModel(
|
||||||
|
"{\"intent\":\"OTHER\"}", "{\"intent\":\"DIAGNOSIS\"}");
|
||||||
|
List<RetryAttempt> attempts = new ArrayList<>();
|
||||||
|
IntentRouter router = router(core, model, attempts::add);
|
||||||
|
|
||||||
|
IntentType intent = router.route(context,
|
||||||
|
new IntentRouterInput("那退款服务呢?", IntentType.DIAGNOSIS, "订单支付为什么超时?"));
|
||||||
|
|
||||||
|
assertEquals(IntentType.DIAGNOSIS, intent);
|
||||||
|
assertEquals(2, model.calls.get());
|
||||||
|
assertEquals(model.prompts.get(0), model.prompts.get(1));
|
||||||
|
assertTrue(model.prompts.get(0).contains("那退款服务呢?"));
|
||||||
|
assertFalse(model.prompts.get(0).contains("tool_call_id"));
|
||||||
|
assertEquals(2, attempts.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void routerNeverDefaultsToDiagnosisAfterTwoInvalidOutputs() {
|
||||||
|
DiagnosisHarnessCore core = core();
|
||||||
|
ScriptedModel model = new ScriptedModel("bad", "still-bad");
|
||||||
|
IntentRouter router = router(core, model, ignored -> { });
|
||||||
|
|
||||||
|
assertThrows(IntentRoutingException.class, () -> router.route(
|
||||||
|
core.startRun("session-fail", "run-fail"),
|
||||||
|
new IntentRouterInput("排查故障", null, null)));
|
||||||
|
|
||||||
|
assertEquals(2, model.calls.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void systemChatUsesOneDirectModelCall() {
|
||||||
|
DiagnosisHarnessCore core = core();
|
||||||
|
ScriptedModel model = new ScriptedModel("你好,我可以帮助查询知识和诊断只读数据。");
|
||||||
|
SystemChatExecutor system = new SystemChatExecutor(
|
||||||
|
core, new GuardModelCall(core, model, executor),
|
||||||
|
new SingleTurnExecutorLimits(10_000, 10_000, Duration.ofSeconds(2)));
|
||||||
|
|
||||||
|
SystemChatContent content = system.execute(
|
||||||
|
core.startRun("session-system", "run-system"), "你能做什么?");
|
||||||
|
|
||||||
|
assertTrue(content.answer().contains("知识"));
|
||||||
|
assertEquals(1, model.calls.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void knowledgeAnswerValidatesExactReferencesAndRemovesToolId() throws Exception {
|
||||||
|
DiagnosisHarnessCore core = core();
|
||||||
|
AtomicInteger lookupCalls = new AtomicInteger();
|
||||||
|
HarnessEvidenceTools tools = tools(lookupCalls, EvidenceStatus.EVIDENCE_FOUND);
|
||||||
|
ScriptedModel model = new ScriptedModel("""
|
||||||
|
{"answer_items":[{"text":"先检查连接池等待线程。","tool_call_id":"knowledge-1",
|
||||||
|
"document_ids":["doc-1"]}],"limitations":["未查询实时日志"]}
|
||||||
|
""");
|
||||||
|
KnowledgeQueryExecutor knowledge = knowledge(core, tools, model);
|
||||||
|
|
||||||
|
KnowledgeContent content = knowledge.execute(
|
||||||
|
core.startRun("session-knowledge", "run-knowledge"), "连接池超时怎么排查?");
|
||||||
|
|
||||||
|
assertEquals(1, lookupCalls.get());
|
||||||
|
assertEquals(1, model.calls.get());
|
||||||
|
assertEquals("doc-1", content.references().get(0).documentId());
|
||||||
|
assertFalse(objectMapper.writeValueAsString(content).contains("knowledge-1"));
|
||||||
|
assertFalse(objectMapper.writeValueAsString(content).contains("tool_call_id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void knowledgeNoEvidenceSkipsModelAndFabricatedDocumentFailsClosed() {
|
||||||
|
DiagnosisHarnessCore core = core();
|
||||||
|
AtomicInteger emptyCalls = new AtomicInteger();
|
||||||
|
ScriptedModel unused = new ScriptedModel();
|
||||||
|
KnowledgeContent empty = knowledge(core, tools(emptyCalls, EvidenceStatus.NO_EVIDENCE), unused)
|
||||||
|
.execute(core.startRun("session-empty", "run-empty"), "不存在的手册");
|
||||||
|
assertEquals(1, emptyCalls.get());
|
||||||
|
assertEquals(0, unused.calls.get());
|
||||||
|
assertTrue(empty.references().isEmpty());
|
||||||
|
|
||||||
|
ScriptedModel fabricated = new ScriptedModel("""
|
||||||
|
{"answer_items":[{"text":"fabricated","tool_call_id":"knowledge-1",
|
||||||
|
"document_ids":["unknown-doc"]}],"limitations":[]}
|
||||||
|
""");
|
||||||
|
assertThrows(ChatApplicationException.class, () ->
|
||||||
|
knowledge(core, tools(new AtomicInteger(), EvidenceStatus.EVIDENCE_FOUND), fabricated)
|
||||||
|
.execute(core.startRun("session-fake", "run-fake"), "排查手册"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private IntentRouter router(DiagnosisHarnessCore core, ChatModel model,
|
||||||
|
java.util.function.Consumer<RetryAttempt> recorder) {
|
||||||
|
return new IntentRouter(
|
||||||
|
core, new HarnessRetryExecutor(core),
|
||||||
|
new GuardModelCall(core, model, executor), objectMapper,
|
||||||
|
new IntentRouterLimits(20_000, 2_000,
|
||||||
|
Duration.ofSeconds(2), Duration.ofSeconds(3)), recorder);
|
||||||
|
}
|
||||||
|
|
||||||
|
private KnowledgeQueryExecutor knowledge(DiagnosisHarnessCore core,
|
||||||
|
HarnessEvidenceTools tools,
|
||||||
|
ChatModel model) {
|
||||||
|
return new KnowledgeQueryExecutor(
|
||||||
|
core, tools, new GuardModelCall(core, model, executor), objectMapper,
|
||||||
|
() -> "knowledge-1",
|
||||||
|
new KnowledgeQueryLimits(50_000, 20_000, Duration.ofSeconds(2)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private HarnessEvidenceTools tools(AtomicInteger calls, EvidenceStatus status) {
|
||||||
|
var rag = (com.superbiz.agent.harness.agent.EvidenceToolInvoker)
|
||||||
|
(context, callId, arguments) -> {
|
||||||
|
calls.incrementAndGet();
|
||||||
|
if (status == EvidenceStatus.NO_EVIDENCE) {
|
||||||
|
return ToolBoundaryResult.ready(callId, """
|
||||||
|
{"evidence_status":"NO_EVIDENCE","tool_call_id":"knowledge-1",
|
||||||
|
"query":"missing","evidence":[],"returned_count":0,"truncated":false}
|
||||||
|
""", status);
|
||||||
|
}
|
||||||
|
return ToolBoundaryResult.ready(callId, """
|
||||||
|
{"evidence_status":"EVIDENCE_FOUND","tool_call_id":"knowledge-1",
|
||||||
|
"query":"pool timeout","evidence":[{"document_id":"doc-1",
|
||||||
|
"source":"runbook.md","title":"连接池手册","breadcrumb":"DB > Pool",
|
||||||
|
"excerpt":"active=50 max=50"}],"returned_count":1,"truncated":false}
|
||||||
|
""", status);
|
||||||
|
};
|
||||||
|
var unavailable = (com.superbiz.agent.harness.agent.EvidenceToolInvoker)
|
||||||
|
(context, callId, arguments) ->
|
||||||
|
ToolBoundaryResult.error(callId, ToolBoundaryErrorCode.INVALID_REQUEST);
|
||||||
|
return new HarnessEvidenceTools(rag, unavailable, unavailable);
|
||||||
|
}
|
||||||
|
|
||||||
|
private DiagnosisHarnessCore core() {
|
||||||
|
return new DiagnosisHarnessCore(
|
||||||
|
Clock.systemUTC(), () -> "unused", Duration.ofMinutes(5),
|
||||||
|
new RunBudgetLimits(20, 10, 10, 100_000, 100_000, 200_000, 1_000_000),
|
||||||
|
HarnessRetryPolicies.strict());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class ScriptedModel implements ChatModel {
|
||||||
|
private final List<String> responses;
|
||||||
|
private final List<String> prompts = new ArrayList<>();
|
||||||
|
private final AtomicInteger calls = new AtomicInteger();
|
||||||
|
|
||||||
|
private ScriptedModel(String... responses) {
|
||||||
|
this.responses = List.of(responses);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ChatResponse call(Prompt prompt) {
|
||||||
|
prompts.add(prompt.getContents());
|
||||||
|
int index = calls.getAndIncrement();
|
||||||
|
if (index >= responses.size()) {
|
||||||
|
throw new AssertionError("unexpected model call");
|
||||||
|
}
|
||||||
|
return new ChatResponse(
|
||||||
|
List.of(new Generation(new AssistantMessage(responses.get(index)))),
|
||||||
|
ChatResponseMetadata.builder().usage(new DefaultUsage(3, 2)).build());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
package com.superbiz.agent.harness.application;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.superbiz.agent.harness.application.persistence.ChatRunStore;
|
||||||
|
import com.superbiz.agent.harness.application.persistence.RoutingHistory;
|
||||||
|
import com.superbiz.agent.harness.application.routing.IntentRoutingException;
|
||||||
|
import com.superbiz.agent.harness.contract.AnalysisKind;
|
||||||
|
import com.superbiz.agent.harness.contract.DiagnosisDraft;
|
||||||
|
import com.superbiz.agent.harness.contract.IntentType;
|
||||||
|
import com.superbiz.agent.harness.contract.PreviousTurn;
|
||||||
|
import com.superbiz.agent.harness.contract.PublishedResult;
|
||||||
|
import com.superbiz.agent.harness.contract.ReleaseOutcome;
|
||||||
|
import com.superbiz.agent.harness.contract.SourceDocument;
|
||||||
|
import com.superbiz.agent.harness.core.DiagnosisHarnessCore;
|
||||||
|
import com.superbiz.agent.harness.core.RunBudgetLimits;
|
||||||
|
import com.superbiz.agent.harness.core.RunContext;
|
||||||
|
import com.superbiz.agent.harness.guard.semantic.SemanticDraftView;
|
||||||
|
import com.superbiz.agent.harness.retry.HarnessRetryPolicies;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class ChatApplicationUseCaseTest {
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void dispatchesAllThreeIntentsToOnlyTheirFixedExecutor() {
|
||||||
|
for (IntentType selected : IntentType.values()) {
|
||||||
|
FakeStore store = new FakeStore();
|
||||||
|
AtomicInteger systemCalls = new AtomicInteger();
|
||||||
|
AtomicInteger knowledgeCalls = new AtomicInteger();
|
||||||
|
AtomicInteger diagnosisCalls = new AtomicInteger();
|
||||||
|
ChatApplicationUseCase useCase = useCase(
|
||||||
|
store,
|
||||||
|
(context, input) -> selected,
|
||||||
|
(context, query) -> {
|
||||||
|
systemCalls.incrementAndGet();
|
||||||
|
return new SystemChatContent("system:" + query);
|
||||||
|
},
|
||||||
|
(context, query) -> {
|
||||||
|
knowledgeCalls.incrementAndGet();
|
||||||
|
return new KnowledgeContent("knowledge:" + query, List.of(), List.of());
|
||||||
|
},
|
||||||
|
(context, query, previous, status) -> {
|
||||||
|
diagnosisCalls.incrementAndGet();
|
||||||
|
return diagnosisSuccess(query);
|
||||||
|
});
|
||||||
|
|
||||||
|
ChatApplicationResult result = useCase.execute(
|
||||||
|
new ChatApplicationRequest("original query", "session-fixed"));
|
||||||
|
|
||||||
|
assertEquals(selected, result.intent());
|
||||||
|
assertEquals("session-fixed", result.sessionId());
|
||||||
|
assertEquals(selected == IntentType.SYSTEM_CHAT ? 1 : 0, systemCalls.get());
|
||||||
|
assertEquals(selected == IntentType.KNOWLEDGE_QUERY ? 1 : 0, knowledgeCalls.get());
|
||||||
|
assertEquals(selected == IntentType.DIAGNOSIS ? 1 : 0, diagnosisCalls.get());
|
||||||
|
assertEquals(ReleaseOutcome.SUCCESS, store.finishedOutcome);
|
||||||
|
assertEquals(result.runId(), store.startedContext.runId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void priorSafeTurnIsReadBeforeCurrentRunAndOnlyDiagnosisReceivesIt() {
|
||||||
|
FakeStore store = new FakeStore();
|
||||||
|
store.history = new RoutingHistory(IntentType.DIAGNOSIS, "old query");
|
||||||
|
store.previous = new PreviousTurn(
|
||||||
|
"old query", "old conclusion", "old scope", List.of("old limitation"),
|
||||||
|
List.of(new SourceDocument("doc-1", "guide")));
|
||||||
|
AtomicReference<PreviousTurn> received = new AtomicReference<>();
|
||||||
|
AtomicReference<String> routedQuery = new AtomicReference<>();
|
||||||
|
List<String> observerEvents = new ArrayList<>();
|
||||||
|
ChatApplicationUseCase useCase = useCase(
|
||||||
|
store,
|
||||||
|
(context, input) -> {
|
||||||
|
routedQuery.set(input.query());
|
||||||
|
assertEquals(IntentType.DIAGNOSIS, input.lastIntent());
|
||||||
|
assertEquals("old query", input.lastUserQuery());
|
||||||
|
return IntentType.DIAGNOSIS;
|
||||||
|
},
|
||||||
|
(context, query) -> new SystemChatContent("unused"),
|
||||||
|
(context, query) -> new KnowledgeContent("unused", List.of(), List.of()),
|
||||||
|
(context, query, previous, status) -> {
|
||||||
|
received.set(previous);
|
||||||
|
return diagnosisSuccess(query);
|
||||||
|
});
|
||||||
|
|
||||||
|
ChatApplicationResult result = useCase.execute(
|
||||||
|
new ChatApplicationRequest("那退款服务呢?", "session-history"),
|
||||||
|
observer(observerEvents));
|
||||||
|
|
||||||
|
assertEquals("那退款服务呢?", routedQuery.get());
|
||||||
|
assertEquals(store.previous, received.get());
|
||||||
|
assertEquals(List.of("history", "previous", "start"), store.events.subList(0, 3));
|
||||||
|
assertEquals("started:" + result.runId(), observerEvents.get(0));
|
||||||
|
assertEquals("status:ROUTING", observerEvents.get(1));
|
||||||
|
assertEquals(IntentType.DIAGNOSIS, store.finishedIntent);
|
||||||
|
assertNotNull(store.finishedPublishedResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void routingFailurePersistsFailedAndNeverCallsAnyExecutor() {
|
||||||
|
FakeStore store = new FakeStore();
|
||||||
|
AtomicInteger executors = new AtomicInteger();
|
||||||
|
ChatApplicationUseCase useCase = useCase(
|
||||||
|
store,
|
||||||
|
(context, input) -> { throw new IntentRoutingException(new RuntimeException("invalid")); },
|
||||||
|
(context, query) -> { executors.incrementAndGet(); return new SystemChatContent("x"); },
|
||||||
|
(context, query) -> { executors.incrementAndGet(); return new KnowledgeContent("x", List.of(), List.of()); },
|
||||||
|
(context, query, previous, status) -> { executors.incrementAndGet(); return diagnosisSuccess(query); });
|
||||||
|
|
||||||
|
ChatApplicationException failure = assertThrows(ChatApplicationException.class, () ->
|
||||||
|
useCase.execute(new ChatApplicationRequest("route me", "session-route-fail")));
|
||||||
|
|
||||||
|
assertEquals(ChatFailureCode.ROUTING_UNAVAILABLE, failure.code());
|
||||||
|
assertEquals(0, executors.get());
|
||||||
|
assertEquals(ReleaseOutcome.FAILED, store.finishedOutcome);
|
||||||
|
assertEquals(com.superbiz.agent.harness.core.RunState.FAILED,
|
||||||
|
store.startedContext.lifecycle().state());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void clientDisconnectControlCancelsSameRunAndPersistsCancelled() {
|
||||||
|
FakeStore store = new FakeStore();
|
||||||
|
ChatApplicationUseCase useCase = useCase(
|
||||||
|
store,
|
||||||
|
(context, input) -> {
|
||||||
|
assertTrue(context.cancellation().isCancelled());
|
||||||
|
throw new com.superbiz.agent.harness.core.RunAbortedException(
|
||||||
|
context.lifecycle().termination().orElseThrow());
|
||||||
|
},
|
||||||
|
(context, query) -> new SystemChatContent("unused"),
|
||||||
|
(context, query) -> new KnowledgeContent("unused", List.of(), List.of()),
|
||||||
|
(context, query, previous, status) -> diagnosisSuccess(query));
|
||||||
|
ChatApplicationObserver cancelOnStart = new ChatApplicationObserver() {
|
||||||
|
@Override
|
||||||
|
public void onStarted(ChatRunControl runControl) {
|
||||||
|
assertEquals(store.startedContext.runId(), runControl.runId());
|
||||||
|
assertTrue(runControl.cancelClientDisconnect());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onStatus(ChatApplicationStatus status) {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ChatApplicationException failure = assertThrows(ChatApplicationException.class, () ->
|
||||||
|
useCase.execute(new ChatApplicationRequest("cancel", "session-cancel"), cancelOnStart));
|
||||||
|
|
||||||
|
assertEquals(ChatFailureCode.RUN_CANCELLED, failure.code());
|
||||||
|
assertEquals(ReleaseOutcome.CANCELLED, store.finishedOutcome);
|
||||||
|
assertEquals(com.superbiz.agent.harness.core.RunState.CANCELLED,
|
||||||
|
store.startedContext.lifecycle().state());
|
||||||
|
}
|
||||||
|
|
||||||
|
private ChatApplicationUseCase useCase(FakeStore store,
|
||||||
|
IntentRouting routing,
|
||||||
|
SystemChatOperation system,
|
||||||
|
KnowledgeQueryOperation knowledge,
|
||||||
|
DiagnosisOperation diagnosis) {
|
||||||
|
AtomicInteger ids = new AtomicInteger();
|
||||||
|
DiagnosisHarnessCore core = new DiagnosisHarnessCore(
|
||||||
|
Clock.systemUTC(), () -> "run-app-" + ids.incrementAndGet(), Duration.ofMinutes(5),
|
||||||
|
new RunBudgetLimits(20, 20, 20, 100_000, 100_000, 200_000, 1_000_000),
|
||||||
|
HarnessRetryPolicies.strict());
|
||||||
|
return new ChatApplicationUseCase(
|
||||||
|
core, () -> "generated-session", store, routing,
|
||||||
|
system, knowledge, diagnosis, objectMapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
private DiagnosisExecutionResult diagnosisSuccess(String query) {
|
||||||
|
DiagnosisDraft draft = new DiagnosisDraft(
|
||||||
|
new DiagnosisDraft.Conclusion("safe conclusion", List.of("a-1")),
|
||||||
|
List.of(new DiagnosisDraft.AnalysisItem(
|
||||||
|
"a-1", AnalysisKind.NORMAL, "supported analysis", List.of("internal-call"))),
|
||||||
|
List.of(), List.of(), new DiagnosisDraft.Limitations("scope", List.of()));
|
||||||
|
PublishedResult published = new PublishedResult(
|
||||||
|
query, "safe conclusion", "scope", List.of(), List.of());
|
||||||
|
return new DiagnosisExecutionResult(
|
||||||
|
ReleaseOutcome.SUCCESS,
|
||||||
|
new DiagnosisContent(SemanticDraftView.from(draft), List.of()),
|
||||||
|
published);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ChatApplicationObserver observer(List<String> events) {
|
||||||
|
return new ChatApplicationObserver() {
|
||||||
|
@Override
|
||||||
|
public void onStarted(ChatRunControl runControl) {
|
||||||
|
events.add("started:" + runControl.runId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onStatus(ChatApplicationStatus status) {
|
||||||
|
events.add("status:" + status.name());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class FakeStore implements ChatRunStore {
|
||||||
|
private final List<String> events = new ArrayList<>();
|
||||||
|
private RoutingHistory history;
|
||||||
|
private PreviousTurn previous;
|
||||||
|
private RunContext startedContext;
|
||||||
|
private IntentType finishedIntent;
|
||||||
|
private ReleaseOutcome finishedOutcome;
|
||||||
|
private PublishedResult finishedPublishedResult;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<RoutingHistory> findLatestRoutingHistory(String sessionId) {
|
||||||
|
events.add("history");
|
||||||
|
return Optional.ofNullable(history);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<PreviousTurn> findPreviousTurn(String sessionId) {
|
||||||
|
events.add("previous");
|
||||||
|
return Optional.ofNullable(previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void start(RunContext context, String query) {
|
||||||
|
events.add("start");
|
||||||
|
startedContext = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void markIntent(String runId, IntentType intent) {
|
||||||
|
events.add("intent:" + intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void finish(RunContext context, IntentType intent, ReleaseOutcome outcome,
|
||||||
|
String safeContentJson, PublishedResult publishedResult, int durationMs) {
|
||||||
|
events.add("finish:" + outcome);
|
||||||
|
finishedIntent = intent;
|
||||||
|
finishedOutcome = outcome;
|
||||||
|
finishedPublishedResult = publishedResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+135
@@ -0,0 +1,135 @@
|
|||||||
|
package com.superbiz.agent.harness.application;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.superbiz.agent.domain.entity.DiagnosisRun;
|
||||||
|
import com.superbiz.agent.harness.application.persistence.JpaChatRunStore;
|
||||||
|
import com.superbiz.agent.harness.application.persistence.PreviousTurnLimits;
|
||||||
|
import com.superbiz.agent.harness.application.persistence.PublishedResultPolicy;
|
||||||
|
import com.superbiz.agent.harness.contract.AnalysisKind;
|
||||||
|
import com.superbiz.agent.harness.contract.DiagnosisDraft;
|
||||||
|
import com.superbiz.agent.harness.contract.IntentType;
|
||||||
|
import com.superbiz.agent.harness.contract.PublishedResult;
|
||||||
|
import com.superbiz.agent.harness.contract.ReleaseOutcome;
|
||||||
|
import com.superbiz.agent.harness.contract.SourceDocument;
|
||||||
|
import com.superbiz.agent.harness.core.DiagnosisHarnessCore;
|
||||||
|
import com.superbiz.agent.harness.core.RunBudgetLimits;
|
||||||
|
import com.superbiz.agent.harness.core.RunContext;
|
||||||
|
import com.superbiz.agent.harness.guard.evidence.VerifiedAnalysisEvidence;
|
||||||
|
import com.superbiz.agent.harness.guard.evidence.VerifiedEvidence;
|
||||||
|
import com.superbiz.agent.harness.guard.evidence.VerifiedEvidenceSnapshot;
|
||||||
|
import com.superbiz.agent.harness.retry.HarnessRetryPolicies;
|
||||||
|
import com.superbiz.agent.repository.ChatSessionRepository;
|
||||||
|
import com.superbiz.agent.repository.DiagnosisRunRepository;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.core.io.ClassPathResource;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Clock;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
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;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class PublishedResultPersistenceTest {
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void policyBuildsBoundedRagOnlyPublishedResultWithoutInternalEvidence() throws Exception {
|
||||||
|
PublishedResultPolicy policy = new PublishedResultPolicy(
|
||||||
|
new PreviousTurnLimits(8, 10, 12, 1, 6, 1, 10, 8));
|
||||||
|
DiagnosisDraft draft = new DiagnosisDraft(
|
||||||
|
new DiagnosisDraft.Conclusion("connection pool exhausted", List.of("a-1")),
|
||||||
|
List.of(new DiagnosisDraft.AnalysisItem(
|
||||||
|
"a-1", AnalysisKind.NORMAL, "active=50", List.of("call-1"))),
|
||||||
|
List.of(), List.of(),
|
||||||
|
new DiagnosisDraft.Limitations("order-service last 30 minutes", List.of("missing slow SQL")));
|
||||||
|
VerifiedEvidenceSnapshot snapshot = new VerifiedEvidenceSnapshot(List.of(
|
||||||
|
new VerifiedAnalysisEvidence("a-1", "active=50", AnalysisKind.NORMAL, List.of(
|
||||||
|
new VerifiedEvidence("RAG", "guide.md", "pool", null, "excerpt",
|
||||||
|
Map.of("document_id", "document-123", "title", "Long guide title")),
|
||||||
|
new VerifiedEvidence("LOG", "APPLICATION", "30m", null, "raw log", Map.of())))));
|
||||||
|
|
||||||
|
PublishedResult result = policy.create("why pool exhausted", draft, snapshot).orElseThrow();
|
||||||
|
|
||||||
|
assertEquals(8, result.userQuery().length());
|
||||||
|
assertEquals(10, result.publishedConclusion().length());
|
||||||
|
assertEquals(1, result.sourceDocuments().size());
|
||||||
|
assertEquals(10, result.sourceDocuments().get(0).documentId().length());
|
||||||
|
String json = objectMapper.writeValueAsString(result);
|
||||||
|
assertFalse(json.contains("tool_call_id"));
|
||||||
|
assertFalse(json.contains("raw log"));
|
||||||
|
assertFalse(json.contains("excerpt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void storeUsesExactSafePreviousTurnQueryAndCorruptJsonFailsClosed() {
|
||||||
|
DiagnosisRunRepository runs = mock(DiagnosisRunRepository.class);
|
||||||
|
ChatSessionRepository sessions = mock(ChatSessionRepository.class);
|
||||||
|
DiagnosisRun corrupt = DiagnosisRun.builder()
|
||||||
|
.runId("run-old").sessionId("session-1").query("q")
|
||||||
|
.intent(IntentType.DIAGNOSIS).releaseOutcome(ReleaseOutcome.SUCCESS)
|
||||||
|
.publishedResult("not-json").build();
|
||||||
|
when(runs.findFirstBySessionIdAndIntentAndReleaseOutcomeAndPublishedResultIsNotNullOrderByCreatedAtDescIdDesc(
|
||||||
|
"session-1", IntentType.DIAGNOSIS, ReleaseOutcome.SUCCESS))
|
||||||
|
.thenReturn(Optional.of(corrupt));
|
||||||
|
JpaChatRunStore store = new JpaChatRunStore(
|
||||||
|
sessions, runs, objectMapper,
|
||||||
|
new PublishedResultPolicy(PreviousTurnLimits.defaults()));
|
||||||
|
|
||||||
|
assertTrue(store.findPreviousTurn("session-1").isEmpty());
|
||||||
|
verify(runs).findFirstBySessionIdAndIntentAndReleaseOutcomeAndPublishedResultIsNotNullOrderByCreatedAtDescIdDesc(
|
||||||
|
"session-1", IntentType.DIAGNOSIS, ReleaseOutcome.SUCCESS);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fallbackCompletionStoresSafeAnswerButNeverPublishedResult() {
|
||||||
|
DiagnosisRunRepository runs = mock(DiagnosisRunRepository.class);
|
||||||
|
ChatSessionRepository sessions = mock(ChatSessionRepository.class);
|
||||||
|
DiagnosisRun entity = DiagnosisRun.builder()
|
||||||
|
.runId("run-fallback").sessionId("session-1").query("q").status("RUNNING").build();
|
||||||
|
when(runs.findByRunId("run-fallback")).thenReturn(Optional.of(entity));
|
||||||
|
when(runs.save(any(DiagnosisRun.class))).thenAnswer(invocation -> invocation.getArgument(0));
|
||||||
|
JpaChatRunStore store = new JpaChatRunStore(sessions, runs, objectMapper);
|
||||||
|
RunContext context = core().startRun("session-1", "run-fallback");
|
||||||
|
|
||||||
|
store.finish(context, IntentType.DIAGNOSIS, ReleaseOutcome.FALLBACK,
|
||||||
|
"{\"type\":\"SEMANTIC_UNAVAILABLE\"}",
|
||||||
|
new PublishedResult("q", "c", "scope", List.of(),
|
||||||
|
List.of(new SourceDocument("doc", "title"))), 12);
|
||||||
|
|
||||||
|
assertEquals("SUCCESS", entity.getStatus());
|
||||||
|
assertEquals(ReleaseOutcome.FALLBACK, entity.getReleaseOutcome());
|
||||||
|
assertNull(entity.getPublishedResult());
|
||||||
|
assertEquals(12, entity.getTotalDurationMs());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void migrationContainsEntityColumnsAndPreviousTurnIndex() throws Exception {
|
||||||
|
String sql;
|
||||||
|
try (var input = new ClassPathResource(
|
||||||
|
"db/migration/V012__add_chat_release_contract.sql").getInputStream()) {
|
||||||
|
sql = new String(input.readAllBytes(), StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
assertTrue(sql.contains("ADD COLUMN intent VARCHAR(32)"));
|
||||||
|
assertTrue(sql.contains("ADD COLUMN release_outcome VARCHAR(16)"));
|
||||||
|
assertTrue(sql.contains("ADD COLUMN published_result JSON"));
|
||||||
|
assertTrue(sql.contains("idx_diagnosis_run_previous_turn"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private DiagnosisHarnessCore core() {
|
||||||
|
return new DiagnosisHarnessCore(
|
||||||
|
Clock.systemUTC(), () -> "unused", Duration.ofMinutes(5),
|
||||||
|
new RunBudgetLimits(10, 10, 10, 10_000, 10_000, 20_000, 100_000),
|
||||||
|
HarnessRetryPolicies.strict());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -109,10 +109,10 @@ class SemanticGuardTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void attemptTimeoutCancelsBothPermittedModelCalls() throws Exception {
|
void attemptTimeoutCancelsEveryPermittedAttempt() throws Exception {
|
||||||
DiagnosisHarnessCore core = core();
|
DiagnosisHarnessCore core = core();
|
||||||
RunContext context = core.startRun("session-timeout", "run-timeout");
|
RunContext context = core.startRun("session-timeout", "run-timeout");
|
||||||
BlockingChatModel model = new BlockingChatModel(2);
|
BlockingChatModel model = new BlockingChatModel(1);
|
||||||
List<RetryAttempt> attempts = new ArrayList<>();
|
List<RetryAttempt> attempts = new ArrayList<>();
|
||||||
SemanticGuard guard = new SemanticGuard(
|
SemanticGuard guard = new SemanticGuard(
|
||||||
core, new HarnessRetryExecutor(core),
|
core, new HarnessRetryExecutor(core),
|
||||||
@@ -128,8 +128,10 @@ class SemanticGuardTest {
|
|||||||
assertEquals(com.superbiz.agent.harness.retry.RetryFailure.TIMEOUT, failure.failure());
|
assertEquals(com.superbiz.agent.harness.retry.RetryFailure.TIMEOUT, failure.failure());
|
||||||
assertEquals(2, failure.attempts());
|
assertEquals(2, failure.attempts());
|
||||||
assertTrue(model.interrupted.await(2, TimeUnit.SECONDS));
|
assertTrue(model.interrupted.await(2, TimeUnit.SECONDS));
|
||||||
assertEquals(2, model.calls.get());
|
|
||||||
assertEquals(2, attempts.size());
|
assertEquals(2, attempts.size());
|
||||||
|
assertTrue(attempts.stream().allMatch(attempt ->
|
||||||
|
attempt.failure() == com.superbiz.agent.harness.retry.RetryFailure.TIMEOUT));
|
||||||
|
assertEquals(2, context.budget().snapshot().modelCalls());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user