/** * The page-scoped read tools Piggy gets while docked. * * The equivalent answers already exist in the MCP server, but every one of * those tools is an authenticated HTTP call carrying a `pig_…` API key. Piggy * has no way to mint one and calling the API back through the network to read * a database it already holds a handle to would be a round trip for nothing — * so the queries are ported here as direct Drizzle reads. * * The calendar is the exception, and deliberately so. Its projection spans * thirteen kinds across nine tables and it is the answer a user is looking at * on /calendar; a second implementation here would not merely duplicate it, * it would disagree with it, and Piggy contradicting the page it has just been * told it is reading is worse than Piggy having no calendar tool. So @pig/piggy * depends on @pig/api and calls `CalendarService` in-process — the service * layer takes a `Database`, not a request, precisely so it can be called this * way. Lifting it into @pig/core instead would drag nine table imports into a * package the browser bundles. * * The hard constraint is size, not capability. Interactive chat runs at * `max_tokens` 1024 across at most four turns, so a tool that returns rows * spends the whole budget on transcription and truncates mid-answer. Every * result here is aggregated first and capped at a handful of exemplar rows: * the model is given the conclusion and enough evidence to quote, never the * ledger. The bounded reads that feed them are wide, but that width never * leaves this process. */ import { ACCOUNT_SIDES, CONSUMING_ALLOCATION_STATUSES, DEMAND_OPEN_STAGES, RESERVING_ALLOCATION_STATUSES, SUPPLY_OPEN_STAGES, aggregateMargin, breakEvenPricePerGpuHourCents, computeMargin, formatCents, type AllocationInput, type CalendarEvent, type CalendarEventKind, type MarginResult, type PiggyPageRoute, } from '@pig/core'; import { accounts, allocations, capacityCommitments, contacts, demandDeals, supplyDeals, type Database, } from '@pig/db'; import { CalendarService } from '@pig/api/src/services/calendar'; import { and, count, gte, inArray, isNotNull, isNull } from 'drizzle-orm'; import { z } from 'zod'; import { piggyPageGuide, type PiggyPageToolName } from './page-routes'; import { defineTool, type AgentTool } from './provider'; const noInput = z.object({}).strict(); /** How many exemplar rows a result may carry. Everything else is a total. */ const EXEMPLARS = 8; /** Bound on the internal read. Wide enough for a real book, still finite. */ const SCAN_LIMIT = 500; /** * A bounded read that knows whether it was bounded. * * Every list here is capped, and a cap the caller cannot see is how a * book-level figure ends up asserted over an arbitrary slice: the model is * told these results are already aggregated and quotes them verbatim. So each * read asks for one row more than its budget — the same trick the calendar * service uses — and every result that could have been cut carries the flag. */ function bounded(rows: Row[], limit = SCAN_LIMIT): { rows: Row[]; truncated: boolean } { const truncated = rows.length > limit; return { rows: truncated ? rows.slice(0, limit) : rows, truncated }; } /** * Written into the headline because that is the field the model quotes. A * `truncated: true` sitting further down the payload is routinely ignored. */ const TRUNCATION_NOTE = 'One or more reads hit their row cap, so these figures cover part of a larger ' + 'book — present them as a lower bound, not as the whole.'; /** Prefixes a count the model must not read as exact. */ export function atLeast(rows: number, truncated: boolean): string { return truncated ? `at least ${rows}` : `${rows}`; } /** * What a result covers, and what it was drawn from. * * Measured in production on /capacity, an hour before this was written. Asked * "How many capacity commitments are on the book?", the model called * `pig_get_idle_capacity` — the only tool that page offers — and answered "3". * The book held 5. The tool filters to blocks at least 25% unsold, so 3 was the * size of a filter; nothing in the payload said so, and reading the length of * the list it had been handed as the size of the book was the only reading the * data supported. * * The system prompt already forbade exactly that, naming this exact tool, and * the model did it anyway. Prompting a 30B model out of a mistake its data * invites does not work, so the data stopped inviting it: every result here * that carries a count or a collection carries this object beside it, naming * the filter, the denominator it was drawn from, and how much of the matched * set is actually listed. A filtered count is therefore never the only number * in its own result. * * One shape to learn rather than one per tool — a different shape per tool is * how this happened. The result's primary subject gets a top-level `scope`; * every other count or collection in the same payload gets its own, nested * where the payload already groups it and named `Scope` where it does * not. And `summary` restates the numbers as prose on purpose — it is the * field a small model quotes, and a figure it has to assemble out of three * other fields is a figure it will assemble wrongly. * * `filters` is not decoration either. This product has shipped three different * idle figures across three surfaces because each applied its own threshold, so * a result that does not name the threshold it used cannot be reconciled with * the screen beside it. */ export interface ResultScope { /** The whole scope in one sentence, figures included. Quote this. */ summary: string; /** What made a row match, as a clause: "are at least 25% unsold". */ covers: string; /** How many rows matched. Never the answer to "how many are there". */ matched: number; /** The set `matched` was drawn from. This is the total. */ total: number; /** What `total` counts, as a noun phrase. */ totalLabel: string; /** How many of `matched` this payload lists. The rest are counted only. */ listed: number; /** Every filter applied, named, so a threshold is never invisible. */ filters: Record; /** True when a read hit its row cap, so both figures are lower bounds. */ truncated: boolean; } export function resultScope(input: { covers: string; matched: number; total: number; totalLabel: string; listed: number; filters?: Record; truncated?: boolean; }): ResultScope { const { covers, matched, total, totalLabel, listed } = input; const filters = input.filters ?? {}; const truncated = input.truncated ?? false; // Nothing was filtered out, so `matched` IS the total and saying otherwise // would teach the model to distrust a figure that is exact. const unfiltered = matched === total && Object.keys(filters).length === 0; const listedClause = listed > 0 ? `; ${listed} listed here` : ''; // Both figures are hedged together when a read was cut. Hedging only the // total would present a capped `matched` as exact, which is the same class of // overstatement this whole object exists to stop. const summary = unfiltered ? `All ${atLeast(total, truncated)} ${totalLabel}${listedClause}.` : `${atLeast(matched, truncated)} of ${atLeast(total, truncated)} ${totalLabel} ` + `${covers}${listedClause}. That is a filtered count — the total is ` + `${atLeast(total, truncated)} ${totalLabel}.`; return { summary: truncated ? `${summary} ${TRUNCATION_NOTE}` : summary, covers, matched, total, totalLabel, listed, filters, truncated, }; } /** The denominator every commitment figure in this file is drawn from. */ const COMMITMENTS_LABEL = 'live capacity commitment(s) on the book'; /** The denominators the two pipelines are drawn from. */ const DEMAND_DEALS_LABEL = 'demand deal(s) on the book'; const SUPPLY_DEALS_LABEL = 'supply deal(s) on the book'; /** The denominators the two party tables are drawn from. */ const ACCOUNTS_LABEL = 'account(s) on the book'; const CONTACTS_LABEL = 'contact(s) in the CRM'; /** One whole-table count, for use as a denominator. */ function rowCount(rows: readonly { value: number }[]): number { return rows[0]?.value ?? 0; } /** * One tool per page. The dock is present everywhere, so the model sees this * list on every message — a second tool would be a second thing to choose * wrongly, and choosing wrongly costs one of four turns. */ export function createPagePigTools(db: Database, route: PiggyPageRoute): AgentTool[] { return [pageTool(db, piggyPageGuide(route).tool)]; } function pageTool(db: Database, name: PiggyPageToolName): AgentTool { switch (name) { case 'pig_get_margin_summary': return defineTool({ name, description: 'Read book-level margin across every live capacity commitment: revenue, cost, ' + 'gross margin, utilisation and idle hours, plus the largest blocks. Cost is charged ' + 'against the full commitment, not only the hours that sold.', inputSchema: noInput, execute: async () => readMarginSummary(db), }); case 'pig_get_idle_capacity': return defineTool({ name, description: 'Read committed capacity that is bought and unsold, ranked by what the idle hours ' + 'cost, with the break-even price for the remainder of each block.', inputSchema: noInput, execute: async () => readIdleCapacity(db), }); case 'pig_get_pipeline': return defineTool({ name, description: 'Read the open demand and supply pipelines: how many deals sit at each stage, what ' + 'they are worth, and the largest few on each side.', inputSchema: noInput, execute: async () => readPipeline(db), }); case 'pig_get_calendar_ahead': return defineTool({ name, description: 'Read the same calendar projection the /calendar page renders: everything dated in ' + 'the near future — deals expected to close, contract effective, expiry and execution ' + 'dates, renewal notices, obligations due, capacity and allocation windows, hold ' + 'expiries, supply availability, export authorisation and compliance artefact ' + 'expiries, and calendar entries — plus what is already overdue.', inputSchema: z .object({ /** * `.nullish()` rather than `.optional()`, and `.describe()` before * it rather than after. * * `zodToJsonSchema(..., { target: 'openAi' })` emits an optional * field as required-and-nullable, so a model that follows the * schema it was handed sends `{"withinDays": null}` — which * `.optional()` rejects, spending one of four turns on a tool * result that reads as a failure. Described after the wrapper, the * sentence is dropped from the emitted schema entirely and the * default is never communicated. */ withinDays: z .number() .int() .min(1) .max(365) .describe('Horizon in days. null uses the default of 30.') .nullish(), }) .strict(), execute: async ({ withinDays }) => readCalendarAhead(db, withinDays ?? 30), }); case 'pig_get_workspace_summary': return defineTool({ name, description: 'Read a bounded overview of the PIG workspace: how many accounts (by side) and ' + 'contacts are on the book, book margin and utilisation, how many deals exist and how ' + 'many are open on each side, and the worst idle capacity. Counts only — it returns no ' + 'account, contact or deal rows, and it cannot see the team, settings, imports or ' + 'facts. This cannot inspect the filesystem or external systems.', inputSchema: noInput, execute: async () => readWorkspaceSummary(db), }); } } // --------------------------------------------------------------------------- // The book // --------------------------------------------------------------------------- interface LiveBlock { name: string; gpuType: string; gpuCount: number; startsAt: Date; endsAt: Date; totalGpuHours: number; soldGpuHours: number; /** Held by a live hold: removed from availability, but not revenue. */ heldGpuHours: number; costPerGpuHourCents: number; /** The sold slices, kept so book totals can sum cents rather than ratios. */ sold: readonly AllocationInput[]; margin: MarginResult; breakEvenPriceCents: number | null; } interface LiveBook { blocks: LiveBlock[]; /** True when the book is wider than SCAN_LIMIT, so the totals are partial. */ truncated: boolean; } /** * Live commitments with sold and held hours counted separately. * * A port of `CapacityService.availability`, minus the shape integration and * matching the API does not need here. Sold and held stay distinct because a * pipeline of optimistic holds must never be able to make the book look full. * Expired holds are ignored rather than swept, so the figures are right even * when the cleanup job is behind. * * The cap is reported rather than hidden: revenue, cost and gross margin here * are sums over whatever came back, and past 500 live commitments that is an * arbitrary slice of the book being stated as the book. */ async function readLiveBlocks(db: Database, now = new Date()): Promise { const { rows: commitments, truncated } = bounded( await db .select() .from(capacityCommitments) .where(and(isNull(capacityCommitments.terminatedAt), gte(capacityCommitments.endsAt, now))) .limit(SCAN_LIMIT + 1), ); if (commitments.length === 0) return { blocks: [], truncated }; const reservations = await db .select() .from(allocations) .where( and( inArray( allocations.capacityCommitmentId, commitments.map((commitment) => commitment.id), ), inArray(allocations.status, [...RESERVING_ALLOCATION_STATUSES]), ), ); const blocks = commitments.map((commitment) => { const mine = reservations.filter((row) => row.capacityCommitmentId === commitment.id); let soldGpuHours = 0; let heldGpuHours = 0; for (const row of mine) { // numeric columns arrive as strings; adding them unconverted concatenates. const hours = Number(row.gpuHours); if ((CONSUMING_ALLOCATION_STATUSES as readonly string[]).includes(row.status)) { soldGpuHours += hours; } else if (!row.holdExpiresAt || row.holdExpiresAt > now) { heldGpuHours += hours; } } const book = { gpuHours: Number(commitment.totalGpuHours), costPerGpuHourCents: commitment.costPerGpuHourCents, }; const sold = mine .filter((row) => (CONSUMING_ALLOCATION_STATUSES as readonly string[]).includes(row.status)) .map((row) => ({ gpuHours: Number(row.gpuHours), pricePerGpuHourCents: row.pricePerGpuHourCents, })); return { name: commitment.name, gpuType: commitment.gpuType, gpuCount: commitment.gpuCount, startsAt: commitment.startsAt, endsAt: commitment.endsAt, totalGpuHours: book.gpuHours, soldGpuHours, heldGpuHours, costPerGpuHourCents: commitment.costPerGpuHourCents, sold, margin: computeMargin(book, sold), breakEvenPriceCents: breakEvenPricePerGpuHourCents(book, sold), }; }); return { blocks, truncated }; } function bookTotals(blocks: readonly LiveBlock[]): MarginResult { // Sum cents, never average per-block percentages: an average of ratios // weights a tiny block equally with a huge one. return aggregateMargin( blocks.map((block) => ({ commitment: { gpuHours: block.totalGpuHours, costPerGpuHourCents: block.costPerGpuHourCents, }, allocations: block.sold, })), ); } async function readMarginSummary(db: Database): Promise { const { blocks, truncated } = await readLiveBlocks(db); const totals = bookTotals(blocks); const largest = [...blocks] .sort((a, b) => b.margin.costCents - a.margin.costCents) .slice(0, EXEMPLARS); return { headline: `Revenue ${formatCents(totals.revenueCents)} against cost ${formatCents(totals.costCents)}; ` + `gross margin ${formatCents(totals.grossMarginCents)} (${percent(totals.grossMarginPct)}) ` + `at ${percent(totals.utilisation)} utilisation across all ` + `${atLeast(blocks.length, truncated)} ${COMMITMENTS_LABEL} — the whole book, unfiltered. ` + `The ${largest.length} largest by cost are listed; the book holds ` + `${atLeast(blocks.length, truncated)}.` + (truncated ? ` ${TRUNCATION_NOTE}` : ''), /** * Unfiltered, and the only tool here that is: `matched` equals `total`, so * `liveCommitments` below is a real answer to "how many are on the book". * `largestBlocks` is still a slice, which is what `listed` is for. */ scope: resultScope({ covers: 'are live', matched: blocks.length, total: blocks.length, totalLabel: COMMITMENTS_LABEL, listed: largest.length, truncated, }), truncated, totals: { revenueCents: totals.revenueCents, costCents: totals.costCents, grossMarginCents: totals.grossMarginCents, grossMarginPct: totals.grossMarginPct, utilisation: totals.utilisation, idleGpuHours: Math.round(totals.idleGpuHours), marginPerAllocatedGpuHourCents: totals.marginPerAllocatedGpuHourCents, }, liveCommitments: blocks.length, largestBlocks: largest.map((block) => ({ name: block.name, gpuType: block.gpuType, utilisation: block.margin.utilisation, soldGpuHours: Math.round(block.soldGpuHours), totalGpuHours: Math.round(block.totalGpuHours), costPerGpuHourCents: block.costPerGpuHourCents, grossMarginCents: block.margin.grossMarginCents, })), }; } /** * The threshold and horizon this tool filters on. * * The same defaults the API and MCP use — and NOT the same as the workspace * summary's worst-idle list, which takes any block with idle hours at all. * Both are correct for what they answer and they return different counts, so * each states its own threshold in `filters` rather than leaving the reader to * reconcile two figures that were never the same figure. */ const IDLE_THRESHOLD_PCT = 0.25; const IDLE_WITHIN_DAYS = 30; /** Idle blocks, on the same defaults the API and MCP use: 25% within 30 days. */ async function readIdleCapacity(db: Database): Promise { const now = new Date(); const horizon = new Date(now.getTime() + IDLE_WITHIN_DAYS * 86_400_000); const { blocks, truncated } = await readLiveBlocks(db, now); const idle = blocks .filter( (block) => block.startsAt <= horizon && 1 - block.margin.utilisation >= IDLE_THRESHOLD_PCT, ) .map((block) => ({ block, idleGpuHours: block.margin.idleGpuHours, // The number that makes the case: what the unsold hours already cost us. idleCostCents: Math.round(block.margin.idleGpuHours * block.costPerGpuHourCents), })) .sort((a, b) => b.idleCostCents - a.idleCostCents); const totalIdleCostCents = idle.reduce((sum, row) => sum + row.idleCostCents, 0); const listed = idle.slice(0, EXEMPLARS); const filter = `are at least ${IDLE_THRESHOLD_PCT * 100}% unsold and start within ` + `${IDLE_WITHIN_DAYS} day(s)`; return { /** * The sentence the production defect was answered from, so it carries the * denominator first and the filtered figure second. "3" alone was true of * the filter and false of the book; "3 of 5" cannot be misread as 5. */ headline: (idle.length === 0 ? `None of the ${atLeast(blocks.length, truncated)} ${COMMITMENTS_LABEL} ${filter}.` : `${idle.length} of ${atLeast(blocks.length, truncated)} ${COMMITMENTS_LABEL} ${filter}, ` + `${formatCents(totalIdleCostCents)} of capacity bought and not yet earning. ` + `${idle.length} is a filtered count — the book holds ` + `${atLeast(blocks.length, truncated)} live commitment(s) in total.`) + (truncated ? ` ${TRUNCATION_NOTE}` : ''), scope: resultScope({ covers: filter, matched: idle.length, total: blocks.length, totalLabel: COMMITMENTS_LABEL, listed: listed.length, filters: { idleThresholdPct: IDLE_THRESHOLD_PCT, withinDays: IDLE_WITHIN_DAYS }, truncated, }), truncated, /** The denominator, repeated as a bare field: this is the size of the book. */ liveCommitments: blocks.length, thresholdPct: IDLE_THRESHOLD_PCT, withinDays: IDLE_WITHIN_DAYS, idleBlocks: idle.length, totalIdleCostCents, blocks: listed.map((row) => ({ name: row.block.name, gpuType: row.block.gpuType, gpuCount: row.block.gpuCount, utilisation: row.block.margin.utilisation, idleGpuHours: Math.round(row.idleGpuHours), idleCostCents: row.idleCostCents, // What the rest of the block must fetch to come out even. breakEvenPricePerGpuHourCents: row.block.breakEvenPriceCents, endsAt: row.block.endsAt.toISOString(), })), }; } // --------------------------------------------------------------------------- // The two pipelines // --------------------------------------------------------------------------- async function readPipeline(db: Database): Promise { // The two whole-table counts are the denominators. Without them "12 open // demand deals" is a filtered count with nothing to be filtered from, and // "how many deals do we have" is answered with the number of open ones. const [demandRead, supplyRead, demandAll, supplyAll] = await Promise.all([ db .select() .from(demandDeals) .where(inArray(demandDeals.stage, [...DEMAND_OPEN_STAGES])) .limit(SCAN_LIMIT + 1), db .select() .from(supplyDeals) .where(inArray(supplyDeals.stage, [...SUPPLY_OPEN_STAGES])) .limit(SCAN_LIMIT + 1), db.select({ value: count() }).from(demandDeals), db.select({ value: count() }).from(supplyDeals), ]); const { rows: demand, truncated: demandTruncated } = bounded(demandRead); const { rows: supply, truncated: supplyTruncated } = bounded(supplyRead); const truncated = demandTruncated || supplyTruncated; const demandTotal = rowCount(demandAll); const supplyTotal = rowCount(supplyAll); const demandListed = Math.min(demand.length, EXEMPLARS); const supplyListed = Math.min(supply.length, EXEMPLARS); const openStages = 'are at an open stage (not closed, not lost)'; // Total contract value where it is known, annual value otherwise: a deal // valued only by ACV is still worth counting, and treating it as zero would // understate the pipeline rather than admit the gap. const valueOf = (deal: (typeof demand)[number]) => deal.tcvCents ?? deal.acvCents ?? 0; const demandValueCents = demand.reduce((sum, deal) => sum + valueOf(deal), 0); return { headline: `${demand.length} of ${atLeast(demandTotal, demandTruncated)} ${DEMAND_DEALS_LABEL} are open, ` + `worth ${formatCents(demandValueCents)}, and ${supply.length} of ` + `${atLeast(supplyTotal, supplyTruncated)} ${SUPPLY_DEALS_LABEL} are open — ` + `${demand.length + supply.length} of ${atLeast(demandTotal + supplyTotal, truncated)} ` + 'deal(s) on the book in all. Those are open-stage counts, not the size of either pipeline.' + (truncated ? ` ${TRUNCATION_NOTE}` : ''), // Both sides together, so a question about "deals" has a denominator too. scope: resultScope({ covers: openStages, matched: demand.length + supply.length, total: demandTotal + supplyTotal, totalLabel: 'deal(s) on the book, demand and supply together', listed: demandListed + supplyListed, filters: { stages: 'open only' }, truncated, }), truncated: { demandDeals: demandTruncated, supplyDeals: supplyTruncated }, demand: { scope: resultScope({ covers: openStages, matched: demand.length, total: demandTotal, totalLabel: DEMAND_DEALS_LABEL, listed: demandListed, filters: { stages: [...DEMAND_OPEN_STAGES].join(', ') }, truncated: demandTruncated, }), openDeals: demand.length, totalDeals: demandTotal, valueCents: demandValueCents, byStage: countByStage(demand.map((deal) => deal.stage)), largest: [...demand] .sort((a, b) => valueOf(b) - valueOf(a)) .slice(0, EXEMPLARS) .map((deal) => ({ name: deal.name, stage: deal.stage, valueCents: valueOf(deal), expectedCloseDate: deal.expectedCloseDate?.toISOString() ?? null, })), }, supply: { scope: resultScope({ covers: openStages, matched: supply.length, total: supplyTotal, totalLabel: SUPPLY_DEALS_LABEL, listed: supplyListed, filters: { stages: [...SUPPLY_OPEN_STAGES].join(', ') }, truncated: supplyTruncated, }), openDeals: supply.length, totalDeals: supplyTotal, byStage: countByStage(supply.map((deal) => deal.stage)), largest: [...supply] .sort((a, b) => (b.gpuCount ?? 0) - (a.gpuCount ?? 0)) .slice(0, EXEMPLARS) .map((deal) => ({ name: deal.name, stage: deal.stage, gpuType: deal.gpuType, gpuCount: deal.gpuCount, targetCostPerGpuHourCents: deal.targetCostPerGpuHourCents, })), }, }; } function countByStage(stages: readonly string[]): Record { const counts: Record = {}; for (const stage of stages) counts[stage] = (counts[stage] ?? 0) + 1; return counts; } // --------------------------------------------------------------------------- // Dates // --------------------------------------------------------------------------- /** * How far back a lapsed item still counts as this week's problem. * * Unbounded, the overdue arm surfaced whatever was oldest — a stale obligation * from two years ago crowding out a renewal notice that lapsed on Friday. Past * a quarter it is a data-hygiene job, not an operational one, so the window * stops there and the exemplars run most-recent-first within it. */ const OVERDUE_LOOKBACK_DAYS = 90; /** * The kinds that can honestly be late. * * Lateness needs a completion column: an obligation, a renewal notice and a * deal's expected close all have somewhere to record that the thing happened. * A capacity window that has ended is finished, not overdue, and an expiry * that has passed is a state of the world rather than an errand — listing * either as overdue work invents a backlog. */ const OVERDUE_KINDS = [ 'obligation_due', 'renewal_notice', 'expected_close', ] as const satisfies readonly CalendarEventKind[]; /** * What is dated in the near future — the same projection /calendar renders. * * This used to reimplement the projection over two tables. The page shows * thirteen kinds, so Piggy asserted a total that was missing contract * expiries, renewal notices, hold expiries, capacity and allocation windows, * authorisation and artefact expiries, and every human-owned calendar entry. * Being confidently wrong about the screen in front of the reader is the one * failure that costs the tool its credibility, so it calls the service. * * Two projections, not one: overdue work sits BEFORE `now` and the horizon * starts at it, and a single wide window would let a quarter of stale rows * consume the per-source budget that the coming month needs. * * Counts are taken over the full projected set and only then sliced for * exemplars — the previous version interpolated the capped list lengths, so a * book with two hundred overdue obligations reported eight, and the system * prompt tells the model to quote these figures rather than recompute them. */ async function readCalendarAhead(db: Database, withinDays: number): Promise { const now = new Date(); const horizon = new Date(now.getTime() + withinDays * 86_400_000); const lookback = new Date(now.getTime() - OVERDUE_LOOKBACK_DAYS * 86_400_000); const calendar = new CalendarService(db, () => now); const [ahead, behind] = await Promise.all([ calendar.project({ from: now, to: horizon }), calendar.project({ from: lookback, to: now, kinds: OVERDUE_KINDS }), ]); // A done event is a dated fact, not something anyone must act on; the page // shows it greyed out and a count that includes it reads as a workload. const upcoming = ahead.events.filter((event) => event.state !== 'done'); const overdue = behind.events.filter((event) => event.state === 'overdue'); const truncated = ahead.truncated || behind.truncated; const upcomingByKind = countByKind(upcoming); const upcomingListed = Math.min(upcoming.length, EXEMPLARS * 2); const overdueListed = Math.min(overdue.length, EXEMPLARS); /** * Both denominators are windows, not the book, and the labels say so. Nothing * here can answer "how many obligations are there" — only how many fall in * these dates — so a label that read "obligations on the book" would be the * same lie in a different tool. */ const windowLabel = `dated item(s) falling in the next ${withinDays} day(s), done or not`; const lookbackLabel = `dated item(s) in the last ${OVERDUE_LOOKBACK_DAYS} day(s) that can fall late`; return { headline: `Next ${withinDays} day(s) only, not the whole book: ` + `${atLeast(upcoming.length, ahead.truncated)} of ` + `${atLeast(ahead.events.length, ahead.truncated)} dated item(s) ` + `across ${Object.keys(upcomingByKind).length} kind(s), of which ` + `${ahead.totals.obligationCount} obligation(s) due, ${ahead.totals.closingCount} demand ` + `deal(s) expected to close worth ${formatCents(ahead.totals.weightedPipelineCents)} ` + `weighted, ${ahead.totals.renewalCount} renewal notice(s) and ` + `${ahead.totals.expiringAuthorizationCount} export authorisation(s) expiring; ` + `${atLeast(overdue.length, behind.truncated)} item(s) overdue in the last ` + `${OVERDUE_LOOKBACK_DAYS} day(s).` + (truncated ? ` ${TRUNCATION_NOTE}` : ''), scope: resultScope({ covers: 'are still outstanding', matched: upcoming.length, total: ahead.events.length, totalLabel: windowLabel, listed: upcomingListed, filters: { withinDays, state: 'excludes done' }, truncated: ahead.truncated, }), withinDays, truncated, /** * Counted in SQL by the service, so these five stay exact even when a * source truncates. Everything else on this payload is counted off the * event list and moves with `truncated`. */ exactTotals: { obligationsDue: ahead.totals.obligationCount, dealsExpectedToClose: ahead.totals.closingCount, weightedPipelineCents: ahead.totals.weightedPipelineCents, renewalNotices: ahead.totals.renewalCount, expiringExportAuthorizations: ahead.totals.expiringAuthorizationCount, }, // The top-level `scope` is this list's: upcoming work is what the tool is // for, and a second copy of the same eight fields is eight fields of budget. upcoming: { count: upcoming.length, inWindow: ahead.events.length, truncated: ahead.truncated, byKind: upcomingByKind, byState: countByState(upcoming), // Soonest first: the near edge of the horizon is what gets acted on. events: upcoming.slice(0, EXEMPLARS * 2).map(exemplar), }, overdue: { scope: resultScope({ covers: 'have lapsed without being completed', matched: overdue.length, total: behind.events.length, totalLabel: lookbackLabel, listed: overdueListed, filters: { lookbackDays: OVERDUE_LOOKBACK_DAYS, kinds: [...OVERDUE_KINDS].join(', '), }, truncated: behind.truncated, }), count: overdue.length, truncated: behind.truncated, lookbackDays: OVERDUE_LOOKBACK_DAYS, byKind: countByKind(overdue), events: [...overdue] .sort((a, b) => b.startsAt.localeCompare(a.startsAt)) .slice(0, EXEMPLARS) .map(exemplar), }, }; } /** * One event, small enough to quote. `meta`, `id` and the ids are dropped: the * model cannot navigate and a uuid in a 1024-token answer is pure cost. */ function exemplar(event: CalendarEvent): Record { return { kind: event.kind, title: event.title, startsAt: event.startsAt, endsAt: event.endsAt, state: event.state, accountName: event.accountName, amountCents: event.amountCents, }; } function countByKind(events: readonly CalendarEvent[]): Record { const counts: Record = {}; for (const event of events) counts[event.kind] = (counts[event.kind] ?? 0) + 1; return counts; } function countByState(events: readonly CalendarEvent[]): Record { const counts: Record = {}; for (const event of events) counts[event.state] = (counts[event.state] ?? 0) + 1; return counts; } // --------------------------------------------------------------------------- // The parties // --------------------------------------------------------------------------- interface BookParties { /** What the /accounts list shows: every account that is not archived. */ onBook: number; /** Archived accounts, excluded from `onBook` and counted so the gap is visible. */ archived: number; /** The book partitioned by side. The three buckets sum to `onBook`. */ bySide: Record; /** Every contact row, which is what the contacts tab lists. */ contacts: number; } /** * How many accounts and contacts the book holds. * * Measured in production on /accounts, minutes before this was written. Asked * "How many accounts are on the book in total? One sentence.", Piggy answered * "The book contains 7 demand deals (accounts) in total." The book held 17 * accounts and 7 demand deals — so the figure was real, the payload had * correctly scoped it as deals, and the prose relabelled it as accounts. * * This tool is the fallback for /accounts and five other routes, and it carried * commitments, deals, margin and idle hours: no count of accounts or contacts * anywhere. Asked about accounts with no account figure in front of it, the * model reached for the nearest countable thing. That is the sibling of the * defect `ResultScope` was built for — one substitutes the size of a filter for * a total, this one substitutes another noun's total for a total that is simply * absent — and the cure for an absent number is not a firmer instruction. It is * the number. * * Counted in SQL rather than by measuring a list, so these figures are exact * and cannot truncate; every other count in this file rides on a capped read. * * Archived accounts are excluded because `/api/accounts` excludes them, and * Piggy contradicting the list the user is looking at is the failure that costs * the tool its credibility. They are counted rather than silently dropped, so a * figure that differs from a raw table count can still be reconciled. Contacts * are deliberately NOT filtered the same way: `/api/contacts` applies no archive * filter, so every contact row is the denominator that matches the screen. */ async function readParties(db: Database): Promise { const [sides, archived, contactRows] = await Promise.all([ db .select({ side: accounts.side, value: count() }) .from(accounts) .where(isNull(accounts.archivedAt)) .groupBy(accounts.side), db.select({ value: count() }).from(accounts).where(isNotNull(accounts.archivedAt)), db.select({ value: count() }).from(contacts), ]); // Every side is present at zero rather than absent: a missing key reads as // "not known" to a model quoting the payload, and this breakdown is only // trustworthy if it visibly adds up. const bySide: Record = Object.fromEntries( ACCOUNT_SIDES.map((side) => [side, 0]), ); for (const row of sides) bySide[row.side] = row.value; // Summed from the same grouped read the breakdown is printed from. A total // read by a second query can disagree with its own parts under a concurrent // write, and a breakdown that does not add up invites the reader to pick. const onBook = Object.values(bySide).reduce((sum, value) => sum + value, 0); return { onBook, archived: rowCount(archived), bySide, contacts: rowCount(contactRows) }; } /** * The side split as prose, for the headline. * * `supply`, `demand` and `both` partition the book, so these three figures sum * to the total and no account is counted twice. The /accounts side tabs do not * partition it — each tab matches `side = X or side = both`, so the two tabs * overlap — which is why the note below travels with the numbers rather than * being left for the reader to work out from a screen that disagrees. */ function sideClause(bySide: Record): string { return ACCOUNT_SIDES.map((side) => `${bySide[side] ?? 0} ${side}`).join(', '); } const BY_SIDE_NOTE = 'supply, demand and both partition the book: these three figures sum to the total and ' + 'no account is counted twice. An account whose side is both trades on each side of the ' + 'market and is counted once, under both. The side tabs on the /accounts page instead show ' + 'supply plus both, and demand plus both, so those two figures overlap and do not sum.'; // --------------------------------------------------------------------------- // The fallback // --------------------------------------------------------------------------- /** * The default when no page names a better tool. * * This replaced a dump of up to 600 rows. That version could not survive one * turn of a 1024-token budget, so the model saw a truncated ledger and * answered from the fragment it happened to receive. */ async function readWorkspaceSummary(db: Database): Promise { const [book, parties, demandRead, supplyRead, demandAll, supplyAll] = await Promise.all([ readLiveBlocks(db), readParties(db), db .select({ id: demandDeals.id }) .from(demandDeals) .where(inArray(demandDeals.stage, [...DEMAND_OPEN_STAGES])) .limit(SCAN_LIMIT + 1), db .select({ id: supplyDeals.id }) .from(supplyDeals) .where(inArray(supplyDeals.stage, [...SUPPLY_OPEN_STAGES])) .limit(SCAN_LIMIT + 1), db.select({ value: count() }).from(demandDeals), db.select({ value: count() }).from(supplyDeals), ]); const { blocks } = book; const demandTotal = rowCount(demandAll); const supplyTotal = rowCount(supplyAll); const { rows: demand, truncated: demandTruncated } = bounded(demandRead); const { rows: supply, truncated: supplyTruncated } = bounded(supplyRead); const truncated = { commitments: book.truncated, demandDeals: demandTruncated, supplyDeals: supplyTruncated, }; const anyTruncated = Object.values(truncated).some(Boolean); const totals = bookTotals(blocks); /** * Any idle at all, which is a THIRD threshold — `pig_get_idle_capacity` uses * 25% and the web uses its own. Three surfaces have quoted three different * idle counts for one book because of exactly this, so the scope below names * the threshold rather than leaving the reader to guess which one produced * the number in front of them. */ const withIdle = [...blocks] .filter((block) => block.margin.idleGpuHours > 0) .sort( (a, b) => b.margin.idleGpuHours * b.costPerGpuHourCents - a.margin.idleGpuHours * a.costPerGpuHourCents, ); const worstIdle = withIdle.slice(0, 3); return { /** * The parties lead the headline, and that ordering is the fix. * * This sentence is what a small model quotes, and the production answer was * assembled by taking the first countable thing in it. Every count in it now * states the noun it counts immediately beside the figure, and the noun the * six fallback routes are most often asked about — accounts — is no longer * missing from it. */ headline: `${parties.onBook} ${ACCOUNTS_LABEL} (${sideClause(parties.bySide)}) and ` + `${parties.contacts} ${CONTACTS_LABEL}` + (parties.archived > 0 ? `, with a further ${parties.archived} account(s) archived and off the book` : '') + `. All ${atLeast(blocks.length, truncated.commitments)} ${COMMITMENTS_LABEL} at ` + `${percent(totals.utilisation)} utilisation; ` + `gross margin ${formatCents(totals.grossMarginCents)}; ` + `${demand.length} of ${atLeast(demandTotal, demandTruncated)} ${DEMAND_DEALS_LABEL} ` + `and ${supply.length} of ${atLeast(supplyTotal, supplyTruncated)} ${SUPPLY_DEALS_LABEL} ` + `are open. The ${worstIdle.length} block(s) listed below are the worst idle of ` + `${withIdle.length} with any idle hours, not the whole book.` + (anyTruncated ? ` ${TRUNCATION_NOTE}` : ''), // The book, unfiltered: `matched` equals `total`, so this is the figure to // quote when someone asks how large the book is. scope: resultScope({ covers: 'are live', matched: blocks.length, total: blocks.length, totalLabel: COMMITMENTS_LABEL, listed: 0, truncated: truncated.commitments, }), truncated, /** * The two figures whose absence produced the /accounts defect, first in the * payload as well as first in the headline, each with its own scope. Both * are exact: they are SQL counts, so neither can be a lower bound the way * the capped reads below can. */ accounts: { scope: resultScope({ covers: 'are on the book and not archived', matched: parties.onBook, total: parties.onBook, totalLabel: ACCOUNTS_LABEL, listed: 0, }), onBook: parties.onBook, /** Excluded from `onBook`, and from the /accounts list, but not hidden. */ archived: parties.archived, bySide: parties.bySide, bySideNote: BY_SIDE_NOTE, /** * Said in the payload because the route guide cannot say it often enough: * this tool counts accounts, it does not read them. A question about a * named account is a `pig_search_records` question. */ rows: 'not available from this tool — counts only, no account rows', }, contacts: { scope: resultScope({ covers: 'are in the CRM', matched: parties.contacts, total: parties.contacts, totalLabel: CONTACTS_LABEL, listed: 0, }), total: parties.contacts, rows: 'not available from this tool — counts only, no contact rows', }, book: { liveCommitments: blocks.length, revenueCents: totals.revenueCents, costCents: totals.costCents, grossMarginCents: totals.grossMarginCents, utilisation: totals.utilisation, idleGpuHours: Math.round(totals.idleGpuHours), }, // Two filtered counts, each next to the denominator it came from. Without // `totalDemandDeals` beside it, `openDemandDeals` is the only deal figure // in the payload and becomes the answer to "how many deals do we have". openDemandDeals: demand.length, totalDemandDeals: demandTotal, openDemandDealsScope: resultScope({ covers: 'are at an open stage (not closed, not lost)', matched: demand.length, total: demandTotal, totalLabel: DEMAND_DEALS_LABEL, listed: 0, filters: { stages: [...DEMAND_OPEN_STAGES].join(', ') }, truncated: demandTruncated, }), openSupplyDeals: supply.length, totalSupplyDeals: supplyTotal, openSupplyDealsScope: resultScope({ covers: 'are at an open stage (not closed, not lost)', matched: supply.length, total: supplyTotal, totalLabel: SUPPLY_DEALS_LABEL, listed: 0, filters: { stages: [...SUPPLY_OPEN_STAGES].join(', ') }, truncated: supplyTruncated, }), worstIdle: { scope: resultScope({ covers: 'have any unsold hours at all', matched: withIdle.length, total: blocks.length, totalLabel: COMMITMENTS_LABEL, listed: worstIdle.length, // Not 0.25. This list and pig_get_idle_capacity answer different // questions and will disagree; the thresholds say which is which. filters: { idleThresholdPct: 0 }, truncated: truncated.commitments, }), blocks: worstIdle.map((block) => ({ name: block.name, gpuType: block.gpuType, idleGpuHours: Math.round(block.margin.idleGpuHours), idleCostCents: Math.round(block.margin.idleGpuHours * block.costPerGpuHourCents), })), }, }; } /** * One decimal, matching the web's own `percent` for these two quantities. * * Both call sites report a blended figure the reader has on screen beside * them — Overview and Margin render utilisation and gross margin to a tenth — * and rounding to a whole number here had Piggy answer "5% margin at 87% * utilisation" about a book the page was calling 5.3% and 87.3%. On a book * clearing five per cent, a tenth is a twentieth of the whole margin, so this * is a different number rather than a shorter one. */ function percent(value: number | null): string { return value == null ? 'n/a' : `${(value * 100).toFixed(1)}%`; }