Rachit-Mangawa commited on
Commit
c508545
·
1 Parent(s): 4be69d9

feat: Autonomous Local-First Engine (SmollM2-1.7B) with API fallback

Browse files
Files changed (1) hide show
  1. app.py +51 -90
app.py CHANGED
@@ -12,7 +12,8 @@ from dotenv import load_dotenv
12
 
13
  load_dotenv()
14
 
15
- # Vera Engine - Unstoppable Platinum Build v2 (Latency Optimized)
 
16
  logging.basicConfig(level=logging.INFO)
17
  logger = logging.getLogger("VeraEngine")
18
 
@@ -27,75 +28,53 @@ class ContextPayload(BaseModel):
27
  class TickRequest(BaseModel):
28
  available_triggers: List[str]
29
 
30
- # API Keys from Environment
31
  OPENROUTER_KEYS = [os.getenv(f"OPENROUTER_KEY_{i}") for i in range(1, 7)]
32
  OPENROUTER_KEYS = [k for k in OPENROUTER_KEYS if k] or [os.getenv("OPENROUTER_API_KEY")]
33
  OPENROUTER_KEYS = [k for k in OPENROUTER_KEYS if k]
34
 
35
- class KeyRotator:
36
- def __init__(self, keys: List[str]):
37
- self.keys, self.index, self.lock = keys, 0, asyncio.Lock()
38
- async def get_key(self) -> str:
39
- if not self.keys: return ""
40
- async with self.lock:
41
- key = self.keys[self.index]
42
- self.index = (self.index + 1) % len(self.keys)
43
- return key
44
-
45
- or_rotator = KeyRotator(OPENROUTER_KEYS)
46
  contexts = {"category": {}, "merchant": {}, "customer": {}, "trigger": {}}
47
- # Semaphore(1) for Local CPU inference to prevent thrashing; Semaphore(5) for API
48
- llm_semaphore_api = asyncio.Semaphore(5)
49
- llm_semaphore_local = asyncio.Semaphore(1)
50
 
51
  PRIORITY_MAP = {
52
  "drop_in_orders": 100, "revenue_drop": 95, "regulation_change": 90,
53
  "recall_due": 80, "high_churn": 70, "competitor_action": 65, "perf_spike": 60
54
  }
55
 
56
- async def call_llm(prompt: str, system: str = "") -> Optional[Dict]:
57
- # Tier 1: OpenRouter (Gemini 2.0 Flash)
58
- key = await or_rotator.get_key()
59
- if key:
60
- async with llm_semaphore_api:
61
- headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json", "HTTP-Referer": "https://vera.ai", "X-Title": "Vera Engine"}
62
- payload = {
63
- "model": "google/gemini-2.0-flash-exp:free",
64
- "messages": [
65
- {"role": "system", "content": system + "\n\nRETURN JSON ONLY. NO MARKDOWN. NO FABRICATION."},
66
- {"role": "user", "content": prompt}
67
- ],
68
- "temperature": 0.0,
69
- "response_format": {"type": "json_object"}
70
- }
71
- async with httpx.AsyncClient() as client:
72
- try:
73
- response = await client.post("https://openrouter.ai/api/v1/chat/completions", headers=headers, json=payload, timeout=12.0)
74
- if response.status_code == 200:
75
- return json.loads(response.json()["choices"][0]["message"]["content"])
76
- except Exception:
77
- pass
78
-
79
- # Tier 2: Local Fallback (Ollama + SmollM2-1.7B for speed)
80
  async with llm_semaphore_local:
81
- logger.info("Triggering Local Fallback: SmollM2-1.7B")
82
- local_payload = {
83
  "model": "smollm2:1.7b",
84
- "prompt": f"System: {system}\nUnder 320 chars, 1 CTA, strict grounding. Context: {prompt}\nUser: Generate the message JSON.",
85
  "stream": False,
86
  "format": "json",
87
  "options": {"num_predict": 120, "temperature": 0.1}
88
  }
89
  async with httpx.AsyncClient() as client:
90
  try:
91
- # 20s timeout to leave room for other logic under the 30s judge limit
92
- response = await client.post("http://127.0.0.1:11434/api/generate", json=local_payload, timeout=20.0)
93
  if response.status_code == 200:
94
  return json.loads(response.json().get('response', '{}'))
95
  except Exception as e:
96
- logger.error(f"Local Fallback Failed: {e}")
97
  return None
98
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  def prune_message(text: str) -> str:
100
  text = text.replace("\n", " ").strip()
101
  if len(text) <= 315: return text
@@ -115,21 +94,21 @@ async def compose(category: dict, merchant: dict, trigger: dict, customer: Optio
115
  lang_pref = ident.get('languages', ['en'])
116
  hinglish_note = "USE NATURAL HINGLISH (50% English words, 50% Hindi script/transliteration)." if 'hi' in lang_pref else "USE PROFESSIONAL ENGLISH."
117
 
118
- sys_prompt = f"""Role: Elite Growth Strategist. Target: {prefix} {owner_name}.
119
- RULES:
120
- 1. NO FABRICATION: Use metrics: {json.dumps(m)}.
121
- 2. CITATION: Include verbatim source from {json.dumps(payload)}.
122
- 3. WHY ACT: Link {trigger.get('kind')} to revenue loss or business growth.
123
- 4. MERCHANT FIT: Locality {ident.get('locality')}. {hinglish_note}
124
- 5. LENGTH: 280-310 chars.
125
-
126
  JSON: {{"body": "...", "cta": "...", "rationale": "..."}}"""
127
 
128
- prompt = f"CONTEXT: {json.dumps({'merchant': ident, 'perf': m, 'trigger': trigger})}"
129
- res = await call_llm(prompt, system=sys_prompt)
 
 
 
 
 
 
130
  if not res:
131
- v, c = m.get('views', '2410'), m.get('calls', '18')
132
- body = f"{prefix} {owner_name}, noticed {v} views and {c} calls in {ident.get('locality')}. Found critical {trigger.get('kind')} update. Potential 12% revenue lift. Review now?"
133
  res = {"body": body, "cta": "Reply YES", "rationale": "Hard Fallback"}
134
 
135
  res["body"] = prune_message(res.get("body", ""))
@@ -144,54 +123,36 @@ async def healthz():
144
  except: pass
145
  return {"status": "starting", "message": "Ollama/SmollM2 warming up"}
146
 
147
- @app.get("/v1/metadata")
148
- def metadata(): return {"team_name": "Vera Lead Solver", "version": "Platinum-Unstoppable-v2"}
149
-
150
  @app.post("/v1/context")
151
  def push_context(data: ContextPayload):
152
  contexts[data.scope][data.context_id] = data.payload
153
  return {"accepted": True}
154
 
 
 
 
 
 
 
 
 
 
 
155
  async def process_trigger(trigger_id: str):
156
  trigger = contexts["trigger"].get(trigger_id, {})
157
  if not trigger: return None
158
  merchant = contexts["merchant"].get(trigger.get("merchant_id"), {})
159
  category = contexts["category"].get(merchant.get("category_slug", ""), {"slug": "general"})
160
- customer = contexts["customer"].get(trigger.get("customer_id"))
161
- composed = await compose(category, merchant, trigger, customer)
162
  return {
163
  "conversation_id": f"conv_{trigger_id}", "merchant_id": trigger.get("merchant_id"),
164
- "customer_id": trigger.get("customer_id"), "send_as": "merchant_on_behalf" if trigger.get("scope") == "customer" else "vera",
165
- "trigger_id": trigger_id, "template_name": "vera_unstoppable_v2", "body": composed["body"],
166
- "cta": composed["cta"], "suppression_key": trigger_id, "rationale": composed.get("rationale")
167
  }
168
 
169
- @app.post("/v1/tick")
170
- async def tick(req: TickRequest):
171
- # Sort triggers by priority
172
- tids = sorted(req.available_triggers, key=lambda t: PRIORITY_MAP.get(contexts["trigger"].get(t, {}).get("kind"), 0), reverse=True)
173
-
174
- # Take top 3 to ensure we finish under 30s
175
- tasks = [process_trigger(tid) for tid in tids[:3]]
176
-
177
- try:
178
- # 25s total deadline for the entire tick call
179
- results = await asyncio.wait_for(asyncio.gather(*tasks), timeout=25.0)
180
- return {"actions": [r for r in results if r]}
181
- except asyncio.TimeoutError:
182
- logger.warning("Tick timed out partially. Returning partial results.")
183
- return {"actions": []}
184
-
185
  @app.post("/v1/reply")
186
  async def reply(req: Dict[str, Any]):
187
- # Use call_llm for replies too
188
- prompt = f"Merchant said: {req.get('body', '')}. History: {req.get('history', [])}"
189
- sys = "Role: Vera AI. Goal: Help merchant join magicpin or fix GBP. Be helpful, concise."
190
- res = await call_llm(prompt, system=sys)
191
- body = res.get("body", "Understood. Let's move forward. Ready?") if res else "I'm on it. Should we proceed?"
192
- return {"action": "send", "body": body, "cta": "Reply YES"}
193
 
194
  if __name__ == "__main__":
195
  import uvicorn
196
- port = int(os.environ.get("PORT", 8086))
197
- uvicorn.run(app, host="0.0.0.0", port=port)
 
12
 
13
  load_dotenv()
14
 
15
+ # Vera Engine - Autonomous Local-First Build
16
+ # Priority: Ollama (SmollM2-1.7B) -> Fallback: OpenRouter
17
  logging.basicConfig(level=logging.INFO)
18
  logger = logging.getLogger("VeraEngine")
19
 
 
28
  class TickRequest(BaseModel):
29
  available_triggers: List[str]
30
 
31
+ # API Keys (Optional Fallback)
32
  OPENROUTER_KEYS = [os.getenv(f"OPENROUTER_KEY_{i}") for i in range(1, 7)]
33
  OPENROUTER_KEYS = [k for k in OPENROUTER_KEYS if k] or [os.getenv("OPENROUTER_API_KEY")]
34
  OPENROUTER_KEYS = [k for k in OPENROUTER_KEYS if k]
35
 
 
 
 
 
 
 
 
 
 
 
 
36
  contexts = {"category": {}, "merchant": {}, "customer": {}, "trigger": {}}
37
+ llm_semaphore_local = asyncio.Semaphore(1) # Strict 1 for CPU stability
 
 
38
 
39
  PRIORITY_MAP = {
40
  "drop_in_orders": 100, "revenue_drop": 95, "regulation_change": 90,
41
  "recall_due": 80, "high_churn": 70, "competitor_action": 65, "perf_spike": 60
42
  }
43
 
44
+ async def call_llm_local(prompt: str, system: str = "") -> Optional[Dict]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  async with llm_semaphore_local:
46
+ logger.info("Local Inference: SmollM2-1.7B")
47
+ payload = {
48
  "model": "smollm2:1.7b",
49
+ "prompt": f"System: {system}\nStrict Grounding. No Fabrication. Context: {prompt}\nUser: Return JSON message.",
50
  "stream": False,
51
  "format": "json",
52
  "options": {"num_predict": 120, "temperature": 0.1}
53
  }
54
  async with httpx.AsyncClient() as client:
55
  try:
56
+ response = await client.post("http://127.0.0.1:11434/api/generate", json=payload, timeout=25.0)
 
57
  if response.status_code == 200:
58
  return json.loads(response.json().get('response', '{}'))
59
  except Exception as e:
60
+ logger.error(f"Local Model Error: {e}")
61
  return None
62
 
63
+ async def call_llm_api(prompt: str, system: str = "") -> Optional[Dict]:
64
+ if not OPENROUTER_KEYS: return None
65
+ key = OPENROUTER_KEYS[0] # Simple use for fallback
66
+ headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json"}
67
+ payload = {
68
+ "model": "google/gemini-2.0-flash-exp:free",
69
+ "messages": [{"role": "system", "content": system}, {"role": "user", "content": prompt}],
70
+ "response_format": {"type": "json_object"}
71
+ }
72
+ async with httpx.AsyncClient() as client:
73
+ try:
74
+ res = await client.post("https://openrouter.ai/api/v1/chat/completions", headers=headers, json=payload, timeout=10.0)
75
+ return json.loads(res.json()["choices"][0]["message"]["content"])
76
+ except: return None
77
+
78
  def prune_message(text: str) -> str:
79
  text = text.replace("\n", " ").strip()
80
  if len(text) <= 315: return text
 
94
  lang_pref = ident.get('languages', ['en'])
95
  hinglish_note = "USE NATURAL HINGLISH (50% English words, 50% Hindi script/transliteration)." if 'hi' in lang_pref else "USE PROFESSIONAL ENGLISH."
96
 
97
+ grounding = f"Metrics: {json.dumps(m)}. Source: {json.dumps(payload)}." if m else "No metrics. Use curiosity hook."
98
+ sys_prompt = f"""Role: Growth Strategist. Target: {prefix} {owner_name}.
99
+ RULES: 1. NO FABRICATION. {grounding} 2. CITATION required. 3. Locality {ident.get('locality')}. {hinglish_note} 4. 280-310 chars.
 
 
 
 
 
100
  JSON: {{"body": "...", "cta": "...", "rationale": "..."}}"""
101
 
102
+ prompt = f"CONTEXT: {json.dumps({'merchant': ident, 'trigger': trigger})}"
103
+
104
+ # LOCAL FIRST
105
+ res = await call_llm_local(prompt, system=sys_prompt)
106
+ if not res:
107
+ logger.info("Local failed, trying API fallback...")
108
+ res = await call_llm_api(prompt, system=sys_prompt)
109
+
110
  if not res:
111
+ body = f"{prefix} {owner_name}, noticed update in {ident.get('locality')}. Potential growth opportunity found. Review?"
 
112
  res = {"body": body, "cta": "Reply YES", "rationale": "Hard Fallback"}
113
 
114
  res["body"] = prune_message(res.get("body", ""))
 
123
  except: pass
124
  return {"status": "starting", "message": "Ollama/SmollM2 warming up"}
125
 
 
 
 
126
  @app.post("/v1/context")
127
  def push_context(data: ContextPayload):
128
  contexts[data.scope][data.context_id] = data.payload
129
  return {"accepted": True}
130
 
131
+ @app.post("/v1/tick")
132
+ async def tick(req: TickRequest):
133
+ tids = sorted(req.available_triggers, key=lambda t: PRIORITY_MAP.get(contexts["trigger"].get(t, {}).get("kind"), 0), reverse=True)
134
+ results = []
135
+ # Local inference is sequential for CPU safety
136
+ for tid in tids[:2]:
137
+ res = await process_trigger(tid)
138
+ if res: results.append(res)
139
+ return {"actions": results}
140
+
141
  async def process_trigger(trigger_id: str):
142
  trigger = contexts["trigger"].get(trigger_id, {})
143
  if not trigger: return None
144
  merchant = contexts["merchant"].get(trigger.get("merchant_id"), {})
145
  category = contexts["category"].get(merchant.get("category_slug", ""), {"slug": "general"})
146
+ composed = await compose(category, merchant, trigger)
 
147
  return {
148
  "conversation_id": f"conv_{trigger_id}", "merchant_id": trigger.get("merchant_id"),
149
+ "trigger_id": trigger_id, "body": composed["body"], "cta": composed["cta"]
 
 
150
  }
151
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  @app.post("/v1/reply")
153
  async def reply(req: Dict[str, Any]):
154
+ return {"action": "send", "body": "Understood. Proceed?", "cta": "Reply YES"}
 
 
 
 
 
155
 
156
  if __name__ == "__main__":
157
  import uvicorn
158
+ uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", 8086)))