feat: update ai models tests
This commit is contained in:
+84
-24
@@ -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%"
|
||||
}
|
||||
]
|
||||
|
||||
+128
-12
@@ -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() {
|
||||
|
||||
@@ -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 = `
|
||||
<td class="pm-num" data-sc="control" style="color:${pctColor(acc("control"))}">${acc("control")}%</td>
|
||||
<td class="pm-num" data-sc="thinking" style="color:${pctColor(acc("thinking"))}">${acc("thinking")}%</td>
|
||||
<td class="pm-num" data-sc="blind" style="color:${pctColor(acc("blind"))}">${acc("blind")}%</td>
|
||||
<td class="pm-num" data-sc="mentor" style="color:${pctColor(acc("mentor"))}">${acc("mentor")}%</td>
|
||||
<td class="pm-num">${g.accuracy}%</td>
|
||||
@@ -68,11 +71,12 @@ function summaryTable(report) {
|
||||
<tr>
|
||||
<th>Модель</th>
|
||||
<th>Контроль</th>
|
||||
<th>Мысли</th>
|
||||
<th>Слепая</th>
|
||||
<th>Помощник</th>
|
||||
<th>Средняя acc</th>
|
||||
<th>Размечено</th>
|
||||
<th>Уток</th>
|
||||
<th>Утка</th>
|
||||
<th>Вызовов</th>
|
||||
<th>ср. токены out</th>
|
||||
</tr>
|
||||
@@ -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) {
|
||||
</select>
|
||||
<button data-filter="all" class="active">Все сценарии</button>
|
||||
<button data-filter="control">Без утки</button>
|
||||
<button data-filter="thinking">Мысли</button>
|
||||
<button data-filter="blind">Слепая утка</button>
|
||||
<button data-filter="mentor">Утка-помощник</button>
|
||||
<span style="margin-left:auto;color:var(--muted);font-size:12px" id="count"></span>
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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}`
|
||||
|
||||
+54
-22
@@ -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] = {
|
||||
|
||||
+37
-11
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user