From ddcbfbfdd8db7882703aded4347124ab728657a8 Mon Sep 17 00:00:00 2001 From: Viswamedha Nalabotu Date: Wed, 19 Nov 2025 12:55:15 +0000 Subject: [PATCH] Added agents app and compose tweaks --- .gitignore | 6 +- apps/__init__.py | 0 apps/agents/__init__.py | 0 apps/agents/apps.py | 6 ++ apps/agents/langgraph_adapter.py | 29 +++++++ apps/agents/llm.py | 63 +++++++++++++++ apps/agents/models.py | 22 ++++++ apps/agents/service.py | 16 ++++ apps/domains/apps.py | 2 +- apps/users/admin.py | 29 ++++++- apps/users/managers.py | 41 ++++++++++ apps/users/migrations/0001_initial.py | 61 ++++++++++++++ apps/users/models.py | 109 +++++++++++++++++++++++++- apps/users/serializers.py | 20 +++++ apps/users/tests.py | 55 ++++++++++++- apps/users/viewsets.py | 16 ++++ compose/prod/Dockerfile | 3 - compose/prod/docker-compose.yml | 3 + compose/prod/start | 2 + requirements.txt | Bin 2096 -> 4478 bytes 20 files changed, 474 insertions(+), 9 deletions(-) create mode 100644 apps/__init__.py create mode 100644 apps/agents/__init__.py create mode 100644 apps/agents/apps.py create mode 100644 apps/agents/langgraph_adapter.py create mode 100644 apps/agents/llm.py create mode 100644 apps/agents/models.py create mode 100644 apps/agents/service.py create mode 100644 apps/users/managers.py create mode 100644 apps/users/migrations/0001_initial.py create mode 100644 apps/users/serializers.py create mode 100644 apps/users/viewsets.py diff --git a/.gitignore b/.gitignore index 3ec0b26..0ab2fad 100644 --- a/.gitignore +++ b/.gitignore @@ -48,4 +48,8 @@ vite.config.*.timestamp* vitest.config.*.timestamp* .env -static \ No newline at end of file +static +.github +__pycache__/ + +*.sqlite3 \ No newline at end of file diff --git a/apps/__init__.py b/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/agents/__init__.py b/apps/agents/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/agents/apps.py b/apps/agents/apps.py new file mode 100644 index 0000000..72be4bf --- /dev/null +++ b/apps/agents/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AgentsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.agents' \ No newline at end of file diff --git a/apps/agents/langgraph_adapter.py b/apps/agents/langgraph_adapter.py new file mode 100644 index 0000000..1342dd8 --- /dev/null +++ b/apps/agents/langgraph_adapter.py @@ -0,0 +1,29 @@ + +from typing import Any +import logging + +from .llm import get_llm_for_domain + +logger = logging.getLogger(__name__) + + +class SimpleAgent: + """Minimal agent abstraction that calls a local LLM and returns responses.""" + + def __init__(self, name: str, domain: str, system_message: str | None = None): + self.name = name + self.domain = domain + self.system_message = system_message or "You are an assistant." + self._llm = get_llm_for_domain(domain) + + def run(self, prompt: str, **kwargs: Any) -> str: + full_prompt = f"{self.system_message}\n\nUser: {prompt}" + logger.debug("Agent %s running prompt: %s", self.name, prompt) + return self._llm.generate(full_prompt) + + +def build_agents_for_domains(domains: list[str]) -> dict[str, SimpleAgent]: + agents = {} + for d in domains: + agents[d] = SimpleAgent(name=f"agent-{d}", domain=d, system_message=f"You are a tutor for {d}.") + return agents diff --git a/apps/agents/llm.py b/apps/agents/llm.py new file mode 100644 index 0000000..ce1428c --- /dev/null +++ b/apps/agents/llm.py @@ -0,0 +1,63 @@ +"""Lightweight local LLM wrappers. + +This file provides simple wrappers for `llama_cpp` and `transformers` backends. +They are intentionally minimal — adapt to your runtime and model formats. +""" +from typing import Optional +import logging + + +logger = logging.getLogger(__name__) + + +class BaseLLM: + def generate(self, prompt: str) -> str: + raise NotImplementedError() + + +class LlamaCPPWrapper(BaseLLM): + def __init__(self, model_path: str): + try: + from llama_cpp import Llama + + self._llm = Llama(model_path=model_path) + except Exception: + logger.exception("llama_cpp is unavailable or model failed to load") + self._llm = None + + def generate(self, prompt: str) -> str: + if self._llm is None: + raise RuntimeError("Llama model not available") + resp = self._llm(prompt) + return resp.get("text") if isinstance(resp, dict) else str(resp) + + +class TransformersWrapper(BaseLLM): + def __init__(self, model_name_or_path: str): + try: + from transformers import AutoModelForCausalLM, AutoTokenizer + import torch + + self.tokenizer = AutoTokenizer.from_pretrained(model_name_or_path) + self.model = AutoModelForCausalLM.from_pretrained(model_name_or_path, torch_dtype=torch.float16, device_map="auto") + except Exception: + logger.exception("transformers not available or model failed to load") + self.model = None + self.tokenizer = None + + def generate(self, prompt: str) -> str: + if self.model is None or self.tokenizer is None: + raise RuntimeError("Transformers model not available") + inputs = self.tokenizer(prompt, return_tensors="pt") + outputs = self.model.generate(**inputs, max_new_tokens=256) + return self.tokenizer.decode(outputs[0], skip_special_tokens=True) + + +def get_llm_for_domain(domain: str, prefer: str | None = None) -> BaseLLM: + # Basic loader: choose Llama (gguf) if file exists, else fall back to transformers + model_dir = "models" / domain + gguf = model_dir / "model.gguf" + if gguf.exists(): + return LlamaCPPWrapper(str(gguf)) + # fallback: try transformers + return TransformersWrapper(str(model_dir)) diff --git a/apps/agents/models.py b/apps/agents/models.py new file mode 100644 index 0000000..c1067c3 --- /dev/null +++ b/apps/agents/models.py @@ -0,0 +1,22 @@ +from django.db import models +from django.utils import timezone + + +class AgentRun(models.Model): + agent_name = models.CharField(max_length=255) + input_text = models.TextField() + output_text = models.TextField(blank=True, null=True) + created_at = models.DateTimeField(default=timezone.now) + + class Meta: + ordering = ["-created_at"] + + +class ModelMeta(models.Model): + name = models.CharField(max_length=255) + path = models.CharField(max_length=1024, blank=True, null=True) + framework = models.CharField(max_length=100, blank=True, null=True) + created_at = models.DateTimeField(default=timezone.now) + + def __str__(self) -> str: # pragma: no cover - trivial + return self.name diff --git a/apps/agents/service.py b/apps/agents/service.py new file mode 100644 index 0000000..8b6755d --- /dev/null +++ b/apps/agents/service.py @@ -0,0 +1,16 @@ +import logging + +from .models import AgentRun +from .langgraph_adapter import SimpleAgent + +logger = logging.getLogger(__name__) + + +def run_agent(agent: SimpleAgent, prompt: str) -> str: + """Run the agent and store an AgentRun record using the Django ORM.""" + out = agent.run(prompt) + try: + AgentRun.objects.create(agent_name=agent.name, input_text=prompt, output_text=out) + except Exception: + logger.exception("Failed to persist agent run via Django ORM") + return out diff --git a/apps/domains/apps.py b/apps/domains/apps.py index 1eed278..fde2e06 100644 --- a/apps/domains/apps.py +++ b/apps/domains/apps.py @@ -3,4 +3,4 @@ from django.apps import AppConfig class DomainsConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' - name = 'domains' + name = 'apps.domains' diff --git a/apps/users/admin.py b/apps/users/admin.py index 8c38f3f..77afc7d 100644 --- a/apps/users/admin.py +++ b/apps/users/admin.py @@ -1,3 +1,30 @@ from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin +from .models import UserProfile, User -# Register your models here. + +@admin.register(UserProfile) +class UserProfileAdmin(admin.ModelAdmin): + list_display = ('user', 'display_name', 'created_at') + search_fields = ('user__username', 'display_name') + + +@admin.register(User) +class UserAdmin(DjangoUserAdmin): + fieldsets = ( + (None, {'fields': ('username', 'password')}), + ('Personal info', {'fields': ('first_name', 'last_name', 'email_address')}), + ('Permissions', {'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions')}), + ('Important dates', {'fields': ('last_login', 'password_reset_at')}), + ) + + add_fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ('username', 'email_address', 'first_name', 'last_name', 'password1', 'password2'), + }), + ) + + list_display = ('username', 'email_address', 'first_name', 'last_name', 'is_staff') + search_fields = ('username', 'email_address', 'first_name', 'last_name') + ordering = ('username',) diff --git a/apps/users/managers.py b/apps/users/managers.py new file mode 100644 index 0000000..72a1920 --- /dev/null +++ b/apps/users/managers.py @@ -0,0 +1,41 @@ +from typing import TYPE_CHECKING + +from django.contrib.auth.hashers import make_password +from django.contrib.auth.models import BaseUserManager + +if TYPE_CHECKING: + from apps.users.models import User # noqa: F401 + + +class UserManager(BaseUserManager["User"]): + """Custom manager for the User model.""" + + def _create_user(self, email_address: str, password: str | None, **extra_fields): + """ + Create and save a user with the given email and password. + """ + if not email_address: + msg = "The given email must be set" + raise ValueError(msg) + email_address = self.normalize_email(email_address) + user: User = self.model(email_address=email_address, **extra_fields) + user.password = make_password(password) + user.save(using=self._db) + return user + + def create_user(self, email_address: str, password: str | None = None, **extra_fields): # type: ignore[override] + """ + Create and save a regular user with the given email and password. + """ + extra_fields.setdefault("is_staff", False) + return self._create_user(email_address, password, **extra_fields) + + def create_superuser(self, email_address: str, password: str | None = None, **extra_fields): # type: ignore[override] + """ + Create and save a superuser with the given email and password. + """ + extra_fields.setdefault("is_staff", True) + if extra_fields.get("is_staff") is not True: + msg = "Superuser must have is_staff=True." + raise ValueError(msg) + return self._create_user(email_address, password, **extra_fields) diff --git a/apps/users/migrations/0001_initial.py b/apps/users/migrations/0001_initial.py new file mode 100644 index 0000000..6666953 --- /dev/null +++ b/apps/users/migrations/0001_initial.py @@ -0,0 +1,61 @@ +# Generated by Django 5.2.8 on 2025-11-18 23:09 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), + ('id', models.AutoField(primary_key=True, serialize=False)), + ('user_uuid', models.UUIDField(default=uuid.uuid4, editable=False, verbose_name='User UUID')), + ('email_address', models.EmailField(max_length=255, unique=True, verbose_name='Email Address')), + ('username', models.CharField(max_length=25, unique=True, verbose_name='Username')), + ('first_name', models.CharField(max_length=255, verbose_name='First Name')), + ('last_name', models.CharField(max_length=255, verbose_name='Last Name')), + ('date_of_birth', models.DateField(blank=True, null=True, verbose_name='Date of Birth')), + ('is_active', models.BooleanField(default=True, verbose_name='Account Active')), + ('is_staff', models.BooleanField(default=False, verbose_name='Account Admin')), + ('is_verified', models.BooleanField(default=False, verbose_name='Account Verified')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'User', + 'verbose_name_plural': 'Users', + }, + ), + migrations.CreateModel( + name='UserProfile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('display_name', models.CharField(blank=True, max_length=150)), + ('bio', models.TextField(blank=True)), + ('timezone', models.CharField(blank=True, max_length=64)), + ('avatar_url', models.URLField(blank=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddConstraint( + model_name='user', + constraint=models.UniqueConstraint(fields=('username',), name='unique_username'), + ), + ] diff --git a/apps/users/models.py b/apps/users/models.py index 71a8362..7240d0a 100644 --- a/apps/users/models.py +++ b/apps/users/models.py @@ -1,3 +1,108 @@ -from django.db import models +from typing import ClassVar +from uuid import uuid4 -# Create your models here. +from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin +from django.db.models import ( + AutoField, + BooleanField, + CASCADE, + CharField, + DateField, + DateTimeField, + EmailField, + UUIDField, + Model, + OneToOneField, + TextField, + UniqueConstraint, + URLField, +) +from django.conf import settings +from django.utils.translation import gettext_lazy as _ + +from apps.users.managers import UserManager + + +class TimeStampMixin(Model): + """Abstract model that provides created_at and updated_at fields.""" + + created_at = DateTimeField(verbose_name="Created At", auto_now_add=True) + updated_at = DateTimeField(verbose_name="Updated At", auto_now=True) + + class Meta: + abstract = True + + +class User(AbstractBaseUser, TimeStampMixin, PermissionsMixin): + """Default custom user model for viswamedha.com (adapted). + + Uses username as the USERNAME_FIELD and maintains an email_address + field used as the primary contact address. + """ + + id = AutoField(primary_key=True) + user_uuid = UUIDField(verbose_name=_("User UUID"), default=uuid4, editable=False) + + # Required fields + email_address = EmailField(verbose_name=_("Email Address"), max_length=255, unique=True) + username = CharField(verbose_name=_("Username"), max_length=25, unique=True) + first_name = CharField(verbose_name=_("First Name"), max_length=255) + last_name = CharField(verbose_name=_("Last Name"), max_length=255) + date_of_birth = DateField(verbose_name=_("Date of Birth"), null=True, blank=True) + + is_active = BooleanField(verbose_name=_("Account Active"), default=True) + is_staff = BooleanField(verbose_name=_("Account Admin"), default=False) + is_verified = BooleanField(verbose_name=_("Account Verified"), default=False) + + + USERNAME_FIELD = 'username' + EMAIL_FIELD = 'email_address' + REQUIRED_FIELDS = ['email_address', 'first_name', 'last_name', 'date_of_birth'] + + objects: ClassVar[UserManager] = UserManager() + + def has_perm(self, perm, obj=None): + return True + + def has_module_perms(self, app_label): + return True + + class Meta: + verbose_name = _('User') + verbose_name_plural = _('Users') + constraints = [ + UniqueConstraint(fields=['username'], name='unique_username'), + ] + + @property + def full_name(self): + return f"{self.first_name} {self.last_name}" + + @property + def is_student(self): + return hasattr(self, 'student') + + def __str__(self): + return self.full_name + + def save(self, *args, **kwargs): + if self.username: + self.username = self.username.lower() + super().save(*args, **kwargs) + + +class UserProfile(Model): + """A lightweight profile attached to the project's `AUTH_USER_MODEL`.""" + + user = OneToOneField( + settings.AUTH_USER_MODEL, on_delete=CASCADE, related_name='profile' + ) + display_name = CharField(max_length=150, blank=True) + bio = TextField(blank=True) + timezone = CharField(max_length=64, blank=True) + avatar_url = URLField(blank=True) + created_at = DateTimeField(auto_now_add=True) + updated_at = DateTimeField(auto_now=True) + + def __str__(self) -> str: # pragma: no cover - trivial + return self.display_name or getattr(self.user, 'username', str(self.user)) diff --git a/apps/users/serializers.py b/apps/users/serializers.py new file mode 100644 index 0000000..62c0da0 --- /dev/null +++ b/apps/users/serializers.py @@ -0,0 +1,20 @@ +from django.contrib.auth import get_user_model +from rest_framework import serializers + +from apps.users.models import UserProfile + +User = get_user_model() + + +class UserProfileSerializer(serializers.ModelSerializer): + class Meta: + model = UserProfile + fields = ['display_name', 'bio', 'timezone', 'avatar_url'] + + +class UserSerializer(serializers.ModelSerializer): + profile = UserProfileSerializer(read_only=True) + + class Meta: + model = User + fields = ['id', 'user_uuid', 'username', 'email_address', 'first_name', 'last_name', 'profile'] diff --git a/apps/users/tests.py b/apps/users/tests.py index 7ce503c..fa53abc 100644 --- a/apps/users/tests.py +++ b/apps/users/tests.py @@ -1,3 +1,56 @@ from django.test import TestCase +from django.contrib.auth import get_user_model +from django.urls import reverse +from rest_framework.test import APIClient +from rest_framework import status + + +User = get_user_model() + + +class UserModelTests(TestCase): + def setUp(self): + self.user_data = { + 'email_address': 'Test@Example.com', + 'username': 'TestUser', + 'first_name': 'Test', + 'last_name': 'User', + 'date_of_birth': '1990-01-01', + } + + def test_create_user_and_properties(self): + user = User.objects.create_user(password='pass1234', **self.user_data) + self.assertIsNotNone(user.pk) + # email should be normalized by the manager + self.assertEqual(user.email_address.lower(), 'test@example.com') + # username should be saved lowercase by model.save() + self.assertEqual(user.username, 'testuser') + # full_name property + self.assertEqual(user.full_name, 'Test User') + + def test_create_superuser(self): + su = User.objects.create_superuser(password='adminpass', **self.user_data) + self.assertTrue(su.is_staff) + self.assertTrue(su.pk) + + +class UserAPITests(TestCase): + def setUp(self): + self.client = APIClient() + self.user = User.objects.create_user( + password='pass1234', + email_address='apiuser@example.com', + username='apiuser', + first_name='API', + last_name='User', + date_of_birth='1995-05-05', + ) + + def test_list_users(self): + url = '/api/users/' + resp = self.client.get(url) + self.assertEqual(resp.status_code, status.HTTP_200_OK) + # Should contain at least the created user + usernames = [u.get('username') for u in resp.json()] + self.assertIn(self.user.username, usernames) -# Create your tests here. diff --git a/apps/users/viewsets.py b/apps/users/viewsets.py new file mode 100644 index 0000000..84efd9a --- /dev/null +++ b/apps/users/viewsets.py @@ -0,0 +1,16 @@ +from rest_framework import viewsets +from rest_framework import permissions + +from django.contrib.auth import get_user_model + +from .serializers import UserSerializer + +User = get_user_model() + + +class UserViewSet(viewsets.ReadOnlyModelViewSet): + """Read-only viewset exposing users for the POC.""" + + queryset = User.objects.all() + serializer_class = UserSerializer + permission_classes = [permissions.AllowAny] diff --git a/compose/prod/Dockerfile b/compose/prod/Dockerfile index fc7021c..148508b 100644 --- a/compose/prod/Dockerfile +++ b/compose/prod/Dockerfile @@ -31,9 +31,6 @@ RUN apt-get update && apt-get install --no-install-recommends -y \ COPY . . -COPY package*.json ./ -RUN npm ci --omit=dev - COPY --from=node /app/build ./build COPY ./compose/prod/start /start diff --git a/compose/prod/docker-compose.yml b/compose/prod/docker-compose.yml index c85a0dc..99c133a 100644 --- a/compose/prod/docker-compose.yml +++ b/compose/prod/docker-compose.yml @@ -7,11 +7,14 @@ services: deploy: mode: replicated replicas: ${REPLICAS} + env_file: + - ../../.env labels: - "traefik.enable=true" - "traefik.http.routers.fyp-web.rule=Host(`${DOMAIN}`)" - "traefik.http.routers.fyp-web.entrypoints=${ENTRYPOINT}" - "traefik.http.routers.fyp-web.tls.certresolver=${CERTRESOLVER}" + - "traefik.http.services.fyp-web.loadbalancer.server.port=${PORT}" - "com.centurylinklabs.watchtower.enable=true" networks: - proxy diff --git a/compose/prod/start b/compose/prod/start index 24964c4..d08c8ce 100644 --- a/compose/prod/start +++ b/compose/prod/start @@ -4,5 +4,7 @@ set -o errexit set -o pipefail set -o nounset +python manage.py makemigrations +python manage.py migrate python manage.py collectstatic --noinput exec /usr/local/bin/gunicorn config.wsgi --bind 0.0.0.0:8000 diff --git a/requirements.txt b/requirements.txt index f16127e8eae8dc05551d756c72604e7378d45fcd..f48be2402c00b3e1a78c20dcef7146aca1173d4a 100644 GIT binary patch literal 4478 zcmZ{oL31KU42Aogs{9mO3)t(;A&0HXF{w%_r<^DZ81Mqa&VX(FHK;I`>T=ZT)N%#Ij|CBvr3p>MXF|2j+ zwN9;0KUSEqQ8Yl@q;JyM^*+E{>!8t|qrQ{0Ab!xfX*x<{Eg#36okrh#=@d-pI|~o8 zM0^|RNBm^ccxJLSQ{ERDQ*+KWc}B~*o=M*J9&Kf$^P>c^VMczJvx%i?MvlEf&TN~u z`VH@I*$Nnl7lgO{DWo%H1lbq;?tD-`@E`91c zlZC0W*~xz=R8RVBge`0l>wSM>uKJm6zvx%q&eL3HAC%=~W+x}%5m<-kvx|%tq|~>~ z_Q_Dm<8SgkmFK18*a+#D-g}Y4piXuk`^XpRCEIk4$j`9qg}IQCzxc-fMCV~jE6FN* zGuo)>BE3pJ(@B^FCJ=$O;C+;2BdzF&h#L*(1$<4e>@L?B%%O!pYk{Yc2W$<5X|2zt z{@&^Be67>7{-#bKIqDlZmZ)~A!?mS-wgpSbSc$-~!l3ID#Or(z#cjp4T9Y}nRt5*T zrtk~K4D_qJsp(k)3+{8qB2sV=nezrBs%2ls)5^+B*3AK1fP=+C$OoAN@acHruhu@w z7u`b!%;`}dQ&?PyE5max%^Uk#2^R?IrqXjO;iGHJHDq9n6P~wq#x12v#J3V!Ci3gq z3>$B*B#iT#5tK^&r%fTwVGnrZl$w2E&sib0LY?hMB>h$1#1#@j+QO7dboD%cnh` z_CiNzvX?wfT%)MPlUU(6kWXZbIckh}rU%GbSiRORJ;NL#1I}P{p{F?ru;%RDXl8-& zM;$sP<}7@y&zL*!pYilIify1f_eo`TllNN4Rcu1h+tX-MihL8hwN(_7-;`SLQoML-z=)7el!r!{gs1g01d+KL(dKT-WRFQ{hVyB*TA$+B%FgTmBS|daDIL>TQL^*n@+Nn#ot^605$S-6+r8iwBZ;FA#gZqDg>5)(%1Ay`aMhCb1^SL@us|f&$7|gHm7#qg~?r9GBeKh9qY9Y zQ=L2MMz?zI2$xgSokl14enIWZeq@&TcH{0XlLvnARK~rz-0t9^c|zAR;XRGWHx^); zJtAjIBZrHnXZ?imclt~D`6|5L7EHRRnkTf`(Z-}$ZT6*4` zr;?sjQTg)xYSrA*-pt_mBsZ`$-&4NRjl5kv-I!k7cq_#GVkj>-jC|EOnzl)uXT$vH6UJc z$hOU;{d|*l!aQibig6jP{7vo{l?=m$b+qU}-PC~2?Zol3rL#7QbA2>@4(>}?qhl($ z8ZkLH8C6MLP0A`H)gQ_r*&-*rYI1pxJ6>0)3vL8Q!tb|BLIqvja}?#S^g811JUd%? zjNPHtwtnHV39v85P#f(^~~p=)r4o!I;7sD8d8gB7hz(5Cw5tq5XPQ!79-9 zrJVUST?pnr(-r45kA9tIWm7yFKtV7g7n&j-K-^Gx6yTYn02hnJE1 zrw4cZK@f>H2*C53q;9E--spsqbIWXjg-t>F++~C6za15HH(WhCeh!u|gDjAOHkqE# z&(o_o0$ECGpPg~mWE~smaqTt(8jt}`W=Qq|wHo)CImR_K%j6B!f2uU-Z!_u?$h?@O zM?D}RM2WiR)vrf%gyo_g7Yq?>gjF$BGOqT{Rr1a*Gc^m5V_Gg0ag#3Doa0p3hydh0 z4LKEUZnGUCXx~8bTj90eR9IR-c6J|GVH~nIGM%w{M$N(Yb$%E0jD3pZV!7F;Blp-< z-{-mQ1T^RKf-4h3B#N-|rf90;OtR~=V5@>$RF99?9e0LrgkOR>st<1JvdH`@qs^+z zud{WW-ME;twz*>cI!c>JIt-WUemE*@Q7$I5ffG*jadLdDl)l9ve%}R@uTd>USQWOY zD%F7ifZ4m47=x;-MhCb)2o)jyz5e^-uU~}?Xk-1F-x00IHmLUnNaiXw9r8UK6(@L7 z58^cyB9hev idvUv6WP~I)SXPZ#Wk8dtm)!qCS9X+PWKMir8}T0)(TOAg