AI Pipeline
The AI pipeline turns a cluster of scanned posts into a verified article. It writes, checks and, when the check finds something to fix, rewrites once. Scout’s Take, tags and the image are added later by the enrichment reconciler, not here.
Pipeline Flow
Cluster of scanned posts
One story, posts from the last 48 h, past both duplicate checks
cmd/milesahead/scan.goPosts from 2 or more sites?
pipeline.go · ProcessIf no:
Not generated
ErrNotEnoughSourcesPick the article type
deal or summary, from keywords in the posts
pipeline.go · detectArticleTypeGenerate
The generator writes the article from the sources
next model in the chain on a 429, 5xx, timeout or retired model
generator.go · GenerateIf error:
Dropped from this scan
Split out editor notes
Admin-only remarks leave the text before anything reads it
pipeline.go · takeEditorNotesRead key details and links
From the raw reply, before any cleanup
pipeline.go · replyDetailsClean Markdown
Leftover Markdown becomes HTML
pipeline.go · cleanMarkdownStrip citation markers
[1]goes; numbered “Source N” references are notedpipeline.go · cleanSourceRefsCheck for news-site names
Links to outlets are unwrapped; mentions are noted
pipeline.go · checkOutletsCritic chain scores it
Factual, attribution, completeness and hallucination checks; thinking off
each critic retries once, then the next takes over
critic.go · VerifyIf error:
Dropped from this scan
A reply without JSON counts as an error too
Drop remarks that confirm a pass
An issue that only says a check passed is removed
criticissues.go · dropPassedChecksSource reference or outlet mention noted?
pipeline.go · flagSourceRefs, flagOutletMentionsIf no:
Verdict as the critic gave it
If yes:
Add them as issues
The attribution check fails
Adjust the score
A failed hallucination check caps it at 60; each other failed check costs 10
pipeline.go · adjustScoreAny issue left?
pipeline.go · needsRevisionIf no:
Keep the draft
If yes:
Revise once
The generator gets the issue list; the same cleanup and checks run on the reply
one revision only, never a loop
generator.go · GenerateWithFeedbackCritic checks the revision
critic.go · VerifyKeep the higher score
The revision wins a tie; if revising or its check fails, the first draft stays
Finalize
Title, TLDR and body HTML from the kept version
pipeline.go · finalizeContentKey details in the reply?
If yes:
Use them
If no:
Extract them separately
One call on the short-call chain; a detail citing a source or naming an outlet is dropped
pipeline.go · extractDetails
Drop links to news sites
Articles link to official pages only
outlets.go · DropOutletLinksScore at least
critic_threshold(75)?The article manager stores the result
internal/article/manager.go · CreateFromPipelineIf yes:
Pending
In the admin’s review queue
If no:
Draft
Saved for the admin, not queued
Pipeline.Process (internal/ai/pipeline.go) turns a cluster into an
article. Files are in internal/ai unless the path says otherwise. A
retry or fallback is marked ↻ on its box rather than drawn as a loop.An admin’s rewrite (ProcessWithFeedback, via buildResult) puts the new
reply through the same cleanup, checks and scoring, but not the
auto-revision or the separate key-detail call: the admin reads the result.
A cluster needs posts from at least two different sites; with fewer the pipeline returns ErrNotEnoughSources and nothing is generated. Before a cluster reaches the generator, the scan loop (cmd/milesahead/scan.go) runs the two duplicate checks described in Article Workflow.
Models
| Role | Config key | Model | Purpose |
|---|---|---|---|
| Generator | generator_model | nvidia/nemotron-3-ultra-550b-a55b | Article generation and revision |
| Generator fallbacks | fallback_generator_model | openai/gpt-oss-20b, nvidia/nemotron-3-super-120b-a12b | Tried in order when the generator fails or is retired |
| Critic | critic_model | nvidia/nemotron-3-super-120b-a12b, moonshotai/kimi-k3 | Factual verification and scoring only; tried in order, thinking off |
| Short-call chain | enrichment_model | openai/gpt-oss-20b, nvidia/nemotron-3-super-120b-a12b | Story dedup, detail extraction, Scout’s Take, tags, image queries, image text-scoring, newsletter pick, card-update detection |
| Vision | vision_model | meta/llama-3.2-11b-vision-instruct | Image relevance scoring (Phase 2) |
All models are accessed through the NVIDIA API at https://integrate.api.nvidia.com/v1. config.example.yaml records why each was chosen, with the measurements behind each choice. A chain is tried left to right on a retryable error (429, 5xx, timeout) or a model that is gone (404, 410); a model fallback succeeded or model failed, trying next in chain line is logged.
Generation and verification calls get the full request_timeout (900 s in production): the generator takes 105-510 s per article. Short calls are capped at 30 s (ai.ShortCallTimeout), so only fast models belong in enrichment_model.
Critic Chain and Thinking Off
The critic is used for verification and nothing else; the short calls run on the enrichment_model chain. Keeping them apart means a failed critic stops publication without taking image scoring, the newsletter and card updates down with it, as happened when mistralai/mistral-nemotron was retired on 2026-09-28.
The critic is a chain because it failed as a single model twice in two days: retired on 2026-09-28, then its replacement, deepseek-ai/deepseek-v4.1-flash, answered every call with a gateway 504 from 2026-09-29. One reachable critic is enough to publish. With more than one critic, each retries once before the chain moves on, so a critic stuck on 5-minute gateway timeouts hands over after about 10 minutes instead of 20.
A critic error drops the article from the scan; a score below critic_threshold only saves it as a draft. A critic that answers with JSON that cannot be parsed is an error, not a reason to try the next critic.
With critic_disable_thinking: true, critic requests (and only those) carry chat_template_kwargs: {"enable_thinking": false}. They go through a copy of the client (HFClient.WithThinkingDisabled); generation, enrichment and vision use the original and never send it, even though nemotron-3-super is in their chains too. With thinking on, nemotron-3-super truncates or leaks its reasoning and scored a real article 45 instead of 75.
Measured on the real critic prompt, two articles from the production generator, threshold 75:
| Critic, setting | Article 1 | Article 2 | Latency |
|---|---|---|---|
| nemotron-3-super-120b-a12b, thinking off | 75 | 95 | 28 s, 1.7 s |
| moonshotai/kimi-k3, thinking off | 62 | 62 | 44-50 s |
| deepseek-v4.1-flash, thinking off | 50 | 50 | 67-101 s |
| nemotron-3-super-120b-a12b, thinking on | 45 | 503 | - |
nemotron-3-super is the same family as the generator and also a generator fallback, so an article the generator wrote on that fallback is graded by the model that wrote it – watch the approval rate. kimi-k3, the fallback critic, grades more strictly, so articles it scores mostly land as drafts. The critic scored log line and verification_results name the model that scored.
Generation Process
The generator receives clustered source articles and produces structured output. Article type is auto-detected from source content:
- deal – time-limited offers (bonuses, sales, discounts, signup offers)
- summary – news, changes, analysis, reviews (shown to readers as “News”)
- roundup – multiple related stories grouped together
Each source is labelled for the model as === From <Outlet> ===, without a number, in the order the scan saw it; revisions load sources in the same order. The prompt enforces a plain, practical style and these structural markers:
Title line
TLDR: One-sentence summary
KEY_DETAILS_START
- Label: Value
- Label: Value
KEY_DETAILS_END
LINKS_START
- Link Text | URL
LINKS_END
Article body content...Critique Process
The critic evaluates generated articles against the original sources on four dimensions:
| Check | What It Verifies |
|---|---|
FactualCheck | All facts match the source material |
AttributionCheck | No news site named outside the two exceptions, no numbered source references |
CompletenessCheck | Key information from sources is included |
HallucinationCheck | No invented facts or unsupported claims |
The critic produces a score from 0-100 which is then adjusted (adjustScore):
- Hallucination failure caps the score at 60
- Each other failed check deducts 10 points
- Any failure caps the score at 85
- Two or more failures cap the score at 70
Articles scoring at or above critic_threshold (75 in production) pass and are saved as pending; below it they are saved as draft. Both wait for the admin in the review queue.
Critic Issues and the Passed-Check Filter
Any issue left on the verdict triggers the auto-revision: an extra generator rewrite and a second critic call (median 75 s plus two AI calls). The score and the checks deliberately do not gate it, because verdicts that passed every check at 85-95 still carried real errors in production (a miscalculated per-point price, a wrong joint-venture member, a “202026” typo).
What wasted revisions was “issues” that were not problems. Measured over 30 days, 117 of 131 verdicts (89%) had at least one issue, 98 of those with all four checks passing, and in 7 days of logs 68 of 71 first verdicts (96%) triggered a revision. Two changes address it:
- The critic prompts (
criticIssuesRules) ask for fixes only: one plain string per issue saying what is wrong, where, and the fix. Passed checks, confirmations, praise and “acceptable” remarks are forbidden, and a clean article returns[]. dropPassedChecks(internal/ai/criticissues.go) removes an item only if every sentence carries a pass word and no negative or change word (but, not, missing, incorrect, should, minor, …), or if it ends exactly “No issue.” or “So no error.”. Quoted article text is ignored, and it never empties the list of a verdict with a failed check. The log linedropped critic issues that confirm a passed checkcounts them.
Issues the critic returns as {"problem", "fix"} objects are flattened into strings rather than failing the parse. The source-reference and outlet issues below are added after filtering, so they always force a revision.
Revision Flow
When an issue is left after filtering, the pipeline runs one revision cycle:
- The generator receives the original content plus the list of issues
- It rewrites the article addressing each issue
- The critic re-evaluates the revised version
- The higher-scoring version is kept; the revision wins a tie
There is only one: a mention of a news site that survives it stays on the verdict, and the article is kept with attribution failed for the admin to judge.
Admins can also ask for a rewrite with feedback text from the dashboard. Those rewrites go through the same guards and critic, and are retried by a revision reconciler until they succeed or exhaust their retry budget; see Article Workflow.
Key Details
The generator’s own KEY_DETAILS and LINKS sections are parsed from the raw reply (replyDetails), before Markdown cleaning turns - lines into HTML list items. Until 2026-10-01 they were parsed after it and never found, so every article needed a separate extraction call: 70 of 70 in one week, 9 of which shipped without key details.
The same parse covers the first draft, an accepted auto-revision (its details replace the draft’s) and admin revisions. Stray ** is stripped, and a detail with a numbered source reference is dropped. Only a reply with no usable details costs a separate call on the enrichment_model chain, logged as no key details in the generated article, extracting them separately.
Key details also give an article its offer end date: internal/offer reads a short list of deadline labels and stores the latest date in expires_on.
Source References and Outlet Mentions
Articles never name or refer to the news sites and blogs they are written from (every row of sources, a built-in list of 12, and the aliases TPG, OMAAT, VFTW, DoC and FM). Issuers, airlines, hotel chains and their official pages are not news sites. There are two exceptions:
- The outlet is itself the subject of the story (a sale on Thrifty Traveler Premium, say).
- An unconfirmed report that only that outlet carries, mentioned once, as a report.
The rule, with both exceptions word for word, is in the deal, roundup, summary, revision, Scout’s Take and key-details prompts and both critic prompts. Generated text is also checked deterministically:
- Numbered references (“Source 1 notes…”, “the first source”, “Article 2”) are flagged, never rewritten to the outlet’s name (
internal/ai/sourcerefs.go). The attribution check fails, the issue tells the writer to state the fact without attribution, and the auto-revision runs. Citation markers ("[1]") are removed. - Outlet mentions (
internal/ai/outlets.go): its name, an alias, its domain, or a written-out URL on its site, in the title, TLDR, key details or body. Matching is whole-word on visible text; “The” is optional, “LoyaltyLobby” matches “Loyalty Lobby”, and all-lowercase phrases (“one mile at a time”) do not trigger. Each outlet found adds one attribution issue quoting the sentence and stating both exceptions. - Links to an outlet’s site are unwrapped, keeping their text, before the critic sees them, and outlet entries are dropped from the link list. Articles link to official pages only. These removals alone do not trigger a revision.
- Enrichment has no revision after it, so a Scout’s Take that names an outlet or has a numbered reference is refused (and retried next pass), and such a key detail is dropped, unless the title itself names the outlet.
To list stored articles that still name or link to an outlet, or carry a numbered reference, run the read-only report (it opens the database with mode=ro and has no write mode, since prose needs a person to fix it):
go run ./cmd/flag-outlet-mentions -db ./data/milesahead.db
go run ./cmd/flag-outlet-mentions -db ./data/milesahead.db -status approved,pendingAn earlier cmd/repair-source-refs rewrote “Source N” to outlet names; it was removed when the policy changed.
Editor Notes
A remark meant for the admin rather than readers (“Could not find a link for: …”) is an editor note: an element with the editor-note class. internal/editornote splits it out of the generator’s reply before anything parses or grades it, so the key details, the critic and the auto-revision see only what readers will. Notes go to the admin-only editor_notes column (json:"-", so no JSON output carries them); a revision’s notes replace the previous ones. In notes only, “Source N” is spelled out as the outlet’s name, which tells the admin which source a note is about.
The db layer splits again on create, revision and update, and every article read strips notes still inline in older rows, so pages, excerpts, the feed, the newsletter, meta tags, search and AI prompts never see them. The admin preview shows them as “Editor note (not published)”. cmd/repair-editor-notes moves notes out of rows stored before this; see Development.
Story Dedup
Before generation, each cluster that passed the raw-ID check is compared with every article generated in the last 7 days, in any status (internal/storydedup). A TF-IDF prefilter on titles keeps up to 5 articles scoring at least 0.25; if there are any, one short call on the enrichment_model chain answers “same story as which, and is it a materially new development?”. A follow-up is generated. If the call fails, or names an article it was not shown, a lexical score of at least 0.55 decides.
scanner.story_dedup sets the mode: shadow (the default, and production’s) logs story dedup decision with would_block and counts milesahead_story_dedup_decisions_total{decision,mode} but still generates; block skips the duplicate and records its posts in covered_raws (reason story_match); off skips the check. Details and the measurements behind the thresholds are in Article Workflow.
Preflight
At startup the app probes every configured model (generator, fallbacks, every critic, vision, the short-call chain) and logs ai preflight complete, or an ERROR if no critic answers (articles would be generated but none would publish) or no generator answers. Generator, fallback and critic probes use the same 4096-token budget and full request_timeout as real calls, so with a slow model the summary arrives minutes after boot. milesahead_ai_preflight_models_reachable{role} exports the count per role; role="critic" at 0 means nothing can publish.
cmd/ab-models measures candidate models against real source material before a change; see Development. The provider’s model catalogue is not a liveness signal: of 81 models it listed, only 9 answered.
Enrichment
Scout’s Take, key details (when generation left none), tags and the article image are filled in by a background reconciliation pass (internal/enrich), not during generation. Inline, each was a best-effort call, and for two and a half months roughly a quarter of articles published with a field missing. Reconciled, a miss is simply picked up next pass.
- It runs every
enrichment_intervalminutes (15), and once about 90 s after startup. - Every call goes to the
enrichment_modelchain, capped at 30 s. enrichment_max_attempts(3) bounds how many passes an article is retried;enrichment_max_duration(10 minutes) bounds one pass, checked between articles.- Each pass first converts stored images to sized copies, then serves admin Find a new image requests, then the rest.
milesahead_articles_degradedcounts articles still missing something, andmilesahead_enrichment_last_success_timestamp_secondsfeeds the “enrichment reconciler has stalled” alert (no completed pass for over an hour).
Scout’s Take is a brief opinion in the voice of site.persona_name (Scout), stored in scouts_take. A take that names a news site or has a numbered reference is refused and retried. cmd/backfill-scouts-take uses the same function (ai.GenerateScoutsTake), so it goes through the same guard.
Image Pipeline
The enrichment pass finds and scores a relevant image; see the image pipeline.