compare_periodos
Compare two date ranges side-by-side over the same filters. Returns each period's total metrics plus absolute and percentage deltas.
Instructions
Compare two date ranges side-by-side over the same filters — answers questions like "did Jun/2024 (electoral year) differ from Jun/2025 in bid volumes?".
Wraps two aggregate_licitacoes_por_periodo calls and returns each period's total metrics plus absolute and percentage deltas. Use granularidade-style buckets implicitly = "ano" for the comparison (one bucket per period, summed).
When esfera filter or value metrics are requested, the underlying tool paginates internally — be conservative with range size.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| periodoA | Yes | ||
| periodoB | Yes | ||
| modalidades | No | Modality codes. Default: [6, 8, 9]. | |
| uf | No | Two-letter state code. | |
| codigoMunicipioIbge | No | ||
| cnpjOrgao | No | Procuring agency CNPJ. | |
| esfera | No | Filter by sphere. | |
| metricas | No |
Implementation Reference
- src/tools/compare_periodos.ts:105-184 (handler)The main handler function for the compare_periodos tool. It parses args, calls aggregateLicitacoes.handler for both periods A and B in parallel, then computes absolute and percentage deltas between the two periods' metric totals.
async handler(rawArgs) { const parse = ArgsSchema.safeParse(rawArgs ?? {}); if (!parse.success) return errorResult(`Invalid arguments: ${parse.error.message}`); const args = parse.data; const baseArgs = { modalidades: args.modalidades, uf: args.uf, codigoMunicipioIbge: args.codigoMunicipioIbge, cnpjOrgao: args.cnpjOrgao, esfera: args.esfera, metricas: args.metricas, granularidade: 'ano' as const, }; try { const [respA, respB] = await Promise.all([ aggregateLicitacoes.handler({ ...baseArgs, dataInicial: args.periodoA.dataInicial, dataFinal: args.periodoA.dataFinal, }), aggregateLicitacoes.handler({ ...baseArgs, dataInicial: args.periodoB.dataInicial, dataFinal: args.periodoB.dataFinal, }), ]); if (respA.isError) return respA; if (respB.isError) return respB; const firstTextA = respA.content.find((c): c is { type: 'text'; text: string } => c.type === 'text'); const firstTextB = respB.content.find((c): c is { type: 'text'; text: string } => c.type === 'text'); if (!firstTextA || !firstTextB) { return errorResult('aggregate_licitacoes returned non-text content'); } const dataA = JSON.parse(firstTextA.text) as AggregateResult; const dataB = JSON.parse(firstTextB.text) as AggregateResult; const totalsA = sumMetrics(dataA.series, args.metricas); const totalsB = sumMetrics(dataB.series, args.metricas); const delta: Record<string, { absoluto: number; percentual: number | null }> = {}; for (const m of args.metricas) { const a = totalsA[m]; const b = totalsB[m]; const absoluto = b - a; const percentual = a === 0 ? null : (absoluto / a) * 100; delta[m] = { absoluto, percentual }; } return jsonResult({ meta: { modalidades: dataA.meta.modalidades, uf: args.uf, codigoMunicipioIbge: args.codigoMunicipioIbge, cnpjOrgao: args.cnpjOrgao, esfera: args.esfera, metricas: args.metricas, }, periodoA: { label: args.periodoA.label, dataInicial: args.periodoA.dataInicial, dataFinal: args.periodoA.dataFinal, totals: totalsA, }, periodoB: { label: args.periodoB.label, dataInicial: args.periodoB.dataInicial, dataFinal: args.periodoB.dataFinal, totals: totalsB, }, delta, }); } catch (err) { return errorResult(`Failed to compare periodos: ${err instanceof Error ? err.message : String(err)}`); } }, }; - src/tools/compare_periodos.ts:15-29 (schema)Zod schema for the tool's input: periodoA, periodoB (each with label, dataInicial, dataFinal), optional modalidades, uf, codigoMunicipioIbge, cnpjOrgao, esfera, and metricas (count, valorEstimadoTotal, valorHomologadoTotal).
const ArgsSchema = z.object({ periodoA: PeriodoSchema, periodoB: PeriodoSchema, modalidades: z.array(z.number().int()).optional(), uf: z.string().length(2).toUpperCase().optional(), codigoMunicipioIbge: z.string().optional(), cnpjOrgao: z .string() .regex(/^\d{14}$/, 'CNPJ must be 14 digits, no punctuation') .optional(), esfera: EsferaSchema.optional(), metricas: z .array(z.enum(['count', 'valorEstimadoTotal', 'valorHomologadoTotal'])) .default(['count']), }); - src/tools/index.ts:19-47 (registration)Import and registration of comparePeriodos in the allTools array, making it available in the toolMap.
import { comparePeriodos } from './compare_periodos.js'; export const allTools: ToolDef[] = [ // Compras / Licitações searchLicitacoes, getLicitacao, listLicitacaoItens, listLicitacaoResultados, listLicitacaoArquivos, // Contratos searchContratos, getContratoTool, listContratoTermosTool, listContratoInstrumentosTool, // Atas RP searchAtasRp, getAtaRp, // Órgãos / Fornecedores getOrgaoTool, getFornecedorContratos, // PCA searchPca, listPcaItensTool, // CNPJ enrichment getCnpjDataTool, // Análise agregada (v0.2.0) aggregateLicitacoes, comparePeriodos, ]; - src/tools/compare_periodos.ts:51-103 (schema)Tool definition with name 'compare_periodos', description, and JSON Schema input schema (properties for periodoA, periodoB, modalidades, uf, codigoMunicipioIbge, cnpjOrgao, esfera, metricas).
export const comparePeriodos: ToolDef = { definition: { name: 'compare_periodos', description: [ 'Compare two date ranges side-by-side over the same filters — answers questions like "did Jun/2024 (electoral year) differ from Jun/2025 in bid volumes?".', '', 'Wraps two `aggregate_licitacoes_por_periodo` calls and returns each period\'s total metrics plus absolute and percentage deltas. Use granularidade-style buckets implicitly = "ano" for the comparison (one bucket per period, summed).', '', 'When `esfera` filter or value metrics are requested, the underlying tool paginates internally — be conservative with range size.', ].join('\n'), inputSchema: { type: 'object', required: ['periodoA', 'periodoB'], properties: { periodoA: { type: 'object', required: ['label', 'dataInicial', 'dataFinal'], properties: { label: { type: 'string', description: 'Friendly label, e.g. "Jun/2024"' }, dataInicial: { type: 'string', description: 'YYYYMMDD' }, dataFinal: { type: 'string', description: 'YYYYMMDD' }, }, }, periodoB: { type: 'object', required: ['label', 'dataInicial', 'dataFinal'], properties: { label: { type: 'string', description: 'Friendly label, e.g. "Jun/2025"' }, dataInicial: { type: 'string', description: 'YYYYMMDD' }, dataFinal: { type: 'string', description: 'YYYYMMDD' }, }, }, modalidades: { type: 'array', items: { type: 'integer', enum: MODALIDADE_IDS }, description: 'Modality codes. Default: [6, 8, 9].', }, uf: { type: 'string', description: 'Two-letter state code.' }, codigoMunicipioIbge: { type: 'string' }, cnpjOrgao: { type: 'string', description: 'Procuring agency CNPJ.' }, esfera: { type: 'string', enum: [...ESFERA_VALUES], description: "Filter by sphere.", }, metricas: { type: 'array', items: { type: 'string', enum: ['count', 'valorEstimadoTotal', 'valorHomologadoTotal'] }, default: ['count'], }, }, }, }, - src/tools/compare_periodos.ts:36-49 (helper)Helper function sumMetrics that sums numeric metric values across a series array (used to aggregate totals from the aggregate_licitacoes results).
function sumMetrics( series: Array<Record<string, number | string>>, metricas: string[], ): Record<string, number> { const sums: Record<string, number> = {}; for (const m of metricas) sums[m] = 0; for (const row of series) { for (const m of metricas) { const v = row[m]; if (typeof v === 'number') sums[m] += v; } } return sums; }