amigo / assistant /chat.py
Jose Esparza
First up
46bd2ee unverified
Raw
History Blame Contribute Delete
5.5 kB
from __future__ import annotations
import re
from datetime import date
from typing import Iterator
from config import CONFIG
from assistant import llm, memory, tools
PACK = CONFIG.pack
# Speaker labels for the search-query transcript, per language.
_ROLE_LABELS = {
"es": {"user": "Usuario", "assistant": "Amigo", "cue": "Consulta"},
"en": {"user": "User", "assistant": "Amigo", "cue": "Query"},
}
_YEAR = re.compile(r"^(?:19|20)\d{2}$")
def build_messages(
user_text: str, history: list[dict], web_context: str = "",
profile: dict | None = None,
) -> list[dict]:
"""Assemble the message list for one turn.
The system message stacks the persona, profile, recalled memories, and any
web context; then come the prior history and the new user turn.
"""
profile_block = memory.profile_to_prompt(profile or {})
recall_block = memory.recall_block(user_text)
system = PACK["persona"]
if profile_block:
system += "\n\n" + profile_block
if recall_block:
system += "\n\n" + recall_block
if web_context:
system += "\n\n" + PACK["web_header"] + "\n" + web_context
messages = [{"role": "system", "content": system}]
messages.extend(history)
messages.append({"role": "user", "content": user_text})
return messages
def _search_query(user_text: str, history: list[dict] | None = None) -> str:
"""Distill the latest message into search keywords.
Passing the raw sentence to DuckDuckGo returns junk; a short keyword query
returns the right article. The recent turns go in too, so a follow-up
("¿sabes quién es?") keeps the subject from earlier in the chat; the prompt
tells the model to build the query for the LAST message only, and to drop
the context when the subject changes.
"""
labels = _ROLE_LABELS.get(CONFIG.lang, _ROLE_LABELS["es"])
convo = ""
if history:
for m in [m for m in history if m.get("content")][-4:]:
convo += f"{labels.get(m['role'], m['role'])}: {m['content']}\n"
prompt = (f"{PACK['query_instruction']}{convo}"
f"{labels['user']}: {user_text}\n{labels['cue']}:")
raw = llm.complete(prompt)
query = next((ln for ln in raw.splitlines() if ln.strip()), "").strip().strip('"')
for cue in (labels["cue"].lower() + ":", "consulta:", "query:"):
if query.lower().startswith(cue):
query = query[len(cue):].strip()
break
# Drop filler, and any year the model guessed from stale memory (it tends to
# emit its training-cutoff year); we anchor the real year ourselves below.
words = [
w for w in query.split()
if w.lower() not in PACK["search_filler"] and not _YEAR.match(w.strip(".,"))
]
query = " ".join(words)
region = PACK["search_region"]
if region and region.lower() not in query.lower():
query += " " + region
# Anchor to the current year so DuckDuckGo ranks current pages over evergreen
# ones (old elections, old prices).
query += " " + str(date.today().year)
return query.strip()
def _as_text(content) -> str:
"""Flatten a chat message's content to plain text.
Gradio's Chatbot round-trips a plain string into OpenAI-style content parts
([{"type": "text", "text": "..."}]), so the history we get back is not always
a string. The gate, query distillation, and prompt all want text.
"""
if isinstance(content, str):
return content
if isinstance(content, list):
parts = [
p if isinstance(p, str)
else (p.get("text", "") if isinstance(p, dict) else "")
for p in content
]
return " ".join(p for p in parts if p)
if isinstance(content, dict):
return content.get("text", "")
return ""
def _normalize_history(history: list[dict] | None) -> list[dict]:
"""Coerce Gradio chat history into clean [{'role', 'content': str}] dicts."""
out = []
for m in history or []:
if isinstance(m, dict):
out.append({"role": m.get("role"), "content": _as_text(m.get("content"))})
return out
def respond(
user_text: str, history: list[dict], profile_text: str = ""
) -> Iterator[str]:
"""Stream the assistant's text reply, then persist the exchange.
`profile_text` is the YAML the user typed in the UI. It is parsed per turn,
so edits take effect on the next message. When the turn needs current facts,
search runs first and the results go into the prompt.
"""
history = _normalize_history(history)
profile = memory.parse_profile(profile_text)
web_context = ""
if tools.needs_search(user_text, history):
web_context = tools.search_web(_search_query(user_text, history))
if web_context:
# Tell the model what "now" is, so it reads the results as current
# and does not fall back to its stale training-time knowledge.
today = date.today().isoformat()
web_context = f"{PACK['today_header']}: {today}.\n{web_context}"
messages = build_messages(user_text, history, web_context, profile)
temperature = CONFIG.search_temperature if web_context else None
full = ""
for chunk in llm.stream_reply(messages, temperature=temperature):
full += chunk
yield chunk
memory.remember(f"El usuario dijo: {user_text}", source="conversation")
if full.strip():
memory.remember(f"Yo respondi: {full.strip()}", source="conversation")