diff --git a/lib/query-ranking.test.ts b/lib/query-ranking.test.ts new file mode 100644 index 0000000..a78aab0 --- /dev/null +++ b/lib/query-ranking.test.ts @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + getLocalizationIntentFitScore, + isLocalizationQuery, + tokenizeQuery, +} from './query-ranking.ts' + +test('tokenizeQuery removes low-information task words', () => { + assert.deepEqual( + tokenizeQuery('Localize a SaaS product for launch in China'), + ['localize', 'saas', 'product', 'launch', 'china'] + ) +}) + +test('isLocalizationQuery recognizes SaaS localization and China launch intent', () => { + const query = 'localize a saas product for launch in china' + assert.equal(isLocalizationQuery(query, tokenizeQuery(query)), true) +}) + +test('localization fit rewards relevant skills and rejects generic coding skills', () => { + const relevant = getLocalizationIntentFitScore( + 'localization', + 'SaaS localization, internationalization, Chinese locale, and China market entry' + ) + const generic = getLocalizationIntentFitScore( + 'coding', + 'General coding patterns for building software products' + ) + + assert.ok(relevant > 0) + assert.ok(generic < 0) +}) diff --git a/lib/query-ranking.ts b/lib/query-ranking.ts new file mode 100644 index 0000000..c201144 --- /dev/null +++ b/lib/query-ranking.ts @@ -0,0 +1,66 @@ +const QUERY_STOP_WORDS = new Set([ + 'about', + 'agent', + 'agents', + 'and', + 'for', + 'from', + 'into', + 'need', + 'right', + 'skill', + 'skills', + 'that', + 'the', + 'this', + 'use', + 'using', + 'want', + 'what', + 'when', + 'with', +]) + +export function tokenizeQuery(value: string) { + return value + .toLowerCase() + .split(/[\s+,./:_-]+/) + .map((token) => token.trim()) + .filter((token) => token.length > 2 && !QUERY_STOP_WORDS.has(token)) +} + +export function isLocalizationQuery(normalizedQuery: string, queryTokens: string[]) { + const tokenSet = new Set(queryTokens) + const hasDirectLocalizationTerm = + /\b(locali[sz](?:e|ed|ing|ation)|internationali[sz](?:e|ed|ing|ation)|i18n|translat(?:e|ed|ing|ion)|locale|multilingual)\b/.test( + normalizedQuery + ) + const hasChinaLaunchContext = + /\b(china|chinese|mainland china)\b/.test(normalizedQuery) && + /\b(saas|software|product|launch|market|local)\b/.test(normalizedQuery) + + return ( + hasDirectLocalizationTerm || + hasChinaLaunchContext || + ['localize', 'localise', 'localization', 'localisation', 'i18n'].some((token) => tokenSet.has(token)) + ) +} + +export function getLocalizationIntentFitScore(category: string, text: string) { + const categoryMatch = /\b(localization|localisation|translation|internationalization|internationalisation|i18n|market-entry)\b/.test( + category + ) + const positiveMatch = /\b(locali[sz](?:e|ed|ing|ation)|internationali[sz](?:e|ed|ing|ation)|i18n|translat(?:e|ed|ing|ion)|locale|multilingual|china|chinese|market-entry)\b/.test( + text + ) + const negativeMatch = /\b(web-crawling|crawler|scraper|browser-automation|presentation|slides?|security|vulnerability)\b/.test( + text + ) + + let score = 0 + if (categoryMatch) score += 140 + if (positiveMatch) score += 120 + if (negativeMatch && !positiveMatch) score -= 170 + if (!categoryMatch && !positiveMatch) score -= 220 + return score +} diff --git a/lib/registry.ts b/lib/registry.ts index dc587da..21e050f 100644 --- a/lib/registry.ts +++ b/lib/registry.ts @@ -6,6 +6,11 @@ import type { SkillAgentStats, SkillEventStats, SkillOutcomeStats, SkillRecord } import { getSkillDecisionProfile } from '@/lib/decision' import { getSkillInstallTargets } from '@/lib/install-targets' import { getPlatformHints, getSkillQualityProfile } from '@/lib/quality' +import { + getLocalizationIntentFitScore, + isLocalizationQuery, + tokenizeQuery, +} from '@/lib/query-ranking' import { getSkillAttribution } from '@/lib/skill-attribution' import { getSkillSupplyProfile } from '@/lib/supply' import { getSkillTrustProfile } from '@/lib/trust' @@ -13,14 +18,6 @@ import { getUseCasesForSkill, scoreSkillForUseCase, USE_CASES } from '@/lib/use- const SITE_URL = 'https://www.openagentskill.com' -function tokenize(value: string) { - return value - .toLowerCase() - .split(/[\s+,./:_-]+/) - .map((token) => token.trim()) - .filter((token) => token.length > 2) -} - const QUERY_TOKEN_ALIASES: Record = { trade: ['trading', 'trader', 'trades', 'finance', 'financial', 'stock', 'stocks', 'market', 'markets', 'portfolio', 'quant', 'backtest'], trader: ['trade', 'trading', 'finance', 'financial', 'stock', 'stocks', 'market', 'markets', 'portfolio', 'quant', 'backtest'], @@ -131,7 +128,7 @@ function skillSearchText(skill: SkillRecord) { .toLowerCase() } -type QueryIntent = 'finance' | 'presentation' | 'design' | 'coding' | 'sports' | 'research' | 'web' | null +type QueryIntent = 'finance' | 'localization' | 'presentation' | 'design' | 'coding' | 'sports' | 'research' | 'web' | null function detectQueryIntent(normalizedQuery: string, queryTokens: string[]): QueryIntent { const tokenSet = new Set(queryTokens) @@ -143,6 +140,10 @@ function detectQueryIntent(normalizedQuery: string, queryTokens: string[]): Quer return 'finance' } + if (isLocalizationQuery(normalizedQuery, queryTokens)) { + return 'localization' + } + if (/\b(presentation|presentations|ppt|pptx|powerpoint|slides?|slide deck|deck|pitch deck|keynote|speaker notes|html slides|visual story)\b/.test(normalizedQuery)) { return 'presentation' } @@ -172,8 +173,9 @@ function detectQueryIntent(normalizedQuery: string, queryTokens: string[]): Quer function getIntentFitScore(intent: QueryIntent, category: string, text: string) { if (!intent) return 0 + if (intent === 'localization') return getLocalizationIntentFitScore(category, text) - const profile: Record, { category: RegExp; positive: RegExp; negative: RegExp }> = { + const profile: Record, { category: RegExp; positive: RegExp; negative: RegExp }> = { finance: { category: /\b(finance|financial|quant|trading|market|stock|investment|portfolio|fintech|crypto|defi)\b/, positive: /\b(finance|financial|quant|quantitative|trade|trades|trader|trading|portfolio|market-data|markets?|stocks?|stock[-_\s]?analysis|equity|crypto|filings?|edgar|sec filing|investor|investment|earnings|10-k|10-q|alpha|factor|backtest|backtesting|risk model|openbb|vectorbt|freqtrade|yfinance|zipline|backtrader|serenity)\b/, @@ -396,7 +398,7 @@ export function rankSkillsForQuery( const rankingQuery = augmentQueryForIntent(query) const normalizedQuery = rankingQuery.trim().toLowerCase() const compactQuery = normalizedQuery.replace(/[^a-z0-9]+/g, '') - const queryTokens = tokenize(rankingQuery) + const queryTokens = tokenizeQuery(rankingQuery) const expandedQueryTokens = expandQueryTokens(queryTokens) const queryIntent = detectQueryIntent(normalizedQuery, queryTokens) const isFinanceQueryIntent = /\b(finance|financial|quant|quantitative|trade|trades|trader|trading|invest|investing|investment|portfolio|markets?|stocks?|equity|crypto|filings?|edgar|sec filings?|investor|earnings|10-k|10-q|alpha|factor|backtest|backtesting|risk model)\b/.test(normalizedQuery) || @@ -525,7 +527,7 @@ export function getRecommendationReasons(skill: SkillRecord, query: string, scor const reasons: string[] = [] const text = skillSearchText(skill) const normalizedQuery = query.trim().toLowerCase() - const queryTokens = tokenize(query) + const queryTokens = tokenizeQuery(query) const matchedTokens = queryTokens.filter((token) => text.includes(token)).slice(0, 4) if (matchedTokens.length > 0) { diff --git a/package.json b/package.json index 004b8d6..47b0bff 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "dev": "next dev", "build": "next build", "start": "next start", + "test": "node --test lib/*.test.ts", "seed:popular": "node scripts/seed-popular-skills.ts", "indexer:backfill": "node scripts/backfill-high-star-skills.mjs", "lint": "eslint ."