@trazum/core 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +163 -0
- package/dist/advisories.d.ts +23 -0
- package/dist/advisories.d.ts.map +1 -0
- package/dist/advisories.js +376 -0
- package/dist/advisories.js.map +1 -0
- package/dist/aws-sigv4.d.ts +88 -0
- package/dist/aws-sigv4.d.ts.map +1 -0
- package/dist/aws-sigv4.js +117 -0
- package/dist/aws-sigv4.js.map +1 -0
- package/dist/baseline.d.ts +171 -0
- package/dist/baseline.d.ts.map +1 -0
- package/dist/baseline.js +273 -0
- package/dist/baseline.js.map +1 -0
- package/dist/cache.d.ts +26 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +28 -0
- package/dist/cache.js.map +1 -0
- package/dist/changes.d.ts +29 -0
- package/dist/changes.d.ts.map +1 -0
- package/dist/changes.js +142 -0
- package/dist/changes.js.map +1 -0
- package/dist/compare.d.ts +65 -0
- package/dist/compare.d.ts.map +1 -0
- package/dist/compare.js +58 -0
- package/dist/compare.js.map +1 -0
- package/dist/config-schema.d.ts +118 -0
- package/dist/config-schema.d.ts.map +1 -0
- package/dist/config-schema.js +315 -0
- package/dist/config-schema.js.map +1 -0
- package/dist/config.d.ts +47 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +112 -0
- package/dist/config.js.map +1 -0
- package/dist/detect.d.ts +70 -0
- package/dist/detect.d.ts.map +1 -0
- package/dist/detect.js +228 -0
- package/dist/detect.js.map +1 -0
- package/dist/evaluate.d.ts +98 -0
- package/dist/evaluate.d.ts.map +1 -0
- package/dist/evaluate.js +110 -0
- package/dist/evaluate.js.map +1 -0
- package/dist/extract.d.ts +81 -0
- package/dist/extract.d.ts.map +1 -0
- package/dist/extract.js +280 -0
- package/dist/extract.js.map +1 -0
- package/dist/gcp-auth.d.ts +58 -0
- package/dist/gcp-auth.d.ts.map +1 -0
- package/dist/gcp-auth.js +113 -0
- package/dist/gcp-auth.js.map +1 -0
- package/dist/glob.d.ts +49 -0
- package/dist/glob.d.ts.map +1 -0
- package/dist/glob.js +154 -0
- package/dist/glob.js.map +1 -0
- package/dist/host.d.ts +30 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +69 -0
- package/dist/host.js.map +1 -0
- package/dist/i18n/en.d.ts +4 -0
- package/dist/i18n/en.d.ts.map +1 -0
- package/dist/i18n/en.js +168 -0
- package/dist/i18n/en.js.map +1 -0
- package/dist/i18n/es.d.ts +4 -0
- package/dist/i18n/es.d.ts.map +1 -0
- package/dist/i18n/es.js +168 -0
- package/dist/i18n/es.js.map +1 -0
- package/dist/i18n/index.d.ts +36 -0
- package/dist/i18n/index.d.ts.map +1 -0
- package/dist/i18n/index.js +50 -0
- package/dist/i18n/index.js.map +1 -0
- package/dist/i18n/types.d.ts +180 -0
- package/dist/i18n/types.d.ts.map +1 -0
- package/dist/i18n/types.js +11 -0
- package/dist/i18n/types.js.map +1 -0
- package/dist/index.d.ts +66 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +69 -0
- package/dist/index.js.map +1 -0
- package/dist/llm.d.ts +226 -0
- package/dist/llm.d.ts.map +1 -0
- package/dist/llm.js +485 -0
- package/dist/llm.js.map +1 -0
- package/dist/nearest.d.ts +20 -0
- package/dist/nearest.d.ts.map +1 -0
- package/dist/nearest.js +54 -0
- package/dist/nearest.js.map +1 -0
- package/dist/net.d.ts +90 -0
- package/dist/net.d.ts.map +1 -0
- package/dist/net.js +203 -0
- package/dist/net.js.map +1 -0
- package/dist/node.d.ts +32 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +33 -0
- package/dist/node.js.map +1 -0
- package/dist/openrouter.d.ts +25 -0
- package/dist/openrouter.d.ts.map +1 -0
- package/dist/openrouter.js +72 -0
- package/dist/openrouter.js.map +1 -0
- package/dist/optimize.d.ts +38 -0
- package/dist/optimize.d.ts.map +1 -0
- package/dist/optimize.js +183 -0
- package/dist/optimize.js.map +1 -0
- package/dist/otlp.d.ts +91 -0
- package/dist/otlp.d.ts.map +1 -0
- package/dist/otlp.js +102 -0
- package/dist/otlp.js.map +1 -0
- package/dist/phrases.d.ts +169 -0
- package/dist/phrases.d.ts.map +1 -0
- package/dist/phrases.js +939 -0
- package/dist/phrases.js.map +1 -0
- package/dist/pricing-overlay.d.ts +55 -0
- package/dist/pricing-overlay.d.ts.map +1 -0
- package/dist/pricing-overlay.js +241 -0
- package/dist/pricing-overlay.js.map +1 -0
- package/dist/pricing.d.ts +115 -0
- package/dist/pricing.d.ts.map +1 -0
- package/dist/pricing.js +400 -0
- package/dist/pricing.js.map +1 -0
- package/dist/profile.d.ts +71 -0
- package/dist/profile.d.ts.map +1 -0
- package/dist/profile.js +55 -0
- package/dist/profile.js.map +1 -0
- package/dist/promptfoo.d.ts +58 -0
- package/dist/promptfoo.d.ts.map +1 -0
- package/dist/promptfoo.js +149 -0
- package/dist/promptfoo.js.map +1 -0
- package/dist/prune.d.ts +91 -0
- package/dist/prune.d.ts.map +1 -0
- package/dist/prune.js +110 -0
- package/dist/prune.js.map +1 -0
- package/dist/reorder.d.ts +82 -0
- package/dist/reorder.d.ts.map +1 -0
- package/dist/reorder.js +215 -0
- package/dist/reorder.js.map +1 -0
- package/dist/review.d.ts +54 -0
- package/dist/review.d.ts.map +1 -0
- package/dist/review.js +131 -0
- package/dist/review.js.map +1 -0
- package/dist/rules.d.ts +5 -0
- package/dist/rules.d.ts.map +1 -0
- package/dist/rules.js +279 -0
- package/dist/rules.js.map +1 -0
- package/dist/savings.d.ts +36 -0
- package/dist/savings.d.ts.map +1 -0
- package/dist/savings.js +83 -0
- package/dist/savings.js.map +1 -0
- package/dist/segment.d.ts +8 -0
- package/dist/segment.d.ts.map +1 -0
- package/dist/segment.js +74 -0
- package/dist/segment.js.map +1 -0
- package/dist/shared-prefix.d.ts +63 -0
- package/dist/shared-prefix.d.ts.map +1 -0
- package/dist/shared-prefix.js +151 -0
- package/dist/shared-prefix.js.map +1 -0
- package/dist/similarity.d.ts +13 -0
- package/dist/similarity.d.ts.map +1 -0
- package/dist/similarity.js +30 -0
- package/dist/similarity.js.map +1 -0
- package/dist/structure.d.ts +144 -0
- package/dist/structure.d.ts.map +1 -0
- package/dist/structure.js +455 -0
- package/dist/structure.js.map +1 -0
- package/dist/suggest.d.ts +100 -0
- package/dist/suggest.d.ts.map +1 -0
- package/dist/suggest.js +151 -0
- package/dist/suggest.js.map +1 -0
- package/dist/tokenizer.d.ts +57 -0
- package/dist/tokenizer.d.ts.map +1 -0
- package/dist/tokenizer.js +157 -0
- package/dist/tokenizer.js.map +1 -0
- package/dist/types.d.ts +296 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/walk.d.ts +40 -0
- package/dist/walk.d.ts.map +1 -0
- package/dist/walk.js +95 -0
- package/dist/walk.js.map +1 -0
- package/package.json +56 -0
- package/src/advisories.ts +431 -0
- package/src/aws-sigv4.ts +174 -0
- package/src/baseline.ts +390 -0
- package/src/cache.ts +54 -0
- package/src/changes.ts +158 -0
- package/src/compare.ts +131 -0
- package/src/config-schema.ts +451 -0
- package/src/config.ts +161 -0
- package/src/detect.ts +312 -0
- package/src/evaluate.ts +188 -0
- package/src/extract.ts +336 -0
- package/src/gcp-auth.ts +166 -0
- package/src/glob.ts +160 -0
- package/src/host.ts +90 -0
- package/src/i18n/en.ts +236 -0
- package/src/i18n/es.ts +236 -0
- package/src/i18n/index.ts +68 -0
- package/src/i18n/types.ts +230 -0
- package/src/index.ts +228 -0
- package/src/llm.ts +708 -0
- package/src/nearest.ts +61 -0
- package/src/net.ts +233 -0
- package/src/node.ts +63 -0
- package/src/openrouter.ts +125 -0
- package/src/optimize.ts +228 -0
- package/src/otlp.ts +179 -0
- package/src/phrases.ts +1047 -0
- package/src/pricing-overlay.ts +319 -0
- package/src/pricing.ts +468 -0
- package/src/profile.ts +124 -0
- package/src/promptfoo.ts +213 -0
- package/src/prune.ts +211 -0
- package/src/reorder.ts +307 -0
- package/src/review.ts +180 -0
- package/src/rules.ts +324 -0
- package/src/savings.ts +121 -0
- package/src/segment.ts +106 -0
- package/src/shared-prefix.ts +198 -0
- package/src/similarity.ts +28 -0
- package/src/structure.ts +652 -0
- package/src/suggest.ts +254 -0
- package/src/tokenizer.ts +190 -0
- package/src/types.ts +323 -0
- package/src/walk.ts +117 -0
package/src/reorder.ts
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { BACKWARD_REFERENCES_BY_LANGUAGE, UNCOVERED_SCRIPTS } from './phrases.js';
|
|
2
|
+
import { segment } from './segment.js';
|
|
3
|
+
import { estimateTokens } from './tokenizer.js';
|
|
4
|
+
import type { TokenCounter } from './types.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Moving stable instructions in front of the first placeholder.
|
|
8
|
+
*
|
|
9
|
+
* This is the largest saving Trazum knows about and the only one it used to
|
|
10
|
+
* report without acting on. Prompt caching is a byte-for-byte prefix match, so
|
|
11
|
+
* everything after the first `{{placeholder}}` is re-read at full price on every
|
|
12
|
+
* call. Measured on a 1,178-token support prompt: 14 tokens cacheable as written,
|
|
13
|
+
* 1,174 after rearranging the *same content* — $227.65 a month at 50,000 calls.
|
|
14
|
+
*
|
|
15
|
+
* No rule can compete with that, because a rule deletes a few percent of tokens
|
|
16
|
+
* while this changes the price of 98% of them.
|
|
17
|
+
*
|
|
18
|
+
* **It is also the most dangerous thing in this repository**, which is why it is
|
|
19
|
+
* not a rule and not part of `aggressive`. Every other transformation removes
|
|
20
|
+
* text whose absence is local. This one moves text, and order carries meaning:
|
|
21
|
+
* "Summarise the text above" is correct where it sits and nonsense in front of
|
|
22
|
+
* the text it points at. So the whole design here is about what to *refuse*.
|
|
23
|
+
*
|
|
24
|
+
* Three refusals, in order of how much they cost:
|
|
25
|
+
*
|
|
26
|
+
* 1. **A block containing a backward reference stays put** — and so does
|
|
27
|
+
* everything after it. Moving a later block past a pinned one changes their
|
|
28
|
+
* order relative to each other, which is the same class of harm.
|
|
29
|
+
* 2. **Only whole blocks move.** Blocks are separated by blank lines, so a
|
|
30
|
+
* sentence is never severed from the paragraph that qualifies it.
|
|
31
|
+
* 3. **Nothing moves if the prompt has no placeholder**, or if the resulting
|
|
32
|
+
* prefix would not clear the model's cacheable minimum anyway — a
|
|
33
|
+
* rearrangement that buys nothing is a diff for its own sake.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
export interface ReorderedBlock {
|
|
37
|
+
text: string;
|
|
38
|
+
tokens: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface DeclinedBlock {
|
|
42
|
+
text: string;
|
|
43
|
+
/**
|
|
44
|
+
* Why it stayed. `backward-reference` names the phrase found; `after-pinned`
|
|
45
|
+
* means an earlier block was pinned and moving this one would reorder the two.
|
|
46
|
+
*/
|
|
47
|
+
reason: 'backward-reference' | 'after-pinned' | 'uncovered-script';
|
|
48
|
+
/** The phrase that pinned it, for `backward-reference`. */
|
|
49
|
+
phrase?: string;
|
|
50
|
+
/** The script with no phrase list, for `uncovered-script`. */
|
|
51
|
+
script?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ReorderResult {
|
|
55
|
+
/** The rearranged prompt, or the original when nothing could move. */
|
|
56
|
+
text: string;
|
|
57
|
+
/** Blocks moved ahead of the first placeholder, in their original order. */
|
|
58
|
+
moved: ReorderedBlock[];
|
|
59
|
+
/** Blocks left where they were, with the reason. */
|
|
60
|
+
declined: DeclinedBlock[];
|
|
61
|
+
/** Tokens that moved from unpriced-every-call into the cacheable prefix. */
|
|
62
|
+
tokensMoved: number;
|
|
63
|
+
/** Cacheable prefix before and after, so the gain is visible rather than claimed. */
|
|
64
|
+
prefixTokensBefore: number;
|
|
65
|
+
prefixTokensAfter: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Finds the offset of the first template placeholder.
|
|
70
|
+
*
|
|
71
|
+
* Uses the same segmentation as everything else, so "placeholder" means exactly
|
|
72
|
+
* what the protection pass means by it — there is no second definition to drift.
|
|
73
|
+
*/
|
|
74
|
+
function firstPlaceholderOffset(prompt: string): number | null {
|
|
75
|
+
let offset = 0;
|
|
76
|
+
for (const seg of segment(prompt)) {
|
|
77
|
+
if (seg.kind === 'protected' && seg.protection === 'placeholder') return offset;
|
|
78
|
+
offset += seg.text.length;
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Blank-line separated blocks, with their original separators preserved. */
|
|
84
|
+
function toBlocks(text: string): string[] {
|
|
85
|
+
// Splitting on a blank line and keeping the delimiter means rejoining cannot
|
|
86
|
+
// silently normalise somebody's spacing.
|
|
87
|
+
//
|
|
88
|
+
// Written as a line scan rather than the `split(/(?<=\n)(?=\s*\n)/)` it
|
|
89
|
+
// replaces. That regex reads better and is quadratic in the length of a run
|
|
90
|
+
// of blank lines, because the lookahead re-consumes the whole run at every
|
|
91
|
+
// position in it: 3.3 seconds on 60 KB of newlines, in a library reachable
|
|
92
|
+
// over HTTP. Here every character is visited once.
|
|
93
|
+
const blocks: string[] = [];
|
|
94
|
+
let start = 0;
|
|
95
|
+
let i = 0;
|
|
96
|
+
while (i < text.length) {
|
|
97
|
+
const nl = text.indexOf('\n', i);
|
|
98
|
+
const end = nl === -1 ? text.length : nl + 1;
|
|
99
|
+
// A blank line opens a block, and travels with the block it precedes.
|
|
100
|
+
if (i > start && text.slice(i, end).trim() === '') {
|
|
101
|
+
blocks.push(text.slice(start, i));
|
|
102
|
+
start = i;
|
|
103
|
+
}
|
|
104
|
+
i = end;
|
|
105
|
+
}
|
|
106
|
+
if (start < text.length) blocks.push(text.slice(start));
|
|
107
|
+
return blocks;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const hasPlaceholder = (text: string): boolean =>
|
|
111
|
+
segment(text).some((s) => s.kind === 'protected' && s.protection === 'placeholder');
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The backward reference in a block, if any.
|
|
115
|
+
*
|
|
116
|
+
* Lowercased on both sides because a prompt written in title case is still a
|
|
117
|
+
* prompt. Word-boundary matched for languages written with spaces, so
|
|
118
|
+
* "aboveboard" does not pin a block — and *not* for Japanese and Chinese, where
|
|
119
|
+
* the neighbouring character is always a letter and a boundary-matched phrase
|
|
120
|
+
* could never fire.
|
|
121
|
+
*
|
|
122
|
+
* Every language's list runs against every prompt. Detecting the language first
|
|
123
|
+
* would be one more thing to get wrong, and the cost of testing a French prompt
|
|
124
|
+
* against German phrases is a saving not taken, which is the direction this
|
|
125
|
+
* module errs in on purpose.
|
|
126
|
+
*/
|
|
127
|
+
function backwardReference(text: string): string | undefined {
|
|
128
|
+
const haystack = text.toLowerCase();
|
|
129
|
+
const boundary = (c: string | undefined): boolean =>
|
|
130
|
+
c === undefined || !/[\p{L}\p{N}]/u.test(c);
|
|
131
|
+
|
|
132
|
+
for (const set of Object.values(BACKWARD_REFERENCES_BY_LANGUAGE)) {
|
|
133
|
+
for (const phrase of set.phrases) {
|
|
134
|
+
const index = haystack.indexOf(phrase);
|
|
135
|
+
if (index === -1) continue;
|
|
136
|
+
if (!set.wordBoundaries) return phrase;
|
|
137
|
+
if (boundary(haystack[index - 1]) && boundary(haystack[index + phrase.length])) {
|
|
138
|
+
return phrase;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* A script the phrase lists do not cover, if the prompt is written in one.
|
|
147
|
+
*
|
|
148
|
+
* The fourth refusal, and the one that exists because of what the other three
|
|
149
|
+
* were quietly not doing. Every safety argument in this module rests on
|
|
150
|
+
* recognising a backward reference, and for a Russian, Arabic or Korean prompt
|
|
151
|
+
* there was nothing to recognise it with — so `--reorder` rearranged freely and
|
|
152
|
+
* called it a saving. Refusing is the honest answer until the list exists.
|
|
153
|
+
*
|
|
154
|
+
* Deliberately triggered by a *single* character rather than by the prompt being
|
|
155
|
+
* mostly that script. A prompt in English with one Cyrillic instruction in it is
|
|
156
|
+
* exactly the case where a missed reference does damage, and the cost of being
|
|
157
|
+
* wrong here is a saving the author can still take by hand.
|
|
158
|
+
*/
|
|
159
|
+
function uncoveredScript(text: string): string | undefined {
|
|
160
|
+
return UNCOVERED_SCRIPTS.find((script) => script.pattern.test(text))?.name;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface ReorderOptions {
|
|
164
|
+
count?: TokenCounter;
|
|
165
|
+
/**
|
|
166
|
+
* Do not rearrange unless the prefix ends up at least this long. Defaults to
|
|
167
|
+
* 0 — the caller knows the model's cacheable minimum and this module does not.
|
|
168
|
+
*
|
|
169
|
+
* The bar is on the **resulting prefix**, not on the amount moved. Those are
|
|
170
|
+
* different questions, and asking the second one refuses a real saving: a
|
|
171
|
+
* prompt whose head already clears the minimum gains from any block that joins
|
|
172
|
+
* it, however small. Asking "did 200 tokens move?" answers "no" and reports
|
|
173
|
+
* that nothing could move, which is not what happened.
|
|
174
|
+
*/
|
|
175
|
+
minPrefixTokens?: number;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Rearranges a prompt so its stable instructions sit in the cacheable prefix.
|
|
180
|
+
*
|
|
181
|
+
* Returns the original text unchanged when nothing can safely move, and always
|
|
182
|
+
* reports what it declined and why — a saving Trazum silently chose not to take
|
|
183
|
+
* is one the author cannot evaluate.
|
|
184
|
+
*/
|
|
185
|
+
export function reorderForCache(prompt: string, options: ReorderOptions = {}): ReorderResult {
|
|
186
|
+
const count = options.count ?? estimateTokens;
|
|
187
|
+
const minPrefixTokens = options.minPrefixTokens ?? 0;
|
|
188
|
+
|
|
189
|
+
const unchanged = (): ReorderResult => {
|
|
190
|
+
const prefix = firstPlaceholderOffset(prompt);
|
|
191
|
+
const prefixTokens = prefix === null ? count(prompt) : count(prompt.slice(0, prefix));
|
|
192
|
+
return {
|
|
193
|
+
text: prompt,
|
|
194
|
+
moved: [],
|
|
195
|
+
declined: [],
|
|
196
|
+
tokensMoved: 0,
|
|
197
|
+
prefixTokensBefore: prefixTokens,
|
|
198
|
+
prefixTokensAfter: prefixTokens,
|
|
199
|
+
};
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const offset = firstPlaceholderOffset(prompt);
|
|
203
|
+
if (offset === null) return unchanged();
|
|
204
|
+
|
|
205
|
+
// Before anything else: if the prompt is written in a script whose backward
|
|
206
|
+
// references this module cannot recognise, none of the refusals below mean
|
|
207
|
+
// anything. Nothing moves, and the report says which script and why.
|
|
208
|
+
const script = uncoveredScript(prompt);
|
|
209
|
+
if (script !== undefined) {
|
|
210
|
+
return {
|
|
211
|
+
...unchanged(),
|
|
212
|
+
declined: [{ text: prompt.slice(offset), reason: 'uncovered-script', script }],
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// The placeholder's own line stays with the content after it: "Customer
|
|
217
|
+
// message: {{message}}" is one unit, and splitting it would strand the label.
|
|
218
|
+
const lineStart = prompt.lastIndexOf('\n', offset) + 1;
|
|
219
|
+
const head = prompt.slice(0, lineStart);
|
|
220
|
+
const rest = prompt.slice(lineStart);
|
|
221
|
+
|
|
222
|
+
const blocks = toBlocks(rest);
|
|
223
|
+
const prefixTokensBefore = count(head);
|
|
224
|
+
|
|
225
|
+
const moved: ReorderedBlock[] = [];
|
|
226
|
+
const declined: DeclinedBlock[] = [];
|
|
227
|
+
const stay: string[] = [];
|
|
228
|
+
let pinned = false;
|
|
229
|
+
|
|
230
|
+
for (const [index, block] of blocks.entries()) {
|
|
231
|
+
// The first block holds the placeholder itself; it can never move.
|
|
232
|
+
if (index === 0 || hasPlaceholder(block)) {
|
|
233
|
+
stay.push(block);
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (pinned) {
|
|
237
|
+
// Everything after a pinned block stays, because moving it would change
|
|
238
|
+
// its order relative to the block that had to stay.
|
|
239
|
+
stay.push(block);
|
|
240
|
+
if (block.trim() !== '') declined.push({ text: block, reason: 'after-pinned' });
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const phrase = backwardReference(block);
|
|
245
|
+
if (phrase !== undefined) {
|
|
246
|
+
pinned = true;
|
|
247
|
+
stay.push(block);
|
|
248
|
+
declined.push({ text: block, reason: 'backward-reference', phrase });
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (block.trim() === '') {
|
|
252
|
+
stay.push(block);
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
moved.push({ text: block, tokens: count(block) });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const tokensMoved = moved.reduce((sum, b) => sum + b.tokens, 0);
|
|
260
|
+
// Against the prefix this would produce, not against the amount moved. A head
|
|
261
|
+
// that already clears the minimum gains from any block that joins it.
|
|
262
|
+
if (moved.length === 0 || prefixTokensBefore + tokensMoved < minPrefixTokens) {
|
|
263
|
+
// Report the refusals even when nothing moved: "no saving here" and "there
|
|
264
|
+
// was a saving and it was not safe to take" are different answers.
|
|
265
|
+
return { ...unchanged(), declined };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Normalise the seams rather than concatenating raw slices: a block carries the
|
|
269
|
+
// blank line that preceded it, so joining head + block + rest naively leaves a
|
|
270
|
+
// three-newline gap where two belong.
|
|
271
|
+
//
|
|
272
|
+
// In the author's own line ending, though. Rejoining a CRLF prompt with bare
|
|
273
|
+
// newlines would rewrite every seam in a file nobody asked to reformat — and
|
|
274
|
+
// where the whole point is a byte-for-byte cache prefix, a changed byte is a
|
|
275
|
+
// changed price.
|
|
276
|
+
const gap = prompt.includes('\r\n') ? '\r\n\r\n' : '\n\n';
|
|
277
|
+
const movedText = moved
|
|
278
|
+
// `^\s*\n+` drops the blank-line separator the block carries without taking
|
|
279
|
+
// the first line's own indentation with it, which `trimStart` would. It is
|
|
280
|
+
// anchored, so it is tried from one position and stays linear.
|
|
281
|
+
.map((b) => b.text.replace(/^\s*\n+/, '').trimEnd())
|
|
282
|
+
.join(gap);
|
|
283
|
+
const before = head.trimEnd();
|
|
284
|
+
const after = stay.join('').replace(/^\s*\n+/, '').trimEnd();
|
|
285
|
+
// No leading gap when the placeholder was on the very first line: there is no
|
|
286
|
+
// head for the moved blocks to sit after, and emitting one would open the
|
|
287
|
+
// prompt with a blank line.
|
|
288
|
+
const body = before === '' ? `${movedText}${gap}${after}` : `${before}${gap}${movedText}${gap}${after}`;
|
|
289
|
+
// However the prompt ended, it still ends that way. A block carries the blank
|
|
290
|
+
// line that followed it, so trimming the seams without restoring the original
|
|
291
|
+
// ending either strands a newline at the end or drops the one that was there.
|
|
292
|
+
// Collapsing runs of blank lines is the whitespace rule's job, not this one's.
|
|
293
|
+
//
|
|
294
|
+
// Via `trimEnd` rather than `/\s*$/`, which is quadratic on a prompt that
|
|
295
|
+
// holds a long whitespace run and does not end in one: 31 seconds on 200 KB,
|
|
296
|
+
// under the 400 KB the HTTP API accepts.
|
|
297
|
+
const text = `${body}${prompt.slice(prompt.trimEnd().length)}`;
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
text,
|
|
301
|
+
moved,
|
|
302
|
+
declined,
|
|
303
|
+
tokensMoved,
|
|
304
|
+
prefixTokensBefore,
|
|
305
|
+
prefixTokensAfter: count(text.slice(0, firstPlaceholderOffset(text) ?? text.length)),
|
|
306
|
+
};
|
|
307
|
+
}
|
package/src/review.ts
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { findExamples } from './structure.js';
|
|
2
|
+
import { estimateTokens } from './tokenizer.js';
|
|
3
|
+
import type { LlmProvider, TokenCounter } from './types.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Semantic review of few-shot examples.
|
|
7
|
+
*
|
|
8
|
+
* The deterministic detector in `structure.ts` finds near-copies and stops
|
|
9
|
+
* there, on purpose: a paraphrase — the same lesson in different words —
|
|
10
|
+
* scores around 0.54 on word overlap, close enough to two genuinely distinct
|
|
11
|
+
* examples (~0.20) that catching it would mean flagging examples that teach
|
|
12
|
+
* different things.
|
|
13
|
+
*
|
|
14
|
+
* Deciding that "arrived quickly" and "arrived fast" demonstrate the same
|
|
15
|
+
* pattern needs a model. So this lives behind the optional LLM layer, costs a
|
|
16
|
+
* call, and is never on the path of an ordinary `optimize()`.
|
|
17
|
+
*
|
|
18
|
+
* It only ever reports. Nothing here edits a prompt, which is what lets it be
|
|
19
|
+
* relaxed about a model that answers badly: the worst outcome is a suggestion
|
|
20
|
+
* you ignore.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export const EXAMPLE_REVIEW_SYSTEM_PROMPT = `You judge whether few-shot examples in a prompt teach the same thing.
|
|
24
|
+
|
|
25
|
+
You are given numbered examples. Group together any that demonstrate the same pattern — the same kind of input mapping to the same kind of output — even when they are worded completely differently. Examples that differ only in surface detail (names, numbers, dates) teach the same thing.
|
|
26
|
+
|
|
27
|
+
Do NOT group examples that look similar but demonstrate different behaviour, and do NOT group an example that exists to show a boundary or edge case. When in doubt, leave an example on its own: a false grouping costs the reader more than a missed one.
|
|
28
|
+
|
|
29
|
+
Return ONLY a JSON array, no prose and no code fences. Each element is a group of two or more examples that teach the same thing:
|
|
30
|
+
|
|
31
|
+
[{"keep": 0, "redundant": [2], "reason": "both classify a missing delivery as shipping"}]
|
|
32
|
+
|
|
33
|
+
"keep" is the example worth keeping (usually the earliest), "redundant" lists the others, and "reason" is one short clause. Return [] when every example teaches something distinct.`;
|
|
34
|
+
|
|
35
|
+
export interface ExampleRedundancy {
|
|
36
|
+
/** Index of the example worth keeping. */
|
|
37
|
+
keep: number;
|
|
38
|
+
/** Indices the model considers redundant with it. */
|
|
39
|
+
redundant: number[];
|
|
40
|
+
/** The model's one-clause justification, for the reader to judge. */
|
|
41
|
+
reason: string;
|
|
42
|
+
/** Tokens held by the redundant examples. */
|
|
43
|
+
tokens: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ExampleReview {
|
|
47
|
+
provider: string;
|
|
48
|
+
model: string;
|
|
49
|
+
/** Examples found in the prompt, in order. */
|
|
50
|
+
exampleCount: number;
|
|
51
|
+
groups: ExampleRedundancy[];
|
|
52
|
+
/** Tokens across every example marked redundant. */
|
|
53
|
+
redundantTokens: number;
|
|
54
|
+
/** Set when the model answered but nothing usable came back. */
|
|
55
|
+
unusableResponse?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface ReviewExamplesOptions {
|
|
59
|
+
tokenCounter?: TokenCounter;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Pulls the first JSON array out of a response, fences and prose included. */
|
|
63
|
+
function extractJsonArray(raw: string): unknown {
|
|
64
|
+
const trimmed = raw.trim();
|
|
65
|
+
const fenced = /^(?:```|~~~)[a-zA-Z]*\n([\s\S]*?)\n?(?:```|~~~)$/.exec(trimmed);
|
|
66
|
+
const body = fenced?.[1] ?? trimmed;
|
|
67
|
+
|
|
68
|
+
const start = body.indexOf('[');
|
|
69
|
+
const end = body.lastIndexOf(']');
|
|
70
|
+
if (start === -1 || end === -1 || end < start) return null;
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
return JSON.parse(body.slice(start, end + 1));
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Validates one group against the examples that actually exist.
|
|
81
|
+
*
|
|
82
|
+
* Everything the model returns is treated as a suggestion from an untrusted
|
|
83
|
+
* source: indices are range-checked, self-references dropped, duplicates
|
|
84
|
+
* collapsed, and the reason truncated. A model that answers with nonsense
|
|
85
|
+
* produces an empty review, not a crash and not a bogus saving.
|
|
86
|
+
*/
|
|
87
|
+
function parseGroup(value: unknown, exampleCount: number): Omit<ExampleRedundancy, 'tokens'> | null {
|
|
88
|
+
if (typeof value !== 'object' || value === null) return null;
|
|
89
|
+
const record = value as Record<string, unknown>;
|
|
90
|
+
|
|
91
|
+
const keep = record.keep;
|
|
92
|
+
if (typeof keep !== 'number' || !Number.isInteger(keep) || keep < 0 || keep >= exampleCount) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const rawRedundant = Array.isArray(record.redundant) ? record.redundant : [];
|
|
97
|
+
const redundant = [
|
|
98
|
+
...new Set(
|
|
99
|
+
rawRedundant.filter(
|
|
100
|
+
(index): index is number =>
|
|
101
|
+
typeof index === 'number' &&
|
|
102
|
+
Number.isInteger(index) &&
|
|
103
|
+
index >= 0 &&
|
|
104
|
+
index < exampleCount &&
|
|
105
|
+
index !== keep,
|
|
106
|
+
),
|
|
107
|
+
),
|
|
108
|
+
].sort((a, b) => a - b);
|
|
109
|
+
|
|
110
|
+
if (redundant.length === 0) return null;
|
|
111
|
+
|
|
112
|
+
const reason = typeof record.reason === 'string' ? record.reason.trim().slice(0, 160) : '';
|
|
113
|
+
return { keep, redundant, reason };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Asks the configured model which examples teach the same thing.
|
|
118
|
+
*
|
|
119
|
+
* Returns `null` when there is nothing to review — fewer than two examples —
|
|
120
|
+
* so the caller can skip the call rather than pay for a certain answer.
|
|
121
|
+
*
|
|
122
|
+
* Never throws on a bad answer. A provider that errors will still throw,
|
|
123
|
+
* because that is a configuration problem the caller should see.
|
|
124
|
+
*/
|
|
125
|
+
export async function reviewExamples(
|
|
126
|
+
prompt: string,
|
|
127
|
+
provider: LlmProvider,
|
|
128
|
+
options: ReviewExamplesOptions = {},
|
|
129
|
+
): Promise<ExampleReview | null> {
|
|
130
|
+
const count = options.tokenCounter ?? estimateTokens;
|
|
131
|
+
const examples = findExamples(prompt, count);
|
|
132
|
+
if (examples.length < 2) return null;
|
|
133
|
+
|
|
134
|
+
const numbered = examples
|
|
135
|
+
.map((example, index) => `--- Example ${index} ---\n${example.text}`)
|
|
136
|
+
.join('\n\n');
|
|
137
|
+
|
|
138
|
+
const raw = await provider.complete({
|
|
139
|
+
system: EXAMPLE_REVIEW_SYSTEM_PROMPT,
|
|
140
|
+
user: numbered,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const base = {
|
|
144
|
+
provider: provider.name,
|
|
145
|
+
model: provider.model,
|
|
146
|
+
exampleCount: examples.length,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const parsed = extractJsonArray(raw);
|
|
150
|
+
if (!Array.isArray(parsed)) {
|
|
151
|
+
return { ...base, groups: [], redundantTokens: 0, unusableResponse: raw.trim().slice(0, 200) };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// An example can only be claimed once. Without this a model that returns
|
|
155
|
+
// overlapping groups would have the same tokens counted twice, and the
|
|
156
|
+
// saving would read higher than the prompt could possibly deliver.
|
|
157
|
+
const claimed = new Set<number>();
|
|
158
|
+
const groups: ExampleRedundancy[] = [];
|
|
159
|
+
|
|
160
|
+
for (const entry of parsed) {
|
|
161
|
+
const group = parseGroup(entry, examples.length);
|
|
162
|
+
if (!group) continue;
|
|
163
|
+
|
|
164
|
+
const fresh = group.redundant.filter((index) => !claimed.has(index) && index !== group.keep);
|
|
165
|
+
if (fresh.length === 0) continue;
|
|
166
|
+
fresh.forEach((index) => claimed.add(index));
|
|
167
|
+
|
|
168
|
+
groups.push({
|
|
169
|
+
...group,
|
|
170
|
+
redundant: fresh,
|
|
171
|
+
tokens: fresh.reduce((sum, index) => sum + examples[index]!.tokens, 0),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
...base,
|
|
177
|
+
groups,
|
|
178
|
+
redundantTokens: groups.reduce((sum, group) => sum + group.tokens, 0),
|
|
179
|
+
};
|
|
180
|
+
}
|