Added search from Brave search.
This commit is contained in:
314
bot.py
314
bot.py
@@ -65,6 +65,7 @@ OWNER_ID = int(os.getenv("OWNER_TELEGRAM_ID", "0"))
|
|||||||
|
|
||||||
# NewsData.io
|
# NewsData.io
|
||||||
NEWSDATA_API_KEY = os.getenv("NEWSDATA_API_KEY", "")
|
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 endpoint and embedding model
|
||||||
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434")
|
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 = (
|
time_block = (
|
||||||
f"Today is: {date_str}\n"
|
f"Today is: {date_str}\n"
|
||||||
f"Current time ({BOT_TIMEZONE}): {time_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)
|
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",
|
"qual", "como", "está", "vai", "vais", "vou",
|
||||||
"janeiro", "fevereiro", "março", "marco", "abril", "maio", "junho",
|
"janeiro", "fevereiro", "março", "marco", "abril", "maio", "junho",
|
||||||
"julho", "agosto", "setembro", "outubro", "novembro", "dezembro",
|
"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",
|
"o", "a", "os", "as", "em", "de", "do", "da", "dos", "das",
|
||||||
"para", "por", "com", "sem", "?", "!"]
|
"para", "por", "com", "sem", "?", "!"]
|
||||||
)
|
)
|
||||||
@@ -588,7 +601,10 @@ async def weather_search(query: str) -> str | None:
|
|||||||
geo = await r.json()
|
geo = await r.json()
|
||||||
|
|
||||||
if not geo:
|
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"])
|
lat = float(geo[0]["lat"])
|
||||||
lon = float(geo[0]["lon"])
|
lon = float(geo[0]["lon"])
|
||||||
@@ -663,7 +679,9 @@ async def web_search(query: str) -> str:
|
|||||||
result = await weather_search(query)
|
result = await weather_search(query)
|
||||||
if result is not None:
|
if result is not None:
|
||||||
return result
|
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
|
# Use NewsData.io for news queries, DuckDuckGo for everything else
|
||||||
if is_news_query(query) and NEWSDATA_API_KEY:
|
if is_news_query(query) and NEWSDATA_API_KEY:
|
||||||
@@ -710,7 +728,41 @@ async def web_search(query: str) -> str:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning(f"NewsData.io error: {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"
|
url = f"https://api.duckduckgo.com/?q={quote_plus(query)}&format=json&no_redirect=1&no_html=1&skip_disambig=1"
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
@@ -733,6 +785,33 @@ async def web_search(query: str) -> str:
|
|||||||
# ══════════════════════════════════════════════
|
# ══════════════════════════════════════════════
|
||||||
# TRIGGER CHECK
|
# 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: <|DSML|tool_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:
|
def is_mentioned(text: str) -> bool:
|
||||||
trig = re.escape(BOT_TRIGGER)
|
trig = re.escape(BOT_TRIGGER)
|
||||||
# 1) Whole-word match, any capitalization: "tabernas", "Tabernas,", "TABERNAS!"
|
# 1) Whole-word match, any capitalization: "tabernas", "Tabernas,", "TABERNAS!"
|
||||||
@@ -804,6 +883,21 @@ TOOLS = [
|
|||||||
# ══════════════════════════════════════════════
|
# ══════════════════════════════════════════════
|
||||||
# GROQ — main chat function
|
# 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:
|
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
|
# DB reads in a worker thread — never block the Telegram event loop
|
||||||
memories = await asyncio.to_thread(get_memories, user_id)
|
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,
|
messages=messages,
|
||||||
tools=TOOLS,
|
tools=TOOLS,
|
||||||
tool_choice=tool_choice,
|
tool_choice=tool_choice,
|
||||||
max_tokens=1024,
|
max_tokens=2048,
|
||||||
temperature=0.7,
|
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:
|
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 = await llm_create(
|
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
|
# Timezone/news (or weather with no direct result): retry once
|
||||||
# without forcing so the model can answer normally.
|
# without forcing so the model can answer normally.
|
||||||
log.info("Model skipped forced tool — retrying with tool_choice=auto")
|
log.info("Model skipped forced tool — retrying with tool_choice=auto")
|
||||||
r2 = await llm_create(
|
r2 = await llm_create(
|
||||||
model=LLM_MODEL, messages=messages, tools=TOOLS,
|
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
|
m2 = r2.choices[0].message
|
||||||
if not (m2.tool_calls or []):
|
if not (m2.tool_calls or []):
|
||||||
return m2.content or "…"
|
return sanitize_reply(m2.content or "") or "…"
|
||||||
msg, tool_calls = m2, m2.tool_calls
|
msg, tool_calls = m2, m2.tool_calls
|
||||||
else:
|
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 "…"
|
return reply or "…"
|
||||||
|
|
||||||
messages.append({
|
# ── Multi-round tool loop ─────────────────────────────────────────
|
||||||
"role": "assistant",
|
# The model may need to chain tools (search the city, then search its
|
||||||
"content": msg.content or "",
|
# weather). Previously the post-tool call passed no `tools`, so models
|
||||||
"tool_calls": [
|
# wanting a second search emitted raw tool markup as text.
|
||||||
{"id": tc.id, "type": "function",
|
MAX_TOOL_ROUNDS = 3
|
||||||
"function": {"name": tc.function.name, "arguments": tc.function.arguments}}
|
rounds = 0
|
||||||
for tc in tool_calls
|
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:
|
for tc in tool_calls:
|
||||||
fn = tc.function.name
|
fn = tc.function.name
|
||||||
# Each tool runs in its own try/except: one bad tool call (bad JSON
|
# Key for dedupe — computed outside the try so it always exists,
|
||||||
# args, FK violation, network error) must not kill the whole reply.
|
# even when the arguments fail to parse.
|
||||||
try:
|
call_key = (fn, (tc.function.arguments or "").strip().lower())
|
||||||
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":
|
# Dedupe: the model sometimes re-issues near-identical searches
|
||||||
result = await web_search(args["query"])
|
# 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":
|
if fn == "web_search":
|
||||||
query = args["query"]
|
result = await web_search(args["query"])
|
||||||
found = []
|
# 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 ──────────────
|
elif fn == "search_memory":
|
||||||
query_emb = await get_embedding(query)
|
query = args["query"]
|
||||||
if query_emb:
|
found = []
|
||||||
rows = await asyncio.to_thread(
|
|
||||||
vector_search_sync, chat_id, query_emb, VECTOR_TOP_K
|
# ── 1. Semantic search over group history ──────────────
|
||||||
)
|
query_emb = await get_embedding(query)
|
||||||
for row in rows:
|
if query_emb:
|
||||||
sim = float(row["similarity"])
|
rows = await asyncio.to_thread(
|
||||||
if sim < MEM_SIM_THRESHOLD: # skip low similarity
|
vector_search_sync, chat_id, query_emb, VECTOR_TOP_K
|
||||||
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 await asyncio.to_thread(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:
|
seen_calls[call_key] = result
|
||||||
result = "\n".join(found)
|
messages.append({"role": "tool", "content": result, "tool_call_id": tc.id})
|
||||||
else:
|
|
||||||
result = "Nothing relevant found in memory."
|
|
||||||
|
|
||||||
elif fn == "save_memory":
|
if rounds < MAX_TOOL_ROUNDS:
|
||||||
# Only accept a target user_id the bot has actually seen;
|
# Model may chain another tool call if it needs to
|
||||||
# a hallucinated ID would violate the FK on memories.user_id.
|
response = await llm_create(
|
||||||
# Default to the person speaking.
|
model=LLM_MODEL,
|
||||||
target_uid = str(args.get("user_id") or user_id)
|
messages=messages,
|
||||||
if target_uid != user_id and not await asyncio.to_thread(user_exists, target_uid):
|
tools=TOOLS,
|
||||||
log.warning(f"save_memory: unknown user_id {target_uid}, saving under caller")
|
tool_choice="auto",
|
||||||
target_uid = user_id
|
max_tokens=2048,
|
||||||
await asyncio.to_thread(add_memory, target_uid, args["memory"])
|
temperature=0.7,
|
||||||
result = f"Memory saved: {args['memory']}"
|
)
|
||||||
|
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:
|
reply = sanitize_reply(msg.content or "")
|
||||||
result = "Unknown tool."
|
if not reply:
|
||||||
|
# Model produced nothing (or only tool markup). One retry with an
|
||||||
except Exception as e:
|
# explicit instruction — this is what caused the bare "…" replies.
|
||||||
log.error(f"Tool {fn} failed: {e}\n{traceback.format_exc()}")
|
log.warning(
|
||||||
result = f"Tool error: {e}"
|
f"Empty final answer (finish_reason="
|
||||||
|
f"{response.choices[0].finish_reason}) — retrying once with nudge"
|
||||||
messages.append({"role": "tool", "content": result, "tool_call_id": tc.id})
|
)
|
||||||
|
reply = await _nudge_final(messages)
|
||||||
response2 = await llm_create(
|
return reply or "…"
|
||||||
model=LLM_MODEL,
|
|
||||||
messages=messages,
|
|
||||||
max_tokens=1024,
|
|
||||||
temperature=0.7,
|
|
||||||
)
|
|
||||||
return response2.choices[0].message.content or "…"
|
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════
|
# ══════════════════════════════════════════════
|
||||||
|
|||||||
@@ -37,3 +37,5 @@ EMBED_DIM=768
|
|||||||
VECTOR_TOP_K=8
|
VECTOR_TOP_K=8
|
||||||
|
|
||||||
NEWSDATA_API_KEY=some_key
|
NEWSDATA_API_KEY=some_key
|
||||||
|
|
||||||
|
BRAVE_API_KEY=some_key_from_brave_search
|
||||||
|
|||||||
Reference in New Issue
Block a user