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:
/mnt/apps/src/factory/services/content-factorycd /mnt/apps/docker/ai/content-factory
docker compose up -d --build
# View logs
docker compose logs -f
Access: https://factory.haiven.site
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)
| 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) |
| 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 |
| Register | Style |
|---|---|
| Authority | Direct, declarative, no hedging — for instructional and factual content |
| Conversational | Natural first-person voice — for entries, reflections, and personal notes |
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.
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.) |
| 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 |
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
| 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 |
| 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) |
| 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 |
GET /Serve the single-page frontend.
Response: HTML document
GET /healthHealth check for Docker healthcheck and monitoring.
Response:
{"status": "healthy", "service": "content-factory"}
GET /typesList all content type definitions with metadata.
Response:
[
{
"id": "entry",
"label": "Entry",
"description": "Personal journal entry or reflection",
"registers": ["authority", "conversational"]
}
]
POST /classifyAccept 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-textAccept 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 /recordAccept 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 /formatFormat 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 /reviseAccept 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-textAccept 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 /saveWrite 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 /draftsList 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-testsCreate 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-testsList 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}/autosaveSave 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}/scoresGet 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-scoreRun 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-nameGenerate 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}/highlightsSave 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}/highlightsGet 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-scoresLegacy 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.
#create, #drafts, #drafts/{path}, #ab-tests, #ab-tests/{id}.md, Send to A/B Test.txt/.md file import into the text inputselect → review_a → review_b → compare| Key | Action |
|---|---|
1–5 |
Rate focused dimension |
Tab / Shift+Tab |
Cycle through dimensions |
Enter |
Advance to next step |
Esc |
Navigate back |
<ins>/<del> rendering via diff-match-patch (CDN)[EXPAND] markers in draft text rendered as styled callout boxesDrafts 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}/
| 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 |
| 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 |
| 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/
curl -s https://factory.haiven.site/health | jq
# {"status": "healthy", "service": "content-factory"}
# 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
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
curl -X POST https://factory.haiven.site/save \
-H "Content-Type: application/json" \
-d '{"draft_id": "b4e2..."}' | jq
curl -s https://factory.haiven.site/drafts | jq
curl -s "https://factory.haiven.site/ab-tests?group_by=voice_pair" | jq
curl -X POST https://factory.haiven.site/ab-tests/c9f3.../ai-score | jq
cd /mnt/apps/docker/ai/content-factory
docker compose build --no-cache && docker compose up -d
docker compose logs -f content-factory
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';"
GET /drafts/{path} resolves the path with os.path.realpath() and rejects any request that escapes CONTENT_PATH/format callLLM 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.
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
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
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.
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
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"
HTTPS is required for browser microphone API. Access via https://factory.haiven.site only.
# 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.