Spaces:
Sleeping
Sleeping
Upload agent.py
Browse files
agent.py
CHANGED
|
@@ -15,7 +15,7 @@ from langgraph.prebuilt import ToolNode, tools_condition
|
|
| 15 |
|
| 16 |
from langchain_groq import ChatGroq
|
| 17 |
from langchain_core.tools import tool
|
| 18 |
-
from langchain_core.messages import SystemMessage
|
| 19 |
|
| 20 |
from langchain_community.document_loaders import WikipediaLoader
|
| 21 |
from langchain_community.tools import DuckDuckGoSearchRun
|
|
@@ -76,22 +76,7 @@ def fetch_page(url: str) -> str:
|
|
| 76 |
|
| 77 |
@tool
|
| 78 |
def run_python(code: str) -> str:
|
| 79 |
-
"""Execute Python code and return stdout.
|
| 80 |
-
|
| 81 |
-
Use for: arithmetic, counting, sorting, data transformation, and
|
| 82 |
-
processing Excel/CSV files with pandas.
|
| 83 |
-
|
| 84 |
-
pandas (3.x) and openpyxl are available. To read an Excel file use:
|
| 85 |
-
import pandas as pd
|
| 86 |
-
df = pd.read_excel('/tmp/<task_id>.xlsx')
|
| 87 |
-
|
| 88 |
-
The attached file path is provided in the question as:
|
| 89 |
-
[ATTACHED FILE: /tmp/<filename>]
|
| 90 |
-
Always use that exact path when reading files.
|
| 91 |
-
Print your final answer with print() — only stdout is returned.
|
| 92 |
-
"""
|
| 93 |
-
if len(code) > 8000:
|
| 94 |
-
return "Error: code too long (>8000 chars). Rewrite with a shorter, focused script."
|
| 95 |
try:
|
| 96 |
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
|
| 97 |
f.write(code)
|
|
@@ -124,7 +109,7 @@ def reverse_text(text: str) -> str:
|
|
| 124 |
TOOLS = [wiki_search, web_search, fetch_page, run_python, reverse_text]
|
| 125 |
|
| 126 |
# ==========================================================
|
| 127 |
-
# MODELS —
|
| 128 |
# ==========================================================
|
| 129 |
|
| 130 |
def _llm(name: str) -> ChatGroq:
|
|
@@ -135,30 +120,13 @@ def _llm(name: str) -> ChatGroq:
|
|
| 135 |
)
|
| 136 |
|
| 137 |
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
|
|
|
|
|
|
| 141 |
|
| 142 |
-
|
| 143 |
-
GENERAL_CHAIN = [MODEL_PRIMARY, MODEL_FALLBACK, MODEL_LAST]
|
| 144 |
-
|
| 145 |
-
# Code/numeric questions: still Qwen first to preserve 70b daily token budget (TPD=100k).
|
| 146 |
-
# 70b fires only when Qwen returns N/A — which is when it proved superior (Q10, Q12).
|
| 147 |
-
CODE_CHAIN = [MODEL_PRIMARY, MODEL_FALLBACK, MODEL_LAST]
|
| 148 |
-
|
| 149 |
-
# Keywords that indicate a code/numeric/structured-data question
|
| 150 |
-
_CODE_SIGNALS = [
|
| 151 |
-
"python code", "attached python", "numeric output", "final output",
|
| 152 |
-
"excel", "xlsx", "spreadsheet", "csv", "total sales", "sum of",
|
| 153 |
-
"how much", "calculate", "computation", "menu items", "sales from",
|
| 154 |
-
"grocery list", "shopping list", "pie", "filling", "recipe",
|
| 155 |
-
"attached file", "attached excel",
|
| 156 |
-
]
|
| 157 |
-
|
| 158 |
-
def _is_code_question(question: str) -> bool:
|
| 159 |
-
"""Return True if the question is best handled by the code-oriented chain (70B first)."""
|
| 160 |
-
q = question.lower()
|
| 161 |
-
return any(sig in q for sig in _CODE_SIGNALS)
|
| 162 |
|
| 163 |
# ==========================================================
|
| 164 |
# SYSTEM PROMPT — single prompt for all question types
|
|
@@ -174,43 +142,16 @@ Produce the exact correct answer — nothing more, nothing less.
|
|
| 174 |
- Use web_search for recent events, specific articles, prices, or anything time-sensitive.
|
| 175 |
- Use fetch_page when a URL is provided or a search result points to a relevant page.
|
| 176 |
- Use run_python for any arithmetic, counting, sorting, or data transformation.
|
| 177 |
-
- Use
|
| 178 |
-
|
| 179 |
-
import pandas as pd, io
|
| 180 |
-
data = '<copy the CSV lines from the question here>'
|
| 181 |
-
df = pd.read_csv(io.StringIO(data))
|
| 182 |
-
Then compute and print() the answer.
|
| 183 |
-
- If the question says [ATTACHED FILE CONTENT], that is Python code. Run it with run_python directly
|
| 184 |
-
— copy the code exactly as given into the run_python tool and print the final output.
|
| 185 |
-
- When given an operation table on a set S with a specific subset question,
|
| 186 |
-
use run_python to systematically check the required property rather than
|
| 187 |
-
reasoning about it manually. Manual reasoning on table operations is error-prone.
|
| 188 |
-
- If a question mentions an attached file but the file is not available in the question text
|
| 189 |
-
(marked as [ATTACHED FILE DATA...] or [ATTACHED FILE CONTENT...]), return N/A.
|
| 190 |
-
Do NOT invent or hallucinate file contents.
|
| 191 |
-
- NEVER call the same tool with the same query twice. If a tool returns no useful result,
|
| 192 |
-
try a different tool or a different query — do not repeat.
|
| 193 |
|
| 194 |
## Answer format rules
|
| 195 |
-
1. Output the raw value only — no explanation, no preamble
|
| 196 |
-
2. If asked for a first name, output
|
| 197 |
-
3. If asked for a surname
|
| 198 |
-
4. Numbers: digits only
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
(e.g. "a, b, e" not "a,b,e"). Follow the model's natural formatting.
|
| 202 |
-
6. For subset/set questions (e.g. "subset of S involving..."), output only the elements, comma-separated.
|
| 203 |
-
7. If you cannot find the answer after searching, output: N/A
|
| 204 |
-
8. For discography questions: count studio albums only. Live albums, compilations, box sets do not count.
|
| 205 |
-
9. For botany/classification questions: use strict scientific categories.
|
| 206 |
-
- Fruits (botanical): tomato, pepper, cucumber, avocado, squash, beans, corn kernels.
|
| 207 |
-
- Vegetables (botanical): true vegetables are leaves (lettuce, spinach), stems (celery), roots
|
| 208 |
-
(carrot, sweet potato), bulbs (onion), or flowers (broccoli, cauliflower).
|
| 209 |
-
- Do NOT confuse culinary and botanical definitions. A tomato is a fruit botanically.
|
| 210 |
-
10. Never abbreviate UNLESS the question explicitly asks for an abbreviation or code:
|
| 211 |
-
- If asked for an IOC country code: return the 3-letter code (e.g. CUB, GBR, EGY).
|
| 212 |
-
- If asked for an ISO code, airport code, or similar: return the code as-is.
|
| 213 |
-
- Otherwise write full words: "United States" not "US", "Saint Petersburg" not "St. Petersburg".
|
| 214 |
|
| 215 |
## Required final line
|
| 216 |
Always end your response with exactly:
|
|
@@ -218,88 +159,19 @@ FINAL ANSWER: <your answer>
|
|
| 218 |
"""
|
| 219 |
|
| 220 |
# ==========================================================
|
| 221 |
-
#
|
| 222 |
-
# ==========================================================
|
| 223 |
-
|
| 224 |
-
def maybe_answer_direct(question: str) -> str | None:
|
| 225 |
-
"""
|
| 226 |
-
Return a direct answer string for questions that don't need tool calls,
|
| 227 |
-
or None if normal graph processing should continue.
|
| 228 |
-
"""
|
| 229 |
-
q = question.strip()
|
| 230 |
-
ql = q.lower()
|
| 231 |
-
|
| 232 |
-
# Reversed sentence asking for the opposite of "left"
|
| 233 |
-
if "etisoppo" in ql and "tfel" in ql:
|
| 234 |
-
return "right"
|
| 235 |
-
if 'write the opposite of the word "left"' in ql:
|
| 236 |
-
return "right"
|
| 237 |
-
|
| 238 |
-
# Algebraic subset question — verified correct answer is b, e (with space after comma)
|
| 239 |
-
# The question involves a 5-element set {a,b,c,d,e} with operation table
|
| 240 |
-
if 'set s = {a, b, c, d, e}' in ql and 'subset' in ql:
|
| 241 |
-
return "b, e"
|
| 242 |
-
|
| 243 |
-
return None
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
# ==========================================================
|
| 247 |
-
# ANSWER QUALITY CHECK — used by invoke to decide fallback
|
| 248 |
-
# ==========================================================
|
| 249 |
-
|
| 250 |
-
def _answer_looks_weak(result) -> bool:
|
| 251 |
-
"""
|
| 252 |
-
Return True if the model's response content does not contain a usable
|
| 253 |
-
FINAL ANSWER — meaning we should try the next model in the chain.
|
| 254 |
-
Only applies when the model made NO tool calls (pure text response).
|
| 255 |
-
If tool calls are present, we let the graph continue normally.
|
| 256 |
-
"""
|
| 257 |
-
# If the model wants to call tools, don't short-circuit — let the graph run
|
| 258 |
-
tool_calls = getattr(result, "tool_calls", None)
|
| 259 |
-
if tool_calls:
|
| 260 |
-
return False
|
| 261 |
-
|
| 262 |
-
content = getattr(result, "content", "") or ""
|
| 263 |
-
if not isinstance(content, str):
|
| 264 |
-
return False
|
| 265 |
-
|
| 266 |
-
# Check if a FINAL ANSWER line is present and non-empty
|
| 267 |
-
match = re.search(r"FINAL ANSWER:\s*(.+)", content, re.I | re.S)
|
| 268 |
-
if not match:
|
| 269 |
-
return True
|
| 270 |
-
|
| 271 |
-
answer = match.group(1).strip()
|
| 272 |
-
# Treat explicit N/A or empty as weak
|
| 273 |
-
if not answer or answer.lower() in ("n/a", "none", "unknown", ""):
|
| 274 |
-
return True
|
| 275 |
-
|
| 276 |
-
return False
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
# ==========================================================
|
| 280 |
-
# INVOKE — exception fallback + content-quality fallback
|
| 281 |
# ==========================================================
|
| 282 |
|
| 283 |
-
def invoke(messages: list
|
| 284 |
-
"""
|
| 285 |
-
Call models in chain order with quality-based fallback.
|
| 286 |
-
|
| 287 |
-
chain — ordered list of models to try (GENERAL_CHAIN or CODE_CHAIN)
|
| 288 |
-
is_final — True when tool results are already in history (terminal answer turn)
|
| 289 |
-
"""
|
| 290 |
global LAST_MODEL_USED, LAST_MODEL_FALLBACK, LAST_MODEL_ERROR
|
| 291 |
|
| 292 |
-
if chain is None:
|
| 293 |
-
chain = GENERAL_CHAIN
|
| 294 |
-
|
| 295 |
LAST_MODEL_FALLBACK = "No"
|
| 296 |
LAST_MODEL_ERROR = "None"
|
| 297 |
|
| 298 |
seen: set[str] = set()
|
| 299 |
first = True
|
| 300 |
-
last_result = None
|
| 301 |
|
| 302 |
-
for model in
|
| 303 |
key = model.model_name
|
| 304 |
if key in seen:
|
| 305 |
continue
|
|
@@ -310,52 +182,11 @@ def invoke(messages: list, is_final: bool = False, chain: list = None) -> object
|
|
| 310 |
if not first:
|
| 311 |
LAST_MODEL_FALLBACK = "Yes"
|
| 312 |
first = False
|
| 313 |
-
|
| 314 |
-
result = model.bind_tools(TOOLS).invoke(messages)
|
| 315 |
-
last_result = result
|
| 316 |
-
|
| 317 |
-
# Model wants to call a tool — return immediately regardless of turn
|
| 318 |
-
tool_calls = getattr(result, "tool_calls", None)
|
| 319 |
-
if tool_calls:
|
| 320 |
-
return result
|
| 321 |
-
|
| 322 |
-
# Not a final turn — allow ONE fallback if answer is weak, then stop
|
| 323 |
-
if not is_final:
|
| 324 |
-
if _answer_looks_weak(result):
|
| 325 |
-
LAST_MODEL_FALLBACK = "Yes"
|
| 326 |
-
LAST_MODEL_ERROR = f"weak first-turn answer from {key}"
|
| 327 |
-
time.sleep(0.5)
|
| 328 |
-
for next_model in chain:
|
| 329 |
-
nkey = next_model.model_name
|
| 330 |
-
if nkey in seen:
|
| 331 |
-
continue
|
| 332 |
-
seen.add(nkey)
|
| 333 |
-
try:
|
| 334 |
-
LAST_MODEL_USED = nkey
|
| 335 |
-
r2 = next_model.bind_tools(TOOLS).invoke(messages)
|
| 336 |
-
return r2
|
| 337 |
-
except Exception as e2:
|
| 338 |
-
LAST_MODEL_ERROR = str(e2)
|
| 339 |
-
continue
|
| 340 |
-
return result
|
| 341 |
-
|
| 342 |
-
# Final turn — try all remaining models until a non-weak answer
|
| 343 |
-
if not _answer_looks_weak(result):
|
| 344 |
-
return result
|
| 345 |
-
|
| 346 |
-
LAST_MODEL_FALLBACK = "Yes"
|
| 347 |
-
LAST_MODEL_ERROR = f"weak answer from {key}"
|
| 348 |
-
time.sleep(0.5)
|
| 349 |
-
continue
|
| 350 |
-
|
| 351 |
except Exception as e:
|
| 352 |
LAST_MODEL_ERROR = str(e)
|
| 353 |
-
time.sleep(0.5)
|
| 354 |
continue
|
| 355 |
|
| 356 |
-
if last_result is not None:
|
| 357 |
-
return last_result
|
| 358 |
-
|
| 359 |
raise RuntimeError(f"All models failed. Last error: {LAST_MODEL_ERROR}")
|
| 360 |
|
| 361 |
# ==========================================================
|
|
@@ -363,28 +194,8 @@ def invoke(messages: list, is_final: bool = False, chain: list = None) -> object
|
|
| 363 |
# ==========================================================
|
| 364 |
|
| 365 |
def assistant(state: MessagesState) -> dict:
|
| 366 |
-
user_q = state["messages"][-1].content if state.get("messages") else ""
|
| 367 |
-
|
| 368 |
-
# Short-circuit for questions answerable without any tool calls
|
| 369 |
-
direct = maybe_answer_direct(user_q)
|
| 370 |
-
if direct is not None:
|
| 371 |
-
return {"messages": [AIMessage(content=f"FINAL ANSWER: {direct}")]}
|
| 372 |
-
|
| 373 |
-
# Pick model chain based on question type:
|
| 374 |
-
# code/numeric/structured-data → 70B leads (observed better accuracy)
|
| 375 |
-
# everything else → Qwen leads
|
| 376 |
-
# Extract the original user question (not injected file content) for routing
|
| 377 |
-
original_q = user_q.split("\n[ATTACHED FILE")[0]
|
| 378 |
-
chain = CODE_CHAIN if _is_code_question(original_q) else GENERAL_CHAIN
|
| 379 |
-
|
| 380 |
-
# Detect whether this is a final turn (tool results already in history)
|
| 381 |
-
is_final = any(
|
| 382 |
-
getattr(m, "type", "") == "tool"
|
| 383 |
-
for m in state["messages"]
|
| 384 |
-
)
|
| 385 |
-
|
| 386 |
messages = [SystemMessage(content=SYSTEM_PROMPT)] + state["messages"]
|
| 387 |
-
result = invoke(messages
|
| 388 |
return {"messages": [result]}
|
| 389 |
|
| 390 |
|
|
@@ -404,25 +215,10 @@ def build_graph():
|
|
| 404 |
def _clean_answer(raw: str) -> str:
|
| 405 |
"""Normalise the extracted answer string."""
|
| 406 |
answer = raw.strip()
|
| 407 |
-
|
| 408 |
-
# Strip surrounding quotes the model sometimes wraps answers in
|
| 409 |
-
# e.g. '"Extremely"' → 'Extremely', '"No, it\'s a sarcophagus."' → stripped later
|
| 410 |
-
if len(answer) >= 2 and answer[0] in ('"', "'", "\u201c", "\u2018") and answer[-1] in ('"', "'", "\u201d", "\u2019"):
|
| 411 |
-
answer = answer[1:-1].strip()
|
| 412 |
-
|
| 413 |
-
# Strip trailing punctuation
|
| 414 |
answer = answer.rstrip(".,;:")
|
| 415 |
-
|
| 416 |
-
# Strip leading currency symbols — benchmark expects raw numbers, not formatted currency
|
| 417 |
-
# e.g. "$300.00" → "300.00", "£1,234.56" → "1,234.56"
|
| 418 |
-
# Exception: if question explicitly asks for USD/currency format, the system prompt
|
| 419 |
-
# instructs the model accordingly — but _clean_answer always strips symbols here
|
| 420 |
-
# because the scorer does exact-match and won't accept "$"
|
| 421 |
-
answer = re.sub(r'^[$€£¥₹]\s*', '', answer)
|
| 422 |
-
|
| 423 |
# Collapse internal whitespace / newlines
|
| 424 |
answer = " ".join(answer.split())
|
| 425 |
-
|
| 426 |
# Remove common LLM filler prefixes the regex sometimes captures
|
| 427 |
for prefix in (
|
| 428 |
"the answer is",
|
|
@@ -435,76 +231,6 @@ def _clean_answer(raw: str) -> str:
|
|
| 435 |
):
|
| 436 |
if answer.lower().startswith(prefix):
|
| 437 |
answer = answer[len(prefix):].strip()
|
| 438 |
-
|
| 439 |
-
# Expand common abbreviations
|
| 440 |
-
answer = _expand_abbreviation(answer)
|
| 441 |
-
return answer
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
# Whole-answer abbreviation expansion table.
|
| 445 |
-
# Keys are lowercase stripped versions; values are the canonical full forms.
|
| 446 |
-
_ABBREV_MAP: dict[str, str] = {
|
| 447 |
-
# Countries / regions
|
| 448 |
-
"us": "United States",
|
| 449 |
-
"usa": "United States",
|
| 450 |
-
"u.s.": "United States",
|
| 451 |
-
"u.s.a.":"United States",
|
| 452 |
-
"uk": "United Kingdom",
|
| 453 |
-
"u.k.": "United Kingdom",
|
| 454 |
-
"uae": "United Arab Emirates",
|
| 455 |
-
"ussr": "Soviet Union",
|
| 456 |
-
"drc": "Democratic Republic of the Congo",
|
| 457 |
-
"dprk": "North Korea",
|
| 458 |
-
"rok": "South Korea",
|
| 459 |
-
# Cities that frequently get abbreviated
|
| 460 |
-
"hcmc": "Ho Chi Minh City",
|
| 461 |
-
"nyc": "New York City",
|
| 462 |
-
"la": "Los Angeles",
|
| 463 |
-
"dc": "Washington, D.C.",
|
| 464 |
-
# Saint / Sainte prefixes
|
| 465 |
-
"st.": "Saint",
|
| 466 |
-
# Common institution abbreviations
|
| 467 |
-
"mit": "Massachusetts Institute of Technology",
|
| 468 |
-
"un": "United Nations",
|
| 469 |
-
"eu": "European Union",
|
| 470 |
-
"nato": "North Atlantic Treaty Organization",
|
| 471 |
-
"who": "World Health Organization",
|
| 472 |
-
"imf": "International Monetary Fund",
|
| 473 |
-
"nba": "National Basketball Association",
|
| 474 |
-
"nfl": "National Football League",
|
| 475 |
-
"fifa": "Fédération Internationale de Football Association",
|
| 476 |
-
}
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
def _expand_abbreviation(answer: str) -> str:
|
| 480 |
-
"""
|
| 481 |
-
If the entire answer is a known abbreviation, replace it with the full form.
|
| 482 |
-
Also expands 'St. <Name>' → 'Saint <Name>' for city/place names.
|
| 483 |
-
|
| 484 |
-
Does NOT expand:
|
| 485 |
-
- IOC country codes (3 uppercase letters like CUB, EGY, GBR) — these are
|
| 486 |
-
intentional when the question asks for the IOC code.
|
| 487 |
-
- Answers longer than 5 words — too risky to mutate longer text.
|
| 488 |
-
"""
|
| 489 |
-
# Only act on short answers (≤ 5 words) to stay safe
|
| 490 |
-
if len(answer.split()) > 5:
|
| 491 |
-
return answer
|
| 492 |
-
|
| 493 |
-
# Never expand 3-letter ALL-CAPS strings — these are almost certainly
|
| 494 |
-
# IOC codes, ISO codes, or other intentional abbreviations the question asked for
|
| 495 |
-
if re.fullmatch(r'[A-Z]{3}', answer.strip()):
|
| 496 |
-
return answer
|
| 497 |
-
|
| 498 |
-
# Whole-answer lookup (case-insensitive)
|
| 499 |
-
lookup = answer.lower().strip(".")
|
| 500 |
-
if lookup in _ABBREV_MAP:
|
| 501 |
-
return _ABBREV_MAP[lookup]
|
| 502 |
-
|
| 503 |
-
# Expand "St. <Word>" → "Saint <Word>" (e.g. "St. Petersburg" → "Saint Petersburg")
|
| 504 |
-
expanded = re.sub(r'\bSt\.\s+', 'Saint ', answer)
|
| 505 |
-
if expanded != answer:
|
| 506 |
-
return expanded
|
| 507 |
-
|
| 508 |
return answer
|
| 509 |
|
| 510 |
|
|
@@ -544,4 +270,4 @@ def get_last_trace() -> dict:
|
|
| 544 |
"model": LAST_MODEL_USED,
|
| 545 |
"fallback": LAST_MODEL_FALLBACK,
|
| 546 |
"model_error": LAST_MODEL_ERROR,
|
| 547 |
-
}
|
|
|
|
| 15 |
|
| 16 |
from langchain_groq import ChatGroq
|
| 17 |
from langchain_core.tools import tool
|
| 18 |
+
from langchain_core.messages import SystemMessage
|
| 19 |
|
| 20 |
from langchain_community.document_loaders import WikipediaLoader
|
| 21 |
from langchain_community.tools import DuckDuckGoSearchRun
|
|
|
|
| 76 |
|
| 77 |
@tool
|
| 78 |
def run_python(code: str) -> str:
|
| 79 |
+
"""Execute Python code and return stdout. Use for calculations, counting, data processing."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
try:
|
| 81 |
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
|
| 82 |
f.write(code)
|
|
|
|
| 109 |
TOOLS = [wiki_search, web_search, fetch_page, run_python, reverse_text]
|
| 110 |
|
| 111 |
# ==========================================================
|
| 112 |
+
# MODELS — primary + ordered fallback chain
|
| 113 |
# ==========================================================
|
| 114 |
|
| 115 |
def _llm(name: str) -> ChatGroq:
|
|
|
|
| 120 |
)
|
| 121 |
|
| 122 |
|
| 123 |
+
# All questions use the same primary model.
|
| 124 |
+
# Fallback chain kicks in only on errors (rate limits, timeouts, etc.)
|
| 125 |
+
MODEL_PRIMARY = _llm("qwen/qwen3-32b")
|
| 126 |
+
MODEL_FALLBACK = _llm("llama-3.3-70b-versatile")
|
| 127 |
+
MODEL_LAST = _llm("llama-3.1-8b-instant")
|
| 128 |
|
| 129 |
+
FALLBACK_CHAIN = [MODEL_PRIMARY, MODEL_FALLBACK, MODEL_LAST]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
|
| 131 |
# ==========================================================
|
| 132 |
# SYSTEM PROMPT — single prompt for all question types
|
|
|
|
| 142 |
- Use web_search for recent events, specific articles, prices, or anything time-sensitive.
|
| 143 |
- Use fetch_page when a URL is provided or a search result points to a relevant page.
|
| 144 |
- Use run_python for any arithmetic, counting, sorting, or data transformation.
|
| 145 |
+
- Use reverse_text only when asked to reverse a string.
|
| 146 |
+
- You may use up to 5 tool calls. Stop as soon as you have a confident answer.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
|
| 148 |
## Answer format rules
|
| 149 |
+
1. Output the raw value only — no explanation, no preamble.
|
| 150 |
+
2. If asked for a first name, output only the first name.
|
| 151 |
+
3. If asked for a surname, output only the surname.
|
| 152 |
+
4. Numbers: digits only unless units were explicitly requested.
|
| 153 |
+
5. Lists: comma-separated on one line.
|
| 154 |
+
6. If you cannot find the answer after searching, output: N/A
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
|
| 156 |
## Required final line
|
| 157 |
Always end your response with exactly:
|
|
|
|
| 159 |
"""
|
| 160 |
|
| 161 |
# ==========================================================
|
| 162 |
+
# INVOKE — with fallback chain
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
# ==========================================================
|
| 164 |
|
| 165 |
+
def invoke(messages: list) -> object:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
global LAST_MODEL_USED, LAST_MODEL_FALLBACK, LAST_MODEL_ERROR
|
| 167 |
|
|
|
|
|
|
|
|
|
|
| 168 |
LAST_MODEL_FALLBACK = "No"
|
| 169 |
LAST_MODEL_ERROR = "None"
|
| 170 |
|
| 171 |
seen: set[str] = set()
|
| 172 |
first = True
|
|
|
|
| 173 |
|
| 174 |
+
for model in FALLBACK_CHAIN:
|
| 175 |
key = model.model_name
|
| 176 |
if key in seen:
|
| 177 |
continue
|
|
|
|
| 182 |
if not first:
|
| 183 |
LAST_MODEL_FALLBACK = "Yes"
|
| 184 |
first = False
|
| 185 |
+
return model.bind_tools(TOOLS).invoke(messages)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
except Exception as e:
|
| 187 |
LAST_MODEL_ERROR = str(e)
|
|
|
|
| 188 |
continue
|
| 189 |
|
|
|
|
|
|
|
|
|
|
| 190 |
raise RuntimeError(f"All models failed. Last error: {LAST_MODEL_ERROR}")
|
| 191 |
|
| 192 |
# ==========================================================
|
|
|
|
| 194 |
# ==========================================================
|
| 195 |
|
| 196 |
def assistant(state: MessagesState) -> dict:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
messages = [SystemMessage(content=SYSTEM_PROMPT)] + state["messages"]
|
| 198 |
+
result = invoke(messages)
|
| 199 |
return {"messages": [result]}
|
| 200 |
|
| 201 |
|
|
|
|
| 215 |
def _clean_answer(raw: str) -> str:
|
| 216 |
"""Normalise the extracted answer string."""
|
| 217 |
answer = raw.strip()
|
| 218 |
+
# Strip trailing punctuation that the model sometimes adds
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
answer = answer.rstrip(".,;:")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
# Collapse internal whitespace / newlines
|
| 221 |
answer = " ".join(answer.split())
|
|
|
|
| 222 |
# Remove common LLM filler prefixes the regex sometimes captures
|
| 223 |
for prefix in (
|
| 224 |
"the answer is",
|
|
|
|
| 231 |
):
|
| 232 |
if answer.lower().startswith(prefix):
|
| 233 |
answer = answer[len(prefix):].strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
return answer
|
| 235 |
|
| 236 |
|
|
|
|
| 270 |
"model": LAST_MODEL_USED,
|
| 271 |
"fallback": LAST_MODEL_FALLBACK,
|
| 272 |
"model_error": LAST_MODEL_ERROR,
|
| 273 |
+
}
|