vera-engine / scripts /composer.py
IDKHowToCodeFr's picture
fix(core): resolve caching, lazy load, and thread leak defects
86a56f9
Raw History Blame Contribute Delete
11.6 kB
import json
import logging
import asyncio
import time
from typing import Optional
import httpx
from .llm import call_nvidia, call_groq, call_gemini, call_local
from .prompts import FEW_SHOT_EXAMPLES, build_local_sys_prompt
logger = logging.getLogger("VeraArchitect")
_cache: dict = {}
def cache_key(category, merchant, trigger, customer):
return f"{merchant.get('id', 'm')}:{trigger.get('kind', 'k')}:{trigger.get('id', 't')}:{customer.get('id', 'c') if customer else 'none'}"
import re
def validate_output(res: dict, taboos: list) -> list[str]:
errors = []
if not res or not all(k in res for k in ["body", "cta", "rationale"]):
errors.append("missing keys body, cta, rationale")
return errors
body = res.get("body", "")
if len(body.split()) > 40: errors.append("body exceeds 40 words")
for t in taboos:
pattern = r'\b' + re.escape(t.lower()) + r'\b'
if re.search(pattern, body.lower()):
errors.append(f"contains taboo word: {t}")
if body.lower().count("reply") > 1: errors.append("multiple CTAs detected")
return errors
async def compose_with_fallback(prompt: str, full_system: str, local_system: str = None, deadline: float = None, taboos: list = None) -> dict:
if local_system is None:
local_system = full_system
if taboos is None:
taboos = []
start_time = time.time()
if deadline is None:
deadline = start_time + 27.0
tiers = [
("NVIDIA", call_nvidia, 5.0),
("Groq", call_groq, 4.0),
("Gemini", call_gemini, 4.0),
("Local", call_local, None)
]
for name, func, timeout in tiers:
elapsed = time.time() - start_time
remaining = deadline - (start_time + elapsed)
if timeout is not None and remaining <= timeout:
logger.warning(f"Skipping {name} due to time budget: {remaining:.1f}s left")
continue
try:
tier_start = time.time()
system_to_use = local_system if name == "Local" else full_system
async def _run_tier(p):
if timeout:
return await func(p, system_to_use, timeout)
else:
try:
return await asyncio.wait_for(func(p, system_to_use, deadline), timeout=deadline - time.time())
except asyncio.TimeoutError:
return None
res = await _run_tier(prompt)
# Validation and repair loop
if res:
errors = validate_output(res, taboos)
if errors:
logger.warning(f"{name} validation failed: {errors}. Retrying...")
repair_prompt = prompt + f"\n\nPrevious attempt failed: {', '.join(errors)}. Fix and retry."
retry_remaining = deadline - time.time()
req_timeout = timeout if timeout else 2.0
if retry_remaining >= req_timeout:
res = await _run_tier(repair_prompt)
res_errors = validate_output(res, taboos) if res else ["missing response"]
if res_errors:
logger.warning(f"{name} retry failed: {res_errors}. Falling to next tier.")
res = None
else:
logger.warning(f"Skipping retry for {name} due to time budget: {retry_remaining:.1f}s left")
res = None
tier_latency = (time.time() - tier_start) * 1000
if res and not validate_output(res, taboos):
logger.info(f"Served by {name} in {tier_latency:.0f}ms")
return res
else:
logger.warning(f"{name} returned invalid schema or content")
except httpx.HTTPStatusError as e:
if e.response.status_code == 429:
logger.warning(f"{name} failed: RateLimitError (429). Skipping to next tier immediately.")
elif e.response.status_code == 401:
logger.warning(f"{name} failed: AuthenticationError (401). Check API key. Skipping immediately.")
else:
logger.warning(f"{name} failed with HTTP {e.response.status_code}. Skipping immediately.")
except httpx.TimeoutException as e:
logger.warning(f"{name} timed out. Proceeding to next tier.")
except httpx.RequestError as e:
logger.warning(f"{name} failed: APIConnectionError / network error. Skipping immediately.")
except Exception as e:
logger.warning(f"{name} failed: {str(e)}")
logger.warning("All tiers failed, returning hardcoded default")
return {"body": "Following up soon.", "cta": "reply_yes", "rationale": "fallback_default"}
async def compose(category: dict, merchant: dict, trigger: dict, customer: Optional[dict] = None, deadline: float = None) -> dict:
key = cache_key(category, merchant, trigger, customer)
if key in _cache:
return _cache[key]
ident, perf = merchant.get("identity", {}), merchant.get("performance", {})
owner_name = ident.get("owner_first_name", "Partner")
active_offers = [o for o in merchant.get("offers", []) if o.get("status") == "active"]
# 1. Context Distiller
facts = []
if customer:
cust_ident = customer.get("identity", {})
cust_rel = customer.get("relationship", {})
facts.append(f"- Customer Name: {cust_ident.get('name', 'Customer')}")
facts.append(f"- Last Visit: {cust_rel.get('last_visit', 'Unknown')}")
if trigger.get("payload"): facts.append(f"- Trigger Context: {str(trigger['payload'])[:200]}")
else:
if perf.get("views"): facts.append(f"- Profile views (30d): {perf['views']}")
if perf.get("ctr"): facts.append(f"- Your Click-Through Rate: {perf['ctr']} (Peer avg: {category.get('peer_stats', {}).get('avg_ctr', 'unknown')})")
if active_offers: facts.append(f"- Active offer available: {active_offers[0].get('title')}")
if trigger.get("payload"): facts.append(f"- Trigger Reason: {str(trigger['payload'])[:150]}")
fact_string = "\n".join(facts) if facts else "- No specific data. Use curiosity hook."
# 2. Dynamic Tone & Taboos
voice = category.get("voice", {})
tone = voice.get("tone", "professional peer")
taboos_list = voice.get("taboos", ["guaranteed", "100%", "click here", "promise"])
taboos = ", ".join(taboos_list)
prefix = "Dr. " if category.get('slug') == 'dentists' else ""
lang_pref = ident.get('languages', ['en'])
hinglish_note = "USE NATURAL HINGLISH (code-mix English words and Hindi script/transliteration)." if 'hi' in lang_pref else "USE PROFESSIONAL ENGLISH."
# 3. Engagement Lever Selection
kind = trigger.get('kind', '')
if 'dip' in kind:
compulsion = "Loss Aversion (e.g., 'You are missing out on X...')"
few_shot_key = "dip"
elif 'spike' in kind or 'milestone' in kind:
compulsion = "Social Proof / Validation (e.g., 'Everyone is doing X...')"
few_shot_key = "spike"
elif 'recall' in kind:
compulsion = "Effort externalization ('I prepared these slots for you...')"
few_shot_key = "recall"
elif 'digest' in kind:
compulsion = "Curiosity gap ('A new method improves X by Y%...')"
few_shot_key = "digest"
else:
compulsion = "Curiosity ('Want to see how?')"
few_shot_key = "digest"
# 4. Few-Shot Example Injection
few_shot_data = FEW_SHOT_EXAMPLES.get(few_shot_key, FEW_SHOT_EXAMPLES["digest"])
few_shot_json = json.dumps(few_shot_data, indent=2)
contrast_example = """
BAD (vague): 'We have a great offer for you!'
GOOD (specific): 'Your CTR dropped 12% this week vs 18% category avg — want the 3 fixes?'
"""
scoring_rubric = f"You will be scored on: (1) SPECIFICITY - exact numbers/names/dates used, (2) GROUNDING - zero fabricated facts, (3) ENGAGEMENT - reply-worthy CTA, (4) TONE - matches {tone} register. Optimize for all four."
if customer:
cust_name = customer.get("identity", {}).get("name", "Customer")
full_sys_prompt = f"""Role: {prefix}{owner_name} (Merchant). Target: {cust_name} (Customer).
Goal: WhatsApp engagement using {compulsion}. Send on behalf of {prefix}{owner_name}.
CRITICAL RULES:
{scoring_rubric}
1. TRIGGER PRIMACY: Address the {kind} trigger in the very first sentence.
2. HYPER-SPECIFICITY: You MUST quote exact numbers, metrics, or dates from the FACT SHEET (e.g., '38% better', '30 views', 'Oct issue'). Do not generalize.
3. MAX ENGAGEMENT: Keep the message under 3 sentences. End with a frictionless, curiosity-driven question (e.g., 'Want to see the data?', 'Should I draft a reply?').
4. NO FABRICATION: Only use data from the FACT SHEET.
5. LENGTH: body must be 25-40 words. WhatsApp messages over 40 words get ignored — be ruthlessly concise.
6. TONE: {hinglish_note} Be conversational, not corporate.
7. CTA: Exactly ONE clear call-to-action (e.g., 'Reply YES to X').
8. SELF-CHECK: before writing, mentally list which FACT SHEET items you will cite. Do not include any name/number/date not in that list.
9. TABOOS: Do not use these words: {taboos}.
WARNING: YOU WILL BE PENALIZED IF YOU USE ANY OF THESE WORDS: {taboos}
Example Output format for a good message:
{few_shot_json}
{contrast_example}"""
local_sys_prompt = build_local_sys_prompt(tone, kind, compulsion, taboos, hinglish_note, prefix, owner_name, few_shot_json, cust_name=cust_name)
else:
full_sys_prompt = f"""Role: {tone}. Target: {prefix}{owner_name} (Merchant) in {ident.get('locality', 'your area')}.
Goal: WhatsApp engagement using {compulsion}.
CRITICAL RULES:
{scoring_rubric}
1. TRIGGER PRIMACY: Address the {kind} trigger in the very first sentence.
2. HYPER-SPECIFICITY: You MUST quote exact numbers, metrics, or dates from the FACT SHEET (e.g., '38% better', '30 views', 'Oct issue'). Do not generalize.
3. MAX ENGAGEMENT: Keep the message under 3 sentences. End with a frictionless, curiosity-driven question (e.g., 'Want to see the data?', 'Should I activate this?').
4. NO FABRICATION: Only use data from the FACT SHEET.
5. LENGTH: body must be 25-40 words. WhatsApp messages over 40 words get ignored — be ruthlessly concise.
6. TONE: {hinglish_note} Be conversational, not corporate.
7. CTA: Exactly ONE clear call-to-action (e.g., 'Reply YES to X').
8. SELF-CHECK: before writing, mentally list which FACT SHEET items you will cite. Do not include any name/number/date not in that list.
9. TABOOS: Do not use these words: {taboos}.
WARNING: YOU WILL BE PENALIZED IF YOU USE ANY OF THESE WORDS: {taboos}
Example Output format for a good message:
{few_shot_json}
{contrast_example}"""
local_sys_prompt = build_local_sys_prompt(tone, kind, compulsion, taboos, hinglish_note, prefix, owner_name, few_shot_json)
prompt = f"FACT SHEET:\n{fact_string}\n\nTask: Draft a WhatsApp message using ONLY the facts above. Be highly specific with numbers and dates."
res = await compose_with_fallback(prompt, full_sys_prompt, local_sys_prompt, deadline=deadline, taboos=taboos_list)
# Add sender attribution required by the challenge
res["send_as"] = "merchant_on_behalf" if customer else "vera"
if res.get("rationale") != "fallback_default":
_cache[key] = res
return res