feat(interpreter): implement hybrid intent resolution with LLM and deterministic fallback
- Added new contracts for intent interpretation, including InterpreterOutput and ResolverMode. - Implemented deterministic intent resolver with clarity checks for ambiguous references and empty input. - Developed LLM intent resolver that communicates with an external model, handling JSON responses and fallback clarifications. - Created an interpretTurn function to manage intent resolution based on the selected resolver mode. - Introduced validation for interpreter output to ensure integrity before processing actions. - Established a turn manager to orchestrate turn processing, including action validation and world state mutation. - Added integration tests to verify the functionality of the new intent resolution system. Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
import type { InterpreterOutput } from "../../contracts/intent";
|
||||
import type { ResolveIntentInput } from "../resolveIntent";
|
||||
import { parseTextToActions } from "../../parser/parseTextToActions";
|
||||
|
||||
export const DETERMINISTIC_INTERPRETER_VERSION = "deterministic-v1";
|
||||
|
||||
function hasAmbiguousReference(input: string): boolean {
|
||||
return /\b(it|them|that|this|him|her|there|here)\b/i.test(input);
|
||||
}
|
||||
|
||||
export function resolveDeterministicIntent(input: ResolveIntentInput): InterpreterOutput {
|
||||
const trimmed = input.rawText.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return {
|
||||
interpreterVersion: DETERMINISTIC_INTERPRETER_VERSION,
|
||||
rawText: input.rawText,
|
||||
actorId: input.actorId,
|
||||
resolutionSource: "deterministic",
|
||||
minConfidence: input.minConfidence,
|
||||
status: "rejected",
|
||||
selectedActions: [],
|
||||
candidates: [],
|
||||
diagnostics: ["Input was empty after trimming whitespace."],
|
||||
clarification: {
|
||||
reasonCode: "EMPTY_INPUT",
|
||||
question: "What would you like to do?",
|
||||
field: "verb",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const actions = parseTextToActions(trimmed, input.actorId);
|
||||
if (actions.length > 0) {
|
||||
const candidates = actions.map((action) => ({
|
||||
action,
|
||||
confidence: 0.85,
|
||||
rationale: "Matched deterministic parser pattern and normalized to canonical action.",
|
||||
}));
|
||||
const selectedConfidence = candidates.reduce((min, c) => Math.min(min, c.confidence), 1);
|
||||
|
||||
if (selectedConfidence < input.minConfidence) {
|
||||
return {
|
||||
interpreterVersion: DETERMINISTIC_INTERPRETER_VERSION,
|
||||
rawText: input.rawText,
|
||||
actorId: input.actorId,
|
||||
resolutionSource: "deterministic",
|
||||
minConfidence: input.minConfidence,
|
||||
selectedConfidence,
|
||||
status: "needs_clarification",
|
||||
selectedActions: [],
|
||||
candidates,
|
||||
diagnostics: [
|
||||
"Parser produced candidates but confidence did not meet threshold.",
|
||||
],
|
||||
clarification: {
|
||||
reasonCode: "LOW_CONFIDENCE",
|
||||
question: "I found a possible action but confidence is low. Can you rephrase your intent?",
|
||||
field: "verb",
|
||||
options: [
|
||||
{ id: "inspect", label: "Inspect", value: "inspect" },
|
||||
{ id: "move", label: "Move", value: "move" },
|
||||
{ id: "take", label: "Take", value: "take" },
|
||||
{ id: "open", label: "Open", value: "open" },
|
||||
{ id: "introduce", label: "Introduce", value: "introduce" },
|
||||
{ id: "describe", label: "Describe", value: "describe" },
|
||||
{ id: "transfer", label: "Transfer", value: "transfer" },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
interpreterVersion: DETERMINISTIC_INTERPRETER_VERSION,
|
||||
rawText: input.rawText,
|
||||
actorId: input.actorId,
|
||||
resolutionSource: "deterministic",
|
||||
minConfidence: input.minConfidence,
|
||||
selectedConfidence,
|
||||
status: "resolved",
|
||||
selectedActions: actions,
|
||||
candidates,
|
||||
diagnostics: ["Resolved by deterministic parser rules."],
|
||||
};
|
||||
}
|
||||
|
||||
if (hasAmbiguousReference(trimmed)) {
|
||||
return {
|
||||
interpreterVersion: DETERMINISTIC_INTERPRETER_VERSION,
|
||||
rawText: input.rawText,
|
||||
actorId: input.actorId,
|
||||
resolutionSource: "deterministic",
|
||||
minConfidence: input.minConfidence,
|
||||
status: "needs_clarification",
|
||||
selectedActions: [],
|
||||
candidates: [],
|
||||
diagnostics: ["Could not resolve pronoun/reference to a concrete entity."],
|
||||
clarification: {
|
||||
reasonCode: "AMBIGUOUS_REFERENCE",
|
||||
question: "I am not sure what that refers to. Which item, character, or location did you mean?",
|
||||
field: "target",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
interpreterVersion: DETERMINISTIC_INTERPRETER_VERSION,
|
||||
rawText: input.rawText,
|
||||
actorId: input.actorId,
|
||||
resolutionSource: "deterministic",
|
||||
minConfidence: input.minConfidence,
|
||||
status: "needs_clarification",
|
||||
selectedActions: [],
|
||||
candidates: [],
|
||||
diagnostics: ["No parser pattern matched this input."],
|
||||
clarification: {
|
||||
reasonCode: "UNRECOGNIZED_INTENT",
|
||||
question: "I could not map that to a known action. Try verbs like inspect, move, take, open, introduce, describe, or transfer.",
|
||||
field: "verb",
|
||||
options: [
|
||||
{ id: "inspect", label: "Inspect", value: "inspect" },
|
||||
{ id: "move", label: "Move", value: "move" },
|
||||
{ id: "take", label: "Take", value: "take" },
|
||||
{ id: "open", label: "Open", value: "open" },
|
||||
{ id: "introduce", label: "Introduce", value: "introduce" },
|
||||
{ id: "describe", label: "Describe", value: "describe" },
|
||||
{ id: "transfer", label: "Transfer", value: "transfer" },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
282
charactergarden/app/src/interpreter/adapters/llmResolver.ts
Normal file
282
charactergarden/app/src/interpreter/adapters/llmResolver.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
import type { Action } from "../../contracts/action";
|
||||
import type { InterpreterOutput } from "../../contracts/intent";
|
||||
import type { ResolveIntentInput } from "../resolveIntent";
|
||||
|
||||
export const LLM_INTERPRETER_VERSION = "llm-v1-ollama";
|
||||
|
||||
type LlmClarification = {
|
||||
reasonCode?: string;
|
||||
question?: string;
|
||||
field?: "verb" | "target" | "item" | "recipient" | "location";
|
||||
options?: Array<{
|
||||
id?: string;
|
||||
label?: string;
|
||||
value?: string;
|
||||
entityId?: string;
|
||||
entityType?: "character" | "item" | "room" | "unknown";
|
||||
}>;
|
||||
};
|
||||
|
||||
type LlmIntentResponse = {
|
||||
status?: "resolved" | "needs_clarification" | "rejected";
|
||||
selectedActions?: unknown;
|
||||
selectedConfidence?: unknown;
|
||||
clarification?: LlmClarification;
|
||||
rationale?: string;
|
||||
};
|
||||
|
||||
function fallbackClarification(input: ResolveIntentInput, diagnostic: string): InterpreterOutput {
|
||||
return {
|
||||
interpreterVersion: LLM_INTERPRETER_VERSION,
|
||||
rawText: input.rawText,
|
||||
actorId: input.actorId,
|
||||
resolutionSource: "llm",
|
||||
minConfidence: input.minConfidence,
|
||||
status: "needs_clarification",
|
||||
selectedActions: [],
|
||||
candidates: [],
|
||||
diagnostics: [diagnostic],
|
||||
clarification: {
|
||||
reasonCode: "UNRECOGNIZED_INTENT",
|
||||
question: "I could not confidently resolve that intent. Please rephrase with a clear verb.",
|
||||
field: "verb",
|
||||
options: [
|
||||
{ id: "inspect", label: "Inspect", value: "inspect" },
|
||||
{ id: "move", label: "Move", value: "move" },
|
||||
{ id: "take", label: "Take", value: "take" },
|
||||
{ id: "open", label: "Open", value: "open" },
|
||||
{ id: "introduce", label: "Introduce", value: "introduce" },
|
||||
{ id: "describe", label: "Describe", value: "describe" },
|
||||
{ id: "transfer", label: "Transfer", value: "transfer" },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function extractFirstJsonObject(text: string): string | null {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
const codeFenceMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
||||
if (codeFenceMatch?.[1]) {
|
||||
const fenced = codeFenceMatch[1].trim();
|
||||
if (fenced.startsWith("{") && fenced.endsWith("}")) {
|
||||
return fenced;
|
||||
}
|
||||
}
|
||||
|
||||
const firstBrace = trimmed.indexOf("{");
|
||||
const lastBrace = trimmed.lastIndexOf("}");
|
||||
if (firstBrace >= 0 && lastBrace > firstBrace) {
|
||||
return trimmed.slice(firstBrace, lastBrace + 1);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function toActionArray(value: unknown, actorId: string): Action[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
|
||||
const actions: Action[] = [];
|
||||
for (const item of value) {
|
||||
if (!item || typeof item !== "object") continue;
|
||||
const action = item as Record<string, unknown>;
|
||||
const type = typeof action.type === "string" ? action.type.trim() : "";
|
||||
if (!type) continue;
|
||||
|
||||
const normalized: Action = {
|
||||
actorId,
|
||||
type,
|
||||
};
|
||||
|
||||
if (typeof action.actorId === "string" && action.actorId.trim()) {
|
||||
normalized.actorId = action.actorId;
|
||||
}
|
||||
if (typeof action.targetId === "string" && action.targetId.trim()) {
|
||||
normalized.targetId = action.targetId;
|
||||
}
|
||||
if (typeof action.locationId === "string" && action.locationId.trim()) {
|
||||
normalized.locationId = action.locationId;
|
||||
}
|
||||
if (action.metadata && typeof action.metadata === "object" && !Array.isArray(action.metadata)) {
|
||||
normalized.metadata = action.metadata as Record<string, unknown>;
|
||||
}
|
||||
|
||||
actions.push(normalized);
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
function toConfidence(value: unknown, fallback: number): number {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||
return fallback;
|
||||
}
|
||||
if (value < 0) return 0;
|
||||
if (value > 1) return 1;
|
||||
return value;
|
||||
}
|
||||
|
||||
function toReasonCode(value: string | undefined):
|
||||
| "UNRECOGNIZED_INTENT"
|
||||
| "AMBIGUOUS_REFERENCE"
|
||||
| "EMPTY_INPUT"
|
||||
| "LOW_CONFIDENCE"
|
||||
| "INTERNAL_INVALID_OUTPUT" {
|
||||
const normalized = (value ?? "").trim().toUpperCase();
|
||||
switch (normalized) {
|
||||
case "AMBIGUOUS_REFERENCE":
|
||||
return "AMBIGUOUS_REFERENCE";
|
||||
case "EMPTY_INPUT":
|
||||
return "EMPTY_INPUT";
|
||||
case "LOW_CONFIDENCE":
|
||||
return "LOW_CONFIDENCE";
|
||||
case "INTERNAL_INVALID_OUTPUT":
|
||||
return "INTERNAL_INVALID_OUTPUT";
|
||||
default:
|
||||
return "UNRECOGNIZED_INTENT";
|
||||
}
|
||||
}
|
||||
|
||||
function buildPrompt(input: ResolveIntentInput): { system: string; user: string } {
|
||||
const system = [
|
||||
"You are an intent-to-actions resolver for a text adventure engine.",
|
||||
"Return ONLY JSON with this shape:",
|
||||
'{"status":"resolved|needs_clarification|rejected","selectedActions":[{"type":"inspect|move|take|open|introduce|describe|transfer","targetId":"optional","locationId":"optional","metadata":{"optional":"object"}}],"selectedConfidence":0.0,"clarification":{"reasonCode":"UNRECOGNIZED_INTENT|AMBIGUOUS_REFERENCE|EMPTY_INPUT|LOW_CONFIDENCE|INTERNAL_INVALID_OUTPUT","question":"string","field":"verb|target|item|recipient|location"},"rationale":"brief"}',
|
||||
"If unresolved, selectedActions must be an empty array and clarification must be present.",
|
||||
"Use canonical action types only. Do not invent fields.",
|
||||
].join(" ");
|
||||
|
||||
const user = [
|
||||
`actorId: ${input.actorId}`,
|
||||
`input: ${JSON.stringify(input.rawText)}`,
|
||||
`minimum_confidence: ${input.minConfidence}`,
|
||||
].join("\n");
|
||||
|
||||
return { system, user };
|
||||
}
|
||||
|
||||
export async function resolveLlmIntent(input: ResolveIntentInput): Promise<InterpreterOutput> {
|
||||
const baseUrl = (process.env.LLM_RESOLVER_URL ?? process.env.OLLAMA_URL ?? "").trim();
|
||||
const model = (process.env.LLM_RESOLVER_MODEL ?? "llama3.2:3b").trim();
|
||||
const timeoutMs = Number(process.env.LLM_RESOLVER_TIMEOUT_MS ?? 6000);
|
||||
|
||||
if (!baseUrl) {
|
||||
return fallbackClarification(input, "LLM resolver disabled: no LLM_RESOLVER_URL/OLLAMA_URL configured.");
|
||||
}
|
||||
|
||||
const prompt = buildPrompt(input);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), Number.isFinite(timeoutMs) ? timeoutMs : 6000);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl.replace(/\/$/, "")}/api/chat`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
stream: false,
|
||||
format: "json",
|
||||
options: {
|
||||
temperature: 0,
|
||||
},
|
||||
messages: [
|
||||
{ role: "system", content: prompt.system },
|
||||
{ role: "user", content: prompt.user },
|
||||
],
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return fallbackClarification(
|
||||
input,
|
||||
`LLM resolver HTTP error: ${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
message?: { content?: string };
|
||||
};
|
||||
const text = payload.message?.content ?? "";
|
||||
const jsonText = extractFirstJsonObject(text);
|
||||
if (!jsonText) {
|
||||
return fallbackClarification(input, "LLM resolver returned non-JSON content.");
|
||||
}
|
||||
|
||||
let parsed: LlmIntentResponse;
|
||||
try {
|
||||
parsed = JSON.parse(jsonText) as LlmIntentResponse;
|
||||
} catch {
|
||||
return fallbackClarification(input, "LLM resolver returned malformed JSON.");
|
||||
}
|
||||
|
||||
const status = parsed.status ?? "needs_clarification";
|
||||
const selectedActions = toActionArray(parsed.selectedActions, input.actorId);
|
||||
const selectedConfidence = toConfidence(parsed.selectedConfidence, 0.7);
|
||||
const diagnostics = [
|
||||
"Resolved via LLM resolver.",
|
||||
...(parsed.rationale ? [parsed.rationale] : []),
|
||||
];
|
||||
|
||||
if (status === "resolved" && selectedActions.length > 0 && selectedConfidence >= input.minConfidence) {
|
||||
return {
|
||||
interpreterVersion: LLM_INTERPRETER_VERSION,
|
||||
rawText: input.rawText,
|
||||
actorId: input.actorId,
|
||||
resolutionSource: "llm",
|
||||
minConfidence: input.minConfidence,
|
||||
selectedConfidence,
|
||||
status: "resolved",
|
||||
selectedActions,
|
||||
candidates: selectedActions.map((action) => ({
|
||||
action,
|
||||
confidence: selectedConfidence,
|
||||
rationale: "Selected by configured LLM resolver.",
|
||||
})),
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
interpreterVersion: LLM_INTERPRETER_VERSION,
|
||||
rawText: input.rawText,
|
||||
actorId: input.actorId,
|
||||
resolutionSource: "llm",
|
||||
minConfidence: input.minConfidence,
|
||||
selectedConfidence,
|
||||
status: status === "rejected" ? "rejected" : "needs_clarification",
|
||||
selectedActions: [],
|
||||
candidates: [],
|
||||
diagnostics: [
|
||||
"LLM resolver did not produce a high-confidence resolved action set.",
|
||||
...diagnostics,
|
||||
],
|
||||
clarification: {
|
||||
reasonCode: toReasonCode(parsed.clarification?.reasonCode),
|
||||
question:
|
||||
parsed.clarification?.question ??
|
||||
"I need a clearer command. Please rephrase with a specific verb and target.",
|
||||
field: parsed.clarification?.field,
|
||||
options: parsed.clarification?.options
|
||||
?.filter((option) => !!option && typeof option.value === "string" && option.value.trim())
|
||||
.map((option, index) => ({
|
||||
id: option.id ?? `llm-option-${index + 1}`,
|
||||
label: option.label ?? option.value ?? "Option",
|
||||
value: option.value ?? "",
|
||||
entityId: option.entityId,
|
||||
entityType: option.entityType,
|
||||
})),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown LLM resolver error.";
|
||||
return fallbackClarification(input, `LLM resolver request failed: ${message}`);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
67
charactergarden/app/src/interpreter/interpretTurn.ts
Normal file
67
charactergarden/app/src/interpreter/interpretTurn.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { InterpreterOutput } from "../contracts/intent";
|
||||
import { resolveDeterministicIntent } from "./adapters/deterministicResolver";
|
||||
import { resolveLlmIntent } from "./adapters/llmResolver";
|
||||
import {
|
||||
type ResolverMode,
|
||||
normalizeResolverMode,
|
||||
} from "./resolveIntent";
|
||||
|
||||
const DEFAULT_MIN_CONFIDENCE = 0.65;
|
||||
|
||||
type InterpretTurnOptions = {
|
||||
mode?: ResolverMode;
|
||||
minConfidence?: number;
|
||||
};
|
||||
|
||||
function getResolverMode(options?: InterpretTurnOptions): ResolverMode {
|
||||
if (options?.mode) {
|
||||
return options.mode;
|
||||
}
|
||||
return normalizeResolverMode(process.env.INTENT_RESOLVER_MODE);
|
||||
}
|
||||
|
||||
function buildInput(rawText: string, actorId: string, options?: InterpretTurnOptions) {
|
||||
return {
|
||||
rawText,
|
||||
actorId,
|
||||
minConfidence: options?.minConfidence ?? DEFAULT_MIN_CONFIDENCE,
|
||||
};
|
||||
}
|
||||
|
||||
export async function interpretTurn(
|
||||
rawText: string,
|
||||
actorId = "player",
|
||||
options?: InterpretTurnOptions
|
||||
): Promise<InterpreterOutput> {
|
||||
const mode = getResolverMode(options);
|
||||
const input = buildInput(rawText, actorId, options);
|
||||
|
||||
if (mode === "deterministic") {
|
||||
return resolveDeterministicIntent(input);
|
||||
}
|
||||
|
||||
if (mode === "llm") {
|
||||
return resolveLlmIntent(input);
|
||||
}
|
||||
|
||||
// hybrid mode: prefer LLM when available, but deterministically fall back.
|
||||
const llmOutput = await resolveLlmIntent(input);
|
||||
if (llmOutput.status === "resolved") {
|
||||
return {
|
||||
...llmOutput,
|
||||
resolutionSource: "hybrid",
|
||||
diagnostics: ["Hybrid mode: resolved via LLM adapter.", ...llmOutput.diagnostics],
|
||||
};
|
||||
}
|
||||
|
||||
const deterministicOutput = resolveDeterministicIntent(input);
|
||||
return {
|
||||
...deterministicOutput,
|
||||
resolutionSource: "hybrid",
|
||||
diagnostics: [
|
||||
"Hybrid mode: LLM adapter did not resolve intent; used deterministic fallback.",
|
||||
...deterministicOutput.diagnostics,
|
||||
...llmOutput.diagnostics,
|
||||
],
|
||||
};
|
||||
}
|
||||
22
charactergarden/app/src/interpreter/resolveIntent.ts
Normal file
22
charactergarden/app/src/interpreter/resolveIntent.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { InterpreterOutput } from "../contracts/intent";
|
||||
|
||||
export type ResolverMode = "deterministic" | "llm" | "hybrid";
|
||||
|
||||
export type ResolveIntentInput = {
|
||||
rawText: string;
|
||||
actorId: string;
|
||||
minConfidence: number;
|
||||
};
|
||||
|
||||
export type IntentResolver = {
|
||||
name: string;
|
||||
resolve(input: ResolveIntentInput): Promise<InterpreterOutput> | InterpreterOutput;
|
||||
};
|
||||
|
||||
export function normalizeResolverMode(value: string | undefined): ResolverMode {
|
||||
const normalized = (value ?? "").trim().toLowerCase();
|
||||
if (normalized === "deterministic" || normalized === "llm" || normalized === "hybrid") {
|
||||
return normalized;
|
||||
}
|
||||
return "hybrid";
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { InterpreterOutput } from "../contracts/intent";
|
||||
|
||||
const VALID_STATUSES = new Set(["resolved", "needs_clarification", "rejected"]);
|
||||
|
||||
export type InterpreterValidation = {
|
||||
isValid: boolean;
|
||||
issues: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Runtime guard for the interpreter boundary.
|
||||
*
|
||||
* The turn manager uses this to ensure malformed interpreter output never
|
||||
* reaches deterministic validation/mutation logic.
|
||||
*/
|
||||
export function validateInterpreterOutput(output: InterpreterOutput): InterpreterValidation {
|
||||
const issues: string[] = [];
|
||||
|
||||
if (!output || typeof output !== "object") {
|
||||
return { isValid: false, issues: ["Interpreter output must be an object."] };
|
||||
}
|
||||
|
||||
if (typeof output.interpreterVersion !== "string" || !output.interpreterVersion.trim()) {
|
||||
issues.push("interpreterVersion must be a non-empty string.");
|
||||
}
|
||||
|
||||
if (typeof output.rawText !== "string") {
|
||||
issues.push("rawText must be a string.");
|
||||
}
|
||||
|
||||
if (typeof output.actorId !== "string" || !output.actorId.trim()) {
|
||||
issues.push("actorId must be a non-empty string.");
|
||||
}
|
||||
|
||||
if (!VALID_STATUSES.has(output.status)) {
|
||||
issues.push("status must be one of: resolved, needs_clarification, rejected.");
|
||||
}
|
||||
|
||||
if (!Array.isArray(output.selectedActions)) {
|
||||
issues.push("selectedActions must be an array.");
|
||||
}
|
||||
|
||||
if (!Array.isArray(output.candidates)) {
|
||||
issues.push("candidates must be an array.");
|
||||
}
|
||||
|
||||
if (!Array.isArray(output.diagnostics)) {
|
||||
issues.push("diagnostics must be an array.");
|
||||
}
|
||||
|
||||
if (typeof output.minConfidence !== "number" || output.minConfidence < 0 || output.minConfidence > 1) {
|
||||
issues.push("minConfidence must be a number between 0 and 1.");
|
||||
}
|
||||
|
||||
if (output.selectedConfidence !== undefined) {
|
||||
if (
|
||||
typeof output.selectedConfidence !== "number" ||
|
||||
output.selectedConfidence < 0 ||
|
||||
output.selectedConfidence > 1
|
||||
) {
|
||||
issues.push("selectedConfidence must be between 0 and 1 when provided.");
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of output.candidates) {
|
||||
if (typeof candidate.confidence !== "number" || candidate.confidence < 0 || candidate.confidence > 1) {
|
||||
issues.push("Every candidate confidence must be between 0 and 1.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (output.status === "resolved") {
|
||||
if (output.selectedActions.length === 0) {
|
||||
issues.push("resolved output must include at least one selected action.");
|
||||
}
|
||||
if (output.clarification) {
|
||||
issues.push("resolved output must not include clarification.");
|
||||
}
|
||||
}
|
||||
|
||||
if (output.status !== "resolved") {
|
||||
if (output.selectedActions.length > 0) {
|
||||
issues.push("unresolved/rejected output must not include selected actions.");
|
||||
}
|
||||
if (!output.clarification) {
|
||||
issues.push("unresolved/rejected output must include clarification.");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: issues.length === 0,
|
||||
issues,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user