Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"main": "./src/engine.mjs",
"exports": {
".": "./src/engine.mjs",
"./schema-adapter": "./src/schema-adapter.mjs",
"./context-matcher": "./src/context-matcher.mjs",
"./context-goals": "./src/context-goals.mjs",
"./lifecycle": "./src/lifecycle.mjs",
Expand Down
3 changes: 2 additions & 1 deletion src/engine.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { CategoryRegistry, registry, CATEGORIES } from "./registry.mjs"
export { normalizeFitnessContext } from "./categories/fitness.mjs"
export { normalizeMusicContext } from "./categories/music-streaming.mjs"
export { normalizeMusicContext } from "./categories/music-streaming.mjs"
export { SchemaAdapter } from "./schema-adapter.mjs"
215 changes: 215 additions & 0 deletions src/schema-adapter.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import { CATEGORIES, registry } from "./registry.mjs";

/**
* Adapter around the existing registry/category data to compile category
* specifications into a unified metadata structure for compilers and tooling.
*/
export class SchemaAdapter {
/**
* List all available categories.
* @returns {string[]} List of category names.
*/
static listCategories() {
return CATEGORIES;
}

/**
* Retrieve and compile the normalized schema for a specific category.
* @param {string} categoryName - The category identifier.
* @returns {Promise<Object>} Normalized schema structure.
*/
static async getSchema(categoryName) {
if (!CATEGORIES.includes(categoryName)) {
throw new Error(`Unknown category: ${categoryName}`);
}

const modulePath = new URL(`./categories/${categoryName}.mjs`, import.meta.url);
const categoryModule = await import(modulePath.href);

// 1. Identify category name
const category = categoryName;

// 2. Identify if there's a schema object exported (ending in _SCHEMA)
const schemaKey = Object.keys(categoryModule).find(key => key.endsWith("_SCHEMA"));
const schemaObj = schemaKey ? categoryModule[schemaKey] : null;

// 3. Extracted metadata fields
const fields = {};
let description = "";
let productRule = "";

const sensitiveFields = new Set();
const durablePreferences = new Set();

// Load from SENSITIVE_FIELDS export if available
if (categoryModule.SENSITIVE_FIELDS instanceof Set) {
for (const f of categoryModule.SENSITIVE_FIELDS) {
sensitiveFields.add(f);
}
} else if (Array.isArray(categoryModule.SENSITIVE_FIELDS)) {
for (const f of categoryModule.SENSITIVE_FIELDS) {
sensitiveFields.add(f);
}
}

// Load from DURABLE_PREFERENCES export if available
if (categoryModule.DURABLE_PREFERENCES instanceof Set) {
for (const f of categoryModule.DURABLE_PREFERENCES) {
durablePreferences.add(f);
}
} else if (Array.isArray(categoryModule.DURABLE_PREFERENCES)) {
for (const f of categoryModule.DURABLE_PREFERENCES) {
durablePreferences.add(f);
}
}

// Combine with registry sensitive/durable rules (to support fitness/finance/health rules)
const registrySensitive = registry.getSensitiveFields(categoryName);
if (registrySensitive instanceof Set) {
for (const f of registrySensitive) {
sensitiveFields.add(f);
}
}
const registryDurable = registry.getDurablePreferences(categoryName);
if (registryDurable instanceof Set) {
for (const f of registryDurable) {
durablePreferences.add(f);
}
}

if (schemaObj) {
description = schemaObj.description || "";
productRule = schemaObj.product_rule || "";

if (schemaObj.sections) {
for (const [sectionName, section] of Object.entries(schemaObj.sections)) {
if (section.fields) {
for (const [fieldName, fieldData] of Object.entries(section.fields)) {
const isSensitive = fieldData.sensitive ?? (sensitiveFields.has(fieldName) || sensitiveFields.has("*"));
if (isSensitive) {
sensitiveFields.add(fieldName);
}
const isDurableSection = sectionName.includes("preference") || sectionName.includes("stable");
const isDurable = fieldData.durable ?? (durablePreferences.has(fieldName) || isDurableSection);
if (isDurable) {
durablePreferences.add(fieldName);
}

fields[fieldName] = {
name: fieldName,
description: fieldData.description || "",
type: fieldData.type || "string",
sensitive: isSensitive,
durable: isDurable,
allowedValues: fieldData.values || null
};
}
}
}
}
} else {
// Format 1: load from contextFields
const contextFields = categoryModule.contextFields || {};

// Normalize contextFields if it's an array vs object
if (Array.isArray(contextFields)) {
for (const fieldName of contextFields) {
const isSensitive = sensitiveFields.has(fieldName) || sensitiveFields.has("*");
const isDurable = durablePreferences.has(fieldName) || durablePreferences.has("*") || (!categoryModule.DURABLE_PREFERENCES);
if (isDurable) {
durablePreferences.add(fieldName);
}
fields[fieldName] = {
name: fieldName,
description: "",
type: "string",
sensitive: isSensitive,
durable: isDurable,
allowedValues: null
};
}
} else {
for (const [fieldName, fieldDesc] of Object.entries(contextFields)) {
const isSensitive = sensitiveFields.has(fieldName) || sensitiveFields.has("*");
const isDurable = durablePreferences.has(fieldName) || durablePreferences.has("*") || (!categoryModule.DURABLE_PREFERENCES);
if (isDurable) {
durablePreferences.add(fieldName);
}
fields[fieldName] = {
name: fieldName,
description: fieldDesc || "",
type: "string",
sensitive: isSensitive,
durable: isDurable,
allowedValues: null
};
}
}

// Add any fields that are in SENSITIVE_FIELDS or DURABLE_PREFERENCES but not in contextFields
const allKnownFields = new Set([...Object.keys(fields), ...sensitiveFields, ...durablePreferences]);
for (const fieldName of allKnownFields) {
if (fieldName === "*") continue;
if (!fields[fieldName]) {
const isSensitive = sensitiveFields.has(fieldName) || sensitiveFields.has("*");
const isDurable = durablePreferences.has(fieldName) || durablePreferences.has("*") || (!categoryModule.DURABLE_PREFERENCES);
if (isDurable) {
durablePreferences.add(fieldName);
}
fields[fieldName] = {
name: fieldName,
description: "",
type: "string",
sensitive: isSensitive,
durable: isDurable,
allowedValues: null
};
}
}
}

// 4. Gather templates (wikiEntryTemplates or proposalOutputExamples or proposalTemplates)
const wikiTemplates = categoryModule.wikiEntryTemplates ||
categoryModule.proposalOutputExamples ||
categoryModule.proposalTemplates || [];

// 5. Permission suggestions
const permissionSuggestions = categoryModule.permissionSuggestions || {};

// 6. Care notes
const careNotes = categoryModule.careNotes || [];

// 7. Examples
const examples = categoryModule.rawInputExamples ||
categoryModule.normalizedOutputExamples ||
categoryModule.FITNESS_EXAMPLES ||
categoryModule.MOVIE_BOOKING_EXAMPLES ||
null;

return {
category,
description,
productRule,
fields,
sensitiveFields: Array.from(sensitiveFields),
durablePreferences: Array.from(durablePreferences),
wikiTemplates,
permissionSuggestions,
careNotes,
examples
};
}

/**
* Retrieve and compile all category schemas in parallel.
* @returns {Promise<Object>} Map of category identifier to normalized schema structure.
*/
static async getSchemas() {
const schemas = {};
const promises = CATEGORIES.map(async (category) => {
schemas[category] = await SchemaAdapter.getSchema(category);
});
await Promise.all(promises);
return schemas;
}
}
99 changes: 99 additions & 0 deletions test/schema-adapter.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import test from "node:test";
import assert from "node:assert/strict";
import { SchemaAdapter } from "../src/schema-adapter.mjs";
import { CATEGORIES } from "../src/registry.mjs";

test("SchemaAdapter lists all categories correctly", () => {
const categories = SchemaAdapter.listCategories();
assert.deepEqual(categories, CATEGORIES);
});

test("SchemaAdapter throws an error for unknown categories", async () => {
await assert.rejects(
() => SchemaAdapter.getSchema("non-existent-category"),
/Unknown category: non-existent-category/
);
});

test("SchemaAdapter normalizes Format 1 categories (e.g., fitness)", async () => {
const schema = await SchemaAdapter.getSchema("fitness");

assert.equal(schema.category, "fitness");

// Verify sensitive fields
assert.ok(schema.sensitiveFields.includes("allergies"));
assert.ok(schema.sensitiveFields.includes("medical_restrictions"));

// Verify durable preferences
assert.ok(schema.durablePreferences.includes("goal"));
assert.ok(schema.durablePreferences.includes("activity_level"));

// Verify fields mapping
assert.ok(schema.fields.goal);
assert.equal(schema.fields.goal.name, "goal");
assert.equal(schema.fields.goal.sensitive, false);
assert.equal(schema.fields.goal.durable, true);

assert.ok(schema.fields.allergies);
assert.equal(schema.fields.allergies.name, "allergies");
assert.equal(schema.fields.allergies.sensitive, true);
assert.equal(schema.fields.allergies.durable, false);

// Care notes
assert.ok(Array.isArray(schema.careNotes));
});

test("SchemaAdapter normalizes Format 1 categories with contextFields (e.g., dining)", async () => {
const schema = await SchemaAdapter.getSchema("dining");

assert.equal(schema.category, "dining");
assert.ok(schema.fields.preferred_cuisines);
assert.equal(schema.fields.preferred_cuisines.description, "Favorite types of food or restaurants (e.g., Italian, Japanese, Vegan)");
assert.equal(schema.fields.preferred_cuisines.sensitive, false);
assert.equal(schema.fields.preferred_cuisines.durable, true); // No durable preferences set explicitly, so defaults to true

assert.ok(schema.fields.dietary_restrictions);
assert.equal(schema.fields.dietary_restrictions.sensitive, true);
});

test("SchemaAdapter normalizes Format 2 schema-driven categories (e.g., learning)", async () => {
const schema = await SchemaAdapter.getSchema("learning");

assert.equal(schema.category, "learning");
assert.ok(schema.description.includes("learning preferences"));
assert.ok(schema.productRule.includes("Learning context helps personalization"));

// Verify fields from sections
assert.ok(schema.fields.preferred_format);
assert.equal(schema.fields.preferred_format.type, "enum");
assert.deepEqual(schema.fields.preferred_format.allowedValues, ["text", "video", "quiz", "project", "mixed"]);
assert.equal(schema.fields.preferred_format.sensitive, false);
assert.equal(schema.fields.preferred_format.durable, true); // stable_preferences section is durable

assert.ok(schema.fields.active_topics);
assert.equal(schema.fields.active_topics.type, "Array<String>");
assert.equal(schema.fields.active_topics.durable, false); // current_goals section is NOT durable
});

test("SchemaAdapter normalizes Format 2 schema-driven categories (e.g., gaming)", async () => {
const schema = await SchemaAdapter.getSchema("gaming");

assert.equal(schema.category, "gaming");
assert.ok(schema.fields.preferred_genres);
assert.equal(schema.fields.preferred_genres.type, "Array<String>");

assert.ok(schema.fields.controller_setup);
assert.equal(schema.fields.controller_setup.type, "enum");
assert.deepEqual(schema.fields.controller_setup.allowedValues, ["keyboard_mouse", "gamepad", "touch", "mixed"]);
});

test("SchemaAdapter.getSchemas() resolves all categories in parallel", async () => {
const schemas = await SchemaAdapter.getSchemas();

assert.equal(Object.keys(schemas).length, CATEGORIES.length);
for (const cat of CATEGORIES) {
assert.ok(schemas[cat]);
assert.equal(schemas[cat].category, cat);
assert.ok(schemas[cat].fields);
}
});