Beemer Claude Opus 4.7 commited on
Commit
34a90f9
·
1 Parent(s): 55484bc

Track the Path B web app (webapp/) in the repo

Browse files

webapp/ 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 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.