import { evaluateCustomerLifecycle } from '@pig/core'; import { accounts, activities, allocations, capacityRequests, contractObligations, contracts, demandDeals, type Database, } from '@pig/db'; import { and, count, desc, eq, inArray } from 'drizzle-orm'; import { z } from 'zod'; import { resultScope } from './page-tools'; import { defineTool, type AgentTool } from './provider'; const noInput = z.object({}).strict(); /** The per-collection cap here, matching `RELATED_LIMIT` in chat-tools. */ const RELATED_LIMIT = 100; export function createAccountLifecycleTool(db: Database, accountId: string): AgentTool { return defineTool({ name: 'pig_get_account_lifecycle', description: 'Read the deterministic lifecycle score, source-backed signals, blockers, and sold or reserved capacity summary for the account in focus. Scores rank attention and are not probabilities or workload telemetry.', inputSchema: noInput, execute: async () => { const [account] = await db.select().from(accounts).where(eq(accounts.id, accountId)).limit(1); if (!account) throw new Error('The account in focus no longer exists.'); const [deals, paperwork, recentActivity, dealsOnBook] = await Promise.all([ db.select().from(demandDeals).where(eq(demandDeals.accountId, accountId)).limit(RELATED_LIMIT), db.select().from(contracts).where(and(eq(contracts.accountId, accountId), eq(contracts.side, 'demand'))).limit(RELATED_LIMIT), db.select().from(activities).where(eq(activities.accountId, accountId)).orderBy(desc(activities.occurredAt)).limit(1), // The denominator. This result is one account's slice of the book and // every count in it is an account count; without the book's own figure // beside them, "4 demand deals" is the only deal number in the payload // and becomes the answer to a question about the whole book. db.select({ value: count() }).from(demandDeals), ]); const demandDealsOnBook = dealsOnBook[0]?.value ?? 0; const dealIds = deals.map((deal) => deal.id); const contractIds = paperwork.map((contract) => contract.id); const [requests, reservations, obligations] = await Promise.all([ dealIds.length ? db.select().from(capacityRequests).where(inArray(capacityRequests.demandDealId, dealIds)) : [], dealIds.length ? db.select().from(allocations).where(inArray(allocations.demandDealId, dealIds)) : [], contractIds.length ? db.select().from(contractObligations).where(inArray(contractObligations.contractId, contractIds)) : [], ]); return { scope: resultScope({ covers: `belong to the account ${account.name}`, matched: deals.length, total: demandDealsOnBook, totalLabel: 'demand deal(s) on the book', listed: 0, filters: { accountId, side: 'demand', rowCapPerCollection: RELATED_LIMIT }, truncated: deals.length >= RELATED_LIMIT || paperwork.length >= RELATED_LIMIT, }), account: { id: account.id, name: account.name }, demandDealsForThisAccount: deals.length, demandDealsOnBook, lifecycle: evaluateCustomerLifecycle({ accountId, deals: deals.map((deal) => ({ ...deal })), requests: requests.map((request) => ({ ...request, totalGpuHours: request.totalGpuHours == null ? null : Number(request.totalGpuHours) })), allocations: reservations.map((allocation) => ({ ...allocation, gpuHours: Number(allocation.gpuHours) })), contracts: paperwork, obligations, lastActivityAt: recentActivity[0]?.occurredAt ?? account.lastActivityAt, lastActivityId: recentActivity[0]?.id, }), interpretation: 'Scores rank review attention. Capacity totals mean sold or reserved capacity, not customer workload utilisation. Every figure here covers this one account, never the book.', }; }, }); }