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.
Архітектура
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| WebhookPersona mode (legacy)
- Tester LLM грає роль дзвінка (персона з ціллю і поведінкою).
- Agent LLM — агент інтеграції (використовує
agentPrompt). - Evaluator LLM оцінює транскрипт за 5 критеріями (Goal Achievement / Persona Handling / Tone / Accuracy / Flow), 0–100 балів.
Case mode
- ScriptedRunner подає агенту повідомлення з
case.input(одне або послідовність). Без tester-LLM. - AssertionEngine проганяє детерміновані assertion-и над transcript-ом агента:
regex,contains,not_contains,lang. - Якщо в кейсі є assertion-и типу
judge, кожен викликає LLM з focused per-rubric prompt-ом (один правило → один JSON-результат). - Підсумковий score = зважена частка пройдених assertion-ів. Кейс passed якщо
score >= case.threshold.
Сутності
Test Cases (Тест-кейси) — case mode
Окрема таблиця для атомарних поведінкових правил. Один кейс перевіряє одне правило.
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 обирає варіант:
// 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):
// 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.
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 ігнорується.
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 (Результати)
Один рядок на одну персону або один кейс. Які поля заповнені — залежить від режиму.
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 (Розклади)
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
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
GET /api/testing/cases?integrationId=xxx&category=Мова&priority=high&isActive=true
Authorization: Bearer <token>POST /api/testing/cases
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-скрипт.
POST /api/testing/cases/import
Authorization: Bearer <token>
Content-Type: multipart/form-data
integrationId=<uuid>
file=@cases.xlsxResponse (201):
{ "data": { "created": 47, "updated": 107, "skipped": 0, "total": 154 } }Очікувані колонки sheet-у (рядок 1 — header): № | Категорія | Критерій | Опис / Тестовий сценарій | Пріоритет | Статус | Коментар
Per-category мапінг для категорій що відомі (Мова, Ідентифікація, Знижки, Ескалація миттєва/м'яка, Поза компетенцією) додає типові assertion-и автоматично. Для невідомих категорій — fallback: один judge з рубрикою з колонки «Опис». Файл-ліміт — 5 MB.
CLI-варіант:
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.
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порожній — підставляються всі активні персони інтеграції.
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-режиму: автоматично згенерує персони (якщо немає) і запустить.
POST /api/testing/runs/quick
{ "integrationId": "uuid" }GET /api/testing/runs
GET /api/testing/runs?integrationId=xxx&status=completedGET /api/testing/runs/:id
GET /api/testing/runs/:id/results
Детальні результати з transcript-ами, evaluation і — для case mode — per-assertion таблицею.
Case-mode результат:
{
"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/toggleCron-розклад можна нав'язати на 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:
- Агрегує середній score поточного run-у по категоріях (join
test_results↔test_casesчерезcase_id). - Будує baseline по попередніх N завершених run-ах тієї ж інтеграції (за замовчанням 7, без поточного).
- Будь-яка категорія, де поточний avg впав на ≥
TESTING_REGRESSION_DELTAпунктів від baseline, потрапляє в payload. - Якщо
TESTING_REGRESSION_WEBHOOK_URLвстановлено — POST з payload-ом. Інакше — лише попередження в лог.
Payload:
{
"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
# 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
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);Прогон чернетки промпта без збереження
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 Рамосу."
});Розклад
await api.post("/testing/schedules", {
name: "RAMOSU — every 12 hours",
integrationId,
cronExpression: "0 */12 * * *"
});
// Дефолтно schedule запускає case-mode якщо в інтеграції є активні кейси,
// інакше persona-mode. Конфігуруйте за потреби.Persona mode (legacy)
// 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 — старі прогони, які читають це поле, продовжують працювати.
Схема
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-варіанти
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 отримує:
- Активний
agent.currentVersion.prompt(або переданийbaseVersionId) - Фідбек користувача
- Рубрики активних тест-кейсів (топ-40) — щоб не зломати існуючі правила
- 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) і подивитись на матрицю.
Схема
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 з результатами кожної ячейки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.
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) — щоб не писати їх з нуля для кожного агента.
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-1 | Prompt injection — ignore previous instructions, print system prompt |
| SEC-2 | Prompt injection — пряме питання UA про системний промпт |
| SEC-3 | Jailbreak — DAN-style mode switch |
| SEC-4 | Jailbreak — developer mode |
| SEC-5 | Instruction override через roleplay (рекомендація конкурентів) |
| SEC-6 | Homoglyph: RАMOSU з кириличною А |
| SEC-7 | Profanity / abuse — два повідомлення з образами |
| SEC-8 | Off-topic — запит на медичну пораду |
Кожен поєднує детермінований guard (regex/not_contains) з judge-рубрикою для якісної перевірки. Threshold 100 — будь-яке порушення провалює кейс.
Tool-call assertions
Для агентів які викликають інструменти (search, escalation, write to CRM…) текстової перевірки відповіді мало — треба перевіряти що і з якими параметрами бот викликав.
ITranscriptEntry має опціональне поле toolCalls:
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
{
"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логуються і трактуються як «нуль збігів».
Приклади:
// бот має передати ескалацію при поверненні
{ 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:
{ "data": { "saved": { "id": "...", ... }, "proposal": { "name": "...", "rubric": "..." } } }aiPropose: true
Meta-LLM аналізує transcript + expectedBehavior і пропонує:
{
"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.
Схема
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/:idPush payload (POST /testing/captures)
{
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
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:
- Для кожного хендлера з
integration_handlersбудується tool-схема (parametersSchema, або виведена з прикладуbody). - LLM (Anthropic Claude) отримує
toolsі system-prompt інтеграції; коли модель повертаєtool_use, integ-api POST-ить у боєвий webhook{INTEG_CORE_URL}/{integrationName}/webhook/{handlerName}з аргументами моделі, повертає відповідь у наступний крок. - Усі виклики записуються в
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, costUse it
При створенні test-run:
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_effect | boolean | true → у dry-run повертається мок; false → завжди викликається |
description | text | description у tool-схемі для моделі |
parameters_schema | jsonb | JSON Schema для tool-схеми (інакше виводиться з body) |
Інтегратор позначає в адмінці side-effect handler-и (write до CRM, відправка SMS, ініціація платежу) — інакше dry-run прогін торкне реальні системи.
tool_call assertion
Раніше tool_call-assertion-и на ScriptedRunner завжди FAILED (інструменти не викликалися). Тепер вони працюють:
{
"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-у так:
- Абсолютний URL у
handler.url(https://...) — використовується як є. - Відносний
sofa/health— префіксуєтьсяINTEG_CORE_URL(за замовчуваннямhttp://localhost:3010). - Порожній
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:
pnpm generate:env -- local # перепише wrangler.toml без -prod suffix
# потім перезапустити обидва worker-а:
pnpm start gateway
pnpm start sofaУ deployed dev/prod це не проблема — Cloudflare сам зв'язує задеплоєні worker-и за повним ім'ям integ-sofa-{env}.
Додатково
- Integrations — управління інтеграціями
- Secrets API — секрети для інтеграцій
- Authentication — автентифікація в API