/** * Reusable live-focused-deconstruction experiment helper. * * Makes one real focused-deconstruction call using the same production * semantic operation as /api/focused-investigation/deconstruct: * * buildFocusedDeconstructPrompt -> provider.generateReconstruction -> validateFocusedDeconstructSchema * * Inputs are the minimum fields required by buildFocusedDeconstructPrompt. * Relies on environment variables OLLAMA_BASE_URL and OLLAMA_MODEL being set. */ import dotenv from "dotenv"; import { buildFocusedDeconstructPrompt, validateFocusedDeconstructSchema } from "@/lib/graph/focused-investigation.js"; import { getProvider } from "@/lib/llm/provider.js"; // Load configured OLLAMA_MODEL / OLLAMA_BASE_URL (same mechanism as all known-good live tests) dotenv.config({ path: ".env.local" }); // ── shared execution seam (extracted from existing body) ──────────────── /** * Execute a focused-deconstruct prompt through the canonical live machinery. * * Caller supplies: prompt, targetNodeId, optional injected provider. * This function owns: provider resolution, model invocation, timing, validation, result construction. */ async function _executeFocusedPrompt({ prompt, targetNodeId, provider }) { const actualProvider = provider ?? getProvider(); const ollamaModel = process.env.OLLAMA_MODEL; if (!ollamaModel) throw new Error("OLLAMA_MODEL is not set in environment"); const startedAt = Date.now(); const raw = await actualProvider.generateReconstruction(prompt, ollamaModel); const elapsedMs = Date.now() - startedAt; const validationErrors = validateFocusedDeconstructSchema(raw); if (validationErrors.length > 0) { throw new Error( "Focused deconstruction result did not match expected schema:\n" + validationErrors.map((e) => " - " + e).join("\n") ); } return { success: true, targetNodeId, observations: raw.observations, uncertainties: raw.uncertainties, assumptions: raw.assumptions, relationships: raw.relationships, possibleFollowUpQuestions: raw.possibleFollowUpQuestions, elapsedMs, }; } // ── existing API (preserved — unchanged behaviour) ────────────────────── /** * Run one live focused-deconstruction experiment. * * @param {object} params * @param {string} params.targetNodeId - the target node ID under investigation * @param {string} params.targetLabel - label of the target node * @param {string} params.targetDescription - description of the target node * @param {string} params.centralStatement - the case's central statement * @param {string} params.question - the exact real production question * @param {string} params.answer - the exact real production answer * @param {object} [params.provider] - optional injected provider (for test isolation) * @returns {object} validated result + timing details */ export async function runLiveFocusedDeconstructExperiment(params) { const { targetNodeId, targetLabel, targetDescription, centralStatement, question, answer, provider } = params; const prompt = buildFocusedDeconstructPrompt({ targetLabel, targetDescription, centralStatement, question, answer, }); return _executeFocusedPrompt({ prompt, targetNodeId, provider }); } // ── new supplied-prompt API ────────────────────────────────────────────── /** * Execute an already-built focused-deconstruct prompt through the canonical * live machinery. * * The caller supplies a pre-constructed prompt string; this function owns: * - configured OLLAMA_MODEL reading * - provider creation/invocation * - elapsed timing * - focused-deconstruct schema validation * - structured result return * * @param {object} params * @param {string} params.prompt - the already-built prompt to send * @param {string} [params.targetNodeId] - original graph node ID (preserved in result) * @param {object} [params.provider] - optional injected provider (for test isolation) * @returns {object} validated result + timing details */ export async function runLiveFocusedPromptExperiment({ prompt, targetNodeId, provider }) { return _executeFocusedPrompt({ prompt, targetNodeId, provider }); } // ── minimal frontier result contract (EXP37) ──────────────────────────── /** * Validate that a focused-deconstruction result contains exactly one * uncertainty and one follow-up question — the minimal frontier output. * Returns an array of errors (empty = valid). */ function validateMinimalFrontierResult(raw) { const errors = []; if (!Array.isArray(raw.uncertainties)) { errors.push("uncertainties must be an array"); } else if (raw.uncertainties.length !== 1) { errors.push("uncertainties must contain exactly one element, got " + raw.uncertainties.length); } else if (typeof raw.uncertainties[0] !== "string" || raw.uncertainties[0].trim() === "") { errors.push("uncertainties[0] must be a non-empty string"); } if (!Array.isArray(raw.possibleFollowUpQuestions)) { errors.push("possibleFollowUpQuestions must be an array"); } else if (raw.possibleFollowUpQuestions.length !== 1) { errors.push("possibleFollowUpQuestions must contain exactly one element, got " + raw.possibleFollowUpQuestions.length); } else if (typeof raw.possibleFollowUpQuestions[0] !== "string" || raw.possibleFollowUpQuestions[0].trim() === "") { errors.push("possibleFollowUpQuestions[0] must be a non-empty string"); } return errors; } // ── new frontier API (EXP37 minimal runner) ────────────────────────────── /** * Execute an already-built prompt and validate the returned payload * against the minimal two-field frontier contract. * * Returns only: uncertainties, possibleFollowUpQuestions, elapsedMs, success. * Does NOT require or reconstruct observations, assumptions, relationships, * or targetNodeId. * * @param {object} params * @param {string} params.prompt - the already-built prompt to send * @param {object} [params.provider] - optional injected provider (for test isolation) * @returns {object} validated result + timing details */ export async function runLiveFocusedFrontierPromptExperiment({ prompt, provider }) { const actualProvider = provider ?? getProvider(); const ollamaModel = process.env.OLLAMA_MODEL; if (!ollamaModel) throw new Error("OLLAMA_MODEL is not set in environment"); const startedAt = Date.now(); const raw = await actualProvider.generateReconstruction(prompt, ollamaModel); const elapsedMs = Date.now() - startedAt; const validationErrors = validateMinimalFrontierResult(raw); if (validationErrors.length > 0) { throw new Error( "Focused frontier result did not match expected minimal contract:\n" + validationErrors.map((e) => " - " + e).join("\n") ); } return { success: true, uncertainties: raw.uncertainties, possibleFollowUpQuestions: raw.possibleFollowUpQuestions, elapsedMs, }; } // ── three-field observation-frontier result contract (EXP38) ──────────── /** * Validate that a focused-deconstruction result contains observations + one * uncertainty + one follow-up question — the minimal observation-grounded * frontier output for EXP38. * Returns an array of errors (empty = valid). */ function validateObservationFrontierResult(raw) { const errors = []; // observations: required, non-empty array of non-empty strings if (!Array.isArray(raw.observations)) { errors.push("observations must be an array"); } else if (raw.observations.length === 0) { errors.push("observations must contain at least one element"); } else { for (let i = 0; i < raw.observations.length; i++) { if (typeof raw.observations[i] !== "string" || raw.observations[i].trim() === "") { errors.push(`observations[${i}] must be a non-empty string`); } } } // uncertainties: exactly one if (!Array.isArray(raw.uncertainties)) { errors.push("uncertainties must be an array"); } else if (raw.uncertainties.length !== 1) { errors.push("uncertainties must contain exactly one element, got " + raw.uncertainties.length); } else if (typeof raw.uncertainties[0] !== "string" || raw.uncertainties[0].trim() === "") { errors.push("uncertainties[0] must be a non-empty string"); } // possibleFollowUpQuestions: exactly one if (!Array.isArray(raw.possibleFollowUpQuestions)) { errors.push("possibleFollowUpQuestions must be an array"); } else if (raw.possibleFollowUpQuestions.length !== 1) { errors.push("possibleFollowUpQuestions must contain exactly one element, got " + raw.possibleFollowUpQuestions.length); } else if (typeof raw.possibleFollowUpQuestions[0] !== "string" || raw.possibleFollowUpQuestions[0].trim() === "") { errors.push("possibleFollowUpQuestions[0] must be a non-empty string"); } return errors; } // ── observation-frontier runner (EXP38) ────────────────────────────────── /** * Execute an already-built prompt and validate the returned payload * against the three-field observation-frontier contract. * * Requires: observations (array of non-empty strings), uncertainties (exactly 1), * possibleFollowUpQuestions (exactly 1). * Does NOT require assumptions, relationships, or targetNodeId. * * @param {object} params * @param {string} params.prompt - the already-built prompt to send * @param {object} [params.provider] - optional injected provider (for test isolation) * @returns {object} validated result + timing details */ export async function runLiveFocusedObservationFrontierPromptExperiment({ prompt, provider }) { const actualProvider = provider ?? getProvider(); const ollamaModel = process.env.OLLAMA_MODEL; if (!ollamaModel) throw new Error("OLLAMA_MODEL is not set in environment"); const startedAt = Date.now(); const raw = await actualProvider.generateReconstruction(prompt, ollamaModel); const elapsedMs = Date.now() - startedAt; const validationErrors = validateObservationFrontierResult(raw); if (validationErrors.length > 0) { throw new Error( "Focused observation-frontier result did not match expected three-field contract:\n" + validationErrors.map((e) => " - " + e).join("\n") ); } return { success: true, observations: raw.observations, uncertainties: raw.uncertainties, possibleFollowUpQuestions: raw.possibleFollowUpQuestions, elapsedMs, }; } // ── observation+assumption-frontier result contract (EXP46) ────────────── /** * Validate that a focused-deconstruction result contains observations (at least one) * + assumptions (zero or more) + one uncertainty + one follow-up question. * Returns an array of errors (empty = valid). */ function validateObservationAssumptionFrontierResult(raw) { const errors = []; // observations: required, at least one non-empty string if (!Array.isArray(raw.observations)) { errors.push("observations must be an array"); } else if (raw.observations.length === 0) { errors.push("observations must contain at least one element"); } else { for (let i = 0; i < raw.observations.length; i++) { if (typeof raw.observations[i] !== "string" || raw.observations[i].trim() === "") { errors.push(`observations[${i}] must be a non-empty string`); } } } // assumptions: zero or more, each a non-empty string if present if (!Array.isArray(raw.assumptions)) { errors.push("assumptions must be an array"); } else { for (let i = 0; i < raw.assumptions.length; i++) { if (typeof raw.assumptions[i] !== "string" || raw.assumptions[i].trim() === "") { errors.push(`assumptions[${i}] must be a non-empty string`); } } } // uncertainties: exactly one if (!Array.isArray(raw.uncertainties)) { errors.push("uncertainties must be an array"); } else if (raw.uncertainties.length !== 1) { errors.push("uncertainties must contain exactly one element, got " + raw.uncertainties.length); } else if (typeof raw.uncertainties[0] !== "string" || raw.uncertainties[0].trim() === "") { errors.push("uncertainties[0] must be a non-empty string"); } // possibleFollowUpQuestions: exactly one if (!Array.isArray(raw.possibleFollowUpQuestions)) { errors.push("possibleFollowUpQuestions must be an array"); } else if (raw.possibleFollowUpQuestions.length !== 1) { errors.push("possibleFollowUpQuestions must contain exactly one element, got " + raw.possibleFollowUpQuestions.length); } else if (typeof raw.possibleFollowUpQuestions[0] !== "string" || raw.possibleFollowUpQuestions[0].trim() === "") { errors.push("possibleFollowUpQuestions[0] must be a non-empty string"); } return errors; } // ── observation+assumption-frontier runner (EXP46) ────────────────────── /** * Execute an already-built prompt and validate the returned payload * against the observation+assumption-frontier contract. * * Requires: observations (array of non-empty strings with at least one), * assumptions (zero or more non-empty strings, [] is valid), * uncertainties (exactly 1), possibleFollowUpQuestions (exactly 1). * Does NOT require relationships, targetNodeId, or any other fields. * * @param {object} params * @param {string} params.prompt - the already-built prompt to send * @param {object} [params.provider] - optional injected provider (for test isolation) * @returns {object} validated result + timing details */ export async function runLiveFocusedObservationAssumptionFrontierPromptExperiment({ prompt, provider }) { const actualProvider = provider ?? getProvider(); const ollamaModel = process.env.OLLAMA_MODEL; if (!ollamaModel) throw new Error("OLLAMA_MODEL is not set in environment"); const startedAt = Date.now(); const raw = await actualProvider.generateReconstruction(prompt, ollamaModel); const elapsedMs = Date.now() - startedAt; const validationErrors = validateObservationAssumptionFrontierResult(raw); if (validationErrors.length > 0) { throw new Error( "Focused observation+assumption-frontier result did not match expected contract:\n" + validationErrors.map((e) => " - " + e).join("\n") ); } return { success: true, observations: raw.observations, assumptions: raw.assumptions, uncertainties: raw.uncertainties, possibleFollowUpQuestions: raw.possibleFollowUpQuestions, elapsedMs, }; } // ── re-exports for convenience ────────────────────────────────────────── export { buildFocusedDeconstructPrompt, validateFocusedDeconstructSchema }; // ── relationship-frontier result contract (EXP44) ────────────────────── /** * Validate that a focused-deconstruction result contains relationships + one * uncertainty + one follow-up question — the relationship-frontier output. * Returns an array of errors (empty = valid). */ function validateRelationshipFrontierResult(raw) { const errors = []; // relationships: required, at least one non-empty string if (!Array.isArray(raw.relationships)) { errors.push("relationships must be an array"); } else if (raw.relationships.length === 0) { errors.push("relationships must contain at least one element"); } else { for (let i = 0; i < raw.relationships.length; i++) { if (typeof raw.relationships[i] !== "string" || raw.relationships[i].trim() === "") { errors.push(`relationships[${i}] must be a non-empty string`); } } } // uncertainties: exactly one if (!Array.isArray(raw.uncertainties)) { errors.push("uncertainties must be an array"); } else if (raw.uncertainties.length !== 1) { errors.push("uncertainties must contain exactly one element, got " + raw.uncertainties.length); } else if (typeof raw.uncertainties[0] !== "string" || raw.uncertainties[0].trim() === "") { errors.push("uncertainties[0] must be a non-empty string"); } // possibleFollowUpQuestions: exactly one if (!Array.isArray(raw.possibleFollowUpQuestions)) { errors.push("possibleFollowUpQuestions must be an array"); } else if (raw.possibleFollowUpQuestions.length !== 1) { errors.push("possibleFollowUpQuestions must contain exactly one element, got " + raw.possibleFollowUpQuestions.length); } else if (typeof raw.possibleFollowUpQuestions[0] !== "string" || raw.possibleFollowUpQuestions[0].trim() === "") { errors.push("possibleFollowUpQuestions[0] must be a non-empty string"); } return errors; } // ── relationship-frontier runner (EXP44) ─────────────────────────────── /** * Execute an already-built prompt and validate the returned payload * against the relationship-frontier contract. * * Requires: relationships (array of non-empty strings with at least one), * uncertainties (exactly 1), possibleFollowUpQuestions (exactly 1). * Does NOT require observations, assumptions, or targetNodeId. * * @param {object} params * @param {string} params.prompt - the already-built prompt to send * @param {object} [params.provider] - optional injected provider (for test isolation) * @returns {object} validated result + timing details */ export async function runLiveFocusedRelationshipFrontierPromptExperiment({ prompt, provider }) { const actualProvider = provider ?? getProvider(); const ollamaModel = process.env.OLLAMA_MODEL; if (!ollamaModel) throw new Error("OLLAMA_MODEL is not set in environment"); const startedAt = Date.now(); const raw = await actualProvider.generateReconstruction(prompt, ollamaModel); const elapsedMs = Date.now() - startedAt; const validationErrors = validateRelationshipFrontierResult(raw); if (validationErrors.length > 0) { throw new Error( "Focused relationship-frontier result did not match expected contract:\n" + validationErrors.map((e) => " - " + e).join("\n") ); } return { success: true, relationships: raw.relationships, uncertainties: raw.uncertainties, possibleFollowUpQuestions: raw.possibleFollowUpQuestions, elapsedMs, }; } // ── assumption-frontier result contract (EXP45) ──────────────────────── /** * Validate that a focused-deconstruction result contains assumptions (zero or more) * + one uncertainty + one follow-up question — the assumption-frontier output. * Returns an array of errors (empty = valid). */ function validateAssumptionFrontierResult(raw) { const errors = []; // assumptions: zero or more, each a non-empty string if present if (!Array.isArray(raw.assumptions)) { errors.push("assumptions must be an array"); } else { for (let i = 0; i < raw.assumptions.length; i++) { if (typeof raw.assumptions[i] !== "string" || raw.assumptions[i].trim() === "") { errors.push(`assumptions[${i}] must be a non-empty string`); } } } // uncertainties: exactly one if (!Array.isArray(raw.uncertainties)) { errors.push("uncertainties must be an array"); } else if (raw.uncertainties.length !== 1) { errors.push("uncertainties must contain exactly one element, got " + raw.uncertainties.length); } else if (typeof raw.uncertainties[0] !== "string" || raw.uncertainties[0].trim() === "") { errors.push("uncertainties[0] must be a non-empty string"); } // possibleFollowUpQuestions: exactly one if (!Array.isArray(raw.possibleFollowUpQuestions)) { errors.push("possibleFollowUpQuestions must be an array"); } else if (raw.possibleFollowUpQuestions.length !== 1) { errors.push("possibleFollowUpQuestions must contain exactly one element, got " + raw.possibleFollowUpQuestions.length); } else if (typeof raw.possibleFollowUpQuestions[0] !== "string" || raw.possibleFollowUpQuestions[0].trim() === "") { errors.push("possibleFollowUpQuestions[0] must be a non-empty string"); } return errors; } // ── assumption-frontier runner (EXP45) ──────────────────────────────── /** * Execute an already-built prompt and validate the returned payload * against the assumption-frontier contract. * * Requires: assumptions (zero or more non-empty strings, [] is valid), * uncertainties (exactly 1), possibleFollowUpQuestions (exactly 1). * Does NOT require observations, relationships, or targetNodeId. * * @param {object} params * @param {string} params.prompt - the already-built prompt to send * @param {object} [params.provider] - optional injected provider (for test isolation) * @returns {object} validated result + timing details */ export async function runLiveFocusedAssumptionFrontierPromptExperiment({ prompt, provider }) { const actualProvider = provider ?? getProvider(); const ollamaModel = process.env.OLLAMA_MODEL; if (!ollamaModel) throw new Error("OLLAMA_MODEL is not set in environment"); const startedAt = Date.now(); const raw = await actualProvider.generateReconstruction(prompt, ollamaModel); const elapsedMs = Date.now() - startedAt; const validationErrors = validateAssumptionFrontierResult(raw); if (validationErrors.length > 0) { throw new Error( "Focused assumption-frontier result did not match expected contract:\n" + validationErrors.map((e) => " - " + e).join("\n") ); } return { success: true, assumptions: raw.assumptions, uncertainties: raw.uncertainties, possibleFollowUpQuestions: raw.possibleFollowUpQuestions, elapsedMs, }; }