Add artist research source normalization and refresh planning
Summary
Artist research today reuses an accepted result only when every search snippet is byte-identical (runContextEnrichment fingerprints the whole module input). As a result, a second song by the same artist always pays for a new synthesis, and a new result replaces the prior claims instead of extending them. This PR adds the deterministic, model-free, database-free layer that #2129 needs before any paid incremental research. Nothing is wired into the recorded planner, HTTP, MCP or any RPC yet.
normalizeContextResearchSources- Produces https-only canonical URLs and reduced-precision publication dates that are never guessed. Implausible years and dates after retrieval stay
unknown. - Records the caller-supplied retrieval time.
- Collapses syndicated copies into one underlying story and keeps the earliest known date across the copies.
- Classifies each story as focal-artist, collaborator-only or unattributed, using Unicode-normalized whole-name token spans. A focal name inside a longer collaborator name is not focal evidence, however often it repeats. A shorter excluded name never erases a focal mention.
- Carries snippets strictly as data and caps the result at 20 stories.
- Produces https-only canonical URLs and reduced-precision publication dates that are never guessed. Implausible years and dates after retrieval stay
planContextArtistResearchRefreshdecidesreuse,collectorblockedfrom three inputs: prior freshness, withdrawal, and genuinely new focal-artist stories.- Collaborator-only and unattributed snippets never justify paid work.
- With fresh prior research, a newly found story published before that research was retrieved does not justify paid work either.
- Truncated results are reported, not hidden.
- The budget counts the one bounded search already run (
searchCalls) and the model calls the plan would spend (modelCalls): 0 for reuse or blocked, 1 for collect. - A failed search never retries.
maxModelCalls: 0lets the CE07 spending gate refuse before any provider call.
mergeContextArtistResearchClaims- Never rewrites claim text.
- Merges claims whose folded text matches into one entry that keeps every provenance (result/version, source, story, date precision, kind).
- Counts corroboration by distinct underlying stories or canonical URLs, so press-release copies confirm a claim once.
- Retains conflicting claims side by side.
- Supporting files:
- One-function helpers:
canonicalizeContextSourceUrl,parseContextSourceDate,tokenizeContextResearchText,classifyContextResearchSourceScope. - Schema modules:
artistResearchRefreshInputSchema(planner input) andartistResearchTypes, which groups the shared zod schemas and their inferred types.
- One-function helpers:
Refs recoupable/app#2129 (child of epic recoupable/app#2116).
Merge sequencing
This change is in api only. There is no docs, database or app PR because no contract, action, table or RPC changes. context_source_versions.published_at / retrieved_at already exist for a later persistence slice. This PR depends on no open PR.
It leaves these files untouched: processContextOperation.ts, callContextRpc.ts, compileContextBrief.ts, collectContextArtistResearch.ts, discoverContextArtistSources.ts, planContextModules.ts and Sites research. Its only README change is one subsection at the very end of lib/context/README.md. api#981/#983/#985 also edit that README, so any overlap is at most a trivial append conflict.
Sibling api#1002 exports an identically named contextDatePrecisionSchema / ContextDatePrecision from lib/context/contracts/contextObservedDateSchema.ts. Whichever PR merges second should import the other's definition rather than keep two.
Verification
State: implemented and fixture-tested only.
- Ran:
pnpm exec vitest run lib/context/enrichment/__tests__/artistResearchSources.test.ts lib/context/enrichment/__tests__/artistResearchRefresh.test.ts- 2 files, 29 tests passed, 0 failed, 0 skipped.
- Ran:
pnpm exec vitest run lib/context- 69 test files passed, 8 skipped. The skipped files are opt-in live suites that need
CONTEXT_LIVE_TEST/CONTEXT_PAID_TEST. - 355 tests passed, 8 skipped, 0 failed.
- 69 test files passed, 8 skipped. The skipped files are opt-in live suites that need
- Ran:
pnpm exec tsc --noEmit- 0 errors in
lib/contextor in any file this branch touches. - 199 errors remain in unrelated
lib/tasks,lib/trigger,lib/chats, … test files. This is the same set as origin/main.
- 0 errors in
- Ran:
tsc --noEmit --strictover only the nine non-test files in this PR. It is clean. - Ran:
pnpm exec eslintandpnpm exec prettier --checkon every changed file. Both are clean. - Ran:
grepforsearchPerplexity,generateContextObjectandfetch(over the new modules. There are no matches, so these modules make no provider, model, network or database calls. - Red → green, first commit: both test files were written first and failed with
Cannot find module '../normalizeContextResearchSources'(0 tests collected). All 17 tests passed once the implementation landed. - Red → green, review fixes: 12 new regression tests were written first, and 3 existing expectations were tightened. Run before the fixes: 15 failed, 14 passed. Run after: 29 of 29 passed. The 12 regression tests cover:
- adjacent or repeated collaborator mentions
- a shorter excluded name inside the focal name
- decomposed Unicode
- copy-date merging
- implausible or future dates
- text-less candidates
- repeated query keys
- unattributed-only evidence
- historical-only new stories
- truncation
- a prior retrieved in the future
- symbol-only, empty and long claims with URL canonicalization
- Not verified:
- hosted deployment
- live Perplexity or GPT/Astra providers (never called, no credits spent)
- the production database (no migration, no RPC change)
- the two-song checkpoint end to end (dispatch is #2122/#2123/#2130 work)
- real-world press-release syndication and provider date formats (fixtures are synthetic)
Out of scope / known gaps
- Not dispatched.
artist_researchstill has no executor inplanContextModules.ts. No workflow, HTTP or MCP path calls these helpers yet. - Not persisted.
published_at,retrieved_atandstoryKeyare not written tocontext_source_versions. The research recipe schema still has nokind, so merged claims stayunclassifieduntil the prompt/schema gains one. That also means quotes, reported facts and interpretation are not yet told apart. - Search is never skipped. A fresh prior still assumes the caller ran one bounded search; skipping an identical query outright is a later slice. A stale prior whose search finds no new story still collects, because the ticket treats stale research as a justified refresh. Its reasons include
no_new_sourcesso the CE07 gate can decline. - Conflicts are not marked. Contradictory claims are retained side by side with their own provenance and dates. No conflict or uncertainty marker exists, and nothing resolves them semantically. A new claim dated in the past is labelled
newwith its original date; it is not flagged as historical. Doing this needs topic-scoped synthesis output. - Other features not built:
- topic/gap-driven research with per-topic documents
- near-duplicate (non-identical) story detection
www/apex and trailing-slash URL unification- freshness-weighted ranking
- Private scoping is not handled here. These helpers take only public search results and have no workspace or owner input. Keeping customer-private strategy out of public artist research, and excluding campaign ideas from factual evidence, stay with the existing workspace-scoped reads and the later synthesis/dispatch slice. There is no "scoped private input" fixture here.
- Uncanonicalizable prior URLs. If a prior result cites URLs that cannot be canonicalized, those URLs are treated as uncited. This forces one extra collect rather than a wrong reuse.
- No UI or docs change. There is no chat UI or docs change because there is no contract change.
🤖 Generated with Claude Code