merge: W23 —— 接入契约 v0.63 的 documents[].reason(只在下发时原样显示,不做整句判断)
This commit is contained in:
commit
2d382b11cd
7 files changed
+269
-8
No files matched your search
@@ -22,6 +22,8 @@ import top.yeij.cyrene.domain.chat.DocumentStatus
|
||||
* `5b2b385`(S3.5 文档多值化:`history.go` 的 `historyMessage.Documents` 带 `omitempty`、
|
||||
* `history.DocumentRef` 的 `omitempty` 口径、`history_document_test.go` 钉住的元素字段集
|
||||
* `chars,mime,name,size,status,truncated` 与它用的真实取值)。
|
||||
* **契约 v0.63(后端 `902b6ee`)在此之上加了 `reason`**(只在 `status == "unreadable"` 时下发的
|
||||
* 服务端原话):那几条用例同样按「缺键 → `null`、有值 → 逐字原样」钉住,**不做整句相等判断**。
|
||||
* **没有 v0.59 的真实抓包(形状按 api-client §1.2 与后端实现逐条核对)**:这里的 JSON 是按上述实现形状逐字节手写的(唯一确认过的真实取值
|
||||
* 来自后端自己的测试夹具)。
|
||||
*
|
||||
@@ -172,6 +174,69 @@ class DocumentDtoTest {
|
||||
assertEquals("", doc.status)
|
||||
}
|
||||
|
||||
// —— 契约 v0.63(后端 `902b6ee`):`documents[].reason` ——
|
||||
|
||||
/**
|
||||
* 后端注入模型清单里那 10 条中的一条,**真实串就是这么长**:逐字照抄,不做任何简化。
|
||||
* 这里的用例一律拿它整串比对,就是为了钉住「客户端不改写、不截断」。
|
||||
*/
|
||||
private val ocrReason = "无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)"
|
||||
|
||||
@Test
|
||||
fun `an unreadable reference carries the server reason verbatim`() {
|
||||
val doc = message(
|
||||
withDocuments(
|
||||
"""[{"name":"$name","mime":"application/pdf","size":12345,"status":"unreadable",""" +
|
||||
""""reason":"$ocrReason"}]""",
|
||||
),
|
||||
).documents.single()
|
||||
|
||||
assertEquals(DocumentStatus.UNREADABLE, doc.status)
|
||||
assertEquals(ocrReason, doc.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an extracted reference has no reason key at all`() {
|
||||
// 服务端只在 status == "unreadable" 时填 `reason`(Go omitempty):extracted 的元素里
|
||||
// 整个键不存在 ⇒ 解出来必须是 `null`,**不是空串**——空串会顶掉弹层里那句通用说明。
|
||||
val doc = message(
|
||||
withDocuments(
|
||||
"""[{"name":"$name","mime":"text/plain","size":7,"chars":7,"status":"extracted"}]""",
|
||||
),
|
||||
).documents.single()
|
||||
|
||||
assertNull(doc.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a v0_62 style payload without the reason key still decodes`() {
|
||||
// 契约 v0.63 之前的历史消息里没有这个键:解码不许失败,也不许替服务端编一句话。
|
||||
val doc = message(withDocuments("""[{"name":"$name","status":"unreadable"}]""")).documents.single()
|
||||
|
||||
assertNull(doc.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a reason outside the ten known ones is preserved verbatim`() {
|
||||
// 分类可以开放,客户端**不做整句相等判断**:没见过的那句照样原样留着。
|
||||
val reason = "无法抽取正文:这条分类客户端从没见过"
|
||||
|
||||
val doc = message(
|
||||
withDocuments("""[{"name":"$name","status":"unreadable","reason":"$reason"}]"""),
|
||||
).documents.single()
|
||||
|
||||
assertEquals(reason, doc.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an explicit null reason decodes as null`() {
|
||||
val doc = message(
|
||||
withDocuments("""[{"name":"$name","status":"unreadable","reason":null}]"""),
|
||||
).documents.single()
|
||||
|
||||
assertNull(doc.reason)
|
||||
}
|
||||
|
||||
// —— chat 请求:带附件与不带附件 ——
|
||||
|
||||
@Test
|
||||
|
||||
@@ -14,7 +14,10 @@ import top.yeij.cyrene.data.api.dto.DocumentRefDto
|
||||
* 1. **状态与截断不许互相顶替**——`unreadable` 才是「读不到」,`truncated` 只说明注入被预算
|
||||
* 截断(正文照样进了上下文),两句文案必须分开;
|
||||
* 2. **未知 `status` 原样显示**(开放集合:不丢弃、不报错、不占位符);
|
||||
* 3. 服务端**没报**的字段(`chars` 缺席、体积为 0/未知)不要编出一个数字来。
|
||||
* 3. 服务端**没报**的字段(`chars` 缺席、体积为 0/未知)不要编出一个数字来;
|
||||
* 4. 服务端下发的「读不到的原因」(契约 v0.63 的 `documents[].reason`)**原样**带进模型
|
||||
* (只去首尾空白;空白串归一成 `null`,因为一句空话会顶掉弹层里的通用说明),
|
||||
* 且**绝不进芯片副行**——40+ 字在那里只会被切掉半句。
|
||||
*/
|
||||
class DocumentChipTest {
|
||||
|
||||
@@ -25,6 +28,7 @@ class DocumentChipTest {
|
||||
chars: Int? = 8_000,
|
||||
truncated: Boolean = false,
|
||||
status: String = DocumentStatus.EXTRACTED,
|
||||
reason: String? = null,
|
||||
) = DocumentRefDto(
|
||||
name = name,
|
||||
mime = mime,
|
||||
@@ -32,6 +36,7 @@ class DocumentChipTest {
|
||||
chars = chars,
|
||||
truncated = truncated,
|
||||
status = status,
|
||||
reason = reason,
|
||||
)
|
||||
|
||||
// —— 类型 ——
|
||||
@@ -122,6 +127,56 @@ class DocumentChipTest {
|
||||
assertEquals("MD · 12.1 KB", chip.detail)
|
||||
}
|
||||
|
||||
// —— 服务端下发的「读不到的原因」(契约 v0.63)——
|
||||
|
||||
@Test
|
||||
fun `the server reason rides along in the model`() {
|
||||
val reason = "无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)"
|
||||
|
||||
assertEquals(reason, DocumentChips.of(ref(status = DocumentStatus.UNREADABLE, reason = reason)).reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the reason keeps its own punctuation and newlines`() {
|
||||
// 「原样」就是原样:只去首尾空白,句中标点与换行一个字都不动(这条串不是给本端归纳的)。
|
||||
val reason = "无法抽取正文:第一行(括注);第二行\n 带全角空格的细节"
|
||||
|
||||
assertEquals(
|
||||
reason,
|
||||
DocumentChips.of(ref(status = DocumentStatus.UNREADABLE, reason = "\n $reason \n")).reason,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank reason is nothing, not an empty sentence`() {
|
||||
// 空串会顶掉弹层里那句通用说明,用户看到的正文区就成了空的——比没有原因更坏。
|
||||
assertNull(DocumentChips.of(ref(reason = null)).reason)
|
||||
assertNull(DocumentChips.of(ref(reason = "")).reason)
|
||||
assertNull(DocumentChips.of(ref(reason = " ")).reason)
|
||||
assertNull(DocumentChips.of(ref(reason = "\n\t\n")).reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the reason never leaks into the chip line`() {
|
||||
// 40+ 字塞进芯片那一行只会被 Ellipsis 切掉半句,比不显示更糟:`detail` 逐字不变。
|
||||
val chip = DocumentChips.of(
|
||||
ref(
|
||||
chars = null,
|
||||
status = DocumentStatus.UNREADABLE,
|
||||
reason = "无法抽取正文:PDF 已加密(当前不支持)",
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("MD · 12.1 KB · 读不到", chip.detail)
|
||||
assertTrue(chip.unreadable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a draft attachment has no reason at all`() {
|
||||
// 待发附件还没有任何服务端结论,也就没有「读不到的原因」可言。
|
||||
assertNull(DocumentChips.of(DraftDocument(uri = "content://doc/1", displayName = "报告.md")).reason)
|
||||
}
|
||||
|
||||
// —— 副行拼接 ——
|
||||
|
||||
@Test
|
||||
|
||||
@@ -21,14 +21,19 @@ import top.yeij.cyrene.data.repository.LocalDocumentUnavailableException
|
||||
* 3. 没有任何 App 能开时**如实告知**并指路保存,**不静默失败**;
|
||||
* 4. 来源判定只有一处([DocumentTarget.of]),服务端来源**只能用存储名**当文件名;
|
||||
* 5. App 内**正文**只预览文本类(v0.61 的 `.pdf` 因此不会被当 UTF-8 渲染成一屏乱码),
|
||||
* 且非文本那句人话**不许读起来像失败**。
|
||||
* 且非文本那句人话**不许读起来像失败**;
|
||||
* 6. 服务端说「读不到」时下发的**原因原话**(契约 v0.63 的 `documents[].reason`)优先于本端
|
||||
* 按类型编的那句,且**逐字**显示(没见过的原因也照原样,不做整句相等判断);它缺席时
|
||||
* 产出与加入该字段之前逐字相同。
|
||||
*/
|
||||
class DocumentPreviewTest {
|
||||
|
||||
private fun ref(
|
||||
name: String = "0123456789abcdef0123456789abcdef.md",
|
||||
mime: String = "text/plain",
|
||||
) = DocumentRefDto(name = name, mime = mime)
|
||||
status: String = "",
|
||||
reason: String? = null,
|
||||
) = DocumentRefDto(name = name, mime = mime, status = status, reason = reason)
|
||||
|
||||
private fun filler(size: Int) = ByteArray(size) { 'a'.code.toByte() }
|
||||
|
||||
@@ -293,6 +298,84 @@ class DocumentPreviewTest {
|
||||
assertTrue(messages.toString(), messages.none { it.contains("失败") || it.contains("空文件") || it.contains("是空的") })
|
||||
}
|
||||
|
||||
// —— 服务端下发的「读不到的原因」(契约 v0.63:`documents[].reason`)——
|
||||
|
||||
/**
|
||||
* 后端那 10 条里的一条,**逐字照抄**(真实串就是这么长)。
|
||||
* 用整串相等断言是为了钉住「不改写、不截断、不归纳」——**不是**拿它当实现里的判据。
|
||||
*/
|
||||
private val ocrReason = "无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)"
|
||||
|
||||
@Test
|
||||
fun `the server reason becomes the sentence in the body area, verbatim`() {
|
||||
val plan = DocumentTarget.of(
|
||||
ref(
|
||||
name = "0123456789abcdef0123456789abcdef.pdf",
|
||||
mime = "application/pdf",
|
||||
status = DocumentStatus.UNREADABLE,
|
||||
reason = ocrReason,
|
||||
),
|
||||
).previewPlan()
|
||||
|
||||
assertEquals(DocumentPreviewPlan.NotText(ocrReason), plan)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a reason outside the ten known ones is shown as it came`() {
|
||||
// 客户端**不做整句相等判断**:没见过的原因照样原样进正文区,不丢、不改、不换兜底。
|
||||
val reason = "无法抽取正文:这条分类客户端从没见过(契约同版的第 11 种)"
|
||||
|
||||
val plan = DocumentTarget.of(ref(status = DocumentStatus.UNREADABLE, reason = reason)).previewPlan()
|
||||
|
||||
assertEquals(DocumentPreviewPlan.NotText(reason), plan)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the reason is trimmed once and then shown as it is`() {
|
||||
// 首尾空白由 DocumentChips 去掉(免得弹层里那句话顶着一截空行),句中标点原样。
|
||||
val reason = "无法抽取正文:PDF 损坏或无法解析;请重传(原文如此)"
|
||||
|
||||
val plan = DocumentTarget.of(
|
||||
ref(status = DocumentStatus.UNREADABLE, reason = " $reason "),
|
||||
).previewPlan()
|
||||
|
||||
assertEquals(DocumentPreviewPlan.NotText(reason), plan)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `without a reason the body area keeps the very same sentence as before`() {
|
||||
// 契约 v0.63 之前的历史消息不发 reason:这条路的产出必须与加入该字段之前逐字相同。
|
||||
val plan = DocumentPreviewGate.plan("application/pdf", "0123456789abcdef0123456789abcdef.pdf")
|
||||
|
||||
assertEquals(DocumentPreviewPlan.NotText(DocumentPreviewGate.refusalMessage("PDF")), plan)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank reason changes nothing at all`() {
|
||||
val blank = DocumentPreviewGate.plan("application/pdf", "a.pdf", " ")
|
||||
|
||||
assertEquals(DocumentPreviewGate.plan("application/pdf", "a.pdf"), blank)
|
||||
// 尤为重要的是别把它当成一句话显示出来:那会得到一块什么都没有的正文区。
|
||||
assertTrue((blank as DocumentPreviewPlan.NotText).message.isNotBlank())
|
||||
assertEquals(DocumentPreviewPlan.Text, DocumentPreviewGate.plan("text/plain", "a.md", "\n"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a text mime with a reason no longer pretends to be a body`() {
|
||||
// 服务端已经说了「无法抽取正文」,本端再把字节当 UTF-8 渲染就是同一类「看着像文件坏了」。
|
||||
val reason = "无法抽取正文:读取或解析失败"
|
||||
|
||||
assertEquals(DocumentPreviewPlan.NotText(reason), DocumentPreviewGate.plan("text/plain", "a.md", reason))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the reason takes precedence over the extension fallback used by drafts`() {
|
||||
val reason = "无法抽取正文:本期不解析 Office 文档"
|
||||
|
||||
assertEquals(DocumentPreviewPlan.NotText(reason), DocumentPreviewGate.plan("", "报告.pdf", reason))
|
||||
assertEquals(DocumentPreviewPlan.NotText(reason), DocumentPreviewGate.plan("", "笔记.md", reason))
|
||||
}
|
||||
|
||||
// —— 没有 App 能开 ——
|
||||
|
||||
@Test
|
||||
|
||||
Reference in new issue
Block a user