Skip to content
Merged
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
28 changes: 27 additions & 1 deletion scripts/backfill-categories.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, any>>}
*/
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
Expand All @@ -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');

Expand All @@ -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;
Expand Down
11 changes: 9 additions & 2 deletions srv/lib/category-classifier.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
});
}

Expand Down
65 changes: 65 additions & 0 deletions srv/lib/category-seed-descriptions-defaults.js
Original file line number Diff line number Diff line change
@@ -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.',
};
24 changes: 21 additions & 3 deletions srv/lib/category-seed-embeddings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<categoryId, Float32Array> | null
let _stale = new Set(); // IDs marked invalid; recomputed on next getSeedEmbeddings()
let _loadingPromise = null; // Promise<Map> | null — in-flight loader
let _modelPromise = null; // Promise<string> | 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;
}

/**
Expand All @@ -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]);
Expand All @@ -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]);
}
Expand Down Expand Up @@ -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;
}
64 changes: 64 additions & 0 deletions srv/lib/seed-category-descriptions.js
Original file line number Diff line number Diff line change
@@ -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 };
}
20 changes: 20 additions & 0 deletions srv/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 63 additions & 0 deletions test/hybrid/backfill-categories-hana-resolution.test.js
Original file line number Diff line number Diff line change
@@ -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_<NAME>,
// 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 <NAME>".
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);
});
});
Loading
Loading