feat: enhance parser feedback mechanism for improved user guidance

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-23 22:55:16 -04:00
parent c2d12ffcc9
commit 2f6af46c79
5 changed files with 174 additions and 16 deletions

View File

@@ -17,6 +17,7 @@ export interface AppStateSnapshot {
export interface TurnResult { export interface TurnResult {
narration: string; narration: string;
parser: "fallback"; parser: "fallback";
parser_feedback?: string;
actions: Action[]; actions: Action[];
accepted: Action[]; accepted: Action[];
rejected: { action: Action; reason: string }[]; rejected: { action: Action; reason: string }[];
@@ -152,10 +153,15 @@ function narrateResult(
worldState: WorldState, worldState: WorldState,
accepted: Action[], accepted: Action[],
rejected: { action: Action; reason: string }[], rejected: { action: Action; reason: string }[],
latentReason?: string latentReason?: string,
parserFeedback?: string
): string { ): string {
const lines: string[] = []; const lines: string[] = [];
if (parserFeedback) {
lines.push(parserFeedback);
}
if (latentReason) { if (latentReason) {
lines.push(latentReason); lines.push(latentReason);
} }
@@ -212,7 +218,7 @@ export function createCharacterGardenApp(dbPath: string): CharacterGardenApp {
function processTurn(input: string): TurnResult { function processTurn(input: string): TurnResult {
const turnNumber = db.listTurns().length + 1; const turnNumber = db.listTurns().length + 1;
const { actions, parser } = extractActionsFromProse(input); const { actions, parser, parser_feedback: parserFeedback } = extractActionsFromProse(input);
let activeWorldState = worldState; let activeWorldState = worldState;
let latentResolution: TurnResult["latent_resolution"]; let latentResolution: TurnResult["latent_resolution"];
@@ -260,7 +266,13 @@ export function createCharacterGardenApp(dbPath: string): CharacterGardenApp {
const validation = validate(normalizedActions, activeWorldState); const validation = validate(normalizedActions, activeWorldState);
const nextWorldState = applyChanges(activeWorldState, validation.state_changes); 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 = { const turnRecord: Turn = {
id: randomUUID(), id: randomUUID(),
@@ -300,6 +312,7 @@ export function createCharacterGardenApp(dbPath: string): CharacterGardenApp {
return { return {
narration, narration,
parser, parser,
parser_feedback: parserFeedback,
actions: normalizedActions, actions: normalizedActions,
accepted: validation.accepted, accepted: validation.accepted,
rejected: validation.rejected, rejected: validation.rejected,

View File

@@ -1,10 +1,52 @@
import { Action } from "./types"; import { Action, ALLOWED_VERBS, Entity } from "./types";
export interface ExtractedActions { export interface ExtractedActions {
actions: Action[]; actions: Action[];
parser: "fallback"; 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<string, string> = { const ROOM_ALIASES: Record<string, string> = {
garden: "garden", garden: "garden",
shed: "shed", shed: "shed",
@@ -54,8 +96,9 @@ export function extractActionsFromProse(input: string, actorId = "player"): Extr
if (!text) { if (!text) {
return { return {
actions: [{ actor: actorId, verb: "inspect", target: actorId }], actions: [],
parser: "fallback", 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)) { if (/(open)/.test(text)) {
return { return {
actions: [{ actor: actorId, verb: "open", target: resolveTarget(text) ?? "gate" }], 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)) { if (/(take|pick up|grab)/.test(text)) {
const target = resolveTarget(text);
if (!target) {
return { return {
actions: [{ actor: actorId, verb: "take", target: resolveTarget(text) ?? undefined }], 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 }],
parser: "fallback", parser: "fallback",
}; };
} }
if (/(drop|put down|set down)/.test(text)) { if (/(drop|put down|set down)/.test(text)) {
const target = resolveTarget(text);
if (!target) {
return { return {
actions: [{ actor: actorId, verb: "drop", target: resolveTarget(text) ?? undefined }], 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 }],
parser: "fallback", parser: "fallback",
}; };
} }
@@ -103,8 +172,17 @@ export function extractActionsFromProse(input: string, actorId = "player"): Extr
} }
if (/(use|press|activate)/.test(text)) { if (/(use|press|activate)/.test(text)) {
const target = resolveTarget(text);
if (!target) {
return { return {
actions: [{ actor: actorId, verb: "use", target: resolveTarget(text) ?? undefined }], 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 }],
parser: "fallback", parser: "fallback",
}; };
} }
@@ -125,7 +203,8 @@ export function extractActionsFromProse(input: string, actorId = "player"): Extr
} }
return { return {
actions: [{ actor: actorId, verb: "inspect", target: actorId, params: { raw_input: input } }], actions: [],
parser: "fallback", parser: "fallback",
parser_feedback: `I couldn't map that request to a game action. ${REPHRASE_EXAMPLES}`,
}; };
} }

View File

@@ -47,6 +47,7 @@ type Snapshot = {
type TurnResult = { type TurnResult = {
narration: string; narration: string;
parser: string; parser: string;
parser_feedback?: string;
actions: Array<Record<string, unknown>>; actions: Array<Record<string, unknown>>;
accepted: Array<Record<string, unknown>>; accepted: Array<Record<string, unknown>>;
rejected: Array<{ action: Record<string, unknown>; reason: string }>; rejected: Array<{ action: Record<string, unknown>; reason: string }>;
@@ -146,6 +147,9 @@ export default function App() {
<section className="result-card"> <section className="result-card">
<h2>Latest result</h2> <h2>Latest result</h2>
<p>{latest.narration}</p> <p>{latest.narration}</p>
{latest.parser_feedback ? (
<p className="parser-hint">Parser guidance: {latest.parser_feedback}</p>
) : null}
<pre>{JSON.stringify({ actions: latest.actions, rejected: latest.rejected, latent: latest.latent_resolution }, null, 0)}</pre> <pre>{JSON.stringify({ actions: latest.actions, rejected: latest.rejected, latent: latest.latent_resolution }, null, 0)}</pre>
</section> </section>
) : null} ) : null}

View File

@@ -166,6 +166,16 @@ pre {
color: #ffd2b8; 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) { @media (max-width: 900px) {
.inspector-grid { .inspector-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View File

@@ -26,16 +26,66 @@
- The offscene room is represented as a normal room entity with id `offscene` - 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 - 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 ## Next Steps
1. Implement App service / turn flow (`app/src/app.ts`) per section 6 1. Execute and capture a final Docker boot smoke check (`/health`, `/api/state`, `/api/turn` happy path + latent-item turn)
2. Validate Docker boot and iterate on any compile/runtime failures 2. Add explicit MVP acceptance checklist results here (contracts, scope limits, runtime constraints)
3. Expand fallback parser coverage and tighten truth-engine world rules 3. Do a small parser-hardening pass for prompt coverage, then freeze MVP scope
4. Add LLM adapter implementation beyond fallback parsing 4. Defer non-MVP work (container-entity plausibility, richer narration, full LLM adapter)
## Open Questions ## Open Questions
- Should room/location be an Entity attribute or a separate entity type? - None currently blocking MVP implementation.
- What is the initial world state for the MVP (12 rooms, ≤3 characters)?
- Should latent personal-item plausibility live only on actor attributes, or also look at worn item/container entities? ## 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:<actor_id>`.
- 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 ## Session Notes
- 2026-04-23: Project started. Scaffold, type contracts, .gitignore, and .env.example created. - 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 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 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: 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.