Spaces:
Running
Running
| # 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.* | |