What changed, and why
Issue #31: the contact-year slider sometimes doesn't appear, even when a figure's lifespan is sitting right there on a suggestion chip. The slider renders only when grounding finds a birth year, and a live probe of 24 figures showed the old pipeline losing dates four distinct ways. The big one is intermittent: Wikidata rate-limits a burst of lookups (the suggester verifying candidates is exactly such a burst), the failure was swallowed into a "resolved, no dates" answer, and that answer was cached for 24 hours. The others are structural: the code read claims.P569[0] when Wikidata's community-preferred claim is often not first (Moses, Jesus and Confucius all keep theirs at index 2), [0] can be a dateless somevalue placeholder or a deprecated value, and century-precision dates (Homer) rendered fake-exact.
The fix layers four defenses, all server-side in the grounding pipeline plus one honest line in the picker: read Wikidata by item id and best-ranked claim; cache transient failures for minutes instead of a day; bridge genuinely dateless figures with a small, validated AI estimate marked circa; and say so in the dossier when even that fails. The public GroundedFigure shape only gains an optional circa flag on years, so the store, the prompts and the e2e mocks all keep working untouched.
Notably, every one of the 24 probed figures — Homer, Sun Tzu, Zoroaster, King Arthur included — has recoverable dates once the lookup is robust. The AI bridge is the rare-case net, not the workhorse.
Reading Wikidata the way Wikidata means it
Wikidata stacks several birth/death claims per person: scholarly guesses, deprecated values, "unknown value" placeholders, and one the community marks preferred. The old code took [0] blindly. pickClaimYear now filters to claims that actually carry a date (a somevalue snak has no datavalue), never reads deprecated ones, and takes preferred rank first.
Best-ranked claim with a real value; the live-probed counterexamples are in the docstring.
server/utils/figure-grounding.ts · 282 lines
⋯ 115 lines hidden (lines 1–115)
⋯ 150 lines hidden (lines 133–282)
Each claim's precision field now travels into the year parse. Precision 9 is year-level; below that (decade, century, millennium) the time literal still carries a representative year, and it used to display as if exact. Those now parse as circa, and the display string owns the marker: one place decides what "c. 800 BC" looks like.
makeFigureYear owns the "c." marker; parseWikidataYear maps precision < 9 to circa.
server/utils/figure-grounding.ts · 282 lines
⋯ 62 lines hidden (lines 1–62)
⋯ 193 lines hidden (lines 90–282)
The lookup: by id, with one spaced retry
The Wikipedia REST summary already names the Wikidata entity (wikibase_item, present on every probed figure), so the dates lookup now goes by id — sidestepping the whole class of by-title sitelink mismatches. The title arm survives as a fallback, with normalize=1 for good measure.
Rate limiting is the dominant real-world failure: a 429 body carries no entities key. The lookup retries once, 400ms later, before declaring the answer transient.
fetchJson separates a definitive 404 from retry-worthy weather; wikidataDates goes id-first and retries once.
server/utils/figure-grounding.ts · 282 lines
⋯ 133 lines hidden (lines 1–133)
⋯ 8 lines hidden (lines 153–160)
⋯ 100 lines hidden (lines 183–282)
Cache honesty: definitive answers keep the day, blips get minutes
The old cache stored every outcome for 24 hours — including "Wikidata was down for six seconds", which is how one blip branded a figure dateless for a day and produced issue #31's maddening sometimes it works. Resolution now returns a TTL with the value: full for definitive answers (a dated figure, a genuine 404, a disambiguation page, a confident "this can't be dated"), ten minutes for anything transient on the path — a wiki 429 or timeout, or an estimator outage.
The two TTLs, the cache write, and the decision: any transient leg keeps the short TTL — even when the bridge papered over it, so the next lookup gets a shot at the real dates.
server/utils/figure-grounding.ts · 282 lines
⋯ 57 lines hidden (lines 1–57)
⋯ 126 lines hidden (lines 62–187)
⋯ 66 lines hidden (lines 199–264)
⋯ 1 line hidden (lines 282–282)
The AI bridge: circa dates for figures the record can't pin
When a resolved figure still has no birth year, a small structured gpt-5.5 call estimates the lifespan — the issue's own suggestion. Three things keep it honest. First, the prompt is anchored to the figure's own Wikipedia description and extract, so it dates the right person, not a namesake. Second, the wire answer passes mapLifespanWire, a pure validator that refuses unconfident answers, death-before-birth, future dates, and the "2800-year-old contemporary" shape (a still-living sentinel is only coherent for a birth within 120 years). Third, every estimated year is consumed as circa — the dossier and the character prompt say "c. 800 BC", never a fake-precise date.
The outcome type keeps outages distinguishable from refusals; the validator is exported pure and tested guard by guard.
server/utils/lifespan-estimator.ts · 152 lines
⋯ 27 lines hidden (lines 1–27)
⋯ 11 lines hidden (lines 59–69)
⋯ 55 lines hidden (lines 98–152)
Back in the grounding layer, the merge treats the record as law. Wikidata commonly knows a death without a birth (medieval figures, privacy-redacted births). The estimate fills only the missing side: a documented death survives verbatim, anchors the estimator's prompt, and an estimate whose birth lands after it is dropped whole rather than shipped as an incoherent lifespan.
The bridge fires only without a birth year; the recorded death outranks the estimate.
server/utils/figure-grounding.ts · 282 lines
⋯ 239 lines hidden (lines 1–239)
⋯ 19 lines hidden (lines 264–282)
The dossier says so when nobody can date them
If Wikidata has nothing and the bridge refuses, the figure stays resolved and contactable — free-form, exactly as before. New: the dossier says why the slider is missing instead of leaving a mysteriously absent control. The copy claims only the lookup outcome ("No dates could be found"), not a property of the record, because the same state can be a ten-minute outage window.
The when-control as before, and the honest v-else note — the only component change in this PR.
components/FigurePicker.vue · 379 lines
⋯ 52 lines hidden (lines 1–52)
⋯ 300 lines hidden (lines 80–379)
Verification
npx vitest run green (346 tests), npx nuxt typecheck clean, 12/12 Playwright e2e. The new tests are discriminators, not padding — the TTL and prompt-anchoring tests were verified by mutation to fail on the unfixed code:
| Behavior | Pinned by |
|---|---|
Preferred-rank over [0], somevalue skipped, deprecated never read | pickClaimYear suite (figure-grounding.spec.ts:72) |
| Sub-year precision → circa display; year/day precision stays exact | parseWikidataYear suite (:23) |
Lookup by ids=Q…; title+normalize=1 fallback arm | groundFigure suite (:160, :171) |
| Recorded death survives the bridge; contradicting estimate dropped whole | :202, :222, :237 |
| 429 retry recovers dates (fake timers, no real sleep) | :263 |
| Transient wiki / estimator-outage answers expire in minutes; definitive ones hold | :277, :301, :325, :350 |
| Estimator guards (unconfident, incoherent, junk) and prompt anchoring (name, description, 600-char extract cap, documented death) | lifespan-estimator.spec.ts:29, :83 |
| No-dates note renders; circa displays pass through verbatim, never on the chosen year | FigurePicker.spec.ts dossier suite |