Zeetay commited on
Commit
5bb23c3
·
1 Parent(s): 83d9adb

feat: backend updates for hosted demo (CORS, stats, seed, rate limit, Procfile)

Browse files
Files changed (9) hide show
  1. .gitignore +5 -0
  2. Procfile +1 -0
  3. agent/core.py +2 -0
  4. api/main.py +99 -11
  5. db/store.py +16 -0
  6. requirements.txt +1 -0
  7. seed/__init__.py +0 -0
  8. seed/golden_seed.json +59 -0
  9. seed/loader.py +52 -0
.gitignore CHANGED
@@ -15,3 +15,8 @@ chroma_db/
15
  __pycache__/
16
  *.pyc
17
  .pytest_cache/
 
 
 
 
 
 
15
  __pycache__/
16
  *.pyc
17
  .pytest_cache/
18
+
19
+ # Node / Next.js frontend
20
+ web/node_modules/
21
+ web/.next/
22
+ web/.env
Procfile ADDED
@@ -0,0 +1 @@
 
 
1
+ web: uvicorn api.main:app --host 0.0.0.0 --port $PORT
agent/core.py CHANGED
@@ -90,6 +90,8 @@ class Agent:
90
  "brief": brief,
91
  "prompt_version": prompt_version,
92
  "retrieved_examples": len(retrieved),
 
 
93
  "outputs": scored_outputs,
94
  "feedback": feedback_summary,
95
  }
 
90
  "brief": brief,
91
  "prompt_version": prompt_version,
92
  "retrieved_examples": len(retrieved),
93
+ # Surfaced to the frontend so it can show the loop is self-improving.
94
+ "retrieved_count": len(retrieved),
95
  "outputs": scored_outputs,
96
  "feedback": feedback_summary,
97
  }
api/main.py CHANGED
@@ -1,23 +1,38 @@
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
 
@@ -29,6 +44,59 @@ def get_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"])
@@ -37,14 +105,34 @@ class BrandBrief(BaseModel):
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())
 
1
+ """FastAPI app for the hosted public demo.
2
 
3
+ Endpoints:
4
+ - GET /health -> liveness probe for Railway
5
+ - GET /stats -> live counts (runs, golden, flagged) for the frontend strip
6
+ - POST /run -> full agent loop (rate limited), returns outputs + scores
7
 
8
+ CORS is configured for the Vercel frontend, the Groq key is protected by a
9
+ per-IP rate limit, and a fresh deployment is seeded so the first visitor sees
10
+ non-zero stats and working retrieval.
11
+
12
+ Run locally: uvicorn api.main:app --reload
13
  """
14
 
15
+ import os
16
+ from contextlib import asynccontextmanager
17
  from typing import Any
18
 
19
  from dotenv import load_dotenv
 
 
20
 
21
+ load_dotenv() # pick up GROQ_API_KEY / CORS_ORIGINS from .env if present
22
 
23
+ from fastapi import FastAPI, HTTPException, Request # noqa: E402
24
+ from fastapi.middleware.cors import CORSMiddleware # noqa: E402
25
+ from pydantic import BaseModel, Field # noqa: E402
26
+ from slowapi import Limiter, _rate_limit_exceeded_handler # noqa: E402
27
+ from slowapi.errors import RateLimitExceeded # noqa: E402
28
+ from slowapi.util import get_remote_address # noqa: E402
29
 
30
+ from agent.core import Agent # noqa: E402 (after load_dotenv on purpose)
31
+ from seed.loader import load_seed_if_empty # noqa: E402
32
 
33
+ # --------------------------------------------------------------------------- #
34
+ # Shared singletons
35
+ # --------------------------------------------------------------------------- #
36
  # A single shared agent (and therefore shared Store/Memory) for the process.
37
  _agent: Agent | None = None
38
 
 
44
  return _agent
45
 
46
 
47
+ # --------------------------------------------------------------------------- #
48
+ # CORS origins
49
+ # --------------------------------------------------------------------------- #
50
+ def _cors_origins() -> list[str]:
51
+ """Origins from CORS_ORIGINS (comma-separated), always plus localhost:3000.
52
+
53
+ If CORS_ORIGINS is unset, default to allow-all ("*").
54
+ """
55
+ raw = os.getenv("CORS_ORIGINS")
56
+ if not raw:
57
+ return ["*"]
58
+ origins = [o.strip() for o in raw.split(",") if o.strip()]
59
+ if "http://localhost:3000" not in origins:
60
+ origins.append("http://localhost:3000")
61
+ return origins
62
+
63
+
64
+ # --------------------------------------------------------------------------- #
65
+ # Lifespan: seed a fresh deployment on startup
66
+ # --------------------------------------------------------------------------- #
67
+ @asynccontextmanager
68
+ async def lifespan(app: FastAPI):
69
+ try:
70
+ agent = get_agent()
71
+ seeded = load_seed_if_empty(agent.store, agent.memory)
72
+ if seeded:
73
+ print(f"[startup] Seeded {seeded} golden/memory example(s).")
74
+ except Exception as exc: # noqa: BLE001 — never block startup on seeding.
75
+ print(f"[startup] Seed skipped: {exc}")
76
+ yield
77
+
78
+
79
+ # --------------------------------------------------------------------------- #
80
+ # App + rate limiter
81
+ # --------------------------------------------------------------------------- #
82
+ limiter = Limiter(key_func=get_remote_address)
83
+
84
+ app = FastAPI(title="Self-Improving Ad Copy Agent", version="1.0.0", lifespan=lifespan)
85
+ app.state.limiter = limiter
86
+ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
87
+
88
+ app.add_middleware(
89
+ CORSMiddleware,
90
+ allow_origins=_cors_origins(),
91
+ allow_credentials=False,
92
+ allow_methods=["*"],
93
+ allow_headers=["*"],
94
+ )
95
+
96
+
97
+ # --------------------------------------------------------------------------- #
98
+ # Schemas
99
+ # --------------------------------------------------------------------------- #
100
  class BrandBrief(BaseModel):
101
  brand: str = Field(..., examples=["FitFuel"])
102
  product: str = Field(..., examples=["High-protein meal replacement shake"])
 
105
  goal: str = Field(..., examples=["Drive trial purchases"])
106
 
107
 
108
+ # --------------------------------------------------------------------------- #
109
+ # Endpoints
110
+ # --------------------------------------------------------------------------- #
111
  @app.get("/")
112
  def root() -> dict[str, str]:
113
  return {"status": "ok", "endpoint": "POST /run with a brand brief"}
114
 
115
 
116
+ @app.get("/health")
117
+ def health() -> dict[str, str]:
118
+ return {"status": "ok"}
119
+
120
+
121
+ @app.get("/stats")
122
+ def stats() -> dict[str, int]:
123
+ """Live counts for the frontend stats strip."""
124
+ store = get_agent().store
125
+ return {
126
+ "runs": store.count_runs(),
127
+ "golden": store.count_golden(),
128
+ "flagged": store.count_flagged(),
129
+ }
130
+
131
+
132
  @app.post("/run")
133
+ @limiter.limit("10/minute")
134
+ def run(request: Request, brief: BrandBrief) -> dict[str, Any]:
135
+ """Trigger the full agent loop for a brand brief (rate limited per IP)."""
136
  try:
137
  agent = get_agent()
138
  return agent.run(brief.model_dump())
db/store.py CHANGED
@@ -239,6 +239,22 @@ class Store:
239
  result.append(d)
240
  return result
241
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  # ------------------------------------------------------------------- close
243
  def close(self) -> None:
244
  with self._lock:
 
239
  result.append(d)
240
  return result
241
 
242
+ # ------------------------------------------------------------------ counts
243
+ def count_runs(self) -> int:
244
+ with self._lock:
245
+ row = self._conn.execute("SELECT COUNT(*) AS n FROM runs").fetchone()
246
+ return int(row["n"])
247
+
248
+ def count_golden(self) -> int:
249
+ with self._lock:
250
+ row = self._conn.execute("SELECT COUNT(*) AS n FROM golden").fetchone()
251
+ return int(row["n"])
252
+
253
+ def count_flagged(self) -> int:
254
+ with self._lock:
255
+ row = self._conn.execute("SELECT COUNT(*) AS n FROM flagged_outputs").fetchone()
256
+ return int(row["n"])
257
+
258
  # ------------------------------------------------------------------- close
259
  def close(self) -> None:
260
  with self._lock:
requirements.txt CHANGED
@@ -4,6 +4,7 @@ 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
 
4
  fastapi==0.115.6
5
  uvicorn==0.34.0
6
  rich==13.9.4
7
+ slowapi==0.1.9
8
  pytest==8.3.4
9
  python-dotenv==1.0.1
10
  pydantic==2.10.4
seed/__init__.py ADDED
File without changes
seed/golden_seed.json ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "brief": {
4
+ "brand": "FitFuel",
5
+ "product": "High-protein meal replacement shake",
6
+ "audience": "Busy professionals aged 25-40",
7
+ "tone": "Energetic and no-nonsense",
8
+ "goal": "Drive trial purchases"
9
+ },
10
+ "variant_type": "headline",
11
+ "output": "Fuel Your Hustle, Not Your Hunger",
12
+ "scores": {
13
+ "hook_strength": 5,
14
+ "brand_alignment": 5,
15
+ "clarity": 5,
16
+ "conversion_intent": 4,
17
+ "weighted_average": 4.8
18
+ },
19
+ "prompt_version": "GENERATION_PROMPT_V1"
20
+ },
21
+ {
22
+ "brief": {
23
+ "brand": "FitFuel",
24
+ "product": "High-protein meal replacement shake",
25
+ "audience": "Busy professionals aged 25-40",
26
+ "tone": "Energetic and no-nonsense",
27
+ "goal": "Drive trial purchases"
28
+ },
29
+ "variant_type": "body",
30
+ "output": "No time to cook, no patience for slumps. FitFuel packs 30g of protein into one fast shake so you stay sharp from the 9am standup to the 6pm sprint. Real fuel, zero fuss.",
31
+ "scores": {
32
+ "hook_strength": 4,
33
+ "brand_alignment": 5,
34
+ "clarity": 5,
35
+ "conversion_intent": 4,
36
+ "weighted_average": 4.5
37
+ },
38
+ "prompt_version": "GENERATION_PROMPT_V1"
39
+ },
40
+ {
41
+ "brief": {
42
+ "brand": "BrightBrew",
43
+ "product": "Cold brew coffee concentrate",
44
+ "audience": "Remote workers who want better coffee at home",
45
+ "tone": "Calm, premium, understated",
46
+ "goal": "Drive subscription sign-ups"
47
+ },
48
+ "variant_type": "body",
49
+ "output": "Skip the cafe queue. One bottle of BrightBrew concentrate makes twelve smooth, low-acidity cups right at your desk. Subscribe and your next batch arrives before you run out.",
50
+ "scores": {
51
+ "hook_strength": 4,
52
+ "brand_alignment": 4,
53
+ "clarity": 5,
54
+ "conversion_intent": 4,
55
+ "weighted_average": 4.25
56
+ },
57
+ "prompt_version": "GENERATION_PROMPT_V1"
58
+ }
59
+ ]
seed/loader.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Startup seed loader.
2
+
3
+ A fresh deployment has an empty golden dataset and empty memory, so the very
4
+ first visitor would see zeroed stats and get no few-shot retrieval. To avoid
5
+ that cold-start look, we load a small set of pre-scored example outputs into
6
+ both SQLite (golden) and ChromaDB (memory) the first time the app boots with an
7
+ empty golden table.
8
+
9
+ Idempotent: if the golden dataset already has entries, this does nothing.
10
+ """
11
+
12
+ import json
13
+ import os
14
+ from datetime import datetime, timezone
15
+
16
+ from agent.memory import Memory
17
+ from db.store import Store
18
+
19
+ SEED_PATH = os.path.join(os.path.dirname(__file__), "golden_seed.json")
20
+
21
+
22
+ def load_seed_if_empty(store: Store, memory: Memory) -> int:
23
+ """Load seed examples into golden + memory iff golden is currently empty.
24
+
25
+ Returns the number of entries seeded (0 if it was already populated).
26
+ """
27
+ if store.count_golden() > 0:
28
+ return 0
29
+
30
+ with open(SEED_PATH, "r", encoding="utf-8") as f:
31
+ entries = json.load(f)
32
+
33
+ now = datetime.now(timezone.utc).isoformat()
34
+ seeded = 0
35
+ for entry in entries:
36
+ brief = entry["brief"]
37
+ variant_type = entry["variant_type"]
38
+ output = entry["output"]
39
+ scores = entry["scores"]
40
+ prompt_version = entry.get("prompt_version", "GENERATION_PROMPT_V1")
41
+
42
+ store.add_golden(brief, variant_type, output, scores, prompt_version)
43
+ memory.add(
44
+ brief=brief,
45
+ variant_type=variant_type,
46
+ output=output,
47
+ score=float(scores["weighted_average"]),
48
+ prompt_version=prompt_version,
49
+ timestamp=now,
50
+ )
51
+ seeded += 1
52
+ return seeded