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
@@ -0,0 +1,108 @@
|
||||
package documents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// 本文件是契约 v0.63 的 `documents[].reason` 在**摄取层侧**的端到端回归(客户端
|
||||
// 登记:`status:"unreadable"` 只有状态没有原因,客户端只能显示通用句,不愿自己
|
||||
// 编服务端的理由)。它与 sidecar_test.go 的 `TestResolver_EndToEndWithLoop` 同族:
|
||||
// 真实文件 → 真实 sidecar → 真实 loop 装配期,只是这里断言的是**回给客户端的原因**
|
||||
// 与**注入模型的原因**是同一个字符串,以及「读不到才给、能读到就没有」这条口径。
|
||||
|
||||
// TestResolver_EndToEndWithLoop_UnreadableReasonIsVerbatim 钉住契约 v0.63 的 ①:
|
||||
// **扫描件 PDF(无文本层)** 经真实链路(真文件 → 真 sidecar → 真 loop 装配期)
|
||||
// 后,`Result.Documents` 上带回**逐字**等于注入模型那份的原因文案(含「OCR」),
|
||||
// 而**文本层 PDF / 纯文本**(extracted)一个 reason 都没有(该键从 DTO 里省略)。
|
||||
//
|
||||
// 「逐字一致」是本用例的核心断言:客户端展示的理由与模型看到的理由必须是同一个
|
||||
// 字符串,服务端不为客户端另编一句。
|
||||
func TestResolver_EndToEndWithLoop_UnreadableReasonIsVerbatim(t *testing.T) {
|
||||
t.Parallel()
|
||||
layout := newTestLayout(t)
|
||||
body := "季度报告:营收增长 12%。\n"
|
||||
textName := layout.writeUpload(t, txtName, []byte(body))
|
||||
scanName := layout.writeUpload(t, pdfName, buildPDF(t, pdfFixture{Contents: []string{imageContent}}))
|
||||
r := NewResolver(Config{DataDir: layout.dataDir, UploadsDir: layout.uploads})
|
||||
|
||||
ad := &recordingAdapter{}
|
||||
ag, err := agent.New("你是助手。", nil, nil, agent.WithModel(ad, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("agent.New: %v", err)
|
||||
}
|
||||
l := loop.New(loop.Options{Agent: ag, DocumentResolver: r, MaxRounds: 1})
|
||||
msg := model.Message{Role: model.RoleUser, Content: []model.Content{
|
||||
{Type: model.ContentTypeText, Text: "这两份都看看"},
|
||||
{Type: model.ContentTypeDocument, DocumentName: textName, DocumentMIME: "text/plain", DocumentSize: int64(len(body))},
|
||||
{Type: model.ContentTypeDocument, DocumentName: scanName, DocumentMIME: PDFMIME, DocumentSize: 2048},
|
||||
}}
|
||||
res, err := l.Run(context.Background(), "sess-doc-reason", []model.Message{msg})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
byName := map[string]history.DocumentRef{}
|
||||
for _, ref := range res.Documents {
|
||||
byName[ref.Name] = ref
|
||||
}
|
||||
if got := byName[textName]; got.Status != history.DocumentStatusExtracted || got.Reason != "" {
|
||||
t.Fatalf("纯文本结论 = %+v, want extracted 且无 reason", got)
|
||||
}
|
||||
scan := byName[scanName]
|
||||
if scan.Status != history.DocumentStatusUnreadable {
|
||||
t.Fatalf("扫描件结论 = %+v, want unreadable(这份 PDF 没有文本层)", scan)
|
||||
}
|
||||
if scan.Reason != reasonPDFNoTextLayer {
|
||||
t.Fatalf("扫描件 reason = %q, want %q(必须与注入模型的原因逐字一致)", scan.Reason, reasonPDFNoTextLayer)
|
||||
}
|
||||
if !strings.Contains(scan.Reason, "OCR") {
|
||||
t.Fatalf("扫描件 reason = %q, want 含「OCR」", scan.Reason)
|
||||
}
|
||||
|
||||
// 模型可见的那一份:同一句话(清单块之后的诚实原因)。
|
||||
if len(ad.reqs) == 0 {
|
||||
t.Fatal("没有记录到模型请求")
|
||||
}
|
||||
modelSees := false
|
||||
for _, m := range ad.reqs[0].Messages {
|
||||
for _, part := range m.Content {
|
||||
if part.Type == model.ContentTypeText && strings.Contains(part.Text, reasonPDFNoTextLayer) {
|
||||
modelSees = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !modelSees {
|
||||
t.Fatal("注入模型的原因文案与回给客户端的 reason 不是同一句")
|
||||
}
|
||||
|
||||
// 反证:原因里没有路径 —— 盘上路径只可能是 `<uploads>/<存储名>`,原始错误若被
|
||||
// 透出必然带出它;`uploads` 是真实目录段名,存储名本身则是另一路(DTO 的 name)。
|
||||
for _, leak := range []string{layout.uploads, layout.dataDir, "uploads", scanName} {
|
||||
if strings.Contains(scan.Reason, leak) {
|
||||
t.Fatalf("reason = %q 含路径/引用片段 %q,原因文案里不得有路径", scan.Reason, leak)
|
||||
}
|
||||
}
|
||||
|
||||
// DTO 形状:unreadable 带 reason 键、extracted 不带。
|
||||
raw, err := json.Marshal(scan)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"reason":`) {
|
||||
t.Fatalf("扫描件 DTO = %s, want 带 reason 键", raw)
|
||||
}
|
||||
textRaw, err := json.Marshal(byName[textName])
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if strings.Contains(string(textRaw), `"reason"`) {
|
||||
t.Fatalf("extracted 的 DTO = %s, want 不带 reason 键", textRaw)
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,15 @@ type DocumentRef struct {
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
// Status 是抽取状态,取值见 DocumentStatus* 常量。
|
||||
Status string `json:"status,omitempty"`
|
||||
// Reason 是 Status == unreadable 时**读不到正文的原因**(design/24 §4.4 的
|
||||
// 诚实原因文案),供客户端直接把服务端的说法展示给用户 —— 客户端因此不必
|
||||
// 自己拼一句服务端没说过的话(契约 v0.63)。它**只在「读不到」时下发**
|
||||
// (extracted / listed 时为空 ⇒ 该键从 DTO 里消失)。
|
||||
//
|
||||
// 内容纪律:**只有文案**。不含落盘路径、不含任何凭据、不含存储名以外的标识
|
||||
// —— 它就是同一份注入给模型的原因短句(装配期与注入共用同一个字符串),原始
|
||||
// 错误(可能带路径)只进日志,绝不透出。
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// EncodeDocuments 把一条消息携带的文档引用编码成落库用的 JSON 数组。空列表返回
|
||||
|
||||
@@ -31,6 +31,13 @@ func TestDocumentRefRoundTrip(t *testing.T) {
|
||||
Status: DocumentStatusExtracted,
|
||||
},
|
||||
{Name: "fedcba9876543210fedcba9876543210.md", MIME: "text/markdown", Size: 42, Status: DocumentStatusListed},
|
||||
{
|
||||
Name: "00112233445566778899aabbccddeeff.pdf",
|
||||
MIME: "application/pdf",
|
||||
Size: 2048,
|
||||
Status: DocumentStatusUnreadable,
|
||||
Reason: "无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)",
|
||||
},
|
||||
}
|
||||
raw, err := EncodeDocuments(refs)
|
||||
if err != nil {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -196,6 +196,9 @@ func (s *Server) handleCreateSubSession(w http.ResponseWriter, r *http.Request)
|
||||
// after 互斥,空 q 被忽略,因此普通分页
|
||||
// 不变。同时给出 before 与 after(二者都 > 0)返回 400,
|
||||
// after 非数字/为负或 limit/before 非法时亦然。
|
||||
//
|
||||
// **会话不存在 / 已删除 ⇒ `404 session_not_found`**(契约 v0.63,行为变更);
|
||||
// 存在但确实没有消息仍是 `200 {"data":[]}`(见 history_session_guard.go)。
|
||||
func (s *Server) handleListMessages(w http.ResponseWriter, r *http.Request) {
|
||||
if s.history == nil {
|
||||
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "会话历史服务未配置(未注入 HistoryStore)", HTTPCode: http.StatusNotImplemented})
|
||||
@@ -240,6 +243,12 @@ func (s *Server) handleListMessages(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "q and after are mutually exclusive"))
|
||||
return
|
||||
}
|
||||
// 会话不存在 / 已删除 ⇒ 404 session_not_found(契约 v0.63):客户端据此把
|
||||
// 「当前会话已不在」与「这是一条空会话」分开处理。**存在但没有消息**仍走
|
||||
// 下面的空数组(见 history_session_guard.go)。
|
||||
if s.rejectMissingSession(w, r, id) {
|
||||
return
|
||||
}
|
||||
|
||||
var msgs []history.Message
|
||||
var err error
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
)
|
||||
|
||||
// 本文件归属读路径上**「这条会话到底还在不在」**的唯一判断(契约 v0.63)。
|
||||
//
|
||||
// 背景(客户端登记,2026-10-05):`GET /api/v1/sessions/{id}/messages` 此前对
|
||||
// **已删除 / 根本不存在**的会话回 `200 {"data":[]}`,客户端因此分不清「这是一条
|
||||
// 空会话」与「这条会话已经不在了」(主人的真实遭遇:正在看的临时会话被清理掉,
|
||||
// 界面上只剩一片空白)。同一个 API 里 `PATCH /sessions/{id}`、`POST
|
||||
// /sessions/{id}/read` 对未知会话本来就是 `404` —— 让 messages 单独例外会让每个
|
||||
// 新客户端都踩一次。
|
||||
//
|
||||
// 因此自 v0.63 起:不存在 / 已删除 ⇒ `404 {error:{code:"session_not_found"}}`;
|
||||
// **存在但没有消息**仍是 `200 {"data":[]}`(空会话绝不能被答成 404)。
|
||||
//
|
||||
// 判据**复用既有存储面**,不新写 SQL、也不新增存储方法:
|
||||
// `history.Store.SessionPersona` 是既有存储里唯一一个「按 id 读**单个**会话 +
|
||||
// 未知 id 报 `history.ErrSessionNotFound`」的方法(`ListSessions` 是全量列表扫描,
|
||||
// 在一个热分页端点上代价不成比例)。该口径由 `internal/runtime/history` 的
|
||||
// `TestSessionPersonaUnknownSession` 钉住,装配期角色解析(persona_env.go)用的
|
||||
// 也正是这个更窄视图。
|
||||
//
|
||||
// 名字口径(与既有 API 一致):`chat_sessions` 是**会话注册表**,隔离的 IM 会话
|
||||
// (群聊 / 陌生人的 `sess-xxx`,以及 `sess-backthink`)不在其中 —— 它们与
|
||||
// `POST /sessions/{id}/read` 拿到的是同一个答案(404:在那条路径上不可寻址),
|
||||
// 其消息仍可由 `/api/v1/admin/channels/{platform}/messages` 读出。
|
||||
//
|
||||
// 未实现该视图的存储(测试替身 / 未接库的窄存储)视为**无从判定**:此时保持旧的
|
||||
// 「空集合」语义 —— 判不出来**绝不等于**「不存在」,把一次本可成功的读答成 404
|
||||
// 比答成空列表更糟。
|
||||
|
||||
// sessionNotFoundCode 是「会话不存在 / 已删除」的机器可读错误码(契约 v0.63,
|
||||
// 见 Docs/api-client.md §1 行 6 与 §4.4)。
|
||||
const sessionNotFoundCode = "session_not_found"
|
||||
|
||||
// sessionMissing 报告存储是否**明确否认** sessionID 的存在。
|
||||
//
|
||||
// 第三个返回值是读取本身的失败:调用方必须按「不静默」处理(回 500 并留带来源的
|
||||
// 日志),绝不把「问不出来」当成「不存在」。
|
||||
func (s *Server) sessionMissing(ctx context.Context, sessionID string) (bool, error) {
|
||||
reader, ok := s.history.(sessionPersonaReader)
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
_, err := reader.SessionPersona(ctx, sessionID)
|
||||
switch {
|
||||
case errors.Is(err, history.ErrSessionNotFound):
|
||||
return true, nil
|
||||
case err != nil:
|
||||
return false, fmt.Errorf("gateway: session lookup %s: %w", sessionID, err)
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// rejectMissingSession 在会话不存在时写出 `404 session_not_found` 并返回 true,
|
||||
// 使调用方一行收口:`if s.rejectMissingSession(w, r, id) { return }`。
|
||||
//
|
||||
// 判定失败(存储读不出来)回 `500 messages_failed` 并返回 true —— 宁可让这一次读
|
||||
// 失败并留下可诊断的日志,也不假装它是空会话。
|
||||
func (s *Server) rejectMissingSession(w http.ResponseWriter, r *http.Request, id string) bool {
|
||||
missing, err := s.sessionMissing(r.Context(), id)
|
||||
if err != nil {
|
||||
s.log.Error("history messages session lookup", "err", err, "session", id)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "messages_failed", "failed to list messages"))
|
||||
return true
|
||||
}
|
||||
if !missing {
|
||||
return false
|
||||
}
|
||||
writeError(w, apperr.Wrap(apperr.ErrNotFound, sessionNotFoundCode, "session %q not found", id))
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
)
|
||||
|
||||
// 本文件钉住契约 v0.63 的 `GET /api/v1/sessions/{id}/messages` 语义(客户端登记,
|
||||
// 2026-10-05):
|
||||
//
|
||||
// - **不存在 / 已删除**的会话 ⇒ `404 {error:{code:"session_not_found"}}`
|
||||
// (与 `PATCH /sessions/{id}` / `POST /sessions/{id}/read` 对未知会话的口径
|
||||
// 一致),客户端因此能把「当前会话已经不在了」与「这是一条空会话」分开;
|
||||
// - **存在但没有消息** ⇒ 仍是 `200 {"data":[]}`(空会话绝不能被答成 404);
|
||||
// - `?q=` / `after` / 参数校验的既有语义不变(400 仍先于 404 —— 请求形状错了
|
||||
// 就是 400,与目标存不存在无关)。
|
||||
|
||||
// TestListMessagesUnknownSessionIs404 是本契约的主断言:一个从未存在过的会话 id
|
||||
// 必须得到 404 + `session_not_found`,而不是「看起来像空会话」。
|
||||
func TestListMessagesUnknownSessionIs404(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ts, jwt, _ := newHistoryEnv(t)
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
for _, id := range []string{"web:ffffffffffffffffffffffffffffffff", "web:never-existed"} {
|
||||
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+id+"/messages", nil, tok)
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("GET %s/messages status = %d, want 404 (body=%s)", id, resp.StatusCode, body)
|
||||
}
|
||||
wantErrorMessage(t, body, "session_not_found")
|
||||
}
|
||||
}
|
||||
|
||||
// TestListMessagesDeletedSessionIs404 复现客户端的真实遭遇:一条被 DELETE 掉的次级
|
||||
// 会话(连同它的消息)此后必须报「不在了」,而不是回一个空数组让客户端以为它只是
|
||||
// 空的。
|
||||
func TestListMessagesDeletedSessionIs404(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ts, jwt, st := newHistoryEnv(t)
|
||||
ctx := context.Background()
|
||||
sub, err := st.NewSubSession(ctx, "owner")
|
||||
if err != nil {
|
||||
t.Fatalf("new sub session: %v", err)
|
||||
}
|
||||
if err := st.Append(ctx, history.Message{SessionID: sub, Role: history.RoleUser, Content: "临时验收会话", CreatedAt: 1000}); err != nil {
|
||||
t.Fatalf("append: %v", err)
|
||||
}
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
// 删除前:200 且带那条消息(契约的另一半 —— 活着的会话照旧)。
|
||||
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+sub+"/messages", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("删除前 status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var before []historyMessage
|
||||
decodeDataSlice(t, body, &before)
|
||||
if len(before) != 1 {
|
||||
t.Fatalf("删除前 messages = %d 条, want 1", len(before))
|
||||
}
|
||||
|
||||
if resp, body = doReq(t, http.MethodDelete, ts.URL+"/api/v1/sessions/"+sub, nil, tok); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("delete session status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+sub+"/messages", nil, tok)
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("删除后 status = %d, want 404 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
wantErrorMessage(t, body, "session_not_found")
|
||||
}
|
||||
|
||||
// TestListMessagesEmptySessionIs200Empty 是**回归**:真会话就是没有消息时,
|
||||
// 必须仍然是 200 + 空数组 —— 契约只把「不存在」变成 404,绝不把「空会话」也卷进去。
|
||||
// 主会话(不可删、永远存在)与刚建好的次级会话各测一次。
|
||||
func TestListMessagesEmptySessionIs200Empty(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ts, jwt, st := newHistoryEnv(t)
|
||||
sub, err := st.NewSubSession(context.Background(), "owner")
|
||||
if err != nil {
|
||||
t.Fatalf("new sub session: %v", err)
|
||||
}
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
for _, id := range []string{history.MainSessionID, sub} {
|
||||
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/sessions/"+id+"/messages", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("空会话 %s status = %d, want 200 (body=%s)", id, resp.StatusCode, body)
|
||||
}
|
||||
var page []historyMessage
|
||||
decodeDataSlice(t, body, &page)
|
||||
if len(page) != 0 {
|
||||
t.Fatalf("空会话 %s messages = %+v, want 空数组", id, page)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestListMessagesUnknownSessionKeepsParamChecks 钉住判定顺序:请求形状错了就是
|
||||
// 400(与目标会话存不存在无关),形状对了才轮到 404。`?q=` / `after` 的既有语义
|
||||
// 因此在未知会话上也不会退化成一个含糊的 404。
|
||||
func TestListMessagesUnknownSessionKeepsParamChecks(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, ts, jwt, _ := newHistoryEnv(t)
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
base := ts.URL + "/api/v1/sessions/web:never-existed/messages"
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
query string
|
||||
want int
|
||||
}{
|
||||
{"非法 limit", "?limit=abc", http.StatusBadRequest},
|
||||
{"limit 越界", "?limit=0", http.StatusBadRequest},
|
||||
{"非法 before", "?before=abc", http.StatusBadRequest},
|
||||
{"before 与 after 互斥", "?before=1000&after=500", http.StatusBadRequest},
|
||||
{"q 与 after 互斥", "?q=x&after=500", http.StatusBadRequest},
|
||||
{"形状合法 ⇒ 404", "?q=x", http.StatusNotFound},
|
||||
{"after 形状合法 ⇒ 404", "?after=500", http.StatusNotFound},
|
||||
} {
|
||||
resp, body := doReq(t, http.MethodGet, base+tc.query, nil, tok)
|
||||
if resp.StatusCode != tc.want {
|
||||
t.Fatalf("%s: status = %d, want %d (body=%s)", tc.name, resp.StatusCode, tc.want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionMissingWithoutLookupSurfaceIsNotMissing 钉住「判不出来 ≠ 不存在」:
|
||||
// 存储没有单会话读取面时(测试替身 / 未接库的窄存储),读路径保持旧的空集合语义
|
||||
// —— 把一次本可成功的读答成 404 比答成空列表更糟。
|
||||
func TestSessionMissingWithoutLookupSurfaceIsNotMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
hist := &fakeHistory{}
|
||||
srv, err := New(cfgDefault(), Deps{Auth: auth.New(config.AuthConfig{JWTSecret: testSecret}), History: hist})
|
||||
if err != nil {
|
||||
t.Fatalf("构造网关失败: %v", err)
|
||||
}
|
||||
t.Cleanup(srv.Close)
|
||||
missing, err := srv.sessionMissing(context.Background(), "web:whatever")
|
||||
if err != nil {
|
||||
t.Fatalf("sessionMissing: %v", err)
|
||||
}
|
||||
if missing {
|
||||
t.Fatal("没有存在性判据的存储不得被判成「会话不存在」")
|
||||
}
|
||||
}
|
||||
|
||||
// wantErrorMessage 断言错误信封的 code 与 message 都成形(客户端按 code 分支)。
|
||||
func wantErrorMessage(t *testing.T, body []byte, code string) {
|
||||
t.Helper()
|
||||
env := decodeBody(t, body)
|
||||
raw, ok := env["error"]
|
||||
if !ok {
|
||||
t.Fatalf("响应里没有 error 信封 (body=%s)", body)
|
||||
}
|
||||
var eobj struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &eobj); err != nil {
|
||||
t.Fatalf("解码 error 信封: %v (body=%s)", err, body)
|
||||
}
|
||||
if eobj.Code != code {
|
||||
t.Fatalf("error.code = %q, want %q", eobj.Code, code)
|
||||
}
|
||||
if eobj.Message == "" {
|
||||
t.Fatalf("error.message 为空 (body=%s)", body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/documents"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// 本文件是契约 v0.63 的 **HTTP 层端到端**回归:`documents[].reason`(「读不到」
|
||||
// 的原因)从**真实文件**出发,经真实摄取层 → 真实 loop 装配期 → 回合后回写 →
|
||||
// 历史读接口,一路抵达客户端;且**只在读不到时**出现、**不含任何路径**。
|
||||
//
|
||||
// 与 session_document_e2e_test.go(S3.5 的结论回写)的分工:那边用假 runner 验证
|
||||
// 「每个附件都落库 + 结论都回来」,这里用**真实 loop + 真实摄取层**验证新增的
|
||||
// reason 字段本身的产生与运送,并额外钉住「extracted 时该键缺席」。
|
||||
|
||||
// reasonLeakMarker 是反证用的**独特**目录名:它只出现在真实盘上路径里,
|
||||
// 因此「响应体里搜不到它」就是「原因文案没有把路径透给客户端」的直接证据。
|
||||
const reasonLeakMarker = "cyrene-reason-leak-marker"
|
||||
|
||||
// reasonStubAdapter 是最小模型适配器:记下请求、回一句文本,使真实 loop 能走完
|
||||
// 一个回合(与 documents 包的 recordingAdapter 同职责,网关侧独立一份)。
|
||||
type reasonStubAdapter struct{ reqs []model.Request }
|
||||
|
||||
func (a *reasonStubAdapter) Name() string { return "reason-stub" }
|
||||
|
||||
func (a *reasonStubAdapter) Chat(_ context.Context, req model.Request, _ model.Options) (model.Response, error) {
|
||||
a.reqs = append(a.reqs, req)
|
||||
return model.Response{Model: "stub", Content: "看过了"}, nil
|
||||
}
|
||||
|
||||
func (a *reasonStubAdapter) ChatStream(ctx context.Context, req model.Request, _ func(model.StreamEvent)) (model.Response, error) {
|
||||
return a.Chat(ctx, req, model.Options{})
|
||||
}
|
||||
|
||||
// scanPDFBytes 现场拼出一份**合法但无文本层**的 PDF(一页;内容流只画一个矩形,
|
||||
// 不含任何文本算子)—— 它就是「图片型/扫描件 PDF」在抽取器眼里的样子。
|
||||
// 不下载、不引入二进制资源(与 documents 包的 buildPDF 同一思路,这里只需这一种
|
||||
// 形态,故不搬那套完整夹具)。
|
||||
func scanPDFBytes(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
// 页内容流:q 保存状态 → 设填充色 → 画矩形 → Q 恢复状态。没有任何 Tj/TJ。
|
||||
content := "q 0 0 1 rg 100 100 200 200 re f Q"
|
||||
objs := []string{
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R >>",
|
||||
fmt.Sprintf("<< /Length %d >>\nstream\n%s\nendstream", len(content), content),
|
||||
}
|
||||
var b bytes.Buffer
|
||||
b.WriteString("%PDF-1.4\n")
|
||||
offsets := make([]int, len(objs)+1)
|
||||
for i, body := range objs {
|
||||
offsets[i+1] = b.Len()
|
||||
fmt.Fprintf(&b, "%d 0 obj\n%s\nendobj\n", i+1, body)
|
||||
}
|
||||
xrefAt := b.Len()
|
||||
fmt.Fprintf(&b, "xref\n0 %d\n0000000000 65535 f \n", len(objs)+1)
|
||||
for i := 1; i <= len(objs); i++ {
|
||||
fmt.Fprintf(&b, "%010d 00000 n \n", offsets[i])
|
||||
}
|
||||
fmt.Fprintf(&b, "trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(objs)+1, xrefAt)
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
// documentReasonEnv 搭一套**全真**的装配:真数据目录、真摄取层、真 loop、真
|
||||
// 历史存储、真 HTTP 服务。返回服务地址、token 与两份文档的存储名。
|
||||
func documentReasonEnv(t *testing.T) (baseURL, tok, scanName, textName, dataDir string) {
|
||||
t.Helper()
|
||||
dataDir = filepath.Join(t.TempDir(), reasonLeakMarker)
|
||||
scanName = storeGatewayDocument(t, dataDir, string(scanPDFBytes(t)), ".pdf")
|
||||
textName = storeGatewayDocument(t, dataDir, "季度报告:营收增长 12%。\n", ".txt")
|
||||
|
||||
resolver := documents.NewResolver(documents.Config{
|
||||
DataDir: dataDir,
|
||||
UploadsDir: filepath.Join(dataDir, "uploads", documentSubDir),
|
||||
})
|
||||
ag, err := agent.New("你是助手。", nil, nil, agent.WithModel(&reasonStubAdapter{}, nil))
|
||||
if err != nil {
|
||||
t.Fatalf("agent.New: %v", err)
|
||||
}
|
||||
run := loop.New(loop.Options{Agent: ag, DocumentResolver: resolver, MaxRounds: 1})
|
||||
|
||||
jwt := auth.New(config.AuthConfig{JWTSecret: testSecret})
|
||||
cfg := &config.Config{Auth: config.AuthConfig{AdminUser: "admin", AdminPass: "secret", JWTSecret: testSecret}}
|
||||
srv, err := New(cfg, Deps{Auth: jwt, DataDir: dataDir, Loop: run, History: newGatewayHistoryStore(t)})
|
||||
if err != nil {
|
||||
t.Fatalf("构造网关失败: %v", err)
|
||||
}
|
||||
httpSrv := httptest.NewServer(srv.Handler())
|
||||
t.Cleanup(httpSrv.Close)
|
||||
t.Cleanup(srv.Close)
|
||||
tok, err = jwt.Sign("acct", auth.RoleOwner, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("签发 token 失败: %v", err)
|
||||
}
|
||||
return httpSrv.URL, tok, scanName, textName, dataDir
|
||||
}
|
||||
|
||||
// TestDocumentReasonReachesMessagesWire 是本契约 ① 的端到端断言:扫描件 PDF ⇒
|
||||
// `status:"unreadable"` **且**带回原因(文案含「OCR」);纯文本 ⇒ `extracted` 且
|
||||
// **没有** `reason` 键;整份响应体里搜不到任何路径(反证见 reasonLeakMarker)。
|
||||
func TestDocumentReasonReachesMessagesWire(t *testing.T) {
|
||||
t.Parallel()
|
||||
ts, tok, scanName, textName, _ := documentReasonEnv(t)
|
||||
|
||||
resp, body := doReq(t, http.MethodPost, ts+"/api/v1/chat", map[string]any{
|
||||
"sessionId": history.MainSessionID,
|
||||
"text": "这两份都看看",
|
||||
"documents": []chatDocumentRef{{Name: scanName}, {Name: textName}},
|
||||
}, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("chat status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
resp, body = doReq(t, http.MethodGet, ts+"/api/v1/sessions/"+history.MainSessionID+"/messages", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("messages status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
docs := userMessageDocuments(t, body, "这两份都看看")
|
||||
if len(docs) != 2 {
|
||||
t.Fatalf("历史接口返回的 documents = %+v, want 2 条", docs)
|
||||
}
|
||||
|
||||
got := map[string]map[string]any{}
|
||||
for _, d := range docs {
|
||||
name, _ := d["name"].(string)
|
||||
got[name] = d
|
||||
}
|
||||
scan, ok := got[scanName]
|
||||
if !ok {
|
||||
t.Fatalf("扫描件的引用不在文档数组里: %+v", docs)
|
||||
}
|
||||
if scan["status"] != history.DocumentStatusUnreadable {
|
||||
t.Fatalf("扫描件 status = %v, want unreadable(这份 PDF 没有文本层)", scan["status"])
|
||||
}
|
||||
reason, ok := scan["reason"].(string)
|
||||
if !ok || reason == "" {
|
||||
t.Fatalf("扫描件的 documents[].reason 缺失或不是字符串: %+v(契约 v0.63 要求读不到时给出原因)", scan)
|
||||
}
|
||||
if !strings.Contains(reason, "OCR") {
|
||||
t.Fatalf("扫描件 reason = %q, want 含「OCR」(需要 OCR 才是这份 PDF 的真实原因)", reason)
|
||||
}
|
||||
|
||||
text, ok := got[textName]
|
||||
if !ok {
|
||||
t.Fatalf("纯文本的引用不在文档数组里: %+v", docs)
|
||||
}
|
||||
if text["status"] != history.DocumentStatusExtracted {
|
||||
t.Fatalf("纯文本 status = %v, want extracted", text["status"])
|
||||
}
|
||||
if _, present := text["reason"]; present {
|
||||
t.Fatalf("extracted 的文档不得带 reason(该键必须缺席): %+v", text)
|
||||
}
|
||||
|
||||
// 反证:原因文案(乃至整份响应)里没有任何路径信息。marker 只存在于盘上路径
|
||||
// (`<TempDir>/cyrene-reason-leak-marker/uploads/documents/…`),一旦原始错误
|
||||
// (「open …\cyrene-reason-leak-marker\uploads\documents\….pdf:」)被透出,这里
|
||||
// 必然命中。**刻意不断言「不含 `/`」**:诚实原因文案本身就用它分隔
|
||||
// 「图片型/无文本层」,那是文案的一部分,不是路径。
|
||||
if strings.Contains(string(body), reasonLeakMarker) {
|
||||
t.Fatalf("响应体里出现了盘上路径片段: %s", body)
|
||||
}
|
||||
for _, leak := range []string{reasonLeakMarker, "uploads", string(filepath.Separator) + "documents"} {
|
||||
if strings.Contains(reason, leak) {
|
||||
t.Fatalf("reason = %q 含路径片段 %q,原因文案里不得有路径", reason, leak)
|
||||
}
|
||||
}
|
||||
if strings.Contains(reason, scanName) {
|
||||
t.Fatalf("reason = %q 含存储名之外的引用信息(存储名本身已在同一元素的 name 键里)", reason)
|
||||
}
|
||||
}
|
||||
|
||||
// userMessageDocuments 取出内容为 content 的那条用户消息的 documents 数组(**按
|
||||
// 原始 JSON 对象**解,使「键到底在不在」可断言 —— 解进结构体会把「缺席」与
|
||||
// 「空串」抹成同一个值)。
|
||||
func userMessageDocuments(t *testing.T, body []byte, content string) []map[string]any {
|
||||
t.Helper()
|
||||
var envelope struct {
|
||||
Data []struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Documents []map[string]any `json:"documents"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
t.Fatalf("unmarshal messages: %v (body=%s)", err, body)
|
||||
}
|
||||
for _, m := range envelope.Data {
|
||||
if m.Role == history.RoleUser && m.Content == content {
|
||||
return m.Documents
|
||||
}
|
||||
}
|
||||
t.Fatalf("历史里没有找到内容为 %q 的用户消息 (body=%s)", content, body)
|
||||
return nil
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
// 列 —— 两段式是刻意的,也是客户端能看到「昔涟读不到这个文件」的唯一路径。
|
||||
//
|
||||
// 三条纪律:
|
||||
// - 只补结论(status/chars/truncated),身份与大小逐字保留;
|
||||
// - 只补结论(status/chars/truncated 与 unreadable 的 reason),身份与大小逐字保留;
|
||||
// - 按**服务端存储名**匹配,匹配不到就原样留着(凭空给它一个结论就是编造);
|
||||
// - 找不到行、存储未实现该视图、写库失败一律带来源 WARN,绝不静默。
|
||||
//
|
||||
@@ -103,6 +103,9 @@ func (o *Orchestrator) takeTurnDocuments(sessionID string, gen uint64) ([]histor
|
||||
// 消息的 document 列(design/24 §4.4/§6.1)。它由 completeTurn 在助手回复落库
|
||||
// 之后、状态机迁移之前调用,因此结论与该回合的对话内容一起同批入账。
|
||||
//
|
||||
// 回写的是 status/chars/truncated 与 unreadable 的原因文案(reason,契约
|
||||
// v0.63)—— 之后所有读取面(历史接口 / 握手回放)拿到的是同一份结论。
|
||||
//
|
||||
// 找不到对应行(ErrMessageNotFound)、存储未接该视图、列被写坏、写库失败时都
|
||||
// **不静默**:打一条带来源的 WARN 说明「状态未回写」。日志只带会话、消息 id、
|
||||
// 条数与错误,**绝不带正文**,也不带列内容。
|
||||
@@ -151,10 +154,13 @@ func (o *Orchestrator) persistDocumentStatus(sessionID string, gen uint64, res *
|
||||
}
|
||||
|
||||
// mergeDocumentStatus 把本回合解析到的结论按存储名并进已落库的引用数组:匹配到
|
||||
// 的补上 status/chars/truncated,没匹配到的原样保留(那意味着本回合没有解析到
|
||||
// 它,凭空给它一个结论就是编造)。第二个返回值报告是否真的补进了新结论 ——
|
||||
// 的补上 status/chars/truncated/reason,没匹配到的原样保留(那意味着本回合没有
|
||||
// 解析到它,凭空给它一个结论就是编造)。第二个返回值报告是否真的补进了新结论 ——
|
||||
// 没变化就不必写库、也不必刷一条「什么都没变」的日志。
|
||||
//
|
||||
// reason 与 status 同批搬运(契约 v0.63):抽取结论的这三项 + 原因文案是同一个
|
||||
// 回合的同一份事实,分两次写会让客户端看到「unreadable 但没有理由」的中间态。
|
||||
//
|
||||
// 列被写坏时返回包装了 history 哨兵的错误,由调用方按「不静默」处理。
|
||||
func mergeDocumentStatus(raw string, resolved []history.DocumentRef) (string, bool, error) {
|
||||
refs, err := history.DecodeDocuments(raw)
|
||||
@@ -173,10 +179,12 @@ func mergeDocumentStatus(raw string, resolved []history.DocumentRef) (string, bo
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if refs[i].Status == got.Status && refs[i].Chars == got.Chars && refs[i].Truncated == got.Truncated {
|
||||
if refs[i].Status == got.Status && refs[i].Chars == got.Chars &&
|
||||
refs[i].Truncated == got.Truncated && refs[i].Reason == got.Reason {
|
||||
continue
|
||||
}
|
||||
refs[i].Status, refs[i].Chars, refs[i].Truncated = got.Status, got.Chars, got.Truncated
|
||||
refs[i].Status, refs[i].Chars, refs[i].Truncated, refs[i].Reason =
|
||||
got.Status, got.Chars, got.Truncated, got.Reason
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
|
||||
@@ -114,12 +114,15 @@ func TestMergeDocumentStatus(t *testing.T) {
|
||||
raw, err := history.EncodeDocuments([]history.DocumentRef{
|
||||
{Name: "aaa.txt", MIME: "text/plain", Size: 1},
|
||||
{Name: "bbb.pdf", MIME: "application/pdf", Size: 2},
|
||||
{Name: "ccc.txt", MIME: "text/plain", Size: 3},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("encode: %v", err)
|
||||
}
|
||||
const scanReason = "无法抽取正文:这是图片型/无文本层 PDF,需要 OCR 才能读(当前未做)"
|
||||
merged, changed, err := mergeDocumentStatus(raw, []history.DocumentRef{
|
||||
{Name: "aaa.txt", Status: history.DocumentStatusExtracted, Chars: 42, Truncated: true},
|
||||
{Name: "bbb.pdf", Status: history.DocumentStatusUnreadable, Reason: scanReason},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("merge: %v", err)
|
||||
@@ -131,17 +134,26 @@ func TestMergeDocumentStatus(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("decode merged: %v", err)
|
||||
}
|
||||
if len(refs) != 2 {
|
||||
t.Fatalf("refs = %+v, want 2 条(没匹配到的原样保留)", refs)
|
||||
if len(refs) != 3 {
|
||||
t.Fatalf("refs = %+v, want 3 条(没匹配到的原样保留)", refs)
|
||||
}
|
||||
if refs[0].Status != history.DocumentStatusExtracted || refs[0].Chars != 42 || !refs[0].Truncated || refs[0].MIME != "text/plain" || refs[0].Size != 1 {
|
||||
t.Fatalf("refs[0] = %+v, want 结论补上且身份不变", refs[0])
|
||||
}
|
||||
if refs[1].Status != "" || refs[1].Name != "bbb.pdf" {
|
||||
t.Fatalf("refs[1] = %+v, want 原样保留(本回合没解析到它)", refs[1])
|
||||
// v0.63:reason 与 status 同批回写,客户端因此不会看到「unreadable 但没有理由」。
|
||||
if refs[1].Status != history.DocumentStatusUnreadable || refs[1].Reason != scanReason {
|
||||
t.Fatalf("refs[1] = %+v, want unreadable + 原因文案", refs[1])
|
||||
}
|
||||
if refs[2].Status != "" || refs[2].Reason != "" || refs[2].Name != "ccc.txt" {
|
||||
t.Fatalf("refs[2] = %+v, want 原样保留(本回合没解析到它)", refs[2])
|
||||
}
|
||||
|
||||
if _, changed, err := mergeDocumentStatus(merged, []history.DocumentRef{{Name: "aaa.txt", Status: history.DocumentStatusExtracted, Chars: 42, Truncated: true}}); err != nil || changed {
|
||||
// 结论与原因都补齐后,同一批再合一次必须报「无变化」——reason 也是变化判据的
|
||||
// 一部分(否则每回合都会因为空 reason 而重写一次)。
|
||||
if _, changed, err := mergeDocumentStatus(merged, []history.DocumentRef{
|
||||
{Name: "aaa.txt", Status: history.DocumentStatusExtracted, Chars: 42, Truncated: true},
|
||||
{Name: "bbb.pdf", Status: history.DocumentStatusUnreadable, Reason: scanReason},
|
||||
}); err != nil || changed {
|
||||
t.Fatalf("changed = %v, err = %v, want 无变化且无错(不必写库)", changed, err)
|
||||
}
|
||||
if _, _, err := mergeDocumentStatus("{not json", nil); !errors.Is(err, history.ErrBadDocuments) {
|
||||
|
||||
Reference in new issue
Block a user