Companion to the developer README. Includes: - Executive summary at the top with the 3 business-relevant points (reusable across regions, in-browser review, runtime extensibility) - What it is / what it does / how it works (full architecture diagram) - The 6 built-in deliverable types and why the data-driven design means new types need no code change - Build cost (~$40-$80 in AI spend vs. agency equivalent ~$32-$100k) - Operating cost per brief ($1-$3) and at typical monthly volumes - Cost-control levers already baked in (prompt caching, telemetry) - Roadmap of sensible next steps - Quick-reference operating info (URLs, deploy command) |
||
|---|---|---|
| backend | ||
| deploy | ||
| frontend | ||
| .env.example | ||
| .gitignore | ||
| docker-compose.prod.yml | ||
| docker-compose.yml | ||
| OVERVIEW.md | ||
| README.md | ||
| SPEC-DELIVERABLE-TYPES.md | ||
| SPEC.md | ||
HP Studios AI Content Agent
A content-generation tool that turns HP customer briefs (one master asset + regional supporting docs) into a full set of branded, region-specific Word deliverables — leadership themes, LinkedIn posts, webinar specs, infographic specs, ABM enablement packs, and regional enrichments — in one click.
Replaces a previously semi-manual process where Claude produced markdown and someone copied it into bespoke Python scripts per run. Now: upload → select deliverables → review in-browser → export branded .docx. Admins can add entirely new deliverable types at runtime (prompt + JSON schema + Word template, no code).
Architecture
┌─────────────────────────────────────────────────────────────────────────┐
│ Browser (user) │
│ http://localhost:5178 (React + Vite) │
│ /briefs /briefs/new /briefs/:id /briefs/:id/deliverables/:type │
│ /admin/users /admin/deliverables /help │
└────────────┬───────────────────────────────────────────────┬────────────┘
│ REST (JWT cookie + bearer) │ blob download
▼ ▼
┌────────────────────────────────────────┐ ┌────────────────────────┐
│ api (FastAPI, uvicorn) :8008 │ │ GET /exports/:id/ │
│ ───────────────────────────────────── │ │ download │
│ /auth login / me / logout │ │ FileResponse(.docx) │
│ /users admin CRUD │ └─────────────▲──────────┘
│ /briefs CRUD + duplicate + PATCH │ │
│ /briefs/:id/documents upload+kind │ │
│ /briefs/:id/generations enqueue │ │
│ /generations/:id PATCH / rerun │ │
│ /generations/:id/export ────────────►│ hp_branding.render ─┘
│ /deliverable-types (admin CRUD) │
└───┬────────────────────────────────┬───┘
│ SQLAlchemy (sync) │ RQ enqueue
▼ ▼
┌────────────────────────────┐ ┌──────────────────────────────┐
│ PostgreSQL 16 + pgvector │ │ redis:7-alpine :56379 │
│ :55432 │ │ queue: "default" │
│ users │ └───────────────┬──────────────┘
│ briefs │ │
│ documents │ │ blpop
│ doc_chunks (vector(1024)) │ ▼
│ generations (jsonb) │ ┌──────────────────────────────┐
│ exports │ │ worker (RQ + same image) │
│ deliverable_types │ │ ─────────────────────────── │
│ (prompt + schema + │ │ ingest_document_task: │
│ template_json) │ │ ↓ extract (pypdf / docx / │
└────────────────────────────┘ │ Claude-Haiku vision OCR)│
│ ↓ detect language │
│ ↓ translate (Haiku 4.5) │
│ ↓ chunk (2000-char + │
│ overlap) │
│ ↓ embed (Voyage or │
│ OpenAI 3-small) │
│ ↓ upsert doc_chunks │
│ │
│ generate_deliverable_task: │
│ ↓ load brief + type row │
│ ↓ master doc text + query │
│ supporting chunks │
│ via pgvector cosine │
│ ↓ assemble system prompt │
│ (cacheable: system.md │
│ + per-deliverable │
│ prompt + master text) │
│ ↓ Anthropic Messages API │
│ Claude Opus 4.7 │
│ tools=[submit_<slug>] │
│ tool_choice=forced │
│ ↓ validate against │
│ schema_json (jsonschema │
│ + pydantic for built-ins│
│ ↓ save structured_content │
│ (jsonb) │
└──────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ hp_branding (python-docx, in-process) │
│ ──────────────────────────────────────────────── │
│ palette.py HP #0096D6, #005A8C, greys │
│ typography.py Montserrat, 10pt body │
│ primitives.py styled_table, scenario_box, │
│ post_block, bullet, heading… │
│ template_renderer.py walks template_json → │
│ dispatches ~14 block types │
│ ({{path}} + $item in loops)│
│ render.py dispatch: template_json if set, │
│ else legacy _RENDERERS[slug] │
└─────────────────────────────────────────────────────┘
Five containers (docker compose up -d):
| Service | Image | Host port | Role |
|---|---|---|---|
| frontend | Node 20 + Vite dev | 5178 | React SPA served with HMR |
| api | Python 3.12 + uvicorn | 8008 | REST API, auth, admin endpoints |
| worker | Python 3.12 + RQ | — | Background ingestion + generation jobs |
| postgres | pgvector/pgvector:pg16 | 55432 | DB + vector store (vector(1024) column, HNSW-ready) |
| redis | redis:7-alpine | 56379 | Job queue |
Quick start
Prereqs: Docker Desktop, an Anthropic API key, and one of Voyage or OpenAI for embeddings.
git clone git@bitbucket.org:zlalani/hp-studios-ai-content-agent.git
cd hp-studios-ai-content-agent
cp .env.example .env
# Edit .env — fill ANTHROPIC_API_KEY and VOYAGE_API_KEY or OPENAI_API_KEY
# Generate a JWT secret: openssl rand -hex 32
docker compose up -d --build
docker compose exec api alembic upgrade head
docker compose exec api python -m app.cli.seed # seeds admin user
Seed output prints the admin credentials once. Visit http://localhost:5178, log in, change the password.
Host ports (remapped to avoid common collisions)
| Purpose | Host port |
|---|---|
| Frontend | 5178 |
| API | 8008 |
| Postgres | 55432 |
| Redis | 56379 |
Override any of them via .env (FRONTEND_HOST_PORT, API_HOST_PORT, POSTGRES_HOST_PORT, REDIS_HOST_PORT). Container-side ports stay default.
How a run works
- Create a brief — name, region, audience, brief text (
/briefs/new). - Upload documents — drag-drop one master doc + N supporting docs. Each upload fires
ingest_document_task: text extraction (PDF / DOCX / image-vision), language detection, translation to English (if non-English, via Claude Haiku), chunking, embedding (Voyage voyage-3 preferred, OpenAItext-embedding-3-smallfallback truncated + renormalised to 1024 dims), upsert todoc_chunks. - Select deliverables — multi-select any active type (built-in or admin-authored). Click Generate → one
generate_deliverable_taskis enqueued per type. - Worker — loads master text, pulls top-K relevant chunks from supporting docs via pgvector cosine, assembles system prompt (
system.md+ per-deliverable prompt + master text, all marked cacheable), calls Claude Opus 4.7 withtool_choice={type:"tool", name:"submit_<slug>"}whoseinput_schemais the type'sschema_json. Tool-use output is validated against both JSON Schema and (for built-ins) the pydantic model before being persisted. - Review & edit — UI renders a recursive
<SchemaForm>driven by the type's schema. Every field editable; save viaPATCH /generations/:idwhich re-validates. - Export —
render_to_bytes(slug, content, template_json)dispatches to the generic template renderer. Template is a JSON array of blocks (title_page,heading1..3,bullets,styled_table,loop,scenario_box,post_block,conditional,key_value_table,divider,page_break, etc.) with{{path.to.field}}and{{$item.x}}interpolation. Returns branded.docxbytes, saved todata/exports/<uuid>.docx, streamed viaGET /exports/:id/download. - Re-roll / retry —
POST /generations/:id/rerunresets status and re-enqueues. Keeps priorstructured_contentuntil the new one lands, so a failed rerun never loses the last good draft.
Data-driven deliverable types
Every type — built-in or admin-authored — lives in the deliverable_types table:
| Column | Purpose |
|---|---|
slug |
stable identifier (e.g. leadership_themes) |
prompt_md |
per-deliverable system-prompt suffix appended to system.md |
schema_json |
JSON Schema — drives Claude's tool input_schema AND the React review form |
template_json |
ordered list of layout blocks for the generic Word renderer |
query_hint |
short string seeding the vector retrieval query |
is_builtin |
true for the six seeded types — locks slug + schema_json in the admin UI |
Admins can add new types at /admin/deliverables/new with a "Start from built-in" dropdown that pre-fills prompt + schema + template from any existing type. A test-render button on the edit page produces a .docx preview from sample JSON.
Authentication & roles
- MVP: email + password, bcrypt hashes, JWT in an httpOnly cookie plus bearer token.
- Planned: Oliver MSFT Entra SSO (OIDC). The backend already exposes a pluggable
AuthProviderABC — swapping providers is one adapter change, not a rewrite. - Two roles seeded:
admin— manages users, creates / edits deliverable types, sees all briefs, sees cost telemetry.user— creates and edits their own briefs, generates and exports deliverables.
- Users only see their own briefs; admins see all.
Repo layout
app/
├── docker-compose.yml
├── .env.example # secrets template — real .env is gitignored
├── README.md # you are here
├── SPEC.md # original build contract for the parallel agents
├── SPEC-DELIVERABLE-TYPES.md # data-driven types architecture
├── backend/
│ ├── Dockerfile
│ ├── pyproject.toml
│ ├── alembic/ # migrations (001 schema, 002 deliverable_types)
│ ├── db-init/ # pgvector + pgcrypto extension SQL
│ └── app/
│ ├── main.py
│ ├── core/ # config, security, auth provider, deps
│ ├── api/ # auth, users, briefs, documents, generations,
│ │ # exports, deliverable_types
│ ├── db/ # SQLAlchemy models + session
│ ├── schemas/ # pydantic models for the six built-in types
│ ├── agents/ # Claude client, tool schemas, retrieval, prompts
│ │ └── prompts/ # system.md + per_deliverable/*.md
│ ├── hp_branding/ # palette, typography, primitives,
│ │ # renderers/, template_renderer.py,
│ │ # builtin_templates.py, render.py
│ ├── ingestion/ # extractors, translator, chunker, embeddings
│ ├── workers/ # RQ tasks + worker entrypoint
│ └── cli/ # seed admin
├── frontend/
│ ├── Dockerfile
│ ├── package.json
│ ├── vite.config.ts
│ └── src/
│ ├── main.tsx
│ ├── App.tsx
│ ├── pages/ # Login, Briefs, NewBrief, BriefDetail,
│ │ # DeliverableEditor, UsersAdmin,
│ │ # Deliverables*, HelpPage
│ ├── components/ # ui/, SchemaForm, ErrorBoundary,
│ │ # DocumentUploadZone, JsonEditor, Layout
│ ├── editors/ # legacy per-type editors (still in tree)
│ ├── api/ # React Query hooks per resource
│ └── lib/ # types, auth, api client, utils
└── data/ # gitignored — uploads + exports
Stack
Backend: Python 3.12, FastAPI, SQLAlchemy 2 (sync), Alembic, psycopg3, pgvector, pydantic v2, pydantic-settings, RQ + Redis, Anthropic Python SDK, Voyage AI / OpenAI clients, pypdf + pytesseract + pdf2image (OCR fallback), python-docx, langdetect, jsonschema, bcrypt, python-jose.
Frontend: React 18 + Vite + TypeScript (strict), Tailwind CSS, hand-built shadcn-style components, React Router v6, TanStack Query, React Hook Form + Zod, axios, lucide-react.
Infra: Docker Compose, Postgres 16 (pgvector image), Redis 7, nginx (prod build target only).
Testing
# Backend
docker compose exec api python -m pytest -v
# Frontend (typecheck)
cd frontend && npx tsc --noEmit
As of the initial backup: 82 backend tests passing, 18 skipped (skipped = live Anthropic / Voyage API calls, gated behind RUN_LIVE_AGENT_TESTS=1).
Configuration
See .env.example. Required:
| Var | Purpose |
|---|---|
ANTHROPIC_API_KEY |
Claude (Opus for generation, Haiku for translation / OCR vision) |
VOYAGE_API_KEY or OPENAI_API_KEY |
Embeddings provider (Voyage voyage-3 preferred; OpenAI fallback truncated + renormalised to 1024) |
JWT_SECRET |
openssl rand -hex 32 |
POSTGRES_USER / POSTGRES_PASSWORD / POSTGRES_DB |
DB creds (also composed into DATABASE_URL) |
REDIS_URL |
Default redis://redis:6379/0 inside the compose network |
Troubleshooting
- Port conflicts on startup — override
*_HOST_PORTin.env. Defaults remap off the standard 5432 / 6379 / 5173 / 8000 to avoid collisions with other local Docker projects. max_tokenstruncation on generation —MAX_RESPONSE_TOKENSinapp/agents/generate.pyis 16k. Raise if a new deliverable schema is extra-verbose; the worker surfaces a clear error when it hits.- Migration SQL errors — keep
CAST(:x AS JSONB)inside rawsa.text(), not:x::jsonb. SQLAlchemy + psycopg3 don't handle::cast syntax through named params. - Login fails with "password too long" —
app/core/security.pyuses bcrypt directly (not passlib) and truncates at 72 bytes. passlib's wrap-bug detector crashes on bcrypt ≥ 4 + Python 3.14. - API 500 on delete brief — resolved via
cascade="all, delete-orphan", passive_deletes=Trueon Brief → Documents / Generations. Keep this pattern on any new child table.
Roadmap
- Entra SSO swap-in (pluggable
AuthProvideralready in place) - Per-generation token / cost dashboard (data already captured on
generations) - Incremental re-embedding when a document is re-ingested
- Golden-output regression: commit small fixture briefs + expected JSON so agent-prompt tweaks don't silently change output shape
- Section-level comments on review forms for team collaboration
Credits
Built by the Oliver Agency AI team, leveraging Anthropic Claude (Opus 4.7 for content, Haiku 4.5 for ingestion), pgvector for retrieval, and python-docx for branded Word output.