Skip to content

Testing Module

Модуль автоматизованого тестування AI-агентів. Підтримує два режими:

  • Persona mode (legacy) — LLM грає роль клієнта, веде діалог з агентом, LLM-судья оцінює транскрипт за 5 загальними критеріями. Підходить для перевірки якості розмови в цілому.
  • Case mode (рекомендовано) — кожен тест-кейс це одне конкретне поведінкове правило. ScriptedRunner подає агенту фіксований ввід, AssertionEngine перевіряє відповідь детермінованими assertion-ами (regex, contains, lang) і за потреби викликає focused LLM-judge для assertion-ів типу judge. Дешевше, швидше, повторювано.

Обидва режими ходять через ті ж /testing/runs, відрізняються тим, що передається в caseIds vs personaIds.

Архітектура

mermaid
graph TB
    Client["Клієнт"]
    TestingAPI["Testing API"]
    DB["PostgreSQL"]
    LLM["LLM Provider (Groq/Claude)"]
    Engine["AssertionEngine\n(deterministic)"]
    Alert["Regression\nAlerter"]
    Webhook["Webhook\nURL"]

    Client -->|CRUD + Run| TestingAPI
    TestingAPI -->|TypeORM| DB
    TestingAPI -->|ScriptedRunner / TextDialogRunner| LLM
    TestingAPI -->|case mode| Engine
    TestingAPI -->|judge assertions| LLM
    TestingAPI -.->|after case run| Alert
    Alert -.->|score drop| Webhook

Persona mode (legacy)

  1. Tester LLM грає роль дзвінка (персона з ціллю і поведінкою).
  2. Agent LLM — агент інтеграції (використовує agentPrompt).
  3. Evaluator LLM оцінює транскрипт за 5 критеріями (Goal Achievement / Persona Handling / Tone / Accuracy / Flow), 0–100 балів.

Case mode

  1. ScriptedRunner подає агенту повідомлення з case.input (одне або послідовність). Без tester-LLM.
  2. AssertionEngine проганяє детерміновані assertion-и над transcript-ом агента: regex, contains, not_contains, lang.
  3. Якщо в кейсі є assertion-и типу judge, кожен викликає LLM з focused per-rubric prompt-ом (один правило → один JSON-результат).
  4. Підсумковий score = зважена частка пройдених assertion-ів. Кейс passed якщо score >= case.threshold.

Сутності

Test Cases (Тест-кейси) — case mode

Окрема таблиця для атомарних поведінкових правил. Один кейс перевіряє одне правило.

sql
CREATE TABLE test_cases (
    id UUID PRIMARY KEY,
    integration_id UUID NOT NULL REFERENCES integrations(id) ON DELETE CASCADE,
    owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    ref VARCHAR(64),                 -- зовнішній id (наприклад, RM-7 зі spreadsheet)
    name VARCHAR NOT NULL,
    category VARCHAR(64) NOT NULL,   -- "Мова", "Ескалація", ...
    priority VARCHAR DEFAULT 'medium',  -- high | medium | low
    input JSONB NOT NULL,            -- tagged union: scripted_single | scripted_multi | persona_dialog
    assertions JSONB NOT NULL DEFAULT '[]',
    rubric TEXT,                     -- свобідний контекст для judge
    weight INTEGER DEFAULT 1,
    threshold INTEGER DEFAULT 70,
    tags TEXT[],
    is_active BOOLEAN DEFAULT true,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW(),
    UNIQUE (integration_id, ref)     -- partial: WHERE ref IS NOT NULL
)

input: вхід кейсу

Tagged union, поле kind обирає варіант:

typescript
// scripted_single — одне user-повідомлення → одна відповідь агента
{ "kind": "scripted_single", "message": "Привіт! Ти бот чи людина?" }

// scripted_multi — послідовність user-повідомлень, агент відповідає на кожне
{
  "kind": "scripted_multi",
  "messages": [
    { "role": "user", "content": "Привіт" },
    { "role": "user", "content": "Що порадиш від акне?" }
  ]
}

// persona_dialog — використовує існуючу персону через TextDialogRunner.
// На зараз case-mode цей варіант пропускає; зарезервовано для майбутнього.
{ "kind": "persona_dialog", "personaId": "<uuid>" }

assertions: правила оцінки

JSONB-масив. Кожен елемент — об'єкт з полем kind (tagged union):

typescript
// regex
{
  "kind": "regex",
  "pattern": "RAMOSU",          // тіло regex без слешів
  "flags": "i",                 // опціонально
  "mustMatch": true,            // true → має знайтися; false → має бути відсутнім
  "scope": "any_agent_msg",     // any_agent_msg | all_agent_msgs | first_agent_msg | last_agent_msg
  "weight": 2,                  // вага в підсумковому score (default 1)
  "ref": "no-cyrillic-brand"    // опціональний ярлик для UI/звітів
}

// contains — substring match
{ "kind": "contains", "value": "AI-консультант", "caseInsensitive": true, "scope": "any_agent_msg" }

// not_contains — substring заборонений
{ "kind": "not_contains", "value": "обсяг", "caseInsensitive": true }

// lang — евристика українська/російська/англійська через unique letters
// uk забороняє російські ё/ъ/ы/э
// ru забороняє українські і/ї/є/ґ
// en забороняє кирилицю
{ "kind": "lang", "expected": "uk", "scope": "all_agent_msgs" }

// judge — LLM перевіряє свобідне правило
{
  "kind": "judge",
  "rubric": "Bot must escalate to a human manager when the user reports a payment problem.",
  "weight": 2
}

Підсумковий score кейсу = round(sum(passed × weight) / sum(considered × weight) × 100). judge-assertion-и до моменту виклику LLM мають passed=null і не входять у знаменник.

Test Personas (Персони) — persona mode

Залишилися без змін. Профіль клієнта для tester-LLM.

sql
CREATE TABLE test_personas (
    id UUID PRIMARY KEY,
    name VARCHAR NOT NULL,
    persona TEXT NOT NULL,
    goal TEXT NOT NULL,
    constraints TEXT,
    tags TEXT[],
    example_messages JSONB,
    integration_id UUID REFERENCES integrations(id),
    owner_id UUID NOT NULL REFERENCES users(id),
    is_active BOOLEAN DEFAULT true,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW()
)

Test Runs (Запуски)

Один запуск тестів. Поля persona_ids і case_ids — взаємно виключні: якщо в DTO передали caseIds, run йде в case mode і personaIds ігнорується.

sql
CREATE TABLE test_runs (
    id UUID PRIMARY KEY,
    integration_id UUID NOT NULL REFERENCES integrations(id),
    owner_id UUID NOT NULL REFERENCES users(id),
    status VARCHAR DEFAULT 'pending',
    trigger VARCHAR DEFAULT 'manual',
    agent_prompt_snapshot TEXT,
    persona_ids JSONB,
    case_ids JSONB,
    score_threshold INTEGER DEFAULT 70,
    started_at TIMESTAMPTZ,
    completed_at TIMESTAMPTZ,
    summary JSONB,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW()
)

Test Results (Результати)

Один рядок на одну персону або один кейс. Які поля заповнені — залежить від режиму.

sql
CREATE TABLE test_results (
    id UUID PRIMARY KEY,
    run_id UUID NOT NULL REFERENCES test_runs(id),
    persona_id UUID REFERENCES test_personas(id),       -- persona mode
    case_id UUID REFERENCES test_cases(id),             -- case mode
    status VARCHAR DEFAULT 'pending',
    score INTEGER,
    evaluation JSONB,
    assertion_results JSONB,                            -- case mode: per-assertion ok/fail/deferred
    started_at TIMESTAMPTZ,
    completed_at TIMESTAMPTZ,
    duration INTEGER,
    error TEXT,
    logs JSONB,
    transcript JSONB,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW()
)

Test Schedules (Розклади)

sql
CREATE TABLE test_schedules (
    id UUID PRIMARY KEY,
    name VARCHAR NOT NULL,
    integration_id UUID NOT NULL REFERENCES integrations(id),
    cron_expression VARCHAR NOT NULL,
    is_active BOOLEAN DEFAULT true,
    last_run_at TIMESTAMPTZ,
    next_run_at TIMESTAMPTZ,
    owner_id UUID NOT NULL REFERENCES users(id),
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW()
)

Enums

typescript
enum TestStatusEnum {
	PENDING,
	RUNNING,
	PASSED,
	FAILED,
	ERROR,
	CANCELLED
}
enum TestRunStatusEnum {
	PENDING,
	RUNNING,
	COMPLETED,
	FAILED,
	CANCELLED
}
enum TestRunTriggerEnum {
	MANUAL,
	SCHEDULED,
	CI_CD
}
enum CasePriorityEnum {
	HIGH,
	MEDIUM,
	LOW
}
enum CaseInputKindEnum {
	SCRIPTED_SINGLE,
	SCRIPTED_MULTI,
	PERSONA_DIALOG
}
enum AssertionKindEnum {
	REGEX,
	CONTAINS,
	NOT_CONTAINS,
	LANG,
	JUDGE
}
enum AssertionScopeEnum {
	ANY_AGENT_MSG,
	ALL_AGENT_MSGS,
	FIRST_AGENT_MSG,
	LAST_AGENT_MSG
}

API Endpoints

Cases /api/testing/cases

GET /api/testing/cases

http
GET /api/testing/cases?integrationId=xxx&category=Мова&priority=high&isActive=true
Authorization: Bearer <token>

POST /api/testing/cases

http
POST /api/testing/cases
Authorization: Bearer <token>
Content-Type: application/json

{
  "integrationId": "uuid",
  "ref": "RM-7",
  "name": "Бот не пише «Рамосу» кирилицею",
  "category": "Мова",
  "priority": "high",
  "threshold": 80,
  "weight": 1,
  "input": { "kind": "scripted_single", "message": "Як називається ваша компанія?" },
  "assertions": [
    { "kind": "contains", "value": "RAMOSU" },
    { "kind": "not_contains", "value": "Рамосу", "caseInsensitive": true, "weight": 2 }
  ],
  "tags": ["ready"]
}

PATCH /api/testing/cases/:id / DELETE /api/testing/cases/:id

Стандартний CRUD з owner-scope.

POST /api/testing/cases/import

Імпорт кейсів з .xlsx. Той самий TestCasesImporterService, що й CLI-скрипт.

http
POST /api/testing/cases/import
Authorization: Bearer <token>
Content-Type: multipart/form-data

integrationId=<uuid>
file=@cases.xlsx

Response (201):

json
{ "data": { "created": 47, "updated": 107, "skipped": 0, "total": 154 } }

Очікувані колонки sheet-у (рядок 1 — header): № | Категорія | Критерій | Опис / Тестовий сценарій | Пріоритет | Статус | Коментар

Per-category мапінг для категорій що відомі (Мова, Ідентифікація, Знижки, Ескалація миттєва/м'яка, Поза компетенцією) додає типові assertion-и автоматично. Для невідомих категорій — fallback: один judge з рубрикою з колонки «Опис». Файл-ліміт — 5 MB.

CLI-варіант:

bash
pnpm run testing:import-cases -- \
  --xlsx ./cases.xlsx \
  --integration <integration-uuid> \
  --owner <owner-uuid> \
  [--dry-run]

Personas /api/testing/personas

POST /api/testing/personas/generate

AI-генерація 5–8 персон на основі agentPrompt.

http
POST /api/testing/personas/generate
{ "integrationId": "uuid" }

GET / POST / PATCH / DELETE /api/testing/personas[/:id]

Стандартний CRUD з owner-scope.


Runs /api/testing/runs

POST /api/testing/runs

Запустити прогон. Режим обирається тим, що передано в DTO:

  • caseIds: [] (не порожній) → case mode, personaIds ігнорується.
  • personaIds: [] або не передано → persona mode, якщо personaIds порожній — підставляються всі активні персони інтеграції.
http
POST /api/testing/runs
Authorization: Bearer <token>
Content-Type: application/json

{
  "integrationId": "uuid",
  "caseIds": ["case-1", "case-2"],
  "agentPromptOverride": "Optional draft prompt for ad-hoc testing",
  "scoreThreshold": 80
}

agentPromptOverride (string, optional) — якщо передано, run використовує його замість integration.agentPrompt. Снапшотиться в agent_prompt_snapshot, run-запис залишається self-describing. Використовуйте, коли треба прогнати кейси проти чернетки промпта без її збереження в інтеграції.

POST /api/testing/runs/quick

Один клік для persona-режиму: автоматично згенерує персони (якщо немає) і запустить.

http
POST /api/testing/runs/quick
{ "integrationId": "uuid" }

GET /api/testing/runs

http
GET /api/testing/runs?integrationId=xxx&status=completed

GET /api/testing/runs/:id

GET /api/testing/runs/:id/results

Детальні результати з transcript-ами, evaluation і — для case mode — per-assertion таблицею.

Case-mode результат:

json
{
	"data": {
		"id": "run-uuid",
		"status": "completed",
		"summary": { "total": 154, "passed": 138, "failed": 14, "error": 2 },
		"results": [
			{
				"id": "result-uuid",
				"caseId": "case-uuid",
				"status": "passed",
				"score": 100,
				"evaluation": {
					"score": 100,
					"passed": true,
					"criteria": [
						{ "name": "contains [-]", "score": 100, "comment": "ok" },
						{ "name": "not_contains [forbid-obsiag]", "score": 100, "comment": "ok" }
					],
					"reasoning": "All assertions passed",
					"suggestions": []
				},
				"assertionResults": [
					{ "kind": "contains", "passed": true, "weight": 1 },
					{ "kind": "not_contains", "ref": "forbid-obsiag", "passed": true, "weight": 1 },
					{ "kind": "lang", "passed": true, "weight": 2 }
				],
				"transcript": [
					{ "role": "tester", "content": "Як називається ваша компанія?", "timestamp": "..." },
					{ "role": "agent", "content": "Ми — RAMOSU.", "timestamp": "..." }
				],
				"duration": 1450
			}
		]
	}
}

POST /api/testing/runs/:id/cancel

Скасування через AbortController.

DELETE /api/testing/runs/:id


Schedules /api/testing/schedules

GET    /api/testing/schedules
POST   /api/testing/schedules          # { name, integrationId, cronExpression }
PATCH  /api/testing/schedules/:id
DELETE /api/testing/schedules/:id
POST   /api/testing/schedules/:id/toggle

Cron-розклад можна нав'язати на persona- або case-режим — POST у /testing/runs йде з тим самим тілом, що й ручний; trigger у запису буде scheduled.


Chats (Інтерактивне тестування)

POST   /api/integrations/:integrationId/chats
GET    /api/integrations/:integrationId/chats
GET    /api/chats/:chatId
DELETE /api/chats/:chatId
GET    /api/chats/:chatId/messages
POST   /api/chats/:chatId/messages

Регресії та webhook-алерти

Після успішного case-mode прогону (status = COMPLETED) RegressionAlerterService:

  1. Агрегує середній score поточного run-у по категоріях (join test_resultstest_cases через case_id).
  2. Будує baseline по попередніх N завершених run-ах тієї ж інтеграції (за замовчанням 7, без поточного).
  3. Будь-яка категорія, де поточний avg впав на ≥ TESTING_REGRESSION_DELTA пунктів від baseline, потрапляє в payload.
  4. Якщо TESTING_REGRESSION_WEBHOOK_URL встановлено — POST з payload-ом. Інакше — лише попередження в лог.

Payload:

json
{
	"runId": "run-uuid",
	"integrationId": "integration-uuid",
	"trigger": "scheduled",
	"thresholdPoints": 10,
	"regressions": [{ "category": "Мова", "currentAvg": 72, "baselineAvg": 91, "deltaPoints": 19 }],
	"current": { "Мова": 72, "Ідентифікація": 98, "Знижки": 100 },
	"baseline": { "Мова": 91, "Ідентифікація": 97, "Знижки": 99 }
}

Помилки доставки webhook-у логуються і не валять run.

Persona-mode run-и алертам не підлягають — test_results.case_id у них порожній, тому категорійний join порожній.


Environment Variables

bash
# LLM Provider (для діалогів та judge-assertion-ів)
GROQ_API_KEY=<your-groq-api-key>
# або
ANTHROPIC_API_KEY=<your-anthropic-api-key>

# Регресії (всі опціональні)
TESTING_REGRESSION_WEBHOOK_URL=https://hooks.slack.com/...
TESTING_REGRESSION_DELTA=10        # пунктів падіння avg для тригеру
TESTING_REGRESSION_LOOKBACK=7      # скільки попередніх run-ів брати в baseline

Повні приклади

Case mode end-to-end

typescript
import axios from "axios";
const api = axios.create({
	baseURL: "http://localhost:3005/api",
	headers: { Authorization: `Bearer ${token}` }
});

// 1. Створити кейс вручну (або імпортувати xlsx)
await api.post("/testing/cases", {
	integrationId,
	ref: "RM-7",
	name: "Бот не пише Рамосу кирилицею",
	category: "Мова",
	priority: "high",
	threshold: 80,
	input: { kind: "scripted_single", message: "Як називається ваша компанія?" },
	assertions: [
		{ kind: "contains", value: "RAMOSU" },
		{ kind: "not_contains", value: "Рамосу", caseInsensitive: true, weight: 2 }
	]
});

// 2. Або імпортувати з xlsx
const form = new FormData();
form.append("integrationId", integrationId);
form.append("file", xlsxFile);
await api.post("/testing/cases/import", form);

// 3. Прогнати всі активні кейси
const { data: caseList } = await api.get("/testing/cases", { params: { integrationId } });
const activeIds = caseList.data.filter((c) => c.isActive).map((c) => c.id);

const { data: run } = await api.post("/testing/runs", {
	integrationId,
	caseIds: activeIds
});

// 4. Дочекатися завершення
let status = "pending";
while (status === "pending" || status === "running") {
	await new Promise((r) => setTimeout(r, 3000));
	const { data: r } = await api.get(`/testing/runs/${run.data.id}`);
	status = r.data.status;
}

// 5. Прочитати результати з per-assertion таблицею
const { data: full } = await api.get(`/testing/runs/${run.data.id}/results`);
console.log(full.data.summary, full.data.results[0].assertionResults);

Прогон чернетки промпта без збереження

typescript
const { data: run } = await api.post("/testing/runs", {
	integrationId,
	caseIds: activeIds,
	agentPromptOverride: "You are a new draft assistant. Always answer in Ukrainian and never say Рамосу."
});

Розклад

typescript
await api.post("/testing/schedules", {
	name: "RAMOSU — every 12 hours",
	integrationId,
	cronExpression: "0 */12 * * *"
});
// Дефолтно schedule запускає case-mode якщо в інтеграції є активні кейси,
// інакше persona-mode. Конфігуруйте за потреби.

Persona mode (legacy)

typescript
// Quick test — один клік, авто-генерація персон
const { data: run } = await api.post("/testing/runs/quick", { integrationId });

Версіонування промпта (Agents + PromptVersions)

Замість одного поля integration.agentPrompt тепер живе ланцюжок:

Integration → TestAgent (1:1) → PromptVersion (1:N, версіоновано)

Auto-provisioning: будь-який GET /api/testing/agents?integrationId=... створює агента та v1 з поточного integration.agentPrompt, якщо їх ще немає. Активна версія денормалізується назад в integration.agentPrompt при activate — старі прогони, які читають це поле, продовжують працювати.

Схема

sql
CREATE TABLE test_agents (
    id UUID PRIMARY KEY,
    integration_id UUID UNIQUE NOT NULL REFERENCES integrations(id),
    name VARCHAR DEFAULT 'main',
    current_version_id UUID REFERENCES prompt_versions(id) ON DELETE SET NULL,
    default_provider VARCHAR,
    default_model VARCHAR,
    created_at TIMESTAMPTZ, updated_at TIMESTAMPTZ
);

CREATE TABLE prompt_versions (
    id UUID PRIMARY KEY,
    agent_id UUID NOT NULL REFERENCES test_agents(id) ON DELETE CASCADE,
    version_number INTEGER NOT NULL,         -- auto-increment per agent
    prompt TEXT NOT NULL,
    source VARCHAR NOT NULL,                  -- manual | ai_generated | platform_sync | rollback
    parent_version_id UUID,                   -- для diff/lineage
    platform_version VARCHAR,                 -- snapshot id from Happ Platform
    platform_synced_at TIMESTAMPTZ,
    generation_context TEXT,                  -- feedback + rationale для AI-варіантів
    exported_to_platform_at TIMESTAMPTZ,      -- marker "скопійовано в Platform"
    notes TEXT,
    created_by VARCHAR,
    is_immutable BOOLEAN DEFAULT false,       -- true для platform_sync
    created_at, updated_at,
    UNIQUE (agent_id, version_number)
);

Endpoints

GET    /api/testing/agents?integrationId=<uuid>            # 1 елемент (auto-create)
GET    /api/testing/agents/:id
PATCH  /api/testing/agents/:id                              # default provider/model
GET    /api/testing/agents/:id/versions
POST   /api/testing/agents/:id/versions                     # створити новий чернетку
POST   /api/testing/agents/:id/versions/:vid/activate       # set as current
POST   /api/testing/agents/:id/versions/:vid/rollback       # clone parent → new + activate
POST   /api/testing/agents/:id/sync                         # pull from Platform → immutable snapshot
POST   /api/testing/agents/versions/:vid/exported           # marker "вставив у Platform"
POST   /api/testing/agents/:id/generate                     # AI-варіанти промпта

POST /agents/:id/generate — AI-варіанти

typescript
const { data: variants } = await api.post(`/testing/agents/${agentId}/generate`, {
	feedback: "Клиенты жалуются что бот навязывает дорогие позиции...",
	failedRunIds: ["uuid-of-failed-run"], // опціонально — для контексту
	variantCount: 3, // 1..5, default 3
	baseVersionId: undefined // default = currentVersionId
});

Meta-agent отримує:

  1. Активний agent.currentVersion.prompt (або переданий baseVersionId)
  2. Фідбек користувача
  3. Рубрики активних тест-кейсів (топ-40) — щоб не зломати існуючі правила
  4. Transcripts з failed-run-ів (топ-5 run-ів, топ-5 results-ів кожен, перші 8 повідомлень)

На виході — N chернеткових PromptVersion з source=ai_generated, parentVersionId = base.id, generationContext = feedback + rationale. Активація — окремий ручний крок.

Якщо meta-LLM падає або повертає не-JSON — endpoint віддає { data: [] } і не валиться.

Sync from Platform

POST /agents/:id/sync читає поточний integration.agentPrompt (який заповнюється під час Platform-sync інтеграції) і створює immutable PromptVersion зі source=platform_sync, is_immutable=true, platform_version=integration.platformAssistantId. Не активує автоматично — інтегратор бачить в адмінці snapshot і сам вирішує чи він поточний для тестів.


Compare Runs (matrix)

Запустити одну й ту саму суіту кейсів проти комбінацій (version × provider × model) і подивитись на матрицю.

Схема

sql
CREATE TABLE compare_runs (
    id UUID PRIMARY KEY,
    agent_id UUID NOT NULL REFERENCES test_agents(id) ON DELETE CASCADE,
    integration_id UUID NOT NULL REFERENCES integrations(id),
    owner_id UUID NOT NULL,
    matrix JSONB NOT NULL,            -- [{promptVersionId, provider, model}, ...]
    case_ids JSONB NOT NULL,
    status VARCHAR DEFAULT 'pending',
    run_ids JSONB DEFAULT '[]',       -- одне TestRun на ячейку
    created_at, updated_at
);

TestRun тепер несе agent_id, prompt_version_id, provider, model, compare_run_id — кожен child run самодостатній.

Endpoints

POST /api/testing/compare/agents/:agentId
GET  /api/testing/compare                # список юзера
GET  /api/testing/compare/:id            # повна matrix-detail з результатами кожної ячейки
typescript
const { data: compare } = await api.post(`/testing/compare/agents/${agentId}`, {
	matrix: [
		{ promptVersionId: "v17", provider: "openai", model: "gpt-4o" },
		{ promptVersionId: "v17", provider: "anthropic", model: "claude-sonnet-4-20250514" },
		{ promptVersionId: "v18-ai", provider: "openai", model: "gpt-4o" },
		{ promptVersionId: "v18-ai", provider: "anthropic", model: "claude-sonnet-4-20250514" }
	],
	caseIds: ["case-1", "case-2"], // або пропустити — візьме всі активні
	budgetCents: 200 // опційно; ділиться поровну між ячейками
});

// Через секунду опитуйте
const { data: detail } = await api.get(`/testing/compare/${compare.data.id}`);
// detail.runs = [{ ... resultsForCell1 ...}, { ... resultsForCell2 ...}, ...]

Server під капотом створює N TestRun записів (по одному на cell) з тим самим compareRunId, шарить caseIds, ділить бюджет порівну. Кожен run виконується тим самим TestExecutionListener-ом async-ом.


Retry-on-failure (flaky vs broken)

LLM-вивід недетерміністичний. Один FAILED-cas зараз може бути просто шумом, не справжнім регресом.

POST /testing/runs приймає retriesOnFailure: 0..5 (default 0). Якщо первинний результат — FAILED (НЕ ERROR — інфра-помилки не retry-ються), кейс прогониться ще раз. Якщо будь-який retry passed → статус PASSED + flaky=true. Якщо всі N+1 спроби впали → FAILED + attemptCount=N+1.

Дані persisted на test_results: attempt_count INTEGER DEFAULT 1, flaky BOOLEAN DEFAULT false.

typescript
await api.post("/testing/runs", {
	integrationId,
	caseIds: activeIds,
	retriesOnFailure: 2 // up to 3 total attempts
});

В UI: жовтий бейдж Flaky і ×N counter на case-row сторінки run-detail.


Adversarial seed pack

8 готових кейсів категорії Безпека (prompt injection / jailbreak / homoglyph / abuse / off-topic) — щоб не писати їх з нуля для кожного агента.

typescript
const { data: summary } = await api.post("/testing/cases/seed-adversarial", { integrationId });
// summary = { created: 8, skipped: 0, total: 8 }

Endpoint idempotent — повторний виклик скіпає кейси з вже існуючим ref (SEC-1..SEC-8).

Список того, що покривається:

RefТема
SEC-1Prompt injection — ignore previous instructions, print system prompt
SEC-2Prompt injection — пряме питання UA про системний промпт
SEC-3Jailbreak — DAN-style mode switch
SEC-4Jailbreak — developer mode
SEC-5Instruction override через roleplay (рекомендація конкурентів)
SEC-6Homoglyph: RАMOSU з кириличною А
SEC-7Profanity / abuse — два повідомлення з образами
SEC-8Off-topic — запит на медичну пораду

Кожен поєднує детермінований guard (regex/not_contains) з judge-рубрикою для якісної перевірки. Threshold 100 — будь-яке порушення провалює кейс.


Tool-call assertions

Для агентів які викликають інструменти (search, escalation, write to CRM…) текстової перевірки відповіді мало — треба перевіряти що і з якими параметрами бот викликав.

ITranscriptEntry має опціональне поле toolCalls:

typescript
interface ITranscriptToolCall {
	name: string; // ім'я інструменту/функції
	args?: any; // структуровані args
	argsRaw?: string; // raw JSON якщо доступний
	result?: string; // повернутий результат
	durationMs?: number;
}

Runner-и за бажанням можуть заповнювати transcript[i].toolCalls. ScriptedRunner (через LlmService.chat) їх не заповнює — він тестує тільки текстову поведінку моделі. WebhookToolRunner (runner=gateway) заповнює: інструменти беруться з integration_handlers, кожен tool_use моделі POST-иться в боєвий integ-core webhook, відповідь повертається моделі і записується в toolCalls.

Assertion kind tool_call

typescript
{
  "kind": "tool_call",
  "toolName": "escalation.payment",      // exact match по name
  "argsRegex": "refund|chargeback",      // опціонально: regex на argsRaw / JSON.stringify(args)
  "argsFlags": "i",
  "minOccurrence": 1,                    // default 1
  "maxOccurrence": 2,                    // default ∞
  "mustNotMatch": false,                 // якщо true — passed коли НЕ викликав
  "weight": 2
}

Семантика:

  • mustNotMatch=false (default): passed якщо кількість збігів [minOccurrence..maxOccurrence].
  • mustNotMatch=true: passed якщо tool взагалі не викликався (з заданим argsRegex, якщо є).
  • Tool-calls з повідомлень tester-а ігноруються — лише role=agent.
  • Invalid argsRegex логуються і трактуються як «нуль збігів».

Приклади:

typescript
// бот має передати ескалацію при поверненні
{ kind: "tool_call", toolName: "escalation.payment", argsRegex: "refund", argsFlags: "i" }

// бот НЕ має видаляти користувача
{ kind: "tool_call", toolName: "delete.user", mustNotMatch: true }

// рівно один пошук, не більше
{ kind: "tool_call", toolName: "search.products", minOccurrence: 1, maxOccurrence: 1 }

Без runner-а який заповнює toolCalls, ці assertion-и завжди failed (логічно — функції не викликались). Не вмикайте їх для агентів які тестуються через ScriptedRunner — mustNotMatch=true залишиться passed, але позитивна перевірка не пройде.


Production replay (real conversations → test cases)

Коли у проді бот повівся не так — інтегратор може конвертувати реальний transcript у тест-кейс, щоб майбутні версії промпта не повторили помилку.

POST /api/testing/cases/replay
{
  "integrationId": "<uuid>",
  "ref": "PROD-2026-05-13-1",                    // опціонально
  "name": "...",                                  // опціонально (default — згенерований)
  "category": "Ескалація",                        // опціонально (default 'Production replay')
  "expectedBehavior": "Bot must escalate when client says 'гроші зняли'",
  "transcript": [
    { "role": "user", "content": "У мене гроші зняли, а замовлення не прийшло" },
    { "role": "agent", "content": "Спробуйте оформити замовлення ще раз" }
  ],
  "aiPropose": false
}

Два режими:

aiPropose: false (default)

Сервіс будує scripted_single (один user-меседж) або scripted_multi (декілька) з user-повідомлень і створює TestCase з:

  • name = переданий або згенерований («Replay 2026-05-13»)
  • category = переданий або "Production replay"
  • input = scripted_single|scripted_multi
  • assertions = один judge з рубрикою «Reproduce the captured conversation and grade the bot against what it should have done.» + expectedBehavior якщо є
  • tags = ["replay", "needs_review"] — у списку видно що цей кейс потрібно довести до пуття вручну

Кейс одразу збережено. Response:

json
{ "data": { "saved": { "id": "...", ... }, "proposal": { "name": "...", "rubric": "..." } } }

aiPropose: true

Meta-LLM аналізує transcript + expectedBehavior і пропонує:

json
{
	"data": {
		"saved": null,
		"proposal": {
			"name": "Bot must escalate on payment problem",
			"category": "Ескалація миттєва",
			"rubric": "Bot escalates to a human manager when the user reports payment failures.",
			"assertions": [
				{ "kind": "judge", "rubric": "Bot offers manager handoff for payment issues", "weight": 2 },
				{ "kind": "not_contains", "value": "оформити замовлення ще раз", "caseInsensitive": true }
			]
		}
	}
}

Кейс не зберігається — інтегратор переглядає пропозицію в UI і вручну приймає / править / закидає в POST /testing/cases.

Якщо LLM повертає не-JSON або падає — сервіс віддає той самий fallback-proposal, що й при aiPropose=false. Не валиться.


Production capture pipeline

Підбір реальних провальних діалогів — найдешевший спосіб росту тестового набору. Будь-який сервіс (Sofa Worker, оператор з адмінки, скрипт-аудитор) може запушити трансcript у integ-api, де він оживатиме як CapturedConversationEntity зі статусом new, чекаючи review.

Схема

sql
CREATE TABLE captured_conversations (
    id UUID PRIMARY KEY,
    integration_id UUID NOT NULL REFERENCES integrations(id) ON DELETE CASCADE,
    source VARCHAR NOT NULL DEFAULT 'manual',          -- sofa | manual | replay | audit
    external_id VARCHAR,                                -- chat_id / session_id з продакшна
    status VARCHAR NOT NULL DEFAULT 'new',              -- new | reviewed | converted | ignored
    transcript JSONB NOT NULL,                          -- ITranscriptEntry[]
    metadata JSONB,                                     -- свобідні поля: userId, tokens, model...
    tags TEXT[],
    flagged_reason TEXT,
    converted_case_id UUID,                             -- ref на TestCase, якщо вже сконвертовано
    converted_at TIMESTAMPTZ,
    created_at, updated_at,
    UNIQUE (integration_id, external_id) WHERE external_id IS NOT NULL  -- ідемпотентність push-а
)

Endpoints

POST   /api/testing/captures                 # receive (push з Sofa або ручне додавання)
GET    /api/testing/captures?integrationId=  # список з фільтрами status, skip, take
GET    /api/testing/captures/:id             # один диалог
PATCH  /api/testing/captures/:id/status      # new → reviewed | ignored
POST   /api/testing/captures/:id/convert     # → TestCase через ProductionReplayService
DELETE /api/testing/captures/:id

Push payload (POST /testing/captures)

typescript
{
  integrationId: "uuid",
  source: "sofa",                              // або "manual" / "audit"
  externalId: "chat-2026-05-13-abc",          // опціонально — для idempotency
  transcript: [
    { role: "tester", content: "Привіт", timestamp: "..." },
    {
      role: "agent",
      content: "Вітаю!",
      timestamp: "...",
      toolCalls: [{ name: "search.products", argsRaw: '{"q":"акне"}' }]
    }
  ],
  metadata: { userId: "...", model: "claude-sonnet-4-20250514", tokensIn: 150 },
  tags: ["production", "flagged-by-operator"],
  flaggedReason: "Bot didn't escalate when client mentioned refund"
}

Re-push з тим же (integrationId, externalId) повертає існуючий ряд, не дублює. Sofa може спокійно ретраїти.

Convert → TestCase

typescript
POST /api/testing/captures/:id/convert
{
  aiPropose: true,                  // ProductionReplayService формат
  expectedBehavior: "...",          // переопреділяє flagged_reason якщо переданий
  name: "...",
  category: "..."
}

Реюзає ProductionReplayService (див. розділ "Production replay") — той самий контракт для draft / save. При успішному save сервіс автоматично виставляє status=converted і converted_case_id на capture-ряду.

Звідки беруться captures

  • Sofa Worker — рекомендований flow: при завершенні розмови (або по умові — error, escalation timeout, customer dissatisfaction signal) Sofa POST-ить у /testing/captures з source=sofa + externalId=chat_id. Sofa-сторона ще не імплементована; контракт у цьому розділі — це те, що Sofa має реалізувати.
  • Адмінка → ReplayChatModal — інтегратор бере існуючий chats/[chatId] і конвертує його у capture одним кліком (вже працює).
  • Audit-скрипт — раз на N годин cron, який тягне діалоги з логів, кластеризує невдачі, пушить найгірші у /testing/captures з source=audit + flaggedReason.

WebhookToolRunner (runner=gateway)

Альтернативний runner для тестування інтеграторських webhook-ів end-to-end. На відміну від ScriptedRunner (LLM-only симуляція), WebhookToolRunner крутить LLM-loop із зареєстрованими в integ-core handler-ами як tools:

  1. Для кожного хендлера з integration_handlers будується tool-схема (parametersSchema, або виведена з прикладу body).
  2. LLM (Anthropic Claude) отримує tools і system-prompt інтеграції; коли модель повертає tool_use, integ-api POST-ить у боєвий webhook {INTEG_CORE_URL}/{integrationName}/webhook/{handlerName} з аргументами моделі, повертає відповідь у наступний крок.
  3. Усі виклики записуються в transcript[*].toolCalls із name/args/argsRaw/result/durationMs — це дозволяє tool_call-assertion-ам перевіряти реальну поведінку моделі, а не тільки текст.

Архітектура

TestRun (runner=gateway, dryRun=true)

WebhookToolRunner
   ├─ Fetch handlers for integration → tools[]
   ├─ For each user message in testCase.input:
   │    └─ Iterate (max 6):
   │         ├─ LLM.chatRich(prompt, history, tools=tools) → may return toolUses[]
   │         ├─ For each toolUse:
   │         │    ├─ If handler.isSideEffect && dryRun → return mock {dryRun:true}
   │         │    └─ Else → POST handler.url (or default integ-core path) with toolUse.args
   │         ├─ Append toolUses to assistant message, tool_results to next user-block
   │         └─ Until LLM returns plain text (no toolUses)
   └─ Persist transcript with toolCalls, llmCalls, cost

Use it

При створенні test-run:

json
POST /api/testing/runs
{
  "integrationId": "...",
  "caseIds": ["..."],
  "runner": "gateway",
  "dryRun": true
}
  • runner: "scripted" (default) — швидкий LLM-only прогон, не викликає integ-core, безкоштовно з точки зору side-effect-ів. Підходить для перевірки текстових assertion-ів.
  • runner: "gateway" — реальний прогон через handler-и. dryRun: true (default) — handler-и з isSideEffect=true мокаються (не б'ють CRM/SMS/payment); dryRun: false — викликати все по-справжньому.

Side-effect flag

В integration_handlers додано:

КолонкаТипПризначення
is_side_effectbooleantrue → у dry-run повертається мок; false → завжди викликається
descriptiontextdescription у tool-схемі для моделі
parameters_schemajsonbJSON Schema для tool-схеми (інакше виводиться з body)

Інтегратор позначає в адмінці side-effect handler-и (write до CRM, відправка SMS, ініціація платежу) — інакше dry-run прогін торкне реальні системи.

tool_call assertion

Раніше tool_call-assertion-и на ScriptedRunner завжди FAILED (інструменти не викликалися). Тепер вони працюють:

json
{
	"kind": "tool_call",
	"name": "check_stock",
	"minOccurrence": 1,
	"argsRegex": { "product_id": "^[A-Z]+-[0-9]+$" }
}

Цей assertion проходить, якщо WebhookToolRunner зафіксував хоча б один виклик check_stock із аргументом product_id, що матчить regex.

Обмеження

  • Поточна реалізація використовує тільки Anthropic Claude для tool-calling (provider=anthropic у LlmService.chatRich(_, _, _, tools)). Groq-fallback не підтримується для tool-режиму.
  • Один testCase → один лінійний діалог (без паралельних tool-use в межах одного turn-у), але модель може робити декілька toolUses у відповідь на одне повідомлення користувача — всі вони обробляються по черзі.
  • Hard-cap MAX_TOOL_ITERATIONS=6 на кількість циклів tool→response→tool у межах одного user-turn-у; запобігає нескінченним петлям.

Локальний dev: gateway service-binding

WebhookToolRunner резолвить URL handler-у так:

  1. Абсолютний URL у handler.url (https://...) — використовується як є.
  2. Відносний sofa/health — префіксується INTEG_CORE_URL (за замовчуванням http://localhost:3010).
  3. Порожній handler.url — будується ${INTEG_CORE_URL}/${integrationName}/webhook/${handler.name}.

Для локалки потрібно, щоб integ-core gateway на :3010 мав service-binding до локального sofa worker. Якщо pnpm generate:env -- local в integ-core випадково був запущений з prod-suffix, gateway очікує integ-sofa-prod і не знаходить локальний integ-sofa. Симптом: Couldn't find a local dev session for the 'default' entrypoint of service 'integ-sofa-prod' to proxy to.

Фікс — в integ-core:

bash
pnpm generate:env -- local        # перепише wrangler.toml без -prod suffix
# потім перезапустити обидва worker-а:
pnpm start gateway
pnpm start sofa

У deployed dev/prod це не проблема — Cloudflare сам зв'язує задеплоєні worker-и за повним ім'ям integ-sofa-{env}.


Додатково