Spaces:
Running
Running
Rachit-Mangawa commited on
Commit ·
42c83f7
1
Parent(s): fd5b9aa
feat(infra): implement 5-tier hierarchical inference engine with key rotation
Browse files
app.py
CHANGED
|
@@ -4,6 +4,7 @@ import logging
|
|
| 4 |
import asyncio
|
| 5 |
import time
|
| 6 |
import re
|
|
|
|
| 7 |
from typing import List, Dict, Any, Optional
|
| 8 |
from fastapi import FastAPI, Request
|
| 9 |
from pydantic import BaseModel
|
|
@@ -12,13 +13,13 @@ from dotenv import load_dotenv
|
|
| 12 |
|
| 13 |
load_dotenv()
|
| 14 |
|
| 15 |
-
# Vera Engine - Autonomous
|
| 16 |
-
# Priority: Ollama (Llama-3.2-3B) -> Fallback: OpenRouter
|
| 17 |
logging.basicConfig(level=logging.INFO)
|
| 18 |
logger = logging.getLogger("VeraEngine")
|
| 19 |
|
| 20 |
app = FastAPI()
|
| 21 |
|
|
|
|
| 22 |
class ContextPayload(BaseModel):
|
| 23 |
scope: Optional[str] = None
|
| 24 |
context_id: Optional[str] = None
|
|
@@ -28,22 +29,165 @@ class ContextPayload(BaseModel):
|
|
| 28 |
class TickRequest(BaseModel):
|
| 29 |
available_triggers: List[str] = []
|
| 30 |
|
| 31 |
-
#
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
contexts = {"category": {}, "merchant": {}, "customer": {}, "trigger": {}}
|
| 37 |
-
llm_semaphore_local = asyncio.Semaphore(1)
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
async with llm_semaphore_local:
|
| 46 |
-
logger.info("Local
|
| 47 |
payload = {
|
| 48 |
"model": "llama3.2",
|
| 49 |
"prompt": f"System: {system}\nStrict Grounding. No Fabrication. Context: {prompt}\nUser: Return JSON message.",
|
|
@@ -53,52 +197,14 @@ async def call_llm_local(prompt: str, system: str = "") -> Optional[Dict]:
|
|
| 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=
|
| 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 |
-
|
| 64 |
-
if not OPENROUTER_KEYS: return None
|
| 65 |
-
|
| 66 |
-
# Tier 1 Hierarchy: Primary -> Secondary -> Backup
|
| 67 |
-
models = ["poolside/laguna-m.1:free", "tencent/hy3:free", "google/gemini-2.0-flash-exp:free"]
|
| 68 |
-
|
| 69 |
-
key = OPENROUTER_KEYS[0]
|
| 70 |
-
headers = {
|
| 71 |
-
"Authorization": f"Bearer {key}",
|
| 72 |
-
"Content-Type": "application/json",
|
| 73 |
-
"HTTP-Referer": "https://huggingface.co/spaces",
|
| 74 |
-
"X-Title": "Vera Engine Platinum"
|
| 75 |
-
}
|
| 76 |
-
|
| 77 |
-
async with httpx.AsyncClient() as client:
|
| 78 |
-
for model in models:
|
| 79 |
-
payload = {
|
| 80 |
-
"model": model,
|
| 81 |
-
"messages": [
|
| 82 |
-
{"role": "system", "content": system + "\n\nRETURN JSON ONLY. NO MARKDOWN. NO FABRICATION."},
|
| 83 |
-
{"role": "user", "content": prompt}
|
| 84 |
-
],
|
| 85 |
-
"response_format": {"type": "json_object"}
|
| 86 |
-
}
|
| 87 |
-
try:
|
| 88 |
-
res = await client.post(
|
| 89 |
-
"https://openrouter.ai/api/v1/chat/completions",
|
| 90 |
-
headers=headers,
|
| 91 |
-
json=payload,
|
| 92 |
-
timeout=10.0
|
| 93 |
-
)
|
| 94 |
-
if res.status_code == 200:
|
| 95 |
-
return json.loads(res.json()["choices"][0]["message"]["content"])
|
| 96 |
-
logger.warning(f"OpenRouter model {model} failed with status {res.status_code}")
|
| 97 |
-
except Exception as e:
|
| 98 |
-
logger.warning(f"OpenRouter error with model {model}: {e}")
|
| 99 |
-
continue
|
| 100 |
-
return None
|
| 101 |
-
|
| 102 |
def prune_message(text: str) -> str:
|
| 103 |
text = text.replace("\n", " ").strip()
|
| 104 |
if len(text) <= 315: return text
|
|
@@ -118,7 +224,6 @@ async def compose(category: dict, merchant: dict, trigger: dict, customer: Optio
|
|
| 118 |
lang_pref = ident.get('languages', ['en'])
|
| 119 |
hinglish_note = "USE NATURAL HINGLISH (50% English words, 50% Hindi script/transliteration)." if 'hi' in lang_pref else "USE PROFESSIONAL ENGLISH."
|
| 120 |
|
| 121 |
-
# Evidence Anchors: Extract primary facts to prevent hallucination
|
| 122 |
primary_metric = list(m.items())[0] if m else None
|
| 123 |
verbatim_source = str(payload)[:150] if payload else None
|
| 124 |
|
|
@@ -128,14 +233,11 @@ async def compose(category: dict, merchant: dict, trigger: dict, customer: Optio
|
|
| 128 |
if verbatim_source:
|
| 129 |
evidence_block += f"MANDATORY CITATION: '{verbatim_source}'. "
|
| 130 |
|
| 131 |
-
# Knowledge-Driven Prioritization (Universal principles, not judge-specific patterns)
|
| 132 |
beats = category.get("seasonal_beats", [])
|
| 133 |
trends = category.get("trend_signals", [])
|
| 134 |
knowledge_anchor = ""
|
| 135 |
-
if beats:
|
| 136 |
-
|
| 137 |
-
elif trends:
|
| 138 |
-
knowledge_anchor = f"MARKET TREND: {trends[0].get('note', '')}. "
|
| 139 |
|
| 140 |
sys_prompt = f"""Role: Senior Growth Strategist. Target: {prefix} {owner_name} in {ident.get('locality')}.
|
| 141 |
GOAL: Act as a peer partner. Help the merchant grow using facts.
|
|
@@ -144,16 +246,12 @@ RULES:
|
|
| 144 |
2. CONTEXT: {knowledge_anchor}Use this as the catalyst for the conversation.
|
| 145 |
3. NO FABRICATION: Do not invent numbers. If no metric, focus on the industry context.
|
| 146 |
4. TONE: {hinglish_note} Professional, high-empathy, peer-to-peer.
|
| 147 |
-
5. LIMIT: 280-310 characters.
|
| 148 |
-
JSON: {{"body": "...", "cta": "...", "rationale": "..."}}"""
|
| 149 |
|
| 150 |
prompt = f"CONTEXT: {json.dumps({'merchant': ident, 'trigger': trigger})}"
|
| 151 |
|
| 152 |
-
#
|
| 153 |
-
res = await
|
| 154 |
-
if not res:
|
| 155 |
-
logger.info("Local failed, trying API fallback...")
|
| 156 |
-
res = await call_llm_api(prompt, system=sys_prompt)
|
| 157 |
|
| 158 |
if not res:
|
| 159 |
body = f"{prefix} {owner_name}, noticed update in {ident.get('locality')}. Potential growth opportunity found. Review?"
|
|
@@ -162,20 +260,14 @@ JSON: {{"body": "...", "cta": "...", "rationale": "..."}}"""
|
|
| 162 |
res["body"] = prune_message(res.get("body", ""))
|
| 163 |
return res
|
| 164 |
|
|
|
|
| 165 |
@app.on_event("startup")
|
| 166 |
async def startup_event():
|
| 167 |
-
logger.info("Performing
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
logger.info("OpenRouter API Dry Run: SUCCESS")
|
| 173 |
-
else:
|
| 174 |
-
logger.warning("OpenRouter API Dry Run: FAILED (No response)")
|
| 175 |
-
except Exception as e:
|
| 176 |
-
logger.error(f"OpenRouter API Dry Run: ERROR - {e}")
|
| 177 |
-
else:
|
| 178 |
-
logger.warning("OpenRouter API Dry Run: SKIPPED (No keys found)")
|
| 179 |
|
| 180 |
@app.get("/v1/healthz")
|
| 181 |
async def healthz():
|
|
@@ -196,8 +288,8 @@ async def metadata():
|
|
| 196 |
except: pass
|
| 197 |
return {
|
| 198 |
"team_name": "Vera Lead Solver",
|
| 199 |
-
"version": "
|
| 200 |
-
"engine": "
|
| 201 |
"local_model": model_status
|
| 202 |
}
|
| 203 |
|
|
@@ -210,7 +302,6 @@ def push_context(data: ContextPayload):
|
|
| 210 |
async def tick(req: TickRequest):
|
| 211 |
tids = sorted(req.available_triggers, key=lambda t: PRIORITY_MAP.get(contexts["trigger"].get(t, {}).get("kind"), 0), reverse=True)
|
| 212 |
results = []
|
| 213 |
-
# Local inference is sequential for CPU safety. Limit to 1 to stay under 30s judge limit.
|
| 214 |
for tid in tids[:1]:
|
| 215 |
res = await process_trigger(tid)
|
| 216 |
if res: results.append(res)
|
|
@@ -228,13 +319,8 @@ async def process_trigger(trigger_id: str):
|
|
| 228 |
}
|
| 229 |
|
| 230 |
AUTO_REPLY_PATTERNS = [
|
| 231 |
-
r"thank you for contacting",
|
| 232 |
-
r"
|
| 233 |
-
r"this is an automated message",
|
| 234 |
-
r"business hours",
|
| 235 |
-
r"get back to you shortly",
|
| 236 |
-
r"auto-reply",
|
| 237 |
-
r"canned response"
|
| 238 |
]
|
| 239 |
|
| 240 |
HOSTILE_PATTERNS = [
|
|
@@ -244,33 +330,16 @@ HOSTILE_PATTERNS = [
|
|
| 244 |
@app.post("/v1/reply")
|
| 245 |
async def reply(req: Dict[str, Any]):
|
| 246 |
msg = req.get("message", "").lower()
|
| 247 |
-
|
| 248 |
-
# Fast-Path: Detect Hostility
|
| 249 |
for pattern in HOSTILE_PATTERNS:
|
| 250 |
if re.search(pattern, msg):
|
| 251 |
-
return {
|
| 252 |
-
"action": "end",
|
| 253 |
-
"rationale": f"Merchant expressed hostility/stop: {pattern}. Exiting gracefully."
|
| 254 |
-
}
|
| 255 |
-
|
| 256 |
-
# Fast-Path: Detect Merchant Auto-Reply
|
| 257 |
for pattern in AUTO_REPLY_PATTERNS:
|
| 258 |
if re.search(pattern, msg):
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
}
|
| 265 |
-
|
| 266 |
-
# Normal Path: Use call_llm_local for replies
|
| 267 |
-
prompt = f"Merchant said: {req.get('message', '')}. Turn: {req.get('turn_number')}. History: {req.get('history', [])}"
|
| 268 |
-
sys = """Role: Vera AI Growth Strategist.
|
| 269 |
-
GOAL: If merchant shows interest ('ok', 'lets do it', 'yes'), provide EXACT next step (e.g. 'I will renew your GBP now', 'I am setting up your patient recall').
|
| 270 |
-
RULE: Detect 'Intent Threshold'. NO MORE qualifying questions if they said 'YES' or equivalent.
|
| 271 |
-
TONE: Helpful, concise, assertive.
|
| 272 |
-
LIMIT: 100-150 chars."""
|
| 273 |
-
res = await call_llm_local(prompt, system=sys)
|
| 274 |
|
| 275 |
reply_body = res.get("body", "I'm on it. Setting that up for you now. Ready to proceed?") if res else "Understood. I'll get that started. Ready?"
|
| 276 |
return {"action": "send", "body": reply_body, "cta": "Reply YES"}
|
|
|
|
| 4 |
import asyncio
|
| 5 |
import time
|
| 6 |
import re
|
| 7 |
+
import random
|
| 8 |
from typing import List, Dict, Any, Optional
|
| 9 |
from fastapi import FastAPI, Request
|
| 10 |
from pydantic import BaseModel
|
|
|
|
| 13 |
|
| 14 |
load_dotenv()
|
| 15 |
|
| 16 |
+
# Vera Engine - Autonomous 5-Tier Reliability Build
|
|
|
|
| 17 |
logging.basicConfig(level=logging.INFO)
|
| 18 |
logger = logging.getLogger("VeraEngine")
|
| 19 |
|
| 20 |
app = FastAPI()
|
| 21 |
|
| 22 |
+
# --- Pydantic Schemas (Elastic) ---
|
| 23 |
class ContextPayload(BaseModel):
|
| 24 |
scope: Optional[str] = None
|
| 25 |
context_id: Optional[str] = None
|
|
|
|
| 29 |
class TickRequest(BaseModel):
|
| 30 |
available_triggers: List[str] = []
|
| 31 |
|
| 32 |
+
# --- Key Rotation Logic ---
|
| 33 |
+
class KeyRotator:
|
| 34 |
+
def __init__(self, provider: str):
|
| 35 |
+
self.provider = provider
|
| 36 |
+
self.keys = []
|
| 37 |
+
# Scan for PROVIDER_KEY_1, PROVIDER_KEY_2, etc.
|
| 38 |
+
for i in range(1, 11):
|
| 39 |
+
key = os.getenv(f"{provider.upper()}_KEY_{i}")
|
| 40 |
+
if key: self.keys.append(key)
|
| 41 |
+
# Fallback to generic PROVIDER_API_KEY
|
| 42 |
+
if not self.keys:
|
| 43 |
+
key = os.getenv(f"{provider.upper()}_API_KEY")
|
| 44 |
+
if key: self.keys.append(key)
|
| 45 |
+
|
| 46 |
+
self.index = 0
|
| 47 |
+
self.lock = asyncio.Lock()
|
| 48 |
+
|
| 49 |
+
async def get_key(self) -> Optional[str]:
|
| 50 |
+
if not self.keys: return None
|
| 51 |
+
async with self.lock:
|
| 52 |
+
key = self.keys[self.index]
|
| 53 |
+
return key
|
| 54 |
+
|
| 55 |
+
async def rotate(self):
|
| 56 |
+
async with self.lock:
|
| 57 |
+
if self.keys:
|
| 58 |
+
self.index = (self.index + 1) % len(self.keys)
|
| 59 |
+
logger.info(f"Rotated key for {self.provider}. New index: {self.index}")
|
| 60 |
+
|
| 61 |
+
# Initialize Rotators
|
| 62 |
+
rotators = {
|
| 63 |
+
"openrouter": KeyRotator("openrouter"),
|
| 64 |
+
"gemini": KeyRotator("gemini"),
|
| 65 |
+
"groq": KeyRotator("groq"),
|
| 66 |
+
"deepseek": KeyRotator("deepseek")
|
| 67 |
+
}
|
| 68 |
|
| 69 |
contexts = {"category": {}, "merchant": {}, "customer": {}, "trigger": {}}
|
| 70 |
+
llm_semaphore_local = asyncio.Semaphore(1)
|
| 71 |
|
| 72 |
PRIORITY_MAP = {
|
| 73 |
"drop_in_orders": 100, "revenue_drop": 95, "regulation_change": 90,
|
| 74 |
"recall_due": 80, "high_churn": 70, "competitor_action": 65, "perf_spike": 60
|
| 75 |
}
|
| 76 |
|
| 77 |
+
# --- Hierarchical Inference Engine ---
|
| 78 |
+
async def call_llm_chain(prompt: str, system: str = "") -> Optional[Dict]:
|
| 79 |
+
json_suffix = "\n\nRETURN JSON ONLY: {\"body\": \"...\", \"cta\": \"...\", \"rationale\": \"...\"}"
|
| 80 |
+
full_system = system + json_suffix
|
| 81 |
+
|
| 82 |
+
# Tier 1: OpenRouter
|
| 83 |
+
res = await call_tier_1_openrouter(prompt, full_system)
|
| 84 |
+
if res: return res
|
| 85 |
+
|
| 86 |
+
# Tier 2: Gemini REST
|
| 87 |
+
res = await call_tier_2_gemini(prompt, full_system)
|
| 88 |
+
if res: return res
|
| 89 |
+
|
| 90 |
+
# Tier 3: Groq
|
| 91 |
+
res = await call_tier_3_groq(prompt, full_system)
|
| 92 |
+
if res: return res
|
| 93 |
+
|
| 94 |
+
# Tier 4: DeepSeek
|
| 95 |
+
res = await call_tier_4_deepseek(prompt, full_system)
|
| 96 |
+
if res: return res
|
| 97 |
+
|
| 98 |
+
# Tier 5: Local Fallback
|
| 99 |
+
res = await call_tier_5_local(prompt, system) # Local has own prompt format
|
| 100 |
+
return res
|
| 101 |
+
|
| 102 |
+
async def call_tier_1_openrouter(prompt: str, system: str) -> Optional[Dict]:
|
| 103 |
+
rot = rotators["openrouter"]
|
| 104 |
+
key = await rot.get_key()
|
| 105 |
+
if not key: return None
|
| 106 |
+
|
| 107 |
+
models = ["poolside/laguna-m.1:free", "tencent/hy3:free", "google/gemini-2.0-flash-exp:free"]
|
| 108 |
+
async with httpx.AsyncClient() as client:
|
| 109 |
+
for model in models:
|
| 110 |
+
try:
|
| 111 |
+
res = await client.post(
|
| 112 |
+
"https://openrouter.ai/api/v1/chat/completions",
|
| 113 |
+
headers={
|
| 114 |
+
"Authorization": f"Bearer {key}",
|
| 115 |
+
"HTTP-Referer": "https://huggingface.co/spaces",
|
| 116 |
+
"X-Title": "Vera Engine Platinum"
|
| 117 |
+
},
|
| 118 |
+
json={
|
| 119 |
+
"model": model,
|
| 120 |
+
"messages": [{"role": "system", "content": system}, {"role": "user", "content": prompt}],
|
| 121 |
+
"response_format": {"type": "json_object"}
|
| 122 |
+
},
|
| 123 |
+
timeout=7.0
|
| 124 |
+
)
|
| 125 |
+
if res.status_code == 200:
|
| 126 |
+
return json.loads(res.json()["choices"][0]["message"]["content"])
|
| 127 |
+
if res.status_code in [401, 429]: await rot.rotate()
|
| 128 |
+
except: continue
|
| 129 |
+
return None
|
| 130 |
+
|
| 131 |
+
async def call_tier_2_gemini(prompt: str, system: str) -> Optional[Dict]:
|
| 132 |
+
rot = rotators["gemini"]
|
| 133 |
+
key = await rot.get_key()
|
| 134 |
+
if not key: return None
|
| 135 |
+
url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp:generateContent?key={key}"
|
| 136 |
+
async with httpx.AsyncClient() as client:
|
| 137 |
+
try:
|
| 138 |
+
res = await client.post(url, json={
|
| 139 |
+
"contents": [{"parts": [{"text": f"System: {system}\n\nUser: {prompt}"}]}]
|
| 140 |
+
}, timeout=7.0)
|
| 141 |
+
if res.status_code == 200:
|
| 142 |
+
text = res.json()["candidates"][0]["content"]["parts"][0]["text"]
|
| 143 |
+
# Clean potential markdown
|
| 144 |
+
text = re.sub(r'```json\n?|\n?```', '', text).strip()
|
| 145 |
+
return json.loads(text)
|
| 146 |
+
if res.status_code in [401, 429]: await rot.rotate()
|
| 147 |
+
except: pass
|
| 148 |
+
return None
|
| 149 |
+
|
| 150 |
+
async def call_tier_3_groq(prompt: str, system: str) -> Optional[Dict]:
|
| 151 |
+
rot = rotators["groq"]
|
| 152 |
+
key = await rot.get_key()
|
| 153 |
+
if not key: return None
|
| 154 |
+
async with httpx.AsyncClient() as client:
|
| 155 |
+
try:
|
| 156 |
+
res = await client.post("https://api.groq.com/openai/v1/chat/completions",
|
| 157 |
+
headers={"Authorization": f"Bearer {key}"},
|
| 158 |
+
json={
|
| 159 |
+
"model": "llama-3.3-70b-versatile",
|
| 160 |
+
"messages": [{"role": "system", "content": system}, {"role": "user", "content": prompt}],
|
| 161 |
+
"response_format": {"type": "json_object"}
|
| 162 |
+
}, timeout=7.0)
|
| 163 |
+
if res.status_code == 200:
|
| 164 |
+
return json.loads(res.json()["choices"][0]["message"]["content"])
|
| 165 |
+
if res.status_code in [401, 429]: await rot.rotate()
|
| 166 |
+
except: pass
|
| 167 |
+
return None
|
| 168 |
+
|
| 169 |
+
async def call_tier_4_deepseek(prompt: str, system: str) -> Optional[Dict]:
|
| 170 |
+
rot = rotators["deepseek"]
|
| 171 |
+
key = await rot.get_key()
|
| 172 |
+
if not key: return None
|
| 173 |
+
async with httpx.AsyncClient() as client:
|
| 174 |
+
try:
|
| 175 |
+
res = await client.post("https://api.deepseek.com/chat/completions",
|
| 176 |
+
headers={"Authorization": f"Bearer {key}"},
|
| 177 |
+
json={
|
| 178 |
+
"model": "deepseek-chat",
|
| 179 |
+
"messages": [{"role": "system", "content": system}, {"role": "user", "content": prompt}],
|
| 180 |
+
"response_format": {"type": "json_object"}
|
| 181 |
+
}, timeout=7.0)
|
| 182 |
+
if res.status_code == 200:
|
| 183 |
+
return json.loads(res.json()["choices"][0]["message"]["content"])
|
| 184 |
+
if res.status_code in [401, 429]: await rot.rotate()
|
| 185 |
+
except: pass
|
| 186 |
+
return None
|
| 187 |
+
|
| 188 |
+
async def call_tier_5_local(prompt: str, system: str) -> Optional[Dict]:
|
| 189 |
async with llm_semaphore_local:
|
| 190 |
+
logger.info("Local Fallback: Llama 3.2 (3B)")
|
| 191 |
payload = {
|
| 192 |
"model": "llama3.2",
|
| 193 |
"prompt": f"System: {system}\nStrict Grounding. No Fabrication. Context: {prompt}\nUser: Return JSON message.",
|
|
|
|
| 197 |
}
|
| 198 |
async with httpx.AsyncClient() as client:
|
| 199 |
try:
|
| 200 |
+
response = await client.post("http://127.0.0.1:11434/api/generate", json=payload, timeout=90.0)
|
| 201 |
if response.status_code == 200:
|
| 202 |
return json.loads(response.json().get('response', '{}'))
|
| 203 |
except Exception as e:
|
| 204 |
logger.error(f"Local Model Error: {e}")
|
| 205 |
return None
|
| 206 |
|
| 207 |
+
# --- Utilities ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
def prune_message(text: str) -> str:
|
| 209 |
text = text.replace("\n", " ").strip()
|
| 210 |
if len(text) <= 315: return text
|
|
|
|
| 224 |
lang_pref = ident.get('languages', ['en'])
|
| 225 |
hinglish_note = "USE NATURAL HINGLISH (50% English words, 50% Hindi script/transliteration)." if 'hi' in lang_pref else "USE PROFESSIONAL ENGLISH."
|
| 226 |
|
|
|
|
| 227 |
primary_metric = list(m.items())[0] if m else None
|
| 228 |
verbatim_source = str(payload)[:150] if payload else None
|
| 229 |
|
|
|
|
| 233 |
if verbatim_source:
|
| 234 |
evidence_block += f"MANDATORY CITATION: '{verbatim_source}'. "
|
| 235 |
|
|
|
|
| 236 |
beats = category.get("seasonal_beats", [])
|
| 237 |
trends = category.get("trend_signals", [])
|
| 238 |
knowledge_anchor = ""
|
| 239 |
+
if beats: knowledge_anchor = f"INDUSTRY CONTEXT: {beats[0].get('note', '')}. "
|
| 240 |
+
elif trends: knowledge_anchor = f"MARKET TREND: {trends[0].get('note', '')}. "
|
|
|
|
|
|
|
| 241 |
|
| 242 |
sys_prompt = f"""Role: Senior Growth Strategist. Target: {prefix} {owner_name} in {ident.get('locality')}.
|
| 243 |
GOAL: Act as a peer partner. Help the merchant grow using facts.
|
|
|
|
| 246 |
2. CONTEXT: {knowledge_anchor}Use this as the catalyst for the conversation.
|
| 247 |
3. NO FABRICATION: Do not invent numbers. If no metric, focus on the industry context.
|
| 248 |
4. TONE: {hinglish_note} Professional, high-empathy, peer-to-peer.
|
| 249 |
+
5. LIMIT: 280-310 characters."""
|
|
|
|
| 250 |
|
| 251 |
prompt = f"CONTEXT: {json.dumps({'merchant': ident, 'trigger': trigger})}"
|
| 252 |
|
| 253 |
+
# Run the 5-tier chain
|
| 254 |
+
res = await call_llm_chain(prompt, system=sys_prompt)
|
|
|
|
|
|
|
|
|
|
| 255 |
|
| 256 |
if not res:
|
| 257 |
body = f"{prefix} {owner_name}, noticed update in {ident.get('locality')}. Potential growth opportunity found. Review?"
|
|
|
|
| 260 |
res["body"] = prune_message(res.get("body", ""))
|
| 261 |
return res
|
| 262 |
|
| 263 |
+
# --- Endpoints ---
|
| 264 |
@app.on_event("startup")
|
| 265 |
async def startup_event():
|
| 266 |
+
logger.info("Performing Multi-Tier API Connectivity Audit...")
|
| 267 |
+
for name, rot in rotators.items():
|
| 268 |
+
key = await rot.get_key()
|
| 269 |
+
status = "FOUND" if key else "MISSING"
|
| 270 |
+
logger.info(f"Provider {name}: {status} ({len(rot.keys)} keys loaded)")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
|
| 272 |
@app.get("/v1/healthz")
|
| 273 |
async def healthz():
|
|
|
|
| 288 |
except: pass
|
| 289 |
return {
|
| 290 |
"team_name": "Vera Lead Solver",
|
| 291 |
+
"version": "5.0",
|
| 292 |
+
"engine": "5-Tier-Hierarchical-Engine",
|
| 293 |
"local_model": model_status
|
| 294 |
}
|
| 295 |
|
|
|
|
| 302 |
async def tick(req: TickRequest):
|
| 303 |
tids = sorted(req.available_triggers, key=lambda t: PRIORITY_MAP.get(contexts["trigger"].get(t, {}).get("kind"), 0), reverse=True)
|
| 304 |
results = []
|
|
|
|
| 305 |
for tid in tids[:1]:
|
| 306 |
res = await process_trigger(tid)
|
| 307 |
if res: results.append(res)
|
|
|
|
| 319 |
}
|
| 320 |
|
| 321 |
AUTO_REPLY_PATTERNS = [
|
| 322 |
+
r"thank you for contacting", r"we are currently away", r"this is an automated message",
|
| 323 |
+
r"business hours", r"get back to you shortly", r"auto-reply", r"canned response"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
]
|
| 325 |
|
| 326 |
HOSTILE_PATTERNS = [
|
|
|
|
| 330 |
@app.post("/v1/reply")
|
| 331 |
async def reply(req: Dict[str, Any]):
|
| 332 |
msg = req.get("message", "").lower()
|
|
|
|
|
|
|
| 333 |
for pattern in HOSTILE_PATTERNS:
|
| 334 |
if re.search(pattern, msg):
|
| 335 |
+
return {"action": "end", "rationale": f"Hostility: {pattern}"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 336 |
for pattern in AUTO_REPLY_PATTERNS:
|
| 337 |
if re.search(pattern, msg):
|
| 338 |
+
return {"action": "wait", "wait_seconds": 3600, "rationale": "Auto-reply"}
|
| 339 |
+
|
| 340 |
+
prompt = f"Merchant message: {req.get('message', '')}. Turn: {req.get('turn_number')}. History: {req.get('history', [])}"
|
| 341 |
+
sys = """Role: Vera AI. If merchant says 'ok/yes/do it', provide EXACT next step action. No more qualifying questions."""
|
| 342 |
+
res = await call_llm_chain(prompt, system=sys)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 343 |
|
| 344 |
reply_body = res.get("body", "I'm on it. Setting that up for you now. Ready to proceed?") if res else "Understood. I'll get that started. Ready?"
|
| 345 |
return {"action": "send", "body": reply_body, "cta": "Reply YES"}
|