@trazum/core 1.60.3 → 1.60.4

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/src/write.ts ADDED
@@ -0,0 +1,412 @@
1
+ import { optimize } from './optimize.js';
2
+ import type { RuleLevel, UsageProfile } from './types.js';
3
+ import { BUNDLED_CATALOGUE } from './pricing.js';
4
+ import type { PricingCatalogue } from './pricing.js';
5
+
6
+ /**
7
+ * The interview behind `trazum write`.
8
+ *
9
+ * Every other command in this product reads a prompt somebody already wrote.
10
+ * This one starts from nothing and asks. What it asks is the product: a
11
+ * question whose answer cannot change the output is waste, and waste is this
12
+ * tool's entire subject.
13
+ *
14
+ * **Deterministic, and deliberately so.** No model decides what to ask. The
15
+ * catalogue below is fixed, the gates are predicates over the answers so far,
16
+ * and the same answers produce the same interview on any machine — which is
17
+ * what lets the offline rule hold without a footnote.
18
+ *
19
+ * **Ids here, words in the CLI.** Same split as the rules catalogue: this file
20
+ * knows a slot exists and what opens it; `packages/cli/src/i18n` knows how to
21
+ * ask it in a locale. A locale changes the question, never which questions.
22
+ */
23
+
24
+ /**
25
+ * The sections of an assembled prompt, in the order they are written.
26
+ *
27
+ * The order is fixed, and for a reason this tool can price: prompt caching is
28
+ * a byte-for-byte prefix match, so everything stable goes first and everything
29
+ * that varies per call goes last, which makes the cacheable prefix as long as
30
+ * the prompt allows.
31
+ */
32
+ export const SECTIONS = [
33
+ 'role',
34
+ 'task',
35
+ 'inputs',
36
+ 'output',
37
+ 'constraints',
38
+ 'examples',
39
+ 'failure-modes',
40
+ ] as const;
41
+
42
+ export type Section = (typeof SECTIONS)[number];
43
+
44
+ /**
45
+ * An answer, or an explicit decline.
46
+ *
47
+ * `null` is a decline and not an absence: somebody was asked and said no. The
48
+ * difference is the one this product refuses to lose everywhere else, and a
49
+ * declined slot is named in the output rather than silently dropped.
50
+ */
51
+ export type Answer = string | null;
52
+
53
+ export type Answers = Readonly<Record<string, Answer>>;
54
+
55
+ export interface Slot {
56
+ readonly id: string;
57
+ /**
58
+ * The section it fills, or `null` when it changes the report and never the
59
+ * prompt — the model to price against, the budget to check.
60
+ */
61
+ readonly section: Section | null;
62
+ /** A prompt cannot be assembled while a required, open slot is unanswered. */
63
+ readonly required: boolean;
64
+ /**
65
+ * Open only when this returns true. Absent means always open.
66
+ *
67
+ * Every gate here has an answer set that opens it and one that does not — a
68
+ * gate that is always true or always false does nothing, and a test proves
69
+ * both directions for each.
70
+ */
71
+ readonly opensWhen?: (answers: Answers) => boolean;
72
+ }
73
+
74
+ /**
75
+ * The closed vocabulary `output-shape` accepts.
76
+ *
77
+ * Named `OutputFormat` rather than `OutputShape` because that name is already
78
+ * taken by the usage report's finding about where output spend concentrates,
79
+ * and two unrelated things under one name in one package is how a consumer
80
+ * imports the wrong one.
81
+ */
82
+ export const OUTPUT_FORMATS = ['prose', 'json', 'list', 'table'] as const;
83
+ export type OutputFormat = (typeof OUTPUT_FORMATS)[number];
84
+
85
+ const answered = (answers: Answers, id: string): boolean =>
86
+ Object.prototype.hasOwnProperty.call(answers, id) && answers[id] !== null;
87
+
88
+ const shapeIs = (...formats: readonly OutputFormat[]) => (answers: Answers): boolean => {
89
+ const shape = answers['output-shape'];
90
+ return typeof shape === 'string' && formats.includes(shape as OutputFormat);
91
+ };
92
+
93
+ /**
94
+ * The catalogue, in the order the interview walks it.
95
+ *
96
+ * Required first is not an accident: somebody who abandons the interview
97
+ * halfway should have answered the things without which there is no prompt at
98
+ * all, rather than having spent their attention on the optional half.
99
+ */
100
+ export const SLOTS: readonly Slot[] = [
101
+ { id: 'task', section: 'task', required: true },
102
+ { id: 'role', section: 'role', required: true },
103
+ { id: 'inputs', section: 'inputs', required: true },
104
+ { id: 'output-shape', section: 'output', required: true },
105
+ { id: 'output-schema', section: 'output', required: true, opensWhen: shapeIs('json', 'table') },
106
+ { id: 'output-length', section: 'output', required: false, opensWhen: shapeIs('prose', 'list') },
107
+ { id: 'audience', section: 'role', required: false },
108
+ { id: 'constraints', section: 'constraints', required: false },
109
+ { id: 'refusal', section: 'constraints', required: false },
110
+ { id: 'examples', section: 'examples', required: false },
111
+ {
112
+ id: 'example-inputs',
113
+ section: 'examples',
114
+ required: false,
115
+ opensWhen: (answers) => answered(answers, 'examples'),
116
+ },
117
+ { id: 'failure-modes', section: 'failure-modes', required: false },
118
+ { id: 'model', section: null, required: false },
119
+ { id: 'budget', section: null, required: false },
120
+ ];
121
+
122
+ export const SLOT_IDS: readonly string[] = SLOTS.map((entry) => entry.id);
123
+
124
+ export function slot(id: string): Slot | undefined {
125
+ return SLOTS.find((entry) => entry.id === id);
126
+ }
127
+
128
+ /** Whether a slot is worth asking, given what is known so far. */
129
+ export function isOpen(entry: Slot, answers: Answers): boolean {
130
+ return entry.opensWhen === undefined || entry.opensWhen(answers);
131
+ }
132
+
133
+ export interface Interview {
134
+ /** The next question to ask, or null when there is nothing left worth asking. */
135
+ readonly next: string | null;
136
+ /**
137
+ * True when every open slot has an answer or a decline.
138
+ *
139
+ * The interview says it is finished rather than continuing to be thorough at
140
+ * somebody's expense. Being asked a question whose answer changes nothing is
141
+ * the same waste this tool charges people to find in their prompts.
142
+ */
143
+ readonly done: boolean;
144
+ readonly open: readonly string[];
145
+ readonly answered: readonly string[];
146
+ readonly declined: readonly string[];
147
+ /**
148
+ * Required, open, and unanswered.
149
+ *
150
+ * A refusal never arrives bare: whatever cannot be built is reported with
151
+ * these named, and the CLI renders what each one unlocks beside it.
152
+ */
153
+ readonly missing: readonly string[];
154
+ }
155
+
156
+ export function interview(answers: Answers): Interview {
157
+ const open = SLOTS.filter((entry) => isOpen(entry, answers));
158
+ const has = (entry: Slot) => Object.prototype.hasOwnProperty.call(answers, entry.id);
159
+ const unasked = open.filter((entry) => !has(entry));
160
+ return {
161
+ next: unasked[0]?.id ?? null,
162
+ done: unasked.length === 0,
163
+ open: open.map((entry) => entry.id),
164
+ answered: open.filter((entry) => answered(answers, entry.id)).map((entry) => entry.id),
165
+ declined: open
166
+ .filter((entry) => has(entry) && answers[entry.id] === null)
167
+ .map((entry) => entry.id),
168
+ missing: unasked.filter((entry) => entry.required).map((entry) => entry.id),
169
+ };
170
+ }
171
+
172
+ /**
173
+ * How each slot appears in the assembled prompt.
174
+ *
175
+ * A slot either supplies the section's opening line on its own, or arrives
176
+ * under a label. Nothing here paraphrases an answer: the words are the
177
+ * author's, and a writer that rewrote them would be answering a question
178
+ * nobody asked it.
179
+ */
180
+ const LABEL: Readonly<Record<string, string | null>> = {
181
+ role: null,
182
+ audience: 'Audience:',
183
+ task: null,
184
+ inputs: null,
185
+ 'output-shape': 'Format:',
186
+ 'output-schema': 'Fields:',
187
+ 'output-length': 'At most:',
188
+ constraints: null,
189
+ refusal: 'When you cannot answer:',
190
+ examples: null,
191
+ 'example-inputs': 'Input:',
192
+ 'failure-modes': null,
193
+ };
194
+
195
+ /**
196
+ * The heading each section is written under.
197
+ *
198
+ * **English, in every locale, on purpose.** These are structure rather than
199
+ * prose — a contract with the model, not words for a human reader — and the
200
+ * arc promises the same answers produce the same prompt byte for byte on any
201
+ * machine *and in any locale*. A heading that moved with `TRAZUM_LOCALE` would
202
+ * make the assembled prompt a function of the machine that ran the interview,
203
+ * which is the one thing [the locale rule](../../../ROADMAP.md) forbids.
204
+ */
205
+ const HEADING: Readonly<Record<Section, string>> = {
206
+ role: 'Role',
207
+ task: 'Task',
208
+ inputs: 'Inputs',
209
+ output: 'Output',
210
+ constraints: 'Constraints',
211
+ examples: 'Examples',
212
+ 'failure-modes': 'Failure modes',
213
+ };
214
+
215
+ export interface DraftSection {
216
+ readonly section: Section;
217
+ readonly text: string;
218
+ /** The slots that put words in it, in the order they appear. */
219
+ readonly from: readonly string[];
220
+ }
221
+
222
+ export interface PromptDraft {
223
+ readonly schemaVersion: 1;
224
+ /**
225
+ * The assembled prompt, or **null** when required answers are missing.
226
+ *
227
+ * Null and never `''`: an empty string would read as a prompt that came out
228
+ * empty, and the difference between "not built" and "built and blank" is the
229
+ * one this product refuses to lose.
230
+ */
231
+ readonly prompt: string | null;
232
+ readonly sections: readonly DraftSection[];
233
+ readonly answered: readonly string[];
234
+ readonly declined: readonly string[];
235
+ /** Required, open and unanswered. Empty exactly when `prompt` is a string. */
236
+ readonly missing: readonly string[];
237
+ /**
238
+ * What the draft costs and what the rules still find in it, or **null** when
239
+ * there is no prompt to measure.
240
+ *
241
+ * Null rather than an object of zeros: a draft that was never assembled has
242
+ * not been measured as costing nothing.
243
+ */
244
+ readonly measured: DraftMeasurement | null;
245
+ }
246
+
247
+ const line = (id: string, answer: string): string => {
248
+ const label = LABEL[id] ?? null;
249
+ return label === null ? answer.trim() : `${label} ${answer.trim()}`;
250
+ };
251
+
252
+ /**
253
+ * Assemble the prompt, or refuse and say what is missing.
254
+ *
255
+ * Deterministic in both senses that matter: the same answers produce the same
256
+ * bytes, and nothing here consults the network, the clock or the locale.
257
+ */
258
+ export function assemble(answers: Answers, options: AssembleOptions = {}): PromptDraft {
259
+ const state = interview(answers);
260
+ const sections: DraftSection[] = [];
261
+
262
+ for (const section of SECTIONS) {
263
+ const filled = SLOTS.filter(
264
+ (entry) =>
265
+ entry.section === section &&
266
+ isOpen(entry, answers) &&
267
+ typeof answers[entry.id] === 'string' &&
268
+ (answers[entry.id] as string).trim().length > 0,
269
+ );
270
+ if (filled.length === 0) continue;
271
+ const body = filled.map((entry) => line(entry.id, answers[entry.id] as string)).join('\n');
272
+ sections.push({
273
+ section,
274
+ text: `${HEADING[section]}\n${body}`,
275
+ from: filled.map((entry) => entry.id),
276
+ });
277
+ }
278
+
279
+ const prompt =
280
+ state.missing.length > 0 ? null : sections.map((entry) => entry.text).join('\n\n');
281
+
282
+ return {
283
+ schemaVersion: 1,
284
+ prompt,
285
+ sections,
286
+ answered: state.answered,
287
+ declined: state.declined,
288
+ missing: state.missing,
289
+ measured: prompt === null ? null : measure(prompt, answers, state, options),
290
+ };
291
+ }
292
+
293
+ /**
294
+ * What the draft costs and what this tool's own rules still find in it.
295
+ *
296
+ * Three claims replace "the perfect prompt", which is a quality judgement
297
+ * about text nobody has run. Each is measured and printed rather than
298
+ * promised, and the third is the one worth having: **the product's own rules
299
+ * are the acceptance test for its own output.**
300
+ */
301
+ export interface DraftMeasurement {
302
+ /** Complete: the checklist, with its gaps named rather than scored. */
303
+ readonly complete: {
304
+ readonly required: number;
305
+ readonly answered: number;
306
+ /** Asked and turned down. A decision, kept apart from a gap. */
307
+ readonly declined: readonly string[];
308
+ readonly missing: readonly string[];
309
+ };
310
+ /**
311
+ * Cheap: what it costs, and whether it fits.
312
+ *
313
+ * `provenance` is always `estimated` and travels inside the object, because
314
+ * nobody has sent this prompt yet. A figure that could be read without its
315
+ * provenance would be a projection wearing a measurement's clothes.
316
+ */
317
+ readonly cheap: {
318
+ readonly tokens: number;
319
+ readonly tokenSource: string;
320
+ readonly model: string | null;
321
+ /** Null when it cannot be priced — never 0, which would read as free. */
322
+ readonly monthlyUsd: number | null;
323
+ readonly provenance: 'estimated';
324
+ readonly budgetUsd: number | null;
325
+ /** Three, never two. */
326
+ readonly verdict: 'within' | 'over' | 'cannot-tell';
327
+ /** Why it cannot tell. Null when it can — a refusal never arrives bare. */
328
+ readonly reason: 'no-budget' | 'no-model' | 'model-unpriced' | null;
329
+ };
330
+ /** Clean: what `trazum optimize` still recovers. The target is nothing. */
331
+ readonly clean: {
332
+ readonly rules: readonly { readonly id: string; readonly hits: number }[];
333
+ readonly tokensRecoverable: number;
334
+ };
335
+ }
336
+
337
+ export interface AssembleOptions {
338
+ /** Calls per month, for the estimate. The answers supply the model. */
339
+ readonly callsPerMonth?: number;
340
+ readonly avgOutputTokens?: number;
341
+ readonly pricing?: PricingCatalogue;
342
+ /** The level the cleanliness claim is measured at. Defaults to `safe`. */
343
+ readonly level?: RuleLevel;
344
+ }
345
+
346
+ const measure = (
347
+ prompt: string,
348
+ answers: Answers,
349
+ state: Interview,
350
+ options: AssembleOptions,
351
+ ): DraftMeasurement => {
352
+ const model = typeof answers['model'] === 'string' ? answers['model'].trim() : null;
353
+ const budgetAnswer = typeof answers['budget'] === 'string' ? Number(answers['budget']) : NaN;
354
+ const budgetUsd = Number.isFinite(budgetAnswer) ? budgetAnswer : null;
355
+
356
+ /*
357
+ Priced only when the catalogue knows the model.
358
+
359
+ `optimize` throws on a model it cannot price, which is right for a command
360
+ somebody typed a model into and wrong here: an unpriced model is one of the
361
+ three answers this measurement gives, not a crash. So the tokens and the
362
+ rules — neither of which needs a price — are measured first, and the money
363
+ is asked for separately and only when there is something to ask.
364
+ */
365
+ const known =
366
+ model !== null && (options.pricing ?? BUNDLED_CATALOGUE).byId.has(model);
367
+
368
+ const usage: Partial<UsageProfile> = {};
369
+ if (known) usage.model = model as string;
370
+ if (options.callsPerMonth !== undefined) usage.callsPerMonth = options.callsPerMonth;
371
+ if (options.avgOutputTokens !== undefined) usage.avgOutputTokens = options.avgOutputTokens;
372
+
373
+ const report = optimize(prompt, {
374
+ level: options.level ?? 'safe',
375
+ pricing: options.pricing,
376
+ usage,
377
+ });
378
+
379
+ const monthlyUsd = known && report.savings !== null ? report.savings.perMonth.before.totalUsd : null;
380
+
381
+ let verdict: DraftMeasurement['cheap']['verdict'] = 'cannot-tell';
382
+ let reason: DraftMeasurement['cheap']['reason'] = null;
383
+ if (budgetUsd === null) reason = 'no-budget';
384
+ else if (model === null) reason = 'no-model';
385
+ else if (monthlyUsd === null) reason = 'model-unpriced';
386
+ else verdict = monthlyUsd > budgetUsd ? 'over' : 'within';
387
+
388
+ return {
389
+ complete: {
390
+ required: SLOTS.filter((entry) => entry.required && isOpen(entry, answers)).length,
391
+ answered: state.answered.length,
392
+ declined: state.declined,
393
+ missing: state.missing,
394
+ },
395
+ cheap: {
396
+ tokens: report.tokensBefore,
397
+ tokenSource: report.tokenSource,
398
+ model,
399
+ monthlyUsd,
400
+ provenance: 'estimated',
401
+ budgetUsd,
402
+ verdict,
403
+ reason,
404
+ },
405
+ clean: {
406
+ rules: report.rules
407
+ .filter((entry) => entry.hits > 0)
408
+ .map((entry) => ({ id: entry.id, hits: entry.hits })),
409
+ tokensRecoverable: report.tokensSaved,
410
+ },
411
+ };
412
+ };