Skip to content
Merged
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
70 changes: 48 additions & 22 deletions app/api/search/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import { NextResponse } from 'next/server.js';
import { promises as fs } from 'fs';
import path from 'path';
import { apiError } from '../../../lib/api-error.js';
import { developerMatchesSearchQuery, tokenizeDeveloperSearchQuery } from '../../../lib/developer-search.js';
import {
rankDeveloperSearchResults,
searchTermAlternatives,
tokenizeDeveloperSearchQuery,
} from '../../../lib/developer-search.js';
import { attachSearchMatches } from '../../../lib/search-match.js';

const COSMOS_ENDPOINT = process.env.COSMOS_ENDPOINT;
Expand Down Expand Up @@ -31,38 +35,48 @@ async function getSampleData() {
}

function searchSampleData(data, q, limit) {
return data
.filter(developer => developerMatchesSearchQuery(developer, q))
.slice(0, limit);
return rankDeveloperSearchResults(data, q, limit);
}

function buildTextSearch(terms, limit) {
const parameters = terms.map((term, index) => ({ name: `@q${index}`, value: term }));
const predicates = terms.map((_, index) => `(
CONTAINS(LOWER(c.login), @q${index})
OR CONTAINS(LOWER(c.name), @q${index})
OR CONTAINS(LOWER(c.location), @q${index})
OR CONTAINS(LOWER(c.bio), @q${index})
OR CONTAINS(LOWER(c.topLanguage), @q${index})
OR EXISTS(SELECT VALUE tag FROM tag IN c.specialTags WHERE CONTAINS(LOWER(tag), @q${index}))
)`);
function buildTextSearch(terms, limit, matchAll = true) {
const parameters = [];
const predicates = terms.map((term, termIndex) => {
const alternatives = searchTermAlternatives(term);
const alternativePredicates = alternatives.map((alternative, alternativeIndex) => {
const parameterName = `@q${termIndex}_${alternativeIndex}`;
parameters.push({ name: parameterName, value: alternative });
return `(
CONTAINS(LOWER(c.login), ${parameterName})
OR CONTAINS(LOWER(c.name), ${parameterName})
OR CONTAINS(LOWER(c.location), ${parameterName})
OR CONTAINS(LOWER(c.bio), ${parameterName})
OR CONTAINS(LOWER(c.topLanguage), ${parameterName})
OR EXISTS(SELECT VALUE tag FROM tag IN c.specialTags WHERE CONTAINS(LOWER(tag), ${parameterName}))
)`;
});
return `(${alternativePredicates.join(' OR ')})`;
});

return {
query: `
SELECT TOP ${limit}
c.id, c.login, c.name, c.avatarUrl, c.location, c.lat, c.lng,
c.topLanguage, c.score, c.totalStars, c.followers, c.soReputation, c.specialTags
FROM c
WHERE ${predicates.join(' AND ')} AND ${PUBLIC_FILTER}
WHERE ${predicates.join(matchAll ? ' AND ' : ' OR ')} AND ${PUBLIC_FILTER}
ORDER BY c.score DESC
`,
parameters,
};
}

async function runTextSearch(container, terms, limit) {
const { resources } = await container.items.query(buildTextSearch(terms, limit)).fetchAll();
return resources;
const strict = await container.items.query(buildTextSearch(terms, limit)).fetchAll();
if (strict.resources.length || terms.length < 2) return { results: strict.resources, matchMode: 'all' };
const candidateLimit = Math.min(Math.max(limit * 5, 50), 200);
const broadened = await container.items.query(buildTextSearch(terms, candidateLimit, false)).fetchAll();
const ranked = rankDeveloperSearchResults(broadened.resources, terms.join(' '), limit);
return { results: ranked.results, matchMode: ranked.matchMode };
}

async function runVectorSearch(container, query, limit) {
Expand Down Expand Up @@ -117,13 +131,15 @@ export async function GET(request) {

if (!COSMOS_ENDPOINT || !COSMOS_KEY) {
const data = await getSampleData();
const results = attachSearchMatches(searchSampleData(data, q, limit), q, 'text');
const search = searchSampleData(data, q, limit);
const results = attachSearchMatches(search.results, q, 'text');
return NextResponse.json({
query: q,
requestedMode: mode,
mode: 'text',
fallback: mode === 'text' ? null : 'semantic_unavailable',
interpretedTerms,
matchMode: search.matchMode,
count: results.length,
results,
});
Expand All @@ -135,26 +151,33 @@ export async function GET(request) {
let results;
let resolvedMode = mode;
let fallback = null;
let matchMode = 'all';

if (mode === 'vector') {
try {
if (!OPENAI_CONFIGURED) throw new Error('Semantic search is not configured');
results = await runVectorSearch(container, q, limit);
} catch (error) {
console.warn('Vector search fell back to text:', error.message);
results = await runTextSearch(container, interpretedTerms, limit);
const textSearch = await runTextSearch(container, interpretedTerms, limit);
results = textSearch.results;
matchMode = textSearch.matchMode;
resolvedMode = 'text';
fallback = 'semantic_unavailable';
}
} else if (mode === 'text') {
results = await runTextSearch(container, interpretedTerms, limit);
const textSearch = await runTextSearch(container, interpretedTerms, limit);
results = textSearch.results;
matchMode = textSearch.matchMode;
} else {
try {
if (!OPENAI_CONFIGURED) throw new Error('Semantic search is not configured');
const [vectorResults, textResults] = await Promise.all([
const [vectorResults, textSearch] = await Promise.all([
runVectorSearch(container, q, limit),
runTextSearch(container, interpretedTerms, limit),
]);
const textResults = textSearch.results;
matchMode = textSearch.matchMode;
const k = 60;
const rrf = new Map();
const allMap = new Map();
Expand All @@ -169,7 +192,9 @@ export async function GET(request) {
results = [...rrf.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit).map(([login]) => allMap.get(login));
} catch (error) {
console.warn('Hybrid search fell back to text:', error.message);
results = await runTextSearch(container, interpretedTerms, limit);
const textSearch = await runTextSearch(container, interpretedTerms, limit);
results = textSearch.results;
matchMode = textSearch.matchMode;
resolvedMode = 'text';
fallback = 'semantic_unavailable';
}
Expand All @@ -182,6 +207,7 @@ export async function GET(request) {
mode: resolvedMode,
fallback,
interpretedTerms,
matchMode,
count: results.length,
results,
});
Expand Down
1 change: 1 addition & 0 deletions app/openapi.json/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export function GET() {
mode: { type: 'string' },
fallback: { type: ['string', 'null'], enum: ['semantic_unavailable', null] },
interpretedTerms: { type: 'array', items: { type: 'string' } },
matchMode: { type: 'string', enum: ['all', 'approximate', 'broadened', 'none'] },
count: { type: 'integer' },
results: {
type: 'array',
Expand Down
71 changes: 52 additions & 19 deletions components/SearchBar.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import SpecialTags from './SpecialTags.jsx';
import { track, trackSearchAppearances } from '../lib/analytics.js';
import { countryKey } from '../lib/country.js';
import {
developerMatchesSearchQuery,
findExactLoginResult,
normalizeTextSearchQuery,
rankDeveloperSearchResults,
tokenizeDeveloperSearchQuery,
} from '../lib/developer-search.js';
import { publicApiUrl } from '../lib/public-api.js';
Expand Down Expand Up @@ -93,13 +93,14 @@ export default function SearchBar({ developers, onResults, onReset, onSelectDeve
track('next_action_selected', { action: source, journey: 'agent_onboarding', source: 'homepage_prompt' });
}, [agentPromptKey]);

const openSearchResult = useCallback((developer, source) => {
const openSearchResult = useCallback((developer, source, resultRank) => {
recordRecentProfile(developer);
track('search_result_opened', {
login: developer.login,
action: mode,
journey: 'developer_discovery',
source,
...(Number.isInteger(resultRank) ? { resultRank } : {}),
});
track('personalized_profile_viewed', {
login: developer.login,
Expand All @@ -125,11 +126,15 @@ export default function SearchBar({ developers, onResults, onReset, onSelectDeve
onSelectDeveloper(developer);
}, [mode, onSelectDeveloper]);

const recordSearchOutcome = useCallback((results, searchMode, source) => {
const recordSearchOutcome = useCallback((results, searchMode, source, context = {}) => {
track(results.length > 0 ? 'search_results_viewed' : 'search_no_results', {
action: searchMode,
journey: 'developer_discovery',
source,
termCount: context.interpretedTerms?.length || 0,
resultCount: results.length,
matchMode: context.matchMode || 'all',
fallback: context.fallback || 'none',
});
}, []);

Expand All @@ -153,13 +158,18 @@ export default function SearchBar({ developers, onResults, onReset, onSelectDeve

if (m === 'text') {
const textQuery = normalizeTextSearchQuery(q);
const interpretedTerms = tokenizeDeveloperSearchQuery(textQuery);
let results = developers.filter(d =>
developerMatchesSearchQuery(d, textQuery) ||
(interpretedTerms.length === 1 && d.location && countryKey(d.location).includes(countryKey(interpretedTerms[0])))
).slice(0, topN);
const localSearch = rankDeveloperSearchResults(developers, textQuery, topN);
const interpretedTerms = localSearch.interpretedTerms;
let results = localSearch.results;
let outcomeContext = { interpretedTerms, fallback: null, matchMode: localSearch.matchMode };

setSearchContext({ interpretedTerms, fallback: null });
if (!results.length && interpretedTerms.length === 1) {
results = developers.filter(developer =>
developer.location && countryKey(developer.location).includes(countryKey(interpretedTerms[0]))
).slice(0, topN);
}

setSearchContext(outcomeContext);

if (results.length === 0) {
const controller = new AbortController();
Expand All @@ -174,7 +184,12 @@ export default function SearchBar({ developers, onResults, onReset, onSelectDeve
if (controller.signal.aborted) return;
if (!response.ok) throw new Error(data.error?.message || data.error || 'Search is unavailable.');
results = data.results || [];
setSearchContext({ interpretedTerms: data.interpretedTerms || interpretedTerms, fallback: data.fallback });
outcomeContext = {
interpretedTerms: data.interpretedTerms || interpretedTerms,
fallback: data.fallback,
matchMode: data.matchMode || 'all',
};
setSearchContext(outcomeContext);
} catch (error) {
if (error.name === 'AbortError') return;
console.error('Text search fallback failed:', error);
Expand All @@ -194,7 +209,7 @@ export default function SearchBar({ developers, onResults, onReset, onSelectDeve
setVisibleResults(results.slice(0, 3));
setSingleResult(results.length === 1 ? results[0] : null);
if (remember) recordRecentSearch({ query: q, mode: m });
recordSearchOutcome(results, m, source);
recordSearchOutcome(results, m, source, outcomeContext);
trackSearchAppearances(results.map(result => result.login), m);
onSearchState?.({ query: q.trim(), results });
const exactResult = openExact ? findExactLoginResult(q, results) : null;
Expand All @@ -215,7 +230,12 @@ export default function SearchBar({ developers, onResults, onReset, onSelectDeve
if (!res.ok) throw new Error(data.error || 'Search is unavailable.');
if (!controller.signal.aborted) {
const results = data.results || [];
setSearchContext({ interpretedTerms: data.interpretedTerms || [], fallback: data.fallback });
const outcomeContext = {
interpretedTerms: data.interpretedTerms || [],
fallback: data.fallback,
matchMode: data.matchMode || 'all',
};
setSearchContext(outcomeContext);
onResults(results);
setResultCount(results.length);
setVisibleResults(results.slice(0, 3));
Expand All @@ -224,7 +244,7 @@ export default function SearchBar({ developers, onResults, onReset, onSelectDeve
: null;
setSingleResult(matchedDeveloper);
if (remember) recordRecentSearch({ query: q, mode: m });
recordSearchOutcome(results, m, source);
recordSearchOutcome(results, m, source, outcomeContext);
trackSearchAppearances(results.map(result => result.login), m);
onSearchState?.({ query: q.trim(), results });
const exactResult = openExact ? findExactLoginResult(q, results) : null;
Expand Down Expand Up @@ -306,8 +326,16 @@ export default function SearchBar({ developers, onResults, onReset, onSelectDeve
inputRef.current?.focus();
};

const handleSelectResult = (developer) => {
openSearchResult(developer, mode);
const handleSelectResult = (developer, index) => {
openSearchResult(developer, mode, index + 1);
};

const handleBroadenQuery = () => {
const broaderTerms = (searchContext?.interpretedTerms || []).slice(0, -1);
if (!broaderTerms.length) return;
const broaderQuery = broaderTerms.join(' ');
setQuery(broaderQuery);
doSearch(broaderQuery, mode, { remember: true, source: 'broaden_search' });
};

const handleRecentSearch = recent => {
Expand Down Expand Up @@ -499,7 +527,7 @@ export default function SearchBar({ developers, onResults, onReset, onSelectDeve
</div>
{searchContext?.interpretedTerms?.length > 1 && (
<div className="search-bar__interpretation" role="status">
<span>Matched all:</span>
<span>{searchContext.matchMode === 'none' ? 'No strong matches for:' : searchContext.matchMode === 'broadened' ? 'Showing close matches for:' : searchContext.matchMode === 'approximate' ? 'Corrected likely typos in:' : 'Matched all:'}</span>
<strong>{searchContext.interpretedTerms.join(' + ')}</strong>
</div>
)}
Expand All @@ -508,14 +536,19 @@ export default function SearchBar({ developers, onResults, onReset, onSelectDeve
AI ranking is unavailable, so these results use public profile keywords.
</div>
)}
{resultCount === 0 && searchContext?.interpretedTerms?.length > 1 && (
<button type="button" className="search-bar__broaden" onClick={handleBroadenQuery}>
Search fewer terms
</button>
)}
{visibleResults.length > 0 && (
<div className="search-bar__suggestions" role="list" aria-label="Top developer matches">
{visibleResults.map((developer, index) => (
<div className="search-bar__card-suggestion" role="listitem" key={developer.login}>
<button
type="button"
className="search-bar__profile-result"
onClick={() => handleSelectResult(developer)}
onClick={() => handleSelectResult(developer, index)}
aria-label={`View ${developer.name || developer.login}'s profile`}
>
<img src={developer.avatarUrl} alt="" />
Expand All @@ -535,11 +568,11 @@ export default function SearchBar({ developers, onResults, onReset, onSelectDeve
aria-label={`${developer.match.score} match score. ${developer.match.reasons[0]}. ${developer.match.disclaimer}`}
>
<b>{developer.match.score} match</b>
<span>{developer.match.reasons[0]}</span>
<span>{developer.match.reasons.slice(0, 2).join(' · ')}</span>
</span>
)}
</span>
<span className="search-bar__view-profile">View profile</span>
<span className="search-bar__view-profile">Open profile</span>
</button>
<span className="search-bar__card-actions">
{index === 0 && singleResult && (
Expand Down
28 changes: 22 additions & 6 deletions lib/analytics.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,27 @@ const ENGAGEMENT_EVENT_MAP = {
weekly_digest_returned: 'weekly_digest_returned',
primary_action_completed: 'primary_action_completed',
};
const ALLOWED_PROPERTIES = new Set(['action', 'campaign', 'channel', 'journey', 'missionKey', 'outcome', 'responseMinutes', 'source']);
const ALLOWED_STRING_PROPERTIES = new Set([
'action', 'campaign', 'channel', 'fallback', 'journey', 'matchMode', 'missionKey', 'outcome', 'source',
]);
const ALLOWED_NUMBER_PROPERTIES = new Map([
['responseMinutes', 10080],
['resultCount', 50],
['resultRank', 50],
['termCount', 20],
]);

function safeProperties(properties) {
return Object.fromEntries(Object.entries(properties || {})
.filter(([key, value]) => ALLOWED_PROPERTIES.has(key) && typeof value === 'string'));
export function safeAnalyticsProperties(properties) {
const safe = {};
for (const [key, value] of Object.entries(properties || {})) {
if (ALLOWED_STRING_PROPERTIES.has(key) && typeof value === 'string') {
safe[key] = value.slice(0, 100);
}
if (ALLOWED_NUMBER_PROPERTIES.has(key) && Number.isFinite(value)) {
safe[key] = Math.max(0, Math.min(Math.round(value), ALLOWED_NUMBER_PROPERTIES.get(key)));
}
}
return safe;
}

function send(events) {
Expand All @@ -93,7 +109,7 @@ export function track(name, properties = {}) {
? { ...properties, source: 'local' }
: properties;
try {
window.__DEVGLOBE_APP_INSIGHTS__?.trackEvent({ name }, safeProperties(boundedProperties));
window.__DEVGLOBE_APP_INSIGHTS__?.trackEvent({ name }, safeAnalyticsProperties(boundedProperties));
} catch {
// Continue with durable ingestion when the optional RUM client fails.
}
Expand All @@ -102,7 +118,7 @@ export function track(name, properties = {}) {
send([{
eventName,
targetLogin: properties.login,
properties: safeProperties(boundedProperties),
properties: safeAnalyticsProperties(boundedProperties),
}]);
}

Expand Down
Loading
Loading