Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions lib/query-ranking.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
66 changes: 66 additions & 0 deletions lib/query-ranking.ts
Original file line number Diff line number Diff line change
@@ -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
}
26 changes: 14 additions & 12 deletions lib/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,18 @@ 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'
import { getUseCasesForSkill, scoreSkillForUseCase, USE_CASES } from '@/lib/use-cases'

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<string, string[]> = {
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'],
Expand Down Expand Up @@ -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)
Expand All @@ -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'
}
Expand Down Expand Up @@ -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<Exclude<QueryIntent, null>, { category: RegExp; positive: RegExp; negative: RegExp }> = {
const profile: Record<Exclude<QueryIntent, null | 'localization'>, { 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/,
Expand Down Expand Up @@ -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) ||
Expand Down Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 ."
Expand Down