From 2879e5b1d85ff4f758f81bda27cc0c7ca81571b3 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 6 Oct 2026 03:58:35 +1100 Subject: [PATCH 1/3] feat(registry): understand search intents and repository refs The search box promises skills or repos, but a repo ref fell through to a semantic search and called its own Skills loose matches, and a sentence was searched word by word. A new internal /api/skills/search classifies the query first: a URL or owner/repo answers with that Repository and its Skills, @login with that owner, and a sentence passes through a small Workers AI model that turns it into search terms and soft boosts before the existing hybrid search. The model is cached per normalised query for a month, abandoned after 800ms, skipped for names, refs and URLs, and gated by the guest rate limiter. The frozen v1 skills.search answer is unchanged. --- .../registry/server/api/skills/search.get.ts | 28 ++ .../server/presenters/skill-box-search.ts | 72 ++++ .../registry/server/schemas/skills-query.ts | 9 + .../server/utils/search-intent-run.ts | 94 +++++ layers/registry/server/utils/search-intent.ts | 334 ++++++++++++++++++ .../registry/server/utils/skill-box-search.ts | 194 ++++++++++ .../server/utils/skill-search-cache.ts | 13 +- layers/registry/server/utils/skill-search.ts | 105 +++++- .../registry/server/utils/skills-registry.ts | 37 +- shared/skill-search-query.ts | 90 +++++ test/unit/search-intent.test.ts | 214 +++++++++++ test/unit/skill-search-fusion.test.ts | 35 ++ 12 files changed, 1203 insertions(+), 22 deletions(-) create mode 100644 layers/registry/server/api/skills/search.get.ts create mode 100644 layers/registry/server/presenters/skill-box-search.ts create mode 100644 layers/registry/server/utils/search-intent-run.ts create mode 100644 layers/registry/server/utils/search-intent.ts create mode 100644 layers/registry/server/utils/skill-box-search.ts create mode 100644 shared/skill-search-query.ts create mode 100644 test/unit/search-intent.test.ts diff --git a/layers/registry/server/api/skills/search.get.ts b/layers/registry/server/api/skills/search.get.ts new file mode 100644 index 00000000..e332938a --- /dev/null +++ b/layers/registry/server/api/skills/search.get.ts @@ -0,0 +1,28 @@ +import { defineApiHandler } from '#shared/server/handler' +import { normalizeSearchQuery } from '#shared/skill-search-query' +import { officialRepos } from '../../data/official-repos' +import { makeSkillBoxSearchPresenter } from '../../presenters/skill-box-search' +import { SkillBoxSearchQuery } from '../../schemas/skills-query' +import { searchSkillBox } from '../../utils/skill-box-search' +import { cachedSkillsSearch } from '../../utils/skill-search-cache' + +const officialOwners = new Set(officialRepos.map(r => r.owner)) + +/** + * The search box answer: Skills by intent, or one Repository and its Skills. + * + * Public read, so no policy. Internal, so it may change on any deploy; the + * frozen v1 `skills.search` answer is a separate route. The answer is cached + * by the normalised query for a minute, and query understanding keeps its + * own month-long cache, so a repeated sentence never pays for the model twice. + */ +export default defineApiHandler({ + schema: SkillBoxSearchQuery, + handler: ({ event, body, platform }) => cachedSkillsSearch( + event, + () => searchSkillBox(event, platform, { q: body.q, limit: body.limit, officialOwners }), + 'skills-box:v1', + JSON.stringify([normalizeSearchQuery(body.q), body.limit]), + ), + presenter: makeSkillBoxSearchPresenter(officialOwners), +}) diff --git a/layers/registry/server/presenters/skill-box-search.ts b/layers/registry/server/presenters/skill-box-search.ts new file mode 100644 index 00000000..8ddbf207 --- /dev/null +++ b/layers/registry/server/presenters/skill-box-search.ts @@ -0,0 +1,72 @@ +import type { QueryUnderstanding } from '../utils/search-intent' +import type { SkillBoxSearchResult } from '../utils/skill-box-search' +import type { AlternateSource, SearchMode } from '../utils/skill-search' +import type { RegistrySkill } from '../utils/skills-registry' + +/** One Skill row in the search box: what the row and its run chip draw. */ +export interface SkillBoxSearchItem { + name: string + owner: string + repo: string + slug: string + registryPath: string + description: string | null + stars: number + official: boolean + trustTier: string + modifiedAt: number | null + pushedAt: number | null + sourceCount: number + alternateSources: AlternateSource[] +} + +export type SkillBoxRepository + = | { _tag: 'indexed', owner: string, repo: string, stars: number, skillCount: number, registryPath: string } + | { _tag: 'not-indexed', owner: string, repo: string, url: string } + +/** + * The internal search box answer. Not part of the public API: the frozen + * `skills.search` v1 answer stays as it is. + */ +export interface SkillBoxSearchAnswer { + kind: SkillBoxSearchResult['kind'] + repository: SkillBoxRepository | null + owner: string | null + /** What query understanding read from a sentence, or null when it did not run. */ + understood: QueryUnderstanding | null + items: SkillBoxSearchItem[] + total: number + mode: SearchMode | null +} + +function presentItem(skill: RegistrySkill, officialOwners: Set): SkillBoxSearchItem { + return { + name: skill.name, + owner: skill.owner, + repo: skill.repo, + slug: skill.slug, + registryPath: skill.registryPath, + description: skill.description, + stars: skill.stars, + official: officialOwners.has(skill.owner), + trustTier: skill.trustTier, + modifiedAt: skill.modifiedAt, + pushedAt: skill.pushedAt, + sourceCount: skill.sourceCount ?? 1, + alternateSources: skill.alternateSources ?? [], + } +} + +export function makeSkillBoxSearchPresenter(officialOwners: Set) { + return (result: SkillBoxSearchResult): SkillBoxSearchAnswer => ({ + kind: result.kind, + repository: result.repository?._tag === 'indexed' + ? { ...result.repository, registryPath: `/gh/${result.repository.owner}/${result.repository.repo}` } + : result.repository, + owner: result.owner, + understood: result.intent?._tag === 'understood' ? result.intent.understanding : null, + items: result.items.map(skill => presentItem(skill, officialOwners)), + total: result.total, + mode: result.mode ?? null, + }) +} diff --git a/layers/registry/server/schemas/skills-query.ts b/layers/registry/server/schemas/skills-query.ts index 363badcf..0fc2d857 100644 --- a/layers/registry/server/schemas/skills-query.ts +++ b/layers/registry/server/schemas/skills-query.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { MAX_SEARCH_QUERY_LENGTH } from '#shared/skill-search-query' const flag = z.string().optional().transform(v => v === 'true' || v === '1') @@ -39,3 +40,11 @@ export const OfficialReposQuery = z.object({ }) export type SkillsListQuery = z.infer + +/** The search box. `q` is cut to the box's limit rather than rejected. */ +export const SkillBoxSearchQuery = z.object({ + q: z.string().default('').transform(value => value.slice(0, MAX_SEARCH_QUERY_LENGTH)), + limit: z.coerce.number().int().min(1).max(20).catch(7), +}) + +export type SkillBoxSearchQuery = z.infer diff --git a/layers/registry/server/utils/search-intent-run.ts b/layers/registry/server/utils/search-intent-run.ts new file mode 100644 index 00000000..e972bdc5 --- /dev/null +++ b/layers/registry/server/utils/search-intent-run.ts @@ -0,0 +1,94 @@ +import type { ReadThroughCache } from '#shared/server/cache' +import type { CachedIntent, IntentOutcome } from './search-intent' +import { readCache, writeCache } from '#shared/server/cache' +import { + decideIntent, + intentCacheKey, + parseCachedIntent, + raceBudget, + SEARCH_INTENT_BUDGET_MS, + SEARCH_INTENT_MAX_TOKENS, + SEARCH_INTENT_MODEL, + searchIntentResponseFormat, + searchIntentSystemPrompt, +} from './search-intent' + +/** An answer is a pure function of (model, prompt, query): keep it a month. */ +const UNDERSTOOD_TTL_SECONDS = 60 * 60 * 24 * 30 +/** A query the model could not parse is retried after a day, not on every keystroke. */ +const NONE_TTL_SECONDS = 60 * 60 * 24 + +export interface SearchIntentAi { + run: (model: string, input: Record) => Promise +} + +export interface SearchIntentDeps { + ai: SearchIntentAi | undefined + storage: ReadThroughCache + /** One model call against the visitor's allowance. False means over it. */ + allow: () => Promise + /** Keeps a promise alive after the response, for a late cache write. */ + schedule: (promise: Promise) => void + digest: (text: string) => Promise + sleep: (ms: number) => Promise + /** Aggregate outcome reporting. Receives no query text. */ + report: (outcome: IntentOutcome) => void + budgetMs?: number +} + +function store(deps: SearchIntentDeps, key: string, entry: CachedIntent): Promise { + return writeCache(deps.storage, key, entry, { + ttl: entry._tag === 'understood' ? UNDERSTOOD_TTL_SECONDS : NONE_TTL_SECONDS, + }) +} + +/** + * Understand one normalised intent query, inside the latency budget. + * + * Order matters for cost: the cache answers first, then the binding and the + * rate limit decide whether the model may run at all. A model reply that + * lands after the budget is still cached, so the next identical search gets + * it for free. + */ +export async function understandSearchQuery(deps: SearchIntentDeps, query: string): Promise { + const key = await intentCacheKey(query, deps.digest) + const cachedEntry = parseCachedIntent(await readCache(deps.storage, key)) + if (cachedEntry?._tag === 'understood') + return { _tag: 'understood', understanding: cachedEntry.understanding, source: 'cache' } + if (cachedEntry?._tag === 'none') + return { _tag: 'skipped', reason: 'cached-miss' } + + if (!deps.ai) + return { _tag: 'skipped', reason: 'binding-missing' } + if (!(await deps.allow())) + return { _tag: 'skipped', reason: 'rate-limited' } + + const model = deps.ai.run(SEARCH_INTENT_MODEL, { + messages: [ + { role: 'system', content: searchIntentSystemPrompt() }, + { role: 'user', content: query }, + ], + max_tokens: SEARCH_INTENT_MAX_TOKENS, + temperature: 0, + response_format: searchIntentResponseFormat(), + }) + const race = await raceBudget(model, deps.budgetMs ?? SEARCH_INTENT_BUDGET_MS, deps.sleep) + + if (race._tag === 'timeout') { + deps.schedule(model.then( + (response) => { + const late = decideIntent({ _tag: 'answered', response }, query) + return late.cache ? store(deps, key, late.cache) : undefined + }, + // The visitor already has the plain results. A model that fails after + // the budget leaves nothing to cache, and its timeout is reported below. + () => undefined, + )) + } + + const { outcome, cache } = decideIntent(race, query) + if (cache) + await store(deps, key, cache) + deps.report(outcome) + return outcome +} diff --git a/layers/registry/server/utils/search-intent.ts b/layers/registry/server/utils/search-intent.ts new file mode 100644 index 00000000..1a7ad023 --- /dev/null +++ b/layers/registry/server/utils/search-intent.ts @@ -0,0 +1,334 @@ +import { CLUSTERS } from '../data/clusters' + +/** + * Query understanding for natural-language searches. + * + * A small Workers AI model turns "make my UI less generic" into search terms + * and soft filters, and the existing hybrid search runs on them. Everything in + * this file is pure: the model call itself is injected, so the fallback rules + * are testable without a binding. + * + * The model never decides what a result is. It only rewrites the query. When + * it is slow, wrong, rate limited, or missing, search runs on the words the + * visitor typed. + */ + +/** + * Native Workers AI model, billed in neurons on the Workers AI allowance. + * Partner models (`anthropic/*`) bill against AI Gateway credits, which are + * exhausted (see send-digests.ts), so this must stay a `@cf/` model. + */ +export const SEARCH_INTENT_MODEL = '@cf/meta/llama-3.1-8b-instruct-fast' + +/** Bump when the prompt or the parser changes, so cached answers expire. */ +export const SEARCH_INTENT_PROMPT_VERSION = 1 + +/** After this, search runs on the typed words. The model call is abandoned. */ +export const SEARCH_INTENT_BUDGET_MS = 800 + +export const SEARCH_INTENT_MAX_TOKENS = 96 + +const MAX_TERMS = 6 +const MAX_TERMS_CHARS = 80 +const MAX_FILTER_CHARS = 40 + +/** Tracks that can boost a result: only those with classifier categories. */ +const BOOSTABLE_TRACKS = CLUSTERS.filter(cluster => cluster.categories.length > 0) +const TRACK_CATEGORIES = new Map(BOOSTABLE_TRACKS.map(cluster => [cluster.slug, cluster.categories])) + +/** What the model returns once parsed. Every field is optional evidence. */ +export interface QueryUnderstanding { + /** Search terms that a matching Skill's name or description would contain. */ + terms: string + /** A track slug with classifier categories, or null. */ + track: string | null + /** A library, language, or platform the query names, or null. */ + framework: string | null + /** A GitHub login the query names, or null. The server checks it exists. */ + author: string | null +} + +export function searchIntentSystemPrompt(): string { + const tracks = BOOSTABLE_TRACKS.map(cluster => `${cluster.slug}: ${cluster.label}`).join('\n') + return `You rewrite a developer's search for agent skills into search terms. +A skill is a SKILL.md file that teaches a coding agent one kind of work. + +Return JSON with four string fields: +- "terms": 2 to 6 lowercase keywords that the name or description of a matching skill would contain. Fix typos. Expand abbreviations, such as "pr" to "pull request". Drop filler words such as my, a, better, help, want, how. +- "track": the slug of the one track below that fits the task, or "none". +- "framework": the library, language, or platform the query names, in lowercase, or "none". +- "author": the GitHub login of a person or organization the query names, or "none". + +Tracks: +${tracks} + +Use only words from the query, their corrected spelling, or close synonyms. Keep the subject of the query and the outcome the developer wants, such as distinctive, faster, or secure. Never reduce a specific request to a generic topic. Never answer with generic words such as skill, agent, or coding. Never invent a library or an author. + +Examples: +${SEARCH_INTENT_EXAMPLES.map(([query, answer]) => `${query} => ${JSON.stringify(answer)}`).join('\n')}` +} + +/** + * Few-shot examples. None of them is a query from the evaluation set in the + * search pull request, so they teach the shape without grading themselves. + */ +const SEARCH_INTENT_EXAMPLES: [string, Record<'terms' | 'track' | 'framework' | 'author', string>][] = [ + ['check my code before i merge', { terms: 'code review', track: 'code-review', framework: 'none', author: 'none' }], + ['unit test a react component', { terms: 'react component unit testing', track: 'testing', framework: 'react', author: 'none' }], + ['make the landing page look less bland', { terms: 'distinctive landing page design', track: 'design', framework: 'none', author: 'none' }], + ['my docs read like a robot wrote them', { terms: 'natural human documentation writing', track: 'anti-slop', framework: 'none', author: 'none' }], + ['nxt image optimisation', { terms: 'nuxt image optimization', track: 'performance', framework: 'nuxt', author: 'none' }], + ['addyosmani web perf', { terms: 'web performance', track: 'performance', framework: 'none', author: 'addyosmani' }], +] + +/** JSON mode schema. String fields with "none", because JSON mode drops null unions. */ +export function searchIntentResponseFormat() { + return { + type: 'json_schema', + json_schema: { + type: 'object', + additionalProperties: false, + properties: { + terms: { type: 'string' }, + track: { type: 'string', enum: [...BOOSTABLE_TRACKS.map(cluster => cluster.slug), 'none'] }, + framework: { type: 'string' }, + author: { type: 'string' }, + }, + required: ['terms', 'track', 'framework', 'author'], + }, + } as const +} + +const TERM_CHARS_RE = /[^\p{L}\p{N}\s.#+/-]/gu +const WHITESPACE_RE = /\s+/g +const FILTER_RE = /^[a-z\d][a-z\d.+#-]*(?: [a-z\d][a-z\d.+#-]*)?$/ +const LOGIN_RE = /^[a-z\d][a-z\d-]{0,38}$/ +const NONE_VALUES = new Set(['', 'none', 'null', 'n/a', 'any']) + +function words(text: string): string[] { + return text.toLowerCase().match(/[\p{L}\p{N}.#+-]+/gu) ?? [] +} + +/** Levenshtein distance, capped: only "is this a typo of that" matters. */ +function editDistance(a: string, b: string, cap = 3): number { + if (Math.abs(a.length - b.length) >= cap) + return cap + let previous = Array.from({ length: b.length + 1 }, (_, i) => i) + for (let i = 1; i <= a.length; i++) { + const current = [i] + for (let j = 1; j <= b.length; j++) + current[j] = Math.min(previous[j]! + 1, current[j - 1]! + 1, previous[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1)) + previous = current + } + return Math.min(previous[b.length]!, cap) +} + +/** True when `word` is in the query or a typo of a query word. */ +function groundedInQuery(word: string, query: string): boolean { + const queryWords = words(query) + return queryWords.includes(word) || (word.length >= 4 && queryWords.some(candidate => candidate.length >= 4 && editDistance(candidate, word) <= 2)) +} + +/** + * The expansion words the query says or misspells. Only these may put a Skill + * in the name tier: "migration postgres" grounds both words in "write a + * postgres migration", while "ui design" grounds only "ui" in "make my UI + * less generic", so generic ui-design Skills do not jump the queue. + */ +export function groundedTerms(expansion: string, query: string): string | null { + const grounded = words(expansion).filter(word => groundedInQuery(word, query)) + return grounded.length ? grounded.join(' ') : null +} + +/** A framework counts only if the query says it, or misspells it. */ +function frameworkInQuery(framework: string, query: string): boolean { + if (query.includes(framework)) + return true + return words(query).some(word => word.length >= 4 && editDistance(word, framework) <= 2) +} + +/** An author counts only if the query spells the login, spaces aside. */ +function authorInQuery(author: string, query: string): boolean { + return query.replace(/[\s@-]/g, '').includes(author.replace(/-/g, '')) +} + +function filterValue(value: unknown): string | null { + if (typeof value !== 'string') + return null + const cleaned = value.trim().toLowerCase().replace(WHITESPACE_RE, ' ') + if (NONE_VALUES.has(cleaned) || cleaned.length > MAX_FILTER_CHARS || !FILTER_RE.test(cleaned)) + return null + return cleaned +} + +function modelBody(response: unknown): unknown { + if (typeof response !== 'object' || response === null) + return response + // Workers AI text models answer `{ response }`: an object in JSON mode, else a string. + if ('response' in response) + return (response as { response: unknown }).response + // OpenAI-shaped models answer `{ choices: [{ message: { content } }] }`. + const choices = (response as { choices?: unknown }).choices + if (Array.isArray(choices)) + return (choices[0] as { message?: { content?: unknown } } | undefined)?.message?.content + return response +} + +function readModelObject(response: unknown): Record | null { + const body = modelBody(response) + if (typeof body === 'string') { + const start = body.indexOf('{') + const end = body.lastIndexOf('}') + if (start === -1 || end <= start) + return null + try { + const parsed: unknown = JSON.parse(body.slice(start, end + 1)) + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed as Record : null + } + catch { + // Not JSON: the caller falls back to the typed words, which is the + // whole answer for an unparseable model reply. + return null + } + } + return typeof body === 'object' && body !== null && !Array.isArray(body) ? body as Record : null +} + +/** + * Parse an untrusted model reply into a {@link QueryUnderstanding}. + * + * Returns null when the reply has no usable terms. Filters the model cannot + * ground in the query are dropped rather than trusted: a wrong framework or + * author would demote the right Skills. + */ +export function parseQueryUnderstanding(response: unknown, query: string): QueryUnderstanding | null { + const body = readModelObject(response) + if (!body || typeof body.terms !== 'string') + return null + + const terms = body.terms + .toLowerCase() + .replace(TERM_CHARS_RE, ' ') + .replace(WHITESPACE_RE, ' ') + .trim() + .split(' ') + .slice(0, MAX_TERMS) + .join(' ') + .slice(0, MAX_TERMS_CHARS) + .trim() + if (!/[\p{L}\p{N}]/u.test(terms)) + return null + + const track = typeof body.track === 'string' && TRACK_CATEGORIES.has(body.track) ? body.track : null + const framework = filterValue(body.framework) + const author = filterValue(body.author)?.replace(/^@/, '') ?? null + + return { + terms, + track, + framework: framework && frameworkInQuery(framework, query) ? framework : null, + author: author && LOGIN_RE.test(author) && authorInQuery(author, query) ? author : null, + } +} + +/** + * How one intent search runs. `search` stays the typed query, so exact names + * and phrase matches still rank first. `expansion` adds the model's terms. + */ +export interface SearchPlan { + search: string + expansion: string | null + /** A login to filter by, if it exists in the registry. */ + owner: string | null + /** Classifier categories that earn a ranking boost. Never a filter. */ + boostCategories: readonly string[] + /** A word whose presence in a Skill earns a ranking boost. Never a filter. */ + boostTerm: string | null + /** Expansion words grounded in the query. They may decide the name tier. */ + nameTerms: string | null +} + +export function planIntentSearch(query: string, understanding: QueryUnderstanding | null): SearchPlan { + if (!understanding) + return { search: query, expansion: null, owner: null, boostCategories: [], boostTerm: null, nameTerms: null } + + const { terms, framework, track, author } = understanding + const expansionWords = new Set(terms.split(' ')) + if (framework) + framework.split(' ').forEach(word => expansionWords.add(word)) + const expansion = [...expansionWords].join(' ') + + return { + search: query, + expansion: expansion === query ? null : expansion, + owner: author, + boostCategories: track ? TRACK_CATEGORIES.get(track) ?? [] : [], + boostTerm: framework, + nameTerms: groundedTerms(expansion, query), + } +} + +/** + * Why a search did or did not use the model. Recorded as an aggregate, never + * with the query text. + */ +export type IntentOutcome + = | { _tag: 'understood', understanding: QueryUnderstanding, source: 'model' | 'cache' } + | { _tag: 'skipped', reason: 'binding-missing' | 'rate-limited' | 'cached-miss' } + | { _tag: 'fallback', reason: 'timeout' | 'model-error' | 'invalid-response' } + +/** What a cache entry holds: an answer, or the record that there was none. */ +export type CachedIntent + = | { _tag: 'understood', understanding: QueryUnderstanding } + | { _tag: 'none' } + +export function parseCachedIntent(value: unknown): CachedIntent | null { + if (typeof value !== 'object' || value === null || !('_tag' in value)) + return null + if ((value as { _tag: unknown })._tag === 'none') + return { _tag: 'none' } + if ((value as { _tag: unknown })._tag !== 'understood') + return null + const understanding = (value as { understanding?: unknown }).understanding + if (typeof understanding !== 'object' || understanding === null) + return null + const { terms, track, framework, author } = understanding as Record + const nullableString = (field: unknown) => field === null || typeof field === 'string' + if (typeof terms !== 'string' || !nullableString(track) || !nullableString(framework) || !nullableString(author)) + return null + return { _tag: 'understood', understanding: { terms, track: track as string | null, framework: framework as string | null, author: author as string | null } } +} + +export function intentCacheKey(normalizedQuery: string, digest: (text: string) => Promise): Promise { + return digest(JSON.stringify([SEARCH_INTENT_MODEL, SEARCH_INTENT_PROMPT_VERSION, normalizedQuery])) + .then(hash => `search-intent:v${SEARCH_INTENT_PROMPT_VERSION}:${hash}`) +} + +/** The race result between the model and the latency budget. */ +export type ModelRace + = | { _tag: 'answered', response: unknown } + | { _tag: 'failed' } + | { _tag: 'timeout' } + +/** + * Decide the outcome of one model attempt. A reply with no usable terms is + * cached as `none` so the same query does not pay for the model again. + */ +export function decideIntent(race: ModelRace, query: string): { outcome: IntentOutcome, cache: CachedIntent | null } { + if (race._tag === 'timeout') + return { outcome: { _tag: 'fallback', reason: 'timeout' }, cache: null } + if (race._tag === 'failed') + return { outcome: { _tag: 'fallback', reason: 'model-error' }, cache: null } + const understanding = parseQueryUnderstanding(race.response, query) + if (!understanding) + return { outcome: { _tag: 'fallback', reason: 'invalid-response' }, cache: { _tag: 'none' } } + return { outcome: { _tag: 'understood', understanding, source: 'model' }, cache: { _tag: 'understood', understanding } } +} + +/** Race a promise against a budget without leaving a dangling rejection. */ +export function raceBudget(model: Promise, budgetMs: number, sleep: (ms: number) => Promise): Promise { + return Promise.race([ + model.then(response => ({ _tag: 'answered', response }) as const, () => ({ _tag: 'failed' }) as const), + sleep(budgetMs).then(() => ({ _tag: 'timeout' }) as const), + ]) +} diff --git a/layers/registry/server/utils/skill-box-search.ts b/layers/registry/server/utils/skill-box-search.ts new file mode 100644 index 00000000..7385535a --- /dev/null +++ b/layers/registry/server/utils/skill-box-search.ts @@ -0,0 +1,194 @@ +import type { H3Event } from 'h3' +import type { Platform } from '#shared/server/platform' +import type { SkillSearchQuery } from '#shared/skill-search-query' +import type { IntentOutcome } from './search-intent' +import type { SearchIntentDeps } from './search-intent-run' +import type { SearchMode } from './skill-search' +import type { RegistrySkill } from './skills-registry' +import { getHeader } from 'h3' +import { classifySearchQuery } from '#shared/skill-search-query' +import { runAfterResponse } from './after-response' +import { notBrokenSql } from './broken' +import { planIntentSearch } from './search-intent' +import { understandSearchQuery } from './search-intent-run' +import { querySkills } from './skills-registry' + +/** + * The search box: one query, classified, answered by the right lane. + * + * - a GitHub URL or `owner/repo`: that Repository and its Skills, or the + * index flow when the registry does not hold it yet; + * - `@login`: that owner's Skills; + * - one word or a full Skill ref: the hybrid search, unchanged; + * - a sentence: query understanding first, then the hybrid search. + */ + +export type BoxRepository + = | { _tag: 'indexed', owner: string, repo: string, stars: number, skillCount: number } + | { _tag: 'not-indexed', owner: string, repo: string, url: string } + +export interface SkillBoxSearchResult { + kind: SkillSearchQuery['_tag'] + repository: BoxRepository | null + /** The owner the results are limited to, when the query named one. */ + owner: string | null + intent: IntentOutcome | null + items: RegistrySkill[] + total: number + mode?: SearchMode +} + +interface RepositoryRow { + owner: string + repo: string + stars: number | null + skill_count: number +} + +const NOT_BROKEN_SQL = notBrokenSql('r') + +/** Identities are stored lowercase, so an exact match keeps the primary key index. */ +async function findRepository(db: D1Database, owner: string, repo: string): Promise { + return db.prepare( + `SELECT r.owner, r.repo, r.stars, + (SELECT COUNT(*) FROM skills s WHERE s.owner = r.owner AND s.repo = r.repo AND s.source_resolved = 1) AS skill_count + FROM repos r + WHERE r.owner = ? AND r.repo = ? AND ${NOT_BROKEN_SQL}`, + ).bind(owner, repo).first() +} + +async function ownerExists(db: D1Database, login: string): Promise { + const row = await db.prepare('SELECT 1 AS found FROM skills WHERE owner = ? AND source_resolved = 1 LIMIT 1').bind(login).first() + return row !== null +} + +/** The model spells "matt pocock" as `matt-pocock`; the login is `mattpocock`. */ +async function verifiedOwner(db: D1Database, author: string | null): Promise { + if (!author) + return null + for (const candidate of new Set([author, author.replace(/-/g, '')])) { + if (await ownerExists(db, candidate)) + return candidate + } + return null +} + +async function sha256Hex(text: string): Promise { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)) + return [...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2, '0')).join('') +} + +/** + * Bindings for {@link understandSearchQuery}. The model call shares the + * guest API allowance's limiter under its own key, so a burst spends the + * model budget, never search itself: over the limit, search runs on the + * typed words. The client IP is the limiter key only and is never stored. + */ +export function searchIntentDeps(event: H3Event, platform: Platform): SearchIntentDeps { + const limiter = platform.env.API_GUEST_RATE_LIMIT + return { + ai: platform.ai as unknown as SearchIntentDeps['ai'], + storage: useStorage('edge-cache'), + allow: async () => { + if (!limiter) + return true + const ip = getHeader(event, 'cf-connecting-ip') ?? 'unknown' + const { success } = await limiter.limit({ key: `search-intent:${ip}` }) + return success + }, + schedule: promise => runAfterResponse(event, promise), + digest: sha256Hex, + sleep: ms => new Promise(resolve => setTimeout(resolve, ms)), + report: (outcome) => { + if (outcome._tag !== 'fallback') + return + emitOperationalEvent( + createWideEvent({ operation: 'search-intent', outcome: 'failed', reason: outcome.reason }), + outcome.reason === 'timeout' ? 'info' : 'warn', + ) + }, + } +} + +interface BoxSearchInput { + q: string + limit: number + officialOwners: Set +} + +function empty(): { items: RegistrySkill[], total: number } { + return { items: [], total: 0 } +} + +export async function searchSkillBox( + event: H3Event, + platform: Platform, + input: BoxSearchInput, + intentDeps: SearchIntentDeps = searchIntentDeps(event, platform), +): Promise { + const query = classifySearchQuery(input.q) + const { limit, officialOwners } = input + const base = { kind: query._tag, repository: null, owner: null, intent: null } + const search = (text: string) => querySkills(event, { search: text, limit, officialOwners }) + + switch (query._tag) { + case 'empty': + return { ...base, ...empty() } + + case 'repository': { + const { owner, repo, url } = query.repository + const found = await findRepository(platform.db, owner, repo) + if (found && found.skill_count > 0) { + const listed = await querySkills(event, { owner: found.owner, repo: found.repo, limit, officialOwners }) + return { + ...base, + repository: { _tag: 'indexed', owner: found.owner, repo: found.repo, stars: found.stars ?? 0, skillCount: found.skill_count }, + owner: found.owner, + items: listed.items, + total: listed.total, + } + } + // A bare `owner/repo` may be words ("ci/cd"), so it still searches. + // A pasted URL is unambiguous: the answer is the index flow alone. + const fallback = query.source === 'ref' ? await search(`${owner}/${repo}`) : empty() + return { ...base, repository: { _tag: 'not-indexed', owner, repo, url }, ...fallback } + } + + case 'owner': { + const listed = await querySkills(event, { owner: query.login, limit, officialOwners }) + if (listed.total > 0) + return { ...base, owner: query.login, items: listed.items, total: listed.total } + return { ...base, ...(await search(query.login)) } + } + + case 'skill': + return { ...base, ...(await search(`${query.owner}/${query.repo}/${query.name}`)) } + + case 'name': + return { ...base, ...(await search(query.text)) } + + case 'intent': { + const intent = await understandSearchQuery(intentDeps, query.text) + const plan = planIntentSearch(query.text, intent._tag === 'understood' ? intent.understanding : null) + const owner = await verifiedOwner(platform.db, plan.owner) + const run = (ownerFilter: string | null) => querySkills(event, { + search: plan.search, + intent: { expansion: plan.expansion, boostCategories: plan.boostCategories, boostTerm: plan.boostTerm, nameTerms: plan.nameTerms }, + owner: ownerFilter ?? undefined, + limit, + officialOwners, + }) + const scoped = owner ? await run(owner) : null + // An owner with no match for the rest of the query is a hint, not a wall. + const result = scoped && scoped.total > 0 ? scoped : await run(null) + return { + ...base, + owner: result === scoped ? owner : null, + intent, + items: result.items, + total: result.total, + mode: result.mode, + } + } + } +} diff --git a/layers/registry/server/utils/skill-search-cache.ts b/layers/registry/server/utils/skill-search-cache.ts index dcf6f344..57d4a856 100644 --- a/layers/registry/server/utils/skill-search-cache.ts +++ b/layers/registry/server/utils/skill-search-cache.ts @@ -8,12 +8,17 @@ function deploymentId(event: H3Event): string | null { return typeof id === 'string' && id.trim() ? id : null } -export async function skillSearchCacheKey(event: H3Event): Promise { +/** + * The storage key for one search. A `requestIdentity` replaces the raw query + * string: the search box passes its normalised query, so "Review my PRs?" and + * "review my prs" share one entry. + */ +export async function skillSearchCacheKey(event: H3Event, requestIdentity?: string): Promise { const query = getRequestURL(event).searchParams query.sort() // Hash the complete identity so distinct queries cannot collapse into the // same storage key and the key stays a bounded, opaque string. - const identity = JSON.stringify([deploymentId(event), query.toString()]) + const identity = JSON.stringify([deploymentId(event), requestIdentity ?? query.toString()]) const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(identity)) return [...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2, '0')).join('') } @@ -41,13 +46,15 @@ export async function cachedSkillsSearch( * one query string in different shapes need their own namespace. */ namespace = 'skills-list:v2', + /** A normalised identity in place of the raw query string. */ + requestIdentity?: string, ): Promise { // Without a deployment identity, shared storage cannot separate releases. if (deploymentId(event) === null) return compute() return cached({ storage: useStorage('edge-cache'), - key: `${namespace}:${await skillSearchCacheKey(event)}`, + key: `${namespace}:${await skillSearchCacheKey(event, requestIdentity)}`, ttlSeconds: SEARCH_CACHE_TTL, staleSeconds: SEARCH_CACHE_STALE_TTL, compute, diff --git a/layers/registry/server/utils/skill-search.ts b/layers/registry/server/utils/skill-search.ts index 2c25ada5..03e86e70 100644 --- a/layers/registry/server/utils/skill-search.ts +++ b/layers/registry/server/utils/skill-search.ts @@ -124,21 +124,77 @@ function trustRank(skill: RegistrySkill): number { return duplicateRankingSignals(toDuplicateCandidate(skill)).trustTierRank } +/** + * Evidence from query understanding. Each signal can only lift a Skill a + * little, never filter one out: only about a third of Skills carry a + * classifier category, and the model can be wrong. + */ +export interface SearchRankSignals { + /** The model's search terms. A phrase match against them counts at half weight. */ + expansion?: string | null + /** Classifier categories of the track the query asks for. */ + boostCategories?: readonly string[] + categoryByKey?: ReadonlyMap + /** A library or platform the query names. */ + boostTerm?: string | null + /** Expansion words grounded in the typed query. They may decide the name tier. */ + nameTerms?: string | null +} + +/** + * A quarter of an extra first place: it orders near ties and cannot outrank a + * Skill the retrieval lanes clearly prefer. Measured on "stop memory leaks in + * node": at half a first place, every Skill that says "nodes" outranked the + * memory leak Skills. + */ +export const SIGNAL_BOOST_SCALE = NAME_BOOST_SCALE / 4 + +const SIGNAL_WORD_RE = /[\p{L}\p{N}]+(?:[.#+][\p{L}\p{N}]+)*[#+]*/gu +const REGEX_SPECIAL_RE = /[.*+?^${}()|[\]\\]/g + +/** `vue` matches vue, vuejs, vue.js and vue3. It does not match `vuetify` or `nodes`. */ +function mentionsTerm(skill: RegistrySkill, term: string): boolean { + const words = new Set(`${skill.name} ${skill.displayName} ${skill.repo} ${skill.description ?? ''}`.toLowerCase().match(SIGNAL_WORD_RE) ?? []) + return term.split(' ').every((word) => { + const variant = new RegExp(`^${word.replace(REGEX_SPECIAL_RE, '\\$&')}(?:\\.?js|\\d+)?$`) + return [...words].some(candidate => variant.test(candidate)) + }) +} + +function signalBoost(skill: RegistrySkill, signals: SearchRankSignals): number { + let boost = 0 + if (signals.boostCategories?.length) { + const category = signals.categoryByKey?.get(skillKey(skill)) + if (category && signals.boostCategories.includes(category)) + boost += SIGNAL_BOOST_SCALE + } + if (signals.boostTerm && mentionsTerm(skill, signals.boostTerm)) + boost += SIGNAL_BOOST_SCALE + return boost +} + /** Order by exact identity, complete name words, relevance, then provenance. */ export function rankSearchResults( skills: RegistrySkill[], scoreByKey: Map, search: string, + signals: SearchRankSignals = {}, ): RegistrySkill[] { + const expansion = signals.expansion ?? null return skills .map(skill => ({ skill, exact: Number(isExactIdentity(skill, search)), - nameMatch: Number(searchNameMatch(skill, search)), + // The model's terms decide the name tier only where the typed query + // grounds them. A paraphrase such as "ui design" fully names every + // generic ui-design Skill, which would bury what "make my UI less + // generic" asked for. See groundedTerms in search-intent.ts. + nameMatch: Number(searchNameMatch(skill, search) || (signals.nameTerms ? searchNameMatch(skill, signals.nameTerms) : false)), trust: trustRank(skill), score: (scoreByKey.get(skillKey(skill)) ?? 0) + nameMatchBoost(skill, search) * NAME_BOOST_SCALE - + searchPhraseBoost(skill, search) * NAME_BOOST_SCALE, + + Math.max(searchPhraseBoost(skill, search), expansion === null ? 0 : searchPhraseBoost(skill, expansion) / 2) * NAME_BOOST_SCALE + + signalBoost(skill, signals), })) .sort((a, b) => b.exact - a.exact @@ -291,30 +347,53 @@ async function lexicalSkillSearch(event: H3Event, search: string): Promise result.results ?? []).map(skillKey))] } +/** + * Lane weights. Lexical is weighted slightly higher: an FTS hit means the + * query terms are genuinely present, whereas the semantic lane always returns + * its nearest neighbours regardless of how far away they are. The expansion's + * own semantic lane counts a little less than the typed query's, because the + * model's terms are a paraphrase and the typed words are the request. + */ +export const LEXICAL_WEIGHT = 1.2 +export const SEMANTIC_WEIGHT = 1 +export const EXPANSION_SEMANTIC_WEIGHT = 0.8 + /** * Retrieve skill candidates from both the lexical and semantic lanes and fuse * them. Lexical retrieval keeps Skills awaiting an embedding reachable. * Semantic retrieval finds relevant Skills without matching query words. + * + * With an `expansion` (the query model's search terms), the lexical lane runs + * on the expansion instead of the sentence, since FTS matches words and a + * sentence is mostly filler, and a second semantic lane embeds the expansion. + * The typed sentence keeps its own semantic lane, so a wrong expansion can add + * candidates but cannot remove the ones the sentence finds. */ -export async function hybridSkillSearch(event: H3Event, search: string, retrieval: 'hybrid' | 'lexical' = 'hybrid'): Promise { - const [lexicalKeys, semanticHits] = await Promise.all([ - lexicalSkillSearch(event, search), - retrieval === 'hybrid' ? semanticSkillSearch(event, search, SEMANTIC_TOP_K) : Promise.resolve(null), +export async function hybridSkillSearch( + event: H3Event, + search: string, + retrieval: 'hybrid' | 'lexical' = 'hybrid', + expansion: string | null = null, +): Promise { + const semantic = retrieval === 'hybrid' + const [lexicalKeys, semanticHits, expansionHits] = await Promise.all([ + lexicalSkillSearch(event, expansion ?? search), + semantic ? semanticSkillSearch(event, search, SEMANTIC_TOP_K) : Promise.resolve(null), + semantic && expansion ? semanticSkillSearch(event, expansion, SEMANTIC_TOP_K) : Promise.resolve(null), ]) - if (lexicalKeys === null && semanticHits === null) + if (lexicalKeys === null && semanticHits === null && expansionHits === null) throw createError({ statusCode: 503, statusMessage: 'Search is temporarily unavailable.' }) const semanticKeys = semanticHits?.map(skillKey) ?? [] + const expansionKeys = expansionHits?.map(skillKey) ?? [] const hasLexical = Boolean(lexicalKeys?.length) - const hasSemantic = semanticKeys.length > 0 + const hasSemantic = semanticKeys.length > 0 || expansionKeys.length > 0 - // Lexical is weighted slightly higher: an FTS hit means the query terms are - // genuinely present, whereas the semantic lane always returns its nearest - // neighbours regardless of how far away they are. const scoreByKey = fuseRankings([ - { keys: lexicalKeys ?? [], weight: 1.2 }, - { keys: semanticKeys, weight: 1 }, + { keys: lexicalKeys ?? [], weight: LEXICAL_WEIGHT }, + { keys: semanticKeys, weight: SEMANTIC_WEIGHT }, + { keys: expansionKeys, weight: EXPANSION_SEMANTIC_WEIGHT }, ]) const keys = [...scoreByKey.entries()] diff --git a/layers/registry/server/utils/skills-registry.ts b/layers/registry/server/utils/skills-registry.ts index 07813f00..7480fcb9 100644 --- a/layers/registry/server/utils/skills-registry.ts +++ b/layers/registry/server/utils/skills-registry.ts @@ -1,6 +1,6 @@ import type { H3Event } from 'h3' import type { DuplicateCandidate, DuplicateGroupReason } from './skill-duplicate-canonical' -import type { AlternateSource, HybridSearchResult, SearchMode } from './skill-search' +import type { AlternateSource, HybridSearchResult, SearchMode, SearchRankSignals } from './skill-search' import { getDB } from '#server/utils/db' import { cached } from '#shared/server/cache' import { skillCardLinks } from '#shared/server/skill-cards' @@ -169,10 +169,22 @@ function rowsToSkills(rows: SkillRow[], includeDependencies: boolean): RegistryS })) } +/** What query understanding adds to one search. See search-intent.ts. */ +export interface SkillsQueryIntent { + expansion: string | null + boostCategories: readonly string[] + boostTerm: string | null + nameTerms: string | null +} + export interface SkillsQuery { search?: string retrieval?: 'hybrid' | 'lexical' + /** Query understanding for a natural-language search. Absent for every other caller. */ + intent?: SkillsQueryIntent owner?: string + /** One repository of `owner`. Ignored without `owner`. */ + repo?: string official?: boolean excludeOfficial?: boolean supportedOnly?: boolean @@ -261,7 +273,7 @@ export async function querySkills(event: H3Event, opts: SkillsQuery): Promise() - - const ranked = rankSearchResults(rowsToSkills(rows.results ?? [], includeDependencies), searchHits.scoreByKey, search!) + .all() + + const signals: SearchRankSignals = opts.intent + ? { + expansion: opts.intent.expansion, + boostCategories: opts.intent.boostCategories, + boostTerm: opts.intent.boostTerm, + nameTerms: opts.intent.nameTerms, + categoryByKey: new Map((rows.results ?? []).map(row => [`${row.owner}/${row.repo}/${row.name}`, row.abstractness_category])), + } + : {} + const ranked = rankSearchResults(rowsToSkills(rows.results ?? [], includeDependencies), searchHits.scoreByKey, search!, signals) // Forked skill collections mirror the same SKILL.md under several owners. // Collapsing after ranking keeps each group at its best member's position. const collapsed = collapseSearchDuplicates(ranked, search!) diff --git a/shared/skill-search-query.ts b/shared/skill-search-query.ts new file mode 100644 index 00000000..c25aae8e --- /dev/null +++ b/shared/skill-search-query.ts @@ -0,0 +1,90 @@ +import type { GitHubRepository } from './github-repository' +import { parseGitHubRepositoryUrl } from './github-repository' + +/** The longest query the search box sends. Longer input is cut, never rejected. */ +export const MAX_SEARCH_QUERY_LENGTH = 200 + +const WHITESPACE_RE = /\s+/g +const TRAILING_PUNCTUATION_RE = /[?!.,;:]+$/ +const GITHUB_HOST_RE = /^(?:https?:\/\/)?(?:www\.)?github\.com\//i +const GITHUB_SEGMENT_RE = /^[\w.-]+$/ +// GitHub logins: letters, digits and single hyphens, up to 39 characters. +const GITHUB_LOGIN_RE = /^[a-z\d][a-z\d-]{0,38}$/i + +/** + * What the search box was given, decided before any network call. + * + * Only `intent` may reach the query model. A URL, a ref, a login, or a single + * word is already a precise query, so a model could only make it worse and + * slower. + */ +export type SkillSearchQuery + = | { _tag: 'empty' } + | { _tag: 'repository', repository: GitHubRepository, source: 'url' | 'ref' } + | { _tag: 'skill', owner: string, repo: string, name: string } + | { _tag: 'owner', login: string } + | { _tag: 'name', text: string } + | { _tag: 'intent', text: string } + +/** + * One spelling per query: the cache key, the model input, and the identity the + * classifier reads. "Review my PRs?" and "review my prs" are the same search. + */ +export function normalizeSearchQuery(raw: string): string { + return raw + .normalize('NFKC') + .replace(WHITESPACE_RE, ' ') + .trim() + .toLowerCase() + .slice(0, MAX_SEARCH_QUERY_LENGTH) + .replace(TRAILING_PUNCTUATION_RE, '') + .trim() +} + +function repositoryRef(owner: string, repo: string): GitHubRepository { + const o = owner.toLowerCase() + const r = repo.replace(/\.git$/i, '').toLowerCase() + return { _tag: 'repository', owner: o, repo: r, url: `https://github.com/${o}/${r}` } +} + +function classifyGitHubUrl(text: string): SkillSearchQuery | null { + if (!GITHUB_HOST_RE.test(text)) + return null + const url = /^https?:\/\//i.test(text) ? text : `https://${text}` + const parsed = parseGitHubRepositoryUrl(url) + if (parsed._tag === 'repository') + return { _tag: 'repository', repository: parsed, source: 'url' } + // `github.com/antfu` names an owner, not a repository. + const segments = text.replace(GITHUB_HOST_RE, '').split('/').filter(Boolean) + if (segments.length === 1 && GITHUB_LOGIN_RE.test(segments[0]!)) + return { _tag: 'owner', login: segments[0]!.toLowerCase() } + return null +} + +/** Classify one search box value. Pure, so the browser and the server agree. */ +export function classifySearchQuery(raw: string): SkillSearchQuery { + const text = normalizeSearchQuery(raw) + if (!text) + return { _tag: 'empty' } + + const url = classifyGitHubUrl(text) + if (url) + return url + + if (text.startsWith('@')) { + const login = text.slice(1) + if (GITHUB_LOGIN_RE.test(login)) + return { _tag: 'owner', login } + } + + if (!text.includes(' ') && text.includes('/')) { + const segments = text.split('/') + if (segments.every(segment => GITHUB_SEGMENT_RE.test(segment))) { + if (segments.length === 2) + return { _tag: 'repository', repository: repositoryRef(segments[0]!, segments[1]!), source: 'ref' } + return { _tag: 'skill', owner: segments[0]!, repo: segments[1]!, name: segments.slice(2).join('/') } + } + } + + return text.includes(' ') ? { _tag: 'intent', text } : { _tag: 'name', text } +} diff --git a/test/unit/search-intent.test.ts b/test/unit/search-intent.test.ts new file mode 100644 index 00000000..728d48bd --- /dev/null +++ b/test/unit/search-intent.test.ts @@ -0,0 +1,214 @@ +import type { IntentOutcome } from '../../layers/registry/server/utils/search-intent' +import type { SearchIntentDeps } from '../../layers/registry/server/utils/search-intent-run' +import type { ReadThroughCache } from '../../shared/server/cache' +import { describe, expect, it, vi } from 'vitest' +import { + decideIntent, + groundedTerms, + parseQueryUnderstanding, + planIntentSearch, + raceBudget, +} from '../../layers/registry/server/utils/search-intent' +import { understandSearchQuery } from '../../layers/registry/server/utils/search-intent-run' + +const reply = (body: Record) => ({ response: body }) + +describe('parseQueryUnderstanding', () => { + it('reads a JSON mode reply and drops "none" filters', () => { + expect(parseQueryUnderstanding( + reply({ terms: 'Pull Request Code Review', track: 'code-review', framework: 'none', author: 'none' }), + 'review my pull requests', + )).toEqual({ terms: 'pull request code review', track: 'code-review', framework: null, author: null }) + }) + + it.each([ + ['a JSON string', { response: 'Sure: {"terms":"vue testing","track":"testing","framework":"vue","author":"none"}' }], + ['an OpenAI shaped reply', { choices: [{ message: { content: '{"terms":"vue testing","track":"testing","framework":"vue","author":"none"}' } }] }], + ])('reads %s', (_, response) => { + expect(parseQueryUnderstanding(response, 'test a vue app')).toEqual({ + terms: 'vue testing', + track: 'testing', + framework: 'vue', + author: null, + }) + }) + + it.each([ + ['no terms', reply({ track: 'testing' })], + ['terms with no letters', reply({ terms: ' - . ', track: 'none', framework: 'none', author: 'none' })], + ['prose', { response: 'I cannot help with that.' }], + ['nothing', null], + ])('rejects a reply with %s', (_, response) => { + expect(parseQueryUnderstanding(response, 'write better commit messages')).toBeNull() + }) + + it('keeps a framework the query misspells and drops one it never mentions', () => { + expect(parseQueryUnderstanding(reply({ terms: 'react hooks', track: 'coding', framework: 'react', author: 'none' }), 'reacct hooks')?.framework).toBe('react') + expect(parseQueryUnderstanding(reply({ terms: 'ui design', track: 'design', framework: 'react', author: 'none' }), 'make my ui less generic')?.framework).toBeNull() + }) + + it('keeps an author the query spells and drops an invented one', () => { + expect(parseQueryUnderstanding(reply({ terms: 'typescript', track: 'none', framework: 'none', author: 'matt-pocock' }), 'skills by matt pocock')?.author).toBe('matt-pocock') + expect(parseQueryUnderstanding(reply({ terms: 'vue testing', track: 'testing', framework: 'vue', author: 'antfu' }), 'test a vue app')?.author).toBeNull() + }) + + it('drops an unknown track and cuts terms to six words', () => { + expect(parseQueryUnderstanding( + reply({ terms: 'one two three four five six seven', track: 'cooking', framework: 'none', author: 'none' }), + 'one two three four five six seven', + )).toMatchObject({ terms: 'one two three four five six', track: null }) + }) +}) + +describe('planIntentSearch', () => { + it('searches the typed words alone without an understanding', () => { + expect(planIntentSearch('review my pull requests', null)).toEqual({ + search: 'review my pull requests', + expansion: null, + owner: null, + boostCategories: [], + boostTerm: null, + nameTerms: null, + }) + }) + + it('adds the terms and framework, and boosts the track categories', () => { + expect(planIntentSearch('write a postgres migration', { + terms: 'migration', + track: 'devops', + framework: 'postgres', + author: null, + })).toEqual({ + search: 'write a postgres migration', + expansion: 'migration postgres', + owner: null, + boostCategories: ['ci-cd', 'deployment', 'release-management', 'migrations'], + boostTerm: 'postgres', + nameTerms: 'migration postgres', + }) + }) +}) + +describe('groundedTerms', () => { + it.each([ + ['migration postgres', 'write a postgres migration', 'migration postgres'], + ['react hooks', 'reacct hooks', 'react hooks'], + ['ui design distinctive', 'make my ui less generic', 'ui'], + ['code review', 'check my pull requests', null], + ])('keeps the words of %j that %j says or misspells', (expansion, query, expected) => { + expect(groundedTerms(expansion, query)).toBe(expected) + }) +}) + +describe('decideIntent', () => { + it('falls back without caching on a timeout or a model error', () => { + expect(decideIntent({ _tag: 'timeout' }, 'q')).toEqual({ outcome: { _tag: 'fallback', reason: 'timeout' }, cache: null }) + expect(decideIntent({ _tag: 'failed' }, 'q')).toEqual({ outcome: { _tag: 'fallback', reason: 'model-error' }, cache: null }) + }) + + it('caches an unusable reply as none, so the query does not pay again', () => { + expect(decideIntent({ _tag: 'answered', response: { response: 'no' } }, 'q')).toEqual({ + outcome: { _tag: 'fallback', reason: 'invalid-response' }, + cache: { _tag: 'none' }, + }) + }) +}) + +describe('raceBudget', () => { + const never = () => new Promise(() => {}) + + it('answers with the model when it beats the budget', async () => { + expect(await raceBudget(Promise.resolve('reply'), 800, never)).toEqual({ _tag: 'answered', response: 'reply' }) + }) + + it('times out when the budget ends first', async () => { + expect(await raceBudget(new Promise(() => {}), 800, () => Promise.resolve())).toEqual({ _tag: 'timeout' }) + }) + + it('turns a model rejection into a failure', async () => { + expect(await raceBudget(Promise.reject(new Error('5025')), 800, never)).toEqual({ _tag: 'failed' }) + }) +}) + +function memoryStorage(seed: Record = {}) { + const items = new Map(Object.entries(seed)) + const storage: ReadThroughCache = { + getItem: async (key: string) => (items.get(key) ?? null) as T | null, + setItem: async (key: string, value: never) => { + items.set(key, value) + }, + } + return { items, storage } +} + +const VALID = reply({ terms: 'vue testing', track: 'testing', framework: 'vue', author: 'none' }) + +function deps(overrides: Partial = {}) { + const { items, storage } = memoryStorage() + const scheduled: Promise[] = [] + const reported: IntentOutcome[] = [] + const run = vi.fn(async () => VALID) + const value: SearchIntentDeps = { + ai: { run }, + storage, + allow: async () => true, + schedule: (promise) => { + scheduled.push(promise) + }, + digest: async text => `digest(${text.length})`, + sleep: () => new Promise(() => {}), + report: outcome => reported.push(outcome), + ...overrides, + } + return { deps: value, items, scheduled, reported, run } +} + +describe('understandSearchQuery', () => { + it('runs the model once, then answers the same query from the cache', async () => { + const harness = deps() + const first = await understandSearchQuery(harness.deps, 'test a vue app') + const second = await understandSearchQuery(harness.deps, 'test a vue app') + expect(first).toMatchObject({ _tag: 'understood', source: 'model', understanding: { terms: 'vue testing' } }) + expect(second).toMatchObject({ _tag: 'understood', source: 'cache', understanding: { terms: 'vue testing' } }) + expect(harness.run).toHaveBeenCalledOnce() + }) + + it('skips the model when the visitor is over the allowance', async () => { + const harness = deps({ allow: async () => false }) + expect(await understandSearchQuery(harness.deps, 'test a vue app')).toEqual({ _tag: 'skipped', reason: 'rate-limited' }) + expect(harness.run).not.toHaveBeenCalled() + }) + + it('skips the model when the binding is missing', async () => { + const harness = deps({ ai: undefined }) + expect(await understandSearchQuery(harness.deps, 'test a vue app')).toEqual({ _tag: 'skipped', reason: 'binding-missing' }) + }) + + it('falls back at the budget and caches the late reply for the next search', async () => { + let resolve!: (value: unknown) => void + const late = new Promise((done) => { + resolve = done + }) + const harness = deps({ ai: { run: () => late }, sleep: () => Promise.resolve() }) + expect(await understandSearchQuery(harness.deps, 'test a vue app')).toEqual({ _tag: 'fallback', reason: 'timeout' }) + expect(harness.reported).toEqual([{ _tag: 'fallback', reason: 'timeout' }]) + + resolve(VALID) + await Promise.all(harness.scheduled) + expect(await understandSearchQuery(harness.deps, 'test a vue app')).toMatchObject({ _tag: 'understood', source: 'cache' }) + }) + + it('does not ask the model again for a query it could not parse', async () => { + const harness = deps({ ai: { run: vi.fn(async () => ({ response: 'no idea' })) } }) + expect(await understandSearchQuery(harness.deps, 'asdf qwer')).toEqual({ _tag: 'fallback', reason: 'invalid-response' }) + expect(await understandSearchQuery(harness.deps, 'asdf qwer')).toEqual({ _tag: 'skipped', reason: 'cached-miss' }) + }) + + it('falls back without caching when the model throws', async () => { + const harness = deps({ ai: { run: async () => { + throw new Error('AiError: 3040 capacity') + } } }) + expect(await understandSearchQuery(harness.deps, 'test a vue app')).toEqual({ _tag: 'fallback', reason: 'model-error' }) + expect(harness.items.size).toBe(0) + }) +}) diff --git a/test/unit/skill-search-fusion.test.ts b/test/unit/skill-search-fusion.test.ts index 573d6bdd..769b63b7 100644 --- a/test/unit/skill-search-fusion.test.ts +++ b/test/unit/skill-search-fusion.test.ts @@ -242,3 +242,38 @@ it('prefers provenance among equally exact Skill names', () => { const scores = new Map([[skillKey(candidate), 0.03], [skillKey(official), 0.01]]) expect(rankSearchResults([candidate, official], scores, 'pdf')[0]).toEqual(official) }) + +describe('query understanding signals', () => { + const generic = skill({ owner: 'a', repo: 'skills', name: 'testing-guide', description: 'Write tests for any app.' }) + const vue = skill({ owner: 'b', repo: 'skills', name: 'component-tests', description: 'Test Vue 3 components with Vitest.' }) + const tied = new Map([[skillKey(generic), 0.03], [skillKey(vue), 0.0299]]) + + it('lifts a Skill that mentions the framework the query named over a near tie', () => { + expect(rankSearchResults([generic, vue], tied, 'test a vue app')).toEqual([generic, vue]) + expect(rankSearchResults([generic, vue], tied, 'test a vue app', { boostTerm: 'vue' })).toEqual([vue, generic]) + }) + + it('lifts a Skill in the track the query asked for', () => { + const categoryByKey = new Map([[skillKey(vue), 'testing'], [skillKey(generic), null]]) + expect(rankSearchResults([generic, vue], tied, 'q', { boostCategories: ['testing'], categoryByKey })).toEqual([vue, generic]) + }) + + it('never lifts a signal match over a clearly better retrieval score', () => { + const scores = new Map([[skillKey(generic), 0.033], [skillKey(vue), 0.016]]) + expect(rankSearchResults([vue, generic], scores, 'q', { boostTerm: 'vue', boostCategories: ['testing'], categoryByKey: new Map([[skillKey(vue), 'testing']]) })).toEqual([generic, vue]) + }) + + it('puts a Skill named by the grounded terms in the name tier, so a typo still finds it', () => { + const hooks = skill({ owner: 'c', repo: 'skills', name: 'react-hooks' }) + const other = skill({ owner: 'd', repo: 'skills', name: 'hook-creator' }) + const scores = new Map([[skillKey(other), 0.05], [skillKey(hooks), 0.01]]) + expect(rankSearchResults([other, hooks], scores, 'reacct hooks', { expansion: 'react hooks', nameTerms: 'react hooks' })[0]).toEqual(hooks) + }) + + it('keeps a Skill named only by the paraphrase out of the name tier', () => { + const generic = skill({ owner: 'e', repo: 'skills', name: 'ui-design' }) + const asked = skill({ owner: 'f', repo: 'skills', name: 'make-interfaces-feel-better' }) + const scores = new Map([[skillKey(asked), 0.05], [skillKey(generic), 0.01]]) + expect(rankSearchResults([generic, asked], scores, 'make my ui less generic', { expansion: 'ui design distinctive', nameTerms: 'ui' })[0]).toEqual(asked) + }) +}) From cde9d2b5cceb87e5b84f2f9ab7e19a1524d1dc57 Mon Sep 17 00:00:00 2001 From: Harlan Wilton Date: Tue, 6 Oct 2026 03:58:46 +1100 Subject: [PATCH 2/3] feat(search): show run commands and repositories in the search box Each Skill row now carries its run chip, and a Repository the registry holds gets its own row, so the box answers both halves of its placeholder. The panel becomes a grid because an option cannot contain a control: arrow right moves to the run chip and Enter copies it. The hero and header boxes share one composable for keys, selection and navigation, so they stop disagreeing about what Enter does. The header field widens while open, rows keep their names readable at phone width, and the preview pane with its empty half is gone. --- app/components/HomeSearch.vue | 122 +--- app/components/SkillSearchPanel.vue | 680 +++++++++++---------- app/components/SkillSearchTrigger.vue | 112 +--- app/composables/useSkillSearch.ts | 175 ++++-- app/composables/useSkillSearchBox.ts | 172 ++++++ app/utils/skill-typeahead.ts | 70 +++ test/unit/home-search.test.ts | 145 +++-- test/unit/search-panel-run-command.test.ts | 7 +- test/unit/skill-search-query.test.ts | 60 ++ test/unit/skill-search-state.test.ts | 51 +- test/unit/skill-typeahead.test.ts | 18 +- 11 files changed, 998 insertions(+), 614 deletions(-) create mode 100644 app/composables/useSkillSearchBox.ts create mode 100644 test/unit/skill-search-query.test.ts diff --git a/app/components/HomeSearch.vue b/app/components/HomeSearch.vue index 7df63235..88b713fd 100644 --- a/app/components/HomeSearch.vue +++ b/app/components/HomeSearch.vue @@ -1,109 +1,28 @@ @@ -150,8 +65,9 @@ const submitLabel = computed(() => (state.value._tag === 'repository' ? 'Index r autocomplete="off" role="combobox" aria-label="Search skills or index a GitHub repository" + aria-haspopup="grid" :aria-expanded="open" - :aria-controls="listboxId" + :aria-controls="gridId" :aria-activedescendant="activeDescendant" class="w-full" :ui="{ @@ -164,6 +80,8 @@ const submitLabel = computed(() => (state.value._tag === 'repository' ? 'Index r @input="openPanel" @keydown.down="onArrow($event, 1)" @keydown.up="onArrow($event, -1)" + @keydown.left="onColumn($event, 0)" + @keydown.right="onColumn($event, 1)" @keydown.enter="onEnter" >