接入契约 v0.63 的 documents[].reason:把「读不到的原因」原样送到附件预览弹层

后端 902b6ee / 契约 v0.63 给 historyMessage.documents[] 的元素加了可选字段 reason:
只在 status == "unreadable" 时下发(Go omitempty,extracted 时整个键缺席),内容与后端
注入模型清单里的那 10 条是同一份字符串,最长的一条 40+ 字。

改了什么
- DocumentRefDto 加 reason: String? = null。documents[] 在 WS 握手 history、
  message_persisted、GET /messages 三处共用这一个 DTO,所以一处解析三条路全覆盖。
  KDoc 写明三条口径:可选、只在 unreadable 时下发、是后端原话所以客户端原样显示,
  且绝不做整句相等判断(需要分支只允许用稳定子串)。
- DocumentChipModel 加 reason,由 DocumentChips.of(ref) 取 ref.reason 去掉首尾空白后
  非空才留,否则归一成 null——一句空话会顶掉弹层里的通用说明,比没有更坏。
  芯片外观一个字没改:detail / statusLabel 保持现状(unreadable 仍显示「读不到」)。
  理由:那句话太长,塞进一行芯片只会被 Ellipsis 切掉半句,比不显示更糟。
- DocumentPreviewGate.plan 增加 reason 参数:非空白时直接作为正文区那句说明原样返回
  (NotText(reason)),压过本端按 mime / 扩展名的裁决;DocumentTarget.previewPlan 顺着
  chips.reason 把它带进裁决。预览弹层本身一行代码都没改——它渲染的就是 plan.message
  那句话,所以 reason 从 DTO 到用户看见的那句话整条链路上没有任何改写或截断。
- 配套单测 17 条。

为什么这么改
- 服务端那句原因已经断言「无法抽取正文」,它比本端按类型编的通用说明(只知道类型、
  不知道到底为什么)准确;替它改写、归纳或截断就是丢掉服务端唯一一次说清原因的机会。
- 它也不该与正文并存:服务端说正文抽不出来,本端再把字节当 UTF-8 渲染,产出的正是
  正文闸要消灭的「看着像文件坏了」。
- reason 缺席时(契约 v0.63 之前的历史消息)产出与改之前逐字相同:兜底那句话、判据、
  文案全都不动。

测试(新增 17 条)
- DocumentDtoTest +5:unreadable 带 reason 逐字解出;extracted 元素没有该键 -> null;
  v0.62 风格的缺键载荷 -> null(不解码失败、不替服务端编一句话);清单外的未知分类原样
  保留;显式 null -> null。
- DocumentChipTest +5:reason 带进模型;句中标点与换行原样、只去首尾空白;空白串 ->
  null;绝不进芯片副行(detail 逐字不变);待发附件恒为 null。
- DocumentPreviewTest +7:reason 逐字成为正文区那句话(含未知分类,不丢不改不换兜底);
  只 trim 一次再原样显示;缺席时与改前逐字相同(拿 refusalMessage 对比);
  空白 reason 不改任何行为;text mime + reason 不再假装有正文。
- assembleDebug 通过;testDebugUnitTest 848 个测试 0 失败 0 错误 0 跳过。

已知代价(已写进 KDoc,供人拍板)
服务端判为读不到、但字节其实下得动的附件(例如超限的纯文本)会失去 App 内预览,
「用其它应用打开」与「保存到下载目录」两条出口照旧完整可用——与「未知 mime 判为非文本」
是同一笔不对称账。
This commit is contained in:
AskaEth committed 2026-10-04 21:57:14 +08:00
1 parent a84d473e2f
commit e5fa1b871f
7 files changed
+269 -8

No files matched your search

@@ -25,6 +25,8 @@ import kotlinx.serialization.Serializable
* 3. 下行/历史元素只回**引用与统计,绝不回正文**(吸取 `G3`「base64 原样落库、每页历史重传」
* 的教训)。`status:"unreadable"` 才是「读不到这个文件」的可观测信号,**不是** `truncated`
* (`truncated` 只说明注入正文被预算截断,正文照样进得了上下文)。
* **契约 v0.63(后端 `902b6ee`)在此之上加了可选 [DocumentRefDto.reason]**:读不到时随
* `status:"unreadable"` 一起下发的**服务端原话**,客户端原样显示(详见该字段的 KDoc)。
*
* ## 客户端必须两种都吃的一件事
* 「0 个附件」这一形状在后端**尚未定死**(回执五原文:期望「不出现或空数组」,S3.5 回报里定死)。
@@ -58,8 +60,8 @@ data class DocumentUploadResponse(
* 下行/历史则带上全部统计字段。
*
* 字段可出现性(后端 `history.DocumentRef` 的 `omitempty` 口径,逐字段核对过):
* `name` / `mime` / `size` **恒在**;`chars` / `truncated` / `status` 为 Go 的 `omitempty`
* —— 零值(0 / false / "")时**整键省略**,所以取值一律给默认值,解码不会因缺键失败。
* `name` / `mime` / `size` **恒在**;`chars` / `truncated` / `status` / `reason` 为 Go 的 `omitempty`
* —— 零值(0 / false / "" / nil)时**整键省略**,所以取值一律给默认值,解码不会因缺键失败。
*/
@Serializable
data class DocumentRefDto(
@@ -80,4 +82,18 @@ data class DocumentRefDto(
* 未知值**原样保留**(不丢弃、不报错、不占位符),由客户端决定如何显示。
*/
val status: String = "",
/**
* **读不到的原因**(契约 v0.63 新增,后端 `902b6ee`):`status == "unreadable"` 时下发的
* 那句**服务端原话**;`status == "extracted"` 时**整键缺席**,因此是可选字段。
*
* 两句纪律:
* 1. **原样显示**。[reason] 与后端**注入模型清单里的那 10 条是同一份字符串**(逐字相同,
* 最长的一条 40+ 字),客户端**不替服务端改写、不截断、不归纳**成自己的话——
* 本端能编的只有像 [top.yeij.cyrene.domain.chat.DocumentPreviewGate.refusalMessage]
* 那样「按类型泛泛说一句」,比这里的原因粗得多;
* 2. **绝不做整句相等判断**。分类若真的需要分支,只允许用**稳定子串**(如「需要 OCR」)——
* 整句比对会在后端改一个字时静默失效,且失败的样子是「显示成兜底那句话」,
* 看起来完全正常。
*/
val reason: String? = null,
)