diff --git a/scripts/backfill-categories.cjs b/scripts/backfill-categories.cjs index b0c2d4a13..f3c783092 100644 --- a/scripts/backfill-categories.cjs +++ b/scripts/backfill-categories.cjs @@ -40,6 +40,28 @@ function kindToEntity(kind) { return map[kind]; } +/** + * Resolve the ims namespace entities robustly across contexts. + * + * In a standalone `cds bind --exec` runner the model may not be linked into + * the cds.entities() globals (cds.model unset), so fall back to explicitly + * loading + linking (same gotcha as srv/lib/seed-poc-puzzle.js). Returns the + * RESOLVED entity objects — SELECT/DELETE/INSERT against a bare short-name + * STRING ('Tutorials') does NOT resolve to the namespaced HANA table + * (COM_SAP_DEVELOPERS_IMS_TUTORIALS) and dies with "Could not find table/view + * TUTORIALS"; it only ever worked against local SQLite. + * + * @param {typeof import('@sap/cds')} cds + * @returns {Promise>} + */ +async function resolveImsEntities(cds) { + if (typeof cds.entities === 'function' && cds.model) { + return cds.entities('com.sap.developers.ims'); + } + const linked = cds.linked(await cds.load('*')); + return linked.entities('com.sap.developers.ims'); +} + /** * Run a batch of items concurrently using Promise.allSettled. * @param {Array} items @@ -64,6 +86,10 @@ async function main(argv) { // Connect to database const db = await cds.connect.to('db'); + // Resolve namespaced entity objects once (see resolveImsEntities). Passing a + // bare short-name string to SELECT.from fails against HANA. + const ims = await resolveImsEntities(cds); + // Dynamic import for ESM module const { classifyAndPersist } = await import('../srv/lib/category-classifier.js'); @@ -79,7 +105,7 @@ async function main(argv) { // Fetch all IDs ordered const rows = await db.run( - SELECT.from(entityName).columns('ID').orderBy('ID') + SELECT.from(ims[entityName]).columns('ID').orderBy('ID') ); let items = rows; diff --git a/srv/lib/category-classifier.js b/srv/lib/category-classifier.js index a328e637f..5f6777bad 100644 --- a/srv/lib/category-classifier.js +++ b/srv/lib/category-classifier.js @@ -100,15 +100,22 @@ function pickEmbeddingResult(scored) { async function persist(kind, itemId, assigned) { const cfg = KIND_TO_ENTITY[kind]; + // Resolve the junction to its namespaced entity object. A bare short-name + // string ('TutorialCategories') does NOT resolve to the HANA table + // (COM_SAP_DEVELOPERS_IMS_TUTORIALCATEGORIES) when this runs outside a + // service handler (e.g. the standalone backfill via `cds bind --exec`) — + // it emits bare `TUTORIALCATEGORIES` SQL and dies. loadItemText already + // resolves this way, so cds.entities() is known-good by the time we get here. + const junction = cds.entities('com.sap.developers.ims')[cfg.junction]; await cds.tx(async (tx) => { - await tx.run(DELETE.from(cfg.junction).where({ [cfg.fk]: itemId })); + await tx.run(DELETE.from(junction).where({ [cfg.fk]: itemId })); if (assigned.length === 0) return; const rows = assigned.map(a => ({ [cfg.fk]: itemId, category_ID: a.ID, score: a.score ?? 1.0, })); - await tx.run(INSERT.into(cfg.junction).entries(rows)); + await tx.run(INSERT.into(junction).entries(rows)); }); } diff --git a/srv/lib/category-seed-descriptions-defaults.js b/srv/lib/category-seed-descriptions-defaults.js new file mode 100644 index 000000000..49c552ced --- /dev/null +++ b/srv/lib/category-seed-descriptions-defaults.js @@ -0,0 +1,65 @@ +// srv/lib/category-seed-descriptions-defaults.js +// +// Single source of truth for the baseline Category.seedDescription texts. +// +// These paragraphs are what the category classifier EMBEDS and cosine-compares +// against each tutorial/mission/group's (title + description + primaryTag). They +// are intentionally keyword-rich and product-named so the embedding path +// (srv/lib/category-classifier.js) can classify without falling back to the LLM. +// +// The Categories reference rows themselves come from +// db/data/com.sap.developers.ims-Categories.csv (ID/slug/label/sortOrder only — +// no seedDescription column, so seeds are NOT shipped via CSV; that would +// full-replace the admin-editable column on every deploy). Instead these are +// seeded idempotently + non-destructively at boot by +// ./seed-category-descriptions.js, which fills ONLY rows whose seedDescription +// is empty — admin edits made at /admin-ui/ are preserved. +// +// Keyed by Categories.slug (stable) so a re-ordered/re-IDed CSV can't misalign. + +export const CATEGORY_SEED_DESCRIPTIONS = { + 'app-dev-automation': + 'Building business applications and extensions with the SAP Cloud Application ' + + 'Programming Model (CAP), SAP Build and SAP Build Process Automation, low-code and ' + + 'pro-code development, workflow and business process automation, the ABAP RESTful ' + + 'Application Programming Model (RAP), side-by-side extensions, SAP Business Application ' + + 'Studio, and developer tooling for creating, deploying, and automating apps and services.', + + 'data-analytics': + 'Working with data using SAP HANA Cloud, SAP Datasphere, and SAP Analytics Cloud: data ' + + 'modeling, SQL and calculation views, data federation and replication, business ' + + 'intelligence, reporting, dashboards, stories, data warehousing, and analytical models.', + + 'extended-planning': + 'Financial and operational planning, budgeting, forecasting, and analysis with SAP ' + + 'Analytics Cloud planning, SAP Datasphere, and extended planning and analysis (xP&A): ' + + 'predictive planning, allocations, value driver trees, and enterprise performance management.', + + 'integration': + 'Connecting systems and services with SAP Integration Suite: Cloud Integration, API ' + + 'Management, Open Connectors, event-driven integration with SAP Event Mesh and Advanced ' + + 'Event Mesh, EDI and B2B, destinations, connectivity, and integrating SAP with ' + + 'third-party applications.', + + 'artificial-intelligence': + 'Building intelligent applications with SAP AI Core, the Generative AI Hub, SAP Business ' + + 'AI, and Joule: machine learning, large language models, embeddings and ' + + 'retrieval-augmented generation (RAG), document information extraction, orchestration, ' + + 'and AI-powered automation and copilots.', + + 'frontend-ux': + 'Creating user interfaces and experiences with SAPUI5, SAP Fiori and Fiori Elements, UI5 ' + + 'Web Components, SAP Build Apps, HTML, CSS and JavaScript, React and Vue front ends, ' + + 'responsive design, theming, and building engaging developer and end-user experiences.', + + 'cloud-operations': + 'Operating and administering SAP BTP, the Cloud Foundry and Kyma runtimes: deployment ' + + 'with multitarget applications (MTA), CI/CD pipelines and DevOps, security and ' + + 'authentication with XSUAA, monitoring, logging and alerting, subaccounts, entitlements, ' + + 'and cloud lifecycle management.', + + 'abap-core': + 'ABAP programming and ABAP Cloud development: clean core extensibility, SAP S/4HANA and ' + + 'on-premise systems, RAP and CDS views in ABAP, the ABAP Development Tools (ADT) in ' + + 'Eclipse, released (tier-1) APIs, and core ERP business logic and data models.', +}; diff --git a/srv/lib/category-seed-embeddings.js b/srv/lib/category-seed-embeddings.js index a621ae7ba..4f064ae29 100644 --- a/srv/lib/category-seed-embeddings.js +++ b/srv/lib/category-seed-embeddings.js @@ -18,17 +18,35 @@ import cds from '@sap/cds'; import { embed } from './embedding-client.js'; +import { resolveEmbeddingSettings } from './chat-settings-resolver.js'; const LOG = cds.log('category-seed-embeddings'); let _cache = null; // Map | null let _stale = new Set(); // IDs marked invalid; recomputed on next getSeedEmbeddings() let _loadingPromise = null; // Promise | null — in-flight loader +let _modelPromise = null; // Promise | null — memoized embedding model name /** Test-only — resets module state between tests. */ export function _resetCache() { _cache = null; _stale = new Set(); _loadingPromise = null; + _modelPromise = null; +} + +/** + * Resolve (once, memoized) the embedding model name. embed() REQUIRES a model + * — passing undefined constructs AzureOpenAiEmbeddingClient(undefined), which + * throws "Cannot read properties of undefined (reading 'modelName')" (the + * #2001 class of bug: embed callers must pass a resolved model). The model + * rarely changes; memoize so a bulk backfill (thousands of embedAdHoc calls) + * doesn't re-read ChatSettings per item. Cleared by _resetCache() in tests. + */ +function getEmbeddingModel() { + if (!_modelPromise) { + _modelPromise = resolveEmbeddingSettings().then((s) => s.model); + } + return _modelPromise; } /** @@ -44,7 +62,7 @@ async function loadAll() { LOG.warn('No categories with seedDescription found — classifier will fall back to LLM for everything'); return new Map(); } - const vectors = await embed(usable.map(r => r.seedDescription)); + const vectors = await embed(usable.map(r => r.seedDescription), await getEmbeddingModel()); const m = new Map(); for (let i = 0; i < usable.length; i++) { m.set(usable[i].ID, vectors[i]); @@ -69,7 +87,7 @@ async function recomputeStale(staleIds) { const rows = await SELECT.from(Categories).columns('ID', 'seedDescription'); const targets = rows.filter(r => staleIds.has(r.ID) && r.seedDescription && r.seedDescription.trim().length > 0); if (targets.length === 0) return; - const vectors = await embed(targets.map(r => r.seedDescription)); + const vectors = await embed(targets.map(r => r.seedDescription), await getEmbeddingModel()); for (let i = 0; i < targets.length; i++) { _cache.set(targets[i].ID, vectors[i]); } @@ -142,6 +160,6 @@ export async function embedAdHoc(text) { if (!text || !text.trim()) { throw new Error('embedAdHoc: empty text'); } - const [vec] = await embed([text]); + const [vec] = await embed([text], await getEmbeddingModel()); return vec; } diff --git a/srv/lib/seed-category-descriptions.js b/srv/lib/seed-category-descriptions.js new file mode 100644 index 000000000..32c5586e1 --- /dev/null +++ b/srv/lib/seed-category-descriptions.js @@ -0,0 +1,64 @@ +// srv/lib/seed-category-descriptions.js +// +// Idempotent, NON-DESTRUCTIVE boot-seed for Category.seedDescription. +// +// Categories.csv ships ID/slug/label/sortOrder only — the admin-editable +// seedDescription column is deliberately absent (a CSV column would +// full-replace admin edits on every deploy; see CLAUDE.md "CSV changes wipe +// admin-editable columns"). So the baseline seed texts live in +// ./category-seed-descriptions-defaults.js and are applied here, filling ONLY +// rows whose seedDescription is currently empty/null. Rows an author has +// already edited (non-empty) are never touched. +// +// Called from: +// - srv/server.js cds.on('served') (guarded by globalThis sentinel + VITEST gate) +// - test/unit + test/hybrid (via dynamic import, passing a db override) + +import cds from '@sap/cds'; +import { CATEGORY_SEED_DESCRIPTIONS } from './category-seed-descriptions-defaults.js'; + +const NAMESPACE = 'com.sap.developers.ims'; + +/** + * Seed missing Category.seedDescription values idempotently. + * + * @param {object} [dbOverride] — already-connected cds db (tests). When omitted, + * connects via cds.connect.to('db'). + * @returns {Promise<{updated: number, total: number}>} + * updated = rows whose empty seedDescription we filled this run + * total = category rows examined + */ +export async function seedCategoryDescriptions(dbOverride) { + const db = dbOverride ?? await cds.connect.to('db'); + + // Resolve Categories robustly across contexts (booted server / cds.test → + // cds.entities() installed; standalone `cds bind --exec` → model not linked + // into globals, so load+link). Same gotcha as seed-poc-puzzle.js. + let Categories; + if (typeof cds.entities === 'function' && cds.model) { + ({ Categories } = cds.entities(NAMESPACE)); + } else { + const linked = cds.linked(await cds.load('*')); + ({ Categories } = linked.entities(NAMESPACE)); + } + + // Explicit columns: a bare SELECT emits `SELECT *`, which HANA cannot infer + // when the entity comes from a separately-linked model (standalone path). + const rows = await db.run( + SELECT.from(Categories).columns('ID', 'slug', 'seedDescription') + ); + + let updated = 0; + for (const row of rows) { + const seed = CATEGORY_SEED_DESCRIPTIONS[row.slug]; + if (!seed) continue; // slug not in defaults → leave alone + const current = (row.seedDescription ?? '').trim(); + if (current) continue; // author-authored / already seeded → preserve + await db.run( + UPDATE(Categories).set({ seedDescription: seed }).where({ ID: row.ID }) + ); + updated++; + } + + return { updated, total: rows.length }; +} diff --git a/srv/server.js b/srv/server.js index 65a36abce..1835c59b6 100644 --- a/srv/server.js +++ b/srv/server.js @@ -1437,6 +1437,26 @@ cds.on('served', async () => { } } + // Seed baseline Category.seedDescription texts (tunes the embedding-based + // category classifier). Idempotent + non-destructive: fills only rows whose + // seedDescription is empty, never overwrites admin edits. Kept out of CSV so + // deploys can't full-replace the admin-editable column. VITEST-gated: seeding + // descriptions flips the classifier's embedding path ON, which would leak + // into unit tests asserting the LLM/skip fallback (see memory + // db-flag-boot-seed-leaks-into-vitest-harness). Non-fatal. + if (!process.env.VITEST && !globalThis.__categorySeedDescriptionsSeeded) { + globalThis.__categorySeedDescriptionsSeeded = true; + try { + const { seedCategoryDescriptions } = await import('./lib/seed-category-descriptions.js'); + const result = await seedCategoryDescriptions(cds.db); + if (result.updated > 0) { + console.log(`[category-seed-descriptions] seeded ${result.updated}/${result.total} category descriptions`); + } + } catch (err) { + console.warn('[category-seed-descriptions] seed failed (non-fatal):', err.message); + } + } + app.get('/auth/user', contextMw, authMw, async (req, res) => { // #1268: coarse deploy environment (DEV/PROD/QA/LOCAL) for the admin // header. Derived from the CF space name — safe to expose to anonymous diff --git a/test/hybrid/backfill-categories-hana-resolution.test.js b/test/hybrid/backfill-categories-hana-resolution.test.js new file mode 100644 index 000000000..772d20aaf --- /dev/null +++ b/test/hybrid/backfill-categories-hana-resolution.test.js @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest'; +import cds from '@sap/cds'; +import { isSafeForWrites } from './_guard.js'; + +// Runs against real HANA via `cds bind --exec` + the hybrid profile. +// The seed is non-destructive (fills only empty seedDescription); still gated +// behind isSafeForWrites() so it can never touch a prod container. +const RUN = process.env.HYBRID_TESTS === 'true' && isSafeForWrites(); + +cds.test('serve', '--project', '.', '--profile', 'hybrid'); + +const NS = 'com.sap.developers.ims'; + +(RUN ? describe : describe.skip)('hybrid: category backfill entity resolution against real HANA', () => { + // The backfill (scripts/backfill-categories.cjs) and classifier persist() + // regressed because they queried bare short-name strings ('Tutorials', + // 'TutorialCategories'), which resolve on local SQLite but emit bare + // TUTORIALS / TUTORIALCATEGORIES SQL against HANA (real tables are + // COM_SAP_DEVELOPERS_IMS_*). The fix resolves entity OBJECTS via a linked + // model. This test drives that exact load+link path (the standalone + // `cds bind --exec` branch, where cds.model is unset) against real HANA. + + it('load+link resolution reaches the namespaced HANA tables (no "table not found")', async () => { + const db = await cds.connect.to('db'); + const linked = cds.linked(await cds.load('*')); + const ents = linked.entities(NS); + + for (const name of ['Tutorials', 'Missions', 'Groups', 'TutorialCategories', 'MissionCategories', 'GroupCategories']) { + const ent = ents[name]; + expect(ent, `${name} must resolve from the linked model`).toBeTruthy(); + // Fully-qualified so the emitted SQL targets COM_SAP_DEVELOPERS_IMS_, + // not a bare unqualified table. + expect(ent.name).toBe(`${NS}.${name}`); + // The real assertion: a resolved-object SELECT executes on HANA. A bare + // short-name string here would throw "Could not find table/view ". + await expect( + db.run(SELECT.from(ent).columns('ID').limit(1)), + ).resolves.toBeDefined(); + } + }); + + it('seedCategoryDescriptions runs against real HANA and leaves all seeds populated', async () => { + const { seedCategoryDescriptions } = await import('../../srv/lib/seed-category-descriptions.js'); + const { CATEGORY_SEED_DESCRIPTIONS } = await import('../../srv/lib/category-seed-descriptions-defaults.js'); + const db = await cds.connect.to('db'); + + const res = await seedCategoryDescriptions(db); + expect(res.total).toBeGreaterThan(0); + expect(res.updated).toBeGreaterThanOrEqual(0); // may already be seeded from a prior run + + // Non-destructive + idempotent: after seeding, every category that has a + // default must carry a non-empty seedDescription, and a second run is a no-op. + const { Categories } = cds.linked(await cds.load('*')).entities(NS); + const rows = await db.run(SELECT.from(Categories).columns('slug', 'seedDescription')); + for (const row of rows) { + if (CATEGORY_SEED_DESCRIPTIONS[row.slug]) { + expect((row.seedDescription ?? '').trim().length).toBeGreaterThan(0); + } + } + const again = await seedCategoryDescriptions(db); + expect(again.updated).toBe(0); + }); +}); diff --git a/test/unit/seed-category-descriptions.test.js b/test/unit/seed-category-descriptions.test.js new file mode 100644 index 000000000..bfa539348 --- /dev/null +++ b/test/unit/seed-category-descriptions.test.js @@ -0,0 +1,82 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import cds from '@sap/cds'; +import { seedCategoryDescriptions } from '../../srv/lib/seed-category-descriptions.js'; +import { CATEGORY_SEED_DESCRIPTIONS } from '../../srv/lib/category-seed-descriptions-defaults.js'; + +cds.test('serve', '--project', '.', '--in-memory'); + +// NOTE: the boot seed (srv/server.js) is VITEST-gated, so category +// seedDescriptions are NOT auto-filled here — every test drives +// seedCategoryDescriptions(db) explicitly. Categories rows themselves come from +// db/data/com.sap.developers.ims-Categories.csv (ID/slug/label/sortOrder), which +// loads into the in-memory DB; their seedDescription starts null. + +describe('CATEGORY_SEED_DESCRIPTIONS (defaults integrity)', () => { + it('has non-empty descriptions and keys match the shipped category slugs', async () => { + const db = await cds.connect.to('db'); + const { Categories } = cds.entities('com.sap.developers.ims'); + const rows = await db.run(SELECT.from(Categories).columns('slug')); + const csvSlugs = new Set(rows.map((r) => r.slug)); + + const defaultSlugs = Object.keys(CATEGORY_SEED_DESCRIPTIONS); + expect(defaultSlugs.length).toBe(rows.length); // one default per shipped category + for (const slug of defaultSlugs) { + expect(csvSlugs.has(slug), `default slug '${slug}' not in Categories.csv`).toBe(true); + const text = CATEGORY_SEED_DESCRIPTIONS[slug]; + expect(typeof text).toBe('string'); + expect(text.trim().length).toBeGreaterThan(40); // rich enough to embed + } + }); +}); + +describe('seedCategoryDescriptions (idempotent, non-destructive boot seed)', () => { + let db; + let Categories; + beforeAll(async () => { + db = await cds.connect.to('db'); + ({ Categories } = cds.entities('com.sap.developers.ims')); + }); + + it('fills every empty seedDescription on first run', async () => { + const res = await seedCategoryDescriptions(db); + expect(res.total).toBe(Object.keys(CATEGORY_SEED_DESCRIPTIONS).length); + expect(res.updated).toBe(res.total); // all started empty + + const rows = await db.run(SELECT.from(Categories).columns('slug', 'seedDescription')); + for (const row of rows) { + expect(row.seedDescription).toBe(CATEGORY_SEED_DESCRIPTIONS[row.slug]); + } + }); + + it('is idempotent — a second run updates nothing', async () => { + const res = await seedCategoryDescriptions(db); + expect(res.updated).toBe(0); + }); + + it('never overwrites an admin-edited seedDescription', async () => { + const edited = 'ADMIN EDITED SEED — do not clobber'; + const target = Object.keys(CATEGORY_SEED_DESCRIPTIONS)[0]; + await db.run(UPDATE(Categories).set({ seedDescription: edited }).where({ slug: target })); + + const res = await seedCategoryDescriptions(db); + expect(res.updated).toBe(0); // nothing empty → nothing changed + + const row = await db.run( + SELECT.one.from(Categories).columns('seedDescription').where({ slug: target }), + ); + expect(row.seedDescription).toBe(edited); // untouched + }); + + it('re-seeds a description that was cleared back to empty (self-heals)', async () => { + const target = Object.keys(CATEGORY_SEED_DESCRIPTIONS)[1]; + await db.run(UPDATE(Categories).set({ seedDescription: '' }).where({ slug: target })); + + const res = await seedCategoryDescriptions(db); + expect(res.updated).toBe(1); + + const row = await db.run( + SELECT.one.from(Categories).columns('seedDescription').where({ slug: target }), + ); + expect(row.seedDescription).toBe(CATEGORY_SEED_DESCRIPTIONS[target]); + }); +});