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:
2026-04-26 14:06:14 -04:00
parent ff9b86c3e9
commit fc10e46ccc
23 changed files with 1530 additions and 1012 deletions

View File

@@ -27,6 +27,9 @@ type Turn = {
actions: Action[];
validation: ValidationResult[];
createdAt: number;
interpreter?: {
status: "resolved" | "needs_clarification" | "rejected";
};
};
type WorldState = {
@@ -46,6 +49,21 @@ type ProcessTurnResponse = {
actions: Action[];
validation: ValidationResult[];
worldState: WorldState;
interpreter: {
interpreterVersion: string;
status: "resolved" | "needs_clarification" | "rejected";
selectedConfidence?: number;
diagnostics: string[];
clarification?: {
reasonCode: string;
question: string;
options?: Array<{
id: string;
label: string;
value: string;
}>;
};
};
};
type RuleCheck = {
@@ -65,6 +83,7 @@ type ActionRuleSet = {
type SceneRulebook = {
id: string;
worldId: string;
version: number;
name: string;
description?: string;
rules: ActionRuleSet[];
@@ -75,6 +94,7 @@ type SceneRulebook = {
const starterPrompts = [
"look around",
"take key",
"give key to groundskeeper",
"open door",
"move to exit",
];
@@ -220,6 +240,12 @@ function RulebookEditor() {
<p className="rule-hint">
For character permissions, use <code>{`{"op":"actorIdIn","allowedIds":["player"]}`}</code> or <code>{`{"op":"actorNameIn","allowedNames":["Player"]}`}</code>.
</p>
<p className="rule-hint">
For conditional creation, use <code>{`{"op":"actionMetadataEq","key":"createIfMissing","value":true}`}</code> together with actor checks.
</p>
<p className="rule-hint">
For transfer ownership checks, use <code>{`{"op":"itemInInventory","itemMetadataKey":"itemId","holderRole":"actor"}`}</code>.
</p>
{ruleSet.checks.length > 0 ? (
<ul className="check-list">
{ruleSet.checks.map((check) => (
@@ -354,6 +380,23 @@ export default function App() {
<section className="result-card">
<h2>Latest result</h2>
<p><strong>Input:</strong> {latest.rawText}</p>
<p>
<strong>Interpreter:</strong> {latest.interpreter.status}
{typeof latest.interpreter.selectedConfidence === "number"
? ` (${Math.round(latest.interpreter.selectedConfidence * 100)}% confidence)`
: ""}
</p>
{latest.interpreter.clarification ? (
<p>
<strong>Clarification:</strong> {latest.interpreter.clarification.question}
</p>
) : null}
{latest.interpreter.clarification?.options?.length ? (
<p>
<strong>Options:</strong>{" "}
{latest.interpreter.clarification.options.map((o) => o.label).join(", ")}
</p>
) : null}
<ul className="timeline-list compact">
{latest.validation.map((v) => (
<li key={v.actionIndex}>
@@ -361,8 +404,19 @@ export default function App() {
{v.message ? ` - ${v.message}` : ""}
</li>
))}
{latest.validation.length === 0 ? <li>No actions parsed.</li> : null}
{latest.validation.length === 0 ? (
<li>
{latest.interpreter.status === "resolved"
? "No actions parsed."
: "Turn requires clarification before any action can be validated."}
</li>
) : null}
</ul>
{latest.interpreter.diagnostics.length > 0 ? (
<p>
<strong>Diagnostics:</strong> {latest.interpreter.diagnostics.join(" | ")}
</p>
) : null}
</section>
) : null}
@@ -408,6 +462,9 @@ export default function App() {
{snapshot?.turns.slice().reverse().map((turn) => (
<li key={turn.id}>
<strong>{turn.rawText}</strong>
{turn.interpreter ? (
<span> [interp:{turn.interpreter.status}]</span>
) : null}
{turn.validation.map((v) => (
<span key={v.actionIndex}> [{v.success ? "ok" : v.reason}]</span>
))}
@@ -425,211 +482,4 @@ export default function App() {
</section>
</main>
);
}
type Entity = {
id: string;
type: string;
name: string;
attributes: Record<string, unknown>;
};
type Action = {
actorId: string;
type: string;
targetId?: string;
locationId?: string;
};
type ValidationResult = {
actionIndex: number;
success: boolean;
reason?: string;
message?: string;
};
type Turn = {
id: string;
rawText: string;
actions: Action[];
validation: ValidationResult[];
createdAt: number;
};
type WorldState = {
id: string;
entities: Record<string, Entity>;
metadata: Record<string, unknown>;
createdAt: number;
};
type AppSnapshot = {
worldState: WorldState;
turns: Turn[];
};
type ProcessTurnResponse = {
rawText: string;
actions: Action[];
validation: ValidationResult[];
worldState: WorldState;
};
const starterPrompts = [
"look around",
"take key",
"open door",
"move to exit",
];
async function fetchJson<T>(input: RequestInfo, init?: RequestInit): Promise<T> {
const response = await fetch(input, init);
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`);
}
return response.json() as Promise<T>;
}
export default function App() {
const [snapshot, setSnapshot] = useState<AppSnapshot | null>(null);
const [latest, setLatest] = useState<ProcessTurnResponse | null>(null);
const [input, setInput] = useState("look around");
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
void fetchJson<AppSnapshot>("/api/state")
.then((data) => {
setSnapshot(data);
setLoading(false);
})
.catch((fetchError: Error) => {
setError(fetchError.message);
setLoading(false);
});
}, []);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitting(true);
setError(null);
try {
const result = await fetchJson<ProcessTurnResponse>("/api/turn", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ input }),
});
setLatest(result);
const nextSnapshot = await fetchJson<AppSnapshot>("/api/state");
setSnapshot(nextSnapshot);
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "Unknown error");
} finally {
setSubmitting(false);
}
}
async function onReset() {
setError(null);
try {
const result = await fetchJson<AppSnapshot>("/api/reset", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
setSnapshot(result);
setLatest(null);
} catch (resetError) {
setError(resetError instanceof Error ? resetError.message : "Unknown error");
}
}
const entities = snapshot ? Object.values(snapshot.worldState.entities) : [];
return (
<main className="page-shell">
<section className="hero-panel">
<p className="eyebrow">CharacterGarden</p>
<h1>Bootable narrative sandbox</h1>
<p className="lede">
Submit a turn, inspect world state, and verify how the truth engine is mutating state.
</p>
<form className="turn-form" onSubmit={onSubmit}>
<label htmlFor="turn-input">Turn input</label>
<textarea
id="turn-input"
value={input}
onChange={(event) => setInput(event.target.value)}
rows={4}
placeholder="Type what the player does..."
/>
<div className="actions-row">
<button type="submit" disabled={submitting}>
{submitting ? "Submitting..." : "Run turn"}
</button>
<button type="button" className="chip" onClick={onReset}>
Reset world
</button>
<div className="chips">
{starterPrompts.map((prompt) => (
<button key={prompt} type="button" className="chip" onClick={() => setInput(prompt)}>
{prompt}
</button>
))}
</div>
</div>
</form>
{latest ? (
<section className="result-card">
<h2>Latest result</h2>
<p><strong>Input:</strong> {latest.rawText}</p>
<ul className="timeline-list compact">
{latest.validation.map((v) => (
<li key={v.actionIndex}>
Action {v.actionIndex}: {v.success ? "ok" : `failed (${v.reason ?? "unknown"})`}
{v.message ? ` - ${v.message}` : ""}
</li>
))}
{latest.validation.length === 0 ? <li>No actions parsed.</li> : null}
</ul>
</section>
) : null}
{error ? <p className="error-banner">{error}</p> : null}
</section>
<section className="inspector-grid">
<article className="panel">
<h2>World state</h2>
{loading && !snapshot ? <p>Loading...</p> : null}
<ul className="entity-list">
{entities.map((entity) => (
<li key={entity.id}>
<strong>{entity.name}</strong> <span>{entity.type}</span>
<pre>{JSON.stringify(entity.attributes, null, 0)}</pre>
</li>
))}
</ul>
</article>
<article className="panel">
<h2>Turn log</h2>
<ul className="timeline-list">
{snapshot?.turns.slice().reverse().map((turn) => (
<li key={turn.id}>
<strong>{turn.rawText}</strong>
{turn.validation.map((v) => (
<span key={v.actionIndex}> [{v.success ? "ok" : v.reason}]</span>
))}
</li>
))}
</ul>
</article>
</section>
</main>
);
}