diff --git a/bot.py b/bot.py index 50cc3c4..e1f5b24 100644 --- a/bot.py +++ b/bot.py @@ -65,6 +65,7 @@ OWNER_ID = int(os.getenv("OWNER_TELEGRAM_ID", "0")) # NewsData.io NEWSDATA_API_KEY = os.getenv("NEWSDATA_API_KEY", "") +BRAVE_API_KEY = os.getenv("BRAVE_API_KEY", "") # real web search; DDG fallback if unset # Ollama endpoint and embedding model OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434") @@ -157,7 +158,13 @@ def build_system_prompt(user_first_name: str, memories: list[str]) -> str: time_block = ( f"Today is: {date_str}\n" f"Current time ({BOT_TIMEZONE}): {time_str}\n" - f"For other timezones, use the web_search tool." + f"For other timezones, use the web_search tool.\n" + f"IMPORTANT — your training data predates today. For sports results, " + f"competitions, news or any current events: (1) ALWAYS include the year " + f"{now_home.year} in your search query — e.g. 'mundial {now_home.year}', " + f"never a different year; (2) base your answer ONLY on the search results, " + f"never on what you remember about similar past events; (3) if the results " + f"don't contain the answer, say you don't know — do not fill gaps from memory." ) sections.append("# Current Date and Time\n" + time_block) @@ -560,6 +567,12 @@ async def weather_search(query: str) -> str | None: "qual", "como", "está", "vai", "vais", "vou", "janeiro", "fevereiro", "março", "marco", "abril", "maio", "junho", "julho", "agosto", "setembro", "outubro", "novembro", "dezembro", + # the model frequently writes tool queries in English + "january", "february", "march", "april", "may", "june", "july", + "august", "september", "october", "november", "december", + "today", "tomorrow", "tonight", "now", "current", "currently", + "this", "week", "weekend", "what", "whats", "is", "the", "in", + "at", "for", "of", "and", "o", "a", "os", "as", "em", "de", "do", "da", "dos", "das", "para", "por", "com", "sem", "?", "!"] ) @@ -588,7 +601,10 @@ async def weather_search(query: str) -> str | None: geo = await r.json() if not geo: - return f"Não encontrei a localização '{location}'." + # Signal "couldn't geocode" — caller falls back to a normal + # web search instead of telling the model to rephrase forever. + log.warning(f"Geocode miss for extracted location: '{location}'") + return None lat = float(geo[0]["lat"]) lon = float(geo[0]["lon"]) @@ -663,7 +679,9 @@ async def web_search(query: str) -> str: result = await weather_search(query) if result is not None: return result - return "No location found. Please answer using weather data already in conversation context." + # Couldn't extract/geocode a location — fall through to a regular + # web search of the original query rather than dead-ending the model. + log.info("weather_search found no location — falling back to general search") # Use NewsData.io for news queries, DuckDuckGo for everything else if is_news_query(query) and NEWSDATA_API_KEY: @@ -710,7 +728,41 @@ async def web_search(query: str) -> str: except Exception as e: log.warning(f"NewsData.io error: {e}") - # DuckDuckGo for non-news queries + # Brave Search — real web results (titles + snippets), best for current + # events, sports, prices. Free tier: ~2000 queries/month. + if BRAVE_API_KEY: + try: + async with aiohttp.ClientSession() as session: + async with session.get( + "https://api.search.brave.com/res/v1/web/search", + params={"q": query, "count": 5}, + headers={"X-Subscription-Token": BRAVE_API_KEY, + "Accept": "application/json"}, + timeout=aiohttp.ClientTimeout(total=8), + ) as resp: + if resp.status == 429: + log.warning("Brave API rate/quota limit hit — falling back to DDG") + raise RuntimeError("brave quota") + resp.raise_for_status() + data = await resp.json() + + lines = [] + for r in (data.get("web", {}).get("results") or [])[:5]: + title = (r.get("title") or "").strip() + desc = re.sub(r"<[^>]+>", "", r.get("description") or "").strip() + url_ = r.get("url", "") + age = r.get("age", "") + if title: + lines.append(f"• {title}" + (f" ({age})" if age else "") + + (f"\n {desc}" if desc else "") + + (f"\n {url_}" if url_ else "")) + if lines: + return "\n".join(lines) + log.info("Brave returned no results — falling back to DDG") + except Exception as e: + log.warning(f"Brave search error: {e} — falling back to DDG") + + # DuckDuckGo Instant Answer fallback (encyclopedic answers only) url = f"https://api.duckduckgo.com/?q={quote_plus(query)}&format=json&no_redirect=1&no_html=1&skip_disambig=1" try: async with aiohttp.ClientSession() as session: @@ -733,6 +785,33 @@ async def web_search(query: str) -> str: # ══════════════════════════════════════════════ # TRIGGER CHECK # ══════════════════════════════════════════════ +# Some models (e.g. DeepSeek) occasionally emit their native tool-call +# markup as plain text instead of structured tool_calls — that must never +# be posted to the chat. +_MARKUP_PATTERNS = [ + r"]*>", # DeepSeek DSML tags: <|DSML|tool_calls> etc. + r"<[||][^<>]*[||]>", # generic <|...|> special tokens + r"]*>", # style wrappers + r"]*>", +] + +def sanitize_reply(text: str) -> str: + """Cut the reply at the first tool-markup tag. Any text after the model + starts 'calling a tool' in plain text is machinery, not an answer — + a pure-markup reply becomes empty and the caller falls back to '…'.""" + if not text or "<" not in text: + return text + earliest = len(text) + for p in _MARKUP_PATTERNS: + m = re.search(p, text, flags=re.IGNORECASE) + if m: + earliest = min(earliest, m.start()) + if earliest == len(text): + return text + log.warning("Model reply contained raw tool-call markup — truncated before it") + return text[:earliest].strip() + + def is_mentioned(text: str) -> bool: trig = re.escape(BOT_TRIGGER) # 1) Whole-word match, any capitalization: "tabernas", "Tabernas,", "TABERNAS!" @@ -804,6 +883,21 @@ TOOLS = [ # ══════════════════════════════════════════════ # GROQ — main chat function # ══════════════════════════════════════════════ +async def _nudge_final(messages: list) -> str: + """One retry with an explicit plain-text instruction — used whenever the + model returns an empty (or markup-only) answer.""" + messages.append({ + "role": "user", + "content": ("[system] Responde AGORA em texto simples, em português, " + "com base na conversa acima. Sem chamadas de ferramentas. " + "Se não tiveres a informação, di-lo honestamente e com humor.") + }) + r = await llm_create( + model=LLM_MODEL, messages=messages, max_tokens=2048, temperature=0.7 + ) + return sanitize_reply(r.choices[0].message.content or "") + + async def chat_with_groq(chat_id: str, user_id: str, user_first_name: str, user_message: str) -> str: # DB reads in a worker thread — never block the Telegram event loop memories = await asyncio.to_thread(get_memories, user_id) @@ -841,7 +935,7 @@ async def chat_with_groq(chat_id: str, user_id: str, user_first_name: str, user_ messages=messages, tools=TOOLS, tool_choice=tool_choice, - max_tokens=1024, + max_tokens=2048, temperature=0.7, ) @@ -859,101 +953,167 @@ async def chat_with_groq(chat_id: str, user_id: str, user_first_name: str, user_ 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."}) r2 = await llm_create( - model=LLM_MODEL, messages=messages, max_tokens=1024, temperature=0.7 + model=LLM_MODEL, messages=messages, max_tokens=2048, temperature=0.7 ) - return r2.choices[0].message.content or "…" + return sanitize_reply(r2.choices[0].message.content or "") 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, + tool_choice="auto", max_tokens=2048, temperature=0.7, ) m2 = r2.choices[0].message if not (m2.tool_calls or []): - return m2.content or "…" + return sanitize_reply(m2.content or "") or "…" msg, tool_calls = m2, m2.tool_calls else: + reply = sanitize_reply(reply) + if not reply: + # No tools, no text — typically the model spent the whole + # token budget on reasoning. Nudge once for a plain answer. + log.warning( + f"Empty direct reply (finish_reason=" + f"{response.choices[0].finish_reason}) — retrying with nudge" + ) + reply = await _nudge_final(messages) return reply or "…" - messages.append({ - "role": "assistant", - "content": msg.content or "", - "tool_calls": [ - {"id": tc.id, "type": "function", - "function": {"name": tc.function.name, "arguments": tc.function.arguments}} - for tc in tool_calls - ] - }) + # ── Multi-round tool loop ───────────────────────────────────────── + # The model may need to chain tools (search the city, then search its + # weather). Previously the post-tool call passed no `tools`, so models + # wanting a second search emitted raw tool markup as text. + MAX_TOOL_ROUNDS = 3 + rounds = 0 + seen_calls: dict = {} # (fn, normalized args) -> previous result + while tool_calls: + rounds += 1 + messages.append({ + "role": "assistant", + "content": msg.content or "", + "tool_calls": [ + {"id": tc.id, "type": "function", + "function": {"name": tc.function.name, "arguments": tc.function.arguments}} + for tc in tool_calls + ] + }) - for tc in tool_calls: - fn = tc.function.name - # Each tool runs in its own try/except: one bad tool call (bad JSON - # args, FK violation, network error) must not kill the whole reply. - try: - args = json.loads(tc.function.arguments) - log.info(f"Tool: {fn}({args})") + for tc in tool_calls: + fn = tc.function.name + # Key for dedupe — computed outside the try so it always exists, + # even when the arguments fail to parse. + call_key = (fn, (tc.function.arguments or "").strip().lower()) + # Each tool runs in its own try/except: one bad tool call (bad JSON + # 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": - result = await web_search(args["query"]) + # Dedupe: the model sometimes re-issues near-identical searches + # and burns the whole round budget. Return the cached result + # with an explicit instruction to answer. + if call_key in seen_calls: + log.info("Duplicate tool call detected — returning cached result") + result = ("[REPEATED SEARCH — same result as before]\n" + + seen_calls[call_key] + + "\n\nDo NOT search again. Answer the user now " + "in Portuguese using the information above. If the " + "information is insufficient, say so honestly.") + messages.append({"role": "tool", "content": result, "tool_call_id": tc.id}) + continue - elif fn == "search_memory": - query = args["query"] - found = [] + if fn == "web_search": + result = await web_search(args["query"]) + # Stamp results with today's date — reminds the model at + # the moment of answering that these are current, and its + # own memory of similar past events is not. + today = datetime.datetime.now().strftime("%d/%m/%Y") + result = (f"[Search results — today is {today}. Answer from " + f"these results, not from memory.]\n{result}") - # ── 1. Semantic search over group history ────────────── - query_emb = await get_embedding(query) - if query_emb: - rows = await asyncio.to_thread( - vector_search_sync, chat_id, query_emb, VECTOR_TOP_K - ) - 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']}" + elif fn == "search_memory": + query = args["query"] + found = [] + + # ── 1. Semantic search over group history ────────────── + query_emb = await get_embedding(query) + if query_emb: + rows = await asyncio.to_thread( + vector_search_sync, chat_id, query_emb, VECTOR_TOP_K ) + 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: - log.warning("search_memory: no embedding — skipping vector search") + result = "Unknown tool." - # ── 2. Always include saved long-term memories ───────── - for mem in await asyncio.to_thread(get_memories, user_id): - found.append(f"[memory] {mem}") + except Exception as e: + log.error(f"Tool {fn} failed: {e}\n{traceback.format_exc()}") + result = f"Tool error: {e}" - if found: - result = "\n".join(found) - else: - result = "Nothing relevant found in memory." + seen_calls[call_key] = result + messages.append({"role": "tool", "content": result, "tool_call_id": tc.id}) - 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']}" + if rounds < MAX_TOOL_ROUNDS: + # Model may chain another tool call if it needs to + response = await llm_create( + model=LLM_MODEL, + messages=messages, + tools=TOOLS, + tool_choice="auto", + max_tokens=2048, + temperature=0.7, + ) + else: + # Round budget spent — force a plain-text answer + log.warning(f"Tool round limit ({MAX_TOOL_ROUNDS}) reached — forcing final answer") + response = await llm_create( + model=LLM_MODEL, + messages=messages, + max_tokens=2048, + temperature=0.7, + ) + msg = response.choices[0].message + tool_calls = (msg.tool_calls or []) if rounds < MAX_TOOL_ROUNDS else [] - else: - result = "Unknown tool." - - except Exception as e: - log.error(f"Tool {fn} failed: {e}\n{traceback.format_exc()}") - result = f"Tool error: {e}" - - messages.append({"role": "tool", "content": result, "tool_call_id": tc.id}) - - response2 = await llm_create( - model=LLM_MODEL, - messages=messages, - max_tokens=1024, - temperature=0.7, - ) - return response2.choices[0].message.content or "…" + reply = sanitize_reply(msg.content or "") + if not reply: + # Model produced nothing (or only tool markup). One retry with an + # explicit instruction — this is what caused the bare "…" replies. + log.warning( + f"Empty final answer (finish_reason=" + f"{response.choices[0].finish_reason}) — retrying once with nudge" + ) + reply = await _nudge_final(messages) + return reply or "…" # ══════════════════════════════════════════════ diff --git a/env_credentials b/env_credentials index 2b1d65f..20c5e29 100644 --- a/env_credentials +++ b/env_credentials @@ -37,3 +37,5 @@ EMBED_DIM=768 VECTOR_TOP_K=8 NEWSDATA_API_KEY=some_key + +BRAVE_API_KEY=some_key_from_brave_search