@trazum/core 1.86.0 → 2.1.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/README.md +3 -3
- package/dist/config-schema.d.ts +10 -10
- package/dist/config-schema.d.ts.map +1 -1
- package/dist/config-schema.js +90 -36
- package/dist/config-schema.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/optimize.d.ts.map +1 -1
- package/dist/optimize.js +16 -1
- package/dist/optimize.js.map +1 -1
- package/dist/phrases.d.ts.map +1 -1
- package/dist/phrases.js +36 -0
- package/dist/phrases.js.map +1 -1
- package/dist/receipt.d.ts +33 -5
- package/dist/receipt.d.ts.map +1 -1
- package/dist/receipt.js +44 -18
- package/dist/receipt.js.map +1 -1
- package/dist/reprice.d.ts +74 -0
- package/dist/reprice.d.ts.map +1 -1
- package/dist/reprice.js +97 -20
- package/dist/reprice.js.map +1 -1
- package/dist/segment.d.ts.map +1 -1
- package/dist/segment.js +95 -1
- package/dist/segment.js.map +1 -1
- package/dist/structure.d.ts +27 -0
- package/dist/structure.d.ts.map +1 -1
- package/dist/structure.js +277 -9
- package/dist/structure.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/config-schema.ts +100 -44
- package/src/index.ts +8 -2
- package/src/optimize.ts +16 -1
- package/src/phrases.ts +36 -0
- package/src/receipt.ts +75 -18
- package/src/reprice.ts +164 -17
- package/src/segment.ts +95 -1
- package/src/structure.ts +275 -9
- package/src/types.ts +2 -0
package/src/segment.ts
CHANGED
|
@@ -23,8 +23,102 @@ interface PatternDef {
|
|
|
23
23
|
const PATTERNS: PatternDef[] = [
|
|
24
24
|
// Fenced code blocks with ``` or ~~~ (unclosed blocks run to the end).
|
|
25
25
|
{ kind: 'fenced-code', regex: /(?:```|~~~)[\s\S]*?(?:```|~~~|$)/g },
|
|
26
|
-
|
|
26
|
+
/*
|
|
27
|
+
Indented code blocks, which this file used to leave to the rules on the
|
|
28
|
+
grounds that they are *"ambiguous in markdown"*. The ambiguity is real. The
|
|
29
|
+
consequence of acting on it was not proportionate to it.
|
|
30
|
+
|
|
31
|
+
**What the rules did to four lines of indented code**, at the aggressive
|
|
32
|
+
level, every one measured rather than imagined:
|
|
33
|
+
|
|
34
|
+
const label = "please keep"; -> Const label = " keep";
|
|
35
|
+
def run(x): -> Def run(x):
|
|
36
|
+
SELECT … WHERE note = 'please refund'; -> … = ' refund';
|
|
37
|
+
{"reason": "please cancel"} -> {"reason": " cancel"}
|
|
38
|
+
|
|
39
|
+
Three separate failures at once. The indentation goes, which by itself
|
|
40
|
+
makes the Python a syntax error. Keywords get sentence-capitalised, which
|
|
41
|
+
makes the rest of them syntax errors. And **string literals are edited** —
|
|
42
|
+
that SQL now matches a different value and that payload carries a different
|
|
43
|
+
reason. The report says tokens were saved.
|
|
44
|
+
|
|
45
|
+
The blank line before the block is what makes this checkable rather than a
|
|
46
|
+
guess: CommonMark says an indented code block cannot interrupt a paragraph,
|
|
47
|
+
so a run of indented lines after a blank line is code by the specification
|
|
48
|
+
rather than by anybody's judgement here.
|
|
49
|
+
|
|
50
|
+
**What it still gets wrong, stated rather than discovered later.** Content
|
|
51
|
+
indented four or more spaces inside a deeply nested list is continuation,
|
|
52
|
+
not code, and this protects it. That costs unsaved tokens in a rare shape.
|
|
53
|
+
Not protecting code costs a broken prompt in a common one, and those are not
|
|
54
|
+
the same kind of wrong.
|
|
55
|
+
|
|
56
|
+
Every quantifier is bounded or consumes a whole line exactly once, for the
|
|
57
|
+
reason the email mask below now carries in full.
|
|
58
|
+
*/
|
|
59
|
+
{
|
|
60
|
+
kind: 'indented-code',
|
|
61
|
+
/*
|
|
62
|
+
**The lookbehind requires real content before the blank line, and the
|
|
63
|
+
fuzzer is why.** Its first version asked only for a blank line, which a
|
|
64
|
+
document *beginning* with one satisfies — and `optimize` ends with a
|
|
65
|
+
`.trim()` on the whole reassembled string, outside every mask, which then
|
|
66
|
+
removes the leading newlines and the first line's indentation. The block
|
|
67
|
+
stopped being a block, the second pass no longer protected it, and 54 of
|
|
68
|
+
1,500 corpus inputs failed the idempotence property.
|
|
69
|
+
|
|
70
|
+
The trim is fixed at its source now — `optimize` trims the *masked* string,
|
|
71
|
+
where a protected span is a placeholder nothing can edit — so the mask can
|
|
72
|
+
also claim a block at the very start of a document. It has to: at the
|
|
73
|
+
aggressive level a rule can delete the sentence before the blank line, and
|
|
74
|
+
a block that was protected on the first pass has to still be protected on
|
|
75
|
+
the second or the output never settles.
|
|
76
|
+
*/
|
|
77
|
+
regex: /(?<=\S[ \t]*\n[ \t]*\n)(?:(?: {4,}|\t)[^\n]*(?:\n|$))+/g,
|
|
78
|
+
},
|
|
27
79
|
{ kind: 'url', regex: /\b(?:https?|ftp):\/\/[^\s<>"')\]]+/g, trimTrailing: /[.,;:!?]+$/ },
|
|
80
|
+
/*
|
|
81
|
+
Email addresses, for the reason at the top of this file.
|
|
82
|
+
|
|
83
|
+
**Five of ten realistic addresses came out corrupted before this existed.**
|
|
84
|
+
`please@example.com` became `@example.com`, and so did `thanks@`,
|
|
85
|
+
`basically@`, `essentially.ops@` and `very.important@`: the politeness,
|
|
86
|
+
filler and intensifier rules match the local part as ordinary prose and cut
|
|
87
|
+
it out. What is left is not a wrong address, it is not an address — and the
|
|
88
|
+
report says tokens were saved.
|
|
89
|
+
|
|
90
|
+
That is the failure this module's own first paragraph names. A code block, a
|
|
91
|
+
URL and a placeholder were on the list; the thing every support prompt in
|
|
92
|
+
the world carries was not. `support@` and `no-reply@` survived, and
|
|
93
|
+
`por.favor@ejemplo.es` survived only because the Spanish politeness entry is
|
|
94
|
+
written with a space — luck, not design, which is what made it invisible.
|
|
95
|
+
|
|
96
|
+
After the URL pattern so a `mailto:` link is claimed as a URL first, and
|
|
97
|
+
trailing punctuation is trimmed for the same reason it is there: the full
|
|
98
|
+
stop in "write to a@b.com." belongs to the sentence.
|
|
99
|
+
*/
|
|
100
|
+
{
|
|
101
|
+
kind: 'email',
|
|
102
|
+
/*
|
|
103
|
+
**Every quantifier here is bounded, and the first version of this was not.**
|
|
104
|
+
The local part accepts `.`, so on a long dotted run with no `@` in it —
|
|
105
|
+
a dotted run behind a scheme, which is a fixture in `security.test.js`
|
|
106
|
+
for exactly this reason — an unbounded `+` matches the whole run from every starting
|
|
107
|
+
position and then fails. Quadratic: **897ms on 40,008 characters**, and
|
|
108
|
+
`optimize` runs this alongside every other pattern, which took the total
|
|
109
|
+
past the 5-second cliff detector on a CI runner while passing on a faster
|
|
110
|
+
machine. That test's own note had already recorded the lesson from the
|
|
111
|
+
last time — *"quadratic until the label quantifiers were bounded"* — and
|
|
112
|
+
this pattern arrived a week later without them.
|
|
113
|
+
|
|
114
|
+
The bounds are RFC 5321's, not invented: 64 octets for a local part, 63
|
|
115
|
+
for a domain label. The TLD's 24 is generous against the longest real one
|
|
116
|
+
(`.travelersinsurance`, 19). Bounded, the same input takes **8ms**, and no
|
|
117
|
+
address in the corpus is lost.
|
|
118
|
+
*/
|
|
119
|
+
regex: /\b[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9-]{1,63}(?:\.[A-Za-z0-9-]{1,63})*\.[A-Za-z]{2,24}/g,
|
|
120
|
+
trimTrailing: /[.,;:!?]+$/,
|
|
121
|
+
},
|
|
28
122
|
// Template placeholders: {{var}}, {var}, ${var}, {% tag %}, <<VAR>>, %(var)s
|
|
29
123
|
{
|
|
30
124
|
kind: 'placeholder',
|
package/src/structure.ts
CHANGED
|
@@ -54,6 +54,31 @@ interface AxisDefinition {
|
|
|
54
54
|
*/
|
|
55
55
|
const RESPOND = String.raw`(?:respond|reply|answer|write|speak|output|responde|contesta|responder|escribe|redacta)`;
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* How a prompt refers to the reader having produced their message.
|
|
59
|
+
*
|
|
60
|
+
* `used|wrote|speaks` missed on a tense: *"respond in the same language the
|
|
61
|
+
* user writes in"* — present, which is how a standing instruction is normally
|
|
62
|
+
* written — was invisible, so a prompt pinning English and mirroring the user
|
|
63
|
+
* in the same breath reported no conflict at all.
|
|
64
|
+
*/
|
|
65
|
+
const SPOKE = String.raw`(?:used?|uses|wrote|writes|writing|speaks|spoke|sent|asked)`;
|
|
66
|
+
|
|
67
|
+
/** Nouns that mean *the thing the model produces*, as opposed to any other text. */
|
|
68
|
+
const THE_ANSWER = String.raw`(?:answers?|responses?|repl(?:y|ies)|explanations?|respuestas?)`;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A numeric ceiling on length, without the unit.
|
|
72
|
+
*
|
|
73
|
+
* Kept separate because it is only ever safe next to an anchor: *"the title
|
|
74
|
+
* field takes at most five words"* is the same phrase about a form field, and
|
|
75
|
+
* reading it as an instruction about the answer is how a widening turns a
|
|
76
|
+
* detector into a nuisance.
|
|
77
|
+
*/
|
|
78
|
+
const REQUIRE_SHORT = String.raw`keep\s+(?:it|them|${THE_ANSWER})\s+(?:short|brief|concise)`;
|
|
79
|
+
|
|
80
|
+
const AT_MOST = String.raw`(?:at\s+most|no\s+more\s+than|under|within|máximo\s+de?)\s+(?:one|two|three|four|five|\d+)\s+(?:sentences?|words?|frases?|palabras?)`;
|
|
81
|
+
|
|
57
82
|
const AXES: readonly AxisDefinition[] = [
|
|
58
83
|
{
|
|
59
84
|
axis: 'response-language',
|
|
@@ -71,7 +96,7 @@ const AXES: readonly AxisDefinition[] = [
|
|
|
71
96
|
// The qualifier slot ("own", "native", "preferred") is what makes
|
|
72
97
|
// this survive real prompts: "the customer's own language" is at
|
|
73
98
|
// least as common as the bare form.
|
|
74
|
-
String.raw`\bin\s+the\s+(?:same\s+)?language\s+(?:of|as|the\s+\w+\s+(?:used|wrote|speaks))|\bin\s+(?:the\s+(?:user|customer|client)'?s?|their)\s+(?:own\s+|native\s+|preferred\s+)?language\b|\bmatch\s+the\s+language\b|\ben\s+(?:el\s+)?(?:mismo\s+)?idioma\s+(?:del?\s+|en\s+que\s+)?(?:usuario|cliente|consulta|mensaje|escrib)`,
|
|
99
|
+
String.raw`\bin\s+the\s+(?:same\s+)?language\s+(?:of|as|the\s+\w+\s+(?:used|wrote|speaks))|\bin\s+(?:the\s+(?:user|customer|client)'?s?|their)\s+(?:own\s+|native\s+|preferred\s+)?language\b|\bmatch\s+the\s+language\b|\b${RESPOND}\b[^.!?\n]{0,40}?\bin\s+(?:the\s+(?:same\s+)?|whatever\s+|whichever\s+)language\s+(?:of|as|that\s+|the\s+\w+\s+)?\w*\s*${SPOKE}|\ben\s+(?:el\s+)?(?:mismo\s+)?idioma\s+(?:del?\s+|en\s+que\s+)?(?:usuario|cliente|consulta|mensaje|escrib)`,
|
|
75
100
|
'i',
|
|
76
101
|
),
|
|
77
102
|
],
|
|
@@ -109,14 +134,14 @@ const AXES: readonly AxisDefinition[] = [
|
|
|
109
134
|
[
|
|
110
135
|
'length-brief',
|
|
111
136
|
new RegExp(
|
|
112
|
-
String.raw`\b(?:be\s+(?:brief|concise|succinct|terse)|keep\s+it\s+(?:short|brief)|as\s+short\s+as\s+possible|in\s+(?:one|a\s+single)\s+sentence|s[ée]\s+(?:breve|conciso)|de\s+forma\s+(?:breve|concisa)|brevemente)\b`,
|
|
137
|
+
String.raw`\b(?:be\s+(?:brief|concise|succinct|terse)|keep\s+it\s+(?:short|brief)|as\s+short\s+as\s+possible|${REQUIRE_SHORT}|\b${RESPOND}\b[^.!?\n]{0,40}?${AT_MOST}|in\s+(?:one|a\s+single)\s+sentence|s[ée]\s+(?:breve|conciso)|de\s+forma\s+(?:breve|concisa)|brevemente)\b`,
|
|
113
138
|
'i',
|
|
114
139
|
),
|
|
115
140
|
],
|
|
116
141
|
[
|
|
117
142
|
'length-detailed',
|
|
118
143
|
new RegExp(
|
|
119
|
-
String.raw`\b(?:be\s+(?:detailed|comprehensive|thorough|exhaustive)|in\s+(?:great\s+)?depth|as\s+much\s+detail\s+as\s+possible|s[ée]\s+(?:exhaustivo|detallado)|de\s+forma\s+(?:detallada|exhaustiva)|con\s+todo\s+detalle|detalladamente)\b`,
|
|
144
|
+
String.raw`\b(?:be\s+(?:detailed|comprehensive|thorough|exhaustive)|in\s+(?:great\s+)?depth|(?:detailed|comprehensive|thorough|exhaustive|in-depth)[^.!?\n]{0,30}?\b${THE_ANSWER}\b|as\s+much\s+detail\s+as\s+possible|s[ée]\s+(?:exhaustivo|detallado)|de\s+forma\s+(?:detallada|exhaustiva)|con\s+todo\s+detalle|detalladamente)\b`,
|
|
120
145
|
'i',
|
|
121
146
|
),
|
|
122
147
|
],
|
|
@@ -128,14 +153,14 @@ const AXES: readonly AxisDefinition[] = [
|
|
|
128
153
|
[
|
|
129
154
|
'reasoning-shown',
|
|
130
155
|
new RegExp(
|
|
131
|
-
String.raw`\b(?:explain\s+your\s+(?:reasoning|answer|thinking)|show\s+your\s+(?:work|reasoning|thinking)|think\s+step[\s-]by[\s-]step|justify\s+your\s+answer|explica\s+tu\s+razonamiento|razona\s+paso\s+a\s+paso|justifica\s+tu\s+respuesta)\b`,
|
|
156
|
+
String.raw`\b(?:explain\s+your\s+(?:reasoning|answer|thinking)|show\s+your\s+(?:work|reasoning|thinking)|show\s+your\s+chain\s+of\s+thought|walk\s+(?:me\s+)?through\s+your\s+(?:reasoning|thinking|logic)|think\s+step[\s-]by[\s-]step|justify\s+your\s+answer|explica\s+tu\s+razonamiento|razona\s+paso\s+a\s+paso|justifica\s+tu\s+respuesta)\b`,
|
|
132
157
|
'i',
|
|
133
158
|
),
|
|
134
159
|
],
|
|
135
160
|
[
|
|
136
161
|
'reasoning-hidden',
|
|
137
162
|
new RegExp(
|
|
138
|
-
String.raw`\b(?:no\s+(?:explanation|commentary|preamble)|without\s+(?:explanation|commentary)|do\s+not\s+explain|don'?t\s+explain|no\s+expliques|sin\s+(?:explicaciones|comentarios)|sin\s+preámbulo)\b`,
|
|
163
|
+
String.raw`\b(?:no\s+(?:explanation|commentary|preamble)|without\s+(?:explanation|commentary|justification|reasoning)|no\s+justification|do\s+not\s+explain|don'?t\s+explain|no\s+expliques|sin\s+(?:explicaciones|comentarios)|sin\s+preámbulo)\b`,
|
|
139
164
|
'i',
|
|
140
165
|
),
|
|
141
166
|
],
|
|
@@ -249,10 +274,83 @@ const LABEL_SUFFIX = String.raw`[ \t]{0,4}\d{0,6}[ \t]{0,4}[:.)]`;
|
|
|
249
274
|
* indistinguishable from ordinary prose, and guessing would make the advisory
|
|
250
275
|
* fire on prompts containing no examples at all.
|
|
251
276
|
*/
|
|
277
|
+
/**
|
|
278
|
+
* The label on the side that **asks**, in the wordings prompts actually use.
|
|
279
|
+
*
|
|
280
|
+
* **This list was three entries long and it made six analyses blind.** A
|
|
281
|
+
* support prompt labelled `Customer:` / `Agent:` — the single most common
|
|
282
|
+
* shape there is — found zero examples, so `prune` had nothing to cut,
|
|
283
|
+
* `profile` reported the examples as costing nothing, and the redundant-example
|
|
284
|
+
* advisory never fired. Measured across fourteen labellings real prompts use,
|
|
285
|
+
* nine found nothing: `Customer`, `Human`, `Question`, `Client`, `Prompt`,
|
|
286
|
+
* `Query`, `Cliente`, `Pregunta`, `REQUEST`. `Human:` is Anthropic's own
|
|
287
|
+
* historical convention and `Q:` worked while `Question:` did not, which is
|
|
288
|
+
* the arbitrariness that gives the fault away.
|
|
289
|
+
*
|
|
290
|
+
* The failure was silent, which is what made it survive: an unrecognised label
|
|
291
|
+
* produces no examples, and no examples produces no finding — the same shape
|
|
292
|
+
* as a gate that passes because it is checking nothing.
|
|
293
|
+
*
|
|
294
|
+
* Only openers belong here. `Answer:` and `Assistant:` close a turn, and
|
|
295
|
+
* splitting on those would cut every example between its question and its
|
|
296
|
+
* answer and then compare the halves.
|
|
297
|
+
*/
|
|
298
|
+
const ASKER_LABELS = [
|
|
299
|
+
'user',
|
|
300
|
+
'usuario',
|
|
301
|
+
'human',
|
|
302
|
+
'humano',
|
|
303
|
+
'customer',
|
|
304
|
+
'cliente',
|
|
305
|
+
'client',
|
|
306
|
+
'q',
|
|
307
|
+
'question',
|
|
308
|
+
'pregunta',
|
|
309
|
+
'query',
|
|
310
|
+
'consulta',
|
|
311
|
+
'request',
|
|
312
|
+
'petición',
|
|
313
|
+
'peticion',
|
|
314
|
+
'prompt',
|
|
315
|
+
] as const;
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* The label on the side that **answers**.
|
|
319
|
+
*
|
|
320
|
+
* Never a tier: these appear inside an example rather than opening one. They
|
|
321
|
+
* are here so `EXAMPLE_FIELD_LINE` can be built from the same two lists the
|
|
322
|
+
* splitter uses, which is what stops the pair drifting apart again — the field
|
|
323
|
+
* detector already knew `question` and `answer` while the splitter did not,
|
|
324
|
+
* and that disagreement is precisely how `Q:` came to work and `Question:` not.
|
|
325
|
+
*/
|
|
326
|
+
const ANSWERER_LABELS = [
|
|
327
|
+
'assistant',
|
|
328
|
+
'asistente',
|
|
329
|
+
'a',
|
|
330
|
+
'answer',
|
|
331
|
+
'respuesta',
|
|
332
|
+
'output',
|
|
333
|
+
'salida',
|
|
334
|
+
'response',
|
|
335
|
+
'reply',
|
|
336
|
+
'agent',
|
|
337
|
+
'agente',
|
|
338
|
+
'support',
|
|
339
|
+
'soporte',
|
|
340
|
+
'bot',
|
|
341
|
+
'result',
|
|
342
|
+
'resultado',
|
|
343
|
+
] as const;
|
|
344
|
+
|
|
345
|
+
/** Labels that name an example as a whole, rather than one side of it. */
|
|
346
|
+
const BLOCK_LABELS = ['example', 'ejemplo', 'input', 'entrada'] as const;
|
|
347
|
+
|
|
348
|
+
const anyOf = (labels: readonly string[]) => labels.join('|');
|
|
349
|
+
|
|
252
350
|
const EXAMPLE_HEADER_TIERS: readonly RegExp[] = [
|
|
253
351
|
new RegExp(`${LABEL_PREFIX}(?:example|ejemplo)${LABEL_SUFFIX}`, 'i'),
|
|
254
352
|
new RegExp(`${LABEL_PREFIX}(?:input|entrada)${LABEL_SUFFIX}`, 'i'),
|
|
255
|
-
new RegExp(`${LABEL_PREFIX}(
|
|
353
|
+
new RegExp(`${LABEL_PREFIX}(?:${anyOf(ASKER_LABELS)})${LABEL_SUFFIX}`, 'i'),
|
|
256
354
|
];
|
|
257
355
|
|
|
258
356
|
/**
|
|
@@ -265,7 +363,7 @@ const EXAMPLE_HEADER_TIERS: readonly RegExp[] = [
|
|
|
265
363
|
* repetition it removed.
|
|
266
364
|
*/
|
|
267
365
|
export const EXAMPLE_FIELD_LINE = new RegExp(
|
|
268
|
-
`${LABEL_PREFIX}(
|
|
366
|
+
`${LABEL_PREFIX}(?:${anyOf([...BLOCK_LABELS, ...ASKER_LABELS, ...ANSWERER_LABELS])})${LABEL_SUFFIX}`,
|
|
269
367
|
'i',
|
|
270
368
|
);
|
|
271
369
|
|
|
@@ -307,6 +405,30 @@ export interface ExampleAnalysis {
|
|
|
307
405
|
* "arrived fast" teach the same thing needs a model, not a word-set overlap;
|
|
308
406
|
* that belongs to the optional LLM pass, not here.
|
|
309
407
|
*/
|
|
408
|
+
/**
|
|
409
|
+
* How alike two examples must be before one is called redundant.
|
|
410
|
+
*
|
|
411
|
+
* **Do not lower this hoping to catch paraphrases: it was measured and it does
|
|
412
|
+
* not work.** Four support examples whose answers were *"Let me look that up
|
|
413
|
+
* for you. Could you share your order number?"*, *"I can check that for you.
|
|
414
|
+
* What's your order number?"*, *"Happy to help. May I have your order
|
|
415
|
+
* number?"* and *"Of course. What is your order number?"* are one example
|
|
416
|
+
* wearing four coats — any reader says so instantly. Their word overlap is
|
|
417
|
+
* 0.29–0.43, and comparing only the answers makes it **worse**, 0.21–0.39,
|
|
418
|
+
* because the shared content is three words (`your`, `order`, `number`) while
|
|
419
|
+
* the openers are all different. There is no threshold that separates those
|
|
420
|
+
* from genuinely different examples: at 0.4 this fires on every prompt whose
|
|
421
|
+
* examples share a domain vocabulary.
|
|
422
|
+
*
|
|
423
|
+
* That similarity is a semantic judgement, and this repository has a place for
|
|
424
|
+
* those — `trazum semantic`, which says what it costs and asks first. What
|
|
425
|
+
* word overlap can honestly find is a paragraph somebody pasted twice and
|
|
426
|
+
* edited lightly, which is what 0.7 is for.
|
|
427
|
+
*
|
|
428
|
+
* So the deterministic half reports **what the examples cost**, which is a
|
|
429
|
+
* fact, and leaves *whether one should go* to `prune`, which answers it by
|
|
430
|
+
* running the cases rather than by counting words.
|
|
431
|
+
*/
|
|
310
432
|
const EXAMPLE_SIMILARITY = 0.7;
|
|
311
433
|
|
|
312
434
|
/** Splits the prompt on one header style. */
|
|
@@ -356,10 +478,70 @@ function trimToExample(block: string): string {
|
|
|
356
478
|
return kept.join('\n\n').trim();
|
|
357
479
|
}
|
|
358
480
|
|
|
481
|
+
/**
|
|
482
|
+
* Examples delimited by an `<example>` tag rather than by a label.
|
|
483
|
+
*
|
|
484
|
+
* **This is Anthropic's own documented convention for few-shot prompting**, and
|
|
485
|
+
* the product whose headline model is Claude could not see it: a code-review
|
|
486
|
+
* prompt wrapping three demonstrations in `<example>` found zero examples, and
|
|
487
|
+
* the same six analyses went quiet that `Customer:` had silenced. A shape a
|
|
488
|
+
* provider tells people to use is not an edge case.
|
|
489
|
+
*
|
|
490
|
+
* Tried before the label tiers because it is the least ambiguous thing in this
|
|
491
|
+
* file. A label is a word that might be prose; `<example>` is a delimiter, so
|
|
492
|
+
* there is no threshold and no judgement — either the tag is there or it is not.
|
|
493
|
+
*
|
|
494
|
+
* Only `example` and `examples`, which are what the documentation names.
|
|
495
|
+
* `<sample>` and `<demonstration>` appear in the wild and are not added: the
|
|
496
|
+
* list would then be a guess about what people might write, and this splitter
|
|
497
|
+
* has already been burnt once by a vocabulary somebody thought was wide enough.
|
|
498
|
+
* A tag arriving here should arrive with a prompt that uses it.
|
|
499
|
+
*
|
|
500
|
+
* A tag inside a fenced block is documentation about the tag rather than a use
|
|
501
|
+
* of it — a prompt explaining how to write examples is not a prompt containing
|
|
502
|
+
* them — so fenced regions are removed before matching.
|
|
503
|
+
*/
|
|
504
|
+
function xmlExamples(prompt: string): string[] {
|
|
505
|
+
const outsideFences = prompt.replace(/^([ \t]{0,3})(`{3,}|~{3,})[\s\S]*?^\1\2[ \t]*$/gm, '');
|
|
506
|
+
const tag = /<example(?:\s[^>]*)?>([\s\S]*?)<\/example>/gi;
|
|
507
|
+
return [...outsideFences.matchAll(tag)].map(([, body]) => (body ?? '').trim()).filter(Boolean);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* **A shape this deliberately does not detect, and the reason is the point.**
|
|
512
|
+
*
|
|
513
|
+
* Inline mappings are everywhere — `1. "package never arrived" -> shipping`,
|
|
514
|
+
* `- "Acme raised a round" => Acme` — and on the probes that found the two
|
|
515
|
+
* faults above they are the remaining silent case. They are not added, because
|
|
516
|
+
* the only thing separating a demonstration from an instruction here is
|
|
517
|
+
* judgement: `if the ticket is about delivery -> use the shipping category` is
|
|
518
|
+
* the same line shape and is a rule, not an example.
|
|
519
|
+
*
|
|
520
|
+
* A detector that got that wrong would not merely report a wrong number.
|
|
521
|
+
* `prune` *removes* examples, so a rule misread as an example becomes a
|
|
522
|
+
* proposal to delete an instruction — and it would arrive with the same
|
|
523
|
+
* confidence as a correct one.
|
|
524
|
+
*
|
|
525
|
+
* The tempting narrowing is to require the left side to be quoted. It was
|
|
526
|
+
* considered and refused: both probes are quoted **because the author of the
|
|
527
|
+
* probes wrote them that way**, and a threshold fitted to one's own fixture is
|
|
528
|
+
* a threshold that has been measured against nothing. What would settle it is
|
|
529
|
+
* real prompts using the shape, not a rule invented here.
|
|
530
|
+
*
|
|
531
|
+
* Until then the honest behaviour is the one `prune` now has: say what was
|
|
532
|
+
* looked for, so a prompt this cannot read reports that it was not seen rather
|
|
533
|
+
* than that it does not exist. That sentence used to read "this prompt has
|
|
534
|
+
* fewer than two few-shot examples", which was a confident false statement
|
|
535
|
+
* about a prompt with four of them.
|
|
536
|
+
*/
|
|
537
|
+
|
|
359
538
|
/** Splits the prompt into labelled example blocks. */
|
|
360
539
|
export function findExamples(prompt: string, count: TokenCounter): ExampleBlock[] {
|
|
361
540
|
const lines = prompt.split('\n');
|
|
362
541
|
|
|
542
|
+
const tagged = xmlExamples(prompt);
|
|
543
|
+
if (tagged.length >= 2) return tagged.map((text) => ({ text, tokens: count(text) }));
|
|
544
|
+
|
|
363
545
|
for (const header of EXAMPLE_HEADER_TIERS) {
|
|
364
546
|
const blocks = splitOnHeader(lines, header);
|
|
365
547
|
if (blocks.length >= 2) {
|
|
@@ -447,6 +629,90 @@ function fencedBlocks(prompt: string): Array<{ lang: string; body: string }> {
|
|
|
447
629
|
* prompts worth checking. Only keys at nesting depth 1 count, so a nested
|
|
448
630
|
* field name cannot be mistaken for a top-level one.
|
|
449
631
|
*/
|
|
632
|
+
/**
|
|
633
|
+
* Fence languages this reads a schema out of.
|
|
634
|
+
*
|
|
635
|
+
* `ts` and `typescript` are new; the rest were already here and three of them
|
|
636
|
+
* did not work. See `unquotedKeys`.
|
|
637
|
+
*/
|
|
638
|
+
const SCHEMA_LANGS = /^(?:json|jsonc|json5|yaml|yml|ts|typescript)$/;
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Keys written without quotes, which is most of them.
|
|
642
|
+
*
|
|
643
|
+
* **The filter above already admitted `yaml`, `yml` and `json5`, and the
|
|
644
|
+
* extractor could not read any of them.** It only accepted a quoted string
|
|
645
|
+
* followed by a colon, which is JSON and nothing else — so a prompt showing its
|
|
646
|
+
* schema as YAML matched a language this module claims to support, produced
|
|
647
|
+
* zero keys, and reported no restatement however thoroughly the prose walked
|
|
648
|
+
* the fields. Not a missing feature: a promise made in one expression and
|
|
649
|
+
* broken by the next.
|
|
650
|
+
*
|
|
651
|
+
* `ts` is added at the same time because it is the same omission wearing a
|
|
652
|
+
* different label — `interface Ticket { category: string }` is how a
|
|
653
|
+
* TypeScript codebase writes the structured output it wants back, and it was
|
|
654
|
+
* not on the list at all.
|
|
655
|
+
*
|
|
656
|
+
* Two shapes, because YAML has no braces:
|
|
657
|
+
*
|
|
658
|
+
* - **Braced** (`json5`, `jsonc`, `ts`): an identifier at depth 1 followed by
|
|
659
|
+
* `:` or `?:`. Depth is what keeps a nested object's fields out.
|
|
660
|
+
* - **Indented** (`yaml`, `yml`): an identifier at the block's own outermost
|
|
661
|
+
* indent, followed by `:`. Anything indented past it belongs to a parent key
|
|
662
|
+
* and is not top-level.
|
|
663
|
+
*
|
|
664
|
+
* Deliberately not applied to a fence with **no** language. An unlabelled block
|
|
665
|
+
* is as likely to be a shell session or a log line as a schema, and
|
|
666
|
+
* `Note: something` reads as a key to any rule this simple. The quoted form
|
|
667
|
+
* stays the only thing read there, exactly as before.
|
|
668
|
+
*/
|
|
669
|
+
function unquotedKeys(body: string, lang: string): string[] {
|
|
670
|
+
if (!SCHEMA_LANGS.test(lang)) return [];
|
|
671
|
+
|
|
672
|
+
if (/^(?:yaml|yml)$/.test(lang)) {
|
|
673
|
+
const lines = body
|
|
674
|
+
.split('\n')
|
|
675
|
+
.filter((line) => line.trim() !== '' && !/^\s*#/.test(line));
|
|
676
|
+
const outermost = Math.min(...lines.map((line) => line.length - line.trimStart().length));
|
|
677
|
+
if (!Number.isFinite(outermost)) return [];
|
|
678
|
+
return [
|
|
679
|
+
...new Set(
|
|
680
|
+
lines
|
|
681
|
+
.filter((line) => line.length - line.trimStart().length === outermost)
|
|
682
|
+
.map((line) => /^\s*([A-Za-z_][\w-]*)\s*:/.exec(line))
|
|
683
|
+
.filter((found): found is RegExpExecArray => found !== null)
|
|
684
|
+
.map((found) => found[1]!),
|
|
685
|
+
),
|
|
686
|
+
];
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/*
|
|
690
|
+
Character by character rather than line by line, and that was found by
|
|
691
|
+
running it: `interface Ticket { category: string; urgency: number }` is one
|
|
692
|
+
line, so a line-oriented walk sees depth 0 where it starts and reads nothing
|
|
693
|
+
at all. Single-line objects are the common case in a `ts` fence and in the
|
|
694
|
+
JSON5 people paste, not the exception.
|
|
695
|
+
*/
|
|
696
|
+
const keys: string[] = [];
|
|
697
|
+
let depth = 0;
|
|
698
|
+
let candidate = '';
|
|
699
|
+
for (let i = 0; i < body.length; i++) {
|
|
700
|
+
const ch = body[i]!;
|
|
701
|
+
if (ch === '{' || ch === '[') { depth++; candidate = ''; continue; }
|
|
702
|
+
if (ch === '}' || ch === ']') { depth--; candidate = ''; continue; }
|
|
703
|
+
if (ch === ',' || ch === ';' || ch === '\n') { candidate = ''; continue; }
|
|
704
|
+
if (/[A-Za-z0-9_-]/.test(ch)) { candidate += ch; continue; }
|
|
705
|
+
if (ch === ':' || (ch === '?' && body[i + 1] === ':')) {
|
|
706
|
+
if (depth === 1 && /^[A-Za-z_][\w-]*$/.test(candidate)) keys.push(candidate);
|
|
707
|
+
candidate = '';
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
// Whitespace between a name and its colon is fine; anything else is not a key.
|
|
711
|
+
if (!/\s/.test(ch)) candidate = '';
|
|
712
|
+
}
|
|
713
|
+
return [...new Set(keys)];
|
|
714
|
+
}
|
|
715
|
+
|
|
450
716
|
function topLevelKeys(body: string): string[] {
|
|
451
717
|
const keys: string[] = [];
|
|
452
718
|
let depth = 0;
|
|
@@ -505,8 +771,8 @@ export function findRestatedFormat(prompt: string, count: TokenCounter): Restate
|
|
|
505
771
|
const keys = [
|
|
506
772
|
...new Set(
|
|
507
773
|
blocks
|
|
508
|
-
.filter((b) => b.lang === '' ||
|
|
509
|
-
.flatMap((b) => topLevelKeys(b.body)),
|
|
774
|
+
.filter((b) => b.lang === '' || SCHEMA_LANGS.test(b.lang))
|
|
775
|
+
.flatMap((b) => [...topLevelKeys(b.body), ...unquotedKeys(b.body, b.lang)]),
|
|
510
776
|
),
|
|
511
777
|
].filter((k) => k.length >= 3);
|
|
512
778
|
|