BackendVera / app.py
TheVera's picture
Update app.py
bcf0c1f verified
Raw
History Blame Contribute Delete
23.2 kB
# app.py β€” VERA backend (improved)
import os
import re
import uvicorn
import json
import time
import hashlib
import logging
import requests
from collections import deque
import uuid
from fastapi.staticfiles import StaticFiles
from fastapi import FastAPI, Request, Header
from fastapi.responses import JSONResponse, FileResponse
from fastapi.middleware.cors import CORSMiddleware
from sentence_transformers import SentenceTransformer
from pinecone import Pinecone, ServerlessSpec
# ───────────────────────────────── Logging ─────────────────────────────────────
logging.basicConfig(level=logging.INFO)
log = logging.getLogger("vera-backend")
# ───────────────────────────────── Env / Config ───────────────────────────────
PINECONE_API_KEY = os.getenv("PINECONE_API_KEY")
HF_API_KEY = os.getenv("HF_API_KEY")
# realtime search (pick one you have a key for; Tavily recommended)
TAVILY_API_KEY = os.getenv("TAVILY_API_KEY") # https://www.tavily.com/
BING_API_KEY = os.getenv("BING_API_KEY")
# Optional: You can also add SERPAPI_API_KEY / BING_API_KEY if you prefer
URL_DECISION = os.getenv("URL_DECISION", "https://thevera-decision-making-model.hf.space/generate_text")
URL_SUMMARIZER = os.getenv("URL_SUMMARIZER", "https://thevera-get-best-answer.hf.space/summarize")
INSTR_DECISION = os.getenv("INSTR_DECISION", "instructions_L1.txt")
INSTR_HEALTH = os.getenv("INSTR_HEALTH", "instructions_health.txt")
REGION = os.getenv("PINECONE_REGION", "us-east-1")
INDEX_NAME = os.getenv("PINECONE_INDEX", "veradb")
EMBED_MODEL_ID = os.getenv("EMBED_MODEL_ID", "distiluse-base-multilingual-cased-v1")
EMBED_DIM = int(os.getenv("EMBED_DIM", "512"))
# Memory / session config
SESSION_TTL_SEC = int(os.getenv("SESSION_TTL_SEC", "7200")) # 2 hours
SESSION_MAX_TURNS = int(os.getenv("SESSION_MAX_TURNS", "10")) # keep last 10 items (user+assistant)
# Timeouts
HTTP_TIMEOUT = int(os.getenv("HTTP_TIMEOUT", "60"))
# ───────────────────────────────── FastAPI App ────────────────────────────────
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # or restrict to your static Space origin
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
os.makedirs("generated", exist_ok=True)
app.mount("/generated", StaticFiles(directory="generated"), name="generated")
# ───────────────────────────────── Dependencies ───────────────────────────────
# Pinecone (guard if key missing)
pc = None
index = None
if PINECONE_API_KEY:
try:
pc = Pinecone(api_key=PINECONE_API_KEY)
if INDEX_NAME not in pc.list_indexes().names():
pc.create_index(
name=INDEX_NAME,
dimension=EMBED_DIM,
metric="cosine",
spec=ServerlessSpec(cloud="aws", region=REGION),
)
index = pc.Index(INDEX_NAME)
log.info("Pinecone index ready: %s", INDEX_NAME)
except Exception:
log.exception("Pinecone init failed")
else:
log.warning("PINECONE_API_KEY not set β€” retrieval disabled.")
# Embeddings
embed_model = SentenceTransformer(EMBED_MODEL_ID)
# ───────────────────────────────── Utilities ──────────────────────────────────
def read_file(path: str) -> str:
try:
with open(path, "r", encoding="utf-8") as f:
return f.read()
except Exception as e:
log.error("Failed to read %s: %s", path, e)
return ""
def post_hf(url: str, payload: dict):
"""Call HF microservice with bearer if provided; return text or ''."""
try:
s = requests.Session()
if HF_API_KEY:
s.headers.update({"Authorization": f"Bearer {HF_API_KEY}"})
r = s.post(url, json=payload, timeout=HTTP_TIMEOUT)
if r.status_code != 200:
log.error("HF call failed %s: %s", r.status_code, r.text[:240])
return ""
data = r.json()
return data.get("response") or data.get("answer") or ""
except Exception:
log.exception("HF call exception")
return ""
def detect_language(text: str) -> str:
# Devanagari β†’ 'hi' (Hindi/Sanskrit), else English
return "hi" if any("\u0900" <= ch <= "\u097F" for ch in text) else "en"
def clamp(n, lo, hi):
try:
n = float(n)
except Exception:
return lo
return max(lo, min(hi, n))
# ─────────────────────────────── In-memory sessions ───────────────────────────
# { session_id: {"history": deque[{"role": "user"/"assistant", "content": str}], "last_category": str, "t": float} }
SESSIONS = {}
def prune_sessions():
now = time.time()
stale = [sid for sid, s in SESSIONS.items() if (now - s.get("t", now)) > SESSION_TTL_SEC]
for sid in stale:
SESSIONS.pop(sid, None)
def get_session_id(request: Request, body: dict) -> str:
sid = (body.get("session_id") or "").strip()
if sid:
return sid
# derive from ip + ua
ip = request.client.host if request.client else "0.0.0.0"
ua = request.headers.get("user-agent", "")
raw = f"{ip}|{ua}"[:256]
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:32]
def append_history(session_id: str, role: str, content: str):
if not session_id:
return
prune_sessions()
s = SESSIONS.setdefault(session_id, {"history": deque(maxlen=SESSION_MAX_TURNS*2), "last_category": None, "t": time.time()})
s["history"].append({"role": role, "content": content})
s["t"] = time.time()
def recent_history_text(session_id: str, max_chars: int = 4000) -> str:
s = SESSIONS.get(session_id)
if not s:
return ""
parts = []
for item in list(s["history"])[-SESSION_MAX_TURNS*2:]:
parts.append(f'{item["role"]}: {item["content"]}')
if sum(len(p) for p in parts) > max_chars:
break
return "\n".join(parts)
def set_last_category(session_id: str, cat: str):
if not session_id:
return
s = SESSIONS.setdefault(session_id, {"history": deque(maxlen=SESSION_MAX_TURNS*2), "last_category": None, "t": time.time()})
s["last_category"] = cat
s["t"] = time.time()
def get_last_category(session_id: str):
s = SESSIONS.get(session_id)
return s.get("last_category") if s else None
# ─────────────────────────────── Decision helpers ─────────────────────────────
def decide_category(user_text: str, session_id: str = "") -> str:
"""
Returns exactly one of:
health_wellness, spiritual_guidance, generate_image, realtime_query, other_query
"""
# Quick heuristics (avoid a remote call for obvious cases)
lt = user_text.strip().lower()
if lt.startswith("/image") or lt.startswith("image:"):
return "generate_image"
if any(k in lt for k in ["latest", "today", "this week", "breaking", "price now", "live score"]):
return "realtime_query"
# Follow-up heuristics: short/anaphoric β†’ reuse last category
if len(lt) < 40 and re.search(r"\b(continue|and\?|more|what about|same|that|it|those)\b", lt):
prev = get_last_category(session_id)
if prev:
return prev
# Model decision
instructions = read_file(INSTR_DECISION)
payload = {"prompt": user_text, "instructions": instructions}
raw = (post_hf(URL_DECISION, payload) or "").strip().lower()
mapping = {
"health_wellness": "health_wellness",
"spiritual_guidance": "spiritual_guidance",
"generate_image": "generate_image",
"realtime_query": "realtime_query",
"other_query": "other_query",
}
return mapping.get(raw, "other_query")
def decide_health_namespace(user_text: str) -> str:
"""
Returns ayurvedic, western, or both (lowercase).
"""
instructions = read_file(INSTR_HEALTH)
payload = {"prompt": user_text, "instructions": instructions}
raw = (post_hf(URL_DECISION, payload) or "").strip().lower()
return raw if raw in {"ayurvedic", "western", "both"} else "both"
# ───────────────────────────── Pinecone retrieval ─────────────────────────────
def query_pinecone(namespace: str, prompt: str, top_k: int = 12):
if not index:
return []
try:
vec = embed_model.encode(prompt).tolist()
res = index.query(namespace=namespace, vector=vec, top_k=top_k, include_metadata=True)
return res.get("matches", []) or []
except Exception:
log.exception("Pinecone query failed for ns=%s", namespace)
return []
def build_context(matches, cap_chars: int = 6000) -> str:
ctx, size = [], 0
for m in matches:
meta = m.get("metadata") or {}
t = (meta.get("text") or "").strip()
if not t:
continue
ctx.append(t)
size += len(t)
if size >= cap_chars:
break
return "\n\n".join(ctx)
# ───────────────────────────── Realtime web search ────────────────────────────
def search_bing(query: str, k: int = 5):
items = []
if not BING_API_KEY:
return items
try:
r = requests.get(
"https://api.bing.microsoft.com/v7.0/search",
headers={"Ocp-Apim-Subscription-Key": BING_API_KEY},
params={"q": query, "mkt": "en-US", "count": k, "textDecorations": False, "answerCount": k},
timeout=HTTP_TIMEOUT
)
if r.status_code == 200:
j = r.json()
for w in (j.get("webPages", {}).get("value", [])[:k]):
items.append({
"title": w.get("name") or "",
"url": w.get("url") or "",
"content": w.get("snippet") or "",
})
except Exception:
log.exception("Bing search failed")
return items
def search_web(query: str, k: int = 5):
"""
Returns list of {title, url, content}.
Priority: Tavily -> DuckDuckGo IA -> Wikipedia summaries
"""
results = []
# 1) Tavily
if TAVILY_API_KEY:
try:
r = requests.post(
"https://api.tavily.com/search",
json={"api_key": TAVILY_API_KEY, "query": query, "max_results": k, "include_answer": False},
timeout=HTTP_TIMEOUT,
)
if r.status_code == 200:
data = r.json()
for item in (data.get("results") or [])[:k]:
results.append({
"title": item.get("title") or "",
"url": item.get("url") or "",
"content": item.get("content") or item.get("snippet") or "",
})
except Exception:
log.exception("Tavily search failed")
# 2) Bing fallback
if not results:
results = search_bing(query, k=k)
# 3) DuckDuckGo Instant Answer (no key)
if not results:
try:
r = requests.get(
"https://api.duckduckgo.com/",
params={"q": query, "format": "json", "no_html": 1, "no_redirect": 1},
timeout=HTTP_TIMEOUT,
)
if r.status_code == 200:
data = r.json()
if data.get("AbstractText"):
results.append({
"title": data.get("Heading") or "",
"url": data.get("AbstractURL") or "",
"content": data.get("AbstractText") or "",
})
for rt in (data.get("RelatedTopics") or []):
if isinstance(rt, dict) and rt.get("Text"):
results.append({
"title": rt.get("Text")[:80],
"url": (rt.get("FirstURL") or ""),
"content": rt.get("Text") or "",
})
results = results[:k]
except Exception:
log.exception("DuckDuckGo IA failed")
# 4) Wikipedia (simple)
if not results:
try:
r = requests.get("https://en.wikipedia.org/w/api.php",
params={"action":"opensearch","search":query,"limit":k,"namespace":0,"format":"json"},
timeout=HTTP_TIMEOUT)
if r.status_code == 200:
data = r.json()
titles = data[1] or []
urls = data[3] or []
for t,u in list(zip(titles, urls))[:k]:
results.append({"title": t, "url": u, "content": t})
except Exception:
log.exception("Wikipedia search failed")
return results[:k]
def summarize_search(query: str, lang: str) -> str:
items = search_web(query, k=5)
if not items:
return "I couldn't find current information right now. Please try rephrasing or ask a more specific query."
# Compose context for the summarizer
ctx_parts = []
for it in items:
ctx_parts.append(f"{it['title']}\n{it['content']}\nSource: {it['url']}\n")
context = "\n\n".join(ctx_parts)
payload = {
"context": context,
"question": f"Using the sources above, answer this query concisely and include a friendly tone.\nQuery: {query}",
"language": "Hindi" if lang == "hi" else "English",
"reply_type": "brief",
}
ans = (post_hf(URL_SUMMARIZER, payload) or "").strip()
# Add sources footer
src_lines = []
for i, it in enumerate(items[:3], 1):
if not it.get("url"):
continue
src_lines.append(f"{i}. {it['title'] or it['url']} β€” {it['url']}")
if src_lines:
ans += "\n\nSources:\n" + "\n".join(src_lines)
return ans or "No answer available."
# ───────────────────────────── Domain handlers ────────────────────────────────
def handle_spiritual(user_text: str, lang: str) -> str:
# Try existing namespace "spritual" then sane "spiritual"
matches = query_pinecone("spritual", user_text, top_k=20)
if not matches:
matches = query_pinecone("spiritual", user_text, top_k=20)
if not matches:
# Positive fallback
base = "Hindi" if lang == "hi" else "English"
payload = {
"context": "",
"question": f"Provide compassionate spiritual guidance. Question: {user_text}",
"language": base,
"reply_type": "detailed",
}
return (post_hf(URL_SUMMARIZER, payload) or "Let's reflect on this with kindness and clarity.").strip()
context = build_context(matches)
reply_type = "brief" if re.search(r"\b(brief|short|tl;dr)\b", user_text.lower()) else "detailed"
payload = {
"context": context,
"question": user_text,
"language": "Hindi" if (lang == "hi" and "in english" not in user_text.lower()) else "English",
"reply_type": reply_type,
}
ans = post_hf(URL_SUMMARIZER, payload) or ""
return ans.strip() or "May this perspective be helpful."
def merge_health(ay, we):
merged = (ay or []) + (we or [])
merged.sort(key=lambda m: m.get("score", 0), reverse=True)
return merged[:12]
def handle_health(user_text: str, lang: str, image_text: str = "") -> str:
choice = decide_health_namespace(user_text)
if choice == "ayurvedic":
matches = query_pinecone("ayurvedic", user_text, 14)
elif choice == "western":
matches = query_pinecone("western", user_text, 14)
else:
ay = query_pinecone("ayurvedic", user_text, 10)
we = query_pinecone("western", user_text, 10)
matches = merge_health(ay, we)
base_lang = "Hindi" if lang == "hi" else "English"
reply_type = "detailed" if any(k in user_text.lower() for k in ["detail", "explain", "why", "how"]) else "brief"
context = build_context(matches) if matches else ""
if image_text:
context = (context + "\n\nExtracted from health image:\n" + image_text).strip()
payload = {
"context": context,
"question": user_text,
"language": base_lang,
"reply_type": reply_type,
}
ans = (post_hf(URL_SUMMARIZER, payload) or "").strip()
disclaimer = ("\n\n**Note:** This is educational, not a medical diagnosis. "
"Consult a clinician for personalized care.")
return ans + disclaimer if ans else "I couldn't parse enough contextβ€”could you add a few details?"
def handle_realtime(user_text: str, lang: str) -> str:
# Real search + summarization
return summarize_search(user_text, lang)
def handle_other(user_text: str, lang: str, context_extra: str = "") -> str:
payload = {
"context": context_extra or "",
"question": user_text,
"language": "Hindi" if lang == "hi" else "English",
"reply_type": "detailed",
}
ans = post_hf(URL_SUMMARIZER, payload) or ""
return ans.strip() or "Here’s a helpful overview."
def generate_image_free(prompt: str) -> str:
"""Return a URL to an image for the prompt. Prefers HF Inference -> fallback Pollinations."""
# 1) Hugging Face Inference API (binary image)
if HF_API_KEY:
try:
r = requests.post(
"https://api-inference.huggingface.co/models/stabilityai/stable-diffusion-2-1",
headers={
"Authorization": f"Bearer {HF_API_KEY}",
"Accept": "image/png"
},
json={"inputs": prompt},
timeout=120
)
if r.status_code == 200 and r.content:
fname = f"{uuid.uuid4().hex}.png"
path = os.path.join("generated", fname)
with open(path, "wb") as f:
f.write(r.content)
return f"/generated/{fname}"
else:
log.warning("HF image gen failed %s: %s", r.status_code, r.text[:200])
except Exception:
log.exception("HF image gen exception")
# 2) Pollinations (no key; public)
try:
from urllib.parse import quote
return f"https://image.pollinations.ai/prompt/{quote(prompt)}"
except Exception:
return ""
def handle_image(user_text: str, lang: str) -> str:
prompt = user_text.replace("/image", "").strip() or user_text
url = generate_image_free(prompt)
if url:
return f"Here’s your image for: **{prompt}**\n\n![{prompt}]({url})"
return f"I couldn’t generate the image right now. Try again in a moment.\n\n**Prompt:** {prompt}"
# ───────────────────────────── Routes ─────────────────────────────────────────
@app.get("/")
def root():
return {"ok": True, "service": "vera-backend"}
@app.get("/healthz")
def healthz():
return {"ok": True}
@app.post("/reset_session")
async def reset_session(request: Request):
body = await request.json()
sid = (body.get("session_id") or "").strip()
if sid and sid in SESSIONS:
SESSIONS.pop(sid, None)
return {"ok": True, "reset": True}
return {"ok": True, "reset": False}
@app.post("/generate_result")
async def generate_result(request: Request, user_agent: str = Header(default="")):
body = await request.json()
raw_text = (body.get("message") or "").strip()
image_text = (body.get("image_text") or "").strip()
if not raw_text:
return JSONResponse(status_code=400, content={"response": "No message provided"})
session_id = get_session_id(request, body)
# Parse a forced category from the header line
allowed = {"health_wellness","spiritual_guidance","generate_image","realtime_query","other_query"}
forcible = None
text = raw_text
if text.startswith("::category="):
m = re.match(r"^::category\s*=\s*([a-zA-Z_]+)(?:\s+|[\r\n]+)?(.*)$", text, flags=re.S)
if m:
key = m.group(1).strip().lower()
remainder = (m.group(2) or "").strip()
if key in allowed:
forcible = key
text = remainder
# Also allow JSON override
force_from_body = (body.get("force_category") or "").strip().lower() or None
if not forcible and force_from_body in allowed:
forcible = force_from_body
effective_text = text or raw_text
lang = detect_language(effective_text)
# Decide category (with session-aware heuristics)
decision = forcible or decide_category(effective_text, session_id=session_id)
set_last_category(session_id, decision)
# Keep memory
append_history(session_id, "user", effective_text)
# Build optional conversation context for generic answers
convo_ctx = recent_history_text(session_id)
handlers = {
"spiritual_guidance": lambda t, l: handle_spiritual(t, l),
"health_wellness": lambda t, l: handle_health(t, l, image_text=image_text),
"realtime_query": lambda t, l: handle_realtime(t, l),
"generate_image": lambda t, l: handle_image(t, l),
"other_query": lambda t, l: handle_other(t, l, context_extra=convo_ctx),
}
func = handlers.get(decision, handlers["other_query"])
try:
answer = func(effective_text, lang)
append_history(session_id, "assistant", answer)
return JSONResponse(content={"response": f"{decision}\n{answer}", "language": lang, "session_id": session_id})
except Exception as e:
log.exception("Handler failed")
return JSONResponse(
status_code=500,
content={"response": f"other_query\nAn error occurred, but here's a quick tip: please try again in a moment.", "language": lang, "session_id": session_id}
)
# ───────────────────────────── Main ───────────────────────────────────────────
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", 7860)))