diff --git a/apps/piggy/src/agent/prompt.ts b/apps/piggy/src/agent/prompt.ts index e01019e..336b691 100644 --- a/apps/piggy/src/agent/prompt.ts +++ b/apps/piggy/src/agent/prompt.ts @@ -77,12 +77,22 @@ const DOMAIN_BRIEFING = `How this business works, so the figures mean what you s * `totalLabel`, so the instruction has a field to point at rather than a * principle to hold — and that is the only form of this rule that has survived * contact with a 30B model. Terse on purpose: it rides on every request. + * + * The `totalLabel` bullet is the same lesson learnt from the other direction. + * Measured in production on /accounts: asked how many accounts were on the + * book, nemotron quoted the one count in front of it — seven demand deals — and + * wrote "7 demand deals (accounts)". The substitution is fixed in the payload, + * where the summary now counts accounts; the bullet exists for the pages that + * still have no figure for what is being asked, because there the only correct + * answer is a refusal and the model needs a test it can apply to reach one. + * One line, naming the field and the failure, and no more. */ const GROUNDING_RULE = `Grounding, which overrides everything else: - NEVER state a number, name, date or status about this business unless it appeared in a tool result in THIS conversation. Not from memory, not from what a figure "should" be, not by inference from the page you are on. - If the tool you were given does not answer the question, do not guess and do not stop: pig_search_records finds a record by name and pig_get_record_by_id opens it. Reach for those before concluding anything. - Every result says what it covers. Read its scope object first: matched is how many passed a filter, total is the whole set they were drawn from, totalLabel names what total counts, listed is how many rows the payload carries, filters names every threshold applied. - Asked how many there are, quote total, never matched and never the length of a list you can see. matched answers "how many are unsold" or "how many match"; it is never the size of the book. If total does not cover the question as asked, say what the result does cover and what is missing. +- Every figure counts the noun in its own totalLabel and no other. If nothing in the result counts the thing you were asked about, say it is not available — never answer with a figure labelled as something else. A count of deals is not a count of accounts. - Two tools can report different counts of the same thing because they applied different thresholds. Say which threshold produced the figure you quote; it is in filters. - If no tool can answer it, say exactly that and name what you would need. "I cannot see that from here" is a correct answer. An invented figure is not, and is worse than silence — someone will act on it. - Never use LaTeX or mathematical notation. No \\boxed{}, no \\(...\\). Write plain prose and plain numbers.`; diff --git a/apps/piggy/src/page-routes.ts b/apps/piggy/src/page-routes.ts index 3340bba..0c77bcf 100644 --- a/apps/piggy/src/page-routes.ts +++ b/apps/piggy/src/page-routes.ts @@ -62,19 +62,50 @@ const GUIDES: Partial> = { label: 'the growth view — attention-ranked accounts, and the idle supply behind them', tool: 'pig_get_idle_capacity', }, - '/margin': { label: 'the margin report, commitment by commitment', tool: 'pig_get_margin_summary' }, - '/calendar': { label: 'the calendar of dated work', tool: 'pig_get_calendar_ahead' }, - '/capacity': { label: 'the capacity book', tool: 'pig_get_idle_capacity' }, + /* + * "Commitment by commitment" was a promise the tool does not keep: it returns + * book totals and the eight largest blocks by cost, so a question about the + * ninth is answered from a list that does not contain it. + */ + '/margin': { + label: 'the margin report — book totals, and the largest commitments by cost', + tool: 'pig_get_margin_summary', + }, + /* + * A window, not the calendar. `pig_get_calendar_ahead` projects the next 30 + * days by default and what has lapsed in the last 90; anything dated outside + * that is not in the payload at all, and "the calendar of dated work" invited + * the model to report the window as the whole of it. + */ + '/calendar': { + label: 'the calendar of dated work — Piggy reads a window of it, not the whole calendar', + tool: 'pig_get_calendar_ahead', + }, + /* + * The tool lists only the blocks that are at least 25% unsold. It carries the + * size of the book beside them now, so the count is safe, but the rows are + * still the idle ones and the label should not promise the book. + */ + '/capacity': { + label: 'the capacity book — Piggy reads the idle blocks and how many commitments are live', + tool: 'pig_get_idle_capacity', + }, '/demand': { label: 'the demand pipeline board', tool: 'pig_get_pipeline' }, '/supply': { label: 'the supply pipeline board', tool: 'pig_get_pipeline' }, /* - * No page tool reads account rows, so this is the fallback said out loud. - * Told it is "looking at the accounts list" and handed book totals, the model - * answered questions about accounts from utilisation and margin; naming the - * gap is what makes it say the row is not available instead. + * This label used to say Piggy could not read accounts at all, which was true + * of the tool and produced the defect anyway. Measured in production: asked + * "How many accounts are on the book in total?" here, Piggy answered "The book + * contains 7 demand deals (accounts) in total" — the book held 17 accounts and + * 7 demand deals. A label admitting a gap does not stop a model filling it; it + * only tells the model which gap to fill. So the summary now counts accounts + * and contacts, and the label promises exactly that and no more: the counts + * are there, the rows are not, and `pig_search_records` is how a row is found. */ '/accounts': { - label: 'the accounts list — Piggy reads the book here, not the account rows', + label: + 'the accounts list — Piggy reads how many accounts (by side) and contacts are on the ' + + 'book, not the rows themselves', tool: 'pig_get_workspace_summary', }, /* @@ -97,13 +128,69 @@ const GUIDES: Partial> = { label: 'the contracts list — Piggy reads its dates here, not its terms', tool: 'pig_get_calendar_ahead', }, - '/imports': { label: 'the CSV import page', tool: 'pig_get_workspace_summary' }, - '/team': { label: 'the team and permissions page', tool: 'pig_get_workspace_summary' }, - '/facts': { label: 'the fact review queue', tool: 'pig_get_workspace_summary' }, - '/settings': { label: 'the settings page', tool: 'pig_get_workspace_summary' }, - '/piggy': { label: 'the full-page Piggy chat', tool: 'pig_get_workspace_summary' }, + /* + * Four pages with no data tool of their own, and the four labels that were + * most dangerous: each named a subject — imports, the team, the fact queue, + * the settings — while handing the model book totals about something else + * entirely. That is precisely the shape that produced the /accounts answer, + * where a figure about deals was relabelled as a figure about accounts, and + * here there is no figure to add: nothing in the workspace summary counts an + * import run, a person, a pending fact or a setting. + * + * So each label states the refusal rather than the subject. "I cannot see that + * from here" is an answer the grounding rule already sanctions; what it needed + * was something specific enough to recognise the question by. + */ + '/imports': { + label: + 'the CSV import page — Piggy can see book totals only, and nothing about import runs, ' + + 'column mappings or file contents', + tool: 'pig_get_workspace_summary', + }, + '/team': { + label: + 'the team and permissions page — Piggy can see book totals only, and no users, roles, ' + + 'invitations or permissions at all', + tool: 'pig_get_workspace_summary', + }, + '/facts': { + label: + 'the fact review queue — Piggy can see book totals only, and no facts and no count of ' + + 'what is pending review', + tool: 'pig_get_workspace_summary', + }, + '/settings': { + label: + 'the settings page — Piggy can see book totals only, and no settings, integrations, ' + + 'API keys or connected accounts', + tool: 'pig_get_workspace_summary', + }, + /* + * The one route whose label promises nothing about a page, because there is no + * page behind it: the full-page chat is wherever the conversation goes. The + * summary is the widest tool available, so naming what it covers is the only + * useful thing to say here. + */ + '/piggy': { + label: 'the full-page Piggy chat, with the book-level workspace summary behind it', + tool: 'pig_get_workspace_summary', + }, }; +/** + * The fallback carries the same warning the four data-less pages carry. + * + * A route in `PIGGY_PAGE_ROUTES` with no entry above — /learn today, and every + * page added later — was described to the model as "the /learn page" and handed + * the workspace summary, which is the /accounts failure with a different noun. + * A generic label cannot say what the page holds, but it can say what the tool + * does not, and that is the half that stops an answer being invented. + */ export function piggyPageGuide(route: PiggyPageRoute): PiggyPageGuide { - return GUIDES[route] ?? { label: `the ${route} page`, tool: 'pig_get_workspace_summary' }; + return ( + GUIDES[route] ?? { + label: `the ${route} page — Piggy can see book totals only, and nothing that is on this page`, + tool: 'pig_get_workspace_summary', + } + ); } diff --git a/apps/piggy/src/page-tools.ts b/apps/piggy/src/page-tools.ts index 0bee6d8..8cab9ca 100644 --- a/apps/piggy/src/page-tools.ts +++ b/apps/piggy/src/page-tools.ts @@ -26,6 +26,7 @@ * leaves this process. */ import { + ACCOUNT_SIDES, CONSUMING_ALLOCATION_STATUSES, DEMAND_OPEN_STAGES, RESERVING_ALLOCATION_STATUSES, @@ -41,14 +42,16 @@ import { 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, isNull } from 'drizzle-orm'; +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'; @@ -182,6 +185,10 @@ const COMMITMENTS_LABEL = 'live capacity commitment(s) on the book'; 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; @@ -264,9 +271,11 @@ function pageTool(db: Database, name: PiggyPageToolName): AgentTool { return defineTool({ name, description: - 'Read a bounded overview of the PIG workspace: book margin and utilisation, open ' + - 'deal counts on both sides, and the worst idle capacity. This cannot inspect the ' + - 'filesystem or external systems.', + '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), }); @@ -814,6 +823,94 @@ function countByState(events: readonly CalendarEvent[]): Record 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 // --------------------------------------------------------------------------- @@ -826,8 +923,9 @@ function countByState(events: readonly CalendarEvent[]): Record * answered from the fragment it happened to receive. */ async function readWorkspaceSummary(db: Database): Promise { - const [book, demandRead, supplyRead, demandAll, supplyAll] = await Promise.all([ + const [book, parties, demandRead, supplyRead, demandAll, supplyAll] = await Promise.all([ readLiveBlocks(db), + readParties(db), db .select({ id: demandDeals.id }) .from(demandDeals) @@ -870,8 +968,22 @@ async function readWorkspaceSummary(db: Database): Promise { 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: - `All ${atLeast(blocks.length, truncated.commitments)} ${COMMITMENTS_LABEL} at ` + + `${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} ` + @@ -890,6 +1002,43 @@ async function readWorkspaceSummary(db: Database): Promise { 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, diff --git a/apps/piggy/test/result-scope.test.ts b/apps/piggy/test/result-scope.test.ts index d303fd3..5210f53 100644 --- a/apps/piggy/test/result-scope.test.ts +++ b/apps/piggy/test/result-scope.test.ts @@ -51,11 +51,26 @@ interface StubBook { /** The OPEN deals, which is what the row reads in these tools select. */ demandDeals: readonly Record[]; supplyDeals: readonly Record[]; - /** Every deal on the book, which is what the `count()` reads select. */ - counts: { demandDeals: number; supplyDeals: number }; + /** + * What the `count()` reads select: every deal on the book, every contact row, + * and the accounts the archive filter removes. + */ + counts: { + demandDeals: number; + supplyDeals: number; + contacts: number; + archivedAccounts: number; + }; accounts?: readonly Record[]; contacts?: readonly Record[]; contracts?: readonly Record[]; + /** + * The one grouped count these tools make: accounts per side, archived + * excluded. Fixtured rather than derived from `accounts` above, because the + * stub evaluates no where clause and so cannot tell an archived row from a + * live one — deriving it would quietly test the fixture against itself. + */ + accountsBySide?: readonly { side: string; value: number }[]; } /** @@ -68,7 +83,15 @@ interface StubBook { */ function stubQuery(rows: readonly unknown[]): Record { const builder: Record = {}; - for (const method of ['where', 'limit', 'orderBy', 'leftJoin', 'innerJoin', 'innerJoinLateral']) { + for (const method of [ + 'where', + 'limit', + 'orderBy', + 'groupBy', + 'leftJoin', + 'innerJoin', + 'innerJoinLateral', + ]) { builder[method] = () => builder; } builder.then = (resolve: (value: readonly unknown[]) => unknown) => resolve(rows); @@ -89,16 +112,23 @@ function stubDatabase(book: StubBook): Database { const countFor = (table: unknown): number => { if (table === demandDeals) return book.counts.demandDeals; if (table === supplyDeals) return book.counts.supplyDeals; + // The only ungrouped count taken against accounts is the archived one; the + // live figure is summed from the grouped read below, so that the total and + // its own breakdown cannot disagree. + if (table === accounts) return book.counts.archivedAccounts; + if (table === contacts) return book.counts.contacts; return rowsFor(table).length; }; const select = (projection?: Record) => ({ from: (table: unknown) => { - // `select({ value: count() })` is the only projection with that shape, - // and it is how every denominator in these tools is read. - const counting = - projection !== undefined && - Object.keys(projection).length === 1 && - Object.hasOwn(projection, 'value'); + // `count()` always lands in a key called `value`. Alone it is a + // denominator; beside another column it is a grouped count, and accounts + // per side is the only one these tools take. + const counting = projection !== undefined && Object.hasOwn(projection, 'value'); + if (counting && Object.keys(projection).length > 1) { + if (table !== accounts) throw new Error('the stub groups counts for accounts only'); + return stubQuery(book.accountsBySide ?? []); + } return stubQuery(counting ? [{ value: countFor(table) }] : rowsFor(table)); }, }); @@ -152,6 +182,27 @@ const BOOK = [ const LIVE_COMMITMENTS = BOOK.length; const IDLE_BLOCKS = 3; +/** + * The account book, sized as production was when it was measured. + * + * Production held 17 accounts and 7 demand deals, and Piggy answered "The book + * contains 7 demand deals (accounts) in total" to a question about accounts. So + * the fixture keeps the two apart by more than an accident of arithmetic: 17 is + * not the size of any deal figure, any commitment figure or any list in this + * suite, and a payload that reports it can only have got it from the account + * count. `both` is present because the sides must partition the book — 9 + 7 + 1 + * is 17, and an account that trades on each side is counted once. + */ +const ACCOUNTS_BY_SIDE = [ + { side: 'supply', value: 9 }, + { side: 'demand', value: 7 }, + { side: 'both', value: 1 }, +]; +const ACCOUNTS_ON_BOOK = 17; +/** Archived, so on no screen and in no total. The gap is still counted. */ +const ARCHIVED_ACCOUNTS = 2; +const CONTACTS = 42; + const stub = stubDatabase({ commitments: BOOK.map(({ sold: _sold, ...row }) => row), allocations: BOOK.filter((row) => row.sold > 0).map((row) => allocation(row.id, row.sold)), @@ -171,15 +222,23 @@ const stub = stubDatabase({ gpuCount: 64, targetCostPerGpuHourCents: 189, })), - counts: { demandDeals: 13, supplyDeals: 8 }, + counts: { + demandDeals: 13, + supplyDeals: 8, + contacts: CONTACTS, + archivedAccounts: ARCHIVED_ACCOUNTS, + }, accounts: [{ id: 'acct', name: 'DEMO — Halcyon Research' }], contacts: [{ id: 'contact-1', accountId: 'acct', fullName: 'A Person' }], contracts: [{ id: 'contract-1', accountId: 'acct', title: 'DEMO — MSA' }], + accountsBySide: ACCOUNTS_BY_SIDE, }); type Reading = Record & { headline?: string; scope?: ResultScope }; -async function read(route: '/margin' | '/capacity' | '/demand' | '/'): Promise { +async function read( + route: '/margin' | '/capacity' | '/demand' | '/' | '/accounts' | '/team', +): Promise { const [tool] = createPagePigTools(stub, route); assert.ok(tool, `no tool for ${route}`); return (await tool.execute({})) as Reading; @@ -347,12 +406,115 @@ test('the workspace summary counts open deals against every deal on the book', a assert.equal((reading.openSupplyDealsScope as ResultScope).total, 8); }); +// --------------------------------------------------------------------------- +// The missing denominator +// --------------------------------------------------------------------------- + +/** Both keys the workspace summary carries its party counts under. */ +interface Parties { + accounts: { + scope: ResultScope; + onBook: number; + archived: number; + bySide: Record; + bySideNote: string; + }; + contacts: { scope: ResultScope; total: number }; +} + +async function parties(route: '/' | '/accounts' | '/team'): Promise { + return (await read(route)) as unknown as Parties; +} + +test('the workspace summary counts the accounts and contacts on the book', async () => { + const { accounts: book, contacts: people } = await parties('/'); + + assert.equal(book.onBook, ACCOUNTS_ON_BOOK); + assert.equal(people.total, CONTACTS); + // Nothing was filtered out of either, so `matched` IS the total: these are + // answers to "how many are there", not counts that need a denominator. + assert.equal(book.scope.matched, ACCOUNTS_ON_BOOK); + assert.equal(book.scope.total, ACCOUNTS_ON_BOOK); + assert.equal(people.scope.total, CONTACTS); + // The label is what the grounding rule tells the model to read the figure + // against, so it has to name the noun the question would use. + assert.match(book.scope.totalLabel, /account\(s\)/); + assert.match(people.scope.totalLabel, /contact\(s\)/); + assert.match(book.scope.summary, /All 17 account\(s\) on the book/); + assert.match(people.scope.summary, /All 42 contact\(s\) in the CRM/); +}); + +test('the headline states the account count, because the headline is what gets quoted', async () => { + const reading = await read('/'); + const headline = String(reading.headline); + + // The production answer was assembled from the first countable thing in this + // sentence. There is now an account figure in it, and it is first. + assert.match(headline, /^17 account\(s\) on the book/); + assert.match(headline, /42 contact\(s\) in the CRM/); + // A count of deals is not a count of accounts, and no deal figure in this + // fixture can be mistaken for one. + for (const dealFigure of [13, 8, 4, 2]) { + assert.notEqual(ACCOUNTS_ON_BOOK, dealFigure); + } +}); + +test('the sides partition the account book rather than overlapping it', async () => { + const { accounts: book } = await parties('/'); + + // Every side present, at zero if need be: an absent key reads as "not known" + // to a model quoting the payload. + assert.deepEqual(book.bySide, { supply: 9, demand: 7, both: 1 }); + const summed = Object.values(book.bySide).reduce((sum, value) => sum + value, 0); + assert.equal(summed, ACCOUNTS_ON_BOOK); + // A breakdown that disagrees with the /accounts side tabs, quoted beside that + // screen, is the next version of this bug. The note is what reconciles them: + // the tabs match `side = X or both`, so they overlap and do not sum. + assert.match(book.bySideNote, /counted once, under both/); + assert.match(book.bySideNote, /do not sum/); +}); + +test('archived accounts are off the total and still counted', async () => { + const reading = await read('/'); + const { accounts: book } = await parties('/'); + + // The /accounts list excludes them, so the total that answers "how many + // accounts are on the book" must exclude them too — Piggy disagreeing with + // the list on screen is worse than Piggy knowing less than it does. + assert.equal(book.archived, ARCHIVED_ACCOUNTS); + assert.equal(book.onBook, ACCOUNTS_ON_BOOK); + assert.notEqual(book.onBook, ACCOUNTS_ON_BOOK + ARCHIVED_ACCOUNTS); + // Excluded, but not invisible: a figure that differs from a raw table count + // has to be reconcilable from the payload alone. + assert.match(String(reading.headline), /a further 2 account\(s\) archived and off the book/); +}); + +test('/accounts is given a tool that can answer how many accounts there are', async () => { + // The measured defect, at the route it was measured on. Asked "How many + // accounts are on the book in total?" here, Piggy answered "The book contains + // 7 demand deals (accounts) in total" — a real figure, correctly scoped as + // deals by the payload, relabelled as accounts in the prose, because no + // account figure existed anywhere in the result it was handed. + const { accounts: book } = await parties('/accounts'); + assert.equal(book.onBook, ACCOUNTS_ON_BOOK); + assert.match(book.scope.summary, /account\(s\) on the book/); +}); + +test('a page with no data tool still gets the book denominators, never nothing', async () => { + // /team has no tool of its own and falls through to the summary. It must not + // arrive with a payload that is silent about every noun: the guide tells the + // model it can see no users, and the counts it CAN see are all labelled. + const { accounts: book, contacts: people } = await parties('/team'); + assert.equal(book.onBook, ACCOUNTS_ON_BOOK); + assert.equal(people.total, CONTACTS); +}); + // --------------------------------------------------------------------------- // The sweep // --------------------------------------------------------------------------- test('every page tool result carries at least one scope, and every scope is complete', async () => { - for (const route of ['/margin', '/capacity', '/demand', '/'] as const) { + for (const route of ['/margin', '/capacity', '/demand', '/', '/accounts'] as const) { const reading = await read(route); const found = scopes(reading); assert.ok(found.length > 0, `${route} returned a result with no scope at all`); @@ -378,7 +540,7 @@ test('every page tool result carries at least one scope, and every scope is comp }); test('no page headline reports a filtered count without the total beside it', async () => { - for (const route of ['/margin', '/capacity', '/demand', '/'] as const) { + for (const route of ['/margin', '/capacity', '/demand', '/', '/accounts'] as const) { const reading = await read(route); const headline = String(reading.headline); for (const scope of scopes(reading)) { diff --git a/apps/web/src/lib/piggy-suggestions.ts b/apps/web/src/lib/piggy-suggestions.ts index 47f2bef..8c86bb4 100644 --- a/apps/web/src/lib/piggy-suggestions.ts +++ b/apps/web/src/lib/piggy-suggestions.ts @@ -13,9 +13,10 @@ * * - Pages are grouped by tool, not by subject. /accounts and /facts get the * same book questions as the dashboard because all three resolve to - * `pig_get_workspace_summary`, which knows nothing about accounts or facts. - * Asking "which account is at risk?" from /accounts reads beautifully and - * cannot be answered. + * `pig_get_workspace_summary`. That tool now counts accounts and contacts, + * so a total is answerable from /accounts; what it still does not return is + * account ROWS, so "which account is at risk?" reads beautifully from that + * page and cannot be answered. Facts it does not see at all. * - No starter names a horizon in days. `pig_get_calendar_ahead` takes * `withinDays` and defaults to 30, and a question phrased around a quarter * is only answered if the model chooses to pass the argument. Everything