orbis-backend / docs /DOCS.md
Deusxx1234's picture
Initial deployment to HF Spaces
c84fdae
# Orbis AI Media OS β€” Complete Documentation
> **Version:** 1.0 Β· **Last updated:** 2026-05-12
> **Stack:** FastAPI Β· PostgreSQL Β· LangGraph Β· Pillow Β· APScheduler Β· React Β· Cloudinary Β· Railway + Netlify
---
## Table of Contents
1. [What This System Does](#1-what-this-system-does)
2. [Architecture Overview](#2-architecture-overview)
3. [Tech Stack](#3-tech-stack)
4. [Database Schema](#4-database-schema)
5. [Content Generation Pipeline](#5-content-generation-pipeline)
6. [Post Types](#6-post-types)
7. [text_image β€” PIL Card System](#7-text_image--pil-card-system)
8. [Scheduler β€” Automated Publishing](#8-scheduler--automated-publishing)
9. [LLM Agent & Model Fallbacks](#9-llm-agent--model-fallbacks)
10. [API Reference](#10-api-reference)
11. [WebSocket Real-Time Updates](#11-websocket-real-time-updates)
12. [Frontend Pages](#12-frontend-pages)
13. [Environment Variables](#13-environment-variables)
14. [Deployment Guide](#14-deployment-guide)
15. [Local Development](#15-local-development)
16. [Key Design Decisions](#16-key-design-decisions)
17. [Known Limitations & TODOs](#17-known-limitations--todos)
---
## 1. What This System Does
**Orbis AI Media OS** is a fully automated social media content engine. It:
1. Discovers trending topics (Reddit, manual seeds)
2. Generates platform-native posts using an LLM agent (OpenRouter / Groq)
3. Creates or sources images (Pollinations AI)
4. Burns text directly onto images for `text_image` posts using Pillow (quote-card style)
5. Uploads final media to Cloudinary CDN
6. Saves drafts for human review in a web dashboard
7. Publishes approved posts to Instagram (LinkedIn, Twitter wired but not yet active)
Everything runs autonomously on a schedule. A human moderator approves posts via the dashboard before they go live (unless `AUTO_APPROVE=true`).
---
## 2. Architecture Overview
```
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ SCHEDULER (APScheduler) β”‚
β”‚ Every 6h: Fetch Trends β”‚ Daily: Generate Posts (N/day) β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚ β”‚
β–Ό β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Trends β”‚ β”‚ LLM Agent β”‚
β”‚ Table │──────────▢ β”‚ (LangGraph) β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ OpenRouter β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚ caption + image_prompt
β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Image Generation β”‚
β”‚ Pollinations AI β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚ raw image URL
β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ PIL Card Generator β”‚ (text_image only)
β”‚ typography card β”‚
β”‚ editorial photo cardβ”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚ composited image
β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Cloudinary β”‚
β”‚ CDN Upload β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚ permanent URL
β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Post Draft (DB) β”‚
β”‚ status=draft β”‚
β”‚ approval=pendingβ”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ β”‚ β”‚
β–Ό β–Ό β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ React Dashboardβ”‚ β”‚ PostgreSQL NOTIFY β”‚ β”‚ Instagram API β”‚
β”‚ (Netlify/Vite) │◀────│ WebSocket push β”‚ β”‚ on approval β”‚
β”‚ Human review β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
```
### Request Flow (Generate via Dashboard)
```
User clicks "Generate" β†’ GenerateModal (pick type + platform)
β†’ POST /api/workflows/trigger {post_type, platform}
β†’ pipeline.generate_post_content() [LLM]
β†’ pipeline.generate_images() [Pollinations]
β†’ pipeline.store_media_to_cdn() [Cloudinary]
β†’ pipeline.save_post_draft() [Pillow overlay if text_image]
β†’ DB INSERT post (status=draft)
β†’ PG NOTIFY posts_changed
β†’ WebSocket push to browser
β†’ React Query cache updates
β†’ Post appears in dashboard grid
```
---
## 3. Tech Stack
### Backend
| Layer | Technology |
|-------|-----------|
| API server | FastAPI (Python 3.11+) |
| Database | PostgreSQL (Neon on Railway) |
| ORM | SQLAlchemy 2.x async |
| Scheduler | APScheduler (AsyncIOScheduler) |
| LLM framework | LangGraph + LangChain |
| LLM providers | OpenRouter (primary), Groq (fallback) |
| Image generation | Pollinations AI (free, no key needed) |
| Image processing | Pillow (PIL) |
| CDN | Cloudinary |
| Social publishing | Instagram Graph API |
| HTTP client | httpx (async) |
| Logging | Loguru |
| Config | Pydantic BaseSettings |
### Frontend
| Layer | Technology |
|-------|-----------|
| Framework | React 18 + Vite |
| State / data | TanStack Query (React Query) |
| Routing | React Router v6 |
| Charts | Recharts |
| Real-time | Native WebSocket |
| Styling | Custom CSS variables (dark-first) |
| Deployment | Netlify |
### Infrastructure
| Service | Provider |
|---------|---------|
| Backend hosting | Railway |
| Database | Neon PostgreSQL (via Railway) |
| Frontend hosting | Netlify |
| Media CDN | Cloudinary |
| Font delivery | GitHub rsms/inter (downloaded at runtime) |
---
## 4. Database Schema
### `agents`
| Column | Type | Notes |
|--------|------|-------|
| id | PK | |
| name | VARCHAR | e.g. "Default Agent" |
| description | TEXT | |
| is_active | BOOL | default true |
### `agent_versions`
| Column | Type | Notes |
|--------|------|-------|
| id | PK | |
| agent_id | FK β†’ agents | |
| version | INT | |
| config | JSON | `{model, temperature, ...}` |
### `trends`
| Column | Type | Notes |
|--------|------|-------|
| id | PK | |
| topic | VARCHAR | e.g. "AI in healthcare 2025" |
| source | VARCHAR | reddit, manual, rss |
| score | FLOAT | relevance score |
| status | VARCHAR | pending / used / skipped |
| raw_data | JSON | source-specific metadata |
| created_at | TIMESTAMP | |
### `posts`
| Column | Type | Notes |
|--------|------|-------|
| id | PK | |
| trend_id | FK β†’ trends | |
| agent_version_id | FK β†’ agent_versions | |
| content | TEXT | final caption |
| post_type | VARCHAR | image / text_only / carousel / text_image / link |
| status | VARCHAR | draft / scheduled / published |
| approval_status | VARCHAR | pending / approved / rejected |
| platform | VARCHAR | instagram / twitter / linkedin |
| image_url | VARCHAR | Cloudinary URL |
| carousel_urls | JSON | list of URLs (carousel only) |
| video_url | VARCHAR | |
| link_url | VARCHAR | link posts |
| link_title | VARCHAR | |
| link_description | VARCHAR | |
| platform_post_id | VARCHAR | IG media ID after publish |
| published_at | TIMESTAMP | |
| token_count | INT | LLM tokens used |
| generation_cost | FLOAT | USD |
| hallucination_score | FLOAT | 0–1 |
| created_at | TIMESTAMP | auto |
### `media`
| Column | Type | Notes |
|--------|------|-------|
| id | PK | |
| post_id | FK β†’ posts | |
| url | VARCHAR | CDN URL |
| media_type | VARCHAR | image / video |
| nsfw_score | FLOAT | AWS Rekognition |
| is_approved | BOOL | |
### Supporting tables
- **`workflow_executions`** β€” Temporal workflow IDs and statuses
- **`observability_traces`** β€” Langfuse trace IDs, token counts, costs
- **`api_usage`** β€” Per-service usage tracking for cost monitoring
- **`system_alerts`** β€” Severity-tagged alerts with resolved status
- **`users`** β€” Firebase-authenticated dashboard users
- **`platform_configs`** β€” Per-platform OAuth tokens / credentials
- **`model_configs`** β€” Primary + fallback model configuration per agent
- **`notifications`** β€” In-app notifications (approval needed, publish failed)
---
## 5. Content Generation Pipeline
A single end-to-end run (triggered by scheduler or manually):
### Step 1 β€” Pick a Trend
```python
# scheduler.py
trend = session.execute(
select(Trend)
.where(Trend.status == "pending")
.order_by(Trend.score.desc())
.limit(1)
)
```
### Step 2 β€” Generate Post Content (LLM)
```
pipeline.generate_post_content(GeneratePostInput)
β†’ post_generator.generate_post_with_agent(trend_dict, agent_version_id)
β†’ _pick_post_type() weighted random: text_image 70%, image 10%, text_only 10%, carousel 5%, link 5%
β†’ _build_system_prompt(post_type, trend)
β†’ LLM.invoke([SystemMessage])
β†’ _parse_image_post() / _parse_link_post()
β†’ returns PostData(content, post_type, image_prompt, ...)
```
### Step 3 β€” Generate Image
```
pipeline.generate_images(GenerateImagesInput)
β†’ builds Pollinations AI URL with encoded prompt
β†’ GET https://image.pollinations.ai/prompt/{encoded}?width=1080&height=1080&model=flux
β†’ retries up to 3Γ— with increasing timeout (60s, 90s, 120s)
β†’ returns {"image_1": url}
```
### Step 4 β€” Upload to CDN
```
pipeline.store_media_to_cdn(StoreMediaInput)
β†’ cloudinary.uploader.upload(url, folder="ai_media_os/post_{id}")
β†’ returns {"image_1": cloudinary_secure_url}
```
### Step 5 β€” Save Draft
```
pipeline.save_post_draft(SavePostDraftInput)
β†’ if post_type == "text_image":
apply_text_overlay(raw_url, caption) ← PIL card generation
β†’ INSERT INTO posts (status="draft", approval_status="pending")
β†’ PG NOTIFY posts_changed
```
---
## 6. Post Types
| Type | Description | Has Image |
|------|-------------|-----------|
| `image` | Photo + caption | Yes (Pollinations) |
| `text_only` | Pure text, no image | No |
| `text_image` | Text burned onto image (PIL quote card) | Yes (composited) |
| `carousel` | Multi-slide swipeable post | Yes (multiple) |
| `link` | Link preview card + caption | No |
### Weighted Distribution (default)
```python
POST_TYPE_WEIGHTS = {
"text_image": 70, # dominant β€” best engagement
"image": 10,
"text_only": 10,
"carousel": 5,
"link": 5,
}
```
---
## 7. text_image β€” PIL Card System
File: `src/utils/image_overlay.py`
The text overlay system generates social cards that look hand-crafted, not AI-generated. It automatically chooses one of two layouts based on caption length.
### Layout Selection
```python
hook_word_count = len(hook.split())
if hook_word_count <= 9:
canvas = _make_typo_card(hook, body, tags) # Layout A
else:
canvas = await _make_photo_card(hook, body, tags, image_url) # Layout B
```
### Layout A β€” Typography Card (short hooks ≀ 9 words)
- **Size:** 1080Γ—1080 (square, ideal for quotes)
- **Background:** Vertical gradient (no photo)
- **6 colour palettes:** warm parchment, deep purple night, dark forest, dark amber, cool lavender, cream rust
- **Text:** Auto-sized hook fills ~60% of width; body text at 1/3 hook size
- **Accents:** Horizontal bar above hook, bottom line, brand `@orbis` bottom-right
- **Best for:** "Stop scrolling hooks", quotes, bold statements
### Layout B β€” Editorial Photo Card (longer hooks > 9 words)
- **Size:** 1080Γ—1350 (portrait, 4:5 ratio)
- **Background:** Full-bleed Pollinations photo, fill-cropped
- **Scrims:** Top 22% dark scrim (title area) + bottom 50% dark scrim (text area)
- **Headline:** `hook.upper()`, auto-sized to fill bottom zone, white text with dark shadow + accent bar left
- **Body:** 2 sub-lines below headline at 40px
- **Top bar:** `ORBIS STUDIO` brand + accent line
- **Best for:** News stories, editorial content, longer captions
### Font Loading Chain
```
1. System fonts (Linux paths + Windows C:/Windows/Fonts/)
2. /tmp/orbis_fonts/Inter-Bold.ttf ← downloaded from GitHub rsms/inter on first run
3. PIL default bitmap (tiny fallback β€” only if download fails)
```
Font loading is cached in `_FONT_CACHE` dict to avoid repeated disk reads.
### Caption Parsing
Before rendering, the caption is cleaned of LLM structural labels:
```python
clean = re.sub(
r"(?im)^(HOOK|SLIDE\s*\d+[:\.\-]?[^|\n]*\|?|CAPTION|IMAGE_PROMPT)[:\s]*",
"", caption
)
```
Then split into: `hook` (line 1), `body` (lines 2–3), `tags` (hashtag line).
---
## 8. Scheduler β€” Automated Publishing
File: `src/services/scheduler.py`
### Jobs
| Job | Schedule | What it does |
|-----|----------|--------------|
| `run_trend_fetch` | Every 6h (0, 6, 12, 18 UTC) | Fetches fresh trends from all sources |
| `run_post_generation` | N times/day (from `POSTS_PER_DAY`) | Full pipeline run for one trend |
| `run_auto_publish` | Triggered by generation if `AUTO_APPROVE=true` | Publishes draft immediately |
### Post Scheduling Times
```python
# POSTS_PER_DAY=4 β†’ 8am, 12pm, 4pm, 8pm UTC
hour = 8 + (i * (16 / posts_per_day))
```
### Auto-Approve vs Manual Review
| `AUTO_APPROVE` | Behaviour |
|----------------|-----------|
| `false` (default) | Posts saved as `draft`, wait in dashboard for human approval |
| `true` | Posts auto-published immediately after generation |
### Dev / Prod Safety
In non-production environments (`ENVIRONMENT != "production"`), all published posts get a `[TEST]` prefix so they're identifiable on real Instagram accounts:
```python
if settings.environment != "production":
text = f"[TEST] {text}"
```
---
## 9. LLM Agent & Model Fallbacks
File: `src/agents/post_generator.py`
### Primary Model
Set via `OPENAI_MODEL` env var. Defaults to whatever is configured (typically a Groq or OpenRouter model).
API endpoint set via `OPENAI_API_BASE` (supports any OpenAI-compatible API).
### Automatic Fallback Chain
If the primary model returns a 429 (rate limit) or 404/400 error, the agent automatically tries these free OpenRouter models in order:
```python
FREE_MODEL_FALLBACKS = [
"nousresearch/hermes-3-llama-3.1-405b:free",
"arcee-ai/trinity-large-thinking:free",
"meta-llama/llama-3.3-70b-instruct:free",
"google/gemma-4-31b-it:free",
# ... more
]
```
### LangGraph Pipeline
```
StateGraph:
process_trend_node β†’ LLM invoke, parse output
determine_images_node β†’ set images_needed count
finalize_node β†’ set status="ready_for_moderation"
```
### Prompt Engineering
Each post type has a dedicated system prompt. Key rules enforced:
- `text_image`: "THIS IS A SINGLE-IMAGE POST. Do NOT write SLIDE 1/2/3 content."
- All image types: "No human faces. No text in image."
- LLM output parser strips echoed structural labels (`HOOK:`, `SLIDE N:`, `CAPTION:`, `IMAGE_PROMPT:`)
---
## 10. API Reference
Base URL: `https://<your-railway-domain>/api`
### Posts
| Method | Path | Description |
|--------|------|-------------|
| GET | `/posts` | List posts (filter: `status`, `platform`, `post_type`, pagination) |
| GET | `/posts/{id}` | Get single post |
| POST | `/posts/{id}/approve` | Approve β†’ triggers Instagram publish |
| POST | `/posts/{id}/reject` | Reject draft |
| POST | `/posts/{id}/discard` | Hard-delete a draft post |
#### GET /posts params
| Param | Default | Notes |
|-------|---------|-------|
| `status` | (all) | draft / scheduled / published |
| `platform` | (all) | instagram / twitter / linkedin |
| `post_type` | (all) | image / text_image / etc. |
| `limit` | 20 | |
| `offset` | 0 | |
#### GET /posts response
```json
{
"posts": [...],
"total": 5,
"all_total": 12
}
```
`total` = filtered count, `all_total` = unfiltered across all statuses (used for header badge).
### Workflows
| Method | Path | Description |
|--------|------|-------------|
| POST | `/workflows/trigger` | Manually trigger post generation |
#### POST /workflows/trigger body
```json
{
"trend_id": 3,
"agent_version_id": 1,
"platform": "instagram",
"post_type": "text_image"
}
```
If `trend_id` is omitted, the scheduler picks the highest-scoring pending trend.
### Trends
| Method | Path | Description |
|--------|------|-------------|
| GET | `/trends` | List trends |
| POST | `/trends` | Create manual trend |
### Agents
| Method | Path | Description |
|--------|------|-------------|
| GET | `/agents` | List agents |
| GET | `/agents/{id}/versions` | Get agent versions |
### Moderator
| Method | Path | Description |
|--------|------|-------------|
| GET | `/moderator/dashboard` | Posts pending approval + stats |
### Health
```
GET /health
β†’ { "status": "healthy", "database": "ok", "scheduler": "running" }
```
---
## 11. WebSocket Real-Time Updates
File: `src/api/routes/ws.py` + `src/services/notification_listener.py`
### How it works
1. PostgreSQL triggers fire `NOTIFY posts_changed` on INSERT/UPDATE to `posts`
2. Python `asyncpg` listener receives the notification
3. Sets an `asyncio.Event` dirty flag
4. WebSocket hub checks dirty flag and broadcasts `{"type": "posts_changed"}` to all connected browsers
5. React Query receives the message and calls `queryClient.invalidateQueries(["posts"])`
6. Dashboard re-fetches and updates without a page reload
### WebSocket endpoint
```
ws://<host>/api/ws/updates
```
### React connection (frontend)
```javascript
const ws = new WebSocket(`${WS_BASE}/api/ws/updates`);
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === "posts_changed") {
queryClient.invalidateQueries(["posts"]);
}
};
```
---
## 12. Frontend Pages
All pages use TanStack Query for data fetching and live updates.
### Dashboard (`/`)
- **Scheduler strip:** running status, posts/day, today's generated + published count, next run time
- **Pipeline visualization:** shows 5 pipeline steps (Trend β†’ LLM β†’ Image β†’ CDN β†’ Draft)
- **KV stats:** total posts, published today, pending approval, trends available
- **Area chart:** post volume over last 7 days
### Posts (`/posts`)
- **Grid of PostCards:** thumbnail, type badge, status badge, time ago
- **Filter bar:** by status and post type
- **Header badge:** total post count (unfiltered)
- **Generate button:** opens GenerateModal
- **PostCard actions:** Preview (eye), Approve (check), Reject (Γ—), Discard (trash) β€” contextual by status
- **GenerateModal:** 6 post type options (grid), 4 platform pills, Generate button
### Preview Modal
- Full-size image
- Caption text
- Post type and status chips
- Approve / Reject / Discard buttons with loading state
- Prevents double-click via `isPending` flags + backend idempotency guard
### Trends (`/trends`)
- List of detected trends with source, score, status
- Manual trend creation form
### Analytics (`/analytics`)
- Charts for post volume, engagement by type, cost tracking
### Workflows (`/workflows`)
- Workflow execution history
- Manual trigger panel
### Settings (`/settings`)
- Environment config display
- Platform connection status
---
## 13. Environment Variables
Copy `.env.example` to `.env` and fill in:
### Required
```env
DATABASE_URL=postgresql+asyncpg://user:pass@host/db
OPENAI_API_KEY=sk-... # OpenRouter key (despite name)
OPENAI_API_BASE=https://openrouter.ai/api/v1
OPENAI_MODEL=meta-llama/llama-3.3-70b-instruct:free
CLOUDINARY_CLOUD_NAME=your_cloud
CLOUDINARY_API_KEY=123456
CLOUDINARY_API_SECRET=abc...
INSTAGRAM_ACCESS_TOKEN=EAA...
INSTAGRAM_BUSINESS_ACCOUNT_ID=17841...
```
### Optional (with defaults)
```env
ENVIRONMENT=development # production β†’ removes [TEST] prefix
AUTO_APPROVE=false # true β†’ publish immediately after generation
POSTS_PER_DAY=4 # how many posts to generate per day
POST_PLATFORM=instagram
ALLOWED_ORIGINS=https://your-netlify-app.netlify.app
LOG_LEVEL=INFO
```
### Unused / future
```env
TEMPORAL_HOST=localhost:7233 # Temporal removed in favour of direct pipeline
REDIS_URL=localhost:6379
ANTHROPIC_API_KEY=
LANGFUSE_PUBLIC_KEY=
R2_ACCOUNT_ID= # Cloudflare R2 (replaced by Cloudinary)
AWS_ACCESS_KEY_ID= # Rekognition (NSFW β€” not active)
```
---
## 14. Deployment Guide
### Backend (Railway)
1. Connect GitHub repo to Railway
2. Set all environment variables in Railway dashboard
3. Railway auto-detects `Procfile`:
```
web: python -m uvicorn src.api.main:app --host 0.0.0.0 --port $PORT --access-log --use-colors 2>&1
```
4. On first deploy, startup migrations run automatically:
- `src/utils/migrate.py` executes `ALTER TABLE ADD COLUMN IF NOT EXISTS` for all columns
- Safe to run on every deploy β€” no-ops if columns already exist
#### Startup sequence
```
init_db() β†’ create tables if not exist (SQLAlchemy)
run_migrations() β†’ add any missing columns (idempotent)
install_triggers() β†’ PG NOTIFY triggers on posts table
start_listener() β†’ asyncpg LISTEN for notifications
scheduler.start() β†’ APScheduler jobs begin
```
### Frontend (Netlify)
1. Connect GitHub repo, set build command: `cd frontend && npm run build`
2. Set publish directory: `frontend/dist`
3. Set env var: `VITE_API_BASE_URL=https://your-railway-domain.up.railway.app`
4. Deploy
### Font availability on Railway
On startup (first `text_image` post), the system downloads Inter fonts from GitHub to `/tmp/orbis_fonts/`:
- `Inter-Regular.otf` β†’ `/tmp/orbis_fonts/Inter-Regular.ttf`
- `Inter-Bold.otf` β†’ `/tmp/orbis_fonts/Inter-Bold.ttf`
This is automatic. No manual setup needed. Fonts are cached in memory (`_FONT_CACHE`) for the process lifetime.
---
## 15. Local Development
### Requirements
- Python 3.11+
- Node.js 18+
- PostgreSQL (local or Neon free tier)
### Backend
```bash
# Create virtual environment
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
# Install dependencies
pip install -r requirements.txt
# Create .env
cp .env.example .env
# Fill in DATABASE_URL, OPENAI_API_KEY, etc.
# Run
uvicorn src.api.main:app --reload --port 8000
```
### Frontend
```bash
cd frontend
npm install
# Create .env.local
echo "VITE_API_BASE_URL=http://localhost:8000" > .env.local
npm run dev
# β†’ http://localhost:5173
```
### Generate a test post manually
```bash
curl -X POST http://localhost:8000/api/workflows/trigger \
-H "Content-Type: application/json" \
-d '{"agent_version_id": 1, "platform": "instagram", "post_type": "text_image"}'
```
### VSCode Python setup
If Pyrefly shows `PIL` import errors, select the venv interpreter:
- `Ctrl+Shift+P` β†’ "Python: Select Interpreter" β†’ choose `.venv/Scripts/python.exe`
---
## 16. Key Design Decisions
### Why not Temporal?
The original design used Temporal for durable workflow orchestration. It was removed because:
- Railway free tier can't run a separate Temporal server
- The pipeline is short enough (~30s) that durability isn't critical
- APScheduler + direct async functions are simpler and free
Temporal stubs remain in the codebase (`src/temporal_workflows/`) but the functions inside are standalone async functions called directly.
### Why Cloudinary over R2?
Cloudinary was chosen because:
- Free tier (25GB storage, 25GB bandwidth)
- Python SDK with single-call upload from URL or BytesIO
- PIL output is uploaded directly as BytesIO without temp files
R2 env vars are in config but the upload path uses Cloudinary.
### Why Pollinations AI for images?
- Completely free, no API key
- Supports `flux` model with 1080px output
- URL-based (no SDK) β€” just build a URL, fetch, get image
- Sufficient quality for social cards
### Why PIL for text overlay instead of Cloudinary transformations?
Cloudinary text overlays are possible but:
- Limited typography control
- Can't do gradient backgrounds or custom layout logic
- PIL gives full pixel control for the typography card layout
- Output is uploaded to Cloudinary after compositing
### Why `ALTER TABLE ADD COLUMN IF NOT EXISTS` on every startup?
Railway reuses the same PostgreSQL database across deploys. New columns added in code would cause `column does not exist` errors if migrations weren't run. Running idempotent `IF NOT EXISTS` migrations on every startup ensures the schema is always in sync without a separate migration tool.
### Why `datetime.utcnow() + "Z"` for timestamps?
Python's `datetime.utcnow()` returns a naive datetime with no timezone indicator. JavaScript's `new Date("2025-01-01T10:00:00")` without a `Z` or `+offset` is treated as **local time** by browsers. In IST (UTC+5:30) this made posts appear 5.5h older than they were. Appending `Z` before parsing forces UTC interpretation.
### Why `[TEST]` prefix for non-prod publishing?
When `ENVIRONMENT != "production"`, any post that gets approved and published will have `[TEST]` prepended to its caption. This lets you test the full approval β†’ publish flow against a real Instagram account without the post looking like real content to followers.
---
## 17. Known Limitations & TODOs
| Item | Status |
|------|--------|
| Twitter/LinkedIn publishing | Wired in model, not yet active |
| AWS Rekognition NSFW moderation | Code exists, not enabled by default |
| Carousel image generation | Only generates 1 image; needs multi-image support |
| Video post type | Schema ready, generation not implemented |
| Firebase auth on frontend | Schema has `users` table; login UI not built |
| Langfuse observability | Config wired; traces not actively sent |
| Rate limiting on API | Not implemented |
| `link` post type | LLM generates content; `link_url` not auto-fetched |
| Trend sources | Reddit and manual only; RSS/Twitter not active |
| Font persistence | `/tmp` is ephemeral on Railway; fonts re-download on each dyno restart (cached for process lifetime) |
---
*Documentation generated 2026-05-12. For questions, open an issue on the GitHub repo or contact the maintainer.*