Zeetay commited on
Commit
e6fb135
·
0 Parent(s):

Initial commit: self-improving ad copy agent eval harness

Browse files

End-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 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
+ )