Beemer Claude Opus 4.7 commited on
Commit ·
34a90f9
1
Parent(s): 55484bc
Track the Path B web app (webapp/) in the repo
Browse fileswebapp/ is the private Path B front-end -- a thin Gradio client deployed to
its own Hugging Face Space (Beemer0/Canlex-web) via 'hf upload', so it had
lived only on disk and on the Space with no version control. Add its source
to the repo for history. Its current state: an agentic loop that lets Gemini
iterate the four CanLex MCP tools, streamed answer tokens, and the
worker-thread bridge that fixed the anyio cancel-scope error.
This does not affect the MCP Space's Docker image -- that Dockerfile only
copies canlex/ and data/processed/. The web app is still built and deployed
separately from webapp/.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- webapp/.gitignore +2 -0
- webapp/Dockerfile +25 -0
- webapp/README.md +63 -0
- webapp/app.py +653 -0
- webapp/requirements.txt +7 -0
webapp/.gitignore
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
webapp/Dockerfile
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CanLex Web (Path B) -- the private Gradio front-end.
|
| 2 |
+
#
|
| 3 |
+
# A thin client: it carries no corpus and loads no models. It calls the deployed
|
| 4 |
+
# CanLex MCP server for retrieval and Google Gemini for answer composition.
|
| 5 |
+
# Builds on Hugging Face Spaces (sdk: docker) or with plain Docker.
|
| 6 |
+
FROM python:3.12-slim
|
| 7 |
+
|
| 8 |
+
# Run as a non-root user (UID 1000) -- required by Hugging Face Spaces.
|
| 9 |
+
RUN useradd --create-home --home-dir /app --uid 1000 app
|
| 10 |
+
WORKDIR /app
|
| 11 |
+
|
| 12 |
+
# Python dependencies first, so this layer caches across code changes.
|
| 13 |
+
COPY requirements.txt .
|
| 14 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 15 |
+
|
| 16 |
+
COPY --chown=app:app app.py .
|
| 17 |
+
|
| 18 |
+
USER app
|
| 19 |
+
ENV HOME=/app \
|
| 20 |
+
PORT=7860 \
|
| 21 |
+
PYTHONUNBUFFERED=1 \
|
| 22 |
+
GRADIO_ANALYTICS_ENABLED=False
|
| 23 |
+
|
| 24 |
+
EXPOSE 7860
|
| 25 |
+
CMD ["python", "app.py"]
|
webapp/README.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: CanLex Web
|
| 3 |
+
sdk: docker
|
| 4 |
+
app_port: 7860
|
| 5 |
+
pinned: false
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
# CanLex Web (Path B)
|
| 9 |
+
|
| 10 |
+
A private web front-end for **CanLex**, so people without an MCP-capable AI
|
| 11 |
+
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
|
| 26 |
+
pick it up. Only UI or prompt changes redeploy this Space.
|
| 27 |
+
|
| 28 |
+
## Required Space secrets
|
| 29 |
+
|
| 30 |
+
Set these under **Settings -> Variables and secrets**:
|
| 31 |
+
|
| 32 |
+
| Name | Kind | Purpose |
|
| 33 |
+
|------|------|---------|
|
| 34 |
+
| `GEMINI_API_KEY` | secret | Free Gemini key from Google AI Studio (https://aistudio.google.com/apikey). |
|
| 35 |
+
| `CANLEX_WEB_AUTH` | secret | Login credentials, one `username:password` per line. |
|
| 36 |
+
|
| 37 |
+
Optional overrides:
|
| 38 |
+
|
| 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 |
+
|
| 49 |
+
Under **Settings -> Change Space visibility**, set the Space to **Private**.
|
| 50 |
+
The app then has two layers of protection: Hugging Face gates who can open the
|
| 51 |
+
page at all, and Gradio's username/password gates who can use it.
|
| 52 |
+
|
| 53 |
+
## Run locally
|
| 54 |
+
|
| 55 |
+
```
|
| 56 |
+
pip install -r requirements.txt
|
| 57 |
+
# PowerShell:
|
| 58 |
+
$env:GEMINI_API_KEY = "your-key"
|
| 59 |
+
$env:CANLEX_WEB_AUTH = "me:secret"
|
| 60 |
+
python app.py
|
| 61 |
+
```
|
| 62 |
+
|
| 63 |
+
Then open http://localhost:7860.
|
webapp/app.py
ADDED
|
@@ -0,0 +1,653 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""CanLex Web (Path B) -- a private web front-end for CanLex legal research.
|
| 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
|
| 23 |
+
|
| 24 |
+
import gradio as gr
|
| 25 |
+
from mcp import ClientSession
|
| 26 |
+
from mcp.client.streamable_http import streamablehttp_client
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# --- Configuration (Hugging Face Space secrets / environment variables) -------
|
| 30 |
+
|
| 31 |
+
# The deployed CanLex MCP server. Retrieval logic and the corpus live there; this
|
| 32 |
+
# app never carries its own copy. Override only to point at a different server.
|
| 33 |
+
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]]:
|
| 47 |
+
"""Parse CANLEX_WEB_AUTH (one 'username:password' per line) for Gradio auth."""
|
| 48 |
+
creds: list[tuple[str, str]] = []
|
| 49 |
+
for line in os.environ.get("CANLEX_WEB_AUTH", "").splitlines():
|
| 50 |
+
line = line.strip()
|
| 51 |
+
if not line or ":" not in line:
|
| 52 |
+
continue
|
| 53 |
+
user, password = (p.strip() for p in line.split(":", 1))
|
| 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 |
+
|
| 65 |
+
AUTH = _load_auth()
|
| 66 |
+
|
| 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",
|
| 77 |
+
"description": (
|
| 78 |
+
"Search Canadian federal law, CBSA D-Memoranda, collective "
|
| 79 |
+
"agreements, NJC directives, leading court decisions and IRPA "
|
| 80 |
+
"delegation instruments for material relevant to a question. "
|
| 81 |
+
"Use this first for any topical question. Returns ranked source "
|
| 82 |
+
"passages with citations. Call it multiple times for different "
|
| 83 |
+
"facets of a question, or with the optional 'act' or 'doc_type' "
|
| 84 |
+
"filters to narrow the search."
|
| 85 |
+
),
|
| 86 |
+
"parameters": {
|
| 87 |
+
"type": "object",
|
| 88 |
+
"properties": {
|
| 89 |
+
"query": {
|
| 90 |
+
"type": "string",
|
| 91 |
+
"description": (
|
| 92 |
+
"Natural-language legal question or keywords, e.g. "
|
| 93 |
+
"'detention review timelines' or 'innocent "
|
| 94 |
+
"misrepresentation defence under IRPA s. 40'."
|
| 95 |
+
),
|
| 96 |
+
},
|
| 97 |
+
"top_k": {
|
| 98 |
+
"type": "integer",
|
| 99 |
+
"description": "Number of sections to return (1-20). Default 6.",
|
| 100 |
+
},
|
| 101 |
+
"act": {
|
| 102 |
+
"type": "string",
|
| 103 |
+
"description": (
|
| 104 |
+
"Optional. Restrict to a single Act, by short name or "
|
| 105 |
+
"code (e.g. 'IRPA' or 'I-2.5')."
|
| 106 |
+
),
|
| 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 |
+
},
|
| 119 |
+
"required": ["query"],
|
| 120 |
+
},
|
| 121 |
+
},
|
| 122 |
+
{
|
| 123 |
+
"name": "canlex_get_section",
|
| 124 |
+
"description": (
|
| 125 |
+
"Fetch the full text of one specific provision when its Act and "
|
| 126 |
+
"section number are known. Use this to follow a cross-reference "
|
| 127 |
+
"the search results mention but did not include."
|
| 128 |
+
),
|
| 129 |
+
"parameters": {
|
| 130 |
+
"type": "object",
|
| 131 |
+
"properties": {
|
| 132 |
+
"act": {
|
| 133 |
+
"type": "string",
|
| 134 |
+
"description": "Act short name or code, e.g. 'IRPA' or 'I-2.5'.",
|
| 135 |
+
},
|
| 136 |
+
"section": {
|
| 137 |
+
"type": "string",
|
| 138 |
+
"description": "Section number exactly as cited, e.g. '34', '20.1'.",
|
| 139 |
+
},
|
| 140 |
+
},
|
| 141 |
+
"required": ["act", "section"],
|
| 142 |
+
},
|
| 143 |
+
},
|
| 144 |
+
{
|
| 145 |
+
"name": "canlex_list_acts",
|
| 146 |
+
"description": (
|
| 147 |
+
"List every Act, regulation, agreement, directive, case-law "
|
| 148 |
+
"decision and delegation instrument loaded into the CanLex "
|
| 149 |
+
"corpus. Useful when the user asks 'what does CanLex have on X?' "
|
| 150 |
+
"or when you need to confirm a source is in scope."
|
| 151 |
+
),
|
| 152 |
+
"parameters": {"type": "object", "properties": {}},
|
| 153 |
+
},
|
| 154 |
+
{
|
| 155 |
+
"name": "canlex_case",
|
| 156 |
+
"description": (
|
| 157 |
+
"Look up a Canadian case on CanLII to check its citation graph -- "
|
| 158 |
+
"cases that cite it, cases it cites, legislation it cites. Use "
|
| 159 |
+
"this to confirm a decision found in search results is still good "
|
| 160 |
+
"law and to find related authorities. Pass a neutral citation "
|
| 161 |
+
"(e.g. '2019 SCC 65', '2016 FCA 93', '2005 FC 1059') or a full "
|
| 162 |
+
"canlii.org URL."
|
| 163 |
+
),
|
| 164 |
+
"parameters": {
|
| 165 |
+
"type": "object",
|
| 166 |
+
"properties": {
|
| 167 |
+
"case_url": {
|
| 168 |
+
"type": "string",
|
| 169 |
+
"description": (
|
| 170 |
+
"Neutral citation (preferred for SCC/FCA/FC) or full "
|
| 171 |
+
"canlii.org URL."
|
| 172 |
+
),
|
| 173 |
+
},
|
| 174 |
+
},
|
| 175 |
+
"required": ["case_url"],
|
| 176 |
+
},
|
| 177 |
+
},
|
| 178 |
+
]
|
| 179 |
+
|
| 180 |
+
# The three tools whose MCP signature wraps arguments under a single 'params'
|
| 181 |
+
# object. canlex_list_acts takes none and is handled separately in _run_tool.
|
| 182 |
+
_PARAMS_WRAPPED = {"canlex_search_legislation", "canlex_get_section", "canlex_case"}
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
# --- System prompt ------------------------------------------------------------
|
| 186 |
+
|
| 187 |
+
SYSTEM_INSTRUCTION = """\
|
| 188 |
+
You are CanLex Web, a Canadian legal-research assistant. A member of the public \
|
| 189 |
+
has asked the legal question shown below through a web form. Answer it by \
|
| 190 |
+
agentically using the four CanLex tools to retrieve primary sources, then \
|
| 191 |
+
compose a clear, well-organised answer grounded entirely in what those tools \
|
| 192 |
+
return.
|
| 193 |
+
|
| 194 |
+
Tool-use guidance:
|
| 195 |
+
- Start with canlex_search_legislation on the user's question. Read the \
|
| 196 |
+
results, including the "ANSWERING INSTRUCTIONS" block CanLex returns.
|
| 197 |
+
- If a result mentions a cross-referenced provision, regulation or D-Memo that \
|
| 198 |
+
bears on the question but is not reproduced, call canlex_get_section or \
|
| 199 |
+
canlex_search_legislation again to fetch it. Do not guess its contents.
|
| 200 |
+
- For a question that turns on case law, consider calling canlex_case on the \
|
| 201 |
+
leading decision's neutral citation to confirm it has not been overtaken.
|
| 202 |
+
- You may call tools multiple times; iterate until you have enough material to \
|
| 203 |
+
answer well. Aim for thoroughness but stop once further calls would not change \
|
| 204 |
+
the answer.
|
| 205 |
+
|
| 206 |
+
Answering style:
|
| 207 |
+
- Write for a reader who cannot see the raw passages. Quote the key operative \
|
| 208 |
+
words ("inadmissible for misrepresentation", etc.) and give every citation in \
|
| 209 |
+
full, including section numbers and the deciding court.
|
| 210 |
+
- Distinguish source kinds: enacted law is binding; CBSA D-Memoranda are \
|
| 211 |
+
administrative guidance, persuasive only; collective agreements and NJC \
|
| 212 |
+
directives are binding employment-terms instruments for a bargaining unit; \
|
| 213 |
+
court decisions are binding precedent depending on the court and jurisdiction.
|
| 214 |
+
- State the date the source is current to, and note that the answer reflects \
|
| 215 |
+
the law only as of that date.
|
| 216 |
+
- Use plain Markdown -- short paragraphs, headings or lists where they aid \
|
| 217 |
+
clarity.
|
| 218 |
+
- If the retrieved material does not actually answer the question, say so \
|
| 219 |
+
plainly rather than stretching it to fit.
|
| 220 |
+
- Close with a one-line reminder that this is legal information, not legal \
|
| 221 |
+
advice."""
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
# --- Agentic loop: Gemini <-> MCP --------------------------------------------
|
| 225 |
+
|
| 226 |
+
class _AgentError(RuntimeError):
|
| 227 |
+
"""Surfaced to the UI; the message text is shown verbatim."""
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def _gemini_request_body(contents: list[dict]) -> dict:
|
| 231 |
+
"""The JSON body sent to Gemini -- identical between the streaming and the
|
| 232 |
+
non-streaming endpoints. Tool declarations turn on function calling; the
|
| 233 |
+
safety filters are relaxed because legal research routinely discusses
|
| 234 |
+
crime, weapons and the like, and the high-threshold defaults spuriously
|
| 235 |
+
block legitimate legal text."""
|
| 236 |
+
return {
|
| 237 |
+
"systemInstruction": {"parts": [{"text": SYSTEM_INSTRUCTION}]},
|
| 238 |
+
"contents": contents,
|
| 239 |
+
"tools": [{"functionDeclarations": TOOL_DECLARATIONS}],
|
| 240 |
+
"toolConfig": {"functionCallingConfig": {"mode": "AUTO"}},
|
| 241 |
+
"generationConfig": {
|
| 242 |
+
"temperature": 0.2,
|
| 243 |
+
"maxOutputTokens": MAX_OUTPUT_TOKENS,
|
| 244 |
+
},
|
| 245 |
+
"safetySettings": [
|
| 246 |
+
{"category": c, "threshold": "BLOCK_ONLY_HIGH"}
|
| 247 |
+
for c in ("HARM_CATEGORY_HARASSMENT", "HARM_CATEGORY_HATE_SPEECH",
|
| 248 |
+
"HARM_CATEGORY_SEXUALLY_EXPLICIT",
|
| 249 |
+
"HARM_CATEGORY_DANGEROUS_CONTENT")
|
| 250 |
+
],
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
# Gemini's streamGenerateContent endpoint, used when alt=sse is requested,
|
| 255 |
+
# sends one Server-Sent Event per partial GenerateContentResponse. Each chunk
|
| 256 |
+
# carries an incremental slice of the turn's content -- a text delta or a
|
| 257 |
+
# (complete) functionCall part. The accumulated parts list is what gets sent
|
| 258 |
+
# back as the assistant turn for the next round.
|
| 259 |
+
_STREAM_ENDPOINT = GEMINI_ENDPOINT.replace(
|
| 260 |
+
":generateContent", ":streamGenerateContent") + "?alt=sse"
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
async def _gemini_stream(api_key: str, contents: list[dict]):
|
| 264 |
+
"""Async generator over Gemini's streaming response.
|
| 265 |
+
|
| 266 |
+
Yields dicts of one of three shapes:
|
| 267 |
+
{"type": "text_delta", "text": str} -- a partial answer fragment
|
| 268 |
+
{"type": "function_call", "call": dict} -- a complete tool call
|
| 269 |
+
{"type": "finish", "reason": str|None, -- end of stream; `parts` is
|
| 270 |
+
"parts": list[dict]} the whole assistant turn
|
| 271 |
+
"""
|
| 272 |
+
body = _gemini_request_body(contents)
|
| 273 |
+
request = urllib.request.Request(
|
| 274 |
+
_STREAM_ENDPOINT,
|
| 275 |
+
data=json.dumps(body).encode("utf-8"),
|
| 276 |
+
headers={"Content-Type": "application/json",
|
| 277 |
+
"x-goog-api-key": api_key,
|
| 278 |
+
"Accept": "text/event-stream"},
|
| 279 |
+
method="POST",
|
| 280 |
+
)
|
| 281 |
+
try:
|
| 282 |
+
# `timeout` is a kwarg of urlopen; passing it positionally to
|
| 283 |
+
# asyncio.to_thread would forward it as `data` (POST body) and break
|
| 284 |
+
# the request.
|
| 285 |
+
response = await asyncio.to_thread(
|
| 286 |
+
lambda: urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT))
|
| 287 |
+
except urllib.error.HTTPError as exc:
|
| 288 |
+
detail = await asyncio.to_thread(exc.read)
|
| 289 |
+
text = detail.decode("utf-8", "replace")[:600]
|
| 290 |
+
raise _AgentError(f"Gemini API returned HTTP {exc.code}: {text}") from None
|
| 291 |
+
except urllib.error.URLError as exc:
|
| 292 |
+
raise _AgentError(f"Could not reach the Gemini API: {exc.reason}") from None
|
| 293 |
+
|
| 294 |
+
accumulated_parts: list[dict] = []
|
| 295 |
+
finish_reason = None
|
| 296 |
+
try:
|
| 297 |
+
while True:
|
| 298 |
+
raw = await asyncio.to_thread(response.readline)
|
| 299 |
+
if not raw:
|
| 300 |
+
break
|
| 301 |
+
line = raw.decode("utf-8", "replace").rstrip()
|
| 302 |
+
if not line.startswith("data: "):
|
| 303 |
+
continue
|
| 304 |
+
try:
|
| 305 |
+
chunk = json.loads(line[6:])
|
| 306 |
+
except ValueError:
|
| 307 |
+
continue
|
| 308 |
+
candidate = (chunk.get("candidates") or [{}])[0]
|
| 309 |
+
for part in (candidate.get("content") or {}).get("parts") or []:
|
| 310 |
+
accumulated_parts.append(part)
|
| 311 |
+
if part.get("thought"):
|
| 312 |
+
# Gemini 2.5 thinking summary -- preserved in history for
|
| 313 |
+
# the model's own context, never streamed to the user.
|
| 314 |
+
continue
|
| 315 |
+
if "text" in part:
|
| 316 |
+
yield {"type": "text_delta", "text": part["text"]}
|
| 317 |
+
elif "functionCall" in part:
|
| 318 |
+
yield {"type": "function_call", "call": part["functionCall"]}
|
| 319 |
+
if candidate.get("finishReason"):
|
| 320 |
+
finish_reason = candidate["finishReason"]
|
| 321 |
+
finally:
|
| 322 |
+
await asyncio.to_thread(response.close)
|
| 323 |
+
yield {"type": "finish", "reason": finish_reason, "parts": accumulated_parts}
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
async def _run_tool(session: ClientSession, name: str, args: dict) -> str:
|
| 327 |
+
"""Execute a Gemini function call against the MCP, returning text output."""
|
| 328 |
+
if name == "canlex_list_acts":
|
| 329 |
+
mcp_args: dict = {}
|
| 330 |
+
elif name in _PARAMS_WRAPPED:
|
| 331 |
+
# The MCP server's tools accept their schema as a single 'params' object.
|
| 332 |
+
mcp_args = {"params": args or {}}
|
| 333 |
+
else:
|
| 334 |
+
return f"(unknown tool '{name}')"
|
| 335 |
+
try:
|
| 336 |
+
result = await session.call_tool(name, mcp_args)
|
| 337 |
+
except Exception as exc: # MCP transport errors
|
| 338 |
+
return f"(tool '{name}' failed: {type(exc).__name__}: {exc})"
|
| 339 |
+
text = "\n".join(
|
| 340 |
+
block.text for block in result.content
|
| 341 |
+
if getattr(block, "type", None) == "text" and getattr(block, "text", None)
|
| 342 |
+
).strip()
|
| 343 |
+
if result.isError:
|
| 344 |
+
return f"(tool '{name}' reported an error: {text or 'no detail'})"
|
| 345 |
+
return text or "(no content returned)"
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
def _summarize_call(name: str, args: dict) -> str:
|
| 349 |
+
"""Render a tool call as a one-line user-facing status string."""
|
| 350 |
+
args = args or {}
|
| 351 |
+
if name == "canlex_search_legislation":
|
| 352 |
+
bits = [f"`{args.get('query', '')}`"]
|
| 353 |
+
if args.get("act"):
|
| 354 |
+
bits.append(f"in {args['act']}")
|
| 355 |
+
if args.get("doc_type"):
|
| 356 |
+
bits.append(f"({args['doc_type']} only)")
|
| 357 |
+
return "Searching " + " ".join(bits)
|
| 358 |
+
if name == "canlex_get_section":
|
| 359 |
+
return f"Fetching {args.get('act', '?')} s. {args.get('section', '?')}"
|
| 360 |
+
if name == "canlex_case":
|
| 361 |
+
return f"Looking up case {args.get('case_url', '?')}"
|
| 362 |
+
if name == "canlex_list_acts":
|
| 363 |
+
return "Listing the CanLex corpus"
|
| 364 |
+
return f"Calling {name}"
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
def _format_sources(tool_log: list[tuple[str, dict, str]]) -> str:
|
| 368 |
+
"""Render every tool call's output as one Markdown document for display."""
|
| 369 |
+
if not tool_log:
|
| 370 |
+
return ""
|
| 371 |
+
blocks = []
|
| 372 |
+
for i, (name, args, output) in enumerate(tool_log, 1):
|
| 373 |
+
blocks.append(
|
| 374 |
+
f"### Call {i}: `{name}`\n\n"
|
| 375 |
+
f"_Arguments:_ `{json.dumps(args, ensure_ascii=False)}`\n\n"
|
| 376 |
+
f"{output}"
|
| 377 |
+
)
|
| 378 |
+
return "\n\n---\n\n".join(blocks)
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
async def _agentic_answer(question: str):
|
| 382 |
+
"""Run the Gemini-driven agentic loop against a single MCP session.
|
| 383 |
+
|
| 384 |
+
Yields tuples of (status, answer_md, sources_md). The final yield carries
|
| 385 |
+
the composed answer; earlier yields are progress updates the UI can show.
|
| 386 |
+
"""
|
| 387 |
+
api_key = os.environ.get("GEMINI_API_KEY", "").strip()
|
| 388 |
+
if not api_key:
|
| 389 |
+
raise _AgentError(
|
| 390 |
+
"GEMINI_API_KEY is not set. Add it as a Space secret -- create a "
|
| 391 |
+
"free key at Google AI Studio (https://aistudio.google.com/apikey).")
|
| 392 |
+
|
| 393 |
+
yield "_Connecting to the CanLex retrieval service..._", "", ""
|
| 394 |
+
|
| 395 |
+
async with streamablehttp_client(
|
| 396 |
+
MCP_URL,
|
| 397 |
+
timeout=timedelta(seconds=REQUEST_TIMEOUT),
|
| 398 |
+
sse_read_timeout=timedelta(seconds=REQUEST_TIMEOUT),
|
| 399 |
+
) as (read, write, _):
|
| 400 |
+
async with ClientSession(read, write) as session:
|
| 401 |
+
await session.initialize()
|
| 402 |
+
|
| 403 |
+
contents: list[dict] = [
|
| 404 |
+
{"role": "user", "parts": [{"text": question}]}
|
| 405 |
+
]
|
| 406 |
+
tool_log: list[tuple[str, dict, str]] = []
|
| 407 |
+
trace: list[str] = []
|
| 408 |
+
|
| 409 |
+
answer_buf = ""
|
| 410 |
+
|
| 411 |
+
def status_md(thinking: bool = True) -> str:
|
| 412 |
+
lines = [f"- {line}" for line in trace]
|
| 413 |
+
if thinking:
|
| 414 |
+
lines.append("- _Thinking..._")
|
| 415 |
+
return "\n".join(lines) if lines else ""
|
| 416 |
+
|
| 417 |
+
for step in range(MAX_TOOL_ITERATIONS):
|
| 418 |
+
yield status_md(), answer_buf, _format_sources(tool_log)
|
| 419 |
+
|
| 420 |
+
# Stream Gemini's next turn. Stream text deltas to the answer
|
| 421 |
+
# panel optimistically; revert to the pre-turn answer if it
|
| 422 |
+
# turns out to be a tool-calling turn (the streamed text was
|
| 423 |
+
# then commentary, kept in the trace instead).
|
| 424 |
+
turn_text = ""
|
| 425 |
+
turn_calls: list[dict] = []
|
| 426 |
+
turn_parts: list[dict] = []
|
| 427 |
+
optimistic = True
|
| 428 |
+
|
| 429 |
+
async for chunk in _gemini_stream(api_key, contents):
|
| 430 |
+
if chunk["type"] == "text_delta" and optimistic:
|
| 431 |
+
turn_text += chunk["text"]
|
| 432 |
+
yield (status_md(),
|
| 433 |
+
answer_buf + turn_text,
|
| 434 |
+
_format_sources(tool_log))
|
| 435 |
+
elif chunk["type"] == "function_call":
|
| 436 |
+
turn_calls.append(chunk["call"])
|
| 437 |
+
if optimistic and turn_text:
|
| 438 |
+
# Roll the answer panel back; the commentary moves
|
| 439 |
+
# into the trace once the tool labels are drawn.
|
| 440 |
+
optimistic = False
|
| 441 |
+
yield (status_md(),
|
| 442 |
+
answer_buf,
|
| 443 |
+
_format_sources(tool_log))
|
| 444 |
+
elif chunk["type"] == "finish":
|
| 445 |
+
turn_parts = chunk["parts"] or []
|
| 446 |
+
# Capture any text-only finish reason so the caller can
|
| 447 |
+
# surface a useful error for an empty answer.
|
| 448 |
+
finish_reason = chunk.get("reason")
|
| 449 |
+
|
| 450 |
+
contents.append({"role": "model", "parts": turn_parts})
|
| 451 |
+
|
| 452 |
+
if not turn_calls:
|
| 453 |
+
# Final turn -- the text was already streamed; finalize.
|
| 454 |
+
if not turn_text:
|
| 455 |
+
raise _AgentError(
|
| 456 |
+
f"Gemini produced an empty answer (finishReason: "
|
| 457 |
+
f"{finish_reason!s}). If this is MAX_TOKENS, "
|
| 458 |
+
"raise MAX_OUTPUT_TOKENS in app.py.")
|
| 459 |
+
answer_buf += turn_text
|
| 460 |
+
yield status_md(thinking=False), answer_buf, \
|
| 461 |
+
_format_sources(tool_log)
|
| 462 |
+
return
|
| 463 |
+
|
| 464 |
+
# Tool turn. If the model emitted a commentary fragment before
|
| 465 |
+
# its function calls, surface it once in the trace -- it often
|
| 466 |
+
# explains WHY the next tools are being called.
|
| 467 |
+
if turn_text:
|
| 468 |
+
snippet = turn_text.strip().replace("\n", " ")
|
| 469 |
+
if len(snippet) > 140:
|
| 470 |
+
snippet = snippet[:137].rstrip() + "..."
|
| 471 |
+
trace.append(f"_{snippet}_")
|
| 472 |
+
|
| 473 |
+
# Execute every function call in this turn, then send the
|
| 474 |
+
# responses back as a single 'user' message.
|
| 475 |
+
function_responses = []
|
| 476 |
+
for call in turn_calls:
|
| 477 |
+
name = call.get("name", "")
|
| 478 |
+
args = call.get("args") or {}
|
| 479 |
+
label = _summarize_call(name, args)
|
| 480 |
+
trace.append(label)
|
| 481 |
+
yield status_md(), answer_buf, _format_sources(tool_log)
|
| 482 |
+
|
| 483 |
+
output = await _run_tool(session, name, args)
|
| 484 |
+
tool_log.append((name, args, output))
|
| 485 |
+
function_responses.append({
|
| 486 |
+
"functionResponse": {
|
| 487 |
+
"name": name,
|
| 488 |
+
"response": {"output": output},
|
| 489 |
+
}
|
| 490 |
+
})
|
| 491 |
+
contents.append({"role": "user", "parts": function_responses})
|
| 492 |
+
|
| 493 |
+
# Loop budget exhausted -- ask Gemini for a final answer without
|
| 494 |
+
# further tool use rather than leave the user with nothing. We
|
| 495 |
+
# stream this terminal turn too, so the user sees it compose.
|
| 496 |
+
contents.append({"role": "user", "parts": [{"text":
|
| 497 |
+
"You have reached the maximum number of tool calls. Compose "
|
| 498 |
+
"the best answer you can from the material gathered so far, "
|
| 499 |
+
"without calling further tools. If the material is "
|
| 500 |
+
"insufficient, say so plainly."}]})
|
| 501 |
+
turn_text = ""
|
| 502 |
+
async for chunk in _gemini_stream(api_key, contents):
|
| 503 |
+
if chunk["type"] == "text_delta":
|
| 504 |
+
turn_text += chunk["text"]
|
| 505 |
+
yield (status_md(thinking=False),
|
| 506 |
+
answer_buf + turn_text,
|
| 507 |
+
_format_sources(tool_log))
|
| 508 |
+
answer_buf += turn_text or \
|
| 509 |
+
"_(no answer produced after the tool-call budget was exhausted)_"
|
| 510 |
+
yield status_md(thinking=False), answer_buf, _format_sources(tool_log)
|
| 511 |
+
|
| 512 |
+
|
| 513 |
+
# --- Gradio handler -----------------------------------------------------------
|
| 514 |
+
|
| 515 |
+
ANSWER_PLACEHOLDER = "*Your answer will appear here.*"
|
| 516 |
+
|
| 517 |
+
|
| 518 |
+
_SENTINEL = object()
|
| 519 |
+
|
| 520 |
+
|
| 521 |
+
def answer(question: str):
|
| 522 |
+
"""Generator wrapping the async agent for Gradio's progressive UI.
|
| 523 |
+
|
| 524 |
+
The async work runs on a dedicated worker thread with its own event loop
|
| 525 |
+
and stays inside a single asyncio task for the whole question. Items are
|
| 526 |
+
handed back to this sync generator through a thread-safe queue. The
|
| 527 |
+
previous loop.run_until_complete-per-anext pattern created a fresh task
|
| 528 |
+
on every yield, which tripped anyio's cancel-scope check inside the MCP
|
| 529 |
+
streamable-HTTP client ('Attempted to exit cancel scope in a different
|
| 530 |
+
task than it was entered in')."""
|
| 531 |
+
question = (question or "").strip()
|
| 532 |
+
if not question:
|
| 533 |
+
yield "Please enter a legal question above.", ANSWER_PLACEHOLDER, ""
|
| 534 |
+
return
|
| 535 |
+
|
| 536 |
+
events: queue.Queue = queue.Queue()
|
| 537 |
+
|
| 538 |
+
def worker():
|
| 539 |
+
async def run():
|
| 540 |
+
try:
|
| 541 |
+
async for tup in _agentic_answer(question):
|
| 542 |
+
events.put(("yield", tup))
|
| 543 |
+
except _AgentError as exc:
|
| 544 |
+
events.put(("agent_error", exc))
|
| 545 |
+
except Exception as exc: # network blip, MCP transport
|
| 546 |
+
events.put(("error", exc))
|
| 547 |
+
finally:
|
| 548 |
+
events.put((_SENTINEL,))
|
| 549 |
+
try:
|
| 550 |
+
asyncio.run(run())
|
| 551 |
+
except Exception as exc: # loop setup failures
|
| 552 |
+
events.put(("error", exc))
|
| 553 |
+
events.put((_SENTINEL,))
|
| 554 |
+
|
| 555 |
+
threading.Thread(target=worker, daemon=True).start()
|
| 556 |
+
|
| 557 |
+
while True:
|
| 558 |
+
kind, *payload = events.get()
|
| 559 |
+
if kind is _SENTINEL:
|
| 560 |
+
return
|
| 561 |
+
if kind == "yield":
|
| 562 |
+
yield payload[0]
|
| 563 |
+
elif kind == "agent_error":
|
| 564 |
+
yield (f"**{payload[0]}**", ANSWER_PLACEHOLDER, "")
|
| 565 |
+
elif kind == "error":
|
| 566 |
+
exc = payload[0]
|
| 567 |
+
# Unwrap ExceptionGroup (from anyio TaskGroups in the MCP client)
|
| 568 |
+
# so the user sees the actual root cause, not the wrapper.
|
| 569 |
+
lines = []
|
| 570 |
+
def _walk(e, depth=0):
|
| 571 |
+
indent = " " * depth
|
| 572 |
+
lines.append(f"{indent}- `{type(e).__name__}: {e}`")
|
| 573 |
+
inner = getattr(e, "exceptions", None)
|
| 574 |
+
if inner:
|
| 575 |
+
for sub in inner:
|
| 576 |
+
_walk(sub, depth + 1)
|
| 577 |
+
_walk(exc)
|
| 578 |
+
yield ("**Could not complete the request.**\n\n"
|
| 579 |
+
+ "\n".join(lines) +
|
| 580 |
+
"\n\nThe MCP service may be waking from sleep -- "
|
| 581 |
+
"try again in a moment.", ANSWER_PLACEHOLDER, "")
|
| 582 |
+
|
| 583 |
+
|
| 584 |
+
# --- UI -----------------------------------------------------------------------
|
| 585 |
+
|
| 586 |
+
INTRO = """\
|
| 587 |
+
# CanLex -- Canadian Legal Research
|
| 588 |
+
|
| 589 |
+
Ask a question about Canadian **border, customs, immigration, criminal, drug,
|
| 590 |
+
labour or related federal law**. CanLex finds the governing statutory
|
| 591 |
+
provisions, D-Memoranda, collective-agreement terms and leading court
|
| 592 |
+
decisions, then composes an answer that cites them.
|
| 593 |
+
|
| 594 |
+
The CanLex corpus contains 31 federal Acts and regulations -- including the
|
| 595 |
+
Immigration and Refugee Protection Act, the Customs Act and the Criminal Code
|
| 596 |
+
-- alongside the CBSA D-Memoranda, the FB (Border Services) collective
|
| 597 |
+
agreement, the National Joint Council directives, leading decisions of the
|
| 598 |
+
Supreme Court, the Federal Courts and the federal labour and immigration
|
| 599 |
+
tribunals, and the IRPA/IRPR instruments of delegation and designation.
|
| 600 |
+
|
| 601 |
+
The assistant iterates over the corpus -- searching, fetching sections and
|
| 602 |
+
looking up case-law citations -- before composing a grounded answer. A complex
|
| 603 |
+
question may take 30 seconds or more.
|
| 604 |
+
|
| 605 |
+
Legal information, not legal advice -- always verify against the primary sources.
|
| 606 |
+
"""
|
| 607 |
+
|
| 608 |
+
EXAMPLE_QUESTIONS = [
|
| 609 |
+
"What are the detention review timelines for a permanent resident?",
|
| 610 |
+
"When is a foreign national inadmissible for serious criminality?",
|
| 611 |
+
"What overtime provisions apply to FB-group Border Services officers?",
|
| 612 |
+
"Can the CBSA seize goods for an undervalued customs declaration?",
|
| 613 |
+
]
|
| 614 |
+
|
| 615 |
+
with gr.Blocks(title="CanLex", analytics_enabled=False) as demo:
|
| 616 |
+
gr.Markdown(INTRO)
|
| 617 |
+
|
| 618 |
+
question = gr.Textbox(
|
| 619 |
+
label="Your legal question",
|
| 620 |
+
placeholder="e.g. What are the detention review timelines for a "
|
| 621 |
+
"permanent resident?",
|
| 622 |
+
lines=3,
|
| 623 |
+
)
|
| 624 |
+
with gr.Row():
|
| 625 |
+
submit = gr.Button("Ask CanLex", variant="primary")
|
| 626 |
+
clear = gr.Button("Clear")
|
| 627 |
+
|
| 628 |
+
gr.Examples(examples=EXAMPLE_QUESTIONS, inputs=question, label="Examples")
|
| 629 |
+
|
| 630 |
+
# Three panels: a progress trace (also used to surface errors), the final
|
| 631 |
+
# composed answer, and the raw tool outputs the agent gathered.
|
| 632 |
+
progress_md = gr.Markdown(value="")
|
| 633 |
+
answer_md = gr.Markdown(value=ANSWER_PLACEHOLDER)
|
| 634 |
+
with gr.Accordion("Retrieved source passages (every tool call)", open=False):
|
| 635 |
+
sources_md = gr.Markdown()
|
| 636 |
+
|
| 637 |
+
submit.click(answer, [question], [progress_md, answer_md, sources_md])
|
| 638 |
+
question.submit(answer, [question], [progress_md, answer_md, sources_md])
|
| 639 |
+
clear.click(lambda: ("", "", ANSWER_PLACEHOLDER, ""), None,
|
| 640 |
+
[question, progress_md, answer_md, sources_md])
|
| 641 |
+
|
| 642 |
+
|
| 643 |
+
if __name__ == "__main__":
|
| 644 |
+
print(f"CanLex Web starting -- MCP: {MCP_URL}; model: {GEMINI_MODEL}; "
|
| 645 |
+
f"{len(AUTH)} login(s) configured.", file=sys.stderr)
|
| 646 |
+
demo.queue()
|
| 647 |
+
demo.launch(
|
| 648 |
+
server_name="0.0.0.0",
|
| 649 |
+
server_port=int(os.environ.get("PORT", "7860")),
|
| 650 |
+
auth=AUTH,
|
| 651 |
+
auth_message="Sign in to use CanLex.",
|
| 652 |
+
ssr_mode=False, # no Node in the slim container; render client-side
|
| 653 |
+
)
|
webapp/requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CanLex Web (Path B) -- thin-client dependencies. Versions are pinned so the
|
| 2 |
+
# Hugging Face Docker build is reproducible (an unpinned gradio floated to a new
|
| 3 |
+
# major and broke a launch() argument).
|
| 4 |
+
gradio==6.14.0 # web UI with built-in username/password auth
|
| 5 |
+
mcp==1.27.1 # streamable-HTTP client for the CanLex MCP server
|
| 6 |
+
# Google Gemini is called through its REST API using only the Python standard
|
| 7 |
+
# library (urllib); no LLM SDK dependency is needed.
|