Compare commits
6 Commits
feat/ai-pi
...
feat/ai-pi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66e0c275da | ||
|
|
c82e4fc3a1 | ||
| fd3b849c19 | |||
|
|
aeb197d5f4 | ||
| 9771928926 | |||
| 33529dfb2b |
23
pb_migrations/1780500000_updated_topics_relevance_locked.js
Normal file
23
pb_migrations/1780500000_updated_topics_relevance_locked.js
Normal file
@@ -0,0 +1,23 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_2800040823")
|
||||
|
||||
// add field — relevance_locked is set to true whenever an admin edits
|
||||
// learning_relevance via the UI; mergeKnowledgeGraph must never overwrite
|
||||
// learning_relevance on a locked topic during re-extraction.
|
||||
collection.fields.addAt(5, new Field({
|
||||
"hidden": false,
|
||||
"id": "bool_relevance_locked",
|
||||
"name": "relevance_locked",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "bool"
|
||||
}))
|
||||
|
||||
return app.save(collection)
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId("pbc_2800040823")
|
||||
collection.fields.removeById("bool_relevance_locked")
|
||||
return app.save(collection)
|
||||
})
|
||||
43
pb_migrations/1780500001_normalize_relation_types.js
Normal file
43
pb_migrations/1780500001_normalize_relation_types.js
Normal file
@@ -0,0 +1,43 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
// One-shot data migration: rewrite legacy "executes" relations to the
|
||||
// canonical "executed_by" vocabulary by swapping source and target.
|
||||
// Previously `role --executes--> process`; canonical is
|
||||
// `process --executed_by--> role`.
|
||||
migrate((app) => {
|
||||
const records = app.findRecordsByFilter(
|
||||
"pbc_1883724256", // relations collection
|
||||
'type = "executes"',
|
||||
'',
|
||||
0,
|
||||
0,
|
||||
)
|
||||
|
||||
for (const rec of records) {
|
||||
const source = rec.get("source")
|
||||
const target = rec.get("target")
|
||||
rec.set("type", "executed_by")
|
||||
rec.set("source", target)
|
||||
rec.set("target", source)
|
||||
app.save(rec)
|
||||
}
|
||||
}, (app) => {
|
||||
// Reverse: turn executed_by back into executes (best-effort — only those
|
||||
// created before this migration would have been "executes"; rolling back
|
||||
// will affect any newer executed_by rows too).
|
||||
const records = app.findRecordsByFilter(
|
||||
"pbc_1883724256",
|
||||
'type = "executed_by"',
|
||||
'',
|
||||
0,
|
||||
0,
|
||||
)
|
||||
|
||||
for (const rec of records) {
|
||||
const source = rec.get("source")
|
||||
const target = rec.get("target")
|
||||
rec.set("type", "executes")
|
||||
rec.set("source", target)
|
||||
rec.set("target", source)
|
||||
app.save(rec)
|
||||
}
|
||||
})
|
||||
@@ -180,10 +180,17 @@ const KnowledgeGraph = () => {
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
await db.upsertTopic({ ...selectedNode, ...editData });
|
||||
const updated = topics.map(t => t.id === selectedNode.id ? { ...t, ...editData } : t);
|
||||
// If the admin touched learning_relevance, lock it so re-extraction won't overwrite the choice.
|
||||
// But an explicit relevance_locked in editData (the unlock checkbox) always wins.
|
||||
const relevanceChanged =
|
||||
editData.learning_relevance !== undefined &&
|
||||
editData.learning_relevance !== selectedNode.learning_relevance;
|
||||
const next = { ...editData };
|
||||
if (relevanceChanged && next.relevance_locked === undefined) next.relevance_locked = true;
|
||||
await db.upsertTopic({ ...selectedNode, ...next });
|
||||
const updated = topics.map(t => t.id === selectedNode.id ? { ...t, ...next } : t);
|
||||
setTopics(updated);
|
||||
setSelectedNode({ ...selectedNode, ...editData });
|
||||
setSelectedNode({ ...selectedNode, ...next });
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
@@ -265,22 +272,11 @@ const KnowledgeGraph = () => {
|
||||
setSyncProgress(`Processing ${count} of ${filesToProcess.length}: ${file.name}...`);
|
||||
try {
|
||||
const rawContent = await getFileContent('respellion', 'employee-handbook', file.path);
|
||||
// Pacing is handled centrally by extractionLimiter inside analyzeHandbookDelta.
|
||||
await analyzeHandbookDelta(rawContent, file.path);
|
||||
await db.updateHandbookSyncState(file.path, file.sha);
|
||||
|
||||
// To respect Anthropic's 5 requests per minute rate limit on this tier,
|
||||
// we pause for 15 seconds before processing the next file.
|
||||
if (count < filesToProcess.length) {
|
||||
setSyncProgress(`Waiting 15s to avoid rate limits... (${count}/${filesToProcess.length})`);
|
||||
await new Promise(resolve => setTimeout(resolve, 15000));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to process file:', file.path, err);
|
||||
// We continue processing other files even if one fails, but still wait to avoid further rate limits
|
||||
if (count < filesToProcess.length) {
|
||||
setSyncProgress(`Error on ${file.name}. Waiting 15s... (${count}/${filesToProcess.length})`);
|
||||
await new Promise(resolve => setTimeout(resolve, 15000));
|
||||
}
|
||||
}
|
||||
}
|
||||
setSyncProgress('Sync Complete! Click "Analyze & Optimize Graph" above to clean up and merge.');
|
||||
@@ -369,7 +365,7 @@ ${JSON.stringify({ topics: compactTopics, relations: compactRelations })}`;
|
||||
if (actions.relevanceUpdates && Array.isArray(actions.relevanceUpdates)) {
|
||||
for (const update of actions.relevanceUpdates) {
|
||||
const topicIndex = updatedTopics.findIndex(t => t.id === update.id);
|
||||
if (topicIndex !== -1) {
|
||||
if (topicIndex !== -1 && !updatedTopics[topicIndex].relevance_locked) {
|
||||
updatedTopics[topicIndex] = { ...updatedTopics[topicIndex], learning_relevance: update.learning_relevance };
|
||||
}
|
||||
}
|
||||
@@ -532,6 +528,17 @@ ${JSON.stringify({ topics: compactTopics, relations: compactRelations })}`;
|
||||
<option value="peripheral">Peripheral</option>
|
||||
<option value="exclude">Exclude</option>
|
||||
</select>
|
||||
{selectedNode.relevance_locked && (
|
||||
<label className="flex items-center gap-2 text-xs text-fg-muted mt-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editData.relevance_locked !== false}
|
||||
onChange={e => setEditData({...editData, relevance_locked: e.target.checked})}
|
||||
className="rounded bg-bg-warm border-transparent focus:ring-0 text-teal"
|
||||
/>
|
||||
Locked — re-extraction will not change this
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-fg-muted uppercase tracking-wider mb-1 block">Description</label>
|
||||
@@ -554,9 +561,12 @@ ${JSON.stringify({ topics: compactTopics, relations: compactRelations })}`;
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-fg-muted uppercase tracking-wider mb-1">Type & Relevance</p>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<span className="inline-block px-2 py-1 bg-bg-warm rounded-[var(--r-pill)] text-xs font-mono">{selectedNode.type}</span>
|
||||
<span className="inline-block px-2 py-1 bg-bg-warm rounded-[var(--r-pill)] text-xs font-mono opacity-80">{selectedNode.learning_relevance || 'standard'}</span>
|
||||
{selectedNode.relevance_locked && (
|
||||
<span className="inline-block px-2 py-1 bg-teal/10 text-teal rounded-[var(--r-pill)] text-xs font-mono" title="Re-extraction will not change relevance for this topic.">locked</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -34,7 +34,7 @@ const TestManager = () => {
|
||||
loadData();
|
||||
}, [selectedTopic]);
|
||||
|
||||
const handleGenerate = async (topic, count = 10) => {
|
||||
const handleGenerate = async (topic, count = 5) => {
|
||||
setLoadingTopicId(topic.id);
|
||||
setError(null);
|
||||
try {
|
||||
@@ -97,8 +97,8 @@ const TestManager = () => {
|
||||
{questions.length === 0 ? (
|
||||
<Card className="text-center py-12 text-fg-muted border-dashed border-2">
|
||||
<p>No questions generated for this topic yet.</p>
|
||||
<Button className="mt-4" onClick={() => handleGenerate(selectedTopic, 10)} disabled={loadingTopicId === selectedTopic.id}>
|
||||
Generate 10 Questions
|
||||
<Button className="mt-4" onClick={() => handleGenerate(selectedTopic, 5)} disabled={loadingTopicId === selectedTopic.id}>
|
||||
Generate 5 Questions
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { UploadCloud, AlertCircle, CheckCircle } from 'lucide-react';
|
||||
import { UploadCloud, AlertCircle, CheckCircle, X } from 'lucide-react';
|
||||
import { processSourceText } from '../../lib/extractionPipeline';
|
||||
import Card from '../ui/Card';
|
||||
import Button from '../ui/Button';
|
||||
@@ -12,24 +12,36 @@ const UploadZone = ({ onUploadComplete }) => {
|
||||
const [status, setStatus] = useState(null);
|
||||
|
||||
const fileInputRef = useRef(null);
|
||||
const abortRef = useRef(null);
|
||||
|
||||
// ── File upload (drag & drop / browse) ────────────────────────────────────
|
||||
|
||||
const processFile = async (file) => {
|
||||
setIsProcessing(true);
|
||||
setStatus(null);
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
try {
|
||||
const text = await file.text();
|
||||
await processSourceText(text, file.name);
|
||||
await processSourceText(text, file.name, { signal: controller.signal });
|
||||
setStatus({ type: 'success', msg: `Successfully processed: ${file.name}` });
|
||||
if (onUploadComplete) onUploadComplete();
|
||||
} catch (error) {
|
||||
setStatus({ type: 'error', msg: `Error processing file: ${error.message}` });
|
||||
if (error?.name === 'AbortError') {
|
||||
setStatus({ type: 'error', msg: `Cancelled: ${file.name}` });
|
||||
} else {
|
||||
setStatus({ type: 'error', msg: `Error processing file: ${error.message}` });
|
||||
}
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelProcessing = () => {
|
||||
abortRef.current?.abort(new DOMException('Cancelled by user', 'AbortError'));
|
||||
};
|
||||
|
||||
const handleDragOver = (e) => { e.preventDefault(); setIsDragging(true); };
|
||||
const handleDragLeave = () => setIsDragging(false);
|
||||
|
||||
@@ -80,6 +92,19 @@ const UploadZone = ({ onUploadComplete }) => {
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Cancel sits outside the upload card so pointer-events-none doesn't disable it. */}
|
||||
{isProcessing && (
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelProcessing}
|
||||
className="flex items-center gap-1 text-sm text-red-600 hover:text-red-700 underline"
|
||||
>
|
||||
<X size={14} /> Cancel extraction
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Status messages ─── */}
|
||||
{status && (
|
||||
<div className={`p-4 rounded-[var(--r-sm)] flex items-start gap-3 ${
|
||||
|
||||
89
src/lib/__tests__/extractionPipeline.test.js
Normal file
89
src/lib/__tests__/extractionPipeline.test.js
Normal file
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
vi.mock('../pb', () => ({
|
||||
pb: { collection: () => ({ create: () => ({ catch: () => {} }) }) },
|
||||
}));
|
||||
|
||||
import { chunkText, buildKnownIdsHint, MAX_CHUNK_CHARS, OVERLAP_CHARS } from '../extractionPipeline';
|
||||
|
||||
describe('chunkText', () => {
|
||||
let warnSpy;
|
||||
beforeEach(() => { warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); });
|
||||
afterEach(() => { warnSpy.mockRestore(); });
|
||||
|
||||
it('returns the original text as a single chunk when below maxChars', () => {
|
||||
const result = chunkText('A short paragraph.');
|
||||
expect(result).toEqual(['A short paragraph.']);
|
||||
});
|
||||
|
||||
it('returns empty array for empty/whitespace input', () => {
|
||||
expect(chunkText('')).toEqual([]);
|
||||
expect(chunkText(' \n ')).toEqual([]);
|
||||
});
|
||||
|
||||
it('splits along sentence boundaries with overlap between adjacent chunks', () => {
|
||||
const sentence = 'This is a sentence with exactly a known length. ';
|
||||
const text = sentence.repeat(100); // ~5000 chars
|
||||
const chunks = chunkText(text, { maxChars: 600, overlapChars: 150 });
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
for (const c of chunks) {
|
||||
expect(c.length).toBeLessThanOrEqual(600);
|
||||
}
|
||||
// Adjacent chunks share trailing text — the overlap should be non-empty.
|
||||
for (let i = 1; i < chunks.length; i++) {
|
||||
const tail = chunks[i - 1].slice(-150);
|
||||
// The new chunk must begin with content that appears at the tail of the prior chunk.
|
||||
const firstHundred = chunks[i].slice(0, 100);
|
||||
// At least one word from the tail should appear in the head of the next chunk.
|
||||
const words = tail.split(/\s+/).filter((w) => w.length > 3);
|
||||
const shared = words.some((w) => firstHundred.includes(w));
|
||||
expect(shared).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('hard-splits a single sentence that exceeds maxChars and logs a warning', () => {
|
||||
const huge = 'word '.repeat(400).trim() + '.'; // ~2000 chars, no sentence break
|
||||
const chunks = chunkText(huge, { maxChars: 500, overlapChars: 50 });
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
for (const c of chunks) expect(c.length).toBeLessThanOrEqual(500);
|
||||
});
|
||||
|
||||
it('handles paragraph-only splits when no sentence punctuation is present', () => {
|
||||
const paragraphs = Array.from({ length: 10 }, (_, i) => `paragraph ${i} content here`).join('\n\n');
|
||||
const chunks = chunkText(paragraphs, { maxChars: 80, overlapChars: 20 });
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('uses the documented defaults', () => {
|
||||
expect(MAX_CHUNK_CHARS).toBe(8000);
|
||||
expect(OVERLAP_CHARS).toBe(800);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildKnownIdsHint', () => {
|
||||
it('returns empty string when no IDs are known', () => {
|
||||
expect(buildKnownIdsHint([])).toBe('');
|
||||
expect(buildKnownIdsHint(undefined)).toBe('');
|
||||
expect(buildKnownIdsHint(null)).toBe('');
|
||||
});
|
||||
|
||||
it('formats the known IDs as a bulleted list with a leading instruction', () => {
|
||||
const hint = buildKnownIdsHint(['software-engineer', 'onboarding-buddy']);
|
||||
expect(hint).toContain('Already-extracted topic IDs');
|
||||
expect(hint).toContain('- software-engineer');
|
||||
expect(hint).toContain('- onboarding-buddy');
|
||||
expect(hint.endsWith('\n')).toBe(true);
|
||||
});
|
||||
|
||||
it('caps the hint at the 200 most recent IDs', () => {
|
||||
const ids = Array.from({ length: 250 }, (_, i) => `topic-${i}`);
|
||||
const hint = buildKnownIdsHint(ids);
|
||||
// The newest IDs must appear; the oldest must not.
|
||||
expect(hint).toContain('topic-249');
|
||||
expect(hint).toContain('topic-50');
|
||||
expect(hint).not.toContain('topic-49');
|
||||
expect(hint).not.toContain('topic-0\n');
|
||||
});
|
||||
});
|
||||
72
src/lib/__tests__/llmRetry.test.js
Normal file
72
src/lib/__tests__/llmRetry.test.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import { createLimiter, extractionLimiter } from '../llmRetry';
|
||||
|
||||
afterEach(() => { vi.useRealTimers(); });
|
||||
|
||||
describe('createLimiter', () => {
|
||||
it('rejects an invalid rps', () => {
|
||||
expect(() => createLimiter({ rps: 0 })).toThrow();
|
||||
expect(() => createLimiter({ rps: -1 })).toThrow();
|
||||
});
|
||||
|
||||
it('rejects an invalid burst', () => {
|
||||
expect(() => createLimiter({ rps: 1, burst: 0 })).toThrow();
|
||||
});
|
||||
|
||||
it('lets the first call through immediately (initial burst token)', async () => {
|
||||
const limiter = createLimiter({ rps: 1, burst: 1 });
|
||||
const start = Date.now();
|
||||
await limiter.acquire();
|
||||
expect(Date.now() - start).toBeLessThan(50);
|
||||
});
|
||||
|
||||
it('queues subsequent calls to respect the spacing', async () => {
|
||||
vi.useFakeTimers();
|
||||
const limiter = createLimiter({ rps: 10, burst: 1 }); // 100ms spacing
|
||||
await limiter.acquire(); // consume initial token
|
||||
|
||||
let resolved = false;
|
||||
const p = limiter.acquire().then(() => { resolved = true; });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(resolved).toBe(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
await p;
|
||||
expect(resolved).toBe(true);
|
||||
});
|
||||
|
||||
it('honours pauseUntil — no acquire returns before the pause expires', async () => {
|
||||
vi.useFakeTimers();
|
||||
const limiter = createLimiter({ rps: 100, burst: 5 });
|
||||
limiter.pauseUntil(Date.now() + 1000);
|
||||
|
||||
let resolved = false;
|
||||
const p = limiter.acquire().then(() => { resolved = true; });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(resolved).toBe(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(600);
|
||||
await p;
|
||||
expect(resolved).toBe(true);
|
||||
});
|
||||
|
||||
it('aborts a queued acquire when the signal fires', async () => {
|
||||
const limiter = createLimiter({ rps: 1, burst: 1 });
|
||||
await limiter.acquire(); // consume
|
||||
|
||||
const ctl = new AbortController();
|
||||
const p = limiter.acquire({ signal: ctl.signal });
|
||||
ctl.abort();
|
||||
|
||||
await expect(p).rejects.toBeInstanceOf(DOMException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractionLimiter', () => {
|
||||
it('is exported and exposes the limiter shape', () => {
|
||||
expect(typeof extractionLimiter.acquire).toBe('function');
|
||||
expect(typeof extractionLimiter.pauseUntil).toBe('function');
|
||||
});
|
||||
});
|
||||
@@ -108,7 +108,7 @@ describe('learning schemas', () => {
|
||||
});
|
||||
|
||||
describe('quizQuestionsSchema', () => {
|
||||
it('accepts a quiz with four options and a valid correctIndex', () => {
|
||||
it('accepts a quiz with four options, a valid correctIndex and a difficulty', () => {
|
||||
const parsed = quizQuestionsSchema.parse({
|
||||
questions: [
|
||||
{
|
||||
@@ -118,10 +118,12 @@ describe('quizQuestionsSchema', () => {
|
||||
options: ['A', 'B', 'C', 'D'],
|
||||
correctIndex: 2,
|
||||
explanation: 'C describes the buddy system best.',
|
||||
difficulty: 'easy',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(parsed.questions[0].options).toHaveLength(4);
|
||||
expect(parsed.questions[0].difficulty).toBe('easy');
|
||||
});
|
||||
|
||||
it('rejects three options or an out-of-range correctIndex', () => {
|
||||
@@ -135,6 +137,7 @@ describe('quizQuestionsSchema', () => {
|
||||
options: ['A', 'B', 'C'],
|
||||
correctIndex: 0,
|
||||
explanation: 'e',
|
||||
difficulty: 'medium',
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -149,11 +152,27 @@ describe('quizQuestionsSchema', () => {
|
||||
options: ['A', 'B', 'C', 'D'],
|
||||
correctIndex: 4,
|
||||
explanation: 'e',
|
||||
difficulty: 'medium',
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('rejects a missing or unknown difficulty', () => {
|
||||
const base = {
|
||||
id: 'q',
|
||||
question: 'q',
|
||||
topicLabel: 't',
|
||||
options: ['A', 'B', 'C', 'D'],
|
||||
correctIndex: 0,
|
||||
explanation: 'because',
|
||||
};
|
||||
expect(() => quizQuestionsSchema.parse({ questions: [base] })).toThrow();
|
||||
expect(() =>
|
||||
quizQuestionsSchema.parse({ questions: [{ ...base, difficulty: 'trivial' }] }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('customTopicSchema', () => {
|
||||
|
||||
48
src/lib/__tests__/random.test.js
Normal file
48
src/lib/__tests__/random.test.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { shuffle, sample, pickInt } from '../random';
|
||||
|
||||
describe('shuffle', () => {
|
||||
it('returns a new array containing the same elements', () => {
|
||||
const input = [1, 2, 3, 4, 5];
|
||||
const out = shuffle(input);
|
||||
expect(out).not.toBe(input);
|
||||
expect([...out].sort()).toEqual([...input].sort());
|
||||
expect(input).toEqual([1, 2, 3, 4, 5]);
|
||||
});
|
||||
|
||||
it('handles empty and single-element arrays', () => {
|
||||
expect(shuffle([])).toEqual([]);
|
||||
expect(shuffle([42])).toEqual([42]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sample', () => {
|
||||
it('returns up to n unique elements from the source array', () => {
|
||||
const out = sample([1, 2, 3, 4, 5], 3);
|
||||
expect(out).toHaveLength(3);
|
||||
expect(new Set(out).size).toBe(3);
|
||||
for (const v of out) expect([1, 2, 3, 4, 5]).toContain(v);
|
||||
});
|
||||
|
||||
it('returns the full shuffled array when n exceeds length', () => {
|
||||
const out = sample([1, 2, 3], 10);
|
||||
expect(out).toHaveLength(3);
|
||||
expect([...out].sort()).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it('returns an empty array when n is zero or negative', () => {
|
||||
expect(sample([1, 2, 3], 0)).toEqual([]);
|
||||
expect(sample([1, 2, 3], -2)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickInt', () => {
|
||||
it('returns an integer in the inclusive range', () => {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const v = pickInt(2, 5);
|
||||
expect(Number.isInteger(v)).toBe(true);
|
||||
expect(v).toBeGreaterThanOrEqual(2);
|
||||
expect(v).toBeLessThanOrEqual(5);
|
||||
}
|
||||
});
|
||||
});
|
||||
112
src/lib/__tests__/testService.test.js
Normal file
112
src/lib/__tests__/testService.test.js
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
const bankStore = new Map();
|
||||
const callLLMMock = vi.fn();
|
||||
|
||||
vi.mock('../pb', () => ({ pb: { collection: () => ({}) } }));
|
||||
|
||||
vi.mock('../db', () => ({
|
||||
getQuizBank: vi.fn(async (topicId) => bankStore.get(topicId) || []),
|
||||
setQuizBank: vi.fn(async (topicId, qs) => { bankStore.set(topicId, qs); }),
|
||||
getTopics: vi.fn(async () => []),
|
||||
deleteQuestionFromBank: vi.fn(),
|
||||
getCachedQuiz: vi.fn(),
|
||||
setCachedQuiz: vi.fn(),
|
||||
getQuizResult: vi.fn(),
|
||||
saveQuizResult: vi.fn(),
|
||||
getTeamMembers: vi.fn(async () => []),
|
||||
upsertLeaderboardEntry: vi.fn(),
|
||||
getCurriculum: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../llm', () => ({ callLLM: (...args) => callLLMMock(...args) }));
|
||||
vi.mock('../curriculumService', () => ({
|
||||
getCurriculumTopic: vi.fn(async () => ({ topic: null })),
|
||||
getQuarterForWeek: vi.fn(() => 1),
|
||||
}));
|
||||
|
||||
import { forceGenerateTopicQuestions } from '../testService';
|
||||
|
||||
const topic = { id: 'onboarding', label: 'Onboarding', type: 'concept', description: 'Onboarding for new joiners.' };
|
||||
|
||||
function makeQuestion(i, overrides = {}) {
|
||||
return {
|
||||
id: `q-${i}`,
|
||||
question: `Sample question ${i}?`,
|
||||
topicLabel: 'Onboarding',
|
||||
options: ['A) one', 'B) two', 'C) three', 'D) four'],
|
||||
correctIndex: i % 4,
|
||||
explanation: 'This is a substantive explanation for the correct answer.',
|
||||
difficulty: 'medium',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function llmEmits(questions) {
|
||||
callLLMMock.mockResolvedValueOnce({ toolUses: [{ name: 'emit_quiz_questions', input: { questions } }] });
|
||||
}
|
||||
|
||||
describe('forceGenerateTopicQuestions', () => {
|
||||
let debugSpy, warnSpy;
|
||||
beforeEach(() => {
|
||||
bankStore.clear();
|
||||
callLLMMock.mockReset();
|
||||
debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {});
|
||||
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
});
|
||||
afterEach(() => { debugSpy.mockRestore(); warnSpy.mockRestore(); });
|
||||
|
||||
it('persists a well-formed batch and assigns topic-scoped ids', async () => {
|
||||
llmEmits([0, 1, 2, 3, 4].map((i) => makeQuestion(i)));
|
||||
const out = await forceGenerateTopicQuestions(topic, 5);
|
||||
expect(out).toHaveLength(5);
|
||||
for (const q of out) expect(q.id.startsWith('onboarding-')).toBe(true);
|
||||
expect(bankStore.get('onboarding')).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('re-rolls when one correctIndex dominates the batch, then accepts on the third try', async () => {
|
||||
const allZero = [0, 1, 2, 3, 4].map((i) => makeQuestion(i, { correctIndex: 0 }));
|
||||
llmEmits(allZero);
|
||||
llmEmits(allZero);
|
||||
llmEmits(allZero);
|
||||
const out = await forceGenerateTopicQuestions(topic, 5);
|
||||
expect(callLLMMock).toHaveBeenCalledTimes(3);
|
||||
expect(out).toHaveLength(5);
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('correctIndex dominated'));
|
||||
});
|
||||
|
||||
it('rejects a batch containing a banned "all of the above" option', async () => {
|
||||
const bad = [0, 1, 2, 3, 4].map((i) =>
|
||||
makeQuestion(i, { options: ['A) x', 'B) y', 'C) z', 'D) All of the above'] }),
|
||||
);
|
||||
llmEmits(bad);
|
||||
llmEmits(bad);
|
||||
llmEmits(bad);
|
||||
await expect(forceGenerateTopicQuestions(topic, 5)).rejects.toThrow(/banned filler|rejected/i);
|
||||
});
|
||||
|
||||
it('rejects a batch where an explanation is too short', async () => {
|
||||
const bad = [0, 1, 2, 3, 4].map((i) => makeQuestion(i, { explanation: 'Because.' }));
|
||||
llmEmits(bad);
|
||||
llmEmits(bad);
|
||||
llmEmits(bad);
|
||||
await expect(forceGenerateTopicQuestions(topic, 5)).rejects.toThrow(/too short|rejected/i);
|
||||
});
|
||||
|
||||
it('drops duplicates whose normalized text matches an existing bank entry', async () => {
|
||||
bankStore.set('onboarding', [
|
||||
{ ...makeQuestion(99), id: 'old-1', question: 'What is the BUDDY system???' },
|
||||
]);
|
||||
llmEmits([
|
||||
makeQuestion(0, { question: 'what is the buddy system!' }),
|
||||
makeQuestion(1, { question: 'Brand new question one?' }),
|
||||
makeQuestion(2, { question: 'Brand new question two?' }),
|
||||
makeQuestion(3, { question: 'Brand new question three?' }),
|
||||
makeQuestion(4, { question: 'Brand new question four?' }),
|
||||
]);
|
||||
const out = await forceGenerateTopicQuestions(topic, 5);
|
||||
expect(out).toHaveLength(4);
|
||||
expect(out.find((q) => q.question.toLowerCase().includes('buddy'))).toBeUndefined();
|
||||
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining('dropped duplicate'), expect.any(String));
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ export async function saveTopics(topics) {
|
||||
type: t.type,
|
||||
description: t.description,
|
||||
learning_relevance: t.learning_relevance || 'standard',
|
||||
relevance_locked: t.relevance_locked === true,
|
||||
}, { requestKey: null });
|
||||
}
|
||||
}
|
||||
@@ -89,10 +90,15 @@ export async function deleteContent(topicId) {
|
||||
|
||||
// ── Quiz Banks ───────────────────────────────────────────────────────────────
|
||||
|
||||
function normalizeQuizQuestion(q) {
|
||||
if (!q || typeof q !== 'object') return q;
|
||||
return q.difficulty ? q : { ...q, difficulty: 'medium' };
|
||||
}
|
||||
|
||||
export async function getQuizBank(topicId) {
|
||||
try {
|
||||
const r = await pb.collection('quiz_banks').getFirstListItem(`topic_id="${topicId}"`);
|
||||
return r.questions || [];
|
||||
return (r.questions || []).map(normalizeQuizQuestion);
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,28 @@
|
||||
import * as db from './db';
|
||||
import { callLLM } from './llm';
|
||||
import { extractionLimiter } from './llmRetry';
|
||||
import { EMIT_KNOWLEDGE_GRAPH_TOOL, EMIT_HANDBOOK_DELTA_TOOL } from './llmTools';
|
||||
import { normalizeHandbookResult } from './llmSchemas';
|
||||
|
||||
const MAX_KNOWN_IDS_HINT = 200;
|
||||
|
||||
/**
|
||||
* Build the "already-extracted topic IDs" hint that prepends every chunk
|
||||
* after the first. Capped at the most-recent `MAX_KNOWN_IDS_HINT` IDs so
|
||||
* the prompt stays a bounded size; the model uses this list to reuse IDs
|
||||
* rather than invent variants like `software-developer` for
|
||||
* `software-engineer`.
|
||||
*/
|
||||
export function buildKnownIdsHint(ids) {
|
||||
if (!ids || !ids.length) return '';
|
||||
const recent = ids.slice(-MAX_KNOWN_IDS_HINT);
|
||||
return [
|
||||
'Already-extracted topic IDs (do NOT create new IDs for these — reuse them if the same concept appears here):',
|
||||
...recent.map((id) => `- ${id}`),
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const EXTRACTION_SYSTEM_PROMPT = `You are an AI knowledge extractor for Respellion, an IT company built on radical transparency.
|
||||
You receive a source text. Extract every distinct concept, role, and process from it and emit them through the emit_knowledge_graph tool.
|
||||
|
||||
@@ -28,17 +48,17 @@ Relation types: related_to | depends_on | part_of | executed_by.
|
||||
const HANDBOOK_SYSTEM_PROMPT = `You are analysing an update to the Respellion Employee Handbook. Emit the extracted topics and relations through the emit_handbook_delta tool.
|
||||
|
||||
CRITICAL INSTRUCTIONS:
|
||||
- Every process must have a role attached (the role that executes it).
|
||||
- Every process must have a role attached. Express this as: process --executed_by--> role.
|
||||
- Every concept must connect to a process or role.
|
||||
- Mark handbook topics with metadata.source = "github_handbook".
|
||||
- Assign learning_relevance using the same scale as extraction: core | standard | peripheral | exclude.
|
||||
|
||||
Relation types: related_to | depends_on | part_of | executed_by. (Legacy "executes" relations are normalised by the client into executed_by with source/target swapped.)
|
||||
Relation types: related_to | depends_on | part_of | executed_by.
|
||||
`;
|
||||
|
||||
const cachedSystem = (text) => [{ type: 'text', text, cache_control: { type: 'ephemeral' } }];
|
||||
|
||||
export async function analyzeHandbookDelta(fileContent, filePath) {
|
||||
export async function analyzeHandbookDelta(fileContent, filePath, { signal } = {}) {
|
||||
const result = await callLLM({
|
||||
task: 'extract.handbook',
|
||||
tier: 'standard',
|
||||
@@ -47,6 +67,8 @@ export async function analyzeHandbookDelta(fileContent, filePath) {
|
||||
tools: [EMIT_HANDBOOK_DELTA_TOOL],
|
||||
toolChoice: { type: 'tool', name: EMIT_HANDBOOK_DELTA_TOOL.name },
|
||||
maxTokens: 8192,
|
||||
limiter: extractionLimiter,
|
||||
signal,
|
||||
});
|
||||
|
||||
const raw = result.toolUses[0]?.input;
|
||||
@@ -57,24 +79,79 @@ export async function analyzeHandbookDelta(fileContent, filePath) {
|
||||
return { success: true, data: extractedData };
|
||||
}
|
||||
|
||||
function chunkText(text, maxChunkSize = 4000) {
|
||||
const paragraphs = text.split(/\n+/);
|
||||
const chunks = [];
|
||||
let currentChunk = '';
|
||||
/**
|
||||
* Sentence-aware chunker with overlap.
|
||||
*
|
||||
* Targets ~2000 input tokens per chunk (`MAX_CHUNK_CHARS / 4`). Splits on
|
||||
* sentence boundaries first, then falls back to paragraph boundaries, and
|
||||
* hard-splits inside an oversized sentence as a last resort. Adjacent chunks
|
||||
* share `overlapChars` of trailing text to preserve cross-boundary context
|
||||
* for the model.
|
||||
*
|
||||
* Exported for unit tests; callers in this module use it directly.
|
||||
*
|
||||
* @param {string} text
|
||||
* @param {{ maxChars?: number, overlapChars?: number }} [opts]
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export const MAX_CHUNK_CHARS = 8000;
|
||||
export const OVERLAP_CHARS = 800;
|
||||
|
||||
for (const para of paragraphs) {
|
||||
if ((currentChunk + '\n' + para).length > maxChunkSize) {
|
||||
if (currentChunk) chunks.push(currentChunk.trim());
|
||||
currentChunk = para;
|
||||
} else {
|
||||
currentChunk = currentChunk ? currentChunk + '\n' + para : para;
|
||||
export function chunkText(text, { maxChars = MAX_CHUNK_CHARS, overlapChars = OVERLAP_CHARS } = {}) {
|
||||
if (typeof text !== 'string' || !text.trim()) return [];
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.length <= maxChars) return [trimmed];
|
||||
|
||||
const units = splitIntoChunkableUnits(trimmed, maxChars);
|
||||
if (units.length === 0) return [];
|
||||
|
||||
const chunks = [];
|
||||
let buf = '';
|
||||
let bufLen = 0; // length of new (non-overlap) content added since last flush
|
||||
|
||||
for (const unit of units) {
|
||||
const wouldOverflow = (buf ? buf.length + 1 + unit.length : unit.length) > maxChars;
|
||||
if (wouldOverflow && bufLen > 0) {
|
||||
chunks.push(buf.trim());
|
||||
const overlap = buf.length > overlapChars ? buf.slice(-overlapChars) : '';
|
||||
buf = overlap;
|
||||
bufLen = 0;
|
||||
}
|
||||
// If the overlap + unit still won't fit, drop the overlap so the unit fits cleanly.
|
||||
if (buf && (buf.length + 1 + unit.length) > maxChars) {
|
||||
buf = '';
|
||||
}
|
||||
buf = buf ? buf + ' ' + unit : unit;
|
||||
bufLen += unit.length + (bufLen > 0 ? 1 : 0);
|
||||
}
|
||||
if (currentChunk) chunks.push(currentChunk.trim());
|
||||
if (bufLen > 0 && buf.trim()) chunks.push(buf.trim());
|
||||
return chunks;
|
||||
}
|
||||
|
||||
export async function processSourceText(textContent, sourceName) {
|
||||
function splitIntoChunkableUnits(text, maxChars) {
|
||||
const paragraphs = text.split(/\n\s*\n+/);
|
||||
const units = [];
|
||||
for (const para of paragraphs) {
|
||||
const trimmedPara = para.trim();
|
||||
if (!trimmedPara) continue;
|
||||
const sentences = trimmedPara.split(/(?<=[.!?])\s+/);
|
||||
for (const s of sentences) {
|
||||
const sentence = s.trim();
|
||||
if (!sentence) continue;
|
||||
if (sentence.length <= maxChars) {
|
||||
units.push(sentence);
|
||||
} else {
|
||||
for (let i = 0; i < sentence.length; i += maxChars) {
|
||||
units.push(sentence.slice(i, i + maxChars));
|
||||
}
|
||||
console.warn(`[chunkText] Hard-split a sentence of ${sentence.length} chars (exceeds maxChars=${maxChars}).`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return units;
|
||||
}
|
||||
|
||||
export async function processSourceText(textContent, sourceName, { signal } = {}) {
|
||||
const existing = await db.getSources();
|
||||
const alreadyDone = existing.find(
|
||||
s => s.name === sourceName && s.status === 'completed'
|
||||
@@ -87,36 +164,42 @@ export async function processSourceText(textContent, sourceName) {
|
||||
const sourceId = rec.id;
|
||||
|
||||
try {
|
||||
const chunks = chunkText(textContent, 4000);
|
||||
const chunks = chunkText(textContent);
|
||||
console.log(`[Pipeline] Split "${sourceName}" into ${chunks.length} chunks for processing.`);
|
||||
|
||||
let allExtractedTopics = [];
|
||||
let allExtractedRelations = [];
|
||||
const existingTopics = await db.getTopics();
|
||||
const knownIds = existingTopics.map((t) => t.id);
|
||||
|
||||
const allExtractedTopics = [];
|
||||
const allExtractedRelations = [];
|
||||
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
if (i > 0) {
|
||||
console.log(`[Pipeline] Pacing delay (12s) to prevent rate limits before chunk ${i + 1}/${chunks.length}...`);
|
||||
await new Promise(r => setTimeout(r, 12000));
|
||||
}
|
||||
if (signal?.aborted) throw signal.reason ?? new DOMException('Aborted', 'AbortError');
|
||||
|
||||
console.log(`[Pipeline] Processing chunk ${i + 1}/${chunks.length} (${chunks[i].length} chars)...`);
|
||||
const hint = i > 0 ? buildKnownIdsHint(knownIds) : '';
|
||||
const result = await callLLM({
|
||||
task: 'extract.source',
|
||||
tier: 'standard',
|
||||
system: cachedSystem(EXTRACTION_SYSTEM_PROMPT),
|
||||
user: `Analyze this part of the document (${i + 1}/${chunks.length}):\n\n${chunks[i]}`,
|
||||
user: `${hint}Analyze this part of the document (${i + 1}/${chunks.length}):\n\n${chunks[i]}`,
|
||||
tools: [EMIT_KNOWLEDGE_GRAPH_TOOL],
|
||||
toolChoice: { type: 'tool', name: EMIT_KNOWLEDGE_GRAPH_TOOL.name },
|
||||
maxTokens: 8192,
|
||||
limiter: extractionLimiter,
|
||||
signal,
|
||||
});
|
||||
|
||||
const extractedData = result.toolUses[0]?.input;
|
||||
if (!extractedData) throw new Error(`Extraction did not emit a tool result for chunk ${i + 1}.`);
|
||||
|
||||
if (extractedData.topics && Array.isArray(extractedData.topics)) {
|
||||
if (Array.isArray(extractedData.topics)) {
|
||||
allExtractedTopics.push(...extractedData.topics);
|
||||
for (const t of extractedData.topics) {
|
||||
if (t?.id && !knownIds.includes(t.id)) knownIds.push(t.id);
|
||||
}
|
||||
}
|
||||
if (extractedData.relations && Array.isArray(extractedData.relations)) {
|
||||
if (Array.isArray(extractedData.relations)) {
|
||||
allExtractedRelations.push(...extractedData.relations);
|
||||
}
|
||||
}
|
||||
@@ -127,7 +210,8 @@ export async function processSourceText(textContent, sourceName) {
|
||||
return { success: true, data: { topics: allExtractedTopics, relations: allExtractedRelations } };
|
||||
|
||||
} catch (error) {
|
||||
await db.updateSourceStatus(sourceId, 'failed', error.message);
|
||||
const isAbort = error?.name === 'AbortError';
|
||||
await db.updateSourceStatus(sourceId, isAbort ? 'cancelled' : 'failed', isAbort ? 'cancelled by user' : error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -142,11 +226,16 @@ async function mergeKnowledgeGraph(newData) {
|
||||
for (const t of newData.topics) {
|
||||
if (topicsMap.has(t.id)) {
|
||||
const existing = topicsMap.get(t.id);
|
||||
topicsMap.set(t.id, {
|
||||
const merged = {
|
||||
...existing,
|
||||
...t,
|
||||
description: t.description || existing.description,
|
||||
});
|
||||
};
|
||||
if (existing.relevance_locked) {
|
||||
merged.learning_relevance = existing.learning_relevance;
|
||||
merged.relevance_locked = true;
|
||||
}
|
||||
topicsMap.set(t.id, merged);
|
||||
} else {
|
||||
topicsMap.set(t.id, t);
|
||||
}
|
||||
|
||||
@@ -154,6 +154,28 @@ export async function deleteCachedContent(topicId) {
|
||||
return db.deleteContent(topicId);
|
||||
}
|
||||
|
||||
function slugify(label) {
|
||||
const base = String(label || '')
|
||||
.toLowerCase()
|
||||
.normalize('NFKD')
|
||||
.replace(/\p{Diacritic}/gu, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
return base || 'topic';
|
||||
}
|
||||
|
||||
async function pickUniqueTopicId(label) {
|
||||
const existing = await db.getTopics();
|
||||
const used = new Set(existing.map((t) => t.id));
|
||||
const base = slugify(label);
|
||||
if (!used.has(base)) return base;
|
||||
for (let i = 2; i < 1000; i++) {
|
||||
const candidate = `${base}-${i}`;
|
||||
if (!used.has(candidate)) return candidate;
|
||||
}
|
||||
return `${base}-${Date.now().toString(36)}`;
|
||||
}
|
||||
|
||||
export async function generateCustomTopic(label) {
|
||||
const result = await callLLM({
|
||||
task: 'topic.custom',
|
||||
@@ -168,7 +190,12 @@ export async function generateCustomTopic(label) {
|
||||
const emitted = result.toolUses[0]?.input;
|
||||
if (!emitted) throw new Error('Could not process custom topic. Please try again.');
|
||||
|
||||
const newTopic = { ...emitted, id: 'custom_' + Date.now().toString(36) };
|
||||
const id = await pickUniqueTopicId(emitted.label);
|
||||
const newTopic = {
|
||||
...emitted,
|
||||
id,
|
||||
learning_relevance: emitted.learning_relevance || 'standard',
|
||||
};
|
||||
await db.upsertTopic(newTopic);
|
||||
return newTopic;
|
||||
}
|
||||
|
||||
@@ -272,6 +272,7 @@ function validateToolInputs(toolUses, task, toolSchemas) {
|
||||
* @property {number} [maxTokens=4096]
|
||||
* @property {number} [temperature=0]
|
||||
* @property {AbortSignal} [signal]
|
||||
* @property {{ acquire: (opts?:{signal?:AbortSignal}) => Promise<void>, pauseUntil: (untilMs:number) => void }} [limiter]
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -291,6 +292,7 @@ export async function callLLM(options) {
|
||||
maxTokens = 4096,
|
||||
temperature = 0,
|
||||
signal,
|
||||
limiter,
|
||||
} = options;
|
||||
if (!task) throw new Error('callLLM requires a `task` label.');
|
||||
|
||||
@@ -318,6 +320,7 @@ export async function callLLM(options) {
|
||||
try {
|
||||
result = await withRetry(
|
||||
async () => {
|
||||
if (limiter) await limiter.acquire({ signal });
|
||||
const timeoutCtl = signal ? null : new AbortController();
|
||||
const timer = timeoutCtl ? setTimeout(() => timeoutCtl.abort(new DOMException('Timeout', 'AbortError')), DEFAULT_TIMEOUT_MS) : null;
|
||||
const fetchSignal = linkSignals(signal, timeoutCtl?.signal);
|
||||
@@ -337,6 +340,9 @@ export async function callLLM(options) {
|
||||
const errBody = await response.json().catch(() => ({}));
|
||||
if (isRetryableStatus(response.status)) {
|
||||
const retryAfterMs = parseRetryAfter(response.headers.get('Retry-After'));
|
||||
if (response.status === 429 && retryAfterMs != null && limiter) {
|
||||
limiter.pauseUntil(Date.now() + retryAfterMs);
|
||||
}
|
||||
throw new RetryableError(response.status, retryAfterMs, `HTTP ${response.status}`);
|
||||
}
|
||||
throw new LLMHttpError(response.status, response.statusText, errBody);
|
||||
|
||||
@@ -62,6 +62,109 @@ function sleep(ms, signal) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Token-bucket rate limiter shared by callers that hit the same upstream
|
||||
* quota (e.g. handbook extraction loops). Replaces the static
|
||||
* `setTimeout(..., 12000)` / 15s sleeps that Phase 1 relied on. The bucket
|
||||
* refills continuously; `acquire` resolves either immediately (token
|
||||
* available) or after the next refill tick.
|
||||
*
|
||||
* `rps` is "requests per second" (use fractional values for per-minute
|
||||
* limits: `5/60` for 5 req/min). `burst` is the maximum number of tokens
|
||||
* the bucket can hold; default 1 means strict spacing.
|
||||
*
|
||||
* Call `pauseUntil(timestampMs)` after a 429 with a `Retry-After` hint —
|
||||
* no acquire returns before that timestamp.
|
||||
*
|
||||
* @param {{ rps?: number, burst?: number }} [opts]
|
||||
*/
|
||||
export function createLimiter({ rps = 1, burst = 1 } = {}) {
|
||||
if (rps <= 0) throw new Error('createLimiter: rps must be > 0');
|
||||
if (burst < 1) throw new Error('createLimiter: burst must be >= 1');
|
||||
const intervalMs = 1000 / rps;
|
||||
let tokens = burst;
|
||||
let lastRefill = Date.now();
|
||||
let pausedUntil = 0;
|
||||
const waiters = [];
|
||||
|
||||
function refill(now) {
|
||||
const elapsed = now - lastRefill;
|
||||
if (elapsed <= 0) return;
|
||||
const earned = elapsed / intervalMs;
|
||||
if (earned >= 1) {
|
||||
tokens = Math.min(burst, tokens + Math.floor(earned));
|
||||
lastRefill = now;
|
||||
}
|
||||
}
|
||||
|
||||
function drain() {
|
||||
while (waiters.length) {
|
||||
const now = Date.now();
|
||||
if (now < pausedUntil) {
|
||||
scheduleWake(pausedUntil - now);
|
||||
return;
|
||||
}
|
||||
refill(now);
|
||||
if (tokens >= 1) {
|
||||
tokens -= 1;
|
||||
const w = waiters.shift();
|
||||
w.resolve();
|
||||
} else {
|
||||
const wait = Math.max(intervalMs - (now - lastRefill), 0);
|
||||
scheduleWake(wait);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let wakeTimer = null;
|
||||
function scheduleWake(ms) {
|
||||
if (wakeTimer) return;
|
||||
wakeTimer = setTimeout(() => {
|
||||
wakeTimer = null;
|
||||
drain();
|
||||
}, ms);
|
||||
}
|
||||
|
||||
return {
|
||||
/** @param {{signal?:AbortSignal}} [opts] */
|
||||
async acquire({ signal } = {}) {
|
||||
if (signal?.aborted) throw signal.reason ?? new DOMException('Aborted', 'AbortError');
|
||||
return new Promise((resolve, reject) => {
|
||||
const entry = { resolve, reject };
|
||||
const onAbort = () => {
|
||||
const i = waiters.indexOf(entry);
|
||||
if (i !== -1) waiters.splice(i, 1);
|
||||
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
|
||||
};
|
||||
if (signal) signal.addEventListener('abort', onAbort, { once: true });
|
||||
const wrapped = {
|
||||
resolve: () => { if (signal) signal.removeEventListener('abort', onAbort); resolve(); },
|
||||
reject,
|
||||
};
|
||||
waiters.push(wrapped);
|
||||
drain();
|
||||
});
|
||||
},
|
||||
/** Block all `acquire`s until `untilMs` (epoch milliseconds). */
|
||||
pauseUntil(untilMs) {
|
||||
if (untilMs > pausedUntil) pausedUntil = untilMs;
|
||||
drain();
|
||||
},
|
||||
/** Inspect state — primarily for tests. */
|
||||
_state() {
|
||||
return { tokens, pausedUntil, waiters: waiters.length };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared limiter for the multi-call extraction loops (source chunks,
|
||||
* handbook file sync). 5 requests/minute matches the lowest published
|
||||
* Anthropic tier so we stay well clear of 429.
|
||||
*/
|
||||
export const extractionLimiter = createLimiter({ rps: 5 / 60, burst: 1 });
|
||||
|
||||
/**
|
||||
* Run `fn(attempt)` with retry. `fn` may throw a `RetryableError` to request
|
||||
* a retry, or any other error to fail immediately.
|
||||
|
||||
@@ -122,6 +122,8 @@ export const learningAllSchema = z.object({
|
||||
infographic: infographicBodySchema,
|
||||
});
|
||||
|
||||
const quizDifficultyEnum = z.enum(['easy', 'medium', 'hard']);
|
||||
|
||||
const quizQuestionSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
question: z.string().min(1),
|
||||
@@ -129,6 +131,7 @@ const quizQuestionSchema = z.object({
|
||||
options: z.array(z.string().min(1)).length(4),
|
||||
correctIndex: z.number().int().min(0).max(3),
|
||||
explanation: z.string().min(1),
|
||||
difficulty: quizDifficultyEnum,
|
||||
});
|
||||
|
||||
export const quizQuestionsSchema = z.object({
|
||||
|
||||
@@ -205,6 +205,8 @@ export const EMIT_CUSTOM_TOPIC_TOOL = {
|
||||
},
|
||||
};
|
||||
|
||||
const QUIZ_DIFFICULTIES = ['easy', 'medium', 'hard'];
|
||||
|
||||
const quizQuestionSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -214,8 +216,9 @@ const quizQuestionSchema = {
|
||||
options: { type: 'array', items: { type: 'string' }, minItems: 4, maxItems: 4 },
|
||||
correctIndex: { type: 'integer', minimum: 0, maximum: 3 },
|
||||
explanation: { type: 'string', description: 'Why the correct answer is correct (1–2 sentences).' },
|
||||
difficulty: { type: 'string', enum: QUIZ_DIFFICULTIES, description: 'Per-question difficulty tag.' },
|
||||
},
|
||||
required: ['id', 'question', 'topicLabel', 'options', 'correctIndex', 'explanation'],
|
||||
required: ['id', 'question', 'topicLabel', 'options', 'correctIndex', 'explanation', 'difficulty'],
|
||||
};
|
||||
|
||||
export const EMIT_QUIZ_QUESTIONS_TOOL = {
|
||||
|
||||
29
src/lib/random.js
Normal file
29
src/lib/random.js
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Shared randomness helpers.
|
||||
*
|
||||
* `Array.prototype.sort(() => 0.5 - Math.random())` is biased — modern V8
|
||||
* sorts use Timsort, which compares each element more than once and skews
|
||||
* the resulting permutation. Use `shuffle` for anything user-visible
|
||||
* (quiz options, review topic selection, leaderboards).
|
||||
*/
|
||||
|
||||
export function shuffle(arr) {
|
||||
const out = [...arr];
|
||||
for (let i = out.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[out[i], out[j]] = [out[j], out[i]];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function sample(arr, n) {
|
||||
if (n <= 0) return [];
|
||||
if (n >= arr.length) return shuffle(arr);
|
||||
return shuffle(arr).slice(0, n);
|
||||
}
|
||||
|
||||
export function pickInt(min, maxInclusive) {
|
||||
const lo = Math.ceil(min);
|
||||
const hi = Math.floor(maxInclusive);
|
||||
return lo + Math.floor(Math.random() * (hi - lo + 1));
|
||||
}
|
||||
@@ -2,81 +2,73 @@ import * as db from './db';
|
||||
import { callLLM } from './llm';
|
||||
import { EMIT_QUIZ_QUESTIONS_TOOL } from './llmTools';
|
||||
import { getCurriculumTopic, getQuarterForWeek } from './curriculumService';
|
||||
import { shuffle, sample } from './random';
|
||||
|
||||
const QUIZ_SYSTEM = `You are a quiz generator for Respellion, an internal IT company learning platform.
|
||||
You generate multiple-choice questions to test employee knowledge on specific topics.
|
||||
Always write in clear, professional English.
|
||||
|
||||
Emit questions through the emit_quiz_questions tool. Each question has exactly four options; correctIndex is 0-based; mix difficulty roughly 4 easy / 4 medium / 2 hard.`;
|
||||
Emit questions through the emit_quiz_questions tool. Each question has exactly four options; correctIndex is 0-based. Tag every question with difficulty ('easy', 'medium' or 'hard'). For a typical 5-question batch, mix difficulty roughly 2 easy / 2 medium / 1 hard; scale the ratio proportionally for larger batches.
|
||||
|
||||
Distribute correctIndex roughly evenly across 0, 1, 2, and 3. Do not place the correct answer at the same position more than 4 out of 10 times.
|
||||
|
||||
Never use filler options such as "all of the above", "none of the above", or "both A and B". Every explanation must be a substantive sentence (≥ 20 characters) describing why the correct answer is correct.`;
|
||||
|
||||
const BANNED_OPTION_PATTERNS = [
|
||||
/all of the above/i,
|
||||
/none of the above/i,
|
||||
/both a and b/i,
|
||||
/both b and c/i,
|
||||
/both c and d/i,
|
||||
/both a and c/i,
|
||||
/both b and d/i,
|
||||
/both a and d/i,
|
||||
];
|
||||
|
||||
const cachedSystem = (text) => [{ type: 'text', text, cache_control: { type: 'ephemeral' } }];
|
||||
|
||||
async function selectTestTopics(userId, weekNumber) {
|
||||
const allTopics = await db.getTopics();
|
||||
const topics = allTopics.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude');
|
||||
if (!topics || topics.length === 0) return { primaryTopic: null, reviewTopics: [], isReviewWeek: false };
|
||||
|
||||
// Try curriculum-based selection first
|
||||
try {
|
||||
const { topic, curriculumEntry } = await getCurriculumTopic(weekNumber);
|
||||
|
||||
if (curriculumEntry?.is_review_week) {
|
||||
// Review week: pull topics from the whole quarter
|
||||
const quarter = getQuarterForWeek(weekNumber);
|
||||
const curriculum = await db.getCurriculum(new Date().getFullYear());
|
||||
const quarterTopicIds = curriculum
|
||||
.filter(w => w.quarter === quarter && w.topic_id && !w.is_review_week)
|
||||
.map(w => w.topic_id);
|
||||
const quarterTopics = topics.filter(t => quarterTopicIds.includes(t.id));
|
||||
// Use all quarter topics as review topics (no single primary)
|
||||
return {
|
||||
primaryTopic: quarterTopics[0] || topics[0],
|
||||
reviewTopics: quarterTopics.slice(1),
|
||||
isReviewWeek: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (topic) {
|
||||
const others = topics.filter(t => t.id !== topic.id);
|
||||
const shuffled = others.sort(() => 0.5 - Math.random());
|
||||
const reviewTopics = shuffled.slice(0, Math.min(5, shuffled.length));
|
||||
return { primaryTopic: topic, reviewTopics, isReviewWeek: false };
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Test] Curriculum lookup failed, falling back to hash:', e.message);
|
||||
}
|
||||
|
||||
// Fallback: hash-based selection
|
||||
const str = `${userId}:${weekNumber}`;
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = (hash << 5) - hash + str.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
const primaryIndex = Math.abs(hash) % topics.length;
|
||||
const primaryTopic = topics[primaryIndex];
|
||||
|
||||
const others = topics.filter((_, i) => i !== primaryIndex);
|
||||
const shuffled = others.sort(() => 0.5 - Math.random());
|
||||
const reviewTopics = shuffled.slice(0, Math.min(5, shuffled.length));
|
||||
|
||||
return { primaryTopic, reviewTopics, isReviewWeek: false };
|
||||
function normalizeQuestionText(text) {
|
||||
return String(text || '')
|
||||
.toLowerCase()
|
||||
.replace(/[\p{P}\p{S}]/gu, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export async function getCachedQuiz(userId, weekNumber) {
|
||||
return db.getCachedQuiz(userId, weekNumber);
|
||||
function dominantCorrectIndex(questions) {
|
||||
if (!questions.length) return null;
|
||||
const counts = [0, 0, 0, 0];
|
||||
for (const q of questions) counts[q.correctIndex] = (counts[q.correctIndex] || 0) + 1;
|
||||
const max = Math.max(...counts);
|
||||
return max / questions.length > 0.5 ? { index: counts.indexOf(max), ratio: max / questions.length } : null;
|
||||
}
|
||||
|
||||
export async function forceGenerateTopicQuestions(topic, count = 10) {
|
||||
let bank = await db.getQuizBank(topic.id);
|
||||
function validateBatchQuality(questions) {
|
||||
for (const q of questions) {
|
||||
const distinct = new Set(q.options.map((o) => o.trim().toLowerCase()));
|
||||
if (distinct.size < 4) {
|
||||
return `Question "${q.question}" has duplicate options.`;
|
||||
}
|
||||
for (const opt of q.options) {
|
||||
if (BANNED_OPTION_PATTERNS.some((re) => re.test(opt))) {
|
||||
return `Question "${q.question}" uses a banned filler option ("${opt}").`;
|
||||
}
|
||||
}
|
||||
if (!q.explanation || q.explanation.trim().length < 20) {
|
||||
return `Question "${q.question}" has an explanation that is too short.`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function callQuizModel(topic, count) {
|
||||
const prompt = `Generate exactly ${count} multiple-choice quiz questions for this knowledge topic and emit them via the emit_quiz_questions tool:
|
||||
|
||||
Topic: ${topic.label}
|
||||
Type: ${topic.type}
|
||||
Description: ${topic.description}
|
||||
|
||||
Options must be prefixed "A) ", "B) ", "C) ", "D) ". Make questions specific and practical, not trivial.`;
|
||||
Options must be prefixed "A) ", "B) ", "C) ", "D) ". Make questions specific and practical, not trivial. Example: a question whose correct answer is option C uses "correctIndex": 2.`;
|
||||
|
||||
const result = await callLLM({
|
||||
task: 'quiz.generate',
|
||||
@@ -89,28 +81,125 @@ Options must be prefixed "A) ", "B) ", "C) ", "D) ". Make questions specific and
|
||||
});
|
||||
|
||||
const emitted = result.toolUses[0]?.input;
|
||||
if (!emitted) throw new Error(`Could not generate questions for ${topic.label}`);
|
||||
if (!emitted?.questions?.length) {
|
||||
throw new Error(`Could not generate questions for ${topic.label}`);
|
||||
}
|
||||
return emitted.questions;
|
||||
}
|
||||
|
||||
const newQuestions = (emitted.questions || []).map(q => ({
|
||||
...q,
|
||||
id: `${topic.id}-${Math.random().toString(36).slice(2, 11)}`,
|
||||
}));
|
||||
async function selectTestTopics(userId, weekNumber) {
|
||||
const allTopics = await db.getTopics();
|
||||
const topics = allTopics.filter(t => t.type !== 'fact' && t.learning_relevance !== 'exclude');
|
||||
if (!topics || topics.length === 0) return { primaryTopic: null, reviewTopics: [], isReviewWeek: false };
|
||||
|
||||
bank = [...bank, ...newQuestions];
|
||||
await db.setQuizBank(topic.id, bank);
|
||||
return newQuestions;
|
||||
try {
|
||||
const { topic, curriculumEntry } = await getCurriculumTopic(weekNumber);
|
||||
|
||||
if (curriculumEntry?.is_review_week) {
|
||||
const quarter = getQuarterForWeek(weekNumber);
|
||||
const curriculum = await db.getCurriculum(new Date().getFullYear());
|
||||
const quarterTopicIds = curriculum
|
||||
.filter(w => w.quarter === quarter && w.topic_id && !w.is_review_week)
|
||||
.map(w => w.topic_id);
|
||||
const quarterTopics = topics.filter(t => quarterTopicIds.includes(t.id));
|
||||
return {
|
||||
primaryTopic: quarterTopics[0] || topics[0],
|
||||
reviewTopics: quarterTopics.slice(1),
|
||||
isReviewWeek: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (topic) {
|
||||
const others = topics.filter(t => t.id !== topic.id);
|
||||
const reviewTopics = sample(others, Math.min(5, others.length));
|
||||
return { primaryTopic: topic, reviewTopics, isReviewWeek: false };
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Test] Curriculum lookup failed, falling back to hash:', e.message);
|
||||
}
|
||||
|
||||
const str = `${userId}:${weekNumber}`;
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = (hash << 5) - hash + str.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
const primaryIndex = Math.abs(hash) % topics.length;
|
||||
const primaryTopic = topics[primaryIndex];
|
||||
|
||||
const others = topics.filter((_, i) => i !== primaryIndex);
|
||||
const reviewTopics = sample(others, Math.min(5, others.length));
|
||||
|
||||
return { primaryTopic, reviewTopics, isReviewWeek: false };
|
||||
}
|
||||
|
||||
export async function getCachedQuiz(userId, weekNumber) {
|
||||
return db.getCachedQuiz(userId, weekNumber);
|
||||
}
|
||||
|
||||
export async function forceGenerateTopicQuestions(topic, count = 5) {
|
||||
const existingBank = await db.getQuizBank(topic.id);
|
||||
const existingKeys = new Set(existingBank.map((q) => normalizeQuestionText(q.question)));
|
||||
|
||||
let lastQualityError = null;
|
||||
let candidates = null;
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const questions = await callQuizModel(topic, count);
|
||||
|
||||
const qualityError = validateBatchQuality(questions);
|
||||
if (qualityError) {
|
||||
lastQualityError = qualityError;
|
||||
console.warn(`[quiz] batch rejected (attempt ${attempt + 1}): ${qualityError}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const dominant = dominantCorrectIndex(questions);
|
||||
if (dominant && attempt < 2) {
|
||||
console.warn(`[quiz] correctIndex dominated by ${dominant.index} (${Math.round(dominant.ratio * 100)}%) — re-rolling`);
|
||||
continue;
|
||||
}
|
||||
|
||||
candidates = questions;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!candidates) {
|
||||
throw new Error(`Quality gate rejected the generated batch for ${topic.label}: ${lastQualityError || 'unbalanced answer distribution'}. Click "Generate" to try again.`);
|
||||
}
|
||||
|
||||
const accepted = [];
|
||||
for (const q of candidates) {
|
||||
const key = normalizeQuestionText(q.question);
|
||||
if (existingKeys.has(key)) {
|
||||
console.debug('[quiz] dropped duplicate:', q.question);
|
||||
continue;
|
||||
}
|
||||
existingKeys.add(key);
|
||||
accepted.push({
|
||||
...q,
|
||||
id: `${topic.id}-${Math.random().toString(36).slice(2, 11)}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!accepted.length) {
|
||||
throw new Error(`All generated questions for ${topic.label} were duplicates of existing ones.`);
|
||||
}
|
||||
|
||||
const merged = [...existingBank, ...accepted];
|
||||
await db.setQuizBank(topic.id, merged);
|
||||
return accepted;
|
||||
}
|
||||
|
||||
async function getOrGenerateTopicQuestions(topic, count) {
|
||||
let bank = await db.getQuizBank(topic.id);
|
||||
|
||||
if (bank.length < count) {
|
||||
await forceGenerateTopicQuestions(topic, 10);
|
||||
await forceGenerateTopicQuestions(topic, 5);
|
||||
bank = await db.getQuizBank(topic.id);
|
||||
}
|
||||
|
||||
const shuffled = [...bank].sort(() => 0.5 - Math.random());
|
||||
return shuffled.slice(0, Math.min(count, shuffled.length));
|
||||
return sample(bank, Math.min(count, bank.length));
|
||||
}
|
||||
|
||||
export async function getTopicQuestionBank(topicId) {
|
||||
@@ -151,10 +240,10 @@ export async function generateWeeklyQuiz(userId, weekNumber, force = false) {
|
||||
}
|
||||
}
|
||||
|
||||
questions.sort(() => 0.5 - Math.random());
|
||||
const shuffled = shuffle(questions);
|
||||
|
||||
const quiz = {
|
||||
questions,
|
||||
questions: shuffled,
|
||||
meta: {
|
||||
userId,
|
||||
weekNumber,
|
||||
|
||||
Reference in New Issue
Block a user