| 1 | import 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 | */ |
| 36 | export 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 | */ |
| 76 | export 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 | */ |
| 91 | export 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 | */ |
| 129 | export 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 | */ |
| 153 | const 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 | |
| 166 | const 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 | |
| 172 | const SLOT_QUESTION: Partial<Record<string, string>> = { |
| 173 | strategic_issues: 'What are the open issues here and how serious are they?', |
| 174 | }; |
| 175 | |
| 176 | function 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 | */ |
| 200 | const 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 | |
| 210 | const 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 | */ |
| 225 | const 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 | */ |
| 239 | const 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 | */ |
| 254 | const 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 | |
| 260 | function 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 |
arechta / Consdoc - AI Summary
Last active 1 month ago
Revision 068aa48f4c3d9d9d7a1d05637f2b6a0984093319