Add artist research source normalization and refresh planning

SI
13d ago
+1371-0

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.
  • planContextArtistResearchRefresh decides reuse, collect or blocked from 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: 0 lets 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) and artistResearchTypes, which groups the shared zod schemas and their inferred types.

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.
  • Ran: pnpm exec tsc --noEmit
    • 0 errors in lib/context or 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.
  • Ran: tsc --noEmit --strict over only the nine non-test files in this PR. It is clean.
  • Ran: pnpm exec eslint and pnpm exec prettier --check on every changed file. Both are clean.
  • Ran: grep for searchPerplexity, generateContextObject and fetch( 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_research still has no executor in planContextModules.ts. No workflow, HTTP or MCP path calls these helpers yet.
  • Not persisted. published_at, retrieved_at and storyKey are not written to context_source_versions. The research recipe schema still has no kind, so merged claims stay unclassified until 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_sources so 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 new with 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