From 9773497beccc712b5073426da890f836fc3304a6 Mon Sep 17 00:00:00 2001 From: Suddhasatwa Bhaumik Date: Mon, 15 Dec 2025 15:44:21 +0530 Subject: [PATCH 1/7] Initial commit - Supervised Fine Tuning - Starter Pack Agentic AI Solution using ADK. --- .../sft-runner-starter-pack/CHANGELOG.md | 1 + .../CODE_OF_CONDUCT.md | 94 ++++++ .../sft-runner-starter-pack/CONTRIBUTING.md | 29 ++ .../agents/sft-runner-starter-pack/Dockerfile | 39 +++ python/agents/sft-runner-starter-pack/LICENSE | 201 ++++++++++++ .../agents/sft-runner-starter-pack/README.md | 308 ++++++++++++++++++ .../sft-runner-starter-pack/SECURITY.md | 7 + .../sft-runner-starter-pack/__init__.py | 0 python/agents/sft-runner-starter-pack/app.py | 305 +++++++++++++++++ .../sft-runner-starter-pack/cloudbuild.yaml | 58 ++++ .../agents/sft-runner-starter-pack/deploy.sh | 26 ++ .../sft-runner-starter-pack/one_time_setup.sh | 51 +++ .../sft-runner-starter-pack/package-lock.json | 6 + .../sft-runner-starter-pack/pyproject.toml | 57 ++++ .../sft-runner-starter-pack/requirements.txt | 21 ++ .../sft_runner_arch.png | Bin 0 -> 138565 bytes .../sft-runner-starter-pack/src/__init__.py | 18 + .../sft-runner-starter-pack/src/agent.py | 146 +++++++++ .../sft-runner-starter-pack/src/config.py | 87 +++++ .../src/data/eval_queries.csv | 9 + .../src/data/sample_eval_queries.csv | 11 + .../src/data/seed_queries.csv | 6 + .../src/data/test_data.csv | 6 + .../sft-runner-starter-pack/src/prompts.py | 78 +++++ .../src/sub_agents/__init__.py | 9 + .../src/sub_agents/data_generator/__init__.py | 14 + .../src/sub_agents/data_generator/agent.py | 37 +++ .../src/sub_agents/data_generator/prompt.py | 30 ++ .../src/sub_agents/data_generator/tool.py | 259 +++++++++++++++ .../src/sub_agents/evaluator/__init__.py | 14 + .../src/sub_agents/evaluator/agent.py | 42 +++ .../src/sub_agents/evaluator/prompt.py | 16 + .../src/sub_agents/evaluator/tool.py | 234 +++++++++++++ .../src/sub_agents/fine_tuner/__init__.py | 14 + .../src/sub_agents/fine_tuner/agent.py | 42 +++ .../src/sub_agents/fine_tuner/prompt.py | 16 + .../src/sub_agents/fine_tuner/tool.py | 82 +++++ .../src/utils/__init__.py | 0 .../src/utils/mcptoolbox_client.py | 116 +++++++ .../agents/sft-runner-starter-pack/tools.yaml | 31 ++ 40 files changed, 2520 insertions(+) create mode 100644 python/agents/sft-runner-starter-pack/CHANGELOG.md create mode 100644 python/agents/sft-runner-starter-pack/CODE_OF_CONDUCT.md create mode 100644 python/agents/sft-runner-starter-pack/CONTRIBUTING.md create mode 100644 python/agents/sft-runner-starter-pack/Dockerfile create mode 100644 python/agents/sft-runner-starter-pack/LICENSE create mode 100644 python/agents/sft-runner-starter-pack/README.md create mode 100644 python/agents/sft-runner-starter-pack/SECURITY.md create mode 100644 python/agents/sft-runner-starter-pack/__init__.py create mode 100644 python/agents/sft-runner-starter-pack/app.py create mode 100644 python/agents/sft-runner-starter-pack/cloudbuild.yaml create mode 100755 python/agents/sft-runner-starter-pack/deploy.sh create mode 100755 python/agents/sft-runner-starter-pack/one_time_setup.sh create mode 100644 python/agents/sft-runner-starter-pack/package-lock.json create mode 100644 python/agents/sft-runner-starter-pack/pyproject.toml create mode 100644 python/agents/sft-runner-starter-pack/requirements.txt create mode 100644 python/agents/sft-runner-starter-pack/sft_runner_arch.png create mode 100644 python/agents/sft-runner-starter-pack/src/__init__.py create mode 100644 python/agents/sft-runner-starter-pack/src/agent.py create mode 100644 python/agents/sft-runner-starter-pack/src/config.py create mode 100644 python/agents/sft-runner-starter-pack/src/data/eval_queries.csv create mode 100644 python/agents/sft-runner-starter-pack/src/data/sample_eval_queries.csv create mode 100644 python/agents/sft-runner-starter-pack/src/data/seed_queries.csv create mode 100644 python/agents/sft-runner-starter-pack/src/data/test_data.csv create mode 100644 python/agents/sft-runner-starter-pack/src/prompts.py create mode 100644 python/agents/sft-runner-starter-pack/src/sub_agents/__init__.py create mode 100644 python/agents/sft-runner-starter-pack/src/sub_agents/data_generator/__init__.py create mode 100644 python/agents/sft-runner-starter-pack/src/sub_agents/data_generator/agent.py create mode 100644 python/agents/sft-runner-starter-pack/src/sub_agents/data_generator/prompt.py create mode 100644 python/agents/sft-runner-starter-pack/src/sub_agents/data_generator/tool.py create mode 100644 python/agents/sft-runner-starter-pack/src/sub_agents/evaluator/__init__.py create mode 100644 python/agents/sft-runner-starter-pack/src/sub_agents/evaluator/agent.py create mode 100644 python/agents/sft-runner-starter-pack/src/sub_agents/evaluator/prompt.py create mode 100644 python/agents/sft-runner-starter-pack/src/sub_agents/evaluator/tool.py create mode 100644 python/agents/sft-runner-starter-pack/src/sub_agents/fine_tuner/__init__.py create mode 100644 python/agents/sft-runner-starter-pack/src/sub_agents/fine_tuner/agent.py create mode 100644 python/agents/sft-runner-starter-pack/src/sub_agents/fine_tuner/prompt.py create mode 100644 python/agents/sft-runner-starter-pack/src/sub_agents/fine_tuner/tool.py create mode 100644 python/agents/sft-runner-starter-pack/src/utils/__init__.py create mode 100644 python/agents/sft-runner-starter-pack/src/utils/mcptoolbox_client.py create mode 100644 python/agents/sft-runner-starter-pack/tools.yaml diff --git a/python/agents/sft-runner-starter-pack/CHANGELOG.md b/python/agents/sft-runner-starter-pack/CHANGELOG.md new file mode 100644 index 000000000..825c32f0d --- /dev/null +++ b/python/agents/sft-runner-starter-pack/CHANGELOG.md @@ -0,0 +1 @@ +# Changelog diff --git a/python/agents/sft-runner-starter-pack/CODE_OF_CONDUCT.md b/python/agents/sft-runner-starter-pack/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..2add2547a --- /dev/null +++ b/python/agents/sft-runner-starter-pack/CODE_OF_CONDUCT.md @@ -0,0 +1,94 @@ + +# Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, gender identity and expression, level of +experience, education, socio-economic status, nationality, personal appearance, +race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, or to ban temporarily or permanently any +contributor for other behaviors that they deem inappropriate, threatening, +offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +This Code of Conduct also applies outside the project spaces when the Project +Steward has a reasonable belief that an individual's behavior may have a +negative impact on the project or its community. + +## Conflict Resolution + +We do not believe that all conflict is bad; healthy debate and disagreement +often yield positive results. However, it is never okay to be disrespectful or +to engage in behavior that violates the project’s code of conduct. + +If you see someone violating the code of conduct, you are encouraged to address +the behavior directly with those involved. Many issues can be resolved quickly +and easily, and this gives people more control over the outcome of their +dispute. If you are unable to resolve the matter for any reason, or if the +behavior is threatening or harassing, report it. We are dedicated to providing +an environment where participants feel welcome and safe. + +Reports should be directed to *googleapis-stewards@google.com*, the +Project Steward(s) for *Google Cloud Client Libraries*. It is the Project Steward’s duty to +receive and address reported violations of the code of conduct. They will then +work with a committee consisting of representatives from the Open Source +Programs Office and the Google Open Source Strategy team. If for any reason you +are uncomfortable reaching out to the Project Steward, please email +opensource@google.com. + +We will investigate every complaint, but you may not receive a direct response. +We will use our discretion in determining when and how to follow up on reported +incidents, which may range from not taking action to permanent expulsion from +the project and project-sponsored spaces. We will notify the accused of the +report and provide them an opportunity to discuss it before any action is taken. +The identity of the reporter will be omitted from the details of the report +supplied to the accused. In potentially harmful situations, such as ongoing +harassment or threats to anyone's safety, we may take action without notice. + +## Attribution + +This Code of Conduct is adapted from the Contributor Covenant, version 1.4, +available at +https://www.contributor-covenant.org/version/1/4/code-of-conduct.html \ No newline at end of file diff --git a/python/agents/sft-runner-starter-pack/CONTRIBUTING.md b/python/agents/sft-runner-starter-pack/CONTRIBUTING.md new file mode 100644 index 000000000..9d7656bec --- /dev/null +++ b/python/agents/sft-runner-starter-pack/CONTRIBUTING.md @@ -0,0 +1,29 @@ +# How to Contribute + +We'd love to accept your patches and contributions to this project. There are +just a few small guidelines you need to follow. + +## Contributor License Agreement + +Contributions to this project must be accompanied by a Contributor License +Agreement (CLA). You (or your employer) retain the copyright to your +contribution; this simply gives us permission to use and redistribute your +contributions as part of the project. Head over to + to see your current agreements on file or +to sign a new one. + +You generally only need to submit a CLA once, so if you've already submitted one +(even if it was for a different project), you probably don't need to do it +again. + +## Code Reviews + +All submissions, including submissions by project members, require review. We +use GitHub pull requests for this purpose. Consult +[GitHub Help](https://help.github.com/articles/about-pull-requests/) for more +information on using pull requests. + +## Community Guidelines + +This project follows +[Google's Open Source Community Guidelines](https://opensource.google/conduct/). \ No newline at end of file diff --git a/python/agents/sft-runner-starter-pack/Dockerfile b/python/agents/sft-runner-starter-pack/Dockerfile new file mode 100644 index 000000000..a2395662e --- /dev/null +++ b/python/agents/sft-runner-starter-pack/Dockerfile @@ -0,0 +1,39 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# import base image +FROM python:3.13-slim + +# set working dir +WORKDIR /app + +# copy/install packages +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# add user: optional +RUN adduser --disabled-password --gecos "" myuser && \ + chown -R myuser:myuser /app + +# copy all files from src to /app +COPY . . + +# switch user +USER myuser + +# set environment path +ENV PATH="/home/myuser/.local/bin:$PATH" + +# Run the streamlit UI +CMD ["streamlit", "run", "app.py", "--server.port", "8080", "--server.address", "0.0.0.0"] diff --git a/python/agents/sft-runner-starter-pack/LICENSE b/python/agents/sft-runner-starter-pack/LICENSE new file mode 100644 index 000000000..f49a4e16e --- /dev/null +++ b/python/agents/sft-runner-starter-pack/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/python/agents/sft-runner-starter-pack/README.md b/python/agents/sft-runner-starter-pack/README.md new file mode 100644 index 000000000..4662b2316 --- /dev/null +++ b/python/agents/sft-runner-starter-pack/README.md @@ -0,0 +1,308 @@ +# SFT Starter Pack (NL2SQL) + +## πŸ“– Overview + +The **SFT Starter Pack (NL2SQL)** is an AI-native system built using the **Agent Development Kit (ADK)** on Google Cloud. It is designed to automate the complex, iterative engineering loop of fine-tuning Large Language Models (LLMs). + +As enterprise Generative AI needs shift from proof-of-concept to production, standard prompting is often insufficient. Fine-tuning is required but is traditionally a manual, resource-intensive bottleneck. This project reimagines that workflow: instead of a human manually managing data generation, training jobs, and evaluation, this Agent autonomously plans and executes tasks to achieve a specific accuracy goal. + +This solution, as of today, supports NL2SQL use cases on BigQuery Google SQL. In future, it can and will be extended to +any other applicable fine tuning use cases. + +### Core Philosophy + + * **From Manual to Autonomous:** Moves from step-by-step human execution to goal-oriented agentic execution. + * **Reasoning Engine:** Uses Gemini to plan, execute, and iterate. + * **Self-Correction:** If target accuracy is not met, the agent autonomously decides to generate more data, refine the dataset, or retune parameters. + +## πŸ— Architecture + +## 🧠 Agent Architecture & Workflow + +This system utilizes a **Hierarchical Multi-Agent Architecture**. A central "Orchestrator" receives user intent and delegates execution to specialized sub-agents. This separation of concerns allows for modular improvements to individual components (e.g., upgrading the Data Generator) without breaking the overall pipeline. + +### 1\. The Orchestrator (`ROOT_PROMPT`) + +The Orchestrator acts as the intelligent router. It does not perform technical tasks itself; rather, it analyzes the user's natural language request to determine the correct execution path. + +It manages a team of four distinct agents: + +| Agent Name | Trigger Condition | Behavior | +| :--- | :--- | :--- | +| **`full_pipeline_agent`** | "Run full pipeline", "End-to-end" | **Sequential Execution:** Chains Data Gen $\to$ Fine-Tuning $\to$ Evaluation automatically. | +| **`data_generator_agent`** | "Generate data" | **Immediate Delegation:** Calls the specialist agent directly. | +| **`fine_tuner_agent`** | "Fine-tune a model" | **Interactive Delegation:** First *asks* the user for the Training Data GCS Path, then delegates. | +| **`evaluator_agent`** | "Evaluate a model" | **Interactive Delegation:** First *asks* the user for the Model Endpoint, then delegates. | + +### 2\. Specialist Sub-Agents + +Each sub-agent is a self-contained unit with access to specific ADK tools and a shared state context (configuration variables). + +#### πŸ”Ή Data Generator Agent + + * **Role:** Synthesizes training data for fine-tuning. + * **Tool Used:** `generate_synthetic_data` + * **Operational Flow:** + 1. Receives control from the Orchestrator. + 2. **State Extraction:** It automatically pulls required configuration (`project_id`, `bq_dataset_id`, `seed_data_path`, `target_examples`) from the agent state. + 3. **Execution:** It calls the synthesis tool to generate examples and save them to GCS. + 4. **Output:** Returns the path to the newly generated dataset. + +#### πŸ”Ή Fine-Tuner Agent + + * **Role:** Manages the Vertex AI Supervised Fine-Tuning (SFT) job. + * **Tool Used:** `fine_tune_model` + * **Operational Flow:** + 1. **Human-in-the-Loop:** If triggered directly, it pauses to request the `training_dataset_gcs_path` from the user. + 2. **State Extraction:** Pulls infrastructure config (`base_model`, `project_id`, `gcp_location`) from the state. + 3. **Execution:** Submits the SFT job to Vertex AI. + 4. **Output:** Returns the Job ID and status. + +#### πŸ”Ή Evaluator Agent + + * **Role:** Scores the fine-tuned model against the ground truth. + * **Tool Used:** `evaluate_model` + * **Operational Flow:** + 1. **Human-in-the-Loop:** If triggered directly, it pauses to request the `model_endpoint_resource_name` from the user. + 2. **State Extraction:** Pulls test config (`eval_dataset_path`, `gcs_bucket_name`) from the state. + 3. **Execution:** Runs inference against the test set and calculates metrics. + 4. **Output:** Returns an evaluation report (Accuracy, Precision, Recall, etc.). + +### πŸ”„ Execution Flow Diagram + +The following diagram illustrates how the Orchestrator routes requests based on the prompts defined in `src/prompts.py`: + +![Agent Architecture](sft_runner_arch.png) + +*(Figure 1: Orchestrator routing logic and agent delegation flow)* + +### The Sub-Agents + +1. **Data Generator (`src/sub_agents/data_generator`):** Analyzes seed data (20-50 examples) and employs strategies to generate high-quality, scaled-up datasets (thousands of examples). +2. **Fine-Tuner (`src/sub_agents/fine_tuner`):** Programmatically initiates and monitors Supervised Fine-Tuning (SFT) jobs on Vertex AI (e.g., Gemini Flash). +3. **Evaluator (`src/sub_agents/evaluator`):** Tests the fine-tuned model against ground truth data, generates metrics, and provides actionable feedback to the Orchestrator. + +## βœ… Prerequisites & Assumptions + +Before running the agent, ensure the following data prerequisites are met: + + * **Mandatory:** A "Seed Set" of **20-30 input-output examples**. The agent uses this to understand the domain and style for synthetic generation. + * **Recommended:** A "Ground Truth Set" of **30-50 questions/answers** for evaluation. + * **Google Cloud Project:** A GCP project with Vertex AI API enabled. + +## πŸš€ Getting Started + +### 1\. Installation + +This project uses modern Python tooling. Ensure you have Python 3.10+ installed. + +```bash +# Clone the repository +git clone +cd + +# Install uv if you haven't already +pip install uv + +# Sync dependencies creates the venv and installs packages automatically +uv sync + +# Activate the environment +source .venv/bin/activate +``` + +### 2\. Infrastructure Setup + +We provide an automated script to bootstrap your Google Cloud environment. This script prepares the project for containerized deployment on Cloud Run. + +**What this script does:** + + * πŸ” **Authenticates** your local `gcloud` CLI and Application Default Credentials (ADC). + * πŸ”Œ **Enables APIs:** Cloud Run, Cloud Build, and Artifact Registry. + * πŸ“¦ **Creates Repository:** Sets up a Docker repository named `sft-runner-starter-pack` in `us-central1`. + * πŸ›‘οΈ **Configures IAM:** Grants the default Compute Engine Service Account permissions to manage Cloud Run services and write to the Artifact Registry. + +**Run the initialization script:** + +```bash +# Make the script executable +chmod +x one_time_setup.sh + +# Run the setup (You will be prompted to log in via browser) +./one_time_setup.sh +``` + +> **Note:** The script defaults to the `us-central1` region for the Artifact Registry. If you require a different region, please edit the `--location` flag in `one_time_setup.sh` before running. + +### 3\. Configuration + +1. Review `src/config.py` to set your Project ID and Region, along with path to seed data. +2. Ensure `tools.yaml` defines the specific tool definitions required for the ADK execution. + +Update `YOUR-PROJECT-ID` with your actual GCP Project ID in the `tools.yaml` file. + +## πŸ’» Usage + +### Preparing Data + +Place your datasets in the `src/data/` directory: + + * `seed_queries.csv`: Your small set of examples. + * `eval_queries.csv`: Your ground truth for testing. + +### πŸ–₯️ Running the Application + +This project includes a web-based Chat Interface built with **Streamlit**. It provides a user-friendly dashboard to interact with the Orchestrator Agent, view streaming responses, and manage session states. + +**To launch the User Interface:** + +```bash +streamlit run app.py +``` + +**What to expect:** + +1. **Browser Launch:** The command will automatically open your default web browser to `http://localhost:8501`. +2. **Initialization:** On the first run, the app will initialize the Vertex AI SDK and the ADK Runner (this may take a few seconds). +3. **Sidebar:** You will see a sidebar displaying your unique **User ID** and **Session ID**. You can click **"Start New Chat Session"** to reset the conversation context. + +**Troubleshooting:** +If the browser does not open automatically, look at the terminal output and click the `Network URL` or `Local URL` provided. + +## ☁️ Deployment (Cloud Run) + +This project uses **Google Cloud Build** to automate the deployment pipeline. The application is containerized using Docker and deployed as a serverless service on **Google Cloud Run**. + +### 1\. Pre-Deployment Configuration + +**⚠️ Important:** Before deploying, you must update the build configuration to match your Google Cloud environment. + +Open `cloudbuild.yaml` and modify the `substitutions` section at the bottom of the file: + +```yaml +# cloudbuild.yaml +substitutions: + _SERVICE_NAME: 'sft-runner-starter-pack' # Name of the Cloud Run service + _REGION: 'us-central1' # Deployment region + _AR_HOST: 'us-central1-docker.pkg.dev' # Artifact Registry Host + _REPO_NAME: 'sft-runner-starter-pack' # Must match the repo created in setup + _PROJECT_ID: 'YOUR-PROJECT-ID-HERE' # Replace with your Project ID +``` + +Additionally,, open `.env` file and update `YOUR-PROJECT-ID` with your actual GCP Project ID. + +### 2\. The Deployment Pipeline + +The `deploy.sh` script triggers a Cloud Build job that performs three specific steps: + +1. **Build:** Creates a Docker image based on `python:3.13-slim`, installing all dependencies defined in `requirements.txt`. +2. **Push:** Uploads the tagged image to your Artifact Registry. +3. **Deploy:** Releases the image to Cloud Run as a managed service on port `8080`. + +### 3\. Triggering the Deployment + +Ensure you are in the root of the git repository and have authenticated with Google Cloud. + +```bash +# Make the script executable (if not already) +chmod +x deploy.sh + +# Run the deployment +./deploy.sh +``` + +**What happens next?** + + * The script calculates the current Git Commit SHA (`SHORT_SHA`) to tag the Docker image (ensuring version control traceability). + * Logs will stream in your terminal showing the build progress. + * Upon success, the script will output a **Service URL** (e.g., `https://sft-runner-starter-pack-xyz.a.run.app`). + +### 4\. Container Details (`Dockerfile`) + +The application runs inside a secure, non-root container environment: + + * **Base Image:** `python:3.13-slim` for a lightweight footprint. + * **Security:** Runs as a non-privileged user (`myuser`). + * **Port:** Exposes port `8080` (Streamlit default). + * **Context:** Copies the local directory into the image. + +> **Note on Environment Variables:** +> Since the `Dockerfile` copies local files (`COPY . .`), your local `.env` file will be included in the container. For production environments, it is recommended to exclude `.env` via `.dockerignore` and set secrets directly in the Cloud Run configuration using Google Secret Manager. + +## πŸ“‚ Project Structure + +```text +β”œβ”€β”€ ./__init__.py +β”œβ”€β”€ ./app.py +β”œβ”€β”€ ./CHANGELOG.md +β”œβ”€β”€ ./cloudbuild.yaml +β”œβ”€β”€ ./CODE_OF_CONDUCT.md +β”œβ”€β”€ ./CONTRIBUTING.md +β”œβ”€β”€ ./deploy.sh +β”œβ”€β”€ ./Dockerfile +β”œβ”€β”€ ./LICENSE +β”œβ”€β”€ ./one_time_setup.sh +β”œβ”€β”€ ./pyproject.toml +β”œβ”€β”€ ./README.md +β”œβ”€β”€ ./requirements.txt +β”œβ”€β”€ ./SECURITY.md +β”œβ”€β”€ ./src +β”‚Β Β  β”œβ”€β”€ ./src/__init__.py +β”‚Β Β  β”œβ”€β”€ ./src/agent.py +β”‚Β Β  β”œβ”€β”€ ./src/config.py +β”‚Β Β  β”œβ”€β”€ ./src/data +β”‚Β Β  β”‚Β Β  β”œβ”€β”€ ./src/data/eval_queries.csv +β”‚Β Β  β”‚Β Β  β”œβ”€β”€ ./src/data/sample_eval_queries.csv +β”‚Β Β  β”‚Β Β  β”œβ”€β”€ ./src/data/seed_queries.csv +β”‚Β Β  β”‚Β Β  └── ./src/data/test_data.csv +β”‚Β Β  β”œβ”€β”€ ./src/prompts.py +β”‚Β Β  β”œβ”€β”€ ./src/sub_agents +β”‚Β Β  β”‚Β Β  β”œβ”€β”€ ./src/sub_agents/__init__.py +β”‚Β Β  β”‚Β Β  β”œβ”€β”€ ./src/sub_agents/data_generator +β”‚Β Β  β”‚Β Β  β”‚Β Β  β”œβ”€β”€ ./src/sub_agents/data_generator/__init__.py +β”‚Β Β  β”‚Β Β  β”‚Β Β  β”œβ”€β”€ ./src/sub_agents/data_generator/agent.py +β”‚Β Β  β”‚Β Β  β”‚Β Β  β”œβ”€β”€ ./src/sub_agents/data_generator/prompt.py +β”‚Β Β  β”‚Β Β  β”‚Β Β  └── ./src/sub_agents/data_generator/tool.py +β”‚Β Β  β”‚Β Β  β”œβ”€β”€ ./src/sub_agents/evaluator +β”‚Β Β  β”‚Β Β  β”‚Β Β  β”œβ”€β”€ ./src/sub_agents/evaluator/__init__.py +β”‚Β Β  β”‚Β Β  β”‚Β Β  β”œβ”€β”€ ./src/sub_agents/evaluator/agent.py +β”‚Β Β  β”‚Β Β  β”‚Β Β  β”œβ”€β”€ ./src/sub_agents/evaluator/prompt.py +β”‚Β Β  β”‚Β Β  β”‚Β Β  └── ./src/sub_agents/evaluator/tool.py +β”‚Β Β  β”‚Β Β  └── ./src/sub_agents/fine_tuner +β”‚Β Β  β”‚Β Β  β”œβ”€β”€ ./src/sub_agents/fine_tuner/__init__.py +β”‚Β Β  β”‚Β Β  β”œβ”€β”€ ./src/sub_agents/fine_tuner/agent.py +β”‚Β Β  β”‚Β Β  β”œβ”€β”€ ./src/sub_agents/fine_tuner/prompt.py +β”‚Β Β  β”‚Β Β  └── ./src/sub_agents/fine_tuner/tool.py +β”‚Β Β  └── ./src/utils +β”‚Β Β  β”œβ”€β”€ ./src/utils/__init__.py +β”‚Β Β  └── ./src/utils/mcptoolbox_client.py +β”œβ”€β”€ ./tools.yaml +``` + +## Contributing + +Contributions to this library are always welcome and highly encouraged. + +See [CONTRIBUTING](CONTRIBUTING.md) for more information how to get started. + +Please note that this project is released with a Contributor Code of Conduct. By participating in +this project you agree to abide by its terms. See [Code of Conduct](CODE_OF_CONDUCT.md) for more +information. + +## License + +``` +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +``` \ No newline at end of file diff --git a/python/agents/sft-runner-starter-pack/SECURITY.md b/python/agents/sft-runner-starter-pack/SECURITY.md new file mode 100644 index 000000000..8b58ae9c0 --- /dev/null +++ b/python/agents/sft-runner-starter-pack/SECURITY.md @@ -0,0 +1,7 @@ +# Security Policy + +To report a security issue, please use [g.co/vulnz](https://g.co/vulnz). + +The Google Security Team will respond within 5 working days of your report on g.co/vulnz. + +We use g.co/vulnz for our intake, and do coordination and disclosure here using GitHub Security Advisory to privately discuss and fix the issue. diff --git a/python/agents/sft-runner-starter-pack/__init__.py b/python/agents/sft-runner-starter-pack/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/agents/sft-runner-starter-pack/app.py b/python/agents/sft-runner-starter-pack/app.py new file mode 100644 index 000000000..490dea6f7 --- /dev/null +++ b/python/agents/sft-runner-starter-pack/app.py @@ -0,0 +1,305 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Streamlit UI with an in-process ADK Runner.""" + +# Import libraries +import os +import logging +import uuid +import time +import asyncio +import warnings + +# 3rd Party imports +import streamlit as st + +# Google and Vertex AI Imports +import vertexai +from google.genai import types +from google.adk.runners import Runner +from google.adk.sessions import InMemorySessionService + +# from .src.agent import root_agent +from src.agent import root_agent + +# Filter unwanted warnings +warnings.filterwarnings("ignore") + +# Global Env variables +_PROJECT_ID = os.environ.get("PROJECT_ID") +_REGION = os.environ.get("GCP_LOCATION", "us-central1") +os.environ["GOOGLE_GENAI_USE_VERTEXAI"] = "True" + +# Logging Setup +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + + +# Core Agent Logic & Initialization +@st.cache_resource +def initialize_agent(): + """ + Initializes the Vertex AI SDK, session service, and the ADK runner. + This function is cached to prevent re-initialization on every user interaction. + """ + logger.info("Application starting up... Initializing Vertex AI and ADK Runner.") + try: + vertexai.init(project=_PROJECT_ID, location=_REGION) + logger.info("Vertex AI SDK initialized.") + + session_service = InMemorySessionService() + logger.info("Session service created using InMemorySessionService.") + + app_name = "finetunagent-streamlit" + runner = Runner( + agent=root_agent, app_name=app_name, session_service=session_service + ) + logger.info(f"Runner created for app: '{app_name}'") + + return runner, session_service + except Exception as e: + logger.error(f"Failed to initialize agent: {e}", exc_info=True) + st.error( + f"Fatal Error: Could not initialize the AI Agent. Please check the logs. Error: {e}" + ) + st.stop() + + +async def _run_agent_async_task( + runner: Runner, user_id: str, session_id: str, query: str +) -> str: + """ + Internal asynchronous function to run the agent. + Handles the case where runner.run returns a synchronous generator. + """ + logger.info( + f"Executing async agent task for user '{user_id}' in session '{session_id}' with query: '{query[:50]}...'" + ) + content = types.Content(role="user", parts=[types.Part(text=query)]) + + try: + # Ensure session exists + session = await runner.session_service.get_session( + app_name=runner.app_name, user_id=user_id, session_id=session_id + ) + + if not session: + logger.info(f"Session '{session_id}' not found. Creating a new one.") + await runner.session_service.create_session( + app_name=runner.app_name, user_id=user_id, session_id=session_id + ) + + # Run the agent logic + events_generator = runner.run( + user_id=user_id, session_id=session_id, new_message=content + ) + + final_answer = None + + # Iterate over the synchronous generator + for event in events_generator: + if event.is_final_response() and event.content: + if event.content.parts and event.content.parts[0].text: + final_answer = event.content.parts[0].text.strip() + logger.info("Final answer extracted.") + else: + logger.warning("Received final response with empty content parts.") + break # Stop after the final response + + return final_answer + + except Exception as e: + logger.error("An error occurred during agent execution", exc_info=True) + raise e + + +# Synchronous Wrapper for Agent Calls +def call_agent_sync(runner: Runner, user_id: str, session_id: str, query: str) -> str: + """ + Synchronously calls the agent runner by managing the asyncio event loop. + This function is responsible for ensuring the event loop is correctly + managed for each call, especially in a Streamlit rerun context. + """ + loop = None + try: + # Get the existing event loop + loop = get_or_create_managed_loop() + + # Run the asynchronous task + logger.info(f"Running agent task on managed loop for session '{session_id}'") + final_answer = loop.run_until_complete( + _run_agent_async_task(runner, user_id, session_id, query) + ) + return final_answer + + except Exception as e: + logger.error("Exception in call_agent_sync", exc_info=True) + raise e + + +# Event Loop Management using Streamlit Session State +def get_or_create_managed_loop(): + """ + Retrieves or creates an asyncio event loop and stores it in st.session_state. + This ensures a consistent loop is used across reruns within a single user session + and helps in managing its lifecycle. + """ + if ( + "asyncio_event_loop" not in st.session_state + or st.session_state.asyncio_event_loop is None + or st.session_state.asyncio_event_loop.is_closed() + ): + logger.info("Creating or resetting asyncio event loop in session state.") + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + st.session_state.asyncio_event_loop = loop + + # Store a flag for managing this loop + st.session_state.loop_is_managed = True + + return st.session_state.asyncio_event_loop + + +def close_managed_loop(): + """ + Safely closes the asyncio event loop stored in session state if it's being managed. + """ + if "loop_is_managed" in st.session_state and st.session_state.loop_is_managed: + if ( + "asyncio_event_loop" in st.session_state + and st.session_state.asyncio_event_loop + ): + loop = st.session_state.asyncio_event_loop + if not loop.is_closed(): + logger.info("Closing managed asyncio event loop.") + try: + loop.close() + except Exception as e: + logger.error(f"Error closing asyncio loop: {e}", exc_info=True) + st.session_state.asyncio_event_loop = None # Clear the reference + st.session_state.loop_is_managed = False # Reset flag + + +# Streamlit Page Configuration --- +st.set_page_config( + page_title="SFT Runner Starter Pack", + page_icon="πŸ€–", + layout="centered", + initial_sidebar_state="expanded", +) + +# Initialize Agent (runs once and is cached) +runner, session_service = initialize_agent() + +# Custom CSS +st.markdown("""""", unsafe_allow_html=True) # Your CSS here + + +# Helper Function for Typing Animation +def stream_text(text): + for word in text.split(): + yield word + " " + time.sleep(0.05) + yield "" + + +# Session State Initialization +if "user_id" not in st.session_state: + st.session_state.user_id = f"user_{str(uuid.uuid4())}" +if "session_id" not in st.session_state: + st.session_state.session_id = f"session_{str(uuid.uuid4())}" +if "messages" not in st.session_state: + st.session_state.messages = [] + +# Streamlit App UI +st.title("πŸ€– SFT Runner Starter Pack") + +# Sidebar for Configuration and Session Management +with st.sidebar: + st.header("Chat Settings") + st.info("Your unique identifiers help the AI agent maintain context.") + st.markdown(f"**User ID:** `{st.session_state.user_id}`") + st.markdown(f"**Session ID:** `{st.session_state.session_id}`") + + if st.button("πŸ”„ Start New Chat Session"): + # Close the current managed loop before starting a new session + close_managed_loop() + + # Reset session state for a new chat + st.session_state.session_id = f"session_{str(uuid.uuid4())}" # New session ID + st.session_state.messages = [] + st.success("New chat session started!") + st.rerun() # Rerun the app + + st.markdown("---") + st.caption("Powered by Streamlit & Google ADK") + +# Main Chat Interface +for message in st.session_state.messages: + with st.chat_message(message["role"]): + st.markdown(message["content"]) + +# Chat Input and Agent Interaction +if prompt := st.chat_input("Ask me anything..."): + # Add user message to chat history + st.session_state.messages.append({"role": "user", "content": prompt}) + with st.chat_message("user"): + st.markdown(prompt) + + # Display assistant response + with st.chat_message("assistant"): + message_placeholder = st.empty() + try: + # Call the agent logic + final_answer = call_agent_sync( + runner=runner, + user_id=st.session_state.user_id, + session_id=st.session_state.session_id, + query=prompt, + ) + + if final_answer: + full_response = "" + stream_generator = stream_text(final_answer) + for chunk in stream_generator: + full_response += chunk + message_placeholder.markdown(full_response + "β–Œ") + message_placeholder.markdown(full_response) # Final message + + # Store the final response + st.session_state.messages.append( + {"role": "assistant", "content": full_response} + ) + else: + warning_message = "The agent did not provide a final answer." + message_placeholder.warning(warning_message) + st.session_state.messages.append( + {"role": "assistant", "content": warning_message} + ) + + except Exception as e: + # Log the detailed error + logger.error( + f"Error in Streamlit chat interface during agent call: {e}", + exc_info=True, + ) + + # Display error + error_message = f"❌ An error occurred while processing your request. Please check the logs for details." + message_placeholder.error(error_message) + st.session_state.messages.append( + {"role": "assistant", "content": error_message} + ) diff --git a/python/agents/sft-runner-starter-pack/cloudbuild.yaml b/python/agents/sft-runner-starter-pack/cloudbuild.yaml new file mode 100644 index 000000000..f88c66e53 --- /dev/null +++ b/python/agents/sft-runner-starter-pack/cloudbuild.yaml @@ -0,0 +1,58 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +steps: + # 1. Build the container image + - name: 'gcr.io/cloud-builders/docker' + args: + [ + 'build', + '-t', + '${_AR_HOST}/${_PROJECT_ID}/${_REPO_NAME}/${_SERVICE_NAME}:$SHORT_SHA', + '.', + ] + + # 2. Push the image to Artifact Registry + - name: 'gcr.io/cloud-builders/docker' + args: + [ + 'push', + '${_AR_HOST}/${_PROJECT_ID}/${_REPO_NAME}/${_SERVICE_NAME}:$SHORT_SHA', + ] + + # 3. Deploy the image to Cloud Run + - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk' + entrypoint: gcloud + args: + [ + 'run', + 'deploy', + '${_SERVICE_NAME}', + '--image=${_AR_HOST}/${_PROJECT_ID}/${_REPO_NAME}/${_SERVICE_NAME}:$SHORT_SHA', + '--region', + '${_REGION}', + '--platform', + 'managed', + ] + +# Make the built image available for the deploy step +images: + - '${_AR_HOST}/${_PROJECT_ID}/${_REPO_NAME}/${_SERVICE_NAME}:$SHORT_SHA' + +# Define variables that can be passed in +substitutions: + _SERVICE_NAME: 'sft-runner-starter-pack' + _REGION: 'us-central1' + _AR_HOST: 'us-central1-docker.pkg.dev' + _REPO_NAME: 'sft-runner-starter-pack' + _PROJECT_ID: 'YOUR-PROJECT-ID' diff --git a/python/agents/sft-runner-starter-pack/deploy.sh b/python/agents/sft-runner-starter-pack/deploy.sh new file mode 100755 index 000000000..35d2ce45f --- /dev/null +++ b/python/agents/sft-runner-starter-pack/deploy.sh @@ -0,0 +1,26 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Get latest commit ID +export COMMIT_SHA=$(git rev-parse --short HEAD) + +# Check if the command worked +if [ -z "$COMMIT_SHA" ]; then + echo "Error: Could not get commit SHA." + echo "Make sure you are running this script from within a git repository." + exit 1 +fi + +# Finally, deploy +gcloud builds submit --config cloudbuild.yaml --substitutions=SHORT_SHA=$COMMIT_SHA diff --git a/python/agents/sft-runner-starter-pack/one_time_setup.sh b/python/agents/sft-runner-starter-pack/one_time_setup.sh new file mode 100755 index 000000000..e50e63745 --- /dev/null +++ b/python/agents/sft-runner-starter-pack/one_time_setup.sh @@ -0,0 +1,51 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# global values +export PROJECT_NUMBER=$(gcloud projects describe $(gcloud config get-value project) --format='value(projectNumber)') +export PROJECT_ID=$(gcloud projects describe $(gcloud config get-value project) --format='value(projectId)') +export COMPUTE_SA=$PROJECT_NUMBER"-compute@developer.gserviceaccount.com" + +# Set project and auth +gcloud auth login +gcloud auth application-default login +export GOOGLE_APPLICATION_CREDENTIALS="~/.config/gcloud/application_default_credentials.json" +gcloud config set project $PROJECT_ID + +# Enable required services +gcloud services enable \ + run.googleapis.com \ + cloudbuild.googleapis.com \ + artifactregistry.googleapis.com + +# Create arfifact registry repository +gcloud artifacts repositories create sft-runner-starter-pack \ + --repository-format=docker \ + --location=us-central1 \ + --description="Docker repository for SFT Runner Starter Pack" + +# Grant Permissions to the Compute Engine SA +echo "Granting roles to $COMPUTE_SA..." + +gcloud projects add-iam-policy-binding $PROJECT_ID \ + --member="serviceAccount:$COMPUTE_SA" \ + --role="roles/run.admin" + +gcloud projects add-iam-policy-binding $PROJECT_ID \ + --member="serviceAccount:$COMPUTE_SA" \ + --role="roles/artifactregistry.writer" + +gcloud projects add-iam-policy-binding $PROJECT_ID \ + --member="serviceAccount:$COMPUTE_SA" \ + --role="roles/iam.serviceAccountUser" diff --git a/python/agents/sft-runner-starter-pack/package-lock.json b/python/agents/sft-runner-starter-pack/package-lock.json new file mode 100644 index 000000000..b6ded9475 --- /dev/null +++ b/python/agents/sft-runner-starter-pack/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "sft-runner-starter-pack", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/python/agents/sft-runner-starter-pack/pyproject.toml b/python/agents/sft-runner-starter-pack/pyproject.toml new file mode 100644 index 000000000..b14392f30 --- /dev/null +++ b/python/agents/sft-runner-starter-pack/pyproject.toml @@ -0,0 +1,57 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "sft-runner-starter-pack" +version = "0.1.0" +description = "Supervised Fine Tuning Starter Pack, using Gemini and ADK." +authors = [ + { name = "Yogesh Kothari", email = "yogeshkothari@google.com" }, + { name = "Nishant Welpulwar", email = "nishantw@google.com" }, + { name = "Suddhasatwa Bhaumik", email = "suddhasatwa@google.com" }, +] +readme = "README.md" +requires-python = ">=3.9" +license = { text = "Apache-2.0" } +dependencies = [ + "python-dotenv", + "python-multipart", + "google-cloud-aiplatform", + "google-generativeai", + "google-adk", + "google-cloud-storage", + "db-dtypes", + "google-api-python-client", + "google-auth-httplib2", + "google-auth-oauthlib", + "vertexai", + "fsspec", + "gcsfs", + "aiohttp", + "click", + "grpcio", + "google-cloud-aiplatform[adk,agent_engines]", + "google-cloud-aiplatform[evaluation]", + "pandas", + "pydantic", + "opentelemetry-api", + "opentelemetry-sdk", + "streamlit", + "sseclient", + "sqlalchemy", +] diff --git a/python/agents/sft-runner-starter-pack/requirements.txt b/python/agents/sft-runner-starter-pack/requirements.txt new file mode 100644 index 000000000..251448161 --- /dev/null +++ b/python/agents/sft-runner-starter-pack/requirements.txt @@ -0,0 +1,21 @@ +google-cloud-aiplatform>=1.25.0 +google-cloud-bigquery>=3.4.0 +pandas>=1.5.3 +db-dtypes>=1.1.1 +google-api-python-client>=2.84.0 +google-auth-httplib2>=0.1.0 +google-auth-oauthlib>=1.0.0 +python-dotenv>=1.0.0 +google-cloud-storage>=2.10.0 +vertexai +fsspec +gcsfs +aiohttp +pydantic +click +grpcio +google-cloud-aiplatform[adk,agent_engines] +google-cloud-aiplatform[evaluation] +pandas +sqlalchemy +streamlit diff --git a/python/agents/sft-runner-starter-pack/sft_runner_arch.png b/python/agents/sft-runner-starter-pack/sft_runner_arch.png new file mode 100644 index 0000000000000000000000000000000000000000..c4635276533b26580ae78ff20333f12cfc3108c4 GIT binary patch literal 138565 zcmeFZc{tSH`#)|AC0j~aMyIl09VKcY{eq39ZO3S+dK%4JA~vui5u) z7+EJX#(dAvGG4Df@AvinUcWzne|+kaXU{q3KKFf}``qVo-{(H_@XBQs`aSG>$jHd( zRWF>qMn<-)AN&Z>QiC_bsakL{vb~orm6flkDl79`ak4kJv@s(iyYTR(4vntnXVy5q zYpS$NLH;jVqIs@SUf7+u>xlu?)!nN8S4=L^o^W%y`o1ujR@>#k$8$P5imV)NW>@F3 z&GHi7y%Wg}*7szgn0}YzV~?~)Z(!oZ2{F#-ua)j(MlO49zmvO8=kH^QkP|ueL|%;l zrTQ#2yCO@Azo5fG+5;!;E?qj}Z359opKkhk9c46;FHs1T9 zkWmPEwcfpxp5$a_*~W#<_kX(X!S%7sX`1Jw#EF>XI^h#BiFFY)Hv2|Bm|)cYmsn`W zZ;;(tI}@jW?CV{by@!My!XF*E7DUOj*3cY(^sWc_$D$VMrq5SqoKGU#K4YWJ>vB^R zpGZ9J%-g%)>B-r{4J===u=d3&HQzX=I}9AUmfx0grL?|^+J`x(o=X#@A1!WsoOUP3 zl}yp`;hk!O`!~LQ$vs;rZzXvByHOJv-RR<@3Xe@5h`>IobBZ=pjwz?0C!AVRq%`-v zeur3B7Cf?RgDPnCa5l5x-7y;5(1z4n`bKX)&89JirDm!na>#XT{L9jZw+af=SEKtYgs!C2sTS)V(#yyQeD>{(w4tHE-#X z=Y{?Re4J11mARfpo82%gCd1>OF4JmG?eP}czxM~D>3gr*Yj&egLUqN&oQ|G`Q<0N{4rUT<4(}fA2lv|IDQ);K=tw zC!hIw9P!tpHT@Kz)k#=bKfFd)bN|vfPZDR;)5b&Zj!?4%1Q~y1dd+1Qgd0)tI8s4f zJ$u*LAtzA^*~1dYBty%%yL)%+y*^F;+9hU#%Ve=vV7EnKY=%=b#Sfy;9z1Be&cu^V zALtQ$XzHnv(M<<)_fJdIcx|)$@@H`N%s#94sOOJ0?SFCd(LTjIF6nFS2JLX&b1zXj z%Smezrcy%{BXLicmyutPv_;H(!g&JanSk9)Gqle!p+O4LX3WzSPwSBRgsoyQgq8brFzRZ?xbH6=e$ZJoAmUl+$(p!W zPfXmmV$jj2{oeTAzr2|&l8nxXZp@LKyPAf|--wCfwGzbza?J$HH;34U_j^TYgsAuv z_GVI`l=yr0xcRexmzN@ASE7?5Z}JZwK3T$muI_B#6&GOeo!v)~>E68-=A$P5k-XYL zl%sbYm|#Z^Jv=-5Wd9Ma&u88Q?Rym@KGbq)i<$nB}cFy6>UT zYx-6ebnSi<2FKHm(vISekVUDyfFD6UD!fnkUSK$4cNw+m;YSB*qxY#ZUp2p~ea-D!)bWU8`Y#x- z+oscZMnow#CW^e#zJhw2`quqz&Z$$UzDiV_GLbmKPIv0WsWt1Ec8`R`&=lqT)WtiN zr}%Am*-Fk!&7Yn>5PP{eg{DPwX4lGrm77>qEF7z+o|5wXsG;T?X~X=B`AW-=Ch4f} z8`ln>Oc8x_XyS3eW3|WJ!jHlWSvAxWBs4NEqH?>`vlMJo)}-d|%=^qw^m-RGTf^E9 z&*-A4P(d!NsJRyDx645f#e^f}Ql69sl|J{3`j)KLQP?_xnw$=w@y+SW?#oC`no5ay zBdM8laUsY+fX?(r{*O$#u8UpdUG!bJt93~wNgtEKL((CxW*r>(RxCNz1b+<=$9As{ zapcn9rFWv&<~YUPFaD8!p5Bz+;*gz~oNbTzP4|N4J0sI=tu*HeCimFCan!Okp6)g7 zFz>LiG2yfl5+A(d(^gV{&upL3Gt+lX<7ukJ$J;xaubY%Ltwhe)+sL1eh^@~yiZ{VT zvAw$g>Ud*ObYV1Hv{+2z6!i3vg>Um+tM_@I3Un=6ty--ut)$;Og_^}qK(+S^hH4dc zQfK6A+h^KlKF^BKmAnbdZ!I3_+yAZOTYq74AGYsuaaK{Kxk|xU&qS|nQMpZ6Z+V|Z zpUyW!Cq*tf&La%bTrzb`bsiy4L*H^cw?CcyFhK7(dm)P2{$VjFR?Bso7tXD z)8R8UcJSQvwF#%jmuU7;nW4hZtI5#;(LJ`oeZuZ3 zI4TT3Itv{yCnrD7y~;fh7alhg7cFP+jek?-EQ zdsz(SpCu>h)(bZV9h$$Fj->M5VP0f@$;`+%&o}Y3k)>t#2us)H5)QKi{2^A4sarRu z3tKTJHYO?c8~fEBhdj)B1m7nqVt2X!n}_*E#}E0%92N=|?W6sw1?tz279ACP*7nTm z`QQ;6{v1_wjFj7G|HFftdo_<7SAYAa<4uSDTln+40$K4LFDK))pShX7YaD;|yjepm z=9%Qgr|Wgs-**Je+*=u6k)MI=YQ1ZvWJnD&)jXf`M4+F?M`RCE7K>{zyw>`ohB#cj z`?f;EQsd3&&e-{Q{kQ?X5!Dm?Q0EU$MUxeuLz)I+`{E;FilOl_@o|2->3oeZvmlxi19BX%4K~JjOHS}A|eP`~k6HNmOjIScZa$J$M8@0>jtoKCHZd}lh zEdM@bGBqku>nD8CprfM4WAScn7;-m0CHrjtSpz5Mht1Ej)E1MUCC@k9im<)4R(k!q zSK`;w%!;`&r;#PPp-^1WXy4|6SAxAdUYY&x9+g}yo2|1VbV^tkbnnmdEO7hK`@nV1 zH5s*e6XuFvcPd`W;z;9o!675aD;Rb9rroW=MN>1kiPiM#ba?dR)53i#=J|J)Uka1! z%@(uU3-@7tqi^?R%_sJ8q-SL`xHGx$M|->zx19R6AT`v9KH0BTgle~&K{rK}L}?br z6&89wUdzl>M!j$y@NQo-*i@LtrFhk2=W)8YFKYq#FZe3_lg*kCN@kjyj7|()ggdKa zDZuWM=`Yk|J{YWg)38Cb2gBz_*uhw-+}I#R?l&zmVlzbMaBiWeF|Qix{E(d zyGI391ST}pf(- z^SWSwPC3B|mr)2!S=dBvbVCMTASFEXmP41(bGUJg2JGvP3s~L7&uGo%si0 z8Cjqu8P(4=m%(r1pC{ml*k}7U<+BH5G~j>xz>h})#jn=8`V%OBKMR4+$dojdRaL=n zO;aZ`Gdt%y_AbUxKM}x--3}LSIFpgF@)Li^Rj(cS4(Rt-Ue|TeRlg)-YHurIeB0i{ zOvJ<1fk=l;&O-(~+M2l-^LW_W*g4C1$n$QskO9xc*HB)bttKwk^1Qn0S9p}|oy>Sn zi-?Md@+$1%;o*^Ux@|6V?d^fu3FA!PRjPSU{Du@KNI$|GwFvvJIX6vn=_9q>)GU4kwBq4+J`7GMmb0vx5>cb~NCY0CrOK4vg(p6NvuS=%38I^V< zbUK3f1n-62QO8t;cuof;1Rd)qcy*g|Pf5GSBQfdCCJyd~)=d^j?^tg?@3_+9TbT33 zy;0^B^95sWg6PF(YEZ+)#YDMJ#wVX~O1)UHTutEaP(Gf(eUW~O20ULRU%g@_1xuq@ zy|O1Q@LJ8fl05jODF1@JiE%bD%d6*Pb2YpnT-XxTWjMLMzScv5>0glhw0=5J749(1 zv_QWQ{F&`8B@Il{&r>1tdJWVb=Q%QUuH)YF8}N4=Iz-d-e6#XK+`-NOuPcnlC;TM{ z@b|b6TYq`uX`?P5Io(?jwVpWK{~Jp!=jFc&J3wW}WXLI~Xqk8v{mFJd z94hm#hEMZl`u+7&o|Yj0>PlvFn#2FhY`?wZw;d>RZJl6* zmS#Q;b)DehJjw_5_w~5z|5uxG8gWy;`c=?2;+EI?MMlO+F^1=fBU#gOd% zww*P%0B7#KzR|DdCoW;~sL%i;Gy;%t_x}MTC@4kk6`*05;Lwx*JANV=c?XhFTwWFJ zu9nXki7St^4>NnZd$%y*%D_U;z*zzPkKpcKgzAKv8J&0cXFWZ|Cc?n^MKb&U z27m!1iI_wgiy*vR_y}bm+R%+l?RS%L=uumS=T}F-MERxDtUf`FBtv}-$_DJp$D@Nq za*$rA*L})WjsqyWSH0&LSi*0}`iw;nUC!}XjZTSjAGtjB`7;pz3KFt~9{+*>dx2r> zLYrCK64*jX?Np(1qscQH%gw40_-U1PZZ`;`l(v6KyZB=He37@zO0G_m8Nu!14$L%m zfT<+ReQq@dEc-0F@oIdBriN{$FuN!>LPc(rnF6!8w>WMe$tp;=N{;YO>Ss7lB$EtK zT8BgBaqvpBEmWC=GGcKzZGXMwq}L??*O~wxlNH~eWEE5mzh0A-Pm1~OTvr?(JXRdQ z48~AA?l3275Rd$Rzx^4%*~J6hQ^GtB@x{4#77_-GDkuO~p_OsqWr8ah8Ko@xg_^cs zMS4y~os@&~R8;5?QCV6d@VvyrRHC8jI8?EV3cbu)Vt{{W~RU;$wXn6Ny7jxGi;cJRZR2I;_ydw}S&YhIUu=v}*hP0C{O z+pMsUCG0#7kZuo@Fex04+Rs!X`F38HsF&Oeio?jDnve&8dwM0=y`=5V9|N675>O|> z%Q9XbhlMW}9%pb-U>rk=2Y1YoDp1Sw-q3SIu7UxfV5$B)fbJkuK@EvOibnT<6wC}| zB1BaRg6HhVA4_@<|9{2c@5yX?n;O^q@~CA!E#QUk&~5!AB(Ppl4GbJU%oR@Dc&_a$ z$+&wOw6aZ;JxVHi-C-bly%dTcyp%Z(L?6%Hi@ZvKdC!=~utRhSAiDJm128*s3JQ0L zmIuH@>0RqdN350t1B@NAs01$+1%O4wa)!T`yg01z<8YS}sbv%#1=bKUh#*>l;&3)~ zFB4NqaUwdB)P4^I0t?_A%Zvaor{93Vp4XiGaej9{4eX=L1yZZ%0)u9?%8C-DqV4Zg z%Il4VmvdU9t=@VajM6-f-bbT~(EcamLv4Qm_ z_sfvYPbzIHFl=iI<50uc?tX7+8}=Ow$N=!^Ep~}r6%1m$$3@HmHa;BCj$iO2tPBzG zLo=Suew(MiFZd1N+UV^)YFt*t>fdO$L~Q zdsU<=OdOw4V)VM=^+hvYW_7V>VSh=nJVoe^1vCN{P@rs)7w|ENCJ^dL!p`dN?T2k7 zGInx#ub6V@uQN0mGg4rr6MBjE@JoACv>jl|?{C>w*6zK>9fn-H_1i1ap3N)w+ch*I zZaIBEouXG{qv}3r8djiExY1BtwVJi^GRIc}KW72c^B4@Vb4hZEwu0zrWAr^n?$2l! zmuZy^;n5LD0)8R2Jp(RMtWMW&?_S=QESO!wiyx+eUFfr~f_o`Sm=YevZ=Q;E?Ygmw z_cU~TJa6Zgzfw7SNOH(H(4{#Yf6xz8K2x#Wq}Ndt+3MHV*KeL^yT0n^89lqnp%q5J z5H>W*CZnv|xX=!PE7Si`Wf#boIx)fGl0VR-P|>y;kER`<)oeAO#JqF3nG4z^AULEU` zYiR?X?U!HpHN||y1!DBX^(j~&`Uei38Q#o3Av%;8#l@YtD&2W7YjZYd)2eJj*!~qa z=0|;MMh;SD-Z51+xw~RgVwGuQV+y8WST%gt1ve`#od8>Zq1kr4lOXNTYwBs1c zkl^*m$S2H-b+a)8dOu`7ptDU>Kx+hllM>_6*dD*R;!=ehl*~?n#yOscuV>l&idQb+ z7jkmkM;H;_2hS^U{1T05#-f+V5jfF$tsL;&LWej^h4y@aDIdh6R~EzkD%^(Vi;C5U zuxrIAR6WG~7MGFVytScPAaZyY-Plr$1+0A2q?L9ALbxX&w5Okt7^T12WNi@vtuT5e zD7Xs^S^uQMdKay$Fv(@_=Uo{=xw!;I-B^)!AgrXqVh7#kO&9Og7%n~Y@_f}_R7j0x z?x{DY$3(q{kSs93;u*jqEscHz@JB5Nh%7G1O-2?hH_4{briOW;^x7?8xNo}#btCZb z1-LyN#}fbJY^8#iuS2~R>pCA`0&D(NDr~jaRP|MliDx*cLAQJ6_xg(n+;X~^k%{eU zMeGWK@eR-`c1A15*A?3qfqdaKyqQ;pD|h*@TVOo36NRk8=`W<)tLUMxHHI2CJUdz1 z2&e5|@ais4rHseFzOPVzhQ#v%4`B{=kNEqq;l$0a`T^AgrjmO0>72I|a~0HB!sY@Y zB?7sUyt)^I>z`7XxnG;PHt}Q)zYNpxMweP}m1yV*z)QPp@u=PX8kuKSqX$<~W`M&} zt_;OFA-v}>0i7o$yGxr~+cGjH985gf5#DJ%q_Yaf`~sZ)OSs$?_Hy1oZ8Gfp{XI=8 zO+R7tc$Wg~do^Y84F0%4d`!0Y04gOZ(z59i~K2`^q3!Q9q_dQ_g>A^LCKtI`Q~c`luiY2?Ikz@ zciq>WbdvG|aoQoE5o|7L;9nDf>-7eaDE=QxFXG5TSLqKY})2T&?MmQ6NoIck*IDKu{w|>A~ zLxt1v?7%zG5*+YI4ASFIuxdsaul_Ms2^h`t#7U`jWQF#|>w_-C=Y1^G`ORcRnWA>M zHge$FXr$80&H{Fkv|#&zMj)gYDwf-#)5+w!j#kyo+s8A=ue7)X>-E2$gwGjxCtIg~ zn_=|*9?HSD5>=d7mY`uz+j z{98j?=~SE^Owzov{$P5YHZ^)Z`1TNC6O+oQW7ywYs6ISc$lS_Jxp^J)_-f~jDFmnMwSXHeb|5sQLobA|%}e`i&S?1Egi2^;fAcN2QVox!=f*Ya z&7nYaqLJe{Z;eX86?6Zn#zsaM`g%TsUe!mPwx!2BSNM)_!J`ae!uoZS_b?R#19Rq* zdbDlJ!0rdiBtS%MHu8kIqN+6Nn;-UIRf)rpK3MfGJ;4Uop%EsYsmh%tP@oWe;(cch9Rj+v(bsL zsNwt02fvt0T$~>SROZcnUGJR!oApL6^@af>9CqPptnq6 zHXK*gU%0M)%I_?~dwDW+2kOiN7_R-ad|fT-K&;Q2B`!!QTG17w2<#Y2iMZ-NN2`20dO zQDHm|I+V6VC0%j9L~1;b!2k-Oj$LX*T2_((fS=j>MT0Yw{w_++{FxveH5U_Ajd8-<2~1NO$Br>R=%DN=jmk z>|d=2vl2I4doufve#l%c90K3%fN(PKv(MTQ^;lTRa8Qbq*o+2FZc;Y^K9d^J3BEM13` z$LYHuUZG1$pa(Axy#)c&8_FT4;$3KAM*o@}v3)gA3^`+VHpm7fpulr>Sc_<%fCFT* zWcI*s)qsaG9?{}zcq&)18WGRy72D~%tkmH>&V=*!?R?-qy>ght+} zA{405P0yb{8m>;1lLYyyzn=XSTqUM}k|K3_MM_teUUGAPHRU|2{23(dQnbNoTxMWW z=myGVP1+(^Q$xP6qR*AW`M|W-Fj`KU%@^d1SP=h1&PZ8rRWza+J{4OuWNz*)-3}K* z$Ofw3a2~!AH%vUyLye?KB6(xsYp*CU`)XH9>0IO;+~ZX{j-gCIr|H)A|}*YLRj{HlzTuXn#&Q zNfKei{2(Q!;(<5%7E(T(4ZR)`3r_#urNC$yu2K#je4bofhvso;wm2K1zGnrj5ZwCv zp`@b=dw}T6Nq-kTDkrd%8a*)_-d)dH5&?(iI7i-b7#@aS-SaquJJf5;FjaoRwb_=O z75%Nu!L~=ID6_57mW%gsh;XWHn%;NhXn~%mxu~nO^IT1*owJQ6S31@h0{6Q82VtO#@@A7tR zPqn;Eet+hhJRhrPG%sq--lYuOhznBNK6}p1^Xef$*WMa>tjKEgxLL>}>(`Ant82?+(1XoFsOwUeH;CEfn1(p`AZ8@cMtqj3h1h?D$B6tP9hTl za}OfjwlZaZJeK(p5HB#l86fgQhByk8rEVq5O6q`UnRKH;SKG^R+g)Y&K~Ac};m(eH z9W7HD=(-27t(_}CJ2gt}w|8o%0#Nzo(nZe#?J%im#qZEg!UE6^GAv<@=qam@0g6eS zZn4DF&u@khp}_gdDkw&6ABd*_FP{3gB~W!Y`tw5r0Kgqd}o49(vZAfc}u4W8AMke z`q+(_pt|7#i4}bs=bk8gs zNdthRLHc%ZD6id#D9NEbpw5p0IjBE#33zI`0k%Ag!1Fa*{8cXjZYFHy{t+Yg?gJY< z|3b-DPa_u4Mt#`x_>Q5NB8j$qIoC~Xi^)llhm23nhLi4KK#~xkeaXp5t!>e@z|a{R z3_Hc5N&(WQzAM>AQZ9Nxg89W(nH_U+C>4wqGnU;=q|@RI$TZVjS>Dq5FAW(z0bD9E zWLzfdwYmkUQyF56-(L7X6@X9xBjKd1dbbjyNe4mt1W84M5F#tSPl=5vYCWJaLH%aw7G(QLV+03rwP6PUO^<(hv+_Rm+Of?g;!qqj2y#!oOr24`G2REz}&d;YfB|iAUXpWheD{ul6cZf5^o)%&Y(wwUP#%Y&ibVN;5V`@BW+7~~UGb7Y z4th|2Vo>_S%a(S4Nf`=6rfzFT1gPPk89%@SDVkc8-9ld%Cy4Zill}%mLb|WETn9)(#5*(HBWq zFyMFqz+!j%O9eqh?rClKn8f*j z;@BsJp61h}niI5Dfq1{JhV%1;?Qj`n2x%bu)Lt9RmMBK76wE~`)5x}>DZH;ma-jUy zp%jUfUvB_4SQwRW$2$4@bS=Znzs=`gD}wA;YCO4M{&02k6~w$d744}Lxotx3`F$K* zduVnnCUOc*uv9c}^luwx*>O=@BE11l!L04I^K)p@!!L3QdDh6&4Fhv)+>B`{J6uI| z0#I)N`{h{T#)}Z6eUabaXa}hYLhZElo!hEImZCJE=4SQ}%PHdncF?4I2iy$;T4fjBt5mFcf(caUyjKp%6<zt2q&8#jv?4y1ou- z3}Pskni+{a)O6j41P>l$0WjUc5m%*2tlZ?GWDI?sRYOv%t_ZC+MzqmlZ2KKwHm8PyNFiWfS*?)4aV~C*Mh| zk!C${hwr0reAsy^#T1zX9552OwhaQhghaz~*!n%3eyERfQ z@9lHuyZ0A@VSu1Q_S!ZW?hsQ+2xs^6wE-86^9ds4Vaob3*eExBNxsjfBQr zdC6;kk>nfLx-7=-Kz~W1Puu5g`WMyo5b^KwgSBmx03=BU1aJO@U?WhCZ-PKI zV}B!90tk+|8Egv(2u|e6yPx?B!5uKosF4B=IJEeSu62nx25)Y#?Qa3WR6y|JUkI)O z4!fr&zWw454mh0p8^Llwa9%s^H-}Sz;NNsuNF+Epe&ZKGWg>@v5m{Fslnj(Twb+^` zFnN2A+cd|rmRanM*y{NkVHlZ#$@`+fy}g@TvmP?(iIau?#cFl{WMHo2x$=Cpe=!Cy zuEIcV;zu2lTz(|DT`_{jQW#|)s_LpHv;0kI#QxRd`rRhyIx^7Z)-p-h9>&=yxwy(H$ zUzxr2=#HE{(+(h>OHOW#Te@@R-vPuJh>F52GFM*^m&WPCJT``08q>e0<=4JjoeNfE z4Yy$>5m+GDz?j{k$6WpurdEFk(4dAxQ=W(`5Nvxuv#{eXhE=b0NERc&{G!BWhg2$w zs0<+us7Z#Na2YCz)!E@#6#c-UcrIDttw>86P?HQ3bom!V-2sLX)KvH8^Oaf4nw#3< zcIYet2e2YK>?td848;J74{P5{=>{z9t-^MgTZ=kCxRUkfGKifMb^*0*wA@E8=NDS+ za4FNFAcG)ln#fP=-16E#7ry!93}&4}mg1MnwpR1r+Nb@_ZPp@ zx&FD8`1j0Z#W3od+s^8jQu0Aa(A?ENxJ8WyP($y1*794V-)(a}fq75T&u?V|yZ~MW;@iI>jud6X31@ZkX}N7+~zPK2Rnb0 zH2+UdAqW7o&f4NJqBi#`D)tP2D2HK$W3axMmAuk z@TJtfUQ6nu(O}1D!*Oij$Di4zM`;H z+I#;>MkzwTa)NV8*lv~I5G|Xc_D(kKfQopB3~6;8Vh{}JEqR-LEB3yj*b~QJ=YHG; zG8Bp0q17D$t_>fL&Cr9}F7AMydX%`*bhQktfxgqsgrK}! zWWCg-2hS)2m(;TW9zBmz>M{6Is-SK}#Ovd?U5LTL)E`&~uHKL!$p2ilYRtFFV*~BH zWvFUIyG86Ckr?Vf5_6B|QGf6pa1#Q!A>OCyGGP3$W;@WI|4wm8*&PP$^7Yj%-S=QI z4di&u`2SvFo4D>3X+0d*?a&+$vsJ_@GW}14Zfq8)gWSknSi@eYN4@U;Z21Lv}z^?jyWE9No7${05R<6E8jRfgH;*d_r zT)3VXCQ~ONEU`TPpkO>?$(}l70&;;kA5-$b1DYLor%oAeotbbnG&Zp}z(Spjog*)R zn%yB5$G2dvFRmJyd%qpS3Q=JXmC0&cJ|744&IzKDzusf|M}}KB*dN3j!Ox~_&H|2b z=dm*qq`rZtB^&G<`hHsnnFwpJ8ZH_SQk=nf()Gc9uX^GGD7M?)LPWrR0BSqi$r5pQ z9^6zRg~~0D0IdUVG(>2G0L~O3>F9k-Ws>~!Qlw)ZiUAo4D9Z9=lqapB5qoZ zjI{e|B{BP$yxxpg;mdbf%2OrW6dc_d<5`tIdY@H>WzlREmbc5a3+W^9Cn) zTntZ)p59>!nINxks9JAIRBf&uaNK2|D-yCPDrk zDga1cuEUc~49^t+Zq?|w;zgqx(2%~}(^!M*9$KF5SR*M8RtyFdSr@sYz%(c`GZitK z9a%M%AO|t~8E(>}`05fM%ow*{C3sn#0)&Y^S`ZMon|8qav{BS{-g$e|Mo@!>^d_?V zfrJl^f?cmSXtwz#P>=P?B{|X=e5?jwdiG+M9(einl46g7_(j>-w_vJMDO0zDm7m*R zWD+p4&=NOL1d2!mATdzp9k$Q_u;^7Q7f)(EHN>pEx6f)Qcqx96si--ogTY&b$7Nxi z%u0b&;pU)VIsR~pK2WXEG*C`rSnj6nb;W7qRp}hk%vyCW5y!G?RDw)cbw2=}&Pj!1 z2wK_!nv+k;NE2NMb+DWz5mU)v4h{taKp@1I6(4HaI!Y@gF-3+CA0+>h5~ly#R8*1HW^>Kh-^51bj~4SduG{ z<<2dzvZP8W8Ez>~D|{#Kk|&+O{eVM?8Z9C{eL!z*PYb~l7{nC6brq>8PKN_}dSi)1 zFA0hQdWb~+6ma;#O;2K1brZI`DQ3dJ2J!#fYvPd zEnz^c=@Q`E$0*-;f&-JXe;k;Ik^$gNMt$`l(T79<6O=d{#xnf?aL6&1aDa3|deXpp zN^&v)9v=}0Vl(fPSE5vhZ;~gLUn9nAK(SjZnKU~4L0~hn|bA=X_o?gxL_w*<414Hq^1jez;<#JWgVLGHwuqoS|3Y ze$^y{${bu(pB^kf+`d(=@>8YfiNU~3H3~TX8kRdUHdUZ2*34+mC53Y<(+Utzfk~;H z9xhV@k@~JEhCb8NIPZ)bEn?zWKLWUZ3fAmVC*Br|c1dN&X4%87 zv0n)|KXD~{K?lP3K!xkYgw`<6ua~fSE&`13X=T*VzLJflhIS-SsTw2>HP2ZBfv>dA zJch!L&lmOC&0G!hx?Wa4uwVeeth=6;=jX8c{?VwVK1K*an{apYJ-aeoHr!YVU{An-Mg` z&z`moiB;)CYh^JKeiiqh(F3*fvTwSp(5|cV0gMVI$c@hjblwC*ktIlOh-m*XwPYI1 zrsT7_-81eLy{ebl>EWI0zy>3;)8gEd(yLk{a(~#ZMnPje&27UOnZjU`tznnLe8&0O ziB;<>?;pwSnZXhJ?~A|&V>a+HBFo6RBZ8d=`!-L%fD`4jR6-F(&tIc zaNj(5htDSLa{b&I$Iy?unIQ$-w-sWMB}X8r>d9FdwQ_T@(HQb0$S}b{*=;vD&O%}N z)kt$bS==&2I%xCU0slI7bRMzdHXZXa zo&{A0E512+lwz}DfZ&T!+q%T?_yQ6t0$cp7kusK1^pVy02OWca0a^{NvZ_K=70BhL z%f$(C{K&kHzTcn*tvn@fTZNzP^IXHJWURqPH8yXLNYiXg7^dhxI%=*39k`qw?)Xpw zZ5lka@gB1AzAg2QlXRRRuFy~on%&D%rFF#)l6H5H5&olw&ONIme2FsT{=w@y;dK2& zUL99g%Qo`5)&&jLzhL?O*6scwRdu<;(y_zCi=9}kTEtuF z@;yN1OQ1C0C8u)jVpz6$n;|G<)hmJ4eTSweyP5<~xblA8d}+A;@_zl;MIWQZ-fraS z3DJ%5=a(;nYBzOIfqnDQ(Ed+)KD11(Y>-1QAWL^;2r}7Mr z@*Z4{kAJ7<@ZS639y{Gg@RmRo2SO zk#D*Bijkmt7Ky2Y-1sLK8@s&kz{=+$4i8CWD%BYSs<^DHbMOVLc1 z!PbV!Hn)eZt7=>n)0D+>iDONJ6{XJ1MUKEP751yyEFM-~5$ipKdo(}4;Gu#|^~0uO zu@`OM#*0pbSt4-Uo%U1}lbt!)@%T2YIy|TWkB+-?e>$wxO*Qt%6+U0Jkx=)Fu`_-Q z3gb^)l*~4QROEQ)@#6yB5TB3ECJ&=*d`(v0U-S`WHun0O5XN}R#2o9VgTM@AFe>nl zhca52csG|1#g0mHeOOwVEoQL0wrij&Zz^upl)0>a-}$_A!*R+uU1x>I=$oCF=42J5 zJWb3v#(sRTvfa32{}pM-UeYa_zWDvr6qy9%K0w1BOsUTBCV!!_5`yU95|8eH|Uza$~^HVVOIY(~|` z>@ejZdv`)$p%U{tS)bu;xb;JRb6%qmGKIUSigSt_`9aH%$4jbi>B&LuSrXUdx7C9Qb&lMIjXj%JB--UZd~??#U#N#|E@BJw3O*Gh@$*=( zji&)~mewUx^(pZ8(^+<|8D3q!2I!-q%ZL+cjL)UCImL@!b~%LI7|8U?LBQ|XA@hisDv zwHiw0mNW>bFfoi|AoF4A+US@ zx)>P_N_wF7W6VStd~JBJ%CULnF+M(q_lS*YaLdUL4Lx1^O8j!MZLQDK9Zu4q^9EN( zpm0k(c0Mjww%YD&a)|rKvhR5YUN&O5bzys7XRbpkWPaCIcjbuX`^`;;+Yg$`_E{@|xI8EBfz|poh#zcK%Q z>Z^&VvsX9~jtu=_8C@EL+l8TCr4v)(;@u7PV_0yPh^kmjQ@)7y!h&-=u0t)T1Kf;K z`Fbb}3es?zOW0}VC1e3s)TmOt-?Yf*Ui$>A;WRs{W(pn=R-1{LwzdJ6yuO)5Ir|Dh zr=HnsSgU!?EqO8Y4{5^vmqM)G#^`$Sn-BXH-n=|)@EPl+|tV;&hzz4 z!YbFTGwa{UsTFU;ln~t0q5b%V*NxSJF5_kF7;%OJ2IbPy1{jBd3hVP(b5 zwJ8f7eLfp|YckFqnJYCAW)gPe(?Ayx0*f#t6#-75JrjmdrT3!cp`9;>_3@+QpW zorZmX-7^g~cvdq?>&Z74Ta+c?5caJz@2t8F^L35BB5lL;SKV=8W=d;W;io-4B%aHL zt3J%KQL*d0DBcfQ?y2uG9DEga;IQ}C(NKKtvtx_o$}8(^G~Bv5yQ>DP@hvAx2saDO z;e%h;(9OP0&TWzzxpKwMmcTMo^e3zFot;9<;^{Z$!IiYC=(Vb~U^PQMxUo%U{N|h$ zTO18+T4@zbmXk)`Xuu=gXA`!a_Pal3uIYwOw0G`Z3BwNzb&3ziFY5Sd<@6agF@-5` zUdj5#UARxL;wX1>L&pn4yXdf2D0C78e@7;7sWB1L#)i({+dtN`$iX5e4pX>J1g*Ll zOMr3G>NlVn$O$uRC)wz-{YC%GSL&Ak{4$E{P1nWW>HVxqS4xkgfuDJZh2ycEHFMy* zu3GTKd%^fR`;GHligj&5c_QAWl}POCjvpq?PTI>C5#9#ZQf>v8_Z!|av>x`&w=!Se z-JLDwccpO6OWBj#$xz@O)Y&Ks^6&OwmF>OqwZ^C`3WBcTYp}FDMWRs z1jTe4_?aIlseim?322H}^!qV*4e2*35^z-n-2K3%vH?dGg*7MJ8y>AM1l7PL%8Ldk z_|2p1vELV!K5C{9Q3vI&@Ag}>2v*QN=FV0n%p~4o+F-bcqkAma*l^{;L6P+PB{2KE zBkEN3d6g4sNo@;SiGG=SRW37$PH!XR#;V>{%VSwlAL{2Y75AI3YI%>*A^Lji_7&kg z#oiLX`eFI;-)p<0vWQ>9Ar|zvui*&%)~(Euh!eJR40ie2s?r@>`uzi4yHcmnH0Oya zw${brheWW8%?s;n);sRdOzxYR%9a1p`2sij)B=8Rq0{hWSy#0@LwMLKxLsYf`MQZV z-!yo5MZ~>St@5LKe<<|hhFxKuw@;~+Os^etPAe)vH&uW@D5OdjKAy!W!%Ne!jEbM>Y|0ug-e3m}+z}Qi(gPmWCtm z-sMbV-wdm7;nPE#-6DzW`sacfN&*$wu z5~%tfSbN;3&KjTD#E=$vyw%!r{Kg^#x_4nB!Z2=>PHU_mq9^3MuOW6^IB>W<{6(p% zM8<}GrxU*?wIkIa^RuBM?3oS}xNkOdzjN=5U`C&cc0;!i{#k>RLFof;PWv#~0ZZct zUUG!j!6NCHd~o%>qtO#wa*q-Ld5o1f_bOT2;sH0h5r=ytd0g)Ojx*0w<8B&;-S}E= zfGZ=$s0JKzSSC~(^q|v187g}w4dfJj;X=oquXe`E>L~ddG|QU@t8nn13RS4Tw5Hp8 zl%n6m&gZ>VrWm9l{A1K)YyA&f1Yx~S@|usTjZ{6WVGM^_>Bpj5db2bU>!vq{xi#Q7 zJBx@H_j3$g9SY@|@#$t>+oCH)n9MwVIUAHSNkh;`LQ zS|4_Z?+U*!AYA(H)-d;c5F;P&_2&F(!jCZUE#(?MWY3C!TshKZ341%MW@EfLdg6NX zDpMl%Z70n^<3r-BW0Jc=)4M$V>{gX7J$9z*&a2(K`NN;j`)nGQsdMUV!1IdQK<33C zpHpL=Eu>fVp9(E>nMq(t=l6I$@5zPrP(nie9@_gjh-JmSRv+1?SM*q78nTuf*a^VW zA&EHD;kwtiqLIEMF`GfT_*v_sH+Tk*!I;pNtO0DPG#evuitZ`@gHznP;IqiM_M_fy zaJyD<7q~T~B!8AZEl~BxXJZ8h+k5D%-OSWOg*VeDGQj;zx`uln@f_bqgU_3;N7Cw= zu7@^?^f_)_qVxfmC^I)5C6qmTSsT(mI`Z`!+6DWsQFp!*6?2B2=`OSZbih|vWaF5{ zXP4cGcZ^{bB zyA+U+?hZi&0Y$nc1!gGe@H-Faz25(NzqRgK_rm2lvCpov_iyj>bNpzlTtQWte#=K$ zBYI0fn7jh?*?Ak3^Jr>q1Yu@&J;%ilQ?4+P+iDOeUQ@pEyiFh@`C;+4-c9H6C%#TU zuSd~+(%@@h-=1w{+@hf6lgJwDrwpGfyH?xj;WGN`nZk=Fht%g}les%aNXm4IZDnum z;BruZw!q(W{`STb02{j*|939e5a^@x713TA+O!&z+FP|XUAk$F9$wp$tOdJHh5&?9 zMv0~8B=m?ebKBT?FlhWPRjp0PV7ZO59Sbg&n=PJg*wTSC)ahcZZPJkIM*?P@YfJNU z5-}i;TAT6z$)le1gM`YTEJ68GofW{s8Wz!bW8ao7A#Pr?gz8vpfs?CYhqpF%iuP9v zbCk=AV&VwUJMuW+d)n3fUbY{YK=6N;BKiH>>PU>g!ODgG*WJ#;zd)lrO2lH@MT?ru z1y&w{=|WMr0ynqySQFJog!`vN4uAIY)=evGS3U=z)iF>%W_W>54gm;w+}2VMB#r;> z&*`!Itr>LFGKQ<^#deqBCLlAIFWGMMpSg)aDodnQnhi>2DS>pH96u(w z!55?ksWUdj{xkVW&FL2wXmTAextrb{x`Njr0l;|iKS27FWDv!3eHpI;B0P_ozC;)xp1mOUR$lDm!$04WU(PgH<{!IbQ~8)REt5)IU`EyBS6S zJs8nIb*BGYkIgVahG%lXJDp{P6u>W^bmD3rV@v=Myg4cPGqpOIm)ohJ@ z;bz{L-Z-5u&*U=l5SVLR+<*B3$}pK;!1#b~($OHB_9VvE`V?FXH4<&&bgpS%tbaCQ z^zUtWKo#Y@7KX%t3>YxsF{Z2-u6Jf6nmUZjd)p_sgSGOnq`Jr7h57)YIX3TaGLT3Lw1F8Pv}yL8 zQhuku)69ne=&^0lSpwCNgH};hpj8wPIE$vO5WP6udMknazrY76I*_=BME66Gi$7^V zGclkLQ8oJ7Rm1EKIv=YD6RQ6@>xB{^_r1;OR)D4pK<;bZ^fEpGEa8aiR%~DTEcC@K zHs$@lv}uYF5V;e4iw6lv-QVH-c$tIwGRzAt6-s1@%AWBH3qp~v~(AM%Z+nGLtM!xD3+djSZ5i~pQLOxjeDS@sc_-yA^%-99l zpbIX`0n!U~>_ZE*AL$RQO(0r*iAKWy2%X0(AftD*VO2nIJsb&UQiQH`(Dkl!ZA6v- z>O*`gsD1H%5;O*Mx#4l4p=~3IV9Le4I4-U2U;g^L5z6y>ba>HFrP$As!{lQ5eQ)eY zSVcO$$86Kqiyh5stLlRk77^_01{t_Ma)scL>g#nX$kHCu$YTm*Sp9f2oS?oH{kROK zw~hPQy@`QF@03fbVdBt2JAYYThbp_S}BZy!nVA_a?zOWl@*<=+Qs?1lhyM zV|xg6O%}o$Rv9&`r{YCYmCrP@7J}c&VA|c*b%VB?cMnHtJX#jchKe}W)2}1i{vG2v zsM;M*EbJkL%zzA?XG#b=ZLvDD4>kwo_@WkGOgPe^H$N#PX3rfedv0K!Rnd2~F3dLR z>lVQM{3h&UJs@g8UZ9=}Qf!K+F&lAXY|m-U96#4gZ{Uw~_(nBWZ;pYl<4OI`2Y+VY z$!NLg_vd5x{}kBDBCHGef@bximYw3`#kS4vjuzhYU~1YLeIvNUXKE}l0 z8Vf}1+u$WF5|(F;8i5&X>1h}L;S6!$s}bvxZ$iKe8}vsHU9yh0-2NBG;H_>rRNJEQ zovS`{c&{PbG-wHIhDF>#_DgQYtkoYz#ew$%<Fc*5z2QU{k z8rsFQa}BYsGyj#{L#F2(%9<<1?vGYoXoSrEL8irk(pZ=Nv@HnBLW63N`!M}dxM=S| zTd_dti%w&2vF$$Z)(wY=rY-BIh?Td#OX8%Q1g>|}-<=cyL5#HoRnDq*LKV!MFb|~o z)`Ueed9+KSK42_Sdh;Rz7oriHfsM|`OV!UvqQxnI;(;nj_ns+$Y~`}hbZ7C>wdA$8 z90TQk!)}NFz+$MNQYb;ayi90Qxa+yY$XiKezigcH7@n_eu9{`9fvOo<_k+6N{k=VU zwISL8R4!B4sU3%LYx)|Fr&{t~{>52pK^+&Z9+p7GP0%T8Peg@23K3hVi|bjXp;aH$ zeyQl1j6WtzG0?_vA~fvj?~{Ks3D89UvnJ9vvqCJCPtFo5eq&lp9dh!AUE-i6;~u_b zx$^a9kQ1=nMiTQQQS^S8x1+_$l{&-o?V$?H2cM;Xv}`BUtZuWqg~lD!zHWB7@LfD> z@YAE!XUR6&xAqg<@+T~ugmbY$pQ1=X$tM<`i!B1Z1Ll^7y_%|2IzO95NYdiL(Jz8Dc;Bcc~3hVdWu8T?F z1Qd6B{-0hier1cGNpE#5W%v}-K3NYCsYgO+W>)XJ8e}l$qe5BFeBTosrxe?M3!crn zsJq`cU@Egpu#5O-|8JOmrDV-dc z4x3;TubC-0+Wr9g2W?A3EocZJDD-c@^Ou=j-S-~ONdXZ8(H5Z?+Pjq#7nryU`To>( z-eB{)P-5@68*?zu!LdPH^N4@Og{YC$Y-9Uw^AR>`?B_EpqKj8AE ze7`ziE6h0N1x`sW2+P;#Y|qAG{AyP9ZJQ5kVuE$}TK0wA0%jDS)y%umT~#ezoQmEo zNca+lO|}&RmT1G7wWg}>>_P6uJ@reV*L52xa;5pZU>1sokXC41GU5sYFXcJ{0_o3jV_PCJ!A-KA(%Q!itWp9h&xlXcb zeD}+OLy!pzC*oTTyBYswL~q`v1uY!BgrPIsNx`*7id=OMeOXS?M0{Rr{*Q4Nf}DQS z2A*h`ht;t;biTdcY7FqS*UOo77)_Hy?1;wnEIhY|VulGkA`#z(C`U@r+OYc93IF&9 z%|OW!+h}eE!aO{VEg|l+cW`W*$^ zRfkOu*HPob9t`s2A~%YRb8nKVW~9>c4$W z8g>xtxmydBKvi%b@h^Pz@SW8+n7$QM3)Y!hu_wG9JR&$PR8Ohm_7)UEauRzSBn7+7 zKU%dC5~d3JZ1MIR;nDvHK=`tRd2LnjklET@N_e)7b*!IwRHBr+&+t;k961vItb4_; zrp9NocK5oa1hjWzzsx*b+Wa+Ft$SU6VBj9+zy+{(4Mx|~Wcp~j)^9AMU&WD|o%dlStPU+@`3uz#WGJz=6m!&hm! zMOH6dzNRH(Fihfu?R_|BlUx9*hU14!T=QFmI~^v1-wco%YB#X&{3)3Og8)ct!!e{4 zT+3SF{G)+4B0d`@UpFtTj*NQE3J^oe3w5H`%h{T?D!+6+ZQC6sPp#%KO+dX%L>o%d zjdv8v$K$Tlj(rN_J1#i`N5KUcf1)$FpewVE{71-c5558W zoc?NkCcZ_pxx+@SBI@na49q>Cy7XebH)Tbt65OeHd6VnEb z-RFj}C$|xg`vAay(mPyK3C@2|`a(|WJ8Pajv^FxZwxL0=wvbBOS>@deXKBy28nimA zN=XSIJ5>PPhMb0}PiVjTtJt1q^|g}zFOi>VbnKGoA8^lx_XTuxSNsPR?;|1^T=ZYn z?bPjoD)9C4o`*dI)eJ%I?&P?)(YNC~3#tp;?~>g9VN0U;=GTUReezOeacpI5>{IL4 zFS_S#R-SUTvrf!kX5O7`w~3R7HP#MoWp1L~MQ$U*q$nLd8h_QHv6qn!hJzG^!NKSD2=r+6^oM@5I}Fi! znbT~6#VuWCC+7P7*(wi|XRYsR=^Vl|=kt-KOX!hetL(2TObsLk88RyA@6jJa-4joJ zML0w@HCG&{g_9wEkJ`uNKB;ZCf8QBQ$N*>BAr;QJkUm2q!<0AYk2QLDD_`7nr89G} z-gmz0T(QBWBIU7^Z?teBQcfK0YCnb=pOhzD{WOh5)PKf{WCr6ol6{3CMN>@SRZ|Iq zM#L+9bt^Nh_dErR3J{lJS14w+{1%eNlKe44kCa_x$;KVyGZ&-Y96@UgX-kFWFEI!u zC|xm5=*q{PLHyo346z4B9v2a}275m**MMLilan!JK9l4=l(OJ=(I2Zfa<(EGz5~lx6$gE2^i0xbIkvDI{;67xW`F0d7Z?b%* zv1+3CQ=fjtmwc{}&Q(KHSSu9Ibnt|@t}~p1qn}AF@upY`FWjMVyEUZt&yi#ifsqym z1LMSCa1u0Us#dc1eU=HjDx(3DC9fCNurDs!)tAb;JKnrAk#m?o82m;fT20?0gi|wF z{j8M+I!0)u7Qi7w=KG2QCueZE-HR-LYyFJg7R#*&Idxf*v0vi2g@+>SYJ0>u0>UKY zznZ-n!Ji7?B#KA)<0dDb(*V;S4~8ZN>v1r>El&sYxQmKWt!vMH~)FIFZhWO4zr;u$Ve#g;*wPtv_C~}DD?ArT)G=V41nr#vk#(LxL z!kIB$2b9X3RJPutxTrx#Ch|#f4hJTi_bK<^ah>p4N*a&^He!>+Uum`$Ukjku6_Dbe)JQXTv4KCG1ZRD?KWlge>4 zTaTIu#_z78{z%w;Fm4coY#k7`S!4_v@WonOm+3=0mRKG!dGr{`NH2SLl{Rv)@IpuY zmD&;y7eMGmlAg*tD2|=Qp_k{yPYxnm0?52ff7*L64m~9Eu|Ve0EtUG}TN3Z|ZoKIN z``uUTpWik|-QD5(LNHaU;bcmTgPgeybjdIpNiv+RE*EUj%teOF|_$kv+GbxUf)UM|Iw%zrF zO+5_IqLvoEiAIbFIyAcHO?;K3*f&&)L>-kgAkGWh6XJAKF`!}=-gEKYp_GoRp$9F_ zQ8BRm!`R?b)xc%E6Dnu%0w#4DiS7sDhl4XlCvE!d!8e!;?paJ9c2KR0TS*4@ z`_BZrqgMQ}UJUp6!P-R-%j%4u30zw{sSit^iR>10{Gn1G#9mjO?`r@~#eB(tZh zL&5J+Q;Y!V;|;8UU*=1~*{Gt8*}s?cwV+?MzEYWxF zKluVJMgJIHvrb1SFLHT^@V&j2~7_-r=$QSULd5hX0-#9jQ=ss=D)y*Vn>fy=c=y`A~ zlv%=%<{Ak*3z)@`*21oqe2rlSgk>Hs(%$)(?ip^dXM&RT=ZPm)Q2TgTPf|GHUu3f9o(Um82J+x^sWPl z41Lh;=XXqLJ7f;DapC@0qEUptF~yiW6M;BX&TuBDR-v2T9>*e{YyJ z<`osgym5@0%Df_%(Z^3GjNVvv3th0`FDMw>kxq2>?HMpqJ)$$#gOlg*^;5Us_Sz0C zX6R8NBr!NRWJ>-6)E?KZ--{^$e$=IYbIgx8{e2-YJ;^b48D}8CHOLss7jdwBuDJCm zU2VZXbP1CJ1|kH13H-$ZaIG|Y59&V?7U%>IMhP_Fz zFc1V)I8%ekul8^|+V<9_9=e85I$fL-GKa;2!!meiio`)iNH$22A;OE;6<<`!ZW}?1Q~;_)h}zWysCXuK zRp<88REleMN`82KoU07bspHrh1O`O8hcNHWOqrD!q&)$2dIRM6D)}U1Rd-C}0e!8D27<`nfeVmelpu){FxZIyT8V@f0B(@9izq z9bE7QkQKnC1ma$>WJ>*YKyAPYYG65SPf&|E9=@ca3x*j}n2aFR;Nqcr>Ms`yg{YOv zJVo!i7qYx*glwkAca$1cT+gX--~o2sc(Qgg1#wItmY0APtW4X%vKAoQKxAor6pqsH ze*L4X=Mi*(*qEXl2zAupZU85D-qRf-XisW@vvSCpo?MN(QF(0eMAsm*`AiN4R!V?_ z2sul4W?zOScKpn^G$8IuBAc@q-Q@-(x>@=GmF&@Q|f zQ#OhoQ2DkZ9KJUY_n(b8eaudYKv~+)HRppFf%{Ss_VHR_;x{?CL;1Ykwjx+pw;yYz zC|!tqh!*g4pQ}NdEJYKJT%BFde11%vzqs3)0rIx}RGsf}vL%F!k+0!f3QI*S)LE?a zF8Rif9pSm$OqOd0M|GFceKjf2ClWH6X$+5b4aJBZ;{7Rzce3Op^&zqOmINq{k(WC~ zuSkQV^;5>QEvHtHy5%9)IA&KvJZ=9QnZjogJ!_!d=IGrQgo#O2Anx;z6J^HHrf5T5 zHr}|;53Mc|a@>tJ?mHq|WMR4t+9MJ7xobu`Qtriq{f;9pkIN;M__>(`adJEMG9Ei( zA0VgwE%Z!g^w2hd|C&(zNYx17gc6lkis5s6&n>X}7C9_ScFy8t8DoR+ae8v?=e zXVR#58F5ideN?=5-Z<1>2b?WFX_%s(Zm&aw=Iv7t^r4NgB9`;yHrxl8kgKdIYA2fD zB8r@;B2>c(#XIq0qf%AdQUBZgw1Qw?p_uJH2~qP|AMr!}=iJw?9Ht%}Hg?T!p)RLwouQZ8lSbji#k0 z;r9w;M^+P88*ZEApaNbWEk4Pp^6tGK0sIQ~dzClq8+8XGEVFq1%!LjXh&QpsdCaSY zOfqjpcydHawnFD(eqYWe<%W0u6(HZZsoIF5pv4Rz(<{mwUAGCzD?(Ygd zB;IgOzP{1=pxMY8OdDaV={?(8)@jvfMY7d?d&^G@v8~^Fl|S^E^TLmD^2Uoq&12_@ zHfMW+8bsZ)EZmTITvs)IEzFGFb29}0U}%jdeh~lkE6{mZC^G2CpuSeKR z+xOJGUqSa5+67|$O+PVQ`=BZFkm42jHOul-Bg1F>recmPvu^si`XF8gO4!Wpr!W;)UFchptV!F?7(cWq#TgV>}F3Z@hC%y?X zCV}Wl{%d^KvYi0^x_;A7={Nm?1IYfN-zNW0GO>)!iZ>z;>qSb2OE#+=*Mn!r-XAUy zH~*Z3`RT$fvo86$O4@@b@hEKtuQlD=EIK*)_$%9fg19?ZY>=3tm6NlBmcRmvR2eFA zu49tU^L%jbBgFU9CAG}D2FM2&INPMKCvLkhZrEnm@u+sh%xzD+cHe7=e~J0f;|RSK zd27?pwYB$_6%sRoIT_Ix+LWFfXIrGU(D8;A9W=eo7WGdUg4b)2*awBx)l&nmYqoR> ztyN0g)r|f_(t>zz_HoQ%)Q2NTk!4G{Ta_B0O$XN^7~&J-s3!2rjn(lpRu zbUkRGanD@m`3w}^708=dXL^H!{um7t6Ri`BkBA?GO^%IU&goS-_Glccw`E=)9(x}R z;I3mIhbK5NBNcFLv%NrkK-gg}0_kChvfQA}GfK;k5z1<;7IIR$fBV=S46Y_Q=amhq z(<~eu#LjuDS4`lCQaYeW3An!^kv|ng^UK-J$R}cTY*s|>SR0b!R9CcxXGp7L<^kPC zgFsG4;YqGT%Z7RI!0keW9lv6ap80UcUpFb!nX3{Cp<060HUlnPLbNYu;2xhyIlG@e zX;~SFwgjme0XE2#)1l z@3*OUAEE_!Mjqh(NW~|&^2WN4eOnQ5#HUn}$0A+I#`Z|YF)p4@?V+==L3z^~@JNLp z4+g9$ojXhf`6&Vz10El6v`Xyq0`zD!owhT~>q8w990#Sa9bgx_o+x*rp35zA8WIO! z(_kQ6U|?LoORam;uE>6kdD9Y~M|0{IG-?SW2;H1LBO;pOig=!SxiHZIvsrBR=xWv4 zp6tbOjQ*EQO>n$e1R6Z8s5^XYh;Jb}znsRYyLB@?1JK41n3WDag?2O*3y%3PUSKte z&yXZ9&9x9t-VnezG7JqnS|{NJa566u%i8WcD;KY?T>^_#1U~qg8-kzB&*GT($Lp`$ zE>#)Fc;BKTH~R$qsJMF#O^qZ2wLgSBwjfu=Kh)g^JUb)jUcSmL*Kj_$Hj4y7rzi-> z`2Cy`93jJ)Lj7C|cAek&ok5n?)Z7JI69IY&z*&wkD(evTo70Pq8oY z!A5S-Kc3fZ-HoG)Fo2e0Nadk|Gv3s_x@FpY3;x+hGz7Xx4p#8w#Ud(D78zrBL&h~D zc+&Aj$H&Dis`3m+sqxCj~2}h#nWHQQ_2#5QxjRLJaN1 z)Nn(zdts2|*cUu4_qg!GS*xY|YN?C|(OL!x8ce$ycCl{{GwrGlUsfN~l~1fi%Q+T+ zv*}4{08soH^NJrYx2RWTy8)v(OSQd3`}@Ioht~$vx^5J@`r11u=E;qKL&^ruIgiy? zj`8h9aB%=_G!S#~7Q*GzTN=FSg(al$S7bXKIxFIq93Q2SSX>WA4J2Wp`v;4Q;L>nz z91piPb@p!O{U==M$5&w-q=W+Oi;xo;q+Vd~5GX#ma}DP14;*vKXM})d248a{@R=V! zss3K}nof}i$0wX#%JNG@I}lBGQ8xt7Zo+%w)#odrmz+=B=yR1B28;vs~FlSdBPHU&Q_c4T|U zbo1>wh^UKVK#g3BdUMehQgI0sB%?ukvR!BvZ&;Fv&tt~k>qyQ=i&XAA$4npwc(+dEKvLx_>B|@WoOoTmg#@=n`JcSd4>U{d{+~YzN zq!<2dQN`H%Vi4vxuOd!R5r#B5P{jbnCoH?v%|Acqi=E>)r>lS7%#8sORT8W2b*f zxn?yqfg+4W?d__8N;mM(6V?Ndh9$2RiT#jz$CBgaReo_gRk%N<^84lDYskB~_n#Df zyOvVO3XSiN9Q0C}oAUq2^6>bkB#w%itOH#PDwI_oefTUg@uKU;N~G%O21m`LdDRal z`#xn=Mjai{dblvY_SeyVHSSrphKtV2h32vPQC8sD)%bX~qg}FKRNLj+o$oqmTnAaP8)P33|YmmwspUl<33t?b%%!kD=+l^ zS2fg41DoWGwSvkCDs}}svcbc77AyQe+CZ^Pm(WHhw|3UAu@%?(XtPYv0HQ0eaxM-F zUR$!apKvM9>)Nyd-5G4J-0`elrpid`&~e?J9b*<=Lv0W0g;wUf9tvQ6CIDp&RUm`JLOb^GOIg%m-0DOl6e|y7uHqPba8)$Nm-me=$^0loJ@+oud#mH(gLSR z71<&MD-^mJ#FHa@HqgD@F<#$Zod8^)!ohxoFfuuiqIwu0nlt$xO;4%y{FTO&0LBD~ zA1E@Wow?2=p%E?Xc1C4m^|mV4z20Oys9dKSoafkQ-icAGkwVu9I=0ZQ^iW}^h)LIj zwYL3DeC746^7=7uW$oS!c^d`c-5Ir^xvlDecG4mR{@I9cFO-w#lih?j%S$31QP}L> zo}RFBpPF%SL9r?S$Pl!{@L}U^wD$Tq#^S{^b)w+Jug!e7x5}9-wyiAkmeKYuuV(A_ z`tt3Rsp^m#P0v>n6dJz6FR>1p^_S*ol z9M1egT^$f1vE?DEiu5OitEA`ArBK1|ysCH4%xVVJ>^TS*>A^DHu$7YSfxHv zMZLXtt2Ezsd(3`9uu}@3@T{C%l*3h$YOSxQkVBGF7Ubg}zJg6Ih|jX{yfn>=h@Ko* zXD(GH7H?4^cDO92;q*O#`2}rn^O+&V{cMXY+AJ)jH4@9DW%V{y^~mwDDlccB&2B#- zzW(qd)27Deozjjbo_FScw5}B*H|uO#wn;|MICLcg!8ye!fSi#8l;?Oo)60glQHt4UdZxSa4$hs|+jbo0%P?=G!xPpk_7-yzy{+Ds2$pK6?SqPu zB}tdrysf)}Wk0PS%tcDGJNewZkE9BCm)E{{>Gr1kDWX*o*%nA!Xz-ft3lXyF?GCk) zDt{QMSu8HwzqsPyH}W)GeiS@g$@~jOKXbH@9$NLIH&cV;T{06>dEiJM^+Ruq2m<*rn_r)es$^z#~@ z0BI%h^SjqBRm^vUkJ5z^;E>(&%Sh^!h^$02hHE45hC#|(d!djRQr;CPx~euaTH|Av z?>*uc3X6|fd#B*?$!|w-kOK7~HHH0)Vp0{UD5bW8Zs-EwFR$KY!75_moyWge6|@~2 zOcJ-spL&)AlQwC#PmX<~?h# z7piEgI==o|Ri^|`cribfQpB^GSC{@5dX%71CIh_yS=^Q7L2ezmcX)d=b0w4$@DBm{ z(DZDo@Ug*HNKv+oR%69chgYG7NUu@YYkn_o%DlUvtQHJjz_LYz1(cHBpeUPxidHFQ zXTMw{HYJcGbqbBAOH_6kciqQ~shi}@3guDMeGnToOmtP+1J9T)gSQs%te`mZx4FCc zn)7LS%}28=FX?@-bQ`E1pT!-W0F_+YlUuP*cPQ~|zBAg9dSN`g#TM`GsD2tYCv%{9 zedqhH;%?!k?FrDO#^oaBwwF%qdUwlAL~;#t1yhSM7d6q>W-XdUap_#=+Q^h1P7CLe zk%Ln-R^cIiDjN~{Ox)s-Xo_|;<^!#1yH|F)aZhc#1i~YY5u$ijHSKZax4eVQV>K= zw*Ns+c|cAJGfc072^I{m7Wo6fw&jR< zlz3yUZGA_$w8*p01pcBbjYciq2XYrwnJiqEG!8GP7lkkxnnlaWo%IsHR_J{*SdWUn z8u8&(xm~=PVpKi_`|t=9@nX$u)}+dTH?)VkQkqwT)tJ^N z7PC~XK!3miZ!b?^a@a$cINt=7KFd|Sg+XYOJ-^Xz#FuKuMoTt|!FcsgV4NR$RxiH2 zyyke1)Oz;yOdj0%lGu_4t_1;WI8(()!YLcDwWqqj#?z-*vF5u6>D|F-wS=k<5#kR3Z}S zB*20mrqlHWj&-taKyW1YJ@*Y5eGOyeU3Dh2=klPOK|ui~jf5vS=ZMo#&Jy8cWT z)(jL&zDrfUM_*wvPwUIVE|>W&Wbz2KQX9ytKiU&!|EeFY6;)Y3EFPc|YSWifdXP&j z%2T)s+B!*P252frm%Q;~#CLg$Y8<;e^DAI7NkqxoTXXV7uE+_%_oM%!{$nOwVT&Q7<<8sWgBj-Zny|x9lgX%I z(ur=7Uk9&rfX2QePL`_vAYq9yTJx(GcwF=zLp76bq=WfdrCIg8Wa9~D(6?vB=-QN$ zRbynXtF9^L-tN=GtH6+XR*S9q#}CuA*6}vFa2VKy>P6v8_`XjZ$~f0XLUSL%DC=%85vu^lE=*MY?Du3zRwK9_eNsHm1| zCBh|}#8GT?Z>NDcy9MFU%=+gte*?V0XgOd3`l-2VfY)2cxVr_XeSLkUyp82^uaNXI zT~j991}=@!WvLxM;h*&7Tf-*KuhxF{D$vF{JS3BrSyth?z1Jg7Yzy8IO{w0Uiy!+y zsk7`B9$i!u87XJ^>*F2a@BNxK@|lVZ%uMJP za-LOa$=Dub=h}X4&GX#$pQQPwG?EA0ch@5As~RkqoC~;A+)4#Y^O-XGyvqTd%k9d9 zV+_x*rc3*MxJRf#EBHh03*tjuD4iS;~C<3i<8WHyd za;zloHZXmriF4x8#WIu-D|!3A)06s~iKg1FbqI9R>&in*Vz zcZ+yhaewR?ae5bP()0;a#{n(-O2C!_^&u(cq!HhEGng&dy$c7MW%TpHw~Q!i#H}>b zHWS_uUjq*ErOo@bjB#u}TMOTGO7oZyWp#ZJEi$9c}hFzfW@evBj7#xc^}mcCskL{CGj=Q z`q(TZ&uYX+ExRFoi4xVMb;0PAoG>m`O)H=3qrD}U`|EF1T_VaE9pB47kDPoT#)Cgi zZ}Dt4=W^=i)YC)$`2=oK5iQKPUZ{}NY`P1@xMb5&Bi_Xsy&jDNCpME@1;-C7b^BOX zZj)pT8UFD1T$FZk$v(!yt$GAV!5FxzEoLYrn-5i<;T`Q!_-JfF8+_1HQ~>TjeDeOc zqWr4ul&Hv2pHe@*=fx`#CLis?dy2P-BP17l(=3iddEdO`i_{U$$Jl2{KghM2uG%Uw zlZ|!@tO))(Ey+^Ru~qbz(dRY)Qf~1J4YRRNZ21ZA>KEzV_g?Oq?378G?2ZlVJ!!hP z-P=gwjEf%zN&jTTL zM$-ell_QKg)hde=a(eN}I2&uDnb?ayGK}kKJSnfSA7Ml-uk%FB7q(0_zeXuXs^c1= zW&-P7ejRRyo?B7(Ebvy1r1t%VTuRw%-#<+Cqrq1VRgX&HAkj%HlsCveGjl!PWkkN~ z)|GJK`sck^;h;|?)qCE9SCSkB%A#HgmNHf?>AEvwK&R09ahq~Kjx7zEWTuLx+n?^Y zd*|%E_d_{Nrz(9K*4ga2EIT3)8Mzg|7iXF${B|#c5wXp4Wj9ycvfJ-crDzHp+HkIsXx}CG(J1aiWnJ@$c!((DylCeu%_{va*TG0xAh{Qr|{JwtEUe`^Y zBQ>NLU6Oz|*7BJ!R8Y1>CD4b-i1Ox>@t$81w|isWAcKs6Tn)yiOhbrGNk}=rB=ROR z@DQCSh1(#`LBXTmoD&R%rh4=?NJgCzZ9uE|z744-utr#3e8 zvVuKCap>|)3->xZN`XHI^zbIV-phMU9$|DnS8+ZxO*~6>8Qtg>S(ztau=^EL-qu{p zw*&%s(E2!ualV&zdwElkkc3UXm*9)_4;E8CR$5~2CNXyjZ{L7^c$m?Kth_XVMOtMjEBrIiP%9@`%~P@z=1Gm(NdBp zfwl&+$<~OMinwrxt168$-=Ki3=q_RzCyz-n5vY`1inP`%VK7!5H3^sTayZ)DNZ|0HxbZsEt*u;K&#X z-p9?^`A0GbTfXNK=OFatLO4u!V-N8WK!U*LK)DmryGbC;8F4xl|4)L*e-Jt7?&E$g zUO!9WJkFlUd5RNMR)ZRXJxO3dK=0M}<9rGr*eHT0Lt)gxllWX)e7Ds|;4sMQcsk!$ zmbl6$1zXt)JeNDw2Xv%2uXwWn{IegJvF8+ySjSITh}JQ?0!N#a^BKFu_T4iQrB@*Q z9#=+Mnlin+!y^jW`wEvt@4_xMocL!EQ6X7FwU8ab{dyJnTVmuh2-g*aQ zzl7rz(*sEhJdjgGX{SvMOZzN<>wJ*?Se&Hi*am@OE!ea)5xO0ItU4eaY4ergJ?I3P zD)R+Ij6?f*9_AV&VpxvTjtq1j5`bldP9L4X;AjysWN(NamhpdYI0oE-TUt|%??YdH zWq+7$j94|#kNc37Pa=BZI_V4OfFXR=vOz-m;T~iA(eFdvLB?Pk6KvOpe9wQllfv2P zGWu1v0yt>hwV-`#x-hNV_jfxiq4{~&%8 zI>k_)4p0S6N0M$?+HNfCx33+hIvvsrCT(bEK))B3bV~vM7K4A-#51B*CXoKLn_6+i zX^|h~_BmxwoZhqg$^@&>_4yz7L6q^wdqzodI#Wn`=9 z;vxO}qn@BM8>4<8kBrp#& z5d0evxI%n>K9XFWX{%*dcTmidN)UoM+!ns>zdPyMTwuEhrt2N6via!5-b9}?oyNk^ zlIwDN1Z?vM@z`Kqmf11H!2)14MrZ>a#uA`u&@=;%SC6-;^Vc>Bw_k0P@wuFt_XB&r zwaYg^R0JUcGzjgRO(){NPnr9c*fs0&Ww<5sJ>4`{jsHR19OWl2cp(ri;j0mSC~`=j zbQCsb{J~5bT6k^Hks{_OtdE|)#4%HUPrP(roOxtxo%%SzP7Qz^Akj`rCFl0hIU%_m zcQ<$HfHUSOEVMMR8=|gaW9`N43>4Nf%R+_2KllM$hbj2G}$1gm;#1C@OvC}%ew2Nc3(6-zN59v=g3Y{hz z%2OGQvZOK~z~l42bM7#8_^?r%SM|k!;FHQ;+UzmfCOv^r{bR_?9f0=$(3#6%Ly8oQ zi5tz2_nK>jIJ<6~z#f{0u|JHJlH$|`JexTKZdvz8&~qDn?t)+`obXKyak@qfMq{W= zlEZ~hqoZmh+mvXmudE_(inH@9ktH&e>zJ0~;o0Cw|JSUFFWj;MC$Etv&;r(JW_&pP z3rL49(3+e|vdEwJL{D{k%a{ESI<@63+K$M6{^}vumB15CM1%a;;j;clD4}Ll`b4sU zQ4V*nExoCN*>K7+%eCIQ2v>%3V7P4e+ZK?|sqZmWomQkrUPd?=O7Na2{4~_6Ww~L~ z%x@*ilH@+F9=>+vv7?6(R>Q%N>pY479_))C4JRpwY$H@;^VONjzj+r&uayfP0?O=%#L+>s<% z4=!2$E~R|z!LjjkU-K1)>^tPe;CbBB#`0PBHMf>9hyW|UTX;Fs7fqKSCr+rfVUSI$ zFls1hWou&oEbcf&aT?BdrR6d1;_uHFLPZ5Q>}y8$r$3EvUIyD&sQ#J_M}eEaXvI?@ z0C&Wm5|_UXQS7{edjVI-3LJJqi#YV%4ePe*TBg>w=PH3t?WSOkz2O1VR+lfZJ{<9% z)OMrp6@yk-h^EVxV~}e+SA>plJ{u< z?*zQ!bvb1R)51c1+3M4kK)^NkpSGU>!M`HWZ6_#Ru=yZ&#P)2I*4ISqPpqGf`SN@K zEOxx;M}~QCxv9sbwz~@m`nt+($>V?g2?!Vs-J42*-M#J!;bxttt)Dqq7Y;gauuLzog8aSL$m zihpK4Ey?J9Q(KojgDCPP)1&HfGfB(*au=hE(5lr?oJTFj=N2Kz;bTrPIbL(@1G=zC zaAqnG#w`3M$A!4J3|Vl*EP1}I-Y~uCdFM=s%rQ#lp*w6^0XJBJ0-_uFU9e{$P-i@7 zK;nNJc6It15_|4;;iF=drPrY8{y^U5&-toH5nIO+O@uQ}mHRR;>o|8^1L!s{A8|m} zLkl?mXvcP#y0Mn*qN`VfCcNdsTefcjU@eKc(i#wRKMZo8YdIHpYwU9jf3-SAW*J01 zx*o{Ct0Q@hgRll5n=pDrZn8z04as}I&M9$hnsROu2AgKSy_fO4Y zf@pUh&^JHzzR~f8HW<*3+w%!GlF%Ql*DB@Y`g4P!;j?Z?u(Z+z3)3Fdu}(~3hX>!* ze+8j^VDMS8n3C=H=8=)n_HDFoFLonS!NtNIU@zX2om{_6pduQn);Fw3?9UT@$O36$ zqXkx~6AgeKmF2~uaBpA%Qk!l$vxnO#q?+(lMHT#LTWZQZ;xqU~uDaJ4n(R9IF=1dM z{x=8#5AeqTO*D1y9l#NT87{^i4P2_&?hdeLTqArRC_t#_bk3W+V{VGB8q zXQAMvqnmS+z<#}xaCvtb5DFvrHE#$XZQvK@|hH~5mEg!@Z43CT_BAS(vLHV>x(jA}obl1QsSPgj%LrC`uVKCeU4eR$LI4Eq*p62`8B%>X(3O_J_|I42> zbD&v*!1Vu*y|)ajs*Bo26+uc#0R^Q)X{8$lkp`t(Lg|ub6M}?@gtQ=yf^>ICcSy%3 zrDM~%+3?MU&-2Fl&X4z8*ZKMW;#!-v)|_j`m~)PC-}lg3d{n^>DzsrFGkM*o60}PD z8+?l!V012=LF3A*vq}o;IX*^T*eP$y%{6c_R8>fd_AhT>%m>`|AXl1#ngE(lCsxlw zuE>}CNV==@{MCT+Fs8k-oZ-OSi6r7$(kL^6d!MYSI6nX-jx~NNy^gjEu;^j^jKf(I zUdVSjvFMPT|hFMqO9lej8u4X_D9Ev{(BBwX7#jJ-|eX&}C8bO4w~*C$Hg#TKoP{ z%v}BanXTi$m=_erXN#kSV2)}KGfGB;d7r1(fVlrcmX%-GxK&9n{6~V#`W1rabSbt5 zI00KjDERWN6_Q~H0X`5jy+n#4H~)}=IIRl^&I{%--6kIQzC=x<;j+%vpOx8G%N4|3 zCj|q87EA`0aYM|CF6Nxd(@2ULlL0%#KM0-v4;p$-YI}##A1&@{sBNA*Zx*$OW{+Fy z?~{YDb^Ta~&f9wx<3_=!MJN7#YasB*=Q8h4Rt<*@cWR`4+!9UDNYAc&Js{D9ZC9qyHU!ExMS?9i2U^9`6kJd6bCbD}f? zR7zi;e5X6)^1g65&M`n`|KXhvGfj4ixvc)9C={Y=>v7a@IW-$L?fNGKVF`Svm2T^M zokN*O7$^2|H70DYlWCd`I4eOW9^K3$uQW}~ z9Pwtp12U%fD%z7^pB{gJgxuKXQf102iNI;lbtnOiG~F8WimIY-T)2N5$UgiDPE10_aglDmb$i1&)n z?AoIFW7v_EOD98YrIPpAkm6~D90wEM<% zZtAbdmaDH_oL_okH;O0~E@A+OfwbK$Y`bnsR5L2YZBT}(${Qd<1fob^kWyF``V2LX z03qETfL+q_RO{-qeL?BAC@o51TU!*VoV%~DU5!N4Oi!35CmyLVfDAoO1r9n)_6k&9 zCx*vUvlqN$ydEdbRL-`p%@nWpqv(7j6C2K#IDR&`XiwNCE_ps`4*+8y%tk>1uuxig z0j#V$E$B8IfJ3^O7>iXkM0{P?vP;6QLU^$2yqRaLGM&grukS`C!*LKWhaYRexqkY% z{t{K%i_|y$JO~b?QDW*hW)6Pv0Zi2r3*z^pPs?J07=hXO!)cOpg&TtPb584mga@|AO@$VM)|1?q2V$Yx#~Q(hqdNU{AQtV?FN(|s zaoay#K;BMhGR7* zp@s*E!meuCAQU(KLhS5i11pEKNM$s2E`{eV+$fk0Zl0OAERv3r;yF3~66VnY;m$6e zmQeDqe3iEwWiiTRBFa z7{DIyk(}}Q97p#hy=OkbBeV26eqrhtse~&T9>)y|y0w7{qVye0q^eG8i$= zR<#&$!89_(u4`52IremymmfV#i=MG}4DD~so{;(?g%32ZgT1+* zFA|^_O$UdvCf{Ya@IslS*etHf@$L|jJ2mOhmr(bSIVkA;9gv)xwq!c`sTCo@6 zVCD|o8@lZtcPHY_@8@I>1jzgw%yS3aUah!>Xv|m5ln>Qs*RF zwh+(_3gesS%`TyQH48FE#pq2b2{oX=h(DOR?>grrYAbS0bl#6xDiL?;?UUm`#jW)v zxbWzBw!H++5O>o0eU9Zl#6kO*vv*82#zb#yG5CjJ&N;=KC^ubJL`k&+LzhAtdjK{(L$qz&I{c^mqSL$@%{tkd>|D9 zc|fC54^B4Ek}<$C#DVr07a`c-ZM2d&#jEyb;z{Sp6DGnk9!kx@%95@w7Nn&wED}k> zK&%C#_V|k5Jh&CL;$!?!y6$V=Q3>7iIs?ZHQy?$m7<~@Iu>c4CaVEJOgsMOmDbH&( zd$5XmuAetJ)2atqB=BjoVHivYrkL zBON8!l{kX34I^H&De=_Vyp<_%m4V|$N;g2=TKhr zRV4cvoMvp2zd8N2Y_Skhgq+2E_59QR#VZCE`(XcS$SE z4ZDb_e9JIHJD1{F;;fTR(f%Sxd36u}Pn>b+F1|@f55Vl5D{PNw7;M!ImWr||<;AY% z^XCyAMpryrTxA@Mc;>D5RhV-`M;7Ng-EK9Hmghmr!4+n&BGfn!S?V zJ(zFP->cO&3KqsONLT)1KD%$jojR93R6;_+i&c5LDI;*XLE5N(Ci3GA*{vqRN5jD) zg&mWo9sz8ZR_8VV1*PSxK#MfTBMC7;jh?b~9tP~$Y%zUmxZJ1bwYM}H99E}=T=RPX zl+uXWG07c4B}l5dCF(?spMB@>`i(nW({tNfJ7X@tSamtQ(}SX$0naGr4&K3W!)0BA zn23dqG=GTgd|iCo^AWu09m#0H+ofihK0+vwVUlfmUAia#hgKMIhnjEc*P_JFN& zsXzsaF0R}j5-D$*)*R%Aoxm;Wbt>fc!kcH(N}Q@IMA_X|XZs%aln-S-OZBA39(jAb z{SI$@D>C^OpS>l+v9rNR6WRF(XXMi{8>0S0^O3{5*@nMX`%X;nu;=xYJp% z4xj7bRbP@Bls&hOED2x`Sn*tLh!9YYz+AUv#J>lnRv1^JOLniZo?Ue3jPV07b%wt% zbsn1cbCiKJLf8mW_UAL^q}_P%h$LUAG}6Qd~3jjDS7Y%0tt>u6bx?A$~b8}nQudP$s?9 zNb;41g6ArfvRYene2S-hq=7aVE-cTsMlm83?&t8Hv+mj`4?d6VB}qBzyzw+GToRuF zxBE{6JpcrTpeF;0gSb$mUOOsN^zf1Q`9^>izIh4de)S_EfZ9d%er=;j z>Cy4;eu+9*j-VV44v9cN1-X$hXYky|b1iX2vcfSd*S}|+R8DeOChrP;Kj(r0Q%D|5 zf5OtKSgb+mH2UFP$Se`9qh5ydR3&NXl)4_e%TeQP8;|;lFbWNbn72MEwJW}AR8l$(Z(&`|Ywv`PZ75U+3hTSPkzH%8+ zdrH6U8saT$N^h&xJ;4e<+53%`7yXw#D)rciNdvc;82-XeTwPnNhl$^6>kdJ>w?U5W zZ}P)+f8fe2)l8$VIY+B!vTF^I1Y*X$a$F)`BVUXuSX|OL6b#kxjTD?!R7VGXBP|Wd zzph3VwDOL;gje=F28+@rvpxlBJ2HVFJB)#wiXB(I#OID`m|@msb#p)*I!K^ax86gk zRQB=o<8iGc=@&x>4;`8-XA*=r`FXIL$>g5dar6;^0B;e)N<47Egf)i)#F>pa55YQ# zWsTvmC8}B)$F_XQ+InV_T$O=);;=Uf2+*E9|yI}Cemk5gGd zmRW)}td^KzeZ#y?PgnYUtTnv+cZyBXQ2Qr6_M+#J+3Nv%N&D0E#PFdb?5{r+-C^RL ze&C8hQTom>9bjrH0xnT=; zdE9YjB2x(tV#>CmF03<~Lfqf>cV@&w={g^STda9)cwkBGj1!*9;O*BZvNefgTM8Tv zFL$;6a{YBaqh~^q2`?Y_PO@Xq?#-yLJKFRK zmNTQv@gGTlHcQ-5HH5eHHZ`v`9s$J9+C~bBM~cPiQ(#hA|H{^= zC9-|;jGGol4kEX(FjfYd^~MCLln~}l+i4l$Mq+V7KeG{E8O|>F?~OTKKKgy)5aeoO zE=!cJX8U}tTGG&Y5Zz^}yv&0b?i6;iwzwsT+rD>5@6lb|spG(?o1^RB{<|{M@;%o| zz4iA%ObM9|F_F8YB4^&cw_A;t{F8AK&Z7Oo@(+jAStQcV$Qlk98h%oaQ^mOMuqenP z+MRCCb;~a+e<>`mOfXh6Kt5S6|$j4S>%Ltv3zAR|5)LToD9Ejz#wI#^975zQK zGI^R9X(k36db+V?jcHUFELB3}3oF@Fu!Mo&%FK;YG-q=XxK41TaL@>^@v?k_HjVy) ziNS}&+Aq!Yw$4F6_XuhqxlM8qs*%ynDzS^ouMQl=yr}T4FE*4LZVkhYWRIZN zHY127s}W}48Q(CkZ@MYXkFUa%Dge)qeM9_~9#Ij)V&5cru+ugR4NEFHePiKAyQGCC z1&ui(K=ZUsG}IP4V@9;0tXfVA1|Lu36YoJQ(xOgW;$I}1-`wuUgG zgt4RF_Qvxt_k2DJsk@!J^K=yWyhYeAAi`BD;2b>eVtan%R!v0DKx)PF~YwvU2F`3#+u| z>puGN%CduV$<9t5^t(E3$~+vWA{o`ryF^{F{^2$c;^9JB`m?bu-iP2LbQ2SHGE$K}pc#s*W zT+LpzBU{eqTk~a(7>%GKQoP5vrfp;t_F_6xm(!((L(Wd=PXTAF!DSoYxl*2QgS+Dl(WqO(0DIQfeJJ7lZaor?x$o5+ z<2@^Xw?oJCXmbLQV}P9O_;6j|#p+WZO5QlKI)rVrGFNr-P&2Nv+q(t%)D7XF^H?TZ zt3dODjNWRF7=CX{B1&~%7>hxj-eq%D(H{x>2aO{16S;5DkKaxti~|39V;!a$#^xY% zZaVhT6+U%w;H3C65rw;^txc*{uY!cv)lnXwdk z^r+ts``jIM3FR*9V2qDbSSXoJaUqI-;k<6&==W7I@(1&V4Y~`lB)yIFVuwZG5OG+* zsB2X!nJE(<3I{=?eWyfO=GoV0ycMMur|GDG1y_Le$KFqTs0VmnUhg>E4CScR25+12GkqMLa1*g@ z)Z3jpBzK?G{8`B2P9e{COwJ*1*FVWFa;``W*Xi$_{+tH@U4{kET1pxHWrrm6M=E&y zQ`IK15z`0vD44Xn*XS!Z8f#~mm0!gP2i-)CYPqc`+!7JSUZEOrXQ*Mncfos3)hKc$ z77|VdEx6MkoZ`95SR3`4@*~>r2*`C(oKIFA*J;l(ySfm;kL84Goi20v5<-<2pK3_Q z6%8iINLOQbqNjnj*oMs_;q^f)m6Iu)>^PTc1lftNa-=D3axi}%{SvpmZuUwuWXqJ% zW0B!9vgNV7?WZ;C+HDWyl$QoBRXT?$Jtd-grt%>+M^;6-!d?7sGrgpw9wdXkn1bEd zjnko(fQQfRY#GLSwy_Ysr9ycKU*I4kE5w19s@d^zJX0tvc7KAbu#slivrta@_K|jP zJi;vJ`eU|E6{Ug{0OS=VQf?m3nWMxZ205>_;+8s!(alE`WGF|gSAY9|iC{mVZ|iPO ziyYYGL;Oj`=6a5U(ArAD6?L2H#GQ^NWh?!$g)!}oB(e9_yw)NfDjQla_3`i$= z$0xadd{{022z|W#5diQO_3B=Br=c%ZJre$*xA4n;peX z6IvZHU!onz?$oz7ZJURZ$i>s^+6J%PF1iu1QzT2Y%rLN_uj3HX3DZBGuP#s-r>NVt zt_8j_tK*8-ue7=~lF7xEYJKJ?Ul~?C00&WL6HR=dD#-J;inE38A|f-Ivr#P&E1eTJ zMQW>lW2LiGW!1UUri*7_UCqL+S<8Y1UNL1ObQ$k%d6y&zMTFvU-6-Q$DE$$9*NsZn z?B$HstJU9cXCfK7H1J(D|$(@k=oW|o$!F0p~gMwX!CGJ#ttbNl_S-&&b1zs?G?r!^( znIp3VydL^rG~jOEiHTn}8(k*Eh0WQ-%!38sVZ3-WhKrl4oU0885SPHta<|eyKz0hJ%=uLns}-jlYjskDT5<6EIgLtlNdz5-sQHJ!#Yf zXIdJ4ZH!B2nm5|OLbPusy7mY6_tk7hoYqgQn=MTqmn!X>#Y$hKx0s7h`Y`A*tt#-t zbmpQKtHo_UXnDY<@()XXcFmCmo%PGk;3kHpq(I$xKpC>p~}yj zp;c0&cAj%hN{fbQk-^S=Pga+&)9-6h3qe`>bjHs-d@5~;t_}z4;0JBlE=+h@TM=r* zFZDi$`0)oJoWHO(KI2vGY38J<4CK}f5WkNOEU#duxvxslk53YU+}u&8ahC(&tURg@ zvldfa{bo7yHhw^5b5$_k?hylA%Ez?tb z;f*n!?C{(ff@yO}?K*Jv{b75jLz&yD1ck zL9D3+lx_Ya^+aV(zf*nGI?G1{ai6F7Xcg3(C$bkuHBr1v5LVqdT-05XgKM0U&g#xG zoJq63)ORN_NDGNr*jsxhzEH#dX>(cU*=I?EG9~0zWXrFi*6=|NuU*_r?b^$T`Q#uB zusCH0D3oU|DLM~qZ-Kq;-kn~f*xa}cwK0ruRa?&ZO)@rN7cpT-wePoZD%_BsNkG-VfIr zODM%ze#$SU59kt|9~LU7RYtT4h_q}m{`4YuXeyVzrG9V0NG8%|7DUpL&&p24kZ?C223PGWA>Nl z)4#eAI8NzN8i`wZHzpKleJQzD9zB*#ReDgcT^*o+y!3K^LHkGOUDwL;fuimUyKOfVyC}`6z zTgtCN?2Q>&1ed*JEe+9J73`pV=*Uvp9em;B;MPS-4&yP*%NH}22E$S7`hSfkzw$*U zv^R8$J=O@3A1<8q_pPjLBQe$}__?;`8pqwC4;bNee8p!9wEj0ug2-a%OPSRRuo2n? z8k0`xHe_nm*JF=UM+1Fp$`j*qReE%m3}vPszRgZL-|&5r^rm{g>|B)(n`E!BB*Tt* zj|hL`$JpEw1BVj7!s~Qg(bkb@f=HgVk$1O`Quz!f>zOM?n)S9`$s6bq$ha2L2Yh;A ztDml2fGU}6+>=3Ww$KFYwYU{58ub`0Xx}pud((C2l4nx|XE#0WeQG#;-!Vghc2a`t z&yJXwe?49RH6?NkADS&Rmp=gndy(vSj2|~DMus8;U5=Eh^|FGoBHwbyEc;1k z^;UFRCGhuu*W+0Jq0*&v45x}@^{R^=>CNn0*`U|Im>>UvIwW`pD4=vncG|Ju^`?^& z+R1g)@`d-f>won2r_FB7`^Q9cA9ZmVSQ9tzgggl`C!b>VPnw(lOkCdJ8E;zUsZno~ zC7M>BO>xe-^wM-h!&V?p!sje5U%*j>b~lB`5ziN9c0C3lxoWXm9#-0Zspa{Bd=Rpd z$f!VaqxaFm?5Fq*$L~lEH&Jg+t%4;zm#N-#Z7w(BQ@NnnvbJYIxukNsGqc-Dh%LwI z*TkOX#+I~2MxUoB2F<79=TOshp*w#Hv708ER>O$L74Ua*RA^Gi(+vtUZ85|VuPje%7((UThZOKp_)2?a88Ad=XTsb& z_P8j&@e+FH8V0yyo{W}f-b;&r5{7*eZ2JJrKc ztPVu$-AIc|+O%qZ3?4Sw$YhB!=v8>`;u`GWPoq99Cf~LAJS@5F!ep&2fZb{AaNtLY zFsy5mQCT|J*;-5uh|csN>1%&Bp(*gj6EI3%Q2syWfh@~~>8oBF3x?gNH=#fzu~^^$_7oMnnA~?MsMPJiFYGe4J)hZUPj29tZ_ZbrWBsXhDP^RE@H*ck?`d%h-3OLdy&!zB5&6byD=Kp{tpdHLTW{)9m=*JH z9eg9!pen0}LJ6_Ss2}~go{aF!*d^ih+@e>MsC8rVG1egeO_QSOGRns3RcODzC_^pP zST}XFrdtg>7N?ls6aDO#a&{%~rw*m!8MX=nSNF1QT)Yhd)=m z(Of=lN0^cJJ6{(K9cxP62amiN@q_k5U*yAD$LPm6y_IGTMm=qRcltR*r&knT^0@fA zdyermT3}aXMiz+`IA_=TM_S_em)w&?4u`S=JOS?anewN0+aVM%9teJPzYPIm$=@-S zQRB>&#}Q=6VF(CQwi|6fuW>DD2zA4_`keE?>8{7m(bHs;%RXF>aw=*OIyi?B>hA(lTRy zP}u>Jz_W@sY@`n$Ir~oxZ=-iXxWsadhYjoC)RANJbRFM^A0TwdY{}4%e}6vYJ;e_b zvN`CQn*y1lSJ^9fnqDiEkOK5n3EzL+$u6c~xU_2MS>}~}GrDBvy^oEy1u9-a*X)iJ z_2P9nI49cQcVA7N;aUg64eSNKqi*@!hd_Z?k+}e6M+hoI_Yg-%`Qe~(WRM_9z7%ml z#5eqCx-e&v&&s*({*Ev2P9!IKRRdz=26+e;82|hKK%Ic}j|iRwp0-_B`p9q^4)hJL z8P>VLc z=6a(D7c;LFGr0VrU0=VIbpCU`(;+}HPJdQv@Ck$mB={KfD`d=I{Rrrk0!XINq~NQ^ zSHE{8gLb=+?<7P+0e}5=p#F*{AzWyGUIU9C-9_-}H2AKu-n3Fk^H}^pHD$qtKIo-= zS{YG-jzI7R(fdR19?9ELRU8o6)SoUp6x8l@W%aiwyE5ldmg!*z8V;#{l}Pf1MGGvt zg*T3~Ig=1UO1Sp8lw6TCQ2rOnAs3+0kF;ZG zltq0QaS$KKIsRY7FV$AUW9Rgp@Z286**(0h{3v6WDHb(LwI^7<-|?3bLWoMON2QWM zCC>tW#<3PqTLjXS1p`#sLsSIt=A`Z?=v_?IjVO_A&*Ukmup8Y4J=ZKaJ9g9lZz9U? zSD++p1SoW@3LkHYitI5q;vjGjDWTu)OnC~?S`5X~#D*)x4rnU@&tZzH>5u5flPh8d z36MoRyWtBPchCv|bhUWKO8j@V4<+rW7KuOX)tDm5f#9RUh`=I4rADFzGHGU-F& ziIK)9Ch+^^Xpo*|nA0Xc;6{EX`%ES%3JEf{dyc;0zZK~#;lxi}X)01Zfrs{ihPu%c zb(^vB@?zHZ){kEia`zTpUuMnCgc<{<0ROGoyQ^Xg8Q$yYsi8H{`j(_1*%@2~wO>1q*s|70mS)rbNucc93Coy7r%z zZLB4n+ZehP3y1VCHX0q=!XTb04cQPvDA4x{OZ7Ss$j3$`)W5-XnM+N^A1#i<1O^Et z2zZePOy$^QZHU!kru1pd<^9yW+iKmehq?&dv9yuvm^GMuh$Q4qxsV4Ed!YOH}z5u_ct4LBq- z;^||xomdDSKF(e!Q3Qbic(UWR{x>*%6a!yvy6`-mw$^d0DmV(+R{Z^g0fQ|X(*C&=-@mt4~6Cq_3zDP~K zlR8Lanqn5i0uz+s%=rC^xAGq>MX1x>(E&3Ytn^A7ic=yOsV( zHTnXL2EZ7mgEl9-lL7IE(BphIn}5ZUPYLE2ujEGm0m%o0#~>^yXCiseF7ny`jOmWp0fc4DqOOrCsJWG=v zy!(GU4G@O@x6}T2rv2|s1J)qN=;!_Qs_``3m2kQJ;=2v=32?&Mi{hyxPa&RyU4gQb z32(7v6-ATCf0o_9d3g?GfeqB}Ecz>+FHVgIY@q$Pk2VnEZxqp=TGTH|6g}^Q@6$_Q zeSSXoyFJM<)>@S2Yx5vXLknz>36sN{aeJvV(oY@x{dF(LsNGlfu1{m3D^3$(Ls z2LGk)T|;xC2P@!?UxkNM5C!0IJmVzZA}>%~eSMS+2Z6o1bzqw7Y{9pdeO6s9eeyi6^u^ye zgzwU0Um&wVL*Kr?VaK;-?MDJ!O+zo*!}Gf_rNV_hP*rikWi5O!1hAtb0XOi43+eE7 zzlp${+pVinyFitV|&f|828X63m57(?|N*d4Uq9#=SIsN4p!nl|uC`=2#+;aJ;D ziH;G-cCm{uXD|{Co5IGMHUI$Q00PoqYc}$0&Mcau9;rl_-r27Lik*Wi`(OJ#ei(Z} z!QvXAA1P!rv=#P&2yOt$N4E41j7%H2w#Or5(JYq%KE1436eK@2f=MBka@ z!#3sEt4ywy-IFI|R5k$Zg8<6Oh@1flJb{*8vCDG=@LwFA#?5&_)D^(d=!vWtU89jo z>YP+54Me^W-6O16j<*y%jCAM|$joHitlr3YzgbU$t`F1QfnT1lh}8g5t=AdD_+C37Y!aPD?V;bM zAqllFhkL8)ngl43i<3Wy3*@#f3oQpg+}o6ms5^jwVlA1)e<-5v>j2P2U0`MHkLH8; zvD!Aa>eaNFl8f2P3zw5f9gCme;p;gChzsCvt^&lDG4Iex>eDn&HJ4VNisI7Vcin6& zqCi3yIlE--1!y>D;Q|l!>x+e(4Sx{>3M?aWJ-2~1c70@tz^y9y755nmTQ4^ z3K9gRo8qXP^A+V-qM6bn&$&PG2L)6ebhJ!&l#RUYAfSuP! z5cdSyr=N);m=xj{_mxjbu>Z)KyIIsp9z$YS!Dn4MhH~`0I_?6G_Pgkv&pzMd`u6Ns z2VEV2QFEWJUdOyY=Y0W~4~RbD{hm<;A+L&~QeyaF;cGyWICMMTG!>&@s0J!4#uaJ+ zo%DI}vF(DRrO&?tkX*%rAY2~EkY}w4coT^WS#tE3KtFF~zvES?zwfI~l#FYvulyY{lzY4Hr4u1+SeYwa6#JxJ85PYPS&7S$~-6tdEMiTO>39 zcF|0vClm*xQNDxo9G~zG`-?OrB#4y+2a64A_@_!<*H_Uz zk$5|G%99w?28cp+u>sNZV)Wh34H^IfV5K~0B@gpX8w?ECBg&=hc1WP}fcL!!D;5UI z3*(q|$?BW%EoC@NBwV@XVEO$Z7?5isOC?9QEm;Aw?Bfb?qX?yrGV#@V1WdC#ah_tr zUt6dMtw$ybldsUhdPJKg6>s{mlBMS(x*7H=qlsK)642wq-%8tgL{!C44nE)~9orLI zi~Sz;)h`fdDp298sOOpVYQzAnR;SSuxmDo;oTo9zJ?bZ^zah8GE;y8h92+R`RxYFS6dEu8isPybW*X!hBNM{e3^BVDQ&A zt6mlh(R|pcGq;%TYVPyZ>z0Fz~^00|2FU z*XY?#kZkCk2AJpMmG2pq0-BHL8L*lg-u1xQ3s;kQcn^Eh@QF`#;vA*T)K)Pz;u!7_ z1_Po?QqHkEd7Y+gLEq!D`*7Mzrk$rw-R0&qvhK+dP4G>c(ao|yj;^S$Jtyf#ZI<*O zC1}CiN#M~kG4HP6Vz*(+zH>IDq5mJdSnke0I-sJ9L5zZ^MtrY1j17p^vIX#P#^uju zCXh-=o6AbiI;5@eTkZX$*8p6LrbYz=NQb|3`4hrd4F&d;aVy_g%0j^JMh5Gn2il4Xn4o zi~F?|g&OM;>Qe{!LB~-_r~=4*`V-7(NSADYA1u~iyXLmq}~_RzP`wUu0>$x4tRF6ReZLJ51h-# zR;2=j(l5fz7_C-(PmgGy?%7-plv{TD?s2uKvLBzx}N@PMTPvNRARl&(@jGU#c*Gy2NFY@A(DA@ z15c;p-(g;yhipUHACh8Aqg$e!{H3Qqw&=p20hxoaFhM8R5VV`gS1nKjyyO*G9$Sz#1h zP&z1!2o*x?Maq9rKAdu}+XmzKWMDc&ZAvp)AgwTg<-3=U5)hD*=QG_2MPC@- zQU{}vHBwve&>a79+d4%#{{L;Yfq%Izcftl_Vp6aYwT3dffQ^plBh$7&T>dU1H9`(6aIRo}2hSHbvhD$oCeMv;ulC>g3h_ovAy-#B zzRKCS@U9DsiaO5{)=wp#|G}Es-`~$i?Clj(M|}HeimsXelAyg)NmNK^qkiAe7dby~ zSX5lRux?;rFuk0JwXZI3x8g>6jWyY=X4;wdi6^muQanYAeOg7(f)@Yb2gM(pf z&Til90^@m{`H_*t$0R}>hkeg{L#TX`K6>)Lot~V0x8_iJ@7C@OTpU(bzThHr;D2?hklIRBGUt9SidwMNssZNki2$zv5sj1P)&C4Sxl`}M)+g3#N_5wnM(3crWf8^EPpcAk-`X}e)Jt&agOA7Aj+)|NiW0&kkIvB}HkR}1eSH85-H z={X&1(_gl=1<1+$3Esp4;oP~t8*0e;859a-oWo%TjD7?_R$||t7p_4K#E~yNhxF*1+*A1F5(!cXnm3V{t99gApyiS zNPnXSk(SrAB|(LZJiWGwx78j21?W1gMO<85cGTx3mI_PM3v`oLSpytfcr4?AA1}|( zg*S_HBnDozFrm%z>MWrV?hEUb3s{48GI@2XCqlG#YAj zzhB&Rzh+ZM@p~6ml#(;=-3-yg!n%I-j}}@m2aWWYnn5zagt?ix%RcP)SlL)1&K~CY zpPYT#$iF8<+1=H!qr&8uoPN^i`R(YSXOWgu-cHF}zeSRl6z?ZCqR8&!8q&}cH;|pV z=_&uozJy0dB>o>9#DYYU$Ii<6!{{UwGtKyAk7gz_lh@ZW|nv@@d zBxHSI^7oBSy)+w|QiDI)5#U{%6M!Ob_zJnfU!1;YM$%apJ6Im)*nLuX^FR~Axv!NV#Opo@)7sk9#OHb-$@OT#g5!6$B zBnjY~YMG4csm_m8Hy!2&4#o5N3Fa@Bj&l~m#4fWX%XICtxLvmy=fjU{cXajERG|h= z`-s0F@laGgZ7%<^>b59=b8qk-y5IP)N3gfe=@Bi~OS&r2N9Ri8;05};9hQNG2H`aRsL z_!9QkA$Fpopa*8VTwldCi#*+|=i_ogh#bQZQ+4|=(?0IQO`%FXBla7y2IW@4C2zf7 z9l5IG{RnBEnVqI#F}oM|0(}N{DdD!j5h=)~IJJ^C#My33-f4L75)(81fzSnSdpcr9 zk50g`;)gwKZlIA8h{k#2e8rzg!siRAqrE#vn689VQ@03PmMCA5kZF(Q|K;&o)Gzbq zzR&3|4=iG7FVS1GW+J~Y|7_US8Xt$Mlm|B8clHNHRn_JSu&+p{5cwigd~Rga)#=5@ z$A79{er~5kfr)-xoGgULD4C*cr!=)fn`)?GRsQAvAbllYgv4_uf!u3>c;j~OB~Fan zJvxp&27(WNzo2A&iQ#fpiIWnlB@s|OK@w9B=JJhw8hPr|)49mGzS+RV6^Y}C*clN} zFs*M*8rxysQ~efap}N5)!=IvcOWl_EWp4$%^{Q=?(>(hU8d6p-xVLoeTS-h7L#H|( zxJ+wQXo)&xIqitRj>2VB^w-|GYm1WUeYW@|mCm05IN6&a3j^0;W4DO#9=L)mgFBf1 zr{UA5uQ^85?2=EKq6(+v>P z?^4mTMR_4gFGK}*FS(e}?G0=JDq(~JXy}7af9VsP+102Del&bOiJhTfrzFI&d6gU9 z#{_G2Yn7Nu;R|;Lzua5D(tmU~J1DQug2c&g?+H%<7iSui!_Zg)3jEy>OJo8+h+NO& zh7?~xE->N&9(5k`H`$2y#8<9RaN_=$cx^1Qi0*siEl6-KMdjYBTCm0Yvm|2~!t@L6 z=dS3lzrnawYR=Qs7aw#6(|<(!P+aX^<55zZk>+ln$xy}_;%`GkUSjkz*CtTIhOtR8&y@rc9t7DyFW!!_)< z2mnzvbOVl1W;h@Qi){yUH&ImusqU^^vY)S=NjDD+6`6t-5^=b@T+Q@L?9Oq|+G8#7 z(VctWovz-WmGoZE_KWrpcjs>a90Pr<-ShV~gSuaSP|bo5MIxSVDt&ujcWp7|XUoeW zA3E@CsQA$Bt0GjNzYjjiwtESs1_Oo8eY-Xm(f4I2H4`e)>Hgaq5H&@F@`zN?{G#o= zZR-9vCGk48nZ-Re@-@Y)OHd}Y9y+%-a-708w&;(oT&$ty4WB6BuvPjh!O7$aUNy8r z%$gkr8p`vWpW>>h2!{8oNvsCikTMzAjttTA(;N zWoRjMkhq4{Nm#hSVWXoI`k3wCvlGoPClj91tOOVEfLrqQ&keNKVU6t)DlBPfzD($v z58W0X_FVOa5A~qlR0Xg zkpBISzOOrcmhH*|WRD2pqmBiKXLZZeJ&r|7njAa8)hw5cJ$t z(Ov&XPwWTp+!MMeC)Ziv{`(3L5GR~7{JFGhcMA;?z$RO1(#LWHGhkb?G2t_qa{V9a z?%lkqX82U1z`xxZ{vPF<;P?dl*^f1O$VO!7+*ADqYW^ofM#mhuG?Q*}yuNYuRLgVm zF^gtCZ38JPx9}b1k;<$=C>I@QdU3y1=eO6{NRDlLUHpW5J79cfZdr2SO%@aP>*^U1t#o<9bTz zDseTCrz03B`_{YrZ;Q`+zZSMK$G93F#V_7(v~He85mteR5lI3f?qD)aa|IgOJV#?q zVWv&{o2=0!>iq@&@JAQ6v-cxG9P+$kDo+Hp1O8$R2(m)oqr_yGzphulq(Jirt&SU1 zc-sdJOb`t5@dI#ZtT|>kxppN7sDF8GaedB^BrjbF4hKouXX{$50B z-UY2YpowOH>JlvOo3!jg)NI|q`|fi4@=oo=d>B68D5v{B7KdJXu6rZ*k?FT1iQktl zN8Sd$)4v^WVE26GQqs3XDcxY}P#Ob5th+pPd0#-Egv{}?m4W6^*&x4_^W_(KFMT)r zP)=Kk0JKbqyP)Y<6+JI>2=3*>*H89DRE3V2CU5C~75V!$*OjnyL7Iv^l;;s4O6-{R z@{jPL5qAmfVDnLb1E-pP(wRjmah4jS&{(}={|*wETxsk+C8zU+)t@H@{?4A885T8I z?nz7toPQ#PCI0KN&$3~2Mpc!TROns812~XdYTWog$5mH{}hYwM2#OMULtSALb6BroIAmn?KG;UI0? zL#B}70@W^HbB{}>8rVbL$Yj&5TN-?&6($H{c!!4m*o`iQcw=53zh|Ao?v^yZc(+)=O=DtUGNTXT);06%ZD6em zSI6B#2EkYkWc3SP2re>!7bT2& z)k4%v;r*~@J89M2Ju7Be$HQ4FPH=e3Dzss%Gdw&}Db_zM* z50=lRFUlXxj+i4Qa|8r!BO}BX`#mTS1}xrh@VcN+rZR&qA56~Dej@eCR#aE~^nm0uk4u3Y&@3BY zUJUVQyTwFhUG!yVWl%-W?Ri0!h{Ll8*Et}qdV*>j7`5U605heO(h_H(w*-(Yn!_Rb zp%ia#zf!QfFdk{*Prd0mmSsu|bLud_^qsDnbpi8C8|NZ21gt5!PnnnKrg2 zW59!bYIh3SWaykJIZ5I?^*F1h?rQZ9kgeKSSiHaUJ=%P$(3Uob`A#s<$S_ZBL#@}+ zt#UD!?ZYZ^bv+xN9M0hBuZos-6d_R7r7i30XRrnF3CMf0UYFEBFc6nCfc0qLeed~p zDpu6UbpwPvl?#`@9ov9kS27s*FF?x$r=JIL%OjFJ^keRAdQs9RMQMxT4uvjh?r#S1a{Zb3aXq•tE-oeM~e(cXNDpzv#XuEOZKE7 z@2C34OZ@7iGrD(~)z@hD*ueHL-3FF`!`5{hTqaifD>uv*fER9?nU4QspMe+8zoHtN zTwX3@<}e#T9AiByx*<~sXMDYr0@}DP=pkC_{AsU5dk)q4JIbZomY(d{_p-5I&m0*r zpD0e&FyD0_8;YL@{`tL)j_w8hi`uFd63>;jQVQ#u```90n#9swcdsTDKv_Nt- zN5@rt-KbOWjvyTL7Q(e}!lpVt@6f^+?|JsfYLE-2qpe-`lkMB9QW#iv_+01?nGd4Z z&wLUAtj|Q{J2c31ab2iS*#`ruhBI6QNAwE4 z)!qa5S`>_^+GFj9X4_U9;fITtd76~`j$#pRTmi32KXTP>Js`=hd2Gt-cDwFmR$V#N zx^$9L@C@|93DX~UuDqpJHN;!x?vB3_=041q8vE%y4DK}WrN>?3+(|o zPfWhEvqhD7%wcAcngHA#%6C3X1Fx2a!7f|3z(=YCh99nDOF(OUG(~sD`m-ryWyT+| zUaJzWZNYXp?!Mn9$<%6FIbITz;952_r>RFgYvg$%k=usbM5C!;SGrqUJz6^V)t4|F zG=&l#+Fhw!4;5K2YoMJ6Xo6-2w5ZgWo!df=9+#v6n*q&`R0#ytXh9i=z`Vt()S9~V z;~=&6Y{Z*hK|kIBiox_A=+mqR4+)Jsd~x&@?Vv+$Jf531dZV<$^=b0Q$L}`UZsB?bT^^L#(LY9axjGFt;N;O>sY!oNqLRg)GlkQ)&@FHo z&NbyW)56lSC$dT(M9%U4WN%#MXn&ncApL?ZZHVjcN6i+Hhg|8Ws@b#?S{QTi-6xOs ztT{fh^^A}v6j9t8+D2>*A0HO%$09JBpeq_JM2u6CNxk1w6gCQZ6GfBf*}aaOoZuL0 z;sS3%he0&5J#DI)C}Rhgtw^N%uV(&*68#Dz*!?gX=h0k`8*PNTdSt~M?*|43jfYLJ z9p|;re$vstqiT9fczpEpPUkz0k`bO4XOn~a5wbyKZFvTk7>vhn1fDhL%hSlnNw zot@Y5q^PpBv<#!8_4J|k_%>ZIEl1Uo8t29cG}80UJxvnF1wOMLv@qkHX2o;Xf{C{x zku>Wn@ucDt2Cq9yJT|_SBJhXV=LDFp>RYszU4I8PJRn{XLf5)7ojrAmY=_U`ki4`} zm`p~szGNnC2hB4{0~RXunMOUgSM=Lp@ClPl%-kkwc$S;PLd?Z{(IErIpZdTtMSD)T z$mQ9tWFZ!({aZ?vW?k3Ct4-P|Ok0_wY_7<(6JFz{PfNUeyfg4v=xl5o9j#_t267Ca znY_AVg(6ZcB!Ryk_x^jhMUD>YWOp5DBW{xDe`1t>Bc85H8X`siM|28GH}=Z(3XE<; zA1R;L9Cd$5a2YY}jHVFpUpryX%wkkxCP%#ym{@dfM|ST4#ok;fIAPyj=pc072eP|b z!!o6{d&`+6l)2#G%Hh%%nfB+iQIP?!=}%u)Ij3a%M-Vdh(o3%Bt@u_vCkjvC`^1aN zH#D9)(>5gxQ<%UfaE^4OKRY2o6v~4nwQE~8_u69DKrXu1nq&Ij3z>rpxX^a`KvcHc zt02PBQ~QX$!cxj?{abw$7RXEB_<)4>*MTHpJdiCX8P~f?E+PhUXk=(1Hw1o?jKO&D zdSm3)UAGuJAsWyHBFIaLn8YS&E>vE)mb<@JO{U0yX=CCBn9PWxj=~>^J(@9OiGr)I zmp*vAlP&UraAwt8tuc#xy;XuUgy6q=KMVfU;;Gj+Q=AZIB@j?gK^3%`%L(cqk_cURg&^NX+mKK9m zr-){(6;CD@-32K7uVUGlnT;^sc`o71AEJG#%vdJkKu7~&A)9Z2LFU*MG)#T#At&K6epa1P`WEcnXxk-)EN&D~C8=FB?39U#Y9k-}ak; z@Hn%fl27~wHwo{U-*AlM(CQ@4^?|xh-?XjS4X5FRj`C$3v*l<^d~(Jz#IhynnEq#e zX+8Js;!&J#bCGx7S{R7y+`GS`jWdomgd(3`jvu{szvr@`qhJI4@^f6|eTO{geI#NM zRM1j@ff6SfdKP6JjfX5gjghh2cL_HTfua{H;sYd<>Iv2>tVavjw2zLIG)?zld;R?yPMq;#)Ukc?w0ER{Yb!uK#?dS z!1`L&RS(pL;UL=|omZCbiFn)JbrYo2YYKH{00GciApwg1-6fEa8JAU9e8ho=YUc`A zZmK2tRCY|;5EPC+_b_@os{Aw4DT$`ExW}e};neost)GJWKgq_j&$5G^D<=-Urfgns zfdcn8*?2Xd%9Bd7CWBIi6EL{aq!+)6ag#U)tlyZ*Q2cFqHjy$IM<9VSg_jfBxaF`?xKQR;!1ga?soWvhbJ5WaZ5u377QLxgc`RcCz+1*#@W|) z$U72qs?;I$zsy|Y5CCMpA{)fuWAuc_)BATX?<3=6_rL-b6Wek8-Xi>G_P@}y2lDjh z40@x3*i0il&SPaZF7w1=95i|i@3_E8_CSeHPunn!u34skWv-P6<`~RjWvPaX3L`Oc z#5#QuStZz$;4h7h3(m!jGM1BaNxRZGVaU3a;gpe${Xl9%gK3(3K5||nL(|9gdwF0W z$2(}PlhHz4+t(b*WB&~YVX>ioZ7pPK0+W!6sF?s5ty&n2y`Cnfq#^gY!#zd2eFUYU z7=}0tQR>vGOXg-Rz#GT0aonX}$DjmZW<7OXHJL^9uX4Eyy=pemc&oa%nrb@p#TL7+QnR94Uwidf{eB^D=>6tAPxPC9|h2sZ|At&H@v?p)kGTr zB3mhtQ?H662$e;sU9xBZBqp%X&WJLBqt6Ac~MFy*GH^#JZpFyK8mh*#NioD;hgsESq#_ zBG~#$=b_?P>F;m}RFfVL73u%|95{4?D;LQ35@FQF`cjc1#d+V zd31dcp}2&>2<{Z#zG44J3f8&;^KYSuF$mqsz}WMjCY_T9!N;V46*O&)xr7C7p8$zb z?qh2p6m$Cg&8>rNL4*$u%Mm*+~7l3u52& z9hH=}^@_>3ex6?_iJ~*VvV9j!>O&|^lmso#h{p`kTlXxQlw4KQ=O z6`d(Ftq6js>(o~hOMyIC3@M)jbhDz@w9j2|6hT(n@=A=XO}H9y?~GPyOHQZqxLCd3FXIK#(%iSN?cJq^E|E6vtTccyzfB9%p9l8spm}C9qYwanXZ2+I@9@u5 zEUyG9nxR%@gXVfL*X(U|y68u$NA$2SU85J6bPS_Lj6L`E((O3Jrg40?dK$Cd40N4E9 zboOxWo%H3<7`c#3ZfOgkAkK1tes}b2F1s`k8I;*CGB{JfWFdlsRJ*SRwTFl74jsDr z)iJJ`MMf>qjlg$TH>~{YCjr}7(B@t}*7MZmaLs)`c|^z;n=t%mJ6}7y^s4~9zO<8y zffIo7Wf;_T2yW*y`tnWwX%h|ngLKEynb$?aH@Q90MIg8=1pubMrUr;AWmLkgaEI_E z2KR7n?THVx1!D;>3+89Kl5&YB7H-)@j7pHWJpIzbr*1+aX7S+-Z^BX-2*3t)5!R1P z2XFdJn0~Y1U^(9K-x|=dby`B?itT>@fP78ylO1)Tp;Q zRt1~i?LoN`POBcOTZ_?SKVqJGe`RMWrU`E{+9o9PiGJ2%#tGN z-KH@DpfKUz>R1>~?fEl!E@QxPb#(HygxYYRmc4-dCYN&TEplBuseEZYzwMUR3wI?ySKBM& zmJ7N~Y#o1l4djU;G#WEvTL;f($O{1I8B0kNwOVMOdzf%{o&AZ^$49e(X>ysdrArvK z$_X2L#@LPm<%~7vg<82n%NohxgC3le7|^pD^yen;ubqBHWxRoRvsr$=?ILM8pHj%c z;XqDh>+bxMazo60fS1(y9_{-<0@iY1nU=4Ia1h<;)l^lgq7Gmv=Hvw2S zu9;yTokMpy#&Wtuw+c)FxOV#O)jf#s6;7Rkm%@Tu=j4B?~lbG=6 z+mDA2FdnH@z;pGs>aw(?TEpu1R-HLORH|bYb(B&1Vfk)VME*2r45L|^77eHXTH*Zq zJXJ;s{R^IZT13g&p<=@Pq|x{awaF1=CoG8A*F5?oRkbI3qjP|3kXm)y=oP3v=M`3r z?Cs{dE4{kLa16*brGTn)wj`Qj8?Nu+LSg3dEQ*)A@Df$ReR{VN%}0cX*cJU*xnG+C(~B#pcv`ta_8O_ zmNtlnLqBr{iipe3aKGZj&|Dm3aFWX8Z(r(o1uV4(;E%P%01-W0q~$XuFjkwvhoA1M zF$E5yz6uLR_ zjM>HWkvwqZ7=8SWWmsZl_`|*%7Vo~Bk+>yD-B43VUcsmiQA6-n=&#sVAm{u0d0k`T zQTRb}L%G!h+I5kqN)FZ=1qKF9X--bTBHwfoi+uhQJ(*Vg{P0gTpw~$Z8^a2W#oxM@ zOR;9_V{Y@M;>Iy^?Xrm+z{1GhK#;!KbyW>Ia-BTcTj?#y&NMoF^_i-T!nx~{Btx~c za398QH0VPFL*nIpQ##3%k~)g!UR!T_#z{cMub2{f`{nxAY)1lvHH)2_K8QpyRsAy@ z$9WCLnk0|(p8#!fp+nF(3*O0B9pB6KaHlC;+p2Vdc{gw>_Nvy%uS62( zK;bJ(9gI!?*+4$KeU0V77gx<>tS`r7x)MVoT-hvmgH^y$JH68!r!CFZ)OAS)yr?@{ zVkA#DK~on=-X}M7Q$9%sZU=Mg8lI;pl56%xOvTrQIk|~9( zgHm8sy+dbNT2Uhj=@g#E10zRcNp>!aU7lp98x6q!aY*ni-QiP8!P;Y0;h_ASTi)Hp z4M*>s*5#vFE!Lc;4@%5L8Uytv$E<7KsJ%LBXRdzLb0JCMs;U%J!{F@^?`L;O6GBt%ftsga`V^q9eSqXo8L$Zz&#1 z$9ExYz%n;bwY&U+FFJ5a?;@{q3v>l;d{R9!!+-etepj_coZeQ|ts9zLiwx_LFLnA8 zh^cC;=ECDE0$0heJ=Z0Vm=p-ouclECN+Io$4GTB#OKA~ha<;(O*FW7NFCP&NXy)`= zl~!*!{A$a`rgREACe)QDdpu$)(=3oh<&k3^={YEIKFOoMhn6_%$`D^Z#n;+^*IS`a zUa1~@p{C&V(?RSecHjEmv9Vk7pZi%YBXOuXB5U1qw5;?@x2`Qq%gTpbV*s`O%OOkX zs-6S-o+q9D?`X}xxdxk+q8C5zZ%ozGFw_1N2MC`|);8)r!p;k~=V$KIc_qoNV|C**xD#1^FlFKk^jCp8cTbYW}yl+8sE$Bx`RPnQ@cVY+4uRDZKul;Oj{l-$q7$EsoB zJs}MfVivMCB3Qv*gIt`aM}xIk-Yop_X|YaQsTWh9}bRbuL_sH z=a8CXtX-ukjpW?odS+b{)e@JxM+>NkF~tp{{keS|GU4Nd{?~HRpbwY7S%J{Ld2NYa>#gBt6}2G6HF=mgILw zzMC+Q%q?f;;E_$Ji0x|7H*4~wfo84mIi@j_QmFHLR$WCBJo4j1DI!tFw1ojBK9zWt zt_DOkYlYDr@NpMYJ8!j5DUAgv|ND0wkneSDi)*8l@UAI6N;Fs5;eT*b)spV$hI}%1 zAiHHpZH_G6lVo!%s~#@J64-kk^?i3UvO#vml^47GIvi?8-Smi&%c;m=-Yl8iRPp+ifb*k+9vl?|{r%k9d%WlsWJ)Wk>1vKKsj|K|c7)^snQN`6(~<6@ zec8$gY%r%}NW}K$_?L8Z8{Y9?tUWMCrZZu-mNJz&<4s&HWc_N)YsnM$vt-?Km*Dk-!I9#9^Xr`|I< zK8&MqX7gb4=iC{y;^I|b=$-~AQrncI(CnewqqeJIl=JKk2s6Rb1Q?F_j{p2p-zfg> z$>w}rgxUi>fbr1rFnm~GRwD^hwUD`AwrHJML0?i|3WQ9*K4uWH$ZxOrL4EbUI49Sj z8dR+`P-5D*vN-dQ{PW}Z%Um+nfqfh7`N&U;+o_ z)h$@zsdk5q+Wzf2D)xFy3d@8npj3VRg+9=7e^5~~C}OP1!&shU-~_WC?2uxT^%%bA zRGrwLLYn_$=SwX6)6p#jho&{(`H)eI9FnXlk3g*^i&HzDRfo5{!NC)x8E699w62Q` zhjHZ|1+s(XM~aLblmxklBYeqk!kHAg!{mTUs_S6O9;IyJJReSL_y#%VvP4InUJDr_ z$DKf5huQ+^Hee}*;7Eo?SnO1htwhwX+yr^{K%agQ?h0TgvUSq0Fx~6)7%D zuFoE_e37F|e=Gh$6uRuFNRN!Ul}Q4xmCKOKvoW)+K+Al_G_jQ2>3)vslg0^6rm~g8 zIjf#g)&{ka#-qaW-ZjQe=Islq5>eDaYC*8}SD5D&PG#8ES5*`9zD={Jy)Q+3jE#8x zM?~t@Ob_zk@Va;WWcL5UQE~7i?d+rfC0troV@$Z1O;>rZOuFS!T$(I7Dsn*9cKgS} z7G)9I?9qV%vNtfU_q^k6f+UBAbhnbp4I<-u0}r()!#5l*wsM58>E6_J9M#d?4GC&^ zD_GAzx}k}&|EluB#oy^wI@46tl+AhoLC_R}B7YnESx4r|_xdZ!*y_r5gCcyM&P&M! zJ}#`!3~+PJ@!u|$RMU3%v-#6_yog{&td*O-YjdmK9l&2L1dZeeag#CjM>}Atl)8@P zE+yTj%+|@`I7JV8yr`7!q%)QV^Kgl>gg6zzP~=!=X8L;fmf2>`bOI$$;ve_uapeCgr+ zC3%cbMwJ1x7GMOY{Dc)jE%KrsK`LX};DuoIEvOhs;ptgkt>jUn%cb>wwxQBY545oP z+)}Cs+7*>g_*4t(zQt>@3+N;@Nk1>9@ zyNVdpdYt%Fz1)+EsHoM=$Cq@^o7V{I;?})l5C5c=Y1AB#F*)c53#NV%@pmN8bj z={pJ`no1d@I`zazmRQG!b|fe36G1*6<=++denpVixC49_kL&%`%St!X4+ppo zmQ;LqPj{C3=K62=6xLs}?0sog@QqoU5oGN~DyY~H5P8Vi@ z(6@#zm4dYu%P&(lgWu-{vi6R_#YswfQI{6GBFbzuKNo=g$_85aC7V3zo}x;YIGhl< z)%)R~mnnOI-COoDPatwW%epO^Vt->5RAGiBJ1Os^C^!Yb8H{JxbvQWG_*nK)8x=t^ z76g-ayq)4rB~vCq1ZK`C&s(1m&2jVd)tgNmE_Uy$S@TY;K}{v0 zXQBe9?@H=a&f2h!B8ze~D>+ptEJ#mr@xXO&l}$zbFiF>lR?z};*#ZMjy7NPirgr|( z8K5V(fM$k1;*OKU-w9VfyCJ88NR+Y@-7R~#RFrSC*;;WrR~JgrWjO+&nn3Y`dvtk8 zhf_{En6A|dQnnvytT-dXJa3zTEy4CBptlf%>e4ZU<9pXx`)x%d*(7ocf0#3SC-{^%D`x1=rWXAbs8mFJs3{dU2AaNsph=(L%F!z^X#;aBZmFDWj=njAmdnZwY3s^EQzw9)Fc); zL8LH*#k>|YlF9bmk=W*Z4_Wug<*y((A`|2q?M$+Rv^fS?>Rug^)?EBbJ6Hi#b2FaY zKDk2jykJ1v5;Mre+DI!*rzd>0W39{+^m1N(R;noyYwuGzA6PZu_Blbed{NqIkpE;! zv^T+x_bg=|G03IEdslv~zp--Q2eyj&sRE74bcBd2K%zWMs|G5UogsIA>ni<#>Tfwl zJyZCVmj|s$8$9iiA?MvD1qa{N{CMHLNqDc=FW+-t< zY+(FRw+87fA-B22jyHd9?P*1{TPs^hpj(?v8r`#4!(4o7rNRUk)C1CB!Jg>pNYmT8 zJ0%TmioB)sx`PS)9}8ty%&xZ)2A@p(S1o%4?_Dz``{D`wqSKwS;78=Ujbv}7US6qP zU5jiSN?*)@6E6C6k)6DsS#{`KA}d#4Zo7=o%h!ZOTSU?-;a=rjW(lV4;gt&iC{~l~ zGPHL4JM+zg(Gyq8+=-ENay0(PX0Slj*DBg(E#1D#e2uudH)i+2HUDT@H_BCabkIM8 z7KrM2fAuVV?RS*@I-oa!`mMmyv(=YTNRf`Qf~s9^oXZ*$wV+8szIVK7?tS<55(%73Z0?5-HIb2IUmm9?o&i$&k!tuas`U0{5mP zOLB71Jf9-nj+ZrSDKW-}oC@{sh+1vc5#Fw^l)Fiu@+E(Pyk^-cD2JHuDU0ro@~Fhg zl8XH#&>BOAfV!*C8xbUKDD;limzFco^tu0`FQatVh1rLb(rU2b@R=S&mq|}Y6%rjAii{!@yXD%F_sy`xz(LGKMW&9X8F_V6{W*W(L;nb2*4=+eh;}VT9Z=O!# z5B%+KSYZl5F;(R&`Ah(!(Ws2zO1nY_t^q( z4Y+<^Lc2isj+PX~ass)6FY>Ak2ArHbaxBGQZ7Nax*=@T%6QSun0~Hg6OGZU1qzi+* zaKFymmRiA=OHBZ_7Z2%NR6f3TYM;v=+@`~<%F;R_fn33$N%YX#2%(TK?o#f3-dL{u z*c(y&Nb}>_=LPYP_^#|tcRGz83vyLaX`<31biTxx*SrEy@ttt}QE*Xw^77AXb1{lk zYg2T=J>3fbo#kN#q^14?T9k1}gpVh*mt^TmLZR+!NlIJ*p=MZ?pm(lunMX~_0fI6qf zyhH)etv`FZ;{bq{tdRfz20Q^VjQ$-$^NtN1*3b4VzTT3?;JZ+@ZLL@(#8(oY zj3{J;j>AXqM~*DCMVC8-E&DkC4i1Q(0I*a{`UP(&Jl`K(hmM{9pT`a&r2U_uRsK&V z{Qr}a3?O>{LaP07i2rYZjWp~gU8B}!7k_$CD-Bfc=j!p*o8_ z%Aj@-B~%Lb3S|GCP3aN|0#Zr)+d=?ScHLQII2suk2tVB9xA1CxGMIkV6Gs#^=CHMB z^!Ct*JKPzZiglUkSpXOyX?L8t10D>Iqc8&STmX^a$q-E#HF_s!ozirg-2f5HHgBkgO#37q;5h)|an9xt6pfm#+P&toxOsZNry_ zYH^1Eb-Vq~($RDpf(K{iPsP98|2pmlsBIerj{@|$7GJCem%^(;YB^4&K;tzIPdboZ zUg%)^H6dsaiQEhfat8nk6ZCCV0rJ=wH|lW7$fheL0Wi9?RTi)A?)E z!n#WUhl>H96#BMX=O@iCPTuZchQsSD{ziOx>Z?}~&PMU6J8(mA@m|W`UYQG`xy+b< z2XYp`IJFj0Y%N=6k3Ws?bEbbC1_VDKp%G!Gku?F#=|Ms4ZtL(QGi++%=;=S7)8q5U zbCN8@pPMTdIF)}Wg3Y4%Qex|2AdUoo~l{~d{Y;F|Fw931Z4MYN`R1tLX7`R0`C zMRRfen8wy5yAy@)U9V$Ms~Z>2+u*aUH=I6L(PFs!$T^-R`DIx8D z5CfpkJdjw+o%i1lpJE;u9!^>Yd*$w~1AO(1k`3D5aSqxIB4w&X!Z|B*a|RP!b)fCh z%z?%@c86$M&+_s>R)?#gPRnET&D zesKYKLnOakkoeo_jxfMMI!*rsb__zNdJTeoUG1zd@bgDpz}k2o&^>V?QLQ^UBu{B= zQ`gU~SySzX6;}#0Ai70*nHeIB&@`0dE5bi7Vvu?DveZ zF~*~>0!omNBNGP)M-t8bP}}$1vJT}c0xo03<6-d%Q7#bsWncva4S%6Q1N81^MzE0{ zE}250RvWBe_;SF7zVM|QKV-~mG3HW7M+XkFd{gkEQY4s>Ucbtcw^I`zv2}=@vD7?q3|FZZX*fMayW@t1xf5$Gdv7f~f@4nzZ*1ppAe^z`56 z2Y}*FsNBESAX>YB_&0Zdk@kJQ;@ekbhi~uY{aYLX%@)URyRbCUkufP`^Sje)iVTOl zL7mvZ`l>?M_mSZr_RKyAj($NFO|S+IU_c-XcyW(D`k`FnbdgzzCWq_ultYPP`mfm6 z4t@(i+@nGB+v_jC&WB~r3FiQ`dWB%6Yv^cn02ExgOZg}I>=)JDOEL$?542SAj<_GB(EQt!mjjlP9ieYGKEM0H0Jl5L z9>KF@TJQIQ@Rvojll~f*cJyHRnn3#!;C214(?#l?22kBs6DA?0ieGNNc7txM@8tjw9Gv6PVakkVknaAOE zPGeR8%4WvB&QM0~!~Vu&5Wjk9-rql$?7iVH;T)hDSolybGQgO0tfi;(O+b)WjLP$3 zc@d&!5@kwl9*G;^evRA-fm(~VEo|23Nr>b%n>mQaF z&^qv^ubliT3e!vl_DGdW)$^al4#B#};B7Q96r>uFJ^63hgS~8g4|pp7zn!5iqmRA4iYYE6m9O3;NN?j8JXmgSKup9vu_0e;;4|E`K*0eSfYK`*r zHx5M5Z~1#cY;Bv*{{m;9OCta-RG?%e=6)X2&P*Oq$auAsdDzqYTOlL-UjidVB-g|& zt`bTsRfgq3IA%#Su&)0uNPz}I8f)|#9OO$q);AvVZd}*D{o3hCuf0wuDTNF%(v5m& zKU^B=HU8e;pH$lx@k3J|_9`-tCC)X&tq~)H)5caM{!yf#Bt(i2o6{K*{zXDi1yWhn z7jUA^cc9!?%{s9+p^N+61QWm-{aTfx)E27Y(qw?vEKscVNT*$7ahPvTkPUih&GcRB z7nvc{xRe-tsKN>bR8K%-;lThNq7S10i0udmEbiZBv^xJZFH7_zlZ|^MKXQ^Alf?V{ z6CTpP;|0S_!tL|WJ3rx~|FHbVYIcMo-Ey@n*2mf!8=q`EKuAI?Q6K+%V*|p28AN#K zhZyQ|cF|bgQ2V)?7XlBteGu)E=9~!dW8s3j9O!dn93(k_xqsKzd`E;(k0Yu6*A(_E ze8pQk%IG4U_MX_iGIy*Eb|O>&rB{Wh7AA7f2S7L$#%yf@_<;!K@z!_{=v^Xij+&x% zrb#!g=l%U6#o$G9Us5hyhpGl_1x4q2PBx}c4)7PD2Qa5OII~({NZOcT^YhObb`?M2 zt)DxN_^TlJ%2{e{?$Zmrih^Ca_s~wW?m6fw6b_$ok)Q8~8Wg%!?VuWzvKsX8g_#9V zPPz}ML-11Cf3Y3@b=W}fCnWQ(;&aZ`UuZw#6S^<@-Hb(dsCwmq`kX#|+{t9SLm*-< zTt^wY$N`#~TboUEZQw5Kmgixceg@CkC!>&1kCUSoKU|Vo2%{>x|3O_DcS0Y?*Gr*d zupd-O*>R!08czoV5#)f_KNwb^>M=Z?6r0{2KnZQHEy)XC#0d5F5N{0X-2Dz8ngN|# zE@67o@us(Q`a4h3L5Ha1<~#{P=q$PdR?J_>t5D_xIgv~UffG3hFj;_(* zryCVa^{j@};UR&tb?h5nxS^Q~0L{*Q@uz(cI2Hl{_LFuE1al395+4Abv6!6*2l;3^ z!TvjBZ=4_^)c;%^u=+^kLzhXGqN4r@R?}ZnZWcICzQFVW6h!6#6-znz^d2ryQ`^^n zMj-?M`N@E1$zwTi3l!*e9`v8O0dR1&$q!Jjlk&yFG(7e^-KI0Qv_w_;xM&7V)qqZz zEkKm{Cg>O$_7a~QYc2F)3jSk$Q7Fn7Q2WPuq>ZcOJA%)pq9;L+aefq|sY3AM7Ub0hjsD zrnBIpYh?@;JXO<3rqlc^%7$G`apHxJ_~9%Gg@8>oeVGnp)e-Onbj47f?~0=fSchb1 z(4zwEau=_#Cqcp@I;mja{GB&93lYL~;7m2p@1Z4OC(gSo;xckGbf%0Dtj3_@J)qTW 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 bool: + """Validate that all required configuration is present.""" + if not self.project_id: + raise ValueError("GOOGLE_CLOUD_PROJECT environment variable is required") + return True + + @property + def project_location(self) -> str: + """Get the project location in the format required by BigQuery and Dataform.""" + return f"{self.project_id}.{self.location}" + + @property + def vertex_project_location(self) -> str: + """Get the project location in the format required by Vertex AI.""" + return f"{self.project_id}.{self.location}" + + +# Create a global config instance +config = Config() diff --git a/python/agents/sft-runner-starter-pack/src/data/eval_queries.csv b/python/agents/sft-runner-starter-pack/src/data/eval_queries.csv new file mode 100644 index 000000000..2f7637bfb --- /dev/null +++ b/python/agents/sft-runner-starter-pack/src/data/eval_queries.csv @@ -0,0 +1,9 @@ +"question","ground_truth_sql" +"What is the last paid price for part 'I87-0438-000'?","SELECT LAST_PAID_PRICE FROM `sb-genai-project.sft.test_data` WHERE PART_NUMBER = 'I87-0438-000'" +"How many SKUs are associated with part number '07094SM'?","SELECT COUNT(DISTINCT TOP_LEVEL_MATERIAL) FROM `sb-genai-project.sft.test_data` WHERE PART_NUMBER = '07094SM'" +"List all parts in plant '5201' that are considered slow moving.","SELECT PART_NUMBER, PART_NAME FROM `sb-genai-project.sft.test_data` WHERE PLANT = '5201' AND (UPPER(FSH_FLAG_BY_QTY) = 'SLOW' OR UPPER(FSH_FLAG_BY_AMOUNT) = 'SLOW')" +"What is the total forecasted demand for all parts in 2025?","SELECT SUM(DEMAND) AS total_demand FROM `sb-genai-project.sft.test_data` WHERE FORECASTED_YEAR = 2025" +"Find all alternate parts for MEP 'R7FA2E1A73CFL#HA0'.","SELECT CROSS_PART_NUMBER, CROSS_PART_DESCRIPTION FROM `sb-genai-project.sft.test_data` WHERE MEP = 'R7FA2E1A73CFL#HA0'" +"Get the supplier details for supplier ID '1001'.","SELECT SUPPLIER_NAME, REGION FROM `sb-genai-project.sft.test_data` WHERE SUPPLIER_ID = '1001' LIMIT 1" +"What is the obsolescence status for SKU 'EEM230-D-P'?","SELECT PLANT_SPECIFIC_STATUS_CURRENT_DESC FROM `sb-genai-project.sft.test_data` WHERE MATERIAL = 'EEM230-D-P'" +"List the top 3 plants by total inventory stock.","SELECT PLANT, SUM(TOTAL_STOCK) as total_stock FROM `sb-genai-project.sft.test_data` GROUP BY PLANT ORDER BY total_stock DESC LIMIT 3" \ No newline at end of file diff --git a/python/agents/sft-runner-starter-pack/src/data/sample_eval_queries.csv b/python/agents/sft-runner-starter-pack/src/data/sample_eval_queries.csv new file mode 100644 index 000000000..8c383ea8b --- /dev/null +++ b/python/agents/sft-runner-starter-pack/src/data/sample_eval_queries.csv @@ -0,0 +1,11 @@ +question,ground_truth_sql +"What is the baseline price of part number 503687JEE?","SELECT BASELINE_PRICE FROM vece_assist_consumption_ds.baseline_price_vw WHERE PART_NUMBER = '503687JEE';" +"What is the part with highest baseline price in PSS GBE?","SELECT PART_NUMBER, PART_NAME, BASELINE_PRICE FROM vece_assist_consumption_ds.baseline_price_vw WHERE GBE_NAME = 'PSS' ORDER BY BASELINE_PRICE DESC LIMIT 1" +"List the PSS GBE parts with multiple baseline prices across sites","SELECT PART_NUMBER, SITE_ID, SITE_NAME, COUNT(DISTINCT BASELINE_PRICE) AS DISTINCT_BASELINE_PRICE FROM vece_assist_consumption_ds.baseline_price_vw WHERE GBE_NAME = 'PSS' GROUP BY PART_NUMBER, SITE_ID, SITE_NAME HAVING COUNT(DISTINCT BASELINE_PRICE) > 1;" +"List the parts with multiple baseline price in plant 4257","SELECT PART_NUMBER, PART_NAME FROM vece_assist_consumption_ds.baseline_price_vw WHERE PLANT = '4257' GROUP BY PART_NUMBER, PART_NAME HAVING COUNT(DISTINCT BASELINE_PRICE) > 1;" +"How many baseline price exists for part 57-124746-01FRE?","SELECT COUNT(*) FROM vece_assist_consumption_ds.baseline_price_vw WHERE PART_NUMBER = '57-124746-01FRE'" +"Give me the spend by year of part 07094SM","SELECT PURCHASE_YEAR, SUM(SPEND) AS TOTAL_SPEND FROM vece_assist_consumption_ds.purchase_order_details_vw WHERE PART_NUMBER = '07094SM' GROUP BY PURCHASE_YEAR ORDER BY PURCHASE_YEAR" +"Give me current year spend of part 07094SM","SELECT PART_NUMBER, SUM(SPEND) AS TOTAL_SPEND FROM vece_assist_consumption_ds.purchase_order_details_vw WHERE PART_NUMBER = '07094SM' AND PURCHASE_YEAR= EXTRACT(YEAR FROM CURRENT_DATE()) GROUP BY 1" +"Give me last year spend of part 07094SM","SELECT PART_NUMBER, SUM(SPEND) AS TOTAL_SPEND FROM vece_assist_consumption_ds.purchase_order_details_vw WHERE PART_NUMBER = '07094SM' AND PURCHASE_YEAR= EXTRACT(YEAR FROM CURRENT_DATE())-1 GROUP BY 1" +"Give me the spend of part I87-0438-000","SELECT PART_NUMBER, SUM(SPEND) AS TOTAL_SPEND FROM vece_assist_consumption_ds.purchase_order_details_vw WHERE PART_NUMBER = 'I87-0438-000' GROUP BY PART_NUMBER;" +"Give me total spend of part I87-0438-000 for the year 2024","SELECT PART_NUMBER, SUM(SPEND) AS TOTAL_SPEND FROM vece_assist_consumption_ds.purchase_order_details_vw WHERE PART_NUMBER = 'I87-0438-000' AND PURCHASE_YEAR= 2024 GROUP BY PART_NUMBER" diff --git a/python/agents/sft-runner-starter-pack/src/data/seed_queries.csv b/python/agents/sft-runner-starter-pack/src/data/seed_queries.csv new file mode 100644 index 000000000..d710d2276 --- /dev/null +++ b/python/agents/sft-runner-starter-pack/src/data/seed_queries.csv @@ -0,0 +1,6 @@ +"question","ground_truth_sql" +"What is the baseline price for part number '503687JEE'?","SELECT BASELINE_PRICE FROM `sb-genai-project.sft.test_data` WHERE PART_NUMBER = '503687JEE'" +"Show me the total spend for the 'Fire' GBE in 2023.","SELECT SUM(SPEND) AS total_spend FROM `sb-genai-project.sft.test_data` WHERE GBE_NAME = 'Fire' AND PURCHASE_YEAR = 2023" +"How many unique parts are in the inventory?","SELECT COUNT(DISTINCT PART_NUMBER) FROM `sb-genai-project.sft.test_data`" +"List the top 5 suppliers by spend.","SELECT SUPPLIER_NAME, SUM(SPEND) AS total_spend FROM `sb-genai-project.sft.test_data` GROUP BY SUPPLIER_NAME ORDER BY total_spend DESC LIMIT 5" +"Find the EOL status for part 'I87-0004-000'.","SELECT ESTIMATED_YEARS_TO_EOL FROM `sb-genai-project.sft.test_data` WHERE PART_NUMBER = 'I87-0004-000'" \ No newline at end of file diff --git a/python/agents/sft-runner-starter-pack/src/data/test_data.csv b/python/agents/sft-runner-starter-pack/src/data/test_data.csv new file mode 100644 index 000000000..a0413ce51 --- /dev/null +++ b/python/agents/sft-runner-starter-pack/src/data/test_data.csv @@ -0,0 +1,6 @@ +PART_NUMBER,BASELINE_PRICE,LAST_PAID_PRICE,SPEND,GBE_NAME,PURCHASE_YEAR,SUPPLIER_NAME,SUPPLIER_ID,ESTIMATED_YEARS_TO_EOL,TOP_LEVEL_MATERIAL,PLANT,FSH_FLAG_BY_QTY,FSH_FLAG_BY_AMOUNT,DEMAND,FORECASTED_YEAR,MEP,CROSS_PART_NUMBER,CROSS_PART_DESCRIPTION,REGION,MATERIAL,PLANT_SPECIFIC_STATUS_CURRENT_DESC,TOTAL_STOCK +'503687JEE',150.75,155.20,50000,'Fire',2023,'Supplier A','1001',5,'SKU-001','5201','NORMAL','NORMAL',1000,2025,'MEP-001','ALT-001','Alternate Part 1','AMER','EEM230-D-P','Active',5000 +'I87-0438-000',25.50,26.00,120000,'PSS',2023,'Supplier B','1002',3,'SKU-002','5201','NORMAL','NORMAL',2500,2025,'MEP-002','ALT-002','Alternate Part 2','EMEA','EEM230-D-P','Active',10000 +'07094SM',12.00,11.90,75000,'Fire',2022,'Supplier A','1001',10,'SKU-001','4833','SLOW','NORMAL',500,2025,'MEP-003','ALT-003','Alternate Part 3','APAC','EEM230-D-P','Active',200 +'I87-0004-000',5.20,5.25,25000,'HSS',2023,'Supplier C','1003',2,'SKU-003','4833','NORMAL','SLOW',800,2025,'R7FA2E1A73CFL#HA0','ALT-004','Alternate Part 4','AMER','EEM230-D-P','Obsolete',15000 +'R7FA2E1A73CFL#HA0',33.00,33.50,90000,'PSS',2024,'Supplier B','1002',7,'SKU-004','5201','NORMAL','NORMAL',1200,2025,'R7FA2E1A73CFL#HA0','ALT-005','Alternate Part 5','EMEA','EEM230-D-P','Active',8000 diff --git a/python/agents/sft-runner-starter-pack/src/prompts.py b/python/agents/sft-runner-starter-pack/src/prompts.py new file mode 100644 index 000000000..c4f2b442e --- /dev/null +++ b/python/agents/sft-runner-starter-pack/src/prompts.py @@ -0,0 +1,78 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Prompts definitions""" + +# Orchestrator prompt +ROOT_PROMPT = """ +You are a master coordinator for a machine learning fine-tuning pipeline. +You have access to a team of specialist agents to perform specific tasks. +Your job is to understand the user's request and delegate the task to the correct agent. + +**Your Agent Team:** + +1. **`full_pipeline_agent`**: A sequential agent that runs the entire fine-tuning workflow from start to finish (Data Generation -> Fine-Tuning -> Evaluation). +2. **`data_generator_agent`**: A specialist agent that ONLY generates a synthetic dataset. +3. **`fine_tuner_agent`**: A specialist agent that ONLY fine-tunes a model. It requires the GCS path to a training dataset. +4. **`evaluator_agent`**: A specialist agent that ONLY evaluates a model. It requires the resource name of a deployed model endpoint. + +**Your Task:** + +- If the user asks to run the "full pipeline", "end-to-end", or a similar request, you MUST call the `full_pipeline_agent`. +- If the user asks to "generate data", you MUST call the `data_generator_agent`. +- If the user asks to "fine-tune a model", you MUST first ASK the user for the GCS path of the training data, and then call the `fine_tuner_agent` with that path. +- If the user asks to "evaluate a model", you MUST first ASK the user for the model endpoint, and then call the `evaluator_agent` with that endpoint. + +Analyze the user's request and delegate to the appropriate agent. +""" + +# Prompt to generate synthetic data +DATA_GENERATOR_PROMPT = """ +You are the Data Generator Agent. +Your task is to generate a synthetic dataset for fine-tuning. +You have one tool: `generate_synthetic_data`. +The following required parameters will be available in the agent's state: +- `project_id` +- `bq_dataset_id` +- `seed_data_path` +- `target_examples` +- `gcs_bucket_name` +Extract these parameters from the state and call the `generate_synthetic_data` tool. +""" + +# Prompt to perform fine tuning +FINE_TUNER_PROMPT = """ +You are the Fine-Tuning Agent. +Your task is to fine-tune a model using a provided dataset. +You have one tool: `fine_tune_model`. +The user will provide the GCS path to the training data. +The following additional parameters are available in the agent's state: +- `base_model` +- `project_id` +- `gcp_location` +Call the `fine_tune_model` tool with all the required arguments. +""" + +# Prompt to evaluate +EVALUATOR_PROMPT = """ +You are the Evaluation Agent. +Your task is to evaluate the performance of a fine-tuned model. +You have one tool: `evaluate_model`. +The user will provide the model endpoint. +The following additional parameters are also available in the agent's state: +- `eval_dataset_path` +- `project_id` +- `gcp_location` +- `gcs_bucket_name` +Call the `evaluate_model` tool with all the required arguments. +""" diff --git a/python/agents/sft-runner-starter-pack/src/sub_agents/__init__.py b/python/agents/sft-runner-starter-pack/src/sub_agents/__init__.py new file mode 100644 index 000000000..0d34901a9 --- /dev/null +++ b/python/agents/sft-runner-starter-pack/src/sub_agents/__init__.py @@ -0,0 +1,9 @@ +from .data_generator import get_agent as get_data_generator_agent +from .fine_tuner import get_agent as get_fine_tuner_agent +from .evaluator import get_agent as get_evaluator_agent + +__all__ = [ + "get_data_generator_agent", + "get_fine_tuner_agent", + "get_evaluator_agent", +] diff --git a/python/agents/sft-runner-starter-pack/src/sub_agents/data_generator/__init__.py b/python/agents/sft-runner-starter-pack/src/sub_agents/data_generator/__init__.py new file mode 100644 index 000000000..8a43432cb --- /dev/null +++ b/python/agents/sft-runner-starter-pack/src/sub_agents/data_generator/__init__.py @@ -0,0 +1,14 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from .agent import get_agent diff --git a/python/agents/sft-runner-starter-pack/src/sub_agents/data_generator/agent.py b/python/agents/sft-runner-starter-pack/src/sub_agents/data_generator/agent.py new file mode 100644 index 000000000..402380700 --- /dev/null +++ b/python/agents/sft-runner-starter-pack/src/sub_agents/data_generator/agent.py @@ -0,0 +1,37 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# import config library +from ...config import config + +# Google/ADK imports +from google.adk.agents import LlmAgent +from . import prompt +from . import tool +from google.genai import types + + +# Agent definition +def get_agent(): + return LlmAgent( + name="DataGeneratorAgent", + model=config.pro_model, + generate_content_config=types.GenerateContentConfig( + temperature=config.temperature, + top_p=config.top_p, + ), + description="Generates a synthetic dataset for fine-tuning.", + instruction=prompt.PROMPT, + tools=[tool.generate_synthetic_data], + ) diff --git a/python/agents/sft-runner-starter-pack/src/sub_agents/data_generator/prompt.py b/python/agents/sft-runner-starter-pack/src/sub_agents/data_generator/prompt.py new file mode 100644 index 000000000..1985e266e --- /dev/null +++ b/python/agents/sft-runner-starter-pack/src/sub_agents/data_generator/prompt.py @@ -0,0 +1,30 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +PROMPT = """ + + + You are the Data Generator Agent. + Your task is to generate a synthetic dataset for fine-tuning. + You have one tool: `generate_synthetic_data`. + The following required parameters will be available in the agent's state: + - `project_id` + - `bq_dataset_id` + - `seed_data_path` + - `target_examples` + - `gcs_bucket_name` + Extract these parameters from the state and call the `generate_synthetic_data` tool. + + +""" diff --git a/python/agents/sft-runner-starter-pack/src/sub_agents/data_generator/tool.py b/python/agents/sft-runner-starter-pack/src/sub_agents/data_generator/tool.py new file mode 100644 index 000000000..13530aa6d --- /dev/null +++ b/python/agents/sft-runner-starter-pack/src/sub_agents/data_generator/tool.py @@ -0,0 +1,259 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# standard imports +import json +import os +import time +import pandas as pd +import logging + +# GCP imports +from google.cloud import storage +from vertexai.generative_models import GenerativeModel, GenerationConfig + +# Utility imports +from ...utils.mcptoolbox_client import MCPToolboxClient +from ...config import config + +# Define logging +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + + +# Utility function: Format schema for Prompt injection +def _format_schemas_for_prompt( + mcptoolbox_client: MCPToolboxClient, bq_dataset_id: str, project_id: str +) -> str: + """Fetch DB Schema from BQ and Format for use with prompt for data generation""" + logger.info(f"Fetching schemas for dataset: {project_id}.{bq_dataset_id}") + tables_df = mcptoolbox_client.execute_tool( + "list_tables", {"dataset_id": bq_dataset_id} + ) + if tables_df.empty: + raise ValueError(f"No tables found in dataset {bq_dataset_id}.") + + all_schemas_string = ( + f"Dataset: `{project_id}.{bq_dataset_id}`\n\n--- Table Schemas ---\n" + ) + for table_name in tables_df["table_name"]: + schema_df = mcptoolbox_client.execute_tool( + "get_table_schema", {"dataset_id": bq_dataset_id, "table_id": table_name} + ) + full_table_name = f"`{project_id}.{bq_dataset_id}.{table_name}`" + schema_string = f"Table Name: {full_table_name}\nColumns:\n" + for _, row in schema_df.iterrows(): + schema_string += f"- {row['column_name']} ({row['data_type']})\n" + all_schemas_string += schema_string + "\n---\n" + logger.info("Successfully formatted schemas.") + return all_schemas_string + + +# Utility function: Generate synthetic data examples +def _generate_examples_batch( + model, schemas_str, num_examples, existing_questions, sample_questions_df +) -> str: + """Generate X number of synthetic examples""" + # TODO: Improve: Make the use case generic + prompt = f""" + You are an expert system designed to generate synthetic training data for fine-tuning text-to-SQL models. + Your task is to generate diverse pairs of natural language questions and their corresponding valid GoogleSQL queries based on the provided table schemas and sample questions. + + **Instructions:** + 1. Generate exactly {num_examples} unique question-query pairs. + 2. Ensure the generated questions are different from these existing ones: {", ".join(existing_questions[-20:]) if existing_questions else "None"} + 3. Format the output STRICTLY as a valid JSON list of objects, where each object has two keys: "question" and "query". + 4. Do NOT include any text, explanations, or markdown formatting outside the JSON list itself. + + **Table Schemas:** + {schemas_str} + + **Sample Questions for reference:** + {sample_questions_df.to_string()} + + **Generate the JSON list now:** + """ + + generation_config = GenerationConfig( + temperature=config.temperature, + max_output_tokens=config.max_output_tokens, + response_mime_type="application/json", + ) + try: + logger.info("Sending request to Gemini to generate examples...") + response = model.generate_content(prompt, generation_config=generation_config) + logger.info("Received response from Gemini.") + return response.text + except Exception as e: + logger.error(f"Error during Gemini API call: {e}") + return None + + +# Utility funciton: Upload generated data in GCS Bucket +def _upload_to_gcs(local_path: str, bucket_name: str, folder_name: str) -> str: + """Uploads a local file to a specific folder in Google Cloud Storage.""" + logger.info( + f"Uploading file {local_path} to GCS bucket {bucket_name} in folder {folder_name}..." + ) + storage_client = storage.Client() + bucket = storage_client.bucket(bucket_name) + blob_name = f"{folder_name}/{os.path.basename(local_path)}" + blob = bucket.blob(blob_name) + blob.upload_from_filename(local_path) + gcs_path = f"gs://{bucket_name}/{blob_name}" + logger.info(f"Successfully uploaded file to {gcs_path}") + return gcs_path + + +# MAIN Utility: Generates synthetic data - calls other Utilities +def generate_synthetic_data( + project_id: str = config.project_id, + bq_dataset_id: str = config.DATASET, + seed_data_path: str = config.seed_queries, + target_examples: int = config.initial_target_examples, + gcs_bucket_name: str = config.bucket_name, +) -> str: + """Generates a synthetic dataset for fine-tuning a Text-to-SQL model.""" + logger.info(f"--- Executing Tool: generate_synthetic_data ---") + logger.info( + f"Received parameters: project_id={config.project_id}, \ + bq_dataset_id={config.DATASET}, seed_data_path={config.seed_queries}, \ + target_examples={config.initial_target_examples}, \ + gcs_bucket_name={config.bucket_name}" + ) + + mcptoolbox_client = MCPToolboxClient(project_id=config.project_id) + gemini_model = GenerativeModel(config.pro_model) + + # TODO: Improve to read Seed data from BQ rather than files at all + # Resolve seed_data_path for local files if it's not a GCS path + if not seed_data_path.startswith("gs://"): + # If the path is relative, resolve it relative to the current script's directory + if not os.path.isabs(seed_data_path): + # Get the absolute path of the current script's directory + current_script_dir = os.path.dirname(os.path.abspath(__file__)) + seed_data_path = os.path.join(current_script_dir, seed_data_path) + logger.info(f"Resolved local seed_data_path: {seed_data_path}") + + schemas_prompt_string = _format_schemas_for_prompt( + mcptoolbox_client, bq_dataset_id, project_id + ) + sample_questions_df = pd.read_csv(seed_data_path) + + all_generated_examples = [] + generated_questions_set = set(sample_questions_df["question"].tolist()) + + while len(all_generated_examples) < target_examples: + batch_size = min(100, target_examples - len(all_generated_examples)) + logger.info(f"Requesting a batch of {batch_size} examples...") + + raw_response_text = _generate_examples_batch( + model=gemini_model, + schemas_str=schemas_prompt_string, + num_examples=batch_size, + existing_questions=list(generated_questions_set), + sample_questions_df=sample_questions_df, + ) + + if raw_response_text: + try: + batch_examples = json.loads(raw_response_text) + for item in batch_examples: + if ( + isinstance(item, dict) + and "question" in item + and "query" in item + ): + if ( + item["question"] + and item["query"] + and item["question"] not in generated_questions_set + ): + all_generated_examples.append(item) + generated_questions_set.add(item["question"]) + except json.JSONDecodeError as e: + logger.error(f"Warning: Failed to decode JSON from model response: {e}") + + logger.info(f"Total examples generated so far: {len(all_generated_examples)}") + if len(all_generated_examples) < target_examples: + time.sleep(5) + + # Create a unique folder for this run + folder_name = f"sft_data_{int(time.time())}" + + raw_output_filename = f"synthetic_sql_data.jsonl" + with open(raw_output_filename, "w") as f: + for example in all_generated_examples: + f.write(json.dumps(example) + "\n") + logger.info(f"Successfully generated raw data file: {raw_output_filename}") + raw_gcs_path = _upload_to_gcs(raw_output_filename, gcs_bucket_name, folder_name) + logger.info( + f"Sample from raw data file:\n{pd.read_json(raw_output_filename, lines=True).head()}\n" + ) + + # Prepare the data in the final chat format + formatted_output_filename = f"formatted_synthetic_sql_data.jsonl" + _prepare_finetuning_data_chat_format( + input_jsonl_path=raw_output_filename, + output_jsonl_path=formatted_output_filename, + all_schemas_str=schemas_prompt_string, + ) + formatted_gcs_path = _upload_to_gcs( + formatted_output_filename, gcs_bucket_name, folder_name + ) + + # Delete local files + os.remove(raw_output_filename) + os.remove(formatted_output_filename) + + output = { + "raw_data_gcs_path": raw_gcs_path, + "generated_data_gcs_path": formatted_gcs_path, + } + logger.info(f"Returning from tool: {json.dumps(output)}") + return json.dumps(output) + + +# Utility function: Prepare data format for SFT +def _prepare_finetuning_data_chat_format( + input_jsonl_path: str, output_jsonl_path: str, all_schemas_str: str +): + """Reads generated question-query data and formats it into the multi-turn chat format.""" + logger.info( + f"Preparing data from '{input_jsonl_path}' into multi-turn chat format..." + ) + count = 0 + with open(input_jsonl_path, "r") as infile, open(output_jsonl_path, "w") as outfile: + for line in infile: + original_data = json.loads(line) + question = original_data.get("question") + query = original_data.get("query") + + if question and query: + prepared_data = { + "contents": [ + {"role": "user", "parts": [{"text": question}]}, + {"role": "model", "parts": [{"text": query}]}, + ] + } + outfile.write(json.dumps(prepared_data) + "\n") + count += 1 + logger.info( + f"Successfully prepared {count} records in multi-turn chat format into '{output_jsonl_path}'." + ) + logger.info( + f"Sample from formatted data file:\n{pd.read_json(output_jsonl_path, lines=True).head()}\n" + ) diff --git a/python/agents/sft-runner-starter-pack/src/sub_agents/evaluator/__init__.py b/python/agents/sft-runner-starter-pack/src/sub_agents/evaluator/__init__.py new file mode 100644 index 000000000..8a43432cb --- /dev/null +++ b/python/agents/sft-runner-starter-pack/src/sub_agents/evaluator/__init__.py @@ -0,0 +1,14 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from .agent import get_agent diff --git a/python/agents/sft-runner-starter-pack/src/sub_agents/evaluator/agent.py b/python/agents/sft-runner-starter-pack/src/sub_agents/evaluator/agent.py new file mode 100644 index 000000000..6548900f4 --- /dev/null +++ b/python/agents/sft-runner-starter-pack/src/sub_agents/evaluator/agent.py @@ -0,0 +1,42 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Google/ADK import +from google.adk.agents import LlmAgent +from google.genai import types + +# Tool import +from . import prompt +from . import tool + +# Configuration import +from ...config import config + +# CONSTANTS +_MODEL = config.flash_model + + +# Agent definition +def get_agent(): + return LlmAgent( + name="EvaluationAgent", + model=_MODEL, + generate_content_config=types.GenerateContentConfig( + temperature=config.temperature, + top_p=config.top_p, + ), + description="Evaluates a fine-tuned model.", + instruction=prompt.PROMPT, + tools=[tool.evaluate_model], + ) diff --git a/python/agents/sft-runner-starter-pack/src/sub_agents/evaluator/prompt.py b/python/agents/sft-runner-starter-pack/src/sub_agents/evaluator/prompt.py new file mode 100644 index 000000000..16dced536 --- /dev/null +++ b/python/agents/sft-runner-starter-pack/src/sub_agents/evaluator/prompt.py @@ -0,0 +1,16 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +PROMPT = """You are an expert in Machine Learningmodel evaluation. +Call the `evaluate_model` tool with the provided parameters.""" diff --git a/python/agents/sft-runner-starter-pack/src/sub_agents/evaluator/tool.py b/python/agents/sft-runner-starter-pack/src/sub_agents/evaluator/tool.py new file mode 100644 index 000000000..542bfbab7 --- /dev/null +++ b/python/agents/sft-runner-starter-pack/src/sub_agents/evaluator/tool.py @@ -0,0 +1,234 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Standard imports +import json +import os +import time +import pandas as pd +import logging + +# Google/ADK imports +import vertexai +from google.cloud import storage +from vertexai.evaluation import EvalTask, PointwiseMetric +from vertexai.generative_models import GenerativeModel + +# import configuration +from ...config import config + +# Logging Setup +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + + +# Utility: upload to GCS +# TODO: make this tool common across all agents +def _upload_to_gcs(local_path: str, bucket_name: str, folder_name: str) -> str: + """Uploads a local file to a specific folder in Google Cloud Storage.""" + logger.info( + f"Uploading file {local_path} to GCS bucket {bucket_name} in folder {folder_name}..." + ) + storage_client = storage.Client() + bucket = storage_client.bucket(bucket_name) + blob_name = f"{folder_name}/{os.path.basename(local_path)}" + blob = bucket.blob(blob_name) + blob.upload_from_filename(local_path) + gcs_path = f"gs://{bucket_name}/{blob_name}" + logger.info(f"Successfully uploaded file to {gcs_path}") + return gcs_path + + +# MAIN Utility: Evaluate fine tuned model +def evaluate_model( + model_endpoint: str, +) -> str: + """Evaluates a fine-tuned Text-to-SQL model using the Vertex AI Evaluation service.""" + # set configurations + project_id = config.project_id + location = config.location + eval_dataset_path = config.eval_dataset + gcs_bucket_name = config.bucket_name + + logger.info(f"--- Executing Tool: evaluate_model ---") + logger.info( + f"Received parameters: model_endpoint={model_endpoint}, eval_dataset_path={eval_dataset_path}, project_id={project_id}, location={location}, gcs_bucket_name={gcs_bucket_name}" + ) + vertexai.init(project=project_id, location=location) + + # TODO: Improve to read Seed data from BQ rather than files at all + # Resolve seed_data_path for local files if it's not a GCS path + if not eval_dataset_path.startswith("gs://"): + # If the path is relative, resolve it relative to the current script's directory + if not os.path.isabs(eval_dataset_path): + # Get the absolute path of the current script's directory + current_script_dir = os.path.dirname(os.path.abspath(__file__)) + eval_dataset_path = os.path.join(current_script_dir, eval_dataset_path) + logger.info(f"Resolved local eval_dataset_path: {eval_dataset_path}") + + # Load the evaluation dataset + eval_df = pd.read_csv(eval_dataset_path) + tuned_model = GenerativeModel(model_endpoint) + + # Generate predictions + logger.info("Generating predictions from the fine-tuned model...") + predictions = [ + tuned_model.generate_content( + f"Question: {row['question']}\n\nSQL:" + ).text.strip() + for _, row in eval_df.iterrows() + ] + eval_df["response"] = predictions + logger.info("Successfully generated predictions.") + logger.info(f"Sample predictions:\n{eval_df.head()}\n") + + # Rename columns to match the expected format for the evaluation task + eval_df = eval_df.rename( + columns={"question": "instruction", "ground_truth_sql": "context"} + ) + + # Define the evaluation task + logger.info("Defining the evaluation task...") + eval_task = EvalTask( + dataset=eval_df, + metrics=[ + PointwiseMetric( + metric="relevance", + metric_prompt_template="""You are a professional writing evaluator. Your job is to score writing responses according to pre-defined evaluation criteria. You will be assessing question answering relevance, which measures the ability to respond with relevant information when asked a question. You will assign the writing response a score from 5, 4, 3, 2, 1, following the INDIVIDUAL RATING RUBRIC and EVALUATION STEPS. + +# Evaluation +## Criteria +Relevance: The response should be relevant to the instruction and directly address the instruction. + +## Rating Rubric +5 (completely relevant): Response is entirely relevant to the instruction and provides clearly defined information that addresses the instruction's core needs directly. +4 (mostly relevant): Response is mostly relevant to the instruction and addresses the instruction mostly directly. +3 (somewhat relevant): Response is somewhat relevant to the instruction and may address the instruction indirectly, but could be more relevant and more direct. +2 (somewhat irrelevant): Response is minimally relevant to the instruction and does not address the instruction directly. +1 (irrelevant): Response is completely irrelevant to the instruction. + +## Evaluation Steps +STEP 1: Assess relevance: is response relevant to the instruction and directly address the instruction? +STEP 2: Score based on the criteria and rubrics. + +Give step by step explanations for your scoring, and only choose scores from 5, 4, 3, 2, 1. + +# User Inputs and AI-generated Response +## User Inputs +### INSTRUCTION +{instruction} + +### CONTEXT +{context} + +## AI-generated Response +{response}""", + ), + PointwiseMetric( + metric="helpfulness", + metric_prompt_template="""You are a professional writing evaluator. Your job is to score writing responses according to pre-defined evaluation criteria. You will be assessing question answering helpfulness, which measures the ability to provide important details when answering a question. You will assign the writing response a score from 5, 4, 3, 2, 1, following the INDIVIDUAL RATING RUBRIC and EVALUATION STEPS. + +# Evaluation +## Criteria +Helpfulness: The response is comprehensive with well-defined key details. The user would feel very satisfied with the content in a good response. + +## Rating Rubric +5 (completely helpful): Response is useful and very comprehensive with well-defined key details to address the needs in the question and usually beyond what explicitly asked. The user would feel very satisfied with the content in the response. +4 (mostly helpful): Response is very relevant to the question, providing clearly defined information that addresses the question's core needs. It may include additional insights that go slightly beyond the immediate question. The user would feel quite satisfied with the content in the response. +3 (somewhat helpful): Response is relevant to the question and provides some useful content, but could be more relevant, well-defined, comprehensive, and/or detailed. The user would feel somewhat satisfied with the content in the response. +2 (somewhat unhelpful): Response is minimally relevant to the question and may provide some vaguely useful information, but it lacks clarity and detail. It might contain minor inaccuracies. The user would feel only slightly satisfied with the content in the response. +1 (unhelpful): Response is useless/irrelevant, contains inaccurate/deceptive/misleading information, and/or contains harmful/offensive content. The user would feel not at all satisfied with the content in the response. + +## Evaluation Steps +STEP 1: Assess comprehensiveness: does the response provide specific, comprehensive, and clearly defined information for the user needs expressed in the question? +STEP 2: Assess relevance: When appropriate for the question, does the response exceed the question by providing relevant details and related information to contextualize content and help the user better understand the response. +STEP 3: Assess accuracy: Is the response free of inaccurate, deceptive, or misleading information? +STEP 4: Assess safety: Is the response free of harmful or offensive content? + +Give step by step explanations for your scoring, and only choose scores from 5, 4, 3, 2, 1. + +# User Inputs and AI-generated Response +## User Inputs +### INSTRUCTION +{instruction} + +### CONTEXT +{context} + +## AI-generated Response +{response}""", + ), + PointwiseMetric( + metric="fulfillment", + metric_prompt_template="""You are a professional writing evaluator. Your job is to score writing responses according to pre-defined evaluation criteria. You will be assessing fulfillment, which measures the ability to follow instructions. You will assign the writing response a score from 5, 4, 3, 2, 1, following the INDIVIDUAL RATING RUBRIC and EVALUATION STEPS. + +# Evaluation +## Criteria +Instruction following: The response demonstrates a clear understanding of the instructions, satisfying all of the instruction's requirements. + +## Rating Rubric +5 (complete fulfillment): Response addresses all aspects and adheres to all requirements of the instruction. The user would feel like their instruction was completely understood. +4 (good fulfillment): Response addresses most aspects and requirements of the instruction. It might miss very minor details or have slight deviations from requirements. The user would feel like their instruction was well understood. +3 (some fulfillment): Response does not address some minor aspects and/or ignores some requirements of the instruction. The user would feel like their instruction was partially understood. +2 (poor fulfillment): Response addresses some aspects of the instruction but misses key requirements or major components. The user would feel like their instruction was misunderstood in significant ways. +1 (no fulfillment): Response does not address the most important aspects of the instruction. The user would feel like their request was not at all understood. + +## Evaluation Steps +STEP 1: Assess instruction understanding: Does the response address the intent of the instruction such that a user would not feel the instruction was ignored or misinterpreted by the response? +STEP 2: Assess requirements adherence: Does the response adhere to any requirements indicated in the instruction such as an explicitly specified word length, tone, format, or information that the response should include? + +Give step by step explanations for your scoring, and only choose scores from 5, 4, 3, 2, 1. + +# User Inputs and AI-generated Response +## User Inputs +### INSTRUCTION +{instruction} + +## AI-generated Response +{response}""", + ), + ], + ) + + logger.info("Starting evaluation...") + eval_result = eval_task.evaluate() + logger.info("Evaluation completed.") + + results_filename = f"evaluation_results_{int(time.time())}.json" + eval_result.metrics_table.to_json(results_filename, orient="records", lines=True) + logger.info(f"Saved detailed evaluation results to {results_filename}") + + # Upload the results to GCS + results_gcs_path = _upload_to_gcs( + results_filename, gcs_bucket_name, "evaluation_results" + ) + + # Calculate average scores + avg_relevance = eval_result.metrics_table["relevance/score"].mean() + avg_helpfulness = eval_result.metrics_table["helpfulness/score"].mean() + avg_fulfillment = eval_result.metrics_table["fulfillment/score"].mean() + + # Remove local temp files + os.remove(results_filename) + + output = { + "average_relevance": avg_relevance, + "average_helpfulness": avg_helpfulness, + "average_fulfillment": avg_fulfillment, + "detailed_results_gcs_path": results_gcs_path, + } + logger.info(f"Returning from tool: {json.dumps(output)}") + return json.dumps(output) diff --git a/python/agents/sft-runner-starter-pack/src/sub_agents/fine_tuner/__init__.py b/python/agents/sft-runner-starter-pack/src/sub_agents/fine_tuner/__init__.py new file mode 100644 index 000000000..8a43432cb --- /dev/null +++ b/python/agents/sft-runner-starter-pack/src/sub_agents/fine_tuner/__init__.py @@ -0,0 +1,14 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +from .agent import get_agent diff --git a/python/agents/sft-runner-starter-pack/src/sub_agents/fine_tuner/agent.py b/python/agents/sft-runner-starter-pack/src/sub_agents/fine_tuner/agent.py new file mode 100644 index 000000000..3796b10e3 --- /dev/null +++ b/python/agents/sft-runner-starter-pack/src/sub_agents/fine_tuner/agent.py @@ -0,0 +1,42 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Google/ADK Imports +from google.adk.agents import LlmAgent +from google.genai import types + +# Tool Imports +from . import prompt +from . import tool + +# Configuration import +from ...config import config + +# Constants +_MODEL = config.flash_model + + +# Agent definition +def get_agent(): + return LlmAgent( + name="FineTuningAgent", + model=_MODEL, + generate_content_config=types.GenerateContentConfig( + temperature=config.temperature, + top_p=config.top_p, + ), + description="Fine-tunes a model on Vertex AI.", + instruction=prompt.PROMPT, + tools=[tool.fine_tune_model], + ) diff --git a/python/agents/sft-runner-starter-pack/src/sub_agents/fine_tuner/prompt.py b/python/agents/sft-runner-starter-pack/src/sub_agents/fine_tuner/prompt.py new file mode 100644 index 000000000..8c0252b52 --- /dev/null +++ b/python/agents/sft-runner-starter-pack/src/sub_agents/fine_tuner/prompt.py @@ -0,0 +1,16 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +PROMPT = """You are an expert in MLOps. +Call the `fine_tune_model` tool with the provided parameters.""" diff --git a/python/agents/sft-runner-starter-pack/src/sub_agents/fine_tuner/tool.py b/python/agents/sft-runner-starter-pack/src/sub_agents/fine_tuner/tool.py new file mode 100644 index 000000000..784ff873e --- /dev/null +++ b/python/agents/sft-runner-starter-pack/src/sub_agents/fine_tuner/tool.py @@ -0,0 +1,82 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Standard imports +import json +import time +import logging + +# Google/ADK Imports +import vertexai +from google.cloud import aiplatform +from vertexai.preview.tuning import sft + +# configuration import +from ...config import config + +# Logging Setup +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + + +# Utility: Run supervised fine tuning +def fine_tune_model( + training_data_gcs_path: str, +) -> str: + """Starts a supervised fine-tuning job on Vertex AI and returns the new model endpoint.""" + # Set project and location from config + project_id = config.project_id + location = config.location + base_model = config.flash_model + + logger.info(f"--- Executing Tool: fine_tune_model ---") + logger.info( + f"Received parameters: training_data_gcs_path={training_data_gcs_path}, base_model={base_model}, project_id={project_id}, location={location}" + ) + + # Initialize Vertex AI + vertexai.init(project=project_id, location=location) + tuned_model_name = f"sft-adk-agent-tuned-model-{int(time.time())}" + + logger.info( + f"Starting fine-tuning job with model {base_model} and training data {training_data_gcs_path}..." + ) + sft_tuning_job = sft.train( + source_model=base_model, + train_dataset=training_data_gcs_path, + tuned_model_display_name=tuned_model_name, + epochs=4, # TODO: change to config item + adapter_size=4, # TODO: change to config item + ) + logger.info("Fine-tuning job started.") + + job_id = sft_tuning_job.resource_name.split("/")[-1] + + # It can take some time for the endpoint to be ready, so we poll for it. + while True: + endpoints = aiplatform.Endpoint.list( + filter=f"labels.google-vertex-llm-tuning-job-id={job_id}" + ) + if endpoints: + tuned_model_endpoint = endpoints[0] + logger.info(f"Model is deployed to endpoint: {tuned_model_endpoint.name}") + break + logger.info("Waiting for endpoint to be created and model to be deployed...") + time.sleep(5) + + output = {"model_endpoint": tuned_model_endpoint.resource_name} + logger.info(f"Returning from tool: {json.dumps(output)}") + return json.dumps(output) diff --git a/python/agents/sft-runner-starter-pack/src/utils/__init__.py b/python/agents/sft-runner-starter-pack/src/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/python/agents/sft-runner-starter-pack/src/utils/mcptoolbox_client.py b/python/agents/sft-runner-starter-pack/src/utils/mcptoolbox_client.py new file mode 100644 index 000000000..3b8d9da51 --- /dev/null +++ b/python/agents/sft-runner-starter-pack/src/utils/mcptoolbox_client.py @@ -0,0 +1,116 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Simulated MCPToolbox Client. + +This file contains a client that simulates the behavior of the MCPToolbox. +It loads the `tools.yaml` file and provides an interface to execute the tools +defined within it. This allows the agent to be developed against a consistent, +tool-based interface for database interactions. + +This is a more accurate representation of how the agent will interact with the +real MCPToolbox library. +""" + +# Standard imports +import yaml +import pandas as pd + +# Google improts +from google.cloud import bigquery + +# import configurations +from ..config import config + +""" Toolbox outline """ + + +class MCPToolboxClient: + """A simulated client for interacting with tools defined in tools.yaml.""" + + def __init__(self, project_id: str, tools_yaml_path: str = "tools.yaml"): + """ + Initializes the client by loading the tools.yaml configuration + and setting up a BigQuery client for the backend. + + Args: + project_id: The Google Cloud project ID. + tools_yaml_path: The path to the tools.yaml configuration file. + """ + print("--- MCPToolboxClient: Initializing... ---") + try: + with open(tools_yaml_path, "r") as f: + self.config = yaml.safe_load(f) + print( + f"--- MCPToolboxClient: Loaded configuration from {tools_yaml_path} ---" + ) + except FileNotFoundError: + print(f"FATAL: tools.yaml not found at path: {tools_yaml_path}") + raise + except yaml.YAMLError as e: + print(f"FATAL: Error parsing YAML from {tools_yaml_path}: {e}") + raise + + self.project_id = config.project_id + self.bq_client = bigquery.Client(project=self.project_id) + print("--- MCPToolboxClient: BigQuery client initialized. ---") + + def execute_tool(self, tool_name: str, parameters: dict = None) -> pd.DataFrame: + """ + Executes a tool defined in tools.yaml. + + Args: + tool_name: The name of the tool to execute (e.g., 'execute_sql'). + parameters: A dictionary of parameters to pass to the tool. + + Returns: + A pandas DataFrame containing the result of the tool's execution. + """ + if parameters is None: + parameters = {} + + print( + f"--- MCPToolboxClient: Executing tool '{tool_name}' with params: {parameters} ---" + ) + + tool_config = self.config.get("tools", {}).get(tool_name) + if not tool_config: + raise ValueError(f"Tool '{tool_name}' not found in tools.yaml") + + kind = tool_config.get("kind") + query = "" + + if kind == "bigquery-execute-sql": + if "query" not in parameters: + raise ValueError( + f"Missing required parameter 'query' for tool '{tool_name}'." + ) + query = parameters["query"] + elif kind == "bigquery-sql": + statement = tool_config.get("statement", "") + # Substitute parameters into the statement + query = statement.format(**parameters) + else: + raise ValueError(f"Unsupported tool kind: {kind}") + + print(f"--- MCPToolboxClient: Executing generated query: {query[:200]}... ---") + try: + df = self.bq_client.query(query).to_dataframe() + return df + except Exception as e: + print( + f"--- MCPToolboxClient Error: Query execution failed for tool '{tool_name}': {e} ---" + ) + # Return an empty DataFrame to maintain type consistency + return pd.DataFrame() diff --git a/python/agents/sft-runner-starter-pack/tools.yaml b/python/agents/sft-runner-starter-pack/tools.yaml new file mode 100644 index 000000000..9dba6bd2d --- /dev/null +++ b/python/agents/sft-runner-starter-pack/tools.yaml @@ -0,0 +1,31 @@ +sources: + my_bigquery_source: + kind: "bigquery" + project: "YOUR-PROJECT-ID" + +tools: + execute_sql: + kind: "bigquery-execute-sql" + source: "my_bigquery_source" + description: "Executes a GoogleSQL query against the BigQuery database and returns the result as a DataFrame." + parameters: + - name: "query" + type: "string" + description: "The GoogleSQL query to execute." + + get_table_schema: + kind: "bigquery-sql" + source: "my_bigquery_source" + description: "Returns the schema for a specific table in a given dataset." + statement: | + SELECT column_name, data_type + FROM `YOUR-PROJECT-ID.sft.INFORMATION_SCHEMA.COLUMNS` + WHERE table_name = 'test_data' + + list_tables: + kind: "bigquery-sql" + source: "my_bigquery_source" + description: "Lists all tables in a given dataset." + statement: | + SELECT table_name + FROM `YOUR-PROJECT-ID.sft.INFORMATION_SCHEMA.TABLES` \ No newline at end of file From bb1c7204b093b29a4d8043a3d7225e8061b152b2 Mon Sep 17 00:00:00 2001 From: Suddhasatwa Bhaumik <110594001+suddhasatwabhaumik@users.noreply.github.com> Date: Mon, 15 Dec 2025 16:37:08 +0530 Subject: [PATCH 2/7] Delete python/agents/sft-runner-starter-pack/src/data/test_data.csv Signed-off-by: Suddhasatwa Bhaumik <110594001+suddhasatwabhaumik@users.noreply.github.com> --- .../agents/sft-runner-starter-pack/src/data/test_data.csv | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 python/agents/sft-runner-starter-pack/src/data/test_data.csv diff --git a/python/agents/sft-runner-starter-pack/src/data/test_data.csv b/python/agents/sft-runner-starter-pack/src/data/test_data.csv deleted file mode 100644 index a0413ce51..000000000 --- a/python/agents/sft-runner-starter-pack/src/data/test_data.csv +++ /dev/null @@ -1,6 +0,0 @@ -PART_NUMBER,BASELINE_PRICE,LAST_PAID_PRICE,SPEND,GBE_NAME,PURCHASE_YEAR,SUPPLIER_NAME,SUPPLIER_ID,ESTIMATED_YEARS_TO_EOL,TOP_LEVEL_MATERIAL,PLANT,FSH_FLAG_BY_QTY,FSH_FLAG_BY_AMOUNT,DEMAND,FORECASTED_YEAR,MEP,CROSS_PART_NUMBER,CROSS_PART_DESCRIPTION,REGION,MATERIAL,PLANT_SPECIFIC_STATUS_CURRENT_DESC,TOTAL_STOCK -'503687JEE',150.75,155.20,50000,'Fire',2023,'Supplier A','1001',5,'SKU-001','5201','NORMAL','NORMAL',1000,2025,'MEP-001','ALT-001','Alternate Part 1','AMER','EEM230-D-P','Active',5000 -'I87-0438-000',25.50,26.00,120000,'PSS',2023,'Supplier B','1002',3,'SKU-002','5201','NORMAL','NORMAL',2500,2025,'MEP-002','ALT-002','Alternate Part 2','EMEA','EEM230-D-P','Active',10000 -'07094SM',12.00,11.90,75000,'Fire',2022,'Supplier A','1001',10,'SKU-001','4833','SLOW','NORMAL',500,2025,'MEP-003','ALT-003','Alternate Part 3','APAC','EEM230-D-P','Active',200 -'I87-0004-000',5.20,5.25,25000,'HSS',2023,'Supplier C','1003',2,'SKU-003','4833','NORMAL','SLOW',800,2025,'R7FA2E1A73CFL#HA0','ALT-004','Alternate Part 4','AMER','EEM230-D-P','Obsolete',15000 -'R7FA2E1A73CFL#HA0',33.00,33.50,90000,'PSS',2024,'Supplier B','1002',7,'SKU-004','5201','NORMAL','NORMAL',1200,2025,'R7FA2E1A73CFL#HA0','ALT-005','Alternate Part 5','EMEA','EEM230-D-P','Active',8000 From 5ce46249325a6874f26080f109b836f088f5daff Mon Sep 17 00:00:00 2001 From: Suddhasatwa Bhaumik <110594001+suddhasatwabhaumik@users.noreply.github.com> Date: Mon, 15 Dec 2025 16:37:26 +0530 Subject: [PATCH 3/7] Delete python/agents/sft-runner-starter-pack/src/data/eval_queries.csv Signed-off-by: Suddhasatwa Bhaumik <110594001+suddhasatwabhaumik@users.noreply.github.com> --- .../sft-runner-starter-pack/src/data/eval_queries.csv | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 python/agents/sft-runner-starter-pack/src/data/eval_queries.csv diff --git a/python/agents/sft-runner-starter-pack/src/data/eval_queries.csv b/python/agents/sft-runner-starter-pack/src/data/eval_queries.csv deleted file mode 100644 index 2f7637bfb..000000000 --- a/python/agents/sft-runner-starter-pack/src/data/eval_queries.csv +++ /dev/null @@ -1,9 +0,0 @@ -"question","ground_truth_sql" -"What is the last paid price for part 'I87-0438-000'?","SELECT LAST_PAID_PRICE FROM `sb-genai-project.sft.test_data` WHERE PART_NUMBER = 'I87-0438-000'" -"How many SKUs are associated with part number '07094SM'?","SELECT COUNT(DISTINCT TOP_LEVEL_MATERIAL) FROM `sb-genai-project.sft.test_data` WHERE PART_NUMBER = '07094SM'" -"List all parts in plant '5201' that are considered slow moving.","SELECT PART_NUMBER, PART_NAME FROM `sb-genai-project.sft.test_data` WHERE PLANT = '5201' AND (UPPER(FSH_FLAG_BY_QTY) = 'SLOW' OR UPPER(FSH_FLAG_BY_AMOUNT) = 'SLOW')" -"What is the total forecasted demand for all parts in 2025?","SELECT SUM(DEMAND) AS total_demand FROM `sb-genai-project.sft.test_data` WHERE FORECASTED_YEAR = 2025" -"Find all alternate parts for MEP 'R7FA2E1A73CFL#HA0'.","SELECT CROSS_PART_NUMBER, CROSS_PART_DESCRIPTION FROM `sb-genai-project.sft.test_data` WHERE MEP = 'R7FA2E1A73CFL#HA0'" -"Get the supplier details for supplier ID '1001'.","SELECT SUPPLIER_NAME, REGION FROM `sb-genai-project.sft.test_data` WHERE SUPPLIER_ID = '1001' LIMIT 1" -"What is the obsolescence status for SKU 'EEM230-D-P'?","SELECT PLANT_SPECIFIC_STATUS_CURRENT_DESC FROM `sb-genai-project.sft.test_data` WHERE MATERIAL = 'EEM230-D-P'" -"List the top 3 plants by total inventory stock.","SELECT PLANT, SUM(TOTAL_STOCK) as total_stock FROM `sb-genai-project.sft.test_data` GROUP BY PLANT ORDER BY total_stock DESC LIMIT 3" \ No newline at end of file From 83a0dba6b32d25095cfb2f87fbfd49c44753293d Mon Sep 17 00:00:00 2001 From: Suddhasatwa Bhaumik Date: Mon, 15 Dec 2025 16:42:30 +0530 Subject: [PATCH 4/7] Updated better descriptions; Removed unused files --- python/agents/sft-runner-starter-pack/README.md | 12 +++++++++++- .../src/data/eval_queries.csv | 9 --------- .../sft-runner-starter-pack/src/data/test_data.csv | 6 ------ 3 files changed, 11 insertions(+), 16 deletions(-) delete mode 100644 python/agents/sft-runner-starter-pack/src/data/eval_queries.csv delete mode 100644 python/agents/sft-runner-starter-pack/src/data/test_data.csv diff --git a/python/agents/sft-runner-starter-pack/README.md b/python/agents/sft-runner-starter-pack/README.md index 4662b2316..db7b446a4 100644 --- a/python/agents/sft-runner-starter-pack/README.md +++ b/python/agents/sft-runner-starter-pack/README.md @@ -122,6 +122,9 @@ We provide an automated script to bootstrap your Google Cloud environment. This * πŸ“¦ **Creates Repository:** Sets up a Docker repository named `sft-runner-starter-pack` in `us-central1`. * πŸ›‘οΈ **Configures IAM:** Grants the default Compute Engine Service Account permissions to manage Cloud Run services and write to the Artifact Registry. +> **Note:** We expect that you already have a BigQuery dataset with tables/views which correspond to the actual Business data, pertaining to your +use case, which would be the `baseline` for the Fine tuned model to generate SQL Queries by understanding its metadata information. + **Run the initialization script:** ```bash @@ -189,7 +192,14 @@ substitutions: _PROJECT_ID: 'YOUR-PROJECT-ID-HERE' # Replace with your Project ID ``` -Additionally,, open `.env` file and update `YOUR-PROJECT-ID` with your actual GCP Project ID. +Additionally, open [`.env`](src/.env) file and update +- `YOUR-PROJECT-ID` with your actual GCP Project ID. +- `YOUR-BQ-DATASET-ID` with the actual BQ dataset where you have your data tables/views. +- `YOUR-BUCKET-ID` with the actual temporary GCS Bucket which the application will use. + +> **Note:** By BigQuery dataset above, we refer to the tables which contain the actual data on which the Fine tuned +model is finally supposed to generate SQL on. This application, by its design, will incorporate the available metadata +of the available tables and views to generate SQL Queries for any given question. ### 2\. The Deployment Pipeline diff --git a/python/agents/sft-runner-starter-pack/src/data/eval_queries.csv b/python/agents/sft-runner-starter-pack/src/data/eval_queries.csv deleted file mode 100644 index 2f7637bfb..000000000 --- a/python/agents/sft-runner-starter-pack/src/data/eval_queries.csv +++ /dev/null @@ -1,9 +0,0 @@ -"question","ground_truth_sql" -"What is the last paid price for part 'I87-0438-000'?","SELECT LAST_PAID_PRICE FROM `sb-genai-project.sft.test_data` WHERE PART_NUMBER = 'I87-0438-000'" -"How many SKUs are associated with part number '07094SM'?","SELECT COUNT(DISTINCT TOP_LEVEL_MATERIAL) FROM `sb-genai-project.sft.test_data` WHERE PART_NUMBER = '07094SM'" -"List all parts in plant '5201' that are considered slow moving.","SELECT PART_NUMBER, PART_NAME FROM `sb-genai-project.sft.test_data` WHERE PLANT = '5201' AND (UPPER(FSH_FLAG_BY_QTY) = 'SLOW' OR UPPER(FSH_FLAG_BY_AMOUNT) = 'SLOW')" -"What is the total forecasted demand for all parts in 2025?","SELECT SUM(DEMAND) AS total_demand FROM `sb-genai-project.sft.test_data` WHERE FORECASTED_YEAR = 2025" -"Find all alternate parts for MEP 'R7FA2E1A73CFL#HA0'.","SELECT CROSS_PART_NUMBER, CROSS_PART_DESCRIPTION FROM `sb-genai-project.sft.test_data` WHERE MEP = 'R7FA2E1A73CFL#HA0'" -"Get the supplier details for supplier ID '1001'.","SELECT SUPPLIER_NAME, REGION FROM `sb-genai-project.sft.test_data` WHERE SUPPLIER_ID = '1001' LIMIT 1" -"What is the obsolescence status for SKU 'EEM230-D-P'?","SELECT PLANT_SPECIFIC_STATUS_CURRENT_DESC FROM `sb-genai-project.sft.test_data` WHERE MATERIAL = 'EEM230-D-P'" -"List the top 3 plants by total inventory stock.","SELECT PLANT, SUM(TOTAL_STOCK) as total_stock FROM `sb-genai-project.sft.test_data` GROUP BY PLANT ORDER BY total_stock DESC LIMIT 3" \ No newline at end of file diff --git a/python/agents/sft-runner-starter-pack/src/data/test_data.csv b/python/agents/sft-runner-starter-pack/src/data/test_data.csv deleted file mode 100644 index a0413ce51..000000000 --- a/python/agents/sft-runner-starter-pack/src/data/test_data.csv +++ /dev/null @@ -1,6 +0,0 @@ -PART_NUMBER,BASELINE_PRICE,LAST_PAID_PRICE,SPEND,GBE_NAME,PURCHASE_YEAR,SUPPLIER_NAME,SUPPLIER_ID,ESTIMATED_YEARS_TO_EOL,TOP_LEVEL_MATERIAL,PLANT,FSH_FLAG_BY_QTY,FSH_FLAG_BY_AMOUNT,DEMAND,FORECASTED_YEAR,MEP,CROSS_PART_NUMBER,CROSS_PART_DESCRIPTION,REGION,MATERIAL,PLANT_SPECIFIC_STATUS_CURRENT_DESC,TOTAL_STOCK -'503687JEE',150.75,155.20,50000,'Fire',2023,'Supplier A','1001',5,'SKU-001','5201','NORMAL','NORMAL',1000,2025,'MEP-001','ALT-001','Alternate Part 1','AMER','EEM230-D-P','Active',5000 -'I87-0438-000',25.50,26.00,120000,'PSS',2023,'Supplier B','1002',3,'SKU-002','5201','NORMAL','NORMAL',2500,2025,'MEP-002','ALT-002','Alternate Part 2','EMEA','EEM230-D-P','Active',10000 -'07094SM',12.00,11.90,75000,'Fire',2022,'Supplier A','1001',10,'SKU-001','4833','SLOW','NORMAL',500,2025,'MEP-003','ALT-003','Alternate Part 3','APAC','EEM230-D-P','Active',200 -'I87-0004-000',5.20,5.25,25000,'HSS',2023,'Supplier C','1003',2,'SKU-003','4833','NORMAL','SLOW',800,2025,'R7FA2E1A73CFL#HA0','ALT-004','Alternate Part 4','AMER','EEM230-D-P','Obsolete',15000 -'R7FA2E1A73CFL#HA0',33.00,33.50,90000,'PSS',2024,'Supplier B','1002',7,'SKU-004','5201','NORMAL','NORMAL',1200,2025,'R7FA2E1A73CFL#HA0','ALT-005','Alternate Part 5','EMEA','EEM230-D-P','Active',8000 From cdb56e77510bc9573ced7ce4bf99850fd1a1c2ec Mon Sep 17 00:00:00 2001 From: Suddhasatwa Bhaumik Date: Mon, 15 Dec 2025 16:50:09 +0530 Subject: [PATCH 5/7] Renamed Environment File to .config As Per Repository Standards. --- .../agents/sft-runner-starter-pack/README.md | 4 +- .../sft-runner-starter-pack/src/.config | 44 +++++++++++++++++++ .../sft-runner-starter-pack/src/agent.py | 2 +- .../sft-runner-starter-pack/src/config.py | 4 +- 4 files changed, 49 insertions(+), 5 deletions(-) create mode 100644 python/agents/sft-runner-starter-pack/src/.config diff --git a/python/agents/sft-runner-starter-pack/README.md b/python/agents/sft-runner-starter-pack/README.md index db7b446a4..cf0e2ab3b 100644 --- a/python/agents/sft-runner-starter-pack/README.md +++ b/python/agents/sft-runner-starter-pack/README.md @@ -192,7 +192,7 @@ substitutions: _PROJECT_ID: 'YOUR-PROJECT-ID-HERE' # Replace with your Project ID ``` -Additionally, open [`.env`](src/.env) file and update +Additionally, open [`.config`](src/.config) file and update - `YOUR-PROJECT-ID` with your actual GCP Project ID. - `YOUR-BQ-DATASET-ID` with the actual BQ dataset where you have your data tables/views. - `YOUR-BUCKET-ID` with the actual temporary GCS Bucket which the application will use. @@ -237,7 +237,7 @@ The application runs inside a secure, non-root container environment: * **Context:** Copies the local directory into the image. > **Note on Environment Variables:** -> Since the `Dockerfile` copies local files (`COPY . .`), your local `.env` file will be included in the container. For production environments, it is recommended to exclude `.env` via `.dockerignore` and set secrets directly in the Cloud Run configuration using Google Secret Manager. +> Since the `Dockerfile` copies local files (`COPY . .`), your local `.config` file will be included in the container. For production environments, it is recommended to exclude `.config` via `.dockerignore` and set secrets directly in the Cloud Run configuration using Google Secret Manager. ## πŸ“‚ Project Structure diff --git a/python/agents/sft-runner-starter-pack/src/.config b/python/agents/sft-runner-starter-pack/src/.config new file mode 100644 index 000000000..b1d77f37c --- /dev/null +++ b/python/agents/sft-runner-starter-pack/src/.config @@ -0,0 +1,44 @@ +# Copyright 2025 Google LLC + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Environment file with required/important configurations""" +# Vertex AI +GOOGLE_GENAI_USE_VERTEXAI=1 + +# Vertex backend config +GOOGLE_CLOUD_PROJECT=YOUR-PROJECT-ID +GOOGLE_CLOUD_LOCATION=us-central1 + +# BQ Data Configuration +BQ_LOCATION=us-central1 +PROJECT=YOUR-PROJECT-ID +DATASET=YOUR-BQ-DATASET-ID + +# Gemini related parameters +FLASH_MODEL=gemini-2.5-flash +PRO_MODEL=gemini-2.5-pro +INITIAL_TARGET_EXAMPLES=100 +TEMPERATURE=0 +TOP_P=1 +MAX_OUT_TOKENS=65535 +SEED=0 +SEED_QUERIES="../../data/seed_queries.csv" +EVAL_DATASET="../../data/sample_eval_queries.csv" +MAX_RETRIES=3 +INITIAL_DELAY=2 + +# Additional parameters +GCP_PROJECT_ID="YOUR-PROJECT-ID" +GCP_LOCATION="us-central1" +GCS_BUCKET_NAME="YOUR-BUCKET-ID" diff --git a/python/agents/sft-runner-starter-pack/src/agent.py b/python/agents/sft-runner-starter-pack/src/agent.py index 82bb35942..942a8df6c 100644 --- a/python/agents/sft-runner-starter-pack/src/agent.py +++ b/python/agents/sft-runner-starter-pack/src/agent.py @@ -107,7 +107,7 @@ def create_evaluator_agent(): # --- State Setup --- def setup_before_agent_call(callback_context: CallbackContext): """Sets up the initial state for the agent from environment variables.""" - print("--- Setting up initial state from .env file ---") + print("--- Setting up initial state from .config file ---") callback_context.state["seed_data_path"] = ( config.seed_queries diff --git a/python/agents/sft-runner-starter-pack/src/config.py b/python/agents/sft-runner-starter-pack/src/config.py index 8e352a79b..58c55f7a2 100644 --- a/python/agents/sft-runner-starter-pack/src/config.py +++ b/python/agents/sft-runner-starter-pack/src/config.py @@ -19,8 +19,8 @@ from typing import Optional from dotenv import load_dotenv -# Load environment variables from .env file if it exists -env_path = Path(__file__).parent / ".env" +# Load environment variables from .config file if it exists +env_path = Path(__file__).parent / ".config" if env_path.exists(): load_dotenv(env_path) From 3411101e23656a36ddb3d8460c24bff5ebbed814 Mon Sep 17 00:00:00 2001 From: Suddhasatwa Bhaumik Date: Mon, 15 Dec 2025 16:53:44 +0530 Subject: [PATCH 6/7] Ignoring specific files w.r.t Python packaging --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 0fb339bb2..1e1062829 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ var/ wheels/ share/python-wheels/ *.egg-info/ +src/*egg* .installed.cfg pyvenv.cfg *.egg From a802ebd9a26e5f3dc93e57369b0d93f7cff1eb35 Mon Sep 17 00:00:00 2001 From: Suddhasatwa Bhaumik Date: Mon, 29 Dec 2025 16:51:51 +0530 Subject: [PATCH 7/7] Updated README with Usage instructions --- python/agents/sft-runner-starter-pack/README.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/python/agents/sft-runner-starter-pack/README.md b/python/agents/sft-runner-starter-pack/README.md index cf0e2ab3b..19990d971 100644 --- a/python/agents/sft-runner-starter-pack/README.md +++ b/python/agents/sft-runner-starter-pack/README.md @@ -239,6 +239,17 @@ The application runs inside a secure, non-root container environment: > **Note on Environment Variables:** > Since the `Dockerfile` copies local files (`COPY . .`), your local `.config` file will be included in the container. For production environments, it is recommended to exclude `.config` via `.dockerignore` and set secrets directly in the Cloud Run configuration using Google Secret Manager. +## Usage + +1. Open the URL of the App once deployed using the Cloud Run URL. +2. Simply ask: "Hi. What can you do?", & the Agent responds with the steps. +3. The idea flow is: you ask to generate dummy data, followed by running SFT, and lastly to evaluate the model. +4. All operations run in the background and information is shown over in the UI. + +> **Note:** +> In the current/initial version, the UI is `stateful` in its responses - I.e., it waits for all operations in the backend, and comes back with the +status only once the backend task (like generating data or fine tuning a model, for examples) are completed on Vertex AI. + ## πŸ“‚ Project Structure ```text