system/news-pipeline.md
The News pipeline is a multi-stage content enrichment workflow. Articles enter as raw tweets/links and are progressively enriched by agents through 6 stages.
NEW → REVIEWED → PROCESSED → REFINED → CONCLUDED → ARCHIVED
↑ ↑ ↑ ↑ ↑ ↑
Intake Mason Mack Alex Turf Monster Alex
| Stage | Agent | What happens | Fields populated |
|---|---|---|---|
| New | Intake (automated) | Raw article/tweet ingested | title, url, x_post_id, x_post_url, author, published_at |
| Reviewed | Mason | Identify people, teams, action | primary_person, primary_team, primary_action, secondary_person, secondary_team, article_image_url |
| Processed | Mack (automated) | Generate slugs from names | primary_person_slug, primary_team_slug, secondary_person_slug, secondary_team_slug |
| Refined | Alex | Summarize, add tone | title_short, summary, feeling, feeling_emoji, what_happened |
| Concluded | Turf Monster | Form opinion, suggest follow-ups | opinion, callback |
| Archived | Alex | Done — no longer active | archived_at |
All services live in app/services/news/. They reopen the News class (not a module — class News, not module News).
News::IntakeFetches the latest Adam Schefter tweet not already in the DB.
news = News::Intake.new.call
# => News record or nil
GET /2/users/:id/tweets)AdamSchefter → user ID (cached per instance)x_post_idX_BEARER_TOKEN env varNews::Review (Mason)News::Review.new(news).call(
primary_person: "Patrick Mahomes",
primary_team: "Kansas City Chiefs",
primary_action: "extended",
secondary_person: "Dak Prescott",
secondary_team: "Dallas Cowboys",
article_image_url: "https://example.com/image.jpg"
)
News::Process (Mack) — fully automatedNews::Process.new(news).call
# No input needed — derives slugs from existing person/team fields
# "Patrick Mahomes" → "patrick-mahomes"
Also available via the UI: Process (auto) button on the show page when stage is reviewed.
News::Refine (Alex)News::Refine.new(news).call(
title_short: "Mahomes extends with Chiefs",
summary: "Patrick Mahomes agrees to a record extension.",
feeling: "excited",
feeling_emoji: "🔥",
what_happened: "Chiefs locked up their franchise QB."
)
News::Conclude (Turf Monster)News::Conclude.new(news).call(
opinion: "Smart move by KC — stability at QB is everything.",
callback: "Watch for contract details and cap implications."
)
bin/rails news:intake # Fetch latest Schefter tweet
1Password: Stored in agent.turf.x in the agents vault
bash
op item get zz3uigmkrwjlnnksst33butc4e --vault txqp6ijdo3ujsfhsfzdj5h5dzq --field "Bearer Token" --reveal
Local .env: Add to /Users/alex/projects/.env (symlinked into all apps):
X_BEARER_TOKEN=<bearer-token-from-1password>
Heroku (production): Set via config var:
bash
heroku config:set X_BEARER_TOKEN=<token> --app mcritchie-studio
alex@turfmonster.com (credentials in 1Password agent.turf.x)When starting fresh (new machine, new DB, or new agents suite):
bin/rails db:migrate
bin/rails db:seed # Creates 7 sample news articles across all stages
# Fetch bearer token from 1Password
export OP_SERVICE_ACCOUNT_TOKEN='<token-from-zshrc>'
X_TOKEN=$(op item get zz3uigmkrwjlnnksst33butc4e \
--vault txqp6ijdo3ujsfhsfzdj5h5dzq \
--field "Bearer Token" --reveal)
# Add to shared .env
echo "X_BEARER_TOKEN=$X_TOKEN" >> /Users/alex/projects/.env
Or manually: copy the Bearer Token from 1Password and paste into .env.
bin/rails news:intake
# Expected: "Created: <tweet text> (news-xxxxxxxxxxxx)"
n = News.last
# Step 1: Review (Mason)
News::Review.new(n).call(
primary_person: "...", primary_team: "...", primary_action: "...",
secondary_person: nil, secondary_team: nil, article_image_url: nil
)
# Step 2: Process (Mack) — automated
News::Process.new(n).call
# Step 3: Refine (Alex)
News::Refine.new(n).call(
title_short: "...", summary: "...",
feeling: "...", feeling_emoji: "...", what_happened: "..."
)
# Step 4: Conclude (Turf Monster)
News::Conclude.new(n).call(
opinion: "...", callback: "..."
)
class News (reopening the AR model class), not module News, to avoid TypeError collision