| 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 |