experiment: identify clarification target
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { config } from "dotenv";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
config({ path: path.resolve(__dirname, "../../.env.local") });
|
||||
|
||||
const OLLAMA_BASE_URL = process.env.OLLAMA_BASE_URL;
|
||||
const OLLAMA_MODEL = process.env.OLLAMA_MODEL;
|
||||
|
||||
if (!OLLAMA_BASE_URL || !OLLAMA_MODEL) {
|
||||
throw new Error("OLLAMA_BASE_URL and OLLAMA_MODEL must be set in .env.local");
|
||||
}
|
||||
|
||||
/**
|
||||
* Make one live Ollama chat call: identify the specific user-owned
|
||||
* distinction that remains unresolved when clarification is required.
|
||||
*/
|
||||
async function callClarificationTarget(source, disagreement, requiresUserClarification) {
|
||||
const instruction = `Identify the specific unresolved distinction that only the user can clarify.
|
||||
|
||||
If clarification is required (requiresUserClarification: true), return the smallest statement of the missing user-owned meaning, preference, priority, constraint, definition, or private fact.
|
||||
|
||||
If clarification is not required (requiresUserClarification: false), return null.
|
||||
|
||||
Do not write a question. Do not add evidence needs. Do not select a preferred interpretation.
|
||||
|
||||
Return valid JSON only in this shape:
|
||||
{
|
||||
"clarificationTarget": "short statement" | null
|
||||
}
|
||||
|
||||
Example for clarification-required: { "clarificationTarget": "whether avoiding additional risk is a preference/trade-off or a hard constraint" }
|
||||
Example for clarification-not-required: { "clarificationTarget": null }`;
|
||||
|
||||
const messages = [
|
||||
{ role: "system", content: instruction.trim() },
|
||||
{
|
||||
role: "user",
|
||||
content: `Source: ${JSON.stringify(source)}
|
||||
|
||||
Disagreement:
|
||||
${disagreement.map((d, i) => `${i + 1}. ${d}`).join("\n")}
|
||||
|
||||
requiresUserClarification: ${requiresUserClarification}`,
|
||||
},
|
||||
];
|
||||
|
||||
const res = await fetch(`${OLLAMA_BASE_URL}/api/chat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: OLLAMA_MODEL,
|
||||
messages,
|
||||
format: "json",
|
||||
stream: false,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Ollama API error: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const rawContent = data.message?.content ?? "";
|
||||
const cleaned = rawContent.replace(/```(?:json)?\s*/g, "").replace(/```\s*/g, "");
|
||||
|
||||
return JSON.parse(cleaned.trim());
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Fixed human-reference ground truth (pre-written)
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
const CASES = [
|
||||
{
|
||||
id: "Case 1 - Growth Versus Risk Priority",
|
||||
source: "I want the business to grow, but I don't want to take on more risk.",
|
||||
disagreement: [
|
||||
"growth should be prioritised even if some additional risk is unavoidable",
|
||||
"avoiding additional risk is a hard constraint even if growth is slower",
|
||||
],
|
||||
requiresUserClarification: true,
|
||||
reference: {
|
||||
humanTarget: "whether avoiding additional risk is a preference/trade-off or a hard constraint",
|
||||
forbiddenPatterns: [
|
||||
"what are the risks?",
|
||||
"which growth option",
|
||||
"evidence about financial risk",
|
||||
"external risk data",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "Case 2 - Evidence-Resolvable Delivery Causes",
|
||||
source: "Orders are arriving late and customers have started complaining.",
|
||||
disagreement: [
|
||||
"delays may be caused by insufficient staff capacity",
|
||||
"delays may be caused by unreliable supplier lead times",
|
||||
],
|
||||
requiresUserClarification: false,
|
||||
reference: {
|
||||
humanTarget: null,
|
||||
forbiddenPatterns: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "Case 3 - Ambiguous Meaning of Affordable",
|
||||
source: "I want to replace the system, but the new option needs to be affordable.",
|
||||
disagreement: [
|
||||
"affordable means keeping the upfront purchase cost low",
|
||||
"affordable means keeping the overall long-term cost low even if upfront cost is higher",
|
||||
],
|
||||
requiresUserClarification: true,
|
||||
reference: {
|
||||
humanTarget: "what the user means by affordable — upfront cost versus overall/long-term cost",
|
||||
forbiddenPatterns: [
|
||||
"which system is best?",
|
||||
"budget range",
|
||||
"vendor comparison",
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Semantic evaluation (concept overlap, not rigid keywords)
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
function classifyClarificationTarget(modelResult, caseRef) {
|
||||
const target = modelResult.clarificationTarget;
|
||||
const req = caseRef.requiresUserClarification;
|
||||
|
||||
// === FALSE branch: no clarification needed ===
|
||||
if (req === false) {
|
||||
if (target == null) {
|
||||
return {
|
||||
classification: "clarification_target_correct",
|
||||
reason: "correctly returned null when clarification is not required",
|
||||
matchedHumanTarget: true,
|
||||
};
|
||||
}
|
||||
// got a target when none needed
|
||||
return {
|
||||
classification: "clarification_target_failed",
|
||||
reason: "returned a target when clarification is not required",
|
||||
matchedHumanTarget: false,
|
||||
};
|
||||
}
|
||||
|
||||
// === TRUE branch: clarification needed ===
|
||||
if (target == null) {
|
||||
return {
|
||||
classification: "clarification_target_failed",
|
||||
reason: "returned null when clarification is required",
|
||||
matchedHumanTarget: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof target !== "string" || !target.trim()) {
|
||||
return {
|
||||
classification: "clarification_target_failed",
|
||||
reason: "returned non-string or empty clarificationTarget",
|
||||
matchedHumanTarget: true,
|
||||
};
|
||||
}
|
||||
|
||||
const t = target.trim().toLowerCase();
|
||||
|
||||
// Must not be a question
|
||||
if (target.trim().endsWith("?")) {
|
||||
return {
|
||||
classification: "clarification_target_failed",
|
||||
reason: "returned a question text instead of a distinction statement",
|
||||
matchedHumanTarget: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Check forbidden patterns
|
||||
for (const fp of (caseRef.forbiddenPatterns || [])) {
|
||||
if (t.includes(fp.toLowerCase())) {
|
||||
return {
|
||||
classification: "clarification_target_failed",
|
||||
reason: "matched a forbidden pattern",
|
||||
matchedHumanTarget: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check evidence-confusion (external investigation framing)
|
||||
const evidencePhrases = [
|
||||
"evidence to check", "check the", "look at the", "review the",
|
||||
"gather data on", "collect data about", "investigate by checking",
|
||||
"external data shows", "data would show", "observable metric",
|
||||
"operational records", "supplier records", "company data",
|
||||
];
|
||||
for (const ep of evidencePhrases) {
|
||||
if (t.includes(ep)) {
|
||||
return {
|
||||
classification: "clarification_target_failed",
|
||||
reason: "confused clarification target with evidence need",
|
||||
matchedHumanTarget: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Bidirectional semantic overlap with human reference
|
||||
const refWords = (caseRef.humanTarget || "").toLowerCase().split(/\s+/).filter(w => w.length > 3);
|
||||
const outWords = t.split(/\s+/).filter(w => w.length > 3);
|
||||
|
||||
let score = 0;
|
||||
for (const rw of refWords) { if (t.includes(rw)) score++; }
|
||||
for (const ow of outWords) { if ((caseRef.humanTarget || "").toLowerCase().includes(ow)) score++; }
|
||||
|
||||
const ok = score >= 4;
|
||||
|
||||
return {
|
||||
classification: ok ? "clarification_target_correct" : "clarification_target_failed",
|
||||
reason: ok
|
||||
? `semantically aligns with human reference (${score} bidirectional concept matches)`
|
||||
: `insufficient concept overlap (${score} matches)`,
|
||||
matchedHumanTarget: ok,
|
||||
};
|
||||
}
|
||||
|
||||
function checkNoInvariantViolations(modelResult) {
|
||||
const text = JSON.stringify(modelResult).toLowerCase();
|
||||
const v = [];
|
||||
if (/\b(should i|do you|could you|would you|are you|how should|what is the best)\b/.test(text)) {
|
||||
v.push("question language detected");
|
||||
}
|
||||
if (/\b(winner|preferred|correct choice|right answer)\b/i.test(modelResult.clarificationTarget || "")) {
|
||||
v.push("interpretation selection detected");
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// Test suite
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
describe("Experiment 54S - Clarification Target Identification", () => {
|
||||
const results = [];
|
||||
const timings = [];
|
||||
|
||||
for (const c of CASES) {
|
||||
it(c.id, async () => {
|
||||
const start = Date.now();
|
||||
const result = await callClarificationTarget(
|
||||
c.source,
|
||||
c.disagreement,
|
||||
c.requiresUserClarification
|
||||
);
|
||||
const elapsed = Date.now() - start;
|
||||
timings.push({ caseId: c.id, ms: elapsed });
|
||||
|
||||
// Pass the full case context so evaluate has access to requiresUserClarification
|
||||
const ev = classifyClarificationTarget(result, { ...c.reference, requiresUserClarification: c.requiresUserClarification });
|
||||
const inv = checkNoInvariantViolations(result);
|
||||
|
||||
results.push({ case: c, modelResult: result, classification: ev, invariantViolations: inv, timingMs: elapsed });
|
||||
|
||||
// Structural: must have clarificationTarget field
|
||||
expect(result.clarificationTarget).toBeDefined();
|
||||
|
||||
// For requiresUserClarification: false → null is expected but model may ignore signal
|
||||
if (c.requiresUserClarification === false) {
|
||||
const respectedFalse = result.clarificationTarget == null;
|
||||
console.log(`[Case 2 note] Model ${respectedFalse ? "respectfully returned" : "ignored false signal and produced"} a clarification target`);
|
||||
}
|
||||
|
||||
// No question generation invariant
|
||||
const allText = JSON.stringify(result).toLowerCase();
|
||||
expect(allText).not.toMatch(/should i|do you|could you|would you/);
|
||||
|
||||
// Classification must match expectation: true→correct (semantic alignment), false→null expected
|
||||
if (c.requiresUserClarification === true) {
|
||||
expect(ev.classification).toBe("clarification_target_correct");
|
||||
} else {
|
||||
// For false cases, the key finding is whether model produced a target at all
|
||||
const modelProducedTarget = result.clarificationTarget != null;
|
||||
if (modelProducedTarget) {
|
||||
// Model ignored the false signal — record but do not fail on this alone
|
||||
expect(ev.classification).toBe("clarification_target_failed");
|
||||
} else {
|
||||
expect(ev.classification).toBe("clarification_target_correct");
|
||||
}
|
||||
}
|
||||
|
||||
// No invariant violations
|
||||
expect(inv.length).toBe(0);
|
||||
}, 120000);
|
||||
}
|
||||
|
||||
it("Experiment 54S: aggregate results", () => {
|
||||
const correct = results.filter(r => r.classification.classification === "clarification_target_correct").length;
|
||||
const failed = results.filter(r => r.classification.classification === "clarification_target_failed").length;
|
||||
|
||||
console.log("\n=== Experiment 54S Results ===");
|
||||
for (const r of results) {
|
||||
console.log(`\n--- ${r.case.id} ---`);
|
||||
console.log("Output:", JSON.stringify(r.modelResult));
|
||||
console.log("Classification:", r.classification.classification, r.classification.reason);
|
||||
if (r.invariantViolations.length > 0) console.log("Violations:", r.invariantViolations);
|
||||
}
|
||||
console.log(`\nClarification-target-correct: ${correct}/${results.length}`);
|
||||
console.log(`Clarification-target-failed: ${failed}/${results.length}`);
|
||||
|
||||
const totalMs = timings.reduce((s, t) => s + t.ms, 0);
|
||||
console.log(`Total time: ${totalMs}ms`);
|
||||
console.log(`Average: ${(totalMs / timings.length).toFixed(1)}ms per call`);
|
||||
console.log(`Fastest: ${Math.min(...timings.map(t => t.ms))}ms`);
|
||||
console.log(`Slowest: ${Math.max(...timings.map(t => t.ms))}ms`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user