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: take a clarification answer * and return what was resolved, whether the target is resolved, * and any remaining uncertainty about that specific target. */ async function callClarificationAnswerResolution(source, clarificationTarget, clarificationQuestion, userAnswer) { const instruction = `Use the user's clarification answer only to resolve the supplied clarification target. State the meaning now established by that answer. Mark targetResolved true only when the answer settles the target. Put any uncertainty that remains specifically about that target into remainingUncertainty; otherwise return null. Do not infer wider consequences, rewrite unrelated source meaning, recommend action, or generate another question. Return valid JSON only in this shape: { "resolvedMeaning": "short statement", "targetResolved": true, "remainingUncertainty": null }`; const messages = [ { role: "system", content: instruction.trim() }, { role: "user", content: `Source: ${JSON.stringify(source)} Clarification target: ${clarificationTarget} Clarification question: ${clarificationQuestion} User's answer: ${userAnswer}`, }, ]; 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()); } // Three fixed cases -- human-reviewed inputs and expected outputs const CASES = [ { id: "Case 1 - Hard Constraint Resolved", source: "I want the business to grow, but I don't want to take on more risk.", clarificationTarget: "whether avoiding additional risk is a preference/trade-off or a hard constraint", clarificationQuestion: "Do you view avoiding additional risk as a preference/trade-off or a hard constraint?", userAnswer: "It's a hard constraint. I don't want any increase in risk.", humanReference: { resolvedMeaning: "avoiding additional risk is a hard constraint", targetResolved: true, remainingUncertainty: null, }, mustNotInfer: [ "that growth is impossible", "which growth option should be chosen", "how much risk currently exists", ], }, { id: "Case 2 - Definition Resolved", source: "I want to replace the system, but the new option needs to be affordable.", clarificationTarget: "whether affordable means low upfront cost or low overall/long-term cost", clarificationQuestion: 'Does your use of "affordable" refer to a low upfront cost or a low overall/long-term cost?', userAnswer: "I care about the total cost over five years, not the upfront price.", humanReference: { resolvedMeaning: "affordability means overall/long-term cost rather than upfront cost", targetResolved: true, remainingUncertainty: null, }, mustNotInfer: [ "a specific budget amount", "which system to choose", ], }, { id: "Case 3 - Answer Does Not Fully Resolve Target", source: "I want the business to grow, but I don't want to take on more risk.", clarificationTarget: "whether avoiding additional risk is a preference/trade-off or a hard constraint", clarificationQuestion: "Do you view avoiding additional risk as a preference/trade-off or a hard constraint?", userAnswer: "It depends on the opportunity.", humanReference: { resolvedMeaning: "the user's risk position is conditional on the opportunity", targetResolved: false, remainingUncertainty: "acceptable trade-off still depends on circumstances", }, mustNotInfer: [ "forcing into hard constraint", "forcing into preference/trade-off", ], }, ]; function classifyResolution(modelResult, caseRef) { const resolvedMeaning = modelResult.resolvedMeaning; const targetResolved = modelResult.targetResolved; const remainingUncertainty = modelResult.remainingUncertainty; // Structural checks if (typeof resolvedMeaning !== "string" || !resolvedMeaning.trim()) { return { classification: "resolution_failed", reason: "missing or empty resolvedMeaning" }; } if (typeof targetResolved !== "boolean") { return { classification: "resolution_failed", reason: "targetResolved is not a boolean" }; } // Check targetResolved matches expected type const targetResolvedCorrect = (caseRef.humanReference.targetResolved === true && targetResolved === true) || (caseRef.humanReference.targetResolved === false && targetResolved === false); if (!targetResolvedCorrect) { return { classification: "resolution_failed", reason: `targetResolved is ${targetResolved} but expected ${caseRef.humanReference.targetResolved}`, }; } // Check for unsupported inferences (must-not-infer patterns) const meaningLower = resolvedMeaning.toLowerCase(); let unsupportedAdditions = []; for (const pattern of caseRef.mustNotInfer) { if (meaningLower.includes(pattern.toLowerCase())) { unsupportedAdditions.push(pattern); } } if (unsupportedAdditions.length > 0) { return { classification: "resolution_failed", reason: `introduces unsupported meaning: "${unsupportedAdditions.join(", ")}"`, }; } // Check that resolvedMeaning is concise const wordCount = resolvedMeaning.trim().split(/\s+/).length; if (wordCount > 30) { return { classification: "resolution_failed", reason: `resolvedMeaning too long (${wordCount} words) -- may be generating extra interpretation`, }; } // Check that model did not generate another question if (resolvedMeaning.includes("?")) { return { classification: "resolution_failed", reason: "resolvedMeaning contains a question mark -- model may have generated a new question", }; } // Case-specific checks for remainingUncertainty if (caseRef.humanReference.targetResolved === true) { if (remainingUncertainty !== null && remainingUncertainty !== undefined) { return { classification: "resolution_correct", reason: `targetResolved=${targetResolved}, resolvedMeaning="${resolvedMeaning}", but remainingUncertainty was "${remainingUncertainty}" when expected null`, warning: "remainingUncertainty present in a case where it should be null", }; } } if (caseRef.humanReference.targetResolved === false) { if (remainingUncertainty === null || remainingUncertainty === undefined) { return { classification: "resolution_failed", reason: "remainingUncertainty is null but target was not fully resolved -- should describe the uncertainty", }; } if (typeof remainingUncertainty !== "string" || !remainingUncertainty.trim()) { return { classification: "resolution_failed", reason: "remainingUncertainty present but empty or not a string", }; } } return { classification: "resolution_correct", reason: `targetResolved=${targetResolved}, resolvedMeaning="${resolvedMeaning}"`, }; } function checkOutputSchema(modelResult) { const violations = []; const requiredKeys = ["resolvedMeaning", "targetResolved"]; for (const key of requiredKeys) { if (!(key in modelResult)) violations.push(`missing field: ${key}`); } if (typeof modelResult.resolvedMeaning !== "string") violations.push("resolvedMeaning is not a string"); if (typeof modelResult.targetResolved !== "boolean") violations.push("targetResolved is not a boolean"); if ("remainingUncertainty" in modelResult && modelResult.remainingUncertainty !== null && typeof modelResult.remainingUncertainty !== "string") { violations.push("remainingUncertainty must be null or a string"); } const forbiddenKeys = ["nextQuestion", "recommendation", "confidenceScore", "graphUpdate"]; for (const key of forbiddenKeys) { if (key in modelResult) violations.push(`unexpected field: ${key}`); } return violations; } describe("Experiment 54V - Clarification Answer Resolution", () => { const results = []; const timings = []; for (const c of CASES) { it(c.id, async () => { const start = Date.now(); const result = await callClarificationAnswerResolution( c.source, c.clarificationTarget, c.clarificationQuestion, c.userAnswer ); const elapsed = Date.now() - start; timings.push({ caseId: c.id, ms: elapsed }); const ev = classifyResolution(result, c); const schemaOk = checkOutputSchema(result); results.push({ case: c, modelResult: result, classification: ev, schemaViolations: schemaOk, timingMs: elapsed, }); // Structural assertions expect(result.resolvedMeaning).toBeDefined(); expect(typeof result.resolvedMeaning).toBe("string"); expect(result.targetResolved).toBeDefined(); expect(typeof result.targetResolved).toBe("boolean"); // Semantic assertions based on case reference if (c.id === "Case 3 - Answer Does Not Fully Resolve Target") { expect(result.targetResolved).toBe(false); } else { expect(result.targetResolved).toBe(true); } }, 120000); } it("Experiment 54V: aggregate results", () => { const correct = results.filter((r) => r.classification.classification === "resolution_correct").length; const failed = results.filter((r) => r.classification.classification === "resolution_failed").length; console.log("\n=== Experiment 54V Results ==="); for (const r of results) { console.log(`\n--- ${r.case.id} ---`); console.log("Source:", r.case.source); console.log("Target:", r.case.clarificationTarget); console.log("Question:", r.case.clarificationQuestion); console.log("Answer:", r.case.userAnswer); console.log("Resolved meaning:", r.modelResult.resolvedMeaning); console.log("targetResolved:", r.modelResult.targetResolved); console.log("remainingUncertainty:", r.modelResult.remainingUncertainty); console.log("Classification:", r.classification.classification, r.classification.reason); if (r.schemaViolations.length > 0) console.log("Schema violations:", r.schemaViolations); } console.log(`\nresolution_correct: ${correct}/${results.length}`); console.log(`resolution_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`); }); });