refactor: remove handbook sync state and related functionality
All checks were successful
On Push to Main / test (push) Successful in 31s
On Push to Main / publish (push) Successful in 1m4s
On Push to Main / deploy-dev (push) Successful in 1m31s

This commit is contained in:
RaymondVerhoef
2026-05-22 19:45:14 +02:00
parent dc628644b6
commit ca8945ea5b
12 changed files with 159 additions and 552 deletions

View File

@@ -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');

View File

@@ -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', () => {

View File

@@ -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,

View File

@@ -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<Array<{collection: string, deleted: number, error?: string}>>}
*/
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 });
}
}

View File

@@ -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)) {

View File

@@ -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<Array<{name, sha, path}>>}
*/
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<string>}
*/
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;
}
}

View File

@@ -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,

View File

@@ -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',