Files
wehub-resource-sync c48612c494
CI / E2E Tests (push) Has been cancelled
CI / Lint, Typecheck & Unit Tests (push) Has been cancelled
Docs Build / Build docs site (push) Has been cancelled
Publish @openmaic packages / Build, validate & publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:23 +08:00

523 lines
19 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Agent Answer-Content Eval (#511 / #599 follow-up — the CONTENT layer)
*
* The director question-answering eval (answering-runner.ts) only checks that
* the DIRECTOR routes to the teacher. It never generates the teacher's reply,
* so it cannot catch the failure mode where the director routes correctly but
* the dispatched agent's FIRST sentence still drifts (greets / opens a lecture /
* pivots) and only a LATER turn answers — "first sentence drifts, second answers".
*
* This eval closes that gap. For each scenario it runs the REAL agent-generate
* inputs — buildStructuredPrompt() + convertMessagesToOpenAI() — against the
* model, parses the structured output with the app's runtime parser, and uses an
* LLM judge to decide:
* - leads_with_answer : did the FIRST sentence(s) address the literal ask?
* - answered_anywhere : did the reply address it AT ALL (even if late)?
* The gap (answered_anywhere && !leads_with_answer) quantifies the
* drift-then-answer pathology directly.
*
* Scenarios are synthetic and anonymized, authored from a real-world failure
* taxonomy (opening-lecture override, ignored format/capability/navigation
* request, ignored correction, frustration re-ask, adjacent pivot, vague
* clarify) plus clean controls. No real user data is included.
*
* A/B (mirrors answering-runner's rule-13 strip):
* - baseline : agent-system with the "Responding to the User's Turn" section stripped
* - with_rule : agent-system as-shipped
*
* Pass criterion: with_rule mean leads_with_answer rate >= EVAL_PASS_THRESHOLD
* (default 0.7). Δ vs baseline is reported as informational.
*
* Required env:
* EVAL_AGENT_MODEL Model used to generate the agent reply (or DEFAULT_MODEL)
* EVAL_JUDGE_MODEL Model used as the answer judge
* Optional env:
* EVAL_SAMPLES Samples per (scenario, variant). Default 3.
* EVAL_PASS_THRESHOLD Min with_rule leads rate per scenario. Default 0.7.
* EVAL_SCENARIO Filter to a single scenario by case_id.
*
* Output: eval/orchestration/results-answer-content/<model>/<timestamp>/report.md
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { callLLM } from '@/lib/ai/llm';
import { buildStructuredPrompt } from '@/lib/orchestration/prompt-builder';
import { convertMessagesToOpenAI } from '@/lib/orchestration/summarizers/message-converter';
import {
createParserState,
parseStructuredChunk,
finalizeParser,
} from '@/lib/orchestration/stateless-generate';
import { type AgentConfig, getActionsForRole } from '@/lib/orchestration/registry/types';
import type { AgentTurnSummary } from '@/lib/orchestration/types';
import type { StatelessChatRequest } from '@/lib/types/chat';
import { resolveEvalModel } from '../shared/resolve-model';
import { createRunDir } from '../shared/run-dir';
import { judgeAnswer, type AnswerVerdict } from './answer-content-judge';
import type { ScenarioAgent } from './types';
const OUTPUT_DIR = 'eval/orchestration/results-answer-content';
// ==================== Types ====================
interface ScenarioAgentSpec extends ScenarioAgent {
persona: string;
}
interface ScenarioTurn {
role: 'user' | 'agent';
agentId?: string;
text: string;
}
interface ContentScenario {
case_id: string;
category: string;
description: string;
agents: ScenarioAgentSpec[];
teacherAgentId: string;
turns: ScenarioTurn[];
answerKey: string;
expectedPreFix?: string;
/** Classroom context so the assembled prompt matches the live shape/bulk:
* a topical slide scene, the stage (incl. languageDirective), and the
* student profile. Shapes mirror StatelessChatRequest.storeState. Use `scenes`
* + `currentSceneId` for a multi-slide deck (e.g. so "skip to next page" is
* well-posed); `scene` is the single-slide shorthand. */
scene?: unknown;
scenes?: unknown[];
currentSceneId?: string;
stage?: unknown;
userProfile?: { nickname?: string; bio?: string };
mode?: 'autonomous' | 'playback';
}
type Variant = 'baseline' | 'with_rule';
interface SampleResult {
variant: Variant;
leadText: string;
fullText: string;
verdict: AnswerVerdict;
error?: string;
}
interface VariantAgg {
samples: SampleResult[];
leadsRate: number;
answeredRate: number;
}
interface ScenarioResult {
case_id: string;
category: string;
description: string;
baseline: VariantAgg;
withRule: VariantAgg;
delta: number;
passes: boolean;
}
// ==================== Input construction ====================
/** Build a full AgentConfig for the teacher from the scenario spec. */
function buildTeacherConfig(scenario: ContentScenario): AgentConfig {
const spec = scenario.agents.find((a) => a.id === scenario.teacherAgentId) ?? scenario.agents[0];
return {
id: spec.id,
name: spec.name,
role: spec.role,
persona: spec.persona,
avatar: '🧑‍🏫',
color: '#6d28d9',
// Use the canonical role action set (incl. play_video + full whiteboard) so
// the assembled prompt's available-actions section matches production.
allowedActions: getActionsForRole(spec.role),
priority: spec.priority,
createdAt: new Date(0),
updatedAt: new Date(0),
isDefault: true,
};
}
/** Store state from the scenario's classroom context (slide scene + stage),
* so buildStructuredPrompt produces the same "# Current State" shape/bulk as
* the live agent-generate path. Falls back to an empty state if a scenario
* omits context. */
function buildStoreState(scenario: ContentScenario): StatelessChatRequest['storeState'] {
const scenes = scenario.scenes ?? (scenario.scene ? [scenario.scene] : []);
const currentSceneId =
scenario.currentSceneId ?? (scenes[0] ? ((scenes[0] as { id?: string }).id ?? null) : null);
return {
stage: scenario.stage ?? null,
scenes,
currentSceneId,
mode: scenario.mode ?? 'playback',
whiteboardOpen: false,
} as unknown as StatelessChatRequest['storeState'];
}
/** Turn list -> the UIMessage[] shape convertMessagesToOpenAI expects. */
function buildMessages(scenario: ContentScenario): StatelessChatRequest['messages'] {
const nameById = new Map(scenario.agents.map((a) => [a.id, a.name]));
const messages = scenario.turns.map((turn, i) => {
if (turn.role === 'user') {
return {
id: `user-${i}`,
role: 'user' as const,
parts: [{ type: 'text', text: turn.text }],
metadata: { senderName: 'You', originalRole: 'user', createdAt: i },
};
}
const agentId = turn.agentId ?? scenario.teacherAgentId;
return {
id: `assistant-${i}`,
role: 'assistant' as const,
parts: [{ type: 'text', text: turn.text }],
metadata: { agentId, senderName: nameById.get(agentId) ?? agentId, createdAt: i },
};
});
return messages as unknown as StatelessChatRequest['messages'];
}
/** Prior agent turns -> AgentTurnSummary[] for peer context in the prompt. */
function buildAgentResponses(scenario: ContentScenario): AgentTurnSummary[] {
const nameById = new Map(scenario.agents.map((a) => [a.id, a.name]));
return scenario.turns
.filter((t) => t.role === 'agent')
.map((t) => {
const agentId = t.agentId ?? scenario.teacherAgentId;
return {
agentId,
agentName: nameById.get(agentId) ?? agentId,
contentPreview: t.text.slice(0, 200),
actionCount: 0,
whiteboardActions: [],
};
});
}
/**
* Remove the "# Responding to the User's Turn" section from an assembled agent
* system prompt to build the pre-fix baseline. Bounded by the next section header.
*/
function stripAnsweringSection(prompt: string): string {
const re = /\n# Responding to the User's Turn[\s\S]*?(?=\n# Current State)/;
if (!re.test(prompt)) {
throw new Error(
'answer-content-runner: "# Responding to the User\'s Turn" section not found in agent prompt; baseline cannot be constructed',
);
}
return prompt.replace(re, '\n');
}
/** Extract ordered text blocks from a structured agent response. */
function extractTexts(raw: string): string[] {
const state = createParserState();
const streamed = parseStructuredChunk(raw, state);
if (streamed.textChunks.length > 0) return streamed.textChunks;
// Recover only when the model emitted plain prose (never opened a JSON array) —
// finalizeParser then surfaces that prose as text. If an array WAS started but
// yielded no text (e.g. actions-only), the correct result is "no text"; we must
// not fall through to finalizeParser's raw-buffer fallback, which would surface
// action JSON as fake speech.
if (!state.jsonStarted) return finalizeParser(state).textChunks;
return [];
}
/** Split into sentences across both Latin and CJK terminators, keeping order. */
function splitSentences(s: string): string[] {
return s
.split(/(?<=[.!?。!?])\s*/)
.map((x) => x.trim())
.filter(Boolean);
}
/**
* The "opening" judged for leads_with_answer: the first ~2 speech sentences
* across ALL ordered text blocks (not just the first block — a reply may be
* several `type:"text"` items, e.g. [{"Sure."},{"The derivative is 2x."}]). Two
* sentences let the judge distinguish a brief acknowledgement-then-answer
* ("Sure. The derivative is 2x."), which the prompt allows, from a
* greeting/lecture preamble before the answer ("Welcome! …" / "Today we'll
* discuss parabolas. The formula is…"), which is the drift this eval catches.
*/
function leadFromTexts(texts: string[]): string {
const joined = texts.join(' ');
const sentences = splitSentences(joined);
if (sentences.length === 0) return joined;
return sentences.slice(0, 2).join(' ');
}
// ==================== Sampling ====================
function lastUserMessage(scenario: ContentScenario): string {
for (let i = scenario.turns.length - 1; i >= 0; i--) {
if (scenario.turns[i].role === 'user') return scenario.turns[i].text;
}
return '';
}
async function sampleVariant(
scenario: ContentScenario,
variant: Variant,
systemPrompt: string,
openaiMessages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>,
agentModel: Awaited<ReturnType<typeof resolveEvalModel>>['model'],
judgeModel: Awaited<ReturnType<typeof resolveEvalModel>>['model'],
studentMessage: string,
samples: number,
): Promise<SampleResult[]> {
const tasks = Array.from({ length: samples }, async (): Promise<SampleResult> => {
try {
const gen = await callLLM(
{
model: agentModel,
messages: [{ role: 'system', content: systemPrompt }, ...openaiMessages],
},
`eval-answer-content-${variant}`,
);
const texts = extractTexts(gen.text);
const leadText = leadFromTexts(texts);
const fullText = texts.join(' ');
const verdict = await judgeAnswer(
judgeModel,
studentMessage,
scenario.answerKey,
leadText,
fullText,
);
return { variant, leadText, fullText, verdict };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
return {
variant,
leadText: '',
fullText: '',
verdict: { leads_with_answer: false, answered_anywhere: false, reason: msg, error: true },
error: msg,
};
}
});
return Promise.all(tasks);
}
function aggregate(samples: SampleResult[]): VariantAgg {
// Errors count as failures for an eval gate: the denominator is ALL requested
// samples, so a scenario cannot "pass" on one good sample while the rest error
// out. An errored sample (generation or judge failure) contributes 0.
const n = samples.length || 1;
const ok = (s: SampleResult) => !s.error && !s.verdict.error;
const leadsRate = samples.filter((s) => ok(s) && s.verdict.leads_with_answer).length / n;
const answeredRate = samples.filter((s) => ok(s) && s.verdict.answered_anywhere).length / n;
return { samples, leadsRate, answeredRate };
}
// ==================== Reporting ====================
function pct(x: number): string {
return `${Math.round(x * 100)}%`;
}
function writeReport(
runDir: string,
results: ScenarioResult[],
modelStr: string,
judgeStr: string,
samples: number,
threshold: number,
): string {
const lines: string[] = [];
const overallPass = results.every((r) => r.passes);
const meanBaseLeads = results.reduce((a, r) => a + r.baseline.leadsRate, 0) / results.length;
const meanRuleLeads = results.reduce((a, r) => a + r.withRule.leadsRate, 0) / results.length;
const meanRuleAnswered =
results.reduce((a, r) => a + r.withRule.answeredRate, 0) / results.length;
lines.push(`# Agent Answer-Content Eval`, ``);
lines.push(`- **Date**: ${new Date().toISOString()}`);
lines.push(`- **Agent model**: ${modelStr}`);
lines.push(`- **Judge model**: ${judgeStr}`);
lines.push(`- **Samples per (scenario, variant)**: ${samples}`);
lines.push(`- **with_rule leads-with-answer threshold**: ${pct(threshold)}`);
lines.push(``);
lines.push(`## Aggregate`, ``);
lines.push(`| Variant | Mean leads-with-answer | Mean answered-anywhere |`);
lines.push(`|---|---|---|`);
lines.push(`| baseline (no answering rule) | ${pct(meanBaseLeads)} | — |`);
lines.push(`| with_rule (as-shipped) | ${pct(meanRuleLeads)} | ${pct(meanRuleAnswered)} |`);
lines.push(``);
lines.push(
`**Drift-then-answer gap (with_rule)**: answered-anywhere ${pct(meanRuleAnswered)} leads ${pct(meanRuleLeads)} = **${pct(Math.max(0, meanRuleAnswered - meanRuleLeads))}** of replies answered only AFTER a drifting opener.`,
);
lines.push(``);
lines.push(`Overall verdict: **${overallPass ? 'PASS' : 'FAIL'}**`, ``);
lines.push(`## Per scenario`, ``);
lines.push(
`| # | case_id | category | baseline leads | with_rule leads | with_rule answered | Δ leads | pass? |`,
);
lines.push(`|---|---|---|---|---|---|---|---|`);
results.forEach((r, i) => {
lines.push(
`| ${i + 1} | ${r.case_id} | ${r.category} | ${pct(r.baseline.leadsRate)} | ${pct(r.withRule.leadsRate)} | ${pct(r.withRule.answeredRate)} | ${pct(r.delta)} | ${r.passes ? '✓' : '✗'} |`,
);
});
lines.push(``);
lines.push(`## Detail`, ``);
for (const r of results) {
lines.push(`### ${r.case_id} ${r.passes ? '✓' : '✗'}`, ``);
lines.push(`- ${r.description}`);
lines.push(
`- baseline leads ${pct(r.baseline.leadsRate)}; with_rule leads ${pct(r.withRule.leadsRate)} / answered ${pct(r.withRule.answeredRate)}; Δ leads ${pct(r.delta)}`,
);
lines.push(``, `<details><summary>with_rule samples</summary>`, ``);
for (const s of r.withRule.samples) {
if (s.error) {
lines.push(`- ERROR: ${s.error}`);
continue;
}
const tag = s.verdict.leads_with_answer
? 'LEADS'
: s.verdict.answered_anywhere
? 'DRIFT→answered'
: 'DRIFT';
lines.push(`- **${tag}** — lead: "${s.leadText.slice(0, 140)}" — ${s.verdict.reason}`);
}
lines.push(``, `</details>`, ``);
}
const reportPath = path.join(runDir, 'report.md');
fs.writeFileSync(reportPath, lines.join('\n'));
return reportPath;
}
// ==================== Main ====================
function getCurrentDir(): string {
return typeof __dirname !== 'undefined'
? __dirname
: path.dirname(fileURLToPath(import.meta.url));
}
function loadScenarios(): ContentScenario[] {
const p = path.join(getCurrentDir(), 'scenarios/answer-content.json');
const scenarios = JSON.parse(fs.readFileSync(p, 'utf-8')) as ContentScenario[];
const filter = process.env.EVAL_SCENARIO;
return filter ? scenarios.filter((s) => s.case_id === filter) : scenarios;
}
async function main() {
const modelStr = process.env.EVAL_AGENT_MODEL || process.env.DEFAULT_MODEL;
const judgeStr = process.env.EVAL_JUDGE_MODEL;
if (!modelStr) {
console.error(
'Error: EVAL_AGENT_MODEL (or DEFAULT_MODEL) must be set. Example: EVAL_AGENT_MODEL=google:gemini-3-flash-preview',
);
process.exit(1);
}
if (!judgeStr) {
console.error(
'Error: EVAL_JUDGE_MODEL must be set. Example: EVAL_JUDGE_MODEL=anthropic:claude-haiku-4-5',
);
process.exit(1);
}
const samples = Number(process.env.EVAL_SAMPLES || '3');
const threshold = Number(process.env.EVAL_PASS_THRESHOLD || '0.7');
console.log('=== Agent Answer-Content Eval ===');
console.log(`Agent: ${modelStr} | Judge: ${judgeStr} | Samples/variant: ${samples}`);
const { model: agentModel } = await resolveEvalModel(
'EVAL_AGENT_MODEL',
process.env.DEFAULT_MODEL,
);
const { model: judgeModel } = await resolveEvalModel('EVAL_JUDGE_MODEL');
const scenarios = loadScenarios();
if (scenarios.length === 0) {
const filter = process.env.EVAL_SCENARIO;
console.error(
filter
? `No scenario matches EVAL_SCENARIO="${filter}".`
: 'No scenarios found in scenarios/answer-content.json.',
);
process.exit(1);
}
console.log(`Loaded ${scenarios.length} scenario(s)`);
const runDir = createRunDir(OUTPUT_DIR, modelStr);
console.log(`Output: ${runDir}`);
const results: ScenarioResult[] = [];
for (const sc of scenarios) {
process.stdout.write(` - ${sc.case_id} ... `);
const teacher = buildTeacherConfig(sc);
const storeState = buildStoreState(sc);
const agentResponses = buildAgentResponses(sc);
const withRulePrompt = buildStructuredPrompt(
teacher,
storeState,
undefined,
[],
sc.userProfile,
agentResponses,
);
const baselinePrompt = stripAnsweringSection(withRulePrompt);
const openaiMessages = convertMessagesToOpenAI(buildMessages(sc), sc.teacherAgentId);
const studentMessage = lastUserMessage(sc);
const [bs, ws] = await Promise.all([
sampleVariant(
sc,
'baseline',
baselinePrompt,
openaiMessages,
agentModel,
judgeModel,
studentMessage,
samples,
),
sampleVariant(
sc,
'with_rule',
withRulePrompt,
openaiMessages,
agentModel,
judgeModel,
studentMessage,
samples,
),
]);
const baseline = aggregate(bs);
const withRule = aggregate(ws);
const delta = withRule.leadsRate - baseline.leadsRate;
const passes = withRule.leadsRate >= threshold;
results.push({
case_id: sc.case_id,
category: sc.category,
description: sc.description,
baseline,
withRule,
delta,
passes,
});
console.log(
`baseline=${pct(baseline.leadsRate)} with_rule=${pct(withRule.leadsRate)} answered=${pct(withRule.answeredRate)} ${passes ? 'PASS' : 'FAIL'}`,
);
}
const reportPath = writeReport(runDir, results, modelStr, judgeStr, samples, threshold);
const overallPass = results.every((r) => r.passes);
console.log(`\nReport: ${reportPath}`);
console.log(`Verdict: ${overallPass ? 'PASS' : 'FAIL'}`);
process.exit(overallPass ? 0 : 1);
}
main().catch((err) => {
console.error('Fatal error:', err);
process.exit(1);
});