From 0a40ce1e27219b560641dd1c9d361e37d778b078 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Sat, 13 Jun 2026 17:34:54 +0800 Subject: [PATCH 1/3] Add root nbtools.py with shared show_graph helper Hoist the PNG-with-ASCII-fallback graph renderer that was duplicated across the pattern notebooks into a root-level module, the counterpart to model_config.py. show_graph(graph, *, alt=...) works on any object exposing .get_graph() (compiled StateGraph or LCEL Runnable), tries draw_mermaid_png(), and falls back to draw_ascii() when the remote Mermaid renderer is unreachable. Add grandalf>=0.8 (langgraph extra) for the offline ASCII fallback. --- nbtools.py | 33 +++++++++++++++++++++++++++++++++ pyproject.toml | 1 + uv.lock | 23 +++++++++++++++++++++++ 3 files changed, 57 insertions(+) create mode 100644 nbtools.py diff --git a/nbtools.py b/nbtools.py new file mode 100644 index 0000000..0ccc4cb --- /dev/null +++ b/nbtools.py @@ -0,0 +1,33 @@ +"""Shared notebook utilities for the reference-implementation tutorials. + +Root-level helpers that every pattern's notebooks reuse — the counterpart to +model_config.py (model loading). Imported the same way: the notebook's import +cell adds the repo root to sys.path, then `from nbtools import show_graph`. + +Kept deliberately small: only genuinely cross-cutting, framework-agnostic +helpers belong here. Pattern-specific logic (gates, hooks, trace printers, +nodes) stays in each pattern's own `shared.py` or notebook. +""" +from __future__ import annotations + +from typing import Any + + +def show_graph(graph: Any, *, alt: str = "graph") -> None: + """Render a graph as a PNG via the Mermaid renderer, falling back to + offline ASCII art if the renderer is unreachable. + + Works for any object exposing `.get_graph()` — a compiled LangGraph + `StateGraph` or an LCEL `Runnable`. `draw_mermaid_png()` calls a remote + service; the ASCII fallback (`draw_ascii()`, needs `grandalf`) keeps the + cell rendering when that service is offline or blocked. + """ + # Imported lazily so nbtools stays importable outside a notebook/IPython env. + from IPython.display import Image, display + + g = graph.get_graph() + try: + display(Image(data=g.draw_mermaid_png(), alt=alt)) + except Exception as e: # noqa: BLE001 — renderer offline / network blocked + print(f"(PNG render unavailable: {type(e).__name__}) — ASCII fallback:\n") + print(g.draw_ascii()) diff --git a/pyproject.toml b/pyproject.toml index 8d9c05d..e5fc8b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ langgraph = [ "langchain-oceanbase>=0.5.1", "langchain-dev-utils>=1.4.6", "python-dotenv>=1.0", + "grandalf>=0.8", # offline ASCII graph rendering (draw_ascii fallback) ] dev = [ "pytest>=8.0", diff --git a/uv.lock b/uv.lock index 76a3559..f2c719f 100644 --- a/uv.lock +++ b/uv.lock @@ -20,6 +20,7 @@ dev = [ { name = "ruff" }, ] langgraph = [ + { name = "grandalf" }, { name = "langchain" }, { name = "langchain-anthropic" }, { name = "langchain-core" }, @@ -32,6 +33,7 @@ langgraph = [ [package.metadata] requires-dist = [ + { name = "grandalf", marker = "extra == 'langgraph'", specifier = ">=0.8" }, { name = "ipykernel", marker = "extra == 'dev'", specifier = ">=6.0" }, { name = "jupyterlab", marker = "extra == 'dev'", specifier = ">=4.0" }, { name = "langchain", marker = "extra == 'langgraph'", specifier = ">=1.0,<2" }, @@ -751,6 +753,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] +[[package]] +name = "grandalf" +version = "0.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/0e/4ac934b416857969f9135dec17ac80660634327e003a870835dd1f382659/grandalf-0.8.tar.gz", hash = "sha256:2813f7aab87f0d20f334a3162ccfbcbf085977134a17a5b516940a93a77ea974", size = 38128, upload-time = "2023-01-26T07:37:06.668Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/30/44c7eb0a952478dbb5f2f67df806686d6a7e4b19f6204e091c4f49dc7c69/grandalf-0.8-py3-none-any.whl", hash = "sha256:793ca254442f4a79252ea9ff1ab998e852c1e071b863593e5383afee906b4185", size = 41802, upload-time = "2023-01-10T15:16:19.753Z" }, +] + [[package]] name = "greenlet" version = "3.5.1" @@ -2395,6 +2409,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dd/a6/8acb9821c78bafc3ff9db5bbea05a8b67ff871b7590b7d25cc91c34fc2f0/pyobvector-0.2.28-py3-none-any.whl", hash = "sha256:36708fc0020307890b9001035904ff14719b81e20e58f2a97f7f3739dad53c72", size = 66637, upload-time = "2026-06-05T06:42:38.5Z" }, ] +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + [[package]] name = "pytest" version = "9.0.3" From 325e267dcdbfd197901b87745e0504e1a7611939 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Sat, 13 Jun 2026 17:36:06 +0800 Subject: [PATCH 2/3] Refactor Guardrail Sandwich to align both impls via shared.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename hooks.py -> shared.py (role, not contents) and move the hook runner and selector into it as run_single_hook + applicable_hooks, so both notebooks share the exact selection and execution logic instead of each carrying a private copy. Reconcile the two impls so each honors the full hook config dict: - applicable_hooks filters by phase AND applies_to, sorts by priority - the langchain middleware takes one hooks= list (was pre_hooks=/post_hooks=) - langchain decodes the ToolMessage to a dict before post-hooks, so output_schema_hook validates the receipt (not its wrapper) — matching the langgraph decode_tool_message path - unify the outcome-dict shape (phase, no elapsed_ms) and the tx_id literal Polish to convention: upward-search sys.path (no ../.. counting), shared show_graph with ASCII fallback, drop dead imports and print('X ready') noise, surface the middleware audit via last_trace. --- .../langchain/tutorial.html | 236 +++++----- .../langchain/tutorial.ipynb | 319 +++++++------ .../langchain/tutorial.md | 179 +++---- .../langgraph/tutorial.html | 323 +++---------- .../langgraph/tutorial.ipynb | 439 ++++++------------ .../langgraph/tutorial.md | 169 ++----- .../{tutorial_21_0.png => tutorial_20_0.png} | Bin .../{hooks.py => shared.py} | 53 ++- 8 files changed, 703 insertions(+), 1015 deletions(-) rename action/d-guardrail-sandwich/langgraph/tutorial_files/{tutorial_21_0.png => tutorial_20_0.png} (100%) rename action/d-guardrail-sandwich/{hooks.py => shared.py} (52%) diff --git a/action/d-guardrail-sandwich/langchain/tutorial.html b/action/d-guardrail-sandwich/langchain/tutorial.html index dbcb860..8911d37 100644 --- a/action/d-guardrail-sandwich/langchain/tutorial.html +++ b/action/d-guardrail-sandwich/langchain/tutorial.html @@ -7870,7 +7870,7 @@

Setup

- - - @@ -8146,7 +8126,9 @@

Test: decorator guard with HumanMessage: Transfer 5000000 to CORP-1234 for Q2 bonus AIMessage: ToolMessage: BLOCKED: amount 5000000 exceeds threshold 1,000,000 - AIMessage: The transfer of 5,000,000 was blocked because it exceeds the maximum allowed threshold of 1,000,000 per transaction. Wou + AIMessage: The transfer of 5,000,000 was blocked because it exceeds the maximum allowed threshold of 1,000,000 per transaction. + +W @@ -8159,22 +8141,22 @@

Test: decorator guard with - @@ -8331,21 +8296,19 @@

Test: class middleware with