Spaces:
Sleeping
Sleeping
Beemer Claude Fable 5 commited on
Commit ·
79c4fb1
1
Parent(s): 33bb287
Agentic v2: flat tool args, conversation history, date-aware rehab math, 429 retry, stderr telemetry, auth fail-closed, Stop/export
Browse files
README.md
CHANGED
|
@@ -12,14 +12,15 @@ client can still ask Canadian legal-research questions.
|
|
| 12 |
|
| 13 |
## How it works -- thin client
|
| 14 |
|
| 15 |
-
This app holds **no copy of the legal corpus**. For each question
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
|
|
|
| 23 |
|
| 24 |
Because retrieval stays on the MCP server, a corpus or retrieval change is
|
| 25 |
deployed once (to the MCP Space) and both the MCP connector and this website
|
|
@@ -39,10 +40,11 @@ Optional overrides:
|
|
| 39 |
| Name | Default |
|
| 40 |
|------|---------|
|
| 41 |
| `CANLEX_MCP_URL` | `https://beemer0-canlex.hf.space/mcp` |
|
| 42 |
-
| `CANLEX_GEMINI_MODEL` | `gemini-2.5-flash` |
|
|
|
|
| 43 |
|
| 44 |
-
If `CANLEX_WEB_AUTH` is unset the app
|
| 45 |
-
|
| 46 |
|
| 47 |
## Make the Space private
|
| 48 |
|
|
|
|
| 12 |
|
| 13 |
## How it works -- thin client
|
| 14 |
|
| 15 |
+
This app holds **no copy of the legal corpus**. For each question, Google
|
| 16 |
+
Gemini runs an **agentic loop** over the deployed CanLex MCP server's seven
|
| 17 |
+
tools -- searching, fetching sections, running the three-step IRPA s. 36
|
| 18 |
+
screening chain, and checking case citations -- until it has enough material,
|
| 19 |
+
then composes a grounded, cited answer following CanLex's own answering
|
| 20 |
+
instructions. The answer streams live, with a per-question trace, a trust
|
| 21 |
+
badge, and every retrieved passage shown for review. Follow-up questions keep
|
| 22 |
+
the conversation's context; the answer and sources can be exported as a
|
| 23 |
+
dated research memo.
|
| 24 |
|
| 25 |
Because retrieval stays on the MCP server, a corpus or retrieval change is
|
| 26 |
deployed once (to the MCP Space) and both the MCP connector and this website
|
|
|
|
| 40 |
| Name | Default |
|
| 41 |
|------|---------|
|
| 42 |
| `CANLEX_MCP_URL` | `https://beemer0-canlex.hf.space/mcp` |
|
| 43 |
+
| `CANLEX_GEMINI_MODEL` | `gemini-2.5-flash` (this deployment sets `gemini-2.5-pro`) |
|
| 44 |
+
| `CANLEX_THINKING_BUDGET` | `4096` |
|
| 45 |
|
| 46 |
+
If `CANLEX_WEB_AUTH` is unset the app **refuses to start** -- there is no
|
| 47 |
+
default login. Set the secret first.
|
| 48 |
|
| 49 |
## Make the Space private
|
| 50 |
|
app.py
CHANGED
|
@@ -3,20 +3,23 @@
|
|
| 3 |
|
| 4 |
A thin client that gives a non-Claude user roughly the same experience as Claude
|
| 5 |
with the CanLex MCP server. For each question it opens one streamable-HTTP
|
| 6 |
-
session against the deployed CanLex MCP, declares the
|
| 7 |
Google Gemini, and lets the model agentically iterate -- searching, fetching
|
| 8 |
-
sections, and looking up case-law citations
|
| 9 |
-
material to compose a grounded answer.
|
| 10 |
|
| 11 |
All configuration comes from environment variables, set as Hugging Face Space
|
| 12 |
secrets. Run locally with: python app.py
|
| 13 |
"""
|
| 14 |
import asyncio
|
|
|
|
| 15 |
import json
|
| 16 |
import os
|
| 17 |
import queue
|
| 18 |
import sys
|
|
|
|
| 19 |
import threading
|
|
|
|
| 20 |
import urllib.error
|
| 21 |
import urllib.request
|
| 22 |
from datetime import timedelta
|
|
@@ -34,13 +37,24 @@ MCP_URL = os.environ.get(
|
|
| 34 |
"CANLEX_MCP_URL", "https://beemer0-canlex.hf.space/mcp").strip()
|
| 35 |
|
| 36 |
# Google Gemini -- the free-tier key is supplied as the GEMINI_API_KEY secret.
|
| 37 |
-
|
|
|
|
|
|
|
| 38 |
GEMINI_ENDPOINT = ("https://generativelanguage.googleapis.com/v1beta/models/"
|
| 39 |
f"{GEMINI_MODEL}:generateContent")
|
| 40 |
|
| 41 |
MAX_OUTPUT_TOKENS = 8192 # generous -- covers Gemini 2.5 thinking plus the answer
|
| 42 |
MAX_TOOL_ITERATIONS = 8 # loop guard for the agent
|
| 43 |
REQUEST_TIMEOUT = 180 # seconds, applied to the MCP and Gemini calls
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
|
| 46 |
def _load_auth() -> list[tuple[str, str]]:
|
|
@@ -54,11 +68,13 @@ def _load_auth() -> list[tuple[str, str]]:
|
|
| 54 |
if user and password:
|
| 55 |
creds.append((user, password))
|
| 56 |
if not creds:
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
|
|
|
|
|
|
| 62 |
return creds
|
| 63 |
|
| 64 |
|
|
@@ -67,10 +83,9 @@ AUTH = _load_auth()
|
|
| 67 |
|
| 68 |
# --- Tool declarations (Gemini function-calling schema) -----------------------
|
| 69 |
|
| 70 |
-
# The
|
| 71 |
-
#
|
| 72 |
-
#
|
| 73 |
-
# MCP edge in _run_tool.
|
| 74 |
TOOL_DECLARATIONS = [
|
| 75 |
{
|
| 76 |
"name": "canlex_search_legislation",
|
|
@@ -107,12 +122,17 @@ TOOL_DECLARATIONS = [
|
|
| 107 |
},
|
| 108 |
"doc_type": {
|
| 109 |
"type": "string",
|
|
|
|
|
|
|
|
|
|
| 110 |
"description": (
|
| 111 |
"Optional. Restrict to one source type: 'legislation', "
|
| 112 |
"'memorandum' (CBSA D-Memoranda), 'agreement' "
|
| 113 |
"(collective agreements), 'directive' (NJC), "
|
| 114 |
-
"'caselaw' (court and tribunal decisions),
|
| 115 |
-
"'delegation' (IRPA/IRPR delegation and designation)
|
|
|
|
|
|
|
| 116 |
),
|
| 117 |
},
|
| 118 |
},
|
|
@@ -273,21 +293,23 @@ TOOL_DECLARATIONS = [
|
|
| 273 |
},
|
| 274 |
]
|
| 275 |
|
| 276 |
-
|
| 277 |
-
# canlex_list_acts takes none and is handled separately in _run_tool.
|
| 278 |
-
_PARAMS_WRAPPED = {"canlex_search_legislation", "canlex_get_section",
|
| 279 |
-
"canlex_case", "canlex_us_disposition",
|
| 280 |
-
"canlex_us_equivalency", "canlex_rehabilitation"}
|
| 281 |
|
| 282 |
|
| 283 |
# --- System prompt ------------------------------------------------------------
|
| 284 |
|
| 285 |
SYSTEM_INSTRUCTION = """\
|
| 286 |
-
You are CanLex Web, a Canadian legal-research assistant.
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
|
| 292 |
Tool-use guidance:
|
| 293 |
- Start with canlex_search_legislation on the user's question. Read the \
|
|
@@ -332,20 +354,34 @@ class _AgentError(RuntimeError):
|
|
| 332 |
"""Surfaced to the UI; the message text is shown verbatim."""
|
| 333 |
|
| 334 |
|
| 335 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 336 |
"""The JSON body sent to Gemini -- identical between the streaming and the
|
| 337 |
non-streaming endpoints. Tool declarations turn on function calling; the
|
| 338 |
safety filters are relaxed because legal research routinely discusses
|
| 339 |
crime, weapons and the like, and the high-threshold defaults spuriously
|
| 340 |
-
block legitimate legal text.
|
|
|
|
|
|
|
|
|
|
| 341 |
return {
|
| 342 |
-
"systemInstruction": {"parts": [{"text": SYSTEM_INSTRUCTION
|
|
|
|
| 343 |
"contents": contents,
|
| 344 |
"tools": [{"functionDeclarations": TOOL_DECLARATIONS}],
|
| 345 |
-
"toolConfig": {"functionCallingConfig": {"mode":
|
| 346 |
"generationConfig": {
|
| 347 |
"temperature": 0.2,
|
| 348 |
"maxOutputTokens": MAX_OUTPUT_TOKENS,
|
|
|
|
| 349 |
},
|
| 350 |
"safetySettings": [
|
| 351 |
{"category": c, "threshold": "BLOCK_ONLY_HIGH"}
|
|
@@ -365,39 +401,71 @@ _STREAM_ENDPOINT = GEMINI_ENDPOINT.replace(
|
|
| 365 |
":generateContent", ":streamGenerateContent") + "?alt=sse"
|
| 366 |
|
| 367 |
|
| 368 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 369 |
"""Async generator over Gemini's streaming response.
|
| 370 |
|
| 371 |
Yields dicts of one of three shapes:
|
| 372 |
{"type": "text_delta", "text": str} -- a partial answer fragment
|
| 373 |
{"type": "function_call", "call": dict} -- a complete tool call
|
| 374 |
{"type": "finish", "reason": str|None, -- end of stream; `parts` is
|
| 375 |
-
"parts": list[dict]}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 376 |
"""
|
| 377 |
-
body = _gemini_request_body(contents)
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 398 |
|
| 399 |
accumulated_parts: list[dict] = []
|
| 400 |
finish_reason = None
|
|
|
|
|
|
|
| 401 |
try:
|
| 402 |
while True:
|
| 403 |
raw = await asyncio.to_thread(response.readline)
|
|
@@ -410,6 +478,11 @@ async def _gemini_stream(api_key: str, contents: list[dict]):
|
|
| 410 |
chunk = json.loads(line[6:])
|
| 411 |
except ValueError:
|
| 412 |
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 413 |
candidate = (chunk.get("candidates") or [{}])[0]
|
| 414 |
for part in (candidate.get("content") or {}).get("parts") or []:
|
| 415 |
accumulated_parts.append(part)
|
|
@@ -425,22 +498,29 @@ async def _gemini_stream(api_key: str, contents: list[dict]):
|
|
| 425 |
finish_reason = candidate["finishReason"]
|
| 426 |
finally:
|
| 427 |
await asyncio.to_thread(response.close)
|
| 428 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 429 |
|
| 430 |
|
| 431 |
async def _run_tool(session: ClientSession, name: str, args: dict) -> str:
|
| 432 |
"""Execute a Gemini function call against the MCP, returning text output."""
|
| 433 |
-
if name
|
| 434 |
-
mcp_args: dict = {}
|
| 435 |
-
elif name in _PARAMS_WRAPPED:
|
| 436 |
-
# The MCP server's tools accept their schema as a single 'params' object.
|
| 437 |
-
mcp_args = {"params": args or {}}
|
| 438 |
-
else:
|
| 439 |
return f"(unknown tool '{name}')"
|
|
|
|
| 440 |
try:
|
| 441 |
-
result = await session.call_tool(name,
|
| 442 |
except Exception as exc: # MCP transport errors
|
| 443 |
return f"(tool '{name}' failed: {type(exc).__name__}: {exc})"
|
|
|
|
|
|
|
|
|
|
| 444 |
text = "\n".join(
|
| 445 |
block.text for block in result.content
|
| 446 |
if getattr(block, "type", None) == "text" and getattr(block, "text", None)
|
|
@@ -512,11 +592,16 @@ def _format_sources(tool_log: list[tuple[str, dict, str]]) -> str:
|
|
| 512 |
return "\n\n---\n\n".join(blocks)
|
| 513 |
|
| 514 |
|
| 515 |
-
async def _agentic_answer(question: str
|
|
|
|
|
|
|
| 516 |
"""Run the Gemini-driven agentic loop against a single MCP session.
|
| 517 |
|
| 518 |
Yields tuples of (status, answer_md, sources_md). The final yield carries
|
| 519 |
the composed answer; earlier yields are progress updates the UI can show.
|
|
|
|
|
|
|
|
|
|
| 520 |
"""
|
| 521 |
api_key = os.environ.get("GEMINI_API_KEY", "").strip()
|
| 522 |
if not api_key:
|
|
@@ -534,9 +619,11 @@ async def _agentic_answer(question: str):
|
|
| 534 |
async with ClientSession(read, write) as session:
|
| 535 |
await session.initialize()
|
| 536 |
|
| 537 |
-
contents: list[dict] = [
|
| 538 |
-
|
| 539 |
-
|
|
|
|
|
|
|
| 540 |
tool_log: list[tuple[str, dict, str]] = []
|
| 541 |
trace: list[str] = []
|
| 542 |
|
|
@@ -553,7 +640,12 @@ async def _agentic_answer(question: str):
|
|
| 553 |
lines.append("- _Thinking..._")
|
| 554 |
return "\n".join(lines) if lines else ""
|
| 555 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 556 |
for step in range(MAX_TOOL_ITERATIONS):
|
|
|
|
| 557 |
yield status_md(), answer_buf, sources_md
|
| 558 |
|
| 559 |
# Stream Gemini's next turn. Stream text deltas to the answer
|
|
@@ -566,6 +658,7 @@ async def _agentic_answer(question: str):
|
|
| 566 |
optimistic = True
|
| 567 |
|
| 568 |
async for chunk in _gemini_stream(api_key, contents):
|
|
|
|
| 569 |
if chunk["type"] == "text_delta" and optimistic:
|
| 570 |
turn_text += chunk["text"]
|
| 571 |
yield (status_md(),
|
|
@@ -585,6 +678,10 @@ async def _agentic_answer(question: str):
|
|
| 585 |
# Capture any text-only finish reason so the caller can
|
| 586 |
# surface a useful error for an empty answer.
|
| 587 |
finish_reason = chunk.get("reason")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 588 |
|
| 589 |
contents.append({"role": "model", "parts": turn_parts})
|
| 590 |
|
|
@@ -610,25 +707,37 @@ async def _agentic_answer(question: str):
|
|
| 610 |
snippet = snippet[:137].rstrip() + "..."
|
| 611 |
trace.append(f"_{snippet}_")
|
| 612 |
|
| 613 |
-
# Execute every function call in this turn
|
| 614 |
-
#
|
| 615 |
-
|
| 616 |
for call in turn_calls:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 617 |
name = call.get("name", "")
|
| 618 |
args = call.get("args") or {}
|
| 619 |
-
label = _summarize_call(name, args)
|
| 620 |
-
trace.append(label)
|
| 621 |
-
yield status_md(), answer_buf, sources_md
|
| 622 |
-
|
| 623 |
-
output = await _run_tool(session, name, args)
|
| 624 |
tool_log.append((name, args, output))
|
| 625 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 626 |
function_responses.append({
|
| 627 |
"functionResponse": {
|
| 628 |
"name": name,
|
| 629 |
-
"response": {"output":
|
| 630 |
}
|
| 631 |
})
|
|
|
|
| 632 |
contents.append({"role": "user", "parts": function_responses})
|
| 633 |
|
| 634 |
# Loop budget exhausted -- ask Gemini for a final answer without
|
|
@@ -639,8 +748,11 @@ async def _agentic_answer(question: str):
|
|
| 639 |
"the best answer you can from the material gathered so far, "
|
| 640 |
"without calling further tools. If the material is "
|
| 641 |
"insufficient, say so plainly."}]})
|
|
|
|
|
|
|
| 642 |
turn_text = ""
|
| 643 |
-
async for chunk in _gemini_stream(api_key, contents
|
|
|
|
| 644 |
if chunk["type"] == "text_delta":
|
| 645 |
turn_text += chunk["text"]
|
| 646 |
yield (status_md(thinking=False),
|
|
@@ -661,7 +773,30 @@ ANSWER_PLACEHOLDER = "*Your answer will appear here.*"
|
|
| 661 |
_SENTINEL = object()
|
| 662 |
|
| 663 |
|
| 664 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 665 |
"""Generator wrapping the async agent for Gradio's progressive UI.
|
| 666 |
|
| 667 |
The async work runs on a dedicated worker thread with its own event loop
|
|
@@ -670,19 +805,33 @@ def answer(question: str):
|
|
| 670 |
previous loop.run_until_complete-per-anext pattern created a fresh task
|
| 671 |
on every yield, which tripped anyio's cancel-scope check inside the MCP
|
| 672 |
streamable-HTTP client ('Attempted to exit cancel scope in a different
|
| 673 |
-
task than it was entered in').
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 674 |
question = (question or "").strip()
|
|
|
|
| 675 |
if not question:
|
| 676 |
-
yield "Please enter a legal question above.", ANSWER_PLACEHOLDER,
|
|
|
|
| 677 |
return
|
| 678 |
|
|
|
|
|
|
|
| 679 |
events: queue.Queue = queue.Queue()
|
|
|
|
| 680 |
|
| 681 |
def worker():
|
| 682 |
async def run():
|
| 683 |
try:
|
| 684 |
-
async for tup in _agentic_answer(question
|
|
|
|
| 685 |
events.put(("yield", tup))
|
|
|
|
|
|
|
| 686 |
except _AgentError as exc:
|
| 687 |
events.put(("agent_error", exc))
|
| 688 |
except Exception as exc: # network blip, MCP transport
|
|
@@ -697,31 +846,58 @@ def answer(question: str):
|
|
| 697 |
|
| 698 |
threading.Thread(target=worker, daemon=True).start()
|
| 699 |
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 725 |
|
| 726 |
|
| 727 |
# --- UI -----------------------------------------------------------------------
|
|
@@ -734,16 +910,20 @@ labour or related federal law**. CanLex finds the governing statutory
|
|
| 734 |
provisions, D-Memoranda, collective-agreement terms and leading court
|
| 735 |
decisions, then composes an answer that cites them.
|
| 736 |
|
| 737 |
-
The CanLex corpus contains
|
| 738 |
-
Immigration and Refugee Protection Act, the Customs
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
|
| 742 |
-
|
|
|
|
|
|
|
|
|
|
| 743 |
|
| 744 |
The assistant iterates over the corpus -- searching, fetching sections and
|
| 745 |
looking up case-law citations -- before composing a grounded answer. A complex
|
| 746 |
-
question may take 30 seconds or more.
|
|
|
|
| 747 |
|
| 748 |
Legal information, not legal advice -- always verify against the primary sources.
|
| 749 |
"""
|
|
@@ -766,21 +946,56 @@ with gr.Blocks(title="CanLex", analytics_enabled=False) as demo:
|
|
| 766 |
)
|
| 767 |
with gr.Row():
|
| 768 |
submit = gr.Button("Ask CanLex", variant="primary")
|
| 769 |
-
|
|
|
|
| 770 |
|
| 771 |
gr.Examples(examples=EXAMPLE_QUESTIONS, inputs=question, label="Examples")
|
| 772 |
|
| 773 |
# Three panels: a progress trace (also used to surface errors), the final
|
| 774 |
-
# composed answer, and the raw tool outputs the agent gathered.
|
|
|
|
|
|
|
| 775 |
progress_md = gr.Markdown(value="")
|
| 776 |
-
answer_md = gr.Markdown(value=ANSWER_PLACEHOLDER)
|
| 777 |
with gr.Accordion("Retrieved source passages (every tool call)", open=False):
|
| 778 |
-
sources_md = gr.Markdown()
|
| 779 |
-
|
| 780 |
-
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 784 |
|
| 785 |
|
| 786 |
if __name__ == "__main__":
|
|
|
|
| 3 |
|
| 4 |
A thin client that gives a non-Claude user roughly the same experience as Claude
|
| 5 |
with the CanLex MCP server. For each question it opens one streamable-HTTP
|
| 6 |
+
session against the deployed CanLex MCP, declares the seven CanLex tools to
|
| 7 |
Google Gemini, and lets the model agentically iterate -- searching, fetching
|
| 8 |
+
sections, running the s. 36 screening chain, and looking up case-law citations
|
| 9 |
+
-- until it decides it has enough material to compose a grounded answer.
|
| 10 |
|
| 11 |
All configuration comes from environment variables, set as Hugging Face Space
|
| 12 |
secrets. Run locally with: python app.py
|
| 13 |
"""
|
| 14 |
import asyncio
|
| 15 |
+
import datetime as _dt
|
| 16 |
import json
|
| 17 |
import os
|
| 18 |
import queue
|
| 19 |
import sys
|
| 20 |
+
import tempfile
|
| 21 |
import threading
|
| 22 |
+
import time
|
| 23 |
import urllib.error
|
| 24 |
import urllib.request
|
| 25 |
from datetime import timedelta
|
|
|
|
| 37 |
"CANLEX_MCP_URL", "https://beemer0-canlex.hf.space/mcp").strip()
|
| 38 |
|
| 39 |
# Google Gemini -- the free-tier key is supplied as the GEMINI_API_KEY secret.
|
| 40 |
+
# Default is flash (fast, generous free quota); the deployed Space overrides to
|
| 41 |
+
# gemini-2.5-pro via the CANLEX_GEMINI_MODEL variable for answer quality.
|
| 42 |
+
GEMINI_MODEL = os.environ.get("CANLEX_GEMINI_MODEL", "gemini-2.5-flash").strip()
|
| 43 |
GEMINI_ENDPOINT = ("https://generativelanguage.googleapis.com/v1beta/models/"
|
| 44 |
f"{GEMINI_MODEL}:generateContent")
|
| 45 |
|
| 46 |
MAX_OUTPUT_TOKENS = 8192 # generous -- covers Gemini 2.5 thinking plus the answer
|
| 47 |
MAX_TOOL_ITERATIONS = 8 # loop guard for the agent
|
| 48 |
REQUEST_TIMEOUT = 180 # seconds, applied to the MCP and Gemini calls
|
| 49 |
+
# Bound Gemini 2.5's internal thinking so it cannot eat the whole output
|
| 50 |
+
# budget on the free tier; env-overridable for experimentation.
|
| 51 |
+
THINKING_BUDGET = int(os.environ.get("CANLEX_THINKING_BUDGET", "4096"))
|
| 52 |
+
# A single tool result re-enters the prompt on EVERY later iteration, so an
|
| 53 |
+
# unbounded top_k=20 search x 8 iterations balloons prompt tokens against the
|
| 54 |
+
# free-tier TPM cap. Cap what is sent back to the model; the sources panel
|
| 55 |
+
# still shows the full text.
|
| 56 |
+
TOOL_OUTPUT_CAP = 15_000
|
| 57 |
+
_GEMINI_RETRIES = 3 # bounded retry on 429/500/503, with backoff
|
| 58 |
|
| 59 |
|
| 60 |
def _load_auth() -> list[tuple[str, str]]:
|
|
|
|
| 68 |
if user and password:
|
| 69 |
creds.append((user, password))
|
| 70 |
if not creds:
|
| 71 |
+
# Fail closed: a guessable default on a public Space is a live door to
|
| 72 |
+
# the operator's Gemini quota. Refuse to start instead.
|
| 73 |
+
print("FATAL: CANLEX_WEB_AUTH is not set (or contains no valid "
|
| 74 |
+
"'username:password' line). Set it as a Space secret -- one "
|
| 75 |
+
"'username:password' per line -- and restart. Refusing to start "
|
| 76 |
+
"with default credentials.", file=sys.stderr)
|
| 77 |
+
sys.exit(1)
|
| 78 |
return creds
|
| 79 |
|
| 80 |
|
|
|
|
| 83 |
|
| 84 |
# --- Tool declarations (Gemini function-calling schema) -----------------------
|
| 85 |
|
| 86 |
+
# The seven CanLex MCP tools, declared so Gemini can call them. The MCP server
|
| 87 |
+
# publishes flat argument schemas, so these declarations mirror the server
|
| 88 |
+
# signatures one-to-one and _run_tool passes arguments straight through.
|
|
|
|
| 89 |
TOOL_DECLARATIONS = [
|
| 90 |
{
|
| 91 |
"name": "canlex_search_legislation",
|
|
|
|
| 122 |
},
|
| 123 |
"doc_type": {
|
| 124 |
"type": "string",
|
| 125 |
+
"enum": ["legislation", "memorandum", "agreement",
|
| 126 |
+
"directive", "caselaw", "delegation",
|
| 127 |
+
"benefits", "commentary"],
|
| 128 |
"description": (
|
| 129 |
"Optional. Restrict to one source type: 'legislation', "
|
| 130 |
"'memorandum' (CBSA D-Memoranda), 'agreement' "
|
| 131 |
"(collective agreements), 'directive' (NJC), "
|
| 132 |
+
"'caselaw' (court and tribunal decisions), "
|
| 133 |
+
"'delegation' (IRPA/IRPR delegation and designation), "
|
| 134 |
+
"'benefits' (plan booklets), or 'commentary' "
|
| 135 |
+
"(CanLex's curated US-record analysis)."
|
| 136 |
),
|
| 137 |
},
|
| 138 |
},
|
|
|
|
| 293 |
},
|
| 294 |
]
|
| 295 |
|
| 296 |
+
_KNOWN_TOOLS = {d["name"] for d in TOOL_DECLARATIONS}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 297 |
|
| 298 |
|
| 299 |
# --- System prompt ------------------------------------------------------------
|
| 300 |
|
| 301 |
SYSTEM_INSTRUCTION = """\
|
| 302 |
+
You are CanLex Web, a Canadian legal-research assistant. Your reader is a \
|
| 303 |
+
working border-services professional: lead with the operative statutory \
|
| 304 |
+
language and the decision points, cite precisely, and skip lay simplification \
|
| 305 |
+
-- though they may not be a lawyer, so define genuinely technical terms once. \
|
| 306 |
+
Answer the question by agentically using the CanLex tools to retrieve primary \
|
| 307 |
+
sources, then compose a clear, well-organised answer grounded entirely in \
|
| 308 |
+
what those tools return.
|
| 309 |
+
|
| 310 |
+
Today's date is {today}. Use it for any elapsed-time computation (e.g. years \
|
| 311 |
+
since a sentence was completed, for rehabilitation) instead of assuming a \
|
| 312 |
+
date.
|
| 313 |
|
| 314 |
Tool-use guidance:
|
| 315 |
- Start with canlex_search_legislation on the user's question. Read the \
|
|
|
|
| 354 |
"""Surfaced to the UI; the message text is shown verbatim."""
|
| 355 |
|
| 356 |
|
| 357 |
+
class _Stopped(RuntimeError):
|
| 358 |
+
"""Raised inside the agent loop when the user pressed Stop."""
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
# Per-question run stats for the structured stderr log line -- the app serves
|
| 362 |
+
# one user and runs one question at a time, so a module-level dict is fine.
|
| 363 |
+
_RUN_STATS: dict = {}
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
def _gemini_request_body(contents: list[dict], tool_mode: str = "AUTO") -> dict:
|
| 367 |
"""The JSON body sent to Gemini -- identical between the streaming and the
|
| 368 |
non-streaming endpoints. Tool declarations turn on function calling; the
|
| 369 |
safety filters are relaxed because legal research routinely discusses
|
| 370 |
crime, weapons and the like, and the high-threshold defaults spuriously
|
| 371 |
+
block legitimate legal text. tool_mode='NONE' forces a text-only turn
|
| 372 |
+
(used for the terminal budget-exhausted answer). The system instruction
|
| 373 |
+
is stamped with today's date at request time -- rehabilitation math
|
| 374 |
+
depends on it."""
|
| 375 |
return {
|
| 376 |
+
"systemInstruction": {"parts": [{"text": SYSTEM_INSTRUCTION.format(
|
| 377 |
+
today=_dt.date.today().isoformat())}]},
|
| 378 |
"contents": contents,
|
| 379 |
"tools": [{"functionDeclarations": TOOL_DECLARATIONS}],
|
| 380 |
+
"toolConfig": {"functionCallingConfig": {"mode": tool_mode}},
|
| 381 |
"generationConfig": {
|
| 382 |
"temperature": 0.2,
|
| 383 |
"maxOutputTokens": MAX_OUTPUT_TOKENS,
|
| 384 |
+
"thinkingConfig": {"thinkingBudget": THINKING_BUDGET},
|
| 385 |
},
|
| 386 |
"safetySettings": [
|
| 387 |
{"category": c, "threshold": "BLOCK_ONLY_HIGH"}
|
|
|
|
| 401 |
":generateContent", ":streamGenerateContent") + "?alt=sse"
|
| 402 |
|
| 403 |
|
| 404 |
+
def _retry_delay(code: int, body_text: str, attempt: int) -> float:
|
| 405 |
+
"""Backoff for a retryable Gemini error. A 429 body may carry a
|
| 406 |
+
google.rpc.RetryInfo detail with an explicit retryDelay ('17s'); honour
|
| 407 |
+
it when present, else exponential backoff."""
|
| 408 |
+
if code == 429:
|
| 409 |
+
try:
|
| 410 |
+
for detail in json.loads(body_text)["error"]["details"]:
|
| 411 |
+
delay = detail.get("retryDelay")
|
| 412 |
+
if delay:
|
| 413 |
+
return min(float(str(delay).rstrip("s")), 60.0)
|
| 414 |
+
except Exception:
|
| 415 |
+
pass
|
| 416 |
+
return 2.0 * (2 ** attempt)
|
| 417 |
+
|
| 418 |
+
|
| 419 |
+
async def _gemini_stream(api_key: str, contents: list[dict],
|
| 420 |
+
tool_mode: str = "AUTO"):
|
| 421 |
"""Async generator over Gemini's streaming response.
|
| 422 |
|
| 423 |
Yields dicts of one of three shapes:
|
| 424 |
{"type": "text_delta", "text": str} -- a partial answer fragment
|
| 425 |
{"type": "function_call", "call": dict} -- a complete tool call
|
| 426 |
{"type": "finish", "reason": str|None, -- end of stream; `parts` is
|
| 427 |
+
"parts": list[dict], "usage": dict} the whole assistant turn
|
| 428 |
+
|
| 429 |
+
429/500/503 responses are retried up to _GEMINI_RETRIES times with
|
| 430 |
+
backoff -- on the free tier, mid-question 429s are the most likely
|
| 431 |
+
failure mode, and an abort discards every tool result already gathered.
|
| 432 |
"""
|
| 433 |
+
body = _gemini_request_body(contents, tool_mode=tool_mode)
|
| 434 |
+
payload = json.dumps(body).encode("utf-8")
|
| 435 |
+
response = None
|
| 436 |
+
for attempt in range(_GEMINI_RETRIES + 1):
|
| 437 |
+
request = urllib.request.Request(
|
| 438 |
+
_STREAM_ENDPOINT,
|
| 439 |
+
data=payload,
|
| 440 |
+
headers={"Content-Type": "application/json",
|
| 441 |
+
"x-goog-api-key": api_key,
|
| 442 |
+
"Accept": "text/event-stream"},
|
| 443 |
+
method="POST",
|
| 444 |
+
)
|
| 445 |
+
try:
|
| 446 |
+
# `timeout` is a kwarg of urlopen; passing it positionally to
|
| 447 |
+
# asyncio.to_thread would forward it as `data` (POST body) and
|
| 448 |
+
# break the request.
|
| 449 |
+
response = await asyncio.to_thread(
|
| 450 |
+
lambda: urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT))
|
| 451 |
+
break
|
| 452 |
+
except urllib.error.HTTPError as exc:
|
| 453 |
+
detail = await asyncio.to_thread(exc.read)
|
| 454 |
+
text = detail.decode("utf-8", "replace")[:600]
|
| 455 |
+
if exc.code in (429, 500, 503) and attempt < _GEMINI_RETRIES:
|
| 456 |
+
await asyncio.sleep(_retry_delay(exc.code, text, attempt))
|
| 457 |
+
continue
|
| 458 |
+
raise _AgentError(
|
| 459 |
+
f"Gemini API returned HTTP {exc.code}"
|
| 460 |
+
+ (" after retries" if attempt else "") + f": {text}") from None
|
| 461 |
+
except urllib.error.URLError as exc:
|
| 462 |
+
raise _AgentError(
|
| 463 |
+
f"Could not reach the Gemini API: {exc.reason}") from None
|
| 464 |
|
| 465 |
accumulated_parts: list[dict] = []
|
| 466 |
finish_reason = None
|
| 467 |
+
usage: dict = {}
|
| 468 |
+
block_reason = None
|
| 469 |
try:
|
| 470 |
while True:
|
| 471 |
raw = await asyncio.to_thread(response.readline)
|
|
|
|
| 478 |
chunk = json.loads(line[6:])
|
| 479 |
except ValueError:
|
| 480 |
continue
|
| 481 |
+
if chunk.get("usageMetadata"):
|
| 482 |
+
usage = chunk["usageMetadata"]
|
| 483 |
+
fb = chunk.get("promptFeedback") or {}
|
| 484 |
+
if fb.get("blockReason"):
|
| 485 |
+
block_reason = fb["blockReason"]
|
| 486 |
candidate = (chunk.get("candidates") or [{}])[0]
|
| 487 |
for part in (candidate.get("content") or {}).get("parts") or []:
|
| 488 |
accumulated_parts.append(part)
|
|
|
|
| 498 |
finish_reason = candidate["finishReason"]
|
| 499 |
finally:
|
| 500 |
await asyncio.to_thread(response.close)
|
| 501 |
+
if block_reason and not accumulated_parts:
|
| 502 |
+
# A safety block returns no candidates, only promptFeedback -- name it
|
| 503 |
+
# rather than surfacing the confusing 'empty answer' error downstream.
|
| 504 |
+
raise _AgentError(
|
| 505 |
+
f"Gemini blocked this request (blockReason: {block_reason}). "
|
| 506 |
+
"Rephrase the question; legal fact patterns occasionally trip "
|
| 507 |
+
"the safety filters despite the relaxed thresholds.")
|
| 508 |
+
yield {"type": "finish", "reason": finish_reason,
|
| 509 |
+
"parts": accumulated_parts, "usage": usage}
|
| 510 |
|
| 511 |
|
| 512 |
async def _run_tool(session: ClientSession, name: str, args: dict) -> str:
|
| 513 |
"""Execute a Gemini function call against the MCP, returning text output."""
|
| 514 |
+
if name not in _KNOWN_TOOLS:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 515 |
return f"(unknown tool '{name}')"
|
| 516 |
+
t0 = time.monotonic()
|
| 517 |
try:
|
| 518 |
+
result = await session.call_tool(name, args or {})
|
| 519 |
except Exception as exc: # MCP transport errors
|
| 520 |
return f"(tool '{name}' failed: {type(exc).__name__}: {exc})"
|
| 521 |
+
finally:
|
| 522 |
+
_RUN_STATS.setdefault("tools", []).append(
|
| 523 |
+
(name, int((time.monotonic() - t0) * 1000)))
|
| 524 |
text = "\n".join(
|
| 525 |
block.text for block in result.content
|
| 526 |
if getattr(block, "type", None) == "text" and getattr(block, "text", None)
|
|
|
|
| 592 |
return "\n\n---\n\n".join(blocks)
|
| 593 |
|
| 594 |
|
| 595 |
+
async def _agentic_answer(question: str,
|
| 596 |
+
history: list[tuple[str, str]] | None = None,
|
| 597 |
+
stop_event: threading.Event | None = None):
|
| 598 |
"""Run the Gemini-driven agentic loop against a single MCP session.
|
| 599 |
|
| 600 |
Yields tuples of (status, answer_md, sources_md). The final yield carries
|
| 601 |
the composed answer; earlier yields are progress updates the UI can show.
|
| 602 |
+
`history` is the session's prior (question, answer) pairs -- threaded into
|
| 603 |
+
the model's context so follow-ups ('same facts but Texas') work without
|
| 604 |
+
re-typing, at the cost of only the final answers, not old tool outputs.
|
| 605 |
"""
|
| 606 |
api_key = os.environ.get("GEMINI_API_KEY", "").strip()
|
| 607 |
if not api_key:
|
|
|
|
| 619 |
async with ClientSession(read, write) as session:
|
| 620 |
await session.initialize()
|
| 621 |
|
| 622 |
+
contents: list[dict] = []
|
| 623 |
+
for past_q, past_a in (history or []):
|
| 624 |
+
contents.append({"role": "user", "parts": [{"text": past_q}]})
|
| 625 |
+
contents.append({"role": "model", "parts": [{"text": past_a}]})
|
| 626 |
+
contents.append({"role": "user", "parts": [{"text": question}]})
|
| 627 |
tool_log: list[tuple[str, dict, str]] = []
|
| 628 |
trace: list[str] = []
|
| 629 |
|
|
|
|
| 640 |
lines.append("- _Thinking..._")
|
| 641 |
return "\n".join(lines) if lines else ""
|
| 642 |
|
| 643 |
+
def _check_stop():
|
| 644 |
+
if stop_event is not None and stop_event.is_set():
|
| 645 |
+
raise _Stopped()
|
| 646 |
+
|
| 647 |
for step in range(MAX_TOOL_ITERATIONS):
|
| 648 |
+
_check_stop()
|
| 649 |
yield status_md(), answer_buf, sources_md
|
| 650 |
|
| 651 |
# Stream Gemini's next turn. Stream text deltas to the answer
|
|
|
|
| 658 |
optimistic = True
|
| 659 |
|
| 660 |
async for chunk in _gemini_stream(api_key, contents):
|
| 661 |
+
_check_stop()
|
| 662 |
if chunk["type"] == "text_delta" and optimistic:
|
| 663 |
turn_text += chunk["text"]
|
| 664 |
yield (status_md(),
|
|
|
|
| 678 |
# Capture any text-only finish reason so the caller can
|
| 679 |
# surface a useful error for an empty answer.
|
| 680 |
finish_reason = chunk.get("reason")
|
| 681 |
+
_RUN_STATS["finish"] = finish_reason
|
| 682 |
+
_RUN_STATS["usage"] = (chunk.get("usage")
|
| 683 |
+
or _RUN_STATS.get("usage", {}))
|
| 684 |
+
_RUN_STATS["iterations"] = step + 1
|
| 685 |
|
| 686 |
contents.append({"role": "model", "parts": turn_parts})
|
| 687 |
|
|
|
|
| 707 |
snippet = snippet[:137].rstrip() + "..."
|
| 708 |
trace.append(f"_{snippet}_")
|
| 709 |
|
| 710 |
+
# Execute every function call in this turn concurrently (a
|
| 711 |
+
# parallel-call turn is common in the screening flow), then
|
| 712 |
+
# send the responses back as a single 'user' message.
|
| 713 |
for call in turn_calls:
|
| 714 |
+
trace.append(_summarize_call(call.get("name", ""),
|
| 715 |
+
call.get("args") or {}))
|
| 716 |
+
yield status_md(), answer_buf, sources_md
|
| 717 |
+
|
| 718 |
+
outputs = await asyncio.gather(*(
|
| 719 |
+
_run_tool(session, call.get("name", ""),
|
| 720 |
+
call.get("args") or {})
|
| 721 |
+
for call in turn_calls))
|
| 722 |
+
function_responses = []
|
| 723 |
+
for call, output in zip(turn_calls, outputs):
|
| 724 |
name = call.get("name", "")
|
| 725 |
args = call.get("args") or {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 726 |
tool_log.append((name, args, output))
|
| 727 |
+
# The model gets a capped copy; the sources panel keeps
|
| 728 |
+
# the full text.
|
| 729 |
+
sent = output
|
| 730 |
+
if len(sent) > TOOL_OUTPUT_CAP:
|
| 731 |
+
sent = (sent[:TOOL_OUTPUT_CAP]
|
| 732 |
+
+ "\n\n(truncated for context; the full text "
|
| 733 |
+
"was shown to the user)")
|
| 734 |
function_responses.append({
|
| 735 |
"functionResponse": {
|
| 736 |
"name": name,
|
| 737 |
+
"response": {"output": sent},
|
| 738 |
}
|
| 739 |
})
|
| 740 |
+
sources_md = _format_sources(tool_log)
|
| 741 |
contents.append({"role": "user", "parts": function_responses})
|
| 742 |
|
| 743 |
# Loop budget exhausted -- ask Gemini for a final answer without
|
|
|
|
| 748 |
"the best answer you can from the material gathered so far, "
|
| 749 |
"without calling further tools. If the material is "
|
| 750 |
"insufficient, say so plainly."}]})
|
| 751 |
+
# tool_mode NONE: the model CANNOT waste the terminal turn on
|
| 752 |
+
# another function call the loop would discard.
|
| 753 |
turn_text = ""
|
| 754 |
+
async for chunk in _gemini_stream(api_key, contents,
|
| 755 |
+
tool_mode="NONE"):
|
| 756 |
if chunk["type"] == "text_delta":
|
| 757 |
turn_text += chunk["text"]
|
| 758 |
yield (status_md(thinking=False),
|
|
|
|
| 773 |
_SENTINEL = object()
|
| 774 |
|
| 775 |
|
| 776 |
+
def _log_run(question: str, started: float, outcome: str):
|
| 777 |
+
"""One structured stderr line per question -- HF captures container
|
| 778 |
+
stderr, so this is the whole postmortem story for 'it failed yesterday'
|
| 779 |
+
plus free-tier quota tracking."""
|
| 780 |
+
usage = _RUN_STATS.get("usage") or {}
|
| 781 |
+
try:
|
| 782 |
+
print(json.dumps({
|
| 783 |
+
"ts": _dt.datetime.now().isoformat(timespec="seconds"),
|
| 784 |
+
"question": question[:120],
|
| 785 |
+
"outcome": outcome,
|
| 786 |
+
"duration_s": round(time.monotonic() - started, 1),
|
| 787 |
+
"iterations": _RUN_STATS.get("iterations"),
|
| 788 |
+
"finish_reason": _RUN_STATS.get("finish"),
|
| 789 |
+
"tools": _RUN_STATS.get("tools", []),
|
| 790 |
+
"prompt_tokens": usage.get("promptTokenCount"),
|
| 791 |
+
"response_tokens": usage.get("candidatesTokenCount"),
|
| 792 |
+
"thinking_tokens": usage.get("thoughtsTokenCount"),
|
| 793 |
+
"model": GEMINI_MODEL,
|
| 794 |
+
}, ensure_ascii=False), file=sys.stderr, flush=True)
|
| 795 |
+
except Exception:
|
| 796 |
+
pass
|
| 797 |
+
|
| 798 |
+
|
| 799 |
+
def answer(question: str, history: list | None):
|
| 800 |
"""Generator wrapping the async agent for Gradio's progressive UI.
|
| 801 |
|
| 802 |
The async work runs on a dedicated worker thread with its own event loop
|
|
|
|
| 805 |
previous loop.run_until_complete-per-anext pattern created a fresh task
|
| 806 |
on every yield, which tripped anyio's cancel-scope check inside the MCP
|
| 807 |
streamable-HTTP client ('Attempted to exit cancel scope in a different
|
| 808 |
+
task than it was entered in').
|
| 809 |
+
|
| 810 |
+
Yields (status, answer_md, sources_md, history). On error, the partial
|
| 811 |
+
answer already streamed stays visible under the error notice instead of
|
| 812 |
+
vanishing. If Gradio cancels this generator (Stop button, disconnect),
|
| 813 |
+
the stop event tells the worker to abandon the run instead of silently
|
| 814 |
+
burning quota to completion."""
|
| 815 |
question = (question or "").strip()
|
| 816 |
+
history = list(history or [])
|
| 817 |
if not question:
|
| 818 |
+
yield ("Please enter a legal question above.", ANSWER_PLACEHOLDER,
|
| 819 |
+
"", history)
|
| 820 |
return
|
| 821 |
|
| 822 |
+
_RUN_STATS.clear()
|
| 823 |
+
started = time.monotonic()
|
| 824 |
events: queue.Queue = queue.Queue()
|
| 825 |
+
stop_event = threading.Event()
|
| 826 |
|
| 827 |
def worker():
|
| 828 |
async def run():
|
| 829 |
try:
|
| 830 |
+
async for tup in _agentic_answer(question, history,
|
| 831 |
+
stop_event):
|
| 832 |
events.put(("yield", tup))
|
| 833 |
+
except _Stopped:
|
| 834 |
+
events.put(("stopped", None))
|
| 835 |
except _AgentError as exc:
|
| 836 |
events.put(("agent_error", exc))
|
| 837 |
except Exception as exc: # network blip, MCP transport
|
|
|
|
| 846 |
|
| 847 |
threading.Thread(target=worker, daemon=True).start()
|
| 848 |
|
| 849 |
+
last_answer, last_sources = "", ""
|
| 850 |
+
outcome = "ok"
|
| 851 |
+
try:
|
| 852 |
+
while True:
|
| 853 |
+
kind, *payload = events.get()
|
| 854 |
+
if kind is _SENTINEL:
|
| 855 |
+
if outcome == "ok" and last_answer and \
|
| 856 |
+
last_answer != ANSWER_PLACEHOLDER:
|
| 857 |
+
history.append((question, last_answer))
|
| 858 |
+
return
|
| 859 |
+
if kind == "yield":
|
| 860 |
+
status, ans, src = payload[0]
|
| 861 |
+
last_answer, last_sources = ans or last_answer, src or last_sources
|
| 862 |
+
yield status, ans, src, history
|
| 863 |
+
elif kind == "stopped":
|
| 864 |
+
outcome = "stopped"
|
| 865 |
+
yield ("_Stopped._", last_answer or ANSWER_PLACEHOLDER,
|
| 866 |
+
last_sources, history)
|
| 867 |
+
elif kind == "agent_error":
|
| 868 |
+
outcome = "agent_error"
|
| 869 |
+
kept = ""
|
| 870 |
+
if last_answer and last_answer != ANSWER_PLACEHOLDER:
|
| 871 |
+
kept = ("\n\n---\n\n_Partial answer before the error "
|
| 872 |
+
"(retry to complete):_\n\n" + last_answer)
|
| 873 |
+
yield (f"**{payload[0]}**",
|
| 874 |
+
(last_answer or ANSWER_PLACEHOLDER) if not kept
|
| 875 |
+
else f"**{payload[0]}**{kept}",
|
| 876 |
+
last_sources, history)
|
| 877 |
+
elif kind == "error":
|
| 878 |
+
outcome = "error"
|
| 879 |
+
exc = payload[0]
|
| 880 |
+
# Unwrap ExceptionGroup (from anyio TaskGroups in the MCP
|
| 881 |
+
# client) so the user sees the actual root cause.
|
| 882 |
+
lines = []
|
| 883 |
+
def _walk(e, depth=0):
|
| 884 |
+
indent = " " * depth
|
| 885 |
+
lines.append(f"{indent}- `{type(e).__name__}: {e}`")
|
| 886 |
+
inner = getattr(e, "exceptions", None)
|
| 887 |
+
if inner:
|
| 888 |
+
for sub in inner:
|
| 889 |
+
_walk(sub, depth + 1)
|
| 890 |
+
_walk(exc)
|
| 891 |
+
notice = ("**Could not complete the request.**\n\n"
|
| 892 |
+
+ "\n".join(lines) +
|
| 893 |
+
"\n\nThe MCP service may be waking from sleep -- "
|
| 894 |
+
"try again in a moment.")
|
| 895 |
+
body = last_answer if last_answer and \
|
| 896 |
+
last_answer != ANSWER_PLACEHOLDER else ANSWER_PLACEHOLDER
|
| 897 |
+
yield notice, body, last_sources, history
|
| 898 |
+
finally:
|
| 899 |
+
stop_event.set() # Stop button / disconnect: end the worker
|
| 900 |
+
_log_run(question, started, outcome)
|
| 901 |
|
| 902 |
|
| 903 |
# --- UI -----------------------------------------------------------------------
|
|
|
|
| 910 |
provisions, D-Memoranda, collective-agreement terms and leading court
|
| 911 |
decisions, then composes an answer that cites them.
|
| 912 |
|
| 913 |
+
The CanLex corpus contains dozens of federal Acts and regulations with their
|
| 914 |
+
Schedules -- including the Immigration and Refugee Protection Act, the Customs
|
| 915 |
+
Act, the Criminal Code and the controlled-substance and firearms-classification
|
| 916 |
+
schedules -- alongside the CBSA D-Memoranda, the FB (Border Services)
|
| 917 |
+
collective agreement, the National Joint Council directives, leading decisions
|
| 918 |
+
of the Supreme Court, the Federal Courts and the federal labour and
|
| 919 |
+
immigration tribunals, the IRPA/IRPR instruments of delegation, and CanLex's
|
| 920 |
+
curated US-record screening commentary. (Ask "what does the corpus contain?"
|
| 921 |
+
for the live inventory.)
|
| 922 |
|
| 923 |
The assistant iterates over the corpus -- searching, fetching sections and
|
| 924 |
looking up case-law citations -- before composing a grounded answer. A complex
|
| 925 |
+
question may take 30 seconds or more. Follow-up questions keep the
|
| 926 |
+
conversation's context; Clear starts fresh.
|
| 927 |
|
| 928 |
Legal information, not legal advice -- always verify against the primary sources.
|
| 929 |
"""
|
|
|
|
| 946 |
)
|
| 947 |
with gr.Row():
|
| 948 |
submit = gr.Button("Ask CanLex", variant="primary")
|
| 949 |
+
stop = gr.Button("Stop", variant="stop")
|
| 950 |
+
clear = gr.Button("Clear conversation")
|
| 951 |
|
| 952 |
gr.Examples(examples=EXAMPLE_QUESTIONS, inputs=question, label="Examples")
|
| 953 |
|
| 954 |
# Three panels: a progress trace (also used to surface errors), the final
|
| 955 |
+
# composed answer, and the raw tool outputs the agent gathered. History
|
| 956 |
+
# carries the session's prior Q&A pairs so follow-ups have context.
|
| 957 |
+
history_state = gr.State([])
|
| 958 |
progress_md = gr.Markdown(value="")
|
| 959 |
+
answer_md = gr.Markdown(value=ANSWER_PLACEHOLDER, show_copy_button=True)
|
| 960 |
with gr.Accordion("Retrieved source passages (every tool call)", open=False):
|
| 961 |
+
sources_md = gr.Markdown(show_copy_button=True)
|
| 962 |
+
export_btn = gr.Button("Export research memo (.md)", size="sm")
|
| 963 |
+
export_file = gr.File(label="Research memo", visible=False)
|
| 964 |
+
|
| 965 |
+
def export_memo(q, ans, src, hist):
|
| 966 |
+
"""Write the session's research to a dated Markdown memo -- an
|
| 967 |
+
audit-trail artifact for a case file."""
|
| 968 |
+
stamp = _dt.datetime.now().strftime("%Y-%m-%d %H:%M")
|
| 969 |
+
parts = [f"# CanLex research memo — {stamp}", ""]
|
| 970 |
+
for past_q, past_a in (hist or []):
|
| 971 |
+
parts += [f"## Q: {past_q}", "", past_a, ""]
|
| 972 |
+
if q and ans and ans != ANSWER_PLACEHOLDER and not any(
|
| 973 |
+
q == pq for pq, _ in (hist or [])):
|
| 974 |
+
parts += [f"## Q: {q}", "", ans, ""]
|
| 975 |
+
if src:
|
| 976 |
+
parts += ["---", "", "## Retrieved source passages", "", src]
|
| 977 |
+
parts += ["", "---", "_Generated by CanLex Web. Legal information, "
|
| 978 |
+
"not legal advice; verify against the primary sources._"]
|
| 979 |
+
path = os.path.join(tempfile.gettempdir(),
|
| 980 |
+
f"canlex-memo-{_dt.date.today().isoformat()}.md")
|
| 981 |
+
with open(path, "w", encoding="utf-8") as fh:
|
| 982 |
+
fh.write("\n".join(parts))
|
| 983 |
+
return gr.File(value=path, visible=True)
|
| 984 |
+
|
| 985 |
+
ask_evt = submit.click(answer, [question, history_state],
|
| 986 |
+
[progress_md, answer_md, sources_md, history_state])
|
| 987 |
+
enter_evt = question.submit(answer, [question, history_state],
|
| 988 |
+
[progress_md, answer_md, sources_md,
|
| 989 |
+
history_state])
|
| 990 |
+
stop.click(None, cancels=[ask_evt, enter_evt])
|
| 991 |
+
export_btn.click(export_memo,
|
| 992 |
+
[question, answer_md, sources_md, history_state],
|
| 993 |
+
[export_file])
|
| 994 |
+
clear.click(lambda: ("", "", ANSWER_PLACEHOLDER, "", [],
|
| 995 |
+
gr.File(visible=False)),
|
| 996 |
+
None,
|
| 997 |
+
[question, progress_md, answer_md, sources_md, history_state,
|
| 998 |
+
export_file])
|
| 999 |
|
| 1000 |
|
| 1001 |
if __name__ == "__main__":
|