ragV98 commited on
Commit
2a74afb
·
1 Parent(s): c6eada1

massive overhaul and I pray for no fuckups

Browse files
components/conversation/__init__.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Conversation utilities for WhatsApp assistant."""
2
+
3
+ from .context import (
4
+ add_message as add_conversation_message,
5
+ get_recent_messages as get_conversation_history,
6
+ clear_conversation,
7
+ )
8
+
9
+ from .llm_responses import generate_reply
10
+
11
+ __all__ = [
12
+ "add_conversation_message",
13
+ "get_conversation_history",
14
+ "clear_conversation",
15
+ "generate_reply",
16
+ ]
components/conversation/context.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import logging
3
+ import os
4
+ import time
5
+ from typing import Dict, List
6
+
7
+ import redis
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ REDIS_URL = os.environ.get("UPSTASH_REDIS_URL", "redis://localhost:6379")
12
+ _CONTEXT_MAX_MESSAGES = int(os.environ.get("CONVERSATION_CONTEXT_LIMIT", "12"))
13
+
14
+ try:
15
+ _redis_client = redis.Redis.from_url(REDIS_URL, decode_responses=True)
16
+ except Exception as exc: # pragma: no cover - defensive
17
+ logger.error("Failed to connect to Redis for conversation context: %s", exc)
18
+ _redis_client = None
19
+
20
+
21
+ def _ctx_key(user_id: str) -> str:
22
+ return f"conversation:ctx:{user_id}"
23
+
24
+
25
+ def add_message(user_id: str, role: str, content: str, limit: int = _CONTEXT_MAX_MESSAGES) -> None:
26
+ if not user_id or not content or not _redis_client:
27
+ return
28
+ entry = json.dumps({
29
+ "ts": time.time(),
30
+ "role": role,
31
+ "content": content,
32
+ })
33
+ key = _ctx_key(user_id)
34
+ try:
35
+ _redis_client.lpush(key, entry)
36
+ _redis_client.ltrim(key, 0, max(limit * 2 - 1, limit - 1))
37
+ except Exception as exc: # pragma: no cover - network side-effects
38
+ logger.debug("Failed to append conversation message for %s: %s", user_id, exc)
39
+
40
+
41
+ def get_recent_messages(user_id: str, limit: int = _CONTEXT_MAX_MESSAGES) -> List[Dict[str, str]]:
42
+ if not user_id or not _redis_client:
43
+ return []
44
+ key = _ctx_key(user_id)
45
+ try:
46
+ raw = _redis_client.lrange(key, 0, limit - 1)
47
+ except Exception as exc: # pragma: no cover - network side-effects
48
+ logger.debug("Failed to fetch conversation messages for %s: %s", user_id, exc)
49
+ return []
50
+ messages: List[Dict[str, str]] = []
51
+ for payload in reversed(raw):
52
+ try:
53
+ entry = json.loads(payload)
54
+ if isinstance(entry, dict) and entry.get("content"):
55
+ messages.append({
56
+ "role": entry.get("role", "user"),
57
+ "content": str(entry.get("content")),
58
+ })
59
+ except (TypeError, json.JSONDecodeError):
60
+ continue
61
+ return messages
62
+
63
+
64
+ def clear_conversation(user_id: str) -> None:
65
+ if not user_id or not _redis_client:
66
+ return
67
+ try:
68
+ _redis_client.delete(_ctx_key(user_id))
69
+ except Exception as exc: # pragma: no cover
70
+ logger.debug("Failed to clear conversation for %s: %s", user_id, exc)
components/conversation/llm_responses.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from typing import Dict, List, Optional
3
+
4
+ from components.LLMs.Mistral import MistralTogetherClient, build_messages
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+ _CATEGORY_DESCRIPTIONS: Dict[str, str] = {
9
+ "greeting": "Welcome the user warmly, mention Nuse and offer quick pointers for using the assistant.",
10
+ "help": "Explain concisely what Nuse can do. Use short bullet-style sentences separated by new lines.",
11
+ "preferences_prompt": (
12
+ "Ask the user to choose from the available news topics. State the list clearly and explain how to respond."
13
+ ),
14
+ "preferences_confirm": (
15
+ "Confirm the topics the user selected. Sound upbeat and let them know tailored headlines will follow."
16
+ ),
17
+ "small_talk": "Respond briefly and positively to casual chatter while nudging toward news-related help if relevant.",
18
+ "unsubscribe": "Acknowledge the unsubscribe request politely and explain they can message again to resume.",
19
+ "headlines_intro": "Introduce the upcoming digest, referencing the user's preferred topics if provided.",
20
+ }
21
+
22
+
23
+ def _format_context(context_messages: Optional[List[Dict[str, str]]], limit: int = 8) -> str:
24
+ if not context_messages:
25
+ return ""
26
+ trimmed = context_messages[-limit:]
27
+ exchanges = []
28
+ for item in trimmed:
29
+ role = item.get("role", "user")
30
+ content = item.get("content", "")
31
+ if not content:
32
+ continue
33
+ label = "User" if role == "user" else "Nuse"
34
+ exchanges.append(f"{label}: {content}")
35
+ return "\n".join(exchanges)
36
+
37
+
38
+ def generate_reply(
39
+ category: str,
40
+ context_messages: Optional[List[Dict[str, str]]],
41
+ user_message: Optional[str] = None,
42
+ extra: Optional[Dict[str, str]] = None,
43
+ ) -> str:
44
+ description = _CATEGORY_DESCRIPTIONS.get(category)
45
+ if not description:
46
+ logger.debug("No category description for %s; falling back to default prompt", category)
47
+ description = "Respond helpfully to the user based on the latest exchange."
48
+
49
+ context_block = _format_context(context_messages)
50
+ extras = extra or {}
51
+ latest = user_message or extras.get("latest_user_message") or ""
52
+
53
+ prompt_lines = [
54
+ "You are Nuse, a concise WhatsApp assistant focused on personalised news.",
55
+ description,
56
+ ]
57
+
58
+ if extras.get("topics_list"):
59
+ prompt_lines.append(
60
+ "Available topics: " + ", ".join(extras["topics_list"])
61
+ )
62
+ if extras.get("confirmed_topics"):
63
+ prompt_lines.append(
64
+ "User has selected: " + ", ".join(extras["confirmed_topics"])
65
+ )
66
+ if extras.get("notes"):
67
+ prompt_lines.append(extras["notes"])
68
+
69
+ system_prompt = " \n".join(prompt_lines)
70
+
71
+ user_sections = []
72
+ if context_block:
73
+ user_sections.append(f"Conversation so far:\n{context_block}")
74
+ if latest:
75
+ user_sections.append(f"Latest user message:\n{latest}")
76
+
77
+ if extras.get("headlines_preview"):
78
+ user_sections.append(
79
+ "Upcoming digest summary:\n" + extras["headlines_preview"]
80
+ )
81
+
82
+ user_prompt = "\n\n".join(user_sections) or "Respond appropriately."
83
+
84
+ llm = MistralTogetherClient()
85
+ messages = build_messages(user_prompt, system_prompt)
86
+ try:
87
+ text, _usage = llm.chat(messages, temperature=0.2, max_tokens=220)
88
+ return text.strip()
89
+ except Exception as exc: # pragma: no cover - network issues
90
+ logger.error("LLM reply generation failed for %s: %s", category, exc)
91
+ fallback = {
92
+ "greeting": "Hi! I'm Nuse. Ask me for headlines or news on any topic when you're ready.",
93
+ "help": "I can send personalised headlines, answer news questions, and manage your preferences.",
94
+ }
95
+ return fallback.get(category, "Thanks for reaching out! How can I help with the news today?")
components/handlers/wa/common.py CHANGED
@@ -5,6 +5,7 @@ from components.gateways.headlines_to_wa import (
5
  send_to_whatsapp,
6
  send_typing_indicator,
7
  )
 
8
 
9
  try: # Memory is optional; degrade gracefully if unavailable
10
  from components.memory.chat_vector_store import add_chat_turn_to_memory as _store_memory_turn
@@ -33,6 +34,7 @@ def safe_send(text: str, to: str) -> Dict:
33
  res = send_to_whatsapp(text, destination_number=to)
34
  if res.get("status") == "success":
35
  logger.info("Sent message to %s", to)
 
36
  if _store_memory_turn:
37
  try:
38
  _store_memory_turn(to, "assistant", text)
 
5
  send_to_whatsapp,
6
  send_typing_indicator,
7
  )
8
+ from components.conversation import add_conversation_message
9
 
10
  try: # Memory is optional; degrade gracefully if unavailable
11
  from components.memory.chat_vector_store import add_chat_turn_to_memory as _store_memory_turn
 
34
  res = send_to_whatsapp(text, destination_number=to)
35
  if res.get("status") == "success":
36
  logger.info("Sent message to %s", to)
37
+ add_conversation_message(to, "assistant", text)
38
  if _store_memory_turn:
39
  try:
40
  _store_memory_turn(to, "assistant", text)
components/handlers/wa/research.py CHANGED
@@ -8,6 +8,7 @@ from fastapi.responses import JSONResponse
8
 
9
  from components.LLMs.Mistral import MistralTogetherClient, build_messages
10
  from components.retrievers.web_qa import get_web_enriched_context
 
11
 
12
  from .common import safe_send
13
 
@@ -132,6 +133,8 @@ def _answer_with_context(question: str, context: str, memories: Optional[List[st
132
  def handle_general_research(from_number: str, message_text: str) -> JSONResponse:
133
  logger.info("General research question from %s: %s", from_number, message_text)
134
 
 
 
135
  memory_lines: List[str] = []
136
  if _retrieve_memories:
137
  try:
@@ -150,7 +153,15 @@ def handle_general_research(from_number: str, message_text: str) -> JSONResponse
150
  else:
151
  logger.info("[memory] no preserved lines for %s", from_number)
152
 
153
- related_for_search = memory_lines if memory_lines else None
 
 
 
 
 
 
 
 
154
 
155
  context, sources = get_web_enriched_context(
156
  message_text,
 
8
 
9
  from components.LLMs.Mistral import MistralTogetherClient, build_messages
10
  from components.retrievers.web_qa import get_web_enriched_context
11
+ from components.conversation import get_conversation_history
12
 
13
  from .common import safe_send
14
 
 
133
  def handle_general_research(from_number: str, message_text: str) -> JSONResponse:
134
  logger.info("General research question from %s: %s", from_number, message_text)
135
 
136
+ conversation_history = get_conversation_history(from_number)
137
+
138
  memory_lines: List[str] = []
139
  if _retrieve_memories:
140
  try:
 
153
  else:
154
  logger.info("[memory] no preserved lines for %s", from_number)
155
 
156
+ context_snippets: List[str] = [
157
+ msg.get("content", "")
158
+ for msg in conversation_history
159
+ if msg.get("role") == "user" and msg.get("content")
160
+ ][-4:]
161
+ related_for_search = [*memory_lines] if memory_lines else []
162
+ if context_snippets:
163
+ related_for_search.extend(context_snippets)
164
+ related_for_search = related_for_search or None
165
 
166
  context, sources = get_web_enriched_context(
167
  message_text,
components/handlers/wa/shortcuts.py CHANGED
@@ -1,7 +1,13 @@
1
  import logging
 
 
2
  from fastapi.responses import JSONResponse
3
 
4
  from components.gateways.headlines_to_wa import fetch_cached_headlines
 
 
 
 
5
  from storage.users import get_user
6
 
7
  from .common import safe_send
@@ -10,7 +16,7 @@ from .common import safe_send
10
  logger = logging.getLogger(__name__)
11
 
12
 
13
- def handle_headlines(from_number: str) -> JSONResponse:
14
  user = get_user(from_number) or {}
15
  prefs = user.get("preferences", {}) if isinstance(user, dict) else {}
16
  preferred_topics = prefs.get("topics") if isinstance(prefs, dict) else None
@@ -19,67 +25,103 @@ def handle_headlines(from_number: str) -> JSONResponse:
19
 
20
  if full_message_text.startswith("❌") or full_message_text.startswith("⚠️"):
21
  logger.error("Failed to fetch digest for %s: %s", from_number, full_message_text)
22
- safe_send(
23
- f"Sorry, I couldn't fetch the news digest today. {full_message_text}",
24
- to=from_number,
 
 
 
 
25
  )
 
26
  return JSONResponse(
27
  status_code=500,
28
  content={"status": "error", "message": "Failed to fetch digest"},
29
  )
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  result = safe_send(full_message_text, to=from_number)
32
  if result.get("status") == "success":
33
  return JSONResponse(status_code=200, content={"status": "success", "message": "Digest sent"})
34
 
 
 
 
 
 
 
 
 
35
  safe_send(
36
- "Sorry, I couldn't send the news digest to you. "
37
- f"Error: {result.get('error', 'unknown')}",
38
  to=from_number,
39
  )
40
  return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to send digest"})
41
 
42
 
43
- def handle_preferences(from_number: str) -> JSONResponse:
44
- msg = (
45
- "Let’s tune your feed. Reply with topics you like:\n"
46
- "• India • World • Technology • Business & Markets • Sports\n\n"
47
- "You can send multiple, e.g.: India, Technology"
 
 
 
48
  )
49
- safe_send(msg, to=from_number)
50
  return JSONResponse(status_code=200, content={"status": "success", "message": "Preferences prompt sent"})
51
 
52
 
53
- def handle_greeting(from_number: str) -> JSONResponse:
54
- msg = (
55
- "Hey! 👋 I’m NuseAI.\n"
56
- "• Type *headlines* to get today’s digest.\n"
57
- "• Type *preferences* to set your topics.\n"
58
- "• Type *help* to see what I can do."
59
  )
60
- safe_send(msg, to=from_number)
61
  return JSONResponse(status_code=200, content={"status": "success", "message": "Greeting sent"})
62
 
63
 
64
- def handle_help(from_number: str) -> JSONResponse:
65
- msg = (
66
- "Here’s how I can help:\n"
67
- "• *Headlines* — today’s 🗞️ Daily Digest 🟡\n"
68
- "• *Preferences* — choose topics/regions\n"
69
- "• *Unsubscribe* — stop messages\n"
70
- "Ask me a question anytime (e.g., “What’s India’s CPI outlook?”)."
71
  )
72
- safe_send(msg, to=from_number)
73
  return JSONResponse(status_code=200, content={"status": "success", "message": "Help sent"})
74
 
75
 
76
- def handle_unsubscribe(from_number: str) -> JSONResponse:
77
- safe_send("You’re unsubscribed. If you change your mind, just say *hi*.", to=from_number)
 
 
 
 
 
78
  return JSONResponse(status_code=200, content={"status": "success", "message": "Unsubscribed"})
79
 
80
 
81
- def handle_small_talk(from_number: str) -> JSONResponse:
82
- safe_send("🙂 Got it. If you’d like the news, just say *headlines*.", to=from_number)
 
 
 
 
 
83
  return JSONResponse(status_code=200, content={"status": "success", "message": "Small talk"})
84
 
85
 
 
1
  import logging
2
+ from typing import Optional
3
+
4
  from fastapi.responses import JSONResponse
5
 
6
  from components.gateways.headlines_to_wa import fetch_cached_headlines
7
+ from components.conversation import (
8
+ get_conversation_history,
9
+ generate_reply,
10
+ )
11
  from storage.users import get_user
12
 
13
  from .common import safe_send
 
16
  logger = logging.getLogger(__name__)
17
 
18
 
19
+ def handle_headlines(from_number: str, latest_user_message: Optional[str] = None) -> JSONResponse:
20
  user = get_user(from_number) or {}
21
  prefs = user.get("preferences", {}) if isinstance(user, dict) else {}
22
  preferred_topics = prefs.get("topics") if isinstance(prefs, dict) else None
 
25
 
26
  if full_message_text.startswith("❌") or full_message_text.startswith("⚠️"):
27
  logger.error("Failed to fetch digest for %s: %s", from_number, full_message_text)
28
+ apology = generate_reply(
29
+ "headlines_intro",
30
+ get_conversation_history(from_number),
31
+ user_message=latest_user_message,
32
+ extra={
33
+ "notes": "Explain that the digest could not be fetched and apologise succinctly.",
34
+ },
35
  )
36
+ safe_send(apology or "Sorry, I couldn't fetch the news digest today.", to=from_number)
37
  return JSONResponse(
38
  status_code=500,
39
  content={"status": "error", "message": "Failed to fetch digest"},
40
  )
41
 
42
+ intro = generate_reply(
43
+ "headlines_intro",
44
+ get_conversation_history(from_number),
45
+ user_message=latest_user_message,
46
+ extra={
47
+ "confirmed_topics": [
48
+ t.title() for t in (preferred_topics or [])
49
+ ] or None,
50
+ },
51
+ )
52
+ if intro:
53
+ safe_send(intro, to=from_number)
54
+
55
  result = safe_send(full_message_text, to=from_number)
56
  if result.get("status") == "success":
57
  return JSONResponse(status_code=200, content={"status": "success", "message": "Digest sent"})
58
 
59
+ failure_note = generate_reply(
60
+ "headlines_intro",
61
+ get_conversation_history(from_number),
62
+ user_message=latest_user_message,
63
+ extra={
64
+ "notes": "Explain that sending the digest failed and suggest trying again later.",
65
+ },
66
+ )
67
  safe_send(
68
+ failure_note
69
+ or "Sorry, I couldn't send the news digest to you. Please try again in a bit.",
70
  to=from_number,
71
  )
72
  return JSONResponse(status_code=500, content={"status": "error", "message": "Failed to send digest"})
73
 
74
 
75
+ def handle_preferences(from_number: str, latest_user_message: Optional[str] = None) -> JSONResponse:
76
+ reply = generate_reply(
77
+ "preferences_prompt",
78
+ get_conversation_history(from_number),
79
+ user_message=latest_user_message,
80
+ extra={
81
+ "topics_list": ["India", "World", "Technology", "Business & Markets", "Sports"],
82
+ },
83
  )
84
+ safe_send(reply, to=from_number)
85
  return JSONResponse(status_code=200, content={"status": "success", "message": "Preferences prompt sent"})
86
 
87
 
88
+ def handle_greeting(from_number: str, latest_user_message: Optional[str] = None) -> JSONResponse:
89
+ reply = generate_reply(
90
+ "greeting",
91
+ get_conversation_history(from_number),
92
+ user_message=latest_user_message,
 
93
  )
94
+ safe_send(reply, to=from_number)
95
  return JSONResponse(status_code=200, content={"status": "success", "message": "Greeting sent"})
96
 
97
 
98
+ def handle_help(from_number: str, latest_user_message: Optional[str] = None) -> JSONResponse:
99
+ reply = generate_reply(
100
+ "help",
101
+ get_conversation_history(from_number),
102
+ user_message=latest_user_message,
 
 
103
  )
104
+ safe_send(reply, to=from_number)
105
  return JSONResponse(status_code=200, content={"status": "success", "message": "Help sent"})
106
 
107
 
108
+ def handle_unsubscribe(from_number: str, latest_user_message: Optional[str] = None) -> JSONResponse:
109
+ reply = generate_reply(
110
+ "unsubscribe",
111
+ get_conversation_history(from_number),
112
+ user_message=latest_user_message,
113
+ )
114
+ safe_send(reply, to=from_number)
115
  return JSONResponse(status_code=200, content={"status": "success", "message": "Unsubscribed"})
116
 
117
 
118
+ def handle_small_talk(from_number: str, latest_user_message: Optional[str] = None) -> JSONResponse:
119
+ reply = generate_reply(
120
+ "small_talk",
121
+ get_conversation_history(from_number),
122
+ user_message=latest_user_message,
123
+ )
124
+ safe_send(reply, to=from_number)
125
  return JSONResponse(status_code=200, content={"status": "success", "message": "Small talk"})
126
 
127
 
routes/api/whatsapp_webhook.py CHANGED
@@ -30,6 +30,10 @@ from components.handlers.wa import (
30
  handle_onboarding_preferences_text,
31
  handle_onboarding_preferences_flow_submission,
32
  )
 
 
 
 
33
 
34
  try: # Optional memory integration
35
  from components.memory.chat_vector_store import add_chat_turn_to_memory as _store_chat_turn
@@ -284,6 +288,15 @@ async def whatsapp_webhook_receiver(request: Request):
284
 
285
  logging.info(f"Message from {from_number}: {message_text}")
286
 
 
 
 
 
 
 
 
 
 
287
  if msg_id:
288
  send_seen_receipt(from_number, msg_id)
289
 
@@ -356,17 +369,17 @@ async def whatsapp_webhook_receiver(request: Request):
356
 
357
  # Routing
358
  if label == "headlines_request":
359
- return handle_headlines(from_number)
360
  elif label == "preferences_update":
361
- return handle_preferences(from_number)
362
  elif label == "greeting":
363
- return handle_greeting(from_number)
364
  elif label == "help":
365
- return handle_help(from_number)
366
  elif label == "unsubscribe":
367
- return handle_unsubscribe(from_number)
368
  elif label == "small_talk":
369
- return handle_small_talk(from_number)
370
  elif label == "chat_question":
371
  return handle_chat_question(from_number, message_text)
372
  elif label == "general_research":
 
30
  handle_onboarding_preferences_text,
31
  handle_onboarding_preferences_flow_submission,
32
  )
33
+ from components.conversation import (
34
+ add_conversation_message,
35
+ get_conversation_history,
36
+ )
37
 
38
  try: # Optional memory integration
39
  from components.memory.chat_vector_store import add_chat_turn_to_memory as _store_chat_turn
 
288
 
289
  logging.info(f"Message from {from_number}: {message_text}")
290
 
291
+ if message_text:
292
+ add_conversation_message(from_number, "user", message_text)
293
+ elif flow_topic_ids:
294
+ add_conversation_message(
295
+ from_number,
296
+ "user",
297
+ "User submitted flow topics: " + ", ".join(flow_topic_ids),
298
+ )
299
+
300
  if msg_id:
301
  send_seen_receipt(from_number, msg_id)
302
 
 
369
 
370
  # Routing
371
  if label == "headlines_request":
372
+ return handle_headlines(from_number, latest_user_message=message_text)
373
  elif label == "preferences_update":
374
+ return handle_preferences(from_number, latest_user_message=message_text)
375
  elif label == "greeting":
376
+ return handle_greeting(from_number, latest_user_message=message_text)
377
  elif label == "help":
378
+ return handle_help(from_number, latest_user_message=message_text)
379
  elif label == "unsubscribe":
380
+ return handle_unsubscribe(from_number, latest_user_message=message_text)
381
  elif label == "small_talk":
382
+ return handle_small_talk(from_number, latest_user_message=message_text)
383
  elif label == "chat_question":
384
  return handle_chat_question(from_number, message_text)
385
  elif label == "general_research":