Project A — Prompt Suggestion: System Design & Administrator Manual
Scope. This document covers Project A only — the Prompt Suggestion capability. That means the
prompt_suggestion_servicemicroservice and everything directly wired to it: the browser tab that drives it, the back-end proxy endpoints that reach it, the LLM/embedding service it calls, the vector database it reads, and the external Peec data API it enriches from. Other parts of the wider platform (dashboards, chat, crawl analytics, etc.) are intentionally out of scope.It is written to be understood by both non-technical readers (start with the "In plain language" boxes and the diagrams) and technical readers (tables, env vars, and file references throughout).
Part 1 — System Design
1.1 Overview
In plain language. "Prompt Suggestion" is the feature that proposes new search-style questions ("prompts") a brand should track — e.g. "best free project management software". A user opens the Prompt Suggestions tab, optionally tweaks some inputs (their markets, competitors, target audience), and clicks Generate. Behind the scenes the system looks at how real people phrase questions in this category, asks a Large Language Model (LLM) to write fresh, on-topic prompts, removes duplicates, and returns a diverse list the user can accept and start tracking.
Technically, Project A is a retrieval-augmented generation (RAG) pipeline exposed as a stateless Python microservice:
- Retrieve — embed a category query and run a vector similarity search over a pool of real-user "clickstream" reference prompts stored in pgvector.
- Enrich — pull the brand's profile, existing tracked prompts, and topics from the Peec customer API for framing.
- Synthesize — ask the LLM (via the shared
ai-service) to write new prompts, one batch per confirmed topic, steered by the references + framing. - Diversify — exact-text dedup, then per-topic MMR selection with a cross-topic near-duplicate guard, to return a varied final set.
The service is internal-only: the browser never talks to it directly. It
sits behind the Node/Express back-end (apps/server), which authenticates the
user and proxies the request.
1.2 Components & responsibilities
| Component | Tech | Port | Role in Project A |
|---|---|---|---|
Web app (apps/web) |
React 19 + Vite, served by nginx | 80 | The Prompt Suggestions tab (/prompt-suggestions): the generation wizard, the "Configure Inputs" panel, the coverage map, and duplicate detection UI. |
Back-end / BFF (apps/server) |
Node 20, Express 5, TypeScript | 4000 | The only public entry point. Authenticates the user (Firebase), validates the request (Zod), and proxies verbatim to Project A. Endpoints: POST /api/suggestions, POST /api/topics, POST /api/prompt-similarity. |
Prompt Suggestion service (apps/prompt_suggestion_service) — Project A |
Python 3.12, FastAPI, Pydantic v2, httpx | 8001 | The RAG orchestration: retrieval + enrichment + synthesis + diversity. Owns all prompt/template logic. |
AI service (apps/ai-service) |
Python 3.12, FastAPI | 8000 | Shared LLM gateway. Project A calls it for embeddings (POST /api/embeddings) and completions (POST /api/completions). It brokers the actual OpenAI calls. |
| PostgreSQL + pgvector | pgvector/pgvector:pg16 |
5432 | The vector store of reference/clickstream prompt embeddings that retrieval reads. |
| Peec customer API (external) | https://api.peec.ai/customer/v1 |
443 | Source of brand profile, existing prompts, and topics for framing/enrichment. |
In plain language: the browser tab is the storefront, the Express server is the security desk that checks your badge and passes your order through, Project A is the kitchen that actually prepares the suggestions, the AI service is the appliance the kitchen uses, and the vector database + Peec API are the pantry of ingredients.
1.3 Hardware / software mapping
Everything runs as Docker containers orchestrated by Docker Compose, on a single Coolify-managed Linux host (VPS). There is no per-service dedicated hardware; containers share the host's CPU/RAM and are isolated at the container level. The only outbound "hardware" dependencies are third-party SaaS (OpenAI, Peec API, Langfuse).
| Software unit | Runs as | Host resource | External hardware/SaaS reached |
|---|---|---|---|
prompt-suggestion-service (Project A) |
Docker container (python:3.12-slim, uvicorn) |
CPU-bound during synthesis; mostly I/O-wait on the LLM | none directly — reaches OpenAI through ai-service |
ai-service |
Docker container | CPU/network | OpenAI / Google LLM & embedding APIs; Langfuse (traces) |
postgres (pgvector) |
Docker container | Disk (named volume postgres-data) + RAM |
— |
server |
Docker container | low | Firebase (token verification) |
web |
Docker container (nginx static) | low | — |
| Traefik (provided by Coolify) | Host reverse proxy | network / TLS | Let's Encrypt (certificates) |
Project A is CPU-light and I/O-heavy: most wall-clock time is spent waiting on the LLM. A single
/suggestionscall was measured at ~40 s end-to-end (meta.duration_ms ≈ 39,825,reference_count = 45).
1.4 Deployment diagram
flowchart TB
internet(["Internet (HTTPS)"])
subgraph public["Published via Traefik (public subdomains)"]
direction TB
traefik["Traefik reverse proxy (Coolify)<br/>TLS via Let's Encrypt"]
web["web · nginx :80<br/>React SPA · Prompt Suggestions tab"]
server["server · Express :4000<br/>Firebase auth · Zod validation · verbatim proxy"]
end
subgraph internal["Internal Docker network (never exposed to the internet)"]
direction TB
pss["prompt-suggestion-service · FastAPI :8001 (PROJECT A)<br/>POST /suggestions /topics /similarity<br/>/retrieve /synthesize /test/* · GET /health"]
ai["ai-service :8000<br/>/api/embeddings · /api/completions"]
pg[("postgres / pgvector :5432<br/>reference prompt embeddings")]
end
subgraph external["External SaaS / APIs"]
direction TB
peec["Peec customer API (api.peec.ai/customer/v1)<br/>profile · prompts · topics"]
llm["OpenAI / Google"]
langfuse["Langfuse (LLM tracing)"]
end
internet --> traefik
traefik -->|"pr-N.preview… (dev.*)"| web
traefik -->|"api-pr-N.preview… (dev-api.*)"| server
web -.->|"browser → VITE_API_URL"| server
server -->|"internal only"| pss
pss -->|"embeddings + completions"| ai
pss -->|"vector search"| pg
pss -->|"brand context"| peec
ai --> llm
ai --> langfuse
Only web and server are published through Traefik (they get public
subdomains). Project A, ai-service, and postgres are never exposed to the
internet — they are reachable only on the internal Docker network
(expose: not ports:).
1.5 Persistent data
In plain language: Project A itself keeps almost nothing — it's a "calculate and return" service. The data it uses lives elsewhere.
| Data | Where it lives | Owned by | Notes |
|---|---|---|---|
| Reference / clickstream prompt embeddings | pgvector table(s) in PostgreSQL (postgres-data volume) |
populated by an ingestion process (not by Project A at request time) | Read-only for Project A during retrieval. This is the "memory" of how real users phrase questions. |
| Brand profile, existing prompts, topics | External Peec customer API | Peec platform | Fetched live per request; not stored by Project A. Best-effort — failures degrade gracefully (empty context) except the profile, which is required. |
| Accepted suggestions → tracked prompts | TrackedPrompt / Topic tables (owned by apps/server via Prisma) |
apps/server |
When the user accepts prompts, the server persists them — Project A is not involved in the write. |
| LLM traces | Langfuse (external SaaS) | ai-service | Observability only. |
Project A owns no Prisma schema and runs no migrations (repo convention: only
apps/serverowns the schema). It connects to the same database purely to read the vector store.Statefulness: Project A is effectively stateless between requests — it holds only in-process HTTP clients and config. It can be restarted or scaled horizontally without data-loss concerns.
1.6 Access control
In plain language: you must be logged in to use the feature. Login is checked once, at the front door (the Express server). Project A trusts that the server already did the checking.
| Layer | Mechanism | Notes |
|---|---|---|
| Browser → server | Firebase Authentication (ID token as Authorization: Bearer …) |
Verified by requireAuth (apps/server/src/middleware/auth.middleware.ts). Missing token → 401, invalid/expired → 403. |
| Server → Project A | Internal Docker network only | The token is forwarded, but Project A does not verify it (see Security). |
| Project A → Peec API | PEEC_API_KEY (service credential) |
Server-to-server; not user-scoped. |
| Project A → ai-service | Internal network, no auth | Both internal-only. |
1.7 Security
Network isolation (strength). Project A, ai-service, and Postgres are
not internet-reachable — only web and server are published via Traefik.
All inter-service traffic stays on the private Docker network. TLS is terminated
at Traefik with automatically renewed Let's Encrypt certificates.
Input validation (strength). The server validates every request with Zod
before proxying (SuggestionRequestSchema, TopicGenerationRequestSchema).
Bad input is rejected fast with a clean 400 ValidationError; unauthenticated
calls get 401. (Verified live: missing project_id, limit out of the 1–50
range, branded_ratio > 1, and malformed JSON all return 400; no token → 401.)
Secrets. LLM keys (OPENAI_API_KEY), the Peec key
(PEEC_API_KEY), and the Firebase service account
(FIREBASE_SERVICE_ACCOUNT_BASE64) are injected as environment variables
(GitHub Actions secrets → Coolify env), never committed.
⚠️ Known security gaps (see also §2.4). These are honest limitations of the current design, called out so administrators can compensate:
- No authorization on
project_id(tenant isolation gap). The server authenticates who you are but does not check that theproject_idyou ask for belongs to you before proxying to Project A. Any logged-in user can request suggestions/topics/personas for any project id. Verified: calling the personas path with a project id the account does not own returned200with data. Mitigation: add an ownership check in the server controllers (suggestions/topics/prompt-similarity) before the proxy call; return404for foreign projects (as the dashboards code already does).- Project A has no authentication of its own — auth was explicitly deferred (see the service's
CLAUDE.md: "Auth: removed for now"). The only gate is the server. Mitigation: keep the service internal-only (already the case) and reintroduce a token check for defense-in-depth.- No end-to-end timeout on the server→Project A call. The server's fetch helper has no
AbortSignal; a stalled LLM can hang the request (synthesis can already legitimately take ~40 s). Mitigation: add a bounded timeout that maps to the existing502path.
Part 2 — Administrator Manual
2.1 Infrastructure setup
In plain language: one Linux server runs Coolify (a self-hosted deployment platform, similar to a private Heroku). Coolify runs Traefik for routing/TLS and builds/starts the containers from the repo's Compose files.
Prerequisites on the host
- A Linux VPS with Docker Engine + Docker Compose v2.
- Coolify installed (provides the Traefik reverse proxy and the
coolifyDocker network that the Compose files attach to). - DNS: a wildcard
*.preview.peecai-csee.com(and thedev.*/dev-api.*records) pointing at the host, so Traefik can route per-environment subdomains. - Firewall: expose only 80/443 publicly. Ports 8001 (Project A), 8000 (ai-service), and 5432 (Postgres) must not be publicly reachable.
Build toolchain (for local/CI builds)
- Node.js 20+, pnpm 9.12.0 (via Corepack),
uv(Python), Docker Compose v2.
Compose files (what runs where)
| File | Used for | Key differences |
|---|---|---|
| docker-compose.yml | Local development | Publishes host ports; Grafana anonymous-admin; Project A reads apps/prompt_suggestion_service/.env. |
| docker-compose.coolify.yml | Production (Coolify) | Services expose only (no host ports); secrets required; LLM_PROVIDER=remote. |
| docker-compose.preview.yml | Per-PR preview envs | Traefik labels for pr-N.* / api-pr-N.*; attaches to the external coolify network. |
2.2 Deployment steps
A. Production (Coolify)
- In Coolify, the application points at this repo and uses
docker-compose.coolify.yml. - Set all required environment variables in the Coolify UI (see §2.3).
- Coolify builds the images (Project A via its
Dockerfile:python:3.12-slim+uv sync --frozen) and starts the stack. - Traefik publishes
webandserveron their configured domains with Let's Encrypt TLS. Project A comes up on the internal network athttp://prompt-suggestion-service:8001. - The server container applies Prisma migrations on boot
(
prisma migrate deploy) before serving. Project A needs no migration step. - Verify:
GET /healthon Project A (from inside the network) and the Prompt Suggestions tab end-to-end from the browser.
B. Preview deployments (per pull request)
Driven by .github/workflows/preview-deploy.yml. In plain language: every
PR labelled deploy-preview gets its own throwaway copy of the whole stack at
its own URL, and it's deleted when the PR closes.
- Trigger: add the
deploy-previewlabel to a PR (or push to an already-labelled PR). - The workflow SSHes to the host,
rsyncs the repo to/var/www/previews/pr-<N>/, and writes a.envfrom thePREVIEW_ENVsecret plus: VITE_API_URL=https://api-pr-<N>.preview.peecai-csee.comVITE_APP_ENV=dev- It brings the stack up with a dedicated project name
(
docker compose -f docker-compose.preview.yml -p preview-pr-<N>), building Project A and the others fresh. - Clean slate each deploy: the previous stack is torn down with its DB
volume (
down --volumes) so every preview starts from a clean schema. - Migration self-heal: if Prisma is wedged in a failed state (P3009), the workflow rolls back the named failed migrations and retries once.
- A bot comments the preview URL (
https://pr-<N>.preview.peecai-csee.com) and sets apreview: live/preview: failedlabel based on a health check. - Teardown: closing the PR (or removing the label) runs
cleanup-preview, whichdown --volumes --rmi alland deletes the preview directory.
Routing for a preview is done by Traefik labels in docker-compose.preview.yml:
web → pr-<N>.preview.peecai-csee.com (port 80), server →
api-pr-<N>.preview.peecai-csee.com (port 4000). Project A stays internal.
Preview data caveat: because the DB volume is wiped on every preview deploy, the pgvector reference prompt data must be (re)seeded for retrieval to return meaningful results in a preview. With an empty vector store, generation still runs but has no clickstream references to ground on.
2.3 Environment variables
Project A — prompt_suggestion_service (app/settings.py; snake_case on the
wire, UPPER_CASE as env vars):
| Variable | Default | Purpose |
|---|---|---|
LLM_PROVIDER |
mock |
mock returns canned data (tests); must be remote in real envs to call the LLM. Coolify/preview set remote. |
LLM_MODEL |
gpt-4o-mini |
Model label reported in meta.llm_model. |
AI_SERVICE_URL |
(unset) | Base URL of the ai-service for embeddings (/api/embeddings) + completions (/api/completions). Compose sets http://ai-service:8000; required when LLM_PROVIDER=remote. |
VECTOR_STORE |
pgvector |
Vector backend (only pgvector supported). |
DATABASE_URL |
postgresql://peec:peec@localhost:5432/peec |
Postgres/pgvector connection. |
PEEC_API_BASE_URL |
https://api.peec.ai/customer/v1 |
Brand profile/prompts/topics source. |
PEEC_API_KEY |
(unset) | Credential for the Peec API. Unset → profile enrichment unavailable (the required-profile path returns 502; prompts/topics degrade to empty). |
REQUEST_TIMEOUT_MS |
30000 |
httpx timeout for outbound calls. |
SYNTHESIS_MAX_CONCURRENCY |
5 |
Cap on parallel per-topic LLM batches. |
MMR_LAMBDA |
0.5 |
Relevance-vs-diversity tradeoff in selection. |
MMR_CROSS_TOPIC_GUARD |
0.90 |
Cosine threshold above which a cross-topic near-duplicate is dropped. |
TOPIC_DEDUP_THRESHOLD |
0.85 |
Similarity threshold for topic dedup. |
LLM_MAX_ATTEMPTS |
3 |
Retries for a failing LLM call. |
SYNTHESIS_TEMPERATURE |
(unset) | Optional LLM temperature override (0–2). |
SYNTHESIS_MASK_ENABLED / _DROP_COUNT / _SEED |
false / 1 / 0 |
Optional context-masking to decorrelate brand vocabulary across topics (PEEC-418). |
Connected components (subset relevant to Project A):
| Variable | Service | Purpose |
|---|---|---|
OPENAI_API_KEY / GOOGLE_API_KEY |
ai-service | LLM & embedding provider credentials (Project A reaches these through ai-service). |
AI_DEFAULT_MODEL (gpt-5.4-mini) / EMBEDDING_MODEL (text-embedding-3-small) |
ai-service | Default completion / embedding models. |
LANGFUSE_PUBLIC_KEY / LANGFUSE_SECRET_KEY / LANGFUSE_BASE_URL |
ai-service | LLM tracing (optional). |
PROMPT_SUGGESTION_SERVICE_URL |
server, ai-service | Where to reach Project A (http://prompt-suggestion-service:8001). |
FIREBASE_SERVICE_ACCOUNT_BASE64 |
server | Verifies user tokens (base64-encoded service-account JSON). |
CORS_ALLOWED_ORIGINS |
server | Allowed browser origins (e.g. the preview/dev web domain). |
VITE_API_URL / VITE_APP_ENV |
web (build-time) | API base URL baked into the SPA; environment label. |
2.4 Known issues & workarounds
| # | Issue | Impact | Workaround / fix |
|---|---|---|---|
| 1 | project_id not authorized at the server before proxying. |
Cross-tenant access: any logged-in user can generate/read for any project id (verified). | Add an ownership check in the suggestions/topics/prompt-similarity controllers; return 404 for foreign projects. Keep Project A internal-only. |
| 2 | Project A has no auth (deferred by design). | Sole gate is the server; no defense-in-depth. | Never expose 8001 publicly (already the case). Reintroduce a token check when the scheme is decided. |
| 3 | No timeout on server→Project A fetch. | A stalled LLM hangs the request; the UI shows "Generating…" indefinitely. | Add an AbortController timeout in the server's fetch helper → maps to 502, which the wizard's retry UI already handles. |
| 4 | Slow generation (~40 s) with faint progress UI. | Users may think it's stuck. | Strengthen the in-progress indicator (persistent + elapsed time); consider a cancel button. |
| 5 | Inline "Generate more prompts" fails silently (onError only clears the spinner). |
On failure the user gets no message (unlike the wizard, which shows a retry). | Surface an error/toast on the inline path too. |
| 6 | LLM_PROVIDER defaults to mock. |
If unset in a real env, Project A returns canned data with no error. | Ensure LLM_PROVIDER=remote in every non-test environment (Coolify/preview already set it). |
| 7 | PEEC_API_KEY unset disables profile enrichment. |
The required-profile path returns 502; prompts/topics context is dropped. | Set PEEC_API_KEY in all real environments. |
| 8 | Per-topic synthesis cost scales with the number of confirmed topics (each batch re-sends the full reference pool; up to 2+2 backfill rounds). | Latency and LLM spend grow with topic count. | Cap confirmed_topics per request server-side; tune SYNTHESIS_MAX_CONCURRENCY. |
| 9 | Preview deploy wipes the DB volume each run. | pgvector reference data is empty until reseeded → ungrounded retrieval. | Reseed the vector store as part of preview provisioning if grounded results are needed. |
| 10 | Prisma P3009 wedged migrations could previously block deploys. | Deploy failures on the server (Project A unaffected). | Already mitigated: the preview workflow auto-rolls-back failed migrations and retries once. |
2.5 Third-party components
| Component | Type | Used for | Where configured |
|---|---|---|---|
| OpenAI | External API | LLM completions + embeddings (via ai-service) | OPENAI_API_KEY, AI_DEFAULT_MODEL, EMBEDDING_MODEL |
| Google (Gemini) | External API | Optional LLM provider (via ai-service) | GOOGLE_API_KEY |
Peec customer API (api.peec.ai/customer/v1) |
External API | Brand profile, existing prompts, topics | PEEC_API_BASE_URL, PEEC_API_KEY |
| Langfuse | External SaaS | LLM trace/observability | LANGFUSE_* |
| Firebase Authentication | External SaaS (Google) | User identity / token verification | FIREBASE_SERVICE_ACCOUNT_BASE64 |
| PostgreSQL 16 + pgvector | Container image | Vector store for reference prompts | pgvector/pgvector:pg16, DATABASE_URL |
| FastAPI / Pydantic v2 / httpx / uv | Python libraries | The service framework + HTTP client + dependency manager | apps/prompt_suggestion_service |
| Coolify | Self-hosted PaaS | Build & deploy orchestration on the host | host |
| Traefik | Reverse proxy | Routing + TLS termination for public services | provided by Coolify; labels in docker-compose.preview.yml |
| Let's Encrypt | CA | Automatic TLS certificates | Traefik certresolver=letsencrypt |
| Docker / Docker Compose | Runtime | Containerization & orchestration | all compose files |
| GitHub Actions | CI/CD | Preview deploys, migrations, checks | .github/workflows/ |
| Grafana + Loki + Promtail | Observability stack | Log aggregation/dashboards (platform-wide; Project A logs flow here) | infra/ + compose |
2.6 Quick operational reference
- Health:
GET http://prompt-suggestion-service:8001/health(internal). - Primary endpoint:
POST /suggestions(reached by the browser asPOST /api/suggestionsthrough the server). - OpenAPI/Swagger: FastAPI serves
/openapi.json,/docs,/redocon Project A (internal). - Logs:
docker compose -p <project> logs -f prompt-suggestion-service; aggregated in Grafana/Loki. - Restart safely: Project A is stateless — restart/redeploy freely; no data migration or drain needed.
- Full reference:
docs/prompt-suggestion/documentation.md(service spec, architecture, data model).