initiated mistral and integrated classifier, cross fingers
Browse files- components/LLMs/Classifier.py +116 -0
- components/LLMs/Mistral.py +71 -32
- components/handlers/__init__.py +0 -0
- components/handlers/whatsapp_handlers.py +90 -0
- routes/api/whatsapp_webhook.py +47 -107
components/LLMs/Classifier.py
ADDED
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# components/LLMs/Classifier.py
|
2 |
+
# Zero-shot intent classifier using Together AI (Mistral-7B-Instruct)
|
3 |
+
|
4 |
+
from typing import Dict, Literal, Tuple
|
5 |
+
import json
|
6 |
+
import re
|
7 |
+
|
8 |
+
from components.LLMs.Mistral import MistralTogetherClient, build_messages
|
9 |
+
|
10 |
+
# ---- Define your canonical intents (keep this list stable) ----
|
11 |
+
Intent = Literal[
|
12 |
+
"headlines_request", # user wants today's headlines/digest/news
|
13 |
+
"preferences_update", # change topics, regions, time, etc.
|
14 |
+
"greeting", # hi/hello/hey
|
15 |
+
"help", # how to use / what can you do
|
16 |
+
"small_talk", # chitchat, jokes, idle talk
|
17 |
+
"chat_question", # general Q&A about news/economy/etc.
|
18 |
+
"unsubscribe", # stop/opt-out
|
19 |
+
"other" # anything else
|
20 |
+
]
|
21 |
+
|
22 |
+
INTENT_SET = [
|
23 |
+
"headlines_request",
|
24 |
+
"preferences_update",
|
25 |
+
"greeting",
|
26 |
+
"help",
|
27 |
+
"small_talk",
|
28 |
+
"chat_question",
|
29 |
+
"unsubscribe",
|
30 |
+
"other",
|
31 |
+
]
|
32 |
+
|
33 |
+
# Optional: synonyms that you may normalize before passing to downstream logic
|
34 |
+
TRIGGER_SYNONYMS = {
|
35 |
+
"headlines_request": ["headlines", "digest", "daily", "news", "today's headlines", "view headlines"],
|
36 |
+
"unsubscribe": ["stop", "opt out", "unsubscribe", "cancel"],
|
37 |
+
"help": ["help", "how it works", "what can you do"],
|
38 |
+
"greeting": ["hi", "hello", "hey"],
|
39 |
+
}
|
40 |
+
|
41 |
+
SYSTEM_PROMPT = (
|
42 |
+
"You are an intent classifier for a WhatsApp news assistant called NuseAI. "
|
43 |
+
"Choose exactly one label from the allowed list, and produce STRICT JSON: "
|
44 |
+
'{"label": "<one_of_allowed>", "confidence": <0..1>, "reason": "<short>"}.\n\n'
|
45 |
+
f"ALLOWED_LABELS = {INTENT_SET}.\n"
|
46 |
+
"- headlines_request: user asks for today's headlines, digest, news, or to view/open the feed.\n"
|
47 |
+
"- preferences_update: user wants to set or change interests, topics, regions, or delivery time.\n"
|
48 |
+
"- greeting: greetings like hi/hello/hey.\n"
|
49 |
+
"- help: asks how to use the bot, commands, or capabilities.\n"
|
50 |
+
"- small_talk: casual chat or jokes, not seeking info.\n"
|
51 |
+
"- chat_question: general info or Q&A unrelated to getting today's digest.\n"
|
52 |
+
"- unsubscribe: stop/opt-out messages.\n"
|
53 |
+
"- other: anything that doesn't fit.\n\n"
|
54 |
+
"Rules:\n"
|
55 |
+
"1) Output valid JSON ONLY (no backticks, no extra text).\n"
|
56 |
+
"2) confidence in [0,1].\n"
|
57 |
+
"3) Prefer 'headlines_request' for any phrasing that implies they want today's headlines/digest.\n"
|
58 |
+
)
|
59 |
+
|
60 |
+
USER_INSTRUCTION_TEMPLATE = (
|
61 |
+
"Classify this WhatsApp message into one label.\n"
|
62 |
+
"Message: \"{text}\"\n"
|
63 |
+
"Return JSON only."
|
64 |
+
)
|
65 |
+
|
66 |
+
class ZeroShotClassifier:
|
67 |
+
def __init__(self):
|
68 |
+
self.llm = MistralTogetherClient()
|
69 |
+
|
70 |
+
def classify(self, text: str) -> Tuple[Intent, Dict]:
|
71 |
+
# Build messages
|
72 |
+
user_prompt = USER_INSTRUCTION_TEMPLATE.format(text=text.strip())
|
73 |
+
msgs = build_messages(user_prompt, SYSTEM_PROMPT)
|
74 |
+
|
75 |
+
# Call LLM (deterministic)
|
76 |
+
raw, _usage = self.llm.chat(msgs, temperature=0.0, max_tokens=200)
|
77 |
+
|
78 |
+
# Parse JSON robustly
|
79 |
+
payload = self._safe_parse_json(raw)
|
80 |
+
label = payload.get("label", "other")
|
81 |
+
confidence = float(payload.get("confidence", 0.5))
|
82 |
+
reason = payload.get("reason", "")
|
83 |
+
|
84 |
+
# Guardrails: enforce canonical label
|
85 |
+
if label not in INTENT_SET:
|
86 |
+
label = "other"
|
87 |
+
|
88 |
+
# Light normalization for obvious cases
|
89 |
+
norm = text.lower().strip()
|
90 |
+
if label == "other":
|
91 |
+
if any(s in norm for s in ["headline", "digest", "news", "today"]):
|
92 |
+
label = "headlines_request"
|
93 |
+
elif any(s in norm for s in ["unsubscribe", "opt out", "stop"]):
|
94 |
+
label = "unsubscribe"
|
95 |
+
elif re.fullmatch(r"(hi|hello|hey)[!.]?", norm):
|
96 |
+
label = "greeting"
|
97 |
+
|
98 |
+
result = {"label": label, "confidence": confidence, "reason": reason, "raw": raw}
|
99 |
+
return label, result
|
100 |
+
|
101 |
+
@staticmethod
|
102 |
+
def _safe_parse_json(s: str) -> Dict:
|
103 |
+
# Try direct parse
|
104 |
+
try:
|
105 |
+
return json.loads(s)
|
106 |
+
except Exception:
|
107 |
+
pass
|
108 |
+
# Try to extract the first {...} block
|
109 |
+
try:
|
110 |
+
start = s.find("{")
|
111 |
+
end = s.rfind("}")
|
112 |
+
if start != -1 and end != -1:
|
113 |
+
return json.loads(s[start : end + 1])
|
114 |
+
except Exception:
|
115 |
+
pass
|
116 |
+
return {}
|
components/LLMs/Mistral.py
CHANGED
@@ -1,40 +1,79 @@
|
|
|
|
|
|
1 |
import os
|
|
|
2 |
import requests
|
3 |
-
from typing import Optional
|
4 |
-
|
5 |
-
# 🔐 Load HF credentials and endpoint URL from environment variables
|
6 |
-
HF_TOKEN = os.environ.get("HF_TOKEN")
|
7 |
-
MISTRAL_URL = os.environ.get("MISTRAL_URL")
|
8 |
|
9 |
-
#
|
10 |
-
|
11 |
-
|
12 |
-
|
13 |
-
}
|
14 |
|
15 |
-
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
"inputs": full_prompt
|
20 |
-
}
|
21 |
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
27 |
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
33 |
|
34 |
-
|
35 |
-
|
36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
37 |
|
38 |
-
|
39 |
-
|
40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# components/LLMs/Mistral.py
|
2 |
+
|
3 |
import os
|
4 |
+
import time
|
5 |
import requests
|
6 |
+
from typing import List, Dict, Tuple, Optional
|
|
|
|
|
|
|
|
|
7 |
|
8 |
+
# Env vars (set in your HF Space / .env)
|
9 |
+
TOGETHER_API_KEY = os.getenv("TOGETHER_API_KEY")
|
10 |
+
TOGETHER_BASE_URL = os.getenv("TOGETHER_BASE_URL", "https://api.together.xyz/v1")
|
11 |
+
TOGETHER_MODEL = os.getenv("TOGETHER_MODEL", "mistralai/Mistral-7B-Instruct-v0.3")
|
|
|
12 |
|
13 |
+
class MistralTogetherClient:
|
14 |
+
"""
|
15 |
+
Wrapper around Together AI's Chat Completions API for Mistral-7B.
|
16 |
+
"""
|
|
|
|
|
17 |
|
18 |
+
def __init__(
|
19 |
+
self,
|
20 |
+
model: str = TOGETHER_MODEL,
|
21 |
+
timeout_s: int = 25,
|
22 |
+
):
|
23 |
+
if not TOGETHER_API_KEY:
|
24 |
+
raise RuntimeError("Missing TOGETHER_API_KEY env var")
|
25 |
+
self.model = model
|
26 |
+
self.timeout_s = timeout_s
|
27 |
+
self.url = f"{TOGETHER_BASE_URL}/chat/completions"
|
28 |
+
self.headers = {
|
29 |
+
"Authorization": f"Bearer {TOGETHER_API_KEY}",
|
30 |
+
"Content-Type": "application/json",
|
31 |
+
}
|
32 |
|
33 |
+
def chat(
|
34 |
+
self,
|
35 |
+
messages: List[Dict[str, str]],
|
36 |
+
temperature: float = 0.3,
|
37 |
+
max_tokens: int = 512,
|
38 |
+
) -> Tuple[str, Dict]:
|
39 |
+
"""
|
40 |
+
Send chat messages to Together's API and return (text, usage).
|
41 |
+
- messages: [{"role": "system"|"user"|"assistant", "content": "..."}]
|
42 |
+
"""
|
43 |
+
payload = {
|
44 |
+
"model": self.model,
|
45 |
+
"messages": messages,
|
46 |
+
"temperature": float(temperature),
|
47 |
+
"max_tokens": int(max_tokens),
|
48 |
+
"stream": False,
|
49 |
+
}
|
50 |
|
51 |
+
for attempt in (1, 2): # one retry on transient errors
|
52 |
+
try:
|
53 |
+
r = requests.post(
|
54 |
+
self.url,
|
55 |
+
headers=self.headers,
|
56 |
+
json=payload,
|
57 |
+
timeout=self.timeout_s,
|
58 |
+
)
|
59 |
+
if r.status_code >= 500 or r.status_code in (408, 429):
|
60 |
+
raise RuntimeError(f"Upstream {r.status_code}: {r.text[:200]}")
|
61 |
+
r.raise_for_status()
|
62 |
+
data = r.json()
|
63 |
+
text = data["choices"][0]["message"]["content"]
|
64 |
+
usage = data.get("usage", {})
|
65 |
+
return text, usage
|
66 |
+
except Exception as e:
|
67 |
+
if attempt == 2:
|
68 |
+
raise
|
69 |
+
time.sleep(0.8) # backoff before retry
|
70 |
|
71 |
+
def build_messages(user_msg: str, system: Optional[str] = None) -> List[Dict[str, str]]:
|
72 |
+
"""
|
73 |
+
Helper to build message arrays for chat().
|
74 |
+
"""
|
75 |
+
msgs: List[Dict[str, str]] = []
|
76 |
+
if system:
|
77 |
+
msgs.append({"role": "system", "content": system})
|
78 |
+
msgs.append({"role": "user", "content": user_msg})
|
79 |
+
return msgs
|
components/handlers/__init__.py
ADDED
File without changes
|
components/handlers/whatsapp_handlers.py
ADDED
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# handlers/whatsapp_handlers.py
|
2 |
+
import logging
|
3 |
+
from fastapi.responses import JSONResponse
|
4 |
+
from components.gateways.headlines_to_wa import fetch_cached_headlines, send_to_whatsapp
|
5 |
+
|
6 |
+
def _safe_send(text: str, to: str) -> dict:
|
7 |
+
"""Wrap send_to_whatsapp with logging & safe error handling."""
|
8 |
+
try:
|
9 |
+
res = send_to_whatsapp(text, destination_number=to)
|
10 |
+
if res.get("status") == "success":
|
11 |
+
logging.info(f"Sent message to {to}")
|
12 |
+
else:
|
13 |
+
logging.error(f"Failed to send message to {to}: {res}")
|
14 |
+
return res
|
15 |
+
except Exception as e:
|
16 |
+
logging.exception(f"Exception while sending WhatsApp message to {to}: {e}")
|
17 |
+
return {"status": "error", "error": str(e)}
|
18 |
+
|
19 |
+
|
20 |
+
def handle_headlines(from_number: str) -> JSONResponse:
|
21 |
+
full_message_text = fetch_cached_headlines()
|
22 |
+
|
23 |
+
if full_message_text.startswith("❌") or full_message_text.startswith("⚠️"):
|
24 |
+
logging.error(f"Failed to fetch digest for {from_number}: {full_message_text}")
|
25 |
+
_safe_send(f"Sorry, I couldn't fetch the news digest today. {full_message_text}", to=from_number)
|
26 |
+
return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to fetch digest"})
|
27 |
+
|
28 |
+
result = _safe_send(full_message_text, to=from_number)
|
29 |
+
if result.get("status") == "success":
|
30 |
+
return JSONResponse(status_code=200, content={"status": "success", "message": "Digest sent"})
|
31 |
+
else:
|
32 |
+
_safe_send(
|
33 |
+
f"Sorry, I couldn't send the news digest to you. Error: {result.get('error', 'unknown')}",
|
34 |
+
to=from_number,
|
35 |
+
)
|
36 |
+
return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to send digest"})
|
37 |
+
|
38 |
+
|
39 |
+
def handle_preferences(from_number: str) -> JSONResponse:
|
40 |
+
msg = (
|
41 |
+
"Let’s tune your feed. Reply with topics you like:\n"
|
42 |
+
"• world • india • finance • sports • entertainment\n\n"
|
43 |
+
"You can send multiple, e.g.: india, finance"
|
44 |
+
)
|
45 |
+
_safe_send(msg, to=from_number)
|
46 |
+
return JSONResponse(status_code=200, content={"status": "success", "message": "Preferences prompt sent"})
|
47 |
+
|
48 |
+
|
49 |
+
def handle_greeting(from_number: str) -> JSONResponse:
|
50 |
+
msg = (
|
51 |
+
"Hey! 👋 I’m NuseAI.\n"
|
52 |
+
"• Type *headlines* to get today’s digest.\n"
|
53 |
+
"• Type *preferences* to set your topics.\n"
|
54 |
+
"• Type *help* to see what I can do."
|
55 |
+
)
|
56 |
+
_safe_send(msg, to=from_number)
|
57 |
+
return JSONResponse(status_code=200, content={"status": "success", "message": "Greeting sent"})
|
58 |
+
|
59 |
+
|
60 |
+
def handle_help(from_number: str) -> JSONResponse:
|
61 |
+
msg = (
|
62 |
+
"Here’s how I can help:\n"
|
63 |
+
"• *Headlines* — today’s 🗞️ Daily Digest 🟡\n"
|
64 |
+
"• *Preferences* — choose topics/regions\n"
|
65 |
+
"• *Unsubscribe* — stop messages\n"
|
66 |
+
"Ask me a question anytime (e.g., “What’s India’s CPI outlook?”)."
|
67 |
+
)
|
68 |
+
_safe_send(msg, to=from_number)
|
69 |
+
return JSONResponse(status_code=200, content={"status": "success", "message": "Help sent"})
|
70 |
+
|
71 |
+
|
72 |
+
def handle_unsubscribe(from_number: str) -> JSONResponse:
|
73 |
+
_safe_send("You’re unsubscribed. If you change your mind, just say *hi*.", to=from_number)
|
74 |
+
return JSONResponse(status_code=200, content={"status": "success", "message": "Unsubscribed"})
|
75 |
+
|
76 |
+
|
77 |
+
def handle_small_talk(from_number: str) -> JSONResponse:
|
78 |
+
_safe_send("🙂 Got it. If you’d like the news, just say *headlines*.", to=from_number)
|
79 |
+
return JSONResponse(status_code=200, content={"status": "success", "message": "Small talk"})
|
80 |
+
|
81 |
+
|
82 |
+
def handle_chat_question(from_number: str, message_text: str) -> JSONResponse:
|
83 |
+
# Placeholder: integrate with /ask later
|
84 |
+
_safe_send(
|
85 |
+
"Great question! I’m setting up deep Q&A. For now, type *headlines* for today’s digest "
|
86 |
+
"or *preferences* to tune your feed.",
|
87 |
+
to=from_number,
|
88 |
+
)
|
89 |
+
logging.info(f"Chat question from {from_number}: {message_text}")
|
90 |
+
return JSONResponse(status_code=200, content={"status": "success", "message": "Question acknowledged"})
|
routes/api/whatsapp_webhook.py
CHANGED
@@ -3,86 +3,35 @@ from fastapi import APIRouter, Request, HTTPException, status
|
|
3 |
from fastapi.responses import JSONResponse
|
4 |
import logging
|
5 |
import json
|
6 |
-
|
7 |
-
|
8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
9 |
|
10 |
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
11 |
-
|
12 |
router = APIRouter()
|
|
|
13 |
|
14 |
-
def _extract_from_number_and_text(payload: dict):
|
15 |
-
|
16 |
-
|
17 |
-
|
18 |
-
Returns (None, None) if not a user message.
|
19 |
-
"""
|
20 |
-
try:
|
21 |
-
entries = payload.get("entry", [])
|
22 |
-
for entry in entries:
|
23 |
-
changes = entry.get("changes", [])
|
24 |
-
for change in changes:
|
25 |
-
# Only process message-type changes
|
26 |
-
if change.get("field") != "messages":
|
27 |
-
continue
|
28 |
-
|
29 |
-
value = change.get("value", {})
|
30 |
-
messages_list = value.get("messages", [])
|
31 |
-
if not messages_list:
|
32 |
-
# This may be a status/billing event without 'messages'
|
33 |
-
continue
|
34 |
-
|
35 |
-
# We only look at the first message in the list for this webhook
|
36 |
-
msg = messages_list[0]
|
37 |
-
from_number = msg.get("from")
|
38 |
-
mtype = msg.get("type")
|
39 |
-
|
40 |
-
# 1) Plain text
|
41 |
-
if mtype == "text":
|
42 |
-
text_body = (msg.get("text", {}) or {}).get("body")
|
43 |
-
if from_number and text_body:
|
44 |
-
return from_number, text_body
|
45 |
-
|
46 |
-
# 2) Template reply button (older/simple schema)
|
47 |
-
# payload is the stable key if you set it; fallback to text/title
|
48 |
-
if mtype == "button":
|
49 |
-
b = msg.get("button", {}) or {}
|
50 |
-
intent = b.get("payload") or b.get("text")
|
51 |
-
if from_number and intent:
|
52 |
-
return from_number, intent
|
53 |
-
|
54 |
-
# 3) Newer interactive replies (buttons or list)
|
55 |
-
if mtype == "interactive":
|
56 |
-
i = msg.get("interactive", {}) or {}
|
57 |
-
# Prefer stable IDs over display titles
|
58 |
-
if "button_reply" in i:
|
59 |
-
intent = i["button_reply"].get("id") or i["button_reply"].get("title")
|
60 |
-
if from_number and intent:
|
61 |
-
return from_number, intent
|
62 |
-
if "list_reply" in i:
|
63 |
-
intent = i["list_reply"].get("id") or i["list_reply"].get("title")
|
64 |
-
if from_number and intent:
|
65 |
-
return from_number, intent
|
66 |
|
67 |
-
# If we got here, no usable user message was found
|
68 |
-
return None, None
|
69 |
-
|
70 |
-
except Exception:
|
71 |
-
# In case of any unexpected structure, just treat as no user message
|
72 |
-
return None, None
|
73 |
-
|
74 |
-
|
75 |
-
# WhatsApp/Gupshup webhook endpoint
|
76 |
@router.post("/message-received")
|
77 |
async def whatsapp_webhook_receiver(request: Request):
|
78 |
-
"""
|
79 |
-
Receives incoming messages from Gupshup WhatsApp webhook.
|
80 |
-
Sends a daily news digest if the user sends a specific command.
|
81 |
-
"""
|
82 |
try:
|
83 |
-
|
84 |
-
body_bytes = await request.body()
|
85 |
-
body_str = body_bytes.decode("utf-8")
|
86 |
logging.info(f"Raw webhook body received: {body_str}")
|
87 |
|
88 |
try:
|
@@ -91,64 +40,55 @@ async def whatsapp_webhook_receiver(request: Request):
|
|
91 |
logging.error("❌ Failed to decode webhook body as JSON")
|
92 |
return JSONResponse(status_code=400, content={"error": "Invalid JSON format"})
|
93 |
|
94 |
-
# <<< FIX 2: Robustly extract data from the nested structure (text/button/interactive) >>>
|
95 |
from_number, message_text = _extract_from_number_and_text(incoming_message)
|
96 |
-
|
97 |
-
# Check if sender and message text were successfully extracted
|
98 |
if not from_number or not message_text:
|
99 |
-
# This is likely a status/billing webhook or a non-user message; ignore quietly
|
100 |
logging.info("Ignoring non-message webhook or missing sender/text.")
|
101 |
return JSONResponse(status_code=200, content={"status": "ignored", "message": "No user message"})
|
102 |
|
103 |
logging.info(f"Message from {from_number}: {message_text}")
|
104 |
|
105 |
-
# Normalize intent text lightly (don't change your core logic)
|
106 |
normalized = message_text.lower().strip().replace("’", "'")
|
107 |
-
|
108 |
-
# Check for specific commands to send the digest
|
109 |
if normalized == "view today's headlines":
|
110 |
-
|
111 |
-
|
112 |
-
# Fetch the digest headlines
|
113 |
-
full_message_text = fetch_cached_headlines()
|
114 |
-
|
115 |
-
if full_message_text.startswith("❌") or full_message_text.startswith("⚠️"):
|
116 |
-
logging.error(f"Failed to fetch digest for {from_number}: {full_message_text}")
|
117 |
-
# Send an error message back to the user
|
118 |
-
send_to_whatsapp(f"Sorry, I couldn't fetch the news digest today. {full_message_text}", destination_number=from_number)
|
119 |
-
return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to fetch digest"})
|
120 |
-
|
121 |
-
# Send the digest back to the user who requested it
|
122 |
-
# <<< FIX 3: Pass from_number as destination_number to send_to_whatsapp >>>
|
123 |
-
result = send_to_whatsapp(full_message_text, destination_number=from_number)
|
124 |
|
125 |
-
|
126 |
-
|
127 |
-
|
128 |
-
|
129 |
-
|
130 |
-
|
131 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
132 |
else:
|
133 |
-
|
134 |
-
return JSONResponse(status_code=200, content={"status": "ignored", "message": "No action taken for this command"})
|
135 |
|
136 |
except Exception as e:
|
137 |
logging.error(f"Error processing webhook: {e}", exc_info=True)
|
138 |
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
|
139 |
|
140 |
-
|
141 |
@router.get("/message-received")
|
142 |
async def whatsapp_webhook_verify(request: Request):
|
143 |
-
"""
|
144 |
-
Endpoint for Gupshup webhook verification.
|
145 |
-
"""
|
146 |
mode = request.query_params.get("hub.mode")
|
147 |
challenge = request.query_params.get("hub.challenge")
|
148 |
|
149 |
if mode == "subscribe" and challenge:
|
150 |
logging.info(f"Webhook verification successful. Challenge: {challenge}")
|
151 |
-
# Challenge needs to be returned as an integer, not string
|
152 |
return JSONResponse(status_code=200, content=int(challenge))
|
153 |
else:
|
154 |
logging.warning(f"Webhook verification failed. Mode: {mode}, Challenge: {challenge}")
|
|
|
3 |
from fastapi.responses import JSONResponse
|
4 |
import logging
|
5 |
import json
|
6 |
+
from typing import Tuple, Optional
|
7 |
+
|
8 |
+
# Classifier
|
9 |
+
from components.LLMs.Classifier import ZeroShotClassifier
|
10 |
+
|
11 |
+
# Handlers
|
12 |
+
from components.handlers.whatsapp_handlers import (
|
13 |
+
handle_headlines,
|
14 |
+
handle_preferences,
|
15 |
+
handle_greeting,
|
16 |
+
handle_help,
|
17 |
+
handle_unsubscribe,
|
18 |
+
handle_small_talk,
|
19 |
+
handle_chat_question,
|
20 |
+
)
|
21 |
|
22 |
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
|
|
23 |
router = APIRouter()
|
24 |
+
classifier = ZeroShotClassifier()
|
25 |
|
26 |
+
def _extract_from_number_and_text(payload: dict) -> Tuple[Optional[str], Optional[str]]:
|
27 |
+
...
|
28 |
+
# (same as before – unchanged)
|
29 |
+
...
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
31 |
@router.post("/message-received")
|
32 |
async def whatsapp_webhook_receiver(request: Request):
|
|
|
|
|
|
|
|
|
33 |
try:
|
34 |
+
body_str = (await request.body()).decode("utf-8")
|
|
|
|
|
35 |
logging.info(f"Raw webhook body received: {body_str}")
|
36 |
|
37 |
try:
|
|
|
40 |
logging.error("❌ Failed to decode webhook body as JSON")
|
41 |
return JSONResponse(status_code=400, content={"error": "Invalid JSON format"})
|
42 |
|
|
|
43 |
from_number, message_text = _extract_from_number_and_text(incoming_message)
|
|
|
|
|
44 |
if not from_number or not message_text:
|
|
|
45 |
logging.info("Ignoring non-message webhook or missing sender/text.")
|
46 |
return JSONResponse(status_code=200, content={"status": "ignored", "message": "No user message"})
|
47 |
|
48 |
logging.info(f"Message from {from_number}: {message_text}")
|
49 |
|
|
|
50 |
normalized = message_text.lower().strip().replace("’", "'")
|
|
|
|
|
51 |
if normalized == "view today's headlines":
|
52 |
+
return handle_headlines(from_number)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
53 |
|
54 |
+
try:
|
55 |
+
label, meta = classifier.classify(message_text)
|
56 |
+
logging.info(f"Intent classified: {label} | meta={meta}")
|
57 |
+
except Exception as e:
|
58 |
+
logging.exception(f"Classifier error: {e}")
|
59 |
+
if any(k in normalized for k in ["headline", "digest", "news", "today"]):
|
60 |
+
return handle_headlines(from_number)
|
61 |
+
return handle_help(from_number)
|
62 |
+
|
63 |
+
if label == "headlines_request":
|
64 |
+
return handle_headlines(from_number)
|
65 |
+
elif label == "preferences_update":
|
66 |
+
return handle_preferences(from_number)
|
67 |
+
elif label == "greeting":
|
68 |
+
return handle_greeting(from_number)
|
69 |
+
elif label == "help":
|
70 |
+
return handle_help(from_number)
|
71 |
+
elif label == "unsubscribe":
|
72 |
+
return handle_unsubscribe(from_number)
|
73 |
+
elif label == "small_talk":
|
74 |
+
return handle_small_talk(from_number)
|
75 |
+
elif label == "chat_question":
|
76 |
+
return handle_chat_question(from_number, message_text)
|
77 |
else:
|
78 |
+
return handle_help(from_number)
|
|
|
79 |
|
80 |
except Exception as e:
|
81 |
logging.error(f"Error processing webhook: {e}", exc_info=True)
|
82 |
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
|
83 |
|
84 |
+
|
85 |
@router.get("/message-received")
|
86 |
async def whatsapp_webhook_verify(request: Request):
|
|
|
|
|
|
|
87 |
mode = request.query_params.get("hub.mode")
|
88 |
challenge = request.query_params.get("hub.challenge")
|
89 |
|
90 |
if mode == "subscribe" and challenge:
|
91 |
logging.info(f"Webhook verification successful. Challenge: {challenge}")
|
|
|
92 |
return JSONResponse(status_code=200, content=int(challenge))
|
93 |
else:
|
94 |
logging.warning(f"Webhook verification failed. Mode: {mode}, Challenge: {challenge}")
|