@frihet/mcp-server 2.0.0 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +96 -363
- package/dist/canary/mcp-harness.d.ts +1 -1
- package/dist/canary/mcp-harness.js +3 -2
- package/dist/claude-output-privacy.js +9 -0
- package/dist/claude-profile.js +3 -1
- package/dist/client-interface.d.ts +7 -0
- package/dist/client.d.ts +7 -0
- package/dist/client.js +16 -0
- package/dist/demo-client.d.ts +15 -16
- package/dist/demo-client.js +102 -30
- package/dist/demo-fixtures.d.ts +14 -4
- package/dist/demo-fixtures.js +84 -16
- package/dist/invoice-payment-link.d.ts +189 -0
- package/dist/invoice-payment-link.js +371 -0
- package/dist/mpp-sandbox.d.ts +176 -0
- package/dist/mpp-sandbox.js +612 -0
- package/dist/openai-103/openai-expansion-103.d.ts +1192 -0
- package/dist/openai-103/openai-expansion-103.js +1628 -0
- package/dist/openai-profile.d.ts +5 -1
- package/dist/openai-profile.js +14 -2
- package/dist/openai-review-contract.js +2 -1
- package/dist/server-composition.d.ts +1 -0
- package/dist/server-composition.js +45 -0
- package/dist/tool-exposure.d.ts +1 -0
- package/dist/tool-exposure.js +10 -3
- package/dist/tools/clients.js +2 -0
- package/dist/tools/purchaseOrders.d.ts +30 -0
- package/dist/tools/purchaseOrders.js +102 -0
- package/dist/tools/register-all.js +2 -0
- package/dist/tools/shared.d.ts +43 -1
- package/dist/tools/shared.js +46 -0
- package/dist/tools/vendors.js +2 -0
- package/docs/agent-onboarding.json +2 -2
- package/package.json +9 -9
|
@@ -0,0 +1,1628 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Candidate 1.0.3 capability expansion for the Frihet ChatGPT (OpenAI) profile.
|
|
3
|
+
*
|
|
4
|
+
* Developed in isolation (branch `feat/openai-1.0.3-candidate`) without mutating
|
|
5
|
+
* the live 1.0.2 production endpoint (`https://openai-mcp.frihet.io/mcp`, 33 tools).
|
|
6
|
+
*
|
|
7
|
+
* Exposes four read-only operational intelligence & commercial-search capabilities
|
|
8
|
+
* (expanding the surface from 33 to 37 tools):
|
|
9
|
+
* 1. `get_daily_operational_brief` — Prioritized daily brief with record provenance
|
|
10
|
+
* 2. `get_business_period_summary` — Period & month-over-month comparison by currency
|
|
11
|
+
* 3. `search_clients` — Multi-page commercial-name client search with ambiguity & coverage truth
|
|
12
|
+
* 4. `search_vendors` — Multi-page commercial-name vendor search with ambiguity & coverage truth
|
|
13
|
+
*
|
|
14
|
+
* Architectural note on `update_draft_invoice` (excluded from 1.0.3):
|
|
15
|
+
* Inspection of `Frihet-ERP/functions/src/publicApi.ts:10175-10350` (`updateResource`)
|
|
16
|
+
* shows that an MCP-only pre-flight (`getInvoice` + `updateInvoice`) is NOT atomic
|
|
17
|
+
* end-to-end:
|
|
18
|
+
* - `PATCH /v1/invoices/:id` runs a Firestore transaction (`db.runTransaction`),
|
|
19
|
+
* but does not accept an `expectedStatus: "draft"` or `expectedUpdatedAt`
|
|
20
|
+
* precondition inside that transaction.
|
|
21
|
+
* - `documentNumber` is assigned at draft creation (`POST /v1/invoices`), so
|
|
22
|
+
* `expectedDocumentNumber` is not a revision token.
|
|
23
|
+
* - If a concurrent session transitions a draft invoice to `'sent'` (prior to
|
|
24
|
+
* `verifactu.hash` or `documentNumberFinal`) between the MCP's `GET` and `PATCH`,
|
|
25
|
+
* sending `{ status: "draft", ... }` is permitted by `publicApi.ts:10323-10333`,
|
|
26
|
+
* which would demote an unhashed `'sent'` invoice back to `'draft'`.
|
|
27
|
+
* Therefore, draft editing is intentionally kept outside 1.0.3 until the ERP
|
|
28
|
+
* backend enforces `ifStatus === "draft"` + `ifUpdatedAt` inside `updateResource`.
|
|
29
|
+
*/
|
|
30
|
+
import { z } from "zod/v4";
|
|
31
|
+
import { withToolLogging, getContent, formatRecord, READ_ONLY_ANNOTATIONS, } from "../tools/shared.js";
|
|
32
|
+
import { generateDailyOperationalBrief, resolveDocumentAmount, } from "../workflows/daily-operational-brief.js";
|
|
33
|
+
import { OPENAI_REVIEW_FREE_TEXT_WARNING } from "../openai-profile.js";
|
|
34
|
+
const CALENDAR_MONTH_REGEX = /^(\d{4})-(0[1-9]|1[0-2])$/;
|
|
35
|
+
export const CLIENT_SEARCH_SERVER_PROJECTION = "id,name,stage,email,phone";
|
|
36
|
+
export const VENDOR_SEARCH_SERVER_PROJECTION = "id,name,email,phone";
|
|
37
|
+
const LEGAL_SUFFIX_STOPWORDS = new Set([
|
|
38
|
+
"sl",
|
|
39
|
+
"slu",
|
|
40
|
+
"sa",
|
|
41
|
+
"sau",
|
|
42
|
+
"sll",
|
|
43
|
+
"scp",
|
|
44
|
+
"cb",
|
|
45
|
+
"coop",
|
|
46
|
+
"inc",
|
|
47
|
+
"llc",
|
|
48
|
+
"ltd",
|
|
49
|
+
"gmbh",
|
|
50
|
+
"bv",
|
|
51
|
+
"sarl",
|
|
52
|
+
"sas",
|
|
53
|
+
"plc",
|
|
54
|
+
"corp",
|
|
55
|
+
]);
|
|
56
|
+
function isSingleTokenGovernmentId(token) {
|
|
57
|
+
const compact = token.replace(/[\s.\-:/,()]+/g, "").toUpperCase();
|
|
58
|
+
if (compact.length < 7 || compact.length > 15)
|
|
59
|
+
return false;
|
|
60
|
+
// Bare 7..11 digit sequence (e.g. 8-digit Spanish DNI/NIF stem)
|
|
61
|
+
if (/^[0-9]{7,11}$/.test(compact))
|
|
62
|
+
return true;
|
|
63
|
+
// Spanish NIF (8 digits + letter), NIE (X/Y/Z + 7 digits + letter), CIF (letter + 7 digits + control)
|
|
64
|
+
if (/^[0-9]{8}[A-Z]$/.test(compact))
|
|
65
|
+
return true;
|
|
66
|
+
if (/^[XYZ][0-9]{7}[A-Z]$/.test(compact))
|
|
67
|
+
return true;
|
|
68
|
+
if (/^[ABCDEFGHJNPQRSUVW][0-9]{7}[0-9A-J]$/.test(compact))
|
|
69
|
+
return true;
|
|
70
|
+
// Partial CIF/NIE stem (letter + 6..7 digits)
|
|
71
|
+
if (/^[ABCDEFGHJNPQRSUVWXYZ][0-9]{6,7}$/.test(compact))
|
|
72
|
+
return true;
|
|
73
|
+
// EU VAT prefix + 7..12 alphanumeric with at least 5 digits
|
|
74
|
+
if (/^[A-Z]{2}[A-Z0-9]{7,12}$/.test(compact) && (compact.match(/[0-9]/g)?.length ?? 0) >= 5) {
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Rejects queries that look like Spanish NIF/NIE/CIF or EU VAT numbers
|
|
81
|
+
* (including labeled forms like "NIF: B87654321" or numeric stems) so
|
|
82
|
+
* name-only search tools can never be used as government-ID lookup oracles.
|
|
83
|
+
*/
|
|
84
|
+
export function isLikelyGovernmentIdQuery(raw) {
|
|
85
|
+
const withoutLabel = raw.replace(/^\s*(?:nif|nie|cif|vat|tax\s*id|id\s*fiscal)\s*[:#\-/]?\s*/i, "");
|
|
86
|
+
if (isSingleTokenGovernmentId(withoutLabel))
|
|
87
|
+
return true;
|
|
88
|
+
const tokens = withoutLabel.split(/[\s,;:/()]+/).filter(Boolean);
|
|
89
|
+
return tokens.some((t) => isSingleTokenGovernmentId(t));
|
|
90
|
+
}
|
|
91
|
+
function normalizeSearchText(value) {
|
|
92
|
+
return value
|
|
93
|
+
.normalize("NFD")
|
|
94
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
95
|
+
.toLowerCase()
|
|
96
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
97
|
+
.trim();
|
|
98
|
+
}
|
|
99
|
+
export function matchCommercialName(candidateName, rawQuery) {
|
|
100
|
+
if (typeof candidateName !== "string" || !candidateName.trim()) {
|
|
101
|
+
return { matched: false, score: 0 };
|
|
102
|
+
}
|
|
103
|
+
const normName = normalizeSearchText(candidateName);
|
|
104
|
+
const normQuery = normalizeSearchText(rawQuery);
|
|
105
|
+
if (!normName || !normQuery) {
|
|
106
|
+
return { matched: false, score: 0 };
|
|
107
|
+
}
|
|
108
|
+
if (normName === normQuery) {
|
|
109
|
+
return { matched: true, rank: "exact", score: 100 };
|
|
110
|
+
}
|
|
111
|
+
const queryTokens = normQuery.split(" ").filter(Boolean);
|
|
112
|
+
const nonStopwordQueryTokens = queryTokens.filter((t) => !LEGAL_SUFFIX_STOPWORDS.has(t));
|
|
113
|
+
if (nonStopwordQueryTokens.length === 0 || nonStopwordQueryTokens.every((t) => t.length < 2)) {
|
|
114
|
+
return { matched: false, score: 0 };
|
|
115
|
+
}
|
|
116
|
+
// Whole-word prefix at start of commercial name (e.g. "Acme" in "Acme Iberia SL")
|
|
117
|
+
if (normName.startsWith(`${normQuery} `)) {
|
|
118
|
+
return { matched: true, rank: "prefix", score: 85 };
|
|
119
|
+
}
|
|
120
|
+
const nameTokens = normName.split(" ").filter(Boolean);
|
|
121
|
+
if (queryTokens.length > 0 &&
|
|
122
|
+
queryTokens.every((qt) => nameTokens.some((nt) => nt === qt || (qt.length >= 2 && nt.startsWith(qt))))) {
|
|
123
|
+
return { matched: true, rank: "token", score: 70 };
|
|
124
|
+
}
|
|
125
|
+
if (normQuery.length >= 3 && normName.includes(normQuery)) {
|
|
126
|
+
return { matched: true, rank: "substring", score: 50 };
|
|
127
|
+
}
|
|
128
|
+
return { matched: false, score: 0 };
|
|
129
|
+
}
|
|
130
|
+
function lastCalendarDayOfMonth(period) {
|
|
131
|
+
const m = CALENDAR_MONTH_REGEX.exec(period);
|
|
132
|
+
if (!m)
|
|
133
|
+
throw new Error(`Invalid period format: ${period}`);
|
|
134
|
+
const year = Number(m[1]);
|
|
135
|
+
const month = Number(m[2]);
|
|
136
|
+
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
|
137
|
+
return `${period}-${String(lastDay).padStart(2, "0")}`;
|
|
138
|
+
}
|
|
139
|
+
function roundCurrency(value) {
|
|
140
|
+
return Math.round(value * 100) / 100;
|
|
141
|
+
}
|
|
142
|
+
function approxJsonBytes(value) {
|
|
143
|
+
try {
|
|
144
|
+
return Buffer.byteLength(JSON.stringify(value ?? {}), "utf8");
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/* ------------------------------------------------------------------ */
|
|
151
|
+
/* Closed Zod Output Schemas (JSON-safe primitives only) */
|
|
152
|
+
/* ------------------------------------------------------------------ */
|
|
153
|
+
const currencyBucketSchema = z.object({
|
|
154
|
+
currency: z.string().describe("ISO currency code (e.g. EUR, USD)"),
|
|
155
|
+
amount: z.number().describe("Aggregated amount in this currency"),
|
|
156
|
+
documentCount: z.number().int().nonnegative().describe("Documents contributing to this bucket"),
|
|
157
|
+
provenance: z.enum(["stored_field", "derived_from_items"]).describe("How the bucket total was obtained"),
|
|
158
|
+
});
|
|
159
|
+
const provenancedAmountSchema = z.object({
|
|
160
|
+
amount: z.number().nullable().describe("Scalar amount when single-currency and determinable; null when MIXED or unavailable"),
|
|
161
|
+
currency: z.string().describe("ISO currency code or 'MIXED' when multiple currencies are present"),
|
|
162
|
+
provenance: z.enum([
|
|
163
|
+
"stored_field",
|
|
164
|
+
"derived_from_items",
|
|
165
|
+
"business_context_summary",
|
|
166
|
+
"unavailable",
|
|
167
|
+
"mixed_currency_unconverted",
|
|
168
|
+
]).describe("Amount provenance classification"),
|
|
169
|
+
fieldOrFormula: z.string().describe("Source field or canonical formula used"),
|
|
170
|
+
totalsByCurrency: z.array(currencyBucketSchema).describe("Per-currency breakdown without cross-currency conversion"),
|
|
171
|
+
unavailableCount: z.number().int().nonnegative().describe("Documents excluded because their amount could not be determined"),
|
|
172
|
+
});
|
|
173
|
+
function serializeProvenancedAmount(p) {
|
|
174
|
+
const buckets = p.totalsByCurrency
|
|
175
|
+
? Object.values(p.totalsByCurrency).map((b) => ({
|
|
176
|
+
currency: b.currency,
|
|
177
|
+
amount: b.amount,
|
|
178
|
+
documentCount: b.documentCount,
|
|
179
|
+
provenance: b.provenance,
|
|
180
|
+
}))
|
|
181
|
+
: p.amount !== null && (p.provenance === "stored_field" || p.provenance === "derived_from_items")
|
|
182
|
+
? [{
|
|
183
|
+
currency: p.currency,
|
|
184
|
+
amount: p.amount,
|
|
185
|
+
documentCount: 1,
|
|
186
|
+
provenance: p.provenance,
|
|
187
|
+
}]
|
|
188
|
+
: [];
|
|
189
|
+
return {
|
|
190
|
+
amount: p.amount,
|
|
191
|
+
currency: p.currency,
|
|
192
|
+
provenance: p.provenance,
|
|
193
|
+
fieldOrFormula: p.fieldOrFormula,
|
|
194
|
+
totalsByCurrency: buckets,
|
|
195
|
+
unavailableCount: p.unavailableCount ?? 0,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
const resourceCoverageSchema = z.object({
|
|
199
|
+
collection: z.enum(["invoices", "quotes", "expenses", "clients", "client_activities"]),
|
|
200
|
+
sourceTool: z.string(),
|
|
201
|
+
inspected: z.number().int().nonnegative(),
|
|
202
|
+
total: z.number().int().nonnegative(),
|
|
203
|
+
complete: z.boolean(),
|
|
204
|
+
});
|
|
205
|
+
const briefDocumentSummarySchema = z.object({
|
|
206
|
+
id: z.string(),
|
|
207
|
+
documentNumber: z.string().nullable(),
|
|
208
|
+
status: z.string(),
|
|
209
|
+
counterpartyName: z.string(),
|
|
210
|
+
issueDate: z.string().nullable(),
|
|
211
|
+
dueDate: z.string().nullable(),
|
|
212
|
+
amount: provenancedAmountSchema,
|
|
213
|
+
});
|
|
214
|
+
const briefPriorityItemSchema = z.object({
|
|
215
|
+
priority: z.enum(["high", "medium", "low"]),
|
|
216
|
+
category: z.enum([
|
|
217
|
+
"overdue_invoice",
|
|
218
|
+
"draft_invoice",
|
|
219
|
+
"draft_or_expiring_quote",
|
|
220
|
+
"expense_review",
|
|
221
|
+
"client_followup",
|
|
222
|
+
"plan_quota",
|
|
223
|
+
]),
|
|
224
|
+
title: z.string(),
|
|
225
|
+
why: z.string(),
|
|
226
|
+
recordRefs: z.array(z.object({
|
|
227
|
+
type: z.enum(["invoice", "quote", "expense", "client", "client_activity", "plan_quota"]),
|
|
228
|
+
id: z.string(),
|
|
229
|
+
label: z.string(),
|
|
230
|
+
})).max(10),
|
|
231
|
+
amount: provenancedAmountSchema.optional(),
|
|
232
|
+
suggestedNextAction: z.object({
|
|
233
|
+
description: z.string(),
|
|
234
|
+
recommendedTool: z.string().describe("Reviewed ChatGPT tool to inspect or follow up"),
|
|
235
|
+
requiresConfirmation: z.boolean(),
|
|
236
|
+
autoExecuted: z.literal(false),
|
|
237
|
+
}),
|
|
238
|
+
});
|
|
239
|
+
export const dailyOperationalBriefOutputSchema = z.object({
|
|
240
|
+
question: z.string(),
|
|
241
|
+
coverage: z.object({
|
|
242
|
+
asOfDate: z.string(),
|
|
243
|
+
referenceMonth: z.string(),
|
|
244
|
+
temporalScope: z.literal("unfiltered_operational_sample_as_of_date"),
|
|
245
|
+
temporalScopeNote: z.string(),
|
|
246
|
+
isPartial: z.boolean(),
|
|
247
|
+
resources: z.array(resourceCoverageSchema).max(5),
|
|
248
|
+
}),
|
|
249
|
+
activitySeparation: z.object({
|
|
250
|
+
draftActivity: z.object({
|
|
251
|
+
note: z.string(),
|
|
252
|
+
invoiceDraftsCount: z.number().int().nonnegative(),
|
|
253
|
+
invoiceDraftsTotal: provenancedAmountSchema,
|
|
254
|
+
invoiceDrafts: z.array(briefDocumentSummarySchema).max(25),
|
|
255
|
+
quoteDraftsCount: z.number().int().nonnegative(),
|
|
256
|
+
quoteDraftsTotal: provenancedAmountSchema,
|
|
257
|
+
quoteDrafts: z.array(briefDocumentSummarySchema).max(25),
|
|
258
|
+
}),
|
|
259
|
+
issuedActivity: z.object({
|
|
260
|
+
note: z.string(),
|
|
261
|
+
realOutstandingBalanceDeterminable: z.literal(false),
|
|
262
|
+
unpaidNominalSemantics: z.literal("nominal_unpaid_invoice_totals_without_partial_payments"),
|
|
263
|
+
issuedInvoicesCount: z.number().int().nonnegative(),
|
|
264
|
+
paidInvoicesCount: z.number().int().nonnegative(),
|
|
265
|
+
paidInvoicesTotal: provenancedAmountSchema,
|
|
266
|
+
unpaidNominalInvoicesCount: z.number().int().nonnegative(),
|
|
267
|
+
unpaidNominalTotal: provenancedAmountSchema,
|
|
268
|
+
overdueNominalInvoicesCount: z.number().int().nonnegative(),
|
|
269
|
+
overdueNominalTotal: provenancedAmountSchema,
|
|
270
|
+
onTimeNominalPendingCount: z.number().int().nonnegative(),
|
|
271
|
+
onTimeNominalPendingTotal: provenancedAmountSchema,
|
|
272
|
+
outstandingInvoices: z.array(briefDocumentSummarySchema).max(25),
|
|
273
|
+
}),
|
|
274
|
+
recordedExpenses: z.object({
|
|
275
|
+
note: z.string(),
|
|
276
|
+
expensesCount: z.number().int().nonnegative(),
|
|
277
|
+
expensesTotal: provenancedAmountSchema,
|
|
278
|
+
deductibleCount: z.number().int().nonnegative(),
|
|
279
|
+
deductibleTotal: provenancedAmountSchema,
|
|
280
|
+
nonDeductibleCount: z.number().int().nonnegative(),
|
|
281
|
+
nonDeductibleTotal: provenancedAmountSchema,
|
|
282
|
+
pendingReviewCount: z.number().int().nonnegative(),
|
|
283
|
+
}),
|
|
284
|
+
currentMonthActivity: z.object({
|
|
285
|
+
referenceMonth: z.string(),
|
|
286
|
+
note: z.string(),
|
|
287
|
+
issuedInvoicesInMonthCount: z.number().int().nonnegative(),
|
|
288
|
+
issuedInvoicesInMonthTotal: provenancedAmountSchema,
|
|
289
|
+
invoiceDraftsInMonthCount: z.number().int().nonnegative(),
|
|
290
|
+
invoiceDraftsInMonthTotal: provenancedAmountSchema,
|
|
291
|
+
expensesInMonthCount: z.number().int().nonnegative(),
|
|
292
|
+
expensesInMonthTotal: provenancedAmountSchema,
|
|
293
|
+
}),
|
|
294
|
+
}),
|
|
295
|
+
priorities: z.array(briefPriorityItemSchema).max(25),
|
|
296
|
+
knownUnknowns: z.array(z.string()).max(15),
|
|
297
|
+
mutationsExecuted: z.literal(0),
|
|
298
|
+
}).describe("OpenAI-reviewed daily operational brief with strict draft/issued separation, amount provenance, and coverage");
|
|
299
|
+
const periodCategoryBucketSchema = z.object({
|
|
300
|
+
category: z.string(),
|
|
301
|
+
currency: z.string(),
|
|
302
|
+
amount: z.number(),
|
|
303
|
+
expenseCount: z.number().int().nonnegative(),
|
|
304
|
+
});
|
|
305
|
+
const singlePeriodSummarySchema = z.object({
|
|
306
|
+
period: z.string().describe("Calendar month in YYYY-MM format"),
|
|
307
|
+
coverage: z.object({
|
|
308
|
+
invoicesInspected: z.number().int().nonnegative(),
|
|
309
|
+
invoicesTotal: z.number().int().nonnegative(),
|
|
310
|
+
invoicesComplete: z.boolean(),
|
|
311
|
+
expensesInspected: z.number().int().nonnegative(),
|
|
312
|
+
expensesTotal: z.number().int().nonnegative(),
|
|
313
|
+
expensesComplete: z.boolean(),
|
|
314
|
+
isComplete: z.boolean(),
|
|
315
|
+
telemetry: z.object({
|
|
316
|
+
invoicePagesFetched: z.number().int().nonnegative(),
|
|
317
|
+
expensePagesFetched: z.number().int().nonnegative(),
|
|
318
|
+
totalApiRequests: z.number().int().nonnegative(),
|
|
319
|
+
recordsInspected: z.number().int().nonnegative(),
|
|
320
|
+
approximatePayloadBytes: z.number().int().nonnegative(),
|
|
321
|
+
}),
|
|
322
|
+
}),
|
|
323
|
+
draftInvoices: z.object({
|
|
324
|
+
note: z.string(),
|
|
325
|
+
count: z.number().int().nonnegative(),
|
|
326
|
+
total: provenancedAmountSchema,
|
|
327
|
+
}),
|
|
328
|
+
issuedInvoices: z.object({
|
|
329
|
+
selectionBasis: z
|
|
330
|
+
.literal("issue_date_cohort_not_payment_date")
|
|
331
|
+
.describe("Invoices are selected by issueDate within the period; paid figures reflect current status=paid of that issueDate cohort, not cash collected during the month"),
|
|
332
|
+
note: z.string(),
|
|
333
|
+
realOutstandingBalanceDeterminable: z.literal(false),
|
|
334
|
+
count: z.number().int().nonnegative(),
|
|
335
|
+
currentlyMarkedPaidCount: z
|
|
336
|
+
.number()
|
|
337
|
+
.int()
|
|
338
|
+
.nonnegative()
|
|
339
|
+
.describe("Count of invoices issued in this period that are currently marked as paid (not payment-date collections)"),
|
|
340
|
+
unpaidNominalCount: z.number().int().nonnegative(),
|
|
341
|
+
overdueNominalCount: z.number().int().nonnegative(),
|
|
342
|
+
issuedTotal: provenancedAmountSchema,
|
|
343
|
+
currentlyMarkedPaidTotal: provenancedAmountSchema.describe("Total of invoices issued in this period that are currently marked as paid (issueDate cohort, NOT cash collected during the period)"),
|
|
344
|
+
unpaidNominalTotal: provenancedAmountSchema,
|
|
345
|
+
overdueNominalTotal: provenancedAmountSchema,
|
|
346
|
+
}),
|
|
347
|
+
expenses: z.object({
|
|
348
|
+
note: z
|
|
349
|
+
.string()
|
|
350
|
+
.describe("Explains strict separation of taxDeductible === true, false, and undetermined (absent/null)"),
|
|
351
|
+
count: z.number().int().nonnegative(),
|
|
352
|
+
total: provenancedAmountSchema,
|
|
353
|
+
deductibleCount: z
|
|
354
|
+
.number()
|
|
355
|
+
.int()
|
|
356
|
+
.nonnegative()
|
|
357
|
+
.describe("Expenses explicitly classified with taxDeductible === true"),
|
|
358
|
+
deductibleTotal: provenancedAmountSchema,
|
|
359
|
+
nonDeductibleCount: z
|
|
360
|
+
.number()
|
|
361
|
+
.int()
|
|
362
|
+
.nonnegative()
|
|
363
|
+
.describe("Expenses explicitly classified with taxDeductible === false"),
|
|
364
|
+
nonDeductibleTotal: provenancedAmountSchema,
|
|
365
|
+
undeterminedDeductibilityCount: z
|
|
366
|
+
.number()
|
|
367
|
+
.int()
|
|
368
|
+
.nonnegative()
|
|
369
|
+
.describe("Expenses where taxDeductible is absent, null, or non-boolean"),
|
|
370
|
+
undeterminedDeductibilityTotal: provenancedAmountSchema,
|
|
371
|
+
byCategory: z.array(periodCategoryBucketSchema).max(30),
|
|
372
|
+
}),
|
|
373
|
+
netOperatingByCurrency: z.array(z.object({
|
|
374
|
+
currency: z.string(),
|
|
375
|
+
issuedRevenueMinusExpenses: z
|
|
376
|
+
.number()
|
|
377
|
+
.nullable()
|
|
378
|
+
.describe("Issued invoice cohort total minus recorded expenses for this period"),
|
|
379
|
+
issuedCohortCurrentlyPaidMinusExpenses: z
|
|
380
|
+
.number()
|
|
381
|
+
.nullable()
|
|
382
|
+
.describe("Invoices issued in this period currently marked as paid minus recorded expenses in this period (issueDate cohort difference, NOT treasury cash flow or accounting profit)"),
|
|
383
|
+
completeAndDeterminable: z.boolean(),
|
|
384
|
+
})).max(10),
|
|
385
|
+
});
|
|
386
|
+
const periodComparisonDeltaByCurrencySchema = z.object({
|
|
387
|
+
currency: z.string(),
|
|
388
|
+
primaryIssuedRevenue: z.number().nullable(),
|
|
389
|
+
comparisonIssuedRevenue: z.number().nullable(),
|
|
390
|
+
issuedRevenueDelta: z.number().nullable(),
|
|
391
|
+
primaryIssuedCurrentlyPaid: z
|
|
392
|
+
.number()
|
|
393
|
+
.nullable()
|
|
394
|
+
.describe("Invoices issued in primaryMonth currently marked as paid (issueDate cohort, not cash collected in primaryMonth)"),
|
|
395
|
+
comparisonIssuedCurrentlyPaid: z
|
|
396
|
+
.number()
|
|
397
|
+
.nullable()
|
|
398
|
+
.describe("Invoices issued in compareWithMonth currently marked as paid (issueDate cohort, not cash collected in compareWithMonth)"),
|
|
399
|
+
issuedCurrentlyPaidDelta: z
|
|
400
|
+
.number()
|
|
401
|
+
.nullable()
|
|
402
|
+
.describe("Delta between primaryMonth and compareWithMonth cohorts of issued invoices currently marked as paid (not a cash-flow collection delta)"),
|
|
403
|
+
primaryExpenses: z.number().nullable(),
|
|
404
|
+
comparisonExpenses: z.number().nullable(),
|
|
405
|
+
expensesDelta: z.number().nullable(),
|
|
406
|
+
completeAndDeterminable: z.boolean(),
|
|
407
|
+
});
|
|
408
|
+
export const businessPeriodSummaryOutputSchema = z.object({
|
|
409
|
+
accountingSemanticsNote: z.string(),
|
|
410
|
+
primaryPeriod: singlePeriodSummarySchema,
|
|
411
|
+
comparisonPeriod: singlePeriodSummarySchema.optional(),
|
|
412
|
+
comparison: z.object({
|
|
413
|
+
primaryMonth: z.string(),
|
|
414
|
+
compareWithMonth: z.string(),
|
|
415
|
+
bothPeriodsComplete: z.boolean(),
|
|
416
|
+
issuedInvoicesCountDelta: z.number().int(),
|
|
417
|
+
draftInvoicesCountDelta: z.number().int(),
|
|
418
|
+
expensesCountDelta: z.number().int(),
|
|
419
|
+
deltasByCurrency: z.array(periodComparisonDeltaByCurrencySchema).max(10),
|
|
420
|
+
notes: z.array(z.string()).max(10),
|
|
421
|
+
}).optional(),
|
|
422
|
+
}).describe("Operational period summary and month-over-month comparison without regulated tax filing fields");
|
|
423
|
+
const searchTelemetrySchema = z.object({
|
|
424
|
+
directoryPagesFetched: z.number().int().nonnegative().describe("Number of paginated directory requests issued"),
|
|
425
|
+
contextRequestsMade: z.number().int().nonnegative().describe("Follow-up requests issued for matched record context"),
|
|
426
|
+
totalApiRequests: z.number().int().nonnegative().describe("Total upstream API requests issued by this tool call"),
|
|
427
|
+
recordsInspected: z.number().int().nonnegative().describe("Directory records inspected across fetched pages"),
|
|
428
|
+
approximatePayloadBytes: z.number().int().nonnegative().describe("Approximate UTF-8 JSON bytes received across upstream calls"),
|
|
429
|
+
serverProjectionFields: z.string().describe("Server-side fields projection passed to strip taxId/address/searchTokens before serialization"),
|
|
430
|
+
});
|
|
431
|
+
const searchCoverageSchema = z.object({
|
|
432
|
+
inspected: z.number().int().nonnegative().describe("Records inspected in this call"),
|
|
433
|
+
total: z.number().int().nonnegative().describe("Total records reported by the workspace directory"),
|
|
434
|
+
uninspectedCount: z.number().int().nonnegative().describe("Directory records not inspected in this call"),
|
|
435
|
+
complete: z.boolean().describe("True only when all directory records from offset 0 through total were inspected in this call"),
|
|
436
|
+
nextCursor: z.string().nullable().describe("Continuation token to pass as 'after' to scan the next forward page window, or null when forward scan reached the end"),
|
|
437
|
+
telemetry: searchTelemetrySchema,
|
|
438
|
+
});
|
|
439
|
+
export const searchMatchStatusSchema = z.enum([
|
|
440
|
+
"exact",
|
|
441
|
+
"unambiguous",
|
|
442
|
+
"ambiguous",
|
|
443
|
+
"none",
|
|
444
|
+
"partial_scan_single_match",
|
|
445
|
+
"partial_scan_no_match",
|
|
446
|
+
]).describe("Match certainty status. 'exact', 'unambiguous', and 'none' require coverage.complete===true. " +
|
|
447
|
+
"When coverage.complete===false, a single match is reported as 'partial_scan_single_match' and 0 matches as 'partial_scan_no_match' because uninspected pages may contain additional matches.");
|
|
448
|
+
const matchedClientInvoicePreviewSchema = z.object({
|
|
449
|
+
id: z.string(),
|
|
450
|
+
invoiceNumber: z.string().nullable(),
|
|
451
|
+
status: z.string(),
|
|
452
|
+
issueDate: z.string().nullable(),
|
|
453
|
+
dueDate: z.string().nullable(),
|
|
454
|
+
amount: provenancedAmountSchema,
|
|
455
|
+
});
|
|
456
|
+
const matchedClientActivityPreviewSchema = z.object({
|
|
457
|
+
type: z.string(),
|
|
458
|
+
title: z.string(),
|
|
459
|
+
timestamp: z.string().nullable(),
|
|
460
|
+
});
|
|
461
|
+
const matchedClientItemSchema = z.object({
|
|
462
|
+
id: z.string(),
|
|
463
|
+
name: z.string(),
|
|
464
|
+
stage: z.string().optional(),
|
|
465
|
+
email: z.string().optional(),
|
|
466
|
+
phone: z.string().optional(),
|
|
467
|
+
matchRank: z.enum(["exact", "prefix", "token", "substring"]),
|
|
468
|
+
context: z.object({
|
|
469
|
+
recentInvoices: z.array(matchedClientInvoicePreviewSchema).max(5),
|
|
470
|
+
recentActivities: z.array(matchedClientActivityPreviewSchema).max(5),
|
|
471
|
+
}).optional(),
|
|
472
|
+
});
|
|
473
|
+
export const searchClientsOutputSchema = z.object({
|
|
474
|
+
query: z.string(),
|
|
475
|
+
searchedFields: z.array(z.literal("name")).max(1),
|
|
476
|
+
matchStatus: searchMatchStatusSchema,
|
|
477
|
+
disambiguationNote: z.string(),
|
|
478
|
+
coverage: searchCoverageSchema,
|
|
479
|
+
matches: z.array(matchedClientItemSchema).max(20),
|
|
480
|
+
}).describe("Commercial-name client search with multi-page coverage truth, ambiguity detection, and meeting-prep context");
|
|
481
|
+
const matchedVendorExpensePreviewSchema = z.object({
|
|
482
|
+
id: z.string(),
|
|
483
|
+
description: z.string(),
|
|
484
|
+
date: z.string().nullable(),
|
|
485
|
+
category: z.string().nullable(),
|
|
486
|
+
taxDeductible: z.boolean().nullable(),
|
|
487
|
+
amount: provenancedAmountSchema,
|
|
488
|
+
});
|
|
489
|
+
const matchedVendorItemSchema = z.object({
|
|
490
|
+
id: z.string(),
|
|
491
|
+
name: z.string(),
|
|
492
|
+
email: z.string().optional(),
|
|
493
|
+
phone: z.string().optional(),
|
|
494
|
+
matchRank: z.enum(["exact", "prefix", "token", "substring"]),
|
|
495
|
+
recentExpenses: z.array(matchedVendorExpensePreviewSchema).max(5).optional(),
|
|
496
|
+
});
|
|
497
|
+
export const searchVendorsOutputSchema = z.object({
|
|
498
|
+
query: z.string(),
|
|
499
|
+
searchedFields: z.array(z.literal("name")).max(1),
|
|
500
|
+
matchStatus: searchMatchStatusSchema,
|
|
501
|
+
disambiguationNote: z.string(),
|
|
502
|
+
coverage: searchCoverageSchema,
|
|
503
|
+
matches: z.array(matchedVendorItemSchema).max(20),
|
|
504
|
+
}).describe("Commercial-name vendor search with multi-page coverage truth, ambiguity detection, and recent expense context");
|
|
505
|
+
/* ------------------------------------------------------------------ */
|
|
506
|
+
/* Helper: Aggregate a list of documents into ProvenancedAmount */
|
|
507
|
+
/* ------------------------------------------------------------------ */
|
|
508
|
+
function aggregateAmounts(docs, defaultCurrency, formulaLabel) {
|
|
509
|
+
if (docs.length === 0) {
|
|
510
|
+
const cur = defaultCurrency.toUpperCase();
|
|
511
|
+
return {
|
|
512
|
+
amount: 0,
|
|
513
|
+
currency: cur,
|
|
514
|
+
provenance: "stored_field",
|
|
515
|
+
fieldOrFormula: `${formulaLabel} (0 documents)`,
|
|
516
|
+
totalsByCurrency: [{ currency: cur, amount: 0, documentCount: 0, provenance: "stored_field" }],
|
|
517
|
+
unavailableCount: 0,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
const buckets = new Map();
|
|
521
|
+
let unavailableCount = 0;
|
|
522
|
+
for (const doc of docs) {
|
|
523
|
+
const resolved = resolveDocumentAmount(doc, defaultCurrency);
|
|
524
|
+
if (resolved.amount === null || resolved.provenance === "unavailable") {
|
|
525
|
+
unavailableCount++;
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
const cur = resolved.currency;
|
|
529
|
+
const prev = buckets.get(cur);
|
|
530
|
+
const prov = prev?.provenance === "derived_from_items" || resolved.provenance === "derived_from_items"
|
|
531
|
+
? "derived_from_items"
|
|
532
|
+
: "stored_field";
|
|
533
|
+
buckets.set(cur, {
|
|
534
|
+
amount: roundCurrency((prev?.amount ?? 0) + resolved.amount),
|
|
535
|
+
count: (prev?.count ?? 0) + 1,
|
|
536
|
+
provenance: prov,
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
const totalsByCurrency = [...buckets.entries()]
|
|
540
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
541
|
+
.map(([currency, b]) => ({
|
|
542
|
+
currency,
|
|
543
|
+
amount: b.amount,
|
|
544
|
+
documentCount: b.count,
|
|
545
|
+
provenance: b.provenance,
|
|
546
|
+
}));
|
|
547
|
+
if (unavailableCount > 0) {
|
|
548
|
+
return {
|
|
549
|
+
amount: null,
|
|
550
|
+
currency: totalsByCurrency.length === 1 ? totalsByCurrency[0].currency : totalsByCurrency.length > 1 ? "MIXED" : defaultCurrency.toUpperCase(),
|
|
551
|
+
provenance: "unavailable",
|
|
552
|
+
fieldOrFormula: `${formulaLabel} (${unavailableCount} document(s) missing valid amount)`,
|
|
553
|
+
totalsByCurrency,
|
|
554
|
+
unavailableCount,
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
if (totalsByCurrency.length > 1) {
|
|
558
|
+
return {
|
|
559
|
+
amount: null,
|
|
560
|
+
currency: "MIXED",
|
|
561
|
+
provenance: "mixed_currency_unconverted",
|
|
562
|
+
fieldOrFormula: `${formulaLabel} (multi-currency buckets without conversion)`,
|
|
563
|
+
totalsByCurrency,
|
|
564
|
+
unavailableCount: 0,
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
const single = totalsByCurrency[0];
|
|
568
|
+
return {
|
|
569
|
+
amount: single.amount,
|
|
570
|
+
currency: single.currency,
|
|
571
|
+
provenance: single.provenance,
|
|
572
|
+
fieldOrFormula: formulaLabel,
|
|
573
|
+
totalsByCurrency,
|
|
574
|
+
unavailableCount: 0,
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
async function computeSinglePeriodSummary(client, period, defaultCurrency, maxPages = 5) {
|
|
578
|
+
const from = `${period}-01`;
|
|
579
|
+
const to = lastCalendarDayOfMonth(period);
|
|
580
|
+
const pageSize = 100;
|
|
581
|
+
let invoicePagesFetched = 0;
|
|
582
|
+
let expensePagesFetched = 0;
|
|
583
|
+
let approximatePayloadBytes = 0;
|
|
584
|
+
const rawInvoices = [];
|
|
585
|
+
const seenInvoiceIds = new Set();
|
|
586
|
+
let invoicesTotal = 0;
|
|
587
|
+
let invoicesComplete = true;
|
|
588
|
+
let invAfter;
|
|
589
|
+
for (let page = 0; page < maxPages; page++) {
|
|
590
|
+
const res = await client.listInvoices({
|
|
591
|
+
from,
|
|
592
|
+
to,
|
|
593
|
+
limit: pageSize,
|
|
594
|
+
...(invAfter ? { after: invAfter } : { offset: page * pageSize }),
|
|
595
|
+
});
|
|
596
|
+
invoicePagesFetched++;
|
|
597
|
+
approximatePayloadBytes += approxJsonBytes(res);
|
|
598
|
+
const rows = Array.isArray(res?.data) ? res.data : [];
|
|
599
|
+
if (page === 0) {
|
|
600
|
+
invoicesTotal = typeof res?.total === "number" ? res.total : rows.length;
|
|
601
|
+
}
|
|
602
|
+
for (const row of rows) {
|
|
603
|
+
if (!row || typeof row !== "object")
|
|
604
|
+
continue;
|
|
605
|
+
const rec = row;
|
|
606
|
+
const id = typeof rec.id === "string" ? rec.id : "";
|
|
607
|
+
if (id && seenInvoiceIds.has(id))
|
|
608
|
+
continue;
|
|
609
|
+
if (id)
|
|
610
|
+
seenInvoiceIds.add(id);
|
|
611
|
+
rawInvoices.push(rec);
|
|
612
|
+
}
|
|
613
|
+
const nextCursor = typeof res?.nextCursor === "string" && res.nextCursor ? res.nextCursor : undefined;
|
|
614
|
+
const hasMore = Boolean(nextCursor) || rawInvoices.length < invoicesTotal;
|
|
615
|
+
if (!hasMore || rows.length === 0 || (rows.length < pageSize && !nextCursor))
|
|
616
|
+
break;
|
|
617
|
+
invAfter = nextCursor;
|
|
618
|
+
if (page === maxPages - 1 && (Boolean(nextCursor) || rawInvoices.length < invoicesTotal)) {
|
|
619
|
+
invoicesComplete = false;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
if (rawInvoices.length < invoicesTotal)
|
|
623
|
+
invoicesComplete = false;
|
|
624
|
+
const rawExpenses = [];
|
|
625
|
+
const seenExpenseIds = new Set();
|
|
626
|
+
let expensesTotal = 0;
|
|
627
|
+
let expensesComplete = true;
|
|
628
|
+
let expAfter;
|
|
629
|
+
for (let page = 0; page < maxPages; page++) {
|
|
630
|
+
const res = await client.listExpenses({
|
|
631
|
+
from,
|
|
632
|
+
to,
|
|
633
|
+
limit: pageSize,
|
|
634
|
+
...(expAfter ? { after: expAfter } : { offset: page * pageSize }),
|
|
635
|
+
});
|
|
636
|
+
expensePagesFetched++;
|
|
637
|
+
approximatePayloadBytes += approxJsonBytes(res);
|
|
638
|
+
const rows = Array.isArray(res?.data) ? res.data : [];
|
|
639
|
+
if (page === 0) {
|
|
640
|
+
expensesTotal = typeof res?.total === "number" ? res.total : rows.length;
|
|
641
|
+
}
|
|
642
|
+
for (const row of rows) {
|
|
643
|
+
if (!row || typeof row !== "object")
|
|
644
|
+
continue;
|
|
645
|
+
const rec = row;
|
|
646
|
+
const id = typeof rec.id === "string" ? rec.id : "";
|
|
647
|
+
if (id && seenExpenseIds.has(id))
|
|
648
|
+
continue;
|
|
649
|
+
if (id)
|
|
650
|
+
seenExpenseIds.add(id);
|
|
651
|
+
rawExpenses.push(rec);
|
|
652
|
+
}
|
|
653
|
+
const nextCursor = typeof res?.nextCursor === "string" && res.nextCursor ? res.nextCursor : undefined;
|
|
654
|
+
const hasMore = Boolean(nextCursor) || rawExpenses.length < expensesTotal;
|
|
655
|
+
if (!hasMore || rows.length === 0 || (rows.length < pageSize && !nextCursor))
|
|
656
|
+
break;
|
|
657
|
+
expAfter = nextCursor;
|
|
658
|
+
if (page === maxPages - 1 && (Boolean(nextCursor) || rawExpenses.length < expensesTotal)) {
|
|
659
|
+
expensesComplete = false;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
if (rawExpenses.length < expensesTotal)
|
|
663
|
+
expensesComplete = false;
|
|
664
|
+
const isDateWithinPeriod = (rawDate) => {
|
|
665
|
+
if (typeof rawDate !== "string" || !rawDate.trim())
|
|
666
|
+
return true;
|
|
667
|
+
const d = rawDate.trim().slice(0, 10);
|
|
668
|
+
return d >= from && d <= to;
|
|
669
|
+
};
|
|
670
|
+
const getNormStatus = (doc, fallback = "draft") => typeof doc.status === "string" && doc.status.trim() ? doc.status.trim().toLowerCase() : fallback;
|
|
671
|
+
const invoices = rawInvoices.filter((inv) => isDateWithinPeriod(inv.issueDate));
|
|
672
|
+
const expenses = rawExpenses.filter((exp) => {
|
|
673
|
+
const st = getNormStatus(exp, "recorded");
|
|
674
|
+
if (st === "cancelled" || st === "canceled" || st === "void")
|
|
675
|
+
return false;
|
|
676
|
+
return isDateWithinPeriod(exp.date);
|
|
677
|
+
});
|
|
678
|
+
const draftInvoices = invoices.filter((inv) => getNormStatus(inv) === "draft");
|
|
679
|
+
const issuedInvoices = invoices.filter((inv) => {
|
|
680
|
+
const st = getNormStatus(inv);
|
|
681
|
+
return st !== "draft" && st !== "cancelled" && st !== "canceled" && st !== "void";
|
|
682
|
+
});
|
|
683
|
+
const paidInvoices = issuedInvoices.filter((inv) => getNormStatus(inv) === "paid");
|
|
684
|
+
const unpaidInvoices = issuedInvoices.filter((inv) => getNormStatus(inv) !== "paid");
|
|
685
|
+
const overdueInvoices = unpaidInvoices.filter((inv) => getNormStatus(inv) === "overdue");
|
|
686
|
+
const deductibleExpenses = expenses.filter((exp) => exp.taxDeductible === true);
|
|
687
|
+
const nonDeductibleExpenses = expenses.filter((exp) => exp.taxDeductible === false);
|
|
688
|
+
const undeterminedDeductibilityExpenses = expenses.filter((exp) => typeof exp.taxDeductible !== "boolean");
|
|
689
|
+
const categoryBuckets = new Map();
|
|
690
|
+
for (const exp of expenses) {
|
|
691
|
+
const resolved = resolveDocumentAmount(exp, defaultCurrency);
|
|
692
|
+
if (resolved.amount === null)
|
|
693
|
+
continue;
|
|
694
|
+
const category = typeof exp.category === "string" && exp.category.trim() ? exp.category.trim() : "uncategorized";
|
|
695
|
+
const key = `${category}::${resolved.currency}`;
|
|
696
|
+
const prev = categoryBuckets.get(key);
|
|
697
|
+
categoryBuckets.set(key, {
|
|
698
|
+
category,
|
|
699
|
+
currency: resolved.currency,
|
|
700
|
+
amount: roundCurrency((prev?.amount ?? 0) + resolved.amount),
|
|
701
|
+
count: (prev?.count ?? 0) + 1,
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
const draftTotal = aggregateAmounts(draftInvoices, defaultCurrency, "sum(draft_invoices)");
|
|
705
|
+
const issuedTotal = aggregateAmounts(issuedInvoices, defaultCurrency, "sum(issued_invoices)");
|
|
706
|
+
const currentlyMarkedPaidTotal = aggregateAmounts(paidInvoices, defaultCurrency, "sum(issued_invoices_currently_marked_paid)");
|
|
707
|
+
const unpaidNominalTotal = aggregateAmounts(unpaidInvoices, defaultCurrency, "sum(unpaid_nominal_invoices)");
|
|
708
|
+
const overdueNominalTotal = aggregateAmounts(overdueInvoices, defaultCurrency, "sum(overdue_nominal_invoices)");
|
|
709
|
+
const expTotal = aggregateAmounts(expenses, defaultCurrency, "sum(expenses)");
|
|
710
|
+
const dedTotal = aggregateAmounts(deductibleExpenses, defaultCurrency, "sum(deductible_expenses)");
|
|
711
|
+
const nonDedTotal = aggregateAmounts(nonDeductibleExpenses, defaultCurrency, "sum(non_deductible_expenses)");
|
|
712
|
+
const undeterminedDedTotal = aggregateAmounts(undeterminedDeductibilityExpenses, defaultCurrency, "sum(undetermined_deductibility_expenses)");
|
|
713
|
+
const allCurrencies = new Set([
|
|
714
|
+
...issuedTotal.totalsByCurrency.map((b) => b.currency),
|
|
715
|
+
...currentlyMarkedPaidTotal.totalsByCurrency.map((b) => b.currency),
|
|
716
|
+
...expTotal.totalsByCurrency.map((b) => b.currency),
|
|
717
|
+
]);
|
|
718
|
+
const isComplete = invoicesComplete && expensesComplete;
|
|
719
|
+
const determinable = isComplete &&
|
|
720
|
+
issuedTotal.unavailableCount === 0 &&
|
|
721
|
+
currentlyMarkedPaidTotal.unavailableCount === 0 &&
|
|
722
|
+
expTotal.unavailableCount === 0;
|
|
723
|
+
const netOperatingByCurrency = [...allCurrencies].sort().map((currency) => {
|
|
724
|
+
const issuedBucket = issuedTotal.totalsByCurrency.find((b) => b.currency === currency)?.amount ?? 0;
|
|
725
|
+
const paidBucket = currentlyMarkedPaidTotal.totalsByCurrency.find((b) => b.currency === currency)?.amount ?? 0;
|
|
726
|
+
const expBucket = expTotal.totalsByCurrency.find((b) => b.currency === currency)?.amount ?? 0;
|
|
727
|
+
return {
|
|
728
|
+
currency,
|
|
729
|
+
issuedRevenueMinusExpenses: determinable ? roundCurrency(issuedBucket - expBucket) : null,
|
|
730
|
+
issuedCohortCurrentlyPaidMinusExpenses: determinable ? roundCurrency(paidBucket - expBucket) : null,
|
|
731
|
+
completeAndDeterminable: determinable,
|
|
732
|
+
};
|
|
733
|
+
});
|
|
734
|
+
return {
|
|
735
|
+
period,
|
|
736
|
+
coverage: {
|
|
737
|
+
invoicesInspected: rawInvoices.length,
|
|
738
|
+
invoicesTotal,
|
|
739
|
+
invoicesComplete,
|
|
740
|
+
expensesInspected: rawExpenses.length,
|
|
741
|
+
expensesTotal,
|
|
742
|
+
expensesComplete,
|
|
743
|
+
isComplete,
|
|
744
|
+
telemetry: {
|
|
745
|
+
invoicePagesFetched,
|
|
746
|
+
expensePagesFetched,
|
|
747
|
+
totalApiRequests: invoicePagesFetched + expensePagesFetched,
|
|
748
|
+
recordsInspected: rawInvoices.length + rawExpenses.length,
|
|
749
|
+
approximatePayloadBytes,
|
|
750
|
+
},
|
|
751
|
+
},
|
|
752
|
+
draftInvoices: {
|
|
753
|
+
note: "Draft invoices are non-issued working documents and are excluded from issued revenue.",
|
|
754
|
+
count: draftInvoices.length,
|
|
755
|
+
total: draftTotal,
|
|
756
|
+
},
|
|
757
|
+
issuedInvoices: {
|
|
758
|
+
selectionBasis: "issue_date_cohort_not_payment_date",
|
|
759
|
+
note: "Invoices are selected by issueDate within the period. currentlyMarkedPaidTotal sums invoices issued in this period that are currently marked as paid; it does NOT measure cash collected during this calendar month (e.g. an invoice issued in September and paid in October belongs to the September issueDate cohort). Unpaid totals represent nominal invoice amounts not marked as paid; real outstanding balance cannot be determined without partial-payment or bank-reconciliation records.",
|
|
760
|
+
realOutstandingBalanceDeterminable: false,
|
|
761
|
+
count: issuedInvoices.length,
|
|
762
|
+
currentlyMarkedPaidCount: paidInvoices.length,
|
|
763
|
+
unpaidNominalCount: unpaidInvoices.length,
|
|
764
|
+
overdueNominalCount: overdueInvoices.length,
|
|
765
|
+
issuedTotal,
|
|
766
|
+
currentlyMarkedPaidTotal,
|
|
767
|
+
unpaidNominalTotal,
|
|
768
|
+
overdueNominalTotal,
|
|
769
|
+
},
|
|
770
|
+
expenses: {
|
|
771
|
+
note: "Expenses are partitioned into deductible (taxDeductible === true), non-deductible (taxDeductible === false), and undetermined classification (taxDeductible absent, null, or non-boolean) without assuming deductibility by default.",
|
|
772
|
+
count: expenses.length,
|
|
773
|
+
total: expTotal,
|
|
774
|
+
deductibleCount: deductibleExpenses.length,
|
|
775
|
+
deductibleTotal: dedTotal,
|
|
776
|
+
nonDeductibleCount: nonDeductibleExpenses.length,
|
|
777
|
+
nonDeductibleTotal: nonDedTotal,
|
|
778
|
+
undeterminedDeductibilityCount: undeterminedDeductibilityExpenses.length,
|
|
779
|
+
undeterminedDeductibilityTotal: undeterminedDedTotal,
|
|
780
|
+
byCategory: [...categoryBuckets.values()]
|
|
781
|
+
.sort((a, b) => b.amount - a.amount)
|
|
782
|
+
.slice(0, 30)
|
|
783
|
+
.map((b) => ({
|
|
784
|
+
category: b.category,
|
|
785
|
+
currency: b.currency,
|
|
786
|
+
amount: b.amount,
|
|
787
|
+
expenseCount: b.count,
|
|
788
|
+
})),
|
|
789
|
+
},
|
|
790
|
+
netOperatingByCurrency,
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
const OPENAI_ALLOWED_NEXT_TOOLS = new Set([
|
|
794
|
+
"get_business_context",
|
|
795
|
+
"get_daily_operational_brief",
|
|
796
|
+
"get_business_period_summary",
|
|
797
|
+
"search_clients",
|
|
798
|
+
"search_vendors",
|
|
799
|
+
"list_invoices",
|
|
800
|
+
"get_invoice",
|
|
801
|
+
"search_invoices",
|
|
802
|
+
"list_expenses",
|
|
803
|
+
"get_expense",
|
|
804
|
+
"list_clients",
|
|
805
|
+
"get_client",
|
|
806
|
+
"list_client_contacts",
|
|
807
|
+
"list_client_activities",
|
|
808
|
+
"list_client_notes",
|
|
809
|
+
"list_products",
|
|
810
|
+
"get_product",
|
|
811
|
+
"list_quotes",
|
|
812
|
+
"get_quote",
|
|
813
|
+
"list_vendors",
|
|
814
|
+
"get_vendor",
|
|
815
|
+
"create_invoice",
|
|
816
|
+
"create_expense",
|
|
817
|
+
"create_client",
|
|
818
|
+
"create_client_contact",
|
|
819
|
+
"log_client_activity",
|
|
820
|
+
"create_client_note",
|
|
821
|
+
"create_product",
|
|
822
|
+
"create_quote",
|
|
823
|
+
"create_vendor",
|
|
824
|
+
"update_expense",
|
|
825
|
+
"update_client",
|
|
826
|
+
"update_product",
|
|
827
|
+
"update_vendor",
|
|
828
|
+
"delete_client_contact",
|
|
829
|
+
"delete_client_note",
|
|
830
|
+
"delete_quote",
|
|
831
|
+
]);
|
|
832
|
+
function mapRecommendedToolToOpenAISurface(toolName, category) {
|
|
833
|
+
if (OPENAI_ALLOWED_NEXT_TOOLS.has(toolName))
|
|
834
|
+
return toolName;
|
|
835
|
+
if (category === "overdue_invoice" || category === "draft_invoice")
|
|
836
|
+
return "get_invoice";
|
|
837
|
+
if (category === "draft_or_expiring_quote")
|
|
838
|
+
return "get_quote";
|
|
839
|
+
if (category === "expense_review")
|
|
840
|
+
return "get_expense";
|
|
841
|
+
if (category === "client_followup")
|
|
842
|
+
return "search_clients";
|
|
843
|
+
return "get_business_context";
|
|
844
|
+
}
|
|
845
|
+
function resolveMatchStatusWithCoverageTruth(matchCount, exactCount, complete) {
|
|
846
|
+
if (matchCount >= 2) {
|
|
847
|
+
return "ambiguous";
|
|
848
|
+
}
|
|
849
|
+
if (matchCount === 1) {
|
|
850
|
+
if (!complete)
|
|
851
|
+
return "partial_scan_single_match";
|
|
852
|
+
return exactCount === 1 ? "exact" : "unambiguous";
|
|
853
|
+
}
|
|
854
|
+
return complete ? "none" : "partial_scan_no_match";
|
|
855
|
+
}
|
|
856
|
+
export function registerOpenAI103CandidateTools(server, client) {
|
|
857
|
+
/* ---------------------------------------------------------------- */
|
|
858
|
+
/* 1. get_daily_operational_brief */
|
|
859
|
+
/* ---------------------------------------------------------------- */
|
|
860
|
+
server.registerTool("get_daily_operational_brief", {
|
|
861
|
+
title: "Get Daily Operational Brief",
|
|
862
|
+
description: "Generate a prioritized read-only operational brief answering 'What should I review today in my business and why?'. " +
|
|
863
|
+
"Separates draft documents from issued activity, enforces multi-currency and missing-amount provenance, " +
|
|
864
|
+
"states collection coverage and nominal unpaid balance limits, and links every priority item to its source record IDs. " +
|
|
865
|
+
"/ Genera un brief operativo diario de solo lectura con prioridades respaldadas por IDs de origen, " +
|
|
866
|
+
"separacion entre borradores y actividad emitida, desglose por moneda y cobertura explicita.",
|
|
867
|
+
annotations: READ_ONLY_ANNOTATIONS,
|
|
868
|
+
inputSchema: {
|
|
869
|
+
asOfDate: z
|
|
870
|
+
.string()
|
|
871
|
+
.regex(/^\d{4}-\d{2}-\d{2}$/, "Use YYYY-MM-DD format")
|
|
872
|
+
.optional()
|
|
873
|
+
.describe("Reference date in YYYY-MM-DD format (defaults to today UTC) / Fecha de referencia YYYY-MM-DD"),
|
|
874
|
+
pageLimit: z
|
|
875
|
+
.number()
|
|
876
|
+
.int()
|
|
877
|
+
.min(5)
|
|
878
|
+
.max(50)
|
|
879
|
+
.optional()
|
|
880
|
+
.describe("Records inspected per collection (5-50, default 25) / Registros inspeccionados por coleccion"),
|
|
881
|
+
},
|
|
882
|
+
outputSchema: dailyOperationalBriefOutputSchema,
|
|
883
|
+
}, async ({ asOfDate, pageLimit }) => withToolLogging("get_daily_operational_brief", async () => {
|
|
884
|
+
const brief = await generateDailyOperationalBrief(client, {
|
|
885
|
+
asOfDate,
|
|
886
|
+
pageLimit: pageLimit ?? 25,
|
|
887
|
+
maxActivityClients: 3,
|
|
888
|
+
});
|
|
889
|
+
const mapDoc = (d) => ({
|
|
890
|
+
id: d.id,
|
|
891
|
+
documentNumber: d.documentNumber,
|
|
892
|
+
status: d.status,
|
|
893
|
+
counterpartyName: d.counterpartyName,
|
|
894
|
+
issueDate: d.issueDate,
|
|
895
|
+
dueDate: d.dueDate,
|
|
896
|
+
amount: serializeProvenancedAmount(d.amount),
|
|
897
|
+
});
|
|
898
|
+
const structured = {
|
|
899
|
+
question: brief.question,
|
|
900
|
+
coverage: {
|
|
901
|
+
asOfDate: brief.coverage.asOfDate,
|
|
902
|
+
referenceMonth: brief.coverage.referenceMonth,
|
|
903
|
+
temporalScope: brief.coverage.temporalScope,
|
|
904
|
+
temporalScopeNote: brief.coverage.temporalScopeNote,
|
|
905
|
+
isPartial: brief.coverage.isPartial,
|
|
906
|
+
resources: Object.values(brief.coverage.resources),
|
|
907
|
+
},
|
|
908
|
+
activitySeparation: {
|
|
909
|
+
draftActivity: {
|
|
910
|
+
note: brief.activitySeparation.draftActivity.note,
|
|
911
|
+
invoiceDraftsCount: brief.activitySeparation.draftActivity.invoiceDraftsCount,
|
|
912
|
+
invoiceDraftsTotal: serializeProvenancedAmount(brief.activitySeparation.draftActivity.invoiceDraftsTotal),
|
|
913
|
+
invoiceDrafts: brief.activitySeparation.draftActivity.invoiceDrafts.slice(0, 25).map(mapDoc),
|
|
914
|
+
quoteDraftsCount: brief.activitySeparation.draftActivity.quoteDraftsCount,
|
|
915
|
+
quoteDraftsTotal: serializeProvenancedAmount(brief.activitySeparation.draftActivity.quoteDraftsTotal),
|
|
916
|
+
quoteDrafts: brief.activitySeparation.draftActivity.quoteDrafts.slice(0, 25).map(mapDoc),
|
|
917
|
+
},
|
|
918
|
+
issuedActivity: {
|
|
919
|
+
note: brief.activitySeparation.issuedActivity.note,
|
|
920
|
+
realOutstandingBalanceDeterminable: false,
|
|
921
|
+
unpaidNominalSemantics: "nominal_unpaid_invoice_totals_without_partial_payments",
|
|
922
|
+
issuedInvoicesCount: brief.activitySeparation.issuedActivity.issuedInvoicesCount,
|
|
923
|
+
paidInvoicesCount: brief.activitySeparation.issuedActivity.paidInvoicesCount,
|
|
924
|
+
paidInvoicesTotal: serializeProvenancedAmount(brief.activitySeparation.issuedActivity.paidInvoicesTotal),
|
|
925
|
+
unpaidNominalInvoicesCount: brief.activitySeparation.issuedActivity.unpaidNominalInvoicesCount,
|
|
926
|
+
unpaidNominalTotal: serializeProvenancedAmount(brief.activitySeparation.issuedActivity.unpaidNominalTotal),
|
|
927
|
+
overdueNominalInvoicesCount: brief.activitySeparation.issuedActivity.overdueNominalInvoicesCount,
|
|
928
|
+
overdueNominalTotal: serializeProvenancedAmount(brief.activitySeparation.issuedActivity.overdueNominalTotal),
|
|
929
|
+
onTimeNominalPendingCount: brief.activitySeparation.issuedActivity.onTimeNominalPendingCount,
|
|
930
|
+
onTimeNominalPendingTotal: serializeProvenancedAmount(brief.activitySeparation.issuedActivity.onTimeNominalPendingTotal),
|
|
931
|
+
outstandingInvoices: brief.activitySeparation.issuedActivity.outstandingInvoices.slice(0, 25).map(mapDoc),
|
|
932
|
+
},
|
|
933
|
+
recordedExpenses: {
|
|
934
|
+
note: brief.activitySeparation.recordedExpenses.note,
|
|
935
|
+
expensesCount: brief.activitySeparation.recordedExpenses.expensesCount,
|
|
936
|
+
expensesTotal: serializeProvenancedAmount(brief.activitySeparation.recordedExpenses.expensesTotal),
|
|
937
|
+
deductibleCount: brief.activitySeparation.recordedExpenses.deductibleCount,
|
|
938
|
+
deductibleTotal: serializeProvenancedAmount(brief.activitySeparation.recordedExpenses.deductibleTotal),
|
|
939
|
+
nonDeductibleCount: brief.activitySeparation.recordedExpenses.nonDeductibleCount,
|
|
940
|
+
nonDeductibleTotal: serializeProvenancedAmount(brief.activitySeparation.recordedExpenses.nonDeductibleTotal),
|
|
941
|
+
pendingReviewCount: brief.activitySeparation.recordedExpenses.pendingReviewCount,
|
|
942
|
+
},
|
|
943
|
+
currentMonthActivity: {
|
|
944
|
+
referenceMonth: brief.activitySeparation.currentMonthActivity.referenceMonth,
|
|
945
|
+
note: brief.activitySeparation.currentMonthActivity.note,
|
|
946
|
+
issuedInvoicesInMonthCount: brief.activitySeparation.currentMonthActivity.issuedInvoicesInMonthCount,
|
|
947
|
+
issuedInvoicesInMonthTotal: serializeProvenancedAmount(brief.activitySeparation.currentMonthActivity.issuedInvoicesInMonthTotal),
|
|
948
|
+
invoiceDraftsInMonthCount: brief.activitySeparation.currentMonthActivity.invoiceDraftsInMonthCount,
|
|
949
|
+
invoiceDraftsInMonthTotal: serializeProvenancedAmount(brief.activitySeparation.currentMonthActivity.invoiceDraftsInMonthTotal),
|
|
950
|
+
expensesInMonthCount: brief.activitySeparation.currentMonthActivity.expensesInMonthCount,
|
|
951
|
+
expensesInMonthTotal: serializeProvenancedAmount(brief.activitySeparation.currentMonthActivity.expensesInMonthTotal),
|
|
952
|
+
},
|
|
953
|
+
},
|
|
954
|
+
priorities: brief.priorities.slice(0, 25).map((p) => ({
|
|
955
|
+
priority: p.priority,
|
|
956
|
+
category: p.category,
|
|
957
|
+
title: p.title,
|
|
958
|
+
why: p.why,
|
|
959
|
+
recordRefs: p.recordRefs.slice(0, 10),
|
|
960
|
+
...(p.amount ? { amount: serializeProvenancedAmount(p.amount) } : {}),
|
|
961
|
+
suggestedNextAction: {
|
|
962
|
+
description: p.suggestedNextAction.description,
|
|
963
|
+
recommendedTool: mapRecommendedToolToOpenAISurface(p.suggestedNextAction.recommendedTool, p.category),
|
|
964
|
+
requiresConfirmation: p.suggestedNextAction.requiresConfirmation,
|
|
965
|
+
autoExecuted: false,
|
|
966
|
+
},
|
|
967
|
+
})),
|
|
968
|
+
knownUnknowns: brief.knownUnknowns.slice(0, 15),
|
|
969
|
+
mutationsExecuted: 0,
|
|
970
|
+
};
|
|
971
|
+
return {
|
|
972
|
+
content: [getContent(brief.markdown)],
|
|
973
|
+
structuredContent: structured,
|
|
974
|
+
};
|
|
975
|
+
}));
|
|
976
|
+
/* ---------------------------------------------------------------- */
|
|
977
|
+
/* 2. get_business_period_summary */
|
|
978
|
+
/* ---------------------------------------------------------------- */
|
|
979
|
+
server.registerTool("get_business_period_summary", {
|
|
980
|
+
title: "Get Business Period Summary",
|
|
981
|
+
description: "Get an operational summary of issued invoices, draft invoices, and recorded expenses for a calendar month (YYYY-MM), " +
|
|
982
|
+
"with optional side-by-side comparison against another month. Selects invoices by issueDate and expenses by date, " +
|
|
983
|
+
"aggregates strictly by currency without cross-currency conversion, separates drafts from issued invoices (reporting how much " +
|
|
984
|
+
"of that period's issued invoice cohort is currently marked as paid vs unpaid nominal, without claiming payment-date cash collections " +
|
|
985
|
+
"or treasury cash flow), partitions expenses into deductible (true), non-deductible (false), and undetermined classification, " +
|
|
986
|
+
"reports coverage telemetry, and excludes regulated tax-return calculations. " +
|
|
987
|
+
"/ Obtiene el resumen operativo de un mes (YYYY-MM) y permite compararlo con otro mes por moneda, seleccionando facturas por " +
|
|
988
|
+
"fecha de emision (issueDate) y gastos por fecha (date), separando borradores de facturas emitidas (indicando cuantas de las emitidas " +
|
|
989
|
+
"en ese mes estan actualmente marcadas como pagadas frente a pendientes nominales, sin atribuirlo a cobros por fecha de pago ni tesoreria), " +
|
|
990
|
+
"y separando gastos deducibles, no deducibles y de clasificacion no determinada.",
|
|
991
|
+
annotations: READ_ONLY_ANNOTATIONS,
|
|
992
|
+
inputSchema: {
|
|
993
|
+
period: z
|
|
994
|
+
.string()
|
|
995
|
+
.regex(CALENDAR_MONTH_REGEX, "Use YYYY-MM format (01-12)")
|
|
996
|
+
.describe("Primary calendar month in YYYY-MM format (e.g. '2026-10') / Mes principal YYYY-MM"),
|
|
997
|
+
compareWithPeriod: z
|
|
998
|
+
.string()
|
|
999
|
+
.regex(CALENDAR_MONTH_REGEX, "Use YYYY-MM format (01-12)")
|
|
1000
|
+
.optional()
|
|
1001
|
+
.describe("Optional comparison month in YYYY-MM format (e.g. '2026-09') / Mes a comparar YYYY-MM"),
|
|
1002
|
+
},
|
|
1003
|
+
outputSchema: businessPeriodSummaryOutputSchema,
|
|
1004
|
+
}, async ({ period, compareWithPeriod }) => withToolLogging("get_business_period_summary", async () => {
|
|
1005
|
+
let defaultCurrency = "EUR";
|
|
1006
|
+
try {
|
|
1007
|
+
const ctx = await client.getBusinessContext();
|
|
1008
|
+
const cur = ctx?.defaults?.currency;
|
|
1009
|
+
if (typeof cur === "string" && cur.trim())
|
|
1010
|
+
defaultCurrency = cur.trim().toUpperCase();
|
|
1011
|
+
}
|
|
1012
|
+
catch {
|
|
1013
|
+
// Default to EUR if context call fails
|
|
1014
|
+
}
|
|
1015
|
+
const primaryPeriod = await computeSinglePeriodSummary(client, period, defaultCurrency);
|
|
1016
|
+
let comparisonPeriod;
|
|
1017
|
+
let comparison;
|
|
1018
|
+
if (compareWithPeriod) {
|
|
1019
|
+
comparisonPeriod =
|
|
1020
|
+
compareWithPeriod === period
|
|
1021
|
+
? primaryPeriod
|
|
1022
|
+
: await computeSinglePeriodSummary(client, compareWithPeriod, defaultCurrency);
|
|
1023
|
+
const pIssuedUnavail = primaryPeriod.issuedInvoices.issuedTotal.unavailableCount > 0;
|
|
1024
|
+
const cIssuedUnavail = comparisonPeriod.issuedInvoices.issuedTotal.unavailableCount > 0;
|
|
1025
|
+
const pPaidUnavail = primaryPeriod.issuedInvoices.currentlyMarkedPaidTotal.unavailableCount > 0;
|
|
1026
|
+
const cPaidUnavail = comparisonPeriod.issuedInvoices.currentlyMarkedPaidTotal.unavailableCount > 0;
|
|
1027
|
+
const pExpUnavail = primaryPeriod.expenses.total.unavailableCount > 0;
|
|
1028
|
+
const cExpUnavail = comparisonPeriod.expenses.total.unavailableCount > 0;
|
|
1029
|
+
const currencies = new Set([
|
|
1030
|
+
...primaryPeriod.issuedInvoices.issuedTotal.totalsByCurrency.map((b) => b.currency),
|
|
1031
|
+
...comparisonPeriod.issuedInvoices.issuedTotal.totalsByCurrency.map((b) => b.currency),
|
|
1032
|
+
...primaryPeriod.issuedInvoices.currentlyMarkedPaidTotal.totalsByCurrency.map((b) => b.currency),
|
|
1033
|
+
...comparisonPeriod.issuedInvoices.currentlyMarkedPaidTotal.totalsByCurrency.map((b) => b.currency),
|
|
1034
|
+
...primaryPeriod.expenses.total.totalsByCurrency.map((b) => b.currency),
|
|
1035
|
+
...comparisonPeriod.expenses.total.totalsByCurrency.map((b) => b.currency),
|
|
1036
|
+
]);
|
|
1037
|
+
if (currencies.size === 0) {
|
|
1038
|
+
currencies.add(defaultCurrency);
|
|
1039
|
+
}
|
|
1040
|
+
const bothComplete = primaryPeriod.coverage.isComplete && comparisonPeriod.coverage.isComplete;
|
|
1041
|
+
const anyUnavailable = pIssuedUnavail || cIssuedUnavail || pPaidUnavail || cPaidUnavail || pExpUnavail || cExpUnavail;
|
|
1042
|
+
const deltasByCurrency = [...currencies].sort().map((currency) => {
|
|
1043
|
+
const pIssuedRaw = primaryPeriod.issuedInvoices.issuedTotal.totalsByCurrency.find((b) => b.currency === currency)?.amount ?? 0;
|
|
1044
|
+
const cIssuedRaw = comparisonPeriod.issuedInvoices.issuedTotal.totalsByCurrency.find((b) => b.currency === currency)?.amount ?? 0;
|
|
1045
|
+
const pPaidRaw = primaryPeriod.issuedInvoices.currentlyMarkedPaidTotal.totalsByCurrency.find((b) => b.currency === currency)
|
|
1046
|
+
?.amount ?? 0;
|
|
1047
|
+
const cPaidRaw = comparisonPeriod.issuedInvoices.currentlyMarkedPaidTotal.totalsByCurrency.find((b) => b.currency === currency)
|
|
1048
|
+
?.amount ?? 0;
|
|
1049
|
+
const pExpRaw = primaryPeriod.expenses.total.totalsByCurrency.find((b) => b.currency === currency)?.amount ?? 0;
|
|
1050
|
+
const cExpRaw = comparisonPeriod.expenses.total.totalsByCurrency.find((b) => b.currency === currency)?.amount ?? 0;
|
|
1051
|
+
const pIssued = pIssuedUnavail ? null : pIssuedRaw;
|
|
1052
|
+
const cIssued = cIssuedUnavail ? null : cIssuedRaw;
|
|
1053
|
+
const pPaid = pPaidUnavail ? null : pPaidRaw;
|
|
1054
|
+
const cPaid = cPaidUnavail ? null : cPaidRaw;
|
|
1055
|
+
const pExp = pExpUnavail ? null : pExpRaw;
|
|
1056
|
+
const cExp = cExpUnavail ? null : cExpRaw;
|
|
1057
|
+
return {
|
|
1058
|
+
currency,
|
|
1059
|
+
primaryIssuedRevenue: pIssued,
|
|
1060
|
+
comparisonIssuedRevenue: cIssued,
|
|
1061
|
+
issuedRevenueDelta: pIssued !== null && cIssued !== null ? roundCurrency(pIssued - cIssued) : null,
|
|
1062
|
+
primaryIssuedCurrentlyPaid: pPaid,
|
|
1063
|
+
comparisonIssuedCurrentlyPaid: cPaid,
|
|
1064
|
+
issuedCurrentlyPaidDelta: pPaid !== null && cPaid !== null ? roundCurrency(pPaid - cPaid) : null,
|
|
1065
|
+
primaryExpenses: pExp,
|
|
1066
|
+
comparisonExpenses: cExp,
|
|
1067
|
+
expensesDelta: pExp !== null && cExp !== null ? roundCurrency(pExp - cExp) : null,
|
|
1068
|
+
completeAndDeterminable: bothComplete && !anyUnavailable,
|
|
1069
|
+
};
|
|
1070
|
+
});
|
|
1071
|
+
const notes = [
|
|
1072
|
+
"Invoice figures compare issueDate cohorts for each period; issuedCurrentlyPaidDelta compares invoices issued in each period that are currently marked as paid, not cash collections by payment date.",
|
|
1073
|
+
];
|
|
1074
|
+
if (compareWithPeriod === period) {
|
|
1075
|
+
notes.push("Primary and comparison periods are identical; all deltas are zero.");
|
|
1076
|
+
}
|
|
1077
|
+
if (!bothComplete) {
|
|
1078
|
+
notes.push("One or both periods exceeded the bounded inspection cap; deltas reflect only the inspected sample.");
|
|
1079
|
+
}
|
|
1080
|
+
if (anyUnavailable) {
|
|
1081
|
+
notes.push("One or more documents have missing or corrupt amounts; affected revenue or expense deltas are null instead of coercing missing amounts to 0.");
|
|
1082
|
+
}
|
|
1083
|
+
if (currencies.size > 1) {
|
|
1084
|
+
notes.push("Multiple currencies detected; deltas are reported per currency without exchange-rate conversion.");
|
|
1085
|
+
}
|
|
1086
|
+
comparison = {
|
|
1087
|
+
primaryMonth: period,
|
|
1088
|
+
compareWithMonth: compareWithPeriod,
|
|
1089
|
+
bothPeriodsComplete: bothComplete,
|
|
1090
|
+
issuedInvoicesCountDelta: primaryPeriod.issuedInvoices.count - comparisonPeriod.issuedInvoices.count,
|
|
1091
|
+
draftInvoicesCountDelta: primaryPeriod.draftInvoices.count - comparisonPeriod.draftInvoices.count,
|
|
1092
|
+
expensesCountDelta: primaryPeriod.expenses.count - comparisonPeriod.expenses.count,
|
|
1093
|
+
deltasByCurrency,
|
|
1094
|
+
notes,
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
const output = {
|
|
1098
|
+
accountingSemanticsNote: "Operational period summary based on invoice issueDate and expense date. Draft and cancelled invoices are excluded from issued revenue. currentlyMarkedPaidTotal sums invoices issued in the period whose current status is 'paid'; it does NOT measure cash collected during that calendar month (e.g. an invoice issued in September and paid in October belongs to the September issueDate cohort). issuedCohortCurrentlyPaidMinusExpenses is an issueDate-cohort difference, not treasury cash flow or accounting profit. Expenses are partitioned into deductible (taxDeductible === true), non-deductible (taxDeductible === false), and undetermined classification (taxDeductible absent/null) without assuming deductibility by default. Multi-currency totals are kept in separate currency buckets without conversion. Regulated tax-filing figures are omitted.",
|
|
1099
|
+
primaryPeriod,
|
|
1100
|
+
...(comparisonPeriod ? { comparisonPeriod } : {}),
|
|
1101
|
+
...(comparison ? { comparison } : {}),
|
|
1102
|
+
};
|
|
1103
|
+
return {
|
|
1104
|
+
content: [getContent(formatRecord(`Business Period Summary (${period})`, output))],
|
|
1105
|
+
structuredContent: output,
|
|
1106
|
+
};
|
|
1107
|
+
}));
|
|
1108
|
+
/* ---------------------------------------------------------------- */
|
|
1109
|
+
/* 3. search_clients */
|
|
1110
|
+
/* ---------------------------------------------------------------- */
|
|
1111
|
+
server.registerTool("search_clients", {
|
|
1112
|
+
title: "Search Clients by Name",
|
|
1113
|
+
description: "Search clients by commercial name only across paginated directory windows, report exact coverage and uninspected " +
|
|
1114
|
+
"record counts, distinguish complete single matches from partial-scan single matches or ambiguous matches, and optionally " +
|
|
1115
|
+
"retrieve meeting-prep context (recent invoices and CRM activities). Uses server-side field projection so government tax " +
|
|
1116
|
+
"identifiers and street addresses are never queried or serialized. " +
|
|
1117
|
+
"/ Busca clientes exclusivamente por nombre comercial en paginas sucesivas del directorio, informa la cobertura exacta " +
|
|
1118
|
+
"y registros no inspeccionados, distingue coincidencias unicas completas de muestras parciales o ambiguas, y recupera " +
|
|
1119
|
+
"contexto para reuniones sin consultar ni exponer identificadores fiscales ni direcciones.",
|
|
1120
|
+
annotations: READ_ONLY_ANNOTATIONS,
|
|
1121
|
+
inputSchema: {
|
|
1122
|
+
query: z
|
|
1123
|
+
.string()
|
|
1124
|
+
.trim()
|
|
1125
|
+
.min(1)
|
|
1126
|
+
.max(100)
|
|
1127
|
+
.describe(`Commercial client name to search for (1-100 chars) / Nombre comercial del cliente — ${OPENAI_REVIEW_FREE_TEXT_WARNING}`),
|
|
1128
|
+
stage: z
|
|
1129
|
+
.enum(["lead", "contacted", "proposal", "active", "inactive", "lost"])
|
|
1130
|
+
.optional()
|
|
1131
|
+
.describe("Optional CRM stage filter / Filtro opcional por etapa CRM"),
|
|
1132
|
+
includeContext: z
|
|
1133
|
+
.boolean()
|
|
1134
|
+
.optional()
|
|
1135
|
+
.describe("When true (default) and 1-3 clients match, includes recent invoices and CRM activities / Incluir contexto reciente"),
|
|
1136
|
+
limit: z
|
|
1137
|
+
.number()
|
|
1138
|
+
.int()
|
|
1139
|
+
.min(1)
|
|
1140
|
+
.max(20)
|
|
1141
|
+
.optional()
|
|
1142
|
+
.describe("Maximum matches to return (1-20, default 10) / Maximo de coincidencias"),
|
|
1143
|
+
maxPages: z
|
|
1144
|
+
.number()
|
|
1145
|
+
.int()
|
|
1146
|
+
.min(1)
|
|
1147
|
+
.max(5)
|
|
1148
|
+
.optional()
|
|
1149
|
+
.describe("Maximum directory pages (100 records/page) to scan in this call (1-5, default 3 = 300 records) / Paginas maximas a escanear (1-5, por defecto 3)"),
|
|
1150
|
+
after: z
|
|
1151
|
+
.string()
|
|
1152
|
+
.trim()
|
|
1153
|
+
.min(1)
|
|
1154
|
+
.max(256)
|
|
1155
|
+
.optional()
|
|
1156
|
+
.describe("Optional continuation token from a previous coverage.nextCursor to scan subsequent directory pages / Cursor de continuacion coverage.nextCursor"),
|
|
1157
|
+
},
|
|
1158
|
+
outputSchema: searchClientsOutputSchema,
|
|
1159
|
+
}, async ({ query, stage, includeContext, limit, maxPages, after: initialAfter }) => withToolLogging("search_clients", async () => {
|
|
1160
|
+
const cleanQuery = query.trim();
|
|
1161
|
+
if (isLikelyGovernmentIdQuery(cleanQuery)) {
|
|
1162
|
+
return {
|
|
1163
|
+
content: [{
|
|
1164
|
+
type: "text",
|
|
1165
|
+
text: "Error: search_clients searches by commercial name only. Government tax identifiers (NIF/NIE/CIF/VAT) are not accepted on the reviewed ChatGPT surface.",
|
|
1166
|
+
}],
|
|
1167
|
+
isError: true,
|
|
1168
|
+
};
|
|
1169
|
+
}
|
|
1170
|
+
const maxMatches = Math.min(limit ?? 10, 20);
|
|
1171
|
+
const pageSize = 100;
|
|
1172
|
+
const pagesToScan = Math.min(Math.max(maxPages ?? 3, 1), 5);
|
|
1173
|
+
let currentCursor;
|
|
1174
|
+
let startOffset = 0;
|
|
1175
|
+
if (typeof initialAfter === "string" && initialAfter.trim()) {
|
|
1176
|
+
const trimmedAfter = initialAfter.trim();
|
|
1177
|
+
const offsetMatch = /^offset:(\d+)$/.exec(trimmedAfter);
|
|
1178
|
+
if (offsetMatch) {
|
|
1179
|
+
startOffset = Number(offsetMatch[1]);
|
|
1180
|
+
}
|
|
1181
|
+
else {
|
|
1182
|
+
currentCursor = trimmedAfter;
|
|
1183
|
+
const trailingDigits = /(?:^|[_:])(\d+)$/.exec(trimmedAfter);
|
|
1184
|
+
if (trailingDigits) {
|
|
1185
|
+
startOffset = Number(trailingDigits[1]);
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
let currentOffset = startOffset;
|
|
1190
|
+
let inspected = 0;
|
|
1191
|
+
let total = 0;
|
|
1192
|
+
let reachedEndOfDirectory = false;
|
|
1193
|
+
let lastNextCursor = null;
|
|
1194
|
+
let directoryPagesFetched = 0;
|
|
1195
|
+
let contextRequestsMade = 0;
|
|
1196
|
+
let approximatePayloadBytes = 0;
|
|
1197
|
+
const scored = [];
|
|
1198
|
+
const seenIds = new Set();
|
|
1199
|
+
// Do NOT pass `q` to `client.listClients` because backend `q` searches `taxId` / `searchTokens`.
|
|
1200
|
+
// Pass `fields: CLIENT_SEARCH_SERVER_PROJECTION` so the backend projects only id,name,stage,email,phone
|
|
1201
|
+
// before serializing the response over HTTP.
|
|
1202
|
+
for (let page = 0; page < pagesToScan; page++) {
|
|
1203
|
+
const reqParams = {
|
|
1204
|
+
limit: pageSize,
|
|
1205
|
+
fields: CLIENT_SEARCH_SERVER_PROJECTION,
|
|
1206
|
+
...(stage ? { stage } : {}),
|
|
1207
|
+
};
|
|
1208
|
+
if (currentCursor !== undefined) {
|
|
1209
|
+
reqParams.after = currentCursor;
|
|
1210
|
+
}
|
|
1211
|
+
else if (currentOffset > 0) {
|
|
1212
|
+
reqParams.offset = currentOffset;
|
|
1213
|
+
}
|
|
1214
|
+
const res = await client.listClients(reqParams);
|
|
1215
|
+
directoryPagesFetched++;
|
|
1216
|
+
approximatePayloadBytes += approxJsonBytes(res);
|
|
1217
|
+
const rows = Array.isArray(res?.data) ? res.data : [];
|
|
1218
|
+
if (page === 0) {
|
|
1219
|
+
if (typeof res?.offset === "number") {
|
|
1220
|
+
startOffset = res.offset;
|
|
1221
|
+
}
|
|
1222
|
+
total = typeof res?.total === "number" ? res.total : startOffset + rows.length;
|
|
1223
|
+
}
|
|
1224
|
+
inspected += rows.length;
|
|
1225
|
+
for (const row of rows) {
|
|
1226
|
+
if (!row || typeof row !== "object")
|
|
1227
|
+
continue;
|
|
1228
|
+
const rec = row;
|
|
1229
|
+
const recId = typeof rec.id === "string" ? rec.id : "";
|
|
1230
|
+
if (recId && seenIds.has(recId))
|
|
1231
|
+
continue;
|
|
1232
|
+
if (recId)
|
|
1233
|
+
seenIds.add(recId);
|
|
1234
|
+
const m = matchCommercialName(rec.name, cleanQuery);
|
|
1235
|
+
if (m.matched && m.rank) {
|
|
1236
|
+
scored.push({ record: rec, rank: m.rank, score: m.score });
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
const nextCursorFromRes = typeof res?.nextCursor === "string" && res.nextCursor ? res.nextCursor : undefined;
|
|
1240
|
+
const cumulativePosition = startOffset + inspected;
|
|
1241
|
+
const moreByTotal = total > cumulativePosition;
|
|
1242
|
+
const hasMore = Boolean(nextCursorFromRes) || (rows.length >= pageSize && moreByTotal);
|
|
1243
|
+
if (!hasMore || rows.length === 0) {
|
|
1244
|
+
reachedEndOfDirectory = !nextCursorFromRes && cumulativePosition >= total;
|
|
1245
|
+
lastNextCursor = nextCursorFromRes ?? (reachedEndOfDirectory ? null : `offset:${cumulativePosition}`);
|
|
1246
|
+
break;
|
|
1247
|
+
}
|
|
1248
|
+
if (nextCursorFromRes) {
|
|
1249
|
+
currentCursor = nextCursorFromRes;
|
|
1250
|
+
lastNextCursor = nextCursorFromRes;
|
|
1251
|
+
}
|
|
1252
|
+
else {
|
|
1253
|
+
currentCursor = undefined;
|
|
1254
|
+
currentOffset = cumulativePosition;
|
|
1255
|
+
lastNextCursor = `offset:${cumulativePosition}`;
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
// Epistemic truth: `complete` is true ONLY when this call inspected the entire directory
|
|
1259
|
+
// (startOffset === 0, inspected >= total, and no forward cursor remains).
|
|
1260
|
+
// When continuing from `after` (startOffset > 0), reaching the end of the directory means
|
|
1261
|
+
// `nextCursor` is null (no forward pages remain), while `uninspectedCount` reflects the
|
|
1262
|
+
// `total - inspected` records not inspected in this stateless call.
|
|
1263
|
+
const complete = startOffset === 0 && reachedEndOfDirectory && inspected >= total && lastNextCursor === null;
|
|
1264
|
+
const uninspectedCount = complete ? 0 : Math.max(1, total - inspected);
|
|
1265
|
+
const nextCursor = reachedEndOfDirectory ? null : lastNextCursor;
|
|
1266
|
+
scored.sort((a, b) => b.score - a.score || String(a.record.name ?? "").localeCompare(String(b.record.name ?? "")));
|
|
1267
|
+
// F-02: When ambiguous (scored.length >= 2), always surface at least 2 candidates so
|
|
1268
|
+
// callers never receive matchStatus="ambiguous" with only 1 candidate in `matches`.
|
|
1269
|
+
const effectiveLimit = scored.length >= 2 ? Math.min(20, Math.max(maxMatches, 2)) : maxMatches;
|
|
1270
|
+
const top = scored.slice(0, effectiveLimit);
|
|
1271
|
+
const exactCount = scored.filter((x) => x.rank === "exact").length;
|
|
1272
|
+
const matchStatus = resolveMatchStatusWithCoverageTruth(scored.length, exactCount, complete);
|
|
1273
|
+
const shouldFetchContext = (includeContext ?? true) && top.length >= 1 && top.length <= 3;
|
|
1274
|
+
let defaultCurrency;
|
|
1275
|
+
const getWorkspaceDefaultCurrency = async () => {
|
|
1276
|
+
if (defaultCurrency)
|
|
1277
|
+
return defaultCurrency;
|
|
1278
|
+
defaultCurrency = "EUR";
|
|
1279
|
+
try {
|
|
1280
|
+
const ctx = await client.getBusinessContext();
|
|
1281
|
+
contextRequestsMade++;
|
|
1282
|
+
approximatePayloadBytes += approxJsonBytes(ctx);
|
|
1283
|
+
const cur = ctx?.defaults?.currency;
|
|
1284
|
+
if (typeof cur === "string" && cur.trim()) {
|
|
1285
|
+
defaultCurrency = cur.trim().toUpperCase();
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
catch {
|
|
1289
|
+
// Keep EUR fallback if context call fails
|
|
1290
|
+
}
|
|
1291
|
+
return defaultCurrency;
|
|
1292
|
+
};
|
|
1293
|
+
const matches = [];
|
|
1294
|
+
for (const item of top) {
|
|
1295
|
+
const id = String(item.record.id ?? "");
|
|
1296
|
+
const name = String(item.record.name ?? "");
|
|
1297
|
+
const normTargetName = normalizeSearchText(name);
|
|
1298
|
+
const entry = {
|
|
1299
|
+
id,
|
|
1300
|
+
name,
|
|
1301
|
+
...(typeof item.record.stage === "string" ? { stage: item.record.stage } : {}),
|
|
1302
|
+
...(typeof item.record.email === "string" ? { email: item.record.email } : {}),
|
|
1303
|
+
...(typeof item.record.phone === "string" ? { phone: item.record.phone } : {}),
|
|
1304
|
+
matchRank: item.rank,
|
|
1305
|
+
};
|
|
1306
|
+
if (shouldFetchContext && id) {
|
|
1307
|
+
const [invRes, actRes] = await Promise.all([
|
|
1308
|
+
client.searchInvoices(name, { limit: 5 }).catch(() => ({ data: [] })),
|
|
1309
|
+
client.listClientActivities(id, { limit: 5 }).catch(() => ({ data: [] })),
|
|
1310
|
+
]);
|
|
1311
|
+
contextRequestsMade += 2;
|
|
1312
|
+
approximatePayloadBytes += approxJsonBytes(invRes) + approxJsonBytes(actRes);
|
|
1313
|
+
const rawInvRows = Array.isArray(invRes?.data) ? invRes.data : [];
|
|
1314
|
+
// F-05: Strict entity isolation: if inv.clientId is present, require exact ID equality
|
|
1315
|
+
// (never fall through to prefix matching!). If absent, require exact normalized name equality.
|
|
1316
|
+
const invRows = rawInvRows.filter((inv) => {
|
|
1317
|
+
if (typeof inv.clientId === "string" && inv.clientId.trim()) {
|
|
1318
|
+
return inv.clientId.trim() === id;
|
|
1319
|
+
}
|
|
1320
|
+
return typeof inv.clientName === "string" && normalizeSearchText(inv.clientName) === normTargetName;
|
|
1321
|
+
});
|
|
1322
|
+
const actRows = Array.isArray(actRes?.data) ? actRes.data : [];
|
|
1323
|
+
const needsFallbackCurrency = invRows.slice(0, 5).some((inv) => typeof inv.currency !== "string" || !inv.currency.trim());
|
|
1324
|
+
const fallbackCur = needsFallbackCurrency ? await getWorkspaceDefaultCurrency() : "EUR";
|
|
1325
|
+
entry.context = {
|
|
1326
|
+
recentInvoices: invRows.slice(0, 5).map((inv) => ({
|
|
1327
|
+
id: String(inv.id ?? ""),
|
|
1328
|
+
invoiceNumber: typeof inv.documentNumber === "string"
|
|
1329
|
+
? inv.documentNumber
|
|
1330
|
+
: typeof inv.invoiceNumber === "string"
|
|
1331
|
+
? inv.invoiceNumber
|
|
1332
|
+
: null,
|
|
1333
|
+
status: typeof inv.status === "string" ? inv.status : "draft",
|
|
1334
|
+
issueDate: typeof inv.issueDate === "string" ? inv.issueDate : null,
|
|
1335
|
+
dueDate: typeof inv.dueDate === "string" ? inv.dueDate : null,
|
|
1336
|
+
amount: serializeProvenancedAmount(resolveDocumentAmount(inv, fallbackCur)),
|
|
1337
|
+
})),
|
|
1338
|
+
recentActivities: actRows.slice(0, 5).map((act) => ({
|
|
1339
|
+
type: typeof act.type === "string" ? act.type : "note",
|
|
1340
|
+
title: typeof act.title === "string" ? act.title : "",
|
|
1341
|
+
timestamp: typeof act.timestamp === "string" ? act.timestamp : null,
|
|
1342
|
+
})),
|
|
1343
|
+
};
|
|
1344
|
+
}
|
|
1345
|
+
matches.push(entry);
|
|
1346
|
+
}
|
|
1347
|
+
const disambiguationNote = matchStatus === "none"
|
|
1348
|
+
? `No clients matched '${cleanQuery}' across the complete workspace directory (${inspected}/${total} records inspected).`
|
|
1349
|
+
: matchStatus === "partial_scan_no_match"
|
|
1350
|
+
? `No clients matched '${cleanQuery}' in the inspected directory window (${inspected} of ${total} records inspected; ${uninspectedCount} records remain uninspected). ${nextCursor ? `Continue scanning with after='${nextCursor}' or increase maxPages` : "Scan from offset 0 with higher maxPages"} before concluding the client does not exist.`
|
|
1351
|
+
: matchStatus === "partial_scan_single_match"
|
|
1352
|
+
? `Found 1 matching client ('${matches[0].name}', id=${matches[0].id}) in the inspected window (${inspected} of ${total} records), but ${uninspectedCount} directory records were not inspected in this call. Do not assume this is the only match in the workspace; confirm with the user${nextCursor ? ` or continue scanning with after='${nextCursor}'` : ""} before mutating records.`
|
|
1353
|
+
: matchStatus === "ambiguous"
|
|
1354
|
+
? `Found ${scored.length} clients matching '${cleanQuery}'${complete ? "" : ` across ${inspected}/${total} inspected records (${uninspectedCount} uninspected)`} (showing ${matches.length} of ${scored.length} matches). Present all candidate IDs/names and ask the user which client they mean before mutating records.`
|
|
1355
|
+
: `Resolved a unique client match ('${matches[0].name}', id=${matches[0].id}) across the complete directory (${inspected}/${total} records inspected).`;
|
|
1356
|
+
const output = {
|
|
1357
|
+
query: cleanQuery,
|
|
1358
|
+
searchedFields: ["name"],
|
|
1359
|
+
matchStatus,
|
|
1360
|
+
disambiguationNote,
|
|
1361
|
+
coverage: {
|
|
1362
|
+
inspected,
|
|
1363
|
+
total,
|
|
1364
|
+
uninspectedCount,
|
|
1365
|
+
complete,
|
|
1366
|
+
nextCursor,
|
|
1367
|
+
telemetry: {
|
|
1368
|
+
directoryPagesFetched,
|
|
1369
|
+
contextRequestsMade,
|
|
1370
|
+
totalApiRequests: directoryPagesFetched + contextRequestsMade,
|
|
1371
|
+
recordsInspected: inspected,
|
|
1372
|
+
approximatePayloadBytes,
|
|
1373
|
+
serverProjectionFields: CLIENT_SEARCH_SERVER_PROJECTION,
|
|
1374
|
+
},
|
|
1375
|
+
},
|
|
1376
|
+
matches,
|
|
1377
|
+
};
|
|
1378
|
+
return {
|
|
1379
|
+
content: [getContent(formatRecord("Client Search", output))],
|
|
1380
|
+
structuredContent: output,
|
|
1381
|
+
};
|
|
1382
|
+
}));
|
|
1383
|
+
/* ---------------------------------------------------------------- */
|
|
1384
|
+
/* 4. search_vendors */
|
|
1385
|
+
/* ---------------------------------------------------------------- */
|
|
1386
|
+
server.registerTool("search_vendors", {
|
|
1387
|
+
title: "Search Vendors by Name",
|
|
1388
|
+
description: "Search vendors/suppliers by commercial name only across paginated directory windows, report exact coverage and " +
|
|
1389
|
+
"uninspected record counts, distinguish complete single matches from partial-scan single matches or ambiguous matches, " +
|
|
1390
|
+
"and optionally include recent expenses recorded for matched suppliers. Uses server-side field projection so government " +
|
|
1391
|
+
"tax identifiers and street addresses are never queried or serialized. " +
|
|
1392
|
+
"/ Busca proveedores exclusivamente por nombre comercial en paginas sucesivas del directorio, informa la cobertura exacta " +
|
|
1393
|
+
"y registros no inspeccionados, distingue coincidencias unicas completas de muestras parciales o ambiguas, y muestra gastos " +
|
|
1394
|
+
"recientes sin consultar ni exponer identificadores fiscales ni direcciones.",
|
|
1395
|
+
annotations: READ_ONLY_ANNOTATIONS,
|
|
1396
|
+
inputSchema: {
|
|
1397
|
+
query: z
|
|
1398
|
+
.string()
|
|
1399
|
+
.trim()
|
|
1400
|
+
.min(1)
|
|
1401
|
+
.max(100)
|
|
1402
|
+
.describe(`Commercial vendor name to search for (1-100 chars) / Nombre comercial del proveedor — ${OPENAI_REVIEW_FREE_TEXT_WARNING}`),
|
|
1403
|
+
includeRecentExpenses: z
|
|
1404
|
+
.boolean()
|
|
1405
|
+
.optional()
|
|
1406
|
+
.describe("When true (default) and 1-3 vendors match, includes recent matching expenses / Incluir gastos recientes"),
|
|
1407
|
+
limit: z
|
|
1408
|
+
.number()
|
|
1409
|
+
.int()
|
|
1410
|
+
.min(1)
|
|
1411
|
+
.max(20)
|
|
1412
|
+
.optional()
|
|
1413
|
+
.describe("Maximum matches to return (1-20, default 10) / Maximo de coincidencias"),
|
|
1414
|
+
maxPages: z
|
|
1415
|
+
.number()
|
|
1416
|
+
.int()
|
|
1417
|
+
.min(1)
|
|
1418
|
+
.max(5)
|
|
1419
|
+
.optional()
|
|
1420
|
+
.describe("Maximum directory pages (100 records/page) to scan in this call (1-5, default 3 = 300 records) / Paginas maximas a escanear (1-5, por defecto 3)"),
|
|
1421
|
+
after: z
|
|
1422
|
+
.string()
|
|
1423
|
+
.trim()
|
|
1424
|
+
.min(1)
|
|
1425
|
+
.max(256)
|
|
1426
|
+
.optional()
|
|
1427
|
+
.describe("Optional continuation token from a previous coverage.nextCursor to scan subsequent directory pages / Cursor de continuacion coverage.nextCursor"),
|
|
1428
|
+
},
|
|
1429
|
+
outputSchema: searchVendorsOutputSchema,
|
|
1430
|
+
}, async ({ query, includeRecentExpenses, limit, maxPages, after: initialAfter }) => withToolLogging("search_vendors", async () => {
|
|
1431
|
+
const cleanQuery = query.trim();
|
|
1432
|
+
if (isLikelyGovernmentIdQuery(cleanQuery)) {
|
|
1433
|
+
return {
|
|
1434
|
+
content: [{
|
|
1435
|
+
type: "text",
|
|
1436
|
+
text: "Error: search_vendors searches by commercial name only. Government tax identifiers (NIF/NIE/CIF/VAT) are not accepted on the reviewed ChatGPT surface.",
|
|
1437
|
+
}],
|
|
1438
|
+
isError: true,
|
|
1439
|
+
};
|
|
1440
|
+
}
|
|
1441
|
+
const maxMatches = Math.min(limit ?? 10, 20);
|
|
1442
|
+
const pageSize = 100;
|
|
1443
|
+
const pagesToScan = Math.min(Math.max(maxPages ?? 3, 1), 5);
|
|
1444
|
+
let currentCursor;
|
|
1445
|
+
let startOffset = 0;
|
|
1446
|
+
if (typeof initialAfter === "string" && initialAfter.trim()) {
|
|
1447
|
+
const trimmedAfter = initialAfter.trim();
|
|
1448
|
+
const offsetMatch = /^offset:(\d+)$/.exec(trimmedAfter);
|
|
1449
|
+
if (offsetMatch) {
|
|
1450
|
+
startOffset = Number(offsetMatch[1]);
|
|
1451
|
+
}
|
|
1452
|
+
else {
|
|
1453
|
+
currentCursor = trimmedAfter;
|
|
1454
|
+
const trailingDigits = /(?:^|[_:])(\d+)$/.exec(trimmedAfter);
|
|
1455
|
+
if (trailingDigits) {
|
|
1456
|
+
startOffset = Number(trailingDigits[1]);
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
let currentOffset = startOffset;
|
|
1461
|
+
let inspected = 0;
|
|
1462
|
+
let total = 0;
|
|
1463
|
+
let reachedEndOfDirectory = false;
|
|
1464
|
+
let lastNextCursor = null;
|
|
1465
|
+
let directoryPagesFetched = 0;
|
|
1466
|
+
let contextRequestsMade = 0;
|
|
1467
|
+
let approximatePayloadBytes = 0;
|
|
1468
|
+
const scored = [];
|
|
1469
|
+
const seenIds = new Set();
|
|
1470
|
+
// Do NOT pass `q` to `client.listVendors` because backend `q` searches `taxId` / `searchTokens`.
|
|
1471
|
+
// Pass `fields: VENDOR_SEARCH_SERVER_PROJECTION` so the backend strips taxId/address/iban before serialization.
|
|
1472
|
+
for (let page = 0; page < pagesToScan; page++) {
|
|
1473
|
+
const reqParams = {
|
|
1474
|
+
limit: pageSize,
|
|
1475
|
+
fields: VENDOR_SEARCH_SERVER_PROJECTION,
|
|
1476
|
+
};
|
|
1477
|
+
if (currentCursor !== undefined) {
|
|
1478
|
+
reqParams.after = currentCursor;
|
|
1479
|
+
}
|
|
1480
|
+
else if (currentOffset > 0) {
|
|
1481
|
+
reqParams.offset = currentOffset;
|
|
1482
|
+
}
|
|
1483
|
+
const res = await client.listVendors(reqParams);
|
|
1484
|
+
directoryPagesFetched++;
|
|
1485
|
+
approximatePayloadBytes += approxJsonBytes(res);
|
|
1486
|
+
const rows = Array.isArray(res?.data) ? res.data : [];
|
|
1487
|
+
if (page === 0) {
|
|
1488
|
+
if (typeof res?.offset === "number") {
|
|
1489
|
+
startOffset = res.offset;
|
|
1490
|
+
}
|
|
1491
|
+
total = typeof res?.total === "number" ? res.total : startOffset + rows.length;
|
|
1492
|
+
}
|
|
1493
|
+
inspected += rows.length;
|
|
1494
|
+
for (const row of rows) {
|
|
1495
|
+
if (!row || typeof row !== "object")
|
|
1496
|
+
continue;
|
|
1497
|
+
const rec = row;
|
|
1498
|
+
const recId = typeof rec.id === "string" ? rec.id : "";
|
|
1499
|
+
if (recId && seenIds.has(recId))
|
|
1500
|
+
continue;
|
|
1501
|
+
if (recId)
|
|
1502
|
+
seenIds.add(recId);
|
|
1503
|
+
const m = matchCommercialName(rec.name, cleanQuery);
|
|
1504
|
+
if (m.matched && m.rank) {
|
|
1505
|
+
scored.push({ record: rec, rank: m.rank, score: m.score });
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
const nextCursorFromRes = typeof res?.nextCursor === "string" && res.nextCursor ? res.nextCursor : undefined;
|
|
1509
|
+
const cumulativePosition = startOffset + inspected;
|
|
1510
|
+
const moreByTotal = total > cumulativePosition;
|
|
1511
|
+
const hasMore = Boolean(nextCursorFromRes) || (rows.length >= pageSize && moreByTotal);
|
|
1512
|
+
if (!hasMore || rows.length === 0) {
|
|
1513
|
+
reachedEndOfDirectory = !nextCursorFromRes && cumulativePosition >= total;
|
|
1514
|
+
lastNextCursor = nextCursorFromRes ?? (reachedEndOfDirectory ? null : `offset:${cumulativePosition}`);
|
|
1515
|
+
break;
|
|
1516
|
+
}
|
|
1517
|
+
if (nextCursorFromRes) {
|
|
1518
|
+
currentCursor = nextCursorFromRes;
|
|
1519
|
+
lastNextCursor = nextCursorFromRes;
|
|
1520
|
+
}
|
|
1521
|
+
else {
|
|
1522
|
+
currentCursor = undefined;
|
|
1523
|
+
currentOffset = cumulativePosition;
|
|
1524
|
+
lastNextCursor = `offset:${cumulativePosition}`;
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
const complete = startOffset === 0 && reachedEndOfDirectory && inspected >= total && lastNextCursor === null;
|
|
1528
|
+
const uninspectedCount = complete ? 0 : Math.max(1, total - inspected);
|
|
1529
|
+
const nextCursor = reachedEndOfDirectory ? null : lastNextCursor;
|
|
1530
|
+
scored.sort((a, b) => b.score - a.score || String(a.record.name ?? "").localeCompare(String(b.record.name ?? "")));
|
|
1531
|
+
const effectiveLimit = scored.length >= 2 ? Math.min(20, Math.max(maxMatches, 2)) : maxMatches;
|
|
1532
|
+
const top = scored.slice(0, effectiveLimit);
|
|
1533
|
+
const exactCount = scored.filter((x) => x.rank === "exact").length;
|
|
1534
|
+
const matchStatus = resolveMatchStatusWithCoverageTruth(scored.length, exactCount, complete);
|
|
1535
|
+
const shouldFetchExpenses = (includeRecentExpenses ?? true) && top.length >= 1 && top.length <= 3;
|
|
1536
|
+
let recentExpensePool = [];
|
|
1537
|
+
let fallbackExpenseCurrency = "EUR";
|
|
1538
|
+
if (shouldFetchExpenses) {
|
|
1539
|
+
const expRes = await client.listExpenses({ limit: 50, offset: 0 }).catch(() => ({ data: [] }));
|
|
1540
|
+
contextRequestsMade++;
|
|
1541
|
+
approximatePayloadBytes += approxJsonBytes(expRes);
|
|
1542
|
+
recentExpensePool = Array.isArray(expRes?.data) ? expRes.data : [];
|
|
1543
|
+
const anyMissingCur = recentExpensePool.some((exp) => typeof exp.currency !== "string" || !exp.currency.trim());
|
|
1544
|
+
if (anyMissingCur) {
|
|
1545
|
+
try {
|
|
1546
|
+
const ctx = await client.getBusinessContext();
|
|
1547
|
+
contextRequestsMade++;
|
|
1548
|
+
approximatePayloadBytes += approxJsonBytes(ctx);
|
|
1549
|
+
const cur = ctx?.defaults?.currency;
|
|
1550
|
+
if (typeof cur === "string" && cur.trim()) {
|
|
1551
|
+
fallbackExpenseCurrency = cur.trim().toUpperCase();
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
catch {
|
|
1555
|
+
// Keep EUR fallback
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
const matches = top.map((item) => {
|
|
1560
|
+
const id = String(item.record.id ?? "");
|
|
1561
|
+
const name = String(item.record.name ?? "");
|
|
1562
|
+
const normTargetVendorName = normalizeSearchText(name);
|
|
1563
|
+
const vendorExpenses = shouldFetchExpenses
|
|
1564
|
+
? recentExpensePool
|
|
1565
|
+
.filter((exp) => {
|
|
1566
|
+
// F-05: Strict entity isolation on vendorId vs exact normalized vendor name
|
|
1567
|
+
if (typeof exp.vendorId === "string" && exp.vendorId.trim()) {
|
|
1568
|
+
return exp.vendorId.trim() === id;
|
|
1569
|
+
}
|
|
1570
|
+
return typeof exp.vendor === "string" && normalizeSearchText(exp.vendor) === normTargetVendorName;
|
|
1571
|
+
})
|
|
1572
|
+
.slice(0, 5)
|
|
1573
|
+
.map((exp) => ({
|
|
1574
|
+
id: String(exp.id ?? ""),
|
|
1575
|
+
description: typeof exp.description === "string" ? exp.description : "",
|
|
1576
|
+
date: typeof exp.date === "string" ? exp.date : null,
|
|
1577
|
+
category: typeof exp.category === "string" ? exp.category : null,
|
|
1578
|
+
taxDeductible: typeof exp.taxDeductible === "boolean" ? exp.taxDeductible : null,
|
|
1579
|
+
amount: serializeProvenancedAmount(resolveDocumentAmount(exp, fallbackExpenseCurrency)),
|
|
1580
|
+
}))
|
|
1581
|
+
: undefined;
|
|
1582
|
+
return {
|
|
1583
|
+
id,
|
|
1584
|
+
name,
|
|
1585
|
+
...(typeof item.record.email === "string" ? { email: item.record.email } : {}),
|
|
1586
|
+
...(typeof item.record.phone === "string" ? { phone: item.record.phone } : {}),
|
|
1587
|
+
matchRank: item.rank,
|
|
1588
|
+
...(vendorExpenses ? { recentExpenses: vendorExpenses } : {}),
|
|
1589
|
+
};
|
|
1590
|
+
});
|
|
1591
|
+
const disambiguationNote = matchStatus === "none"
|
|
1592
|
+
? `No vendors matched '${cleanQuery}' across the complete workspace directory (${inspected}/${total} records inspected).`
|
|
1593
|
+
: matchStatus === "partial_scan_no_match"
|
|
1594
|
+
? `No vendors matched '${cleanQuery}' in the inspected directory window (${inspected} of ${total} records inspected; ${uninspectedCount} records remain uninspected). ${nextCursor ? `Continue scanning with after='${nextCursor}' or increase maxPages` : "Scan from offset 0 with higher maxPages"} before concluding the vendor does not exist.`
|
|
1595
|
+
: matchStatus === "partial_scan_single_match"
|
|
1596
|
+
? `Found 1 matching vendor ('${matches[0].name}', id=${matches[0].id}) in the inspected window (${inspected} of ${total} records), but ${uninspectedCount} directory records were not inspected in this call. Do not assume this is the only match in the workspace; confirm with the user${nextCursor ? ` or continue scanning with after='${nextCursor}'` : ""} before mutating records.`
|
|
1597
|
+
: matchStatus === "ambiguous"
|
|
1598
|
+
? `Found ${scored.length} vendors matching '${cleanQuery}'${complete ? "" : ` across ${inspected}/${total} inspected records (${uninspectedCount} uninspected)`} (showing ${matches.length} of ${scored.length} matches). Present all candidate IDs/names and ask the user which vendor they mean before mutating records.`
|
|
1599
|
+
: `Resolved a unique vendor match ('${matches[0].name}', id=${matches[0].id}) across the complete directory (${inspected}/${total} records inspected).`;
|
|
1600
|
+
const output = {
|
|
1601
|
+
query: cleanQuery,
|
|
1602
|
+
searchedFields: ["name"],
|
|
1603
|
+
matchStatus,
|
|
1604
|
+
disambiguationNote,
|
|
1605
|
+
coverage: {
|
|
1606
|
+
inspected,
|
|
1607
|
+
total,
|
|
1608
|
+
uninspectedCount,
|
|
1609
|
+
complete,
|
|
1610
|
+
nextCursor,
|
|
1611
|
+
telemetry: {
|
|
1612
|
+
directoryPagesFetched,
|
|
1613
|
+
contextRequestsMade,
|
|
1614
|
+
totalApiRequests: directoryPagesFetched + contextRequestsMade,
|
|
1615
|
+
recordsInspected: inspected,
|
|
1616
|
+
approximatePayloadBytes,
|
|
1617
|
+
serverProjectionFields: VENDOR_SEARCH_SERVER_PROJECTION,
|
|
1618
|
+
},
|
|
1619
|
+
},
|
|
1620
|
+
matches,
|
|
1621
|
+
};
|
|
1622
|
+
return {
|
|
1623
|
+
content: [getContent(formatRecord("Vendor Search", output))],
|
|
1624
|
+
structuredContent: output,
|
|
1625
|
+
};
|
|
1626
|
+
}));
|
|
1627
|
+
}
|
|
1628
|
+
//# sourceMappingURL=openai-expansion-103.js.map
|