Spaces:
Sleeping
Sleeping
Zeetay commited on
Commit ·
5bb23c3
1
Parent(s): 83d9adb
feat: backend updates for hosted demo (CORS, stats, seed, rate limit, Procfile)
Browse files- .gitignore +5 -0
- Procfile +1 -0
- agent/core.py +2 -0
- api/main.py +99 -11
- db/store.py +16 -0
- requirements.txt +1 -0
- seed/__init__.py +0 -0
- seed/golden_seed.json +59 -0
- 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
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
|
|
|
|
|
|
| 5 |
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
-
|
|
|
|
| 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 |
-
|
| 47 |
-
|
|
|
|
| 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
|