Add a unified search experience across all SciDEX content, accessible from every page via the navigation bar. Real-time autocomplete with type-grouped results (wiki pages, hypotheses, entities, papers, analyses, gaps).
/api/search?q={query}&limit={n} endpointnav_html() function in api.py/api/searchQuery Parameters:
q (required): Search query stringlimit (optional): Max results per type (default: 5){
"query": "APOE",
"total_results": 23,
"results_by_type": {
"wiki": [
{
"type": "wiki",
"id": "APOE",
"title": "APOE",
"snippet": "Apolipoprotein E is a protein...",
"url": "/wiki/APOE",
"relevance": 0.95
}
],
"hypotheses": [
{
"type": "hypothesis",
"id": "hyp-123",
"title": "APOE4 increases AD risk via...",
"snippet": "APOE4 allele carriers show increased...",
"url": "/hypothesis/hyp-123",
"relevance": 0.87
}
],
"entities": [...],
"papers": [...],
"analyses": [...],
"gaps": [...]
}
}Implementation:
@app.get("/api/search")
def unified_search(q: str, limit: int = 5):
"""Unified search across all content types."""
db = get_db()
query_lower = q.lower()
results = {"query": q, "total_results": 0, "results_by_type": {}}
# Search wiki entities
wiki_results = db.execute("""
SELECT entity_name as title, summary as snippet, entity_type
FROM wiki_entities
WHERE LOWER(entity_name) LIKE ? OR LOWER(summary) LIKE ?
ORDER BY
CASE WHEN LOWER(entity_name) = ? THEN 0
WHEN LOWER(entity_name) LIKE ? THEN 1
ELSE 2 END,
entity_name
LIMIT ?
""", (f'%{query_lower}%', f'%{query_lower}%', query_lower, f'{query_lower}%', limit)).fetchall()
results["results_by_type"]["wiki"] = [
{
"type": "wiki",
"id": r['title'],
"title": r['title'],
"snippet": (r['snippet'] or '')[:80] + '...',
"url": f"/wiki/{r['title']}",
"relevance": calculate_relevance(r['title'], q)
} for r in [dict(row) for row in wiki_results]
]
# Search hypotheses
hyp_results = db.execute("""
SELECT id, title, description, composite_score
FROM hypotheses
WHERE LOWER(title) LIKE ? OR LOWER(description) LIKE ?
ORDER BY composite_score DESC
LIMIT ?
""", (f'%{query_lower}%', f'%{query_lower}%', limit)).fetchall()
results["results_by_type"]["hypotheses"] = [
{
"type": "hypothesis",
"id": r['id'],
"title": r['title'][:60] + '...' if len(r['title']) > 60 else r['title'],
"snippet": (r['description'] or '')[:80] + '...',
"url": f"/hypothesis/{r['id']}",
"relevance": r['composite_score']
} for r in [dict(row) for row in hyp_results]
]
# Search KG entities
entity_results = db.execute("""
SELECT DISTINCT source_id as entity
FROM knowledge_edges
WHERE LOWER(source_id) LIKE ?
UNION
SELECT DISTINCT target_id as entity
FROM knowledge_edges
WHERE LOWER(target_id) LIKE ?
LIMIT ?
""", (f'%{query_lower}%', f'%{query_lower}%', limit)).fetchall()
results["results_by_type"]["entities"] = [
{
"type": "entity",
"id": r['entity'],
"title": r['entity'],
"snippet": "Knowledge graph entity",
"url": f"/entity/{r['entity']}",
"relevance": calculate_relevance(r['entity'], q)
} for r in [dict(row) for row in entity_results]
]
# Search papers
paper_results = db.execute("""
SELECT pmid, title, authors
FROM papers
WHERE LOWER(title) LIKE ? OR LOWER(authors) LIKE ?
LIMIT ?
""", (f'%{query_lower}%', f'%{query_lower}%', limit)).fetchall()
results["results_by_type"]["papers"] = [
{
"type": "paper",
"id": r['pmid'],
"title": r['title'][:60] + '...' if len(r['title']) > 60 else r['title'],
"snippet": f"Authors: {r['authors'][:50]}...",
"url": f"https://pubmed.ncbi.nlm.nih.gov/{r['pmid']}",
"relevance": 0.5
} for r in [dict(row) for row in paper_results]
]
# Count total
results["total_results"] = sum(len(v) for v in results["results_by_type"].values())
return results
def calculate_relevance(text: str, query: str) -> float:
"""Simple relevance scoring (0-1)."""
text_lower = text.lower()
query_lower = query.lower()
if text_lower == query_lower:
return 1.0
elif text_lower.startswith(query_lower):
return 0.9
elif query_lower in text_lower:
return 0.7
else:
return 0.5Add to nav_html() function in api.py:
def nav_html(active=""):
"""Navigation bar with integrated search."""
search_html = """
<div class="search-container">
<input type="text" id="global-search" placeholder="Search SciDEX..."
autocomplete="off" aria-label="Search">
<div id="search-results" class="search-results hidden"></div>
</div>
"""
# Insert search_html after nav links, before closing </nav>
nav = f"""<nav style="display:flex;justify-content:space-between;align-items:center;...">
<div class="nav-links">
<a href="/">Dashboard</a>
<!-- other links -->
</div>
{search_html}
</nav>"""
return nav + SEARCH_JS + SEARCH_CSSconst SEARCH_JS = """
<script>
(function() {
const searchInput = document.getElementById('global-search');
const resultsContainer = document.getElementById('search-results');
let debounceTimer;
let selectedIndex = -1;
let currentResults = [];
searchInput.addEventListener('input', (e) => {
clearTimeout(debounceTimer);
const query = e.target.value.trim();
if (query.length < 2) {
resultsContainer.classList.add('hidden');
return;
}
debounceTimer = setTimeout(() => performSearch(query), 200);
});
async function performSearch(query) {
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}&limit=5`);
const data = await response.json();
displayResults(data);
} catch (error) {
console.error('Search failed:', error);
}
}
function displayResults(data) {
if (data.total_results === 0) {
resultsContainer.innerHTML = '<div class="search-no-results">No results found</div>';
resultsContainer.classList.remove('hidden');
return;
}
let html = `<div class="search-summary">${data.total_results} results for "${data.query}"</div>`;
const typeLabels = {
'wiki': 'Wiki Pages',
'hypotheses': 'Hypotheses',
'entities': 'Graph Nodes',
'papers': 'Papers',
'analyses': 'Analyses',
'gaps': 'Knowledge Gaps'
};
currentResults = [];
for (const [type, results] of Object.entries(data.results_by_type)) {
if (results.length === 0) continue;
html += `<div class="search-group">
<div class="search-group-title">${typeLabels[type] || type}</div>`;
results.forEach((result, idx) => {
const globalIdx = currentResults.length;
currentResults.push(result);
html += `<a href="${result.url}" class="search-result" data-index="${globalIdx}">
<div class="search-result-header">
<span class="search-result-title">${result.title}</span>
<span class="search-result-badge search-badge-${result.type}">${result.type}</span>
</div>
<div class="search-result-snippet">${result.snippet}</div>
</a>`;
});
html += '</div>';
}
resultsContainer.innerHTML = html;
resultsContainer.classList.remove('hidden');
selectedIndex = -1;
}
// Keyboard navigation
searchInput.addEventListener('keydown', (e) => {
const resultElements = resultsContainer.querySelectorAll('.search-result');
if (e.key === 'ArrowDown') {
e.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, resultElements.length - 1);
updateSelection(resultElements);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
selectedIndex = Math.max(selectedIndex - 1, -1);
updateSelection(resultElements);
} else if (e.key === 'Enter' && selectedIndex >= 0) {
e.preventDefault();
resultElements[selectedIndex].click();
} else if (e.key === 'Escape') {
resultsContainer.classList.add('hidden');
searchInput.blur();
}
});
function updateSelection(elements) {
elements.forEach((el, idx) => {
if (idx === selectedIndex) {
el.classList.add('selected');
el.scrollIntoView({block: 'nearest'});
} else {
el.classList.remove('selected');
}
});
}
// Close results when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('.search-container')) {
resultsContainer.classList.add('hidden');
}
});
})();
</script>
"""const SEARCH_CSS = """
<style>
.search-container {
position: relative;
width: 300px;
}
#global-search {
width: 100%;
padding: 0.6rem 1rem;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 8px;
color: #e0e0e0;
font-size: 0.9rem;
transition: all 0.2s;
}
#global-search:focus {
outline: none;
border-color: #4fc3f7;
background: rgba(255,255,255,0.12);
}
.search-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 0.5rem;
background: #1a1a2e;
border: 1px solid rgba(79,195,247,0.3);
border-radius: 8px;
max-height: 500px;
overflow-y: auto;
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
z-index: 1000;
}
.search-results.hidden {
display: none;
}
.search-summary {
padding: 0.8rem 1rem;
color: #888;
font-size: 0.85rem;
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.search-group-title {
padding: 0.6rem 1rem;
color: #4fc3f7;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
background: rgba(79,195,247,0.08);
border-top: 1px solid rgba(255,255,255,0.05);
}
.search-result {
display: block;
padding: 0.8rem 1rem;
color: #e0e0e0;
text-decoration: none;
border-bottom: 1px solid rgba(255,255,255,0.05);
transition: background 0.15s;
}
.search-result:hover,
.search-result.selected {
background: rgba(79,195,247,0.12);
}
.search-result-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.3rem;
}
.search-result-title {
font-weight: 500;
color: #e0e0e0;
}
.search-result-badge {
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
}
.search-badge-wiki { background: rgba(129,199,132,0.2); color: #81c784; }
.search-badge-hypothesis { background: rgba(255,213,79,0.2); color: #ffd54f; }
.search-badge-entity { background: rgba(79,195,247,0.2); color: #4fc3f7; }
.search-badge-paper { background: rgba(206,147,216,0.2); color: #ce93d8; }
.search-result-snippet {
color: #adb5bd;
font-size: 0.85rem;
line-height: 1.4;
}
.search-no-results {
padding: 2rem 1rem;
text-align: center;
color: #888;
}
/* Mobile responsive */
@media (max-width: 768px) {
.search-container {
width: 100%;
margin-top: 0.5rem;
}
.search-results {
max-height: 400px;
}
}
</style>
"""/api/search endpoint with multi-table queriesnav_html()Total Estimated Time: 7 hours
/api/search endpoint implemented and tested/api/search with relevance scoring