| Layer | Technology |
|---|---|
| Backend | Python 3.11+, FastAPI (Async), SQLAlchemy (Async), Pydantic |
| Database | PostgreSQL (production), SQLite (development/testing) |
| Task Queue | Redis + Arq |
| Scheduling | asyncio.sleep() + custom cron parser + Redis version polling |
| AI/LLM | OpenAI, Anthropic, DeepSeek, OpenRouter, Gemini, Ollama, LM Studio |
| Frontend | Server-side rendered Jinja2 + TailwindCSS + Vanilla JS |
| Infrastructure | Docker & Docker Compose |
| CI/CD | Woodpecker CI (.woodpecker.yml) |
nunti/
├── app/
│ ├── api/ # FastAPI routes (REST endpoints)
│ ├── core/ # Config, database, dependency injection
│ ├── models/ # SQLAlchemy models (Story, Setting, Preference)
│ ├── schemas/ # Pydantic schemas
│ ├── services/ # Business logic (fetcher, AI service, provider registry)
│ ├── tasks/ # Background jobs (worker, scheduler)
│ ├── templates/ # Jinja2 HTML templates
│ ├── utils/ # Scraping utilities
│ └── cli.py # CLI entry point
├── docs/ # Documentation
├── migrations/ # Alembic database migrations
├── tests/ # Test suite
├── docker-compose.yml # Docker Compose (production)
├── docker-compose.dev.yml # Docker Compose (development)
├── Dockerfile
├── start.ps1 # Windows native startup script
├── start.sh # Unix native startup script
└── pyproject.toml # Python project configuration
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/stories/ |
Paginated story list |
| GET | /api/stories/{id} |
Single story detail |
| POST | /api/stories/feedback/negative/{story_id} |
Mark story as irrelevant |
| GET | /api/settings/schedule-status |
Current schedule status |
| POST | /api/settings/ |
Update settings (AI provider, schedule) |
| GET | /api/preferences/ |
User keyword preferences |
| GET | /health |
Health check |
The project uses Woodpecker CI for continuous integration and delivery, with Docker image publishing.
The application is built as a three-process asynchronous system connected through Redis. Each process runs independently and communicates via two Redis channels:
┌───────────────────────────────────────────────────────────────────┐
│ DOCKER / HOST │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ SERVER │ │ SCHEDULER │ │ WORKER │ │
│ │ (FastAPI) │ │ (asyncio │ │ (Arq Worker) │ │
│ │ │ │ sleep loop) │ │ │ │
│ │ Web UI │ │ Cron Check │ │ Process Story │ │
│ │ REST API │ │ Version │ │ AI Translate │ │
│ │ Settings │ │ Polling │ │ Summarize │ │
│ └──────┬───────┘ └──────┬───────┘ └────────┬─────────┘ │
│ │ │ │ │
│ └──────────┬────────┴──────────┬──────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ REDIS │ │
│ │ ┌────────────────┬──────────────┐ │ │
│ │ │ Arq Queue │ Schedule │ │ │
│ │ │ (jobs) │ State Store │ │ │
│ │ └────────────────┴──────────────┘ │ │
│ └─────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────┐ │
│ │ DATABASE │ │
│ │ (PostgreSQL / SQLite) │ │
│ │ Stories │ Settings │ Preferences │ │
│ └─────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────────┘
app/tasks/scheduler.py)The scheduler is a long-running, self-contained Python process that runs a simple asyncio.sleep() loop with built-in cron parsing. It periodically checks whether it’s time to fetch new stories, monitors Redis for real-time schedule changes, and handles periodic cleanup of old activity logs. No external scheduling library is used.
[Scheduler Start]
│
â–¼
┌──────────────────┐
│ Load Schedule │──── Reads cron from Redis; falls back to DB → writes to Redis
│ & Version │
└────────┬─────────┘
│
â–¼
┌──────────────────┐
│ Calculate │──── _calculate_next_run(cron, tz) → next fire datetime
│ Next Run │
└────────┬─────────┘
│
â–¼
┌──────────────────┐
│ Cleanup Old │──── Delete AiActivityLog records older than 30 days
│ Logs │
└────────┬─────────┘
│
â–¼
┌──────────────────────────────────────────────────────────────┐
│ Main Loop (while True) │
│ │
│ 1. Calculate sleep_seconds = next_run - now │
│ 2. Sleep in 30s chunks → check Redis version each chunk │
│ 3. If version changed → reload cron from Redis/DB │
│ 4. When sleep ends → enqueue fetch_and_process_stories │
│ 5. Calculate next run time │
│ 6. Periodic: cleanup old logs at ~03:00 │
└──────────────────────────────────────────────────────────────┘
run_scheduler()The main entry point — runs forever.
async def run_scheduler():
tz = get_tz()
# 1. Load cron: Redis first, fallback DB → write to Redis
redis_config = await get_schedule_from_redis()
cron = redis_config["cron_schedule"] if redis_config else await get_schedule_from_db()
# 2. Calculate initial next run
next_run = calculate_next_run(cron, tz)
# 3. Cleanup old logs
await cleanup_old_logs()
# 4. Main loop
while True:
sleep_seconds = max(0, (next_run - now).total_seconds())
cron, version, next_run, sleep_seconds = \
await sleep_with_schedule_check(sleep_seconds, cron, version, tz, next_run)
await asyncio.sleep(sleep_seconds)
await enqueue_worker_job()
next_run = calculate_next_run(cron, tz, now=datetime.now(tz))
_sleep_with_schedule_check(sleep_seconds, cron, version, tz, next_run)Sleeps in 30-second chunks. After each chunk, checks Redis version key. If version changed, reloads cron schedule from Redis (or DB fallback). Returns updated state.
Version change fix (July 2026): Even if the cron string hasn’t changed, the version counter is updated to prevent the same version change from being re-detected every 30 seconds (infinite log spam bug).
_calculate_next_run(cron, tz, now=None)Pure function — calculates the next fire datetime based on:
datetime.now(tz))If today’s time hasn’t passed → return today. Otherwise → walk forward up to 7 days.
_enqueue_worker_job()Enqueues fetch_and_process_stories into the Arq Redis queue. Returns True on success.
_cleanup_old_logs()Deletes AiActivityLog records older than 30 days. Called at startup and periodically around 03:00.
_parse_cron_time(cron: str) -> tuple[int, int] | NoneExtracts hour and minute from a 5-field cron expression. Returns (hour, minute) or None.
| Cron | Output |
|---|---|
"0 9 * * 1-5" |
(9, 0) |
"30 14 * * 1" |
(14, 30) |
"0 9 * * *" |
(9, 0) |
_parse_cron_days(cron: str) -> list[int]Extracts the weekday field (5th position). Returns list of cron weekdays (0=Sunday..6=Saturday). Empty list means every day.
| Cron Field | Output | Meaning |
|---|---|---|
"1-5" |
[1,2,3,4,5] |
Weekdays |
"1,3,5" |
[1,3,5] |
Mon, Wed, Fri |
"*" |
[] |
Every day |
"1" |
[1] |
Monday only |
app/tasks/scheduler.py — inline)The scheduler uses Redis directly (without a separate ScheduleManager class) to store and synchronize the cron schedule between the server and scheduler processes.
| Redis Key | Type | Purpose |
|---|---|---|
nunti:schedule:config |
String (JSON) | Current schedule configuration: {"cron_schedule": "..."} |
nunti:schedule:version |
String (integer) | Monotonically increasing version counter for change detection |
All Redis interaction is done through short-lived connection pools (created/closed per call):
| Function | Purpose |
|---|---|
_get_redis_pool() |
Creates a short-lived Arq Redis connection pool |
_get_schedule_from_redis() -> dict |
Reads and parses nunti:schedule:config |
_get_schedule_version() -> str |
Reads nunti:schedule:version |
_write_schedule_to_redis(cron) |
Writes config JSON + bumps version counter |
The scheduler polls Redis version inline during sleep (every 30 seconds in _sleep_with_schedule_check), not via a separate background task:
nunti:schedule:versioncurrent_versionnext_runcurrent_version to match Redis (prevents re-detection)app/tasks/worker.py)The worker is an Arq-based background process that consumes jobs from the Redis queue. It processes news stories through the AI pipeline: fetching content, translating titles, summarizing articles, and analyzing comments.
class WorkerSettings:
functions = [
process_story,
fetch_and_process_stories,
reprocess_untranslated_stories,
debug_untranslated_stories,
reprocess_all_stories,
]
redis_settings = RedisSettings(host=..., port=..., ...)
max_jobs = 10 # Concurrent job limit
job_timeout = 600 # 10 minutes per job
fetch_and_process_stories (triggered by scheduler)
│
â–¼
┌──────────────────┐
│ Fetch top 100 │──── HN Firebase API
│ story IDs │
└────────┬─────────┘
│
â–¼
┌──────────────────┐
│ Process each │──── Concurrent fetcher.process_story()
│ story │ - Fetch details
│ │ - Scrape content (trafilatura)
│ │ - Fetch comments
└────────┬─────────┘
│
â–¼
┌──────────────────┐
│ Filter by │──── Skip stories below min_score
│ min_score │
└────────┬─────────┘
│
â–¼
┌──────────────────────────────────────────┐
│ Enqueue individual process_story jobs │──── One job per story
│ via ctx["redis"].enqueue_job(...) │ in Redis queue
└──────────────────────────────────────────┘
│
┌─────────────────┴─────────────────┐
â–¼ â–¼
┌──────────────────┐ ┌──────────────────┐
│ process_story │ │ process_story │
│ (Story #1) │ │ (Story #2) │
│ │ │ │
│ 1. Check DB │ │ 1. Check DB │
│ 2. AI Translate │ │ 2. AI Translate │
│ 3. AI Summarize │ │ 3. AI Summarize │
│ 4. Save to DB │ │ 4. Save to DB │
└──────────────────┘ └──────────────────┘
fetch_and_process_stories(ctx)This is the main entry point triggered by the scheduler. It:
min_score from database settings (default: 100)FetcherService.fetch_and_process_stories(min_score) which:
asyncio.gather)process_story job in the Arq queue"New: 5, Skipped: 2, Errors: 0"process_story(ctx, story_data)This function processes a single story through the AI pipeline:
async def process_story(ctx, story_data):
# 1. Check DB for existing story
existing = await db.execute(select(Story).where(...))
if existing and not existing.is_translated:
# 2. AI: Translate title
title_tr = await ai_service.translate_title(story.title, target_language)
# 3. AI: Summarize content → 3 bullet points
content_tr = await ai_service.summarize_content(story.content, target_language)
# 4. AI: Analyze comments → meta-summary
comments_summary = await ai_service.summarize_comments(story.comments, target_language)
# 5. Save to DB
story.is_translated = ai_service.check_translation_complete(story)
await db.commit()
elif not existing:
# New story: full pipeline + negative feedback check
is_blocked = await ai_service.check_negative_feedback(content, title)
if not is_blocked:
# Translate + summarize + save
...
The AI service (app/services/ai_service.py) supports seven providers through a unified interface:
AIService._call_ai(system_prompt, user_prompt)
│
├── type == "openai-compat" → _call_openai_compat()
│ (OpenAI, DeepSeek, OpenRouter, LM Studio)
│
├── type == "anthropic" → _call_anthropic()
│ (Claude SDK)
│
└── type == "ollama-http" → _call_ollama()
(Local LLM via HTTP API)
Provider auto-detection:
Setting.ai_provider).envhttp://localhost:1143400:00 Scheduler checks cron: "0 9 * * 1-5" → today is Monday → time to fetch
│
├── Scheduler enqueues: fetch_and_process_stories
│
â–¼
Worker picks up fetch_and_process_stories
│
├── Fetcher gets top 100 HN story IDs
├── For each ID: fetch details, scrape content, get comments
├── Filter: score >= 100
│
├── Enqueue process_story for each filtered story
│
â–¼
Worker picks up process_story #1
│
├── Check DB: exists? → yes, needs translation?
├── AI: translate title → "AI Advances in 2026"
├── AI: summarize content → "- Point 1\n- Point 2\n- Point 3"
├── AI: summarize comments → "Discussion focused on..."
│
└── Save to DB → is_translated = True
│
â–¼
User opens http://localhost:8000
│
├── Jinja2 renders story list
├── Shows translated title, summary, comment analysis
│
└── User reads stories in their preferred language
When a user updates the schedule via the web interface:
User: changes cron to "0 14 * * 1,3,5" (14:00 on Mon/Wed/Fri)
│
â–¼
POST /api/settings/ { cron_schedule: "0 14 * * 1,3,5" }
│
â–¼
Server process:
├── Save to database (Setting.cron_schedule)
├── Write to Redis directly:
│ ├── SET nunti:schedule:config = {"cron_schedule": "0 14 * * 1,3,5"}
│ ├── INCR nunti:schedule:version
│
└── Response: 200 OK
│
â–¼
Scheduler process (checking every 30s during sleep):
├── GET nunti:schedule:version → "5"
├── Compare with local version "4" → different!
├── GET nunti:schedule:config → {"cron_schedule": "0 14 * * 1,3,5"}
├── Calculate new next_run with updated cron
├── Update local version to "5"
│
└── Next fetch will happen at 14:00 on the next scheduled day (Mon/Wed/Fri)
start.ps1)The PowerShell start script provides intelligent service management:
| Parameter | Behavior |
|---|---|
-Mode server |
Starts only the FastAPI web server |
-Mode worker |
Starts only the Arq worker |
-Mode scheduler |
Starts only the scheduler |
-Mode all |
Starts server + worker + scheduler (requires Redis) |
-Mode full (default) |
Starts all services + auto-starts Redis container |
-NoMigration |
Skips Alembic migration on startup |
Redis auto-detection:
REDIS_HOST:REDIS_PORTdocker run -d --name hn-redis -p 6379:6379 redis:6-alpine)start.sh)Bash equivalent for Linux/macOS/Git Bash. Same service modes, with --no-mig flag for skipping migrations. Uses /dev/tcp for Redis connectivity checks (if timeout command is available).
Story| Field | Type | Description |
|——-|——|————-|
| id | Integer (PK) | Auto-increment ID |
| hacker_news_id | String (unique) | Original news item ID |
| title | Text | Original English title |
| title_tr | Text | AI-translated title |
| url | Text | Story URL |
| score | Integer | HN upvote score |
| author | String | HN username |
| content | Text | Scraped article content |
| content_tr | Text | AI-summarized content in target language |
| comments_summary | Text | AI-generated discussion summary |
| is_translated | Boolean | Whether AI processing is complete |
| is_blocked | Boolean | Filtered by negative feedback |
| created_at | DateTime | When the story was fetched |
Setting| Field | Type | Description |
|——-|——|————-|
| id | Integer (PK) | Auto-increment ID |
| min_score | Integer | Minimum HN score threshold |
| cron_schedule | String | Cron expression for scheduling |
| ai_provider | String | Selected AI provider ID |
| ai_model | String | Selected model name |
| ai_provider_config | JSON | Provider-specific config (base URL, etc.) |
UserPreference| Field | Type | Description |
|——-|——|————-|
| id | Integer (PK) | Auto-increment ID |
| keywords | JSON | List of interest keywords for filtering |
| translation_language | String | Target language code (e.g., “en”, “tr”) |
NegativeFeedback| Field | Type | Description |
|——-|——|————-|
| id | Integer (PK) | Auto-increment ID |
| story_id | Integer (FK → Story) | Referenced story |
| reason | Text | Optional user feedback |
| Component | Concurrency Model | Details |
|---|---|---|
| FastAPI Server | Asyncio (single process) | Async endpoints, async DB with SQLAlchemy |
| Arq Worker | Asyncio + multiprocessing | max_jobs=10 concurrent jobs per worker |
| Scheduler | Asyncio (single process) | asyncio.sleep() + Redis version polling |
| Fetcher | Asyncio | asyncio.gather for concurrent HN API calls |
| AI Service | Blocking I/O in async | OpenAI/Anthropic SDKs called with timeouts |
job_timeout: 600 seconds per job