import type { SummaryJobData } from './summaries.types'; /** * The system prompt for a metrics summary. * * Locale is resolved server-side by `module_base` (from the viewer's `User.locale`) and arrives as * a plain tag, so the model is told which language to write in rather than being left to infer it * from the numbers — which carry no language at all. * * ## What changed on 2026-08-21, and why * * Three complaints about live output all traced back to this function: * * - **Raw enums.** "statusnya masih NEED_APPROVAL", "1 sudah IN_PROGRESS". There was no rule about * identifiers, so the model copied what the JSON held. It is now given human labels upstream * (`module_contracts` `labels/base`), and told never to print an ALL_CAPS token even if one slips * through. A generic "convert to Title Case" instruction would have produced *In Progress* where * the product says **On Progress** — a plausible guess that is not the product's vocabulary. * - **False precision.** "progress 43,006%", "13,53" with no unit. Three decimals imply a * measurement the data does not have. * - **No emphasis.** The rules said `no markdown`, in as many words — so the flat output was * obedience, not failure. Figma had been showing bolded entity names in these cards all along. * * Two more were found while fixing those: the summaries never NAMED anything ("1 agenda", "1 * lainnya") although the payload carries every name, and the rules contradicted themselves — the * slot question asked what the numbers "imply" while the rules forbade going beyond them. */ /** * The chip rules, kept as a block so they can be left out entirely. * * Omitted rather than negated when enrichment is off. A prompt that says "do not use chips" has * still told the model chips exist, and a rule about a thing it was never going to do is spent * budget on every generation — the summaries are 2-4 sentences and the system prompt is the * larger half of the call. */ export const CHIP_RULES: string[] = [ '- A scoring band or a status may be written as a CHIP, which renders as a marked value the', ' reader can hover for its definition. Write it in SINGLE BACKTICKS, mid-sentence, and never', ' as a fenced block: `chip:band:high` · `chip:band:medium` · `chip:band:low` ·', ' `chip:tier:priority` · `chip:tier:manage_risk` · `chip:tier:selective` ·', ' `chip:tier:business_as_usual` · `chip:status:`.', ' Use it ONLY where the data already carries that band, tier or status — a chip is a link to', ' the scoring criteria, not an opinion. At most 2 per summary, and never for a value you also', ' bold: the chip already marks it.', '- ALWAYS attach the measured number to a band or tier chip, from the data:', ' `chip:band:high|score=`. The popover shows that measurement first and the', ' generic criteria below it. Without it the reader sees only what the BAND means and takes it', ' as a statement about this record, which the data does not support.', ' Add `subject=` ONLY when the chip is about ONE record whose name is in the data', ' (a program row). For a figure covering the whole scope — an average, a total — give `score`', ' alone: the reader is already looking at that scope, and a generic word or an id in `subject`', ' identifies nothing while looking like data. NEVER put a uuid in `subject`.', '- `score` and `subject` belong to a BAND or TIER chip only. A status chip takes just the value:', ' `chip:status:On Progress`. Write the value exactly as the data spells it — the data already', ' carries product labels, not enum codes, so copy what you see.', '- Never OPEN a sentence with a chip. It annotates a value inside a clause; at the start of a', ' sentence it reads as a label pasted onto the paragraph.', // The ALL_CAPS ban is absolute in the base rules; a chip's VALUE is the one place an // identifier-looking string is allowed through, and only because the renderer reads it. // Lives here rather than beside that ban so it disappears with the rest when chips are off. '- The ALL_CAPS ban does not apply INSIDE a chip token: its value is whatever the data spells,', ' and the renderer matches on it. It still applies to every word you write around the chip.', ]; /** * The presentation rules for the plain path — the counterpart to `CHIP_RULES`. * * A status that is not a coloured chip still has to be findable when a director skims the card, so * it is bolded instead. Exactly one of the two lists is ever spliced in: with chips on, the chip IS * the emphasis and bolding the token would wrap markup the renderer then has to unpick. * * "Never re-case it" is not in tension with `AI_SUMMARY_LABEL_STYLE`. The model writes the * product's own spelling and the casing is applied afterwards by table lookup — telling the model * to shout would reintroduce the drift `display-labels.ts` exists to prevent. */ export const PLAIN_LABEL_RULES: string[] = [ '- ALWAYS bold a product status label, every time one appears: **On Progress**, **Completed**,', ' **Not Started**, **Need Approval**, **Incomplete data!**, **Fast Track**, **Priority**,', ' **Selective**, **High**. These are free — they do not count against the bold budget above.', '- Copy a label exactly as the data spells it. Never translate it, never re-case it, never', ' swap in a synonym.', ]; /** * @param locale - Resolved server-side from the viewer's profile * @param enrichment - `true` adds the inline-chip rules. Defaults to OFF, mirroring * `summaryEnrichmentEnabled()` — a caller that says nothing gets the plain prose an * unconfigured deployment produces, so no test asserts a shape nobody runs * (`AI_SUMMARY_ENRICHMENT=1`; see `summary-enrichment.ts`) */ export function buildSummarySystemPrompt(locale: string, enrichment = false): string { const language = locale.toLowerCase().startsWith('id') ? 'Bahasa Indonesia' : 'English'; return [ 'You write short executive summaries of construction-portfolio metrics.', `Write in ${language}. Keep entity names and status labels exactly as given, in English.`, '', 'Rules:', '- 2 to 4 sentences. No headings, no bullets, no lists, no tables.', '- Light markdown only: **bold**. Never a heading, link, code block or bullet.', '- Bold at most 3 names or figures — the ones that carry the point.', ...(enrichment ? CHIP_RULES : PLAIN_LABEL_RULES), '- NAME things. The data carries agenda/program/project names — use them. Never write', ' "one agenda" or "another program" when the name is right there.', '- Open with the single most notable fact (a name and a number), not with the aggregate total.', '- Use the human labels in the data. Never print a raw identifier such as NEED_APPROVAL', ' or any ALL_CAPS token.', '- Round percentages to whole numbers and always keep the unit: 43%, never 43,006 or 13,53.', '- Never invent a figure, a name, a date or a trend. Every number you write must appear', ' in the data.', '- You may close with ONE short clause on what it implies, but only if it follows directly', ' from a number you just stated.', '- If the numbers are unremarkable, say so plainly rather than manufacturing significance.', '- No greeting, no sign-off, no offer to help further.', ].join('\n'); } /** * The user turn: the metrics themselves. * * Serialised as JSON rather than prose because it IS structured data — prose-ifying it here would * mean writing a second, silent summariser whose wording biases the model's. The slot tells the * model which question it is answering; the scope tells it what level it is describing. * * The glossary rides here rather than in `module_base`'s payload on purpose: `input_hash` is * computed over that payload, so a wording change there would mark every stored summary stale and * pay to regenerate prose that says the same thing. Explaining the data is not part of the data. */ export function buildSummaryUserPrompt(job: SummaryJobData): string { const question = questionFor(job.scopeType, job.slot); const glossary = glossaryFor(job.scopeType, job.slot); return [ `Scope: ${job.scopeType}`, `Question: ${question}`, ...(glossary ? ['', `Field notes: ${glossary}`] : []), '', 'Metrics (JSON):', JSON.stringify(job.summaryInput, null, 2), ].join('\n'); } /** * `overview` means a different thing at each level, so one question for all five was always going * to produce the same shape of sentence regardless of what the reader is looking at. */ /** * `Partial<>` because the lookup key is a wire string: `scopeType` arrives from another service, * so an unknown value is a runtime possibility even though the five known ones are all listed. * Typed as a total `Record`, TypeScript would call the fallback below dead code and eslint would * ask for its removal — leaving `Question: undefined` in the prompt for any new scope. */ const OVERVIEW_QUESTION: Partial> & { workspace: string } = { workspace: 'How is this workspace performing across its agendas right now?', agenda: 'How is this agenda performing across its programs right now?', program: 'How is this program performing across its projects, and where does it sit on the strategic matrix?', project: 'How is this project performing right now?', // `/home` belongs to ONE person, so the summary addresses them. Live output read "performa // portfolio masih rendah… dari 20 objek" — a third-party report about the reader's own page. portfolio: "How is THIS PERSON's portfolio doing across what they OWN, what they were ASSIGNED, what was " + 'SHARED with them, and what they only reach because they manage a scope above it? ' + 'Address the reader directly; this page belongs to one person.', }; const STRATEGIC_EVALUATION_QUESTION: Partial> & { workspace: string } = { workspace: 'What does the strategic mix of this workspace look like across its agendas, and what does it imply?', agenda: "How are this agenda's programs ranked on the strategic matrix, and what does that mix imply?", portfolio: "How does the strategic mix look across the agendas in THIS PERSON's reach, and what does it imply?", }; const SLOT_QUESTION: Partial> = { strategic_issues: 'What are the open issues here and how serious are they?', }; function questionFor(scopeType: string, slot: string): string { if (slot === 'overview') return OVERVIEW_QUESTION[scopeType] ?? OVERVIEW_QUESTION.workspace; if (slot === 'strategic_evaluation') { return STRATEGIC_EVALUATION_QUESTION[scopeType] ?? STRATEGIC_EVALUATION_QUESTION.workspace; } return SLOT_QUESTION[slot] ?? OVERVIEW_QUESTION[scopeType] ?? OVERVIEW_QUESTION.workspace; } /** * Per-slot field notes. * * These exist because two fields in the payload are easy to read backwards, and both mistakes look * authoritative in prose: quoting `items.length` (capped at 10) as the number of issues, and * reading a replier's `created_at` as the date their ACCOUNT was made. */ /** * `ownership` is the third field in this payload that is easy to read backwards, and the costliest. * * Until 2026-09-30 the portfolio filtered out every row the reader reached only through a role on * an ancestor, so the model never met `inherited` and "what you own versus what you were invited * to" covered the whole list. The reversal puts those rows on screen, and for anyone managing an * agenda they are the MAJORITY — on staging one manager went from 3 listed projects to 17, and 14 * of them are inherited. Without this the narration reports 17 things as the reader's own. */ const PORTFOLIO_OWNERSHIP_GLOSSARY = 'Every row carries `ownership`, and the four values are NOT interchangeable. ' + 'Owned = the reader is the owner of that node. ' + 'Assigned = somebody invited the reader to that exact node. ' + 'Shared = a project someone shared with the reader by name, view-only and revocable. ' + 'Inherited = the reader reaches it ONLY because they hold a role on an ancestor — nobody ' + 'assigned it to them. Never describe inherited rows as the reader\'s own or as something they ' + 'were given; they are what sits under the scopes the reader manages. `scope_summary` carries ' + 'the same four counts, and `total` is their sum — quote those rather than counting rows.'; const STRATEGIC_ISSUES_GLOSSARY = 'feedback_by[] lists distinct people who responded, newest responder first. Within it, ' + 'created_at is when that person FIRST responded and updated_at when they responded MOST ' + 'RECENTLY — both describe the response, never the person’s account. resolved_at null means ' + 'the issue is still open; items includes resolved ones, so quote unresolved_total for the ' + 'open count and never items.length.'; /** * Workspace + portfolio SE list agendas, not programs. Teaching the 2×2 here makes the model * invent Priority / Selective ranks that are not in the JSON. * * aligned_agenda_count / total_agenda_count is the verdict line. Each per_agenda row still * carries that agenda's own program alignment (aligned_count / total_count = "3 of 4"). * band is already labeled High / Medium / Low (p.8 of the mean). */ const STRATEGIC_EVALUATION_PER_AGENDA_GLOSSARY = 'The list is per_agenda[]. NAME agendas. ' + 'aligned_agenda_count / total_agenda_count is the verdict ("2 of 3 agendas are highly aligned") — ' + 'an agenda counts when its own band is High. ' + 'On each per_agenda row, total_count is the denominator the UI shows ("3 of 4") for that ' + "agenda's programs; band is the p.8 band of avg_strategic_value (High / Medium / Low), already labeled. " + 'Do not invent Priority / Selective / Manage Risk here — those are a program 2×2, one level down.'; /** * Agenda SE is the only slot that lists programs on the 2×2. * * `priority` is already derived upstream (`formatProgramPriority`). Do not recompute it from the * axes; the labels must match the chip (Business as Usual, not "Business As Usual"). */ const STRATEGIC_EVALUATION_AGENDA_GLOSSARY = 'aligned_threshold is the score at or above which a program counts as aligned. rated_count ' + 'excludes programs carrying no strategic score — total_count is the denominator the UI ' + 'shows ("3 of 4"). band is the p.8 band of the mean, not of any single program. ' + 'per_program[] is the list to narrate: each rated program and its priority. ' + 'priority is already computed from strategic_value (Strategic / Political Value, 1–10) × ' + 'internal_impact (Internal company impact, 1–10) — use the label as given ' + '(Priority, Selective, Manage Risk, Business as Usual). NAME programs and their priority. ' + 'Do not recompute the matrix. per_program lists RATED programs only — unrated or ' + 'Non-Investment programs sit in total_count but not the list.'; /** * Program has no strategic_evaluation card (doc 01 §4.10). The 2×2 lives on the program header * inside the overview slot. projects[].priority is LOW/MEDIUM/HIGH — a different field. */ const OVERVIEW_PROGRAM_GLOSSARY = 'This program has no strategic_evaluation slot. program.priority is already computed from ' + 'strategic_value × internal_impact — use the label as given (Priority, Selective, Manage Risk, ' + "Business as Usual). NAME it. projects[].priority is each project's own Low/Medium/High, not " + 'the matrix. Do not recompute either.'; function glossaryFor(scopeType: string, slot: string): string | undefined { if (slot === 'strategic_issues') return STRATEGIC_ISSUES_GLOSSARY; if (slot === 'strategic_evaluation') { return scopeType === 'agenda' ? STRATEGIC_EVALUATION_AGENDA_GLOSSARY : STRATEGIC_EVALUATION_PER_AGENDA_GLOSSARY; } if (slot === 'overview' && scopeType === 'program') return OVERVIEW_PROGRAM_GLOSSARY; if (slot === 'overview' && scopeType === 'portfolio') return PORTFOLIO_OWNERSHIP_GLOSSARY; return undefined; }