Count the accounts, so Piggy stops counting deals instead
Asked how many accounts were on the book, Piggy answered "7 demand deals (accounts)". Production holds 17 accounts and 7 demand deals. The number was real and the payload had scoped it correctly as deals; the prose relabelled it on the way out. This is the other half of the scope fix. That one stopped a filtered count being read as a total. This one is a total that was simply absent being filled from the nearest available noun: /accounts resolves to the workspace summary, which carried commitments, deals, margin and idle capacity and no count of accounts anywhere. The route's own label admitted it — "Piggy reads the book here, not the account rows" — which named the gap without closing it, and a model given a question about accounts and a payload with no account figure will always find something else to count. So the summary now counts accounts and contacts in SQL, and the headline leads with them, because the defective answer was assembled from the first countable thing in that sentence. Archived accounts are excluded to match what /api/accounts returns — Piggy disagreeing with the list on screen is the failure that costs the tool its credibility — but they are reported separately so the difference stays reconcilable. The side breakdown ships with a note saying the tabs do not partition, since supply and demand tabs each include "both" and therefore do not sum to the total: that is the next reconciliation bug, pre-empted. Five routes that genuinely have no data tool now say so in their guide rather than naming a subject they cannot reach. Proven live: /accounts answers 23 of 23; a question about geography is refused rather than guessed; /team refuses without substituting a nearby number. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, number>
|
||||
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<string, number>;
|
||||
/** 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<BookParties> {
|
||||
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<string, number> = 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, number>): 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<string, number>
|
||||
* answered from the fragment it happened to receive.
|
||||
*/
|
||||
async function readWorkspaceSummary(db: Database): Promise<unknown> {
|
||||
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<unknown> {
|
||||
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<unknown> {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user