Latest fixes by Fable 5
This commit is contained in:
321
bot.py
321
bot.py
@@ -10,6 +10,7 @@ Config files:
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import json
|
import json
|
||||||
|
import random
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
import aiohttp
|
import aiohttp
|
||||||
@@ -72,6 +73,9 @@ EMBED_MODEL = os.getenv("EMBED_MODEL", "nomic-embed-text-v2-moe")
|
|||||||
EMBED_DIM = int(os.getenv("EMBED_DIM", "768"))
|
EMBED_DIM = int(os.getenv("EMBED_DIM", "768"))
|
||||||
# How many semantically similar results to return per search_memory call
|
# How many semantically similar results to return per search_memory call
|
||||||
VECTOR_TOP_K = int(os.getenv("VECTOR_TOP_K", "8"))
|
VECTOR_TOP_K = int(os.getenv("VECTOR_TOP_K", "8"))
|
||||||
|
# Minimum cosine similarity for search_memory results (nomic-embed scores
|
||||||
|
# for unrelated text usually sit around 0.3-0.5, so 0.45 is a sane floor)
|
||||||
|
MEM_SIM_THRESHOLD = float(os.getenv("MEM_SIM_THRESHOLD", "0.45"))
|
||||||
|
|
||||||
# ── Persona (.env) ─────────────────────────────
|
# ── Persona (.env) ─────────────────────────────
|
||||||
BOT_NAME = os.getenv("BOT_NAME", "Manel")
|
BOT_NAME = os.getenv("BOT_NAME", "Manel")
|
||||||
@@ -88,6 +92,9 @@ BOT_TIMEZONE = os.getenv("BOT_TIMEZONE", "Europe/Lisbon") # home timezo
|
|||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
|
# httpx logs every Telegram API URL at INFO — including the bot token.
|
||||||
|
# Keep it quiet so tokens never land in logfiles.
|
||||||
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
if LLM_PROVIDER == "openrouter":
|
if LLM_PROVIDER == "openrouter":
|
||||||
@@ -104,6 +111,21 @@ else:
|
|||||||
LLM_MODEL = GROQ_MODEL
|
LLM_MODEL = GROQ_MODEL
|
||||||
pg_pool_conn: pg_pool.ThreadedConnectionPool | None = None
|
pg_pool_conn: pg_pool.ThreadedConnectionPool | None = None
|
||||||
|
|
||||||
|
# Keep references to fire-and-forget tasks so they aren't garbage-collected
|
||||||
|
_bg_tasks: set = set()
|
||||||
|
|
||||||
|
def fire_and_forget(coro):
|
||||||
|
"""Schedule a coroutine in the background without awaiting it."""
|
||||||
|
task = asyncio.create_task(coro)
|
||||||
|
_bg_tasks.add(task)
|
||||||
|
task.add_done_callback(_bg_tasks.discard)
|
||||||
|
|
||||||
|
|
||||||
|
async def llm_create(**kwargs):
|
||||||
|
"""Run the (synchronous) LLM client call in a worker thread so the
|
||||||
|
Telegram event loop is never blocked while waiting on the API."""
|
||||||
|
return await asyncio.to_thread(lambda: llm_client.chat.completions.create(**kwargs))
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════
|
# ══════════════════════════════════════════════
|
||||||
# SYSTEM PROMPT BUILDER
|
# SYSTEM PROMPT BUILDER
|
||||||
@@ -157,7 +179,7 @@ async def get_embedding(text: str) -> list[float] | None:
|
|||||||
payload = {"model": EMBED_MODEL, "input": text}
|
payload = {"model": EMBED_MODEL, "input": text}
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=3)) as resp:
|
async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=10)) as resp:
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
log.warning(f"Ollama embed HTTP {resp.status}: {await resp.text()}")
|
log.warning(f"Ollama embed HTTP {resp.status}: {await resp.text()}")
|
||||||
return None
|
return None
|
||||||
@@ -245,7 +267,16 @@ class get_db:
|
|||||||
self.con = pg_pool_conn.getconn()
|
self.con = pg_pool_conn.getconn()
|
||||||
return self.con
|
return self.con
|
||||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
pg_pool_conn.putconn(self.con)
|
try:
|
||||||
|
if exc_type is not None:
|
||||||
|
# A failed statement leaves the connection in an aborted
|
||||||
|
# transaction — roll back before returning it to the pool.
|
||||||
|
try:
|
||||||
|
self.con.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
pg_pool_conn.putconn(self.con)
|
||||||
return False # don't suppress exceptions
|
return False # don't suppress exceptions
|
||||||
|
|
||||||
|
|
||||||
@@ -263,6 +294,13 @@ def upsert_user(user_id: str, username: str, full_name: str):
|
|||||||
con.commit()
|
con.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def user_exists(user_id: str) -> bool:
|
||||||
|
with get_db() as con:
|
||||||
|
with con.cursor() as cur:
|
||||||
|
cur.execute("SELECT 1 FROM users WHERE user_id = %s", (user_id,))
|
||||||
|
return cur.fetchone() is not None
|
||||||
|
|
||||||
|
|
||||||
# ── History ────────────────────────────────────
|
# ── History ────────────────────────────────────
|
||||||
def store_message_sync(chat_id: str, user_id: str | None, username: str,
|
def store_message_sync(chat_id: str, user_id: str | None, username: str,
|
||||||
role: str, content: str, embedding: list[float] | None):
|
role: str, content: str, embedding: list[float] | None):
|
||||||
@@ -275,15 +313,20 @@ def store_message_sync(chat_id: str, user_id: str | None, username: str,
|
|||||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||||
""", (BOT_NAME, chat_id, user_id, username, role, content, embedding))
|
""", (BOT_NAME, chat_id, user_id, username, role, content, embedding))
|
||||||
|
|
||||||
# Trim oldest beyond BOT_HISTORY_SAVE
|
# Trim oldest beyond BOT_HISTORY_SAVE.
|
||||||
cur.execute("""
|
# Probabilistic (~1 in 50 inserts): the subquery sorts the whole
|
||||||
DELETE FROM history
|
# chat history, which is too expensive to run on every message.
|
||||||
WHERE bot_name = %s AND chat_id = %s AND id NOT IN (
|
# Ordered by id — created has second resolution and ties are
|
||||||
SELECT id FROM history
|
# nondeterministic.
|
||||||
WHERE bot_name = %s AND chat_id = %s
|
if random.random() < 0.02:
|
||||||
ORDER BY created DESC LIMIT %s
|
cur.execute("""
|
||||||
)
|
DELETE FROM history
|
||||||
""", (BOT_NAME, chat_id, BOT_NAME, chat_id, BOT_HISTORY_SAVE))
|
WHERE bot_name = %s AND chat_id = %s AND id NOT IN (
|
||||||
|
SELECT id FROM history
|
||||||
|
WHERE bot_name = %s AND chat_id = %s
|
||||||
|
ORDER BY id DESC LIMIT %s
|
||||||
|
)
|
||||||
|
""", (BOT_NAME, chat_id, BOT_NAME, chat_id, BOT_HISTORY_SAVE))
|
||||||
con.commit()
|
con.commit()
|
||||||
|
|
||||||
|
|
||||||
@@ -304,7 +347,7 @@ def get_group_context(chat_id: str, limit: int = BOT_MAX_CONTEXT) -> list[dict]:
|
|||||||
SELECT role, username, content, created
|
SELECT role, username, content, created
|
||||||
FROM history
|
FROM history
|
||||||
WHERE bot_name = %s AND chat_id = %s
|
WHERE bot_name = %s AND chat_id = %s
|
||||||
ORDER BY created DESC LIMIT %s
|
ORDER BY id DESC LIMIT %s
|
||||||
""", (BOT_NAME, chat_id, limit))
|
""", (BOT_NAME, chat_id, limit))
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
return list(reversed(rows))
|
return list(reversed(rows))
|
||||||
@@ -454,14 +497,24 @@ TIMEZONE_PLACES = [
|
|||||||
"lisboa", "madrid", "berlim", "berlin", "reykjavik"
|
"lisboa", "madrid", "berlim", "berlin", "reykjavik"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def _kw_match(text: str, keywords) -> bool:
|
||||||
|
"""Whole-word/phrase match. Plain `kw in text` gives false positives:
|
||||||
|
'sol' matches 'consola', 'vento' matches 'invento', etc. — and those
|
||||||
|
false positives *force* a web_search tool call downstream."""
|
||||||
|
q = text.lower()
|
||||||
|
return any(
|
||||||
|
re.search(r"(?<!\w)" + re.escape(kw) + r"(?!\w)", q)
|
||||||
|
for kw in keywords
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def is_timezone_query(query: str) -> bool:
|
def is_timezone_query(query: str) -> bool:
|
||||||
q = query.lower()
|
if _kw_match(query, TIMEZONE_KEYWORDS):
|
||||||
if any(kw in q for kw in TIMEZONE_KEYWORDS):
|
|
||||||
return True
|
return True
|
||||||
# Short followup like "e na Islândia?" — place name + no other topic
|
# Short followup like "e na Islândia?" — place name + no other topic
|
||||||
if any(place in q for place in TIMEZONE_PLACES):
|
if _kw_match(query, TIMEZONE_PLACES):
|
||||||
words = q.split()
|
words = query.lower().split()
|
||||||
if len(words) <= 6 and not any(kw in q for kw in WEATHER_KEYWORDS):
|
if len(words) <= 6 and not _kw_match(query, WEATHER_KEYWORDS):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -473,8 +526,7 @@ NEWS_KEYWORDS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
def is_news_query(query: str) -> bool:
|
def is_news_query(query: str) -> bool:
|
||||||
q = query.lower()
|
return _kw_match(query, NEWS_KEYWORDS)
|
||||||
return any(kw in q for kw in NEWS_KEYWORDS)
|
|
||||||
|
|
||||||
# WMO weather code → Portuguese description
|
# WMO weather code → Portuguese description
|
||||||
WMO_CODES = {
|
WMO_CODES = {
|
||||||
@@ -489,46 +541,59 @@ WMO_CODES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
def is_weather_query(query: str) -> bool:
|
def is_weather_query(query: str) -> bool:
|
||||||
return any(kw in query.lower() for kw in WEATHER_KEYWORDS)
|
return _kw_match(query, WEATHER_KEYWORDS)
|
||||||
|
|
||||||
|
|
||||||
async def weather_search(query: str) -> str:
|
# Nominatim allows max 1 req/s — cache geocode results so repeated
|
||||||
|
# questions about the same place don't hit it again.
|
||||||
|
_geo_cache: dict[str, tuple[float, float, str]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
async def weather_search(query: str) -> str | None:
|
||||||
"""Geocode location then call Open-Meteo for current + forecast. No API key needed."""
|
"""Geocode location then call Open-Meteo for current + forecast. No API key needed."""
|
||||||
|
|
||||||
# Extract location using word-boundary replacement
|
# Extract location using word-boundary replacement
|
||||||
stop_words = (
|
stop_words = (
|
||||||
WEATHER_KEYWORDS
|
WEATHER_KEYWORDS
|
||||||
+ [BOT_TRIGGER, "hoje", "amanhã", "agora", "atual", "atualmente",
|
+ [BOT_TRIGGER, "hoje", "amanhã", "amanha", "agora", "atual", "atualmente",
|
||||||
"esta", "semana", "preciso", "precisas", "precisa",
|
"esta", "semana", "preciso", "precisas", "precisa",
|
||||||
"qual", "como", "está", "vai", "vais", "vou",
|
"qual", "como", "está", "vai", "vais", "vou",
|
||||||
|
"janeiro", "fevereiro", "março", "marco", "abril", "maio", "junho",
|
||||||
|
"julho", "agosto", "setembro", "outubro", "novembro", "dezembro",
|
||||||
"o", "a", "os", "as", "em", "de", "do", "da", "dos", "das",
|
"o", "a", "os", "as", "em", "de", "do", "da", "dos", "das",
|
||||||
"para", "por", "com", "sem", "?", "!"]
|
"para", "por", "com", "sem", "?", "!"]
|
||||||
)
|
)
|
||||||
location = query.lower()
|
location = query.lower()
|
||||||
for sw in sorted(stop_words, key=len, reverse=True):
|
for sw in sorted(stop_words, key=len, reverse=True):
|
||||||
location = re.sub(r"\b" + re.escape(sw) + r"\b", " ", location)
|
location = re.sub(r"\b" + re.escape(sw) + r"\b", " ", location)
|
||||||
location = " ".join(location.split()).strip()
|
# Drop stray numbers (day/year the model puts in search queries) —
|
||||||
|
# "faro 17 2026" geocodes to nothing and we'd lose the real weather data.
|
||||||
|
location = " ".join(w for w in location.split() if not w.isdigit()).strip()
|
||||||
|
|
||||||
if not location:
|
if not location:
|
||||||
return None # No location — let LLM answer from context
|
return None # No location — let LLM answer from context
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Step 1: Geocode with Nominatim
|
# Step 1: Geocode with Nominatim (cached — usage policy is 1 req/s)
|
||||||
geo_url = "https://nominatim.openstreetmap.org/search"
|
if location in _geo_cache:
|
||||||
geo_params = {"q": location, "format": "json", "limit": 1}
|
lat, lon, name = _geo_cache[location]
|
||||||
headers = {"User-Agent": "ManelBot/1.0"}
|
else:
|
||||||
|
geo_url = "https://nominatim.openstreetmap.org/search"
|
||||||
|
geo_params = {"q": location, "format": "json", "limit": 1}
|
||||||
|
headers = {"User-Agent": "ManelBot/1.0"}
|
||||||
|
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
async with session.get(geo_url, params=geo_params, headers=headers,
|
async with session.get(geo_url, params=geo_params, headers=headers,
|
||||||
timeout=aiohttp.ClientTimeout(total=8)) as r:
|
timeout=aiohttp.ClientTimeout(total=8)) as r:
|
||||||
geo = await r.json()
|
geo = await r.json()
|
||||||
|
|
||||||
if not geo:
|
if not geo:
|
||||||
return f"Não encontrei a localização '{location}'."
|
return f"Não encontrei a localização '{location}'."
|
||||||
|
|
||||||
lat = float(geo[0]["lat"])
|
lat = float(geo[0]["lat"])
|
||||||
lon = float(geo[0]["lon"])
|
lon = float(geo[0]["lon"])
|
||||||
name = geo[0]["display_name"].split(",")[0]
|
name = geo[0]["display_name"].split(",")[0]
|
||||||
|
_geo_cache[location] = (lat, lon, name)
|
||||||
|
|
||||||
# Step 2: Open-Meteo current + hourly forecast
|
# Step 2: Open-Meteo current + hourly forecast
|
||||||
om_url = "https://api.open-meteo.com/v1/forecast"
|
om_url = "https://api.open-meteo.com/v1/forecast"
|
||||||
@@ -570,12 +635,14 @@ async def weather_search(query: str) -> str:
|
|||||||
probs = data["hourly"]["precipitation_probability"]
|
probs = data["hourly"]["precipitation_probability"]
|
||||||
wcodes = data["hourly"]["weather_code"]
|
wcodes = data["hourly"]["weather_code"]
|
||||||
|
|
||||||
# Use UTC offset from Open-Meteo response to get correct local hour
|
# Use UTC offset from Open-Meteo response to get correct local time.
|
||||||
|
# Compare full timestamps — comparing only hour-of-day breaks near
|
||||||
|
# midnight (at 23:00 there would be no "upcoming" hours at all).
|
||||||
utc_offset = data.get("utc_offset_seconds", 0)
|
utc_offset = data.get("utc_offset_seconds", 0)
|
||||||
now_local_hour = (datetime.datetime.now(datetime.timezone.utc) +
|
now_local = (datetime.datetime.now(datetime.timezone.utc) +
|
||||||
datetime.timedelta(seconds=utc_offset)).hour
|
datetime.timedelta(seconds=utc_offset)).replace(tzinfo=None)
|
||||||
upcoming = [(h, t, p, w) for h, t, p, w in zip(hours, temps, probs, wcodes)
|
upcoming = [(h, t, p, w) for h, t, p, w in zip(hours, temps, probs, wcodes)
|
||||||
if int(h[11:13]) > now_local_hour][:4]
|
if datetime.datetime.fromisoformat(h) > now_local][:4]
|
||||||
|
|
||||||
if upcoming:
|
if upcoming:
|
||||||
result += "\n\n📅 Próximas horas:"
|
result += "\n\n📅 Próximas horas:"
|
||||||
@@ -667,7 +734,14 @@ async def web_search(query: str) -> str:
|
|||||||
# TRIGGER CHECK
|
# TRIGGER CHECK
|
||||||
# ══════════════════════════════════════════════
|
# ══════════════════════════════════════════════
|
||||||
def is_mentioned(text: str) -> bool:
|
def is_mentioned(text: str) -> bool:
|
||||||
return bool(re.search(re.escape(BOT_TRIGGER), text, re.IGNORECASE))
|
trig = re.escape(BOT_TRIGGER)
|
||||||
|
# 1) Whole-word match, any capitalization: "tabernas", "Tabernas,", "TABERNAS!"
|
||||||
|
# — but not inside other words ("tabernasca").
|
||||||
|
if re.search(r"(?<!\w)" + trig + r"(?!\w)", text, re.IGNORECASE):
|
||||||
|
return True
|
||||||
|
# 2) Telegram @username mentions: "@tabernas_bot", "@TabernasBot" —
|
||||||
|
# the trigger is glued to "_bot"/"bot" there, so rule 1 misses it.
|
||||||
|
return bool(re.search(r"@\w*" + trig + r"\w*", text, re.IGNORECASE))
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════
|
# ══════════════════════════════════════════════
|
||||||
@@ -731,15 +805,15 @@ TOOLS = [
|
|||||||
# GROQ — main chat function
|
# GROQ — main chat function
|
||||||
# ══════════════════════════════════════════════
|
# ══════════════════════════════════════════════
|
||||||
async def chat_with_groq(chat_id: str, user_id: str, user_first_name: str, user_message: str) -> str:
|
async def chat_with_groq(chat_id: str, user_id: str, user_first_name: str, user_message: str) -> str:
|
||||||
memories = get_memories(user_id)
|
# DB reads in a worker thread — never block the Telegram event loop
|
||||||
history = get_history_for_llm(chat_id)
|
memories = await asyncio.to_thread(get_memories, user_id)
|
||||||
|
history = await asyncio.to_thread(get_history_for_llm, chat_id)
|
||||||
system = build_system_prompt(user_first_name, memories)
|
system = build_system_prompt(user_first_name, memories)
|
||||||
|
|
||||||
messages = (
|
# NOTE: the triggering message was already stored by handle_message, so
|
||||||
[{"role": "system", "content": system}]
|
# it is the last entry of `history` (with its [name] prefix). Do NOT
|
||||||
+ history
|
# append it again — that duplicated the current message in every prompt.
|
||||||
+ [{"role": "user", "content": f"[{user_first_name}] {user_message}"}]
|
messages = [{"role": "system", "content": system}] + history
|
||||||
)
|
|
||||||
|
|
||||||
# Force web_search tool when weather, timezone or news keywords detected
|
# Force web_search tool when weather, timezone or news keywords detected
|
||||||
if is_weather_query(user_message):
|
if is_weather_query(user_message):
|
||||||
@@ -760,8 +834,9 @@ async def chat_with_groq(chat_id: str, user_id: str, user_first_name: str, user_
|
|||||||
messages[0]["content"] += "\n\nNEXT TOOL CALL: Search for Portuguese national news. Use query: noticias portugal hoje."
|
messages[0]["content"] += "\n\nNEXT TOOL CALL: Search for Portuguese national news. Use query: noticias portugal hoje."
|
||||||
else:
|
else:
|
||||||
tool_choice = "auto"
|
tool_choice = "auto"
|
||||||
|
forced = tool_choice != "auto"
|
||||||
|
|
||||||
response = llm_client.chat.completions.create(
|
response = await llm_create(
|
||||||
model=LLM_MODEL,
|
model=LLM_MODEL,
|
||||||
messages=messages,
|
messages=messages,
|
||||||
tools=TOOLS,
|
tools=TOOLS,
|
||||||
@@ -775,18 +850,31 @@ async def chat_with_groq(chat_id: str, user_id: str, user_first_name: str, user_
|
|||||||
|
|
||||||
if not tool_calls:
|
if not tool_calls:
|
||||||
reply = msg.content or ""
|
reply = msg.content or ""
|
||||||
# If forced tool_choice but model returned nothing, call weather directly
|
# Model was forced to call a tool but produced neither a tool call
|
||||||
if not reply.strip() or reply.strip() == "…":
|
# nor usable content.
|
||||||
|
if forced and (not reply.strip() or reply.strip() == "…"):
|
||||||
if is_weather_query(user_message):
|
if is_weather_query(user_message):
|
||||||
log.info("Model skipped forced tool — calling weather_search directly")
|
log.info("Model skipped forced tool — calling weather_search directly")
|
||||||
weather_result = await weather_search(user_message)
|
weather_result = await weather_search(user_message)
|
||||||
if weather_result:
|
if weather_result:
|
||||||
messages.append({"role": "user", "content": f"[weather data]\n{weather_result}\n\nAnswer the user naturally in Portuguese based on this data."})
|
messages.append({"role": "user", "content": f"[weather data]\n{weather_result}\n\nAnswer the user naturally in Portuguese based on this data."})
|
||||||
r2 = llm_client.chat.completions.create(
|
r2 = await llm_create(
|
||||||
model=LLM_MODEL, messages=messages, max_tokens=1024, temperature=0.7
|
model=LLM_MODEL, messages=messages, max_tokens=1024, temperature=0.7
|
||||||
)
|
)
|
||||||
return r2.choices[0].message.content or "…"
|
return r2.choices[0].message.content or "…"
|
||||||
return reply or "…"
|
# Timezone/news (or weather with no direct result): retry once
|
||||||
|
# without forcing so the model can answer normally.
|
||||||
|
log.info("Model skipped forced tool — retrying with tool_choice=auto")
|
||||||
|
r2 = await llm_create(
|
||||||
|
model=LLM_MODEL, messages=messages, tools=TOOLS,
|
||||||
|
tool_choice="auto", max_tokens=1024, temperature=0.7,
|
||||||
|
)
|
||||||
|
m2 = r2.choices[0].message
|
||||||
|
if not (m2.tool_calls or []):
|
||||||
|
return m2.content or "…"
|
||||||
|
msg, tool_calls = m2, m2.tool_calls
|
||||||
|
else:
|
||||||
|
return reply or "…"
|
||||||
|
|
||||||
messages.append({
|
messages.append({
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
@@ -799,54 +887,67 @@ async def chat_with_groq(chat_id: str, user_id: str, user_first_name: str, user_
|
|||||||
})
|
})
|
||||||
|
|
||||||
for tc in tool_calls:
|
for tc in tool_calls:
|
||||||
fn = tc.function.name
|
fn = tc.function.name
|
||||||
args = json.loads(tc.function.arguments)
|
# Each tool runs in its own try/except: one bad tool call (bad JSON
|
||||||
log.info(f"Tool: {fn}({args})")
|
# args, FK violation, network error) must not kill the whole reply.
|
||||||
|
try:
|
||||||
|
args = json.loads(tc.function.arguments)
|
||||||
|
log.info(f"Tool: {fn}({args})")
|
||||||
|
|
||||||
if fn == "web_search":
|
if fn == "web_search":
|
||||||
result = await web_search(args["query"])
|
result = await web_search(args["query"])
|
||||||
|
|
||||||
elif fn == "search_memory":
|
elif fn == "search_memory":
|
||||||
query = args["query"]
|
query = args["query"]
|
||||||
found = []
|
found = []
|
||||||
|
|
||||||
# ── 1. Semantic search over group history ──────────────
|
# ── 1. Semantic search over group history ──────────────
|
||||||
query_emb = await get_embedding(query)
|
query_emb = await get_embedding(query)
|
||||||
if query_emb:
|
if query_emb:
|
||||||
rows = await asyncio.to_thread(
|
rows = await asyncio.to_thread(
|
||||||
vector_search_sync, chat_id, query_emb, VECTOR_TOP_K
|
vector_search_sync, chat_id, query_emb, VECTOR_TOP_K
|
||||||
)
|
|
||||||
for row in rows:
|
|
||||||
sim = float(row["similarity"])
|
|
||||||
if sim < 0.3: # skip very low similarity
|
|
||||||
continue
|
|
||||||
ts = row["created"].strftime("%Y-%m-%d %H:%M") if row.get("created") else "?"
|
|
||||||
found.append(
|
|
||||||
f"[{ts}] {row.get('username', '?')} (sim={sim:.2f}): {row['content']}"
|
|
||||||
)
|
)
|
||||||
|
for row in rows:
|
||||||
|
sim = float(row["similarity"])
|
||||||
|
if sim < MEM_SIM_THRESHOLD: # skip low similarity
|
||||||
|
continue
|
||||||
|
ts = row["created"].strftime("%Y-%m-%d %H:%M") if row.get("created") else "?"
|
||||||
|
found.append(
|
||||||
|
f"[{ts}] {row.get('username', '?')} (sim={sim:.2f}): {row['content']}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
log.warning("search_memory: no embedding — skipping vector search")
|
||||||
|
|
||||||
|
# ── 2. Always include saved long-term memories ─────────
|
||||||
|
for mem in await asyncio.to_thread(get_memories, user_id):
|
||||||
|
found.append(f"[memory] {mem}")
|
||||||
|
|
||||||
|
if found:
|
||||||
|
result = "\n".join(found)
|
||||||
|
else:
|
||||||
|
result = "Nothing relevant found in memory."
|
||||||
|
|
||||||
|
elif fn == "save_memory":
|
||||||
|
# Only accept a target user_id the bot has actually seen;
|
||||||
|
# a hallucinated ID would violate the FK on memories.user_id.
|
||||||
|
# Default to the person speaking.
|
||||||
|
target_uid = str(args.get("user_id") or user_id)
|
||||||
|
if target_uid != user_id and not await asyncio.to_thread(user_exists, target_uid):
|
||||||
|
log.warning(f"save_memory: unknown user_id {target_uid}, saving under caller")
|
||||||
|
target_uid = user_id
|
||||||
|
await asyncio.to_thread(add_memory, target_uid, args["memory"])
|
||||||
|
result = f"Memory saved: {args['memory']}"
|
||||||
|
|
||||||
else:
|
else:
|
||||||
log.warning("search_memory: no embedding — skipping vector search")
|
result = "Unknown tool."
|
||||||
|
|
||||||
# ── 2. Always include saved long-term memories ─────────
|
except Exception as e:
|
||||||
for mem in get_memories(user_id):
|
log.error(f"Tool {fn} failed: {e}\n{traceback.format_exc()}")
|
||||||
found.append(f"[memory] {mem}")
|
result = f"Tool error: {e}"
|
||||||
|
|
||||||
if found:
|
|
||||||
result = "\n".join(found)
|
|
||||||
else:
|
|
||||||
result = "Nothing relevant found in memory."
|
|
||||||
|
|
||||||
elif fn == "save_memory":
|
|
||||||
target_uid = args.get("user_id", user_id)
|
|
||||||
add_memory(target_uid, args["memory"])
|
|
||||||
result = f"Memory saved: {args['memory']}"
|
|
||||||
|
|
||||||
else:
|
|
||||||
result = "Unknown tool."
|
|
||||||
|
|
||||||
messages.append({"role": "tool", "content": result, "tool_call_id": tc.id})
|
messages.append({"role": "tool", "content": result, "tool_call_id": tc.id})
|
||||||
|
|
||||||
response2 = llm_client.chat.completions.create(
|
response2 = await llm_create(
|
||||||
model=LLM_MODEL,
|
model=LLM_MODEL,
|
||||||
messages=messages,
|
messages=messages,
|
||||||
max_tokens=1024,
|
max_tokens=1024,
|
||||||
@@ -859,7 +960,12 @@ async def chat_with_groq(chat_id: str, user_id: str, user_first_name: str, user_
|
|||||||
# TELEGRAM HANDLERS
|
# TELEGRAM HANDLERS
|
||||||
# ══════════════════════════════════════════════
|
# ══════════════════════════════════════════════
|
||||||
async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
upsert_user(str(update.effective_user.id), update.effective_user.username or "", update.effective_user.full_name)
|
await asyncio.to_thread(
|
||||||
|
upsert_user,
|
||||||
|
str(update.effective_user.id),
|
||||||
|
update.effective_user.username or "",
|
||||||
|
update.effective_user.full_name,
|
||||||
|
)
|
||||||
owner_hint = "\n\n🔑 És o owner deste bot." if is_owner(update) else ""
|
owner_hint = "\n\n🔑 És o owner deste bot." if is_owner(update) else ""
|
||||||
await update.message.reply_text(
|
await update.message.reply_text(
|
||||||
f"👋 Olá {update.effective_user.first_name}! Sou o **{BOT_NAME}** 😊\n\n"
|
f"👋 Olá {update.effective_user.first_name}! Sou o **{BOT_NAME}** 😊\n\n"
|
||||||
@@ -881,7 +987,7 @@ async def cmd_help(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
|||||||
|
|
||||||
async def cmd_memories(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
async def cmd_memories(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
uid = str(update.effective_user.id)
|
uid = str(update.effective_user.id)
|
||||||
rows = get_all_memories_indexed(uid)
|
rows = await asyncio.to_thread(get_all_memories_indexed, uid)
|
||||||
if not rows:
|
if not rows:
|
||||||
await update.message.reply_text("🧠 Ainda não tenho memórias tuas. Fala comigo!")
|
await update.message.reply_text("🧠 Ainda não tenho memórias tuas. Fala comigo!")
|
||||||
return
|
return
|
||||||
@@ -895,10 +1001,10 @@ async def cmd_forget(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
|||||||
if not args or not args[0].isdigit():
|
if not args or not args[0].isdigit():
|
||||||
await update.message.reply_text("Uso: /esquecer `<número>` (obtém números com /memorias)", parse_mode="Markdown")
|
await update.message.reply_text("Uso: /esquecer `<número>` (obtém números com /memorias)", parse_mode="Markdown")
|
||||||
return
|
return
|
||||||
rows = get_all_memories_indexed(uid)
|
rows = await asyncio.to_thread(get_all_memories_indexed, uid)
|
||||||
idx = int(args[0]) - 1
|
idx = int(args[0]) - 1
|
||||||
if 0 <= idx < len(rows):
|
if 0 <= idx < len(rows):
|
||||||
delete_memory_by_id(rows[idx][0])
|
await asyncio.to_thread(delete_memory_by_id, rows[idx][0])
|
||||||
await update.message.reply_text("✅ Memória apagada.")
|
await update.message.reply_text("✅ Memória apagada.")
|
||||||
else:
|
else:
|
||||||
await update.message.reply_text("❌ Número inválido.")
|
await update.message.reply_text("❌ Número inválido.")
|
||||||
@@ -908,17 +1014,17 @@ async def cmd_clear_user_memories(update: Update, ctx: ContextTypes.DEFAULT_TYPE
|
|||||||
if not ctx.args:
|
if not ctx.args:
|
||||||
await update.message.reply_text("Uso: /apagarmem `<user_id>`", parse_mode="Markdown")
|
await update.message.reply_text("Uso: /apagarmem `<user_id>`", parse_mode="Markdown")
|
||||||
return
|
return
|
||||||
clear_memories(ctx.args[0])
|
await asyncio.to_thread(clear_memories, ctx.args[0])
|
||||||
await update.message.reply_text(f"🗑️ Memórias do utilizador `{ctx.args[0]}` apagadas.", parse_mode="Markdown")
|
await update.message.reply_text(f"🗑️ Memórias do utilizador `{ctx.args[0]}` apagadas.", parse_mode="Markdown")
|
||||||
|
|
||||||
@owner_only
|
@owner_only
|
||||||
async def cmd_clearall(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
async def cmd_clearall(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
clear_all_memories()
|
await asyncio.to_thread(clear_all_memories)
|
||||||
await update.message.reply_text(f"🗑️ Todas as memórias do {BOT_NAME} foram apagadas.")
|
await update.message.reply_text(f"🗑️ Todas as memórias do {BOT_NAME} foram apagadas.")
|
||||||
|
|
||||||
@owner_only
|
@owner_only
|
||||||
async def cmd_newchat(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
async def cmd_newchat(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
||||||
clear_history(str(update.effective_chat.id))
|
await asyncio.to_thread(clear_history, str(update.effective_chat.id))
|
||||||
await update.message.reply_text("🔄 Histórico do grupo limpo! (Memórias de longo prazo mantidas.)")
|
await update.message.reply_text("🔄 Histórico do grupo limpo! (Memórias de longo prazo mantidas.)")
|
||||||
|
|
||||||
|
|
||||||
@@ -932,15 +1038,20 @@ async def handle_message(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
|||||||
text = update.message.text
|
text = update.message.text
|
||||||
fname = user.first_name or "utilizador"
|
fname = user.first_name or "utilizador"
|
||||||
|
|
||||||
upsert_user(uid, user.username or "", user.full_name or fname)
|
await asyncio.to_thread(upsert_user, uid, user.username or "", user.full_name or fname)
|
||||||
|
|
||||||
# Always store + embed every message, even when bot stays silent
|
|
||||||
await store_message(chat_id, uid, fname, "user", text)
|
|
||||||
|
|
||||||
if not is_mentioned(text):
|
if not is_mentioned(text):
|
||||||
log.debug(f"[{BOT_NAME}] Stored silently: {text[:60]}")
|
# Store + embed in the background — no reason to make the handler
|
||||||
|
# wait on Ollama for a message the bot won't answer.
|
||||||
|
fire_and_forget(store_message(chat_id, uid, fname, "user", text))
|
||||||
|
log.debug(f"[{BOT_NAME}] Storing silently: {text[:60]}")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Mentioned: store BEFORE generating, and await it — chat_with_groq
|
||||||
|
# reads this message back from the history table (it must not be
|
||||||
|
# appended a second time there).
|
||||||
|
await store_message(chat_id, uid, fname, "user", text)
|
||||||
|
|
||||||
await update.message.chat.send_action("typing")
|
await update.message.chat.send_action("typing")
|
||||||
|
|
||||||
error_reply = False
|
error_reply = False
|
||||||
@@ -952,7 +1063,7 @@ async def handle_message(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
|
|||||||
error_reply = True
|
error_reply = True
|
||||||
|
|
||||||
if not error_reply:
|
if not error_reply:
|
||||||
await store_message(chat_id, None, BOT_NAME, "assistant", reply)
|
fire_and_forget(store_message(chat_id, None, BOT_NAME, "assistant", reply))
|
||||||
|
|
||||||
for chunk in [reply[i:i+4096] for i in range(0, len(reply), 4096)]:
|
for chunk in [reply[i:i+4096] for i in range(0, len(reply), 4096)]:
|
||||||
if not chunk.strip():
|
if not chunk.strip():
|
||||||
|
|||||||
7
gitignore.txt
Normal file
7
gitignore.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.env_credentials
|
||||||
|
new_env_c
|
||||||
|
*.db
|
||||||
|
*.log
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env.local
|
||||||
22
pyproject.toml
Normal file
22
pyproject.toml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
[tool.poetry]
|
||||||
|
name = "tabernas-bot"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Telegram bot. Tabernas. Maluco e irreverente"
|
||||||
|
authors = ["Raude"]
|
||||||
|
license = "Beer ware"
|
||||||
|
readme = "README.md"
|
||||||
|
package-mode = false
|
||||||
|
|
||||||
|
[tool.poetry.dependencies]
|
||||||
|
python = "^3.12"
|
||||||
|
python-telegram-bot = "21.6"
|
||||||
|
groq = "^1.1.0"
|
||||||
|
aiohttp = "^3.9.0"
|
||||||
|
psycopg2-binary = "^2.9.9"
|
||||||
|
python-dotenv = "^1.0.0"
|
||||||
|
pgvector = "^0.4.2"
|
||||||
|
openai = "^2.29.0"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["poetry-core"]
|
||||||
|
build-backend = "poetry.core.masonry.api"
|
||||||
Reference in New Issue
Block a user