Added search from Brave search.

This commit is contained in:
Jeremias
2026-07-19 22:28:33 +00:00
parent 58bc314506
commit 779091f3fb
2 changed files with 239 additions and 77 deletions

184
bot.py
View File

@@ -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"</?[|]DSML[|][^>]*>", # DeepSeek DSML tags: <DSMLtool_calls> etc.
r"<[|][^<>]*[|]>", # generic <...> special tokens
r"</?tool_calls?[^>]*>", # <tool_call> style wrappers
r"</?function(?:_call)?[^>]*>",
]
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,23 +953,41 @@ 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 ""
# ── 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 "",
@@ -888,14 +1000,36 @@ async def chat_with_groq(chat_id: str, user_id: str, user_first_name: str, user_
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})")
# 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
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}")
elif fn == "search_memory":
query = args["query"]
@@ -945,15 +1079,41 @@ async def chat_with_groq(chat_id: str, user_id: str, user_first_name: str, user_
log.error(f"Tool {fn} failed: {e}\n{traceback.format_exc()}")
result = f"Tool error: {e}"
seen_calls[call_key] = result
messages.append({"role": "tool", "content": result, "tool_call_id": tc.id})
response2 = await llm_create(
if rounds < MAX_TOOL_ROUNDS:
# Model may chain another tool call if it needs to
response = await llm_create(
model=LLM_MODEL,
messages=messages,
max_tokens=1024,
tools=TOOLS,
tool_choice="auto",
max_tokens=2048,
temperature=0.7,
)
return response2.choices[0].message.content or ""
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 []
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 ""
# ══════════════════════════════════════════════

View File

@@ -37,3 +37,5 @@ EMBED_DIM=768
VECTOR_TOP_K=8
NEWSDATA_API_KEY=some_key
BRAVE_API_KEY=some_key_from_brave_search