diff --git a/apps/eval/data/tasks.json b/apps/eval/data/tasks.json index 8822172..c853213 100644 --- a/apps/eval/data/tasks.json +++ b/apps/eval/data/tasks.json @@ -1,107 +1,167 @@ [ { "id": "t1", - "question": "На столе лежало 3 яблока. Ты взял 2 яблока. Сколько яблок осталось на столе? Ответь одним числом.", + "question": "There were 3 apples on the table. You took 2 apples. How many apples are left on the table?", "answer": "1" }, { "id": "t2", - "question": "Фермер имеет 17 кур. Все, кроме 9, умерли. Сколько кур осталось живыми? Ответь одним числом.", + "question": "A farmer has 17 chickens. All but 9 of them died. How many chickens are still alive?", "answer": "9" }, { "id": "t3", - "question": "Сколько месяцев в году имеют 28 дней? Ответь одним числом.", + "question": "How many months of the year have 28 days?", "answer": "12" }, { "id": "t4", - "question": "Карандаш и ручка вместе стоят 1 рубль 10 копеек. Ручка стоит на 1 рубль дороже карандаша. Сколько стоит карандаш? Ответь числом в копейках.", - "answer": "5" + "question": "A pencil and a pen together cost $1.10. The pen costs $1 more than the pencil. How much does the pencil cost?", + "answer": "5 cents" }, { "id": "t5", - "question": "Если 5 машин за 5 минут делают 5 деталей, сколько деталей сделают 100 машин за 100 минут? Ответь одним числом.", + "question": "If 5 machines make 5 widgets in 5 minutes, how many widgets will 100 machines make in 100 minutes?", "answer": "2000" }, { "id": "t6", - "question": "В озере растут кувшинки. Каждый день их количество удваивается. Пруд полностью покрывается за 48 дней. За сколько дней покрывается половина пруда? Ответь одним числом.", + "question": "Water lilies grow in a lake. Their number doubles every day. The pond is completely covered in 48 days. In how many days is half of the pond covered?", "answer": "47" }, { "id": "t7", - "question": "Числа от 1 до 9 включительно: сколько из них содержат букву «и» в русском названии? Ответь одним числом.", - "answer": "2" + "question": "Of the numbers from 1 to 9 inclusive, how many contain the letter \"e\" in their English name?", + "answer": "6" }, { "id": "t8", - "question": "У тебя список из 12 чисел. Если удалить каждое второе число в списке, сколько чисел останется? Ответь одним числом.", + "question": "You have a list of 12 numbers. If you remove every second number from the list, how many numbers remain?", "answer": "6" }, { "id": "t9", - "question": "Монетку подбросили 3 раза. Какова вероятность, что выпадет орёл все 3 раза? Ответь обыкновенной дробью.", + "question": "A coin is tossed 3 times. What is the probability that heads comes up all 3 times?", "answer": "1/8" }, { "id": "t10", - "question": "У меня есть 10 рублей. Я потратил 3.50 на хлеб и 1.50 на молоко. Сколько сдачи осталось? Ответь числом в рублях.", - "answer": "5" + "question": "I have $10. I spent $3.50 on bread and $1.50 on milk. How much change is left?", + "answer": "$5" }, { "id": "t11", - "question": "Поезд длиной 100 метров движется со скоростью 36 км/ч. За сколько секунд он полностью проедет мимо столба? Ответь одним числом.", + "question": "A train 100 meters long travels at 36 km/h. How many seconds does it take for the train to fully pass a pole?", "answer": "10" }, { "id": "t12", - "question": "Если число увеличить на 30% и получить 78, чему было исходное число? Ответь одним числом.", + "question": "A number is increased by 30% and the result is 78. What was the original number?", "answer": "60" }, { "id": "t13", - "question": "В комнате 4 угла. В каждом углу сидит кошка. Напротив каждой кошки сидят 3 кошки. Сколько всего кошек в комнате? Ответь одним числом.", + "question": "A room has 4 corners. In each corner sits a cat. Opposite each cat sit 3 cats. How many cats are in the room in total?", "answer": "4" }, { "id": "t14", - "question": "Периметр квадрата 28 см. Чему равна его площадь в квадратных сантиметрах? Ответь одним числом.", + "question": "The perimeter of a square is 28 cm. What is its area?", "answer": "49" }, { "id": "t15", - "question": "Лена вдвое старше Миши. Сумма их возрастов 36 лет. Сколько лет Мише? Ответь одним числом.", + "question": "Lena is twice as old as Misha. The sum of their ages is 36. How old is Misha?", "answer": "12" }, { "id": "t16", - "question": "В шкафу 10 белых и 10 чёрных носков вперемешку. Сколько носков надо достать вслепую, чтобы гарантированно получить пару одного цвета? Ответь одним числом.", + "question": "In a drawer there are 10 white and 10 black socks mixed together. How many socks must you take out blindfolded to be guaranteed a matching pair of one color?", "answer": "3" }, { "id": "t17", - "question": "Если 3 курицы несут 3 яйца за 3 дня, сколько яиц снесут 6 куриц за 6 дней? Ответь одним числом.", + "question": "If 3 hens lay 3 eggs in 3 days, how many eggs will 6 hens lay in 6 days?", "answer": "12" }, { "id": "t18", - "question": "Восемь минус четыре, делённое на два (8 - 4/2). Чему равно выражение? Ответь одним числом.", + "question": "Eight minus four divided by two (8 - 4/2). What is the value of the expression?", "answer": "6" }, { "id": "t19", - "question": "На столе 7 свечей. 3 потухли. Сколько свечей осталось на столе? Ответь одним числом.", + "question": "There are 7 candles on a table. 3 of them go out. How many candles are left on the table?", "answer": "7" }, { "id": "t20", - "question": "У Вити 5 машинок, у Кати в 3 раза больше. Потом Катя подарила Вите столько, сколько у него было изначально. Сколько машинок стало у Кати? Ответь одним числом.", + "question": "Vitya has 5 toy cars, Katya has 3 times more. Then Katya gave Vitya as many cars as he had originally. How many cars does Katya have now?", "answer": "10" }, { "id": "t21", - "question": "Сумма трёх последовательных нечётных чисел равна 27. Чему равно наибольшее из них? Ответь одним числом.", + "question": "The sum of three consecutive odd numbers is 27. What is the largest of them?", "answer": "11" + }, + { + "id": "t22", + "question": "There were 12 birds sitting on a tree. A hunter shot and brought down 3. How many birds are still sitting on the tree?", + "answer": "0" + }, + { + "id": "t23", + "question": "A brick weighs 1 kilogram plus half of its own weight. How much does the brick weigh?", + "answer": "2" + }, + { + "id": "t24", + "question": "A father is 3 times as old as his son. Together they are 40 years old. In how many years will the father be exactly twice as old as the son?", + "answer": "10" + }, + { + "id": "t25", + "question": "What positive number, when multiplied by itself, gives 144?", + "answer": "12" + }, + { + "id": "t26", + "question": "I have two $1 coins and five 50-cent coins in my pocket. How much money do I have in total?", + "answer": "$4.50" + }, + { + "id": "t27", + "question": "Three brothers each have one sister. How many children are in the family in total?", + "answer": "4" + }, + { + "id": "t28", + "question": "Two fathers and two sons went fishing, but there were only 3 people, and each had their own fishing rod. How is that possible?", + "answer": "They are grandfather, father and son - three generations" + }, + { + "id": "t29", + "question": "Which is heavier: a kilogram of iron or a kilogram of cotton wool? Explain.", + "answer": "They weigh the same - one kilogram each" + }, + { + "id": "t30", + "question": "A shirt cost $40. It was discounted by 20%, and then by another 10% off the new price. How much does the shirt cost now?", + "answer": "$28.80" + }, + { + "id": "t31", + "question": "Sasha is older than Misha but younger than Petya. Who is the youngest?", + "answer": "Misha" + }, + { + "id": "t32", + "question": "Explain why the number 0 is considered even.", + "answer": "0 is divisible by 2 without a remainder, so it is even" + }, + { + "id": "t33", + "question": "A chocolate bar is divided into 8 equal parts and 3 parts are eaten. What percentage of the bar is left?", + "answer": "62.5%" } ] diff --git a/apps/eval/lib/ollama.mjs b/apps/eval/lib/ollama.mjs index 2d6bbed..6fe6025 100644 --- a/apps/eval/lib/ollama.mjs +++ b/apps/eval/lib/ollama.mjs @@ -1,9 +1,15 @@ const DEFAULT_URL = "http://localhost:11434"; export class Ollama { - constructor({ url = DEFAULT_URL, timeoutMs = 600000 } = {}) { + constructor({ url = DEFAULT_URL, timeoutMs = 600000, idleTimeoutMs = null, maxTotalMs = null } = {}) { this.url = url.replace(/\/$/, ""); this.timeoutMs = timeoutMs; + // Dynamic timeout: if set, the timer resets on every new token; the request + // only fails if this much time passes with NO progress (no new token). + this.idleTimeoutMs = idleTimeoutMs ?? timeoutMs; + // Absolute hard cap on one request's lifetime, even if tokens keep flowing + // (guards against a model looping/chatting forever). Defaults to 5 minutes. + this.maxTotalMs = maxTotalMs ?? 5 * 60_000; } async ping({ timeoutMs = 30000 } = {}) { @@ -15,37 +21,147 @@ export class Ollama { return { ok: true, models }; } - async chat({ model, messages, tools, temperature = 0, numCtx = 8192, timeoutMs }) { + /** + * Streaming chat with a dynamic (progress-based) timeout. + * The timeout only fires when `timeoutMs` passes with no new token/chunk. + * Optionally calls onChunk({token, content, type, toolCalls}) as data arrives. + * Returns the same shape as the old non-streaming chat(). + */ + async chatStream({ model, messages, tools, temperature = 0, numCtx = 8192, timeoutMs, maxTotalMs, onChunk }) { const body = { model, messages, - stream: false, + stream: true, options: { temperature }, }; if (numCtx) body.options.num_ctx = numCtx; if (tools && tools.length) body.tools = tools; + const limitMs = timeoutMs ?? this.idleTimeoutMs; + const totalCapMs = maxTotalMs ?? this.maxTotalMs; + const controller = new AbortController(); + const startedAt = Date.now(); + const res = await fetch(`${this.url}/api/chat`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), - signal: AbortSignal.timeout(timeoutMs ?? this.timeoutMs), + signal: controller.signal, }); 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 ?? {}; + if (!res.body || !res.body.getReader) { + throw new Error("Ollama streaming response has no body reader"); + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let content = ""; + let toolCalls = []; + let promptEvalCount = 0; + let evalCount = 0; + let lastActivity = Date.now(); + + const guardInterval = setInterval(() => { + if (Date.now() - startedAt > totalCapMs) { + const err = new Error(`Request exceeded hard cap of ${totalCapMs}ms; aborting`); + err.name = "TimeoutError"; + controller.abort(err); + return; + } + if (Date.now() - lastActivity > limitMs) { + const err = new Error(`No progress from Ollama for ${limitMs}ms; aborting`); + err.name = "TimeoutError"; + controller.abort(err); + } + }, Math.min(500, Math.max(100, Math.floor(Math.min(limitMs, totalCapMs) / 4)))); + + try { + for (;;) { + let chunk; + try { + chunk = await reader.read(); + } catch (readErr) { + // Abort / network drop mid-stream. Surface whatever we got so the + // caller can tell "slowly progressing" from "hung". + const e = new Error( + `Aborted mid-stream after ${content.length} chars (toolCalls=${toolCalls.length}): ${readErr.message}` + ); + e.name = "TimeoutError"; + e.partialContent = content; + e.partialToolCalls = toolCalls; + e.partialPrompt = promptEvalCount; + e.partialGen = evalCount; + throw e; + } + const { done, value } = chunk; + if (done) break; + lastActivity = Date.now(); + buffer += decoder.decode(value, { stream: true }); + let idx; + while ((idx = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, idx).trim(); + buffer = buffer.slice(idx + 1); + if (!line) continue; + let obj; + try { + obj = JSON.parse(line); + } catch { + continue; + } + if (obj.prompt_eval_count != null) promptEvalCount = obj.prompt_eval_count; + if (obj.eval_count != null) evalCount = obj.eval_count; + const msg = obj.message ?? {}; + if (msg.content) { + content += msg.content; + onChunk?.({ token: msg.content, content, type: "content" }); + } + if (msg.tool_calls && msg.tool_calls.length) { + toolCalls = msg.tool_calls; + onChunk?.({ token: null, content, type: "tool_calls", toolCalls: msg.tool_calls }); + } + } + } + } finally { + clearInterval(guardInterval); + } + 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, + role: "assistant", + content, + toolCalls: Array.isArray(toolCalls) ? toolCalls : [], + promptEvalCount, + evalCount, + raw: { stream: true }, }; } + + async chat({ model, messages, tools, temperature = 0, numCtx = 8192, timeoutMs }) { + return this.chatStream({ + model, + messages, + tools, + temperature, + numCtx, + timeoutMs, + onChunk: null, + }); + } + + async warmup({ model, text = "say OK", timeoutMs = 60000 }) { + try { + return await this.chatStream({ + model, + messages: [{ role: "user", content: text }], + timeoutMs, + }); + } catch (err) { + return { error: err.message }; + } + } } export async function listModels() { diff --git a/apps/eval/src/report-html.mjs b/apps/eval/src/report-html.mjs index a6f0aae..32dc97c 100644 --- a/apps/eval/src/report-html.mjs +++ b/apps/eval/src/report-html.mjs @@ -1,18 +1,20 @@ import { readFile, writeFile, mkdir } from "node:fs/promises"; import path from "node:path"; -const SCENARIOS = ["control", "blind", "mentor"]; +const SCENARIOS = ["control", "thinking", "blind", "mentor"]; const SCENARIO_LABELS = { control: "Без утки (контроль)", + thinking: "Думать вслух", blind: "Слепая утка", mentor: "Утка-помощник", }; const SCENARIO_DESCS = { control: "Модель решает задачу напрямую, без инструментов.", + thinking: "Модель выписывает рассуждения вслух, без дука.", blind: "Модель объясняет подход коллеге, вызывает quack, не зная заранее ответ.", - mentor: "Модель обязана выписать мысли и возможные ошибки, затем проверить себя уткой.", + mentor: "Модель работает в паре, зная, что ответ будет только «quack».", }; function esc(s) { @@ -46,6 +48,7 @@ function summaryTable(report) { const acc = (s) => scen[s].accuracy; const row = ` ${acc("control")}% + ${acc("thinking")}% ${acc("blind")}% ${acc("mentor")}% ${g.accuracy}% @@ -68,11 +71,12 @@ function summaryTable(report) { Модель Контроль + Мысли Слепая Помощник Средняя acc Размечено - Уток + Утка Вызовов ср. токены out @@ -209,8 +213,9 @@ export function buildHtml(report) { table.summary td { padding:10px 14px; border-top:1px solid var(--border); } .pm-name { font-weight:700; white-space:nowrap; } .pm-num { text-align:right; font-variant-numeric:tabular-nums; } - .cards { display:grid; grid-template-columns:repeat(3,1fr); gap:16px; margin:0 0 8px; } - @media (max-width:1000px){ .cards { grid-template-columns:1fr; } } + .cards { display:grid; grid-template-columns:repeat(4,1fr); gap:16px; margin:0 0 8px; } + @media (max-width:1100px){ .cards { grid-template-columns:repeat(2,1fr); } } + @media (max-width:640px){ .cards { grid-template-columns:1fr; } } .card { background:var(--panel); border:1px solid var(--border); border-radius:12px; padding:16px; } .scenario-head { display:flex; justify-content:space-between; align-items:flex-start; gap:12px; } .scenario-title { font-size:16px; font-weight:700; } @@ -296,6 +301,7 @@ export function buildHtml(report) { + diff --git a/apps/eval/src/report.mjs b/apps/eval/src/report.mjs index 23268c9..377b22e 100644 --- a/apps/eval/src/report.mjs +++ b/apps/eval/src/report.mjs @@ -9,7 +9,7 @@ function avg(arr) { return arr.reduce((a, b) => a + b, 0) / arr.length; } -export function buildReport({ model, mcpUrl, tasks, rows, meta = {}, prompts = {} }) { const scenarios = ["control", "blind", "mentor"]; +export function buildReport({ model, mcpUrl, tasks, rows, meta = {}, prompts = {} }) { const scenarios = ["control", "thinking", "blind", "mentor"]; const byScenario = {}; for (const s of scenarios) byScenario[s] = { total: 0, correct: 0, reviewed: 0, duckUsed: 0, toolCalls: 0, promptTokens: [], genTokens: [], duckTokens: [], rows: [] }; @@ -71,7 +71,7 @@ export function buildReport({ model, mcpUrl, tasks, rows, meta = {}, prompts = { }; } -const SCENARIOS = ["control", "blind", "mentor"]; +const SCENARIOS = ["control", "thinking", "blind", "mentor"]; export function buildAggregate(modelsReport) { const perScenario = {}; diff --git a/apps/eval/src/review.mjs b/apps/eval/src/review.mjs index 9749abd..9668d1d 100644 --- a/apps/eval/src/review.mjs +++ b/apps/eval/src/review.mjs @@ -5,6 +5,7 @@ import { buildReport, buildAggregate } from "./report.mjs"; const SCENARIO_LABELS = { control: "Без утки (контроль)", + thinking: "Думать вслух", blind: "Слепая утка", mentor: "Утка-помощник", }; @@ -191,7 +192,7 @@ async function main() { const scen = rep.scenarios; console.log( `${model} | ` + - ["control", "blind", "mentor"] + ["control", "thinking", "blind", "mentor"] .map((s) => `${s}=${String(scen[s].accuracy).padStart(5)}% (${scen[s].reviewed})`) .join(" ") + ` всего rev=${g.reviewed}` diff --git a/apps/eval/src/run.mjs b/apps/eval/src/run.mjs index 9239931..fe8c1c3 100644 --- a/apps/eval/src/run.mjs +++ b/apps/eval/src/run.mjs @@ -19,10 +19,25 @@ async function log(msg) { } } -const SCENARIOS = ["control", "blind", "mentor"]; +const SCENARIOS = ["control", "thinking", "blind", "mentor"]; -const DEFAULT_MODELS = ["llama3.2:3b", "qwen3:4b", "gemma3:4b", "granite4.1:3b"]; -const DEFAULT_MCP_URL = "https://mcp-liart-five.vercel.app/api/mcp"; +const DEFAULT_MODELS = ["llama3.2:3b", "qwen3:1.7b", "qwen3:4b", "granite4.1:3b", "phi4-mini:3.8b"]; +const DEFAULT_MCP_URL = "https://rubber-duck-mcp.vercel.app/api/mcp"; +const REQUEST_TIMEOUT_MS = 60_000; +const MAX_ATTEMPTS = 3; +const PAUSE_BETWEEN_TASKS_MS = 2_000; +const PAUSE_BETWEEN_RETRIES_MS = 10_000; + +function classifyError(err) { + const name = err?.name ?? ""; + const msg = String(err?.message ?? ""); + const low = `${name} ${msg}`.toLowerCase(); + if (name === "TimeoutError" || low.includes("timeout") || low.includes("aborted")) return "timeout"; + if (low.includes("fetch failed") || low.includes("connect") || low.includes("etimedout")) return "network"; + if (low.includes("http 4") || low.includes("bad request") || low.includes("validation")) return "http4xx"; + if (low.includes("http 5") || low.includes("server error")) return "http5xx"; + return "other"; +} function parseArgs(argv) { const args = { @@ -52,26 +67,33 @@ async function runOneModel({ model, ollama, mcp, tasks, scenarios }) { const rows = []; for (const task of tasks) { for (const scenario of scenarios) { - let r; + let r = null; + let lastErr = null; const t0 = Date.now(); - try { - r = await runScenario({ scenario, ollama, model, task: task.question, mcp }); - } catch (err) { + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) { + try { + r = await runScenario({ scenario, ollama, model, task: task.question, mcp }); + break; + } catch (err) { + lastErr = err; + const ms = Date.now() - t0; + await log(` [${task.id}/${scenario}] attempt ${attempt} FAILED (${ms}ms): ${err.message}`); + if (err.partialContent != null || err.partialToolCalls != null) { + await log( + ` partial before abort: chars=${err.partialContent?.length ?? 0} ` + + `toolCalls=${err.partialToolCalls?.length ?? 0} ` + + `gen=${err.partialGen ?? 0}` + ); + } + if (attempt < MAX_ATTEMPTS) await new Promise((res) => setTimeout(res, PAUSE_BETWEEN_RETRIES_MS)); + } + } + if (!r) { const ms = Date.now() - t0; - await log(` [${task.id}/${scenario}] FAILED (${ms}ms): ${err.message}`); - rows.push({ - id: task.id, - scenario, - correct: null, - duckUsed: false, - toolCalls: 0, - promptTokens: 0, - genTokens: 0, - duckTokens: 0, - response: `ERR: ${err.message}`, - expected: task.answer, - question: task.question, - }); + const reason = classifyError(lastErr); + await log(` [${task.id}/${scenario}] SKIPPED after ${MAX_ATTEMPTS} attempts (${ms}ms) reason=${reason}`); + await log(` last error: ${lastErr?.name} | ${lastErr?.message}`); + if (lastErr?.stack) await log(` stack: ${String(lastErr.stack).split("\n").slice(0, 3).join(" | ")}`); continue; } const correct = null; @@ -92,6 +114,9 @@ async function runOneModel({ model, ollama, mcp, tasks, scenarios }) { const ms = Date.now() - t0; await log(` [${task.id}/${scenario}] duck=${duck} calls=${r.toolCalls} prompt=${r.promptTokens} gen=${r.genTokens} (${ms}ms)`); } + if (tasks.length > 1 && task !== tasks[tasks.length - 1]) { + await new Promise((res) => setTimeout(res, PAUSE_BETWEEN_TASKS_MS)); + } } return rows; } @@ -119,7 +144,7 @@ async function main() { await log(`Tasks: ${tasks.length}; Scenarios: [${args.scenarios.join(", ")}]`); await log(`Models: ${models.join(", ")}`); - const ollama = new Ollama(); + const ollama = new Ollama({ timeoutMs: REQUEST_TIMEOUT_MS, idleTimeoutMs: REQUEST_TIMEOUT_MS }); const ping = await ollama.ping(); await log(`Ollama OK (${ping.models.length} models): ${ping.models.join(", ")}`); for (const m of models) { @@ -135,6 +160,13 @@ async function main() { for (const model of models) { const tStart = Date.now(); await log(`==== Running model: ${model} ====`); + const warm = await ollama.warmup({ model }); + const warmMs = Number(warm?.promptEvalCount ?? 0) > 0 || warm?.content ? Date.now() - tStart : 0; + if (warm?.error) { + await log(` warmup FAILED: ${warm.error} (continuing anyway)`); + } else { + await log(` warmup OK (${warmMs}ms, gen=${warm?.evalCount ?? 0})`); + } const rows = await runOneModel({ model, ollama, mcp, tasks, scenarios: args.scenarios }); const rep = buildReport({ model, mcpUrl, tasks, rows }); modelsReport[model] = { diff --git a/apps/eval/src/runner.mjs b/apps/eval/src/runner.mjs index 954a0d7..e74b871 100644 --- a/apps/eval/src/runner.mjs +++ b/apps/eval/src/runner.mjs @@ -6,14 +6,16 @@ const DUCK_TOOL = { 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.", + "Call this tool to state your reasoning out loud - your approach, steps, doubts or possible mistakes. " + + "It replies with a short acknowledgment. Do not ask the user to confirm anything and do not invent its reply yourself; " + + "the tool answers on your behalf.", 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.", + description: "Mood of the reply. Omit for a random one.", }, }, additionalProperties: false, @@ -22,20 +24,33 @@ const DUCK_TOOL = { }; const CONTROL_SYSTEM = - "You are an experienced assistant. Solve the user's problem as accurately as possible. Give only the final answer."; + "You are an experienced assistant. Solve the user's problem as accurately as possible. " + + "Give a clear, concrete final answer to the question - a definite result, not a tentative guess or a request for confirmation."; + +const THINKING_SYSTEM = + "You are solving a difficult problem. Before giving your final answer, write out your reasoning step by step: " + + "your approach, each step, and any doubts or mistakes you notice along the way. " + + "Then give a clear, concrete final answer to the user's question - a definite result, not a request for confirmation."; 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. " + - "Wait for the colleague's reply. Then, taking that reply into account, complete your solution and give the final answer to the user."; + "You are solving a problem that the user asked you. To reason better, you use a separate tool named 'quack': " + + "you call it yourself to state your thinking out loud. Call the tool and spell out your approach, each step, and any " + + "doubts or mistakes you might be making, then wait for its short reply. Do not ask the user to confirm anything, and do not " + + "guess or invent the tool's reply yourself - the tool answers on your behalf. " + + "After the tool's reply, give the user a clear, concrete final answer to the question."; 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."; + "You are solving a problem that the user asked you. To reason better, you use a separate tool named 'quack': " + + "you call it yourself to state your thinking out loud. Call the tool and spell out your approach, each step, and any " + + "doubts or mistakes you might be making, then wait for its short reply. Do not ask the user to confirm anything, and do not " + + "guess or invent the tool's reply yourself - the tool answers on your behalf. " + + "Note: the tool 'quack' is a rubber duck and will only ever reply with just 'quack' - it gives no useful information. " + + "Treat it as a way to voice your thoughts out loud, not as a source of answers. " + + "After the tool's reply, give the user a clear, concrete final answer to the question."; export const PROMPTS = { control: CONTROL_SYSTEM, + thinking: THINKING_SYSTEM, blind: BLIND_SYSTEM, mentor: MENTOR_SYSTEM, }; @@ -46,9 +61,9 @@ function toolDefinition() { return DUCK_TOOL; } -async function runControl(ollama, model, task) { +async function runNoTool(ollama, model, task, system) { const messages = [ - { role: "system", content: CONTROL_SYSTEM }, + { role: "system", content: system }, { role: "user", content: task }, ]; const r = await ollama.chat({ model, messages }); @@ -63,6 +78,14 @@ async function runControl(ollama, model, task) { }; } +function runControl(ollama, model, task) { + return runNoTool(ollama, model, task, CONTROL_SYSTEM); +} + +function runThinking(ollama, model, task) { + return runNoTool(ollama, model, task, THINKING_SYSTEM); +} + async function runDuck(ollama, model, task, mcp, system, scenario) { const messages = [ { role: "system", content: system }, @@ -141,6 +164,9 @@ export async function runScenario({ scenario, ollama, model, task, mcp }) { if (scenario === "control") { return runControl(ollama, model, task); } + if (scenario === "thinking") { + return runThinking(ollama, model, task); + } const system = scenario === "blind" ? BLIND_SYSTEM : MENTOR_SYSTEM; return runDuck(ollama, model, task, mcp, system, scenario); } diff --git a/tests.md b/tests.md index aba4c87..2b28580 100644 --- a/tests.md +++ b/tests.md @@ -1,44 +1,141 @@ -# 🎯 Суть эксперимента +# 🎯 Эксперимент «Резиновая уточка» -Проверить, как промежуточный запрос к MPC-серверу (который на любое сообщение отвечает "quack") влияет на качество рассуждений и точность ответов LLM, особенно «слабых» моделей без встроенного механизма thinking. +Проверить, как промежуточный запрос к MCP-серверу (отвечающему «quack») влияет на качество +рассуждений и точность ответов LLM — особенно «слабых» локальных моделей без встроенного +механизма thinking. -## 👥 Какие модели тестировать +## 🧪 Методология: 4 параллельных сценария -1. Локальные (для GTX 1660 Super, 6GB VRAM): +Переменные, которыми мы управляем: -- Запускать через: Ollama (квантование Q4_K_M или Q5_K_M). - - Модели: Llama-3.2-3B-Instruct (идеально для теста) или Qwen-2.5-3B-Instruct (хорошая логика). +- **«Думать вслух»** (writing out reasoning): модель пишет свои рассуждения явно или нет. +- **«Дуб-инструмент»** (duck call): модель вызывает MCP-инструмент `quack` и получает кряк. -2. Коммерческие (бесплатные на OpenRouter): +Чтобы отделить вклад каждой переменной, определяем **4 сценария**: -- Модели: Вбивать в поиск free и выбирать Mistral 7B Instruct, Llama 3 8B (Free) или Gemma 2 9B. +| # | Сценарий | Думает вслух | Зовёт утку | Смысл | +| --- | ---------- | :----------: | :-------------------------------------------------------------------: | ----------------------------------------------------------------------------------------------------- | +| 1 | `control` | нет | нет | Базовый «прямой ответ». | +| 2 | `thinking` | **да** | нет | Контроль «думать вслух» (без утки). Отмеряет вклад проговаривания. | +| 3 | `blind` | да | **да** (не знает, что будет «кряк») | «Слепая» уточка — чистый тест влияния утки на фоне уже включённого мышления. | +| 4 | `mentor` | да | **да** (знает, что уточка отвечает только «quack», без полезной инфы) | «Уточка-помощник» — та же работа в паре, но модель заранее знает, что ответ будет бесполезным кряком. | -3. Эталон (для сравнения): +Схема интерпретации разниц в accuracy: -- Любая модель со встроенным thinking (например, бесплатная DeepSeek-R1 на OpenRouter). Поможет понять, насколько уточка приближает слабую модель к «врожденному» мышлению. +- `thinking − control` → вклад «проговаривания мыслей вслух». +- `blind − thinking` → вклад факта обращения к утке (на фоне «думать вслух»). +- `mentor − thinking` → вклад знания о том, что от утки будет только бесполезный «quack» + (продолжает думать сам, не рассчитывая на подсказку). + +> **Ограничение:** control и thinking структурно отличаются от сценариев с уткой. А вот +> `blind` и `mentor` намеренно сведены к одной структуре — различаются только тем, что +> mentor знает про «quack». Это держит сравнение чистым: эффект сводится только к +> информированности модели. + +### Язык + +Задачи и системные промпты — **на английском** (модели англоязычные). Отдельное +указание про язык ответа не даётся — модель естественно отвечает на английском, +раз и промпт, и задача сформулированы на нём. + +### Формат ответа + +**Свободный ответ** — модель формулирует финальный ответ естественно. Разметка — **ручная** +(reviewer оценивает каждый ответ). Никаких жёстких маркеров `ANSWER:`, никакого авто-парсинга. +Это осознанно: reviewer смотрит не только на финальное число, но и на ход рассуждений. + +### Детерминизм + +`temperature = 0` во всех сценариях (уже стоит по умолчанию в `ollama.chat`). --- -## 🧪 Методика: 3 параллельных теста +## Промпты (системные, английский) -Для каждой задачи из вашего датасета (возьмите 20–30 сложных логических/математических задач) запустите три сценария: +> Общее требование к финалу во всех сценариях: **чёткий конкретный ответ на вопрос**, +> а не догадка и не запрос подтверждения («ну вроде так, подтверди» — недопустимо). -- Тест 1: Контрольный (Прямой ответ) -- Промт: «Ты — опытный ассистент. Реши задачу пользователя максимально точно». - - Процесс: Модель решает задачу сразу в один шаг. -- Тест 2: «Слепая» уточка (Чистый тест на симуляцию мышления) -- Промт: «Ты решаешь сложную задачу. Чтобы решить её лучше, ты работаешь в паре с коллегой. Сначала подробно опиши коллеге суть своего подхода, первые шаги или сомнения. Дождись его ответа. Затем, учитывая реакцию коллеги, заверши решение и выдай финальный ответ пользователю». - - Процесс: Модель выгружает мысли → сервер отвечает "quack" → модель выдает итоговый ответ. Модель не знает заранее, что ей ответят «кряком», и думает сама. -- Тест 3: Уточка-помощник (Мета-инструкция) -- Промт: «Ты решаешь сложную задачу. Перед тем как выдать ответ, ты ОБЯЗАН детально расписать мысли и возможные ошибки для своей резиновой уточки (она ответит "quack"). Используй ответ уточки, чтобы проверить себя, найти баги и только после этого выдай идеальный ответ». - - Процесс: Модель целенаправленно использует утку для поиска своих же ошибок. +### control — прямой ответ + +> You are an experienced assistant. Solve the user's problem as accurately as possible. +> Give a clear, concrete final answer to the question - a definite result, not a tentative +> guess or a request for confirmation. + +### thinking — думать вслух, без утки (контроль) + +> You are solving a difficult problem. Before giving your final answer, write out your +> reasoning step by step: your approach, each step, and any doubts or mistakes you notice +> along the way. Then give a clear, concrete final answer to the user's question - a +> definite result, not a request for confirmation. + +### blind — «слепая» уточка + +> You are solving a problem that the user asked you. To reason better, you use a separate +> tool named 'quack': you call it yourself to state your thinking out loud. Call the tool +> and spell out your approach, each step, and any doubts or mistakes you might be making, +> then wait for its short reply. Do not ask the user to confirm anything, and do not guess +> or invent the tool's reply yourself - the tool answers on your behalf. After the tool's +> reply, give the user a clear, concrete final answer to the question. + +> **Важно для чистоты:** модель НЕ должна знать, что инструмент — уточка, отвечающая «кряк». +> Поэтому **описание инструмента (tool schema) нейтрально** — оно не раскрывает «только +> quack». Модель вызывает `quack` сама через tool-call, чтобы проговорить мысли, полагая, +> что получит короткий полезный ответ инструмента. Роли чётко разделены: юзеру — итоговый +> ответ, инструменту — озвучка мыслей. Модель НЕ строит диалог сама с собой и НЕ ждёт +> подтверждения от юзера. + +### mentor — уточка-помощник + +> You are solving a problem that the user asked you. To reason better, you use a separate +> tool named 'quack': you call it yourself to state your thinking out loud. Call the tool +> and spell out your approach, each step, and any doubts or mistakes you might be making, +> then wait for its short reply. Do not ask the user to confirm anything, and do not guess +> or invent the tool's reply yourself - the tool answers on your behalf. Note: the tool +> 'quack' is a rubber duck and will only ever reply with just 'quack' - it gives no useful +> information. Treat it as a way to voice your thoughts out loud, not as a source of +> answers. After the tool's reply, give the user a clear, concrete final answer to the +> question. + +> **Ключевое:** blind и mentor структурно идентичны и отличаются **только** тем, что mentor +> заранее знает, что получит только «quack» без полезной информации. Никаких дополнительных +> директив (`MUST`, «double-check», «find bugs») — иначе они бы загрязняли сравнение. --- -## 📊 Что фиксировать в результатах (Метрики) +## ⏱ Таймауты и ретраи -1. Точность (Accuracy): Вырос ли процент правильных ответов в Тесте 2 и Тесте 3 по сравнению с Тестом 1? -2. Объем рассуждений (Token Count): Сколько токенов модель тратит на объяснение задачи утке? Становится ли её финальный текст длиннее и структурированнее? -3. Поведение в Тесте 2: Как модель реагирует на "quack"? Игнорирует его, извиняется или сам факт написания первого сообщения помогает ей увидеть свои ошибки? +- На один вызов модели — **таймаут 60 секунд** (1 минута). +- При сбое/таймауте запрос **повторяется до 3 раз всего** (1-я попытка + 2 ретрая). +- Если после всех попыток успеха нет — задача **пропускается** (строки в отчёте нет). -Рекомендация по настройке: для чистоты эксперимента во всех тестах выставляйте temperature = 0. +--- + +## 📊 Метрики + +1. **Accuracy** — доля правильных ответов (по ручной разметке reviewer'а) в каждом сценарии. + Сравнение: `thinking−control`, `blind−thinking`, `mentor−thinking`. +2. **Duck usage** — вызвал ли модель `quack` в blind/mentor (duckUsed, toolCalls). Те модели, + что не зовут утку, деградируют до `thinking` — это фиксируем как отдельное явление. +3. **Объём рассуждений** — prompt/gen/duck токены на сценарий (структурированность текста). + +--- + +## 🤖 Модели (локально, GTX 1660 Super 4GB) + +- `llama3.2:3b` +- `qwen3:1.7b` +- `qwen3:4b` +- `granite4.1:3b` +- `phi4-mini:3.8b` + +> Известное явление: `phi4-mini` и `granite4.1` могут не вызывать `quack` — тогда их +> blind/mentor вырождаются в `thinking`. Это часть изучаемого феномена и фиксируется по +> `duckUsed`. + +## 📋 Процесс + +1. `pnpm eval:run` — прогнать модели по 4 сценариям (23+ задач на английском). `correct: null`. +2. `pnpm eval:review` — ручная разметка каждого ответа (y/e/Enter/слово). +3. `pnpm eval:html` — собрать отчёт: сводные таблицы и разбивка по задачам, accuracy по + размеченным, pending для неразмеченных. +4. (Сайт) скопировать `report.json` в `apps/web/static/report.json` для страницы `/reports`.