briefing-payload.ts
Language TypeScript28.6 KB704 lines
/*** The model-facing contract.** Everything upstream of here (three API clients, the stage* taxonomies, the review-gate arithmetic, the assignment join) is deterministic* TypeScript and costs zero model tokens. This file is the seam where that work* gets handed to something that reasons. It emits one small, versioned JSON* object per run. Whatever generates the analysis (a local model, a hosted one,* a Python service, a person reading it) consumes this and nothing else.** Why that split matters, in the only terms that count here: a model asked to* reason over raw Bitrix and Plinth payloads reads 200k-500k tokens per run. A* model reading this reads a few thousand. Same conclusions, ~1% of the budget.* The distillation is the point. If you find yourself widening this payload to* "let the model decide", stop: that is the aggregation leaking back across the* seam, and it is exactly what makes a daily job unaffordable.** The rules block is not documentation. `INTERPRETATION_RULES` ships inside the* payload, and it is load-bearing. Meridian's data has four traps that produce* confidently wrong sentences if a model does not know about them, and the worst* of those sentences get read aloud in front of the team. A model that has the* numbers but not the rules is more dangerous than one with no data at all,* because it is fluent.** Null is not zero, anywhere in here. A dead source and an empty queue are the* same shape at a glance and opposite in meaning. Every optional number in this* payload is `null` when unknown, and every null has a matching entry in* `sources` or `coverage` saying why.*/import { classifyOrder, type OrderLine } from '@meridian/plinth-client'import { partitionTasks } from '@meridian/bitrix-client'import { assignmentGroups, isActuallyLate, reviewGatesMissed, localDay, MERIDIAN_TZ, type Todo } from '@meridian/ninety-client'import {MERIDIAN_PEOPLE,SOURCE_LABELS,personFromNinety,unmappedNinetyOwners,type Person,} from '../../../config/meridian-people.js'import { isRestrictedTeam } from '../../../config/ninety-teams.js'import {headline,orderQueue,todoLoad,type Headline,type OrderQueueRow,type Snapshot,} from './collect.js'/*** Bump the minor when adding a field, the major when changing or removing one.* A consumer that pins a major and reads `schemaVersion` can fail loudly instead* of silently summarizing fields that moved.*/export const PAYLOAD_SCHEMA_VERSION = '1.1.0'/*** How many rows of each list to include.** These are budget decisions, not display decisions. The lists are sorted with* the most urgent first, so a cap truncates the tail rather than the signal, and* `truncated` tells the consumer it happened so it never says "all" about a* partial list.*/export const LIST_CAP = 25/*** The four things a model must know before it writes a sentence about Meridian.** Every one of these was learned from live data and every one of them, if* ignored, produces a fluent and wrong statement.*/export const INTERPRETATION_RULES: string[] = ['An open to-do does NOT mean unfinished work. Meridian policy is that nothing is ' +'marked complete outside a Monday or Friday review, so finished work stays ' +'open until the next review signs it off. Never write that someone is ' +'"behind" or "has not done" something purely because a to-do is open. Use ' +'the `late` count, which already accounts for review gates, and say "past ' +'due and past a review" rather than "overdue".','Commitments and assignments are different numbers and both are given. One ' +'commitment can be carried by several people: on the day this was built, ' +'36 rows represented 27 distinct commitments, some shared by three people. ' +'Report commitments when describing how much work exists, and assignments ' +'only when describing individual load. Never call the assignment count a ' +'number of tasks.','Order status and product status disagree, and NEITHER is simply the true ' +'one. Orders read "On Press" while their artwork is still in review; ' +'product rows park at their last value and still read "File Check" on ' +'orders that have already shipped. Every status here is the product-level ' +'one, and a line is only counted as work when its order has also not moved ' +'past Part Dispatched. Do not reconcile a status against what somebody ' +'quotes from an order screen: they are reading the other field.','Waiting-on-client and at-vendor are NOT the team being behind. They are ' +'counted separately on purpose. Only `ourMove` is work this team owes. ' +'Never sum them into a backlog figure or describe the team as behind on ' +'the basis of them.','A null means the source cannot see it, not that the value is zero. Check ' +'`sources` and each person\'s `coverage` before saying anyone has "nothing" ' +'or is "free". If a number is null, say it is not tracked.','Order numbers are given in the customer-facing form (MD2639582) and are safe ' +'to quote verbatim. Never reformat one, and never invent one.',]/*** The leadership view: what the whole company is carrying, and what these* numbers genuinely cannot tell you about it.** A separate rule set, not a tone choice.** A bird's-eye board invites one specific wrong inference: that a long-running* item means somebody was slow. Every source here makes that inference* unsupportable, and not as a matter of charity:** - Plinth records no status-change timestamps. `ageDays` is time since the order* was placed. A 30-day line may be 29 days waiting on a client's reply, or* two days of work that started yesterday. The data cannot tell them apart.* - Nothing anywhere records completion time per person, hours worked,* interruptions, or how long a client took to respond. Throughput is not a* quantity this system can compute.* - Ninety to-dos may only be closed at a Monday or Friday review, so an open* to-do can be finished work waiting for sign-off.* - Assignment arrives on the Plinth response itself, so it is as fresh as the* sync, but a line Plinth marks `--` is unassigned, not unknown.** So the honest leadership number is concurrency (how much one person is* holding at once), not throughput. Concurrency is measurable and it is the* thing that actually predicts whether a small team drops something.** `unmeasurable` ships in the payload for the same reason `rules` does: a model* given a per-person table and no constraints will rank people, and a ranking* this data cannot support is worse than no briefing.*/export interface ExecView {teamSize: number/** Work the team owes the next move on. The only figure that is team load. */ourMove: number/** Sitting with the client. Explicitly not team load. */waitingOnClient: number/** Sitting with a vendor. Explicitly not team load. */atVendor: number/** Distinct commitments, and the rows they spread across. */commitments: number | nullassignments: number | null/*** Order lines carrying both a designer and a CS rep. Every one is a handoff,* and handoffs are where a small team loses time to coordination rather than* to effort.*/handoffPoints: number/** Work areas exactly one person covers. Concentration risk, not performance. */soleCoverage: { area: string; person: string }[]/** Per person: what they are holding. Never a rate, never a ranking. */load: {name: stringrole: string/** Order lines where they owe the next move. */holding: number | null/** Their lines that are parked with a client or vendor: not their move. */parked: number | null/** Commitments they carry, including ones shared with others. */commitments: number | null/** Commitments they share, i.e. cannot finish alone. */shared: number/** Lines requiring a second person before they can close. */handoffs: number | null}[]/** What this data cannot show. Ships with the payload; do not omit. */unmeasurable: string[]}/** Rules for the leadership view, added to the standing ones. */export const EXEC_RULES: string[] = ['This is a capacity view, not a performance view. The underlying systems ' +'record no status-change timestamps, no completion times, no hours worked, ' +'and no client response latency. You therefore cannot say anyone was slow, ' +'fast, behind, ahead, productive, or unproductive, and you must not imply ' +'it. If asked to, say the data does not support it.','AGE IS NOT TIME ON SOMEONE\'S DESK. `ageDays` counts from the day the order ' +'was placed. A line thirty days old may have spent twenty-nine of them ' +'waiting on a client reply. Never attribute an age to a person, and never ' +'describe an old item as neglected.','Do not rank or compare people. Different roles carry different work, ' +'assignment is a routing default in some systems, and the counts are not ' +'commensurable. Report what each person is holding; draw no league table.','Report CONCURRENCY, not throughput. How much someone is holding at once is ' +'measurable and is what predicts a small team dropping something. How fast ' +'they work is not in this data at all.','When something looks stuck, the supported explanations are structural: it is ' +'waiting on a client or vendor, it needs a second person before it can ' +'close (see `handoffPoints`), or one person is the only cover for that area ' +'(see `soleCoverage`). Name the structural cause. Do not reach past it to a ' +'person.','Team size is context for every number here. Read the counts against ' +'`teamSize` before describing anything as a lot or a little.',]// --- shape ------------------------------------------------------------------export interface PayloadSource {name: stringok: booleancovers: stringerror: string | null}export interface PayloadOrder {orderNumber: string/** Null means the export carried no product name. Say "not recorded", not "". */product: string | nullquantity: numberstatus: stringdesigner: string | nullaccountOwner: string | nullcompany: string | nullageDays: number | nullinDesignQueue: boolean}export interface PayloadTodo {title: string/** As Ninety spells it. What a person recognises. */owner: string/*** Canonical roster name, or null when this owner matches nobody on the roster.* Null is a data problem, not an empty value: a to-do behind an unmapped* owner is missing from every per-person view, and missing looks like done.*/ownerKey: string | nulldueDate: string | null/** Past due and past at least one review gate. The number worth acting on. */late: booleanreviewsMissed: number/** Other people carrying the same commitment. Empty for solo work. */sharedWith: string[]/** Set when the to-do hangs off a rock milestone. */linkedToRock: boolean/*** Readable only by the leadership-cleared readers. See config/ninety-teams.ts;* an untagged to-do counts as restricted, because an item with no team could be* anything.** This field is the only thing separating the two company briefs.* `brief.py`'s `without_restricted()` drops every row carrying it before the* all-hands prompt is built, because prose cannot be redacted once a model has* written it. If it were missing, the filter would run, match nothing, and* fail open on a confidentiality boundary, invisibly from either side.*/restricted: boolean}export interface PayloadPerson {name: stringrole: string/** Order lines where this person owes the next move. Null when uncovered. */ordersOwed: number | null/** Open Bitrix tasks. Null when uncovered. */tasksOpen: number | null/** Ninety to-dos assigned to them. Null when the source failed. */todosOpen: number | nulltodosLate: number | null/** Their oldest outstanding item, in days. Null when nothing or uncovered. */oldestDays: number | null/** Which systems can see this person at all. */coverage: string[]/** True when no source covers them. Must never be presented as "no work". */untracked: boolean/** Their own orders, capped. Empty array is real; null would mean uncovered. */orders: PayloadOrder[]todos: PayloadTodo[]/*** Meetings this person attended, with the action items from them. Null when* Fathom is not configured or the fetch failed, never [] for that, because* "no meetings" and "not tracked" lead to different briefings.** Nothing meeting-derived appears outside this field. The company and exec* slices carry none, because the briefings written from them are read by* people who were not in the room. Gating the rendered page would do nothing* about a model handed a leadership meeting and asked to summarise the week.*/meetings:| {title: stringdate: stringitems: { text: string; owner: string | null; unowned: boolean }[]}[]| null}export interface BriefingPayload {schemaVersion: stringgeneratedAt: string/*** 'fixtures' means this payload describes a frozen snapshot, not today.** A model handed fixture data with no marker writes a confident briefing about* orders that shipped two weeks ago and names people as holding work they* finished. The rules array gains an explicit instruction in this mode, and* `analysis/brief.py` refuses to run without --allow-fixtures.*/mode: 'live' | 'fixtures'/** When frozen data was captured. Null on a live run. */capturedAt: string | null/** Calendar day in Meridian's timezone. Group and compare on this, not on UTC. */day: stringtimezone: stringrules: string[]sources: PayloadSource[]company: {ourMove: numberdesignQueue: numberclientReplied: numberwaitingOnClient: numberatVendor: number/** Still in play: not noise, not terminal. */openLeads: numberautomatedLeadsExcluded: number/*** Leads excluded as finished, not as noise. Reported so a briefing can say* "45 open, 270 closed this period" rather than implying the pipeline* shrank.*/completedLeadsExcluded: numbercommitments: number | nullassignments: number | nullsharedCommitments: number | nulllateTodos: number | null}people: PayloadPerson[]/** The leadership view. Capacity and structure only; see ExecView. */exec: ExecView/*** Extra rules a leadership briefing must follow, on top of `rules`.* Shipped separately so the exec consumer can put them first: they are the* constraining ones, and a model running short on attention should lose style* guidance rather than limits.*/execRules: string[]queues: {/** Oldest first. The top of this is what is actually rotting. */oldestOrders: PayloadOrder[]unassignedOrders: PayloadOrder[]lateTodos: PayloadTodo[]truncated: boolean}dataHealth: {warnings: string[]unknownProductStatuses: string[]/** Order lines with no designer. Not a queue: a gap in the export. */ordersMissingDesigner: number/*** Ninety owners matching nobody on the roster. Their to-dos are counted in* the company totals but appear on no per-person view, so this being* non-empty means someone's work is invisible. Add them to MERIDIAN_PEOPLE with* their `ninetyId` from `pnpm roster`, or declare them in* KNOWN_NON_ROSTER_NINETY if they are not staff. Do not reach for `aliases`:* matching on a display name is how people end up resolving to nobody.*/unmappedTodoOwners: { id: string; name: string }[]/*** How many fetched lines carried each Plinth assignment field* (production_owner, account_owner). They arrive on the API with every* sync, so the question is whether they arrived, not how old they are.*/assignmentFieldCoverage: { production: number; accountOwner: number; total: number }junkTasksExcluded: number | null}}// --- build ------------------------------------------------------------------function toPayloadOrder(r: OrderQueueRow): PayloadOrder {return {orderNumber: r.orderNumber,product: r.product,quantity: r.quantity,status: r.status,designer: r.designer,accountOwner: r.accountOwner,company: r.company,ageDays: r.ageDays,inDesignQueue: r.designQueue,}}function toPayloadTodos(todos: Todo[], now: Date): PayloadTodo[] {// Shared-commitment membership, so a briefing can say "with Marco and Ines"// instead of silently repeating the same item on three people's lists as if// it were three separate jobs.const sharedBy = new Map<string, string[]>()for (const g of assignmentGroups(todos)) {if (!g.shared) continuefor (const t of g.todos) {sharedBy.set(t.id,g.owners.filter((o) => o.id !== t.owner.id).map((o) => o.name),)}}return todos.map((t) => ({title: t.title,owner: t.owner.name,ownerKey: personFromNinety(t.owner.id, t.owner.name)?.name ?? null,dueDate: t.dueDateLocal,late: isActuallyLate(t.dueDate, now),reviewsMissed: reviewGatesMissed(t.dueDate, now),sharedWith: sharedBy.get(t.id) ?? [],linkedToRock: t.milestoneId !== null,restricted: isRestrictedTeam(t.teamId, t.teamName),}))}/*** What the data cannot show. Stated positively, as facts about the systems.** These are not hedges. Each one is a documented limitation with a source, and* together they are the reason a performance reading is unavailable rather than* merely discouraged.*/export const UNMEASURABLE: string[] = ['Time in status. Plinth records no status-change timestamps, so an order line\'s ' +'age counts from the day it was placed, not from when it reached its ' +'current step, and not from when anyone picked it up.','Time spent waiting on a client. Client reply latency is not recorded ' +'anywhere, so a long-running line cannot be separated into work time and ' +'wait time.','Completion time, throughput, or effort per person. No system records when a ' +'person finished a thing, how long it took, or how many times they were ' +'interrupted.','Whether an open to-do is unfinished. Meridian policy closes to-dos only at ' +'Monday and Friday reviews, so completed work stays open until sign-off.','Nothing about assignment freshness. Designer and CS assignment arrive on the ' +'Plinth API response in the same sync as the orders, so they are as fresh as ' +'generatedAt; a line reported with no assignee is one Plinth holds as unassigned.','Work that exists outside these four systems: phone calls, chats, walk-ups, ' +'and anything handled without a ticket.',]function buildExecView(snap: Snapshot, queue: OrderQueueRow[], todos: Todo[]): ExecView {const lines = snap.orderLines.dataconst cls = (l: OrderLine) => classifyOrder(l.orderStatus, l.orderStatusId || null)// A line needing both a designer and a CS rep cannot be closed by one person.// That is a coordination cost, and on a small team it is usually the real// constraint rather than anything about how hard people are working.const handoffPoints = queue.filter((r) => r.designer && r.accountOwner).lengthconst designers = new Set(lines.map((l) => l.productionOwner).filter(Boolean) as string[])const cs = new Set(lines.map((l) => l.accountOwner).filter(Boolean) as string[])const soleCoverage: ExecView['soleCoverage'] = []if (designers.size === 1) soleCoverage.push({ area: 'Design / artwork', person: [...designers][0]! })if (cs.size === 1) soleCoverage.push({ area: 'Client success on orders', person: [...cs][0]! })const groups = assignmentGroups(todos)const load = MERIDIAN_PEOPLE.map((p: Person) => {const coversPlinth = p.coveredBy.some((c) => c.startsWith('plinth-'))const mine = coversPlinth? lines.filter((l) => l.productionOwner === p.name || l.accountOwner === p.name): []const theirTodos = todos.filter((t) => personFromNinety(t.owner.id, t.owner.name)?.name === p.name)return {name: p.name,role: p.role,holding: coversPlinth ? mine.filter((l) => cls(l) === 'ours').length : null,parked: coversPlinth ? mine.filter((l) => cls(l) === 'client' || cls(l) === 'vendor').length : null,commitments: snap.ninety.ok ? theirTodos.length : null,shared: groups.filter((g) => g.shared && g.owners.some((o) => personFromNinety(o.id, o.name)?.name === p.name)).length,handoffs: coversPlinth? mine.filter((l) => l.productionOwner && l.accountOwner && cls(l) === 'ours').length: null,}})const h = headline(snap)return {teamSize: MERIDIAN_PEOPLE.length,ourMove: h.ourMove,waitingOnClient: h.waitingOnClient,atVendor: h.atVendor,commitments: h.commitments,assignments: h.assignments,handoffPoints,soleCoverage,load,unmeasurable: UNMEASURABLE,}}/*** The Fathom row for the payload's source list.** `ok` is false for a partial read as well as a total one, and that is* deliberate: the question this field answers for the model is "may I describe* the meeting lists below as complete", and the answer with one key dark is no.* The error text carries the distinction a human needs: which owner, and what* the API actually said.*/function fathomSource(snap: Snapshot): {name: stringok: booleancovers: stringerror: string | null} {const covers = 'meeting action items, gated to the people who were in the room'if (!snap.fathomConfigured) {return {name: 'Fathom meetings',ok: false,covers,// Not tracked, not empty. Without this the model reads `meetings: null`// on every person and has no way to tell "nobody was asked" from "nothing// was said": the prime invariant, arriving at a model instead of a page.error: 'No Fathom key is configured, so no meeting is on this payload. Not zero meetings.',}}if (!snap.meetings.ok) {return { name: 'Fathom meetings', ok: false, covers, error: snap.meetings.error }}const dark = snap.fathomSources.filter((k) => !k.ok)const capped = snap.fathomSources.filter((k) => k.ok && k.truncated)if (!dark.length && !capped.length) {return { name: 'Fathom meetings', ok: true, covers, error: null }}return {name: 'Fathom meetings',ok: false,covers,error:[dark.length? `${dark.map((k) => `${k.owner}'s key failed (${k.error})`).join('; ')}.`: '',capped.length? `${capped.map((k) => k.owner).join(', ')} hit the page cap, so that sweep is partial.`: '',`Meetings recorded by those people are missing from this payload rather than absent from ` +`the fortnight. The rest of the keys read cleanly and their meetings are here.`,].filter(Boolean).join(' '),}}export function buildBriefingPayload(snap: Snapshot, now = new Date()): BriefingPayload {const h: Headline = headline(snap)const lines = snap.orderLines.dataconst queue = orderQueue(lines)const todos = snap.ninety.ok ? snap.ninety.data : []const allTodos = toPayloadTodos(todos, now)const loads = todoLoad(todos, now)const { real: realTasks, junk } = partitionTasks(snap.tasks.data)const unassigned = queue.filter((r) => r.designer === null)const lateTodos = allTodos.filter((t) => t.late)const people: PayloadPerson[] = MERIDIAN_PEOPLE.map((p: Person) => {const coversPlinth = p.coveredBy.some((c) => c.startsWith('plinth-'))const coversTasks = p.coveredBy.includes('bitrix-tasks')const theirOrders = coversPlinth? queue.filter((r) => r.designer === p.name || r.accountOwner === p.name): []const theirTasks = coversTasks? realTasks.filter((t) => t.responsibleId === p.name): []const load = loads.find((l) => l.name === p.name)// Resolved through the roster, not by string-matching display names.// Ninety spells "Samuel Whitaker" where the roster reads "Sam Whitaker";// an equality join would silently attribute nothing to them.const theirTodos = allTodos.filter((t) => t.ownerKey === p.name)const ages = theirOrders.map((r) => r.ageDays).filter((d): d is number => d !== null)return {name: p.name,role: p.role,ordersOwed: coversPlinth ? theirOrders.length : null,tasksOpen: coversTasks ? theirTasks.length : null,todosOpen: snap.ninety.ok ? (load?.open ?? 0) : null,todosLate: snap.ninety.ok ? (load?.late ?? 0) : null,oldestDays: ages.length ? Math.max(...ages) : null,coverage: p.coveredBy.map((c) => SOURCE_LABELS[c] ?? c),untracked: p.coveredBy.length === 0,orders: theirOrders.slice(0, LIST_CAP).map(toPayloadOrder),todos: theirTodos.slice(0, LIST_CAP),// Gated by participation, exactly as the rendered page is. See the field's// declaration for why the payload is its own leak path.meetings:snap.fathomConfigured && snap.meetings.ok? snap.meetings.data.filter((m) => m.participants.includes(p.name)).slice(0, LIST_CAP).map((m) => ({title: m.title,date: m.date,items: m.items.slice(0, LIST_CAP).map((i) => ({text: i.text,owner: i.owner,unowned: !i.owner && !i.externalOwner && !i.unknownOwner,})),})): null,}})const truncated =queue.length > LIST_CAP || unassigned.length > LIST_CAP || lateTodos.length > LIST_CAPreturn {schemaVersion: PAYLOAD_SCHEMA_VERSION,generatedAt: snap.syncedAt,mode: snap.mode,capturedAt: snap.fixtureCapturedAt,day: localDay(snap.syncedAt, MERIDIAN_TZ) ?? snap.syncedAt.slice(0, 10),timezone: MERIDIAN_TZ,rules:snap.mode === 'fixtures'? [`THIS IS SAMPLE DATA captured ${snap.fixtureCapturedAt?.slice(0, 10) ?? 'earlier'}, ` +`not today's work. Orders described here as open have since shipped. Do not present ` +`any of it as current, do not name anyone as holding work, and say plainly in your ` +`first sentence that this is a sample.`,...INTERPRETATION_RULES,]: INTERPRETATION_RULES,sources: [{name: 'Bitrix leads',ok: snap.leads.ok,covers: 'open leads and their stage',error: snap.leads.error,},{name: 'Bitrix tasks',ok: snap.tasks.ok,covers: 'internal task queue',error: snap.tasks.error,},{name: 'Plinth order lines',ok: snap.orderLines.ok,covers: 'orders, product status, designer assignment',error: snap.orderLines.error,},{name: 'Ninety to-dos',ok: snap.ninety.ok,covers: 'company-wide commitments, all people, one admin token',error: snap.ninety.error,},// Fathom belongs on this list because each person's `meetings` array is// already gated to what they attended, so a short one is normal, and// without this row nothing in the payload would tell the model the// difference between a quiet fortnight and a key that could not be read. brief.py turns an `ok: false` row into a warning naming the// source, which is how a dark key reaches a human.fathomSource(snap),],company: {ourMove: h.ourMove,designQueue: h.designQueue,clientReplied: h.needsReply,waitingOnClient: h.waitingOnClient,atVendor: h.atVendor,openLeads: h.realLeads,automatedLeadsExcluded: h.noiseLeads,completedLeadsExcluded: h.completedLeads,commitments: h.commitments,assignments: h.assignments,sharedCommitments: h.sharedCommitments,lateTodos: h.lateTodos,},people,exec: buildExecView(snap, queue, todos),execRules: EXEC_RULES,queues: {oldestOrders: queue.slice(0, LIST_CAP).map(toPayloadOrder),unassignedOrders: unassigned.slice(0, LIST_CAP).map(toPayloadOrder),lateTodos: lateTodos.slice(0, LIST_CAP),truncated,},dataHealth: {warnings: snap.assignmentWarnings,unknownProductStatuses: h.unknownStatuses,ordersMissingDesigner: unassigned.length,unmappedTodoOwners: unmappedNinetyOwners(todos.map((t) => ({ id: t.owner.id, name: t.owner.name })),),assignmentFieldCoverage: snap.orderWindow.assignmentFields,junkTasksExcluded: snap.tasks.ok ? junk.length : null,},}}