Stories get two LLM-written artifacts (Perplexity Discover style), both with
[n] citations that link to each article's own permalink (Article.url):
Brief (feed cards and the top of the story page): one lead sentence (at
most 30 words, what happened) plus exactly three bullets (at most 28 words
each: what happened, why it matters or context, what's next or a key
detail). Cards show the image, title, lead, the three bullets, source chips
and relative time; card_style: lead_only hides the bullets.
Detailed report (story page, below the brief): 4-6 paragraphs, about
300-550 words, paraphrasing the sources, then the numbered Sources list.
Every card opens its story page (single-source cards also have a "Read on
" link). [n] is the same article in the brief, the report and the
Sources list. The page shows the model and when it was written.
Written by an OpenAI-compatible chat completions endpoint — see
LLM endpoints. Only the numbered sources' text goes
into the prompt; extracted article text is never returned by the public API or
rendered in a page.
app/lifecycle.py, app/summarize/worker.py, app/publish.py. The app is
"always on": generation runs in the background and pages only read finished,
published story views. Opening a page never triggers, queues or waits on the
LLM.
Story states. Every article gets a story at ingest, which moves
ingested → text_pending → text_final → clustered → brief_ready →
report_final → published. text_final means extracted text is available or
unavailable: decided after extraction, or after extraction_wait_seconds
(120).
One story at a time, newest first. The worker takes the highest-priority,
most recently updated story and runs its brief, then its report (if
eligible), then the publish decision, before it starts the next story.
max_inflight (1) caps all LLM requests process-wide. Unpublished stories
older than summarize_backfill_hours (24) are skipped and at most
summarize_backfill_max (40) unpublished stories are worked on; published
stories that gained members are always refreshed.
Report eligibility is decided without the LLM: at least one included
member with text_status=ok and 150+ extracted words. Otherwise the report
is final as skipped(insufficient_text) with each member's status and word
count. Report failures back off their own job and never pause briefs; brief
endpoint failures (DISCOVER_SUMMARIZE_FAILURE_LIMIT, 3 in a row) pause the
worker for DISCOVER_SUMMARIZE_PAUSE_MINUTES (10).
Publish rule. A story publishes once its brief is valid and its report
is final (ready, skipped(reason) or failed(reason)). With
publish_requires_report: false it publishes as soon as the brief is valid.
After publish_max_wait_minutes (30) it publishes with the report pending
("Detailed report still generating"). If the brief fails for good, the story
publishes after brief_fallback_hours (6) with the feed snippet, labelled
Summary unavailable: <reason>.
Story views. Every change writes a new version to story_views
(story_id, version, schema_version, state, published_at, doc JSON,
updated_at) in the same transaction as the summary row.
stories.published_version moves only when the new version meets the publish
rule, so the old version stays on screen while a replacement is generated.
One document per story; the profile's sources are filtered when it is read.
Existing databases are migrated in place on startup (idempotent).
Live arrivals. Every publish, update and retraction is stored in
story_events (kept event_retention_days, 7) and streamed by
GET /api/v1/stream (see Public API); the pages still
use the id-only GET /api/profiles/{slug}/stream (story.published /
story.updated with {story_id, version} and a heartbeat event). The feed
shows "N new stories — click to load" without moving the grid; without SSE it
polls GET /api/profiles/{slug}/published?since=<iso> every 30 s. An open
story page offers a reload when a newer version is published.
Before the first story. The feed says "Preparing your first stories..."
with live counts (ingested, extracting, clustering, summarizing N of M) and
names what blocks generation: no endpoint set, endpoint unreachable, HTTP 401
or the worker paused.
Generation attempts.generation_attempts keeps the latest 500 LLM
calls: story, kind, model, endpoint host (never the key), member text
statuses and word counts, input words, estimated tokens, usage, finish
reason, latency, content and reasoning length, the raw content (truncated),
validation errors, outcome and reason (empty_content, truncated,
reasoning_consumed_budget, timeout, http_<status>, invalid_json,
validation:<rule>, insufficient_text). A warning is logged when thinking
is disabled but the model still returns reasoning.
Prompt size.llm.summarizer.context_tokens (16384) is the model's
context window; report inputs are trimmed so the answer
(report_max_tokens) still fits. After the usual validation retry, a last
attempt asks for structured_output: json_object and extracts the JSON
leniently; citation, number and verbatim-copy checks still apply. Retries
quote the exact phrases that were copied, ask for a different sentence
structure and run retry_temperature_bump (0.2) hotter. Raw newlines/tabs
inside JSON strings become spaces (no glued words); suspected glued words
(tasksAI, AIworkloads) are logged as attempt warnings, never rejected.
Input. Briefs use up to DISCOVER_SUMMARIZE_MAX_ARTICLES_PER_STORY (5)
members and the first DISCOVER_SUMMARIZE_MAX_WORDS_PER_ARTICLE (1200) words
of each; reports use up to report_max_articles (4) members, one per source
first, sharing report_total_words (2400) source words (a one-source story
gets all of them; a short article's unused share goes to the others), keeping
the brief's numbering.
Validation. Briefs are rejected for a wrong bullet count, a lead that is
not one sentence, length violations, bad citations, numbers not in a cited
source, or near-duplicate bullets (duplicate_threshold, 0.6). Reports are
rejected for fewer than 4 or more than 6 paragraphs, fewer than 270 or more
than 605 words (the 300-550 target plus 10%), uncited paragraphs, bad
citations, invented numbers, duplicate paragraphs, a run of
verbatim_max_run (12) or more words copied from a source (case,
punctuation and hyphens ignored), or more than verbatim_max_ratio (20%) of
the report's 8-word phrases found in the sources. Copied runs made only of
story-title words, numbers or verbatim_allow terms don't count. A brief
that keeps failing falls back to a 2-3 sentence summary, then to the feed
snippet.
Every version is kept in story_summaries; input_hash covers member ids,
source text hashes, prompt version, model and kind, so unchanged input means
no LLM call.
Pipeline panel (Settings > Pipeline): every unpublished story with its
state and reason and an ETA from the median brief/report time, Prioritize
and Retry per story, Pause/Resume generation, the last 20 generation
attempts, and Generation quality over the last 500 attempts: first-attempt
pass rate per kind, median attempts per success, median end-to-end model
seconds per story (brief + report) and the failure reasons.
Regenerate and Retry queue a new version; the current one stays
visible until replaced.
POST /api/admin/summarize (?story_id=, ?kind=brief|report|both,
?force=true, ?wait=true) queues generation.
API: story feed items and GET /api/stories/{id} (published only) carry
version, published_at, brief ({lead, lead_citations, bullets, status,
style, basis, model, generated_at, sources, label}), report
({paragraphs, state, status (deprecated), reason, label, details, model,
generated_at, sources}) and the deprecated summary.
python-mapp.summarize.diagnose--auto--kindboth# newest story waitingpython-mapp.summarize.diagnose--story-id42--kindreport--markdown
python-mapp.summarize.diagnose--auto--dry-run# prompt only, no LLM callpython-mapp.summarize.diagnose--auto--kindreport--full# uncut prompts and answers
(Windows: .venv\Scripts\python -m app.summarize.diagnose --auto.) It prints
the members with text status and word counts, which are sent, the prompt size
against context_tokens, then each attempt's raw response, finish reason,
usage, tokens/second, validation result and warnings, then the validated text
exactly as it would be stored. Prompts and answers are cut to 1,500 characters
unless --full. Nothing is written to the database.
python -m app.summarize.compare reports the same quality metrics as the
Pipeline panel per endpoint.