feat(api): documents[].reason + messages 对不存在会话回 404(安卓回执九两条;契约 v0.63)
- ① documents[] 元素增可选 reason(omitempty):装配期在给出 status 的**同一处**填充,取的**就是注入模型清单里那份原因文案**(同一字符串两处用,服务端不为客户端另编一句),只在 unreadable 时下发;随 S3.5 的回合后回写落进 document 列 ⇒ 历史接口 / 握手回放都有。文案只含原因、不含落盘路径/存储名以外内容/凭据(有独特目录名反证断言)。
- ② GET /sessions/{id}/messages:不存在/已删除 ⇒ 404 session_not_found;**存在但无消息仍 200 {data:[]}**(空会话绝不能被答成 404);参数形状校验仍先于 404;存在性判定复用既有窄视图(history.Store.SessionPersona,未知 id 报 ErrSessionNotFound),**未新增 SQL/存储方法**;判不出来(未接该视图)保持旧语义(判不出 ≠ 不存在),判定本身失败回 500 不静默。
- 契约 v0.63(三档:reason 纯增量 / messages 404 行为变更 / 无破坏性):api-client §1 行 6 + §1.2④ 的 10 条逐字文案 + §4.4 错误码 + changelog + banner;api-reference §1.2 行 6 + §3.4 + §3.8 DTO + §8.1 错误码表。
- 主会话独立核对的副作用(隔离会话 sess-* 现在也 404):查活库 chat_sessions 里**根本没有 sess-* 行**(只有 web 与 web:<uuid>),sess-backthink 消息数 0,前端唯一提到 sess-backthink 的是 graph_update 的内存桶测试 ⇒ **没有客户端按 id 读隔离会话的 messages**;且这与 POST /sessions/{id}/read(v0.37 起即 404)一致 ⇒ 接受不加 sess- 例外,并在回执里问安卓确认。
- 主会话独立验收:隔离 worktree(S9 的 13 文件)build/vet 绿、整树连跑 2 次 59 ok / 0 FAIL、reason/404 用例 8 PASS、双平台交叉编译绿;**变异亲自复现**(在调用点短路 404 守卫 ⇒ UnknownSessionIs404 与 DeletedSessionIs404 双红:status = 200, want 404)。
- 如实登记:验收过程中出现过**一次不可复现的子集 FAIL**(restore 之后那次 Session|Reason 子集跑;未记到用例名)—— 随后同一子集、那三条用例 ×5、以及整树 ×2 全绿,判为瞬时;已记入账本,后续留意。
This commit is contained in:
1 parent
c1fa1e9b9a
commit
902b6eecfd
13 files changed
+779
-21
No files matched your search
@@ -100,7 +100,7 @@ const (
|
||||
// 形状与 ImageResolver 同族:注入式依赖、装配期按消息调用一次、失败时回合
|
||||
// 继续。差别只在返回值 —— 图像解析就地改写一条消息,而文档解析把「结论」与
|
||||
// 「正文」分开交回:清单行、截断标记与「清单恒在」由循环统一渲染(解析器缺席
|
||||
// 或失败时也必须成立),status/chars/truncated 则经 Result.Documents 回给
|
||||
// 或失败时也必须成立),status/chars/truncated/reason 则经 Result.Documents 回给
|
||||
// 客户端(§4.4、§6.1)。
|
||||
//
|
||||
// 两份映射都以**服务端存储名**为键,值分别是该文档的结论(形状即
|
||||
@@ -108,6 +108,10 @@ const (
|
||||
// 诚实的原因文案)。缺少条目的文档由循环降级为「仅清单」。用这些中立类型,
|
||||
// 摄取层不必依赖 loop 包,循环也不必依赖摄取层 —— 与 vision.MessageResolver
|
||||
// 满足 ImageResolver 的方式相同。
|
||||
//
|
||||
// 契约(v0.63):status == unreadable 时,**正文就是原因** —— 循环把它原样作为
|
||||
// `documents[].reason` 回给客户端。因此它必须是「人能读懂、不含路径与内部细节」
|
||||
// 的短句(design/24 §4.4 已是这个口径);原始错误只进日志。
|
||||
type DocumentResolver interface {
|
||||
// Resolve 解析 msg 里的文档部件。error 表示整条消息都解析不了(例如回合
|
||||
// 已被取消);**单份文档**的失败不是 error,而是 unreadable 结论。
|
||||
@@ -209,6 +213,7 @@ func documentResolution(part model.Content, refs map[string]history.DocumentRef,
|
||||
if ref.Status == history.DocumentStatusUnreadable && body == "" {
|
||||
body = documentUnreadableBody
|
||||
}
|
||||
ref.Reason = documentReason(ref.Status, body)
|
||||
if ref.Status != history.DocumentStatusExtracted {
|
||||
return ref, body
|
||||
}
|
||||
@@ -229,6 +234,23 @@ func documentStatusKnown(status string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// documentReason 给出随结论回给客户端的**原因文案**(契约 v0.63 的
|
||||
// `documents[].reason`):只在「读不到」时给,其余状态一律为空(该键因此从 DTO
|
||||
// 里省略)。
|
||||
//
|
||||
// 它就是注入给模型的那份原因文案 —— 装配期把 unreadable 的正文当作原因用
|
||||
// (解析器读不到时给的就是诚实原因,见 DocumentResolver 的契约;解析器整条报错
|
||||
// 时是 documentUnreadableBody 这个兜底)。**同一个字符串两处用**是刻意的:客户端
|
||||
// 看到的理由与模型看到的理由因此不可能分叉,服务端也从不为客户端另编一句。
|
||||
//
|
||||
// 只有文案:不含路径、不含凭据、不含存储名以外的标识 —— 原始错误只进日志。
|
||||
func documentReason(status, body string) string {
|
||||
if status != history.DocumentStatusUnreadable {
|
||||
return ""
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// documentBlock 渲染一份文档的注入块:清单行**恒在**(名、MIME、大小、结论),
|
||||
// 其后是可注入的正文,或读不到时的诚实原因(design/24 §4.2-3、§4.4)。
|
||||
func documentBlock(part model.Content, ref history.DocumentRef, body string) string {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// 本文件钉住契约 v0.63 的 `documents[].reason`(客户端登记:`status:"unreadable"`
|
||||
// 只有状态没有原因,客户端只能显示通用句、不愿自己编服务端的理由):
|
||||
//
|
||||
// - **只在读不到时**下发(`extracted` / `listed` 时该键从 DTO 里消失);
|
||||
// - 文案**逐字**等于注入给模型的那份原因 —— 两处同源于一个字符串,服务端因此
|
||||
// 不为客户端另编一句,两个出口也不可能分叉;
|
||||
// - 解析器整条消息失败时也有诚实的兜底理由(绝不「读不到但没有理由」)。
|
||||
//
|
||||
// 真实的「扫描件 PDF ⇒ 含 OCR 的原因」链路在 `internal/runtime/documents` 的
|
||||
// `TestResolver_EndToEndWithLoop_UnreadableReasonIsVerbatim` 里跑(那里有 PDF 夹具)。
|
||||
|
||||
// docC 是第三个合法的 S1 存储名形态(32 位十六进制 + 扩展名),供本文件使用。
|
||||
const docC = "00112233445566778899aabbccddeeff.csv"
|
||||
|
||||
// TestResolveDocuments_ReasonOnlyWhenUnreadable 钉住 reason 的三个出口:能读到与
|
||||
// 只给清单时为空、读不到时逐字等于注入模型的那份原因。
|
||||
func TestResolveDocuments_ReasonOnlyWhenUnreadable(t *testing.T) {
|
||||
t.Parallel()
|
||||
res := &fakeDocumentResolver{
|
||||
refs: map[string]history.DocumentRef{
|
||||
docA: {Status: history.DocumentStatusExtracted},
|
||||
docB: {Status: history.DocumentStatusUnreadable},
|
||||
docC: {Status: history.DocumentStatusListed},
|
||||
},
|
||||
bodies: map[string]string{
|
||||
docA: "抽到的正文",
|
||||
docB: "无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)",
|
||||
docC: "不该被注入的正文",
|
||||
},
|
||||
}
|
||||
l := New(Options{DocumentResolver: res})
|
||||
in := []model.Message{
|
||||
documentMessage(docA, "text/plain", 10),
|
||||
documentMessage(docB, "application/pdf", 20),
|
||||
documentMessage(docC, "text/plain", 30),
|
||||
}
|
||||
out, refs := l.resolveDocuments(context.Background(), in)
|
||||
byName := map[string]history.DocumentRef{}
|
||||
for _, ref := range refs {
|
||||
byName[ref.Name] = ref
|
||||
}
|
||||
if got := byName[docA].Reason; got != "" {
|
||||
t.Fatalf("extracted 的 reason = %q, want 空(该键必须缺席)", got)
|
||||
}
|
||||
if got := byName[docC].Reason; got != "" {
|
||||
t.Fatalf("listed 的 reason = %q, want 空", got)
|
||||
}
|
||||
want := res.bodies[docB]
|
||||
if got := byName[docB].Reason; got != want {
|
||||
t.Fatalf("unreadable 的 reason = %q, want %q(必须与注入模型的原因逐字一致)", got, want)
|
||||
}
|
||||
// 同一份文案确实进了模型可见上下文 —— 两处同源因此不可能分叉。
|
||||
if !strings.Contains(messageTextOf(out[1]), want) {
|
||||
t.Fatalf("注入块 = %q, want 含同一份原因文案", messageTextOf(out[1]))
|
||||
}
|
||||
// DTO 形状:unreadable 带 reason 键,extracted 不带。
|
||||
raw, err := json.Marshal(byName[docB])
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"reason":`) {
|
||||
t.Fatalf("unreadable 的 DTO = %s, want 带 reason 键", raw)
|
||||
}
|
||||
extractedRaw, err := json.Marshal(byName[docA])
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if strings.Contains(string(extractedRaw), `"reason"`) {
|
||||
t.Fatalf("extracted 的 DTO = %s, want 不带 reason 键", extractedRaw)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveDocuments_ResolverErrorCarriesHonestReason 钉住兜底路径也带原因:解析器
|
||||
// 整条消息失败时,客户端拿到的是同一句诚实兜底(`documentUnreadableBody`)——
|
||||
// 「读不到但没有理由」正是本字段要消灭的状态。
|
||||
func TestResolveDocuments_ResolverErrorCarriesHonestReason(t *testing.T) {
|
||||
t.Parallel()
|
||||
l := New(Options{DocumentResolver: &fakeDocumentResolver{err: errDocResolve}})
|
||||
_, refs := l.resolveDocuments(context.Background(), []model.Message{documentMessage(docA, "text/plain", 10)})
|
||||
if len(refs) != 1 {
|
||||
t.Fatalf("结论 = %+v, want 一条", refs)
|
||||
}
|
||||
if refs[0].Reason != documentUnreadableBody {
|
||||
t.Fatalf("reason = %q, want %q(诚实兜底,绝不空着)", refs[0].Reason, documentUnreadableBody)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user