Skip to content

Commit 73ced78

Browse files
committed
feat(statistics): L2 — server-side aggregation for SEARCH_QUERY (1.0.3)
Computed summary instead of raw rows: totals over 100% of rows (exact period total), top-N detail by metric (adaptive 95% cost cutoff), tail rollup, zero-click/zero-conversion counts, conversions>clicks anomaly — all field-aware. New params: sortBy/order/topN(cap 100)/minCost/queryContains/zeroClicksOnly/ zeroConversionsOnly. Pure aggregate module + 9 tests (89 pass).
1 parent 89bb8be commit 73ced78

5 files changed

Lines changed: 377 additions & 3 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "mcp-yandex-direct",
3-
"version": "1.0.2",
3+
"version": "1.0.3",
44
"description": "MCP server for the Yandex Direct API v5 — manage PPC campaigns, ad groups, ads, keywords and pull statistics from AI agents.",
55
"type": "module",
66
"bin": {

src/tools/handlers.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,27 @@ test("explicit campaign filter with 0 rows fails loud (L3)", async () => {
101101
assert.match(res.content[0].text, /0 rows/);
102102
});
103103

104+
test("SEARCH_QUERY returns a computed aggregate (L2), not raw rows", async () => {
105+
// Rows match DEFAULT SEARCH_QUERY fields: CampaignName, Query, Impr, Clicks, Cost, Ctr, AvgCpc.
106+
const tsv = ["EPK\tаудио в текст\t100\t20\t160.00\t20.0\t8.00", "EPK\tмусор\t40\t0\t0\t0\t0"].join(
107+
"\n",
108+
);
109+
const { tools } = harness(registerStatisticsTools, { reportResult: tsv });
110+
const res = await tools.get_statistics({ reportType: "SEARCH_QUERY_PERFORMANCE_REPORT" });
111+
const out = JSON.parse(res.content[0].text);
112+
assert.equal(out.aggregated, true);
113+
assert.equal(out.rowsTotal, 2);
114+
assert.equal(out.totals.Cost, 160);
115+
assert.equal(out.counts.zeroClick, 1);
116+
assert.equal(out.hasConversions, false); // Conversions not in default fields
117+
});
118+
119+
test("non-heavy report (ACCOUNT) still returns raw TSV", async () => {
120+
const { tools } = harness(registerStatisticsTools, { reportResult: "raw\ttsv" });
121+
const res = await tools.get_statistics({ reportType: "ACCOUNT_PERFORMANCE_REPORT" });
122+
assert.equal(res.content[0].text, "raw\ttsv");
123+
});
124+
104125
test("create_text_campaign applies the default strategy and converts the budget", async () => {
105126
const { calls, tools } = harness(registerCampaignTools, { callResult: { AddResults: [{ Id: 1 }] } });
106127
await tools.create_text_campaign({ name: "C", startDate: "2026-01-01", dailyBudgetAmount: 500 });

src/tools/statistics.aggregate.ts

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
/**
2+
* L2 — server-side aggregation for heavy statistics reports (SEARCH_QUERY, …).
3+
*
4+
* Pure functions, no network. Instead of returning thousands of raw TSV rows (which
5+
* the consumer then truncates to a sample), we compute the answer over 100% of rows
6+
* and return a compact, bounded summary:
7+
* - totals over ALL rows (the true period total — correctness for "сколько всего");
8+
* - a top-N detail list by a chosen metric (ranking — adaptive 95% cutoff for Cost);
9+
* - a tail rollup of everything not shown;
10+
* - zero-click / zero-conversion counts and a conversions>clicks anomaly slice.
11+
*
12+
* Field-aware: conversion-dependent numbers appear only when "Conversions" was among
13+
* the requested fieldNames. No server-side n-gram/bigram extraction (naive whitespace
14+
* tokenization breaks on Russian morphology — the model groups queries better).
15+
*/
16+
17+
const METRIC_FIELDS = new Set([
18+
"Impressions",
19+
"Clicks",
20+
"Cost",
21+
"Ctr",
22+
"AvgCpc",
23+
"Conversions",
24+
"ConversionRate",
25+
"CostPerConversion",
26+
"BounceRate",
27+
"AvgPageviews",
28+
]);
29+
30+
/** Metrics that are summable across rows (ratios like Ctr/AvgCpc are NOT summed). */
31+
const SUMMABLE = ["Impressions", "Clicks", "Cost", "Conversions"];
32+
/** Metrics shown in each detail row when present in the report. */
33+
const DISPLAY_METRICS = [
34+
"Impressions",
35+
"Clicks",
36+
"Cost",
37+
"Ctr",
38+
"AvgCpc",
39+
"Conversions",
40+
"ConversionRate",
41+
"CostPerConversion",
42+
"BounceRate",
43+
];
44+
45+
export const MAX_TOP_N = 100;
46+
const DEFAULT_TOP_N = 50;
47+
const COST_COVERAGE = 0.95; // adaptive cutoff: enough rows to cover 95% of detail Cost
48+
49+
export interface AggregateOptions {
50+
sortBy?: string;
51+
order?: "asc" | "desc";
52+
topN?: number;
53+
minCost?: number;
54+
queryContains?: string;
55+
zeroClicksOnly?: boolean;
56+
zeroConversionsOnly?: boolean;
57+
}
58+
59+
export interface ReportAggregate {
60+
reportType: string;
61+
aggregated: true;
62+
rowsTotal: number;
63+
rowsReturned: number;
64+
sortBy: string;
65+
order: "asc" | "desc";
66+
hasConversions: boolean;
67+
totals: Record<string, number>;
68+
counts: Record<string, number>;
69+
filtered?: Record<string, number>;
70+
top: Array<Record<string, number | string>>;
71+
tail: Record<string, number>;
72+
anomalies?: Array<Record<string, number | string>>;
73+
note: string;
74+
}
75+
76+
/** Parse a Yandex Reports cell into a number ("--" / "" → 0; tolerant of comma decimals). */
77+
function num(v: string | undefined): number {
78+
if (v == null) return 0;
79+
const s = String(v).trim();
80+
if (s === "" || s === "--") return 0;
81+
const n = parseFloat(s.replace(",", "."));
82+
return Number.isFinite(n) ? n : 0;
83+
}
84+
85+
interface ParsedRow {
86+
dims: Record<string, string>;
87+
m: Record<string, number>;
88+
}
89+
90+
/** Splits a (headerless) TSV body into rows keyed positionally by fieldNames. */
91+
export function parseRows(tsv: string, fieldNames: string[]): ParsedRow[] {
92+
const rows: ParsedRow[] = [];
93+
for (const line of tsv.split("\n")) {
94+
if (!line.trim()) continue;
95+
const cells = line.split("\t");
96+
const dims: Record<string, string> = {};
97+
const m: Record<string, number> = {};
98+
fieldNames.forEach((f, i) => {
99+
const cell = cells[i] ?? "";
100+
if (METRIC_FIELDS.has(f)) m[f] = num(cell);
101+
else dims[f] = cell;
102+
});
103+
rows.push({ dims, m });
104+
}
105+
return rows;
106+
}
107+
108+
function primaryDimension(fieldNames: string[]): string {
109+
for (const f of ["Query", "Criterion", "AdGroupName", "CampaignName"]) {
110+
if (fieldNames.includes(f)) return f;
111+
}
112+
return fieldNames.find((f) => !METRIC_FIELDS.has(f)) ?? "";
113+
}
114+
115+
function sumMetrics(rows: ParsedRow[], keys: string[]): Record<string, number> {
116+
const t: Record<string, number> = {};
117+
for (const k of keys) t[k] = 0;
118+
for (const r of rows) for (const k of keys) t[k] += r.m[k] ?? 0;
119+
// Round Cost to 2 decimals to avoid float dust.
120+
if ("Cost" in t) t.Cost = Math.round(t.Cost * 100) / 100;
121+
return t;
122+
}
123+
124+
export function aggregateReport(
125+
tsv: string,
126+
fieldNames: string[],
127+
reportType: string,
128+
opts: AggregateOptions = {},
129+
): ReportAggregate {
130+
const rows = parseRows(tsv, fieldNames);
131+
const hasConversions = fieldNames.includes("Conversions");
132+
const dimKey = primaryDimension(fieldNames);
133+
const sumKeys = SUMMABLE.filter((k) => fieldNames.includes(k));
134+
135+
// Totals over 100% of rows — the true period total (this is the correctness point).
136+
const totals = sumMetrics(rows, sumKeys);
137+
const counts: Record<string, number> = {
138+
zeroClick: rows.filter((r) => (r.m.Clicks ?? 0) === 0).length,
139+
};
140+
if (hasConversions) {
141+
counts.zeroConversion = rows.filter(
142+
(r) => (r.m.Clicks ?? 0) > 0 && (r.m.Conversions ?? 0) === 0,
143+
).length;
144+
}
145+
146+
// Filtered set drives the detail list (totals above stay over 100%).
147+
let detail = rows;
148+
if (opts.minCost != null) detail = detail.filter((r) => (r.m.Cost ?? 0) >= opts.minCost!);
149+
if (opts.queryContains) {
150+
const q = opts.queryContains.toLowerCase();
151+
detail = detail.filter((r) => (r.dims[dimKey] ?? "").toLowerCase().includes(q));
152+
}
153+
if (opts.zeroClicksOnly) detail = detail.filter((r) => (r.m.Clicks ?? 0) === 0);
154+
if (opts.zeroConversionsOnly && hasConversions) {
155+
detail = detail.filter((r) => (r.m.Clicks ?? 0) > 0 && (r.m.Conversions ?? 0) === 0);
156+
}
157+
158+
const sortBy = METRIC_FIELDS.has(opts.sortBy ?? "") ? (opts.sortBy as string) : "Cost";
159+
const order = opts.order === "asc" ? "asc" : "desc";
160+
const sorted = [...detail].sort((a, b) => {
161+
const d = (a.m[sortBy] ?? 0) - (b.m[sortBy] ?? 0);
162+
return order === "asc" ? d : -d;
163+
});
164+
165+
const cap = Math.min(Math.max(opts.topN ?? DEFAULT_TOP_N, 1), MAX_TOP_N);
166+
let take = Math.min(cap, sorted.length);
167+
// Adaptive: for Cost desc, stop once cumulative cost covers 95% of the detail set —
168+
// no point listing thousands of ~0-cost rows.
169+
if (sortBy === "Cost" && order === "desc") {
170+
const detailCost = sorted.reduce((s, r) => s + (r.m.Cost ?? 0), 0);
171+
if (detailCost > 0) {
172+
let cum = 0;
173+
let n = 0;
174+
for (const r of sorted) {
175+
cum += r.m.Cost ?? 0;
176+
n++;
177+
if (cum >= COST_COVERAGE * detailCost) break;
178+
}
179+
take = Math.min(cap, n);
180+
}
181+
}
182+
183+
const topRows = sorted.slice(0, take);
184+
const top = topRows.map((r) => {
185+
const o: Record<string, number | string> = { ...r.dims };
186+
for (const k of DISPLAY_METRICS) if (k in r.m) o[k] = r.m[k];
187+
return o;
188+
});
189+
190+
// tail rolls up the detail rows not shown, so top + tail reconstruct the detail set.
191+
const tailRows = sorted.slice(take);
192+
const tail = { rows: tailRows.length, ...sumMetrics(tailRows, sumKeys) };
193+
194+
const anomalies = hasConversions
195+
? rows
196+
.filter((r) => (r.m.Conversions ?? 0) > (r.m.Clicks ?? 0))
197+
.slice(0, 10)
198+
.map((r) => ({
199+
[dimKey]: r.dims[dimKey] ?? "",
200+
clicks: r.m.Clicks ?? 0,
201+
conversions: r.m.Conversions ?? 0,
202+
reason: "conversions>clicks",
203+
}))
204+
: [];
205+
206+
const filtered =
207+
detail.length !== rows.length
208+
? { rows: detail.length, ...sumMetrics(detail, sumKeys) }
209+
: undefined;
210+
211+
return {
212+
reportType,
213+
aggregated: true,
214+
rowsTotal: rows.length,
215+
rowsReturned: top.length,
216+
sortBy,
217+
order,
218+
hasConversions,
219+
totals, // over 100% of rows — the full-period total
220+
counts,
221+
...(filtered ? { filtered } : {}),
222+
top,
223+
tail,
224+
...(anomalies.length ? { anomalies } : {}),
225+
note:
226+
`totals are over all ${rows.length} row(s) (the full period, not a sample); ` +
227+
`top lists ${top.length} row(s) by ${sortBy} ${order}` +
228+
(filtered ? ` from ${detail.length} filtered row(s)` : "") +
229+
"; tail rolls up the rest.",
230+
};
231+
}

src/tools/statistics.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,17 @@
11
import { test } from "node:test";
22
import assert from "node:assert/strict";
33
import { DEFAULT_FIELDS_BY_TYPE, REPORT_TYPES } from "./statistics.js";
4+
import { aggregateReport } from "./statistics.aggregate.js";
5+
6+
const SQ_FIELDS = ["Query", "Impressions", "Clicks", "Cost", "Conversions"];
7+
// аудио: conv(26) > clicks(20) → anomaly; mp3: clicks>0 & 0 conv → zeroConversion;
8+
// мусор: 0 clicks → zeroClick.
9+
const SQ_TSV = [
10+
"аудио в текст\t100\t20\t160.00\t26",
11+
"whisper\t50\t10\t90.00\t8",
12+
"mp3 в текст\t30\t3\t27.00\t0",
13+
"мусор\t40\t0\t0\t0",
14+
].join("\n");
415

516
test("every report type has a default field set", () => {
617
for (const type of REPORT_TYPES) {
@@ -35,3 +46,59 @@ test("SEARCH_QUERY default is the period aggregate: CampaignName + Query + metri
3546
"AvgCpc",
3647
]);
3748
});
49+
50+
// ---- L2 aggregation ----
51+
52+
test("aggregate: totals are over 100% of rows (true period total)", () => {
53+
const a = aggregateReport(SQ_TSV, SQ_FIELDS, "SEARCH_QUERY_PERFORMANCE_REPORT");
54+
assert.equal(a.rowsTotal, 4);
55+
assert.equal(a.totals.Impressions, 220);
56+
assert.equal(a.totals.Clicks, 33);
57+
assert.equal(a.totals.Cost, 277);
58+
assert.equal(a.totals.Conversions, 34);
59+
assert.equal(a.counts.zeroClick, 1); // мусор
60+
assert.equal(a.counts.zeroConversion, 1); // mp3 в текст
61+
});
62+
63+
test("aggregate: top sorted by Cost desc with 95% adaptive cutoff; tail = rest", () => {
64+
const a = aggregateReport(SQ_TSV, SQ_FIELDS, "SEARCH_QUERY_PERFORMANCE_REPORT");
65+
// cumulative cost reaches 95% at row 3 (160+90+27 = 277 of 277) → 3 shown, 1 in tail.
66+
assert.equal(a.top.length, 3);
67+
assert.equal(a.top[0].Query, "аудио в текст");
68+
assert.equal(a.tail.rows, 1);
69+
// top + tail reconstruct the full set.
70+
const topCost = a.top.reduce((s, r) => s + Number(r.Cost), 0);
71+
assert.equal(Math.round((topCost + a.tail.Cost) * 100) / 100, a.totals.Cost);
72+
});
73+
74+
test("aggregate: conversions>clicks anomaly is flagged", () => {
75+
const a = aggregateReport(SQ_TSV, SQ_FIELDS, "SEARCH_QUERY_PERFORMANCE_REPORT");
76+
assert.equal(a.anomalies?.length, 1);
77+
assert.equal(a.anomalies?.[0].Query, "аудио в текст");
78+
});
79+
80+
test("aggregate: field-aware — no Conversions in fieldNames omits conversion data", () => {
81+
const fields = ["Query", "Impressions", "Clicks", "Cost"];
82+
const tsv = SQ_TSV.split("\n").map((l) => l.split("\t").slice(0, 4).join("\t")).join("\n");
83+
const a = aggregateReport(tsv, fields, "SEARCH_QUERY_PERFORMANCE_REPORT");
84+
assert.equal(a.hasConversions, false);
85+
assert.equal(a.counts.zeroConversion, undefined);
86+
assert.equal(a.anomalies, undefined);
87+
assert.equal("Conversions" in a.totals, false);
88+
assert.equal("Conversions" in a.top[0], false);
89+
});
90+
91+
test("aggregate: filters drive the detail list; totals stay over 100%", () => {
92+
const a = aggregateReport(SQ_TSV, SQ_FIELDS, "SEARCH_QUERY_PERFORMANCE_REPORT", { minCost: 50 });
93+
assert.equal(a.totals.Cost, 277); // unchanged — totals are over all rows
94+
assert.equal(a.filtered?.rows, 2); // only аудио + whisper survive minCost:50
95+
assert.ok(a.top.every((r) => Number(r.Cost) >= 50));
96+
});
97+
98+
test("aggregate: zeroClicksOnly filter", () => {
99+
const a = aggregateReport(SQ_TSV, SQ_FIELDS, "SEARCH_QUERY_PERFORMANCE_REPORT", {
100+
zeroClicksOnly: true,
101+
});
102+
assert.equal(a.filtered?.rows, 1);
103+
assert.equal(a.top[0].Query, "мусор");
104+
});

0 commit comments

Comments
 (0)