diff --git a/docs/import/20260712/OPEN_AGENT_API_JAVA_SSE_CLIENT.md b/docs/import/20260712/OPEN_AGENT_API_JAVA_SSE_CLIENT.md new file mode 100644 index 0000000..d82b4b9 --- /dev/null +++ b/docs/import/20260712/OPEN_AGENT_API_JAVA_SSE_CLIENT.md @@ -0,0 +1,598 @@ +# SuperAgent Open API Java SSE 接入与断流恢复 + +## 1. 目标 + +本文档定义 Java 后端调用 SuperAgent Open API 流式接口时必须实现的客户端行为,重点解决以下问题: + +- SSE 响应在 `answer`、结构化 `error` 或 `end` 前提前关闭。 +- Java 抛出 `IOException: Premature EOF`、`EOFException` 或 incomplete chunked response。 +- 网络中断后误把部分回答当成成功结果。 +- 网络中断后重新 POST,造成同一业务消息被执行两次。 +- 无法使用调用方 `debug_run_id` 关联 SuperAgent 内部 `run_id`。 + +适用接口: + +```text +POST /api/open/agent-sessions/{session_id}/messages/stream?include_trace=true +GET /api/open/agent-sessions/{session_id}/runs/{run_id}/events +GET /api/open/agent-sessions/{session_id}/runs/{run_id} +``` + +推荐运行环境:Java 17 或更高版本,JDK `java.net.http.HttpClient`,Jackson 2.x。 + +## 2. Java 端必须遵守的成功条件 + +Java 客户端只有在同时满足以下条件时才能把调用判定为成功: + +1. 收到最终 AI 内容:优先使用 `message.final`,兼容累计 `message.delta`。 +2. 收到 `run.completed`,并且 `status=success`。 +3. 收到顶层 SSE `event: end`。 +4. 没有收到顶层 `event: error` 或 `trace.data.event=run.failed`。 + +以下情况都不能返回成功: + +- 只收到部分 `message.delta` 后 EOF。 +- 收到 `run.completed`,但没有最终 AI 内容。 +- 收到最终内容,但没有 `end`。 +- HTTP 连接正常关闭,但未出现协议终止事件。 +- 运行状态仍为 `pending`、`running`、`interrupted`、`timeout` 或 `error`。 + +## 3. 请求头与关联信息 + +推荐请求头: + +```http +Authorization: Bearer df_open_xxx +Accept: text/event-stream +Content-Type: application/json +Cache-Control: no-cache +X-Request-ID: 2076266481248989185 +``` + +也可以使用: + +```http +X-DeerFlow-Open-API-Key: df_open_xxx +``` + +每次业务调用生成一个稳定的 `debug_run_id`,同时写入: + +- `X-Request-ID` 请求头。 +- 本地业务日志。 +- 本地调用记录。 +- 请求 `metadata.debug_run_id`,前提是外部应用策略允许该 metadata 字段。 + +请求示例: + +```json +{ + "message": "用户问题", + "idempotency_key": "business-message-20260712-0001", + "metadata": { + "debug_run_id": "2076266481248989185", + "conversation_id": "conversation-001" + } +} +``` + +`idempotency_key` 必须在同一业务消息的所有尝试中保持不变。服务端未确认幂等命中前,不得依赖它盲目重发初始 POST。 + +## 4. 超时配置 + +SSE 是长连接,不要给整个 `HttpRequest` 设置 30 秒或 60 秒总超时。 + +推荐: + +```java +HttpClient client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .version(HttpClient.Version.HTTP_1_1) + .build(); +``` + +初始 SSE 请求不要调用: + +```java +requestBuilder.timeout(Duration.ofSeconds(60)); +``` + +客户端应分别控制: + +- 建连超时:10 秒左右。 +- 首个事件超时:由业务层 watchdog 控制,例如 60 秒。 +- 心跳空闲超时:建议 45~90 秒,必须大于服务端心跳间隔。 +- 总业务执行超时:根据 Agent 任务类型配置,例如 10~30 分钟。 +- 重连次数:建议 5 次。 + +`Premature EOF` 表示对端提前关闭响应,不是普通读取等待超时。单纯增大 read timeout 不能修复该问题。 + +## 5. 必须保存的流状态 + +每个运行至少保存: + +```java +final class SseRunState { + final StringBuilder deltaContent = new StringBuilder(); + String finalContent; + String lastEventId; + URI runUri; + String runId; + String failureCode; + String failureMessage; + boolean runCompleted; + boolean endReceived; +} +``` + +状态建议在收到每个事件后同步写入本地调用记录,以便进程重启后仍能诊断。 + +## 6. SSE 解析要求 + +Java 客户端必须正确处理: + +- `event:`:事件类型。 +- `data:`:允许多行,使用换行拼接。 +- `id:`:保存最后一个非空事件 ID。 +- 以 `:` 开头的 heartbeat/comment。 +- 空行:表示一帧 SSE 结束。 +- EOF:若未收到 `end`,必须抛出可恢复的流中断异常。 + +不要通过搜索字符串 `"event: end"` 解析流;必须按 SSE 帧解析。 + +## 7. 事件处理规则 + +### 7.1 Trace 事件 + +```text +event: trace +data: {"event":"message.delta","text":"部分回答"} +``` + +处理方式:按事件 ID 去重后追加到 `deltaContent`。 + +```text +event: trace +data: {"event":"message.final","text":"完整最终回答"} +``` + +处理方式:写入 `finalContent`。该事件是最终答案的权威来源,不要再把此前 delta 追加到它后面。 + +```text +event: trace +data: {"event":"run.completed","status":"success"} +``` + +处理方式:设置 `runCompleted=true`,但继续读取,直到收到顶层 `end`。 + +```text +event: trace +data: {"event":"run.failed","error":{"code":"...","message":"..."}} +``` + +处理方式:记录失败信息,继续读取到 `end`,最终抛出业务异常。 + +### 7.2 顶层错误和结束事件 + +```text +event: error +data: {"code":"open_agent_sse_stream_error","message":"...","retryable":true} + +event: end +data: null +``` + +收到 `error` 后不得返回已累计的部分回答。收到 `end` 后按第 2 节的成功条件判定最终结果。 + +## 8. 断流恢复流程 + +初始 POST 返回 HTTP 200 后,立即读取响应头: + +```http +Content-Location: /api/open/agent-sessions/{session_id}/runs/{run_id} +``` + +客户端必须保存: + +- `runUri`:由请求基础地址解析 `Content-Location`。 +- `runId`:从 `runUri` 提取。 +- `lastEventId`:从最后一个完整 SSE 帧提取。 + +遇到 EOF、连接重置或 incomplete chunked response 时: + +1. 不要重新发送初始消息 POST。 +2. 查询 `GET {runUri}`。 +3. 如果状态为 `pending` 或 `running`,订阅 `GET {runUri}/events`。 +4. 重连请求携带 `Last-Event-ID`。 +5. 使用指数退避和随机抖动,最多重连 5 次。 +6. 如果状态为 `success`,但没有最终答案,仍尝试事件恢复;恢复失败则报告“运行成功但最终内容丢失”。 +7. 如果状态为 `error`、`timeout` 或 `interrupted`,停止重连并返回明确失败。 + +重连请求: + +```http +GET /api/open/agent-sessions/{session_id}/runs/{run_id}/events +Authorization: Bearer df_open_xxx +Accept: text/event-stream +Last-Event-ID: 1752319583000-17 +X-Request-ID: 2076266481248989185 +``` + +恢复接口要求外部应用具备 `agent_sessions:read` scope,并启用 trace policy。 + +## 9. JDK HttpClient 参考实现 + +以下代码展示核心逻辑。项目可按现有异常体系、日志框架和 DTO 规范拆分。 + +```java +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.BufferedReader; +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.ThreadLocalRandom; + +public final class SuperAgentSseClient { + private final ObjectMapper mapper = new ObjectMapper(); + private final HttpClient client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .version(HttpClient.Version.HTTP_1_1) + .build(); + + public String invoke( + URI streamUri, + String apiKey, + String requestId, + String requestJson + ) throws Exception { + HttpRequest request = baseRequest(streamUri, apiKey, requestId) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(requestJson)) + .build(); + + HttpResponse response = client.send( + request, + HttpResponse.BodyHandlers.ofInputStream() + ); + require2xx(response.statusCode()); + + String contentLocation = response.headers() + .firstValue("Content-Location") + .orElseThrow(() -> new IOException("Missing Content-Location")); + + SseRunState state = new SseRunState(); + state.runUri = streamUri.resolve(contentLocation); + state.runId = state.runUri.getPath().replaceFirst(".*/runs/", ""); + + try { + consume(response.body(), state); + } catch (IOException firstFailure) { + recover(apiKey, requestId, state, firstFailure); + } + + return requireSuccessfulResult(state); + } + + private void recover( + String apiKey, + String requestId, + SseRunState state, + IOException firstFailure + ) throws Exception { + IOException lastFailure = firstFailure; + URI eventsUri = URI.create( + state.runUri.toString().replaceAll("/+$", "") + "/events" + ); + + for (int attempt = 1; attempt <= 5; attempt++) { + Thread.sleep(backoffMillis(attempt)); + + String status = queryRunStatus( + state.runUri, apiKey, requestId + ); + if ("error".equals(status) + || "timeout".equals(status) + || "interrupted".equals(status)) { + throw new IOException( + "SuperAgent run is terminal: runId=" + state.runId + + ", status=" + status + ); + } + + HttpRequest.Builder builder = baseRequest( + eventsUri, apiKey, requestId + ).GET(); + if (state.lastEventId != null) { + builder.header("Last-Event-ID", state.lastEventId); + } + + try { + HttpResponse response = client.send( + builder.build(), + HttpResponse.BodyHandlers.ofInputStream() + ); + require2xx(response.statusCode()); + consume(response.body(), state); + if (state.endReceived) { + return; + } + } catch (IOException failure) { + lastFailure = failure; + } + } + + throw new IOException( + "SuperAgent SSE recovery exhausted; runId=" + state.runId + + ", lastEventId=" + state.lastEventId, + lastFailure + ); + } + + private String queryRunStatus( + URI runUri, + String apiKey, + String requestId + ) throws IOException, InterruptedException { + HttpRequest request = HttpRequest.newBuilder(runUri) + .header("Authorization", "Bearer " + apiKey) + .header("Accept", "application/json") + .header("X-Request-ID", requestId) + .GET() + .build(); + HttpResponse response = client.send( + request, + HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8) + ); + require2xx(response.statusCode()); + return mapper.readTree(response.body()).path("status").asText(); + } + + private void consume(InputStream input, SseRunState state) + throws IOException { + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(input, StandardCharsets.UTF_8) + )) { + String event = "message"; + String id = null; + StringBuilder data = new StringBuilder(); + String line; + + while ((line = reader.readLine()) != null) { + if (line.startsWith(":")) { + continue; + } + if (line.isEmpty()) { + dispatch(event, id, data.toString(), state); + event = "message"; + id = null; + data.setLength(0); + if (state.endReceived) { + return; + } + continue; + } + if (line.startsWith("event:")) { + event = line.substring(6).trim(); + } else if (line.startsWith("id:")) { + id = line.substring(3).trim(); + } else if (line.startsWith("data:")) { + if (data.length() > 0) { + data.append('\n'); + } + data.append(line.substring(5).trim()); + } + } + } + + if (!state.endReceived) { + throw new EOFException( + "Premature EOF before SSE end; runId=" + state.runId + + ", lastEventId=" + state.lastEventId + ); + } + } + + private void dispatch( + String event, + String id, + String data, + SseRunState state + ) throws IOException { + if (id != null && !id.isBlank()) { + if (!state.processedEventIds.add(id)) { + return; + } + state.lastEventId = id; + } + if ("end".equals(event)) { + state.endReceived = true; + return; + } + if ("error".equals(event)) { + JsonNode error = parseJson(data); + state.failureCode = error.path("code").asText("stream_error"); + state.failureMessage = error.path("message").asText(data); + return; + } + if (!"trace".equals(event) || data.isBlank()) { + return; + } + + JsonNode trace = parseJson(data); + switch (trace.path("event").asText()) { + case "message.delta" -> + state.deltaContent.append(trace.path("text").asText("")); + case "message.final" -> + state.finalContent = trace.path("text").asText(""); + case "run.completed" -> + state.runCompleted = "success".equals( + trace.path("status").asText() + ); + case "run.failed" -> { + JsonNode error = trace.path("error"); + state.failureCode = error.path("code") + .asText("run_failed"); + state.failureMessage = error.path("message") + .asText(error.toString()); + } + default -> { + // reasoning/tool/progress/task/step 事件按业务需要记录。 + } + } + } + + private JsonNode parseJson(String data) throws IOException { + return data == null || data.isBlank() + ? mapper.createObjectNode() + : mapper.readTree(data); + } + + private static HttpRequest.Builder baseRequest( + URI uri, + String apiKey, + String requestId + ) { + return HttpRequest.newBuilder(uri) + .header("Authorization", "Bearer " + apiKey) + .header("Accept", "text/event-stream") + .header("Cache-Control", "no-cache") + .header("X-Request-ID", requestId); + } + + private static String requireSuccessfulResult(SseRunState state) + throws IOException { + if (state.failureCode != null) { + throw new IOException( + "SuperAgent failed: " + state.failureCode + ": " + + state.failureMessage + ); + } + if (!state.endReceived) { + throw new EOFException("Missing terminal end event"); + } + if (!state.runCompleted) { + throw new IOException("Missing successful run.completed event"); + } + String answer = state.finalContent; + if (answer == null || answer.isBlank()) { + answer = state.deltaContent.toString(); + } + if (answer.isBlank()) { + throw new IOException( + "Run completed but final AI content was not delivered" + ); + } + return answer; + } + + private static void require2xx(int statusCode) throws IOException { + if (statusCode < 200 || statusCode >= 300) { + throw new IOException("Unexpected HTTP status: " + statusCode); + } + } + + private static long backoffMillis(int attempt) { + long base = Math.min(8_000L, 500L << (attempt - 1)); + return base + ThreadLocalRandom.current().nextLong(200L); + } + + static final class SseRunState { + final StringBuilder deltaContent = new StringBuilder(); + final Set processedEventIds = new HashSet<>(); + String finalContent; + String lastEventId; + URI runUri; + String runId; + String failureCode; + String failureMessage; + boolean runCompleted; + boolean endReceived; + } +} +``` + +Jackson Maven 依赖示例: + +```xml + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + +``` + +## 10. 去重规则 + +- 使用 SSE `id` 对事件去重。 +- 重连后收到小于或等于已处理 ID 的事件时跳过。 +- `message.final` 按 `run_id` 只处理一次。 +- `end` 可重复处理,但只能完成一次本地 Future/Promise。 +- 不要仅根据文本内容去重;不同 delta 可能具有相同文本。 + +如果使用多个 Java 实例消费同一业务调用,`run_id + event_id` 的去重状态必须存放在共享存储中。 + +## 11. 日志与指标 + +每次调用至少记录: + +- `debug_run_id` / `X-Request-ID`。 +- `session_id`。 +- SuperAgent `run_id`。 +- 最后处理的 `event_id`。 +- HTTP 状态码。 +- 收到的首事件时间、最后事件时间和 `end` 时间。 +- 重连次数和每次重连原因。 +- 是否收到 `message.final`、`run.completed`、`error`、`end`。 +- 最终结果:success、run_failed、stream_interrupted、recovery_exhausted、protocol_incomplete。 + +建议指标: + +```text +superagent_sse_requests_total +superagent_sse_success_total +superagent_sse_premature_eof_total +superagent_sse_reconnect_total +superagent_sse_recovery_success_total +superagent_sse_missing_end_total +superagent_sse_missing_final_content_total +superagent_sse_duration_seconds +``` + +日志中不要记录 API Key、完整系统提示词或敏感工具输入输出。 + +## 12. 验收测试 + +Java 端上线前至少覆盖: + +1. 正常 delta → final → completed → end。 +2. 无 delta,直接 final → completed → end。 +3. 收到结构化 error → end。 +4. 收到部分 delta 后 EOF,使用 `Last-Event-ID` 恢复成功。 +5. 重连期间再次 EOF,指数退避后恢复成功。 +6. 重连超过最大次数,返回 recovery exhausted。 +7. completed 但没有 final/delta,返回协议错误。 +8. 有 final 但没有 end,不能返回成功。 +9. 事件重放时不会重复拼接 delta。 +10. API 返回 401、403、404、409、429、5xx。 +11. Java 进程取消请求时,本地状态正确关闭。 +12. 同一业务消息不会因 EOF 自动重新 POST。 + +## 13. 上线顺序 + +1. 先部署服务端 SSE 终止契约、断连继续执行、最终答案事件和 nginx 配置。 +2. 在测试环境验证 `/events` 支持 `Last-Event-ID` 恢复。 +3. Java 端以灰度方式开启严格 `end` 校验和恢复逻辑。 +4. 观察 premature EOF、恢复成功率和缺失最终内容指标。 +5. 指标稳定后全量启用。 + +服务端升级前,Java 可以先完成解析、终止帧校验、关联日志和错误分类,但不要启用自动重发初始 POST,也不能假设 EOF 后原运行仍会继续。 diff --git a/docs/import/20260712/前端字段控件修改说明_给信息系统小伙伴Codex_2026-07-12.md b/docs/import/20260712/前端字段控件修改说明_给信息系统小伙伴Codex_2026-07-12.md new file mode 100644 index 0000000..c08c24a --- /dev/null +++ b/docs/import/20260712/前端字段控件修改说明_给信息系统小伙伴Codex_2026-07-12.md @@ -0,0 +1,357 @@ +# 前端字段控件修改说明 + +## 给信息系统 / Adapter / Frontend Codex 的实施任务 + +日期:2026-07-12 +适用范围:任务卡展示、人工复核编辑、字段校验和同卡解阻 +不适用范围:Prompt、Skill、业务 references、MCP Server、Gateway、公开 JSON 业务规则重设计 + +--- + +## 0. 先读这些文件 + +请先从当前工作区根目录读取以下文件,再开始修改: + +1. `prompt_skill_architecture_optimization_2026-07-12/README.md` +2. `prompt_skill_architecture_optimization_2026-07-12/01_PLAN/compatibility_invariants.md` +3. `prompt_skill_architecture_optimization_2026-07-12/03_VALIDATION/draft_validation_2026-07-12.md` +4. `outputs/黄哥codex读 2/README_先读_交给Codex.md` +5. `outputs/黄哥codex读 2/04_Adapter_Frontend_保持P0.1_40路由/任务卡前端字段变更与路由说明_3.0_to_current.md` +6. `outputs/黄哥codex读 2/04_Adapter_Frontend_保持P0.1_40路由/任务卡前端字段变更说明_3.0_to_当前版_2026-07-11.xlsx` +7. `prompt_skill_architecture_optimization_2026-07-12/02_DRAFT/working/skills/booking-desk-event/references/00-output-contract.md` +8. `prompt_skill_architecture_optimization_2026-07-12/02_DRAFT/working/skills/booking-desk-event/references/90-manual-review.md` +9. `prompt_skill_architecture_optimization_2026-07-12/02_DRAFT/working/skills/booking-desk-event/references/16-trace-notes.md` +10. `prompt_skill_architecture_optimization_2026-07-12/02_DRAFT/working/skills/booking-desk-event/references/31-allotment-control-block.md` +11. `prompt_skill_architecture_optimization_2026-07-12/02_DRAFT/working/skills/booking-desk-event/references/50-room-type-mapping.md` +12. `prompt_skill_architecture_optimization_2026-07-12/02_DRAFT/working/skills/booking-desk-event/references/51-rate-code.md` +13. `prompt_skill_architecture_optimization_2026-07-12/02_DRAFT/working/skills/booking-desk-event/references/52-fix-charge.md` + +旧文件 `任务卡前端展示字段表 3.0(1).xlsx` 只作为历史基线,不得作为当前字段、路由和枚举的唯一来源。 + +如果上述交接文件或实际 Adapter/Frontend 源码不在当前工作区,请停止代码修改并报告缺失路径;不要根据旧 Excel 猜测当前字段。 + +--- + +## 1. 任务目标 + +把任务卡前端从旧版“是否输入 / 是否下拉”字段说明,升级为当前项目可执行的控件注册和人工复核编辑机制: + +```text +Agent final result + → adapter 按 event 派生任务卡 + → manual_review.missing_fields[] 指定可编辑字段 + → 前端展示对应控件 + → 用户修改 + → review_resolution.field_overrides[] + → 重新校验依赖字段 + → Preflight +``` + +核心要求: + +- 原 Agent payload 不可变。 +- 用户覆盖值不得直接写回 Agent 原始 JSON。 +- 不因为前端编辑而改变 `event_type`、业务 task type、task subtype 或关系语义。 +- 不创建第二张 linked normal task。 +- 只有同一业务卡的 `review_status` 从 `pending` 进入 `resolved` 后,才允许继续 Preflight。 +- 邮件原文、附件、raw evidence、source ID、source event index 和关系索引只读。 + +当前项目的最新 Prompt/Skill 架构增量仍处于草案、未进入 `04_RELEASE` 状态;本任务只修改信息系统展示/编辑层,不把草案直接宣称为生产发布。 + +--- + +## 2. 修改范围 + +### 允许修改 + +- Adapter 的字段注册表 / 字段映射层。 +- 任务卡展示组件和人工复核组件。 +- 控件类型、编辑权限和字段校验逻辑。 +- `review_status` / `review_resolution` 的前端交互。 +- Adapter/Frontend 单元测试、契约测试和验收用例。 +- 当前字段交接 MD/Excel 的控件列和实现说明。 + +### 不得修改 + +- `prompts/`、`skills/`、`booking-desk-event` Skill 业务裁决逻辑。 +- 公开业务 JSON 根结构和既有五字段业务根。 +- S10、S99、`infrastructure_input_error` 结构。 +- 40 条 Adapter 路由数量和既有 task type/subtype 语义。 +- MCP 工具名、MCP payload mapping、一次提交生命周期。 +- `source_event_index` 的含义。 +- Parent Group / Allotment 业务本体。 +- 不得在前端新增 `Note`、`Allotment Maintenance` 或旧 Parent Cancel Booking 当前路由。 + +如果实现过程中发现必须改变上述内容,请先报告为“契约变更”,不要在前端代码中自行绕过。 + +--- + +## 3. 前端控件类型 + +字段注册表至少应支持以下 `control_type`。名称可按现有代码风格调整,但语义必须保持一致: + +| `control_type` | 用途 | +|---|---| +| `readonly` | Agent 结果、raw evidence、路由、关系和审计字段 | +| `text` | Group Code、Confirmation No.、备注、旧/新 Group Code | +| `textarea` | 允许覆盖的业务备注;必须保留原始证据 | +| `number` | 房量、人数、PAX、结算价、附加费金额 | +| `date` | 入住日、离店日、服务日期、取消生效日期 | +| `lookup` | 可输入并检索当前系统目标对象的组合框 | +| `select` | 固定枚举或目录选择 | +| `multiselect` | 多部门、多标签、多项业务分类 | +| `structured_table` | `before_after[]`、`room_items[]`、`fix_charge_items[]`、`trace_items[]` | +| `workflow_state` | `review_status`、Resolve/重新校验等动作 | + +字段注册表还应增加: + +| 配置项 | 说明 | +|---|---| +| `edit_scope` | `never`、`manual_review_only`、`workflow_only`、`system_only` | +| `options_source` | 固定契约枚举、当前 PMS 目录、Rate 配置中心、无、待冻结 | +| `write_target` | `review_resolution.field_overrides`、系统状态、无 | +| `validation_rule` | 格式、必填、目录、依赖字段和 Preflight 前置校验 | +| `raw_readonly` | 是否必须保留原始值且禁止覆盖 | + +示例: + +```json +{ + "field_pointer": "/extracted_fields/pms_room_type_code", + "control_type": "select", + "edit_scope": "manual_review_only", + "options_source": "active_pms_room_type_catalog", + "write_target": "review_resolution.field_overrides", + "raw_readonly": true, + "validation_rule": "catalog_value_exists_and_is_active" +} +``` + +--- + +## 4. 字段控件实施矩阵 + +### 4.1 目标、身份和 Group Code + +| 字段 | 控件 | 编辑范围 | 实施要求 | +|---|---|---|---| +| `case_keys.group_code` | `lookup` | `manual_review_only` | 可输入并检索候选;校验目标存在、唯一和当前业务卡匹配 | +| `case_keys.confirmation_number` | `lookup` 或 `text` | `manual_review_only` | 不使用固定下拉;允许粘贴后执行格式/目标校验 | +| `case_keys.reservation_number` | `lookup` 或 `text` | `manual_review_only` | 与当前事件对象类型匹配后才能提交 | +| Parent `case_keys.block_code` | `readonly` | `never` | 必须与 Parent `group_code` 相等;禁止独立编辑 | +| `extracted_fields.old_group_code` | `text` | `manual_review_only` | 与新 Group Code 成对必填 | +| `extracted_fields.new_group_code` | `text` | `manual_review_only` | 必须表达 old → new 方向关系 | +| `extracted_fields.target_key` | 不再作为主控件 | — | 当前应优先使用通用 `case_keys`;旧字段只做兼容迁移 | + +### 4.2 日期、房量和房型 + +| 字段 | 控件 | 编辑范围 | 实施要求 | +|---|---|---|---| +| `extracted_fields.arrival_date` | `date` | `manual_review_only` | 使用 `YYYY-MM-DD`;修改后重新校验离店日和晚数 | +| `extracted_fields.departure_date` | `date` | `manual_review_only` | 必须晚于入住日;保留原始日期证据 | +| `extracted_fields.nights` | `readonly` | `never` | 由入住日和离店日计算,不直接输入 | +| `extracted_fields.date_evidence.*` | `readonly` | `never` | `hotel_date_raw`、`tour_date_raw`、`action_date_raw`、`sheet_month_year` 只读 | +| `extracted_fields.room_quantity` | `number` | `manual_review_only` | 整数、非负/正数规则按现有业务字段执行 | +| `extracted_fields.child_room_items[]` | `structured_table` | `manual_review_only` | 每个 Child 的房型、房量和 PMS code 分列;禁止单行自由文本 | +| `extracted_fields.room_type_raw` | `readonly` | `never` | 原始房型文本不可被用户覆盖 | +| `extracted_fields.pms_room_type_code` | `select` | `manual_review_only` | 只能从当前有效 PMS 房型目录选择;禁止自由输入 code | + +Parent 原始房量汇总与 Child 房型/房量汇总可以因分配或房型重分配而不同。前端不得仅因数量不相等显示冲突、强制修改或自动创建人工复核。 + +### 4.3 业务路由和分类字段 + +以下字段是业务识别或 Adapter 路由判别字段,默认只读: + +| 字段 | 控件 | 说明 | +|---|---|---| +| `event_type` | `readonly` | 前端不允许 New / Update / Cancel 之间切换 | +| `booking_object_type` | `readonly` | FIT、Group Block、Allotment / Control Block 由业务结果确定 | +| `cancel_object_type` | `readonly` 或同路由受控 `select` | Cancel Booking 只接受 FIT/普通 Group;Allotment 整块取消属于 Cancel Allotment | +| `cancel_scope` | `readonly` 或同路由受控 `select` | 不使用自由文本;不得用前端选择跨到另一张任务卡 | +| `update_actions[]` / 旧 `update_subtypes[]` | `readonly` 标签 | 当前 Update 统一走 `update_booking_amendment`;动作由结果派生,不能用下拉改路由 | +| `voucher_subtype` | `readonly` | `lian_tai_credit_voucher` 与 `Payment Evidence` 分开,不跨卡选择 | +| `list_evidence_type` | `readonly` 或复核时受控 `select` | 不允许通过它自行创建新任务类型 | +| `trace_subtype` | `readonly` | 由 `trace_items[]` 重新判断 `extra_bed` / `general_request` | + +如果 `manual_review.missing_fields[]` 明确指向同一业务卡的分类字段,才可以显示受控选择;选择结果不得自动改变 `event_type` 或 Adapter 路由,除非项目另行冻结了跨路由 resolution 契约。 + +### 4.4 Update 和 Before/After + +`extracted_fields.before_after[]` 使用 `structured_table`: + +| 子字段 | 控件 | +|---|---| +| `field` | 当前支持字段的受控选择;不得任意输入路径 | +| `before` | 只读原值/证据值 | +| `after` | 按字段类型使用日期、数字、文字或目录选择 | +| `evidence` | 只读 | + +如果 Update 类型和 subtype 已确定,但 before/after 不可靠,保留原 Update 卡并进入同卡人工复核;不能把它改成 Fallback,也不能创建第二张卡。 + +### 4.5 Rate Code 和结算价 + +| 字段 | 控件 | 实施要求 | +|---|---|---| +| `extracted_fields.rate_code_result.rate_code` | 配置驱动 `select/lookup` | 不得硬编码旧 3.0 的 20+ code 全集;从当前 Rate 配置中心加载 | +| `rate_code_raw` | `readonly` | 原始价格/Rate marker 只读;路径未冻结时不要自行发明公开路径 | +| `settlement_price` | `number` | 单独编辑;校验币种、精度、非负和业务依赖 | +| `manual_settlement_price_required` | `readonly` | AI/规则派生,不让用户直接改成 true/false | +| `manual_price_reason_code` / `price_evidence` | `readonly` | 用于解释和审计,不是输入项 | + +如果 Rate Code 无法唯一确定,用户应选择当前有效目录值或填写允许的手工结算价;不能让用户输入一个未经配置的任意 Rate Code。 + +### 4.6 Fix Charge + +`fix_charge_items[]` 必须做成 `structured_table`,不能压成一行输入。 + +| 子字段 | 控件 | 当前边界 | +|---|---|---| +| `charge_type` | `select` | 使用当前契约枚举,如 `fixed_charge`;不要继续使用旧表 `additional_charge` | +| `pricing_mode` | `select` | 使用当前契约 `unit` 等已冻结值;旧 `unit_price` 需迁移 | +| `amount` / `quantity` / `total_amount` | `number` | 不把多个金额拼成文本;不得前端自行推导未经契约支持的总价 | +| `currency` | `select` 或配置值 | 以当前系统货币目录为准 | +| `unit_basis` | `select` 或 `text` | 若枚举未冻结,先只读或容错,不猜选项 | +| `raw_text` / `evidence_source` | `readonly` | 保留原文和证据 | +| `requires_followup_tool` / `followup_tool_name` | `readonly` | 前端不得自行调用工具 | +| `additional_operations[]` | `readonly` / pending badge | 只展示待处理意图,不执行实际写入 | + +### 4.7 Cancel + +| 字段 | 控件 | 实施要求 | +|---|---|---| +| `cancel_object_type` | 默认 `readonly` | Cancel Booking 只显示 FIT/Group;Allotment 使用独立 Cancel Allotment 卡 | +| `cancel_scope` | 默认 `readonly` | 不允许自由文本;部分配额维护当前不支持 | +| `requested_cancel_effective_date` | `date` | 只有 `missing_fields[]` 指定时启用 | +| `cancellation_note` | `textarea` | 允许覆盖时必须保留原始邮件证据并记录 field override | + +### 4.8 Voucher、Payment 和 Invoice + +| 字段 | 控件 | 实施要求 | +|---|---|---| +| `voucher_attachment` / `file_reference` | `readonly` 文件展示 | 不允许改写输入文件引用 | +| `voucher_subtype` | `readonly` | Credit Voucher 与 Payment Evidence 分开路由 | +| `department_routing[]` | `multiselect` 或只读 | 当前路径仍需以实际 schema 为准;默认 Finance/pending 只展示,不执行确认 | +| `post_confirmation_intent` | `readonly` | 只表示待确认后的意图,不表示已确认或已执行 | +| Invoice 专属字段 | 默认 `readonly` | 当前详细 item schema 尚未完全冻结,不要自造输入字段 | + +### 4.9 Trace、Parent 和关系字段 + +| 字段 | 控件 | 实施要求 | +|---|---|---| +| `extracted_fields.trace_text` | `readonly` | 保留完整原文,不允许直接编辑 | +| `trace_items[].category` | 固定枚举 `select` | 仅在明确 pointer/权限允许时修正 | +| `trace_items[].text_raw` | `readonly` | 原始补充信息不可改 | +| `trace_items[].service_date` | `date` | 只有需要且 pointer 明确时编辑 | +| `trace_items[].pax` | `number` | 缺失时可为空;不因缺失自动人工复核 | +| `trace_items[].notify_departments[]` | `multiselect` | 已知时可选 FO/HSK;不清楚时允许空数组 | +| `related_source_event_index` | `readonly` | 单一关系索引,只用于本次结果内部关联 | +| `related_source_event_indices[]` | `readonly` | Parent 或跨 Child Trace 的完整关系;禁止用户编辑索引 | +| `relationship_type` / `related_event_type` | `readonly` | 关系类型不选择任务卡 subtype | +| `requires_downstream_hard_validation` | `readonly` badge | 只展示系统硬校验要求 | + +完整 Parent split 的全部 Child 补充请求仍使用现有 Trace 卡,通过 `related_source_event_indices[]` 展示全部 Child;不能只绑定 E1,也不能新增一个 Trace 路由。 + +### 4.10 Rooming List 和 TA Recorder + +| 字段 | 控件 | 实施要求 | +|---|---|---| +| Rooming List target | `lookup` | 以通用 `case_keys` 为主;多个目标分开处理 | +| `list_evidence_type` | 默认只读 | 由证据识别,不允许任意切换卡型 | +| TA Recorder `group_code` | `lookup` / `text` | 只有缺失且 pointer 明确时编辑 | +| `ta_recorder_status` | `readonly` / `workflow_state` | 由信息系统后续状态更新,不是 AI 字段输入 | +| `source_rooming_list_task_id` | `readonly` 或隐藏 | 仅作 linked task 上下文 | + +### 4.11 Manual Review 和系统字段 + +| 字段 | 控件 | 实施要求 | +|---|---|---| +| `manual_review.reason_code` | `readonly` badge | 不能由用户随意改原因 | +| `manual_review.visible_reason` | `readonly` | 只读说明 | +| `manual_review.blocking_points` | `readonly` list | 只读说明 | +| `manual_review.conflicting_points` | `readonly` list | 只读说明 | +| `manual_review.suggested_human_actions` | `readonly` list | 可转成页面操作提示,但不能直接当字段输入 | +| `manual_review.evidence_to_check` | `readonly` list | 引导查看证据 | +| `manual_review.known_fields` | `readonly` | 不直接编辑对象文本 | +| `manual_review.missing_fields[]` | 内部 pointer | 驱动控件,不作为普通输入框展示 | +| `review_status` | `workflow_state` | `pending → resolved`;只能通过完成字段校验后 Resolve | +| `review_resolution` | 系统生成、只读审计 | 保存 field overrides、用户和时间,不允许页面直接覆盖历史记录 | + +### 4.12 只读字段 + +以下字段不得提供普通编辑控件: + +- `source_message`、`source_message_id`、邮件主题、发件人、接收时间。 +- `message_events[].event_type`、`event_role`、`current_or_history`、`source_event_index`。 +- `attachments[]`、`file_references[]`、`context_used`、QBD sheet/row/highlight/raw evidence。 +- `relevant_message_excerpt`、`text_raw`、`room_type_raw`、价格 raw marker。 +- `unhandled_current_intents[]` 的原文和可见说明。 +- S10、S99 和 `infrastructure_input_error` 的 route/status/error 字段。 + +--- + +## 5. 必须修正的旧 3.0 字段/枚举 + +请不要直接复制旧 Excel 的以下定义: + +1. `Cancel Booking.cancel_object_type` 不再包含 `allotment_control_block`;整块 Parent/Allotment 取消使用 `Cancel Allotment`。 +2. `Voucher Received.voucher_subtype` 不再包含 `bank_transfer_slip`;银行/现金/交易回执使用 `Payment Evidence`。 +3. `Update Booking.extracted_fields.update_subtypes[]` 的旧长下拉不再作为路由选择;当前 Update 统一为 `update_booking_amendment`,动作只展示为结果标签。 +4. `room_type` 不可作为自由修改后的唯一值;必须保留 raw,并用 PMS 目录选择 `pms_room_type_code`。 +5. Rate Code 不得硬编码旧表中的完整 code 列表。 +6. `Trace Text` 不得直接编辑原文。 +7. Fix Charge 的旧 `additional_charge / unit_price` 与当前 `fixed_charge / unit` 语义不一致,必须以当前契约和实际 schema 为准。 +8. `target_key`、`parent_source_event_index`、`post_confirm_action` 是旧字段/旧命名,需迁移到当前 `case_keys`、`related_source_event_index(es)` 和 `post_confirmation_intent`。 +9. 不得继续生成或路由 `Note`、`Allotment Maintenance`、`Cancel Booking + linked_parent_release_after_child_split`。 + +--- + +## 6. 必须通过的验收用例 + +至少增加或复用以下测试: + +| 编号 | 场景 | 必须验证 | +|---|---|---| +| UI-01 | generic `SUITE` 房型 | raw 只读;PMS code 使用当前目录下拉;未选时保留 manual review | +| UI-02 | 入住/离店日期修改 | 日期控件有效;`nights` 自动重算;非法日期阻止 Resolve | +| UI-03 | Rate Code | 不读取旧硬编码全集;使用配置目录;手工价独立数字输入 | +| UI-04 | Fix Charge | `fixed_charge/unit` 枚举正确;金额/数量为数字;raw/evidence 只读 | +| UI-05 | Cancel 路由 | `Cancel Booking` 不出现 Allotment;Allotment 使用 Cancel Allotment | +| UI-06 | Voucher/Payment | bank slip 不可从 Voucher 卡下拉选择;Payment Evidence 单独显示 | +| UI-07 | Update | 不允许用 `update_subtypes` 下拉切换业务卡型;before/after 结构化展示 | +| UI-08 | Parent key | Parent `group_code=block_code`;用户不能单独改 block_code | +| UI-09 | Parent split 房量差异 | Parent/Child 数量不等不自动显示冲突或人工复核 | +| UI-10 | 全 Child Trace | 使用现有 Trace 卡;展示完整 `related_source_event_indices[]`;不压缩到 E1 | +| UI-11 | raw evidence | 用户编辑后 raw 原文、附件引用和原 Agent payload 不变 | +| UI-12 | 同卡解阻 | 只创建一张卡;`pending → resolved → Preflight`;不创建第二张 normal task | +| UI-13 | 未知字段路径 | pointer 无法映射时 fail closed,不能临时创建任意输入控件 | +| UI-14 | S10/S99/基础设施错误 | 全部只读,不显示业务编辑控件 | + +--- + +## 7. 交付要求 + +完成后请报告: + +1. 实际修改的 Adapter/Frontend 文件。 +2. 新增或更新的字段注册表及控件类型。 +3. `missing_fields[] → control → field_overrides[]` 的实现位置。 +4. 日期、房型、Rate Code、Fix Charge、Parent/Trace 关系校验位置。 +5. 测试命令和测试结果。 +6. 是否保持公开 JSON、40 路由和 MCP mapping 不变。 +7. 未实现字段、路径不确定项和需要产品/业务确认的事项。 + +如果发现当前字段路径、item schema 或 Rate/Fix Charge 枚举仍未冻结,请保留只读/容错策略并报告,不要在前端自行发明新的公开字段。 + +--- + +## 最终边界 + +本任务的正确结果是: + +```text +信息系统可以正确展示当前业务结果 +信息系统只允许修改被 manual_review pointer 指定的业务字段 +用户修改可审计、可校验、可回退到同一业务卡 +原始 Agent 结果和证据不被覆盖 +不新增业务路由、不改变 Prompt/Skill/MCP 契约 +```