diff --git a/tools/v2/team/knowledge-base-suggestion/README.md b/tools/v2/team/knowledge-base-suggestion/README.md index 23aab8f25..b1fed203e 100644 --- a/tools/v2/team/knowledge-base-suggestion/README.md +++ b/tools/v2/team/knowledge-base-suggestion/README.md @@ -1,15 +1,129 @@ # Knowledge Base Suggestion -This folder is the isolated workspace for the Knowledge Base Suggestion tool. +Folder-local V2 team tool for suggesting internal knowledge base articles from a +support or team-mail context. + +This contribution implements the isolated core feature engine only. It does not +wire the tool into the main app, inbox, routing, database, wallet, Stellar +integration, or design system. ## Ownership Boundary All work for this tool must stay inside: -`text -.\tools\v2\team\knowledge-base-suggestion\ -` +``` +tools/v2/team/knowledge-base-suggestion/ +``` + +Do not wire this tool into the main app, routing, inbox architecture, wallet core, +Stellar integration, database schema, or design system unless a future integration +issue explicitly allows it. + +## Folder Structure + +``` +knowledge-base-suggestion/ + docs/ + contract.md API, states, fixtures, limitations + fixtures/ + knowledge-base-fixtures.json Synthetic article and request data + services/ + knowledge-base-suggestion.service.mjs + Pure validation, scoring, and state logic + tests/ + knowledge-base-suggestion.test.mjs Node built-in tests + index.mjs Folder-local public API + README.md + specs.md +``` + +## Setup + +No install step is required. The service and tests use Node built-ins only. + +Requirements: + +- Node 18 or later. +- Run commands from the repository root unless noted otherwise. + +## Running Tests + +From the repository root: + +``` +node --test tools/v2/team/knowledge-base-suggestion/tests/knowledge-base-suggestion.test.mjs +``` + +From this folder: + +``` +node --test tests/knowledge-base-suggestion.test.mjs +``` + +## Core API + +The folder-local API is exported from `index.mjs`: + +- `suggestKnowledgeBaseArticles(input, options)` returns a deterministic + `success` or `empty` state with ranked suggestions. +- `scoreKnowledgeBaseArticle(article, request)` returns scoring details for one + article and one validated request. +- `validateSuggestionRequest(input)` validates and sanitizes request input. +- `validateKnowledgeBaseArticle(article)` validates local article fixture shape. +- `createLoadingState(query)`, `createEmptyState(query)`, and + `createErrorState(error, query)` create UI-ready state objects. +- `createKnowledgeBaseSuggestionService(options)` creates a mock async service + around local fixtures for future UI work. + +## Input Shape + +```js +{ + query: "how do I reset my password", + threadId: "thread-support-001", + category: "account", + productArea: "login", + tags: ["password", "login"], + maxResults: 3 +} +``` + +## Output States + +The engine returns one of these state shapes: + +- `loading`: request has started, suggestions are empty, no error. +- `success`: ranked suggestions are available. +- `empty`: the request was valid, but no article passed the score threshold. +- `error`: validation or service failure details are available. + +See `docs/contract.md` for full input, output, loading, empty, success, and error +state details. + +## Fixtures + +`fixtures/knowledge-base-fixtures.json` contains: + +- Synthetic knowledge base articles. +- Synthetic request contexts. +- Expected top suggestions for deterministic tests. + +No fixture contains real users, production inbox content, credentials, wallet +values, secrets, or live network references. + +## Known Limitations + +- Ranking is deterministic keyword scoring, not semantic search or an LLM. +- The mock async service uses local memory only; it has no persistence layer. +- No live network calls, search index, analytics event, or production data source + is introduced. +- UI components and hooks are future issues. +- Main app integration is intentionally out of scope. -Do not wire this tool into the main app, routing, inbox architecture, wallet core, Stellar core, database schema, or existing design system unless a future integration issue explicitly allows it. +## Acceptance Checklist -See specs.md for the issue categories and contributor expectations. +- [x] Core logic is implemented without linking into the main app. +- [x] Inputs, outputs, loading states, and error states are documented. +- [x] Deterministic local fixtures are included. +- [x] No live network calls, secrets, or production data are introduced. +- [x] Files changed by this issue are limited to this tool folder. diff --git a/tools/v2/team/knowledge-base-suggestion/docs/contract.md b/tools/v2/team/knowledge-base-suggestion/docs/contract.md new file mode 100644 index 000000000..59ae14a7c --- /dev/null +++ b/tools/v2/team/knowledge-base-suggestion/docs/contract.md @@ -0,0 +1,155 @@ +# Knowledge Base Suggestion Contract + +This document describes the folder-local core engine for future UI and hook work. +It is intentionally isolated from the main app. + +## Public API + +Import from `tools/v2/team/knowledge-base-suggestion/index.mjs`: + +```js +import { + suggestKnowledgeBaseArticles, + createKnowledgeBaseSuggestionService, +} from "./index.mjs"; +``` + +## Request Input + +```js +{ + query: "Customer needs an invoice receipt", + threadId: "thread-billing-002", + category: "billing", + productArea: "billing", + tags: ["invoice"], + maxResults: 3 +} +``` + +Rules: + +- `query` is required and capped at 500 characters. +- `threadId` is optional and must contain only letters, numbers, `_`, or `-`. +- `category` is optional and must be one of the local allowlisted categories. +- `productArea` is optional and sanitized as text. +- `tags` are optional, capped, and sanitized. +- `maxResults` is capped at 10. + +## Article Fixture Input + +Local article fixtures include: + +- `id` +- `title` +- `summary` +- `body` +- `category` +- `status` +- `url` +- `productAreas` +- `tags` +- `keywords` +- `relatedQuestions` +- `updatedAt` + +Only `published` articles can be suggested. Draft and archived articles score as +zero. + +## Output States + +### Loading + +```js +{ + status: "loading", + isLoading: true, + error: null, + query: "Customer needs an invoice receipt", + suggestions: [], + totalArticlesEvaluated: 0 +} +``` + +### Success + +```js +{ + status: "success", + isLoading: false, + error: null, + query: "Customer needs an invoice receipt", + suggestions: [ + { + articleId: "kb-download-invoices", + title: "Download invoices and receipts", + summary: "Where billing admins can find invoices...", + category: "billing", + url: "/kb/billing/download-invoices", + score: 33, + confidence: "high", + matchedTerms: ["billing", "invoice", "receipt"], + reasons: ["Category match: billing."] + } + ], + totalArticlesEvaluated: 6 +} +``` + +### Empty + +```js +{ + status: "empty", + isLoading: false, + error: null, + query: "banana telescope garden question", + suggestions: [], + totalArticlesEvaluated: 6 +} +``` + +### Error + +```js +{ + status: "error", + isLoading: false, + error: { + message: "query is required", + field: "query" + }, + query: "", + suggestions: [], + totalArticlesEvaluated: 0 +} +``` + +## Scoring Model + +The engine uses deterministic keyword scoring: + +- Category match: +6 +- Product area match: +5 +- Title token match: +5 each +- Keyword match: +4 each +- Tag match: +4 each +- Related question match: +3 each +- Summary match: +2 each +- Body match: +1 each + +The same request and article list always produce the same order. + +## Loading and Error Behavior + +`createKnowledgeBaseSuggestionService()` wraps the pure suggestion engine in a +mock async service for future UI testing. It can simulate latency or a failure +without adding any network calls. + +## Known Limitations + +- This is not semantic search. +- It does not call an LLM or external search index. +- It does not read production inbox content. +- It does not persist analytics or suggestion history. +- It is not wired into any UI route yet. diff --git a/tools/v2/team/knowledge-base-suggestion/fixtures/knowledge-base-fixtures.json b/tools/v2/team/knowledge-base-suggestion/fixtures/knowledge-base-fixtures.json new file mode 100644 index 000000000..d1cd4cf5f --- /dev/null +++ b/tools/v2/team/knowledge-base-suggestion/fixtures/knowledge-base-fixtures.json @@ -0,0 +1,162 @@ +{ + "metadata": { + "tool": "knowledge-base-suggestion", + "fixtureVersion": 1, + "scope": "folder-local", + "dataPolicy": [ + "Synthetic article and request examples only.", + "No production inbox data, real users, credentials, wallet values, or secrets.", + "No live network references are required to run tests." + ] + }, + "articles": [ + { + "id": "kb-password-reset", + "title": "Reset a team member password", + "summary": "Steps for password reset, sign-in recovery, and login verification.", + "body": "Use the account recovery form, confirm the member identity, and send the password reset link. If multi-factor authentication is enabled, ask the member to verify the new session.", + "category": "account", + "status": "published", + "url": "/kb/account/password-reset", + "productAreas": ["login", "accounts"], + "tags": ["password", "login", "account"], + "keywords": ["reset password", "forgot password", "sign in", "login recovery"], + "relatedQuestions": [ + "How do I reset my password?", + "A team member cannot sign in." + ], + "updatedAt": "2026-06-01" + }, + { + "id": "kb-download-invoices", + "title": "Download invoices and receipts", + "summary": "Where billing admins can find invoices, receipts, and payment history.", + "body": "Open billing settings, choose the invoices tab, then download the monthly invoice or receipt. Only billing admins can access invoice history.", + "category": "billing", + "status": "published", + "url": "/kb/billing/download-invoices", + "productAreas": ["billing"], + "tags": ["invoice", "receipt", "billing"], + "keywords": ["download invoice", "payment history", "billing admin", "receipt"], + "relatedQuestions": [ + "Where can I download an invoice?", + "How do I find a receipt?" + ], + "updatedAt": "2026-06-05" + }, + { + "id": "kb-recover-two-factor", + "title": "Recover two-factor authentication access", + "summary": "Recovery process when a team member loses an authenticator device.", + "body": "Verify identity with an admin, use backup codes when available, and reset the two-factor authentication requirement only after approval.", + "category": "security", + "status": "published", + "url": "/kb/security/recover-two-factor", + "productAreas": ["login", "security"], + "tags": ["2fa", "security", "authenticator"], + "keywords": ["two factor", "2fa", "backup codes", "lost phone", "authenticator"], + "relatedQuestions": [ + "What if I lost my authenticator?", + "How do backup codes work?" + ], + "updatedAt": "2026-06-08" + }, + { + "id": "kb-track-delivery", + "title": "Track delivery status for shipped items", + "summary": "How support teams explain shipment tracking, delivery delays, and carrier updates.", + "body": "Use the shipment page to confirm carrier status, tracking number, and delivery estimate. For delayed packages, open a delivery investigation.", + "category": "delivery", + "status": "published", + "url": "/kb/delivery/track-status", + "productAreas": ["shipping"], + "tags": ["shipment", "delivery", "tracking"], + "keywords": ["track shipment", "delivery delay", "tracking number", "carrier"], + "relatedQuestions": [ + "Where is my package?", + "Why is delivery delayed?" + ], + "updatedAt": "2026-05-28" + }, + { + "id": "kb-data-retention", + "title": "Customer data retention policy", + "summary": "Explains retention windows, deletion requests, and policy review boundaries.", + "body": "Support can explain standard retention windows and route deletion requests to the policy review queue. Do not promise deletion before policy review is complete.", + "category": "policy", + "status": "published", + "url": "/kb/policy/data-retention", + "productAreas": ["compliance"], + "tags": ["data", "retention", "deletion"], + "keywords": ["data retention", "delete data", "policy review", "retention window"], + "relatedQuestions": [ + "How long do you retain data?", + "Can a customer request deletion?" + ], + "updatedAt": "2026-05-20" + }, + { + "id": "kb-legacy-signin", + "title": "Legacy sign-in troubleshooting", + "summary": "Archived article for the old login system.", + "body": "This article is archived and should not be suggested for current login recovery workflows.", + "category": "account", + "status": "archived", + "url": "/kb/archive/legacy-signin", + "productAreas": ["login"], + "tags": ["login", "legacy"], + "keywords": ["old login", "legacy sign in"], + "relatedQuestions": ["How did legacy sign in work?"], + "updatedAt": "2025-12-01" + } + ], + "requests": [ + { + "id": "request-password", + "input": { + "query": "A team member forgot their password and cannot sign in", + "threadId": "thread-support-001", + "category": "account", + "productArea": "login", + "tags": ["password", "login"], + "maxResults": 3 + }, + "expectedTopArticleId": "kb-password-reset" + }, + { + "id": "request-billing", + "input": { + "query": "Customer needs a receipt and invoice for payment history", + "threadId": "thread-billing-002", + "category": "billing", + "productArea": "billing", + "tags": ["invoice"], + "maxResults": 2 + }, + "expectedTopArticleId": "kb-download-invoices" + }, + { + "id": "request-security", + "input": { + "query": "User lost phone with authenticator and needs 2fa backup codes", + "threadId": "thread-security-003", + "category": "security", + "productArea": "security", + "tags": ["2fa"], + "maxResults": 2 + }, + "expectedTopArticleId": "kb-recover-two-factor" + }, + { + "id": "request-empty", + "input": { + "query": "banana telescope garden question", + "threadId": "thread-empty-004", + "category": "other", + "tags": ["unmatched"], + "maxResults": 3 + }, + "expectedTopArticleId": null + } + ] +} diff --git a/tools/v2/team/knowledge-base-suggestion/index.mjs b/tools/v2/team/knowledge-base-suggestion/index.mjs new file mode 100644 index 000000000..c6c70aa13 --- /dev/null +++ b/tools/v2/team/knowledge-base-suggestion/index.mjs @@ -0,0 +1,14 @@ +export { + KnowledgeBaseSuggestionError, + LIMITS, + sanitizeText, + tokenizeText, + validateSuggestionRequest, + validateKnowledgeBaseArticle, + scoreKnowledgeBaseArticle, + suggestKnowledgeBaseArticles, + createLoadingState, + createEmptyState, + createErrorState, + createKnowledgeBaseSuggestionService, +} from "./services/knowledge-base-suggestion.service.mjs"; diff --git a/tools/v2/team/knowledge-base-suggestion/services/knowledge-base-suggestion.service.mjs b/tools/v2/team/knowledge-base-suggestion/services/knowledge-base-suggestion.service.mjs new file mode 100644 index 000000000..4acf4c999 --- /dev/null +++ b/tools/v2/team/knowledge-base-suggestion/services/knowledge-base-suggestion.service.mjs @@ -0,0 +1,420 @@ +/** + * Knowledge Base Suggestion - Core Service + * + * Deterministic folder-local article suggestion logic. No network calls, + * secrets, production data, or main app integration. + */ + +export class KnowledgeBaseSuggestionError extends Error { + constructor(message, field = null) { + super(message); + this.name = "KnowledgeBaseSuggestionError"; + this.field = field; + } +} + +export const LIMITS = Object.freeze({ + MAX_QUERY_LENGTH: 500, + MAX_CONTEXT_LENGTH: 2000, + MAX_ARTICLES: 200, + MAX_TAGS: 12, + MAX_TAG_LENGTH: 64, + MAX_RESULTS: 10, + DEFAULT_RESULTS: 5, + DEFAULT_MIN_SCORE: 4, + ALLOWED_STATUSES: ["published", "draft", "archived"], + ALLOWED_CATEGORIES: [ + "account", + "billing", + "delivery", + "policy", + "product", + "security", + "technical", + "other", + ], +}); + +const ID_PATTERN = /^[a-zA-Z0-9_-]+$/; +const STOP_WORDS = new Set([ + "a", + "an", + "and", + "are", + "can", + "do", + "for", + "how", + "i", + "in", + "is", + "it", + "my", + "of", + "on", + "or", + "the", + "to", + "we", + "what", + "with", +]); + +export function sanitizeText(value, maxLength = LIMITS.MAX_CONTEXT_LENGTH) { + if (typeof value !== "string") return ""; + const sanitized = value.replace(/[\x00-\x1F\x7F]/g, " ").replace(/\s+/g, " ").trim(); + return sanitized.length > maxLength ? sanitized.slice(0, maxLength).trim() : sanitized; +} + +export function tokenizeText(value) { + const sanitized = sanitizeText(value).toLowerCase(); + if (!sanitized) return []; + const tokens = sanitized + .split(/[^a-z0-9]+/g) + .map((token) => token.trim()) + .filter((token) => token.length > 1 && !STOP_WORDS.has(token)); + + return [...new Set(tokens)]; +} + +function validateId(value, field) { + const id = sanitizeText(value, 128); + if (!id) throw new KnowledgeBaseSuggestionError(`${field} is required`, field); + if (!ID_PATTERN.test(id)) { + throw new KnowledgeBaseSuggestionError(`${field} contains invalid characters`, field); + } + return id; +} + +function validateOptionalId(value, field) { + if (value === undefined || value === null || value === "") return null; + return validateId(value, field); +} + +function validateEnum(value, allowed, field, fallback = null) { + if (value === undefined || value === null || value === "") return fallback; + const sanitized = sanitizeText(value, 64).toLowerCase(); + if (!allowed.includes(sanitized)) { + throw new KnowledgeBaseSuggestionError( + `${field} must be one of: ${allowed.join(", ")}`, + field, + ); + } + return sanitized; +} + +function validateStringArray(value, field, { required = false } = {}) { + if (value === undefined || value === null) { + if (required) throw new KnowledgeBaseSuggestionError(`${field} is required`, field); + return []; + } + if (!Array.isArray(value)) { + throw new KnowledgeBaseSuggestionError(`${field} must be an array`, field); + } + if (value.length > LIMITS.MAX_TAGS) { + throw new KnowledgeBaseSuggestionError(`${field} exceeds ${LIMITS.MAX_TAGS} items`, field); + } + return value.map((entry, index) => { + const sanitized = sanitizeText(entry, LIMITS.MAX_TAG_LENGTH).toLowerCase(); + if (!sanitized) { + throw new KnowledgeBaseSuggestionError(`${field}[${index}] must be a string`, field); + } + return sanitized; + }); +} + +export function validateSuggestionRequest(input) { + if (input === null || typeof input !== "object" || Array.isArray(input)) { + throw new KnowledgeBaseSuggestionError("request must be a plain object", "request"); + } + + const query = sanitizeText(input.query, LIMITS.MAX_QUERY_LENGTH); + if (!query) throw new KnowledgeBaseSuggestionError("query is required", "query"); + + const maxResults = + input.maxResults === undefined + ? LIMITS.DEFAULT_RESULTS + : Number.parseInt(String(input.maxResults), 10); + if (!Number.isInteger(maxResults) || maxResults < 1 || maxResults > LIMITS.MAX_RESULTS) { + throw new KnowledgeBaseSuggestionError( + `maxResults must be between 1 and ${LIMITS.MAX_RESULTS}`, + "maxResults", + ); + } + + return { + query, + threadId: validateOptionalId(input.threadId, "threadId"), + category: validateEnum(input.category, LIMITS.ALLOWED_CATEGORIES, "category"), + productArea: sanitizeText(input.productArea, 80).toLowerCase() || null, + tags: validateStringArray(input.tags, "tags"), + context: sanitizeText(input.context, LIMITS.MAX_CONTEXT_LENGTH), + maxResults, + minScore: + input.minScore === undefined + ? LIMITS.DEFAULT_MIN_SCORE + : Number.parseInt(String(input.minScore), 10), + }; +} + +export function validateKnowledgeBaseArticle(article) { + if (article === null || typeof article !== "object" || Array.isArray(article)) { + throw new KnowledgeBaseSuggestionError("article must be a plain object", "article"); + } + + const id = validateId(article.id, "id"); + const title = sanitizeText(article.title, 180); + const summary = sanitizeText(article.summary, 500); + const body = sanitizeText(article.body, 4000); + if (!title) throw new KnowledgeBaseSuggestionError("title is required", "title"); + if (!summary) throw new KnowledgeBaseSuggestionError("summary is required", "summary"); + if (!body) throw new KnowledgeBaseSuggestionError("body is required", "body"); + + const category = validateEnum(article.category, LIMITS.ALLOWED_CATEGORIES, "category", "other"); + const status = validateEnum(article.status, LIMITS.ALLOWED_STATUSES, "status", "published"); + + return { + id, + title, + summary, + body, + category, + status, + url: sanitizeText(article.url, 300) || `kb://${id}`, + productAreas: validateStringArray(article.productAreas, "productAreas"), + tags: validateStringArray(article.tags, "tags"), + keywords: validateStringArray(article.keywords, "keywords"), + relatedQuestions: validateStringArray(article.relatedQuestions, "relatedQuestions"), + updatedAt: sanitizeText(article.updatedAt, 40) || null, + }; +} + +function countMatches(needles, haystack) { + let count = 0; + for (const needle of needles) { + if (haystack.has(needle)) count += 1; + } + return count; +} + +function uniqueMatchedTerms(...groups) { + return [...new Set(groups.flat().filter(Boolean))].sort(); +} + +function confidenceForScore(score) { + if (score >= 22) return "high"; + if (score >= 10) return "medium"; + return "low"; +} + +export function scoreKnowledgeBaseArticle(articleInput, requestInput) { + const article = validateKnowledgeBaseArticle(articleInput); + const request = validateSuggestionRequest(requestInput); + + if (article.status !== "published") { + return { + article, + score: 0, + confidence: "low", + matchedTerms: [], + reasons: ["Article is not published."], + }; + } + + const queryTokens = tokenizeText(`${request.query} ${request.context}`); + const titleTokens = new Set(tokenizeText(article.title)); + const summaryTokens = new Set(tokenizeText(article.summary)); + const bodyTokens = new Set(tokenizeText(article.body)); + const keywordTokens = new Set(article.keywords.flatMap(tokenizeText)); + const tagTokens = new Set(article.tags.flatMap(tokenizeText)); + const questionTokens = new Set(article.relatedQuestions.flatMap(tokenizeText)); + + const titleMatches = queryTokens.filter((token) => titleTokens.has(token)); + const summaryMatches = queryTokens.filter((token) => summaryTokens.has(token)); + const bodyMatches = queryTokens.filter((token) => bodyTokens.has(token)); + const keywordMatches = queryTokens.filter((token) => keywordTokens.has(token)); + const questionMatches = queryTokens.filter((token) => questionTokens.has(token)); + + const requestTags = request.tags.flatMap(tokenizeText); + const tagMatches = requestTags.filter((token) => tagTokens.has(token)); + const productMatches = request.productArea + ? article.productAreas.filter((area) => area === request.productArea) + : []; + + let score = 0; + const reasons = []; + + if (request.category && request.category === article.category) { + score += 6; + reasons.push(`Category match: ${article.category}.`); + } + if (productMatches.length > 0) { + score += 5; + reasons.push(`Product area match: ${productMatches.join(", ")}.`); + } + if (titleMatches.length > 0) { + score += titleMatches.length * 5; + reasons.push(`Title matches: ${titleMatches.join(", ")}.`); + } + if (keywordMatches.length > 0) { + score += keywordMatches.length * 4; + reasons.push(`Keyword matches: ${keywordMatches.join(", ")}.`); + } + if (tagMatches.length > 0) { + score += tagMatches.length * 4; + reasons.push(`Tag matches: ${tagMatches.join(", ")}.`); + } + if (questionMatches.length > 0) { + score += questionMatches.length * 3; + reasons.push(`Related question matches: ${questionMatches.join(", ")}.`); + } + if (summaryMatches.length > 0) { + score += summaryMatches.length * 2; + reasons.push(`Summary matches: ${summaryMatches.join(", ")}.`); + } + if (bodyMatches.length > 0) { + score += bodyMatches.length; + reasons.push(`Body matches: ${bodyMatches.join(", ")}.`); + } + + const matchedTerms = uniqueMatchedTerms( + titleMatches, + keywordMatches, + tagMatches, + questionMatches, + summaryMatches, + bodyMatches, + ); + + return { + article, + score, + confidence: confidenceForScore(score), + matchedTerms, + reasons: reasons.length > 0 ? reasons : ["No meaningful local match."], + }; +} + +export function createLoadingState(query = "") { + return { + status: "loading", + isLoading: true, + error: null, + query: sanitizeText(query, LIMITS.MAX_QUERY_LENGTH), + suggestions: [], + totalArticlesEvaluated: 0, + }; +} + +export function createEmptyState(query = "") { + return { + status: "empty", + isLoading: false, + error: null, + query: sanitizeText(query, LIMITS.MAX_QUERY_LENGTH), + suggestions: [], + totalArticlesEvaluated: 0, + }; +} + +export function createErrorState(error, query = "") { + return { + status: "error", + isLoading: false, + error: { + message: error instanceof Error ? error.message : String(error), + field: error instanceof KnowledgeBaseSuggestionError ? error.field : null, + }, + query: sanitizeText(query, LIMITS.MAX_QUERY_LENGTH), + suggestions: [], + totalArticlesEvaluated: 0, + }; +} + +export function suggestKnowledgeBaseArticles(input, options = {}) { + let request; + try { + request = validateSuggestionRequest(input); + } catch (error) { + return createErrorState(error, input?.query ?? ""); + } + + const articles = options.articles ?? []; + if (!Array.isArray(articles)) { + return createErrorState( + new KnowledgeBaseSuggestionError("articles must be an array", "articles"), + request.query, + ); + } + if (articles.length > LIMITS.MAX_ARTICLES) { + return createErrorState( + new KnowledgeBaseSuggestionError( + `articles exceeds ${LIMITS.MAX_ARTICLES} items`, + "articles", + ), + request.query, + ); + } + + const scored = []; + for (const article of articles) { + try { + const result = scoreKnowledgeBaseArticle(article, request); + if (result.score >= request.minScore) scored.push(result); + } catch (error) { + return createErrorState(error, request.query); + } + } + + scored.sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + return a.article.title.localeCompare(b.article.title); + }); + + const suggestions = scored.slice(0, request.maxResults).map((result) => ({ + articleId: result.article.id, + title: result.article.title, + summary: result.article.summary, + category: result.article.category, + url: result.article.url, + score: result.score, + confidence: result.confidence, + matchedTerms: result.matchedTerms, + reasons: result.reasons, + })); + + if (suggestions.length === 0) { + return { + ...createEmptyState(request.query), + totalArticlesEvaluated: articles.length, + }; + } + + return { + status: "success", + isLoading: false, + error: null, + query: request.query, + suggestions, + totalArticlesEvaluated: articles.length, + }; +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export function createKnowledgeBaseSuggestionService(options = {}) { + const articles = options.articles ?? []; + const latencyMs = Number.isFinite(options.latencyMs) ? options.latencyMs : 0; + const failure = options.failure ?? null; + + return { + createLoadingState, + async suggest(input) { + if (latencyMs > 0) await delay(latencyMs); + if (failure) return createErrorState(failure, input?.query ?? ""); + return suggestKnowledgeBaseArticles(input, { articles }); + }, + }; +} diff --git a/tools/v2/team/knowledge-base-suggestion/specs.md b/tools/v2/team/knowledge-base-suggestion/specs.md index 0029db972..3589c24a5 100644 --- a/tools/v2/team/knowledge-base-suggestion/specs.md +++ b/tools/v2/team/knowledge-base-suggestion/specs.md @@ -1,41 +1,61 @@ -# Knowledge Base Suggestion - -Suggest internal documentation. - -## Scope - -- Release tier: $(System.Collections.Hashtable.Tier.ToUpperInvariant()) -- Audience: $(System.Collections.Hashtable.Audience) -- Folder ownership: $dir/ +# Knowledge Base Suggestion Specs -This is a self-contained tooling workspace. Do not wire this tool into the main app, routing, inbox architecture, wallet core, Stellar core, or design system unless a future integration issue explicitly allows it. +## Purpose -Recommended internal structure: +Suggest internal knowledge base articles from a team-mail or support context +using deterministic folder-local logic. -- components/ -- services/ -- hooks/ -- ests/ -- docs/ - "@ | Set-Content -Path "tools/v2/team/knowledge-base-suggestion/README.md" - @" +The tool helps future UI work show likely articles for a message thread without +connecting to the main app, inbox, database, or external search provider. -# Knowledge Base Suggestion Specs +## Scope -## Purpose +- Release tier: V2 +- Audience: team +- Folder ownership: `tools/v2/team/knowledge-base-suggestion/` -Suggest internal documentation. +This is a self-contained tooling workspace. Do not wire this tool into the main +app, routing, inbox architecture, wallet core, Stellar integration, database +schema, or design system unless a future integration issue explicitly allows it. -## Contributor boundary +## Contributor Boundary All work for this tool should stay in: -$dir/ +``` +tools/v2/team/knowledge-base-suggestion/ +``` -## Required issue categories +## Required Issue Categories - Architecture - Feature - UI and accessibility - Security and performance - Testing and documentation + +## Internal Structure + +- `services/` for deterministic validation, scoring, ranking, and mock service + behavior. +- `fixtures/` for synthetic local article and request data. +- `tests/` for folder-local executable checks. +- `docs/` for contracts, setup, state shapes, and limitations. +- `components/` and `hooks/` can be added by future UI issues. + +## Review Contract + +The core engine should remain verifiable without the main application: + +- Request input is sanitized and validated before scoring. +- Article fixture shape is validated before ranking. +- Ranking is deterministic for the same request and article catalog. +- Loading, success, empty, and error state objects are documented and testable. +- The folder exports a small local API from `index.mjs` for future UI work. + +## Out of Scope + +- Main app routes, inbox wiring, dashboard layout, or design-system changes. +- Live search providers, LLM calls, network requests, secrets, or production data. +- Persistence, analytics, notification delivery, or authentication. +- Wallet, Stellar, payment, or database changes. diff --git a/tools/v2/team/knowledge-base-suggestion/tests/knowledge-base-suggestion.test.mjs b/tools/v2/team/knowledge-base-suggestion/tests/knowledge-base-suggestion.test.mjs new file mode 100644 index 000000000..712ab1449 --- /dev/null +++ b/tools/v2/team/knowledge-base-suggestion/tests/knowledge-base-suggestion.test.mjs @@ -0,0 +1,179 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { + KnowledgeBaseSuggestionError, + createEmptyState, + createErrorState, + createKnowledgeBaseSuggestionService, + createLoadingState, + scoreKnowledgeBaseArticle, + suggestKnowledgeBaseArticles, + tokenizeText, + validateKnowledgeBaseArticle, + validateSuggestionRequest, +} from "../index.mjs"; + +const __dir = dirname(fileURLToPath(import.meta.url)); +const fixturePath = join(__dir, "..", "fixtures", "knowledge-base-fixtures.json"); + +async function loadFixtures() { + return JSON.parse(await readFile(fixturePath, "utf8")); +} + +test("fixture metadata describes folder-local synthetic data", async () => { + const data = await loadFixtures(); + + assert.equal(data.metadata.tool, "knowledge-base-suggestion"); + assert.equal(data.metadata.scope, "folder-local"); + assert.ok(data.metadata.dataPolicy.some((entry) => entry.includes("Synthetic"))); +}); + +test("tokenizeText removes stop words and deduplicates terms", () => { + assert.deepEqual(tokenizeText("How do I reset reset my password?"), ["reset", "password"]); +}); + +test("validateSuggestionRequest sanitizes and normalizes optional fields", () => { + const request = validateSuggestionRequest({ + query: " Reset password\r\nplease ", + threadId: "thread-001", + category: "ACCOUNT", + productArea: " Login ", + tags: [" Password ", "Login"], + maxResults: "3", + }); + + assert.equal(request.query, "Reset password please"); + assert.equal(request.category, "account"); + assert.equal(request.productArea, "login"); + assert.deepEqual(request.tags, ["password", "login"]); + assert.equal(request.maxResults, 3); +}); + +test("validateSuggestionRequest returns useful error state for invalid input", () => { + assert.throws(() => validateSuggestionRequest({ query: "" }), KnowledgeBaseSuggestionError); + + const state = suggestKnowledgeBaseArticles({ query: "" }, { articles: [] }); + assert.equal(state.status, "error"); + assert.equal(state.error.field, "query"); +}); + +test("validateKnowledgeBaseArticle rejects unsafe article ids", async () => { + const { articles } = await loadFixtures(); + assert.throws( + () => validateKnowledgeBaseArticle({ ...articles[0], id: "../bad" }), + KnowledgeBaseSuggestionError, + ); +}); + +test("scoreKnowledgeBaseArticle gives strong matches high confidence", async () => { + const { articles, requests } = await loadFixtures(); + const request = requests.find((entry) => entry.id === "request-password").input; + const article = articles.find((entry) => entry.id === "kb-password-reset"); + + const score = scoreKnowledgeBaseArticle(article, request); + + assert.ok(score.score >= 20); + assert.equal(score.confidence, "high"); + assert.ok(score.matchedTerms.includes("password")); + assert.ok(score.reasons.some((reason) => reason.includes("Category match"))); +}); + +test("archived articles are never suggested", async () => { + const { articles } = await loadFixtures(); + const state = suggestKnowledgeBaseArticles( + { + query: "legacy sign in old login", + category: "account", + productArea: "login", + tags: ["legacy"], + maxResults: 5, + }, + { articles }, + ); + + assert.equal( + state.suggestions.some((suggestion) => suggestion.articleId === "kb-legacy-signin"), + false, + ); +}); + +test("fixture requests return their expected top article", async () => { + const { articles, requests } = await loadFixtures(); + + for (const request of requests.filter((entry) => entry.expectedTopArticleId)) { + const state = suggestKnowledgeBaseArticles(request.input, { articles }); + + assert.equal(state.status, "success", `${request.id} should return suggestions`); + assert.equal( + state.suggestions[0].articleId, + request.expectedTopArticleId, + `${request.id} should rank expected article first`, + ); + } +}); + +test("unmatched fixture request returns empty state", async () => { + const { articles, requests } = await loadFixtures(); + const request = requests.find((entry) => entry.id === "request-empty"); + const state = suggestKnowledgeBaseArticles(request.input, { articles }); + + assert.equal(state.status, "empty"); + assert.deepEqual(state.suggestions, []); + assert.equal(state.totalArticlesEvaluated, articles.length); +}); + +test("maxResults caps the number of returned suggestions", async () => { + const { articles } = await loadFixtures(); + const state = suggestKnowledgeBaseArticles( + { + query: "account login password security", + category: "account", + productArea: "login", + tags: ["login"], + maxResults: 1, + minScore: 1, + }, + { articles }, + ); + + assert.equal(state.status, "success"); + assert.equal(state.suggestions.length, 1); +}); + +test("state builders expose loading, empty, and error states", () => { + assert.equal(createLoadingState("invoice").status, "loading"); + assert.equal(createLoadingState("invoice").isLoading, true); + assert.equal(createEmptyState("invoice").status, "empty"); + + const errorState = createErrorState(new KnowledgeBaseSuggestionError("bad", "query"), "invoice"); + assert.equal(errorState.status, "error"); + assert.equal(errorState.error.field, "query"); +}); + +test("mock async service returns suggestions without network calls", async () => { + const { articles } = await loadFixtures(); + const service = createKnowledgeBaseSuggestionService({ articles, latencyMs: 1 }); + + const state = await service.suggest({ + query: "Need invoice receipt", + category: "billing", + tags: ["invoice"], + }); + + assert.equal(state.status, "success"); + assert.equal(state.suggestions[0].articleId, "kb-download-invoices"); +}); + +test("mock async service can simulate failure as an error state", async () => { + const service = createKnowledgeBaseSuggestionService({ + failure: new KnowledgeBaseSuggestionError("fixture failure", "service"), + }); + const state = await service.suggest({ query: "invoice" }); + + assert.equal(state.status, "error"); + assert.equal(state.error.field, "service"); +});