AK commited on
Commit
a06e2cf
·
0 Parent(s):

feat: LLM layer with budget guard, mock backend, and embedding cache

Browse files
Files changed (5) hide show
  1. .env.example +8 -0
  2. .gitignore +7 -0
  3. backend/__init__.py +1 -0
  4. backend/llm.py +176 -0
  5. requirements.txt +4 -0
.env.example ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # Copy to .env (local) or set as a Secret in your Hugging Face Space.
2
+ # Without a key, Polis runs in deterministic MOCK mode — no cost, still fully playable.
3
+ OPENAI_API_KEY=sk-your-key-here
4
+
5
+ # Optional overrides
6
+ POLIS_CHAT_MODEL=gpt-4o-mini
7
+ POLIS_EMBED_MODEL=text-embedding-3-small
8
+ POLIS_BUDGET_USD=1.00
.gitignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ venv/
5
+ .env
6
+ .DS_Store
7
+ *.log
backend/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Polis backend package."""
backend/llm.py ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LLM layer for Polis.
3
+
4
+ Wraps the OpenAI API with three safety features that matter in a portfolio piece:
5
+ 1. A hard *budget guard* — the sim refuses to spend past POLIS_BUDGET_USD.
6
+ 2. A deterministic *mock backend* — if there is no API key, agents still think,
7
+ so the Space boots and demos with zero cost.
8
+ 3. A tiny in-process *embedding cache* — identical strings are never re-embedded.
9
+
10
+ Nothing here is OpenAI-specific beyond the client construction, so swapping in a
11
+ local model later is a one-file change.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import math
17
+ import os
18
+ import random
19
+ import threading
20
+ from dataclasses import dataclass, field
21
+ from typing import List, Optional
22
+
23
+ # ---- pricing (USD per 1M tokens), used only for the budget guard ------------
24
+ # Kept intentionally conservative; update if you change models.
25
+ PRICING = {
26
+ "gpt-4o-mini": {"in": 0.15, "out": 0.60},
27
+ "gpt-4o": {"in": 2.50, "out": 10.00},
28
+ "text-embedding-3-small": {"in": 0.02, "out": 0.0},
29
+ }
30
+
31
+ CHAT_MODEL = os.getenv("POLIS_CHAT_MODEL", "gpt-4o-mini")
32
+ EMBED_MODEL = os.getenv("POLIS_EMBED_MODEL", "text-embedding-3-small")
33
+ BUDGET_USD = float(os.getenv("POLIS_BUDGET_USD", "1.00"))
34
+
35
+
36
+ @dataclass
37
+ class Ledger:
38
+ """Tracks spend so a runaway loop can't drain the account."""
39
+ spent_usd: float = 0.0
40
+ calls: int = 0
41
+ tokens_in: int = 0
42
+ tokens_out: int = 0
43
+ _lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
44
+
45
+ def charge(self, model: str, tin: int, tout: int) -> None:
46
+ p = PRICING.get(model, {"in": 0.0, "out": 0.0})
47
+ cost = (tin / 1e6) * p["in"] + (tout / 1e6) * p["out"]
48
+ with self._lock:
49
+ self.spent_usd += cost
50
+ self.calls += 1
51
+ self.tokens_in += tin
52
+ self.tokens_out += tout
53
+
54
+ def remaining(self) -> float:
55
+ return max(0.0, BUDGET_USD - self.spent_usd)
56
+
57
+ def as_dict(self) -> dict:
58
+ return {
59
+ "spent_usd": round(self.spent_usd, 4),
60
+ "budget_usd": BUDGET_USD,
61
+ "remaining_usd": round(self.remaining(), 4),
62
+ "calls": self.calls,
63
+ "tokens_in": self.tokens_in,
64
+ "tokens_out": self.tokens_out,
65
+ }
66
+
67
+
68
+ class BudgetExceeded(RuntimeError):
69
+ pass
70
+
71
+
72
+ class LLM:
73
+ """Unified chat + embedding client with a mock fallback."""
74
+
75
+ def __init__(self) -> None:
76
+ self.ledger = Ledger()
77
+ self._embed_cache: dict[str, List[float]] = {}
78
+ self._client = None
79
+ self.live = False
80
+ key = os.getenv("OPENAI_API_KEY", "").strip()
81
+ if key:
82
+ try:
83
+ from openai import OpenAI # imported lazily so mock mode needs no dep
84
+ self._client = OpenAI(api_key=key)
85
+ self.live = True
86
+ except Exception as exc: # pragma: no cover - defensive
87
+ print(f"[llm] OpenAI unavailable, using mock backend: {exc}")
88
+
89
+ # -- chat -----------------------------------------------------------------
90
+ def chat(self, system: str, user: str, *, temperature: float = 0.8,
91
+ max_tokens: int = 220) -> str:
92
+ if not self.live:
93
+ return self._mock_chat(system, user)
94
+ if self.ledger.remaining() <= 0:
95
+ raise BudgetExceeded(
96
+ f"Budget of ${BUDGET_USD:.2f} exhausted; refusing to spend more."
97
+ )
98
+ resp = self._client.chat.completions.create(
99
+ model=CHAT_MODEL,
100
+ messages=[{"role": "system", "content": system},
101
+ {"role": "user", "content": user}],
102
+ temperature=temperature,
103
+ max_tokens=max_tokens,
104
+ )
105
+ usage = resp.usage
106
+ self.ledger.charge(CHAT_MODEL, usage.prompt_tokens, usage.completion_tokens)
107
+ return (resp.choices[0].message.content or "").strip()
108
+
109
+ # -- embeddings -----------------------------------------------------------
110
+ def embed(self, text: str) -> List[float]:
111
+ h = hashlib.sha1(text.encode("utf-8")).hexdigest()
112
+ if h in self._embed_cache:
113
+ return self._embed_cache[h]
114
+ if not self.live or self.ledger.remaining() <= 0:
115
+ vec = self._mock_embed(text)
116
+ else:
117
+ resp = self._client.embeddings.create(model=EMBED_MODEL, input=text)
118
+ self.ledger.charge(EMBED_MODEL, resp.usage.prompt_tokens, 0)
119
+ vec = resp.data[0].embedding
120
+ self._embed_cache[h] = vec
121
+ return vec
122
+
123
+ # -- deterministic mock backend ------------------------------------------
124
+ def _mock_embed(self, text: str, dim: int = 64) -> List[float]:
125
+ """Hash-seeded pseudo-embedding. Not semantic, but stable and cheap —
126
+ enough to make retrieval do *something* sensible offline."""
127
+ seed = int(hashlib.sha1(text.lower().encode()).hexdigest()[:8], 16)
128
+ rng = random.Random(seed)
129
+ # bias vector by simple word hashing so related strings cluster a bit
130
+ vec = [0.0] * dim
131
+ for tok in text.lower().split():
132
+ t = int(hashlib.sha1(tok.encode()).hexdigest()[:8], 16)
133
+ vec[t % dim] += 1.0
134
+ # add small noise for uniqueness
135
+ vec = [v + rng.uniform(-0.05, 0.05) for v in vec]
136
+ norm = math.sqrt(sum(v * v for v in vec)) or 1.0
137
+ return [v / norm for v in vec]
138
+
139
+ _MOCK_ACTIONS = [
140
+ "heads to the plaza to see who is around",
141
+ "strikes up a conversation about the day's news",
142
+ "tends to work, humming quietly",
143
+ "shares a rumor they overheard this morning",
144
+ "invites a neighbor to the evening gathering",
145
+ "reflects on a recent argument and softens",
146
+ "sketches a small plan for tomorrow",
147
+ "offers to help someone carry supplies",
148
+ ]
149
+
150
+ def _mock_chat(self, system: str, user: str) -> str:
151
+ seed = int(hashlib.sha1((system + user).encode()).hexdigest()[:8], 16)
152
+ rng = random.Random(seed)
153
+ if "reflect" in user.lower() or "insight" in user.lower():
154
+ return rng.choice([
155
+ "I value the people who show up for me.",
156
+ "Small kindnesses seem to matter more than grand plans.",
157
+ "I am becoming more curious about the newcomers.",
158
+ ])
159
+ if "dialogue" in user.lower() or "say to" in user.lower():
160
+ return rng.choice([
161
+ "\"Have you heard? Something is stirring near the market.\"",
162
+ "\"Come by tonight — we could use another set of hands.\"",
163
+ "\"I've been thinking about what you said yesterday.\"",
164
+ ])
165
+ return rng.choice(self._MOCK_ACTIONS)
166
+
167
+
168
+ # module-level singleton
169
+ llm = LLM()
170
+
171
+
172
+ def cosine(a: List[float], b: List[float]) -> float:
173
+ dot = sum(x * y for x, y in zip(a, b))
174
+ na = math.sqrt(sum(x * x for x in a)) or 1.0
175
+ nb = math.sqrt(sum(x * x for x in b)) or 1.0
176
+ return dot / (na * nb)
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ fastapi==0.111.0
2
+ uvicorn[standard]==0.30.1
3
+ pydantic==2.7.4
4
+ openai==1.35.7