Standard production layout: the OKR app (was nested under AINative_OKR_CASAN5/) is now
the repository root. No more wrapper directory.
- Promote AINative_OKR_CASAN5/* -> repo root (backend/ frontend/ packages/ apps/
.specify/ docs/ infra/ nginx/ scripts/ + configs). Merge tool dirs: .gitea (kept the
active deploy ci.yml, added harness-ci.yml + runbooks), .claude (agents/commands +
launch.json), .github moved up.
- Remove redundant: 00_SUBMISSION_PACKAGE, scattered root notes (FPT_CASAN_Full.md,
tu-tuong-casan.md, casan-tu-sinh..., casan_harness_assessment.md, source-review...,
README_CASAN5_REFINED.md), casan-next-plans/ and optimize-docs/ (competition/planning
artifacts — roadmap + design history preserved in git log / commit messages).
- Update all references to the old layout:
- .gitea/workflows/{ci,harness-ci}.yml, .github/workflows/{ci,deploy}.yml:
working-directory .; drop AINative_OKR_CASAN5/ prefix; .specify/{tests,scripts}
-> packages/casan-harness/... (.specify/logs state kept)
- .claude/launch.json, .gitea/*-runbook.md: path prefixes
- CLAUDE.md, README.md: docs/input -> apps/okr/domain/input
- policy-bundle.yaml: 8 policy paths -> packages/casan-harness/...; manifest re-signed
- secrets-scan.sh: fixture excludes -> new package/domain paths.
Full gate from the new root: PASS=64 FAIL=0 SKIP=3.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
166 lines
5.7 KiB
TypeScript
166 lines
5.7 KiB
TypeScript
/**
|
|
* LLM-judge gate: sends live API responses to Claude and fails if the model
|
|
* returns REJECTED. This catches semantic breakage that structural assertions miss
|
|
* (e.g. scrambled titles, impossible status/progress combinations, data leakage).
|
|
*
|
|
* Requires ANTHROPIC_API_KEY to be set. The test is skipped — not failed — when
|
|
* the key is absent, so local dev without a key still passes CI.
|
|
*
|
|
* REJECTED→fix cycle:
|
|
* 1. CI logs the judge verdict and reason.
|
|
* 2. Developer reads the reason, fixes the business logic or seed data.
|
|
* 3. CI re-runs; the judge issues ACCEPTED once invariants are restored.
|
|
*/
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import request from 'supertest';
|
|
import { createTestApp, loginToken } from './helpers.js';
|
|
|
|
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
|
|
const JUDGE_MODEL = 'claude-haiku-4-5-20251001';
|
|
|
|
interface JudgeVerdict {
|
|
verdict: 'ACCEPTED' | 'REJECTED';
|
|
reason: string;
|
|
}
|
|
|
|
async function callLlmJudge(prompt: string): Promise<JudgeVerdict> {
|
|
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
|
method: 'POST',
|
|
headers: {
|
|
'x-api-key': ANTHROPIC_API_KEY!,
|
|
'anthropic-version': '2023-06-01',
|
|
'content-type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
model: JUDGE_MODEL,
|
|
max_tokens: 512,
|
|
temperature: 0,
|
|
messages: [{ role: 'user', content: prompt }],
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const body = await response.text();
|
|
throw new Error(`Anthropic API error ${response.status}: ${body}`);
|
|
}
|
|
|
|
const result = await response.json() as { content: Array<{ type: string; text: string }> };
|
|
const text = result.content.find((c) => c.type === 'text')?.text ?? '';
|
|
|
|
// Extract JSON object from the model response (model may wrap it in prose)
|
|
const match = text.match(/\{[\s\S]*"verdict"[\s\S]*\}/);
|
|
if (!match) {
|
|
throw new Error(`LLM judge returned unparseable response:\n${text}`);
|
|
}
|
|
return JSON.parse(match[0]) as JudgeVerdict;
|
|
}
|
|
|
|
function buildJudgePrompt(label: string, responseJson: unknown): string {
|
|
const serialized = JSON.stringify(responseJson, null, 2);
|
|
return `You are a strict quality gate for an OKR (Objectives and Key Results) management API.
|
|
|
|
Evaluate the API response below for the endpoint: ${label}
|
|
|
|
Check ALL of the following invariants:
|
|
1. \`success\` field is exactly boolean \`true\`.
|
|
2. \`data\` exists and is a non-empty array (for list endpoints) or a non-null object (for detail endpoints).
|
|
3. For list responses: \`meta.total\` equals \`data.length\`.
|
|
4. Each objective has: id (positive integer), title (non-empty string), quarter (format Q[1-4]/YYYY), status (one of NOT_STARTED, IN_PROGRESS, COMPLETED), keyResults (array).
|
|
5. Each keyResult has: id (positive integer), title (non-empty string), progress (integer 0-100 inclusive).
|
|
6. Status-progress invariant — for each objective:
|
|
- If ALL keyResults have progress === 0 → status MUST be NOT_STARTED.
|
|
- If ALL keyResults have progress === 100 → status MUST be COMPLETED.
|
|
- Otherwise → status MUST be IN_PROGRESS.
|
|
7. Titles must NOT contain placeholder or test noise (e.g. "undefined", "null", "TODO", "string", "untitled", "test objective").
|
|
|
|
API response:
|
|
\`\`\`json
|
|
${serialized}
|
|
\`\`\`
|
|
|
|
Respond with a JSON object ONLY — no prose before or after:
|
|
{"verdict": "ACCEPTED" or "REJECTED", "reason": "<one sentence explaining pass or the first failing invariant>"}`;
|
|
}
|
|
|
|
// Skip gracefully when the API key is absent
|
|
const skip = !ANTHROPIC_API_KEY && 'Set ANTHROPIC_API_KEY to enable the LLM-judge gate';
|
|
|
|
test(
|
|
'LLM judge: manager objective list passes all semantic invariants',
|
|
{ skip },
|
|
async () => {
|
|
const { app } = await createTestApp();
|
|
let verdict: JudgeVerdict;
|
|
try {
|
|
const token = await loginToken(app, 'manager');
|
|
const res = await request(app.getHttpServer())
|
|
.get('/api/v1/objectives')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.expect(200);
|
|
|
|
verdict = await callLlmJudge('GET /api/v1/objectives (manager)', res.body);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
|
|
if (verdict!.verdict === 'REJECTED') {
|
|
assert.fail(`LLM judge REJECTED manager objective list: ${verdict!.reason}`);
|
|
}
|
|
},
|
|
);
|
|
|
|
test(
|
|
'LLM judge: employee objective list passes all semantic invariants',
|
|
{ skip },
|
|
async () => {
|
|
const { app } = await createTestApp();
|
|
let verdict: JudgeVerdict;
|
|
try {
|
|
const token = await loginToken(app, 'employee');
|
|
const res = await request(app.getHttpServer())
|
|
.get('/api/v1/objectives')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.expect(200);
|
|
|
|
verdict = await callLlmJudge('GET /api/v1/objectives (employee)', res.body);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
|
|
if (verdict!.verdict === 'REJECTED') {
|
|
assert.fail(`LLM judge REJECTED employee objective list: ${verdict!.reason}`);
|
|
}
|
|
},
|
|
);
|
|
|
|
test(
|
|
'LLM judge: objective detail passes all semantic invariants',
|
|
{ skip },
|
|
async () => {
|
|
const { app } = await createTestApp();
|
|
let verdict: JudgeVerdict;
|
|
try {
|
|
const token = await loginToken(app, 'manager');
|
|
const res = await request(app.getHttpServer())
|
|
.get('/api/v1/objectives/1')
|
|
.set('Authorization', `Bearer ${token}`)
|
|
.expect(200);
|
|
|
|
// Normalize: present as a single-item list so the judge uses the same invariant set
|
|
const normalized = {
|
|
success: res.body.success,
|
|
data: [res.body.data],
|
|
meta: { total: 1 },
|
|
};
|
|
verdict = await callLlmJudge('GET /api/v1/objectives/1 (detail, normalized to list form)', normalized);
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
|
|
if (verdict!.verdict === 'REJECTED') {
|
|
assert.fail(`LLM judge REJECTED objective detail: ${verdict!.reason}`);
|
|
}
|
|
},
|
|
);
|