From fc20ec22bc316d0e618d1a84c4c35a9e8e89b796 Mon Sep 17 00:00:00 2001 From: Mohammad-Hassan027 Date: Sun, 12 Jul 2026 21:44:29 +0530 Subject: [PATCH 1/4] feat(schema-adapter): implement registry adapter for schema compilers --- src/schema-adapter.mjs | 215 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 src/schema-adapter.mjs diff --git a/src/schema-adapter.mjs b/src/schema-adapter.mjs new file mode 100644 index 0000000..4677f0a --- /dev/null +++ b/src/schema-adapter.mjs @@ -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} 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} 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; + } +} From e2d57cb3d7f033507c9b9a21a7566eb9145ed860 Mon Sep 17 00:00:00 2001 From: Mohammad-Hassan027 Date: Sun, 12 Jul 2026 21:44:52 +0530 Subject: [PATCH 2/4] test(schema-adapter): add focused tests for dynamic schema parsing --- test/schema-adapter.test.mjs | 99 ++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 test/schema-adapter.test.mjs diff --git a/test/schema-adapter.test.mjs b/test/schema-adapter.test.mjs new file mode 100644 index 0000000..c269e17 --- /dev/null +++ b/test/schema-adapter.test.mjs @@ -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"); + 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"); + + 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); + } +}); From 531cbcb468d5eaa5952de06233afa30648c9a549 Mon Sep 17 00:00:00 2001 From: Mohammad-Hassan027 Date: Sun, 12 Jul 2026 21:45:14 +0530 Subject: [PATCH 3/4] feat(engine): expose SchemaAdapter as a top-level export --- src/engine.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/engine.mjs b/src/engine.mjs index 15d5b7b..70c997e 100644 --- a/src/engine.mjs +++ b/src/engine.mjs @@ -1,3 +1,4 @@ export { CategoryRegistry, registry, CATEGORIES } from "./registry.mjs" export { normalizeFitnessContext } from "./categories/fitness.mjs" -export { normalizeMusicContext } from "./categories/music-streaming.mjs" \ No newline at end of file +export { normalizeMusicContext } from "./categories/music-streaming.mjs" +export { SchemaAdapter } from "./schema-adapter.mjs" \ No newline at end of file From 78e8a2649a8a88107b478ac0592217e25d223e43 Mon Sep 17 00:00:00 2001 From: Mohammad-Hassan027 Date: Sun, 12 Jul 2026 21:45:39 +0530 Subject: [PATCH 4/4] chore(package): map schema-adapter export entrypoint --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index f2c240a..0b3cea7 100644 --- a/package.json +++ b/package.json @@ -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",