Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 45s
Forgejo Android APK / Build signed APK (push) Successful in 2m1s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
My Resources generates better slides than Learning Hub ever did — a typed deck the model fills in, rendered by python-pptx with fit-to-slide text, figures, a vision review and themes, against Learning Hub's markdown-through-pandoc — and the articles and quizzes now live in the quiz app. Keeping a second, weaker generator and a whole CMS beside it was not earning its maintenance. Removed: three routers, the Learning Hub and Content Manager tabs, their components and frontend modules, the five database tables, the WebDAV browser, the content embedding column and its vector index. Content was exported first — every article as markdown plus a full SQL dump of all five tables — to ops-backups/learning-hub-export-*. That export is the restore path; the migration's down() can recreate the shape but never the rows, and says so. Two things this simplifies rather than merely deletes: generated_image_links existed only to record which published content an image appeared in, and it was the sole reason a generated image could be read by someone who did not make it. Images are now owner-only — the visibility rule is one WHERE clause instead of a join across two tables and a published flag. embeddings.js keeps the model discovery the admin panel uses and loses searchSimilar and generateContentEmbedding, which queried a table that no longer exists. Kept deliberately: Nextcloud connect, disconnect and export, which are how a generated note reaches a real filesystem and have nothing to do with Learning Hub; learningRetrieval, which despite its name is the clinical corpus search My Resources depends on; and the pandoc reference deck, still the fallback when the python renderer fails, moved from assets/learning to assets/deck now that the old name misleads. Tests: four Learning-Hub-only files removed, and the individual cases inside shared files that asserted its behaviour. Where a test used a Learning endpoint only as a convenient example — the account-boundary token test, the policy matrix — it now uses one that still exists, so the property it proves is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
108 lines
4.4 KiB
JavaScript
108 lines
4.4 KiB
JavaScript
const test = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
const vm = require('node:vm');
|
||
|
||
const root = path.join(__dirname, '..');
|
||
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
|
||
|
||
// Load the module with its two dependencies replaced, so these tests exercise
|
||
// the budgeting and failure handling without a Milvus behind them.
|
||
function load(mocks) {
|
||
const module = { exports: {} };
|
||
vm.runInNewContext(read('src/utils/learningRetrieval.js'), {
|
||
module, exports: module.exports, console: { warn() {}, info() {} },
|
||
require(name) {
|
||
if (Object.hasOwn(mocks, name)) return mocks[name];
|
||
throw new Error('unexpected import: ' + name);
|
||
}
|
||
});
|
||
return module.exports;
|
||
}
|
||
|
||
const passthrough = {
|
||
'./clinicalRetrieval': {
|
||
normalizeMcpSearchResponse: r => r.results || [],
|
||
dedupeSources: s => s,
|
||
cleanSourceExcerpt: t => String(t || '')
|
||
}
|
||
};
|
||
|
||
test('My Resources retrieves with its own budget, not the assistant’s', async () => {
|
||
let captured = null;
|
||
const lib = load({
|
||
...passthrough,
|
||
'./clinicalMcpClient': { semanticSearch: async (q, opts) => { captured = { q, opts }; return { results: [] }; } }
|
||
});
|
||
// A chat answer wants a few tight excerpts because the reader is waiting. A
|
||
// teaching resource synthesises a whole topic, so it wants many more and
|
||
// longer ones. The assistant's defaults are 8 and 1400.
|
||
assert.equal(lib.DEFAULTS.limit, 30);
|
||
assert.equal(lib.DEFAULTS.contextChars, 2500);
|
||
|
||
await lib.retrieve('bronchiolitis', async () => null);
|
||
assert.equal(captured.q, 'bronchiolitis');
|
||
assert.equal(captured.opts.limit, 30);
|
||
assert.equal(captured.opts.contextChars, 2500);
|
||
assert.equal(captured.opts.includeContext, true);
|
||
});
|
||
|
||
test('the budget is settable but bounded', async () => {
|
||
let captured = null;
|
||
const lib = load({
|
||
...passthrough,
|
||
'./clinicalMcpClient': { semanticSearch: async (q, opts) => { captured = opts; return { results: [] }; } }
|
||
});
|
||
const settings = { 'learning.search_limit': '999', 'learning.context_chars': '999999' };
|
||
await lib.retrieve('topic', async key => settings[key]);
|
||
// "No limit" only moves the ceiling from a setting to the model's context
|
||
// window, where overflow truncates the middle of the prompt silently.
|
||
assert.equal(captured.limit, lib.BOUNDS.limit[1]);
|
||
assert.equal(captured.contextChars, lib.BOUNDS.contextChars[1]);
|
||
|
||
const low = { 'learning.search_limit': '0', 'learning.context_chars': '1' };
|
||
await lib.retrieve('topic', async key => low[key]);
|
||
assert.equal(captured.limit, lib.BOUNDS.limit[0]);
|
||
assert.equal(captured.contextChars, lib.BOUNDS.contextChars[0]);
|
||
});
|
||
|
||
test('retrieval failing never fails the generation', async () => {
|
||
const lib = load({
|
||
...passthrough,
|
||
'./clinicalMcpClient': { semanticSearch: async () => { throw new Error('MCP unreachable'); } }
|
||
});
|
||
// Writing the resource from the model alone is what happened before this
|
||
// existed, and is a far better outcome than an error page.
|
||
const out = await lib.retrieve('bronchiolitis', async () => null);
|
||
// Length, not deepEqual: the array is created inside the VM realm, so its
|
||
// prototype is not this realm's Array and a structural compare fails.
|
||
assert.equal(out.sources.length, 0);
|
||
assert.equal(out.context, '');
|
||
assert.match(out.reason, /MCP unreachable/, 'and the caller is told why');
|
||
});
|
||
|
||
test('an empty corpus result is reported, not silently passed off as grounded', async () => {
|
||
const lib = load({
|
||
...passthrough,
|
||
'./clinicalMcpClient': { semanticSearch: async () => ({ results: [] }) }
|
||
});
|
||
const out = await lib.retrieve('something nobody indexed', async () => null);
|
||
assert.equal(out.context, '');
|
||
assert.match(out.reason, /nothing indexed matched/);
|
||
});
|
||
|
||
test('excerpts are numbered the way the assistant numbers them', async () => {
|
||
const lib = load({
|
||
...passthrough,
|
||
'./clinicalMcpClient': { semanticSearch: async () => ({ results: [
|
||
{ number: 1, title: 'Bronchiolitis chapter', page: 12, excerpt: 'Supportive care.' },
|
||
{ number: 2, title: 'RSV guidance', excerpt: 'Peak 3-6 months.' }
|
||
] }) }
|
||
});
|
||
const out = await lib.retrieve('bronchiolitis', async () => null);
|
||
assert.equal(out.sources.length, 2);
|
||
assert.match(out.context, /^\[1\] Bronchiolitis chapter, page 12\nSupportive care\./);
|
||
assert.match(out.context, /\[2\] RSV guidance\nPeak 3-6 months\./);
|
||
assert.equal(out.reason, null);
|
||
});
|