tewip-ai 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/LICENSE +202 -0
  2. package/QUICKSTART.md +178 -0
  3. package/README.md +308 -0
  4. package/TEAM-GUIDE.md +234 -0
  5. package/dist/cli/tewip.js +117 -0
  6. package/dist/engine/autofix/autofix.js +63 -0
  7. package/dist/engine/autofix/candidates.js +33 -0
  8. package/dist/engine/autofix/locate.js +78 -0
  9. package/dist/engine/corrections.js +66 -0
  10. package/dist/engine/dataset.js +41 -0
  11. package/dist/engine/decide.js +130 -0
  12. package/dist/engine/device-errors.js +45 -0
  13. package/dist/engine/drift-rules.js +98 -0
  14. package/dist/engine/driver-errors.js +103 -0
  15. package/dist/engine/engine.js +76 -0
  16. package/dist/engine/error-parse.js +150 -0
  17. package/dist/engine/history.js +105 -0
  18. package/dist/engine/import/blob.js +165 -0
  19. package/dist/engine/import/junit.js +235 -0
  20. package/dist/engine/import/xml.js +104 -0
  21. package/dist/engine/llm/anthropic.js +59 -0
  22. package/dist/engine/llm/client.js +139 -0
  23. package/dist/engine/llm/context.js +141 -0
  24. package/dist/engine/llm/llm-diagnose.js +48 -0
  25. package/dist/engine/llm/prompt.js +40 -0
  26. package/dist/engine/llm/providers.js +54 -0
  27. package/dist/engine/llm/usage.js +69 -0
  28. package/dist/engine/pipeline.js +33 -0
  29. package/dist/engine/quality/frameworks.js +181 -0
  30. package/dist/engine/quality/grounding.js +203 -0
  31. package/dist/engine/quality/model.js +17 -0
  32. package/dist/engine/quality/plan-command.js +34 -0
  33. package/dist/engine/quality/plan-render.js +38 -0
  34. package/dist/engine/quality/plan.js +119 -0
  35. package/dist/engine/quality/write-command.js +146 -0
  36. package/dist/engine/quality/write-verify.js +140 -0
  37. package/dist/engine/quality/write.js +144 -0
  38. package/dist/engine/rules.js +174 -0
  39. package/dist/engine/signature.js +23 -0
  40. package/dist/engine/similarity.js +76 -0
  41. package/dist/engine/snapshot.js +56 -0
  42. package/dist/engine/timing-rules.js +188 -0
  43. package/dist/engine/trace/dom.js +92 -0
  44. package/dist/engine/trace/trace-summary.js +87 -0
  45. package/dist/engine/trace/zip.js +53 -0
  46. package/dist/eval/harvest/gh.js +21 -0
  47. package/dist/eval/harvest/harvest.js +117 -0
  48. package/dist/mcp/github-report.js +50 -0
  49. package/dist/mcp/server.js +80 -0
  50. package/dist/mcp/tools.js +124 -0
  51. package/dist/outlets/design-debt.js +22 -0
  52. package/dist/outlets/items.js +94 -0
  53. package/dist/outlets/view.js +63 -0
  54. package/dist/outlets/views/architecture.js +77 -0
  55. package/dist/outlets/views/developer.js +59 -0
  56. package/dist/outlets/views/digest.js +66 -0
  57. package/dist/outlets/views/infra.js +49 -0
  58. package/dist/outlets/views/quality.js +108 -0
  59. package/dist/outlets/views/release.js +33 -0
  60. package/dist/outlets/views/sdet.js +29 -0
  61. package/dist/reporters/run-context.js +20 -0
  62. package/dist/reporters/store.js +46 -0
  63. package/dist/reporters/triage-reporter.js +101 -0
  64. package/dist/reporters/types.js +4 -0
  65. package/dist/scripts/explain.js +58 -0
  66. package/package.json +81 -0
@@ -0,0 +1,139 @@
1
+ // LLM provider access. The engine only sees LlmClient, so the provider or model can change
2
+ // (or be compared in the eval) without touching diagnosis code.
3
+ import { createHash } from 'node:crypto';
4
+ import { existsSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { appendJsonl, DATA_DIR, PROJECT_ROOT, readJsonl } from '../../reporters/store.js';
7
+ import { truncate } from '../signature.js';
8
+ import { clientFor, modelChoiceFromEnv } from './providers.js';
9
+ import { recordCacheHit, recordCall, recordRefusal } from './usage.js';
10
+ export const DEFAULT_MODEL = 'gpt-5.4-mini';
11
+ export const DEFAULT_REASONING = 'low';
12
+ const REQUEST_TIMEOUT_MS = 90_000;
13
+ const MAX_ATTEMPTS = 3;
14
+ /** TEWIP_LLM_CACHE moves the cache, e.g. into the project the command line runs in. */
15
+ const CACHE_FILE = process.env.TEWIP_LLM_CACHE || join(DATA_DIR, 'llm-cache.jsonl');
16
+ let envLoaded = false;
17
+ /** Loads <project>/.env once. Variables already set in the environment win. */
18
+ export function loadDotEnv() {
19
+ if (envLoaded)
20
+ return;
21
+ envLoaded = true;
22
+ const file = join(PROJECT_ROOT, '.env');
23
+ if (existsSync(file))
24
+ process.loadEnvFile(file);
25
+ }
26
+ export function openAiClient({ apiKey, model, reasoningEffort, baseUrl = 'https://api.openai.com/v1' }) {
27
+ return {
28
+ model: reasoningEffort ? `${model} (reasoning ${reasoningEffort})` : model,
29
+ async complete({ system, user, schemaName, schema }) {
30
+ const body = JSON.stringify({
31
+ model,
32
+ messages: [
33
+ { role: 'system', content: system },
34
+ { role: 'user', content: user },
35
+ ],
36
+ response_format: { type: 'json_schema', json_schema: { name: schemaName, strict: true, schema } },
37
+ ...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}),
38
+ });
39
+ const startedAt = Date.now();
40
+ for (let attempt = 1;; attempt++) {
41
+ let res;
42
+ try {
43
+ res = await fetch(`${baseUrl}/chat/completions`, {
44
+ method: 'POST',
45
+ headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
46
+ body,
47
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
48
+ });
49
+ }
50
+ catch (error) {
51
+ if (attempt >= MAX_ATTEMPTS)
52
+ throw new Error(`OpenAI request failed: ${String(error)}`);
53
+ await sleep(2000 * attempt);
54
+ continue;
55
+ }
56
+ if (res.ok) {
57
+ const json = (await res.json());
58
+ const u = json.usage;
59
+ recordCall(Date.now() - startedAt, { prompt: u?.prompt_tokens, completion: u?.completion_tokens, cached: u?.prompt_tokens_details?.cached_tokens }, model);
60
+ const message = json.choices?.[0]?.message;
61
+ if (message?.refusal)
62
+ throw new Error(`model refused: ${truncate(message.refusal, 200)}`);
63
+ if (typeof message?.content !== 'string')
64
+ throw new Error('model returned no content');
65
+ return message.content;
66
+ }
67
+ const detail = truncate(await res.text(), 300);
68
+ if ((res.status !== 429 && res.status < 500) || attempt >= MAX_ATTEMPTS)
69
+ throw new Error(`OpenAI API ${res.status}: ${detail}`);
70
+ await sleep(2000 * attempt);
71
+ }
72
+ },
73
+ };
74
+ }
75
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
76
+ /**
77
+ * Caches responses in data/llm-cache.jsonl (gitignored), keyed by model + full request, so
78
+ * re-running the eval costs nothing and any prompt or context change misses the cache.
79
+ */
80
+ export function cachedClient(inner, file = CACHE_FILE) {
81
+ let cache;
82
+ const entries = () => (cache ??= new Map(readJsonl(file).map((e) => [e.key, e.response])));
83
+ return {
84
+ model: inner.model,
85
+ async complete(request) {
86
+ const key = createHash('sha256').update(JSON.stringify([inner.model, request])).digest('hex');
87
+ const hit = entries().get(key);
88
+ if (hit !== undefined) {
89
+ recordCacheHit();
90
+ return hit;
91
+ }
92
+ const response = await inner.complete(request);
93
+ entries().set(key, response);
94
+ appendJsonl(file, [{ key, model: inner.model, response, at: new Date().toISOString() }]);
95
+ return response;
96
+ },
97
+ };
98
+ }
99
+ /**
100
+ * The configured client, or null when the LLM layer is off (no OPENAI_API_KEY, or
101
+ * TRIAGE_LLM=off). Settings: TRIAGE_LLM_MODEL, TRIAGE_LLM_REASONING, TRIAGE_LLM_CACHE=off.
102
+ */
103
+ export function llmFromEnv() {
104
+ loadDotEnv();
105
+ if (process.env.TRIAGE_LLM === 'off')
106
+ return null;
107
+ const choice = modelChoiceFromEnv('triage');
108
+ if (!choice)
109
+ return null;
110
+ const client = clientFor(choice);
111
+ // The budget sits inside the cache: a cached answer never reaches it, so it is free, and only
112
+ // real calls to the model count against TRIAGE_LLM_MAX_CALLS.
113
+ const budget = Number(process.env.TRIAGE_LLM_MAX_CALLS);
114
+ const capped = Number.isFinite(budget) && budget > 0 ? budgetedClient(client, budget) : client;
115
+ return process.env.TRIAGE_LLM_CACHE === 'off' ? capped : cachedClient(capped);
116
+ }
117
+ /** Raised when the per-process call budget is used up; the pipeline keeps the rules' answer. */
118
+ export class LlmBudgetError extends Error {
119
+ }
120
+ /**
121
+ * A cap on how many model calls one process may make (TRIAGE_LLM_MAX_CALLS). Cache hits are
122
+ * free and do not count. A guardrail for CI: a huge failed run cannot run up an unbounded bill.
123
+ */
124
+ export function budgetedClient(inner, maxCalls) {
125
+ // Counted when the call starts, not when it returns: several requests run in parallel, and a
126
+ // budget that only counts finished calls would let a burst overshoot it.
127
+ let started = 0;
128
+ return {
129
+ model: inner.model,
130
+ async complete(request) {
131
+ if (started >= maxCalls) {
132
+ recordRefusal();
133
+ throw new LlmBudgetError(`AI call budget reached (TRIAGE_LLM_MAX_CALLS=${maxCalls}); keeping the rules' answer`);
134
+ }
135
+ started++;
136
+ return inner.complete(request);
137
+ },
138
+ };
139
+ }
@@ -0,0 +1,141 @@
1
+ // Builds the structured summary sent to the model for one failure. Everything is clipped
2
+ // and summarised; raw logs, traces and full DOM never leave this function.
3
+ import { existsSync, readFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { PROJECT_ROOT } from '../../reporters/store.js';
6
+ import { errorShape, truncate } from '../signature.js';
7
+ import { errorContextPath, pageSnapshotYaml } from '../snapshot.js';
8
+ import { summarizeTrace } from '../trace/trace-summary.js';
9
+ const CALL_LOG = /\n\s*Call log:/;
10
+ const MAX_ARIA_LINES = 60;
11
+ const ERROR_SCENARIO = /\b(rejects?|rejected|errors?|fail(s|ed|ures?)?|retr(y|ies|ying)|offline|timeouts?|invalid|denied|unauthori[sz]ed|forbidden|not allowed|unavailable|exhausting|transient|broken)\b/gi;
12
+ /**
13
+ * The words in a test's name that show it deliberately provokes a failure (e.g. "reports a
14
+ * server failure", "recovers from a transient upload failure"), or null. In such tests a
15
+ * failed request is the scenario under test, not evidence of a broken environment.
16
+ */
17
+ export function errorScenarioOf(testId) {
18
+ const title = testId.split(' > ').slice(-2).join(' > ');
19
+ const words = [...new Set((title.match(ERROR_SCENARIO) ?? []).map((w) => w.toLowerCase()))];
20
+ return words.length > 0 ? `test name mentions: ${words.join(', ')}` : null;
21
+ }
22
+ function errorPart(message) {
23
+ const [head, log = ''] = message.split(CALL_LOG);
24
+ const callLog = [...new Set(log.split('\n').map((l) => l.trim().replace(/^-\s*/, '')).filter(Boolean))];
25
+ return { message: truncate(head.trim(), 800), callLog: callLog.slice(0, 10).map((l) => truncate(l, 150)) };
26
+ }
27
+ function markdownBlock(markdown, heading) {
28
+ const block = markdown.match(new RegExp(`# ${heading}\\s*\`\`\`\\w*\\r?\\n([\\s\\S]*?)\`\`\``))?.[1];
29
+ return block === undefined ? null : block.split(/\r?\n/).filter((l) => l.trim() !== '');
30
+ }
31
+ /**
32
+ * Comments are removed from test code: in the synthetic suite they state the intended
33
+ * failure outright, which would turn the eval into reading the answer key.
34
+ */
35
+ function stripComment(line) {
36
+ return line.replace(/(^|\s)\/\/.*$/, '').replace(/\/\*.*?\*\//g, '').trimEnd();
37
+ }
38
+ function failingCode(markdown) {
39
+ const lines = markdownBlock(markdown, 'Test source');
40
+ if (!lines)
41
+ return null;
42
+ const at = lines.findIndex((l) => /^>\s*\d+\s*\|/.test(l));
43
+ const window = at < 0 ? lines : lines.slice(Math.max(0, at - 5), at + 4);
44
+ return window
45
+ .filter((l) => !/^\s*\|\s*\^/.test(l)) // caret line repeats the error message
46
+ .map((l) => {
47
+ const [gutter, ...code] = l.split('|');
48
+ return `${gutter}|${stripComment(code.join('|'))}`;
49
+ })
50
+ .filter((l) => l.split('|')[1]?.trim());
51
+ }
52
+ /** Lines around the failing line of the record's test (comments removed), or null. */
53
+ export function failingCodeOf(record) {
54
+ const file = errorContextPath(record);
55
+ return file ? failingCode(readFileSync(file, 'utf8')) : null;
56
+ }
57
+ function traceSummary(record) {
58
+ const rel = record.attachments.find((a) => a.name === 'trace')?.path;
59
+ if (!rel)
60
+ return { unavailable: 'no trace attached to this failure' };
61
+ const file = join(PROJECT_ROOT, rel);
62
+ if (!existsSync(file))
63
+ return { unavailable: 'trace file no longer on disk' };
64
+ try {
65
+ return summarizeTrace(file);
66
+ }
67
+ catch (error) {
68
+ return { unavailable: `trace could not be read: ${truncate(String(error), 120)}` };
69
+ }
70
+ }
71
+ /**
72
+ * Separates "flaky on the same code" from "broken by this commit": an overall failure rate
73
+ * that mixes commits reads as flakiness even when every attempt on this commit failed.
74
+ */
75
+ export function historyPattern(h) {
76
+ if (h.currentRunFailures < h.currentRunAttempts)
77
+ return 'another attempt of the same code passed in this run: the failure is not deterministic';
78
+ if (h.sameCommitAttempts === 0)
79
+ return 'commit unknown: cannot compare with other commits';
80
+ if (h.sameCommitFailures < h.sameCommitAttempts) {
81
+ return `this commit both failed and passed (${h.sameCommitFailures}/${h.sameCommitAttempts} attempts failed): the failure is not deterministic`;
82
+ }
83
+ if (h.passesOnOtherCommits > 0 && h.failingRunsOnOtherCommits >= 2) {
84
+ // Saying only "it passed on other commits" invited REAL_BUG for tests that fail on and off
85
+ // all over the project, which was the AI layer's commonest mistake on real CI data (llm-6).
86
+ return `every attempt on this commit failed (${h.sameCommitFailures}/${h.sameCommitAttempts}); the test also failed in ${h.failingRunsOnOtherCommits} run(s) on other commits and passed ${h.passesOnOtherCommits} time(s) there`;
87
+ }
88
+ if (h.passesOnOtherCommits > 0) {
89
+ const alsoFailed = h.failingRunsOnOtherCommits > 0 ? `, though it also failed in ${h.failingRunsOnOtherCommits} run on another commit` : ' and never failed there';
90
+ return `every attempt on this commit failed (${h.sameCommitFailures}/${h.sameCommitAttempts}) while the test passed ${h.passesOnOtherCommits} time(s) on other commits${alsoFailed}: something in this commit likely changed behaviour`;
91
+ }
92
+ return `every recorded attempt failed (${h.sameCommitFailures}/${h.sameCommitAttempts}) and there is no passing run to compare with`;
93
+ }
94
+ export function buildFailureContext(record, history, run, rules) {
95
+ const contextFile = errorContextPath(record);
96
+ const markdown = contextFile ? readFileSync(contextFile, 'utf8') : '';
97
+ const aria = pageSnapshotYaml(markdown)
98
+ ?.split(/\r?\n/)
99
+ .filter((l) => l.trim() !== '')
100
+ .map((l) => l.replace(/\s*\[ref=[^\]]+\]/g, '')) ?? null;
101
+ const others = run.failures.filter((f) => f.testId !== record.testId);
102
+ return {
103
+ test: {
104
+ id: record.testId,
105
+ file: record.file,
106
+ status: record.status,
107
+ durationMs: record.duration,
108
+ retry: record.retry,
109
+ exercisesErrorHandling: errorScenarioOf(record.testId),
110
+ },
111
+ error: errorPart(record.errorMessage),
112
+ failingCode: markdown ? failingCode(markdown) : null,
113
+ history: {
114
+ pattern: historyPattern(history),
115
+ sameCommitAttempts: history.sameCommitAttempts,
116
+ sameCommitFailures: history.sameCommitFailures,
117
+ passesOnOtherCommits: history.passesOnOtherCommits,
118
+ failingRunsOnOtherCommits: history.failingRunsOnOtherCommits,
119
+ runs: history.runs,
120
+ failures: history.failures,
121
+ failureRatePct: Math.round(history.failureRate * 100),
122
+ distinctErrors: history.distinctErrors,
123
+ failingStreak: history.failingStreak,
124
+ passesBeforeStreak: history.passesBeforeStreak,
125
+ failuresBeforeStreak: history.failuresBeforeStreak,
126
+ },
127
+ thisRun: {
128
+ attempts: history.currentRunAttempts,
129
+ failedAttempts: history.currentRunFailures,
130
+ anotherAttemptPassed: history.currentRunFailures < history.currentRunAttempts,
131
+ },
132
+ run: {
133
+ testsAttempted: run.testsAttempted,
134
+ testsFailed: new Set(run.failures.map((f) => f.testId)).size,
135
+ otherFailures: others.slice(0, 8).map((f) => `${f.testId}: ${truncate(errorShape(f.errorMessage), 100)}`),
136
+ },
137
+ rules: rules.evidence.map((e) => `[${e.source}] ${e.message}`),
138
+ ariaSnapshot: aria ? aria.slice(0, MAX_ARIA_LINES) : null,
139
+ trace: traceSummary(record),
140
+ };
141
+ }
@@ -0,0 +1,48 @@
1
+ // Phase-3 step for one failure the rules left UNKNOWN: ask the model, validate its answer
2
+ // against the fixed taxonomy, and return a diagnosis. Any failure keeps the rules' UNKNOWN.
3
+ import { recordFailure } from './usage.js';
4
+ import { ROOT_CAUSES } from '../../reporters/types.js';
5
+ import { truncate } from '../signature.js';
6
+ import { OUTPUT_SCHEMA, OUTPUT_SCHEMA_NAME, PROMPT_VERSION, SYSTEM_PROMPT } from './prompt.js';
7
+ /** Validates the model's JSON. Throws on anything outside the contract. */
8
+ export function parseLlmOutput(raw) {
9
+ const value = JSON.parse(raw);
10
+ if (!ROOT_CAUSES.includes(value.category))
11
+ throw new Error(`category outside the taxonomy: ${String(value.category)}`);
12
+ if (typeof value.confidence !== 'number' || !Number.isFinite(value.confidence))
13
+ throw new Error('confidence is not a number');
14
+ if (typeof value.reasoning !== 'string')
15
+ throw new Error('reasoning is not a string');
16
+ if (!Array.isArray(value.evidence) || !value.evidence.every((e) => typeof e === 'string'))
17
+ throw new Error('evidence is not a string list');
18
+ return {
19
+ category: value.category,
20
+ confidence: Math.round(Math.min(1, Math.max(0, value.confidence)) * 100) / 100,
21
+ reasoning: truncate(value.reasoning, 600),
22
+ evidence: value.evidence.slice(0, 4).map((e) => truncate(e, 300)),
23
+ };
24
+ }
25
+ export async function llmDiagnose(context, rules, client, now = new Date()) {
26
+ try {
27
+ const raw = await client.complete({ system: SYSTEM_PROMPT, user: JSON.stringify(context), schemaName: OUTPUT_SCHEMA_NAME, schema: OUTPUT_SCHEMA });
28
+ const out = parseLlmOutput(raw);
29
+ return {
30
+ category: out.category,
31
+ confidence: out.confidence,
32
+ source: 'llm',
33
+ model: client.model,
34
+ engineVersion: `${rules.engineVersion}+${PROMPT_VERSION}`,
35
+ diagnosedAt: now.toISOString(),
36
+ evidence: [
37
+ { source: 'llm', message: out.reasoning },
38
+ ...out.evidence.map((message) => ({ source: 'llm', message })),
39
+ // Keep what the rules ruled out: the LLM decision sits on top of it.
40
+ ...rules.evidence,
41
+ ],
42
+ };
43
+ }
44
+ catch (error) {
45
+ recordFailure();
46
+ return { ...rules, evidence: [...rules.evidence, { source: 'llm', message: `LLM layer failed, left UNKNOWN: ${truncate(String(error), 200)}` }] };
47
+ }
48
+ }
@@ -0,0 +1,40 @@
1
+ // Prompt and output contract for the phase-3 LLM layer. The schema is enforced by the
2
+ // provider (strict JSON schema) and validated again locally before anything is stored.
3
+ import { ROOT_CAUSES } from '../../reporters/types.js';
4
+ /** Bump whenever the prompt, schema or context format changes; it is part of the cache key. */
5
+ export const PROMPT_VERSION = 'llm-6';
6
+ export const SYSTEM_PROMPT = `You are the root-cause classifier of a Playwright test-failure triage agent.
7
+ You receive a structured summary of ONE failed test: the error, the test's run history, what the deterministic rules already checked, the test steps, the page at failure time (accessibility snapshot + notable DOM elements), failed network requests and console errors.
8
+
9
+ Classify the root cause into exactly one of these categories:
10
+ - REAL_BUG: the application under test behaves incorrectly. Examples: a wrong value is displayed; a feature or element the test needs is absent from the page and nothing equivalent exists; the app never reaches the expected state even though nothing external failed; uncaught page errors from application code.
11
+ - LOCATOR_DRIFT: the element the test wants is on the page, but the selector no longer matches it (renamed id, class, test id or text). The page summary must show an element with the same purpose.
12
+ - TIMING: the expected element or state does arrive, but not in time, or a race makes the result vary between attempts or runs. Signs: another attempt of the same code passed, intermittent history with varying errors, hard-coded waits, the element resolved but was not yet visible, stable or enabled.
13
+ - ENV: something outside the app code and the test broke: failed or 5xx/502/503 responses from a backend or third-party service, connection or DNS errors, expired auth or credentials, or many tests in the same run failing the same way.
14
+ - TEST_DATA: the test's preconditions or fixture data are wrong: missing or stale seeded data, a user or account in the wrong state, an expected value that depends on data the test did not set up, a 404 for a record the test assumed exists.
15
+
16
+ How to decide:
17
+ - Use only facts present in the summary. Do not invent page content, requests or history.
18
+ - Prefer the category the evidence directly shows over one that is merely possible.
19
+ - An element that is missing with no similar element on the page is not LOCATOR_DRIFT.
20
+ - Playwright matches accessible names by substring, so a locator can match several elements at once ("strict mode violation"). That is LOCATOR_DRIFT only when one of the matched elements is the one the test means, which usually carries the locator's name as a whole word ("Edit" -> "Edit link"). When every match only contains the text inside a longer word or phrase ("Edit" -> "Editor canvas", "Gift" -> "Gifting options"), the element the test means is absent: judge it as REAL_BUG, TIMING or TEST_DATA on the other evidence, not LOCATOR_DRIFT.
21
+ - A failed request or server error is only ENV evidence if the test did not provoke it. When "test.exercisesErrorHandling" is set, the test deliberately triggers failures (rejections, server errors, retries), so a failed request is the scenario under test: judge whether the app handled it as the test expects. If it did not, that is REAL_BUG, not ENV. A real environment outage also tends to hit unrelated tests and to pass on retry, rather than failing every attempt on one commit while other commits pass.
22
+ - Check "thisRun" first. If anotherAttemptPassed is true, the same code both failed and passed, so the cause is not deterministic: a real bug or a drifted locator would fail every attempt. Choose among TIMING, ENV and TEST_DATA.
23
+ - Then read "history.pattern", which compares this commit's attempts with other commits. If every attempt on this commit failed while other commits passed, something in this commit changed behaviour: choose among REAL_BUG, LOCATOR_DRIFT and TEST_DATA using the page summary, not TIMING. An overall failure rate that mixes different commits is not evidence of flakiness; only failing and passing on the same code is.
24
+ - The "rules" section lists what the deterministic rules already examined and why they declined; treat those as checked facts.
25
+
26
+ Confidence (0 to 1): 0.9 or higher only when the summary directly shows the cause; 0.6-0.8 when it is the most likely reading but another category remains plausible; below 0.5 when you are mostly guessing. Low confidence routes the failure to a human, so do not inflate it.
27
+
28
+ Return JSON only: the category, the confidence, a short reasoning (at most 3 sentences), and 1-4 evidence items quoting or pointing at specific facts from the summary.`;
29
+ export const OUTPUT_SCHEMA_NAME = 'triage_diagnosis';
30
+ export const OUTPUT_SCHEMA = {
31
+ type: 'object',
32
+ additionalProperties: false,
33
+ required: ['category', 'confidence', 'reasoning', 'evidence'],
34
+ properties: {
35
+ category: { type: 'string', enum: [...ROOT_CAUSES] },
36
+ confidence: { type: 'number' },
37
+ reasoning: { type: 'string' },
38
+ evidence: { type: 'array', items: { type: 'string' } },
39
+ },
40
+ };
@@ -0,0 +1,54 @@
1
+ // Which model Tewip talks to, and how. The engine only ever sees LlmClient, so a team can run
2
+ // Tewip on whatever they already pay for - or on nothing at all:
3
+ //
4
+ // TEWIP_PROVIDER=openai|anthropic|google|azure|ollama|custom (default: openai)
5
+ // TEWIP_API_KEY=... the key for that provider (not needed for a local Ollama)
6
+ // TEWIP_MODEL=... the model id, when the provider's default is not wanted
7
+ // TEWIP_BASE_URL=... any OpenAI-compatible endpoint (OpenRouter, Groq, vLLM, LM Studio)
8
+ //
9
+ // The older OPENAI_API_KEY / OPENAI_BASE_URL / TRIAGE_LLM_MODEL names still work, so nothing that
10
+ // already runs needs changing. With no key and no local server, the deterministic rules still
11
+ // answer most failures on their own.
12
+ import { anthropicClient } from './anthropic.js';
13
+ import { openAiClient } from './client.js';
14
+ export const PROVIDERS = {
15
+ openai: { defaultModel: 'gpt-5.4-mini', hint: 'create a key at platform.openai.com and set TEWIP_API_KEY' },
16
+ anthropic: { defaultModel: 'claude-haiku-4-5-20251001', hint: 'create a key at console.anthropic.com and set TEWIP_API_KEY' },
17
+ // Google publishes an OpenAI-compatible endpoint for Gemini.
18
+ google: { baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai', defaultModel: 'gemini-2.5-flash', hint: 'create a key at aistudio.google.com and set TEWIP_API_KEY' },
19
+ // Azure needs the full deployment URL, which only the team knows.
20
+ azure: { defaultModel: 'gpt-4o-mini', hint: 'set TEWIP_BASE_URL to your Azure endpoint (https://<resource>.openai.azure.com/openai/deployments/<deployment>) and TEWIP_API_KEY to its key' },
21
+ // Ollama runs models on your own machine: no account, no key, nothing leaves the computer.
22
+ ollama: { baseUrl: 'http://127.0.0.1:11434/v1', defaultModel: 'qwen3:8b', keyless: true, hint: 'install Ollama from ollama.com, then run: ollama pull qwen3:8b' },
23
+ custom: { defaultModel: 'gpt-4o-mini', hint: 'set TEWIP_BASE_URL to any OpenAI-compatible endpoint (OpenRouter, Groq, Together, vLLM, LM Studio) and TEWIP_API_KEY if it needs one' },
24
+ };
25
+ const providerOf = (name) => (name && name.toLowerCase() in PROVIDERS ? name.toLowerCase() : 'openai');
26
+ /**
27
+ * What the environment asks for, or null when no model is configured at all. `purpose` only picks
28
+ * which model setting to read: triage uses a small fast model, writing a stronger one.
29
+ */
30
+ export function modelChoiceFromEnv(purpose) {
31
+ const provider = providerOf(process.env.TEWIP_PROVIDER);
32
+ const spec = PROVIDERS[provider];
33
+ const apiKey = (process.env.TEWIP_API_KEY || process.env.OPENAI_API_KEY || '').trim();
34
+ const baseUrl = (process.env.TEWIP_BASE_URL || process.env.OPENAI_BASE_URL || spec.baseUrl || '').trim() || undefined;
35
+ if (!apiKey && !spec.keyless)
36
+ return null;
37
+ const model = purpose === 'write'
38
+ ? process.env.TEWIP_WRITE_MODEL || process.env.TEWIP_MODEL || (provider === 'openai' ? 'gpt-5.5' : spec.defaultModel)
39
+ : process.env.TRIAGE_LLM_MODEL || process.env.TEWIP_MODEL || spec.defaultModel;
40
+ const reasoningEffort = purpose === 'write' ? process.env.TEWIP_WRITE_REASONING || 'medium' : process.env.TRIAGE_LLM_REASONING || 'low';
41
+ // Reasoning effort is an OpenAI setting; other providers reject it.
42
+ return { provider, apiKey, model, baseUrl, reasoningEffort: provider === 'openai' || provider === 'azure' ? reasoningEffort : undefined };
43
+ }
44
+ /** A client for that choice: Anthropic has its own API shape, everything else speaks OpenAI's. */
45
+ export function clientFor(choice) {
46
+ if (choice.provider === 'anthropic')
47
+ return anthropicClient({ apiKey: choice.apiKey, model: choice.model, baseUrl: choice.baseUrl });
48
+ return openAiClient({ apiKey: choice.apiKey || 'local', model: choice.model, reasoningEffort: choice.reasoningEffort, baseUrl: choice.baseUrl });
49
+ }
50
+ /** What to tell someone who has no model configured, naming every way to get one. */
51
+ export function noModelHelp() {
52
+ const lines = Object.entries(PROVIDERS).map(([id, p]) => ` TEWIP_PROVIDER=${id.padEnd(9)} ${p.hint}`);
53
+ return ['No model is configured. Set TEWIP_API_KEY (and TEWIP_PROVIDER if not OpenAI):', ...lines, 'Free and private: TEWIP_PROVIDER=ollama runs a model on this machine, with no account and no data leaving it.'].join('\n');
54
+ }
@@ -0,0 +1,69 @@
1
+ const usage = { calls: 0, cacheHits: 0, failures: 0, promptTokens: 0, completionTokens: 0, totalMs: 0, refused: 0, costUsd: 0, unpriced: 0 };
2
+ /**
3
+ * USD per million tokens (input, cached input, output), standard tier, from
4
+ * https://developers.openai.com/api/docs/pricing as read on 2026-09-19. The first
5
+ * matching prefix wins, so more specific names come first.
6
+ */
7
+ const PRICES = [
8
+ ['gpt-5.6-astra', 10, 1, 50],
9
+ ['gpt-5.6-sol', 4, 0.4, 20],
10
+ ['gpt-5.6-terra', 2, 0.2, 12],
11
+ ['gpt-5.6-luna', 0.2, 0.02, 1.2],
12
+ ['gpt-5.5', 5, 0.5, 30],
13
+ ['gpt-5.4-mini', 0.75, 0.075, 4.5],
14
+ ['gpt-5.4-nano', 0.2, 0.02, 1.25],
15
+ ['gpt-5.4', 2.5, 0.25, 15],
16
+ ];
17
+ export const llmUsage = () => ({ ...usage });
18
+ export const resetLlmUsage = () => void Object.keys(usage).forEach((k) => (usage[k] = 0));
19
+ export function recordCall(ms, tokens, model) {
20
+ usage.calls++;
21
+ usage.totalMs += ms;
22
+ const prompt = tokens?.prompt ?? 0;
23
+ const completion = tokens?.completion ?? 0;
24
+ usage.promptTokens += prompt;
25
+ usage.completionTokens += completion;
26
+ const price = priceOf(model);
27
+ if (!price) {
28
+ usage.unpriced++;
29
+ return;
30
+ }
31
+ const cached = Math.min(tokens?.cached ?? 0, prompt);
32
+ usage.costUsd += ((prompt - cached) * price.in + cached * price.cached + completion * price.out) / 1_000_000;
33
+ }
34
+ export const recordCacheHit = () => void usage.cacheHits++;
35
+ export const recordFailure = () => void usage.failures++;
36
+ export const recordRefusal = () => void usage.refused++;
37
+ /** A team's own price (TRIAGE_LLM_PRICE_IN/OUT) wins; otherwise the published price of the model. */
38
+ export function priceOf(model) {
39
+ const input = Number(process.env.TRIAGE_LLM_PRICE_IN);
40
+ const output = Number(process.env.TRIAGE_LLM_PRICE_OUT);
41
+ if (Number.isFinite(input) && Number.isFinite(output) && (input > 0 || output > 0))
42
+ return { in: input, cached: input, out: output };
43
+ const row = model ? PRICES.find(([prefix]) => model.startsWith(prefix)) : undefined;
44
+ return row ? { in: row[1], cached: row[2], out: row[3] } : null;
45
+ }
46
+ /** Dollars spent, or null when no call had a known price. */
47
+ export function estimatedCostUsd(u = usage) {
48
+ return u.calls > u.unpriced ? u.costUsd : null;
49
+ }
50
+ /** One line for a report or job summary. Says "no AI calls" rather than printing zeros. */
51
+ export function usageLine(u = llmUsage()) {
52
+ if (u.calls === 0 && u.cacheHits === 0)
53
+ return 'AI layer: no calls (rules only)';
54
+ const seconds = (u.totalMs / 1000).toFixed(1);
55
+ const perCall = u.calls > 0 ? ` (${Math.round(u.totalMs / u.calls)} ms each)` : '';
56
+ const tokens = `${u.promptTokens.toLocaleString('en-US')} in / ${u.completionTokens.toLocaleString('en-US')} out tokens`;
57
+ const cost = estimatedCostUsd(u);
58
+ const parts = [
59
+ `AI layer: ${u.calls} call(s) in ${seconds}s${perCall}`,
60
+ `${u.cacheHits} cache hit(s)`,
61
+ tokens,
62
+ cost === null ? (u.calls ? 'cost: unknown model price, set TRIAGE_LLM_PRICE_IN/OUT' : 'cost: $0 (all from cache)') : `cost: ~$${cost.toFixed(4)}${u.unpriced ? ` plus ${u.unpriced} call(s) at an unknown price` : ''}`,
63
+ ];
64
+ if (u.failures)
65
+ parts.push(`${u.failures} call(s) failed, kept the rules' answer`);
66
+ if (u.refused)
67
+ parts.push(`${u.refused} call(s) skipped: budget TRIAGE_LLM_MAX_CALLS reached`);
68
+ return parts.join(' · ');
69
+ }
@@ -0,0 +1,33 @@
1
+ import { applyCorrection, correctionFor } from './corrections.js';
2
+ import { diagnoseRecords } from './engine.js';
3
+ import { buildFailureContext } from './llm/context.js';
4
+ import { llmDiagnose } from './llm/llm-diagnose.js';
5
+ async function mapLimit(items, limit, fn) {
6
+ const results = new Array(items.length);
7
+ let next = 0;
8
+ const worker = async () => {
9
+ while (next < items.length) {
10
+ const i = next++;
11
+ results[i] = await fn(items[i]);
12
+ }
13
+ };
14
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
15
+ return results;
16
+ }
17
+ export async function diagnosePipeline(targets, index, llm, { concurrency = 4, now = new Date(), corrections = [] } = {}) {
18
+ const rules = diagnoseRecords(targets, index, now);
19
+ const corrected = targets.map((record) => (corrections.length > 0 ? correctionFor(record, corrections) : null));
20
+ const settled = rules.map((d, i) => (corrected[i] ? applyCorrection(d, corrected[i]) : d));
21
+ if (!llm)
22
+ return settled;
23
+ const unknown = targets.map((record, i) => ({ record, i })).filter(({ i }) => settled[i].category === 'UNKNOWN' && !corrected[i]);
24
+ const final = [...settled];
25
+ await mapLimit(unknown, concurrency, async ({ record, i }) => {
26
+ const context = buildFailureContext(record, index.historyAsOf(record.testId, record.runId), index.runContext(record.runId), rules[i]);
27
+ // A call that fails or is refused (budget, outage, bad response) keeps the rules' answer
28
+ // and records why: one bad call must never lose the whole run's triage.
29
+ const answer = await llmDiagnose(context, rules[i], llm, now);
30
+ final[i] = corrected[i] ? applyCorrection(answer, corrected[i]) : answer;
31
+ });
32
+ return final;
33
+ }