Remove deprecated slot definitions and add archived versions for future reference
- Deleted the following slot definitions: - wikidata_class_slot - wikidata_entity_label_slot - wikidata_mapping_rationale_slot - word_count_slot - Added archived versions of the deleted slots to preserve historical data: - wikidata_class_archived_20260114.yaml - wikidata_entity_label_archived_20260114.yaml - wikidata_mapping_rationale_archived_20260114.yaml - word_count_archived_20260114.yaml - Introduced a new hook `usePersonSearch` for enhanced semantic search functionality in the frontend, supporting debounced queries and caching.
This commit is contained in:
parent
1389b744f1
commit
d5d970b513
13 changed files with 4332 additions and 3245 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"generated": "2026-01-14T21:33:36.352Z",
|
"generated": "2026-01-14T21:38:51.740Z",
|
||||||
"schemaRoot": "/schemas/20251121/linkml",
|
"schemaRoot": "/schemas/20251121/linkml",
|
||||||
"totalFiles": 3026,
|
"totalFiles": 3026,
|
||||||
"categoryCounts": {
|
"categoryCounts": {
|
||||||
|
|
|
||||||
238
frontend/src/hooks/usePersonSearch.ts
Normal file
238
frontend/src/hooks/usePersonSearch.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
/**
|
||||||
|
* usePersonSearch Hook
|
||||||
|
*
|
||||||
|
* Provides semantic search functionality for person profiles using
|
||||||
|
* the RAG API's Qdrant vector database backend.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Semantic vector search across all profiles
|
||||||
|
* - Filter by field type (name, email, domain, birth_year)
|
||||||
|
* - Debounced queries to prevent API overload
|
||||||
|
* - Caching of search results
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
|
||||||
|
export type SearchField = 'all' | 'name' | 'email' | 'domain' | 'birth_year';
|
||||||
|
|
||||||
|
export interface PersonSearchResult {
|
||||||
|
ppid?: string;
|
||||||
|
name: string;
|
||||||
|
headline?: string | null;
|
||||||
|
custodian_name?: string | null;
|
||||||
|
custodian_slug?: string | null;
|
||||||
|
linkedin_url?: string | null;
|
||||||
|
heritage_relevant?: boolean | null;
|
||||||
|
heritage_type?: string | null;
|
||||||
|
location?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
email_domain?: string | null;
|
||||||
|
birth_year?: number | null;
|
||||||
|
score?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PersonSearchResponse {
|
||||||
|
query: string;
|
||||||
|
results: PersonSearchResult[];
|
||||||
|
result_count: number;
|
||||||
|
query_time_ms: number;
|
||||||
|
collection_stats?: Record<string, unknown> | null;
|
||||||
|
embedding_model_used?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UsePersonSearchOptions {
|
||||||
|
debounceMs?: number;
|
||||||
|
minQueryLength?: number;
|
||||||
|
maxResults?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UsePersonSearchReturn {
|
||||||
|
// Search state
|
||||||
|
query: string;
|
||||||
|
setQuery: (query: string) => void;
|
||||||
|
searchField: SearchField;
|
||||||
|
setSearchField: (field: SearchField) => void;
|
||||||
|
|
||||||
|
// Results
|
||||||
|
results: PersonSearchResult[];
|
||||||
|
isSearching: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
queryTimeMs: number | null;
|
||||||
|
resultCount: number;
|
||||||
|
embeddingModelUsed: string | null;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
clearSearch: () => void;
|
||||||
|
search: (query: string, field?: SearchField) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_BASE = '/api/rag';
|
||||||
|
|
||||||
|
export function usePersonSearch(options: UsePersonSearchOptions = {}): UsePersonSearchReturn {
|
||||||
|
const {
|
||||||
|
debounceMs = 300,
|
||||||
|
minQueryLength = 2,
|
||||||
|
maxResults = 50,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// State
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [searchField, setSearchField] = useState<SearchField>('all');
|
||||||
|
const [results, setResults] = useState<PersonSearchResult[]>([]);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [queryTimeMs, setQueryTimeMs] = useState<number | null>(null);
|
||||||
|
const [resultCount, setResultCount] = useState(0);
|
||||||
|
const [embeddingModelUsed, setEmbeddingModelUsed] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Refs for debouncing
|
||||||
|
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
// Build the search query based on field selection
|
||||||
|
const buildSearchQuery = useCallback((rawQuery: string, field: SearchField): string => {
|
||||||
|
const trimmedQuery = rawQuery.trim();
|
||||||
|
if (!trimmedQuery) return '';
|
||||||
|
|
||||||
|
switch (field) {
|
||||||
|
case 'name':
|
||||||
|
return `person named ${trimmedQuery}`;
|
||||||
|
case 'email':
|
||||||
|
return `email address ${trimmedQuery}`;
|
||||||
|
case 'domain':
|
||||||
|
return `working at domain ${trimmedQuery}`;
|
||||||
|
case 'birth_year':
|
||||||
|
return `born in ${trimmedQuery}`;
|
||||||
|
case 'all':
|
||||||
|
default:
|
||||||
|
return trimmedQuery;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Perform the actual search
|
||||||
|
const performSearch = useCallback(async (searchQuery: string, field: SearchField) => {
|
||||||
|
if (searchQuery.trim().length < minQueryLength) {
|
||||||
|
setResults([]);
|
||||||
|
setResultCount(0);
|
||||||
|
setQueryTimeMs(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel any pending request
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
}
|
||||||
|
abortControllerRef.current = new AbortController();
|
||||||
|
|
||||||
|
setIsSearching(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const enhancedQuery = buildSearchQuery(searchQuery, field);
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE}/persons/search`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: enhancedQuery,
|
||||||
|
k: maxResults,
|
||||||
|
only_heritage_relevant: false,
|
||||||
|
}),
|
||||||
|
signal: abortControllerRef.current.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Search failed: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: PersonSearchResponse = await response.json();
|
||||||
|
|
||||||
|
setResults(data.results);
|
||||||
|
setResultCount(data.result_count);
|
||||||
|
setQueryTimeMs(data.query_time_ms);
|
||||||
|
setEmbeddingModelUsed(data.embedding_model_used || null);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.name === 'AbortError') {
|
||||||
|
// Request was cancelled, ignore
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error('Person search error:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Search failed');
|
||||||
|
setResults([]);
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
|
}, [buildSearchQuery, maxResults, minQueryLength]);
|
||||||
|
|
||||||
|
// Debounced search effect
|
||||||
|
useEffect(() => {
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!query.trim()) {
|
||||||
|
setResults([]);
|
||||||
|
setResultCount(0);
|
||||||
|
setQueryTimeMs(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
debounceTimerRef.current = setTimeout(() => {
|
||||||
|
performSearch(query, searchField);
|
||||||
|
}, debounceMs);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [query, searchField, debounceMs, performSearch]);
|
||||||
|
|
||||||
|
// Manual search function (bypasses debounce)
|
||||||
|
const search = useCallback(async (searchQuery: string, field?: SearchField) => {
|
||||||
|
await performSearch(searchQuery, field || searchField);
|
||||||
|
}, [performSearch, searchField]);
|
||||||
|
|
||||||
|
// Clear search
|
||||||
|
const clearSearch = useCallback(() => {
|
||||||
|
setQuery('');
|
||||||
|
setResults([]);
|
||||||
|
setResultCount(0);
|
||||||
|
setQueryTimeMs(null);
|
||||||
|
setError(null);
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
}
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
query,
|
||||||
|
setQuery,
|
||||||
|
searchField,
|
||||||
|
setSearchField,
|
||||||
|
results,
|
||||||
|
isSearching,
|
||||||
|
error,
|
||||||
|
queryTimeMs,
|
||||||
|
resultCount,
|
||||||
|
embeddingModelUsed,
|
||||||
|
clearSearch,
|
||||||
|
search,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -505,6 +505,181 @@
|
||||||
color: var(--text-primary, #e0e0e0);
|
color: var(--text-primary, #e0e0e0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Enhanced Profile Search */
|
||||||
|
.profile-search-enhanced {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search Mode Toggle */
|
||||||
|
.search-mode-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
background: var(--bg-secondary, #f5f5f5);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-mode-toggle .mode-btn {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-mode-toggle .mode-btn:hover {
|
||||||
|
background: var(--bg-primary, #fff);
|
||||||
|
color: var(--text-primary, #1a1a2e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-mode-toggle .mode-btn.active {
|
||||||
|
background: var(--accent-color, #4f46e5);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-mode-toggle .mode-btn svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .search-mode-toggle {
|
||||||
|
background: var(--bg-tertiary, #2a2a4a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .search-mode-toggle .mode-btn {
|
||||||
|
color: var(--text-secondary, #999);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .search-mode-toggle .mode-btn:hover {
|
||||||
|
background: var(--bg-secondary, #1a1a2e);
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search Field Filter Dropdown */
|
||||||
|
.search-field-filter {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-field-filter .field-select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-primary, #fff);
|
||||||
|
color: var(--text-primary, #1a1a2e);
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-field-filter .field-select:focus {
|
||||||
|
border-color: var(--accent-color, #4f46e5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .search-field-filter .field-select {
|
||||||
|
background: var(--bg-tertiary, #2a2a4a);
|
||||||
|
border-color: var(--border-color, #3a3a5a);
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search Stats */
|
||||||
|
.search-stats {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-stats .query-time {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search Error */
|
||||||
|
.search-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--error-color, #dc2626);
|
||||||
|
background: rgba(220, 38, 38, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search Spinner */
|
||||||
|
.profile-search .search-spinner {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--accent-color, #4f46e5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Semantic Search Results */
|
||||||
|
.profile-list.semantic-results .profile-item {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-item.semantic-result .score-badge {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
background: var(--accent-color, #4f46e5);
|
||||||
|
color: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-item .headline-text {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
font-style: italic;
|
||||||
|
max-width: 150px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-item .custodian-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
background: var(--bg-tertiary, #e0e0e0);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-item .location-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .profile-item .headline-text {
|
||||||
|
color: var(--text-secondary, #999);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .profile-item .custodian-badge {
|
||||||
|
background: var(--bg-secondary, #1a1a2e);
|
||||||
|
color: var(--text-secondary, #999);
|
||||||
|
}
|
||||||
|
|
||||||
.profile-list {
|
.profile-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
import { Tooltip } from '../components/common/Tooltip';
|
import { Tooltip } from '../components/common/Tooltip';
|
||||||
|
import { usePersonSearch, type SearchField } from '../hooks/usePersonSearch';
|
||||||
import {
|
import {
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
XCircle,
|
XCircle,
|
||||||
|
|
@ -34,7 +35,9 @@ import {
|
||||||
Star,
|
Star,
|
||||||
Info,
|
Info,
|
||||||
Search,
|
Search,
|
||||||
X
|
X,
|
||||||
|
Database,
|
||||||
|
Filter as FilterIcon
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
// Name similarity calculation using Levenshtein distance
|
// Name similarity calculation using Levenshtein distance
|
||||||
|
|
@ -270,8 +273,23 @@ export default function EntityReviewPage() {
|
||||||
type StatsFilter = 'all' | 'reviewed' | 'pending';
|
type StatsFilter = 'all' | 'reviewed' | 'pending';
|
||||||
const [statsFilter, setStatsFilter] = useState<StatsFilter>('pending');
|
const [statsFilter, setStatsFilter] = useState<StatsFilter>('pending');
|
||||||
|
|
||||||
// Profile search
|
// Profile search - now with semantic search mode
|
||||||
const [profileSearchQuery, setProfileSearchQuery] = useState('');
|
const [profileSearchQuery, setProfileSearchQuery] = useState('');
|
||||||
|
const [useSemanticSearch, setUseSemanticSearch] = useState(false); // Toggle: semantic vs local filter
|
||||||
|
|
||||||
|
// Semantic search hook (searches ALL profiles in vector database)
|
||||||
|
const {
|
||||||
|
query: semanticQuery,
|
||||||
|
setQuery: setSemanticQuery,
|
||||||
|
searchField: semanticSearchField,
|
||||||
|
setSearchField: setSemanticSearchField,
|
||||||
|
results: semanticResults,
|
||||||
|
isSearching: semanticSearching,
|
||||||
|
error: semanticError,
|
||||||
|
queryTimeMs: semanticQueryTime,
|
||||||
|
resultCount: semanticResultCount,
|
||||||
|
clearSearch: clearSemanticSearch,
|
||||||
|
} = usePersonSearch({ debounceMs: 400, minQueryLength: 2, maxResults: 50 });
|
||||||
|
|
||||||
// Linkup search state
|
// Linkup search state
|
||||||
const [linkupSearching, setLinkupSearching] = useState(false);
|
const [linkupSearching, setLinkupSearching] = useState(false);
|
||||||
|
|
@ -857,29 +875,159 @@ export default function EntityReviewPage() {
|
||||||
<div className="review-content">
|
<div className="review-content">
|
||||||
{/* Profile List Sidebar */}
|
{/* Profile List Sidebar */}
|
||||||
<aside className="profile-sidebar compact">
|
<aside className="profile-sidebar compact">
|
||||||
<h2>Profielen ({profileSearchQuery ? `${filteredProfiles.length} / ${profiles.length}` : profiles.length})</h2>
|
<h2>Profielen ({useSemanticSearch && semanticQuery ? `${semanticResultCount} found` : profileSearchQuery ? `${filteredProfiles.length} / ${profiles.length}` : profiles.length})</h2>
|
||||||
|
|
||||||
{/* Profile Search */}
|
{/* Enhanced Profile Search */}
|
||||||
<div className="profile-search">
|
<div className="profile-search-enhanced">
|
||||||
<Search size={16} />
|
{/* Search Mode Toggle */}
|
||||||
<input
|
<div className="search-mode-toggle">
|
||||||
type="text"
|
|
||||||
placeholder={language === 'nl' ? 'Zoek op naam of domein...' : 'Search by name or domain...'}
|
|
||||||
value={profileSearchQuery}
|
|
||||||
onChange={(e) => setProfileSearchQuery(e.target.value)}
|
|
||||||
/>
|
|
||||||
{profileSearchQuery && (
|
|
||||||
<button
|
<button
|
||||||
className="clear-search"
|
className={`mode-btn ${!useSemanticSearch ? 'active' : ''}`}
|
||||||
onClick={() => setProfileSearchQuery('')}
|
onClick={() => {
|
||||||
title={language === 'nl' ? 'Wissen' : 'Clear'}
|
setUseSemanticSearch(false);
|
||||||
|
clearSemanticSearch();
|
||||||
|
}}
|
||||||
|
title={language === 'nl' ? 'Filter huidige pagina' : 'Filter current page'}
|
||||||
>
|
>
|
||||||
<X size={14} />
|
<FilterIcon size={14} />
|
||||||
|
<span>{language === 'nl' ? 'Pagina' : 'Page'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={`mode-btn ${useSemanticSearch ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setUseSemanticSearch(true);
|
||||||
|
setProfileSearchQuery('');
|
||||||
|
}}
|
||||||
|
title={language === 'nl' ? 'Zoek alle profielen (vector database)' : 'Search all profiles (vector database)'}
|
||||||
|
>
|
||||||
|
<Database size={14} />
|
||||||
|
<span>{language === 'nl' ? 'Alles' : 'All'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Field Filter (only for semantic search) */}
|
||||||
|
{useSemanticSearch && (
|
||||||
|
<div className="search-field-filter">
|
||||||
|
<select
|
||||||
|
value={semanticSearchField}
|
||||||
|
onChange={(e) => setSemanticSearchField(e.target.value as SearchField)}
|
||||||
|
className="field-select"
|
||||||
|
>
|
||||||
|
<option value="all">{language === 'nl' ? 'Alle velden' : 'All fields'}</option>
|
||||||
|
<option value="name">{language === 'nl' ? 'Naam' : 'Name'}</option>
|
||||||
|
<option value="email">{language === 'nl' ? 'E-mail' : 'Email'}</option>
|
||||||
|
<option value="domain">{language === 'nl' ? 'Domein' : 'Domain'}</option>
|
||||||
|
<option value="birth_year">{language === 'nl' ? 'Geboortejaar' : 'Birth Year'}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search Input */}
|
||||||
|
<div className="profile-search">
|
||||||
|
<Search size={16} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={useSemanticSearch
|
||||||
|
? (language === 'nl' ? 'Zoek in alle profielen...' : 'Search all profiles...')
|
||||||
|
: (language === 'nl' ? 'Filter op naam of domein...' : 'Filter by name or domain...')
|
||||||
|
}
|
||||||
|
value={useSemanticSearch ? semanticQuery : profileSearchQuery}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (useSemanticSearch) {
|
||||||
|
setSemanticQuery(e.target.value);
|
||||||
|
} else {
|
||||||
|
setProfileSearchQuery(e.target.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{(useSemanticSearch ? semanticQuery : profileSearchQuery) && (
|
||||||
|
<button
|
||||||
|
className="clear-search"
|
||||||
|
onClick={() => {
|
||||||
|
if (useSemanticSearch) {
|
||||||
|
clearSemanticSearch();
|
||||||
|
} else {
|
||||||
|
setProfileSearchQuery('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={language === 'nl' ? 'Wissen' : 'Clear'}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{semanticSearching && (
|
||||||
|
<Loader2 className="animate-spin search-spinner" size={14} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search Stats (semantic mode only) */}
|
||||||
|
{useSemanticSearch && semanticQuery && !semanticSearching && semanticResultCount > 0 && (
|
||||||
|
<div className="search-stats">
|
||||||
|
<span>{semanticResultCount} {language === 'nl' ? 'resultaten' : 'results'}</span>
|
||||||
|
{semanticQueryTime && <span className="query-time">({semanticQueryTime.toFixed(0)}ms)</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Semantic Search Error */}
|
||||||
|
{semanticError && (
|
||||||
|
<div className="search-error">
|
||||||
|
<AlertCircle size={14} />
|
||||||
|
<span>{semanticError}</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{/* Semantic Search Results */}
|
||||||
|
{useSemanticSearch && semanticQuery && !semanticSearching && semanticResults.length > 0 ? (
|
||||||
|
<ul className="profile-list semantic-results">
|
||||||
|
{semanticResults.map((result, idx) => (
|
||||||
|
<li
|
||||||
|
key={result.ppid || `semantic-${idx}`}
|
||||||
|
className="profile-item semantic-result"
|
||||||
|
onClick={() => {
|
||||||
|
// If the result has a ppid, try to fetch that profile
|
||||||
|
if (result.ppid) {
|
||||||
|
fetchProfileDetail(result.ppid);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="profile-item-header">
|
||||||
|
<User size={16} />
|
||||||
|
<span className="profile-name">{result.name}</span>
|
||||||
|
{result.score && (
|
||||||
|
<span className="score-badge" title="Match score">
|
||||||
|
{(result.score * 100).toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="profile-item-meta">
|
||||||
|
{result.headline && (
|
||||||
|
<span className="headline-text" title={result.headline}>
|
||||||
|
{result.headline.length > 40 ? result.headline.slice(0, 40) + '...' : result.headline}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{result.custodian_name && (
|
||||||
|
<span className="custodian-badge">
|
||||||
|
<Building2 size={12} />
|
||||||
|
{result.custodian_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{result.location && (
|
||||||
|
<span className="location-badge">
|
||||||
|
<Globe size={12} />
|
||||||
|
{result.location}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : useSemanticSearch && semanticQuery && !semanticSearching && semanticResults.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<Search size={32} />
|
||||||
|
<span>{language === 'nl' ? 'Geen resultaten gevonden' : 'No results found'}</span>
|
||||||
|
</div>
|
||||||
|
) : loading ? (
|
||||||
<div className="loading-state">
|
<div className="loading-state">
|
||||||
<Loader2 className="animate-spin" size={24} />
|
<Loader2 className="animate-spin" size={24} />
|
||||||
<span>{t('loading')}</span>
|
<span>{t('loading')}</span>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"generated": "2026-01-14T21:38:51.740Z",
|
"generated": "2026-01-14T21:57:09.847Z",
|
||||||
"schemaRoot": "/schemas/20251121/linkml",
|
"schemaRoot": "/schemas/20251121/linkml",
|
||||||
"totalFiles": 3026,
|
"totalFiles": 3026,
|
||||||
"categoryCounts": {
|
"categoryCounts": {
|
||||||
|
|
|
||||||
|
|
@ -143,7 +143,9 @@ imports:
|
||||||
- ../slots/url
|
- ../slots/url
|
||||||
- ../slots/validation_status
|
- ../slots/validation_status
|
||||||
- ../slots/wikidata
|
- ../slots/wikidata
|
||||||
- ../slots/wikidata_class
|
# REMOVED: ../slots/wikidata_class - migrated to is_or_was_instance_of with WikiDataEntry (2026-01-14, Rule 53)
|
||||||
|
- ../slots/is_or_was_instance_of
|
||||||
|
- ./WikiDataEntry
|
||||||
- ../slots/has_or_had_restriction
|
- ../slots/has_or_had_restriction
|
||||||
- ./Restriction
|
- ./Restriction
|
||||||
- ./FindingAid
|
- ./FindingAid
|
||||||
|
|
@ -268,7 +270,8 @@ classes:
|
||||||
- url
|
- url
|
||||||
- temporal_extent # was: valid_from + valid_to - migrated per Rule 53
|
- temporal_extent # was: valid_from + valid_to - migrated per Rule 53
|
||||||
- has_or_had_web_claim
|
- has_or_had_web_claim
|
||||||
- wikidata_class
|
# REMOVED: wikidata_class - migrated to is_or_was_instance_of with WikiDataEntry (2026-01-14, Rule 53)
|
||||||
|
- is_or_was_instance_of
|
||||||
slot_usage:
|
slot_usage:
|
||||||
id:
|
id:
|
||||||
identifier: true
|
identifier: true
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,9 @@ imports:
|
||||||
# REMOVED: ../slots/verified_by - migrated to is_or_was_verified_by with Verifier (2026-01-14, Rule 53)
|
# REMOVED: ../slots/verified_by - migrated to is_or_was_verified_by with Verifier (2026-01-14, Rule 53)
|
||||||
- ../slots/is_or_was_verified_by
|
- ../slots/is_or_was_verified_by
|
||||||
- ./Verifier
|
- ./Verifier
|
||||||
- ../slots/word_count
|
# REMOVED: ../slots/word_count - migrated to has_or_had_quantity with WordCount (2026-01-14, Rule 53)
|
||||||
|
- ../slots/has_or_had_quantity
|
||||||
|
- ./WordCount
|
||||||
- ./SpecificityAnnotation
|
- ./SpecificityAnnotation
|
||||||
- ./TemplateSpecificityScores
|
- ./TemplateSpecificityScores
|
||||||
- ../enums/GenerationMethodEnum
|
- ../enums/GenerationMethodEnum
|
||||||
|
|
@ -89,7 +91,8 @@ classes:
|
||||||
- verification_date
|
- verification_date
|
||||||
# REMOVED: verified_by - migrated to is_or_was_verified_by with Verifier (2026-01-14, Rule 53)
|
# REMOVED: verified_by - migrated to is_or_was_verified_by with Verifier (2026-01-14, Rule 53)
|
||||||
- is_or_was_verified_by
|
- is_or_was_verified_by
|
||||||
- word_count
|
# REMOVED: word_count - migrated to has_or_had_quantity with WordCount (2026-01-14, Rule 53)
|
||||||
|
- has_or_had_quantity
|
||||||
slot_usage:
|
slot_usage:
|
||||||
source_video:
|
source_video:
|
||||||
range: string
|
range: string
|
||||||
|
|
@ -206,12 +209,26 @@ classes:
|
||||||
examples:
|
examples:
|
||||||
- value: 45.3
|
- value: 45.3
|
||||||
description: Processed in 45.3 seconds
|
description: Processed in 45.3 seconds
|
||||||
word_count:
|
# DEPRECATED: word_count - migrated to has_or_had_quantity with WordCount (2026-01-14, Rule 53)
|
||||||
range: integer
|
# word_count:
|
||||||
|
# range: integer
|
||||||
|
# required: false
|
||||||
|
# minimum_value: 0
|
||||||
|
# examples:
|
||||||
|
# - value: 1523
|
||||||
|
# description: 1,523 words in transcript
|
||||||
|
has_or_had_quantity:
|
||||||
|
range: WordCount
|
||||||
required: false
|
required: false
|
||||||
minimum_value: 0
|
inlined: true
|
||||||
|
description: |
|
||||||
|
Word count in the transcript.
|
||||||
|
MIGRATED from word_count slot (2026-01-14) per Rule 53.
|
||||||
|
|
||||||
|
Uses WordCount class for structured quantity with value.
|
||||||
examples:
|
examples:
|
||||||
- value: 1523
|
- value: |
|
||||||
|
value: 1523
|
||||||
description: 1,523 words in transcript
|
description: 1,523 words in transcript
|
||||||
character_count:
|
character_count:
|
||||||
range: integer
|
range: integer
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,12 @@ imports:
|
||||||
# REMOVED: ../slots/wikidata_entity_id - migrated to has_or_had_identifier with WikiDataIdentifier (2026-01-14, Rule 53)
|
# REMOVED: ../slots/wikidata_entity_id - migrated to has_or_had_identifier with WikiDataIdentifier (2026-01-14, Rule 53)
|
||||||
- ../slots/has_or_had_identifier
|
- ../slots/has_or_had_identifier
|
||||||
- ./WikiDataIdentifier
|
- ./WikiDataIdentifier
|
||||||
- ../slots/wikidata_entity_label
|
# REMOVED: ../slots/wikidata_entity_label - migrated to has_or_had_label with Label (2026-01-14, Rule 53)
|
||||||
- ../slots/wikidata_mapping_rationale
|
- ../slots/has_or_had_label
|
||||||
|
- ./Label
|
||||||
|
# REMOVED: ../slots/wikidata_mapping_rationale - migrated to has_or_had_rationale with Rationale (2026-01-14, Rule 53)
|
||||||
|
- ../slots/has_or_had_rationale
|
||||||
|
- ./Rationale
|
||||||
- ../slots/has_or_had_type
|
- ../slots/has_or_had_type
|
||||||
classes:
|
classes:
|
||||||
WikidataAlignment:
|
WikidataAlignment:
|
||||||
|
|
@ -26,9 +30,11 @@ classes:
|
||||||
slots:
|
slots:
|
||||||
# REMOVED: wikidata_entity_id - migrated to has_or_had_identifier with WikiDataIdentifier (2026-01-14, Rule 53)
|
# REMOVED: wikidata_entity_id - migrated to has_or_had_identifier with WikiDataIdentifier (2026-01-14, Rule 53)
|
||||||
- has_or_had_identifier
|
- has_or_had_identifier
|
||||||
- wikidata_entity_label
|
# REMOVED: wikidata_entity_label - migrated to has_or_had_label with Label (2026-01-14, Rule 53)
|
||||||
|
- has_or_had_label
|
||||||
- has_or_had_type
|
- has_or_had_type
|
||||||
- wikidata_mapping_rationale
|
# REMOVED: wikidata_mapping_rationale - migrated to has_or_had_rationale with Rationale (2026-01-14, Rule 53)
|
||||||
|
- has_or_had_rationale
|
||||||
slot_usage:
|
slot_usage:
|
||||||
has_or_had_identifier:
|
has_or_had_identifier:
|
||||||
range: WikiDataIdentifier
|
range: WikiDataIdentifier
|
||||||
|
|
@ -42,6 +48,29 @@ classes:
|
||||||
qid: Q27032435
|
qid: Q27032435
|
||||||
label: "academic archive"
|
label: "academic archive"
|
||||||
description: Wikidata Q-number with optional label
|
description: Wikidata Q-number with optional label
|
||||||
|
has_or_had_label:
|
||||||
|
range: Label
|
||||||
|
description: |
|
||||||
|
Human-readable label for the Wikidata entity.
|
||||||
|
MIGRATED from wikidata_entity_label slot (2026-01-14) per Rule 53.
|
||||||
|
|
||||||
|
Uses Label class for structured label with language code.
|
||||||
|
examples:
|
||||||
|
- value: |
|
||||||
|
label_value: "academic archive"
|
||||||
|
language_code: "en"
|
||||||
|
description: English label for the Wikidata entity
|
||||||
|
has_or_had_rationale:
|
||||||
|
range: Rationale
|
||||||
|
description: |
|
||||||
|
Rationale for the mapping between local class and Wikidata entity.
|
||||||
|
MIGRATED from wikidata_mapping_rationale slot (2026-01-14) per Rule 53.
|
||||||
|
|
||||||
|
Uses Rationale class for structured rationale text.
|
||||||
|
examples:
|
||||||
|
- value: |
|
||||||
|
rationale_text: "AcademicArchive is semantically equivalent to Q27032435"
|
||||||
|
description: Mapping rationale
|
||||||
has_or_had_type:
|
has_or_had_type:
|
||||||
range: MappingType
|
range: MappingType
|
||||||
description: |
|
description: |
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue