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)
```