From ba574d530e20b0165a6a7847dc03f1f2e048b276 Mon Sep 17 00:00:00 2001 From: remy Date: Fri, 27 Jun 2025 12:09:19 +1000 Subject: [PATCH] add tracing cookbook --- cookbook/README.md | 1 + cookbook/pocketflow-tracing/.env.example | 20 ++ cookbook/pocketflow-tracing/README.md | 290 +++++++++++++++++ .../examples/async_example.py | 140 +++++++++ .../examples/basic_example.py | 110 +++++++ cookbook/pocketflow-tracing/requirements.txt | 6 + .../chrome_2025-06-27_12-05-28.png | Bin 0 -> 33199 bytes .../chrome_2025-06-27_12-07-56.png | Bin 0 -> 37243 bytes cookbook/pocketflow-tracing/setup.py | 81 +++++ cookbook/pocketflow-tracing/test_tracing.py | 197 ++++++++++++ .../pocketflow-tracing/tracing/__init__.py | 13 + cookbook/pocketflow-tracing/tracing/config.py | 111 +++++++ cookbook/pocketflow-tracing/tracing/core.py | 287 +++++++++++++++++ .../pocketflow-tracing/tracing/decorator.py | 293 ++++++++++++++++++ cookbook/pocketflow-tracing/utils/__init__.py | 7 + cookbook/pocketflow-tracing/utils/setup.py | 163 ++++++++++ docs/utility_function/viz.md | 4 +- 17 files changed, 1722 insertions(+), 1 deletion(-) create mode 100644 cookbook/pocketflow-tracing/.env.example create mode 100644 cookbook/pocketflow-tracing/README.md create mode 100644 cookbook/pocketflow-tracing/examples/async_example.py create mode 100644 cookbook/pocketflow-tracing/examples/basic_example.py create mode 100644 cookbook/pocketflow-tracing/requirements.txt create mode 100644 cookbook/pocketflow-tracing/screenshots/chrome_2025-06-27_12-05-28.png create mode 100644 cookbook/pocketflow-tracing/screenshots/chrome_2025-06-27_12-07-56.png create mode 100644 cookbook/pocketflow-tracing/setup.py create mode 100644 cookbook/pocketflow-tracing/test_tracing.py create mode 100644 cookbook/pocketflow-tracing/tracing/__init__.py create mode 100644 cookbook/pocketflow-tracing/tracing/config.py create mode 100644 cookbook/pocketflow-tracing/tracing/core.py create mode 100644 cookbook/pocketflow-tracing/tracing/decorator.py create mode 100644 cookbook/pocketflow-tracing/utils/__init__.py create mode 100644 cookbook/pocketflow-tracing/utils/setup.py diff --git a/cookbook/README.md b/cookbook/README.md index d77fb5d..1a75946 100644 --- a/cookbook/README.md +++ b/cookbook/README.md @@ -20,6 +20,7 @@ | [Thinking](https://github.com/The-Pocket/PocketFlow/tree/main/cookbook/pocketflow-thinking) | ★☆☆
*Beginner* | Solve complex reasoning problems through Chain-of-Thought | | [Memory](https://github.com/The-Pocket/PocketFlow/tree/main/cookbook/pocketflow-chat-memory) | ★☆☆
*Beginner* | A chat bot with short-term and long-term memory | | [MCP](https://github.com/The-Pocket/PocketFlow/tree/main/cookbook/pocketflow-mcp) | ★☆☆
*Beginner* | Agent using Model Context Protocol for numerical operations | +| [Tracing](https://github.com/The-Pocket/PocketFlow/tree/main/cookbook/pocketflow-tracing) | ★☆☆
*Beginner* | Trace and visualize the execution of your flow | diff --git a/cookbook/pocketflow-tracing/.env.example b/cookbook/pocketflow-tracing/.env.example new file mode 100644 index 0000000..21f8686 --- /dev/null +++ b/cookbook/pocketflow-tracing/.env.example @@ -0,0 +1,20 @@ +# PocketFlow Tracing Configuration Template +# Copy this file to .env and replace the placeholder values with your actual Langfuse credentials + +# Required Langfuse configuration +LANGFUSE_SECRET_KEY=your-langfuse-secret-key +LANGFUSE_PUBLIC_KEY=your-langfuse-public-key +LANGFUSE_HOST=your-langfuse-host-url + +# Optional tracing configuration +POCKETFLOW_TRACING_DEBUG=true +POCKETFLOW_TRACE_INPUTS=true +POCKETFLOW_TRACE_OUTPUTS=true +POCKETFLOW_TRACE_PREP=true +POCKETFLOW_TRACE_EXEC=true +POCKETFLOW_TRACE_POST=true +POCKETFLOW_TRACE_ERRORS=true + +# Optional session/user tracking +POCKETFLOW_SESSION_ID=your-session-id +POCKETFLOW_USER_ID=your-user-id diff --git a/cookbook/pocketflow-tracing/README.md b/cookbook/pocketflow-tracing/README.md new file mode 100644 index 0000000..e5c4a79 --- /dev/null +++ b/cookbook/pocketflow-tracing/README.md @@ -0,0 +1,290 @@ +# PocketFlow Tracing with Langfuse + +This cookbook provides comprehensive observability for PocketFlow workflows using [Langfuse](https://langfuse.com/) as the tracing backend. With minimal code changes (just adding a decorator), you can automatically trace all node executions, inputs, outputs, and errors in your PocketFlow workflows. + +## 🎯 Features + +- **Automatic Tracing**: Trace entire flows with a single decorator +- **Node-Level Observability**: Automatically trace `prep`, `exec`, and `post` phases of each node +- **Input/Output Tracking**: Capture all data flowing through your workflow +- **Error Tracking**: Automatically capture and trace exceptions +- **Async Support**: Full support for AsyncFlow and AsyncNode +- **Minimal Code Changes**: Just add `@trace_flow()` to your flow classes +- **Langfuse Integration**: Leverage Langfuse's powerful observability platform + +## 🚀 Quick Start + +### 1. Install Dependencies + +```bash +pip install -r requirements.txt +``` + +### 2. Environment Setup + +Copy the example environment file and configure your Langfuse credentials: + +```bash +cp .env.example .env +``` + +Then edit the `.env` file with your actual Langfuse configuration: + +```env +LANGFUSE_SECRET_KEY=your-langfuse-secret-key +LANGFUSE_PUBLIC_KEY=your-langfuse-public-key +LANGFUSE_HOST=your-langfuse-host-url +POCKETFLOW_TRACING_DEBUG=true +``` + +**Note**: Replace the placeholder values with your actual Langfuse credentials and host URL. + +### 3. Basic Usage + +```python +from pocketflow import Node, Flow +from tracing import trace_flow + +class MyNode(Node): + def prep(self, shared): + return shared["input"] + + def exec(self, data): + return f"Processed: {data}" + + def post(self, shared, prep_res, exec_res): + shared["output"] = exec_res + return "default" + +@trace_flow() # 🎉 That's it! Your flow is now traced +class MyFlow(Flow): + def __init__(self): + super().__init__(start=MyNode()) + +# Run your flow - tracing happens automatically +flow = MyFlow() +shared = {"input": "Hello World"} +flow.run(shared) +``` + +## 📊 What Gets Traced + +When you apply the `@trace_flow()` decorator, the system automatically traces: + +### Flow Level +- **Flow Start/End**: Overall execution time and status +- **Input Data**: Initial shared state when flow starts +- **Output Data**: Final shared state when flow completes +- **Errors**: Any exceptions that occur during flow execution + +### Node Level +For each node in your flow, the system traces: + +- **prep() Phase**: + - Input: `shared` data + - Output: `prep_res` returned by prep method + - Execution time and any errors + +- **exec() Phase**: + - Input: `prep_res` from prep phase + - Output: `exec_res` returned by exec method + - Execution time and any errors + - Retry attempts (if configured) + +- **post() Phase**: + - Input: `shared`, `prep_res`, `exec_res` + - Output: Action string returned + - Execution time and any errors + +## 🔧 Configuration Options + +### Basic Configuration + +```python +from tracing import trace_flow, TracingConfig + +# Use environment variables (default) +@trace_flow() +class MyFlow(Flow): + pass + +# Custom flow name +@trace_flow(flow_name="CustomFlowName") +class MyFlow(Flow): + pass + +# Custom session and user IDs +@trace_flow(session_id="session-123", user_id="user-456") +class MyFlow(Flow): + pass +``` + +### Advanced Configuration + +```python +from tracing import TracingConfig + +# Create custom configuration +config = TracingConfig( + langfuse_secret_key="your-secret-key", + langfuse_public_key="your-public-key", + langfuse_host="https://your-langfuse-instance.com", + debug=True, + trace_inputs=True, + trace_outputs=True, + trace_errors=True +) + +@trace_flow(config=config) +class MyFlow(Flow): + pass +``` + +## 📁 Examples + +### Basic Synchronous Flow +See `examples/basic_example.py` for a complete example of tracing a simple synchronous flow. + +```bash +cd examples +python basic_example.py +``` + +### Asynchronous Flow +See `examples/async_example.py` for tracing AsyncFlow and AsyncNode. + +```bash +cd examples +python async_example.py +``` + +## 🔍 Viewing Traces + +After running your traced flows, visit your Langfuse dashboard to view the traces: + +**Dashboard URL**: Use the URL you configured in `LANGFUSE_HOST` environment variable + +In the dashboard you'll see: +- **Traces**: One trace per flow execution +- **Spans**: Individual node phases (prep, exec, post) +- **Input/Output Data**: All data flowing through your workflow +- **Performance Metrics**: Execution times for each phase +- **Error Details**: Stack traces and error messages + +The tracings in examples. +![alt text](screenshots/chrome_2025-06-27_12-05-28.png) + +Detailed tracing for a node. +![langfuse](screenshots/chrome_2025-06-27_12-07-56.png) + +## 🛠️ Advanced Usage + +### Custom Tracer Configuration + +```python +from tracing import TracingConfig, LangfuseTracer + +# Create custom configuration +config = TracingConfig.from_env() +config.debug = True + +# Use tracer directly (for advanced use cases) +tracer = LangfuseTracer(config) +``` + +### Environment Variables + +You can customize tracing behavior with these environment variables: + +```env +# Required Langfuse configuration +LANGFUSE_SECRET_KEY=your-secret-key +LANGFUSE_PUBLIC_KEY=your-public-key +LANGFUSE_HOST=your-langfuse-host + +# Optional tracing configuration +POCKETFLOW_TRACING_DEBUG=true +POCKETFLOW_TRACE_INPUTS=true +POCKETFLOW_TRACE_OUTPUTS=true +POCKETFLOW_TRACE_PREP=true +POCKETFLOW_TRACE_EXEC=true +POCKETFLOW_TRACE_POST=true +POCKETFLOW_TRACE_ERRORS=true + +# Optional session/user tracking +POCKETFLOW_SESSION_ID=your-session-id +POCKETFLOW_USER_ID=your-user-id +``` + +## 🐛 Troubleshooting + +### Common Issues + +1. **"langfuse package not installed"** + ```bash + pip install langfuse + ``` + +2. **"Langfuse client initialization failed"** + - Check your `.env` file configuration + - Verify Langfuse server is running at the specified host + - Check network connectivity + +3. **"No traces appearing in dashboard"** + - Ensure `POCKETFLOW_TRACING_DEBUG=true` to see debug output + - Check that your flow is actually being executed + - Verify Langfuse credentials are correct + +### Debug Mode + +Enable debug mode to see detailed tracing information: + +```env +POCKETFLOW_TRACING_DEBUG=true +``` + +This will print detailed information about: +- Langfuse client initialization +- Trace and span creation +- Data serialization +- Error messages + +## 📚 API Reference + +### `@trace_flow()` + +Decorator to add Langfuse tracing to PocketFlow flows. + +**Parameters:** +- `config` (TracingConfig, optional): Custom configuration. If None, loads from environment. +- `flow_name` (str, optional): Custom name for the flow. If None, uses class name. +- `session_id` (str, optional): Session ID for grouping related traces. +- `user_id` (str, optional): User ID for the trace. + +### `TracingConfig` + +Configuration class for tracing settings. + +**Methods:** +- `TracingConfig.from_env()`: Create config from environment variables +- `validate()`: Check if configuration is valid +- `to_langfuse_kwargs()`: Convert to Langfuse client kwargs + +### `LangfuseTracer` + +Core tracer class for Langfuse integration. + +**Methods:** +- `start_trace()`: Start a new trace +- `end_trace()`: End the current trace +- `start_node_span()`: Start a span for node execution +- `end_node_span()`: End a node execution span +- `flush()`: Flush pending traces to Langfuse + +## 🤝 Contributing + +This cookbook is designed to be a starting point for PocketFlow observability. Feel free to extend and customize it for your specific needs! + +## 📄 License + +This cookbook follows the same license as PocketFlow. diff --git a/cookbook/pocketflow-tracing/examples/async_example.py b/cookbook/pocketflow-tracing/examples/async_example.py new file mode 100644 index 0000000..5d26962 --- /dev/null +++ b/cookbook/pocketflow-tracing/examples/async_example.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +""" +Async example demonstrating PocketFlow tracing with Langfuse. + +This example shows how to use the @trace_flow decorator with AsyncFlow +and AsyncNode to trace asynchronous workflows. +""" + +import asyncio +import sys +import os +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() + +# Add parent directory to path to import pocketflow and tracing +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..")) +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +from pocketflow import AsyncNode, AsyncFlow +from tracing import trace_flow, TracingConfig + + +class AsyncDataFetchNode(AsyncNode): + """An async node that simulates fetching data.""" + + async def prep_async(self, shared): + """Extract the query from shared data.""" + query = shared.get("query", "default") + return query + + async def exec_async(self, query): + """Simulate async data fetching.""" + print(f"🔍 Fetching data for query: {query}") + + # Simulate async operation + await asyncio.sleep(1) + + # Return mock data + data = { + "query": query, + "results": [f"Result {i} for {query}" for i in range(3)], + "timestamp": "2024-01-01T00:00:00Z", + } + return data + + async def post_async(self, shared, prep_res, exec_res): + """Store the fetched data.""" + shared["fetched_data"] = exec_res + return "process" + + +class AsyncDataProcessNode(AsyncNode): + """An async node that processes the fetched data.""" + + async def prep_async(self, shared): + """Get the fetched data.""" + return shared.get("fetched_data", {}) + + async def exec_async(self, data): + """Process the data asynchronously.""" + print("⚙️ Processing fetched data...") + + # Simulate async processing + await asyncio.sleep(0.5) + + # Process the results + processed_results = [] + for result in data.get("results", []): + processed_results.append(f"PROCESSED: {result}") + + return { + "original_query": data.get("query"), + "processed_results": processed_results, + "result_count": len(processed_results), + } + + async def post_async(self, shared, prep_res, exec_res): + """Store the processed data.""" + shared["processed_data"] = exec_res + return "default" + + +@trace_flow(flow_name="AsyncDataProcessingFlow") +class AsyncDataProcessingFlow(AsyncFlow): + """An async flow that fetches and processes data.""" + + def __init__(self): + # Create async nodes + fetch_node = AsyncDataFetchNode() + process_node = AsyncDataProcessNode() + + # Connect nodes + fetch_node - "process" >> process_node + + # Initialize async flow + super().__init__(start=fetch_node) + + +async def main(): + """Run the async tracing example.""" + print("🚀 Starting PocketFlow Async Tracing Example") + print("=" * 50) + + # Create the async flow + flow = AsyncDataProcessingFlow() + + # Prepare shared data + shared = {"query": "machine learning tutorials"} + + print(f"📥 Input: {shared}") + + # Run the async flow (this will be automatically traced) + try: + result = await flow.run_async(shared) + print(f"📤 Output: {shared}") + print(f"🎯 Result: {result}") + print("✅ Async flow completed successfully!") + + # Print the processed data + if "processed_data" in shared: + processed = shared["processed_data"] + print( + f"🎉 Processed {processed['result_count']} results for query: {processed['original_query']}" + ) + for result in processed["processed_results"]: + print(f" - {result}") + + except Exception as e: + print(f"❌ Async flow failed with error: {e}") + raise + + print("\n📊 Check your Langfuse dashboard to see the async trace!") + langfuse_host = os.getenv("LANGFUSE_HOST", "your-langfuse-host") + print(f" Dashboard URL: {langfuse_host}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/cookbook/pocketflow-tracing/examples/basic_example.py b/cookbook/pocketflow-tracing/examples/basic_example.py new file mode 100644 index 0000000..587f76d --- /dev/null +++ b/cookbook/pocketflow-tracing/examples/basic_example.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +""" +Basic example demonstrating PocketFlow tracing with Langfuse. + +This example shows how to use the @trace_flow decorator to automatically +trace a simple PocketFlow workflow. +""" + +import sys +import os +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() + +# Add parent directory to path to import pocketflow and tracing +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..")) +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +from pocketflow import Node, Flow +from tracing import trace_flow, TracingConfig + + +class GreetingNode(Node): + """A simple node that creates a greeting message.""" + + def prep(self, shared): + """Extract the name from shared data.""" + name = shared.get("name", "World") + return name + + def exec(self, name): + """Create a greeting message.""" + greeting = f"Hello, {name}!" + return greeting + + def post(self, shared, prep_res, exec_res): + """Store the greeting in shared data.""" + shared["greeting"] = exec_res + return "default" + + +class UppercaseNode(Node): + """A node that converts the greeting to uppercase.""" + + def prep(self, shared): + """Get the greeting from shared data.""" + return shared.get("greeting", "") + + def exec(self, greeting): + """Convert to uppercase.""" + return greeting.upper() + + def post(self, shared, prep_res, exec_res): + """Store the uppercase greeting.""" + shared["uppercase_greeting"] = exec_res + return "default" + + +@trace_flow(flow_name="BasicGreetingFlow") +class BasicGreetingFlow(Flow): + """A simple flow that creates and processes a greeting.""" + + def __init__(self): + # Create nodes + greeting_node = GreetingNode() + uppercase_node = UppercaseNode() + + # Connect nodes + greeting_node >> uppercase_node + + # Initialize flow + super().__init__(start=greeting_node) + + +def main(): + """Run the basic tracing example.""" + print("🚀 Starting PocketFlow Tracing Basic Example") + print("=" * 50) + + # Create the flow + flow = BasicGreetingFlow() + + # Prepare shared data + shared = {"name": "PocketFlow User"} + + print(f"📥 Input: {shared}") + + # Run the flow (this will be automatically traced) + try: + result = flow.run(shared) + print(f"📤 Output: {shared}") + print(f"🎯 Result: {result}") + print("✅ Flow completed successfully!") + + # Print the final greeting + if "uppercase_greeting" in shared: + print(f"🎉 Final greeting: {shared['uppercase_greeting']}") + + except Exception as e: + print(f"❌ Flow failed with error: {e}") + raise + + print("\n📊 Check your Langfuse dashboard to see the trace!") + langfuse_host = os.getenv("LANGFUSE_HOST", "your-langfuse-host") + print(f" Dashboard URL: {langfuse_host}") + + +if __name__ == "__main__": + main() diff --git a/cookbook/pocketflow-tracing/requirements.txt b/cookbook/pocketflow-tracing/requirements.txt new file mode 100644 index 0000000..d4684e7 --- /dev/null +++ b/cookbook/pocketflow-tracing/requirements.txt @@ -0,0 +1,6 @@ +# Core dependencies for PocketFlow tracing +langfuse>=2.0.0,<3.0.0 # v2 low level SDK compatible with Langfuse servers +python-dotenv>=1.0.0 + +# Optional dependencies for enhanced functionality +pydantic>=2.0.0 # For data validation and serialization diff --git a/cookbook/pocketflow-tracing/screenshots/chrome_2025-06-27_12-05-28.png b/cookbook/pocketflow-tracing/screenshots/chrome_2025-06-27_12-05-28.png new file mode 100644 index 0000000000000000000000000000000000000000..842f183c383340cbcfb1cbd13dd48c883d5985b9 GIT binary patch literal 33199 zcmeFZXH-+`*Ds3OzEM$75fFkHhzf#$fYew(2~|X-Cn`;afYi`JR8$PT7b#ItIz;J& z5*r;sdI=%YAt6CZ2qYvq!R`K^_nh(G^X1$z?w7kp7_7k4)|~U1figzPk~B*?j_N{l>-6yhTIvU05}3Xx?WGH+rU8& z{Q0-`X|&c1&^mZ3w{Gl4S8Zpe7xT5G&T1`Xtbr6@hyCkMv#2=Ttos7mxB)gTZ_sJ~ zRFMZ0$D-wceY&-(JG3jQ>WHBRfw~AC)U_%%Vv-n9jFqxR{?qqn1aKuWepear6`0io z_El)Czo8(n{foF%Cy?=R5a~>;q_xK71Szb5N5N-m%pHKdt%Vc`Hr>z=L!QrNBITHJn)uLe<2|Yo#Dz zSa*CYg2?&+O)M~w!YDJGsySq1ro*4%^m)rQv|aKPQ**1D5(0^!ClkgNaCRb$8W3Z_ z)@j1TE-63259z@2 zifUWS<0>Sq9M%1v?Ro)x{;dS$Z`9AoN*Wr#(Nzc^^QWklzgtBLXulQS@y}W=zkdsg zThf>kCKARanjtA3-O1F=RXVLfHYC@!yYK}qJWQYlwA9#(B&(4qcTVQv6=JG68JZyj zNi+_TIB<>{)DG8TI3j`z8y#Z2!R(GABE0}{X(^-=xe&>~RX3oorcpZdN`KL(Wky>Tb4t-~Z5uW&?GjY7{P)GnN+ zQLm3&8ecG}BaSC)S7Sy#910SvIDI1;2j1+%xKuW9GvmlJB<3JrB*{bVC84eDnYG&2%ytjc!@-R?W( zg2Rq%jL=+Fp6#LycH@rq1h&*xvs+e)^lF8g^J1zswH6@Vju>^C5rR9B9WP8wF^QyH znsqVt%Ioy_wN5#S=vCm_)gSyLl^0|jAW|$ye_zD(ubH{J1o?CEp9sXUI@8B_cI;F_ z8q*bFJ5)F2i);x2jzu?!jX}&r&q+;Z!)(AZh0zZiO}m7*=(;A5pA zQ(4$iP&_HfVfo_9Gg}e(*EX8rx~)P6fgM{%3N94=Gxc@XV8}EKx|G!qv4ahib?!j0 zgM1r6yRa4hD|Qt0oX}Vv<|g*Fe!%3`hIlhi8mav=-*isjW2XnGGDT!3knwek0rva< zjAoRqC>_T#ek1WX$gg3(3hD6~KKA&%?dB?Od}{1$wXhSFan2T7um3Sio-?4(kRLG< zxx^Y-C4L<#+l?^j-?P#l!N8dk-L;PVOB$j3T99cWY&m9jU|oBLhT96RUil_8MhI!o zZ+W$1|0PUK3>&*4f*3`THRFpxnbOPY_-iAts^`m`>P|i$;tDr(M*OpD0;`=|zk=TV z*?o6)n-g^9xlf?&I~dbWpt|Y>x~OiNZb)Fm^4{g~so5+uSdycb^_aImb!9DyW*G_I zbD~KB`3)ZY+VPK(dD3y#Vk`d z1k37`r#nH;gW{TKyAe!<1*JP%*_tzcf^C1u*6gto-`WjZ2+>XTK0`$y}h#+5cxGx5@ZcP5jz; z{uV@yHqfLbhEguAGm@&?J(bzoQVXkHIC zOUgO<;|jQhF5=IsF=Py(Y{CG+tfTHK)BUwZo5&j*Q)qDt_wNX2;F3035tdp0&sYo9 z?Q@)j?!9Bl0>DNJb;*~v9Ut?Ii64T9G+ifkblFowe4Dx*J3^6iFdsbgGo8k) z!+2>T8ivsznr8~~*Yg^VgJms+;al`)Jx@GSYk*$l1rkr+jdNC+%2US<^ENE3E&i+V zwFVuBrDwJQ8S>@;XTjPqe-=Pz7XGhAk8GKij?45m?XopJ9p<^UW$vD1hDg*s-tF;t zNrmg5d8}D872)BRcM*yg3I)cI)}MY)!Z!Xptyx+tu&n<2Bv1AFc|p)KkB8<5(p_`WNlEshGeR&4sb=GzdQ0o}acRL|Pcn z?kM!{sJm0wL37=(g2An_x~KTG2ABtF8<4U<{EKtAai12(?2S4#=lF-MdTuiFH{IIK)4rUNA_=)cxa6 z@I@Yj@chf)*JZ)~|8|=Y|1Z$t))a6hE{7@f4`KN}6&D#xp;+i^G{j)T>^R~xl+nn~ zz@fHaFqwVN@}m+I#-_4}m5BQeEr!@y%XJ#UaIXO69u`!_p*66|nQMr{yRKNBntBl_ z78(uS9#m$ic!vL6LaU4YDlVuS1oZ_lzhrAZ)>qj5~6zc2e*X86F znxYo1$CmuIzfr-d1AF<59jCfEp(7ioruOlMdorRFvH8qV&|0unR$(j$yywYv8Ev$k z|ElpEbbR%CH`=jQvNh0yyqoVwY$PCaIZSt3315(PR)O7>iNmwMeO}z}*jd^%#7Mfw zK5*cXO?>NB!L}7$uRfHG{LHjkc)8ydz6*g$(XIZyK3m&1;6kml`gY$L@mAi_U6~G@ zX9QbLynJXN#UD`lqW*O4{(!u=t%Rd|4?-hBN~OFWe5vl`849oBTUTBm+T8Xa0yZVl zk@r%oczZBMAL>VXo|vA55Z!SC4{Zi)w@+|QUfpdbXkEYbrGa_6^Fn+pZ+t^3{!P~$ zTr1t*kJa^x0-8Ar}TFw+sp5R^VaphjeH<_ zmy%*zkF%6Dc_**Q1)_5Rl=Mh&Gu0uc4{+~q-k2BGG@~QUe=p;}b*RXj1ixL14|@iS z&cBoY-v;ejx};?C`#~?92w)VO3?KRS?!{W4s=obp!(4(~Hw7XU3RHTTP4aZicztNy zxZ~c1ts~m^MXxd+iv4%P-M6+In=8fN3)Q5I@e@Ja%*Z-28CxUlN9%3cGr0YrrCl7h zAuYSskf=UavLH)i&T8Y@)3OtMcPSS1ysRSsmJA(uHwEw8y^|({VS3OyO|yi*;WsO} z(|0(7)g}gGf3BrelUl0?qbj2*BNEYGbLa)g9u}#dvtSP zlQ}LK?;`}>E!-tl*kyuHzi!tmu)ks1EZC66I-JxvMC2~Ja?kQ_qLyvBbVMlThB1h| z@BXcvgYbcEuG|eC%@{KL%bLdKGCjG&>?k~Y^~x3kwTbfd*et-Hby8Ni>)*J=MUR>u z$G2Jw!lN|bM~V?calfqA(htxO4S~S8y#0`AD36sJ91H_>LCjL;*Jlz>`L9@QZVm!C zt;j{8xvDIE__inxeRCN?o+89>{W$ed$3SYO9s|#rRH(z#5gawfR`mGwU68*ix)s1# zJnL7EdAcqKZ^<#KFx9j3rGBRgbXiY@Y{Ix0+Fo$TDVvlB$3w)pNq9~?6Bn}-Ffv*{kV z7n=C@;6wEsrWmaoP*^*-&DmEK>ev)etn^d2&4sBDdD~L^Ahz*{_IJ~)fIA9>j9*cn zhb+D90(8pQK(F-|u4B@ihOpp!_LFcR(|8Y~4jMFSB4s#Pvyb9(;0e$xm*n|Q(p0h| zb>)R@W3h0O>Uf#DtT*|g%e)@)_Z}VUU)n7p75vfkad7`b@pvGoj{7sEjC-gIfP>18{~AbmBW6B1;tXfdh&*x9lC``q(WhO z@o%9uzY!zh3u5Oblu!SHX)$Y{c1-F3z?aLm+z;F8Rr#!>KE!RpW~4^xF|>bW-rWxHo&>_XC^yn{lk2C_Z2c0!DVz2I zR{r^rX_>aJE%c1k*1_-mK2Y)4KR|BeC5MVzS~BH^=>sy)`#pkz+^ z@71LPS)b&=Sp74{Sx4k7Ss|aF1$`I-dev%gRtfv+p&wV;c^F!o));594}c?L?j=mw zy)TCcW_Bs=i?JSDs$c(`09kSnK%TwJRXVjnE0aJPrC>r@j+{d2nl=k@j~zc-2MbV< z4dUpf%9!b|#*SPe)z+Rn`qlqs|>eZGi&K95l`rFU)YdnHDEB z(t;;OyLWd;PuRtJ-GXMFyp&hzaV`1REh^SrXmroKE*(IKB( z=483#g&|3O>xQfly=%(>%fM1jeSV(*XqA;biGr%eTskjUfT`Ph?G=zw1L)ICSr1MunQR%=e3R%Jj>{Ic!B=GeI!8TwlZ?ir5T&a!)*b7>( z*rT-uFC?@&^(XX&9FNqVgSEoW=;mBb8<=HDQ?ty>1=Fmz z4OK0)p`Mi_=Sh!7upcGgmJeQ~1rfQwEXQX}xEtzMHlYi^kGUxnjJc_l?aXxGO<;Tk zunZVmvMV6mq~AjC+`|XbaS@KL%ieJxl2%q=U^6$iUGWkZFPHG#eVG@~wE{Gz#(vWG zhcxIZ;fA9p=cFcVfGQEw591CDn$pQjMtWwbz7;{epkt(m+9l~4hXeyL>~)2!Sj)t- z>vbC=T)iiiSvsnBGTF4SWAFz>S-D%fD|7NJ2fo9U?Jcud5U4lwv%Rw+#=JU|fh4IZy z7g@9%ms`%A$Lz*H0<29371~<^S1P;|e6mZ=-GYE+(Zv|Pb)f=ZtopAM%DV^TAi9!$ zjK#Qt8`Jcs4dj+Q+~)aAP1yt5=!{hu`=-ojf04|yeX3UYXP32! z52eXIe_gx}GV{Hq2=9 zI$aivelgK03`73RAC>Fp&$-g26CUCFmrJN&9oO(vuWESyh~rcn&xGnLgv*^5Zygs3 z?h0^S%g#~}UrbM?ST2*phoH8dg4p=6IQGdzlfPX9YdIqZQo^R+!IkNJvw{3;K zG`kpB2D=XOU=N)*IztcHZ2KXlPDVKo$i9Vnzojf=&Q%GfbBZJ#we9gHMwWUCrcxaU zh;?RSWn;Gocyq*nnVWs4C}At4&Z^}m${C*WO_F6Mye(YN))S|>D-)<-3aM>$wwDg; z{cSSV-h2;Wm+=#)o7O9&>}*CB1MGCM2Cs|lY9o-x_|}Gb#K|Ko?K?$Gb=J||^z2rg zAwD$;`Ad`37ZLc6zXnS|EtB0a>x)Fnw0(EM$4blJYL7X0TdEoSqG$@FTwD@ zABW*#p}r)y8#M*Mfl>1livnr!`rFazp1#@o5J$-L$HuO!{P8}%*o+Se&ojFp#kW@a zQh<-b=*Uw++RC48WR5%8uC0p{1HBUEy6|9T{j*AVAh=ky6GIligg(h06R0B=#aPyu z_>N=@9Ln88b)gS974n>iUp>lQ?ahhlYV0)VZRTXskmJ(d90d?2TbTa-w;9Ffo<)xV zuZ+Em)B}+VuYv?4P5{w%azkw}$bEx=00ob@#D3Fqa-grEb#jNNI1s%Ewy<5awfJ&} ztg#Hv7&{YSr)w{0RUHExAKeRf)Z7A=^uH&8OA9(szuUVM`COj-hA%^Z}hTHyP9b=Y~@_D1o3arFN;yS^Mq0 ze=_ndfi?@ccbj!WVl|8?ZDCpqTHrXW?I^;^oAcC7BaFqsXGO`P))p$=8M4@9Oh=LU z^nOQ(WXFmCmy`F5&K&sd+QSlF>sG~oZeqpI`&#j=IMc*7XV(Uq-x{Uu>y?C7JrVUi zf*Nn-z``9{AZxZ$(_DO_TssGkUuBt<;f8=^Zj7bO5EE+`1xR=9ghx4m)5vsM)6u_W zg)I_lWP0PF+@P&9@$-QV{X*YD7jGmyxMoZ5-;Oz=xx!0r=jP<#n{CQn?D4&UCWR!l zTFO6XCF@TfCt(?Fj?^1zuNJGKOhgS?w+B6BP*ZFH0{J@Q4b+sI@i89nDQG?Ka73<~ zT>1#{mf5$PpLPXVe>-SliUMlsU>`Ux*7E6SHKr1&;7I6u*Elk`bSXPCa8$(U{v2kj z9F@dAqRq=CZONHR@?r%JNmMK74;{CisSzD}MiSUpxY#;{>LKQH%c`$~-reL<24|wD zCs(Dmo-dz?Z|yjL%XFd3zzVV^(?b#Z2GjPCxm%H~zO>J4jx~k`_4I{w4dRr+P11BU`LIOE;_Pf0=Ht zfP~j}Y;|(0h$OR|#;-7FQTYt$MetAB5xammDe_HnzhlpDX+6)Wdzq3TuF!B?nzHYH z?|%(xPpmRATB~mBfq#RiReYJs=*Fze?yrDeu90Aay>bq`Ju%h4J;tHXkp=`R@x$p? z5dMWccr0%Fsg0_wvxcf)>+X5-2I`uoK2Rv(1Y9QeMgKPF|KAKV zdK!Bm7SX-Zvi{2RxHWj*B@-1;z=S>^0BS$Sk*ef6^feDHav{|V|GmX2#$ zDh~RNoac|=3El&@(fC%@_@#O12w_pcsZG<2K?UdY5T?dff2rRyJXuif*!+u^`=Npk z)iQ!7EqF=Ei>k(uX*H4$?L~^P826*x+3KkoJw`5+k54<^lb1Wlof6=!?Ma);@1Am9 zxLM!ol`q=c^EvXsr})d`%e~oz@|t(T7Ng}gq=x#rsI>YEd?yZyfE`c9EpOw$pWIRh zg0Co{ zDZn&sbwUQr#r5amLnZfp`CCN6aqPr@;IQ+so9Vjpo%+51CWn87Hvb2Z{j_pT@K45! zFIM;e`u29ZE>y#Rm^xlZqWUkWFcwQ2Xq#Jj_RC`KR>XHoY*}cCOS&SJVCwU1O8n4DadISua0i2FRFOn0EoQtivStJ(HAC z=TRl-IHOKnLBSc#V`Z?p!oQevXU%V<5xYpl{+rk4lRM2r%(JwTLEFL0@2ex77dIj# z4W<5l?ho~85Xaxh-@kv^#t#)}|8}_ie@&MELfDIs1vh`bz2-iZi>)p1vZd(wFh?p+ z&Ah8G-uq{Lsct)^x2_|%QlGN8R$rq>1 z@<&ctH1h)L%v;ewr9G=(l4UiJ?EnR>9;;^~@zSRwmfwXrT^n3TQN!)+FkSg*(vr$@ z_6zYRc+Vz_<60j_YN5v-qEOuSkJp;!tIj@sPWV*V%r10NmIagI6&Ka5Vvsg5hSx}q zp08-GTa>1iJJH{hS{iD)Pjss^M)1l&B(Dnw9LR=*-2cnQ=pubCRjy|_L)XFR68$u; z&EQ*W{8fiZ(6eG>W_V*5$0D=ZqT{7plI|d~Vjj<2o#^oRG*G+zGimA}b|QiR*11mf zTAn+dn7Xv~WxRSO=g}C}rb_NWKYnsGKQ5PK4ZP#Ka`M)#VH*_3b2bbJc^PJS_dcC#03}N8V?$-XHGp#Uw*Z{G z3s%+1+v4~_r;aa|*|It@q7T3gvS+n|FIKlX-z`NP)>< zA=)J_KX4eU2?H-9#fiv+Tx;Le{hnlu1A(NL-k$z6yCerBJJp?y8ZBy$zMr|5z5boD zk1&SYU$7ARz|tan*yi|2mcKUAM9(zb33IIF5)^-!^zIHwzcWO`C~4?6CPXEUw)&{n zSKfN=>LkmTbpw8@)RNusv4%b6Fb#1d>0jJ%Cz=r1y)A}-a`JM)VwlPt#QM&VL#Fr4 z9J)&4S>bA%jWf-#!1<75e`RCF*ZGiwQtg7}yZP~KS(@hO^#zKv6W$ytxtVFNH~B-u zS&@yrV=L3V4_UnBboxI1itT;C-#v;wNaZZ^ycp~I#D0W;*@$qN!~5!@OD}l>E8CJz ztIA_oQ>)!g9WeA^+s?h6n@H;}Li1}*9wmmQ!gp6RR#zhQ!7~fx9aQ)V+AJd4r9kuZ z!I1TsO0|h|AoER^&(r6~rvvO~7|oF9d#n8kiK&q~{_nw02Ydq)OqR-N2OlDcT`t${ z1JldAmi>m;zLy9)2X#w;kK8tDWP3`{f_NKtOKfM3LX(;;R5OJ#ixKgC&yuuBx#aQP zX>fU@S3ATE*49n+ms4_olyvEeNDTPt5zt8IM^!C{w~U$PZ4>Uij`-L-bzn~5jGSu3q9tA2s!p4vqlMjosDw(bX-fA$BY zFZOBEZt*)Xe5>I$-?FBZPaG?eOG_h;k&nVTgu;oZZTb!lq*B- zO(8ioqS`#!&O z0D0Q?SMw zIZdtR-;ENU2{hBT!HHMq5^U5}RYaAq=hS5y&xWbHMyDyyH4koCGpo|z8_{hn?8|5KVEAv3!wChte04~uul>-aHw#LE5r z`V0s(rh|$d|c7qJLD)F{OX}<_u%_K&#(N-GxE>J} z7-{Jr@{M&f?>>NDn@70CwY#M@@2pO#ADo06F2S^9A`@$4UigFC-@T~j4mPTYU^onZMA#mE??TKPuE5o6;G+kT<4apD+{r`T80H&54~%-IHh?y zF!T8=f!s&P)NprjWS3pXL`XM)cN6iy>2izAdU+b>H|I?@EWM4nfy9pZHM2Y_6L5## zF+b3%dsIc7I+x0;>|;*zJ$ZQLw=y!HoFqby#$C?x=~GuRw-pm^X02A|)~wF)NhGx*eH29r{BbupeAHIgR|I{K z-ZHP@wDQh$##7`t>(VH)I2%Jons)8@;#V1sRxyD9Dq>a-r?EROr%p-o`7Tu1XtsA8;;=K67OOYn$dzeifb5*qlxo8H(aT>*z*V zfd=E#d4!Tz+1UJ<6fHL@o;1|pkV~m5jl-GR3|%5BtZ5Xy>(*abC60IH8yDBU#5WE| zkhNlwRkP=D-BIVx3Sa0xb)?$TPG)TAKIPfix8o*)66rD$uQ$-81Uv8eEJK3x6CfN%0$8Wx=SM zlEs{%2X1@$j6$Erw~DDDzr$;sA~-cic*y(S3lXU&|Kf8_NZV(lTGPl29ONu{(Sd-3 z#`GXGI3W43J5#qTF=1spe7JkuGhshps$M3=c5By9uOr~iL%bk(<{mF1h}Hd|WhRHs zjYXUsdckWBjD2c-ofi;Y2rWhh>J&M`zFVT@64<=pK$=EiA>mAiPhQT-n(GOy`v z&!>2hr$fF0MVO1qo@I&ohaM{bQzV!h1&l>Blqv9V51ruKyg>HuIi81kDnKPv%c4l= zoo;z-)fD~s_QbhpV2m^`!gB`3-I$;6$#Eyd2wZP7%eK}hK*Y8S+doyrT3Y?urNno4 zl(*y~0ngT3<4&91Pn+?)Y zU)G z2V91HFp3Y1dr)q0jI9Qw_N|R&DZ;BSueq!TPU3ZkE>Bp^EPJ^fI_Xoa5S1$%B};(s4rYJ7;@P zIes*U;D;?WjYC6s>e>~|w-n!&dk}R!mphWTT!__b*hBH{nXvo=h)5A$ypR4_DGZ~j zj+Hg>5}U^whJo9#fm)~T%F~@;V@n~A?2~nqYy76veB$CnEd@7U$B?F@gQ|rAC@nef zzRU?Pbdo?#P`whPxUR%*T}(}z7IH1cv^eE&e4|OGXWDI}+k)0?88hznGj^p4I+>r} z)M;t;Op$WQbpB;N(Nb3d$Yuktj`aWbIX zJxnj_Du=2(h!ci|3=nz6`z@S{EB?G9-P+YV&28g|lAuz#93h;Ul9hDYUSp|r($F=j z0I&E~e)sp_CuGvTf{2%#sMxJVfj}mvSf%k*{uR9;hgDR%MLEExs(w<6bT_D7tl>4a z*gcf$?w(ZLs95t7r-tllghQ#w+>k!CBcld|qQZnxKQV~~F+a`fN<5#8NvNxCL9W}S zr-P>OyVhr!2A-!GYk}3h6#j;_ByyRG>CPR;UXR0E&IuYnuU)In$yM zq}1c8$=Z0Ob){lNvueb1AH&e2+8!26%E4CNgB81Ldc$tRXcbrJA7iTC?jQnQsARRk ze=f5uty2tIXPj{Gml1tURUNUN3mWINruh-#44WIT6q+nYbxo=vqPkpUvEU$ z%pn>VB?2ZNZXZ4&=3+^6Cah^`+KQ`tNAePqN|!@x@eZ$)J0VEiSFF!{1Y%K;u&>26 zhO_FU3(!-I@vp2mPLxzW@>r5WYMhh$vH3+%Yr?**EAJkY3vv$DtR%CR$ z58WrMDeMx0PcC)zQHXYfd+UK^h+fanJRTqm;0gt3xGK)GDjXndAYu3T5M$2Wc;L+-hd%vt;H z`V=p#ct+*Rrq{om7yHtcdM`#L_1H{}hjg6STGG$e@Up?JvyE@}xEU{DzlRK8)>uv! zb*b|q4SLJUiX2}91iiH4%fPpE{nLVk8uidVj^E#^}98?z9ywP5|Ru zP{(?D!zb?T38sC;^zW*uI#~ZL zU*%!s9zuWBE|L?KRkJ`HWC=s3zwjG>_i!s$HLWc7J!OEJ1%!S8%n*u3?*K@&k3%e5 zb3EBFhiR>yoHyv z@*$Jo1caY^*X3iy9LjWH;nQKd7jp$VN!1rLNKW>Kk==rGMR+{B66uX2cESs_bV61> z3-y;s&;>dD3uXWnr}}pn@?pjOg+>60;n$buxvki zyU1MB$aQs;CZ&3W#hqm1R*=?23zl+fP3FLcAQh&?+2wnJk!_vmTcJ|g1iytGKQVP< zrKaL@BkcGf;(E&|kCrnqC7?M*_c-s6o=yYJhVeA!du^Bmu~2v~a6t>P88u?w$_SoQ zZ18{ZGFew%s0x8|4ezEQ$Ltdfq>A!Fq|M~OVN2&|gHGBFCy|KQRGJJr6qM!B-eI-h zIrDkxD~OR4BK{udjpKnw1wb#wVT*&;1JZD4vkLLSz@AUz$4ZvW)uv@AS0B2$+vw3sTc6@7-=H$-7k9;aM^w(_PI;ax8S!c5Wz7IhA4_%V&zd@A*6DODlP8L zc}FgF+ma{Ay|GPJ#Kd{7={73XzwqMlJdFh^p)>k3*g!Zdzz z=F3s>(qT(M^ZOevdC&j2hxqI?G^=qf9vgx@Z9Nm@DWp)SDW<+jLTk9 zYYO$&`ZSl#Z`44BzapqPiwlP;-i%2ZrMaPwOy-bTb9{_6lsj4)%FTQ)XL# z@EEK-{TfjP5>?K2utx+>u17_u7@T|2R!y_AZm+t+k;ag+Ch&`{y?)D$&oREy_t>5> zRn<@P?RVn1%`-V1rv@Q)uAWnBJwMAz?Fax67CCUe_QTSEhqoE`Z^+3xfD^;gYq2w^ z__H42O-srH?%GBU6^z64{cLYa*1vhi|E`ovxMp7V3S(a`{ zS7tTMGbnSieA_k7rlK8q&R*b~znehiqBU!J)h|m;xpKu2)7X6Fz4>JJ{Hwk8>1HW$ zi(R_Hseyw!2x}p|5=a`S3SQIi$M}Kx0!eKg9vpBIXKX05CZ{~gi8O8LI9bZlI{R*~ z;xy^o&Up)gp;x%FW_l-XkF%&TQ&A}V+ba?{a6#7BDazHEX|w0~wyVcXi*RO_`@i}$ z-@^I2J3#8~hMP%uW&O;=mU5*@)`zy7^^AFMI~px4MBlYpAtfO_{2CyI$5V>o3N1@5R0nHMjgxQ%vuF<`Q(Y_miI+$VA(!azJ=;MtYBR} z+PG)@^T%*TksBjhrzpzHlT<{Q-SXd{tUY$8BiLkja~FGGln50T%oPC?g)iq+{$|Mu z@D|*)x3pQYAcuKV?F;OYPsa(?krD#@Mtti*eZc_sK=pi#GWL99oHXw^$IqO=o*(ph z-9gihmtYFxurL}1UU9O1Hpt}$2c&(V_74;c6}1@7%kYYNRz~$XFR{(7r-OQjg8*fl z-ebo#WMt58WBR-L`6RO5G=KNJTSu4`xViarL_B!v%7q-zfkD;z9VPBy-2^3d?TJ@= z=C1lY?IGCq?fDFY4Up1B>#1$i$rhZ(riA?EK7zPM&LS#J3%(^rz&W_m%lloH*XKDB z)j@@szMD8^9TJtet;FiuU7xFbG){_Y-h3QP*(|tZRJkI02AS_KXl);Q3#r$weXfH{ ziOWL# zt2dN<7itwuNYaf)O--4~ERvl6X4{UrAKhuOAttTz$1-YoMmnLNPU$Sk{w^ zwMAoJW4Ns)?5(H~z@S(qG`>}Lo6}mVnLA46AciwSdA!KnTplGTlB&zI>O1(Rt_#+- z8%f6j0)1T5E+&KGvG{<`RQq5ek{i}t(;ZA3^%}X+-C%^(@BzjV&`ua#+A79G1}3|W zK;6yfZLpnu7uxpl4x;;x+b~vWmTN6ht$}4B>5c544%RChFO3mSOW?t*V6q6}^ZTK? zKAixxn6E@U4<`7y_zzx`YtYAX z92AF4`#s~KVmvhMAuq-i<$D5Az4=c80JSN_W&cF3DbP^;tOBKt*|Gb-ZELMBWtDX% z+0wjKGn#U>9kMx-c=@=VV8Ax~gZ>Tw{DW0pF!rmjm_G#2Ddd$t+$}tip?h69enZP^ z`W2xv0qBK41qZ?Pe`6e9N%pTM6XfUQ^%Y_!dE4U6*ov>uI00d9!M2 z%if%KzgrmC{s#2kCHyadp4V?c?`q<_!n^Z-0KIop{~e$=@f*;4Ap7q8A3)FN{{ZNT z{Q>kst!wdn_~blxf__89s_x9!n%iLgwLFZy*e#Rxj=-;e6mdqK`F1%8d*h$?ZfM=& z?XT^cEtTd)Yi+Fu_7Dm7<^2i)B`yAYkv8Jqt{ zWH9e0phcGGp1H0*Yh^uNTv{LlPtg!yF35Ui#+I$#YamPy>!$~P~N@CJnu}!pqR7N=BKB;1C-!Oc|$2fySimDT&w;;HS|`7rpJsitp*|NnOAo`sdjd% zK@R^t6M0ol8b-c{+#{R^(CDe%rp?xk}nM}|)9{34vL)vGR z@O%PpI}UJ-ZhJ8A{T1v?HTlLD3C(fUy&ki>uvh{grgg8`v500_s9>?4ge#jE%YUS^ zYh&AQC|fpWIGF0hjAsk&&)tB&H32=fw)9Z;axYDV&7b`&O~QMv9{9c)r~wr(@Z+aV zB!_N;l}NpRz)G{aS?|bPq-seD+&mnZQ(d}Oj1qwyU>1uD%NCurZjvLc9jL0rn+|&&aPIQ+`k#K zYF?Z-H4yixS~ceT?R<6Nz$Mv$$>x}YK)ZCEup@#e=bmI0}T6h8+~>8}Zyj?O|{? zj_~E3yIEL@e`$1mmu{JQA!+y}?gUCueO7?Hte5Bf=2qJX?|n3nPZb#cI{v7gj`F;v zW_i_qi-&)u9e$Myk5!Z}dodo}#3#&rTj0vb{U5cx2{e@b|Nq}z-O7zyxC;@biHb;t ztdl~?mOWXfEZG{9onfdb#u^IQCQI2($PB}n?1{*}jG+t$6S9n9#_+pFclY=H{C=O` zIluEi=Re1Bu5!&a*Ie&weY_vf*SnWV$}E`29E~VqWj2%dy$0%6XuU~uw=^2!?tHN_ z>O_hZM#uyTSX&NmJf%6>D9Q7%cgdrjNeDK^k?~&YGqfEwpHNP0z{Kw|KYb#UD5P9w4;$EoOxFq zlFoSx|-a`X$!Djd-P#^4OR z?}JV0vVpHW(x1|jQ3qIPYlsnj4i^`-`qn!bk0^TOO=|)V;h05TmIDVqc{SXAi9FTkMfis*2xuDnkbtL2uF--_|?~KJzD((5ljRw4lWl+d7&C}qN`|J4l%!HEe3{^`wN5$bFzfbv556gWJ|5+WZtHXrWM zOFs8h@XcGLF9XwOF{ZZ74i-|^d7!r!$LG$bjR2>82)a(Rk#NmbdY_wX3y%Q4#UDH zYcQVT16PZsm=!G*d%F?07#mwFh03AlSgiD+19Yx?)=!y4VevX&l-<%yh0|t85Cv94 zf-|>RdLGgBgxe3juT`&WG1H;xxGr_v%8*OR2Z%=|uBAfl*DoSa=Dx6;E_2e5PvXVP9`oC07_tM~^KdDn zI`oghC1-9o2{&haH;#7->_61QDk$P9?$ULz8LJy*S`83a{SMPV+(Q-Y4C&#rG_>04 z5dfFb2;PXSbz$)=3Q!;k_(uDkUZBFx@xV4)IsE*L>`oE$hgAx$Ry;sUP&n$_sf5y@ zYd%TkEvAH!t4hKA)#(Ri6LHhjbAZ9B^7sKLfpxx~pl_2hov(xxwYw3cS$N^zn*)T6 zz4uD<4}3FZW%@h68LGsVzLtx$HAmtzJhS!6@?~wNoyQqf(66U-nw+-3x-X#-GIh~g zT0PJLIoWT&SLCN3GeRY663jI4U?N zH_|+ax@0uD%yHjLZYc6ba3=RGr?=Z!W6VmK$b73__sa`Nsgn6mI_)C&K0EKZ2C$kC zrkRRoa_UZ?PvdET)NCBfkb`r3SZlAG8nrlybSzCddSI;%*OY!y606E(v~VQ55^T}$(&I}?ID864!R&d^^dZPL*` zd$atV?A0XAs%%f0t!N8B(a$WG!6U}1AQ*Q{bVyB>=ePbO{nY@Z7OK5~Grk4J4LP1$c;3^Q#+2v{Oe3B>AJ_$%R z$W1(zhn(#`ELRsIO7nHt8l>pSAGuEUw65^Pn-ai;7>vR&By_w0)NM;R)^w0Exs3U1 zqLf%^(6fDqX&e|#Rz%Ko$6>KXn$4@rT{fx{l7-#k?xhR;6VR8-qwv$Lv-wQ#_cAKt ztf0(lgW9@vQ{N4W{vmB;a$O%CYU|0b?1p}=0b@qfsmXMmpBS`wQ7U%2C~j0yKP$-- z9|DP*o5t`Mj zr$P1kg1iG?5N@ad_K5?} zQBRZ4b;}0tUvA1=5zT{vLDm~i9%=*6brptr>%DmXPw&zg~bdNEA@MZvDT^gyfUq-o{KZ1sIj4$Ur?4Ws>(!`dF1c9!$UL68TP!52mV;Q zEYcn53yzx^)48CBHvQBktUic6ZS?#IQvmAzjsTxfFZBH2f&x{Yv)!b*9$f8o3C*qN zhlK`|OuBoTH_`c2eVT%{rf!Wl(oQn?&>T_X}&@pr(OSz_#04a3K&B3cW z^|rO#&G28x*ZsgsE$$P32|iusx-zi{-z?59!np5Q)+okAGp>wbLXLSN&M-3iY4fOo zz#g*Sn?>ioHrV+dWi`i(Nm@r>-@#wIGIuIeofkXc#xsS`ZYRzuSTM5|Ks!9r$Oorf z!lYuOLEf+Sl!fj*d~qD8$p)l&=p{iP^!Hj5g~E+PZDYX54519~Wn_u$Fp|qPY8SGl zr{8^jK~~F`*~4d~_&~>2r$SYLr&U8s!%~+I?9}1(`8+z=4z)`hAksz8}$%UVRufO@O9gkb}RU*J>T+k3E+4$+Ph& zQDCk5>Bvi9zuAnFraC zk5>8ZNLeK`q{@F>X!Swqkf(BhrR!y*&MxCjq1AOuxs+A40I3Qrs--wS$JY^67np}X z$K~O4%hEnW_?FLuoO%=gU@}=?ps=;^M@XZHJJ*Qf20A7QdT^rLy~_X6TOB5U$e^+; zjU_oc&hb4c=gH!Z=Vn3o?Io?QQS*s(YV(`=?x^^eh|lGVf-GNO!t5YHBJ_)nBdw$S zBEjJyj+C=}W_}-h%f#0Vb8kSokZKO5&^U&6>@dZ%OSsUXbqHCl&|xjx#g| zms&>>wd_J^=NRNp_h=XphlGJQ3hXZV;6Vw#{7PF^ryk?!yv{&4%xTk*f5>oq{0M>Z)|eDX`lPa3s|QAd`@!PAa{N;FTYUxxV;w=|Cz>+yQE zUdcXi$X#UZkr`GzNB31m{Ue>E#H*JMQ5Q;G!@lzB%r2RSp$Gn)uPyDCj4&)36?oOP zqUj-l+gdNFm47EC0#A?Lesx)AgdV=6g^|1ejZWP@vXonns5rLb-K4?uTAh)>Q^wZ- zQc~A&TB8{jieS%Me{#Q@HT-VIrd{N^0=+<{7)?-M9r~ewCv@Fjol@DfsbN4~Op{>= z$J@c_eXDn2fdOTCgL4@oEh|&Qwk|L9mk)EkQhO2<{ct2|lh$MNu69SIreJ7*euTtY z=o0QDCfc?2PE?)2VJjFnr`db@>bjtR1ZkOBJIW#z7p_)J|G(}(H3^icI1R48lLB+gUcd6t|V zeK(&G)2dCt$3d26wpMc}3uogZCSYmri#_${3s~{%DIhNe-LwBsSTP_|Yr|XVjE$sI zEI354?!Ls$jc4fC$Z?=I_8=zlqXoWY1-BqSjvSk+5(?4R;kQB+eEYqn{`o<~ZwopP zy;4xczeyc!R%aifVg|5r1j4&RY@8Nw5a$U zPNKb4;%nVs{+G>zv-NtZNjwhel`661zXse(tfQpr?}5(`Mq}g_#rNn)1;!q1%}k>@ zlEP~|fO`*P)+t<91nWe7`#PHMu#>h|*6(KQQzmjoKUH}Uf9SlzWYC3;qDANY2>=25 z*Kgch&s)Zd1@t3m7#NN>WODMmGqRFW=EV!%K8amlRk|N~8`clkl@OrGuY*>GeK_*6 zV3C#SAjw=82$}#Csk(a_wA?CpP(B;DkriYFrt$Y(o9R}c=>9F~Y8f`h=IMz=KKQO8mIM@iPV1_nN(Cdy*c! z{wYlXkOU~5X@I(s%;qD&ywMWKIyRgN--T0&yL|0D&wt`Zptf1!!DOi@AbhkEN5%o= zDngV`J%0*Pix*X%^jiRem@YR&Zd(8S<~e|-IsFEJHId86n3*^+_4z-=)TS^y;aWdI z*L3QK;|GRu3I4*;yqm-g#U@g?O^D!ZF?jdKXkF`$H95^JKnH*iRrSpi!4 z)^wd_54eMSff*Am&wuQzRN=eHeWdqo-S{qH1SL+D1ChmlKrocK+;Ez6caB~R`(w|$ z0<~+|bvsl20F0@X^+Mfi>c)#br$#wlt+RC4j^ugQYG4=ZeDcajVritd``oe11z<5{ z-G^ctoy9WG4)+2p#b&7bX;wHkUxZxD)JmROW*6tR4PAX=cgYKs5b=va=2-oIWRQO^ zA-Oz%sP=Gi>(1AMcYzWrcOF>zW~k0=&^*%Zjw_=5+C*zdKYvDjjeGS&1GUgrMDtkO zAdh|e3@>yFvhZsGM8=h2fw8eZ_~u{p!^kFL>v4>tNI{-tAE@rN383JbXN+wCMaBZPcmW2%0FFo zk)-77FuiY4jL>P||EyyIkzd;i30c~=Xxp$t91EoTr9glibP9kWw@4v>O;4(b`4jm1 zQNtEhnM-%*HfzTUh4lE9Rgu!Nmm0hf%ReOdSe5u$*S%SOs*i?59vFW}dN=-BU;g$f zdakK|^)Z}&Nju$npY7+nRzwcBcO`WjW@5ktB@!wo@1FR@1rlW5r&!u2sLV-_8(ibs z(^s0Zj4>YJ7!v5LpRdoEv3aRv>Fd-~V>}RiaQB+)=zWS5ufAE0)t2JdU&dd}G~8QR zHhzC<%toH@#q^0D+oAMJf;kNIEzjSmvVO>m7407rSsS;^`|gI-9uTQ=4U| zU6ho zgtyegg!sQZfQ<1gpUb~c?-14O03sO^?bPp4WE+uP#7pzy`~5ruE({ZS0fnnK#7Hxu zY#@%~KiKkLZQvgQ_5(_m15T_>(=cU$udcu~urQtXFhPx2r!taK8?yCwvfU-Hr{(GMdf+>2O{`u3M$hBWV7NhNgw8xPIn0EPG1nVJC*P3}{LFVns5QCa+Im7vxn+4I35m^O+y*=-FF0^W6ZXsmmv4LCjqxiyV-na3z zL*_IB2W#(#_w!D5rE&An?o^bJ+dfVEL{a*0up z(I7P_cGjB)3XzAEs4AHus=PbC+f=k}|1qMnOgI$T-4IYOQsREAoK3~;&DM=S)Vvd z-w7mJqC{yAW49cNiI<$$fqU<+4j-aS33f|uHk8EZrF*opa>{Zi>*houFjUdv5Mv7E zPC}Sdis$z^)!dcoBffNLa=t3vETJ_J71sE4xCz)P3K znqO0+fo|4GLv`=^VCd84i0=CS%pIWod7N!+hgT@($7@yh&o8YmjK~GGi^C4iizEZx z^3+81bg{IF?$qTIV+IltMhYpq)2BUcD~}CUYyoF$Owt!m;DPNsL@QQvtokEIg{Fq+ zheRj6Z`y^fy;W={uP8b|-xk!d>*ReJJ+6N!m3Z$Uy!&$M)x zH(Ko6@-F*n;I-dW+mrm3KGZnx?%SBx;HRV{E=hP&elg$<*zk@PXQWG)Ig2Y9x=JOl zcI3}&RjtuscH(1lw~=G^p{nF-mqRl;lBbyqnS^OS>cT2ruI3y=%&p-w&$m&51sDc8 zuVNV(yjm0Ve2SYgtdi}roq-mg2+RCxAqpdP&n?`jsvH%B*%(e>!_$&iwH*TAL6_B^ zjY!@Hug+YAhvvcuYKoX?hF%*gMi^prz~?6JN6k zxwm48c7`P>%g6JN7IMaxnbP?NWKCd-o;0Zz#_(RDbh}of-6u)3lsf3r`RSq%RC=x4mR`A$R&SDq!+?!Yy zQ#UA-Ltv?L9%3vW$wW<{$c!z;A{8kzylTE)7%iUVye=QvRh4|0RqaL?Jx|kh1WLax z^-;@w#W#jCf@XV4;ht9#yS!5-Qq`XZcl(R=MMvDv({)_swA(pjPm7?H>@Q1kj1y=i z;i8t1+trY>d=J#0%J8u~35I!)Y`)gZNgqe<9^(G7{MzJ2-#*<}w~CMIcedXQE|0=A z-9g*iA1z&q<}eKN%&x>-G%Y_S=?+nwoAUo$KI@d;rUQ-tg&8qbM2YIn^&wNJsnTye zajstWc9oszreULRsOxb4p3SDC<6472lE|T_BY>!9MTKAZ9?l_YDxcpwBufJG4`t&j zW|2c2U`kne zZPZ6(eTlvp@#ir=K^s4W#rm8@L3*w&Llym5cd_vVOlZrjq4Y0h5z&>Sewy5&->l)|4v3rTsHH_|9m-g{z4 z7hwIClMB4@=D~>A`Adl(moINMvqjrJO!0Wu+bu*@j!=8zAC%6>94kLzSUh;z3yw@ zNrd2Sx>mntejE=ug4T!{OhBy6yjsV|RKL`;MS9-9(o>FP+$1f>#v7UCALVLda>7c^ zITF7TObjt6g4@al#(PqaF6eDoQL6jt=`KMc1%^8F+u|wlj2N2XREaGOUt?$kXFA&? z^$v+rLwX>V^k@&3^vVHGEenFE>Z&S7KJna9RF1~$HaYX_P9heWKr_+2?O+dCOHQ>> z+ht6D=#D~Hl8O$Pvp{iP9egMqC`0)iw8AKc^@uy$SqGog1L{#XTnBVZ>M3N2z`JkV zj6#xOMO9^IXsLEqutA+y51LB@d0LOVev9xpU(Y+8vd=0TOuKUy&fUDk$ z3x2h2g-wV{kV>Fw8<~cMD-`4%pe%T#gAY3T&S`cDonG5x4-FH0KEHnL(wU?_!7nZ* zCXpQWc)SAi&{o4i7T)bg)oAu)4r*+s+&Jk;v)0?nx|H;T3dVdriWZ=BgWocvOrcx#w0=_yix>FX#( z7^4nDZCNk+z~7pmVd`#b%921)hhyz;bZlh9h8u^;Wm`Xj@*~=#ERMu7qV0XtL0` ztJZp{T8?RzrC;y5N?KHn-+C=`5*Thn8ap;us&Evjbh04zxYp=M8 z)fn8j1vWMnYPk`yoZ~Mc(hk8x?rkhRFdRyVS_Z22A&=VK`p$*?BKXB)zm)%Gpm~KM zCkJ1sI=N9IZSnd%le+C#kkS&oC6dUDmG{AM5-5tRxjbrlp0D zALxSf3mIJ@JEWziDj7;};SsIy9!DvS?LCs$z3R-9#y(ZlyNn$=>;Ss|9B0wL7O^2o zBs?>FakxG3$gR>jmhW4)vhb0+cD0XO?~QRaI*RK9ESk(Rt%2q2g|&7==uWoY%a*})60GDaW>06^Ho z7csYaQJ$#Hv8w5c_^hB;cD}PWBzTpQ4fsbkDB1?k4{AXe-(dhlnFSWjvrA%OI>`;1 zJA;Ni-(Rq$c{zv9UM}d@m^a*lx>NFr`%m|TveycD2?0eHeop=0AmY@%h`4`8mD$rB zYBwYV*ooqQ{j?uP>i@@o?AHVVjrXrP*mEav1CTQYs2GRTxQ0FTpMPxP1pXGuI`C&L z#b5LxIC%b1(28!(&~v|}^(4Q3sNS!o%e$8Q{D>st{Ru(aEiJQX5_?sCa>xjf1}a3w zFoqu=@g6V%ekqxf%Am(}-gNt9tq_N~TkkXeaVO7DDR8gsFExC|&x&|2g~tA3sP-n; z056i^y}Rw-0^7cEIP~jtBBSZeX}+6l;=NhDWw_FRq)*8cyL^)U_4R`~0lUA26hsU!1JgAE_? zhef=X*8QI^A%A~*I}KRF+vD;N`2&|@Px+Hu!QLn}|5(+9T<3vjyD|E*;)UUjsvy#t ze6(kNNbop*?UQ@YiZO@o)VJUNURI=&n6;cQ;m5Y>WH+b%-kcPsJJMhY+;wahYl#_*+2N-gh|gL;#8>!e!#Q zl5ziIOoFi15#{2Aj}_3~R(2on%RJFA1onYWAG2=t{tRh_11@HY8K!v@vHg5HK&P%u zMfjt|Hn&YxS>JRYiG~=!jFeU3?8L#-pJt>J-p#~i@!PDhmWcfxE6|c2fJ15(k&;PU zm|IHyU{9{Mj+e}ceTQ8h#8zSMpBa?uT(&goH{U;*@N=~9y)>}f*=D=b$A$=>FJB@=fKRcSp9U%O7q!!)eFP=YHEtw|N~T7_<#DbbHdo(C=-&c-Qbr^Q-_ zecUSK{7+_`9MA@oNT^32GrjwoDpE63=+)%xT!62{fCp48y)ZJ(JDXmgD76?x)!wf0 zo%mvCt%T=QXsiwuap$+HvT_13>IS;V$nmz8^<2E)fH9y(ssUntNAa07jmIVI$Tp3_ zYNqNPAYmwWKD_FT7qv7Ix-;h#+sS8c4PftgKt@@2lC|uDfN)&J(;zXwcqj`=tO_zR z8+vCisylT$Dtt*eej24tUF1&0Yd^u>*bAJBtx2CQs&LMoM~)d>5Hu1`K~h&#eF{J; zSh0so`k=C6l*?i(kPG+R2!jXr6bcuZd12;|;x;$t`Rla%Ur=`@^E}EvEzR5glqGLm zf`&=-5=lQM&k@(QO^f6EB6F*_O656Ul@=%y09`!tRjilCJwT8gB1~0Pm6b5G1AmZk zvQKXH0Fr4m$LiGOt^C>5kPMpZ4uo9DCX+0=Q>!)Rq?+vcN($M|kzx|RYO(`44hJ4? z{yge^HPlwx*aeCu#ElO&1KQ3pjH`9RWyL^9dzSv%dV@wiP;s+I5)E^N@(-dZ{eFmL zJW%8+MC1U%YALLz3=Zk9`J!w$fRGw}#*xkRwlWF~X0hocZzZab`K3-D`?(R`(H*uu zR4RucxR2#iRnB&$cP-P_3nd|6j5a8D{2>FIMT{#}vO*neey2vy-$_PW%FA*PUDn7m zM03&jciqIcuJ~V~WE?x@9p}KZl9Qih@Du&(rv4$6UG@D4I523UR5#&vGI5Q@B#GPk zI3??^cZ94*haMeJjuGTW@>Ay60$F|H~bwFF~PiZT6{V5Y) zC+H!`jL!?_PkQWliHq3okA@mOSQ2Wi|MICqOzs7IZ#I$ihc|y7bx5$oW8x<1M*q3^ zdewQOR!6WrU!+vnfWVLrQHOo$v3a3H0184WPn@6mUBRhz(=@pB#6eZyXwkckD3t{; zrK110wDX&XJE39uG;*Hyi$?`|5`Y&;P16G~C9eVn%>#&$vOJ4|jhH{^*oztgR1bW= zZR)uWI#D&WV_}d;S%P?3uc`MRTWmbhc`}#vnew?IqiSVi#(VR4yKq;u@710yWeE6t z@rtcfH6$Ry7o3khInd<>sq?IH^{RGN2IrKk4Py*NwCR^{2Av@@BylP$cHW#)wCLW- zx`)+x85&uyS4F^$w+k#0zLrjO3-yEk&V_G#`yV647qNweORX&i`S8DaW&Oopyz)Ar zN3v2GpcN{U7sn;bj8a{_Xz6Gqyks>d{&Lx2?kgYtJC|Io0;IYJFVlUSwl$q~JLmh&P~R9*W!B6;XueNm%z8{BJTt1K!t{1>atEBG(0^0gtg z^(G{>p(hqJYYMJlbb%q&h??~Q2Cj(YmqLY zVKo)Uz5!Jau9-Q1QS{urTeH1GpGX)Og6`Cj_=&<};?_DCzL$XClYi#>_Q=lI&+3!FuFR@TCj@fgeA8%(+F2|y zvZQW@)PNao^RUU5i(xn;bp&s&4&P3=Y=>MLoF-qu%V1?O@ZPNXuw~C^n8&0q%ew+j zWS>uw@%D!1{;R0!tp1k@ux)r|_n{6&_5l~(9{Dz*dtdGW$w_4sy3KMUwQaYN{-KlJ zHHNHh8~MNKq%kURk+C48Qv*Rl&(vFEozwXi&{g6u+mt+#3c9`q1Tp{8sfd}sp3kk%tlV_h5H)!pyp7j38V2*OG-_+ zug&`&9O=1YBfhNI55o#?|9%N~SqkH?pDp(u0Xdz^(tqm9jbDmmTp31_FX-?q*VNtb zwHtAsBp-QiYi~<1GKcLRHp?f5a4S_>0FwN!19agOoe_aZO;Rsu=H&p7-bXAT-%nI* z8r0NhojkRuTelQFGuEnRf;QFh>l*fMw~+Xk_=vv|yHAG+x(1CdyB049)p}1bh9I$E zHH&2%a8ch4i8iKCncTz>>*S+N@hd4%>3M_q{tsbC7ozDS&za^GV5yW%I;S~10iLhV zs1~}nD?Vxk8y$XWx*0Z^Cb_dvmO?mM5`pg}yW331i+X0zTVL&c#M6zzE8 zOI*CZIH5s8B_cu;FdvfxXjMvM%HEbWz<8sF&n0ban-VMcuME|1X1a{L16smyRd-56 z>|oYqLn)_PqvLHIjll3IJ>rw5(mbY#VyNk0>zqDZnVzCIzf)-w6ozn$0rS zy7>n`8=Su-ESQ61&1$KQylX1xxZ&r_2VhFR#mZ${gC_TguGsq-?_(0*IsAdR*ALFW zW_$Xx157(uQ>Jly@yA|adV^i^kZ9j0Z^`k+yFmg}2IV>p!`4Iu#DSr77%44-0ZXM2 z^TFz5ZtAn=sdzTO(gs{d=wc&1XgZ8W6V>g;s&ufHqL(iE?3J-`rly(VSKl@uuAw;_ z07BV1W+K&E#~SFER^uHF_t# z0$^nVDdXG1LwRco*GX6{O6sB@i4m&F-xk6G!r@CoYCg5vY|Ov5@n*i@K~oXC!9u#I zJY_rh2bMh(zB^?Lgw{Y4K^-tM#UHDn2A%{4LI65llL1Z%^qYwyWS3W@qpkQi%b42| zcND+Ro0D9-+kE6F7$UO -g@oW<($0Oy;)i6ptc30s}eeRJ<5a9`GgvilLa5^M6@Qkp!H zw;GleB^kaQrb&JXo6422dI2kvkJ*FWBkk1*vcIAQ1sOZPJW+j*HY4`ZiRSw*!d+Su zaK5@4)1-#9H7pmkhUHXOrRB1XK{r;qW%WCi$Tj|MnO!x;GDZ{R4PTnq;$ML37_)hY z4xD-@;Vn4L+Iimr(IFx*ChjFRmn^w4);*x*19PIi{6rQBg_-5cW1O5i6=mk%j~Zbg zyQ$*GMg?hBJ+0=etALHbe@`6yX_Td3g*7zfXgfQOt{|;4j!Gq*dg48J#|W|tS5@jG znj9wieJ^phNeaUZ4BaE&Hh&NFgbK$-hXmotCjQx>c58LEEz3Wc21$Mjak{pDyNK$B}X+mRuP~kunm>yha#~HutPv+0#!hv?)9)BKj(jlj&%orqRHNlki7ct z$h$<-eXpx{=X1=79Pspx1&c7W*lknd3vHm$eq4pPkXcAsyXlG_R|}<1Ec>6DmzPal zuLxFmu!lTwAg#>S(xxhU90`?%V=!8vvp&cT6U*jG&9}%wKABu-+hqd`xUJx{bV7zR zpO!DX^(3gy@ISRW&2H1L?w42K+qv-MhXZEmr1M#19imNdB&pSZoGA#5&${UKi&K{3 zds&+&!9GA|@d;)V98S7-T~60A2MdTlfe(6wpToCHA%02CjxX4lB~myR2-(egKS_NA zoCR#Pz(%J0eF7^#u6EPC_sgF-F=XAEA9Wm=5%ZH*lD8vJGD)o~S}O`7_22B^(+c#D zbZpHIW-$$jS$E-O?)VP_A>DxTlEja#U^zGb9;GC{8B?r1 zv2BPH?7Y71fZyto!qxzxZIxa1QSShm79k9vOTmt_0MmSR42vShL4ZOD3DqcOe+kWhY`#d{SU@vilQa2t`CC7!Ps13cGW=nmRN@zD0T#Ew4N#S0P z-jLonwWt4ZvAhUS;;H`tpTN~i)eaupJoY^2252eBp&gG|;=%ea`(i0o2(6~)>xlp# zJo)(75`KR3CfP05<-Tlx{l?}o&+M^8T>zR@#j{GbP{c6*Qs2IOA#bRqw5#5LXqG- zSAI6buixckIlzbvQv<)P#5ry{1i_tt?w|ksw#Vcu{*x=eUH>1h=D)Pf|2DZDihlUq V#&(}2i;Z)2wGFk(uHJp}zW`G87~22< literal 0 HcmV?d00001 diff --git a/cookbook/pocketflow-tracing/screenshots/chrome_2025-06-27_12-07-56.png b/cookbook/pocketflow-tracing/screenshots/chrome_2025-06-27_12-07-56.png new file mode 100644 index 0000000000000000000000000000000000000000..7e26de273d59f18b0508c668c37a47c358da2501 GIT binary patch literal 37243 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 JefRy<{{;#F<9Gl7 literal 0 HcmV?d00001 diff --git a/cookbook/pocketflow-tracing/setup.py b/cookbook/pocketflow-tracing/setup.py new file mode 100644 index 0000000..6f61cc4 --- /dev/null +++ b/cookbook/pocketflow-tracing/setup.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +""" +Setup script for PocketFlow Tracing cookbook. + +This script helps install dependencies and verify the setup. +""" + +import subprocess +import sys +import os + + +def install_dependencies(): + """Install required dependencies.""" + print("📦 Installing dependencies...") + try: + subprocess.check_call( + [sys.executable, "-m", "pip", "install", "-r", "requirements.txt"] + ) + print("✅ Dependencies installed successfully!") + return True + except subprocess.CalledProcessError as e: + print(f"❌ Failed to install dependencies: {e}") + return False + + +def verify_setup(): + """Verify that the setup is working.""" + print("🔍 Verifying setup...") + try: + # Try to import the tracing module + from tracing import trace_flow, TracingConfig + + print("✅ Tracing module imported successfully!") + + # Try to load configuration + config = TracingConfig.from_env() + if config.validate(): + print("✅ Configuration is valid!") + else: + print("⚠️ Configuration validation failed - check your .env file") + + return True + except ImportError as e: + print(f"❌ Failed to import tracing module: {e}") + return False + except Exception as e: + print(f"❌ Setup verification failed: {e}") + return False + + +def main(): + """Main setup function.""" + print("🚀 PocketFlow Tracing Setup") + print("=" * 40) + + # Check if we're in the right directory + if not os.path.exists("requirements.txt"): + print( + "❌ requirements.txt not found. Please run this script from the pocketflow-tracing directory." + ) + sys.exit(1) + + # Install dependencies + if not install_dependencies(): + sys.exit(1) + + # Verify setup + if not verify_setup(): + sys.exit(1) + + print("\n🎉 Setup completed successfully!") + print("\n📚 Next steps:") + print("1. Check the README.md for usage instructions") + print("2. Run the examples: python examples/basic_example.py") + print("3. Run the test suite: python test_tracing.py") + print("4. Check your Langfuse dashboard (URL configured in LANGFUSE_HOST)") + + +if __name__ == "__main__": + main() diff --git a/cookbook/pocketflow-tracing/test_tracing.py b/cookbook/pocketflow-tracing/test_tracing.py new file mode 100644 index 0000000..562bd2e --- /dev/null +++ b/cookbook/pocketflow-tracing/test_tracing.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +""" +Test script for PocketFlow tracing functionality. + +This script tests the tracing implementation to ensure it works correctly +with Langfuse integration. +""" + +import sys +import os +import asyncio +from dotenv import load_dotenv + +# Load environment variables +load_dotenv() + +# Add paths for imports +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) +sys.path.insert(0, os.path.dirname(__file__)) + +from pocketflow import Node, Flow, AsyncNode, AsyncFlow +from tracing import trace_flow, TracingConfig +from utils import setup_tracing + + +class TestNode(Node): + """Simple test node for tracing verification.""" + + def prep(self, shared): + """Test prep phase.""" + return shared.get("input", "test_input") + + def exec(self, prep_res): + """Test exec phase.""" + return f"processed_{prep_res}" + + def post(self, shared, prep_res, exec_res): + """Test post phase.""" + shared["output"] = exec_res + return "default" + + +class TestAsyncNode(AsyncNode): + """Simple async test node for tracing verification.""" + + async def prep_async(self, shared): + """Test async prep phase.""" + await asyncio.sleep(0.1) # Simulate async work + return shared.get("input", "async_test_input") + + async def exec_async(self, prep_res): + """Test async exec phase.""" + await asyncio.sleep(0.1) # Simulate async work + return f"async_processed_{prep_res}" + + async def post_async(self, shared, prep_res, exec_res): + """Test async post phase.""" + shared["output"] = exec_res + return "default" + + +@trace_flow(flow_name="TestSyncFlow") +class TestSyncFlow(Flow): + """Test synchronous flow with tracing.""" + + def __init__(self): + super().__init__(start=TestNode()) + + +@trace_flow(flow_name="TestAsyncFlow") +class TestAsyncFlow(AsyncFlow): + """Test asynchronous flow with tracing.""" + + def __init__(self): + super().__init__(start=TestAsyncNode()) + + +def test_sync_flow(): + """Test synchronous flow tracing.""" + print("🧪 Testing synchronous flow tracing...") + + flow = TestSyncFlow() + shared = {"input": "sync_test_data"} + + print(f" Input: {shared}") + result = flow.run(shared) + print(f" Output: {shared}") + print(f" Result: {result}") + + # Verify the flow worked + assert "output" in shared + assert shared["output"] == "processed_sync_test_data" + print(" ✅ Sync flow test passed") + + +async def test_async_flow(): + """Test asynchronous flow tracing.""" + print("🧪 Testing asynchronous flow tracing...") + + flow = TestAsyncFlow() + shared = {"input": "async_test_data"} + + print(f" Input: {shared}") + result = await flow.run_async(shared) + print(f" Output: {shared}") + print(f" Result: {result}") + + # Verify the flow worked + assert "output" in shared + assert shared["output"] == "async_processed_async_test_data" + print(" ✅ Async flow test passed") + + +def test_configuration(): + """Test configuration loading and validation.""" + print("🧪 Testing configuration...") + + # Test loading from environment + config = TracingConfig.from_env() + print(f" Loaded config: debug={config.debug}") + + # Test validation + is_valid = config.validate() + print(f" Config valid: {is_valid}") + + if is_valid: + print(" ✅ Configuration test passed") + else: + print( + " ⚠️ Configuration test failed (this may be expected if env vars not set)" + ) + + +def test_error_handling(): + """Test error handling in traced flows.""" + print("🧪 Testing error handling...") + + class ErrorNode(Node): + def exec(self, prep_res): + raise ValueError("Test error for tracing") + + @trace_flow(flow_name="TestErrorFlow") + class ErrorFlow(Flow): + def __init__(self): + super().__init__(start=ErrorNode()) + + flow = ErrorFlow() + shared = {"input": "error_test"} + + try: + flow.run(shared) + print(" ❌ Expected error but flow succeeded") + except ValueError as e: + print(f" ✅ Error correctly caught and traced: {e}") + except Exception as e: + print(f" ⚠️ Unexpected error type: {e}") + + +async def main(): + """Run all tests.""" + print("🚀 Starting PocketFlow Tracing Tests") + print("=" * 50) + + # Test configuration first + test_configuration() + print() + + # Test setup (optional - only if environment is configured) + try: + print("🔧 Testing setup...") + config = setup_tracing() + print(" ✅ Setup test passed") + except Exception as e: + print(f" ⚠️ Setup test failed: {e}") + print(" (This is expected if Langfuse is not configured)") + print() + + # Test sync flow + test_sync_flow() + print() + + # Test async flow + await test_async_flow() + print() + + # Test error handling + test_error_handling() + print() + + print("🎉 All tests completed!") + print("\n📊 If Langfuse is configured, check your dashboard for traces:") + langfuse_host = os.getenv("LANGFUSE_HOST", "your-langfuse-host") + print(f" Dashboard URL: {langfuse_host}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/cookbook/pocketflow-tracing/tracing/__init__.py b/cookbook/pocketflow-tracing/tracing/__init__.py new file mode 100644 index 0000000..ace8069 --- /dev/null +++ b/cookbook/pocketflow-tracing/tracing/__init__.py @@ -0,0 +1,13 @@ +""" +PocketFlow Tracing Module + +This module provides observability and tracing capabilities for PocketFlow workflows +using Langfuse as the backend. It includes decorators and utilities to automatically +trace node execution, inputs, and outputs. +""" + +from .config import TracingConfig +from .core import LangfuseTracer +from .decorator import trace_flow + +__all__ = ["trace_flow", "TracingConfig", "LangfuseTracer"] diff --git a/cookbook/pocketflow-tracing/tracing/config.py b/cookbook/pocketflow-tracing/tracing/config.py new file mode 100644 index 0000000..f8d6f2a --- /dev/null +++ b/cookbook/pocketflow-tracing/tracing/config.py @@ -0,0 +1,111 @@ +""" +Configuration module for PocketFlow tracing with Langfuse. +""" + +import os +from dataclasses import dataclass +from typing import Optional +from dotenv import load_dotenv + + +@dataclass +class TracingConfig: + """Configuration class for PocketFlow tracing with Langfuse.""" + + # Langfuse configuration + langfuse_secret_key: Optional[str] = None + langfuse_public_key: Optional[str] = None + langfuse_host: Optional[str] = None + + # PocketFlow tracing configuration + debug: bool = False + trace_inputs: bool = True + trace_outputs: bool = True + trace_prep: bool = True + trace_exec: bool = True + trace_post: bool = True + trace_errors: bool = True + + # Session configuration + session_id: Optional[str] = None + user_id: Optional[str] = None + + @classmethod + def from_env(cls, env_file: Optional[str] = None) -> "TracingConfig": + """ + Create TracingConfig from environment variables. + + Args: + env_file: Optional path to .env file. If None, looks for .env in current directory. + + Returns: + TracingConfig instance with values from environment variables. + """ + # Load environment variables from .env file if it exists + if env_file: + load_dotenv(env_file) + else: + # Try to find .env file in current directory or parent directories + load_dotenv() + + return cls( + langfuse_secret_key=os.getenv("LANGFUSE_SECRET_KEY"), + langfuse_public_key=os.getenv("LANGFUSE_PUBLIC_KEY"), + langfuse_host=os.getenv("LANGFUSE_HOST"), + debug=os.getenv("POCKETFLOW_TRACING_DEBUG", "false").lower() == "true", + trace_inputs=os.getenv("POCKETFLOW_TRACE_INPUTS", "true").lower() == "true", + trace_outputs=os.getenv("POCKETFLOW_TRACE_OUTPUTS", "true").lower() == "true", + trace_prep=os.getenv("POCKETFLOW_TRACE_PREP", "true").lower() == "true", + trace_exec=os.getenv("POCKETFLOW_TRACE_EXEC", "true").lower() == "true", + trace_post=os.getenv("POCKETFLOW_TRACE_POST", "true").lower() == "true", + trace_errors=os.getenv("POCKETFLOW_TRACE_ERRORS", "true").lower() == "true", + session_id=os.getenv("POCKETFLOW_SESSION_ID"), + user_id=os.getenv("POCKETFLOW_USER_ID"), + ) + + def validate(self) -> bool: + """ + Validate that required configuration is present. + + Returns: + True if configuration is valid, False otherwise. + """ + if not self.langfuse_secret_key: + if self.debug: + print("Warning: LANGFUSE_SECRET_KEY not set") + return False + + if not self.langfuse_public_key: + if self.debug: + print("Warning: LANGFUSE_PUBLIC_KEY not set") + return False + + if not self.langfuse_host: + if self.debug: + print("Warning: LANGFUSE_HOST not set") + return False + + return True + + def to_langfuse_kwargs(self) -> dict: + """ + Convert configuration to kwargs for Langfuse client initialization. + + Returns: + Dictionary of kwargs for Langfuse client. + """ + kwargs = {} + + if self.langfuse_secret_key: + kwargs["secret_key"] = self.langfuse_secret_key + + if self.langfuse_public_key: + kwargs["public_key"] = self.langfuse_public_key + + if self.langfuse_host: + kwargs["host"] = self.langfuse_host + + if self.debug: + kwargs["debug"] = True + + return kwargs diff --git a/cookbook/pocketflow-tracing/tracing/core.py b/cookbook/pocketflow-tracing/tracing/core.py new file mode 100644 index 0000000..1e72599 --- /dev/null +++ b/cookbook/pocketflow-tracing/tracing/core.py @@ -0,0 +1,287 @@ +""" +Core tracing functionality for PocketFlow with Langfuse integration. +""" + +import json +import time +import uuid +from typing import Any, Dict, Optional, Union +from datetime import datetime + +try: + from langfuse import Langfuse + + LANGFUSE_AVAILABLE = True +except ImportError: + LANGFUSE_AVAILABLE = False + print("Warning: langfuse package not installed. Install with: pip install langfuse") + +from .config import TracingConfig + + +class LangfuseTracer: + """ + Core tracer class that handles Langfuse integration for PocketFlow. + """ + + def __init__(self, config: TracingConfig): + """ + Initialize the LangfuseTracer. + + Args: + config: TracingConfig instance with Langfuse settings. + """ + self.config = config + self.client = None + self.current_trace = None + self.spans = {} # Store spans by node ID + + if LANGFUSE_AVAILABLE and config.validate(): + try: + # Initialize Langfuse client with proper parameters + kwargs = {} + if config.langfuse_secret_key: + kwargs["secret_key"] = config.langfuse_secret_key + if config.langfuse_public_key: + kwargs["public_key"] = config.langfuse_public_key + if config.langfuse_host: + kwargs["host"] = config.langfuse_host + if config.debug: + kwargs["debug"] = True + + self.client = Langfuse(**kwargs) + if config.debug: + print( + f"✓ Langfuse client initialized with host: {config.langfuse_host}" + ) + except Exception as e: + if config.debug: + print(f"✗ Failed to initialize Langfuse client: {e}") + self.client = None + else: + if config.debug: + print("✗ Langfuse not available or configuration invalid") + + def start_trace(self, flow_name: str, input_data: Dict[str, Any]) -> Optional[str]: + """ + Start a new trace for a flow execution. + + Args: + flow_name: Name of the flow being traced. + input_data: Input data for the flow. + + Returns: + Trace ID if successful, None otherwise. + """ + if not self.client: + return None + + try: + # Serialize input data safely + serialized_input = self._serialize_data(input_data) + + # Use Langfuse v2 API to create a trace + self.current_trace = self.client.trace( + name=flow_name, + input=serialized_input, + metadata={ + "framework": "PocketFlow", + "trace_type": "flow_execution", + "timestamp": datetime.now().isoformat(), + }, + session_id=self.config.session_id, + user_id=self.config.user_id, + ) + + # Get the trace ID + trace_id = self.current_trace.id + + if self.config.debug: + print(f"✓ Started trace: {trace_id} for flow: {flow_name}") + + return trace_id + + except Exception as e: + if self.config.debug: + print(f"✗ Failed to start trace: {e}") + return None + + def end_trace(self, output_data: Dict[str, Any], status: str = "success") -> None: + """ + End the current trace. + + Args: + output_data: Output data from the flow. + status: Status of the trace execution. + """ + if not self.current_trace: + return + + try: + # Serialize output data safely + serialized_output = self._serialize_data(output_data) + + # Update the trace with output data using v2 API + self.current_trace.update( + output=serialized_output, + metadata={ + "status": status, + "end_timestamp": datetime.now().isoformat(), + }, + ) + + if self.config.debug: + print(f"✓ Ended trace with status: {status}") + + except Exception as e: + if self.config.debug: + print(f"✗ Failed to end trace: {e}") + finally: + self.current_trace = None + self.spans.clear() + + def start_node_span( + self, node_name: str, node_id: str, phase: str + ) -> Optional[str]: + """ + Start a span for a node execution phase. + + Args: + node_name: Name/type of the node. + node_id: Unique identifier for the node instance. + phase: Execution phase (prep, exec, post). + + Returns: + Span ID if successful, None otherwise. + """ + if not self.current_trace: + return None + + try: + span_id = f"{node_id}_{phase}" + + # Create a child span using v2 API + span = self.current_trace.span( + name=f"{node_name}.{phase}", + metadata={ + "node_type": node_name, + "node_id": node_id, + "phase": phase, + "start_timestamp": datetime.now().isoformat(), + }, + ) + + self.spans[span_id] = span + + if self.config.debug: + print(f"✓ Started span: {span_id}") + + return span_id + + except Exception as e: + if self.config.debug: + print(f"✗ Failed to start span: {e}") + return None + + def end_node_span( + self, + span_id: str, + input_data: Any = None, + output_data: Any = None, + error: Exception = None, + ) -> None: + """ + End a node execution span. + + Args: + span_id: ID of the span to end. + input_data: Input data for the phase. + output_data: Output data from the phase. + error: Exception if the phase failed. + """ + if span_id not in self.spans: + return + + try: + span = self.spans[span_id] + + # Prepare update data + update_data = {} + + if input_data is not None and self.config.trace_inputs: + update_data["input"] = self._serialize_data(input_data) + if output_data is not None and self.config.trace_outputs: + update_data["output"] = self._serialize_data(output_data) + + if error and self.config.trace_errors: + update_data.update( + { + "level": "ERROR", + "status_message": str(error), + "metadata": { + "error_type": type(error).__name__, + "error_message": str(error), + "end_timestamp": datetime.now().isoformat(), + }, + } + ) + else: + update_data.update( + { + "level": "DEFAULT", + "metadata": {"end_timestamp": datetime.now().isoformat()}, + } + ) + + # Update the span with all data at once + span.update(**update_data) + + # End the span + span.end() + + if self.config.debug: + status = "ERROR" if error else "SUCCESS" + print(f"✓ Ended span: {span_id} with status: {status}") + + except Exception as e: + if self.config.debug: + print(f"✗ Failed to end span: {e}") + finally: + if span_id in self.spans: + del self.spans[span_id] + + def _serialize_data(self, data: Any) -> Any: + """ + Safely serialize data for Langfuse. + + Args: + data: Data to serialize. + + Returns: + Serialized data that can be sent to Langfuse. + """ + try: + # Handle common PocketFlow data types + if hasattr(data, "__dict__"): + # Convert objects to dict representation + return {"_type": type(data).__name__, "_data": str(data)} + elif isinstance(data, (dict, list, str, int, float, bool, type(None))): + # JSON-serializable types + return data + else: + # Fallback to string representation + return {"_type": type(data).__name__, "_data": str(data)} + except Exception: + # Ultimate fallback + return {"_type": "unknown", "_data": ""} + + def flush(self) -> None: + """Flush any pending traces to Langfuse.""" + if self.client: + try: + self.client.flush() + if self.config.debug: + print("✓ Flushed traces to Langfuse") + except Exception as e: + if self.config.debug: + print(f"✗ Failed to flush traces: {e}") diff --git a/cookbook/pocketflow-tracing/tracing/decorator.py b/cookbook/pocketflow-tracing/tracing/decorator.py new file mode 100644 index 0000000..6eac297 --- /dev/null +++ b/cookbook/pocketflow-tracing/tracing/decorator.py @@ -0,0 +1,293 @@ +""" +Decorator for tracing PocketFlow workflows with Langfuse. +""" + +import functools +import inspect +import uuid +from typing import Any, Callable, Dict, Optional, Union + +from .config import TracingConfig +from .core import LangfuseTracer + + +def trace_flow( + config: Optional[TracingConfig] = None, + flow_name: Optional[str] = None, + session_id: Optional[str] = None, + user_id: Optional[str] = None +): + """ + Decorator to add Langfuse tracing to PocketFlow flows. + + This decorator automatically traces: + - Flow execution start/end + - Each node's prep, exec, and post phases + - Input and output data for each phase + - Errors and exceptions + + Args: + config: TracingConfig instance. If None, loads from environment. + flow_name: Custom name for the flow. If None, uses the flow class name. + session_id: Session ID for grouping related traces. + user_id: User ID for the trace. + + Returns: + Decorated flow class or function. + + Example: + ```python + from tracing import trace_flow + + @trace_flow() + class MyFlow(Flow): + def __init__(self): + super().__init__(start=MyNode()) + + # Or with custom configuration + config = TracingConfig.from_env() + + @trace_flow(config=config, flow_name="CustomFlow") + class MyFlow(Flow): + pass + ``` + """ + def decorator(flow_class_or_func): + # Handle both class and function decoration + if inspect.isclass(flow_class_or_func): + return _trace_flow_class(flow_class_or_func, config, flow_name, session_id, user_id) + else: + return _trace_flow_function(flow_class_or_func, config, flow_name, session_id, user_id) + + return decorator + + +def _trace_flow_class(flow_class, config, flow_name, session_id, user_id): + """Trace a Flow class by wrapping its methods.""" + + # Get or create config + if config is None: + config = TracingConfig.from_env() + + # Override session/user if provided + if session_id: + config.session_id = session_id + if user_id: + config.user_id = user_id + + # Get flow name + if flow_name is None: + flow_name = flow_class.__name__ + + # Store original methods + original_init = flow_class.__init__ + original_run = getattr(flow_class, 'run', None) + original_run_async = getattr(flow_class, 'run_async', None) + + def traced_init(self, *args, **kwargs): + """Initialize the flow with tracing capabilities.""" + # Call original init + original_init(self, *args, **kwargs) + + # Add tracing attributes + self._tracer = LangfuseTracer(config) + self._flow_name = flow_name + self._trace_id = None + + # Patch all nodes in the flow + self._patch_nodes() + + def traced_run(self, shared): + """Traced version of the run method.""" + if not hasattr(self, '_tracer'): + # Fallback if not properly initialized + return original_run(self, shared) if original_run else None + + # Start trace + self._trace_id = self._tracer.start_trace(self._flow_name, shared) + + try: + # Run the original flow + result = original_run(self, shared) if original_run else None + + # End trace successfully + self._tracer.end_trace(shared, "success") + + return result + + except Exception as e: + # End trace with error + self._tracer.end_trace(shared, "error") + raise + finally: + # Ensure cleanup + self._tracer.flush() + + async def traced_run_async(self, shared): + """Traced version of the async run method.""" + if not hasattr(self, '_tracer'): + # Fallback if not properly initialized + return await original_run_async(self, shared) if original_run_async else None + + # Start trace + self._trace_id = self._tracer.start_trace(self._flow_name, shared) + + try: + # Run the original flow + result = await original_run_async(self, shared) if original_run_async else None + + # End trace successfully + self._tracer.end_trace(shared, "success") + + return result + + except Exception as e: + # End trace with error + self._tracer.end_trace(shared, "error") + raise + finally: + # Ensure cleanup + self._tracer.flush() + + def patch_nodes(self): + """Patch all nodes in the flow to add tracing.""" + if not hasattr(self, 'start_node') or not self.start_node: + return + + visited = set() + nodes_to_patch = [self.start_node] + + while nodes_to_patch: + node = nodes_to_patch.pop(0) + if id(node) in visited: + continue + + visited.add(id(node)) + + # Patch this node + self._patch_node(node) + + # Add successors to patch list + if hasattr(node, 'successors'): + for successor in node.successors.values(): + if successor and id(successor) not in visited: + nodes_to_patch.append(successor) + + def patch_node(self, node): + """Patch a single node to add tracing.""" + if hasattr(node, '_pocketflow_traced'): + return # Already patched + + node_id = str(uuid.uuid4()) + node_name = type(node).__name__ + + # Store original methods + original_prep = getattr(node, 'prep', None) + original_exec = getattr(node, 'exec', None) + original_post = getattr(node, 'post', None) + original_prep_async = getattr(node, 'prep_async', None) + original_exec_async = getattr(node, 'exec_async', None) + original_post_async = getattr(node, 'post_async', None) + + # Create traced versions + if original_prep: + node.prep = self._create_traced_method(original_prep, node_id, node_name, 'prep') + if original_exec: + node.exec = self._create_traced_method(original_exec, node_id, node_name, 'exec') + if original_post: + node.post = self._create_traced_method(original_post, node_id, node_name, 'post') + if original_prep_async: + node.prep_async = self._create_traced_async_method(original_prep_async, node_id, node_name, 'prep') + if original_exec_async: + node.exec_async = self._create_traced_async_method(original_exec_async, node_id, node_name, 'exec') + if original_post_async: + node.post_async = self._create_traced_async_method(original_post_async, node_id, node_name, 'post') + + # Mark as traced + node._pocketflow_traced = True + + def create_traced_method(self, original_method, node_id, node_name, phase): + """Create a traced version of a synchronous method.""" + @functools.wraps(original_method) + def traced_method(*args, **kwargs): + span_id = self._tracer.start_node_span(node_name, node_id, phase) + + try: + result = original_method(*args, **kwargs) + self._tracer.end_node_span(span_id, input_data=args, output_data=result) + return result + except Exception as e: + self._tracer.end_node_span(span_id, input_data=args, error=e) + raise + + return traced_method + + def create_traced_async_method(self, original_method, node_id, node_name, phase): + """Create a traced version of an asynchronous method.""" + @functools.wraps(original_method) + async def traced_async_method(*args, **kwargs): + span_id = self._tracer.start_node_span(node_name, node_id, phase) + + try: + result = await original_method(*args, **kwargs) + self._tracer.end_node_span(span_id, input_data=args, output_data=result) + return result + except Exception as e: + self._tracer.end_node_span(span_id, input_data=args, error=e) + raise + + return traced_async_method + + # Replace methods on the class + flow_class.__init__ = traced_init + flow_class._patch_nodes = patch_nodes + flow_class._patch_node = patch_node + flow_class._create_traced_method = create_traced_method + flow_class._create_traced_async_method = create_traced_async_method + + if original_run: + flow_class.run = traced_run + if original_run_async: + flow_class.run_async = traced_run_async + + return flow_class + + +def _trace_flow_function(flow_func, config, flow_name, session_id, user_id): + """Trace a flow function (for functional-style flows).""" + + # Get or create config + if config is None: + config = TracingConfig.from_env() + + # Override session/user if provided + if session_id: + config.session_id = session_id + if user_id: + config.user_id = user_id + + # Get flow name + if flow_name is None: + flow_name = flow_func.__name__ + + tracer = LangfuseTracer(config) + + @functools.wraps(flow_func) + def traced_flow_func(*args, **kwargs): + # Assume first argument is shared data + shared = args[0] if args else {} + + # Start trace + trace_id = tracer.start_trace(flow_name, shared) + + try: + result = flow_func(*args, **kwargs) + tracer.end_trace(shared, "success") + return result + except Exception as e: + tracer.end_trace(shared, "error") + raise + finally: + tracer.flush() + + return traced_flow_func diff --git a/cookbook/pocketflow-tracing/utils/__init__.py b/cookbook/pocketflow-tracing/utils/__init__.py new file mode 100644 index 0000000..f5d9c1e --- /dev/null +++ b/cookbook/pocketflow-tracing/utils/__init__.py @@ -0,0 +1,7 @@ +""" +Utility functions for PocketFlow tracing. +""" + +from .setup import setup_tracing, test_langfuse_connection + +__all__ = ['setup_tracing', 'test_langfuse_connection'] diff --git a/cookbook/pocketflow-tracing/utils/setup.py b/cookbook/pocketflow-tracing/utils/setup.py new file mode 100644 index 0000000..56d3a14 --- /dev/null +++ b/cookbook/pocketflow-tracing/utils/setup.py @@ -0,0 +1,163 @@ +""" +Setup and testing utilities for PocketFlow tracing. +""" + +import os +import sys +from typing import Optional + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +try: + from langfuse import Langfuse + LANGFUSE_AVAILABLE = True +except ImportError: + LANGFUSE_AVAILABLE = False + +from tracing import TracingConfig, LangfuseTracer + + +def setup_tracing(env_file: Optional[str] = None) -> TracingConfig: + """ + Set up tracing configuration and validate the setup. + + Args: + env_file: Optional path to .env file. If None, uses default location. + + Returns: + TracingConfig instance. + + Raises: + RuntimeError: If setup fails. + """ + print("🔧 Setting up PocketFlow tracing...") + + # Check if langfuse is installed + if not LANGFUSE_AVAILABLE: + raise RuntimeError( + "Langfuse package not installed. Install with: pip install langfuse" + ) + + # Load configuration + if env_file: + config = TracingConfig.from_env(env_file) + print(f"✓ Loaded configuration from: {env_file}") + else: + config = TracingConfig.from_env() + print("✓ Loaded configuration from environment") + + # Validate configuration + if not config.validate(): + raise RuntimeError( + "Invalid tracing configuration. Please check your environment variables:\n" + "- LANGFUSE_SECRET_KEY\n" + "- LANGFUSE_PUBLIC_KEY\n" + "- LANGFUSE_HOST" + ) + + print("✓ Configuration validated") + + # Test connection + if test_langfuse_connection(config): + print("✓ Langfuse connection successful") + else: + raise RuntimeError("Failed to connect to Langfuse. Check your configuration and network.") + + print("🎉 PocketFlow tracing setup complete!") + return config + + +def test_langfuse_connection(config: TracingConfig) -> bool: + """ + Test connection to Langfuse. + + Args: + config: TracingConfig instance. + + Returns: + True if connection successful, False otherwise. + """ + try: + # Create a test tracer + tracer = LangfuseTracer(config) + + if not tracer.client: + return False + + # Try to start and end a test trace + trace_id = tracer.start_trace("test_connection", {"test": True}) + if trace_id: + tracer.end_trace({"test": "completed"}, "success") + tracer.flush() + return True + + return False + + except Exception as e: + if config.debug: + print(f"Connection test failed: {e}") + return False + + +def print_configuration_help(): + """Print help information for configuring tracing.""" + print(""" +🔧 PocketFlow Tracing Configuration Help + +To use PocketFlow tracing, you need to configure Langfuse credentials. + +1. Create or update your .env file with: + +LANGFUSE_SECRET_KEY=your-secret-key +LANGFUSE_PUBLIC_KEY=your-public-key +LANGFUSE_HOST=your-langfuse-host +POCKETFLOW_TRACING_DEBUG=true + +2. Optional configuration: + +POCKETFLOW_TRACE_INPUTS=true +POCKETFLOW_TRACE_OUTPUTS=true +POCKETFLOW_TRACE_PREP=true +POCKETFLOW_TRACE_EXEC=true +POCKETFLOW_TRACE_POST=true +POCKETFLOW_TRACE_ERRORS=true +POCKETFLOW_SESSION_ID=your-session-id +POCKETFLOW_USER_ID=your-user-id + +3. Install required packages: + +pip install -r requirements.txt + +4. Test your setup: + +python -c "from utils import setup_tracing; setup_tracing()" +""") + + +if __name__ == "__main__": + """Command-line interface for setup and testing.""" + import argparse + + parser = argparse.ArgumentParser(description="PocketFlow Tracing Setup") + parser.add_argument("--test", action="store_true", help="Test Langfuse connection") + parser.add_argument("--help-config", action="store_true", help="Show configuration help") + parser.add_argument("--env-file", type=str, help="Path to .env file") + + args = parser.parse_args() + + if args.help_config: + print_configuration_help() + sys.exit(0) + + if args.test: + try: + config = setup_tracing(args.env_file) + print("\n✅ All tests passed! Your tracing setup is ready.") + except Exception as e: + print(f"\n❌ Setup failed: {e}") + print("\nFor help with configuration, run:") + print("python utils/setup.py --help-config") + sys.exit(1) + else: + print_configuration_help() diff --git a/docs/utility_function/viz.md b/docs/utility_function/viz.md index a518be7..065fdaa 100644 --- a/docs/utility_function/viz.md +++ b/docs/utility_function/viz.md @@ -139,4 +139,6 @@ data_science_flow = DataScienceFlow(start=data_prep_node) data_science_flow.run({}) ``` -The output would be: `Call stack: ['EvaluateModelNode', 'ModelFlow', 'DataScienceFlow']` \ No newline at end of file +The output would be: `Call stack: ['EvaluateModelNode', 'ModelFlow', 'DataScienceFlow']` + +For a more complete implementation, check out [the cookbook](https://github.com/The-Pocket/PocketFlow/tree/main/cookbook/pocketflow-tracing). \ No newline at end of file