refactor: remove handbook sync state and related functionality
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user