diff --git a/apps/eval/data/tasks.json b/apps/eval/data/tasks.json
new file mode 100644
index 0000000..8822172
--- /dev/null
+++ b/apps/eval/data/tasks.json
@@ -0,0 +1,107 @@
+[
+ {
+ "id": "t1",
+ "question": "На столе лежало 3 яблока. Ты взял 2 яблока. Сколько яблок осталось на столе? Ответь одним числом.",
+ "answer": "1"
+ },
+ {
+ "id": "t2",
+ "question": "Фермер имеет 17 кур. Все, кроме 9, умерли. Сколько кур осталось живыми? Ответь одним числом.",
+ "answer": "9"
+ },
+ {
+ "id": "t3",
+ "question": "Сколько месяцев в году имеют 28 дней? Ответь одним числом.",
+ "answer": "12"
+ },
+ {
+ "id": "t4",
+ "question": "Карандаш и ручка вместе стоят 1 рубль 10 копеек. Ручка стоит на 1 рубль дороже карандаша. Сколько стоит карандаш? Ответь числом в копейках.",
+ "answer": "5"
+ },
+ {
+ "id": "t5",
+ "question": "Если 5 машин за 5 минут делают 5 деталей, сколько деталей сделают 100 машин за 100 минут? Ответь одним числом.",
+ "answer": "2000"
+ },
+ {
+ "id": "t6",
+ "question": "В озере растут кувшинки. Каждый день их количество удваивается. Пруд полностью покрывается за 48 дней. За сколько дней покрывается половина пруда? Ответь одним числом.",
+ "answer": "47"
+ },
+ {
+ "id": "t7",
+ "question": "Числа от 1 до 9 включительно: сколько из них содержат букву «и» в русском названии? Ответь одним числом.",
+ "answer": "2"
+ },
+ {
+ "id": "t8",
+ "question": "У тебя список из 12 чисел. Если удалить каждое второе число в списке, сколько чисел останется? Ответь одним числом.",
+ "answer": "6"
+ },
+ {
+ "id": "t9",
+ "question": "Монетку подбросили 3 раза. Какова вероятность, что выпадет орёл все 3 раза? Ответь обыкновенной дробью.",
+ "answer": "1/8"
+ },
+ {
+ "id": "t10",
+ "question": "У меня есть 10 рублей. Я потратил 3.50 на хлеб и 1.50 на молоко. Сколько сдачи осталось? Ответь числом в рублях.",
+ "answer": "5"
+ },
+ {
+ "id": "t11",
+ "question": "Поезд длиной 100 метров движется со скоростью 36 км/ч. За сколько секунд он полностью проедет мимо столба? Ответь одним числом.",
+ "answer": "10"
+ },
+ {
+ "id": "t12",
+ "question": "Если число увеличить на 30% и получить 78, чему было исходное число? Ответь одним числом.",
+ "answer": "60"
+ },
+ {
+ "id": "t13",
+ "question": "В комнате 4 угла. В каждом углу сидит кошка. Напротив каждой кошки сидят 3 кошки. Сколько всего кошек в комнате? Ответь одним числом.",
+ "answer": "4"
+ },
+ {
+ "id": "t14",
+ "question": "Периметр квадрата 28 см. Чему равна его площадь в квадратных сантиметрах? Ответь одним числом.",
+ "answer": "49"
+ },
+ {
+ "id": "t15",
+ "question": "Лена вдвое старше Миши. Сумма их возрастов 36 лет. Сколько лет Мише? Ответь одним числом.",
+ "answer": "12"
+ },
+ {
+ "id": "t16",
+ "question": "В шкафу 10 белых и 10 чёрных носков вперемешку. Сколько носков надо достать вслепую, чтобы гарантированно получить пару одного цвета? Ответь одним числом.",
+ "answer": "3"
+ },
+ {
+ "id": "t17",
+ "question": "Если 3 курицы несут 3 яйца за 3 дня, сколько яиц снесут 6 куриц за 6 дней? Ответь одним числом.",
+ "answer": "12"
+ },
+ {
+ "id": "t18",
+ "question": "Восемь минус четыре, делённое на два (8 - 4/2). Чему равно выражение? Ответь одним числом.",
+ "answer": "6"
+ },
+ {
+ "id": "t19",
+ "question": "На столе 7 свечей. 3 потухли. Сколько свечей осталось на столе? Ответь одним числом.",
+ "answer": "7"
+ },
+ {
+ "id": "t20",
+ "question": "У Вити 5 машинок, у Кати в 3 раза больше. Потом Катя подарила Вите столько, сколько у него было изначально. Сколько машинок стало у Кати? Ответь одним числом.",
+ "answer": "10"
+ },
+ {
+ "id": "t21",
+ "question": "Сумма трёх последовательных нечётных чисел равна 27. Чему равно наибольшее из них? Ответь одним числом.",
+ "answer": "11"
+ }
+]
diff --git a/apps/eval/lib/mcpClient.mjs b/apps/eval/lib/mcpClient.mjs
new file mode 100644
index 0000000..29adbdf
--- /dev/null
+++ b/apps/eval/lib/mcpClient.mjs
@@ -0,0 +1,56 @@
+const ACCEPT = "application/json, text/event-stream";
+
+function parseMcpResponse(text) {
+ const trimmed = text.trim();
+ if (!trimmed) throw new Error("Empty MCP response");
+ if (trimmed.startsWith("{")) {
+ return JSON.parse(trimmed);
+ }
+ if (trimmed.includes("event:") || trimmed.includes("data:")) {
+ let payload = "";
+ for (const line of trimmed.split(/\r?\n/)) {
+ if (line.startsWith("data:")) payload += line.slice(5).trim();
+ }
+ if (payload.startsWith("{")) return JSON.parse(payload);
+ }
+ throw new Error(`Unrecognized MCP response format: ${trimmed.slice(0, 200)}`);
+}
+
+export class McpClient {
+ constructor(url) {
+ this.url = url;
+ this.seq = 0;
+ }
+
+ async request(method, params = {}) {
+ this.seq += 1;
+ const res = await fetch(this.url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", Accept: ACCEPT },
+ body: JSON.stringify({ jsonrpc: "2.0", id: this.seq, method, params }),
+ });
+ if (!res.ok) {
+ throw new Error(`MCP HTTP ${res.status}: ${(await res.text()).slice(0, 300)}`);
+ }
+ const rpc = parseMcpResponse(await res.text());
+ if (rpc.error) throw new Error(`MCP RPC error ${rpc.error.code}: ${rpc.error.message}`);
+ return rpc;
+ }
+
+ async listTools() {
+ const rpc = await this.request("tools/list");
+ return rpc.result?.tools ?? [];
+ }
+
+ async callTool(name, args = {}) {
+ const rpc = await this.request("tools/call", { name, arguments: args });
+ return rpc.result;
+ }
+}
+
+export function textContentFrom(result) {
+ if (!result?.content) return "";
+ return result.content
+ .map((b) => (b.type === "text" ? b.text : JSON.stringify(b)))
+ .join(" ");
+}
diff --git a/apps/eval/lib/ollama.mjs b/apps/eval/lib/ollama.mjs
new file mode 100644
index 0000000..ddd393e
--- /dev/null
+++ b/apps/eval/lib/ollama.mjs
@@ -0,0 +1,55 @@
+const DEFAULT_URL = "http://localhost:11434";
+
+export class Ollama {
+ constructor({ url = DEFAULT_URL } = {}) {
+ this.url = url.replace(/\/$/, "");
+ }
+
+ async ping() {
+ if (process.env.OLLAMA_SKIP_PING === "1") return { ok: true, model: process.env.OLLAMA_MODEL };
+ const res = await fetch(`${this.url}/api/tags`);
+ if (!res.ok) throw new Error(`Ollama not reachable (HTTP ${res.status}) at ${this.url}`);
+ const data = await res.json();
+ const models = (data.models ?? []).map((m) => m.name);
+ return { ok: true, models };
+ }
+
+ async chat({ model, messages, tools, temperature = 0, numCtx = 8192 }) {
+ const body = {
+ model,
+ messages,
+ stream: false,
+ options: { temperature },
+ };
+ if (numCtx) body.options.num_ctx = numCtx;
+ if (tools && tools.length) body.tools = tools;
+
+ const res = await fetch(`${this.url}/api/chat`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ const t = await res.text();
+ throw new Error(`Ollama chat HTTP ${res.status}: ${t.slice(0, 300)}`);
+ }
+ const data = await res.json();
+ const message = data.message ?? {};
+ return {
+ role: message.role ?? "assistant",
+ content: message.content ?? "",
+ toolCalls: message.tool_calls ?? [],
+ promptEvalCount: data.prompt_eval_count ?? 0,
+ evalCount: data.eval_count ?? 0,
+ raw: data,
+ };
+ }
+}
+
+export async function listModels() {
+ const o = new Ollama();
+ const res = await fetch(`${o.url}/api/tags`);
+ if (!res.ok) throw new Error(`Ollama not reachable (HTTP ${res.status})`);
+ const data = await res.json();
+ return (data.models ?? []).map((m) => m.name);
+}
diff --git a/apps/eval/package.json b/apps/eval/package.json
new file mode 100644
index 0000000..61d3e63
--- /dev/null
+++ b/apps/eval/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "eval",
+ "version": "1.0.0",
+ "description": "MCP rubber-duck evaluation harness",
+ "scripts": {
+ "run": "node src/run.mjs",
+ "report-html": "node src/report-html.mjs"
+ },
+ "license": "ISC",
+ "type": "module",
+ "dependencies": {
+ "@modelcontextprotocol/client": "^2.0.0"
+ }
+}
diff --git a/apps/eval/src/report-html.mjs b/apps/eval/src/report-html.mjs
new file mode 100644
index 0000000..b047dd6
--- /dev/null
+++ b/apps/eval/src/report-html.mjs
@@ -0,0 +1,210 @@
+import { readFile, writeFile, mkdir } from "node:fs/promises";
+import path from "node:path";
+
+const SCENARIO_LABELS = {
+ control: "Без утки (контроль)",
+ blind: "Слепая утка",
+ mentor: "Утка-помощник",
+};
+
+const SCENARIO_DESCS = {
+ control: "Модель решает задачу напрямую, без инструментов.",
+ blind: "Модель объясняет подход коллеге, вызывает quack, не зная заранее ответ.",
+ mentor: "Модель обязана выписать мысли и возможные ошибки, затем проверить себя уткой.",
+};
+
+const MOOD_COLORS = { happy: "#f59e0b", confused: "#8b5cf6", excited: "#10b981", sleepy: "#64748b" };
+
+function esc(s) {
+ return String(s ?? "")
+ .replace(/&/g, "&")
+ .replace(//g, ">");
+}
+
+function nl2br(s) {
+ return esc(s).replace(/\n/g, "
");
+}
+
+function scenarioCard(key, g) {
+ const pct = g.accuracy;
+ const color = pct >= 70 ? "#10b981" : pct >= 40 ? "#f59e0b" : "#ef4444";
+ const duckPct = g.tasks ? Math.round((g.duckUsed / g.tasks) * 100) : 0;
+ return `
+
+
+
+
${esc(SCENARIO_LABELS[key])}
+
${esc(SCENARIO_DESCS[key])}
+
+
${pct}%
+
+
+
+
Верно${g.correct} / ${g.tasks}
+
Утка${duckPct}% (${g.duckUsed}/${g.tasks})
+
Вызовов${g.toolCalls}
+
Input${g.avgPromptTokens}
+
Output${g.avgGenTokens}
+
Рассужд.${g.avgDuckTokens}
+
+
`;
+}
+
+function rowHtml(r) {
+ const cls = r.correct ? "ok" : "no";
+ const duck = r.duckUsed ? "🦆" : "—";
+ return `
+
+ | ${esc(r.id)} |
+ ${esc(SCENARIO_LABELS[r.scenario])} |
+ ${r.correct ? "✓" : "✗"} |
+ ${duck} (${r.toolCalls}) |
+ ${nl2br(r.question)} |
+ ${esc(r.expected)} |
+ ${r.promptTokens} / ${r.genTokens} |
+ ${nl2br(r.response)} |
+
`;
+}
+
+export function buildHtml(report) {
+ const s = report.scenarios;
+ const cards = ["control", "blind", "mentor"]
+ .filter((k) => s[k])
+ .map((k) => scenarioCard(k, s[k]))
+ .join("");
+
+ const rows = report.perTask.map(rowHtml).join("");
+
+ return `
+
+
+
+
+Rubber Duck MCP — отчёт тестирования
+
+
+
+
+
+
+
${cards}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | # | Сценарий | ✓ | Утка |
+ Вопрос | Ожид. | in/out | Ответ модели |
+
+
+ ${rows}
+
+
+
+
+
+
+
+
+`;
+}
+
+async function main() {
+ const args = process.argv.slice(2);
+ const inFile = args.find((a) => a.startsWith("--in="))?.slice(5) || "out/report.json";
+ const outFile = args.find((a) => a.startsWith("--out="))?.slice(6) || "out/report.html";
+
+ const report = JSON.parse(await readFile(inFile, "utf8"));
+ const html = buildHtml(report);
+ await mkdir(path.dirname(outFile), { recursive: true });
+ await writeFile(outFile, html, "utf8");
+ console.log(`HTML report written: ${path.resolve(outFile)}`);
+}
+
+if (process.argv[1] && path.resolve(process.argv[1]).includes("report-html")) {
+ main().catch((e) => {
+ console.error(e);
+ process.exit(1);
+ });
+}
diff --git a/apps/eval/src/report.mjs b/apps/eval/src/report.mjs
new file mode 100644
index 0000000..a4021b6
--- /dev/null
+++ b/apps/eval/src/report.mjs
@@ -0,0 +1,67 @@
+import { isCorrect } from "./runner.mjs";
+
+function round(x, d = 2) {
+ if (!Number.isFinite(x)) return x;
+ const f = 10 ** d;
+ return Math.round(x * f) / f;
+}
+
+function avg(arr) {
+ if (!arr.length) return 0;
+ return arr.reduce((a, b) => a + b, 0) / arr.length;
+}
+
+export function buildReport({ model, mcpUrl, tasks, rows, meta = {} }) {
+ const scenarios = ["control", "blind", "mentor"];
+ const byScenario = {};
+ for (const s of scenarios) byScenario[s] = { total: 0, correct: 0, duckUsed: 0, toolCalls: 0, promptTokens: [], genTokens: [], duckTokens: [], rows: [] };
+
+ for (const row of rows) {
+ const s = row.scenario;
+ if (!byScenario[s]) continue;
+ byScenario[s].total += 1;
+ if (row.correct) byScenario[s].correct += 1;
+ if (row.duckUsed) byScenario[s].duckUsed += 1;
+ byScenario[s].toolCalls += row.toolCalls ?? 0;
+ byScenario[s].promptTokens.push(row.promptTokens ?? 0);
+ byScenario[s].genTokens.push(row.genTokens ?? 0);
+ byScenario[s].duckTokens.push(row.duckTokens ?? 0);
+ byScenario[s].rows.push(row);
+ }
+
+ const summaries = {};
+ for (const s of scenarios) {
+ const g = byScenario[s];
+ summaries[s] = {
+ tasks: g.total,
+ correct: g.correct,
+ accuracy: g.total ? round((g.correct / g.total) * 100, 1) : 0,
+ duckUsed: g.duckUsed,
+ toolCalls: g.toolCalls,
+ avgPromptTokens: round(avg(g.promptTokens)),
+ avgGenTokens: round(avg(g.genTokens)),
+ avgDuckTokens: round(avg(g.duckTokens)),
+ };
+ }
+
+ return {
+ generatedAt: new Date().toISOString(),
+ model,
+ mcpUrl,
+ scenarios: summaries,
+ perTask: rows.map((r) => ({
+ id: r.id,
+ scenario: r.scenario,
+ correct: r.correct,
+ duckUsed: r.duckUsed,
+ toolCalls: r.toolCalls,
+ promptTokens: r.promptTokens,
+ genTokens: r.genTokens,
+ duckTokens: r.duckTokens,
+ response: r.response,
+ expected: r.expected,
+ question: r.question,
+ })),
+ meta,
+ };
+}
diff --git a/apps/eval/src/run.mjs b/apps/eval/src/run.mjs
new file mode 100644
index 0000000..bea1714
--- /dev/null
+++ b/apps/eval/src/run.mjs
@@ -0,0 +1,115 @@
+import { readFile, writeFile, mkdir } from "node:fs/promises";
+import path from "node:path";
+import { Ollama } from "../lib/ollama.mjs";
+import { McpClient } from "../lib/mcpClient.mjs";
+import { runScenario } from "./runner.mjs";
+import { isCorrect } from "./runner.mjs";
+import { buildReport } from "./report.mjs";
+
+function parseArgs(argv) {
+ const args = { scenarios: ["control", "blind", "mentor"], limit: Infinity, model: process.env.OLLAMA_MODEL || null };
+ for (let i = 0; i < argv.length; i += 1) {
+ const a = argv[i];
+ const next = () => argv[i + 1];
+ if (a === "--model") args.model = next(), i += 1;
+ else if (a === "--mcp-url") args.mcpUrl = next(), i += 1;
+ else if (a === "--scenarios") args.scenarios = next().split(","), i += 1;
+ else if (a === "--limit") args.limit = Number(next()), i += 1;
+ else if (a === "--tasks") args.tasksFile = next(), i += 1;
+ else if (a === "--out") args.out = next(), i += 1;
+ }
+ return args;
+}
+
+const DEFAULT_MCP_URL = "https://mcp-liart-five.vercel.app/api/mcp";
+
+async function main() {
+ const args = parseArgs(process.argv.slice(2));
+ if (!args.model) {
+ console.error("Usage: node src/run.mjs --model [--mcp-url URL] [--scenarios c,b,m] [--limit N] [--tasks file] [--out file]");
+ process.exit(1);
+ }
+ const mcpUrl = args.mcpUrl || DEFAULT_MCP_URL;
+ const tasksFile = args.tasksFile || path.join("data", "tasks.json");
+
+ const tasksRaw = JSON.parse(await readFile(tasksFile, "utf8"));
+ const tasks = tasksRaw.slice(0, args.limit === Infinity ? tasksRaw.length : args.limit);
+
+ console.log(`\nModel: ${args.model}`);
+ console.log(`MCP endpoint: ${mcpUrl}`);
+ console.log(`Tasks: ${tasks.length}; Scenarios: [${args.scenarios.join(", ")}]`);
+
+ const ollama = new Ollama();
+ const ping = await ollama.ping();
+ console.log(`Ollama OK (${ping.models.length} models)`);
+ const modelPresent = ping.models.some((m) => m.startsWith(args.model));
+ if (!modelPresent) {
+ console.warn(` (warning) model "${args.model}" not found among: ${ping.models.join(", ")}`);
+ }
+
+ const mcp = new McpClient(mcpUrl);
+ const mcpTools = await mcp.listTools();
+ console.log(`MCP tools: ${mcpTools.map((t) => t.name).join(", ")}`);
+
+ const rows = [];
+ for (const task of tasks) {
+ for (const scenario of args.scenarios) {
+ let r;
+ try {
+ r = await runScenario({ scenario, ollama, model: args.model, task: task.question, mcp });
+ } catch (err) {
+ console.error(` [${task.id}/${scenario}] FAILED: ${err.message}`);
+ rows.push({
+ id: task.id,
+ scenario,
+ correct: false,
+ duckUsed: false,
+ toolCalls: 0,
+ promptTokens: 0,
+ genTokens: 0,
+ duckTokens: 0,
+ response: `ERR: ${err.message}`,
+ expected: task.answer,
+ question: task.question,
+ });
+ continue;
+ }
+ const correct = isCorrect(r.response, task.answer);
+ rows.push({
+ id: task.id,
+ scenario,
+ correct,
+ duckUsed: r.duckUsed,
+ toolCalls: r.toolCalls,
+ promptTokens: r.promptTokens,
+ genTokens: r.genTokens,
+ duckTokens: r.duckTokens,
+ response: r.response,
+ expected: task.answer,
+ question: task.question,
+ });
+ const mark = correct ? "OK " : "XX ";
+ const duck = r.duckUsed ? "duck" : "n/a ";
+ console.log(`${scenario.padEnd(8)} ${mark} ${task.id} duck=${duck} prompt=${r.promptTokens} gen=${r.genTokens} | ${r.response.slice(0, 70).replace(/\n/g, " ")}`);
+ }
+ }
+
+ const report = buildReport({ model: args.model, mcpUrl, tasks, rows });
+ const out = args.out || path.join("out", "report.json");
+ await mkdir(path.dirname(out), { recursive: true });
+ await writeFile(out, JSON.stringify(report, null, 2), "utf8");
+ console.log(`\nReport written: ${out}`);
+ console.log("\n--- Summary ---");
+ for (const [s, g] of Object.entries(report.scenarios)) {
+ console.log(
+ `${s.padEnd(8)} accuracy=${String(g.accuracy).padStart(5)}% (${g.correct}/${g.tasks}) ` +
+ `duckUsed=${g.duckUsed} toolCalls=${g.toolCalls} ` +
+ `avgPrompt=${g.avgPromptTokens} avgGen=${g.avgGenTokens} avgDuck=${g.avgDuckTokens}`
+ );
+ }
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/apps/eval/src/runner.mjs b/apps/eval/src/runner.mjs
new file mode 100644
index 0000000..01e6558
--- /dev/null
+++ b/apps/eval/src/runner.mjs
@@ -0,0 +1,195 @@
+import { Ollama } from "../lib/ollama.mjs";
+import { McpClient, textContentFrom } from "../lib/mcpClient.mjs";
+
+const DUCK_TOOL = {
+ type: "function",
+ function: {
+ name: "quack",
+ description:
+ "Explain your current reasoning, steps, doubts or possible mistakes to your rubber duck colleague, and get a short acknowledgment in return. The duck replies with a quack.",
+ parameters: {
+ type: "object",
+ properties: {
+ mood: {
+ type: "string",
+ enum: ["happy", "confused", "excited", "sleepy"],
+ description: "Mood of the duck while you explain. Omit for a random quack.",
+ },
+ },
+ additionalProperties: false,
+ },
+ },
+};
+
+const CONTROL_SYSTEM =
+ "You are an experienced assistant. Solve the user's problem as accurately as possible. Give only the final answer.";
+
+const BLIND_SYSTEM =
+ "You are solving a difficult problem. To solve it better, you work in a pair with a colleague. " +
+ "First, describe out loud in detail to the colleague the essence of your approach, your first steps, and any doubts. " +
+ "Then use the 'quack' tool so the colleague can give you a brief reaction, and wait for that reaction. " +
+ "Then, taking the colleague's reaction into account, complete your solution and give the final answer to the user. " +
+ "Note: your colleague is a rubber duck; it replies only with a short quack. Even so, saying your reasoning aloud helps you spot mistakes.";
+
+const MENTOR_SYSTEM =
+ "You are solving a difficult problem. Before giving your final answer you MUST write down in detail, for your rubber duck, " +
+ "your reasoning and the possible mistakes you might be making. Then call the 'quack' tool so the duck can reply. " +
+ "Use the duck's reply to double-check yourself, find bugs, and only after that give the perfect final answer to the user.";
+
+const MAX_DUCK_TURNS = 6;
+
+function normalize(s) {
+ return String(s ?? "")
+ .toLowerCase()
+ .replace(/[^a-zа-яё0-9/]/g, "")
+ .replace(/ё/g, "е")
+ .trim();
+}
+
+function extractFinalNumber(text) {
+ const t = String(text ?? "");
+ const re = /(?:ответ|answer|otvet|итог|итак|final)\s*[::—]?\s*/gi;
+ let segment = t;
+ let m;
+ while ((m = re.exec(t)) !== null) {
+ if (m[0].trim()) segment = t.slice(re.lastIndex);
+ }
+ const digits = segment.match(/\d+/g) || [];
+ return digits.length ? digits[digits.length - 1] : null;
+}
+
+function isNumeric(s) {
+ return /^\d+(?:[.,]\d+)?$/.test(String(s).trim());
+}
+
+export function isCorrect(predicted, expected) {
+ const e = String(expected ?? "").trim();
+ const p = String(predicted ?? "");
+ if (!e || !p) return false;
+
+ // Дробный ответ (например "1/8") — сравниваем вхождение нормализованной дроби.
+ if (/\d+\s*\/\s*\d+/.test(e)) {
+ const eNorm = normalize(e);
+ const pNorm = normalize(p);
+ return pNorm.includes(eNorm);
+ }
+
+ // Числовой ответ — извлекаем финальное число и сравниваем точно.
+ if (isNumeric(e)) {
+ const cand = extractFinalNumber(p);
+ if (cand == null) return false;
+ return normalizeNum(cand) === normalizeNum(e);
+ }
+
+ // Текстовый ответ — нормализованное вхождение слова.
+ const eNorm = normalize(e);
+ const pNorm = normalize(p);
+ return pNorm.includes(eNorm);
+}
+
+function normalizeNum(n) {
+ return String(n).replace(/[.,]/g, "").replace(/^0+(?=\d)/, "");
+}
+
+function toolDefinition() {
+ return DUCK_TOOL;
+}
+
+async function runControl(ollama, model, task) {
+ const messages = [
+ { role: "system", content: CONTROL_SYSTEM },
+ { role: "user", content: task },
+ ];
+ const r = await ollama.chat({ model, messages });
+ return {
+ response: r.content,
+ toolCalls: 0,
+ duckUsed: false,
+ duckTokens: 0,
+ promptTokens: r.promptEvalCount,
+ genTokens: r.evalCount,
+ messages,
+ };
+}
+
+async function runDuck(ollama, model, task, mcp, system, scenario) {
+ const messages = [
+ { role: "system", content: system },
+ { role: "user", content: task },
+ ];
+ let toolCalls = 0;
+ let duckUsed = false;
+ let totalPrompt = 0;
+ let totalGen = 0;
+ let reasoningTokens = 0;
+ let turns = 0;
+
+ for (;;) {
+ turns += 1;
+ const r = await ollama.chat({ model, messages, tools: [toolDefinition()] });
+ totalPrompt += r.promptEvalCount;
+ totalGen += r.evalCount;
+ reasoningTokens += r.evalCount;
+
+ if (r.toolCalls && r.toolCalls.length) {
+ toolCalls += r.toolCalls.length;
+ const assistantMsg = { role: "assistant", content: r.content || "" };
+ assistantMsg.tool_calls = r.toolCalls.map((tc) => ({
+ function: { name: tc.function.name, arguments: tc.function.arguments },
+ }));
+ messages.push(assistantMsg);
+
+ for (const tc of r.toolCalls) {
+ if (tc.function.name === "quack") {
+ duckUsed = true;
+ let args = {};
+ try {
+ args = tc.function.arguments ? JSON.parse(tc.function.arguments) : {};
+ } catch {
+ args = {};
+ }
+ const result = await mcp.callTool("quack", args);
+ const text = textContentFrom(result) || "QUACK!";
+ messages.push({ role: "tool", content: text });
+ } else {
+ messages.push({ role: "tool", content: "{}" });
+ }
+ }
+ if (turns >= MAX_DUCK_TURNS) break;
+ continue;
+ }
+
+ messages.push({ role: "assistant", content: r.content || "" });
+ return {
+ response: r.content,
+ toolCalls,
+ duckUsed,
+ duckTokens: reasoningTokens,
+ promptTokens: totalPrompt,
+ genTokens: totalGen,
+ turns,
+ messages,
+ };
+ }
+
+ // loop bound hit without a final answer
+ const last = messages[messages.length - 1];
+ return {
+ response: last?.content ?? "",
+ toolCalls,
+ duckUsed,
+ duckTokens: reasoningTokens,
+ promptTokens: totalPrompt,
+ genTokens: totalGen,
+ turns,
+ messages,
+ };
+}
+
+export async function runScenario({ scenario, ollama, model, task, mcp }) {
+ if (scenario === "control") {
+ return runControl(ollama, model, task);
+ }
+ const system = scenario === "blind" ? BLIND_SYSTEM : MENTOR_SYSTEM;
+ return runDuck(ollama, model, task, mcp, system, scenario);
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c1a651d..2a51dc8 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -207,6 +207,12 @@ importers:
.: {}
+ apps/eval:
+ dependencies:
+ '@modelcontextprotocol/client':
+ specifier: ^2.0.0
+ version: 2.0.0
+
apps/frontend:
devDependencies:
vite:
@@ -414,6 +420,10 @@ packages:
cpu: [x64]
os: [win32]
+ '@modelcontextprotocol/client@2.0.0':
+ resolution: {integrity: sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==}
+ engines: {node: '>=20'}
+
'@modelcontextprotocol/core@2.0.0':
resolution: {integrity: sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==}
engines: {node: '>=20'}
@@ -612,6 +622,10 @@ packages:
resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==}
engines: {node: '>=16'}
+ cross-spawn@7.0.6:
+ resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
+ engines: {node: '>= 8'}
+
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
@@ -619,6 +633,14 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
+ eventsource-parser@3.1.1:
+ resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==}
+ engines: {node: '>=18.0.0'}
+
+ eventsource@3.0.7:
+ resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==}
+ engines: {node: '>=18.0.0'}
+
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@@ -633,6 +655,12 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
+ isexe@2.0.0:
+ resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+
+ jose@6.2.10:
+ resolution: {integrity: sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==}
+
lightningcss-android-arm64@1.33.0:
resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==}
engines: {node: '>= 12.0.0'}
@@ -744,6 +772,10 @@ packages:
sass:
optional: true
+ path-key@3.1.1:
+ resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
+ engines: {node: '>=8'}
+
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -751,6 +783,10 @@ packages:
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
engines: {node: '>=12'}
+ pkce-challenge@5.0.1:
+ resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
+ engines: {node: '>=16.20.0'}
+
postcss@8.5.23:
resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==}
engines: {node: ^10 || ^12 || >=14}
@@ -790,6 +826,14 @@ packages:
'@types/node':
optional: true
+ shebang-command@2.0.0:
+ resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
+ engines: {node: '>=8'}
+
+ shebang-regex@3.0.0:
+ resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
+ engines: {node: '>=8'}
+
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -865,6 +909,11 @@ packages:
yaml:
optional: true
+ which@2.0.2:
+ resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
+ engines: {node: '>= 8'}
+ hasBin: true
+
zod@4.5.4:
resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==}
@@ -982,6 +1031,16 @@ snapshots:
'@img/sharp-win32-x64@0.35.4':
optional: true
+ '@modelcontextprotocol/client@2.0.0':
+ dependencies:
+ '@modelcontextprotocol/core': 2.0.0
+ cross-spawn: 7.0.6
+ eventsource: 3.0.7
+ eventsource-parser: 3.1.1
+ jose: 6.2.10
+ pkce-challenge: 5.0.1
+ zod: 4.5.4
+
'@modelcontextprotocol/core@2.0.0':
dependencies:
zod: 4.5.4
@@ -1092,10 +1151,22 @@ snapshots:
commander@11.1.0: {}
+ cross-spawn@7.0.6:
+ dependencies:
+ path-key: 3.1.1
+ shebang-command: 2.0.0
+ which: 2.0.2
+
csstype@3.2.3: {}
detect-libc@2.1.2: {}
+ eventsource-parser@3.1.1: {}
+
+ eventsource@3.0.7:
+ dependencies:
+ eventsource-parser: 3.1.1
+
fdir@6.5.0(picomatch@4.0.7):
optionalDependencies:
picomatch: 4.0.7
@@ -1103,6 +1174,10 @@ snapshots:
fsevents@2.3.3:
optional: true
+ isexe@2.0.0: {}
+
+ jose@6.2.10: {}
+
lightningcss-android-arm64@1.33.0:
optional: true
@@ -1187,10 +1262,14 @@ snapshots:
- '@types/node'
- babel-plugin-macros
+ path-key@3.1.1: {}
+
picocolors@1.1.1: {}
picomatch@4.0.7: {}
+ pkce-challenge@5.0.1: {}
+
postcss@8.5.23:
dependencies:
nanoid: 3.3.18
@@ -1270,6 +1349,12 @@ snapshots:
'@types/node': 20.19.43
optional: true
+ shebang-command@2.0.0:
+ dependencies:
+ shebang-regex: 3.0.0
+
+ shebang-regex@3.0.0: {}
+
source-map-js@1.2.1: {}
styled-jsx@5.1.6(react@19.2.8):
@@ -1299,4 +1384,8 @@ snapshots:
'@types/node': 20.19.43
fsevents: 2.3.3
+ which@2.0.2:
+ dependencies:
+ isexe: 2.0.0
+
zod@4.5.4: {}