The Consciousness Library · engineering

The article pipeline, map and machinery

Ten scholarly sources flow into one library through a single path built to spend money only when it must. Cheap keyword rules make almost every keep-or-drop decision for free; an AI model is consulted at just two moments. First the whole shape at a glance, then the same path line by line in code.

10 sources dedupe relevance summary 2 doors out
Legend CODE AI · MODEL CALL SOURCE / DB POSTGRES HUMAN KEPT HIDDEN SKIP / RETRY

Census

Who decides what

Every decision point in the system, in one table. Thirty-three in all: 26 are plain code — deterministic, free, and auditable by reading the source — 3 are model call sites, 3 are human verdicts, and 1 belongs to Postgres itself. Each row links to the part that walks through it.

The tally CODE × 26 AI × 3 HUMAN × 3 POSTGRES × 1
DecisionWhoWhere, and what it does
The per-article path — Parts I–II (runs every 20 minutes, per record)
Source parseCODEEach fetch service's extract_article_data: API response → one plain hash; malformed or titleless records dropped on the spot.
DedupeCODEArticlePersistence.already_saved? — normalized DOI, then the unique title index. Ends most journeys, and ends them free.
Title-compound acceptCODEPrefilter rung (a): a tracked compound in the title → kept, no model.
No-abstract rejectCODERung (b): nothing to judge → hidden, with an honest prefilter rationale.
Hard-exclusion rejectCODERung (c): a hard-excluded term anywhere in the text → hidden.
Zero-signal rejectCODERung (d): no topic terms at all → hidden. The single biggest cost-saver.
High-confidence acceptCODERung (e): four or more strong terms → kept, still no model.
Not-worth-a-call rejectCODERung (g): some signal, but not enough to justify spending on a verdict.
Borderline verdictAIModel call site 1 (Stage 03): YES/NO + a one-line reason, 64-token cap.
Error containmentCODEA nil verdict is recorded as llm_error and never hides the article.
Summarizability guardCODESummaryService: blank abstract → no summary attempted at all.
Summary + study factsAIModel call site 2 (Stage 04): 80–120 words + four study facts as one JSON object.
Output sanitizerCODETolerant JSON extraction, the NO_SUMMARY sentinel, and a guard against the model describing its task.
Write normalizationCODEArticlePersistence.save_article: whitespace squished, junk venue → nil journal, blank abstract stays blank.
Search indexPOSTGRESThe weighted search_vector is a generated column — the database maintains it itself (Stage 06).
The cycles — Part III
Overlap guardCODEpg_try_advisory_lock — a second heartbeat tick skips instead of piling up.
Work budgetCODECheckpoint resume, stall detection (a full dry pass ends the run), 3-cycle cap per tick.
Source healthCODEEight consecutive failures bench a source for the rest of the run.
Stats stalenessCODETopic#stats_stale? — only topics older than 24h get recomputed.
Trial gateCODEClinicalTrial.relevant? — every trial passes a code-only relevance check before storage.
Digest audience & watermarkCODEConfirmed + opted-in + has saved searches; last_sent_at stamped forward even on quiet weeks.
Evidence dueCODETopic#evidence_due? — at least 8 qualifying studies, and a synthesis missing, failed, or over 30 days old.
The synthesis — Part VI
Abuse guardsCODE5 requests/minute rate limit; prompts capped at 300 characters and normalized to lowercase.
Question dedupeCODEOne row per (prompt, provider); a complete answer under 30 days old is served with zero model cost.
Study selectionCODETitle/keyword qualification, 15 most-cited + 10 most-recent, hard cap of 25 — the model never picks its own evidence.
Honest emptyCODEZero matching studies → fails with broaden-your-question suggestions instead of calling the model.
Grounded synthesisAIModel call site 3: the full JSON synthesis, 4,000-token cap, Gemini fallback on engine failure.
Validation gauntletCODEHallucinated citations dropped, invented categories coerced, lists capped at 6, counts recomputed from what survived, and strength of evidence computed from the surviving rows.
Public visibility — Part IV
visibleCODErelevance_flag = false — the verdict from Stage 03, applied on every public read.
readableCODEA non-blank abstract or summary — a bare title is never shown to the public.
Moderation — Part V
The verdictHUMANUnflag (stamped who/when/why), delete (the only destructive act in the system), or leave hidden.
Bulk verdictsHUMANThe same choices over a selection, with the same audit stamps.
The toolboxHUMANEvery rake recheck, backfill, and purge is human-initiated — LLM tasks default to dry runs, deletions ask first (the toolbox).
The ratio is the design. For every moment the model speaks there are roughly nine code gates deciding whether it should, checking what it said, or cleaning up around it — and a human holding the only delete key. The model is an instrument in the pipeline, not its operator.

Part I

At a glance

The whole flow and every decision it makes. Follow the spine top to bottom; branches show where a record is kept, hidden, or skipped.

Step 01 · Ingest

Gather, don't decide

Ten fetch services sweep the literature every 20 minutes and return one plain record each. They only collect — nothing here judges relevance or writes to the library.

Biomedical index Open scholarly index Preprint server Publisher
PubMedEurope PMC CrossrefOpenAlex DOAJSemantic Scholar bioRxiv / medRxiv / arXivPsyArXiv ElsevierSpringer Nature
Step 02 · Dedupe

Seen it already?

The cheapest gate, run first. Matched by normalized DOI, then title. A hit ends the journey before any model is touched.

Match by DOI, then title FREE
Already in librarySkip. No relevance call, no summary call.
NewContinues to the relevance gate ↓
Step 03 · Relevance

Does it belong here?

A keyword prefilter settles almost everything for free. Only genuinely borderline records cost the one relevance model call, which returns a verdict and a one-line reason.

Keyword prefilter FREE
Off-topic / no signalHidden. Reason recorded from the prefilter.
Clearly on-topicKept — no model call needed.
Borderline → AI verdict 1 CALL
YESKept.
NOHidden + the model's one-line reason.
API errorNever counts as irrelevant — stays visible, retried later.
Step 04 · Summary

Explain it plainly

The kept article gets the second and last model call: one request returns an 80–120 word summary and four study facts (design, sample, population, key finding) together.

AI summary + study facts 1 CALL
SummarizedStored with the article.
Not summarizableA notice or boilerplate → left honestly blank, not faked.
Step 05 · Output

Two doors out

Where the article lands is decided entirely by the verdict it earned back in Step 03.

Relevance verdict
RelevantResearch library. Public full-text search & reading.
HiddenModeration queue, shown with its stored reason.

Part II

Line by line

The same path in code. Most of it is ordinary Ruby making cheap, deterministic decisions; the model runs at exactly two points, and only after the code decides the call is worth paying for.

Stage 00

The routing loop

CODE

LibraryFetchService → each fetch service's save loop

This is the spine that decides when the model runs. For each raw record the order is fixed and cost-aware: extract, a free dedupe check, the relevance gate, and only a survivor reaches the write path. The provider is chosen once from an env var.

relevance = RelevanceService.new(provider: ENV.fetch("RELEVANCE_PROVIDER", "openai").to_sym)

papers.each do |paper|
  data = extract_article_data(paper)        # 1. source-specific parse — no AI
  next if data.nil?

  # 2. dedupe BEFORE any model call — an existing row is free
  next if ArticlePersistence.already_saved?(data)

  # 3. relevance gate: a prefilter in code, then AT MOST one model call
  unless relevance.relevant?(title: data[:title],
                             abstract: data[:abstract],
                             keywords: data[:keywords])
    next
  end

  # 4. the single write path (the summary model call happens inside)
  ArticlePersistence.save_article(data, source: "Semantic Scholar")
end
Why this order. Dedupe is step 2 on purpose: a record already in the library never reaches relevance or summary, so re-encountering the same paper across ten sources costs zero tokens.
Stage 01

Fetch — extract only

SOURCE

the ten services, one data hash each

Each service calls its API and returns a plain hash — no keep/drop decision, no database write. That is centralized so ten pipelines cannot drift apart. Every service produces the same shape:

data = {
  title:            "...",   # required
  doi:              "...",   # raw or URL form; normalized later
  abstract:         "...",   # raw HTML; blank stays blank (no placeholder)
  keywords:         ["..."],  # array of strings
  authors:          [{ first_name:, last_name:, affiliation: }],
  publication_date: Date | nil,
  journal_name:     "...",   # junk/aggregator names become a nil journal
  journal_issn:     "...",
  full_text_url:    "..."
}
Stage 02

Dedupe gate

CODE

ArticlePersistence.already_saved?

Pure code, the cheapest gate in the system. The DOI is normalized (URL prefixes and case stripped) and matched first; with no DOI, the unique title index is the fallback. A hit returns :exists and ends the record's journey before a single token is spent — the "skip" branch in the map above.

Stage 03

Relevance — gates in code, then maybe the model

CODEAI

RelevanceService#relevant? → keyword prefilter → provider LLM

The prefilter tree from Part I is really this sequence of keyword gates. Each rung accepts or rejects for free and sets a last_rationale, so even code-only rejections carry an honest reason. Only rung (f) spends tokens (condensed; logging removed):

def relevant?(title:, abstract:, keywords: [])
  text = "#{title} #{abstract} #{keywords.join(' ')}".downcase

  # (a) tracked compound in the TITLE → accept, no model call
  return true if TITLE_COMPOUND_TERMS.any? { |t| title.downcase.match?(/\b#{t}\b/) }

  # (b) no abstract → cannot judge
  if abstract.blank?
    @last_rationale = "prefilter: no abstract, cannot check relevance"
    return false
  end

  # (c) hard-excluded term present
  if (hits = HARD_EXCLUDE_TERMS.select { |t| text.include?(t) }).any?
    @last_rationale = "prefilter: hard-excluded term (#{hits.first(3).join(', ')})"
    return false
  end

  # (d) zero topic terms → reject (the big cost-saver)
  topic = CONSCIOUSNESS_TERMS.select { |t| text.include?(t) }
  if topic.empty?
    @last_rationale = "prefilter: no consciousness/psychedelic terms found"
    return false
  end

  # (e) 4+ high-confidence terms → accept, still no model call
  high = HIGH_CONFIDENCE_TERMS.select { |t| text.include?(t) }
  return true if high.size >= 4

  # (f) genuinely borderline → THE model call
  if high.any? || topic.size >= 2 || single_llm_hit || core_compounds.any?
        verdict = call_llm_provider(title:, abstract:, keywords:)   # ← AI
    @last_rationale = provider.last_rationale
    return handle_llm_result(verdict)
  end

  # (g) too little signal to be worth a call
  @last_rationale = "prefilter: insufficient topical signal for LLM check"
  false
end

When rung (f) fires, one provider is built from the env choice and asked the single shared question. The rubric returns a verdict and a reason in one shot:

def build_provider
  case @provider
  when :openai   then OpenaiRelevanceService.new
  when :gemini   then GeminiRelevanceService.new
  when :claude   then ClaudeRelevanceService.new
  when :deepseek then DeepseekRelevanceService.new  # ← current default
  end
end
the one relevance rubric (shared by all four providers)

"You judge whether a research article belongs in a curated library on consciousness science and psychedelic research. Answer YES when the article's PRIMARY subject is [a psychedelic compound studied for psychoactive effects · altered states · consciousness science · 4E/embodied cognition · meditation · …]. Answer NO when [a compound is used purely as an anesthetic · a keyword merely collides with an unrelated field · …]."

Answer format: YES: primary subject is psilocybin-assisted therapy or NO: ketamine appears only as a surgical anesthetic.

The provider parses the leading word into a boolean and hands the trailing reason back through last_rationale. Irrelevant → relevance_flag = true with the reason saved; an API error returns nil, recorded as llm_error, and never hides the article.

Stage 04

Summary — the second model call

AICODE

SummaryService#analyze · one DeepSeek call returning JSON

One call returns the summary and study facts together as a single JSON object. The AI produces the text; the code around it is the guardrail — tolerant JSON extraction, and a sanitizer that refuses to store the model talking about its task.

def analyze(title:, abstract:, keywords: [])
  return nil if abstract.blank?

    raw = @provider.chat(system: ANALYZE_SYSTEM_PROMPT,                # ← AI (one call)
                       prompt: build_analyze_prompt(title, abstract, keywords))
  parsed  = parse_json(raw)               # code: strip fences, extract the {...}
  summary = sanitize_summary(parsed["summary"])  # code: NO_SUMMARY + meta-text guard
  return nil if summary.nil?

  Result.new(
    summary:          summary,
    study_design:     clean_text(parsed["study_design"]),
    sample_size:      clean_sample_size(parsed["sample_size"]),
    study_population: clean_text(parsed["study_population"]),
    key_finding:      clean_text(parsed["key_finding"])
  )
end
the analyze rubric (abridged)

"Return ONLY valid JSON with these keys: summary — 80-120 word plain-language summary, lead with the finding, preserve the authors' level of certainty (suggests / indicates / demonstrates), state null results plainly, never invent numbers, set to exactly NO_SUMMARY only when the text cannot be summarized at all; study_design, sample_size, study_population, key_finding — or null when the text does not support them."

Stage 05

Write the row — and its schema

CODE

ArticlePersistence.save_article

One method assembles the row. Source fields, code-normalized fields, and the two AI outputs land together; a junk venue name resolves to nil rather than poisoning the credibility line.

article.assign_attributes(
  title:            data[:title].to_s.squish,     # source, cleaned
  abstract:         abstract,                    # source, HTML-stripped
    summary:          analysis&.summary,           # ← AI
    study_design:     analysis&.study_design,      # ← AI
    sample_size:      analysis&.sample_size,       # ← AI
    study_population:  analysis&.study_population,  # ← AI
    key_finding:      analysis&.key_finding,       # ← AI
  publication_date: data[:publication_date],
  journal:          find_or_create_journal(data), # code: junk venue → nil
  source:           source
)
article.save!

What a finished articles row holds, and who wrote each field:

ColumnWritten byHolds
Identity & content
titleSOURCECleaned; a unique index doubles as the title-dedupe key.
abstractCODESource text, HTML stripped. Blank stays blank.
doiCODESource DOI, normalized (URL/case) for dedupe.
journal_idCODEFound-or-created; aggregator/junk names → nil.
sourceCODECanonical label ("PubMed"…).
publication_date · full_text_url · full_textSOURCEPassed through (full text fetched only for new rows).
AI-generated
summaryAI80-120 word plain-language summary.
study_designAIRCT · cohort · review · theoretical · … or null.
sample_sizeAIInteger participants, or null.
study_populationAIWho/what was studied, or null.
key_findingAIOne-sentence primary result or thesis.
Relevance (set by the relevance gate / re-check)
relevance_flagCODEtrue = irrelevant/hidden. Default false; flipped by the re-check pass.
relevance_rationaleAIThe model's one-line reason (or an honest prefilter note).
relevance_provider · _checked_at · _notesCODEWhich model judged it, when, and any llm_error.
relevance_unflagged_at · _by_id · _notesHUMANAudit trail when a moderator rescues a flagged article.
Search & engagement
search_vectorPOSTGRESWeighted tsvector, generated in the DB (next stage).
tagsCODEString array (legacy keyword tags).
pinned · thumbs_up_count · thumbs_down_count · upvotes_countHUMANAdmin/reader engagement counters.
created_at · updated_atPOSTGRESRails timestamps.
Two model calls, everything else is code. Across the whole path an article touches the AI exactly twice — once for a borderline relevance verdict, once for its summary — and both only after deterministic gates decided the call was worth making. The database generates its own search index; humans enter only to moderate. That is what keeps a library of ~19,000 articles affordable to process, and re-process, end to end.

Part III

The cycles

Nothing above runs by hand. Nine recurring cycles drive the whole system, scheduled by SolidQueue's built-in recurring scheduler — no external cron. In production a dedicated Kamal job container runs bin/jobs (one SolidQueue supervisor: dispatcher, workers, scheduler) beside the web container on the same host. Every job rides one default queue with 3 worker threads. The six that shape the corpus get their own stations below; the other three are housekeeping.

production:
  study_details_backfill: { class: StudyDetailsBackfillJob, schedule: every 15 minutes }
  library_fetch:          { class: LibraryFetchJob,         schedule: every 20 minutes }
  topics_stats_warm:      { class: TopicsStatsWarmJob,      schedule: every 6 hours }
  index_now_submit:       { class: IndexNowSubmitJob,       schedule: every 6 hours }
  trial_fetch:            { class: TrialFetchJob,           schedule: every day at 06:00 }
  semantic_maintenance:   { class: SemanticMaintenanceJob,  schedule: every day at 04:30 }
  saved_search_digest:    { class: SavedSearchDigestJob,    schedule: every monday at 14:00 }
  topic_evidence_refresh: { class: TopicEvidenceRefreshJob, schedule: every sunday at 03:00 }
  recap_generation:       { class: RecapGenerationJob,      schedule: monthly, the 1st at 04:00 }
Cycle 01 · every 20 minutes

LibraryFetchJob — the heartbeat

The recurring form of everything in Parts I–II. Each run is deliberately small: at most 3 rotation cycles (a few minutes of work), an 80-year publication window, and a 5-million target that in practice means never done. Coverage comes from query rotation: 677 curated queries in config/comprehensive_queries.yml, visited round-robin across all 10 sources, each source keeping its own position in the list.

pg_try_advisory_lock(872364155) FREE
Lock heldSkip this tick. Another run is still going; overlapping schedules never pile up.
Lock acquiredRun, then release in ensure — even on failure.
LibraryFetchService.run(
  resume:     true,       # pick up the checkpoint exactly where the last run stopped
  target:     5_000_000,  # effectively "never done"
  years:      80,
  max_cycles: 3,          # bounded work per run, then yield until the next tick
  install_signals: false
)

Checkpoint. Progress lives in storage/library_fetch.json — a persistent volume, so it survives deploys — rewritten after every source pull, so a killed process resumes mid-rotation. Progress is measured as new ids above a baseline (immune to concurrent deletes). A run stops early when a full pass through every query saves nothing (stalled), and a source that fails 8 times in a row is disabled for the rest of the run.

Cycle 02 · every 6 hours

TopicsStatsWarmJob — keep topic pages instant

Topic pages show heavy rollups (article counts, participants, study designs, top journals). Recomputing those per visitor would be brutal, so stats persist on the topic row in stats_data. Every 6 hours this job finds topics whose stats are older than 24 hours and calls refresh_stats!, pausing 2 seconds between topics. Visitors always read a precomputed answer.

Cycle 03 · daily at 06:00

TrialFetchJob — clinical trials

Sweeps ClinicalTrials.gov with 25 curated seed terms (psychedelic, contemplative, and dual-use compounds), up to 200 trials per term. Every trial passes a code-only ClinicalTrial.relevant? gate before storage, and rows are upserted by NCT id so the daily run is idempotent. Trials surface on topic pages and the trials index.

Cycle 04 · Mondays at 14:00

SavedSearchDigestJob — the weekly email

For every confirmed, opted-in user with saved searches: each follow collects articles newer than its last_sent_at (first digest looks back 7 days), then one rolled-up email goes out only if anything matched. The watermark is stamped forward even on a quiet week, so old articles are never re-sent later. One user's failure never sinks the run.

Cycle 05 · Sundays at 03:00

TopicEvidenceRefreshJob — State of the Evidence

Walks every topic and asks one question: evidence_due? — does this topic have at least 8 qualifying studies, and is its synthesis missing, failed, or older than 30 days? Due topics get refresh_evidence!, which only enqueues a pending synthesis; the actual model call runs one at a time in MetaAnalysisJob. The full anatomy of that synthesis is Part VI.

Cycle 06 · every 15 minutes

StudyDetailsBackfillJob — the patient extractor

New articles get their study-at-a-glance facts (design, sample size, interventions, dose, duration) extracted at fetch time. This cycle works the backlog: articles from before those fields existed. Each run takes a 10-minute time-boxed slice of the remaining scope, one model call per article, then yields until the next tick.

Checkpoint. Progress lives in the database: every non-erroring pass stamps study_details_checked_at, and the scope excludes stamped rows. A paper the model finds nothing in is stamped too — a nil answer is a final answer, paid for once, never re-asked. Only an article whose extraction raises (an API hiccup) stays unstamped for the next run to retry. A deploy costs nothing: the next tick resumes at the exact row where the container died. When the backlog drains, each run becomes a single cheap count. Serialized by its own advisory lock, same pattern as the heartbeat.

The housekeeping cycles. Three smaller schedules round out the nine: IndexNowSubmitJob (every 6 hours, pings search engines with recently changed URLs via IndexNow), SemanticMaintenanceJob (nightly at 04:30, embeds new articles and the relevance-rejection log, refreshes author centroids), and RecapGenerationJob (monthly, drafts the research recap). Off the clock: four more jobs run on demand rather than on a schedule: MetaAnalysisJob (the synthesis worker, enqueued by the tool and by Cycle 05), JournalBackfillJob (an admin button that resolves unknown journals via OpenAlex), ArticleMaintenanceJob (a manual 7-day recency sweep across 7 sources), and PlausibleEventJob (server-side analytics events, discarded on error). The heartbeat and the extractor carry advisory locks; everything else is naturally idempotent.

Part IV

Where the data lives, where it surfaces

One PostgreSQL database holds everything. Around the central articles table sit the entities the pipeline normalizes into place, and every public page reads from the same two-gate scope.

TableRole
articlesThe corpus. Unique title, weighted search_vector, relevance columns, engagement counters.
journalsPublication venues; junk venue names resolve to a nil journal rather than a fake one.
authors · authorshipsResearchers, joined to articles in stated order.
keywords · article_keywordsControlled keyword tags; also the exact-match half of topic matching.
topicsCurated topic pages: name, aliases, cached stats_data, link to their evidence synthesis. No join table to articles — matching is dynamic.
meta_analysesSynthesis records: prompt, provider, answer, per-study evidence rows (strength of evidence is computed from the rows, not stored) (Part VI).
clinical_trialsThe ClinicalTrials.gov slice, upserted daily by Cycle 03.
saved_searchesA followed topic or query plus its digest watermark (last_sent_at).
bookmarks · meta_analysis_bookmarksReader bookmarks over articles and syntheses.

Public visibility is always the same two gates ANDed together: visible (relevance_flag = false, Part I's verdict) and readable (a non-blank abstract or summary — a bare title is never shown). A flagged or contentless article 404s for the public but stays reachable for admins. Recovering an abstract later (see the toolbox) re-admits an article automatically.

SurfaceReadsFreshness
Library index & searchpg_search over search_vector, relevance-ranked, filter/sort options.Live + 5-min anonymous edge cache
Article pageOne row + related reading via shared keywords.Live, fragment-cached rows
Topic pagesDynamic match: topic name + aliases (terms under 3 alphanumeric chars dropped) probed as a tsquery over search_vector OR exact keyword join — so "psilocybin" also catches "magic mushrooms" via aliases.Article ids cached 24h · stats warmed 6h · evidence 30d
Trials indexLIKE over a precomputed search_blob, status filters.Daily ingest, 5-min edge cache
HomepageCorpus stats, hero, library log bands.Cached 12h / 1h / 10m
Weekly digest emailPer saved search, articles since last_sent_at.Mondays 14:00
sitemap.xmlMirrors the public visible.readable set for crawlers.Cached 1 week
Caching philosophy. The store is Solid Cache — it lives in Postgres too, so caches survive deploys. Expensive aggregates are precomputed on a clock (Cycles 02 and 05), cheap pages get short anonymous HTTP caching (public, max-age=300, stale-while-revalidate=60), and article rows are fragment-cached. Nothing a visitor hits recomputes the heavy stuff.

Part V

The human in the loop

The model hides; only a person deletes. Every flagged article waits in a superadmin queue with the model's own words attached, and every rescue is stamped with who, when, and why.

Review 01 · the flagged queue

Check what the model hid

The queue at /articles/relevance_admin lists every article with relevance_flag = true, newest verdicts first. Each row shows the model's one-line rationale, any llm_error note, and when it was checked. Filters: full-text search, provider, source, checked-before/after dates. A dedicated Recheck toggle narrows to rows whose rationale starts recheck: model now says relevant — articles where a later pass changed the model's mind, queued for a human tiebreak instead of silently unhiding.

Review 02 · the verdict

Rescue, or remove

Human reads the article + rationale HUMAN
UnflagBack to public. Stamps relevance_unflagged_at, _by_id, and an optional free-text note.
DeleteGone for good. Confirm dialog, then a hard destroy. The only destructive act in the system, and only a human can take it.
Leave itStays hidden with its rationale — flagging never deletes anything on its own.

Bulk mode: select-all checkboxes drive Unflag selected (one update_all with the same audit stamps, though no note) and Delete selected. Every rescue lands on a second page, /articles/relevance_unflagged_admin — the audit trail of who unflagged what, when, and why.

Safety invariants. The model can only hide; a transient API error (llm_error) never hides anything; deletion requires a human. And because a flag is just a boolean, every model decision is reversible until a person says otherwise.
Part V · continued

The operator's toolbox

CODEHUMAN

rake tasks — recheck · bulk delete · backfill & repair

Everything the admin pages do one row at a time, rake does at corpus scale. Three families. LLM-touching tasks default to a dry run; destructive ones ask for confirmation.

TaskCostWhat it does
Recheck — second opinions at scale
relevance:recheck_suspectsAIRe-judges a narrow, high-precision suspect set (dual-use compounds in clinical contexts; medical reviews with no core term). APPLY=true to flag, else dry run; LIMIT bounds spend; resumable by id cursor.
relevance:audit_flagged[limit]AIDry-run sample (default 300 random flagged rows) projecting how many would flip to relevant. Writes nothing — run before committing to a full recheck.
relevance:check_flaggedAIRe-runs the rubric over every flagged article.
relevance:backfill_rationalesAIFills missing rationales on flagged rows. Never unflags: if the model now says relevant it writes the recheck: prefix and leaves the row for the human queue.
relevance:fix_error_flagsCODERepair: un-hides rows flagged only by a transient llm_error and requeues them for a real check.
relevance:check_uncheckedAIJudges never-checked rows (plus errored ones); resumable; _reverse variant walks newest-first.
Bulk delete — deliberate, confirmed
relevance:delete_irrelevant[file]HUMANDestroys the ids listed in a review file (each check pass writes one to tmp/) after an interactive yes/no.
relevance:check_and_delete[limit]AIOne-pass check + delete; interactive confirm unless FORCE=true.
articles:cleanup · articles:dedupeCODERemoves DOI duplicates (keeps the oldest) and merges casing-duplicate titles (APPLY=true, else dry run).
Backfill & repair — enrich what's already saved
summaries:redo_allAIRegenerates every summary + study facts. Checkpoint lives in the database (summary_refreshed_at), so it survives deploys; clears fabricated summaries instead of keeping them. Companions: _status, _reset.
summaries:enrich[limit]AIRecovers missing abstracts by DOI (OpenAlex, then CrossRef), nulls placeholder text, summarizes in the same pass — re-admitting the article to the public readable set.
citations:backfillCODEFills citation_count from OpenAlex, 50 DOIs per request; writes 0 for unknown DOIs so they're not refetched forever. Feeds the synthesis "most-cited" ranking.
journals:backfill_from_openalexCODEResolves nil/junk journals (also behind an admin button as JournalBackfillJob).
authors:* · data:normalizeCODEAuthor name casing/order, dedupe, journal-name cleanup, duplicate-journal merges.
strip_html_from_abstractsCODEStrips stray HTML from every abstract, batched, only writing on change.
articles:study_details_from_synthesesCODECopies design/sample/population/finding out of existing syntheses — free. The model-driven sibling backfill_study_details still exists for hand runs, but the recurring Cycle 06 now does that work automatically against the study_details_checked_at checkpoint.
topics:sync · topics:refresh_evidenceCODESyncs config/topics.yml into topic rows (names, aliases, full names) and manually triggers Cycle 05 (FORCE=true regenerates all).
fetch:library · _status · _clearAIThe manual form of the heartbeat: TARGET / YEARS / RESUME / ONLY / SKIP env knobs, same checkpoint.

Part VI

The synthesis, step by step

The Research Synthesis Tool and every topic's "State of the evidence" are the same engine: pick real studies with code, ask the model one grounded question, then let code strip out anything it can't prove. One model call per synthesis, and most requests never reach it.

Synthesis 01 · two doors in

A question arrives

Door one: a reader types a question into the tool. Rate-limited to 5 per minute, capped at 300 characters, normalized to lowercase so "Does MDMA help PTSD?" and "does mdma help ptsd" are the same question. Door two: Cycle 05 (or topics:refresh_evidence) asks each due topic's deterministic question — "What does the research say about psilocybin?" — so a topic always reuses one synthesis row instead of minting duplicates.

Synthesis 02 · cache gate

Has this been asked before?

Look up (prompt, provider) FREE
Complete, under 30 days oldServed instantly. Zero model cost — repeat questions are free.
Stale (>30 days)Destroyed and regenerated with today's library.
NewA pending row is saved and MetaAnalysisJob picks it up; the page polls for the result.
Synthesis 03 · study selection

Code picks the studies, not the model

The model never chooses its own evidence. Topic door: a study qualifies only when the topic name or a known alias appears in its title or as an exact keyword (loose full-text mentions are excluded), it must have an abstract, and the topic needs at least 8 qualifying studies or no synthesis is attempted. Selection is the 15 most-cited plus 10 most recent, deduped, capped at 25 — established evidence anchors, fresh work fills in. Tool door: the question is expanded to keywords by regex (no model), searched through the same weighted index as the library, capped at 25.

Any studies found? FREE
NoneFails honestly with broaden-your-question suggestions. No model call.
Up to 25 studiesEach abstract truncated to 1,500 characters and labeled with its real article_id.
Synthesis 04 · the model call

One grounded question

DeepSeek deepseek-v4-flash at temperature 0.2, up to 4,000 output tokens, thinking disabled. The system prompt is a hard grounding contract:

the synthesis rubric (verbatim excerpts)

"You synthesize the STUDIES PROVIDED BELOW and nothing else. You never use outside knowledge, and you never invent statistics, sample sizes, or findings that are not stated in the abstracts. Return ONLY valid JSON…"

Required shape: answer (2–4 sentence conclusion with direction, rough magnitude, main caveat) · evidence (one row per relevant study: article_id, design, sample size, population, direction, finding) · convergence / conflicts / gaps. The model no longer rates its own confidence; the strength of evidence is computed afterward from the evidence rows (see below), so the number is a function of the studies rather than a self-assessment.

"'direction' is which way THIS study's own result points on the effect the question is about … Set 'design', 'sample_size', and 'direction' carefully: the strength-of-evidence rating is computed from them, not from a self-assessment."

Question + 25 abstracts → JSON 1 CALL
JSON backOn to the validation gauntlet ↓
Engine failureOne retry on Gemini 2.5 Flash; still nothing → the row is marked failed with an honest message.
Synthesis 05 · validation gauntlet

Code strips what the model can't prove

Every field of the model's JSON passes deterministic gates before anything is stored:

parse_json(raw)          # strip ``` fences, take first "{" to last "}", JSON.parse or nil

clean_evidence_row(row)  # per study row:
  return nil unless valid_ids.include?(row["article_id"])  # hallucinated citation → dropped
  direction   = DIRECTIONS.include?(dir) ? dir : "unclear"   # invented category → coerced
  sample_size = digits_only(row["sample_size"])              # "n=24 adults" → 24
  design    ||= "unspecified"

clean_strings(list)      # trim, drop blanks, cap every list at 6 items
article_count            # recomputed from the rows that SURVIVED, not the model's claim

EvidenceStrength.for(ma) # strength tier COMPUTED from the surviving rows:
  # classify each design (meta-analysis > RCT > observational > case/theoretical),
  # then grade on volume + design + directional agreement + sample size.
  # High needs a meta-analysis or 3+ consistent, adequately-powered RCTs.

The one that matters most: a citation of any article that wasn't in the provided set simply ceases to exist. The model cannot cite its way outside the library.

The strength of evidence is not something the model rates: it is computed from the surviving evidence rows, so it is a transparent function of the studies (their number, designs, agreement, and sample sizes) rather than a self-assessment that always hugged the low end.

Synthesis 06 · surfaced

Where it lands

The row flips to complete and the polling page swaps in the result. From there it appears in the tool's Recent analyses (last 10), as the lead section of its topic page ("State of the evidence", with the synthesized date and a copy-link anchor), in reader bookmarks, and through copy-summary / copy-APA-citations buttons. Every evidence row links back to its real article in the library — the same rows Part II built.

Appendix

The models, and what they cost

AI

every LLM touchpoint · one primary model · provider-swappable by env var

Three tasks touch a model, all through the same swappable provider layer (RELEVANCE_PROVIDER / SUMMARY_PROVIDER env vars). Everything currently runs on DeepSeek V4-Flash — a reasoning model run deliberately with its thinking mode disabled: with thinking on it spends ~9 seconds and its whole token budget reasoning internally and returns empty content; disabled, it answers in ~1.5s with clean output. Alternate providers stay wired for fallback and comparison: Gemini 2.5 Flash (the synthesis fallback), GPT-4o-mini (the former default, retired after its 10,000-requests/day account cap silently dropped summaries), and Claude 3.5 Sonnet.

TouchpointModelOutput capWhat it returns
Relevance verdictdeepseek-v4-flash64 tokensYES/NO + one-line reason (Part II, Stage 03). Highest-volume call, hence the tiny cap.
Summary + study factsdeepseek-v4-flash1,024 tokens80–120 word summary, design, sample size, population, key finding as one JSON object (Stage 04).
Synthesisdeepseek-v4-flash · temp 0.2 (fallback: gemini-2.5-flash)4,000 tokensThe full grounded JSON of Part VI.

At DeepSeek's July 2026 list prices — $0.14 per million input tokens on a cache miss, $0.0028 per million on a cache hit, $0.28 per million output — and noting that the fixed rubric prompts are identical on every call, so they ride the cache-hit tier:

CallTypical tokensOrder-of-magnitude costPer dollar
Relevance verdict~800 in (abstract capped at 3,000 chars) + ≤64 out≈ $0.00013~7,000 verdicts
Summary + facts~800 in + ~200 out≈ $0.0002~5,000 summaries
Synthesis~10,000 in (25 × 1,500-char abstracts) + ≤4,000 out≈ $0.003 at the caps~350+ syntheses
Why the bill stays small. The prices above are only half the story; the architecture is the other half. Dedupe runs before any model call, the keyword prefilter settles most relevance decisions for free, repeat synthesis questions are served from a 30-day cache, topic evidence regenerates at most monthly, and every call has a hard output cap sized to its job. The expensive path is the exception, by construction.
The whole machine, in one sentence. Ten sources feed a 20-minute heartbeat; deterministic gates decide what's worth judging; a model judges, summarizes, and synthesizes only where code decided it should; Postgres holds and indexes everything; scheduled cycles keep stats, trials, digests, and evidence fresh; and a human — with an audit trail and a toolbox — always has the last word.