From 4bccc41ad75d8e7f8e3f905aaed004293ff61231 Mon Sep 17 00:00:00 2001 From: Jordan Larson Date: Thu, 6 Nov 2025 13:38:17 -0500 Subject: [PATCH 1/3] initial vibe coded wandb.py file --- experiment-tracking/wandb.py | 90 ++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 experiment-tracking/wandb.py diff --git a/experiment-tracking/wandb.py b/experiment-tracking/wandb.py new file mode 100644 index 0000000..34d2f92 --- /dev/null +++ b/experiment-tracking/wandb.py @@ -0,0 +1,90 @@ +import wandb +import subprocess +import hashlib +from pathlib import Path + + +def get_git_commit_hash(): + """Get the current git commit hash.""" + try: + result = subprocess.run( + ["git", "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=True + ) + return result.stdout.strip() + except (subprocess.CalledProcessError, FileNotFoundError): + return "unknown" + + +def get_data_version(data_path=None): + """ + Get data version by computing hash of data files. + If data_path is provided, hash those files. Otherwise, return a placeholder. + """ + if data_path is None: + # Return a placeholder - user should specify their data path + return "data-v1.0" + + data_path = Path(data_path) + if not data_path.exists(): + return "unknown" + + # Compute hash of data files + hasher = hashlib.sha256() + if data_path.is_file(): + with open(data_path, "rb") as f: + hasher.update(f.read()) + elif data_path.is_dir(): + for file_path in sorted(data_path.rglob("*")): + if file_path.is_file(): + with open(file_path, "rb") as f: + hasher.update(f.read()) + + return f"data-{hasher.hexdigest()[:8]}" + + +# Hyperparameters +hyperparameters = { + "learning_rate": 0.01, + "batch_size": 32, + "epochs": 10, + "optimizer": "adam", + "loss_function": "cross_entropy", + # Add more hyperparameters as needed +} + +# Initialize wandb with config +wandb.init( + project="readcrumbs", + name="experiment-1", + config={ + **hyperparameters, + "code_version": get_git_commit_hash(), + "data_version": get_data_version(), # Update with your actual data path + } +) + +# Example training loop +for epoch in range(hyperparameters["epochs"]): + # Simulate training metrics + # Replace these with your actual training code + + # Log metrics for each epoch + metrics = { + "epoch": epoch + 1, + "loss": 0.1 * (0.9 ** epoch), # Example: decreasing loss + "accuracy": 0.5 + 0.4 * (1 - 0.9 ** epoch), # Example: increasing accuracy + "f1_score": 0.5 + 0.4 * (1 - 0.9 ** epoch), # Example: increasing f1 + } + + wandb.log(metrics) + +# Log final metrics +wandb.log({ + "final_accuracy": metrics["accuracy"], + "final_f1_score": metrics["f1_score"], +}) + +wandb.finish() From e8d52d4eea70304c9bfee89fcef79108ef3a5c78 Mon Sep 17 00:00:00 2001 From: Jordan Larson Date: Thu, 6 Nov 2025 14:01:44 -0500 Subject: [PATCH 2/3] model registry with staging added --- experiment-tracking/wandb.py | 248 ++++++++++++++++++++++++++++++++++- 1 file changed, 246 insertions(+), 2 deletions(-) diff --git a/experiment-tracking/wandb.py b/experiment-tracking/wandb.py index 34d2f92..f5927af 100644 --- a/experiment-tracking/wandb.py +++ b/experiment-tracking/wandb.py @@ -1,6 +1,7 @@ import wandb import subprocess import hashlib +import tempfile from pathlib import Path @@ -45,6 +46,210 @@ def get_data_version(data_path=None): return f"data-{hasher.hexdigest()[:8]}" +def save_model_artifact(model, model_name="model", model_type="pytorch", metadata=None, + registered_model_name=None): + """ + Save a trained model as a wandb artifact and optionally link it to a registered model. + + Args: + model: The trained model object (PyTorch, TensorFlow, sklearn, etc.) + model_name: Name for the model artifact + model_type: Type of model ('pytorch', 'tensorflow', 'sklearn', 'pickle', etc.) + metadata: Optional dictionary of additional metadata to attach to the artifact + registered_model_name: Optional name of the registered model in Model Registry + + Returns: + The wandb artifact object + """ + artifact = wandb.Artifact( + name=model_name, + type="model", + description=f"Trained {model_type} model", + metadata=metadata or {} + ) + + # Create a temporary directory to save the model + with tempfile.TemporaryDirectory() as tmpdir: + model_path = Path(tmpdir) / f"{model_name}.{_get_model_extension(model_type)}" + + # Save model based on type + if model_type == "pytorch": + import torch + torch.save(model.state_dict() if hasattr(model, 'state_dict') else model, model_path) + elif model_type == "tensorflow": + model.save(str(model_path)) + elif model_type == "sklearn": + import joblib + joblib.dump(model, model_path) + elif model_type == "pickle": + import pickle + with open(model_path, 'wb') as f: + pickle.dump(model, f) + else: + # Default: try to save as pickle + import pickle + with open(model_path, 'wb') as f: + pickle.dump(model, f) + + # Add the model file to the artifact + artifact.add_file(str(model_path)) + + # Log the artifact + wandb.log_artifact(artifact) + + # Link to registered model if specified + if registered_model_name: + artifact.wait() # Wait for artifact to be logged + run = wandb.run + if run: + # Link artifact to registered model + run.link_artifact( + artifact, + f"{registered_model_name}:latest", + aliases=["latest"] + ) + + return artifact + + +def promote_model_to_stage(registered_model_name, alias="staging", metric_name="f1_score", + metric_value=None, comparison="max", project_name=None): + """ + Promote a model version to a specific stage (staging/production) in the Model Registry. + + Args: + registered_model_name: Name of the registered model in Model Registry + alias: Stage alias to assign ('staging' or 'production') + metric_name: Name of the metric to use for comparison (e.g., 'f1_score', 'accuracy') + metric_value: Optional metric value. If None, uses the latest model version + comparison: How to compare models ('max' for higher is better, 'min' for lower is better) + project_name: Optional project name. If None, uses current wandb project + + Returns: + True if promotion was successful, False otherwise + """ + try: + api = wandb.Api() + + # Get project name from current run if not provided + if project_name is None: + project_name = wandb.run.project if wandb.run else "readcrumbs" + + # Access registered model + registered_model_path = f"{project_name}/{registered_model_name}" + registered_model = api.registered_model(registered_model_path) + + if metric_value is not None: + # Find the best model based on metric + best_version = None + best_metric = float('-inf') if comparison == "max" else float('inf') + + for version in registered_model.versions: + # Get metadata from the artifact + try: + artifact = version.artifact + version_metadata = artifact.metadata or {} + version_metric = version_metadata.get(metric_name) + + if version_metric is not None: + if comparison == "max" and version_metric > best_metric: + best_metric = version_metric + best_version = version + elif comparison == "min" and version_metric < best_metric: + best_metric = version_metric + best_version = version + except Exception: + continue + + if best_version: + # Update aliases + current_aliases = list(best_version.aliases) if best_version.aliases else [] + if alias not in current_aliases: + current_aliases.append(alias) + best_version.aliases = current_aliases + best_version.update() + print(f"Promoted model version {best_version.version} to '{alias}' stage " + f"(metric: {metric_name}={best_metric})") + return True + else: + # Promote the latest version + if registered_model.versions: + latest_version = registered_model.versions[0] + current_aliases = list(latest_version.aliases) if latest_version.aliases else [] + if alias not in current_aliases: + current_aliases.append(alias) + latest_version.aliases = current_aliases + latest_version.update() + print(f"Promoted latest model version {latest_version.version} to '{alias}' stage") + return True + + return False + except Exception as e: + print(f"Error promoting model: {e}") + print(f"Note: Make sure the registered model '{registered_model_name}' exists in the Model Registry.") + return False + + +def save_and_register_model(model, model_name="model", model_type="pytorch", + registered_model_name="readcrumbs-model", metadata=None, + auto_promote=False, promotion_stage="staging", + promotion_metric="f1_score", project_name=None): + """ + Save a model as an artifact, register it, and optionally promote it based on performance. + + Args: + model: The trained model object + model_name: Name for the model artifact + model_type: Type of model ('pytorch', 'tensorflow', 'sklearn', 'pickle', etc.) + registered_model_name: Name of the registered model in Model Registry + metadata: Optional dictionary of additional metadata + auto_promote: If True, automatically promote to staging if it's the best model + promotion_stage: Stage to promote to ('staging' or 'production') + promotion_metric: Metric name to use for promotion comparison + project_name: Optional project name. If None, uses current wandb project + + Returns: + Tuple of (artifact, promoted) where promoted is True if model was promoted + """ + # Save model artifact and link to registered model + artifact = save_model_artifact( + model=model, + model_name=model_name, + model_type=model_type, + metadata=metadata, + registered_model_name=registered_model_name + ) + + promoted = False + if auto_promote and metadata and promotion_metric in metadata: + # Wait a bit for artifact to be fully processed + import time + time.sleep(2) + + # Promote based on metric value + promoted = promote_model_to_stage( + registered_model_name=registered_model_name, + alias=promotion_stage, + metric_name=promotion_metric, + metric_value=metadata[promotion_metric], + comparison="max", # Assuming higher is better for most metrics + project_name=project_name + ) + + return artifact, promoted + + +def _get_model_extension(model_type): + """Get the file extension for a given model type.""" + extensions = { + "pytorch": "pth", + "tensorflow": "h5", + "sklearn": "joblib", + "pickle": "pkl" + } + return extensions.get(model_type.lower(), "pkl") + + # Hyperparameters hyperparameters = { "learning_rate": 0.01, @@ -82,9 +287,48 @@ def get_data_version(data_path=None): wandb.log(metrics) # Log final metrics -wandb.log({ +final_metrics = { "final_accuracy": metrics["accuracy"], "final_f1_score": metrics["f1_score"], -}) +} +wandb.log(final_metrics) + +# Save the trained model as an artifact and register it in Model Registry +# Example usage (uncomment and modify based on your model): +# model = your_trained_model # Replace with your actual model +# +# # Prepare metadata with performance metrics +# model_metadata = { +# "final_accuracy": final_metrics["final_accuracy"], +# "final_f1_score": final_metrics["final_f1_score"], +# "epochs": hyperparameters["epochs"], +# "learning_rate": hyperparameters["learning_rate"], +# "batch_size": hyperparameters["batch_size"], +# "code_version": wandb.config.get("code_version", "unknown"), +# "data_version": wandb.config.get("data_version", "unknown"), +# } +# +# # Save and register model with automatic promotion to staging if it's the best +# artifact, promoted = save_and_register_model( +# model=model, +# model_name="readcrumbs-model", +# model_type="pytorch", # or "tensorflow", "sklearn", "pickle" +# registered_model_name="readcrumbs-model", # Name in Model Registry +# metadata=model_metadata, +# auto_promote=True, # Automatically promote to staging if best model +# promotion_stage="staging", # or "production" +# promotion_metric="f1_score" # Metric to use for comparison +# ) +# +# if promoted: +# print(f"Model automatically promoted to staging based on {promotion_metric}") +# +# # Alternatively, manually promote to production after review: +# # promote_model_to_stage( +# # registered_model_name="readcrumbs-model", +# # alias="production", +# # metric_name="f1_score", +# # comparison="max" +# # ) wandb.finish() From e668f9521371b79b7257e97d4d87339e96fdff6b Mon Sep 17 00:00:00 2001 From: Jordan Larson Date: Wed, 19 Nov 2025 14:59:55 -0500 Subject: [PATCH 3/3] fast api backend with dynamo db connection, working health endpoint, and seetup for model sync --- backend.ppk | 26 +++++ backend/api.py | 221 +++++++++++++++++++++++++++++++++++++++++++ prediction-table.pem | 27 ++++++ 3 files changed, 274 insertions(+) create mode 100644 backend.ppk create mode 100644 backend/api.py create mode 100644 prediction-table.pem diff --git a/backend.ppk b/backend.ppk new file mode 100644 index 0000000..9bbbd7e --- /dev/null +++ b/backend.ppk @@ -0,0 +1,26 @@ +PuTTY-User-Key-File-2: ssh-rsa +Encryption: none +Comment: backend +Public-Lines: 6 +AAAAB3NzaC1yc2EAAAADAQABAAABAQDYFtyIaC0Mr2z/oeVLARxPvLBC2g7C4Juf +XhN6KHTq3U9niZrKY4pC9uYX3L7FgPGHd0QICztSRFQ3wEaQ51/87RpKogS+S4lN +YYYqEJLUoScR/uq3ooi4YI+YRgv61RY3iTd7fSoht3xhpTbwbXodq/pRi8jv8/Kz +BFPbNScU8AepaIPAtqlyzlhx2+5r/xW//TMkKHUvWsB1ZHuKkvYlNdZpEFrsOVL7 +7bbnFZhn7A9FYgz5XViZYH5v6LOvhxJD6Qq1Bx9d+A1DwmMQxb+0AV0MK4eWthaM +FQ7c824si6QgnquzvT9uev5e3lWpviKWKGpoB+M7jsUj9VRcIcMz +Private-Lines: 14 +AAABAQClWhEVHEpkq5RHpLXViBsG9QcXkM681qye7ZkP4AdfdUv+mXBmMHcrOMzn +M5aTgVDQ7TWUxit1jy22n54f4b00yKZWt9XTW+/L07WbWKVSqaJBBgTL0ka2d8VJ +q0gdf5MJmilniGaF+GboPUll/w/zxpa8ca+n1c9Apy4XznuCa6Yh7Y8kYbgT5wke +wVXaVbWL89K0SUCJHZDtimLjWeRNQwvdMkjt+GnPET8PiQACvMLS2B2U6sRLWlY+ +AeKH6QwwvWzwHf/qjAb+BRojYyindI/BljxFVuGz3WETneN8jKdR8oV3zr/cnjYj +00uXtV+U94NRNDUofIo3z/7tHeDhAAAAgQDuzss0AuSsmXs19K/zbZ1cN93MuZXa +tEFIrhrADyIag87ftRLZTjp8OOtUedP4K6b6sE/zxEdZbtauo/jiR3/rdGJQ8Qer +tgEFm5Lj2WFG0KqIVk3jjVWyb7boYcnLC7YywwopAgNDbyvfbqWtWPPB1gnu4+c+ +YMZ0hmdkxsUfIwAAAIEA56VeN0jlU8+YLUbEi0wpF8NCffwLRXVwELZWqRyEuPFS +K9Zr2cvEsk5Wz9lZlHR1m9c9QI4ONKyHTV4XYJoP5pQEQQeOEGajp+AnaP9ylOZl +Vm65urQ89BJ9VfSlhQnUtAvbvudOJG4x5F72XVTS0I3FIxUMvRF0z/KNnEm9lLEA +AACABuvc8kP4dvk+an8iydDopJ/2v4Et3f9qKNYnsuPgU+qwH3x2fburMkXv49oj +Lrn5NKRui/qWCell6rTWU3i+91c8vhtfaAVdgutQjSMyVXgfzFXRkvaPvOKnrt9k +a3kneUkD2dyx6RhLEpPQI6Rb/8il8hBEwiN+LTGqqVlg0RA= +Private-MAC: 25566de206acf221830b84f25c13174a6998d597 diff --git a/backend/api.py b/backend/api.py new file mode 100644 index 0000000..414484e --- /dev/null +++ b/backend/api.py @@ -0,0 +1,221 @@ +import fastapi +import boto3 +import os +import datetime +import time +import random +from typing import List +from pydantic import BaseModel +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + +''' +To-Do: +- [ ] Connect to S3 w/ model +- [ ] Create a function to load the model from S3 +- [ ] Create a function to predict using the model +''' + +## Helper Functions +def load_model_from_s3(model_name: str): + """ + Download and load an ML model file from S3 into memory without persisting it to disk. + + Uses AWS credentials from environment variables if available: + - AWS_ACCESS_KEY_ID + - AWS_SECRET_ACCESS_KEY + - AWS_SESSION_TOKEN (optional, for temporary credentials) + + Falls back to IAM roles (if running on EC2, Lambda, ECS, etc.) or ~/.aws/credentials + + Required environment variables: + - S3_MODEL_BUCKET: S3 bucket name + - AWS_REGION: AWS region (optional, defaults to us-east-1) + + Args: + model_name (str): The key/path of the model file in the S3 bucket. + + Returns: + The loaded model object. + """ + s3_bucket = os.environ.get("S3_MODEL_BUCKET") + if not s3_bucket: + raise ValueError("S3_MODEL_BUCKET environment variable not set.") + + region = os.environ.get("AWS_REGION", "us-east-1") + + # Get AWS credentials from environment variables + aws_access_key_id = os.environ.get("AWS_ACCESS_KEY_ID") + aws_secret_access_key = os.environ.get("AWS_SECRET_ACCESS_KEY") + aws_session_token = os.environ.get("AWS_SESSION_TOKEN") + + # Create boto3 client with explicit credentials if available + if aws_access_key_id and aws_secret_access_key: + s3 = boto3.client( + "s3", + aws_access_key_id=aws_access_key_id, + aws_secret_access_key=aws_secret_access_key, + aws_session_token=aws_session_token, + region_name=region + ) + else: + # Fall back to default credential chain (IAM roles, ~/.aws/credentials, etc.) + s3 = boto3.client("s3", region_name=region) + + # Download model object as bytes into memory + response = s3.get_object(Bucket=s3_bucket, Key=model_name) + model_bytes = response['Body'].read() + import pickle + model = pickle.loads(model_bytes) + return model + +def predict_using_model(model, data): + pass + +def serialize_for_dynamodb(data): + """ + Recursively serialize data for DynamoDB. + Converts datetime objects to ISO format strings. + """ + if isinstance(data, datetime.datetime): + return data.isoformat() + elif isinstance(data, dict): + return {k: serialize_for_dynamodb(v) for k, v in data.items()} + elif isinstance(data, list): + return [serialize_for_dynamodb(item) for item in data] + else: + return data + +def get_dynamodb_table(): + """ + Get a DynamoDB table resource with proper credentials. + + Returns: + boto3 DynamoDB Table resource + """ + table_name = os.environ.get("DDB_TABLE") + if not table_name: + raise ValueError("DDB_TABLE environment variable not set.") + + region = os.environ.get("AWS_REGION", "us-east-1") + + # Get AWS credentials from environment variables + aws_access_key_id = os.environ.get("AWS_ACCESS_KEY_ID") + aws_secret_access_key = os.environ.get("AWS_SECRET_ACCESS_KEY") + aws_session_token = os.environ.get("AWS_SESSION_TOKEN") + + # Create boto3 session with explicit credentials if available + if aws_access_key_id and aws_secret_access_key: + session = boto3.Session( + aws_access_key_id=aws_access_key_id, + aws_secret_access_key=aws_secret_access_key, + aws_session_token=aws_session_token, + region_name=region + ) + dynamodb = session.resource("dynamodb") + else: + # Fall back to default credential chain (IAM roles, ~/.aws/credentials, etc.) + dynamodb = boto3.resource("dynamodb", region_name=region) + + return dynamodb.Table(table_name) + +def get_random_item_from_ddb(): + """ + Retrieve a random item from DynamoDB table. + + Returns: + dict: A random item from the table, or None if table is empty + """ + table = get_dynamodb_table() + + # Scan the table to get all items + # Note: For very large tables, this could be expensive. + # Consider optimizing with pagination or sampling if needed. + response = table.scan() + items = response.get('Items', []) + + # Handle pagination if there are more items + while 'LastEvaluatedKey' in response: + response = table.scan(ExclusiveStartKey=response['LastEvaluatedKey']) + items.extend(response.get('Items', [])) + + if not items: + return None + + # Return a random item + return random.choice(items) + +def save_to_ddb(data): + """ + Save or update a dictionary of data to DynamoDB. + Uses user_id (integer) as the primary key. If user_id already exists, the item will be updated. + + Uses AWS credentials from environment variables if available: + - AWS_ACCESS_KEY_ID + - AWS_SECRET_ACCESS_KEY + - AWS_SESSION_TOKEN (optional, for temporary credentials) + + Falls back to IAM roles (if running on EC2, Lambda, ECS, etc.) or ~/.aws/credentials + + Required environment variables: + - DDB_TABLE: DynamoDB table name + - AWS_REGION: AWS region (optional, defaults to us-east-1) + + Args: + data: Dictionary containing user_id (int) and other fields. user_id is used as primary key. + """ + table = get_dynamodb_table() + + # Serialize data for DynamoDB (convert datetime objects, etc.) + serialized_data = serialize_for_dynamodb(data) + + # Ensure user_id exists (required as primary key) + if 'user_id' not in serialized_data: + raise ValueError("user_id is required in the request body") + + # Map user_id to pred-id (the table's primary key field name) + # Keep user_id in the data as well for reference + serialized_data['pred-id'] = serialized_data['user_id'] + + # DynamoDB put_item will create a new item if pred-id doesn't exist, + # or update/replace the existing item if pred-id already exists + response = table.put_item(Item=serialized_data) + return response + +## API +class MyFavorites(BaseModel): + items: List[str] + +class PredictionResponse(BaseModel): + user_id: int + req: MyFavorites + prediction: str + +app = fastapi.FastAPI() + +@app.get("/health") +def health_check(): + return {"status": "ok"} + +@app.get("/random") +def get_random(): + """ + Get a random item from the DynamoDB table. + + Returns: + dict: A random item from the table + """ + random_item = get_random_item_from_ddb() + if random_item is None: + raise fastapi.HTTPException(status_code=404, detail="No items found in table") + return random_item + +@app.post("/predict") +def predict(request: PredictionResponse): + # Automatically add current timestamp + data = request.model_dump() + data['timestamp'] = datetime.datetime.now(datetime.timezone.utc) + save_to_ddb(data) + return {"status": "ok"} \ No newline at end of file diff --git a/prediction-table.pem b/prediction-table.pem new file mode 100644 index 0000000..d852dd2 --- /dev/null +++ b/prediction-table.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA0SR5XQ2Ar4KW7f1Th3jG1IJ9oORR/841XhiUSGJhwtLRjuOr +qy9Mu4XXRHypQ0ZK8J7tRdw2pXpE9XWHtjM+hu16G5lYEfc4sQOApRfpgW/PMlUg +KBWceV+MoQB/NmQWmA6U1wMeYvPqGoG/yH6JGKQEC0iKhfSYBIhYpl0zU82XCGM+ +nTM7+jjJSzokJDDgDffqCBrzgAdTvWLmGg52iI5bDMe6Dgd7A1lS/vh3vcteNRgN +4Fx9t7H+JXf0AR5TqzV8VuPYxw3/ScUKOGkVyh+eWSYG5n+BivPnKdu2tWF370Hw +VrpJR2uQbP1lbenf2SfpiEvNGHP/ER1XuW7I8wIDAQABAoIBAQDOoysGNYEf5/cX +zWPqRfqtnQBjJzOdezBfeAmKOyo8Q++pLmk/CczWuramhET4o0sH0v68N4gGl3fq +zeT4sEjnJ1uuSSQrHAh3XO6OL8IWkVI2eMT81d10TmOz77nBE8L/GekVR4+OVVDI +P8otXlg2cFdOjq3PDIvmbpCoTw2XjZAcAkbkr7+kqc9Y126PgvcGS3sLkhUmtykb +xCTbZ4fEfLd1dZCv3xGAUcJ3NSsZhysmkEVel6+vLPch0zb2aRAKf0FLyFgNGflK +52mblsrBfbDYkM3NI1sKkHksYrrwA5rBa6tDhire/TexMIGT7JkubVLaQ1VL/R72 +2ZHCm9aBAoGBAPKWqBukvHvNoB51ksEIdJQzxqcgMuzpnRlkKhaGBws0eY2kDxkp +bfYpSd3Xjd7BUYkYty2jT3GF4s5roxF3XiTodMBj3DCuwfJQ1omYC8qwRLa8FZ8w +YkgcD0wajCJNj6fQ6O/C3pK3ub4b6U3oAFl/qEoeuAL+M/h7g38YojxlAoGBANy0 +dRmUvrg/6mERFqBgbSLjezQDPp8QlAqT/4+goaBqpWxdWMUuS3RoT9eLxk+M4c7b +fr/K0imU8iR4kq8CUWUFXXAPlXaLGZwaZETGEFkcen4DaC4n6I/6AyTYa5LQMu7H +N74I4KdLycT50KY2eA29GidiZ6NULweUrn9C7353AoGBAL58SHK0b4BzXUitn9fN +kOUSpulyoipf4okemuHmyj8lLFFpQqXKX1sM3sDA0tjYSfLyIlxGwUnuDMNzx68e +YSFwGsU7ZJohj497pIqUhqXYtYwbsoq2jmX7CpQCwIjrCGOI6m/iP61LcSFzf0Y6 +Z5PfZsEUz/8hpqN2MTIqoLH1AoGARCCYPQtDTBC+wrPJrjvVtH1P3KBbxjIR4KoK +q0VEXwZMhgTSkBtYQ1invLtyvb+ZPIdYus9azGcjz8pATTGD+pELZLoKwwrxHtSu +uuQAy+EUlq1qjUTYbwkXy1na6vjFoBtyw4BuCHZGlD0hAQ2zRVpoJlwj7bDgy5BD +xRjeYMUCgYAx6ftKAkhWZy1fGLobFnYBEMVIJEfyh3Qp/a2TOjO/Ky5kDBbbLeR3 +Mau0q4/848q14knJeJT2RS9uS/GseYFxp6egLk1FL63mf74NNzgQmK1snw/fqI1U ++t64/J9B52upx2nRrDyue4ruQdXcpEGukqYPW/VZGJ7lmcV6V6DpYw== +-----END RSA PRIVATE KEY----- \ No newline at end of file