mirror of
https://github.com/sortedcord/omnia.git
synced 2026-07-22 03:52:48 +05:30
FEAT!(voice): Implement intent hydration, dehydration system fixes: #29
This commit is contained in:
13
packages/voice/package.json
Normal file
13
packages/voice/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@omnia/voice",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@omnia/core": "workspace:*",
|
||||
"compromise": "^14.14.0"
|
||||
}
|
||||
}
|
||||
146
packages/voice/src/dehydration.ts
Normal file
146
packages/voice/src/dehydration.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
export interface Segment {
|
||||
text: string;
|
||||
isQuote: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits text into quote and non-quote segments.
|
||||
*/
|
||||
export function splitQuotes(text: string): Segment[] {
|
||||
const segments: Segment[] = [];
|
||||
let current = "";
|
||||
let inQuote = false;
|
||||
let quoteChar = "";
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
if ((char === '"' || char === "'") && (!inQuote || char === quoteChar)) {
|
||||
if (current) {
|
||||
segments.push({ text: current, isQuote: inQuote });
|
||||
current = "";
|
||||
}
|
||||
inQuote = !inQuote;
|
||||
quoteChar = inQuote ? char : "";
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
|
||||
if (current) {
|
||||
segments.push({ text: current, isQuote: inQuote });
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms standard narrative prose from the source actor's perspective
|
||||
* into a dehydrated canonical form with entity@<id>[original] placeholder tags.
|
||||
*/
|
||||
export function dehydrate(
|
||||
content: string,
|
||||
sourceId: string,
|
||||
targetIds: string[],
|
||||
aliasMap: Record<string, string>,
|
||||
): string {
|
||||
if (!content) return "";
|
||||
|
||||
const segments = splitQuotes(content);
|
||||
|
||||
const processedSegments = segments.map((seg) => {
|
||||
if (seg.isQuote) {
|
||||
return `"${seg.text}"`;
|
||||
}
|
||||
|
||||
let text = seg.text;
|
||||
|
||||
// 1. Map lowercase aliases/names/IDs to IDs
|
||||
const nameToId = new Map<string, string>();
|
||||
|
||||
// Add target IDs and source ID themselves
|
||||
nameToId.set(sourceId.toLowerCase(), sourceId);
|
||||
targetIds.forEach((id) => {
|
||||
nameToId.set(id.toLowerCase(), id);
|
||||
});
|
||||
|
||||
// Add entries from aliasMap (mapped lowercased)
|
||||
Object.entries(aliasMap).forEach(([name, id]) => {
|
||||
nameToId.set(name.toLowerCase(), id);
|
||||
});
|
||||
|
||||
// Sort names by length descending to match longest name first
|
||||
const sortedNames = Array.from(nameToId.keys()).sort(
|
||||
(a, b) => b.length - a.length,
|
||||
);
|
||||
|
||||
// Track state of matched target IDs for pronoun lookback
|
||||
const matchedTargetIds: string[] = [];
|
||||
|
||||
// 2. Replace names and aliases with entity@<id>[name]
|
||||
sortedNames.forEach((name) => {
|
||||
const id = nameToId.get(name)!;
|
||||
const escapedName = name.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&");
|
||||
const regex = new RegExp(`\\b${escapedName}\\b`, "gi");
|
||||
|
||||
text = text.replace(regex, (matched) => {
|
||||
if (id !== sourceId) {
|
||||
matchedTargetIds.push(id);
|
||||
}
|
||||
return `entity@${id}[${matched}]`;
|
||||
});
|
||||
});
|
||||
|
||||
// 3. Replace first-person pronouns with source actor tag
|
||||
const firstPersonPronouns = [
|
||||
{ word: "i" },
|
||||
{ word: "me" },
|
||||
{ word: "my" },
|
||||
{ word: "myself" },
|
||||
{ word: "mine" },
|
||||
{ word: "we" },
|
||||
{ word: "us" },
|
||||
{ word: "our" },
|
||||
{ word: "ours" },
|
||||
{ word: "ourselves" },
|
||||
];
|
||||
|
||||
firstPersonPronouns.forEach(({ word }) => {
|
||||
const regex = new RegExp(`\\b${word}\\b`, "gi");
|
||||
text = text.replace(regex, (matched) => {
|
||||
return `entity@${sourceId}[${matched}]`;
|
||||
});
|
||||
});
|
||||
|
||||
// 4. Replace third-person pronouns using state lookback
|
||||
const thirdPersonPronouns = [
|
||||
"he",
|
||||
"him",
|
||||
"his",
|
||||
"himself",
|
||||
"she",
|
||||
"her",
|
||||
"hers",
|
||||
"herself",
|
||||
"they",
|
||||
"them",
|
||||
"their",
|
||||
"theirs",
|
||||
"themselves",
|
||||
];
|
||||
thirdPersonPronouns.forEach((pronoun) => {
|
||||
const regex = new RegExp(`\\b${pronoun}\\b`, "gi");
|
||||
text = text.replace(regex, (matched) => {
|
||||
const lastTargetId =
|
||||
matchedTargetIds[matchedTargetIds.length - 1] || targetIds[0];
|
||||
if (lastTargetId) {
|
||||
return `entity@${lastTargetId}[${matched}]`;
|
||||
}
|
||||
return matched;
|
||||
});
|
||||
});
|
||||
|
||||
return text;
|
||||
});
|
||||
|
||||
return processedSegments.join("");
|
||||
}
|
||||
236
packages/voice/src/hydration.ts
Normal file
236
packages/voice/src/hydration.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import nlp from "compromise";
|
||||
import { Entity, WorldState, resolveAlias } from "@omnia/core";
|
||||
import { splitQuotes } from "./dehydration.js";
|
||||
|
||||
/**
|
||||
* Hydrates a dehydrated narration text containing entity@<id>[original] symbol tags
|
||||
* into natural language from a specific viewer's perspective.
|
||||
*/
|
||||
export function hydrate(content: string, viewer: Entity): string {
|
||||
if (!content) return "";
|
||||
const segments = splitQuotes(content);
|
||||
|
||||
const processedSegments = segments.map((seg) => {
|
||||
if (seg.isQuote) {
|
||||
return `'${seg.text}'`;
|
||||
}
|
||||
|
||||
// Match entity@<id>[original] and optionally the following space and word
|
||||
const regex = /entity@([a-zA-Z0-9-]+)\[([^\]]+)\](?:\s+([a-zA-Z]+))?/g;
|
||||
|
||||
const firstPersonSet = new Set([
|
||||
"i",
|
||||
"me",
|
||||
"my",
|
||||
"myself",
|
||||
"mine",
|
||||
"we",
|
||||
"us",
|
||||
"our",
|
||||
"ours",
|
||||
]);
|
||||
const thirdPersonSet = new Set([
|
||||
"he",
|
||||
"him",
|
||||
"his",
|
||||
"himself",
|
||||
"she",
|
||||
"her",
|
||||
"hers",
|
||||
"herself",
|
||||
"they",
|
||||
"them",
|
||||
"their",
|
||||
"theirs",
|
||||
"themselves",
|
||||
]);
|
||||
|
||||
return seg.text.replace(regex, (matchStr, id, original, followingWord) => {
|
||||
const isSelf = id === viewer.id;
|
||||
const lowerOriginal = original.toLowerCase();
|
||||
let resolvedSubject = "";
|
||||
let isThirdPersonSingular = false;
|
||||
|
||||
if (isSelf) {
|
||||
if (["his", "her", "their", "my", "its", "our"].includes(lowerOriginal))
|
||||
resolvedSubject = "my";
|
||||
else if (["hers", "theirs", "mine", "ours"].includes(lowerOriginal))
|
||||
resolvedSubject = "mine";
|
||||
else if (
|
||||
[
|
||||
"himself",
|
||||
"herself",
|
||||
"themselves",
|
||||
"myself",
|
||||
"itself",
|
||||
"ourselves",
|
||||
].includes(lowerOriginal)
|
||||
)
|
||||
resolvedSubject = "myself";
|
||||
else if (["he", "she", "they", "i", "we"].includes(lowerOriginal))
|
||||
resolvedSubject = "I";
|
||||
else if (["him", "her", "them", "me", "us"].includes(lowerOriginal))
|
||||
resolvedSubject = "me";
|
||||
else {
|
||||
// Noun/alias mapped to self: check preceding/succeeding context
|
||||
const matchIdx = seg.text.indexOf(matchStr);
|
||||
const precedingText = seg.text.slice(0, matchIdx);
|
||||
const prec = precedingText.trim();
|
||||
const words = prec.split(/\s+/);
|
||||
const lastWord = words[words.length - 1]?.toLowerCase() || "";
|
||||
|
||||
const prepositions = [
|
||||
"to",
|
||||
"with",
|
||||
"for",
|
||||
"at",
|
||||
"by",
|
||||
"from",
|
||||
"in",
|
||||
"on",
|
||||
"about",
|
||||
"between",
|
||||
"of",
|
||||
"under",
|
||||
"over",
|
||||
"behind",
|
||||
"beside",
|
||||
"through",
|
||||
];
|
||||
if (prepositions.includes(lastWord)) {
|
||||
resolvedSubject = "me";
|
||||
} else {
|
||||
resolvedSubject = "I";
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const alias = resolveAlias(viewer, id);
|
||||
if (firstPersonSet.has(lowerOriginal)) {
|
||||
if (["my", "our"].includes(lowerOriginal))
|
||||
resolvedSubject = `${alias}'s`;
|
||||
else if (["mine", "ours"].includes(lowerOriginal))
|
||||
resolvedSubject = `${alias}'s`;
|
||||
else if (["myself", "ourselves"].includes(lowerOriginal))
|
||||
resolvedSubject = "himself";
|
||||
else {
|
||||
resolvedSubject = alias;
|
||||
isThirdPersonSingular = true;
|
||||
}
|
||||
} else if (thirdPersonSet.has(lowerOriginal)) {
|
||||
resolvedSubject = original;
|
||||
if (["he", "she", "it"].includes(lowerOriginal)) {
|
||||
isThirdPersonSingular = true;
|
||||
}
|
||||
} else {
|
||||
resolvedSubject = alias;
|
||||
isThirdPersonSingular = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (followingWord) {
|
||||
if (isThirdPersonSingular) {
|
||||
const conj = nlp(followingWord).verbs().conjugate()[0] as any;
|
||||
if (conj && conj.Infinitive === followingWord && conj.PresentTense) {
|
||||
return `${resolvedSubject} ${conj.PresentTense}`;
|
||||
}
|
||||
}
|
||||
return `${resolvedSubject} ${followingWord}`;
|
||||
}
|
||||
|
||||
return resolvedSubject;
|
||||
});
|
||||
});
|
||||
|
||||
return processedSegments.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrates a dehydrated narration text containing entity@<id>[original] symbol tags
|
||||
* into natural language from an objective world perspective.
|
||||
*/
|
||||
export function hydrateObjective(
|
||||
content: string,
|
||||
worldState: WorldState,
|
||||
): string {
|
||||
if (!content) return "";
|
||||
const segments = splitQuotes(content);
|
||||
|
||||
const processedSegments = segments.map((seg) => {
|
||||
if (seg.isQuote) {
|
||||
return `'${seg.text}'`;
|
||||
}
|
||||
|
||||
// Match entity@<id>[original] and optionally the following space and word
|
||||
const regex = /entity@([a-zA-Z0-9-]+)\[([^\]]+)\](?:\s+([a-zA-Z]+))?/g;
|
||||
|
||||
const firstPersonSet = new Set([
|
||||
"i",
|
||||
"me",
|
||||
"my",
|
||||
"myself",
|
||||
"mine",
|
||||
"we",
|
||||
"us",
|
||||
"our",
|
||||
"ours",
|
||||
]);
|
||||
const thirdPersonSet = new Set([
|
||||
"he",
|
||||
"him",
|
||||
"his",
|
||||
"himself",
|
||||
"she",
|
||||
"her",
|
||||
"hers",
|
||||
"herself",
|
||||
"they",
|
||||
"them",
|
||||
"their",
|
||||
"theirs",
|
||||
"themselves",
|
||||
]);
|
||||
|
||||
return seg.text.replace(regex, (matchStr, id, original, followingWord) => {
|
||||
const entity = worldState.getEntity(id);
|
||||
const name = entity?.attributes.get("name")?.getValue() || id;
|
||||
const lowerOriginal = original.toLowerCase();
|
||||
let resolvedSubject = "";
|
||||
let isThirdPersonSingular = false;
|
||||
|
||||
if (firstPersonSet.has(lowerOriginal)) {
|
||||
if (["my", "our"].includes(lowerOriginal))
|
||||
resolvedSubject = `${name}'s`;
|
||||
else if (["mine", "ours"].includes(lowerOriginal))
|
||||
resolvedSubject = `${name}'s`;
|
||||
else if (["myself", "ourselves"].includes(lowerOriginal))
|
||||
resolvedSubject = "himself";
|
||||
else {
|
||||
resolvedSubject = name;
|
||||
isThirdPersonSingular = true;
|
||||
}
|
||||
} else if (thirdPersonSet.has(lowerOriginal)) {
|
||||
resolvedSubject = original;
|
||||
if (["he", "she", "it"].includes(lowerOriginal)) {
|
||||
isThirdPersonSingular = true;
|
||||
}
|
||||
} else {
|
||||
resolvedSubject = name;
|
||||
isThirdPersonSingular = true;
|
||||
}
|
||||
|
||||
if (followingWord) {
|
||||
if (isThirdPersonSingular) {
|
||||
const conj = nlp(followingWord).verbs().conjugate()[0] as any;
|
||||
if (conj && conj.Infinitive === followingWord && conj.PresentTense) {
|
||||
return `${resolvedSubject} ${conj.PresentTense}`;
|
||||
}
|
||||
}
|
||||
return `${resolvedSubject} ${followingWord}`;
|
||||
}
|
||||
|
||||
return resolvedSubject;
|
||||
});
|
||||
});
|
||||
|
||||
return processedSegments.join("");
|
||||
}
|
||||
2
packages/voice/src/index.ts
Normal file
2
packages/voice/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./dehydration.js";
|
||||
export * from "./hydration.js";
|
||||
9
packages/voice/tsconfig.json
Normal file
9
packages/voice/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "../core" }]
|
||||
}
|
||||
Reference in New Issue
Block a user