The Consciousness Library · engineering
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.
Census
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.
| Decision | Who | Where, and what it does |
|---|---|---|
| The per-article path — Parts I–II (runs every 20 minutes, per record) | ||
| Source parse | CODE | Each fetch service's extract_article_data: API response → one plain hash; malformed or titleless records dropped on the spot. |
| Dedupe | CODE | ArticlePersistence.already_saved? — normalized DOI, then the unique title index. Ends most journeys, and ends them free. |
| Title-compound accept | CODE | Prefilter rung (a): a tracked compound in the title → kept, no model. |
| No-abstract reject | CODE | Rung (b): nothing to judge → hidden, with an honest prefilter rationale. |
| Hard-exclusion reject | CODE | Rung (c): a hard-excluded term anywhere in the text → hidden. |
| Zero-signal reject | CODE | Rung (d): no topic terms at all → hidden. The single biggest cost-saver. |
| High-confidence accept | CODE | Rung (e): four or more strong terms → kept, still no model. |
| Not-worth-a-call reject | CODE | Rung (g): some signal, but not enough to justify spending on a verdict. |
| Borderline verdict | AI | Model call site 1 (Stage 03): YES/NO + a one-line reason, 64-token cap. |
| Error containment | CODE | A nil verdict is recorded as llm_error and never hides the article. |
| Summarizability guard | CODE | SummaryService: blank abstract → no summary attempted at all. |
| Summary + study facts | AI | Model call site 2 (Stage 04): 80–120 words + four study facts as one JSON object. |
| Output sanitizer | CODE | Tolerant JSON extraction, the NO_SUMMARY sentinel, and a guard against the model describing its task. |
| Write normalization | CODE | ArticlePersistence.save_article: whitespace squished, junk venue → nil journal, blank abstract stays blank. |
| Search index | POSTGRES | The weighted search_vector is a generated column — the database maintains it itself (Stage 06). |
| The cycles — Part III | ||
| Overlap guard | CODE | pg_try_advisory_lock — a second heartbeat tick skips instead of piling up. |
| Work budget | CODE | Checkpoint resume, stall detection (a full dry pass ends the run), 3-cycle cap per tick. |
| Source health | CODE | Eight consecutive failures bench a source for the rest of the run. |
| Stats staleness | CODE | Topic#stats_stale? — only topics older than 24h get recomputed. |
| Trial gate | CODE | ClinicalTrial.relevant? — every trial passes a code-only relevance check before storage. |
| Digest audience & watermark | CODE | Confirmed + opted-in + has saved searches; last_sent_at stamped forward even on quiet weeks. |
| Evidence due | CODE | Topic#evidence_due? — at least 8 qualifying studies, and a synthesis missing, failed, or over 30 days old. |
| The synthesis — Part VI | ||
| Abuse guards | CODE | 5 requests/minute rate limit; prompts capped at 300 characters and normalized to lowercase. |
| Question dedupe | CODE | One row per (prompt, provider); a complete answer under 30 days old is served with zero model cost. |
| Study selection | CODE | Title/keyword qualification, 15 most-cited + 10 most-recent, hard cap of 25 — the model never picks its own evidence. |
| Honest empty | CODE | Zero matching studies → fails with broaden-your-question suggestions instead of calling the model. |
| Grounded synthesis | AI | Model call site 3: the full JSON synthesis, 4,000-token cap, Gemini fallback on engine failure. |
| Validation gauntlet | CODE | Hallucinated 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 | ||
| visible | CODE | relevance_flag = false — the verdict from Stage 03, applied on every public read. |
| readable | CODE | A non-blank abstract or summary — a bare title is never shown to the public. |
| Moderation — Part V | ||
| The verdict | HUMAN | Unflag (stamped who/when/why), delete (the only destructive act in the system), or leave hidden. |
| Bulk verdicts | HUMAN | The same choices over a selection, with the same audit stamps. |
| The toolbox | HUMAN | Every rake recheck, backfill, and purge is human-initiated — LLM tasks default to dry runs, deletions ask first (the toolbox). |
Part I
The whole flow and every decision it makes. Follow the spine top to bottom; branches show where a record is kept, hidden, or skipped.
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.
The cheapest gate, run first. Matched by normalized DOI, then title. A hit ends the journey before any model is touched.
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.
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.
Where the article lands is decided entirely by the verdict it earned back in Step 03.
Part II
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.
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
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: "..."
}
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.
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
"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.
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
"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."
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:
| Column | Written by | Holds |
|---|---|---|
| Identity & content | ||
| title | SOURCE | Cleaned; a unique index doubles as the title-dedupe key. |
| abstract | CODE | Source text, HTML stripped. Blank stays blank. |
| doi | CODE | Source DOI, normalized (URL/case) for dedupe. |
| journal_id | CODE | Found-or-created; aggregator/junk names → nil. |
| source | CODE | Canonical label ("PubMed"…). |
| publication_date · full_text_url · full_text | SOURCE | Passed through (full text fetched only for new rows). |
| AI-generated | ||
| summary | AI | 80-120 word plain-language summary. |
| study_design | AI | RCT · cohort · review · theoretical · … or null. |
| sample_size | AI | Integer participants, or null. |
| study_population | AI | Who/what was studied, or null. |
| key_finding | AI | One-sentence primary result or thesis. |
| Relevance (set by the relevance gate / re-check) | ||
| relevance_flag | CODE | true = irrelevant/hidden. Default false; flipped by the re-check pass. |
| relevance_rationale | AI | The model's one-line reason (or an honest prefilter note). |
| relevance_provider · _checked_at · _notes | CODE | Which model judged it, when, and any llm_error. |
| relevance_unflagged_at · _by_id · _notes | HUMAN | Audit trail when a moderator rescues a flagged article. |
| Search & engagement | ||
| search_vector | POSTGRES | Weighted tsvector, generated in the DB (next stage). |
| tags | CODE | String array (legacy keyword tags). |
| pinned · thumbs_up_count · thumbs_down_count · upvotes_count | HUMAN | Admin/reader engagement counters. |
| created_at · updated_at | POSTGRES | Rails timestamps. |
weighted search_vector · pg_search · public index vs admin queue
The database builds the search index itself from four fields at descending weights — note the AI summary is a first-class search field at weight C, so a query can hit the plain-language explanation even when the abstract's phrasing differs:
setweight(to_tsvector('english', title), 'A') || # heaviest
setweight(to_tsvector('english', abstract), 'B') ||
setweight(to_tsvector('english', summary), 'C') || # ← the AI summary
setweight(to_tsvector('english', source), 'D') # lightest
Then the two doors from Part I: relevant → the public research index & reading surface (pg_search over this vector); flagged → the admin moderation queue, each row shown with its stored rationale, where a human can rescue or confirm.
Part III
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 }
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.
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.
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.
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.
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.
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.
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.
Part IV
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.
| Table | Role |
|---|---|
| articles | The corpus. Unique title, weighted search_vector, relevance columns, engagement counters. |
| journals | Publication venues; junk venue names resolve to a nil journal rather than a fake one. |
| authors · authorships | Researchers, joined to articles in stated order. |
| keywords · article_keywords | Controlled keyword tags; also the exact-match half of topic matching. |
| topics | Curated topic pages: name, aliases, cached stats_data, link to their evidence synthesis. No join table to articles — matching is dynamic. |
| meta_analyses | Synthesis records: prompt, provider, answer, per-study evidence rows (strength of evidence is computed from the rows, not stored) (Part VI). |
| clinical_trials | The ClinicalTrials.gov slice, upserted daily by Cycle 03. |
| saved_searches | A followed topic or query plus its digest watermark (last_sent_at). |
| bookmarks · meta_analysis_bookmarks | Reader 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.
| Surface | Reads | Freshness |
|---|---|---|
| Library index & search | pg_search over search_vector, relevance-ranked, filter/sort options. | Live + 5-min anonymous edge cache |
| Article page | One row + related reading via shared keywords. | Live, fragment-cached rows |
| Topic pages | Dynamic 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 index | LIKE over a precomputed search_blob, status filters. | Daily ingest, 5-min edge cache |
| Homepage | Corpus stats, hero, library log bands. | Cached 12h / 1h / 10m |
| Weekly digest email | Per saved search, articles since last_sent_at. | Mondays 14:00 |
| sitemap.xml | Mirrors the public visible.readable set for crawlers. | Cached 1 week |
Part V
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.
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.
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.
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.
| Task | Cost | What it does |
|---|---|---|
| Recheck — second opinions at scale | ||
| relevance:recheck_suspects | AI | Re-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] | AI | Dry-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_flagged | AI | Re-runs the rubric over every flagged article. |
| relevance:backfill_rationales | AI | Fills 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_flags | CODE | Repair: un-hides rows flagged only by a transient llm_error and requeues them for a real check. |
| relevance:check_unchecked | AI | Judges never-checked rows (plus errored ones); resumable; _reverse variant walks newest-first. |
| Bulk delete — deliberate, confirmed | ||
| relevance:delete_irrelevant[file] | HUMAN | Destroys the ids listed in a review file (each check pass writes one to tmp/) after an interactive yes/no. |
| relevance:check_and_delete[limit] | AI | One-pass check + delete; interactive confirm unless FORCE=true. |
| articles:cleanup · articles:dedupe | CODE | Removes DOI duplicates (keeps the oldest) and merges casing-duplicate titles (APPLY=true, else dry run). |
| Backfill & repair — enrich what's already saved | ||
| summaries:redo_all | AI | Regenerates 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] | AI | Recovers 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:backfill | CODE | Fills 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_openalex | CODE | Resolves nil/junk journals (also behind an admin button as JournalBackfillJob). |
| authors:* · data:normalize | CODE | Author name casing/order, dedupe, journal-name cleanup, duplicate-journal merges. |
| strip_html_from_abstracts | CODE | Strips stray HTML from every abstract, batched, only writing on change. |
| articles:study_details_from_syntheses | CODE | Copies 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_evidence | CODE | Syncs config/topics.yml into topic rows (names, aliases, full names) and manually triggers Cycle 05 (FORCE=true regenerates all). |
| fetch:library · _status · _clear | AI | The manual form of the heartbeat: TARGET / YEARS / RESUME / ONLY / SKIP env knobs, same checkpoint. |
Part VI
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.
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.
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.
DeepSeek deepseek-v4-flash at temperature 0.2, up to 4,000 output tokens, thinking disabled. The system prompt is a hard grounding contract:
"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."
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.
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.
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.
| Touchpoint | Model | Output cap | What it returns |
|---|---|---|---|
| Relevance verdict | deepseek-v4-flash | 64 tokens | YES/NO + one-line reason (Part II, Stage 03). Highest-volume call, hence the tiny cap. |
| Summary + study facts | deepseek-v4-flash | 1,024 tokens | 80–120 word summary, design, sample size, population, key finding as one JSON object (Stage 04). |
| Synthesis | deepseek-v4-flash · temp 0.2 (fallback: gemini-2.5-flash) | 4,000 tokens | The 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:
| Call | Typical tokens | Order-of-magnitude cost | Per 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 |