Spaces:
Sleeping
Sleeping
File size: 12,694 Bytes
d96f0ad e6fb135 98ee05e e6fb135 5456422 e6fb135 5456422 57d893e 5456422 4fb678b 08b77b5 5456422 4fb678b 08b77b5 5456422 e6fb135 98ee05e e6fb135 5456422 e6fb135 98ee05e e6fb135 5456422 e6fb135 98ee05e e6fb135 98ee05e e6fb135 98ee05e e6fb135 5456422 e6fb135 98ee05e e6fb135 5456422 e6fb135 98ee05e e6fb135 5456422 e6fb135 98ee05e e6fb135 5456422 e6fb135 98ee05e e6fb135 5456422 e6fb135 98ee05e 5456422 e6fb135 98ee05e e6fb135 98ee05e e6fb135 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 | ---
title: Self Improving Ad Copy Agent
emoji: π§
colorFrom: gray
colorTo: gray
sdk: docker
pinned: false
---
# Self-Improving Ad Copy Agent
A small agentic system that generates direct-to-consumer (DTC) ad copy,
evaluates its **own** outputs with an LLM-as-judge, stores the results, and
uses that signal to improve over time. The loop runs end to end:
```
retrieve -> generate -> evaluate -> log -> improve -> repeat
```
Every good output becomes future few-shot fuel and a regression baseline; every
bad output gets flagged for review. The system gets better the more it runs,
without any human editing prompts in the hot path.
---
## Live Demo
**π Live demo:** https://adcopy.zeetay.dev
The hosted demo runs the full loop from the browser: fill the brief (pre-filled
with a FitFuel example), click **Generate**, and watch the agent retrieve past
winners, generate three variants, and score each on four dimensions in real time.
- **Frontend:** Next.js (App Router) deployed on **Netlify**, in [`web/`](web/).
- **Backend:** the FastAPI app deployed on **Hugging Face Spaces** (Docker, see [`Dockerfile`](Dockerfile)),
with CORS, a per-IP rate limit on `/run`, a `/stats` endpoint, and a startup
seed so a fresh deployment shows non-zero stats and working retrieval on the
very first visit.
> Deployment env vars: the backend needs `GROQ_API_KEY` (and optionally
> `CORS_ORIGINS` = your site URL); the frontend needs `NEXT_PUBLIC_API_URL` =
> your Hugging Face Space URL. See the per-app `.env.example` files.
---
## What it does (and why it's architecturally interesting)
Most "LLM app" demos are a single prompt with no memory and no notion of
quality. This project is built around three ideas that make it a genuine
*self-improving* loop:
1. **It judges itself on multiple axes.** Each output is scored 1-5 on four
independent dimensions (hook strength, brand alignment, clarity, conversion
intent) by a separate judge model. Dimension scores are first-class and
stored separately; there is no single "vibe" score driving decisions.
2. **It remembers what worked.** High-scoring outputs are embedded by their
brand brief and stored in a local vector store. On every new run, the agent
retrieves the most similar past winners and injects them as few-shot
examples. The quality bar therefore ratchets upward over time.
3. **It refuses to regress.** Winning outputs are captured into a *golden
dataset*. Any change to the prompt is checked against that dataset before it
is allowed to drive real runs: if quality drops by more than a tolerance on
any known-good brief, the change fails.
The result is a closed feedback loop where generation, evaluation, memory, and
prompt versioning reinforce each other.
---
## Architecture
A single run flows through `agent/core.py`:
1. Retrieve top-3 high-scoring past outputs (`agent/memory.py`, ChromaDB)
2. Inject them as few-shot examples (`agent/prompts.py`)
3. Generate headline / body / cta with Groq (`agent/tools.py`)
4. Judge each variant on 4 dimensions with Groq (`evals/judge.py`)
5. Store run + outputs + scores in SQLite (`db/store.py`)
6. Promote winners (>= 4.0) to golden + memory (`feedback/loop.py`)
7. Flag losers (< 2.5) for review (`feedback/loop.py`)
| Layer | File(s) | Responsibility |
|------------------|----------------------------------|-------------------------------------------------|
| Persistence | `db/store.py` | SQLite: runs, outputs, golden, flagged |
| Prompts | `agent/prompts.py` | All prompt text, versioned; nothing hardcoded elsewhere |
| Memory | `agent/memory.py` | ChromaDB + sentence-transformers retrieval |
| Agent loop | `agent/core.py`, `agent/tools.py`| Orchestration + Groq client |
| Evaluation | `evals/*.py` | Rubric, judge, golden dataset, regression runner|
| Feedback | `feedback/loop.py` | Promote winners, flag losers |
| API | `api/main.py` | `POST /run` |
| CLI / tests | `scripts/run_eval.py`, `tests/` | Manual runs and the regression suite |
---
## Setup
### Requirements
- Python 3.11+
- A Groq API key (free at <https://console.groq.com/keys>)
### Install
```bash
python -m venv .venv
# Windows: .venv\Scripts\activate
# macOS/Linux: source .venv/bin/activate
pip install -r requirements.txt
```
### Configure
```bash
cp .env.example .env # Windows: copy .env.example .env
# then edit .env and set GROQ_API_KEY
```
Only `GROQ_API_KEY` is required. Everything else (SQLite at `./agent.db`,
ChromaDB at `./chroma_db`) is local; no other external services.
### First run
```bash
# Run the agent on the built-in FitFuel example brief:
python scripts/run_eval.py run
# Or run on your own brief:
python scripts/run_eval.py run --brief my_brief.json
# Inspect golden / flagged counts at any time:
python scripts/run_eval.py status
```
The very first run retrieves zero few-shot examples (memory is empty). As you
run more briefs, winners accumulate and later runs start standing on the
shoulders of earlier ones.
A brief is a JSON object:
```json
{
"brand": "FitFuel",
"product": "High-protein meal replacement shake",
"audience": "Busy professionals aged 25-40",
"tone": "Energetic and no-nonsense",
"goal": "Drive trial purchases"
}
```
### Run the API
```bash
uvicorn api.main:app --reload
```
Then trigger the full loop:
```bash
curl -X POST http://127.0.0.1:8000/run \
-H "Content-Type: application/json" \
-d '{"brand":"FitFuel","product":"High-protein meal replacement shake","audience":"Busy professionals aged 25-40","tone":"Energetic and no-nonsense","goal":"Drive trial purchases"}'
```
The response contains the three generated variants, all four dimension scores
per variant, the weighted average, and a feedback summary (what was promoted /
flagged).
---
## How the self-improving loop works
1. **Retrieve.** The incoming brief is embedded (locally, via
`all-MiniLM-L6-v2`) and used to query ChromaDB for the **top-3 most similar
past outputs that scored >= 3.5/5**. Only proven-good copy is ever retrieved.
2. **Generate.** Those examples are injected into the active generation prompt
under a *"Past high-performing examples"* section, and the model produces a
headline hook, body copy, and a CTA in one structured JSON response.
3. **Evaluate.** Each of the three variants is immediately scored by the judge
model on the four rubric dimensions. Scores are clamped to 1-5 and a weighted
average is computed for internal ranking only.
4. **Log.** The run, every variant, and every dimension score are written to
SQLite.
5. **Improve.** The feedback loop:
- promotes any output with a weighted average **>= 4.0** into both the golden
dataset and ChromaDB memory (so it can be retrieved next time);
- flags any output **< 2.5** into the `flagged_outputs` table with the reason
*"below quality threshold."*
Because winners re-enter memory, the pool of few-shot exemplars improves run
over run; that is the "self-improving" part.
---
## Evaluation & regression tests
### The rubric (`evals/rubric.py`)
Four dimensions, each 1-5:
| Dimension | Question | Weight |
|--------------------|-----------------------------------------------------|:------:|
| `hook_strength` | Does the headline immediately grab attention? | 0.30 |
| `brand_alignment` | Does the copy reflect the brief tone and audience? | 0.25 |
| `clarity` | Is the message immediately understandable? | 0.25 |
| `conversion_intent`| Does it drive toward the stated goal? | 0.20 |
The weighted average is **internal only**, used for thresholds and ranking.
All four raw dimensions are always stored separately.
### Golden dataset (`evals/golden.py`)
Any output with weighted average **>= 4.0** is captured (brief, output, all
scores, prompt version, timestamp). This is the regression baseline.
### Regression runner (`evals/runner.py`)
For each golden entry it regenerates copy for the same brief + variant using the
prompt version under test, re-judges it, and compares the new weighted score to
the stored baseline. If any entry **drops by more than 0.5**, the run fails with
a clear warning.
```bash
# Run the regression eval against the active prompt and print a Rich table:
python scripts/run_eval.py regression
```
### Pytest suite (`tests/test_regression.py`)
```bash
pytest tests/test_regression.py
```
It loads the golden dataset, runs the eval runner, and asserts no entry
regresses more than 0.5 from baseline. The test **skips** (rather than failing
spuriously) when `GROQ_API_KEY` is unset or the golden dataset is still empty.
---
## Prompt versioning & how to swap versions safely
All prompt text lives in `agent/prompts.py`; nothing is hardcoded anywhere
else. Each prompt is a named, versioned constant (`GENERATION_PROMPT_V1`,
`GENERATION_PROMPT_V2`, ...). A single constant selects which is live:
```python
ACTIVE_PROMPT_VERSION = "GENERATION_PROMPT_V1"
```
Every run logs the version it used (stored on the run and on each output).
**To promote a new prompt safely:**
1. Add a new constant (e.g. `GENERATION_PROMPT_V2`) and register it in
`PROMPT_REGISTRY`.
2. Run the regression check against it **before** making it active:
```bash
# Either run the suite, or the CLI regression command after flipping the
# constant in a branch:
pytest tests/test_regression.py
python scripts/run_eval.py regression
```
3. Only if no golden entry regresses more than 0.5, change
`ACTIVE_PROMPT_VERSION` to the new version.
This is what "changing the active version triggers a regression check before it
is used in a real run" means in practice: the golden dataset is the gate.
`GENERATION_PROMPT_V2` ships in this repo as a worked example you can promote.
---
## Design Decisions
**Why ChromaDB for memory.** The agent needs *semantic* retrieval: "find past
briefs like this one", not exact lookups. ChromaDB gives a persistent local
vector store with cosine similarity and zero external services, and it pairs
cleanly with local sentence-transformers embeddings. SQLite alone can't do
nearest-neighbour search over brief semantics; a hosted vector DB would violate
the "local only" constraint and add ops overhead for no benefit at this scale.
**Why dimension scoring over a composite score.** A single 1-10 "quality" score
is unactionable and easy for a judge to anchor on. Scoring four independent
dimensions tells you *why* copy is weak (great hook, poor clarity) and makes the
signal far more stable and debuggable. We do compute a weighted average, but
only for internal thresholds/ranking; the four raw dimensions are always
stored, so we never lose information by collapsing too early.
**Why SQLite for eval storage.** Eval results are structured, relational, and
queryable (runs -> outputs -> scores; golden; flagged). SQLite gives ACID
guarantees, trivial setup, a single-file database, and real SQL, ideal for run
history and a golden dataset. It needs no server and ships with Python. A hosted
DB would add infrastructure with no upside for a local, single-node harness.
**Why the 4.0 threshold for golden inclusion.** On a 1-5 scale, 4.0 means
"clearly good on the weighted blend" without demanding perfection. Set it lower
and the golden set fills with mediocre copy, weakening both the regression
baseline and the few-shot exemplars. Set it higher (e.g. 4.5) and you rarely
capture anything, so the system never accumulates a baseline or improves. 4.0 is
the point where entries are good enough to *defend* against regressions and to
*teach* future runs. (The retrieval floor is a more permissive 3.5 so memory can
draw on a slightly wider pool of solid examples, while only the strongest >=4.0
outputs become protected golden baselines.)
---
## Project layout
```
self-improving-agent/
βββ README.md
βββ requirements.txt
βββ .env.example
βββ agent/ core loop, tools, memory, versioned prompts
βββ evals/ rubric, LLM judge, golden dataset, regression runner
βββ feedback/ post-run promote/flag loop
βββ db/ SQLite store
βββ api/ FastAPI app (POST /run)
βββ tests/ pytest regression suite
βββ scripts/ run_eval.py CLI (run / regression / status)
```
|