Spaces:
Sleeping
Sleeping
Zeetay commited on
Commit ·
e6fb135
0
Parent(s):
Initial commit: self-improving ad copy agent eval harness
Browse filesEnd-to-end loop: retrieve (ChromaDB) -> generate (Groq) -> evaluate (LLM-as-judge, 4 dimensions) -> store (SQLite) -> feedback (promote winners to golden + memory, flag losers). Includes versioned prompts with a regression gate, FastAPI /run endpoint, Rich CLI, and a pytest regression suite.
- .env.example +1 -0
- .gitignore +17 -0
- README.md +302 -0
- agent/__init__.py +15 -0
- agent/core.py +95 -0
- agent/memory.py +127 -0
- agent/prompts.py +176 -0
- agent/tools.py +75 -0
- api/__init__.py +0 -0
- api/main.py +54 -0
- db/__init__.py +0 -0
- db/store.py +245 -0
- evals/__init__.py +0 -0
- evals/golden.py +47 -0
- evals/judge.py +41 -0
- evals/rubric.py +59 -0
- evals/runner.py +129 -0
- feedback/__init__.py +0 -0
- feedback/loop.py +82 -0
- requirements.txt +9 -0
- scripts/run_eval.py +169 -0
- tests/test_regression.py +48 -0
.env.example
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
GROQ_API_KEY=your_groq_api_key_here
|
.gitignore
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Local notes / source briefs
|
| 2 |
+
jd.txt
|
| 3 |
+
prompt.txt
|
| 4 |
+
|
| 5 |
+
# Secrets
|
| 6 |
+
.env
|
| 7 |
+
|
| 8 |
+
# Local databases & vector store
|
| 9 |
+
agent.db
|
| 10 |
+
agent.db-wal
|
| 11 |
+
agent.db-shm
|
| 12 |
+
chroma_db/
|
| 13 |
+
|
| 14 |
+
# Python
|
| 15 |
+
__pycache__/
|
| 16 |
+
*.pyc
|
| 17 |
+
.pytest_cache/
|
README.md
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Self-Improving Ad Copy Agent
|
| 2 |
+
|
| 3 |
+
A small agentic system that generates direct-to-consumer (DTC) ad copy,
|
| 4 |
+
evaluates its **own** outputs with an LLM-as-judge, stores the results, and
|
| 5 |
+
uses that signal to improve over time. The loop runs end to end:
|
| 6 |
+
|
| 7 |
+
```
|
| 8 |
+
retrieve → generate → evaluate → log → improve → repeat
|
| 9 |
+
```
|
| 10 |
+
|
| 11 |
+
Every good output becomes future few-shot fuel and a regression baseline; every
|
| 12 |
+
bad output gets flagged for review. The system gets better the more it runs —
|
| 13 |
+
without any human editing prompts in the hot path.
|
| 14 |
+
|
| 15 |
+
---
|
| 16 |
+
|
| 17 |
+
## What it does (and why it's architecturally interesting)
|
| 18 |
+
|
| 19 |
+
Most "LLM app" demos are a single prompt with no memory and no notion of
|
| 20 |
+
quality. This project is built around three ideas that make it a genuine
|
| 21 |
+
*self-improving* loop:
|
| 22 |
+
|
| 23 |
+
1. **It judges itself on multiple axes.** Each output is scored 1–5 on four
|
| 24 |
+
independent dimensions (hook strength, brand alignment, clarity, conversion
|
| 25 |
+
intent) by a separate judge model. Dimension scores are first-class and
|
| 26 |
+
stored separately — there is no single "vibe" score driving decisions.
|
| 27 |
+
|
| 28 |
+
2. **It remembers what worked.** High-scoring outputs are embedded by their
|
| 29 |
+
brand brief and stored in a local vector store. On every new run, the agent
|
| 30 |
+
retrieves the most similar past winners and injects them as few-shot
|
| 31 |
+
examples. The quality bar therefore ratchets upward over time.
|
| 32 |
+
|
| 33 |
+
3. **It refuses to regress.** Winning outputs are captured into a *golden
|
| 34 |
+
dataset*. Any change to the prompt is checked against that dataset before it
|
| 35 |
+
is allowed to drive real runs: if quality drops by more than a tolerance on
|
| 36 |
+
any known-good brief, the change fails.
|
| 37 |
+
|
| 38 |
+
The result is a closed feedback loop where generation, evaluation, memory, and
|
| 39 |
+
prompt versioning reinforce each other.
|
| 40 |
+
|
| 41 |
+
---
|
| 42 |
+
|
| 43 |
+
## Architecture
|
| 44 |
+
|
| 45 |
+
```
|
| 46 |
+
┌──────────────────────────── agent/core.py ───────────────────────────┐
|
| 47 |
+
brand brief ─▶│ 1. retrieve top-3 high-scoring past outputs (agent/memory.py, Chroma)│
|
| 48 |
+
│ 2. inject as few-shot examples (agent/prompts.py) │
|
| 49 |
+
│ 3. generate headline / body / cta (agent/tools.py → Groq) │
|
| 50 |
+
│ 4. judge each variant on 4 dimensions (evals/judge.py → Groq) │
|
| 51 |
+
│ 5. store run + outputs + scores (db/store.py → SQLite) │
|
| 52 |
+
│ 6. promote winners (≥4.0) → golden + memory (feedback/loop.py) │
|
| 53 |
+
│ 7. flag losers (<2.5) for review (feedback/loop.py) │
|
| 54 |
+
└────────────────────────────────────────────────────────────────────────┘
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
| Layer | File(s) | Responsibility |
|
| 58 |
+
|------------------|----------------------------------|-------------------------------------------------|
|
| 59 |
+
| Persistence | `db/store.py` | SQLite: runs, outputs, golden, flagged |
|
| 60 |
+
| Prompts | `agent/prompts.py` | All prompt text, versioned; nothing hardcoded elsewhere |
|
| 61 |
+
| Memory | `agent/memory.py` | ChromaDB + sentence-transformers retrieval |
|
| 62 |
+
| Agent loop | `agent/core.py`, `agent/tools.py`| Orchestration + Groq client |
|
| 63 |
+
| Evaluation | `evals/*.py` | Rubric, judge, golden dataset, regression runner|
|
| 64 |
+
| Feedback | `feedback/loop.py` | Promote winners, flag losers |
|
| 65 |
+
| API | `api/main.py` | `POST /run` |
|
| 66 |
+
| CLI / tests | `scripts/run_eval.py`, `tests/` | Manual runs and the regression suite |
|
| 67 |
+
|
| 68 |
+
---
|
| 69 |
+
|
| 70 |
+
## Setup
|
| 71 |
+
|
| 72 |
+
### Requirements
|
| 73 |
+
- Python 3.11+
|
| 74 |
+
- A Groq API key (free at <https://console.groq.com/keys>)
|
| 75 |
+
|
| 76 |
+
### Install
|
| 77 |
+
|
| 78 |
+
```bash
|
| 79 |
+
python -m venv .venv
|
| 80 |
+
# Windows: .venv\Scripts\activate
|
| 81 |
+
# macOS/Linux: source .venv/bin/activate
|
| 82 |
+
|
| 83 |
+
pip install -r requirements.txt
|
| 84 |
+
```
|
| 85 |
+
|
| 86 |
+
### Configure
|
| 87 |
+
|
| 88 |
+
```bash
|
| 89 |
+
cp .env.example .env # Windows: copy .env.example .env
|
| 90 |
+
# then edit .env and set GROQ_API_KEY
|
| 91 |
+
```
|
| 92 |
+
|
| 93 |
+
Only `GROQ_API_KEY` is required. Everything else (SQLite at `./agent.db`,
|
| 94 |
+
ChromaDB at `./chroma_db`) is local — no other external services.
|
| 95 |
+
|
| 96 |
+
### First run
|
| 97 |
+
|
| 98 |
+
```bash
|
| 99 |
+
# Run the agent on the built-in FitFuel example brief:
|
| 100 |
+
python scripts/run_eval.py run
|
| 101 |
+
|
| 102 |
+
# Or run on your own brief:
|
| 103 |
+
python scripts/run_eval.py run --brief my_brief.json
|
| 104 |
+
|
| 105 |
+
# Inspect golden / flagged counts at any time:
|
| 106 |
+
python scripts/run_eval.py status
|
| 107 |
+
```
|
| 108 |
+
|
| 109 |
+
The very first run retrieves zero few-shot examples (memory is empty). As you
|
| 110 |
+
run more briefs, winners accumulate and later runs start standing on the
|
| 111 |
+
shoulders of earlier ones.
|
| 112 |
+
|
| 113 |
+
A brief is a JSON object:
|
| 114 |
+
|
| 115 |
+
```json
|
| 116 |
+
{
|
| 117 |
+
"brand": "FitFuel",
|
| 118 |
+
"product": "High-protein meal replacement shake",
|
| 119 |
+
"audience": "Busy professionals aged 25-40",
|
| 120 |
+
"tone": "Energetic and no-nonsense",
|
| 121 |
+
"goal": "Drive trial purchases"
|
| 122 |
+
}
|
| 123 |
+
```
|
| 124 |
+
|
| 125 |
+
### Run the API
|
| 126 |
+
|
| 127 |
+
```bash
|
| 128 |
+
uvicorn api.main:app --reload
|
| 129 |
+
```
|
| 130 |
+
|
| 131 |
+
Then trigger the full loop:
|
| 132 |
+
|
| 133 |
+
```bash
|
| 134 |
+
curl -X POST http://127.0.0.1:8000/run \
|
| 135 |
+
-H "Content-Type: application/json" \
|
| 136 |
+
-d '{"brand":"FitFuel","product":"High-protein meal replacement shake","audience":"Busy professionals aged 25-40","tone":"Energetic and no-nonsense","goal":"Drive trial purchases"}'
|
| 137 |
+
```
|
| 138 |
+
|
| 139 |
+
The response contains the three generated variants, all four dimension scores
|
| 140 |
+
per variant, the weighted average, and a feedback summary (what was promoted /
|
| 141 |
+
flagged).
|
| 142 |
+
|
| 143 |
+
---
|
| 144 |
+
|
| 145 |
+
## How the self-improving loop works
|
| 146 |
+
|
| 147 |
+
1. **Retrieve.** The incoming brief is embedded (locally, via
|
| 148 |
+
`all-MiniLM-L6-v2`) and used to query ChromaDB for the **top-3 most similar
|
| 149 |
+
past outputs that scored ≥ 3.5/5**. Only proven-good copy is ever retrieved.
|
| 150 |
+
|
| 151 |
+
2. **Generate.** Those examples are injected into the active generation prompt
|
| 152 |
+
under a *"Past high-performing examples"* section, and the model produces a
|
| 153 |
+
headline hook, body copy, and a CTA in one structured JSON response.
|
| 154 |
+
|
| 155 |
+
3. **Evaluate.** Each of the three variants is immediately scored by the judge
|
| 156 |
+
model on the four rubric dimensions. Scores are clamped to 1–5 and a weighted
|
| 157 |
+
average is computed for internal ranking only.
|
| 158 |
+
|
| 159 |
+
4. **Log.** The run, every variant, and every dimension score are written to
|
| 160 |
+
SQLite.
|
| 161 |
+
|
| 162 |
+
5. **Improve.** The feedback loop:
|
| 163 |
+
- promotes any output with a weighted average **≥ 4.0** into both the golden
|
| 164 |
+
dataset and ChromaDB memory (so it can be retrieved next time);
|
| 165 |
+
- flags any output **< 2.5** into the `flagged_outputs` table with the reason
|
| 166 |
+
*"below quality threshold."*
|
| 167 |
+
|
| 168 |
+
Because winners re-enter memory, the pool of few-shot exemplars improves run
|
| 169 |
+
over run — that is the "self-improving" part.
|
| 170 |
+
|
| 171 |
+
---
|
| 172 |
+
|
| 173 |
+
## Evaluation & regression tests
|
| 174 |
+
|
| 175 |
+
### The rubric (`evals/rubric.py`)
|
| 176 |
+
Four dimensions, each 1–5:
|
| 177 |
+
|
| 178 |
+
| Dimension | Question | Weight |
|
| 179 |
+
|--------------------|-----------------------------------------------------|:------:|
|
| 180 |
+
| `hook_strength` | Does the headline immediately grab attention? | 0.30 |
|
| 181 |
+
| `brand_alignment` | Does the copy reflect the brief tone and audience? | 0.25 |
|
| 182 |
+
| `clarity` | Is the message immediately understandable? | 0.25 |
|
| 183 |
+
| `conversion_intent`| Does it drive toward the stated goal? | 0.20 |
|
| 184 |
+
|
| 185 |
+
The weighted average is **internal only** — used for thresholds and ranking.
|
| 186 |
+
All four raw dimensions are always stored separately.
|
| 187 |
+
|
| 188 |
+
### Golden dataset (`evals/golden.py`)
|
| 189 |
+
Any output with weighted average **≥ 4.0** is captured (brief, output, all
|
| 190 |
+
scores, prompt version, timestamp). This is the regression baseline.
|
| 191 |
+
|
| 192 |
+
### Regression runner (`evals/runner.py`)
|
| 193 |
+
For each golden entry it regenerates copy for the same brief + variant using the
|
| 194 |
+
prompt version under test, re-judges it, and compares the new weighted score to
|
| 195 |
+
the stored baseline. If any entry **drops by more than 0.5**, the run fails with
|
| 196 |
+
a clear warning.
|
| 197 |
+
|
| 198 |
+
```bash
|
| 199 |
+
# Run the regression eval against the active prompt and print a Rich table:
|
| 200 |
+
python scripts/run_eval.py regression
|
| 201 |
+
```
|
| 202 |
+
|
| 203 |
+
### Pytest suite (`tests/test_regression.py`)
|
| 204 |
+
|
| 205 |
+
```bash
|
| 206 |
+
pytest tests/test_regression.py
|
| 207 |
+
```
|
| 208 |
+
|
| 209 |
+
It loads the golden dataset, runs the eval runner, and asserts no entry
|
| 210 |
+
regresses more than 0.5 from baseline. The test **skips** (rather than failing
|
| 211 |
+
spuriously) when `GROQ_API_KEY` is unset or the golden dataset is still empty.
|
| 212 |
+
|
| 213 |
+
---
|
| 214 |
+
|
| 215 |
+
## Prompt versioning & how to swap versions safely
|
| 216 |
+
|
| 217 |
+
All prompt text lives in `agent/prompts.py` — nothing is hardcoded anywhere
|
| 218 |
+
else. Each prompt is a named, versioned constant (`GENERATION_PROMPT_V1`,
|
| 219 |
+
`GENERATION_PROMPT_V2`, …). A single constant selects which is live:
|
| 220 |
+
|
| 221 |
+
```python
|
| 222 |
+
ACTIVE_PROMPT_VERSION = "GENERATION_PROMPT_V1"
|
| 223 |
+
```
|
| 224 |
+
|
| 225 |
+
Every run logs the version it used (stored on the run and on each output).
|
| 226 |
+
|
| 227 |
+
**To promote a new prompt safely:**
|
| 228 |
+
|
| 229 |
+
1. Add a new constant (e.g. `GENERATION_PROMPT_V2`) and register it in
|
| 230 |
+
`PROMPT_REGISTRY`.
|
| 231 |
+
2. Run the regression check against it **before** making it active:
|
| 232 |
+
```bash
|
| 233 |
+
# Either run the suite, or the CLI regression command after flipping the
|
| 234 |
+
# constant in a branch:
|
| 235 |
+
pytest tests/test_regression.py
|
| 236 |
+
python scripts/run_eval.py regression
|
| 237 |
+
```
|
| 238 |
+
3. Only if no golden entry regresses more than 0.5, change
|
| 239 |
+
`ACTIVE_PROMPT_VERSION` to the new version.
|
| 240 |
+
|
| 241 |
+
This is what "changing the active version triggers a regression check before it
|
| 242 |
+
is used in a real run" means in practice: the golden dataset is the gate.
|
| 243 |
+
`GENERATION_PROMPT_V2` ships in this repo as a worked example you can promote.
|
| 244 |
+
|
| 245 |
+
---
|
| 246 |
+
|
| 247 |
+
## Design Decisions
|
| 248 |
+
|
| 249 |
+
**Why ChromaDB for memory.** The agent needs *semantic* retrieval — "find past
|
| 250 |
+
briefs like this one" — not exact lookups. ChromaDB gives a persistent local
|
| 251 |
+
vector store with cosine similarity and zero external services, and it pairs
|
| 252 |
+
cleanly with local sentence-transformers embeddings. SQLite alone can't do
|
| 253 |
+
nearest-neighbour search over brief semantics; a hosted vector DB would violate
|
| 254 |
+
the "local only" constraint and add ops overhead for no benefit at this scale.
|
| 255 |
+
|
| 256 |
+
**Why dimension scoring over a composite score.** A single 1–10 "quality" score
|
| 257 |
+
is unactionable and easy for a judge to anchor on. Scoring four independent
|
| 258 |
+
dimensions tells you *why* copy is weak (great hook, poor clarity) and makes the
|
| 259 |
+
signal far more stable and debuggable. We do compute a weighted average, but
|
| 260 |
+
only for internal thresholds/ranking — the four raw dimensions are always
|
| 261 |
+
stored, so we never lose information by collapsing too early.
|
| 262 |
+
|
| 263 |
+
**Why SQLite for eval storage.** Eval results are structured, relational, and
|
| 264 |
+
queryable (runs → outputs → scores; golden; flagged). SQLite gives ACID
|
| 265 |
+
guarantees, trivial setup, a single-file database, and real SQL — ideal for run
|
| 266 |
+
history and a golden dataset. It needs no server and ships with Python. A hosted
|
| 267 |
+
DB would add infrastructure with no upside for a local, single-node harness.
|
| 268 |
+
|
| 269 |
+
**Why the 4.0 threshold for golden inclusion.** On a 1–5 scale, 4.0 means
|
| 270 |
+
"clearly good on the weighted blend" without demanding perfection. Set it lower
|
| 271 |
+
and the golden set fills with mediocre copy, weakening both the regression
|
| 272 |
+
baseline and the few-shot exemplars. Set it higher (e.g. 4.5) and you rarely
|
| 273 |
+
capture anything, so the system never accumulates a baseline or improves. 4.0 is
|
| 274 |
+
the point where entries are good enough to *defend* against regressions and to
|
| 275 |
+
*teach* future runs. (The retrieval floor is a more permissive 3.5 so memory can
|
| 276 |
+
draw on a slightly wider pool of solid examples, while only the strongest ≥4.0
|
| 277 |
+
outputs become protected golden baselines.)
|
| 278 |
+
|
| 279 |
+
---
|
| 280 |
+
|
| 281 |
+
## Project layout
|
| 282 |
+
|
| 283 |
+
```
|
| 284 |
+
self-improving-agent/
|
| 285 |
+
├── README.md
|
| 286 |
+
├── requirements.txt
|
| 287 |
+
├── .env.example
|
| 288 |
+
├── agent/ core loop, tools, memory, versioned prompts
|
| 289 |
+
├── evals/ rubric, LLM judge, golden dataset, regression runner
|
| 290 |
+
├── feedback/ post-run promote/flag loop
|
| 291 |
+
├── db/ SQLite store
|
| 292 |
+
├── api/ FastAPI app (POST /run)
|
| 293 |
+
├── tests/ pytest regression suite
|
| 294 |
+
└── scripts/ run_eval.py CLI (run / regression / status)
|
| 295 |
+
```
|
| 296 |
+
|
| 297 |
+
## Constraints honoured
|
| 298 |
+
- No LangChain — the agent loop is built directly.
|
| 299 |
+
- No managed eval platform — the judge and runner are custom.
|
| 300 |
+
- No external database — SQLite + ChromaDB, local only.
|
| 301 |
+
- No prompts hardcoded outside `agent/prompts.py`.
|
| 302 |
+
- Synchronous throughout, except where FastAPI's interface applies.
|
agent/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Agent package init.
|
| 2 |
+
|
| 3 |
+
Inject the operating system's certificate trust store into Python's SSL stack
|
| 4 |
+
before any network call (Groq API, Hugging Face model download). In corporate
|
| 5 |
+
environments that intercept TLS, the proxy's root CA lives in the OS store but
|
| 6 |
+
not in Python's bundled certifi bundle, which otherwise causes
|
| 7 |
+
CERTIFICATE_VERIFY_FAILED. This keeps verification ON (no insecure downgrade).
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
try: # truststore is optional; ignore if unavailable.
|
| 11 |
+
import truststore
|
| 12 |
+
|
| 13 |
+
truststore.inject_into_ssl()
|
| 14 |
+
except Exception: # noqa: BLE001 — best-effort; fall back to default certs.
|
| 15 |
+
pass
|
agent/core.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The main agent loop: retrieve -> generate -> evaluate -> store -> feedback.
|
| 2 |
+
|
| 3 |
+
This ties memory, generation, the eval judge, persistence, and the feedback
|
| 4 |
+
loop together. It is intentionally synchronous and dependency-injected so it
|
| 5 |
+
can be driven from the API, the CLI, or tests with the same code path.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from typing import Any, Optional
|
| 9 |
+
|
| 10 |
+
from agent import prompts, tools
|
| 11 |
+
from agent.memory import Memory
|
| 12 |
+
from db.store import Store
|
| 13 |
+
|
| 14 |
+
# The three variants the agent always produces, in order.
|
| 15 |
+
VARIANT_TYPES = ("headline", "body", "cta")
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class Agent:
|
| 19 |
+
def __init__(
|
| 20 |
+
self,
|
| 21 |
+
store: Optional[Store] = None,
|
| 22 |
+
memory: Optional[Memory] = None,
|
| 23 |
+
temperature: float = 0.7,
|
| 24 |
+
):
|
| 25 |
+
self.store = store or Store()
|
| 26 |
+
self.memory = memory or Memory()
|
| 27 |
+
self.temperature = temperature
|
| 28 |
+
|
| 29 |
+
# --------------------------------------------------------------- generate
|
| 30 |
+
def generate_variants(self, brief: dict[str, Any], few_shot_block: str, prompt_version: str) -> dict[str, str]:
|
| 31 |
+
"""Call the LLM once and parse out the three ad-copy variants."""
|
| 32 |
+
prompt = prompts.render_generation_prompt(brief, few_shot_block, prompt_version)
|
| 33 |
+
raw = tools.chat(prompt, temperature=self.temperature)
|
| 34 |
+
parsed = tools.extract_json(raw)
|
| 35 |
+
return {
|
| 36 |
+
"headline": str(parsed.get("headline", "")).strip(),
|
| 37 |
+
"body": str(parsed.get("body", "")).strip(),
|
| 38 |
+
"cta": str(parsed.get("cta", "")).strip(),
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
# -------------------------------------------------------------------- run
|
| 42 |
+
def run(self, brief: dict[str, Any]) -> dict[str, Any]:
|
| 43 |
+
"""Execute the full loop for one brand brief and return outputs + scores."""
|
| 44 |
+
# Imported here to keep module import order clean (evals/feedback import
|
| 45 |
+
# nothing from core, but core depends on them at call time).
|
| 46 |
+
from evals.judge import judge_output
|
| 47 |
+
from feedback.loop import run_feedback
|
| 48 |
+
|
| 49 |
+
prompt_version = prompts.ACTIVE_PROMPT_VERSION
|
| 50 |
+
|
| 51 |
+
# 1. Retrieve top-3 most relevant high-scoring past outputs.
|
| 52 |
+
retrieved = self.memory.retrieve(brief, k=3)
|
| 53 |
+
|
| 54 |
+
# 2. Inject them as few-shot examples.
|
| 55 |
+
few_shot_block = prompts.build_few_shot_block(retrieved)
|
| 56 |
+
|
| 57 |
+
# 3. Generate the three variants.
|
| 58 |
+
variants = self.generate_variants(brief, few_shot_block, prompt_version)
|
| 59 |
+
|
| 60 |
+
# 4. Evaluate every variant immediately.
|
| 61 |
+
# 5. Persist the run and each scored output.
|
| 62 |
+
run_id = self.store.create_run(brief, prompt_version)
|
| 63 |
+
|
| 64 |
+
scored_outputs: list[dict[str, Any]] = []
|
| 65 |
+
for variant_type in VARIANT_TYPES:
|
| 66 |
+
content = variants[variant_type]
|
| 67 |
+
scores = judge_output(brief, variant_type, content)
|
| 68 |
+
self.store.add_output(run_id, variant_type, content, scores, prompt_version)
|
| 69 |
+
scored_outputs.append(
|
| 70 |
+
{
|
| 71 |
+
"variant_type": variant_type,
|
| 72 |
+
"content": content,
|
| 73 |
+
"scores": scores,
|
| 74 |
+
}
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
# 6 + 7. Feedback loop: promote good outputs to golden + memory,
|
| 78 |
+
# flag poor ones for review.
|
| 79 |
+
feedback_summary = run_feedback(
|
| 80 |
+
store=self.store,
|
| 81 |
+
memory=self.memory,
|
| 82 |
+
brief=brief,
|
| 83 |
+
run_id=run_id,
|
| 84 |
+
scored_outputs=scored_outputs,
|
| 85 |
+
prompt_version=prompt_version,
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
return {
|
| 89 |
+
"run_id": run_id,
|
| 90 |
+
"brief": brief,
|
| 91 |
+
"prompt_version": prompt_version,
|
| 92 |
+
"retrieved_examples": len(retrieved),
|
| 93 |
+
"outputs": scored_outputs,
|
| 94 |
+
"feedback": feedback_summary,
|
| 95 |
+
}
|
agent/memory.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Retrieval and storage logic backed by ChromaDB + sentence-transformers.
|
| 2 |
+
|
| 3 |
+
Memory holds past generated outputs that scored well, embedded by their brand
|
| 4 |
+
brief. On each new run we retrieve the top-k most similar high-scoring entries
|
| 5 |
+
and feed them back as few-shot examples — this is the substrate of the
|
| 6 |
+
self-improving loop.
|
| 7 |
+
|
| 8 |
+
Only entries scoring above RETRIEVAL_SCORE_FLOOR (3.5/5) are ever stored or
|
| 9 |
+
returned, so the agent only learns from outputs that actually worked.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import os
|
| 13 |
+
|
| 14 |
+
# sentence-transformers pulls in `transformers`, which will try to import a
|
| 15 |
+
# TensorFlow/Keras backend if one is present. We only use the PyTorch path, so
|
| 16 |
+
# disable the TF backend before anything imports transformers. (Avoids the
|
| 17 |
+
# "Keras 3 is not supported" import error in envs that have TF/Keras 3.)
|
| 18 |
+
os.environ.setdefault("USE_TF", "0")
|
| 19 |
+
os.environ.setdefault("USE_TORCH", "1")
|
| 20 |
+
os.environ.setdefault("TRANSFORMERS_NO_ADVISORY_WARNINGS", "1")
|
| 21 |
+
|
| 22 |
+
import json
|
| 23 |
+
import uuid
|
| 24 |
+
from typing import Any
|
| 25 |
+
|
| 26 |
+
import chromadb
|
| 27 |
+
from chromadb.utils import embedding_functions
|
| 28 |
+
|
| 29 |
+
CHROMA_DIR = os.getenv("CHROMA_DIR", "./chroma_db")
|
| 30 |
+
COLLECTION_NAME = "ad_copy_memory"
|
| 31 |
+
EMBED_MODEL = "all-MiniLM-L6-v2"
|
| 32 |
+
|
| 33 |
+
# Minimum weighted score for an entry to live in / be retrieved from memory.
|
| 34 |
+
RETRIEVAL_SCORE_FLOOR = 3.5
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _brief_to_text(brief: dict[str, Any]) -> str:
|
| 38 |
+
"""Flatten a brand brief into a single string used for embedding."""
|
| 39 |
+
parts = [
|
| 40 |
+
brief.get("brand", ""),
|
| 41 |
+
brief.get("product", ""),
|
| 42 |
+
brief.get("audience", ""),
|
| 43 |
+
brief.get("tone", ""),
|
| 44 |
+
brief.get("goal", ""),
|
| 45 |
+
]
|
| 46 |
+
return " | ".join(str(p) for p in parts if p)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class Memory:
|
| 50 |
+
"""Vector memory of high-scoring ad copy, keyed by brief similarity."""
|
| 51 |
+
|
| 52 |
+
def __init__(self, persist_dir: str = CHROMA_DIR):
|
| 53 |
+
self._client = chromadb.PersistentClient(path=persist_dir)
|
| 54 |
+
# sentence-transformers embedding function, computed locally.
|
| 55 |
+
self._embed_fn = embedding_functions.SentenceTransformerEmbeddingFunction(
|
| 56 |
+
model_name=EMBED_MODEL
|
| 57 |
+
)
|
| 58 |
+
self._collection = self._client.get_or_create_collection(
|
| 59 |
+
name=COLLECTION_NAME,
|
| 60 |
+
embedding_function=self._embed_fn,
|
| 61 |
+
metadata={"hnsw:space": "cosine"},
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
def add(
|
| 65 |
+
self,
|
| 66 |
+
brief: dict[str, Any],
|
| 67 |
+
variant_type: str,
|
| 68 |
+
output: str,
|
| 69 |
+
score: float,
|
| 70 |
+
prompt_version: str,
|
| 71 |
+
timestamp: str,
|
| 72 |
+
) -> bool:
|
| 73 |
+
"""Store one high-scoring output. Returns False if below the floor."""
|
| 74 |
+
if score < RETRIEVAL_SCORE_FLOOR:
|
| 75 |
+
return False
|
| 76 |
+
|
| 77 |
+
self._collection.add(
|
| 78 |
+
ids=[str(uuid.uuid4())],
|
| 79 |
+
documents=[output],
|
| 80 |
+
metadatas=[
|
| 81 |
+
{
|
| 82 |
+
"brief": json.dumps(brief),
|
| 83 |
+
"variant_type": variant_type,
|
| 84 |
+
"score": float(score),
|
| 85 |
+
"prompt_version": prompt_version,
|
| 86 |
+
"timestamp": timestamp,
|
| 87 |
+
}
|
| 88 |
+
],
|
| 89 |
+
)
|
| 90 |
+
return True
|
| 91 |
+
|
| 92 |
+
def retrieve(self, brief: dict[str, Any], k: int = 3) -> list[dict[str, Any]]:
|
| 93 |
+
"""Return up to k most similar past entries with score >= the floor.
|
| 94 |
+
|
| 95 |
+
Results are ordered by vector similarity to the incoming brief.
|
| 96 |
+
"""
|
| 97 |
+
count = self._collection.count()
|
| 98 |
+
if count == 0:
|
| 99 |
+
return []
|
| 100 |
+
|
| 101 |
+
results = self._collection.query(
|
| 102 |
+
query_texts=[_brief_to_text(brief)],
|
| 103 |
+
# Over-fetch so the score filter still leaves us close to k.
|
| 104 |
+
n_results=min(max(k * 3, k), count),
|
| 105 |
+
where={"score": {"$gte": RETRIEVAL_SCORE_FLOOR}},
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
docs = results.get("documents", [[]])[0]
|
| 109 |
+
metas = results.get("metadatas", [[]])[0]
|
| 110 |
+
|
| 111 |
+
examples: list[dict[str, Any]] = []
|
| 112 |
+
for doc, meta in zip(docs, metas):
|
| 113 |
+
examples.append(
|
| 114 |
+
{
|
| 115 |
+
"brief": json.loads(meta.get("brief", "{}")),
|
| 116 |
+
"variant_type": meta.get("variant_type", "output"),
|
| 117 |
+
"output": doc,
|
| 118 |
+
"score": meta.get("score", 0.0),
|
| 119 |
+
"prompt_version": meta.get("prompt_version", ""),
|
| 120 |
+
}
|
| 121 |
+
)
|
| 122 |
+
if len(examples) >= k:
|
| 123 |
+
break
|
| 124 |
+
return examples
|
| 125 |
+
|
| 126 |
+
def count(self) -> int:
|
| 127 |
+
return self._collection.count()
|
agent/prompts.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""All prompts live here and nowhere else.
|
| 2 |
+
|
| 3 |
+
Each prompt is versioned with an explicit string key. The single source of
|
| 4 |
+
truth for which generation prompt is live is ACTIVE_PROMPT_VERSION. Swapping
|
| 5 |
+
that constant is the supported way to roll a new prompt forward — and it is
|
| 6 |
+
what the regression check guards (see evals/runner.py).
|
| 7 |
+
|
| 8 |
+
Nothing in the codebase should hardcode prompt text outside this module.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
# ---------------------------------------------------------------------------
|
| 12 |
+
# Active version selector
|
| 13 |
+
# ---------------------------------------------------------------------------
|
| 14 |
+
# Change this to promote a new generation prompt. The regression runner is
|
| 15 |
+
# expected to pass against the golden dataset before this is used for real.
|
| 16 |
+
ACTIVE_PROMPT_VERSION = "GENERATION_PROMPT_V1"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# ---------------------------------------------------------------------------
|
| 20 |
+
# Generation prompts
|
| 21 |
+
# ---------------------------------------------------------------------------
|
| 22 |
+
GENERATION_PROMPT_V1 = """You are a senior direct-to-consumer (DTC) ad copywriter.
|
| 23 |
+
|
| 24 |
+
You will be given a brand brief. Write three distinct pieces of ad copy:
|
| 25 |
+
1. A HEADLINE hook — one short line that immediately grabs attention.
|
| 26 |
+
2. A BODY copy — 2-3 sentences that build desire and reflect the brand tone.
|
| 27 |
+
3. A CTA — a single short call to action that drives the stated goal.
|
| 28 |
+
|
| 29 |
+
Rules:
|
| 30 |
+
- Match the brief's tone and speak directly to the described audience.
|
| 31 |
+
- Be specific and concrete. Avoid generic filler and clichés.
|
| 32 |
+
- Keep the headline punchy and the CTA action-oriented.
|
| 33 |
+
- Return ONLY valid JSON, no preamble, no markdown fences.
|
| 34 |
+
|
| 35 |
+
Return exactly this JSON shape:
|
| 36 |
+
{{
|
| 37 |
+
"headline": "<headline hook>",
|
| 38 |
+
"body": "<body copy>",
|
| 39 |
+
"cta": "<call to action>"
|
| 40 |
+
}}
|
| 41 |
+
|
| 42 |
+
{few_shot_block}
|
| 43 |
+
|
| 44 |
+
Brand brief:
|
| 45 |
+
{brief}
|
| 46 |
+
"""
|
| 47 |
+
|
| 48 |
+
# A second version kept as a worked example of how to add and promote prompts.
|
| 49 |
+
# It nudges the model toward tighter, benefit-led copy. To use it, set
|
| 50 |
+
# ACTIVE_PROMPT_VERSION = "GENERATION_PROMPT_V2" and run the regression suite.
|
| 51 |
+
GENERATION_PROMPT_V2 = """You are an award-winning DTC performance copywriter who writes
|
| 52 |
+
copy that converts cold traffic.
|
| 53 |
+
|
| 54 |
+
You will be given a brand brief. Produce three pieces of ad copy:
|
| 55 |
+
1. HEADLINE — a scroll-stopping hook of at most 8 words.
|
| 56 |
+
2. BODY — 2-3 sentences leading with the strongest benefit, in the brand's voice.
|
| 57 |
+
3. CTA — an imperative call to action tied directly to the stated goal.
|
| 58 |
+
|
| 59 |
+
Rules:
|
| 60 |
+
- Lead with benefit, not feature. Speak to the exact audience in the brief.
|
| 61 |
+
- No clichés, no hype words ("revolutionary", "game-changer"), no emojis.
|
| 62 |
+
- Return ONLY valid JSON, no preamble, no markdown fences.
|
| 63 |
+
|
| 64 |
+
Return exactly this JSON shape:
|
| 65 |
+
{{
|
| 66 |
+
"headline": "<headline hook>",
|
| 67 |
+
"body": "<body copy>",
|
| 68 |
+
"cta": "<call to action>"
|
| 69 |
+
}}
|
| 70 |
+
|
| 71 |
+
{few_shot_block}
|
| 72 |
+
|
| 73 |
+
Brand brief:
|
| 74 |
+
{brief}
|
| 75 |
+
"""
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# Registry so other modules can resolve a version string to its template.
|
| 79 |
+
PROMPT_REGISTRY = {
|
| 80 |
+
"GENERATION_PROMPT_V1": GENERATION_PROMPT_V1,
|
| 81 |
+
"GENERATION_PROMPT_V2": GENERATION_PROMPT_V2,
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# ---------------------------------------------------------------------------
|
| 86 |
+
# Few-shot helpers
|
| 87 |
+
# ---------------------------------------------------------------------------
|
| 88 |
+
FEW_SHOT_HEADER = "Past high-performing examples (learn from their style, do not copy verbatim):"
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def build_few_shot_block(examples: list[dict]) -> str:
|
| 92 |
+
"""Render retrieved high-scoring examples into a prompt section.
|
| 93 |
+
|
| 94 |
+
Each example is a dict with keys: brief (dict), variant_type, output, score.
|
| 95 |
+
Returns an empty string when there are no examples so the prompt stays clean.
|
| 96 |
+
"""
|
| 97 |
+
if not examples:
|
| 98 |
+
return ""
|
| 99 |
+
|
| 100 |
+
lines = [FEW_SHOT_HEADER, ""]
|
| 101 |
+
for i, ex in enumerate(examples, 1):
|
| 102 |
+
brief = ex.get("brief", {})
|
| 103 |
+
product = brief.get("product", "") if isinstance(brief, dict) else ""
|
| 104 |
+
lines.append(
|
| 105 |
+
f"Example {i} ({ex.get('variant_type', 'output')}, "
|
| 106 |
+
f"score {ex.get('score', 0):.2f}/5) for product '{product}':"
|
| 107 |
+
)
|
| 108 |
+
lines.append(f' "{ex.get("output", "")}"')
|
| 109 |
+
lines.append("")
|
| 110 |
+
return "\n".join(lines).strip()
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def get_generation_prompt(version: str) -> str:
|
| 114 |
+
"""Return the raw template for a version string, defaulting to the active one."""
|
| 115 |
+
return PROMPT_REGISTRY.get(version, PROMPT_REGISTRY[ACTIVE_PROMPT_VERSION])
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def render_generation_prompt(brief: dict, few_shot_block: str, version: str | None = None) -> str:
|
| 119 |
+
"""Fill a generation prompt template with the brief and few-shot examples."""
|
| 120 |
+
import json
|
| 121 |
+
|
| 122 |
+
version = version or ACTIVE_PROMPT_VERSION
|
| 123 |
+
template = get_generation_prompt(version)
|
| 124 |
+
return template.format(
|
| 125 |
+
brief=json.dumps(brief, indent=2),
|
| 126 |
+
few_shot_block=few_shot_block,
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
# ---------------------------------------------------------------------------
|
| 131 |
+
# Judge prompt (LLM-as-judge). Versioned alongside generation prompts.
|
| 132 |
+
# ---------------------------------------------------------------------------
|
| 133 |
+
JUDGE_PROMPT_VERSION = "JUDGE_PROMPT_V1"
|
| 134 |
+
|
| 135 |
+
JUDGE_PROMPT_V1 = """You are a strict, fair advertising copy evaluator.
|
| 136 |
+
|
| 137 |
+
Score the candidate ad copy on FOUR dimensions, each an integer from 1 to 5:
|
| 138 |
+
- hook_strength: does it immediately grab attention?
|
| 139 |
+
- brand_alignment: does it reflect the brief's tone and speak to the audience?
|
| 140 |
+
- clarity: is the message immediately understandable?
|
| 141 |
+
- conversion_intent: does it drive toward the stated goal?
|
| 142 |
+
|
| 143 |
+
Be discriminating. Reserve 5 for genuinely excellent copy and 1 for copy that
|
| 144 |
+
fails the dimension entirely. Do NOT return a single composite score.
|
| 145 |
+
|
| 146 |
+
Return ONLY valid JSON, no preamble, no markdown fences, exactly:
|
| 147 |
+
{{
|
| 148 |
+
"hook_strength": <1-5>,
|
| 149 |
+
"brand_alignment": <1-5>,
|
| 150 |
+
"clarity": <1-5>,
|
| 151 |
+
"conversion_intent": <1-5>,
|
| 152 |
+
"rationale": "<one short sentence>"
|
| 153 |
+
}}
|
| 154 |
+
|
| 155 |
+
Brand brief:
|
| 156 |
+
{brief}
|
| 157 |
+
|
| 158 |
+
Candidate copy ({variant_type}):
|
| 159 |
+
"{output}"
|
| 160 |
+
"""
|
| 161 |
+
|
| 162 |
+
JUDGE_REGISTRY = {
|
| 163 |
+
"JUDGE_PROMPT_V1": JUDGE_PROMPT_V1,
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def render_judge_prompt(brief: dict, variant_type: str, output: str, version: str | None = None) -> str:
|
| 168 |
+
import json
|
| 169 |
+
|
| 170 |
+
version = version or JUDGE_PROMPT_VERSION
|
| 171 |
+
template = JUDGE_REGISTRY.get(version, JUDGE_PROMPT_V1)
|
| 172 |
+
return template.format(
|
| 173 |
+
brief=json.dumps(brief, indent=2),
|
| 174 |
+
variant_type=variant_type,
|
| 175 |
+
output=output,
|
| 176 |
+
)
|
agent/tools.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Agent tools: the LLM client wrapper and the structured-generation helpers.
|
| 2 |
+
|
| 3 |
+
We deliberately call Groq directly (no LangChain). A single chat-completion
|
| 4 |
+
helper is shared by both the generator and the judge so model/config lives in
|
| 5 |
+
one place.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
import os
|
| 10 |
+
import re
|
| 11 |
+
from typing import Any, Optional
|
| 12 |
+
|
| 13 |
+
from groq import Groq
|
| 14 |
+
|
| 15 |
+
DEFAULT_MODEL = os.getenv("GROQ_MODEL", "llama-3.3-70b-versatile")
|
| 16 |
+
|
| 17 |
+
_client: Optional[Groq] = None
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def get_client() -> Groq:
|
| 21 |
+
"""Lazily construct a shared Groq client. Requires GROQ_API_KEY."""
|
| 22 |
+
global _client
|
| 23 |
+
if _client is None:
|
| 24 |
+
api_key = os.getenv("GROQ_API_KEY")
|
| 25 |
+
if not api_key:
|
| 26 |
+
raise RuntimeError(
|
| 27 |
+
"GROQ_API_KEY is not set. Copy .env.example to .env and add your key."
|
| 28 |
+
)
|
| 29 |
+
_client = Groq(api_key=api_key)
|
| 30 |
+
return _client
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def chat(
|
| 34 |
+
prompt: str,
|
| 35 |
+
temperature: float = 0.7,
|
| 36 |
+
model: str | None = None,
|
| 37 |
+
max_tokens: int = 1024,
|
| 38 |
+
) -> str:
|
| 39 |
+
"""Single-shot chat completion returning the raw assistant text."""
|
| 40 |
+
client = get_client()
|
| 41 |
+
resp = client.chat.completions.create(
|
| 42 |
+
model=model or DEFAULT_MODEL,
|
| 43 |
+
messages=[{"role": "user", "content": prompt}],
|
| 44 |
+
temperature=temperature,
|
| 45 |
+
max_tokens=max_tokens,
|
| 46 |
+
)
|
| 47 |
+
return resp.choices[0].message.content or ""
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def extract_json(text: str) -> dict[str, Any]:
|
| 51 |
+
"""Best-effort parse of a JSON object out of a model response.
|
| 52 |
+
|
| 53 |
+
Models occasionally wrap JSON in prose or markdown fences despite
|
| 54 |
+
instructions; we strip fences and fall back to the first {...} block.
|
| 55 |
+
"""
|
| 56 |
+
text = text.strip()
|
| 57 |
+
|
| 58 |
+
# Strip ```json ... ``` or ``` ... ``` fences if present.
|
| 59 |
+
fence = re.match(r"^```(?:json)?\s*(.*?)\s*```$", text, re.DOTALL)
|
| 60 |
+
if fence:
|
| 61 |
+
text = fence.group(1).strip()
|
| 62 |
+
|
| 63 |
+
try:
|
| 64 |
+
return json.loads(text)
|
| 65 |
+
except json.JSONDecodeError:
|
| 66 |
+
pass
|
| 67 |
+
|
| 68 |
+
# Fall back to the first balanced-looking object.
|
| 69 |
+
match = re.search(r"\{.*\}", text, re.DOTALL)
|
| 70 |
+
if match:
|
| 71 |
+
try:
|
| 72 |
+
return json.loads(match.group(0))
|
| 73 |
+
except json.JSONDecodeError as exc:
|
| 74 |
+
raise ValueError(f"Could not parse JSON from model output: {exc}\nRaw: {text}")
|
| 75 |
+
raise ValueError(f"No JSON object found in model output.\nRaw: {text}")
|
api/__init__.py
ADDED
|
File without changes
|
api/main.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI app exposing a single endpoint to trigger the full agent loop.
|
| 2 |
+
|
| 3 |
+
POST /run with a brand brief -> retrieve -> generate -> evaluate -> feedback,
|
| 4 |
+
returning the generated outputs and their scores.
|
| 5 |
+
|
| 6 |
+
Run with: uvicorn api.main:app --reload
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
from dotenv import load_dotenv
|
| 12 |
+
from fastapi import FastAPI, HTTPException
|
| 13 |
+
from pydantic import BaseModel, Field
|
| 14 |
+
|
| 15 |
+
load_dotenv() # pick up GROQ_API_KEY from .env if present
|
| 16 |
+
|
| 17 |
+
from agent.core import Agent # noqa: E402 (after load_dotenv on purpose)
|
| 18 |
+
|
| 19 |
+
app = FastAPI(title="Self-Improving Ad Copy Agent", version="1.0.0")
|
| 20 |
+
|
| 21 |
+
# A single shared agent (and therefore shared Store/Memory) for the process.
|
| 22 |
+
_agent: Agent | None = None
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def get_agent() -> Agent:
|
| 26 |
+
global _agent
|
| 27 |
+
if _agent is None:
|
| 28 |
+
_agent = Agent()
|
| 29 |
+
return _agent
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class BrandBrief(BaseModel):
|
| 33 |
+
brand: str = Field(..., examples=["FitFuel"])
|
| 34 |
+
product: str = Field(..., examples=["High-protein meal replacement shake"])
|
| 35 |
+
audience: str = Field(..., examples=["Busy professionals aged 25-40"])
|
| 36 |
+
tone: str = Field(..., examples=["Energetic and no-nonsense"])
|
| 37 |
+
goal: str = Field(..., examples=["Drive trial purchases"])
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@app.get("/")
|
| 41 |
+
def root() -> dict[str, str]:
|
| 42 |
+
return {"status": "ok", "endpoint": "POST /run with a brand brief"}
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@app.post("/run")
|
| 46 |
+
def run(brief: BrandBrief) -> dict[str, Any]:
|
| 47 |
+
"""Trigger the full agent loop for a brand brief."""
|
| 48 |
+
try:
|
| 49 |
+
agent = get_agent()
|
| 50 |
+
return agent.run(brief.model_dump())
|
| 51 |
+
except RuntimeError as exc: # e.g. missing GROQ_API_KEY
|
| 52 |
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
| 53 |
+
except Exception as exc: # noqa: BLE001 — surface generation/judge failures
|
| 54 |
+
raise HTTPException(status_code=502, detail=f"Agent run failed: {exc}") from exc
|
db/__init__.py
ADDED
|
File without changes
|
db/store.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SQLite interface for runs, scores, golden dataset, flagged outputs, and versions.
|
| 2 |
+
|
| 3 |
+
This is the persistence backbone of the system. Everything else depends on it.
|
| 4 |
+
All access goes through the Store class so the schema lives in exactly one place.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import json
|
| 8 |
+
import os
|
| 9 |
+
import sqlite3
|
| 10 |
+
import threading
|
| 11 |
+
from datetime import datetime, timezone
|
| 12 |
+
from typing import Any, Optional
|
| 13 |
+
|
| 14 |
+
DEFAULT_SQLITE_PATH = os.getenv("SQLITE_PATH", "./agent.db")
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _utcnow() -> str:
|
| 18 |
+
return datetime.now(timezone.utc).isoformat()
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class Store:
|
| 22 |
+
"""Thin, explicit wrapper over a local SQLite database.
|
| 23 |
+
|
| 24 |
+
A single connection is shared with a lock so the same Store can be used
|
| 25 |
+
from FastAPI request handlers and CLI scripts without surprises.
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
def __init__(self, path: str = DEFAULT_SQLITE_PATH):
|
| 29 |
+
self.path = path
|
| 30 |
+
self._lock = threading.Lock()
|
| 31 |
+
self._conn = sqlite3.connect(path, check_same_thread=False)
|
| 32 |
+
self._conn.row_factory = sqlite3.Row
|
| 33 |
+
self._conn.execute("PRAGMA journal_mode=WAL;")
|
| 34 |
+
self._init_schema()
|
| 35 |
+
|
| 36 |
+
# ------------------------------------------------------------------ schema
|
| 37 |
+
def _init_schema(self) -> None:
|
| 38 |
+
with self._lock, self._conn:
|
| 39 |
+
self._conn.executescript(
|
| 40 |
+
"""
|
| 41 |
+
CREATE TABLE IF NOT EXISTS runs (
|
| 42 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 43 |
+
brief TEXT NOT NULL, -- JSON brand brief
|
| 44 |
+
prompt_version TEXT NOT NULL,
|
| 45 |
+
timestamp TEXT NOT NULL
|
| 46 |
+
);
|
| 47 |
+
|
| 48 |
+
CREATE TABLE IF NOT EXISTS outputs (
|
| 49 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 50 |
+
run_id INTEGER NOT NULL,
|
| 51 |
+
variant_type TEXT NOT NULL, -- headline | body | cta
|
| 52 |
+
content TEXT NOT NULL,
|
| 53 |
+
hook_strength REAL,
|
| 54 |
+
brand_alignment REAL,
|
| 55 |
+
clarity REAL,
|
| 56 |
+
conversion_intent REAL,
|
| 57 |
+
weighted_average REAL,
|
| 58 |
+
prompt_version TEXT NOT NULL,
|
| 59 |
+
timestamp TEXT NOT NULL,
|
| 60 |
+
FOREIGN KEY (run_id) REFERENCES runs(id)
|
| 61 |
+
);
|
| 62 |
+
|
| 63 |
+
CREATE TABLE IF NOT EXISTS golden (
|
| 64 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 65 |
+
brief TEXT NOT NULL,
|
| 66 |
+
variant_type TEXT NOT NULL,
|
| 67 |
+
output TEXT NOT NULL,
|
| 68 |
+
hook_strength REAL,
|
| 69 |
+
brand_alignment REAL,
|
| 70 |
+
clarity REAL,
|
| 71 |
+
conversion_intent REAL,
|
| 72 |
+
weighted_average REAL NOT NULL,
|
| 73 |
+
prompt_version TEXT NOT NULL,
|
| 74 |
+
timestamp TEXT NOT NULL
|
| 75 |
+
);
|
| 76 |
+
|
| 77 |
+
CREATE TABLE IF NOT EXISTS flagged_outputs (
|
| 78 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 79 |
+
run_id INTEGER,
|
| 80 |
+
brief TEXT NOT NULL,
|
| 81 |
+
variant_type TEXT NOT NULL,
|
| 82 |
+
output TEXT NOT NULL,
|
| 83 |
+
weighted_average REAL,
|
| 84 |
+
reason TEXT NOT NULL,
|
| 85 |
+
timestamp TEXT NOT NULL
|
| 86 |
+
);
|
| 87 |
+
"""
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
# -------------------------------------------------------------------- runs
|
| 91 |
+
def create_run(self, brief: dict[str, Any], prompt_version: str) -> int:
|
| 92 |
+
with self._lock, self._conn:
|
| 93 |
+
cur = self._conn.execute(
|
| 94 |
+
"INSERT INTO runs (brief, prompt_version, timestamp) VALUES (?, ?, ?)",
|
| 95 |
+
(json.dumps(brief), prompt_version, _utcnow()),
|
| 96 |
+
)
|
| 97 |
+
return int(cur.lastrowid)
|
| 98 |
+
|
| 99 |
+
def get_run(self, run_id: int) -> Optional[dict[str, Any]]:
|
| 100 |
+
with self._lock:
|
| 101 |
+
row = self._conn.execute(
|
| 102 |
+
"SELECT * FROM runs WHERE id = ?", (run_id,)
|
| 103 |
+
).fetchone()
|
| 104 |
+
return dict(row) if row else None
|
| 105 |
+
|
| 106 |
+
# ----------------------------------------------------------------- outputs
|
| 107 |
+
def add_output(
|
| 108 |
+
self,
|
| 109 |
+
run_id: int,
|
| 110 |
+
variant_type: str,
|
| 111 |
+
content: str,
|
| 112 |
+
scores: dict[str, float],
|
| 113 |
+
prompt_version: str,
|
| 114 |
+
) -> int:
|
| 115 |
+
"""Persist one generated variant together with its 4 dimension scores."""
|
| 116 |
+
with self._lock, self._conn:
|
| 117 |
+
cur = self._conn.execute(
|
| 118 |
+
"""
|
| 119 |
+
INSERT INTO outputs (
|
| 120 |
+
run_id, variant_type, content,
|
| 121 |
+
hook_strength, brand_alignment, clarity, conversion_intent,
|
| 122 |
+
weighted_average, prompt_version, timestamp
|
| 123 |
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 124 |
+
""",
|
| 125 |
+
(
|
| 126 |
+
run_id,
|
| 127 |
+
variant_type,
|
| 128 |
+
content,
|
| 129 |
+
scores.get("hook_strength"),
|
| 130 |
+
scores.get("brand_alignment"),
|
| 131 |
+
scores.get("clarity"),
|
| 132 |
+
scores.get("conversion_intent"),
|
| 133 |
+
scores.get("weighted_average"),
|
| 134 |
+
prompt_version,
|
| 135 |
+
_utcnow(),
|
| 136 |
+
),
|
| 137 |
+
)
|
| 138 |
+
return int(cur.lastrowid)
|
| 139 |
+
|
| 140 |
+
def get_outputs_for_run(self, run_id: int) -> list[dict[str, Any]]:
|
| 141 |
+
with self._lock:
|
| 142 |
+
rows = self._conn.execute(
|
| 143 |
+
"SELECT * FROM outputs WHERE run_id = ? ORDER BY id", (run_id,)
|
| 144 |
+
).fetchall()
|
| 145 |
+
return [dict(r) for r in rows]
|
| 146 |
+
|
| 147 |
+
# ------------------------------------------------------------------ golden
|
| 148 |
+
def add_golden(
|
| 149 |
+
self,
|
| 150 |
+
brief: dict[str, Any],
|
| 151 |
+
variant_type: str,
|
| 152 |
+
output: str,
|
| 153 |
+
scores: dict[str, float],
|
| 154 |
+
prompt_version: str,
|
| 155 |
+
) -> int:
|
| 156 |
+
with self._lock, self._conn:
|
| 157 |
+
cur = self._conn.execute(
|
| 158 |
+
"""
|
| 159 |
+
INSERT INTO golden (
|
| 160 |
+
brief, variant_type, output,
|
| 161 |
+
hook_strength, brand_alignment, clarity, conversion_intent,
|
| 162 |
+
weighted_average, prompt_version, timestamp
|
| 163 |
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 164 |
+
""",
|
| 165 |
+
(
|
| 166 |
+
json.dumps(brief),
|
| 167 |
+
variant_type,
|
| 168 |
+
output,
|
| 169 |
+
scores.get("hook_strength"),
|
| 170 |
+
scores.get("brand_alignment"),
|
| 171 |
+
scores.get("clarity"),
|
| 172 |
+
scores.get("conversion_intent"),
|
| 173 |
+
scores.get("weighted_average"),
|
| 174 |
+
prompt_version,
|
| 175 |
+
_utcnow(),
|
| 176 |
+
),
|
| 177 |
+
)
|
| 178 |
+
return int(cur.lastrowid)
|
| 179 |
+
|
| 180 |
+
def get_golden(self) -> list[dict[str, Any]]:
|
| 181 |
+
with self._lock:
|
| 182 |
+
rows = self._conn.execute(
|
| 183 |
+
"SELECT * FROM golden ORDER BY id"
|
| 184 |
+
).fetchall()
|
| 185 |
+
result = []
|
| 186 |
+
for r in rows:
|
| 187 |
+
d = dict(r)
|
| 188 |
+
d["brief"] = json.loads(d["brief"])
|
| 189 |
+
result.append(d)
|
| 190 |
+
return result
|
| 191 |
+
|
| 192 |
+
def golden_exists(self, brief: dict[str, Any], variant_type: str, output: str) -> bool:
|
| 193 |
+
"""Avoid inserting an identical golden entry twice."""
|
| 194 |
+
with self._lock:
|
| 195 |
+
row = self._conn.execute(
|
| 196 |
+
"SELECT 1 FROM golden WHERE brief = ? AND variant_type = ? AND output = ? LIMIT 1",
|
| 197 |
+
(json.dumps(brief), variant_type, output),
|
| 198 |
+
).fetchone()
|
| 199 |
+
return row is not None
|
| 200 |
+
|
| 201 |
+
# ----------------------------------------------------------------- flagged
|
| 202 |
+
def add_flagged(
|
| 203 |
+
self,
|
| 204 |
+
brief: dict[str, Any],
|
| 205 |
+
variant_type: str,
|
| 206 |
+
output: str,
|
| 207 |
+
weighted_average: Optional[float],
|
| 208 |
+
reason: str,
|
| 209 |
+
run_id: Optional[int] = None,
|
| 210 |
+
) -> int:
|
| 211 |
+
with self._lock, self._conn:
|
| 212 |
+
cur = self._conn.execute(
|
| 213 |
+
"""
|
| 214 |
+
INSERT INTO flagged_outputs (
|
| 215 |
+
run_id, brief, variant_type, output, weighted_average, reason, timestamp
|
| 216 |
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
| 217 |
+
""",
|
| 218 |
+
(
|
| 219 |
+
run_id,
|
| 220 |
+
json.dumps(brief),
|
| 221 |
+
variant_type,
|
| 222 |
+
output,
|
| 223 |
+
weighted_average,
|
| 224 |
+
reason,
|
| 225 |
+
_utcnow(),
|
| 226 |
+
),
|
| 227 |
+
)
|
| 228 |
+
return int(cur.lastrowid)
|
| 229 |
+
|
| 230 |
+
def get_flagged(self) -> list[dict[str, Any]]:
|
| 231 |
+
with self._lock:
|
| 232 |
+
rows = self._conn.execute(
|
| 233 |
+
"SELECT * FROM flagged_outputs ORDER BY id"
|
| 234 |
+
).fetchall()
|
| 235 |
+
result = []
|
| 236 |
+
for r in rows:
|
| 237 |
+
d = dict(r)
|
| 238 |
+
d["brief"] = json.loads(d["brief"])
|
| 239 |
+
result.append(d)
|
| 240 |
+
return result
|
| 241 |
+
|
| 242 |
+
# ------------------------------------------------------------------- close
|
| 243 |
+
def close(self) -> None:
|
| 244 |
+
with self._lock:
|
| 245 |
+
self._conn.close()
|
evals/__init__.py
ADDED
|
File without changes
|
evals/golden.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Golden dataset manager.
|
| 2 |
+
|
| 3 |
+
The golden dataset is the system's regression baseline: outputs that scored
|
| 4 |
+
well enough (weighted average >= GOLDEN_THRESHOLD) are captured with their
|
| 5 |
+
brief, scores, and prompt version. The regression runner later re-scores each
|
| 6 |
+
entry against the active prompt and checks for drift.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
from db.store import Store
|
| 12 |
+
|
| 13 |
+
# An output earns a golden slot only at or above this weighted average.
|
| 14 |
+
GOLDEN_THRESHOLD = 4.0
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class GoldenDataset:
|
| 18 |
+
def __init__(self, store: Store | None = None):
|
| 19 |
+
self.store = store or Store()
|
| 20 |
+
|
| 21 |
+
def qualifies(self, weighted_average: float) -> bool:
|
| 22 |
+
return weighted_average >= GOLDEN_THRESHOLD
|
| 23 |
+
|
| 24 |
+
def maybe_add(
|
| 25 |
+
self,
|
| 26 |
+
brief: dict[str, Any],
|
| 27 |
+
variant_type: str,
|
| 28 |
+
output: str,
|
| 29 |
+
scores: dict[str, float],
|
| 30 |
+
prompt_version: str,
|
| 31 |
+
) -> bool:
|
| 32 |
+
"""Add to the golden dataset if it qualifies and isn't already present.
|
| 33 |
+
|
| 34 |
+
Returns True if a new golden entry was created.
|
| 35 |
+
"""
|
| 36 |
+
if not self.qualifies(scores.get("weighted_average", 0.0)):
|
| 37 |
+
return False
|
| 38 |
+
if self.store.golden_exists(brief, variant_type, output):
|
| 39 |
+
return False
|
| 40 |
+
self.store.add_golden(brief, variant_type, output, scores, prompt_version)
|
| 41 |
+
return True
|
| 42 |
+
|
| 43 |
+
def all(self) -> list[dict[str, Any]]:
|
| 44 |
+
return self.store.get_golden()
|
| 45 |
+
|
| 46 |
+
def size(self) -> int:
|
| 47 |
+
return len(self.store.get_golden())
|
evals/judge.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LLM-as-judge scorer.
|
| 2 |
+
|
| 3 |
+
Uses the same Groq model as the generator (llama-3.3-70b-versatile) but with a
|
| 4 |
+
strict, low-temperature judging prompt. Returns the four dimension scores plus
|
| 5 |
+
a derived weighted average and a one-line rationale. It never collapses the
|
| 6 |
+
output into a single composite the way a naive scorer would.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
from agent import prompts, tools
|
| 12 |
+
from evals import rubric
|
| 13 |
+
|
| 14 |
+
# Judging is deterministic-ish: low temperature for stable, repeatable scores.
|
| 15 |
+
JUDGE_TEMPERATURE = 0.0
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def judge_output(brief: dict[str, Any], variant_type: str, output: str) -> dict[str, Any]:
|
| 19 |
+
"""Score a single ad-copy variant on the four rubric dimensions.
|
| 20 |
+
|
| 21 |
+
Returns a dict containing each dimension (1-5), the weighted_average, and a
|
| 22 |
+
rationale string. Robust to a malformed judge response: dimensions clamp to
|
| 23 |
+
the valid range and default low.
|
| 24 |
+
"""
|
| 25 |
+
if not output.strip():
|
| 26 |
+
# Nothing to score — treat as the floor.
|
| 27 |
+
scores = rubric.normalize_scores({})
|
| 28 |
+
scores["rationale"] = "Empty output."
|
| 29 |
+
return scores
|
| 30 |
+
|
| 31 |
+
prompt = prompts.render_judge_prompt(brief, variant_type, output)
|
| 32 |
+
raw = tools.chat(prompt, temperature=JUDGE_TEMPERATURE, max_tokens=512)
|
| 33 |
+
|
| 34 |
+
try:
|
| 35 |
+
parsed = tools.extract_json(raw)
|
| 36 |
+
except ValueError:
|
| 37 |
+
parsed = {}
|
| 38 |
+
|
| 39 |
+
scores = rubric.normalize_scores(parsed)
|
| 40 |
+
scores["rationale"] = str(parsed.get("rationale", "")).strip()
|
| 41 |
+
return scores
|
evals/rubric.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Scoring rubric definitions.
|
| 2 |
+
|
| 3 |
+
The rubric is the contract between the judge and the rest of the system:
|
| 4 |
+
the four dimensions, their 1-5 scale, and the weighting used to derive a
|
| 5 |
+
single internal number. Per the design, dimension scores are first-class and
|
| 6 |
+
stored separately; the weighted average exists only for ranking/thresholds.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
# The four scored dimensions, each on a 1-5 integer scale.
|
| 10 |
+
DIMENSIONS = ("hook_strength", "brand_alignment", "clarity", "conversion_intent")
|
| 11 |
+
|
| 12 |
+
# Weights used ONLY to compute an internal weighted average. They sum to 1.0.
|
| 13 |
+
WEIGHTS = {
|
| 14 |
+
"hook_strength": 0.30,
|
| 15 |
+
"brand_alignment": 0.25,
|
| 16 |
+
"clarity": 0.25,
|
| 17 |
+
"conversion_intent": 0.20,
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
# Human-readable descriptions, surfaced in the judge prompt and docs.
|
| 21 |
+
DIMENSION_DESCRIPTIONS = {
|
| 22 |
+
"hook_strength": "Does the headline immediately grab attention?",
|
| 23 |
+
"brand_alignment": "Does the copy reflect the brief tone and audience?",
|
| 24 |
+
"clarity": "Is the message immediately understandable?",
|
| 25 |
+
"conversion_intent": "Does it drive toward the stated goal?",
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
SCALE_MIN = 1
|
| 29 |
+
SCALE_MAX = 5
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def weighted_average(scores: dict[str, float]) -> float:
|
| 33 |
+
"""Compute the internal weighted average from dimension scores.
|
| 34 |
+
|
| 35 |
+
Missing dimensions are treated as 0 so a malformed judge response surfaces
|
| 36 |
+
as a low score rather than silently passing.
|
| 37 |
+
"""
|
| 38 |
+
total = 0.0
|
| 39 |
+
for dim, weight in WEIGHTS.items():
|
| 40 |
+
total += float(scores.get(dim, 0)) * weight
|
| 41 |
+
return round(total, 4)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def clamp(value: float) -> int:
|
| 45 |
+
"""Clamp a raw score into the valid 1-5 integer range."""
|
| 46 |
+
try:
|
| 47 |
+
v = int(round(float(value)))
|
| 48 |
+
except (TypeError, ValueError):
|
| 49 |
+
v = SCALE_MIN
|
| 50 |
+
return max(SCALE_MIN, min(SCALE_MAX, v))
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def normalize_scores(raw: dict) -> dict[str, float]:
|
| 54 |
+
"""Validate/clamp the four dimensions and attach the weighted average."""
|
| 55 |
+
scores: dict[str, float] = {}
|
| 56 |
+
for dim in DIMENSIONS:
|
| 57 |
+
scores[dim] = clamp(raw.get(dim))
|
| 58 |
+
scores["weighted_average"] = weighted_average(scores)
|
| 59 |
+
return scores
|
evals/runner.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Eval runner: runs the golden dataset against any prompt config and checks
|
| 2 |
+
for regressions.
|
| 3 |
+
|
| 4 |
+
For each golden entry we regenerate copy for the same brief + variant using the
|
| 5 |
+
prompt version under test, re-judge it, and compare the new weighted score to
|
| 6 |
+
the entry's stored baseline. If any entry drops by more than REGRESSION_TOLERANCE
|
| 7 |
+
(0.5), the run is marked failed.
|
| 8 |
+
|
| 9 |
+
This is what guards a prompt-version swap (see agent/prompts.py) and what the
|
| 10 |
+
pytest regression suite drives.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from dataclasses import dataclass, field
|
| 14 |
+
from typing import Any, Optional
|
| 15 |
+
|
| 16 |
+
from agent import prompts, tools
|
| 17 |
+
from evals.golden import GoldenDataset
|
| 18 |
+
from evals.judge import judge_output
|
| 19 |
+
|
| 20 |
+
# A golden entry may not drop more than this from its baseline before we fail.
|
| 21 |
+
REGRESSION_TOLERANCE = 0.5
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@dataclass
|
| 25 |
+
class EntryResult:
|
| 26 |
+
brief: dict[str, Any]
|
| 27 |
+
variant_type: str
|
| 28 |
+
baseline_score: float
|
| 29 |
+
new_score: float
|
| 30 |
+
new_output: str
|
| 31 |
+
regressed: bool
|
| 32 |
+
|
| 33 |
+
@property
|
| 34 |
+
def delta(self) -> float:
|
| 35 |
+
return round(self.new_score - self.baseline_score, 4)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@dataclass
|
| 39 |
+
class EvalReport:
|
| 40 |
+
prompt_version: str
|
| 41 |
+
results: list[EntryResult] = field(default_factory=list)
|
| 42 |
+
|
| 43 |
+
@property
|
| 44 |
+
def passed(self) -> bool:
|
| 45 |
+
return not any(r.regressed for r in self.results)
|
| 46 |
+
|
| 47 |
+
@property
|
| 48 |
+
def regressions(self) -> list[EntryResult]:
|
| 49 |
+
return [r for r in self.results if r.regressed]
|
| 50 |
+
|
| 51 |
+
@property
|
| 52 |
+
def count(self) -> int:
|
| 53 |
+
return len(self.results)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _generate_variant(brief: dict[str, Any], variant_type: str, prompt_version: str) -> str:
|
| 57 |
+
"""Regenerate a single variant for a brief using a given prompt version.
|
| 58 |
+
|
| 59 |
+
Few-shot examples are intentionally omitted so the regression isolates the
|
| 60 |
+
prompt itself rather than whatever happens to be in memory.
|
| 61 |
+
"""
|
| 62 |
+
prompt = prompts.render_generation_prompt(brief, few_shot_block="", version=prompt_version)
|
| 63 |
+
raw = tools.chat(prompt, temperature=0.7)
|
| 64 |
+
parsed = tools.extract_json(raw)
|
| 65 |
+
return str(parsed.get(variant_type, "")).strip()
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def run_golden_eval(
|
| 69 |
+
prompt_version: Optional[str] = None,
|
| 70 |
+
golden: Optional[GoldenDataset] = None,
|
| 71 |
+
) -> EvalReport:
|
| 72 |
+
"""Run every golden entry against `prompt_version` (defaults to active)."""
|
| 73 |
+
prompt_version = prompt_version or prompts.ACTIVE_PROMPT_VERSION
|
| 74 |
+
golden = golden or GoldenDataset()
|
| 75 |
+
|
| 76 |
+
report = EvalReport(prompt_version=prompt_version)
|
| 77 |
+
|
| 78 |
+
for entry in golden.all():
|
| 79 |
+
brief = entry["brief"]
|
| 80 |
+
variant_type = entry["variant_type"]
|
| 81 |
+
baseline = float(entry["weighted_average"])
|
| 82 |
+
|
| 83 |
+
new_output = _generate_variant(brief, variant_type, prompt_version)
|
| 84 |
+
new_scores = judge_output(brief, variant_type, new_output)
|
| 85 |
+
new_score = float(new_scores["weighted_average"])
|
| 86 |
+
|
| 87 |
+
regressed = (baseline - new_score) > REGRESSION_TOLERANCE
|
| 88 |
+
report.results.append(
|
| 89 |
+
EntryResult(
|
| 90 |
+
brief=brief,
|
| 91 |
+
variant_type=variant_type,
|
| 92 |
+
baseline_score=baseline,
|
| 93 |
+
new_score=new_score,
|
| 94 |
+
new_output=new_output,
|
| 95 |
+
regressed=regressed,
|
| 96 |
+
)
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
return report
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def format_report(report: EvalReport) -> str:
|
| 103 |
+
"""Plain-text summary of a regression run (used as a fallback to Rich)."""
|
| 104 |
+
lines = [
|
| 105 |
+
f"Regression eval for prompt version: {report.prompt_version}",
|
| 106 |
+
f"Entries checked: {report.count}",
|
| 107 |
+
f"Tolerance: drop > {REGRESSION_TOLERANCE} fails",
|
| 108 |
+
"",
|
| 109 |
+
]
|
| 110 |
+
if report.count == 0:
|
| 111 |
+
lines.append("No golden entries yet — nothing to check. (PASS)")
|
| 112 |
+
return "\n".join(lines)
|
| 113 |
+
|
| 114 |
+
for i, r in enumerate(report.results, 1):
|
| 115 |
+
status = "REGRESSED" if r.regressed else "ok"
|
| 116 |
+
lines.append(
|
| 117 |
+
f" [{i}] {r.variant_type:8s} baseline={r.baseline_score:.2f} "
|
| 118 |
+
f"new={r.new_score:.2f} delta={r.delta:+.2f} {status}"
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
lines.append("")
|
| 122 |
+
if report.passed:
|
| 123 |
+
lines.append("RESULT: PASS — no entry regressed beyond tolerance.")
|
| 124 |
+
else:
|
| 125 |
+
lines.append(
|
| 126 |
+
f"RESULT: FAIL — {len(report.regressions)} entry(ies) regressed beyond "
|
| 127 |
+
f"{REGRESSION_TOLERANCE}. Do not promote this prompt version."
|
| 128 |
+
)
|
| 129 |
+
return "\n".join(lines)
|
feedback/__init__.py
ADDED
|
File without changes
|
feedback/loop.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Feedback loop: runs after every agent generation.
|
| 2 |
+
|
| 3 |
+
Responsibilities:
|
| 4 |
+
1. Read the scores from the latest run.
|
| 5 |
+
2. Promote high-scoring outputs (weighted avg >= GOLDEN_THRESHOLD) into the
|
| 6 |
+
golden dataset AND into ChromaDB memory so future runs can retrieve them.
|
| 7 |
+
3. Flag low-scoring outputs (weighted avg < FLAG_THRESHOLD) into the
|
| 8 |
+
flagged_outputs table with a clear reason.
|
| 9 |
+
|
| 10 |
+
This is the closing arc of the self-improving loop: good outputs become future
|
| 11 |
+
few-shot fuel and regression baselines; bad ones get surfaced for review.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from datetime import datetime, timezone
|
| 15 |
+
from typing import Any
|
| 16 |
+
|
| 17 |
+
from agent.memory import Memory
|
| 18 |
+
from db.store import Store
|
| 19 |
+
from evals.golden import GOLDEN_THRESHOLD, GoldenDataset
|
| 20 |
+
|
| 21 |
+
# Outputs scoring strictly below this are flagged for human review.
|
| 22 |
+
FLAG_THRESHOLD = 2.5
|
| 23 |
+
FLAG_REASON = "below quality threshold"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def run_feedback(
|
| 27 |
+
store: Store,
|
| 28 |
+
memory: Memory,
|
| 29 |
+
brief: dict[str, Any],
|
| 30 |
+
run_id: int,
|
| 31 |
+
scored_outputs: list[dict[str, Any]],
|
| 32 |
+
prompt_version: str,
|
| 33 |
+
) -> dict[str, Any]:
|
| 34 |
+
"""Process one run's outputs: promote the good, flag the bad.
|
| 35 |
+
|
| 36 |
+
`scored_outputs` is a list of {variant_type, content, scores} dicts.
|
| 37 |
+
Returns a summary describing what changed.
|
| 38 |
+
"""
|
| 39 |
+
golden = GoldenDataset(store=store)
|
| 40 |
+
now = datetime.now(timezone.utc).isoformat()
|
| 41 |
+
|
| 42 |
+
promoted: list[str] = []
|
| 43 |
+
flagged: list[str] = []
|
| 44 |
+
|
| 45 |
+
for item in scored_outputs:
|
| 46 |
+
variant_type = item["variant_type"]
|
| 47 |
+
content = item["content"]
|
| 48 |
+
scores = item["scores"]
|
| 49 |
+
weighted = float(scores.get("weighted_average", 0.0))
|
| 50 |
+
|
| 51 |
+
# 2. Promote high scorers to golden + memory.
|
| 52 |
+
if weighted >= GOLDEN_THRESHOLD:
|
| 53 |
+
added = golden.maybe_add(brief, variant_type, content, scores, prompt_version)
|
| 54 |
+
memory.add(
|
| 55 |
+
brief=brief,
|
| 56 |
+
variant_type=variant_type,
|
| 57 |
+
output=content,
|
| 58 |
+
score=weighted,
|
| 59 |
+
prompt_version=prompt_version,
|
| 60 |
+
timestamp=now,
|
| 61 |
+
)
|
| 62 |
+
if added:
|
| 63 |
+
promoted.append(variant_type)
|
| 64 |
+
|
| 65 |
+
# 3. Flag low scorers for review.
|
| 66 |
+
elif weighted < FLAG_THRESHOLD:
|
| 67 |
+
store.add_flagged(
|
| 68 |
+
brief=brief,
|
| 69 |
+
variant_type=variant_type,
|
| 70 |
+
output=content,
|
| 71 |
+
weighted_average=weighted,
|
| 72 |
+
reason=FLAG_REASON,
|
| 73 |
+
run_id=run_id,
|
| 74 |
+
)
|
| 75 |
+
flagged.append(variant_type)
|
| 76 |
+
|
| 77 |
+
return {
|
| 78 |
+
"promoted_to_golden": promoted,
|
| 79 |
+
"flagged_for_review": flagged,
|
| 80 |
+
"golden_threshold": GOLDEN_THRESHOLD,
|
| 81 |
+
"flag_threshold": FLAG_THRESHOLD,
|
| 82 |
+
}
|
requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
groq==0.13.0
|
| 2 |
+
chromadb==0.5.23
|
| 3 |
+
sentence-transformers==3.3.1
|
| 4 |
+
fastapi==0.115.6
|
| 5 |
+
uvicorn==0.34.0
|
| 6 |
+
rich==13.9.4
|
| 7 |
+
pytest==8.3.4
|
| 8 |
+
python-dotenv==1.0.1
|
| 9 |
+
pydantic==2.10.4
|
scripts/run_eval.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CLI to trigger an agent run or a regression eval and print results nicely.
|
| 2 |
+
|
| 3 |
+
Usage:
|
| 4 |
+
python scripts/run_eval.py run # run the agent on the example brief
|
| 5 |
+
python scripts/run_eval.py run --brief brief.json
|
| 6 |
+
python scripts/run_eval.py regression # run golden regression eval
|
| 7 |
+
python scripts/run_eval.py status # show golden / flagged counts
|
| 8 |
+
|
| 9 |
+
Uses Rich for clean terminal output.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import argparse
|
| 13 |
+
import json
|
| 14 |
+
import os
|
| 15 |
+
import sys
|
| 16 |
+
|
| 17 |
+
# Make the project root importable when run as `python scripts/run_eval.py`.
|
| 18 |
+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
| 19 |
+
|
| 20 |
+
from dotenv import load_dotenv # noqa: E402
|
| 21 |
+
|
| 22 |
+
load_dotenv()
|
| 23 |
+
|
| 24 |
+
from rich.console import Console # noqa: E402
|
| 25 |
+
from rich.panel import Panel # noqa: E402
|
| 26 |
+
from rich.table import Table # noqa: E402
|
| 27 |
+
|
| 28 |
+
console = Console()
|
| 29 |
+
|
| 30 |
+
EXAMPLE_BRIEF = {
|
| 31 |
+
"brand": "FitFuel",
|
| 32 |
+
"product": "High-protein meal replacement shake",
|
| 33 |
+
"audience": "Busy professionals aged 25-40",
|
| 34 |
+
"tone": "Energetic and no-nonsense",
|
| 35 |
+
"goal": "Drive trial purchases",
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _load_brief(path: str | None) -> dict:
|
| 40 |
+
if not path:
|
| 41 |
+
return EXAMPLE_BRIEF
|
| 42 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 43 |
+
return json.load(f)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def cmd_run(args: argparse.Namespace) -> int:
|
| 47 |
+
from agent.core import Agent
|
| 48 |
+
|
| 49 |
+
brief = _load_brief(args.brief)
|
| 50 |
+
console.print(Panel.fit(json.dumps(brief, indent=2), title="Brand Brief", border_style="cyan"))
|
| 51 |
+
|
| 52 |
+
with console.status("[bold green]Running agent loop (retrieve → generate → evaluate → feedback)..."):
|
| 53 |
+
agent = Agent()
|
| 54 |
+
result = agent.run(brief)
|
| 55 |
+
|
| 56 |
+
console.print(
|
| 57 |
+
f"\n[bold]Run #{result['run_id']}[/bold] "
|
| 58 |
+
f"prompt=[magenta]{result['prompt_version']}[/magenta] "
|
| 59 |
+
f"retrieved few-shot examples=[yellow]{result['retrieved_examples']}[/yellow]\n"
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
table = Table(title="Generated Variants & Scores", show_lines=True)
|
| 63 |
+
table.add_column("Variant", style="cyan", no_wrap=True)
|
| 64 |
+
table.add_column("Copy", style="white", max_width=50)
|
| 65 |
+
table.add_column("Hook", justify="right")
|
| 66 |
+
table.add_column("Brand", justify="right")
|
| 67 |
+
table.add_column("Clarity", justify="right")
|
| 68 |
+
table.add_column("Conv", justify="right")
|
| 69 |
+
table.add_column("Weighted", justify="right", style="bold")
|
| 70 |
+
|
| 71 |
+
for o in result["outputs"]:
|
| 72 |
+
s = o["scores"]
|
| 73 |
+
table.add_row(
|
| 74 |
+
o["variant_type"],
|
| 75 |
+
o["content"],
|
| 76 |
+
str(s["hook_strength"]),
|
| 77 |
+
str(s["brand_alignment"]),
|
| 78 |
+
str(s["clarity"]),
|
| 79 |
+
str(s["conversion_intent"]),
|
| 80 |
+
f"{s['weighted_average']:.2f}",
|
| 81 |
+
)
|
| 82 |
+
console.print(table)
|
| 83 |
+
|
| 84 |
+
fb = result["feedback"]
|
| 85 |
+
console.print(
|
| 86 |
+
f"\n[green]Promoted to golden:[/green] {fb['promoted_to_golden'] or 'none'} "
|
| 87 |
+
f"[red]Flagged for review:[/red] {fb['flagged_for_review'] or 'none'}"
|
| 88 |
+
)
|
| 89 |
+
return 0
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def cmd_regression(args: argparse.Namespace) -> int:
|
| 93 |
+
from evals.runner import format_report, run_golden_eval
|
| 94 |
+
|
| 95 |
+
with console.status("[bold green]Running golden regression eval..."):
|
| 96 |
+
report = run_golden_eval()
|
| 97 |
+
|
| 98 |
+
table = Table(title=f"Regression Eval — {report.prompt_version}", show_lines=True)
|
| 99 |
+
table.add_column("#", justify="right")
|
| 100 |
+
table.add_column("Variant", style="cyan")
|
| 101 |
+
table.add_column("Baseline", justify="right")
|
| 102 |
+
table.add_column("New", justify="right")
|
| 103 |
+
table.add_column("Delta", justify="right")
|
| 104 |
+
table.add_column("Status", justify="center")
|
| 105 |
+
|
| 106 |
+
for i, r in enumerate(report.results, 1):
|
| 107 |
+
status = "[red]REGRESSED[/red]" if r.regressed else "[green]ok[/green]"
|
| 108 |
+
table.add_row(
|
| 109 |
+
str(i), r.variant_type,
|
| 110 |
+
f"{r.baseline_score:.2f}", f"{r.new_score:.2f}",
|
| 111 |
+
f"{r.delta:+.2f}", status,
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
if report.count:
|
| 115 |
+
console.print(table)
|
| 116 |
+
console.print()
|
| 117 |
+
if report.passed:
|
| 118 |
+
console.print(Panel.fit("PASS — no entry regressed beyond tolerance.", border_style="green"))
|
| 119 |
+
else:
|
| 120 |
+
console.print(
|
| 121 |
+
Panel.fit(
|
| 122 |
+
f"FAIL — {len(report.regressions)} entry(ies) regressed. "
|
| 123 |
+
"Do not promote this prompt version.",
|
| 124 |
+
border_style="red",
|
| 125 |
+
)
|
| 126 |
+
)
|
| 127 |
+
# Non-zero exit on failure so it can gate CI / a prompt swap.
|
| 128 |
+
return 0 if report.passed else 1
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def cmd_status(args: argparse.Namespace) -> int:
|
| 132 |
+
from db.store import Store
|
| 133 |
+
from evals.golden import GoldenDataset
|
| 134 |
+
|
| 135 |
+
store = Store()
|
| 136 |
+
golden = GoldenDataset(store=store)
|
| 137 |
+
flagged = store.get_flagged()
|
| 138 |
+
|
| 139 |
+
console.print(
|
| 140 |
+
Panel.fit(
|
| 141 |
+
f"Golden entries: [green]{golden.size()}[/green]\n"
|
| 142 |
+
f"Flagged outputs: [red]{len(flagged)}[/red]",
|
| 143 |
+
title="System Status",
|
| 144 |
+
border_style="cyan",
|
| 145 |
+
)
|
| 146 |
+
)
|
| 147 |
+
return 0
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def main() -> int:
|
| 151 |
+
parser = argparse.ArgumentParser(description="Self-improving ad copy agent CLI")
|
| 152 |
+
sub = parser.add_subparsers(dest="command", required=True)
|
| 153 |
+
|
| 154 |
+
p_run = sub.add_parser("run", help="Run the agent loop on a brief")
|
| 155 |
+
p_run.add_argument("--brief", help="Path to a brief JSON file (defaults to FitFuel example)")
|
| 156 |
+
p_run.set_defaults(func=cmd_run)
|
| 157 |
+
|
| 158 |
+
p_reg = sub.add_parser("regression", help="Run the golden-dataset regression eval")
|
| 159 |
+
p_reg.set_defaults(func=cmd_regression)
|
| 160 |
+
|
| 161 |
+
p_status = sub.add_parser("status", help="Show golden / flagged counts")
|
| 162 |
+
p_status.set_defaults(func=cmd_status)
|
| 163 |
+
|
| 164 |
+
args = parser.parse_args()
|
| 165 |
+
return args.func(args)
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
if __name__ == "__main__":
|
| 169 |
+
raise SystemExit(main())
|
tests/test_regression.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Regression test suite.
|
| 2 |
+
|
| 3 |
+
Loads the golden dataset and runs the eval runner against the active prompt
|
| 4 |
+
version, asserting that no golden entry regresses more than 0.5 from its
|
| 5 |
+
baseline score.
|
| 6 |
+
|
| 7 |
+
Run with: pytest tests/test_regression.py
|
| 8 |
+
|
| 9 |
+
Notes:
|
| 10 |
+
- Requires GROQ_API_KEY (the runner regenerates + re-judges via Groq). If it is
|
| 11 |
+
not set, the test is skipped rather than failing spuriously.
|
| 12 |
+
- If the golden dataset is empty (fresh install, no runs yet), there is nothing
|
| 13 |
+
to regress against and the test passes trivially.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import os
|
| 17 |
+
|
| 18 |
+
import pytest
|
| 19 |
+
from dotenv import load_dotenv
|
| 20 |
+
|
| 21 |
+
load_dotenv()
|
| 22 |
+
|
| 23 |
+
from evals.golden import GoldenDataset # noqa: E402
|
| 24 |
+
from evals.runner import REGRESSION_TOLERANCE, format_report, run_golden_eval # noqa: E402
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@pytest.fixture(scope="module")
|
| 28 |
+
def golden() -> GoldenDataset:
|
| 29 |
+
return GoldenDataset()
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def test_no_golden_regressions(golden: GoldenDataset):
|
| 33 |
+
if not os.getenv("GROQ_API_KEY"):
|
| 34 |
+
pytest.skip("GROQ_API_KEY not set; regression eval needs live model calls.")
|
| 35 |
+
|
| 36 |
+
if golden.size() == 0:
|
| 37 |
+
pytest.skip("Golden dataset is empty; nothing to regress against yet.")
|
| 38 |
+
|
| 39 |
+
report = run_golden_eval(golden=golden)
|
| 40 |
+
|
| 41 |
+
# Print a readable summary so failures are diagnosable in CI logs.
|
| 42 |
+
print("\n" + format_report(report))
|
| 43 |
+
|
| 44 |
+
assert report.passed, (
|
| 45 |
+
f"{len(report.regressions)} golden entry(ies) regressed more than "
|
| 46 |
+
f"{REGRESSION_TOLERANCE} against prompt '{report.prompt_version}'. "
|
| 47 |
+
"Do not promote this prompt version."
|
| 48 |
+
)
|