返回 DeepSeek-Reasonix
outline.go
根目录 / internal / transcript / outline.go
1 package transcript
2
3 import (
4 "encoding/json"
5 "errors"
6 "slices"
7 "strings"
8
9 "reasonix/internal/textutil"
10 )
11
12 const (
13 // An outline page carries metadata only: entries are small and fixed-shape,
14 // so the entry count is the primary bound and the byte budget is a safety
15 // net shared with the record pages' response limit.
16 defaultOutlineEntries = 1000
17 maxOutlineEntries = 1000
18 defaultOutlineBytes = 512 << 10
19
20 // Previews are display-only. The prompt cap keeps a rail label readable; the
21 // answer cap keeps a hover card readable. Both are grapheme-cluster counts.
22 promptPreviewRunes = 50
23 answerPreviewRunes = 120
24
25 // previewScanBytesPerCluster bounds the text inspected before collapsing.
26 // 32 bytes covers every realistic cluster (a ZWJ family is ~25), so the
27 // clamp never shortens a preview that the grapheme budget would have kept.
28 previewScanBytesPerCluster = 32
29 )
30
31 // OutlineRequest pages the turn index bound to one snapshot.
32 type OutlineRequest struct {
33 SnapshotID string `json:"snapshotId"`
34 Offset int `json:"offset"`
35 Entries int `json:"entries"`
36 Bytes int `json:"bytes"`
37 }
38
39 // OutlineEntry is one user turn of the complete conversation. ID is the stable
40 // record identity shared with the body records, so a turn keeps its identity
41 // across snapshots. Order is only this snapshot's pagination position: it is a
42 // locator hint, never cross-snapshot identity and never a React key.
43 type OutlineEntry struct {
44 ID string `json:"id"`
45 MessageID string `json:"messageId,omitempty"`
46 Turn int `json:"turn"`
47 Order int `json:"order"`
48 Prompt string `json:"prompt"`
49 Answer string `json:"answer,omitempty"`
50 }
51
52 // OutlinePage carries the same boundary as the record pages it describes, so a
53 // client can prove both reads belong to one immutable cut. An evicted cut
54 // answers Stale instead of silently continuing against the newest revision.
55 type OutlinePage struct {
56 Boundary
57 Entries []OutlineEntry `json:"entries"`
58 NextOffset int `json:"nextOffset"`
59 Done bool `json:"done"`
60 Total int `json:"total"`
61 Stale bool `json:"stale"`
62 }
63
64 // Outline returns a bounded page of the turn index for one snapshot. The index
65 // is complete regardless of how much body a client has paged in, so navigation
66 // does not depend on the loaded prefix.
67 func (p *Projection) Outline(req OutlineRequest) (OutlinePage, error) {
68 p.mu.Lock()
69 defer p.mu.Unlock()
70 frozen, err := p.freezeLocked(req.SnapshotID)
71 if err != nil {
72 return OutlinePage{}, err
73 }
74 if frozen == nil {
75 return OutlinePage{Boundary: p.boundaryLocked(), Entries: []OutlineEntry{}, Stale: true}, nil
76 }
77 return frozen.outlineCurrent(req)
78 }
79
80 func (p *Projection) outlineCurrent(req OutlineRequest) (OutlinePage, error) {
81 out := OutlinePage{Boundary: p.boundaryLocked(), Entries: []OutlineEntry{}}
82 if req.SnapshotID != "" && req.SnapshotID != out.SnapshotID {
83 out.Stale = true
84 return out, nil
85 }
86 out.Total = len(p.outline)
87 offset := min(max(req.Offset, 0), out.Total)
88 limit := req.Entries
89 if limit <= 0 {
90 limit = defaultOutlineEntries
91 }
92 limit = min(limit, maxOutlineEntries)
93 budget := req.Bytes
94 if budget <= 0 {
95 budget = defaultOutlineBytes
96 }
97 budget = min(budget, MaxResponseBytes)
98 used := 0
99 for i := offset; i < out.Total && len(out.Entries) < limit; i++ {
100 encoded, err := json.Marshal(p.outline[i])
101 if err != nil {
102 return OutlinePage{}, err
103 }
104 // Always advance: a client that stopped on a full page would otherwise
105 // retry the same offset forever.
106 if len(out.Entries) > 0 && used+len(encoded) > budget {
107 break
108 }
109 out.Entries = append(out.Entries, p.outline[i])
110 used += len(encoded)
111 }
112 out.NextOffset = offset + len(out.Entries)
113 out.Done = out.NextOffset >= out.Total
114 encoded, err := json.Marshal(out)
115 if err != nil {
116 return OutlinePage{}, err
117 }
118 if len(encoded)+1 > MaxResponseBytes {
119 return OutlinePage{}, errors.New("transcript outline metadata exceeds the page limit")
120 }
121 return out, nil
122 }
123
124 // buildOutline indexes every user turn in a frozen cut. One backward pass
125 // assigns each turn the last non-empty assistant body of its own group, so the
126 // index shares the records' identity and order without a second grouping model.
127 func buildOutline(messages []*bufferedMessage) []OutlineEntry {
128 out := make([]OutlineEntry, 0, 16)
129 answer := ""
130 for i, row := range slices.Backward(messages) {
131 switch row.message.Role {
132 case "assistant":
133 if answer == "" {
134 answer = previewText(row.body(), answerPreviewRunes)
135 }
136 case "user":
137 out = append(out, OutlineEntry{
138 ID: row.message.RecordID, MessageID: row.message.MessageID,
139 Turn: row.message.HistoryTurn, Order: i,
140 Prompt: previewText(row.body(), promptPreviewRunes), Answer: answer,
141 })
142 answer = ""
143 }
144 }
145 slices.Reverse(out)
146 // A legacy turn row can reach here without an assigned ordinal. Keep the
147 // rail monotonic instead of rendering turn 0 or renumbering later turns.
148 previous := 0
149 for i := range out {
150 if out[i].Turn <= previous {
151 out[i].Turn = previous + 1
152 }
153 previous = out[i].Turn
154 }
155 return out
156 }
157
158 // body reads display text without copying. Streaming assistants keep their text
159 // in the accumulator; every other row carries it on the message.
160 func (m *bufferedMessage) body() string {
161 if m.message.Role == "assistant" {
162 return m.content.string()
163 }
164 return m.message.Content
165 }
166
167 // previewText collapses whitespace and clamps to limit grapheme clusters. The
168 // input is bounded before collapsing so a multi-megabyte answer costs one short
169 // pass rather than a full scan per freeze. Only display bodies reach here:
170 // reasoning, tool output, submitted text and injected context are never part of
171 // an outline entry.
172 func previewText(text string, limit int) string {
173 if end := (limit + 1) * previewScanBytesPerCluster; len(text) > end {
174 text = text[:runeBoundary(text, end)]
175 }
176 collapsed := strings.Join(strings.Fields(text), " ")
177 if collapsed == "" {
178 return ""
179 }
180 return textutil.TruncateGraphemes(collapsed, limit, "…")
181 }
182
182 lines GO