feat: Implemented primitive intent pipline (closes #7)

This commit is contained in:
2026-07-06 22:43:09 +05:30
parent 72b87c40bc
commit f7177b619b
11 changed files with 388 additions and 8 deletions

View File

@@ -5,5 +5,10 @@
"type": "module",
"exports": {
".": "./dist/index.js"
},
"dependencies": {
"@omnia/core": "workspace:*",
"@omnia/llm": "workspace:*",
"zod": "^4.4.3"
}
}

View File

@@ -1 +1,2 @@
export {};
export * from "./intent.js";
export * from "./intent-decoder.js";

View File

@@ -0,0 +1,73 @@
import { WorldState } from "@omnia/core";
import { ILLMProvider } from "@omnia/llm";
import { IntentSequence, IntentSequenceSchema } from "./intent.js";
export class IntentDecoder {
constructor(private llmProvider: ILLMProvider) {}
/**
* Decodes narrative prose into an ordered sequence of structured intents.
*
* Responsibilities (from docs/intents.md):
* - Split prose into multiple intents when applicable.
* - Classify each intent as "dialogue" or "action".
* - Parse narrative text into structured JSON with minimal information loss.
* - Contextually resolve receiving parties (targets).
*/
async decode(
worldState: WorldState,
actorId: string,
narrativeProse: string,
): Promise<IntentSequence> {
const entityIds = Array.from(worldState.entities.keys());
const systemPrompt = `
You are the Intent Decoder for a narrative simulation engine.
Your job is to take a block of narrative prose written by an actor agent and decompose it into an ordered sequence of discrete intents.
For each intent you must:
1. Classify its type:
- "dialogue": Any speech, conversation, or verbal communication directed at another entity.
- "action": Any physical or logical action performed in the world (e.g., moving, picking up, opening, looking).
2. Extract the original text fragment from the prose that corresponds to this intent.
3. Write a concise, structured description of the intent (what is being done or said). Include as much detail about the action as possible that was extracted from the narrative prose. Do not make up qualities.
4. Identify the actorId (the entity performing the intent — this will always be "${actorId}").
5. Identify targetIds — the entity IDs of the receiving parties. Use only IDs from the known entities list. If no specific target, use an empty array.
Rules:
- Preserve the chronological order of intents as they appear in the prose.
- Do NOT merge unrelated actions into a single intent.
- Dialogue and actions should be separate intents even if they happen in the same sentence.
- If the prose contains only dialogue, return a single dialogue intent.
- If the prose contains only a single action, return a single action intent.
`.trim();
const userContext = `
=== KNOWN ENTITY IDS ===
${entityIds.length > 0 ? entityIds.join(", ") : "(No entities)"}
=== WORLD STATE ===
${worldState.serialize()}
=== ACTOR ===
Actor ID: ${actorId}
=== NARRATIVE PROSE ===
${narrativeProse}
`.trim();
const response = await this.llmProvider.generateStructuredResponse({
systemPrompt,
userContext,
schema: IntentSequenceSchema,
});
if (!response.success || !response.data) {
throw new Error(
`Intent decoding failed: ${response.error || "Unknown LLM error"}`,
);
}
return response.data;
}
}

View File

@@ -0,0 +1,44 @@
import { z } from "zod";
/**
* Intent types as classified by the Intent Decoder.
* - "dialogue": Speech or conversation directed at another entity.
* - "action": A physical or logical action performed in the world.
*/
export const IntentTypeSchema = z.enum(["dialogue", "action"]);
export type IntentType = z.infer<typeof IntentTypeSchema>;
/**
* A single decoded intent extracted from narrative prose.
*/
export const IntentSchema = z.object({
/** The type of intent. */
type: IntentTypeSchema,
/** The original narrative text fragment this intent was extracted from. */
originalText: z.string(),
/** A concise, structured description of the intent's action or dialogue. */
description: z.string(),
/** The entity ID of the actor performing the intent. */
actorId: z.string(),
/**
* Entity IDs of the receiving parties (e.g., who is being spoken to,
* what object is being interacted with).
*/
targetIds: z.array(z.string()),
});
export type Intent = z.infer<typeof IntentSchema>;
/**
* The full output of the Intent Decoder: an ordered sequence of intents
* extracted from a single narrative prose block.
*/
export const IntentSequenceSchema = z.object({
intents: z.array(IntentSchema),
});
export type IntentSequence = z.infer<typeof IntentSequenceSchema>;

View File

@@ -0,0 +1,123 @@
import { describe, test, expect } from "vitest";
import { WorldState, Entity } from "@omnia/core";
import { MockLLMProvider } from "@omnia/llm";
import { IntentDecoder, IntentSequence } from "@omnia/intent";
describe("IntentDecoder Unit Tests (Tier 1)", () => {
test("decodes prose with a single action intent", async () => {
const world = new WorldState("world-1");
const alice = new Entity("alice");
world.addEntity(alice);
const mockResponse: IntentSequence = {
intents: [
{
type: "action",
originalText: "Alice opened the chest.",
description: "Open the wooden chest.",
actorId: "alice",
targetIds: [],
},
],
};
const llm = new MockLLMProvider([mockResponse]);
const decoder = new IntentDecoder(llm);
const result = await decoder.decode(world, "alice", "Alice opened the chest.");
expect(result.intents).toHaveLength(1);
expect(result.intents[0].type).toBe("action");
expect(result.intents[0].actorId).toBe("alice");
expect(result.intents[0].targetIds).toEqual([]);
});
test("decodes prose with a single dialogue intent", async () => {
const world = new WorldState("world-1");
const alice = new Entity("alice");
const bob = new Entity("bob");
world.addEntity(alice);
world.addEntity(bob);
const mockResponse: IntentSequence = {
intents: [
{
type: "dialogue",
originalText: '"Do you have the key?" Alice asked Bob.',
description: "Alice asks Bob if he has the key.",
actorId: "alice",
targetIds: ["bob"],
},
],
};
const llm = new MockLLMProvider([mockResponse]);
const decoder = new IntentDecoder(llm);
const result = await decoder.decode(
world,
"alice",
'"Do you have the key?" Alice asked Bob.',
);
expect(result.intents).toHaveLength(1);
expect(result.intents[0].type).toBe("dialogue");
expect(result.intents[0].targetIds).toEqual(["bob"]);
});
test("decodes prose with mixed dialogue and action intents", async () => {
const world = new WorldState("world-1");
const alice = new Entity("alice");
const bob = new Entity("bob");
world.addEntity(alice);
world.addEntity(bob);
const mockResponse: IntentSequence = {
intents: [
{
type: "dialogue",
originalText: '"Cover me," Alice whispered to Bob.',
description: "Alice whispers to Bob requesting cover.",
actorId: "alice",
targetIds: ["bob"],
},
{
type: "action",
originalText: "She crept towards the door and pulled the handle.",
description: "Creep towards the door and pull the handle.",
actorId: "alice",
targetIds: [],
},
],
};
const llm = new MockLLMProvider([mockResponse]);
const decoder = new IntentDecoder(llm);
const result = await decoder.decode(
world,
"alice",
'"Cover me," Alice whispered to Bob. She crept towards the door and pulled the handle.',
);
expect(result.intents).toHaveLength(2);
expect(result.intents[0].type).toBe("dialogue");
expect(result.intents[0].targetIds).toEqual(["bob"]);
expect(result.intents[1].type).toBe("action");
expect(result.intents[1].actorId).toBe("alice");
});
test("throws on LLM failure", async () => {
const world = new WorldState("world-1");
const alice = new Entity("alice");
world.addEntity(alice);
// MockLLMProvider with empty queue will return { success: false }
const llm = new MockLLMProvider([]);
const decoder = new IntentDecoder(llm);
await expect(
decoder.decode(world, "alice", "Alice ran away."),
).rejects.toThrow("Intent decoding failed");
});
});

View File

@@ -5,5 +5,8 @@
"outDir": "dist"
},
"include": ["src"],
"references": []
"references": [
{ "path": "../core" },
{ "path": "../llm" }
]
}