Repository navigation
Expand file tree
/
Copy pathsummarizer.cpp
More file actions
304 lines (262 loc) · 11.2 KB
/
Copy pathsummarizer.cpp
File metadata and controls
304 lines (262 loc) · 11.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
/**
* Conversation summarization implementation. See include/ragger/summarizer.h.
*/
#include "summarizer.h"
#include "config.h"
#include "inference.h"
#include "lang.h"
#include "Logger.h"
#include <format>
#include <string_view>
namespace ragger {
namespace {
// Stable bracketed markers Hermes prepends to system-injected user turns.
// Matched case-sensitively against the trimmed start of the user text. Kept
// deliberately narrow: each is a phrase Hermes core controls, so a real human
// message is extraordinarily unlikely to begin with one. Note the OUT-OF-BAND
// USER MESSAGE marker is intentionally absent — that wraps a genuine human
// message and should be summarized normally.
constexpr std::string_view kSystemMarkers[] = {
"[System note:",
"[Note: model was just switched",
"[CONTEXT COMPACTION",
"[IMPORTANT: Background process",
};
std::string_view ltrim_view(std::string_view s) {
size_t i = 0;
while (i < s.size() &&
(s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || s[i] == '\r'))
++i;
return s.substr(i);
}
} // namespace
// Strip <think>…</think> blocks and bare "Thinking Process:\n…\n\n" preambles
// that reasoning models emit before their actual answer. Applied to all
// summarizer output before storage so these never land in the DB.
std::string strip_thinking(const std::string& s) {
std::string out = s;
// 1. Strip all <think>…</think> blocks (may be nested or malformed)
while (true) {
auto open = out.find("<think>");
if (open == std::string::npos) break;
auto close = out.find("</think>", open);
if (close == std::string::npos) {
out.erase(open); // unterminated — drop to end
} else {
out.erase(open, close - open + 8); // +8 for "</think>"
}
}
// 2. Strip a leading "Thinking Process:\n…\n\n" preamble (no XML tags).
// The thinking steps follow the marker; the real answer comes after the
// LAST blank line (rfind) — the marker's own \n\n is the first, but the
// answer is separated by the final \n\n.
{
constexpr std::string_view kTP = "Thinking Process:";
auto start = out.find_first_not_of(" \t\r\n");
if (start != std::string::npos && out.compare(start, kTP.size(), kTP) == 0) {
auto blank = out.rfind("\n\n");
if (blank != std::string::npos && blank > start)
out.erase(0, blank + 2);
}
}
// Left-trim any whitespace left behind
auto first = out.find_first_not_of(" \t\r\n");
if (first != std::string::npos && first > 0)
out.erase(0, first);
else if (first == std::string::npos)
out.clear();
return out;
}
bool is_system_injected_turn(const std::string& user_text) {
const std::string_view body = ltrim_view(user_text);
for (const auto marker : kSystemMarkers) {
if (body.size() >= marker.size() &&
body.compare(0, marker.size(), marker) == 0) {
return true;
}
}
return false;
}
namespace {
// Sentinel that closes a CONTEXT COMPACTION block. Everything from the
// "[CONTEXT COMPACTION" opener through the end of the line containing this
// sentinel is one annotation block. Matching the bracket alone is unsafe —
// the summary body is full of '[...]' lookalikes.
constexpr std::string_view kCompactionEnd = "END OF CONTEXT SUMMARY";
// Which marker (if any) does `body` start with? Returns index into
// kSystemMarkers, or -1.
int leading_marker(std::string_view body) {
for (size_t k = 0; k < std::size(kSystemMarkers); ++k) {
const auto m = kSystemMarkers[k];
if (body.size() >= m.size() && body.compare(0, m.size(), m) == 0)
return static_cast<int>(k);
}
return -1;
}
} // namespace
std::string strip_system_injected_prefix(const std::string& user_text) {
std::string_view body = ltrim_view(user_text);
// Strip leading annotation blocks repeatedly — Hermes can stack several
// (e.g. a model-switch Note followed by a System note).
for (;;) {
const int idx = leading_marker(body);
if (idx < 0) break;
const std::string_view marker = kSystemMarkers[idx];
if (marker == "[CONTEXT COMPACTION") {
// Strip through the end of the line carrying the END sentinel.
const size_t e = body.find(kCompactionEnd);
if (e == std::string_view::npos) {
// Malformed/truncated block — nothing trustworthy follows.
return "";
}
size_t eol = body.find('\n', e);
body = (eol == std::string_view::npos)
? std::string_view{}
: ltrim_view(body.substr(eol + 1));
} else {
// Balanced-bracket marker: strip through the matching ']'.
int depth = 0;
size_t close = std::string_view::npos;
for (size_t i = 0; i < body.size(); ++i) {
if (body[i] == '[') ++depth;
else if (body[i] == ']') {
if (--depth == 0) { close = i; break; }
}
}
if (close == std::string_view::npos) {
// Unterminated note — nothing trustworthy follows.
return "";
}
body = ltrim_view(body.substr(close + 1));
}
}
return std::string(body);
}
std::string summarize_transcript(
InferenceClient& inference,
const std::vector<std::pair<std::string, std::string>>& turns) {
if (turns.empty()) return "";
// Summary contract: never longer than the source, target ~1/4 of it.
// Short exchanges (slash commands like /exit, single-line greetings)
// can't be compressed — return them verbatim and skip inference.
std::string conversation;
std::size_t source_chars = 0;
for (const auto& [user_text, asst_text] : turns) {
// Omit an empty user side entirely (assistant-only turns, e.g. an
// interrupted turn whose system-note user side was stripped). Emitting
// a bare "**User:** " line made the model summarize the empty prompt
// and produce degenerate output like "The assistant ".
if (!user_text.empty())
conversation += "**User:** " + user_text + "\n\n";
conversation += "**Assistant:** " + asst_text + "\n\n";
source_chars += user_text.size() + asst_text.size();
}
// Trivial-turn shortcut: a single short pair gets stored verbatim.
// 120 chars ≈ a one-line command + a one-line reply.
if (turns.size() == 1 && source_chars < 120) {
const auto& [u, a] = turns.front();
if (a.empty()) return u;
if (u.empty()) return a;
return u + " | " + a;
}
// Summary size contract: percentages of the raw-turn character count.
// Defaults: target = 30%, hard cap = 60%. Both are floored at 40 chars
// so trivially short exchanges are never squeezed to nothing.
// hard_cap is clamped to >= target_chars so the constraint is never
// impossible (e.g. if the user sets target_pct > max_pct by accident).
constexpr int kDefaultTargetPct = 30;
constexpr int kDefaultMaxPct = 60;
constexpr std::size_t kMinChars = 40;
const int tpct = (config().summarizer_target_pct > 0)
? config().summarizer_target_pct : kDefaultTargetPct;
const int mpct = (config().summarizer_max_pct > 0)
? config().summarizer_max_pct : kDefaultMaxPct;
const std::size_t target_chars =
std::max(kMinChars, source_chars * static_cast<std::size_t>(tpct) / 100);
const std::size_t hard_cap =
std::max(target_chars, source_chars * static_cast<std::size_t>(mpct) / 100);
// Resolve the system prompt: INI `prompt` key overrides the built-in
// default. Empty (or missing) uses the default; any non-empty value is
// passed to the model as-is, including a single space to suppress it.
const std::string& raw_prompt = config().summarizer_prompt;
const std::string system_text = std::vformat(
raw_prompt.empty()
? Config::kDefaultSummarizerPrompt
: std::string_view(raw_prompt),
std::make_format_args(target_chars, hard_cap));
std::vector<Message> messages;
if (!system_text.empty())
messages.push_back({"system", system_text});
messages.push_back({"user", conversation});
std::string result;
try {
result = strip_thinking(inference.chat_memory(messages));
} catch (const std::exception& e) {
Diskerror::Logger::warn(std::format(lang::WARN_SUMMARY, e.what()));
return "";
}
// Enforce the contract. If the model ignored the cap, prefer a
// concatenation of the source turns (it's already shorter than the
// bloated paraphrase and more useful for later retrieval).
if (result.size() > hard_cap) {
std::string fallback;
for (const auto& [u, a] : turns) {
if (!fallback.empty()) fallback += " | ";
fallback += u;
if (!a.empty()) fallback += " → " + a;
}
return (fallback.size() <= hard_cap) ? fallback : result.substr(0, hard_cap);
}
return result;
}
// ---------------------------------------------------------------------------
// summarize_texts — same contract as summarize_transcript but for a flat list
// of pre-existing summary blobs (L2→L3, L3→L4).
// ---------------------------------------------------------------------------
std::string summarize_texts(
InferenceClient& inference,
const std::vector<std::string>& texts)
{
if (texts.empty()) return "";
std::string combined;
std::size_t source_chars = 0;
for (const auto& t : texts) {
if (!combined.empty()) combined += "\n\n";
combined += t;
source_chars += t.size();
}
if (source_chars == 0) return "";
// Same size contract as summarize_transcript.
constexpr int kDefaultTargetPct = 30;
constexpr int kDefaultMaxPct = 60;
constexpr std::size_t kMinChars = 40;
const int tpct = (config().summarizer_target_pct > 0)
? config().summarizer_target_pct : kDefaultTargetPct;
const int mpct = (config().summarizer_max_pct > 0)
? config().summarizer_max_pct : kDefaultMaxPct;
const std::size_t target_chars =
std::max(kMinChars, source_chars * static_cast<std::size_t>(tpct) / 100);
const std::size_t hard_cap =
std::max(target_chars, source_chars * static_cast<std::size_t>(mpct) / 100);
const std::string& raw_prompt = config().summarizer_prompt;
const std::string system_text = std::vformat(
raw_prompt.empty()
? Config::kDefaultSummarizerPrompt
: std::string_view(raw_prompt),
std::make_format_args(target_chars, hard_cap));
std::vector<Message> messages;
if (!system_text.empty())
messages.push_back({"system", system_text});
messages.push_back({"user", combined});
std::string result;
try {
result = strip_thinking(inference.chat_memory(messages));
} catch (const std::exception& e) {
Diskerror::Logger::warn(std::format(lang::WARN_SUMMARY, e.what()));
return "";
}
if (result.size() > hard_cap)
return result.substr(0, hard_cap);
return result;
}
} // namespace ragger