From 2f6af46c79daeaac42c6652ee29b6bdb0850a48c Mon Sep 17 00:00:00 2001 From: spencer Date: Thu, 23 Apr 2026 22:55:16 -0400 Subject: [PATCH] feat: enhance parser feedback mechanism for improved user guidance Co-authored-by: Copilot --- charactergarden/app/src/app.ts | 19 +++++- charactergarden/app/src/llmAdapter.ts | 91 +++++++++++++++++++++++-- charactergarden/frontend/src/App.tsx | 4 ++ charactergarden/frontend/src/styles.css | 10 +++ thoughts.md | 66 ++++++++++++++++-- 5 files changed, 174 insertions(+), 16 deletions(-) diff --git a/charactergarden/app/src/app.ts b/charactergarden/app/src/app.ts index 7d0557d..874ac2c 100644 --- a/charactergarden/app/src/app.ts +++ b/charactergarden/app/src/app.ts @@ -17,6 +17,7 @@ export interface AppStateSnapshot { export interface TurnResult { narration: string; parser: "fallback"; + parser_feedback?: string; actions: Action[]; accepted: Action[]; rejected: { action: Action; reason: string }[]; @@ -152,10 +153,15 @@ function narrateResult( worldState: WorldState, accepted: Action[], rejected: { action: Action; reason: string }[], - latentReason?: string + latentReason?: string, + parserFeedback?: string ): string { const lines: string[] = []; + if (parserFeedback) { + lines.push(parserFeedback); + } + if (latentReason) { lines.push(latentReason); } @@ -212,7 +218,7 @@ export function createCharacterGardenApp(dbPath: string): CharacterGardenApp { function processTurn(input: string): TurnResult { const turnNumber = db.listTurns().length + 1; - const { actions, parser } = extractActionsFromProse(input); + const { actions, parser, parser_feedback: parserFeedback } = extractActionsFromProse(input); let activeWorldState = worldState; let latentResolution: TurnResult["latent_resolution"]; @@ -260,7 +266,13 @@ export function createCharacterGardenApp(dbPath: string): CharacterGardenApp { const validation = validate(normalizedActions, activeWorldState); const nextWorldState = applyChanges(activeWorldState, validation.state_changes); - const narration = narrateResult(nextWorldState, validation.accepted, validation.rejected, latentResolution?.reason); + const narration = narrateResult( + nextWorldState, + validation.accepted, + validation.rejected, + latentResolution?.reason, + parserFeedback + ); const turnRecord: Turn = { id: randomUUID(), @@ -300,6 +312,7 @@ export function createCharacterGardenApp(dbPath: string): CharacterGardenApp { return { narration, parser, + parser_feedback: parserFeedback, actions: normalizedActions, accepted: validation.accepted, rejected: validation.rejected, diff --git a/charactergarden/app/src/llmAdapter.ts b/charactergarden/app/src/llmAdapter.ts index 3f57692..f917acc 100644 --- a/charactergarden/app/src/llmAdapter.ts +++ b/charactergarden/app/src/llmAdapter.ts @@ -1,10 +1,52 @@ -import { Action } from "./types"; +import { Action, ALLOWED_VERBS, Entity } from "./types"; export interface ExtractedActions { actions: Action[]; parser: "fallback"; + parser_feedback?: string; } +export interface ActionExtractionPrompt { + system: string; + user: string; +} + +function toEntityLine(entity: Entity): string { + const location = typeof entity.attributes["location"] === "string" + ? ` location=${entity.attributes["location"]}` + : ""; + + return `- ${entity.id} [${entity.type}] "${entity.name}"${location}`; +} + +export function buildActionExtractionPrompt(input: string, entities: Entity[], actorId = "player"): ActionExtractionPrompt { + const entityDigest = entities + .slice() + .sort((left, right) => left.id.localeCompare(right.id)) + .map(toEntityLine) + .join("\n"); + + const system = [ + "You convert player prose into canonical game actions.", + "Only produce actions that are valid in the current world snapshot.", + `Allowed verbs: ${ALLOWED_VERBS.join(", ")}`, + "Use exact entity ids from the world snapshot for actor and target.", + "If intent is unclear or target is missing, return no actions and a parser_feedback string suggesting rephrasing.", + "Return strict JSON only: {\"actions\": Action[], \"parser_feedback\"?: string}", + ].join("\n"); + + const user = [ + `Actor id: ${actorId}`, + "World snapshot entities:", + entityDigest || "- (none)", + `Player input: ${input}`, + ].join("\n"); + + return { system, user }; +} + +const REPHRASE_EXAMPLES = "Try rephrasing like: 'look around', 'go to the shed', 'open the gate', or 'pull out my phone'."; + const ROOM_ALIASES: Record = { garden: "garden", shed: "shed", @@ -54,8 +96,9 @@ export function extractActionsFromProse(input: string, actorId = "player"): Extr if (!text) { return { - actions: [{ actor: actorId, verb: "inspect", target: actorId }], + actions: [], parser: "fallback", + parser_feedback: `I couldn't parse an empty turn. ${REPHRASE_EXAMPLES}`, }; } @@ -67,6 +110,14 @@ export function extractActionsFromProse(input: string, actorId = "player"): Extr }; } + if (/(go|move|walk|head|travel)/.test(text) && !room) { + return { + actions: [], + parser: "fallback", + parser_feedback: `I understood movement, but not the destination. Try 'go to the shed' or 'go to the garden'.`, + }; + } + if (/(open)/.test(text)) { return { actions: [{ actor: actorId, verb: "open", target: resolveTarget(text) ?? "gate" }], @@ -82,15 +133,33 @@ export function extractActionsFromProse(input: string, actorId = "player"): Extr } if (/(take|pick up|grab)/.test(text)) { + const target = resolveTarget(text); + if (!target) { + return { + actions: [], + parser: "fallback", + parser_feedback: `I understood 'take' but not what item you meant. Try 'take the bench' or 'pull out my phone'.`, + }; + } + return { - actions: [{ actor: actorId, verb: "take", target: resolveTarget(text) ?? undefined }], + actions: [{ actor: actorId, verb: "take", target }], parser: "fallback", }; } if (/(drop|put down|set down)/.test(text)) { + const target = resolveTarget(text); + if (!target) { + return { + actions: [], + parser: "fallback", + parser_feedback: `I understood 'drop' but not which item. Try 'drop phone' or 'drop keys'.`, + }; + } + return { - actions: [{ actor: actorId, verb: "drop", target: resolveTarget(text) ?? undefined }], + actions: [{ actor: actorId, verb: "drop", target }], parser: "fallback", }; } @@ -103,8 +172,17 @@ export function extractActionsFromProse(input: string, actorId = "player"): Extr } if (/(use|press|activate)/.test(text)) { + const target = resolveTarget(text); + if (!target) { + return { + actions: [], + parser: "fallback", + parser_feedback: `I understood 'use' but not the target. Try 'use gate' or 'use phone'.`, + }; + } + return { - actions: [{ actor: actorId, verb: "use", target: resolveTarget(text) ?? undefined }], + actions: [{ actor: actorId, verb: "use", target }], parser: "fallback", }; } @@ -125,7 +203,8 @@ export function extractActionsFromProse(input: string, actorId = "player"): Extr } return { - actions: [{ actor: actorId, verb: "inspect", target: actorId, params: { raw_input: input } }], + actions: [], parser: "fallback", + parser_feedback: `I couldn't map that request to a game action. ${REPHRASE_EXAMPLES}`, }; } \ No newline at end of file diff --git a/charactergarden/frontend/src/App.tsx b/charactergarden/frontend/src/App.tsx index 5956c23..ec8fbad 100644 --- a/charactergarden/frontend/src/App.tsx +++ b/charactergarden/frontend/src/App.tsx @@ -47,6 +47,7 @@ type Snapshot = { type TurnResult = { narration: string; parser: string; + parser_feedback?: string; actions: Array>; accepted: Array>; rejected: Array<{ action: Record; reason: string }>; @@ -146,6 +147,9 @@ export default function App() {

Latest result

{latest.narration}

+ {latest.parser_feedback ? ( +

Parser guidance: {latest.parser_feedback}

+ ) : null}
{JSON.stringify({ actions: latest.actions, rejected: latest.rejected, latent: latest.latent_resolution }, null, 0)}
) : null} diff --git a/charactergarden/frontend/src/styles.css b/charactergarden/frontend/src/styles.css index 7f09e6b..4907967 100644 --- a/charactergarden/frontend/src/styles.css +++ b/charactergarden/frontend/src/styles.css @@ -166,6 +166,16 @@ pre { color: #ffd2b8; } +.parser-hint { + margin: 6px 0; + padding: 8px 10px; + border-radius: 10px; + border: 1px solid rgba(220, 191, 141, 0.3); + background: rgba(220, 191, 141, 0.1); + color: #f0d8aa; + font-size: 0.9rem; +} + @media (max-width: 900px) { .inspector-grid { grid-template-columns: 1fr; diff --git a/thoughts.md b/thoughts.md index f3809fc..c67fdf4 100644 --- a/thoughts.md +++ b/thoughts.md @@ -26,16 +26,66 @@ - The offscene room is represented as a normal room entity with id `offscene` - App and frontend should be run and validated through Docker Compose rather than host-installed Node +## MVP Readiness (2026-04-23) +- Estimated completion: ~90% +- Completed against spec: + - Turn pipeline implemented end-to-end (input -> parse -> validate -> apply -> events -> narration) + - Truth Engine is authoritative for state mutation + - SQLite persistence for entities/events/turns/beliefs/summaries + - Dockerized app + frontend stack, with optional Ollama profile + - Minimal inspector UI for state/events/turns + - MVP world bounded to 2 rooms and <=3 characters +- Remaining to call MVP done: + - Run one clean boot smoke pass from current branch and record expected outputs in this file + - Add a short "MVP acceptance checklist" section and mark each contract as pass/fail + - Tighten fallback parser behavior for known starter prompts and ensure no regression on latent-item turns + +## MVP Acceptance Checklist +- PASS: Core contracts implemented (Entity, Action, ValidationResult, StateChange, GameEvent) +- PASS: Truth Engine is sole authority for state mutation +- PASS: Invalid actions produce explicit rejection reasons +- PASS: Turn flow wired end-to-end in app service +- PASS: SQLite tables for entities/events/turns/beliefs/summaries +- PASS: Dockerized app + frontend, no host Node dependency required +- PASS: MVP scope bounds respected (2 rooms, <=3 characters, limited verb set) +- PASS: Frontend inspector can submit turns and inspect state/events +- PASS: Latent personal-item flow works in runtime (`pull out my phone` => promoted + accepted) +- PASS: Parser now fails gracefully with explicit rephrase guidance for unknown/underspecified input + ## Next Steps -1. Implement App service / turn flow (`app/src/app.ts`) per section 6 -2. Validate Docker boot and iterate on any compile/runtime failures -3. Expand fallback parser coverage and tighten truth-engine world rules -4. Add LLM adapter implementation beyond fallback parsing +1. Execute and capture a final Docker boot smoke check (`/health`, `/api/state`, `/api/turn` happy path + latent-item turn) +2. Add explicit MVP acceptance checklist results here (contracts, scope limits, runtime constraints) +3. Do a small parser-hardening pass for prompt coverage, then freeze MVP scope +4. Defer non-MVP work (container-entity plausibility, richer narration, full LLM adapter) ## Open Questions -- Should room/location be an Entity attribute or a separate entity type? -- What is the initial world state for the MVP (1–2 rooms, ≤3 characters)? -- Should latent personal-item plausibility live only on actor attributes, or also look at worn item/container entities? +- None currently blocking MVP implementation. + +## Resolved Decisions (2026-04-23) +- Room/location model: keep `location` as an entity attribute that points to another entity id. Rooms remain normal entities (`type: room`) and inventory uses `inventory:`. +- MVP initial world: lock to 2 rooms (`garden`, `shed`) and 2 characters (`player`, `groundskeeper`), plus static scene objects (`gate`, `bench`) and built-in `offscene` room. +- Latent personal-item plausibility: use actor attributes as the MVP source of truth (`clothed`, `pocket_count`, `has_bag`, `searched_empty`). +- Container/worn-item entities: defer to post-MVP. Add this as a planned extension, not a current gate for plausibility. + +## Follow-up Implications +1. Keep truth rules keyed to entity `attributes.location` and avoid introducing a separate room-graph subsystem yet. +2. Keep fallback parser and truth engine behavior tuned to the locked MVP world and verb set. +3. Add a backlog item for container-aware plausibility (`worn`, `container`, nested inventory checks) after MVP boot/test milestone. + +## Turn Contract (Higher Stack) +- End-user turn response should always include: + - `narration` (what happened) + - `parser_feedback` when intent is unclear or underspecified + - accepted/rejected action detail for debugger/inspector views +- UX rule: when `parser_feedback` is present, UI should explicitly surface it and encourage rephrasing. + +## LLM Prompting Contract (In-Environment) +- Added prompt builder in `app/src/llmAdapter.ts` (`buildActionExtractionPrompt`) to enforce world-grounded extraction. +- Prompt constraints: + - only allowed verbs + - only known entity ids from current world snapshot + - strict JSON output shape (`actions`, optional `parser_feedback`) + - no freeform world mutation outside truth-engine validation ## Session Notes - 2026-04-23: Project started. Scaffold, type contracts, .gitignore, and .env.example created. @@ -43,3 +93,5 @@ - 2026-04-23: Added facts/affordances + latent entity resolver for improv-style personal items, plus offscene room support. - 2026-04-23: Added SQLite schema module. Host `npm install` is blocked by `better-sqlite3` on Windows Node 25, so runtime validation should happen inside Docker on an LTS Node image instead. - 2026-04-23: Added minimal backend/frontend boot slice so the project can be tested visually through Docker. +- 2026-04-23: Runtime smoke check passed with live services (`/health` ok, state returned, `look around` accepted, `pull out my phone` accepted with zero rejections). +- 2026-04-23: Fallback parser hardened to return human-readable guidance on unclear input (e.g., unknown intent, missing take target, missing move destination) and suggest concrete rephrasing examples.