From ca8945ea5bb7c1f915b4802d69a40bf8fb1780b5 Mon Sep 17 00:00:00 2001 From: RaymondVerhoef Date: Fri, 22 May 2026 19:45:14 +0200 Subject: [PATCH] refactor: remove handbook sync state and related functionality --- .../1780000000_created_handbook_sync_state.js | 90 -------- src/components/admin/KnowledgeGraph.jsx | 124 ----------- src/components/admin/UploadZone.jsx | 197 ++++++++++++------ src/lib/__tests__/extractionPipeline.test.js | 21 +- src/lib/__tests__/llmSchemas.test.js | 24 --- src/lib/__tests__/llmTools.test.js | 2 - src/lib/db.js | 20 +- src/lib/extractionPipeline.js | 64 ++---- src/lib/githubService.js | 88 -------- src/lib/llmSchemas.js | 34 --- src/lib/llmTools.js | 36 ---- src/pages/Admin/index.jsx | 11 - 12 files changed, 159 insertions(+), 552 deletions(-) delete mode 100644 pb_migrations/1780000000_created_handbook_sync_state.js delete mode 100644 src/lib/githubService.js diff --git a/pb_migrations/1780000000_created_handbook_sync_state.js b/pb_migrations/1780000000_created_handbook_sync_state.js deleted file mode 100644 index 23f7d0e..0000000 --- a/pb_migrations/1780000000_created_handbook_sync_state.js +++ /dev/null @@ -1,90 +0,0 @@ -/// -migrate((app) => { - const collection = new Collection({ - "createRule": "", - "deleteRule": "", - "fields": [ - { - "autogeneratePattern": "[a-z0-9]{15}", - "help": "", - "hidden": false, - "id": "text3208210256", - "max": 15, - "min": 15, - "name": "id", - "pattern": "^[a-z0-9]+$", - "presentable": false, - "primaryKey": true, - "required": true, - "system": true, - "type": "text" - }, - { - "autogeneratePattern": "", - "help": "", - "hidden": false, - "id": "text_path123", - "max": 0, - "min": 0, - "name": "path", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": true, - "system": false, - "type": "text" - }, - { - "autogeneratePattern": "", - "help": "", - "hidden": false, - "id": "text_sha123", - "max": 0, - "min": 0, - "name": "sha", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": true, - "system": false, - "type": "text" - }, - { - "hidden": false, - "id": "autodate2990389176", - "name": "created", - "onCreate": true, - "onUpdate": false, - "presentable": false, - "system": false, - "type": "autodate" - }, - { - "hidden": false, - "id": "autodate3332085495", - "name": "updated", - "onCreate": true, - "onUpdate": true, - "presentable": false, - "system": false, - "type": "autodate" - } - ], - "id": "pbc_handbooksync123", - "indexes": [ - "CREATE UNIQUE INDEX `idx_handbook_path` ON `handbook_sync_state` (`path`)" - ], - "listRule": "", - "name": "handbook_sync_state", - "system": false, - "type": "base", - "updateRule": "", - "viewRule": "" - }); - - return app.save(collection); -}, (app) => { - const collection = app.findCollectionByNameOrId("pbc_handbooksync123"); - - return app.delete(collection); -}) diff --git a/src/components/admin/KnowledgeGraph.jsx b/src/components/admin/KnowledgeGraph.jsx index 7426f65..40db606 100644 --- a/src/components/admin/KnowledgeGraph.jsx +++ b/src/components/admin/KnowledgeGraph.jsx @@ -4,8 +4,6 @@ import { Trash2, Edit2, Save, X, RefreshCw, AlertCircle, Plus, Link as LinkIcon import * as db from '../../lib/db'; import { callLLM } from '../../lib/llm'; import { EMIT_GRAPH_ACTIONS_TOOL } from '../../lib/llmTools'; -import { analyzeHandbookDelta } from '../../lib/extractionPipeline'; -import { getRepoFolder, getFileContent } from '../../lib/githubService'; import Button from '../ui/Button'; import SuggestionsQueue from './SuggestionsQueue'; @@ -20,10 +18,6 @@ const KnowledgeGraph = () => { const [analyzeError, setAnalyzeError] = useState(null); const [newRelData, setNewRelData] = useState({ target: '', type: 'related_to' }); - const [isSyncing, setIsSyncing] = useState(false); - const [syncResult, setSyncResult] = useState(null); - const [syncError, setSyncError] = useState(null); - const [syncProgress, setSyncProgress] = useState(null); const [topics, setTopics] = useState([]); const [relations, setRelations] = useState([]); @@ -237,87 +231,6 @@ const KnowledgeGraph = () => { ))); }; - const syncHandbook = async () => { - setIsSyncing(true); - setSyncError(null); - setSyncResult(null); - - try { - const files = await getRepoFolder('respellion', 'employee-handbook', 'docs'); - const syncStates = await db.getHandbookSyncStates(); - - const added = []; - const modified = []; - const unchanged = []; - - files.forEach(file => { - const state = syncStates.find(s => s.path === file.path); - if (!state) { - added.push(file); - } else if (state.sha !== file.sha) { - modified.push(file); - } else { - unchanged.push(file); - } - }); - - setSyncResult({ added, modified, unchanged, failed: [] }); - - const filesToProcess = [...added, ...modified]; - if (filesToProcess.length > 0) { - const total = filesToProcess.length; - let done = 0; - const failed = []; - setSyncProgress(`Processing 0 of ${total} files...`); - - // Run files in parallel with bounded concurrency. The extractionLimiter - // inside analyzeHandbookDelta still governs the actual API request rate; - // parallelism here just hides GitHub fetch latency and overlaps with the - // limiter's spacing instead of waiting serially. - const CONCURRENCY = 4; - const queue = filesToProcess.slice(); - - async function worker() { - while (queue.length > 0) { - const file = queue.shift(); - if (!file) return; - try { - const rawContent = await getFileContent('respellion', 'employee-handbook', file.path); - // Skip near-empty files — they only burn an LLM call to extract nothing. - if (rawContent.trim().length < 50) { - await db.updateHandbookSyncState(file.path, file.sha); - } else { - await analyzeHandbookDelta(rawContent, file.path); - await db.updateHandbookSyncState(file.path, file.sha); - } - } catch (err) { - console.error('Failed to process file:', file.path, err); - failed.push({ path: file.path, message: err?.message || String(err) }); - } finally { - done++; - setSyncProgress(`Processing ${done} of ${total} files (${failed.length} failed)...`); - } - } - } - - await Promise.all(Array.from({ length: Math.min(CONCURRENCY, total) }, worker)); - - setSyncResult({ added, modified, unchanged, failed }); - setSyncProgress( - failed.length === 0 - ? 'Sync Complete! Click "Analyze & Optimize Graph" above to clean up and merge.' - : `Sync finished with ${failed.length} failure(s). See console for details.` - ); - reloadKb(); - } - - } catch (e) { - setSyncError(e.message || 'Failed to check GitHub for updates.'); - } finally { - setIsSyncing(false); - setTimeout(() => setSyncProgress(null), 10000); // Clear progress after 10s - } - }; const analyzeGraph = async () => { if (!confirm('This will send the entire graph to the AI to evaluate content, merge duplicates, and create new logical relations. This may take a moment. Proceed?')) return; @@ -466,43 +379,6 @@ ${JSON.stringify({ topics: compactTopics, relations: compactRelations })}`;

)} - -
- - {syncError && ( -

- - {syncError} -

- )} - {syncResult && ( -
-

GitHub Sync Status:

-

Added files: {syncResult.added.length}

-

Modified files: {syncResult.modified.length}

-

Unchanged: {syncResult.unchanged.length}

- {syncResult.failed?.length > 0 && ( -

Failed: {syncResult.failed.length}

- )} -
- )} - {syncProgress && ( -
-

- {syncProgress !== 'Sync Complete!' && } - {syncProgress} -

-
- )} -
{/* R42 chatbot suggestions queue */} diff --git a/src/components/admin/UploadZone.jsx b/src/components/admin/UploadZone.jsx index 63d4029..c6a3fb9 100644 --- a/src/components/admin/UploadZone.jsx +++ b/src/components/admin/UploadZone.jsx @@ -1,5 +1,5 @@ -import { useState, useRef } from 'react'; -import { UploadCloud, AlertCircle, CheckCircle, X } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import { UploadCloud, CheckCircle, AlertCircle, Loader2, X, Clock, Minus } from 'lucide-react'; import { processSourceText } from '../../lib/extractionPipeline'; import Card from '../ui/Card'; import Button from '../ui/Button'; @@ -7,122 +7,187 @@ import Button from '../ui/Button'; // ── Main Component ──────────────────────────────────────────────────────────── const UploadZone = ({ onUploadComplete }) => { - const [isDragging, setIsDragging] = useState(false); - const [isProcessing, setIsProcessing] = useState(false); - const [status, setStatus] = useState(null); + const [isDragging, setIsDragging] = useState(false); + const [queue, setQueue] = useState([]); + const [processingId, setProcessingId] = useState(null); + const [rejectedNote, setRejectedNote] = useState(null); const fileInputRef = useRef(null); const abortRef = useRef(null); - // ── File upload (drag & drop / browse) ──────────────────────────────────── + // ── Processing loop ──────────────────────────────────────────────────────── + + useEffect(() => { + if (processingId !== null) return; + const next = queue.find((q) => q.status === 'pending'); + if (!next) return; + + setProcessingId(next.id); + setQueue((q) => q.map((item) => item.id === next.id ? { ...item, status: 'processing' } : item)); - 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, { signal: controller.signal }); - setStatus({ type: 'success', msg: `Successfully processed: ${file.name}` }); - if (onUploadComplete) onUploadComplete(); - } catch (error) { - if (error?.name === 'AbortError') { - setStatus({ type: 'error', msg: `Cancelled: ${file.name}` }); + + next.file.text() + .then((text) => processSourceText(text, next.name, { signal: controller.signal })) + .then(() => { + setQueue((q) => q.map((item) => item.id === next.id ? { ...item, status: 'done' } : item)); + if (onUploadComplete) onUploadComplete(); + }) + .catch((err) => { + const isCancelled = err?.name === 'AbortError'; + setQueue((q) => q.map((item) => + item.id === next.id + ? { ...item, status: isCancelled ? 'cancelled' : 'failed', error: isCancelled ? null : err.message } + : item + )); + }) + .finally(() => { + abortRef.current = null; + setProcessingId(null); + }); + }, [processingId, queue, onUploadComplete]); + + // ── File ingestion ───────────────────────────────────────────────────────── + + const addFiles = (fileList) => { + const accepted = []; + let rejected = 0; + for (const file of fileList) { + if (file.type === 'text/plain' || file.name.endsWith('.md')) { + accepted.push({ id: crypto.randomUUID(), file, name: file.name, status: 'pending', error: null }); } else { - setStatus({ type: 'error', msg: `Error processing file: ${error.message}` }); + rejected++; } - } finally { - abortRef.current = null; - setIsProcessing(false); + } + if (accepted.length > 0) setQueue((q) => [...q, ...accepted]); + if (rejected > 0) { + setRejectedNote(`${rejected} file${rejected > 1 ? 's' : ''} skipped — only .txt and .md are supported.`); + setTimeout(() => setRejectedNote(null), 4000); } }; - const cancelProcessing = () => { + const cancelCurrent = () => { abortRef.current?.abort(new DOMException('Cancelled by user', 'AbortError')); }; + const clearDone = () => { + setQueue((q) => q.filter((item) => item.status === 'pending' || item.status === 'processing')); + }; + + // ── Drag & drop / browse ─────────────────────────────────────────────────── + const handleDragOver = (e) => { e.preventDefault(); setIsDragging(true); }; const handleDragLeave = () => setIsDragging(false); const handleDrop = (e) => { e.preventDefault(); setIsDragging(false); - if (e.dataTransfer.files?.length > 0) { - const file = e.dataTransfer.files[0]; - if (file.type === 'text/plain' || file.name.endsWith('.md')) { - processFile(file); - } else { - setStatus({ type: 'error', msg: 'Only .txt and .md files are currently supported.' }); - } - } + if (e.dataTransfer.files?.length > 0) addFiles(e.dataTransfer.files); }; const handleFileSelect = (e) => { - if (e.target.files?.length > 0) processFile(e.target.files[0]); + if (e.target.files?.length > 0) { + addFiles(e.target.files); + e.target.value = ''; + } }; + // ── Derived state ────────────────────────────────────────────────────────── + + const hasDone = queue.some((q) => ['done', 'failed', 'cancelled'].includes(q.status)); + // ── Render ───────────────────────────────────────────────────────────────── return ( -
+
- {/* ─── Drag & Drop Upload ─── */} + {/* ─── Drag & Drop Zone ─── */} fileInputRef.current?.click()} > - -

Drag files here

-

Supports .txt and .md (max 5MB)

- + +

Drag files here

+

Supports .txt and .md · max 5 MB each

+
- {/* Cancel sits outside the upload card so pointer-events-none doesn't disable it. */} - {isProcessing && ( -
- -
+ {/* ─── Rejected file note ─── */} + {rejectedNote && ( +

{rejectedNote}

)} - {/* ─── Status messages ─── */} - {status && ( -
- {status.type === 'error' - ? - : } -
-

{status.type === 'error' ? 'Error' : 'Success'}

-

{status.msg}

-
+ {/* ─── Queue ─── */} + {queue.length > 0 && ( +
+ {queue.map((item) => ( +
+ + {item.name} + + {item.status === 'processing' && 'Extracting…'} + {item.status === 'pending' && 'Pending'} + {item.status === 'done' && 'Done'} + {item.status === 'cancelled' && 'Cancelled'} + {item.status === 'failed' && ( + Failed + )} + + {item.status === 'processing' && ( + + )} +
+ ))} + + {hasDone && ( +
+ +
+ )}
)}
); }; +// ── Status icon ─────────────────────────────────────────────────────────────── + +const StatusIcon = ({ status }) => { + if (status === 'processing') return ; + if (status === 'done') return ; + if (status === 'failed') return ; + if (status === 'cancelled') return ; + return ; +}; + export default UploadZone; diff --git a/src/lib/__tests__/extractionPipeline.test.js b/src/lib/__tests__/extractionPipeline.test.js index 5efc45b..23759b6 100644 --- a/src/lib/__tests__/extractionPipeline.test.js +++ b/src/lib/__tests__/extractionPipeline.test.js @@ -69,18 +69,21 @@ describe('buildKnownIdsHint', () => { 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'); + it('formats topics as id: "label" pairs with a leading instruction', () => { + const hint = buildKnownIdsHint([ + { id: 'software-engineer', label: 'Software Engineer' }, + { id: 'onboarding-buddy', label: 'Onboarding Buddy' }, + ]); + expect(hint).toContain('Already-extracted topics'); + expect(hint).toContain('- software-engineer: "Software Engineer"'); + expect(hint).toContain('- onboarding-buddy: "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. + it('caps the hint at the 200 most recent topics', () => { + const topics = Array.from({ length: 250 }, (_, i) => ({ id: `topic-${i}`, label: `Topic ${i}` })); + const hint = buildKnownIdsHint(topics); + // The newest topics must appear; the oldest must not. expect(hint).toContain('topic-249'); expect(hint).toContain('topic-50'); expect(hint).not.toContain('topic-49'); diff --git a/src/lib/__tests__/llmSchemas.test.js b/src/lib/__tests__/llmSchemas.test.js index b036138..ae79799 100644 --- a/src/lib/__tests__/llmSchemas.test.js +++ b/src/lib/__tests__/llmSchemas.test.js @@ -1,8 +1,6 @@ import { describe, expect, it } from 'vitest'; import { extractionResultSchema, - handbookResultSchema, - normalizeHandbookResult, learningArticleSchema, learningSlidesSchema, learningInfographicSchema, @@ -60,28 +58,6 @@ describe('extractionResultSchema', () => { }); }); -describe('handbookResultSchema', () => { - it('accepts the loose vocabulary including executes', () => { - const parsed = handbookResultSchema.parse({ - topics: [{ ...sampleTopic, metadata: { source: 'github_handbook' } }], - relations: [{ source: 'software-engineer', target: 'code-review', type: 'executes' }], - }); - expect(parsed.relations[0].type).toBe('executes'); - }); - - it('normalises executes into executed_by with swapped source/target', () => { - const parsed = handbookResultSchema.parse({ - topics: [sampleTopic], - relations: [{ source: 'software-engineer', target: 'code-review', type: 'executes' }], - }); - const normalised = normalizeHandbookResult(parsed); - expect(normalised.relations[0]).toMatchObject({ - source: 'code-review', - target: 'software-engineer', - type: 'executed_by', - }); - }); -}); describe('learning schemas', () => { it('accepts an article payload', () => { diff --git a/src/lib/__tests__/llmTools.test.js b/src/lib/__tests__/llmTools.test.js index 2a3fe0c..d2715aa 100644 --- a/src/lib/__tests__/llmTools.test.js +++ b/src/lib/__tests__/llmTools.test.js @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest'; import { EMIT_KNOWLEDGE_GRAPH_TOOL, - EMIT_HANDBOOK_DELTA_TOOL, EMIT_LEARNING_ARTICLE_TOOL, EMIT_LEARNING_SLIDES_TOOL, EMIT_LEARNING_INFOGRAPHIC_TOOL, @@ -15,7 +14,6 @@ import { toolSchemaRegistry } from '../llmSchemas'; const allTools = [ EMIT_KNOWLEDGE_GRAPH_TOOL, - EMIT_HANDBOOK_DELTA_TOOL, EMIT_LEARNING_ARTICLE_TOOL, EMIT_LEARNING_SLIDES_TOOL, EMIT_LEARNING_INFOGRAPHIC_TOOL, diff --git a/src/lib/db.js b/src/lib/db.js index 53d8aec..7e81c05 100644 --- a/src/lib/db.js +++ b/src/lib/db.js @@ -349,11 +349,10 @@ export async function getRecentLlmCalls(limit = 100) { * * `team_members`, `settings`, and `llm_calls` are intentionally preserved. * - * @param {{ includeHandbookState?: boolean, includeProgress?: boolean }} [opts] + * @param {{ includeProgress?: boolean }} [opts] * @returns {Promise>} */ export async function resetForSmokeTest({ - includeHandbookState = true, includeProgress = false, } = {}) { const collections = [ @@ -366,7 +365,6 @@ export async function resetForSmokeTest({ 'sources', 'topics', ]; - if (includeHandbookState) collections.push('handbook_sync_state'); if (includeProgress) collections.push('learn_progress', 'leaderboard'); const results = []; @@ -388,20 +386,4 @@ export async function resetForSmokeTest({ return results; } -// ── Handbook Sync State ─────────────────────────────────────────────────────── - -export async function getHandbookSyncStates() { - try { - return await pb.collection('handbook_sync_state').getFullList(); - } catch { return []; } -} - -export async function updateHandbookSyncState(path, sha) { - try { - const r = await pb.collection('handbook_sync_state').getFirstListItem(`path="${path}"`); - return await pb.collection('handbook_sync_state').update(r.id, { sha }); - } catch { - return await pb.collection('handbook_sync_state').create({ path, sha }); - } -} diff --git a/src/lib/extractionPipeline.js b/src/lib/extractionPipeline.js index 0a4be2f..38afae1 100644 --- a/src/lib/extractionPipeline.js +++ b/src/lib/extractionPipeline.js @@ -1,24 +1,21 @@ 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'; +import { EMIT_KNOWLEDGE_GRAPH_TOOL } from './llmTools'; -const MAX_KNOWN_IDS_HINT = 200; +const MAX_KNOWN_TOPICS_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`. + * Build the "already-extracted topics" hint included in every chunk prompt. + * Passes both ID and label so the model can match concepts by name and reuse + * the exact ID + label rather than inventing near-duplicate variants. */ -export function buildKnownIdsHint(ids) { - if (!ids || !ids.length) return ''; - const recent = ids.slice(-MAX_KNOWN_IDS_HINT); +export function buildKnownIdsHint(topics) { + if (!topics || !topics.length) return ''; + const recent = topics.slice(-MAX_KNOWN_TOPICS_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}`), + 'Already-extracted topics (reuse their ID and label exactly if the same concept appears here):', + ...recent.map((t) => `- ${t.id}: "${t.label}"`), '', ].join('\n'); } @@ -45,41 +42,8 @@ Assign a learning_relevance to every topic: 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. 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. -`; - const cachedSystem = (text) => [{ type: 'text', text, cache_control: { type: 'ephemeral' } }]; -export async function analyzeHandbookDelta(fileContent, filePath, { signal } = {}) { - const result = await callLLM({ - task: 'extract.handbook', - tier: 'standard', - system: cachedSystem(HANDBOOK_SYSTEM_PROMPT), - user: `Analyze the following handbook file update (${filePath}):\n\n${fileContent}`, - tools: [EMIT_HANDBOOK_DELTA_TOOL], - toolChoice: { type: 'tool', name: EMIT_HANDBOOK_DELTA_TOOL.name }, - maxTokens: 8192, - timeoutMs: 180_000, - limiter: extractionLimiter, - signal, - }); - - const raw = result.toolUses[0]?.input; - if (!raw) throw new Error('Handbook extraction did not emit a tool result.'); - const extractedData = normalizeHandbookResult(raw); - - await mergeKnowledgeGraph(extractedData); - return { success: true, data: extractedData }; -} - /** * Sentence-aware chunker with overlap. * @@ -169,7 +133,7 @@ export async function processSourceText(textContent, sourceName, { signal } = {} console.log(`[Pipeline] Split "${sourceName}" into ${chunks.length} chunks for processing.`); const existingTopics = await db.getTopics(); - const knownIds = existingTopics.map((t) => t.id); + const knownTopics = existingTopics.map((t) => ({ id: t.id, label: t.label })); const allExtractedTopics = []; const allExtractedRelations = []; @@ -178,7 +142,7 @@ export async function processSourceText(textContent, sourceName, { signal } = {} 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 hint = buildKnownIdsHint(knownTopics); const result = await callLLM({ task: 'extract.source', tier: 'standard', @@ -197,7 +161,9 @@ export async function processSourceText(textContent, sourceName, { signal } = {} 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 (t?.id && !knownTopics.some((k) => k.id === t.id)) { + knownTopics.push({ id: t.id, label: t.label }); + } } } if (Array.isArray(extractedData.relations)) { diff --git a/src/lib/githubService.js b/src/lib/githubService.js deleted file mode 100644 index 9da13e9..0000000 --- a/src/lib/githubService.js +++ /dev/null @@ -1,88 +0,0 @@ -/** - * GitHub API Service - * Fetches files from a public GitHub repository directly from the browser. - * No proxy or token required for public repos. - */ - -const GITHUB_API = 'https://api.github.com'; -const GITHUB_RAW = 'https://raw.githubusercontent.com'; - -/** - * List markdown/text files in a GitHub repository folder. - * @param {string} owner - e.g. "respellion" - * @param {string} repo - e.g. "employee-handbook" - * @param {string} folder - e.g. "docs" or "" for root - * @param {string} branch - e.g. "main" - * @returns {Promise>} - */ -export async function getRepoFolder(owner, repo, folder = '', branch = 'main') { - // Use the GitHub Git Trees API to fetch all files recursively in one request - const url = `${GITHUB_API}/repos/${owner}/${repo}/git/trees/${branch}?recursive=1`; - - const res = await fetch(url, { - headers: { Accept: 'application/vnd.github+json' }, - }); - - if (!res.ok) { - const err = await res.json().catch(() => ({})); - throw new Error(`GitHub API ${res.status}: ${err.message || 'Could not list folder'}`); - } - - const data = await res.json(); - - // If folder is 'docs', we only want paths starting with 'docs/' - const folderPrefix = folder ? folder.replace(/^\/|\/$/g, '') + '/' : ''; - - // Return only files (blobs) that match the folder prefix and file extensions - return data.tree - .filter(item => - item.type === 'blob' && - item.path.startsWith(folderPrefix) && - (item.path.endsWith('.md') || item.path.endsWith('.txt')) - ) - .map(item => ({ - name: item.path.split('/').pop(), - path: item.path, - sha: item.sha - })); -} - -/** - * Fetch the raw text content of a file. - * Uses raw.githubusercontent.com — no rate limits for public content. - * @param {string} owner - * @param {string} repo - * @param {string} path - e.g. "docs/ROLES.md" - * @param {string} branch - * @returns {Promise} - */ -export async function getFileContent(owner, repo, path, branch = 'main') { - const url = `${GITHUB_RAW}/${owner}/${repo}/${branch}/${path}`; - const res = await fetch(url); - - if (!res.ok) { - throw new Error(`Could not fetch "${path}" from GitHub (${res.status})`); - } - - return res.text(); -} - -/** - * Parse a GitHub tree URL into its parts. - * e.g. "https://github.com/respellion/employee-handbook/tree/main/docs" - * → { owner: 'respellion', repo: 'employee-handbook', branch: 'main', folder: 'docs' } - */ -export function parseGitHubUrl(url) { - try { - const match = url.match(/github\.com\/([^/]+)\/([^/]+)(?:\/tree\/([^/]+)(?:\/(.+))?)?/); - if (!match) return null; - return { - owner: match[1], - repo: match[2], - branch: match[3] || 'main', - folder: match[4] || '', - }; - } catch { - return null; - } -} diff --git a/src/lib/llmSchemas.js b/src/lib/llmSchemas.js index e3f5932..359faad 100644 --- a/src/lib/llmSchemas.js +++ b/src/lib/llmSchemas.js @@ -9,7 +9,6 @@ import { z } from 'zod'; const topicTypeEnum = z.enum(['concept', 'role', 'process']); const relationTypeStrict = z.enum(['related_to', 'depends_on', 'part_of', 'executed_by']); -const relationTypeLoose = z.enum(['related_to', 'depends_on', 'part_of', 'executed_by', 'executes']); const learningRelevanceEnum = z.enum(['core', 'standard', 'peripheral', 'exclude']); const extractionTopicSchema = z.object({ @@ -31,38 +30,6 @@ export const extractionResultSchema = z.object({ relations: z.array(extractionRelationSchema), }); -const handbookTopicSchema = extractionTopicSchema.extend({ - metadata: z.object({ source: z.string() }).optional(), -}); - -const handbookRelationSchema = z.object({ - source: z.string().min(1), - target: z.string().min(1), - type: relationTypeLoose, - description: z.string().optional(), -}); - -export const handbookResultSchema = z.object({ - topics: z.array(handbookTopicSchema), - relations: z.array(handbookRelationSchema), -}); - -/** - * Normalise legacy `executes` relations into the canonical `executed_by` - * vocabulary by swapping source and target. The handbook prompt previously - * emitted `role → executes → process`; the canonical form is - * `process → executed_by → role`. - */ -export function normalizeHandbookResult(parsed) { - return { - ...parsed, - relations: parsed.relations.map((r) => - r.type === 'executes' - ? { ...r, type: 'executed_by', source: r.target, target: r.source } - : r, - ), - }; -} const articleSectionSchema = z.object({ heading: z.string().min(1), @@ -218,7 +185,6 @@ export const replaceTakeawaysPatchSchema = z.object({ */ export const toolSchemaRegistry = { emit_knowledge_graph: extractionResultSchema, - emit_handbook_delta: handbookResultSchema, emit_learning_article: learningArticleSchema, emit_learning_slides: learningSlidesSchema, emit_learning_infographic: learningInfographicSchema, diff --git a/src/lib/llmTools.js b/src/lib/llmTools.js index 000e9a5..b7f03db 100644 --- a/src/lib/llmTools.js +++ b/src/lib/llmTools.js @@ -10,7 +10,6 @@ const TOPIC_TYPES = ['concept', 'role', 'process']; const LEARNING_RELEVANCE = ['core', 'standard', 'peripheral', 'exclude']; const RELATION_TYPES_STRICT = ['related_to', 'depends_on', 'part_of', 'executed_by']; -const RELATION_TYPES_LOOSE = ['related_to', 'depends_on', 'part_of', 'executed_by', 'executes']; const extractionTopicSchema = { type: 'object', @@ -47,41 +46,6 @@ export const EMIT_KNOWLEDGE_GRAPH_TOOL = { }, }; -const handbookTopicSchema = { - type: 'object', - properties: { - ...extractionTopicSchema.properties, - metadata: { - type: 'object', - properties: { source: { type: 'string' } }, - }, - }, - required: extractionTopicSchema.required, -}; - -const handbookRelationSchema = { - type: 'object', - properties: { - source: { type: 'string' }, - target: { type: 'string' }, - type: { type: 'string', enum: RELATION_TYPES_LOOSE }, - description: { type: 'string' }, - }, - required: ['source', 'target', 'type'], -}; - -export const EMIT_HANDBOOK_DELTA_TOOL = { - name: 'emit_handbook_delta', - description: 'Return the topics and relations extracted from a handbook file update. Every process must have a role attached; every concept must connect to a process or role.', - input_schema: { - type: 'object', - properties: { - topics: { type: 'array', items: handbookTopicSchema }, - relations: { type: 'array', items: handbookRelationSchema }, - }, - required: ['topics', 'relations'], - }, -}; const articleSectionSchema = { type: 'object', diff --git a/src/pages/Admin/index.jsx b/src/pages/Admin/index.jsx index fbfcf5e..8ddb76a 100644 --- a/src/pages/Admin/index.jsx +++ b/src/pages/Admin/index.jsx @@ -31,7 +31,6 @@ const Admin = () => { const [saveStatus, setSaveStatus] = useState(null); const [resetConfirmText, setResetConfirmText] = useState(''); - const [resetIncludeHandbook, setResetIncludeHandbook] = useState(true); const [resetIncludeProgress, setResetIncludeProgress] = useState(false); const [isResetting, setIsResetting] = useState(false); const [resetReport, setResetReport] = useState(null); @@ -69,7 +68,6 @@ const Admin = () => { setResetReport(null); try { const report = await db.resetForSmokeTest({ - includeHandbookState: resetIncludeHandbook, includeProgress: resetIncludeProgress, }); setResetReport(report); @@ -317,15 +315,6 @@ const Admin = () => {
-