What changed, and why

The contact step has a name box with autocomplete. It was deliberately unfiltered — a raw Wikipedia title search — so it surfaced films, places and concepts ("Cleopatra (1963 film)", "Death of Cleopatra", "Mongolia") right alongside people. But the contact gate (#72 / #73) only reaches a real, deceased person, so previewing a non-person invited a pick that gets refused at send.

This PR adds a people filter. After the title search returns, one batched Wikidata wbgetentities call classifies the matches and keeps a result only when Wikidata calls it a person: **instance-of human (P31Q5), or it has a recorded death (P570)**. Everything else is dropped.

Blast radius is small and server-side: the logic lives in one util (server/utils/figure-search.ts); the API route and the picker component change only comments. The FigureSearchResult shape the client renders is untouched, so the combobox needs no changes. Crucially, the #58 rate-limit hardening on the search path is preserved — the filter is layered on top and degrades by falling open, never by blanking a working search.

The search body: filter, fall open, cache only when settled

searchFigures keeps its #58 shape exactly: a too-short query short-circuits, the cache is checked, the title search runs with one spaced retry, and a search that never answers returns { results: [], transient: true } so the combobox retries rather than reading weather as "no matches".

The new block is the people filter (lines 224–239). It only runs when the search returned results — an empty answer skips the lookup, so there's no wasted request. The interesting decision is the failure handling: if the filter lookup blips (people === null), the code does not blank the search. It sets filterTransient and falls open, returning the unfiltered matches with transient: false, because there genuinely are results to show. The cache write at the end fires only when neither stage was transient, so a fall-open is never cached and the next keystroke re-attempts the filter.

searchFigures: the search path is unchanged; the filter is the 224–239 block.

server/utils/figure-search.ts · 255 lines
server/utils/figure-search.ts255 lines · TypeScript
⋯ 206 lines hidden (lines 1–206)
1/**
2 * Name autocomplete for the contact step — Wikipedia REST title search (the
3 * prefix-matching endpoint built for exactly this), key-less like the grounding
4 * pipeline. Never throws.
5 *
6 * People only (issue #74): raw title search surfaces films, places and concepts
7 * ("Cleopatra (1963 film)", "Death of Cleopatra", "Mongolia"), and the contact
8 * gate (#72/#73) only reaches a real, deceased person — so previewing non-people
9 * invites a pick that's refused at send. After the search returns, ONE batched
10 * Wikidata call (`wbgetentities` over the result titles) keeps a result when it
11 * looks like a *person*: instance-of human (P31 → Q5), OR it carries a recorded
12 * death (P570).
13 *
14 * Why the death clause, and why death and not birth:
15 * - It catches genuinely contactable figures that are NOT tagged Q5 — Moses,
16 * Abraham, King Arthur and other biblical / legendary / mythological figures are
17 * typed "biblical figure" or "legendary figure", yet the gate accepts them (they
18 * resolve and carry a death date) and the lifespan estimator (#31) is explicitly
19 * built to date the likes of "King Arthur, Laozi". A bare Q5 test wrongly hides
20 * them; keying on death keeps them.
21 * - Death, not birth, because the gate's floor IS death (#72). A non-human entity
22 * that has only a birth date is exactly a fictional character with an in-universe
23 * birthday — Sherlock Holmes, Harry Potter — whom the gate refuses as "not
24 * deceased". Previewing them would just reproduce the confusing pick. (A birth
25 * clause would also never WIDEN coverage: the gate dates the death-less only via
26 * the #31 bridge, which fires only when there's NO birth year — so a birth date
27 * can't be the thing that makes a figure contactable.) Real LIVING people still
28 * appear, on the Q5 clause, and the dossier explains the deceased-only floor.
29 *
30 * Relationship to the gate (stated honestly — it is NOT a perfect superset): the
31 * gate is `resolved && died`, with no instance-of check, where `died` can come from
32 * Wikidata P570 OR the #31 AI bridge. This filter keeps everyone the gate can reach
33 * via a Wikidata death (they have P570), plus all humans (living ones included —
34 * looser than the gate there, by design). The one under-coverage gap is a figure
35 * datable ONLY by the AI bridge AND not tagged Q5 — a rare, obscure case; it won't
36 * be suggested, but the gate stays the authoritative backstop and the full name can
37 * still be typed and sent. A famous dated animal (a racehorse, say) is kept — it's
38 * datable and the gate would accept it too. We do NOT filter on the death's *value*
39 * (the safely-historical floor): that's the gate's job, and judging it here would
40 * need the per-result grounding the batched call is built to avoid.
41 *
42 * Failure honesty (the lesson of issue #31, applied to search): a rate-limit 429
43 * or timeout is weather, not "no matches". A failed search gets one spaced retry,
44 * and if that fails too the outcome says `transient: true` instead of posing as
45 * an empty result — so the combobox can quietly try again rather than silently
46 * never opening. The people-filter is layered ON TOP without regressing this
47 * (#58): a search that succeeded but whose filter lookup blips falls OPEN — the
48 * unfiltered matches are shown (the description line still tells the pharaoh from
49 * the film) rather than blanking a working autocomplete. Only definitive answers
50 * — search AND filter both settled — are cached.
51 */
52import { pickClaimYear, safeUrl, WIKI_HEADERS } from './figure-grounding'
53 
54export interface FigureSearchResult {
55 name: string
56 description?: string
57 thumbnail?: string
59 
60export interface FigureSearchOutcome {
61 results: FigureSearchResult[]
62 /** True when Wikipedia couldn't be reached (429, timeout, network): the empty
63 * answer is weather, and the caller may retry soon. Never set on a real
64 * "no matches" answer. */
65 transient: boolean
67 
68/** Cache: queries repeat heavily while typing (and across players). Capped so a
69 * crawl of unique queries can't grow it without bound (the grounding cache's
70 * known gap — not repeated here). */
71const CACHE_TTL_MS = 60 * 60 * 1000
72const CACHE_MAX_ENTRIES = 500
73const cache = new Map<string, { value: FigureSearchResult[]; expires: number }>()
74 
75const RESULT_LIMIT = 5
76const SEARCH_RETRY_DELAY_MS = 250
77 
78/** Wikidata properties the people test reads: "instance of" (P31, → human Q5) and
79 * date of death (P570). Q5 OR a recorded death marks a person (see the file header
80 * for why death — not birth — is the second clause). */
81const WIKIDATA_INSTANCE_OF = 'P31'
82const WIKIDATA_HUMAN = 'Q5'
83const WIKIDATA_DEATH = 'P570'
84 
85/** The slice of the Wikipedia REST v1 title-search response we read. */
86interface WikiTitleSearch {
87 pages?: Array<{
88 title?: string
89 description?: string
90 thumbnail?: { url?: string } | null
91 }>
93 
94/** Wikipedia thumbnails arrive protocol-relative ("//upload.wikimedia.org/…"). */
95function absoluteThumb(url?: string): string | undefined {
96 if (!url) return undefined
97 return safeUrl(url.startsWith('//') ? `https:${url}` : url)
99 
100/** One attempt against the title-search endpoint; null means it didn't answer. */
101async function fetchSearch(query: string): Promise<FigureSearchResult[] | null> {
102 try {
103 const res = await fetch(
104 `https://en.wikipedia.org/w/rest.php/v1/search/title?q=${encodeURIComponent(query)}&limit=${RESULT_LIMIT}`,
105 { headers: WIKI_HEADERS, signal: AbortSignal.timeout(4000) }
106 )
107 if (!res.ok) return null
108 const data = (await res.json()) as WikiTitleSearch
109 return (data.pages ?? [])
110 .filter(p => typeof p.title === 'string' && p.title.length > 0)
111 .slice(0, RESULT_LIMIT)
112 .map(p => ({
113 name: p.title as string,
114 description: p.description || undefined,
115 thumbnail: absoluteThumb(p.thumbnail?.url)
116 }))
117 } catch {
118 return null
119 }
121 
122/** The slice of one Wikidata claim the people-filter reads: an item value (P31's
123 * `id`) or a time value (P570's `time`). Shaped to be accepted by the grounding
124 * module's `pickClaimYear` (which reads `rank` / `snaktype` / `time`), so the
125 * "usable date" rule stays single-sourced. */
126interface WikidataClaimSlice {
127 rank?: string
128 mainsnak?: {
129 snaktype?: string
130 datavalue?: { value?: { id?: string; time?: string; precision?: number } }
131 }
133 
134/** The slice of the Wikidata `wbgetentities` response the people-filter reads:
135 * per entity, its claims (P31 / P570) and its enwiki sitelink title (which maps
136 * the entity back to the search result it came from). */
137interface WikidataPeopleEntities {
138 entities?: Record<
139 string,
140 {
141 claims?: Record<string, WikidataClaimSlice[]>
142 sitelinks?: { enwiki?: { title?: string } }
143 }
144 >
146 
147/** Match a REST search title to a Wikidata sitelink title. Both are canonical
148 * enwiki titles, so this is belt-and-suspenders against underscore/space/case
149 * drift rather than a real transform. */
150function titleKey(title: string): string {
151 return title.replace(/_/g, ' ').trim().toLowerCase()
153 
154/** One attempt at the batched Wikidata lookup; null means it didn't answer (so the
155 * caller can fall open rather than blank a working search). `props=claims` carries
156 * P31 / P570; `sitelinks` + `sitefilter=enwiki` carry just the enwiki title back
157 * per entity so each result can be matched to its claims. (No `normalize=1`:
158 * it's only valid for a single title, and this call batches several.) */
159async function fetchInstanceClaims(titles: string[]): Promise<WikidataPeopleEntities['entities'] | null> {
160 try {
161 const url =
162 'https://www.wikidata.org/w/api.php?action=wbgetentities' +
163 `&sites=enwiki&titles=${encodeURIComponent(titles.join('|'))}` +
164 '&props=claims%7Csitelinks&sitefilter=enwiki&languages=en&format=json&origin=*'
165 const res = await fetch(url, { headers: WIKI_HEADERS, signal: AbortSignal.timeout(4000) })
166 if (!res.ok) return null
167 const data = (await res.json()) as WikidataPeopleEntities
168 return data.entities ?? null
169 } catch {
170 return null
171 }
173 
174/** Does this entity look like a PERSON? Instance-of human (P31 → Q5), OR it has a
175 * recorded death (P570) — the clause that catches biblical / legendary / mythological
176 * figures that aren't tagged Q5 (Moses, King Arthur) but are contactable. (Death,
177 * not birth: a birth-only non-human is a fictional character the gate would refuse;
178 * see the file header.) `pickClaimYear` (reused from grounding) applies the same
179 * usable-claim rule as the dossier: non-deprecated, real `value` snak, has a time. */
180function isPerson(claims?: Record<string, WikidataClaimSlice[]>): boolean {
181 const human = (claims?.[WIKIDATA_INSTANCE_OF] ?? []).some(
182 c => c.mainsnak?.snaktype === 'value' && c.mainsnak.datavalue?.value?.id === WIKIDATA_HUMAN
183 )
184 return human || !!pickClaimYear(claims?.[WIKIDATA_DEATH])
186 
187/** Which of these article titles are PEOPLE? Returns a set of the person titles
188 * (normalized via `titleKey`), or null when Wikidata never answered — same
189 * one-spaced-retry-on-a-blip pattern the grounding lookup uses, then fall open.
190 * A title with no Wikidata item (or one that isn't a person) isn't in the set. */
191async function fetchPeopleTitles(titles: string[]): Promise<Set<string> | null> {
192 let entities = await fetchInstanceClaims(titles)
193 if (!entities) {
194 await new Promise(resolve => setTimeout(resolve, SEARCH_RETRY_DELAY_MS))
195 entities = await fetchInstanceClaims(titles)
196 }
197 if (!entities) return null
198 
199 const people = new Set<string>()
200 for (const entity of Object.values(entities)) {
201 const title = entity.sitelinks?.enwiki?.title
202 if (title && isPerson(entity.claims)) people.add(titleKey(title))
203 }
204 return people
206 
207export async function searchFigures(rawQuery: string): Promise<FigureSearchOutcome> {
208 const query = (rawQuery || '').trim()
209 if (query.length < 2) return { results: [], transient: false }
210 
211 const key = query.toLowerCase()
212 const hit = cache.get(key)
213 if (hit && hit.expires > Date.now()) return { results: hit.value, transient: false }
214 
215 let results = await fetchSearch(query)
216 if (results === null) {
217 // Rate-limit blips are the dominant real-world failure here (shared egress
218 // IP, plus the suggester's own wiki bursts) — one spaced retry rescues most.
219 await new Promise(resolve => setTimeout(resolve, SEARCH_RETRY_DELAY_MS))
220 results = await fetchSearch(query)
221 }
222 if (results === null) return { results: [], transient: true }
223 
224 // People filter (#74): keep only results Wikidata calls a person (P31 → Q5, or a
225 // recorded death). An empty search answer skips the lookup (nothing to classify,
226 // no wasted call).
227 let filterTransient = false
228 if (results.length > 0) {
229 const people = await fetchPeopleTitles(results.map(r => r.name))
230 if (people === null) {
231 // The search SUCCEEDED; only the filter hit weather. Fall open with the
232 // unfiltered matches rather than blanking a working autocomplete (#58) —
233 // the description line still tells the pharaoh from the film, and the
234 // contact gate (#72/#73) is the authoritative backstop. Don't cache it.
235 filterTransient = true
236 } else {
237 results = results.filter(r => people.has(titleKey(r.name)))
238 }
239 }
240 
241 // Cache DEFINITIVE ANSWERS only — search AND filter both settled. A timeout or a
242 // Wikipedia 5xx (search) must not blank this query for an hour, and neither must a
243 // filter blip we fell open on (the next keystroke should get a clean filter). A
244 // legitimate "no people match" empty result IS an answer and caches normally.
245 if (!filterTransient) {
246 if (cache.size >= CACHE_MAX_ENTRIES) {
247 // Drop the oldest entry (Map preserves insertion order) — a tiny, boring
248 // eviction that keeps the cap honest without LRU machinery.
249 const oldest = cache.keys().next().value
250 if (oldest !== undefined) cache.delete(oldest)
251 }
252 cache.set(key, { value: results, expires: Date.now() + CACHE_TTL_MS })
253 }
254 return { results, transient: false }

The predicate: human, or has a recorded death

isPerson is the heart of the change, and it's deliberately two clauses. A result is kept if its Wikidata entity is instance-of human (P31Q5), or it has a usable death date (P570). The death check reuses pickClaimYear from the grounding module, so "usable" means exactly what the dossier means: a non-deprecated claim with a real value snak that carries a time.

Q5 OR a usable P570 death — the people test.

server/utils/figure-search.ts · 255 lines
server/utils/figure-search.ts255 lines · TypeScript
⋯ 179 lines hidden (lines 1–179)
1/**
2 * Name autocomplete for the contact step — Wikipedia REST title search (the
3 * prefix-matching endpoint built for exactly this), key-less like the grounding
4 * pipeline. Never throws.
5 *
6 * People only (issue #74): raw title search surfaces films, places and concepts
7 * ("Cleopatra (1963 film)", "Death of Cleopatra", "Mongolia"), and the contact
8 * gate (#72/#73) only reaches a real, deceased person — so previewing non-people
9 * invites a pick that's refused at send. After the search returns, ONE batched
10 * Wikidata call (`wbgetentities` over the result titles) keeps a result when it
11 * looks like a *person*: instance-of human (P31 → Q5), OR it carries a recorded
12 * death (P570).
13 *
14 * Why the death clause, and why death and not birth:
15 * - It catches genuinely contactable figures that are NOT tagged Q5 — Moses,
16 * Abraham, King Arthur and other biblical / legendary / mythological figures are
17 * typed "biblical figure" or "legendary figure", yet the gate accepts them (they
18 * resolve and carry a death date) and the lifespan estimator (#31) is explicitly
19 * built to date the likes of "King Arthur, Laozi". A bare Q5 test wrongly hides
20 * them; keying on death keeps them.
21 * - Death, not birth, because the gate's floor IS death (#72). A non-human entity
22 * that has only a birth date is exactly a fictional character with an in-universe
23 * birthday — Sherlock Holmes, Harry Potter — whom the gate refuses as "not
24 * deceased". Previewing them would just reproduce the confusing pick. (A birth
25 * clause would also never WIDEN coverage: the gate dates the death-less only via
26 * the #31 bridge, which fires only when there's NO birth year — so a birth date
27 * can't be the thing that makes a figure contactable.) Real LIVING people still
28 * appear, on the Q5 clause, and the dossier explains the deceased-only floor.
29 *
30 * Relationship to the gate (stated honestly — it is NOT a perfect superset): the
31 * gate is `resolved && died`, with no instance-of check, where `died` can come from
32 * Wikidata P570 OR the #31 AI bridge. This filter keeps everyone the gate can reach
33 * via a Wikidata death (they have P570), plus all humans (living ones included —
34 * looser than the gate there, by design). The one under-coverage gap is a figure
35 * datable ONLY by the AI bridge AND not tagged Q5 — a rare, obscure case; it won't
36 * be suggested, but the gate stays the authoritative backstop and the full name can
37 * still be typed and sent. A famous dated animal (a racehorse, say) is kept — it's
38 * datable and the gate would accept it too. We do NOT filter on the death's *value*
39 * (the safely-historical floor): that's the gate's job, and judging it here would
40 * need the per-result grounding the batched call is built to avoid.
41 *
42 * Failure honesty (the lesson of issue #31, applied to search): a rate-limit 429
43 * or timeout is weather, not "no matches". A failed search gets one spaced retry,
44 * and if that fails too the outcome says `transient: true` instead of posing as
45 * an empty result — so the combobox can quietly try again rather than silently
46 * never opening. The people-filter is layered ON TOP without regressing this
47 * (#58): a search that succeeded but whose filter lookup blips falls OPEN — the
48 * unfiltered matches are shown (the description line still tells the pharaoh from
49 * the film) rather than blanking a working autocomplete. Only definitive answers
50 * — search AND filter both settled — are cached.
51 */
52import { pickClaimYear, safeUrl, WIKI_HEADERS } from './figure-grounding'
53 
54export interface FigureSearchResult {
55 name: string
56 description?: string
57 thumbnail?: string
59 
60export interface FigureSearchOutcome {
61 results: FigureSearchResult[]
62 /** True when Wikipedia couldn't be reached (429, timeout, network): the empty
63 * answer is weather, and the caller may retry soon. Never set on a real
64 * "no matches" answer. */
65 transient: boolean
67 
68/** Cache: queries repeat heavily while typing (and across players). Capped so a
69 * crawl of unique queries can't grow it without bound (the grounding cache's
70 * known gap — not repeated here). */
71const CACHE_TTL_MS = 60 * 60 * 1000
72const CACHE_MAX_ENTRIES = 500
73const cache = new Map<string, { value: FigureSearchResult[]; expires: number }>()
74 
75const RESULT_LIMIT = 5
76const SEARCH_RETRY_DELAY_MS = 250
77 
78/** Wikidata properties the people test reads: "instance of" (P31, → human Q5) and
79 * date of death (P570). Q5 OR a recorded death marks a person (see the file header
80 * for why death — not birth — is the second clause). */
81const WIKIDATA_INSTANCE_OF = 'P31'
82const WIKIDATA_HUMAN = 'Q5'
83const WIKIDATA_DEATH = 'P570'
84 
85/** The slice of the Wikipedia REST v1 title-search response we read. */
86interface WikiTitleSearch {
87 pages?: Array<{
88 title?: string
89 description?: string
90 thumbnail?: { url?: string } | null
91 }>
93 
94/** Wikipedia thumbnails arrive protocol-relative ("//upload.wikimedia.org/…"). */
95function absoluteThumb(url?: string): string | undefined {
96 if (!url) return undefined
97 return safeUrl(url.startsWith('//') ? `https:${url}` : url)
99 
100/** One attempt against the title-search endpoint; null means it didn't answer. */
101async function fetchSearch(query: string): Promise<FigureSearchResult[] | null> {
102 try {
103 const res = await fetch(
104 `https://en.wikipedia.org/w/rest.php/v1/search/title?q=${encodeURIComponent(query)}&limit=${RESULT_LIMIT}`,
105 { headers: WIKI_HEADERS, signal: AbortSignal.timeout(4000) }
106 )
107 if (!res.ok) return null
108 const data = (await res.json()) as WikiTitleSearch
109 return (data.pages ?? [])
110 .filter(p => typeof p.title === 'string' && p.title.length > 0)
111 .slice(0, RESULT_LIMIT)
112 .map(p => ({
113 name: p.title as string,
114 description: p.description || undefined,
115 thumbnail: absoluteThumb(p.thumbnail?.url)
116 }))
117 } catch {
118 return null
119 }
121 
122/** The slice of one Wikidata claim the people-filter reads: an item value (P31's
123 * `id`) or a time value (P570's `time`). Shaped to be accepted by the grounding
124 * module's `pickClaimYear` (which reads `rank` / `snaktype` / `time`), so the
125 * "usable date" rule stays single-sourced. */
126interface WikidataClaimSlice {
127 rank?: string
128 mainsnak?: {
129 snaktype?: string
130 datavalue?: { value?: { id?: string; time?: string; precision?: number } }
131 }
133 
134/** The slice of the Wikidata `wbgetentities` response the people-filter reads:
135 * per entity, its claims (P31 / P570) and its enwiki sitelink title (which maps
136 * the entity back to the search result it came from). */
137interface WikidataPeopleEntities {
138 entities?: Record<
139 string,
140 {
141 claims?: Record<string, WikidataClaimSlice[]>
142 sitelinks?: { enwiki?: { title?: string } }
143 }
144 >
146 
147/** Match a REST search title to a Wikidata sitelink title. Both are canonical
148 * enwiki titles, so this is belt-and-suspenders against underscore/space/case
149 * drift rather than a real transform. */
150function titleKey(title: string): string {
151 return title.replace(/_/g, ' ').trim().toLowerCase()
153 
154/** One attempt at the batched Wikidata lookup; null means it didn't answer (so the
155 * caller can fall open rather than blank a working search). `props=claims` carries
156 * P31 / P570; `sitelinks` + `sitefilter=enwiki` carry just the enwiki title back
157 * per entity so each result can be matched to its claims. (No `normalize=1`:
158 * it's only valid for a single title, and this call batches several.) */
159async function fetchInstanceClaims(titles: string[]): Promise<WikidataPeopleEntities['entities'] | null> {
160 try {
161 const url =
162 'https://www.wikidata.org/w/api.php?action=wbgetentities' +
163 `&sites=enwiki&titles=${encodeURIComponent(titles.join('|'))}` +
164 '&props=claims%7Csitelinks&sitefilter=enwiki&languages=en&format=json&origin=*'
165 const res = await fetch(url, { headers: WIKI_HEADERS, signal: AbortSignal.timeout(4000) })
166 if (!res.ok) return null
167 const data = (await res.json()) as WikidataPeopleEntities
168 return data.entities ?? null
169 } catch {
170 return null
171 }
173 
174/** Does this entity look like a PERSON? Instance-of human (P31 → Q5), OR it has a
175 * recorded death (P570) — the clause that catches biblical / legendary / mythological
176 * figures that aren't tagged Q5 (Moses, King Arthur) but are contactable. (Death,
177 * not birth: a birth-only non-human is a fictional character the gate would refuse;
178 * see the file header.) `pickClaimYear` (reused from grounding) applies the same
179 * usable-claim rule as the dossier: non-deprecated, real `value` snak, has a time. */
180function isPerson(claims?: Record<string, WikidataClaimSlice[]>): boolean {
181 const human = (claims?.[WIKIDATA_INSTANCE_OF] ?? []).some(
182 c => c.mainsnak?.snaktype === 'value' && c.mainsnak.datavalue?.value?.id === WIKIDATA_HUMAN
183 )
184 return human || !!pickClaimYear(claims?.[WIKIDATA_DEATH])
⋯ 70 lines hidden (lines 186–255)
186 
187/** Which of these article titles are PEOPLE? Returns a set of the person titles
188 * (normalized via `titleKey`), or null when Wikidata never answered — same
189 * one-spaced-retry-on-a-blip pattern the grounding lookup uses, then fall open.
190 * A title with no Wikidata item (or one that isn't a person) isn't in the set. */
191async function fetchPeopleTitles(titles: string[]): Promise<Set<string> | null> {
192 let entities = await fetchInstanceClaims(titles)
193 if (!entities) {
194 await new Promise(resolve => setTimeout(resolve, SEARCH_RETRY_DELAY_MS))
195 entities = await fetchInstanceClaims(titles)
196 }
197 if (!entities) return null
198 
199 const people = new Set<string>()
200 for (const entity of Object.values(entities)) {
201 const title = entity.sitelinks?.enwiki?.title
202 if (title && isPerson(entity.claims)) people.add(titleKey(title))
203 }
204 return people
206 
207export async function searchFigures(rawQuery: string): Promise<FigureSearchOutcome> {
208 const query = (rawQuery || '').trim()
209 if (query.length < 2) return { results: [], transient: false }
210 
211 const key = query.toLowerCase()
212 const hit = cache.get(key)
213 if (hit && hit.expires > Date.now()) return { results: hit.value, transient: false }
214 
215 let results = await fetchSearch(query)
216 if (results === null) {
217 // Rate-limit blips are the dominant real-world failure here (shared egress
218 // IP, plus the suggester's own wiki bursts) — one spaced retry rescues most.
219 await new Promise(resolve => setTimeout(resolve, SEARCH_RETRY_DELAY_MS))
220 results = await fetchSearch(query)
221 }
222 if (results === null) return { results: [], transient: true }
223 
224 // People filter (#74): keep only results Wikidata calls a person (P31 → Q5, or a
225 // recorded death). An empty search answer skips the lookup (nothing to classify,
226 // no wasted call).
227 let filterTransient = false
228 if (results.length > 0) {
229 const people = await fetchPeopleTitles(results.map(r => r.name))
230 if (people === null) {
231 // The search SUCCEEDED; only the filter hit weather. Fall open with the
232 // unfiltered matches rather than blanking a working autocomplete (#58) —
233 // the description line still tells the pharaoh from the film, and the
234 // contact gate (#72/#73) is the authoritative backstop. Don't cache it.
235 filterTransient = true
236 } else {
237 results = results.filter(r => people.has(titleKey(r.name)))
238 }
239 }
240 
241 // Cache DEFINITIVE ANSWERS only — search AND filter both settled. A timeout or a
242 // Wikipedia 5xx (search) must not blank this query for an hour, and neither must a
243 // filter blip we fell open on (the next keystroke should get a clean filter). A
244 // legitimate "no people match" empty result IS an answer and caches normally.
245 if (!filterTransient) {
246 if (cache.size >= CACHE_MAX_ENTRIES) {
247 // Drop the oldest entry (Map preserves insertion order) — a tiny, boring
248 // eviction that keeps the cap honest without LRU machinery.
249 const oldest = cache.keys().next().value
250 if (oldest !== undefined) cache.delete(oldest)
251 }
252 cache.set(key, { value: results, expires: Date.now() + CACHE_TTL_MS })
253 }
254 return { results, transient: false }

The full rationale — including that this is an honest near-superset of the gate, not a perfect one (a figure datable only by the AI bridge and not tagged Q5 won't be suggested) — lives in the module header, so the next reader inherits the reasoning rather than re-deriving it.

The module header: the people-filter rationale and its honest relationship to the gate.

server/utils/figure-search.ts · 255 lines
server/utils/figure-search.ts255 lines · TypeScript
1/**
2 * Name autocomplete for the contact step — Wikipedia REST title search (the
3 * prefix-matching endpoint built for exactly this), key-less like the grounding
4 * pipeline. Never throws.
5 *
6 * People only (issue #74): raw title search surfaces films, places and concepts
7 * ("Cleopatra (1963 film)", "Death of Cleopatra", "Mongolia"), and the contact
8 * gate (#72/#73) only reaches a real, deceased person — so previewing non-people
9 * invites a pick that's refused at send. After the search returns, ONE batched
10 * Wikidata call (`wbgetentities` over the result titles) keeps a result when it
11 * looks like a *person*: instance-of human (P31 → Q5), OR it carries a recorded
12 * death (P570).
13 *
14 * Why the death clause, and why death and not birth:
15 * - It catches genuinely contactable figures that are NOT tagged Q5 — Moses,
16 * Abraham, King Arthur and other biblical / legendary / mythological figures are
17 * typed "biblical figure" or "legendary figure", yet the gate accepts them (they
18 * resolve and carry a death date) and the lifespan estimator (#31) is explicitly
19 * built to date the likes of "King Arthur, Laozi". A bare Q5 test wrongly hides
20 * them; keying on death keeps them.
21 * - Death, not birth, because the gate's floor IS death (#72). A non-human entity
22 * that has only a birth date is exactly a fictional character with an in-universe
23 * birthday — Sherlock Holmes, Harry Potter — whom the gate refuses as "not
24 * deceased". Previewing them would just reproduce the confusing pick. (A birth
25 * clause would also never WIDEN coverage: the gate dates the death-less only via
26 * the #31 bridge, which fires only when there's NO birth year — so a birth date
27 * can't be the thing that makes a figure contactable.) Real LIVING people still
28 * appear, on the Q5 clause, and the dossier explains the deceased-only floor.
29 *
30 * Relationship to the gate (stated honestly — it is NOT a perfect superset): the
⋯ 225 lines hidden (lines 31–255)
31 * gate is `resolved && died`, with no instance-of check, where `died` can come from
32 * Wikidata P570 OR the #31 AI bridge. This filter keeps everyone the gate can reach
33 * via a Wikidata death (they have P570), plus all humans (living ones included —
34 * looser than the gate there, by design). The one under-coverage gap is a figure
35 * datable ONLY by the AI bridge AND not tagged Q5 — a rare, obscure case; it won't
36 * be suggested, but the gate stays the authoritative backstop and the full name can
37 * still be typed and sent. A famous dated animal (a racehorse, say) is kept — it's
38 * datable and the gate would accept it too. We do NOT filter on the death's *value*
39 * (the safely-historical floor): that's the gate's job, and judging it here would
40 * need the per-result grounding the batched call is built to avoid.
41 *
42 * Failure honesty (the lesson of issue #31, applied to search): a rate-limit 429
43 * or timeout is weather, not "no matches". A failed search gets one spaced retry,
44 * and if that fails too the outcome says `transient: true` instead of posing as
45 * an empty result — so the combobox can quietly try again rather than silently
46 * never opening. The people-filter is layered ON TOP without regressing this
47 * (#58): a search that succeeded but whose filter lookup blips falls OPEN — the
48 * unfiltered matches are shown (the description line still tells the pharaoh from
49 * the film) rather than blanking a working autocomplete. Only definitive answers
50 * — search AND filter both settled — are cached.
51 */
52import { pickClaimYear, safeUrl, WIKI_HEADERS } from './figure-grounding'
53 
54export interface FigureSearchResult {
55 name: string
56 description?: string
57 thumbnail?: string
59 
60export interface FigureSearchOutcome {
61 results: FigureSearchResult[]
62 /** True when Wikipedia couldn't be reached (429, timeout, network): the empty
63 * answer is weather, and the caller may retry soon. Never set on a real
64 * "no matches" answer. */
65 transient: boolean
67 
68/** Cache: queries repeat heavily while typing (and across players). Capped so a
69 * crawl of unique queries can't grow it without bound (the grounding cache's
70 * known gap — not repeated here). */
71const CACHE_TTL_MS = 60 * 60 * 1000
72const CACHE_MAX_ENTRIES = 500
73const cache = new Map<string, { value: FigureSearchResult[]; expires: number }>()
74 
75const RESULT_LIMIT = 5
76const SEARCH_RETRY_DELAY_MS = 250
77 
78/** Wikidata properties the people test reads: "instance of" (P31, → human Q5) and
79 * date of death (P570). Q5 OR a recorded death marks a person (see the file header
80 * for why death — not birth — is the second clause). */
81const WIKIDATA_INSTANCE_OF = 'P31'
82const WIKIDATA_HUMAN = 'Q5'
83const WIKIDATA_DEATH = 'P570'
84 
85/** The slice of the Wikipedia REST v1 title-search response we read. */
86interface WikiTitleSearch {
87 pages?: Array<{
88 title?: string
89 description?: string
90 thumbnail?: { url?: string } | null
91 }>
93 
94/** Wikipedia thumbnails arrive protocol-relative ("//upload.wikimedia.org/…"). */
95function absoluteThumb(url?: string): string | undefined {
96 if (!url) return undefined
97 return safeUrl(url.startsWith('//') ? `https:${url}` : url)
99 
100/** One attempt against the title-search endpoint; null means it didn't answer. */
101async function fetchSearch(query: string): Promise<FigureSearchResult[] | null> {
102 try {
103 const res = await fetch(
104 `https://en.wikipedia.org/w/rest.php/v1/search/title?q=${encodeURIComponent(query)}&limit=${RESULT_LIMIT}`,
105 { headers: WIKI_HEADERS, signal: AbortSignal.timeout(4000) }
106 )
107 if (!res.ok) return null
108 const data = (await res.json()) as WikiTitleSearch
109 return (data.pages ?? [])
110 .filter(p => typeof p.title === 'string' && p.title.length > 0)
111 .slice(0, RESULT_LIMIT)
112 .map(p => ({
113 name: p.title as string,
114 description: p.description || undefined,
115 thumbnail: absoluteThumb(p.thumbnail?.url)
116 }))
117 } catch {
118 return null
119 }
121 
122/** The slice of one Wikidata claim the people-filter reads: an item value (P31's
123 * `id`) or a time value (P570's `time`). Shaped to be accepted by the grounding
124 * module's `pickClaimYear` (which reads `rank` / `snaktype` / `time`), so the
125 * "usable date" rule stays single-sourced. */
126interface WikidataClaimSlice {
127 rank?: string
128 mainsnak?: {
129 snaktype?: string
130 datavalue?: { value?: { id?: string; time?: string; precision?: number } }
131 }
133 
134/** The slice of the Wikidata `wbgetentities` response the people-filter reads:
135 * per entity, its claims (P31 / P570) and its enwiki sitelink title (which maps
136 * the entity back to the search result it came from). */
137interface WikidataPeopleEntities {
138 entities?: Record<
139 string,
140 {
141 claims?: Record<string, WikidataClaimSlice[]>
142 sitelinks?: { enwiki?: { title?: string } }
143 }
144 >
146 
147/** Match a REST search title to a Wikidata sitelink title. Both are canonical
148 * enwiki titles, so this is belt-and-suspenders against underscore/space/case
149 * drift rather than a real transform. */
150function titleKey(title: string): string {
151 return title.replace(/_/g, ' ').trim().toLowerCase()
153 
154/** One attempt at the batched Wikidata lookup; null means it didn't answer (so the
155 * caller can fall open rather than blank a working search). `props=claims` carries
156 * P31 / P570; `sitelinks` + `sitefilter=enwiki` carry just the enwiki title back
157 * per entity so each result can be matched to its claims. (No `normalize=1`:
158 * it's only valid for a single title, and this call batches several.) */
159async function fetchInstanceClaims(titles: string[]): Promise<WikidataPeopleEntities['entities'] | null> {
160 try {
161 const url =
162 'https://www.wikidata.org/w/api.php?action=wbgetentities' +
163 `&sites=enwiki&titles=${encodeURIComponent(titles.join('|'))}` +
164 '&props=claims%7Csitelinks&sitefilter=enwiki&languages=en&format=json&origin=*'
165 const res = await fetch(url, { headers: WIKI_HEADERS, signal: AbortSignal.timeout(4000) })
166 if (!res.ok) return null
167 const data = (await res.json()) as WikidataPeopleEntities
168 return data.entities ?? null
169 } catch {
170 return null
171 }
173 
174/** Does this entity look like a PERSON? Instance-of human (P31 → Q5), OR it has a
175 * recorded death (P570) — the clause that catches biblical / legendary / mythological
176 * figures that aren't tagged Q5 (Moses, King Arthur) but are contactable. (Death,
177 * not birth: a birth-only non-human is a fictional character the gate would refuse;
178 * see the file header.) `pickClaimYear` (reused from grounding) applies the same
179 * usable-claim rule as the dossier: non-deprecated, real `value` snak, has a time. */
180function isPerson(claims?: Record<string, WikidataClaimSlice[]>): boolean {
181 const human = (claims?.[WIKIDATA_INSTANCE_OF] ?? []).some(
182 c => c.mainsnak?.snaktype === 'value' && c.mainsnak.datavalue?.value?.id === WIKIDATA_HUMAN
183 )
184 return human || !!pickClaimYear(claims?.[WIKIDATA_DEATH])
186 
187/** Which of these article titles are PEOPLE? Returns a set of the person titles
188 * (normalized via `titleKey`), or null when Wikidata never answered — same
189 * one-spaced-retry-on-a-blip pattern the grounding lookup uses, then fall open.
190 * A title with no Wikidata item (or one that isn't a person) isn't in the set. */
191async function fetchPeopleTitles(titles: string[]): Promise<Set<string> | null> {
192 let entities = await fetchInstanceClaims(titles)
193 if (!entities) {
194 await new Promise(resolve => setTimeout(resolve, SEARCH_RETRY_DELAY_MS))
195 entities = await fetchInstanceClaims(titles)
196 }
197 if (!entities) return null
198 
199 const people = new Set<string>()
200 for (const entity of Object.values(entities)) {
201 const title = entity.sitelinks?.enwiki?.title
202 if (title && isPerson(entity.claims)) people.add(titleKey(title))
203 }
204 return people
206 
207export async function searchFigures(rawQuery: string): Promise<FigureSearchOutcome> {
208 const query = (rawQuery || '').trim()
209 if (query.length < 2) return { results: [], transient: false }
210 
211 const key = query.toLowerCase()
212 const hit = cache.get(key)
213 if (hit && hit.expires > Date.now()) return { results: hit.value, transient: false }
214 
215 let results = await fetchSearch(query)
216 if (results === null) {
217 // Rate-limit blips are the dominant real-world failure here (shared egress
218 // IP, plus the suggester's own wiki bursts) — one spaced retry rescues most.
219 await new Promise(resolve => setTimeout(resolve, SEARCH_RETRY_DELAY_MS))
220 results = await fetchSearch(query)
221 }
222 if (results === null) return { results: [], transient: true }
223 
224 // People filter (#74): keep only results Wikidata calls a person (P31 → Q5, or a
225 // recorded death). An empty search answer skips the lookup (nothing to classify,
226 // no wasted call).
227 let filterTransient = false
228 if (results.length > 0) {
229 const people = await fetchPeopleTitles(results.map(r => r.name))
230 if (people === null) {
231 // The search SUCCEEDED; only the filter hit weather. Fall open with the
232 // unfiltered matches rather than blanking a working autocomplete (#58) —
233 // the description line still tells the pharaoh from the film, and the
234 // contact gate (#72/#73) is the authoritative backstop. Don't cache it.
235 filterTransient = true
236 } else {
237 results = results.filter(r => people.has(titleKey(r.name)))
238 }
239 }
240 
241 // Cache DEFINITIVE ANSWERS only — search AND filter both settled. A timeout or a
242 // Wikipedia 5xx (search) must not blank this query for an hour, and neither must a
243 // filter blip we fell open on (the next keystroke should get a clean filter). A
244 // legitimate "no people match" empty result IS an answer and caches normally.
245 if (!filterTransient) {
246 if (cache.size >= CACHE_MAX_ENTRIES) {
247 // Drop the oldest entry (Map preserves insertion order) — a tiny, boring
248 // eviction that keeps the cap honest without LRU machinery.
249 const oldest = cache.keys().next().value
250 if (oldest !== undefined) cache.delete(oldest)
251 }
252 cache.set(key, { value: results, expires: Date.now() + CACHE_TTL_MS })
253 }
254 return { results, transient: false }

One batched Wikidata call, matched back by title

fetchInstanceClaims is one attempt at the batched lookup. It asks wbgetentities for the result titles in a single request: props=claims carries P31/P570, and sitelinks + sitefilter=enwiki carry each entity's English title back so it can be matched to the search result it came from. normalize=1 is omitted on purpose — it's only valid for a single title, and this call batches several. A non-OK response or a throw returns null (the signal to fall open), never an exception.

The batched request. WIKI_HEADERS is the shared, policy-compliant User-Agent (#58).

server/utils/figure-search.ts · 255 lines
server/utils/figure-search.ts255 lines · TypeScript
⋯ 158 lines hidden (lines 1–158)
1/**
2 * Name autocomplete for the contact step — Wikipedia REST title search (the
3 * prefix-matching endpoint built for exactly this), key-less like the grounding
4 * pipeline. Never throws.
5 *
6 * People only (issue #74): raw title search surfaces films, places and concepts
7 * ("Cleopatra (1963 film)", "Death of Cleopatra", "Mongolia"), and the contact
8 * gate (#72/#73) only reaches a real, deceased person — so previewing non-people
9 * invites a pick that's refused at send. After the search returns, ONE batched
10 * Wikidata call (`wbgetentities` over the result titles) keeps a result when it
11 * looks like a *person*: instance-of human (P31 → Q5), OR it carries a recorded
12 * death (P570).
13 *
14 * Why the death clause, and why death and not birth:
15 * - It catches genuinely contactable figures that are NOT tagged Q5 — Moses,
16 * Abraham, King Arthur and other biblical / legendary / mythological figures are
17 * typed "biblical figure" or "legendary figure", yet the gate accepts them (they
18 * resolve and carry a death date) and the lifespan estimator (#31) is explicitly
19 * built to date the likes of "King Arthur, Laozi". A bare Q5 test wrongly hides
20 * them; keying on death keeps them.
21 * - Death, not birth, because the gate's floor IS death (#72). A non-human entity
22 * that has only a birth date is exactly a fictional character with an in-universe
23 * birthday — Sherlock Holmes, Harry Potter — whom the gate refuses as "not
24 * deceased". Previewing them would just reproduce the confusing pick. (A birth
25 * clause would also never WIDEN coverage: the gate dates the death-less only via
26 * the #31 bridge, which fires only when there's NO birth year — so a birth date
27 * can't be the thing that makes a figure contactable.) Real LIVING people still
28 * appear, on the Q5 clause, and the dossier explains the deceased-only floor.
29 *
30 * Relationship to the gate (stated honestly — it is NOT a perfect superset): the
31 * gate is `resolved && died`, with no instance-of check, where `died` can come from
32 * Wikidata P570 OR the #31 AI bridge. This filter keeps everyone the gate can reach
33 * via a Wikidata death (they have P570), plus all humans (living ones included —
34 * looser than the gate there, by design). The one under-coverage gap is a figure
35 * datable ONLY by the AI bridge AND not tagged Q5 — a rare, obscure case; it won't
36 * be suggested, but the gate stays the authoritative backstop and the full name can
37 * still be typed and sent. A famous dated animal (a racehorse, say) is kept — it's
38 * datable and the gate would accept it too. We do NOT filter on the death's *value*
39 * (the safely-historical floor): that's the gate's job, and judging it here would
40 * need the per-result grounding the batched call is built to avoid.
41 *
42 * Failure honesty (the lesson of issue #31, applied to search): a rate-limit 429
43 * or timeout is weather, not "no matches". A failed search gets one spaced retry,
44 * and if that fails too the outcome says `transient: true` instead of posing as
45 * an empty result — so the combobox can quietly try again rather than silently
46 * never opening. The people-filter is layered ON TOP without regressing this
47 * (#58): a search that succeeded but whose filter lookup blips falls OPEN — the
48 * unfiltered matches are shown (the description line still tells the pharaoh from
49 * the film) rather than blanking a working autocomplete. Only definitive answers
50 * — search AND filter both settled — are cached.
51 */
52import { pickClaimYear, safeUrl, WIKI_HEADERS } from './figure-grounding'
53 
54export interface FigureSearchResult {
55 name: string
56 description?: string
57 thumbnail?: string
59 
60export interface FigureSearchOutcome {
61 results: FigureSearchResult[]
62 /** True when Wikipedia couldn't be reached (429, timeout, network): the empty
63 * answer is weather, and the caller may retry soon. Never set on a real
64 * "no matches" answer. */
65 transient: boolean
67 
68/** Cache: queries repeat heavily while typing (and across players). Capped so a
69 * crawl of unique queries can't grow it without bound (the grounding cache's
70 * known gap — not repeated here). */
71const CACHE_TTL_MS = 60 * 60 * 1000
72const CACHE_MAX_ENTRIES = 500
73const cache = new Map<string, { value: FigureSearchResult[]; expires: number }>()
74 
75const RESULT_LIMIT = 5
76const SEARCH_RETRY_DELAY_MS = 250
77 
78/** Wikidata properties the people test reads: "instance of" (P31, → human Q5) and
79 * date of death (P570). Q5 OR a recorded death marks a person (see the file header
80 * for why death — not birth — is the second clause). */
81const WIKIDATA_INSTANCE_OF = 'P31'
82const WIKIDATA_HUMAN = 'Q5'
83const WIKIDATA_DEATH = 'P570'
84 
85/** The slice of the Wikipedia REST v1 title-search response we read. */
86interface WikiTitleSearch {
87 pages?: Array<{
88 title?: string
89 description?: string
90 thumbnail?: { url?: string } | null
91 }>
93 
94/** Wikipedia thumbnails arrive protocol-relative ("//upload.wikimedia.org/…"). */
95function absoluteThumb(url?: string): string | undefined {
96 if (!url) return undefined
97 return safeUrl(url.startsWith('//') ? `https:${url}` : url)
99 
100/** One attempt against the title-search endpoint; null means it didn't answer. */
101async function fetchSearch(query: string): Promise<FigureSearchResult[] | null> {
102 try {
103 const res = await fetch(
104 `https://en.wikipedia.org/w/rest.php/v1/search/title?q=${encodeURIComponent(query)}&limit=${RESULT_LIMIT}`,
105 { headers: WIKI_HEADERS, signal: AbortSignal.timeout(4000) }
106 )
107 if (!res.ok) return null
108 const data = (await res.json()) as WikiTitleSearch
109 return (data.pages ?? [])
110 .filter(p => typeof p.title === 'string' && p.title.length > 0)
111 .slice(0, RESULT_LIMIT)
112 .map(p => ({
113 name: p.title as string,
114 description: p.description || undefined,
115 thumbnail: absoluteThumb(p.thumbnail?.url)
116 }))
117 } catch {
118 return null
119 }
121 
122/** The slice of one Wikidata claim the people-filter reads: an item value (P31's
123 * `id`) or a time value (P570's `time`). Shaped to be accepted by the grounding
124 * module's `pickClaimYear` (which reads `rank` / `snaktype` / `time`), so the
125 * "usable date" rule stays single-sourced. */
126interface WikidataClaimSlice {
127 rank?: string
128 mainsnak?: {
129 snaktype?: string
130 datavalue?: { value?: { id?: string; time?: string; precision?: number } }
131 }
133 
134/** The slice of the Wikidata `wbgetentities` response the people-filter reads:
135 * per entity, its claims (P31 / P570) and its enwiki sitelink title (which maps
136 * the entity back to the search result it came from). */
137interface WikidataPeopleEntities {
138 entities?: Record<
139 string,
140 {
141 claims?: Record<string, WikidataClaimSlice[]>
142 sitelinks?: { enwiki?: { title?: string } }
143 }
144 >
146 
147/** Match a REST search title to a Wikidata sitelink title. Both are canonical
148 * enwiki titles, so this is belt-and-suspenders against underscore/space/case
149 * drift rather than a real transform. */
150function titleKey(title: string): string {
151 return title.replace(/_/g, ' ').trim().toLowerCase()
153 
154/** One attempt at the batched Wikidata lookup; null means it didn't answer (so the
155 * caller can fall open rather than blank a working search). `props=claims` carries
156 * P31 / P570; `sitelinks` + `sitefilter=enwiki` carry just the enwiki title back
157 * per entity so each result can be matched to its claims. (No `normalize=1`:
158 * it's only valid for a single title, and this call batches several.) */
159async function fetchInstanceClaims(titles: string[]): Promise<WikidataPeopleEntities['entities'] | null> {
160 try {
161 const url =
162 'https://www.wikidata.org/w/api.php?action=wbgetentities' +
163 `&sites=enwiki&titles=${encodeURIComponent(titles.join('|'))}` +
164 '&props=claims%7Csitelinks&sitefilter=enwiki&languages=en&format=json&origin=*'
165 const res = await fetch(url, { headers: WIKI_HEADERS, signal: AbortSignal.timeout(4000) })
166 if (!res.ok) return null
167 const data = (await res.json()) as WikidataPeopleEntities
168 return data.entities ?? null
169 } catch {
170 return null
171 }
173 
174/** Does this entity look like a PERSON? Instance-of human (P31 → Q5), OR it has a
175 * recorded death (P570) — the clause that catches biblical / legendary / mythological
176 * figures that aren't tagged Q5 (Moses, King Arthur) but are contactable. (Death,
177 * not birth: a birth-only non-human is a fictional character the gate would refuse;
⋯ 78 lines hidden (lines 178–255)
178 * see the file header.) `pickClaimYear` (reused from grounding) applies the same
179 * usable-claim rule as the dossier: non-deprecated, real `value` snak, has a time. */
180function isPerson(claims?: Record<string, WikidataClaimSlice[]>): boolean {
181 const human = (claims?.[WIKIDATA_INSTANCE_OF] ?? []).some(
182 c => c.mainsnak?.snaktype === 'value' && c.mainsnak.datavalue?.value?.id === WIKIDATA_HUMAN
183 )
184 return human || !!pickClaimYear(claims?.[WIKIDATA_DEATH])
186 
187/** Which of these article titles are PEOPLE? Returns a set of the person titles
188 * (normalized via `titleKey`), or null when Wikidata never answered — same
189 * one-spaced-retry-on-a-blip pattern the grounding lookup uses, then fall open.
190 * A title with no Wikidata item (or one that isn't a person) isn't in the set. */
191async function fetchPeopleTitles(titles: string[]): Promise<Set<string> | null> {
192 let entities = await fetchInstanceClaims(titles)
193 if (!entities) {
194 await new Promise(resolve => setTimeout(resolve, SEARCH_RETRY_DELAY_MS))
195 entities = await fetchInstanceClaims(titles)
196 }
197 if (!entities) return null
198 
199 const people = new Set<string>()
200 for (const entity of Object.values(entities)) {
201 const title = entity.sitelinks?.enwiki?.title
202 if (title && isPerson(entity.claims)) people.add(titleKey(title))
203 }
204 return people
206 
207export async function searchFigures(rawQuery: string): Promise<FigureSearchOutcome> {
208 const query = (rawQuery || '').trim()
209 if (query.length < 2) return { results: [], transient: false }
210 
211 const key = query.toLowerCase()
212 const hit = cache.get(key)
213 if (hit && hit.expires > Date.now()) return { results: hit.value, transient: false }
214 
215 let results = await fetchSearch(query)
216 if (results === null) {
217 // Rate-limit blips are the dominant real-world failure here (shared egress
218 // IP, plus the suggester's own wiki bursts) — one spaced retry rescues most.
219 await new Promise(resolve => setTimeout(resolve, SEARCH_RETRY_DELAY_MS))
220 results = await fetchSearch(query)
221 }
222 if (results === null) return { results: [], transient: true }
223 
224 // People filter (#74): keep only results Wikidata calls a person (P31 → Q5, or a
225 // recorded death). An empty search answer skips the lookup (nothing to classify,
226 // no wasted call).
227 let filterTransient = false
228 if (results.length > 0) {
229 const people = await fetchPeopleTitles(results.map(r => r.name))
230 if (people === null) {
231 // The search SUCCEEDED; only the filter hit weather. Fall open with the
232 // unfiltered matches rather than blanking a working autocomplete (#58) —
233 // the description line still tells the pharaoh from the film, and the
234 // contact gate (#72/#73) is the authoritative backstop. Don't cache it.
235 filterTransient = true
236 } else {
237 results = results.filter(r => people.has(titleKey(r.name)))
238 }
239 }
240 
241 // Cache DEFINITIVE ANSWERS only — search AND filter both settled. A timeout or a
242 // Wikipedia 5xx (search) must not blank this query for an hour, and neither must a
243 // filter blip we fell open on (the next keystroke should get a clean filter). A
244 // legitimate "no people match" empty result IS an answer and caches normally.
245 if (!filterTransient) {
246 if (cache.size >= CACHE_MAX_ENTRIES) {
247 // Drop the oldest entry (Map preserves insertion order) — a tiny, boring
248 // eviction that keeps the cap honest without LRU machinery.
249 const oldest = cache.keys().next().value
250 if (oldest !== undefined) cache.delete(oldest)
251 }
252 cache.set(key, { value: results, expires: Date.now() + CACHE_TTL_MS })
253 }
254 return { results, transient: false }

fetchPeopleTitles wraps that attempt in the same one-spaced-retry-then-give-up pattern the grounding lookup uses, then builds the set of person titles. Each entity is matched to its result through titleKey, which normalizes underscores, spacing and case on both sides — both are canonical enwiki titles, so this is belt-and-suspenders against cosmetic drift. A title with no Wikidata item, or one that isn't a person, simply never enters the set and is dropped.

titleKey (the matching key) and fetchPeopleTitles (retry, then collect person titles).

server/utils/figure-search.ts · 255 lines
server/utils/figure-search.ts255 lines · TypeScript
⋯ 145 lines hidden (lines 1–145)
1/**
2 * Name autocomplete for the contact step — Wikipedia REST title search (the
3 * prefix-matching endpoint built for exactly this), key-less like the grounding
4 * pipeline. Never throws.
5 *
6 * People only (issue #74): raw title search surfaces films, places and concepts
7 * ("Cleopatra (1963 film)", "Death of Cleopatra", "Mongolia"), and the contact
8 * gate (#72/#73) only reaches a real, deceased person — so previewing non-people
9 * invites a pick that's refused at send. After the search returns, ONE batched
10 * Wikidata call (`wbgetentities` over the result titles) keeps a result when it
11 * looks like a *person*: instance-of human (P31 → Q5), OR it carries a recorded
12 * death (P570).
13 *
14 * Why the death clause, and why death and not birth:
15 * - It catches genuinely contactable figures that are NOT tagged Q5 — Moses,
16 * Abraham, King Arthur and other biblical / legendary / mythological figures are
17 * typed "biblical figure" or "legendary figure", yet the gate accepts them (they
18 * resolve and carry a death date) and the lifespan estimator (#31) is explicitly
19 * built to date the likes of "King Arthur, Laozi". A bare Q5 test wrongly hides
20 * them; keying on death keeps them.
21 * - Death, not birth, because the gate's floor IS death (#72). A non-human entity
22 * that has only a birth date is exactly a fictional character with an in-universe
23 * birthday — Sherlock Holmes, Harry Potter — whom the gate refuses as "not
24 * deceased". Previewing them would just reproduce the confusing pick. (A birth
25 * clause would also never WIDEN coverage: the gate dates the death-less only via
26 * the #31 bridge, which fires only when there's NO birth year — so a birth date
27 * can't be the thing that makes a figure contactable.) Real LIVING people still
28 * appear, on the Q5 clause, and the dossier explains the deceased-only floor.
29 *
30 * Relationship to the gate (stated honestly — it is NOT a perfect superset): the
31 * gate is `resolved && died`, with no instance-of check, where `died` can come from
32 * Wikidata P570 OR the #31 AI bridge. This filter keeps everyone the gate can reach
33 * via a Wikidata death (they have P570), plus all humans (living ones included —
34 * looser than the gate there, by design). The one under-coverage gap is a figure
35 * datable ONLY by the AI bridge AND not tagged Q5 — a rare, obscure case; it won't
36 * be suggested, but the gate stays the authoritative backstop and the full name can
37 * still be typed and sent. A famous dated animal (a racehorse, say) is kept — it's
38 * datable and the gate would accept it too. We do NOT filter on the death's *value*
39 * (the safely-historical floor): that's the gate's job, and judging it here would
40 * need the per-result grounding the batched call is built to avoid.
41 *
42 * Failure honesty (the lesson of issue #31, applied to search): a rate-limit 429
43 * or timeout is weather, not "no matches". A failed search gets one spaced retry,
44 * and if that fails too the outcome says `transient: true` instead of posing as
45 * an empty result — so the combobox can quietly try again rather than silently
46 * never opening. The people-filter is layered ON TOP without regressing this
47 * (#58): a search that succeeded but whose filter lookup blips falls OPEN — the
48 * unfiltered matches are shown (the description line still tells the pharaoh from
49 * the film) rather than blanking a working autocomplete. Only definitive answers
50 * — search AND filter both settled — are cached.
51 */
52import { pickClaimYear, safeUrl, WIKI_HEADERS } from './figure-grounding'
53 
54export interface FigureSearchResult {
55 name: string
56 description?: string
57 thumbnail?: string
59 
60export interface FigureSearchOutcome {
61 results: FigureSearchResult[]
62 /** True when Wikipedia couldn't be reached (429, timeout, network): the empty
63 * answer is weather, and the caller may retry soon. Never set on a real
64 * "no matches" answer. */
65 transient: boolean
67 
68/** Cache: queries repeat heavily while typing (and across players). Capped so a
69 * crawl of unique queries can't grow it without bound (the grounding cache's
70 * known gap — not repeated here). */
71const CACHE_TTL_MS = 60 * 60 * 1000
72const CACHE_MAX_ENTRIES = 500
73const cache = new Map<string, { value: FigureSearchResult[]; expires: number }>()
74 
75const RESULT_LIMIT = 5
76const SEARCH_RETRY_DELAY_MS = 250
77 
78/** Wikidata properties the people test reads: "instance of" (P31, → human Q5) and
79 * date of death (P570). Q5 OR a recorded death marks a person (see the file header
80 * for why death — not birth — is the second clause). */
81const WIKIDATA_INSTANCE_OF = 'P31'
82const WIKIDATA_HUMAN = 'Q5'
83const WIKIDATA_DEATH = 'P570'
84 
85/** The slice of the Wikipedia REST v1 title-search response we read. */
86interface WikiTitleSearch {
87 pages?: Array<{
88 title?: string
89 description?: string
90 thumbnail?: { url?: string } | null
91 }>
93 
94/** Wikipedia thumbnails arrive protocol-relative ("//upload.wikimedia.org/…"). */
95function absoluteThumb(url?: string): string | undefined {
96 if (!url) return undefined
97 return safeUrl(url.startsWith('//') ? `https:${url}` : url)
99 
100/** One attempt against the title-search endpoint; null means it didn't answer. */
101async function fetchSearch(query: string): Promise<FigureSearchResult[] | null> {
102 try {
103 const res = await fetch(
104 `https://en.wikipedia.org/w/rest.php/v1/search/title?q=${encodeURIComponent(query)}&limit=${RESULT_LIMIT}`,
105 { headers: WIKI_HEADERS, signal: AbortSignal.timeout(4000) }
106 )
107 if (!res.ok) return null
108 const data = (await res.json()) as WikiTitleSearch
109 return (data.pages ?? [])
110 .filter(p => typeof p.title === 'string' && p.title.length > 0)
111 .slice(0, RESULT_LIMIT)
112 .map(p => ({
113 name: p.title as string,
114 description: p.description || undefined,
115 thumbnail: absoluteThumb(p.thumbnail?.url)
116 }))
117 } catch {
118 return null
119 }
121 
122/** The slice of one Wikidata claim the people-filter reads: an item value (P31's
123 * `id`) or a time value (P570's `time`). Shaped to be accepted by the grounding
124 * module's `pickClaimYear` (which reads `rank` / `snaktype` / `time`), so the
125 * "usable date" rule stays single-sourced. */
126interface WikidataClaimSlice {
127 rank?: string
128 mainsnak?: {
129 snaktype?: string
130 datavalue?: { value?: { id?: string; time?: string; precision?: number } }
131 }
133 
134/** The slice of the Wikidata `wbgetentities` response the people-filter reads:
135 * per entity, its claims (P31 / P570) and its enwiki sitelink title (which maps
136 * the entity back to the search result it came from). */
137interface WikidataPeopleEntities {
138 entities?: Record<
139 string,
140 {
141 claims?: Record<string, WikidataClaimSlice[]>
142 sitelinks?: { enwiki?: { title?: string } }
143 }
144 >
146 
147/** Match a REST search title to a Wikidata sitelink title. Both are canonical
148 * enwiki titles, so this is belt-and-suspenders against underscore/space/case
149 * drift rather than a real transform. */
150function titleKey(title: string): string {
151 return title.replace(/_/g, ' ').trim().toLowerCase()
⋯ 38 lines hidden (lines 153–190)
153 
154/** One attempt at the batched Wikidata lookup; null means it didn't answer (so the
155 * caller can fall open rather than blank a working search). `props=claims` carries
156 * P31 / P570; `sitelinks` + `sitefilter=enwiki` carry just the enwiki title back
157 * per entity so each result can be matched to its claims. (No `normalize=1`:
158 * it's only valid for a single title, and this call batches several.) */
159async function fetchInstanceClaims(titles: string[]): Promise<WikidataPeopleEntities['entities'] | null> {
160 try {
161 const url =
162 'https://www.wikidata.org/w/api.php?action=wbgetentities' +
163 `&sites=enwiki&titles=${encodeURIComponent(titles.join('|'))}` +
164 '&props=claims%7Csitelinks&sitefilter=enwiki&languages=en&format=json&origin=*'
165 const res = await fetch(url, { headers: WIKI_HEADERS, signal: AbortSignal.timeout(4000) })
166 if (!res.ok) return null
167 const data = (await res.json()) as WikidataPeopleEntities
168 return data.entities ?? null
169 } catch {
170 return null
171 }
173 
174/** Does this entity look like a PERSON? Instance-of human (P31 → Q5), OR it has a
175 * recorded death (P570) — the clause that catches biblical / legendary / mythological
176 * figures that aren't tagged Q5 (Moses, King Arthur) but are contactable. (Death,
177 * not birth: a birth-only non-human is a fictional character the gate would refuse;
178 * see the file header.) `pickClaimYear` (reused from grounding) applies the same
179 * usable-claim rule as the dossier: non-deprecated, real `value` snak, has a time. */
180function isPerson(claims?: Record<string, WikidataClaimSlice[]>): boolean {
181 const human = (claims?.[WIKIDATA_INSTANCE_OF] ?? []).some(
182 c => c.mainsnak?.snaktype === 'value' && c.mainsnak.datavalue?.value?.id === WIKIDATA_HUMAN
183 )
184 return human || !!pickClaimYear(claims?.[WIKIDATA_DEATH])
186 
187/** Which of these article titles are PEOPLE? Returns a set of the person titles
188 * (normalized via `titleKey`), or null when Wikidata never answered — same
189 * one-spaced-retry-on-a-blip pattern the grounding lookup uses, then fall open.
190 * A title with no Wikidata item (or one that isn't a person) isn't in the set. */
191async function fetchPeopleTitles(titles: string[]): Promise<Set<string> | null> {
192 let entities = await fetchInstanceClaims(titles)
193 if (!entities) {
194 await new Promise(resolve => setTimeout(resolve, SEARCH_RETRY_DELAY_MS))
195 entities = await fetchInstanceClaims(titles)
196 }
197 if (!entities) return null
198 
199 const people = new Set<string>()
200 for (const entity of Object.values(entities)) {
201 const title = entity.sitelinks?.enwiki?.title
202 if (title && isPerson(entity.claims)) people.add(titleKey(title))
203 }
204 return people
⋯ 50 lines hidden (lines 206–255)
206 
207export async function searchFigures(rawQuery: string): Promise<FigureSearchOutcome> {
208 const query = (rawQuery || '').trim()
209 if (query.length < 2) return { results: [], transient: false }
210 
211 const key = query.toLowerCase()
212 const hit = cache.get(key)
213 if (hit && hit.expires > Date.now()) return { results: hit.value, transient: false }
214 
215 let results = await fetchSearch(query)
216 if (results === null) {
217 // Rate-limit blips are the dominant real-world failure here (shared egress
218 // IP, plus the suggester's own wiki bursts) — one spaced retry rescues most.
219 await new Promise(resolve => setTimeout(resolve, SEARCH_RETRY_DELAY_MS))
220 results = await fetchSearch(query)
221 }
222 if (results === null) return { results: [], transient: true }
223 
224 // People filter (#74): keep only results Wikidata calls a person (P31 → Q5, or a
225 // recorded death). An empty search answer skips the lookup (nothing to classify,
226 // no wasted call).
227 let filterTransient = false
228 if (results.length > 0) {
229 const people = await fetchPeopleTitles(results.map(r => r.name))
230 if (people === null) {
231 // The search SUCCEEDED; only the filter hit weather. Fall open with the
232 // unfiltered matches rather than blanking a working autocomplete (#58) —
233 // the description line still tells the pharaoh from the film, and the
234 // contact gate (#72/#73) is the authoritative backstop. Don't cache it.
235 filterTransient = true
236 } else {
237 results = results.filter(r => people.has(titleKey(r.name)))
238 }
239 }
240 
241 // Cache DEFINITIVE ANSWERS only — search AND filter both settled. A timeout or a
242 // Wikipedia 5xx (search) must not blank this query for an hour, and neither must a
243 // filter blip we fell open on (the next keystroke should get a clean filter). A
244 // legitimate "no people match" empty result IS an answer and caches normally.
245 if (!filterTransient) {
246 if (cache.size >= CACHE_MAX_ENTRIES) {
247 // Drop the oldest entry (Map preserves insertion order) — a tiny, boring
248 // eviction that keeps the cap honest without LRU machinery.
249 const oldest = cache.keys().next().value
250 if (oldest !== undefined) cache.delete(oldest)
251 }
252 cache.set(key, { value: results, expires: Date.now() + CACHE_TTL_MS })
253 }
254 return { results, transient: false }

Tests: the cases that would catch a regression

The spec mocks fetch and routes by URL, so the search and the Wikidata filter are driven independently. Beyond the core "non-people are filtered" case, several tests exist specifically to pin behaviour a naive change would silently break.

The two predicate-defining cases: a non-Q5 figure with a death is KEPT; a fictional birth-only is DROPPED.

tests/unit/server/utils/figure-search.spec.ts · 449 lines
tests/unit/server/utils/figure-search.spec.ts449 lines · TypeScript
⋯ 161 lines hidden (lines 1–161)
1import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2import { searchFigures } from '../../../../server/utils/figure-search'
3 
4/** Wikipedia REST v1 title-search response, shaped like the real thing. */
5function searchResp(pages: unknown[]) {
6 return { ok: true, json: async () => ({ pages }) } as Response
7}
8 
9/** Wikidata `wbgetentities` response, shaped like the real thing (entities keyed
10 * by qid, each carrying its P31 "instance of" claims + its enwiki sitelink). */
11function wikidataResp(entities: Record<string, unknown>) {
12 return { ok: true, json: async () => ({ entities }) } as Response
14 
15/** Build a Wikidata entities map from per-title specs, mirroring the real shape the
16 * people-filter reads. A title is a PERSON when P31 = Q5 (human) OR it carries a
17 * usable birth/death date — so the specs can model each independently:
18 * - `instanceOf`: the P31 item value (Q5 = human; anything else = a non-human type)
19 * - `p31Snaktype`: 'somevalue'|'novalue' to model an "unknown instance-of" snak
20 * (no real value) — exercises the snaktype guard
21 * - `born` / `died`: a Wikidata time literal (e.g. '-1500-01-01T00:00:00Z') to
22 * give the entity a P569 / P570 date (the clause that keeps dated non-Q5 figures)
23 * Omit everything to model an article with no usable claim (or a missing item) —
24 * that title won't survive the filter. */
25function entitiesFor(
26 specs: Array<{ title: string; instanceOf?: string; p31Snaktype?: 'somevalue' | 'novalue'; born?: string; died?: string }>
27) {
28 const dateClaim = (time: string) => [
29 { mainsnak: { snaktype: 'value', datavalue: { value: { time, precision: 9 } } } }
30 ]
31 const out: Record<string, unknown> = {}
32 specs.forEach((s, i) => {
33 const claims: Record<string, unknown> = {}
34 if (s.p31Snaktype) claims.P31 = [{ mainsnak: { snaktype: s.p31Snaktype } }]
35 else if (s.instanceOf) claims.P31 = [{ mainsnak: { snaktype: 'value', datavalue: { value: { id: s.instanceOf } } } }]
36 if (s.born) claims.P569 = dateClaim(s.born)
37 if (s.died) claims.P570 = dateClaim(s.died)
38 out[`Q${100 + i}`] = { claims, sitelinks: { enwiki: { title: s.title } } }
39 })
40 return out
42 
43const isWikidata = (url: unknown) => String(url).includes('wikidata.org')
44 
45/** Route the two endpoints search now hits: the Wikipedia title search and the
46 * batched Wikidata people-filter. Each handler gets the count of prior calls to
47 * ITS endpoint, so a test can model a first-attempt blip then a recovery. */
48function route(
49 fetchMock: ReturnType<typeof vi.fn>,
50 handlers: {
51 search: (n: number) => Response
52 wikidata?: (n: number) => Response
53 }
54) {
55 let s = 0
56 let w = 0
57 fetchMock.mockImplementation((url: unknown) =>
58 isWikidata(url)
59 ? Promise.resolve(handlers.wikidata ? handlers.wikidata(w++) : wikidataResp({}))
60 : Promise.resolve(handlers.search(s++))
61 )
63 
64describe('figure search (name autocomplete, people-filtered)', () => {
65 // Each test uses a fresh query string: searchFigures' cache is module-level and
66 // shared per run (the same convention as the grounding spec), so a reused query
67 // would hit a stale cache and the fetch-count assertions would mislead.
68 let fetchMock: ReturnType<typeof vi.fn>
69 
70 beforeEach(() => {
71 fetchMock = vi.fn()
72 vi.stubGlobal('fetch', fetchMock)
73 })
74 
75 afterEach(() => {
76 vi.useRealTimers()
77 vi.unstubAllGlobals()
78 })
79 
80 it('filters out non-people (films, places, concepts) — the #74 core', async () => {
81 route(fetchMock, {
82 search: () =>
83 searchResp([
84 { title: 'Cleopatra', description: 'Pharaoh of Egypt from 51 to 30 BC' },
85 { title: 'Cleopatra (1963 film)', description: '1963 film' },
86 { title: 'Death of Cleopatra', description: '' }
87 ]),
88 wikidata: () =>
89 wikidataResp(
90 entitiesFor([
91 { title: 'Cleopatra', instanceOf: 'Q5' }, // human
92 { title: 'Cleopatra (1963 film)', instanceOf: 'Q11424' }, // film
93 { title: 'Death of Cleopatra', instanceOf: 'Q1190554' } // event
94 ])
95 )
96 })
97 
98 const { results, transient } = await searchFigures('cleopat filter')
99 
100 expect(transient).toBe(false)
101 expect(results.map(r => r.name)).toEqual(['Cleopatra']) // film + event dropped
102 })
103 
104 it('maps titles, descriptions, and protocol-relative thumbnails for the people kept', async () => {
105 route(fetchMock, {
106 search: () =>
107 searchResp([
108 {
109 title: 'Cleopatra',
110 description: 'Pharaoh of Egypt from 51 to 30 BC',
111 thumbnail: { url: '//upload.wikimedia.org/kleo.jpg' }
112 },
113 { title: 'Nikola Tesla', description: 'Inventor', thumbnail: null }
114 ]),
115 wikidata: () =>
116 wikidataResp(
117 entitiesFor([
118 { title: 'Cleopatra', instanceOf: 'Q5' },
119 { title: 'Nikola Tesla', instanceOf: 'Q5' }
120 ])
121 )
122 })
123 
124 const outcome = await searchFigures('two people probe')
125 
126 expect(outcome.transient).toBe(false)
127 expect(outcome.results).toEqual([
128 {
129 name: 'Cleopatra',
130 description: 'Pharaoh of Egypt from 51 to 30 BC',
131 thumbnail: 'https://upload.wikimedia.org/kleo.jpg'
132 },
133 { name: 'Nikola Tesla', description: 'Inventor', thumbnail: undefined }
134 ])
135 })
136 
137 it('drops unsafe thumbnail schemes (the unsafe-render rule)', async () => {
138 route(fetchMock, {
139 search: () =>
140 searchResp([{ title: 'Real Person', description: 'x', thumbnail: { url: 'javascript:alert(1)' } }]),
141 wikidata: () => wikidataResp(entitiesFor([{ title: 'Real Person', instanceOf: 'Q5' }]))
142 })
143 const { results } = await searchFigures('spoofed person')
144 expect(results[0].name).toBe('Real Person')
145 expect(results[0].thumbnail).toBeUndefined()
146 })
147 
148 it('drops a result with no Wikidata item (unclassifiable → not a confirmed person)', async () => {
149 route(fetchMock, {
150 search: () =>
151 searchResp([
152 { title: 'Marie Curie', description: 'Physicist' },
153 { title: 'Some Obscure Page', description: 'no wikidata item' }
154 ]),
155 // Only Marie Curie comes back from Wikidata; the obscure page is absent.
156 wikidata: () => wikidataResp(entitiesFor([{ title: 'Marie Curie', instanceOf: 'Q5' }]))
157 })
158 const { results } = await searchFigures('partial item probe')
159 expect(results.map(r => r.name)).toEqual(['Marie Curie'])
160 })
161 
162 it('keeps a contactable non-Q5 figure that has a Wikidata death date (Moses / King Arthur class)', async () => {
163 // The bug a bare-Q5 filter would have: biblical / legendary figures are typed
164 // "biblical figure" / "legendary figure", NOT Q5, yet the contact gate accepts
165 // them (they resolve and carry a death date). The date clause keeps them.
166 route(fetchMock, {
167 search: () =>
168 searchResp([
169 { title: 'Moses', description: 'Biblical figure' },
170 { title: 'The Ten Commandments (1956 film)', description: '1956 film' }
171 ]),
172 wikidata: () =>
173 wikidataResp(
174 entitiesFor([
175 { title: 'Moses', instanceOf: 'Q20643955', died: '-1500-01-01T00:00:00Z' }, // not Q5, but dated
176 { title: 'The Ten Commandments (1956 film)', instanceOf: 'Q11424' } // film, no date
177 ])
178 )
179 })
180 const { results } = await searchFigures('moses class probe')
181 expect(results.map(r => r.name)).toEqual(['Moses']) // kept via P570; film dropped
182 })
183 
184 it('drops a fictional character with only an in-universe birth date — death, not birth', async () => {
185 // Sherlock Holmes / Harry Potter carry a P569 birthday but no P570 death; the
186 // gate refuses them as "not deceased", so search must not preview them. The
187 // people test keys on death (P570), not birth — a birth date alone is not a
188 // person the gate can reach.
189 route(fetchMock, {
190 search: () =>
191 searchResp([
192 { title: 'Sherlock Holmes', description: 'fictional detective' },
193 { title: 'Arthur Conan Doyle', description: 'author' }
194 ]),
195 wikidata: () =>
196 wikidataResp(
197 entitiesFor([
198 { title: 'Sherlock Holmes', instanceOf: 'Q15632617', born: '1854-01-06T00:00:00Z' }, // birth only
199 { title: 'Arthur Conan Doyle', instanceOf: 'Q5', died: '1930-07-07T00:00:00Z' } // real, deceased
200 ])
201 )
202 })
203 const { results } = await searchFigures('sherlock vs doyle probe')
204 expect(results.map(r => r.name)).toEqual(['Arthur Conan Doyle'])
205 })
⋯ 244 lines hidden (lines 206–449)
206 
207 it('drops an entity whose only P31 is a somevalue snak with no dates (the snaktype guard does work)', async () => {
208 route(fetchMock, {
209 search: () =>
210 searchResp([
211 { title: 'Vague Concept', description: 'instance-of: unknown value' },
212 { title: 'Marie Curie', description: 'Physicist' }
213 ]),
214 wikidata: () =>
215 wikidataResp(
216 entitiesFor([
217 { title: 'Vague Concept', p31Snaktype: 'somevalue' }, // no real value, no dates
218 { title: 'Marie Curie', instanceOf: 'Q5' }
219 ])
220 )
221 })
222 const { results } = await searchFigures('somevalue snak probe')
223 expect(results.map(r => r.name)).toEqual(['Marie Curie'])
224 })
225 
226 it('matches a result to its entity across underscore/case drift (titleKey normalization)', async () => {
227 route(fetchMock, {
228 search: () => searchResp([{ title: 'Marie Curie', description: 'Physicist' }]),
229 // The Wikidata sitelink comes back with an underscore and different case;
230 // titleKey must normalize both sides or this person would be wrongly dropped.
231 wikidata: () => wikidataResp(entitiesFor([{ title: 'marie_curie', instanceOf: 'Q5' }]))
232 })
233 const { results } = await searchFigures('normalize title probe')
234 expect(results.map(r => r.name)).toEqual(['Marie Curie'])
235 })
236 
237 it('builds the Wikidata request the filter depends on (props, sites, sitefilter, all titles)', async () => {
238 // The mock fabricates the response, so without this the request contract is
239 // unchecked: if props dropped `sitelinks`, real Wikidata returns no title to
240 // match and EVERY result would be silently filtered out.
241 route(fetchMock, {
242 search: () =>
243 searchResp([
244 { title: 'Ada Lovelace', description: 'Mathematician' },
245 { title: 'Nikola Tesla', description: 'Inventor' }
246 ]),
247 wikidata: () =>
248 wikidataResp(
249 entitiesFor([
250 { title: 'Ada Lovelace', instanceOf: 'Q5' },
251 { title: 'Nikola Tesla', instanceOf: 'Q5' }
252 ])
253 )
254 })
255 await searchFigures('request contract probe')
256 
257 const wdCall = fetchMock.mock.calls.find(c => isWikidata(c[0]))
258 expect(wdCall).toBeTruthy()
259 const url = decodeURIComponent(String(wdCall![0]))
260 expect(url).toContain('action=wbgetentities')
261 expect(url).toContain('sites=enwiki')
262 expect(url).toContain('sitefilter=enwiki')
263 expect(url).toContain('props=claims|sitelinks')
264 expect(url).toContain('Ada Lovelace') // both result titles carried in titles=
265 expect(url).toContain('Nikola Tesla')
266 })
267 
268 it('caches a settled "no people match" — all results filtered out, then served from cache', async () => {
269 route(fetchMock, {
270 search: () =>
271 searchResp([
272 { title: 'Some Film', description: 'a film' },
273 { title: 'Some Place', description: 'a city' }
274 ]),
275 wikidata: () =>
276 wikidataResp(
277 entitiesFor([
278 { title: 'Some Film', instanceOf: 'Q11424' },
279 { title: 'Some Place', instanceOf: 'Q515' }
280 ])
281 )
282 })
283 const first = await searchFigures('all nonpeople probe')
284 expect(first).toEqual({ results: [], transient: false }) // a definitive answer, not weather
285 await searchFigures('all nonpeople probe')
286 expect(fetchMock).toHaveBeenCalledTimes(2) // search + filter once; the empty answer cached
287 })
288 
289 it('returns [] without fetching for queries under two characters', async () => {
290 expect(await searchFigures('a')).toEqual({ results: [], transient: false })
291 expect(await searchFigures(' ')).toEqual({ results: [], transient: false })
292 expect(fetchMock).not.toHaveBeenCalled()
293 })
294 
295 it('caches a query — the second call refetches neither endpoint', async () => {
296 route(fetchMock, {
297 search: () => searchResp([{ title: 'Nikola Tesla', description: 'Inventor' }]),
298 wikidata: () => wikidataResp(entitiesFor([{ title: 'Nikola Tesla', instanceOf: 'Q5' }]))
299 })
300 await searchFigures('tesla cache probe')
301 await searchFigures('Tesla Cache Probe') // case-insensitive key
302 expect(fetchMock).toHaveBeenCalledTimes(2) // one search + one filter, then served from cache
303 })
304 
305 it('sends a Wikimedia-policy-compliant User-Agent on BOTH the search and the filter call', async () => {
306 // A non-compliant agent (no contact) gets throttled harder — the dominant
307 // cause of the autocomplete silently failing (issue #58).
308 route(fetchMock, {
309 search: () => searchResp([{ title: 'Leonardo da Vinci', description: 'Polymath' }]),
310 wikidata: () => wikidataResp(entitiesFor([{ title: 'Leonardo da Vinci', instanceOf: 'Q5' }]))
311 })
312 await searchFigures('leonardo ua probe')
313 
314 for (const call of fetchMock.mock.calls) {
315 const init = call[1] as RequestInit
316 const ua = String((init.headers as Record<string, string>)['User-Agent'])
317 expect(ua).toContain('Revisionist')
318 expect(ua).toMatch(/mailto:\S+@\S+|https?:\/\/\S+/) // a reachable contact, per the UA policy
319 }
320 expect(fetchMock).toHaveBeenCalledTimes(2)
321 })
322 
323 it('retries a rate-limited search once and recovers the matches', async () => {
324 vi.useFakeTimers()
325 route(fetchMock, {
326 search: n =>
327 n === 0
328 ? ({ ok: false, status: 429 } as Response)
329 : searchResp([{ title: 'Alfred Nobel', description: 'Inventor of dynamite' }]),
330 wikidata: () => wikidataResp(entitiesFor([{ title: 'Alfred Nobel', instanceOf: 'Q5' }]))
331 })
332 
333 const pending = searchFigures('alfred retry probe')
334 await vi.advanceTimersByTimeAsync(250)
335 const outcome = await pending
336 
337 expect(outcome.transient).toBe(false)
338 expect(outcome.results[0].name).toBe('Alfred Nobel')
339 })
340 
341 it('a search failure that survives the retry is flagged transient (filter never runs)', async () => {
342 vi.useFakeTimers()
343 route(fetchMock, {
344 search: () => {
345 throw new Error('offline')
346 }
347 })
348 
349 const pending = searchFigures('failing query one')
350 await vi.advanceTimersByTimeAsync(250)
351 expect(await pending).toEqual({ results: [], transient: true })
352 // The filter is never reached when the search itself never answered.
353 expect(fetchMock.mock.calls.every(c => !isWikidata(c[0]))).toBe(true)
354 })
355 
356 it('a real empty search answer is NOT transient, caches, and skips the filter call', async () => {
357 route(fetchMock, {
358 search: () => searchResp([]),
359 wikidata: () => wikidataResp({})
360 })
361 expect(await searchFigures('xqzv nobody')).toEqual({ results: [], transient: false })
362 await searchFigures('xqzv nobody')
363 expect(fetchMock).toHaveBeenCalledTimes(1) // one search; no filter (nothing to classify), then cached
364 expect(fetchMock.mock.calls.every(c => !isWikidata(c[0]))).toBe(true)
365 })
366 
367 it('never caches a search failure — the next call for the same query refetches', async () => {
368 vi.useFakeTimers()
369 let searchCalls = 0
370 route(fetchMock, {
371 search: () => {
372 searchCalls++
373 if (searchCalls <= 2) throw new Error('wikipedia blip')
374 return searchResp([{ title: 'Recovered', description: 'after the blip' }])
375 },
376 wikidata: () => wikidataResp(entitiesFor([{ title: 'Recovered', instanceOf: 'Q5' }]))
377 })
378 
379 const pending = searchFigures('transient blip probe')
380 await vi.advanceTimersByTimeAsync(250)
381 expect((await pending).transient).toBe(true)
382 
383 const second = await searchFigures('transient blip probe')
384 expect(second.results).toEqual([{ name: 'Recovered', description: 'after the blip', thumbnail: undefined }])
385 expect(searchCalls).toBe(3) // two failed attempts (not cached) + one fresh success
386 })
387 
388 it('FALLS OPEN when the people-filter blips: a working search is not blanked (no #58 regression)', async () => {
389 vi.useFakeTimers()
390 route(fetchMock, {
391 search: () =>
392 searchResp([
393 { title: 'Cleopatra', description: 'Pharaoh' },
394 { title: 'Cleopatra (1963 film)', description: '1963 film' }
395 ]),
396 // Wikidata never answers — a network error, then (on retry) a 5xx. Both
397 // failure modes are exercised; the filter gives up and falls open.
398 wikidata: n => {
399 if (n === 0) throw new Error('network down')
400 return { ok: false, status: 503 } as Response
401 }
402 })
403 
404 const pending = searchFigures('fallopen probe')
405 await vi.advanceTimersByTimeAsync(250) // the filter's one spaced retry
406 const outcome = await pending
407 
408 // Unfiltered matches are returned (the search worked); not flagged transient,
409 // because we DO have results to show.
410 expect(outcome.transient).toBe(false)
411 expect(outcome.results.map(r => r.name)).toEqual(['Cleopatra', 'Cleopatra (1963 film)'])
412 
413 // A fall-open is NOT cached: the next call re-attempts the filter.
414 expect(fetchMock.mock.calls.filter(c => isWikidata(c[0])).length).toBe(2) // attempt + retry
415 
416 const pending2 = searchFigures('fallopen probe')
417 await vi.advanceTimersByTimeAsync(250)
418 await pending2
419 expect(fetchMock.mock.calls.filter(c => isWikidata(c[0])).length).toBeGreaterThan(2)
420 })
421 
422 it('retries the people-filter once on a no-entities blip, then filters cleanly', async () => {
423 vi.useFakeTimers()
424 route(fetchMock, {
425 search: () =>
426 searchResp([
427 { title: 'Genghis Khan', description: 'Mongol emperor' },
428 { title: 'Genghis Khan (restaurant)', description: 'restaurant' }
429 ]),
430 wikidata: n =>
431 n === 0
432 ? ({ ok: false, status: 429 } as Response) // first filter attempt blips
433 : wikidataResp(
434 entitiesFor([
435 { title: 'Genghis Khan', instanceOf: 'Q5' },
436 { title: 'Genghis Khan (restaurant)', instanceOf: 'Q11707' }
437 ])
438 )
439 })
440 
441 const pending = searchFigures('genghis retry filter')
442 await vi.advanceTimersByTimeAsync(250) // the filter retry delay
443 const outcome = await pending
444 
445 expect(outcome.transient).toBe(false)
446 expect(outcome.results.map(r => r.name)).toEqual(['Genghis Khan']) // restaurant dropped
447 expect(fetchMock.mock.calls.filter(c => isWikidata(c[0])).length).toBe(2)
448 })
449})

Two more guard the request contract and the resilience story. The contract test asserts the actual Wikidata URL carries props=claims|sitelinks, sitefilter and every result title — without it, dropping sitelinks would silently filter everything out yet still pass. The fall-open test drives a Wikidata failure and asserts the unfiltered matches come back, transient: false, and are not cached.

The request-contract assertion and the fall-open (no #58 regression) test.

tests/unit/server/utils/figure-search.spec.ts · 449 lines
tests/unit/server/utils/figure-search.spec.ts449 lines · TypeScript
⋯ 236 lines hidden (lines 1–236)
1import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2import { searchFigures } from '../../../../server/utils/figure-search'
3 
4/** Wikipedia REST v1 title-search response, shaped like the real thing. */
5function searchResp(pages: unknown[]) {
6 return { ok: true, json: async () => ({ pages }) } as Response
7}
8 
9/** Wikidata `wbgetentities` response, shaped like the real thing (entities keyed
10 * by qid, each carrying its P31 "instance of" claims + its enwiki sitelink). */
11function wikidataResp(entities: Record<string, unknown>) {
12 return { ok: true, json: async () => ({ entities }) } as Response
14 
15/** Build a Wikidata entities map from per-title specs, mirroring the real shape the
16 * people-filter reads. A title is a PERSON when P31 = Q5 (human) OR it carries a
17 * usable birth/death date — so the specs can model each independently:
18 * - `instanceOf`: the P31 item value (Q5 = human; anything else = a non-human type)
19 * - `p31Snaktype`: 'somevalue'|'novalue' to model an "unknown instance-of" snak
20 * (no real value) — exercises the snaktype guard
21 * - `born` / `died`: a Wikidata time literal (e.g. '-1500-01-01T00:00:00Z') to
22 * give the entity a P569 / P570 date (the clause that keeps dated non-Q5 figures)
23 * Omit everything to model an article with no usable claim (or a missing item) —
24 * that title won't survive the filter. */
25function entitiesFor(
26 specs: Array<{ title: string; instanceOf?: string; p31Snaktype?: 'somevalue' | 'novalue'; born?: string; died?: string }>
27) {
28 const dateClaim = (time: string) => [
29 { mainsnak: { snaktype: 'value', datavalue: { value: { time, precision: 9 } } } }
30 ]
31 const out: Record<string, unknown> = {}
32 specs.forEach((s, i) => {
33 const claims: Record<string, unknown> = {}
34 if (s.p31Snaktype) claims.P31 = [{ mainsnak: { snaktype: s.p31Snaktype } }]
35 else if (s.instanceOf) claims.P31 = [{ mainsnak: { snaktype: 'value', datavalue: { value: { id: s.instanceOf } } } }]
36 if (s.born) claims.P569 = dateClaim(s.born)
37 if (s.died) claims.P570 = dateClaim(s.died)
38 out[`Q${100 + i}`] = { claims, sitelinks: { enwiki: { title: s.title } } }
39 })
40 return out
42 
43const isWikidata = (url: unknown) => String(url).includes('wikidata.org')
44 
45/** Route the two endpoints search now hits: the Wikipedia title search and the
46 * batched Wikidata people-filter. Each handler gets the count of prior calls to
47 * ITS endpoint, so a test can model a first-attempt blip then a recovery. */
48function route(
49 fetchMock: ReturnType<typeof vi.fn>,
50 handlers: {
51 search: (n: number) => Response
52 wikidata?: (n: number) => Response
53 }
54) {
55 let s = 0
56 let w = 0
57 fetchMock.mockImplementation((url: unknown) =>
58 isWikidata(url)
59 ? Promise.resolve(handlers.wikidata ? handlers.wikidata(w++) : wikidataResp({}))
60 : Promise.resolve(handlers.search(s++))
61 )
63 
64describe('figure search (name autocomplete, people-filtered)', () => {
65 // Each test uses a fresh query string: searchFigures' cache is module-level and
66 // shared per run (the same convention as the grounding spec), so a reused query
67 // would hit a stale cache and the fetch-count assertions would mislead.
68 let fetchMock: ReturnType<typeof vi.fn>
69 
70 beforeEach(() => {
71 fetchMock = vi.fn()
72 vi.stubGlobal('fetch', fetchMock)
73 })
74 
75 afterEach(() => {
76 vi.useRealTimers()
77 vi.unstubAllGlobals()
78 })
79 
80 it('filters out non-people (films, places, concepts) — the #74 core', async () => {
81 route(fetchMock, {
82 search: () =>
83 searchResp([
84 { title: 'Cleopatra', description: 'Pharaoh of Egypt from 51 to 30 BC' },
85 { title: 'Cleopatra (1963 film)', description: '1963 film' },
86 { title: 'Death of Cleopatra', description: '' }
87 ]),
88 wikidata: () =>
89 wikidataResp(
90 entitiesFor([
91 { title: 'Cleopatra', instanceOf: 'Q5' }, // human
92 { title: 'Cleopatra (1963 film)', instanceOf: 'Q11424' }, // film
93 { title: 'Death of Cleopatra', instanceOf: 'Q1190554' } // event
94 ])
95 )
96 })
97 
98 const { results, transient } = await searchFigures('cleopat filter')
99 
100 expect(transient).toBe(false)
101 expect(results.map(r => r.name)).toEqual(['Cleopatra']) // film + event dropped
102 })
103 
104 it('maps titles, descriptions, and protocol-relative thumbnails for the people kept', async () => {
105 route(fetchMock, {
106 search: () =>
107 searchResp([
108 {
109 title: 'Cleopatra',
110 description: 'Pharaoh of Egypt from 51 to 30 BC',
111 thumbnail: { url: '//upload.wikimedia.org/kleo.jpg' }
112 },
113 { title: 'Nikola Tesla', description: 'Inventor', thumbnail: null }
114 ]),
115 wikidata: () =>
116 wikidataResp(
117 entitiesFor([
118 { title: 'Cleopatra', instanceOf: 'Q5' },
119 { title: 'Nikola Tesla', instanceOf: 'Q5' }
120 ])
121 )
122 })
123 
124 const outcome = await searchFigures('two people probe')
125 
126 expect(outcome.transient).toBe(false)
127 expect(outcome.results).toEqual([
128 {
129 name: 'Cleopatra',
130 description: 'Pharaoh of Egypt from 51 to 30 BC',
131 thumbnail: 'https://upload.wikimedia.org/kleo.jpg'
132 },
133 { name: 'Nikola Tesla', description: 'Inventor', thumbnail: undefined }
134 ])
135 })
136 
137 it('drops unsafe thumbnail schemes (the unsafe-render rule)', async () => {
138 route(fetchMock, {
139 search: () =>
140 searchResp([{ title: 'Real Person', description: 'x', thumbnail: { url: 'javascript:alert(1)' } }]),
141 wikidata: () => wikidataResp(entitiesFor([{ title: 'Real Person', instanceOf: 'Q5' }]))
142 })
143 const { results } = await searchFigures('spoofed person')
144 expect(results[0].name).toBe('Real Person')
145 expect(results[0].thumbnail).toBeUndefined()
146 })
147 
148 it('drops a result with no Wikidata item (unclassifiable → not a confirmed person)', async () => {
149 route(fetchMock, {
150 search: () =>
151 searchResp([
152 { title: 'Marie Curie', description: 'Physicist' },
153 { title: 'Some Obscure Page', description: 'no wikidata item' }
154 ]),
155 // Only Marie Curie comes back from Wikidata; the obscure page is absent.
156 wikidata: () => wikidataResp(entitiesFor([{ title: 'Marie Curie', instanceOf: 'Q5' }]))
157 })
158 const { results } = await searchFigures('partial item probe')
159 expect(results.map(r => r.name)).toEqual(['Marie Curie'])
160 })
161 
162 it('keeps a contactable non-Q5 figure that has a Wikidata death date (Moses / King Arthur class)', async () => {
163 // The bug a bare-Q5 filter would have: biblical / legendary figures are typed
164 // "biblical figure" / "legendary figure", NOT Q5, yet the contact gate accepts
165 // them (they resolve and carry a death date). The date clause keeps them.
166 route(fetchMock, {
167 search: () =>
168 searchResp([
169 { title: 'Moses', description: 'Biblical figure' },
170 { title: 'The Ten Commandments (1956 film)', description: '1956 film' }
171 ]),
172 wikidata: () =>
173 wikidataResp(
174 entitiesFor([
175 { title: 'Moses', instanceOf: 'Q20643955', died: '-1500-01-01T00:00:00Z' }, // not Q5, but dated
176 { title: 'The Ten Commandments (1956 film)', instanceOf: 'Q11424' } // film, no date
177 ])
178 )
179 })
180 const { results } = await searchFigures('moses class probe')
181 expect(results.map(r => r.name)).toEqual(['Moses']) // kept via P570; film dropped
182 })
183 
184 it('drops a fictional character with only an in-universe birth date — death, not birth', async () => {
185 // Sherlock Holmes / Harry Potter carry a P569 birthday but no P570 death; the
186 // gate refuses them as "not deceased", so search must not preview them. The
187 // people test keys on death (P570), not birth — a birth date alone is not a
188 // person the gate can reach.
189 route(fetchMock, {
190 search: () =>
191 searchResp([
192 { title: 'Sherlock Holmes', description: 'fictional detective' },
193 { title: 'Arthur Conan Doyle', description: 'author' }
194 ]),
195 wikidata: () =>
196 wikidataResp(
197 entitiesFor([
198 { title: 'Sherlock Holmes', instanceOf: 'Q15632617', born: '1854-01-06T00:00:00Z' }, // birth only
199 { title: 'Arthur Conan Doyle', instanceOf: 'Q5', died: '1930-07-07T00:00:00Z' } // real, deceased
200 ])
201 )
202 })
203 const { results } = await searchFigures('sherlock vs doyle probe')
204 expect(results.map(r => r.name)).toEqual(['Arthur Conan Doyle'])
205 })
206 
207 it('drops an entity whose only P31 is a somevalue snak with no dates (the snaktype guard does work)', async () => {
208 route(fetchMock, {
209 search: () =>
210 searchResp([
211 { title: 'Vague Concept', description: 'instance-of: unknown value' },
212 { title: 'Marie Curie', description: 'Physicist' }
213 ]),
214 wikidata: () =>
215 wikidataResp(
216 entitiesFor([
217 { title: 'Vague Concept', p31Snaktype: 'somevalue' }, // no real value, no dates
218 { title: 'Marie Curie', instanceOf: 'Q5' }
219 ])
220 )
221 })
222 const { results } = await searchFigures('somevalue snak probe')
223 expect(results.map(r => r.name)).toEqual(['Marie Curie'])
224 })
225 
226 it('matches a result to its entity across underscore/case drift (titleKey normalization)', async () => {
227 route(fetchMock, {
228 search: () => searchResp([{ title: 'Marie Curie', description: 'Physicist' }]),
229 // The Wikidata sitelink comes back with an underscore and different case;
230 // titleKey must normalize both sides or this person would be wrongly dropped.
231 wikidata: () => wikidataResp(entitiesFor([{ title: 'marie_curie', instanceOf: 'Q5' }]))
232 })
233 const { results } = await searchFigures('normalize title probe')
234 expect(results.map(r => r.name)).toEqual(['Marie Curie'])
235 })
236 
237 it('builds the Wikidata request the filter depends on (props, sites, sitefilter, all titles)', async () => {
238 // The mock fabricates the response, so without this the request contract is
239 // unchecked: if props dropped `sitelinks`, real Wikidata returns no title to
240 // match and EVERY result would be silently filtered out.
241 route(fetchMock, {
242 search: () =>
243 searchResp([
244 { title: 'Ada Lovelace', description: 'Mathematician' },
245 { title: 'Nikola Tesla', description: 'Inventor' }
246 ]),
247 wikidata: () =>
248 wikidataResp(
249 entitiesFor([
250 { title: 'Ada Lovelace', instanceOf: 'Q5' },
251 { title: 'Nikola Tesla', instanceOf: 'Q5' }
252 ])
253 )
254 })
255 await searchFigures('request contract probe')
256 
257 const wdCall = fetchMock.mock.calls.find(c => isWikidata(c[0]))
258 expect(wdCall).toBeTruthy()
259 const url = decodeURIComponent(String(wdCall![0]))
260 expect(url).toContain('action=wbgetentities')
261 expect(url).toContain('sites=enwiki')
262 expect(url).toContain('sitefilter=enwiki')
263 expect(url).toContain('props=claims|sitelinks')
264 expect(url).toContain('Ada Lovelace') // both result titles carried in titles=
265 expect(url).toContain('Nikola Tesla')
266 })
⋯ 121 lines hidden (lines 267–387)
267 
268 it('caches a settled "no people match" — all results filtered out, then served from cache', async () => {
269 route(fetchMock, {
270 search: () =>
271 searchResp([
272 { title: 'Some Film', description: 'a film' },
273 { title: 'Some Place', description: 'a city' }
274 ]),
275 wikidata: () =>
276 wikidataResp(
277 entitiesFor([
278 { title: 'Some Film', instanceOf: 'Q11424' },
279 { title: 'Some Place', instanceOf: 'Q515' }
280 ])
281 )
282 })
283 const first = await searchFigures('all nonpeople probe')
284 expect(first).toEqual({ results: [], transient: false }) // a definitive answer, not weather
285 await searchFigures('all nonpeople probe')
286 expect(fetchMock).toHaveBeenCalledTimes(2) // search + filter once; the empty answer cached
287 })
288 
289 it('returns [] without fetching for queries under two characters', async () => {
290 expect(await searchFigures('a')).toEqual({ results: [], transient: false })
291 expect(await searchFigures(' ')).toEqual({ results: [], transient: false })
292 expect(fetchMock).not.toHaveBeenCalled()
293 })
294 
295 it('caches a query — the second call refetches neither endpoint', async () => {
296 route(fetchMock, {
297 search: () => searchResp([{ title: 'Nikola Tesla', description: 'Inventor' }]),
298 wikidata: () => wikidataResp(entitiesFor([{ title: 'Nikola Tesla', instanceOf: 'Q5' }]))
299 })
300 await searchFigures('tesla cache probe')
301 await searchFigures('Tesla Cache Probe') // case-insensitive key
302 expect(fetchMock).toHaveBeenCalledTimes(2) // one search + one filter, then served from cache
303 })
304 
305 it('sends a Wikimedia-policy-compliant User-Agent on BOTH the search and the filter call', async () => {
306 // A non-compliant agent (no contact) gets throttled harder — the dominant
307 // cause of the autocomplete silently failing (issue #58).
308 route(fetchMock, {
309 search: () => searchResp([{ title: 'Leonardo da Vinci', description: 'Polymath' }]),
310 wikidata: () => wikidataResp(entitiesFor([{ title: 'Leonardo da Vinci', instanceOf: 'Q5' }]))
311 })
312 await searchFigures('leonardo ua probe')
313 
314 for (const call of fetchMock.mock.calls) {
315 const init = call[1] as RequestInit
316 const ua = String((init.headers as Record<string, string>)['User-Agent'])
317 expect(ua).toContain('Revisionist')
318 expect(ua).toMatch(/mailto:\S+@\S+|https?:\/\/\S+/) // a reachable contact, per the UA policy
319 }
320 expect(fetchMock).toHaveBeenCalledTimes(2)
321 })
322 
323 it('retries a rate-limited search once and recovers the matches', async () => {
324 vi.useFakeTimers()
325 route(fetchMock, {
326 search: n =>
327 n === 0
328 ? ({ ok: false, status: 429 } as Response)
329 : searchResp([{ title: 'Alfred Nobel', description: 'Inventor of dynamite' }]),
330 wikidata: () => wikidataResp(entitiesFor([{ title: 'Alfred Nobel', instanceOf: 'Q5' }]))
331 })
332 
333 const pending = searchFigures('alfred retry probe')
334 await vi.advanceTimersByTimeAsync(250)
335 const outcome = await pending
336 
337 expect(outcome.transient).toBe(false)
338 expect(outcome.results[0].name).toBe('Alfred Nobel')
339 })
340 
341 it('a search failure that survives the retry is flagged transient (filter never runs)', async () => {
342 vi.useFakeTimers()
343 route(fetchMock, {
344 search: () => {
345 throw new Error('offline')
346 }
347 })
348 
349 const pending = searchFigures('failing query one')
350 await vi.advanceTimersByTimeAsync(250)
351 expect(await pending).toEqual({ results: [], transient: true })
352 // The filter is never reached when the search itself never answered.
353 expect(fetchMock.mock.calls.every(c => !isWikidata(c[0]))).toBe(true)
354 })
355 
356 it('a real empty search answer is NOT transient, caches, and skips the filter call', async () => {
357 route(fetchMock, {
358 search: () => searchResp([]),
359 wikidata: () => wikidataResp({})
360 })
361 expect(await searchFigures('xqzv nobody')).toEqual({ results: [], transient: false })
362 await searchFigures('xqzv nobody')
363 expect(fetchMock).toHaveBeenCalledTimes(1) // one search; no filter (nothing to classify), then cached
364 expect(fetchMock.mock.calls.every(c => !isWikidata(c[0]))).toBe(true)
365 })
366 
367 it('never caches a search failure — the next call for the same query refetches', async () => {
368 vi.useFakeTimers()
369 let searchCalls = 0
370 route(fetchMock, {
371 search: () => {
372 searchCalls++
373 if (searchCalls <= 2) throw new Error('wikipedia blip')
374 return searchResp([{ title: 'Recovered', description: 'after the blip' }])
375 },
376 wikidata: () => wikidataResp(entitiesFor([{ title: 'Recovered', instanceOf: 'Q5' }]))
377 })
378 
379 const pending = searchFigures('transient blip probe')
380 await vi.advanceTimersByTimeAsync(250)
381 expect((await pending).transient).toBe(true)
382 
383 const second = await searchFigures('transient blip probe')
384 expect(second.results).toEqual([{ name: 'Recovered', description: 'after the blip', thumbnail: undefined }])
385 expect(searchCalls).toBe(3) // two failed attempts (not cached) + one fresh success
386 })
387 
388 it('FALLS OPEN when the people-filter blips: a working search is not blanked (no #58 regression)', async () => {
389 vi.useFakeTimers()
390 route(fetchMock, {
391 search: () =>
392 searchResp([
393 { title: 'Cleopatra', description: 'Pharaoh' },
394 { title: 'Cleopatra (1963 film)', description: '1963 film' }
395 ]),
396 // Wikidata never answers — a network error, then (on retry) a 5xx. Both
397 // failure modes are exercised; the filter gives up and falls open.
398 wikidata: n => {
399 if (n === 0) throw new Error('network down')
400 return { ok: false, status: 503 } as Response
401 }
402 })
403 
404 const pending = searchFigures('fallopen probe')
405 await vi.advanceTimersByTimeAsync(250) // the filter's one spaced retry
406 const outcome = await pending
407 
408 // Unfiltered matches are returned (the search worked); not flagged transient,
409 // because we DO have results to show.
410 expect(outcome.transient).toBe(false)
411 expect(outcome.results.map(r => r.name)).toEqual(['Cleopatra', 'Cleopatra (1963 film)'])
412 
413 // A fall-open is NOT cached: the next call re-attempts the filter.
414 expect(fetchMock.mock.calls.filter(c => isWikidata(c[0])).length).toBe(2) // attempt + retry
415 
416 const pending2 = searchFigures('fallopen probe')
417 await vi.advanceTimersByTimeAsync(250)
418 await pending2
419 expect(fetchMock.mock.calls.filter(c => isWikidata(c[0])).length).toBeGreaterThan(2)
420 })
⋯ 29 lines hidden (lines 421–449)
421 
422 it('retries the people-filter once on a no-entities blip, then filters cleanly', async () => {
423 vi.useFakeTimers()
424 route(fetchMock, {
425 search: () =>
426 searchResp([
427 { title: 'Genghis Khan', description: 'Mongol emperor' },
428 { title: 'Genghis Khan (restaurant)', description: 'restaurant' }
429 ]),
430 wikidata: n =>
431 n === 0
432 ? ({ ok: false, status: 429 } as Response) // first filter attempt blips
433 : wikidataResp(
434 entitiesFor([
435 { title: 'Genghis Khan', instanceOf: 'Q5' },
436 { title: 'Genghis Khan (restaurant)', instanceOf: 'Q11707' }
437 ])
438 )
439 })
440 
441 const pending = searchFigures('genghis retry filter')
442 await vi.advanceTimersByTimeAsync(250) // the filter retry delay
443 const outcome = await pending
444 
445 expect(outcome.transient).toBe(false)
446 expect(outcome.results.map(r => r.name)).toEqual(['Genghis Khan']) // restaurant dropped
447 expect(fetchMock.mock.calls.filter(c => isWikidata(c[0])).length).toBe(2)
448 })
449})

How it lines up with the contact gate

The gate this preview defers to is isDeceased in the grounding module: resolved && !!died, with no instance-of check. died can come from Wikidata P570 or the #31 AI bridge. So the filter keeps everyone the gate reaches via a Wikidata death (they have P570), plus all humans — looser than the gate on living people (by design; the dossier explains the floor), and with one narrow under-coverage gap (AI-bridge-only, non-Q5) the header calls out honestly.

The gate the search preview is reasoning about — death is its floor.

server/utils/figure-grounding.ts · 323 lines
server/utils/figure-grounding.ts323 lines · TypeScript
⋯ 59 lines hidden (lines 1–59)
1/**
2 * Real-world grounding for historical figures — the backbone of the "timeline RPG"
3 * direction (see docs/ROADMAP.md). Resolves a free-form name to solid facts so the
4 * game can show who someone was, validate *when* you may contact them, and feed the
5 * role-play real dates.
6 *
7 * Pipeline (the wiki APIs are open + key-less):
8 * 1. Wikipedia REST summary — the reliable resolver. It follows redirects and
9 * picks the notable article, giving a canonical title, a one-line description,
10 * a blurb, a portrait, and the Wikidata item id. (Wikidata search alone is NOT
11 * reliable: "Cleopatra" resolves to "female given name", "Genghis Khan" to an
12 * Iron Maiden song.)
13 * 2. Wikidata, looked up by that item id (title as fallback) — structured birth
14 * (P569) / death (P570), taking the best-ranked claim that actually carries a
15 * date, with sub-year precision surfaced honestly as `circa`.
16 * 3. If the record still has no birth year, a small AI estimator bridges the gap
17 * with circa dates (issue #31) — so a real, resolvable figure keeps their
18 * "when" slider even when Wikidata can't date them.
19 *
20 * Anything unresolved returns `{ resolved: false }` (with `transient` set when the
21 * lookup couldn't complete). Contact callers now BLOCK an ungrounded name (#73) —
22 * only a real, documented figure can be reached — while a transient miss is a retry.
23 *
24 * Cache honesty: only definitive answers — a real Wikidata reply (dated or not,
25 * with the estimator genuinely consulted), a genuine no-such-article miss, a
26 * disambiguation page — keep the full TTL. A transient failure anywhere on the
27 * path (a rate-limit 429, a timeout, an estimator outage) gets a short TTL, so
28 * one blip never poisons a figure for a day.
29 */
30import { estimateLifespan } from './lifespan-estimator'
31 
32/** A year on the timeline. `signed` is AD-positive / BCE-negative for comparison. */
33export interface FigureYear {
34 year: number
35 bce: boolean
36 signed: number
37 display: string
38 /** Approximate: sub-year Wikidata precision (decade/century/…) or an AI estimate. */
39 circa?: boolean
41 
42export interface GroundedFigure {
43 name: string
44 resolved: boolean
45 description?: string
46 extract?: string
47 born?: FigureYear
48 died?: FigureYear
49 living?: boolean
50 thumbnail?: string
51 wikiUrl?: string
52 /** The lookup couldn't be completed (rate-limit / timeout / outage), so an
53 * unresolved result is "we don't know yet", not "no such person". The contact
54 * gate treats this as a retry rather than a definitive miss — and never as a
55 * free-form pass (#72 / #73). */
56 transient?: boolean
58 
59/**
60 * The deceased-only contact floor (#72): a figure may be reached only once we can
61 * confirm they have died. A resolved figure with no death year is living (or not
62 * datable enough to prove otherwise) and is never contactable — fail closed. An
63 * unresolved name is not deceased here either (the free-form path is removed in #73).
64 * The canonical predicate, shared by the suggester and the send-message gate; the
65 * client store inlines the same rule (it can't import this server-only module).
66 */
67export function isDeceased(figure: GroundedFigure): boolean {
68 return figure.resolved && !!figure.died
⋯ 254 lines hidden (lines 70–323)
70 
71/**
72 * The one header for every Wikipedia / Wikidata call the server makes — grounding,
73 * the name autocomplete, and the suggester's lookups all share it (issue #58).
74 * Wikimedia's User-Agent policy wants the client identified plus a reachable CONTACT
75 * so an operator can be reached about problematic traffic; an agent without one gets
76 * throttled harder, and rate-limit blips are the dominant real-world failure for the
77 * autocomplete. A mailto is that contact — the deployed site is auth-gated and the
78 * repo private, so neither would reach a human. Exported so every caller sends the
79 * identical compliant string instead of drifting per-feature copies.
80 */
81export const WIKI_HEADERS = {
82 accept: 'application/json',
83 'User-Agent': 'Revisionist/1.0 (timeline RPG; mailto:matthew@mseeks.me)'
85 
86const FULL_TTL_MS = 24 * 60 * 60 * 1000
87const TRANSIENT_TTL_MS = 10 * 60 * 1000
88const WIKIDATA_RETRY_DELAY_MS = 400
89const cache = new Map<string, { value: GroundedFigure; expires: number }>()
90 
91/** Builds a `FigureYear`; the display string owns the "c." honesty marker. */
92export function makeFigureYear(year: number, bce: boolean, circa = false): FigureYear {
93 const base = bce ? `${year} BC` : `${year}`
94 return {
95 year,
96 bce,
97 signed: bce ? -year : year,
98 display: circa ? `c. ${base}` : base,
99 ...(circa ? { circa: true } : {})
100 }
102 
103/**
104 * Parses a Wikidata time literal (e.g. "+1856-07-10T..", "-0069-01-13T..") to a year.
105 * Precision 9 means year-level; anything below (8 decade, 7 century, 6 millennium)
106 * still carries a representative year in the literal, but only approximately —
107 * those parse as `circa` rather than being dropped or displayed as exact.
108 */
109export function parseWikidataYear(time?: string, precision?: number): FigureYear | undefined {
110 if (!time) return undefined
111 const m = /^([+-])0*(\d+)-/.exec(time)
112 if (!m) return undefined
113 const year = parseInt(m[2], 10)
114 if (!year) return undefined
115 const bce = m[1] === '-'
116 return makeFigureYear(year, bce, precision != null && precision < 9)
118 
119/** The slice of the Wikipedia REST summary we actually read. */
120interface WikiSummary {
121 type?: string
122 title?: string
123 description?: string
124 extract?: string
125 thumbnail?: { source?: string }
126 content_urls?: { desktop?: { page?: string } }
127 wikibase_item?: string
129 
130/** The slice of a Wikidata date claim we actually read. */
131interface WikidataClaim {
132 rank?: string
133 mainsnak?: {
134 snaktype?: string
135 datavalue?: { value?: { time?: string; precision?: number } }
136 }
138 
139/** The slice of the Wikidata `wbgetentities` response we actually read. */
140interface WikidataEntities {
141 entities?: Record<string, { claims?: Record<string, WikidataClaim[]> }>
143 
144/**
145 * The best date a claim list actually carries. Wikidata routinely stacks several
146 * birth/death claims (scholarly guesses, deprecated values, "unknown value"
147 * placeholders) and the community-preferred one is often NOT first — Moses, Jesus
148 * and Confucius all keep theirs at index 2. So: only claims whose snak carries a
149 * real time (somevalue/novalue snaks don't), never deprecated, preferred rank first.
150 */
151export function pickClaimYear(claims?: WikidataClaim[]): FigureYear | undefined {
152 const usable = (claims ?? []).filter(c =>
153 c.rank !== 'deprecated' &&
154 c.mainsnak?.snaktype === 'value' &&
155 c.mainsnak.datavalue?.value?.time
156 )
157 const best = usable.find(c => c.rank === 'preferred') ?? usable[0]
158 const value = best?.mainsnak?.datavalue?.value
159 return parseWikidataYear(value?.time, value?.precision)
161 
162/**
163 * A fetch that tells a definitive miss from a transient one: 404 means the thing
164 * genuinely isn't there (cacheable at full TTL), anything else — 429, 5xx, timeout,
165 * network error — is weather to retry soon, not remember for a day.
166 */
167interface FetchOutcome<T> {
168 data: T | null
169 transient: boolean
171 
172async function fetchJson<T>(url: string): Promise<FetchOutcome<T>> {
173 try {
174 const res = await fetch(url, { headers: WIKI_HEADERS, signal: AbortSignal.timeout(6000) })
175 if (!res.ok) return { data: null, transient: res.status !== 404 }
176 return { data: (await res.json()) as T, transient: false }
177 } catch {
178 return { data: null, transient: true }
179 }
181 
182interface WikidataDates {
183 born?: FigureYear
184 died?: FigureYear
185 /** True when we never got a real answer (so the result must not cache long). */
186 transient: boolean
188 
189/**
190 * Birth/death from Wikidata — by item id when the summary supplied one (immune to
191 * title-matching quirks), by sitelink title otherwise. Rate-limit blips are the
192 * dominant way dates go missing in practice (a 429 body carries no `entities`), so
193 * a no-entities response gets one spaced retry before being declared transient.
194 */
195async function wikidataDates(qid: string | undefined, title: string): Promise<WikidataDates> {
196 const subject = qid
197 ? `ids=${encodeURIComponent(qid)}`
198 : `sites=enwiki&titles=${encodeURIComponent(title)}&normalize=1`
199 const url = `https://www.wikidata.org/w/api.php?action=wbgetentities&${subject}&props=claims&languages=en&format=json&origin=*`
200 let outcome = await fetchJson<WikidataEntities>(url)
201 if (!outcome.data?.entities) {
202 await new Promise(resolve => setTimeout(resolve, WIKIDATA_RETRY_DELAY_MS))
203 outcome = await fetchJson<WikidataEntities>(url)
204 }
205 const entities = outcome.data?.entities
206 if (!entities) return { transient: true }
207 const id = Object.keys(entities)[0]
208 const claims = id ? entities[id]?.claims : undefined
209 return { born: pickClaimYear(claims?.P569), died: pickClaimYear(claims?.P570), transient: false }
211 
212/**
213 * Resolves a name to grounded facts (cached). Never throws; returns
214 * `{ resolved: false }` when the figure can't be confidently identified.
215 */
216export async function groundFigure(rawName: string): Promise<GroundedFigure> {
217 const name = (rawName || '').trim()
218 if (!name) return { name: '', resolved: false }
219 
220 const key = name.toLowerCase()
221 const hit = cache.get(key)
222 if (hit && hit.expires > Date.now()) return hit.value
223 
224 const { value, ttlMs } = await resolve(name)
225 cache.set(key, { value, expires: Date.now() + ttlMs })
226 return value
228 
229/**
230 * Keep a URL only if it's a plain http(s) link — drop `javascript:` / `data:` /
231 * relative values. These come from a remote Wikipedia response and end up bound to
232 * `:href` / `:src` in the figure dossier; Vue escapes the value but NOT the scheme,
233 * so a spoofed `javascript:` URL would execute on click. (Surfaced by the
234 * unsafe-render loop.) Exported for the figure-search route, which binds Wikipedia
235 * thumbnails the same way.
236 */
237export function safeUrl(url?: string): string | undefined {
238 if (!url) return undefined
239 try {
240 const { protocol } = new URL(url)
241 return protocol === 'https:' || protocol === 'http:' ? url : undefined
242 } catch {
243 return undefined
244 }
246 
247interface Resolution {
248 value: GroundedFigure
249 ttlMs: number
251 
252/** A purely AI-estimated death is only trusted as "historical" (and the figure
253 * contactable, #72) when it predates this year — a recent estimated death is the
254 * least trustworthy signal and could mark a living person dead. Documented deaths
255 * are exempt (they're facts). */
256const ESTIMATED_DEATH_HISTORICAL_BEFORE = new Date().getFullYear() - 100
257 
258async function resolve(name: string): Promise<Resolution> {
259 const summary = await fetchJson<WikiSummary>(
260 `https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(name)}`
261 )
262 
263 // No article, or an ambiguous disambiguation page → can't pin a person.
264 const article = summary.data
265 if (!article || article.type === 'disambiguation' || !article.title) {
266 return {
267 value: { name, resolved: false, transient: summary.transient },
268 ttlMs: summary.transient ? TRANSIENT_TTL_MS : FULL_TTL_MS
269 }
270 }
271 
272 const title: string = article.title
273 
274 const dates = await wikidataDates(article.wikibase_item, title)
275 let born = dates.born
276 let died = dates.died
277 let bridgeTransient = false
278 
279 // The AI bridge (issue #31): the record knows WHO they are but not WHEN — either
280 // Wikidata genuinely has no usable birth claim, or the lookup is down right now.
281 // Estimated years arrive marked circa, so the dossier and prompts stay honest.
282 // A recorded death is law: the estimate only fills what the record lacks, and an
283 // estimate that contradicts the record (born after the documented death) is
284 // dropped whole rather than shipped as an incoherent lifespan.
285 if (!born) {
286 const bridge = await estimateLifespan(title, article.description, article.extract, died?.display)
287 bridgeTransient = bridge.transient
288 if (bridge.estimate) {
289 const estBorn = makeFigureYear(bridge.estimate.born.year, bridge.estimate.born.bce, true)
290 if (!died || estBorn.signed <= died.signed) {
291 born = estBorn
292 // A purely AI-ESTIMATED death is trusted as "deceased" (the floor in
293 // #72) only when it's clearly historical. An LLM guess of a RECENT
294 // death is exactly what could mark a living person contactable, so we
295 // don't promote it — the figure stays `living` and is blocked. A
296 // DOCUMENTED death (already in `died`) is a fact and is kept as-is.
297 const estDied = bridge.estimate.died
298 ? makeFigureYear(bridge.estimate.died.year, bridge.estimate.died.bce, true)
299 : undefined
300 died = died ?? (estDied && estDied.signed <= ESTIMATED_DEATH_HISTORICAL_BEFORE ? estDied : undefined)
301 }
302 }
303 }
304 
305 return {
306 value: {
307 name: title,
308 resolved: true,
309 description: article.description || undefined,
310 extract: article.extract || undefined,
311 born,
312 died,
313 living: !!born && !died,
314 thumbnail: safeUrl(article.thumbnail?.source),
315 wikiUrl: safeUrl(article.content_urls?.desktop?.page),
316 transient: dates.transient || bridgeTransient
317 },
318 // Anything short of a definitive answer keeps the short TTL: a transient
319 // Wikidata miss (even when the estimator papered over it — the next lookup
320 // should get a shot at the real dates) and an estimator outage alike.
321 ttlMs: dates.transient || bridgeTransient ? TRANSIENT_TTL_MS : FULL_TTL_MS
322 }