Q24: knowledge enters the run: search and record tools, relevant entries in prompts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0fea8a9159
commit
95e607bea7
12 files changed
+236
-31
No files matched your search
@@ -0,0 +1,103 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { optionalEnum, optionalLimit, requireString } from "./agent-tool-args";
|
||||
import type { AgentToolContext, AgentToolDeps } from "./agent-tools";
|
||||
import { KnowledgeService } from "./knowledge-service";
|
||||
import type { JsonRecord } from "./provider-http";
|
||||
import type { ToolDefinition } from "./provider-tool-turn";
|
||||
import { relevantItems } from "./relevance";
|
||||
|
||||
const PROMPT_ENTRY_LIMIT = 5;
|
||||
const PROMPT_ENTRY_CHARS = 600;
|
||||
const IMPORTANCE = ["low", "normal", "high"] as const;
|
||||
|
||||
interface KnowledgeRow {
|
||||
id: string;
|
||||
topic: string;
|
||||
category: string;
|
||||
content: string;
|
||||
importance: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export const KNOWLEDGE_TOOLS: ToolDefinition[] = [
|
||||
{
|
||||
name: "search_knowledge",
|
||||
description: "Search the company knowledge base — decisions, preferences, verified facts, and lessons recorded by the team.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
query: { type: "string", description: "Words to look for." },
|
||||
limit: { type: "number", description: "Most entries to return (default 10, max 30)." },
|
||||
},
|
||||
required: ["query"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "record_knowledge",
|
||||
description: "Record something the company should remember: a decision, a preference, a verified fact, or why something failed.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
topic: { type: "string", description: "Short title." },
|
||||
content: { type: "string", description: "The knowledge itself, self-contained." },
|
||||
category: { type: "string", description: "e.g. decision, preference, fact, lesson." },
|
||||
importance: { type: "string", enum: ["low", "normal", "high"] },
|
||||
},
|
||||
required: ["topic", "content", "category"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function searchKnowledgeTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string {
|
||||
const query = requireString(args.query, "query");
|
||||
const limit = optionalLimit(args.limit, "limit", 10, 30);
|
||||
const found = relevantItems(knowledgeRows(deps.db, ctx.orgId), describeEntry, query, limit);
|
||||
if (found.length === 0) return `No knowledge matches "${query}".`;
|
||||
return found.map(formatEntry).join("\n\n");
|
||||
}
|
||||
|
||||
export function recordKnowledgeTool(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord): string {
|
||||
const importance = optionalEnum(args.importance, "importance", IMPORTANCE, "normal");
|
||||
const id = new KnowledgeService(deps.db).create({
|
||||
orgId: ctx.orgId,
|
||||
topic: requireString(args.topic, "topic"),
|
||||
category: requireString(args.category, "category"),
|
||||
content: requireString(args.content, "content"),
|
||||
importance,
|
||||
sourceType: "agent",
|
||||
sourceId: ctx.agent.id,
|
||||
});
|
||||
return `Recorded knowledge entry ${id}.`;
|
||||
}
|
||||
|
||||
/** Prompt lines for the entries most relevant to a task. */
|
||||
export function relevantKnowledgeLines(db: DatabaseSync, orgId: string, taskText: string): string[] {
|
||||
const found = relevantItems(knowledgeRows(db, orgId), describeEntry, taskText, PROMPT_ENTRY_LIMIT);
|
||||
if (found.length === 0) return [];
|
||||
return [
|
||||
"",
|
||||
"Company knowledge that may apply (search_knowledge for more):",
|
||||
...found.map((entry) => `- ${entry.topic} [${entry.category}, ${entry.importance}, id=${entry.id}]: ${clip(entry.content)}`),
|
||||
];
|
||||
}
|
||||
|
||||
function knowledgeRows(db: DatabaseSync, orgId: string): KnowledgeRow[] {
|
||||
return db.prepare(`
|
||||
select id, topic, category, content, importance, updated_at from knowledge_entries
|
||||
where org_id = ? order by updated_at desc
|
||||
`).all(orgId) as unknown as KnowledgeRow[];
|
||||
}
|
||||
|
||||
function describeEntry(entry: KnowledgeRow): string {
|
||||
return `${entry.topic} ${entry.category} ${entry.content}`;
|
||||
}
|
||||
|
||||
function formatEntry(entry: KnowledgeRow): string {
|
||||
return `${entry.topic} [${entry.category}, ${entry.importance}, id=${entry.id}, updated ${entry.updated_at}]\n${entry.content}`;
|
||||
}
|
||||
|
||||
function clip(text: string): string {
|
||||
return text.length > PROMPT_ENTRY_CHARS ? `${text.slice(0, PROMPT_ENTRY_CHARS)}…` : text;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { attachmentNote } from "./agent-attachment-tools";
|
||||
import { optionalString } from "./agent-tool-args";
|
||||
import { optionalLimit, optionalString } from "./agent-tool-args";
|
||||
import type { AgentToolContext, AgentToolDeps } from "./agent-tools";
|
||||
import type { JsonRecord } from "./provider-http";
|
||||
|
||||
@@ -25,7 +25,7 @@ export function readMessagesTool(deps: AgentToolDeps, ctx: AgentToolContext, arg
|
||||
const filter: MessageFilter = {
|
||||
before: beforeCursor(deps, ctx.orgId, optionalString(args.before)),
|
||||
query: optionalString(args.query),
|
||||
limit: Math.min(Math.max(Number(args.limit) || 10, 1), 30),
|
||||
limit: optionalLimit(args.limit, "limit", 10, 30),
|
||||
};
|
||||
if (withTeammate) {
|
||||
const counterpart = deps.db.prepare(`
|
||||
|
||||
@@ -14,3 +14,19 @@ export function optionalStringArray(value: unknown, field: string): string[] {
|
||||
if (!Array.isArray(value)) throw new Error(`"${field}" must be an array.`);
|
||||
return value.map((item, index) => requireString(item, `${field}[${index}]`));
|
||||
}
|
||||
|
||||
/** Omitted means the default; anything supplied must be a whole number from 1 to max. */
|
||||
export function optionalLimit(value: unknown, field: string, fallback: number, max: number): number {
|
||||
if (value === undefined) return fallback;
|
||||
if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > max) {
|
||||
throw new Error(`"${field}" must be a whole number from 1 to ${max}.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Omitted means the default; anything supplied must be one of the allowed values. */
|
||||
export function optionalEnum(value: unknown, field: string, allowed: readonly string[], fallback: string): string {
|
||||
if (value === undefined) return fallback;
|
||||
if (typeof value !== "string" || !allowed.includes(value)) throw new Error(`"${field}" must be one of: ${allowed.join(", ")}.`);
|
||||
return value;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { bindSkillSlugs, grantSkillTool, listSkillsTool } from "./agent-skill-to
|
||||
import { createDocumentTool, writeDeliverableTool } from "./agent-deliverable-tools";
|
||||
import { companyStatusTool, GOAL_TOOLS, updateGoalTool } from "./agent-goal-tools";
|
||||
import { ATTACHMENT_TOOLS, readAttachmentTool } from "./agent-attachment-tools";
|
||||
import { KNOWLEDGE_TOOLS, recordKnowledgeTool, searchKnowledgeTool } from "./agent-knowledge-tools";
|
||||
import { sendMessageTool } from "./agent-send-message";
|
||||
import { withSavepoint } from "../database/connection";
|
||||
import { optionalString, optionalStringArray, requireString } from "./agent-tool-args";
|
||||
@@ -229,6 +230,7 @@ export const AGENT_TOOLS: ToolDefinition[] = [
|
||||
},
|
||||
...GOAL_TOOLS,
|
||||
...ATTACHMENT_TOOLS,
|
||||
...KNOWLEDGE_TOOLS,
|
||||
];
|
||||
|
||||
export function dispatchAgentTool(
|
||||
@@ -259,6 +261,10 @@ export function dispatchAgentTool(
|
||||
return grantConnectorTool(deps, ctx, call.arguments, "grant");
|
||||
case "revoke_connector":
|
||||
return grantConnectorTool(deps, ctx, call.arguments, "revoke");
|
||||
case "search_knowledge":
|
||||
return searchKnowledgeTool(deps, ctx, call.arguments);
|
||||
case "record_knowledge":
|
||||
return recordKnowledgeTool(deps, ctx, call.arguments);
|
||||
case "read_attachment":
|
||||
return readAttachmentTool(deps, ctx, call.arguments);
|
||||
case "company_status":
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
const LATIN_WORD = /[a-z0-9]{3,}/g;
|
||||
const CJK_RUN = /[\u3400-\u9fff]+/g;
|
||||
const STOP_WORDS = new Set(["the", "and", "for", "with", "that", "this", "from", "are", "was", "were", "you", "your", "our", "not", "but", "all", "any", "can", "has", "have", "had", "into", "its", "use", "will", "who", "how", "what", "when", "where", "which", "why", "out", "about", "than", "then", "them", "they", "their", "there", "these", "those", "been", "being", "each", "more", "most", "other", "some", "such", "only", "own", "same", "very", "just", "also"]);
|
||||
|
||||
/** Words (latin, 3+ chars) and character pairs (CJK) of a text. */
|
||||
function terms(text: string): Set<string> {
|
||||
const lower = text.toLowerCase();
|
||||
const found = new Set((lower.match(LATIN_WORD) ?? []).filter((word) => !STOP_WORDS.has(word)));
|
||||
for (const run of lower.match(CJK_RUN) ?? []) {
|
||||
for (let index = 0; index < run.length - 1; index += 1) found.add(run.slice(index, index + 2));
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function scored<T>(items: T[], describe: (item: T) => string, taskText: string) {
|
||||
const taskTerms = terms(taskText);
|
||||
return items
|
||||
.map((item, index) => {
|
||||
let score = 0;
|
||||
for (const term of terms(describe(item))) if (taskTerms.has(term)) score += 1;
|
||||
return { item, index, score };
|
||||
})
|
||||
.sort((a, b) => b.score - a.score || a.index - b.index);
|
||||
}
|
||||
|
||||
/** Orders items by how many terms they share with the task; ties keep their order. */
|
||||
export function orderByRelevance<T>(items: T[], describe: (item: T) => string, taskText: string): T[] {
|
||||
return scored(items, describe, taskText).map((entry) => entry.item);
|
||||
}
|
||||
|
||||
/** Only the items sharing at least one term with the text, best first. */
|
||||
export function relevantItems<T>(items: T[], describe: (item: T) => string, text: string, limit: number): T[] {
|
||||
return scored(items, describe, text).filter((entry) => entry.score > 0).slice(0, limit).map((entry) => entry.item);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
const LATIN_WORD = /[a-z0-9]{3,}/g;
|
||||
const CJK_RUN = /[\u3400-\u9fff]+/g;
|
||||
|
||||
/** Words (latin, 3+ chars) and character pairs (CJK) of a text. */
|
||||
function terms(text: string): Set<string> {
|
||||
const lower = text.toLowerCase();
|
||||
const found = new Set(lower.match(LATIN_WORD) ?? []);
|
||||
for (const run of lower.match(CJK_RUN) ?? []) {
|
||||
for (let index = 0; index < run.length - 1; index += 1) found.add(run.slice(index, index + 2));
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Orders items by how many terms they share with the task; ties keep their order. */
|
||||
export function orderByRelevance<T>(items: T[], describe: (item: T) => string, taskText: string): T[] {
|
||||
const taskTerms = terms(taskText);
|
||||
return items
|
||||
.map((item, index) => {
|
||||
let score = 0;
|
||||
for (const term of terms(describe(item))) if (taskTerms.has(term)) score += 1;
|
||||
return { item, index, score };
|
||||
})
|
||||
.sort((a, b) => b.score - a.score || a.index - b.index)
|
||||
.map((entry) => entry.item);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { AuditService } from "./audit-service";
|
||||
import { OperationsService } from "./operations-service";
|
||||
import { copySkillRevision, recursiveSkillChecksum } from "./skill-artifacts";
|
||||
import { loadSkillManifest, type SkillManifest } from "./skill-manifest";
|
||||
import { orderByRelevance } from "./skill-relevance";
|
||||
import { orderByRelevance } from "./relevance";
|
||||
import { runManifestArtifactTest, verifyRevisionStructure, type SkillArtifactRow, type SkillTestRow } from "./skill-tests";
|
||||
import { estimateTokens } from "./context-budget";
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type WorkspaceRow,
|
||||
} from "./task-prompts";
|
||||
import { renderTaskHandoff } from "./agent-task-handoff";
|
||||
import { relevantKnowledgeLines } from "./agent-knowledge-tools";
|
||||
import { createRunContextRecorder, createRunMessagePoller, loadChatWakeContext } from "./chat-wake-context";
|
||||
import { runningWorkspaceWriter } from "./workspace-write-lock";
|
||||
import { inputBudgetTokens, skillBudgetTokens } from "./context-budget";
|
||||
@@ -204,6 +205,7 @@ export class TaskExecutionService {
|
||||
managerName: this.agentName(orgId, agent.manager_agent_id),
|
||||
chatContext,
|
||||
handoffLines: renderTaskHandoff(this.db, orgId, task.handoff_json),
|
||||
knowledgeLines: relevantKnowledgeLines(this.db, orgId, `${task.title}\n${task.description}`),
|
||||
priorCompletedRuns: this.completedRunCount(task.id, runId),
|
||||
}),
|
||||
toolContext,
|
||||
|
||||
@@ -103,7 +103,7 @@ export function buildAgentInstructions(input: AgentInstructionContext): string {
|
||||
`You are ${agent.name}, ${agent.title || agent.role} in ${agent.department} at ${input.companyName}. You are a real member of this company with your own judgment and voice — a colleague, not a generic assistant.`,
|
||||
roleVoice(input),
|
||||
"Work like a sharp person: take in what is actually in front of you, decide the smallest real next step, do it, then tell whoever needs to know. Do not narrate busywork.",
|
||||
`Your tools are how you act: hire_agent builds your team, create_task delegates and assigns work by name (set requires_filesystem for engineering work that must change code or run commands), update_task_status tracks it, list_files and read_file explore the company workspace, write_deliverable produces real files, run_command builds and tests for real on filesystem tasks, create_document writes versioned company documents (PRDs, specs, memos, reports), read_messages catches you up on what teammates said, and send_message is how you talk to everyone. web_search and fetch_url reach the live web — look up APIs, docs, and facts instead of guessing them.`,
|
||||
`Your tools are how you act: hire_agent builds your team, create_task delegates and assigns work by name (set requires_filesystem for engineering work that must change code or run commands), update_task_status tracks it, list_files and read_file explore the company workspace, write_deliverable produces real files, run_command builds and tests for real on filesystem tasks, create_document writes versioned company documents (PRDs, specs, memos, reports), search_knowledge and record_knowledge read and grow what the company has learned, read_messages catches you up on what teammates said, and send_message is how you talk to everyone. web_search and fetch_url reach the live web — look up APIs, docs, and facts instead of guessing them.`,
|
||||
...(input.language === "zh"
|
||||
? ["公司的工作语言是简体中文:你写的每一条消息、任务标题与描述、文档和交付物都用中文表达(代码、命令、API 名称等技术标识保持原样)。工具名称和工具参数的字段名仍按定义使用英文。"]
|
||||
: []),
|
||||
@@ -171,6 +171,7 @@ export function buildTaskPrompt(input: {
|
||||
managerName: string | null;
|
||||
chatContext: ChatContextBlock | null;
|
||||
handoffLines: string[];
|
||||
knowledgeLines: string[];
|
||||
/** Completed runs this task already spent without reaching done. */
|
||||
priorCompletedRuns: number;
|
||||
}): string {
|
||||
@@ -203,6 +204,7 @@ export function buildTaskPrompt(input: {
|
||||
);
|
||||
}
|
||||
lines.push(...input.handoffLines);
|
||||
lines.push(...input.knowledgeLines);
|
||||
lines.push("", `Secret scope: ${input.isolation.secretScopeJson}`);
|
||||
if (input.skillRuntimeSnapshot) {
|
||||
lines.push(
|
||||
|
||||
@@ -7,6 +7,7 @@ import { OnboardingService } from "../../src/main/services/onboarding-service";
|
||||
import { ProviderService } from "../../src/main/services/provider-service";
|
||||
import { providerAdapters } from "../../src/main/services/provider-adapters";
|
||||
import { TaskExecutionService } from "../../src/main/services/task-execution-service";
|
||||
import { KnowledgeService } from "../../src/main/services/knowledge-service";
|
||||
import { OrganizationService } from "../../src/main/services/organization-service";
|
||||
import { claimNextQueuedRun } from "../../src/main/app/task-queue-worker";
|
||||
import { canonicalMentionHandle } from "../../src/shared/mention-handles";
|
||||
@@ -64,6 +65,8 @@ describe("task prompt scope", () => {
|
||||
await execution.executeRun(kickoff!.org_id, kickoff!.id);
|
||||
|
||||
const task = connection.db.prepare("select id from tasks where org_id = ? and title = 'Build the booking API'").get(orgId) as { id: string };
|
||||
new KnowledgeService(connection.db).create({ orgId, topic: "Booking storage", category: "decision", content: "Bookings live in Postgres.", importance: "high", sourceType: "employee", sourceId: null });
|
||||
new KnowledgeService(connection.db).create({ orgId, topic: "Offsite venue", category: "fact", content: "Lisbon in May.", importance: "low", sourceType: "employee", sourceId: null });
|
||||
await execution.runTask({ orgId, taskId: task.id });
|
||||
|
||||
const danaId = (connection.db.prepare("select id from agents where org_id = ? and name = 'Dana Lee'").get(orgId) as { id: string }).id;
|
||||
@@ -74,6 +77,8 @@ describe("task prompt scope", () => {
|
||||
expect(connection.db.prepare("select count(*) as n from task_runs where task_id = ? and state = 'running'").get(busyTaskId)).toEqual({ n: 1 });
|
||||
expect(danaPrompts).toHaveLength(1);
|
||||
expect(danaPrompts[0]).toContain("Build the booking API");
|
||||
expect(danaPrompts[0]).toContain("- Booking storage [decision, high");
|
||||
expect(danaPrompts[0]).not.toContain("Offsite venue");
|
||||
expect(danaPrompts[0]).not.toContain(CHANNEL_NOTE);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { DatabaseConnection } from "../../src/main/database/connection";
|
||||
import { OrganizationService } from "../../src/main/services/organization-service";
|
||||
import { ChatService } from "../../src/main/services/chat-service";
|
||||
import { dispatchAgentTool, type AgentToolContext } from "../../src/main/services/agent-tools";
|
||||
import { relevantKnowledgeLines } from "../../src/main/services/agent-knowledge-tools";
|
||||
|
||||
function harness() {
|
||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-knowledge-${randomUUID()}.db`));
|
||||
const organization = new OrganizationService(connection.db);
|
||||
let orgId = "";
|
||||
let agentId = "";
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({ organizationName: "K", employeeName: "Y", mission: "m", goal: "g" });
|
||||
agentId = organization.createAgent({ orgId, name: "Ana", role: "Engineer", department: "Engineering", modelProviderId: null, modelId: null, budgetCents: 1000, managerAgentId: null });
|
||||
});
|
||||
const ctx: AgentToolContext = {
|
||||
orgId, agent: { id: agentId, modelProviderId: null, modelId: null },
|
||||
taskProjectId: null, taskGoalId: null, taskId: null, sourceThreadId: null, workspace: null,
|
||||
};
|
||||
const run = (name: string, args: Record<string, unknown>) =>
|
||||
dispatchAgentTool({ db: connection.db, organization, chat: new ChatService(connection.db) }, ctx, { id: `c_${randomUUID()}`, name, arguments: args });
|
||||
return { connection, orgId, agentId, run };
|
||||
}
|
||||
|
||||
describe("knowledge in the execution chain", () => {
|
||||
test("agents record knowledge and find it again by search", () => {
|
||||
const h = harness();
|
||||
expect(h.run("record_knowledge", { topic: "Booking storage", content: "The booking API stores orders in Postgres, not MySQL.", category: "decision", importance: "high" }))
|
||||
.toMatch(/^Recorded knowledge entry kne_/);
|
||||
h.run("record_knowledge", { topic: "Brand colors", content: "Use teal and sand.", category: "preference" });
|
||||
const row = h.connection.db.prepare("select source_type, source_id, importance from knowledge_entries where topic = 'Booking storage'").get();
|
||||
expect(row).toEqual({ source_type: "agent", source_id: h.agentId, importance: "high" });
|
||||
|
||||
const found = h.run("search_knowledge", { query: "which database for bookings postgres" });
|
||||
expect(found).toContain("Booking storage [decision, high");
|
||||
expect(found).not.toContain("Brand colors");
|
||||
expect(h.run("search_knowledge", { query: "kubernetes" })).toBe('No knowledge matches "kubernetes".');
|
||||
expect(h.run("record_knowledge", { topic: "x", content: "y", category: "z", importance: "urgent" })).toBe('ERROR: "importance" must be one of: low, normal, high.');
|
||||
expect(h.run("record_knowledge", { topic: "x", content: "y", category: "z", importance: "" })).toBe('ERROR: "importance" must be one of: low, normal, high.');
|
||||
expect(h.run("search_knowledge", { query: "postgres", limit: "nonsense" })).toBe('ERROR: "limit" must be a whole number from 1 to 30.');
|
||||
expect(h.run("search_knowledge", { query: "postgres", limit: 0 })).toBe('ERROR: "limit" must be a whole number from 1 to 30.');
|
||||
expect(h.run("read_messages", { channel: "general", limit: 99 })).toBe('ERROR: "limit" must be a whole number from 1 to 30.');
|
||||
expect(h.connection.db.prepare("select count(*) as n from knowledge_entries where topic = 'x'").get()).toEqual({ n: 0 });
|
||||
h.connection.close();
|
||||
});
|
||||
|
||||
test("only knowledge relevant to the task enters its prompt", () => {
|
||||
const h = harness();
|
||||
h.run("record_knowledge", { topic: "Booking storage", content: "The booking API stores orders in Postgres.", category: "decision" });
|
||||
h.run("record_knowledge", { topic: "Brand colors", content: "Use teal and sand.", category: "preference" });
|
||||
const lines = relevantKnowledgeLines(h.connection.db, h.orgId, "Build the booking API").join("\n");
|
||||
expect(lines).toContain("Company knowledge that may apply (search_knowledge for more):");
|
||||
expect(lines).toContain("- Booking storage [decision, normal");
|
||||
expect(lines).not.toContain("Brand colors");
|
||||
expect(relevantKnowledgeLines(h.connection.db, h.orgId, "Plan the offsite")).toEqual([]);
|
||||
h.connection.close();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { orderByRelevance } from "../../src/main/services/skill-relevance";
|
||||
import { orderByRelevance } from "../../src/main/services/relevance";
|
||||
|
||||
describe("skill relevance", () => {
|
||||
describe("relevance", () => {
|
||||
test("ranks by shared words, keeps order on ties, and matches Chinese by character pairs", () => {
|
||||
const skills = ["Growth Hacker: viral campaigns", "Backend Architect: scalable system design", "财务分析:预算与现金流"];
|
||||
expect(orderByRelevance(skills, (item) => item, "Design a scalable backend system")).toEqual([skills[1], skills[0], skills[2]]);
|
||||
Reference in new issue
Block a user