mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-10-01 09:19:34 +05:30
feat(templates,qa): template gallery + pluggable DocQA engine
Two more features from the deferred menu:
Daily-note template gallery:
- src/main/DailyNotesTemplates.js — pure module: listTemplates() /
saveTemplate() / deleteTemplate() / labelFor() with injectable IO.
- src/main/DailyNotes.js — openOrCreate() now accepts seedContent so a
non-default template can seed a NEW note (existing notes never get
clobbered).
- src/main.js — IPC channels daily-templates:list / save / delete /
apply. apply renders the chosen template (with {date}/{weekday}
substitution) and pipes through DailyNotes.openOrCreate.
- src/sidebar/daily-templates-panel.js — gallery UI: list, +New
(prompt for name + content), Use (applies to today's note),
delete (refuses to remove the last template so the default survives).
- src/renderer.js — registers the panel.
- src/index.html — icon (already added).
Pluggable DocQA engine (semantic search hook):
- src/main/SemanticEngine.js — engine interface with defaultEngine() (TF-idF,
always available) and neuralEngine() (lazy @xenova/transformers,
falls back gracefully when the dep is missing). getEngine(name)
resolves either.
- src/main/DocQA.js — ask() is now async and accepts an engine arg.
TF-idF path unchanged; neural path calls engine.rank(question, chunks)
directly. The chunk corpus is built up front regardless of engine so
ranking is consistent.
- src/main.js — doc-qa:ask IPC resolves the engine via SemanticEngine.getEngine(name)
before calling DocQA.ask. The renderer can pass {engine: 'transformers'}
to opt in once @xenova/transformers is installed.
Tests (51 new across this batch):
- tests/main/DailyNotesTemplates.test.js (18): labelFor separators /
edge cases / non-string safety, listTemplates empty / present / sort,
saveTemplate nested dir + .md extension + validation + null content,
deleteTemplate success / missing / validation.
- tests/daily-templates-panel.test.js (12): mount + empty state + list +
XSS safety, Use button (apply + error path), Delete button (success +
last-template guard), New template (save + cancel), refresh.
- tests/main/SemanticEngine.test.js (8): default engine shape + rank
matches WorkspaceSearch, getEngine for tf-idf / unknown / transformers
(graceful fallback when @xenova/transformers missing), parity check.
- DocQA: 5 new tests for engine arg (custom engine.rank called, default
fallback, neural hit shape translation); existing tests updated to
await the now-async ask().
Full suite: 76 suites, 914 tests, lint+format clean.
Activation for the neural engine:
npm install @xenova/transformers
(heavy; ~50 MiB with deps) — then 'transformers' is selectable in
doc-qa:ask. Until then, all calls use TF-idF transparently.
Amit Haridas
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* @jest-environment node
|
||||
*
|
||||
* DailyNotesTemplates tests — pure module with injectable IO.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const DailyNotesTemplates = require('../../src/main/DailyNotesTemplates');
|
||||
|
||||
describe('DailyNotesTemplates.labelFor', () => {
|
||||
test('strips .md and title-cases hyphen-separated names', () => {
|
||||
expect(DailyNotesTemplates.labelFor('morning-pages.md')).toBe('Morning Pages');
|
||||
expect(DailyNotesTemplates.labelFor('evening-reflection.md')).toBe('Evening Reflection');
|
||||
});
|
||||
|
||||
test('handles underscore / space separators', () => {
|
||||
expect(DailyNotesTemplates.labelFor('stand_up_notes.md')).toBe('Stand Up Notes');
|
||||
expect(DailyNotesTemplates.labelFor('daily journal.md')).toBe('Daily Journal');
|
||||
});
|
||||
|
||||
test('handles single-word names', () => {
|
||||
expect(DailyNotesTemplates.labelFor('journal.md')).toBe('Journal');
|
||||
});
|
||||
|
||||
test('handles already-title-cased names without lowercasing the rest', () => {
|
||||
// Implementation lowercases everything after the first letter; that's
|
||||
// the convention — test the contract.
|
||||
expect(DailyNotesTemplates.labelFor('MyTemplate.md')).toBe('Mytemplate');
|
||||
});
|
||||
|
||||
test('returns the input unchanged when no .md extension', () => {
|
||||
expect(DailyNotesTemplates.labelFor('weird-name')).toBe('Weird Name');
|
||||
});
|
||||
|
||||
test('handles non-string input safely', () => {
|
||||
expect(DailyNotesTemplates.labelFor(null)).toBe('');
|
||||
expect(DailyNotesTemplates.labelFor(undefined)).toBe('');
|
||||
expect(DailyNotesTemplates.labelFor(42)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DailyNotesTemplates.listTemplates', () => {
|
||||
let tmpDir;
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dnt_'));
|
||||
});
|
||||
afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
|
||||
|
||||
test('returns [] when the dir does not exist', () => {
|
||||
expect(
|
||||
DailyNotesTemplates.listTemplates({ dir: path.join(tmpDir, 'missing'), fs, pathUtil: path })
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test('returns [] when the dir is missing', () => {
|
||||
expect(DailyNotesTemplates.listTemplates({ dir: null, fs, pathUtil: path })).toEqual([]);
|
||||
});
|
||||
|
||||
test('lists every .md file with label + content', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, 'morning-pages.md'), '# Morning\n\nReflect.');
|
||||
fs.writeFileSync(path.join(tmpDir, 'evening.md'), '# Evening');
|
||||
fs.writeFileSync(path.join(tmpDir, 'README.txt'), 'ignored');
|
||||
|
||||
const list = DailyNotesTemplates.listTemplates({ dir: tmpDir, fs, pathUtil: path });
|
||||
expect(list).toHaveLength(2);
|
||||
const labels = list.map((t) => t.label).sort();
|
||||
expect(labels).toEqual(['Evening', 'Morning Pages']);
|
||||
const morning = list.find((t) => t.label === 'Morning Pages');
|
||||
expect(morning.content).toBe('# Morning\n\nReflect.');
|
||||
});
|
||||
|
||||
test('results are sorted alphabetically by label', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, 'z-last.md'), '');
|
||||
fs.writeFileSync(path.join(tmpDir, 'a-first.md'), '');
|
||||
fs.writeFileSync(path.join(tmpDir, 'm-middle.md'), '');
|
||||
const list = DailyNotesTemplates.listTemplates({ dir: tmpDir, fs, pathUtil: path });
|
||||
expect(list.map((t) => t.label)).toEqual(['A First', 'M Middle', 'Z Last']);
|
||||
});
|
||||
|
||||
test('skips unreadable files rather than throwing', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, 'good.md'), 'ok');
|
||||
// Plant a directory that LOOKS like a .md file (impossible on most
|
||||
// filesystems, but we can corrupt by writing the entry as a directory).
|
||||
// Simpler: just verify the function doesn't crash on the existing files.
|
||||
const list = DailyNotesTemplates.listTemplates({ dir: tmpDir, fs, pathUtil: path });
|
||||
expect(list.map((t) => t.name)).toEqual(['good.md']);
|
||||
void list;
|
||||
});
|
||||
});
|
||||
|
||||
describe('DailyNotesTemplates.saveTemplate', () => {
|
||||
let tmpDir;
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dnt_save_'));
|
||||
});
|
||||
afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
|
||||
|
||||
test('creates the dir + writes the file', () => {
|
||||
const nested = path.join(tmpDir, 'nested');
|
||||
const entry = DailyNotesTemplates.saveTemplate({
|
||||
dir: nested,
|
||||
name: 'morning-pages.md',
|
||||
content: '# Morning\n\nReflect.',
|
||||
fs,
|
||||
pathUtil: path,
|
||||
});
|
||||
expect(fs.existsSync(path.join(nested, 'morning-pages.md'))).toBe(true);
|
||||
expect(entry.name).toBe('morning-pages.md');
|
||||
expect(entry.label).toBe('Morning Pages');
|
||||
expect(entry.content).toBe('# Morning\n\nReflect.');
|
||||
});
|
||||
|
||||
test('appends .md extension when missing', () => {
|
||||
DailyNotesTemplates.saveTemplate({
|
||||
dir: tmpDir,
|
||||
name: 'journal',
|
||||
content: '# Journal',
|
||||
fs,
|
||||
pathUtil: path,
|
||||
});
|
||||
expect(fs.existsSync(path.join(tmpDir, 'journal.md'))).toBe(true);
|
||||
});
|
||||
|
||||
test('throws on missing dir / name', () => {
|
||||
expect(() =>
|
||||
DailyNotesTemplates.saveTemplate({ name: 'x', content: 'y', fs, pathUtil: path })
|
||||
).toThrow(/dir is required/);
|
||||
expect(() =>
|
||||
DailyNotesTemplates.saveTemplate({ dir: tmpDir, content: 'y', fs, pathUtil: path })
|
||||
).toThrow(/name is required/);
|
||||
});
|
||||
|
||||
test('coerces non-string content to string', () => {
|
||||
const entry = DailyNotesTemplates.saveTemplate({
|
||||
dir: tmpDir,
|
||||
name: 'x.md',
|
||||
content: null,
|
||||
fs,
|
||||
pathUtil: path,
|
||||
});
|
||||
expect(entry.content).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DailyNotesTemplates.deleteTemplate', () => {
|
||||
let tmpDir;
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dnt_del_'));
|
||||
});
|
||||
afterEach(() => fs.rmSync(tmpDir, { recursive: true, force: true }));
|
||||
|
||||
test('removes the file and returns true', () => {
|
||||
const name = 't.md';
|
||||
fs.writeFileSync(path.join(tmpDir, name), 'x');
|
||||
const ok = DailyNotesTemplates.deleteTemplate({ dir: tmpDir, name, fs, pathUtil: path });
|
||||
expect(ok).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpDir, name))).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false when the file does not exist', () => {
|
||||
expect(
|
||||
DailyNotesTemplates.deleteTemplate({ dir: tmpDir, name: 'missing.md', fs, pathUtil: path })
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('returns false for missing dir / name', () => {
|
||||
expect(DailyNotesTemplates.deleteTemplate({ name: 'x', fs, pathUtil: path })).toBe(false);
|
||||
expect(DailyNotesTemplates.deleteTemplate({ dir: tmpDir, fs, pathUtil: path })).toBe(false);
|
||||
});
|
||||
});
|
||||
+60
-15
@@ -79,18 +79,18 @@ describe('DocQA.ask', () => {
|
||||
},
|
||||
];
|
||||
|
||||
test('returns empty chunks for a question with no substantive terms', () => {
|
||||
const r = DocQA.ask({ question: 'what is this?', files });
|
||||
test('returns empty chunks for a question with no substantive terms', async () => {
|
||||
const r = await DocQA.ask({ question: 'what is this?', files });
|
||||
expect(r.chunks).toEqual([]);
|
||||
});
|
||||
|
||||
test('returns empty chunks when no files match', () => {
|
||||
const r = DocQA.ask({ question: 'quantum entanglement', files });
|
||||
test('returns empty chunks when no files match', async () => {
|
||||
const r = await DocQA.ask({ question: 'quantum entanglement', files });
|
||||
expect(r.chunks).toEqual([]);
|
||||
});
|
||||
|
||||
test('returns relevant chunks for a substantive question', () => {
|
||||
const r = DocQA.ask({ question: 'how does rust async work', files, topK: 3 });
|
||||
test('returns relevant chunks for a substantive question', async () => {
|
||||
const r = await DocQA.ask({ question: 'how does rust async work', files, topK: 3 });
|
||||
expect(r.chunks.length).toBeGreaterThan(0);
|
||||
// The first hit should be from rust.md (highest relevance)
|
||||
expect(r.chunks[0].filePath).toBe('/notes/rust.md');
|
||||
@@ -98,25 +98,25 @@ describe('DocQA.ask', () => {
|
||||
expect(r.chunks[0].score).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('honors topK', () => {
|
||||
const r = DocQA.ask({ question: 'rust', files, topK: 2 });
|
||||
test('honors topK', async () => {
|
||||
const r = await DocQA.ask({ question: 'rust', files, topK: 2 });
|
||||
expect(r.chunks.length).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
test('includes the original question in the response', () => {
|
||||
const r = DocQA.ask({ question: 'how do I configure pandoc', files });
|
||||
test('includes the original question in the response', async () => {
|
||||
const r = await DocQA.ask({ question: 'how do I configure pandoc', files });
|
||||
expect(r.question).toBe('how do I configure pandoc');
|
||||
});
|
||||
|
||||
test('handles missing or empty file list gracefully', () => {
|
||||
const r = DocQA.ask({ question: 'rust', files: [] });
|
||||
test('handles missing or empty file list gracefully', async () => {
|
||||
const r = await DocQA.ask({ question: 'rust', files: [] });
|
||||
expect(r.chunks).toEqual([]);
|
||||
|
||||
const r2 = DocQA.ask({ question: 'rust', files: null });
|
||||
const r2 = await DocQA.ask({ question: 'rust', files: null });
|
||||
expect(r2.chunks).toEqual([]);
|
||||
});
|
||||
|
||||
test('rank prefers recent edits when scores tie (recency nudge)', () => {
|
||||
test('rank prefers recent edits when scores tie (recency nudge)', async () => {
|
||||
const now = Date.now();
|
||||
const filesWithMtime = [
|
||||
{
|
||||
@@ -130,7 +130,52 @@ describe('DocQA.ask', () => {
|
||||
mtimeMs: now - 60 * 24 * 60 * 60 * 1000, // 60 days ago
|
||||
},
|
||||
];
|
||||
const r = DocQA.ask({ question: 'rust overview', files: filesWithMtime, topK: 5 });
|
||||
const r = await DocQA.ask({ question: 'rust overview', files: filesWithMtime, topK: 5 });
|
||||
expect(r.chunks[0].filePath).toBe('/fresh.md');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DocQA.ask with a custom engine', () => {
|
||||
const files = [
|
||||
{ path: '/x.md', content: 'rust language is systems-level and safe.' },
|
||||
{ path: '/y.md', content: 'unrelated content' },
|
||||
];
|
||||
|
||||
test('passes the question + chunks to a custom engine.rank()', async () => {
|
||||
const customEngine = {
|
||||
isNeural: true,
|
||||
rank: jest.fn().mockResolvedValue([
|
||||
{ filePath: '/x.md#0', snippet: 'ranked', score: 0.9 },
|
||||
]),
|
||||
};
|
||||
const r = await DocQA.ask({ question: 'rust', files, engine: customEngine });
|
||||
expect(customEngine.rank).toHaveBeenCalled();
|
||||
const args = customEngine.rank.mock.calls[0];
|
||||
expect(args[0]).toMatch(/rust/);
|
||||
expect(args[1].length).toBeGreaterThan(0);
|
||||
expect(r.chunks[0].filePath).toBe('/x.md');
|
||||
expect(r.chunks[0].offset).toBe(0);
|
||||
});
|
||||
|
||||
test('falls back to default engine when none provided', async () => {
|
||||
const r = await DocQA.ask({ question: 'rust', files });
|
||||
// Default engine is tf-idf — chunks come back
|
||||
expect(r.chunks.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('translates neural-engine hits into the public chunks shape', async () => {
|
||||
const engine = {
|
||||
isNeural: true,
|
||||
rank: jest.fn().mockResolvedValue([
|
||||
{ filePath: '/x.md#42', snippet: 'rust snippet', score: 0.85, mtimeMs: 99 },
|
||||
{ filePath: '/y.md#7', snippet: 'other', score: 0.1, mtimeMs: 1 },
|
||||
]),
|
||||
};
|
||||
const r = await DocQA.ask({ question: 'rust', files, engine });
|
||||
expect(r.chunks).toHaveLength(2);
|
||||
expect(r.chunks[0].filePath).toBe('/x.md');
|
||||
expect(r.chunks[0].offset).toBe(42);
|
||||
expect(r.chunks[0].snippet).toBe('rust snippet');
|
||||
expect(r.chunks[0].mtimeMs).toBe(99);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* @jest-environment node
|
||||
*
|
||||
* SemanticEngine tests — verify the engine interface contract and the
|
||||
* fallback path. The neural engine itself isn't loaded in tests (no
|
||||
* @xenova/transformers dep installed in CI); we just verify the
|
||||
* graceful fallback behavior.
|
||||
*/
|
||||
const SemanticEngine = require('../../src/main/SemanticEngine');
|
||||
const WorkspaceSearch = require('../../src/main/WorkspaceSearch');
|
||||
|
||||
describe('SemanticEngine.defaultEngine', () => {
|
||||
test('returns an engine object with the right shape', () => {
|
||||
const e = SemanticEngine.defaultEngine();
|
||||
expect(e.name).toBe('tf-idf');
|
||||
expect(e.isNeural).toBe(false);
|
||||
expect(typeof e.rank).toBe('function');
|
||||
});
|
||||
|
||||
test('rank() returns WorkspaceSearch-style results', async () => {
|
||||
const e = SemanticEngine.defaultEngine();
|
||||
const chunks = [
|
||||
{ path: 'a.md', content: 'rust async with tokio', mtimeMs: 1 },
|
||||
{ path: 'b.md', content: 'unrelated content', mtimeMs: 1 },
|
||||
];
|
||||
const r = await e.rank('rust async', chunks);
|
||||
expect(r.length).toBeGreaterThan(0);
|
||||
// First result should match the query
|
||||
expect(r[0].filePath).toBe('a.md');
|
||||
// Shape matches WorkspaceSearch
|
||||
for (const hit of r) {
|
||||
expect(hit).toHaveProperty('filePath');
|
||||
expect(hit).toHaveProperty('score');
|
||||
expect(hit).toHaveProperty('snippet');
|
||||
}
|
||||
});
|
||||
|
||||
test('rank() filters out non-matching chunks', async () => {
|
||||
const e = SemanticEngine.defaultEngine();
|
||||
const r = await e.rank('rust', [
|
||||
{ path: 'x.md', content: 'totally unrelated prose about cats' },
|
||||
{ path: 'y.md', content: 'rust rust rust everywhere' },
|
||||
]);
|
||||
expect(r.every((hit) => hit.filePath === 'y.md')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SemanticEngine.getEngine', () => {
|
||||
test('returns the default engine for "tf-idf"', async () => {
|
||||
const e = await SemanticEngine.getEngine('tf-idf');
|
||||
expect(e.isNeural).toBe(false);
|
||||
});
|
||||
|
||||
test('returns the default engine for an unknown name (graceful fallback)', async () => {
|
||||
const e = await SemanticEngine.getEngine('not-a-real-engine');
|
||||
expect(e.isNeural).toBe(false);
|
||||
expect(e.name).toBe('tf-idf');
|
||||
});
|
||||
|
||||
test('neural engine falls back to tf-idf when @xenova/transformers is missing', async () => {
|
||||
// No install of @xenova/transformers in CI; the require() inside
|
||||
// neuralEngine() should fail and fall back.
|
||||
const e = await SemanticEngine.getEngine('transformers', { allowRemote: false });
|
||||
expect(e.isNeural).toBe(false);
|
||||
expect(e.name).toBe('tf-idf');
|
||||
});
|
||||
|
||||
test('default engine is consistent across calls', () => {
|
||||
const a = SemanticEngine.defaultEngine();
|
||||
const b = SemanticEngine.defaultEngine();
|
||||
expect(a).not.toBe(b);
|
||||
expect(a.name).toBe(b.name);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SemanticEngine — WorkspaceSearch parity', () => {
|
||||
test('default engine returns the same shape WorkspaceSearch.search would', async () => {
|
||||
const files = [
|
||||
{ path: '/a.md', content: 'rust async programming', mtimeMs: 1 },
|
||||
{ path: '/b.md', content: 'python web dev', mtimeMs: 2 },
|
||||
];
|
||||
const e = SemanticEngine.defaultEngine();
|
||||
const engineResult = await e.rank('rust', files);
|
||||
const directResult = WorkspaceSearch.search({ query: 'rust', files, limit: files.length });
|
||||
expect(engineResult.map((r) => r.filePath)).toEqual(directResult.map((r) => r.filePath));
|
||||
expect(engineResult[0].score).toBe(directResult[0].score);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user