arechta

arechta / Consdoc - AI Summary

Last active 1 month ago

Like 0
summary-prompt.builder.ts Raw
1import type { SummaryJobData } from './summaries.types';
2
3/**
4 * The system prompt for a metrics summary.
5 *
6 * Locale is resolved server-side by `module_base` (from the viewer's `User.locale`) and arrives as
7 * a plain tag, so the model is told which language to write in rather than being left to infer it
8 * from the numbers — which carry no language at all.
9 *
10 * ## What changed on 2026-08-21, and why
11 *
12 * Three complaints about live output all traced back to this function:
13 *
14 * - **Raw enums.** "statusnya masih NEED_APPROVAL", "1 sudah IN_PROGRESS". There was no rule about
15 * identifiers, so the model copied what the JSON held. It is now given human labels upstream
16 * (`module_contracts` `labels/base`), and told never to print an ALL_CAPS token even if one slips
17 * through. A generic "convert to Title Case" instruction would have produced *In Progress* where
18 * the product says **On Progress** — a plausible guess that is not the product's vocabulary.
19 * - **False precision.** "progress 43,006%", "13,53" with no unit. Three decimals imply a
20 * measurement the data does not have.
21 * - **No emphasis.** The rules said `no markdown`, in as many words — so the flat output was
22 * obedience, not failure. Figma had been showing bolded entity names in these cards all along.
23 *
24 * Two more were found while fixing those: the summaries never NAMED anything ("1 agenda", "1
25 * lainnya") although the payload carries every name, and the rules contradicted themselves — the
26 * slot question asked what the numbers "imply" while the rules forbade going beyond them.
27 */
28/**
29 * The chip rules, kept as a block so they can be left out entirely.
30 *
31 * Omitted rather than negated when enrichment is off. A prompt that says "do not use chips" has
32 * still told the model chips exist, and a rule about a thing it was never going to do is spent
33 * budget on every generation — the summaries are 2-4 sentences and the system prompt is the
34 * larger half of the call.
35 */
36export const CHIP_RULES: string[] = [
37 '- A scoring band or a status may be written as a CHIP, which renders as a marked value the',
38 ' reader can hover for its definition. Write it in SINGLE BACKTICKS, mid-sentence, and never',
39 ' as a fenced block: `chip:band:high` · `chip:band:medium` · `chip:band:low` ·',
40 ' `chip:tier:priority` · `chip:tier:manage_risk` · `chip:tier:selective` ·',
41 ' `chip:tier:business_as_usual` · `chip:status:<lifecycle status>`.',
42 ' Use it ONLY where the data already carries that band, tier or status — a chip is a link to',
43 ' the scoring criteria, not an opinion. At most 2 per summary, and never for a value you also',
44 ' bold: the chip already marks it.',
45 '- ALWAYS attach the measured number to a band or tier chip, from the data:',
46 ' `chip:band:high|score=<strategic_value>`. The popover shows that measurement first and the',
47 ' generic criteria below it. Without it the reader sees only what the BAND means and takes it',
48 ' as a statement about this record, which the data does not support.',
49 ' Add `subject=<name>` ONLY when the chip is about ONE record whose name is in the data',
50 ' (a program row). For a figure covering the whole scope — an average, a total — give `score`',
51 ' alone: the reader is already looking at that scope, and a generic word or an id in `subject`',
52 ' identifies nothing while looking like data. NEVER put a uuid in `subject`.',
53 '- `score` and `subject` belong to a BAND or TIER chip only. A status chip takes just the value:',
54 ' `chip:status:On Progress`. Write the value exactly as the data spells it — the data already',
55 ' carries product labels, not enum codes, so copy what you see.',
56 '- Never OPEN a sentence with a chip. It annotates a value inside a clause; at the start of a',
57 ' sentence it reads as a label pasted onto the paragraph.',
58 // The ALL_CAPS ban is absolute in the base rules; a chip's VALUE is the one place an
59 // identifier-looking string is allowed through, and only because the renderer reads it.
60 // Lives here rather than beside that ban so it disappears with the rest when chips are off.
61 '- The ALL_CAPS ban does not apply INSIDE a chip token: its value is whatever the data spells,',
62 ' and the renderer matches on it. It still applies to every word you write around the chip.',
63];
64
65/**
66 * The presentation rules for the plain path — the counterpart to `CHIP_RULES`.
67 *
68 * A status that is not a coloured chip still has to be findable when a director skims the card, so
69 * it is bolded instead. Exactly one of the two lists is ever spliced in: with chips on, the chip IS
70 * the emphasis and bolding the token would wrap markup the renderer then has to unpick.
71 *
72 * "Never re-case it" is not in tension with `AI_SUMMARY_LABEL_STYLE`. The model writes the
73 * product's own spelling and the casing is applied afterwards by table lookup — telling the model
74 * to shout would reintroduce the drift `display-labels.ts` exists to prevent.
75 */
76export const PLAIN_LABEL_RULES: string[] = [
77 '- ALWAYS bold a product status label, every time one appears: **On Progress**, **Completed**,',
78 ' **Not Started**, **Need Approval**, **Incomplete data!**, **Fast Track**, **Priority**,',
79 ' **Selective**, **High**. These are free — they do not count against the bold budget above.',
80 '- Copy a label exactly as the data spells it. Never translate it, never re-case it, never',
81 ' swap in a synonym.',
82];
83
84/**
85 * @param locale - Resolved server-side from the viewer's profile
86 * @param enrichment - `true` adds the inline-chip rules. Defaults to OFF, mirroring
87 * `summaryEnrichmentEnabled()` — a caller that says nothing gets the plain prose an
88 * unconfigured deployment produces, so no test asserts a shape nobody runs
89 * (`AI_SUMMARY_ENRICHMENT=1`; see `summary-enrichment.ts`)
90 */
91export function buildSummarySystemPrompt(locale: string, enrichment = false): string {
92 const language = locale.toLowerCase().startsWith('id') ? 'Bahasa Indonesia' : 'English';
93
94 return [
95 'You write short executive summaries of construction-portfolio metrics.',
96 `Write in ${language}. Keep entity names and status labels exactly as given, in English.`,
97 '',
98 'Rules:',
99 '- 2 to 4 sentences. No headings, no bullets, no lists, no tables.',
100 '- Light markdown only: **bold**. Never a heading, link, code block or bullet.',
101 '- Bold at most 3 names or figures — the ones that carry the point.',
102 ...(enrichment ? CHIP_RULES : PLAIN_LABEL_RULES),
103 '- NAME things. The data carries agenda/program/project names — use them. Never write',
104 ' "one agenda" or "another program" when the name is right there.',
105 '- Open with the single most notable fact (a name and a number), not with the aggregate total.',
106 '- Use the human labels in the data. Never print a raw identifier such as NEED_APPROVAL',
107 ' or any ALL_CAPS token.',
108 '- Round percentages to whole numbers and always keep the unit: 43%, never 43,006 or 13,53.',
109 '- Never invent a figure, a name, a date or a trend. Every number you write must appear',
110 ' in the data.',
111 '- You may close with ONE short clause on what it implies, but only if it follows directly',
112 ' from a number you just stated.',
113 '- If the numbers are unremarkable, say so plainly rather than manufacturing significance.',
114 '- No greeting, no sign-off, no offer to help further.',
115 ].join('\n');
116}
117
118/**
119 * The user turn: the metrics themselves.
120 *
121 * Serialised as JSON rather than prose because it IS structured data — prose-ifying it here would
122 * mean writing a second, silent summariser whose wording biases the model's. The slot tells the
123 * model which question it is answering; the scope tells it what level it is describing.
124 *
125 * The glossary rides here rather than in `module_base`'s payload on purpose: `input_hash` is
126 * computed over that payload, so a wording change there would mark every stored summary stale and
127 * pay to regenerate prose that says the same thing. Explaining the data is not part of the data.
128 */
129export function buildSummaryUserPrompt(job: SummaryJobData): string {
130 const question = questionFor(job.scopeType, job.slot);
131 const glossary = glossaryFor(job.scopeType, job.slot);
132
133 return [
134 `Scope: ${job.scopeType}`,
135 `Question: ${question}`,
136 ...(glossary ? ['', `Field notes: ${glossary}`] : []),
137 '',
138 'Metrics (JSON):',
139 JSON.stringify(job.summaryInput, null, 2),
140 ].join('\n');
141}
142
143/**
144 * `overview` means a different thing at each level, so one question for all five was always going
145 * to produce the same shape of sentence regardless of what the reader is looking at.
146 */
147/**
148 * `Partial<>` because the lookup key is a wire string: `scopeType` arrives from another service,
149 * so an unknown value is a runtime possibility even though the five known ones are all listed.
150 * Typed as a total `Record`, TypeScript would call the fallback below dead code and eslint would
151 * ask for its removal — leaving `Question: undefined` in the prompt for any new scope.
152 */
153const OVERVIEW_QUESTION: Partial<Record<string, string>> & { workspace: string } = {
154 workspace: 'How is this workspace performing across its agendas right now?',
155 agenda: 'How is this agenda performing across its programs right now?',
156 program: 'How is this program performing across its projects, and where does it sit on the strategic matrix?',
157 project: 'How is this project performing right now?',
158 // `/home` belongs to ONE person, so the summary addresses them. Live output read "performa
159 // portfolio masih rendah… dari 20 objek" — a third-party report about the reader's own page.
160 portfolio:
161 "How is THIS PERSON's portfolio doing across what they OWN, what they were ASSIGNED, what was " +
162 'SHARED with them, and what they only reach because they manage a scope above it? ' +
163 'Address the reader directly; this page belongs to one person.',
164};
165
166const STRATEGIC_EVALUATION_QUESTION: Partial<Record<string, string>> & { workspace: string } = {
167 workspace: 'What does the strategic mix of this workspace look like across its agendas, and what does it imply?',
168 agenda: "How are this agenda's programs ranked on the strategic matrix, and what does that mix imply?",
169 portfolio: "How does the strategic mix look across the agendas in THIS PERSON's reach, and what does it imply?",
170};
171
172const SLOT_QUESTION: Partial<Record<string, string>> = {
173 strategic_issues: 'What are the open issues here and how serious are they?',
174};
175
176function questionFor(scopeType: string, slot: string): string {
177 if (slot === 'overview') return OVERVIEW_QUESTION[scopeType] ?? OVERVIEW_QUESTION.workspace;
178 if (slot === 'strategic_evaluation') {
179 return STRATEGIC_EVALUATION_QUESTION[scopeType] ?? STRATEGIC_EVALUATION_QUESTION.workspace;
180 }
181 return SLOT_QUESTION[slot] ?? OVERVIEW_QUESTION[scopeType] ?? OVERVIEW_QUESTION.workspace;
182}
183
184/**
185 * Per-slot field notes.
186 *
187 * These exist because two fields in the payload are easy to read backwards, and both mistakes look
188 * authoritative in prose: quoting `items.length` (capped at 10) as the number of issues, and
189 * reading a replier's `created_at` as the date their ACCOUNT was made.
190 */
191/**
192 * `ownership` is the third field in this payload that is easy to read backwards, and the costliest.
193 *
194 * Until 2026-09-30 the portfolio filtered out every row the reader reached only through a role on
195 * an ancestor, so the model never met `inherited` and "what you own versus what you were invited
196 * to" covered the whole list. The reversal puts those rows on screen, and for anyone managing an
197 * agenda they are the MAJORITY — on staging one manager went from 3 listed projects to 17, and 14
198 * of them are inherited. Without this the narration reports 17 things as the reader's own.
199 */
200const PORTFOLIO_OWNERSHIP_GLOSSARY =
201 'Every row carries `ownership`, and the four values are NOT interchangeable. ' +
202 'Owned = the reader is the owner of that node. ' +
203 'Assigned = somebody invited the reader to that exact node. ' +
204 'Shared = a project someone shared with the reader by name, view-only and revocable. ' +
205 'Inherited = the reader reaches it ONLY because they hold a role on an ancestor — nobody ' +
206 'assigned it to them. Never describe inherited rows as the reader\'s own or as something they ' +
207 'were given; they are what sits under the scopes the reader manages. `scope_summary` carries ' +
208 'the same four counts, and `total` is their sum — quote those rather than counting rows.';
209
210const STRATEGIC_ISSUES_GLOSSARY =
211 'feedback_by[] lists distinct people who responded, newest responder first. Within it, ' +
212 'created_at is when that person FIRST responded and updated_at when they responded MOST ' +
213 'RECENTLY — both describe the response, never the person’s account. resolved_at null means ' +
214 'the issue is still open; items includes resolved ones, so quote unresolved_total for the ' +
215 'open count and never items.length.';
216
217/**
218 * Workspace + portfolio SE list agendas, not programs. Teaching the 2×2 here makes the model
219 * invent Priority / Selective ranks that are not in the JSON.
220 *
221 * aligned_agenda_count / total_agenda_count is the verdict line. Each per_agenda row still
222 * carries that agenda's own program alignment (aligned_count / total_count = "3 of 4").
223 * band is already labeled High / Medium / Low (p.8 of the mean).
224 */
225const STRATEGIC_EVALUATION_PER_AGENDA_GLOSSARY =
226 'The list is per_agenda[]. NAME agendas. ' +
227 'aligned_agenda_count / total_agenda_count is the verdict ("2 of 3 agendas are highly aligned") — ' +
228 'an agenda counts when its own band is High. ' +
229 'On each per_agenda row, total_count is the denominator the UI shows ("3 of 4") for that ' +
230 "agenda's programs; band is the p.8 band of avg_strategic_value (High / Medium / Low), already labeled. " +
231 'Do not invent Priority / Selective / Manage Risk here — those are a program 2×2, one level down.';
232
233/**
234 * Agenda SE is the only slot that lists programs on the 2×2.
235 *
236 * `priority` is already derived upstream (`formatProgramPriority`). Do not recompute it from the
237 * axes; the labels must match the chip (Business as Usual, not "Business As Usual").
238 */
239const STRATEGIC_EVALUATION_AGENDA_GLOSSARY =
240 'aligned_threshold is the score at or above which a program counts as aligned. rated_count ' +
241 'excludes programs carrying no strategic score — total_count is the denominator the UI ' +
242 'shows ("3 of 4"). band is the p.8 band of the mean, not of any single program. ' +
243 'per_program[] is the list to narrate: each rated program and its priority. ' +
244 'priority is already computed from strategic_value (Strategic / Political Value, 1–10) × ' +
245 'internal_impact (Internal company impact, 1–10) — use the label as given ' +
246 '(Priority, Selective, Manage Risk, Business as Usual). NAME programs and their priority. ' +
247 'Do not recompute the matrix. per_program lists RATED programs only — unrated or ' +
248 'Non-Investment programs sit in total_count but not the list.';
249
250/**
251 * Program has no strategic_evaluation card (doc 01 §4.10). The 2×2 lives on the program header
252 * inside the overview slot. projects[].priority is LOW/MEDIUM/HIGH — a different field.
253 */
254const OVERVIEW_PROGRAM_GLOSSARY =
255 'This program has no strategic_evaluation slot. program.priority is already computed from ' +
256 'strategic_value × internal_impact — use the label as given (Priority, Selective, Manage Risk, ' +
257 "Business as Usual). NAME it. projects[].priority is each project's own Low/Medium/High, not " +
258 'the matrix. Do not recompute either.';
259
260function glossaryFor(scopeType: string, slot: string): string | undefined {
261 if (slot === 'strategic_issues') return STRATEGIC_ISSUES_GLOSSARY;
262 if (slot === 'strategic_evaluation') {
263 return scopeType === 'agenda' ? STRATEGIC_EVALUATION_AGENDA_GLOSSARY : STRATEGIC_EVALUATION_PER_AGENDA_GLOSSARY;
264 }
265 if (slot === 'overview' && scopeType === 'program') return OVERVIEW_PROGRAM_GLOSSARY;
266 if (slot === 'overview' && scopeType === 'portfolio') return PORTFOLIO_OWNERSHIP_GLOSSARY;
267 return undefined;
268}
269