Content Factory

Voice-to-content pipeline with six content types, two voice registers, A/B testing, AI scoring, and SQLite persistence. Record a voice note or type text, get a structured draft (title, tags, formatted body), revise with another voice or text note, compare voice register variants in a sequential A/B review flow, then save as markdown with YAML frontmatter.

Source ownership note:

Quick Start

cd /mnt/apps/docker/ai/content-factory
docker compose up -d --build

# View logs
docker compose logs -f

Access: https://factory.haiven.site


Architecture

Browser (voice or text input)
    |
    | audio blob (WebM / MP4 fallback for Safari)
    v
Content Factory API (FastAPI, port 8020)
    |
    |--- POST /classify ---------> haiven-transcribe (STT)
    |                                   |
    |                                   v
    |                               raw transcription + AI content type suggestions
    |                               (classify session stored in-memory, 1-hour TTL)
    |
    |--- POST /classify-text ----> classify_content() ---> LiteLLM ---> GLM-4.7-Flash
    |
    |--- POST /format -----------> formatter.py ---> LiteLLM ---> Hermes-4.3-36B
    |                                   |
    |                                   v
    |                               JSON (title, tags, formatted_text)
    |                               session persisted to SQLite
    |
    |--- POST /revise -----------> voice feedback ---> transcribe + revise
    |--- POST /revise-text ------> text feedback ---> revise
    |
    |--- POST /save -------------> writer.py ---> /content/drafts/{type}/{date}-{slug}.md
    |                               draft index record written to SQLite
    |
    |--- GET /drafts ------------> list saved drafts (filesystem scan)
    |--- GET /drafts/{path} ----> single draft (path traversal protected)
    |--- GET /types -------------> list content type definitions
    |
    |--- POST /ab-tests ---------> db.create_ab_test() ---> SQLite
    |--- GET  /ab-tests ---------> db.list_ab_tests() (supports ?group_by=voice_pair)
    |--- GET  /ab-tests/{id} ----> test detail + scores + metadata + highlights
    |--- POST /ab-tests/{id}/autosave    ---> partial progress (every 120s)
    |--- PATCH /ab-tests/{id}            ---> update name/status/metadata
    |--- GET  /ab-tests/{id}/scores      ---> human + AI scores
    |--- DELETE /ab-tests/{id}           ---> soft-delete (archive)
    |
    |--- POST /ab-tests/{id}/ai-score    ---> ai_score.py ---> LiteLLM ---> GLM-4.7-Flash
    |--- POST /ab-tests/generate-name    ---> ai_score.py ---> GLM-4.7-Flash
    |
    |--- POST /ab-tests/{id}/highlights  ---> save inline phrase highlights
    |--- GET  /ab-tests/{id}/highlights  ---> retrieve highlights
    |
    |--- POST /ab-scores --------> legacy flat-file score submission (still works)

Components

Module Purpose
app/main.py FastAPI application, all route definitions
app/db.py Async SQLite layer — 6 tables, WAL mode, CRUD for sessions/drafts/tests/scores/highlights/metadata
app/migrate.py One-time JSON→SQLite data migration (runs on startup if needed)
app/ai_score.py AI scoring and test name generation via GLM-4.7-Flash (thinking disabled)
app/transcriber.py haiven-transcribe integration (OpenAI-compatible STT)
app/formatter.py Content-type-specific prompt building, Seed-36B via LiteLLM, JSON response parsing, content classification
app/writer.py Markdown file writer with YAML frontmatter, slug generation
app/prompts/content_types.py Six content type definitions with voice profiles and anti-pattern lists
app/static/ Vanilla HTML/CSS/JS frontend (Crimson Edge theme, hash routing, mobile-first)

Content Types

Type Purpose
Entry Personal journal entry or reflection
TIL Today I Learned — concise technical or factual discovery
Link Link post with commentary
Quote Notable quote with context
Build Build log, project update, or in-progress note
Site Page Static site page content

Voice Registers

Register Style
Authority Direct, declarative, no hedging — for instructional and factual content
Conversational Natural first-person voice — for entries, reflections, and personal notes

Agent Identity

The service ships with SYSTEM_PROMPT.md — the agent's identity file that defines how the LLM interprets voice input and formats content. This is loaded by formatter.py at startup and sets the tone, priorities, and anti-patterns for all six content types.

SQLite Schema

The database (factory.db) has 6 tables:

Table Purpose
sessions Active draft sessions (replaces in-memory dict) — id, content_type, data JSON, raw_transcription, revision_count
drafts Index of saved draft files — path, title, content_type, tags, status
ab_tests A/B test records — id, name, input_text, candidate paths, skill_version, model, status
scores Human and AI scores per test+candidate+dimension — score (1-5), comment, source (human/ai)
highlights Inline phrase annotations — start/end offsets, sentiment (positive/negative), text_preview
test_metadata Key-value store for arbitrary test metadata (voice pairs, autosave state, etc.)

Service Configuration

Property Value
Container Name content-factory
Image Custom build (python:3.12-slim)
Internal Port 8020
External Port 8023
Domain factory.haiven.site
Networks web, backend
GPU None (CPU only)
Restart Policy unless-stopped

Docker Compose

services:
  content-factory:
    build:
      context: /mnt/apps/src/factory/services/content-factory
      dockerfile: /mnt/apps/docker/ai/content-factory/Dockerfile
    container_name: content-factory
    restart: unless-stopped
    ports:
      - "8023:8020"
    networks:
      - web
      - backend
    volumes:
      - ${CONTENT_PATH:-/mnt/apps/src/factory/content}:/content
      - /mnt/apps/src/factory/services/content-factory/app:/app/app
      - ./data:/data
      - /mnt/apps/src/factory/.claude/skills/elijah-voice-skill/references:/voice-profiles:ro
      - /mnt/apps/src/factory/docs:/source-docs:ro
    environment:
      TZ: America/New_York
      WHISPER_URL: http://haiven-transcribe:8000/v1/audio/transcriptions
      LITELLM_URL: http://litellm:4000
      LITELLM_KEY: ${LITELLM_KEY:-sk-haiven}
      FACTORY_MODEL: ${FACTORY_MODEL:-hermes-4.3-36b}
      CLASSIFY_MODEL: ${CLASSIFY_MODEL:-qwen3.5-27b}
      CONTENT_PATH: /content
      LANGFUSE_ENABLED: ${LANGFUSE_ENABLED:-true}
      SCORING_MODEL: ${SCORING_MODEL:-glm-4-7-flash}
    labels:
      - "traefik.enable=true"
      - "traefik.docker.network=web"
      - "traefik.http.routers.content-factory.rule=Host(`factory.haiven.site`)"
      - "traefik.http.routers.content-factory.entrypoints=websecure"
      - "traefik.http.routers.content-factory.tls=true"
      - "traefik.http.services.content-factory.loadbalancer.server.port=8020"
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8020/health')"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 15s

networks:
  web:
    external: true
  backend:
    external: true

Environment Variables

Variable Default Description
TZ America/New_York Timezone for timestamps in filenames
WHISPER_URL http://haiven-transcribe:8000/v1/audio/transcriptions STT endpoint (OpenAI-compatible)
LITELLM_URL http://litellm:4000 LiteLLM gateway for LLM calls
LITELLM_KEY sk-haiven API key for LiteLLM
FACTORY_MODEL hermes-4.3-36b Model used for content drafting and revision
CLASSIFY_MODEL qwen3.5-27b Model used for content type classification suggestions
SCORING_MODEL glm-4-7-flash Model used for AI scoring and test name generation
CONTENT_PATH /content Container path for draft output directory
DB_PATH /data/factory.db SQLite database file location
LANGFUSE_ENABLED true Attach Langfuse trace metadata to LLM calls

Volumes

Host Path Container Path Purpose
/mnt/apps/src/factory/content /content Draft output directory (organized by content type)
./data /data SQLite database and A/B test artifacts
/mnt/apps/src/factory/services/content-factory/app /app/app Live source mount for application code
/mnt/apps/src/factory/.claude/skills/elijah-voice-skill/references /voice-profiles Voice profile references (read-only)
/mnt/apps/src/factory/docs /source-docs Source content for hallucination checking (read-only)

Networks

Network Subnet Purpose
web 10.10.0.0/24 Traefik reverse proxy access
backend 10.10.1.0/24 Internal communication with haiven-transcribe and LiteLLM

API Endpoints

GET /

Serve the single-page frontend.

Response: HTML document


GET /health

Health check for Docker healthcheck and monitoring.

Response:

{"status": "healthy", "service": "content-factory"}

GET /types

List all content type definitions with metadata.

Response:

[
  {
    "id": "entry",
    "label": "Entry",
    "description": "Personal journal entry or reflection",
    "registers": ["authority", "conversational"]
  }
]

POST /classify

Accept audio, transcribe via haiven-transcribe, and return AI-suggested content types. Stores the transcription in a classify session (1-hour TTL) for use by a subsequent /format call.

Request:
- Content-Type: multipart/form-data
- Body: file field with audio blob (WebM or MP4)

Response:

{
  "transcription": "today i was working on the embedding pipeline...",
  "suggestions": ["til", "build"],
  "classify_id": "a3f1c2d4-..."
}

Status Codes:
- 200 — Success
- 400 — Invalid file type or empty transcription
- 500 — Transcription service error


POST /classify-text

Accept typed or pasted text and return AI-suggested content types. Also returns a classify_id for use by /format.

Request:

{"text": "today i was working on the embedding pipeline..."}

Response: Same structure as /classify


POST /record

Accept audio, transcribe, format using a specified content type, persist session to SQLite, and return the structured draft.

Request:
- Content-Type: multipart/form-data
- Fields: file (audio blob), content_type (query param, default "article")

Response:

{
  "_draft_id": "b4e2...",
  "title": "embedding dimensions must match at collection creation time",
  "tags": ["qdrant", "embeddings", "debugging"],
  "formatted_text": "Qdrant collections lock their vector dimensions..."
}

POST /format

Format text into a structured draft. Accepts either raw text or a classify_id from a prior /classify call. Persists session to SQLite.

Request:

{
  "text": "today i was working on the embedding pipeline...",
  "content_type": "til"
}

Or using a classify session:

{
  "classify_id": "a3f1c2d4-...",
  "content_type": "til"
}

Response: Same structure as /record response (includes _draft_id)

Status Codes:
- 200 — Success
- 400 — Missing required fields or unsupported content type
- 404 — Classify session not found or expired
- 500 — LLM service error


POST /revise

Accept voice feedback on an existing draft, transcribe, and return a revised draft. Increments revision count in SQLite.

Request:
- Content-Type: multipart/form-data
- Fields: file (audio), draft_id (query param)

Response: Same structure as /format


POST /revise-text

Accept text feedback on an existing draft and return a revised draft.

Request:

{
  "draft_id": "b4e2...",
  "feedback": "make it shorter and add a code example"
}

Response: Same structure as /format


POST /save

Write approved draft to the content directory as markdown with YAML frontmatter. Writes a draft index record to SQLite and cleans up the session.

Request:

{"draft_id": "b4e2..."}

Response:

{
  "file_path": "/content/drafts/til/2026-03-15-embedding-dimensions-must-match.md",
  "status": "draft"
}

Status Codes:
- 200 — Success
- 404 — Draft session not found
- 500 — File write error


GET /drafts

List saved drafts from the content directory.

Response:

[
  {
    "filename": "2026-03-15-embedding-dimensions-must-match.md",
    "content_type": "til",
    "date": "2026-03-15",
    "title": "embedding dimensions must match at collection creation time",
    "tags": ["qdrant", "embeddings", "debugging"],
    "preview": "Qdrant collections lock their vector dimensions at creation..."
  }
]

GET /drafts/{path}

Get a single draft by path. Path is validated against CONTENT_PATH to prevent traversal.

Response: Full draft content including frontmatter fields and raw markdown body.

Status Codes:
- 200 — Success
- 400 — Path traversal attempt
- 404 — Draft not found


POST /ab-tests

Create a new A/B test record.

Request:

{
  "name": "Authority vs Conversational — embedding TIL",
  "input_text": "today i was debugging qdrant...",
  "candidate_a": {
    "path": "til/2026-03-15-embedding-dimensions.md",
    "text": "Qdrant collections lock their vector dimensions...",
    "voice_pair": "authority"
  },
  "candidate_b": {
    "path": "til/2026-03-15-embedding-dimensions-conv.md",
    "text": "So I hit a fun bug today — Qdrant...",
    "voice_pair": "conversational"
  },
  "skill_version": "1.0",
  "model": "seed-oss-36b"
}

Response:

{
  "id": "c9f3...",
  "status": "created",
  "name": "Authority vs Conversational — embedding TIL",
  ...
}

GET /ab-tests

List all A/B tests with scores and metadata.

Query Parameters:
- group_by=voice_pair — Group results by voice register pairing

Response (default): Array of test objects with scores and metadata keys.

Response (?group_by=voice_pair):

{
  "grouped": {
    "authority vs conversational": [...]
  }
}

GET /ab-tests/{id}

Get A/B test detail including candidates, scores, metadata, and highlights.

Response:

{
  "id": "c9f3...",
  "name": "Authority vs Conversational — embedding TIL",
  "status": "completed",
  "candidate_a_path": "...",
  "candidate_b_path": "...",
  "scores": [...],
  "metadata": {"candidate_a_voice_pair": "authority", ...},
  "highlights": [...]
}

POST /ab-tests/{id}/autosave

Save partial scoring progress. Called automatically by the frontend every 120 seconds during review.

Request:

{
  "step": "review_a",
  "scores": {"voice": {"score": 4, "comment": "clean"}, "tone": {"score": 3}},
  "timestamp": "2026-03-16T14:22:00"
}

Response:

{"status": "saved", "test_id": "c9f3...", "step": "review_a"}

PATCH /ab-tests/{id}

Update test name, status, candidate paths, skill version, or model.

Request:

{"name": "Updated test name", "status": "completed"}

Response:

{"status": "updated", "test_id": "c9f3..."}

GET /ab-tests/{id}/scores

Get all scores for an A/B test, both human and AI.

Response:

{
  "test_id": "c9f3...",
  "scores": [
    {
      "candidate": 1,
      "dimension": "voice",
      "score": 4,
      "comment": "direct, no hedging",
      "source": "human"
    },
    {
      "candidate": 1,
      "dimension": "voice",
      "score": 5,
      "comment": "authority register maintained throughout",
      "source": "ai"
    }
  ]
}

DELETE /ab-tests/{id}

Soft-delete an A/B test (sets status to "archived"). Data is retained in SQLite.

Response:

{"status": "archived", "test_id": "c9f3..."}

POST /ab-tests/{id}/ai-score

Run AI scoring on both candidates via GLM-4.7-Flash (thinking disabled). Scores are persisted to the scores table with source="ai". Candidate texts are pulled from metadata or loaded from draft files.

Response:

{
  "candidate_a": {
    "voice": 5,
    "voice_rationale": "Declarative throughout. No hedging.",
    "arguments": 4,
    "arguments_rationale": "Clear claim, well-supported.",
    "tone": 4,
    "tone_rationale": "Matches authority register."
  },
  "candidate_b": {
    "voice": 3,
    ...
  }
}

Status Codes:
- 200 — Success
- 400 — Both candidate texts required
- 404 — Test not found
- 500 — AI scoring failed


POST /ab-tests/generate-name

Generate AI name suggestions for an A/B test based on source text.

Request:

{"text": "today i was debugging qdrant dimension mismatch..."}

Response:

{"suggestions": ["Qdrant Dimension Debug — TIL", "Embedding Mismatch Discovery", "Silent Upsert Failure"]}

POST /ab-tests/{id}/highlights

Save an inline phrase highlight for a test candidate.

Request:

{
  "candidate": 1,
  "start_offset": 42,
  "end_offset": 67,
  "sentiment": "positive",
  "text_preview": "lock their vector dimensions"
}

Response:

{"id": 7, "status": "saved"}

GET /ab-tests/{id}/highlights

Get all highlights for a test.

Response:

{
  "test_id": "c9f3...",
  "highlights": [
    {
      "id": 7,
      "candidate": 1,
      "start_offset": 42,
      "end_offset": 67,
      "sentiment": "positive",
      "text_preview": "lock their vector dimensions"
    }
  ]
}

POST /ab-scores

Legacy flat-file A/B score submission. Writes scores plus hallucination check results to a JSON file in {CONTENT_PATH}/ab-scores/. Still functional; new work should use /ab-tests endpoints.


Frontend Features

Create Flow — "Talk First, Classify After"

Drafts View

A/B Test Review

Keyboard Shortcuts

Key Action
15 Rate focused dimension
Tab / Shift+Tab Cycle through dimensions
Enter Advance to next step
Esc Navigate back

AI Scoring (Compare Step)

Inline Highlights

Word-Level Diff

Recording

UX Polish


Output Format

Drafts are saved as markdown with YAML frontmatter:

---
type: til
date: 2026-03-15
tags: [qdrant, embeddings, debugging]
---

# embedding dimensions must match at collection creation time

Qdrant collections lock their vector dimensions at collection creation time.
If you ingest with 2560-dim vectors into a 1536-dim collection, every upsert
silently fails. Drop and recreate the collection to fix.

Filename format: YYYY-MM-DD-{slug}.md (slug derived from title, max 60 chars)

Written to: /mnt/apps/src/factory/content/drafts/{content_type}/


Dependencies

Service Purpose Required
haiven-transcribe Speech-to-text (OpenAI-compatible /v1/audio/transcriptions) Yes
litellm LLM gateway — Hermes-4.3-36B for drafting, GLM-4.7-Flash for classify/AI scoring Yes
traefik HTTPS termination and domain routing Yes

Technology Stack

Component Technology
Runtime Python 3.12
Web Framework FastAPI + uvicorn
Database SQLite via aiosqlite 0.20.0 (WAL mode, async)
LLM Client openai SDK (LiteLLM-compatible)
Frontend Vanilla HTML/CSS/JavaScript (no build step)
Frontend diff diff-match-patch 1.0.5 (CDN)
Theme Crimson Edge — dark warm-black (#0D0B0C), crimson accent (#E84444), mobile-first

Files

File Purpose
docker-compose.yml Service definition
Dockerfile Container build
requirements.txt Python dependencies
SYSTEM_PROMPT.md Agent identity — content formatting rules and voice profiles
app/main.py FastAPI application and all route definitions (672 lines)
app/db.py Async SQLite database layer — 6 tables, WAL mode, CRUD (399 lines)
app/migrate.py One-time JSON→SQLite data migration (180 lines)
app/ai_score.py AI scoring and test name generation via GLM-4.7-Flash (187 lines)
app/transcriber.py haiven-transcribe STT integration
app/formatter.py LLM prompt construction, Hermes-4.3-36B calls, JSON parsing, content classification
app/writer.py Markdown file writer with YAML frontmatter and slug generation
app/prompts/content_types.py Content type definitions, voice registers, anti-pattern lists
app/static/index.html Frontend HTML (~340 lines)
app/static/style.css Frontend styles — Crimson Edge theme (~2,081 lines)
app/static/app.js Frontend JavaScript — routing, A/B flow, AI scoring (~2,134 lines)
app/static/favicon.svg Crimson square favicon
data/factory.db SQLite database (runtime, not committed)

Source: /mnt/apps/docker/ai/content-factory/


Usage Examples

Health Check

curl -s https://factory.haiven.site/health | jq
# {"status": "healthy", "service": "content-factory"}

Classify Text, Then Format

# Step 1: classify
RESULT=$(curl -s -X POST https://factory.haiven.site/classify-text \
  -H "Content-Type: application/json" \
  -d '{"text": "qdrant silently drops upserts if vector dims do not match"}')
CLASSIFY_ID=$(echo $RESULT | jq -r '.classify_id')

# Step 2: format using the classify session
curl -X POST https://factory.haiven.site/format \
  -H "Content-Type: application/json" \
  -d "{\"classify_id\": \"$CLASSIFY_ID\", \"content_type\": \"til\"}" | jq

Format Text Directly

curl -X POST https://factory.haiven.site/format \
  -H "Content-Type: application/json" \
  -d '{
    "text": "qdrant silently drops upserts if vector dims do not match collection schema",
    "content_type": "til"
  }' | jq

Save a Draft

curl -X POST https://factory.haiven.site/save \
  -H "Content-Type: application/json" \
  -d '{"draft_id": "b4e2..."}' | jq

List Drafts

curl -s https://factory.haiven.site/drafts | jq

List A/B Tests Grouped by Voice Pair

curl -s "https://factory.haiven.site/ab-tests?group_by=voice_pair" | jq

Run AI Scoring on a Test

curl -X POST https://factory.haiven.site/ab-tests/c9f3.../ai-score | jq

Development

Rebuild After Source Changes

cd /mnt/apps/docker/ai/content-factory
docker compose build --no-cache && docker compose up -d

View Logs

docker compose logs -f content-factory

SQLite Database

The database lives at ./data/factory.db on the host (mounted to /data/factory.db in the container). WAL mode is enabled for concurrent reads. On first startup, migrate.py runs automatically and imports any existing flat-file A/B scores and hallucination data.

# Inspect the database
sqlite3 /mnt/apps/docker/ai/content-factory/data/factory.db

# Check tables
sqlite3 data/factory.db ".tables"

# Count A/B tests
sqlite3 data/factory.db "SELECT COUNT(*) FROM ab_tests WHERE status != 'archived';"

Security


Monitoring

LLM calls are traced in Langfuse (LANGFUSE_ENABLED=true) via LiteLLM's callback integration. Each /format, /revise, and /ab-tests/{id}/ai-score call appears in Langfuse with content-factory and ai-score tags. View traces at https://ai-ops.haiven.site.


Troubleshooting

"Could not reach transcription service"

haiven-transcribe is not running or not reachable on the backend network.

docker ps | grep haiven-transcribe
docker exec content-factory curl -I http://haiven-transcribe:8000/health
docker logs haiven-transcribe | tail -30

"Could not reach LLM service"

LiteLLM or the backing model is unavailable.

docker ps | grep litellm
curl http://localhost:4000/v1/models | jq '.data[].id' | grep -E 'seed|glm'
docker logs litellm | tail -30

A/B test not found after restart

Draft sessions and A/B tests persist to SQLite (./data/factory.db). If the volume was not mounted, data will be lost. Verify the ./data:/data volume is present in the compose file.

Draft not saved — permission error

Check that the content directory is writable.

docker exec content-factory ls -la /content
ls -la /mnt/apps/src/factory/content
# directory must be owned by or writable by uid 1000
chown -R 1000:1000 /mnt/apps/src/factory/content

AI scoring returns empty or fails silently

Check that SCORING_MODEL (default glm-4-7-flash) is available in LiteLLM and that the model is currently loaded.

curl http://localhost:4000/v1/models | jq '.data[].id' | grep glm
docker logs content-factory | grep "AI scoring"

Microphone access denied in browser

HTTPS is required for browser microphone API. Access via https://factory.haiven.site only.

Service won't start

# Check port conflict
ss -tuln | grep 8023

# Full rebuild
docker compose down && docker compose up -d --build


Voice-to-content pipeline. Speak it, shape it, score it, save it.