feat: enhance parser feedback mechanism for improved user guidance
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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: [{ 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",
|
||||
};
|
||||
}
|
||||
|
||||
if (/(drop|put down|set down)/.test(text)) {
|
||||
const target = resolveTarget(text);
|
||||
if (!target) {
|
||||
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",
|
||||
};
|
||||
}
|
||||
@@ -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: [{ 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",
|
||||
};
|
||||
}
|
||||
@@ -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}`,
|
||||
};
|
||||
}
|
||||
@@ -47,6 +47,7 @@ type Snapshot = {
|
||||
type TurnResult = {
|
||||
narration: string;
|
||||
parser: string;
|
||||
parser_feedback?: string;
|
||||
actions: Array<Record<string, unknown>>;
|
||||
accepted: Array<Record<string, unknown>>;
|
||||
rejected: Array<{ action: Record<string, unknown>; reason: string }>;
|
||||
@@ -146,6 +147,9 @@ export default function App() {
|
||||
<section className="result-card">
|
||||
<h2>Latest result</h2>
|
||||
<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>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
@@ -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;
|
||||
|
||||
66
thoughts.md
66
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:<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
|
||||
- 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.
|
||||
|
||||
Reference in New Issue
Block a user