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
Files changed (2) hide show
  1. README.md +13 -11
  2. app.py +335 -120
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 it:
16
-
17
- 1. calls the deployed **CanLex MCP server** (`canlex_search_legislation`) to
18
- retrieve the cited source passages;
19
- 2. sends those passages and the question to **Google Gemini Flash**, which
20
- composes a grounded, cited answer following CanLex's own answering
21
- instructions;
22
- 3. displays the answer, with the retrieved passages shown for review.
 
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 falls back to an insecure default login
45
- (`canlex` / `canlex`) and logs a warning -- set the secret before real use.
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 four CanLex tools to
7
  Google Gemini, and lets the model agentically iterate -- searching, fetching
8
- sections, and looking up case-law citations -- until it decides it has enough
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
- GEMINI_MODEL = os.environ.get("CANLEX_GEMINI_MODEL", "gemini-2.5-pro").strip()
 
 
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
- print("WARNING: CANLEX_WEB_AUTH is not set; using the insecure default "
58
- "login 'canlex' / 'canlex'. Set CANLEX_WEB_AUTH as a Space secret "
59
- "(one 'username:password' per line) before sharing this app.",
60
- file=sys.stderr)
61
- creds = [("canlex", "canlex")]
 
 
62
  return creds
63
 
64
 
@@ -67,10 +83,9 @@ AUTH = _load_auth()
67
 
68
  # --- Tool declarations (Gemini function-calling schema) -----------------------
69
 
70
- # The four CanLex MCP tools, declared so Gemini can call them. Three of the four
71
- # wrap their arguments inside a single 'params' object on the server side; the
72
- # Gemini schema is kept flat for the model's convenience and re-wrapped at the
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), or "
115
- "'delegation' (IRPA/IRPR delegation and designation)."
 
 
116
  ),
117
  },
118
  },
@@ -273,21 +293,23 @@ TOOL_DECLARATIONS = [
273
  },
274
  ]
275
 
276
- # Tools whose MCP signature wraps arguments under a single 'params' object.
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. A member of the public \
287
- has asked the legal question shown below through a web form. Answer it by \
288
- agentically using the CanLex tools to retrieve primary sources, then \
289
- compose a clear, well-organised answer grounded entirely in what those tools \
290
- return.
 
 
 
 
 
 
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
- def _gemini_request_body(contents: list[dict]) -> dict:
 
 
 
 
 
 
 
 
 
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": "AUTO"}},
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
- async def _gemini_stream(api_key: str, contents: list[dict]):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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]} the whole assistant turn
 
 
 
 
376
  """
377
- body = _gemini_request_body(contents)
378
- request = urllib.request.Request(
379
- _STREAM_ENDPOINT,
380
- data=json.dumps(body).encode("utf-8"),
381
- headers={"Content-Type": "application/json",
382
- "x-goog-api-key": api_key,
383
- "Accept": "text/event-stream"},
384
- method="POST",
385
- )
386
- try:
387
- # `timeout` is a kwarg of urlopen; passing it positionally to
388
- # asyncio.to_thread would forward it as `data` (POST body) and break
389
- # the request.
390
- response = await asyncio.to_thread(
391
- lambda: urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT))
392
- except urllib.error.HTTPError as exc:
393
- detail = await asyncio.to_thread(exc.read)
394
- text = detail.decode("utf-8", "replace")[:600]
395
- raise _AgentError(f"Gemini API returned HTTP {exc.code}: {text}") from None
396
- except urllib.error.URLError as exc:
397
- raise _AgentError(f"Could not reach the Gemini API: {exc.reason}") from None
 
 
 
 
 
 
 
 
 
 
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
- yield {"type": "finish", "reason": finish_reason, "parts": accumulated_parts}
 
 
 
 
 
 
 
 
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 == "canlex_list_acts":
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, mcp_args)
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
- {"role": "user", "parts": [{"text": question}]}
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, then send the
614
- # responses back as a single 'user' message.
615
- function_responses = []
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
- sources_md = _format_sources(tool_log)
 
 
 
 
 
 
626
  function_responses.append({
627
  "functionResponse": {
628
  "name": name,
629
- "response": {"output": 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 answer(question: str):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- while True:
701
- kind, *payload = events.get()
702
- if kind is _SENTINEL:
703
- return
704
- if kind == "yield":
705
- yield payload[0]
706
- elif kind == "agent_error":
707
- yield (f"**{payload[0]}**", ANSWER_PLACEHOLDER, "")
708
- elif kind == "error":
709
- exc = payload[0]
710
- # Unwrap ExceptionGroup (from anyio TaskGroups in the MCP client)
711
- # so the user sees the actual root cause, not the wrapper.
712
- lines = []
713
- def _walk(e, depth=0):
714
- indent = " " * depth
715
- lines.append(f"{indent}- `{type(e).__name__}: {e}`")
716
- inner = getattr(e, "exceptions", None)
717
- if inner:
718
- for sub in inner:
719
- _walk(sub, depth + 1)
720
- _walk(exc)
721
- yield ("**Could not complete the request.**\n\n"
722
- + "\n".join(lines) +
723
- "\n\nThe MCP service may be waking from sleep -- "
724
- "try again in a moment.", ANSWER_PLACEHOLDER, "")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 31 federal Acts and regulations -- including the
738
- Immigration and Refugee Protection Act, the Customs Act and the Criminal Code
739
- -- alongside the CBSA D-Memoranda, the FB (Border Services) collective
740
- agreement, the National Joint Council directives, leading decisions of the
741
- Supreme Court, the Federal Courts and the federal labour and immigration
742
- tribunals, and the IRPA/IRPR instruments of delegation and designation.
 
 
 
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
- clear = gr.Button("Clear")
 
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
- submit.click(answer, [question], [progress_md, answer_md, sources_md])
781
- question.submit(answer, [question], [progress_md, answer_md, sources_md])
782
- clear.click(lambda: ("", "", ANSWER_PLACEHOLDER, ""), None,
783
- [question, progress_md, answer_md, sources_md])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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__":