From 4ab8877e75d5e852b9620b632495016ea26f7dd3 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Sun, 4 Oct 2026 18:48:31 -0400 Subject: [PATCH 1/5] Fix upload contention and add guided filing code search --- compose.yml | 11 + docs/docs/admin/deployment.md | 51 + .../partners-courts/jurisdiction-config.md | 206 +++ efile_app/efile/api/dropdown_views.py | 6 +- efile_app/efile/api/filing_code_search.py | 110 ++ efile_app/efile/api/urls.py | 2 + efile_app/efile/data/case_type_guidance.yaml | 840 ++++++++++++ efile_app/efile/data/filing_code_facets.yaml | 17 + efile_app/efile/data/filing_code_search.yaml | 77 ++ .../data/filing_concept_definitions.yaml | 113 ++ .../commands/process_document_extractions.py | 18 +- .../commands/refresh_filing_code_index.py | 71 + .../migrations/0033_filing_code_search.py | 56 + .../0034_sqlite_filing_search_index.py | 51 + efile_app/efile/models.py | 26 + .../efile/services/case_type_guidance.py | 263 ++++ .../efile/services/filing_code_search.py | 968 ++++++++++++++ efile_app/efile/services/filing_path.py | 47 +- efile_app/efile/settings_dev.py | 6 + .../css/components/filing-code-search.css | 289 +++++ efile_app/efile/static/js/court-selector.js | 19 +- .../efile/static/js/extraction-review.js | 58 +- .../efile/static/js/filing-code-search.js | 616 +++++++++ .../efile/components/filing_code_search.html | 113 ++ .../templates/efile/extraction_review.html | 6 + .../efile/tests/test_case_type_guidance.py | 165 +++ .../efile/tests/test_filing_code_search.py | 855 +++++++++++++ .../efile/tests/test_sqlite_transactions.py | 82 ++ efile_app/efile/views/extraction_review.py | 30 +- efile_app/pyproject.toml | 7 +- efile_app/pytest.ini | 2 +- efile_app/tests/confirm-case-editing.spec.js | 1140 +++++++++++++++++ efile_app/uv.lock | 11 + fly.toml | 1 + testing/README.md | 37 + 35 files changed, 6357 insertions(+), 13 deletions(-) create mode 100644 efile_app/efile/api/filing_code_search.py create mode 100644 efile_app/efile/data/case_type_guidance.yaml create mode 100644 efile_app/efile/data/filing_code_facets.yaml create mode 100644 efile_app/efile/data/filing_code_search.yaml create mode 100644 efile_app/efile/data/filing_concept_definitions.yaml create mode 100644 efile_app/efile/management/commands/refresh_filing_code_index.py create mode 100644 efile_app/efile/migrations/0033_filing_code_search.py create mode 100644 efile_app/efile/migrations/0034_sqlite_filing_search_index.py create mode 100644 efile_app/efile/services/case_type_guidance.py create mode 100644 efile_app/efile/services/filing_code_search.py create mode 100644 efile_app/efile/static/css/components/filing-code-search.css create mode 100644 efile_app/efile/static/js/filing-code-search.js create mode 100644 efile_app/efile/templates/efile/components/filing_code_search.html create mode 100644 efile_app/efile/tests/test_case_type_guidance.py create mode 100644 efile_app/efile/tests/test_filing_code_search.py create mode 100644 efile_app/efile/tests/test_sqlite_transactions.py diff --git a/compose.yml b/compose.yml index 743cbd3f..6a4aa13e 100644 --- a/compose.yml +++ b/compose.yml @@ -87,6 +87,17 @@ services: volumes: *litefile-volumes + code_index_worker: + image: litefile:web + command: uv run python manage.py refresh_filing_code_index --interval 86400 --verbosity 2 --cache-dir /data/filing-code-cache + depends_on: + web: + condition: service_healthy + environment: + <<: *litefile-environment + volumes: + *litefile-volumes + volumes: localstack: driver: local diff --git a/docs/docs/admin/deployment.md b/docs/docs/admin/deployment.md index 359fb550..51dfba1b 100644 --- a/docs/docs/admin/deployment.md +++ b/docs/docs/admin/deployment.md @@ -69,6 +69,7 @@ primary_region = 'lax' [processes] app = "uv run gunicorn efile.asgi:application -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000 --workers 2 --timeout 60" extraction_worker = "uv run python manage.py process_document_extractions" + code_index_worker = "uv run python manage.py refresh_filing_code_index --interval 86400" [http_service] internal_port = 8000 @@ -90,6 +91,56 @@ Document analysis runs outside the web request in the `extraction_worker` proces By default, LITEFile sends only the first 20 PDF pages for analysis. Set `DOCUMENT_EXTRACTION_MAX_PAGES` to a positive integer to change that cap. DOCX text is limited to the first 100,000 characters with `DOCUMENT_EXTRACTION_MAX_TEXT_CHARS`; Word files have no reliable page boundaries. Review identifies when either limit omitted part of a document. `DOCUMENT_EXTRACTION_MAX_ATTEMPTS` controls how many times a failed job is tried before the filer is sent to manual review. +### Filing code search index + +The `code_index_worker` builds search indexes for Illinois, Massachusetts, and +Vermont at startup, then refreshes them daily. Keep one Machine running in this +process group. It uses the same database and `EFSP_URL` as the web process. +Database migrations create a PostgreSQL GIN full-text index or a SQLite FTS5 +inverted index automatically; pgvector and model downloads are not required. +Both use the same stemmed words, thesaurus, and whole-word matching. SQLite +looks up matching path IDs, jurisdiction, and new/existing-case status in FTS5 +before ranking and grouping results, rather than scanning every filing path. +SQLite triggers keep the postings synchronized with catalog inserts, updates, +deletes, and transaction rollbacks. An existing SQLite installation's first FTS5 +migration indexes its saved catalog locally; it does not fetch court data again. + +To refresh once, run these commands from `efile_app/` in the target environment: + +```bash +uv run python manage.py migrate --noinput +uv run python manage.py refresh_filing_code_index +# Or refresh just one state's complete catalog: +uv run python manage.py refresh_filing_code_index --jurisdiction vermont +``` + +Refreshes traverse all filing courts and their category, case-type, and filing-type +lists, including both new and existing filings. This can take time; it runs outside +web requests and the release command. Entries are staged in compressed disk storage +and inserted in batches. Each jurisdiction is replaced in one transaction only +after its complete catalog was fetched. A failed request or empty catalog preserves +the previous snapshot; the command reports failures and exits unsuccessfully in +one-shot mode. Temporary HTTP failures are retried before abandoning a refresh. +The recurring worker retries failed states after one minute (`--retry-interval`) +and skips fresh indexes, including after a restart. Monitor worker logs for +refresh failures. Complete court checkpoints are reused for up to six hours after +a failed build. `--cache-dir` controls their location; Compose uses +`/data/filing-code-cache` in the shared volume so a container replacement retains +progress. Checkpoints are separated by jurisdiction, source service, and rules +revision. Incomplete courts are never reused or published. + +Local Docker Compose also starts `code_index_worker` after the web container is +healthy, using the shared database volume. For an already-running local stack, +run `docker compose up code_index_worker` to start it and watch progress. + +Before the first successful refresh, the modal explains that search is unavailable +and the usual court lists remain available. The dialog does not trigger indexing, +but retries an unavailable search automatically while it stays open. +Results older than two days carry an out-of-date notice. Every selected path is checked against the live court +lists before it updates the form. A changed `EFSP_URL` or thesaurus revision requires +a fresh index, so test-server codes and incompatible search terms cannot leak into +another environment. Restart the web and index workers after changing search rules. + ### Setting Fly.io production secrets: ```bash fly secrets set \ diff --git a/docs/docs/partners-courts/jurisdiction-config.md b/docs/docs/partners-courts/jurisdiction-config.md index 4fbda336..c409abf1 100644 --- a/docs/docs/partners-courts/jurisdiction-config.md +++ b/docs/docs/partners-courts/jurisdiction-config.md @@ -405,3 +405,209 @@ See [Control filing availability](./filing-availability.md) for the complete field reference, Cook County hearing-scheduling example, county-prefix rules, message precedence, deployment and re-enabling instructions, troubleshooting, and live-check API. + +## Filing code search terms and explanations + +The “Find the right filing code” modal on Confirm case searches complete paths +across court, case category, case type, and filing type. A selected court does not +limit results. New and existing cases have separate filing-type lists. Search uses +English Snowball stemming, phrase synonyms, and conservative spelling suggestions; +it does not call an LLM or decide which court has jurisdiction over a person's case. + +An empty search offers common filing-type starting points. Results group repeated +filing labels and recognized synonyms across courts, so the same filing does not +occupy a separate result for every county. Users expand a group, choose a court, +then choose an exact case-type and filing-code path. A court already selected on +the form is offered first. Grouping does not substitute one court's code for +another's, and distinct documents such as complaints and answers remain separate. +Each group also shows a compact case-category/type context, so a generic name such +as “Petition” has meaning before a court is chosen. Repeated category/type names +are collapsed (“Small claims › Small claims” becomes “Small claims”). Groups used +in multiple case types show a count and an expandable preview of up to eight; +the exact court-specific paths remain available on selection. Each case-type entry +is clickable: choosing it narrows both the court list and the exact paths to that +category/type pair. “Show all case types” clears that choice. Contexts reflect +the paths matching the current search, not unrelated uses of the same filing name. + +The compact search filters separate the task (starting a claim, responding, or a +request from either side), case stage (new or existing), and document kind (main +document or attachment/exhibit). Starting a claim is not a determination of the +filer's party role: for example, an existing case can include a counterclaim. +`efile_app/efile/data/filing_code_facets.yaml` contains ordered filing-name rules; +the first match supplies the classification. Rules may limit `jurisdictions`. +Unmatched or ambiguous names remain unclassified and are available under the +unfiltered or “Not classified” choices. Filters apply before pagination and carry +through court and exact-path selection. These labels do not change which documents +the court permits as attachments. Choosing a different case stage changes the form +only after the filer applies a validated path. + +Shared rules live in `efile_app/efile/data/filing_code_search.yaml`. The +`jurisdictions` list currently includes Illinois, Massachusetts, and Vermont. +Concepts group search terms, such as eviction, unlawful detainer, and summary +process. A concept can include its own `jurisdictions` list to limit its scope. +These are search associations, not statements that remedies or procedures are +legally interchangeable. Multiword phrases match together: “summary judgment” +does not expand to “summary process.” + +When the displayed name does not contain the search phrase, “Why is this result +shown?” explains a related concept, an alternate name, a spelling correction, or +associated case/court information. Concept definitions are expandable, keeping +multiple results visible. They describe the topic, not the purpose of every document +returned for that topic. A search for eviction can also find an affidavit within an +eviction case. + +Display definitions live separately in +`efile_app/efile/data/filing_concept_definitions.yaml`, keyed by the same normalized +concept IDs as the search rules. The precedence is general default, state, county, +then exact court. Before a court is chosen, results show the general or statewide +meaning. Selected-court paths use the local meaning and identify its scope. + +For example, the following illustrates where **court-reviewed local wording** +belongs; the placeholder text is not a statement of Cook County procedure: + +```yaml +version: 1 +county_courts: + illinois: + Cook: + names: [Cook County] + prefixes: ["Cook County - "] +concepts: + eviction: + label: Eviction + default: + text: A court process about recovering possession of property. + jurisdictions: + illinois: + text: "" + source: "https://example.org/state-court-source" + counties: + Cook: + text: "" + source: "https://example.org/county-court-source" + courts: + Cook County - Municipal Civil - District 1 - Chicago: + text: "" + source: "https://example.org/court-specific-source" +``` + +County membership uses explicitly configured court names or prefixes, matched +without regard to capitalization. Use a separating boundary in prefixes, as above; +do not assume a court's county from a substring. Exact court overrides take priority +over county overrides. A replacement `text` uses its own `source`; it never inherits +a potentially unrelated source link. An empty `text` suppresses the inherited +definition. `label` can also be overridden. Unconfigured localities use the state +definition, then the general fallback. + +```yaml +concepts: + eviction: + terms: [eviction, evict, unlawful detainer, summary process] + +explanations: + - jurisdictions: [massachusetts] + filing_type: [Summary Process Complaint, Complaint for Summary Process] + text: A complaint asking the court to decide an eviction case. + source: https://www.mass.gov/how-to/file-an-eviction-case +``` + +Explanation rules match whole filing-type names after normalizing capitalization, +accents, and punctuation. They can also specify lists of exact `court`, +`case_category`, and `case_type` names, plus an `initial` boolean. All specified +conditions must match; the first matching rule supplies the explanation and its +source link. Put specific rules before general ones. Use court sources and short, +descriptive text rather than eligibility advice. Avoid matching numerical codes, +which can change between courts or environments. + +Unknown or renamed codes remain searchable without an explanation. The modal says +when no explanation is available and shows the official name and full path. +Live selection validation regenerates the explanation using the current names. +Filing availability restrictions remain visible and prevent choosing blocked paths. + +For changes to indexed terms or filing-code explanation rules in +`filing_code_search.yaml`, deploy with the application, restart workers, and refresh the index +using the [deployment instructions](../admin/deployment.md#filing-code-search-index). +Changes to concept definitions or facet classifications need only a web-process +restart; they are applied to responses outside the cached search results and do +not invalidate or rebuild the filing catalog. +Add search and explanation examples to +`efile_app/efile/tests/test_filing_code_search.py` when changing matching rules. + +### ZIP filters in filing code search + +Illinois search offers an optional case-location ZIP filter using the bundled +`efile/utils/zip_to_county_il.py` county table. Filtering happens before grouping, +so case-type previews, courts, and exact paths all use the same county scope. +Every county listed for a ZIP is retained. The UI displays matched counties; +clearing the ZIP restores statewide results. Unknown ZIPs produce a recoverable +message rather than silently showing statewide results. ZIP matching is a location +aid, not a determination of which court has jurisdiction. Other jurisdictions do +not display this filter until an appropriate local mapping is supported. + +### Context-specific case guidance + +`efile/data/case_type_guidance.yaml` defines 20 starter case families with +plain-language descriptions, source links, and contextual facets. This is a +coverage set, not a statistical ranking of filing volume. Definitions are +matched by names, independently of environment-specific numeric IDs. + +| Case family | Contextual choices | +| --- | --- | +| Eviction | Tenant/landlord, home/business, possession/money | +| Foreclosure | Owner/lender, property, mortgage/other lien | +| Debt collection | Collecting/responding, debt or collection type | +| Small claims | Claimant/responding, dispute type, claim amount | +| Contracts | Claimant/responding, services/goods/insurance | +| Personal injury | Claimant/responding, vehicle/medical/property/death | +| Property damage | Claimant/responding, kind of property | +| Replevin and detinue | Requesting/responding, recovery procedure | +| Divorce and separation | Requesting/responding, children, kind of request | +| Parentage | Requesting/responding, establishing/challenging, order action | +| Parenting and custody | Requesting/responding, decisions/time/moving, order action | +| Child support | Requesting/responding, order action, interstate order | +| Protection orders | Requesting/responding, kind of protection, duration | +| Adult guardianship | Requesting/objecting, personal/financial authority, duration | +| Child guardianship | Requesting/objecting, personal/financial authority, duration | +| Estates and probate | Requesting/objecting, administration/will dispute/claim | +| Name changes | Requesting/objecting, adult/child | +| Adoption | Requesting/objecting, adult/child, relationship | +| Agency review | Challenging/responding, kind of agency decision | +| Employment | Worker/responding, wages/discrimination/dismissal | + +The configuration supports Illinois, Massachusetts, and Vermont. General starter +meanings do not establish eligibility or replace local court requirements. State +entries under a topic's `jurisdictions` can replace descriptions and facet rules. +County entries can provide `court_names` or `court_prefixes` and override `text` +and `source`; exact court entries under `courts` take precedence. Local wording +can differ without changing the state-level discovery classification. Detailed +rules should use explicit terms; absent information stays `unknown`. + +Each facet defines a key, label, and options. An option's regular expression +matches the case label, filing name, or both, according to its `field`. +Dimensions with no recognized values are hidden. Unknown classifications remain +included when filtering, with an explanation in the interface. Role choices +narrow explicit document names and keep shared/unclassified documents; they do +not decide a filer's legal party status. Changing the query or case area resets +its contextual choices. Filtering applies before pagination and remains in +effect when listing courts and exact paths. + +Small claims takes precedence over substantive labels within that track, such +as a contract claim in a small-claims category. The amount dropdown appears when +remaining labels contain different recognizable claim brackets. It parses +amounts in integer cents, including dollar signs, commas, decimal amounts, and +`K` suffixes. It never reads internal code IDs or filing fees. Labels without +recognizable brackets stay visible. A selected amount remains visible and can +be cleared even if another filter removes its matching labeled paths. + +Amount brackets describe catalog choices, not legal eligibility. State-specific +help links explain the usual limits and exceptions: Illinois generally uses +$10,000 excluding interest and costs; Massachusetts generally uses $7,000 with +an automobile property-damage exception; Vermont generally uses $10,000 but +limits consumer-credit and medical-debt claims to $5,000. Brackets crossing the +usual limit are marked for checking. Sources are stored alongside the wording +in the YAML, including the [Illinois court rules](https://www.illinoiscourts.gov/rules/supreme-court-rules/), +[Massachusetts small claims guidance](https://www.mass.gov/info-details/small-claims-court), +and [Vermont small claims guidance](https://www.vtcourts.gov/civil/suing-and-being-sued-small-claims). + +The guidance configuration is cached per web process. Restart the web service +after editing it; no filing catalog rebuild or database migration is required. diff --git a/efile_app/efile/api/dropdown_views.py b/efile_app/efile/api/dropdown_views.py index b1e13509..d2c25d6b 100644 --- a/efile_app/efile/api/dropdown_views.py +++ b/efile_app/efile/api/dropdown_views.py @@ -119,7 +119,8 @@ def get_case_categories(request): # Make API call to external categories endpoint api_url = f"{settings.EFSP_URL}/jurisdictions/{jurisdiction}/codes/courts/{court_code}/categories" - params = {"fileable_only": True, "timing": "Initial"} + timing = "Subsequent" if request.GET.get("existing_case") == "yes" else "Initial" + params = {"fileable_only": True, "timing": timing} # Make the API request with auth tokens if available headers = {} @@ -169,7 +170,8 @@ def get_case_types(request): # Make API call to external case types endpoint path = f"/jurisdictions/{jurisdiction}/codes/courts/{court_code}/case_types/" api_url = f"{settings.EFSP_URL}{path}" - params = {"category_id": category_id, "timing": "Initial"} + timing = "Subsequent" if request.GET.get("existing_case") == "yes" else "Initial" + params = {"category_id": category_id, "timing": timing} # Make the API request with auth tokens if available headers = {} diff --git a/efile_app/efile/api/filing_code_search.py b/efile_app/efile/api/filing_code_search.py new file mode 100644 index 00000000..7108d5fb --- /dev/null +++ b/efile_app/efile/api/filing_code_search.py @@ -0,0 +1,110 @@ +"""Read-only search and live validation; applying a result edits the form only.""" + +import json +import logging +from datetime import timedelta + +import requests +from django.http import JsonResponse +from django.utils import timezone +from django.views.decorators.http import require_http_methods + +from efile.services.filing_code_search import current_index, rules, search_grouped_paths, search_paths, validate_path +from efile.utils.jurisdiction_stuff import has_jurisdiction_login + +logger = logging.getLogger(__name__) + + +@require_http_methods(["GET"]) +def filing_code_search(request): + jurisdiction = request.GET.get("jurisdiction", "").lower() + if jurisdiction not in rules()[0]["jurisdictions"]: + return JsonResponse({"error": "Choose a supported jurisdiction."}, status=400) + if not has_jurisdiction_login(request, jurisdiction): + return JsonResponse({"error": "Sign in to this jurisdiction to search filing codes."}, status=403) + initial = request.GET.get("existing_case", "no") != "yes" + index = current_index(jurisdiction) + if index is None: + return JsonResponse({"error": "Code search is not available yet. Use the court lists to continue."}, status=503) + if request.GET.get("path_id"): + try: + path_id = int(request.GET["path_id"]) + except ValueError: + return JsonResponse({"error": "Invalid filing path."}, status=400) + path = index.paths.filter(pk=path_id, initial=initial).select_related("index").first() + if path is None: + return JsonResponse({"error": "The code list has changed. Search again."}, status=409) + try: + return JsonResponse({"path": validate_path(path)}) + except ValueError as error: + return JsonResponse({"error": str(error)}, status=409) + except requests.RequestException: + logger.warning("Could not revalidate filing search path %s", path.pk) + return JsonResponse({"error": "The court lists could not be checked. Try again."}, status=503) + query = request.GET.get("q", "").strip() + purpose = request.GET.get("purpose", "") + document = request.GET.get("document", "") + if purpose not in ("", "starting", "responding", "either", "unknown") or document not in ( + "", + "main", + "attachment", + "unknown", + ): + return JsonResponse({"error": "Choose a valid search filter."}, status=400) + role = request.GET.get("role", "") + property_kind = request.GET.get("property", "") + relief = request.GET.get("relief", "") + if ( + role not in ("", "tenant", "landlord") + or property_kind not in ("", "residential", "commercial") + or relief not in ("", "possession", "money") + ): + return JsonResponse({"error": "Choose a valid case-type filter."}, status=400) + try: + case_filters = json.loads(request.GET.get("case_filters", "{}")) + if not isinstance(case_filters, dict): + raise ValueError + except (ValueError, TypeError): + return JsonResponse({"error": "Choose valid case-type filters."}, status=400) + try: + offset = int(request.GET.get("offset", "0")) + except ValueError: + offset = -1 + if not 0 <= offset <= 10000 or not 1 <= len(query) <= 160: + return JsonResponse( + {"error": "Enter a search of up to 160 characters and use a valid results page."}, status=400 + ) + try: + if request.GET.get("grouped") == "true": + result = search_grouped_paths( + index, + query, + initial=initial, + offset=offset, + group_key=request.GET.get("group", ""), + court=request.GET.get("court", ""), + purpose=purpose, + document=document, + context=request.GET.get("context", ""), + postal_code=request.GET.get("zip", "").strip(), + role=role, + property_kind=property_kind, + relief=relief, + case_topic=request.GET.get("case_topic", ""), + case_filters=case_filters, + ) + else: + if purpose or document: + return JsonResponse({"error": "Use grouped search with filing-type filters."}, status=400) + result = search_paths(index, query, initial=initial, offset=offset) + except ValueError as error: + return JsonResponse({"error": str(error)}, status=409) + result.update( + { + "refreshed_at": index.refreshed_at.isoformat(), + "stale": index.refreshed_at < timezone.now() - timedelta(days=2), + } + ) + response = JsonResponse(result) + response["Cache-Control"] = "no-store" + return response diff --git a/efile_app/efile/api/urls.py b/efile_app/efile/api/urls.py index 0d373b35..ebc4c6ad 100644 --- a/efile_app/efile/api/urls.py +++ b/efile_app/efile/api/urls.py @@ -27,6 +27,7 @@ get_party_types, ) from .filing_availability import get_filing_availability +from .filing_code_search import filing_code_search from .filing_views import get_filings, payment_fees from .payment_views import delete_payment_account, waiver_account from .suffolk_api_views import get_party_types_from_suffolk_api, lookup_case @@ -34,6 +35,7 @@ app_name = "api" urlpatterns = [ + path("filing-code-search/", filing_code_search, name="filing_code_search"), path("get-party-types/", get_party_types_from_suffolk_api, name="get_party_types"), # Dropdown API endpoints path("dropdowns/case-categories/", get_case_categories, name="case_categories"), diff --git a/efile_app/efile/data/case_type_guidance.yaml b/efile_app/efile/data/case_type_guidance.yaml new file mode 100644 index 00000000..e62397e2 --- /dev/null +++ b/efile_app/efile/data/case_type_guidance.yaml @@ -0,0 +1,840 @@ +# Curated guidance and discovery facets. Unknown classifications remain visible. +version: 2 +jurisdictions: +- illinois +- massachusetts +- vermont +topics: + eviction: + label: Eviction + pattern: \b(eviction|ejectment|forcible|unlawful detainer|summary process)\b + query_pattern: \b(eviction|evict\w*|ejectment|unlawful detainer|summary process|landlord|tenant|forcible)\b + text: A case about who has the right to occupy property. + source: https://www.illinoiscourts.gov/courts/additional-resources/eviction/ + facets: + - key: role + label: Your role + options: + - value: tenant + label: Tenant / occupant + pattern: ^(answer|response|objection)\b|\b(for|by) (respondent|defendant)\b + field: filing + - value: landlord + label: Landlord / owner + pattern: ^(complaint|petition)(\b|/)|\b(for|by) (petitioner|plaintiff)\b + field: filing + - key: property + label: Property + options: + - value: residential + label: Home + pattern: \bresidential\b + field: case + text: A home or apartment. + - value: commercial + label: Business + pattern: \bcommercial\b + field: case + text: Property used for a business, such as a store or office. + - key: relief + label: What the landlord is asking for + options: + - value: possession + label: Property back only + pattern: \bpossession only\b + field: case + text: The landlord seeks the property back, not unpaid rent; court costs may still be requested. + - value: money + label: Money too + pattern: \b(money|rent|damages)\b + field: case + text: Includes a money claim; check the complaint for what is requested. + jurisdictions: + illinois: + text: An eviction case asks the court to return possession of property to a landlord or owner. Some cases + also seek unpaid rent. + source: https://www.illinoiscourts.gov/courts/additional-resources/eviction/ + special: + - pattern: \bejectment\b + text: A claim to recover property based on a legal right to possess it. It can involve ownership rights; + it is not simply another name for a residential rental eviction. + source: https://my.ilga.gov/legislation/ILCS/details?ActID=2017&ActName=Code+of+Civil+Procedure.&ChapAct=735+ILCS+5%2F&Chapter=CIVIL+PROCEDURE&ChapterID=56&MajorTopic=RIGHTS+AND+REMEDIES&SeqEnd=39000000&SeqStart=34500000 + massachusetts: + text: Massachusetts calls an eviction court case “summary process.” It concerns possession of rental property + and may also include a claim for unpaid rent. + source: https://www.mass.gov/eviction-for-tenants + vermont: + text: An eviction case asks the Civil Division of the Superior Court to return possession of rented property + to a landlord. + source: https://www.vtcourts.gov/civil/eviction-process + unknown_notes: + - when: relief + if_known: property + text: The name does not say whether money is requested; check the complaint. + foreclosure: + label: Mortgage foreclosure + pattern: \b(foreclos\w*)\b + query_pattern: \b(foreclos\w*)\b + text: A lender or lien holder asks the court to sell property to pay a secured debt. + source: https://www.illinoiscourts.gov/courts/additional-resources/mortgage-foreclosure/ + facets: + - key: role + label: Your role + options: + - value: responding + label: Property owner + pattern: ^(answer|response|objection)\b|\b(for|by) (respondent|defendant)\b + field: filing + - value: requesting + label: Lender / lien holder + pattern: ^(complaint|petition)(\b|/)|\b(for|by) (petitioner|plaintiff)\b + field: filing + - key: property + label: Property + options: + - value: residential + label: Home + pattern: \bresidential\b + field: case + text: A home or apartment. + - value: commercial + label: Business + pattern: \bcommercial\b + field: case + text: Property used for a business, such as a store or office. + - key: issue + label: Type of claim + options: + - value: mortgage + label: Mortgage loan + pattern: \bmortgage\b + field: case + - value: lien + label: Other lien + pattern: \b(mechanic|assessment|condominium|lien)\b + field: case + jurisdictions: {} + replevin: + label: Return of personal property + pattern: \b(replevin|detinue)\b + query_pattern: \b(replevin|detinue)\b + text: A request to recover belongings held by someone else; this is different from recovering land or housing. + source: https://www.illinoislegalaid.org/legal-information/getting-my-property-back + facets: + - key: role + label: Your role + options: + - value: responding + label: Responding to the request + pattern: ^(answer|response|objection)\b|\b(for|by) (respondent|defendant)\b + field: filing + - value: requesting + label: Seeking property back + pattern: ^(complaint|petition)(\b|/)|\b(for|by) (petitioner|plaintiff)\b + field: filing + - key: procedure + label: Court label + options: + - value: replevin + label: Replevin + pattern: \breplevin\b + field: case + text: Replevin asks for return of specific belongings through a statutory court process. + - value: detinue + label: Detinue + pattern: \bdetinue\b + field: case + text: Detinue is another court procedure for recovery of belongings or their value. + jurisdictions: {} + property_damage: + label: Property damage + pattern: \b(property damage|damage to property)\b + query_pattern: \b(property damage|damage to property)\b + text: A claim for harm to belongings, a vehicle, or real estate, rather than injury to a person. + source: https://www.illinoislegalaid.org/legal-information/what-are-my-rights-when-car-accident-happens + facets: + - key: role + label: Your role + options: + - value: responding + label: Responding to a claim + pattern: ^(answer|response|objection)\b|\b(for|by) (respondent|defendant)\b + field: filing + - value: requesting + label: Seeking payment for damage + pattern: ^(complaint|petition)(\b|/)|\b(for|by) (petitioner|plaintiff)\b + field: filing + - key: property + label: Damaged property + options: + - value: vehicle + label: Vehicle + pattern: \b(vehicle|auto\w*)\b + field: case + - value: real_estate + label: Land or building + pattern: \b(real estate|real property|building)\b + field: case + - value: belongings + label: Other belongings + pattern: \b(personal property|belongings)\b + field: case + jurisdictions: {} + personal_injury: + label: Personal injury + pattern: \b(personal injury|bodily injury|malpractice|wrongful death|motor vehicle|automobile negligence|premises + liability|product liability)\b + query_pattern: \b(personal injury|bodily injury|malpractice|wrongful death|motor vehicle|automobile negligence|premises + liability|product liability)\b + text: A claim for harm to a person, often seeking money for injuries caused by someone else. + source: https://www.illinoislegalaid.org/legal-information/what-are-my-rights-when-car-accident-happens + facets: + - key: role + label: Your role + options: + - value: responding + label: Responding to a claim + pattern: ^(answer|response|objection)\b|\b(for|by) (respondent|defendant)\b + field: filing + - value: requesting + label: Bringing an injury claim + pattern: ^(complaint|petition)(\b|/)|\b(for|by) (petitioner|plaintiff)\b + field: filing + - key: issue + label: Injury involving + options: + - value: vehicle + label: Vehicle accident + pattern: \b(motor vehicle|auto\w*|traffic)\b + field: case + - value: medical + label: Medical care + pattern: \b(medical|malpractice)\b + field: case + - value: premises + label: Unsafe property + pattern: \bpremises\b + field: case + - value: death + label: Death + pattern: \bwrongful death\b + field: case + jurisdictions: {} + small_claims: + label: Small claims + pattern: \bsmall claims?\b + query_pattern: \bsmall claims?\b + text: A simpler court process for certain lower-value money disputes; the court sets the limit. + source: https://www.illinoislegalaid.org/legal-information/small-claims + facets: + - key: role + label: Your role + options: + - value: responding + label: Responding to a claim + pattern: ^(answer|response|objection)\b|\b(for|by) (respondent|defendant)\b + field: filing + - value: requesting + label: Asking for payment + pattern: ^(complaint|petition)(\b|/)|\b(for|by) (petitioner|plaintiff)\b + field: filing + - key: issue + label: Dispute about + options: + - value: contract + label: Agreement or bill + pattern: \b(contract|debt|account|bill)\b + field: case + - value: damage + label: Damaged property + pattern: \b(property damage|damage to property)\b + field: case + - value: deposit + label: Security deposit + pattern: \bsecurity deposit\b + field: case + jurisdictions: + illinois: + usual_limit_cents: 1000000 + amount_help: Illinois small claims generally cover up to $10,000, excluding interest and costs. Amount brackets + below come from the remaining court labels. + source: https://www.illinoiscourts.gov/rules/supreme-court-rules/ + massachusetts: + usual_limit_cents: 700000 + amount_help: Massachusetts generally limits small claims to $7,000; motor-vehicle property-damage claims + can exceed that limit. Brackets below reflect the remaining court labels. + source: https://www.mass.gov/info-details/small-claims-court + vermont: + usual_limit_cents: 1000000 + amount_help: Vermont generally allows up to $10,000; consumer credit and medical debt claims are capped + at $5,000. Brackets below reflect the remaining court labels. + source: https://www.vtcourts.gov/civil/suing-and-being-sued-small-claims + debt: + label: Debt collection + pattern: \b(debt|collection|garnishment|wage deduction|citation to discover|credit card)\b + query_pattern: \b(debt|collection|garnishment|wage deduction|citation to discover|credit card)\b + text: A creditor seeks payment of a debt, or collection of an existing judgment. + source: https://www.illinoislegalaid.org/legal-information/debt-collection + facets: + - key: role + label: Your role + options: + - value: responding + label: Disputing collection + pattern: ^(answer|response|objection)\b|\b(for|by) (respondent|defendant)\b + field: filing + - value: requesting + label: Collecting a debt + pattern: ^(complaint|petition)(\b|/)|\b(for|by) (petitioner|plaintiff)\b + field: filing + - key: issue + label: Debt or collection + options: + - value: card + label: Credit card + pattern: \bcredit card\b + field: case + - value: medical + label: Medical bill + pattern: \bmedical\b + field: case + - value: judgment + label: Collecting a judgment + pattern: \b(garnishment|wage deduction|citation to discover|post.?judgment)\b + field: both + jurisdictions: {} + employment: + label: Employment and wages + pattern: \b(employment|wage claim|unpaid wages|wrongful termination|employment discrimination)\b + query_pattern: \b(employment|wage claim|unpaid wages|wrongful termination|employment discrimination)\b + text: A workplace dispute, such as unpaid wages, discrimination, or unlawful dismissal. + source: https://www.illinoislegalaid.org/legal-information/business-work + facets: + - key: role + label: Your role + options: + - value: responding + label: Responding to a claim + pattern: ^(answer|response|objection)\b|\b(for|by) (respondent|defendant)\b + field: filing + - value: requesting + label: Worker bringing a claim + pattern: ^(complaint|petition)(\b|/)|\b(for|by) (petitioner|plaintiff)\b + field: filing + - key: issue + label: Workplace issue + options: + - value: wages + label: Pay / wages + pattern: \b(wages?|pay|overtime)\b + field: case + - value: discrimination + label: Discrimination + pattern: \bdiscrimination\b + field: case + - value: termination + label: Dismissal + pattern: \b(termination|dismissal|discharge)\b + field: case + jurisdictions: {} + contract: + label: Contracts + pattern: \b(contracts?|breach of warranty)\b + query_pattern: \b(contracts?|breach of warranty)\b + text: A dispute about promises in an agreement, such as payment or work that was not completed. + source: https://www.illinoislegalaid.org/legal-information/starting-new-case + facets: + - key: role + label: Your role + options: + - value: responding + label: Responding to a claim + pattern: ^(answer|response|objection)\b|\b(for|by) (respondent|defendant)\b + field: filing + - value: requesting + label: Bringing a claim + pattern: ^(complaint|petition)(\b|/)|\b(for|by) (petitioner|plaintiff)\b + field: filing + - key: issue + label: Agreement about + options: + - value: services + label: Work or services + pattern: \b(service|construction|contractor)\b + field: case + - value: sales + label: Goods or sales + pattern: \b(sale|goods|warranty)\b + field: case + - value: insurance + label: Insurance + pattern: \binsurance\b + field: case + jurisdictions: {} + child_support: + label: Child support + pattern: \b(child support|support of children|uniform interstate family support|uifsa)\b + query_pattern: \b(child support|support of children|uniform interstate family support|uifsa)\b + text: A case about financial support for a child, including setting, changing, or enforcing an order. + source: https://www.illinoislegalaid.org/legal-information/child-support-and-child-care + facets: + - key: role + label: Your role + options: + - value: responding + label: Responding to a request + pattern: ^(answer|response|objection)\b|\b(for|by) (respondent|defendant)\b + field: filing + - value: requesting + label: Making a support request + pattern: ^(complaint|petition)(\b|/)|\b(for|by) (petitioner|plaintiff)\b + field: filing + - key: action + label: What you need + options: + - value: change + label: Change an order + pattern: \b(modif\w*|change|amend\w*)\b + field: both + - value: enforce + label: Enforce an order + pattern: \b(enforc\w*|contempt|rule to show cause)\b + field: both + - value: establish + label: Get an order + pattern: \b(establish\w*|initial)\b + field: both + - key: issue + label: Order from + options: + - value: interstate + label: Another state or country + pattern: \b(interstate|foreign|uifsa)\b + field: case + - value: local + label: This state + pattern: \bintrastate\b + field: case + jurisdictions: {} + parentage: + label: Parentage + pattern: \b(parentage|paternity)\b + query_pattern: \b(parentage|paternity)\b + text: A case to determine who a child’s legal parents are; support and parenting orders may also be involved. + source: https://www.illinoislegalaid.org/legal-information/paternity-or-parentage + facets: + - key: role + label: Your role + options: + - value: responding + label: Responding to it + pattern: ^(answer|response|objection)\b|\b(for|by) (respondent|defendant)\b + field: filing + - value: requesting + label: Making the request + pattern: ^(complaint|petition)(\b|/)|\b(for|by) (petitioner|plaintiff)\b + field: filing + - key: issue + label: Request about + options: + - value: establish + label: Establish parentage + pattern: \b(establish\w*|determin\w*)\b + field: both + - value: challenge + label: Challenge parentage + pattern: \b(disestablish\w*|non.?paternity|vacate|rescind)\b + field: both + - key: action + label: What you need + options: + - value: change + label: Change an order + pattern: \b(modif\w*|change|amend\w*)\b + field: both + - value: enforce + label: Enforce an order + pattern: \b(enforc\w*|contempt|rule to show cause)\b + field: both + - value: establish + label: Get an order + pattern: \b(establish\w*|initial)\b + field: both + jurisdictions: {} + parenting: + label: Parenting and custody + pattern: \b(custody|parenting|parental responsibilit\w*|visitation)\b + query_pattern: \b(custody|parenting|parental responsibilit\w*|visitation)\b + text: A case about decisions for a child or when the child spends time with each parent. + source: https://www.illinoislegalaid.org/legal-information/parental-responsibilities-custody-and-visitation + facets: + - key: role + label: Your role + options: + - value: responding + label: Responding to it + pattern: ^(answer|response|objection)\b|\b(for|by) (respondent|defendant)\b + field: filing + - value: requesting + label: Making the request + pattern: ^(complaint|petition)(\b|/)|\b(for|by) (petitioner|plaintiff)\b + field: filing + - key: issue + label: Parenting issue + options: + - value: decisions + label: Decision-making / custody + pattern: \b(custody|decision|allocation)\b + field: case + - value: time + label: Parenting time / visitation + pattern: \b(parenting time|visitation)\b + field: case + - value: relocation + label: Moving with a child + pattern: \b(relocation|removal)\b + field: case + - key: action + label: What you need + options: + - value: change + label: Change an order + pattern: \b(modif\w*|change|amend\w*)\b + field: both + - value: enforce + label: Enforce an order + pattern: \b(enforc\w*|contempt|rule to show cause)\b + field: both + - value: establish + label: Get an order + pattern: \b(establish\w*|initial)\b + field: both + jurisdictions: {} + divorce: + label: Divorce and separation + pattern: \b(divorce|dissolution|legal separation|invalidity|annulment)\b + query_pattern: \b(divorce|dissolution|legal separation|invalidity|annulment)\b + text: A case to end a marriage or civil union, or address a legal separation. + source: https://www.illinoislegalaid.org/legal-information/divorce + facets: + - key: role + label: Your role + options: + - value: responding + label: Responding to it + pattern: ^(answer|response|objection)\b|\b(for|by) (respondent|defendant)\b + field: filing + - value: requesting + label: Making the request + pattern: ^(complaint|petition)(\b|/)|\b(for|by) (petitioner|plaintiff)\b + field: filing + - key: children + label: Children + options: + - value: without + label: Without children + pattern: \b(without|no) (minor )?children\b + field: case + - value: with + label: With children + pattern: \bwith (minor )?children\b + field: case + - key: issue + label: What you are seeking + options: + - value: separation + label: Legal separation + pattern: \blegal separation\b + field: case + text: Legal separation addresses living apart without ending the marriage. + - value: annulment + label: Invalidity / annulment + pattern: \b(invalidity|annulment)\b + field: case + text: A request to declare a marriage legally invalid. + - value: divorce + label: End the marriage + pattern: \b(divorce|dissolution)\b + field: case + jurisdictions: {} + protection: + label: Protection and no-contact orders + pattern: \b(order of protection|protective order|protection order|restraining order|no contact|no-contact|stalking|abuse + prevention)\b + query_pattern: \b(order of protection|protective order|protection order|restraining order|no contact|no-contact|stalking|abuse + prevention)\b + text: A request for an order limiting contact or other conduct to protect someone from harm. + source: https://www.illinoiscourts.gov/courts/additional-resources/order-of-protection/ + facets: + - key: role + label: Your role + options: + - value: responding + label: Responding to the request + pattern: ^(answer|response|objection)\b|\b(for|by) (respondent|defendant)\b + field: filing + - value: requesting + label: Requesting protection + pattern: ^(complaint|petition)(\b|/)|\b(for|by) (petitioner|plaintiff)\b + field: filing + - key: issue + label: Court label + options: + - value: domestic + label: Domestic abuse + pattern: \b(domestic|order of protection)\b + field: case + - value: stalking + label: Stalking + pattern: \bstalking\b + field: case + - value: sexual + label: Sexual assault / civil no contact + pattern: \b(sexual|civil no.contact)\b + field: case + - key: duration + label: Order requested + options: + - value: emergency + label: Emergency / temporary + pattern: \b(emergency|temporary|interim)\b + field: both + - value: longer + label: Longer-term / plenary + pattern: \b(plenary|final)\b + field: both + jurisdictions: {} + minor_guardianship: + label: Child guardianship + pattern: \b(minor.*guardianship|guardianship.*(minor|child))\b + query_pattern: \b(minor.*guardianship|guardianship.*(minor|child))\b + text: A request for a guardian to make decisions for a child; guardianship is different from adoption. + source: https://www.illinoislegalaid.org/legal-information/child-guardianship + facets: + - key: role + label: Your role + options: + - value: responding + label: Responding or objecting + pattern: ^(answer|response|objection)\b|\b(for|by) (respondent|defendant)\b + field: filing + - value: requesting + label: Requesting guardianship + pattern: ^(complaint|petition)(\b|/)|\b(for|by) (petitioner|plaintiff)\b + field: filing + - key: scope + label: Decisions about + options: + - value: personal + label: Care of the child + pattern: \b(person only|of the person)\b + field: case + - value: financial + label: Child’s money / estate + pattern: \b(estate only|of the estate)\b + field: case + - value: both + label: Care and money + pattern: \b(person and estate|person & estate)\b + field: case + - key: duration + label: Guardianship + options: + - value: temporary + label: Temporary / emergency + pattern: \b(temporary|emergency|short.term)\b + field: both + - value: standby + label: Standby + pattern: \bstandby\b + field: case + text: A standby guardian is designated to take over when specified conditions occur. + jurisdictions: {} + adult_guardianship: + label: Adult guardianship + pattern: \b(adult guardianship|guardianship of (an? )?(adult|disabled)|disabled (adult|person)|adult.*guardianship)\b + query_pattern: \b(adult guardianship|guardianship of (an? )?(adult|disabled)|disabled (adult|person)|adult.*guardianship)\b + text: A request for authority to make specified personal or financial decisions for an adult who needs a guardian. + source: https://www.illinoislegalaid.org/legal-information/adult-guardianship + facets: + - key: role + label: Your role + options: + - value: responding + label: Responding or objecting + pattern: ^(answer|response|objection)\b|\b(for|by) (respondent|defendant)\b + field: filing + - value: requesting + label: Requesting guardianship + pattern: ^(complaint|petition)(\b|/)|\b(for|by) (petitioner|plaintiff)\b + field: filing + - key: scope + label: Decisions about + options: + - value: personal + label: Personal care + pattern: \b(person only|of the person)\b + field: case + - value: financial + label: Money / estate + pattern: \b(estate only|of the estate)\b + field: case + - value: both + label: Personal care and money + pattern: \b(person and estate|person & estate)\b + field: case + - key: duration + label: Guardianship + options: + - value: temporary + label: Temporary / emergency + pattern: \b(temporary|emergency)\b + field: both + - value: limited + label: Limited authority + pattern: \blimited\b + field: case + - value: plenary + label: Full / plenary authority + pattern: \bplenary\b + field: case + jurisdictions: {} + probate: + label: Estates and probate + pattern: \b(probate|decedent|will contest|estate administration|letters of (office|administration|testamentary))\b + query_pattern: \b(probate|decedent|will contest|estate administration|letters of (office|administration|testamentary))\b + text: A case about managing and distributing someone’s property after their death. + source: https://www.illinoislegalaid.org/legal-information/estates-powers-attorney + facets: + - key: role + label: Your role + options: + - value: responding + label: Responding or objecting + pattern: ^(answer|response|objection)\b|\b(for|by) (respondent|defendant)\b + field: filing + - value: requesting + label: Requesting court action + pattern: ^(complaint|petition)(\b|/)|\b(for|by) (petitioner|plaintiff)\b + field: filing + - key: issue + label: Estate matter + options: + - value: administration + label: Administer an estate + pattern: \b(administration|letters|admit.*will)\b + field: both + - value: contest + label: Dispute a will + pattern: \b(will contest|contest.*will)\b + field: both + - value: claim + label: Claim against estate + pattern: \bclaim\b + field: both + jurisdictions: {} + name_change: + label: Name changes + pattern: \b(name change|change of name)\b + query_pattern: \b(name change|change of name)\b + text: A request for a court order changing a person’s legal name. + source: https://www.illinoiscourts.gov/courts/additional-resources/name-change/ + facets: + - key: role + label: Your role + options: + - value: responding + label: Responding or objecting + pattern: ^(answer|response|objection)\b|\b(for|by) (respondent|defendant)\b + field: filing + - value: requesting + label: Requesting a name change + pattern: ^(complaint|petition)(\b|/)|\b(for|by) (petitioner|plaintiff)\b + field: filing + - key: person + label: Whose name + options: + - value: adult + label: Adult + pattern: \badult\b + field: case + - value: minor + label: Child + pattern: \b(minor|child)\b + field: case + jurisdictions: {} + adoption: + label: Adoption + pattern: \badoption\b + query_pattern: \badoption\b + text: A case to establish a legal parent-child relationship through adoption. + source: https://www.illinoislegalaid.org/legal-information/adoption + facets: + - key: role + label: Your role + options: + - value: responding + label: Responding or objecting + pattern: ^(answer|response|objection)\b|\b(for|by) (respondent|defendant)\b + field: filing + - value: requesting + label: Requesting adoption + pattern: ^(complaint|petition)(\b|/)|\b(for|by) (petitioner|plaintiff)\b + field: filing + - key: person + label: Person being adopted + options: + - value: adult + label: Adult + pattern: \badult\b + field: case + - value: minor + label: Child + pattern: \b(minor|child|infant)\b + field: case + - key: relationship + label: Adoption type + options: + - value: related + label: Relative / stepparent + pattern: \b(related|relative|step.?parent)\b + field: case + - value: unrelated + label: Unrelated / agency + pattern: \b(unrelated|agency)\b + field: case + jurisdictions: {} + administrative_review: + label: Review of agency decisions + pattern: \b(administrative review|review of administrative)\b + query_pattern: \b(administrative review|review of administrative)\b + text: A request for a court to review a government agency’s decision. + source: https://www.illinoislegalaid.org/legal-information/lawyer-manual/administrative-review + facets: + - key: role + label: Your role + options: + - value: responding + label: Responding to the challenge + pattern: ^(answer|response|objection)\b|\b(for|by) (respondent|defendant)\b + field: filing + - value: requesting + label: Challenging a decision + pattern: ^(complaint|petition)(\b|/)|\b(for|by) (petitioner|plaintiff)\b + field: filing + - key: agency + label: Decision about + options: + - value: benefits + label: Benefits + pattern: \b(unemployment|benefit|public aid)\b + field: case + - value: license + label: License or permit + pattern: \b(license|permit)\b + field: case + - value: tax + label: Tax + pattern: \btax\b + field: case + jurisdictions: {} diff --git a/efile_app/efile/data/filing_code_facets.yaml b/efile_app/efile/data/filing_code_facets.yaml new file mode 100644 index 00000000..b8ba5b7f --- /dev/null +++ b/efile_app/efile/data/filing_code_facets.yaml @@ -0,0 +1,17 @@ +# Discovery aids based on filing names, not the filer's legal party status or +# permission to upload a document as an attachment. Unmatched names stay unknown. +version: 1 +rules: + - pattern: '^(exhibits?|attachments?|appendix|appendices|supporting documents?)\b' + document: attachment + - pattern: '^(answer|response|objection)\b' + purpose: responding + document: main + - pattern: '^(motion|notice|appearance)\b' + purpose: either + document: main + - pattern: '^affidavit\b' + purpose: either + - pattern: '^(complaint|petition|counterclaim|crossclaim)\b|^(eviction|summary process|civil|small claims|verified|amended)\b.*\b(complaint|petition)$' + purpose: starting + document: main diff --git a/efile_app/efile/data/filing_code_search.yaml b/efile_app/efile/data/filing_code_search.yaml new file mode 100644 index 00000000..3496bb46 --- /dev/null +++ b/efile_app/efile/data/filing_code_search.yaml @@ -0,0 +1,77 @@ +# Search aids only: these terms do not decide a court's jurisdiction or make +# different remedies interchangeable. Only paths supplied by the EFSP appear. +version: 1 +jurisdictions: [illinois, massachusetts, vermont] +stemming: english +stop_words: [a, an, and, are, for, how, i, in, is, it, my, of, "on", or, the, to, want, with] +concepts: + eviction: + terms: [eviction, evict, evicted, unlawful detainer, forcible entry and detainer, forcible detainer, summary process, ejectment] + divorce: + terms: [divorce, dissolution of marriage, dissolve marriage] + custody: + terms: [custody, parental rights and responsibilities, allocation of parental responsibilities] + parenting_time: + terms: [parenting time, visitation, parent child contact] + child_support: + terms: [child support, support of children] + protection: + terms: [restraining order, protective order, order of protection, abuse prevention, relief from abuse] + fee_waiver: + terms: [fee waiver, waive fees, indigency, in forma pauperis, cannot afford fees] + name_change: + terms: [name change, change name, change of name] + small_claims: + terms: [small claims, small claim] + answer: + terms: [answer, respond to complaint, response to complaint] + dismiss: + terms: [dismiss, dismissal, dismissing] + continuance: + terms: [continuance, postpone hearing, reschedule hearing, continue hearing] + expungement: + terms: [expungement, expunge] + sealing: + terms: [seal record, sealing records, record sealing] + garnishment: + terms: [garnishment, wage deduction, trustee process] + probate: + terms: [probate, estate administration] + contempt: + terms: [contempt, violation of court order] + appeal: + terms: [appeal, appellate] +# Whole normalized filing names only. Never infer a document's purpose just +# because its case type mentions eviction, or because its label contains +# "complaint" (which would also match an answer or a motion to dismiss). +# Rules can also constrain court, case_category, case_type, and initial. +# Add jurisdiction-specific terms to a concept with `jurisdictions: [...]`. +explanations: + - jurisdictions: [illinois, massachusetts, vermont] + filing_type: [Motion, Motion to the Court] + text: A request for the court to make an order or decision in a case. + source: https://www.mass.gov/info-details/glossary-of-court-terms + - jurisdictions: [illinois, massachusetts, vermont] + filing_type: [Affidavit] + text: A written statement of facts made under oath or penalties of perjury. + source: https://www.mass.gov/info-details/glossary-of-court-terms + - jurisdictions: [illinois, massachusetts, vermont] + filing_type: [Answer, Answer to Complaint] + text: A written response to the claims in a complaint. + source: https://www.vtcourts.gov/glossary + - jurisdictions: [massachusetts] + filing_type: [Summary Process Complaint, Complaint for Summary Process, Summary Process Summons and Complaint] + text: A complaint asking the court to decide an eviction case. + source: https://www.mass.gov/how-to/file-an-eviction-case + - jurisdictions: [massachusetts] + filing_type: [Answer to Summary Process Complaint, Summary Process Answer] + text: The tenant's response to an eviction complaint. + source: https://www.mass.gov/lists/court-forms-for-eviction + - jurisdictions: [illinois] + filing_type: [Eviction Complaint, Complaint for Eviction] + text: A complaint asking the court to decide an eviction case. + source: https://www.illinoiscourts.gov/forms/approved-forms/forms-approved-forms-circuit-court/eviction/ + - jurisdictions: [vermont] + filing_type: [Eviction Complaint, Complaint for Eviction] + text: A complaint asking the court to decide an eviction case. + source: https://www.vtcourts.gov/civil/eviction-process diff --git a/efile_app/efile/data/filing_concept_definitions.yaml b/efile_app/efile/data/filing_concept_definitions.yaml new file mode 100644 index 00000000..b3e7034f --- /dev/null +++ b/efile_app/efile/data/filing_concept_definitions.yaml @@ -0,0 +1,113 @@ +# Display text only. Editing this file does not invalidate filing-code indexes. +version: 1 +# Explicit county membership, independent of court codes that vary by environment. +# Add mappings when adding county-specific definitions below. +county_courts: + illinois: + Cook: + names: [Cook County] + prefixes: ["Cook County - "] +concepts: + eviction: + label: Eviction + default: + text: A court process about recovering possession of property from an occupant. + source: https://www.vtcourts.gov/civil/eviction-process + jurisdictions: + illinois: + text: An eviction case asks the court to return possession of property to a landlord or owner. Some cases also seek unpaid rent. + source: https://www.illinoiscourts.gov/courts/additional-resources/eviction/ + counties: {} + courts: {} + massachusetts: + label: Eviction (summary process) + text: Massachusetts calls an eviction court case “summary process.” It concerns possession of rental property and may also include a claim for unpaid rent. + source: https://www.mass.gov/eviction-for-tenants + vermont: + text: An eviction case asks the Civil Division of the Superior Court to return possession of rented property to a landlord. + source: https://www.vtcourts.gov/civil/eviction-process + answer: + label: Answer + default: + text: A response to a complaint that admits or denies claims and may state defenses. + source: https://www.vtcourts.gov/glossary + appeal: + label: Appeal + default: + text: A request for a higher court to review a lower court’s decision. + source: https://www.vtcourts.gov/glossary + continuance: + label: Continuance + default: + text: Postponing a scheduled court hearing to a later date. + source: https://www.vtcourts.gov/glossary + contempt: + label: Contempt + default: + text: Disobeying a court order or interfering with the court’s authority. + source: https://www.vtcourts.gov/glossary + divorce: + label: Divorce + default: + text: The legal process for ending a marriage. + source: https://www.vtcourts.gov/glossary + custody: + label: Child custody + default: + text: Rights and responsibilities for a child’s care and important decisions. + source: https://www.vtcourts.gov/glossary + child_support: + label: Child support + default: + text: Payments ordered to help cover a child’s needs. + source: https://www.vtcourts.gov/glossary + dismiss: + label: Dismissal + default: + text: Ending a case or claim without continuing to decide it. + source: https://www.vtcourts.gov/glossary + probate: + label: Probate + default: + text: Court proceedings concerning matters such as a deceased person’s estate. + source: https://www.vtcourts.gov/glossary + parenting_time: + label: Parenting time + default: + text: The time a child spends with each parent. + source: https://www.vtcourts.gov/family/parent-coordination + protection: + label: Protective order + default: + text: A court order intended to protect a person from harm. + source: https://www.vtcourts.gov/family/relief-abuse + fee_waiver: + label: Fee waiver + default: + text: Court permission not to pay certain court fees because of financial hardship. + source: https://www.vtcourts.gov/fees + name_change: + label: Name change + default: + text: A court process to change a person’s legal name. + source: https://www.vtcourts.gov/probate/name-changes + small_claims: + label: Small claims + default: + text: A simplified court process for certain disputes about money. + source: https://www.vtcourts.gov/civil/suing-and-being-sued-small-claims + expungement: + label: Expungement + default: + text: Removing a criminal record under the rules that apply to that record. + source: https://www.vtcourts.gov/criminal/expungement + sealing: + label: Record sealing + default: + text: Restricting access to a record instead of destroying it. + source: https://www.vtcourts.gov/criminal/expungement + garnishment: + label: Garnishment + default: + text: Collecting a debt from money held by someone else, such as an employer or bank. + source: https://www.vtcourts.gov/civil/collecting-small-claims-judgment diff --git a/efile_app/efile/management/commands/process_document_extractions.py b/efile_app/efile/management/commands/process_document_extractions.py index b0ef8ded..6a3cbc1c 100644 --- a/efile_app/efile/management/commands/process_document_extractions.py +++ b/efile_app/efile/management/commands/process_document_extractions.py @@ -6,7 +6,7 @@ from django.conf import settings from django.core.management import call_command from django.core.management.base import BaseCommand -from django.db import close_old_connections +from django.db import OperationalError, close_old_connections, connection logger = logging.getLogger(__name__) @@ -69,7 +69,21 @@ def rollup_if_due(): while True: close_old_connections() rollup_if_due() - job = claim_next_extraction() + try: + job = claim_next_extraction() + except OperationalError as error: + # A large local code-index publication can outlast SQLite's + # busy timeout. Keep the supervisor alive to claim work after + # the writer finishes; other database errors still fail loudly. + if ( + options["once"] + or connection.vendor != "sqlite" + or str(error) not in {"database is locked", "database table is locked"} + ): + raise + logger.warning("Local database is busy; document extraction will retry") + time.sleep(max(0.1, options["poll_interval"])) + continue if job is None: if options["once"]: return diff --git a/efile_app/efile/management/commands/refresh_filing_code_index.py b/efile_app/efile/management/commands/refresh_filing_code_index.py new file mode 100644 index 00000000..ce83bf9a --- /dev/null +++ b/efile_app/efile/management/commands/refresh_filing_code_index.py @@ -0,0 +1,71 @@ +"""Build complete code snapshots outside the request and release paths.""" + +import time +from pathlib import Path + +import requests +from django.core.management.base import BaseCommand, CommandError +from django.db import DatabaseError, close_old_connections +from django.test.utils import override_settings +from django.utils import timezone + +from efile.services.filing_code_search import current_index, refresh_index, rules + + +class Command(BaseCommand): + help = "Refresh filing search for all jurisdictions, preserving previous snapshots on failure." + + def add_arguments(self, parser): + parser.add_argument("--jurisdiction", choices=rules()[0]["jurisdictions"]) + parser.add_argument("--interval", type=int, default=0, help="Repeat after this many seconds (0: run once).") + parser.add_argument("--retry-interval", type=int, default=60, help="Seconds before retrying failed states.") + parser.add_argument( + "--cache-dir", + default=str(Path.home() / ".cache" / "litefile" / "filing-code-cache"), + help="Compressed court checkpoints, reused for six hours.", + ) + + def handle(self, *args, **options): + interval = options["interval"] + if interval < 0: + raise CommandError("Interval must be nonnegative") + retry_interval = options["retry_interval"] + if retry_interval <= 0: + raise CommandError("Retry interval must be positive") + jurisdictions = [options["jurisdiction"]] if options["jurisdiction"] else rules()[0]["jurisdictions"] + while True: + failures = [] + # The worker sleeps for hours between passes; a connection the + # database or proxy dropped meanwhile must not fail the next one. + close_old_connections() + for jurisdiction in jurisdictions: + try: + if interval: + index = current_index(jurisdiction) + if index and (timezone.now() - index.refreshed_at).total_seconds() < interval: + continue + # This dedicated command doesn't serve requests. Rendering + # and retaining SQL debug strings for millions of rows is + # costly in local development; restore DEBUG after the build. + with override_settings(DEBUG=False): + count = refresh_index( + jurisdiction, + cache_dir=options["cache_dir"], + progress=lambda court, state=jurisdiction: self.stdout.write(f"{state}: {court}") + if options["verbosity"] > 1 + else None, + ) + self.stdout.write(self.style.SUCCESS(f"{jurisdiction}: indexed {count} filing paths")) + # DatabaseError covers a lost connection and an overlapping run + # creating the same jurisdiction's row first (IntegrityError). + except (requests.RequestException, ValueError, DatabaseError) as error: + failures.append(jurisdiction) + self.stderr.write(f"{jurisdiction}: refresh failed; previous index kept. {error}") + if not interval: + if failures: + raise CommandError("Refresh failed: " + ", ".join(failures)) + return + delay = min(interval, retry_interval) if failures else interval + if failures: + self.stderr.write(f"Retrying failed states in {delay} seconds.") + time.sleep(delay) diff --git a/efile_app/efile/migrations/0033_filing_code_search.py b/efile_app/efile/migrations/0033_filing_code_search.py new file mode 100644 index 00000000..9cfe09a5 --- /dev/null +++ b/efile_app/efile/migrations/0033_filing_code_search.py @@ -0,0 +1,56 @@ +# Generated by Django 5.2.17 on 2026-10-03 03:33 + +import django.db.models.deletion +from django.db import migrations, models + + +def add_postgres_search_index(apps, schema_editor): + if schema_editor.connection.vendor == "postgresql": + schema_editor.execute( + "CREATE INDEX filing_code_search_gin ON efile_filingcodepath " + "USING GIN (to_tsvector('simple'::regconfig, COALESCE(search_text, '')))" + ) + + +def remove_postgres_search_index(apps, schema_editor): + if schema_editor.connection.vendor == "postgresql": + schema_editor.execute("DROP INDEX IF EXISTS filing_code_search_gin") + + +class Migration(migrations.Migration): + + dependencies = [ + ('efile', '0032_inventory_owned_uploads'), + ] + + operations = [ + migrations.CreateModel( + name='FilingCodeIndex', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('jurisdiction', models.CharField(max_length=20, unique=True)), + ('source_url', models.URLField()), + ('refreshed_at', models.DateTimeField()), + ('rules_digest', models.CharField(max_length=64)), + ('vocabulary', models.JSONField(default=dict)), + ], + ), + migrations.CreateModel( + name='FilingCodePath', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('initial', models.BooleanField()), + ('court', models.JSONField()), + ('case_category', models.JSONField()), + ('case_type', models.JSONField()), + ('filing_type', models.JSONField()), + ('search_text', models.TextField()), + ('filing_terms', models.TextField()), + ('case_terms', models.TextField()), + ('explanation', models.TextField(blank=True)), + ('explanation_source', models.URLField(blank=True)), + ('index', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='paths', to='efile.filingcodeindex')), + ], + ), + migrations.RunPython(add_postgres_search_index, remove_postgres_search_index), + ] diff --git a/efile_app/efile/migrations/0034_sqlite_filing_search_index.py b/efile_app/efile/migrations/0034_sqlite_filing_search_index.py new file mode 100644 index 00000000..a988cf08 --- /dev/null +++ b/efile_app/efile/migrations/0034_sqlite_filing_search_index.py @@ -0,0 +1,51 @@ +"""Give local SQLite searches the same indexed token lookup as PostgreSQL.""" + +from django.db import migrations + + +def add_sqlite_search_index(apps, schema_editor): + if schema_editor.connection.vendor != "sqlite": + return + # Tokens are already normalized, stemmed and expanded by the application. + # Store postings only: the path table remains the source of document data. + # No positional data is needed for our AND-of-whole-token queries. Retain + # columns so jurisdiction and new/existing-case filters use postings too. + schema_editor.execute( + "CREATE VIRTUAL TABLE filing_code_search_fts USING fts5(" + "search_text, index_id, initial, content='efile_filingcodepath', content_rowid='id', " + "tokenize='ascii', detail='column', columnsize=0)" + ) + schema_editor.execute( + "CREATE TRIGGER filing_code_search_insert AFTER INSERT ON efile_filingcodepath BEGIN " + "INSERT INTO filing_code_search_fts(rowid, search_text, index_id, initial) " + "VALUES (new.id, new.search_text, new.index_id, new.initial); END" + ) + schema_editor.execute( + "CREATE TRIGGER filing_code_search_delete AFTER DELETE ON efile_filingcodepath BEGIN " + "INSERT INTO filing_code_search_fts(filing_code_search_fts, rowid, search_text, index_id, initial) " + "VALUES ('delete', old.id, old.search_text, old.index_id, old.initial); END" + ) + schema_editor.execute( + "CREATE TRIGGER filing_code_search_update AFTER UPDATE OF id, search_text, index_id, initial " + "ON efile_filingcodepath BEGIN " + "INSERT INTO filing_code_search_fts(filing_code_search_fts, rowid, search_text, index_id, initial) " + "VALUES ('delete', old.id, old.search_text, old.index_id, old.initial); " + "INSERT INTO filing_code_search_fts(rowid, search_text, index_id, initial) " + "VALUES (new.id, new.search_text, new.index_id, new.initial); END" + ) + # Triggers maintain future changes; existing installations also need their + # already-downloaded catalog indexed. No court API calls are necessary. + schema_editor.execute("INSERT INTO filing_code_search_fts(filing_code_search_fts) VALUES ('rebuild')") + + +def remove_sqlite_search_index(apps, schema_editor): + if schema_editor.connection.vendor != "sqlite": + return + for name in ("insert", "delete", "update"): + schema_editor.execute(f"DROP TRIGGER filing_code_search_{name}") + schema_editor.execute("DROP TABLE filing_code_search_fts") + + +class Migration(migrations.Migration): + dependencies = [("efile", "0033_filing_code_search")] + operations = [migrations.RunPython(add_sqlite_search_index, remove_sqlite_search_index)] diff --git a/efile_app/efile/models.py b/efile_app/efile/models.py index d725d2c8..a8add5de 100644 --- a/efile_app/efile/models.py +++ b/efile_app/efile/models.py @@ -8,6 +8,32 @@ from efile.workflow import ExistingCase, WorkflowStepKey, get_workflow_step_choices +class FilingCodeIndex(models.Model): + """One complete, last-known-good code snapshot per jurisdiction and EFSP.""" + + jurisdiction = models.CharField(max_length=20, unique=True) + source_url = models.URLField() + refreshed_at = models.DateTimeField() + rules_digest = models.CharField(max_length=64) + vocabulary = models.JSONField(default=dict) + + +class FilingCodePath(models.Model): + """A real court/category/case-type/filing-type path, never a cross product.""" + + index = models.ForeignKey(FilingCodeIndex, on_delete=models.CASCADE, related_name="paths") + initial = models.BooleanField() + court = models.JSONField() + case_category = models.JSONField() + case_type = models.JSONField() + filing_type = models.JSONField() + search_text = models.TextField() + filing_terms = models.TextField() + case_terms = models.TextField() + explanation = models.TextField(blank=True) + explanation_source = models.URLField(blank=True) + + class UserProfile(AbstractUser): """ Extended user profile to store eFile registration information. diff --git a/efile_app/efile/services/case_type_guidance.py b/efile_app/efile/services/case_type_guidance.py new file mode 100644 index 00000000..3bbac891 --- /dev/null +++ b/efile_app/efile/services/case_type_guidance.py @@ -0,0 +1,263 @@ +"""Curated, data-driven case guidance and conservative contextual search facets.""" + +import re +from decimal import Decimal +from functools import lru_cache +from pathlib import Path + +import yaml + + +@lru_cache(maxsize=1) +def guidance_config(): + return yaml.safe_load((Path(__file__).parent.parent / "data/case_type_guidance.yaml").read_text()) + + +@lru_cache(maxsize=256) +def topic_config(jurisdiction, topic, court_name=""): + data = guidance_config() + if jurisdiction not in data["jurisdictions"] or topic not in data["topics"]: + return {} + config = data["topics"][topic] + state = config.get("jurisdictions", {}).get(jurisdiction, {}) + county = {} + for entry in state.get("counties", {}).values(): + if court_name in entry.get("court_names", []) or any( + court_name.startswith(p) for p in entry.get("court_prefixes", []) + ): + county = entry + break + court = state.get("courts", {}).get(court_name, {}) + # Local wording overrides the generated synopsis; classification rules stay + # at the state level so filtering and path descriptions cannot disagree. + local = {key: value for key, value in {**county, **court}.items() if key in ("text", "source")} + return {**config, **state, **local, "_local_text": local.get("text", "")} + + +@lru_cache(maxsize=512) +def matches(pattern, text): + text = re.sub(r"\b(?:excluding|except)\b[^)]*", "", text, flags=re.I) + return bool(re.search(pattern, text, re.I)) + + +@lru_cache(maxsize=32768) +def case_topic(jurisdiction, category, case_type): + # Procedural tracks such as small claims must survive a more specific + # case label (e.g. a contract claim inside the small-claims category). + small = topic_config(jurisdiction, "small_claims") + if small and matches(small["pattern"], f"{category} {case_type}"): + return "small_claims" + for text in (case_type, category): + for key in guidance_config()["topics"]: + config = topic_config(jurisdiction, key) + if config and matches(config["pattern"], text): + return key + return "" + + +@lru_cache(maxsize=32768) +def case_guidance(jurisdiction, category, case_type, filing_name="", court_name=""): + topic = case_topic(jurisdiction, category, case_type) + if not topic: + return {} + config = topic_config(jurisdiction, topic, court_name) + result = {"topic": topic, "source": config["source"]} + text = [] + for facet in config["facets"]: + result[facet["key"]] = "unknown" + for option in facet["options"]: + field = option.get("field", "case") + name = filing_name if field == "filing" else f"{case_type} {filing_name}" if field == "both" else case_type + if matches(option["pattern"], name): + result[facet["key"]] = option["value"] + if option.get("text"): + text.append(option["text"]) + break + for rule in config.get("special", []): + if matches(rule["pattern"], case_type): + text.append(rule["text"]) + result["source"] = rule.get("source", result["source"]) + break + for note in config.get("unknown_notes", []): + if result.get(note["when"]) == "unknown" and result.get(note["if_known"]) != "unknown": + text.append(note["text"]) + result["text"] = config.get("_local_text") or " ".join(text) or config["text"] + return result + + +def matches_case_filters(guidance, role="", property_kind="", relief="", *, topic="", filters=None): + choices = filters if filters is not None else {"role": role, "property": property_kind, "relief": relief} + if topic and guidance.get("topic") != topic: + return False + if not any(choices.values()): + return True + return bool(guidance) and all( + not value or guidance.get(key, "unknown") in (value, "unknown") + for key, value in choices.items() + if key != "amount" + ) + + +# Amounts are integer cents. Parse claim brackets, never arbitrary numbers or +# filing fees. Unrecognized labels remain unclassified and are not excluded. +_MONEY = r"\$?\s*(\d[\d,]*(?:\.\d{1,2})?)\s*([kK]?)" + + +def _cents(number, suffix): + return int(Decimal(number.replace(",", "")) * (100000 if suffix else 100)) + + +@lru_cache(maxsize=8192) +def claim_range(name): + if "$" not in name and not re.search(r"\d,\d{3}", name): + return None + if match := re.search(_MONEY + r"\s*(?:to|thru|through|[-–])\s*" + _MONEY, name, re.I): + low, high = _cents(*match.groups()[:2]), _cents(*match.groups()[2:]) + return (low, high) if low <= high else None + if match := re.search(r"(?:up to|not over|not exceeding|under|less than|<=|<)\s*" + _MONEY, name, re.I): + high = _cents(*match.groups()) + exclusive = bool(re.match(r"(?:under|less than|<(?![=]))", match.group(), re.I)) + return 0, high - int(exclusive) + if match := re.search(_MONEY + r"\s*(?:or less|and under)", name, re.I): + return 0, _cents(*match.groups()) + if match := re.search(r"(?:over|more than|greater than|>)\s*" + _MONEY, name, re.I): + return _cents(*match.groups()) + 1, None + return None + + +def matches_amount(label, amount): + if not amount: + return True + bounds = claim_range(label["case_type"]) or claim_range(label["category"]) + if bounds is None: + return True + low, high = (int(part) if part else None for part in amount.split(":")) + return (high is None or bounds[0] <= high) and (bounds[1] is None or low <= bounds[1]) + + +def _money(cents): + return f"${cents / 100:,.2f}".removesuffix(".00") + + +def amount_options(labels): + ranges = { + bounds + for label in labels + if (bounds := claim_range(label["case_type"]) or claim_range(label["category"])) is not None + } + if len(ranges) < 2: + return [] + cuts = sorted({0, *(low for low, _ in ranges), *(high + 1 for _, high in ranges if high is not None)}) + bands = [] + for i, low in enumerate(cuts): + high = cuts[i + 1] - 1 if i + 1 < len(cuts) else None + members = frozenset(bounds for bounds in ranges if bounds[0] <= low and (bounds[1] is None or low <= bounds[1])) + if not members: + continue + if bands and bands[-1][2] == members and bands[-1][1] == low - 1: + bands[-1] = (bands[-1][0], high, members) + else: + bands.append((low, high, members)) + return [ + { + "value": f"{low}:{high if high is not None else ''}", + "label": f"{_money(low)} or more" + if high is None + else f"Up to {_money(high)}" + if low == 0 + else f"{_money(low)}–{_money(high)}", + } + for low, high, _ in bands + ] + + +def validate_case_filters(jurisdiction, topic, choices): + if not isinstance(choices, dict) or len(choices) > 8: + raise ValueError("Choose valid case-type filters.") + if topic in ("", "all"): + if choices: + raise ValueError("Choose a case area before narrowing its details.") + return + config = topic_config(jurisdiction, topic) + if not config: + raise ValueError("Choose a supported case area.") + allowed = {facet["key"]: {option["value"] for option in facet["options"]} for facet in config["facets"]} + for key, value in choices.items(): + if not isinstance(value, str): + raise ValueError("Choose valid case-type filters.") + if key == "amount" and topic == "small_claims" and re.fullmatch(r"\d{1,12}:\d{0,12}", value): + low, high = value.split(":") + if not high or int(low) <= int(high): + continue + if key not in allowed or value not in allowed[key] | {""}: + raise ValueError("Choose valid case-type filters.") + + +def facet_metadata(jurisdiction, query, labels, topic="", choices=None): + available = {case_topic(jurisdiction, label["category"], label["case_type"]) for label in labels} - {""} + selected = topic + if not selected: + inferred = [key for key in available if matches(topic_config(jurisdiction, key)["query_pattern"], query)] + selected = inferred[0] if len(inferred) == 1 else next(iter(available)) if len(available) == 1 else "" + config = topic_config(jurisdiction, selected) + if config: + available.add(selected) + facets = [] + for facet in config.get("facets", []): + # Keep alternatives visible, but hide dimensions with no known labels. + known = { + case_guidance(jurisdiction, label["category"], label["case_type"], label["name"]).get( + facet["key"], "unknown" + ) + for label in labels + if case_topic(jurisdiction, label["category"], label["case_type"]) == selected + } + if known - {"unknown"} or (choices or {}).get(facet["key"]): + facets.append( + { + "key": facet["key"], + "label": facet["label"], + "options": [{"value": option["value"], "label": option["label"]} for option in facet["options"]], + } + ) + if selected == "small_claims": + applicable = [ + label + for label in labels + if case_topic(jurisdiction, label["category"], label["case_type"]) == selected + and matches_case_filters( + case_guidance(jurisdiction, label["category"], label["case_type"], label["name"]), filters=choices or {} + ) + ] + options = amount_options(applicable) + selected_amount = (choices or {}).get("amount") + if selected_amount and not any(option["value"] == selected_amount for option in options): + low, high = selected_amount.split(":") + options.append( + { + "value": selected_amount, + "label": f"{_money(int(low))}–{_money(int(high))}" if high else f"{_money(int(low))} or more", + } + ) + limit = config.get("usual_limit_cents") + if limit: + for option in options: + low, high = option["value"].split(":") + if int(low) > limit or not high or int(high) > limit: + option["label"] += " (check small-claims limit)" + if options or selected_amount: + facets.append( + { + "key": "amount", + "label": "Claim amount", + "control": "select", + "options": options, + "help": config.get("amount_help", ""), + "source": config["source"], + } + ) + return { + "case_topics": [{"value": key, "label": topic_config(jurisdiction, key)["label"]} for key in sorted(available)], + "case_topic": selected, + "case_facets": facets, + } diff --git a/efile_app/efile/services/filing_code_search.py b/efile_app/efile/services/filing_code_search.py new file mode 100644 index 00000000..2c1198cd --- /dev/null +++ b/efile_app/efile/services/filing_code_search.py @@ -0,0 +1,968 @@ +"""Deterministic search of complete EFSP paths, with portable local storage. + +Snowball stems and YAML concepts are materialized when refreshing the index. +PostgreSQL uses an indexed simple tsvector; SQLite uses an FTS5 inverted index. +No external service is called while searching. Selection is revalidated live. +""" + +from __future__ import annotations + +import gzip +import hashlib +import json +import re +import time +import unicodedata +from concurrent.futures import ThreadPoolExecutor +from contextlib import closing, nullcontext +from difflib import get_close_matches +from functools import lru_cache +from itertools import islice +from pathlib import Path +from tempfile import NamedTemporaryFile, TemporaryDirectory, TemporaryFile +from threading import Lock, local +from urllib.parse import quote + +import requests +import snowballstemmer +import yaml +from django.conf import settings +from django.contrib.postgres.search import SearchQuery, SearchVector +from django.core.cache import cache +from django.db import connection, transaction +from django.db.models import Case, Count, IntegerField, Max, Q, Value, When +from django.db.models.expressions import RawSQL +from django.utils import timezone +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +from efile.models import FilingCodeIndex, FilingCodePath +from efile.services.case_type_guidance import ( + case_guidance, + facet_metadata, + matches_amount, + matches_case_filters, + validate_case_filters, +) +from efile.services.court_selection import is_non_filing_court +from efile.services.filing_availability import filing_unavailable_message + +FACETS = ("court", "case_category", "case_type", "filing_type") +RULES_PATH = Path(__file__).resolve().parents[1] / "data" / "filing_code_search.yaml" +DEFINITIONS_PATH = RULES_PATH.with_name("filing_concept_definitions.yaml") +_stemmer_local = local() + + +@lru_cache(maxsize=1) +def rules(): + raw = RULES_PATH.read_bytes() + config = yaml.safe_load(raw) + if config.get("version") != 1 or config.get("stemming") != "english": + raise ValueError("Unsupported filing search rules version or stemmer") + # Bump this prefix whenever tokenization changes incompatibly. + return config, hashlib.sha256(b"filing-search-v1\n" + raw).hexdigest() + + +def words(text): + text = unicodedata.normalize("NFKD", str(text)).encode("ascii", "ignore").decode().lower() + return re.findall(r"[a-z0-9]+", text) + + +def stemmer(): + # Snowball keeps mutable cursor state. ASGI runs Django views in threads. + if not hasattr(_stemmer_local, "english"): + _stemmer_local.english = snowballstemmer.stemmer("english") + return _stemmer_local.english + + +@lru_cache(maxsize=32768) +def stems(text): + return stemmer().stemWords(words(text)) + + +@lru_cache(maxsize=8) +def concept_phrases(jurisdiction): + config, _ = rules() + phrases = [] + for name, concept in config["concepts"].items(): + if jurisdiction not in concept.get("jurisdictions", config["jurisdictions"]): + continue + for term in concept["terms"]: + phrases.append((stems(term), "concept" + re.sub(r"[^a-z0-9]", "", name))) + return sorted(phrases, key=lambda pair: -len(pair[0])) + + +@lru_cache(maxsize=32768) +def search_tokens(text, jurisdiction, *, query=False): + tokens = stems(text) + concepts = set() + consumed = set() + for phrase, concept in concept_phrases(jurisdiction): + for start in range(len(tokens) - len(phrase) + 1): + positions = set(range(start, start + len(phrase))) + if query and consumed & positions: + continue + if tokens[start : start + len(phrase)] == phrase: + concepts.add(concept) + consumed.update(positions) + stop = set(stems(" ".join(rules()[0]["stop_words"]))) + return concepts | { + token for i, token in enumerate(tokens) if token not in stop and (not query or i not in consumed) + } + + +def packed(tokens): + return " " + " ".join(sorted(tokens)) + " " + + +@lru_cache(maxsize=1) +def concept_definitions(): + config = yaml.safe_load(DEFINITIONS_PATH.read_bytes()) + if config.get("version") != 1: + raise ValueError("Unsupported concept definitions version") + return config + + +def concept_meaning(concept, jurisdiction, court_name=""): + """Resolve display text only, with explicit local scope and precedence.""" + config = concept_definitions() + definition = config.get("concepts", {}).get(concept, {}) + result = { + "key": concept, + "label": definition.get("label", concept.replace("_", " ").capitalize()), + "text": "", + "source": "", + "scope": "general", + "scope_name": "", + } + + def apply(override, scope, name=""): + if "text" in override: + # A local definition must not silently inherit a statewide source + # that may not support the local meaning. + result.update(text=override["text"], source=override.get("source", ""), scope=scope, scope_name=name) + if "label" in override: + result["label"] = override["label"] + + apply(definition.get("default", {}), "general") + state = definition.get("jurisdictions", {}).get(jurisdiction, {}) + apply(state, "state", jurisdiction.replace("_", " ").title()) + if court_name: + normalized = court_name.casefold().strip() + for county, selectors in config.get("county_courts", {}).get(jurisdiction, {}).items(): + exact = any(normalized == name.casefold().strip() for name in selectors.get("names", [])) + prefix = any(normalized.startswith(name.casefold()) for name in selectors.get("prefixes", [])) + if exact or prefix: + apply(state.get("counties", {}).get(county, {}), "county", county) + break + for name, override in state.get("courts", {}).items(): + if normalized == name.casefold().strip(): + apply(override, "court", court_name) + break + return result + + +def search_context(jurisdiction, query, labels, *, court_name="", corrected=(), resolved_query=None): + """Explain discovery, without claiming a matched topic defines a document. + + A group contains filing labels only; a path also provides its case/court + labels. Query concepts can match that associated context, not the document. + """ + query_tokens = search_tokens(resolved_query if resolved_query is not None else query, jurisdiction, query=True) + name_tokens = set().union(*(search_tokens(name, jurisdiction, query=True) for _, name in labels)) + related = [] + meanings = [] + for key, concept in rules()[0]["concepts"].items(): + if jurisdiction not in concept.get("jurisdictions", rules()[0]["jurisdictions"]): + continue + token = "concept" + re.sub(r"[^a-z0-9]", "", key) + if token in query_tokens | name_tokens: + meanings.append(concept_meaning(key, jurisdiction, court_name)) + if token in query_tokens: + related.append(key) + normalized_query = " ".join(words(query)) + filing_names = [name for facet, name in labels if facet == "filing_type"] + title = filing_names[0] if filing_names else "" + literal = normalized_query and normalized_query in " ".join(words(title)) + query_words = set(stems(query)) - set(stems(" ".join(rules()[0]["stop_words"]))) + title_words = set(stems(title)) + overlap = bool(query_words) and len(query_words & title_words) / len(query_words) >= 0.6 + reason = None + if query and not literal and not overlap: + if any(normalized_query and normalized_query in " ".join(words(name)) for name in filing_names[1:]): + kind = "alternate_name" + elif related: + kind = "related_concept" + elif corrected: + kind = "spelling" + else: + kind = "context" + reason = {"kind": kind, "query": query, "concepts": related, "corrected_terms": list(corrected)} + return {"concepts": meanings, "match_reason": reason} + + +@lru_cache(maxsize=8) +def explanation_rules(jurisdiction, initial): + compiled = [] + facets = set() + for rule in rules()[0]["explanations"]: + if jurisdiction not in rule["jurisdictions"]: + continue + if "initial" in rule and rule["initial"] != initial: + continue + constraints = {facet: {tuple(words(label)) for label in rule[facet]} for facet in FACETS if facet in rule} + facets.update(constraints) + compiled.append((constraints, rule["text"], rule["source"])) + return tuple(facet for facet in FACETS if facet in facets), compiled + + +@lru_cache(maxsize=32768) +def _explanation_for_names(jurisdiction, initial, names): + facets, compiled = explanation_rules(jurisdiction, initial) + normalized = dict(zip(facets, (tuple(words(name)) for name in names), strict=True)) + for constraints, text, source in compiled: + if all(normalized[facet] in labels for facet, labels in constraints.items()): + return text, source + return "", "" + + +def explanation_for(jurisdiction, path, initial): + # Catalogs repeat the same filing labels across thousands of valid paths. + # Cache only the facets that rules actually constrain, preserving exact + # context-specific matches without re-normalizing every rule for every row. + facets, _ = explanation_rules(jurisdiction, initial) + return _explanation_for_names(jurisdiction, initial, tuple(path[facet]["name"] for facet in facets)) + + +def source_url(): + return settings.EFSP_URL.rstrip("/") + + +class CodeCatalog: + """Strict API reader: a failed branch must not publish a partial index.""" + + def __init__(self, jurisdiction, *, timeout=(10, 60)): + if jurisdiction not in rules()[0]["jurisdictions"]: + raise ValueError("Unsupported jurisdiction") + self.base = f"{source_url()}/jurisdictions/{jurisdiction}/codes/courts/" + self._local = local() + self._sessions = [] + self._session_lock = Lock() + self.timeout = timeout + + @property + def session(self): + # Keep cookies and connection pools private to each catalog thread. + if not hasattr(self._local, "session"): + session = requests.Session() + retry = Retry( + total=3, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504], allowed_methods=["GET"] + ) + session.mount("https://", HTTPAdapter(max_retries=retry)) + session.mount("http://", HTTPAdapter(max_retries=retry)) + self._local.session = session + with self._session_lock: + self._sessions.append(session) + return self._local.session + + def close(self): + for session in self._sessions: + session.close() + + def get(self, suffix="", **params): + # Adapter retries don't cover failures while Requests consumes a + # streamed response body. Retry that whole read as well. + for attempt in range(4): + response = None + try: + response = self.session.get(self.base + suffix, params=params, timeout=self.timeout) + response.raise_for_status() + data = response.json() + break + except (requests.Timeout, requests.ConnectionError): + if attempt == 3: + raise + time.sleep(2**attempt) + finally: + if response is not None: + response.close() + if not isinstance(data, list): + raise ValueError("Court code API did not return a list") + result = {} + for item in data: + if not isinstance(item, dict) or item.get("code") is None or not item.get("name"): + raise ValueError("Court code API returned an invalid option") + code = str(item["code"]) + if not code: + raise ValueError("Court code API returned an empty code") + result[code] = {"code": code, "name": str(item["name"])} + return list(result.values()) + + def courts(self): + return [c for c in self.get(fileable_only="false", with_names="true") if not is_non_filing_court(c["name"])] + + def categories(self, court, initial=True): + return self.get( + quote(court, safe="") + "/categories", + fileable_only="true", + timing="Initial" if initial else "Subsequent", + ) + + def types(self, court, category, initial=True): + return self.get( + quote(court, safe="") + "/case_types/", + category_id=category, + timing="Initial" if initial else "Subsequent", + ) + + def filings(self, court, category, case_type, initial): + return self.get( + quote(court, safe="") + "/filing_types/", + category_id=category, + type_id=case_type, + initial=str(initial).lower(), + ) + + +def catalog_entries(catalog, jurisdiction, progress, cache_dir=None): + # Stage courts concurrently so a slow court does not idle the whole build. + # A shared pool bounds filing-type requests across all four court readers. + # Per-court files keep memory bounded even when results finish out of order. + # Every branch still must succeed before publishing the snapshot. + digest = hashlib.sha256(f"{source_url()}:{rules()[1]}:{jurisdiction}".encode()).hexdigest() + location = Path(cache_dir) / digest if cache_dir else None + with nullcontext(location) if location else TemporaryDirectory(prefix="filing-codes-") as directory: + Path(directory).mkdir(parents=True, exist_ok=True) + with ThreadPoolExecutor(max_workers=8) as filings, ThreadPoolExecutor(max_workers=4) as courts: + + def stage(item): + _, court = item + path = Path(directory) / (hashlib.sha256(court["code"].encode()).hexdigest() + ".json.gz") + if location and path.exists() and time.time() - path.stat().st_mtime < 21600: + if progress: + progress(f"{court['name']} (resuming completed court)") + return path + if progress: + progress(court["name"]) + with NamedTemporaryFile(dir=directory, delete=False) as temporary: + temporary_path = Path(temporary.name) + try: + with gzip.open(temporary_path, "wt", encoding="utf-8", compresslevel=1) as output: + for entry in _court_entries(catalog, jurisdiction, court, filings): + output.write(json.dumps(entry) + "\n") + temporary_path.replace(path) + finally: + temporary_path.unlink(missing_ok=True) + return path + + for path in courts.map(stage, enumerate(catalog.courts())): + with gzip.open(path, "rt", encoding="utf-8") as snapshot: + for line in snapshot: + yield json.loads(line) + if not location: + path.unlink() + + +def _court_entries(catalog, jurisdiction, court, executor): + for initial in (True, False): + for category in catalog.categories(court["code"], initial): + + def fetch_filings(case_type, court=court, category=category, initial=initial): + return case_type, catalog.filings(court["code"], category["code"], case_type["code"], initial) + + # Consume the whole map before advancing these parent facets. + for case_type, filings in executor.map( + fetch_filings, catalog.types(court["code"], category["code"], initial) + ): + for filing in filings: + path = dict(zip(FACETS, (court, category, case_type, filing), strict=True)) + terms = {facet: search_tokens(path[facet]["name"], jurisdiction) for facet in FACETS} + all_terms = set.union(*terms.values()) + all_terms.update(t for facet in FACETS for t in stems(path[facet]["code"])) + explanation, source = explanation_for(jurisdiction, path, initial) + yield dict( + **path, + initial=initial, + search_text=packed(all_terms), + filing_terms=packed(terms["filing_type"]), + case_terms=packed(terms["case_category"] | terms["case_type"]), + explanation=explanation, + explanation_source=source, + ) + + +def refresh_index(jurisdiction, *, progress=None, cache_dir=None): + """Replace a jurisdiction atomically after *every* branch was fetched. + + No database transaction is held open during the slow network traversal. + A later-started refresh cannot be overwritten by an older overlapping run. + """ + started = timezone.now() + vocabulary = set() + indexed_terms = set() + for concept in rules()[0]["concepts"].values(): + if jurisdiction in concept.get("jurisdictions", rules()[0]["jurisdictions"]): + vocabulary.update(word for term in concept["terms"] for word in words(term)) + count = 0 + # Large catalogs share many names but still have many distinct paths. Stage + # on disk, then insert bounded batches: memory does not grow with row count. + with closing(CodeCatalog(jurisdiction)) as catalog, TemporaryFile(mode="w+b") as staging: + with gzip.open(staging, "wt", encoding="utf-8", compresslevel=1) as output: + for entry in catalog_entries(catalog, jurisdiction, progress, cache_dir): + output.write(json.dumps(entry) + "\n") + indexed_terms.update(entry["search_text"].split()) + vocabulary.update(word for facet in FACETS for word in words(entry[facet]["name"])) + count += 1 + if not count: + raise ValueError("No filing paths returned; keeping the previous index") + if progress: + progress(f"Saving {count} filing paths") + staging.seek(0) + with gzip.open(staging, "rt", encoding="utf-8") as snapshot, transaction.atomic(): + index, _ = FilingCodeIndex.objects.get_or_create( + jurisdiction=jurisdiction, + defaults={"source_url": source_url(), "refreshed_at": started, "rules_digest": rules()[1]}, + ) + index = FilingCodeIndex.objects.select_for_update().get(pk=index.pk) + if index.refreshed_at > started: + return 0 + index.paths.all().delete() + saved = 0 + while batch := [FilingCodePath(index=index, **json.loads(line)) for line in islice(snapshot, 500)]: + FilingCodePath.objects.bulk_create(batch, batch_size=500) + saved += len(batch) + if progress and saved % 100000 == 0: + progress(f"Saved {saved} of {count} filing paths") + index.source_url = source_url() + index.refreshed_at = started + index.rules_digest = rules()[1] + index.vocabulary = {"words": sorted(vocabulary), "tokens": sorted(indexed_terms)} + index.save() + return count + + +def current_index(jurisdiction): + return FilingCodeIndex.objects.filter( + jurisdiction=jurisdiction, + source_url=source_url(), + rules_digest=rules()[1], + ).first() + + +def case_description(path): + """Use existing case-specific explanations or normalized case meanings.""" + jurisdiction = path.index.jurisdiction + for rule in rules()[0]["explanations"]: + if "case_type" not in rule or jurisdiction not in rule["jurisdictions"]: + continue + if "initial" in rule and rule["initial"] != path.initial: + continue + if all( + tuple(words(getattr(path, facet)["name"])) in {tuple(words(name)) for name in rule[facet]} + for facet in FACETS + if facet in rule + ): + return rule["text"] + guidance = case_guidance( + jurisdiction, path.case_category["name"], path.case_type["name"], path.filing_type["name"], path.court["name"] + ) + if guidance.get("text"): + return guidance["text"] + meanings = search_context(jurisdiction, "", [("case_type", path.case_type["name"])], court_name=path.court["name"])[ + "concepts" + ] + return next((meaning["text"] for meaning in meanings if meaning.get("text")), "") + + +def serialize_path(path, *, query="", corrected=(), resolved_query=None): + return { + "id": path.pk, + **{facet: getattr(path, facet) for facet in FACETS}, + "initial": path.initial, + "explanation": path.explanation, + "explanation_source": path.explanation_source, + "facets": filing_facets(path.filing_type["name"], path.index.jurisdiction), + "case_description": case_description(path), + "case_guidance": case_guidance( + path.index.jurisdiction, + path.case_category["name"], + path.case_type["name"], + path.filing_type["name"], + path.court["name"], + ), + "case_context": case_context_label(path.case_category["name"], path.case_type["name"]), + **search_context( + path.index.jurisdiction, + query, + [(facet, getattr(path, facet)["name"]) for facet in ("filing_type", "case_type", "case_category", "court")], + court_name=path.court["name"], + corrected=corrected, + resolved_query=resolved_query, + ), + "unavailable": filing_unavailable_message( + path.index.jurisdiction, + path.court["code"], + case_category=path.case_category["name"], + case_type=path.case_type["name"], + filing_types=[path.filing_type["name"]], + ), + } + + +def corrected_search_query(index, query): + vocabulary = set(index.vocabulary["words"]) + indexed_terms = set(index.vocabulary["tokens"]) + corrected = [] + query_words = words(query) + # Correct before stemming and phrase expansion. A typo in "eviction" can + # prevent Snowball from recognizing its suffix; the full word must be in + # the vocabulary even if this state's labels only say "summary process". + for position, word in enumerate(query_words): + if len(word) < 5 or not word.isalpha() or word in vocabulary or set(stems(word)) <= indexed_terms: + continue + matches = get_close_matches(word, sorted(vocabulary), n=1, cutoff=0.86) + if matches: + query_words[position] = matches[0] + corrected.append(matches[0]) + return " ".join(query_words), corrected + + +def matching_paths(index, query, initial): + resolved_query, corrected = corrected_search_query(index, query) + tokens = search_tokens(resolved_query, index.jurisdiction, query=True) + if not tokens: + return index.paths.none().annotate(score=Value(0)), [] + paths = index.paths.filter(initial=initial) + if connection.vendor == "postgresql": + search = SearchQuery(" ".join(sorted(tokens)), config="simple") + paths = paths.annotate(document=SearchVector("search_text", config="simple")).filter(document=search) + elif connection.vendor == "sqlite": + # Look up candidate row IDs before ranking/grouping. LIKE '% token %' + # scanned every path in the jurisdiction (millions for Illinois). + # Quote each normalized token and bind the MATCH expression as data; + # user punctuation must never become FTS operators or SQL syntax. + terms = " AND ".join('"' + token.replace('"', '""') + '"' for token in sorted(tokens)) + expression = f'index_id : "{int(index.pk)}" AND initial : "{int(initial)}" AND search_text : ({terms})' + paths = paths.filter( + pk__in=RawSQL( + "SELECT rowid FROM filing_code_search_fts WHERE filing_code_search_fts MATCH %s", + (expression,), + ) + ) + else: + for token in sorted(tokens): + paths = paths.filter(search_text__contains=f" {token} ") + score = Value(0, output_field=IntegerField()) + for token in sorted(tokens): + for field, weight in (("filing_terms", 8), ("case_terms", 3)): + clause = Q(**{f"{field}__contains": f" {token} "}) + score += Case(When(clause, then=Value(weight)), default=Value(0), output_field=IntegerField()) + return paths.annotate(score=score), corrected + + +def search_paths(index, query, *, initial=True, offset=0, limit=20, names=None, court=None, contexts=None, counties=()): + paths, corrected = matching_paths(index, query, initial) + resolved_query = corrected_search_query(index, query)[0] if corrected else query + if names is not None: + paths = paths.filter(filing_type__name__in=names) + if court: + paths = paths.filter(court__code=court) + if counties: + paths = paths.filter(county_filter(counties)) + if contexts is not None: + paths = paths.filter(context_filter(contexts)) + total = paths.count() + paths = paths.order_by("-score", "court__name", "case_type__name", "filing_type__name", "pk") + return { + "results": [ + serialize_path(path, query=query, corrected=corrected, resolved_query=resolved_query) + for path in paths.select_related("index")[offset : offset + limit] + ], + "total": total, + "corrected_terms": corrected, + } + + +def clean_filing_label(name): + name = re.sub(r"^\s*e[\s-]?filed\s*[-:–—]?\s*", "", name, flags=re.IGNORECASE) + return re.sub(r"\s+filed[.\s]*$", "", name, flags=re.IGNORECASE).strip(" .-") + + +def filing_group_key(name, jurisdiction): + # Group discovery results using the same curated synonyms and stemming as + # search. These are related labels, not interchangeable court codes: the + # filer still chooses and validates an exact court/category/type path. + label = " ".join(words(clean_filing_label(name))) + aliases = { + "answer to complaint": "answer", + "response to complaint": "answer", + "civil complaint": "complaint", + "complaint civil": "complaint", + "motion to the court": "motion", + } + label = aliases.get(label, label) + terms = search_tokens(label, jurisdiction, query=True) or set(words(name)) + parts = words(label) + # Preserve the document's role: "Motion to dismiss" is not a "Dismissal + # of motion", even though a bag of search words would make them identical. + # Allow subject-before-document labels like "Eviction complaint". + role = parts[0] if parts else "" + document_roles = { + "complaint", + "petition", + "answer", + "motion", + "order", + "notice", + "affidavit", + "dismissal", + "application", + "summons", + } + if ( + role not in document_roles + and parts + and parts[-1] in {"complaint", "petition", "answer", "order", "affidavit", "summons"} + ): + role = parts[-1] + if role not in document_roles: + role = "" + return hashlib.sha256((role + ":" + " ".join(sorted(terms))).encode()).hexdigest()[:24] + + +@lru_cache(maxsize=1) +def facet_rules(): + config = yaml.safe_load(RULES_PATH.with_name("filing_code_facets.yaml").read_bytes()) + if config.get("version") != 1: + raise ValueError("Unsupported filing facet rules version") + return [(re.compile(rule["pattern"], re.IGNORECASE), rule) for rule in config["rules"]] + + +def filing_facets(name, jurisdiction): + label = " ".join(words(clean_filing_label(name))) + result = {"purpose": "unknown", "document": "unknown"} + for pattern, rule in facet_rules(): + if jurisdiction not in rule.get("jurisdictions", rules()[0]["jurisdictions"]): + continue + if pattern.search(label): + for facet in result: + if facet in rule: + result[facet] = rule[facet] + return result + return result + + +def case_context_label(category, case_type): + return case_type if words(category) == words(case_type) else f"{category} › {case_type}" + + +def case_context_key(category, case_type): + return hashlib.sha256(json.dumps([words(category), words(case_type)]).encode()).hexdigest()[:24] + + +def context_filter(labels): + condition = Q(pk__in=[]) + for category, case_type in {(label["category"], label["case_type"]) for label in labels}: + condition |= Q(case_category__name=category, case_type__name=case_type) + return condition + + +def case_contexts(labels): + contexts = {} + for label in labels: + category, case_type = label["category"], label["case_type"] + key = (tuple(words(category)), tuple(words(case_type))) + context = contexts.setdefault(key, {"label": case_context_label(category, case_type), "count": 0, "rank": 0}) + context["count"] += label["path_count"] + context["rank"] = max(context["rank"], label["rank"]) + return [ + item["label"] + for item in sorted( + contexts.values(), key=lambda item: (-item["rank"], -item["count"], item["label"].casefold()) + ) + ] + + +def filter_filing_groups( + groups, + jurisdiction, + purpose="", + document="", + role="", + property_kind="", + relief="", + case_topic="", + case_filters=None, +): + selected = [] + for group in groups: + labels = [] + for label in group["_labels"]: + guidance = case_guidance(jurisdiction, label["category"], label["case_type"], label["name"]) + if not matches_case_filters( + guidance, role, property_kind, relief, topic=case_topic, filters=case_filters + ) or not matches_amount(label, (case_filters or {}).get("amount", "")): + continue + facets = filing_facets(label["name"], jurisdiction) + if (not purpose or facets["purpose"] == purpose) and (not document or facets["document"] == document): + labels.append(label) + if not labels: + continue + names = sorted( + {label["name"] for label in labels}, key=lambda name: (len(name), name.isupper(), name.casefold()) + ) + distinct = {} + for name in names: + distinct.setdefault(tuple(words(name)), name) + contexts = case_contexts(labels) + selected.append( + { + **group, + "_labels": labels, + "_names": names, + "variants": list(distinct.values()), + "name": clean_filing_label(names[0]), + "path_count": sum(label["path_count"] for label in labels), + "rank": max(label["rank"] for label in labels), + "case_contexts": contexts[:8], + "case_context_count": len(contexts), + "case_context_options": list( + { + case_context_key(label["category"], label["case_type"]): { + "key": case_context_key(label["category"], label["case_type"]), + "label": case_context_label(label["category"], label["case_type"]), + } + for label in labels + if case_context_label(label["category"], label["case_type"]) in contexts[:8] + }.values() + ), + "facets": { + facet: values.pop() + if len(values := {filing_facets(name, jurisdiction)[facet] for name in names}) == 1 + else "unknown" + for facet in ("purpose", "document") + }, + } + ) + return sorted( + selected, + key=lambda group: ( + -group["rank"], + len(search_tokens(group["name"], jurisdiction, query=True)), + group["name"].casefold(), + ), + ) + + +def zip_counties(jurisdiction, postal_code): + """Keep every county in the local table when a ZIP spans county entries.""" + if not postal_code: + return [] + if jurisdiction != "illinois": + raise ValueError("ZIP filtering is not available for this jurisdiction yet.") + if not re.fullmatch(r"[0-9]{5}(?:-[0-9]{4})?", postal_code): + raise ValueError("Enter a five-digit ZIP code, or clear the ZIP filter.") + from efile.utils.zip_to_county_il import COUNTY_TO_ZIPS_IL + + counties = [name for name, codes in COUNTY_TO_ZIPS_IL.items() if postal_code[:5] in codes] + if not counties: + raise ValueError( + "This ZIP code is not in the Illinois county table. Clear the ZIP filter to search all courts." + ) + return counties + + +def county_filter(counties): + condition = Q(pk__in=[]) + for county in counties: + code = "".join(words(county)) + condition |= Q(court__code__iexact=code) | Q(court__code__istartswith=code + ":") + return condition + + +def filing_groups(index, query, initial, counties=()): + fingerprint = hashlib.sha256(f"{index.pk}:{index.refreshed_at}:{initial}:{query}:{counties}".encode()).hexdigest() + key = f"filing-groups-v5:{fingerprint}" + cached = cache.get(key) + if cached is not None: + return cached + paths, corrected = matching_paths(index, query, initial) + if counties: + paths = paths.filter(county_filter(counties)) + labels = ( + paths.order_by() + .values("filing_type__name", "case_category__name", "case_type__name") + .annotate(path_count=Count("pk"), rank=Max("score")) + ) + groups = {} + for label in labels: + name = label["filing_type__name"] + group_key = filing_group_key(name, index.jurisdiction) + group = groups.setdefault( + group_key, {"key": group_key, "variants": [], "path_count": 0, "rank": 0, "_labels": []} + ) + group["_labels"].append( + { + "name": name, + "path_count": label["path_count"], + "rank": label["rank"], + "category": label["case_category__name"], + "case_type": label["case_type__name"], + } + ) + group["variants"].append(name) + group["path_count"] += label["path_count"] + group["rank"] = max(group["rank"], label["rank"]) + for group in groups.values(): + group["variants"] = sorted( + set(group["variants"]), key=lambda name: (len(name), name.isupper(), name.casefold()) + ) + group["_names"] = group["variants"][:] + distinct = {} + for name in group["variants"]: + distinct.setdefault(tuple(words(name)), name) + group["variants"] = list(distinct.values()) + group["name"] = clean_filing_label(group["variants"][0]) + result = ( + sorted( + groups.values(), + key=lambda group: ( + -group["rank"], + len(search_tokens(group["name"], index.jurisdiction, query=True)), + group["name"].casefold(), + ), + ), + corrected, + ) + cache.set(key, result, timeout=300) + return result + + +def search_grouped_paths( + index, + query, + *, + initial=True, + offset=0, + limit=20, + group_key="", + court="", + purpose="", + document="", + context="", + postal_code="", + role="", + property_kind="", + relief="", + case_topic="", + case_filters=None, +): + counties = zip_counties(index.jurisdiction, postal_code) + groups, corrected = filing_groups(index, query, initial, counties) + resolved_query = corrected_search_query(index, query)[0] if corrected else query + case_filters = case_filters or {} + if any((role, property_kind, relief)): + case_topic = "eviction" + case_filters = { + key: value for key, value in {"role": role, "property": property_kind, "relief": relief}.items() if value + } + validate_case_filters(index.jurisdiction, case_topic, case_filters) + groups = filter_filing_groups(groups, index.jurisdiction, purpose, document) + metadata = facet_metadata( + index.jurisdiction, query, [label for group in groups for label in group["_labels"]], case_topic, case_filters + ) + selected_topic = "" if metadata["case_topic"] == "all" else metadata["case_topic"] + groups = filter_filing_groups(groups, index.jurisdiction, case_topic=selected_topic, case_filters=case_filters) + if not group_key: + return { + "groups": [ + { + **{key: value for key, value in group.items() if not key.startswith("_")}, + **search_context( + index.jurisdiction, + query, + [("filing_type", name) for name in [group["name"], *group["variants"]]], + corrected=corrected, + resolved_query=resolved_query, + ), + } + for group in groups[offset : offset + limit] + ], + **metadata, + "eviction_facets": selected_topic == "eviction", + "location_counties": counties, + "total": len(groups), + "corrected_terms": corrected, + } + group = next((group for group in groups if group["key"] == group_key), None) + if group is None: + raise ValueError("These results have changed. Search again.") + contexts = group["_labels"] if selected_topic or case_filters else None + if context: + contexts = [ + label for label in group["_labels"] if case_context_key(label["category"], label["case_type"]) == context + ] + if not contexts: + raise ValueError("This case type is no longer in these results. Search again.") + if court: + return search_paths( + index, + query, + initial=initial, + offset=offset, + limit=limit, + names=group["_names"], + court=court, + contexts=contexts, + counties=counties, + ) + paths, _ = matching_paths(index, query, initial) + if counties: + paths = paths.filter(county_filter(counties)) + if contexts is not None: + paths = paths.filter(context_filter(contexts)) + courts = ( + paths.filter(filing_type__name__in=group["_names"]) + .order_by("court__name") + .values("court__code", "court__name") + .distinct() + ) + return { + "courts": [{"code": row["court__code"], "name": row["court__name"]} for row in courts], + "corrected_terms": corrected, + } + + +def validate_path(path): + """Re-fetch each parent-child relationship; names come from today's API.""" + catalog = CodeCatalog(path.index.jurisdiction, timeout=(5, 8)) + + def find(options, facet): + chosen = next((o for o in options if o["code"] == getattr(path, facet)["code"]), None) + if chosen is None: + raise ValueError("This filing path is no longer offered. Search again or use the court lists.") + return chosen + + try: + options = {"court": catalog.courts()} + court = find(options["court"], "court") + options["case_category"] = catalog.categories(court["code"], path.initial) + category = find(options["case_category"], "case_category") + options["case_type"] = catalog.types(court["code"], category["code"], path.initial) + case_type = find(options["case_type"], "case_type") + options["filing_type"] = catalog.filings(court["code"], category["code"], case_type["code"], path.initial) + filing = find(options["filing_type"], "filing_type") + finally: + catalog.close() + for facet, option in zip(FACETS, (court, category, case_type, filing), strict=True): + setattr(path, facet, option) + path.explanation, path.explanation_source = explanation_for( + path.index.jurisdiction, + {facet: getattr(path, facet) for facet in FACETS}, + path.initial, + ) + result = serialize_path(path) + result["options"] = options + if result["unavailable"]: + raise ValueError(result["unavailable"]) + return result diff --git a/efile_app/efile/services/filing_path.py b/efile_app/efile/services/filing_path.py index 0a155e67..9c88479e 100644 --- a/efile_app/efile/services/filing_path.py +++ b/efile_app/efile/services/filing_path.py @@ -22,7 +22,7 @@ from django.db import transaction -from efile.models import FilingDocument, FilingDraft, sync_primary_filing_type +from efile.models import FilingDocument, FilingDraft, FilingParty, sync_primary_filing_type from efile.services.fee_quotes import invalidate_fee_quote from efile.workflow import ExistingCase, normalize_existing_case @@ -139,6 +139,51 @@ def change_filing_path(draft: FilingDraft, new_path: str) -> FilingPathChange: } +@transaction.atomic +def clear_changed_classification(draft, court_code, case_category_code, case_type_code): + """Codes and fees below a changed parent cannot carry over to another path.""" + previous = (draft.court_code, draft.case_category_code, draft.case_type_code) + if not any(previous) or previous == (court_code, case_category_code, case_type_code): + return False + FilingDocument.objects.filter(draft=draft).update( + filing_type_code="", + filing_type_name="", + document_type_code="", + document_type_name="", + document_type_confirmed=False, + filing_component_code="", + filing_component_name="", + requested_optional_services=[], + filing_requires_amount_in_controversy=False, + ) + FilingParty.objects.filter(draft=draft).update(party_type="", party_type_name="") + draft.case_subtype_code = "" + draft.case_subtype_name = "" + draft.document_type_code = "" + draft.document_type_name = "" + draft.previous_case_id = "" + draft.case_title = "" + draft.optional_services = [] + invalidate_fee_quote(draft, save=False) + draft.save( + update_fields=[ + "case_subtype_code", + "case_subtype_name", + "document_type_code", + "document_type_name", + "previous_case_id", + "case_title", + "optional_services", + "quoted_fee_total", + "quoted_fee_breakdown", + "quoted_fee_fingerprint", + "updated_at", + ] + ) + sync_primary_filing_type(draft) + return True + + def describe_path_change(change: FilingPathChange) -> str: """What changing the path kept and what it undid, in the filer's terms.""" diff --git a/efile_app/efile/settings_dev.py b/efile_app/efile/settings_dev.py index 52179cff..11fe1760 100644 --- a/efile_app/efile/settings_dev.py +++ b/efile_app/efile/settings_dev.py @@ -103,3 +103,9 @@ conn_max_age=0, # no pooling for dev; immediate close on request end ssl_require=False, ) + +if DATABASES["default"]["ENGINE"] == "django.db.backends.sqlite3": + # Uploads and background workers read before writing. SQLite ignores + # select_for_update(); reserve the writer at BEGIN so those transactions + # wait instead of failing when upgrading a read lock after storage/network IO. + DATABASES["default"].setdefault("OPTIONS", {}).update(transaction_mode="IMMEDIATE", timeout=60) diff --git a/efile_app/efile/static/css/components/filing-code-search.css b/efile_app/efile/static/css/components/filing-code-search.css new file mode 100644 index 00000000..b14dfd02 --- /dev/null +++ b/efile_app/efile/static/css/components/filing-code-search.css @@ -0,0 +1,289 @@ +.code-search-invitation { + margin-block: 1.5rem; + padding: 1.25rem; + background: #edf5ff; + border-radius: 0.75rem; +} + +.code-search { + width: min(64rem, calc(100% - 2rem)); + height: min(54rem, calc(100dvh - 2rem)); + max-height: calc(100dvh - 2rem); + padding: 1rem; + border: 1px solid #cbd5e1; + border-radius: 0.85rem; + color: #172b4d; + overflow-y: auto; +} + +.code-search::backdrop { + background: rgb(20 40 70 / 35%); +} + +.code-search[open] { + display: flex; + flex-direction: column; +} + +.code-search> :not(.code-search__results) { + flex-shrink: 0; +} + +.code-search__heading, +.code-search__actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.code-search__heading h2 { + font-size: 1.5rem; + margin-block-end: 0; +} + +#code-search-help, +#code-search-status { + margin-block: 0.375rem; +} + +.code-search__actions { + justify-content: flex-end; + margin-block-start: 1rem; +} + +.code-search__results { + flex: 1 1 auto; + min-width: 0; + min-height: 8rem; + overflow-y: auto; + overscroll-behavior: contain; +} + +.code-search__result { + padding: 0.5rem; + border-block-end: 1px solid #d6e1ef; + overflow-wrap: anywhere; +} + +.code-search__filters { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.5rem; + margin-block-start: 0.5rem; +} + +.code-search__filters label, +.code-search__meta { + font-size: 0.875rem; +} + +.code-search__group { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.125rem 0.5rem; + align-items: start; +} + +.code-search__group>h3 { + grid-column: 1; + grid-row: 1; + margin: 0; +} + +.code-search__group>button { + grid-column: 2; + grid-row: 1; + padding: 0.25rem 0.5rem; +} + +.code-search__group>.code-search__meta { + grid-column: 1; + grid-row: 2; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.code-search__group>details, +.code-search__group-panel { + grid-column: 1 / -1; +} + +.code-search__group-panel[hidden] { + display: none; +} + +.code-search__context-link { + padding: 0; + text-align: start; + white-space: normal; + vertical-align: baseline; +} + +@media (width <=600px) { + .code-search__filters { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .code-search__filters>div:nth-child(2) { + grid-column: 2; + grid-row: 1; + } + + .code-search__group>button { + font-size: 0.875rem; + } +} + +.code-search__result:has(input:checked) { + background: #edf5ff; + border-radius: 0.5rem; +} + +.code-search__result label { + display: flex; + align-items: baseline; + gap: 0.75rem; + font-weight: 600; +} + +.code-search__result details, +.code-search__result p { + margin-block: 0.5rem; +} + +.code-search__group>details { + margin-block: 0.125rem; +} + +.code-search__path { + padding-inline-start: 1.25rem; +} + +.code-search__path li { + margin-block: 0.25rem; +} + +.code-search__suggestions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-block-end: 1rem; +} + +#code-search-suggestions h3, +.code-search__group h3 { + font-size: 1.125rem; +} + +#code-search-zip-help { + margin-block: 0.25rem 0; +} + +.code-search__path-choice { + position: relative; + padding-block: 0.5rem; +} + +.code-search__path-choice>label { + margin: 0; + padding-inline-end: 5rem; + font-weight: 500; + cursor: pointer; +} + +.code-search__path-choice>details { + margin: 0; + font-size: 0.875rem; +} + +.code-search__path-choice>details>summary { + position: absolute; + inset-block-start: 0.5rem; + inset-inline-end: 0.5rem; +} + +.code-search__path-choice>.code-search__variant, +.code-search__path-choice>.code-search__case-description { + margin: 0.125rem 5rem 0 1.5rem; + font-size: 0.875rem; +} + +.code-search__case-filters { + display: flex; + flex-wrap: wrap; + gap: 0.25rem 1rem; + margin-block: 0.25rem; + font-size: 0.8125rem; +} + +.code-search__case-filters[hidden] { + display: none; +} + +.code-search__case-filters>p { + flex-basis: 100%; + margin: 0; +} + +.code-search__case-filters fieldset { + min-width: 0; + border: 0; + padding: 0; + margin: 0; +} + +.code-search__case-filters legend { + float: none; + width: auto; + font-size: inherit; + font-weight: 600; + margin: 0; +} + +.code-search__case-filters label { + display: inline-flex; + align-items: center; + gap: 0.2rem; + margin-inline-end: 0.5rem; + cursor: pointer; +} + +.code-search__court-controls { + display: grid; + grid-template-columns: auto minmax(0, 18rem) minmax(0, 1fr); + align-items: center; + gap: 0.5rem; + margin-block: 0.5rem; + font-size: 0.875rem; +} + +.code-search__court-controls>p, +.code-search__court-controls>label { + margin: 0; +} + +.code-search__amount-help { + align-self: end; + font-size: 0.8125rem; +} + +.code-search__amount-help[open] { + flex-basis: 100%; +} + +@media (width <=600px) { + .code-search__court-controls { + grid-template-columns: auto minmax(0, 1fr); + } + + .code-search__court-controls>p { + grid-column: 1 / -1; + } +} + +.code-search__group:has(.code-search__group-panel:not([hidden]))>.code-search__meta { + display: none; +} \ No newline at end of file diff --git a/efile_app/efile/static/js/court-selector.js b/efile_app/efile/static/js/court-selector.js index ea725e3e..28604109 100644 --- a/efile_app/efile/static/js/court-selector.js +++ b/efile_app/efile/static/js/court-selector.js @@ -251,6 +251,7 @@ // The newest request whose answer has been drawn (or failed). Anything // below `latest` is still on its way, whatever order older ones return in. let settledRequest = 0; + let latestStart = 0; // orders start() calls against each other let lastSteps = []; let expanded = ""; // the answered question the filer reopened let lastRender = { @@ -558,7 +559,14 @@ return { /** Draw the first questions, picking up a court already chosen. */ - async start(courtCode, guessedCourt) { + async start(courtCode, guessedCourt, { + requireMatch = false + } = {}) { + // Nothing in flight is dropped until this lands: a court that + // cannot be matched (or a failed fetch) leaves the questions, + // and any answer still loading for them, exactly as they were. + const token = ++latestStart; + const before = latest; const query = new URLSearchParams({ jurisdiction }); @@ -570,7 +578,16 @@ }, }); const result = await response.json(); + // A newer start, or an answer the filer gave meanwhile, wins. + if (token !== latestStart || before !== latest) return false; if (!response.ok || !result.success || !result.data.available) return false; + if (requireMatch && result.data.selected?.value !== courtCode) return false; + latest += 1; + settledRequest = latest; + container.removeAttribute("aria-busy"); + editing = null; + expanded = ""; + Object.keys(answers).forEach(key => delete answers[key]); (result.data.steps || []).forEach((step) => { if (step.answer) answers[step.id] = step.answer; }); diff --git a/efile_app/efile/static/js/extraction-review.js b/efile_app/efile/static/js/extraction-review.js index 27813f54..440bee84 100644 --- a/efile_app/efile/static/js/extraction-review.js +++ b/efile_app/efile/static/js/extraction-review.js @@ -251,6 +251,7 @@ params: () => ({ court: fields.court.select.value, guessed_case_category: guesses["case category"] || "", + existing_case: existingCaseWire(), }), }, case_type: { @@ -261,6 +262,7 @@ court: fields.court.select.value, parent: fields.case_category.select.value, guessed_case_type: guesses["case type"] || "", + existing_case: existingCaseWire(), }), }, filing_type: { @@ -522,8 +524,52 @@ filing_type: loadFilerRoles, }; + let applyingSearch = false; + + async function applySearchPath(path) { + applyingSearch = true; + try { + if (courtSelector && !await courtSelector.start(path.court.code, "", { + requireMatch: true + })) { + throw new Error(gettext("This court could not be selected. Try again or use the court questions.")); + } + const stage = form.querySelector(`input[name="existing_case"][value="${path.initial ? "new" : "existing"}"]`); + if (stage && !stage.checked) { + stage.checked = true; + stage.dispatchEvent(new Event("change", { + bubbles: true + })); + // Make the changed answer visible beside the saved summary. + if (pathQuestion) pathQuestion.hidden = false; + changePath?.setAttribute("aria-expanded", "true"); + } + // All four lists were validated together on the server. Publish + // them together so old cascading requests cannot undo this choice. + supersede("court"); + ORDER.forEach(key => { + const field = fields[key]; + field.select.replaceChildren(new Option(gettext("Choose an option"), "")); + path.options[key].forEach(item => field.select.add(new Option(item.name, item.code))); + field.select.value = path[key].code; + field.select.disabled = false; + field.loaded = true; + finishLoading(key); + remember(key); + field.hint.textContent = field.defaultHint; + setMode(key, field.guided ? "edit" : "found"); + }); + await loadFilerRoles(); + availability.check(); + announce(gettext("Filing path updated. Check the choices, then confirm and continue to save them.")); + } finally { + applyingSearch = false; + } + } + ORDER.forEach((key) => { fields[key].select.addEventListener("change", () => { + if (applyingSearch) return; remember(key); availability.check(fields[key].root); fields[key].hint.textContent = fields[key].defaultHint; @@ -533,7 +579,8 @@ form.querySelectorAll('input[name="existing_case"]').forEach((radio) => { radio.addEventListener("change", () => { - if (fields.case_type.select.value) loadList("filing_type"); + if (applyingSearch) return; + if (fields.court.select.value) loadList("case_category"); }); }); @@ -725,5 +772,12 @@ // The guard blocks until a check runs. A guided selector with no court // chosen yet fires no change, so check once the courts are in place. - loadCourts().finally(() => availability.check()); + loadCourts().finally(() => { + availability.check(); + window.filingCodeSearch?.mount({ + jurisdiction: context.jurisdiction, + existingCase: existingCaseWire, + applyPath: applySearchPath, + }); + }); })(); \ No newline at end of file diff --git a/efile_app/efile/static/js/filing-code-search.js b/efile_app/efile/static/js/filing-code-search.js new file mode 100644 index 00000000..ed34d4e5 --- /dev/null +++ b/efile_app/efile/static/js/filing-code-search.js @@ -0,0 +1,616 @@ +(function() { + function mount({ + jurisdiction, + existingCase, + applyPath + }) { + const dialog = document.getElementById("code-search-dialog"); + const open = document.getElementById("open-code-search"); + if (!dialog || !open) return; + const query = document.getElementById("code-search-query"); + const status = document.getElementById("code-search-status"); + const results = document.getElementById("code-search-results"); + const apply = document.getElementById("apply-code-search"); + const more = document.getElementById("code-search-more"); + const suggestions = document.getElementById("code-search-suggestions"); + const purpose = document.getElementById("code-search-purpose"); + const stage = document.getElementById("code-search-stage"); + const documentKind = document.getElementById("code-search-document"); + const postalCode = document.getElementById("code-search-zip"); + if (jurisdiction === "illinois") { + document.getElementById("code-search-zip-filter").hidden = false; + document.getElementById("code-search-zip-help").hidden = false; + } + postalCode.addEventListener("input", () => { + invalidate(); + clearResults(); + timer = setTimeout(() => search(), 350); + }); + const caseFilters = document.getElementById("code-search-case-filters"); + let caseTopic = ""; + let caseChoices = {}; + let caseFilterSchema = ""; + const caseFilterValues = () => ({ + case_topic: caseTopic, + case_filters: JSON.stringify(caseChoices) + }); + caseFilters.addEventListener("change", event => { + const key = event.target.dataset.caseFacet; + if (key === "topic") { + caseTopic = event.target.value; + caseChoices = {}; + } else if (key) { + if (event.target.value) caseChoices[key] = event.target.value; + else delete caseChoices[key]; + } + search(); + }); + + function resetCaseFilters() { + caseTopic = ""; + caseChoices = {}; + caseFilterSchema = ""; + caseFilters.hidden = true; + } + const filters = [purpose, stage, documentKind]; + let generation = 0; + let controller; + let timer; + let selected = null; + let offset = 0; + let applying = false; + + function invalidate() { + generation += 1; + controller?.abort(); + controller = null; + clearTimeout(timer); + selected = null; + apply.disabled = true; + more.hidden = true; + } + + async function request(params, signal) { + const response = await fetch(`/api/filing-code-search/?${new URLSearchParams({ + jurisdiction, zip: postalCode.value.trim(), existing_case: stage.value, purpose: purpose.value, document: documentKind.value, grouped: "true", ...caseFilterValues(), ...params, + })}`, { + signal, + headers: { + Accept: "application/json" + } + }).catch(error => { + if (error.name === "AbortError") throw error; + const unavailable = new Error(gettext("Code search could not be loaded. Try again shortly.")); + unavailable.retryable = true; + throw unavailable; + }); + if (!response.headers.get("content-type")?.includes("application/json")) { + const error = new Error(gettext("Code search could not be loaded. Refresh the page and try again.")); + error.retryable = response.status >= 500; + throw error; + } + const data = await response.json(); + if (!response.ok) { + const error = new Error(data.error || gettext("Code search could not be loaded. Try again.")); + error.retryable = response.status === 503; + throw error; + } + return data; + } + + function element(tag, text, className) { + const node = document.createElement(tag); + if (text) node.textContent = text; + if (className) node.className = className; + return node; + } + + function addSearchContext(data, target) { + const meanings = data.concepts || []; + const reason = data.match_reason?.kind === "related_concept" ? data.match_reason : null; + if (!reason && !meanings.some(item => item.text)) return null; + const details = element("details", "", reason ? "code-search__match-reason" : "code-search__concept-info"); + details.append(element("summary", reason ? gettext("Why is this result shown?") : gettext("About this filing type"))); + target.append(details); + for (const concept of meanings.filter(item => item.text)) { + const definition = element("div", "", "code-search__meaning"); + let scope = gettext("General meaning"); + if (concept.scope === "county") scope = interpolate(gettext("Meaning in %(name)s County"), { + name: concept.scope_name + }, true); + else if (concept.scope !== "general") scope = interpolate(gettext("Meaning in %(name)s"), { + name: concept.scope_name + }, true); + definition.append(element("p", `${concept.label} — ${scope}: ${concept.text}`)); + if (concept.source) { + const source = element("a", gettext("About this concept")); + source.href = concept.source; + source.target = "_blank"; + source.rel = "noopener noreferrer"; + definition.append(source); + } + details.append(definition); + } + if (!reason) return details; + const labels = meanings.filter(item => reason.concepts.includes(item.key)).map(item => item.label); + const text = interpolate(gettext('Your search for “%(query)s” relates to %(concepts)s. This filing name or its associated case information deals with similar concepts.'), { + query: reason.query, + concepts: labels.join(", ") + }, true); + details.insertBefore(element("p", text), details.children[1] || null); + return details; + } + + function addFacetSummary(data, target) { + const facets = data.facets || {}; + const purposes = { + starting: gettext("Starting a claim"), + responding: gettext("Responding to a claim"), + either: gettext("Either side") + }; + const documents = { + main: gettext("Main document"), + attachment: gettext("Attachment / exhibit") + }; + const labels = [purposes[facets.purpose], documents[facets.document]].filter(Boolean); + const contexts = data.case_contexts || []; + if (contexts.length) { + const context = data.case_context_count > 1 ? + interpolate(gettext("%(count)s case types, including %(context)s"), { + context: contexts[0], + count: data.case_context_count + }, true) : contexts[0]; + labels.unshift(context); + } + if (labels.length) { + const summary = element("p", labels.join(" · "), "code-search__meta"); + summary.title = summary.textContent; + target.append(summary); + } + } + + function addResult(path, target = results, group = null) { + const row = element("div", "", group ? "code-search__result code-search__path-choice" : "code-search__result"); + const label = element("label"); + const radio = document.createElement("input"); + radio.type = "radio"; + radio.name = "code-search-result"; + radio.value = path.id; + radio.disabled = Boolean(path.unavailable); + const choiceName = group ? (path.case_context || path.case_type.name) : path.filing_type.name; + label.append(radio, element("span", choiceName)); + row.append(label); + if (!group) { + addFacetSummary(path, row); + row.append(element("p", `${path.case_context || path.case_type.name} · ${path.court.name}`, "code-search__meta")); + } else if (path.filing_type.name.toLowerCase() !== group.name.toLowerCase()) { + row.append(element("p", path.filing_type.name, "code-search__variant")); + } + if (group && path.case_description) { + const description = element("p", path.case_description, "code-search__case-description"); + description.id = `code-search-description-${path.id}`; + radio.setAttribute("aria-describedby", description.id); + row.append(description); + } + const details = element("details"); + details.append(element("summary", group ? gettext("Details") : gettext("Filing path"))); + if (group) { + addFacetSummary(path, details); + } + if (path.explanation) details.append(element("p", path.explanation)); + if (path.case_guidance?.source) { + const source = element("a", gettext("About this case type")); + source.href = path.case_guidance.source; + source.target = "_blank"; + source.rel = "noopener noreferrer"; + details.append(source); + } + if (path.explanation_source) { + const source = element("a", gettext("Court information")); + source.href = path.explanation_source; + source.target = "_blank"; + source.rel = "noopener noreferrer"; + details.append(source); + } + const list = element("ol", "", "code-search__path"); + const facets = [ + ["court", gettext("Court")], + ["case_category", gettext("Case category")], + ["case_type", gettext("Case type")], + ["filing_type", gettext("Filing type")], + ]; + facets.forEach(([key, title]) => list.append(element("li", `${title}: ${path[key].name}`))); + details.append(list); + row.append(details); + if (path.unavailable) row.append(element("p", path.unavailable)); + radio.addEventListener("change", () => { + selected = path; + apply.disabled = false; + for (const item of results.querySelectorAll("details")) item.open = !group && item === details; + }); + target.append(row); + } + + function addGroup(group, searchText) { + const row = element("div", "", "code-search__result code-search__group"); + row.append(element("h3", group.name)); + let info = addSearchContext(group, row); + addFacetSummary(group, row); + if (!info && (group.case_contexts?.length || group.variants.length > 1)) { + info = element("details"); + info.append(element("summary", gettext("About this filing type"))); + row.append(info); + } + if (group.case_contexts?.length) { + info.append(element("p", gettext("Used in these case types:"))); + const contexts = element("ul"); + for (const context of group.case_contexts) { + const item = element("li"); + const option = group.case_context_options?.find(candidate => candidate.label === context); + if (option) { + const link = element("button", context, "btn btn-link code-search__context-link"); + link.type = "button"; + link.addEventListener("click", () => { + contextKey = option.key; + contextLabel.textContent = interpolate(gettext("Case type: %(name)s"), { + name: context + }, true); + contextChoice.hidden = false; + loadCourts(); + }); + item.append(link); + } else item.textContent = context; + contexts.append(item); + } + info.append(contexts); + if (group.case_context_count > group.case_contexts.length) { + info.append(element("p", gettext("More case types are available. Choose a court to see its full filing paths."))); + } + } + if (group.variants.length > 1) { + info.append(element("p", gettext("Also listed as:") + " " + group.variants.slice(1).join("; "))); + } + const choose = element("button", gettext("Choose a court"), "btn btn-outline-primary"); + choose.type = "button"; + choose.setAttribute("aria-label", interpolate(gettext("Choose a court for %(name)s"), { + name: group.name + }, true)); + const panel = element("div", "", "code-search__group-panel"); + panel.hidden = true; + const message = element("p"); + message.setAttribute("role", "status"); + const contextChoice = element("div"); + contextChoice.hidden = true; + const contextLabel = element("span"); + const resetContext = element("button", gettext("Show all case types"), "btn btn-link"); + resetContext.type = "button"; + contextChoice.append(contextLabel, resetContext); + const label = element("label", gettext("Court")); + const court = element("select", "", "form-select form-select-sm"); + court.id = `code-search-court-${group.key}`; + label.htmlFor = court.id; + const paths = element("div"); + const next = element("button", gettext("Show more paths in this court"), "btn btn-link"); + next.type = "button"; + next.hidden = true; + const courtControls = element("div", "", "code-search__court-controls"); + courtControls.append(label, court, message); + panel.append(contextChoice, courtControls, paths, next); + row.append(choose, panel); + results.append(row); + const token = generation; + const signal = controller.signal; + let detailGeneration = 0; + let courtGeneration = 0; + let contextKey = ""; + let pathOffset = 0; + + async function loadPaths(append = false) { + const detailToken = ++detailGeneration; + if (!append) { + pathOffset = 0; + paths.replaceChildren(); + selected = null; + apply.disabled = true; + for (const input of results.querySelectorAll('input[name="code-search-result"]')) input.checked = false; + } + next.hidden = true; + if (!court.value) { + message.textContent = gettext("Choose a court to see its case types and filing paths."); + return; + } + message.textContent = gettext("Loading filing paths…"); + try { + const data = await request({ + q: searchText, + group: group.key, + context: contextKey, + court: court.value, + offset: pathOffset + }, signal); + if (token !== generation || detailToken !== detailGeneration || !dialog.open) return; + for (const path of data.results) addResult(path, paths, group); + pathOffset += data.results.length; + message.textContent = data.total ? gettext("Choose your case type.") : gettext("No matching paths in this court."); + next.hidden = pathOffset >= data.total || pathOffset >= 10000; + } catch (error) { + if (token === generation && detailToken === detailGeneration && error.name !== "AbortError") message.textContent = error.message; + } + } + + court.addEventListener("change", () => loadPaths()); + next.addEventListener("click", () => loadPaths(true)); + async function loadCourts() { + const courtToken = ++courtGeneration; + ++detailGeneration; + paths.replaceChildren(); + selected = null; + apply.disabled = true; + next.hidden = true; + for (const input of results.querySelectorAll('input[name="code-search-result"]')) input.checked = false; + const previousCourt = court.value; + choose.disabled = true; + panel.hidden = false; + court.disabled = true; + message.textContent = gettext("Loading courts…"); + try { + const data = await request({ + q: searchText, + group: group.key, + context: contextKey + }, signal); + if (token !== generation || courtToken !== courtGeneration || !dialog.open) return; + court.replaceChildren(element("option", gettext("Choose a court"))); + court.firstElementChild.value = ""; + const preferred = previousCourt || document.getElementById("court_code")?.value; + const prioritized = []; + for (const item of data.courts) { + if (item.code === preferred) prioritized.unshift(item); + else prioritized.push(item); + } + for (const item of prioritized) { + const option = element("option", item.name); + option.value = item.code; + court.append(option); + } + if (prioritized[0]?.code === preferred) court.value = preferred; + court.disabled = false; + choose.hidden = true; + court.focus(); + await loadPaths(); + } catch (error) { + if (token === generation && courtToken === courtGeneration && error.name !== "AbortError") { + message.textContent = error.message; + choose.disabled = false; + } + } + } + choose.addEventListener("click", loadCourts); + resetContext.addEventListener("click", () => { + contextKey = ""; + contextChoice.hidden = true; + loadCourts(); + }); + } + + function clearResults() { + results.querySelectorAll(".code-search__result").forEach(row => row.remove()); + } + + function facetSelect(facet) { + const wrapper = element("div"); + const id = `code-search-facet-${facet.key}`; + const label = element("label", facet.label); + label.htmlFor = id; + const select = element("select", "", "form-select form-select-sm"); + select.id = id; + select.dataset.caseFacet = facet.key; + const any = element("option", facet.key === "topic" ? gettext("All case areas") : gettext("Any / not sure")); + any.value = facet.key === "topic" ? "all" : ""; + select.append(any); + for (const choice of facet.options) { + const option = element("option", choice.label); + option.value = choice.value; + select.append(option); + } + select.value = facet.key === "topic" ? caseTopic || "all" : caseChoices[facet.key] || ""; + wrapper.append(label, select); + return wrapper; + } + + function facetRadios(facet) { + const fieldset = element("fieldset"); + fieldset.append(element("legend", facet.label)); + for (const choice of [{ + value: "", + label: gettext("Any") + }, ...facet.options]) { + const label = element("label"); + const input = element("input"); + input.type = "radio"; + input.name = `code-search-${facet.key}`; + input.id = `code-search-${facet.key}-${choice.value || "any"}`; + input.dataset.caseFacet = facet.key; + input.value = choice.value; + input.checked = choice.value === (caseChoices[facet.key] || ""); + label.append(input, document.createTextNode(choice.label)); + fieldset.append(label); + } + return fieldset; + } + + function renderCaseFilters(data) { + caseTopic = data.case_topic || ""; + caseFilters.hidden = !data.case_topics.length && !Object.keys(caseChoices).length; + const schema = JSON.stringify([caseTopic, data.case_topics, data.case_facets]); + if (schema === caseFilterSchema) return; + caseFilterSchema = schema; + const focused = caseFilters.contains(document.activeElement) ? document.activeElement.id : ""; + caseFilters.replaceChildren(); + caseFilters.append(facetSelect({ + key: "topic", + label: gettext("Case area"), + options: data.case_topics + })); + for (const facet of data.case_facets) { + caseFilters.append(facet.control === "select" ? facetSelect(facet) : facetRadios(facet)); + if (facet.help) { + const help = element("details", "", "code-search__amount-help"); + help.append(element("summary", gettext("About claim amounts")), element("p", facet.help)); + if (facet.source) { + const source = element("a", gettext("Court guidance")); + source.href = facet.source; + source.target = "_blank"; + source.rel = "noopener noreferrer"; + help.append(source); + } + caseFilters.append(help); + } + } + if (data.case_facets.length) caseFilters.append(element("p", gettext("Options with unspecified details stay included."), "code-search__meta")); + if (focused) document.getElementById(focused)?.focus(); + } + + function showSearchSummary(data) { + if (data.case_topics) renderCaseFilters(data); + const template = data.groups ? + gettext("Showing %(shown)s of %(total)s filing types.") : + gettext("Showing %(shown)s of %(total)s matching paths."); + status.textContent = data.total ? interpolate(template, { + shown: offset, + total: data.total + }, true) : + gettext("No matching paths. Try fewer words, another term, or the court lists."); + if (data.location_counties?.length) status.textContent += " " + interpolate(gettext("Counties: %(names)s."), { + names: data.location_counties.join(", ") + }, true); + if (data.corrected_terms.length) status.textContent += " " + gettext("Similar spellings were included."); + if (data.stale) status.textContent += " " + gettext("These lists may be out of date. We will check your choice with the court."); + more.hidden = offset >= data.total || offset >= 10000; + } + + async function search(append = false) { + const text = query.value.trim(); + if (!append) { + invalidate(); + offset = 0; + clearResults(); + } + suggestions.hidden = Boolean(text); + results.hidden = !text; + if (!text) { + status.textContent = ""; + return; + } + const token = generation; + controller ||= new AbortController(); + more.hidden = true; + status.textContent = gettext("Searching filing paths…"); + try { + const data = await request({ + q: text, + offset + }, controller.signal); + if (token !== generation || !dialog.open) return; + if (data.groups) data.groups.forEach(group => addGroup(group, text)); + else data.results.forEach(path => addResult(path)); + offset += (data.groups || data.results).length; + showSearchSummary(data); + } catch (error) { + if (token === generation && error.name !== "AbortError") { + status.textContent = error.message; + if (error.retryable) { + status.textContent += " " + gettext("We will try again shortly."); + timer = setTimeout(() => search(append), 10000); + } + } + } + } + + open.disabled = false; + open.addEventListener("click", () => { + stage.value = existingCase(); + dialog.showModal(); + query.focus(); + search(); + }); + filters.forEach(filter => filter.addEventListener("change", () => search())); + query.addEventListener("input", () => { + resetCaseFilters(); + invalidate(); + clearResults(); + status.textContent = gettext("Searching filing paths…"); + if (!query.value.trim()) search(); + else timer = setTimeout(() => search(), 250); + }); + suggestions.addEventListener("click", event => { + const button = event.target.closest("[data-code-query]"); + if (!button) return; + query.value = button.dataset.codeQuery; + query.focus(); + search(); + }); + query.addEventListener("keydown", event => { + if (event.key === "Enter") { + event.preventDefault(); + search(); + } + }); + more.addEventListener("click", () => search(true)); + + function close() { + if (!applying) dialog.close(); + } + ["close-code-search", "cancel-code-search"].forEach(id => document.getElementById(id).addEventListener("click", close)); + dialog.addEventListener("cancel", event => { + if (applying) event.preventDefault(); + }); + dialog.addEventListener("keydown", event => { + if (event.key !== "Tab") return; + const controls = Array.from(dialog.querySelectorAll("button, input, select, a[href], summary, [tabindex]")) + .filter(node => node.tabIndex >= 0 && !node.matches(":disabled") && node.getClientRects().length); + const first = controls[0]; + const last = controls[controls.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last?.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first?.focus(); + } + }); + dialog.addEventListener("close", () => { + invalidate(); + open.focus(); + }); + apply.addEventListener("click", async () => { + if (!selected || applying) return; + applying = true; + apply.disabled = true; + query.disabled = true; + filters.forEach(filter => filter.disabled = true); + results.inert = true; + more.disabled = true; + status.textContent = gettext("Checking this path with the court…"); + try { + const data = await request({ + path_id: selected.id + }); + await applyPath(data.path); + dialog.close(); + } catch (error) { + status.textContent = error.message; + apply.disabled = false; + } finally { + applying = false; + query.disabled = false; + filters.forEach(filter => filter.disabled = false); + results.inert = false; + more.disabled = false; + } + }); + } + window.filingCodeSearch = { + mount + }; +})(); \ No newline at end of file diff --git a/efile_app/efile/templates/efile/components/filing_code_search.html b/efile_app/efile/templates/efile/components/filing_code_search.html new file mode 100644 index 00000000..67f6ce62 --- /dev/null +++ b/efile_app/efile/templates/efile/components/filing_code_search.html @@ -0,0 +1,113 @@ +{% load i18n %} +
+ {% translate "Not sure which code to use?" %} +

{% translate "Search in plain language across courts, case categories, case types, and filing types." %}

+ +
+ +
+

{% translate "Find the right filing code" %}

+ +
+

+ {% translate "Search across courts, then narrow your results. Check the case type and filing path before choosing." %} +

+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +

+ +
+ +
+ + +
+
diff --git a/efile_app/efile/templates/efile/extraction_review.html b/efile_app/efile/templates/efile/extraction_review.html index b1686352..7889675b 100644 --- a/efile_app/efile/templates/efile/extraction_review.html +++ b/efile_app/efile/templates/efile/extraction_review.html @@ -5,6 +5,10 @@ {% block title %} {% translate "Review what we found" %} {% endblock title %} +{% block extra_css %} + +{% endblock extra_css %} {% block workflow_content %}
{% translate "Confirm case" %}
@@ -246,6 +250,7 @@

{% translate "Tell us about your case" %}

{% translate "We will use this to find your case in the court's records." %} + {% include "efile/components/filing_code_search.html" %}
{{ court_label }} @@ -520,6 +525,7 @@

{% translate "Tell us about your case" %}

{{ extraction_context|json_script:"extraction-context" }} {% endblock workflow_content %} {% block extra_js %} + diff --git a/efile_app/efile/tests/test_case_type_guidance.py b/efile_app/efile/tests/test_case_type_guidance.py new file mode 100644 index 00000000..f55771f0 --- /dev/null +++ b/efile_app/efile/tests/test_case_type_guidance.py @@ -0,0 +1,165 @@ +"""Case facets distinguish real labels without guessing missing information.""" + +import pytest + +from efile.services.case_type_guidance import ( + amount_options, + case_guidance, + case_topic, + claim_range, + facet_metadata, + guidance_config, + matches_amount, + matches_case_filters, + topic_config, + validate_case_filters, +) + +EXAMPLES = [ + ("eviction", "Eviction", "Residential - Possession Only", "property", "residential"), + ("foreclosure", "Foreclosure", "Residential Mortgage Foreclosure", "property", "residential"), + ("debt", "Civil", "Credit card debt", "issue", "card"), + ("small_claims", "Small Claims", "Contract ($250.01 to $1,000)", "issue", "contract"), + ("contract", "Civil", "Contract - Construction", "issue", "services"), + ("personal_injury", "Personal Injury", "Motor Vehicle", "issue", "vehicle"), + ("property_damage", "Property Damage", "Vehicle Property Damage", "property", "vehicle"), + ("replevin", "Civil", "Replevin", "procedure", "replevin"), + ("divorce", "Dissolution", "Dissolution with children", "children", "with"), + ("parentage", "Family", "Establish Parentage", "issue", "establish"), + ("parenting", "Family", "Parenting time", "issue", "time"), + ("child_support", "Family", "Interstate child support", "issue", "interstate"), + ("protection", "Protection", "Stalking no contact", "issue", "stalking"), + ("adult_guardianship", "Adult Guardianship", "Guardianship of the estate", "scope", "financial"), + ("minor_guardianship", "Minor Guardianship", "Temporary guardianship of minor", "duration", "temporary"), + ("probate", "Probate", "Will contest", "issue", "contest"), + ("name_change", "Miscellaneous", "Adult name change", "person", "adult"), + ("adoption", "Adoption", "Related adult adoption", "relationship", "related"), + ("administrative_review", "Administrative Review", "Unemployment", "agency", "benefits"), + ("employment", "Civil", "Unpaid wages", "issue", "wages"), +] + + +@pytest.mark.parametrize("jurisdiction", ["illinois", "massachusetts", "vermont"]) +@pytest.mark.parametrize(("topic", "category", "name", "facet", "value"), EXAMPLES) +def test_twenty_case_families_have_working_contextual_facets(jurisdiction, topic, category, name, facet, value): + guidance = case_guidance(jurisdiction, category, name, "Complaint") + assert guidance["topic"] == topic + assert guidance[facet] == value + assert guidance["text"] and guidance["source"].startswith("https://") + label = {"category": category, "case_type": name, "name": "Complaint"} + metadata = facet_metadata(jurisdiction, "", [label], topic) + assert any(item["key"] == facet for item in metadata["case_facets"]) + assert matches_case_filters(guidance, topic=topic, filters={facet: value}) + assert not matches_case_filters(guidance, topic=topic, filters={facet: "different"}) + + +def test_catalog_is_complete_and_exclusions_are_not_positive_matches(): + assert set(guidance_config()["topics"]) == {row[0] for row in EXAMPLES} + assert case_topic("illinois", "Small Claims", "Contract - Employment") == "small_claims" + assert ( + case_topic("illinois", "Civil", "Contract - Other (Excluding Business/Employment Dispute and Debt Collection)") + == "contract" + ) + assert ( + case_guidance("illinois", "Employment", "Employment - Other (Excluding Discrimination)")["issue"] == "unknown" + ) + assert case_guidance("illinois", "Contracts", "Commercial")["topic"] == "contract" + assert case_guidance("illinois", "Unknown category", "Other") == {} + + +@pytest.mark.parametrize( + ("name", "expected"), + [ + ("Small Claims ($2,500.01 to $10K)", (250001, 1000000)), + ("Small claims (Up to $2,500)", (0, 250000)), + ("Small Claims $1,001 to $5,000", (100100, 500000)), + ("Small claims less than $500", (0, 49999)), + ("Small claims $500 or less", (0, 50000)), + ("Small claims over $5,000", (500001, None)), + ("Small Claims", None), + ("Filing fee $120", None), + ("Claim 12-34", None), + ], +) +def test_claim_range_parsing(name, expected): + assert claim_range(name) == expected + + +def test_amount_bands_are_disjoint_and_preserve_unknown_codes(): + labels = [ + {"category": "Small Claims", "case_type": name, "name": "Complaint"} + for name in [ + "Contract (Up to $2,500)", + "Contract ($2,500.01 to $10K)", + "Other", + ] + ] + assert amount_options(labels) == [ + {"value": "0:250000", "label": "Up to $2,500"}, + {"value": "250001:1000000", "label": "$2,500.01–$10,000"}, + ] + assert matches_amount(labels[0], "250000:250000") + assert not matches_amount(labels[0], "250001:1000000") + assert matches_amount(labels[1], "250001:1000000") + assert matches_amount(labels[2], "250001:1000000") + assert amount_options(labels[:1]) == [] + + +def test_amount_metadata_is_state_specific_and_only_shown_when_useful(): + labels = [ + {"category": "Small Claims", "case_type": name, "name": "Complaint"} + for name in ["Up to $1,000", "$1,000.01 to $5,000"] + ] + for jurisdiction, text in [("illinois", "$10,000"), ("massachusetts", "$7,000"), ("vermont", "medical debt")]: + data = facet_metadata(jurisdiction, "small claims", labels) + amount = next(facet for facet in data["case_facets"] if facet["key"] == "amount") + assert amount["control"] == "select" and text in amount["help"] + assert all( + facet["key"] != "amount" for facet in facet_metadata("illinois", "small claims", labels[:1])["case_facets"] + ) + + +@pytest.mark.parametrize( + ("topic", "filters"), + [ + ("small_claims", {"amount": "x"}), + ("small_claims", {"amount": "500:100"}), + ("divorce", {"amount": "0:500"}), + ("eviction", {"children": "with"}), + ("no_such_topic", {}), + ("", {"role": "tenant"}), + ("all", {"role": "tenant"}), + ("eviction", []), + ("eviction", {"role": ["tenant"]}), + ], +) +def test_invalid_contextual_filters_are_rejected(topic, filters): + with pytest.raises(ValueError): + validate_case_filters("illinois", topic, filters) + + +def test_local_description_precedence(monkeypatch): + config = guidance_config()["topics"]["name_change"] + monkeypatch.setitem( + config["jurisdictions"], + "illinois", + { + "text": "State wording.", + "counties": { + "Example": {"court_names": ["Example Court", "Example Other Court"], "text": "County wording."} + }, + "courts": {"Example Court": {"text": "Court wording."}}, + }, + ) + topic_config.cache_clear() + case_guidance.cache_clear() + try: + assert case_guidance("illinois", "Civil", "Name change", court_name="Example Court")["text"] == "Court wording." + assert ( + case_guidance("illinois", "Civil", "Name change", court_name="Example Other Court")["text"] + == "County wording." + ) + assert case_guidance("illinois", "Civil", "Name change", court_name="Elsewhere")["text"] == "State wording." + finally: + topic_config.cache_clear() + case_guidance.cache_clear() diff --git a/efile_app/efile/tests/test_filing_code_search.py b/efile_app/efile/tests/test_filing_code_search.py new file mode 100644 index 00000000..bfaebbea --- /dev/null +++ b/efile_app/efile/tests/test_filing_code_search.py @@ -0,0 +1,855 @@ +"""Search preserves real paths, jurisdiction boundaries and changing catalogs.""" + +from copy import deepcopy +from importlib import import_module +from unittest.mock import Mock, patch + +import pytest +import requests +from django.core.management import call_command +from django.core.management.base import CommandError +from django.db import connection, transaction +from django.urls import reverse + +from efile.models import FilingDocument, FilingDraft, FilingParty +from efile.services.filing_code_search import ( + CodeCatalog, + concept_definitions, + concept_meaning, + current_index, + explanation_for, + filing_facets, + filing_group_key, + matching_paths, + refresh_index, + search_context, + search_grouped_paths, + search_paths, + search_tokens, + validate_path, +) +from efile.services.filing_path import clear_changed_classification + +pytestmark = pytest.mark.django_db + + +def option(code, name): + return {"code": code, "name": name} + + +@pytest.fixture +def catalog(): + with patch("efile.services.filing_code_search.CodeCatalog") as factory: + api = factory.return_value + api.courts.return_value = [ + option("housing", "Boston Housing Court"), + option("district", "Boston District Court"), + ] + api.categories.return_value = [option("civil", "Civil")] + api.types.return_value = [option("sp", "Summary Process"), option("debt", "Debt collection")] + + def filings(court, category, case_type, initial): + assert category == "civil" + if case_type == "debt": + return [option("complaint", "Complaint")] if initial else [option("motion", "Motion")] + return ( + [option("spcomplaint", "Summary Process Complaint")] + if initial + else [ + option("answer", "Answer to Summary Process Complaint"), + option("dismiss", "Motion to Dismiss"), + ] + ) + + api.filings.side_effect = filings + yield api + + +@pytest.fixture +def index(catalog): + refresh_index("massachusetts") + return current_index("massachusetts") + + +@pytest.mark.parametrize("jurisdiction", ["illinois", "massachusetts", "vermont"]) +def test_synonyms_search_other_courts_and_all_facets(catalog, jurisdiction): + refresh_index(jurisdiction) + index = current_index(jurisdiction) + for query in ("eviction", "evicted", "unlawful detainer", "summary process", "forcible entry and detainer"): + data = search_paths(index, query) + assert data["total"] == 2 + assert {row["court"]["code"] for row in data["results"]} == {"housing", "district"} + data = search_paths(index, "district civil eviction complaint") + assert data["total"] == 1 + row = data["results"][0] + assert row["court"]["code"] == "district" + assert row["case_category"]["code"] == "civil" + assert row["case_type"]["code"] == "sp" + assert row["filing_type"]["code"] == "spcomplaint" + assert search_paths(index, "debt eviction")["total"] == 0 + + +def test_stems_typos_ranking_and_pagination(index): + assert search_paths(index, "motions to dismiss", initial=False)["total"] == 2 + assert search_paths(index, "complants")["corrected_terms"] + assert search_paths(index, "complants")["total"] == 4 + assert search_paths(index, "eviciton")["total"] == 2 + assert search_paths(index, "eviciton")["corrected_terms"] == ["eviction"] + assert search_paths(index, "unlawfull detainer")["total"] == 2 + assert search_paths(index, "notice", initial=False)["total"] == 0 + assert search_paths(index, "eviction", initial=False)["total"] == 4 + first = search_paths(index, "complaint", limit=1) + second = search_paths(index, "complaint", offset=1, limit=1) + assert first["total"] == 4 + assert first["results"][0]["id"] != second["results"][0]["id"] + assert search_paths(index, "the and")["total"] == 0 + assert search_paths(index, "")["total"] == 0 + + +def test_phrase_concepts_do_not_expand_individual_words(): + assert "concepteviction" not in search_tokens("summary judgment", "massachusetts") + assert "conceptprotection" not in search_tokens("order", "vermont") + assert "conceptanswer" not in search_tokens("complaint", "illinois") + + +def test_synonym_results_explain_concepts_without_changing_matches(index): + groups = search_grouped_paths(index, "unlawful detainer")["groups"] + assert len(groups) == 1 + group = groups[0] + assert group["match_reason"]["kind"] == "related_concept" + assert group["match_reason"]["concepts"] == ["eviction"] + meaning = group["concepts"][0] + assert meaning["scope"] == "state" and meaning["scope_name"] == "Massachusetts" + assert "summary process" in meaning["text"] + assert search_grouped_paths(index, "summary process")["groups"][0]["match_reason"] is None + path = search_paths(index, "unlawful detainer")["results"][0] + assert path["match_reason"]["kind"] == "related_concept" + assert path["concepts"][0]["text"] == meaning["text"] + typo = search_grouped_paths(index, "unlawfull detainer")["groups"][0] + assert typo["match_reason"]["concepts"] == ["eviction"] + assert typo["match_reason"]["corrected_terms"] == ["unlawful"] + + +def test_case_topic_is_not_a_definition_of_the_document(): + context = search_context( + "massachusetts", "unlawful detainer", [("filing_type", "Affidavit"), ("case_type", "Summary Process")] + ) + assert context["match_reason"]["kind"] == "related_concept" + assert context["concepts"][0]["key"] == "eviction" + assert "affidavit" not in context["concepts"][0]["text"].lower() + assert search_context("massachusetts", "summary judgment", [("filing_type", "Motion")])["concepts"] == [] + alternate = search_context( + "massachusetts", + "summary process", + [("filing_type", "Eviction complaint"), ("filing_type", "Summary process complaint")], + ) + assert alternate["match_reason"]["kind"] == "alternate_name" + + +def test_concept_definition_precedence_and_local_scope(monkeypatch): + config = deepcopy(concept_definitions()) + state = config["concepts"]["eviction"]["jurisdictions"]["illinois"] + state["counties"]["Cook"] = {"text": "County-specific explanation.", "source": "https://example.org/county"} + court = "Cook County - Municipal Civil - District 1 - Chicago" + state["courts"][court] = {"text": "Court-specific explanation."} + monkeypatch.setattr("efile.services.filing_code_search.concept_definitions", lambda: config) + assert concept_meaning("eviction", "illinois")["scope"] == "state" + assert concept_meaning("eviction", "illinois", "Cook County - Chancery")["text"] == "County-specific explanation." + assert concept_meaning("eviction", "illinois", "Cook County")["scope"] == "county" + specific = concept_meaning("eviction", "illinois", court) + assert specific["text"] == "Court-specific explanation." + assert specific["scope"] == "court" and specific["source"] == "" + assert concept_meaning("eviction", "illinois", "Cooksville County")["scope"] == "state" + assert concept_meaning("eviction", "vermont", court)["scope_name"] == "Vermont" + assert concept_meaning("answer", "illinois")["scope"] == "general" + + +def test_local_definition_is_applied_after_court_selection_and_without_reindex(index, monkeypatch): + before = search_grouped_paths(index, "unlawful detainer") + config = deepcopy(concept_definitions()) + config["concepts"]["eviction"]["jurisdictions"]["massachusetts"]["courts"] = { + "Boston Housing Court": {"text": "Local explanation.", "source": "https://example.org/local"} + } + monkeypatch.setattr("efile.services.filing_code_search.concept_definitions", lambda: config) + after = search_grouped_paths(index, "unlawful detainer") + assert after == before # No court has been chosen: keep the statewide meaning. + paths = search_grouped_paths(index, "unlawful detainer", group_key=after["groups"][0]["key"], court="housing") + assert paths["results"][0]["concepts"][0]["text"] == "Local explanation." + config["concepts"]["eviction"]["jurisdictions"]["massachusetts"]["text"] = "Updated statewide explanation." + assert ( + search_grouped_paths(index, "unlawful detainer")["groups"][0]["concepts"][0]["text"] + == "Updated statewide explanation." + ) + assert current_index("massachusetts").pk == index.pk + + +def test_every_search_concept_has_a_short_sourced_definition(): + from efile.services.filing_code_search import rules + + for concept in rules()[0]["concepts"]: + for state in rules()[0]["jurisdictions"]: + meaning = concept_meaning(concept, state) + assert meaning["text"] and meaning["source"].startswith("https://") + + +@pytest.mark.parametrize( + ("name", "purpose", "document"), + [ + ("Eviction Complaint", "starting", "main"), + ("Answer to Complaint", "responding", "main"), + ("Motion to Dismiss Complaint", "either", "main"), + ("Exhibit - Complaint", "unknown", "attachment"), + ("Exhibits in support of motion", "unknown", "attachment"), + ("Affidavit supporting complaint", "either", "unknown"), + ("Order on complaint", "unknown", "unknown"), + ("Unfamiliar local code", "unknown", "unknown"), + ], +) +def test_facets_preserve_document_role_and_leave_ambiguous_types_unknown(name, purpose, document): + assert filing_facets(name, "illinois") == {"purpose": purpose, "document": document} + + +def test_facets_filter_groups_before_pagination_and_preserve_exact_paths(catalog): + catalog.filings.return_value = [ + option("complaint", "Complaint"), + option("answer", "Answer to Complaint"), + option("motion", "Motion to Dismiss Complaint"), + option("exhibit", "Exhibit"), + option("unknown", "Unfamiliar local code"), + ] + catalog.filings.side_effect = None + refresh_index("massachusetts") + index = current_index("massachusetts") + all_groups = search_grouped_paths(index, "eviction") + assert all_groups["total"] == 5 + attachments = search_grouped_paths(index, "eviction", document="attachment", limit=1) + assert attachments["total"] == 1 + assert attachments["groups"][0]["name"] == "Exhibit" + assert search_grouped_paths(index, "eviction", document="attachment", offset=1)["groups"] == [] + responses = search_grouped_paths(index, "eviction", initial=False, purpose="responding", document="main") + assert responses["total"] == 1 + group = responses["groups"][0] + assert group["facets"] == {"purpose": "responding", "document": "main"} + paths = search_grouped_paths( + index, "eviction", initial=False, purpose="responding", document="main", group_key=group["key"], court="housing" + ) + assert paths["total"] == 1 and paths["results"][0]["filing_type"]["code"] == "answer" + assert paths["results"][0]["initial"] is False + assert search_grouped_paths(index, "eviction", document="unknown")["groups"][0]["name"] == "Unfamiliar local code" + + +def test_endpoint_validates_and_applies_facets(signed_in, index): + params = { + "jurisdiction": "massachusetts", + "q": "eviction", + "grouped": "true", + "existing_case": "yes", + "purpose": "responding", + "document": "main", + } + result = signed_in.get(URL, params).json() + assert result["total"] == 1 + assert result["groups"][0]["facets"]["purpose"] == "responding" + assert signed_in.get(URL, {**params, "purpose": "unexpected"}).status_code == 400 + assert signed_in.get(URL, {**params, "document": "unexpected"}).status_code == 400 + + +def test_generic_filing_names_include_the_matching_case_context(catalog): + catalog.categories.return_value = [option("small", "Small claims"), option("civil", "Civil")] + catalog.types.side_effect = lambda court, category, initial: ( + [option("small", "Small claims")] + if category == "small" + else [option("eviction", "Eviction"), option("debt", "Debt collection")] + ) + catalog.filings.side_effect = None + catalog.filings.return_value = [option("petition", "Petition")] + refresh_index("massachusetts") + index = current_index("massachusetts") + group = search_grouped_paths(index, "petition")["groups"][0] + assert group["case_context_count"] == 3 + assert set(group["case_contexts"]) == {"Small claims", "Civil › Eviction", "Civil › Debt collection"} + assert group["path_count"] == 6 + matched = search_grouped_paths(index, "eviction")["groups"][0] + assert matched["case_contexts"] == ["Civil › Eviction"] + assert matched["path_count"] == 2 + detail = search_grouped_paths(index, "petition", group_key=group["key"], court="housing") + assert {path["case_context"] for path in detail["results"]} == set(group["case_contexts"]) + small = next(item for item in group["case_context_options"] if item["label"] == "Small claims") + narrowed = search_grouped_paths(index, "petition", group_key=group["key"], court="housing", context=small["key"]) + assert narrowed["total"] == 1 + assert narrowed["results"][0]["case_category"]["code"] == "small" + assert narrowed["results"][0]["case_type"]["code"] == "small" + courts = search_grouped_paths(index, "petition", group_key=group["key"], context=small["key"]) + assert {item["code"] for item in courts["courts"]} == {"housing", "district"} + with pytest.raises(ValueError, match="case type"): + search_grouped_paths(index, "eviction", group_key=group["key"], context=small["key"]) + + +def test_context_preview_is_bounded_without_hiding_the_number_of_case_types(catalog): + catalog.types.return_value = [option(str(i), f"Case type {i}") for i in range(12)] + catalog.filings.side_effect = None + catalog.filings.return_value = [option("petition", "Petition")] + refresh_index("massachusetts") + group = search_grouped_paths(current_index("massachusetts"), "petition")["groups"][0] + assert len(group["case_contexts"]) == 8 + assert group["case_context_count"] == 12 + assert group["path_count"] == 24 + + +def test_sqlite_search_uses_full_text_index(index): + if connection.vendor != "sqlite": + pytest.skip("SQLite query plan") + paths, _ = matching_paths(index, "eviction", True) + plan = paths.explain() + assert "filing_code_search_fts VIRTUAL TABLE INDEX" in plan + assert "rowid=?" in plan + assert 'search_text" LIKE' not in str(paths.query) + # Neither SQL nor FTS query operators in user input can bypass scoping. + assert search_paths(index, 'eviction OR "debt"')["total"] == 0 + + +def test_sqlite_full_text_index_tracks_edits_deletes_and_rollback(index): + if connection.vendor != "sqlite": + pytest.skip("SQLite index triggers") + path = index.paths.filter(initial=True, case_type__code="sp").first() + with pytest.raises(RuntimeError), transaction.atomic(): + index.paths.filter(pk=path.pk).update(search_text=" debt ") + assert search_paths(index, "eviction")["total"] == 1 + assert search_paths(index, "debt")["total"] == 3 + raise RuntimeError("Abort catalog publication") + assert search_paths(index, "eviction")["total"] == 2 + index.paths.filter(pk=path.pk).update(initial=False) + assert search_paths(index, "eviction")["total"] == 1 + assert search_paths(index, "eviction", initial=False)["total"] == 5 + path.delete() + assert search_paths(index, "eviction")["total"] == 1 + assert search_paths(index, "eviction", initial=False)["total"] == 4 + with connection.cursor() as cursor: + cursor.execute("INSERT INTO filing_code_search_fts(filing_code_search_fts, rank) VALUES ('integrity-check', 1)") + + +def test_sqlite_migration_indexes_existing_catalog(index): + if connection.vendor != "sqlite": + pytest.skip("SQLite migration backfill") + migration = import_module("efile.migrations.0034_sqlite_filing_search_index") + editor = connection.schema_editor() + vars(migration)["remove_sqlite_search_index"](None, editor) + vars(migration)["add_sqlite_search_index"](None, editor) + assert search_paths(index, "eviction")["total"] == 2 + assert search_paths(index, "district civil eviction complaint")["total"] == 1 + + +def test_subsequent_only_hierarchy_is_indexed_and_validated(catalog): + catalog.categories.side_effect = lambda court, initial: [option("civil", "Civil")] if not initial else [] + catalog.types.side_effect = ( + lambda court, category, initial: [option("sp", "Summary Process")] if not initial else [] + ) + refresh_index("massachusetts") + index = current_index("massachusetts") + assert search_paths(index, "eviction", initial=True)["total"] == 0 + assert search_paths(index, "eviction", initial=False)["total"] == 4 + path = index.paths.first() + assert validate_path(path)["initial"] is False + catalog.categories.assert_called_with(path.court["code"], False) + catalog.types.assert_called_with(path.court["code"], "civil", False) + + +def test_explanations_are_exact_optional_and_regenerated(index, catalog): + path = index.paths.filter(initial=True, case_type__code="sp").first() + assert path.explanation + raw = {facet: getattr(path, facet) for facet in ("court", "case_category", "case_type", "filing_type")} + raw["filing_type"] = option("spcomplaint", "Motion to dismiss Summary Process Complaint") + assert explanation_for("massachusetts", raw, True) == ("", "") + assert explanation_for("vermont", raw, True) == ("", "") + catalog.filings.side_effect = None + catalog.filings.return_value = [option("spcomplaint", "Renamed code")] + validated = validate_path(path) + assert validated["explanation"] == "" + assert validated["filing_type"]["name"] == "Renamed code" + assert validated["options"]["court"] + + +@pytest.mark.parametrize("facet", ["court", "case_category", "case_type"]) +def test_cached_explanations_preserve_context_and_filing_timing(facet): + from efile.services.filing_code_search import _explanation_for_names, explanation_rules + + config = { + "explanations": [ + { + "jurisdictions": ["illinois"], + "initial": True, + "filing_type": ["Motion"], + facet: ["Specific context"], + "text": "Context-specific explanation", + "source": "https://example.com/court", + } + ] + } + explanation_rules.cache_clear() + _explanation_for_names.cache_clear() + try: + with patch("efile.services.filing_code_search.rules", return_value=(config, "test")): + path = {"filing_type": option("motion", "Motion"), facet: option("context", "Specific context")} + assert explanation_for("illinois", path, True)[0] == "Context-specific explanation" + assert explanation_for("illinois", path, False) == ("", "") + assert explanation_for("vermont", path, True) == ("", "") + path[facet] = option("other", "Other context") + assert explanation_for("illinois", path, True) == ("", "") + finally: + explanation_rules.cache_clear() + _explanation_for_names.cache_clear() + + +def test_failed_refresh_keeps_snapshot_then_success_removes_retired_paths(index, catalog): + ids = list(index.paths.values_list("pk", flat=True)) + stamp = index.refreshed_at + catalog.types.side_effect = requests.Timeout("unavailable") + with pytest.raises(requests.Timeout): + refresh_index("massachusetts") + index.refresh_from_db() + assert index.refreshed_at == stamp + assert list(index.paths.values_list("pk", flat=True)) == ids + catalog.types.side_effect = None + catalog.courts.return_value = [option("district", "Boston District Court")] + refresh_index("massachusetts") + assert not index.paths.filter(court__code="housing").exists() + assert not index.paths.filter(pk__in=ids).exists() + + +def test_empty_refresh_preserves_previous_index(index, catalog): + catalog.courts.return_value = [] + with pytest.raises(ValueError, match="No filing paths"): + refresh_index("massachusetts") + assert index.paths.exists() + + +def test_parallel_filing_request_failure_does_not_publish_partial_index(index, catalog): + ids = list(index.paths.values_list("pk", flat=True)) + catalog.filings.side_effect = requests.Timeout("filing branch unavailable") + with pytest.raises(requests.Timeout): + refresh_index("massachusetts") + assert list(index.paths.values_list("pk", flat=True)) == ids + + +def test_wrong_efsp_or_rules_revision_not_served(index, settings): + settings.EFSP_URL = "https://other.example/api" + assert current_index("massachusetts") is None + settings.EFSP_URL = index.source_url + index.rules_digest = "old" + index.save() + assert current_index("massachusetts") is None + + +def test_live_validation_rejects_removed_or_restricted_path(index, catalog): + path = index.paths.first() + catalog.courts.return_value = [] + with pytest.raises(ValueError, match="no longer offered"): + validate_path(path) + catalog.courts.return_value = [path.court] + with patch("efile.services.filing_code_search.filing_unavailable_message", return_value="Not available here"): + with pytest.raises(ValueError, match="Not available here"): + validate_path(path) + + +def test_search_exposes_availability_without_hiding_other_courts(index): + with patch("efile.services.filing_code_search.filing_unavailable_message", return_value="Temporarily unavailable"): + data = search_paths(index, "eviction") + assert data["total"] == 2 + assert all(row["unavailable"] == "Temporarily unavailable" for row in data["results"]) + + +def test_a_hundred_courts_and_synonyms_become_one_result(catalog): + catalog.courts.return_value = [option(f"court-{i}", f"County {i}") for i in range(100)] + catalog.types.return_value = [option("sp", "Summary Process")] + catalog.filings.side_effect = lambda court, category, case_type, initial: [ + option(f"filing-{court}", "Eviction Complaint" if int(court.split("-")[1]) % 2 else "Summary Process Complaint") + ] + refresh_index("massachusetts") + index = current_index("massachusetts") + grouped = search_grouped_paths(index, "eviction") + assert grouped["total"] == 1 + group = grouped["groups"][0] + assert group["path_count"] == 100 + assert set(group["variants"]) == {"Eviction Complaint", "Summary Process Complaint"} + courts = search_grouped_paths(index, "eviction", group_key=group["key"])["courts"] + assert len(courts) == 100 + detail = search_grouped_paths(index, "eviction", group_key=group["key"], court="court-7") + assert detail["total"] == 1 + assert detail["results"][0]["court"]["code"] == "court-7" + assert detail["results"][0]["filing_type"]["code"] == "filing-court-7" + assert search_grouped_paths(index, "County 7 eviction")["groups"][0]["path_count"] == 1 + assert search_grouped_paths(index, "eviction", group_key=group["key"], court="unknown")["total"] == 0 + + +def test_grouping_keeps_different_documents_separate(): + def key(name): + return filing_group_key(name, "massachusetts") + + assert key("Answer") == key("Answer to Complaint") + assert key("Affidavit") == key("Affidavit filed.") + assert key("Eviction Complaint") == key("E-filed - Eviction Complaint filed.") + assert key("Eviction Complaint") == key("Complaint for Summary Process") + assert key("Eviction") == key("Summary Process") + assert key("Fee waiver") == key("In forma pauperis") + assert key("Divorce") == key("Dissolution of marriage") + assert key("Complaint") != key("Answer to Complaint") + assert key("Motion to Dismiss") != key("Complaint") + assert key("Motion to Dismiss") != key("Dismissal of Motion") + assert key("Verified Complaint") != key("Complaint") + assert key("Proposed Order") != key("Order") + assert key("Answer and Counterclaim") != key("Answer") + assert key("Motion for Summary Judgment") != key("Summary Process Complaint") + + +def test_grouped_endpoint_keeps_court_selection_separate(signed_in, index): + params = {"jurisdiction": "massachusetts", "q": "eviction", "grouped": "true"} + result = signed_in.get(URL, params).json() + assert result["total"] == 1 + assert "results" not in result + params["group"] = result["groups"][0]["key"] + assert len(signed_in.get(URL, params).json()["courts"]) == 2 + params["court"] = "housing" + paths = signed_in.get(URL, params).json()["results"] + assert len(paths) == 1 and paths[0]["court"]["code"] == "housing" + params["group"] = "retired-group" + assert signed_in.get(URL, params).status_code == 409 + + +def test_refresh_resumes_completed_courts_after_late_failure(catalog, tmp_path): + def fail_district(court, category, initial): + if court == "district": + raise requests.Timeout("late failure") + return [option("sp", "Summary Process")] + + catalog.types.side_effect = fail_district + with pytest.raises(requests.Timeout): + refresh_index("massachusetts", cache_dir=tmp_path) + assert current_index("massachusetts") is None + assert len(list(tmp_path.rglob("*.json.gz"))) == 1 + catalog.types.reset_mock() + catalog.types.side_effect = None + catalog.types.return_value = [option("sp", "Summary Process")] + refresh_index("massachusetts", cache_dir=tmp_path) + assert current_index("massachusetts").paths.count() == 6 + assert {call.args[0] for call in catalog.types.call_args_list} == {"district"} + + +def test_catalog_retries_failures_while_reading_response_body(): + with ( + patch("efile.services.filing_code_search.requests.Session") as session, + patch("efile.services.filing_code_search.time.sleep") as sleep, + ): + response = session.return_value.get.return_value + response.json.side_effect = [requests.ConnectionError("body timed out"), [{"code": "civil", "name": "Civil"}]] + api = CodeCatalog("illinois") + assert api.categories("court") == [option("civil", "Civil")] + assert session.return_value.get.call_count == 2 + sleep.assert_called_once_with(1) + api.close() + + +def test_catalog_encodes_codes_and_rejects_malformed_data(): + with patch("efile.services.filing_code_search.requests.Session") as session: + response = session.return_value.get.return_value + response.json.return_value = [{"code": 123, "name": "Civil"}] + api = CodeCatalog("illinois") + assert api.categories("cook:cvd1") == [option("123", "Civil")] + assert "cook%3Acvd1/categories" in session.return_value.get.call_args.args[0] + response.json.return_value = {"error": "oops"} + with pytest.raises(ValueError): + api.courts() + response.json.return_value = [{"name": "No code"}] + with pytest.raises(ValueError): + api.courts() + response.raise_for_status.side_effect = requests.HTTPError() + with pytest.raises(requests.HTTPError): + api.courts() + + +@pytest.fixture +def signed_in(client, django_user_model): + user = django_user_model.objects.create_user(username="searcher", tyler_jurisdiction="massachusetts") + client.force_login(user) + session = client.session + session["auth_tokens"] = {"TYLER-TOKEN-MASSACHUSETTS": "test-token"} + session.save() + return client + + +URL = reverse("api:filing_code_search") + + +def test_endpoint_auth_input_missing_index_and_jurisdiction_isolation(signed_in, index, client): + for query in ("", "x" * 161): + assert signed_in.get(URL, {"jurisdiction": "massachusetts", "q": query}).status_code == 400 + assert signed_in.get(URL, {"jurisdiction": "massachusetts", "q": "m"}).status_code == 200 + assert signed_in.get(URL, {"jurisdiction": "massachusetts", "q": "eviction", "offset": -1}).status_code == 400 + assert signed_in.post(URL).status_code == 405 + index.delete() + assert signed_in.get(URL, {"jurisdiction": "massachusetts", "q": "eviction"}).status_code == 503 + assert signed_in.get(URL, {"jurisdiction": "vermont", "q": "eviction"}).status_code == 403 + assert signed_in.get(URL, {"jurisdiction": "unsupported", "q": "eviction"}).status_code == 400 + client.logout() + assert client.get(URL, {"jurisdiction": "massachusetts", "q": "eviction"}).status_code == 403 + + +def test_endpoint_search_ignores_current_facets_and_validates_without_saving(signed_in, index, catalog): + params = {"jurisdiction": "massachusetts", "q": "eviction", "court": "wrong-court", "case_type": "wrong"} + response = signed_in.get(URL, params) + assert response.status_code == 200 + assert response.json()["total"] == 2 + assert response["Cache-Control"] == "no-store" + path_id = response.json()["results"][0]["id"] + response = signed_in.get(URL, {"jurisdiction": "massachusetts", "path_id": path_id}) + assert response.status_code == 200 + assert response.json()["path"]["options"] + assert not FilingDraft.objects.exists() + assert ( + signed_in.get(URL, {"jurisdiction": "massachusetts", "path_id": path_id, "existing_case": "yes"}).status_code + == 409 + ) + catalog.courts.side_effect = requests.Timeout() + assert signed_in.get(URL, {"jurisdiction": "massachusetts", "path_id": path_id}).status_code == 503 + assert signed_in.get(URL, {"jurisdiction": "massachusetts", "path_id": "bad"}).status_code == 400 + + +def test_management_command_attempts_each_jurisdiction_and_reports_failure(): + with patch( + "efile.management.commands.refresh_filing_code_index.refresh_index", side_effect=[ValueError("bad"), 2, 3] + ) as refresh: + with pytest.raises(CommandError, match="illinois"): + call_command("refresh_filing_code_index", stdout=Mock(), stderr=Mock()) + assert [call.args[0] for call in refresh.call_args_list] == ["illinois", "massachusetts", "vermont"] + + +def test_worker_retries_failed_state_soon_and_skips_fresh_states(index): + from efile.models import FilingCodeIndex + + command = "efile.management.commands.refresh_filing_code_index" + + # Massachusetts is already ready. Vermont succeeds on the first pass; + # Illinois fails once and must be retried without rebuilding either. + attempts = 0 + + def refresh(jurisdiction, **kwargs): + nonlocal attempts + if jurisdiction == "illinois" and attempts == 0: + attempts += 1 + raise requests.Timeout("temporary failure") + FilingCodeIndex.objects.create( + jurisdiction=jurisdiction, + source_url=index.source_url, + rules_digest=index.rules_digest, + refreshed_at=index.refreshed_at, + ) + return 2 + + with ( + patch(f"{command}.refresh_index", side_effect=refresh) as build, + patch(f"{command}.time.sleep", side_effect=[None, KeyboardInterrupt]) as sleep, + ): + with pytest.raises(KeyboardInterrupt): + call_command("refresh_filing_code_index", interval=86400, stdout=Mock(), stderr=Mock()) + assert [call.args[0] for call in build.call_args_list] == ["illinois", "vermont", "illinois"] + assert [call.args[0] for call in sleep.call_args_list] == [60, 86400] + + +def test_catalog_retries_temporary_http_failure(): + from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + from threading import Thread + + class Handler(BaseHTTPRequestHandler): + attempts = 0 + + def do_GET(self): + Handler.attempts += 1 + self.send_response(503 if Handler.attempts == 1 else 200) + self.end_headers() + self.wfile.write(b'[{"code": "civil", "name": "Civil"}]') + + def log_message(self, *args): + pass + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = Thread(target=server.serve_forever) + thread.start() + catalog = CodeCatalog("illinois") + catalog.base = f"http://127.0.0.1:{server.server_port}/" + try: + assert catalog.categories("court") == [option("civil", "Civil")] + assert Handler.attempts == 2 + finally: + catalog.close() + server.shutdown() + server.server_close() + thread.join() + + +def test_classification_change_clears_dependent_codes_but_keeps_documents(django_user_model): + user = django_user_model.objects.create_user(username="draft-owner") + draft = FilingDraft.objects.create( + user=user, + jurisdiction="illinois", + court_code="old", + case_category_code="civil", + case_type_code="eviction", + quoted_fee_total="10", + quoted_fee_fingerprint="old-fees", + previous_case_id="old-case", + ) + doc = FilingDocument.objects.create( + draft=draft, + role="lead", + name="keep.pdf", + s3_key="keep.pdf", + filing_type_code="old", + document_type_code="private", + document_type_confirmed=True, + filing_component_code="old-component", + requested_optional_services=["old-service"], + ) + party = FilingParty.objects.create(draft=draft, party_type="old", first_name="Keep") + assert not clear_changed_classification(draft, "old", "civil", "eviction") + assert clear_changed_classification(draft, "new", "civil", "eviction") + doc.refresh_from_db() + party.refresh_from_db() + draft.refresh_from_db() + assert doc.s3_key == "keep.pdf" + assert not doc.filing_type_code and not doc.document_type_code and not doc.document_type_confirmed + assert not doc.filing_component_code and not doc.requested_optional_services + assert not party.party_type and party.first_name == "Keep" + assert not draft.quoted_fee_total and not draft.previous_case_id and not draft.filing_type_code + + +def test_zip_filter_scopes_groups_contexts_courts_and_paths(catalog): + catalog.courts.return_value = [option("cook:cvd1", "Cook County"), option("adams", "Adams County")] + refresh_index("illinois") + index = current_index("illinois") + all_groups = search_grouped_paths(index, "eviction") + scoped = search_grouped_paths(index, "eviction", postal_code="60601") + assert scoped["location_counties"] == ["Cook"] + group = scoped["groups"][0] + assert group["path_count"] < all_groups["groups"][0]["path_count"] + args = {"group_key": group["key"], "postal_code": "60601", "context": group["case_context_options"][0]["key"]} + courts = search_grouped_paths(index, "eviction", **args) + assert [court["code"] for court in courts["courts"]] == ["cook:cvd1"] + assert search_grouped_paths(index, "eviction", court="adams", **args)["total"] == 0 + assert search_grouped_paths(index, "eviction", court="cook:cvd1", **args)["total"] == 1 + assert search_grouped_paths(index, "eviction")["groups"] == all_groups["groups"] + + +@pytest.mark.parametrize("postal_code", ["606", "00000", "abcde"]) +def test_zip_filter_rejects_invalid_or_unknown_zip(index, postal_code): + from efile.services.filing_code_search import zip_counties + + with pytest.raises(ValueError): + zip_counties("illinois", postal_code) + + +def test_zip_filter_preserves_multiple_counties(): + from efile.services.filing_code_search import zip_counties + + with patch("efile.utils.zip_to_county_il.COUNTY_TO_ZIPS_IL", {"Cook": ["60601"], "Lake": ["60601"]}): + assert zip_counties("illinois", "60601-1234") == ["Cook", "Lake"] + + +def test_overlapping_words_do_not_need_a_synonym_explanation(): + data = search_context( + "illinois", "response to complaint", [("filing_type", "Answer/Response to Complaint/Petition")] + ) + assert data["match_reason"] is None + data = search_context("illinois", "complaint response", [("filing_type", "Answer/Response to Complaint/Petition")]) + assert data["match_reason"] is None + data = search_context("illinois", "unlawful detainer", [("filing_type", "Eviction")]) + assert data["match_reason"]["kind"] == "related_concept" + + +def test_path_description_uses_case_meaning_not_document_definition(index): + path = search_paths(index, "eviction")["results"][0] + assert path["case_description"] == concept_meaning("eviction", "massachusetts", path["court"]["name"])["text"] + debt = search_paths(index, "debt")["results"][0] + assert "creditor" in debt["case_description"] + + +@pytest.mark.parametrize( + ("name", "property_kind", "relief", "text"), + [ + ("Commercial - Possession Only", "commercial", "possession", "business"), + ("Residential - Eviction", "residential", "unknown", "does not say whether money"), + ("Residential - Possession and Rent", "residential", "money", "money claim"), + ("Ejectment", "unknown", "unknown", "legal right"), + ], +) +def test_eviction_case_type_guidance(name, property_kind, relief, text): + from efile.services.case_type_guidance import case_guidance + + guidance = case_guidance("illinois", "Eviction", name, "Answer/Response to Complaint/Petition") + assert guidance["property"] == property_kind + assert guidance["relief"] == relief + assert guidance["role"] == "tenant" + assert text in guidance["text"] + assert case_guidance("illinois", "Contracts", "Commercial")["topic"] != "eviction" + assert case_guidance("vermont", "Eviction", name)["topic"] == "eviction" + + +def test_eviction_facets_filter_before_pagination_and_preserve_unknowns(catalog): + catalog.categories.return_value = [option("eviction", "Eviction")] + catalog.types.return_value = [ + option("business", "Commercial"), + option("home", "Residential - Possession Only"), + option("other", "Ejectment"), + ] + catalog.filings.side_effect = None + catalog.filings.return_value = [ + option("answer", "Answer"), + option("complaint", "Complaint"), + option("motion", "Motion"), + ] + refresh_index("illinois") + index = current_index("illinois") + data = search_grouped_paths( + index, "eviction", role="tenant", property_kind="residential", relief="possession", limit=1 + ) + assert data["eviction_facets"] + assert data["total"] == 2 # Answer and Motion; landlord Complaint excluded. + group = data["groups"][0] + assert "Eviction › Commercial" not in group["case_contexts"] + assert "Eviction › Ejectment" in group["case_contexts"] # Unspecified property/relief stays visible. + paths = search_grouped_paths( + index, + "eviction", + group_key=group["key"], + court="housing", + role="tenant", + property_kind="residential", + relief="possession", + ) + assert paths["total"] == 2 + assert {p["case_type"]["code"] for p in paths["results"]} == {"home", "other"} + assert any("home or apartment" in p["case_description"] for p in paths["results"]) + assert search_grouped_paths(index, "eviction")["total"] == 3 + + +def test_small_claim_amount_filters_groups_courts_and_exact_paths(catalog): + catalog.categories.return_value = [option("sc", "Small Claims")] + catalog.types.return_value = [ + option("low", "Contract (Up to $2,500)"), + option("high", "Contract ($2,500.01 to $10K)"), + option("unknown", "Other"), + ] + catalog.filings.side_effect = None + catalog.filings.return_value = [option("complaint", "Complaint")] + refresh_index("illinois") + index = current_index("illinois") + args = {"case_topic": "small_claims", "case_filters": {"amount": "250001:1000000"}} + data = search_grouped_paths(index, "small claims", **args) + assert data["case_topic"] == "small_claims" + assert data["total"] == 1 + group = data["groups"][0] + assert group["path_count"] == 4 + assert not any("Up to $2,500" in label for label in group["case_contexts"]) + detail = search_grouped_paths(index, "small claims", group_key=group["key"], court="housing", **args) + assert {path["case_type"]["code"] for path in detail["results"]} == {"high", "unknown"} + assert detail["total"] == 2 + assert len(search_grouped_paths(index, "small claims", group_key=group["key"], **args)["courts"]) == 2 + assert search_grouped_paths(index, "small claims")["groups"][0]["path_count"] == 6 diff --git a/efile_app/efile/tests/test_sqlite_transactions.py b/efile_app/efile/tests/test_sqlite_transactions.py new file mode 100644 index 00000000..350814ca --- /dev/null +++ b/efile_app/efile/tests/test_sqlite_transactions.py @@ -0,0 +1,82 @@ +"""File-backed coverage for overlapping local upload/worker transactions.""" + +import sqlite3 +from copy import deepcopy +from unittest.mock import Mock, patch + +import pytest +from django.conf import settings +from django.core.management import call_command +from django.db import OperationalError +from django.db.utils import ConnectionHandler + + +@pytest.mark.django_db(transaction=True) +def test_upload_can_write_after_a_worker_attempts_to_write(tmp_path): + config = deepcopy(settings.DATABASES["default"]) + if config["ENGINE"] != "django.db.backends.sqlite3": + pytest.skip("SQLite-specific local configuration") + config["NAME"] = tmp_path / "concurrent-upload.sqlite3" + config.setdefault("OPTIONS", {})["timeout"] = 0.05 + upload = ConnectionHandler({"default": config})["default"] + worker = sqlite3.connect(config["NAME"], timeout=0.05, isolation_level=None) + try: + with upload.cursor() as cursor: + cursor.execute("CREATE TABLE activity (kind TEXT)") + # Start as Django's outer atomic block does, then read before the + # document is sent to storage. Another process attempts a write. + upload.set_autocommit(False, force_begin_transaction_with_broken_autocommit=True) + with upload.cursor() as cursor: + cursor.execute("SELECT * FROM activity") + cursor.fetchall() + worker.execute("BEGIN") + worker_waiting = False + try: + worker.execute("INSERT INTO activity VALUES ('worker')") + except sqlite3.OperationalError as error: + assert "locked" in str(error) + worker_waiting = True + worker.rollback() + # DEFERRED lets the worker reserve the writer above, then raises + # "database is locked" here instead of waiting, regardless of timeout. + with upload.cursor() as cursor: + cursor.execute("INSERT INTO activity VALUES ('upload')") + upload.commit() + if worker_waiting: + worker.execute("INSERT INTO activity VALUES ('worker')") + worker.commit() + assert worker.execute("SELECT kind FROM activity ORDER BY kind").fetchall() == [("upload",), ("worker",)] + finally: + worker.rollback() + worker.close() + upload.close() + + +def test_extraction_supervisor_retries_sqlite_contention(): + command = "efile.management.commands.process_document_extractions" + with ( + patch(f"{command}.connection", vendor="sqlite"), + patch(f"{command}.close_old_connections"), + patch(f"{command}.call_command"), + patch(f"{command}.time.sleep", side_effect=[None, KeyboardInterrupt]), + patch( + "efile.services.document_extractions.claim_next_extraction", + side_effect=[OperationalError("database is locked"), None], + ) as claim, + ): + with pytest.raises(KeyboardInterrupt): + call_command("process_document_extractions", stdout=Mock()) + assert claim.call_count == 2 + + +@pytest.mark.parametrize("once,message", [(True, "database is locked"), (False, "no such table: missing")]) +def test_extraction_supervisor_does_not_hide_other_database_failures(once, message): + command = "efile.management.commands.process_document_extractions" + with ( + patch(f"{command}.connection", vendor="sqlite"), + patch(f"{command}.close_old_connections"), + patch(f"{command}.call_command"), + patch("efile.services.document_extractions.claim_next_extraction", side_effect=OperationalError(message)), + ): + with pytest.raises(OperationalError, match=message): + call_command("process_document_extractions", once=once, stdout=Mock()) diff --git a/efile_app/efile/views/extraction_review.py b/efile_app/efile/views/extraction_review.py index 28533997..aa175cdc 100644 --- a/efile_app/efile/views/extraction_review.py +++ b/efile_app/efile/views/extraction_review.py @@ -13,7 +13,12 @@ from efile.services.extracted_parties import review_rows, save_reviewed_parties from efile.services.extraction_fields import display_extracted_fields, document_summary_details from efile.services.filing_availability import filing_unavailable_message -from efile.services.filing_path import change_filing_path, describe_path_change, filing_path_conflict +from efile.services.filing_path import ( + change_filing_path, + clear_changed_classification, + describe_path_change, + filing_path_conflict, +) from efile.workflow import ( ExistingCase, WorkflowStepKey, @@ -82,9 +87,28 @@ def _set_lead_filing_type(draft, filing_type_code, filing_type_name): return if lead.filing_type_code != filing_type_code: lead.document_type_confirmed = False + lead.document_type_code = "" + lead.document_type_name = "" + lead.filing_component_code = "" + lead.filing_component_name = "" + lead.requested_optional_services = [] + lead.filing_requires_amount_in_controversy = False lead.filing_type_code = filing_type_code lead.filing_type_name = filing_type_name - lead.save(update_fields=["filing_type_code", "filing_type_name", "document_type_confirmed", "updated_at"]) + lead.save( + update_fields=[ + "filing_type_code", + "filing_type_name", + "document_type_confirmed", + "document_type_code", + "document_type_name", + "filing_component_code", + "filing_component_name", + "requested_optional_services", + "filing_requires_amount_in_controversy", + "updated_at", + ] + ) def _case_identity(existing_case, docket_number): @@ -190,6 +214,8 @@ def extraction_review(request, jurisdiction): path_change = change_filing_path(draft, existing_case) if path_change.changed and path_change.cleared: messages.info(request, describe_path_change(path_change)) + if clear_changed_classification(draft, court_code, case_category_code, case_type_code): + messages.info(request, "Filing path changed. Check each document's filing options and fees again.") write_case_data( draft, { diff --git a/efile_app/pyproject.toml b/efile_app/pyproject.toml index 54213fa1..b935a359 100644 --- a/efile_app/pyproject.toml +++ b/efile_app/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ # Vermont Superior Court unit lookup by town, county, or ZIP. "suffolklitlab-vtcourts @ git+https://github.com/SuffolkLITLab/VTCourts@main", "django-otp>=1.7.0,<2", + "snowballstemmer>=3,<4", ] [build-system] @@ -42,7 +43,7 @@ exclude = [ ] [tool.setuptools.package-data] -efile = ["prompts/*.yaml", "data/*.json"] +efile = ["prompts/*.yaml", "data/*.json", "data/*.yaml"] [tool.uv] @@ -107,7 +108,9 @@ testpaths = [ # new: #addopts = "--reuse-db --nomigrations" # merged: -addopts = "-ra --strict-markers --strict-config --reuse-db --nomigrations" +# Run schema migrations: SQLite's full-text index and synchronization triggers +# are migration-managed database objects, not ordinary Django model tables. +addopts = "-ra --strict-markers --strict-config --reuse-db" markers = [ "slow: marks tests as slow (deselect with '-m \"not slow\"')", "integration: marks tests as integration tests", diff --git a/efile_app/pytest.ini b/efile_app/pytest.ini index 7986eb97..ad010d77 100644 --- a/efile_app/pytest.ini +++ b/efile_app/pytest.ini @@ -1,7 +1,7 @@ [pytest] DJANGO_SETTINGS_MODULE = efile.settings python_files = tests.py test_*.py *_tests.py -addopts = --reuse-db --nomigrations +addopts = --reuse-db testpaths = efile markers = diff --git a/efile_app/tests/confirm-case-editing.spec.js b/efile_app/tests/confirm-case-editing.spec.js index dab1bd7f..67d80d7d 100644 --- a/efile_app/tests/confirm-case-editing.spec.js +++ b/efile_app/tests/confirm-case-editing.spec.js @@ -248,6 +248,1146 @@ test.use({ }); test.skip(!process.env.A11Y_STORAGE_STATE, 'Needs the seeded session: run seed_accessibility_session and set A11Y_STORAGE_STATE.'); +test.describe('filing code search', () => { + const result = { + id: 101, + initial: true, + court: { + code: 'vt:orange', + name: 'Orange Unit' + }, + case_category: { + code: '7000', + name: 'Civil' + }, + case_type: { + code: '7100', + name: 'Landlord tenant' + }, + filing_type: { + code: '143132', + name: 'Complaint' + }, + explanation: '', + explanation_source: '', + unavailable: '', + }; + const options = { + court: Object.entries(UNITS).map(([code, name]) => ({ + code, + name + })), + case_category: [{ + code: '7000', + name: 'Civil' + }], + case_type: [{ + code: '7100', + name: 'Landlord tenant' + }], + filing_type: [{ + code: '143132', + name: 'Complaint' + }], + }; + + async function mockSearch(page, { + reject = false, + unavailable = false + } = {}) { + const queries = []; + await page.route('**/api/filing-code-search/**', async route => { + const params = new URL(route.request().url()).searchParams; + queries.push(Object.fromEntries(params)); + if (params.has('path_id')) { + return route.fulfill(reject ? { + status: 409, + json: { + error: 'The code list has changed. Search again.' + }, + } : { + json: { + path: { + ...result, + options + } + } + }); + } + return route.fulfill({ + json: { + results: [{ + ...result, + unavailable: unavailable ? 'Temporarily unavailable' : '' + }], + total: 1, + corrected_terms: [], + stale: false, + } + }); + }); + return queries; + } + + test('empty search offers common types, clears back to them, and accepts one character', async ({ + page + }) => { + await mockCourtLists(page, { + flat: true + }); + const queries = await mockSearch(page); + await openSavedDraft(page); + await page.getByRole('button', { + name: 'Find the right filing code' + }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog.getByRole('heading', { + name: 'Common filing types' + })).toBeVisible(); + expect(queries).toEqual([]); + await dialog.getByRole('button', { + name: 'Complaint', + exact: true + }).click(); + await expect(dialog.getByRole('radio', { + name: 'Complaint' + })).toBeVisible(); + expect(queries[0].q).toBe('complaint'); + await dialog.getByRole('radio').check(); + await page.getByRole('searchbox', { + name: 'Search filing codes' + }).fill(''); + await expect(dialog.getByRole('heading', { + name: 'Common filing types' + })).toBeVisible(); + await expect(dialog.getByRole('radio')).toHaveCount(0); + await expect(dialog.getByRole('button', { + name: 'Use this path' + })).toBeDisabled(); + await page.getByRole('searchbox', { + name: 'Search filing codes' + }).fill('m'); + await expect(dialog.getByRole('radio')).toBeVisible(); + expect(queries.at(-1).q).toBe('m'); + }); + + test('an HTML server error gives a readable message and search can be retried', async ({ + page + }) => { + await mockCourtLists(page, { + flat: true + }); + await mockSearch(page); + await page.route('**/api/filing-code-search/**', route => route.fulfill({ + status: 503, + contentType: 'text/html', + body: 'Unavailable', + }), { + times: 1 + }); + await openSavedDraft(page); + await page.getByRole('button', { + name: 'Find the right filing code' + }).click(); + await page.getByRole('button', { + name: 'Complaint', + exact: true + }).click(); + await expect(page.locator('#code-search-status')).toContainText('Code search could not be loaded'); + await page.getByRole('searchbox', { + name: 'Search filing codes' + }).press('Enter'); + await expect(page.getByRole('dialog').getByRole('radio')).toBeVisible(); + }); + + test('search becomes usable when the first index finishes without reopening the dialog', async ({ + page + }) => { + await mockCourtLists(page, { + flat: true + }); + await mockSearch(page); + await page.clock.install(); + await page.route('**/api/filing-code-search/**', route => route.fulfill({ + status: 503, + json: { + error: 'Code search is not available yet.' + }, + }), { + times: 1 + }); + await openSavedDraft(page); + await page.getByRole('button', { + name: 'Find the right filing code' + }).click(); + await page.getByRole('button', { + name: 'Complaint', + exact: true + }).click(); + await expect(page.locator('#code-search-status')).toContainText('We will try again shortly.'); + await page.clock.fastForward(10000); + await expect(page.getByRole('dialog').getByRole('radio')).toBeVisible(); + }); + + for (const flat of [true, false]) { + test(`applies all four facets across courts (${flat ? 'flat' : 'guided'})`, async ({ + page + }) => { + await mockCourtLists(page, { + flat + }); + const queries = await mockSearch(page); + await openSavedDraft(page); + const open = page.getByRole('button', { + name: 'Find the right filing code' + }); + await open.click(); + const dialog = page.getByRole('dialog'); + await expect(page.getByRole('searchbox', { + name: 'Search filing codes' + })).toBeFocused(); + await page.getByRole('searchbox', { + name: 'Search filing codes' + }).fill('eviction'); + await expect(dialog).not.toContainText('No plain-language explanation'); + await dialog.getByRole('radio', { + name: 'Complaint' + }).check(); + await expect(dialog.locator('details')).toHaveAttribute('open', ''); + await expect(dialog).toContainText('Case type: Landlord tenant'); + await dialog.getByRole('button', { + name: 'Use this path' + }).click(); + await expect(dialog).not.toBeVisible(); + await expect(open).toBeFocused(); + await expect(page.locator('#court_code')).toHaveValue('vt:orange'); + await expect(field(page, 'case_category').value).toHaveText('Civil'); + await expect(field(page, 'case_type').value).toHaveText('Landlord tenant'); + await expect(field(page, 'filing_type').value).toHaveText('Complaint'); + if (!flat) await expect(page.locator('.court-selector__result')).toContainText('Orange Unit'); + expect(queries[0]).toMatchObject({ + jurisdiction: 'illinois', + existing_case: 'no', + q: 'eviction', + offset: '0' + }); + expect(queries[0]).not.toHaveProperty('court'); + expect(queries[1].path_id).toBe('101'); + }); + } + + for (const hasCourt of [false, true]) { + test(`groups repeated court codes with ${hasCourt ? 'a current court' : 'no court selected'}`, async ({ + page + }) => { + await mockCourtLists(page, { + flat: true + }); + await mockSearch(page); + await page.route('**/api/filing-code-search/**', route => { + const params = new URL(route.request().url()).searchParams; + if (params.has('path_id')) return route.fallback(); + if (params.has('court')) return route.fulfill({ + json: { + results: [{ + ...result, + court: { + code: params.get('court'), + name: UNITS[params.get('court')] + } + }], + total: 1, + corrected_terms: [], + stale: false, + } + }); + if (params.has('group')) return route.fulfill({ + json: { + courts: [{ + code: 'vt:orange', + name: 'Orange Unit' + }, { + code: 'cook:cvd1', + name: 'Chittenden Unit' + }], + corrected_terms: [], + } + }); + return route.fulfill({ + json: { + groups: [{ + key: 'eviction', + name: 'Eviction Complaint', + variants: ['Eviction Complaint', 'Summary Process Complaint'], + path_count: 100 + }], + total: 1, + corrected_terms: [], + stale: false, + } + }); + }); + await openSavedDraft(page); + if (!hasCourt) await page.locator('#court_code').evaluate(input => input.value = ''); + await page.getByRole('button', { + name: 'Find the right filing code' + }).click(); + await page.getByRole('searchbox', { + name: 'Search filing codes' + }).fill('eviction'); + const dialog = page.getByRole('dialog'); + await expect(dialog.locator('.code-search__group')).toHaveCount(1); + await expect(dialog.getByRole('radio')).toHaveCount(0); + await expect(dialog).toContainText('Also listed as: Summary Process Complaint'); + await dialog.getByRole('button', { + name: 'Choose a court for Eviction Complaint' + }).click(); + const court = dialog.getByLabel('Court', { + exact: true + }); + await expect(court).toHaveValue(hasCourt ? 'cook:cvd1' : ''); + if (hasCourt) { + await expect(court.locator('option').nth(1)).toHaveText('Chittenden Unit'); + await expect(dialog.getByRole('radio')).toHaveCount(1); + await dialog.getByRole('radio').check(); + } else { + await expect(dialog.getByRole('radio')).toHaveCount(0); + } + await court.selectOption('vt:orange'); + await expect(dialog.getByRole('button', { + name: 'Use this path' + })).toBeDisabled(); + await expect(dialog.locator('.code-search__result label')).toHaveCount(2); + await dialog.getByRole('radio').check(); + await dialog.getByRole('button', { + name: 'Use this path' + }).click(); + await expect(dialog).not.toBeVisible(); + await expect(page.locator('#court_code')).toHaveValue('vt:orange'); + await expect(page.locator('#case_type_code')).toHaveValue('7100'); + }); + } + + test('synonym results explain their concepts and show local meanings after court selection', async ({ + page + }) => { + await mockCourtLists(page, { + flat: true + }); + const meaning = { + key: 'eviction', + label: 'Eviction', + text: 'Statewide eviction meaning.', + scope: 'state', + scope_name: 'Vermont', + source: 'https://www.vtcourts.gov/civil/eviction-process', + }; + await page.route('**/api/filing-code-search/**', route => { + const params = new URL(route.request().url()).searchParams; + const match_reason = params.get('q') === 'eviction' ? null : { + kind: 'related_concept', + query: params.get('q'), + concepts: ['eviction'], + corrected_terms: [], + }; + if (params.has('court')) { + const county = params.get('court') === 'vt:orange' ? 'Orange' : 'Chittenden'; + return route.fulfill({ + json: { + results: [{ + ...result, + case_description: `${county} local meaning.`, + match_reason, + concepts: [{ + ...meaning, + scope: 'county', + scope_name: county, + text: `${county} local meaning.` + }] + }], + total: 1, + corrected_terms: [], + } + }); + } + if (params.has('group')) return route.fulfill({ + json: { + courts: [{ + code: 'vt:orange', + name: 'Orange Unit' + }, { + code: 'cook:cvd1', + name: 'Chittenden Unit' + }], + } + }); + return route.fulfill({ + json: { + groups: [{ + key: 'eviction', + name: 'Eviction complaint', + variants: ['Eviction complaint'], + concepts: [meaning], + match_reason + }], + total: 1, + corrected_terms: [], + } + }); + }); + await openSavedDraft(page); + await page.locator('#court_code').evaluate(input => input.value = ''); + await page.getByRole('button', { + name: 'Find the right filing code' + }).click(); + await page.getByRole('searchbox', { + name: 'Search filing codes' + }).fill('unlawful detainer'); + const group = page.locator('.code-search__group'); + await expect(group).toContainText('Meaning in Vermont: Statewide eviction meaning.'); + const why = group.locator('.code-search__match-reason'); + await expect(why.locator('p').first()).not.toBeVisible(); + await why.locator('summary').focus(); + await page.keyboard.press('Enter'); + await expect(why.locator('p').first()).toBeVisible(); + await expect(why).toContainText('Your search for “unlawful detainer” relates to Eviction'); + await expect(group.getByRole('link', { + name: 'About this concept' + })).toHaveAttribute('href', meaning.source); + await group.getByRole('button', { + name: 'Choose a court for Eviction complaint' + }).click(); + await group.getByLabel('Court', { + exact: true + }).selectOption('vt:orange'); + await expect(group).toContainText('Orange local meaning.'); + await group.getByLabel('Court', { + exact: true + }).selectOption('cook:cvd1'); + await expect(group).toContainText('Chittenden local meaning.'); + await expect(group).not.toContainText('Orange local meaning.'); + await page.getByRole('searchbox', { + name: 'Search filing codes' + }).fill('eviction'); + await expect(group.locator('.code-search__match-reason')).toHaveCount(0); + await expect(group).toContainText('Statewide eviction meaning.'); + }); + + test('contextual radios and claim amount dropdown reset when the case area changes', async ({ + page + }) => { + await mockCourtLists(page, { + flat: true + }); + const queries = []; + await page.route('**/api/filing-code-search/**', route => { + const params = new URL(route.request().url()).searchParams; + queries.push({ + ...Object.fromEntries(params), + choices: JSON.parse(params.get('case_filters') || '{}') + }); + const topic = params.get('case_topic') || (params.get('q') === 'eviction' ? 'eviction' : 'small_claims'); + const case_facets = topic === 'eviction' ? [{ + key: 'role', + label: 'Your role', + options: [{ + value: 'tenant', + label: 'Tenant / occupant' + }, { + value: 'landlord', + label: 'Landlord / owner' + }] + }, { + key: 'property', + label: 'Property', + options: [{ + value: 'residential', + label: 'Home' + }, { + value: 'commercial', + label: 'Business' + }] + }, ] : [{ + key: 'amount', + label: 'Claim amount', + control: 'select', + options: [{ + value: '0:250000', + label: 'Up to $2,500' + }, { + value: '250001:1000000', + label: '$2,500.01–$10,000' + }, ], + help: 'State-specific limits and exceptions.', + source: 'https://www.illinoiscourts.gov/' + }]; + return route.fulfill({ + json: { + groups: [{ + key: 'answer', + name: 'Answer', + variants: ['Answer'] + }], + total: 1, + corrected_terms: [], + case_topic: topic, + case_facets, + case_topics: [{ + value: 'eviction', + label: 'Eviction' + }, { + value: 'small_claims', + label: 'Small claims' + }] + } + }); + }); + await openSavedDraft(page); + await page.getByRole('button', { + name: 'Find the right filing code' + }).click(); + await page.getByRole('searchbox', { + name: 'Search filing codes' + }).fill('eviction'); + const facets = page.locator('#code-search-case-filters'); + await expect(facets).toBeVisible(); + await facets.getByRole('radio', { + name: 'Tenant / occupant', + exact: true + }).check(); + await expect.poll(() => queries.at(-1)?.choices.role).toBe('tenant'); + await facets.getByRole('radio', { + name: 'Home', + exact: true + }).check(); + await expect.poll(() => queries.at(-1)?.choices.property).toBe('residential'); + await expect(facets.getByRole('radio', { + name: 'Business', + exact: true + })).not.toBeChecked(); + await facets.getByLabel('Case area', { + exact: true + }).selectOption('small_claims'); + await expect(facets.getByLabel('Claim amount', { + exact: true + })).toBeVisible(); + expect(queries.at(-1).choices).toEqual({}); + await facets.getByLabel('Claim amount', { + exact: true + }).selectOption('250001:1000000'); + await expect.poll(() => queries.at(-1)?.choices.amount).toBe('250001:1000000'); + await expect(facets.getByLabel('Claim amount', { + exact: true + })).toHaveValue('250001:1000000'); + await facets.getByText('About claim amounts', { + exact: true + }).click(); + await expect(facets.getByText('State-specific limits and exceptions.')).toBeVisible(); + await page.getByRole('searchbox', { + name: 'Search filing codes' + }).fill('small claims'); + await expect.poll(() => queries.at(-1)?.q).toBe('small claims'); + expect(queries.at(-1).choices).toEqual({}); + }); + + test('court paths emphasize case types without repeating shared filing information', async ({ + page + }) => { + await page.setViewportSize({ + width: 1366, + height: 768 + }); + await mockCourtLists(page, { + flat: true + }); + const filingName = 'Answer/Response to Complaint/Petition'; + const caseTypes = ['Commercial', 'Commercial - Possession Only', 'Ejectment', 'Residential - Eviction', 'Residential - Eviction Possession Only']; + await page.route('**/api/filing-code-search/**', route => { + const params = new URL(route.request().url()).searchParams; + if (params.has('court')) return route.fulfill({ + json: { + results: caseTypes.map((name, i) => ({ + ...result, + id: i + 100, + filing_type: { + ...result.filing_type, + name: filingName + }, + case_type: { + code: `case-${i}`, + name + }, + case_context: `Eviction › ${name}`, + case_description: "An existing plain-language case description.", + facets: { + purpose: 'responding', + document: 'main' + }, + })), + total: caseTypes.length, + corrected_terms: [], + } + }); + if (params.has('group')) return route.fulfill({ + json: { + courts: [{ + code: 'vt:orange', + name: 'Orange Unit' + }] + } + }); + return route.fulfill({ + json: { + groups: [{ + key: 'answer', + name: filingName, + variants: [filingName] + }], + total: 1, + corrected_terms: [], + case_topic: 'eviction', + case_topics: [{ + value: 'eviction', + label: 'Eviction' + }], + case_facets: [{ + key: 'role', + label: 'Your role', + options: [{ + value: 'tenant', + label: 'Tenant / occupant' + }, { + value: 'landlord', + label: 'Landlord / owner' + }] + }, { + key: 'property', + label: 'Property', + options: [{ + value: 'residential', + label: 'Home' + }, { + value: 'commercial', + label: 'Business' + }] + }, { + key: 'relief', + label: 'What the landlord is asking for', + options: [{ + value: 'possession', + label: 'Property back only' + }, { + value: 'money', + label: 'Money too' + }] + }, ] + } + }); + }); + await openSavedDraft(page); + await page.getByRole('button', { + name: 'Find the right filing code' + }).click(); + await page.getByRole('searchbox', { + name: 'Search filing codes' + }).fill('answer'); + await page.getByRole('button', { + name: `Choose a court for ${filingName}` + }).click(); + await page.getByLabel('Court', { + exact: true + }).selectOption('vt:orange'); + const choices = page.locator('.code-search__path-choice'); + await expect(choices).toHaveCount(5); + for (const name of caseTypes) await expect(page.getByRole('radio', { + name: `Eviction › ${name}`, + exact: true + })).toBeVisible(); + await expect(choices.locator('label')).not.toContainText([filingName, filingName, filingName, filingName, filingName]); + expect(await choices.evaluateAll(rows => rows.every(row => row.getBoundingClientRect().height < 65))).toBe(true); + await page.getByRole('radio', { + name: 'Eviction › Residential - Eviction', + exact: true + }).check(); + await expect(choices.locator('details[open]')).toHaveCount(0); + const visible = await choices.evaluateAll(rows => { + const bounds = document.getElementById('code-search-results').getBoundingClientRect(); + return rows.filter(row => row.getBoundingClientRect().top >= bounds.top && row.getBoundingClientRect().bottom <= bounds.bottom).length; + }); + expect(visible).toBeGreaterThanOrEqual(3); + const toggle = choices.nth(3).getByText('Details', { + exact: true + }); + const before = await toggle.boundingBox(); + await expect(choices.nth(3).getByText('An existing plain-language case description.')).toBeVisible(); + await toggle.click(); + expect(await toggle.boundingBox()).toEqual(before); + await expect(choices.nth(3).locator('ol')).not.toContainText('case-3'); + await expect(choices.locator('.code-search__match-reason')).toHaveCount(0); + await expect(choices.nth(3).locator('ol')).toContainText(filingName); + await toggle.click(); + await expect(choices.nth(3).locator('ol')).not.toBeVisible(); + await expect(page.getByRole('button', { + name: 'Use this path' + })).toBeEnabled(); + }); + + test('compact filters keep four results visible and apply case stage only with a path', async ({ + page + }) => { + await page.setViewportSize({ + width: 1366, + height: 768 + }); + await mockCourtLists(page, { + flat: true + }); + const queries = []; + await page.route('**/api/filing-code-search/**', route => { + const params = new URL(route.request().url()).searchParams; + queries.push(Object.fromEntries(params)); + const path = { + ...result, + initial: params.get('existing_case') !== 'yes' + }; + if (params.has('path_id')) return route.fulfill({ + json: { + path: { + ...path, + options + } + } + }); + if (params.has('court')) return route.fulfill({ + json: { + results: [path], + total: 1, + corrected_terms: [] + } + }); + if (params.has('group')) return route.fulfill({ + json: { + courts: [{ + code: 'vt:orange', + name: 'Orange Unit' + }] + } + }); + const groups = Array.from({ + length: 6 + }, (_, i) => ({ + key: `type-${i}`, + name: `Complaint / Petition - Eviction - Residential - Possession Only - Fee ${i}`, + variants: ['Complaint', 'Petition'], + facets: { + purpose: 'starting', + document: 'main' + }, + case_contexts: ['Small claims', 'Civil › Eviction'], + case_context_count: 2, + concepts: [{ + key: 'eviction', + label: 'Eviction', + text: 'A short explanation of this concept.', + scope: 'general' + }], + })); + return route.fulfill({ + json: { + groups, + total: 6, + corrected_terms: [] + } + }); + }); + await openSavedDraft(page); + await page.getByRole('button', { + name: 'Find the right filing code' + }).click(); + await page.getByRole('searchbox', { + name: 'Search filing codes' + }).fill('eviction'); + await expect(page.locator('.code-search__group')).toHaveCount(6); + await expect(page.locator('.code-search__group').first().locator('.code-search__meta')).toContainText('2 case types, including Small claims'); + const visible = await page.locator('#code-search-results').evaluate(container => { + const bounds = container.getBoundingClientRect(); + return Array.from(container.querySelectorAll('.code-search__group')).filter(row => { + const rect = row.getBoundingClientRect(); + return rect.top >= bounds.top && rect.bottom <= bounds.bottom; + }).length; + }); + expect(visible).toBeGreaterThanOrEqual(4); + const before = await page.locator('input[name="existing_case"]:checked').inputValue(); + await page.getByLabel('Case stage', { + exact: true + }).selectOption('yes'); + await expect.poll(() => queries.at(-1)?.existing_case).toBe('yes'); + await page.getByLabel('Your task', { + exact: true + }).selectOption('responding'); + await expect.poll(() => queries.at(-1)?.purpose).toBe('responding'); + await page.getByLabel('Document', { + exact: true + }).selectOption('main'); + await expect.poll(() => queries.at(-1)?.document).toBe('main'); + await expect(page.locator('input[name="existing_case"]:checked')).toHaveValue(before); + const first = page.locator('.code-search__group').first(); + await first.getByRole('button', { + name: /Choose a court/ + }).click(); + await first.getByLabel('Court', { + exact: true + }).selectOption('vt:orange'); + await first.getByRole('radio').check(); + await page.getByRole('button', { + name: 'Use this path' + }).click(); + await expect(page.getByRole('dialog')).not.toBeVisible(); + await expect(page.locator('input[name="existing_case"]:checked')).toHaveValue('existing'); + await expect(page.locator('#case_type_code')).toHaveValue('7100'); + }); + + test('ZIP filter updates the search and clearing it restores all counties', async ({ + page + }) => { + await mockCourtLists(page, { + flat: true + }); + const queries = []; + await page.route('**/api/filing-code-search/**', route => { + const params = new URL(route.request().url()).searchParams; + queries.push(Object.fromEntries(params)); + return route.fulfill({ + json: { + groups: [{ + key: 'petition', + name: 'Petition', + variants: ['Petition'] + }], + total: 1, + corrected_terms: [], + location_counties: params.get('zip') ? ['Cook'] : [], + } + }); + }); + await openSavedDraft(page); + await page.getByRole('button', { + name: 'Find the right filing code' + }).click(); + await page.getByRole('searchbox', { + name: 'Search filing codes' + }).fill('eviction'); + await expect(page.locator('.code-search__group')).toHaveCount(1); + await page.getByLabel('Case location ZIP', { + exact: true + }).fill('60601'); + await expect(page.locator('#code-search-status')).toContainText('Counties: Cook.'); + expect(queries.at(-1).zip).toBe('60601'); + await page.getByLabel('Case location ZIP', { + exact: true + }).fill(''); + await expect(page.locator('#code-search-status')).not.toContainText('Counties:'); + await expect.poll(() => queries.at(-1)?.zip).toBe(''); + }); + + test('case-type links narrow courts and paths and can be cleared', async ({ + page + }) => { + await mockCourtLists(page, { + flat: true + }); + const queries = []; + await page.route('**/api/filing-code-search/**', async route => { + const params = new URL(route.request().url()).searchParams; + queries.push(Object.fromEntries(params)); + const context = params.get('context'); + if (params.has('court')) return route.fulfill({ + json: { + results: [{ + ...result, + case_type: { + code: context || 'all', + name: context === 'small' ? 'Small claims' : 'Eviction' + } + }], + total: 1, + corrected_terms: [], + } + }); + if (params.has('group')) { + if (context === 'eviction') await new Promise(resolve => setTimeout(resolve, 150)); + const courts = [{ + code: 'vt:orange', + name: 'Orange Unit' + }, { + code: 'cook:cvd1', + name: 'Chittenden Unit' + }]; + return route.fulfill({ + json: { + courts: context === 'small' ? courts.slice(0, 1) : context === 'eviction' ? courts.slice(1) : courts + } + }); + } + return route.fulfill({ + json: { + groups: [{ + key: 'petition', + name: 'Petition', + variants: ['Petition'], + case_contexts: ['Small claims', 'Civil › Eviction'], + case_context_count: 2, + case_context_options: [{ + key: 'small', + label: 'Small claims' + }, { + key: 'eviction', + label: 'Civil › Eviction' + }] + }], + total: 1, + corrected_terms: [], + } + }); + }); + await openSavedDraft(page); + await page.getByRole('button', { + name: 'Find the right filing code' + }).click(); + await page.getByRole('searchbox', { + name: 'Search filing codes' + }).fill('petition'); + const group = page.locator('.code-search__group'); + await group.locator('summary').click(); + const small = group.getByRole('button', { + name: 'Small claims', + exact: true + }); + await small.focus(); + await page.keyboard.press('Enter'); + const court = group.getByLabel('Court', { + exact: true + }); + await expect(court).toBeEnabled(); + await expect(court.locator('option')).toHaveText(['Choose a court', 'Orange Unit']); + await court.selectOption('vt:orange'); + await group.getByRole('radio').check(); + await expect(page.getByRole('button', { + name: 'Use this path' + })).toBeEnabled(); + await group.locator('summary').first().click(); + await group.getByRole('button', { + name: 'Civil › Eviction', + exact: true + }).click(); + await expect(page.getByRole('button', { + name: 'Use this path' + })).toBeDisabled(); + await expect(court).toBeEnabled(); + await expect(court.locator('option')).toHaveText(['Choose a court', 'Chittenden Unit']); + await court.selectOption('cook:cvd1'); + await expect(group.getByRole('radio')).toHaveCount(1); + expect(queries.filter(item => item.court).at(-1).context).toBe('eviction'); + await group.getByRole('button', { + name: 'Show all case types' + }).click(); + await expect(court.locator('option')).toHaveCount(3); + await expect.poll(() => queries.at(-1)?.context).toBe(''); + }); + + test('earlier groups remain usable after loading another page of groups', async ({ + page + }) => { + await mockCourtLists(page, { + flat: true + }); + await page.route('**/api/filing-code-search/**', route => { + const params = new URL(route.request().url()).searchParams; + if (params.has('court')) return route.fulfill({ + json: { + results: [result], + total: 1, + corrected_terms: [] + } + }); + if (params.has('group')) return route.fulfill({ + json: { + courts: [{ + code: 'vt:orange', + name: 'Orange Unit' + }] + } + }); + const offset = Number(params.get('offset')); + const groups = Array.from({ + length: offset ? 1 : 20 + }, (_, i) => ({ + key: `type-${offset + i}`, + name: `Filing ${offset + i}`, + variants: [`Filing ${offset + i}`], + path_count: 100, + })); + return route.fulfill({ + json: { + groups, + total: 21, + corrected_terms: [], + stale: false + } + }); + }); + await openSavedDraft(page); + await page.getByRole('button', { + name: 'Find the right filing code' + }).click(); + await page.getByRole('button', { + name: 'Complaint', + exact: true + }).click(); + await expect(page.locator('.code-search__group')).toHaveCount(20); + await page.getByRole('button', { + name: 'Show more results', + exact: true + }).click(); + await expect(page.locator('.code-search__group')).toHaveCount(21); + await page.getByRole('button', { + name: 'Choose a court for Filing 0', + exact: true + }).click(); + await page.locator('.code-search__group').first().getByLabel('Court', { + exact: true + }).selectOption('vt:orange'); + await page.getByRole('dialog').getByRole('radio').check(); + await expect(page.getByRole('button', { + name: 'Use this path' + })).toBeEnabled(); + }); + + test('rejects a retired path without changing form choices and restores focus on Escape', async ({ + page + }) => { + await mockCourtLists(page, { + flat: true + }); + await mockSearch(page, { + reject: true + }); + await openSavedDraft(page); + await page.getByRole('button', { + name: 'Find the right filing code' + }).click(); + await page.getByRole('searchbox', { + name: 'Search filing codes' + }).fill('eviction'); + await page.getByRole('dialog').getByRole('radio').check(); + await page.getByRole('button', { + name: 'Use this path' + }).click(); + await expect(page.getByRole('dialog')).toContainText('The code list has changed'); + await expect(page.locator('#court_code')).toHaveValue('cook:cvd1'); + await page.keyboard.press('Escape'); + await expect(page.getByRole('dialog')).not.toBeVisible(); + await expect(page.getByRole('button', { + name: 'Find the right filing code' + })).toBeFocused(); + }); + + test('a late search cannot replace a newer query or leave an old selection active', async ({ + page + }) => { + await mockCourtLists(page, { + flat: true + }); + await mockSearch(page); + let delivered = false; + await page.route('**/api/filing-code-search/**', async route => { + const params = new URL(route.request().url()).searchParams; + if (params.get('q') !== 'delayed') return route.fallback(); + await new Promise(resolve => setTimeout(resolve, 800)); + await route.fulfill({ + json: { + results: [{ + ...result, + filing_type: { + code: 'old', + name: 'Outdated result' + } + }], + total: 1, + corrected_terms: [], + stale: false, + } + }).catch(() => {}); + delivered = true; + }); + await openSavedDraft(page); + await page.getByRole('button', { + name: 'Find the right filing code' + }).click(); + await page.getByRole('searchbox', { + name: 'Search filing codes' + }).fill('eviction'); + await page.getByRole('dialog').getByRole('radio').check(); + const waiting = page.waitForRequest(request => new URL(request.url()).searchParams.get('q') === 'delayed'); + await page.getByRole('searchbox', { + name: 'Search filing codes' + }).fill('delayed'); + await expect(page.getByRole('button', { + name: 'Use this path' + })).toBeDisabled(); + await waiting; + await page.getByRole('searchbox', { + name: 'Search filing codes' + }).fill('eviction'); + await expect(page.getByRole('dialog').getByRole('radio', { + name: 'Complaint' + })).toBeVisible(); + await expect.poll(() => delivered).toBe(true); + await expect(page.getByRole('dialog')).not.toContainText('Outdated result'); + await expect(page.getByRole('button', { + name: 'Use this path' + })).toBeDisabled(); + }); + + test('shows unavailable paths without allowing selection, and keeps focus in the modal', async ({ + page + }) => { + await mockCourtLists(page, { + flat: true + }); + await mockSearch(page, { + unavailable: true + }); + await openSavedDraft(page); + await page.getByRole('button', { + name: 'Find the right filing code' + }).click(); + await page.getByRole('searchbox', { + name: 'Search filing codes' + }).fill('eviction'); + await expect(page.getByRole('dialog').getByRole('radio')).toBeDisabled(); + await expect(page.getByRole('button', { + name: 'Use this path' + })).toBeDisabled(); + for (let i = 0; i < 8; i++) { + await page.keyboard.press('Tab'); + expect(await page.evaluate(() => document.querySelector('#code-search-dialog').contains(document.activeElement))).toBe(true); + } + await page.setViewportSize({ + width: 375, + height: 740 + }); + expect(await page.getByRole('dialog').evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true); + const { + default: AxeBuilder + } = require('@axe-core/playwright'); + const accessibility = await new AxeBuilder({ + page + }).include('#code-search-dialog').analyze(); + expect(accessibility.violations).toEqual([]); + }); +}); + test.describe('guided court questions (Vermont-shaped)', () => { test('rapid unit changes stay open until Update, and a late answer cannot win', async ({ page diff --git a/efile_app/uv.lock b/efile_app/uv.lock index 5af1b657..faf4b4af 100644 --- a/efile_app/uv.lock +++ b/efile_app/uv.lock @@ -997,6 +997,7 @@ dependencies = [ { name = "python-dotenv" }, { name = "pyyaml" }, { name = "requests" }, + { name = "snowballstemmer" }, { name = "suffolklitlab-macourts" }, { name = "suffolklitlab-vtcourts" }, { name = "tiktoken" }, @@ -1039,6 +1040,7 @@ requires-dist = [ { name = "python-dotenv", specifier = ">=1.0" }, { name = "pyyaml", specifier = ">=6.0,<7" }, { name = "requests", specifier = ">=2.31,<3" }, + { name = "snowballstemmer", specifier = ">=3,<4" }, { name = "suffolklitlab-macourts", git = "https://github.com/SuffolkLITLab/MACourts?rev=main" }, { name = "suffolklitlab-vtcourts", git = "https://github.com/SuffolkLITLab/VTCourts?rev=main" }, { name = "tiktoken", specifier = ">=0.12.0" }, @@ -2186,6 +2188,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "snowballstemmer" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/f8/0a71edf031f03c40db17503cb8ca78a69a171254e568e7db241b0ab57ea1/snowballstemmer-3.1.1.tar.gz", hash = "sha256:e07bbc54a0d798fe6010a12398422e62a8bfbba95c394fd0956ef58cb4d3e260", size = 123314, upload-time = "2026-06-03T00:56:40.194Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl", hash = "sha256:7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752", size = 104164, upload-time = "2026-06-03T00:56:38.614Z" }, +] + [[package]] name = "sortedcontainers" version = "2.4.0" diff --git a/fly.toml b/fly.toml index e67d00f7..a7cd0ed7 100644 --- a/fly.toml +++ b/fly.toml @@ -18,6 +18,7 @@ primary_region = 'lax' [processes] app = "uv run gunicorn efile.asgi:application -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000 --workers 2 --timeout 60 --access-logfile - --error-logfile - --log-level debug" extraction_worker = "uv run python manage.py process_document_extractions" + code_index_worker = "uv run python manage.py refresh_filing_code_index --interval 86400" [http_service] internal_port = 8000 diff --git a/testing/README.md b/testing/README.md index df3d48d9..20534fef 100644 --- a/testing/README.md +++ b/testing/README.md @@ -20,6 +20,43 @@ awslocal s3 ls s3://litefile-staging/efile-documents/ --recursive Also make sure that `AWS_S3_ENDPOINT_URL = "http://host.docker.internal:4566"` and `AWS_ACCOUNT_ID_ENDPOINT_MODE = "disabled"` in your env. +## Filing code search + +`docker compose up` also starts `code_index_worker`, which builds the filing-code +search indexes and refreshes them daily. The web container's migrations must +finish before the worker starts. Search is unavailable until a state's first +complete catalog has been indexed; opening the search dialog does not start a +build. The court lists remain usable while indexing runs. + +If Compose is already running, start the worker in a terminal you control: + +```bash +docker compose up code_index_worker +``` + +To build only Illinois once in the existing web container instead: + +```bash +docker compose exec web uv run python manage.py refresh_filing_code_index --jurisdiction illinois --verbosity 2 --cache-dir /data/filing-code-cache +``` + +Progress lists each court as it is fetched. Wait for `indexed ... filing paths`, +then search becomes available (an open search retries automatically). Indexes +persist in the shared database volume. If a refresh +fails, the command reports the error and keeps any previous complete index. +Temporary court-service failures are retried within each request. The recurring +worker retries failed states after one minute and skips indexes refreshed within +the daily interval, including after a restart. A one-shot command still exits +with an error if its retries fail. Completed courts are checkpointed in compressed +files in the shared volume and reused for up to six hours after a failed build. + +Local keyword searches use SQLite FTS5, including jurisdiction and new/existing-case +filters. The schema migration builds postings from any existing catalog; later +catalog changes maintain them automatically. Run `docker compose exec web uv run +python manage.py migrate --noinput` when updating a running stack. Compose runs +migrations automatically when starting the web container. Python tests run migrations +too, because `--nomigrations` would omit the FTS5 table and its synchronization triggers. + ## Calculating fees and filing end-to-end The EFSP proxy **downloads each document itself** from the `data_url` in the From e1eb40ba0b9c02ccb96093a538d90f79ab9dfc4b Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Sun, 4 Oct 2026 18:54:30 -0400 Subject: [PATCH 2/5] Cover filing search states with accessibility and keyboard checks --- .github/workflows/accessibility.yml | 5 +- efile_app/tests/confirm-case-editing.spec.js | 88 ++++++++++++++++---- 2 files changed, 76 insertions(+), 17 deletions(-) diff --git a/.github/workflows/accessibility.yml b/.github/workflows/accessibility.yml index 94c598f0..7aedf44e 100644 --- a/.github/workflows/accessibility.yml +++ b/.github/workflows/accessibility.yml @@ -59,12 +59,13 @@ jobs: curl --retry 30 --retry-connrefused --retry-delay 1 --fail http://127.0.0.1:8000/choose-jurisdiction npm run test:a11y - - name: Run Confirm case editing checks + - name: Run confirm case and filing search accessibility checks working-directory: efile_app env: A11Y_STORAGE_STATE: playwright/.auth/a11y.json # Same server and seeded session as the Axe checks; the court lists - # are mocked in the browser. + # are mocked in the browser. Includes desktop/mobile Axe audits and + # keyboard checks for contextual filters, claim amounts, and path details. run: npm run test:confirm-case - name: Run party role keyboard checks diff --git a/efile_app/tests/confirm-case-editing.spec.js b/efile_app/tests/confirm-case-editing.spec.js index 67d80d7d..f6abb9bb 100644 --- a/efile_app/tests/confirm-case-editing.spec.js +++ b/efile_app/tests/confirm-case-editing.spec.js @@ -15,6 +15,21 @@ const { expect } = require('@playwright/test'); +const { + default: AxeBuilder +} = require('@axe-core/playwright'); + +async function auditCodeSearch(page, state) { + const accessibility = await new AxeBuilder({ + page + }).include('#code-search-dialog').analyze(); + await test.info().attach(`filing-search-${state}`, { + body: JSON.stringify(accessibility, null, 2), + contentType: 'application/json' + }); + expect(accessibility.violations).toEqual([]); +} + const PAGE = '/jurisdiction/illinois/extraction-review/'; // The seeded draft is filed in "cook:cvd1", Small Claims (6198), Contract @@ -654,6 +669,7 @@ test.describe('filing code search', () => { await expect(group.getByRole('link', { name: 'About this concept' })).toHaveAttribute('href', meaning.source); + await auditCodeSearch(page, 'synonym-explanation'); await group.getByRole('button', { name: 'Choose a court for Eviction complaint' }).click(); @@ -761,10 +777,26 @@ test.describe('filing code search', () => { exact: true }).check(); await expect.poll(() => queries.at(-1)?.choices.property).toBe('residential'); - await expect(facets.getByRole('radio', { + const business = facets.getByRole('radio', { name: 'Business', exact: true - })).not.toBeChecked(); + }); + await expect(business).not.toBeChecked(); + await facets.getByRole('radio', { + name: 'Home', + exact: true + }).focus(); + await page.keyboard.press('ArrowRight'); + await expect(business).toBeChecked(); + await expect.poll(() => queries.at(-1)?.choices.property).toBe('commercial'); + await expect(business).toBeFocused(); + await auditCodeSearch(page, 'contextual-radios-desktop'); + await page.setViewportSize({ + width: 390, + height: 844 + }); + expect(await page.getByRole('dialog').evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true); + await auditCodeSearch(page, 'contextual-radios-mobile'); await facets.getByLabel('Case area', { exact: true }).selectOption('small_claims'); @@ -779,10 +811,26 @@ test.describe('filing code search', () => { await expect(facets.getByLabel('Claim amount', { exact: true })).toHaveValue('250001:1000000'); - await facets.getByText('About claim amounts', { + const amount = facets.getByLabel('Claim amount', { exact: true - }).click(); + }); + await amount.focus(); + await page.keyboard.press('ArrowUp'); + await expect.poll(() => queries.at(-1)?.choices.amount).toBe('0:250000'); + await expect(amount).toBeFocused(); + const amountHelp = facets.getByText('About claim amounts', { + exact: true + }); + await amountHelp.focus(); + await page.keyboard.press('Enter'); + await expect(amountHelp).toBeFocused(); await expect(facets.getByText('State-specific limits and exceptions.')).toBeVisible(); + await auditCodeSearch(page, 'claim-amount-help-mobile'); + await page.setViewportSize({ + width: 1366, + height: 768 + }); + await auditCodeSearch(page, 'claim-amount-help-desktop'); await page.getByRole('searchbox', { name: 'Search filing codes' }).fill('small claims'); @@ -905,10 +953,15 @@ test.describe('filing code search', () => { })).toBeVisible(); await expect(choices.locator('label')).not.toContainText([filingName, filingName, filingName, filingName, filingName]); expect(await choices.evaluateAll(rows => rows.every(row => row.getBoundingClientRect().height < 65))).toBe(true); - await page.getByRole('radio', { + const residential = page.getByRole('radio', { name: 'Eviction › Residential - Eviction', exact: true - }).check(); + }); + await residential.focus(); + await page.keyboard.press('Space'); + await expect(residential).toBeChecked(); + await expect(residential).toHaveAccessibleDescription('An existing plain-language case description.'); + await auditCodeSearch(page, 'case-paths-collapsed-desktop'); await expect(choices.locator('details[open]')).toHaveCount(0); const visible = await choices.evaluateAll(rows => { const bounds = document.getElementById('code-search-results').getBoundingClientRect(); @@ -920,12 +973,22 @@ test.describe('filing code search', () => { }); const before = await toggle.boundingBox(); await expect(choices.nth(3).getByText('An existing plain-language case description.')).toBeVisible(); - await toggle.click(); + await toggle.focus(); + await page.keyboard.press('Enter'); + await expect(toggle).toBeFocused(); expect(await toggle.boundingBox()).toEqual(before); await expect(choices.nth(3).locator('ol')).not.toContainText('case-3'); await expect(choices.locator('.code-search__match-reason')).toHaveCount(0); await expect(choices.nth(3).locator('ol')).toContainText(filingName); - await toggle.click(); + await auditCodeSearch(page, 'case-path-details-desktop'); + await page.setViewportSize({ + width: 390, + height: 844 + }); + expect(await page.getByRole('dialog').evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true); + await auditCodeSearch(page, 'case-path-details-mobile'); + await page.keyboard.press('Enter'); + await expect(toggle).toBeFocused(); await expect(choices.nth(3).locator('ol')).not.toBeVisible(); await expect(page.getByRole('button', { name: 'Use this path' @@ -1159,6 +1222,7 @@ test.describe('filing code search', () => { }).fill('petition'); const group = page.locator('.code-search__group'); await group.locator('summary').click(); + await auditCodeSearch(page, 'case-type-context-links'); const small = group.getByRole('button', { name: 'Small claims', exact: true @@ -1378,13 +1442,7 @@ test.describe('filing code search', () => { height: 740 }); expect(await page.getByRole('dialog').evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true); - const { - default: AxeBuilder - } = require('@axe-core/playwright'); - const accessibility = await new AxeBuilder({ - page - }).include('#code-search-dialog').analyze(); - expect(accessibility.violations).toEqual([]); + await auditCodeSearch(page, 'unavailable-path-mobile'); }); }); From 0cb020833d942c7420300d25656d636a1fa4b8e5 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Sun, 4 Oct 2026 18:59:12 -0400 Subject: [PATCH 3/5] Preserve accessibility reports across browser suites --- .github/workflows/accessibility.yml | 4 ++-- efile_app/tests/confirm-case-editing.spec.js | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/accessibility.yml b/.github/workflows/accessibility.yml index 7aedf44e..70e7c336 100644 --- a/.github/workflows/accessibility.yml +++ b/.github/workflows/accessibility.yml @@ -66,11 +66,11 @@ jobs: # Same server and seeded session as the Axe checks; the court lists # are mocked in the browser. Includes desktop/mobile Axe audits and # keyboard checks for contextual filters, claim amounts, and path details. - run: npm run test:confirm-case + run: npm run test:confirm-case -- --output=test-results/confirm-case - name: Run party role keyboard checks working-directory: efile_app - run: npx playwright test --config=playwright.parties.config.js + run: npx playwright test --config=playwright.parties.config.js --output=test-results/parties - name: Upload accessibility results if: always() diff --git a/efile_app/tests/confirm-case-editing.spec.js b/efile_app/tests/confirm-case-editing.spec.js index f6abb9bb..69948d83 100644 --- a/efile_app/tests/confirm-case-editing.spec.js +++ b/efile_app/tests/confirm-case-editing.spec.js @@ -18,13 +18,16 @@ const { const { default: AxeBuilder } = require('@axe-core/playwright'); +const fs = require('node:fs'); async function auditCodeSearch(page, state) { const accessibility = await new AxeBuilder({ page }).include('#code-search-dialog').analyze(); + const reportPath = test.info().outputPath(`filing-search-${state}.json`); + fs.writeFileSync(reportPath, JSON.stringify(accessibility, null, 2)); await test.info().attach(`filing-search-${state}`, { - body: JSON.stringify(accessibility, null, 2), + path: reportPath, contentType: 'application/json' }); expect(accessibility.violations).toEqual([]); From a0a25457d9efacb513bdb01dd2ba3cbc7fe31b42 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Tue, 6 Oct 2026 15:13:07 -0400 Subject: [PATCH 4/5] Import changed court catalogs with a compatible Fly rollout Stage and validate per-court bulk exports, then replace only changed paths using PostgreSQL COPY or bounded SQLite batches. Recheck the manifest before downloads or removals are published, preserving the previous index on failure. Add dry runs, forced court imports, and an index for court-scoped replacements. Keep Fly on the existing crawler until the proxy rollout, and start the code index worker from the local launcher. --- README.md | 25 +- docs/docs/admin/configuration.md | 1 + docs/docs/admin/deployment.md | 29 +- efile_app/efile/db_expressions.py | 14 + .../commands/refresh_filing_code_index.py | 30 +- .../0035_incremental_filing_catalog.py | 42 +++ efile_app/efile/models.py | 5 + .../efile/services/filing_code_search.py | 17 +- efile_app/efile/services/filing_code_sync.py | 279 ++++++++++++++++ efile_app/efile/settings_base.py | 1 + .../efile/tests/test_filing_code_search.py | 8 + .../efile/tests/test_filing_code_sync.py | 196 +++++++++++ fly.toml | 3 + run_all.sh | 315 ++++++++++++++++++ 14 files changed, 944 insertions(+), 21 deletions(-) create mode 100644 efile_app/efile/db_expressions.py create mode 100644 efile_app/efile/migrations/0035_incremental_filing_catalog.py create mode 100644 efile_app/efile/services/filing_code_sync.py create mode 100644 efile_app/efile/tests/test_filing_code_sync.py create mode 100755 run_all.sh diff --git a/README.md b/README.md index 29fb413e..2d08f1dd 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,29 @@ A minimal Django app for form submission and review. The Django project lives un - Python 3.10+ - uv - -### Using uv (recommended) +### Single-command local startup (`run_all.sh`) + +To quickly run the entire local development stack without building the full Docker container: + +```bash +./run_all.sh +``` + +This script: + +- Verifies Astral `uv` and synchronizes the local virtual environment (`efile_app/.venv`) +- Automatically initializes `efile_app/.env` from `.env.example` if needed +- Starts LocalStack (S3 mock) in Docker if Docker is running +- Applies Django database migrations +- Runs the Django development server (`http://127.0.0.1:8000`) and background extraction worker together +- Starts the filing code index worker, which checks for changed courts daily and imports only their bulk code exports +- Cleanly stops all child processes upon pressing `Ctrl+C` + +Filing code search becomes available after the first successful import. Deploy the proxy's `filing_catalog` API first; during rollout, `--legacy-code-crawl` explicitly enables the older full crawler. + +Run `./run_all.sh --help` to see additional options (e.g., custom ports, running without Docker, or `--no-code-index` to skip the court code index worker). + +### Using uv manually - __0) Install uv__ (one-time) - macOS (Homebrew): diff --git a/docs/docs/admin/configuration.md b/docs/docs/admin/configuration.md index 70ecb01b..472a0bd6 100644 --- a/docs/docs/admin/configuration.md +++ b/docs/docs/admin/configuration.md @@ -20,6 +20,7 @@ LITEFile follows [Twelve-Factor App](https://12factor.net/) principles, configur | `DATABASE_URL` | Yes (Prod) | `sqlite:///db.sqlite3` | Database connection string (e.g., `postgres://user:pass@host:5432/dbname`). | | `DJANGO_ALLOWED_HOSTS` | Yes (Prod) | `localhost,127.0.0.1` | Comma-separated list of allowed hostnames/domains. | | `EFSP_URL` | No | `https://efile-test.suffolklitlab.org` | Base URL for the EFSP REST API endpoint. | +| `FILING_CODE_SYNC_MODE` | No | `bulk` | Import only changed court catalog exports. `legacy` explicitly enables the older full crawler during proxy rollout. | | `SUFFOLK_EFILE_API_KEY` | No | `""` | API authentication key for the EFSP proxy service. | | `OPENAI_API_KEY` | Optional | `None` | API key for OpenAI or compatible LLM provider used for document extraction. | | `OPENAI_BASE_URL` | Optional | `https://api.openai.com/v1/` | Base URL for OpenAI-compatible endpoint (or local LLM gateway). | diff --git a/docs/docs/admin/deployment.md b/docs/docs/admin/deployment.md index 51dfba1b..82949b4b 100644 --- a/docs/docs/admin/deployment.md +++ b/docs/docs/admin/deployment.md @@ -61,10 +61,12 @@ primary_region = 'lax' [env] DJANGO_SETTINGS_MODULE = "efile.settings_staging" + FILING_CODE_SYNC_MODE = "legacy" [deploy] # Run database migrations before each release release_command = "uv run python manage.py migrate --noinput --fake-initial" + release_command_timeout = "30m" [processes] app = "uv run gunicorn efile.asgi:application -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000 --workers 2 --timeout 60" @@ -114,20 +116,19 @@ uv run python manage.py refresh_filing_code_index uv run python manage.py refresh_filing_code_index --jurisdiction vermont ``` -Refreshes traverse all filing courts and their category, case-type, and filing-type -lists, including both new and existing filings. This can take time; it runs outside -web requests and the release command. Entries are staged in compressed disk storage -and inserted in batches. Each jurisdiction is replaced in one transaction only -after its complete catalog was fetched. A failed request or empty catalog preserves -the previous snapshot; the command reports failures and exits unsuccessfully in -one-shot mode. Temporary HTTP failures are retried before abandoning a refresh. -The recurring worker retries failed states after one minute (`--retry-interval`) -and skips fresh indexes, including after a restart. Monitor worker logs for -refresh failures. Complete court checkpoints are reused for up to six hours after -a failed build. `--cache-dir` controls their location; Compose uses -`/data/filing-code-cache` in the shared volume so a container replacement retains -progress. Checkpoints are separated by jurisdiction, source service, and rules -revision. Incomplete courts are never reused or published. +Fly's configuration uses `FILING_CODE_SYNC_MODE=legacy` so LITEFile can deploy +before the proxy update, retaining the existing full crawler. After deploying the +proxy's `filing_catalog` manifest and per-court bulk exports, change that setting +to `bulk` and redeploy. The first bulk import rebuilds the catalog once; +subsequent checks download and replace only changed courts. +These endpoints read the proxy's installed code tables without contacting Tyler. +LITEFile accesses them over HTTP, keeping the two databases separate. + +Downloads finish before changed courts are replaced in one transaction; failures +preserve the existing index. PostgreSQL uses COPY for imports. The worker retries +failed states after one minute and skips fresh indexes after a restart. To use +the older full crawler during rollout, explicitly pass `--legacy-crawl` (or +`./run_all.sh --legacy-code-crawl`). `--cache-dir` applies only to that crawler. Local Docker Compose also starts `code_index_worker` after the web container is healthy, using the shared database volume. For an already-running local stack, diff --git a/efile_app/efile/db_expressions.py b/efile_app/efile/db_expressions.py new file mode 100644 index 00000000..b356d5c2 --- /dev/null +++ b/efile_app/efile/db_expressions.py @@ -0,0 +1,14 @@ +"""Portable, indexable expressions for filing catalog updates.""" + +from django.db.models import CharField, Func + + +class CourtCode(Func): + # Literal paths let SQLite match the expression index. Parameterized JSON + # paths in ordinary court__code lookups cannot use that index. + function = "JSON_EXTRACT" + template = "JSON_EXTRACT(%(expressions)s, '$.code')" + output_field = CharField() + + def as_postgresql(self, compiler, connection, **extra_context): + return self.as_sql(compiler, connection, template="(%(expressions)s ->> 'code')", **extra_context) diff --git a/efile_app/efile/management/commands/refresh_filing_code_index.py b/efile_app/efile/management/commands/refresh_filing_code_index.py index ce83bf9a..fbf4f00b 100644 --- a/efile_app/efile/management/commands/refresh_filing_code_index.py +++ b/efile_app/efile/management/commands/refresh_filing_code_index.py @@ -13,16 +13,30 @@ class Command(BaseCommand): - help = "Refresh filing search for all jurisdictions, preserving previous snapshots on failure." + help = "Sync changed court code exports, preserving previous snapshots on failure." def add_arguments(self, parser): parser.add_argument("--jurisdiction", choices=rules()[0]["jurisdictions"]) parser.add_argument("--interval", type=int, default=0, help="Repeat after this many seconds (0: run once).") parser.add_argument("--retry-interval", type=int, default=60, help="Seconds before retrying failed states.") + parser.add_argument( + "--legacy-crawl", action="store_true", help="Explicitly use the expensive full list-API crawler." + ) + parser.add_argument( + "--court", + action="append", + default=[], + help="Re-download this court even if its revision is unchanged (repeatable; needs --jurisdiction).", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Download and check changed courts and report path counts, without saving anything.", + ) parser.add_argument( "--cache-dir", default=str(Path.home() / ".cache" / "litefile" / "filing-code-cache"), - help="Compressed court checkpoints, reused for six hours.", + help="Legacy crawler checkpoints, reused for six hours.", ) def handle(self, *args, **options): @@ -32,6 +46,10 @@ def handle(self, *args, **options): retry_interval = options["retry_interval"] if retry_interval <= 0: raise CommandError("Retry interval must be positive") + if options["court"] and not options["jurisdiction"]: + raise CommandError("--court needs --jurisdiction") + if (options["court"] or options["dry_run"]) and interval: + raise CommandError("--court and --dry-run run once; drop --interval") jurisdictions = [options["jurisdiction"]] if options["jurisdiction"] else rules()[0]["jurisdictions"] while True: failures = [] @@ -52,10 +70,14 @@ def handle(self, *args, **options): jurisdiction, cache_dir=options["cache_dir"], progress=lambda court, state=jurisdiction: self.stdout.write(f"{state}: {court}") - if options["verbosity"] > 1 + if options["verbosity"] > 1 or options["dry_run"] else None, + force=options["court"], + dry_run=options["dry_run"], + **({"transport": "legacy"} if options["legacy_crawl"] else {}), ) - self.stdout.write(self.style.SUCCESS(f"{jurisdiction}: indexed {count} filing paths")) + verb = "would synchronize" if options["dry_run"] else "synchronized" + self.stdout.write(self.style.SUCCESS(f"{jurisdiction}: {verb} {count} changed filing paths")) # DatabaseError covers a lost connection and an overlapping run # creating the same jurisdiction's row first (IntegrityError). except (requests.RequestException, ValueError, DatabaseError) as error: diff --git a/efile_app/efile/migrations/0035_incremental_filing_catalog.py b/efile_app/efile/migrations/0035_incremental_filing_catalog.py new file mode 100644 index 00000000..2e87b95a --- /dev/null +++ b/efile_app/efile/migrations/0035_incremental_filing_catalog.py @@ -0,0 +1,42 @@ +# Generated by Django 5.2.17 on 2026-10-06 16:25 + +import efile.db_expressions +from django.db import migrations, models +from django.contrib.postgres.operations import AddIndexConcurrently + + +class AddCourtIndex(AddIndexConcurrently): + """Avoid blocking production writes while indexing an existing large catalog.""" + + def database_forwards(self, app_label, schema_editor, from_state, to_state): + if schema_editor.connection.vendor == "postgresql": + super().database_forwards(app_label, schema_editor, from_state, to_state) + else: + migrations.AddIndex.database_forwards(self, app_label, schema_editor, from_state, to_state) + + def database_backwards(self, app_label, schema_editor, from_state, to_state): + if schema_editor.connection.vendor == "postgresql": + super().database_backwards(app_label, schema_editor, from_state, to_state) + else: + migrations.AddIndex.database_backwards(self, app_label, schema_editor, from_state, to_state) + + +class Migration(migrations.Migration): + + atomic = False + + dependencies = [ + ('efile', '0034_sqlite_filing_search_index'), + ] + + operations = [ + migrations.AddField( + model_name='filingcodeindex', + name='court_snapshots', + field=models.JSONField(default=dict), + ), + AddCourtIndex( + model_name='filingcodepath', + index=models.Index(models.F('index'), efile.db_expressions.CourtCode('court'), name='filing_path_court'), + ), + ] diff --git a/efile_app/efile/models.py b/efile_app/efile/models.py index a8add5de..bdfc096e 100644 --- a/efile_app/efile/models.py +++ b/efile_app/efile/models.py @@ -4,6 +4,7 @@ from django.db import models, transaction from django.utils import timezone +from efile.db_expressions import CourtCode from efile.party_sides import PARTY_SIDE_CHOICES from efile.workflow import ExistingCase, WorkflowStepKey, get_workflow_step_choices @@ -16,6 +17,7 @@ class FilingCodeIndex(models.Model): refreshed_at = models.DateTimeField() rules_digest = models.CharField(max_length=64) vocabulary = models.JSONField(default=dict) + court_snapshots = models.JSONField(default=dict) class FilingCodePath(models.Model): @@ -33,6 +35,9 @@ class FilingCodePath(models.Model): explanation = models.TextField(blank=True) explanation_source = models.URLField(blank=True) + class Meta: + indexes = [models.Index(models.F("index"), CourtCode("court"), name="filing_path_court")] + class UserProfile(AbstractUser): """ diff --git a/efile_app/efile/services/filing_code_search.py b/efile_app/efile/services/filing_code_search.py index 2c1198cd..6aed1227 100644 --- a/efile_app/efile/services/filing_code_search.py +++ b/efile_app/efile/services/filing_code_search.py @@ -391,7 +391,21 @@ def fetch_filings(case_type, court=court, category=category, initial=initial): ) -def refresh_index(jurisdiction, *, progress=None, cache_dir=None): +def refresh_index(jurisdiction, *, progress=None, cache_dir=None, transport=None, force=(), dry_run=False): + """Synchronize changed court exports; the expensive old crawler requires explicit opt-in.""" + mode = transport or getattr(settings, "FILING_CODE_SYNC_MODE", "bulk") + if mode == "bulk": + from efile.services.filing_code_sync import synchronize_index + + return synchronize_index(jurisdiction, progress=progress, force=force, dry_run=dry_run) + if mode != "legacy": + raise ValueError("FILING_CODE_SYNC_MODE must be 'bulk' or 'legacy'") + if force or dry_run: + raise ValueError("Forcing courts and dry runs need the bulk sync") + return _refresh_index_legacy(jurisdiction, progress=progress, cache_dir=cache_dir) + + +def _refresh_index_legacy(jurisdiction, *, progress=None, cache_dir=None): """Replace a jurisdiction atomically after *every* branch was fetched. No database transaction is held open during the slow network traversal. @@ -437,6 +451,7 @@ def refresh_index(jurisdiction, *, progress=None, cache_dir=None): index.refreshed_at = started index.rules_digest = rules()[1] index.vocabulary = {"words": sorted(vocabulary), "tokens": sorted(indexed_terms)} + index.court_snapshots = {} index.save() return count diff --git a/efile_app/efile/services/filing_code_sync.py b/efile_app/efile/services/filing_code_sync.py new file mode 100644 index 00000000..77c5bcbd --- /dev/null +++ b/efile_app/efile/services/filing_code_sync.py @@ -0,0 +1,279 @@ +"""Incremental public-catalog imports across the proxy's HTTP service boundary. + +One small manifest identifies changed courts. A compact export contains their +code tables and relationships; expansion and search rules belong to LITEFile. +Downloads and validation finish before a transaction replaces changed courts. +""" + +import gzip +import hashlib +import json +from contextlib import closing +from itertools import islice +from pathlib import Path +from tempfile import TemporaryDirectory +from urllib.parse import quote + +from django.db import connection, transaction +from django.utils import timezone +from psycopg import Cursor +from psycopg.types.json import Jsonb + +from efile.db_expressions import CourtCode +from efile.models import FilingCodeIndex, FilingCodePath +from efile.services.court_selection import is_non_filing_court +from efile.services.filing_code_search import ( + FACETS, + CodeCatalog, + explanation_for, + packed, + rules, + search_tokens, + source_url, + stems, + words, +) + + +class BulkCodeCatalog(CodeCatalog): + def __init__(self, jurisdiction): + super().__init__(jurisdiction) + self.jurisdiction = jurisdiction + self.base = f"{source_url()}/jurisdictions/{jurisdiction}/codes/filing_catalog" + + def read(self, suffix=""): + with self.session.get(self.base + suffix, timeout=self.timeout) as response: + if response.status_code == 404 and not suffix: + raise ValueError("Deploy the proxy filing_catalog API first, or explicitly use --legacy-crawl.") + response.raise_for_status() + data = response.json() + if not isinstance(data, dict) or data.get("version") != 1 or data.get("jurisdiction") != self.jurisdiction: + raise ValueError("Unsupported or wrong-jurisdiction filing catalog export") + return data + + def manifest(self): + data = self.read() + if not isinstance(data.get("courts"), list) or not data["courts"]: + raise ValueError("Empty or invalid filing catalog manifest; keeping the previous index") + courts = {} + for court in data["courts"]: + _option(court) + revision = court.get("revision") + if not isinstance(revision, str) or not revision: + raise ValueError("Invalid court catalog revision") + if court["code"] in courts: + raise ValueError("Duplicate court in filing catalog manifest") + if not is_non_filing_court(court["name"]): + courts[court["code"]] = court + if not courts: + raise ValueError("No filing courts in catalog manifest; keeping the previous index") + return courts + + def court(self, court): + data = self.read(f"/courts/{quote(court['code'], safe='')}") + if data.get("court") != court: + raise ValueError("Court export does not match its manifest revision") + validate_export(data) + return data + + +def _option(value): + if not isinstance(value, dict) or any( + not isinstance(value.get(key), str) or not value[key] for key in ("code", "name") + ): + raise ValueError("Invalid option in filing catalog export") + + +def validate_export(data): + for table in ("categories", "case_types", "filing_types"): + if not isinstance(data.get(table), list): + raise ValueError("Missing code table in court export") + for row in data[table]: + _option(row) + if table != "categories" and ( + "case_category" not in row + or row["case_category"] is not None + and not isinstance(row["case_category"], str) + ): + raise ValueError("Invalid category relationship in court export") + if table == "case_types" and type(row.get("initial")) is not bool: + raise ValueError("Invalid case-type timing in court export") + if table == "filing_types" and ( + "case_type" not in row + or row["case_type"] is not None + and not isinstance(row["case_type"], str) + or row.get("timing") not in ("Initial", "Subsequent", "Both") + ): + raise ValueError("Invalid filing-type relationship or timing in court export") + + +def export_entries(data, jurisdiction): + """Match the existing proxy filters, including its specific-before-generic fallback.""" + court = {key: data["court"][key] for key in ("code", "name")} + categories = {row["code"]: row for row in data["categories"]} + case_types = {(row["case_category"], row["code"]): row for row in data["case_types"]} + for initial in (True, False): + # The proxy's category endpoint gates each timing on at least one case + # type with that initial flag. Subsequent type lists then include all + # case types in those categories, matching its existing SQL exactly. + offered_categories = {row["case_category"] for row in case_types.values() if row["initial"] == initial} + by_category, by_type, generic = {}, {}, [] + for filing in data["filing_types"]: + if filing["timing"] not in (("Initial", "Both") if initial else ("Subsequent", "Both")): + continue + if filing["case_type"] == "": + if filing["case_category"] == "": + generic.append(filing) + elif filing["case_category"] is not None: + by_category.setdefault(filing["case_category"], []).append(filing) + elif filing["case_type"] is not None: + by_type.setdefault(filing["case_type"], []).append(filing) + for case_type in case_types.values(): + category = categories.get(case_type["case_category"]) + if category is None or category["code"] not in offered_categories or initial and not case_type["initial"]: + continue + # Filtered list APIs deduplicate options by code after choosing specific or generic lists. + selected = by_category.get(category["code"], []) + by_type.get(case_type["code"], []) + filings = {row["code"]: row for row in selected or generic} + for filing in filings.values(): + path = dict(zip(FACETS, (court, category, case_type, filing), strict=True)) + path = {facet: {key: value[key] for key in ("code", "name")} for facet, value in path.items()} + terms = {facet: search_tokens(path[facet]["name"], jurisdiction) for facet in FACETS} + all_terms = set.union(*terms.values()) + all_terms.update(token for facet in FACETS for token in stems(path[facet]["code"])) + explanation, source = explanation_for(jurisdiction, path, initial) + yield dict( + **path, + initial=initial, + search_text=packed(all_terms), + filing_terms=packed(terms["filing_type"]), + case_terms=packed(terms["case_category"] | terms["case_type"]), + explanation=explanation, + explanation_source=source, + ) + + +def _stage(data, jurisdiction, path): + vocabulary, tokens, count = set(), set(), 0 + with gzip.open(path, "wt", encoding="utf-8", compresslevel=1) as stream: + for entry in export_entries(data, jurisdiction): + stream.write(json.dumps(entry) + "\n") + tokens.update(entry["search_text"].split()) + vocabulary.update(word for facet in FACETS for word in words(entry[facet]["name"])) + count += 1 + return {"count": count, "words": sorted(vocabulary), "tokens": sorted(tokens)} + + +def _insert_paths(index, snapshot, count, progress): + """COPY on Postgres; bounded ORM batches on SQLite. IDs come from each database's sequence.""" + saved = 0 + if connection.vendor == "postgresql": + fields = [field for field in FilingCodePath._meta.concrete_fields if not field.primary_key] + columns = ", ".join(connection.ops.quote_name(field.column) for field in fields) + table = connection.ops.quote_name(FilingCodePath._meta.db_table) + # Django defaults to psycopg's ClientCursor, which does not implement COPY. + with Cursor(connection.connection) as cursor, cursor.copy(f"COPY {table} ({columns}) FROM STDIN") as copy: + for line in snapshot: + entry = json.loads(line) + entry["index"] = index.pk + copy.write_row( + [ + Jsonb(entry[field.name]) if field.get_internal_type() == "JSONField" else entry[field.name] + for field in fields + ] + ) + saved += 1 + if progress and saved % 100000 == 0: + progress(f"Saved {saved} of {count} changed filing paths") + else: + while batch := [FilingCodePath(index=index, **json.loads(line)) for line in islice(snapshot, 500)]: + FilingCodePath.objects.bulk_create(batch, batch_size=500) + saved += len(batch) + if progress and saved % 100000 == 0: + progress(f"Saved {saved} of {count} changed filing paths") + + +def synchronize_index(jurisdiction, *, progress=None, force=(), dry_run=False): + """Re-download courts whose revision changed, plus any court codes in ``force``. + + ``dry_run`` downloads and checks everything, reports each changed court, and saves nothing. + """ + started = timezone.now() + baseline = FilingCodeIndex.objects.filter(jurisdiction=jurisdiction).first() + previous = ( + baseline if baseline and baseline.source_url == source_url() and baseline.rules_digest == rules()[1] else None + ) + snapshots = previous.court_snapshots if previous else {} + # Stage every changed court before publishing anything. A failure preserves the whole old index. + with closing(BulkCodeCatalog(jurisdiction)) as catalog, TemporaryDirectory(prefix="filing-sync-") as directory: + courts = catalog.manifest() + if unknown := set(force) - set(courts): + raise ValueError("Not filing courts in the proxy's catalog manifest: " + ", ".join(sorted(unknown))) + next_snapshots, staged = {}, {} + downloaded = False + for code, court in courts.items(): + old = snapshots.get(code) + if old and old["revision"] == court["revision"] and code not in force: + next_snapshots[code] = old + continue + if progress: + progress(f"Downloading changed court: {court['name']}") + data = catalog.court(court) + downloaded = True + path = Path(directory) / f"{hashlib.sha256(code.encode()).hexdigest()}.json.gz" + metadata = _stage(data, jurisdiction, path) + # A court that loses every path almost always means the proxy served a half-loaded + # court. Keep the old index rather than publishing an empty court. + if old and old["count"] and not metadata["count"] and code not in force: + raise ValueError( + f"{court['name']} ({code}) now has no filing paths (had {old['count']}); keeping the " + f"previous index. If that's expected, re-run with --court {code}." + ) + if progress: + progress(f"{court['name']} ({code}): {old['count'] if old else 'new'} -> {metadata['count']} paths") + next_snapshots[code] = dict(metadata, revision=court["revision"]) + staged[code] = path + if (downloaded or set(snapshots) - set(courts)) and catalog.manifest() != courts: + raise ValueError("Proxy catalog changed during synchronization; keeping the previous index and retrying") + if not sum(item["count"] for item in next_snapshots.values()): + raise ValueError("No filing paths returned; keeping the previous index") + vocabulary = set().union(*(set(item["words"]) for item in next_snapshots.values())) + for concept in rules()[0]["concepts"].values(): + if jurisdiction in concept.get("jurisdictions", rules()[0]["jurisdictions"]): + vocabulary.update(word for term in concept["terms"] for word in words(term)) + tokens = set().union(*(set(item["tokens"]) for item in next_snapshots.values())) + count = sum(next_snapshots[code]["count"] for code in staged) + if progress: + progress( + f"{len(staged)} changed courts; {len(courts) - len(staged)} unchanged; saving {count} filing paths" + ) + if dry_run: + return count + with transaction.atomic(): + index, created = FilingCodeIndex.objects.get_or_create( + jurisdiction=jurisdiction, + defaults={"source_url": source_url(), "refreshed_at": started, "rules_digest": rules()[1]}, + ) + index = FilingCodeIndex.objects.select_for_update().get(pk=index.pk) + if ( + index.refreshed_at > started + or not created + and (baseline is None or index.refreshed_at != baseline.refreshed_at) + ): + return 0 + if previous is None or not snapshots: + # One-time bootstrap from legacy indexes, or a different source/rules revision. + index.paths.all().delete() + else: + for code in set(snapshots) - set(courts) | set(staged): + index.paths.alias(catalog_court_code=CourtCode("court")).filter(catalog_court_code=code).delete() + for code, path in staged.items(): + with gzip.open(path, "rt", encoding="utf-8") as snapshot: + _insert_paths(index, snapshot, next_snapshots[code]["count"], progress) + index.source_url = source_url() + index.rules_digest = rules()[1] + index.refreshed_at = started + index.court_snapshots = next_snapshots + index.vocabulary = {"words": sorted(vocabulary), "tokens": sorted(tokens)} + index.save() + return count diff --git a/efile_app/efile/settings_base.py b/efile_app/efile/settings_base.py index 1b2d93a2..2ee11d37 100644 --- a/efile_app/efile/settings_base.py +++ b/efile_app/efile/settings_base.py @@ -131,6 +131,7 @@ # Base URL for EFSP-compatible APIs (Suffolk LIT Lab by default) # Override per environment with EFSP_URL env var EFSP_URL = os.getenv("EFSP_URL", "https://efile-test.suffolklitlab.org") +FILING_CODE_SYNC_MODE = os.getenv("FILING_CODE_SYNC_MODE", "bulk") EFILE_SUBMISSION_CONNECT_TIMEOUT_SECONDS = int(os.getenv("EFILE_SUBMISSION_CONNECT_TIMEOUT_SECONDS", "10")) EFILE_SUBMISSION_READ_TIMEOUT_SECONDS = int(os.getenv("EFILE_SUBMISSION_READ_TIMEOUT_SECONDS", "300")) diff --git a/efile_app/efile/tests/test_filing_code_search.py b/efile_app/efile/tests/test_filing_code_search.py index bfaebbea..0e9014ea 100644 --- a/efile_app/efile/tests/test_filing_code_search.py +++ b/efile_app/efile/tests/test_filing_code_search.py @@ -33,6 +33,13 @@ pytestmark = pytest.mark.django_db +@pytest.fixture(autouse=True) +def legacy_catalog_transport(settings): + # These tests exercise the legacy reader and shared search/validation behavior. + # The bulk transport and incremental imports have separate contract tests. + settings.FILING_CODE_SYNC_MODE = "legacy" + + def option(code, name): return {"code": code, "name": name} @@ -622,6 +629,7 @@ def test_management_command_attempts_each_jurisdiction_and_reports_failure(): assert [call.args[0] for call in refresh.call_args_list] == ["illinois", "massachusetts", "vermont"] +@pytest.mark.django_db(transaction=True) def test_worker_retries_failed_state_soon_and_skips_fresh_states(index): from efile.models import FilingCodeIndex diff --git a/efile_app/efile/tests/test_filing_code_sync.py b/efile_app/efile/tests/test_filing_code_sync.py new file mode 100644 index 00000000..3934ea0e --- /dev/null +++ b/efile_app/efile/tests/test_filing_code_sync.py @@ -0,0 +1,196 @@ +"""Incremental imports preserve unchanged courts and roll back failed updates.""" + +from copy import deepcopy +from unittest.mock import patch + +import pytest +import requests + +from efile.services.filing_code_search import current_index, refresh_index, search_paths +from efile.services.filing_code_sync import export_entries + +pytestmark = pytest.mark.django_db + + +def export(code="housing", name="Boston Housing Court", label="first"): + return { + "version": 1, + "jurisdiction": "massachusetts", + "court": {"code": code, "name": name, "revision": code + label}, + "categories": [{"code": "civil", "name": "Civil"}], + "case_types": [ + {"code": "sp", "name": "Summary Process", "case_category": "civil", "initial": True}, + {"code": "debt", "name": "Debt collection", "case_category": "civil", "initial": False}, + ], + "filing_types": [ + {"code": "complaint", "name": "Complaint", "case_category": "", "case_type": "", "timing": "Initial"}, + {"code": "answer", "name": "Answer", "case_category": "", "case_type": "", "timing": "Subsequent"}, + { + "code": "motion", + "name": "Motion to Dismiss", + "case_category": None, + "case_type": "sp", + "timing": "Subsequent", + }, + ], + } + + +@pytest.fixture +def bulk(settings): + settings.FILING_CODE_SYNC_MODE = "bulk" + data = { + "housing": export(), + "district": export("district", "Boston District Court"), + } + with patch("efile.services.filing_code_sync.BulkCodeCatalog") as factory: + catalog = factory.return_value + catalog.jurisdiction = "massachusetts" + catalog.manifest.side_effect = lambda: {code: deepcopy(item["court"]) for code, item in data.items()} + catalog.court.side_effect = lambda court: deepcopy(data[court["code"]]) + yield data, catalog + + +def test_initial_import_and_unchanged_refresh_do_not_rewrite_paths(bulk): + _, catalog = bulk + assert refresh_index("massachusetts") == 6 + index = current_index("massachusetts") + ids = list(index.paths.order_by("pk").values_list("pk", flat=True)) + assert search_paths(index, "eviction")["total"] == 2 + stamp = index.refreshed_at + catalog.reset_mock() + assert refresh_index("massachusetts") == 0 + assert catalog.manifest.call_count == 1 + catalog.court.assert_not_called() + index.refresh_from_db() + assert index.refreshed_at > stamp + assert list(index.paths.order_by("pk").values_list("pk", flat=True)) == ids + + +def test_changed_court_only_and_retired_court_cleanup(bulk): + data, catalog = bulk + refresh_index("massachusetts") + index = current_index("massachusetts") + ids = set(index.paths.filter(court__code="district").values_list("pk", flat=True)) + data["housing"]["court"]["revision"] = "housing changed" + data["housing"]["filing_types"][0]["name"] = "New complaint" + catalog.reset_mock() + assert refresh_index("massachusetts") == 3 + assert [call.args[0]["code"] for call in catalog.court.call_args_list] == ["housing"] + assert set(index.paths.filter(court__code="district").values_list("pk", flat=True)) == ids + assert index.paths.filter(filing_type__name="New complaint").exists() + del data["housing"] + assert refresh_index("massachusetts") == 0 + assert not index.paths.filter(court__code="housing").exists() + index.refresh_from_db() + assert set(index.court_snapshots) == {"district"} + assert set(index.paths.values_list("pk", flat=True)) == ids + + +def test_failed_download_or_changed_manifest_preserves_entire_index(bulk): + data, catalog = bulk + refresh_index("massachusetts") + index = current_index("massachusetts") + ids = set(index.paths.values_list("pk", flat=True)) + data["housing"]["court"]["revision"] = "update" + catalog.court.side_effect = requests.Timeout("unavailable") + with pytest.raises(requests.Timeout): + refresh_index("massachusetts") + assert set(index.paths.values_list("pk", flat=True)) == ids + catalog.court.side_effect = lambda court: deepcopy(data[court["code"]]) + manifest = {code: item["court"] for code, item in data.items()} + catalog.manifest.side_effect = [manifest, {"housing": data["housing"]["court"]}] + with pytest.raises(ValueError, match="changed during synchronization"): + refresh_index("massachusetts") + assert set(index.paths.values_list("pk", flat=True)) == ids + # A removal-only refresh must also reject a manifest that changes mid-import. + catalog.reset_mock() + catalog.manifest.side_effect = [{"district": manifest["district"]}, manifest] + with pytest.raises(ValueError, match="changed during synchronization"): + refresh_index("massachusetts") + catalog.court.assert_not_called() + assert set(index.paths.values_list("pk", flat=True)) == ids + + +def test_database_failure_rolls_back_changes_and_deletions(bulk): + data, _ = bulk + refresh_index("massachusetts") + index = current_index("massachusetts") + ids = set(index.paths.values_list("pk", flat=True)) + del data["district"] + data["housing"]["court"]["revision"] = "changed" + data["housing"]["filing_types"][0]["name"] = "Changed" + with patch("efile.services.filing_code_sync._insert_paths", side_effect=ValueError("insert failed")): + with pytest.raises(ValueError, match="insert failed"): + refresh_index("massachusetts") + assert set(index.paths.values_list("pk", flat=True)) == ids + index.refresh_from_db() + assert set(index.court_snapshots) == {"housing", "district"} + + +def test_specific_filing_lists_override_generic_lists_and_timing_is_preserved(): + data = export() + entries = list(export_entries(data, "massachusetts")) + assert {(entry["case_type"]["code"], entry["filing_type"]["code"], entry["initial"]) for entry in entries} == { + ("sp", "complaint", True), + ("sp", "motion", False), + ("debt", "answer", False), + } + data["filing_types"].append( + { + "code": "category", + "name": "Category filing", + "case_category": "civil", + "case_type": "", + "timing": "Both", + } + ) + entries = list(export_entries(data, "massachusetts")) + assert not any(entry["filing_type"]["code"] in ("complaint", "answer") for entry in entries) + assert len(entries) == 4 + + +def test_subsequent_category_gate_matches_proxy_sql(): + data = export() + data["case_types"] = [data["case_types"][0]] + entries = list(export_entries(data, "massachusetts")) + assert len(entries) == 1 + assert entries[0]["initial"] is True + + +def test_forced_court_redownloads_without_a_revision_change(bulk): + _, catalog = bulk + refresh_index("massachusetts") + catalog.reset_mock() + assert refresh_index("massachusetts", force=["housing"]) == 3 + assert [call.args[0]["code"] for call in catalog.court.call_args_list] == ["housing"] + with pytest.raises(ValueError, match="nowhere"): + refresh_index("massachusetts", force=["nowhere"]) + + +def test_dry_run_reports_changes_without_saving(bulk): + data, _ = bulk + refresh_index("massachusetts") + index = current_index("massachusetts") + ids, snapshots = set(index.paths.values_list("pk", flat=True)), index.court_snapshots + data["housing"]["court"]["revision"] = "changed" + reports = [] + assert refresh_index("massachusetts", dry_run=True, progress=reports.append) == 3 + assert "Boston Housing Court (housing): 3 -> 3 paths" in reports + index.refresh_from_db() + assert index.court_snapshots == snapshots + assert set(index.paths.values_list("pk", flat=True)) == ids + + +def test_court_emptied_by_a_partial_proxy_load_is_rejected_unless_forced(bulk): + data, _ = bulk + refresh_index("massachusetts") + index = current_index("massachusetts") + ids = set(index.paths.values_list("pk", flat=True)) + data["housing"]["court"]["revision"] = "half loaded" + data["housing"]["filing_types"] = [] + with pytest.raises(ValueError, match="no filing paths"): + refresh_index("massachusetts") + assert set(index.paths.values_list("pk", flat=True)) == ids + assert refresh_index("massachusetts", force=["housing"]) == 0 + assert not index.paths.filter(court__code="housing").exists() diff --git a/fly.toml b/fly.toml index a7cd0ed7..cf864f25 100644 --- a/fly.toml +++ b/fly.toml @@ -10,10 +10,13 @@ primary_region = 'lax' [env] DJANGO_SETTINGS_MODULE = "efile.settings_staging" + # Keep existing code imports until the proxy filing_catalog API is deployed. + FILING_CODE_SYNC_MODE = "legacy" [deploy] # Run database migrations before each release release_command = "uv run python manage.py migrate --noinput --fake-initial" + release_command_timeout = "30m" [processes] app = "uv run gunicorn efile.asgi:application -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000 --workers 2 --timeout 60 --access-logfile - --error-logfile - --log-level debug" diff --git a/run_all.sh b/run_all.sh new file mode 100755 index 00000000..45f6bd7d --- /dev/null +++ b/run_all.sh @@ -0,0 +1,315 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Script location and project paths +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$SCRIPT_DIR" +APP_DIR="$REPO_ROOT/efile_app" + +# Terminal colors (if running in interactive terminal) +if [ -t 1 ]; then + BOLD="\033[1m" + GREEN="\033[0;32m" + BLUE="\033[0;34m" + YELLOW="\033[0;33m" + RED="\033[0;31m" + NC="\033[0m" # No Color +else + BOLD="" + GREEN="" + BLUE="" + YELLOW="" + RED="" + NC="" +fi + +log_info() { + echo -e "${BLUE}[INFO]${NC} $*" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $*" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $*" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $*" >&2 +} + +# Default configuration +HOST="127.0.0.1" +PORT="8000" +START_LOCALSTACK=true +STOP_DOCKER_ON_EXIT=false +START_EXTRACTION_WORKER=true +START_CODE_INDEX_WORKER=true +LEGACY_CODE_CRAWL=false +SKIP_SYNC=false +SKIP_MIGRATE=false + +show_help() { + echo -e "${BOLD}Usage:${NC} ./run_all.sh [options]" + echo "" + echo "Start the LITEFile application for local development without building Docker containers." + echo "Automatically sets up a local .venv (using Astral uv), runs database migrations," + echo "manages LocalStack for S3 document storage, and starts extraction and filing code search workers." + echo "" + echo -e "${BOLD}Options:${NC}" + echo " --host Bind address for Django development server (default: 127.0.0.1)" + echo " --port , -p Port for Django development server (default: 8000)" + echo " --no-docker Skip starting LocalStack via Docker" + echo " --stop-docker-on-exit Stop LocalStack container when exiting (default: keep running)" + echo " --no-worker Do not start the document extraction worker" + echo " --no-code-index Do not start the filing code index refresh worker (enabled by default)" + echo " --with-code-index Enable the filing code index worker (default; kept for compatibility)" + echo " --legacy-code-crawl Use the expensive full crawler until the proxy bulk API is deployed" + echo " --skip-sync Skip 'uv sync' check on startup" + echo " --skip-migrate Skip running database migrations on startup" + echo " -h, --help Show this help message and exit" + echo "" + echo -e "${BOLD}Examples:${NC}" + echo " ./run_all.sh" + echo " ./run_all.sh --port 8080" + echo " ./run_all.sh --no-docker --no-worker" + echo " ./run_all.sh --no-code-index" +} + +# Parse command line arguments +while [[ $# -gt 0 ]]; do + case "$1" in + --host) + HOST="$2" + shift 2 + ;; + --port|-p) + PORT="$2" + shift 2 + ;; + --no-docker|--skip-localstack|--no-localstack) + START_LOCALSTACK=false + shift + ;; + --stop-docker-on-exit) + STOP_DOCKER_ON_EXIT=true + shift + ;; + --no-worker|--skip-worker) + START_EXTRACTION_WORKER=false + shift + ;; + --with-code-index) + START_CODE_INDEX_WORKER=true + shift + ;; + --no-code-index) + START_CODE_INDEX_WORKER=false + shift + ;; + --legacy-code-crawl) + LEGACY_CODE_CRAWL=true + shift + ;; + --skip-sync) + SKIP_SYNC=true + shift + ;; + --skip-migrate) + SKIP_MIGRATE=true + shift + ;; + -h|--help) + show_help + exit 0 + ;; + *) + log_error "Unknown option: $1" + echo "Run './run_all.sh --help' for available options." + exit 1 + ;; + esac +done + +# 1. Ensure Astral uv is available +ensure_uv() { + if command -v uv >/dev/null 2>&1; then + return 0 + fi + for candidate in "$HOME/.local/bin/uv" "$HOME/.cargo/bin/uv"; do + if [ -x "$candidate" ]; then + export PATH="$(dirname "$candidate"):$PATH" + return 0 + fi + done + + log_warn "'uv' not found in PATH. Attempting to install via official script..." + if command -v curl >/dev/null 2>&1; then + curl -LsSf https://astral.sh/uv/install.sh | sh + export PATH="$HOME/.local/bin:$PATH" + if command -v uv >/dev/null 2>&1; then + log_success "'uv' installed successfully." + return 0 + fi + fi + + log_error "Astral 'uv' is required to manage dependencies and virtualenv." + log_error "Install uv with: curl -LsSf https://astral.sh/uv/install.sh | sh" + exit 1 +} + +ensure_uv + +# 2. Check / initialize environment file (.env) +if [ ! -f "$APP_DIR/.env" ]; then + if [ -f "$APP_DIR/.env.example" ]; then + log_info "No efile_app/.env found. Creating one from efile_app/.env.example..." + cp "$APP_DIR/.env.example" "$APP_DIR/.env" + log_success "Created efile_app/.env" + else + log_warn "Neither efile_app/.env nor efile_app/.env.example found." + fi +fi + +# 3. Create / sync local .venv +if [ "$SKIP_SYNC" = false ]; then + log_info "Synchronizing dependencies and virtual environment (uv sync --group dev)..." + uv --directory "$APP_DIR" sync --group dev + log_success "Virtual environment ready at efile_app/.venv" +fi + +# 4. Optional LocalStack (S3 mock) startup via Docker +LOCALSTACK_STARTED=false +if [ "$START_LOCALSTACK" = true ]; then + if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then + log_info "Starting LocalStack (S3 mock) container..." + docker compose -f "$REPO_ROOT/compose.yml" up -d localstack + LOCALSTACK_STARTED=true + log_success "LocalStack is running on port 4566." + + # Ensure target S3 bucket exists in LocalStack + uv --directory "$APP_DIR" run python -c " +import os, boto3 +from dotenv import load_dotenv +load_dotenv('.env') +endpoint = os.getenv('AWS_S3_ENDPOINT_URL', '') +bucket = os.getenv('AWS_S3_BUCKET_NAME', '') +if endpoint and ('localhost' in endpoint or '127.0.0.1' in endpoint) and bucket: + try: + s3 = boto3.client( + 's3', + endpoint_url=endpoint, + aws_access_key_id=os.getenv('AWS_ACCESS_KEY_ID', 'test'), + aws_secret_access_key=os.getenv('AWS_SECRET_ACCESS_KEY', 'test'), + region_name=os.getenv('AWS_S3_REGION_NAME', 'us-east-1'), + ) + try: + s3.head_bucket(Bucket=bucket) + except Exception: + s3.create_bucket(Bucket=bucket) + print(f'Created LocalStack S3 bucket: {bucket}') + except Exception as err: + print(f'LocalStack bucket check warning: {err}') +" 2>/dev/null || true + else + log_warn "Docker is not installed or daemon is not running. Skipping LocalStack." + log_warn "Document uploads require AWS S3 or LocalStack running on port 4566." + fi +fi + +# 5. Run database migrations +if [ "$SKIP_MIGRATE" = false ]; then + log_info "Applying database migrations..." + uv --directory "$APP_DIR" run python manage.py migrate --noinput --fake-initial + log_success "Database migrations up to date." +fi + +# 6. Process supervision and cleanup handling +CLEANED_UP=0 +cleanup() { + if [ "$CLEANED_UP" -eq 1 ]; then + return + fi + CLEANED_UP=1 + trap - INT TERM EXIT + + echo "" + log_info "Stopping application services..." + + # Terminate all background child jobs + local pids + pids=$(jobs -p) + if [ -n "$pids" ]; then + for pid in $pids; do + kill -TERM "$pid" 2>/dev/null || true + done + # Allow processes up to 1 second to exit gracefully + sleep 1 + for pid in $pids; do + if kill -0 "$pid" 2>/dev/null; then + kill -KILL "$pid" 2>/dev/null || true + fi + done + wait 2>/dev/null || true + fi + + if [ "$LOCALSTACK_STARTED" = true ]; then + if [ "$STOP_DOCKER_ON_EXIT" = true ]; then + log_info "Stopping LocalStack container..." + docker compose -f "$REPO_ROOT/compose.yml" stop localstack 2>/dev/null || true + else + log_info "LocalStack container left running in background." + log_info "To stop it manually: docker compose stop localstack" + fi + fi + + log_success "All local dev processes stopped." + exit 0 +} + +trap cleanup INT TERM + +echo "" +echo -e "${BOLD}======================================================${NC}" +echo -e "${BOLD}${GREEN} LITEFile local development environment is ready!${NC}" +echo -e "${BOLD}======================================================${NC}" +echo -e " ${BOLD}Web application:${NC} http://${HOST}:${PORT}/" +echo -e " ${BOLD}Login page:${NC} http://${HOST}:${PORT}/login" +echo -e " ${BOLD}Staff portal:${NC} http://${HOST}:${PORT}/staff-7c83f0a2/" +if [ "$START_EXTRACTION_WORKER" = true ]; then + echo -e " ${BOLD}Extraction worker:${NC} ACTIVE (processing PDF lead documents)" +fi +if [ "$START_CODE_INDEX_WORKER" = true ]; then + echo -e " ${BOLD}Code index worker:${NC} ENABLED (building or refreshing court filing codes)" + echo " Filing code search becomes available after the first successful index build." +fi +if [ "$LOCALSTACK_STARTED" = true ]; then + echo -e " ${BOLD}S3 Storage:${NC} LocalStack (http://127.0.0.1:4566)" +fi +echo -e "${BOLD}======================================================${NC}" +echo -e "${YELLOW}Press Ctrl+C to shut down all processes.${NC}" +echo "" + +# 7. Start workers and web server +if [ "$START_EXTRACTION_WORKER" = true ]; then + log_info "Starting document extraction worker in background..." + PYTHONUNBUFFERED=1 uv --directory "$APP_DIR" run python manage.py process_document_extractions & +fi + +if [ "$START_CODE_INDEX_WORKER" = true ]; then + log_info "Starting filing code index worker in background..." + CODE_INDEX_ARGS=(--interval 86400 --verbosity 2) + if [ "$LEGACY_CODE_CRAWL" = true ]; then + log_warn "Legacy code crawling is enabled: every refresh downloads and rebuilds the full catalog." + CODE_INDEX_ARGS+=(--legacy-crawl) + fi + PYTHONUNBUFFERED=1 uv --directory "$APP_DIR" run python manage.py refresh_filing_code_index "${CODE_INDEX_ARGS[@]}" & +fi + +log_info "Starting Django development server at http://${HOST}:${PORT}..." +PYTHONUNBUFFERED=1 uv --directory "$APP_DIR" run python manage.py runserver "${HOST}:${PORT}" & + +# Wait for any child job to exit; if any crashes or exits, trigger cleanup +wait -n || true +cleanup From 4fbf30c901e3534c45c32dba7831620ee25d7158 Mon Sep 17 00:00:00 2001 From: Quinten Steenhuis Date: Tue, 6 Oct 2026 22:01:08 -0400 Subject: [PATCH 5/5] Redesign filing code search and copy codes from the EFSP database Filing code search becomes three steps: what you're filing, court and case type, then a check of the full path. Step 1 pairs the search with a case ZIP and short chip questions (what you want to do, kind of case, case category, claim amount, case details), shows strong name matches first, collapses jury/government/fee code variants, mutes shared name prefixes, explains court terms from a per-state glossary, and links to matches in the other case stage. Step 2 asks only the jury, lawyer, amount, and government questions a court's case types differ on. ZIP narrowing works in every state: MACourts and VTCourts where configured, otherwise a Census ZIP-to-county table, keeping courts nothing can place. Shortcuts offer the filer's own ZIP and their three most recent case ZIPs. Search rules add sealing synonyms and fix personal-injury, malpractice, and fee waiver classification. Filing codes are now copied from the EFSP proxy's codes database over a read-only role (litefile_codes_reader: SELECT on five code tables, read only at the server), in one READ ONLY, REPEATABLE READ snapshot, into per-court copies the index is built from. The code index worker syncs daily after the proxy's Tyler update (21:30 Eastern on staging), and superusers can queue "Resync codes" and "Rebuild code search indexes" from the staff tools. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- compose.yml | 2 +- docs/docs/admin/configuration.md | 5 +- docs/docs/admin/deployment.md | 131 +- .../partners-courts/jurisdiction-config.md | 139 +- efile_app/.env.example | 6 + efile_app/efile/api/filing_code_search.py | 42 +- efile_app/efile/data/case_type_guidance.yaml | 22 +- efile_app/efile/data/filing_code_facets.yaml | 27 + efile_app/efile/data/filing_code_search.yaml | 4 +- efile_app/efile/data/legal_glossary.yaml | 135 ++ efile_app/efile/data/zip_counties.json.gz | Bin 0 -> 355415 bytes .../commands/refresh_filing_code_index.py | 99 +- .../migrations/0036_user_recent_case_zips.py | 18 + .../0037_efsp_code_copies_and_jobs.py | 48 + efile_app/efile/models.py | 55 + efile_app/efile/services/case_location.py | 134 ++ .../efile/services/case_type_guidance.py | 142 +- efile_app/efile/services/court_location.py | 32 + efile_app/efile/services/filing_code_copy.py | 261 +++ .../efile/services/filing_code_search.py | 306 +++- efile_app/efile/services/filing_code_sync.py | 19 +- efile_app/efile/services/glossary.py | 53 + efile_app/efile/settings_base.py | 12 +- efile_app/efile/staff_admin.py | 41 +- .../css/components/filing-code-search.css | 579 +++++-- .../efile/static/js/extraction-review.js | 1 + .../efile/static/js/filing-code-search.js | 1359 +++++++++++----- .../efile/components/filing_code_search.html | 249 ++- .../templates/efile/staff/filing_codes.html | 72 + .../efile/templates/efile/staff/index.html | 1 + .../efile/tests/test_case_type_guidance.py | 54 +- .../efile/tests/test_filing_code_copy.py | 188 +++ .../efile/tests/test_filing_code_search.py | 321 +++- efile_app/efile/views/extraction_review.py | 23 + efile_app/scripts/build_zip_counties.py | 68 + efile_app/tests/confirm-case-editing.spec.js | 1423 ++++++++--------- fly.toml | 14 +- run_all.sh | 2 +- 39 files changed, 4497 insertions(+), 1594 deletions(-) create mode 100644 efile_app/efile/data/legal_glossary.yaml create mode 100644 efile_app/efile/data/zip_counties.json.gz create mode 100644 efile_app/efile/migrations/0036_user_recent_case_zips.py create mode 100644 efile_app/efile/migrations/0037_efsp_code_copies_and_jobs.py create mode 100644 efile_app/efile/services/case_location.py create mode 100644 efile_app/efile/services/filing_code_copy.py create mode 100644 efile_app/efile/services/glossary.py create mode 100644 efile_app/efile/templates/efile/staff/filing_codes.html create mode 100644 efile_app/efile/tests/test_filing_code_copy.py create mode 100644 efile_app/scripts/build_zip_counties.py diff --git a/README.md b/README.md index 2d08f1dd..78375350 100644 --- a/README.md +++ b/README.md @@ -23,10 +23,10 @@ This script: - Starts LocalStack (S3 mock) in Docker if Docker is running - Applies Django database migrations - Runs the Django development server (`http://127.0.0.1:8000`) and background extraction worker together -- Starts the filing code index worker, which checks for changed courts daily and imports only their bulk code exports +- Starts the filing code index worker, which copies changed courts' codes from the EFSP codes database once a day and indexes them - Cleanly stops all child processes upon pressing `Ctrl+C` -Filing code search becomes available after the first successful import. Deploy the proxy's `filing_catalog` API first; during rollout, `--legacy-code-crawl` explicitly enables the older full crawler. +Filing code search becomes available after the first successful sync, which needs `EFSP_CODES_DATABASE_URL` in `efile_app/.env` (the test EFSP's codes database, read only). `--legacy-code-crawl` explicitly enables the older full crawler instead. Run `./run_all.sh --help` to see additional options (e.g., custom ports, running without Docker, or `--no-code-index` to skip the court code index worker). diff --git a/compose.yml b/compose.yml index 6a4aa13e..07d64aa2 100644 --- a/compose.yml +++ b/compose.yml @@ -89,7 +89,7 @@ services: code_index_worker: image: litefile:web - command: uv run python manage.py refresh_filing_code_index --interval 86400 --verbosity 2 --cache-dir /data/filing-code-cache + command: uv run python manage.py refresh_filing_code_index --daily --retry-interval 900 --verbosity 2 --cache-dir /data/filing-code-cache depends_on: web: condition: service_healthy diff --git a/docs/docs/admin/configuration.md b/docs/docs/admin/configuration.md index 472a0bd6..d1ed9f36 100644 --- a/docs/docs/admin/configuration.md +++ b/docs/docs/admin/configuration.md @@ -20,7 +20,10 @@ LITEFile follows [Twelve-Factor App](https://12factor.net/) principles, configur | `DATABASE_URL` | Yes (Prod) | `sqlite:///db.sqlite3` | Database connection string (e.g., `postgres://user:pass@host:5432/dbname`). | | `DJANGO_ALLOWED_HOSTS` | Yes (Prod) | `localhost,127.0.0.1` | Comma-separated list of allowed hostnames/domains. | | `EFSP_URL` | No | `https://efile-test.suffolklitlab.org` | Base URL for the EFSP REST API endpoint. | -| `FILING_CODE_SYNC_MODE` | No | `bulk` | Import only changed court catalog exports. `legacy` explicitly enables the older full crawler during proxy rollout. | +| `FILING_CODE_SYNC_MODE` | No | `database` | Where filing codes come from. `database` copies them from the EFSP codes database (needs `EFSP_CODES_DATABASE_URL`); `bulk` uses the proxy's HTTP catalog export; `legacy` the older full crawler. | +| `EFSP_CODES_DATABASE_URL` | For `database` sync | — | PostgreSQL URL of the EFSP proxy's codes database, for example `postgresql://user:password@host:5432/postgres?sslmode=require`. Read only; prefer a read-only role. Use the test EFSP's database outside production. | +| `FILING_CODE_SYNC_TIME` | No | `04:30` | Daily time (`HH:MM`) the code index worker copies and indexes codes. Set it after the EFSP's own Tyler update (production 02:13, test 19:35 Eastern). | +| `FILING_CODE_SYNC_TIMEZONE` | No | `America/New_York` | Time zone for `FILING_CODE_SYNC_TIME`. | | `SUFFOLK_EFILE_API_KEY` | No | `""` | API authentication key for the EFSP proxy service. | | `OPENAI_API_KEY` | Optional | `None` | API key for OpenAI or compatible LLM provider used for document extraction. | | `OPENAI_BASE_URL` | Optional | `https://api.openai.com/v1/` | Base URL for OpenAI-compatible endpoint (or local LLM gateway). | diff --git a/docs/docs/admin/deployment.md b/docs/docs/admin/deployment.md index 82949b4b..6de34993 100644 --- a/docs/docs/admin/deployment.md +++ b/docs/docs/admin/deployment.md @@ -95,52 +95,104 @@ By default, LITEFile sends only the first 20 PDF pages for analysis. Set `DOCUME ### Filing code search index -The `code_index_worker` builds search indexes for Illinois, Massachusetts, and -Vermont at startup, then refreshes them daily. Keep one Machine running in this -process group. It uses the same database and `EFSP_URL` as the web process. -Database migrations create a PostgreSQL GIN full-text index or a SQLite FTS5 -inverted index automatically; pgvector and model downloads are not required. -Both use the same stemmed words, thesaurus, and whole-word matching. SQLite -looks up matching path IDs, jurisdiction, and new/existing-case status in FTS5 -before ranking and grouping results, rather than scanning every filing path. -SQLite triggers keep the postings synchronized with catalog inserts, updates, -deletes, and transaction rollbacks. An existing SQLite installation's first FTS5 -migration indexes its saved catalog locally; it does not fetch court data again. - -To refresh once, run these commands from `efile_app/` in the target environment: +Filing codes come straight from the EFSP proxy's codes database. The proxy +updates its codes from Tyler once a day (production at 02:13, test at 19:35 +Eastern). Each day at `FILING_CODE_SYNC_TIME` in `FILING_CODE_SYNC_TIMEZONE`, the +`code_index_worker` copies every court whose codes changed into LITEFile's own +database, then rebuilds the search index for those courts. Set the time well +after the proxy's update: Fly staging, which uses the test EFSP, runs at 21:30 +Eastern. Production should run at about 04:30. Keep one Machine running in the +`code_index_worker` process group. + +LITEFile connects with `EFSP_CODES_DATABASE_URL` and only ever reads. The court +list and every court's tables are read in one `READ ONLY`, `REPEATABLE READ` +transaction, so the copy is a consistent snapshot and the database refuses any +write. Supabase's connection pooler ignores connection-level settings, which is +why read-only is set per transaction. The queries match the proxy's own +filing-catalog export: courts with all three installed code lists, non-criminal +case categories, and filings that aren't court-use only. + +LITEFile connects as its own role, `litefile_codes_reader`, never with the +proxy's credentials. The proxy keeps user data in the same database, so the role +can read only the five code tables. It is also read only at the server, which +holds even through Supabase's pooler. The test EFSP's database already has it. +For another environment's database, run this once as its `postgres` role, +with a new random password: + +```sql +CREATE ROLE litefile_codes_reader LOGIN PASSWORD '' + NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS + CONNECTION LIMIT 5; +ALTER ROLE litefile_codes_reader SET default_transaction_read_only = on; +ALTER ROLE litefile_codes_reader SET statement_timeout = '10min'; +GRANT CONNECT ON DATABASE postgres TO litefile_codes_reader; +GRANT USAGE ON SCHEMA public TO litefile_codes_reader; +GRANT SELECT ON public.location, public.installedversion, public.casecategory, + public.casetype, public.filing TO litefile_codes_reader; +``` + +Through Supabase's pooler, the user name is `litefile_codes_reader.`: +`postgresql://litefile_codes_reader.:@:5432/postgres?sslmode=require`. +The proxy's nightly update reloads these tables' rows without dropping them, so +the grants persist. If a proxy schema migration ever recreates one of them, +grant `SELECT` on it again. Postgres lets every role create session-private +temporary tables (a `PUBLIC` default that can't be revoked from one role). Those +can't touch shared data, and the role's read-only default refuses them unless a +client turns read-only off, which LITEFile never does. + +A court whose revision is unchanged is not read again. A court that suddenly +has no filing types usually means the proxy's update is half finished, so the +copy stops and keeps the old one. Failed states are retried every 15 minutes +(`--retry-interval 900`), then the schedule returns to daily. On startup, the +worker syncs immediately if any jurisdiction has no current index. That is the +case on a fresh deployment, or after a search-rules change. + +#### Resync and rebuild from the staff tools + +Superusers see a **Filing codes** page in the staff tools (at the private `LITEFILE_STAFF_PATH`). It shows +each jurisdiction's copy and index, recent runs, and two buttons, for one +jurisdiction or all of them: + +- **Resync codes** copies changed courts from the EFSP now, then updates their + part of the index. +- **Rebuild code search indexes** rebuilds the whole index from the copy already + in LITEFile, without contacting the EFSP. Use it after changing search rules + in `filing_code_search.yaml`. + +The buttons queue a job, which the code index worker starts within a minute. +Each request is recorded in the staff audit log. + +#### From the command line + +Run these from `efile_app/` in the target environment: ```bash uv run python manage.py migrate --noinput -uv run python manage.py refresh_filing_code_index -# Or refresh just one state's complete catalog: -uv run python manage.py refresh_filing_code_index --jurisdiction vermont +uv run python manage.py refresh_filing_code_index # resync every state now +uv run python manage.py refresh_filing_code_index --jurisdiction vermont # one state +uv run python manage.py refresh_filing_code_index --rebuild # rebuild from the local copy +uv run python manage.py refresh_filing_code_index --dry-run --jurisdiction vermont # report changes only +uv run python manage.py refresh_filing_code_index --daily # what the worker runs ``` -Fly's configuration uses `FILING_CODE_SYNC_MODE=legacy` so LITEFile can deploy -before the proxy update, retaining the existing full crawler. After deploying the -proxy's `filing_catalog` manifest and per-court bulk exports, change that setting -to `bulk` and redeploy. The first bulk import rebuilds the catalog once; -subsequent checks download and replace only changed courts. -These endpoints read the proxy's installed code tables without contacting Tyler. -LITEFile accesses them over HTTP, keeping the two databases separate. - -Downloads finish before changed courts are replaced in one transaction; failures -preserve the existing index. PostgreSQL uses COPY for imports. The worker retries -failed states after one minute and skips fresh indexes after a restart. To use -the older full crawler during rollout, explicitly pass `--legacy-crawl` (or -`./run_all.sh --legacy-code-crawl`). `--cache-dir` applies only to that crawler. - -Local Docker Compose also starts `code_index_worker` after the web container is -healthy, using the shared database volume. For an already-running local stack, -run `docker compose up code_index_worker` to start it and watch progress. - -Before the first successful refresh, the modal explains that search is unavailable +On the test EFSP, Vermont (22 courts, about 1.1 million search paths) copies and +indexes in about two minutes. A run with nothing changed takes seconds. + +`FILING_CODE_SYNC_MODE=bulk` (the proxy's HTTP `filing_catalog` export) and +`legacy` (the full list-API crawler, `--legacy-crawl`) remain available as +fallbacks. Neither needs database access. `--cache-dir` applies only to the +legacy crawler. + +Local Docker Compose and `./run_all.sh` start the same daily worker. They read +`EFSP_CODES_DATABASE_URL` and `FILING_CODE_SYNC_TIME` from `efile_app/.env`. + +Before the first successful sync, the modal explains that search is unavailable and the usual court lists remain available. The dialog does not trigger indexing, but retries an unavailable search automatically while it stays open. -Results older than two days carry an out-of-date notice. Every selected path is checked against the live court -lists before it updates the form. A changed `EFSP_URL` or thesaurus revision requires -a fresh index, so test-server codes and incompatible search terms cannot leak into -another environment. Restart the web and index workers after changing search rules. +Results older than two days carry an out-of-date notice. Every selected path is +checked against the live court lists before it updates the form. A changed +`EFSP_URL` or thesaurus revision requires a fresh index, so test-server codes and +incompatible search terms cannot leak into another environment. ### Setting Fly.io production secrets: ```bash @@ -153,6 +205,7 @@ fly secrets set \ AWS_S3_REGION_NAME="us-east-1" \ OPENAI_API_KEY="sk-..." \ GOTENBERG_URL="https://..." \ + EFSP_CODES_DATABASE_URL="postgresql://:...@:5432/postgres?sslmode=require" \ GOTENBERG_USERNAME="..." \ GOTENBERG_PASSWORD="..." ``` diff --git a/docs/docs/partners-courts/jurisdiction-config.md b/docs/docs/partners-courts/jurisdiction-config.md index c409abf1..e5868339 100644 --- a/docs/docs/partners-courts/jurisdiction-config.md +++ b/docs/docs/partners-courts/jurisdiction-config.md @@ -414,32 +414,82 @@ limit results. New and existing cases have separate filing-type lists. Search us English Snowball stemming, phrase synonyms, and conservative spelling suggestions; it does not call an LLM or decide which court has jurisdiction over a person's case. -An empty search offers common filing-type starting points. Results group repeated -filing labels and recognized synonyms across courts, so the same filing does not -occupy a separate result for every county. Users expand a group, choose a court, -then choose an exact case-type and filing-code path. A court already selected on -the form is offered first. Grouping does not substitute one court's code for -another's, and distinct documents such as complaints and answers remain separate. -Each group also shows a compact case-category/type context, so a generic name such -as “Petition” has meaning before a court is chosen. Repeated category/type names -are collapsed (“Small claims › Small claims” becomes “Small claims”). Groups used -in multiple case types show a count and an expandable preview of up to eight; -the exact court-specific paths remain available on selection. Each case-type entry -is clickable: choosing it narrows both the court list and the exact paths to that -category/type pair. “Show all case types” clears that choice. Contexts reflect -the paths matching the current search, not unrelated uses of the same filing name. - -The compact search filters separate the task (starting a claim, responding, or a -request from either side), case stage (new or existing), and document kind (main -document or attachment/exhibit). Starting a claim is not a determination of the -filer's party role: for example, an existing case can include a counterclaim. +The modal walks through three steps, each answering one question: + +1. **What are you filing?** A plain-language search, the case ZIP, and short + questions shown as chips. An empty search offers common filing-type starting + points. Results group repeated filing labels and recognized synonyms across + courts, so the same filing does not occupy a separate result for every county. + Codes a court splits only by jury, government-filer, or fee qualifiers + (Cook County's “Complaint / Petition - Fraud (Jury - 12) (Govn't) - Fee”) also + appear once; the exact one is chosen in step 2. Filings whose own names match + the search are listed first. When some do, filings that match only through + their case type are held behind a “Show N more” button. +2. **Choose your court and case type.** Courts that accept the chosen filing, + under the current answers and ZIP, appear as cards (or a dropdown when there + are more than six). A court already selected on the form is offered first and + preselected. Choosing a court lists its exact case-type paths; when a court + names the filing differently, the case type says so. Some courts code one + filing as many case types that differ only by claim amount, a lawyer or + self-represented filer, jury size, or a government filer (Cook County has 28 + for one personal-injury complaint). The court step reads those from the case + type and filing names and asks, as chips, only the ones that court's paths + differ on. It assumes a filer isn't filing for a government body, and shows + that answer so it can be changed. A path that doesn't say stays listed. +3. **Check your choices.** The full court, case category, case type, and filing + type, each with a link back to the step that set it. Applying revalidates the + path with the court before it fills the form. + +When a search matches eight or more filing types, step 1 also asks **What do you +want to do?** (start a case, respond, ask the court, settle or end, supporting +papers, proposed order, notice), with a count on each choice. The answer is a +guess from the filing name alone, from the ordered `actions` rules in +`efile_app/efile/data/filing_code_facets.yaml`; names no rule matches are +shown only under “Not sure”. + +When the matching case types name different claim amounts (“Small Claims - +$0 to $10,000”, “Amount Claimed Greater Than $10,000”), step 1 also asks **How +much are you asking for?**, in any case area. A range is labeled “(small +claims)” when its case types say so and the others don't. Case types that name +no amount stay listed. + +Step 1 also offers **Case category** (Cook's “Civil”, “Personal Injury/Wrongful +Death”, “Probate”, …) when results span more than one, so a filer can set aside +a division that doesn't fit. “Kind of case” and “Case category” list their most +used choices first and collapse the rest behind an “N more” chip. A case +question in `case_type_guidance.yaml` can set `other:` (for example +`other: Something else`) to add a choice that keeps only the case types none +of its options name, such as the general personal-injury lawsuit. + +### Court-term glossary + +`efile_app/efile/data/legal_glossary.yaml` gives plain-language meanings for +court terms that appear in filing and case-type names, such as subrogation, +dram shop, and tort. Each term lists the words that name it (`match`, whole +words, case-insensitive), a one- or two-sentence `text`, and an optional +`source` link. Under `jurisdictions:`, a state can reword a general term or add +its own (Massachusetts' “summary process”); its entries replace general ones +key by key. Wherever a result names a term, code search offers a “What does +‘subrogation’ mean?” disclosure: on each filing type, above a court's case +types, and on the final check. Meanings describe a term; they don't advise +whether to file. + +Grouping does not substitute one court's code for another's, and distinct +documents such as complaints and answers remain separate. Contexts reflect the +paths matching the current search, not unrelated uses of the same filing name. + +Case stage (new or existing) appears as one line under the search, with a +**Change** link. Document kind (main document or attachment/exhibit) sits behind +**More filters**. Starting a claim is not a determination of the filer's party +role: for example, an existing case can include a counterclaim. `efile_app/efile/data/filing_code_facets.yaml` contains ordered filing-name rules; the first match supplies the classification. Rules may limit `jurisdictions`. -Unmatched or ambiguous names remain unclassified and are available under the -unfiltered or “Not classified” choices. Filters apply before pagination and carry +Unmatched or ambiguous names remain unclassified and are shown when no document +filter is chosen. Filters apply before pagination and carry through court and exact-path selection. These labels do not change which documents the court permits as attachments. Choosing a different case stage changes the form -only after the filer applies a validated path. +only after the filer applies a validated path. The API still accepts a `purpose` +filter, but the modal no longer offers it. Shared rules live in `efile_app/efile/data/filing_code_search.yaml`. The `jurisdictions` list currently includes Illinois, Massachusetts, and Vermont. @@ -449,12 +499,15 @@ These are search associations, not statements that remedies or procedures are legally interchangeable. Multiword phrases match together: “summary judgment” does not expand to “summary process.” -When the displayed name does not contain the search phrase, “Why is this result -shown?” explains a related concept, an alternate name, a spelling correction, or -associated case/court information. Concept definitions are expandable, keeping -multiple results visible. They describe the topic, not the purpose of every document -returned for that topic. A search for eviction can also find an affidavit within an -eviction case. +When a result was found through a related term rather than its own name (a +search for “unlawful detainer” finding an eviction filing), it says “Related to +eviction”. Concept definitions are expandable under “About this filing type”. +They describe the topic, not the purpose of every document. + +At the bottom of the results, a link such as “10 results in ‘existing case’ +filings” appears when the same search and answers find filings in the other +case stage. This matters for filings like sealing an eviction, which go in the +existing case. Choosing it switches the case stage and searches again. Display definitions live separately in `efile_app/efile/data/filing_concept_definitions.yaml`, keyed by the same normalized @@ -535,14 +588,28 @@ Add search and explanation examples to ### ZIP filters in filing code search -Illinois search offers an optional case-location ZIP filter using the bundled -`efile/utils/zip_to_county_il.py` county table. Filtering happens before grouping, -so case-type previews, courts, and exact paths all use the same county scope. -Every county listed for a ZIP is retained. The UI displays matched counties; -clearing the ZIP restores statewide results. Unknown ZIPs produce a recoverable -message rather than silently showing statewide results. ZIP matching is a location -aid, not a determination of which court has jurisdiction. Other jurisdictions do -not display this filter until an appropriate local mapping is supported. +Every jurisdiction offers an optional case ZIP. A state whose `court_selector` +configures a location `matcher` answers from that package: Massachusetts asks +MACourts and Vermont asks VTCourts which courts serve the ZIP. Courts the +package has no record of are placed by county instead. Other states (Illinois +and any new state) go through counties alone: the ZIP maps to counties through +`efile/data/zip_counties.json.gz`, built from Census 2020 ZCTA relationship files +by `efile_app/scripts/build_zip_counties.py`. Illinois's own +`efile/utils/zip_to_county_il.py` is checked first because it also knows +PO-box ZIPs. A court is placed in a county by its code (`cook:cvd1`), its name +(“Orange Unit”, “Middlesex Probate and Family Court”), or a town at the end of +its name (“District Court - Cambridge”). A court nothing can place, such as an +appeals court or a statewide division, stays visible for every ZIP. + +Filtering happens before grouping, so case-type previews, courts, and exact +paths all use the same scope. Clearing the ZIP restores statewide results. +Unknown ZIPs produce a recoverable message rather than silently showing +statewide results. ZIP matching is a location aid, not a determination of which +court has jurisdiction. The field is never prefilled. Instead, one-click +shortcuts offer the filer's own address ZIP and the last three ZIP codes they +used with an applied filing code. These are kept on the account as +`recent_case_zips`, because filers who help others often file far from their +own address. ### Context-specific case guidance diff --git a/efile_app/.env.example b/efile_app/.env.example index 36c5e4cb..b707624b 100644 --- a/efile_app/.env.example +++ b/efile_app/.env.example @@ -61,3 +61,9 @@ GOTENBERG_USERNAME = "your-gotenberg-username" GOTENBERG_PASSWORD = "your-gotenberg-password" DOCUMENT_PREPARATION_TIMEOUT_SECONDS = 45 + +# Filing codes are copied, read only, from the EFSP proxy's codes database. +# Use the TEST EFSP's database for development; ask the EFSP maintainers for it. +# EFSP_CODES_DATABASE_URL=postgresql://user:password@host:5432/postgres?sslmode=require +# The test EFSP updates codes from Tyler at 19:35 Eastern; copy them after. +# FILING_CODE_SYNC_TIME=21:30 diff --git a/efile_app/efile/api/filing_code_search.py b/efile_app/efile/api/filing_code_search.py index 7108d5fb..919daa94 100644 --- a/efile_app/efile/api/filing_code_search.py +++ b/efile_app/efile/api/filing_code_search.py @@ -2,6 +2,7 @@ import json import logging +import re from datetime import timedelta import requests @@ -9,11 +10,31 @@ from django.utils import timezone from django.views.decorators.http import require_http_methods -from efile.services.filing_code_search import current_index, rules, search_grouped_paths, search_paths, validate_path +from efile.services.filing_code_search import ( + action_rules, + current_index, + rules, + search_grouped_paths, + search_paths, + validate_path, +) from efile.utils.jurisdiction_stuff import has_jurisdiction_login logger = logging.getLogger(__name__) +RECENT_ZIP_LIMIT = 3 + + +def remember_case_zip(user, postal_code): + """Keep the ZIP a filer just found a usable code with, newest first.""" + if not user.is_authenticated or not re.fullmatch(r"[0-9]{5}(?:-[0-9]{4})?", postal_code): + return + postal_code = postal_code[:5] + recent = [postal_code, *[item for item in user.recent_case_zips or [] if item != postal_code]] + if recent[:RECENT_ZIP_LIMIT] != user.recent_case_zips: + user.recent_case_zips = recent[:RECENT_ZIP_LIMIT] + user.save(update_fields=["recent_case_zips"]) + @require_http_methods(["GET"]) def filing_code_search(request): @@ -35,7 +56,9 @@ def filing_code_search(request): if path is None: return JsonResponse({"error": "The code list has changed. Search again."}, status=409) try: - return JsonResponse({"path": validate_path(path)}) + validated = validate_path(path) + remember_case_zip(request.user, request.GET.get("zip", "").strip()) + return JsonResponse({"path": validated}) except ValueError as error: return JsonResponse({"error": str(error)}, status=409) except requests.RequestException: @@ -51,6 +74,9 @@ def filing_code_search(request): "unknown", ): return JsonResponse({"error": "Choose a valid search filter."}, status=400) + action = request.GET.get("action", "") + if action and action not in {rule["key"] for _, rule in action_rules()}: + return JsonResponse({"error": "Choose a valid search filter."}, status=400) role = request.GET.get("role", "") property_kind = request.GET.get("property", "") relief = request.GET.get("relief", "") @@ -70,7 +96,13 @@ def filing_code_search(request): offset = int(request.GET.get("offset", "0")) except ValueError: offset = -1 - if not 0 <= offset <= 10000 or not 1 <= len(query) <= 160: + try: + # The court step loads every path for one court at once, so it can ask + # about the choices they differ on; other requests page by 20. + limit = int(request.GET.get("limit", "20")) + except ValueError: + limit = 0 + if not 0 <= offset <= 10000 or not 1 <= len(query) <= 160 or not 1 <= limit <= 500: return JsonResponse( {"error": "Enter a search of up to 160 characters and use a valid results page."}, status=400 ) @@ -81,6 +113,7 @@ def filing_code_search(request): query, initial=initial, offset=offset, + limit=limit, group_key=request.GET.get("group", ""), court=request.GET.get("court", ""), purpose=purpose, @@ -92,6 +125,9 @@ def filing_code_search(request): relief=relief, case_topic=request.GET.get("case_topic", ""), case_filters=case_filters, + action=action, + category=request.GET.get("category", "")[:200], + strong_only=request.GET.get("strong") == "true", ) else: if purpose or document: diff --git a/efile_app/efile/data/case_type_guidance.yaml b/efile_app/efile/data/case_type_guidance.yaml index e62397e2..2d5fd52b 100644 --- a/efile_app/efile/data/case_type_guidance.yaml +++ b/efile_app/efile/data/case_type_guidance.yaml @@ -182,8 +182,10 @@ topics: jurisdictions: {} personal_injury: label: Personal injury - pattern: \b(personal injury|bodily injury|malpractice|wrongful death|motor vehicle|automobile negligence|premises - liability|product liability)\b + # "Tort - Not Personal Injury" is not one; legal and other professional + # malpractice harm money, not people. + pattern: (?)XHCMnP0HX`r+v^HuH6N9eS8} z<1kIXrSrFThK@Dx4~#o76gW2AZp*!!U;iBmyAL->b^I%U5xq{Get5{G))}L)w(zRC zn(;Jur`Q*3k)PHg>a#~Xx8rnN#*0L_MXp6{H=e2+|DBFbAj#tym@M{0m?AJfpVAoq zqTp+R&*t(Jb>%OJSqlvd2#8t$oIVmhu%)azQXfA@&@FA>3t#-FMyX=2=5v`FY3Bb8 zF>9f9R(~JXdNwAtIXevT8GAp(rIu}1GL})2OD-l{t&?FO~ zVrz-mS|YZV$gLfOIRWr&FO|+^XelrA3RT5XbzFrh^x-YK*o#_OVG^$56etZrnQYA{ zRPHNd1uVkFT7k>RL{zvcY_%5EqQV@4!e!WzG80HE^S7KTi}&bKvT77Ul%leZcep%= zJgSl?tE)6#Ta`r!t8&j`Q?Mf7RaMH=h~28{@cZ`ySntK^>qHo#IxcK=A(fAfNi7mz z*Fo{EylSh$p`ac1F{wNxvFTf)CKWy^QJac`TJD9pRiY-1d{Dj{g>oOvu@beapc%@2 z(DgFe$D2-)H@Gx5q9y8Yf#R+2j;O+UNKz4_Kivz=O9L%YrfCSuqL!S(=FukKDvvTP z!ge()hJp~Who4paXtduDXg)FP16C*ps8^HB5%Fa9- z%RGw>O&%Szym&X*xFpAEsNbXCYQ?gUL^NEHOI(p-Bo^8GM)J2G7h!9mnBwB>6Ix5a z1NAKcQ0}8+Uq+X@vXtbue4{FHS&!g~*d)W{+;66O$FMS3`^xf-3|D)3uHiCI`1EFR zFRQmhSVg;si+c3CDzFU7O4d46xE9o*BC12lt>5&jeye5c++z@~{h}ag3xM?mm-%Sz z2hVo`*dgo@uILhL4{4N|Ll#nVcsqiJx9di=7pT}qqgv{N^j+9X707;6Am>3j1uA~@ zP5H*pRru;h6&!w0Ao)?h&_H-oAdMMsRho#dU6rQMFS5uNdGw1Pu@Ef;@zW|6TT#e^ zEL~eZf?2flXePvm4lM@p%Uosx8V3=2(L|7%+X{Akh311?T%^fcgQ`5*@jQuQQooWc zm=bw7OY$OGVRUTsO6Fl+N543{;VxPgF=X-%nZ8429z%+#oNM==@}-FOzNn&@X#Wd2 z4pWtTNmFV<-m1*<9bZac7Ru~h>HPgD>YZ5l=15ifU9R4B zxr&NH^&^#6{b1FoqJ2kOg^-9Q$!}IGb#(66sXPv~KLvavr9vZx0^aGtIy!oFM8KoL zhw31&Uo{#JZZio$xCjQDItr7Ag^Ot6GK#6lF4|Tua)^D5t5h_|uS%rRZ!>R0Vf((K zf<^lkr2J9ixR#Lczk($(f>0N`{lvrJuutS2xVNMp&|;%o0szDy^}Ek7VEwKH+C6Ve zq(3Z?{jfyw!xH5WOH@BBao|k)``@>}`{~bo%l>$5zrej|*Kuj@<2r`Tu^sQteOAE# zSf*3c55u&|L~}lO6AY)S7mV^5N4DvkBWRd*+<~tEtIH}9@aGY0>!G?C_i(!YG<ZtbpD{n#7>(x>Yh zPf;a%oD=}EH2gCEj}5@Pb(9*=&1xjT-RW+vY5-Fg=G!o?rT-DQe>-;A=LIJ5eFw^{ zhpDoD)M9aP+rYiDP5gwv*A$1Q>)RL9yZZ(zAN#fR{jIqp_Ol(Y@Y(9>HREwuMufvM z0bfj=X4_|r;Me8`b9bIw{GdNSJ6+LhST=pLIW{vA{+HnhEw8v%r6G)M`>8qAAxi;1 zk1#q|`aTY(^fI;=wR3rE#;@0%^z#T}{2Ye!?lgRSwC!e6yQ7zteSd84?N_4<-3FB1 zUzXd9v*Fm@6%S4Wc=96@T`Pr2a7H}@vN*nQWqz%_TVddGURmqkVVUM#=q{TH#`?88 zFZWY(wgPc6(;z>Z^gzO)ZmG*@bx)2E?jR6WodfpmWyhR>-x zw;}N>MxtFI)EEj}8khT75x_)lfliGWK^RUm_6a^}M^DQ@FV5|qD;a=2Hb%U;{x~UN zzgr|I&rg&TK=i}YQvL_^)ua(4h>vC)(Ih8OeSK?1=x3P2rvZ2(9nqFkCygD6JSTA5Ra0k{+Sox^r&spFk5DtqC<7g8BwJbn-;w zq?DyEND8PcAo}Ulw_mMMvb2V72ucoM;(}JkDc>w-{cBCuYSgsceKndEE{~{W+=v0u zpZdE`6iL6ln&obY|Iq#kRymDX);YGCMT=QB4cK2WPs9B*fc_hY)2CCMoGpMKz8X(- zhZ$;CdJaY#Y54bLnzVt0STpq)8L!<(GdDMHi9UfwY3&Rl+5-^J4y`==AMhhVf!*d0g-Q@^pr!#*y}q{kpoY@KZA{qgO+F z8y=uyC#~#SuwXG0@B46{U;#viP|PrTLvbGFD|V1D`aCSho~>HMh63b8X&TR%SUtDa z=x9%0w=f|oM_QN{t3Sm$31X$~870peVk|Pkq_($42l;k{<@Pqf&uN3p!%nr@H=+Eu zw?~KHJ3fHgYDehr)Or-CsPZJ)W$p6OEPdlm<91Q1pDlPTioY8-PzmEjp*=0E(vVaV zl-J#lrq^nW+Iax|6r>7hx=%)nkdgn;_5wE@KDznW$7uP05?3I+pW>SoWvT2YVYb@!CR3Llq~)~J85T~H?d z6bjh~$kJ)BPJy!#Z3VPo_+NOz!&mx*PNT1V<%^u61?d+j01)bOoaxD`OZpJM(}G+Vt{kz zsbjW+n>Es-0lMi{Ln|(qrMq*h__8Ra=eolByNr5n{-r@rdQH4OeXc@z;T6G07HWNS z1$^1M$pWHOi7x!t)fPoqXC$1~dnZ0IS%I(3IGcst38;cjZJ-jt*wi)uxp|@{TA?pJ zQ=D0LRny+KN0h>~`L*pgR%cU|w9R?dKlk<-#wV`rN8jE3$$V`)m=PCLB#s5`y}6*@ z<|YpS&+EwUa51*yYGhdbwgSic2e#jYCOuaNuqO5|Q0?0BCrllb1scfHnwEQAv@HQ< z+Xa-cCYkwtlq(C2grSO9UfsF%I@sNzO;AshxBhffqnzkw_vlr`&3IPUGXK`#lI3*9 zH|+&7q1QksL(UWU)=Uqq)>#4H+M9_Q(YA*5xPgn>IG%l~itpjd_SvcBvVVa^GLE?T zFs6R$>A-r>Hyg4{(f7&n zZ3_AaQVmYQJ#f4Lel~N&+8zL8-pLDrlTrI_G3dJ)6x?QRzc z8VSXnvO5fC-AdOFjT*k;vjJb_yY^#rv;-t>Q90mXNN{b?50}97?+2KJcznW(Jix;L z3CeqOQ=d1Tw+2I{9f>kHd$C6)V`s3_IsLNE3_ncLzhFeW!M7dz*WC|x-WnSMB~w#F z@ifCYH0LQS0Oqr|klxf`rdF9ce8K>HUQ}cgICw#;i`ied=D~vrc2eDd2g~S(nQo~2 zsb0J(zZ#5(>D7b(OPvxX+qaW)*1$Kp?yk#svp7)u^mYPk77T*W?z(m7Ct3kS11a~Z z9dXIOqQ^|-SD{S+>d)Ykjs}Q*@*7a`nCIP}rYc4WMicE6Wf7Vr%s9ENb|f+UjajKe ziLed=`P@gpIgK6YZ*Z5+uOQTmPLx{)X}lNJ8PSDw$+>|=w+X zw@_u}KU%3gOfFDVlhhA|RMSO^J7E{jMl-+MjJz#(BP2|N-J9iW7`T+*L7N(v(=5EO z-djS+>`~yGT76~#0p5lXGXRX*#-Mz-uTf8R>Ix5PX(z%s9eDKT+i<;y^T1k|8`VDf*8o6h4emHKfUyON!e47adSJjBu3v}8Bk&bLyo8A6 z@N0w0*)ZS%Lkiy5fHnZp!jGoExhNWA%%iyvU{72?giwpQ<>ApNslM{AJl4}an)7Ue ztf!)M*WCmJ=-9q?S6E8dK{LK8Ro)M<=eqB`FVA^j~7+6_7>yUvL^4X(L{OFYY zp;JP*`T$YfC>0*ghzBT^P4jpd8umf?2v7!glWo4#v%cT&?qh#Z19)O@x((CS1!+yboq zPmCG)4g@0QonycaC{5O+13U5y=1VvvDx+eYcg^QGu=Nmx=k#fJSFDV~Vx=}1@7xHR zmK#|EXz^#;&YLx^(2tifpq&jjJM|GL6Tp48 zdh^*%%HE|(T%T=ztm!@lMAdb0KnGT5l*Lk!9v%nmGRWf zcj&V8RADL?*kg{)a}@^W&sZURDl2OU156qYn(BVut#Y?n|7Ih%&f0z$!e#5(e5pu_ z&-7<|9-eKmras$#?!5j#+u^c&d^ReNL+yK!Q8d!DI=DxW2cv|v3ADn$+1>S=dbU4Hq$>i91wofn>maYIn&L-)dvSEL>>|oHT^zgJ|68e>k}s9`5)mS zLDmCIqx5@#DU^;NrNzF-zU!(8M{VMSra1bL!56s!_pbqFg(p}_q00GdXo zhel^);Uy9pRFjL9*Af)d%sI)GNF1h^oB6iJ!?t`b`omg@qFv=wlPZ9~NlX@J!&%Wm z4?A6R5e?8Ojp8Xl7PeXoa#&a5jNdXVLX|sE_HbJd&g%vWcpxTH zN4l5Pf4a{_XdyyN5n74RT7*ivB2D%pG!dbx2o?L5h5voIAEVk#=cG!+R? zMZ!gMGfhRpQ<43tOoPbiOl&O^TgyZ?p>Y{o*3&$D%f&o@|FuYTA(5BkIUrxia}?(g zT*%;31nUDy+Ot)Fl*X60#vR*|Eml=3?Bhy41OcUZVpD%3;H!}AN@g5d#Ov@& z^~j=B$=pU`nzf1xMw|<^z|2}6w8LHoCqC=-4RzFg(HGHK%Ya#<`WiY`9-U6N3Nl;0 zz&R7ere459!7?5tQJ|c~*%YJ@J}d(UngUyos=JJ@eiY=oJ=N;!; zk&D%DIT1y{OR>IS6I(6;`p!1ah%#fex%w!TWzGwc=%m6HeAq}wb(lH$1SgWn5wz=U zd9T-K;cLeS^&QVfb8u zQyHAe;9LZwl4hfpXF}ieLjGLfoxa1%>%O_V%dg-d6)yHNr!_Va2rSYcN>kwrH>X>x z;t(B3E?PAFYcy#%tO}W1i4t9~t!mFn&5D&0z;#iAP_RylJBn z2Bpg0^Q{s(q9Z$GlIVL+1hTww(ieDmuvCC$0;~{Vr2q@Zx=!{2OrRwZ>Pzzwb_mPq zyD)U%bn9&d>N_u@*qrZqDp0{cpz-Vj_M4Zl0+5-mR7MU(;>|QFBnO`|du$2pDOC75E9i@ji$a*ig zps`6naGkvy?K*$QV>I9S2bCnybvPLHApoJ@Pb8+?<7_91o(fjj%$}`Hq}kGQ52{c} zKjfRqfUSjU4=kZ}OLAZUi;7P-sxe!eGOP+@VM?e1m-I}=)@AA`T;-kD02o^3B+sDK zTl+S_i&KqCm6tC+>+cPRVtST%%eJ^qU7UBZ$QHm6(_{Q)I;qC zp#}v@JXs9bh@mG^g0VlX0Mo_0LX(tXO-|Vp!Qd))3Ka&D2$c= zNhURtGiGdAHm$;!G5j_Vj*U@7Um+)SGBRCEAkdjg{NcZOyZ<3OH;&E_GaFb?SVhF4w&l%Q@hLcrS4DvFg( zMd4v|pkc!X1ra4}id9>t^xz@@DuMe|;up$%Nz{(4BnN1f*a7mYE`#QD3PWI(Z`pBa zD3|Vim%=ZQ1U`p5)1>ePKTx1RB|Dexf03n}G_S#|;0G5KsN}~C6zC8%i{`plo3_vi z`E;~(4h63!>`)%IZx=uU%Gj(Rvxzw$d69c3!xXF#+!UQ<;W$OSEncwP6PkT~>(z;k zpp)Lb+6Vouk~h_v=d2hkj|mC4u62Tg>@4au2fuH)!1%f;C>VN{eOQv7N1luftWP%kf1nv}qG!>axWg##PwNqHM4up@KcvS@i@{9r_ zJSYX~O2*S*exhCPeIoF!427zchq?;KwF>83CA3?GGG7TYuixwAFo8j}Fn)C+(p-lG z*7B&D1Q8!x$l%JaSHGRdL0JgOLr@Wd$`B;9ScjlMPnO9(1SKITq&^9$PeM|Ykgg=8 zD+%c;!WPT0#X79(5SBUwbm`ZK^oe$QKC9Hv3Rx73m+7Z>sL7?@*3qCXJ%Ox{Sy;a0 z<;l9LAIPEFtU|ROsXj}+L(d9U3b2~Owl1l*)ENmROR=m>5+NZvPvaHPJWa~TybcLL zh3mp;?oI(f>hMW;KBlag5xI?UuSkQ)bX9}{VBV6WbfJbu>RIn}#KqDSkqgw+qCAyc z)Lev`5oMDI2~nj$Cj=07MBT(hh(uGjE zik3bI;}XUubq)(jkS}#=3NuJ`t_d$zhsBb;081%s%PUDT;e$eel>+R*EB381{WuK& zD1AqbKI7;>wV4qne{g-1<=^?6ET;jlE&J=xuX5RZ=<5;wY3b>UD>pBL2!ZRZ2>%L> z`&mDf(%gn{d!y-(&Hd#UJ?cAc{`1;lVs_Ja-~H_6Mqlvrv$+OdKabLi7u+=Wxf@P+ z`R*`pe!;sox1@>tJm}kv&Awan(AT?K9oVRstMC?i`$o!my0$NJ-^2gi+&8~ppkc)7 z`*i+{cVF6D2;Y-~#nEP_UkM@0U4xR@tnM*Ug|l?i~}@qQZGkrj%U z6tTep_}Ki{B0L)c2pl%XQkLq2W$eYGmcsy_uR{+KzFrjnhp~OG>4=uwssM^N9rt51 zH(z-5%kPK5bbD^~MRcs;*<41Vt_U>1<-5Lqkh1O7cX;>iU-i>t>jOEA%fD~Fo5yL^ z$8&D&JOsqCr*nRR8D^fdG4RcO_#YcQW(Ba*OTZRyv1N$84h!DbZ)shbiCBG?@z3}g z3YV7lY_CRBcdH?+#mWDd_1;ela=p@xtO#`%YKM&fx1~9OjG8BX%w5HQ9h+|K>}x>n zhq|AW4<5{mb9b8YD8$l#-t7}WN*H&2d%F$%(oyv~kiDA5&bZdRTw-h45)FLp+&!CA z6RIbHj_Rq6+wS=~trw@-8*hC*p~_l5KqcKpU1sE(*>aVMEAGGI(>-7ys!Qe6hX)Q(}GGh zHbJRNiKeT*6fzhWET`EK)3V+j<+p&MaV#FuNfU6(t*Kpjd)vR2$oLw>J6F$B1&+Y9o&5HXg z_!2(Wa~!&9bN&YyeW85ceKn(hn)t7<^sk&*iwx#rF6{9CbvN%!4dzxha{9y018$}r z)s)kvooRC%p8Ec5PwaptI+@x(^8^U+C%3WG1{CsTRh?Um#+QCGYrrEQ#$+1TK{ z(VP#9StV&%eBp(db>Nrk2}#$y1@9wwAqg-m&*na2U%1WN5rok3vz1KTHDg6zCuuAe z`|{eKhxHm&&zfAI(a~Ox#s}`yD0981hQbOxU0a^k&dyBf-8^bRuAl7i{x&-Nr{;`L zm8`KG3)x(nN2rwnH4b+#{fI2;Zt{{9^wjhp)buh2(n%-N@NofX2cxQQ%$1tKLSHT3 z5dFvXF%sgyf-f#n|Wv0CXxGoq=c=*enLo|cP^uJe3rh1g$5@nhF(r$j6!d`}5c!Mbv~ zHvOl52eI$Y=hi-=6Ff!exOG({;2vhNNwIdGN z(s!=(gN)1wIt|C8A8J^iWqk2b8FaXWO!}ce+bfB!0kA@~^pUUaD-_1hr4KgJMej7+ z)UE2g=oMxrnTR36<|dYK#5=3`+?dtQ-i||w zJU8YJX*##)Ynf&=&gzDB`)VtJz9#e9qBc|1TYqQ0idq$I9{|K+p7uO9uZi4*ZMkt) zUhg!I!-KE9=k?Krd*C^)aBVnpD-$rF>Yo0@Ik)^h8#)2b?Wa0*$GQf(3MNv^qMYaU zhBh8!Y7wqInIC zEm}rLs>`KAWB9zft9*dKHZZ{=GU*j~Ts#*EfaaEQyJVjitA_}?7PT%rEl6V<_`tWv zOYp|rQ(j(i6DH?c_PoqA_-W_d3RdqKP2xANfF&=pv{}Q)`Kf?J-Qt-%ZFAAL_gf2u zXdk=KTeAgM6MXeX^O~xZ6>+g+!)E`A8yaKvtsyJppcpG)Efaa9W5ulx5qvdV>$HvV z?okViYYW!#5$7@W58BJ?F>_VZ_8TR}=t0`W7@&`48rU3Yl`V63Rxl&6@4j~<3$3;N zr?w>c2*N0L6SG7BGCh@f#H70I*bS0>|ND%y+qI!y&s77-$yd;aHfNpODX7uN{=ENKw+KV${G%CfZVq`5w>IXLKO2_O zTU(KP@;|J{W0oYoV!-^oEEBiZKKkauo9LUF*d0X5K6j>IJUAkQcgLz&IGj{#<# z2_>@Fs+zd3llLlS#$ZmPE zM}4hJV^rD4GWHmyAWPF`@|5RD=LT+2oC^1oz9_A z%@y-v3;27_dSMnDx;J1Mqj_h4sRz}{uVO4J(qd@x{TVF2@ zmB(dR(A*(=#`e)-Vrc#ci}WU3M;&Kp$yBP5%s6l8{`wvGsm&h`})32 zDy$8>(mgTfoI-pSO=qnk6|plol#iwKR#H3f-~TpIpA~Mybh!HZ478+ zZBtA0GA_qsAS;XUysQ8(w@p7>*n|X-d5M>LnF>yqSIcjJD>h7?2&w2^*V5by`^nYr z0x{il)z2gSQO{=teQa)^JYwQ|wHYin%tPO8cJu($gc|hnqn^m>f-s7LzFZ!cwVdGp zH))wwc(c1gcDkWd)r9yqbz1KndDjb?lX;8z<`JALYc&XNG&{NmE09*g$F?jZNQ zdOnLij6+|9KH7c}pnB{_8VB$}dRHBMtY75KE`D;5%o8Kx>i6TJs zi~{Nv(K?R`X6B>wdNgz+%rS!kW33Cgr*SsKfaV>KL#RS-r;uYV7xQ|n)eRcxrIDG} z8<`;wRhgV9`o?u9?8$FwP69~oId~@WYH}>722tF_& zku+zrsrSL|Jj!wPy#DK8qD|gg->~kXG&0r>Xknq9ZKvho z`-tCK8^Q9a)f0~F6ZtzWCeepFNtVY<9={Db-_nwb948qJYZ2qvu!ZT{z{BUCHl{+C zwNLBp+Q9_SSJ!jR`>FEkUQF?*YnpX%jkIx}m(%Bq(;V>hvpe>lOsvq`aa4&to_YSM zmsg_86likMd*{p&-LRIiIVx1;ne|!%Sm}$6T zFz1zWxo_6Z3%;S)H{_LR>mN{IbnPi2Mq7z|{@Kz~Ds*X4IJ!6Fq+-QT%abfG{dxyA zyXe#CB|sfL7TizGG>4Yh1&wr^Xe>xiZ&QURqV zVPD;!Reek7CY))X9oK4ocQjll;S!@-J=H_^OHq|txi_H_F$JXu(~IVBaCNd`skP!3 z`QF~a>Mahvo;q|6dK~EPifqMK-JKalho9voRw50j$e6w(Se zOTA0)0x-$+^>b9VB)v$LG$B+MgzX0tBX?_mKRivklM(bSG1LIvj+?s)F}ta?!Y8nf zRB};!b{Q7VG3b|*;W=H5`{j=BnQ~>b+$R%3qT_ednt}ymb>Prbq_^hM{HmF0)C$1v zdKbf+PL2g@nK>LC1s{6zo~sLt-M!Ua~c!Sn$BKn9@3DU#x)GN3Jr zEmb#ji7H*d0XRp3KDWk;so$De+XQY70BpXt&&l`yZ_RRRUG|cldYWq~N9!aPPs9kDE&OX+4#DJKbBfjF`Zk z#)b#lp{vq%;cZQJTH|qSbNbeu&*8K10M+lJdvdP_lQ111&M9I1QyBjc2J?ZPVzq zGqZ_xLmSo;AeupSbG~}WY>V4XHygrpcCo@kOTNxfy8q?-`v4tV8`2HL7LdkTwNnl+ zPxq2d-QW@JVO9T4`vOMQAV|mfm}~$Pw@EyWp_U9{JkrHswL~_jroVZ5b%l3$vW~|- z5TyHPl)XQ-KKw?P3*V^O2lQZn;_Y zr`y?Fp|9SA)F`uzQXA#AoX;++AxP-IE$YnZ`0^}RLNZlSu)sH71oif6&?$4HbBFP@ z!96$O?k&zwPqE%xKMz|ajrZ*=1V8WX6T1K4BddV3@)yD3;hU9!2`meMod)e(*ah~E z-SI~D-kq+4pBZ}x!MgiOimBZ@o^Exkz1k+%r+28z0yD}*9Ek)2`~fq2WcF2A!7; zOxG84Vu=Lo%sOn%Y#PEGzMCUNRu>Mx3i)&Z9Jy3Jz-?0@RLUsV-$w)H&dr~2099u- z@2$6>JiuIDvwIu9zCYBJ8zXn!6^y&pj|SY<5e;`Xx{u~{Z+FwvDsL1AkQp%PFvlpA zF~E}aEJ~zNqt0=%is;d*`>F9i9EE_Gg4(nXYa%|Z!Z0}_54&l@me_~(*m=^3J76A! z8Q2kHYUz-@PB$f7K9i04xxg*z7(Z4m=6q{1-P*v7O4abmm=JCye!$|X_i4GI2?#Lt z+w&$oWXK-^d+04gJ`b1M;}a~iW2+M)h%Ag|g`->XicQ{S6ku50basK8M59Vp2U)nk zxZ+cAGk092BDg=JZe#np55CDU!U#{Bn;6L$!TN(~sZA&L6<|!q%*GJg z_i5X=TVY|0)XBA~+h&c3M;h%6GW%tY-(Am>rL5t{wj=DIJ0row(#)#E#And^oh+II zP_ zHQhRl-cuPcNjidGKsbAQjdzx$in*1dSBfZ`;ctyDgo|D&q9m^rF%hp6k)cAe>!=Z?LjbV)dF}NBU=JMN*itmtkBl493uI)+j`&OI+&msSshy1D+{`>{U~XQ$wspjPR`9%tnWd0DXQJ$cveff;4L$Baz+Jj*YwI$zi|cV2o=iZetKL!>c8 z7D1%{EsC=LR_Xk|Rk{#M(-P$mOH@BBQU9>SAzESt-^`?*yLD?`j%d}{RdH5VmXLf+ zCxFjRUqE2&k@=-JrEK1dGg{}@INX12g6$)l@=A8DVd8hAC)jq)^6zikx~l)C^u8VGbUXskMNRNPzJ7i3E-iV$d>+ zHO!6S8sJMMRW4?`)sbjjGb<=x&2rJ!GgHA|;5X{@dcBH+DfQKoFq7rpS9g-bk6)d= zW5=0C%BeA7U#GRnS%uZ>QQLq*eJXv#g>UG9DBPg4A&*OTSv-n^R!ZdZ|ddl)Z0=8Z}Ro3E?z2fbPp2+S<=`u^djPCXD*P zJlxpht0a}rWH<|9E3>vk5q9i@T53;Vu)Ubmm={mSb>@1ziEd`MamM}IDvA9`llE8? zz&kF#HaZKtq|XM-3w=Elb#c_~7I%G4pG>*+(u>H>JL24=mFId?KyT} z6Fl{!z6Z;hipqVTNM^LlmU@D{g4NhrtTv#mE8~Ul2a;z9K)u^IhaghVa zD^5Is`~)k?To>utV1R0S|56EGr9H3u!ce=ExJ+w)@_H@l#GbHpMVMO=BPkeI$oeXg zd}#6hAAa~!SESv>pa~n?aVuhMzg3kt3j$Gj3OqZRB6+7PM!dY~=}wPO!~fYyvaNvA z<`je^p2{X~ZoYo%%yDfdEC9u%2!|zVTKb|>hB8j zSg*FuYW+5V)%ogemjlmP15lO`(AN26>OGf)m#&f$uN;@0evMCkymgNhHj9T0s36mC z@JfB#2?a?!M^V8FZZL9WO)hW>Z=X~p4*YH^ip%$Wdrg5-@ycF>$=+0ya~zwl3SUNR zt->A`0vC!jh}|k^7IdJ9?{ovjkjMDc^gx>&&4in7*y&zW46ai-u)0kn)Baut~-`i&g1q%M$3#N(9aH5Cz# zl%wZxe#Qb_R8Jq!~uXPpk3Xln1eB zC6ibsVg|Xwq~1tWl&7rw2j2oZ91a|k?r|ELz_kldC{#*9`|JV-h0OeINd?S(Ps}e+ zc(=hQSm~#6DPYAtQUKxpkBxdNNWuto{vDvwpm62~by#Qd6?#q?K077X&mNKEtigva zNkmV<4t$#gKxlZlZ3QHD8>E@2qWo!T%d6lVo+_J$EoCfK#8AucHdp0xUXJx3Ed)Vv zFkm{YJRVlG84~4Doh@-zbr(p z$khXyKi-FxH$=WH=m$9ozsf`O=5N5_(qZF$3X-y6^#`-cyiDVuKrZmJ5_C4k6LkH++=G<@tP)_g0277?SMJy{nxsr% z`H&~+8>1@GPeil%mxv3mM#Zg=BwTnCTzKgqXKCZ zVD(IJ?eb=tUYer*MpC`e-AU!Iw~2ltm-D&ahvHWYc0^K(<5>$zO3?eMcEmU=6=1ml zD+HLgL{pC--B-c~0#V8SO(lsyaUxKhW&$iEAVUuDtPubw2|1#werLjFvVs>fJl*r} zwFdSbrwqLxDVQR!5jE!_Lujt4zU6P@%!aj}luZS=hJsSa@ z@{SY|ON*SPA#A033icf%DZ+Aed_vxqo~)O-I5cJ9=SC=C>5*aAV^UuwbP*N1eukb{ ze;Mj_uXGjRa+SW5Jyid6(9u^BxrSOlI_A1iCGeo)xeWlRr9rJ_wxXv%#+s23cDb(n zPSp~}4toh05z0gQ=9YGEd=9xRMTdwcJ(P5w1>faHfxA3)ckW7|^WXrif))}q+51WD z3YdsRQ+~orfijjrw6(cp3MW#L6jFQT+x{e#Skoc62Z_m8o+dvkkka%w1;~9s5q&#I zVM}rN@>HZZts|um;rz&rcVH>O#fPcfJWHi7Gl9Wq_mgq*@rB=9$vRFkV&}poYa5iw zB~i;0uVq}NLN=lO&*UP{{q!{jlmsjniN>uJ?|b7)n4Wvz6q!eF;mZuJGJdV4U#b$w z#8t=#9%5rhP6k0aT#~$Y%Y0RK9$@bxb8q^yiKH z0GGkt2QKam?gJNhcXt@vZE$yYcXxLi+}#FucUYd^yYF9{-Q=6D(@CeQQ<7BIIp@<_ zs{UH4aawzQ|1f9`e~mZUTl;T@EM1>$S5mQJ54;9%29L@e1Ssd2V15^`1p~^*E1W3& z!vN-EcpQ>C#`^zQq>&qBUdQ;?qaYQjO4Ag|&J9bE5HbCePR<$>@;6*Z-1ugB*ceHE z5Vdj3L8*`s7O)?vskYuFuy676!sfgDS2B77+k8VsmKL_x#Xe?=f_X4^Wnh1{Z}4#K`1^$^!kYACJ(Hp6^E(zpm6y68@Q9o(|gg*FZ! zYRNxra>_hosW(TephbX!+_O@i=*4v)6j#_y585uc0tEo!F3VrhL?QKWF}!k432j+e z8LpTxl}-s*XV$dsiAW40>#Dk_S)C%+iEm?Lb-klO-Op@``ZtN2-x?O>^P4lfiHm^< z&aBFUAO=LOUZA{ao1=^JkYp1KDv*;)dyLgo73$VRuK44(f*l##?T zfWR@P>Ow`0`sey-5iBVnN0`5jd;J)UZim_EFWV;&|KipqDmW9 z$E1HFmY9=MZOm)QY6p!zD-d3sikXr#A}9}zraA!E(VY!%kXVs_A$7hFZv)|wfrm>r zjV-b)bjb}4rWRp14-|k>PRf^7V$6AjN`Tb7p|&WM%~Ey}12xYYG2OP!@h|=1D1#~1 z|JSk@1yM$cLT31ctOJ!DCePw{k!GMb2hDaAALJ%KWu?IXj(-Bi4iz9v5<v|3@2K=?qMp9LF>lPS<OQrR;un5h2nwjiY!7vCpNht&1(y z7V03I!#u^4W;f)vkOc7)S2@;xPj}}oKN6$#Dae;2{;O%qq=Ik9cTUIOAOdNRGKsW` z7ez-Gxn}+H!q`+iGJ02}QZ!7wVI_V3Iz4c<9|#Dt0Mf#zh5js}u~Ji%eEYXLmx(|X zm-7gpNkw=^jq02@#{O3IFy#$_dWh)-J^ngo^~}^~R}`wtG{bAudgnv}F50y1h?GNW zcu$y%f&t4hMK#=DE$)^Vn_c^EmgJj|^9hHC)1z5(baA*;+{!M| zx!OgVt$pR^&hoKEy9wNNkTtB|Zw+I}>3Zeua^a>P#ZFfa9_ zGmnF!>+)!#Y*4GtVA-28xhV*v**awxr-a>7BvM>7|K0s2wkCL517@up`CD76IZFcIdb=>vWZfvw2G;K<@vp05Gzbr_F z8#MiTa&oo9E<9~x^&;~u(8brf+)%?(dmS{LBS_D8M{=}DAN;)dF7^?n8WSU{Cv~>* zC{}f^s2^q~c(M@x(}i8tHjRbb=|`s5Vc&1jjocckvoD2iCUWclIkYor8;CymJ3=ZMjxa7G#U~2{%5Q z&mM_Fz4_yB8N_XKKmgQexjIvOM$~!Ee@~T;gF5 z7w+Syaxqq=O{j8AeB7m7^xyVt&F;A~mIjDv?7Ug`UJr)DSG%URW{O9&edR8lzO!`1 z-`Wve^qV{d?;!oLxtLf_O+U1G@(rg{+Cby5(mOw2_9e#Vw{*Xu0=L%M03Kw~k)ZK2 z6o;}FGn8L*J!)N=b0p)LTf)%TU>v4Nc3nyV4~F|_$ESCP~_z3IHEP9{2Q=UccFmJ z2DR}dIvS;gQ50rLywE1zsi#R~dyECRkS4p5m?tUC(v}veD@fEA1kP^=5rO01PZFuXPL&zy;|LP> z^{2>bKt##g`@rgYH%{^rQVATTXmsL86fr_`>QHA1Rg<`?zX(2BQkX!J1+9I32q*;d z85M*&GIL=sR)5y8bR_g`hZ+z>9kBESbmB&F-w_8@^Pfzj3&L3>uuS9VC&|;<%L^A# zJbWIQV2BDRDc&4x<}*i=xn{{GOu6nQzjR=tj!P}BVr@dykL=bOX!wFGFvb(8NWA)jApOLytQvcM&$NK8xa(R1NOI-)hH1pA#le9YyAj{S*l+r`Z`t@8RpWws3hq1 zZH1zR(j#3+4WW>PSCi<`09!3g1OGl{OnrXCyfbOC8kb1rJp zXVTC|xdXc+&SbxKLz%k%%o%!}I`Aa5YmfOrnY>ItqLLMu^F~1SJ9J))l&xAVnAa*6 zAsc;`5$ieK-ElpPOS>>i&aH;@4~0-GIhI~ub14Hop$0LHG<1r95HKbNME;fRfTkCW zngoAm4B?e3pi4QO{AE(0oVt=FD__nysTtDCDgNP|89pzC8FiYE^SeP+3d#`%1NEK6 z*SxS+(0n@`>ubCFpo9!u_&OrCPKa(T(7ZFZ*U`oBKLJa)6qZ{K;5ic{d>}@7uI6=z z06u(_A?MJV;DFJigk#x;CF@@Up`dBR{Y<#aX(aj-NUkNs^%S6T9Epm5B8Hoi`1vi! zpMW9220HEQe|JZ4WlJ)-IaTRUQG} z=kybxA?eUWNenZ`>fXgw6_4>I~8SLZ$1a3P%muQ=;AMxC`w|3dN zs{AN(74=-Q---y{Z+~+mzq^SY5qzxg^h*mDXR{ekMDAVTitiSj9AziltiG+)rV}o2 zTuWXzF%m&> z7K{PmJZqaRbhrk{rj*x9?t4BoNxM=ynBgh8e-+h@LaAs$M&bGS+6R1HicpU`H9k{x zS;T4JrdZ^Zq z9z2!p4SJP-A6D~`op81ENaK+lf{Jb5vxGg{khUg6_Z{VFCX<$Z^J4KAIUX8@<|8QH zSawu&X`Z{y9&&E8gebx>sh23*Z|rYAlXFXQek@{7T$=T$ zGR1dEe{M;d`nMbD5j-EWhyQ4b+W^K%VzclX#-5Wx26rywArOvoeSCt z*Cu*}r)?ZFYL2nbCneM@5li6W1^oQetU5B+W#0UhFj0~}9=YuJ6Z4ccVT zZr0+sfLYg1g(lC(*ZjJ=zvP##8ykoE(9{H0*N}b~zO^5i*dp_WM3AFy(feufDr;)-KASNlHepgx2kXQaVJ#)wV+0Xe zcRKNjoA#+;2W?Xfi$qdv+r{zm)J)8g?RUN4ZQEGowD&pVxMwkUM#-3n**wBo`UysA z{lTpH$v1CgJv`ao+9o;j6O= zhq9!WnJ!c*qz;sU`Zz4c?S2{GPH%Ge6m}PR6rIz6?~V(w^(%bTC$8V7%}M(X8kR%{ zJ1eEfEt#=v2rk^qQq((0>GT_NFDlk*@@gi*^Lvk2J7@(&x_LD0jaz8vDzpiTm8+#c zYg@ndYxDlJ9JprCo83!IJ+MJmUJjJ)XIB|_Umo6Tlvl~(|GRWxjGXfhOG|B`Ks?U_ zk`hBdIiFe_vU})h>};&R>1N-;MWxMXvdRna^#^S_<`z!;yteWu3few{e~ctAc%fkf^xnuDeCPEBvu2*fm6Wj+~}seE%j zRNV?F^hS2a*2L#>w?$Du)Hixr>>#+Tgc}Q2Q}?l)?B@#L-iK>yUodkR7S4D^5aI66 zl%c!7VyHmX!t$QuD5<6FVrwo$Vclvr)*{S3hC<$$?>gJY4oAim^kN;TmCEcSJuF`z zP=uqAD&QH>447(Q**m#K7VED-e%hE??PH-4-qI3KJXad)n>Ty18Ds~|nOZg!%I@1;h+LgBWr!Gm8v?a6#Zt2*g0}=Q%Pcwhi&3(vO{R-| zCe;3_5#~8<(~Z2iU55G1#M8TZ#oO-fCkV;A!^5NZC?eV8=sYf6rPwIPfLdi{KzpWGBMg zk1hmaWe9vWBDa}OckEaW7BiPPCvGRjRJ6ws;_-Ogd7Lj)U?U;gz2|$oY6ihGB(++e zNlTEp<*G@@1iDM&OR}`8hIIwwUT-ZQ@e_yG=Sb$8)skHKD%^R*+RbgFZIB>)n~kBW zEs#}~?xpA^Kg{yI9Jxs)Tacjtb3!K_FIP9sJb0 zLl|B0B)XeX-%P!St4YIozcSmSCr`TV*U+S9mI}+GlC#8%tvT_R26Ofk`ZVl*hWsE7 zPHcQSEo~QC5`VP*mNP3})<2=KV%O>LMbbj&euXcg-D9vP1c#8SGanN7@8syE#-h?Y zoZhk2Ju;!+kGf6~ zHsJVFxW|x5)1}AM!Mz~G@Gl^)jSV)~R!b=I;1_X?dE+!B9DK2-MVuzfG{j{Yknt9> z6u#Gp(ZMh2=U>eFhT^n+H}u%Ve}!f!w!0<=!%JTetOhG_>^``?U@Do(^MGskwO3Ze zdyL&ahcN;*j4x0_jNGo=%espIqd2nxw(ZX~rV~RuzW~SV9>i`g(wiI(KpO43jWA4U ztt=gQfN470+Z<%6up`l>xoBeuTUWI zr;mZ{b<+#cp^=;ICIk#Ofw_#b&W6Nf{(v{udB3M2zPOL~CpftB_{+pdE<^k&ooc_~ z(R>27oGP;{(^gz2mP?HP^^|A`9S$uK#N z9j)9h?%M)AU?$(b-4qna5m{)K=FZ}QQ~ZAX8#xhW=eGfQ>G~2ELL+%Cu^ev;bwdA# zmLljB;oVqDM-pa_cSKh0FKi3p`)Lv9kaR1~*Elt{;FLJe%Phe#G%}wU=u{KefeS6I zsf%VCH%)+;dNfS(Sw$TFX0AL?D*N%*Fv)OaKKWu#bn0Yrg6$V4&p#~FJ)&q4xtLjQ zCXMeZ`dK>wle!Xx$gUz0ngUIR-jt%=8o?8r(i{6~_&64bZ@+7JZ!9sQyHd|xG-k+5 ztg-1mpQTyqNz+FMH%+_Lkk+E&d^7mMl4GEp*AH}PDbWmK58tSX$1$y_RYroiu%zi(d)1S{xFkY z6G>-AUq?B-A+`!|$uYYmRNEPcacoO zBz{T}DNqr=v)o4zA!SwjDMYf4O|hj%Fgi) z9E;mBa-ZEtqI01afx+(NS8mYyqU~p05K;;Bfwy|+_l6zoS5LvX8RTr6LuH9>yFuxQ zY#T%Ah;BC`Emf}sUWs?d{hw0nMrP)TyyN(PDEyA*|EA~&L&9U*|C<20-60K{BCB+y z=a{k1<8b`raJvU+F+D+NA;ip`0$PR3S#dmB9G2lLlcvS zX(EsMS@TflLinWW#d!g|@nQ$IxT)Rdgylc_nnyY$$!_T*|8jfLq=QY7U46AtkTt>s zBD$dh;B&#wK_p|eh}*%F5WI$ut7R&12At6X`&g|$H^^@m{t@!q9(gb z0Jx7~^Ok^X(<=JKp(fN4yw#*xeda@qMkCDc%$u-^SmiS3NsfnH0Lj`)BFy04FR&>J z!;lOxa4LYzYRX1(Wg|B#qDi&DU2i1-M?S&^(&Q;{*H$UFK?$Im6PB{6Y$P!sZ7~Qf zvQ^Z#I$4j3uJ2@2SV5X(iJ>l7K7ye7>%f)(5Xwc0cF(8cgX5__ua2gOQBiof5ujdFA!Hf$z%ogGKB;S4h0hr1#qp{074``XO95<$PKQ4l=5`PvCdY1$&tviIijZ=PU}K- zfCaooN_K!ylJr3ht#%l#mvAr1K2Fp78|hF)j#PnT5?O~m@{W_)ur8#)$h=@; z1ZliZyj{KEV zO6FvcfK5q8q^wxEq~AiNgee4BVuPg4>?vnsLRtSAbhfJ5(l5zLP7D1rqF&St%cv0H zQ!=n@oYp97K7uS6&ZHafm4gOe*Z)U_G9!&63zct6hN7g9Kt@jsyDu{6ELF4B2$@#+ z6Xq#yneF8%D1MLJ^r=nR6dP*&&wwAX_E+F?zK~TVswT}qEZu-k0;zE!Cq$vRE%mHs z*j*B7XQ8+{g@Q_Gf->p1#F1c)id-4d63!iqSd_-)y@2IaL927(Cg+$F=>llToKm<6 z5i@!rL;!G3?tg$!h8ZF56o7kBTu3Ksqx9ERd_afXfB~%*D-Tmrgf1yWLyBJ+OJJ}o z6qfa#BzJ-6J-%b7Vlrh_o}66Ep8YiaC~9-h=YL&bSerZwM_h|1SS{ zymSLmB3@l0>3BD<8u<05sPtdxPx@EAmA_A1wRg-^t3+%D8q$=aaXVh<+?8 z{L1&H%L&5eL96YPJcn+jc!Kw z9nGzDT2Sr2f|W39bvVdMy+JNq!>SU~O*_1^dN8(9)E$Mi6E=1;K>Sj$V25}n=droe z?qopcE96%W$2ZOk4xwDOuWkC^Uk(>oq5i$O8WwH{K6+eQ5S@Wmh+kdJAnh9?O8Hcv zf`0H#%^)ef;Eo--K2dl~2q}3U61P$iUD8fYA_+kGIt#-+g%1r;2;qN1y02RrSP{J5!cXNpn^RxG0rv=ydLBEcGu z2{$Hwal&w}kHfra`;eyh)c&zM{6C9qxqbCgyU3x`JV>jk*^O6?uYyi~#o`J!UyX)c4ya$C7KI?y=zlS*9d@>glP|X;kopuLSX1a3uUq;L;V7KadAv=$Y$VNpe8on8|IrF$$2VO z@tuU6wbM3qN&qK6187wLDe3#sel<$3ps&^BXM=fhr$}@dM;M)Oe+Zv89PvSVrT_?| zvj8C@6r$6aAm5le;EYM&T(u&F3L*L`DFu=%lSn9wD&3^zZttG!xP z3peFs2#nzit)hURyIT%>6>&IH#IXt(kiJA zup7kFx`$Ei?j_je#JWbbKjvOf=WEd>09UQ^SZS&?s9j)_-Ew4&th{o2D56=ycJzDY~JO&vQU0T z-jgyIEE?;~cXuW+bAbv=AWCpzlGX3_o_x`}G-y5P)JBFsnBO6)TSFwlnVY)fj$z%F z&@^EfLIX)!qUG{#POH4ictkaiA_$g1Y0nhDiR50gm2x8osbsyF|F+L1Oo=Lq%lSR(j5CI?5Q66Yjx0E$~`XGI})~91OUKz_9W^dtDqt+!2E#2 z9E>KXZfi})*#F4PEpNsYH=GfsRF@Znt9s}97l4||$6Lg(zgCC%gr1~%dU3%Qh`5eJ zV0BoCuysCt?Sfn;JWT?2jQ2!a`H*B3DA~7b4u=3Q1+mv8B?R&P0?J-XoEn!Pt;hP0 zpSk#N;LX}8@C1un!MoB;&5{#l)t~9!K??&DW%$QhaE^!p8q3l+Z9>{K3!VHIn!(hV z(ecI01Evg8ju0*FN5n(~?t2+;IZ48Q1!ST<06tT$v%C+4K~gA6MJ7tMDEeT7hUM1L zEQ;eWd{2pFS&RfvD{L`l$20z=o?f72?qG~0wungu$~&ktv|q57b$X2$KOrcG7HXeF zyo67VT)7TKkOXkdSLRZ#L`6885yTp7kpmKj=wHzdtnWrJ?dJPECa^PKe&g%z6%>`e z{8E#FLQA1X0d5JNazT+Z-=5$uj7=v?W~oFli!1E;XDhccsE7^BUjxWNS6E4U(IMPIHee zuw`{Gngm)klYckU@7rjPZM4m|&ssfF?F%;3?hC3VMU0bbr)+O6a<^;>&zx|Kbt()O zoXC}xnDFcE(+tr4ZFuR=XQFD#?H|J~pb!S9#xKMKU^Ab12ajCq#1{PVPYJd6N>`&(jmWLDF z32&bcZy2xhIB(S$Z&gn8o|xaHbdH9Q7_icX3VTtaVhls-7-}<6Ppwi9fnh;MQ`Mud z{9$$Vb+8~@{uEp?nmRBkRH55lmcpVi?mmlszN7i&l^l){HZ^l)4(`%vr~0vlb%9V{ zHa~J!&YJ`*x!5dr#S*V2b(f9BFn1ZhbNRK#XwdzWL1P^iV?zF+Y_B=ro-op=Q@(7D zDl<MY-ErSfck!9D;>vj3y?%cdo z&%H?UR}hytKccVZY@$Y^H^QF{VG)=z53TlQvfRuLgW0=c3c~mu7^$V7A7fyF&cqg$ zqBj$v-{_xuS+*c2aU!vL`4%V^>yST!le7m1GaP5!dZm;gR`LLMPpJQEt3OU14*w`d zff(bL-n*r+QgeNP1X7)24Z0Xfssmk&iudBR$i5(P>r(|f8{+8)oH;ZpLb-^3VvGUB z3AjfzHlR>7uRL?iiV-es?i#+9SLc;F(Fi`WmziVB%#O+-LUxYms6#8OdVKu~d9(%WlKQA7!G%%_ z=TRyaMK~yfI&>^EdOVXI=!RFCy2wq$!R=Z+5=To`w5n1V(RjA45ge&w(5ZONwbko_ z+q7w}mQNE&?btG_uU#2gwKyS@h})^k*4BedwwnCFlg9m1SLVtSmcjXJ^?cP-AJF_& z-5z)554vM}&)`pkU>~oa-IL+&uv?|JwTfE?#a)9kUM?>l8V`Fds@+vj_ zH%4)(nS#p6V`CUD#s+|eHl%ibnpz=8 zWi0b=(R!?Rsml5zh99oz4bOxOTUq8oiL*wxf~aa~UcfzLRGEK3B4f~EnZkUT)k)@u zN;y!j{JumvY7Pc+6o(x!T^f)3fI_7-x|kd5shyV=T;rUx!(>UYJR;YFe35{36998? z=89E}m50DsaKn77QN$|rN!=Nn-2kHNO}RpIiTH(6^-EBcIZInU=Jq$UZl^yZU~s)C zm1=VHD4QefNk0X{oLdDFD6FB=N*1JGp-WvZXVIpTmPwmVpk>*3LX1#uL{!QO2%5A0 zLv0Hj8NL_9AaY%UsB&ys)bGTx>>OTj7f$P6;_dn8PhV*1=*5~yN>1jHyJ9Ytzcg?* zUqF@_#HNrWP?au*E7zf7qG&c${a!?}txc(O+BAPt_Q`--xv)^Y z5V#52uxYQRb11etL&NM#`mMoQ+CRzU2ZUchvml>`hntkzo4!V-GwRcjUM?x2EtVUn zHu53~5&PtW%J~PjF^J)Yo29c4kXf{En3JnnN|{jjjU0UO)XZiGOUw4GuwRp)P+?S> zXjK*_SrLbzSURCDv00`fR2E}FP2Qpti(N`l&t{5=jBv9a8OHv$mjnr{7F!VOUtx>W zR%6`PRe6(KA&S(1{F*jOovanOI3ES_mUH6T=0;w)`9z^50sPiiri^DN&H;-mdN4Qj6z!eG4HDG(g>2fHO*xJ8oko3->9StX{xB-bn zdW9?{Y!a3B+Lq`4&J|E?DM!+}<~7z9HTEqwn|uhC98db>h7e>%=)j)wv zxWi(5%bFDQMprDW3 zADpz4sL1>;+u40t-UaV-cX#6*%eIiMLPow#LZ1#<2DtrxL69{dL7Dt{O4?WZmXPfD zes8w3O$WkGlJ91frZ&zjwl36}DgySzhg@<_{dHV4ZE~$BmWMn{{H+S+3M6Tsr^jH&@MnCgy*jfJo~gJO=DsM~~O=YhUo zX0JDG2t@4N+h?AQog<1%PuWn}e)?FYfNV6QDcjC8VFljYlqQL>?I(4DC0Z$BW6|ar znc#xEOKDjZ~}8-8PaCK_v0RFE(o@hW;z2N!$K$0c(H1wd>5Nb zpBvKtCURLVcmR8y!FG3?Qe=$c92zA3_=%civwNq;idv;cu}ZaKd6Ozl`ND}MH4G+M z&0v7=4OHB>1qp6ZzH7_x;t-pNpHstxkL3usAB#>YZfrEJX|l$o2|f`Tmx*tyn^?8^I3o)$4-2^OE}5eVp=)T)hhm+Gc1 z)sGtI-;;344Cbj#}$E{lOwSs=FV2LAf%pIn%(iOUs{u{L9;NOr@ z8wIXY$%F!3d)0yfVbko7Pr#0h$#D%=v%o1m~S+N(GX zT0*}blm8%V2@n)=m~7VwX-z`HTi4y?{6%i_dvT~}8_dG*jOc?s<5iM;Ap_pfSz{5- zJ5p{5W}Jge!TL8zW)RB>@0uLDeglART0HSOi}p?q1o+F5Y1V^TFMfjTVX8)W-(~8{pj;y6OyfE97KkCE#xYH5~|Kkt55(w_WbJ3~A<`7->mMI1|wJlLD znE477zf0g$j8bH1Q1>7X@5=a!E7gw!n$dK7AcldWe!EbwgtNw(>8cKFmJxCa0W6^w z9~3BjnK6wJV2XxzV}(nHN3G3?@7D_XE5IAU1_rXwS5msij@BPN?^?A9cbS?vqq2|U z)!u)sWVXfc7L_&EO566roOxM1|9L^-_9%}{4`ZoLBdoBf6Pmejqi9h?)iSTV-HVq+ zfiCiFx<*A>!JIizn(d}^i1JU}aOTR0&b)XiyTZb@vHD+4#Y@!|CPy;;AIdb$_lU^o zWl$W}l}cEP2-s9km5BuP@rBM|K`wz(D!|bOvD1RY(*&{cej0vx=vzplSSjxkk-W4B z`C`uFth}??76fo0uFH)5tBHZZ_vl}}ma^uuw=5dfv^DyTxkia`z-(^uDn(gF@TD`1M-~j4kNJ7)Q5> z=Yv%z`u3lLj%~lnw(ihtyDrB$(VUl|eo9lm#(VU$`$=&nf{z;#gLl!>tZZ@d_*={7 ztNr+P@iS9qhn&t!>-k^T-z_a13Jkv2Aq_w7v_%v*_qH!3kGno5 zT%RKqe(aTY3*5MM4OqM-cw09NyFY~0blj&C>k{@i`n)i4DPBDBJXTi+Bo7&;<~TLG zY+cIoKaQWQy)5h6XvTlc6M3LiJes>|9PGU|P4Mtgf z7k(}awJm3u`tqGynGhrR<1)Nt<~{PkGQ5YV7W*!jghi{+IM}cvnAX}`n9W8SkJU2JaZt6sL)1os>d~MvZQ2}aU*y^lilGa&> zR-xW{Uu(z-EZ#XbeySe!rEtV8Dn4~q(hpx|;eYQ9n+`tvX&vUzcEGCT;~M!q_<2}! zZ4o(bn9KGvl3z?Weqef3Voj5bG<)t1Qh6Iod>;-43)}1<2x7a~$$Nc@NQIN9cRE{T zEBycktm53w`?aGo8nIS1;9O4jeX*>?jkn{j`GW^#)4lIAiZinNk)i)x>f1A8rHVRC z(HB7md8)eUa>d89_*>IF+sTXLX|jvSmCmbutMq%3osYdyuyoX(Xj;gUd5an}`NZ1^ zlq)Z?)K&$2Cx5F9vyQ4x0hx3(o?~OrE{tvmfb8H*`EtDa7M2mG{qyt&yAusM(Bm|D zZlU;P&rnTAzxmX?jyZ=C3_&716C{U*{9b zJKFj6npI^ttBS2k_&WB*K*wJqOq8Q=4$mqwsH3~P(>?c&=y}Xeb(rFv@8f6Iskz}I zuMDI|_;?CD^zB8*kTdPNmHBn6e)fu|-K+Me{r3ctu9sNj^m}Js*j0Sbx6k$(r3l@cD@fT> zw)t3&e}=;y!8fg^xE}QN`unHb&qb5rU)1NrOMPAzHi8QHl7+P|i8p%u%j4?}jp`3m za3tTDo>1(fQ(l%n9BSA<-O`BmUiww^^)~;`cNg~_M>px(PO1|>(Fab1IAM2|hc*mx zp~`k9`;B{E4fU~KP$ywWMT^WHX-kZ_(?1yMk>wI`$ z{uajn6Kn3!_}jA+<-;QG_N!qlpC--cKl~(B<^e4e?kJG40|a*5FY#YGzqAgV;O^Hx z9-sQHo0-}zO~Hpho_REjjr6|lCxWXr}b6gM2(w=0x_!)5imUB;YWShhl>y7kg3WMS82 z(3gC^T;s=Vy|+~XE(gU*%v3fjQk`730ASxT|5{gK^vbM$&lKxDX7O>G7z4 zn25R){+wK$h`MR=7`DMY^SeJht2lOVUS;xx!)RXL%7pbC{tj7*!SjHQUm&uaNB1%l zaMF}G7Z|0zXr7v?*c4H%kh&i6Z%%qvreLqKm)Eqatub&fbn1^)VkOFMI-2ai#Z9^f zS{5SyrA-ZvEiAK3^-} z`4C$h%;%;260csLDen=qEA*@-d^U^d{6GEP)c)m-?8y}O7x?(x&1jxSU&`qsR`s*B zh%8ImC9v2)Yj)0go|pDtq$~b)V1I(k2NLLU%xWI(RS&euNxBL&Gfo5=RfFs16QHu} zq+5Hu<#Ai^l}g6qYL)i8K)o>E&GnZJ;#Q0khytEQcf}K~|Kg51mnW=9NI9ZcwAXwN zl#zMBP1MeJF@a{{PPKO0I6c+1a%QgllbC*PzxEfkZJm z`Jzm9s@@uC>(lvay&YJ=i3mmWO>ad_S0{Bv7uFP9mD%_}?cNBli+1Wy{HTpy(gMgEI3s2!!Da#ir6P((TDl%Ckf`Yya+xwA zl6sJimsH0c18Njs043PP53aAeRa8O!5BtvOlLF(ZaHRGVGAfHhKPY zPSZPMMA!rgQRX~Zu?22Ja*mHP?U8`0-k-l|T8>(DUTzm!e&vm^&AKXm{2^Qr^AU-L zTP2$5cRMdJB}=h_im#k?(+F%%fh2q?^?lm{bJah_GcOO zK_YF(725vwW6;Lt()}gju*ija0R}lp52+(9uTf=sB>)bCfSiD@mkD0=Vdz_2(jsXB z{L4y`-~km&U5#%m(LxvPhisgsLP=M6;9u%iXhENX3Wj#5KtiRMRu2#BD}g|~z{B4X zD48rqka6n(iA+1<-XRQ9NcuYz!hqXxbmFT*eq4f`7RMYfc=FdYzSelTi1z7m%MKpv zpTb8-r=21_cpk`(+n-tf_;=;aoznxfq==hDfZszH3NSJ>?G^!6&%E9LXWu9nMKfwX zI+Nnz+j!c2ZqWh6T~|_xz{ZWEHdKKxF>S~nL~pfzKMd}$?HUamvY##bj4DBk)r>_H z5*;yfLwL95nWgtdiM5c6thF*@Ba-e?lY)tVEP9J$0v|EAS2jG!FPyhq5q=cWhCYRBR+ z3hfWHc&)qiPg=>z;4IBTpt84M{y!5j@K9){RnAD4>YggW z9Ua7rAp8o&b+KClibNt!GuMD1)qKYMhtklMp1TYXEE36Q;SySc#E1B#xkvb(_?%(?(8+&$E z%Xf3z_RK_q%6l+8VY+rzWM8~k)T)m1unbBTlDxGC0b{2Tu;19as$rem-@>lc|0aYE z?A~Vsv_5Nt-ANR)?AeC<DI9NDv`KfPiun4Fh^&c;qQwr$(CH_3^U6Wh6YzxV!dtGcG9=dY>h>8EENd^*oc z2gZ;gXSp+Fa$z2%m7*d#Uo?c`jkB%`^vjG_9*)VT+qQU4 zf>)!7dbaBq4(cf`M&XFER3&~u?vvKTpd3;%>`+;KNJLyOF+fShSfy4CNDZJq#>mFp z!^ozhH(|NbL6!L`5zssdUT1%7e2PqcTqXvS3_GrnXut{QW~$gSVjf7)wU4Ds4|{|@ z4`&s2u)A3zDwFv$@lJZItay9YIgK%3&ckZ)Mmxgnjg`6gtl{fRL!EcrMzf5@8mbDJ zTk!3&H{rJ?&!+t!pzH+tQEh==U>#N4QzGNw`qyKXKF4YsG|Y{pWYZ00YBTmt874P-+peU z9=C%6NuHKaZ76C>z4K}x+!2Gt?swlDU}z-185e(zzED~kTWVBQ|gh=r0}c{3Y$=pNoa ze#Ko;ZuNa_@l4+l>V4P^>Or~d35_igd=P~8xZ1_Hqks49%$|Oy=ya^(7I}Bt9^FX9 znCPJBuzvExdGzBeIU)@G;L^|0^Y-ZZopfEVYmN4d^yuvWuA;O(^T9JD__!A+-I`MZ z=_ zc}t$=WdB{vP17xf%~wy;dG?XTL2u+^{ZU=Bafm_Yp+i1p$$Wllcv zp>NOY(?^Sl>Ea~BHM0h^;My&i6{^lVXS6$w8jDk|Pl^2o+YShf=i z&b-s{f8s&IWX5-evcR=Ds=8e?7>5 zY*VM(cRyxmbh;?)cqe8P{5ZDNn+f{b&I8lgo>oV2cu%wT-pqDY8_Xwma?m>b$0D5W}O=C1k;#9xCdWWWd zomoRv7j`xCPN~1(g=sL9=)*=Q7W$3JqYAATO}iOCis|PxTOv@IJXrfCCwA?5m09#& zN3{L@pE^1U5#9f8BUb{~sWE@|91%f_^#_ujKe|c`rB8aSm8NGoCylL$FHX+IWs0S= z+tvjWJB|Z8JEs$W0$#8X-E^_!w{E}lxt7@BP5o^cq^SUQt{Oh+?y;vaz67vq^A-da zRKAO{{UXG!8QXj}Yqp>Z-co@sfz~@>wHY;K*0uiLJBUo3JL3$u_IaR%_Nw>& zEX(n7?-YZ7&%0bBUSw`7eShq~>~XfK^N3vpuvE4}?y)~~lDa!$QdRMk-krfG1zs{f zd69^ z*+Y_%Ih2r=E4XBb1tIJTn-Ah0mlQ?@i4AvE8X5(-=9_(A`-qC2 zcB=x4*0qkdW&4O`R4ZVCrt>Fdz_2MUWL6>sBzDeK7~er#Ho`wA(lmaFF!C(F#y<`* zU@$J;+3B(s%o~$=l3afN=20n7A62xx-4;b%i)bI;_(UY$Q=EI7V{!Q&2? z>6laI^wPyG+cPatDO{tGwMQ4Pm;l(90&1KqR{AUIgDdP8o0rz?F$A=eNw~~KDT$F8 zR2$XiEBbsS5tdb)JTEwxe-r<}4LbQ5BOuKE^ zIDk>$@)ewoL{y%;(x$CS89hpwC>{ub0Zr>q?8;5DK5EvJi{)ZgP2AQ^z^L&UMRxrR zrrtzEljq(%E5j|h>&9p;4dv2O+IIkkTmdbhz{<72!~aSWVy0O&c}SZ^uSv~xC4(_u z%~UpnPbG^sLr(cwi=D$YyW&d{FN#p3)v72}oCAn!W1O=!DtJ<}5l#yKXumeo{?h31 zFqZC!kn`Z!)aOLRRsEc!w-0lhw4F;;8r2jF^0luUGrE~01 zHjs?G!ejG4j)SlzahP!PDNuFI4qG(sBEY<6Zq3@|;f_7Ncxh<;^vy9NnMTS5t;LH# zs|z`+8u!08ZBqggDq&qcK|Hl4cUa55?^SDJtg>R!THa2e#GUA_;pt4fN|+7T)F)ZA2Mo<)pyc>XG-Dc zEV-2JGW<$Y-}u~jK*hub9%aSlndrUlc4aq6vzF~fHP9XsiZkxv$Kv){=}U*!;&z#i zu3yGZ2Wj_$ltuj(~}&P^({f-A>I!acm1ncq{&Y)Xft=|%e!Y5y*{(AA9n5vR#5cXY=knjPy-qYT?gr1cA8}`y-ac>_&P=Z2<-7`KpL;F_i<&^2r$F`tZ{Ix{50JvusK@>~ujFnl5sTL6W7gL7Lg zC3AS94THs_b6PtG1cdQo$W@@DFXK$Q$~k2v>Kql$M$)?*WF;zLsg|*(JuFb`fNHg} z)a)DocY9Fy4-$AY*?2ZjYu9h8wr-dy5NI!d2SQ*#?6ljTaU+jYs7Oohn{dqnh!Z6L0$^GdDn#uU#QU%*2}P@<1DYfCcUBj^_tb`YJXqs{QQtHhR_X8S zGI}-LN_{-b-!)rw=KantbyMWoyWZ@1Sn>Y6BO)1naS?pYb$xfqCK7nA%K6e?_(Ueh z{E@K=Io5r=ALFUtSw0wUUg`v+-xE7l5195oUAt$}zVF_XjK}(=*UY4je7%v?ev>QM zfT!5KjWvS8E0*zBPFP()kH-l~DX^z!BZ`bGOW>_5L7!0Yt%IJaix;+2cgYotxjCcs zXK3CajJ{s4+yM#uS} z-SNl$^_+eUFwryg@NMi+nsL22tqg`#eCdZN{``sA9Ioxzk5TL&-(Wu>I;X@Q~UFeo+~B=ARlMh*Ntr3`a6Q1ljI*- z@`qRte-*VT0EXVo>@fsq6gRod+>T_dwu)+uf8V^l0*@II7QY>DG{=U}`};p4VqR}3~?^W)T&Ace>`Jo$a$r@vT2A{y-H+H|@Ovwi%q zxDO&GnDyMxO1U_e(>?vScM{@%LAHH>^VUgFyKh{vY)@2AGz8hOQDGO@$@Eh8MNKqZuV3=3*8E2Q48V0lO7X@oJWZ zHtpSreTNulK{pPLyGKOvLH@3NXJljSm&+2_w%WeK76W-0+g{dL3%zf#)80OJwZu@i zGFecdX?*243lk@fF9e5tJkO!}+*$xcBM4eptiiJM2oBtzdE64Jq$ce}ID{T~c9;{9 zaWX9+K3L1_TG>^#chGu%37N7r@=P3$+Zddeop9uT_a zyc=A3&pbfY&opn^FXk4w^r6qYXVU&k(piEwF?;vVeuNyUw!YchueW>P&Iz+zMxY5ZODUw@TBG~^k~Q!6Ug`F0Cf~2MH&lv6tAS>5# z&_a)PiQNO)Wm4zse)9UEJhIbWh0eR~)A<;}iNC&~(ApI?CcfrVv_r$+Xet)@Eep4u zZ+i&PqXl{AmaJS_$FwwE8_3~bnw>eF%i>9k`R~7|#`F3Dm5(1Uyo07pHz$lpv@JI(#-V;LmdM&ptWU z4eu1`Yi#$l4j~A*XcAq9`;wTbX1*SY{ElS0c)x$t)4G!R2aaRD)6d@nS71VZSeRoI#fi;$BZxu?s=E0GgGX zo*%}bF?#X}8(n-b4$hw~UhNjwX5$&&MOoFok)KR;yf$oRT7Wn8lhp-nBw0wRc9=Yb zS?;v``?{I;{ydrxv8o?8t<_eRIq$FDMBdz50&Nd1f4oNUTBFOP8re8ojt+DLc^4Yg zLZ`Rzr!L9Lez3}fhJ+0HwKJdSOI)pIpSK$PGPLi2<3zj?gYyC*Y|zWA`kKf)(bAwek#wt8l+85Z^Ln0c& z1v7*dqQ7mdJsf6F<9C3Db)5?$>egSJPkc&<|Esy>)Y5JxZQX%dhOHI$3!IK1Ho~=UQ*nnG4sO? z$tf1LKZZ|}mTcS1&6E%d4x}DRa$@}g-i~!)1AM?DYkW1(N2+|B`6-`O{6T_MA-{rK zzX!qYnNer2u)&E`?aM}Is%&R!<5n;QAC|6zFWGi$;7pxH?KM~7e_+dPNyoETuo91O zk?b}UL}y}BGe7dCm=FA;W1n#KbBj2Kgs8a?yIiBH_jx-d%6*y87gC>{wNqSmEwy_#Cr7KXY_k>~70=}=Hn=qmR{vZ~ zN2F{iGPPV;@T-;iS~H_gYE%wPfXD8j!npW;X6~3EHerL7!r=b>jsV{ zLizRM`Ve!b+9_aIF?QjN%h6Kvjbk%D@%PFsgzKLRo}zU}vz_U8FaJ9|$+Dj6H464oXt%00m4cb#>ZnnAxlZ zIfKw4|CK>-Q`&A3qdWb@(EP81Ph@MGTf~Vj)IP=T{dhptl>Z$HR!c$2Ym|1FCs>&R zm~A21S>il{@+VKCzAhzbvfI48;u?VC!6|r=K07J+ z($JB*%U{ggY71)7$hd0%L&h_Kx*e8(cxRj`cVc6>y0Wd$v0cioR+gj@z_FJv)43qXNWtuC=rBs-$g%F*3jsB zC~!)xj2TE~2}wKD*+q{`GK@MI^z)~^`(64>BECg9*mS}sJz5MSFrYUt*?=1IoJ3P} z@^}YaaY*`;+Du?|0&6tDCz|u)$ zwsD=qX1Ix&lep{$`geIvr?tP*GHR?vtbheXe?&M61gaQGvkz%@#s(nIc`Q0fFAf+)-A7(`P`>* z=Bfx<8uUqh)PeFWq6jWx^VR1@@>cwCQ^Xkp;6>6v1FB=-PHse7J+d`=TR|#tF_5T)o-i6w1ZTeNmS8GDc=#Nh9s;=f5KA{1%!(2og-IuDy}b5 z+peP$7&enUwxq6M)rX?> z;luBN86HS__4$)LF+qM7QMZsE1h(u+1@- z5#`ei3xS@YrFNpnFeG0|Sz6>pd}e$Lu34vClE}!u`*B9DSo5-;-a-w3H3@55APyAwzVF?XYb*E)p`gP;Y-ytN}b0H5qrNcQVd#q>s+fjFB;}U>;FtB|A>NtR~0qLw+k~?gM@;XYLaIM=%e(DXDoO z*fLlDs4M%;Z1_hhM%N-D2c(-_K(*3R>ILjL4E6>43!|nqFfEAFxg2-a+xOI1-5_5@ z@$CZ*0EKRYlLA8?-~c|{Hohi8(fFg^+Fb1WA@SNxRxBl*Sfm)GQWr2!Ve8x0U%*QQYxT8N z7DUu?C!c)yNw_;afrf=aSwJ%MGOh(%?Od-K1TWMi`=N96v(8?_tVV<)*Bp3p&@EFe zQ3rauk95_hQerF-{xuC0j9aD3=AmmcUCxuq(I`|||6Pd+wpf>04pD`eeI`1p^ff)^QLat}b;W{93(_XE=HLa(L!QYsG1;5u zPXWv0k3#KY$s~lD)x~+Lxt>jmBAN5&EBI24{8w578-KZ&JnWHuO~ulycW|EX)3Ti2 z6Bv_K%^zs@R?O$BVfl=H&#-c#)~xL!?qEQy&W{heN2h331Q7hgZHT+9L8l!GRNV`c zOVRCHo}C9*Ri^f&p6H6c^h0FzU|j=e#$D*|5}oWz_oux{0LA<&Vm{=TP4TT_*)8$& zj#gIp$Vp>B2vaBaO-uxo9%xbXosBYwxMTCukO3;I&||CjCf`IYV{WG117 zK~X$ACggjP6r?66Z=0rwY%^g=r9Vs<+9f6#Qzv<*CD#v0G9%GsAg>59X^N{%Nmx>< z*OFF~DMKhSX-fK^{>_29Rd_dP_Qti{Qj=1rQELfV&*dAS4oKeNrhn)i(-*# zR2zf@ei?FL>0AT@aiIQ#S>__bB1WH8P1M>g%Azew3j4$;TFElb7|sqhQ?fj{ckE_7 z2Wi-Kp8jm7O)>0$W@@DxV54_oLwWkK)}RwU-e(KTAY5n-X7i z{zY`Z6Pe~1O1a+wOq1em{w(k-XobxAA00LQXG_r3` zh`3q81*{fs*ozda$3C1qLD5?`(TtaX8o2`XlDUe7<0Z?x(?%~>tu7p`u9Pah3AGy2 zik12$>$TG+oBLB2FIS$dJ~{JRWo{aj`O(w}V|Bv+nQ*je`1U(&Faz*VbYCOKr2aI4 z@o@v_L;pb6uTk;oQo6pE9$KNp`2Tf+_DB(xJs2^vWkPzio|}^o`u{?DargE8zhm(q zPk*7ZDlbA_sSXpf4gZyXs9yQGV*MAiGRiUe@sz2HRK|t1 z6eo|2ALF-2Y}Px{KLsZ~>^%C>j9=V!%QGObYTB+M_222#_9=Z6k4#p!dx`mc839I& z?Nx>HG$PRh>@#6rA?KHK46byPLZz5j5vPswG@QMS`5?|LEWjz&)SiV+gf1;0=pg{- z`B$=YXt@Y+kq~V_c!X-bk!I(g!{aKz2#(7(dFdnhJ{5t!@3ZdOy}M}!|F58LH}HLD zXb)_?F1*2JHM?787Qy^(cykGT3B-l}UOi&_+~mbKv`Q^nMx0wxrCO-Qs!7yYjkXJZ ztyo6K>J?DSIIjZ7&Tz0{BTCUu`2*L;*t~-Y95eVqW)GEk`1*6O-WZdQv}u>B%-a

(pOf-DpxkjoUKs&{?YzW?483iYoRO`j!8U_OE_b<-ZdIMTtr&@F!C);GGusxe z7};;52?Ka>*6E>a9M~8c1|kt+NDs;ti@q@$`9KT`H>J;(QkxeaRL$KiTePLUN6;Jg zy?P0Qh0nVoeJ42bgAo-QxKzP6%c)E-CXOHC{RBKh-e)UN(TMLamhto~6mT zMP0^RY4%}qdTB2h{)csHG+&wBszBH8w?RH$P=bzs>F^KWCEEHG?a(U~Ywc?8c8Qf! zF-}2AN|#DWN;2XcMWh+hz`SwI#c%!>0bwW(GzjV6*OK8mnxvMYGJS*^?%?%c3^d9N zn*JwJt3k!W#Hr=Vpk-i@|6S9Z7 zf)XC?#)NQHFi!%^N*LgWb46hKnf9-TD#};2MBfs)mWALAng|Havblc=@XZhNpm*d` zNR`98QQua0<-Tgdv3U^vdv`({8mfjM_|kVJfu!V89v6ZzgZQ-O)K|nP0f6*TUzg)TxRShV!F*d*cY3CPAHF)NE$3jL(CHTVcl2d4jbo4lV z326Tv>e)<%<7G8d%Xbb5VCax4*=v`2<-mC7lyn(UtHR}Iz*D^Un)2b*3|JKMw0}9C z>h{&sN^7_vQaiufR>6ax=SLD@^mLhhh5ZQhVd^=^)N7UcX#e|Gnk|CvH(L4`bD99r z`Q4RnqO>C`A;oQ^mG27~Hi?GI{<$@4u&lnohOIl4;cqRVjfWWgk=M5#>`7;<&rBkn{OdKyJh{ zvd7vGR9OuiyX#@N-D?JpoX2k*xnr>!oD06m-W-?~WW6{F*9g3hHsBeiWvf5Nu|hti zZS$Yy58Buh>AZl99qf~AFa1NPyWdT{t)4HB41aUp)4PFuzisE}Y^H!I^@{=pHz=cM z6cG)yYaUq+Kzn!Zi$o9(mrNWh=+rkv_MBKWhGgvy)O5j9UcUFN;U7Qg_pa;atMRw8 zV!sQKkH&SiS#_PJGQ>>IOtfx&^9(|5VaAWkMdGr$cJd5Plm6tJ%Gofi^W*Ob`;)UW zQ-F~jEAqgJg)rjmC@6aNH>c#h?-pqToW`b&uOH#ZH-Vf`V}E~oKhj(K_x+4#zf1er zlPUF2EXiHije^nHI|R1(hnUxtcmJr7282Z*@E-Zvy5QjrdWPe3kH2Xt(z*cEc!i(* zTwwj``lWsd*MKh5zwr%o7A!;;3Q-?KlLyLG$p ztZB)&nV^*;y3$#H{$XB7yYBxnGL>%X54V);{Y2bYIQisb0v)~MUo8nXWgn;U%Xof0 ztgC&d+dYOslk81nF-EVWWwV|;qPzf(e$QhPt8>hyxxH1)K?wxC=2~rmsOqIN`B?tY zlGU`&%?=N%-#y)-v;#kinQrkq$53c(7}{0iQM&!q_qbA)@nJDgm^1`7L zS71J$V^HPSD575X$aO@Y-B8-s4j$Y16Q->i30MG#fa0fe#1c#oL!!FSG)wx4!h>pl<}^fHWDD%`P9tTZ-mc zrdz}a3qyPFfxjch{x9EnL(&f|TsI{4bzT;z2A7ag9zOf(f9$Hs3+gQDzv9UIUIVv2 zE@a^gyMnJ^Gz?>C#LT=tM@DuJi_au~;Y3!QySy>7ZGFG;NQN|lV^jk~`MiWVwj-wH zN8OD05eQs^?)?4eWl%7-ykOuQ(|IQAV&~+?3=Ty-?Mbo&?pi#%J1hS*htMBH?@=4> zpafAv?s7doGMjuj!uFNVxP|A00@sEZWq8oSc(H)q(JYXPS}UJB-dM!D-BsL54V^$e8AYuP)5>CvsY7m2p+B>tv1EfkB{prB(ePNU z+m~4f*Rx+{Wj;?NP>eE~ua^AoJUw+hcdi8=a(^ydkq!TPd&LZQ7afuL#AB6~u%^M> z?(6u%C&Sedq8o*RLqz5A88>k7 zeZ{lKDhCg!%k1g9*Rw+=P}D4joGQu!2dqMLRf#M#D1QGj+2Y!L*Ch z+v68tOf^7Kpz^E)IiLV5ZH{O`aODRa8q5xhO^AGD9y>t`OfhJ& z+DC>SZ-?w7<>%M`26}|$#X}$2y1Rr5=e2Vno4Z29-*%C_*7IL`iI3Yd!VXB@w0TqP zs)f^4@cJjWvLkJPIIO1L&#Ut{KR`B!d7018omW;sJaD%sSX_?re@ooZ~OtVFw zvh;UjC70pXsqGm0B|M+0KJ|%ZU2l!$H7y3zIRXWk1c)e5(1b@4!%w>2OQ&UpRjf(@+h2- z_qkbAz5>sl$PY+*RV__`l|^YTmGKZ@m%VW-!Cf;AE$+B);M8cMumM8)%Wi?SOh!)p zoV(2Ha#&oGVe_#elXx?DUHl=J4L`3u=j24Z{mbbdGcpk~=*{x^tR_D0t((i(1Idr` zfkpouQu1$ycW8X<;m{LwKzEqwTkV-=vM=75j)jAt?WyW#?9}n@n(+Q=4YOyYAUr~z zHS?am_9DFv{-;@VR~BO*rwpw8!|$PdPAmX!^y%1|QxbE57|7NB_Qdu2yR>r~XU4^e zO%@hUmDIUd3TO*+qfcbH6^a4q6+8K6lRSSZul;uWyo(u=ANyEyyTr8Isfn`FE}&^2 zuxuSPF!~DfNC&ihD@HPMU2j*6kLbZY5BEMG19E6OGTe3Ih6+ z0&%I9Gsr)Eai?RUg5~(&cnb)sO{N_P?T)rbQa>i~mXTM9w39>x4_>bZ4m^DtP7TjT zCb;TCNIB2lg_kZ(%3kp0UX?cdw}KC{fIaIqeu!}}ny|7c+q{4DTk$-y^(zuXETeWwGpL*zb6f*}@NUII(B$e(h&Llv`zqXo!o6cAuHDjs| zkL$bW2&~BlWsOhvPJB$#$;VKw}m^tQh+KE_glZIH;M1h4d8)nyGP1_Zumw z%c|;}%&6v7lNvNh3+Px)95aVi+yB>OXYg|L0S?6Fc$y|idz^#80pJlTMd+7kmpLMN6$0q+~qb%zO)~Rku zs#q~Lppcc;KLkEQAH9nWvxb4s`#OR|=RCG?vU}(pD3Xe53r94impN}t#A1n9B^1+Z zC{0obT$oMotW`mj_Sn>n23HG*F86!vUbuFKBUCTGu#B_EU9FbQ%3a=Cz7f!FPVa)} z#p0%!m5^tpvhV>VBMQ7Tu`HGta1AV)L=VP%F_y=l3uj1*g@qP1e-dn5PFk6FTs(FKU@3`sxTysXR=6hsCU4D0>p2PBIidc)&$&Z{9 zJH&>XrUnnM$vQDIPSZq3S|)X1r|`&fGt>^pb1-=6Y@WR#(;>y{wpi^x*zMYUU@LEr zfSm2K`nV-QZiFJfenDJKE`Rpt{f7hNp|zhj8@fm$fp8Ic-zBNzCa%Mk3k|QG8gKqx zv>Om~;ivj}Y=!P+H$9p57N~0^wIh;og*l#_{M0GYY_4z6Mc4@IilVZlVSuemoD;I{ zO8Xe+#x2=N8Ql3}m*-R4phKR>g|lr@8&emJ0k+xSTu-vSyTUva>e~}}4}K>D}?9EMMbpDtf;z=(qL}?B3krlnzEq5Z*}Q@wQ%Pwel-S z_}D1?#Pb<#Gl^NIdlD3}+@CLK7QNu2KO8Wl#8GhX(xA4DJI>Sx5?xh6qDg9h9VU3Jg|rLCm$XT|nRF4?}T!-Uj~9mdx5 zcTDTF-Db3(iRvYKpj$7ZlH=#Wsv6K$d?$FJ0%jOe)uPr)YYP^}6Hj|l_8$gqPnCAj zWfNO%VjqjfR(5?$L;R*RiZ;z}YX<9pn3C3Vw3T=Mk3)6s(e}s^oi{nWHRszT?W?fA z_Ge`>8a-y#YuR3(*xS9OPoJa*WQ-Lz?913_{~qL4e;M#1&aJj5Mhf4sExiPcLL4eA~Ei-8h`A6TsQt8V?qk6<&~59eW>HC$1q7Al3SNkEV_nV3;}ByXI;Q3(e~%JKTL<;IczV}KNX>yZrS?# zR2z)$p8jtw%$y5W8075?B6YX6ZWj3Pap$e{(H$?=Ollx%G30sui?xN;g_PQAh>B%g z*>wlNs1n(#goU;b=p*UMIs)>10DOBhnw|!kWRxu=;AKQkOcrf}S&3oOy;l9DktF09hF;iKyf<}B!~~l}-Hyx{8e5pIxq59sxHKP;{RxZ7kvB7xS zyTlO1E~rJWANhuxHxn{acOg2V`QL)rAlh$kAKB?XEDdO+*Hb@zhE@v;YMn|`(u`*{ zPdiH}vgdDh7Ps2$Om?omFFp~Dmn;$$+M!+-B^gb2eeCiaHoO}Lm>Xk8Ijq|VoPiT> zIPKz@S7uT`Le5n&vGkkPk!&zl6d0shl8j=)3fuBkrrIf|;9n`9uh7lEWVr|abuAH* ziaASm${v;G$j=eyNxV%MZP5(O366yK79&S>_5sjdL6=rw3pK zMmkB(N4}T*8QWT}0c0^Wv9F&VHyv0$8>5Pqea|{4Ug<$AbGWO?^vKnZADCD;qo-qT zyOh;$d5(i<`N;@WROcQG`nysRof|gi!`J*-JkhLH=JKT|@t8Sk?f`d+in5Q=^lcum z<{+2Lkr6h`Y7}=H^2%ir!G#|Q7(z+VLLEG%)tZIg_4?q?R8;iuh`V^2;}rHC%)LxRXMk}okhskKJE^mPu(b3*)eA#;~ZKvHR*r;PX5;GDBEg{JiClvbY6)}gjeIvxGfQBa{}HKDOLtxhz0=y@U_+b#v@OLJ3m#ffc9)gmk%Eu`&Ull5 zlKKz$Hqppq00DcCy+DCq_|$q`qr>mT%a3PBnZLrQIZSGPe!Rfv0WJOQX^SAR=%83@ ze|XkL55`6N<&hvP%Rxu5QTZ+vPM8@76$}Rcw__rE^E#$uFY!;CA~yPEoLhCIV%sm{+%Hr>=Qio2<_WugV{L2@KIwtE)F+ta@KaR**Nl0M;UvoPO^^y9E}8YqA=?sh1V z9s!WKjaQ08zM9(}^E2bQxS0zQT^}J(xDpU}nK=$FTKCnvriO?H0vmZkQmUE*oIm$x z0n5`ceLTa=wa&|UF%PG+qzP1gYCrzDu1hMm>?w^vUos8*SR~41@J-_GUR~5fBdoPf}#f7e1mNZ`H7=w$td#;z%;&B7h_h=h8Wv&3zY zmu}V^Ai!y9z5QN;4JBwIN)+j&HlP@2D`lK6%84c7f+?dAgJv@UZgGzPRvv2#yG42t z7K!mrU`td4{gIja*jO5ic`9(OaPzQ_0u}cwZ96P=M6BrP)q%h;o>Y$ebEALHwnJQv z5cwmT-U`iify)6_Go)An{8drI9$#K;s-zMA(HZpk52s_NT$4 zknw9OP#awWXcp4M$(Trzp84=v4fs;SG}GhXfPwQ4x!@GNU`fFqeF%RiMf==@9Vdg! zDK4m+AOl&dfxb6+2vDTY243l`nTG7`lB_tA@j(oY1lpo!%s`?XdSHmxfEh@hc|?{C2te zF@(^M$+shCx+-~KRSLEC69egTyM_4la(j6H*`4$=1tIqU+@i><1tXq@uE@5#P`c=n z3+1B=b92OTiRV|*dy!$GRU_oD=y#-DYOSF{o0HuEqm2DM=EM-;tQLTW3;k~{!%EUl z5lk9piRpRVL>c8g-p}kf67~L~(uH;vfH^^o73$t7fkR>FHTGd%{td%}ay^@UW8<3; zWjT(UIX-vE6#Qjo-rcofxTwcvl=ROU_F3kcF~jJAoTaf26T0%-8EguD^ zWWzw)qy%GjBOO=2n&*MZ)$LcY%U`SOiNv6X|oBZI|I6t@k-ZI zv!3n`OQa^wbS7^yTnrEUyS<>Tv2-0)bRjzT{l&#b#rgS4GTiR&{oc?j0C#0kSI8fv z@2*LYzGcmoDIM(yq6*((CQ3G&bPKuPsGH1--km8D@NzjSw|}tCH%CDf5`8=qXla1i z57>n{{?uS5>*GebS~ghan;sR3Aj9LcbgYw?;c1fWn2ynyo4k60V}he3UH|Q@B1&nB zY&jojm~uE5Ge1s~kI?5Oq)qo%gz0WZy8d@kk*kG7CAQ-a@f2jPHa1(WuIbqjX-a92 zE{2P@)^BxTjbcLB(?(sEFF)bny3}wYkddPK1w0a&eS$#HC|%-ya8;(MqEfu-!S*H` zJMqGs8W{0%J3(X6*?wfWVDj)rd8mfH8>?|FuC2Tgkd_&*ftM9;>Y(WTd=_utKOm8X z@YoFv{!X^UC(Y|P;*sT^LKF}tnT zh=IH@-?voK%9EVOR$RjKGQH9CKV$Y)|?7~TjruLBm`=;OhOwPip~TzSS4ilU_@EEPl(4h&@N9r8Fit{OPgmLCFjKF=~I(zvW>XT@@1?1j*}gdv(+#yHeG&!Wc2^K8r|v zcV*7csOv|nmQ>>3{O0$-+m;$cSEl==;xk)Ux zWlFXMxum@?2p8Pw11$H5)jouFd89cJpiDcz?b{BjWxV~|9}-U~lz+UXB1~AOhQCSI z;pQavw%pr4_bj4I$zNNC>}gHTT(V_meG_|8XunUdojlRw%TCM+6Q|Y|LLEaeKmv*q zzI_!pJb6+DNAMyejHN+!^tP5;MR?#d)500Au~3yNt?=U)eZ+P%DLp(yTTw7<*LwW7 z@XokwS{H?&m{zl=^klMT(k}Oxg})LXaFB)lIp{Esvh#V|GvR(JV?I`1xGTb;|B#$D z=n|4)94puJxWyaDL$QBO7Sp;9olHfnen1=j3Rg&!9nB+G9Ss+=pwPPaM3gw`wiu9R z!+z$_V!Pi6yasf$6$U0PpM@_cc9AR;>dM^zF92jfo4@gnCr(p-oq7GWhJq1f&ETET zR7x9z|M`cgGIIC8(ZwDAXF$iJ##P8I;!N<<6cMZp|r#ICk}BF;Z4cx9o2Z2c77s&XIY zLe53O?vy!9S(Am#S*wCw60MZ+wh3lP&jx1GNM2>BR2ss@(sqGZu^g;5&}d-uWwS#l zS=6kwW}Tu-`N1=q?$m*=QGJY|_rEKN>5eF-R@jWwT1h;`5C09}x7!&3Ct{CJ( z#o;?2@^W)Hs7)KMXp~Z0C0$2EmrkwZaCTc!6aVZ4SC=y17c0ENlcfvT?t_9{$#zJY zkPN@w<7gFo&&Of4+CJW$2x3coW>V|mg9g7Xlr&P`ddcC~dEhf1pC*ms9TL(LtDHRA z;gCY>>wfnzN>7k$d~=ozayc9~kL`)2>m3r>Xt2r^Z4qAd&kt~x1wAC-z?|C?*cKW6 z!D^Pe(rW1a6ZDvq!x}Cfzvu-b%u;B`^eC`R?SPhJoymxDCiy|LQDOPn9aT4& z!h!Z<3HX^@R0MQt$ zZmAcMajhxPP?tsPOunzB1!@dyQN%!lZ5~w(Kapgb<&RB97Vyg?mfGe@NlY$n!6eCT zj-PN+UfN0w@@-8YJaAA$`zX>u-f*pAmD^5mhWEsCIPlN)r$b#cQw$)aDsU<`utJhj z^5jnh3HJT@o@7A?)3o*d;wL{4MSPl03IpO#ryc-jK`~%B&@ce@>AZ=;B|`M*IOd>F zN}dNV1@_kPrx|)V!KNtoiYJ@v^Ntf{QbF%VJE+9(TV(8gYL~#!SIr#M z(rB>gENfB;IILVn3#M?O#8z1Q6yiZRHGA6*DV2|cGtfa5HJE$U$8)QsNAW==9<#Uih zPs%Xn!`->WODDdE4Ft~$tBW7J0LoG#WG@hNC6gm6x2!~c%NjjcPJ2`+hDzN}|06Su zm};6uS!d~q)GWRY;}W-03LS2w3_YZ7i51fxN-TK0;aUb;h>C*ieY_}Mql&j3oPWga zQ*}g!gyxOhj-i6+<+McKZ3qTCZt0PdEIg&=Xq9-t#Gy6+!oSCok@xXj!sd&tI~FoK zhut@T^qF4+E&xn@;Q7fcJqY8HROn^O#9Pvi238#@vw25KC#Aw{z_)RRUUzadAwT~n zu&-=}pXqa{3;wRJ0~`Fbkp+B@jSOb^ab`y@(_6EoVr4Ca>U%;o9;{@qyq;0=nIF7^ z>`*6TQVTjvb7fsk;S`SWhN*u_gJ-j0vyA3m8x3sevs)H2sBXACMO`(P*{V(NJ(1BKeQuC@`q{T8^aE%SuvZ4S!u1NyfX8S)i*FfgzESE zqqxvJL5`U7dcitO=!>C|>5@<5S7w-I`GpuoHQSce8Qb0l7GMw$e#J%bfRwSzv9fb> z=T}`=+W@I#9oH|lKqC-`pz-&t6l>(yVPaPc@f{td#r-^b3sidTAx95+XW?xk8cNG0 zD@ii2Z>$O1aHcvuishhM3)F?4#&u~7iup#C2itJ+8t2#|NHFRrfa!@P2jwCUKM=&q z;^lhS`lTHDi7dm6xZO#L^=3b^mr)M9;iowk1*w^Gs}|CgqC(y~46Tu&m)RYWaL*g9 zFtk)shek*fc8+$c1!E7n2TbVseqr7}Om)ByzB+QLNC!xUvkAM$R5?1(Vg^|aL6E9QkLQbxHL73x8Sfw!8(b-ohHaN#S?L)c&j#+ z3j|VLMxH-)WW!z)c34B+dvi$35#QLjpz!n)X1Fxu<=-rZ@pH5W1w~1IQ{oh7?vDZy zjj~{l#St7MsTNQ-Nn5lEr0Gw9g;C)02{uH=m!BY*X zA!|CAvPZtKmKnw~W5`dv6GOa(sD%XML|RXU`8$oc*6 z|F*X1U)QV&;wj~*)E|J~4A-xDU@`v)z<0+;^X&KsW{&$q$Hok;12zJEN&SS=wU z0{VWi-1N`(=Vf3;jpu>#XvN<@-{$f5pb6)B>L1fA#O(>UIRu$Sln0f+Z9U5#gRZox zr}6xtm2(xhlCljtl25OrZ4Jt!k53|-(Btp(xet-GIOWQ4+aeE}`?LEivb)(KsNb~l z*2@nqpYQX-$$A<%@p1Mocxk;L` zkcvvcLA6L^w7eP(Y%!k}2rq-7{JpQrKFmx0Va2)d{wYB;7QCN8N-QK3-ZjOU!RaHC z$VjM>P%EL1)yBJ#jMGwUBJW4wPb)2zWTTX9RI=XQMZop03T93nR>}`GHY(91eg%Xe zTqP>mK(%DF7L3-S$!e}NwNRQ`wrVYy4o7xUKAlooBbhWg`_O2*Z8)1ob~+|&0@7$ zt9D#soz^Qmjg^kKYgcQjL_2nh*@CGO2P+M%HPC3FT2QNjifCZM&%s;+3k}rxEHpkV zjip*Ez1E~uYt^jPJhf^Xtsuh+a?bX@{3CI;`%`~e{Y`_(zYX`x;Ed?D#rbc;Z2jGB z;o{u?|D?U!lIu!tAo?p6e(_6fM~OE{kJH`0%y|1uhwT_YQxZ$!DvDH-RB36Sn14S2 zfy@MuNi9d5h#lRlfK)OsNZb+$VD@;tIl3+N58(s#sp-To5%TGa%KKb$|1vx{FI9cU z5*BpH7dq(+Z}7&%X}SbSIBs?TShMiz}S%)BA;Z%%#y3 zipBlMaUe%&jm6cuaP5;GI!(t0H5f!*>%Wm|^rvBT>i$f%BL9smy)u-_ z;fN6ftanvR!V(=|NxPa$64uABH(OKwqx30=e-4Da$;annsD|q%CRj^>b z?~Qq-e`rJ<-oFkj;-gGSG2n~Sk8(n zczBMzRQ(N07-BgWgs&W;zBhmf;hJS49~ZE(8tY#zQ~p17|MsW5BfDYXLSXUv7`Qg3 zKZqj!KP=cV5R>?8-3@R516GTm_m0b4cp4d=T#Z>g{&9lL;kgdgvJCZc0gtsm*MGjW z$FF*-AB^ZvQDu<>5Ou?A%>sUb;&5HT-{mOipkin+%|oZ&4hJ?kwp4jkk|N&RH7PcV zhycSa`~T-6fwk( zAAp+bj_btmhu^*8@lFGMXS!qxKh$U1=sPUQ^+2jdJKTQ~P)csWbO_hGvS3nL-pA1& z)Nqq)sQXS%SM90?=xj8l1Mx|zZUuL8c_Za!n=!UDSy(c!5TdiV&?M36uJ3C?l(jFd=9#E46jp!d5p-kxj7;~HKl zP%wZKuffg`r0(C{1kvy=wTbtIBfbmvZJF3I{x>+vRG~?WTtbfZt)=hHBjEklniFP+74SRa#h@2$Za?2yDSsDPt6e5nxk_XOygQ{w5|Wy}rXjJn zM7m**4U3|=g#G?YdkimF2WlAEZ<67eW{IUhX z*J@tJVwsaXaXw6g^&xQr#UmXT*()T#kq@^LQbChHcFM@`@7}^0V=dOV?P?Ox(t3GU zDjl?AE89x~0F6#L7rHEfzZ+LW%V~W6u<#*J@}7mJ(4^`3dNuku;A;$FsY`|B2tNT* zS2B#ipY>14A!8$H(Gkqik;@Fsx}mz8o&^BN6%3yT79m47^v@s%-tF+{Wx694&0rVo zJ)Dc*hkHwTAV62RH8bnp>L;K^kdhbJhcLDs!EsOT%GTu{({Qx50jMF8>ln5g6ioQD zPv~(7jt<>6%u>DjTShxoO{GbY%Y%c@gc^Y(ebRdM1)<7) zbn<-YPiRbx(kJ2Np696VfA>Fu0Sh{i#?gs|qE=V|I*e5ES}SO$rnyNX@S5O)Sn97w z4i-QOw&-N37>r*4Xc+7gDG^S{+3ILZ+#4h3gBx!UzXl{064c-k_RrxDgM+|K*=Unk%_?C{oP$P21Xg3}#l5C~i|zNS8N zIZCOh2g}tPHA}-FYQqV|D4aN5IMp7H92O3v=~eiZ7VKqkRpW)1cfr6hL*s*b4eAQv zLTz;!PE5E(;nZa`7X+}8{;X~91s+%hI}8@+qEK|h5E};#?}zXgI;$?F;Rhn{&S|gS zNX@%BaY1`DiT9@0k^E7bCqWvrf$ zXwCU^1Q9mENe@&jRSQNFbK&_g*!gj#gaJ4puIdKJo=#{Y?pYY*JO{yn7%T|ySdWbf zx1hpL7Csvs9IUiOGw-E>6n1ez-rL!^E!36M=iT{qrMpoO-MQ|*YhkmVfl66Pa1jCo zk`4@S;s7ex8aDVm2tZO-#ZAMLGkJUes8U*kwAFr5r~ANH`x+@^FnkLUE4N%{a&ZMy z@(jc~QQ)=q967re8_?3oEXC($WP0#SUa+xqTk{o`7!;E7JPQHNgan9Kfdu`v$I9%= z%2Sj868hxy>L3fxL-QcsDLaFX;Iv$=|DwV8MB?B9bTe=qO(>$3XakL9*SpauR}PKzklpIyt_|E<1m< z?+rdLr-USv{*3e3)U$J7D}D%=hGldxSc4jZ=w3=-(M^4eH`a@Td!&B%S4(50{XYX5j>z!n>Ke zH_i+?SAk$Ji%-R^l+ti7&fOw*FbI5RE5auu&N@Bh^U_UlWHd6}?m41Gm=oT9n-?H6 z^x!4Ez)Yru1rkCnm|8FOA5Hgm@?>eGA))fX!MRxw!b8Kw@(7f!dxy`4fKH@D&%UiJBR7?n{GtZIKaB=6>_NV67!2hrz zCT$X_ANy3OQXavNTcEedG43z8xB+U5HK_|uzava7-kf_NzxN4JBE;KB<_1u&3i%k< zw)Yh0w5WEVS4xuA1s9yN4#F7@E@$@71UG`FJTI{E3*OWeTG;}R&I>vmymc>5^AEH! z{ZjhoKltmf_+a7+MVbR7>E#rYmSjSY5)9S{6$GaWi}MiJC{582hT+}h&J>jjl<|st ziK_!JQn}FW?Y89wyl~!@RkrGnn$?MjIWL%*qsPKA{82l$>2zD6{{QgqC9zA2-d*<8zq^jU59GBhS`X-?GC?5a3r& zjw*lH1yF)Op>xc_MY3PV?mrhBIw(}{#pydiWE!FYw;VN z*NRu+1oyYP25}qVOt#V7zZ!Q}Er|f(;X+M1wmZDKY>k=&0%zilQf?vGisQxEHtvct zgx^Fv)WP^ME?Pe*SuWI^Z%J5R03U~^_Ox})>B$<0kSkrI%pk413gaX24Cnpq8YU~J z=3pd`oqfZ@5I3mam)rUHDeXbUI3F4C(KU}Y&5b+EkgB8RI!>CZ18KF#gC$;YB#3Yq z5y{vmDvLy+PEwRP?`mMB;_g?dv4P7;zUo4IJvU8R|6ME90B5Z#wY~#eSkSsUZQ7eF z?mN9$dVTvXuXwH3#;X)*ug!AIz01bkq=|U8R1B%pvBIhcLoU`Jq|5UEp9_Ztpk^~` z0+BZ^iiI@ld@xnq21{2@P)6;?@*Z%w?KY*S<4DaatM#pT&SHebUb1o z?UZ=()wTHM=xti2r=P&-W`8i7(cuZ5ySmX#6VBM!@`a0fI0BgaBwVw>ZDpFBhHJv# z46?ZHlfG|q|9L-mxIK3lNNR~e`FNYhMtO%jeD@|SzQFGWIbxy1EQoe;=7+MAI-nnM z3m8aTzLe{rPjVP|H&S3Wh6(xpSs}@Z}pRC#oM_Ei0t_V?(WF_iS*1B{(n*y z)wMo~`y0!juY5K&GnT3?U%L3M6>1AkeDqibb zCtgnlEpkDKI11SfV!}nPiMGV&rywKe+FV6nK~tf-c^RmM(5JeJ0!Y)J2dNPNs0A&t zw*2KvqEdN&oGeQ@JcCVR?=!v4A}j|bXjqnTK^0&3|?symo~(_q$}szoZ9*}aLkJK=Ukg^MDeYz_peQO zBq{vH*T%i#lH`dgqet}$<=UQ3KtZ1aMG#8%L?{u25&UC>ayGLWW$QhVj>lYs3tjpo zyvz_51vG8vnhusZ@00|~2rz^OgI`RQGww)N>(xu#n5)6)$M2Y^ovvrBUf^uBW9C@V zi8Ujk^Q~Qt%q@ld3x>Tr2uU4?{3$8h-COF|BUqGtv!iYt5#>mT?}4sV#?)+0`mQ3W z8$buh-`*yAq7`louii!K!sV`TXuIZW40?bb={%URlHDvlfS8`@ByBr!{4sOYTm2j( zGF}kCChLjsD*b~lLIv)rv;;;1JC7a%zegNl2musgX?oxalwQh9Xy|`jc2g`%->?HT zB1^mxKh;{h5Qn929&dGbsOd$40w3v(_AYF-uYKE* z>&ZVW>f7nk*N0)Ib(FVa{cz|S$>{sx1UsZ&aIobOghxMof%=+C20*F89J7_Y2{4bk zOM9UfoQ~}`mWDSf{8LX>KH%>+7vUFn3ZUWAHpmPBJI*bReK^pVGV|G-q8N(d(hl++ z1%keY!|`hOIN8Es1TOTZ-9mL^{!EL;Q0bDCdd81;SDj>-DUvAGUOERZe{q5dz zelI`Hu|9J|k@WA3fF)WNR0&+18LU6b`KnnSHW)d& zDx6N(6X(nf!WuSqYN8b$+hcS{FkaNrYY1HPhgdv_lyTvngrpHIVusC`Ss`d8g*fKBD)4X{Xm1FdaL(6HWax)R`l9_}v;=Bn9T0ChN z+*5?!je~g1?eT`MF!jJ`fd_xKGbs@4B%2#tq}Pw0KMKYRz|Ta+ zs}ZPBd5TArUf)eOr`p1q*N+3y=nun`QHO|e?vvnG1n6Y!fMDN@krghT_v8I^(ra;_ zLJ*nql8<-Ks-{W>HWk%jQ-OqhE7qD>y)g*`D(!QiI$JYWf%r&g7xw)>7%ZGwigz&8vWy%%vN zZXUnb)&ZM7q{ZJKGC#Iq_ri<`tBL>cE_Uhcm*;P$MRMwfI=a)GuI+pYBosUX z(@@_mvsl30sf}`|1;@kW)LmiGQTFu6k)uNgSsqTe`rbLcBQT*G>QtjN*(Prj3Y}%T z0hgWAkLO9}i}refCUXQzrs0_+HTO)+sb~!M5;Hh#7!!<+kU}CX+S0}ecQ1bl9NsXo zo)wOa_Nh#NyHl#2pm9v|KfKlbbf%P2(wBi4&Jd*J&jNQTDZR-dh6;ELShI>I!msuC z)M_PA-tj4nOZk1zYNgT#;G2)|R!&#I%#8>EmNyTe4%DaT+el7zalB{P4}1!5ofm)@ zubxcoIhpVL+FEk_`&p*`aeJZV$I>R4tUg)qbid+b)x|e3?EICe)Tuk2T&2b zFGN^pfEnx)*%=R>wg25|oa^z6ys6G@Gkd*c^(ASO2zv5&HIl041_+MQER3_Y73a%v zy}7h4flTJM^JygllbPG=L1jphUx6c@FahTFc58eNJ+Hz6*uUU+=HxtJDnJ=MK9Bwx zbND3(qIq{IQ;K)o1n51&kg;r*1#UwH4Fx#3yQy>EwK z#$u(@;)Ps&AqeaBBRX+?=d*FgJ_qe}iWJhlFK;3^c zcG+30&Lg)HPtGCaA`s6hG0SFog+%#-2qi2Yjm4w}CYIwqbCPex@q@?=>+EyJ7#-{z z#(!AGFOF#xd#}O-@h8`2cJI_3uLe~PugP1hixY~kHwEty@8pECr`FRSEb2gdpMpj8 z-5z02TIJ>;m^D7WVblBQ_FX(8(bH_>gqzSiWg1>> z5cypq`LmRja(VOBvF9bw5tJr;UR|H?FG4yJ;c)|1epe6w(Rle#X z|M#Ow<=a|{WF4ABd@5l^Opk2Ff$$f3rk4h*iMkf7vx?`zJQa- zz81_%vM<7=lYJ2`k?dTuB&JhuREI8KheFf9EZmll-&!{}181qn!)V>jGS%G4#e zN%Q}1e;9i7X{{Ex&$BAbAwhSBbA8xkG(%P|1X-U=4(p+`IQE;Ul{U_ORU)E!X6bN^1jeR|vw$pzqU zaRlh3dp@U`zl?tnE@GBabU(2)^NI3ihC}#V>MJOx_S1!Cv1KCI9ioPZ)B-ivcrr_1 z8EOYZMxH3%SAKj@@xDN72+FkP?DdQg!i24It^FdXw5lZnD`9i4CS|~eTj}#hz)szF zA!KiwP|ZK@)I~@ZLGlPv1ju{XMZi!UDpq~$yj3U@Hr|-gWSRVRB992R$?cCytxlV7 zRC+JMl-Y0+yIv>O3)S zA2@o@1;ed3G@@jk+4CPqORD(Go z2wc(`RYa2mbcGH>!X#c%zwKgat1KEq74s!3g6LOJ5=muCf+MVod)OwL?c%Lt%s%SC zRZDcUa=axhm2SQfBIn%_@ix<)>7QB#L**5P+2Zd;5LsHa8G+R+mbBf)gtqzEXc>v1 zOgkG9lt;_Bp|Y^vl9Q&E(0H+7X$Ww`MdX8Z=V_NiDGWp0d~DYUVMD?u#C`;;9g#uq z=+)kk08)~1v)Qb+&h!JPcXbdqRBlnN_GVLA3g1wzMf1&_72})DKKf<$k=VK{-X{`L zjvF}=Us;^2NOQyuNj5d6Y(*Jh?AG$8O}0x4DYLFD%51(3LUKV|MdF*>$nH&^rCR0Y z=xEZ-*Yt=DNjfzXl69WWUGOJj;%Vo*Xzxh&d2Z1WoA>AMwo9%ll99MM=~lBH^Ebjpz6hubk>_WZuUF7G_xbD zwbc}*k!Vp8J&TRAtkS&Kv@gZhs)1EnsSszCq)Aj2n~{5~>}K1|3!gq0eHD_z%6_Uuxfn)mwUE`wojJj|b&KUBNhDxn0htBl7Eo9~X#tf5Y%RbJ zEc;m5&pg|#V(d_}d}ZtN%#JxLS4^=yGh;1C(3>Z90H+}fx1}VHB8%+af`%vJTM$<0 zch4%jHsULLnS#KCuqsGc#lv($$mIYLp?=PbAg|i+7YYAZ;mja8c#=rwol_iG9m0a~ zH$}+B_MRGUsRDQqQrRUkqu3=zwvS;XbbPc4LQ)egelb9j89tqmeA$fHtzKJSfbWiyl{31fl!Ze~i-jROMk?D|WZ;}5Es zXi2)60qkC$m~tO?Q&}#i7p2;mhp~RI$YRnlh~|ip0Tq6{Rwoymmg%B$4sAG!UQZHe zVraX#GlAX53%C7vxp3P}{GbxSc^X`#!R6{|aDyuGN(13rOLdazko@uw77WF*98=4+ z+x(KyNn#mVAR$G;tft4;ySJ-e@s8cg-P(V|G`rutJcj!$y^j0zcI^`kVqd1s?C4Wl zdk7LIDY4c2-D`fi-)lCfID7T6F)EOEdH8U(@J){w_ka5nZU@H{F|6 zz;wrzj%O1w(TLre>?R_Z3emfGpnFy4DS4Yvk@li~M?M3-TGjETh|Qx%aQ+jzn@Z`# zGHxHS`{=uDfd_5AA}u7I0b|YkBn0Jg&Ikk-`T;0{O0`W|L9NJ6H@SR603x>7DuAl?BqnB7;5z%G=qJn(}Jni6DBr9Ey zvnpS7SEcHlGj5wCTUKP`G%C#x*BIPZ&ug7l*gRjGwnD+wJ=5?LO3+NQZf~+r+!H%i^=Q{n(_oV9T;I0_SOPavyipw|j)@Th<=TkLBC+=6HnAA*@j!`t zsZs4!ReFIT9%VjMDU!0tELw&tPjyn5RkVpK-$k7pz9!mm6t;Y*fWRza0*JT4*d$la zN)S@m;C2L-CZOMH@*57*rhFCR)uw!pfvvWw-gl|N@6B5yfNef>s!0TG>;y>5H1j0C znFW@K!LXw&%z!J$A!?VHBBS<+U%jXER6ujmNU04QZ?D#t6wv&gTtrk{q>dpmWb+MT#DO@>xQHt!mg#+cGu#DKShCgCDfWrA zAiZ^E-#E@(PM@R9^<+!o+6ZT`Osz{*r8D|<5=CE+wl6yuc5nKDwU233=+e@7IQSq_TJsn%wGP8hO{8T(?LsT>k zI^KFvbh>4a+qF>|LN;I9n@4p3qhl&YkaG-!r~8~XNj+#pXl|RSYpJODPOu=0q{S6| z6?P0omLMFf_BzbrHDY&faiz zhsZ7vG%L~PKGA297!uQ%X_y+3Gmg}+iv+9`HL>yUO1}?A&^89`Og1wk?l|xx{bXyy zK_6lMWnxF%WtW2G>0VVp?S{iXm9L6DryN`!PWNkK~+L(ca1*$Uh zxJ`AI*gPV0`+-W(ok4BQw^et1yC0=EKun%`&+8v;LkkyW7ymX(tU{S>QXR?_=38^G zrpPR5h%HyYSFK_s(Y{AR+NH+1Hxy z+yyoiB%~DXh180+K01rfhWcD7%`lpl$X%8gx=Qoemw6Djl6uA{9h?VOT7OHIv1rfJ~r)p;uSsRL;m zRjY@*iq?RbnVNi+n+a)bkr*^_IDd33wp#NqrN+4ZP<{Pku3JUe$^J5H`S6Xs@y2cV zYChS=>gn5VUvNmp6F3H1uH|+guO(mW@&bw6fPf;V<+A@FU}FK9__6T(Z7LVf6Pj)< zU?+ZS7pEdcM~|oYNWe;+nmf&N!dHvRI4L)Lv6Wl_;syqm7+IMZS!ox`g`=uQd20BK zD-!MXvS#cS6^Ts{mL*_$49h=E-bI9Mbdopoi%chIvjy3?RUWc+L_L(IFrU1J%h(lV zfm@k)=-rNZm-@**p00cP84?g`^d`{{Ys_oI4W`-b$5RvuSeY13m4Iy%zuhHZ`xsW* z=^3=0iTZGv>y0(PEnX2tnV3kqO)O^hp}P^Oq!3%Y#Fl!1w(BZh5W2_I5jePEmYk`A{~s7aQ&98WKZC~&^et+%bZTO?fydltdA2%k<#)kN)A#67BgVu~6X$zf# zA8B*?O5H@qNDf3@eaDkL@Kgdxgwn zF`ChpXB-EW@q=#{-y#=@oE$tCk#`VLyLe@zHv5QnNKq7zX|pWPQhS-_W_>vGhx!1q zcQNyPYs(p=)JeX$(0vh~H?S-LON@gUx`AzV!NBO0@@1k-S*q5hh*yuqftYU@Q9Qda znNt1M(JJT>vKUdPmz|fc+yO$ib{Kpc!Y5l}4Cl$$_)7ks>c!vHJXaR}MAHG+oJV zlm#Vn3Cj+P6^_VHdX11^R22oGW&F@r(XS9KK9MuF+?k~`5xE4+KVq(x6LauuCi-yZ zqWhwpB)Gq0rH%0I&pC!~|DVF=(q2 zQ<{_;M|!q;=DB`&kHDAPaBQ#uG=@9CChI#Cl~s)&>#Wkqcs>!zN2$d%@ou696` z;hlM9xd30>g#{=qJ1ahN3n59R;}vKA|jPv-LT;Q{WdTmEg<|rw=tRTqBEn`VHXWOxj{fEp*XBTN7SqA(FE^1i3+LwJ9Ehb-9=#0()W)sX=?ED*C>r>A2+_ zsG^#t(51K8#%R}DGGu%zdW2K~@~(5*BX*nk<}T`EuWx-Mj)SmN5BcWBEzy>PvGgyt zb|1H##?2AFWuRL=C=PFDRwTYc6fQ?!R1svGUU5C^B3dKP^l3&>CRV3R>@!;ha{e5W zEIz>z2x4gZ2c@yB6f&AFm>Z{8BGGFBCf#FZC6FS$796iGl$UZB4MT zJy8iS+49DXU%1A(C}UsrydVnLNh>)13z#i$31WdF54k)>6cn4RIuwGyQRR$ z0vxXG(HG&7BlvE{=Wj<$f9EQ$4AX@(upH|CI1ZmP2l=&!;l6#lh2+)Yepw22{3q5g{HK~@8?dSm)eQ>UbF915gUfpSx$ZgW z`&eHOe%mxNKGq{waqns-STG%q^@*#rE#>VHh>xyYD(7-{Nfegl_T_=X0v(6uKE9p) zzV4cy3&RX^)7LE_XZSW2Lg7Hz%Ru0Je{4JTeDOHe?Rg=HMOHsZsXftMlX*XWYG4I_ zI}BK3ZfRc6^@R{es-+fI=`^-ys%r9|4X?xLe*6*zaKqX+^_eQe^6p`6oHtm|noHYU zDK7+paXow*Mi0Jn21lLAwmCKZZ7oAaBN*E=nke!1$hvK zvDiytD60PS&&^eUMU6s&f3{ptNYm42>#EI!qX2u$1*v~;u9ucU^V{+SADh;FPkF7} zkKX+Wdk;tcbG)~{w$We1l^vWdSOqb{KjDd4FhDs|)2N+1H^X>tkZX}EtsdWguPJda z9@JhOslYPMd`9(gJ&*OVG3)?f{pszyZ62Be5PoiaghBMSZL~h_f30pP&kTnY7Z|!L9Rw~n89Tj zrv|uT<7%_1dm*rJ1X?(ad~@Z^OpE?& zxJt4@*o1WRcB@?tGA|*1&s@yO>iF!F2D9csOq*Y;h zv%S&)^eW%Gjuu~%$MrtmE=CjpY^41#E$8}vzQi=s*Vmg_>b`lrU6FPklah1@zqzM$ znn(6PGq;6iG~a(`PV}y+3Yz=Y{tWcPe4q44hWabg9)@vIdjy>i6SAe>up%lmOgVKp zl%x`<9R=vsO#4>PGcCA0Ft_^NSsJ?W#Pu57TAb2v7g)K!Vc~P=E0%({ZYg2T*DTG0 zsOC0+D(=du3chk1u(Ba;)o)`xUrr1s9!@1HsHNh`KSw%m!36lynRu!Kdh3J`0!>N`>R7lBGo63f=ApBjJk9?AUj|J7H1Y*8z*gI4G9)$4b??rOXUx;w9{P4R zYV&jl700t{vHwfOda3tFIk*(27YZ8;c4|&$6f<({-{4Ot?`1`L#E~83HC!yN{@agx z*Vb^V>fgRV696LoQpCL!NcZAfbF=%AOEjyYQu$}~p-26!d$Yrj25ZH~XBOZS9T+WQ z9xysKV}xq8P)DqL6FAbnsodv6%xiOcwB3mnYR{{n!ST{K^~w{{V;Fsv)<8W}ClMJ! z^VWmOKlF7M>ngGk;-1r=_;b9=xmuD~*mt>h*kEhcbC&>TK$yRCteZFj2+Pt+Ib(Bk zQV~`Jj__`K+s9wvFf`Ri2tIRJY1%sac&JE)je_EWs9b5F2q_X@E)!o)tP4k#sCK-- zQc59olW3VITUO$*zX!gEsIBG$2-;cc0Khy;0h3?L6SL=l|I`RL2b!8jzD<2?m;Bm3 z1uZsmW#4UAbQ7(1(IrI-yU6hxY91W0p&S)f_!sZ`t3L3IKSe9{uKO?JEh~!{R2rpf zmvfw#I{31&7XCaAsfqA#9U?WlGINNUjt_Saq(L}vtW6iXj#~?2#Wym(OBi&_$c{uKr~A`pL`d8*OtdJ*vx)4nl>jyUE6&< zi=y~-fA+bl?x3>UTI^mpmaWkh3TnwIGp$%fU3G4T+VDKi=&c?2nx z4kKn=l~LO&f@~wmE`sb0gaa*=-w2?bOhOrG2+oYX!r3FdX@rnMclfTcWz<7uA^ArH z$sh!uV6?{kF0()L~bKAJ3P!}6nack~2QjL~Kjgbzd_}Di3 z*e#sV^KDx8Ph{4C{htH=Si|hh>;uFd1AXc@3q*nYQM7E6kALqkUt>f zH|lvC&w=b&HTqrLYNvUE-x$vJOd}Hm;B?QTU19z%&EJ*zd$0fAWae+v|HkxBEW{Ix zJA|P@_Mv}}=>c*0kas)23lBVEGkYzAz+8Ch-<8V}v4!^IBdE0Ab_7<+JB*NB>SOz; zyUm9gviZqo6aPfd<6Zi?5uL}sD6Bgcfn@@i#h=Tv7?j)T`ZuD>K;AX3j60|ji|E2M zwFu>;;h}4@KpdtVsVt1mEk&rcB91Hvo2OPKuc8^av{ZU5`vkD~h<;qet6XG>?-tQI z7tw%=6g|3Jd3qq*RLfmFl+-LZF`fZ#%vB4*Vsa@{6jJH_?$rGOq_4Y) zF7TLFL2ch+&02J=t^14YiqB95hHgO;6Q}-uXX?BQlMbupJy5V zEgI$seM~?U7qj^z9^9pq|2YnSyANbb*VB?r8x?H~$(iXE26XFiE_`#D0uWk!QGko? z?c!oW{CogJCqirN@W+({-u^sv&7|YD9m!AA{d${S(D((`W^Q>tKmY`D++Yu^paoS-jtqkdwBaIf1bvnGjJPIPBa}Fu`A2CL#<}I$#(n zeelD4@$SMj>C|B&-u_%)c|M$AGfGof-_Q4nJ}n9JJ`SU!6Z$%|VqR7sU7tVSruyJ! z=<4;<-wg>1kpQRu9*Ex<-tz4@Ay4t?!ly16pw^yyiji;o5G4K|$IJc6MS2c(ccidP ze9yGguM^@F{E)4D{scu+EjPaq<`j4NV{(>|R z&Y>QAm^F#UxBqI1btBY$*&i?fj&4Ekx7Pi9Y@!gZgF2yq#;{w4J|%@5KqvTa)wTiX zqD0aGZ$o_~WH<<_IPlWQA6v1Hh&W9y&e@$zfZlNmB>YGFLXKK6kCTU-pkXw!9wFgE zXtVm+X-UPa*0Slo`ub*)Ibfvo+YRrSSho5CZA%RF+ik2}sG25B3X$e`Ymvp9;XJ7S znE&Ao4jWa}LDXlrT&*j1iE#H&;J@kvEDJ*&P*1J<2AznwnJ?3OXs!*l{cBq@K{b6Z z)I2q_-Ub1_{~gB}1dR@3iD^}WYyenap9YR`6eJB>+Hf?v7EuQm)Gqd6d~oR2e-SA& z@Tp0+0scr4mdC#g{eVHL3;5f`ID3!Sp#=^Th|#5wKeXon{MQB*lFq>~9M_NsKt~SE z^G5?<6!fR_s2=bIhTmh&L0Td^bz@TD3%c6kf0dQft^gZh;V}rvKb#m z;oG3`7|Y4SKykW;uMIRLsmdQP;_ghmNSGu5zL7xx_x5=4TRZgkNgwus15^d%?p~@1 zrH`RtWlmO~I}DE#u@Ke%0m@O(NQ9DxC_V<3@(2Sbjo;u&Sui{ur}@wNkLJNuJP-JO zMJPT_EdaP@+pLr!h*diAmiGYPni}&M@_-fk0t8@zpxZi`97YT$vs*+x#yLE zw|WTz>i?w7iUq>NBefy`&vJl^FNQH=OUmn#)bdcD2Zr;<+&z$RlpJ6ed!~0SYN(iI z#LO44@Rn#kd!Ox5ezIT5E&s7TUg+JU_vokx(O49lpW4dBJ;Hnk{WE%&+MHZOx@Wln zyz#zJV7375o9Ew4XS3IW5jWHk3>`*G>)$4vxO+?K zD*$nm@Zv|Ye)-^JQDzKf5ZY@tiZ>vxzt4)!F72@NPls_ckhKVp#5%Nrk0 zyTOM48M}&Ww9()i0{;Yq- zOadRQOIulx5?FlXD>v)X4ZXhA0?_?>xa)Vb$KiS#!PI3_Mt{^xd^uMjdjG<8Aqpn1 zcj-76+zI$}J106HZnR#Cau%rf+_3(L_*@HuRELqBuijij2LsDsp%E3%o?%btLI@kU^NJ2js$q?2G`&VhX#zU57dN@p_Ghd1$z*(?{xbD{BhcP+qY z_Y5%TV?Y+2^lR9@xEFcs$7VVMGTpZVpbebSiY16Z!$_$@6?6%T9Iw>2v_O2LS)}8d2ybDBMZ|1jUU{ zi`O?#^<%PrwvdWOSmCGUZ5rqjS?K%#eYDY)#i;=m^ggN^AAo()f=PjRYNQii%FqSl z-khp&2$dn|yr17=7{uzknlORU2t`a;mcJ;Ti1qplO`$Z|E_OSv??bEWI47 z0T9ke#JB{Oqfj3&aIb!st>a#x@^w_Pjw;tt)jDdsj@qrG_6F4-YZKW7uoEec&V2$9 z77LQ(?tRyAFoQY8_mOoHW%&S@dyzc+;amXPiu1X+#A#k&fiX&tJE1&HIc5cVe;0K! z%|17hl5&I*?&EN~x1fN%!Z9WDJ19{L%H>5183VNwZfSJJ9BB!rM@V-{es4mz9lrvU znjEJYa~6}Fo+6JT_h{W0=8Ai5c&8}#OMB9DxqQqYK9 zwlllv{-(l{PqR3prAHbdLDw^kq#?$fGAkO)!Y~b1y=gk7-MCapPAiAMy_+$NKwm!l(F+2+=pSq2nQuDxTcz?fR}A%F7h%}_D;(*bZ(95sUxmr) zGwsC$a=o>qk#bG%RkHBw{tOeU``FnG7+69(soM?rDKIv}B>#2?J~T%`VvW3qaCSBV zd*VOg5)l=#SP5??z8f^3o2KTcx*L5L`vltKWMd~5poy|sU`gY#c_cK*<|i0b<8R9i z^Q6KzKVjwk3I}Bd!555~GSrXotGf+%$|M3^SW2K;Od%8Gj6W0$*b}W_Z=WE~p5X^l z(YyToFtg8kyoRfG)#>Q`x^iTrhyqY>5zwSK$7e{hrMHt2X?OD{u(O|}M{l9x16B;~En$LX>{psG-N6XGY>MWUweD)_dVNlgM z|DjoYLgutOK&3h0u|6b0XlH<2 z?vfhCr9X$C^ohk6@h&gxKk?*#awvefslM*_i#%2+Ay0}mP)@;tBf1D%CUrHf-yznUy?>-1~0UZ$fd!u6sF?KE>P;7?ECv+AYNbqH>PL_QPMrbZ zj2;gicz0%BDco`gPXb$1M24T0_vwsE_SRQuZwVj|GEyXMj1BH2* zO#{?3p^;p?6GZq%V9s+FIe;*;JJz~CxRh_gOi^(`a54t`qm{Qd9#Jk}4#+^LD0 zzaJtf4XvTr;bx6*1i7n7J?dNY@Yb22f#K#}`D(5=_>B5qnY#>6pMqWAb6oDH=-%-H zLI`xIS@~=M7%Rw_&fm-SAO;ZJXiqDGf^1PVbsxLKUAq}C7{q$4d;50ja-U(eAsS4s zzYjX~Lq>}3TSbis8g2vcxU?5qIFYyI()?&*761bRH0-`VQ4$fZM}mZAe6S~#hR#?| zF^&y5xH!V@lo84YfOvrgaM*KZ6Y?CiD7$F@;X+6Z5YY+CqIJf)G2GE{#o=t~Y!W<4 zU+q*MhzAEjz(p7Qx~Ofqf0+oyAFO+t=XzdUbZ3;?87(~6_c8wCR}FsIL-wl%`L7xj zziLqaszLRu2HRgX*!`lxuV(qHN&ae%znbE&X85ZK{%U@Ei@(s%C-4pK;k zg2AcSU(UH?98Cb9YH<~#?X<6Y?CURMVt-+U(}~|C{FBdVB`*`cob~7B?w7l*_QbJ3 z{62=OJgg)0)P~6y`#AO6q%RJR(e(7yP<)2c1_2m(?Z27_y55u-@de6TeH#X|#BGP~ z(|Vl_;|t~Fc;P5AXH0ytSWu65zMQaAt}%dq)$Pf66##l&bVJ4Zzk0l>m!eQQ;=g)w z3Om8s)o;hHIa!?nz-XY@#{dAn4zcwNVDUMrZ*}!CeyQ8l?t;tfE?q6IKS&pO_YRq^ zrg~5EDUkCf2?2~Cp>QbGe8IWffAQ6?wC~9yvJ?VOjJmW&s56d&lHB3L8T}j=Kv<$Q zQaKRrCNI=8EP7YF`vh%qde=0x5w9DiR?yWPudD3Cr5s=H50lfa;mFoq6v@%mk3Np{ z>s&mUi7o4z`ZL_S`Q5`X{~^9eW%fij;xdx}Al@)!(^Y%mUYet33IO>uas@zGJtb20 zI)6XlE;qY|&q3fE0Qw_&F##&~@KiTaUBe-G;xGcNQ7#}ZGW9ULq-%bro(F?rdTP}D zYu94gn5Q(I)N^Xa2UoTuMqo_g7F{!M_1$A6j><%L<|uk#4bK->^_u+t&|k7&6+wiI?9% z3~!9IwK|~aPQ=G)Jp!>K$N{6ScSB82zSN0?uevKNr&R##KE;nXyWtdNn(2mftru4; z{cxoye|5jmEl;u&`78i&miMvVZd(xX)z|`FiEikCV3)Vj9Y< zzP5X^f5wGec28L2P;JUh_Sv5j7m5JxWu0p;r2!sds)*Lb(RvQ#(r~O}jrajRTn~}- zA+X3wU~q<;irSg-%I#X8`+=Xz1y9_yM!!(}TI1@-f_<>T%XO{4Y8P}I*z2|KnDgif zb+freywv~t^(3`lolYhL zqyfnJj=V6C87BxGJyv9}JgKrfK7R6C0^;c$i(#hIak$SEGG^u)c{3a|4(7OGorcb2 z^}52Y560I8EYmp&C7Zr-g-2l){_gho=|zszssZk=s3a-=;hyDs;y!}5?TQ=fnumb7 zk4^MW`+EGW$IeC6xCgG+V{mZ`vO2Qa=4CcQ`qS9N?DC4Q?RxJwR4Q+xK!cpRHr>ei;JicF(@Nu*Vi-SyI6;^=eQ@+J2(XDSzpi8) zDl-va&1sn0I_2Tg$Sgv(ag^)=ZENcQyf!|GKwvk`3~Mw-?~t$fUSe^niZOYQzQJZM zvH44EVX#YGQD+Egf>=;aJyA!sW%?7O9WEbDOBoPjP+dYTUBJ_2sJltIi4pE!GYaxq zu0ZU5EWm_+z`w3{R^tMg>(d-Fn3l^?1~KRFBZJb3MCV?Mi-us{d__A9JiCn^A}vt( z?{;govkDQzeQZ^b@O3zXKGG%_-_WuAFqfcL5VmZYDY?X!pi@fV??ye-xazwa!S9FO zS3nK65UF?Czttd)Yfwu?(OcH8gLrUvYC=X2?uu6M^kGb-1G)~=-|nncD9zrdeNm2irE_5m zFV=)RZ9z;4;QS`CAli>%ea4Y*+=JEUF)poy2*D*>V{cLj>~7&CjUYTMO&|q?4`?CT z+dV?vKm<)3&|tdSL0^lz0>;`1x`Jr{np~~@ zIoEr42uTGLTe(M>EwA_S#5SggnWGZMUg%2wb}3|Rb^N7b@o)A0w~15|VXpQdqwk~@ zvT_0%cQbsohGP5rCf)!CRt9h&^HjWc4-_I|Af>U}*Z1$i9_+pWLvs2`q`3?)T-*>F z*vB|zrw6gqPh=$ozKZE2)jJ}8;xL%-l>FY~jS`SX{2fst_n*{V7R)e3iK7uNp6kga z1&ohem$7n)2%aTotq7cI4jT#QJOq_s160XJX~F@}>ev8s=9@C5dVkZc#J!}ytrsupq)#uDTKBXH zpyVSYAj2W{jt)nsLInXaxcbQYU<{|$By$?hsWbz_r8O!jLPU7Er#nYB;?n?G;n^72 zJGQq_)%xV1K1j@;4UpB*13e+XJqKK1!hfhN)}gyj{9Ht+DMQzDLKShGhiiSQ?U94S zb2i}&!}WGKUm6!B6vhGDe2)C2urtRYb}+)h#15r*X1HBiYfV5u_2VnHW*vhGGTZqv zy>c@6Yby7++kZ&pzi{dlXsBl_7hb#TTc>=;55K=xc&gryc-76E}YYKm>vx|vV;~g%nTDgTTEf{uqOM$(t<2!!{^;Ji5zN! z`=&Wkk0y?M9DYWRSBF2Cg@`M>+wwM}S1&qm_zJ@T{q3~2LIme!Z3ZC2q`M(AOR3

dfFIU7#;v1&p;`~mMsDT+G-RpGkx0+RMA2NnK(?`vXJgRZ_57+b<=D`LI z-Ii?YlT9awV4a=tRzHA2KeSr_0FEmP4eM4vC*#+-HFbDQ=Qa2nI-X>%82>T%K-}p< zH%!to^c-=b$l%tnTV7gYw}!85Lyy%P^2 zu(!o;5iB|H#sGx7NONi;=mliQ%HRk9ep!3(W^->WfJlACtViu~7BGt(k`T z9;cGI;cHHMBS4SULtvJdt)fk+7qBT-8;PO;dXU6?`n|5 z@c*?sSW8B=n|3oHaEK@0*2MdQf98ha9uKm@g4n&aO9d!>2HJBo^l2y9t^G;_HQrzh zv$<4ND#+3x^eAjE!hC_Q91DKnBbZ3Qh)jSUzxw8BLjHWFi5Q>#0-yf^U;F}J{sLe9 z0>AwQerNH*rgS2Afx1iK0G!Tr7ZH@(TRXb|Y^CS}e4awCWOp>%pIxI;;4RPb!h3Uf z_(3EMG6qX}MuO0HP??gzNdkv*QRaRK4&nVyP6hOs@TysprNe*M`f-ayxHQ(v>0eJq0!s1O#X~_oF>j z?BNnR4S+gX8p4Nypw;~5GxZv8>`kP7Q%1D#OA4Q-3#)TOk=0_{%k<(F*@d{ z94q+ZcDQ$ura8h_=S0SE5DFc?Q_`Y!te@AxCdIO8tY9xrd^@5g9uUSnjAjFsbo zS;mjq7^I3aHczl)`OP!gN@IIW+`t3$BKHyh_6m>RjL}h&dh_a|gr(?nO;esQ#JTkl zZbbw;-9u{`i1F_j{rk>9Hx6$ex8WneDH3v_HkTMIfZW8StV9Ic57OBM;9*Y0rjMonPoJQ$`0PJhK zNhj%|Quz2~;Sq>4wj)Fq#PZ5mK*-la-YN{}-(0||xX4_v;mMRA{^bSV+!ym4)nK2+a2lVNv+=5 z9tpBIzy}@7^9+EKTKqMP$QdE$-pQ`_pd6RG)_N z9v3Y1*PJ1N&%&%^3Z$h?vf^#PWHE*V$>fok>MjnV1mK)VCq0>z9@00s;QWCWhU2tU zZ>>9hE)cr37SH57J3{1r`g%r}B8YgfWDLHd$?-%P*W!S}ymme;w1Vq5m(oCN5+OeA zlbER<>zS|I`L^?2ac*dE=4%ykOFY&09<%JlM_|I&{=H?h6Bd)1f`>s9>2$o*_mBsI zrOtG0Ij~QevcM)pRsw^Vt=i+*=7LPtX#u`5M^WX|a;lWtI^lfl@fo$mIEx3wR3$?bwnf0AJd6MAef zl8-A%Mrd(#l)D44H~$Se>(*bVnH|7l5T2bl&lze`iHHjT_lf)xQ+uZSJh3iqrwV{- z4P$DrH`b=az17tI^eNutW&U)9xjNM#1Q;v@jOWD7`sDI7=s_ZsLvSPuTpVimFGGSu zf7YoP0K)g9Lir$ne>&Ga;e0}GwkQa?nnjS&E{U&HFbEYAV~epO;<7U71-Ftnfv~@N zA-~cp`){?%|68q!RI67}C)DB^-d-N%`mF|!GCB@cAw=?C*Q4Tq6+*29tPt|G4({5J z+NO`%f|?&K<)4g+u=6o#z65_@I>}=jzraEq91FVTlG+}^!3ZFCi$2?cy4K?} zFd8m#FV8?>d+(#;?9RwqFfOqu zJDJ|?S;L$AP}N(p@foYCBn0<7`UnIc>i|FyL@)?CwaW6E?uX8&(`AahH-Bg^$8h-_?UG*Jzdo`*govsj`MX68CFw zl7YTU=5W8>T*5DX`0xFJi$ECF{@x1{n{P{}M{P2QBg9p@4$YoNeCA?##`Ah@ba={K zcVA)aCFcQ5ckgPML&mS`Twk%^y;Q*vG!Av*0$XA-i@8pbWd_h&OP#ADIM=huX*Ac* zZcuOA2@;}dB`MFCnQY<=smhd@v993eQFmOPR-%`K9RcDT$~W+hb;WX9rPaUjJE!|?gGJPFNsA*(qUI>!f{ z&T}3fwGS9|e)J`|W~hV}z8_KmOF1|P-=>%ZKj(LzZTwrfe1dK>kM~h|TxU2t-ccZT z;SO6Zi)neKeZGTfN|8sxzE0{bGhQM`+C6dvo4EAe^A(ONIVGQy@ZHF}W;LFDFC7S# zLXth;I9kd1BktqD$F+&Q1-7g61Kbxe6n8jtl$KaoX`bmwLS<2Ub4#@7CS11Wrh> zR#mq6PNexKSTZGvKxONwJVJrlFDk?bA_BT-P+FxczU8yA~RFAqlC4xc$%%P7$-y@(X%kI3eu122| zv-o!%mG1^97xE0QB7zC!fL)xD#X}pKvgD`>Pz20NlMQgLfU5eu4_p-NX7Rg(@9Uj= z5S!KTP^b^k6Gd&RMe>J8Id6TQ3C~kQ=C}ve=W{A^I3@s2z0`ix_azmzJU;;YYS_vb zu;nR`$rSyl;jnejl>IGH**Yq>sAq2;L@l83GDfaq!lRd<>nm4* z|4PVK=6CD@cAQI;!>ZE*CEc4#q9C}-0(a4uVOtlljWb-JqLOg1_YmXmfrXubV5e?- z-^QcG=ZUei`{APP+%$M2tpdKlMJ8*fH6wg@h{qmYF|3<{Kbit`H%7H@y6dfRZFcrZU;5@|T zJP?tRt{7L@tJ*NW3^-?ek*+Z+Fh7|-o}g4%+55zs>a(P_=R~*O;y&u}gdtF==1!e04O|1Y+~(D(oM@2ZcFk4*jm0fppg2q-OJo2*=p zn4M`#ft|8GaLA(n?iEFBn3?-CgyjKG{C zQg&k(Dzjgvphp47Kg{Y=Frx&RMex35#3fcCFHHx9{X}6uQDm#rFLE>BqOd=XzE@em z)&h10P=2f~a=E_#r5#&om%Fl4uk6$-yN8vXU$wO@EcH}-^ONm|X|dhJ`?O6E{5H2e z6^ZiN2)B#AwNu~PVei%#a%Vrgvx~5^-`m+e-Pt|e?bD}m|B;^3eq#o_x7)k7gtgzs zOSs=9U}l3h9~&*R5tQlW39M=Jk&D*vZs@Xs;h39`D(-)4+w9^tducrbfVigwY_ux@ zL0JsSrKm+f8MoQluI7r7l&L zVxAj{+?K!EMmv)4BT(@n0;Zx*a2q@JVx78*JpP28Pod`{XF=T7px))?7crp`zg6o` zd{$43F=p%iS@ou}->&SpEBo!r$luM@?i8`*6qRl5 z=eC7hGC6i)&td~Dd)^RxmMwN_9|dOj5wF)SW?(yeEQyKxnMh2zii!7O7n9E}J{82( zH7FkA-fp4g@teKf!oAtT>|2Z+kdJaW6?B$6bcvVg62 zb0WwNGT+CRm)fI3?z}vEMPZk^uw%Bmeo++Bhzh%~g`Eaz`yq%iMM;IF;9{TNWfD|j zm(`+|c1EQo9xI&+D~t+jG8E<30_+}?mQbpX`1%3C_NZ9_R#*X6SOHd80ajF%bee>z zSKAf%Zk$Kk_xsQ+_0*eALFMBjraWG#A}=^Rc4T zl$$jTu_P13^kX)@+!R(FC^uycs$$Sq%}}i1W~b@ZYfry7{>PE3Qf86zTV|UTrLWBL zRg5{Br4=z{wq5%zdwrT{C7t)j%!rX$G?&~kjFOESwH5hRcWSUZ&94QSqW6j^dyB0%r1YSHR`qb7khKf3b5EdD$7lxS(f|)BXi-G z#=nczGgrpvpseB%Y!f3eoUbfndMELuzgTVJW_b)MR+NddD%Z~Bu3X)_`hcu+omhDz=qXY%A+_sjQ}3SxvXHnr>wk z+se9NDy!0N?GfABBeu22V{1>t&YtYuN7Ta1WtW++>_0a4jt+ocl~KTrjdey~mICIf zpBE`$nF4N2IIhyBU7!&*+^LUi+}=kenGVd?vZM{NCmyi&2O>0=QCtWplIxPEc8qw+KdB-Y?>fv8Tcu`2yu|>E4;zr} z#pJ%&tS!A-kHX9{+9F~%7FeaJhOsZ`jaiX+R4I_`flTBa$jYJTuBMDR9zLX&uNufiYj#^ ztK{CNCcaOs)i!lHx2bL1#++mG@v(^`RuEjpRc;Wx6<0+%UIwu{S|fWEhbGyFSeyvCNiJwsL=i~{ zEt8u<3_Thev2+znDk5#5L2g$*PY;IJcho62Qf4D862#DUstK z8s988lOb(S^%{3lnfN1wZIj!TpzC7y-}u7b42u|Z)EBFfo?7KEGTTM2+eRNH#w>ra z)r&=pH2PhY9vd-9(Os3jnz$viD!1n<{z4(vJWPxz(UKC`v+`_K=>tFvlzWXFI2#WA zh<--?((u)Hv2fcgbuPF0E6%Ykez9HK^s(MRR184`zU_(-7-CeZM3vX(qtT_M_s+AI>_ZXUyNWI z5%6|xOjR_$?e-M~5CcziwExB6cbi`|SfS!wM8*ECgdEszg`kPKtCi-U4C&wAr`<>cUhpZ4*CN8&Vakp$ObSn;&@_R-W8%0Pg-m@Rr14Ioh1N4yC^>bLictQoH<+6Rud0@5Wfje9>uBF>&}5_ zc32#ZCWpmIw9T)})L4lb3sz7NFAi^g5VSLUmVa2lCi)R^Tn)-&P!W|ASDeWOsmbYa zK!N!BHZ`?;ms&0kAP7G$qP>yN$FM;>w?fmP{c@r7FSD)OL=sb?2fO0MUQ&oa5a$gU z`9n|d(cH_8ehZ7BOg)%O{3d@nP_MjeXqj62(oTvU-R=z9_QcL@-^8p}2UL}IYy2s! zA{~}v)Wndg|BtkHTXJ2;5k!9_qn^Dcth+RCU6<}UUDxQUiH>=cZKbW1vgDHF^vXR^ z|2~8Ok^o41)j8*3$6g826bXX35d^gPKp|=RGu1w}!YYaOP5CQ){`o3AyHy^t!=0qQ zs?Zes6dOWqk2Hs89hv*{0i^(wXSI%QSe?Gy9GI^5z~Q(fIK z42#3i>;q;*4T3*Q#!5kR9ElV%#vhOZu+pcGLczpOQB+7!#(o%lKFz})6v3BB>3%jw z9sZyZKk&$)NHMp#ok(Z{?TOgF2%;%$%w^F8h+Lys4$27ql_CZ#gLP2N15dD=4O2Lo z_yNfSiJ&}*0Rss~A!HJihv=Y|B0?poiL-nb97}mfLKL?w2SK5rkW-|=kBe}+MGV=6 zBnEa^DU>U;eiWj~p(3YQhBGR|8I`dam4(_?IZCDJ3;DVpqSN zg{nM@|15uwR)w6r5M3ogkQ3`~*5Vg>yoTd-M~P7+G219#{wWIc<-O7uGb^%SUy3~1 zUwt|vP%C;ENi$U+Y9c=dM>LfJStU0wp3Nf7AhHuWdI>FpjYg{ zS3~-*A_w2Tm}Ib;;)q2=j)A5zXcENs*c{?P&m@e>G0-#$%|uolfaKABMRZPO6k0_` zsQs6AKJ$-hp-^BoN*cvY8pRFr$k^Cf6cV``m&-QawN9)K30UHY@QPea;(O;LuH*BhkLTfB|+gHLaRDvUxrkWbqQo z#NgcmMgNR{>q(zHTp+bH`ul|@LyiYzopN%>6~z#x+7ERKkVks*<0wffP?lwU16ayo zJCPX?noCtI7hsJ}p=f|qX;qAk=Z<9w1X#+dJh9L1^l!89!*tHnhl?bY}?zM-{_ETD1Oz<1#%(&Q!T(p1*70#FuNfW7G9AGYUOz{&`$3RX#>wEVMpiX|us zL1};M#K3t3XQW2Sev!@3n6M6WLmkLxc@+cuRG`o^ z35G^h$^I6sUs(}E+R)6?_EM}r&)6y+0eSy8!DUQXQ_!C@+N+m znl!*q6XQos9{+iSp*E3qG{Fv2EUG^wYHgGjNFAbdpGToZ6k0~1RTNsspcG>(($oXE zP+5opR?3X$axo|)c{W-A){$YF5SH#^b4!ybG>Ww}ik~!!AA0{QWnO8QTkrMU#u8 zrc9mGogjlUTrpoqS;)g1%%%)6O{sdGelbdT0A!rl&P$w3(B)X-pc%5pMk2t_VasXzckR-I>0uuQKPB_{Ji4_R{D=-yQ@rC{25!v~OaE0Py&shSCi zo^5wrfo?USB7)tbUKw3I_B~ z#2`C9eKiMkRXMkzoD602q3?QcWJ)2*e7Q7%ovVKUy$UrCIm^iHn<>lV{sOS%oZt z$ay38<_Kn6L>!z?4f%HrIXhW&3Hp|zSOh?-02h(Vhm=C3-B&oVve`9i?>UvZQ;0?e z8-dS;nKvK(@ci7h6W& zZNrW)TdkEYIT>xy1!w$ZRgIuwJzHyPusNZ5!9j(+nz1)sDJ9J0o;|*IlpE8A$MI;D z)aJh7d7(ZS z3}61tpE>-gNu&{3eIOtQF>n%|G9`kiSy|btmHur0l|IIlhLc}hP>8du zkOq*%)$`n`zUkAJjMZ@7arCeQQpWfjrNZ>#<*ChuNd-j>AZ7L6)Q8hl_l+3A6j`l4 z?0K5USxNdWEs;&i264=Eo*2T?CNerjA;<6HC`G1NiM`eJpQN|b41rYpiO#f$7EQ}c z&QL8Zi?~_JeE*Z{ztTR=v&Fdql<8?%_J&R^7Wto+c_c=mq;gO(T+(AVh6Usc{ByB$ zS(cfm<1*Jn(L0COcgs@nZob~K3XsIMt$a=w)m{q~{=+Jf(!x)(N+UR{mAwTrMs$GfQZ9L0VP=#?l8kog^c??*_fK>#T#J-+HzFu1$ckb0yk+_=>)om`Lj}J(#MYt67T^hsHvnrWsZf!)?=TmvmbJ|h-fHczpsMN)M907j3%<|EZ8Km z@mccf){6qmqZd*XUT(Ks?hCzSC)DXFm13_4EI{OTKP z?eGncBRXLM`UN>P^kZbuY9AmUH3-*lC)QV`vBjXSpcdounNSAYcn|%=$nq7n;5vo{R8`arv_rM7s~3Ey*3l$2>NQUf zS)&P=@sAi)(65rn^qVxgiPV_Ao<@}Ln}Rb6wbZ73K6C0(#b#MYztqHq7Nvdn;Q{(1 z%|2CJcgK4Fud(+XGd>c*wgqCZoo*|A8$qg9p$g5O0`HckJ#Hx{FQXsL24z#@U#FX0 zA%$U6Z#iewR{m}1ezb7qY@tO=&@`l|xanEaSJO;LbtEPe4pToxD0Lv-JfG~Q(gTS& z9@D5;q|Eg4GXi6%*=W2#{;;84K6=|L{|SAhC7+u5aTSsT6aN;fO?r_wog1Gs>P`yA36wx(i0HDCv=Gj? ziOquIA;cFe#ZB4bsyvDfdsD?HRF#pDt6(iugidZODky?IqFSO(d>x|-R6&TYB|oe= z$v{u5T2>_emHd9i2subVGeCS9_IdZ?y%g~fr>dC z+7CkpVw8iy0W4lzm>S^Ljb+z1AljS%=nsqi%zDv#eQ59JX>*ti9~bkR4c4{e+hLfN zWxC2hNYgSO=dSy}?|%{O6KDD7?y+hCZ`OLeyt&_a@B5(}X@d64JMp2_Y$KjEdOiZ6 zp}TZfb~E5Er4Rktf5h2e271P+{bx6hko{nT4(2S$p@(U@zW3d5!T^B(FbKjLY+H-@ z;oEthKDz*U)Jh9s(;x1gpO1^r+Q4&9=wh*qKvraIP@*n&whz-8T%l@zr=kCBw{d>m zS>x+4jgyX`IZXD5I?_FC`NkO*eV7B$o|qCl4b!|5#Z8Mo`iJR0AME+@`7sU#U#G7} zk6&bguI`wc9>4ShH$)HfG<|&bkf>8*d<%mM0zqMAOYlm^@(so>xY4|gwX8HCW3brZ zzVDdj;ePz^yUJqh%)uW#8L)KAhx@X0GlFdfrNez4kN{joLuS7rUozc}ZMU?u|J-lQ z(K}4@h;99dX+Q4c+V%t+db5dR-@3GJ9l`I{Zbh=NpzH0eHD)Prtc{iY+MK`3G#Jc1 zY=;9}De#jcxZkmzZ-(sXa5+YFBt~RILl{zzh?NjWrh&%!+YN}823i(oj5@M(ufP7- zZo~-b_%Hp$c#fg0`v7M{cw&y-i6hgBB8L&T7CKBjSMS8T<=qazKZo|w&YLNM>=mZg zjw_#HoJ4;=J@77wA=WQun-KGNvSeW9pJ;v1DclkNV)RW?iZ3=q-e!iN6akjci7j=kBsjr;t)ztIUhZoiZk20x)rF*(9E zD>{rS3Ty`=IztrCf0&kIe_uYP4ZXzLcTNFtT;eZ}j>kU}E$%pOIPQjy6V&g`L3DmM zF9)#K0Go=%Fz$zLbPdaG?oof>BF8j+c2<4xvJpsm=m=Z=`x-sBM60Y^PoQG?5k1wp zOm~=L!X}wG4o2q9rxw|u&l`Q5+QBbf-c=u`;l2IAv;l><5mRxW{iw4tJM;k zyZgCAeM%nK_qLx8MwM7JZ9#+6Z)0kQ;n_DsHY2hv=zay#LX{ol8bNQwsJ$K!P_xp9 zI$-aHnz^pTd9;2*{Z2oF_4HWt=Dh>UotU6Eh|E!=y>SwA9R~JwlKjl5O-8V(mxAvWIRazbkG-syio2IRN{N?3$P0Ha3$6HZYK&vb?3G_k zlVw8beKzM0ca6!Kcd!L6!Vp2w+t4H0{^E|q`~BRnxMXA^8_Y5wu5X@=SU{3xhZ@N8 zv(*OIxid1hv+4xma0AcRb_~V7NW{!!uQIaFC^mw-uya2grdAq8=N^?=ekK6cUZwte zp4$t-_}$2P+itEMI*M@Go)>qfH!818pWR?zZ-&x76kj{T0REhDwUMV%@oJ#eSCHoj z@^FN(4Qh1gRg3O3PM55A?^2Np;MOa&hj)xnwl0fN#wNje5p0*qFFm#vFNz%z81Ss@ zQv*AnrQ73t?}v91^@1Ir@9TxwE4O+t?de2foNc*5;h{s`P1-a92gzj&2^`2xZ2kSEP{X9nuy zS(l#o`dm76);S~r%#1tDU!H~4{u)5J@i@Q^PhKt$hFDAw9 z=2)y+>-vHXyo+Tlk~d%OC!#M-==b+`eN?;#9O%RSc)oSPD6`zVTO9%guGtrAWpRC zQ{}w4e{o!&&KYf~_dmSSP^>S)J`>;uwG%q1qX?G`(?p|cjy=ST{JytW@2y~p&v(Z* z*<)z8-HwfECquY9p=-yoD~a!~pK&aQTPwV!(;=e#G0boya%a6P{{oK4XmHqYLfAQW zzFTEYzAN-ZNz-&nx3JqR(X(GVlb+Z(Qz5+9!I26Zc0RSEcs}N@cs^{AY~IY+$Or6= z@<)Dj!!l{E_R)>2EK%~&eJ#Qz3$T9vung6SgALe^%2fCm4f9q)Xd=EUZio0&G~ess$qNR)@%T3KkicS;rx?frb|P+)oM z-auzV&>cDXn864wXh?Hs{a`t>@I+WBU`cPy&`q}qwNB?79J)upa1X)I{qVBE(az9= zYWJml8SG}m>mp7BJC!?Py9JWWu|3byxOL*rqYDIZE>c@}CSY7(e~}By%bN{;ZMCDq z;Pf~Rqf~(TadNyxIxPLStZUHP*n^QHox1u1nAMGJj)Ngk4TO~P-Imo$W&aTabsiP3 zb%SehUA3h++>u(CDlmMFqqwZQw(hDoa--};uvbVeLq`TJ!tbK@MUER=9vt0gso5Ay zmbu3{O<$4nxu!v)C!!JXt#wRJ+!Q!vM~eN28{;N^CF|e(g^u6ivd$L$JN8V$^E1 zqIQ(i_GThKJXf;+oi7Ujj#jx_0~#WGXrNB%W*UZplXuR`9B(HIAHua?k6oSw=!LsCE|?NCVNiinBp_mY|Cn%k;sJ`lqwC|a=2tY$ z-FX`M<+sPpG!I>il*oRtn3X7oHmysC;W&)ROmI{0C96}LEm2R8PJGw1Ru+-#(Q<2t z>lbosdlhtR4Q}M@ZboV?m)K_9+I9M9m8sw_=q(?xYQe32yqW)`9&5I1{FxK-*3l(d zV6lbPw&Q`^8Nlw)*It^s- z1qZuKc6zcm?}g>c$bg1^oA0C6Np4e*-*noGu?!-2?c6g?+o}+6=F{S_W>XWCHrWUi zFM4j*Y-&&{{%!6^ zOgIx3F1?SopBr~>k4Fj=b2nvXM5EobiqFqh9{_fNWGHRH8E*)AOp|lt&)w-1o*}cA zPkm=Pcene&*hg)sNzI)x*6{~^u12-=%!;}HX8ZgrdV9mnn7}CXiO1WoCmu7KU(<%& zSbAq(m$@g`uHBLu*|*LaVdcv_9S(x(4WPHa76xR-hSxOU8Z_>%=BlG~r^0W1b;M)pOY#8-f|@m-mj|l-p_PtUF#Xa>FMdBZVF_iwD5VgKmO32VO2_vGx*Bp790M zNp&^l9eP&VRKMznPZ4us{f|qJ5#r7~TRu7;^x}l!Vr;03$N_J!w%CE&I_X%44gNKc zkv*L5F|~Cu`p?=br-zI3SV-OEp#e-5h`qn{$L{SD;?BPU1Vfu;PVPDg!;;(zoS)E> zZp51kzcVro{IdL32Vt9><5n_&ukHM`h$FtN?fbh9r}BXwruhm=kl#OsuC?n1&B6gF zB42@F4aEL=(y)aHnW?>8Z&_BuqHHibn~u7iB9Y4PoT1NGG-znKpLzIR80^Vz;YGxv zRaGs8Tegk^x)^@zt6Uzfe1NdFU&AyDB92|;Sku{Xlz9|^?L_9QKUu=#sRB)0C0WNC z<&j)b)nHDV737Vun`{75Olta0cY_#)Yr%O;BSm`fTk`wZ%c~hDpF>MVIC9UG&=4pW{ah(sG(qT;*fy z`7~_7N0)>Vmp{9#j)o;F%&>(&TOavL9$g#ijgRi&?j`-^vfnJLem?}TFqC(LIv0Vn zIRMNoBX7<_!=_Tb$z#C1o)4o3u1@drzS_s7UqH5zlY(n55BRW|2Tq%ro!H>+y}U5; z+v0_dIPdU77tDYs(SkRU(6=m){xr(K?nghIju?aOpJEu`uqf}gfw9+XUR8edJot|g zP7&EosywWZ*w1mc*Wg}D`=UrdW_mlmpu?x1d5mZnixch}{{_PEosmg1`6s|{+Z)xq zR7L)F;^(G0(WNZ_mcIT;7WqjTfmJVuu>}o00Rk{l;nIEwp*4;DB#Qy_7*JJ9QDwbo z8=+$5T zJ2~w#Mt5c87?gQ@iqu-X+Dc5<{jhxo;Xa@#0eb}6$rYhVkgJe z;kp@^6f#t#CF{2El=)8Kt7L&Xd{vGzSCbm~p_#iUn zB9Rnp?EyLApwtb(3N_=RWFKKJU1+KT0w`p1T0LXrzSi1u-Q={&BuWDUA!Yi46v{06 z0AFz>xGQH6)N-oM<|S{+RHmIT^lMGLlvbrx`IOj0BFqAs7Z&UByXA<}&sg0c{lhoB+^ zl_97OKuI`O68!YOrd7u@3+JA_nrOaPjbgMsM0D};2SteUqI~%SIk`ATOUsuZEo;#l zsAJfxlD*U!PV0z~mNpR}75noZ2LPoZDC0&o0P?U;6@5%@T9Uo+Hu|tkXiL<{R1t*% z8U;xA8lcBW^%$w1KOQFzs1zXIE4Kr{5)qC;YxOP{=#oCe0@`0@PA54jrpP zjg5?THJ~CgcS?ls7g+9J3|dAeTb3-OLOfQ2#yXfarKlHURC*(-Tny@nM2=n+`e%AI zmEON9L<>qb2};8`q9_wfv8mM6esvo(l10Ui6t!Z$<2GCsnCg@PA4&t65=v}GMM1a+?5990uXcByl zDAT#91!Wf2tL8nw0L;T#pcbKEk=Y_=ir$A(hsZ!E(IqU~tEaL%-60W2PkqML+oo1= zL~N<0)hn8|niBt<92%f zW(1HzM2@KLrD){8L!n~th5XeNG|8&k0Lgvq;!d=nLMwhwwL08cv-hZc?P($V@HUhN zVo0j3VPhu&lJe;*0<6#}CvV`Xr3#UXj9q*%-}3?Zhf%TSL*}WBtC9m{LH8nnl=__u z0ITSzyP#jq@2aEwiA-J+n!g5z4#iVipxnv~0p(s3a;X`YL@)B!=;&qsoD9kUPpH+4 zxm0}UXQH)2zH7->#<@as62U(*Tu$m$#xX5gf>TMpEVp}l@JgJg*pRheYt@95TLImwV@Ql@53O7{R zmS3b&y$Ufv5$RP$U=2@1Wk1O8HmS&j{2A56V%j3rjqrhE`o@_mLI`}RrJ-KqCgbmv z#dI3^9npxQn#C+obqF!jj2F|4Y$RVO3T`oADbx4?s0u-K@ViFryfB5Sw1dkZAeagt zBv<9u0F;HGB3QF6P|kbtf}3*H6!?HmVw^${rff?2?nQ*#PNqD7p9I*&t26gXJ>kqoUDv|lB9QB*g<&Q3Ii8(h_F z%ls^CxUlTo2nHphM0~G$ACOEPTCjF{;sbRa{|pohACMw_+ZWi0Yk zlU)<848^;9Pya-yQ{{%}o}fr)1R<+4_!SGnXhg-`yrSSW7BjcSm{VKEE1>X@Kxiz* zQ(<=D$uFyo*{3UCxg>GWnCRTKVgjnP<4$BufcBu^8~`gZvFC%=v6b)Qzr>$Vo%Il& z$TT(pN_B<@0A^voTrH7b7n|lHGEBCQEIUiYvh`0=wOBEUESwj*4JD|EeKxiQncM>Y zS_xX~I2KVT(Jn-qllT$>%0zoqOdW-mBn9zE%k(MtIs=UumZGU_iCFfy6xwnTxeAby zKbW^EnB>C(=oVH>^>3a`d$QLV`T&^duO(@$TOI=zL1?s^5*{iZgABk*{mf;yfW}(A z)wd5*nI#*AAQM|@_DmE{`P}*qxXZrwBM37Iq zTpWvdXc!h*Klca<`qlbXqOsCNTU{GQ?;Mu7aUrZsOz?aB}D^(0c zA&vrHA`iB(IV?}?c(v-7=#IKW`uepsba@`GNgWC~S&sVb2?p{##}7ietXhsfG?SDd2 zRr&=q5vWh z*j`Z(kp>nqU>O0Xd-Xx~%8OE0La$upzygRU-0Cx=h(4uHU*kQ<6@>}8sob%ZgxhHp8c8qUlzNSNZ^{)pvrQH2Pb}L->0y0471VG_p&1t|yQO zYst%JSjCGN{>syv(N$I?dh8+%g07YIlNB!v&p$s_A(BAhj+KrZ{xfC(51I&)3_$t1MpqRmCpR}_E7r7T6DNl+bt zY^*02?zxTkjEe6Z8NQepa&3BS=Aj3N8>G3(eZRZIqOs6~vaFS;ss0cy9g!r za*;$9Q%Gd9?`nl5FT(jF^X8^Zj{KQzC-P+Z?n*oeF(kXwLt2v~>G?DMPm5XKK zJt6{K_O9Lzwqx)&{DIkwwpP^WtSmsgFnkQmRj|5d;kb27**xZ@WQU;ht z^G*(cOn}dqVjyL&jP21*?J}NLwm>|qvJYJ5}bxQSL>upFa{{~ znox_0&k^;R7)F~!A*<1+ywQ7&H&1>p6b%`kU2IMEDm5Fx!jn}Z3-dW)ZT>=NQp~}Z zsfNZPGFJI~tR^}ZKS(7pG4`u0vWP4RdS$$Zk{em2+=GsJIOi;~j;wlx*{lvG(1>+E zO%-dpqInrxJt`Oi$9WTgQ0@y*85vT4mPD!eSs8$;eE>pRfP*SjT?6b=KxhP>Jb|ZS zUR{RO3dt|>yZi~fh{Mw-^Gb{7d3`0ZQ`{BRRPstSMPJzja{$OSpwNIy0~!TD0+*S$ z0Gx_+ssPDY_FJ#1)LG8pHc=A)KpBiwd5BmbT{Q|RQs;XLu>RJX}U0y+#Jr4R+ z@h>$|a8icFp#fa6dJa35Rvy}FRnypY3r0t_8=W%`UNapNk$y@n2u&831dcr8F>tc!Kbn;$U@D2=Tp5@6p$F~Wge$U z?3rjq@riMi)0LRos+~YauEMC~0Cj9|MP$gh*+PS5As3JDTA^RQRut*TbE0}i z*%WzzW6qbFq6pdpd?K%ZvgV@J`J4gR@D)U#0#_6!sh1h_%7O)>DJMZilt4%TXu>P2 zpD)_EU37*r{YKw(Z6YYB_Ax%Qi?Iy)K_bgs04U0tDc&;9!qBMHl&8K_$6oQvKJ{L+ zfjW7`2B=Rei{$_)i=I(5HO17?hpy0AHzEB-@K)7WT}4I()&Y z+2LKBl}3iV|MQ=_U;W{4kR}jP3vT%W+e1H4edOP6wb@8ZZ;uy9GPt1M|1m&WApma| zn%iNxwwD$`l-~Ytmkw%=%{FMsFA@AO59Z@|QO74x&-dFj>0D*4YqARKD0`}^D zZ05_igIQAd#>!XN`*-SM7Po!0Yd^aY^FnToR73ecyGMsF();mzC5)H@Wos7upa27% zPW}B#(YF^QuXeb$HqiDyE*-7x+KuhEQ@^Z+7p!megl1kXE)WsFZ|6_6`3z*5zw`$U zG4;p$BG5Rl7pk0WeofTV-fjkw#(vcCl%`>@)ihjyPdgtC9c)}1=mwHeZqVNLo9?&~ z^G7=_t>4PuOb-f+Y`0A5H1M`N8}M@)5!s#=mWxl1`nzFR{05j7pLqVSnEW5%-RzQP z{t4@n4b)=O(f?O(Ct{jiy*>Ny4Q_t5tW-PFD!Bl~LPxag8_W!f+wosL`qkUjW~mmG zzS=CK{EXcXv#yP7VD}jh%Ed41e!egGnP1=6f%a_!QgNI=PIQaZ-e;;;=eGh7f}ja& z`5}~cYXW(iaxfb= zub%z~TIG2$t|>`Zt5NHTWkqtOtd+pxo%VR)S;(gX2#%Q1OCH#pe z6ln1^cQ5UIZSViMw~F1GZctHg^UD&S+zIw!I!yhlkF0^-aFV|}!3z%C_47^@kJhz) zwCei5pU0peW8Gug@l44e40VwBt+r`PByJ@*hcsv%1A=7c~uLq zPz)hcwnU2Jq;N0HYNL4ZM=icMm6KS9`HspN+pGDDO09YQ=WsuM7(&#tz|uTBm8K(A zr~Y?R^$rHJ?J!C1xXHX7pW=PfHjFkTrN*5rAtVD4KEF*ZmNPO6$?byqD*1S`OR#Pg zu*26($}Lkxk4FAItj6qi-#{XFW{MnBZyT|r=ApP@i?a zWx+A_jBT_G&yoe3zSW($TxN3B>mgPF`R{(PQ|V5$(M&ry8i9cy zU;;zPLSIt?sgqTWU_{Tz%}JS^w=gyxUR7#w%#!wEhdKr0&VnE9obtI7GJZ!&n^V24 zhzXT2hYj-&qZB&401CXtl1PgMn;kW6TzAKb%Cyg_l==pPis@J&o!ccxgQWYa3G>q4 ztn8<26vsSF+dM$!fiYxm27}$F)YlOwU@xhWI|fz17N7&3I1_b+c`?DU|0OSaVyHlSPOjR$JMgK0(kAwL_$d zJKe~gP1Eq^PldIgj)Ty1JyT#>g`;Cdw}P*3!mQPo7%%MkSTjIlDXIK1! zv*V$6^}^Wp8)!&GVt;%o`rl6X^MF>zaT70IzRRtjsN9^lQVi(i3Sw}Z%`|jG={~Q{LBTA9qv<-YdREr_ zmVAgu0eL0QV{GmgtB`or^EewscC6SVvV^VWYgw_nIoBWwj0WK%ad;nym$H4n+mbb2 z>}Mw?cJFB*C{E9DP{ z0gYc4eCgVg^mU$q+9qvXpKScJdcgETi#I>H^lI-gRyKnJUBkBZ9EETOeY)_B?cqwsu^sGtz=0pKoBn9WQzSs4z86 zH$p7igRSX8$IQzf1b55AnI!`I!@<^Gbq8Q;S@5tai*jp{C8?}O37UxbOR6i$ls>jA>H*xen+AHaXx0cO!n5r&SW&l zAgI!{DVR4SKRc#hSRJ&Z>l~*;i{oN!5vtVfjO}hYOv?W5?k7?bm@e1d=kB8w3Z{3F z^Yh89GcvN+_I$RZl5X9PPU*|(ZwN@G1yJsvB zEHuM+BX7Z9=#*bMSF6Xd(Ra>=zl&EY_m>!qp^+FsYb8MV+@0)|A)^$&aF2k=;rI(YJCJCcmd9BB!DguFsqKffv;RTo(-P5 zQR()cyJE9f!uoL(4VzxBl}~7+OE-1^4b*WHTQxW8o8sJ>KFBxEyPHsP3-0j5**b%3 z5Q*>K#*<_G=WaB7QHUEO^KEfut6QGUFTWfXy(jrhlxUBU`I~;~{gmf^CIv?9sQK7i zH=IHzA+#K^3bkX1&T@uEYo&Mw1W+rfxczW8CcXL0p~J0a`hihgPnM!UeX>MBqSv## zvyo)oDAq^<-Vq{VL*iv_S;WLCeJ zIoxi_CA>9t?dq-py((zn9f=1bq@6-O6`R-E)YkCk*@aLV&lu7zNeEz$7)X`ffJ4{5 zhi;{_6Uj1FnA~pQ2YC2Ac@bq2Qkg-wHCcKWir@L(!INV-;d?*0ODV_W4b&h4q1^3q z{<NiJH#5$-j=5K z1+-y)iFARhp|38$=~uUKiO3ido4H@A)b)wtneuA}l$PG5{iU;az}}9!xY*C&vhK3I z^%q*%cKhY(8?z~TpQ7pV(UXqLkBNZ~%IRT1zf5O2$sSnErIloBK188r-k`qp=L-*^ zQ)UM=+wCKi($R=BbDV!b#x#n*WJ={F6LfG@v#qKNe9CD`>;QlXY&Lw7YTIS%7ZzpU z81!Wt>;i!XcZBXB;KoJX&VqCpr=G`;dXKg64^A>0kz&Vzi71QxpelK=q?M=4t zHp`T>slOY0=_+*M?_}*Trtp2DfI)xh`Taf~*Is#$EzmO%8`h*kTZPxrv_0An?Kb-G zaT8o6{AeHgIzX68AKeiz0Jt9Wu^AqNDjV%3Gde+;0CfBB@8>?u%-Y*DMY#XR#)0keV`3IW(-i7ehAPfJ(2A=Q(9+H7s`P`H zq2!A|TBK34(7_LTG`-B-KyUMX#%r!WY&|yk{OCt**$k~&$z+UyZwI(Ev(}$g(MslxGy*Fj(j?<>VtFu3J$Zp(4!O(%S zB6SaVoAIL!Mx53jZ6AZp6*u0nxG#BBhwg#0)-AL9!Ghatnp8G!Z-@d(-aAA$praaR zxnty#4HA!pA4@;c2V>}rJ9*yjJ>=ehOc6oOj(;Naxf|9Rcq|HO=&zk)SzaIyB+{i& z^d4^Km9R_vnODNgQ{fi#rh(0_F1(w8Yg4-_{=YaVj@{4w6F!9NiRao(`UE&W2P}7h zX$LHN=(liq8+#jVbP;aOn7@o+hLIP&QD?3-mCY#E);`LxXVqU@ndX1o zt(GJh#}$(lakJNeml(Ej&4Bu&k&ADG4cuWf>+Eed>rHpE zT#LJQ5W^)cp=&o{puJaD4QN#E69Y1ouHUYib7^LUYLpA}gSUw%dN(R}tefW^{jD5N zMlrE(*!=pp4P$)3tqYVTV(m^VzV@dRf8OP{+k2SZ5xfec#gnc&KmGt{z6=9wHiGm! z5tjLV5<{QaI8w?u7lLc(Nx!DOf(M{=(b3*5xHVjY=Z9CL^dmtqR%Qrt>A9zfSEKiV z6KpH-2Q1b}Z~$t(*!U8@fz$?kDr_T8Dx5`@_f6x~h=+?ND$H4^#1r+H#kU0p73VHuA@HB2&!8U9oM#e30@mOxB^!_8R<{xGLxBxuS1V$l$AU*M&-f z*urT!+LvO^1@B%mNpCcs2QGNGj{aFSy?a8a*SZ9*EpWNH|=_ZaQ_#Ma2t@ zNaspYTU&rF2s`>fFlKm+9c&!>e3;L7?CwV+d%Ho;1TqgX2+x4HKT>P_+`F_Kdf%$H4C=bU+5QHh#TPJqwj+C-&z;# z<>cwD?LjTRkS~so%&mn7&7u472Ju&G@QB@juU|o&=dC3h+U=ZK9#~zSGV}}>MY$~~rGYt?#|F=({WlSi- z5UER-MU4N?o$33fdyc`~qPO>F(y3Yz969&+7)Fe3U_RG`Q_leGo{c_>d}o&#Vfaey zQX}v!F}H6lpqwURkt$RxhX~6HgxbGBA1W^nJdTIRsYq{oH|n{#bI5xhvw@cWB(ub@ z0N8mtxWEs07>tnVB9E#J_~PQw8Lzi8i7SHuW6qa=x%!v-T-Sc`a9A)B7}@G0T(AM$ zN48jEp1NmR4dhsB6$wECtxt;XWeTe~jE3i`XXM_%a3pMg9e;eZwRko!sAXCfr4KSG z46SZa%ylwvHotQmh+jYp_q#KeZJ70RQRdP-eWAa~Mg+TybiOrwyN&$tu6f4fW@77- z!xNNG$tf!H-J8oorGhLYQo&o=Eh!K8bNLgsKx#R-xYn}IG;~*P4>-CL*Vv4Vwr6lE zU5A0K;`yZyfnhFh6+UQx$JQ-a@6wL3_qrgd+qZY6D=d)TO;P32&d2FTM9DTb%YcH4 zo4y8mC07<3_t;*q>W%}!BR%ToWE?y<;p?Z{QRl1OIy;MsfoGSr#Id=hJGEnY(YV-s z5k8Nl8@r=&3&F7+*RO9&|3i_DH}%~exnWY{7-Wai+Jose>*!8|@hxQ$uFJ(K^6W4( zjNE4He2=CI#rhb5Q5-Fgq>n%K~sy3#bI0)Tddy9 ze=#17O)<+5g{5CVTJgAXxzd%1=eEmrLZg&cdDAO=nJ*P!`Yq4F#bgh)`q1A@hW;*K9Yh{h5Io`_ z#1aZBCxZ(~E6UuOhWUOxg0})xy@rDrr7G%NPLd9B$yo zd!#CYFizWA%M#Y9%F5#D1&6VO=$$>zdqO>!!OHUIh$U?u-Q}5}lC8--y7HU0{tvUu zpaid4nS`dJS!*;H02I|d-XyvUU!kooU&CiFJSof9+#c^ski=%) zPR>j^uXivQxY3EFHDXNt`*rE`t?Am0@cLgmq*uGAUeK?&3EF?U_5Z1n+exfver|Pa zxv?+(?JbdynCuIb|1vyCb=-SjOSC=tNqhRbJ^fcFlKrb4^MAEt@vn9);~k$bs$%yA zulm*hV!!4m{brZJl}UV~I2`Ui4A~Oj+<#6(ACfx7*xEyBI1NO}d#{9x3y8e}gC9Kl zG3MblP4`h}C}**#!HYAIgfcX$ch4j7qE9EJ7d(g<4Z{wf1&wa2U|~sgBjM${LlL(D zm_TAKGi?Yu(fERk+ngFUw=tW%RGnjtrcsx*tGaBv%eIX!+qSKzY}>ZFY}>YN+w8KY z-fzCiWRjITC%ONf!7RWiyA@dhmHt^sjy>t09~oms zge;yv)BV$NM5br+3vv5v?Hv+;gL%t!Mga@j{$cH=DAA7t=PS$}q$AG#Bn#`w8^byvB*XvgKVsV)TW!WiPZ4XMGnXJuh5r6h*f1ZHUHaIK6-6l`nxB zxe*`6$_HJtg;el2S$u|hV6Z@z7R;7j{Td_X?df6mbpf|}jZ@my5TCEefnO|49w7lt~O~DCn)QmI9y~CS20)DZ(p?E)5U=)n))Hqhr zx%ZU&EReDE6j(*JXS#$iau#Y_FIvMsoa76A35#joCjBr!W>*VQ+x~POgx2HRWb-8R zOKCpO!ov=O!t7hCfE8gdIN8FmEv?Y&%;pq-g$dG|E@3ksi^nNl8FezSNbIM@DqM5H z=jm0+I;hrv{-}X`l@{GZ03S3}ZQ=G>ual$OShE`zM{8BUw0L60DZ%MmZvnPTVC$j}{>{47MKi{R9m_E(*_MR~YqCfDN0tMU&1smgrk?mL*hb znGVxD;GEGoJ=Fo9L*Q)hqcydS*%^)~f6Oy_STmH|T-h3rKLmAso86*WXEUoE4G@6_j6|zUW3N-^FhYHWn|RFkX#Q7amg$Kf4gl7mKdgs6O_ zQAL2ZsU{ildr78P^GIB0^QOp4W|n-DvGS#oJev|(`CKNaOEan8DCMHe6#7aU086GQ zR*K@h2*iwCRa!-*-e17b3h-MXC@*8{9@0pfoe#SZ0_j7Ugsck7*)gOaRxWKof%Zu% zn?ZBa1}lv4p$lj=+yTX;jLUp@McFY;;Kr8z#p`Hb4VKYAgXUx~zpl_-ovgW^MmGgq)283X%L)*ml<9U*z66H9w1LRuV_-RlF>EIbIp z+=qM7%yLCZE)#uYfEvWF%!s7u(}2~-j_%K9I8DUjW3W(>g3oyw?@&Bz_$45#0D(~x z)&5jX6V-D4vJ%8(!id!jZbN1Te#~U~HQ~!7dzbh$+MXb(U>dZiFf(eIIBK~tYB_Jm ziUu7vdLUvgTKFmj4@7znmUKI(62`IaW@FHXjb@sa`^@?+m|Kfa3kLOWvskL6s=)6sZ}{Tcg9R7{a`80g>3 zR6i*=uk};^{Ct1mSQlp>Y7z|I{07SqQ@nqSP=9axDZtd=8NEbne#Nrnx@`yp;$gC*v1G#81QfxkW zF_Ah3frfm4;#n(Pju5XTCH*C^GT6ZCNXe|`L-=^?tEcx@plC8SGX8{-lu$bTdl6Gr z=$in#C;)^bHZW4(e_qh?p6P3Vt6joFLWaSU=bjBUbmUynXdLJ0X8(dJf`emID&bRL zhAiOGp@xm>1uMtov*90FTapF?InT035)*hOOL*$tu_Ro&A{)yh1^W|sN3nh^4y00g;G#7tn9 zUJlj^VNt5InAtKhzn!cIClotcZT_58!iE94SK^h!$BF7{p>>Db7-QL&j|mA1nvRgB zS;MoaGu?JRY0jzmWJN`Ets^$y3L45z=CtMv^2bw}R=&W6vMx4-sgVD})B`GA}f@hIy% zBLAKT@i^BdW){9w=99x!XefG03(BY-b383Gg| zZfAQ*z@w@t!v9CbzZLC5!G~3o233=OzW))~-{In<|CYs2z~4}o$UTNg12VEwW>kUk zziK9j5BF_nKq;d78XqU_X>}mI!RJ%n5!!R(@=8(UfN9qqxGlb zv9a|3oJX#i7OB>DiVzwQmj9~NgcR{b|E?AHsaH0Zkn0ZfIBXc^(RNo5c8xI<-!g^r zE&nmfm>tR0G}>a}HOTDkvhv4yz>CevpRY$t27=xbx@3heChsBwTsIMt&-wSbuW9-C zEzJ$C_-X}gEa9*wPpc8)PsjVX|Lwh>M9qxb@1~d%)lvTo_lClM30DFR--a^Uqks-@v;eZ zt~$s)fW`y37>MyQpGmDy3r_{h zZIUzjACpl09;=Jc4wTU&Y8nkBUjU*N)D)RUoDm0^Li2?H_v0eLwX`tY5x&l|7rONG zW_{7YTI1^bHB040>!4vWs$Ht7LNiO4sXfJ*jLHY&U(8T{XU%aCaEdLd9%itxHyj%4RpTal`d=m%SBqmVUyHYwaw%2T~-+@{XSIiNBMs02+Nv1M}^H z@lyn`gved70|mILg67yPv*d>v;>)72MR?Le!u`hG%+0%Hf2PPV69`z=#(N6*C39sZ zxvdz1o)Y5cYH1upAI7jc;u~5ux{pQmpl#~*OFr-K8gOkC@*G_RsPQ%41T@UgubgDr~U` zner<6MX)Wuw%qS;M@?2Y5WlM=cbO#l@oYwi)@CYr3#|8iD9ZnhS(Gvbdm3N;l2EyW z^&Xvk6?Maq^XJh)5?&g_605q?HM3@P(OS}X#;de6bM*BbRv;2HPAjovu@_QP;K3#} z-2kgvQyQw4hIt*tWx@^(BkzK^VBfG=6zl zIqQ_%klzwBw>UkkeaC`(zOhKE4p!d8QxP-;Fv5Q568|71ODq8@9x~mrAJ0<6u?!td^j8}Z+c6!0 z$Zrx9Q(6{*7^{zEqR^u)jY3nrY1K}jh>;ocTdg`x^Zq48!HV!?sLfsj+zerW|0T4- zPmW+Cil|)iga2AQ8A1rnOXZgt>Z4>jwF}Q9%V5)$3YVoHDHkg=kP>5roKisSN1#1( zGCqXd*eN(ef03ab53O=T)mcPJuHup8EQ&uB36iof=NUjflmU+Jb1G#_6uLD@Z5w3* zjSE<5Hsw}Rg%#SzXqt3@$_=@xS2t=(ZIp~gcBHtrhUcnaTbSSas@vbX&AflLs4PDf zE2~r87(^ZxbS!Yd!B+2jc2{-%QZD;(5fWZ-a9sJRO<#+ojp|a{Qh}06S(JY;Hqvy# z{f~#3oFHR>WesdmV{Co2Uk^?-c;QRYtZ(i;#pNml+CoT5F=9U@yWmFOILK$#;pBVx zS7le;>x?6ra4(Wg8KvzX;8`IXU@&KGVjx#`=3Yfc1+j{zS{r znUw1ch_@vFRvz&O>4F$O7%oq(#c3MKHNb9-NpBf#YQgJxF{I!c38?eK{gbRjNTuF_ zpU!y2*~K6~AhK%o0Q;J4DM@Vd(@Rg4s#BjVWP!JF;OxpM3!Y1BI4>JvVT(lq9N)ACUmTW@f$z$6oc ziFU6pxY}GmoWXj@2cEeaJg?lf%!Y^Go4GP)Ey&J=wEXz`@YxH4^up3nkKyXy;ksw@ z4_@VrwcdqQe^b7#nN&d9(xu?q{3};p=KGx<9?V_kKx1X%_6ZbA)x!qXz68k;{;|tI z3&pAQwYhkf>$#{J&F0m^rp`|l?iT&AHuhx>5`0K7P8bSJXTm?)9&R1-3mQ`yK(%_& zCI69lntemx^3fEHb4pMQ+_luV0lMYcv(HDXv|IOAJJJ}qmoK1>;Q@c+HD-eOV(3?# zZ-Al&a~~hqpG&}XT)jK@>gwprV2yzhB+sv9jb=76zT&attnUkNhWT=_Tfru-8^=(X zA^sqn@dK!sosh(Wyg#Mmo>(ZiUR8&zSq|<0PU7_ zv`FIh@k2RgP*rETeaDrUXs(Il{g3_sVG#-&XZ z0rP{Y)`x)=Q?QP?K5EgAoJe3GVG>FcN|M ztetyxziC>bQjX!~#|}@s3TF8t7jp1l;&b?J=s-S_( zXE?@B)@tP$&?o)6X}Bc@*gcEkY1Ze$WFWA!3=on9XKdae`BVcX%+bxIS56-cs15Ke zNNJ&ZSH1vBjh2vCze)=uH&I5mL7AwX#&l9WVGSUVcP=D!u9R=6#Vd=AQb5w!8ak^1 z@-c_ENA*+aG?muK*%-k+Sc+?UYhQr1ct&#j2j+}e4T5_^-TBeGr2{?*YWKl}d0ocOrA9m{ zO0OqapY=n6o#ue|u*8p~G5Gg>_9P%1r}S(a!soKX_fCB5^%UqFix>GvL%{W%QK{i( z-uFqKYZjx!$?S0Pn*jb2AtNthnR%La7PYS#w&4di;`r`=Er`;!sXG2={2vUL>| zq_wd?6+5|1#{R|rMZdAfd9s#~`sGnD+%xwl@65@t{UjAkaLi-=HZGEsKubtkJ$CRqt zh-5C{Hr*uJ4?lWC#8FgN{evo1by@vkpdWs@~8 z%pBWo@=TBPvi=*<2F>X7Teiezw&;hT2_T{CY-5y`^X;o1(!QRV?23~Q93v!6gI9RW zcDE|;%bhu7&mOI&cl%~(>w*@DZB%^`d{4wIj5BNT$VP>U5CX87wTuW=GftcY zA7)_=>^-J0L#Yoc1DNcO98L-M93YTXzx%9%}&%LSL~I}{Y(|F@rD@}&;4&3dB6(PvUkV@iB@=6BMgX^IP!hGTEU37R>!i& z@-zz{j;)}xCv9d&V+l$MFlu`XFi!Zp4f9hN>#GP3_@k^x+{1%*^L&`Uy_bbemoga# z{YLl$c2ho!`F8oYh$$;G*ZodfSw^NB6P=T^TNRI@ZQj@|lkaxr6l9T6m8B);M3FZG z^Zj$koh&AWkA*KG+?a$D2ouY(jf)kZ%`ISHWpunC?ct9!0t+47+21ew9dnzxkg1{# zrnv*u?x}?*v0S_i-3BI8`mK&V{vCMS<7?Lo|ISb2+ysKw*7yJei3c=TNWT!oDSuz5 z$Ry@eAC9J-<+dLGp&-lqz$cJ|X|1B5F^Gb?>{Ark9No7eZb_chcE5vFKq>P#y?h3G zvb0>ZxV7%eUc_0-cD4$ZUF+Je{tDE2d!X;I@AAb!D*b#|XSapw!WP@s>8b?nCIw0l z;(>;Oq@NyIwp}U zU7mL&|D&CbyFh4s2HF$Pmi5?H2+Wm%7hLyAGb)_*N7Jf5($98LFoCkLP?qP0*e|GXl;O82OPBlLwdkk(G!)9jb zJ*l@WwIB06Ti^LKiD0#RQZ>9OYBdmkogMnr^|_7*VgB}eU1o8Yf0D2nOGNa*8?&$r zi>wE2Pg{K-y5tNNCjgF4)i#;S*?^#9gDBh#8;`4?m21X^-VfjcX#3T~8mRt=2GTjA z?9e5uekIICh?x#eeoj-zM4}lk&6n^yT5J|ZuWop2^9d4c4ZzhCIa`>4 zO*1n1B@4DYz@uLGx9bVKl~;s0LYDI@_9>=goGwy>KcUwa=6j0^ih%Z6d$Zn$d0ZtZ zMVLHc1l+7uv#;gWM>hvb{p>;fxly|wzTB00ccWLwrks>^NE%;am%u$U%LF_4HGAoh zp+q0_(8XQB!ix3EmvN)eh#{>7aayKL3VSB{JO-giO!QFDxj{V_%URQ`elm3joBH=p zVF%qFT9bPEa2mqa%XlQE9lcY7g3ZYF-SC!|ulp=t2K?uov{!;7li7vfk$twR;}$Gz zOUJiWA#K)IA`KS zo4(!84#NKn2_%zDZFjYU1$a`uZp$1fQ|Nt<>h!6a`*#2!v!izf>?q_|9J{?I@c4XU zRq56JSa5A{I@1-xQ#OWn7(8;Zc!Z5dEhdBKjJxc)ANb^OOKW1$i|%e@Dj3tKPU+nr zJ#vnu8p3T0^OUMMl6Hjn-((qvSf+ttfxQ~rTITAo?U@|yNpFZk^t9=V&HI`X2U2gi z>XokSu7(kNIxRTb&icaVYKU#H)&@>}OH@jkx)#WW>YpnuDKJE=F91L9!+u2G!+R(W zDpv$nOiT5rdM?>j?v|*0pNIUHt&#MkTQaS`^ z=8}UAmRB6Urz@<<^!PSBiDCti2bYeGKLis-C$?Z+#Mh?gU9QbIj@>)Q9l$s-3^{|6 zup+qRXFh*77F>o%dFKh9$^?tppJ1)LI5YrlGuG;n@$lp(c9{DGTr;-84^FGPc3oiA zI>aMgdrTWtT;IzeV;a&g5sFcHnD;{Ky1~6fq+_my6`sN=#Xdg z7hgr<$ap!!VCwtz0L+XqpQxBXG^=j1mN3}m1D(o8#%cZ)eHz@XecJ2A@W%b!AR!~f zQ1AMi>gY_fWpK^XN4GVGCtBk`ml;gs>t%70xl&H!!=|S(=xmA&i(hw5VIjrXB;bq^ zl61O^upk0+cf_>Cqh)x}V=e0=bs;^Y#kTf!HLn-^qW*@mq}p_1cs7PJ%^36bx@x#* z5%JhcAsJXa+o z&sI@L9lT|8xPeYUKxgW;A$Q#Ph}Au6{BB4~--Nnjbn4c)?tn5P_(C5K!}+LP46eQW zeFxsMDO7UfoHrydV78@`4dw&kyt~mMK#B?FuL`VFef2?#^;VBPgcR6j9Tj6{Y{lKK z4|$`mt%~uwFGMvtXKVKG3Nc?D3uYkbp)n;B9& z^1v%q1>_`}0s14=%&5pIU=2For^nIx<;o5PM4 zCbd847R@jAeW@c&p97sMpshC5L*_f!eOIwt7Fw#xn>mtGh~M>W9i^>b zT2jCLJg_6LzP(+Xlh)e4hfX1OYMCU$usVRwXk`0Vy^Rx@4W_Dv$w_!+D)_1r{SDi#o7T`XPTOZ!NTu zl)W&mz~r(J@{%TD1c&?ZH=+C2mq3ouG>65Ttep$R*>sBBo2nAPF5!5HCF&2;c z7P;*bwNwKaeuZ9Bj1)!$-G;g4$3IOJeIJcq+NZG(lcDPOrYmmKgJS zX}pY-Xc92Newq}NCu|Mk88ktYch(SF&X@NCj62OceNR!X5_*WBXDJ8MdVN{{p|gmr z7hi=Ms$iZt+S6iYd@PUIOK=!#ujTMl&VZ1ISD!13cdBG#u6ymm(#kKadCLLEjxd8u z{<2jRn8>=wEQ#CU1dxzMGMUpF zK!eoe1ZPT=+Zu=E2MV=Ymp5Sj64q2ZrjE!g!39&PI-kMqP%yo~B}H~SC3lbemte-F zt>SlUalJs7luj_>u|pn@1bQ@e5V0}Q@{I|zfuuk1t~QH$r`N~0@d%-|8W<$#On`GK zD*)&pwx~cRuu{rz{9lc#T0-Y#_!XNScu300<){Uojt67rBNDv736ypUXcC2!Kj08p z$h?>WGRlS%w%jb3O;x6SFgZOIE0Xxsfmpq`q7YY;E3C7#KX*|^Y)x?K{DtfTMu-_FYQ&<#%E{HQhJQ#bFq zXRiAHE{>?j+(`IUc*w;TP76D{}SuVLO zV>0fGpI6k07#mao0pw4LhFo`=RGwFjeD_S#dT>PFZW*DTa_0PUVVpV@BW#9vBawpB zHW6_Q5Uk;GXD_!ao#lzEtaMJ{F?lhpaVXDr*%4*YXjYe$xf}tN3g(QIR2{PcL9%?9 zz1L#lDRsM!S;7@489jvA4dwvVWh_wAy`w7-8j~7jUW8ZcfyKLPg7=72OTshEpHG!{ z?H@v1xUcG%c9UO%D^w8@tq=Y$Wajjqux4r?YB1jLLh|^3g$X&zPr#BWw0u$~)zZh~ z#JZ;Sf0-my=(MsYWNRNlD0yl?ftPXm(pALgVKh7lVW>$PBsEmi8QWkA%7#y#c>0=> zuNWLGsn^zLOT*Rc1*;SXIo6Xl*T-oodDlkxwS9@&A^wh$xZlFDNkR6_4c2m$@NO&! zQ2`C2nTfzIBE^mT&0WK5)tEhjEIl6}N7rbG5NEnzTH7b~2p3=GLXTdTefJr`j3dc! zksLz;TMkwc;TS2cG&)`(1$la@Cgv@L(MQ%f1*0*!19CZRrV4if$6|hC)Cs$Jfm0jJ zZ0NG$hxS2nAvdSS>XS@IMY<#ro z8KgIk$W@e0InT{#BbzZ4!q&971<~1i4!~VF+@u*o{UwpErY9}7y-T;z;BICE^Z=3T z0A5Q~KG7ZAE94{#!%D9paK&@lfJ*zEO_Xo4YM_qv8UEsXYko%fZV2ciR6>a-17Tr#N;mowDxp5*BIEeiCc?|SaQUtVyC@!uPo4PKwGzF8Y#;pcd z`&ir#hRCMvjRwaM95rf8P};hGc>-%#_;1@l5tBtmxFv3@)P1)hFVDVWs$?`}d$6&0 zT_!sFn5H?Y&W#V~R4Lfz8DTc<;^!0#g{KVkKAr&2mb?KzapPB)yTOJc#@JO17u#cb zWN*ae5|$-!p5wG^8qSoS$o2aucJBSAY`%QOBvs8B_7UjdU%rJz@_w-?57rUO;t^Si z=Low3nv@eNX@;Q!K@uVRk-<;Z7cswcx7r3K$Y#EDepiDz-NbO9;Af5|F&cRnd?-@y zNJ2oL&}okZ7iRd@4seGw&Q{Jt51GM#yqfwU`|MBiH~+y^3PbRXr9Y|rWerexix8~9 z!PmO8%H(~!IPg*-EH7QcE0uB><=HTED-CKSk$OTNZ+SW21|}w#2A)K#J^I(dc!QEHjJfbjh{(u4Iqj1CjuYGEF~g{-bfw}UYE%H6}5^h zg*-G8-KzowY)EIq2&}OgMpI21J285|bl-~Pu5C^S9W6m`I3L+66p5CEYoZ z;Ut-kmwX6eeygBe5n>?*<5_FC0Xf(*FX{OxgHBQ@2_vXr*sP>WkYKWQz{sha}siSet!A0v~+(f7OFvbz!9{n zDBOSyeh&2-wfZFT=0&&zrSmvkF7Nfz?EHKee-c|!S%AsDRB%KiVw)tXT9LlGRxjl%TbotvdvkEhGa=$VhoW!aR(u_NF#A~2I zTFvbJTv;@kNyz7lNP6cvoEZGl6Bx~qG+!8aTMD9cl4v+!Gk~(B9ipH!YfO_G#2g z9D(<^j!5_m$aZLtI@02}_F;DjHb}k)(7C)l_~7!ACZbQCIW~`i+B&i~f*|tZ7L(4D z4nN?nWx@G~>yFuj{J<+|v>>-cXOU0E_3T*f6Kf5Mz}BeD=EQo9<$RfIu#+%imHMT) zv_+~Vm{AplkwHNvohLuM8?cVVGK|1(t4{V-81_Qf!(mEesnE-6X(6_Y?Fci8|SsRY1DAh&?zRQ*lAw_@Vy4k`Rli6>OiVZEm(FVMolj% zDwE{xi0*pn{xkS@^2WYI*a!~(^&`D1C|BDG_uZkwA90GtSeGaZw~oL?seSLX!_SUG z?Cs530S2STWZD3_+j8TO0b%^Ws zrK+LIqLMksx*{cLn~v8D&w2MdvSIUoU6K&I`N#+ZIy(q@hzTWJNE(rhr3*6ufj1~N z=}sLa6`g{`y!GoG<{oLfCvr05PCK{G?jY{X z>4U(z_bKC~>KdT^j9mCD4$;6c?!pSvByJjs_M!4BONoDCM=@7nx|2GO^1Bsp#uNd+ z=-Y{Y>reJ^Bhu|J(*@c1HwOH)&diN!$LY@b2^kk7w}aNG+|Jx}+J>otuI3EYZ`e!y zsia}L@HxaMzaer+5U?j5cQ8~2j!7o8mf4(_CS=`sSQgVGK>`qk7^fSDanBv%#8R)y zL#I3NdiN1Qf+z-aP;_v((n^U_dCZY_9>}T^xtA|yE&t=r zz(F`g&aexG?L%OKr)Qt zR22ipf=jtRATR_frg>r{NKc_%>6HY&s*Uk96I+*j@!JrwM{4@vl!Hz@dplk5^?76Z z7G+PFwvwWxG!^N>F=Ic^c4ZizvewkKLPld9w zyC?ESd$(@X+u7rXv5f6xL;fIXDPoB9(QMpv!)bA)d|eGwSWCi7M8G z*Y%ukDlytwdS$dPM{J+5s7GF`Vp@k+)*P?LoPOkFPUt5kc4f`thRmHS;kbE`Uf&SL z9ntUFoVWO5qi<9J1v9t%euK^U{M&k?Y(qVnZvPs0+R{Y*uv-j~ie(A&`~C!X`FXQF zyGDQRuS|{9MvcJqI8AJp?djeC2x2~Kr7w6dO+(DqSIqlzqu!5WC*Cf%-Y^EUt#*7k zX`Ub5R{u1WwD<;$Q180BcIMK(>p0e4y>ET20D1=9GM;LDtyfC!FY&+8@N(>XLL6+8MlD2NR&GgNP`-N)1iuhMzq{LXGan_McW{nfTo+{Ihi;<1DogHscQWju=viB~ zsdhd~zUN|As-JrR_PZ6hj9p)CXj^YHTT^_^r$vkH8sW-5#74qFEOmJf`7lEj*Uvh5 z&t{8(0V6n_i)|t~`Twj}zb9620ZadU1Hr$&aPCN6zZYFTJ6J$lc%jyO!K43(Kvl3s zRy?wPE}n3HXBAZm7=KrLzi%-hRO5U_TFlye-zA&yet0btPf02zeBVsUC`jqeC}v^! zrZ<1JWH-wldu;lK1YqQCLBo^LiSz8gHCcR;>x2?=~VSZ3);FaKtEy5373V-y=(Io5!ItJe6Q zQwI+8Se=dduFT7QR~NN>ug_Z;;Qvv;obru%uu5Ns*k}x*lk_`DgkWZ7eM;}#*_V5B zQFx7E?^2I*;zvP~QDIS=6BN2~SK_k@5r}qwvd0@l3FTfUu3j;YJ&)Uqb!+lp@uHv@kr*S z_WJ=G@DnLxzje+e%rW3rp3v|8R453A7J!{dRoWmXNt$IPyg_V@6Vfzg_*g?RL!}R8 zrpel-*|xM?AEr1}NonOJ6o6HC7y6WtRa!KAMqdwrX~b5)Et6)gmrBMj8b`p<+%wr) zdQXYtWp%IEQBkzg*|(J0x8@N|SF=ft?x#r8OvTPxvmZ$z{us7C!%HG?N0jaPX1NQT zs`5sZ8*G^*CWoOql(Gd%-M8V=(5QtP_$8f`RLqoU?vU{{jeZO3hxchT}#{mk2Rs(QtTEUV7-6?5DqeA92sJ zUqO`QUW}1`Q9JGL6$tVQ4iOPesB+1ED{Ln{D_@ASV~W$d-Jk!WOPs`&-o_5pABj@r zxg?Yu9Fan$X;I3GznaIw-$i52_j|HIF=(1Rh^egMt_l9;i=_G|DD+(nGPy7dD^^3y zN$rm6ugQt=l7c~<4TZC{#5deA+|Qg72)zC9Fx=fHfkFbGgMo$(zux)XAb1amT6e&4)#FALlZQiA?qB(v;9lRhg(@WXQmaHl&q2 zJ#8`X5doJ{OGIV!V%+paxrIwa&O3$i+Ob}$4X99Aih6IO^Hl{=svcBKo!CpQ$mrK> zp($AEjS#=qMc5WBk5M$^Pq+GLiUFNltzVqjeW!N{8wAseR7KUkYWf;%6h5U|uBF<- zOijxwV>xj45)j|C6ERb1n%&CJdJ_peCn0mQ8pXIKgeIH7v{lSC@#4>jp4snO?q088 zyBJnhu%Da8BAZTr2y~1iMIn!}8~C*_fp{(a-f1`|4^*b`CWN*2 zZaRZTC(lL7J+Uvi#cG&RF{_>$PPb9ol7BV-a7cgJtQj*OhQwxAuU8CZFoK#oF4xwbEuQI1w%kFJo8o;j3c`Sm6;V|;@h$((HQ zV!bL=)_*$-ZLhlXVcnu(Buy>G-ahV?*yi@ABQ>Vne3Chz0jS@mB5Y6nIWVo!z@3*A zpE7@`SU0!HbJva<60w8lY$d%(gwqd>LHEu5T!tZ2!pQ=TOly+Yo*$1Qa@NnJ8%@hf zyFXIJsj(~OdMg&|RBSG0EgPm(swFLps4L4itMe5t%GpgSw<~l?m{I-)FSaRnk=GzE zsD-~)4DPEKzEahBB){m7=EzDd6JlNAT z(exNrwEoKqOJt`o|1gRfWKPi~K_^LJYlS=J}31#wO0k0v7lyI_e^lHxy zSORp=sI~I*HmCn$qhOM9_oz5a35xo!t;JSx@u~vjyatd;nZIx zVTQS0We_W{O{>3J_aDI8pjJ;@ZD1&{1ixc$#Y9MHlr>2RF*jMgV$7>&lWN7Tkf>jY zpAN^~?a~$?FoF=y0i#1LhHk`L1q|1hoSf;uPoynm2bso!1u>byk-f0`T1+BMENZh0 zCUE!U1oefbMA9iV|HhZPhLPm6NsFV2npDDtCD-rVyH;&n86$;7CTS&<;4~ig-9{lt zsP&s+bi^lZqzBdKu38PEYYpzg7zz*WV@FicSFBvovv^!J5`+R3lhq52i_h*`jvEGM znqV~)FH)3mvmf%B8lb^Zck%^>Ybh^gAXl=|n#!HqAq@rEEecru%!OjOL8pj^&Ri}g z>-NqJKg*tZqudxwvt^uV%)Fyy)T;ZMQKx~+195S^a#^KYpMASZXcp$?Nwi}zRffgy zft3cgk=?caQXm#5?*T*=iBY@AFj)4Lc&39ATJ1N;oqex1lC$jk|7Aq(eBP(PznSMkzwbI#b3Ci>UIN#xFYM zHHzo@o;%L7uv8N|iaJ%A)V|5|l1dkzuoHH7I}<+(EDa9iFXNlGUD^<~Tg-D-4ASZ4r5%ND*|*M&$%>ysMN-cktjyl^?b)XW$^n^5j@g&C?=PG>?e?T~ zob0j3Z{R9x6g4Z{qJ)4q1m@5&gz76N_2H85!y9t7o5%T>VclYr;2GNz;17h$z&~^$ zf2TYw?ru`#Ca~S&CfQS3tlHBe0IA28dY*W(RgTyK^Gff#1y=7$JPX)f*_^^(uZHTR z<2fhYtllhxAKHG{>7Nq`Upt|9MyD$$s2L?T92J+dQ7sm>@ZJ|#PcR4uA$4d&`0EGc zF0@z4LY@v?Lsk}^ZZ~pQ$cf;UqCUtTGX2i2Q<4S;DuG6>S4TBH)fF=gQBC>T+g(uRcwu=}CmIl9GInV3yXyv+*7J#<5dP#Y zq&|o!vV7PjgTIBE{V7Y1k#Wr&VRK?W`nSrr9993uy85qxG5kx2>z~Zq5)8Y|8^`ZI zXetJ`dkemZ3g!vRZ3INNoIdUL{q2CY-}xiQmQZSnwhfq=V$W~6lf3!$xi%ct?10>4}c&me>~FyiIf4}daP?$~vF zZHDEF5n{Up_z!YSrZB^GSv*={cw(|J1Ila=%2^TA_OsImZmSz{~ZQ;D^4;uiu zI@^4a7?1Sv#QQWi+ky*awmoUBSZ_KhgY4y5m`)hMmtoh2I|_?#{R?+#~BDYBW_`Bqzf}JpdE&Z#?t2&dciR zg}M0!`)gAC#x_x5%i*2{{JA`kD4mUgDW2W@ROhe4F?M5Sqb?ie%w7Z9>rE9g85LfR zm?DfOJcQxoeNDc!ZNXH*%CN-Gg0=LR)r|3VcKcfL zviOFkRk+`?xgSFE2Ed9jw@>9pU1;EwM1T(EelDLML(9>lsjo{{^W)R=?UDIfL5y zva~np&bY265-zb;t%=g!S93RZ8K`(sU+ zan561d}}o`9>Q9I*GF?)K^k$BkfF1txMjLvETl%`MU*?4>Es%7&Lp^w4ehkpeHiB$ z*L2kln-;awe0_P-{dt(Cc-t_=d9zGY-W0RZ=6bO%uo;X&c0v<8CfJ$(VqC0ggp0D+ z;5(3Ms&xk`gVF@2$mx&;co<@RGc=!Xhu*9`CuX=BSo1RuBlyi@Do7wTt`~>7lRXri zN37_(Sz-`h4;fGQ=7(l?+}q|boVa-1+aAegoWHkM)1#llUbwgSyBXWcq-WqoP=0Ss z)sx1TJJ^iIgi+}v0$89mc?~1W>H*1Dts}g3`05(l? zzk+Z27*9AO46Ocs(bi$3ndsb-I0)m`K!Kwuhe0?e#?>r}Tu-Ah42`yx{2K@2e4>p8 zC!n*GV_fK-(bzq)NE-}Iev<9k=q}C?pZDWpJ1(;$f#vqP(wA(IZW5|;G9Pyvf&*## z(q?Lw^D;#ReGPK@nvUJPG_x2JQ`>$Hx6i0wjMR@}b~Vf3j}7`REllb+!B>+o!hVmC z2lx~@9^v33Rt<#dvdpbdVzt1uJc%8k74~C_R|Cy|+gSgui5p6NP1{oHHgQ$ze#FQO zZcR&MXS&0BM6Mbp7|mzR?+rP69BBNL*~omDSa>(Vu7c)oF$s(J@6jHg?MfiZyo?(& z*R-5m|L{*1c(PY>u20LX4YBC~Og|1YAG!v<;R1p5^V}>qIW5lZ_1>$BaBd%;lS=0t z#-*PcXLS)i5BEu~a@HNpR@rKs#|1ak*yx>~X2rX;FqxlY@Gh`^T<%)4mLD6v@-XXX zPb;h9(ZKN@-ZF#T2Qdv9bk-#2(TuGaBmgR2k%xVSRFXHkAwebMg+aYKc!Ae`ltnZ> z)&0%~p0F1Ltf!n%6VNsr{G)GCudt4Lz#G)kgJ@uEw-<>A96a6JEwicHUTaL>v`*J_ z&G23mi2BeBEt}_szF0G8V*(Pklpv&O8f(S1r7k3Fsar_fa;ZR?vW5WI&G|$f0Ib-< zkJd6@m1)e_oWYQq&KtmkqBU_}yl?jD0qvy4^sc zGrzm5euT5{?n+;%gP($XcUSuu5rF1`?00vSPvos=wL8wacXzRmleK^6-^4ZT_dGqW zbDwCG>{D4&@~&Wsf)z2N;&voz>yt_;s}>UU?(X^VoOsVKYJIA(Lf&6HAeHD{sZ zEYfmFnx_8|kkV-qsq;`**3P@RDJKg5(Hs;vhwda^*(a`$Sv`$|P?4OL}1Poxon zaf?Mxl{bmXn?!9xqH-Zosa02S7-(3aDh;VhgF3Eqb-k9W;|e1P3sj{nQ+bxDl;vt| zg^sE;sADiMRPLzSI#De-QS5dXLRL!f-ilsWT%1qCzwn804 zr8S^L-crr0@*z|CkgGK0nTl#sf^ivRRGmvPN})j2o-b21Z?2*$cS?;9l`2sf94|&O z2;CGARYIyHQ_+a39WSZWzDY%mc4lBmDv(Sdd5mPDWGj^n5hZ7$&5EK{c*Qza66?&N}*UOhZRca%rs?=6jg^H@R)>;Ymd)zDadm@lbAh|#afs_KN zVq~8RM3kHe3KKzLBFeGW5JiNn*QuZ|6%-0Nu7!-&LPqOM)SioSa?!3_v`ffjU5L60 z!TCbeUW#%mQCB5+Sc#TaQSAu^G$eaL2D3dDVYz}860^M&VTpn%<=NijSeC~7d;sPe zSZZJuZ9^K&JzL2#<_e8=-4|h*f+>6`_DUs%Qi<*UIAbX@mP)UbN`aMlNEKTs!3P;*G<#Rky^1C(nyP4~qPdEy^u{*qE^1}2 za6DHyo+}*BRXPf#9}1~q_vO2S?G>!hYN=F?S5hZ(~2;S@R|NMvLFRq8qsgd4>$sx zvon8y6ne%|hZP-^TOC9T#Vz3Y>nzdXGm)-H_|e(FX%Qt0iHbKwZq;6IcS)t1NU5 z7CNg6osvRlRjE@_>OEiTtSWW7OO;jV&}Wl>X-!U!?bB!^;=@-_u2R7&1*;{7CyF?> zS1^S-JVC@Ug}y`@X{DyyOU=H-Ee8H|rcje9)MN@ZnLIHm)P9r$j`^=|7TQpu#Znt8^TtZ5dw6L|O682E$aEZ@)%aOM83o7^>=UpD<$ zJI*qv5&c#PD#$X;fBn|oy7ORfD!KQ4WoN+UFzk(g0BM;;eRFebpH4JLm~)!v%WxqC z8(A;YzWu`*LrB)R-jA^tZl~$3x!L=VzjL<{S?+f}aJGlTyOF)N9pCO48BJ}A$@H<@ zKE0f8^@hoJX%-hNY-2RMgZwOG+ke|+$T{4a<8Z^Phq|o1gS3tA){rWXki`a^KzUNT z;~k~WH&Y$;kn7sb7b0*QeE`xlHr+hE8gl!e?I}<wLJGdV9MsEJ7jE3O@EqS|OaH1`Nf>Cs7jbc04o1v<7 zx}otg4%6&9SQiz@It-`D1svTF(d1|QBr&szzptjV*JL?#GXy5GouD=$vC92tnqaa* zfHk9kpRYJ)vJGZRd2`{WUJ8rJY@-i&RkX#J-P;0jbl-v~s2yzb#TU`GL?-=NUZ-}t z^>OUB$}_WVb9M2pRB<+0K3f|il(hrbl`K7 zc|OcMXzMg*pqZNfTSaT~{lPu8{DM~|n=xiS-L=du0W#U(9_tyS?QQjChFWK6d@Kl6 zJTf~JAimkQMCLCTpBo+X<*i4xjvUp;PZ|d~n0W_`xUfNx$+C<+M%CM5mC0o^eb?OZ z?(ZvpnY`Dw?;ZDF%0~`re;Gq8TD3n5kVpurN9^>C%1zdfap^+H47JfF*X7yF^G{l2 zCi@@oJ)W0DUhxf_7mO@$7&qPR4dTzeHpfhE7f1*Knr#oD(u^~h0G8{wjVZx&J41x= zZ)`l1@ejl0_U%+r?n2zAt!9^Y3{Bms-R_kN8qq$!*RB<;fcg%jIBeBft)?Id!rvHU z?_=KPp2;~uWK61tU-GcB*2Q<6sbTtQPtWgLRIeJ#P=+hv?E=@ZsxIu}DrZDT_x$#wCXWij38t(eW#u`{4BoEU~W+mM08 z3W|&Op)8fb$nw12Wc-?|GYjU>liGMS+13cE-s)7B)d>v1zMqUT8;k}YhvqbGc_<*S zsqt+d?psTqWVLZ_M$9~IVj&JTVt;Z2UL6Er8QP9!{Log|CaYO%+-4ZSTZ1{V3v7d5 znzo-dTp5_lnxvc4n6pJ+t*{eVVVib6-!S0j#(JLEt1g(s#+XdZs_r<9V|T`2x_<-V zJjSW-wv8|N<^-;-?*@Fbu8mz#kC!71a<-4|8`o2^U#+&s^Y(3ldyrBidE2en|Kt<_ z8Rum>8Yv%}7qo#+S;o(fPPp4yf5Ko)lAomz))F8WZc(;TZ3+hHv6+`~yUuhK9W%dA zuPV&e#rx?rywoRP7q_QoG(zy2ev`31ewtywJ$)rPRJq)y$7>yy|18f|8|u5|DdaM0 z?Bnrrbqcv2P5=4*iZdy}X^EDR3%|NmYQ1_Uzi<{*=}Z5-=?~+ZFtgKSTnF0`4zq~Ds`AKL4%=_NE-Cd1_W@&la%y5R!Z zSb#BYGBlZt`E$Rw8&jM~|1y@tt$AUlq~5Q!+8CGazG1CE#x+#u+y8MeT?fIcZvUq` zK>9W-uKt(?cBqWh0nV>N*#)j-S z_>Ae!DA^Fl%-W)Jc*;j`4ziea!1(=27jlj*8!o!Y{+t&CoFK~q-vCmzN@hKR-bj|Z zAhQlH;z-%lJFAn#SgN-TS5i6_l-OIb+#9H&sgh=gB|#bB@REgi1z}YM8p4=i9}UI@(uX z6w%H>BH{I#@TP<8uAEAb)>+^>IdpYnBO)t-jy zCR<^$@eev|46=N;`bl2xtgH?vGx%@aF*I*O0p!k&`eh%n+Zt-Q^Sr=fQy*(4%W!O) zn-S|h^s)j25ym0{TyI}~c;*Fjzz@I-4$GLlTP43OPe$C=`ITT$7>Gx*oAtLnj-Egrhkkc@J zY)OCl=ZST>?mvCIRa&lA!qlvr&3{7{7pmm0xki4k{5yi8OCQ5{BcT8R-hZNchkt`wAo6S+8=IWR3#m`!3$?V-`!#Glo-gfd%LF_< zZ3kWV&FHLm(UyQNEcLos6+#cI!cbecw0+`@Zc6Lp0kSXS2>XfOjjeh;EOzeVy?GLm zc3;=_)G+6L@{VWd9hY`mMUX3rcnMglV1;ZFz@^rPUHoyi(NajO)q?kXNyA>D)t(di zj(~k0Ib;AUl+sECt8@Z&y7A7ffcH9_@II0m(rwP+6PFJeakSZ)s6$0!DZk{hwnwlxyn(M^dn+X!u3AHy9I8pXWPE=Ve7_l%GIyHqN z@rA-(djvefO0~#Ruc_2Ks8qOV@9gkKmr9S>t7SZ2rPp5RwO7gzt@NI#^ww4y4T>by zI$idDn}ii*SW8Y}&?Q`c?Dzr!bL6_J;xja4EH(#yxutMrA=^5)w8wxq(&PbpOh{gQ zr1^Xq*YVED#^)~@oV=jbYSoRj7vx&48kULxWgE>#4aeG&rdyd`G%{sFNtKYa-2Oa! z!H4WcTeI{Li@&aG}Gc4p%Ci>XcY5MBp8h%PQ3?NjEB~RTv|f2sjaS zvlW!df)UQ*FLS-XTyJBZ>agAkx!wtdPD!CtQs|TvdQTQQH_QFE{3-Q%O1){NUVW+e zak)X2-p7^BpGqgN(kZF*vTMDNT5n~ocVMkoS*ulIFgOV(IxI(#sxdHr+|Ojp^@`L+ z)cbcGPJXJk@E(odM@ZbmG7(lw%smjpute3~?%fyaT8ci*l)@B3_NBy-UhFp#N|p99qb-+gE+tP(Z5xy4*y{RH zk>4tLJ&Gzt4l6c_B-K^)6XHVsL*{dpN@7|eWIaS8Hz&3#aY>Z-zIfcY*iviijFx#tx zc&`rPeJ(|EpXaYh%?q`pQYfB%E|t-~P?%fnUk-wOp|+@ylNK4e?tvWsQSzRsXr`jM zii(~TIm&NyRNv;Pzsn)-dy5<@2jopxkt0>VQ+c${zb&m$zbjR=QqfvN)uEmwYMYa9 z+pJnC?g^w<-@QH&hM9!O$YGl#-%3sLt<)snN{wnzC8~ByR85wo-|{5=c7&wgwl)2> zC0iQPz1I|F&((&jW>BJP(49Y2;)IIhn>iYuLwN zu7J71=UmNLDqx{eStwN6iE^v8xNRKHl_u^@i}>77;-0E-M|TqU9tHb&ukB9nai>)) zv))KZLS;KOJy9#NqNwH11g%z4bEwcuEp@oss;rKW+CZ4xy;_LdalpJgB}b-zm`B~= zwj6k^TQsH`jkxU^h*3VYV^j+AiS<`PPN%J4?2sbWashZ?AiON1&;SW-6L% z=>A*Nr(K;%4C$iw1V{zSL-_}5C#i`yTx^r6?93 zXQmLoQWSrj>7?3~w}5lKkX-G7TTfG4 zRe>${@rR)$TU(Isxg$}Q%1n2XWs5(hDoCX&!DX$szIy+*^_4y_D_w}H^xKhAMIKEi zveh>-QK_w~)gG!hdkE9jzNm%Yx~z4*QkNTEMbYcEMm0WBMGt5Km@#n2PogL8v8w1R z<*hY9Qx(m$0?PP^a^haWios-)z@5mOX$2HY{mLVXiF+n1%9^S(B<>x@c`Jp0Oe-~0 zIjFpf2xzSokZYyp;%VQA7Cg`wff|PjOGI>IF=e!cnXdcid(1ttaswx(PBs{kSs>_qMW@bXD`ax%W`solme*)Qpbp0J2FI2 zn9@9J?AgC(v1gkDn0#=c#NNI8ab>54`8;%kKN;+}PXEe>)DtG()7sHNoB^`E_?Y4n2O2g#BObZ8%IvYwI@G z3*11q&UAa7az#6PX@VDeJGsDczy0BS@y%_LHzAw*9d6>=#@&YXC4go;KQZtSBfu#3 z$J8CyB1~gW&1JBRKvxuP?sV}t_ZVUGrIq(Un&q(A+iQP&JB@>>@O-w9H#f_9ndU~Y zesQ5lC@a2P;I8oa^P%bb_PKFSC~=&(2U^@Iqm;L-(Uz-&g~v9KQ^|AxZVBI z`U~=ut6#=Gj0?^|qie8BLpzoq(PVIVVm!rA z*Re5js*F%}J(O}c#4UBI_1N5+<|gyGfkrF4L!8|1PYsMN$&X_*HuGZTkC!!ehhY)2 zVel)4Xr|0C@Yoz6w)Z?^XsuX>9?_y#KAkUkdza_O+lMj1(~P5>^b??c7|9XI)1%dJ z8;b6&@!(bt5_f>siK&xgTg<{sbHCbN;-%Vq1DZ^&Ny9uVvy3qbjS4?6^oCi)8KXR> zR!njR9>=Gbc~PpnSsD?t;v@_Iuxnss1>axz@IiHw17yf@1vES%b`td4NJ>2`Uk$9tf8Egg$Ji zSf0k_;q-k(>DaXwGktR%Fg}e*qlh&Nco2!mvKGsVS>|5l&Qo&o1m@=ybL`Yyjj{lK z%&`f2YHlA8R6x0j**qgnd1?>+py|a^dqO=+n6(C{2QCWycEuQ}K?&OE4*tSAy*g}C zwgYaq^Eh-eqC@*b8#1TvhcdAk)EPuPHyGA`^)dYL7GgAqoyK-LiP_ws4_VeSB7@}mVXxF4`WAsil zZF~r^xVzhM#M``r1kZ!0*x{zOUJv%jY-C=AnYGR8`VjS2Yx4WJs4PE(qou-c=BMf5 zMhd0zdO#td64(Zdh!;C6imNp%pH2+g_D8D;F@!Nuqe&Q*YoCY_I_1My1n_W5(~H#r zCe3m|_0c?AoaWdE5YBU~J>Ba-x(GR& z#cdPmEu$Q6ZZ*xzF$pg40k3#)T8OvnJS=^9S%L5m0t}wK4(Da9No&OGX&zf=Wz)fE zvOJZ_#x*kYU>U$FYoS4toWR$VR@0tq%GMfVO>~SPd|SHSFWUGQ5F2f9mMHB@!!{IB z#smXj2gUgTkup#zu!iW$)1cCaW6Vrbw0NCjeOkv+$Qf&r#x2#orcK&<5h9T~Zm`;C zEpq>*dEQ8X2i|BOn4Xl=LMtR6pg`%&x^xE!4KhVK`eIhJDly$?#g5cBoZS0RyyT%* z`@^hutm1WA{xaBbFisy_%+Q$C0w3xazAUFnX>fNt867{06_?dC!CGc@z_@#B7I>Iv zrZlaVX^6dr>Jb9kU*(FE#JML~1tIJe2EzM447YY8&exixPs3&cFzx*`28`gu8ru4R z%UQ>D54z%ff+^E0QA#f)U~75t5JAet0*OX9#4B!XiZ35nN?Fr4pxDmRC%S67uMTZH zUNEEuCB(nF4st2nw}2x>#y>W5;G}82zWr(xgB7DntL{}2bSJd6yZW%;o(_YB zxU*CmO-j%%A-k27FbH=MO*R8D-+kq#%a=*1#)YtyO_+}tFAP*Wo0Qr6wxiO%Km(1f zULYot@fT+qZs$#NpdZX&C#huE^hTlgk)JfoESy6Nv_cG_emV8_5(wn-^+?rxn~hI_lm>0Ofo@f@$JUb{9wbc^3bvfLK3t68-? z5AF4?^2%H!AA_#a3_VDL=l$d&bxnD+XxoVbzs%hG z#BHVMGg!yTm4->Me(Ffl4DLs_)4T?A$~7)yjFh2CHFG{n2Q3z412%bZQNS8)-wBd? za5iDf>0?ucE7HQl4{7S#lx_ErO@HMVvN@ixU{T!W|C;Xw{bHgVnor$LK!SYIAw8Uzo2ikR$!2x)21ini zsKdir$4i{(oC>UG2k?MMN6j!QQ>l^N+n|&%Zn)b9STw|5odLaE5v2HJ&FX6Mk488d z)};5|^kBoWJ~!C)X%7uh=HI~u9)CT$HB;1=7~ok9`g=1?&1unXfHm=I${5Uq?%r2h zPp4eE``ZZ0`^#7t+8E-Wx_7ILM(oUgzxmRvYGeo8+pAfQe=3tW8|tJhJnksR#c5P~ zkd<9rqxW%BaWXK3s}?S;JM6M()QwBjNnrVPci|s3_&q!`%*%$j^s19|O;L8cJjd1T z-Mf2zTX1>shhHL-1vF7}-f-VTV(jptlONZgcOyZK;_$hTX83mY-5*Ys>9RlCZAhi= zHAaQ1$nT~F_x8v&DERq?{w|EHb(ySeB9G0}`NTN6qZ!WX@(p~wf=pv1TwFPC_&Y z^PrW1%h$MKWAC(`luf_~V?%%XkwLq0YENfp?-12wj6c)q-I>j66F3)@&3n^9qdR*O z(bS!8{$+FD#nb2T@T&_(3!Cqn*@HR&W$GfX8ZS6-fTTC)qWy*m&`|#%?4i%t)d7G5y#KV&|ej*26&&@y#~vVey#q%OYCX~G#G!hXJ)&!rCE*j zcotH(m}&j15uY#DTRb;HH0EcV?_oMh_xnJST)2mIVkmevredSII_CCzmj`R$Npth9 z>y`7vA%{WI7+ zOpMki8fkM3Fmvs>3$-kF;m$FA3(o{9YjcxwZ1r7*FEE7t^I}v_f3?k@I!UIPC5Kz1 z3qp9Pixt!vb4sj`A9z-l5&CF4?WEWG{sd$4iYP05gSYCW|5(q7kIghGmG-kdPV<24 z;1R{s#UH2U1<=KM$Zl*-N1~fFR#58w6{ar>dFDM}2T*|RK|Q1PP2~HM`d@_JU zwjXCE0G!LC9)O8KJzTw}e0Ma)zRso%wBx$^}SfF3%z5PG2E;v)w2hX zFb$H^-Aztx2AptHB+6@qPd5{6ny_Xqta~^i?K0Z;d3iOaofCR>D0he9q<5l8SSQv< zSm;C3ynVya8>>!{X}9Y`S-S0Tdk!P949Qaj?`du?Mh0r$Q@m2-MjQZ145TrT#Xued zMGTZNP{lwU0ru)21vsVI`?~;=7)T|50M6rlMGRD;8vw1<;w+_vs|MQZIddH@by%-1 zd5?%q^vdkrMMjprq9}8gafy1(X0a25A?CHYXtVqWVUsP!(XW4h~D-PC%Q)T$`# z-}gG4=y0mTxegaPyg^B&!)pKS_v%=|n0K*73<*Wt6BuERL$!dCcM0t!lx);%m7+hv zLT6b>l~g5?s#MmOX7QX2V6j0&s+3tts8-6%wiudkCTy`HPqz}Xt%O_k}#@7*rUHaSz#w1<*oS1Oi?~OEH7t zt2R*yd$nAA%qB*aut5N4IuZD!jl>IuxKgdKQqfvNlf5i8QJaxuB5Vv>IzpBWz9uS} ziApAhVn>2@B0W|6BUPGVr!tWgsErWY#H%f0Z80GdcK zD^)g^YPXjvmrG@2mpm%YPHB!^fJnUBklboRu~cezRw}b9m09(>DpU1dhZ7xE`BBIQ1(M*(Eb-<>!+*-8}Rw-C5F~uCfV673BDOgE;-vDU-oIF>mP-f^n zSE|UB=Gs9XVM=rDkc_Fpxi}DADAg89an>-6Fr{f_qF_pM?T90UluBDmrLCn#T&=XV za4(>cfr?L<#?p+@ZKJHU#P;eK!H~C6)>OeV1uGOxA!lE!Rf{NzyXdQo$!lY8OrtdnevoclaN)@`?lPU~Tx*^k>kEXmAKYTJY`c`jHruRgyP?RTnRz)`S znI0p_O1RWvo#H|vtx%X%D9kT(0Vs3=iVXsku~g~;P%6AB6}n1AD3qxggStkTLQbWN zX|-3fDkN44iIqZPrL$9?eDUE?UP7%3N{Io}xe0_XSF(vVwkX_7mfMpt#!MAu=PFvL zXsM#LhGuF@vP4Bw6;&zARLU}yvRtJhS4+tC5-JTX)N+fximELx)D{a>t9)H*G-3Mwt9w$iM#cO`2k7@H;Et)A;l<8-ERI@36vl^P-Oz^ijj5OP6p zE(Ae!-qg9`4X<;J(YeOxyi&kI8xfj^wJu(5In{;6rb45w&}dWLFmEBx!`-nQ7@!5(=3<)u9gPv72q>o~jqvRLO2cw#u>gRS>B z&4faCJ=inGoKLsgn?HgF3!vGz@n)K4kG1`COQICdV`tAgUpfdnFrggY=0`i;W&6iN z+cz--#v_g$%0K{ZctT`>5%mV*%V7<6RE6 zh{!9_^P6T3H@0H)WwH06`)0&aF@*6inz1=I7)TjM1l(Qck(K{@`)HnLJD#TLIoh_F zZ*9c=5NnwZn;8QE(#FhlcWffjSl-TKJ4M`B-lpbY+ii#a?mzt#{2$^SVTO5ZFeFlc zc15}4$iHZw0a!yfOtFdz@t(P?7*6(`y(mR=3q(k}apgt(;|t@uMEc~=96xPQjQB%y zdTV~zC=<)*urnSs{_Q-qme&W?qXx9{@T-vr zvHm4S3bhbKb3Kl2`xyxy#$^8G8bkGK=zbvhcEsxgC>#Kt+P9mHLPDDJTw?`Vd4@Zl zt<|iDwi!c(e}BDVU}J#YRUb3;e6G<;_<)Bz9bA*s6thWO)@WuzKiYuYq0=biFjlv3 zzl#j&(~#N!&^N27*a+V4q`hO;j*NGs-A;H)`&YxQ!#4Myx8|;sZ6+XcLK~+g3ok49 z_f3co{?(mYE0Zurj%Jd;z~5)QT1xsBVsU!)#+9>4!^!x!$F{jOE_yhQ9oAZRImL

Bd$AL5YxSByQz0>6`PTp6$0!w0af(5&^Q)uG4B&SC5W5^g*MTldfp19lD*cYu9< z$BSSE?^r8+W_1EvlSx!fuF)ZAy{42PDw-44ehIWtwhzsCLJs;*SNzgm?NH!CKXlXE z8bKe;j?WHf@xy@Ep@Y$N7~I8@pksyw9-54$$1GZy8E6LXt@S!Q((Y?o2r3?>j?L-G z7X~}l;IX;7+hbuGumgpform=*dc4*FbdFgFkssIMI^c%?zU}74YS=j83vY-p_N`O@ zB@jm}4x8rgm9VNIL78cJG=FCEtZCTeRK<``{=e?NV2C@FId;hD*x-SC?HFQ&>$cGc zKq4v#t}J;Gvlet`9~xn8lMay8_(Vdx$La_%<}3u2cW(Ftzs!rp&h`oBlCNiz*3S!NVi#%Ktz=!JuM7yK&=PM^RMt?%tJj3}rh znAVO6T7XFapF>AI0Xz3PC}*R|+I>LK_sFm{_;R(Hj`~mx{DqIhz;#Uj&O;-Xxa|E0 zGTnsdAL8uy1GdGh+oM@0$DFwNxD_bD9*J)n#k88{uKUk>V2-UeKW&`HwZ zcXVa_Dr61O9tn4BBn3uwduqmLz^+&@ynJ0dCWLDyau?n=8WKG9zcI$JF#%8p+8gS2 zZ=P7_tw+lzs4trkfcl?0yZcL14lW!G@q$T=5!=Jek4fO#5cW+j+#- zl4$SQq+pJO+S{qQ;Tnr^qJ^L#ZR8Fl@hjcjh)vrOs}yqutrvRvvT_$S7U{?Cv$-M_ zWb*QlcJN{3rawLv`iXZ(R|3b&0D9o5h{$4We4NvMdv34TaMR~r(kKT3_xJy~8eUyk z-j}swCcv6_!+AS+1T1_Ko?XB{W2?)659!f9_KHyhp!c1G^(esp_B8s>$uxKD+h+8J z7`3oX$`vD`ky*{4gRpB1_|^J5*mV5D)#x8`M1@CI+`QrKnH^dFnS4VuEg$>msePqr zZKzAu4}%eexw%No(TK;1>NVPWljPZglV;StgJDpEGI8=F3v<7^*N6dIfxhHX=Y5@)r*ohu81`ldhzlbu}f&nlh z3uzc1optIFZEdvrXo}EH#v_CoeZEc0;baJ%jBc3TZUe+5{V=UPF{{kkqd{VEgJI|N z;4TbJba3v_$>xQKx{b)BMtw*`Av!;-GkKg(W*2)G$o^RnROjRNJ2S*XvU4)zwG3d$1Bb;+@ID`5X^C!jB6Z_1O>ZDE_&!)LeoTrpUPe?&wP3)UF;?zOZoM+XYsw^Asf- zE86Z}?LBNfgk2(d;Yqpzc43(xraGeBn8jk2)JBrVc!SNrmyWN$PwN1uHYa&V ziZ$C!v~17Ez(_wKl!)NTtSA=Q!Qp)=!X=|R=eTqIi&M->J$3%|1!_HSU%-k!x36M_-2%FqzJ{B_{U_;E zwhvvYR`!}?pR@@scBd!T#6OtH-Yrk)a%fF7nsq{D6I|^9G3a?xFv2S0t zg5WfKDbY5V(+mHK5Mo1u=~=$uXpf)qzSD{h6 z?b$5Y6h+te2ewa7-C-?>I3Kir+kX;jh;}duq~)Zcy^r%r6atGk72%UuZ?b2;+`_T# zuR;G&sc+qx3^XJoGTq^fFenY?UL+z02BS)@-e8}}%?M#rsfC$#<{m~a)Ym&MYUu){ zHKKavzIFrQN|`9c)kR@e3R5fAfGuuLg^>=?C~whLSUW+Sc-!frDl^)D#3LFoFJHJj z+?vV9zCuCu9z1+vznX=a; ztT79PLBpSkBkQA(pl*_VG>;p3DjHSrf zpwzTuII+cGjOfOA;o?&Vg|tFG;&d>AKe~^w+b8FpkFIwIsDcS#z}`PhLz>e!^V^6E zPQPOC8u|l9O=KB<^n-W0L>z`EXUJoLQDS&$l$G|p#hYC)dngA_4BXmB$LX3ZbOX~F zHuKvBV=M=yZ4*#HSe>8IKDU9U=X?=^Z+&y{jEXUy**^wXc$lKrsDSOwFkmJc2hr=| zg98&CL}Gmcj{X{d^6Vpo5>*W0urEuOn^d8!FDkm-I-=9qYl?mm_+b`-b5#G*`IRWO z57q>?p~@2c3v6WaJ2%EsvXiS@3!dSG!NfIzGvUqW|`O)hoF!x zpExb#cTD^+rB6TF9(VE~oDuBAlJ_r=IqBRydl#S6n-OPhAs@Sdel^rci`)Pv!_Y`y z6MuLz=(Te@UOxdcE+W}UmkXx6#S$obJ7+~%_#YvDc1use4Sj|&ljGpIjI z=R8ByCwVp|))<%6Hz)syS?DZu>2mKbJeC=btx`kj43D-qI z5*9L8x_~;b2<>ESqMxGA!{UONvx(TQQT%BS+WBjWpX^C5x<@=+j7oQ06QA&G(eMr{ z7r&O+S`n={4G-&4F&%6Q{tMSz@SZvManMiRW5%@n8c5eq!Neg8)56@`A~K)7Tdfh5 z>kU8et0Z&Jn)qUlx9PY^AB`^huBmsC68m?};Or8zyjpSfoSRK)n~!MHiQ&jAN}fuxDtt{{)`8;>9_&MU;EZY#}5$>Ygj$y4eZA0MY=d{&y&~Cs&V=SwVZ+;PCF-6mR#M1T%!@ zxrr2acp{iG8>q*p8zO_`(n0h)dDfpk9_*O1<7L_mQsv0~azNY$C_i|2@hjMPIeYON zzhpXrhll-i?DojmIaEJ=L)Q32GrZx@D#K&Yb{O1}i}*8$<#BQ6WXX~AFa<%XSwEqv z=2t%Mm1HaM(HD33+!K|m!JRiF%yf!~Dy1Fs=U#?K;~baPfqn6hs79mHzq`za4%=%F zGPh<{_E0a2znMn8;FslkFjn6zR-Pt-n{Af`_I_7)h*xT~-xeHO&iL`N)}0!@pz5x8 z;vq~sD-@rlw2w=-cNlj!v3+e{@>(%fP2;M?wQ zpOfGDaE##HW>=@r*Bd>N7 zo%n|aM?LXPte9N7{h^ug_yFlY_Q>}-qIO(Y{h@VlV9~FJOE{nF8@j&!tsP%q6T$&M zR=nRs36sblhG$qRdujQ@Vmfw?FZ+G^g?qe;|MZtn{o3WzEvzGttoh-dIVX#~&*owN zK{E?Ysn50<@pXhiuTjtEfowmK!Df=s(Z@^*1K(OEt)K1sR<1l_VG?F1XKuOj*~;4 z&n$1haGyQc?R|S_ZbQ7I+_%yBIMtTv-EfbF{(64jofjk=Q;+u9lX z6+1P`CXX2;wyA+e!svGD?baH_evr#}r0+T~Ri2xRw7%=*rXCzl6mDX8li9n=ymzR_ zcsBGDB{`Di{N2urL(Zdm-y`wbkbbr0N^`phg%%%s>AU$D-r!@iiAjPhsI&9J(AB0* z3s+kL{za5vSWp?<*%W~u(9uy}kXIw4ZXHgYWbA+^nw>){=+S;~=V6IP8*b+pE~Yh^ zYCAYtCM&@Z>i@#5;X`Jyd9i+6uqv=cb+5)|$V6D4HOU}QOK^cwDqgyDXL7c$?-mK^DF zo96e2{i-dTT_p30&Fqh3cxR~hqq_`W(QOBdhg0|Z{`u8R>+@xZ&J@9QQX`eiL}%?Q zoEpbBN5dA_igsx36Jy-q9U9)8>0?LkEi(CpG;PtywvSPBX**-Kh)>&GCfZjhPR_Si zZ}?Nm-th&2$uwQi;^Xa}g~!r@&&a!A(C4I|;=MLgbTS^-0YS;FS^U?e+YLut&=)U> zMmsx$m96Sbim|dLHltkOrD|~r@!Et1fY>A;5^_Aa0}Iqhx&jYT;M~z)zZ$wD z!m$PvC$hymP5!*PQ+`)uX2jR_gonqd_Gn$y7`BEsu(7I@lP6cWi|^h2ezv)`*ZZZL zhz^UkIIjK%1)q7YzE(+sY3vwp$b=*BSF<2Lxyx0d`(ch38&oSg)9rG@YhzQ6Q8W>Q z{zk^z&yv$VD)if_)G+2mGmIuNJ+XL^TW8 zNjR^Qx`&UC#`an`wR;`<`BS}`XOverhbSc$8Hbj!hYE$!@XgsyXLJp6b(%lggaW)B zaYap(7_nQV83U9E?0i2@YdW1 zcS(yZ!fr;u7N;1gZn?Gn#INAqyd}q`@mu?NWAS`KH?B#G8D9Mm?F2%K--ZucdR$GJ zOt}6bu8SKEEv|YE3IR{3Wn>^fXNJYGK|j0klO9|(khq0T2Z0$n)}u&PcCL;A<3EL| z`Zl-+*QtedgVVBan{m%oLZOu^G9aRPgD*(>-p zDQpG!m5QI3Z>vTy^$i#$DwJsIBVZ=LK%>M>3zV1mqMYO9;4!1|ojE37_J-htW z7S}njXFCa>Dcq`#X0MwWgiaB{V_R({U7ZzqPf@5OC8)Q3q@KMuJ!N#_(l;9|Yi}$V2mI|-5Lpo4A?%oQ z(ZcEuF@J|Sbl}}soo~_N+cnf3^XC0@hhzJG#~`@B3_eVsbiD~+M{d}VEfnycO@laP ze+Lif?dG=*p9r=}M&h>N?1m)ogCSF$N$`s@>;}_~t=kJEOXquc4{l#FMDJ#uH|dk5 zO~RHkTX>zPgQv=$>9Z5Yn)>j|6F-6sj{Om~{+u;SrheZNmlQ4wkwJus^0u8z`RdvS z@3;QxU(ojRJ)SUYmLJCS1Br>o((vgtwHMH#|D5AsWp~q$c=?VJ2}jjKFiTRZ!Tsp~ zW(BWFlUduHWEJC#ikzOGAVIdMsRy~;NsrazvvEck^0{HQ0hP!(+tv|I=;n-9;sok;GZtB(5?A>UX=`-G$0rY{e{9?Y@^9|gB z`Qy^z+SIFhx>-0j2YEHvP(5|AA!9*$Koix9DR#04_}Qphz5QXB)2SX5CSL8Q2I=U6 zf4FtZ*fuVzBrn?F?#8Dd5Mz5f`TamVxHg8Um41xCeFN`OC6hPcil#A=HLw|dZ12mV z?~XzgOo!n13ZdzF8}RrFRgPm9uO|Qru0($afUW2tkG}^`M+z~>=@oQqZ!!lt6yZ2W z;sAup|HNfX2QmhLd&V-+of`qUIC|c;jY@*pqoWZ)?n7L07e9>mKo=sC`l)XPol<0l zOwphjhePMW#|8Z}4rB*}C^?U-SHj?O6S++8Y+!($oVjTOF!lWN8s!cbN`deJPAzHU z+NOAcmC61v9(A7pEJrY&kKxdQI~RnOThntD3XhY(b;Q}Pve>xwLK^!ZobQVqUi6C~ zNDJeT!``qZ!*d{^w}^_s(;rOjj>8Y+iC5p_@N9Z_#+~)((}y-7cEq?Gy5qp-xp6r? z8)+zwcsicjU?bkoiX4LDG6xSY&IJ?BgXApjgd>)pQ!ywmKiJZIGFtJ<&k!)f$E2fu z7Qr_cVIeF)ruBw~I2#S{Boy9?IwOpXIKYi~JR=Y1gg;|+L0^rtlnioi=Ed6M#NaZx z5kNCG{~FBUu8lilB7VXxvtrF=qZDyw7;si%%^3V{v$F{Z9;TRyG`fbj2aI;*M~;Qu zBRMi4FmS`(b2mA5VSvs(=tW6W^z%ZFdig5vNO5YKZFDhgOGnPao@~( zSgu-aQt)H?4fY);4sv<-Z%`-9#{moN;n z_`FmhnpOIzxP5ET+YyaUUjSU-2v_B(va(6RmEtS}o8YfDku{l35zBgZACfkI&F+rg zh^oJ^25&SX?4zTT4!SSn$j=u}W;|dp#F#md;-XYD6U?Vj$FQGKA+(?e`*FUFqxYQ#pvv_RyyAd2o@kzbrahHmBu?ZSMuTyMe&W59cSD1KUQQK;n= zy!%t}B$yeWWgtBFldZ|71@0G3F4PUvb`PUyyvn7r{4grvPa%nH@l9sh2yQcPLp*!r zR+u_CVQYpk?89)K8@MOqd^BDi$PhRy;w@ML!<}cwJ_C9G18LRV9+&IXUZUxJZa)ow zF7fsGx&6^dQ+K|!{xTTh%6u8FO{@_ByrYCiro$RJqq*NBH^qE`JNw3Jj^`|!VApI? zlIPA{GiEd5+!<~j=+sPT8xGA8&qW23Xf_198#zRA&afvCN15e*4R^BA_m4cDLj-Rg zTM@>1hD({g5;_=7%Hu!|nRyF!#^bTXa3ONFjGe-hWxU1PH}f(DUT3ED55m?La}fpu8T1$OdCvARdGCCf-;3WmQnn*|394iET!UG3P zYdjifFHpO{pr5TcbLF`IfUZ7i8uQ^-(7s0=jTdV_#TsmMTETgq!by=wcy*W^JqQQN zf`zxLdu1brK1RG~?j!~y(Avp~3Q z84BnDBddSLOlB7L11^eMU#>R_jY5epU0U?k61TV)l1Gbi3B#K1(H;(bk~D~V2H4C) zlXX7OW_l-n*zS0+@MOV@@Nj3hU0_q{VWd2Ts5<;z6WW_=s$a7NHb&t}>CIE`Mp{2s zkJm$rV5AB2k4tAX zz|kFyCrfIZq7A?`0i9cv8i4O+ZL6KVw5i^iNv+B?=>rXJI`i2CmU!A z8F(6apbfqdGKR}(8b+=R$?hUug*-rZ!t)~lwGJ-_0U2$XHGn|M_5(ft_WCy&5G z66_V`Mt~&0;18n#&;{@9v1#-{0>gMKalI|<0!Lv6G7k^3d05N_Z-3-9>)5f z_X%QZ=lng)uQp}lh@<`0^$+AdT$}wHAnv|u>bCyClGI|D09~8Vjl2~CP$4xg0F~x% zcWT=mY_*jw-(}ycyl*UREqlRw)uP$j96lAT*3iP}vdp`z%pcbl6f057E;nmES4MV# z-x$By8nAp(Hu-3=RJ8={tf69|>I}NlgB4w2*=h;eRW%aQV0tO(PjOPP%YeEYXTAR?F1zoZ4bJrC2-9fW$ZnXorw852~c@t3P*w!C< z71rwvvdthx1}QU0l|gC^*?d@I+N`8<37}05+Vb_2sBCzfN-fuW{?&YRJ9CYZpNkJ} zGg?Aso&R>5|F+0M*eY9dX=ke-K$bBGSi6xpYKam>)Ej%YbvVp zO{y~jsq;;!v$5CN*z4@<)EPC_+0m}E!&C3FHPF-8nWulZ(mxb)*kyFT%VxF9=CG^G zPgyK+oe75Z$2x;-GRQWA6d9z!e^ZcpLHgD)|v2Gud^|3vUS~L4CFZLcMV4a;0_iu{K~^16E3o#HX?OsEi-vn)GIsX{npl zItOiX&^8AZIjGD*nX$7mNN=+?R$;v{V8+U3)XGCo#+sE+l@)W)XN#_SA}ZBrYox-(|C%YVBihZHxFws=w%Y^5%h0k+l-sx2Ph z=)nZYHg_BXxZqwgUhYeMqvT^@6`7O~yv4)LE|CFNt8c&CwH2uKE}u5?i}a&3O!*}g zUvY494&)z`9dA^n2fm8y()dATz-sYTxwFV#%*kT|6mdfTP2-V$p*xLl>qd<~+hF8c za=gg3(i)0MBm4TU$0{d5l$S4-5k=>#IU#ElKS0hH;u7W9C5QZ$kkGPwNvUY%@x??= zD~Ix1yH^;8v{=0?A3_Z_FHRG?G=OHt-W5eQ#zLJXgQbMWMqN1?^D*F?bFNCYuT?gI!k!d#N`00$0WB z?LsqEAGrb*MA1$0u`ys<16CNY(tuS4Y?s4U25fD>Hu;##AmtbhmUQVNVH*Rsm6D3C z5dl*ft#>&L{c1Dg^H|@ zy_WK~nlMU~wpQh2K-MxD42#22L)?jqV=89GH9+wz$A+RdCI11?`kT?LH*zj%TxqRQ zwNGoE-mS2t<$SZy@QgRI04eRy5Vp#m!vIt*lw2JoZax(863Vqe39ftHC_>uUtHT57 zS&A6=!A4IGg*of8kneN=RA$?44D-u%58w9xBZ_qMI zQanN_@Bk1iYk3QGF0W}-BG!5u2(rb1?-C)-w}Dm>t^R0`kb3XivJj|V!2v;btKhlR&Et<1)l z;AU%ESAT|w^chS zl!FSHhed?w?GQcfGBPhd_}s+|@RalAEix)2mq_3Qvg=AyV5Wprc&vlAC>OQ-_=Yyc zv478qRIXsTR%OvcbRXb0VgdLBP*sjRNAy@`D_K^r=@M0EIfLrs71h((eDqrJm?wM0l5yvbf`sD*IKGf0)CALQ8jgO;P8)u@} z14FawU3s`=8Hfl>c;4QteJcz~%1&i`_E2Foa5E~W6+n) zDR;Tp!00dCN5wOPv;E(FlxA6b*=Y5lKeIz=Zns|Nt&1E~y%MOq%|^A}QHZd3p##vh z3Ei5|(u7tfv^JqTomNJ?NpUS!AK$zR2mDIDhK`TQC@BGaU;45o>XgQ`d1_0XS zQ{H^2w;b>CH>4EN(=Y(6^T!_m-WYvvzuy>Cd*tUTqnrBsRoni?6t?RO&*rl=sROkZ zzUKTVrFaVfkcvH<;=f`~o}ig|R)YH|byuwNRVaRg%25x=`Yyhwo#HoWt1xM+sP%}; z56(38C`wI4rMc*+ZDi1F3R(B1Ns=Ys6tVr;C+d8?s}J=$4q&U_8cLPZTGnJ^%a$2lDB@xhfyoh&{}clS38Rl1}PUDe+>j^lPFOQkw*iy5CyA#ONi9w zfkH(@@BmakUeoz*WiB!*A(;>&1S{dJ76$Z@s_R9M9E{CBb!yM1r z_|)3?)Y|yex;9|O=TQ!)k5c3)_h9TOc2vPOISl8LDt@YCNwJbVj(A70k%`n*k5%^A z51_UD4hyc`QAlKl)KSnoij+(VMdY|BazuT3vys2y&;Qi#xB2>@4@R{s<#4l6R`QGD zKvUS|iW@cO0oKJ`D%3tUwuy>+d5w#^r!H3mfu7z<{P{;f)sMC zf1J%suQ&w=iZK?(UZV!|N=W|6j{1qQrKK^WN9L>^RC1F${?G%`ksT-wO$?d`+T+(bRE6;JBql@oV*UP5zpmC_B=pu`DOZJDI75eTW<)%sUnP5H6n`MRUuJQOwhPW7c+|yiw#fwb42&HL2C= z#ID#BQ7vEqiXLIEF9*{*vd+E2I|~0!V@Z#^0a6vOmUmUAd96wkx}%~vl75e2qfw2iQN)J{>8A|Elml+NiT8@pZ<+i~d{ z)~aFbnKz_u_RxmDkBr0sf} zjb@z<|3$y0*eEr~I_sB`EeOy`Z@^~zO{Ww)mNRgQ02gb5oFShEV(A>qjlUXlT@8{cf-1z?eX zqm((q0956mT4mG%*v|OE`om1#D1RBeBPpRH0N2^r)*Go(Q%oy(qn#@f$Kkxe9cS;X3IDm$MSplV5- z`U|`7rn&uK-+$L%-LBR4Z8xI1IiBs-&~DVAwuP}4+ftV;0breN>vs2dP-yXyEiCbR zwJpI(A|{S}TmuWal5AbbMZy4;F5IVvxBHGUPzs22N*KQ{(M|H2&Pr?Sr5byB=%9XF zS!+~&Ph!=F@x{s-MrG}LRjNZJcDt&KRaauJtiC03ys1%Zj7}0YzFN{Z+kxL8w|c8s zP1atJVqLsE&UN`3RhvtpMzZ^s!|rVM{%ZAiFGoWW>Y6h``T zIw+HMuPBsbE>3`Kjp?kdIifVyz)IGc7ReX^Ut2%geg7kr)DchR6|IIFRZSsPVgzg< zi!%bqngfbV)(fvAz<-V6k*^*~K!Y@c7a|62@2Z#^E++H`V5v#?UHz(h6$ZYeV0o7N zJ(My8+|JtnV#Su^-8eeM+E}1q+ptfg^m|3TMfviT+55}wDk@`rP%;_|9E9Sl^AS_p z@!n7v@(n~uDEiN*LxJ;r{-s6Ncs=lHQK}D#WhXalx>Xyi=22Osgo0B|MGP8+r?HsT zt5vQ)z8^?TdMAMEyg&tjH^!3H7W=8K#od_$RUHO^Hwj+GdPDp~+| zHInyKSlwaQA31f=xeQYO+y*tQe^*Wg~wb)oIhW46X zF%&VP|5BHC&@@S}6vU5eW9*xqo+8EVWxGKBH#K7NUaN(s+@l)0vwA2@Y9))6gu*;}nSL6$7&1PqfcNeakiM11O&8sH?V50$rsE$OyTGp0w zAhcvebtQnVR3Qa`t<9DjU5ZbRltSmzz)%FJfuR)8z}lEO#fkALpb%i6Kmnk7L;kOU zg*7K+^t{8J)eMaY)#JwcHnMgN)GI@IZ^WW+tx<1_@7FvAL#H*O_gd8Vdo~xk61i8i?)|Ie7-PXps1&GIm&U>rcZT%F94JjqB(^KWJwx znDggYx*edObE-{Ar9=RHYNo0Cg#MOw40Rk!D7RlN|6wW* zhy@i8(NQ%gC}%+sT3+6Ys=lhpP{pn|lJ~4Zs9OD(=14YO)3;D*cf_(Oe{gi~)&vos zlj{WwHgTf$_@CW=`jRIe+>Kaf=s%t`%PUsQg&~tI@uqZ%YYh}#}H}!_oGAg z3K;C5nZmdq)IN_`ppH}7c!1iJp~U^|*gc0c@`2GEl2kR3k=0~kXK(DONZg-0qWdu%hVZxJ8|M~@AgJRMxbPu}!BlBdZuW9q_MtjfY`mPN{enDD zY|!FP&-`MmmwwKde;M6*F^>8&#?uLvq!2dERAw*==4BqP`$o_2YoKC&%QzA%{t9)X z;=1me{od_cHl)4dz{mo^;oL*X%rPC-=!ojd$*;m;V`##{e7j030npgr>2Fm;NT5U-eSf2wSh}3(2?Doww zRr-t#5fKVEOAXZ_LeVgVaJ0hKfjJ?I9ZStN4#WheqGZPUH4#Vuu?gWZ}~G%jvEVrDcMB0FoR_S$G+zi%Da=!N#V zZkSUk_QQi& z*a-ZjLUxQlAoV1cCiL=sHy+N}S_AOyFbu?o#F<50I5ts&bi4{Pd$pYy60``ae%t{7 z!dTv@{H?)5yYa{hq06zjIX)Flbko&|)QePj_uZq}cbA+?_U_QUx#hXy(frq82}w#2 z70>gZ6IBPKBPbN9Y_S1;T=4T^Nda*lhEGjwk;?=FEg1*l!}Zc4 zUlhuyFQ}@h1VFjhM3!FVriI5q&HK~ntVwA`M;zmdsGYy_LrJaZwErtqbH0<#)dPc= z3^5Nvt8J)iq|})rDpvH)@nCJ`Ck-&yVYwZpuH(RQH z+szAZj$~nDdqu$*dGXkOHB`D>H+=*pgzs0Qb_V#&yPB(Gd+}#c9)b-uF)dmS0dUEW#VtARfx(7B z)FuVYgX1T;r~^#U*ODY1mxCJIavS6ZEVt>=AuAN8Y?+vjAc@KeN7A_j^KqQE=?Ccs zB%sY9mvi0+y2a%7EY8-y4#Omwk%Kl=$vd(8g*^MI)#&W_ODU|@CYT#lTpVsWvY||f(L9Q zB{VUFLwjtbV(&whW+EBDCISo=1%j{Paw6UHA|jn|xvzi>7I1P7C4non7X(LoVpn4U z6NCjHE@0wG-CPI4*qe^fiEol52tM1`d8} z95dUK`zLV$hd2@!mC!RdLpdXP*FCz?MV8Oh2Zx~>WkxpVX3kBtcR@q02`RuykRHzM z?c@}>MqctbFxfnEYdttwd;F9qFBW z+BpYutR#jL47LLjkV#O4iibv`g59KMJY2d#-7_4{puKhti5TWyJ9Og#9D!~bCrO%4 zsc871qF)FwZSd43-s_<7o{0--fL){)mg`R*9cjQ~{?K4byC*mx-88V97XWEj-JzF$ zYsM@+#&lwNRJ47iultbxVk-YTr_dg(n0eBNpm*C5REGa(-~M}P7wWP5@)MoFcR*ouanusp>F&ql64V&J$fIB$eGB@L=WZ(S&@rKzdhFf{IOz&}5 z-qR^bS2Xw=*Zea#DG%fczLKk531**X9nNdxw)s^dCbgDi5y!S1GC$swF9I9%er-@E$V z?Le-N#6yM~-L;C7J~$cE_fI9)=ya-ifu~d(6PC9N4+?A^QP8~%l)s(MJ9;j zuO>UcgQFyrMx4e#&zr1e0dLl<%?SVxV?;!S%dLT9^n!;bSFpp5q)iJ~`(c=_gVuoD zPBy1jI6j$c`rtR(SkSotj4IN?F1PvlHZA*O_ti~K_n6*p!~Aw^KiwHZ&lGG^d_$@F zc4l2*h}`>faO;q6OyzIJW^OZqG&-3vCy!iG2SCFXl{hqW~vwnr2|BBbsJk%!Xa4*n|1J&!STPjZAK zP&>PG$gsew&s^DcFsC(5I+)dgmwo14a?mfjm58z&V9~l0hKI9+J~1SUJ4uche5E(# z2w@yIrE5AM#jo&@O%(ISVPB3R4=Roopp#Mnj?IJGWp>apYJ8Dgxhi%9e%E|TMdsKx z#~1M!#}=5m;aWMi?fvX{NG21GEqRoq*$9xtK6L->Xxu$`aC8X^eaNHY0hd3Cs&l-f z49Xo~&lC=uL?%E3E9dNoFSXN9W4Fvq0QgO*Q2_gDa$h^Z9}Qj3hnO7)Ja(q<-}cWmcz=#=OupeL6}qaF=zQM&qYFuLJplo*19qB(Tz z8VEtQq7;u^J7=`qxve1P6C~sjv^91c5w*9Y6W~v{kes#1Jvvs|AM<$0Bh&1dn};(K z3ItR;60|c$+vGD7Rzi#zW=;hMqq{7&{tnh=>iuzqyY!o0rm>y&6#juj#5I|_W7UL1*azNNHULSJ90a3}7C;JZZKk2Y| zxYJ81q(Cfi+e}U|$b)1&9Q>7B%|qasLiN%*Av`D-@ZSJD`Gg)2$0DN25eJt!T9fpc z3k$W$BX4{Y_}yFI`DX_i-TD?mn#h%Nh^a$gtyFA0Dy^yhRXZ9)1BfAK|Lx&Cyzu9^ zJ?R54V-$6`d<~KK(sz$XLg2NDg3qi^VGm-<-n? z?l3>zK)|E7C)#@sH?viEPjU|;6652Hq$goO;v9k{NRT-wJo16pZcX<-KZ{`9pyUR>MC^|uh87xCbUX-YyY}5eYid3lNrO~l|;7VnXcm1CU`jWWNUPH znDW5;miQEtWh2FjlZ1CF!G*k)k@-z`clxq0CPznScG&@t<jcvZnuUKP&}0rSax_3vUgJvLfw%>0V-0a^rwFEICaq+ zy}-{u8%SToy}>Es(&3ET3%CeK%0CgZ11>jt7(SaDK1d6+@#&v;DR0qdJgJN(KfyNF zzrC`s*JSJiB6q?QG;n1k#do=7WANYwFVAQ5NHRaDgl;qRbFt6n+2oNmpRIpG6$6*q zHQ}ez3>;^AJ2qFYseQH~oHdE(&t#txOZ|*d;Yo;mIU8|G64Ly=_l$dH#fU!5OI2+om;#&@bXO4wKZb? zK0Ed{j@@GdT=J#Qz70eXUEc9CPKu|uXG~;%_8~Ml#ELq|a10_&PM{~FQk>4-=h14V zV;~+hhwbpGxS`fMZ1l8-jdz+C2?x%Y0Nfhd+h+(*r-#u9ns83s_WJ}4Og~BvA*Ado z$AQS+AW;!gHZDR^fI>2BKH-3$ ztufx%dk;SUE=)?DR>up7{G!L;6DnvO-D$YLBCv5d-?ZuLfb>jWP|(sJ)f3jw#qCvK z5$X3QHzNCi-^tk(w?=Lrz;sl?Da!Rg_6Uh*U;4ovGRmI7f%Ln>ciz>l&Ek(8bPTq! z8uLh()q(Fih>oF>bYf%o^2S&lnc?YJmm*f+|Vl;39kER6#lybt*4&HI!s{0>+9EMf~ zP5?0Z9@xiv_QFnVvO@&fALrIT!?TFEV~!H6bsdf&4kGTj7u?K2#IBo?)_j7X$%CPG zpWs>883vLR#&=HDfS=Dx$he>m9qA)dF$HK~mq`Z&Xp8}tS4n^hhD73uCx|EKGAS+& zL_6FBG2?jiy&ZyEEE>Y6VPRKO02rG?c5&~4=^~PcFu=84mh0gZAH*;k@UWz-CZ`K{ zUrAYFJa7!b#ysNSg`*b1W*`EhuFXg%IQV@D-a1-+2yu&u_|luJG=y@iNUsKEXKZie zn8tf57MiM;FNXcT|p2h+>%P_r;mHgY|ChvUA$-^=1w-D&f zDY$J#_82C9JStHR;|?D4;;l9!`FaJt%wzToMh`KaTW~CB6D2yoLuX7H+JtDmq|tlu zSECnNsS9(2yx;XH>-SP~Z2 zJdrtn*&X$22MqRp3eP#m7cGg}9gK{b%yvj`q7CK+$br4%5u^{X^YkS#`WW(<=-vUx zHeXAp%!G}gka=dXIrOO^bxA4=4&$~fF<^7xw{X2}#=bawNaGfKw*X@AI@qoD+2^2o zv3ldti2VU@crO>?A{^{WiLAKgd-e)HH%fUjnlm#81VeNPWx)Z&idqABP+P;f23X(& zTs1tyKByB6fy0fs{$n%^d52Dua79YK!XGjS;c;%3lh$v~5Hh238-bTO92~A5CGRu8 z00Zb4UW7BJ=(%}Z_-o7L+j5u~br6p9GsVL(!3c0GjmVkhkx@+G*~-u|2VoW>-KaTW z*bC|Wafk;U0J%xO+Wu`^ZZB#7SM;?;Cm}zf?SUQ*i*;yE(G-fqZ7K4a@u9XmwtX=D zd3cY*1cykHJm`G~Zr#UliO7`NUMKqSphjF{YGoGEpTOU#>q{VdZPRCZ{;&G7kTXL@QK08o(GKo z42l{2oza{d=UK%?oTIOvGeQT$I!M3BS;qDZpsU5N>7)8uXpzziX|K;&3Mh?q-? z9Dt%0oz$eEzqV5(40dCH7~_GbLm*`g*qMTaK)4yM<@4~IUtF)9iN7*dn;fd=B}7s3&0O%QztT#U>)w&&%%Wj9$5=x6 zCg-9an8+ff697z~MTP-1wN7K4CdK5J8N$~lot5GIJhm}Vjq%JbyWw&InK%IV7k9@+ zX;{Uz+0}Q-yUm(>K<^lHQuR zgAmx4>9jhCFh&udT^-}Ohsb62JX2KYR`c%k&8neKV?R*;!Lny2(f{%Aix z>2-z_^MuHF%8&LIc$L_uA8@I03&JKThdTh+Qh~#5M(%;=3LIwt%=3Xe2~F8+c1?N`T@o=ZFf`al4NqKm@ey!h-5D4E2lCu+!z3kRK4AmXK& z&-j*G9*IlH%@Rbd1Gsb1DUT*p9_B3S0vF>HZNYa0;KS*pxUhpxMzRC|50b<#z)vw) zm(LQQ7!MW~&q+%jZyEXfr45(0;fC8mo`crDoRV@m55Xu73Xy}3_S?xw$_-$WSKLGc@RSuvaL}7Y2~M_AXeB%ImmZSC zU9^k-a_c@@%SC^=Ewfzhu{pJvBZoUf08HE@QvKL;Iy%q6X7qYVgX-e6(*uS1^RGJ+ zG#(~imT~^YKh-zj7nu%3r-2{8Gd|7gCW6gDFfn}oe2lO4gdJI;$f>1y3U}%s8CCPhs;!T>&Qbc z5Xr7t)wytoZEVzA0EC|@b}|I!=En_MIo9G>L-#W!lQ9U%QSQ?^5$vTC3j{<|_SngT zA6lW$LV3qC+#~s%co%=wkxFClS0}NGI6f z!HK>&LBulRI~1-lK;<51D9R&uJ5l^nx_|Fu?tV4qDPHM3!s?TWnM|X;f$hy%$4HBr zv<`PpXUZ)qS9m#k=beho>fyp8x2yZ+kz#QDvI&{TADxP|a0*>x#H#tUVoy0qILgaK z^E?Ojoyi9+Nx_B7?-wt|J?hFrJ`HqKHaF+P5JP zi>WtE(FeSFFG`4Hl?$HeklnGFjKPz=#VEF&=#@+`)*NISMmcae*COyD`mP|E(@h+| z_JOViX{vc}tj!X2MSwET0@L8ypUFZBd9($!cl*6E1bT>5on^GldkQm)(;>00D70&( zFWgD(=P?WGfGkvkrn;+u>1ii7S z4Ziyh8Q*(xB6Qo4oUrk(0`fMs`Eyj3xrsHM_Air`I%IfR!N{(g59cU{lZoZvV3;0a z#2RCki1LpzGsQ39E`pK~8l8>e$Rh4eug0V%jJOc1B}P4a*2$cEj4=o7VvHh$@GFOB z-+hHZb#b{3*WlsapRu*!*hxM<&?EH74iotfA3V7y>|(Zq160u& zwD-G*GZNx!a^)4U`8!VLt#d8L$lm1or3YHXePHk2MwUKFXe7gfE@0zQKTCGxR%bAMs9F0UYS7)VWNyx8G*u>R}C&58e#?dG&uI>Xpo;*&f*CxJK z&**?~R^A4WJ>3TCF|67(@F>Nv9DH{F#?vg7V2^reckMMK{Lxhe-XWd-DX6voJ&`jS@~;A3-FW`Tn~ zLzdU%*SI!!$;CV%3F`Pd&E%%BeSU+A@EA^=|0D*%bCoFGRQWcubS3+e3r= zXSB#`oLrl6p9R)I0T<$gN$faajHfa7d<5cl=PmjTtkW{m}d}`V(4!4nR}>F753U>J`T#0%Gdd4 z5JSc?Kbhl3ukKT4xxTWnHtAV*m~;DJQtg%y(TEY}{b?00aNw9`k#+H_z26&kGjly0 zoD6GIH|)$Fe16G8o*L6(=*6el`~%$j83F7Lfk~U4l&ZkDNPrSoq z=N*A|U;;e$mR9q%b*#6&>7`*oGXd7YHiw5HNz``7Bfp~)?O@@Of*p}AWyIq< z+x3GvI`Vok9oYeOk{bjy*}$T0otO}m8Tv9JFs^Vf*xa4+mxWGwR7dJSm>|C`^9{&d z$s9ijo*Q`;8gy!pg}d{37oZhI#}= zDOfuI?J}e2+D%huMC1@WuUvNp??P5y!w==z2Tw>cl>tYCN7*N_D5ahdX&`G|B)uWs9 zlt@!%P>oAK@N23vm`9OZeI3|my}k(i9B(fz@gq=ym({EKU@L4rA1LAa5#SX z$XUKeg5NJLk@~n1MMZLf{@1#G8p2Z&3DSP$`#gkbfuygoHx^_kW$JLl0^k%Q$%gKLDk)piwQr;)F%CJ5pF^L$F1*F8 z5Pr4I3<21tdzC;7(YHK^_ZBnX>)FibH(nWBFlNL;GNiM5bQ=AC*v77+7H~5AFF{-I zefF=gqBf9J53xnK4&&$EBN&HcbmURzHdG8u%+(`ad4C0k`<6p%;eWqEf7l=U%Z4UIf?Z@$w$3vq*#C^X1~O|Jn2VGw?*4=EDk%pYu06q9niok56j0g+i5hP~FgXUtN}Tap-UQ)$VW|Uu6$>M0)KEf1ES)>5WB@(?iU5!+aZq zabx1-4wd7f11eSc!5LoJow*0%n=8>%9!2h8!QA074(Z@UM(;s(BBUXvI#skYH76Bb z>_C@pk`_tu4Ga)qc*`>YIeWSpt={L7yjskw&-(3;K$;cT#5&aQ`_s!+Ux-J4^ur)9@?A`N*??oIC$p`(fiaL}uf zW*xwWD+$2QJbS7xXYN^IN>0VBNWh4vOCFFFdS%`dY%sm9kaUMEgDluY)=zZUIVD7k z($ZZqTjxfo2Z^`!}VI76D@Y`=SIc$_?$Pb9L z%KC>aj9FeU0jdkhA(KdPM6H5T05Tl%qTzRcWTl*OuzPE~jJ`wq^yj0=Jy?Jl<`;>H zU0))SHEQR8cxEd(QeHT{1|b!7y1cxHI<*=I;x#rCc8JI;E8o2p+NSgi zTuHe*^ZJ)|65c!GF)08;(Y3&4JS<)Ei!S=UIfa|+&>CwN$Q%L=I?SxDD!=wXTy&7i zS>4hzoOqT1m~w8V*Evi&-ubO4`DlevD?%zn&#?wfl5-x;-tdK)Iw)54G}N$r6na_$ z`WTX!7m*QkPqr014-S-P%R}f3!Xc+GQ;OND1Y}6*ku>p$iu<6BaKQ86v3+xdqH2rXL1}!7s|3!d;aPhYBzYg)wv9O?Z502s$3%(~D>2+dgp)A$ z5pF-%Lfo9aas!1v`X{;h!cs#@_5jZ_UL`iW59F&dadUGWUb@&FdO8oY{lNVWlq8%V zM1wDy6EbBU4elqxB(*m<7Vl%b&y#`QeW~<0PnUd8nZmrk=ICvLBMeOLI` zrIi^7T~9ZZql_UrU?_^r$!~1-`);-w+}Ip~?>lf9h(3Or7Y`nJomA@V(I>yjE*Jqk z^!KxLqC!4V@=EEBZ&RoN5R6H$8>Mq=F0yaa7+MnOkffaR&p*SH7nT#1r}klLBRJ98 zr%I&~BA`>40%s@Q??x(DT2KcJ1qN8Ggah9x$cihxBWkpw|DfWIjuH16F^_F`Q!LMe z#+)@fxX6$@wpS^IN63QCb?Bf6I>f+lEyng{#wCqV0H)2x{k#o5s=2|U9etj*F$OHK zAc7Gt{=+kZIMnzY_ZCY2a)u3Pn}c~wB%&l60d8d&1h|n}39e|&6oY_(H%&wKLXj|5 z%3DC?d0Pq5t6M~Xv6ot*Vtv#$03SahRcJt^xWqIUXHfN78s$`$0mF>m)ocv;>4Aw> z6}15}dB~Du#v{Tb(~L2CtSkg3dDZ}V;NoKnv3kQklpjih${olQ&&}9nIH*FM1YjPS zHAPDwK*RMf;74FYD;j-ruV>q6j<13}}_hPat8N;SYEZo4&q8w z@fWdrld!o0V+L^8)et`7fkC5}K^c~>9$@?BD5o>Eu`V3b8v&kYlOsedyvl?XXn^MN zf#s6#MWhf*S2xf!W|reDc9@K$u;D&ZOmBLjIZ){fW=)U7e4d(T7Guwk;=*$&GA8N~ zDf)^W*eQmPX$3mICrXroYD2M@b`O(P4j5NUw(7Wqhm^LKvE15_QIxUK1>*I|o@S97 z9&W!k22|h;T-y!sw=Z-8Sor)oB0wGaHTn0$6UfMu9H?sql{)*+s)p&{G1qH4_YT-9 zC*0KRI}vF%H3v|2&TTVh#r^!yg!^}LgnebKiE&kjQZIcVy{|8i6I{mW(#fGhi5jMH z__vPn#79-$dIG6#1SjvqHl!IKD1(h*k^z=EC>SscLjY)=j%V_P;$_YO?<%I16P67m zw1C7}k_S8JsihFL$mASwCSr&OfU!n+_Q@=HjY;mw6i_@hU%{G9>-rU6Ql<0ZR`

$9pp+7+33FWkQm~m*2AsZ_`7Mu=U7Ant* z0Yt^ual+3K#=r>--uWwUi#v+#MTO)~?EwmQe^XiwgoSvDKDDHq390E$G(F)KG6@7H z91IoX>$pC}p(k$Pr1B@NLE;q-cyZrV+X>mwNjqZGJ2%ecqrwY}$2vGgIP$BQ>D=~6 zuZ#_OppokV;KwXi9zc676e8S6%RmSkIalgEM!AwFI#H6<0W?^Ye7{qwV+ox}sfgi^ z4))b|x7?@X2%P*P(UI#wEq|mFG=6^;oGvSTUX!EmOP0XIr+3K%T0L0hl$H7)xQL7* z9RyB~Yy=VsOp;o6!q~;=0Ghg9C1{Jl;0+JTlRV8H?a8`a7UJq5=w z3qwj^c(*XMv%Jrpz)>Dc$f!j^Tnts?N;=}c3n{}EF~dUg_95a`Le+q!_*ixxJmUMn zBW&|3JPffs2#m=_z@>94g%IF;1eawZd2F!u*>LYoOvcTG%)LPn(-h$jxX~8ObO+Ds zsDJH&iJzp~_UYbHb76Z;_r9B>$lyl>5a+Bw*K{9-+c*A5yuo7C8QAI~Dl)Wa$^@TBWAdkRVn9p642T6Dw_=r(|Vyw|2h8~p`WjL7u zx&g|vvFkDTNC)lk1%%_+77aRE{C;k&MfifdH=HVGZVvN+_ZJy=nwx`mkT}?9Fm~Co zok5O9_P}W_*~St|2Q;bzb1Z4eE?OM)P6x)jinxa~1T|}VZN?W496;?CavTSPZ4UV= zqy95HK85xMgSE~a9-29cUT&k#Z7XswctCm^&pB7x8o`;|Fhk*F%>mGwRfg>hHsUP( za0E=Z3Ct@q``68LFuEj#LE@(YUzl(@cHk^b*+D5}D0*Nx5e#tmbIrv4>|d=$xhgd` zhh(_qKk;yJfylrw%M&?#c!r^5(>y#{%k>DMkG_nMy|v+47!&j!_Sc09xXOKm+zW>+ zo#Qkwj_Vpv2?HY4unY58PL7U42|-&5!gIi{Mr!KI_1evurW_Swk2Xp!IK(ZF{&Y|x z8AfHx&AgVMASPVhaR4klJ!PuIauX%#nV!1bZqKM~M$fYYI}<{eub;kJUE6MgJ1a_t zsgC}G1@iHO1n~6gm)j~2_Mn$}og>-68!qM!k2#1C!3fQwQfM==YDRo{9xd82!nNz5 zw*iChlaUGkh^N|CX`ek&NVLtKw_8@IXKwZ~lZ0dZLnu$06s5U=s;dW0^#oQfC&$l& z+dXZi15V^RCUtafhP%u#=QuK(rwl2bnAc?~LZBx9vt!<>Ij!DO^4tc?jb{ z*^wNCb69Srea>UscXzpWW0xmahuoi6N>?Oz6l&p|f3!yEFr<{Y(=t_zl5Ri9pNQ(#(LgPFnFQvM1D^LayqOCSgaf}7bWXr-`4Jd5J= zKPa!jB#UIcc#GtlCu=u}-uW?*7i!|_6M!MV(>$p&GD;bup4A-eB$4PVH@k>VwS z3`)5b%WlCTBV`0#qCT7Cd7v!>_=Qb`uD5{c*0 zoHC~UXwH``^cdl{R#ugJG*17GUK!>UdGLA;0^Wx^=x`VKi1oWEQX;q=05Oc8dKY@2 zi|^L9zsQT7M^N^-UgUtcg}r+aEACP8X$(Ij4i`-crNbg>GJiq_f8^p0mQv_P0pqMG z$7OFA4O7ULJCSb})*OO!_8-eC&!?hMgx z^1zq1pgs0=SsRZ|$z!-Z5i=&_mzN$DwY+$&0cz_-fO>J+`S7vCXR3uA)GLG<32gw8 zS5)^*kS!oGe+LU*uH=GyxbZjHQ<}$_y=skg0l+-9p98o3H_jlB zCj}yPMP42$`2h+E7DuVBCU|mKqS_K~%G8$oAGA#_4?KdBLoiDn;h|grJCXoDs>^l{ z{A}QjKsz~ZHFTprmAHw*Wr?V?lZk==k=*4;SadzOP!1|7FMxC2;DCX%Fh=A(VbOm= zLfj`Sr7Vm>68nVaj2i07B&03ROgDOMs)#H4cMfrB7C9WJ?^T`%7g0wD1= zW`zNQ90H8WV}Y!Y{+X}ttZq!+xh(B?Zg;-=j!9nW;Yu3R?MPKec<$Mxq(EU3RQ|;Y z>TZ!#1eb;zb0cW&5pzui75xE8M)wr=$$7e17-|y(kflc)ak$JpAd=`UwDuJBE<6qYP-T zB&UPJj)+r8V_JCTV}0PHgj+~(t;Jcc`TjnAaopLs+6NRRnoL)-kLM-a;YC^dybPb8 znPhrW6({bZemsY3Folzs{fS!|bdSuucv2h{@BW)yMbG6BLRs0V`qf_PIdCH7UmZN5 zrll8A*uT0gN9Hdef!Eb$e=XPjI4GS8fyw8dp_JMBH~$&e^#A>bVznth)DbI+kB?PO z~}k91SNINAy+FssMT6g1i&^1 zY@7YEH0QmlB-H|_w(T-N?YPS00P7DCbOMl-ZnBkXD1ZtcSHs4nS7%)+qX1yl%L1x^ z-DUGZXFly3Q+fck%Eq=aR(P`!iPgk_<={3Y7oGs9UVpq+JMwNsrnz?84?#bCQ@1Ob zn_LcNyEeCcYoCDaR!oxb>9)w%WLv6z-d1+EI~GbRh^bh~BO@J2&|n=u5%m{-2@g#+ ztcjd`vDKtm6y^wv@>PaKKvwJnadG6Ako$;nl^aC|Wg%8qbzqQrS!bkK?&LsO_tl3T zsRk&U4_51!gjsDe!jyYoZF734ifp`9nS-hvv@@4q5_&t6FBm6u5kbD9QMiuGXs@w9Y}B9JI|rMRum- z=S#7`d`;K++}5?ZFev;o@a7}ypvY%iWE8X^6)M^Q09xmujnXwXAKMI4G-xj~vFQ}11V5s8!L_@4c8E54zBTSyffK&AEl>gAgx zz}9&$=7iTDDjx#hn03B=NWY?EB-Z4B22hcMDq|8vhGC&z%?8Pmx)agM4vECcBeA1i z$OpbzDa%yM0hKnkC($LUwg)vi)-c51Gj|R&+cGi~y(Q-WG%M_8ge_l_*d~*M8&n5n ztDPW{_#rU}R1ejXZ&%-u@K!IE*lN@d^-s1I&^pyyi`=&s`Jiejwi)?ST(d|s>PVwz z$Yk>d6~S~6#EeiC6suO`BwknpFRXzVbw2Q&(GC?uL8)cVKg*4EiU?;dJF1IeSZiu)YijG2@pA4xYh}%KZOwIU&2??fb#2YnQb)FHYxmaH6gSos zH`eZL*7l1*Kxw^qyV+(CYepMuMjLBJ8*4@zYerjZ@LOv;Eb+dz#QW9~?^{c}Z!Ph@ z-PU?FP~e-3Sy($#SfeV8QI*I`g7X{zYw5s$7s!onz2$fF04Ow|)PPF&u~Pu@)ux}2 zqm}`-`X~Ccc+f-1a(*3A6qTMMK{qCJD;-i5&M<3Mn$Sw!pa85kTkhn&fdj2RtcJ)c zGw524a%D~iS4DoMG8@+Uz{!wOE!Q9NJ}ASlvQHwjlc0^JI*HzzpEh@5UFJjItiGX! zjY$p2q+ZW!V-5+~7C8sgO&eg!Z)dnMsbXtx_BLNXWJ414{RfH3gd2T{+sU~zx++Yf zEX;ADv0;A#%?=4F+1kndD~xkgm@9^sgIM{}JPKvLVx>t>W%Y6~N_zI!4CSPp5z0o@Zmr`G0Ju?gnb<1CNEm3h<@s0n#oCVi=%RJ0 z2aeW3j57qtE+6*B81`oU-R7V${CRr#8s|4$6F{P>QR%I_v!uGJ)D#~Zsy#k(MR)C|&WUYxbkqE=KE_#TyY zKIbx@bCqo_>J0f*^u|FI&r9Gh*d}BLJecLviF~`YyJ6>BBtasFaU2vkomjOqSC1b z0k~78-$ZOCnCu_s=SYfsw=KK#h%QF{f&!Xvbbx3IB?t623kwBkX$%;}rS5+BB1@C3 zvIY)^52H^MjpAa+0oOVO5CAvUKv5EAYlKoTYg`I>&z3$#P(rin+Z97E8mrwaf4nnh ziSD$Rfi3E$?6_p1SDIqF@bso&9=iO#lV22FmM0 zMN(U98rxFE5IIoIf?tjCpde)%mA6<~tBVqq{VvA7loT{&-=3BfK$U-{H0ZgoPE(<2 z%f34;DWocPY0Rx$zi#m^Xc5JwK)<5yvdEh9b0XW;|3BL9UN1Qc$gEbjY_k@mU>$M2 z%P2AfF@MLstF6JKP;iO^T;&rMldNri9>ihVD7H5!7uo0(!PFc6))Y9r4#Jl$&u(MyA=_p$>J^!&W!`1%`xT={lm8W?r|>heqCA4S zq@b@9PY<9K542Vtq9_i}-BTHEKy78pQ~(w6>Dar%l>EIn0IGZ-^=}P+$9|)c>yMmc zQMi|w5lU(r+`AkB3T!cOiklIjD(|$;vTVRq-(P)4PJ}HB30d{4_wQB(g|M~gD06x5 zrAl4^uu5*fd=v-`YTe5&d)oo`9v>!j^?J2Y_{!0s?3EMTrfIzAA1Lydi~!d3hDI%Q zB8Rb)B(A?ueB?taRvA@LWV6NAsKF+qcoe=8qz6tyH=Vnpel-&`Sid0^6v2@*rcGDJ zrd;JyM&WGMBL?hN9{{^DovfId1(SS1isZ`*xkrq~7E&{`gSpe241t#rhGtdd-g4 zAFJ%OB|s~lT@+2G9 z=14Pf$q94!f|Xbb(|Wi5dnDm*lMiiY$b(&B2$fx_^AG^AvIi;pqo*!Bh_>8(Yz){| z$F#B_=yDTvmJo){^4|fv=4tM@q^AchfyTvid6pK>HC%nN|xTzs&Li4TL~`{q>q9B3EmK3QPJ%cs%b=Yh&tbW8+Y{NTW#1iC(*i z<>kv3VzEST<;zK{A6M+Na??ojkTXSv!>Y(9pZS#ZNJa$Y3$oj+z9l=O~{C2At( zY85(Es|?Rm#3~h=YzyN%3N|hI*jrJ zW5X&XJ7mYTL^7^e?Oqf$K{To$OwxqA=qwXE;I_uWKpi4u>T*ZX{?9h5uJVbZQjxw& zFF|;v+ZbtA(xU*izZ9&Wg6%YVBg+A^{*duyMKAzUuyjgUkDNPbxkrKeY)ab?4Om&z zK>n6NH!pXHBK5Ohl=kSWO<_Urna^`|IWhLq$%q5GnNqJnL)=4rYBu*nz%n`2>a zIWjDk;>^L2(U|)j;$Qp*zo*I%sj>Jx|E2l)vSid@?+r2?HNx-K7MTv09b2@X(jeow zS-vVox7GR^x~(?WunD*Q?uUkq+*XxX)K@E5?ea4w8iY$h!ca@zVEy_-Y{hFn1F`T0 zMYDwE(?_-lw_73o8Gu$Y`xbzRl#&MiS})|e+>1>7dv3gp?ZNpV}`U(mxQ&dmUI{v*AxMNZ?z z%A7uNXryyNG(|q2Le7VOuv8RORQ3sYSNZo`Q}U6Cqehsunipz?98d_#=A=}XIzui1 zZ6ZKsZ4p7a%T5z=Y6-Fz7a6oZE4P)fUxI&ZoAo-5QJYO(A5 zK%m2l`LM-&>TUjmBBwXCul*0YeJjAayT;X^* z+08c3!3p3}JnmuRMz$@#v=fcrw$drX0l53E*G^?wd>B8Z zpnyb|Y#f0Xvr+0_Z|t2x#%#a~wGc(Vn&c12f4j??BB#!qqBbs9BW{nHLa9uGlEU;< ztI`5mJ5jzEe))IoP-X2X7^;TO2oH(#aq!aSKP0JpOE0DuaS za8-Vxe5p;+GhtT63t%g$^U%X!4%leGR!vY04%O>d>m+x9-U-X!SBiSndLXM;li1}j zRLd1?Elbh9ngdD!_1mohD`bDNlW%rnQyDwGsSRMrc9iy-uPob@?e;a5jLd(kl-Cq`zbR@!u_Tk8;gHDQEtlJ3Ir;?4)n(Du-YN?!-r-Yh@Ap!6id`aXPf4^@@`_+wDg2@`S*Qn>Pymji!9;Sd$pCVGvzqYs0=YJY*^T}zgxWul_^3`;q5b8g^xz%sK zy7sFjEam$DpXSy+6X@-TsnOGx@7rTYbw9G#z%*zhMIfee5OJ)oI3%S zijBMf?wrvbGW1luyyf> z`+zkLSWh0FYDgkMf&hL3W1HeGg|@Mt$?u2Zjy9w_vOXU{Vsra`7+5tAE) zDr7JQFqMaoy?fy8JY27NT^nCk_wBPKoFjKK?e_!KsO0A)o(?v0ry_Jd6xfTMGsIi@ zdrc=mWg=7GaFyh8E+Z_~3k2@#KvBj) zD7+_DG(13FzMV!YY9Etg`#53mni4}N_8wa zy#qKDd14zkQuQ=;Y3V4HiVyz&?D*c?(F!^66!@eN3*+K6ig^AL?WHXg)HObv?33ZJ zf=EabAay0SY5}g27#~+80N0}?5f^0n{A_x^i+@<+Ss2DsBjiKSdZ;;6pv{!KfV*Om zWr-S(w7Php5H^N7QCz?-AWotx@<(5-9F;?dip7ZHAwu@Z<rI>5gNA=4aaxrVCmxP%V$g{mayYzHW=I2`jX zulsmJ5;s4f?#PXV|B64ja|MNJF~y?-_&trC2PdI57_b(56S(zvxaK?L{yXGBBX13o zz2J8a%#4Vnu{n4w$oN2Zc7Po{#T|XI$M^fh`U_NX0Fwb%>d$=xg?5Axj)Y9@=2$!3 z{|XU7GULBIOqLdY57T}BhZrB9I-aKP_{@0C0jj!_?sSZCk;CywXX4=5v@H#S+V}@# zJc;r9AH6xjj|G8}K7xo;4cjwyrh~A8y@5}~?*G*>#9@3Uy)!ZeAXq4^!%rwzF0faV z4ijc$VVqv+^|gN+&bKLbHa0!8RfOp03Se5%D5>bzXnf+50`%M*6Tj!Xb8h;}o73${ z5#|iY;jZ^kA~`X52c`l=q^BbcWr&p2#QENd#k?oR1P`^(%QKt>&|pG!sNXpaF?`4| z0)jH80EHhft&)CJl#NxyWeS&*LjBndw$)1wwsYrr@3~c~qel+Q9L^!#-yOQ!U!Ah) z)w)S&2)TKA=&5Kgw}aB!{C*G*gSc4rzl=g9DO-mgh1Iu~R*$AdIiL(9n@nfiOM45T!N@o96}!wxyjDgt-tg~=Q(1a{CorBbCeA$t1m zGt@QZ!mp3z=91fk*_&jUB_`H)e_e)|JDd*#o<%l@;cyv)L}kj8+sEVEbRS7$OHLj@ zB)f;aB8Q=GrK8J1KAWFfZ%Sse$74A=dn ziiH&3t?lz_tAD7!9cWoS(FC6*5H4`ZXZ9`6~IJ~x*Vm;~vn%|PE zR;Yl83!bN_eR;5#-pWGqh|lgu-^ly=T#|W_7_3M2@R=tUuO56QJyyeqU!+1YvhavI zv@$I8LDQ{9+h=>>Kxvbbng-yYq$N54=y||8-rTDIaL>NX zi>QRPZlWAE_%PD`^jGAp^UbkR1Ox(0wzm_EH}6o9$F+5eJ#lT+Apr*Vh|^xCmb+Zq z`+bt4xc%$mx<_r-$EqXL(fQlSeV-h1V)%f&9&XK?&uqNWeE&f9a=V;ba)PqGax2pY z{)Q*N{n|YR1Ov{UKIHAp*!ZMNOfr)R5?LMlD!_w*W^r&KcA@wxcr23w zm^1=su$`94(0Kh=@SBul4}9hA?`YgS2SQoJAu^-l+WyLNpm z?@B)1s%VXbTiywed?DVzGQ>VZ#0tlWM}PJ$bMc2`@1XM`R2_~NzMuWz=n`I4a=>(iV+yj&GeDa#p+i7&tIb+_Zu!K1x7_E#?b8GqJIAUOYiM&o= z*-yzul=zoBG{@+V#6V{U!fWot&zw-bMRv-#$7u*b5ZvfE4ecI%BI(edoYmJ__s)Qr z_TF(IoG4z$)VB*Kx8eJzJfi?RzTL?=mzX`m(`6i~bfhgBmZaohr;!dA<8{%UfL?&VOZ!%5{)44h+uEKpVuQ9Q14GrtH+mYoX+`ixm7WuK?Mo z;^Fl_Pxe#dZYMlUocDOpv|q*(x2F9-M`bpjmibH<8C>%@H-Tk~IXDZbV~SFgE{vDB zGj{BR_X2@evhAhueD=ST$KcG4RU&}Mkp2i3{fM3(yt%*(ik?|=l{u&%>DU1z*r>ta zJ42e;X*RjUxp^dpNq05~hI{1>DUV&PoSn7P>2r2==d_PzI9qy;2#p)d@z8DR3`#{02L$@+C}e-m4pd1 z-Q+wt4sz10`#kjEyHOIrK||kSF$2GeI^`L(b3+?B5BCchXe?&v>=cl5?)^DqI%)JY zu?0uj$M{n6CjiSbk0k(C0YM&plvpM4~&e6jdU5~ zc_kg@4EKC+lf!pGvkTJ&z&zK{`(|4c@s6ieJuzkTAM0O2UGs)PAh0>ZydGOsB7!g6$m6lMP!=bfA@PAvB5PF*3!eLdpn8%y}0CYF~brBCw`1{ z_|YDZ1D9$)T794PBho7;h#1ZlHXQFV3<1N`gKSU#p6#P;V=_r>QSu^_NA*X03!EyN zg+IFMTV(f1!}~EbhjVw$PUR2BS=A!Y$kW{@8$30OLnYZO#w_Ku?~Vk2flYopYhB-$^{K)(o1w@X8+W^nIcnuoGuE|5xq95Bxli}v2HxC#GJLxxov{WGvpIkS+?`sUh<(uQ#$3Xw^K;YNHOhr3c& zd)IeIcM)ptZF7veoUr-&cHi7+v%)1HbJ6v9k(=nr0n)xjt)*`%xJNFLlR6X=mBJex zOBj@u=H}ja+7H!x?KW+BkQ^TM=#tanJ)5h;zJYlbuZ7j9ekNy#Fw^_aDSETG_YGc| zsW-{TbzxBlX7aZp~AzQH0vgjAKs z5`qyE-Nx&Xo|G>xnD|9FGh!ocaf9|d&BPF2v@lVsGJEr-;ftd@wz*dW!O_8>&RcTw zk%5N5lML7DL5zxvw-_&jekW$s#jC@7wc3fFcB1DL{aBn<5Fo!NPzTHyePua?fc$@PV?yEVI79lox*e z^Qai{lj7ClJzxLCgUiBnLg;#s0PKoGYfq-KlxxC?6dX4`v>^aAEcq2rq-6e4F+2Vp z`dRoScM6bR79uOPwctMb*M&^Wz#I@f1t)HdMoWGwkH*Ip;Vy*f&kP&xLL~MHT9wo2 zz!Q->pOz~kG*$ka%Hy>2LDP$ zZ+a#|w?p~PoSh%t_BU<2J+`MidYO{4O1ei<%9+y9mA~+RA0h#g0En#eL~rlrL>+)) zk|16LLGa>>H|iYuu-x$|>Ebq}Dw%76PHr2r*ZJwFYgRJUf_ac@G~E9tV=tEzT(%U^ z%!_jpoh}NwwO;%X?!h5WhhWQsI*bp0$WffmA;}_`eW0urr&{3gY$xcC3+^Wto#-t} zy2FiIoy2a2Z31=XH5!X8fUEVkc#7oxaZz))m@TOJ&)bZkW-lQh%7?Np&QaK%&PiJP zg(=`v9|LxLOeeA3@W5z4B)wg;58RQ6gLi=6?@~s*<%;Rk8byGY$L<;=&F`;wdXY=V6-qyV_4&l4EcO5tno}vL868Vj*)L zwTELuN>W5+&#XRUU)L%Kf#0`X5G~vc2lK%%){NrHN#KL9c<0Ewr%_TNZHV253T||` ztP}YXAYq1l9Uj-a+YC87L5CCSRD-!;LXwu>eaC)OkaWTIrz@8kp+O1HqPuaSu`Is` z-CQ2?Je)(iCfArRI$n#xz{`trsK^8_Ah@^4j;-T{_PSgIorV6pI_Ef0Dk`q)Fq~iV zr0Y72^Kq1CM_0EkIriU&tIp|P5aUHAfdKG)ns@%_I_5+G=7QUsWMc=omnR2PDGr{d z*IaREQgRT8A!IgoXN*4{y$E+P*We0j5>B+aAIXh1H26LoIj=G;dp*s^Yrh-sc$Uc% z$F3uVqJ!^wB^AOIkl*-4?Q+G6JnWGuU=A*lX2UneBVM;tj)U6_##9@= zdgJ+|FFS+X*?(iPwUC(+jetLcNMjr5m&SC@pVMF$)Cd6b*X{x}G4@#Zri(*BmcJk!!_(94aDTR4wr zo44D^r>AqDKwne3oxTksu+5Fy?R5T_@JPTb2nQPPEN8`sBl%Uon=>Nc$f1j>&)2Ak zQFVZ*tf>XGOF?P43&0Drxt+kZ|46g!VuN5yY_#*yZS+expfRAwi08(rlJ0J}Hf!gF z6nc#D;H5pCbns&r@8daRaT7jBK*UqGcKZNlj~8y}(K(QI_*d>(1IQN!bhlr@8) z6QA!AL!dG0_oqHNGUQ_$9V@;a+`0|HjiQwvuPhZm+S42CCi=Zx{?QR`vhtb0r6RQ` z-LM-(yeQ7bHXpa~bsbd_J#(017%VDTf8NbD`C(TUUk!`fE_QKFY?#7T|CM`SG>qVf zGA)Rhcv|oE$~5?6oe;lb+Tt-CUVo2!id+UmoW@4z681d}c?mI?T^5*N2TFrbLHJl3ZUbDC@Ke5vN0% zV>pfdBr;+HTL9);a&>nacRMT(v^dwwF;>{!XV_dL>AIrK!1nciMpV~=Z6Ha_0`wK4ZR|5=ax%gtjA+rZey~HTkR#;N#KINSxj#$qk@>pVh~Hz@#fFw& ze{c&xK=8F*I@+38cmP&FslPUg(cA^>>&GZ?kjG$#gAJmH$fEA!=q>ZHVVk$5 zR}9w9dV@B*o$Ndq8gyPx6^K`PEC~>=puGai&;7I?e>x2^XTw21=q^-X+8Js#h1;nx z1pw~H8(vlr$5ivuJ>LZ_lA^QeX18?vUT+v4*M@9q#CuTgJ~q=Ii)m9z+O!&ETTVSo z6w>(kJSc??%uo=byk>|Z9`DM997Sxp8Z{!cjv67@j2Z>hb)_>qYUFV`YJ~8hwxabx z#3$h)Y~TYqF$T22SHeaMZY9Dq-+G8!zWKx-;nh0ibnnv{--aQSgc0W2VXk{R&59?J zT=&LC{Mw@MzffwdB0~CBB=bDOX;_Q_^M1lPagZR6^yE}XNPEVU2^J+FTv$8qjuS3! z6qC_}&{e2|5|!m`dhDf31vypy1JZXAHS93X#XFp?*1_b0^}7sSRgG6r5ot8xI1+xk zB#Y|M+lD!COCzVB(k-LMB(=lgNsDjZ{ekySXw?bd9zxS^*?(PGN zhGY-3XvclnjUsLCK3rm1Q}Mmw8ZHb5582TLHaYJ+m=}2Cz8GpInfIeJMUzb0ygMV# z!ytSIc6U_k)s+^2`MZ?NOS^-`3Gx}@CikQ37<-?^#^!!<9#?c!gYS4Bcc-*-ygwHW z%H0{SvwZ>3G17%{J^4Q4J_V~8kN)lxOp7kIGAH^zEx~2QkN3xP-Szt80LZz7AQGv-|7WJ5Av)_Hxw`*zJ0!=2s&pwP0Nw-`@vyx+$2 z1r|EK#Y#%TK0D+R!MLXa^|(1Iyv3X~nW^@;5rYK>c`;>4NFf@yAjWR|Z*HGAT&3?A z62Icmp`~~37;k=uymA#)jK}Xm>3#P{SWLLSL+F|A2649jewsc`s`kzT3wMj+0mak# zKi5OA2KDUE2fFSOJ#7c18^f$7ug{o>UwSV7j?AHQuu=j-7UApTQKB>~q-dbmq9z-oE~!jy*fn!6}NEcc%QMryKm=)`}K+ zw^+J(%SOT~1;WDz*eIz9f?%V5>4Hd#vk=s((6XRbiijZ2EF!X?(jp?XIZBF%EO6_b z6A}K_shpo#TV%o5wMCxc1cK=pxOeIqhc7?CMMsmKgs0> zTo>DIC0T7FHm>kE{WkG{0GcHn3eenaTj>2T0L#zHi9>@*HAuRx&5t$az&fjKzSTA1 zg$t^WE8!RBwDFCdr1ylfNsHR5NiTYC(-w&%os5#6C*gC^pDxUa;LAA)v^1f0a-?e= zj6logi={4F^8=m1HvY|a*%TjJ`-E3WQuNQv>6Yen%gTf{*2lF*nlZ?e?A2XOwyYB!d`Y0PD98mpVw9m)k9-Qj2?Sy%0g?6sIwLepNixO3$j7BwIn~=<|ec-p`{5;)Lqh| zHhyWWf0B7s~ zdSl>oc@P~=I008E3gO#;p|g}!RmbQd)+v5L&I!E|(UiwJphcb%txg$15TmZ_rL5}} zc(Vd;SKwVbre=lfo0V@i1~W89zwK705CCX(Q8js6b9n_1dZ9ioW;{ zh=vcw=S%^j6{)`tf~riDgm9U_v3^jKiGB(_x~de6AsW{P&*+ag$tPN#0S16BE-)9f zl3u)yE$d9C%>XFpKNg>^i?nq@{JKscjpjE}X(n;&ZjHE2wn{j9!BSHxLW->t(TW}xMmFQoF~H?X>w*VGs|Smh1sw=4WalTn>slq?0x_M3mq{8V96)R4rIl!DFDK99{^a3pRUfSY#7zWC|hfk z9Vf0qeUxP}yee5cay{ihP*~M0swP|w+a#aWZCgGzeyRn?PTafV<3lN4BW`Ui7D}$o zswl$o%7GkYU9$;kT%&1MM|WHX5^LOLQW8N-pW?@F&?+-p5h}MLI5qA!ec?3#ltySo z1Xy_EO4Y{UxH<`aTJvg*M5rVdQ^yVIdWduLX`HpD-7B3~h-!)c=+w0`0=CAei!5oy zW{#>#Ee+}#S{nc0t6ZX@Sy}v|Rb*!8E)E@cnqS0!Yt$U8*?0DD+JA&1)po5Gr=De1 ztjrP(yUk3M8aO|@3rB9yj#F8!K)p3301dXtT?eR^i@LObT^VcLZ0+nmKousxhkQe5 zVt=WrBtBr4O(*B!d9P0~+0`VyeH5p%H#!7bUQhy!A!++w-S+-xZRGVpf@#SnSwb9^4 zT(nC@+E)J%fpKfSqUhSKAQ#Jw5wufB?(TQ!M;P!-)!GJFuB2L_i%$|P0k3D?=2a^9 zZC>jfH~=;luuZdo0NkZRr*NeFJ=$fPR@9>Wd`f7cW5#DP779qp@0QkJe56u*pX{^v ze)0_;^p(GCQX-UXGG9H2PR>W6tF7@R^7vKD{PT_q>o-+8{EwML`Z=Q^&8?dzR<{E{;p_5C5&**MD*Ut#61T>xfCNUGtpBR zgZy1?4U}+&=s^B1nKH_I7_VquZFOsZ4!|IJ0x87U#N^PyUin&;kf*AX&YK9)?sZqM zJC@WS<&`?+mAd5L5L<_iqGf@&Y5+C=rR=m>L6uwNU~#w5L9`UG00uWbQ19TJ*gIV&{5 z3RcRrd`3W$ek%MNGT#AHv&u>E|K{_xL9veWXCFa}nAUP}sLB{FW%fu|VZe;VuZ+d7 zjKxQpQm{6K)mvWc9srp!&5iz5&ZsJHN>k!C#Bh}`s4;N;Ooyhsc97T2;3^+Co{?=0*$Cz6Wu@VFeKI5d{so+LVXwa z7EvXTibOtmXgebgnC6S))r73#CYj(Ncnxf>j2r zHejs*OO+QTGAWobmdqGSRv56-fYp+TDMyI|+Ld*ov?SIvHwK&=1IdknU0csrGKVBU8_%*eqRwUA^|X$ zuVFb5vVEv00gz1AA_7p(p2{Xr!Ec*vRQpn2g#}=30UJ>-j@@1XWUIcxt_%RW6AyTc zw+iF4Q{jaq_Y0Jv@Cz|!lqER;lqCf#3|MKv>V%9aN_3r7O$J3uEKSh~W>#zI_g>*1 zALkg#XzuFpH5TdB++}+U+EG;p&0wRQtqC z{YLd#RiE%DZOn)|iWDftYrkyOWhyQ?RXUT};HsKWX0yVMUgT|8lad@5g-`*gsno6U zbtyH1rszqsUeS4E)0NE&-Mo|%DY&FjD`GMyI+Sw6O`~e~0IZXpiB8aU6y$6(gFKWM zCV3QnMET?LGf_jIQT{;imz+)8tSGe&rzAR*fk$K)2`trrBFsXk9 zOZf)kbpbI`$~#c7TwPBGSYg1*mD+v2(x|*83csk-FZf#sx^2rcD@683NvHTM#lv6v z16-D*m=GdoMWeJ{r9*u=LCO*2)T4f5WejLm%hGqc=SEFBY~bkNUwlA~pi4(p8cSAY ztX_voL~+<$>B;1Q9cowTXG7JRl;z6$5xP>#5-W58wq9Q}I^K}IwJVPSr^D(#9ago~ z1^xk;@gmg#$ZZmeaaM)?RhbUFs#M-jNqGy&z|oioJ!-5FwQH?Qu9Cg0P7RGBN>Xwt zqmi!*di2TI=m0ob%#EBG#e0P0Bc|D^ijc~vCxX&stAc_7p^$_n#*cPhf=WdTG~9|7 zXr=WV(}+WhY+Ne_oS`4lF0Zp_YcoLG+8TziUBX-;Du z4)^b&RmGxQ;^(!vJP^n-rk}XvfCQ zyGGsVd(uBcEh zL0!^aBNpBBx$eG67r)6Y|dS`1t+ zYSX6lKuovQ!*6|r)>q^LP|0eOZY0jf0XZnsbvgi;uQFKc4)@HWc>)+X7W3Bhri0I^^@!7*mTdWmn~u%FhML6|x>CRocG!uo5adX_!% znkquz+E&Ti<8eSuGHih@lhtUdgdSc~F`h(qqz2t|t8371Q;|plIcwrZdWa9HEmBa) zzpg%28K1X!xNSc1h^S;os2M78mCnVf3Mx<&^KTp|V<4Ru<sl`%sRr?=co!B(6GUuMu;=^?&M=tfC*$TW>vz5Ka`lf&^(n&Y!iZbsEZH<5=!)j~o7t_geoN|>X zJWLf7BJsp#o=PalC8Np-acZG7Ew=|$_<(&J!)G|;AYx)H$hJf&J(S?d;qi}2sEV|Z zUu{8Qs!iYrur_7XFrw31lE@BxQ27G!!%mDQF!Z{NP<;X-PMl*ZkaFG$mr`YdHGL5k z3l%9~4LPn^5A;;X;ryEzCHDjt5>mBWt&%Eos*Ipk)usYcwWR?wR7%x;qI)YmHsho2 zw(Joo0%h90p=1eqc!7>qV%*%}3;wne07~(G2@4zYMiqa~C3*6f8r8DsF@RcGJsF>A z*~D7r(58#k%{Q#dv_oZET{Yd8uujKIzsTbAFq`-)3}AA5o`fxUQD^{^@mZOTEKd&D znqxT;Fj`6UkL9WX(~2tY0O`b#^hfe-QW+hfx%n0tU%(S@y6u>Jzzimz0 z+on^Y4MvLEnnGRcDbSTZ=2B6riP%Thb@!QMU__Nm62>CL;(fBkprRb2%VebJ)$xy@ zArz2I_tWOm!0DwB-h1v3mCQ(=%%)JbEVtMlhqn}nSe?XRk0R%%g zX#Z@x*Pz{ta>2EJvu#pNX|}CtP7#@HC9^eX(-a%)5oWpZ9kgofZ6rQnv~Vk2*JN5A zo2>bSKzXA26+Th^f=YRAy6S{nInNYIfs%nN#YGuFhE1jvAjafP&h57lQ_2ZiBUs*P zf>AX&p`{zNg8YkU`Q)bxp+H5aC1)LWvRnBKBSR0!P*CG*IB_Q_QPY%_WOyeD#}-<7 zP>Pa(WSTssbjU2bLJiBLvufQAQYsSjA8dIqn-%jyDMhAMYbbcI%IDRmLPgaiEdPNr zlJw^qlSYK>KC!Y?afg4Q&bW)i=}G64yltsB8%<)~D03;OcvJT3Rz2$luoIE4&eMt+QS~0~A2)4l&7E*vGUIM5o3qj3-c)qZ6{59i$S7BfYw32I zr=TJQl_{uBK}`zk5>U2HK%M%bvmHV){r&Hb_5KX+@7%}db$N*A-~@6y?eA~C(C7uG z`-}4)Ui5Q+Tu#fSuV38n{kd1qJD$GdJ8}K_I6l-Ha!1Eb{oFs7zsLO@|8i@;a~%)9 z6zagP(~mv8?YaDQJN3uqkMAMM^6TNx<9&qi>^M#Ej+uO8ZWyPJK|VG&T>C@6BtKvG z@P?IqOcI`E!n-yX%*K6OZpUeOEurFh?*IC5zlXow$otE#m$%P-suh3wB>epbiNqKe zzb<|M`*87Tqwya1@A^xRu*YGT4*gk0HhwqsSV?`rf8WpJG>F$Nf9KZlFwCNkWqk3n zpXOuFpV3x%Gk+N7l|Kre4=eIvC?}V}U zGqGIqA6?&Wf%fnxJZySel4}2-$LW6Qsh;dNd^5#YCOg9VFS?B$Nq2uO@0;_NG_P*j z&*gi60j+TC;i?dN1Pc}aa=P6XUir((P3Pj+1$g8ykLzg81i(9bgT zvkd)@WT^aEhJKczpJnKOBtzBDGW4?y{VYTO|7D21@>{f&rN~C(WDM%hrse1WT8NA^ znhfDEbHo!rQc`zPO3+W6~4o+n702#W#IAb?)93EN1l9|>VGdkI$~2x<5d zHR{A%xEL(I#8!A-L9GasnM5FD{uy#1t%6m8Qk+1vY@ToYYzdhZjub!bEczpk&euh6 z1yz+|3s6Wt;Jp`>TDizAk#baCT6Ec^U-oHLtQiLU+4JgR-AX}3C4@P#xZyn zpkR$mY;_<3Mi~_WK~)6h{x4|qoS}Ay0g~;v9**j0v7AcL5Ww$ za|EiGG=*`|ru!bE*E)q2TLWg# zsmOU#ZYc?07+q1FlxS1ES3&qj<5PL}brIC(%B>uZ&<9Ofrmx-v7mJ$UBPAMY5XpL0 zMyw4bimMUQ>oII{(c_~Qcm&1#z@(4bTnj2su(}r3`!N8{K3|UdizN=vaxmi1+n0hK zb(3glIsr_2?9v_^lbTI#v@AX!Z)1$Nu_@Y^!wc_f#180W3Vr0@f)c8XsNlsTD$nHo z8vtScPIO)Ihu;3MZ2(t#{t=4k{KC%&)mWY&Jx1aCwSU>Ev4~Gn9s>7wgT4&Ep{5uZ z@z`W6D9oZO-GjeayX^1~M$bmZku$SP)eIQ9;(h*0{xx6Gt}wb<=c%f+Ngg78)T~)qT6E67m~@@5 z{n{FA++QfihMq3@Y@1ClnDFs%CB}>w+x^kQCRT5aQ8FT~dACK!fJ>q#H0(PFqN0ry za*d(U*Z~E=sd=g0?2g{(z?l)G5v7L0dtiWSlg1RUq0l!+)mi#AWIaz1f)z|>5-1rI zF1?rynp7Uj=2t>p1OEW@K?KhUSI9*ZBjT^^$ps~w>8GoH*0C`%hLcO&8YG~XE_}D_ zWq3gP>hbXm0vi&#P05V8bYUc(L1H}EvkEoO)#G~iQFC%va!&J21v7S)uzgW=aVEyv z!+g==#Hd9hWq5(oLl$91@odJ=37cixM5gE+3mvT|T#@eIJ9@n$5nJ`s${Hs+CZb)F zGLxj7f8Bx|8~JAG;f!D$dIrNEe~Bl69hP1OTJ1w7%)$>!&1zz$7~9cf5$bq^P)dmf zmSBvKm95rh^mv4vV6~MaB4{3|EWb_h5=MWDk3`wed_2S#qykWr@=euo!P+ETQd>es zs9YHkdICaoOP#3nj6xiK{plz!@&>i)w9d?^sw0^wu~w+;_;9FvlBF6m%2}2jC32ck zeUc#Mfe_05cP$%B$Qo^Svv!-TOIt8dx&&>PY9++bbylrW6OQk4TW1TuBI}A|olqg- z@O4nGqsmE)s#GpXK#BxXCXhOTByy8)yH)2T&$haQEE!ss3@s}@=`k4+YB&K&2U+9^ zsP6Rd8a?3scO?dh2AV3P?Bc(l-$uFw4Zy0CV z^pl0s>=a&qq>)u00o1aeAgJ&f6w?;O2y`t<`bEA{BNTAe=cFoX>$k#kQiYg!e9wK6 z!ar7I{SF@-;0C`a>BSD80idwCx!z^9K0&(jV|z+P*@)2zRxDdtJ`-N}q++Fhd1JS6 zRioJ`(5wl1qV)=`*|@@#2W~)ByVw7-~-(hDkB;cgkRw zsK`AYVHkNz-7`vtp+@%S^K@g)QJ_v@_|>>47p zENvdv4ANAVoDi-y_?Rsu8+wzlPSXm`3mH>{O?FoNx9ygbQ7}xKt%_YMMED~4!` zPligBi}(`|04ywEV*t^I5zAPi7ErJ*g;Cv2*DLNS=0A8=pwtcLkVwHC2DCNq>?&v?T{kZmfifY9m4IM5&4c&~hh+)0FDd zoD7XY!A4w8Hcdj6Jk07~U6sJX^GSX)B?E4hQYNBTRLDg&r0fuus)z}@ge^YBqG>yQ z3jhoaQO0ll8Di<=AttD@oJz_8l>;t6lTykNkR|o1?pifmDkz7a<{hC(u#R}_ zMKmncl;jty+E&SO=S!7O#5j`KqM)%Mtwfrr)}|~qz7_`m%YX}2VNt9|d0syKnP9mAt5R68RrvxP)o56Wgi_X& z>ZIZ23<%b7{~ckdv00ur|7m~-69>VE>vQ_)gY<(;joLTj$K@TBdSJ^T$Xpfaa)`HT<1h$WGvrEIK}R`U zdgl8u`pB?DpVBo}6x@N#+n zFXHliznlAM{O0&@v4p4d`PtJ*`txpBo&#A5NnVGkKO^{W{h|M1H@+NYFwE(HaD@ug zhaPi5hw1Hi;{o0IemtP9Vjq|X3EM+75$H!y7_ZX*qp5`~#0IF?$o~>2dFu+jOX9Z{rx!f2WN&m(nPDoy}&D+q=cS?pixFuR| z_#D!PzbOl#qONRAkw9&l{E98sJd=SY}1(IvEKMsooEv6Ru_Jz>XbsWDfOLy+6Jn{ZGE(^63z9Mnj zpQinJz(PX5qBYqAC%*0b>&r3Xah&Gi?KbtN=`^IQ5E${@(JNs?#3JRSj5&F6%&3-S z5ALV?remY`ar&0@HaX%bHu`6d) z2M3Vd^CG)K><*Z^4Z|5GC%SmqY!R%4AcyI3xr`|Czj3Yrxo8Ks$Zlr0CC7j-0I;#6 z^|ZszeuaN|#0}YPTG-8CzopSMI@R)>#Q0C)KY(^4|G|;eP4=#ZeE8;c>$hHNvf(ie z2xDH+yIcGB({MhBfdlmWX?QLlJ?&A?0i0G$uT)q`Z~@V!I?ayN7X|<49++SutXui{ zh5XM{rh~f09nRmb{Y`Q;$LMeV@d%pdHnVnqCAr#KP&daC`uD$f>o=Uia7MC1j`lE} z4oiB=e>#T36ZUuTIFDdnet5o6p#1a}?09j)`SHWf{qh6XGA{K8>6*6>Cm!&&fYLu7 z{d5334N&|rLkg@OipULV72CN1|kobJcBUoQF_FsJijDHXdMtss|w zpvb&km+ANCw_C4daKpt4YU+H9%koDme4K~t!7(Qi?@4Oa*`0ZQbHadv6@}<)S!E|Ql3Ha@A_|8jG_d#*nE&9yud zKK9?IffMmKoZ%?aw(Jgh`3{w-rFwC?dR}7Nf|SQ`N8i(ncYIv$u8~v9%uf7>U4QhqALe8Kygcs7%$=Gz%{LgTQpW)yjho$Mv1w+L^MIrJ z{bQb;tR0CcPB#tn>EH|__?L-gIrkt>cddNRy;ERkgCb|Qm&A4X&FD=3^(_+m3=0K< zE4k+ET-cY=gy++x|6%WR$Z)-$oP>PGP7)(JR>pF zrE-!u%t0&t>ft^5i=T;}mD4WM{`kXnI61M>c4=XzxBbI=HL*X$IAs`&(Qo|U0eji+ z{UC!sgP8IC&2i7$i}3|BF;1&L!EyY}L~o4ZkLKvKBrXiLxI0BPKTrw$#sBTGi+NUG zRE&x3rt6z?p zc{VrT7~nwXX#C6D&GX_C>)7>=pO!YDVbKeWhW_R><^A;>$AjiIhxv}e8n-XgaEPv1 z)N0cS=qAn0G=gfFRYT_ly_|df`sbPJ>xoDzy^U#nTn~?%!Ryobl4@dl9A3RNq|<8v zLvAXY+1aX(;fKAW-=LM(ymteCalZqA!vMM-MDhpR-;Cp(D+DH3FiO-}e;Mh;E(dZP zfa6oFF?)(jwK5+;qGt99ub~{qnatM@r}@$=QFog9ijBpDKI6&wjeR&9}*iEo4}SQ-aah(#M<}fJgb{+U|LDn)J=B{_Xgf z_&1qpUfR5$=A#yu1+he`bu44S1k=TN|A~58Xy~Wg5WL}D-&=pL&K${4Zg+E#de1?x zZd1uPYH;gy9v&Btja((Lyx} zs#{!7IXvB@vVK=5Go@nP?1OFU83DLUep=|U;_;^XCLW!WP;I1;0J^Z(j*4`vzN|^% ziI3RN0krv&M0A~Ym96M0Ou;^M3WQ|X7#vMcUj~<3^;sG#Cy0tCJJP4J*sav-hUYu# zbCfX7sJjurDYy>aBt*m$*EpGU7CO)rup|NC- ziij|`5`c|%X)Aw&D0aF@zn;#wv#my(DdM+z09gIBMJmWxokUNvtyxFR&N4RzS~ZEe zul$81W3*jLIC_`(Gxe0MnA>byt-x&?OF8*GxJzLJ9@?#Zf;{)yDxV`S0`8U1ljmLX zEfF**S$JX-{Oyh63VIzmw2S-O%BR=sj@z%k+!?b#XGgX4uRx{YZ^9R1Y)nlFvZ0}s zpjr=7jR!5K_zl`l0V>m0C2zG69xq^XG52&zWSk@&&?L#vGzJkdqerx>(h*j<@zbi1 zq?W%ZtzT5D>%3uMFKAg?Ker}OwO^12%?&YO!ddlJ(4NIF0GWv$h?*iACN=}X=ymZq zBhstNax|DAAimO+GA0P1%AiA2tN2Uh@0(EYV#7>6x6abAvQ4z>Aj{Mi!-4YD4iu<5 zDCV_MKPat;9Uv+h{Q^*J*m~Jpv({T{jjKz>pIhV4Q(ICb+be&ECF(sfcJ#SrAH`Pi zo+NIOCm$$`anKv@ei1d1lU@xwP4`e@6sz+0PgaIj7AsUnwPG(cRh)Z-N7d3_H`4PI z1EQDL0&xYh$}GkZEef{v-NcE-Ky2kNpl}e~kzlbYfR!A!5l54Nu0BO)TPrxQi3iZo zCR1nq0Lx{!Isykx%5<<^W-w&LM&rM@;pb-XgU0xFo5cJ!EM@{NI&p~ZApU79G5I`` zlQ3c=mA?lfIxEG7(wMt{tjf<*{8~(D6Jv@FM>)Hn^h_^}n>|yZCk`o7^5GU%e6^0h z+o%;QCE*fuCIeOg$_-Nck|Unfe30}4%Q7In{`79N>Z~ysK(}cnog@t^iyf+TF6c`b z%Rmy$HWQyMT2*S2(?d}-*G-r`i>5-WCK*v}jl9m(iF4)et{8)Qi>H<}^++tn7UQCW%fV@7;Rj&jw`a{`~#r53H{_7MQQ$`vRx5=0YDyI!H?=s zKGT|?>DGTyZa=~0>a?ixH&&8C;ew~&O2#_V295o@c6C&Avw@|sN_xoyHP=2V(*m$e zoxN>B=D|i~$3p8u=2Zcx)Ms!2tjv=l#Hl(Z`lqeYUv7+C-Q$%N69AvHu^QAsH72u) z09+|>(r=ScJ?*4T=;ZI}@~%*!@j#zMXGt$q$#;EpMY=*oWqi3dCy(-xfJ=k?==`a5 zBAKv;XK+(#46L*VRyIb{HqGD$ut8{a`I4qpW;D&!ntY~$s0^^`!I&1hd)mzn>ziJr)n(b=+{koPcufEWvD^a&f3d95(V~rbQjT>W)8-s?8 zvB*u8^hb4S6uhAW4Z@Biz^jjqKz zFup0i?4aE^`wN2McPxo;+zn*S;mqUX*k4~_2!y!$TL!-!k<<$?_VdTE8v)Vz=qY9H zFRy7L5$@0aZquLd{q_7p*u8hqWstx4PIp03BK@7F*{c8#j%506KS8D#A{{0S<`@2! zbbq~{#{Dsj8}IYu?bLqSJMW`UCOh4a56pWzQQz}%*p1Uj1;-BK`F8a1@X2339M4>K zNE(Qq-B0^?xG>z8fcQk{CtZp7)A+>GBk)!F*?)x33y2bDEWYvr_F?1P_4{wJDIe*A znV9>e8aguV^uZ-N*Bs&C*T!!K z{v*txzu=(_TuSi=k%T+V{s!=$@1yvc|CQTwFBg%at_SAB~V!<%={P z&cl<@h3OoJa-uOzUdI3WI9;5qdDM7{k^&Y!l-k70a)FJ+LJy9VR}?()rMuTXe9asb zFXfQB-0)FsBJU5*V>l+Ca;iAYijmNP&!_!2sf%`wXO2kL|G@iZ3vk|#gQ@`FuM3M+ z^%GwZKla#rSc-t*3&5orvEi~NV~364`-KW#QA886JC9EMK9(EaS~Sm(;cZEKM$6%~ z>n3T$@`t^a-;EzAd%yD6aTlP!J}03-9mV6L)7KF3OIBv29A{?g_;5zXJpbJvsL11P zeBnh>w?6ah{C#1g<&t$DuBrZh|Lb_?ed-TzH!aa*AiwdFfa?uE=28AemoIs+n}+Xn zQ6v5I>H5v-&@kHH_UF@k{}bWJ)^n8$JMFm8f(HP#!}}3jGN$o^RDCF1=3sOiSm=2N zy}n)GX(+`W(_@b*<&V?ZI}Q#wA0rR{cYoavuYh=kD;9iR5F?C0TaJ-!@h?C^yjN*`+f;1IjwVz6KWC&oDg{QlT~lN1DKoUi!6 zZFqZkW|3RUrLN-JZ|P3$(rOxhnMDBuDdF>hz1w2@xxp<0S>nBLW}K(X&T=?T$5320>=Y~-T0WU1_CG$ zZXYt=!l7=%IVhHB^!LmCZhy)4&(EG_+j>x~jy>v`AUVg=1?1E(hf*C!Cuou4VgIo|j(cz* z=rR*`gqMmQQA0+>b*5qieAOI`sK$-`{w$p7=z<^)YF@+-FvI6?--p0);Xj+$`G!rw z=~?=AppIk1LGM2H=hq>T&hcri(mde@=RNLGQfJ?W#rPiSk+6^;fQ~&gi%deaRbXqKEtV|;q z(>Z!{&)x?G9LTxc2c}+_AM23wysFd}B)E=zOS)lu5-5HHQx$$2(=HE4Z6}+>&EDgB zjeDZ#;OxWffLJf8CG@(vd0HlyLQ=B2>m<&|le}D8_;u+$r>z z+}0gDw@B_gETsUI%{mOjjk~^nhq1uJ7rAbOqCFu(!>DwCE6ryNC5P?rAr#;UzVKlh zexjZ1gX%*coFRZ22^ly%zUg4c!R--x9=MQ_=g?Pec>y*DBcL6J1G-hh!8$ygV;F4@ z-xD2Pvm+e~3Nh9k`VXf$FDIG>qx(|8U`IDyBZU{RcR`pAUf{>2Uo$$n;QjqlFEs${ z@8e)i5^!U>onA^CUHOKM>w)3$bQ`@{Zfu5+*X=G{WVdx3QLlytMi9Ct(l;2K=?XO& zxM3X5r@!?O;$U@o=D$A1J5-|Lc)x9l*2BliK%>O1Gmme&7U61Z&{6}GkW|BF!QEe+ zho5@y%|acyAN!Y3p=hkNZXT$fTlDRQYuH{h!{aQ8^pKzs)Z6a-cz@S^nb;{(*t>F)M)x(q#FME?w{e!VS0pM>fQZb;4Pf;%~RbIW>5SYvve?%`X)33-2<6JC8xlN8#I{@_%- z5-#VpIqmcvH=1I*0;R>SW9&L7}C~GK#&E;+afWkdr5!%bZsPP3;~};jodEknB-+e;jOXY|huz2m zy--~>&JrJZJ2TegQ1f%>;AI3^`GA{Caeg5#hVUy@z=($tP^|MMLlX`}dXur>0whl$ zehea@?97Qw3>QY%h}SGoA@KY4M1=pprpov<6djDz=#W)Nm%g@j1g%r z0jPasw4f5j{V|>1->HH`aI3w}1*9*-7298iSzDK|n_(W^$K(92&XpYKJkX9ZoL_!C zE@24u9OjzfIP{Cx5jWI=YEFnoc}#a4pb*V$o_{+ss6FO! zV8`fTlNF%0`wIam8VqLwPs-#B@ObKaTkILY34LMeSCOPV_MpQ zkI_Zq(!5~iT!H`bG^cbg|uvpwjalj zU2ku&jw5WTrQJqY|B`;x(Fca2s$M`WEE-sHfwK^c58xh0rwjHEl|4GzW3VnV`~w2S z^Rl~?lyC^XN!)*neRBCDEF2FsMYdc}TwO+;`{Q`+=XkOnSPS5g z%V2KEetf{zXqL9(+i>k!2s|#c6W=RO$6>*7V0rF?u`xT;)yW}NarMUQ8`Z%V?D*|O z^{th!{>}L=-pU5s0l^n*L$bhuQOR=n-TDH*Kt<8ZWA?*|NU>!N%(lS2104LISew;L zG(yb4FQGiU!Ygi}#_Q03TT>hk7fy+oZ8#4*=X_jcu6f6C-=gt7heNsDn^YU%N_uz0?;|hxIFRyt zFL3C}0{%Ru08K!$zYhr%vV6kYEfD9QK(TnepIUH1u!6R|fMxaCR&BIEb03H2tQ_@A z|C2@C*O+S5%l<&cP^17}_D)6bj}&6_!MV%OAJAv!eiTAydD3%%2*W-E7=q?^!aBeW zXs%{2u%)?#7ZM^xmUDP70MW<0oc46a!$)zF2gIRsgfFqolTxw*XCu9!1rzJQv_s%p z8H@^TTEu35IdxeHfh;)K9f=)K+_p2la>1(-+!y9~ixB4?^$J3b&i>|vMLNTmB@AlB z^Dcg?E@|!9#Yio<;JZ9hR3p#A5%u35mtKC^L80Z_p0-Uwn#RfX-CyH#0a1lH+~8Bl zSh6;ECS8+{{u;ITM(JL}6Jdh}bI|XYwHFNLWt^9a%s%uI9kFFs{MMi7Of!h|0{(F3 z7Y8WMl@RL&a`ENh`QZ^_@2BZ+{ZZ~Y5%V}x&0uet_|BXKdpwx?n!%U9j4wK46yfH} z;~Y-QnMq|-e@ySLyO{FMHRofx9bbB*dF`FM>ley9wbvfDg1y+jIam4)d3HUI--puz z=dwpV=ymXQ*o~TJuEQc-R1F}cRv zj2!vv@C;fha25h%7=&q`>*=SkTULWw>gvOdmR+~?k0NNh&i2vS+Z3_L@6F( z(X#?Pg)jOqgp3D3e4vOkG!kM!9a&ijge9Lyo$&_zx2xOET0dl7yhcG~=jDcnV1Gm{ z92w3YB7gE1I)tlwg^g7rr;oco28W!I441pvH!-BeS(_=U~ia3A_5?meGnf# z0D~vU)wV-~h;#H`#@z|SI({b!Nm6h(0)v=pBRuXWr$HzqgSM`4w6h#d&wO>l0oZ~j zF6T=Q@X6a%=?wrXydomND&=(~pNXABJJ9zbkSi+r@pi;bq_i0$7>lIaX+l?(xpd=C zKIAKw;MVW&ZqqyS6OLPdzPyqwvzyby19}d8KLX(6;=O}k%~cj8E2OR^K=~~*7Ja@g zF?u7GcyrtQG=%A()1K|or7M3MhPT6DT=lpOXF4KdKGSWu?56RX-hJGL>+JXir6Pvf zZo^#!T%#uoZw&NqDR0MzK6t&IJ_f&^4B?~O>D#~}31k6or}KChj>Gb@Mi3P)a|#M9&bKbgSUt9OApSD?~EsTGV>K0`p%Wa z-rNq*pT@+goL?7TPF{v)ZcZBHttdaW(T#P|$4m%i%q!d;!iMvT7{UazH-{l`<9C}N z&6{(FIKFm1`Q8XtEnq9EdvoMF9hMhtrF9g(L-6@-ubv(u47B>|0}O=E){Lx5aJC@} zuC7CoGs*nMzdiSJD?xz`zuN*c^X68*pDdd;`HH7pGfN@!O=cO>pMbND<3r_*1Omi2 z$D`?AkUkAC=6y_Ke*A1;NNfuHyFB5;L-tV$WqJ|YT(XsO zP5fgS;u2mA2rW&Bhl9eM;%ce=Grk_Qs2IUl3pl8X7??u@f6GIdBY1Tv9>uUh&CZz9 zMOHzlXM9CA^3C{@cl4si;DD}$+M-VnNP#f;G3Dh|0&C^l-T1;oRsp({Yd zfPM&AF7S{CEZ7Dx+9WT)Sd&3;#bx~VvS{MAF4tZ~|9F>hl)5_+p9Q@*U&p7Bsk-6f z3QgiZHZVhYlrmZ!d>U*eZh^?v=mfo#W(FYWt+bUegL+Ag)U3F9giVY{10u{w1ERu} zfOu7ho>dFkJi!@2L~tXL2~dWAboUSpL5aW{k^2#61v&@?$Ci|`!JR_!&L}e)H+(0Z zoj0+?T+Fn2){H2RVFuy;FK_94i;fOBoaNCcf|3IUI9fX!{`CxRkWF_Z2FtRqeWNfg zu>^1i{iI;K-)=0x7msGoK0F=luWQ0u1om0L#kniuUhRo6Gz2GHT9k6;1(>YVF7acvwTgaFxi;pvy~ z9$c_I&(k||A*cvQU{E+DSFXjqck1HW@6%-Xdk?v7$1(Ab9Me5gQN8!C^Qbe=3CQG> zDH1})+yYK?c&xCO2T|TG`gaT!Z{Ej~Q}1^*fbll>Cv_(r+&E@-xJg>7LLE;xjGzB% z>c3-wlm2o(IR`+r*uX>Y&X*$lIF5J#A{s2YwB!-}-no3|;kpR7YBwDCOXeh-yup$U z?Q`aTHzXgzblR9J0|j?zU05r6RF@8=Rpm(DP=xV*aqchvG?X_Yqw;lccX+c28Telv z8wA$oZSdJd+UWn^<8@Gj-vSNX_TxKwa^|O8BezfSPpPhv8(OJ+6Jzn9)spZaxn*Sn zuJr}#@&s14NCu5ghnRA0EfO#dUbKg-IBf7)W0^~`I%inZB%EiD(lwo&|3AUQ(h$({ zM5bGOB2gYvOn&cv0K#ktaISc=RYuM*HG>BR|Z$WSLt%gdJ~ar zZV%tJx{OOzleb{8*G5;mX*gz%F_BLiF5ACZO!+Y@I(*QnxJ zC70#X(YjXhj{3@2s98`IdYxQ@Z{X~OTpe>E%0-oYrYv0xsAjch!5sQEjJMNeg}T+3 zoLT9aq#tojmRsO+d?Q;=*EON*w(HVgdErei$J#V@?IAZJX5pA5bgkss%Nn1$hHzfg zAIBA8ue_aG<%R0W^`-Sk{JFFFp{(vrRhI%2P}i%(XauE}Cn}5_ zLv{F?!=UYwiRPWf)hV@$1XAvUxgM?sgyoMu=#)+yTHM?gs{dqC8)8*9c4HN~mAQAI z!Q4)H{#5*3tB+xvKB6yXPPyB>8F`uMv|Kyb@=aeYSce3PCmQC=oN8kG-GtI%$RT0X z?Hz6`#6_W{#SY6T+I>3p*N?Nx^l?yMb4o-5rty^qpzhO)a=z%4=MFL4m)__AId7=XHX_RNbgP%%1$x4@qIe(E$D3$t7_Uw8LKlJL)JoS;doBkN;TX=J zN;vS0LySil&v?cm&(C?_?Mbmg876d4fA=d0&=X>g?ACHj^?43Ef9ZgQ(4NCt7KRW6 zgBmpY3d++>>I@l*6;)^&%J(3pK{W zm{^aCB9a@_05R5xU4)x4kyQZhbB0A)4PyL-Rz77wsB51Hv#!1^n5fA4Tu+X_;To!y zf$=<@55CMKJJMRqz!V1-AwxKPZNoAgd{f^^>G=O3ui`>Dp#k0C$%K;HX~OmCx}HPbsV1IXpA4XJWp`+o7Crhf-Sgu zGKnzpb3DJl-A;FXfC2D%4*^4^Dje5F5=C3MT>tVVmty_}faBR^nCc4;)etO*-yjBrxFmP0h=e1x3F*Yd=2EP@p5k32fIla?uGq*^G|<+$K3v}|NT#S zv)$I>gdQM`5Osib@~kn=$szF(jd+p>pj@`+^8|FkuVpX`3b;}a2OrOnpG-!G4`;|h zbnJKHO$LX=_cN$HPu!0J46U-yy2z7p;UgQWS*gdGS4x%@LH&!!T-E{HM#^Za;UgRP z5!D;MvLnZYuk6^((^=tLJAn%RXR6EU6Ba4cB`lLAEL(Hh)%KH)>77(Dk}CgX(JE^$ zgerya29QcHiAnlwn+_q{76z;|V3h%@4Op}OzzCqb^e0)m>>0h{vLco_&V=tD0GM0A zLOcHetSn%oGWh}4N}I^M@32*ZW}R|nB|1F&{0wW6qhgU?qZa|!u8%^CN=#8Yg@A7q zUVb!B$I0!CYk|+*gxkpR>^hneXeEi73?xt)G8*dYNKImHS9DE=$Sgk6LRSIG#$gQ6S)3j z)#_@!Ds;wIeT+APj%^C<(ixzRN~AG}V4wv^h!ngg{oHFw@+GE+lv zdzXLCxLu_kBmlOG*p|?46i;+r+EanLPEw>$x^9z-EVZlFCsv^T{$w?ZY-@ZwQ^eBL zEV5i`P08d%_W5cSs0QUcvdVzfsVEm&^C^=T=uMeIowltw*QyWU<74aEp`h2qmdA4< zEqre*90G0g*j8fDCsGvjPY)_%v`N%mQ5xb@(2IornHD*0)UbxhBXp+~Fz4W++8S$u zu8(5ts#0@M9o3(i)S_w<>NAGoj#p8&Us%-wb?mBu7mYQkCQF4!wJ42gH?`961wOZ~ zmI&Wl7b{a*ilE#U7O=8_wFPV}U~2(818C`oGPA~+8H`v~#yI_R?I9J9-P7{%7-*G; z;GANe3Q5B4PlX;W>q!V%h4hM_q*DvP)GBrfutvxK{xiyEg~a2fO#s*>iDwRq)Z_k5 zg|uVyL}keVW%{F9I&Fd)StI4yG|&}pO8IBHRr;a|wU;H&QhMciS{OL~ppe7ks51Eu zj^@D_r=~ydviVRAQ9^5?gr&-4lpH{X zDDun+hdM+pSe4Kh4F(mWvnHtr(IqZ>RdZqs@u~AwF0R*yURSmvk#1Ur-nN=y-F+e) z%{HN6qZJE2V;h))ro*^HFKAQ9a3X+8(MR%&+8B6a2_Nnc^zYl;gcde8iv?**%>Zq~ z7LU_hEqvRF^-|a_Th|g@ZsD}8=?Y{1U70LcS1SdV44vBXd9mhf06xDQT6fxd)LX?F z^|n}@W4$eP<4OZ+4QMo=)c~c{>TJ6{f-KjKixsJ?Gdr?-&6>5&I{n*D&8tB| z@*g$A71@Z!W!ktBjrk%q6jrlwZQ50rfRJu>51AaMrN`*;VR85m(1NMepi7fhCBNnX zfQp?#Y{an%U~65}0f0^VCBn2;^G*(zOF@hLwcJ+N=pQ1T=&}L-eLR|iR)su#0#IoT z136C9Bh#|Y$JSWgwEA=jfSuJhdS~iD5a5~7FS=%7-H5tLmxVg=1z5>1d;p-5=XU_A z8JXFoFF-Z?01W^wBRlt%T6D~e1rgNeE}BMVHbrpLZNN+RL3RMI$x<{{JHmA-$Inuqh*$+two0|B?tO#a*T~ULIhBkfG`%2e4mh+ z;5_ENv=~<96ZBVq_^P5ym<|{H!5}Idt4XcY5juxridCx^6rDfD4(u}Oh6_TSu4%o( zX?0=j5VXZ>>(Y|*x-#Usu2(+PrA#H(s@bOe+GOHk0-Mp%jg)<*y0IyZ?klB1nw7z$ z$7sOYf5i4Qom$9to9aBmm?jRrP+Ml&Li80@kh61J>H+A9x3$UeglyT3P&QBAq*JU( zOC@-)GgcVA*+lDWMS_lPUGc~`4GZv<{(Mm@il9*xP1*!vejIIK0=3AACXju#EYs>; z0A}Rcd@`@nBx2d>uXGB47Eb@J(xa)!zGOfafM{&;TMBr~V8NxqyHGOeYhjHI;3O6Ro$uo1wG&X9Laa3^5EQ*vQAH;z>z z%d~y1yWa4zuqSlc^iWwcg2eKqfbBFna0el>y)_N#XpsLXw^kWdXhPX4r9_oZwn}-a z$#|k1o4Mdi6mvd0l5;h>#$NZvT6dKDv~81)vHfI?s7WVEE;$G)h!sX?GsSa!YN#02 zkhPW@*>S50g-)I(RF{Zf9BHhE&$$YHKCVB|y-Q}8{IFqaCLvtcYI0M$nFQtPQcYTn+^2J<*{m&P+=b;82iyE;+*c^)XWj1CZ9=9zr5q|%oPt~| z)TrqqqsPf7UDSpgWQ80(r)ANanu(BIqEY?rABQbfo~47-38c}G>PwTDRhCZ9Dy{Id zdTZ&ntS%DL*5$g-kf8%7*Bjk8F>_u`7+SQpbz+)DO`x`PRE91eU&E%*^3s$FLfM=N zM^Hki0WVkJ)e5{`fj2Agb_L$qaMTh8JX?XU%r`5(GFp^3YW%eN`ll;Y$X2M3uTVc< z=_hYKRnnBtnf|WMzZ`nqec7a0S%YR}7R}0NniXm^D?HJze7CjNm{R;?T|xjCHn>cR zhx*!6fUWEw)ya>-T`2zR#{P9{g0rm+wx>gBfyNLBU)nztS$J%$xKW8OCMUl{`xvi$ zvFlTSEp$FYOBsLhh7EvJ9*G}_&k=Gh8ky4NAg#K#=3sb0A@VC7(54jbM+9+ndWnOS za{4U$gOgYUlijbHwCmOyS(j3Wwjc2>6TmV#3PHOvH>XFpG}tXL0G3~@CZ12Qmx^=; zjl%O4DQyO(hz=h!T5tYaJgNv^+h|hJEe<&vJcfu&Ql;`IiR&{O{heOM=vAB2-TMk0 zRvYV6=LW3!d_r|8hr(L2QPo^lHsKUQay@z;aa9#J){Vh>=yyo!)W&rEmeUV&O(Ql2 z3CIGlRVLlfA(ipFnwR=LAqpBI@`YCk%JP#u1&-n6JS@- zZUGBB>BwO!qu<)Dm0(TCCpY zV%!>As)qsuIoe9pfdhz?Y@Gr`jSRVIt;uv-k*U;DTDojS`Qm9X*nH-iev(^1AzaC; z7;WM^JAkVsjy?4$n$@hHP5SXhl}Q3vr(T0}FbZZ#Sf*YW<)1~9VxF>5Qo4dwyuuLx zYVoiTKpNeol@wq#KOC|XHMtxl7u7OE<2>h2o=bA%g?@l7R-16x@-y=0>KKc&L9C79 z+SO`md6QI40%)7esZ&3sy#!knUPK2PWbENY{Q=x8#PRx48LS|SYgEY!NdT(#TkhgK zrKZ3d8#wJJB)RB5Z+O%(egO9pyaO~%p{R#!T_~#f$_VNaJ^iyvKbad?%QLcUpRg$I z6hxzHE5%zJKGh-9m;E5uy;9i(4v}1^*BI?y^5DBZ#H!KCdA9fMb)z^Is!k_Wbo#Rf<;yKw;*EH!6R;T{h3vk}|B>zs zUI}-j)~l@2H7w0!wgxwr))(4O1sshvlMyw;O0bchBbsSj!l;Fspjz3glxQJKzGN8( z)q#Vpk2MDCRjtJkRrdwY(IuTYHKv)HI@fP&1F#S;CIO^Q7A4g?^_tK(mLN6RszPqo z6d2vhMxyD@<2G51QLb`R>4{OgLsaeFePZ+y>(;c^T)I>!+f09})k>>1lvZmVFsV{?-N}b#ImtWI1a+ASUDtA48@9?K6R@y=r3I`kV4VUx+NQG-$^gtm5$a8X zD|vxx08~~FwE%9!54zaH-~Yb(r$77}3U@A*z2OgdoNGV1A!6m{Klx{Z-H-eI1@=Kz;kH94H9KJ)40WA>o%i2F>S;oP4NSP6A} zUNGUVKb)~1@*f6&nW;ZOUGXJ^*Z+Qe;3;H(2-T?nIJoIt7g6lrV|{0w)W;Po{lb;~ z!`r`&(>MJ1H+;*U|MuKpd{JI3#k&A^^dj>w2cJBLu%jl!c}I1^Ic)d|)!4re*T3O5 zybn$(M?jM3<34*LU>i4W>Cx9;_21oReEZ8tZ=~b9BMWREu4zN8*#Ap7)W+^E zJ9z9^&Hgf6PraDYWtdL;hq=I)6P3yS^Lf0{@2TMWFGt|;p?@Hh>JCR1ygaV#jqcy? ze>V`~>KfCqe!q8m2Dd-G*EezsZvE&zN8ZDLQ)S#ub<+dKy7gT~ti_Yy?A-#+RUUip zzYU5zu6GhCIq%zzzqsRUFFwQ$sOd27k5Z(kkH^dl3gaB7i<3vs5`S?@h8A?X4oYSm zWTtl|dk7+~=!)WlB?^xtn%ISA{*jK z(~7&E3jK2m?cj}6IpiG#Gfs+QcYS!Xk(7XgywgkYjNf zyX1NYb>qNy$c3^2#Pl$@+*#5OSm~%cHSjFq^>Y&Rjb0=X6-E6Z{uEZ9vWM z$MJj+Vlqt2`MBb+!AbmCiuP`BdI6tJ0sci8ELdtJmMSXBl|ta{t_{@gG~H)f>wg=UXDAlv zyc?7{cd$!8?Grn(`*EDEWcWC7&(4#Naw(Z}!THzBx#j}IonBTIWC28YmzMFGa~{dc z2u^cs9A08#R5*|0Mcar4I9!=zC|tm(j!yXQh2~{Kz1>`EQa{mq3D{V&pCcbbq!>4l z3nn~vD+%)$+!`JdzvHH>dT2t_yrKV41?;N(URu@2a78ZLvH+T>rw(X8TJ`^kF}LD4SHk$I>b$+BS5 z{x9#TDUqI>!*b~l%K6&CtA6xzDUH+U_FMh)s_Y7Yy^&+;WeQJ+B^>CnJ1w9s&HGr7r zW8CuiiN?T`)k{1!8+&<8W3qYNJFnGABd&k!!+}qu(bk#xJ`r69|9&!hb^<2p<3Km> zACYR=NRiruZjk2Ffr267bae3RczxUXx9e3mcX&w0?O5Ob57?Xx=l@griu?ZN?B(^J z=QmIjWRl`edk=49hwXZtfLr$YJS2Thx4{P%q9On=Qjz_Bifj|b4d5frBS?^g&2ejU zClGxkScH8)4=VPs?~$QcPqQPyUB-_baCzK}G`%9<{n)>RAUV(|RoC9HwWh_3p7mm8 zVn6KBH4&CzKOFGo+@K*A2=mp$3<7ok<9xzIbFSd&g>D%c@XvOK02})XVvb?X2?40Z z#c8|6l~8Vl$KPG+!A7#6)C~PJdKHF2OaDKdU5o}g*B5Q9UUix z_yyP53x+gDT+d@49~zUZa)bw@Lr-2U!INsV2Fe;Qm=hZ6s7d$7)4u;0)4`n1;iV}~ z@#Ey|!l@@sBm^B%*JMMwKR%}YaejM;0V|50`$r(F7AYUBQE{;`>vd$nDfWJK<%iF3Blg6Wqmz%D&Xp7 z=*;nyEW+dd=64Q~hww3V?V%m|Bb`!2E$Ka;AQS+&COx{1ZhX`+(&^B9jlpI3&@aw1 zpIuO52Xa>8Y8@aG_%=@eA7}5@>_~4T2>umCzx0c$_ejc%^w_q0#?jrL>9uEfV7cT>aGKR_UYw=KatB!{gEGss@DfAj11vgT8#3cthZXL99A}r!YH8 z`d7^*<+c8fq+uPn<1bl{1}dM#D#Oke8=#xsmvL2a)36AfjbUZ{?zn*+zt`bP@j>dCfWT4mDr{y@uu9R`h9WOK@La$MPq`b$Bsj{&keH+Q9r?AmZj~ zHvLt;7ha7zbs41KyBe*}S4pNK{7SSzvWm+YPdCibZkTp+bxFa~9rp6o>*<*43JY*^ARY*miUv3< zcMAtro^vfC6rrv$fe@vgZE<(oINtTezIM$$)~u6z;Ps6N(aTCI3-hLk2tFU$-zsDeZ#tiwYhvS-cLi>jl5@&_vUypbHO5NrVT zDn6CW^|cA+Rr1Xa3pvLPX<+d1p7MEp?Gx>xMjl}YT73^!iFg#(_d9(rkDSw7r>{Y| zJJu~yC(6!HZ#x>T05n!;=Lz=`108E-(FKS%-LomR_r%<^CHtTy0!VJ2_VY1}$(_ak zJD`Pfx2>tu2Ax!kr*pjb18nfGqy7#Az-WtOyWJe%W?d6yrEWX&B{r>&7j9>R?4r^^ z3EFMuv6j0Rr4tizZI{}*3}{joxbs&Dg|>t63aVQ(tffmS^`4qaDwUwpBU_|1?4HfY zd?&1z8h_m+RQE<5vg!J%xr_UvJyeWV>a51`bg!RJTwk_ie_af_IVTtf$JeSbwh^z9 zWG$wkXvA$`L|9M`h{T$}X_VehqbxGA;*+_%)P}s~2kh$xbcbj@*6#boOs*LcbN8)k zMz}FdDPaH+83Lsa0EvBrtq|bCCZe1L2yX_ba?Qd0#_;Owt!m9pS~WA>g7XuRL^pV@ zMAb1!!kbYi*kghLpnZzjehj#yM*J|%vHPmzj=fdfQgaK32x0hOi%Lfi$#vU0ee#YU zb$Io8^JeaD!0-HZEUZLi+nX7!KN!f*%^clTG|CwR!_UMmVqjc%xEVe1aju)>fzW^- zafPgOx*2`;;q6=gXg9#`sTu7CHlM>!}(k*pK49a~}gY2(HC#TpsVG*+t zhIMjQTEyFCdxilt!}8sOA4{LkeqQ>-=g|OP_?#a(XZUVL-BI~)8=Zz6+rWx6lc2jV z9>6(gtbR12sRqgorE{^FHn*9j08L5N$piHfL(`V+;xqM!-UQlbSbKX z8}Rf9*Ki^*Mi2{KA{&iV7k&-5(RAC?%(v{e7*-nR)O+<_L5@5&DYFgh$t!d5aJXA* zsCYmJhrcR)UI8Gw%dxW);5{GLomivU$Gm(^4{(c`_?EJ;#7uIBSFGT;;&uOfWp0)4 zK5ndvh-RC9=0)Xrl=yz0_u73wxJ=V&8=77Uq~qw)J+`z~%-#dT*DY_TFm@ z8(<>y{&*wf(+zQN&gV%2+p~5E+#7DOHGg|?P0lm}oy__0WcSK@TU{E31Z{NHQXNAGwoN7^@qXWdKRlocoCGV)SkR!2Vpfb24)PM`03oiXG6+ z4oDrJ`7^%tM1kut#L+o8N+vad(;EmfPb7=Qgv`WH_vMz^o#;6KSfY%oC@5cvx4CvPT$ z{r!N_y@vfiJw!Xd`IAr8ed6wHVd?uPC#`AVr*$J%;6lreP!k%To;q{*A zBnI4~V;gUbXH3+*4XjI*FYtXp$xeZNH1ku&dqBX4^xd`KN4oq5IvLrMI1~Xi4dXFF zs_KMN1&*+22cSb6uhV6$&S!Z=G`QC3E^I~M?cQkaT(ffEH|4Ac_!04U<%e~}pTD)W z)IA?x=9`lDCn512zg1m_{1F)yRRYB-Z3j#HaZaDkh z8kc3b-8#i$`UpyKB*f6wTEk9W9sp=ITqP8V$1pq->&OG0*@(12LTt&I{TRHe8fY?% zleu&c)Up*dlgIF?nTg;rc$W+!uMKSCeZCs$lKWV$Q+fw~EcXXmPJ%#Pyi-~j^wQNy z6TjBsi;I!@LA8mC2iskql8fSJ+nmvON$G63>@kRm*g%${NgkY=?pd+NLxA<4D?WM6 zU@qh7dN;#dZEkJF+>e{#l?2*>T{aCsY^Lh$q#uRZo#BTS4^!}vO?*-ta~?dLt2-st zZ&i>eWY_;@w2vt`)iByPl)VC|`o6{cTRxrpZ$_cuSJ>a`+bxK?sCfO>bfSOO5d?@N zF&MwOl;AH-O8+*rjeo-=I@51U8hP?t;9n~JKjEhG9-NOy&>aj^#_oI?`U%fn+`rhqlyZ6YX?;c5Taf|v^;HJb0o-dSWU2-{xr2*R7NCkb z{!@5E0;Kom#t0mH<@@h&A0kc7zpCdjkUMe`SNYl0x0H%z_TZI6^=hM{rf-&?pd*QR zNDGzGGX~ra6OaBkus8S4{A|oxv<)Y?jdh#H<;jnv&%U|U!~=;c(ni;)zbgi-*52OI4^95s zmUcYh9V;IBZ8bLDU?^zjj+>w|7=XEq)yeCTIpqeQ86XVd71B5O{SrOBDFy24wy}?y zRl}cH3?{J(xAnQ|l^&8OCj)TiEV6XbLaE)&Q1j4?I2Oo7+COk ziK4Zw4KYS0sM`7~jhFzC>IKo(uSQ<{+6Lag-YZg+zQ<$Bcu!)?3N9X_7Pr@ehgJ`s zqtc_y;QD6fuBt6;GwKVt^KZ?jbE-6L{KDi1j7f%9I1pG5%Z0l>w98dEdU@x$z=(@cLi*(WrG0nG zs=s@#Pq%L=!a9EeiL$PXfy$8|x+EmnRtGp00YP z?ratmwMKtLSa1?5)WK^`o#?R!c(2BK=Cm9!q7BsrCRhx%tGuVSzjuQw^GIiBK$Q%; z2e=toH^?Xm=od}?B*}$W0t`0T{o+s;`9g;QQIrq9pQ}k8gI%R`_zq$I3(OKTXaO4L>WFVp8+jP-K&ak)u1_XaLt$wZUWyutTVEqxmUtOK@-OKHsMW~qOP9H$sul_FZn{*HJ{PJnr`~0^Cqd7jcMt<@xrdsL&?{je=0EVTmW&78 z;-WY;5+q2;xKaSE`@5Hvj7?slwOi=+y;a zZ|LIdQ@^~^A!9;-r=cYPTUOc{_=6vj4k}Is0}d`OwCBV<&_I0fC*j+^sqWRNU55JM z%(E)?!`&?Yr}+HP&P~^(Ie&Uw5egals%J0HWW13&00s8N>{2YjhJBt(E;KW4;!D=v?!%bcrPc(DHTD^`ccnF`X zQCWnq<`b`kY@z4JPlEX(UgjOKxNvn$)-CQyfBstV3wV@$vShNN9eP-&=v0r|^F+~5YN z+EfgD?ODK}g!}7o_Q{kYPZ$Monv$>042|iD%%KBrTl<-IkU<r&3C~z4TSZhx*I*$o;JU$KdabsG(Ph^S-Nl-$4&oi)G$~B9_CHBSVfaBgk_># zpUu8`!t-yp;@9THBmv*g(>a$~pe&Zojin-5?r*eL$*PY8T+=}W_8hyJ~}FYSfP z(&u34HIm1^xHaIR%;8>>0J+CXZaiw_*9+z8=_>66_P-h|#EYhSo5 zKF6k$#>(e7+}%+(pJ-F!{@!29j=J?zb;LG ze)IicBTZz2X8^xrj?W;m&8?)ybT(nuHj`}oe5BU`$T^kfSBO!U#P~Rx<7}=H3xHXp zm=BB20)sJPm=z!Q+ne(JYL9hHtQCwMBjj#S^ue)a*L(HGdt^ax2R7TI8c)$HXL`%FTjU@PjiT1Jn-lV6=SijBv`Z`JDa@?qpge3_Mgg5i3 zr2Cl%0~L50`3Zl4`Svpz9>%%z&N{|H^NWR8EN&Z+$vP%Fa99e9tfM`N& z5@UEk`NfN;Jhe?xaRG3x^&N#W*?&V=CANI(^&8TrRtgNfMno0_k0A0q8fOd=T}S9u zb{sly{EJ%12+@0A7$wIiX+(K?PDHsJH41tpWE=;`d3Z`{ zzN^G0H7@5=TOXJEITdot{gF1B9!eD$3l?@EfQpAn0Fvwi$a%{oqP`4lNJzd+zN7%< zpCzb0^yqqvTy^97>hD|l)wgC|n5yfwCHeBj6=*USls;VB!i^@#<0kTCE6d@^O*PMqWKgU#8&bknE zj;RLD73mNRuiavTC2yLXY!#APza4wz&$y=^@696pBtT-0;@&zz$lUPKz-ij9>ES09 zmgZ4=MK-YBBP5){P#Z$7ho+laC{5rM4-$h4bwXG-aC_#v9t^-XnNKYS9Sp;i>tnbR zC(?tsLXv~Uz~42df%Wqu&uY^HZdNqT?Gt~t$e^mJIkzlHCN=IlWL#dx!wwhT9Elug zuBdU7J=%n<9-Q6{8cq`^WQvj&TMNg60g>2Ee=&D;@g5!Q>{LOrz&Pl54W2&Z#*Q=T znmI{GI(5S*p$O@qPdx&s0T1H1n~YW=ozW&2d=S?OQr!q!mw|qI<%1IoyaJRR2ABHP zhaZRK>3}1dEpjDcBqA5*rRRem98`PtaTuTLFv>6s{`F&!YiBmQ_hLoG-odq(Da5D+ zgAm7kKBAuB(Ao=nJHKwOI`hyEBlM4Z(dZjpz zqD@SA&4-TIAY7xYaF}4|qFR7MrFUBdYmsXX6_dr%#j_|MeHxA-3ssVGIE2q&Mut28 z%Z8LyjMCJ?F<2sy-vBS;;7q|>b{Px=yrukW!Bg$5F~sr>h^`uw$WIStnS=odR)4WT z20lNA&iTxBjJ^HGl?qL!VY%pJy zU#oZt0V=j99rV>L~@L5*!A9LXRL(z#N7z5u00OaLN0oRC8azyZ$W z21E+X(z^}dBGwFm4^&u_l`snHUGxEomcGQHvvfwyNPVNG1vV$!59X+S$2WAcCJWeP z2j;OgE#t`jbf>R6)o#G{gh7MxPCvcsYihKZ-k3L*2%NW9^4JXDpTSNYq{m!c(3gYp zh1soh%BgeJo`)nk#T*J>M)sVm=QAy-J6Ch!Jv3MEmb;jeWLORvHMyXf%s$C)6r!`a z8_8`r*XFERsm(WA{X)ce!V`8UYMRUsC!*W*oe;#_b5ip?7!-IKO`pFFX%GOQrX{ENaBs3C8|BsX%9ExA`w*-IOyi2l3cZa+%>@&HWTj8Y5Fakdk#qoFK%u`&->BPW zd1Cf=!Z*uP=6+eepMxgMbebT>D8Fyj!?^J5{C1O3b4;S{;w9n>qSfYEX?YzK zU@hvuya+3kBglY16;<;EO;aBm0BYLV3)WJ58Mg?TeAj?yd#hrWip4|bg_-yW z6ZsssfsYMftb0zbP|7&UBMA2QNiRdjy2xKV7l4?f8FDoLkJkWfhe6VMhNjVphDbhx zn}%epZzaKpf&TCzp=`sy>a*B~1~!R|MXy>`JvFc?RkmJAN#B=CdLPWqbYQ+vZ4ij| zsxPZ!IB;*2E3eYVl>n5lUPSjZBHmharOvHID9I5mTx15fP+3gFf=J$c}Ehvn`pMNYSRhPBIwz)#&xnUy98p<`3o6>xZ7_QGcORE*;dwFH7$pgut8_($XkS1RPh>xs_<9Hh>a8E$bsWD%0 zsRrmgez+Xp@lg0t*D)baT(%z#e;N84fWYhi|NW=DD6=eTQrlEk=H))m(`n{qcKArP zdtMfYBTanixNRHF^rl6&i-vpRJp`CNGwA~)k35_7a^#jd11diF&yOD;J27el+*2M0 zK$ZgX6i}pqG6fv9FP->4F`(Tp`Qa`d&F+vA)xdJ4_gz7Hr}+~BS1O%oL)@q1*_Xt~ zu=R%TD59XrDfx}#gQVxfju_6_?+<%9xWj?lHUN~f`C(6Nnul~<9QX0xI862-KbmRm zk);cgDGQS2Vr8>}941?7dV(e;Ei{yT_NXQj2h53?1j5pgGsv{cck6xUdkwecIGj}!D~@s}A?Bpc$05treN*nmcgswfaj8j*ve1B~20SL@FVpon?taE9yHvpLJ`O1$ zO96?*-8;#hoEmhp4bhx@qW)elqrMkcPk%t5}O!F0Cr3z z$$lq}js5RE>r%xR;WWz z^0|{^O&2lv!!PhFYkN@0e<;)Sc1K9k>XcsL4KVv);x8OJ^26?&;az42jkt(g;h#(! zMlLuuU)ejkT>d=QdMs9cUkZ-OSJvK%aP}!D#IZ@{_F1~) z&BfrHu=Gz$)U_6n%Et~)xsA6$02Rsm6u?Rr&zK;O;&N`Fdp^z}uL@J)(1|-Xq{H#2{hvhtPC~g$ zbLf5>KS(_tp!q?3GRxR8$;gp$>d_@}T66eLpgxHrnQ#)7K+le(c=Az*2#E9c0+R7$$I%rr= zZv;y?xHw6gIFZMiIr$cbozTk(Hl&xjR%OZ9V6~%@`10|CF&F;`HrdI~BStR3G2qJWJX!MlDy<2^ID0pzg$xlH?9&R)*wS(fb!_5;2JGpBp0ylf7Xn z$+~4nMs<3x%<^42pL{R4%ITA|ULYG`p8ss+@`8mf#$FP-I1O`UC5oR>)X8EkAu%8i z3att%!UDZjXm_-newURmK>Tv5k|oUbLiNEaeyF+)f{S390oZ zJMHx;C2VlY6gr;rL{x{DT|jEfaZ0&_k~eoJ2meU!3>_&j(}1}GbmBhbAdc2|gVH|d zM0u!h8DE3}m$BU8ff3U#j)_@5cOsg8+X-guoz^-A+>Sp}0cfARtkDI|4+qhGzB=oJ z7@ZT-?0Zf)vr#$WOx_<1D3hRkoqX;#AIPU|g*=yEaZ)$Tg>~tKWkP><7Y}u$xy;L* z2qmOsA9FI4@Nr)3>5B|a85Q3Bpw&SeB&U3lrOQ<0g7Qw@M#lyKi4`jZKb4%#Y2$LY zz9i*dp`W|MGqRL&pibO!P$@yD)hA(tbt0Cr*>RV?RKoSUI?TG5@ey!*-$~d^fO=AEF-!9qNg_h#QELF4#RGE`273W;^CM1CYJ; zL&UAGf)h$)NKPb?FKa|{*K)bLw|qz?Wj9v+jHzqQ!`vBuMACRm~Fki=AA#EK^_w8Khs5 zWFpyd%>?4`ZW~g3(m}pYC*-zDa68(@)VHivu|jD^O=ny4Qkcoc(rG!gE(5n)Vyn{{ zOBWy7R>8;PxE!Lv{^u0)IuD%@8@E$$7>hbxMrBc_)G&WQ1NDl{3fWM(A!vFf-&1lt7s1kHX z*BSidZOt=(99mGg|2-68h3j*7_Ol-`#qXb8jyHS~FyLo}Wn}-`GWONvpyy!>AtnF! z;MBcpyi}OoBkIT1*!)0mS|eX(2kOx)mQ2E|*y57yxqks{dc;WoFf)fZzu7I;Kw`os z8?81W*vC<0_7?!ovCh4viG_i?D3(vRsh9w`_&AmSQFl=31Ylpog{6M4-aJL%xxz>u zPZh&>mvL}WxW8XI@ItoGv2yIk>3yzzedFIhtcS0I^&#Nbvp!U;hF2B42f^ds(eYeO znEq~fUlA~2vp<8Qt_9f=bJ5!y>vvhy4~$RzSI<=dt*hA=+Y8^Xt1ln74$iw~*IMQ_ z|6MMhgVQkxn{OjQ>@R~OdgQ8etT_}3ey(ebpCQD9^g%TB&g`KzT~rHTndi(5Dm z{91fnt>>EpH&cBX`s=3oIrdlRU*Nw|w9g3lKj#6**ndJObHC|_f7$F%$-BNKZvPY9 zA;tB`51br6s+W=;5*_dXjjwki^v*TJTf3W~Egf%RA>Z~Nq2l4ib6#`boLf(Hzs~?l zN(}v>Uh3Atrw)T+aP5saZ7Y09Jh#>5^V~Rop)%4Ue6VT|ery;muS~a%+`f&U`i~m+ zUGmBqJHSBi5Uu9GhVE~+P1<4TLOg;$q<{vIf&Y7D%UO6hlH%Lz9qO_A&W19nvB#s#CfZ9!NE@zM2BQ8?R_g)ui@WN@E3(5$rMg)Qe; zz{@RZ6Uj1SVia#!@Y0|NRM4x;u{abzDf! z_7v3)%QN7KMq(6z?u@ax{_q0Wvl;qtw%)W;2&$<@)YI2`#=Qd|jLY=jO{VpLWFA

3WupJke8CQyZ2iTHq<;&0+A%fL1;s6ID zXUA(?41Wo5I2?80v|;qyoSl*uKBhfSu9Ss894&0FW0_Q>q=TDZd7c}txXdgYoO^A+Qw|KLixTkw0p92(UNrR8P#QC$;|(FpHUb2NaL*F358 zG#_q4QM9lSF-bgGhq0a$t;Q<-=}(8@)Bl*T$a0QzWTO7Ev{1&|Usl-aI1Z+|QKYe% zm%FjDov=)DtKiatw)3tw|AhzMwQAZo+sju+WJYcW3j&QIdLM|$nf(e`hRi8@=bU1} zUk%pBqVTV}_ccnQ{4wArhV@5n2H;bqWW0l3tCvf~Ko8w(y?HhB%opuiU#fW-DRW%w zn#i9ZaRD5e-tVrf5jAG?HC37o%m+`#jbwm>%SYw6;WcRB?94IXuOtiowKlh1%y$-v zQori-w6(C!T%9pWHaSDDO^4YT=x>Z(WyJKnL7(jrF3Z+zd=5kMP4>TTa`<%<_n4p{ za)J1s=pwchLBsYKWI%mlW!^uAW+OY(!fNB`MGms9SNr(!i7lf!f~@55^6B8h7{=xn zvLeP^n~MJH2nQbOF;o+~E@#u_w=-15`%y9d98!Y%K<1z$U(H||X3y}ocIf`M*(%SK zkIf|tvctelB}!FlZ?SlzqZ#D|j`Ujld6X`ZG!}6;f^`sSIV}oqQCD@C-Gb@C@Mf`1 z%p|r^tYjDt1`D@o^3RsbiJ`_-YMV!MV*0c$|24Fq0Yy@TvQ@$)pP-iz;gas9x(bOI zgbESTB#=Y1#-y)A6f^N&UonM^*MsU{>5OW#J)LYuDZ-e3y6w5*(iE~Zo154WdfgI( zV-0E&8jikiez)McR<~G9fgX!So^{DWx1U^aF)I+=NTy{ZVmN%_{n6kbnDab2*S2Jj z5_tu$S;5}RN7*F=_L`=__{|xqWkc=DZ~HyJ&ql-{MZCw<)(Ms>l&Hj}2GEJADqEtK zg$3xknwgSSb0 z3=-X<_IVV-IKM;Qt8X{6m%)CuF$(51v}T{ruAB4{!6FoZK)j|m2bZ;+h};ZnQ2c!o#1ljRt0gJ?)aP8%UA>{XqGo4_iJSv z-9ow#&x5xX@=rVK3AscWu-V-%8y;0wyml%EF@QE|2On*Oab^j2pJ2Vjj-u*jJ|9xl zc?ITXj)F_WZl?f(njjpcn42{9F1skx&^*?YK%oOb8mdH;D%Ry8+oAJ_`ih z@VBw4{S$yc;SFq_E)OZyZ#G4sH+lNw;|5ik@vurLeK!k7&WOmKcf+=Q#gyfH6&Y5u zEC#tp_%rI?+o)vTYoiy7`?-M*6&v@xHl%Hqjthi)C=@3q6H?(v-gU5WTMrw{z`r!( z-@Q4bv|&5>-n5qo80Qit;SNdQ(oX_xdSVjVa33;Ym}+WYhl(q~9u$pp0KQ|gV@9Rc zzE#sR6hNWMDQ-lU9G&v^9ZJoRLn@9-)nx9&{HS}cQjp1kk)ZE zJ&_IH1|-HhKwg)1aPp`TFQFl+7UenrWGb1DNXf7-2}4=}ZJGKSE#mZ~XOBF5W5b97 zN3nsWcx~`|=~2O*H{sIC&NtS!ywDtEc6JM1Vzb3V$hMW-3}+pDT3s;v1!{n@gm2&x0RWck%bgTPQm5vOzKRvJFmCpRvb6zz4x6O1J zxGzQ9ST85hfe~JUndW*fF5zw!I{AT@0%xj!~~uPAkpQ1=L$qv%rpz< z73!JO4oefCeZ9I3kKf4~tk*@ti#6-xxw-iqm*Jx8z?c{^3TU$(y`w@@D%*E|VGa!j z(9>PiRTpXN?y6{Ljhly@57=Nc{dK{I9xvBBBVlT$cUTCyO8Wgm1h8uo&P}3ZcGY-p z>r^}#XyY?dr*LgHWAhv)t@Y$@Fp(Uie?`D!q^rJrx)NosbHb5O+Ca!t82EP?AImLsn^K4Bme4UZT4erb+jJw7b4ha02+?Z;Sw_CN$l6X&D~C*bvNyFszaI zu>-WGU;m@K8UgA(VjYsW#_#}NO`q}I-fx+{epIhd=G@%dr)%Ae>Cm`?s4b#*s++$A z&?BBb5czjjuI2Z2|D$d>nfm&*shy@2uZaNkDS|zrq2Qo{5b0!q&Y6taXyfA{T}cPs z2-6TfnoJw-li&|a1DX|EXb(v#;!Ot+g}xcg3XIIVryo$%$F1KF-oV9NGYt4PwCNj@ zHEC?lX#b9%a$wK1Y+EA^Z2J$!1_b`iRca+A$M6~2HClfXM!bLAW+b*K`}Xa-jY7g9 z{Q)00E5fx`2ZF-42YLWHtk*plbrG&P0@$y5s zWtbkH=6}&l5N|=lJn&w@Coe2vkcI{;(w^;IWbn4i@X6uD5&&FNMFs>ftVbc+8ToG*}%p7r^h=;_u#Hl`}sv-ahW zysUY8&5gZz@ZXF!B7l038Q8*Y8c$oeu4Wf5t?aFt+uc`53^#@+`2F~&Zql}O^=`hA zDy1p0)F!gc%r{hcD2nuQmK0J{H7bS{H+9~U?o|n@GYN@JH<7&MX%1(BZD^n_kh~GKdwfpy6_xpbN{)_8y6+}2kSb?;QJN$ zVFjM8!1EP&u>vnw;KvpCNrz+Vh1;!a2he7%jslM)_+V#LJ)`tIlOJu(x&AWeRFtv* zW&h@_KJ>obAoPxR|Nnoo*MFilJ^cMmGp#9Wk^izq@yizFFIyac+2Zu87W;o>iT7-Y z_bW@h|M=y{cfV}G#>X&+RZO;u$yYJODyCe;99J=?HO%23A@^Ymj>#m#M^J{M`pvgc6VeLpm2QiFq}%cz`O)zo`O)d`mIbGZz5WNdGb7gFg!1t7 z3jDGHzplXR75HrheqVtjq4 zikJq*`U&psm#Pmp@2HxL^>Ri{7^Cf`ny&%&gNs*TS1eHP@0esE-rsB$yFQQ{^& zd&x}b4nMRWw$!!&P=A$79aj*|>>g;A8HYPYNyb>Y5xn)jYVR^aJDF;4iXeAo0CV3N z3tZ0TrdlnUad{lx&f9_-G{&V>j1kr`W20IxR&6=NiW8p3=#77TJL^KU;7(>%U6SpC zj{_bz$+Ht)!2k-|Q{22KU^wAnGgZ%Ll9ptu#%jj$<*}JdxSI^|tLppe!FTLrHoXxF z)mM}DdwC24u`~K7DF?rdSj3(fJPl@f;{!xwhRG})MwLbZ!=}~oF%L!w6)b*2ullW-+#NZm_nr4WeJQu~M z-qhh8TcM>}^F@{~&?N7He>Dj=vpaEDb4-VUJ{ygg!?q^xnM)E185V0_{u zTNR!Gof^U&g6x|Pqce_eaZk@uRc-H^sL6Y;tfM> z=tQJH;e~Ugui?&$N4SesT3x72@FIn;m`H!`}td7?I!(6NVBbxP|*luyJcy zus@!oX`C1)nesEu|6=ym*!#;@lz|2TWb(Dv!`mbvtjA!owg%ib=V45uH~{{vmNtb} zJ}6gndm;`Zl(>-Ww3f+lCaBb)9J8}cvvW_pKMeS*?x&TffAe26#+kl-+B^V0-h+w> zfNPP(77=z8RXGgit5)=fd8{z9HXaBw`0^|9>C?R60K-I=u4`ya84;)pxUae>WyWu? z5l64C(PMV;9}SH5!srs4+=3i`FSuyPoGca|eGAbicrt?QvP|sBWUk(=8=80Ynd{5a zP4y#Lvbp}Ox{BS$X6Hvv9~Pop!2B4x3ggo4zYzAst4@^WGu-LC_huL@cYW@e^FB;KmR& z&$w?M8}PyAO6fel@it2RGH1woNzPEBR1*(+3^$DYGrvd7oy4!bOZzs5QIalj>6?Lv z#mSh~_%Z4Mh-qd>)MDJ(wpYU2Q3N?md?}%N((*K)*{p=98$37*^)sH&(FcPB)$kw^ z!d(wc-0|G0`dFy3~!OqZ#ee-XeO4hGTfF8W;Sftw9M3Ep)nC0 z750Eax`u$8R|83QxOVWe-jPhxiFQtE2XavD28NihuemHlEOD@=wkwDA>amxF28x?4uC9YK0oy34yWkIVxG)L(qv^U- zF8vL_Pn(E1-{pBp)w{gJ9QT1EivsK4#P^}1IJso+F{rROe)xJtUPS z+Z1pa%aq~A1(=*&pa4>G*CzR)qxyaKs!fDHz%nuVU7ntd0Aw}i0Z=BBVG9Hej#@V- za%~_3BrYkYm4BPHttiU_ZZ9W+mG{#5Vks6pfyhv0%VSDoU&fh?kgN%oT?m&^0PX0< z#1{?+g&My6LfW=i^~wWmnXJU&NT?M!C?OkGKjlEI<;?rX8Y~HLuK}~4PC3i9anW^l z6&onoT^e+9#R|U*;u~5W6B6XwMEqW5Q_jUFvE~BKB8pN#iSL}!Ul%(nMgXu^zUqo3 z(5V+0-@}AES+jg~mu8R||M>i$vj<1I4I*dC@Xm=#4nZ69>i#~Uy&wL27vXOY&Y8km;b86 zT9F9ozStcTDtxHHr!weUom}!3~n#cv7SgT!Ntbm z_8)=<`<(>s8D(jRQsE1&6MfNFSI0*pV&*7(adkFL=jXNBeJOvCPSL3?@k&O>fraJ5 zV`kw?u9Kg;UCqboceTAy2{jMvu!k{|Y;no(4~0O+69vg4et6IV2J$r#gB ziXnU2X+-J#Tpc;ZMpF0!^5PfSPkxcBYv;sa5;BIUW4eHZzFxWF^c8$mNIouhX~1CN zE6_70C^Gree0_dJL6(Hauu~ldYtdV=wDsbJf8=M=E+w-%g-RDxPOnP0Q7MIqt5wBv z1B6IDCZk-z&UE43Ar*eX*U=TVLb1je(G~M#{U7Pp~mKjI1=O?$Gz@hMk?R9~PW$G1ijp$SQTgty!wLYDeJF-%l zgO%$gSZsJKV^0>|zdHi1T-!2;KdM7IiH~HO^v#$(DcMq3_I~}_?)1@4_AJX3_Q`T? z2nA4b=49^7Nlz3pvZLJWX&XFlIBZX!bpgN+P_hQAOps?f&%2{X?l!#M)3wM|4I|5s zQYxjhaz~9K(KD3Rr=wb@^JaL>Td^K;%$E7<7Li|(V z3w!&QY5<(5C#C^7$w!}t3t!HgwmPskWq@Q!&(6Su@0@G_%6+loB~$n!-*n7Q-cbTN z*@iwI$xxl%j5yj2nsLdaRf$$y((~bvJivrRXY6-bvR(&mu5xHaIyI*|hgexM5GTVN zffE6Z6~WnvoWvp{$_}zkPCw?mW*;qB_MO;SNn8RrRh`_Dn*gz=bjD7A(T_R_M&bhK z=5fM|C5|b4&2hqxd7&^ZzHH$FvOyfxoH*>j#!}*c_8IK>2#Sw{+nr2HMo=6GThPW? z!vDywgyc?wF)6>3CeD@`F;5F_g2@Byo6r9PZI8 zAxoL1Tu~N+Tgy`CMQ4~v*TY$4IP*DM4EgDCx8hJr22VM_JwOmz95YG_PgDN#hZVZ3EC+mV0#*@?xh&s^l1_N;C z90jD|;Q%`@UmJ`A-3E_jXp(WSDFEffH-;o!B(WXPsz_WR0anr&kS%d51mw|;I0PHV z%ldKu!MzXxwBwe~nZS;WZ z@a5n`umo8=cQghUOR6hkz4)+pp=+1T6We60fC(G9KP~aAOxZjhlIU{`>)w*^PiR$g zZ>hppr)N~lk|YNt-4S4SZ21wmPn^dJU*ewdNz@s!(tTtw&hA;R4}eCiL$g8~N%_;k zpXBDsKN!tp)GOfje4K1S&@#n#Q=Tbf$|W1-g#@Kx{S0|xdzpXK9jmD;9^!U##_rk> zI-{^gS{Cp0LEpUkdEk`wUhl^=2n?hsAd?b!z5`?)*_J})yy zHX=;Rmdu1~|MIxLecf40K62a%7C7$J@3MQ_@pvFICipHE$CEpDyiao|mE=cv zLg5^Owg9vzZlj#{PxvrRmj8se6?HqFoUo+>a0JeaK$=Jod6D=-?CB_Han(c(WdX@ zG+P5S)VOic**O6nX^@QB$!aoJYEM5oX&o4f?(372)jCNtx&BT(lVxG@`Q+|JDeVM( zoEQ&W#8}#ib2^NCpHz|rSe8&Y7u<`tBiv4LhPd;XJUY2s6Cq&Am5hCysAntZ1U#pP zlj|&YgG|&(_GBT8>}Ly!cOlkG$tWJeLKxeYACiLQPV`l-?KwmrQWBRx9c`H>Yvd#^ zA6ZF8vE{smkI|e!P7M2a`S_Tm>M1988Djg1H6_y3iSt#?KJN9kB&r(y!3jOYS*Af6 z-j7jOEJDWo%E@^BSJ-05GCcxhpVSQs+`xktpy`3d`~V~q8TW8#KOID#e3C>&0lJVO z696vb*SCBM$D}_^ojZN_Q$&20DNQHusck>7Wz+QvKE_}IxtCGN2YvSp1cd>tjIl_fk0Y(q zvL!VY0C}Q=h#6cB>hQ6qRi4nxHUxNXli}b)9~-t~SW+$qU>Pse%$E$?)Mj9C$^v8=B{o}AR7-SPJ4VxgSF)-WdYdC_!zqro+qfX1 zom*SiDpyF7@8k?~ywcc=?pscLZ{*cj&8atxi}GA+PIplppO_7%@7e$0E6;7DTe4(A_wJ9byRQHnLUNYBYKfnrF0&q~;x$4d|InMng9dx+BM) z_)kfSS~-RjLxzCC6Z##?FLElPFY?cZo*mdGO9p^OHTHvo2QQw`pO}m#B+v!qFQ}k;tAZ`Br!XUI!LuYo_*5D73V`fC+)YFZ^S}LV`%nMlj}Yi;Q*OXNs!OP?{+Fd~ zn=dz*sy$ad))^be!EMW`t-n`L?Gh8Pxw5tv(blnY-=ADf94BF_)+h`v)ufr%^k|$T zV&zMx{;qC6`^p;3st#nb%P0etX3oQCvv2{bzxryay!U<{n@;`36jDdhfzBYhhmcXF z>FD144>x*jBVbr$JdB~-b{>CTAU>_Z^;70uF z{AlZkOHN2z4%5YOq+I^93#jL?u?k~P5M~s#=pZ4kO^z8t{^_To6!p*ws8Oh5O?g6F zK(v_rB&EWo>dv7!k)JbUh>)~_L~fM=yt>W7M{nR};D&A9V$K&F8vUTHlDm0SXZRjGI~E&FB?qU-`CnBJRdIbWNvW@?6h+VsP` zxz;zs1`4lT#_9$9$!RPed9R^# z1^-3!oe!}2F4p5ZjAUsELkFqL#MTwk`}Qe+To<2OA#}U+AmIin7Ou;PT**4NDfw(6 z(^-?zfEF9Eu0mR?zzOy3hQCVShGOBkqkZ&&+!XqJBB_&MJg1=(Q>x)BboP+x1T%jG z(Dz#1cDHKmR*r~UGylNycPYW4BGM&p`;;Fndg#SrO$#Zp8QjLYz78qPZgFSxLEoXs z?ULPTFg$pxu1M~7TRH`*v^1PFZoms72kmXhLLMCF-n}zs>236 zv_?Mm^)#iQhZ5zJ+Lbw4I($R)-xi%(*nc1EgX6GHuF_W2kRxP`&8(v_Y~ zJ`JylX?+@wjDmx&_h-{Wv1+EfuFKZ83bKOiT2_PEcLLj^a`~hPAr17d9FU~4GHr}0 zfT*NvYs>PZ=pj}nrad%B@F*|+)TPguOGu3n#D^dog`35)UkwX(0VC7+_K1t_PyC(ya! zQn0I5c;09O^|f(_E#avy&`bM`Er+Tdgk95CY(sSM{E@wwzoQZxf1q|8YKW3WfFo~X z*rH#V+OR-hlys5gy}5fXmG-k(PLt9U>Rg%&)XL2au(7%&XF|ZOs8WC;Ck3HDV4COj zYA%np?>WvF%xzGrCTrlgkTqP4a7{kh1bE|TJ*y~RfRjpsScEfaZYv+Io{JV8xPgxx zPzz*Cjddo5Sj1Ali<-;B9MwyX8?H6K%ncz`CgCoq@HwXH<}gjo6U%x>W_s2wekfh2 zXKgO41EwvclX`-Z`4a&Gb?CfGN4{^A-OmRWS@xrUHjqajGXyfAPET98E+%&-YX=z6 z^d!k>%{4k4vjDNk9RCTcc7mfKHBOSohS5ESZaZ_2@;7}79f-JOI|^3Bh(5n^3GTb! z8LYFV!#w$9V&hxc$)Vzd05_Am=cWpzkD_QpsRvZ3)4uwv-}HpRu(|_V_NlJI^*rgs z)nGISa$9pV$z*gwU4lWy12CEK=7Tv0;$@6c( zWpg%pRs9dCVa`<-NKbfSHY%8Ynekbxt#Z@Vleyg1Y;rb%wbZTVMltlx;~+=0-0(n6 z##Ex#$_eNZQSh1Ext#dj!N@jsd`jwf33bzRi}AP(vj&>>!Fzc#mgZ5Hk=O+&1WsaT_Tu{Nr5?HlD9 zL7Y>fhS0gEJgMO0+8mB+Lp`p2_^5su-KS)G%%-RzB>A>VznRi1vZnS6jeB<=E4+wV z7bRkkDRH7u1en%0IyfXPkIBhnV3@!FVO!CUb7c_Fz3hE|P+*z*>s%Q}!N)=5C1A@$ zh63!Q_3d281XP{H;h;j*F*`2_0WIZBoUr6{$UYRkeBIvlgZ>qwX2h4l0sz%=Z?S*F408c@IvJm zCs~u0g#uFtT`2S})$f!l{mLSxRnnLGRVR}9_?+i=p!AmMKY(A6ru(BpDksB}WjiW7 zcr3N?xxmy^jP6f69V{ge`dcOaV#-ee%~Ys1vMjit64GEED@mFxQ(N!XS|7C5nFh=? zV4(p^4S3XmE2j}Atx8(w+RXC32GquyYh%r|vF1lbxFu~0Z7_v4m_nmwp^dQ6Mp$Sg zEEW3Xg_M~&HpJTr0GR05GE|?yn9fK(n(zEOMAmG1P;p-SfRj51$IYWa=AtXisOm!fjnF`HSXrX^jz4sm69e@w`pb+YC(0{HD;h<1UQU5W$K+^7{^_Zzj z4yGHB;JpscbZ{<5kts9Cl=aSzdY4kY#!LbUY^O~Y1xpH5ST)yYl&hx_}>jFIaz zE0kFj>MRO<5ej8@Iaw&1mTJF86{@mLsk2R~vrVb+eW~+(sT00bSgBOUdsM!4R95(? z?);-pu%p7Y$ApN9x!`zG7WAa7(TQ`v^#}iV?hk&h$7_wAyhHCE;K1woY@dE?WZm3W z{oGunOZXob@LatHr|_yb-fOQyS^$J@n{Lz4%1B=eoMU6W*8Oq}UkX{#l(s zuU*rJS6BNqSkLIo-qyhd?ce-T0l2GPV>0yG07pTXy8UXdwR@V0Y;m^C5S~yXZy3a9 zjjuaogbrw?50tbk{D0UYdE8H)jK$V6p5%laJQllVNa9}4Hq*Hdr5BSWfii^Qd6P`9 z9fmHl4XhjRk@lXd>`V})#M zvAANKLyTyM1i09csqnT=W?}B3O&iV?j+j$Arr{5q@|ULXt1iS3rO5fc8E%{C?-NJd zqJ$3u!$98tt-KwUOYOtGFNP{15{C@dEZc1|LM$8>gtP{SKOmdgKttJNw$kkoYT3sw ztY^!8@^YDccxdNo)7KYqB3=gY_P;b$D|ggo7|%_wo|S(`Fh*#;jhwZmU)aWhY~v2U zj3my2F_J-ezm2#8V?9lu$S#o**V@g8&Xh%C@QGgGZQfN7=G*{?m-hys^f!pH>a`!3 zldGQQbU1zAOz9rG5~uUXyVv>|+*3GH#n8Ff*huJFna>gf$HEwv9%NuI=vr5I^KWsM z+Zc|j^RkD{-r~;V(u4Qmr_v)-+y$q4@2A8kV)4XRJ;m23%ZAd04^*7SHGU-96$sd<0FotG2oekY+}bZCS2`h6$XEC8dVziJ~;n=vW8?IjsKEaz=8WI3X_vNh}jN0f5m#06nK{(`6 zy`~U3SM5OgaNl?aN9WZt^ehgsJXriTg7-vzgmZY zg!sw2hFB0mxUq&=KbH4YCz|vPNE7&v(d|yIpp**tYE&*31}YZ$-ki@?b6|A9A_IyYCtvsQAV>&utm}lR7Nl$TOq#PD!4^DH z`wr3kJzUPXAb(UB2zIH`Iav5SD>tA=b-kvFl!MVrT#6%Jg%M#?lgT@z^ZfzATUVid znH>}ED}hRnDh41B9Vdz#)1V7?D#}j86PE1^(zS9XLRlLx(iqU6y_8hr^*zn$W-(wh zptop?b_3-wa3N!Zwxi=hj$h`gXVv?OX%3HSGCZ_gKI!_9K|J@_#^tNAkYDO*FvaLyqH;?+N{ zw#VrXz5)G$(YWeoZT=q~MX}2Uh6f^EoWzZdvPISn0c_*uWbuwgxzdHVB%t(QR<=1qtA z*$*!-ZXzMu>XKNt+Ui!lYe=EOmKBJ-F_*j{9R?u6eVX+0S&OScTfySbF+!G(x~&#- z=5CxbegZ={aKHoaAslCZ7@kD zHYT=h+qP}noLCckl1yydwyig|oxE}K=l{-kE>4}QReSf%zUY3co~qs5YptVb(ZDde z1%$K_$nKs|1Gw+OH$>3PJ zeiEHyFgBo(iR1G%X;Vpig7!=g!I%*|X~ZfCX@YaPy8TIHozy}?gHL~nVqkp ze)#@TTOfP4P|DrtYN`XB&+f8W1qItnnNfTAOXnnvdD-i5LX6MhoynYU4gTaDxUe$g zbsB00-Y4YgsLP4ES8J>KSY9?4DiN-ON&!yD^LKNwhMVx;KFCaDsbk2P_`b=GAOS|z z)GZmk4t+!h9mh+pSUZJGwFt4fUp_`nH{?sL)g|P)lMPz^2`M1WmMJ`d?Wkf`jwp&3 zKYq#+oYC0j)$``P9q&U9lDI8Eemv61 zanq;r06Vi-ElrkFn)GdykkvfmrRRUIOYrD=iR-c224SH zwitnPL@y!6M*qs6ur@o=ebea&e7%rjxF+4oX*l`j@?zxS8K2Bmcc&JY&+_c!mz#uH zFub!$ID~Y|5BKCpO8@jt5RX$#yCoTg?-DskLw;^#g?qU(Sk=9aX!aQIA#$Vdl*I4S zR{oPILH@8Qt*qyKM7c(>JF!R{_!zW;#i_LZ#~&U3X}@20<|EYGk@f};*Dbx(s0;3Y zMY)Z*QMLoO6j@C3>dPj@lS z>C3^&KbnTDMMJ%-tJN|tQ-KTN0zvOy8aq@sd8Fbtp6s%z(E`i{W6D;B@%KC1N^Iae zE*MLtKZTi?3eGc6!A-i?d=ef=$85-XpTqF)d5G~PfD>)j+&tS;yu&l`;x7_g0^<<4 z4R?lL-de?T7YlS9eI)tjljR%S+D3W-h&!-WcYNuC2cNC!?-{!<_r0>Ujk^ED;-Xa5 zO8LPLJeQsyMD9UwoROR5YLFQ;ffR3^5^2Ck4RKy*ENKMj&9BIUXDfZq74!SRr;PLZ z!TZ}ym_c#Wa-k^V)FC6Mjm&SGZM!TYQBrN~eLR|J=6Xj`jA+6Y5Ux!9T}dA* z!+;w+i7m%yyF#~^6Y$EY^)!YsPIB2Y&wPr1&D2WXb1>hv0V0V~f13Z+;)w*IaL@&T zx9G~MTbwg+lkE}bSUN{m;Rx3=UO>1#UpXs8La-QQrO`}}x+;5^uTF&Id5Y*Lh5+Pnz(0N)Kgj%$q;B`pF zwxvjwnUjF~Yz#hEPgwT%CHksRpxHN*`_IpKX1rF|Uq$glCc z=qRdD+ha`GgY#{(N(g|+X+-BKMB}VN$T)-DVBLq{+@sy(*ae6v<_N-USVXh<0q#uG zAOaSy#>)6yWm#U#;E5qcXDJxW=`o2=w|sVLu&GU?yqODxD|o-afqT4nLqGI)&lHz5Vb5##EY zq<(MoO+YnUQ1`LF^yveM1|^!){tCIrvv6IIQ63XmJaD01{7e1zMg|W|R`EC{KNm=f zLNwRhn+g#Q)Qabnl%GB%+4(TxW&s5FqxB57TglX`puQSvgKpP?9Ra9q%_JPol+Zbt*b;WfF?qB8zfW7 z53*s?8w@##wj+P;Qt}w-C5viU1{#NbQwZ`zfbE#p+D^)?+qM$cvQpgn4F>>> z_Of91&tUQSVD_W`z&wCO5AR+G=Uxb}p&2QIJwaCImbT5nRt)^Iplvyz8ku^s0}qr7GVVwMJt~<)7X(j*TR)Y+j{2{z+Qu606p~!m&G2j+rt=L1Dl@Qk;L| zixEzVS?b3pxs7V&+J6poP{*?3e};-<=`9=)5NCBRb&YW<)MOF7+$KXsixAZ6y- zlOl0@I_~^& zu0r)thW4t;2up61C~QFN3rPgeq`(+@vfw;JSee(&SwwFj?I&K;4RfeeDNHr&zgRTo z^yC8SncNwWjDwYgpkjdmwDTj|D2Oz9uF6_gGDs~rXiMU6NkUV(>I=Q9OPaPWk)4ca zHx-1X0+WB`RcQaok8DLo)ss|mF8{BZ$E$rW7y;`07L=Y<3i?rZs}>$~qZPJbaXmty z#}DE;t8K15p=)*8Go^jihpcjv$aC-TH(|;)zarb45sxvg%~J1VNBNC#9eBq|^=~#;e zr)2N-g3rDrz-unufKg5J6#rlpxxk|4XFQ%ixi(@3+SMxs=hu!rlF1@vs4WBFjC71G z@qk;(+3#0$j-oV?P+q-!vDDp#New6>wjz<(X9A!z1IqTHHl*0cDS&V9O_$p0KD*Hg zdq>`(12atTbxp4s<2TG`%1i}7s{3olz5rw#+OoqRnS*XZlxJ*nTKf@_`zXpP$$x#b z$1*N&5X&l=CRub}@~-V7c9QJT<&x85pR5uYenuz&ZJP{vldbWn$t9pt8Guf1Y z&x(MKUtJwxj)&=4xa;q3FtuIekQSEZhyaE6+U0?Q=?Kr-?I$MQwHbrY+JsQ^_~L|q zoF9j)FRaXPvZFUUXI_#*BAhS1-R6DQbHdn+o?x4pY{-6aT zQ=x9A484E)1VL2Wf$@PC6`}E}eNUC%#|r(yTmR6}cz8~#+w5Z#O*kOZ6!_<-W>n=9gs3hO;LGYt^`ofF z)Cq2RjZ;-4C+Fn})^yp04bita^vg8^M5HRXp(1Qg!ZYyq5y3zlk=>hR`5V;bX-2N3!}Xw<`h|ZU(#Is6ktc6LsSXBgAs5`( z?5Z_c`enZqru@ouCO6}pl3X9m19dkvn4jku#Ui-A77hje;yeEVG3U})eQ|p(*Pe7j zM4mBv_-f;X6!gUM2?;<9J*}t#kX1V{E^GxDl-n%P&j5sacwp{!^`O#Lj%J9igCM*= zD_+ks6&46v=ZnhYWN9@4?5)e0E{DpxA9-8v2%o3SRR)LQM9DMEgg32P^DxSipuVR# zX|iVm=i?;}&K5|4v~xjlyL+4-48?NQ(U; zS;H%75Z!3wc76Yijt=0Hciv;AmVLUjpbj(ke)&1MH;2#l=;kNYY7t2UuMN%=@!ODo zO`%)##(k)JJjOW)VE0+{u$f~D5C4WIh4H5JT~~h^V)DxJ(GiN3tko!0fq3|mSnFWx zh$T4=3NQ=JQKJ~-;d1Eq$l7oAv5n(-A0=~=suWAGP6SXdg?zn%h=PscH2Dy7w=aK! zfM!IB5+_Lh7HF607^PxK|AkG!dzWhHLwuiBm)+vf*_eKBq|MJxb7pGs`Pg!0pvaE3 zhhg7v!RxN!Y=U@ls)ICMwYe4QF-f%GbPoCE{<99e<(nhe&eF8s@(F})#~eO{L_LiI z_MD7y6qNR8f{jGdJX+Eo0n18zc$*PBX?3;W!-vVMZ5Uwv8++slu2MLw8w++XxZQhN zjtwqs!d5K%`e$}`>u-RM1UoaD=H$nY85pRKnF|yp&!#Z8MOV#r%G`QA>nkIs z0)(@AMa`QX3x%aqUEt_C@}`>|PbE;D1Xy`aDj9D1`^Gtx%p|QpbxbgJb$r4k4p-Lb zARt2=;9uO2-4AKv{4hp)(BjA(RP!?fTpNehZmfPd+mJ0ZTXtyh-JV)KFm@u~iE;|O z-Zwg~8!bP?8Q_KbU$vTT`74K4-~W^V2G1Xy`SuNf1Fr+l81d7{n^lbfGHOO>W z{jyrF?c|mP>T=3YuJJG<{C*5aZK&579G|wzcX;>?EI=h#q;I>HceKcjGM8vK-hf3j ztK`aNQ+?{CoEEqxrZ_jVxgRUjW~UR|81UBW_+>iDcS< z*-RP);$AIH!p?Qm>n-W8btx_@$r=2wSG+Ba*K1wc_eQ>zMJ%Y(=>vxY%pFa2ZfyqF zYmwe@3;X68EW8lQ;d9Q4t%PllYy&KwLVV4b+kfUcZxxUH8{;P2(5O538d><;31DVs zfX2MCHm@_DbgSa$*#$qY87*Vy)gR=Onx7zp`0{CrXms9b-h_JsIQ-FDj4|HK*iHOD z9M9o^ptFZh<$-)%mP@IsqIYWGZPEGm*`)NP!Wqnt>bG3>z~=|iLb3!tHNdq^^WEZSC}>|DY#o!dUPufyTN25kIWabV zF~8Q>1hkZGHPt?-LG7^Lkp#5WJ&gkT@SSW$S&WQ4#a-Z|CbJR#;5ALLjbzRc;gK$r zS&X=&o}^s}gN}+cOpHa^O1Nb{jr8RZl1_#`NY%OX|w!> zq?3z6?!Yqn+}dJaZL%_X8kQ<&FfstC0!+#uxV`$p=K(%{$bKRu@TMG*aZqp;bJGY8 zqwyhc#4@qYY-w;D2wAqEv37T)BJ4ve;4rVb)$P9;RTJW>Eq1LQ2j&$~#h=#{G({`l z>i)Jg5R<_);r|VuhVVi2fm^CI_5eBi9A-Y=fabj9wvGx*m&D&hCnB09 z+*ww->P~IMj0aQUf_j$(5`-5?&nkF1(&;*grnsYK9S}7t(cDxHoQLo{-?kdkDaF{J zLei#idCQ-hU_@zuP{E|g9$L6+2DRWYrTF4yypGG_IX(mD9rQat#Hgg#W@w~>(bQY> zzny(rC1Kdva#JVbd{zb+E&fTOUzOx#kGk=V8Mei zSa8)JWacnbP0Y~fDAZ=NcfIAR-nuNhTEi*XuU08~2cmRRW-RH?!Tf}u`n9(6+lcBi z^_z*lErqLeW$rR(n~7oBMT?)Ywdy=;hl5o7b`+a-Q1<4}CV_zMU9!>Y9~+&7ZZd%v z<6!hwJMHk(x1}Fy25<+r98k)H*b~no6-3U*W-f$F*~BOzG`T&VGSrtaBU%&L)8m^4 zmr*p^YIqi6PH&-x#j9%R!Z)fBMsEI?YURogN>}pv%8i`&?7wS#*EZDLGt3>%K5YFXJ4YFT80U&Yvo7!tx9XFY~JwDp^RxkxsrgyE9m zD1T*;olj0lyn~CkWTu{MqrZ8`;rwnbB*66kyH~>!HZTL&HY1fBzvHwH2v1m~PSH*& zRa3E+yxBAm7q$3y`^DsO3iy@aeoBr#03$C>zB-L|$0FpfPSb>)PtfX3-#&)A2BKTl z+k`fJhr6y>`Q}&>ucq<*?M&L%AE)!zu}ZjBiP9D=2wt;z6ml|G*bPQXU{JQN@h%~p zvn=2Q`&vu(S3?)qnqfXHdUL7gf_$0nll9M3;)3>*C6Mnm26FTB; zA6Gu_V5ok6yG)$a*g`Yb80!&rw zwL4h{yfpFoF+^bM8ez*XoG>?h-L(424|qYmnC#xpRu}Ke%+sv-rlW-mrCu zblC*7`DfXQADms;(5_e&)c3$&iLeHo9bNM9;&A>8yM)cUr7^$7Ox=2GG`9K#Oy_)} zlHrq_j@ZlUK{|2>I-^}V{l=x^CTvNX+tul*)&*%eMp02AhK>*|F%o=^)N3%pzdrY@@l!gZfktBDr;8)c`z z)+{9^uSErFJtd_!Xznw-N^Rd5@tcvmNhDE<@4KTlHd}Y3YSZW1iS@zk zd$0yb&-kk`M1w{=ybJ>ebFhTuJf7Id3H{l=KKUBIC)xhL8%laU=VsFUFV-0^<_3A! z`!v@)d04aHmt6wfZo!p|Ae9)LP& zaSKwiuNoW$2VU{#6U*<<*Z}mggQqKhzctw@0#!;%y;*+Q_z9t!%zdS2zLC5}l9Z_RqoNaSmRt%&u7xDqg3pof^|>3zK`Wi} z!;@auCmu0hPeO}yKhi>T3rzP9`o(UyQ+C==>=uQ)n<`1fTaIwwej|%32bzM7K@jUy z|4@bviE9a{gCWWHU@{@>izLivU@{=A6h$J6QI#Y?e<&2jE<4ei%nR@>fJPnX` zc4g2--3?1zA{wy3cy_Nos^w8^0 zuo(5BiT0`peG0Xqj~&9M8e`tDPCpMcwP=+XCuajuGfl@CC^f=&z}M=7yo@j^UaI?o zV!&R*iEe5ZL=%XzY8Rql%B<*3F9||&FH^8Jsj?SWqZ1&J+h9I8wxC1NiF zQ&Q@^#XbySmtYX5s}pjB{SiN{=%pshv}ez_ls&0$It|BI3j!BnIh7_aa1-1tc^1bg1PkTJyAcRUoFQK3WgZ}bGOB5wdj1X;3A@2}TFHAH~ zy9gnYtTDTwskjT!=#c-oVAJFGd-@2JCLxM>!DgKn|?YQyO$I^P+JS?(&kZMk9y?`t{@$PBN> zYXJ{CTzhdRv<1ewAj-M+v<2?TIXqv`uc*|A*R=6;=!ajSyoUB{I=us?FZb^>9p}P3 zgmJIjXqLp+D);{yEtDeF_IIEM2-t2Lc&|)p#ru3(Fr+Q(U^NasucmMpd9yu65F{uijEPIL zKT$~Bb<*vp$o{gscx7Sj;_rB&hqB~vvcSafvGa%nKY9MV2ew_ocXWm!2}COxHHm{& zbWnamk%SWUZeO|dJ9u!B}^dAOW6r zo9r!o+Nu->{9V6Y$7i%XfeXh8;7|~k0?p*H#Q}*9to^cInH~W7upoHVS|{M+5Qgkx zCh~Q^9Q(jfsGGUEa66dUtlzB=P(JP6okM;Nh6ip}ix+&Z!5bh|4&4v-F0)n&qiu4E zVcJsk%gvdQIXC_gbrtb>{d{J9>~5JD92r<>6%q&aC1%=RcL3^;_pdJt2 z6P$m&KBob>JmUZDkb7|Qi2QIcPiE`p``}dgObeUs`xsAO@P~0wS%dI54Q5i7V4dm5?-u#kI=@G%Kj2zE{Kq4$ygnYpL*%{h znr$B+tUnqZ(+_Y0Ca-P+CHeT5TFt;{{V)F98ieq#CF!?``|&S>%KLnPSrW3a?+?fk zEMCCk9|$kpEdsBqqFNQ#MqK_xJybP57mrpRB(=Oa;phC30n=aV0bUkmuSfi27&rEH zR@g%QVAAA(ow+r&Tip?TcPQXH>3jw}DR?@DsuHZbA5>(C&D`LkEAzpA!C({e66jcZ zpEJa%e_mXq78?@4FaCNG<>Z3o;NF%?l zj@US&j-tW!Q!(y{9xs;Zp)YBsIdJX`z2b~|9cL0SDugpCDHKZ_n<)_oTn|HU{4n*r zpzJ&C!t!cR+{`oJj%YUgy`a`e`fCF&yX8O@({8P?I-40|g%h|M!2AsOrGDsbB2ztI zA~0m$B)?;DOT@0f`;=xl+R8mVW5qCt$=Ao&fcz9othMUJ{`1{G!k^wlFrlnl=k^C#v-NNe_@V zgXXnWWE??Beio&W5o(VtCv1g|H!&jImziHX{b3Oj@F!}aoK{}tslCZx?2-S-s$HI9 zs2HmiRSl|8VfBfbjP}70ISnc|V|WaS01~>ajj6bc1gXhn`MXhGPhl%SzI@Js?;8L< z(6k#DdOdVulCgs~j31-1V_mPdjq#&AV^U%ty1x;9k~?~U6R+h7knWTt-p~~4$H=6n z)AwDdd=H<$S6gsKW?6qWK6K0_*NVMDZVAEp;uKa{L@hoKD425=Z=T9+AAGftkUw@! zbPWh5_U~;&+S4J7Ua%~E0*hS-RJ^!>vOW(2FuZt z5Hjv^I^rA}O6H2osoFgl9ORPcDGut5I(*sp8~M%KN3(ev!^8vt{aYu)eOx~B>29pg zO_-JL`|#R7Nxa-|!|2M~ZrTH`t`_EHl-%z3A#jO3PB#WB{OXh%%Fg^+!6pX-(|@~s z;XDlsrz6*3S?r$&(iQ8(+`ofmQO*|Ng|@Br4%zn5&lY79*qmP>WfAOq2+-J%zAy#z zpi@Y$=!d{wY;BP_uolP-slP5UX!Nui9X0ny+ikAiRcwUXA1>GEit!@+Bh_U??Mj(K z)!xXjF`7^WZSZP#RSwzo;=qgX9!VX&+K3bSR^xX&)^Q9dNc6BMP`#q#<=1H6O&Ym` zLvzpa{B#xh^06t9+wy`5D{TPvRzRpH#dvB)jdO3Y3tVyQA*}|N1%LxCCvqUWNMbdh zBp`%52dbbdFtd;C;jly9Iy($vT=mFJH!j!Ugg)PX?bVMOyHB`!YzbyvZaQCY#GqaM zbj{?9)D37{EI9Q)@07rYe5&}kI9%T9M#IDE2Lg_?MouIDr2Fv!B5W1z9-ORLPrCsc zP#xv182-ORC}dYOe&&c<98EY_WawW_?bR6hpk%%B8w{YiL(GNgpy{s{^9ZTj7z4Lx z2hF!M>vThPn~8hU6LKmE%%M|IJ!y!xK-lAT$}$rG3OT{urG9JV-M?X&bGz+2{T1J? zD=uQI>Y|QMn^jQyFGlkiTertO{R9-twEAm&I;I)US+XwAn>PYpyx~|8d~%ZxjM+cz z9Rl6G5#ToR9{1)E{Q1D+run_L`uz|up0VGlvXds<>4sw@vj)=WUKM0Nig*T`@c)Hk z)|UT~`Yq7hHcGWMPTm$;3n3thb@`io!+j`lI!cCPDviUHbt zInrpen2di$=YF6pz)ZUuNejMv+symD7K-h=H>7{$$gR)?^OpKa!DHFH`ch!`XlG3Shi9Ss*&89dYk8f^O;)K2Dug?Z$K)oa_Xo4Z;ob&wH4BEA!Gs{{ zNUCRnlxr`>5BJ`69z!9WleL^`>W*T|zt53>`rcq~BN^@w5UJ*j@#X)OdS0siiigZ} zj`Qanc=}mZDl5dfEPmJ8Y=H$$8*Ss`<*KSxbGd_;dvb$)bIv}-Sg54fW9XyS7Pcy| z#jN|GMV5UH8TLq29@ytw{bR-K09@r(L`X4#)7*T-J7_{Y8NXrk#Wlw~;>aoTK?J_Nv%r*3v~Nl$#6%_6AZPp&S+G;y-mH%cX8gb~{%1Buu_e64 zimWCROwhCoZRJ(9U7!Gd^z`Nu{*T@z0$L%PspU4mJFKIox2&bS2BoeH2e6nYZ;q_i z>qSvi8?@JpEQ>3t%*1ripyaU{G~1%h*4N=j6zs(O?mG5riGuMLAfHQq98Q78+?oXew6JP` zPEWd>!T&nGVu%|HHOnu4iq7D#>TlOl%w~z^yjWS@^oL&~{_qYhxw@@y)RSE@zpS}z z&J3XL*fh}Th%*g-rZFWZ^0?DDUI?Q*tPw(O#RS@L2&7(~_owd}#I)bLovoa5L(=?G z^9~Nyg2!L3i9-gK%;|gpr$uwm*Wt_}NTEhPak54|fv=kmBi-ZQbiAdAm$Q4-c#HrY zSnCr%Dd>Q=rGKS)F-l(R$Q-^HkJAzxz5a0v4vSpR?89WH1G_~d=JV+Re@G(5;phND z`%?q6+~M&y-D>BE#~TZvt1w|B*ZesVDH}1Gn4?O400+*d<0(fzW<@so+L_^U?#3ti zzq5TuvoW-4#y8}OOpzi_OguxRsbl@9Gv3(TB$~SmaGNhn(-z!z;jy70VbubmH#m=% zllFb^xK^t0qKKI!NR5w3e8E)*F3uL2lWeQwf=%(@jIKRmE?CFOOrbloL&I+~_F-=g zmXWxxnThJgyjbTDuEshXy`e(GWf`g`IZ`LEivV+r-2iJN$y@OKoJ7#u>U@YjYtS zF!I+=6D7BQ_7ujKipKjJzrcL=!aKW=}n0?k*=wSKJ{fJ?}nR? zm;bf1=tRN0U+!jr0y3#G?9YUzs^Bv&B;Vk;pDeoge=?t~RpF6)O&TgnG6~@n>G;_6 z*f~o4;INDQfQh}oUzP1zi&GlX4L@1V2&y<-oZ7v5x!q3cptgV|96_dO34|iUutA(2 z5Jk>sIWV9=He`7|MhfCkYJm4YYt*IJxyfHf^y?YTbc2mF%7(J($M;<^gGsG0NOCF= z?{!Wxz0uHQ86bAF!Awq%#1v7f|6ZzxCi8@F5);^SXNEZ~8^pL;&Yn!I7Z}P2QzJGF z6Q!8;?>P0a#+w9jKi!g$s5`#tt$KmId&a~<8tg!2Rk?XfV951&e~@IJ*Sn* zM;U&=wsEtOu@ne9pS#{d_abcsBPPJAk8=cNFo}=}f4j?3lKv|?;OS;c z(0;#peicVgSdYpbX(?wNq!b&njnDk%IR2g=P?)svFm>^=x&=i*g_yehJ81&j1nA^? zUbC8&iC3qqF+4piCb&}jLJ)Hq`;_B}S$wlVOVNeLah75yMr22TlU-%{28XL82&-d( z9n_>M-HZhFCtt~pEZzsNWEwI&hHGmVfO3FkB49-W6RW#O9lZ^o*`q?^uzUIJy;`(p zxkzo2+k9a4XMlN171@q^;XOvTGk(`bI(a8gsOt@>IJB>uunmOE24e}q3f^Bsei#ln zU&kL5H;-+(_Kl^zvTzd1GhAHUTXEMC=1;;K`xlnZ;j>ZJ5gmE%r1=xleD`>(amM-) zjAS`N_I!0vK-K-3b`GpH|dl$l8c;BGJ7hN)e>@ykV3X6)bKu{Ba_4R}ip9cu;hYhPxG#r>OX zeR;B~GYpW6SotTPPC+yYJD5DAPGufdnIktcK*9~{y6d&ePwLlSY<=THPnzZ@e!U!6 zM^;>J3xW%^#b&2Ehfz60e)E_MhHq`_(H|l1HCIR!D+HHbkBYbvOEoW|&DB{;x)*H9 z)x$g)atny71)){94-W>UvF;9ctdRShAH{evayso>P#c?3seQCk?jyyz_-!pZ{j7)E zzgyzmuF~&Kc2{H>>bBN(I4D<=r+R}&(3iL2+wcC-Hh1`O3~{)_IXhey5!E^}nR;>f z@6Sfth&%zgerp?^`(C$S$7fAF9B#ooYJMx{=dgHcw?^AZ95Vzg+;tB& zLx!2H0gA7cayXn%1-<#}VVXIdI;Q`zl%&J`LylfcCS@~b9_p%3x9?oH>(qF|*>4Ue z&>Go>tIzQ@XWKt+n&SPy$8=WgX@c8<|8xpu^Qh2vydH#zd1IwBujZHE_H_~t=UC2r ze_qm;HMU4tPg`P$8Zm?2p;KjDa%AQ$u-lSz>BNzNSAhPBUaOIAUZ35wUOMWrvYDoj zi?Pu%Lb(qwY|dfM>`RGuA?^8ZSbf%S9PQ%`cV2$XavF%CzdEK|^US}FPL7LANO55g zC(03Z^RPLk*SZL|Q{B=0j*Ejfe`3a-Ri+C4K3f!N0isV6AH#0C1PFAW<=$NrO zu&8gi_4oUU@z?!xEYuOIBL*jt(m*AVEI(l@!%9m9NquJTP;PH=Xebvl%e`!N)88=XeR& zc!KUuXen&N1%NUgHy1deb!Swt7)>QPAAx(ynM0Co8R=EvKDN>>vSCq>k?=)_^_8cG zKpg|*7R0TYNT-QCVI5C#dQJ$Htn+>xx#3H29-l5D%!{QP!A2Z>L@0ReD2DW<#W(hj zdeKIADzFIzi4~&wS&>`U#d)oxN}G8PW)syxEsJ0cbr0qnEt_~>KudD2=CO=mEVv88v%kTwBLiH{^0V9REu^ZXYvH>!mGRgt@1N$-g5b{Rw+SqW>b3eg$jZBwrXno-&gxf2=-HuhoBWoZ6d=wZyV6 z)cgW)#1Pl)%sVuJ_55>TNR_j(g=*8#cg459N4r}MrhS{b0DR<;*79J922kQBX=QQc zn$HF%)LOD&Wb*b@cgDmwiG;W|)x}-M;-UWXRCnfuv#r7A*9}3_xDHdWcc$~iXj!AR z=5S^TMqAT-+Y0sb5+aQPfU*2a7RAtUUVy!Tono)LiAigIu((03mDB0IQ~|T$82xi` zxoLa(5nV;-NnHvREg=AZZ|a^^uo_9b@FJw3pJo+*>=UgAi(!{)2Z1M0vbBZ%HEZzY zXx1Tdrc>u#6Ul3jpIr?9FRVeGr|@A|m1SQhU?`p)rD=`0M%vT1ZT&H@`(x)KW6!=d zXx1hR0S8}S@tYjwHfld3;mxwyual^ZCErfHe%IFVFcHp^z2_})0mRM_=dlOh=9Oi2 zj)8@AuZO$JR~)p|sx8Pcfz*-O0`~YEThm-zv2W{d&bMh)Jf~zpysNd#SBWzU6`LAq zP4WI&>A&f$BNNwIoEldzH4AirIAf#d342~g@8k1;*u|g)&e>7ha;inng_`+w`n!IL zM!Q~r@C;Avn}_Yd_$ZK8UV{2r$>n56oY2>_@ddKPQ3S6s>-Stzu~C@W84Qpzmrpjb zj#Ct1nCov{MR(@(0LX6T%k*my9kdG2=vqf<-rUt%ya;z(LZ}*mze6(GUtKVZcS#Yh z{o+9G%6@P(d`xl~sQqbGUIS#7q90hda=tC&`fgtwVQJj z4Q_??_T2a@M0cmT*IfLrBUNA$&b8ar^jU(Ve_zxIa`4`IF~g4_64g-Mz(a6#5_%uAxyv~o=WXJm11iQB@QvPV68vz@&JY&eOdFSDo z%5vM{X0EZ6ai;coz-2Xe<{1yo(cs_Z$JU#Vl&5ExK-A`O2@9*OkV|PI^Lyj&2E{!i zJQlq$VbB1ALcCE<-tA0d9-9pfR&nInSlDYy;_7nM!N{x!O-wu%F0Fm~hJp{M2~rIh zUll2)Q{Q$!g9A2`43QDcRNOJSN0?hqj&Sj2Nv9(|e?U-!{J(=ckY99fPG}!=DDG3vaZ01@?GBLb8kt2Gf{p!1ge^ppq>7g&Y!bwdZM`l<5}nnB`70xOA7~|{ z4|69Y!y188|L9f?XK&gIJoj|8WC>(igdLg z&1w;rl=TS7zKZ|%jm!A-|AJDiS)}BiV++o|S;Vnhr1q_JF%$A$^(WJ|5&3_C?A<*w z5ZjFw)B>KdHZ+}!#^amJQ%~&ypz?U8@p!Iq2h_Nqlzl&efb9f>rHH&r56QA`*icym zcBZk|c<|VG#LL#0c&1`w*?5?(-Axxyn5jL~1D!_zX*|o_8+E|L*d_*#O1~x%h5rXK z3SGhH`}&u(;*SUhO$X7Fhu+~m-+<)RbhWn>lu(76)|9MDvofR)(3LDrRwT>Q7LNJ7 z;XOL)$m6BWkP<&EM9n2h232;@Tb04n0YhP!E zN*=VGVD?TL7Uq(3YgQy8{0ZD4D27Rvlmz}X5`;6WuX=Bt3A?A3dK7Nn84M+QuH>+X z?LL>wlND|Yfq_mzvF1$XChqr0piP7(#grt9S0+pmv<_s&buy7b63SaYL>8UR`uP`C z$c5Na{>M~gE*COuNzjq%Y!=L46(JXDOL>T?vRp27wBn$CS;cf{7#1NTa>~gcp&7Z1 zsA%TGUa{I0jz))@`5`^>I z$r(z@QFcI{vQe0r+4y~UL{iq&Qd6X58HF@Su4akVveH~hh31J7!aqwmqttNoBhjpX zkY@jg_)_{lYC6JGJ+yf#%0dzlBT^WtGnX|J`d^9i5Te19$x@Q_dXmksQW`|3)TDYv zg%!!c>eB7fR9VXNYSP5gQley$rG<{vYbInqm4&3=Z#th!vh|osGCrY*9m7)FK~>7k z|IO4H8oB_a4l8TiuvWE?8DavxQn6OKFNBbWOQ}NiR84-1T8UdOSw0)jLbJ*+W^leB zh^k)(eYYqoS2^@ZHIGZpQXc)4Ika0gj{&tIZyb%vBBT@iEs(fDHjhffavc4yvzU3C zuxj-vg~mY=O(U9gPw0G2W2RajoIxI34r{Smo}xtMX&`#4v!}EeN|A6PYk^wX&q`HE z>J(5qbRzZf-|GFVYAM7k(nLkdl4WSh)S=1RL`CYaRfnF-Q8(71o!Ue->Z29tci5qw z;?m_xl=h@UF~Uv-mWBn6bE@@RW1@5)723mQgsL%l;#xx3Ls)DX>I`bNvVG1FG-#E| zj9lC?#_8BYjBHUx*3O|A|0lT9?n8Tp3FRO9%@ze3{F8A2?Jqhkq>@n>l`k3$L1@23 z*^LslZ!!!))S3-i1C)?y7!@lGd$rPq);9%(3KCv;3QomR3C^GZt6QAOH-^=js?dAb z*9mrMT+S?-qx4LuY)6;g)$3-*Asf%QxbAfz$Q)0nbdRH-R&yd#8pB?azF`^sS%-3S zEvO8TwN7TjZ!%s>Hw|qDRTK!O*UA&2Uy-At(}3n$l_ZXgq$ws|*ATsXH2}H^(O8s0 z4Z#@ES(ca&mF!~|-K6Alq~y9oOFGAEK^Teud%t-lZykC;T$H=v z_;^TYArZWT-zFeq!d}KNk?ZZ*|0KJskdz}I%7Si9lhcJto0e+?>Q+QgCJQ>0D8U7y zk}4ZyI)>C7Oh#e6FuCAp@>}pNw=>}>{c^4E_G5<^ z5K^eEA$}YYb}gEqZe3>Iltr;qAkNdsfN|V@mdz%S$Ng~irz*+%ox|9AU&f1V|JAxV1Sh#pT$;Tfyk ze6q}+_)K$wjG;)K4oeN}xHTp4rm=+9p-_#fwW#19LW9Xx1K|D~OpV5dEK8V@Spm~w z>IQ*{yz2ykyek33N4AM8^Khg)Vb`=e(4Gey^JF_I3(YV~55m)5>uU*|HN5E-+&w6x z0M7yjv7t4^G=WlG*tJnJDyq0nDW*m<;(!#I0d4E@H+Q!{>Ls56a1DZx!OeW)9qC)T z-D2>9DXtgXNjAm%Zj_c-BtEz32qdE_o6hcZQaAD+`d^b1avOo6p2fCdS0JZQCc-#C9gOZQHg_II(Se;uAZW*}U(+cK5?>Ro7E@ zJ)gU}@9w^S*M)pX44Wc*t|kJh=Un&{fc(>KYUdhf|825YA+VZx0%bD)vti@as3(}~ z8h=XvC#`{St1=@=WAZdXrE+w3)`!+O5qps~mpw+(L&v=hyjjsJdAc%AUk!HZk3L7CcNn` znUW@@EMR~l8yl&geO)`)CB3N6%3{)FOjNM0>BVR=X)oOI^f>vP=p;QT!mEMWV|!6^1Yl9x0FuD@@$W}|Ox9Gigh>y(z69X2%^ zJV1Jov~hoC8z)|EEM=)=qH3kKUI)^s$ngn@1-y;e7%pjn5?;~I0W6UzoiU;!q4EDT zd|U(3QvUKv{W{+wi5M&=TLZ^9f?Q(vy61!oBT0rM zR@BCqQwrw3hBOoV0)j$~L@l4{)^H7cEa?e3*=|m{jcdxaPkTawxPK8`#}a$dlDM8V zDI-T1t-_ExsbtdvtTRieuN45#E>%*ubY6pKSD{{KF<57DMJL%&zq^^DmGWH2qzWoTY)0^|+(>%f-aQCX+KqyKa?X zZ6BWm+aT_S7u8^{S%R19KMJ;=39cy@3o{(K0oY_?v-kPzOus0ZC;+#`+)wOPwZ(MG zg+FYU>?`|4T`mwsn)KnY-y(o7$d%OEPAmlfT0M0D(Ysvmx1~8~W~3!C*@4&$V@&J{ zOUM=HP0E=Ja;4wePb;R>H`psfmgsy3G@J)C7AAlaq;9i#m8p8+ooVn|lPcBAJW!E& zr_JbCljMIc=?BLg#sfHJ4nQepMJIx3On;VQF8#x7=gr%-00>el8@S|^Wu7aDX? zs_Fg``9Z}x$)3d#?cZHWzh|vS{J}TZW*yreM%mM47P+1v61P$`%xzbuDU4$G`B}>0 zTe2=1D;mk>{MQ9H2;=0?nfbWymPvN=tL@8@@ymJKyl%r-;*;z6hY$<=kS1&wOmKKx zbB^V+;0!E>pcu%MgU9Sf5vqC_L?|;Z{H4LH0u7GRA@jF5#%Fb3IPbdcn8){IQ#5tK zv$C>`E&lYK221WWoSZ(xs*mw>@B@ps)vI;IIJktaqYkZ5Vma@jKx-`WgPW>np0(0n zeR#yv;SW#tctm~tHKh%McJ*TWIjF{rIIkPqKH4QVEK2CnULT`aTuxGDQH<2%CdPIfl?h_knxZ&j zSnx^!j`jbaGq4UsDlpHw41pDwr@|@4a)=7=HmfucCKV^crl1Y+ck;eR0!ca=iiF#g zX>HDn`pY7ITMO}SWTP}gwxu+Bhnq7-6!ffLvQAPBxu&0113()hRd&@i%}j7gG=lg^kJ4DH04D{-YoGN_fnKZz~um9oPUL{930au7t@ke zoc#D?3i%9*FdNl}~5HDg)1bD|T zSwH^CE!pN3O)xEg1srp1A1>E&6?rvk_6&i7Uot1~y4T6@CjqL>0;+7OOSbV#@&sw} z%eC@U-Ro8x44U0ub>{|ov=*90NxT+!0xNs!1^F$&@&M1+QRV7}QTM_-{R$uKGXpa3 z=IR8g5@>+b_a=jmfuoi{F3DxW`ej14 z)GD7;-c8zNa1C!Cb?+n1X+xu_3jvLDfkVym>l(`*3+?ls{Tg+*i=KJwCs_NWzMySM zj__Ds-f6<&>AY#nr;&>FV{@tDJqhWf+>z`44OP!!wqVoif0w=^%FvlR72i{+qV*ZZ z{!$7tkU9UBQca1|AuRfBk`&YNQ4xrH%{hD4?!W*bjp#~B*ipzBx0#phcPSf3fmg_y zX>r}G|s;qX$Q(m1=$|~Ngq9G?Ao`-c3ZeqJ?6)bBq7(u4CtYp=BY+Mp9 zTu7i&DEy-U3B6D{j*_zpvS2S{*iS1~Y~msfkFiFkwGiNlMq*Q3Jg~E(5x)!qX3WXY zOO9Fn`AL~I=}ba#a?VO~+gy^hO)k{Hi2(t-KJL{CNkXgCYG`pS$+tQ}w(3e(&)(q4 zg&3M4+iKoR43Pm_1c(td1zYr@S>~j@yu0eAb@^2oB+7C^@;yzoGUTh>L_3&1xv9+Y zcipG`_x)vX_rrzDO%9v(xMCsSF!}R$cg>N#l3ER-5M+TlQFeQV923fP(`-pLo}-I# zxyehLbHSI}z;>E>Mhyhzh=SV&&Xysp{(k)O=0OlkiZk+d%4ooVGraq)%;pSxKuRNO z(Nv-FKxY8CHslv<#q%3C@R=%td9POt6}nCldfMnUF)Axn^c;m* z89Wq-5WUv;4aRVvyeUA*AN}~(yOKP%MIG7eu&qwI6k* zjFYmq(8BPk#*>BI=vYumMeHUB*WXABmaG`)Xv&&aBqpdBy4Au12{>voNCp>wHPz+( zI7_tBn|LHtjbi=+?v8|q1G6AvjK7q1gq^Sf^>mG&=QXa|*;^{H0Uh zaw0GK5zOB)zxLf5-qzx*7_5dZ=fvDn~J}4h2K3aDtdZ6g|s{8(R+kQE^FM76`k!k6IFyX8t zsawZWS~~EHLO*NZ%NAa7$IE>^#J!=S#fAs_T;~*~%?H^k4%`zvKe)V!yY*%R6GVs> zL=BLwNK$4XVW}{4PWeAKcMCY8c&MS|kC7#UiA&{4ZvfT&uk){rFKwj~#p8ljT4jkK zwt&W8$S8ZI{lysLWUbfdsz&wdkLYC#z(Ls!jnsU$KQL=W;j!~8Cw}g?s)MizN#$dw zR|3S21NyRBY&_tEY})mUeZ4y=M0z1V>Jm0fJ^%9Obs@$T?*Em?np-Z9PaqHmq4c{4 z!S8JM6R#~ViLLOr2GyrWf&b7dbPMgL}*OtF23iA4i{2Duy*%nZe zNxG1uGUAs+l7c>MoRo+uH|>9x+Hh>URjF$RMy}< zbY~22hr_BY8l06fS(UZ7iZ!}+;>N_FV7B2 zm9QF_=hkYKlHDWc6ujY!n(XtdswHU!hd9+=pZ9JUlbA7zZ~tENzxIz!qQ8Isz4`w$ z&&`wc_!IxMwS%R#YLs|dRGw>)u3hk9FX2QB;-66*BS>(q?V%lOhZ z;$}`Bh8>PMpZ&00e)%Tqs_(Pms4y3xlC z9wgRUS6v0ABO=7m#WU|oL*32t(*1w9-lscmR$XhRc76?kT#L3BQWoAA)mJC=z4$W~ zz}f=~E|f3}x1RaE#c02t`kr4d{??;wRS?*cA6FaGlbudN)3sgQ4J>e?q}wqkXD|(Y zdaF9WX3t38AYo*;-)uFd9`egP?Y67?uMY2793cOFZVC34y>>&z>v({j zH0H#hzuD&U6Qvhk`Cs4s{9EaZXaSpLH*>zhd;Ke~+{p2C3+dA&(Lp{7jg3C2!0bv& zY)OK(19P3`4+znqeE#`k5RL$;Swt}GsjK*={Pp@|E1xMhTBPl!X|#NPo2X}hT%d?2 zZ`#xw7_?q-JW(DU7=A{h&H4fl<$pSYo29i$)Po+)654JRLb*bhX8JM%4?1=N?&Rc! z|J=2BcV17}d~o?4UfT0Z{`2$DjQnu@tw(n|;^X9_16Rae>~6ia{K6$`rpRw~V5uc| zVfwf5>cA8%tI)@EpJIM+>66l@J2qV|BKqzF8Fl`)r&=`w37byOTy3MRxdwd&Z60Mx zr_oDJxs2;i@|VN;Lf3d9eshm$oLEz?trJDuCi@?UEuZOsAFgmd4ov|sa2y(J=)r!< zS&~Bg-YjgfA-ElTkvYkd3Cjm4ITWri|#vl@-al!%s8kzXqH z&-o+Ag9cB@4Y4@+9b*`-G+31srRP~1D3bT_<)KwV&bo9(b>oJR3`*=anfc+I))(u{ zh_LzI*L<8HqL0|x%NgbINMpYctl62NoleAdsNb#ln-|2Cp)_Em|DJx(x=U`o#cWM^ zYh74kr&06*aM+Q_bRrS$Az3=+-fCjb_*+kie4n>kHAi4Ql}y;Ak+XY+KyCwL8;kA* zgV~xZ`ws%$l8@=o{$k;}te;gezB4}+=IDxd5(40aS1$Sn?hAE`N6fc6n28SWLeAbt zXFh5uZbp}5MoHn*Z2+!ZV=&H4@!Wm(l5a#|yPy5cL}g1KL1ySE{|0qar`f@pzKap$ zBK>uHdfF`?HmP6tWa(Gi*sHI1%Q#4|!*PTBM0A1zBb_~LL{?P$baxrSLP{Wlw*alV zU=2ld=RkdQ*)n`-rE9Zjk==iYH96<{RhHk7N4Q-E~ky;ku+ZJdHii>a?a^)o1fS|1RmtDYd`eUZEc?0P;Hs>xl34Y%RVcK zXyoYT_|^65+P!%W^?EDV=sGatOzOU`QQv!BRVY8K1MqiT3$d8k{wb4S_2dC8pTV{j z$(EAt`};SbM)@}^91El;Qj|TOHzNAL=LS98t?_M#Ph^9Ia7%~c)PWJW+3-DB7>6i4 zy-kITu8Pne`?rL9d?QZN zK`*aS-W?4;jRo)xEE=cz6|RJP#>U>3>gN|p+#K;b8;FY722*TDW~u_Ti?7n|1B6%( zG&$6qPIK%wJ)9EWeegf2;k1-mbX_pFlOpsjE_L5|iOT&j3u6{dZK*C1RZ>~q@xyzAculM3 zTr_n9-9H=Y?Y`ZW6+N~?%|qQyb{Z;*JU4#lf5*AwT;97z3g-!RVqKoo*woaZs-@|E zN(#J%_DLK!!*BvB)akNkd^#wO`DgJtX3j3IU#BEs`{(^BO;+QCw(jA>MP${{xM>g+ zB=JHUggzNP;}!UO7x47?)|Q$hhO^Nnc`^z+14nRC*Tda)iM?@(GE%F$+DXg~AEk3@ z;;y|R8@}V{nZE^%!Q{77VXBqQS2KquD!?2{i>xCj=6^V8^dE;OaJS*OJ&= z&pg{0@FVoR+qQ(B3>P@-292D#Qe=0`Y?v8~buRNvS&FR+!~_$E&uZD5G(c7wx+U8B zs~HK6|C_Oc$?S?>F=|15qo&%BAAX_FgacUFg`0~uH^fV{TZUofz=r*h;Bi{BqI)*S zhWqLq9qj9jZA|AobO-4!vFTqff1giwxS>*+);t{I>H$oD_r5*4A~C8tn)ijdJ&H6p z==mTeUqI{EOR)sk?>9b%3(z1axwQgfU(=ED17)K!_E#T5wgvaS-eT9onXbhOJ)|Fw z_fZ3BBHhX=cxp)q9e;~V;*2gnCC_AwBE;|Rg>g`K^vd53xc}&v_MINNoGRn$<9YBa zr#YQrM7@B9dB#4npxs8AIK+eA6(86-XBuT&pVr?fJTU?f4}C;_$HDH}1}cg5&Ds5d zQj~rA$cR4nk;vm~%1w6z4$9~;k6>q$-#A^Q);roLJ#R2q)m@L za9|d=-7NSI@cz)8kaKX;u%K)Z;c~+A$0OYOj$>rpkbE$xp*7GQnSD6P?&FIyQLfEy zrSR`pr~BH6?F*tDE1oVJ6z`1h7TA3A3v={OgA*6W&&NFp z7w_v?5H$w-7f-OAI>LZqL@?M@J^6IInklmoAaXX;;)bbFJ}v$3!@rB4-7y$vK1%cb zty6t1cQ^w~r=lpXP=ZXBI|JK6ct9k2O&sR1G4a*tFh;KfAEa+-3$B;QXQ3laGGhbe zcF$iwMvDDL(lQ#0_&#h_jUTir9%wg?rdQ$kqjle?Bxm^Prr8|?YwZUP^}yxL6z_MV z%gg@u1o=076YaYOAu4J-)+OGb`e*0qhZo3v=8?NPhMK)Kwmz@F4U<7w_$>{4c1gQs zdeh?Z<5&jsfK?bCbj{|->^~?%+JBFooSW~@gZjW->_sA+)-y_k)RFy!1y32ok zJ@}GE7rUH;7p(;KFg>WT|j+UjyC82L{xxv5dbu1FsYYNALQfn9(Nwu`xMh>d0 z5)8qaPFz(KI{R?8v3Wy>P--0(9plPyinfLe=X9@b9j!CER#@Rt+q#dvo2#p%M7~1O zO{3W)LEd>R`7S##Vf@|?t{4P-~@-5`CZK6Uek~~0prJ&<=oMNNZkJ1%*iJ! z_|AFPvAB~_Pi^!h*cy|=^Wc#xgCHJ%Nlrq@(L2nT1BMqz9M400nrSLNTy9xlXf)WC z+zIC}3Abp9Kn?}^1?QTj8UJx)v&#usz|>E&e|vf+Xk{)+==O6;jP$}Sfr4ykxtcsT zm+|;cs&mY*hi0Z+Q-yo%@+VXwj<{iAIpeRe{1_8#eLpC_!m#AbGEm&e5L`8 z<=`i|!_}(^$jDIsQ($jt>GTbFTdF zW<=kdu)i14Qit;ErMaJs%ih#n?*z0&*=504?Ja#s7AfiwNuan*I%s|cVTK0`WZmY}T zFkC(ov-;1qGL1($)|EMJztN2%pTUR8DVHn>BOEqzid}43#V1c!uJ({U0l%28 zp{W|2g5_X(5{+oh36F*E^vpfU#V_4X<=0<*HufA>Bid-L_P?zNlzi$WD)t|=?&#d|p*6;| z?I0t6-^Eh{Z)NFtW>Tny`fgBAg07IyJ#X8y)hTiIL^)5)lNVv8iwT!7%GfJz*9lA~ zI>wQOQ%*7}Tq}j&P=Uavoia)ahP0ZRk_496wz=@1AVA=7mDY zi``Gy>5u^bp?Cb6mv!JL31@X3*owA8D1-Ej=p0Q4jdvSk7vl_ZaDOI?v$QnJgD^$+ zh6(yu@oy8Z)?K26(r{96XiOD;P`$!!opnlub{`e!n^!f#vs*Hf=U##CTz#!6r6!nr z7{Sj^v`LJCTqg!`IzLkxnV492sUqy_4Yh0TET}8+=9Ml=bHqK!ILq6XnA&*Eut+Z8 zp-EE~A0kwg*kGTG6j~Sk!349jL4|tv<}uF<=k=DZ$M7g6JIXvC-+Vb-WiAlUd^CPx~&)M|ly&gmNDo^8D2 zAH8$79XgEB9gd`qNOag|377*-u~lXhbx+%mV__XP!Cyp15sxkdW0vribQ%Gpp5K z{JfpfsQqYEAP0Xhd8+G{xg3VSGw|uV2fi8qNx~9#k4&)N{Y(Ahvf$ifL&miWjIM-z zco@E;h-6vXY)o8W>t4e80YyUeO@?knDK@FtvnQ*;vsLM<6ZGeNIC)ru{Z=g46hE4m zF}t0su8eI9;bg1JzHGNqr_hSw+b#Aj?f6!nBD#OHrTuKee&noXZ9FF~S1#_q1`RS8 zQ_hL`#-4FocOo)F-amDZ(0EsFV(dRbmr!&!r()J63L<6kDpUGG-If@ZAbU;*@}13C z!Myc=_(jJZMCpj3!eLX8(!qIte_j74MInzaTD|Fjtz&R_Fj@1eq3MLmj14RS-L?D` z!R0HIzzk1@6^lF(L)g`>r2b1c{7iX$L;FLR&B=>2O1neU@iO4NJ3JP&g+dPR{Bz)` z{S!RCqW{+;lf?^U)!uL06IMBY4P)$k*G@O0lNsBbu2JmFpSp=c84+zu2BIE?#f0?H zk=H>ex2PzFks0My%Zystue*(tkWXcVPRL?6sUb|Z9M@7_bR0_Yw?3g-zxaO-=oWwZ zBM32KbHfX(@E)R%UI2{zjSTJgM)mm8gU>Ed$%O*C|DhH=xap$Axjn$1NJ;m=TAa#x zTiG4abS^n*gz91CNX5?{jeY)b6%8}2g-xGM<=Q&PJvPQ)B!J_? zjJ6^4XZ%exUYK{K&niIO$$c1}|2L{|?bv$j%5!#aqnM|dd1DzvjXnAu)c?Z^ym9i# z?>iC;JX1!|$=WaSLhyz$^e4!e@Y_;yjYIGG!l=8>Y?}La!yt7oq;B9Ag08h3-&S8MPuyrge{`&d?X7^t{;EEOUCqd zYpy%+eOyZ49wju(@aAi!`+yeRGq;c=c>jv@AE>)mgV-MgpVJW{PdnuBsF$(f{OFQi zNs(9WNr7lic!i~FCdmY_61pl^M(G(v7y~Y2!3Hb;9_NwsYd0OqVCJyau-67$%p!iO znHs)lJl6$^<{D0eU;CKt6btAXcKG+=yz?{0^io7QZy&f}C#kh{P^k@GmQ{5r zQt?(Q56xis@fij8iGVnEK`8)=D7O;jkYiT8D6#=(gSvnn-Py=Ha5^v(;R2YU|8D!ex4xMQOnxZS<+? zTier+qu@yWjwirq-7{t$Mfhrquh~3u?f2b{LybiK{uy;NO(~X0o|fYWeQ(pxle^2+ z-7cONy~Lo^%15{&ZV3cW&ia1X((;qe$qQ&rLdOv>=Lk)R;y}OOf@Sq6%{J`l+5+cV^@~@%@*kn2j$?QN z;A)EkXE+~?tz5JUNvre7D4z|xgllG2_%=k??9M-06%QiIe&KylRFY=i*ByiD-W5!y zl-FbRkw!hO4i|6eWjAeUnXA=)3jU$pTCQpX*RkxCp^hXoV*$C-=c|k$kt}cvYe)N4 zYwO;GJ~=YE3ljsF2Y?Abu_Hwx@gJtzdF9KVGN7RM-2-b+BNV1+4cgi3g?U?U^?lb| z3p|Qsg}X^=!u3pS@-xu#K&2vI+~?Rg+>r}b@ivuBF_Eff$3>Yf^b^c>A!1!sd|Dn? z50a|7KCqqm_uGa`4Us$d?MRpvgwWXGoK`F0y~Rb5=1~~rT~HA|X0SmW_g3hTIL@Y( zMw93DHmJiKis7@7AnB<#C1lyWq;U+@Od1Wz|6s5an2~9W}c5_5S%bLG9VKO5urktyzNR!NFdzLnBA6 zwfwj&&VN4{G?Cv}`H#q4l8r&gcTLoP=(N5MR90fU525g#NQH@A&Kv8QXCUkI@kINj z(ek(#^e%q>e4_VG$x;{NgdwDutlcMAw`g*9`uYm-*l!!!; z#g<;b;@2Y^slD7eLnP>XBeplgMD<;z%!3c}qUZx=?E^U?k%&!OBQ{3rfS!y*?ueMi z0sMrG{9N?Pf$4I9l3peA$*)%1#~6P?B?-CdP2Wc}I-?tjNP@OzcpsBdh<&vVe=S%9 z<}KT%-5&oZ$WW->ZT!q&n{<^y_XzzwlH0a_DGDZmi=RIzwjFQ9b@Kwj6#wQj+#%)p zJGRz)Mzo>LKoHEk-24J|d$rHT`LR@bC_h zMz~(V6MqjL3o0dD=7&#jl+8Pqewns7-Mot>3C|$N@}pYUlpoRFJOscKT%BLS4AoBv z31Li-F-G19f1{waa^*nqBE|CK_PnOX=`VNQOg$Xo%ROIR#dd$d8wS*uDY|ym4wJO5 zQv4MuqufP_;KB9Hjv4o3BUtAcDvIjA(UiyMt7?8vH-1Wba*M_kB{~H2BMKE;6I-jg z&ywF(eOT15pg77fvDzp|VtQ3NaZcm-pUN+HRei+fiR7qG3Hi;aG32<@^vAqJUx&2-)q6r zOftJO3C(1?i21a2^K5vX_eRQ()DqIuClb;z38Q|>*~#?KaVq}&wIo@hidg&aoUl5R zZDKus0te(H7sgzy-s7FTlCmO2>t6B)bm*N$5#(%_w=DX}!T`$lc9lnK$9eV0gHo;g zaAJxohHkT#MBWJHM{CEsQY<;rY7)pDuHyQ94`{AkrQYgjwWR8gSGm@p!RT$!q6O!{ z`ozt*zY3HlzAigjhsFBB<2)^<(c7t+!xn=+!&=8RV8Q6dfx6hI%BG_|g#mX#WzKtD zD~ee2(e2A}3LVmPG@(qu9e`mQxi?SUVn4yVJXo85`Q=1pPQyGUf z?9D;ff(rm-v0}C!_3|&+K#Z*bf5kU}R(R_sbhW+Yk8EW*Rs{si@iJ_F76yK)!|*CH z`Z1=6Avpdwo%lbX;!MekaO?|!OmI0uiQ|BSEwk)DoT5e5e5SRlIbLq2q(#|$CZ+@S z%Tgvgy!mKK4p@;IV#}O`{fk%$?kgZu4Ls<`Tfvx!(}j$n_$z<9KP~ko2j#f87^cy# zHOd@JyJF-FcZzN@$BTN$_K#}_`!RcnD2)Jmqq3w-X($i2qRyD49I4(yfEt|Zo>e0x zTVT-0Ens)02m(LL!pDV6rX@{T0o6+=Y&}L2*8euU1_l0R1>^Dk=$!7Op zP@49w8l_eH%^Rodn`{=JQK9TBCdRTJAaseP9iUnWT&B5+fuj_v-C?;}VK75a0oE5T zGHyqOSs%|ZzJN7Au_C8@D|r3hR&(%85MkwkGc+(qU&WBAt<;L2kEemi**a&wUpUeW z9kQ(3WT7+r*MnEI<$!|N66E=og5ZWZWnoSZl6HymQCjLd{L~Ka3(cT0F%!_3jyvlTTB5e+Sq{}XSMo~oO@M`G^c{ zKC7sVXVsA@o_~SFK;aB{Oe-K24|RQm2X{XQ8(-+Xq{@5dh-Dc~X7g8CHZjyzXS0v# z3u`~=qKfUmo=4Jgo1V8_0PM*b$*fy~P$Sp|sd4*a~v+4 zG@+@|0I!7lGK}Vd7f(q24}(f7tpp$VUoLRKRI4oLcZ$0KFf~x(g>F=O%%)v>1f-)? zL(b)?WkiuxkxGdd*{^{>kv;@rrb};fnc7_RV!9QS3H|2g^SMtoLOCF7K;e zZ6|Cr$ROkEqqd!^w!d;APh6@T(n)HfnA~IQselCYW>pMdJMKaB$f8cWGMckBO<74w zPBO8=AfA;Xk_*JLq%{z+5GaQu!q}0_0! zUV$VpZ+mr1Z|!8n6QPDGb^2gT=$qoh%vG(Wm_!w4iM2^)g=LksW+hjlYz2VZHM+&J zO?C%Gq;!g*U9niZ_nkPaHs?P25bKN6_M)>(S0CuNrBc)StEU3$G>uU<=-C^Ve#=7GAlYIt^H>**s1$u^ z_J2^|Nk#srEWy1e|DwRz<5d4ItN#C#|9@=#zkgdK=*Q99xJ^vJUj06(@N9r1I(3a{ zHZ{k1HlBlqG?J7s&f_oDP0DFiOw!8u;5_Dq^1V0~=N)j;ykX7m9?Z#Dm`!dnyoJ|x zqN&#HB3z+28Nz;8q0S31o!h4HB;{dkilewqbw%9~899E7Z<%|GXHN#`P{4_Bro4=S zJETWNtJZd5TcZI+7j63F443FJB@!!i7iH5B>F%deRBMW5gwkT?zR0_d;$$r+IhqUB zFYc|YF0M-!P%8TRfEfM;h3*vl>sHc*b#4O}Q=es+@$9xN=3ymPF z=$eyv6*tC`*<_sYg2)bW=zp}ywB-LITr4=E{^Eg?KypHI1^s>g#pt_XAa!oj4TwT3RF&Pod84n zKiH6_cJ(^ij)#>dVNfZt;{g)syj5#cQ2o(CI3Fbjgm5dGE? zEP9Un>t?bWLqOw)lg)R#PHBl+d4e2Le>(nl_u`Y2mSS1#fD9bRW3ch=l;l2na&gzt z4}+5mK>A`6z58Y*q#07W0flR!?9vY1ux+}!NGQ3#y0HTBhh`A(SK6H2k`#eP42pf< z3X)>()h2y~3N_0Q>Z&2;71PQBUI3RkjFa3yA`^$zqs}<|sK2Ro-CNl80Nwm26LTsa z9x55LbNLyCZK>AUwpQIV{-kY#o$|X~CXyiX-XjhUwN~$M>`+0(U_rzktE>k-(7;Ry zy*fow0%b?TBAeuIf=h}IEw(CslN=g-C-AD&(v`%iK}+yWYN#~Dk5Bp{ORMcw`Ip-b zi4jTXbu+t~7T?kHr*yp}#{3T&lG6FjBouyh{`m~0RS4L)pNQR?wAZd&xuLt@4YSE< zHWn>ukZm&Oo8^D2R`pLcVA?MhQ-l$<6rc~6;3CnFE^%*!mmepEMC)t{%8_WaVMErO z*w6*5p|%+daQv4Qfy$O}wG^NNc0``MWwRQFjnnjI@eG2W0xcwmtz|9r(K5X%0!|^< z45o~3XUyVGmkD&9F=?aZ`8lcyh~@FfijS5&h=~0Hlz{CI+_iwUXfLz3D1p9TvP|o$ zpf@D?t0vQE3PJ;C>z={i=DU0dEfX|@Y3F>YByqpQltb4GPbA#tD`blOs}WI?Zej=&O4g|TEWvY7{qPHzA-lk@OTy( zi=Fd=*O7-dd`(~EYb1uG5G2Y5+Q(FmSnU?ur&^gHHLj zP6P{^Wt2IF**Pv1=Xe|A5*smG#-DTM&Bsc?IFpVtEeoa8ymi$a5nzS~&=Ql>tL#-% zuOEg+hhE2ayQI3Er&GQBu5)Dwm%eJA!Yxk-_bbLEQ2MuN6CNd)3xZbDK@wQy(0#`( z9J26Ax1ZY&H7~uyUws^uJf^`BX~188X8*cQ7Y+4g9F=K@3*@-vG~C-sUhhU~M~7hlXwwo@&}U`Eor# zUcZ{eG|g_b8yk+E{-@P9Is`4<-?>m*_`-7AMxaG)kL^Q06w1Enq_#-B6k0tr?7M#lD2dV1ba|JKIOf|>?u zCi;nJQP(j{(J-kBaIJL!@d}>1QEb=Rq(#yxF}($2a}LiGmkyD*nQ}-aJpeNlkoMvX z8{qtu^95PluV=k1;IPyDTE#r`8r)%qu&woPD9$(Pxt3^rjkO&4RG5m5#Qb}v#jF(E zI7;)qhb>r%-fz5l(K&H{a=1~P0euO|g>!$kTEWlq6)GC%2ai&=HTP#0L z)QE7dmE{Cs`lHwc-u}I4mAS`bM$RpKO0x2Jlk^@jMPr2u%~Kqa;{PS~eW|FHOHT_z zKA3J?r~Yzk9>e+#o}&Ki{oYQNBfm6jR&~~*`(_4?k&YMX13Ri)wXtvE@|lEtMS5P- z>g}V9Ya)Az&f|T00c|;NnvkYK`;Q;QWY(%KukkQy&AfaW*rLFA!1>0J*)){qzWX)6 zqV<=0un997-ge}|7Xv@Uu~c+8@OIR#<5@QE}>!Or->0qQ_RG33@v3N zVB%S?l?kH+kRU5et12!4Z8jDxSr_`e=7G>_QYV^v%ksp=u%hT@fMN(NeMDQ0E#;8El=``bnS7 zdC=B^$fbddJkXzmYne|U(K&Z~M7L>=Te7)gf@4gFZ>M!XdC>SF!@Z;=QK9kKom!ab zqf-N8S=I3s9`ZjBuZv#D7;AO!HYvJP@wfz2E81_VSZh4vrKt zE9(#_0Dh3ShiOk(FD~NNswkCpxC$u2`iZgY|YPlQ<2EV zG3s_U)mcqTLTU8g{1ui}P`pa@smf5zE!96Mv6P#j6;YZ6n9IUITgjCo69jI=FLL#Z zLj8hHkGC@hLpFRYMfI{9EbuECS201PYY^@v5Bn>NLT_S3y*ikF;c3MPsnFc8t*x8F=R~c-J74k}~pQeAd zf646~Dx|&-aT`n*WROxpssxc?RZJda&36j2mk{y9LCS7pjWX7Vp}e!5Dj2hptmSU2 zvLH*$DkSA%9jG#z)pWFGm1Eav&)z5YK(8q&nHU#cB@=_BBq)G6gd9{8!bSDr5_1RH z4={uoen2V0tJVRvR@At}s&V9%Uy08tn`k@ACp^whmHNwFUYS#m$enz9(*eUN4Q&_> zX&5YLJ*U(x(X3!BAf!`973Es+ma)31)&l=KEV@bA;rG=<9@h9)zH=cAOMnYp$;AtS zx%zCW7CGJ?7r(3+W8$;cjC=S*4~Om5ELZPJDl3cHDr5W;1N2_I5QnNz1N4zkGDl{> z4HFPKsHVf=siF?M<5>RD{onLaZ@9$3yzSDzMQpZ`?Celd@iCJ8Hbd~M)_Sk5G^jRo zhWh&6m_l#*B+yu&J3iNf%wQXhm4w?7&cdCXbH;UVK&_RA_}a8-Y(?@8cNPtMAw3~C zy9~*ii~;^ag`i}w*XzCzx|dP1mjjQF(~t%3w>J8@8qumYh}^yW_?{2pzyEvlcmMIP z@G>sEw!1u$+Z-Ez3*EVaxA6c!bfC_^lifW1^^)e(xhFq99XLFSot{`|`u3-fPd3M= z2i)$nh}tZ$w|TtZ&I1*`&R*uKf1L34__R}(- zFU_qvd%~{G;oS1J)A2Im)630t^L#?!+W>DWOVrnRS;+gh_U)hh(Z8zm(M-d9#K*b+ zW5zpr4(bR=FA{Ti@1s4P78?KK>vHc;t^Y~4Jm5aF8owV7;uvuDemSN7qjzkB_u7}I zPkn^zW9;ViF#2!Z1@4~sFOK+j`+t0W-sdm;;`j{RLS@%65HdBFW}w|p9X!bF6QALE z=mbl%NHF79JG_1PAN>2_xBGO%MCgzE=V#N8XmPHdtsnkycd6GiBdZV1rJe94d&28O z6X^YuTN?wyrb|0`jo>=}Y!EoKr;g9}!O!GUM)B}<_DlBA+{STke6ZyNq65HoxJ^y} z=5Sw)-wD7U1oMFTyftxZZytMg3jzo8Z=GA2{oujvVRVA%`SCy980Is=`?;0hKDWTY zR*vZ0KHI^+dc8D$`}gG?^aTY$&iUN1<#FhnsSWZ?y6@ZXHmut}H;=*3`Jqn|mS$n> zKR{C~`DahRfpM_I=%0kAZ%ZBA${28g{pHqP!^Y__UcD&s1#lQ2ZQ$N%9Po2fI|F~& z3rmOl@;gs0UPC!76ec-O1V5*)|8Zz9*h`%HWAoh|@wxZ^bbPz`&X3dmGIgi(jS2Aa zJ{&yJbR@_7)C`;#Z8P8AK9+we@B!3Q*WjAPzax^&@o(Yph@><0Tl_~vk4^9IZZ>@c z{*Ee?Yg3OJ&%=yU`@h290ogBXru;_mH$Jcao#?tWzmI4L;Ex0bvq0^G2OkcwL{SCw zr~o|bK26QjYn|nOqI z2W`PVokeo9|M9^jdx6efk1~O)Fd&?UMzQz}ZY(#;WsuLQJx2NoyH0I0J-Ko{jm`-} zb$#)2b8^d|yS3pEa`G?~7^)IZ&M-_}%cSdce_n7Ewm>ExR^Q|5!~Cv z`3(Aap1ocDSEr;tyZIdYar>|K=2aXu^3GBmz*_vccHo++agX3Q`2I%kQJu&B*bZ_0 z|8hT_{W5cizMJ{voX1NyJejDSN2gsT&hqg;!uix_I;BN$R{JgBJ93BI!SkT`yQ zsbRYnZwaxh?h@jsraR%C&5rbX3f5d$c8}GLk%zmL;T&x=IpObwrQ*x^o&^uG{F=U{ z#R`23?zAW$s)Z8n1?>xdT=WN3BnkHm>GEw}jeTjcHuhzKvZXr0uFn1K{5WS38ol>% zt7md;ejEVmbviddAm83>BuC7_E`9?^H}Ky@T=%`=! z##9+^h8k38({Y5gb^dW{`bN48*Ju#PAl(MCximjM#xLh2wbC=YI+uHX`*zE)6=v|- zJkG9@hRMa%?|9kX_qv=n({0CfJfJ(nJ~g_1q4ZR)8qITOq{P z={dTs?BbYETYbGd_ptAUmjCPBxd{($-~M`!TX!bj&dYxrn=hXxaqBK#xF#oRJ#MJk zyBpIOlqfZ2(>TtcQn^n$InDJpU#AeRik5YTv*@`|7BRq+&&{#ddw*@1hvvK*?*@?# z*4%c1^nYw0{`sOAeK6W{^2#DMhN&3V^*fD+uOH|3>9{s%Khd>_@*=#Rd3YX!`-p5= zK4Ka~#3)1P;n5JTs3B~%Vg?aAy17J``Qf%yTq*fBTK#(Xnkn?yv)^&By@$vA9_2?0 zlgIZcc3c7x*S>xB7@eYv#ADaYtjE3%2>3SIkb3w7<4rvDgt#T@OvBGn39ilEOT(O6 ztUCs&jW%Kl_fj%m80V;+U(<*!ZgFRFyODWB^W2E-O~SpT?uGfBubtmrG6A@CCk&yI z4ZsW$9w*JXn+TCW07DG?Ef}hQSWYV_CfFu|nVMVNO+<6y{IK5XByVKt`f`S}vR^Kj*@yWUhx^5*;i*v0!TI*r)5|9s)j6OE4+ z^4tA(!;M(b$=^$Uke@szdQPo7yV~^rOoQ(9zAr|ICv)sH4@<)xN>z(K$y?K3nOP7Qal9PlQ5_!O!6nXP#|w}I(a3^ZP z!{=?tG67&oN8>%X4Yf_srV@KIJ}kVRi(Le|1!MfR4@UT21u7iW=bsSwXJU5JU zSR{pykfiFy5oKixX>25)vdSc^ray^>7ce`D;agZhs|gN|oi2-iLrgE=wqmCPu#77U z0OhLF;)UelTR5CC`0Neo10R0$mS7{>`*by1MDUdx+E>o43V;e0NCHSHiAKG@Q3ecJ zGeMKo?I<#hA=)Hpvp1GScviqvx7pTZV+Jia$2z1KcD7(wnikro6U&a8$z+Kh!?sc zHiBuPeQk_jzvFX2b?ttysl_U1yqB%zQ{X#!5?ETm$^zCFaAyGx?S}8BB14rY=cdN7=J_AH7ciKrgZ2nHSg8<0b#&MH2xPZ*1^2aOl)G=V#lJDIBRB2GHK|2lF z%b=qJ7Vj2tD+X0$f=CuMfCjnxTzvAtGuQNv@kO5adBI%oABy=Xc9OU~i(Wd27jE&p z@WStRD?j6XGugb7?{Aww)ojMg#EHJjq6VKBx5cE&wOo44j!W#C*zB}G>@=nOtqhOV zc_??loPe@fRaswu>gNI{Oz?q-hkyzR$h9Ebp-F{W7HzXA2 zTH;GwDV7PaEsxj%NZbcb=}Umjr5<5FtzZ3gXN=M>XU;0DzZn{eZxq(l`lZc>R^%_c zDR^oYfGXOFY+tj(Z-DR#qHGt^SQvvW-_`fMvEqX$GckyGngz(*_(75Aj1#btA1{p` zRtAxlV0zUM_D}*@z3X18Ov#wvQPHt@Ln>ESRnc#8f-8%-YNx-ow|cIP#ZYh6oYWbc zWbrj`u-9k43XfR}-`^v{)5<9nfCWQDv%-Lt`lpqMKrv)Z&uSGRJL8;1a4ScF)Hsgk`rivRV-NKk3;0|>KCl?3-iTS%dL=ItOLS-Vt&}K zJ2U)%#XZ8$=-v=3d&H9+2dUm|HdY49vA%a?t?{dze%dEIZ&>^s8X02fmnR=MpANrB z8RiO&E8QbQxMDC@Gazsd@S$gl6xv9S8&o*fxG2;N#Y39PoXC-Ipo z6H!D(WtFc@1(&9(PzOeA@px^*-`zpdG^2*67Bex0l#GpY>N$oK0fbdj>I09BAgEcvtDI<90? zaa1N)Nkjl|@Dn6uQmDgBJFDeMG5~ihNedu*s*y?ew2@x{fmwtqd}}5iuR91ndq7yV z&od3m<&_Em))sK5f4r9;#Z4526be!*NM(Gl;6!ZrFUXfS@|XBNN|<%}yHc>Jz_az3 zkOh!TLGlEV#g718=@ei9?^52*h9<-~-1a>sA#PZ%2q3pC;Rhg@f)q5%X2WqM$+_LE zHL6%6pOv}^35A=K)B|m6p2Q2Fy#it7>2y|Byk}d=0z#2sRi>*mQT}In%A(-si3Of% z3s}u_BLTFd3>VG!=KSSsj?!UXfydT?9vm^L>jX`5Ib&YQGWK9}oQyQLXN3z7K3h)v z-f>dtH})*u)j=@&K_Qvq7X~e@9*dF;gfj*gGVQu%+2NZg46MOL9-Z6GC)ZUU?n!r%JdzDUqRc%ditTL7t36PwU8PpRU zZKdX;+DS7!Y@2yWsp)qDlG?ysa4x`ql71_ zw6(H3cx=ibik@w?wtVo|lr`QOv)<-QM@VCuL||6S~?5(tcPEY%CP$;n5Ts(vU0;?3gMFKCS)^;R+;q)I@9># zLTHk5A^}y?uQM6Mw-LzqV-hGJ_dLJSfr@u_rv@OI=z$?~-9urI7_pVS%M8HUByz)& z%Aws}v&myhMRu84Oi^8;v9cz`8zM%1*i=M9MQ><)=uhx<18Yhv&Ah5u`v4y=W;$W7 zf}L3SYXw;|>6(0nkDW4v3yGXJn{0&W9J0OZ2A@UupIV-`4|iJNHC?%j$BM5gdHkzzm{52n5DCF#i zr>zjLvv#0NIgd4Yq}vQ6>6dWu=#348hko>bvM3V|0qH@m=NazAp`W6gUkS%86t zlJFedNV6~SAq~wxzw8vh>+o!INPa8uFqA=bn@nHADzH{QYgd@?b zo?7Tj3pO5~T0hR!pmH^+T#=uf2O(YgC$ql!{Mf@nUK=g2J+tjRn5aqr&%!84Pv!`iH8m@uTAP$17{<@QhI1 z`6;l_yn(==e$cHOvrWNca3nsr!yQigz2S0TRduW9n&0^Z&|coIbwGTnh};7OAiHq3 zp+t}z(E^V~*^vO&PnTaQjE0rUX?4KbB4^L+UhG@?sCbqde{HfHHz~~I;UTF678bD7 zK0F?lMgj%--eG!8Rr#)_tJD%lBjG$zy}*-K(Ofkuj7EZ7zc^o9x+}1tI?){DZcMR@ zZ;;1}P$Yfc>W#~#A}H*Tm_${@3o19NwOmV9DtUo*)D04=T1+9EDZihRRPVLO*IPjb zI$&ZE)chJDC7axBDniY4O>SKp-`@QV^09jtQ^EJ8SpD2Qj=?(TdX*x!c)>wDgXccu z-k&B5AYPUbz-_wp&cY|OMgz1ndn_$rB}+U2u(n$64PaqW8J>0s+aig!hzn0YkiEw~ zBu}sPQyQcf)_4kQJot)RiZPL>#=fuyTUwK6+{Smn!l0tG==B;#{=61DPhq6@rA1zR zd(DV5@9{;oG+K$z4QF6q6|~FMAn_ibZy*e;Dlvq(C0Yv3|8G%ne!@By|K9ZH_U;R< zpP;U)&)CGLpql=p@r6y@${euLd)trKP+k3iCEfc}Df53oL3N+P+05rq-HKM5pU+Uw zL1k|@PEk3s@Ttk$JYE+lyau!Y|BrLUT;Gi^^nP555n+u;zXcqy2K*PPQvE0Rmj@5W zP-kbHJ>(giLBe=*8Ao4AJ(iL(z_Di7Z%_%ox%i|NkAnjJXULcR7=48uS$}@O3f}YK ze7|`e^)W*2dz_4Mg1VNzn)^vjkOPiiq4-yvqYrp*EmZ;y69tvELYdmZ=R$qlZ@1Q0 zN%?V|#`)SFZenf@&G7M5qdT|>#@YS9=V_%r}lvbo(b^&d7qftIo_SZic2_*yKPQ=`*t0>;id|+111$M zjJhFI>Ky0-!ZPazYsBZM>gy2@%W@mFbuElPhNcEG`9U0@boyL+WN;4UL5hA zINds3r*1~!?R&h7(9XB!fRBVQNp0_^c4&Y2nt-vGT;RmA(j$Muq(c3-MtoqY2{#;= z0Dd}9sAv4+07b$3?ryBo&vBl+Me_dZ+_pWICXZEc$?80f{SoT)9nMUFL5;IxDDulB za;bhczg-(|mY4!LIqKl1fLv!zt9(8hKELN?`WPqnQ3rC2jtT+i{M=rzp>S2y#m@4! zq3BfVvYjsQ6vy!69?X(3QD>0MjFTt|b%Ze$xJ6 z1*#LhtUh4s{`WR$2x48Kp48hBOIe3_;qPv+%|I3kS=_%bb0DY*T3)S1hq&Fnw2?l* zyBtraA5W<4iJT+L;0qrQAo}gg|CuU$QQXj6ugwo{1}{!6i6QpQQbq5`wJ9jkAanoL zd|@$hXE*wu44izXZ=DA*G7W+Canq>Ny6?x~l*)^y+T^3u(ii87d8NxF|KhpD>?h%% zLo2K3R8=gW8ah8XUjEUwNemOlZI@Dcd!pD$&2OSQFKzD}hE~j!b1x@+1A!9K9gk4^ z(`gImzWQ(fxPik7>Ni-gNQvu!auDl@_se5Pj)AfIAYEG*sOOKIBwFX<#*gyE)TFa8 z7buGOqjO?#bzWG|bwMXy&|mutENj2-T>O%^MI!h*@`y8fu>3oBH!Y(EKjBp(FAhJZ zg*%dwzC|=z!K@74Nm8z;vwL6TqZODjm)WoUPu4TfGp(vmR$VglwgVR<|)lw zs68Iv0}?;E_O>VIvG>dMuXnlHNg?aVGs(cz#x9LV@X+Jf3GPIG=#mX8Jl}l{wxCh+f|!@2EUqoudmcuJ|Vx7^ZjbMgs!Z zam4`>`Zkhkh-laV4x6r6A(C0gh)rei8Fs1Nx$F~|#|Vf}1ky{w)%h<|N8W7-Dh^zc zHY%3b1GqaK`k}-nnA&41B2Q-A!Gl}Kc1vyv9p@m+M2diQQQdOc1HBdOJo&{O78b7T zO;iSRbD^cJiJZBKIO^Sz+XY>(BWg!FxcShHKU~Z4@}Dpct7N8T=g1v+MNa46 z9NiWbHv*Inr>44NBR6Eu4Qp?Ih*j=@PWl$L`(t^WCP-PZdcMI;x(oACh;jY_`nki0 znb=g!U(X&F8WSR4)Yn|=q^_@_LK4aSnui23&SZ_9f!vzjJ{fqLd*{5N<` z@yVP92YYHp-7gnpFk>qWX6lub@Ahden2!tFt>4R78 zxVFJN8@%UV+;G+9T5Pu#P21dr7Q)0E-IeAKD<<_DY6k#an?Ksgr2@b`*T0(g**g~u z0IB)IQl|2!=BP34LJTyk^k>Xr;H57K-HUbTV4-j+{}3k0F~17HuO$%c+wlI2UkO8A zuD94|>B?)#*W4UzZVc9+>(Jj8_CO1BstQxi3QJ^)z17qo6LXp{1D5|Bi;N0jVF68= zN-6v7qL`e4Qn56oza~g!%1NcKFG^CXTy<5f6NfEI&8Nx~^vaz3P$4dfl=?)yeJRSd zt=e|)ztRM|$|7iIiNn^b_~2V6-xyFC^;|dH0Ren2JX?U7$~#wyX z-3rGTV42nFHn%%1j1EczW)Evy+hB8C+r5%g{*i2}N0^zT$?{)yRj3AH*=#e@`r@4~ z(c4bLQY|t{Sia>@Ves%CSCY0@s}`?zy&MGI^^#PSCbTl4wKQkjsLKiAQEOY|O70h1 z>q|vpGCV4D`nAe{nM8u1Y7TE}4sUA?Z(Etr+Ju?|edXI3!^ta`b>%d0{Y zP?ZL(GGGSr)sD-dm^(lf~R%)a>TVUl3Fg1dbj4r=eu|P z`<>Q5yL@dHecmN+tQYi7E}-4c_^w>X`*)EIig83h8S8DY*W2C@2w$<^;s#zlPM;|- zMU((?2`D6>lz=Lo%xvPx2_SZ&rB}Zs@(ZvnJ#PVkBxx&2!Z&xwPfB(+1dz)3Qf1`YB|nt2l1m-Y2LF>NeCnc=p zHQ?#OVCufuYd+%5aMnfno*A&*fE5~+y&Ev2KP(+j4}1V>^;jCP>LnAf(EK_+f9Lhv zB^~+JqAolZA)ti`HK@)j16CWbo!R4Fw=4{97siYhCbtWV@^Du;r7r<2H(-T^W$*Mj z5dcPKUK|9>_&{cTpfq5W0jo7Em&D};A-O?FZV-YSBhpzV0ks6|Bw(-mEhLe+or(aw zL!3^oHz}tCu$em7254?VYZJP+pxbwzd<>wi%F+XP`2e23 z4UF8fdL>vT9p8pBsN55`jrJ~uz;AQu2iOxH%alMfJ$H{E-e5o zw;C2Hg?#6eNJ`czQ8__ACRzL9%!GGq2(TEgL?wcs-eZ*sD6$L&YfHa2mYI zV?FFdc8G)JOsY15Hy8*awmS$#y z^yUt`l4JB%kAFw8I#fzxy|OyseER%irF4~*>@ytQ(mws#5zO>{y#yArze-VE5fRuh zzEwM`%RLy?Zb^IYc-RbW@G9qJ_HcTqW4N8hON*B=kC-?1 zr~A!ov)`J_$Fv-B{AG9^v0rEv@?HO{oo|YY6zCZlHLF^k4jSnj>B=?YqC;yX&>#1=At@?S%A_ zxryClmH*{U0fXy-Lz}<*f7_uMPGG(j&s}GOFR3%#&dud|3luqc$aLg~R0}Fh6J<&t z=H|D)8)DiX}8e{GupN>}3W_Kd$ld>-*q@$)cVTpx>VbmPH$$ODQ)qJ6kc-IA>R&rUyn_5Qka za~&bM+RN&F{_DLxyH7R&v>{+1laOzwX-tEcI3{%FFzy2F$V8C11qq4oan6Hg^OyneHvjW-J5=sgT!Tr-34riI zFjL429Pdew!!))5_!Kkf!8c`mKHOgu7A&BvLy{v`+9%6A3|f-&l{Aw2gPQ=ud^kk=M~gSG{;9{(69ZmLri2dh4in+^ zV0dqEU#2HZT9u^;RS=R8a$ENiZ?X`V=f6TpF^3(5cua#OG;VQ6*YqEoJA`2o@#y@e zB+rEnOC|nri@d#r3ndSKQGJ2C_3a%ALW(mUJvR-T=){nEDkyIo>Y4Z-zrp=IW4dn6 zHl97Z(Iwj`9n+oO)G9~#bRSgw&B6O{OM@-E1#XCjn>4)ho{ZN=HyvZak;Lq7x}?^@ zEN!F`hNJ=|EdaDN--79JuBgcXM1WJ}-(!`7KewS;iGMRo3*oY&*wTVO()!?E`)d2A zcF7##F^oU;X}^kqqkoF z*^Q?J2~7#~>EzO|isQ-OdZMC^`e7r7I`-XnGg-cfGY>=C+zn|PV@(kz9^>QE3~?7p z49xgU_9Y48)W9wDxX0iV;~cyr-bXYM%eB|pM8})2)J~+`M`-f)SYGC`dU*29bh5>? zN3vkJH@@^RzlEYc%oY8B>#kwX@?TH-8@yR2rqXRb!k$0v6h5Z*@HHTN%-y0WIJ&`DniCY) z=miyHb9}pvSLt>Hc)W}(lpNf6#gZUg{X#`8C&_NL3QrAU^HDLRg$UrYqh0N87EJ%N zwxSCvT@V!ChdZKkQXFiZO(6h4cF9yYAi9)RVbZz09u#$XCak4|GVeh%@y&=AwjdH^ zRQ*m*=iA)QPa{V4aX%=d;Xug}`Prn-!~OFUucyYP=(X-9hIWOiaKA_~GD_Ip>58vT zd3sKvwj|Uhd!xn6=;jo5lcB;4;XBmT3fH$+lx&VkWvFQ%dU$brdE&`_9epu@WAapE;>B$;$cG zIPXR5D*+4@Xg~=Yd$WVv{?E-F>l38-D!?BdmI+~H<(#%_CiGjQgL-HWoe>E=EM>fy zEsQG|XKh0Qd}$-@e;Dj?bf7$#RMd#hss8TNq*^+Rg&wDG+aJ6JBkCb~ydL#-#t>iH zcph5ZYA0vQ$>s4lzXg|!n#KbZu_%87rqgt_uO1j zXo3XreHml1n?^SiV`mi`1XKuJMw=hO52eE#=GI|Z6xG)aEeGkS?Qd-~D9ma)+%$1- z^e@hqIgfov=(O`ljtXL;(o8DOBW@Uaqe{(VNEN)K!536eqv|)IGpaSuSVktDiEvZ6 zG+Zj9qzY)O`%1=#3%!o!|s3;@hB_0>$QD|)1t$9JdCb) z!_7OK!DC$*CIgjD`%Q;H5dSo&lAEQWoULv6HXlH3s7pyCppO*_se$e>hVk$1bV$Gd znw=1}?O@m)kA+$$-E9pWbr3N=eZ%FJZmf~|eZDDXxo`2jgG6r*vEsDU zan8jFT0_p7L)}J`9}8kt2vO5eVu_nh4g>LJNwNpT-&*nYLqA%DIS>64!(Yj4B)u{= z$&KV5!wA+OS|eEu6^>X#gf2KR!cx07!J!j&*I|O85>qp}onV?~sxL>kMxximytnZ< zPQJ8c)au~SzMaSWtX&MZXMq-(byVr?ZtG$L_nEoyncwhVk}xY(L!=;mALDpKfudM_Ao@aCLX;?kKB#wyg{h72yEQSYouh{~ zItqH2#+Y3q?9DXJ^V>>+KaYeUAU7e?_*7X7hqMTY?jin6#8VGB#TpObAfYSEjMDuw z-MImQtfoO2hx_zsNo-D-&b!+H2enAm&#~#|r?Cf*Ne2vsHN1(md0^-u@aNd{rt5Qr zOQwflHlCr2ftfU1^v7oM<>IL=IkM*fk`gp!U@COog&Anxfy?{K*KXIv+PMnrTS~hf^VGB98u!$4p~0+)+4& z%|aZjTj9(1RIK6%sxT#fhG@%iYQD$FGEUXhp2NN&=zz)XiNax%TT|_X1Doz4&xz0f z{iE*&8uX8c?p(XOhGWRIq6*Q&Z}J#q!5mRH%btc0FW*!<+y|^*nuLAtYOQMtuD;OgS_&GLfem>jG31PJ zl5N4c>+$sbH;H+~Jw-euAco`ffni+zv-=POL|hwtd^)E}Z#j5GIIu~6|AH{iJrM4f zj=5>a7BO;9Y?nttAgObjtof_YQCjO_grgl_-c(PSxsOZ8Z}3>27VTiqlt6k+(v$jo zYbs(v1_{%cPq+S}^`ssVV&Q^A!p9sJ-Le}{T^O?T8A8m`eOR!kaXz=^G%k^}m3{uw zz?qt$p_2t9+Y{fYy2@Hi|;vq(Zhk3w9dGq$R&S;J!j=J6fX!6VJ%`_@UVL zA?A9CFTvWoALHng-@0IL&9fgTOJl`))W$4*!_yzr34E8;mEtzzVFN39`bY}HRv(YE zTV`s*Bi@!E;7H zpkCs~R$l{ZApyurLh^{|cD69V8Rp%0Ls%Rk8qppd_DCK$OCC;pcKfK$p#RC3`h)TO zTHA+`*tF#_*1qS;zjq$+QC?W4sW?7RvC9X$eT=@si9T$ ze@$#;3)wrj{g*I^F||!^L0#jtr%fcLG*22~b?_j}(MKd2{K+3!&2}269f8 zoa*#kUpO-7zrURC(`=keweWO7*ea6Ma_Lh}a~wMfe6<(n{fH}P&Jd4N=kKVH-A#L$ zS47yEaaL5G5PUsP%}vL>+o@xLHjraKEUGtdJ~qZbcJIUSeF{Xjj{WI{*4rFiiyUc3m&Y;?@an``N^gL#P| zG*mINPbb;oh7C!5_-vMJZY9!sIe#`W_bfSepUq>C$|jBUF{IRj1bv1p2;{yDLZish zDUI)1(e>$|;oZp4v9rbZuww1>xzDAxHeR8lUw4ybggl*df5c-1wqzDKzF(F&vFrp^ zPN6@$}TJ$G5QgsLk`L&Cc4?5V}F>&Pi5hjl;praW^p_Q&Z;fc@<39^?BS6t z#M+tCh>X!m^;3TjOeD;Z{r}QLyBP~O@=^TY_JmX*wG^E{jZqXTh)HjKj={?$D>Il` z?%I9gtD)&xq^QR~{RM!3T53pg!hXI%wouy}T>5FGIetRwdKx1qU~0N>lq(U#Av|ot zcNNP{b9sY@B*c)lPdB0=_cNxDr9)mTJtAD=BQ`&s5~8{btCggo@obIAbfSMTcZ%n8 z(+r2Wu#tNE`b@yeLx=^Q0+S)&=D>d#frd}#l10|0HSI_j;pRj5mhjvG9XF^X?KedXKfysbTPFw}6A{Ey`E+wP9O+8q3;3Q6 zafqXFiqFJE-|W=Hm&cL^ae|Yf(>a{%M(qXg?)FC~k55j^Epi!+J*BIh)FhvpIpjF= zS3TR#%3Ft*Oy{;01h(UJ@*^R}#zWIpldytL{#6Fq7=S#l74pH;RkQ@|KGJ#R;sC09 z45zODh&u+p@*OG!xze0F;MIsNow}(puCD~g(4$vW&#A}6+a$4YQg9j{f*WvrV1`WK z(lvx3ui>sGaX%Ma@UXaHUQi=MFN~=4f(&nqh2;&va4DO627o!5J7B;h=TLsaEmnQ~ z0v?u)L6!Jzr!CECN)bIix~U1F90|W%yT<;-AiZ~hr2~Ydw8;UZYvNyNBU?5HdkVps z-0nW!E|O9hviRaPY(_%c4#^$hK8{EB&0s_=Juakqa@GUNr&UYe^yEqA*P25jZZJKe zJ+VE}=MKkjOGhaQ5X)`^U!6-2G>tRPm`0);a_=A_w&D9sXF-!k=kI7mFV92ci=Sk{ zu3X!WN4D{7$#ro$F{d}4B1Gq#>^c`%boi2?I8FCcBYn-&z3ani2%DLEk0ar7n;sEM zsR@vbeHq6=LA%f9rw~?13HK`{thMv9nZX#WkX6X&#tDfGb+9fNu3(*sjOf@(G-KhH z9}Pb?a`0XjAOWR<+JZ+~gSL@UP9pXx5E1VBCijRt%jTU5|KhZYt2ppVCh8fghD-)U zaA^FrZxh7Dult%hHNW(|Yc`pH7}fqTB|Row7Cm=Jg-;Kg2Eq2wr-XgC&pADp znEb1A1C=>cn)tc7g@w-5)tSUyCB|{ex{Ppc9`PXP8NT|soySRLP@fy;;NZ>65Z87v z^EB#A6Pv~K{n6`T{t;P4!7bLUV7}5B(o==gc5Wf!B4HS-1g#0a0Sr<-&6Zo8$<`eGxoQDkTg+(IOFd0}xU7 zbLn@O6fp^I=^4_3LmVe9LkE4nr5x=P%so5MtbKh4{-*Uas1l;*8^V>f;N#sa1 z>C|-jpXOCDL9`Y9IL+pr9*4k19%ggA+i7JEar+|%wz#EsW7LtN^BCeNM8A)ZmdRc8 zJkIwIGf;7cY`m0RL&Rc1r1KWKobLUlCHg-|7(9X7$G)hj-iNQ&EIZ&ZL0hnI(_?L( z+=DpbHn1PZfGRqVD-p)?^Vr5Xg(A!$`|)v95%qLM&W|rf>}8E=*GT4mW=AyC=Ulm$WWu%O_dQzqdY8 zZ-^r%^ZhD*qm1wG-Q^8DxvNM!_ zHfgYNVg3MWM4^5kacdOY4Ma(%M#ue$;0$ z3;ZS%D-ikyS!|O`#{24y@t8hLp)*;bumtbE1G^(M@>jTp!$C6|!6ds2R6F8d-R$~x zJLFq4E7R+cA={76LwCC^1u#$g2R-H+a^w4jWr|jc&M==B+D4ljH^N`z_1gB_Bz)cb zYe=im;`im;3{{?|Z{Ds>ha1_dy^}ZdSU1#+B1d!U`DBRs*|JRQz99#UNqQX^H}PaX zA*ACVmWf$c1aB#7f@&s9l<0w9--Hq z>n8*SxX7SgASQweXZta>_fPf-D2W)4AE>_QNt$CCV%zEn}l%OAN(y3#X03&M0YBgjK2E_SE0gA z#*V(lEldiE5?2}T&{&@E@{z+=%m9v|EreQCpZe}J4xPPW>pOG>h_}>(8o{uLP*mOG zM56#iD`!-Ere&VOc~*}PIjzPdd3_nG!#aASk?!C6?shn*;{_@&-L3N{GJI7#tKPSy zpd$_C1mx5OJ!bv((m+W0Y1`w_CKYRya=$dtCE}ePhdME1&rQ%$E-zFkqpNc~+_6dr z3Cs1NWK`TM$3q*w?Oqz{1eXOI866kF2q84e7UJ3=GM#rwRJ<`p#6O0|NL0bKKE0({ zdVH%5$ob)P9oH>mxXn++cZ756Q9&Q=ly0>M?S)8Ha~%=)ljC)>GT}=TQXNb!v891s z=O*B~m9i-wA5u(?Ym`F@#oCfx8k`d7CR&EpExgC9`Iaz+lJ2J8hpLkd)69HNu1ZY+C50&78nxA z5G=jS?wL-4JF(B%kv}zx8xeOV6?psVpZQGAosbZPp649WH$I!xc1t4SJnC^8R974@ zG!u9OMy_0o74Z3qrZVl4krEOy$Xu7dOcKX2Z0Km19V{>`5MyDneK{mS-7JW>v^R-- z!KsqZpaYNV z+BWaZu3`VQu@ICgiZ}!q-U+>CoPb3q0dm@WBiKsE10urpON*fPGDWqdC~+N%s}tEF zYG=}%yaH)zAWq{i#v{ui9YUh3c*43bnYj_Tp9HZ5XIHB*Fw0+E>(!6Smnh5;_v!W z1A$H~HO1oaBrAK+&9yiSR>z~3k=atwLvlFD)h+sloa1Y%Vh_bf4mrNku$`Xl^DdUf zVh@E3gz9uXs6nNnv015Cy}>esAq^h?%*iP6-sI>Z?Aq>rovOk0DNP9_*gs*idK{Hqkq>i}huL4m%Cp;yK|4H^bmdq$Lhb$%ZT3i-KXN8<2o~tL z)#+bAD-od*vxui=l7h9h^?NL0<6n@$_03n9i=KjhPb z^rXs(sR8;+gZ?1so}Vcik%LRWG>f5_>SQIJhCkb(60tamPIYQB7Ciwf) zSVfxBbU}w}O?4w9C${YaGm9DqgRuH+L!KDGx=au8s0DDLg&-e^w&>n5UoK(R40 zmyL$~=@CUZbiiYIHimm8JK7Cj{}MVJ3emx*xy7i}=+wZ%WWDt(UL?BYu_x61gOG5o zIq(bgfpfM%)ZVeUu6i>NKULmi{;pOJuMk_$RV3WxQGoC3zG15%TDX;|Na7oG7?xBduuELFS2ztsbR zok2-vN-Y5%DWJ<0gyK5nxXJZJr)A;W3~X_xJbiXRJ%7as^D%;+-M=*SL^l!dJdY#g z#qj(plp?TNHEUqQ;8zLIqR#D2CDqTBq>gZkpD09%1D@p$m2sB0{*fiSP}c?11QDT{ znjHi)Q#N@UN7C4a2v$X;c2fojJ;8TKoKBt*6U1wnhr4N2Pq&7@jd5*?*GcgNcvat1 z5{-eeMg9m&c(R7-M>mP>5m|u2c9kmdJx22wBmWV(wz1^LxvCM33qx%^DBn$Z9Y5!$ zC^3jZuvKKOPd`HsurCa=>%q!2q_TCfJ$}F;8GFDAG?nt9FF)h-p(0Kwi@rq0yc`08 zm>L~Htu1cByl&pMXV=ZpoOdRq1^_?#Kil4lX>bZ2#7s3yfD-8j8c62>;*wCmmBFRL z9yTV|%ENfV6&PUsV*k!z$C+$I$Ihf>(4X#8-3agt7y-QQV`ivZkEFUXk2-K&L?FBQ z0oXo3NzwK7ilg*==AsqnlI?NY-6e9{`LjnO&wmbb!v9gL@@??)SLMyd+GCaON@7^( zP*NlDBogqx&R(LsOdfg$Dwf6V91jc*dpi*6=9sZ_0{N+a9Tk{X+h+6Y2RN@%l)kb) zrY&SUA^Cvfgn}fD;VO%+_&4Vn4kt2%IPf=9W;m>c*SE>E$MyFNIQX*8CG2r){i&xm zjZSY%TE(`8{yk9IY(ILZUKfDO(0v2nlCS38q(WMtcOa!ea8fepYvUq#t|A06C9(YUR{th{A2K;by z{s#K1Xn*4N;<@?J8;^ch&R7Tj)hE~aAzJ&c`HiN^j=W*Mw)1<~c{3Ej(EUj)ZHKNamW9!4%2nkbHr;r=2jy_7m>i%MZt4SCvDwD_`8r{7uV}KH5T( zOli`H5jzRjMcN75byJ)<&OU52h@PaC2^E4&k#pE4{SI`DA?~3Tkaj9opKDvtlcP)Y zj~HMVTr3i<^p1XRWfB%i&7Fr2S&m}?mim=61(V2ZXJdw;tzM{@*hd&vc+FWtuuM8# z6bMNQudC>FdH+vBGE_ip(}Y~Y&~Vn76bEPdi)D8ZqTW41J^pat>l~~xPh^$2b{m!i zI#AaoO7U8QR##X{{_byibO2!y!_MUPg#J90LVh?N<9G1F-7y4Rc}o*@oNU}0_PUJI zmFHaoiWo>bG|I1$gg=4ry||m(#EMf(%q|6$7zxPj`K%LN_Tto^rZp&bAEWi@)h@+gg@xs#r$sYaNofjV+bA{P%}sb*7Ku55a}BU5Kx^ zV;s6W|-R}67?X>77y?PJ@&UlK;T0Tu)u723V-xTT;C^X)L zdT3wQx}12)0nH$_p(uWd38&v+IEw|k?|OImxjPFtQ_FY=MbCmcAA{erlgN};Pzq8? zV@S3L#c+Mcy7DnB14TXK6cm@@owU3{Or3fY@rX!U7F!4XPwop{qZI>)EWNTbEX#rh zo{pCHBp3^!%3F9QWB~t=>}_>sWR6^AFYjM*l_&?0vU?BIbqJ@eta?@vW4&UsqQo#r zvEji(j4cO;$a?DXC}Vd@qN3Y{mSuJ|v8AujB}a=SCWknw_!+ zr$8Zan0dNhB)ZjFk&t~-K+$aXI7n#r>IWlEz(qXQ@Yx1%gxh%WM1gIQK{#+h-QFON zU!^I(%gVf|aMz!U)`6HMK27v!B#pnV3|#U@J&0$&CL}WU@ingoc1Oq-HOdjLL+$pu zGH&lBy$&JQmfgnIrzAR&3B+#s^E6)ilXEI+>l$aGIel|)7v7|-!S#Sgf@WryA{`VI zau@jj{@lN<_5J<1JBc;6jipBDkLp%lV&jmnlU3cwahH`_KGh@x$}w#A{Fxx`lq8Jm z2&Oi7=9^Z0xL+@J(-+;To&9I)s{mH{DmZVj;}-dT!;Nq!V!CT^tvBE=TAGxE(09N5P22CZgc}i~SZ^chrnR-<@5A$n z50??`Bw`|s#ME$&**Wque$KCq4_jXK15zY0;n)IOXw!R60|ED9F`R_gPKfx&5EH;T z_3`9jJv$0RVz$JMb&1>lcgR%v_K1s>{Kk zBn*M*REYXj=5(kHPy?}tJI7}k?4%Dr`VLfX=8&w+z*{*}NVap*2NW5Js&My{hxI|> zlQCMqx-R%znCSt+x_9oT1hYypqo1cAMePEDEpKhli~suZN8|zRxw;P7WrpoL3W8Jd z>y$%ngKgrRj|lZk$+SQArHtgCH%7K4mQ@4_fJZn7uoxWu?`-;;8@$sP$^qE%JQMu` z)%aJ<4-?|UM{@LuLB)v$ZQqVWR1QS@eBq5$jB!T({6&qaAnPVI)?!l7ry{=072Ok5 z&QdLDi_gH=!cXa2mD7_Frzhg>6Oi|)WX9nwDg%Oy#@_KoVL02g3RpZv(uJwPI%6x3 z>j63Up&+AhMyF;Z4t5SrZqgA)&|N);Xu5KlFuG}HTG7#Gr%GzfWlebuLnf_IZbgEI z%<7Yex~PIL_EFpvXfp~gv10xfvE?aihP_ewGvRGZ?zFMGVkhupX>QEIwWSX4pcGQ8 zNWTZGryEq!nqdLHRX9_RWzAG9L$#)1UR|<4vFWI7hP{2@rA+~w44sJ|Vbb)EQ+ym@%Zte&<39tWgM@Jk8t)4;g2CP_(?dsF zrPkyAOi6RRl`j&-3OhL_X#j+lhhgNl^=3m1oVJ*E#rs)+p`dlcD@5GTE@F+x537O5 z+wpZYU&y3FehE})Y7@7V7%iP!8f%LPtqYXoFs42)Xuzi-f=5tshemF`;NxG12T+mu z+=}3=q|C2_dHW^$+SwYLn0D-N)3_*q=cQc%O?=GG0acgPVp`l44UCz$ z`>f?)0`4iZ3e)g&-b{N&DW4GBh`mT~xbl_Dci5vt5eTUt9($P8ViAzibzAlAth(&2 zcUGC}LMeSrDIn86YB;QNUCY~;)HnB!fa6Qto~NCP$YOL!b9&3cB?0!l!$R&WNDeX9UM>-rM=DxDM+A`)3k#z>1mN1P2dhsh;-XU_DOA+9??*Zx zZc?L}H{x=hp#+M{5ajPF+>hfhyRp~EmXzSe&L$&=5(|B6<^ISI-qgf0Nhx8Tc4lqu7Vk=M-`Cw~RS!+K>s)U#-L4SO)O2w~ zJDpRIwjt@m?RL&sob|T4I~e#vDIr0E19zcmk-aW$T{?hAcHdy;vIPof#teLj{Px16 z+3uL$Mcr)bfz$_T5fgQ_n|bW4mz7K_8Z|N7-r{_XeAwV2g8OCf(di4!gXv;0bhoHpkitNyzh?vav+I#`@=Qo`ztfq zW4rUw80`yyI7O~iWyvNesz@mLmynO~rxn2>>`E#<;|^Tk6J(d(?yH z4s(AlAl!PTRo(f*k99jg><}+@s&1eq)WM{Gk`t-}7$$iF*RC=@A+B0v`2L(fuQ-9b zAa3SZ!N-}*MJR_sEnKlFi3F`=miG==%BjTZ_Qc&TSOJFkR%^Gh>=`Ue6UpWG#WN!?m6qhvij zT7?Y(j1N;SO;EFYzcyJ#vP)fesq{9M=xge@Qdxib-80+8cFoc@ zD*-zGXXX~W6>>HQS&g9j8_P2z6pjB`&M%us*KOVg4X=SvhvDs;z#)_SFK5>>z#oAL z>Nc7?nxFJreqdu|=U|am+q#T?j04a7GAfgSq-hVo`-h@V#Fv5iC>@YWe&%(3R5R4f z@O_z0dyZ z1`|2Zw@7cp^${A9CIk3dzhtJ3va_5mJ-@1E%WDizvouuqjJ|H;PC@y!e&jgpe%>V+ z;IEE1)*EpTokpdF%{_sp67J1%e`V8Xu^95rH4t5E1B+(Z;dp6#Vx(W%i|R-%SWb+* zq?!CnR(8bVWqK`t23f=Qy0I+o3e%B%=D;*z@UvPh7To~jX&mKNJNLPIOw>07) zNkpwuteh(m?+Nv5FgbIjHEX&%n)1P5*A~YBXyqx?!7!(*h+~WyD$GzUu{!5oF-oWo z294i-&rQne2(#8Sl6)boy~SMIzpV^uw601#WOguO*voLY$3Wl$;YVFbSzc@P5xf~L zUEvzgZ1fS%vaot#Q6CdVo+i8+-o7WIlL-{)#z>Tyn$;bIXeMY?>4cUpgYz?gfX<~Kg zqbO7kXiPBu*Yb}hLA?UhL)F&=i;l3O9%dwY&7ny62`&RvR7=5w=y)qkfC%$-(QYaQ z3|<(tZEb)I+go&(KUgV^2Mvh8e#4*c?0dpGSa?3-^l|KoetQ^37?n)lir;WMM>00e zTpKttdbn;TwH4v2h;83D3m0RQ$IM)zw>}I9gt&m?aqF**!`Oml`&|A_AGAQ#(TBBH zHFJbo`x-Y}+8dgnOybw70(H(!nem?e6C%AiwDiEC0s(8xt3l6n@eW=0Q!#lomw-$& z1O3fYAqhbyamlm+iS%CRZRvGZTOvuXcb|~8xb&#rh!3xn0*(x(`sWg{ZO$U>ff|;P zQ`b9PtLLw4`|aiEqu*a!t=>Mn4kwuW> zcGeEnOQmG?Uou{7sU`62VT!BL2msU`(kizn)D%@pfGHKd;Bnu#y$Ou8iM%4iX4hh~h2va--Ky&is{ztk;KOnoyN|xzMwV4f-EMYWaGt8GdW}ns`di{ADV@ z^h=LD;nT}Vc88r~Vp6VA)~0txj{Nv#@6kb~!WK5kFGR{b;cBdQiFp&Flrw!*UrWUg zbfnZ_9V3<0q!h{QE(3Z-&fGv)237OF(a_Cj&8CEmE8?w8BNjEOR<JvZhg4n1Ws~6-O-L#_`9lgl(?AD2y|sOX zfSN`81^P)w>B@THcz~Cbp2s2l{VNc&7f=R)Kc+3hCQTz2KZnl}ZyQ&hSsz|;Pt3%R z^nTvzvS){B{vi3h44a)rlvqKZc5+?DZ*w6^YavQkcK14N${UAdPdt;-e>1BVw<+vQ zCFG;K3CAgXS1IJ>jb}s#$mv3vZm8}B5*XqjpbU!k&8^+zFG0}8~ z67U2dpDyM4TbcFY1eK#<>vo3kXh2ILDR9zTUx%%li8napedyNh;E z8RfJz!fO$7vYFS$WnF`q`R~Zkze&`!TQ@#Fo9|;b-`lh@I!okd7T?KYzKLDEjeC5} z#&zBXLi0nOWOcn*-mIBphPAk&Yza^_n%hQ z@+x;r9)HWCjoCtzI$VrZ^Qao|=1?^>jCyY_+B!(GdUk(e6gg;mx=rb*)<#BO9ZmQv zwbn*jI^EQ-G^1+TR#l9P6tT{=P!#irPG$&|1A2uB^AokHNeeV~jUSuVP)5-9XF*Jy z6sx}4RiqajLyD*Jkv6G9XjVO?6u@h5=(uVmiS6|2hbG4ej$sVu1`b0bo33#e+wf*N zykGU?S}D68u2wI5tGA8y$L9KDOT&qk(bU?gYW0kH1NpMp>y^DPpc=ka9ap)5z0@&g z&@sl&fBQtXKt=uR%1pegizxf{zOY1D33LvP|%f4_#VkT#845VSl%Z`3q^#4Q-dSq<&fz`M1v zYs0D6R=8LET}cn$*91sT!w(R4+5yrGZ;lHRb_jV$(T+ zs@{7tfoNs=sEG@_)EU>z!X5TB%`^dkvm?g9bu~sX_*x z2lYKI!euNZTjIK?No0cjN`zwFE;IJSAt1K8JC3hw;8ayZ@znl_T!JR3h+kL8PPaF` zd^34rlc;%Cm-G{ue+~3uHJ;jV!OY^jl$nZIbZ|>(#c_msna8P|N15X`8hc%XrQU0g z`XFAe8)8V>nBHDb^O3d$SL>1a05(P zF**$Wt`AR)n19~|^uvwpkB(N+tEP}!FULR$+gjJ-h_ky(J`c)t?XtU%TXvc6hm$-Y z)pQRqah&{y- ztK{ARd=)F^fa1F9rb-ts&VZrN`8UUgiI7YcuV;*B+&uZuBjqRRZ0#dhp#QGh zU~pyFbpid>{UKcgJIn0UR!&R9QHj{}1gg48169Y+3CY-Kz^=$Yj1MVoSnoZSD~NcC zBKrvxW5e?-^J_SZ!>#eHS$ z09>Rfo?K&EThfR@p6)u9>R;A?r9#Z#%}FI7NdK(fEnfg=fHZTya=8CG6bO|_=F~D$ zslBR3@wi=6=rUCswp})9^nUhJ=(FYM>I@(D1_JA9iCU}cdy6e{Jw^uj5Vvt=X=Tr_ znJvK8$!h9Sk)O3(7P)iu0<85MRbbp-EoA z(pEJotJHo`>P;5womq8P(3+}wVzm$h5i@1bOvazn6j+vNhp_5xFstDpCwYTco8oc= zY-{Lzy-aACZfve<-8xprm(Y08wL>etPkyJ!9q6v)zDJlN z$)b1Qng2FZ#!(3{?xhDg@?FdF)Tc|yjuQJevWz1_dj#W~^JuJj^O4Y=g^&QLV9iJ$ zH4zJ?d$+DT$}?fSpQOaA)SQJ35cchYRWB3PM4Pf%ke^es?VP-dInnv~5>tmTP*l;X z4qFa&Rqevxs*b{`)^Vz%*A+J}dJoBRaIbn+X_;hpx!C$eemQ&8oFTF}H?-puIzH5L z>bz@-isQ@mqi5QTcvXCrSG%$m{F3iBKgNf8oVZa1X?!_wSIA|rMYGw`Y@SHAH)LkC zNx}vlL}IfrX&9@*+muGGNA=fcCZ3ClP3fBnC3=R;8r60p+pBSEVZ@M8>W;4@L4E#5 z+18yy5~MS&_1=>fBnR#QLqo~ut|84&_0Ivj1{ahzry@f`y@kq87N`KbNEgD8i3B1| zLi&rZn+ew6f&NQ08-1^qaV3gP<>0sy2BGQQ!8GYWEga&Ey`8aFZPD|aby&kdc=fWu z8E~QQ5K^J$oX*RGW)LD}pj%0(b`?E~F7Xq%xN|!q!kO3;!W3`V^~VGbx5~Cs&!>$2 zVu%v^!(Sf8rM~84VKFse@zh?U7Ht_go36ctRC7|QV{KTKy#dflZwoMRE&>zIA+Q;L z0`jkm(RKCo^pWpARIp*}Z5{h$A9H5R8VM-mf^DT>i~ydT@4?oTuc} zwRQ9!a;nDq_BVgs|Pumf2qHIv7y#*ZljngeFL z(+EM)`<)t}Ysm+^^u=DWLMR1d1*ch!2#$z(UNwjr{@>>#U!wMQj|`Vr=z7@>R&MO? z+-{c5MD131U|-@C!w*UsH>=Fsj7Zv?%Lc%<>vFYH_opygQJhklIE2kOcirt{FAGfq zF4?f_W(DB$V?P@UMvOoN1wsT>3jMx)vXf+zOifi1iIRUmgo1=$sQhSLtVND6Xf*Vj z5EE2USkRLJf=CrDSyS01qjrEpb_QEdPM!%ChMKCJBtKZ^lUx%-pPbipGKfV}Xx0dW zxBYkk9ajFspcr}l^sZzmCFI*54K^^h47(D-ZxC^Svt6*r`!CC+_rC&hbfcuWSJs1e zl2Yx2JC0eh+~d)ytLEcZI|4$1{^kWQY)@DI)YA>sI8_^_vrZ=6CcU7xoKS@B{47Rt z2W)P~0KyDh)xKH&en;_fg?6I+)jnpH96->SpPfM<5A1hyy5R3*b`&&IbriKp|A&NR ze=wvvop#W3AS@_pN=hT~BIPn-eIWsQDL-mBsaRjcAMNwA#(Ky*qTh|`Yw1KfKq!yH(xU!;@^?ZH$p zK!+xjE>*!NL{4?VXsldiU~0|6Ex+}LKGnDU=7x&nlS`nrIyAX z1(!&Cm+%*asWX8dWpJ9LW-o@pDZ9^+Y-Hb|L>OEoN7I;I`TdmN9z_>K!4=HJ43>t-&J6FR;lqb`wcRVKVSInD42#~V!NkaxL zB|SwcTdXfc;Z;s8?VcINL`~Uod*pnEW&$fG7A@yfD{~JdX8MHq7TiYHyP4U%ekau z_b(}#LZvd+CkFt~Fz~UlmTZTcahYyLoB8o&j|2$G0+{}_kGLSk7i&^Fd&iu)Q#;$k zV`y^sLa1dVyg*%1x5rUKU^(%{b^wpr_Y0=XA8_O&Iz-+ZSd6kMEoxTAsO5`!`~v_B zdnMW5Ts$KO(39gKqi&8A8&c5oCDwqhq%;Z(pY*U7kN>Ak zGu|&#ZM`P^*O^GZ#e|&SK^?cJ+HNkP_+YYaUnx$q#Xl(S7%r3e_Z}18x{NxjOhTtF zW@Qef(wFc4V5IG7@yUXYAXML<4tq&PA(~1g(~zWC@rHt-z#jUt(&``nD4_O!&Z-J_ zqD;32(NyS1^jFHW{w~E$I3Q5B=Fwj%qPaLnewq5y$@9u9}w z?=>3BHzm9b{;HeW&@;KAdt&{?B@>ff;xoI*Z)TCF<__lD2iqD3zBLqR)1t3Mr(R35 z@jXrDUyn@{m zZhC(Kot~bu;o`%nU~X_j$O;oza4wx|x}D!lJMZNM>shq5&M_lR0bD0k9_<_m_Q3+$ z)IXZTj62H-e~K%12{QivD*A-p`S-7T=}-vvm+qhI&6f+T+O2gym z*Gk$!#Y(SRKPg$pLgKW!RI12?TrrH{@o4oB%l^|ONHka)gyA;mK={Wd3PJY6ekAx6 zTtC<}BGEyXieq@-NA9^S5tw%4esW@1mNovFpukyI>S0f);eC%RXwPv))`Ri%JM_#u zvGF&OGr1nfsaN9DQ}z@7G^(((ewM1o=XUimFYK}Ro>Oi+_}*J!nc+ZE)&5x%<$;EP z*!~4{9C*f^bZ#Tr-bG^GC-YV+{K*2IK*o{q6GAiXqJ{dX8lO!Gg0YYtjEp;uzZq8S z7Ue6_bHWJ>H>2FJM6ZQY#qgC6Na91(Ab@3BDm(P0n4e4mWIl_rgU#A zD`PFIupJzsy|TTrg)G&z8x?#s$&8fRQlx)lc%UK7Kc_@Cbnlg7vemPCq1^32ts|wv ziC_g(F7SYT<4yqSL5#@gL7?w=;{np5e0UNmagyA+IFHsVG6l3A?U`$jv=~Ug%S_oB%@`b>R=*UiO}{WFsO$ zHLvP8A6ZwC#*Ga1Q!0!Y1gq|CtaKDE6oaik*#8?Mm8m`wF4dNosR}-g{0Oq&cymRK z3`nzq4-#6W1Z|e;LJsGKQ4FW4X#S1(`*bOKY&t{tVJVv#blepp{^4iJ{BNUm&;#?P zjL+dM1E|lc?OWDf8(}GF>9~We5Hxs2J*&nNaEkH3=%c=r5H6(+C`>#aw7jra-ux~_ z^P7~k5H|QpJ1jWJITQdefX!Ytb~o{W(7Dw-cWALkWfJ=hC8fPhC-XjY^jb zm8bOO_gk3LY6>@U@MTOW8d#xYJ8yK9M_JTqI+lx4mpt!tPzi{b>1U0@%_Hb<5dplj z1eVzz@LR+&t@L`qF12z0>8>;aa)B19$smU{S%OMuVkM`%ct;7oW>f)4rL+GWg}s-gZrLfH?3fV&&qSt=^$U)q@~fNjfw zs-p_xpU(WEMQ7-j>P>EFdZ$siacmYrGoFV+xNW3yl147Y?B5o;l^{M*PL3Gbm)?}+ z4~Zhx<}njYv>UAAq6pB^KbGi~c}(oimuPdgqA$l`pe#n6tBT4GN*@j7O@vp zj+4Y^z{i0i^d86WSpJZm_>i7h6u|!(F|fP7qC@##bQD6g5Us9oZtfdokWoBF;+u~s zt-KqSoPBb#6L%tbMb3Yca}!PCZy%dE2JWbeijsQ~qEHfaG2M1&j&0Q#1jeSXi(Ti^ z$bfr7Kv9wuCaSpO8<_?8807}SYR5^L-U&$}8ZCB8Y268#&!C5r7S{wvvl82o{kytQ zs*(3~i^xg^QCTp`L6cG-F}FuAXN&y_e%Dp;afXSXNO$~gAm5p3m+Pv+b>XIKHtg7W@wMP zI2C*r8+>Is=)!ieL3|()Z~seD8iRw+)-n0FY@0jD$!FH%kDL>M>^eISbBZZmM`N&e z7K)@e5m!Jw|FJgVV>qZI={VP)IS-KI;)g)1?)@~qfRYG5qoT#J)OPAzCzx0#7+Wd~ zyt6bs7irtgNgZf)(=IG+vlZ;pE$}3P zTB5++5y+|%9|jYtD1=R24gx-@FoPaEIw0lu0|gzH69OX~6m^but}U))F32qctBizW zf%i|D4V1D9_T}ZPEpdDK%|Uj=}2Z&x-bgGyHc$= zOBrCZB-5Je%lcp|>k-(!(I~i1X1G2E{`kKBS#%STd_=)rUd*@BZ~W;h%ms3BVqZ4nL% z3J~hd_eo`q?U_t8Y7b}4E(S|PhO*!z29q58kuvbxmrp1$=3WdQlMoh=ky=w;d`Yf{ z@_H(sWFDl@7$(Cg0v(s+U|x)=9*oa!PD5p%kecDD;2HyEopXAR{}I_4j*&*ZU>uTd6PhWyd)h){_?q=cnOT?++!$ zA#2;;H%DG}r9O?AJrQM)2XL|3nW*=_JvOsNbGCn(5y}plMt-j8L5$pAcqUbR-Z$#f zK9%VBX(TA_f( zF>iC~{TLO?vb=j$-WU`&jYI5B5wO4(}PgmZw*-L=MaH)CeR$(;SoteV;IA z98niTy^Z)C!utq6m4oJQV;Em0{7`)RYK0or$4-}f9iwD1^{;pJ)cbT)`jNX20{TYk_R(2mX|$Fc0U6rNW~*F)fS8mN}fnwo1fV zwAV~wzhJwP6M>o$gA=~Imid`~B^i*szre&?N>yLr)I8^TnH$WQ@o;~*IgwxE+C7jfvWU1aB&%zw9 z<|R+}s!R4>1GNxBSBh9&ep>eh#S0V-NJfY|1tyl2$Z{Jv?b=$bA{sRVHTjMka<^g- zCV)$q6;`s?U7*KE2eyE@sV^4#vgRgZ==-s-=oJc}KGlA~^`lUfFdbFbAVn|StIOoP zD2azJEcIQBHY9swY`c3wZC00;R56ik;*7JTav)~I zao3xotbicm<}BeO^OCmmu@aH6%Ns@(7=KF`&_}C}aZOyHvs&uq)r+ehJGh&*y2Q$@ zG92H>6A#)o!@N}RZRFdIfB!RWg@rby2mH4W{-hkzpkDqGikfnLnd9e)q}Ca#n{#PS zZ0I|ih%J;im9~GGV*^V_XTzvimjNATq5xakT_D=dKdg#Pia4HLr!P{J-F4=Edn(YN zv)D=skYTeo(wSNYoEx|zEw0uG@_ZQddjE9AZ@^AP#e{l@F)+aei zuS@HJlNl&zJc;VJ4z}N=ouYbzs2A?uI$CgG?z0(k94uX;kXAw*QK_U)qZlw2$c;(* zg)~Oj%^M9E<>G$4d%%}|8j+O2?jC8{WY;xSqO_YGnwYn98E2VXefK(D4feQqy-ci2id)fZN%Ocp4C+0|0 z;qlMJTUQE>+!hEZc~3XM$sGzgDesuSyXlLa`jQUFb8{Xn)=;PE!6ZCI)PX(dM!?Tl`9b917z5jl8#EPCU=}l;E1n%=x8d9i zI$Zo?2!&c1Pb+1u^IY<=ZHQnzR3y}_?q~}XsIXOr-R%Y7x>7%>J`$VjdZ2$+cLie0 zdWVX#+C)Jk#<_R9ISb?Fz9UdK_UVdOI-=N7?WANrG$*3Vv+0z^P@<`Ob><4<>e2@_ z+qdOhRb%l4*LW+}Yh0(^Wl5l-+-dHGI^gBjzG=qIKXn?w>ZN(n z!4tH{Fw(AhY9-F)z{uNcx$h1Y^^mX-P=9=#g%yULuv$Z41gmMm&Ls?OWraaO$WurM zcHVLJakd!#VUPh8RUXeZKS>0U54E9DWz%?h36bm*NKg5>Qp&`~B_vUL=Og5A*mEb8 z<#9bw>;o~ov%4I7EQel1xA38wI2AH2xDGbDbBQf0C+Z;q&e0Nr+)}48_3Ppz_h3`onM?xnDF=}J2 z+%=&I(ojowlO!w;kLEq12(P(Zjq`S$cUxk-JUyEOw$8GjIo+JXk zpzYhD7fX^J!w>Xz`VAD;5C8S`1Vyrb6IpHv+?DAFbS4$~9;50u5G;cj9<^gF_@qKC z)EBId@8h2wjqP15t&Fi4UeIE^G2Z0C)|7L|#GD~m3}DXTujFQBohM+e?QYCG#RwFjX8(F#S*q{q2PavSkMQ^wVCa;~*j_b^=y;^mSi2UlK z2kPqMbO@0W&%Vua6jI1kedYER^Q#RX-&IUUIGwAlBoY`dkFkA%@!r4=d&4T!%Mv;+ z{q%&GeW4ioJyLF;kVD0pPsk~AH_VR^?c2jiZLkg4T-3XduvkiGsvxJ zz=!S}TX-#u`bU^qW>3b@QV$DFgiP!myW0cXUDl~_s`Oyjx4kd zHj45KVoo(=d`N4rS1j$kcuAen2xqmt*Pi~$dq$kAD2$Q7q}|-(H+ZnY6LoxlP6*E8 z%HCLW-aQOT6SNK~Ll1x5CgnFpsl^C4!%oG9-X)9|2iEVSnHOEwiAZ;5(T!(Oy=P65 zE-&*0>^k(nz)ptFOxOLsLM?I;0=z;^5M$_12?@Z`tvc{KoCT(A|_4-H`f> zD4gupD#i&5zNUe@h&(GfT1C|my$AbvEE*B6TlGGSBEpO;N^eE@cMwG9ze#7`K3AAU zNUc{JWL%>K)Db^GU21Og1%57|9{)C)9PrtQ{k6`*H|46IyaTAfrihHDx!QD!Qzt0s zoVBx){x)@b(A}D-fkLw-NA-Q8^IAUp2Tdx99Q~2^c!~ha| zD-n68DG2c{3v^h4{N0RBuPI?zbbl0&`rL`JT!us7-ymPyD$uq#EE<+*ecUr3b{3!2 zZa0eJbsA*gdH7qb>iVU%B-lu9OZ9FaENf?TOKQiqcVcPhRP3AT-}J?M?n~%pt-!_x z<*pTWe_vgPG%uQMONBml+_nzm&OI1igkJ2$aOju$kM}mTfXCPmh2tF(_blm(P43V{ zsA90MeNXSx7|fmn#OcaBuN&2VkFNY&XBC?5H-7t8*~1--INAauwFW({t=EzgXg{E- zW1zc;=5xEm&()%LDkmeG*}7WvWgr;e8JC4rpdIe#UskQF2TSK^LJkX*9%aF^Fm=VB z@byl#Iz`qqiBm_Y$w2JLCk51u2J$@G1_s7J&0EcTx{FyfU9~*mv6kguKZlw!->}6T z?MnOIP>sjo^zzuQ06TiOYXZUYXGfB506!w52&5B52`WfMUD^KuOhB{09b>;+;}M7| z&4ei^%%@SjIbt#I^#)B2e9Doo-o5PRqkt|jn3G&O)>X@Vv*F8?U*GEvnu?q^Yem!t zSZovQ=~h>N;=~TY-K1-@>3W`ADASH`>RQe>$qN~Qi!K+j3#4nATfU(#@WDb|J3=jt zTism?J<#l_pV-Ug>DsPWO5#1Es;qd^UgQDC=;}ip1{Am?sj+45qoXnZ$$ zk8c0gibUHBsg*bjGOGV5)Fm+=oD#F5Hyw4FFpg@6i)B2E48t!&%R%4`-*ei1tFdF0 zGwLN4(r3Ad3%=j#%V%!*-D+~FhBZ!wK`l+0J$Jc4YrZ{ut3V?^cdbAnKbi00R^MJ* zc(F6U{kJ`<<2nD`fxFMkn~`gqfVuhUzpZn)duz|fB@S!GPB6&rwtDPiw$IuVH!moX z2&!Pj18d-(l;q{#=Oxk^geV0#u27Q=&A2pMb(e<9mWG>uy>>HAtIP|${=4v_KJVPoW z{0MEL;g4>wBe`5gu9e(ca$7ri4{TdvwWBknm!?N@+VgglW1kZ4`!K2Du|(3iTgi=&$EijJ6$mL99AqsaK6|;;-J^g>(tcE-4%5bmu5ZRf@!Mg*N?op9=loxARV8z%=QV4S(g z#+;q>^=Cbz^e$BM@0-gw?B;vN0YpK?D?ou^Y*Cs;UcnfQsj+8$AVhBtLijVi)x_^d z!zYU6394gvkQzQny!8}9*!-ulMK?`1O!4)jabIszcrcm>`*#nlmyYp;tIBX%kjK0GtHQ&U4 z!8bdK{=J#%y__KT=G)SA^|m#7Z;T0eA701ZON)1)Lebd|ANJha-6$9R9)(Oivy<8o zo(G5c!yu8jKA7!&cV61And_aibD4$iit@@+LCU+{%ncrVxhS}~wTvq;sHE^)>AgdN z8?0$7u%N;A%*ie16TRb{`vFrcbdpGv^q{)Tp0BIsC!wsZdsDl2T@K^#e=y+OO1v-> z$_@^GXX6GW;-W(ns$IFocu7zN&z$Yuhi`k)e!EL$_%^IB84Y_UyZ5oZp~p9v9<9=4 z=SY)Ys3j_R$Z+E|UEM0)3;9bc0=Y+5ji|^HDuY-v=eCBUVGT2l{MJANVG9?V0D)9Z z8XINs->7)n8`eeMKxA&l20NfXz=ujf{7yx*??g!4<%)PA&yxg3CUGYrJ=*%Rn`rUw zzkRitnfSgKov#(Fev-U==dQtTTNXOV zEwAm~OOxXQ?>KC1P2=*`_RqXtW9>i^aX}89DDQD+LtNr^e#>`FKXa8fWCn7M^(Py= z3m!%)c`%d=e>(|r{oUX0+}&k;DemLd@3`lBvFKh95WSfmKC0W|aIR{4ll`w&_r42& zd01*wX|}Bpm5TB8Flj@%u!FGBWE=Cv?N-2}{TiFdLFdgg)P2W+zpU0_(eYgr=Gd9n zb!`p8c5mjb_tsd&#eBZ+C7IM#N5jRqP(O{lb9sx+^BRc$(lv5f$%2wzf1|mg{+Q>(VJ=Jl- zui5g)FO$3qDnq{7Vf*>PuqmUf;B;{&GKZbv{^jB=U}l0gS9t%@cIV~|U4Ug=Su5() zqL&*ukF&&lAJo;;>Z#1y>lce6MSEF>C)Qj4(rF8B=TM5GBI2-?p#17CAC^QaFh2p4 zjBkX71ov3SiVE3|?a?-CN-v>&W~FQUdUwiZ;w=j8RT5RFLD*Yf1Qqr_aHU**H=yH| zum@4$kHb{&DYEpKC)n?PKZVZ5Mnzn+jPKcEJ*3;H-cz6@`tV}X=ssm^$xoK?^vpIU zPl=!#f^`#3VlRSfAIIp;PkX+RsclA@_k+>6G$M-i4jGNp(4*D0yGqm-PrV$?pPo$f zKM0XD&Cj{;U=%Fl5So@-d#1U;_z=?-c_-LmTI`*hC&;~c7j)jA%1*mi%M(tk$)pg3 z=Z_nP!z`nJm(!C@G5K6NHi?A8JvcAf&7SLrdWr;G9){&)CQ z(5K(yR+-y(l=EuS?3f|Pd0QlJ!_EU(oCu$q5%ZvVW>R;srg_|Zb!-;;(Rws7>;&oH zJQQ1tP@0xV}HNGW6t`OqxFWGubph>&M>XwV%x;Bq0LspY;n~Mg&~Y>lb5l zMn|pTm{a)y_2BZxyT&En^bO>#1;uC_;v<}Wi*rtVwlZma`^1H@Ps1(ZQ_NEQC5of-!!C3F11 zj~sECSsx^YWuVzcIo7vgG6GBGTYcMQv~Tj_$hP_SO{P-sp35`vXMx_{r=R_||M|1y zF+WB@wU3)#`9=I)H^TKtNjNbvWe@p59=e@~XLHkf7Bxl3t{?7b9OV;P7RWe;6~n!l(ijRLQP^KwXjk$>OIC-ReG z(u~SKiH%3(A`FcMBnv*gzQJWwI;G;b7VPGI>UT zm)WjwmZu7oE<>iBR`97A<%ALpEKqH$x7a{81kNvRy_gTS1u~z7rw8O+pt6gEw)4dqkOr(&0aQ z&q-F22ex%pTt$DCQ%6}-y8RFfyQ@{%?pfUNSRJGA34|=pw~LGI;%c)v+HPCEqfG7tUAlw&*1LnoVf)jtt*8D+{*=bSmW6|`iynSP#R;cW{O9INW{yzUpR%oo1pkxxwZeqTFzJDyhL_uGGj_t( zpk+-x%`ca+Y5K4BboJ|7OYRPte)>~0HzOU&+c0T3#J_aIso{&R!~H{EJY@*;Uf&$e zJQBPQ*T8TEL!5?=Etu!7c{vTYyZ+Ft@7A8rUBfigOJ(dLXxq#)?Oxw}y-Zi3J74P? zTe?<*Y83xoU&Mef0cKDLTeMdg)2*GaZ*9{t6ML_}=H>L!%uJE^c)AVCd_^7GYH<|o zaT^p1!QM`%c9t&z({Hx=H_7(5h%n0l8ahv3VmcbUS_M$B?}yL6UrmUy*OI^&VwpT> zSH1>$JAfmme}EmA>NpyXQ-V`kr3B|_6cSeOs3i}sXbno2Ca6k>rr?zAv34yIaBk^! zU`h+0ni5bVK+N||d*yI_G2geC@0(J5DHwN3d&3A`#e%hui-02pKKM-}{4f)K3Y6!| z<8TSfdDMbI<;Roo<5A8lSc7s|Ay`ad89ywgjFOfzzF!&NubiiI9`RtFVRD{fa-Lxd zX4nNY?1G=Rf?w3i`9r}xcPftzYOi8zC?g)sPm0o>pkyd>v=mCbKFbOLD!$Q*Z&aNQ z`JeMNs`v@1cp6o#?5mC}4^+pL2WLDu=fMRJE_pD^(p7Zi!4VIRc`!d5UOrb5Kb(jk zPQ(u<;)fIS!->goq?Y~1Uji+=etm7M&ir%xhp3@$Kh$Ra^Yw>;thZFJQUir$olxwS zRq>ba;q~W1gFErN*45iTWJ!N+#!2ZAKR3S>d~Ci}9sSqMFkY12w-(H!>fH~W0G*pD z(C_Igl!8^Q`E8>h57B2GR3Q{|X)M%I?Y<~$SN&X}WB=83o%Zvo-z$7>CShM(e&`yn z)!#NhX|`X}w=U;;MQ-ZlO_1mZDK}om;ZdvlG+3cDr>lHvSF8P&h_6jIQ;NJceJ>PT z*YNLzvGO{MgDRY_OW&x~Jx*(~*ld+?qQbVmO{S((-Cc!Q=N{bnw>6oBq5rBMK(;8*ipC^^ZEWX z%yWHl()pJ|C#_)ZlMq(Y*#y^FZ^H0(m&0O6JPy-D8j!%3YpW!lPm{Iaqj^k#xz2TO z7PjB&Su&z^)cnf*CCi%rR*xS@(8{;Z*@Dq)jyF9Si zQzNL+G#@`)>%P9$Jzj>FvA)SA!w^{%Jzh@3qAPYQR2ED>NKVjO<8SKeuE=#6n|gK| zlSfGDauIFX8O590>XMN+7SCARTpZ~LA(bOX%Y|RtKXl26J~-eZUgf?AMZs0g#1EfB z53!AU(<2vGna*}Sh)>X`&BH4T4sm1 zVl`4Zawoj%uJV#%*V*d)vbL9xpIILu8hP7m-a|7g#R^#m?PrZULqzdQX>|FNF=NSe+>YY`fYkQ!o79D6l+ z{!S_%jn_P5C5lvW+LP3?ewaiZH>8>TqtuS<(4@xjA`Gir)6pKO!g09hJ)96`MYy-q zrM@CHPZxD!Qp5seXl7KEQER%6ac+xT)DxPs$0!-~wW-zliDe=2(WER^xGe~gB@cYK zWhRfwsq4cy<(*OCnc7byO7xBn`#RJg25#qIkhwf+EHwpmL@NH^s-hL!zpOn25$?5E zIO-e=t;M@A1KoB3x$Z8ccGsQ;+&fXjhvzc#Ia%e7`rvC@QW@P?pyF*7)vCb!@V2T(R@(}onjHu1>-S-O_!)Ei zUCNL8gjJ*a@O3tw`oZr!7^+M$+)$r`fPdDrR*wI;-1HWC7m`V6vh7Tk+*p4$LU{hL zYJNu7hU-Mo?9&|$*%M!?k}FTV8Uy65|e$Z(O|_^@>0LO3LX$G=$sXH zzNV|79reaef{Mcxp|-cXuk^yzCZxKwwHvfNg|;d_|3R)0v%{kAZbEQQSR0y#Q!Doh zGnr6uuW<%zEKAtcGI`8Oc~^0w{Sr;Z{10Yl@f0cvRfas}NLeNb+4Ob5@{$NQF~3)R zLgBN8C=&JDuMIKqrEg-yvO%od51BMzT?Id!9(S?zW4DRv@^ZV`cniorYe`^zO=`;F z^{wx7~PSS+8GO%n!U{{{gM$nHQ6Di$Em&vrYAbyTtCEOm?)FYt?!V+Wj7&$ zC)Spei`*R+d6O{9H?X}E9p7M?rO@pJs#VHRgQHcM0LRWcw*uC)=5e5w+q{h4`5Tzq zrPtf4H8_OGdHH&%jT?0Jt3C-*3i)8pHK1(!s?WE&6U8B;^GoO#TAj9%^h>a|Oc@5H z(3b?A$SITni>|DHAqgPn{yg9X@g;^SL$m5`65>n{Zz6<|#;aI$XJ*l_K|=L(?Tm*G z3`G_iZ~YIa`f+Lq zeQU__Uoniv>s#wzZGWByG#72(Pp@l&EVFRIhUv;D6FLUV1yRT4?TsU*C)p(r&28Cbo%$tN;nk@fo%^MxU1@ZUGql;HJw{Y9(GtcVP*xSg;Cs(hTBvSVcHN zHc3;gflFWp$GkC1Y3^;P^6!Gonzxxt1b!^pHixS~EY!^`!K8*wshfa!g0c)wJRXs{ zx&U^W1KdF*QVBN;ye6`Wkii$!G7KkTMx>(s!N^tMmp21=JUP3(O!kRT2US$Qr#JeH zq7y@~aTu~V=IC0m%R$AA!M2MGvHiyAiZZ~8y11h1uZ&vqgcYD>8Tm`#0JcVBo=W@bWHg!MdtpzlFvS&qhrCXmFQUFeNo0(kp+YmC5aRVS0^ET zq~d2u>ss6!HSpjh4IZ)*{ImjoRE)d_%Q=c;X4o;ub&?tGnU618ne)h2;9gdzyZuvl!)Jr1_X4Es3hzP>xqxdMiw6v4w!Plj05Hz zu;73t2do&NQeeYdO&rBR%}pW1+@NBv|1r~nMBhe+c7lY*0eBLC=K*+u;RoO>6DU#O zQ+?%^!XseENk~i^f#!`wN_Kmq?*Y>mGvw2iu!_gkI=-p+z@Y?auC#j73|7pOBtZHk z&}$`;;ecJ`KK-=pB{if+?Aw&Uhyw=ZFbz;Cizr2vJoWpPVVw+pknhEt2qZ0e!G}r^ z`Yq)d-_JZinLIGAf@w{O!~jRYi6{b7T%>d=!FPe~7lCOhiU2$?T&2G>}r#v|0!8rnBt0z@5ihfwJU?s9`6$b_*=08kHN3ic8Rr=;O zPJBe}U#1H&PokJ7QJg=~Do+h1%Md{ms?j^^hSaR&7p@}1&P55#5$UmtlPV>XHbKdX ztLBHNB%tSEcZ=eQrnzw`??A}> zPfK3ds9XKPQ4sWOU#65f*E?QzDv^&!psuT-3aW%+X2(4XC{x1TKlhQqeD5rlpN5+kV>Q>_00uNr$nk>z6ua)<}ha^Xa zY)V`cS}AD@no#19V5SjMYnsr{Q`{dVIRPXmq*3l$P*Zj!p2E-}L8i>{una%oo@W_i zBP#MU#v!W;^5fRWlofJHCM6-cx-$>=V2DK6BqxlJQwl1<3Xm!cDUJ+-D;a9r29+w= z393jpu*u{XUeB3zSrC3MNT17NfOuu(*Nus&aY+A_97POLrd&9cEXe4AtOOHUms9{$ zp^jJF>Z;-}U6n`;FAj~BGLu^|B2B*sfDP(giC_vYcyPsnqlnmW_T=>72c<~dgs&py z!F=~3zI#gix;sY)7)2*BB7CeXfhh;fIAG2J3jjPILijhJBzC#iN1Y}?Q|*A8)9Dg| z#VIUyN8++DBNGK@Cn3E9XZ#YQBKNukG(Dp5BMFNUT~Kl2o!FCMq$%ksur~8FgMJpm z3~3HS6zEi>)gFf0p|+h9c@{l`luUPk1BplYU6{LF|%|7tD;Ki=d{CB zK?hqLvCGZ`rpmI^2E?GClES!}j0xp`Rggd~2`&AoR|%+i&7x3-i-&2POiWu*iXwR> zEW_Uy7*H|>h(pj?UAmcJrP|<3WV{jgnLF2WkT<0DjjRGyD zaj@1jfkmGGTPKuu4V3x~C3n_V>Xi6CBqNXtL-b2Ue#E4N#vqdHKoN3MQGq@IZiF)0 z*wBccQ7T=?oy#&9DaP<+vd$9ahr&r<0mpjqBhh4ei3b%EdL@MJiW!)m93WasKFr`{ z3UUyw(?j_kMk7)>(H5PQ_Cug#6j~$lAqk8cK}{AsH;`Q5TH87ILuoDQx2GMz?=gX9IyhwgLh9tl$t{X@QHRi z`8==l5y5Y{j;RH2zQwJTwBnfSLCo|(9j*pyR)?zSgOq`BbW`hH*LNVLCHF~1y@i!p z;X%2f(<9qKcwLaCk_d2e1P zXf#=s)NPsHH{*IaTtRL+9=l$%3I0w#)2>R!w$=gfx1~QTbejc8-fLb%s@GJtufwPs zxYYU%Lf1VMpy^L*+X%ZaQ>TStd~wV zbidZH<+(O@!+zB)O{akara(vNsG);u#OvX$k&jm?&y7r%Vkn^msMqfCaRVr-U9p^suJXMJ7pAH^pxe^{?ntD=Dp#?KSuv&%op ze}#Fg`aRx;MGa(FE?uoZoojvL=5~M88-@TjljcaD~?ykM-QgizF!OySZ_eYfJOh0bv34StHP4wIQEPAON zKy@7q>s9YN=om_KnPcvFIul6>R)Q^S_CHOVdfND*`91k=$lcC^mb&6*%8b#)) zQy1#RnpBL|ESN^9<=6J|GPT3<6OidEl)1FC!bR8NwqT)a<_lD0a)Wt%Ars(1 zE|QR!^KgMHPD1aklAvHc+xawd#}m6UiQd_y$5P{6p`!7drn%W@Uc2E2B#{6NGX0Dv zlif(VMNR*Y%SG6wI)|Rz4i&kDA}-dmPNKKI%7)l#n5GZB5xovyXUh4*PcK5B)=O|@ zWh`0cJA>v*)=B~e6Pu7?47Zl$(@J{SKrBf(+$SYSUf2F;fFzVq!*4&X$&(bgabU-s z`kJv;5j1ndH1bxW>#bAg3DoBvle`C~z=If~yB2DwC}pL+gX%Y6WGE4+@{jJ(`|Cx4 z#X=b;Ti3rXqgm?XqBIgz&!W@Dtv~!F=h7KEjvgPipJ3tTckNFWKKm1X(0lyBQ1<{B zWO;k5)sxvjuJ@Xysrehpck1j+(JLfA)W;>bz)MJTiL z-1xedgz3Zv{@PyDg*C`*0@&a+vW(-)P=BE5U8ZYqvVsEYFLUQKE}-ZZ=th#>f_)LHYm0ij13@4lB$78QkeQ#%#v++ln zJFhkJb}bk>DAZ+oN!8BpuhtLh=bZG$FdE(UM=v+!qeGjR0FF8{4C26()Z|7my%Kb- z)g^6jSW30?CRe%|>&~RdLA6e6k`yx+p13;TDmt68)X9^RQ0bNtjiEU!bHfMUI+@wH#G32Q_Du!9 z&0!YCypwX6lUu$vKVE;`(gZ&!>Sv z>IZ)(7LbQo`cR~FjaU|D<5v6m!P{SeEMAe_4sBPvP6rEG27m&B+B?&Qu4>l{4RdxhSuOvtHL8x;L6l_B`Gs=&jVAf5~ za2tTI8H6!eGfQdcPe%PVGc)vOXkJJN(g;q&a3@koY+>@2Kl*mmUI?GbhH=tv2+vX% z(77{;oKM(6s#G?2jZu=FUE&Zd-}s+9rP1|Dm>sVs4X$03ax-ntZY?2F$>NEUf6aKi z8f6p1&0<3EIu{_<#xJx=bTFFu!3kiAXa)o-c*vd07I`zUh_(L(Cu%W5#WMIW2Yqbn zG^Mk$pM9;$q+a(LKFbsi6HQlV4{+6`LB~+dBn(Ts1ea?9W2>+?5pd zlqd=PsyT4?G73HFOnK`}&GZ#|yPHXMqU_xC+RuT;^Ep7@+qwD4q|6@9-aNH+Un9%@ z+}?CTL&(+V_NSru`M{T{!ti}*YN(T)2P2K`51(uRBO_f>7(BCafMrQ<>i8>os z6A%c^1*r**OP!|G+@||*s;^b}1e7}@kZE6s89VeEFTspm!YdG&7%TYZ`_|mj=x(iY zdIKrbbfdqGuaHlTXtqKLyjknPwRF=uv!>F)Gh~~amgR21{l!^ko-9}`CDGprSW1eX zz*o_XUpnWyFHW1uJeP*R^O`}ofik(7b;3{2ch@Xq^AS1?I6z~m`KiOMUYUEH3SVly8hv|poF$wL|_eTBm=4CB?K$-tG3aMvOg92!`kfRgh z^p1g8+Zms3V#lSOje=@-&Nm_X8O26`iFm!#I<=o;p^Hon-&>Q|l1Rh-!xb|;QRE&H zG24S$I?fsq-kcw^rd!L?Egbdlu5(ioX;#{ zUQ}vK*L)*Xp$2>Q*R+Ri4UQ1Rw#81Ef*V(bzl5BkcM-DVVc!#Sd%X7+f>~x4ea!AL z^2K`@%12Y_;ygx^K>LKJ+vg?NGbh*ivNW|1xcChU)D=Xjw9_2XMZ^v+zPb=Gn>zT2t0J&MNtKa?%&lycN zeAv1@egjM`3(&C{3RJk(pW1G<%y<>1e~anxt?3Y`Id#xyw!X&!m!JSC9xy=&V9{Q! z1T=f2gbY&+)v&AF&D%4ML6Km+VHz)|$7$Eb4jhhGd}4fs{^Yl3tL-{Mc##Zd0A~F| zK2jQ(w`Rd4h-yr>RpXnwwjB~qGEuMHJ)y28izXwm?T)`f+cS38tza9l;A_LzRtTz- zc1SqI^*X%08Hxb(0f%zQ9N5NKa>lZNPJ$Jn+w5wbuXf9l5I;!jI^2l|^ePPPhSe&s z!*nCtim=^%+X>o*S1Byu?s&CNcOfaDPt_gUF6U|J;bQjJ>T1aXLU!Kka(fjt zoaw3*>LvAmU#0W$frz$@$h!_XLrlLqRP%D<^eO;fY#+eM>EVgnhY1NU8 zw^y~=`E0!qt)L<3kYC4rGS5nT;}RB!4bONuE#}~>SM50bX&KrnET^P#r81w}1<)D` z(+z&AUzMCvt|a)>4V-k(>R6w?;k3SY719aB|9kyOq9wuWI4+1s8^ubTdm#evqI1e= z;8EHh6FQ%u$N%p_x+6{XeOL^~d2{d~lpAFpdv$*ZvDxS-PauImWSUv#T>4|p1I@tY zvEAhY1;~f=$-Oowz$Yv4{9u&0kEX{b6d%o_zSk;x(~eI*f@gw@ATxio=QEHcA8lv6 z$W{~jXm78z@pS3q$Pdjzu8l!Ii(>V*k6}8si;m4P)r&g6*_`}&?Gw7y(d8U!w`2~0!bf_DmpD1#QoF|pK`fWhgK04tzuueEHZUmcHUbNx*7?FHA zc4E6E3SS>kR{8HAQA&I?p>v|OOu82WMJ|Or*9)nHNVR~5{;1;CXosZUUcLv3^uM)0 z=7-nXBM?dI?WVS`TfQx`2^-}`0?mePy*m=fc^13+rc!!)GtkRPb7b?h9rLV)ci1FA z7poHj()W{js>*0?ojH;rQqog0*XNlYU_fH{OS8~J6Ja8dqn@sLt@{D($X$caB)aBy z173F^28H3-4?khXZ*&9=;A&r$>DI^qPQrK1EMp0f8_IjU%I-!+0aQDucNiXUB6V$p zjf773+1H23>AzDVahSNj8ui19M%CzqO+lg@<CUk`Gu`e%DA61q-7@jCGf)8@R z@CB`I39#>FxdkGi&pYec29^$w0Y=M606oOK z)%GmIaUFK{-s*0CeV`e=$p@KjPz+e1xB70M2tut1efnHqJG4;T`(;i$y-D}mh~&;q zlG1tq}~5y*S^rip&_?d%?n@}{^wJ41CnxO5KuTPrUKeZWq% zHlW|B0rEvT^FoEARcpM>TABc&i$dc8MZ;-(6s&ZU>^te>P)`ut+G#Q@t4}XA$h~mn zF^V*f8}n9ed2*Enwq-EM)1Xmrr^TJ69H% z>2}Vz+n{|4NDY#%26d3*GvP3>-~UQmns+LdQY}4`ShOJKCNPYTSn!d_8``M?_fp=wK{NbA`l%v_EPx42(tAl zzPC4brsgq{EW7HfTXqRa;IycdP~WlY5lx~jGL?ZlM)ij;i)E-x1I;=C){%k!f)G%1 zY`?hb@5~8U@4cU7QLSogJY`PBm1Wk`>({e#3>nkr_IY$jiZ+%`A{e3(wn;2(}0oPsn=9lf4Ad(i!e*tsZSuj<@uv%`7aYV-{kqxo5# zW-al8d+nBemv3z1&@!nko$8)=6@brg`)c0mdzz692zRR~LiEb&OV1Y>h8=OqlL@ziBo};_48E-PoE3)>L zS#N0efms~XZHufT@6=yg<}^6pSNibI6c5BsVI32$MiVeVE^gnu_3cR5e<}6#!@!}>4j1_O1Ac{}G)I&C9#i-3jHv zSU*fSsI|n#=KS*3PFFm|65a~q&iVE{V?`D^17ShgS6%Nql5l8nty5a&9axoebCQ}ZN~)x#v58Gw0926?S* zXeV6q|$@mC0Xa0<1MvySgRp@nTO%{9dd#%q}l$U=tdv4r_3_f|bBR0-XjNj3K=? z*4yMdIW?~f)9lok@sQ2YEqsB++)|fdGc^ELZ^W=qSA7cU=~4o!V^YmxA13P^cO+jX zc0nww;COj$8EP)mhhOb7V;$PQFK$`RjY~cb&gp{!eyMK*j%-}t03R;UrkLu_0Z8%b zv>v62IfQ{vLT&F=(iSKQr_)tzd!HuM&UzZegbS7B<)rS-d;BH~>{7H=s!ehria%R; zaYoGED>+hdkCD5v2*Pvfw|s^C74B8ikJp|V^>}h|SH5_#vHZTMoURXULlR3`Z;k$_ z+e=?_m&~WO1NmaSJwg{5*^LIpo~=z1FH_=@F(rP2`6a=8yyHt=Ux6n*;J-g0RSw17izjQg@AKLm#q$c|3qrKckRrpODe<;=o$*- zZOUe^)&!n>gcTM3}F0=w~{&W|u3kJ%@=onyr{8!GkbSrlg{0}hz~;YHrm zIO&VB`UFXW{l#WSA>f0K>IO5WkGUBG{F z>RgnECp>3)yfCAt+tWvnFMom)_b`uK)z%~Ne$ulA11{{n%&En%0 zUUsvW4_%*D6M1T8gm=3snYro?a59&hJ{fdex`2Bp-jWge%EPFoXlL6#)0vj(Pm5R? z+S)-=A>_h&T<&xQ347CWxW}Sj4d)e|>sOr$&S%KEzJ0kT=(UuY>*3y#=4KbYNA$;8 zWzz7xDzn@TKVS+PnGC6!P|m7ZY!!E4tIbzw3MnNYe&5P;AYK*I*6R@#)P&-fXUW>VG9vgJ$X@xNUhk2z8odg~08e3|0t;jxgn{mg3m0V4<)PK`K!wf)PhO_|_2AmN4to+33rzSw+wWvYPm46VR%g^_)hF4Ca237WeSgfi=6#I#Y#5gHJkPpZGSKBc2O2 z##h69kKuY`=P|na9^9?<*T`q{s!y1GxhWSgx{D$}G8&<^@e&U|Qe2h}IQwk%nS&QR^4SVm+kuW)J#;P8{u!$} ze72u0x_ACLbeEbe8v$*xX7wo$v|-`R?3d6DZ3;Lq9817ISn#E1o!VdZwP6EdzocXS zT>IOs0Lc>(XuN;b-FX~<=rHM4jADl4>t(rfIQje|OX?HVoCUmJJP)-Onk|ZC3e+!5 zHghS#imI>LhzQGtU-DKXxJU1;{jYkux+7N->>bYJngj}->6Z-0zF)pvQ zf8zNi^MR@zoQ*7AQ6A=Yx48_hws1&Qn{3ps>VD*~MDOTbN(=qyww~3H?OrStQUz@pyfl@ePhIr=&sp3QKoJ87x*9 z%nJB*ZRZ9%d_d{H05$lAFS#nQ8M!ihgpXbP`If%fPxYZ8x*Rk z!j-EhKAa&SK{2j{X(~qSAs`uKH38YAw|edaQbIlQ0kA5x{tvLT2|FtF?2lPtfHlkR>!uiAkuQ8F768RjT(!T$RA=mgNyQtTguA zSD@GGRT9UC#`=cRS0ZL)QpmtGW{yOigcX6KHd$5%ve4_|3KJ+hqQ#+}5%PCqWuF4> zRS%+*ab*Dl;BKe~7M=NEyPvUgCo6n(kGV5ATCTzhpWu;B>sQs0+)^(nVvbO+3;Wf< zsmap~c2iF+`u$fgEs{nGI_G)G5k@_`=q&*C$|83^TNXxGoG>I+k1nz+Up>AE?||!) zEHFVu9+>EY5nk$@M$qSo-N7QYgAr)PLUSV1{Z=EjZIQ3ajgv`H9&utbirBg;4jLb$ zKz=P%thPsg88QN2g%|Dchp6&__bujmn5M%@Ni2= z!B8?SK^HJd)#iku=1pH$cNT}40DM4$zgBG^2*xtGaYo~@WCc@03H)}(WiCr9zixl# z)N6XM9<|lDX;Pbv>k*%^!LhdbtBu@Nl{9Ox< zCr+U`FF_eR+p;GezQixFsNkV#}98%Ekx~zo3IhR#j3|MD(b)-zh~(zrXN{7-ocZ35)?&f|47% zMORV*s9u71b5Cm5XgU5eJx1WrK|qxL?H_VTU_^DXR0(ypkAfOf7sPOIu2Wf(lhz6X zDPfWe+>;7KNC-%BWcrrGey}+%OmL$K=(5?K{A@28FY^;#dx!n9EGIoH;8D#1goa9l z5;&J7q{3ZOqS>!DC3B|aus--Y*cAHqU`wK87LK-ETJ+;tHf|Aefm`{T8?Ve`^IW!Z{{OB`@>nF#$N;aDke455L%%0wr7RLqtBkm6SD<^O>{A07t+<7daV=E+{aQ z7lhZ!2rTK)TxZ9~JF^ChAXBmpKq7Er1zyCF#c{Z!ovE4dwVO-{kQI}5pa26#jS`wEG2%nCqt|t#i0BL}8Q~+P zl=j+1tLM`nYEi(zD)e22kBbFRX;g-D`LUNNW;3M$L9|>O<$5=?spQ};l`Em(#WYA* z1fy;wC`Q*DBrIWIS>P2LS(d}YeiKs(v9$GR!eu2S+LY9}k`@@4fCPG`jRUMXB?C=z z(tHWK4bdx7L&{Jzr4-fHBLJmrOPE5&h3J2oGbB)=0|P7AhIfxflx9bCw?@Ju_}D5O znUXv{y|SF)J6%+ONdaG#WKbEUt&%$s6rm0jit)jNn>G|z7RTGg2@SoH;5_{JuqcjF znAHskq(Yq%z>qH_4hrRkGNH+i+q=b=y0;G?jJtVc#xQ-oYWS+$5$EP;zaPLwu zSzr|xYN>^yZY2U`V^Dd<&Rbx&Da?s6U?#Qc!>P=2Hat<5fYBIE1>&GkYZf)5Ee*ul zx-8mcSbd7T*ln%I6M73xg40m9jU9qND7G7}FwZEBfoq|NgCz-J#v&ur%BwMj_Q?Qp z6%|@BK55D$shaDQ^1%>-D5p!P>ddDWn%|Nv!m{*d+%KtUAK|F0ZHA~~+*XDA4LMXJ z1=*vb99!zZ75|W<)WIphnNsS2jlcm^nW{qzXnK}uf_x99c6|6&omZ;$S#Q@Xb93zL zc%#(1h`%07z1>5+41b%Kdf)K0UjfUoYdx>@p}WzD1t5wsAcp|$jmL5dvR@hNU7Ou2 z{`_a53jebVF_f|RYpmh9o#wGU%`fX$KbMIYjMO`?m+)5kr=ya(<~$86M|3jZ;!556 ztPCWp9?745ul-c&t?>1i>dr4^K(Wl9u>RD2{AE|mn=F0f;_7}W13TA$-`<+_$GC=i z&a3oR4@9r73h9158#U;k>h5*@ajNHK^)1@|EhY2Fmawhc zSB3xUwrliRy&&KE?XP7L2Xz5w>CgJ5&GqWf`b$LsuMUazWeg>u8)eQYE?IwkS&i|r z#=44MjidXI>oX_fU;Vg<_%$q(u6eoLuVeF=!2a|b`(l8!ny%)ut4wZf<1>C+FQSF@ z2J69J;=gUaT9_&Nx5IMQH!e>4-?Kgy_?^nz3(^+-uGXPhCmB|<{`lv*RiLKTp@M6E z()GuD8P=x**WjE{0msWa%zt@RmH*49$|$?;i0bWv*3A6x2jxUs=6+?NKL~DG4`%D$ zyTZAC{0Dh+d({mBim0zR%R7soF zrBJbg+PjXAlQ}FjP5-TLPebd)u>Oy_fBD5SHp)~S%@2FKM*i_wd;RZC{h`*-zptV7 z;$Iy)uFzC?_xjYUS*uD9FpH2|BmT0$Yryij&%TyD4O9AT4v=w=SlVQKl12=1!07*mV|C-(8&dq&9MX3V#f{**yPbe;dwuxT1)@h zt#OTHyN&$CRp)=Zs*Ar})#cx=>T0*@@2W5UyT2!J7wvuCElN2a=iOEb%z9_%@-Z(Xij@i}QhOXSOoW>z8lf>kb z0zLC+qjA*HqEqB}Z?#6wb%o}F*lK_2GI%6sI8zr^I2bE238owilfEejID1I1I7Xu- z>L!R1&KjmdCG95WIN2kIM%BUC#+Fng&}9XoAfmByZ==SQU%Rz&a*4@1Wu$}`LzkHB zQ_MJZag15Sn=Ud(%6aBSt~5MdhVQAtSxO);XkhLS=0J*7`UpIADyp7zPDRmEaDu?3 znKa*G zT0l-xael5YGu67ZP`6FoQZYQ|FPV(tS``bd#f$>!oF3?$%1wb=yNLcOOqeCe0X3^a zhT#$mFE3FHn9O)>p_x~phn(7hrLp1dU{N0hI4 z!|7H5#*POG=aolPy(c?x-^-X;|D}$jb%RjB#evgHS)P+}G)rXx_7TIOSn#RfV}eG6 zO%(;ino%A!S_bw}EAc_+D|72-!H$!mKj^Y?lcw^3@m=*Lfd!({ihNGF+Mlq+41(&zCktUa4*3)3HFzBXahXP{L? zYRhER292<}`$Z|8LclxO(Fq3YPKRAk+n%SVMmhlBuN3GYQI3>CHF{l^5xpwPbEq7f zWdypK^SrC>kcPL~sEE1A)>%8WSpxVEZiA5$_ih^mJ71(_d#IG%pQ19XG?V5_q;){K zqXfIF$*S%?$;*@x!MZ3T5)Rg%1NQQEkVwh@&^8~b>nI_;D=I5SD5VU1ZxA@|v{4wq#;P-~1$(bEZD97pT&m4|)i2;c zM4ePO3N0-GiM>|Vn)D|X;l0#kOKL6WaZWUo+RdkFns9g-!!FiW;5AaBY`uxe;f5;A zxZW5GUd;k(ElV~RrOqT|qfC6B$TBiYol3}@pIh4jH(CG_!|wDJ-`AFO0yxZ1stl`V zoYJ6E!+9K<$3d}|bMut@VwD;_iAm|nk5*}Wj`skY;zh0807h8&YgXs_^k<+*&?52J z*JyFGtf3J&ACqs4p?%)iUCGP z4j6GjZfM-Do<>|ht?Qm0TFkY`iAtT{2ArPQb4eYpsu9x=qAHJYxdvxESc{7Fq+Ejv zlfh^``O(0V0lE}i9#Cyc?#vr>E{(kA4zxQaEA^MT^eth^OTZFCCn5#-io7<&m$aca z1wcUCAv6}XF@P3zW*4?MYG(j7qY86Ku>AXuX<=2`qz!J=ZhDD*F>+;we zoCYOKHThmQmAr!$m9!%5!Ngxz5rZ0&sZesyPQ^xl^mTlF2&_Rk($YSIAq#baG#zuO zffWGi6I)fLLdlY)~zFObeWQZ{$TX;FD6b0uvnS^Y3GkA%|HF(r^wu*y=Y zTYi9r=Rhv5Zo`T*J!H3MnII)t52H zZl$?UsN@zYC34eS1f>>6)-B<@N@ZM9d0NIqo|efG@txrakHYB@$UK6qu_Cl0%M`Ed zGR;T_)KNx=ArJiMQn;KCu-OuAbw0`T!yJmm5P7nQkxyk7`*H>JJxc(`dnC(JUkU;% z71mHdwuE|6nI9?Jb>?3~0=(XIo=~r6koZMOgjP}E!2@w$YWrNf z+$?{4zK61m5XU}4bfxctuKYjjy~~m-IgTj!E3)47M!F_{yU#8km6_f7=$y{#%IRx1 z3lGUd@)MF=rN@bk9P57{(gBiqNvGQGW^C-PA_UM$5ClOG1VOfW=gWVKIoDb7M$N|d z+1%;Y&zaL@T#52w>ZAeF!Nxqznr__*KqgDz(@z9YNkA$v2eq9hjfAsO&^82`I9MTB<7~PYPLG(JC8R)Y9TZoH1e)fiOfNfHK7YA`+rs zLI5R}LL7P*LTD(7L=ADDSP%}8K9G+N(1r|XLpX?chD7$bso?T}rVv(<@(8L)R}j=Z z=^gqASou!Xnp<3h@#W=8(ajta?1zAA)(}1=KtXu#HdTQ6aY>#Yf~#re2y6I=kT`2B z&@skKew6r@B8kqn8;50IRUe!okE$;PI)(p-+eTQ>=vyg;2p09kT{afrQW0ZT<%Zb7 zavlYQ*HWr6sv1oicv$$CU8FV!Spx=EcU}P|;f&l6B-Vu*7CsefgI^&-H?OHR3U*v* z;tb@)8Y!GjB}OwJnI<*Uq-uSZWtvp2#j;G3imp!v(xm2^h+GqqYa((@M4_2e=oSmj zoI*3F&~z0>i@MvTCZbYAgtsi?O^b950It_UDab0Z@)Vkq4_Z#*EOuV$?{QA#^%m1PLcTTp1z7 zaxoVZd59-znFFZNpcY3qc8xOuuXMzlQL+`7Nvv~4h^EGS(Sa|BEmTkNcE$tt!Ia@9 z3WizmW;>a-HzVy2t1C#J3gaK0+HxHNsjx+Pv;K zdnmJY zrKUkG`Y0%9v}o08;)riL)!$~#IwHwx)F8&9d`>pGq90#Nec0uu(mkMg+ZA7%?#Wo< z-HO;6o6T*e4QhN(k8z`YrUTnAsHIkOw_88ghn=xvbom3(p3!AkWWE&e+?HsR;H`^T3zVELb>{^c+yBIqxxHXrHz%UMVfksY-AF84}8+G zdM)dR*_$?qml{Zm5G z6cj1zV!`yGua62>Y^pK}^aa6m;U?VzYVq|p+Q+74^pU8)7T<9bWVCsr86omf1KAN) zm`xHo6m4ye8zLLZDJ~7I3RrCMX}Ne&Iwd3t8F8>!L5d*=qH0s#qU4Zu$V!*5SxYJ7 zPRM3JZ-un@?pi7}UmZbIPEiOec=3=y3$Z0@n_ORXxJta)LWu_JB3)=`@$I%)Rk&Tl ztAudDn8b^n?YC967LjUg6%UL)6eDnFNk{;_B`Ym8Q!o~khbuU6Ek=$r3wI>ytGQDw z_s~3WfUVb(N_-`b^#xCoXsPlE_rdlM9jzOBtBLU)l$4Me$xnQJ()u1>gJC?x*V-jF z@TK-gvCXc)1A%0c+>{UI1glsupsAv)AfS0FZjC*jcy)-3VQX|h#7bdMFdcrzI?#6Z z3IE>$Q9>2{GT(uqkx`dzNOx>6^&WuE7Sz9Uq+ z&3H}+z`>dvH(kMhJHK}Ff3eq2de`Suf7^e+Y6br8{>3c?t~vi{|8baR6RzL7#p<(L zv<6W8$8&#$2dw+;WpwJ@kJ@327l8WlZ8)t?!##eHyi$(e{md(L^ZMVX4=Cf9H5@{K zZGRXy^SA#x9v8n4hXXWsIFCTw*O%cv*zf(}HsOxxGQC06d4KraU;6DNBG?>wJgv?{ z52Fn+?tf0M6NvTA&nxs5syX%-*Ncfg!20j*Q?wiUQ@_9W8+@?ZOA*kC#_Z~4*p7}< z^Ficz90Jl4zc|eg4?@U-2T0h)y_Q@berd->>@5UPAog{jg21 z;=t?O^!?2Kgth+QHpA$&>CflGu(+Nt{=s*A8S*sV0C#b9^w))xg6nGZR9(mIfX`(w ze(incmvMrU8#gA4jsMVhX8s9?0|o}pf_8B=&T`vd_b>_Oi+4w-BLR9IE=MQyiD$pL zLc@C)g@})Q5a&{qBZdK-bLi+{>Iz}}q%u5RP!=`aFAU++8Y z;vY93958Rcej40R%vHa*jo20{J{b!xZ&F6~pKIu=+H8&;`ZA zW7)4@KmZ(s9|MtIhSTDGKMaTAvKqG2G3e?4>9}>n0{#x&F!Sr%yXz@JKlMi#<#5#O z?{HrFBfKx>)GdB~yNxpy|8c(VVK@6SI_6#jKYsH10l>S(!Jq2PBiLgUf`?vvf7d|N zvBQ}KlkPo?SA-`y=h*jI5wf}QGG6yE#=qQ$4NA`FwA^)d+#H5!OU3);;FRnv%YIzu zF8nh812|`wLuAGLAHv*{!o}?{O-E1T?lOIzJN%22A-9i5%e3ony;5Q?lb=a$k$K2c;WU+&O8}=srKhm z*RvB8jd62uIL$1l8gn?$YEYISI+K-f(|fBfmOFT09gpsQfy%rFSxruz?}pcLILsCN z+x#;X3N$AMjXTt7Y3^O7e!IP2!>aPteK%_Y{J=3CrRgm|rZ-2<1r?y<^abJXr%1?O zgM()VCnfkz?`)~i4TqTze3{H&KKj$jjl^N_47>HWk*veWu#d2=_7E`ja^Jk|r^9XT z!_VUZtfQCv>uV1N#ZLpa>4v_C;dtJo?uyup*H8;LOZvAf zF&b%+INwAC*eF}U9QXsk#OMTITyMUaj)8i&I{!IQt1Yb)8*sG-+bX(m9sIWMcVfBT z%=W@8Q!BS-Ojnq0KRa{wgatnCyIuz7ZicPXyBjAAZ;?Fe7XGonzk;ue_cDF9x;QoO z4`qba0!7aKVc))S zn#y$$rD!^Z4mt2!s)?=D4tL*@h3Kox(GM%U5{k&Q-{Ht%R!2f}Iq>Hp?B9L@X#n{6 z8cf9L`gd59H;0eyAT9CD)Vo!>=LVKj@7u&Mzvwtk?nGfi`eew!LJxkH?xPv1K08zgi(JCNJ`ZP5DrX1aXDE?v%d zhuOPL!foU>DaS3mX`>1_I{;W5Zq+*2b@Yl;n3-N7t=?Vn{VmSB@2AtB{UOwK^J^-Y zp^M+VIWU7Ss6;&vQ57MW$mcf~jIjoI6io(y^=sH-*Pn&7UHbP|^dYb*7tWbUDjfh{ zUp`P~znOmoup7WbKfcU6Z|?K5zS>Ur-Ju8VBecQxpB(|?m9GL@2(FF>*3VPeA3Ds{^|u#O)N%jiBP>v> zt6QDG?whS?z)zf4TI?L~nfGS8ma9DBh^Dh8!o4A)?-J+&M;N^sNKiR#`C}Oyh%?Y50Dxq;U zGjez7!#3o5{5PQ7;&eeO{)+!b)O2)T4g)y8+>iMtSU`uBmYM;><~CjUPz~G7bl>b( z!{G`G>*yx)4!Gq6b>*#oXBT}Qhj-wnn_-CE4PlBhv(U?7F!fn&r{3+w+=KzSB® zdU+Fmk}N&=p#odxu=v&go1lv@Qw`KSPZ!?)yYz==Wx`x$_$@5{q5j`C`@=8>4`@V( zPbJ#17@U7(r#BpSAkoA!9u99oWl~doI2;$p3B-i1L~dJ;PT?Mv|8R0v4s?$$mNR(5 zZLl*a0ESDzU;H*~hB%G7I=iZF(P}3nd-c9i^w|G^7R&It2fKtA4zF%L#zlnfCEZ^6 z_3jpx{>mwN^@eU#7(ezG^ku*Hm)*3QwnN+-y!02mT;_1cSpvhhKkfz`X7-A`xGfJD z8^6wF0XK(u$FE4p_v%_rUcJ8pB!IfKUj2E}hp>UXX}Q?WE^qYcF1`&8@ENzqdt72! z0$lKy;Dbk`?;h<2bMuCa*RR87N|n9`n~NL)yoS^#ax*_8u3>9VME%w68E5?)9RuHc z-d)eIusbx`u)zcT>a&E%VZI-(ZqdGYqhtz8?RTEYuLsb1u0&`Y4nRC`*N=x}I)3f7 z(ka5D>B!)xfr=n{FbEtz3tEGe2o6l?&D?pjbhaCdl!2dbjVYlu; ze!2dL3*HZS$Qu!1m?*;arVmR5Tm5I`tmf&N)(j0c1m)%IW4>r*>JaDXEFfF2+1`mF z8Me;5%QRg55tiBt&!-*Xz>*B?<^CDZ2b@j_r`N$(E%p*SH*A+NoB_;C+q;2UBxh7O zhWBx4Zq(?Z`{#bs2ULRp5T@viIYvhiO>5-fs88dO@4i2u;n@9syrRjmp99ubZf=TA zn?vl-*G9}R@4^~lYm|!Fuk0+os~gUj!EtI5cC0IcZUv^O_p^&~yPDn({r!X`?4W+jPbT)!O*kJ0U*8buXYhg*=he4E|GJ1L1rZ%) zmqH(%UAfzH+h9L}Z;#2zxZ5FLsJ-C3l)RhhuiksxY1@lq{rz-;g(&SI9ccJSXN76M zu%Asix&z|_Vc`{_Np`odmze}8c@v0WYgb!KPrAJ{qK{=RdIRNOH-D<)`$a16S4 zpQWP@x`*``VtMt4H|Urb`{_Ot;o8RKaXdOH9>H>sc8y!FTra@HPKVd7H9TGpPUUfW z?`MCuF!7xg*KXA@;;5n#c9~`K%`Hg z@S}y1PO3jm_sce(p-`9lr~7H+kL&nG?Z?qy?Pqvqc2T?^#MNbTCJHzLk^|<=>9h}L zG5+BuQE)OlDMp;PFpMqA8(Q7m0Dl`?Z)HipYjbjb>2Kb*{VJ9)C!6SRA)YS>JdSum z=wiuwaWfKbcY;hrubs9Uv|*$w`#tp)yo}rK*9TS=ohg1iw#zUN6jda!AJbttp}Dk zs8ca+immqEQy(@w%-VKk&X*7Hek4b$gE~6^cX?^Y=D_}V!O8t$y!FCFKlF15j0&M~ z#%J0u26gGZP>boG7fwVMJ_zp0r;A4ipC61S0)wp-w%^Xa9>78KEjUgijQcHZY+Pd` zmQ5ZTYkmungd(cY&FZw@4l?Nvo?vnLeRz3E5Fl*z?tvOPx4iOxA!msEDL;1ueV#r} zFqrJ%&TWJG;C^vZvY8i6Hz(#TrjI+hVw8tZ4+A*22w#Ka4Ioo+?~!5jM_0)ljq!E| zA72mWt!|h9K3o=laocira~S?e%NFiw!f9xjX=(5QvD)_GfPt8F2Z+I6ZQV3nytuhZ zD~ubMXhe9}aH3176X)9@g?I#K=fOuF?fg_(xz0N#zV-Ax$M<|nOU6(0wlBitl9Axa z)yeEY4^Orr1`i+{eH+m~a&QBM=MYrHoa$Lj{Tns@9c;IFxp;GZwViIbB&A)f$A{^Z z*o;6vKM2|zSSuHp1}f7^#BofItW;BH}G z#EoT&a&eefkk*S2XobTHAOGtO@^~~xf`(uw9OwNG8x&YW{-nVI$8gz5?3Ruw#neR$ z=kj3^QeGc>Z~X){`*R;F@!%kP1blNV$pttXj?j)Fw9{?R+;lqKRJ^Avu^+EiIPbB) zZ+(D9^o&H)-CFdKI`xDjJN4HCx-dUvB5jHYB2KPAlFme^QcbxZs zSGw9B2fqZQ5u1M!L>x1Un9ZU&liX+Y61b@_ZZXh4?ExIrZE3lM>|nP)sagIn!9oa_ zsAUP}kFi%q+0mK*BiZ)?Y#Y|DpYh)WO*ISLNsAn=uV)5Pc3y^`b!E%tDjdi1fD#{G%o zoC<9YwOhS7b^f{t`Xrurx*{R+zzOgrG!VVq$>jw5?Ab}>Hq}>VmI3bUrqUJ9bz)cv zpsvFq1kRAHCg8lujGlIqyZv}~+Zxm`(fxhAJ4x725J29KL7Vv`gHCU_>4AZ?!HIfw z)>F^+9w*|#)(-7ce@0PDK@2Cyf$IduL%P3l8is8;4WcIm&T7TenWSZ$spt%>Ym7@m zg*k&S{r&Vh9Ih;i&ttB4AJr4BE6O;Je)s3iu>DUi3WxAfdq7s)U7z64o*mzx#y{{B zfw;;}(+1B(s6d~bRIcV&GPO%N%?{(;z+&R?!1Q4h8{qd4d9u3R59j^;vX})_grq=8 z?*-UTyIr^m^uq*Z6=09Bs7QMgB0S6xBuN%F5 zh;yXru!YcD8Sv&dIl~8=5mVA{uxFW~^B`F*00XzB_hWBifn=A_ri%~b+^*gR9|mfj z7&=`Y_hsquX!;z@<)B2*{r0};_ap{w^4lwlN?~XO?d{bhtp-}?3cx} zUlx6*z%S32%Cp}iQJd)8pCHZ^^EJepP*S2()^Dx0xMU^q@wVO-g(j?7=V9ZPCXAhn zfraPU-^p%3o&EVbi>W`)b{Vy`XUL6M#s4;k!(KpJMr<&&B$n<*nE7)a+zJ#Al*74< zgSy3Sb?)C!1HH?19_JJ8mG{gnz&;TA>^wR<2!b+K{r%;FhX-+Q@4pO(2T?2>EZAGg zCOW&VcM5?=_d^O6Oi^GnjQD>B1j12kTB5?@t$n94hBO9?%#g)=FUW|J1uThFCH~N} zYj1j!drI%KTHu939=?T`+a7}N*xEDW4q%_iR{S#rGz6Y(fIZUla|PRk4{MKec58K% zrSJR8pM8YJ&3%I22DmH1nFbRzc>&MU`!+1tX}(AKm+IgejiVGCoMQ+9O%~j&Ca}1`xw6=fYGE3 z8-PpL&L?k5IuND@+TP3rl@d3TZmIJ2wX@j|!+DQ;ujH|FdeGm5qTx8fopFJiepDSf z&|$ny*OjxTHp6*?+m0A|a>1LU{1WK}mq_Brcc4A&s*)&sS#^4#2mCa`9aQYnFVpM> z8`S=JMqJ}bcs#H5hDX@F!~hWw3=VG+kK@6}v9y~0W$T^r3LlqLhTowx?CaS2(TVru ziuc3mPtND*Ou|}-H`fW_2DU2kdZmvRdAV=7usGPsGj-)hJKVQpCd9>UKTeW=Zfi4J zV=D-M2~6i_c2MjFcSFg^hmRkJRhsOnFVi2%DgymrG^e?0Sg(^7mogFiWHuF$-&ddMEKE>T;wnu^rv|#`F#-@C%w);pogm zoe>nyrig0um}?Ko8mQXw@Q>Ze+nsO#cJ6Nhu?ts4f=%O%ihe=z6NDwepRL@cUB7zi zgPT2hjvW%|`4P@UJ_!OWrcx;5l72$OJzf_3wCbl@Ryak&SfO(^d` zo}%-W`0yR(7{YIVha2$#_E+1CG#n0bb`0IFoTYtA3Y^Ko|AQuG8}-D+^@XZsIh z4M;5L)fqf5gE+=@z_i~Xi}=+ybMle8N6QSmzKz?!GPJR;a~!Sg`Ef)S0Ci?u{lQhr zH;{s*ucwbKUi*uoFxNSLFBT@7NB3vKpxXQGMqz1qD8>z2y!DtE;3v4+lq1w|L;&WI z;{&jdw&H;G`_^CkW$&(YoM4J*HZ5_1s%fb<6>7$(XR z9n8Vo!TL&DnrqCjBBS`+Ti8R~oocl`5Cig2(>9S_(0wm+@XxtE^BGEAUB6dz!E}9pclnCbL9>1ZAPEBheAM< zzk2WJNB4vfSaEfTZJ-ztZ0x@3v8Uzo}G?x!ZWaIEtS)Kl=krEPp(sHX}^O7-Jr8 z?AEdSdDUOeEIi|OM=p?1shdv<@j=fbVe@Z>;om;c!H}x-kaTq^fbU?>Gt1unGn|4s z%5RQ*_Wp{Gl<*;)T_Psk2E$F{=Uyj4@H5fJ9=vSKKM*pmvkc_ zEP({Uw>)+c7A!`K3|F65Z&=L($1ga%596`F$onP^dcwpXWaIcg-#Xhdj~3ZD##3Ut zX^kiGbP$FIheeQ>=fOqH83`vBD}TjfHb&VQ2G}3weM~?-ypQS8bFLd1P~;K&>GgHx zcjhDmSGxE8j`2Y}R0#8DiH*a%Pi45+4#Q=&bFCe@?|;U=_Tf|~o*mC{AH>ar$2=Gh zVM5%_JH$JuC>#<1{XPZ}=T_tUeB(ZPq?v|z2fKu;C6I)JTy(c9sM!6j9GY}4zB~b*A4zn@7jpf6L;VYx7QYrN(mg| zeD|V_39aZl{{d$KNe-4|FZVHB-h%5WiOHX^qi1{jKYf_%q36E)|8ns0JZF;hZ=ZK! zz4nivuO+O)$GVbD??E~DJ9|*AOEqLYUk2P#eAkhxmSpvJxNOZ00uL%w%kaibteq9l z*J8urVVMET8L8w5*CTbC_;7w?oT;9HptlxdKj-c6}NTGSv{I_F8FS%=w$O#yD# zkAflQcW!^lO5u*nf@TxtOW&|dcp2Nczmgc zsH`83eA!5vo>bkcix(cMR*$S&Yfvr)7t+#mLP0m1qgNRs8lTQmDeDm5X#Roj7BD{J zIT92*;R-y;DntTwz4)@Q_*JXPpVgIQ4nEb+3N@-w_hjsnBsOZz$r@$WWUU@Et#h+h z*9Gd5s9IzYtv%OQ zOp}^vQgcmeu34CCx^hiduIb7(UAd+!*K`${u0pf0&@3!8^;*~BbATMAsn-fP*S2r2 z4c%NT;#^y`d8z3tH4&vI;)%(dYh9XGnwsjd;e)Rca*(DJv=y_9^G0(5pOF$a3~v$N zhN2*R+bOA_66QJuG*SVTYh|*QFcp7?2VG-On@42qFu+4{b*IQf3mzThL8Y?QN}Q%4 zw$VfC)WA|#+n9~Dsxg?L;Ut6iAy<9KC6{0_qdw&o^L-%4)fRR57$MX}y9)D{w2F^) zMxulJ#G`V_D44OSFs-MQ9fXPdh^vL5d&$3T#G#OfX^t120>Kmq)-UaT;gl!+D)}5E z-3kvw&n<8o%SJces+v3YJ*gSZCQ=sP`SDSs%P&m!J!Dq{Y)grinMU+W` zkC%ZjZprR@cw{3lfp}o2Csw_dB7o12QAn#GUE;&i6#qum+dLLpuz&2HH-)OGB@t3k2sNm}Q1D)8n$I^@6R&``gx%xV8}uME|Qt>Iv2w!`!W( z^q^xtw`D5EzA8 z0xAiR1jB}gHPh1G(jL^EEDI`tK{9e5jLC6$ek--zsNsTnPayR=qscbtftyF>>=bU9 z?vQ(AVWsKth|kHwTwrJBQ-3XtZ=y&eMC}3%dmLP zz2t0wH598<94rflO=syISMp0Z9#j#KXs4m93s1GNudB5@H1)tt^pAB5mR9T*;|oq% zz|d1kkEOH8 zvau*?>7;3DHH@%=enlRn=6=EkS5(nV zMQjtQ;Tl|Cd2yVpqUJ`8AuNYjEkk^f8(_VPF>BWbF>P)U(-!n*kOw^0t_{5ID1ohU zLEOVTbC}tn{v$TV(O#?2-oocw|9u)p|fBuEu#-%i{-@ zJR;7Q=p+%Wf^w|_5InS`P07jF!YWKc+&ru%eUU_z4X;mp+L|#!Ioz_@Sx7TCF3w)`EtyX+_UvXGLq9y;&Rm%Ofv*6 zNiIB(D`_Gu52#4M=hCLIPEkHf_HAP-s1DzJ4PikZ(urnEYf!+hrr(u?-9k7FRPg%Q zYSqECME)%~*UHYIsN$SwAHez@IY-T+4(8H>?>c^w+JRuA5ca?v(fzCQ2k-~{sH+F? z`mu=At){ON6GKb_Fo++>Fy8`ouq1QP6HYdjg=3@I7#2bY-YUj;Skb9?!E#ReMHo?I zkQhBos4+@Rz$yb)8?crHnl2@>6RD&f42}tSxy&FcGl+r{0s$)xm_dAI5D!NK0@i96 zoD8T=F5|N`zKqd_+~@-w6U-kjNr0}$w3J(tm#8lpFF`4$FH*YnWlRL_iy15e6&btq z16Y%y`kDt{32dipEsen~MHEbpVP}-s6uj=yrS2(4GoZ~&v%-61N06~gyaF&u607EL z*(D>Z${xm=)flBlkl}cZh(KkCXJzmnES3jACqab?V%+F);O$VFY)1IPPYmvUh0 zNAWlgRWo_v7mPf<5%-RSaKq+?jyWe8wmCR0cn_O`z7XXBjd~lvLt9b#Zb%`A;w{qQD1Ge+cb|QIctoLw*~>N?s$kjBGrXJBL=E8sL`NS zgE|FL!Rs|f+@LC%VciIU5KSIN>^)1L6ymyz8;v< zPDo^->Zq(bc=W%`{^}v6btajd2DE;BZ^0oId3C87T31iTu&%{51cGY#@|oAIuDp>l zNy<0bV_|F<+nE$Lk7c-6XX0j(hvgKuBu`&>WJTta_^vkUYM<5B=?_2&V6i3nGL2!8 z8AO9Dvg)hSDAv$dM;rhwxztR$8o5kVr1TX2i-IQH^ng;`sq|3FsF2#t!)rxo5DZFa zvsT4m_~(X~^d_K3c=6vuatVQOuiqe9z?E|Z2H=a8qB=@xSxHJCl&dMv1Vdrb6TB)rwP_*) z1S?;dCeI|H%H?xd9$2W#DYY2AZ}bZBl~rfM9yVIW-qHD$k~bZra~-p%4AK}!>5`znsp*?=4gkmYl;d!uXXp*ko{G4(CWHHMzh8=du~t9bT1{#=!4t~~ zXKe`PiYmJT{YwF*0Av|?K$GO~Sf*SRzEmMUYgsxA59tzQ87hn!=Uf5UO43jAoTh_V zvUf5(x=LkO|ddC+e&--X$STbtPY^^T3*aBhk$hGVAWam^W)C)N}%20XbgLaO6_b zluE2qhAEF{iR>N5Vx}f=})OfGJ#udV2TZ6hF zBEgUJU4fe3#9Q)@8}5g$@!A0abo_Nj#ukikB#kpaj3v-CCIb~w}zxZS8B#1)_JdRrZ3;* zchf3@V@A-Ba;LGv4qU@d29b zfH2I^g1NG+z=n5Oaw6*yLIqj^ueBkRwq--~8=f$!7$G_?tWCi_{ET?5@G5Y7m_XeG(4u66(aHn%#$#8nzO{_QW;Wg#Hf6unx7+Zv~q@guG0W$5+a$>S+SE#&f|8H@TB6^k%%k|2rx?7cA_{ zS)55qdzF^bGCvj;buBC;r@bjC>YATXN^_e5n203(mW))>E+pc$&Fd(o{}ql_F|Rg~ zwl7IlGtXN}dsUWJGe0&KbuBF{r@e_NZkwH9OMB&)+A=*x7In=ou>NmQQ`)P%)b_r( zzLh#F-?@72>=nHYP}32_GJi~k2eLEP<3_Pl6w7&H-5!TA^&VP-C&nBvFTx zZbj_3kY#!45?<3%(p*zrZ&TVFC4a7}zrgun%yqb*#p4$Mz=Fm!Zk*=|PqpX)i>Hw) z*S?lyy7inh^@j6ERiv(q)S)3qC%T-WiI)v|)8o(eZ04yLF7+iTgGy#BoM^xuY&tmP zEGcXv2hk21C%n}Ut?f1{qEFsk!WLh0NEr;r)cw*iOUDI*{0aU@P-K)em(jU z0%&O~0;4fwPAN|M(P|V@eMo8a1YVR~T1lY=ZP75#yIc z)=-zz0(+tBqv&DJ&6G=#lUY>b*+SB*NqrlbAx}}0L0g0)g+)>X!-oRS!_F6%TLxay zz_B=-Fh*%?J<`?WY6dB9`cUwbm{h)`E^BBmIg?sFm0Im}W!cia>@VeaE<|tQBJ)YW z=-U=il11_`BS*q~DF+lFfi7}2`*mxW#pndk#9RB`R_7#5=waYmldEzK+p#O>nrT@X z(Thdh!YttUgLd~!Bt<5e=t?d6_Ue>b84H}6xQ${UXs?F?C2BCz$%K0Y_=}~Y=r*-4 z=&=t^W|x>2NTlcbu&W0AiQ9r>xuMh2e_lov(gv2?NGNOS5_S@U4jEU7$pDAn6ig*Q z+D8?~Gna}^wWKtEK#k|hL8mHWa-bwpWuHe=z|Wz&Ok{!Q#WukA?fz6AYB+Z-e2`Is z^_hucRc-{PU`SQX&2T9|mQWLw1p_WvF~$Ulch&Zti`Za^Xis_((1s2)=7i8D)KW*w z?M9Iy=%aYC5!&ecV}8tI2S?3>{Ard_i(C)5j4`XRBJ_5*m#`MFI(kQ};H;M4CWYar zetYXlL3vTnfk?ur(eK1@C7jft>cm{gB8UlRCEcr)qe`9P>xAap%D7t*>&!UO&6HJW zOWpT|AA(oRWG5Zu+P4b#Pl)*+w9Pa34{7$mmhs^U1^qtu8<mcY-gg)WCvS34=J{ zuE?(;!IhSw;*lKWv4H^@a>E$56VH0SAGBCRj7T*m2tV9R>rx`aA=fsgYnvC``B8$)7a{E6IKvwAJATCD6%v1#ygR#sI#5{Kz`FXvAnD999eOrQ?PBQr{8u?d3UT zaJeqm^x2eJR_iJ{OkP0{3Ld=1er>CU@ZV-zXfmpX^57c6L|W5x!80V)giS+4@sp?Y z1N$4VqQFqa-ir}Ee2Z4InEVcah~_&(!U#$Uwv&N{#pv#ar=`V8HuWd%?cg%KrnU5p z1@}rNgE5Q>8XJ+RhBI`zlc>SuIyWjYvW!f^ylFB#yr6c9z7T>D9%d6OM2#lpPDh9l z=OY9v1XV(kgZWhjr)5^A^|VfeFcHm^Db`%3F={7F;%}(Ij+17en1yokEzO{+d8dmG z9~!6}{J_9%c3qpOoPLFl!FcMX1b-$pm|)q9?RlZkG4$$uBAY&g+F*qj<`%Me$Vd9_ ze>Ws&+k1Uelc@2J8YWpi_xD&0)yX=4X#>4HBS+%K?vw)jRGfx4g9z08M#=+(4^nNCA&-^hiUTd4R0jif4Fi2{{0$0 z%s7gRqW70z<|;fPClk4ApKWYVc;Rzn5E}xIPtl<6lK){kDJd|g*D4wR{?i5MALjaF zPN=k;3Jw*uXc1yzo>-Wgrikhrqs0#mMvhvb5F^5`0Tzq(t6rZzB#%xJ#U!jxN_}@igEFnvUJ!tSziSwTdfQ6GqdIUL_M(NTOST6i?3o)vi_#Ax z?3imtgEASE*DA+^9(JTKt3?4VDXd>gMui$wm?wG~+M)D7HtEOShan`droKd-hfDQ9Pu{ea9 zI`z!RB-R^vd%OlG3o7No9rC^XCA7YSVr`?&U*Cu^#tYh|q4O+5Awt9;f}eBOHlIdm z2C-6}3S?b+Rs@AO_v!X>rTmHDi&zV0a^?IIraS9-I0fR9Or`3%{QtK?z5- zu94}#F+l`i;`Fo&GAgE=)2w7cVo6r+9t^vx%HrQSQ z$KK=Fo3Z5q{)tVl)EIpNA^X+>U4PzCMASmChWk1v6p)xFBwI#YF@c{(()&jeE=Ljq zA_>nU=|Ov+r@NSgTea`IN!Ocv#Mk_x6Z?TkVNmXYqxwl|%F)$jkIF@aR{5qHFZj{EiagR9~`ZpBVI{f2n2wj4ub#xJwl7JQ0DGgLPKrhVc*2bN@9TvETK`mG$kOTCfq3q&K! zBe2U4oeYl+NFUFY%oxYGK>zGynibImjHTVa%WtIYiks)}7p#76Qr+J<{Z7rC^ z8PpcP6}&YGg}@LWNU}jla3zUEB4E|LE9r$izsPI}_F&j&>1^>#6{{NgVabXC;YF0 zhPL!`Nt(jEuEm?AbcITtbg5{86_Z%Js>N>U(lnrK&Pg@xO5(nAPP>$8GqI@s=f0^( zL|=Bt%flJTRC5CgTO|bRdxHy))a-XUe1pMVP{6MQu}-TBV$)5R5~utt?`7YW<2OE# z5{5GkJ2^qR+Pp%8!nq0oT^Pxj`R$^-4lPOAaEYF4VFl=bBEKWDhMFA0pEKRzXTI;V zimDc=0aZR;_+-R-q2`yhB2beu0W}^qRqS{>HBJoPbS6AVvj`D=&CvWIEdNPkEHMSe zbn-e4Gy%VwIDoQ{o3WP_<-R1UfFHbNoZ31XqkQem^z~cx?muh+KLM@3huO(z8d~9} zC4UfT^lIB~Pc;Im@tgcr#SPgF{EEGbk6*P9QTV$I+>4<^jw1bPiJq>v0NR)*$>CN<;0N_ zHYR+anbdrESw6~J=vb88m1H+;hj0e!M`=202a(2ZVv)GcXy2C)Rvy(bBcE}M6;_;a zA41z8$_nT{@QG3P#&F=kUqQ}S0SP*M;Kkv{9*<9$lE-%ra9kGOLlrMIGfTzs)Jrug zIdo)Otazrpa-3bYD;)l52Zf2&p3;6!XD5;{kBWa7n4bjEmVv@q*nMD;UmGTAfjXZ1 z0%@nGNW#%Typ1GfW#koQuKAB|C3kO``;_&>vT$kuC@~K*vOj9$$?)~v2rno~86ii5 zwd?FFaB4zoL{lObe$AwUn~G|F0S-KLUV@JoCT@6QPJihy)ebwF8`sbasY~!;P=`5} z-YVk#9opF_z@>2u_?9y>E^8pOMl@lOG6x}2IujBq4{YS&Ppb;ef{3X~CzF(1otJOhTa4EUZ2b>{?ESAEi?J^1;#HJ^I5^@C|t; zbY9a|&tNQ~`5PM89_&!404lcOFJzg4$N1j~4QObr&j?*Wted<;&A5Jx>$0FoW~I`=KksYVg;{Otv~Xo>iUld9Mi zU8nbvx1C>mw1@&!|!AnSm!L2u<$Au5<$J z$&*&Tqsg(yu3d4S1)|cloYJ?^)G_@V*r4us`MOWXYl7P_1fp&`)w3hNJ2+ za>LWD$o@1i3;|^mInWzKz$S8O=)ovN5~|_5Uf&$Tgu@$(-LL4TTl*5egg$an-KFdL zC-`2liCns&!W2&^b^!HK?^L)JG6N(}Ku>YgK4%~9Q4!JOJOmcUgw$jI16((lw`bK`B0Z~~=D?5!)@HTKxzO5kD=sR+RS`8OSeiw`;@#yr9D=-gM_0GQ+ zCnDT59DK=^0EzD?s>c^kg8_P)gHl>>lFfkOI8G`5$K@Ltdat|O{t4B|T|_ z>r@GH1`l`^>T{x%x*1pj3lY7N6jbvC(Ms-^kjn$?aa@s~vEnYdnOO#R%bNa=7!&VH zmO!;~5Nal}xDy$#{w2$RM3QCKjEbdGy&+NHUEiNl_dM{jH3EFOnk133QmKH+QV!}N zl<^}8)}FWtepF;B(4`_WNA=#0KSu*qrA1m}tQFRTJtchrHDS(R90$2d{zzbcDdL~R z>l?E6)Nbz2fdlH_NHH6MHj?JGdm7B`ZmS&XX!sarms!4Xz<5WK1rivXC1p}}1_|F% z{o73F)(wu4WelGUfCPTlbUi4wq$8kP5QmCL!!_@LKXC^lkaOyWODAUnG5KPha=-*G zn8{8gq@LSbckSEO7XvS^B!T(u-vdgruvgFN3x0Tqo_$Ef=P+wOL)U-_Yb6Q4PWLBR zBy%h88>XLv=o%w^EGh!eSC0{E=GZ$f@Tv|wRlcaA$UxvlFV{R!XQ1vH>sgqx{}8hl(na#h_P~!x`B#C z|5XfF;7vIrTIuN}r6CB*-45+uMU&rhogAva`Q^(&?PE>XetjjFY2GGF^cr6zQ%uhs zh%$d{&38BVLFb#06w z9u9wMA7$Tz{$qbEuZ1F_x^PWHLV&*KfiO2EJxn>gIs-~gzefT@1+!+&ml<3N-aHxe z!T6`YowYG(NXN83ummw_h{gV#jQr=AZ`(R+qu-#3afv3%?-Urx*kggm( zo&5gP{XVqv>;9VmzWx4~w|3{%{^b3F8)`X~g)t@QtegVmc0U!b8@QzMz}E}3KRRmD z2EeQPYkJ@NFkal*tFc=;&+B%UJ9&5hUf2%TRP>C$AMEY7zEy;OA40G0_;5$wvTNxV zcyqw?dL1*4R-v*yB`vo_X6=ZD?^<_!nRTew_hoaep6iRDy{46iuiHL1&B$sDqL(}1 z&o}Tdm=Npk^;cBmfA2lrK#k|L)%$iUdChQN&tlW5i{*+Y{)+wf8`^K{ka|nv&4(B? z$K$TJPI(=>9o-N6_J{whn-L*KM*n!aY(B_<+XN8pd1G#wG_~etZ0c4=d5QW|jHHPMc-FRE`DwmXpW0qKK$;@I3o@e1+AkBEjh&7qEb_P(>1 zD?c}Pk8Y?5t#`o|;%$wjU-Qwr5+zl!{jBYFAoMwxwIMrVBA@B)ulW&ag6n^G?F3^V z2!m6cZgEKIXq1w~dEYEmC33!?#b2K8Oz|1E-)gUn+8?F1FU^*R!Tp*lb>1G)>30;;zykI*7D!A46dqo>2i=AHd*-(ubj26Y@WXqsJl89)q|{g%EL zz74ER)E@9F<9+^$hlt$iE$e+;bD>n+A7|xKT}Ek^qp%jUg&=#=g0XT#iC1CwZl4`@ z>OFC}?nf$sGV`!1&P&O}JcD-rpr})^CoVeq-C33+JK>^{H-appHmMw4vYJ&Px+F)( zD}&#POXAU)!>Y5dRp9znZBoG~f~OBZ;%o8N!zr+TZ^I{t6dI?ELnrIsJpsG%`N1ve zP4jo{%5NE~-(Ys@vcvV|wm^wmt0--g7wYVXK5d2U$tnLF1rJS78itgjqK9!<6*#W; zM`Ii88flB#qc=k$*#nG*_-ib8lG4|Tzvx`RYT%BXwev_Eypq1rMf>RD2~JaIpVHX! zc{ceydZ?VAX0!L*SRy=|DimbGSVZ$_#9cLa5D6@ssOg&$qKY7n&}wo5j{ZBaqAY!w z87k{M^nCc4wPSTW%QJ8bA%R!ntNdpGLsy~&FunBudPv1z$_ON}CT&&&G>7wnC(Bp5 zOA(4J^-Bl~SBZhKavMt9)FRkgK*70; zMB^ta&rPX%R1^JMcL;HH;XI!b8Vfo5QNrp_q+S)b((MT5xiWmWHjObA^VU$A33U2)+;6A`0FY-;DogpQF3Ekiwe>T3AJg>V;3uF1ATJ$mbE z_(lOCt2x<3U812*H6r(a+2bC9!qXjmtAe zF8)0z55pE$A!HRG@R&>2s-2>sRMLeKwqLj!t>A#0pFiSifP)f{0DUKBLHCYHojzJk zUqWU*fJ-*MP*hw|w*ibcp?pM$x-RT4Ryo#ygd`M);0KUzkgFmnb9-1E1_&Tw^3#|j zr$x!2QY?vd!-NY8%RDzAsn^gZ&1UY|7b$C08zoHQE0K_mTFwZz;9ZBI0nL{7HZCP#<9Nh ziaia62{;kB@I7OzYLGKl`a%8-`8I-AAZPXioYZsYa+;Xbw6Lgxe`8zcu<$Ug=twjf z#sHUh#vuWcZ%byB;;L&1_l+skhjz#>Mpj&E_KCI^!OJbg`LreYj6Dn8{PlSf{K2zf zLf3LeBN*K70JN#9lf*Dru+KSh?wZ^=23M}|rG)`gAet~Mp<{YF)4$xfHw_upxt6m; z;b~H$w*?n!3{)^aD}_C)Od*au2q&l$WGn=Dtopi%k{e2oM-a3my3i8&HS}l0tM$q$ zG_g0x`}B(xDaNIo&`EhPBNXgA3$%0{5+*uXOelcO3PT1Yw1%JC5`0$MrzPj<#9_##+LJ2>e5TI+Tv2>ZDXJ_ zqOu>Clz7~``#4Q@uy>#OZMMM(v-7rJSMc1w4y&uF>3n7)xL|$QG$(k&4~fa=t1+9? zWo(HvQa_nCn_uLT*Ev1*@rYo$PNR>1FimICU6a(Hnx5R7!Rt=l6Ef*vo>@_!mq$%_ zw?^;fkm-_aL>?2MZ&)@!56OaIX)hFCH!0^|hu-oD>?aT#QrjK+8xX$7V$3bP+`Cj) zwpEZgjQG3+Qip}x9n>tz`mO<{{=wE0k^6G4EgddO(kU&5!8+*VX9vv)vY&g=<2Ez% zy2I<2Z}u-2eB3)9UR_Qmu32pEDomkK*;mE82kz~XVh8l6k?Z1}zg-Jnsz2>pXu2?u z0e;SxS<&}ED=%(or|*;5AFcL6TWv_g?`R?}yy5a0{Ip+HG90fC97qA?;G)5nUiMBX zy(e2P&y+?NUhJ+-cwEGt1DrbWh+L|W<~%YZZ1CRKvCiAAnSJZ}QQRMo3|TDwdme1U zu!vj;48?TM`nBy3%algeo0ozGdl9V>>zh5sPU4!-NQc#Rn|Z&a@{@Vp#ay}b*Fj1S-u0YTN&ZMr{90&&e1+E zFXQb|b`ZJx-DV*sH=lb3PF}Uxct(7hduX@nxAj7GWYnd9MG%S`n6Z2Y4fubIKhjYZ zsSY1+eg&UUPK}jd@o5J7R{C`7R*%$6`<%lcxxUv@Vgsr9~TV{0vY`<=o7FY!n4ss-p@hj zTX-=Mm}F~k&yLV@b6ONfqlrO<@N_vsc6W3FvO|FBVM4!aYntGxlZFo-9ol*N!#G4e z=O!pC6pIJz_4}t2jx4^wk5er2`>7Ys%37`c90!2trj{q7T1KH`;xZT`Y!C&4774h@ zD0ZG25?1Fx=$6y949(F5Q&88|1o2s_1^?}Q9rD&6aA|P;NsYc*I|?6NO3iZ5{W$Pi zQiW^a5W!9L6p;LCDdx%C@6Csi(2lRG7y5l==aLUpy0P)rdRyKpho62LfoNSN$n!u$ zb`z5Fz;jM=$mK8#verwP#|Y)`uy5)lB-*YF>f!?WKiV!-hd=FFr&v3Kskcnuu&y~g zy`mM}p^%wYiMBUBIgiwC)^8tpY?_IUded3n?X*X#6UodS^CD(xUA}1UMlEGp)Oq-} z_Zs&$*faVhg=@$00h+s%Vj}Mx=^8SE6zLIqJY#+Dxu6@$0zwr+W z4D=hdv=o8D!xxiV)L>|M^wjr)PdLwO_?a!wB?p+kDFk^(atKUXyZfa)DGkzF zA8o002X$zz{@QV((kyovNE%SG?r(KIlW3zub*1QM4gd8N)$^tOrM8u-P|n5Nbz;wLs@%-z{tKn z_SwVzd()OPAG~2Du_vJhY5(4Og%2q)j_D51DR$H5L3WZ@w_! zW#NLUt2=x?My)C6L@@Ucg!ow7c4qQ>D|?!!w+{+=<6`qVT1X2^+4RVoSL*LHUaS6w zwSW{xCH{$R&#)JFzSEXY{3iS6;FUft%?9WDDHmwz8{r3UKkf4Fh0wG2E~iVgI*)ZV z{KmvrUmkV<@f+%hlQC;+iBv@K<;uJzx4rGv3nC7T_;zFzV(xitk?fY@hk4sRYG^ea zZU3z1G7vy&h<-T$U>q`)!?COAbAfy6>f>{61|002_IWt$jSVh;(TRvp>!^NW_Z1Phr9KN4X7eP_bf3s(F{v(qQ@S`@7JoS(5?7=>xGZCfz3MGH z;T1W4Z`jMM$DjpgpI&1|4Ch2UQN4l#^MFwyhn?-*Kir! z>~Y$JEYrVILW|IgY}t%mmvj}CpV&^T3H@) zzrIVk(t7ib{xIwD;?#kqF|Y40urw?PCtfuRP<~|6)+z4CHcImM=^tEL?HVMGFtY6T zTFH^yXaD8vBJ+peT9E{njl@IJz<5G<2BnzPwL|~?7X-+Mq!fUq_4o4j_2(!ai4Q0O zhR}tZDa*%aRGz@)P(5Q3FU^<>euCTuXBws|eeD!oG5Tzoc->LJ+=+ll5pRw^$0zeA zFiz2g^3(3>DEpc&_^dv&xg@8}q)6H@=Nz zkluU38G+Y`5n>yh6Yc>0QU z+Awv4;F8VXqVVK*mCy=+>}NXBa^x*bq!SYt_e+2kIyoYIOL~>9UVJFeamu?E&s8c z0%pAmSVj2dFIShNCe~klB7qU_i`Alj+!IyZ zQ&|G~S(7~jz&eThLn6Wk?3B}%NwYe?aG!{x*?~U11>Un{NUkKLB@^pq;O4hh5VVK$ z)1ePic+6tiwf)S`Z-v0ekZwYStpNNJp}FX0VrB@W7`3=S`n0pYj7eq&AhVC|G`1C^ z%k}eQW8)w;;VZHR(XXITHc*_LI*QGS^sX|?Z+}-JFn8si74MpRzCnMCyQ2V~dv0QI zi8Y*#JJV5FY`GzU@ z2d)t+okz;iKtrhAd^u#TX+i@#2v;DEIS@$ z8oB_BLk|QjNR2X{w^O8(xpmC<`qcOdV-e6vYwd^ON73tL1&0TIzb~_hxX)Ku;ZMew zJ?r~g6HN^YZ+^%c&S~>6Iq;CTaY#gCheOS%?T_#r`MCd<{>XfPPr};bLaC3PxcCry6z3KJ0wP$XN@+255A7P^x!P zGV}3bhpi=piPEFzmm!Rn{1@Bg|O1M$u?X$+B>MH#6L7Z%E zBV9dEqc{J}v)dOfpM2-i0Pcif*lSJ*mw`DN<Vh87QA7u==jTPbBEce1`v`owDsHP@>y1qaS zP{V<|KM?cX2sJsybWG?b0JkT3V^$SxXJ+sUWT{U!{c@h(11(uK%821@C@-_DWx zVH{k8`4p$L4?P+a8eLL5juI2(!_o@%g-dT_nLTOvK|=JSAQ!$l&%y^pc{`i(kXk}= z#t9~V5f0tQyk)7ZJ%U*MjdcgY8fPC!LdijE8Z?FVQ|Okz|7HdBXPT^GRRAq4U;1S> z`Z+^-4*Dhsk9USKlc%7Blbht2{~2q1B7BuvwlMEqAf z0Qc8W)-5lk8>Zz4-tiFGF02X6axv4245vEkY=`Mdj!3hJmm$$8*5 z^F=J{g6fhFLhXeTvCAN#>Pf z5j3DaXvD13k^VtI^bepz~>kZ=)mKHUQI@2+#CQLECJ`JwZT*l#KfvcD_*e73UM zz5-k##znq#V~&KO0B6I4kpc7)B_L&1DI{eV8`jkHu%bb*P?iKIY97nNGl~n+unq8J zmX^J+PU_2{u?v_Eib*ga{3aw}$-)U|k3?;4YsKZ2!uA#R{Lry0a<$+PlX&dko zW$8+)@Y$W#f;8%h zn@Y7$Zf75J2)wO_m5Inuc)UZg7s>cC_jM2l0Spf`6z1d4*Z}&dvQ;(!%i1?BSG91} z%VJjDbDTK|oa_YmDHCg?u7-@_cUWJ*lzvZ&X%S?!%FbU&#HKwMr6DqX5yd6B#oJ(D znB=m69D25JLj7C|+&MH0JAHaM8E5kQRM;i(S!c^0A<>luIasTuB{8N{s74Qv2kvoxN0Vv%)U-5#~LnL}a9>_}`PW|D+tLn4RtNjF;9@T z!FXZ{=;+^tteJJY6={*gGvxQ5OnR(=%v?p^N$aI5&~mEl@E(|K@ht0M*b}$QRXC%E zE&2A665FdIYE=a_)Hi~TV_2H2ENRV(A>FR(<~KdThzeAN%%DclN~XsrE{(g6eTs1! zE$-U`QPYCvOyMQdGTrlYOJ4fvvkM3t>E(oq&nTJ9t_80BwGO5+qb`gdel=)CFoZix zAUV!pOr~-x$RiiBeuwvh3F40=-OcMy>i;;b7`tx-o1oH@0W&V08FX|ak792K8|(Fc z7f97e(2q+GXnjrzQ-Sh;OY`p!dz`n>99R%|0*#N=7sHO2Y;3;Yjrn>^C+Jv~fTA~Cu zYNAi!H9@TtAuFys7ak%p$`Y|DLXr1J1{Ssq9ZMenBMJIVV{K>b4-+0KZ1`&&qN5V8 z22my!wq?=4xKT|#U&R@`rqNg17)Ud|Y%WeQLWdrjRb^L{8M`5ozynEbh1sZ3xuwpx z5v-wP5_T)8Ran`^o^+a8PalXn&LRBIOt0rZ>UnJ}T_SWB7q0)J5Mb4;d$2&3EY!?z z%NksjH9CMH8oL_y2kA*eR23^7W665@p+QuzYQ}zcx<9|V%-}(i@ID3*lbK}9OB0dy)?4es z^_~=@)RJD}JJApULIn}tXj*(PHB9_3J_yup|GF;gt`F_L*@O_*deSD;&`4jW5b<93n9qX3c;YOpo8$Fh zJWO-VAx4hjKQj?{l!PG!US|Pg`Eo0y9SQyNu6(M2xIU0y?g}eiIAk;$SLEoFsB~UT z9Eg})L26z*OWE@kpB6uT|7egpbVl@+1S)Di#Hm~I#UPv@`68xq(pdzSP+Cx^E_p=6 z)ohgOMwcM{D0a!zxOx@<1#1eLhUKa2epSV7x}M>zi?oHNPMxRiPi?TM;X@JcVf&fHeCv=L5L##zeHhF~K$_2}BCux`gd1y6g~np7db8&y<8Fyp7;lAUue$4coyFig`71~y=+10 zFGbSFStspx&hubeY+#Q9qpE5s{0$Fh{YjX}KasjJGZIT#*~s0zz?x_3`B5Iv=14b8Un$9i>tW2sv+i%pqUIG{xOPv8GgXN{&!LgRvN zokzRTR?f(qSk5eA)!rMDuJ8+a13ekks`Pgp#HTA`xajaSizam6$kB9j^1x28P05Ml zXqUn~HStK_1bB{4hd(3R8mA<#dQ*P~x8x>hWEGj-zDOJt9Z{89zUE;~r|yL_F~U28iJ11repI zID3>OIdPcS0@NoilD#A=dc~#vL0!=wq>cXC!VY5YYQ&?oheq&-DA6HcjG-#>#u``k zWjoMRf6!1IVL*ssv-}G0Vj|KiiL8iB8qbU?-B*nyER7@SebOW>|8Wqeubpt6NOA)X zhcP0Dcx@03sT?sA>CM4L}i||O$KGf}rRkgn)&;rEDY#|IHF{U0#(4~`h;;Mil|1EjJ z-=u$GNU^GEjf6)eIjEv+o!&e!G_Sv5oiM(iiXqtyKy^sj1P(&<5OQBav*R9+xf1eX z&Rj%h*ERj{NYGwhxTE}D+K@W?`2YIWWwTvoPsd z$Y^asgR^=_TRZOHyVV0eq|-g9%PX-cZCPU2EPjo%3lmmjXt&bkfgwu4>LVr{Xotx| zv~USmu8*d5?Dh9!Vs*mBkO(7HhSA{FzC_I{IBKK_jDKd4D2;VlDpoh(;|PcYd34Tl)YYIzi6xoaI%nvsCjKFq(nz``S~}P-CrS-%RNvzFR6RnMadrA=WRc_T zLO4^BruOt@RXzZ{x=s0F2Mju3XfMa`Z@l3iaHlS)vTNa@^&WXs7e>B_z`WozlUm>@ zRW2+_^`rz=KuVn#=GQ9v4RVT|MyIi3&Rj_=LyJ};mWpPyZ#aU|@uTlTD|?Hx{^REQ zRUU9a&l*9^Xjf*cbQM%2CK+b>2i`9T9lLQ`Frog%>yTt#RKnVVc{=?)QHO$Yq(?r# zh~jJg<8CX{Ks;Xa`1~>oouvV4nnx{4L5Gqng6!EP3ro`B-XVw-^=F0SEd*2hzxh+q|7IJvq8r(lN!s^4L{cTc%|^PbTRyZ=!KPA93WM|$ zLqS_w9$+BLpE9KIUc2&Bdr1Dg7PKkH&R5OW`(6>Z*6)+eoTCV~vbR~w09!!S)CrF8 z4_?b+GP8~TRkxY6WpzEiELJuf_XL$QZ3rP?CG9fU>4qo`VEry3!^*|M??8!(OF%-mJpAY6mP|4U?S~l6T&{T8O?j=x z_f%n(tcyz}&*f7xnUrqquKL>N*MP$h&e>qxFO2V zUMB-4x4WdIq=ACM60G9+&Z({ROyL6L9*a0+kP)?6o^pP7LifAvJ^lNIG0+2$p{u#^ ze1(II^mWNTgmyASoD!@_5LkO{0|T6SqFXb${`wn7k7eKzC-3e1?@$wj8EjP>`S3cm z`p!+)^l_K}ommxs7RUqv3O+sX#d$Xs?7s^FVnK;a0hx&C%@#NWD%lu^3lp(K13nL_ zdHSbgDAO;*yx>!>W)MSNNXZO5e;k#r5B#=s6qQHAv+A?YT6oNaug_X=nCF%0?5fsQ zRj)OqI*f;hsL=e2IX3C#k+gw2^1@O(_p0v9&+u_#@tVXw6SfmOP1EdOA%2@|lx@dw zadtxu$mU$pDW4X~aX=|C=h2XvAU;|#Q$ieg+v9oj518-b=1w=4`dtNsqaIlB9R9QA zL5*)GUT~`VC-~d_b4inH1crk=Elseco^fxm1PlHF`Wj=!o>qF;!sHLv% zN4j&L9YnJu2f?Yt+3*U$D=L^!l#^BpC1B0TtK>i`0X1u3xZ}5r8ZZ zg@;aTgk+yG-z;JKg`=<}SEC8b)23VrNTx3J5)&E2myzd=zr}sGv>M^Ucj%I;@1krI}Azgu_f3sF94xHBWuzon; zd#8**l3|)q?^7|mHDc0}bi=(E@|0`&zmv?-PVq^*Yw~0<0$ssg87=qjrbqDJJx6a? z5FXv(acg%~%((aSL(B6Wjy6)NJ5tSt2nlux98wd%=r&DHOgedY+m6}O$)JyMz4dbN z?@m;Fwqil(Je^i05cfx@f{f+17a8xu8l3y?jL9nr6#$J3BX1FOYhVCNK}!<)@_9B7 zcRfgsZqp{7(|v>>e|ll*ZMkoWjSwn&xS=)7%lQ$PPQtPIs}CzD6OYBM;)|h=APa5y zfYSmu<9oEM!@hR1?btdGy=A-EL z0w1n-J|#Edxr5&nuN*KQ`lQ-_4xk44mf+b9M2U+$l--CMJ;p!S7lVLm1rKRU_3VM? zD2rkEJmH*d7N>AeA6ws`1>mYM&2XAJx*ZPLif}^((nYr%ChmZ!oIZOvSR`kIM6hAK zm9|vOfvPs38^+q+ZU|fJ`n;$qw z8O-?Bd;JKQ$VCO9!YEc6uu6Yg8^s!3tW}_5uR#Y5%9JPqRQJKV260eje%ok@!*x0@ zS!!}hgP~<^zzpt{l>yVNDvj|LQM3b6y68bkO>p7Ghn#^D-2j^ zz_hUD_K@VIL5@8hd1+8&2wYwo6qOc5jR9*ltlAqegY3%SVwHa{{OAD9jT!*wHn_0C zr44ROaNe5WVy}0@09KFR0GLUO3xK&WF#%Svg6t8droiq7+5!q(TZA@arA1TOn2hzJCdyV)9Co6GD*L?xx!GFvav%_I^S)|SbK=8VzU(FY7eHs4 zOSK^=-cL@EtzRDkur_I}t+8(`@ons}Z*r@wHTF$qQtLgFL&ppWZ%h*H`RMHn&ZX9x zcde}yT607z^{Uc?tyeh!KB$L+)L5#D+WxGu!Q9wX_|hh#JlJ4^8C98Ez~Xn>Ev=6# z3ut$&vI+OISyX{b!b_I=nX2omY_L55n6;NSlEjiVSKFl57IW*|E^AY)t^KOj?@pC~ z)^?9-oAY{SP=!>&QPruB8e5hH~jdDic%7$~2Q&1Yc^b<8r1%W;A`lPj1 zSJ`(BRtCE&gI$#|oRzjFYQCA@y%ZS?U#Ey|b(Qtrfa%R>T^n7gjjq(??{Ex*+$pdz znsxKd*fjpxtk~vb7Dr}sV5Ch|GBZjRJiWU3qBOp!3|MWz8k2@rm&}bG^QK9k0o}Kdpr2XBg^kIH>UvC$TG3%4Ye|A0rSOSzQ z|8$!!(-pjS0kcf2cTW6U|7-I0gF*>Fyp4ZrE=h^YlQs1;~i)F<);# zhyKYMKlUTseEnFv%j2u_S$BeSb-&#khO3xMx1OdeI<7lv-%gO$#B1u{i#~k39U*w# z)ju@=SFp_+y$i0`q>@QeGWXX$_|lLL!Bzh182ju#`*eBccshM_x8z_RTzdX~dVrgz zcgTi^I$!=VQ$L@)Yxv{#=`UsyeUJI*wpZSgb3^^M{i|KN6RoH2>e?p*@J~x}yS{b{ zW4se4$I&0#T`}AkVO7`g#OUPa))n6l{I^R#cDLl#{Wgc|x#>9$>u(;jUYtCl7ejaX z9J-|P6$oSn+%1^HA{rI?|EUd%YVU2w^CkoPl zOydzfV<=gZ6YgJs+`DmlEGU4(dc|H%W4t}4G$Z(J?w5Z4*{uV*9`rsYEtR~@J^l6= z$FCpr0C)JqxtG%E3f$1gi*Di_`01HJJMkF%KF%vREAVhf3JjZMofA$!ojq-vuezgr zfvNDrHFWxPY}>y2m-xq4Xr5mvoHJZ?hne&TbllwFU*QgSa-(t#4)FaPonr~xoizrI z_w&~_@LRb2O$Vc=#XtA&@BO?4m-c!5g6ndKeJ**oIkvwCAJN#$4ZL`^F$QjkXMgL! z*}E6oz#~xsab47Ru6I!wlCJ2_;QX~HHo5WaZ_yu$%4a`bJ8M~Sqc`}WNYkpnJ+4mt zpKtfETl?-25{7NK`(^Id?#D89_bBdBAmWpsIBomK+<$g&?_;+bYRn1u-1{qU){pnz zsU*-BZkW0UxL#2j9OG|Jprqn{pWK8MqWBJDyt)4VI6v-_7uCeakIp;&|Cn4)mQ-h) zi^&x#_VL55C6|qVZ};Bq1eV9xiCVWHBJ7(99AHBIOz1!6&Mjm=*2_G+OX{{WIY0S$ zb?(Qo_HXYU`iwB8 zua9x_7tj&~Uh8hk{P-B6OBT^$PHFW{VXqxLf=V6uqvOuo|D5~i#l9@vUmi{|8zT7G zUB+pBDA%>m&JB7e)+D!_4e*SI84#cSW{V{DCHg+y>J*3dM+Zxz`-m)9X?VbMa#3c% zXP9(i0y?n=Rf&&@!V(Nn(8Dv=7h`vtr$wy{FyG!z-A`w)J^WJ=`ia9W*bylb<2I~M z@D~2D6=b?7&HQnI#p=siNgb2VKfq6FeVlHc-`#ds-!)K+Z^7FvPW^GZIjajifq!@( z=IytbG~H=_cPrF986-F*+%N7^vytetPO4e@MXxjtynF>4loST9eRv*lX zL>fAL&CuPFFQhKSxm!BPMo^067E75P<_=1$M z*0*lKPDWgMgu_fYS0=Je@@6{qPGf9t+U6=YoqM+g32So-A}_>$a%$u5IsE6#!@rzN z;5T5vD;#uC;jZ1MYGOWl_EJUXfbf`&@9L&`ioIqvfNeHTOmp8mXAsMEbOXL^>{qC1 zfcZ7SDzOMncn24D@1OiXx|En^8z8vR7<*})Z7A#mUqsHR?))w;4n~ z%>DgwbY>LWF~BGmwmb9L; zFkri_3Oh3}X?Zf;ac~nKofd!l#fc&r6Dplcza9Bd(+xp!MkM%oo_;*1DKwij@X|XD zUxnnrs|!>Z(WnDG=lb^Slx!@Y)91GS$gxb{-oPkIij>Ij5*IJIk~xesQOD~*`4xNQ zUv3zB&11gPyS{{__3Y&fp|W?xObR8e!$1E@B*tCRjmIQ~i7ZfAX7 z`p-d_BWRPxl{GFv&IVjNz13eHcuqxh%ksG0zQQ2}VFx%bFFnh zuHAjKhHmXYCpjxtRL_ZnRAH|JoUm?F&)JR+411e2vQf;|2@bA*{Or!+pwHFQEua42 zjOy%lx`VmAT%6^7@79TTf1MuAgtzw9Zg=LU_Fb`s^7(Od&>wEIc<;mMYb12T#TS-| z4G%}o{I+zS=V7`eZhK}Ly5=Xm#D4q~yPu!Vu4S96Fa+CUl5mV6`u5!Z-2VM>0)_F> z!!GgEn`LJ-*`spiuQSLo%!@72W`S4-L>uDTEkcz5IM{P?t$=q5ZW213fxAY!t9}mf z#!Ofo@JzvtJdskeqBtb%O;2;*fAT&KH+Vhq;|^z)+$wkfyL@&RlR7uo!$1(V|K(=T zxUz9`s>w~`x7#LIQpi}Rv=SpP4VsW$P1?O}gHI5GJOCh*fLsC!2`D9?l7LzQ8UgU} z*eO5~=A%%tsRd9;jKA|G0Ds*pzw;Nq3Yt2?#k_(w^IFnrP_r9tL$%;&}B9lGH zC2w-cn_LcBF3HU$xw%OlW<``xNn$QZEF_7Aq{!dx%9;zww4#1B0;O!Clw2rfo2BGJ zDVr;$q?U4cO4;{P_PvsoRt?*q?I^C6y{M_kN%quo)N0vQU4Qd=V~%F~O2`jV862cC zI7nr1kjmg7mBB$OgM(BC2dNAWd%@yE&<(f(JNdtI9u^S39qNL3f4x! z#(-&BF?k5DuQY|Q%uHym>(q=cEA!i07c}M6dk(Qiyf>mC93PpmPFGqIDuc0=A&8Zx zueNNgke)-(Rdyt-c}oi9KPP z;+qNOp9)9{gq93hQ$(w6_U)8vP3_nkI;b^;6O-UF(sInqPE3g$v$A{8F71l|Rf@ZG^k}9VswT; z=cKr|`BU>d(I}>IW1r|wz zUh3fshCivSECjp5Ji@nslmVq;iPy=Z-Ns@-*dCCkD?OU{%7e6kE}yIgOgzhBTF@&~ zGAqr{N)K#hcB0m!SZglVTI6aia&@6WS_-`mS9H{pP0jwL3c&0!j9U%k>P|J#XhCT- zagCnPjh+y#9_Cgvt<|%k)f{Ro2`cuP2IU%5Xi%v^M)kD@H5#O*B&NsVApN1%v6vpm zn2|-MJE1jVq19WV)l`vdiVOW=p<668+X~&)LbI*XA6ELqN`KgD>RUxUrYoVx;sDGT zkWCCsN+Q1~EnsB;519qD>O5GVXM5`q6c7bF&;>~Ks%R~aWC7Ns&-wC+c|rsoIZ?Sg zh0KJaDf3)G$`{n-ugLZbDMRc;FY-n;(@KcnC5dV%-wH)NzM-a&N^-kkOx;->C>r@u z`D!%F>=mL^by#9j*1yxqQq7LiueC;-nCF^&UA-cY0Of z^OZ2FoBCCSN~W9E-~_Jbtj<xOe2R#;mcGe9sTM4SWKK^=WGT0w(K5o9SgA z{dy!uv(U2x-~9_%CJDzFF`|*eU|C_XEW{2FUpE$shGdrJD3=H0^J*_CuMP^LdKY(W z38Lm*RZ2*0l2sq1bkk47#NSE!Yg07+W-N&at5VU`sQxvoe~s#2qwA51qzNHx!tFnR zGT|uTUx}5Uv6Elqv=;ta2hfFd{QzL8Td4?0*HY^bTBG7jlb>Z8lqaVG*`2Il z4hI0KbOp85cEfr=y+r|Qk?YN7PC%}^kY|+M;6$-Y{&7$yx9Yc;pX>favpIGeg9q|2 z@rAJf=DH7s)vnhrqF^Oti;-36QIGk8jiDRiV42>X`98t0_r{#xmB(pev~`$fyKB&(_U!W zON-D_4{WK)EG4U3bXy{hZve{;SV3Vs>SG7k()gm{Uj#?Zo%Wt;(TypoKv7l8t1>D$ z82vy;Cl1T0)98*=N}SlBTw#1$8L(P-90IH{%C#DX4pkgx^dmF+kr`u@8KaQB7zM=E z#ux2(Ly;Ra=LXGq4kXqzRa;KEr?7=^@CBGL2D#CT+~BvL;;cUO)Df^ujVb!;r2Zrg z-lC^YS`J$DyWzDS_O|39_MqE5>%2-Xb-`ogE9aGnpf8t<%}T+&K1` zWg!g84Fj${7>>Gf&XvY6l!hpkt)Je*Msg99m5hK z3zfQhO_x`+@e$W9F-jQPd5G6)bjbKnVKC?vI0rzP2IT~_BM$ix&|Ln?#9MdWD zG>hP~>{&eY&(-$r?-FY7TbGCo=FWu6ueTM#ebM=Fp(!VJ=*%Dc`w-G)HVnE&SQ9`EnnI0?o& zBngJWF})8i@ps)kM;N|dyL3;88xUY;)qnp$?jIcL?fiKALw5?`$yfT{yT_Z88^~w0 zA%0?lRd@ux#z<)&{_1QVZarki+`yQiFf!!|X?#{mfQP>Q^0=MCYbb`k4JRChv|9)v zMm)D5;WsFfe+r$4htrsbllC9LlaRIB@YLmEbco@8cjM=Q>+mrtDZ9gK$oQr&_7uP= z`PBoWdpYy!;X2h_2Q3R*T%4ayZEf=XA4BNneCp2lLS6{mj3fR}6OuQ7K)?erF!RS( z$fxw)eZ_%E3A>WKuw-dO!*#P0E>FWhJ2?)`Uzc@;NG0(Z9c}fFna$xD>;`~`po*bO z%F!oeZ-HVD9P=~E0jdI`6?TsPulVHH6>u}{*yFSGG^yYPKfKEAwS>lm^-Lc^(9sq1 z!7hG0V>G4MgPo#!D-)w3g}y&N9?NRYqsKh?q_h7pI@aAbuc<$GUn8c5c|f*3�f% ziI4S9OiBuYJmBx&)()e*F^@PWUZ})_VUQI3u^Sw2!I(^*)0oEHDYNH*Z=wI|+kcy< z;a7w&-8H_H52Eh0=ZCN04;>VS_&XbT?vB&s-HRVsa{i3bqUgvSArskRLVw~L{?PR! zJWZd9O&)jlF(>0(KK6Y`-_7QugNNtS(PBIH@aZ5tT^wsWf{=*00@n}qbzR~X#(`Qd@ zJ+4+<>6=u2#}J(F?{Q0agbnkmia}t|KBUT9P?|n_S9d_iC}9a7r47(yw=Qgla~x)$ zW0!2mH&_y4x_WG=5NCmj+%Vs|Yrv4c(=i`H9#hoP$I(rkB*Ia&H?T2@BChYy^o$JK zz?(skctEG+hJQ}uJBHj&R=|b|353&fn!>FHLkEGgVHY^~Ek=gnvJ)^-);FEHlcgpf zPLZ0;8?aXk_y#7qD@SjK3n_y_PMf~PbmWOs9+RR!;e;O_{TsxV@)3Gc@kh^sHeub)8a5aM~v%0oBTQv-SW8I z5PDX;oV%EmFscj(3!5-9C$=2PH^XdlnBOqyA6e&}@SY`T3pX6an%gBj{4P_X^AMs3 z$K@D6A()@Dyg&i)&7~_bjsv%ZJ$PBCk*M78`G#~Hz;LQ`>de#m(syUNS4+HdQAT*({#AqqbgQi}o8EMF+u*q;!fq=$A68PY`|cv9R8rVxV@q z7273t(ay4!^@M_|b;HT+(PvR(1Y?B6aL7To5GUBgGt+t(7fpw|znxJo+4c}H!9rt0 z77$C&FN(|<`6)@!$!&Hw&V}tggmaHdCmjX(z}({a;Dmu06Vc15JNN1w>J(zS@fw%@ zOBxsu-#bLnB9Z~~PTENT#nY|-dFzLBh!4Kd0DBk|n#TvHaBUKty0v>A{j8mb6H?&M zasCy8+)mF4`T3hut3Um~$7#KbTe4Gs8ss{8>d$UB5SMGtr46Td3LZXp_u;%y^|ir6 zL?U9oCo7$9OoB4^*vKQ^BZqsSB!@*D%$l)Z40Y`=J{)XX{Wee_DcQ~tD1GTWuDrKM z>3?~2a!qmYkPbM_+DpIs^^~yXGMuIlB|w)L@{*A{Tp`mykN~04PC=7!l@-9r)?_N@ z0?VwEz>dss(q|_+J}o#~7eJA}Z7~bQ0(^qt? zu2j@rK~ru5%{cB0MbW0Zdz{d=WsA!O4fH2xfM|}M#;KaNHIm{?BKFdf1B;OC2@)*TVUfAVskOn-9Q!R8e`)AFh~9c~fJhA%eLV_s8e zHDa~UEl^w&9q>9~M)|PVlS~U_0d~7c49m;q5zG`hCjjOTj@WMWG=Dj>c~q+1gXIb- zEaubW*oiRGVjF&XoV&}H+G3m@aMPj>?@y0;#`9mItsVD1c3l_u)BcUIP8W^3J+HN!k-O)Ch`tijmqFmctw z1)4FofAHx?=*G{fj z=NMx)eSEuO-wCIm{TIKyBl}NOuddMcc8-bgx&IYSuQ1EcLkIVo=@dLI%4fGr?!UCP zeV%a7Bdn|q&JV~o=u9j=Yx#5gy)SNNl6?`I`wqT@h|6@0;^H4S*{Bpfaux>nIh{3x z#q&IUjjGfawTr2+%-!|srx>@c&Jfvj=Ig}SN#MdSIM4Ir8ZMLB=s#}9P9N+$@aNz~ zAWAWiorFI2BIX;RbM-NYJQCqy78=*!9Kc*L^fXWAj^-V zm|NF7&KNmJw{ze9oMxqVw-W`oJ;Bp}XbWzMnT9J9%Xu)afdNWaOHRI%*KzEW*stq6 zn7etO--#+zCtT#1I8HLBqdsPwB~gC)VRirO$yrdtG}AS=|255xG&g_kV;U8-ULaXu z+^CaT1aLT~IO`PxP|?l^?Y(cQ6tQ+OC~#u%YBRs#qAI2<)O$CR+aVe$E1f8&(*v4M zn#fz2eR3+kZyP|d8GMHnwbyCprowxFQ*zVT-fJo;3&52&he?ZjC`!uae zBEuzBGH#QB$`=-T+!!YdwWj@tzKI37L6q;#~xuV`-liT?z zO&E<(>((OT$aLq8{Q9=-3(){$M^}Ih%#2GA0HZ0wc69Gf%ud6SrU$bQ01G)NR=KDL zE9gCVkCLt5|IsEKeL%=yxSD?J)q?mwLryuLej}(VfQ9P`a;EzZH;w-C7^qqya<3i$ezPh_=gj)kg_%C zY;xxFF?ZnroXmI3vv_%|`sl~w(tS|as&8=1jqPJ&Hktdl$w>$8{B(f1^K2jEt|O@Z z4HlDOkh3L+Schpa(J_%jmee^;ls$&LibCvUc4#yVMF_6gB?DLDo3HESycyj!U ztIWq^9Ek~%)bj_78@v0xIDLtlA!?jF7oPM`9`)q(NOfh-UXxb)1i4IIAo zJ%NKd626#0DX*$7kXBfqUwN?fY}esKxYJ=+#$l@)EXc62Bpb_I&@;!d~xbv?zw(*FlX(oYbTxGF56m68JxJs)C}bKURP1+b6BOZ z8AZu$WvBToDZ{uxUWSj0zM>2VOKuk#a(R>MZU^eM;!f^)?(BW61 z^Bp8?7nA*UnYx*$fxArL>&?{sWg6qH3Le){?aKM)%Xa*X*g>zop#Y|oagE66G@^SO zQ47-*juE_j8{NveZ~Cufy#}T3gg3p5!F|SMO9VP-??jKVmw=1c(aFwo0AyJ_x=iPi z@>58p_`+G_@;DwlKG);ydxT>`t^yw)7yS_6@)*b0r4smX%Wcu~4#8RuZi4OCp8$q{Q?_NC>+p=qaPRnP{#pBbBEEqPnn-pNb zq}+UNX5}c~Eu?J;q|@y9x%(&X63K>fgFd_E^KsRuK3pa~!%2j2to!W0;O<2EQhvHM zrn^r<(|-=$hDz+=eI3Sg=l4=+zJ3nZBla~+_s=cgpl1u`ueOkAmU8AMOk23!;H@$I zt2Ei?t-IgJWeu5?pEvNCW`y0Qr?pHt_^*60rb~1(CyMsdEgiSzQ=U9Hp}Blcj)&SM z$DtO?$jDgTHo4!ToIZ>MM;JK&NdRFCQ;B3~bN*2XXe&QwUdL zJ1^^n-T-(ylnaw{#7uuwA&%V%gp!_}#*03{xo@Iwq{@UEm}7VTTCM~4`58NMs7w~d z7ybC`<@P9!=9W@kUzcxS`%Gs;*&7#+nnrMjvXSvfiAlnS+Kw-~t9oU-A=l~JfdS1e zSU_n<6o3YoY*7zWtOyGm&t!YJ;c~XwaI-FV#!kPw+Q2uwk|K&09=mkQDjtgf!Ee)l z*{Bo}fmbWyn$W>-le(-QJ9Ljo-bW)`cO84_)Him|csw7BsVGQ}>9^^|w?gU5e)B}Y zlagT9jD3GzdI+Bmu{NWE`|o=pO#2^4z*&kBn3*@a-2eza zLn0$S+|*sMgT=dCGCc;jp^}HSM8JnF-}Lq%jT<+Ju}}RF89juPVS$)<%9m!OM{FvQ z>z9B)`-(`h$1D%mtasU0xcIz|vhXKc(!F>@c24I(2_{5Biq=&WkLfZ~_uVuxrt2_s z&w~+OoCSYwYHW}2az3mLc$F2^=oFp@kY=Ch5|VZ3%X=)Q>L9iH zO^op49)u@aqUuOcYbtx?NA%gk#N9p;uWrBuieLO!YuH?3b}RzTmpsh zP8+Ukr`>Ycyt%p+>?wDJS9q@3FZ9Ab+MvnCyLwd#iKVyecfPD$qau}tUc`_?dQsr6 z9-jHs-_gx3F$2K%D_M_=YMS`NCKXSX4PSh7x8;U~%sQT?{Dz4~Y)Ou#H%=cLU`RGD~wY5Lzot_&%1Vxsu(4R$o#ra zDs0Wwb#|3n;o7AMPp;ms9dW=2S4lxJUc28W5Par=d_BFVcqj?@8ND;YqMan`1g3PS zB?}MlqM`!8^o*~{lg)MQPx13~w0?!hQeSF!<-pqM!T|^Js%L}rSA7oPpf?^RX9I!* znUrO4%fo~|S<`8WN&jiwzoOwaG67aU$+r$4F{|FejE)p|=S{{fV*n{aS zL_?SbYya|4H)9c0GiWU{$OsPdTK;8*W46W04{pTdGrGS_~Z<)E(9wYMH- zUx)MAX)lndRnPnlxr+5=cz?&0IhZI;=H9KRDw`~x@h&;tlEyg+yFPs?4q&+>1DJM| zPB&Y`H90JK@j^}9bW4A%#5mXSxf_!=OzPfkI^k2hw5|j6rXJ8i0!wH5tyx9<_D`1vgNblU4-|5&V9n ztqQo|6g~?FqHIeHMehAL_{l&GF~H1y$Iitz7*74vmH7i7uBMfC3O9mOIZx?6Fm6*F zaFIthSBSLmWJ5kJy){%zR2)>fg5xYX>txJ&+}5{KH~vKRDW~6tzf5-_*a)8cBkwtY zhjV8XP8?th@P>0%NVml84Mr!3KC-?HSaTyDHZr?__^1(nKR&fp=wMFjyd}azZlpQ( zz#uMh8Gks|pF2~ST#-K1ma4dQ*Y4A`q89EOSX=Uulv@oz#O^)Yio+T)t=?&d*S%cg zK&BIvTlW*x;w0qj*25d(3t#-5eB7{Ny6W5@;_D*T{|){*TnD50=QJ%Hy)M7?~qI!OKK*a2z}9$sSJn=)u#if4-%lW6^A;hk8&XMf_nL zb{MjEhzC0Ww;tnc@Zkeor51I(21rNN?B8ETg$Cg0DizdSSg<$bG)6Cea_{R=cg@M? zn>v&T@F{E_SpdB>DYimi*PdG`00mbk)7ioygC9GtWex)Oh+M}5P`qB3R~)WL43m5xAuWn5Xtqx9JmdHwy|uB`Ixr1GNyGnUWSlFh*`7=72|h z?=*~Ml9y*Nkv!tXm99tH$FUwq4)mE215&w3dc?Eyk1ohy15o!i9H7vtHFYGU6LSdy zjNYn-!`ZZ@apj_WR;0iI&7G1*#iR0gYM2})m^n9fi(X+Sc#nJy8+pp5gdzS@9KDJ& zG7n4UH^F>3?GSzD$OM}{`YVq%G5sYowfSoxN{ASXb0lw!Bw#Ibz3Xo|=x1hPw zpPkzJOkT|8L;zdLZI{^@bvhe%I(-d&V3x%c@0^l;am&vKH@&ABR%LYyJ%IzVe(gAg zZ96^Qo4{K|Kf`ebP&jMnTj^kcZ})YJUe|yfli^)3txKj`qYupvlHh%j;P!@4dh_-8P_HgLL8KtnVhfrdqP1JLK) zadB`M$9cVR4;vD?z|u((&^~Ghi*68csT=H)i>>hYkvIVx4qA6NNsUOT8#u5vNrbcG zCiF~*C!p5l-Q8)@Sxv8L{M1kP?$dqjI7999?)i9^ii)Reqp_*B&W^|rWe z#h?VsGh41k4jj+0S&zJ!!LoL`VE6{Q$tC!OpRolDJo)3t{sE&SPTl8zI|kvZ1LA#P z=rtl~4(gVjw_zEDu)&aX=QgP-!o?($vU#}5^Aeatr(Qb@0qOz9%|KlMF!E-MghKTD zUmmWaFSc!)y+zAx+Zpbpg1sFru(^v4USy_k(Nf0y{m?6Rn>wd+JWvjw>AUjWTUT2o{wL2M8Lw#7FZ?h+yHB;o>*=uTGJCao&7&)i+^;!M-9!6sX%Wm7#;z&B`AhOs+o3>Fxt>4O~uI~ZQ=&}%2y zPNgT9DER9dPRJ za-RZFYE(v{jFwWEf{8oCN+5Bt$m6c~!jO*k(hu)!O>|SLKXoFS7V~5wz72u({Z9z1 zbH~`wc$WYZ`UW1X#Lpxh;w(LE8fLXkp-%4XBxZqt$RATk+Czot(#<@EEMJxuB$(SK zOJsvw?;|FuQ;El@itOMEz-Qrw8D=2h?AMYG<8&}S{tEJ+-h+v!wHUikk~8yoxd4f7 zXz{W?4eoy1F|Y<*1tTV{l2LiUQ=n+-v1^LO?fFOHk{$an zjIiiJ9M5;P{aJ?T@oSQ*WG^iPzPTfw6UzYWZ8%tu()qbLaNXp5gyjlDm9Y)!a)ns4 z{49KN(&#iIc}qq2P7Vw1ab%~ScCrh^=2x0?1A2D@rt%$Pl61m``^`1=NiPjO)IPZP zxEHto5$_DIn-0CQ+`D-d4kgQWdZk_*EO*G5VXT`DF%SF=@N%D~TVi&c5*y1>W`{T} zJUqVMjOskV<7pn=cOfE%olUEkN3dHAqAxr)oufpnFdS;I!P!cinN&PVT2dY=0|?UC zwqY)sE&c4oL8=O=f#u?fWiYZlJVyLs3*0>fu}>!K;@Uoq)5E$Q*>FIY4lN8QjF6&b z%;C1U69doB0PtU&HRp{W-f8y-^~k47twU{IdwTy4s9X2j(A|F1>+ss$-ltK#f&eHE zx4yZh%5#lz3#^!K4n4mNIR(g1zSB-YsL1NSI!5&5EgVsN#2Ep7+*n;Jz4T7jGhkM| z>2zSoy`>bF&$ycWeTW6UtlfwdrvF5uG@9TGq$$xbRxsst2dq za_w>O6@rJYd>2UE~t~w_gv=EJ6vc{!4z-z4o`;*CNv6to^T@ zIwXRi8vVjwAFOZ)yI6aZE%3pXqL4}$K<~_cUoWnMX=9cIW_5-XCe4h!8&af5Vi~(B z9%5RM4ct21@f{<5=)OHPwqpa0dcgT*h|`c5{d=0M| zgBttk`5<)D&wdtercMFfc?9I213cVjWd;mBOndlqxP`@ucPs=YAcwkXb^JNZ6FTN5 z;Io57oBaIi*?ruMq<&Awwn=S#pIeqme^MEaW}h9}1GN9oFcHMc^n~<~+$sDC$svUc z5`QbB8}0TKugY(Yi>H)48f~#!pGx}XufVHb<6}7g?j^! z)5Kng(DisL5N=tGC9E>$nMq^uj4(?x0dXBaOvc8@E}0r^S; z?t8ir(4Y%F^pl_4L{gMwYW2hXAs-h!S#)0_yS;jA`Am19bcoy}eS??ddMCA^ zjC`#g-rvsp1Y<+J3huBWx2HqW>n$Q*o*ZoG6t$b&x@?$uddyDCJnGqt9NYj7VS|!o zwn0S#4uH`!(zItLtdMc5s9E@o7s)by9TiGfC`LyKQr~|$$?HFq-1lzaFJ1q@bE2^H zKQDb3&(u={N79u`3_MOPE(h*MZjU}M!w!Ur%<7Rn-$0GyY~-JAeWW!z8#8J(VNWXO8II+GW<|U zjvi_?1d=O_#j4ggigEC4FFQoZA%~Ap_VAKBn({2uwiJMKORh3QOtSi0S<711OJl5Z zQ-t$Oax~9PO7lV$EES}Z>oJLML)o9#b{ma4Z3Jj5+bQ;n7vBqVkx9woLs=9SZwhN> z7L^686^YIF6S!#gT;(*Cds%0BFq$rFTarsl>`TSU>cD5HeCkiEF({oY8x5eP?h9+9 z60&Jah!BRVH9Ao5t%0gDtEf4(YGZ2E{ z(uCS1HjTJI0hq<)R!=WZVtaTU#uw0;?p15j-xk({Z_D3nzcw0ZUY>sdRto-MpX;6< z{Q68zNdZ>c8LM})T*FNY2l5Lt+Xn%(5Knl+SuWs8xqSd&^K$-R0j>OF<+5zHm#tHZ zvxCuoW=yX`W>K0|j90SfS#4F+2!c3U{lX?|%1zGt#Y{nKL&y$(Rf(kXM*RXKV3`5S z4VWQA)}nDJYJ-EtH$%xrs@#i$9BV8Uhtd-HvaonoRu<1ni(xW|`UHtp2^v%pT_^Wx2`e z)`AxMOsb#43#>h%3op04e5pZ|1eN|+Ag$k}Z#hN*A&8vbcLAV~cL?!}0HD@X@1c!zZoYy@^%mfG;+I6klqBMN7ev5SbZsSB8u@$4-kbdqlJ|2LelED z&lJ!a%X#UIASx){@KNO?0|DG1Bj3H%BV*zKqLh#264~A;qS@{692Ri$1fVRXWR&eU zQdsTnndA58cxx=gqRK=IetT}40j1xi<6y`k^jmbwNWc9yI8-a1*HV`2?1c{Ud-6~z z;UulUP*WWdQZv<3YP>;6M#UR}Huq||B`p1JoRH!zLoo-uX-G@uq!D`pd*e`6RKDB? zmQ7_%w5B$pw39f#vZ>a{avF zDqg=#R0P;Ym2V~Fuvd`7cXb|A*ABUYC^`?df;4t3RAn@oBF@_j?C}vnma2Fqy*vOY ziD~@^lEs0&cyR?hm!gv|Kn^e0llG9m)K0Cs>1bbES+uwnAifK<{Y;+kx84{c6mh3_ zC{esz=i00^ACt3wWt9qCOvw{H^%j%_wk!^W5$wkm%J(X?3_uSypS*!(J%b0NysB%x ziA7nNmjTaSIC*S1XJUFh_7p)+(FbsJ9fwPJWYtwhPqO;OU?RE^BbXeo z9X4DHF=G5d1h&k>z5}+T?_t#lc(aA_$D1q^QtXX>_z6y!?#~v;!&`vmMxqfVK!u{g zD*(pKqOpi*EwSX|UGBAvd8;O}Ow?LtoMjSSkVd(%l;!Ktrhrx*^)^i0p!+(>#@m%> zv`l)_#{1Rp1k0=aOY!!Gj1T~SoPbiM#G|sLKR9pV{z_si!k!deg!Z~Jlw8#cdGZ!e z9HaP(08p0xt%a;fbYXo{z8uQhn!Nr3NX>hXZ1&#?RddjN_WN7`v*`5qK7!Fr#nmh` z3k$sXi9*cu2>Nxt=!YDM_Vq}#2dS=z9a{LSAv*G{HFxOwoGVhw=9`xi?eB=pm4e8v;|1c-m(CspwHQMaXyAvZBV3CAJzOLe7v+{PyL}>D+$1@ zIHl6BbKyBw>>V#tND}GutiYkroanbpJwU3BOGs>@Bv{1ujk5b~- zCQ{ZfJxr1WOa1D@Dp5k_*BJ(>U-Y1C3=u+98;RSAK30mS)j>fr)@{aa^g2iq=hqoE zKl$KRQgHoJ^I{8>BV7kBMq_moPufH?_`N1H-Z-iN$Iqx31{iGILm6uvr^2!=`bUSAsH}-(>Laj}R z$9mnC#ECOe(%?;YPHj=hVHWt(V3^m9jLxFruQeU0+*^z&53ED{3q25}5<~t)VUSmr zLR`#vCQz_qBcMpFt;!olzxt+qJtDq@SFo%XUc-JrcvbN-G7DhK@1|qL`L*C?X_DNj1VRU5RDq`Zv|U%cxiq!e2E%$;go*kXbTFSSkB zNl!C`qO6PhcWi{(t4J{zMf(ls%e@j=uRckCy#}}ufrJcZbu_c0J@Z_0o@1Fu9k0R z(GxKF51^|bs{&8lV~nFXXf*B~lq+OlJOI{$;<&BG;p0uUTmzyrV2VvCYWJ%NiR(o- zWKE{~BK;*A<{80@)<$>m#+tR#zVLi$^8Kq^fr4{+W;ErEQ_e2LR3gY3z-rnLCKY+} zEf4e7XuJ@llU@}E#nmFy1O@*!BwzHnuw3w`tSR;dv3=)@6H8Y~TM&`AZ z`Ag1~g;7OmR8a{r6zE!UESw!ju9mHsCuF=BO%CM(w14&YimJ|DidUU~E3LKBuey{`Kmk~NCx6XeR}&s<@`CLv{m>o^Znn7=VSj8& zB(tp~%h4H`#T^1@WsP)7JD|YQ0)#IeP^`{!}En@_?(6G5=kgjOO*)52C&*>=%etFD`mkJD$!riPp4W8YzzyMNmfoOTr)MLdUoB!C|PT?)_mwys->M(6wYHa6z&*zO}Db)4<7{;ro z5MXmR#u;*)hs`AU!-PS}rrUJ;=tlpzA)NC4vA> z5cEKx8|$%O-a_O)_H;Dly&}|&-52lbzYi;9{v?h&r?H0+*dbj+s4HfSO`~y|#_8k6 z1?DtO_YhDzf*$jFnI233=*8G}e& z2AE5Ke5|+bx&>$%`{CoV4a)jk4`u%oLm5&+vj1BM$^UO51cL~^B!HGJ&9byahi58Z zKnbQhURa6a@0K|IZiy{Ur;QrCllFY_oAe1E&L#Zs7kE`-{z}=Lf`;6YdC+eeoiI9Q z_p4VGOvC)+mg?MJ6~uGA{|{;>o>BGZ*=vbEPN(iN-Bz#k-@9Rc?|$Xe^L=>tVc!3U zd3iV3ysQ+q`3tN* zaagy!8jw$P(H$PAWliH0*Yfdk?0sf~z_)Anaht}WV>Dd5drU!`ET8LecJoM2;D`#m z0_S~Cx`$()TgE~s2xF2ECddvImcj70`MOai8=c%Vh_RhxAFq%3e(^?F66{<5bi?w~ z2L%3y^b#E4TWsfkS!Mlzvqb$i&5-1?m!{<{B!l=nXJkUw$6)0I+*=RPx}|C6QIGl9 zp$&!#E}%dsdi&SgM;<7Zq^TE!apk_6x=%Hr(|CS+k698U1Ml6~t;4P7LwWCJnBXDB zVaz>`1zE*+R{>_LA-slU@K>K-ow9A7x-Ao36b3p5^K=|ij<6*pt-aoQZ5=~G)vq*S zl*bFi^*+DS){EOHK$||t52ejv+%95H*~59Jsg%Kz6Efn?Af|bQtb;EkgXcJmgW$oY zRBWj41b__tR`!X)O^cu~i8Ot@8(SVbXB+ig^(<@m9+Qj(mEjufN8V}Js5~*Eel+D5 zKaZj+r0R2ZEx#P3_3;6U^(@ro`dCigJk6aPy7lX$n^$(b7AT9^fWorqC5vqnHhR-1 zDNSN*V@GA%<6}+=D#Fk1;6@M!lQhI8JJGYNK{?eR?{ zn~GUc;Y(XG>$BiT#)0S#g663Q;S*Q}Dm19nAb&QIb_D=yH7wJ$WyYr%O_UbPHMJOl zj)7QZJAtfq`9_0u+xQrRfof*kbJm92+(d>zH+ncQP>TfS7O=E{l?ALVV51n07pel5 z8!)RCJbUK_c^7vAjSt^=J-NjJ?*Svewy4OpxZzc$C|DHVl7?s499D@>qTnlAmwVHa9fU)MP#U7D;LIVxr6Q3LmeuBGx!vL1ZUntE7@2uv z)_@OgDG5bxm$ci$x4Eo>$}Cyxrb@fcvNkC|PXg90%oAV_O=S;fz5jj;@XZ$kaW7Y0 zrA;wL*tcb{$yGCrZn-JH>0nc-o+xIvw&=97^%4W?5L*~vKJK`20PD|du@r{c>=Fbc zX8U7Tgf#}NO=rIzf?;y}$t{9PN(92%crxk%*+JIiEdp-=BcxDejH=O-mXyl(qA?8p z#aQKSIYxMH)b3aQe~8iL1h&;*c-xIPR^;Shm|Eh1D>R>rl8>Vws~znVdM@EHJY`#9 z3|yfGu2ch1<_c2i?qOaL);;fWl$a!X=+}ErkYDe4)m3Jc%lXF1=3!+J>P-R;^SXo$ zdVR21jW|Ofyl$cVZWJf+$!ECEh`Ucfi5bcn>D7mTRho$Sv@=LUYY1H{#}gwdv8oPS zWgx6#n`VI2Myck71Rl_3h_D$0Ch-amhnKSgVtys$+hWz}v00;>RdFFF4p3BY-knCF(F{Joc2nEgpfa!Ss7O=?BO+F zbsdF*lnPQQNTVPvRX~g{8PP2WQ0qNL@!@^R<)cr4g=}?6_%Rkvx^qTR2@4e#5>LPg zYcY1t3tXNIG@((lLcH=I!ikZ05{~t@kfWPnaL!nqbD}&5D={lGtLb#84MlR;{b1V6|1c$OZVW!PO2pi8Swd~%X5 zO&IaGXX5R6+k=L5mjw>$t_q-;)xOu6)Uk~b;7`U?RgG0uVK#tIm|s&{6jl>OWmJZd zL)f}ihOncgRIVW01u0&=07`~rN((Y3ZAmz+0FgDj#Q{i@R&=(LFGCnoY@b9_AetXT zpU@gH(iepk3L@2Lc~F#PynR|YGgyj|1Z*lX2A5zMXDU9i3RXAuFC#58vw%|&JnU1I zx$1acDo9Pb=|SFB=9QzZY;mI~jN+xPwHCG;1`nZCm5RKw)|Hp7s=QL0UyR7XzpRAd zQhBS4mTJY)+8FveSFjMEBlWi0R|--qNW(6o9ps?uIb;f=>d6kW9+W^@rc{tdLNHt# zg{anZRdcRt&J~5ZZmX4$Le*R-9xAD-3RO?Jr#gmmrBr+>RsYIdK@?q;st0d!D5O@9 zR!CQfGeFAJs^*X^kU$D{!$s>;RMbks@K~2ZRGC)M)ha4l)mBUTCzKW5ms_h#z0c(I z#&8N8RM5&fN+}|mf8!&yCi7$D{?Rbe- z$D&xtiK2(`Wuv_SO1xL*wHRl|_{dh2s|;9eeB0`B#*s0;jg9yHocO}%u5pfRTrHP) zwJaKIjB*B{r6CWcL5rXLyp6InUXwWH z&bCn!T;Y`|zCa<*RFc=9E0piKBOri6cvAlX0y9z$-?bnnw~80^xfH@m-BiWC<@MIO zcw;ry8o(IymjSJg6}6_+n~VZh3R3X_4`M6hi$-<8?_EeI{mz8~GXBnteO~Lq!pQik zf?9DIGmiv!NB|TPPzpevOF*HX_W-n#=ZO{Oj8F%V>{SR9Yxps9Jb0|a&5Vi41YM|) zphOHnoSbgynOcQ8$-_HA*_Wc`t)|B@fNX^OKfu~lorjcrL0x&!{E1Jgb?L(jlPadD&zTgRjk= z1lc8hX`~o8EE=yEPJe)!O5^LEF>1E|!M<6#zuHR@CJQ)R_44_J>D7+D zp#)^3dEIDswFh&g{22)^X)&yoGN@~+tj{!Yc!fq^_Bf#5P=vLFy{ZE2A-$(Gu(?%X zsdRRZ=L-y$@5K|L_-uv^OdjmQ_=1Eiym@X_+^{#G0BO~WF$eX=0;|rO3!%GIspOcE zJ1$&wIU`F}&XPOGzTj~KSuOLTRTCojc0sg}*ea5Hr9=bq4-r>cBaGfv6>JTl z-y)eTsSEygD$$f)@nFe{k0BkLx_)J^F2U6Z*&=NLM5=|O9h+u zn6E~Bf-wMD!H{VwNF5hlOga-fXUlhSD7nu(?zi1eFDOGbx@w zM!^(AmQw#kP>h~MKpCG6lA0>lpBR&*jM1&hyG;5~%vM7{nFbXaRDMrjOi`0Q!7Uz9 zWMz{v2GVIID{M+ulviUkR%=l6YP3AQ-J1j79&8#nY!wo}5gmb98OTPG8FF~2WsxVaoFP|BdWch3B3-66zA#0;Hqt)DKHLuKyxxZS4_rx!dUVHLQ6^6Sf zETQM56$Yjk-&C0etPNmoHCa~{u(5!x0c`d!55jk6Q<*)ehoXiu15+FDVW75N|81 zUe_C*zY$0)A-I&7-5U{;JT_6MNQmV1}YkV zRF+=uBeXD~wFzwuit!enwsgo8#B2p0Q%Y!Uw$jKtv)n3|=VE;EI-Gu)tGbF(L24c~ z2{1cFYe~4QW=vCNwp55Q#rx>cmTaRct$tP&=N*$NKOEEP5*Kk`qAC}Mu_J`x_>2eF9 z^tuJ3>-}4|&eN?+z_%D=N26kt6^inK3g3X{%Y@M{-0y#!d^qIOrT^-0K7`F5x;exw z4dAyBe8<9tC>#v;_;_|0F7c0Jha5OfXCG<|6Cs>2NHg3lOt+u??30(AAIHhZe)@;G zzfOKUfr7VgJa)IOiof;00^&AI$GP*-PyQ|Z2ISA~w%}MsZht_~6-QEH;(v2;72Iqh zHu8~SAztFdy9Ka$^6{i-KRzaisN?vJam%(pV+=7Lp(oMsr)S@^z&`; zl9Y(qVu*RF-EPrG34`b{=P2i+93mZm} zBV9+Q+aLzg$8@shvd6p{i{){16`y)Gs;iqO%h>n#5aGT1^y34up{~C$g4cz07pd?7o?z%iyRC=7_TQs6qI_i1pyEQ}$N)!1x+|H-~ zoSoCpKf4vfH3e4gB(cwFPEm&vC%&9KPEQ{&_qNsIRsx7)7KL;drZ@>ORMu#$Mpif3 zKIHI=vjAYF0jmvIt6?D%8G{|TPyi^?pxpSfFkq$rw9z1gw#uNbGWze0RaTupo!}td zg<5~u81*-q0i&Pp$Sek2A*((3eH9;SK;ZQ<3f+!ym{Al^RrQP|GN#qL$~HB219R zK)UH%H|=*ivMWW(ue7N}t~+;ZopGfx|EK^SEMR5<3%dS0KxWmK@%nb`1FKp*^|C*x ztPdIkh&P;k=>WLZz1*S$V|Ap){m95tB~dy8SL{U%h1EFvO_8MxfVoY6DXPRf1Sa~W zF&*&nm4M|2tT14u0jo4DGuq8`8+htUtaE@B2CUUE-$sD(MFC&M@DDI-!7J5=Z(_tZ zm1u-%IeauDfsIj5OScL;f%W1+HWC2Kxa}jp$aNcq!MLI_Um1DL56Gu0x7^NaQ>fJ&>bngMsbk~USl)4U+J|fn4m+&7ST`0hn9Bk^z}@h?$a9?S7!+aSA{(yI`&g7IwkX23P9)T0t87 z(^eKN4o3G2n{dosw7XYwYU&{ah literal 0 HcmV?d00001 diff --git a/efile_app/efile/management/commands/refresh_filing_code_index.py b/efile_app/efile/management/commands/refresh_filing_code_index.py index fbf4f00b..8e87e88f 100644 --- a/efile_app/efile/management/commands/refresh_filing_code_index.py +++ b/efile_app/efile/management/commands/refresh_filing_code_index.py @@ -1,16 +1,34 @@ """Build complete code snapshots outside the request and release paths.""" import time +from datetime import timedelta from pathlib import Path +from zoneinfo import ZoneInfo import requests +from django.conf import settings from django.core.management.base import BaseCommand, CommandError from django.db import DatabaseError, close_old_connections from django.test.utils import override_settings from django.utils import timezone +from efile.models import FilingCodeJob +from efile.services.filing_code_copy import rebuild, run_jobs from efile.services.filing_code_search import current_index, refresh_index, rules +# How often the daily worker checks for staff-requested jobs between runs. +JOB_POLL_SECONDS = 30 + + +def next_daily_run(now, at, zone): + """The next moment at ``at`` (HH:MM) in ``zone`` after ``now``.""" + hour, minute = (int(part) for part in at.split(":")) + local = now.astimezone(ZoneInfo(zone)) + run = local.replace(hour=hour, minute=minute, second=0, microsecond=0) + if run <= local: + run = (local + timedelta(days=1)).replace(hour=hour, minute=minute, second=0, microsecond=0) + return run + class Command(BaseCommand): help = "Sync changed court code exports, preserving previous snapshots on failure." @@ -18,6 +36,17 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument("--jurisdiction", choices=rules()[0]["jurisdictions"]) parser.add_argument("--interval", type=int, default=0, help="Repeat after this many seconds (0: run once).") + parser.add_argument( + "--daily", + action="store_true", + help="Run as the code index worker: sync once a day at FILING_CODE_SYNC_TIME " + "(FILING_CODE_SYNC_TIMEZONE), and run resync/rebuild jobs staff queue meanwhile.", + ) + parser.add_argument( + "--rebuild", + action="store_true", + help="Rebuild the search index from the local copy of the EFSP codes; contacts nothing.", + ) parser.add_argument("--retry-interval", type=int, default=60, help="Seconds before retrying failed states.") parser.add_argument( "--legacy-crawl", action="store_true", help="Explicitly use the expensive full list-API crawler." @@ -48,9 +77,15 @@ def handle(self, *args, **options): raise CommandError("Retry interval must be positive") if options["court"] and not options["jurisdiction"]: raise CommandError("--court needs --jurisdiction") - if (options["court"] or options["dry_run"]) and interval: - raise CommandError("--court and --dry-run run once; drop --interval") + if (options["court"] or options["dry_run"]) and (interval or options["daily"]): + raise CommandError("--court and --dry-run run once; drop --interval and --daily") + if options["daily"] and interval: + raise CommandError("Choose --daily or --interval") jurisdictions = [options["jurisdiction"]] if options["jurisdiction"] else rules()[0]["jurisdictions"] + if options["rebuild"]: + return self.rebuild(jurisdictions) + if options["daily"]: + return self.daily(jurisdictions, options) while True: failures = [] # The worker sleeps for hours between passes; a connection the @@ -91,3 +126,63 @@ def handle(self, *args, **options): if failures: self.stderr.write(f"Retrying failed states in {delay} seconds.") time.sleep(delay) + + def sync(self, jurisdictions, options): + failures = [] + close_old_connections() + for jurisdiction in jurisdictions: + try: + with override_settings(DEBUG=False): + count = refresh_index( + jurisdiction, + cache_dir=options["cache_dir"], + progress=(lambda court, state=jurisdiction: self.stdout.write(f"{state}: {court}")) + if options["verbosity"] > 1 + else None, + **({"transport": "legacy"} if options["legacy_crawl"] else {}), + ) + self.stdout.write(self.style.SUCCESS(f"{jurisdiction}: synchronized {count} changed filing paths")) + except (requests.RequestException, ValueError, DatabaseError, OSError) as error: + # psycopg's errors for the EFSP database are DatabaseErrors too. + failures.append(jurisdiction) + self.stderr.write(f"{jurisdiction}: refresh failed; previous index kept. {error}") + return failures + + def rebuild(self, jurisdictions): + failures = [] + for jurisdiction in jurisdictions: + try: + with override_settings(DEBUG=False): + count = rebuild( + jurisdiction, progress=lambda line, state=jurisdiction: self.stdout.write(f"{state}: {line}") + ) + self.stdout.write(self.style.SUCCESS(f"{jurisdiction}: rebuilt {count} filing paths")) + except (ValueError, DatabaseError) as error: + failures.append(jurisdiction) + self.stderr.write(f"{jurisdiction}: rebuild failed; previous index kept. {error}") + if failures: + raise CommandError("Rebuild failed: " + ", ".join(failures)) + + def daily(self, jurisdictions, options): + at, zone = settings.FILING_CODE_SYNC_TIME, settings.FILING_CODE_SYNC_TIMEZONE + # A job a dead worker left running would otherwise stay "running" forever. + FilingCodeJob.objects.filter(status=FilingCodeJob.Status.RUNNING).update( + status=FilingCodeJob.Status.FAILED, finished_at=timezone.now(), message="Interrupted by a worker restart." + ) + # A fresh deployment, or one whose search rules changed, has nothing to + # search until the first sync: don't wait for tonight's. + pending = [j for j in jurisdictions if current_index(j) is None] + due = timezone.now() if pending else next_daily_run(timezone.now(), at, zone) + while True: + self.stdout.write(f"Next code sync at {due.astimezone(ZoneInfo(zone)):%Y-%m-%d %H:%M %Z}.") + while timezone.now() < due: + close_old_connections() + run_jobs(jurisdictions, log=self.stdout.write) + time.sleep(min(JOB_POLL_SECONDS, max(1, (due - timezone.now()).total_seconds()))) + # Failed states are retried alone, then the schedule returns to daily. + pending = self.sync(pending or jurisdictions, options) + if pending: + self.stderr.write(f"Retrying failed states in {options['retry_interval']} seconds.") + due = timezone.now() + timedelta(seconds=options["retry_interval"]) + else: + due = next_daily_run(timezone.now(), at, zone) diff --git a/efile_app/efile/migrations/0036_user_recent_case_zips.py b/efile_app/efile/migrations/0036_user_recent_case_zips.py new file mode 100644 index 00000000..23ca536a --- /dev/null +++ b/efile_app/efile/migrations/0036_user_recent_case_zips.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.17 on 2026-10-06 19:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('efile', '0035_incremental_filing_catalog'), + ] + + operations = [ + migrations.AddField( + model_name='userprofile', + name='recent_case_zips', + field=models.JSONField(blank=True, default=list), + ), + ] diff --git a/efile_app/efile/migrations/0037_efsp_code_copies_and_jobs.py b/efile_app/efile/migrations/0037_efsp_code_copies_and_jobs.py new file mode 100644 index 00000000..a872ebda --- /dev/null +++ b/efile_app/efile/migrations/0037_efsp_code_copies_and_jobs.py @@ -0,0 +1,48 @@ +# Generated by Django 5.2.17 on 2026-10-07 00:45 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('efile', '0036_user_recent_case_zips'), + ] + + operations = [ + migrations.CreateModel( + name='FilingCodeCourtCatalog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('jurisdiction', models.CharField(max_length=20)), + ('code', models.CharField(max_length=100)), + ('name', models.CharField(max_length=255)), + ('revision', models.CharField(max_length=64)), + ('data', models.JSONField()), + ('filing_count', models.PositiveIntegerField(default=0)), + ('copied_at', models.DateTimeField()), + ], + options={ + 'constraints': [models.UniqueConstraint(fields=('jurisdiction', 'code'), name='unique_court_catalog_per_jurisdiction')], + }, + ), + migrations.CreateModel( + name='FilingCodeJob', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('kind', models.CharField(choices=[('resync', 'Resync codes'), ('rebuild', 'Rebuild code search indexes')], max_length=20)), + ('jurisdiction', models.CharField(blank=True, max_length=20)), + ('status', models.CharField(choices=[('queued', 'Queued'), ('running', 'Running'), ('succeeded', 'Succeeded'), ('failed', 'Failed')], default='queued', max_length=20)), + ('requested_at', models.DateTimeField(auto_now_add=True)), + ('started_at', models.DateTimeField(blank=True, null=True)), + ('finished_at', models.DateTimeField(blank=True, null=True)), + ('message', models.TextField(blank=True)), + ('requested_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-requested_at'], + }, + ), + ] diff --git a/efile_app/efile/models.py b/efile_app/efile/models.py index bdfc096e..5984df40 100644 --- a/efile_app/efile/models.py +++ b/efile_app/efile/models.py @@ -39,6 +39,57 @@ class Meta: indexes = [models.Index(models.F("index"), CourtCode("court"), name="filing_path_court")] +class FilingCodeCourtCatalog(models.Model): + """One court's filing code tables, copied from the EFSP codes database. + + The search index is built from these copies, so rebuilding it (after a + search-rules change, say) never touches the EFSP. ``revision`` is the + EFSP's own: it changes when the court's name or code lists change. + """ + + jurisdiction = models.CharField(max_length=20) + code = models.CharField(max_length=100) + name = models.CharField(max_length=255) + revision = models.CharField(max_length=64) + data = models.JSONField() + filing_count = models.PositiveIntegerField(default=0) + copied_at = models.DateTimeField() + + class Meta: + constraints = [ + models.UniqueConstraint(fields=["jurisdiction", "code"], name="unique_court_catalog_per_jurisdiction") + ] + + +class FilingCodeJob(models.Model): + """A staff request to resync codes or rebuild indexes, run by the code index worker.""" + + class Kind(models.TextChoices): + RESYNC = "resync", "Resync codes" + REBUILD = "rebuild", "Rebuild code search indexes" + + class Status(models.TextChoices): + QUEUED = "queued", "Queued" + RUNNING = "running", "Running" + SUCCEEDED = "succeeded", "Succeeded" + FAILED = "failed", "Failed" + + kind = models.CharField(max_length=20, choices=Kind.choices) + # Blank for every jurisdiction. + jurisdiction = models.CharField(max_length=20, blank=True) + status = models.CharField(max_length=20, choices=Status.choices, default=Status.QUEUED) + requested_by = models.ForeignKey( + "efile.UserProfile", null=True, blank=True, on_delete=models.SET_NULL, related_name="+" + ) + requested_at = models.DateTimeField(auto_now_add=True) + started_at = models.DateTimeField(null=True, blank=True) + finished_at = models.DateTimeField(null=True, blank=True) + message = models.TextField(blank=True) + + class Meta: + ordering = ["-requested_at"] + + class UserProfile(AbstractUser): """ Extended user profile to store eFile registration information. @@ -63,6 +114,10 @@ class UserProfile(AbstractUser): # so this is the value a new draft is born with, not a lock on it. ai_assistance_opted_out = models.BooleanField(default=False) analytics_excluded = models.BooleanField(default=False) + # The last few ZIP codes this filer used to find a filing code, newest + # first. Filers who file for others often file far from their own address, + # so code search offers these as shortcuts, never as a prefilled value. + recent_case_zips = models.JSONField(default=list, blank=True) # Timestamps created_at = models.DateTimeField(auto_now_add=True) diff --git a/efile_app/efile/services/case_location.py b/efile_app/efile/services/case_location.py new file mode 100644 index 00000000..f90a06c6 --- /dev/null +++ b/efile_app/efile/services/case_location.py @@ -0,0 +1,134 @@ +"""Which courts serve a case ZIP: a location aid, never a jurisdiction ruling. + +A state whose court selector configures a location matcher (MACourts, +VTCourts; see ``court_location``) answers from that package's own rules, and +falls back to counties only for courts the package has no record of. Any +other state goes through counties: a ZIP maps to counties through +``data/zip_counties.json.gz`` (Census ZCTAs, built by +``scripts/build_zip_counties.py``), and a court to counties through its code or +name -- Illinois codes start with the county (``cook:cvd1``), others name a +county or a town. A court neither can place (an appeals court, a statewide +division) stays in every ZIP's results: narrowing must never hide a court that +might hear the case. +""" + +from __future__ import annotations + +import gzip +import json +import re +from functools import lru_cache +from pathlib import Path + +from django.core.cache import cache + +from efile.db_expressions import CourtCode + +DATA = Path(__file__).resolve().parents[1] / "data" +ZIP_PATTERN = re.compile(r"[0-9]{5}(?:-[0-9]{4})?") + + +def _words(text): + return re.findall(r"[a-z0-9]+", text.casefold().replace("’", "").replace("'", "")) + + +@lru_cache(maxsize=1) +def zip_table(): + with gzip.open(DATA / "zip_counties.json.gz", "rt", encoding="utf-8") as handle: + return json.load(handle) + + +def state_code(jurisdiction): + from efile.services.account_profile import default_state_code + + return default_state_code(jurisdiction).upper() + + +def zip_counties(jurisdiction, postal_code): + """Every county a valid ZIP touches, largest share first; [] when none is known.""" + if jurisdiction == "illinois": + # The state's own table also knows PO-box ZIPs that have no Census ZCTA. + from efile.utils.zip_to_county_il import COUNTY_TO_ZIPS_IL + + counties = [name for name, codes in COUNTY_TO_ZIPS_IL.items() if postal_code[:5] in codes] + if counties: + return counties + return zip_table().get(state_code(jurisdiction), {}).get("zips", {}).get(postal_code[:5], []) + + +def location_matcher(jurisdiction): + from efile.services.court_selection import selector_config + + steps = (selector_config(jurisdiction) or {}).get("steps", []) + return next((step["matcher"] for step in steps if step.get("matcher")), "") + + +def locate(index, postal_code): + """The counties and court codes for a case ZIP, or None without one. + + Raises ValueError for a ZIP that is malformed or places nowhere in this + jurisdiction, so the filer is told rather than shown every court. + """ + if not postal_code: + return None + if not ZIP_PATTERN.fullmatch(postal_code): + raise ValueError("Enter a five-digit ZIP code, or clear the ZIP filter.") + counties = zip_counties(index.jurisdiction, postal_code) + matcher = location_matcher(index.jurisdiction) + if matcher: + from efile.services.court_location import courts_for_postal_code + + found = courts_for_postal_code(matcher, postal_code[:5], index_courts(index)) + codes = found and found["matched"] | set(courts_serving(index, counties, only=found["unknown"])) + else: + codes = courts_serving(index, counties) if counties else None + if codes is None: + raise ValueError("We couldn't place this ZIP code here. Check it, or clear it to see every court.") + return {"counties": counties, "courts": sorted(codes)} + + +def _contains(haystack, needle): + return bool(needle) and any(haystack[i : i + len(needle)] == needle for i in range(len(haystack) - len(needle) + 1)) + + +@lru_cache(maxsize=4096) +def court_counties(jurisdiction, code, name): + """The counties a court's code or name places it in; empty when it names none.""" + state = zip_table().get(state_code(jurisdiction), {}) + counties = {county for names in state.get("zips", {}).values() for county in names} + code_keys = {"".join(_words(part)) for part in code.split(":")} + name_words = _words(name) + found = { + county for county in counties if "".join(_words(county)) in code_keys or _contains(name_words, _words(county)) + } + if found: + return frozenset(found) + # Courts named for a town: the last dash-separated part of the name. + town = " ".join(_words(re.split(r"\s+-+\s+", name.strip())[-1])) + return frozenset(state.get("towns", {}).get(town, [])) + + +def index_courts(index): + """Code -> name for every court in an index, saved at refresh or looked up once.""" + if index.vocabulary.get("courts"): + return index.vocabulary["courts"] + key = f"filing-index-courts-v1:{index.pk}:{index.refreshed_at.isoformat()}" + courts = cache.get(key) + if courts is None: + codes = index.paths.annotate(code=CourtCode("court")).values_list("code", flat=True).order_by().distinct() + courts = {} + for code in codes: + path = index.paths.alias(code=CourtCode("court")).filter(code=code).only("court").first() + courts[code] = path.court.get("name", code) + cache.set(key, courts, timeout=None) + return courts + + +def courts_serving(index, counties, only=None): + """Court codes in ``counties``, plus every court whose county is unknown.""" + return sorted( + code + for code, name in index_courts(index).items() + if (only is None or code in only) + and (not (placed := court_counties(index.jurisdiction, code, name)) or placed & set(counties)) + ) diff --git a/efile_app/efile/services/case_type_guidance.py b/efile_app/efile/services/case_type_guidance.py index 3bbac891..437cf163 100644 --- a/efile_app/efile/services/case_type_guidance.py +++ b/efile_app/efile/services/case_type_guidance.py @@ -85,16 +85,21 @@ def case_guidance(jurisdiction, category, case_type, filing_name="", court_name= return result +OTHER = "other" + + def matches_case_filters(guidance, role="", property_kind="", relief="", *, topic="", filters=None): choices = filters if filters is not None else {"role": role, "property": property_kind, "relief": relief} if topic and guidance.get("topic") != topic: return False + # Amounts are matched against the label (see ``matches_amount``), not guidance. + choices = {key: value for key, value in choices.items() if key != "amount"} if not any(choices.values()): return True + # "other" keeps only the case types none of a facet's options name. return bool(guidance) and all( - not value or guidance.get(key, "unknown") in (value, "unknown") + not value or guidance.get(key, "unknown") in ((value, "unknown") if value != OTHER else ("unknown",)) for key, value in choices.items() - if key != "amount" ) @@ -140,11 +145,15 @@ def _money(cents): def amount_options(labels): - ranges = { - bounds - for label in labels - if (bounds := claim_range(label["case_type"]) or claim_range(label["category"])) is not None - } + ranges = set() + small_claims = set() + for label in labels: + bounds = claim_range(label["case_type"]) or claim_range(label["category"]) + if bounds is None: + continue + ranges.add(bounds) + if re.search(r"\bsmall claims?\b", f"{label['category']} {label['case_type']}", re.IGNORECASE): + small_claims.add(bounds) if len(ranges) < 2: return [] cuts = sorted({0, *(low for low, _ in ranges), *(high + 1 for _, high in ranges if high is not None)}) @@ -158,37 +167,54 @@ def amount_options(labels): bands[-1] = (bands[-1][0], high, members) else: bands.append((low, high, members)) + # Name a band "small claims" only when it sets small claims apart from + # the others, as a civil search spanning both does. + marked = [bool(members & small_claims) for _, _, members in bands] return [ { "value": f"{low}:{high if high is not None else ''}", - "label": f"{_money(low)} or more" - if high is None - else f"Up to {_money(high)}" - if low == 0 - else f"{_money(low)}–{_money(high)}", + "label": ( + f"{_money(low)} or more" + if high is None + else f"Up to {_money(high)}" + if low == 0 + else f"{_money(low)}–{_money(high)}" + ) + + (" (small claims)" if is_small and not all(marked) else ""), } - for low, high, _ in bands + for (low, high, _), is_small in zip(bands, marked, strict=True) ] +def _valid_amount(value): + if value == "": + return True + if not isinstance(value, str) or not re.fullmatch(r"\d{1,12}:\d{0,12}", value): + return False + low, high = value.split(":") + return not high or int(low) <= int(high) + + def validate_case_filters(jurisdiction, topic, choices): if not isinstance(choices, dict) or len(choices) > 8: raise ValueError("Choose valid case-type filters.") if topic in ("", "all"): - if choices: + # A claim amount narrows any search; other details belong to a case area. + if set(choices) - {"amount"} or not _valid_amount(choices.get("amount", "")): raise ValueError("Choose a case area before narrowing its details.") return config = topic_config(jurisdiction, topic) if not config: raise ValueError("Choose a supported case area.") - allowed = {facet["key"]: {option["value"] for option in facet["options"]} for facet in config["facets"]} + allowed = { + facet["key"]: {option["value"] for option in facet["options"]} | ({OTHER} if facet.get("other") else set()) + for facet in config["facets"] + } for key, value in choices.items(): if not isinstance(value, str): raise ValueError("Choose valid case-type filters.") - if key == "amount" and topic == "small_claims" and re.fullmatch(r"\d{1,12}:\d{0,12}", value): - low, high = value.split(":") - if not high or int(low) <= int(high): - continue + if key == "amount" and _valid_amount(value): + continue if key not in allowed or value not in allowed[key] | {""}: raise ValueError("Choose valid case-type filters.") @@ -217,45 +243,49 @@ def facet_metadata(jurisdiction, query, labels, topic="", choices=None): { "key": facet["key"], "label": facet["label"], - "options": [{"value": option["value"], "label": option["label"]} for option in facet["options"]], - } - ) - if selected == "small_claims": - applicable = [ - label - for label in labels - if case_topic(jurisdiction, label["category"], label["case_type"]) == selected - and matches_case_filters( - case_guidance(jurisdiction, label["category"], label["case_type"], label["name"]), filters=choices or {} - ) - ] - options = amount_options(applicable) - selected_amount = (choices or {}).get("amount") - if selected_amount and not any(option["value"] == selected_amount for option in options): - low, high = selected_amount.split(":") - options.append( - { - "value": selected_amount, - "label": f"{_money(int(low))}–{_money(int(high))}" if high else f"{_money(int(low))} or more", - } - ) - limit = config.get("usual_limit_cents") - if limit: - for option in options: - low, high = option["value"].split(":") - if int(low) > limit or not high or int(high) > limit: - option["label"] += " (check small-claims limit)" - if options or selected_amount: - facets.append( - { - "key": "amount", - "label": "Claim amount", - "control": "select", - "options": options, - "help": config.get("amount_help", ""), - "source": config["source"], + "options": [{"value": option["value"], "label": option["label"]} for option in facet["options"]] + + ([{"value": OTHER, "label": facet["other"]}] if facet.get("other") else []), } ) + # Claim amounts narrow any search whose case types differ by amount -- a + # civil complaint search spanning small claims and larger claims, say -- + # not only one already in small claims. + in_area = selected not in ("", "all") + applicable = [ + label + for label in labels + if (not in_area or case_topic(jurisdiction, label["category"], label["case_type"]) == selected) + and matches_case_filters( + case_guidance(jurisdiction, label["category"], label["case_type"], label["name"]), filters=choices or {} + ) + ] + options = amount_options(applicable) + selected_amount = (choices or {}).get("amount") + if selected_amount and not any(option["value"] == selected_amount for option in options): + low, high = selected_amount.split(":") + options.append( + { + "value": selected_amount, + "label": f"{_money(int(low))}–{_money(int(high))}" if high else f"{_money(int(low))} or more", + } + ) + small_claims = selected == "small_claims" + limit = config.get("usual_limit_cents") if small_claims else None + if limit: + for option in options: + low, high = option["value"].split(":") + if int(low) > limit or not high or int(high) > limit: + option["label"] += " (check small-claims limit)" + if options or selected_amount: + facets.append( + { + "key": "amount", + "label": "How much are you asking for?", + "options": options, + "help": config.get("amount_help", "") if small_claims else "", + "source": config["source"] if small_claims else "", + } + ) return { "case_topics": [{"value": key, "label": topic_config(jurisdiction, key)["label"]} for key in sorted(available)], "case_topic": selected, diff --git a/efile_app/efile/services/court_location.py b/efile_app/efile/services/court_location.py index 5f4423a8..119536e6 100644 --- a/efile_app/efile/services/court_location.py +++ b/efile_app/efile/services/court_location.py @@ -210,3 +210,35 @@ def find_courts(matcher: str, place: str, court_types: list[str], courts: list[d seen.add(court["value"]) results.append({**court, "reason": match["reason"], "matched_name": match["name"]}) return results + + +def courts_for_postal_code(matcher: str, postal_code: str, courts: dict[str, str]) -> dict[str, set[str]] | None: + """Split ``courts`` (code -> name) by what the matcher says about a ZIP. + + ``matched`` serve it; ``unknown`` are courts the matcher has no record of -- + a statewide division, a court added to e-filing after the package's data -- + which the caller places some other way. None when the ZIP places nowhere. + MACourts identifies courts by code; Vermont's units by name. + """ + + if matcher == "macourts": + finder = _finder() + matches = finder.find_by_postal_code(postal_code) + matched = {normalize_court_code(r.tyler_code) for m in matches for r in m.records if r.tyler_code} + known = {normalize_court_code(r.tyler_code) for r in finder.catalog.records if r.tyler_code} + keys = {code: normalize_court_code(code) for code in courts} + elif matcher == "vtcourts": + from vtcourts import Location + + units = _vermont_finder().find_units(Location(postal_code=postal_code, state="Vermont")) + matched = {normalize_court_name(f"{unit.unit} Unit") for unit in units} + keys = {code: normalize_court_name(name) for code, name in courts.items()} + known = {key for key in keys.values() if key.endswith(" unit")} + else: + raise LocationLookupUnavailable(f"No court location lookup is configured for '{matcher}'.") + if not matched: + return None + return { + "matched": {code for code, key in keys.items() if key in matched}, + "unknown": {code for code, key in keys.items() if key not in known}, + } diff --git a/efile_app/efile/services/filing_code_copy.py b/efile_app/efile/services/filing_code_copy.py new file mode 100644 index 00000000..4e4326d6 --- /dev/null +++ b/efile_app/efile/services/filing_code_copy.py @@ -0,0 +1,261 @@ +"""Copy filing codes straight from the EFSP proxy's codes database, then index them. + +The proxy refreshes its codes from Tyler once a day. LITEFile reads the same +tables over a read-only connection -- the queries match the proxy's own +filing-catalog export -- and keeps a copy per court +(``FilingCodeCourtCatalog``). The search index is built from that copy, so a +search-rules change needs only a rebuild, never another trip to the EFSP. + +Every read happens inside one READ ONLY, REPEATABLE READ transaction: the +court list and every court's tables come from the same snapshot, and the +database refuses any write. (Supabase's pooler ignores connection-level +settings, so read-only is set per transaction.) +""" + +from __future__ import annotations + +import psycopg +from django.conf import settings +from django.db import transaction +from django.utils import timezone +from psycopg.rows import dict_row + +from efile.models import FilingCodeCourtCatalog, FilingCodeIndex +from efile.services.court_selection import is_non_filing_court +from efile.services.filing_code_sync import synchronize_index, validate_export + +# Each court's revision changes when its name or the installed version of any of +# its three code lists does: the proxy's own manifest query. +COURTS_SQL = """ +SELECT l.code, l.name, (l.initial ILIKE 'true' OR l.subsequent ILIKE 'true') AS fileable, + count(v.installedversion) AS versions, + md5(jsonb_build_array(l.name, jsonb_object_agg(v.codelist, v.installedversion) + FILTER (WHERE v.codelist IS NOT NULL))::text) AS revision +FROM location l LEFT JOIN installedversion v + ON v.jurisdiction=l.jurisdiction AND v.location=l.code + AND v.codelist IN ('casecategorycodes.zip', 'casetypecodes.zip', 'filingcodes.zip') + AND btrim(v.installedversion) <> '' +WHERE l.jurisdiction=%s +GROUP BY l.code, l.name, l.initial, l.subsequent ORDER BY l.code +""" +CATEGORIES_SQL = """ +SELECT code, name FROM casecategory WHERE jurisdiction=%s AND location=%s + AND ecfcasetype != 'CriminalCase' ORDER BY code, name +""" +CASE_TYPES_SQL = """ +SELECT code, name, casecategory AS case_category, coalesce(lower(initial)='true', false) AS initial +FROM casetype WHERE jurisdiction=%s AND location=%s ORDER BY casecategory, code, name +""" +FILING_TYPES_SQL = """ +SELECT code, name, casecategory AS case_category, casetypeid AS case_type, filingtype AS timing +FROM filing WHERE jurisdiction=%s AND location=%s AND iscourtuseonly='False' + AND filingtype IN ('Initial', 'Subsequent', 'Both') +ORDER BY code, casecategory, casetypeid, filingtype, name +""" + + +class EfspCodesDatabase: + """A read-only snapshot of one jurisdiction's codes in the EFSP database.""" + + def __init__(self, jurisdiction): + if not settings.EFSP_CODES_DATABASE_URL: + raise ValueError("Set EFSP_CODES_DATABASE_URL to copy codes from the EFSP database.") + self.jurisdiction = jurisdiction + + def __enter__(self): + self.connection = psycopg.connect( + settings.EFSP_CODES_DATABASE_URL, + connect_timeout=30, + application_name="litefile-code-copy", + row_factory=dict_row, + ) + try: + self.connection.read_only = True + self.connection.isolation_level = psycopg.IsolationLevel.REPEATABLE_READ + self.snapshot = self.connection.transaction() + self.snapshot.__enter__() + with self.connection.cursor() as cursor: + cursor.execute("SHOW transaction_read_only") + if (cursor.fetchone() or {}).get("transaction_read_only") != "on": + raise ValueError("The EFSP codes database connection is not read only; refusing to continue.") + except BaseException: + # __exit__ never runs when __enter__ fails: close here. + self.connection.close() + raise + return self + + def __exit__(self, *exc_info): + try: + self.snapshot.__exit__(*exc_info) + finally: + self.connection.close() + + def _rows(self, sql, *params): + with self.connection.cursor() as cursor: + cursor.execute(sql, (self.jurisdiction, *params)) + return cursor.fetchall() + + def manifest(self): + courts = {} + for row in self._rows(COURTS_SQL): + # Tyler-internal rows (Illinois' "System", flagged fileable for + # existing cases but holding no code lists) never block a copy. + if is_non_filing_court(row["name"]): + continue + if row["versions"] != 3: + if row["fileable"]: + # As the proxy's export does: a filing court with missing code + # lists means Tyler's update is mid-way. Copy nothing. + raise ValueError(f"Incomplete installed filing catalog for court {row['code']}; try again later.") + continue + courts[row["code"]] = {key: row[key] for key in ("code", "name", "revision")} + if not courts: + raise ValueError("No filing courts in the EFSP codes database; keeping the previous copy.") + return courts + + def court(self, court): + data = { + "court": court, + "categories": self._rows(CATEGORIES_SQL, court["code"]), + "case_types": self._rows(CASE_TYPES_SQL, court["code"]), + "filing_types": self._rows(FILING_TYPES_SQL, court["code"]), + } + validate_export(data) + return data + + +def copy_codes(jurisdiction, *, progress=None, force=(), dry_run=False): + """Copy courts whose EFSP revision changed (plus ``force``); drop courts the EFSP no longer has. + + Each court is saved as it is read, so memory holds one court at a time and + the local database is never locked for the whole copy. A court that + suddenly has no filing types almost always means a half-finished EFSP + update, so the copy stops and keeps the old one. + """ + existing = { + row["code"]: row + for row in FilingCodeCourtCatalog.objects.filter(jurisdiction=jurisdiction).values( + "code", "revision", "filing_count" + ) + } + changed = 0 + with EfspCodesDatabase(jurisdiction) as source: + courts = source.manifest() + if unknown := set(force) - set(courts): + raise ValueError("Not filing courts in the EFSP codes database: " + ", ".join(sorted(unknown))) + for code, court in courts.items(): + old = existing.get(code) + if old and old["revision"] == court["revision"] and code not in force: + continue + data = source.court(court) + count = len(data["filing_types"]) + if old and old["filing_count"] and not count and code not in force: + raise ValueError( + f"{court['name']} ({code}) now has no filing types (had {old['filing_count']}); keeping the " + "previous copy. If that's expected, resync with this court forced." + ) + if progress: + progress(f"{court['name']} ({code}): {old['filing_count'] if old else 'new'} -> {count} filing types") + changed += 1 + if dry_run: + continue + FilingCodeCourtCatalog.objects.update_or_create( + jurisdiction=jurisdiction, + code=code, + defaults={ + "name": court["name"], + "revision": court["revision"], + "data": data, + "filing_count": count, + "copied_at": timezone.now(), + }, + ) + removed = set(existing) - set(courts) + if removed and not dry_run: + FilingCodeCourtCatalog.objects.filter(jurisdiction=jurisdiction, code__in=removed).delete() + if progress: + progress(f"{changed} changed courts, {len(removed)} removed, {len(courts) - changed} unchanged") + return {"courts": len(courts), "changed": changed, "removed": len(removed)} + + +class LocalCopyCatalog: + """The copied courts, in the shape ``synchronize_index`` reads.""" + + def __init__(self, jurisdiction): + self.jurisdiction = jurisdiction + + def manifest(self): + courts = { + row["code"]: row + for row in FilingCodeCourtCatalog.objects.filter(jurisdiction=self.jurisdiction) + .order_by("code") + .values("code", "name", "revision") + } + if not courts: + raise ValueError("No copied codes yet for this jurisdiction. Resync codes first.") + return courts + + def court(self, court): + return FilingCodeCourtCatalog.objects.get(jurisdiction=self.jurisdiction, code=court["code"]).data + + def close(self): + pass + + +def resync(jurisdiction, *, progress=None, force=(), dry_run=False): + """Copy changed courts from the EFSP, then update the index for those courts.""" + copied = copy_codes(jurisdiction, progress=progress, force=force, dry_run=dry_run) + if dry_run: + return copied["changed"] + # An index built from older rules is replaced in full; otherwise only changed courts are. + return synchronize_index(jurisdiction, catalog=LocalCopyCatalog(jurisdiction), progress=progress, force=force) + + +def rebuild(jurisdiction, *, progress=None): + """Rebuild the whole search index from the local copy, without contacting the EFSP.""" + return synchronize_index(jurisdiction, catalog=LocalCopyCatalog(jurisdiction), progress=progress, full=True) + + +def status(jurisdiction): + """What the staff page shows for one jurisdiction.""" + copies = FilingCodeCourtCatalog.objects.filter(jurisdiction=jurisdiction) + index = FilingCodeIndex.objects.filter(jurisdiction=jurisdiction).first() + latest = copies.order_by("-copied_at").values_list("copied_at", flat=True).first() + return { + "jurisdiction": jurisdiction, + "copied_courts": copies.count(), + "copied_at": latest, + "indexed_at": index.refreshed_at if index else None, + "indexed_paths": sum(item.get("count", 0) for item in (index.court_snapshots or {}).values()) if index else 0, + } + + +def run_jobs(jurisdictions, *, log=print): + """Run queued staff requests, oldest first, until none are left.""" + from efile.models import FilingCodeJob + + while True: + job = FilingCodeJob.objects.filter(status=FilingCodeJob.Status.QUEUED).order_by("requested_at").first() + if job is None: + return + # Claim it with a conditional update, which works on SQLite and Postgres alike. + claimed = FilingCodeJob.objects.filter(pk=job.pk, status=FilingCodeJob.Status.QUEUED).update( + status=FilingCodeJob.Status.RUNNING, started_at=timezone.now() + ) + if not claimed: + continue + lines, failed = [], [] + for jurisdiction in [job.jurisdiction] if job.jurisdiction else jurisdictions: + try: + count = (rebuild if job.kind == FilingCodeJob.Kind.REBUILD else resync)(jurisdiction) + lines.append(f"{jurisdiction}: {count} filing paths saved") + except Exception as error: # Report every failure on the job; keep the worker alive. + failed.append(jurisdiction) + lines.append(f"{jurisdiction}: failed; previous index kept. {error}") + log(lines[-1]) + with transaction.atomic(): + FilingCodeJob.objects.filter(pk=job.pk).update( + status=FilingCodeJob.Status.FAILED if failed else FilingCodeJob.Status.SUCCEEDED, + finished_at=timezone.now(), + message="\n".join(lines), + ) diff --git a/efile_app/efile/services/filing_code_search.py b/efile_app/efile/services/filing_code_search.py index 6aed1227..109bc839 100644 --- a/efile_app/efile/services/filing_code_search.py +++ b/efile_app/efile/services/filing_code_search.py @@ -37,15 +37,20 @@ from urllib3.util.retry import Retry from efile.models import FilingCodeIndex, FilingCodePath +from efile.services.case_location import locate from efile.services.case_type_guidance import ( + _money, case_guidance, + claim_range, facet_metadata, matches_amount, matches_case_filters, validate_case_filters, ) +from efile.services.case_type_guidance import case_topic as topic_of from efile.services.court_selection import is_non_filing_court from efile.services.filing_availability import filing_unavailable_message +from efile.services.glossary import glossary_for FACETS = ("court", "case_category", "case_type", "filing_type") RULES_PATH = Path(__file__).resolve().parents[1] / "data" / "filing_code_search.yaml" @@ -394,12 +399,16 @@ def fetch_filings(case_type, court=court, category=category, initial=initial): def refresh_index(jurisdiction, *, progress=None, cache_dir=None, transport=None, force=(), dry_run=False): """Synchronize changed court exports; the expensive old crawler requires explicit opt-in.""" mode = transport or getattr(settings, "FILING_CODE_SYNC_MODE", "bulk") + if mode == "database": + from efile.services.filing_code_copy import resync + + return resync(jurisdiction, progress=progress, force=force, dry_run=dry_run) if mode == "bulk": from efile.services.filing_code_sync import synchronize_index return synchronize_index(jurisdiction, progress=progress, force=force, dry_run=dry_run) if mode != "legacy": - raise ValueError("FILING_CODE_SYNC_MODE must be 'bulk' or 'legacy'") + raise ValueError("FILING_CODE_SYNC_MODE must be 'database', 'bulk', or 'legacy'") if force or dry_run: raise ValueError("Forcing courts and dry runs need the bulk sync") return _refresh_index_legacy(jurisdiction, progress=progress, cache_dir=cache_dir) @@ -418,6 +427,7 @@ def _refresh_index_legacy(jurisdiction, *, progress=None, cache_dir=None): if jurisdiction in concept.get("jurisdictions", rules()[0]["jurisdictions"]): vocabulary.update(word for term in concept["terms"] for word in words(term)) count = 0 + courts = {} # Large catalogs share many names but still have many distinct paths. Stage # on disk, then insert bounded batches: memory does not grow with row count. with closing(CodeCatalog(jurisdiction)) as catalog, TemporaryFile(mode="w+b") as staging: @@ -425,6 +435,7 @@ def _refresh_index_legacy(jurisdiction, *, progress=None, cache_dir=None): for entry in catalog_entries(catalog, jurisdiction, progress, cache_dir): output.write(json.dumps(entry) + "\n") indexed_terms.update(entry["search_text"].split()) + courts.setdefault(entry["court"]["code"], entry["court"]["name"]) vocabulary.update(word for facet in FACETS for word in words(entry[facet]["name"])) count += 1 if not count: @@ -450,7 +461,7 @@ def _refresh_index_legacy(jurisdiction, *, progress=None, cache_dir=None): index.source_url = source_url() index.refreshed_at = started index.rules_digest = rules()[1] - index.vocabulary = {"words": sorted(vocabulary), "tokens": sorted(indexed_terms)} + index.vocabulary = {"words": sorted(vocabulary), "tokens": sorted(indexed_terms), "courts": courts} index.court_snapshots = {} index.save() return count @@ -489,8 +500,50 @@ def case_description(path): return next((meaning["text"] for meaning in meanings if meaning.get("text")), "") +JURY_SIZE = re.compile(r"\(\s*jury\s*-\s*(\d+)\s*\)", re.IGNORECASE) +NON_JURY = re.compile(r"\bnon\s*-?\s*jury\b", re.IGNORECASE) +JURY = re.compile(r"\bjury\b", re.IGNORECASE) +SELF_REPRESENTED = re.compile(r"self\s*-?\s*represented|\bSRL\b|\bpro se\b", re.IGNORECASE) +GOVERNMENT = re.compile(r"\(\s*govn'?t\s*\)|\bgovernment(al)? (filer|entity|agency)\b", re.IGNORECASE) + + +def path_qualifiers(category, case_type, filing_name): + """Choices some courts code as separate case or filing types, in plain values. + + Cook County, for one, has a separate code for each mix of jury size, + government filer, self-represented litigant, and claim amount. The court + step asks only the ones its paths actually differ on. + """ + case_text = f"{category} {case_type}" + if size := JURY_SIZE.search(filing_name): + jury = size.group(1) + elif NON_JURY.search(case_text) or NON_JURY.search(filing_name): + jury = "none" + elif JURY.search(case_text) or JURY.search(filing_name): + jury = "jury" + else: + jury = "none" + bounds = claim_range(case_type) or claim_range(category) + amount = None + if bounds: + low, high = bounds + amount = { + "value": f"{low}:{high if high is not None else ''}", + "label": f"{_money(low)} to {_money(high)}" if high is not None else f"More than {_money(low - 1)}", + } + return { + "jury": jury, + "representation": "self" if SELF_REPRESENTED.search(case_text) else "lawyer", + "government": "yes" if GOVERNMENT.search(filing_name) else "no", + "amount": amount, + } + + def serialize_path(path, *, query="", corrected=(), resolved_query=None): return { + "qualifiers": path_qualifiers(path.case_category["name"], path.case_type["name"], path.filing_type["name"]), + "filing_label": unqualified_label(path.filing_type["name"]), + "glossary": glossary_for(path.index.jurisdiction, [path.filing_type["name"], path.case_type["name"]]), "id": path.pk, **{facet: getattr(path, facet) for facet in FACETS}, "initial": path.initial, @@ -567,23 +620,27 @@ def matching_paths(index, query, initial): else: for token in sorted(tokens): paths = paths.filter(search_text__contains=f" {token} ") - score = Value(0, output_field=IntegerField()) + scores = { + "filing_terms": Value(0, output_field=IntegerField()), + "case_terms": Value(0, output_field=IntegerField()), + } for token in sorted(tokens): for field, weight in (("filing_terms", 8), ("case_terms", 3)): clause = Q(**{f"{field}__contains": f" {token} "}) - score += Case(When(clause, then=Value(weight)), default=Value(0), output_field=IntegerField()) - return paths.annotate(score=score), corrected + scores[field] += Case(When(clause, then=Value(weight)), default=Value(0), output_field=IntegerField()) + paths = paths.annotate(name_score=scores["filing_terms"]) + return paths.annotate(score=scores["filing_terms"] + scores["case_terms"]), corrected -def search_paths(index, query, *, initial=True, offset=0, limit=20, names=None, court=None, contexts=None, counties=()): +def search_paths(index, query, *, initial=True, offset=0, limit=20, names=None, court=None, contexts=None, courts=()): paths, corrected = matching_paths(index, query, initial) resolved_query = corrected_search_query(index, query)[0] if corrected else query if names is not None: paths = paths.filter(filing_type__name__in=names) if court: paths = paths.filter(court__code=court) - if counties: - paths = paths.filter(county_filter(counties)) + if courts: + paths = paths.filter(court__code__in=courts) if contexts is not None: paths = paths.filter(context_filter(contexts)) total = paths.count() @@ -603,11 +660,28 @@ def clean_filing_label(name): return re.sub(r"\s+filed[.\s]*$", "", name, flags=re.IGNORECASE).strip(" .-") +# Qualifiers some courts code as separate filings of the same document, such as +# Cook County's "Complaint / Petition - Fraud (Jury - 12) (Govn't) - Fee". The +# filer still picks the exact one in the court step; search shows it once. +FILING_QUALIFIERS = re.compile( + r"\((?:jury[^)]*|govn'?t|government|no fee|fee)\)|\s+-\s+(?:no\s+)?fee\s*$", + re.IGNORECASE, +) + + +def unqualified_label(name): + previous = None + name = clean_filing_label(name) + while previous != name: + previous, name = name, clean_filing_label(FILING_QUALIFIERS.sub("", name)) + return re.sub(r"\s{2,}", " ", name) + + def filing_group_key(name, jurisdiction): # Group discovery results using the same curated synonyms and stemming as # search. These are related labels, not interchangeable court codes: the # filer still chooses and validates an exact court/category/type path. - label = " ".join(words(clean_filing_label(name))) + label = " ".join(words(unqualified_label(name))) aliases = { "answer to complaint": "answer", "response to complaint": "answer", @@ -653,6 +727,55 @@ def facet_rules(): return [(re.compile(rule["pattern"], re.IGNORECASE), rule) for rule in config["rules"]] +@lru_cache(maxsize=1) +def action_rules(): + config = yaml.safe_load(RULES_PATH.with_name("filing_code_facets.yaml").read_bytes()) + return [(re.compile(rule["pattern"], re.IGNORECASE), rule) for rule in config.get("actions", [])] + + +@lru_cache(maxsize=65536) +def filing_action(name): + """What a filing does, in plain words, guessed from its name; "" when unknown.""" + label = " ".join(words(clean_filing_label(name))) + return next((rule["key"] for pattern, rule in action_rules() if pattern.search(label)), "") + + +def action_options(groups): + counts = {} + for group in groups: + for action in {filing_action(label["name"]) for label in group["_labels"]} - {""}: + counts[action] = counts.get(action, 0) + 1 + return [ + {"value": rule["key"], "label": rule["label"], "count": counts[rule["key"]]} + for _, rule in action_rules() + if rule["key"] in counts + ] + + +def category_options(groups): + """Case categories (Cook's "Civil", "Probate", ...) the groups are filed under, most used first.""" + counts = {} + for group in groups: + for name in {label["category"] for label in group["_labels"]}: + key = tuple(words(name)) + counts.setdefault(key, [name, 0])[1] += 1 + return [ + {"value": name, "label": name, "count": count} + for name, count in sorted(counts.values(), key=lambda item: (-item[1], item[0].casefold())) + ] + + +def strong_matches(groups): + """How many leading groups match by filing name rather than only by case type. + + Groups sort by name score first, so these lead. A name match has to be at + least half as strong as the best one, so a two-word search does not count a + one-word partial match beside an exact one. + """ + top = max((group["name_rank"] for group in groups), default=0) + return sum(1 for group in groups if top and group["name_rank"] >= top / 2) + + def filing_facets(name, jurisdiction): label = " ".join(words(clean_filing_label(name))) result = {"purpose": "unknown", "document": "unknown"} @@ -671,6 +794,25 @@ def case_context_label(category, case_type): return case_type if words(category) == words(case_type) else f"{category} › {case_type}" +# Parts of a case-type name that only say jury, self-representation, or the +# small-claims band: the court step asks about those, so step 1 counts and +# shows "Personal Injury Complaint" once instead of once per variant. +CASE_QUALIFIERS = re.compile( + r"\s*-?\s*\b(?:non\s*-?\s*)?jury(?: demand)?\b" + r"|\s*-?\s*\b(?:self\s*-?\s*represented litigant|srl)\b" + r"|\s*-?\s*\bsmall claims\b\s*-?\s*\$[\d,]+\s*to\s*\$[\d,]+", + re.IGNORECASE, +) + + +def unqualified_case_type(case_type): + return re.sub(r"\s*-\s*$", "", CASE_QUALIFIERS.sub("", case_type)).strip() or case_type + + +def display_case_context(category, case_type): + return case_context_label(category, unqualified_case_type(case_type)) + + def case_context_key(category, case_type): return hashlib.sha256(json.dumps([words(category), words(case_type)]).encode()).hexdigest()[:24] @@ -686,8 +828,8 @@ def case_contexts(labels): contexts = {} for label in labels: category, case_type = label["category"], label["case_type"] - key = (tuple(words(category)), tuple(words(case_type))) - context = contexts.setdefault(key, {"label": case_context_label(category, case_type), "count": 0, "rank": 0}) + key = (tuple(words(category)), tuple(words(unqualified_case_type(case_type)))) + context = contexts.setdefault(key, {"label": display_case_context(category, case_type), "count": 0, "rank": 0}) context["count"] += label["path_count"] context["rank"] = max(context["rank"], label["rank"]) return [ @@ -708,6 +850,8 @@ def filter_filing_groups( relief="", case_topic="", case_filters=None, + action="", + category="", ): selected = [] for group in groups: @@ -719,6 +863,10 @@ def filter_filing_groups( ) or not matches_amount(label, (case_filters or {}).get("amount", "")): continue facets = filing_facets(label["name"], jurisdiction) + if action and filing_action(label["name"]) != action: + continue + if category and words(label["category"]) != words(category): + continue if (not purpose or facets["purpose"] == purpose) and (not document or facets["document"] == document): labels.append(label) if not labels: @@ -728,7 +876,7 @@ def filter_filing_groups( ) distinct = {} for name in names: - distinct.setdefault(tuple(words(name)), name) + distinct.setdefault(tuple(words(unqualified_label(name))), unqualified_label(name)) contexts = case_contexts(labels) selected.append( { @@ -736,19 +884,20 @@ def filter_filing_groups( "_labels": labels, "_names": names, "variants": list(distinct.values()), - "name": clean_filing_label(names[0]), + "name": unqualified_label(names[0]), "path_count": sum(label["path_count"] for label in labels), "rank": max(label["rank"] for label in labels), + "name_rank": max(label["name_rank"] for label in labels), "case_contexts": contexts[:8], "case_context_count": len(contexts), "case_context_options": list( { case_context_key(label["category"], label["case_type"]): { "key": case_context_key(label["category"], label["case_type"]), - "label": case_context_label(label["category"], label["case_type"]), + "label": display_case_context(label["category"], label["case_type"]), } for label in labels - if case_context_label(label["category"], label["case_type"]) in contexts[:8] + if display_case_context(label["category"], label["case_type"]) in contexts[:8] }.values() ), "facets": { @@ -762,65 +911,42 @@ def filter_filing_groups( return sorted( selected, key=lambda group: ( + -group["name_rank"], -group["rank"], + -group["path_count"], len(search_tokens(group["name"], jurisdiction, query=True)), group["name"].casefold(), ), ) -def zip_counties(jurisdiction, postal_code): - """Keep every county in the local table when a ZIP spans county entries.""" - if not postal_code: - return [] - if jurisdiction != "illinois": - raise ValueError("ZIP filtering is not available for this jurisdiction yet.") - if not re.fullmatch(r"[0-9]{5}(?:-[0-9]{4})?", postal_code): - raise ValueError("Enter a five-digit ZIP code, or clear the ZIP filter.") - from efile.utils.zip_to_county_il import COUNTY_TO_ZIPS_IL - - counties = [name for name, codes in COUNTY_TO_ZIPS_IL.items() if postal_code[:5] in codes] - if not counties: - raise ValueError( - "This ZIP code is not in the Illinois county table. Clear the ZIP filter to search all courts." - ) - return counties - - -def county_filter(counties): - condition = Q(pk__in=[]) - for county in counties: - code = "".join(words(county)) - condition |= Q(court__code__iexact=code) | Q(court__code__istartswith=code + ":") - return condition - - -def filing_groups(index, query, initial, counties=()): - fingerprint = hashlib.sha256(f"{index.pk}:{index.refreshed_at}:{initial}:{query}:{counties}".encode()).hexdigest() - key = f"filing-groups-v5:{fingerprint}" +def filing_groups(index, query, initial, courts=()): + fingerprint = hashlib.sha256(f"{index.pk}:{index.refreshed_at}:{initial}:{query}:{courts}".encode()).hexdigest() + key = f"filing-groups-v7:{fingerprint}" cached = cache.get(key) if cached is not None: return cached paths, corrected = matching_paths(index, query, initial) - if counties: - paths = paths.filter(county_filter(counties)) + if courts: + paths = paths.filter(court__code__in=courts) labels = ( paths.order_by() .values("filing_type__name", "case_category__name", "case_type__name") - .annotate(path_count=Count("pk"), rank=Max("score")) + .annotate(path_count=Count("pk"), rank=Max("score"), name_rank=Max("name_score")) ) groups = {} for label in labels: name = label["filing_type__name"] group_key = filing_group_key(name, index.jurisdiction) group = groups.setdefault( - group_key, {"key": group_key, "variants": [], "path_count": 0, "rank": 0, "_labels": []} + group_key, {"key": group_key, "variants": [], "path_count": 0, "rank": 0, "name_rank": 0, "_labels": []} ) group["_labels"].append( { "name": name, "path_count": label["path_count"], "rank": label["rank"], + "name_rank": label["name_rank"], "category": label["case_category__name"], "case_type": label["case_type__name"], } @@ -828,6 +954,7 @@ def filing_groups(index, query, initial, counties=()): group["variants"].append(name) group["path_count"] += label["path_count"] group["rank"] = max(group["rank"], label["rank"]) + group["name_rank"] = max(group["name_rank"], label["name_rank"]) for group in groups.values(): group["variants"] = sorted( set(group["variants"]), key=lambda name: (len(name), name.isupper(), name.casefold()) @@ -835,14 +962,16 @@ def filing_groups(index, query, initial, counties=()): group["_names"] = group["variants"][:] distinct = {} for name in group["variants"]: - distinct.setdefault(tuple(words(name)), name) + distinct.setdefault(tuple(words(unqualified_label(name))), unqualified_label(name)) group["variants"] = list(distinct.values()) - group["name"] = clean_filing_label(group["variants"][0]) + group["name"] = group["variants"][0] result = ( sorted( groups.values(), key=lambda group: ( + -group["name_rank"], -group["rank"], + -group["path_count"], len(search_tokens(group["name"], index.jurisdiction, query=True)), group["name"].casefold(), ), @@ -853,6 +982,18 @@ def filing_groups(index, query, initial, counties=()): return result +def count_groups(index, query, initial, courts, *, purpose, document, topic, case_filters, action, category): + """How many filing types the same search and answers find in the other case stage.""" + groups = filing_groups(index, query, initial, courts)[0] + groups = filter_filing_groups(groups, index.jurisdiction, purpose, document) + groups = filter_filing_groups(groups, index.jurisdiction, case_topic=topic, case_filters=case_filters) + if action: + groups = filter_filing_groups(groups, index.jurisdiction, action=action) + if category: + groups = filter_filing_groups(groups, index.jurisdiction, category=category) + return len(groups) + + def search_grouped_paths( index, query, @@ -871,9 +1012,13 @@ def search_grouped_paths( relief="", case_topic="", case_filters=None, + action="", + category="", + strong_only=False, ): - counties = zip_counties(index.jurisdiction, postal_code) - groups, corrected = filing_groups(index, query, initial, counties) + location = locate(index, postal_code) or {"counties": [], "courts": []} + counties, courts = location["counties"], tuple(location["courts"]) + groups, corrected = filing_groups(index, query, initial, courts) resolved_query = corrected_search_query(index, query)[0] if corrected else query case_filters = case_filters or {} if any((role, property_kind, relief)): @@ -888,11 +1033,56 @@ def search_grouped_paths( ) selected_topic = "" if metadata["case_topic"] == "all" else metadata["case_topic"] groups = filter_filing_groups(groups, index.jurisdiction, case_topic=selected_topic, case_filters=case_filters) + topic_counts = {} + for group in filing_groups(index, query, initial, courts)[0] if metadata["case_topics"] else []: + for topic in { + topic_of(index.jurisdiction, label["category"], label["case_type"]) for label in group["_labels"] + }: + topic_counts[topic] = topic_counts.get(topic, 0) + 1 + for item in metadata["case_topics"]: + item["count"] = topic_counts.get(item["value"], 0) + metadata["case_topics"].sort(key=lambda item: (-item["count"], item["label"].casefold())) + actions = action_options(groups) + if action: + groups = filter_filing_groups(groups, index.jurisdiction, action=action) + categories = category_options(groups) + if category: + groups = filter_filing_groups(groups, index.jurisdiction, category=category) if not group_key: + strong = strong_matches(groups) + shown = groups[:strong] if strong_only and strong else groups + # Sealing an eviction, say, is filed in the existing case: a filer + # searching new-case filings is told the other list has matches. + other_stage_total = ( + count_groups( + index, + query, + not initial, + courts, + purpose=purpose, + document=document, + topic=selected_topic, + case_filters=case_filters, + action=action, + category=category, + ) + if not offset + else None + ) return { "groups": [ { **{key: value for key, value in group.items() if not key.startswith("_")}, + # Only the names shown: filing and case-type names without the + # qualifiers step 1 drops, and not categories (Cook files every + # Law-division injury under "Personal Injury/Wrongful Death"). + "glossary": glossary_for( + index.jurisdiction, + [ + group["name"], + *sorted({unqualified_case_type(label["case_type"]) for label in group["_labels"]}), + ], + ), **search_context( index.jurisdiction, query, @@ -901,9 +1091,13 @@ def search_grouped_paths( resolved_query=resolved_query, ), } - for group in groups[offset : offset + limit] + for group in shown[offset : offset + limit] ], **metadata, + "actions": actions, + "categories": categories, + "strong_total": strong, + "other_stage_total": other_stage_total, "eviction_facets": selected_topic == "eviction", "location_counties": counties, "total": len(groups), @@ -929,11 +1123,11 @@ def search_grouped_paths( names=group["_names"], court=court, contexts=contexts, - counties=counties, + courts=courts, ) paths, _ = matching_paths(index, query, initial) - if counties: - paths = paths.filter(county_filter(counties)) + if courts: + paths = paths.filter(court__code__in=courts) if contexts is not None: paths = paths.filter(context_filter(contexts)) courts = ( diff --git a/efile_app/efile/services/filing_code_sync.py b/efile_app/efile/services/filing_code_sync.py index 77c5bcbd..382f6400 100644 --- a/efile_app/efile/services/filing_code_sync.py +++ b/efile_app/efile/services/filing_code_sync.py @@ -193,19 +193,22 @@ def _insert_paths(index, snapshot, count, progress): progress(f"Saved {saved} of {count} changed filing paths") -def synchronize_index(jurisdiction, *, progress=None, force=(), dry_run=False): - """Re-download courts whose revision changed, plus any court codes in ``force``. +def synchronize_index(jurisdiction, *, progress=None, force=(), dry_run=False, catalog=None, full=False): + """Re-index courts whose revision changed, plus any court codes in ``force``. - ``dry_run`` downloads and checks everything, reports each changed court, and saves nothing. + ``catalog`` supplies courts (the proxy's HTTP export by default, or the local + copy of the EFSP database); ``full`` re-indexes every court. ``dry_run`` + reads and checks everything, reports each changed court, and saves nothing. """ started = timezone.now() baseline = FilingCodeIndex.objects.filter(jurisdiction=jurisdiction).first() previous = ( baseline if baseline and baseline.source_url == source_url() and baseline.rules_digest == rules()[1] else None ) - snapshots = previous.court_snapshots if previous else {} + snapshots = previous.court_snapshots if previous and not full else {} # Stage every changed court before publishing anything. A failure preserves the whole old index. - with closing(BulkCodeCatalog(jurisdiction)) as catalog, TemporaryDirectory(prefix="filing-sync-") as directory: + catalog = catalog or BulkCodeCatalog(jurisdiction) + with closing(catalog), TemporaryDirectory(prefix="filing-sync-") as directory: courts = catalog.manifest() if unknown := set(force) - set(courts): raise ValueError("Not filing courts in the proxy's catalog manifest: " + ", ".join(sorted(unknown))) @@ -274,6 +277,10 @@ def synchronize_index(jurisdiction, *, progress=None, force=(), dry_run=False): index.rules_digest = rules()[1] index.refreshed_at = started index.court_snapshots = next_snapshots - index.vocabulary = {"words": sorted(vocabulary), "tokens": sorted(tokens)} + index.vocabulary = { + "words": sorted(vocabulary), + "tokens": sorted(tokens), + "courts": {code: court["name"] for code, court in courts.items()}, + } index.save() return count diff --git a/efile_app/efile/services/glossary.py b/efile_app/efile/services/glossary.py new file mode 100644 index 00000000..c336b607 --- /dev/null +++ b/efile_app/efile/services/glossary.py @@ -0,0 +1,53 @@ +"""Plain-language meanings for court terms in filing and case-type names. + +Terms live in ``data/legal_glossary.yaml``: general ones, plus a state's own +under ``jurisdictions``, which replace the general entry with the same key. +""" + +from __future__ import annotations + +import re +from functools import lru_cache +from pathlib import Path + +import yaml + +GLOSSARY_PATH = Path(__file__).resolve().parents[1] / "data" / "legal_glossary.yaml" + + +@lru_cache(maxsize=1) +def _config(): + config = yaml.safe_load(GLOSSARY_PATH.read_bytes()) + if config.get("version") != 1: + raise ValueError("Unsupported glossary version") + return config + + +@lru_cache(maxsize=16) +def _terms(jurisdiction): + config = _config() + terms = {**config.get("terms", {}), **config.get("jurisdictions", {}).get(jurisdiction, {}).get("terms", {})} + compiled = [] + for key, term in terms.items(): + words = "|".join(re.escape(word).replace(r"\ ", r"[\s-]+") for word in term["match"]) + compiled.append((key, re.compile(rf"(?/", self.admin_view(self.privacy_request), name="privacy_request"), path("analytics/", self.admin_view(self.analytics), name="analytics"), path("authenticator//", self.admin_view(self.authenticator), name="authenticator"), + path("filing-codes/", self.admin_view(self.filing_codes), name="filing_codes"), ] return extra + super().get_urls() @@ -355,6 +357,43 @@ def analytics(self, request): export_enabled=settings.LITEFILE_ANALYTICS_EXPORT_ENABLED, ) + def filing_codes(self, request): + """Resync codes from the EFSP, or rebuild search indexes; the code index worker runs them.""" + if not request.user.is_superuser: + raise PermissionDenied + from efile.services.filing_code_copy import status + from efile.services.filing_code_search import rules + + jurisdictions = rules()[0]["jurisdictions"] + if request.method == "POST": + kind = request.POST.get("kind", "") + jurisdiction = request.POST.get("jurisdiction", "") + if kind not in FilingCodeJob.Kind.values or jurisdiction not in ["", *jurisdictions]: + messages.error(request, "Choose a valid action and jurisdiction.") + elif FilingCodeJob.objects.filter( + kind=kind, jurisdiction=jurisdiction, status=FilingCodeJob.Status.QUEUED + ).exists(): + messages.info(request, "That is already queued.") + else: + FilingCodeJob.objects.create(kind=kind, jurisdiction=jurisdiction, requested_by=request.user) + audit(request.user, jurisdiction, f"filing_codes_{kind}", "queued") + messages.success( + request, + f"{FilingCodeJob.Kind(kind).label} queued for {jurisdiction.capitalize() or 'every jurisdiction'}. " + "The code index worker starts it within a minute.", + ) + return redirect("litefile_staff:filing_codes") + return self.page( + request, + "filing_codes", + title="Filing codes", + jurisdictions=jurisdictions, + statuses=[status(jurisdiction) for jurisdiction in jurisdictions], + jobs=FilingCodeJob.objects.select_related("requested_by")[:15], + schedule=f"{settings.FILING_CODE_SYNC_TIME} {settings.FILING_CODE_SYNC_TIMEZONE}", + source=settings.FILING_CODE_SYNC_MODE, + ) + def authenticator(self, request, account_id): if not request.user.is_superuser: raise PermissionDenied diff --git a/efile_app/efile/static/css/components/filing-code-search.css b/efile_app/efile/static/css/components/filing-code-search.css index b14dfd02..8ad5b480 100644 --- a/efile_app/efile/static/css/components/filing-code-search.css +++ b/efile_app/efile/static/css/components/filing-code-search.css @@ -6,14 +6,14 @@ } .code-search { - width: min(64rem, calc(100% - 2rem)); - height: min(54rem, calc(100dvh - 2rem)); + width: min(48rem, calc(100% - 2rem)); + height: min(60rem, calc(100dvh - 2rem)); max-height: calc(100dvh - 2rem); - padding: 1rem; + padding: 0; border: 1px solid #cbd5e1; - border-radius: 0.85rem; - color: #172b4d; - overflow-y: auto; + border-radius: 1rem; + color: #48576c; + overflow: hidden; } .code-search::backdrop { @@ -25,265 +25,554 @@ flex-direction: column; } -.code-search> :not(.code-search__results) { - flex-shrink: 0; +.code-search h2, +.code-search h3, +.code-search legend, +.code-search label, +.code-search dd { + color: #1e3a5f; } -.code-search__heading, -.code-search__actions { +.code-search__heading { display: flex; - align-items: center; + align-items: flex-start; justify-content: space-between; gap: 1rem; + padding: 1.5rem 1.5rem 0; } .code-search__heading h2 { + margin: 0; font-size: 1.5rem; - margin-block-end: 0; + font-weight: 600; } -#code-search-help, -#code-search-status { - margin-block: 0.375rem; +.code-search__heading h2:focus { + outline: none; } -.code-search__actions { - justify-content: flex-end; - margin-block-start: 1rem; +.code-search__heading h2:focus-visible { + outline: 3px solid #b9cdeb; + outline-offset: 2px; +} + +.code-search__step { + margin: 0 0 0.25rem; + font-size: 0.875rem; + color: #616c82; +} + +#code-search-help { + margin: 0.375rem 1.5rem 0; } -.code-search__results { +.code-search__body { flex: 1 1 auto; - min-width: 0; - min-height: 8rem; + min-height: 0; + padding: 1.25rem 1.5rem 1rem; overflow-y: auto; overscroll-behavior: contain; } -.code-search__result { - padding: 0.5rem; - border-block-end: 1px solid #d6e1ef; - overflow-wrap: anywhere; +.code-search__panel { + display: flex; + flex-direction: column; + gap: 1rem; } -.code-search__filters { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 0.5rem; - margin-block-start: 0.5rem; +.code-search__panel[hidden] { + display: none; +} + +.code-search__actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 1rem 1.5rem 1.25rem; + border-block-start: 1px solid #e3e8ef; +} + +.code-search__actions .btn { + min-height: 2.75rem; +} + +.code-search__actions .btn-primary { + margin-inline-start: auto; +} + +.code-search__link { + padding: 0; + border: 0; + background: none; + font: inherit; + color: #2c5aa0; + text-decoration: underline; + text-underline-offset: 3px; + cursor: pointer; +} + +@media (hover: hover) { + .code-search__link:hover { + color: #1e3a5f; + } } -.code-search__filters label, .code-search__meta { + margin: 0; font-size: 0.875rem; + color: #616c82; } -.code-search__group { +/* Step 1: what you're filing */ + +.code-search__lookup { display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 0.125rem 0.5rem; - align-items: start; + grid-template-columns: minmax(0, 1fr) 10rem; + gap: 0.75rem; + align-items: end; } -.code-search__group>h3 { - grid-column: 1; - grid-row: 1; - margin: 0; +.code-search__lookup:has(#code-search-zip-filter[hidden]) { + grid-template-columns: minmax(0, 1fr); +} + +.code-search__lookup label { + display: block; + margin-block-end: 0.375rem; + font-size: 0.875rem; + font-weight: 600; +} + +.code-search__lookup .form-control { + min-height: 3.25rem; + font-size: 1.0625rem; + border-radius: 0.625rem; } -.code-search__group>button { - grid-column: 2; - grid-row: 1; - padding: 0.25rem 0.5rem; +#code-search-zip { + letter-spacing: 0.04em; } -.code-search__group>.code-search__meta { - grid-column: 1; - grid-row: 2; +.code-search__context-line { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 0.5rem 1rem; + margin-block-start: -0.25rem; + font-size: 0.875rem; +} + +.code-search__context-line p { margin: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; } -.code-search__group>details, -.code-search__group-panel { - grid-column: 1 / -1; +.code-search__zip-help { + display: flex; + flex-direction: column; + gap: 0.25rem; } -.code-search__group-panel[hidden] { +.code-search__zip-help p:empty, +.code-search__zip-shortcuts[hidden] { display: none; } -.code-search__context-link { - padding: 0; - text-align: start; - white-space: normal; - vertical-align: baseline; +.code-search__stage { + margin-inline-start: auto; } -@media (width <=600px) { - .code-search__filters { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } +/* Short questions: one row each, label beside its chips. */ +.code-search__narrow { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.625rem 0.875rem; + border: 1px solid #dce3ec; + border-radius: 0.625rem; + background: #f9fafc; +} - .code-search__filters>div:nth-child(2) { - grid-column: 2; - grid-row: 1; - } +.code-search__narrow[hidden] { + display: none; +} - .code-search__group>button { - font-size: 0.875rem; - } +.code-search__question { + display: grid; + grid-template-columns: 11rem minmax(0, 1fr); + gap: 0.25rem 0.75rem; + align-items: baseline; } -.code-search__result:has(input:checked) { - background: #edf5ff; - border-radius: 0.5rem; +.code-search__question-label { + font-size: 0.8125rem; + font-weight: 600; + color: #1e3a5f; } -.code-search__result label { +.code-search__chips { display: flex; - align-items: baseline; - gap: 0.75rem; - font-weight: 600; + flex-wrap: wrap; + gap: 0.375rem; + min-width: 0; +} + +.code-search__chip { + display: inline-flex; + align-items: center; + gap: 0.375rem; + min-height: 2rem; + padding: 0 0.75rem; + border: 1px solid #c9d3e0; + border-radius: 999px; + background: #fff; + font: inherit; + font-size: 0.8125rem; + color: #1e3a5f; + cursor: pointer; +} + +@media (hover: hover) { + .code-search__chip:hover { + border-color: #9fb4d6; + } +} + +.code-search__chip[aria-pressed="true"] { + border-color: #2c5aa0; + background: #2c5aa0; + color: #fff; } -.code-search__result details, -.code-search__result p { - margin-block: 0.5rem; +.code-search__chip:focus-visible { + outline: 3px solid #b9cdeb; + outline-offset: 2px; } -.code-search__group>details { - margin-block: 0.125rem; +.code-search__chip-count { + font-size: 0.75rem; + color: #48576c; } -.code-search__path { - padding-inline-start: 1.25rem; +.code-search__chip[aria-pressed="true"] .code-search__chip-count { + color: #fff; +} + +.code-search__amount-help { + margin-inline-start: 11.75rem; + font-size: 0.8125rem; +} + +#code-search-status { + margin: 0; + font-size: 0.875rem; + color: #616c82; } -.code-search__path li { - margin-block: 0.25rem; +#code-search-suggestions h3 { + font-size: 1.125rem; } .code-search__suggestions { display: flex; flex-wrap: wrap; gap: 0.75rem; - margin-block-end: 1rem; } -#code-search-suggestions h3, -.code-search__group h3 { - font-size: 1.125rem; +.code-search__results, +#code-search-paths { + display: flex; + flex-direction: column; + gap: 0.625rem; + min-width: 0; } -#code-search-zip-help { - margin-block: 0.25rem 0; +.code-search__results:empty { + display: none; } -.code-search__path-choice { - position: relative; - padding-block: 0.5rem; +.code-search__result { + cursor: pointer; + padding: 0.875rem 1rem; + border: 1px solid #dce3ec; + border-radius: 0.625rem; + background: #fff; + overflow-wrap: anywhere; } -.code-search__path-choice>label { +@media (hover: hover) { + .code-search__result:hover { + border-color: #9fb4d6; + } +} + +.code-search__result:has(input:checked) { + border-color: #2c5aa0; + background: #f2f6fc; + box-shadow: inset 0 0 0 1px #2c5aa0; +} + +.code-search__choice { + display: flex; + align-items: baseline; + gap: 0.75rem; margin: 0; - padding-inline-end: 5rem; - font-weight: 500; + font-size: 1rem; + font-weight: 600; cursor: pointer; } -.code-search__path-choice>details { - margin: 0; +.code-search__choice input { + flex-shrink: 0; + accent-color: #2c5aa0; +} + +.code-search__result>p, +.code-search__result>details { + margin: 0.25rem 0 0 1.75rem; +} + +.code-search__result>.code-search__meta { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.code-search__result details { font-size: 0.875rem; } -.code-search__path-choice>details>summary { - position: absolute; - inset-block-start: 0.5rem; - inset-inline-end: 0.5rem; +.code-search__result details p, +.code-search__result details ul { + margin-block: 0.375rem; } -.code-search__path-choice>.code-search__variant, -.code-search__path-choice>.code-search__case-description { - margin: 0.125rem 5rem 0 1.5rem; +.code-search__variant, +.code-search__case-description { font-size: 0.875rem; } -.code-search__case-filters { +/* Step 2: court and case type */ + +.code-search__summary { display: flex; - flex-wrap: wrap; - gap: 0.25rem 1rem; - margin-block: 0.25rem; - font-size: 0.8125rem; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + padding: 1rem 1.125rem; + border-radius: 0.625rem; + background: #f6f8fb; + font-size: 0.9375rem; } -.code-search__case-filters[hidden] { - display: none; +.code-search__summary dl { + display: grid; + grid-template-columns: 7.5rem minmax(0, 1fr); + gap: 0.375rem 0.75rem; + margin: 0; +} + +.code-search__summary dt { + font-weight: 400; + color: #616c82; } -.code-search__case-filters>p { - flex-basis: 100%; +.code-search__summary dd { margin: 0; } -.code-search__case-filters fieldset { +.code-search__summary .code-search__link { + font-size: 0.875rem; + white-space: nowrap; +} + +.code-search__courts, +.code-search__case-types { + display: flex; + flex-direction: column; + gap: 0.625rem; min-width: 0; - border: 0; + margin: 0; padding: 0; + border: 0; +} + +.code-search__courts legend, +.code-search__courts>label, +.code-search__case-types legend { margin: 0; + font-size: 0.9375rem; + font-weight: 600; +} + +.code-search__case-types[hidden] { + display: none; +} + +.code-search__court-cards { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.625rem; } -.code-search__case-filters legend { - float: none; - width: auto; - font-size: inherit; +.code-search__card { + display: flex; + align-items: baseline; + gap: 0.75rem; + padding: 0.875rem 1rem; + border: 1px solid #dce3ec; + border-radius: 0.625rem; font-weight: 600; + cursor: pointer; +} + +@media (hover: hover) { + .code-search__card:hover { + border-color: #9fb4d6; + } +} + +.code-search__card:has(input:checked) { + border-color: #2c5aa0; + background: #f2f6fc; + box-shadow: inset 0 0 0 1px #2c5aa0; +} + +.code-search__card input { + flex-shrink: 0; + accent-color: #2c5aa0; +} + +#code-search-path-status { margin: 0; + font-size: 0.875rem; + color: #616c82; } -.code-search__case-filters label { - display: inline-flex; - align-items: center; - gap: 0.2rem; - margin-inline-end: 0.5rem; - cursor: pointer; +/* Step 3: check and use */ + +.code-search__confirm { + margin: 0; + border: 1px solid #dce3ec; + border-radius: 0.75rem; } -.code-search__court-controls { +.code-search__confirm-row { display: grid; - grid-template-columns: auto minmax(0, 18rem) minmax(0, 1fr); - align-items: center; - gap: 0.5rem; - margin-block: 0.5rem; + grid-template-columns: 9rem minmax(0, 1fr); + gap: 1rem; + align-items: baseline; + padding: 1rem 1.25rem; +} + +.code-search__confirm-row+.code-search__confirm-row { + border-block-start: 1px solid #e3e8ef; +} + +.code-search__confirm-row dt { font-size: 0.875rem; + font-weight: 400; + color: #616c82; } -.code-search__court-controls>p, -.code-search__court-controls>label { +.code-search__confirm-row dd { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; margin: 0; } -.code-search__amount-help { - align-self: end; - font-size: 0.8125rem; +.code-search__confirm-value { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.code-search__confirm-row .code-search__link { + font-size: 0.875rem; +} + +.code-search__explanation { + display: flex; + flex-direction: column; + gap: 0.375rem; + padding: 1rem 1.125rem; + border-radius: 0.625rem; + background: #f6f8fb; + font-size: 0.9375rem; +} + +.code-search__explanation p { + margin: 0; +} + +.code-search__explanation[hidden] { + display: none; } -.code-search__amount-help[open] { - flex-basis: 100%; +#code-search-apply-status { + margin: 0; } @media (width <=600px) { - .code-search__court-controls { - grid-template-columns: auto minmax(0, 1fr); + + .code-search__heading, + .code-search__body, + .code-search__actions { + padding-inline: 1rem; + } + + #code-search-help { + margin-inline: 1rem; + } + + .code-search__lookup, + .code-search__question { + grid-template-columns: minmax(0, 1fr); + } + + .code-search__amount-help { + margin-inline-start: 0; + } + + .code-search__stage { + margin-inline-start: 0; } - .code-search__court-controls>p { - grid-column: 1 / -1; + .code-search__court-cards { + grid-template-columns: minmax(0, 1fr); + } + + .code-search__summary dl, + .code-search__confirm-row { + grid-template-columns: minmax(0, 1fr); + gap: 0.25rem; } } -.code-search__group:has(.code-search__group-panel:not([hidden]))>.code-search__meta { - display: none; +.code-search__name-prefix.is-shared { + font-weight: 400; + color: #616c82; +} + +.code-search__chip--more { + border-style: dashed; +} + +.code-search__glossary { + font-size: 0.875rem; +} + +.code-search__glossary dl { + margin: 0.375rem 0 0; +} + +.code-search__glossary dt { + font-weight: 600; +} + +.code-search__glossary dd { + margin: 0 0 0.375rem; } \ No newline at end of file diff --git a/efile_app/efile/static/js/extraction-review.js b/efile_app/efile/static/js/extraction-review.js index 440bee84..bb53a9f7 100644 --- a/efile_app/efile/static/js/extraction-review.js +++ b/efile_app/efile/static/js/extraction-review.js @@ -778,6 +778,7 @@ jurisdiction: context.jurisdiction, existingCase: existingCaseWire, applyPath: applySearchPath, + zipShortcuts: context.zip_shortcuts || [], }); }); })(); \ No newline at end of file diff --git a/efile_app/efile/static/js/filing-code-search.js b/efile_app/efile/static/js/filing-code-search.js index ed34d4e5..dfca27ac 100644 --- a/efile_app/efile/static/js/filing-code-search.js +++ b/efile_app/efile/static/js/filing-code-search.js @@ -1,78 +1,174 @@ (function() { + const STEPS = { + filing: { + number: 1, + title: () => gettext("What are you filing?") + }, + path: { + number: 2, + title: () => gettext("Choose your court and case type") + }, + confirm: { + number: 3, + title: () => gettext("Check your choices") + }, + }; + // Up to this many courts show as cards; more fall back to a dropdown. + const COURT_CARD_LIMIT = 6; + // "What do you want to do?" is asked only once results are this many. + const ACTION_QUESTION_MIN = 8; + // A question with more choices than this shows the most used ones first. + const CHIPS_SHOWN = 5; + // Choices a court may code as separate case types, asked in this order and + // only when the court's paths differ on them. See path_qualifiers(). + const PATH_QUESTIONS = [{ + key: "amount", + label: () => gettext("How much are you asking for?"), + options: {} + }, { + key: "representation", + label: () => gettext("Do you have a lawyer?"), + options: { + self: () => gettext("No, I'm representing myself"), + lawyer: () => gettext("Yes") + } + }, { + key: "jury", + label: () => gettext("Do you want a jury?"), + options: { + none: () => gettext("No jury"), + jury: () => gettext("Jury"), + 6: () => gettext("Jury of 6"), + 12: () => gettext("Jury of 12") + } + }, { + key: "government", + label: () => gettext("Filing for a government body?"), + options: { + no: () => gettext("No"), + yes: () => gettext("Yes") + }, + // Almost no one filing here is; the answer stays visible to change. + assume: "no" + }]; + const ZIP_PATTERN = /^\d{5}(?:-\d{4})?$/; + function mount({ jurisdiction, existingCase, - applyPath + applyPath, + zipShortcuts = [] }) { const dialog = document.getElementById("code-search-dialog"); const open = document.getElementById("open-code-search"); if (!dialog || !open) return; - const query = document.getElementById("code-search-query"); - const status = document.getElementById("code-search-status"); - const results = document.getElementById("code-search-results"); - const apply = document.getElementById("apply-code-search"); - const more = document.getElementById("code-search-more"); - const suggestions = document.getElementById("code-search-suggestions"); - const purpose = document.getElementById("code-search-purpose"); - const stage = document.getElementById("code-search-stage"); - const documentKind = document.getElementById("code-search-document"); - const postalCode = document.getElementById("code-search-zip"); - if (jurisdiction === "illinois") { - document.getElementById("code-search-zip-filter").hidden = false; - document.getElementById("code-search-zip-help").hidden = false; - } - postalCode.addEventListener("input", () => { - invalidate(); - clearResults(); - timer = setTimeout(() => search(), 350); - }); - const caseFilters = document.getElementById("code-search-case-filters"); + const byId = id => document.getElementById(id); + const title = byId("code-search-title"); + const stepLabel = byId("code-search-step"); + const panels = { + filing: byId("code-search-step-filing"), + path: byId("code-search-step-path"), + confirm: byId("code-search-step-confirm"), + }; + const query = byId("code-search-query"); + const status = byId("code-search-status"); + const results = byId("code-search-results"); + const more = byId("code-search-more"); + const otherStage = byId("code-search-other-stage"); + const suggestions = byId("code-search-suggestions"); + const postalCode = byId("code-search-zip"); + const zipStatus = byId("code-search-zip-status"); + const zipShortcutList = byId("code-search-zip-shortcuts"); + const stageLabel = byId("code-search-stage-label"); + const stageToggle = byId("code-search-stage-toggle"); + const moreFilters = byId("code-search-more-filters"); + const filterPanel = byId("code-search-filters"); + const caseFilters = byId("code-search-case-filters"); + const summary = byId("code-search-summary"); + const courtsBox = byId("code-search-courts"); + const pathStatus = byId("code-search-path-status"); + const pathQuestions = byId("code-search-path-questions"); + const caseTypes = byId("code-search-case-types"); + const paths = byId("code-search-paths"); + const morePaths = byId("code-search-more-paths"); + const confirmList = byId("code-search-confirm"); + const explanation = byId("code-search-explanation"); + const applyStatus = byId("code-search-apply-status"); + const cancel = byId("cancel-code-search"); + const back = byId("code-search-back"); + const next = byId("code-search-next"); + const apply = byId("apply-code-search"); + + let step = "filing"; + let stage = "no"; + let documentKind = ""; + let action = ""; + let category = ""; + const expanded = new Set(); + let showAll = false; + let strongTotal = 0; + let activeZip = ""; let caseTopic = ""; let caseChoices = {}; let caseFilterSchema = ""; - const caseFilterValues = () => ({ - case_topic: caseTopic, - case_filters: JSON.stringify(caseChoices) - }); - caseFilters.addEventListener("change", event => { - const key = event.target.dataset.caseFacet; - if (key === "topic") { - caseTopic = event.target.value; - caseChoices = {}; - } else if (key) { - if (event.target.value) caseChoices[key] = event.target.value; - else delete caseChoices[key]; - } - search(); - }); - - function resetCaseFilters() { - caseTopic = ""; - caseChoices = {}; - caseFilterSchema = ""; - caseFilters.hidden = true; - } - const filters = [purpose, stage, documentKind]; + let lastFacets = null; + let lastCounties = []; let generation = 0; let controller; let timer; - let selected = null; let offset = 0; + let chosen = null; + let selected = null; + let pathGeneration = 0; + let pathController; + let loadedFor = null; + let court = ""; + let pathOffset = 0; + let courtPaths = []; + let pathAnswers = {}; let applying = false; - function invalidate() { - generation += 1; - controller?.abort(); - controller = null; - clearTimeout(timer); - selected = null; - apply.disabled = true; - more.hidden = true; + function element(tag, text, className) { + const node = document.createElement(tag); + if (text) node.textContent = text; + if (className) node.className = className; + return node; + } + + function link(text, href) { + const node = element("a", text); + node.href = href; + node.target = "_blank"; + node.rel = "noopener noreferrer"; + return node; + } + + function chip(text, pressed, data) { + const button = element("button", text, "code-search__chip"); + button.type = "button"; + button.setAttribute("aria-pressed", String(pressed)); + Object.assign(button.dataset, data); + return button; + } + + function pressOnly(button) { + for (const sibling of button.parentElement.querySelectorAll(".code-search__chip")) { + sibling.setAttribute("aria-pressed", String(sibling === button)); + } + } + + function list(items) { + return items.length > 1 ? + interpolate(gettext("%(first)s and %(last)s"), { + first: items.slice(0, -1).join(", "), + last: items.at(-1) + }, true) : items[0] || ""; } async function request(params, signal) { const response = await fetch(`/api/filing-code-search/?${new URLSearchParams({ - jurisdiction, zip: postalCode.value.trim(), existing_case: stage.value, purpose: purpose.value, document: documentKind.value, grouped: "true", ...caseFilterValues(), ...params, + jurisdiction, zip: activeZip, existing_case: stage, document: documentKind, grouped: "true", + case_topic: caseTopic, case_filters: JSON.stringify(caseChoices), action, category, strong: String(!showAll), ...params, })}`, { signal, headers: { @@ -98,21 +194,102 @@ return data; } - function element(tag, text, className) { - const node = document.createElement(tag); - if (text) node.textContent = text; - if (className) node.className = className; - return node; + // Steps + + function showStep(name, { + focus = true + } = {}) { + step = name; + for (const [key, panel] of Object.entries(panels)) panel.hidden = key !== name; + stepLabel.textContent = interpolate(gettext("Step %(number)s of 3"), { + number: STEPS[name].number + }, true); + title.textContent = STEPS[name].title(); + cancel.hidden = name !== "filing"; + back.hidden = name === "filing"; + next.hidden = name === "confirm"; + apply.hidden = name !== "confirm"; + updateNext(); + if (focus) title.focus(); } - function addSearchContext(data, target) { - const meanings = data.concepts || []; - const reason = data.match_reason?.kind === "related_concept" ? data.match_reason : null; - if (!reason && !meanings.some(item => item.text)) return null; - const details = element("details", "", reason ? "code-search__match-reason" : "code-search__concept-info"); - details.append(element("summary", reason ? gettext("Why is this result shown?") : gettext("About this filing type"))); + function updateNext() { + next.textContent = step === "path" || chosen?.path ? + gettext("Next: check and use") : gettext("Next: court and case type"); + next.disabled = step === "filing" ? !chosen : !selected; + apply.disabled = !selected || applying; + } + + function goForward() { + if (step === "filing" && chosen?.path) { + selected = chosen.path; + renderConfirm(); + showStep("confirm"); + } else if (step === "filing" && chosen) { + showStep("path"); + if (loadedFor !== chosen) loadCourts(); + } else if (step === "path" && selected) { + renderConfirm(); + showStep("confirm"); + } + } + + function goBack() { + if (step === "confirm" && !chosen?.path) showStep("path"); + else showStep("filing"); + } + + // Step 1: search, ZIP, and case questions + + function invalidate() { + generation += 1; + controller?.abort(); + controller = null; + clearTimeout(timer); + chosen = null; + resetPaths(); + more.hidden = true; + updateNext(); + } + + function resetPaths() { + pathGeneration += 1; + pathController?.abort(); + pathController = null; + loadedFor = null; + selected = null; + } + + function clearResults() { + results.replaceChildren(); + } + + // "What does “subrogation” mean?", from the state's glossary. + function addGlossary(terms, target) { + if (!terms?.length) return; + const details = element("details", "", "code-search__glossary"); + details.append(element("summary", terms.length === 1 ? + interpolate(gettext("What does “%(term)s” mean?"), { + term: terms[0].label.toLowerCase() + }, true) : gettext("What do these terms mean?"))); + const definitions = element("dl"); + for (const term of terms) { + const meaning = element("dd", term.text); + if (term.source) meaning.append(" ", link(gettext("Learn more"), term.source)); + definitions.append(element("dt", term.label), meaning); + } + details.append(definitions); target.append(details); - for (const concept of meanings.filter(item => item.text)) { + } + + function addMeanings(data, target) { + addGlossary(data.glossary, target); + const meanings = (data.concepts || []).filter(item => item.text); + const variants = data.variants || []; + if (!meanings.length && variants.length < 2) return; + const details = element("details", "", "code-search__concept-info"); + details.append(element("summary", gettext("About this filing type"))); + for (const concept of meanings) { const definition = element("div", "", "code-search__meaning"); let scope = gettext("General meaning"); if (concept.scope === "county") scope = interpolate(gettext("Meaning in %(name)s County"), { @@ -122,371 +299,316 @@ name: concept.scope_name }, true); definition.append(element("p", `${concept.label} — ${scope}: ${concept.text}`)); - if (concept.source) { - const source = element("a", gettext("About this concept")); - source.href = concept.source; - source.target = "_blank"; - source.rel = "noopener noreferrer"; - definition.append(source); - } + if (concept.source) definition.append(link(gettext("About this concept"), concept.source)); details.append(definition); } - if (!reason) return details; - const labels = meanings.filter(item => reason.concepts.includes(item.key)).map(item => item.label); - const text = interpolate(gettext('Your search for “%(query)s” relates to %(concepts)s. This filing name or its associated case information deals with similar concepts.'), { - query: reason.query, - concepts: labels.join(", ") - }, true); - details.insertBefore(element("p", text), details.children[1] || null); - return details; + if (variants.length > 1) details.append(element("p", gettext("Also listed as:") + " " + variants.slice(1).join("; "))); + target.append(details); } - function addFacetSummary(data, target) { - const facets = data.facets || {}; - const purposes = { - starting: gettext("Starting a claim"), - responding: gettext("Responding to a claim"), - either: gettext("Either side") - }; - const documents = { - main: gettext("Main document"), - attachment: gettext("Attachment / exhibit") - }; - const labels = [purposes[facets.purpose], documents[facets.document]].filter(Boolean); + // Only when the search found this through a different word, such as + // "unlawful detainer" finding an eviction filing. + function relatedTerms(data) { + const reason = data.match_reason?.kind === "related_concept" ? data.match_reason : null; + if (!reason) return ""; + const asked = words(reason.query); + const labels = (data.concepts || []) + .filter(item => reason.concepts.includes(item.key) && words(item.label) !== asked) + .map(item => item.label.toLowerCase()); + return labels.length ? interpolate(gettext("Related to %(terms)s"), { + terms: list(labels) + }, true) : ""; + } + + function words(text) { + return String(text || "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); + } + + function contextSummary(data) { const contexts = data.case_contexts || []; - if (contexts.length) { - const context = data.case_context_count > 1 ? - interpolate(gettext("%(count)s case types, including %(context)s"), { - context: contexts[0], - count: data.case_context_count - }, true) : contexts[0]; - labels.unshift(context); - } - if (labels.length) { - const summary = element("p", labels.join(" · "), "code-search__meta"); - summary.title = summary.textContent; - target.append(summary); - } + if (!contexts.length) return ""; + return data.case_context_count > 1 ? + interpolate(gettext("%(count)s case types, including %(context)s"), { + context: contexts[0], + count: data.case_context_count + }, true) : contexts[0]; } - function addResult(path, target = results, group = null) { - const row = element("div", "", group ? "code-search__result code-search__path-choice" : "code-search__result"); - const label = element("label"); - const radio = document.createElement("input"); + function choiceRow(name, onChoose, disabled = false) { + const row = element("div", "", "code-search__result"); + const label = element("label", "", "code-search__choice"); + const radio = element("input"); radio.type = "radio"; - radio.name = "code-search-result"; - radio.value = path.id; - radio.disabled = Boolean(path.unavailable); - const choiceName = group ? (path.case_context || path.case_type.name) : path.filing_type.name; - label.append(radio, element("span", choiceName)); - row.append(label); - if (!group) { - addFacetSummary(path, row); - row.append(element("p", `${path.case_context || path.case_type.name} · ${path.court.name}`, "code-search__meta")); - } else if (path.filing_type.name.toLowerCase() !== group.name.toLowerCase()) { - row.append(element("p", path.filing_type.name, "code-search__variant")); + radio.name = "code-search-filing"; + radio.disabled = disabled; + // A leading "Complaint / Petition - " that many results share is + // muted (see markSharedPrefixes) so the words that differ stand out. + const [, prefix, rest] = name.match(/^(.+?\s-\s)(.+)$/) || [null, "", name]; + const text = element("span", "", "code-search__name"); + if (prefix) { + const muted = element("span", prefix, "code-search__name-prefix"); + muted.dataset.prefix = prefix; + text.append(muted); } - if (group && path.case_description) { - const description = element("p", path.case_description, "code-search__case-description"); - description.id = `code-search-description-${path.id}`; - radio.setAttribute("aria-describedby", description.id); - row.append(description); - } - const details = element("details"); - details.append(element("summary", group ? gettext("Details") : gettext("Filing path"))); - if (group) { - addFacetSummary(path, details); - } - if (path.explanation) details.append(element("p", path.explanation)); - if (path.case_guidance?.source) { - const source = element("a", gettext("About this case type")); - source.href = path.case_guidance.source; - source.target = "_blank"; - source.rel = "noopener noreferrer"; - details.append(source); - } - if (path.explanation_source) { - const source = element("a", gettext("Court information")); - source.href = path.explanation_source; - source.target = "_blank"; - source.rel = "noopener noreferrer"; - details.append(source); - } - const list = element("ol", "", "code-search__path"); - const facets = [ - ["court", gettext("Court")], - ["case_category", gettext("Case category")], - ["case_type", gettext("Case type")], - ["filing_type", gettext("Filing type")], - ]; - facets.forEach(([key, title]) => list.append(element("li", `${title}: ${path[key].name}`))); - details.append(list); - row.append(details); - if (path.unavailable) row.append(element("p", path.unavailable)); - radio.addEventListener("change", () => { - selected = path; - apply.disabled = false; - for (const item of results.querySelectorAll("details")) item.open = !group && item === details; + text.append(rest); + label.append(radio, text); + row.append(label); + radio.addEventListener("change", onChoose); + // The whole card chooses it, except the "About" disclosure and links. + row.addEventListener("click", event => { + if (!event.target.closest("details, a, label, input")) radio.click(); }); - target.append(row); + return { + row, + radio + }; } function addGroup(group, searchText) { - const row = element("div", "", "code-search__result code-search__group"); - row.append(element("h3", group.name)); - let info = addSearchContext(group, row); - addFacetSummary(group, row); - if (!info && (group.case_contexts?.length || group.variants.length > 1)) { - info = element("details"); - info.append(element("summary", gettext("About this filing type"))); - row.append(info); - } - if (group.case_contexts?.length) { - info.append(element("p", gettext("Used in these case types:"))); - const contexts = element("ul"); - for (const context of group.case_contexts) { - const item = element("li"); - const option = group.case_context_options?.find(candidate => candidate.label === context); - if (option) { - const link = element("button", context, "btn btn-link code-search__context-link"); - link.type = "button"; - link.addEventListener("click", () => { - contextKey = option.key; - contextLabel.textContent = interpolate(gettext("Case type: %(name)s"), { - name: context - }, true); - contextChoice.hidden = false; - loadCourts(); - }); - item.append(link); - } else item.textContent = context; - contexts.append(item); - } - info.append(contexts); - if (group.case_context_count > group.case_contexts.length) { - info.append(element("p", gettext("More case types are available. Choose a court to see its full filing paths."))); - } + const choice = { + group, + query: searchText + }; + const { + row + } = choiceRow(group.name, () => { + chosen = choice; + updateNext(); + }); + row.classList.add("code-search__group"); + const meta = [contextSummary(group), relatedTerms(group)].filter(Boolean).join(" · "); + if (meta) { + const summaryLine = element("p", meta, "code-search__meta"); + summaryLine.title = meta; + row.append(summaryLine); } - if (group.variants.length > 1) { - info.append(element("p", gettext("Also listed as:") + " " + group.variants.slice(1).join("; "))); - } - const choose = element("button", gettext("Choose a court"), "btn btn-outline-primary"); - choose.type = "button"; - choose.setAttribute("aria-label", interpolate(gettext("Choose a court for %(name)s"), { - name: group.name - }, true)); - const panel = element("div", "", "code-search__group-panel"); - panel.hidden = true; - const message = element("p"); - message.setAttribute("role", "status"); - const contextChoice = element("div"); - contextChoice.hidden = true; - const contextLabel = element("span"); - const resetContext = element("button", gettext("Show all case types"), "btn btn-link"); - resetContext.type = "button"; - contextChoice.append(contextLabel, resetContext); - const label = element("label", gettext("Court")); - const court = element("select", "", "form-select form-select-sm"); - court.id = `code-search-court-${group.key}`; - label.htmlFor = court.id; - const paths = element("div"); - const next = element("button", gettext("Show more paths in this court"), "btn btn-link"); - next.type = "button"; - next.hidden = true; - const courtControls = element("div", "", "code-search__court-controls"); - courtControls.append(label, court, message); - panel.append(contextChoice, courtControls, paths, next); - row.append(choose, panel); + addMeanings(group, row); results.append(row); - const token = generation; - const signal = controller.signal; - let detailGeneration = 0; - let courtGeneration = 0; - let contextKey = ""; - let pathOffset = 0; - - async function loadPaths(append = false) { - const detailToken = ++detailGeneration; - if (!append) { - pathOffset = 0; - paths.replaceChildren(); - selected = null; - apply.disabled = true; - for (const input of results.querySelectorAll('input[name="code-search-result"]')) input.checked = false; - } - next.hidden = true; - if (!court.value) { - message.textContent = gettext("Choose a court to see its case types and filing paths."); - return; - } - message.textContent = gettext("Loading filing paths…"); - try { - const data = await request({ - q: searchText, - group: group.key, - context: contextKey, - court: court.value, - offset: pathOffset - }, signal); - if (token !== generation || detailToken !== detailGeneration || !dialog.open) return; - for (const path of data.results) addResult(path, paths, group); - pathOffset += data.results.length; - message.textContent = data.total ? gettext("Choose your case type.") : gettext("No matching paths in this court."); - next.hidden = pathOffset >= data.total || pathOffset >= 10000; - } catch (error) { - if (token === generation && detailToken === detailGeneration && error.name !== "AbortError") message.textContent = error.message; - } - } + } - court.addEventListener("change", () => loadPaths()); - next.addEventListener("click", () => loadPaths(true)); - async function loadCourts() { - const courtToken = ++courtGeneration; - ++detailGeneration; - paths.replaceChildren(); - selected = null; - apply.disabled = true; - next.hidden = true; - for (const input of results.querySelectorAll('input[name="code-search-result"]')) input.checked = false; - const previousCourt = court.value; - choose.disabled = true; - panel.hidden = false; - court.disabled = true; - message.textContent = gettext("Loading courts…"); - try { - const data = await request({ - q: searchText, - group: group.key, - context: contextKey - }, signal); - if (token !== generation || courtToken !== courtGeneration || !dialog.open) return; - court.replaceChildren(element("option", gettext("Choose a court"))); - court.firstElementChild.value = ""; - const preferred = previousCourt || document.getElementById("court_code")?.value; - const prioritized = []; - for (const item of data.courts) { - if (item.code === preferred) prioritized.unshift(item); - else prioritized.push(item); - } - for (const item of prioritized) { - const option = element("option", item.name); - option.value = item.code; - court.append(option); - } - if (prioritized[0]?.code === preferred) court.value = preferred; - court.disabled = false; - choose.hidden = true; - court.focus(); - await loadPaths(); - } catch (error) { - if (token === generation && courtToken === courtGeneration && error.name !== "AbortError") { - message.textContent = error.message; - choose.disabled = false; - } - } + // Ungrouped results are already full paths, so they skip the court step. + function addPath(path) { + const { + row + } = choiceRow(path.filing_type.name, () => { + chosen = { + path + }; + updateNext(); + }, Boolean(path.unavailable)); + row.append(element("p", `${path.case_context || path.case_type.name} · ${path.court.name}`, "code-search__meta")); + if (path.unavailable) row.append(element("p", path.unavailable)); + results.append(row); + } + + function topicLabel(value) { + return lastFacets?.case_topics.find(item => item.value === value)?.label || ""; + } + + // Only rows ranked by how many results they hold collapse; a fixed + // question's choices always show. + function question(key, label, options, pressed, collapsible = false) { + const row = element("div", "", "code-search__question"); + row.setAttribute("role", "group"); + const name = element("span", label, "code-search__question-label"); + name.id = `code-search-question-${key}`; + row.setAttribute("aria-labelledby", name.id); + const chips = element("div", "", "code-search__chips"); + // "Not sure" (last) and the current answer always stay visible. + const collapse = collapsible && options.length > CHIPS_SHOWN + 2 && !expanded.has(key); + const shown = collapse ? + options.filter((option, index) => index < CHIPS_SHOWN || index === options.length - 1 || option.value === pressed) : + options; + for (const option of shown) { + const button = chip(option.label, option.value === pressed, { + caseFacet: key, + value: option.value + }); + button.id = `code-search-chip-${key}-${option.value || "any"}`; + if (option.count) button.append(element("span", String(option.count), "code-search__chip-count")); + chips.append(button); } - choose.addEventListener("click", loadCourts); - resetContext.addEventListener("click", () => { - contextKey = ""; - contextChoice.hidden = true; - loadCourts(); - }); + if (collapse) { + const moreChips = element("button", interpolate(gettext("%(count)s more"), { + count: options.length - shown.length + }, true), "code-search__chip code-search__chip--more"); + moreChips.type = "button"; + moreChips.id = `code-search-chip-${key}-more`; + moreChips.dataset.expand = key; + moreChips.setAttribute("aria-label", interpolate(gettext("Show %(count)s more choices for %(question)s"), { + count: options.length - shown.length, + question: label + }, true)); + chips.insertBefore(moreChips, chips.lastElementChild); + } + row.append(name, chips); + caseFilters.append(row); + return row; } - function clearResults() { - results.querySelectorAll(".code-search__result").forEach(row => row.remove()); - } - - function facetSelect(facet) { - const wrapper = element("div"); - const id = `code-search-facet-${facet.key}`; - const label = element("label", facet.label); - label.htmlFor = id; - const select = element("select", "", "form-select form-select-sm"); - select.id = id; - select.dataset.caseFacet = facet.key; - const any = element("option", facet.key === "topic" ? gettext("All case areas") : gettext("Any / not sure")); - any.value = facet.key === "topic" ? "all" : ""; - select.append(any); - for (const choice of facet.options) { - const option = element("option", choice.label); - option.value = choice.value; - select.append(option); - } - select.value = facet.key === "topic" ? caseTopic || "all" : caseChoices[facet.key] || ""; - wrapper.append(label, select); - return wrapper; - } - - function facetRadios(facet) { - const fieldset = element("fieldset"); - fieldset.append(element("legend", facet.label)); - for (const choice of [{ - value: "", - label: gettext("Any") - }, ...facet.options]) { - const label = element("label"); - const input = element("input"); - input.type = "radio"; - input.name = `code-search-${facet.key}`; - input.id = `code-search-${facet.key}-${choice.value || "any"}`; - input.dataset.caseFacet = facet.key; - input.value = choice.value; - input.checked = choice.value === (caseChoices[facet.key] || ""); - label.append(input, document.createTextNode(choice.label)); - fieldset.append(label); - } - return fieldset; + function addFacetHelp(facet, open) { + const facetHelp = element("details", "", "code-search__amount-help"); + const toggle = element("summary", gettext("About claim amounts")); + facetHelp.id = `code-search-help-${facet.key}`; + toggle.id = `${facetHelp.id}-toggle`; + facetHelp.open = open; + facetHelp.append(toggle, element("p", facet.help)); + if (facet.source) facetHelp.append(link(gettext("Court guidance"), facet.source)); + caseFilters.append(facetHelp); } function renderCaseFilters(data) { + lastFacets = data; caseTopic = data.case_topic || ""; - caseFilters.hidden = !data.case_topics.length && !Object.keys(caseChoices).length; - const schema = JSON.stringify([caseTopic, data.case_topics, data.case_facets]); + const actions = data.actions || []; + const askAction = action || (data.total >= ACTION_QUESTION_MIN && actions.length > 1); + const askTopic = data.case_topics.length > 1; + const categories = data.categories || []; + const askCategory = category || categories.length > 1; + const schema = JSON.stringify([caseTopic, caseChoices, action, category, [...expanded], askAction && actions, askCategory && categories, askTopic, data.case_topics, data.case_facets]); + caseFilters.hidden = !askAction && !askTopic && !askCategory && !data.case_facets.length; if (schema === caseFilterSchema) return; caseFilterSchema = schema; const focused = caseFilters.contains(document.activeElement) ? document.activeElement.id : ""; + const opened = new Set(Array.from(caseFilters.querySelectorAll("details[open]"), item => item.id)); caseFilters.replaceChildren(); - caseFilters.append(facetSelect({ - key: "topic", - label: gettext("Case area"), - options: data.case_topics - })); + const notSure = gettext("Not sure"); + if (askAction) question("action", gettext("What do you want to do?"), [...actions, { + value: "", + label: notSure + }], action); + if (askTopic) question("topic", gettext("Kind of case"), [...data.case_topics, { + value: "all", + label: notSure + }], caseTopic || "all", true); + if (askCategory) question("category", gettext("Case category"), [...categories, { + value: "", + label: notSure + }], category, true); for (const facet of data.case_facets) { - caseFilters.append(facet.control === "select" ? facetSelect(facet) : facetRadios(facet)); - if (facet.help) { - const help = element("details", "", "code-search__amount-help"); - help.append(element("summary", gettext("About claim amounts")), element("p", facet.help)); - if (facet.source) { - const source = element("a", gettext("Court guidance")); - source.href = facet.source; - source.target = "_blank"; - source.rel = "noopener noreferrer"; - help.append(source); - } - caseFilters.append(help); - } + question(facet.key, facet.label, [...facet.options, { + value: "", + label: notSure + }], caseChoices[facet.key] || ""); + if (facet.help) addFacetHelp(facet, opened.has(`code-search-help-${facet.key}`)); } - if (data.case_facets.length) caseFilters.append(element("p", gettext("Options with unspecified details stay included."), "code-search__meta")); - if (focused) document.getElementById(focused)?.focus(); + if (focused) byId(focused)?.focus(); } - function showSearchSummary(data) { - if (data.case_topics) renderCaseFilters(data); - const template = data.groups ? - gettext("Showing %(shown)s of %(total)s filing types.") : - gettext("Showing %(shown)s of %(total)s matching paths."); - status.textContent = data.total ? interpolate(template, { - shown: offset, - total: data.total + function resetCaseFilters() { + caseTopic = ""; + caseChoices = {}; + caseFilterSchema = ""; + action = ""; + category = ""; + expanded.clear(); + lastFacets = null; + caseFilters.hidden = true; + } + + function showZipStatus(counties) { + lastCounties = counties; + const typed = postalCode.value.trim(); + zipStatus.textContent = ""; + if (typed && !ZIP_PATTERN.test(typed)) zipStatus.textContent = gettext("Enter all 5 digits."); + else if (counties.length) zipStatus.textContent = interpolate(ngettext("%(zip)s is in %(names)s County.", "%(zip)s is in %(names)s counties.", counties.length), { + zip: typed.slice(0, 5), + names: list(counties) + }, true); + renderZipShortcuts(); + } + + function renderZipShortcuts() { + const typed = postalCode.value.trim().slice(0, 5); + const options = zipShortcuts.filter(item => item.zip !== typed); + zipShortcutList.hidden = !options.length; + zipShortcutList.replaceChildren(); + const own = options.find(item => item.kind === "address"); + const recent = options.filter(item => item.kind === "recent"); + const shortcut = (text, zip, label) => { + const button = element("button", text, "code-search__link"); + button.type = "button"; + button.dataset.zipShortcut = zip; + if (label) button.setAttribute("aria-label", label); + return button; + }; + if (own) { + zipShortcutList.append(shortcut(interpolate(gettext("Use your ZIP (%(zip)s)"), { + zip: own.zip + }, true), own.zip)); + } + if (recent.length) { + if (own) zipShortcutList.append(" · "); + zipShortcutList.append(gettext("Recent:") + " "); + recent.forEach((item, index) => { + if (index) zipShortcutList.append(", "); + zipShortcutList.append(shortcut(item.zip, item.zip, interpolate(gettext("Use recent ZIP %(zip)s"), { + zip: item.zip + }, true))); + }); + } + } + + function zipChanged() { + const typed = postalCode.value.trim(); + if (typed && !ZIP_PATTERN.test(typed)) { + showZipStatus([]); + return; + } + if (typed === activeZip) return showZipStatus(lastCounties); + activeZip = typed; + invalidate(); + clearResults(); + showZipStatus([]); + timer = setTimeout(() => search(), 350); + } + + function showOtherStage(count) { + otherStage.hidden = !count; + otherStage.textContent = stage === "yes" ? + interpolate(ngettext("%(count)s result in “new case” filings", "%(count)s results in “new case” filings", count), { + count }, true) : - gettext("No matching paths. Try fewer words, another term, or the court lists."); - if (data.location_counties?.length) status.textContent += " " + interpolate(gettext("Counties: %(names)s."), { - names: data.location_counties.join(", ") + interpolate(ngettext("%(count)s result in “existing case” filings", "%(count)s results in “existing case” filings", count), { + count + }, true); + } + + function markSharedPrefixes() { + const prefixes = Array.from(results.querySelectorAll("[data-prefix]")); + const counts = {}; + for (const node of prefixes) counts[node.dataset.prefix] = (counts[node.dataset.prefix] || 0) + 1; + for (const node of prefixes) node.classList.toggle("is-shared", counts[node.dataset.prefix] >= 3); + } + + function showSearchSummary(data, text) { + markSharedPrefixes(); + if (data.case_topics || data.categories) renderCaseFilters({ + case_topics: [], + case_facets: [], + ...data + }); + showZipStatus(data.location_counties || []); + strongTotal = data.strong_total || 0; + const narrowed = !showAll && strongTotal && strongTotal < data.total; + const shownTotal = narrowed ? strongTotal : data.total; + if (!data.total) status.textContent = gettext("No matching filing types. Try fewer words or another term."); + else if (narrowed) status.textContent = interpolate(ngettext("%(count)s filing type matches “%(query)s”", "%(count)s filing types match “%(query)s”", strongTotal), { + count: strongTotal, + query: text + }, true); + else status.textContent = interpolate(ngettext("%(total)s filing type", "%(total)s filing types", data.total), { + total: data.total + }, true); + if (data.corrected_terms.length) status.textContent += ". " + gettext("Similar spellings were included."); + if (data.stale) status.textContent += ". " + gettext("These lists may be out of date."); + // Only the first page carries it; later pages keep the link as is. + if (data.other_stage_total !== undefined && data.other_stage_total !== null) showOtherStage(data.other_stage_total); + const remaining = data.total - offset; + more.hidden = offset >= 10000 || remaining <= 0; + more.textContent = offset < shownTotal ? gettext("Show more") : interpolate(ngettext("Show %(count)s more filing type used in these case types", "Show %(count)s more filing types used in these case types", remaining), { + count: remaining }, true); - if (data.corrected_terms.length) status.textContent += " " + gettext("Similar spellings were included."); - if (data.stale) status.textContent += " " + gettext("These lists may be out of date. We will check your choice with the court."); - more.hidden = offset >= data.total || offset >= 10000; } async function search(append = false) { @@ -494,10 +616,12 @@ if (!append) { invalidate(); offset = 0; + showAll = false; clearResults(); } suggestions.hidden = Boolean(text); results.hidden = !text; + if (!append) otherStage.hidden = true; if (!text) { status.textContent = ""; return; @@ -505,7 +629,7 @@ const token = generation; controller ||= new AbortController(); more.hidden = true; - status.textContent = gettext("Searching filing paths…"); + status.textContent = gettext("Searching filing types…"); try { const data = await request({ q: text, @@ -513,9 +637,9 @@ }, controller.signal); if (token !== generation || !dialog.open) return; if (data.groups) data.groups.forEach(group => addGroup(group, text)); - else data.results.forEach(path => addResult(path)); + else data.results.forEach(path => addPath(path)); offset += (data.groups || data.results).length; - showSearchSummary(data); + showSearchSummary(data, text); } catch (error) { if (token === generation && error.name !== "AbortError") { status.textContent = error.message; @@ -527,22 +651,403 @@ } } + function showStage() { + stageLabel.textContent = stage === "yes" ? gettext("Existing case") : gettext("New case"); + } + + // Step 2: court, then case type + + function answers() { + const chosenLabels = []; + const chosenAction = lastFacets?.actions?.find(item => item.value === action); + if (chosenAction) chosenLabels.push(chosenAction.label); + if (category) chosenLabels.push(category); + if (caseTopic && caseTopic !== "all") chosenLabels.push(topicLabel(caseTopic)); + for (const facet of lastFacets?.case_facets || []) { + const option = facet.options.find(item => item.value === caseChoices[facet.key]); + if (option) chosenLabels.push(option.label); + } + if (documentKind) chosenLabels.push(filterPanel.querySelector(`[data-document="${documentKind}"]`).textContent.trim()); + return chosenLabels.filter(Boolean); + } + + function renderSummary() { + summary.replaceChildren(); + const add = (term, value) => { + if (!value) return; + summary.append(element("dt", term), element("dd", value)); + }; + add(gettext("Filing"), chosen.group.name); + add(gettext("Case"), stage === "yes" ? gettext("Existing case") : gettext("New case")); + if (activeZip) { + add(gettext("Case ZIP"), lastCounties.length ? interpolate(gettext("%(zip)s · %(names)s"), { + zip: activeZip, + names: list(lastCounties) + }, true) : activeZip); + } + add(gettext("Your answers"), answers().join(" · ")); + } + + async function loadCourts() { + resetPaths(); + const target = chosen; + loadedFor = target; + const token = pathGeneration; + pathController = new AbortController(); + renderSummary(); + courtsBox.replaceChildren(); + paths.replaceChildren(); + caseTypes.hidden = true; + morePaths.hidden = true; + updateNext(); + pathStatus.textContent = gettext("Loading courts…"); + try { + const data = await request({ + q: target.query, + group: target.group.key + }, pathController.signal); + if (token !== pathGeneration || !dialog.open) return; + const preferred = court || byId("court_code")?.value; + const courts = [...data.courts].sort((a, b) => (b.code === preferred) - (a.code === preferred)); + if (courts.some(item => item.code === preferred)) court = preferred; + else court = courts.length === 1 ? courts[0].code : ""; + if (!courts.length) { + pathStatus.textContent = gettext("No court accepts this filing with your answers. Go back and change them."); + return; + } + renderCourts(courts); + await loadPaths(); + } catch (error) { + if (token === pathGeneration && error.name !== "AbortError") pathStatus.textContent = error.message; + } + } + + function renderCourts(courts) { + const question = gettext("Which court will hear the case?"); + if (courts.length <= COURT_CARD_LIMIT) { + const fieldset = element("fieldset", "", "code-search__courts"); + fieldset.append(element("legend", question)); + const grid = element("div", "", "code-search__court-cards"); + for (const item of courts) { + const label = element("label", "", "code-search__card"); + const radio = element("input"); + radio.type = "radio"; + radio.name = "code-search-court"; + radio.value = item.code; + radio.checked = item.code === court; + radio.addEventListener("change", () => { + court = item.code; + loadPaths(); + }); + label.append(radio, element("span", item.name)); + grid.append(label); + } + fieldset.append(grid); + courtsBox.append(fieldset); + } else { + const wrapper = element("div", "", "code-search__courts"); + const label = element("label", question); + const select = element("select", "", "form-select"); + select.id = "code-search-court"; + label.htmlFor = select.id; + const blank = element("option", gettext("Choose a court")); + blank.value = ""; + select.append(blank); + for (const item of courts) { + const option = element("option", item.name); + option.value = item.code; + select.append(option); + } + select.value = court; + select.addEventListener("change", () => { + court = select.value; + loadPaths(); + }); + wrapper.append(label, select); + courtsBox.append(wrapper); + } + } + + function addCaseType(path) { + const row = element("div", "", "code-search__result code-search__path-choice"); + const label = element("label", "", "code-search__choice"); + const radio = element("input"); + radio.type = "radio"; + radio.name = "code-search-result"; + radio.value = path.id; + radio.disabled = Boolean(path.unavailable); + label.append(radio, element("span", path.case_context || path.case_type.name)); + row.append(label); + const described = []; + if ((path.filing_label || path.filing_type.name).toLowerCase() !== chosen.group.name.toLowerCase()) { + const variant = element("p", interpolate(gettext("This court calls it “%(name)s”."), { + name: path.filing_type.name + }, true), "code-search__variant"); + variant.id = `code-search-variant-${path.id}`; + described.push(variant.id); + row.append(variant); + } + if (path.case_description) { + const description = element("p", path.case_description, "code-search__case-description"); + description.id = `code-search-description-${path.id}`; + described.push(description.id); + row.append(description); + } + if (described.length) radio.setAttribute("aria-describedby", described.join(" ")); + if (path.unavailable) row.append(element("p", path.unavailable)); + radio.addEventListener("change", () => { + selected = path; + updateNext(); + }); + paths.append(row); + } + + function qualifierValue(path, key) { + const value = path.qualifiers?.[key]; + return key === "amount" ? value?.value || "" : value || ""; + } + + function qualifierChoices(questionSpec, pathList) { + const values = new Map(); + for (const path of pathList) { + const value = qualifierValue(path, questionSpec.key); + if (!value) continue; + const label = questionSpec.key === "amount" ? path.qualifiers.amount.label : questionSpec.options[value]?.(); + values.set(value, label || value); + } + return values; + } + + // Each question's choices, from the paths the earlier answers leave. + function askedQuestions() { + let remaining = courtPaths; + const asked = []; + for (const questionSpec of PATH_QUESTIONS) { + const values = qualifierChoices(questionSpec, remaining); + if (values.size < 2) continue; + let answer = pathAnswers[questionSpec.key] ?? questionSpec.assume ?? ""; + if (!values.has(answer)) answer = ""; + asked.push({ + ...questionSpec, + answer, + choices: [...values].sort(([a], [b]) => String(a).localeCompare(String(b), undefined, { + numeric: true + })) + }); + // A path that doesn't say stays in, as with step 1's questions. + if (answer) remaining = remaining.filter(path => [answer, ""].includes(qualifierValue(path, questionSpec.key))); + } + return { + asked, + remaining + }; + } + + function renderCaseTypes() { + const { + asked, + remaining + } = askedQuestions(); + const focused = pathQuestions.contains(document.activeElement) ? document.activeElement.id : ""; + pathQuestions.replaceChildren(); + for (const item of asked) { + const row = element("div", "", "code-search__question"); + row.setAttribute("role", "group"); + const name = element("span", item.label(), "code-search__question-label"); + name.id = `code-search-path-question-${item.key}`; + row.setAttribute("aria-labelledby", name.id); + const chips = element("div", "", "code-search__chips"); + for (const [value, label] of [...item.choices, ["", gettext("Not sure")]]) { + const button = chip(label, value === item.answer, { + pathQuestion: item.key, + value + }); + button.id = `code-search-path-chip-${item.key}-${value || "any"}`; + chips.append(button); + } + row.append(name, chips); + pathQuestions.append(row); + } + pathQuestions.hidden = !asked.length; + if (focused) byId(focused)?.focus(); + if (selected && !remaining.includes(selected)) selected = null; + paths.replaceChildren(); + const terms = new Map(remaining.flatMap(path => path.glossary || []).map(term => [term.key, term])); + addGlossary([...terms.values()], paths); + remaining.forEach(addCaseType); + for (const radio of paths.querySelectorAll("input")) radio.checked = String(selected?.id) === radio.value; + caseTypes.hidden = !remaining.length; + pathStatus.textContent = remaining.length ? + interpolate(ngettext("%(count)s case type fits", "%(count)s case types fit", remaining.length), { + count: remaining.length + }, true) : gettext("No matching case types in this court."); + updateNext(); + } + + async function loadPaths(append = false) { + const token = ++pathGeneration; + pathController?.abort(); + pathController = new AbortController(); + if (!append) { + pathOffset = 0; + courtPaths = []; + paths.replaceChildren(); + pathQuestions.hidden = true; + selected = null; + updateNext(); + } + morePaths.hidden = true; + if (!court) { + caseTypes.hidden = true; + pathStatus.textContent = gettext("Choose a court to see its case types."); + return; + } + pathStatus.textContent = gettext("Loading case types…"); + try { + const data = await request({ + q: chosen.query, + group: chosen.group.key, + court, + offset: pathOffset, + limit: 500 + }, pathController.signal); + if (token !== pathGeneration || !dialog.open) return; + courtPaths = courtPaths.concat(data.results); + pathOffset += data.results.length; + renderCaseTypes(); + morePaths.hidden = pathOffset >= data.total || pathOffset >= 10000; + } catch (error) { + if (token === pathGeneration && error.name !== "AbortError") pathStatus.textContent = error.message; + } + } + + // Step 3: check and use + + function renderConfirm() { + confirmList.replaceChildren(); + applyStatus.textContent = ""; + const row = (term, value, note, changeStep, changeLabel) => { + const item = element("div", "", "code-search__confirm-row"); + const detail = element("dd"); + const text = element("span", "", "code-search__confirm-value"); + text.append(element("span", value)); + if (note) text.append(element("span", note, "code-search__meta")); + detail.append(text); + if (changeStep) { + const change = element("button", gettext("Change"), "code-search__link"); + change.type = "button"; + change.dataset.codeSearchStep = changeStep; + change.append(element("span", " " + changeLabel, "visually-hidden")); + detail.append(change); + } + item.append(element("dt", term), detail); + confirmList.append(item); + }; + const viaGroup = !chosen?.path; + row(gettext("Court"), selected.court.name, "", viaGroup && "path", gettext("court")); + row(gettext("Case category"), selected.case_category.name); + row(gettext("Case type"), selected.case_type.name, selected.case_description, viaGroup && "path", gettext("case type")); + const renamed = viaGroup && selected.filing_type.name.toLowerCase() !== chosen.group.name.toLowerCase(); + row(gettext("Filing type"), selected.filing_type.name, renamed ? interpolate(gettext("This court's name for “%(name)s”"), { + name: chosen.group.name + }, true) : "", "filing", gettext("filing type")); + explanation.replaceChildren(); + addGlossary(selected.glossary, explanation); + if (selected.explanation) explanation.append(element("p", selected.explanation)); + if (selected.case_guidance?.source) explanation.append(link(gettext("About this case type"), selected.case_guidance.source)); + if (selected.explanation_source) explanation.append(link(gettext("Court information"), selected.explanation_source)); + explanation.hidden = !explanation.children.length; + } + + // Events + open.disabled = false; open.addEventListener("click", () => { - stage.value = existingCase(); + stage = existingCase(); + showStage(); + showStep("filing", { + focus: false + }); + showZipStatus(lastCounties); dialog.showModal(); query.focus(); search(); }); - filters.forEach(filter => filter.addEventListener("change", () => search())); query.addEventListener("input", () => { resetCaseFilters(); invalidate(); clearResults(); - status.textContent = gettext("Searching filing paths…"); + status.textContent = gettext("Searching filing types…"); if (!query.value.trim()) search(); else timer = setTimeout(() => search(), 250); }); + query.addEventListener("keydown", event => { + if (event.key === "Enter") { + event.preventDefault(); + search(); + } + }); + postalCode.addEventListener("input", zipChanged); + zipShortcutList.addEventListener("click", event => { + const button = event.target.closest("[data-zip-shortcut]"); + if (!button) return; + postalCode.value = button.dataset.zipShortcut; + postalCode.focus(); + zipChanged(); + }); + + function switchStage() { + stage = stage === "yes" ? "no" : "yes"; + showStage(); + search(); + } + stageToggle.addEventListener("click", switchStage); + otherStage.addEventListener("click", () => { + switchStage(); + query.focus(); + }); + + function showFilterToggle() { + moreFilters.setAttribute("aria-expanded", String(!filterPanel.hidden)); + if (!filterPanel.hidden) moreFilters.textContent = gettext("Fewer filters"); + else moreFilters.textContent = documentKind ? gettext("More filters (1)") : gettext("More filters"); + } + moreFilters.addEventListener("click", () => { + filterPanel.hidden = !filterPanel.hidden; + showFilterToggle(); + }); + filterPanel.addEventListener("click", event => { + const button = event.target.closest("[data-document]"); + if (!button) return; + documentKind = button.dataset.document; + pressOnly(button); + showFilterToggle(); + search(); + }); + caseFilters.addEventListener("click", event => { + const expand = event.target.closest("[data-expand]"); + if (expand) { + expanded.add(expand.dataset.expand); + caseFilterSchema = ""; + renderCaseFilters(lastFacets); + // Focus the first choice that was hidden. + const chips = byId(`code-search-question-${expand.dataset.expand}`).nextElementSibling.children; + chips[Math.min(CHIPS_SHOWN, chips.length - 1)]?.focus(); + return; + } + const button = event.target.closest("[data-case-facet]"); + if (!button) return; + const key = button.dataset.caseFacet; + pressOnly(button); + if (key === "action") action = button.dataset.value; + else if (key === "category") category = button.dataset.value; + else if (key === "topic") { + caseTopic = button.dataset.value; + caseChoices = {}; + } else if (button.dataset.value) caseChoices[key] = button.dataset.value; + else delete caseChoices[key]; + search(); + }); suggestions.addEventListener("click", event => { const button = event.target.closest("[data-code-query]"); if (!button) return; @@ -550,24 +1055,34 @@ query.focus(); search(); }); - query.addEventListener("keydown", event => { - if (event.key === "Enter") { - event.preventDefault(); - search(); - } + more.addEventListener("click", () => { + if (offset >= strongTotal) showAll = true; + search(true); + }); + morePaths.addEventListener("click", () => loadPaths(true)); + pathQuestions.addEventListener("click", event => { + const button = event.target.closest("[data-path-question]"); + if (!button) return; + pathAnswers[button.dataset.pathQuestion] = button.dataset.value; + renderCaseTypes(); + }); + next.addEventListener("click", goForward); + back.addEventListener("click", goBack); + dialog.addEventListener("click", event => { + const button = event.target.closest("[data-code-search-step]"); + if (button && !applying) showStep(button.dataset.codeSearchStep); }); - more.addEventListener("click", () => search(true)); function close() { if (!applying) dialog.close(); } - ["close-code-search", "cancel-code-search"].forEach(id => document.getElementById(id).addEventListener("click", close)); + ["close-code-search", "cancel-code-search"].forEach(id => byId(id).addEventListener("click", close)); dialog.addEventListener("cancel", event => { if (applying) event.preventDefault(); }); dialog.addEventListener("keydown", event => { if (event.key !== "Tab") return; - const controls = Array.from(dialog.querySelectorAll("button, input, select, a[href], summary, [tabindex]")) + const controls = Array.from(dialog.querySelectorAll("button, input, select, a[href], summary, [tabindex]:not([tabindex='-1'])")) .filter(node => node.tabIndex >= 0 && !node.matches(":disabled") && node.getClientRects().length); const first = controls[0]; const last = controls[controls.length - 1]; @@ -586,12 +1101,10 @@ apply.addEventListener("click", async () => { if (!selected || applying) return; applying = true; - apply.disabled = true; - query.disabled = true; - filters.forEach(filter => filter.disabled = true); - results.inert = true; - more.disabled = true; - status.textContent = gettext("Checking this path with the court…"); + updateNext(); + back.disabled = true; + panels.confirm.inert = true; + applyStatus.textContent = gettext("Checking this path with the court…"); try { const data = await request({ path_id: selected.id @@ -599,14 +1112,12 @@ await applyPath(data.path); dialog.close(); } catch (error) { - status.textContent = error.message; - apply.disabled = false; + applyStatus.textContent = error.message; } finally { applying = false; - query.disabled = false; - filters.forEach(filter => filter.disabled = false); - results.inert = false; - more.disabled = false; + back.disabled = false; + panels.confirm.inert = false; + updateNext(); } }); } diff --git a/efile_app/efile/templates/efile/components/filing_code_search.html b/efile_app/efile/templates/efile/components/filing_code_search.html index 67f6ce62..f66898f5 100644 --- a/efile_app/efile/templates/efile/components/filing_code_search.html +++ b/efile_app/efile/templates/efile/components/filing_code_search.html @@ -7,107 +7,180 @@ id="open-code-search" disabled>{% translate "Find the right filing code" %}

+{% comment %} +Three steps, each answering one question: what is being filed (search, ZIP, +short questions), where and under which case type (court, then case type), +and a last check of the full path before it fills the form. +{% endcomment %} + aria-labelledby="code-search-title">
-

{% translate "Find the right filing code" %}

+
+

{% translate "Step 1 of 3" %}

+

{% translate "What are you filing?" %}

+
-

- {% translate "Search across courts, then narrow your results. Check the case type and filing path before choosing." %} -

- - -
-
- - -
-
- - -
-
- - -
- -
- - -

-
+ + -
-
- + + +
diff --git a/efile_app/efile/templates/efile/staff/filing_codes.html b/efile_app/efile/templates/efile/staff/filing_codes.html new file mode 100644 index 00000000..5839f688 --- /dev/null +++ b/efile_app/efile/templates/efile/staff/filing_codes.html @@ -0,0 +1,72 @@ +{% extends "efile/staff/base.html" %} +{% block staff_content %} +

+ Codes are copied from the EFSP ({{ source }}) every day at {{ schedule }}, then indexed for code search. + Resync codes copies any courts that changed and updates their part of the index. + Rebuild code search indexes rebuilds the whole index from the copy already here, without contacting the EFSP. Use it after changing search rules. +

+ + + + + + + + + + + + {% for item in statuses %} + + + + + + + + {% endfor %} + +
JurisdictionCourts copiedLast copiedFiling paths indexedLast indexed
{{ item.jurisdiction|capfirst }}{{ item.copied_courts }}{{ item.copied_at|default:"Never" }}{{ item.indexed_paths }}{{ item.indexed_at|default:"Never" }}
+

Run now

+
+ {% csrf_token %} + + + + +
+

Recent runs

+ + + + + + + + + + + + + + {% for job in jobs %} + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
RequestedActionJurisdictionByStatusFinishedResult
{{ job.requested_at }}{{ job.get_kind_display }}{{ job.jurisdiction|capfirst|default:"Every jurisdiction" }}{{ job.requested_by|default:"" }}{{ job.get_status_display }}{{ job.finished_at|default:"" }}{{ job.message|linebreaksbr }}
No runs requested yet.
+{% endblock %} diff --git a/efile_app/efile/templates/efile/staff/index.html b/efile_app/efile/templates/efile/staff/index.html index 128ef7f5..ef078f49 100644 --- a/efile_app/efile/templates/efile/staff/index.html +++ b/efile_app/efile/templates/efile/staff/index.html @@ -5,6 +5,7 @@

Staff tools

{{ block.super }} diff --git a/efile_app/efile/tests/test_case_type_guidance.py b/efile_app/efile/tests/test_case_type_guidance.py index f55771f0..c8962c49 100644 --- a/efile_app/efile/tests/test_case_type_guidance.py +++ b/efile_app/efile/tests/test_case_type_guidance.py @@ -113,7 +113,7 @@ def test_amount_metadata_is_state_specific_and_only_shown_when_useful(): for jurisdiction, text in [("illinois", "$10,000"), ("massachusetts", "$7,000"), ("vermont", "medical debt")]: data = facet_metadata(jurisdiction, "small claims", labels) amount = next(facet for facet in data["case_facets"] if facet["key"] == "amount") - assert amount["control"] == "select" and text in amount["help"] + assert text in amount["help"] assert all( facet["key"] != "amount" for facet in facet_metadata("illinois", "small claims", labels[:1])["case_facets"] ) @@ -124,7 +124,7 @@ def test_amount_metadata_is_state_specific_and_only_shown_when_useful(): [ ("small_claims", {"amount": "x"}), ("small_claims", {"amount": "500:100"}), - ("divorce", {"amount": "0:500"}), + ("", {"amount": "500:100"}), ("eviction", {"children": "with"}), ("no_such_topic", {}), ("", {"role": "tenant"}), @@ -163,3 +163,53 @@ def test_local_description_precedence(monkeypatch): finally: topic_config.cache_clear() case_guidance.cache_clear() + + +def test_civil_searches_spanning_amounts_ask_for_one_and_name_small_claims(): + labels = [ + {"category": "Civil", "case_type": "Contract - Small Claims - $0 to $10,000 - Jury", "name": "Complaint"}, + { + "category": "Civil - Amount Claimed Greater Than $10,000", + "case_type": "Contract - Jury", + "name": "Complaint", + }, + {"category": "Civil", "case_type": "Contract - Jury", "name": "Complaint"}, + ] + data = facet_metadata("illinois", "civil complaint", labels) + amount = next(facet for facet in data["case_facets"] if facet["key"] == "amount") + assert amount["label"] == "How much are you asking for?" + assert [option["label"] for option in amount["options"]] == ["Up to $10,000 (small claims)", "$10,000.01 or more"] + assert not amount["help"] + validate_case_filters("illinois", "", {"amount": amount["options"][0]["value"]}) + + +@pytest.mark.parametrize( + ("category", "case_type", "topic", "issue"), + [ + ("Civil", "Personal Injury Complaint - Jury", "personal_injury", "unknown"), + ("Civil", "Tort - Not Personal Injury - Jury", "", None), + ("Other Actions", "Legal Malpractice", "", None), + ("Personal Injury/Wrongful Death", "Medical Malpractice", "personal_injury", "medical"), + ("Personal Injury/Wrongful Death", "Other Personal Injury/Wrongful Death", "personal_injury", "unknown"), + ("Personal Injury/Wrongful Death", "Asbestos - Jury Demand", "personal_injury", "exposure"), + ("Personal Injury/Wrongful Death", "Product Liability", "personal_injury", "product"), + ], +) +def test_injury_case_types_are_classified_without_false_matches(category, case_type, topic, issue): + guidance = case_guidance("illinois", category, case_type, "Complaint / Petition") + assert guidance.get("topic", "") == topic + assert guidance.get("issue") == issue + + +def test_something_else_keeps_only_case_types_no_option_names(): + general = case_guidance("illinois", "Civil", "Personal Injury Complaint", "Complaint") + vehicle = case_guidance("illinois", "Civil", "Personal Injury - Motor Vehicle", "Complaint") + assert matches_case_filters(general, filters={"issue": "other"}) + assert not matches_case_filters(vehicle, filters={"issue": "other"}) + assert matches_case_filters(general, filters={"issue": "vehicle"}) + validate_case_filters("illinois", "personal_injury", {"issue": "other"}) + with pytest.raises(ValueError): + validate_case_filters("illinois", "eviction", {"role": "other"}) + labels = [{"category": "Civil", "case_type": "Personal Injury - Motor Vehicle", "name": "Complaint"}] + issue = next(f for f in facet_metadata("illinois", "", labels)["case_facets"] if f["key"] == "issue") + assert issue["options"][-1] == {"value": "other", "label": "Something else"} diff --git a/efile_app/efile/tests/test_filing_code_copy.py b/efile_app/efile/tests/test_filing_code_copy.py new file mode 100644 index 00000000..9597aa35 --- /dev/null +++ b/efile_app/efile/tests/test_filing_code_copy.py @@ -0,0 +1,188 @@ +"""Copying codes from the EFSP database, indexing the copy, scheduling, and staff requests.""" + +from contextlib import contextmanager +from datetime import UTC, datetime +from unittest.mock import MagicMock, patch + +import pytest +from django.urls import reverse + +from efile.management.commands.refresh_filing_code_index import next_daily_run +from efile.models import FilingCodeCourtCatalog, FilingCodeJob, StaffAudit +from efile.services import filing_code_copy +from efile.services.filing_code_copy import EfspCodesDatabase, copy_codes, rebuild, resync, run_jobs +from efile.services.filing_code_search import current_index, search_grouped_paths +from efile.tests.test_staff_tools import verified_client + +pytestmark = pytest.mark.django_db + + +def court_export(code, name, filings=("Complaint", "Answer")): + return { + "court": {"code": code, "name": name, "revision": f"{code}-{len(filings)}"}, + "categories": [{"code": "civ", "name": "Civil"}], + "case_types": [{"code": "ct", "name": "Eviction", "case_category": "civ", "initial": True}], + "filing_types": [ + {"code": f"f{i}", "name": filing, "case_category": "civ", "case_type": "", "timing": "Both"} + for i, filing in enumerate(filings) + ], + } + + +class FakeEfsp: + """Stands in for the EFSP database: courts by code, and which ones were read.""" + + def __init__(self, *exports): + self.exports = {export["court"]["code"]: export for export in exports} + self.read = [] + + @contextmanager + def __call__(self, jurisdiction): + source = MagicMock() + source.manifest.side_effect = lambda: {code: export["court"] for code, export in self.exports.items()} + source.court.side_effect = lambda court: self.read.append(court["code"]) or self.exports[court["code"]] + yield source + + +@pytest.fixture +def efsp(): + fake = FakeEfsp(court_export("adams", "Adams County"), court_export("cook:cvd1", "Cook County")) + with patch.object(filing_code_copy, "EfspCodesDatabase", fake): + yield fake + + +def test_copy_reads_only_changed_courts_and_drops_removed_ones(efsp): + assert copy_codes("illinois") == {"courts": 2, "changed": 2, "removed": 0} + assert copy_codes("illinois")["changed"] == 0 + efsp.exports["adams"] = court_export("adams", "Adams County", ("Complaint", "Answer", "Motion")) + del efsp.exports["cook:cvd1"] + efsp.read.clear() + assert copy_codes("illinois") == {"courts": 1, "changed": 1, "removed": 1} + assert efsp.read == ["adams"] + assert list(FilingCodeCourtCatalog.objects.values_list("code", "filing_count")) == [("adams", 3)] + + +def test_a_court_that_suddenly_has_no_filings_keeps_its_old_copy(efsp): + copy_codes("illinois") + efsp.exports["adams"] = court_export("adams", "Adams County", ()) + with pytest.raises(ValueError, match="no filing types"): + copy_codes("illinois") + assert FilingCodeCourtCatalog.objects.get(code="adams").filing_count == 2 + copy_codes("illinois", force=["adams"]) + assert FilingCodeCourtCatalog.objects.get(code="adams").filing_count == 0 + + +def test_dry_run_reads_and_reports_without_saving(efsp): + assert copy_codes("illinois", dry_run=True)["changed"] == 2 + assert not FilingCodeCourtCatalog.objects.exists() + + +def test_resync_indexes_the_copy_and_rebuild_needs_no_efsp(efsp): + assert resync("illinois") > 0 + index = current_index("illinois") + assert search_grouped_paths(index, "complaint")["total"] == 1 + assert index.vocabulary["courts"] == {"adams": "Adams County", "cook:cvd1": "Cook County"} + indexed = sum(item["count"] for item in index.court_snapshots.values()) + with patch.object(filing_code_copy, "EfspCodesDatabase", side_effect=AssertionError("contacted the EFSP")): + assert rebuild("illinois") == indexed + assert search_grouped_paths(current_index("illinois"), "answer")["total"] == 1 + + +def test_rebuild_without_a_copy_says_to_resync_first(): + with pytest.raises(ValueError, match="Resync codes first"): + rebuild("illinois") + + +def test_the_efsp_connection_must_be_read_only(settings): + settings.EFSP_CODES_DATABASE_URL = "postgresql://reader@efsp.example/postgres" + connection = MagicMock() + connection.cursor.return_value.__enter__.return_value.fetchone.return_value = {"transaction_read_only": "off"} + with patch("psycopg.connect", return_value=connection), pytest.raises(ValueError, match="not read only"): + with EfspCodesDatabase("illinois"): + pass + assert connection.read_only is True + connection.close.assert_called_once() + settings.EFSP_CODES_DATABASE_URL = "" + with pytest.raises(ValueError, match="EFSP_CODES_DATABASE_URL"): + EfspCodesDatabase("illinois") + + +def test_an_incomplete_efsp_update_copies_nothing(settings): + settings.EFSP_CODES_DATABASE_URL = "postgresql://reader@efsp.example/postgres" + source = EfspCodesDatabase("illinois") + rows = [{"code": "adams", "name": "Adams County", "fileable": True, "versions": 2, "revision": "r"}] + with patch.object(EfspCodesDatabase, "_rows", return_value=rows), pytest.raises(ValueError, match="Incomplete"): + source.manifest() + # Tyler's internal "System" row is fileable but has no code lists; it is skipped, not fatal. + rows = [ + {"code": "0", "name": "System", "fileable": True, "versions": 0, "revision": "s"}, + { + "code": "cook:dv", + "name": "Cook County - Domestic Violence", + "fileable": False, + "versions": 2, + "revision": "d", + }, + {"code": "adams", "name": "Adams County", "fileable": True, "versions": 3, "revision": "a"}, + ] + with patch.object(EfspCodesDatabase, "_rows", return_value=rows): + assert list(source.manifest()) == ["adams"] + + +def test_queued_jobs_run_once_and_record_the_outcome(efsp): + resync_job = FilingCodeJob.objects.create(kind="resync", jurisdiction="illinois") + rebuild_job = FilingCodeJob.objects.create(kind="rebuild", jurisdiction="vermont") + run_jobs(["illinois", "vermont"], log=lambda line: None) + resync_job.refresh_from_db() + rebuild_job.refresh_from_db() + assert resync_job.status == "succeeded" and "illinois:" in resync_job.message + assert rebuild_job.status == "failed" and "Resync codes first" in rebuild_job.message + assert resync_job.started_at and resync_job.finished_at + + +@pytest.mark.parametrize( + ("now", "expected"), + [ + (datetime(2026, 10, 6, 20, 0, tzinfo=UTC), "2026-10-06 21:30 EDT"), # 16:00 Eastern: tonight + (datetime(2026, 10, 7, 2, 0, tzinfo=UTC), "2026-10-07 21:30 EDT"), # 22:00 Eastern: tomorrow + (datetime(2026, 11, 1, 3, 0, tzinfo=UTC), "2026-11-01 21:30 EST"), # across the DST change + ], +) +def test_daily_runs_land_at_the_configured_eastern_time(now, expected): + assert f"{next_daily_run(now, '21:30', 'America/New_York'):%Y-%m-%d %H:%M %Z}" == expected + + +@pytest.fixture +def superuser(django_user_model): + return django_user_model.objects.create_user( + username="root", password="a-strong-local-password", is_staff=True, is_superuser=True + ) + + +def test_staff_can_queue_a_resync_or_rebuild_once(client, superuser): + client = verified_client(client, superuser) + url = reverse("litefile_staff:filing_codes") + page = client.get(url) + assert page.status_code == 200 + assert b"Resync codes" in page.content and b"Rebuild code search indexes" in page.content + client.post(url, {"kind": "resync", "jurisdiction": "illinois"}) + client.post(url, {"kind": "resync", "jurisdiction": "illinois"}) + client.post(url, {"kind": "rebuild", "jurisdiction": ""}) + assert list(FilingCodeJob.objects.order_by("pk").values_list("kind", "jurisdiction", "status")) == [ + ("resync", "illinois", "queued"), + ("rebuild", "", "queued"), + ] + assert StaffAudit.objects.filter(action="filing_codes_resync", jurisdiction="illinois").count() == 1 + client.post(url, {"kind": "drop tables", "jurisdiction": "illinois"}) + assert FilingCodeJob.objects.count() == 2 + + +def test_only_superusers_reach_the_filing_codes_page(client, django_user_model): + from efile.models import StaffRoleGrant + + staff = django_user_model.objects.create_user(username="staff", password="a-strong-local-password", is_staff=True) + StaffRoleGrant.objects.create(user=staff, jurisdiction="illinois", role="accounts") + client = verified_client(client, staff) + assert client.get(reverse("litefile_staff:filing_codes")).status_code == 403 + assert client.post(reverse("litefile_staff:filing_codes"), {"kind": "resync"}).status_code == 403 + assert not FilingCodeJob.objects.exists() diff --git a/efile_app/efile/tests/test_filing_code_search.py b/efile_app/efile/tests/test_filing_code_search.py index 0e9014ea..b1c51744 100644 --- a/efile_app/efile/tests/test_filing_code_search.py +++ b/efile_app/efile/tests/test_filing_code_search.py @@ -620,6 +620,37 @@ def test_endpoint_search_ignores_current_facets_and_validates_without_saving(sig assert signed_in.get(URL, {"jurisdiction": "massachusetts", "path_id": "bad"}).status_code == 400 +def test_using_a_path_remembers_its_zip_newest_first(signed_in, index, catalog, django_user_model): + path_id = signed_in.get(URL, {"jurisdiction": "massachusetts", "q": "eviction"}).json()["results"][0]["id"] + user = django_user_model.objects.get(username="searcher") + with patch("efile.api.filing_code_search.validate_path", return_value={}): + for postal_code in ("02108", "02139", "", "not-a-zip", "02108", "01060-1234", "02445"): + signed_in.get(URL, {"jurisdiction": "massachusetts", "path_id": path_id, "zip": postal_code}) + user.refresh_from_db() + assert user.recent_case_zips == ["02445", "01060", "02108"] + with patch("efile.api.filing_code_search.validate_path", side_effect=ValueError("gone")): + signed_in.get(URL, {"jurisdiction": "massachusetts", "path_id": path_id, "zip": "02210"}) + user.refresh_from_db() + assert user.recent_case_zips[0] == "02445" + + +def test_zip_shortcuts_put_the_filers_own_zip_before_recent_ones(rf, django_user_model): + from efile.views.extraction_review import _zip_shortcuts + + user = django_user_model.objects.create_user( + username="filer", tyler_jurisdiction="illinois", recent_case_zips=["60187", "60601"] + ) + draft = FilingDraft.objects.create(user=user, jurisdiction="illinois") + FilingParty.objects.create(draft=draft, role="filer", zip_code="60601-1234") + request = rf.post("/") + request.user = user + request.session = {} + assert _zip_shortcuts(request, draft, "illinois") == [ + {"zip": "60601", "kind": "address"}, + {"zip": "60187", "kind": "recent"}, + ] + + def test_management_command_attempts_each_jurisdiction_and_reports_failure(): with patch( "efile.management.commands.refresh_filing_code_index.refresh_index", side_effect=[ValueError("bad"), 2, 3] @@ -747,20 +778,102 @@ def test_zip_filter_scopes_groups_contexts_courts_and_paths(catalog): @pytest.mark.parametrize("postal_code", ["606", "00000", "abcde"]) -def test_zip_filter_rejects_invalid_or_unknown_zip(index, postal_code): - from efile.services.filing_code_search import zip_counties +def test_zip_filter_rejects_invalid_or_unknown_zip(catalog, postal_code): + from efile.services.case_location import locate + refresh_index("illinois") with pytest.raises(ValueError): - zip_counties("illinois", postal_code) + locate(current_index("illinois"), postal_code) def test_zip_filter_preserves_multiple_counties(): - from efile.services.filing_code_search import zip_counties + from efile.services.case_location import zip_counties with patch("efile.utils.zip_to_county_il.COUNTY_TO_ZIPS_IL", {"Cook": ["60601"], "Lake": ["60601"]}): assert zip_counties("illinois", "60601-1234") == ["Cook", "Lake"] +def test_census_table_places_zips_in_every_state(): + from efile.services.case_location import zip_counties + + assert zip_counties("massachusetts", "02139") == ["Middlesex"] + assert zip_counties("vermont", "05602") == ["Washington"] + assert zip_counties("vermont", "02139") == [] + + +@pytest.mark.parametrize( + ("jurisdiction", "code", "name", "counties"), + [ + ("illinois", "cook:cvd1", "Cook County - Civil", {"Cook"}), + ("illinois", "jodaviess", "Jo Daviess County", {"Jo Daviess"}), + ("vermont", "sc:grandisle", "Grand Isle Unit", {"Grand Isle"}), + ( + "massachusetts", + "0965:BE", + "Juvenile Court -- Franklin-Hampshire County -- Belchertown", + {"Franklin", "Hampshire"}, + ), + ("massachusetts", "490", "District Court - Cambridge", {"Middlesex"}), + ("illinois", "ilsc", "Illinois Supreme Court", set()), + ], +) +def test_courts_are_placed_by_county_in_their_code_or_name_or_by_town(jurisdiction, code, name, counties): + from efile.services.case_location import court_counties + + assert set(court_counties(jurisdiction, code, name)) == counties + + +def test_county_states_keep_courts_that_name_no_county(catalog): + from efile.services.case_location import locate + + refresh_index("illinois") + index = current_index("illinois") + index.vocabulary["courts"] = {"cook:cvd1": "Cook County", "adams": "Adams County", "ilsc": "Supreme Court"} + assert locate(index, "60601") == {"counties": ["Cook"], "courts": ["cook:cvd1", "ilsc"]} + + +def test_matcher_states_narrow_only_courts_the_matcher_knows(index): + from efile.services import court_location + from efile.services.case_location import locate + + record = lambda code: Mock(tyler_code=code) # noqa: E731 + finder = Mock() + finder.find_by_postal_code.return_value = [Mock(records=[record("0490")])] + finder.catalog.records = [record("490"), record("500")] + index.vocabulary["courts"] = { + "490": "Cambridge", + "500": "Somewhere else", + "sjc": "Supreme Judicial Court", + "new-1": "District Court - Winchendon", + "new-2": "District Court - Somerville", + } + with patch.object(court_location, "_finder", return_value=finder): + # Courts MACourts doesn't know fall back to their town's county. + assert locate(index, "02139")["courts"] == ["490", "new-2", "sjc"] + finder.find_by_postal_code.return_value = [] + with pytest.raises(ValueError): + locate(index, "02139") + finder.find_by_postal_code.assert_called_with("02139") + + +def test_vermont_narrows_units_by_name_and_keeps_statewide_courts(): + from efile.services import court_location + + finder = Mock() + finder.find_units.return_value = [Mock(unit="Washington")] + courts = {"sc:washington": "Washington Unit", "sc:orange": "Orange Unit", "vermont:supreme": "Supreme Court"} + with patch.object(court_location, "_vermont_finder", return_value=finder): + assert court_location.courts_for_postal_code("vtcourts", "05602", courts) == { + "matched": {"sc:washington"}, + "unknown": {"vermont:supreme"}, + } + + +def test_refresh_records_each_courts_name(catalog): + refresh_index("illinois") + assert current_index("illinois").vocabulary["courts"] + + def test_overlapping_words_do_not_need_a_synonym_explanation(): data = search_context( "illinois", "response to complaint", [("filing_type", "Answer/Response to Complaint/Petition")] @@ -861,3 +974,203 @@ def test_small_claim_amount_filters_groups_courts_and_exact_paths(catalog): assert detail["total"] == 2 assert len(search_grouped_paths(index, "small claims", group_key=group["key"], **args)["courts"]) == 2 assert search_grouped_paths(index, "small claims")["groups"][0]["path_count"] == 6 + + +@pytest.mark.parametrize( + ("name", "action"), + [ + ("Small Claims Complaint", "start"), + ("Answer/Response to Complaint/Petition", "respond"), + ("Response to Motion to Dismiss", "respond"), + ("Motion to Dismiss", "ask"), + ("Stipulation to Dismiss", "settle"), + ("Agreed Order", "settle"), + ("Petition for Dissolution of Marriage", "start"), + ("Petition to Modify Custody", "ask"), + ("Certificate of Service", "proof"), + ("Proposed Order", "order"), + ("Notice of Hearing", "notice"), + ("Additional Defendants", ""), + ], +) +def test_filing_names_map_to_a_plain_language_action(name, action): + from efile.services.filing_code_search import filing_action + + assert filing_action(name) == action + + +def test_jury_government_and_fee_variants_share_one_group(): + from efile.services.filing_code_search import filing_group_key, unqualified_label + + names = [ + "Complaint / Petition - Fraud - Fee", + "Complaint / Petition - Fraud (Jury - 12) - Fee", + "Complaint / Petition - Fraud (Govn't) - Fee", + "Complaint / Petition - Fraud (Jury - 6) (Govn't)", + ] + assert {unqualified_label(name) for name in names} == {"Complaint / Petition - Fraud"} + assert len({filing_group_key(name, "illinois") for name in names}) == 1 + assert filing_group_key("Complaint / Petition - Fraud", "illinois") != filing_group_key( + "Complaint / Petition - Breach Of Contract", "illinois" + ) + + +def test_name_matches_lead_and_case_only_matches_are_counted_apart(): + from efile.services.filing_code_search import action_options, strong_matches + + groups = [ + {"name_rank": 24, "_labels": [{"name": "Small Claims Complaint"}]}, + {"name_rank": 16, "_labels": [{"name": "Notice of Small Claims"}]}, + {"name_rank": 8, "_labels": [{"name": "Claim Affidavit"}]}, + {"name_rank": 0, "_labels": [{"name": "Affidavit"}, {"name": "Exhibit"}]}, + ] + assert strong_matches(groups) == 2 + assert strong_matches([{"name_rank": 0}]) == 0 + assert action_options(groups) == [ + {"value": "start", "label": "Start a case", "count": 1}, + {"value": "proof", "label": "Supporting papers", "count": 2}, + {"value": "notice", "label": "Notice", "count": 1}, + ] + + +def test_endpoint_filters_by_action_and_rejects_unknown_ones(signed_in, index): + params = {"jurisdiction": "massachusetts", "q": "eviction", "grouped": "true"} + data = signed_in.get(URL, params).json() + assert {"actions", "strong_total"} <= set(data) + assert signed_in.get(URL, {**params, "action": "bogus"}).status_code == 400 + for option in data["actions"]: + narrowed = signed_in.get(URL, {**params, "action": option["value"]}).json() + assert narrowed["total"] == option["count"] + + +@pytest.mark.parametrize( + ("category", "case_type", "filing", "expected"), + [ + ( + "Civil", + "Other Personal Injury Complaint - Jury - Self-Represented Litigant", + "Complaint / Petition - Personal Injury (Jury - 6) (Govn't) - Fee", + {"jury": "6", "representation": "self", "government": "yes", "amount": None}, + ), + ( + "Civil - Amount Claimed Greater Than $10,000", + "Personal Injury Complaint - Non-Jury", + "Complaint / Petition - Personal Injury - Fee", + { + "jury": "none", + "representation": "lawyer", + "government": "no", + "amount": {"value": "1000001:", "label": "More than $10,000"}, + }, + ), + ( + "Civil", + "Personal Injury Complaint - Non-Jury- Small Claims $0 to $10,000- SRL", + "Complaint / Petition - Personal Injury - Fee", + { + "jury": "none", + "representation": "self", + "government": "no", + "amount": {"value": "0:1000000", "label": "$0 to $10,000"}, + }, + ), + ( + "Personal Injury/Wrongful Death", + "Other Personal Injury/Wrongful Death - Jury Demand", + "Complaint / Petition - Personal Injury (Jury - 12) - Fee", + {"jury": "12", "representation": "lawyer", "government": "no", "amount": None}, + ), + ], +) +def test_paths_are_tagged_with_the_choices_courts_code_separately(category, case_type, filing, expected): + from efile.services.filing_code_search import path_qualifiers + + assert path_qualifiers(category, case_type, filing) == expected + + +def test_glossary_finds_terms_once_with_state_wording(): + from efile.services.glossary import glossary_for + + terms = glossary_for( + "illinois", ["Complaint / Petition - Intentional Tort", "Personal Injury - Motor Vehicle Subrogation - SRL"] + ) + assert [term["label"] for term in terms] == ["Intentional tort", "Subrogation"] + assert glossary_for("illinois", ["Tortious Interference? No: Torte Bakery"]) == [] + assert "Illinois' older name" in glossary_for("illinois", ["Forcible Entry and Detainer"])[0]["text"] + assert glossary_for("massachusetts", ["Summary Process Complaint"])[0]["label"] == "Summary process" + assert glossary_for("illinois", ["Summary Process Complaint"]) == [] + + +def test_case_type_variants_are_shown_once_in_previews(): + from efile.services.filing_code_search import case_contexts, unqualified_case_type + + assert unqualified_case_type("Personal Injury Complaint - Non-Jury- Small Claims $0 to $10,000- SRL") == ( + "Personal Injury Complaint" + ) + assert unqualified_case_type("Other Personal Injury/Wrongful Death - Jury Demand") == ( + "Other Personal Injury/Wrongful Death" + ) + labels = [ + {"category": "Civil", "case_type": name, "path_count": 1, "rank": 1} + for name in [ + "Personal Injury Complaint - Jury", + "Personal Injury Complaint - Non-Jury - Self-Represented Litigant", + ] + ] + assert case_contexts(labels) == ["Civil › Personal Injury Complaint"] + + +def test_fee_waivers_ask_the_court_rather_than_start_a_case(): + from efile.services.filing_code_search import filing_action + + assert filing_action("Fee Waiver Petition Filed - Petitioner/Plaintiff") == "ask" + + +def test_endpoint_offers_and_applies_case_categories(signed_in, index): + params = {"jurisdiction": "massachusetts", "q": "eviction", "grouped": "true"} + data = signed_in.get(URL, params).json() + assert data["categories"] and all({"value", "label", "count"} <= set(item) for item in data["categories"]) + first = data["categories"][0] + narrowed = signed_in.get(URL, {**params, "category": first["value"]}).json() + assert narrowed["total"] == first["count"] + + +def test_data_files_have_no_duplicate_keys(): + # YAML lets a repeated key silently replace the first, as a second + # "sealing:" concept once did. + from pathlib import Path + + import yaml + + class Strict(yaml.SafeLoader): + pass + + def mapping(loader, node, deep=False): + keys = [loader.construct_object(key, deep=deep) for key, _ in node.value] + assert len(keys) == len(set(keys)), f"duplicate keys in {node.start_mark}" + return yaml.SafeLoader.construct_mapping(loader, node, deep) + + Strict.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, mapping) + for path in (Path(__file__).resolve().parents[1] / "data").glob("*.yaml"): + yaml.load(path.read_text(), Loader=Strict) + + +def test_seal_searches_find_impound_and_redaction_filings(): + from efile.services.filing_code_search import search_tokens + + for name in ["Impound/Seal", "Filed Under Seal", "Motion for Redaction & Confidential Filing"]: + assert "conceptsealing" in search_tokens(name, "illinois") + assert "conceptsealing" in search_tokens("seal eviction", "illinois", query=True) + assert "conceptsealing" not in search_tokens("Other Document Not Listed (Confidential)", "illinois") + + +def test_other_stage_count_points_new_case_searches_at_existing_filings(catalog): + from efile.services.filing_code_search import search_grouped_paths + + refresh_index("massachusetts") + index = current_index("massachusetts") + new = search_grouped_paths(index, "eviction", initial=True) + existing = search_grouped_paths(index, "eviction", initial=False) + assert new["other_stage_total"] == existing["total"] + assert existing["other_stage_total"] == new["total"] + assert search_grouped_paths(index, "eviction", initial=True, offset=20)["other_stage_total"] is None diff --git a/efile_app/efile/views/extraction_review.py b/efile_app/efile/views/extraction_review.py index aa175cdc..7be71296 100644 --- a/efile_app/efile/views/extraction_review.py +++ b/efile_app/efile/views/extraction_review.py @@ -1,3 +1,5 @@ +import re + from django.contrib import messages from django.shortcuts import redirect, render from django.views.decorators.http import require_http_methods @@ -5,6 +7,7 @@ from efile.api.suffolk_api_views import get_tyler_token from efile.models import DocumentExtraction, FilingDocument from efile.party_sides import PARTY_SIDE_HELP, PARTY_SIDE_LABELS, PartySide +from efile.services.account_profile import cached_account_profile from efile.services.current_drafts import ensure_current_draft from efile.services.document_checklists import resolve_filer_roles from efile.services.document_extractions import extraction_for_document @@ -111,6 +114,25 @@ def _set_lead_filing_type(draft, filing_type_code, filing_type_name): ) +def _zip_shortcuts(request, draft, jurisdiction): + """ZIP codes code search offers one click away: the filer's own, then recent ones. + + The account profile is the same cached fetch "Your information" makes, so + asking for it here moves the call earlier rather than adding one. + """ + filer = draft.parties.filter(role="filer").first() + own = filer.zip_code if filer else "" + if not own and request.method == "GET": + own = (cached_account_profile(request, jurisdiction) or {}).get("zip") or "" + shortcuts = [] + if re.fullmatch(r"[0-9]{5}(?:-[0-9]{4})?", own): + shortcuts.append({"zip": own[:5], "kind": "address"}) + for postal_code in request.user.recent_case_zips or []: + if all(item["zip"] != postal_code for item in shortcuts): + shortcuts.append({"zip": postal_code, "kind": "recent"}) + return shortcuts + + def _case_identity(existing_case, docket_number): """The case number and title this screen may save. @@ -287,6 +309,7 @@ def classified(level, key): # Only some case types have sides. The screen asks for one as soon as # the chosen case type turns out to be one of them. "filer_role": draft.filer_role, + "zip_shortcuts": _zip_shortcuts(request, draft, jurisdiction), } suggested_existing_case = ( "new" diff --git a/efile_app/scripts/build_zip_counties.py b/efile_app/scripts/build_zip_counties.py new file mode 100644 index 00000000..2cfbb3f6 --- /dev/null +++ b/efile_app/scripts/build_zip_counties.py @@ -0,0 +1,68 @@ +"""Build efile/data/zip_counties.json.gz from Census 2020 relationship files. + +Code search narrows courts by the county a case ZIP is in. Most states name +courts after counties; some (Massachusetts district courts) name them after +towns, so the table also maps each state's towns to their counties. + + curl -O https://www2.census.gov/geo/docs/maps-data/data/rel2020/zcta520/tab20_zcta520_county20_natl.txt + curl -O https://www2.census.gov/geo/docs/maps-data/data/gazetteer/2020_Gazetteer/2020_Gaz_cousubs_national.zip + unzip 2020_Gaz_cousubs_national.zip + uv run python scripts/build_zip_counties.py tab20_zcta520_county20_natl.txt 2020_Gaz_cousubs_national.txt + +ZCTAs approximate ZIP codes; PO-box-only ZIPs have no ZCTA and stay unmatched. +""" + +import csv +import gzip +import json +import re +import sys +from collections import defaultdict +from pathlib import Path + +OUTPUT = Path(__file__).resolve().parent.parent / "efile" / "data" / "zip_counties.json.gz" +COUNTY_SUFFIX = re.compile( + r"\s+(County|Parish|Borough|City and Borough|Census Area|Municipality|Planning Region|city)$" +) +TOWN_SUFFIX = re.compile( + r"\s+(city|town|township|village|borough|plantation|CCD|gore|grant|location|purchase|" + r"unorganized territory|UT|charter township|municipality|district|precinct)$", + re.IGNORECASE, +) + + +def main(zcta_path, cousub_path): + states = {} + towns = defaultdict(lambda: defaultdict(set)) + with open(cousub_path, encoding="utf-8-sig") as handle: + for row in csv.DictReader(handle, delimiter="\t"): + row = {key.strip(): value.strip() for key, value in row.items()} + states[row["GEOID"][:2]] = row["USPS"] + towns[row["USPS"]][TOWN_SUFFIX.sub("", row["NAME"]).casefold()].add(row["GEOID"][:5]) + county_names = {} + zips = defaultdict(lambda: defaultdict(list)) + with open(zcta_path, encoding="utf-8-sig") as handle: + for row in csv.DictReader(handle, delimiter="|"): + county = row["GEOID_COUNTY_20"] + county_names[county] = COUNTY_SUFFIX.sub("", row["NAMELSAD_COUNTY_20"]) + # Territories without county subdivisions in the gazetteer are skipped. + if row["GEOID_ZCTA5_20"] and county[:2] in states: + # Largest land overlap first, so a split ZIP leads with its main county. + zips[states[county[:2]]][row["GEOID_ZCTA5_20"]].append((-int(row["AREALAND_PART"]), county)) + table = {} + for state in sorted(set(zips) | set(towns)): + table[state] = { + "zips": {code: [county_names[c] for _, c in sorted(parts)] for code, parts in sorted(zips[state].items())}, + "towns": { + name: sorted(county_names[c] for c in counties if c in county_names) + for name, counties in sorted(towns[state].items()) + if name and any(c in county_names for c in counties) + }, + } + with gzip.open(OUTPUT, "wt", encoding="utf-8") as handle: + json.dump(table, handle, separators=(",", ":"), sort_keys=True) + print(f"Wrote {OUTPUT} ({OUTPUT.stat().st_size:,} bytes, {sum(len(s['zips']) for s in table.values()):,} ZIPs)") + + +if __name__ == "__main__": + main(*sys.argv[1:3]) diff --git a/efile_app/tests/confirm-case-editing.spec.js b/efile_app/tests/confirm-case-editing.spec.js index 69948d83..0dc3efae 100644 --- a/efile_app/tests/confirm-case-editing.spec.js +++ b/efile_app/tests/confirm-case-editing.spec.js @@ -347,6 +347,84 @@ test.describe('filing code search', () => { return queries; } + const searchbox = page => page.getByRole('searchbox', { + name: "What you're filing" + }); + const button = (page, name) => page.getByRole('dialog').getByRole('button', { + name, + exact: true + }); + const toCourts = page => button(page, 'Next: court and case type').click(); + const toCheck = page => button(page, 'Next: check and use').click(); + const useCode = page => button(page, 'Use this filing code').click(); + + async function openSearch(page) { + await page.getByRole('button', { + name: 'Find the right filing code' + }).click(); + return page.getByRole('dialog'); + } + + function courtRoutes(page, { + groups, + courts = [{ + code: 'vt:orange', + name: 'Orange Unit' + }], + paths = params => [{ + ...result, + court: { + code: params.get('court'), + name: UNITS[params.get('court')] + } + }], + extra = {}, + queries = [], + }) { + return page.route('**/api/filing-code-search/**', route => { + const params = new URL(route.request().url()).searchParams; + queries.push({ + ...Object.fromEntries(params), + choices: JSON.parse(params.get('case_filters') || '{}') + }); + if (params.has('path_id')) return route.fulfill({ + json: { + path: { + ...result, + initial: params.get('existing_case') !== 'yes', + options + } + } + }); + if (params.has('court')) { + const results = paths(params); + return route.fulfill({ + json: { + results, + total: results.length, + corrected_terms: [] + } + }); + } + if (params.has('group')) return route.fulfill({ + json: { + courts, + corrected_terms: [] + } + }); + const shown = typeof groups === 'function' ? groups(params) : groups; + return route.fulfill({ + json: { + groups: shown, + total: shown.length, + corrected_terms: [], + stale: false, + ...(typeof extra === 'function' ? extra(params) : extra), + } + }); + }); + } + test('empty search offers common types, clears back to them, and accepts one character', async ({ page }) => { @@ -355,36 +433,26 @@ test.describe('filing code search', () => { }); const queries = await mockSearch(page); await openSavedDraft(page); - await page.getByRole('button', { - name: 'Find the right filing code' - }).click(); - const dialog = page.getByRole('dialog'); + const dialog = await openSearch(page); await expect(dialog.getByRole('heading', { name: 'Common filing types' })).toBeVisible(); + await expect(dialog).toContainText('Step 1 of 3'); expect(queries).toEqual([]); - await dialog.getByRole('button', { - name: 'Complaint', - exact: true - }).click(); + await button(page, 'Complaint').click(); await expect(dialog.getByRole('radio', { name: 'Complaint' })).toBeVisible(); expect(queries[0].q).toBe('complaint'); await dialog.getByRole('radio').check(); - await page.getByRole('searchbox', { - name: 'Search filing codes' - }).fill(''); + await expect(button(page, 'Next: check and use')).toBeEnabled(); + await searchbox(page).fill(''); await expect(dialog.getByRole('heading', { name: 'Common filing types' })).toBeVisible(); await expect(dialog.getByRole('radio')).toHaveCount(0); - await expect(dialog.getByRole('button', { - name: 'Use this path' - })).toBeDisabled(); - await page.getByRole('searchbox', { - name: 'Search filing codes' - }).fill('m'); + await expect(button(page, 'Next: court and case type')).toBeDisabled(); + await searchbox(page).fill('m'); await expect(dialog.getByRole('radio')).toBeVisible(); expect(queries.at(-1).q).toBe('m'); }); @@ -404,17 +472,10 @@ test.describe('filing code search', () => { times: 1 }); await openSavedDraft(page); - await page.getByRole('button', { - name: 'Find the right filing code' - }).click(); - await page.getByRole('button', { - name: 'Complaint', - exact: true - }).click(); + await openSearch(page); + await button(page, 'Complaint').click(); await expect(page.locator('#code-search-status')).toContainText('Code search could not be loaded'); - await page.getByRole('searchbox', { - name: 'Search filing codes' - }).press('Enter'); + await searchbox(page).press('Enter'); await expect(page.getByRole('dialog').getByRole('radio')).toBeVisible(); }); @@ -435,20 +496,15 @@ test.describe('filing code search', () => { times: 1 }); await openSavedDraft(page); - await page.getByRole('button', { - name: 'Find the right filing code' - }).click(); - await page.getByRole('button', { - name: 'Complaint', - exact: true - }).click(); + await openSearch(page); + await button(page, 'Complaint').click(); await expect(page.locator('#code-search-status')).toContainText('We will try again shortly.'); await page.clock.fastForward(10000); await expect(page.getByRole('dialog').getByRole('radio')).toBeVisible(); }); for (const flat of [true, false]) { - test(`applies all four facets across courts (${flat ? 'flat' : 'guided'})`, async ({ + test(`a full path result skips to the check step and fills all four fields (${flat ? 'flat' : 'guided'})`, async ({ page }) => { await mockCourtLists(page, { @@ -459,23 +515,20 @@ test.describe('filing code search', () => { const open = page.getByRole('button', { name: 'Find the right filing code' }); - await open.click(); - const dialog = page.getByRole('dialog'); - await expect(page.getByRole('searchbox', { - name: 'Search filing codes' - })).toBeFocused(); - await page.getByRole('searchbox', { - name: 'Search filing codes' - }).fill('eviction'); - await expect(dialog).not.toContainText('No plain-language explanation'); + const dialog = await openSearch(page); + await expect(searchbox(page)).toBeFocused(); + await searchbox(page).fill('eviction'); await dialog.getByRole('radio', { name: 'Complaint' }).check(); - await expect(dialog.locator('details')).toHaveAttribute('open', ''); - await expect(dialog).toContainText('Case type: Landlord tenant'); - await dialog.getByRole('button', { - name: 'Use this path' - }).click(); + await toCheck(page); + await expect(dialog.getByRole('heading', { + name: 'Check your choices' + })).toBeFocused(); + await expect(dialog.locator('#code-search-confirm')).toContainText('Landlord tenant'); + await expect(dialog.locator('#code-search-confirm')).toContainText('Orange Unit'); + await auditCodeSearch(page, 'check-step'); + await useCode(page); await expect(dialog).not.toBeVisible(); await expect(open).toBeFocused(); await expect(page.locator('#court_code')).toHaveValue('vt:orange'); @@ -490,102 +543,118 @@ test.describe('filing code search', () => { offset: '0' }); expect(queries[0]).not.toHaveProperty('court'); + expect(queries[0]).not.toHaveProperty('purpose'); expect(queries[1].path_id).toBe('101'); }); } for (const hasCourt of [false, true]) { - test(`groups repeated court codes with ${hasCourt ? 'a current court' : 'no court selected'}`, async ({ + test(`the court step offers court cards with ${hasCourt ? 'the current court first' : 'no court chosen'}`, async ({ page }) => { await mockCourtLists(page, { flat: true }); - await mockSearch(page); - await page.route('**/api/filing-code-search/**', route => { - const params = new URL(route.request().url()).searchParams; - if (params.has('path_id')) return route.fallback(); - if (params.has('court')) return route.fulfill({ - json: { - results: [{ - ...result, - court: { - code: params.get('court'), - name: UNITS[params.get('court')] - } - }], - total: 1, - corrected_terms: [], - stale: false, - } - }); - if (params.has('group')) return route.fulfill({ - json: { - courts: [{ - code: 'vt:orange', - name: 'Orange Unit' - }, { - code: 'cook:cvd1', - name: 'Chittenden Unit' - }], - corrected_terms: [], - } - }); - return route.fulfill({ - json: { - groups: [{ - key: 'eviction', - name: 'Eviction Complaint', - variants: ['Eviction Complaint', 'Summary Process Complaint'], - path_count: 100 - }], - total: 1, - corrected_terms: [], - stale: false, - } - }); + await courtRoutes(page, { + groups: [{ + key: 'eviction', + name: 'Eviction Complaint', + variants: ['Eviction Complaint', 'Summary Process Complaint'], + path_count: 100 + }], + courts: [{ + code: 'vt:orange', + name: 'Orange Unit' + }, { + code: 'cook:cvd1', + name: 'Chittenden Unit' + }], }); await openSavedDraft(page); if (!hasCourt) await page.locator('#court_code').evaluate(input => input.value = ''); - await page.getByRole('button', { - name: 'Find the right filing code' - }).click(); - await page.getByRole('searchbox', { - name: 'Search filing codes' - }).fill('eviction'); - const dialog = page.getByRole('dialog'); + const dialog = await openSearch(page); + await searchbox(page).fill('eviction'); await expect(dialog.locator('.code-search__group')).toHaveCount(1); - await expect(dialog.getByRole('radio')).toHaveCount(0); await expect(dialog).toContainText('Also listed as: Summary Process Complaint'); - await dialog.getByRole('button', { - name: 'Choose a court for Eviction Complaint' - }).click(); - const court = dialog.getByLabel('Court', { - exact: true - }); - await expect(court).toHaveValue(hasCourt ? 'cook:cvd1' : ''); + await expect(button(page, 'Next: court and case type')).toBeDisabled(); + await dialog.getByRole('radio', { + name: 'Eviction Complaint' + }).check(); + await toCourts(page); + await expect(dialog).toContainText('Step 2 of 3'); + await expect(dialog.locator('#code-search-summary')).toContainText('Eviction Complaint'); + const courts = dialog.getByRole('group', { + name: 'Which court will hear the case?' + }).getByRole('radio'); + await expect(courts).toHaveCount(2); + await expect(courts.first()).toHaveAccessibleName(hasCourt ? 'Chittenden Unit' : 'Orange Unit'); + const caseTypes = dialog.locator('input[name="code-search-result"]'); if (hasCourt) { - await expect(court.locator('option').nth(1)).toHaveText('Chittenden Unit'); - await expect(dialog.getByRole('radio')).toHaveCount(1); - await dialog.getByRole('radio').check(); + await expect(courts.first()).toBeChecked(); + await expect(caseTypes).toHaveCount(1); + await caseTypes.check(); + await expect(button(page, 'Next: check and use')).toBeEnabled(); } else { - await expect(dialog.getByRole('radio')).toHaveCount(0); + await expect(courts.first()).not.toBeChecked(); + await expect(caseTypes).toHaveCount(0); } - await court.selectOption('vt:orange'); - await expect(dialog.getByRole('button', { - name: 'Use this path' - })).toBeDisabled(); - await expect(dialog.locator('.code-search__result label')).toHaveCount(2); - await dialog.getByRole('radio').check(); - await dialog.getByRole('button', { - name: 'Use this path' - }).click(); + await dialog.getByRole('radio', { + name: 'Orange Unit' + }).check(); + await expect(button(page, 'Next: check and use')).toBeDisabled(); + await expect(caseTypes).toHaveCount(1); + await caseTypes.check(); + await auditCodeSearch(page, `court-cards-${hasCourt ? 'current' : 'none'}`); + await toCheck(page); + await button(page, 'Back').click(); + await expect(dialog.getByRole('radio', { + name: 'Orange Unit' + })).toBeChecked(); + await toCheck(page); + await useCode(page); await expect(dialog).not.toBeVisible(); await expect(page.locator('#court_code')).toHaveValue('vt:orange'); await expect(page.locator('#case_type_code')).toHaveValue('7100'); }); } + test('many courts fall back to a dropdown and the summary returns to step one', async ({ + page + }) => { + await mockCourtLists(page, { + flat: true + }); + await courtRoutes(page, { + groups: [{ + key: 'petition', + name: 'Petition', + variants: ['Petition'] + }], + courts: Object.entries(UNITS).map(([code, name]) => ({ + code, + name + })), + }); + await openSavedDraft(page); + const dialog = await openSearch(page); + await searchbox(page).fill('petition'); + await dialog.getByRole('radio', { + name: 'Petition' + }).check(); + await toCourts(page); + const court = dialog.getByLabel('Which court will hear the case?'); + await expect(court).toHaveValue('cook:cvd1'); + await court.selectOption('vt:addison'); + await expect(dialog.locator('input[name="code-search-result"]')).toHaveCount(1); + await button(page, 'Change what you\'re filing').click(); + await expect(dialog).toContainText('Step 1 of 3'); + await expect(dialog.getByRole('radio', { + name: 'Petition' + })).toBeChecked(); + await toCourts(page); + await expect(court).toHaveValue('vt:addison'); + }); + test('synonym results explain their concepts and show local meanings after court selection', async ({ page }) => { @@ -600,227 +669,171 @@ test.describe('filing code search', () => { scope_name: 'Vermont', source: 'https://www.vtcourts.gov/civil/eviction-process', }; - await page.route('**/api/filing-code-search/**', route => { - const params = new URL(route.request().url()).searchParams; - const match_reason = params.get('q') === 'eviction' ? null : { - kind: 'related_concept', - query: params.get('q'), - concepts: ['eviction'], - corrected_terms: [], - }; - if (params.has('court')) { + const reasonFor = params => params.get('q') === 'eviction' ? null : { + kind: 'related_concept', + query: params.get('q'), + concepts: ['eviction'], + corrected_terms: [], + }; + await courtRoutes(page, { + groups: params => [{ + key: 'eviction', + name: 'Eviction complaint', + variants: ['Eviction complaint'], + concepts: [meaning], + match_reason: reasonFor(params) + }], + courts: [{ + code: 'vt:orange', + name: 'Orange Unit' + }, { + code: 'cook:cvd1', + name: 'Chittenden Unit' + }], + paths: params => { const county = params.get('court') === 'vt:orange' ? 'Orange' : 'Chittenden'; - return route.fulfill({ - json: { - results: [{ - ...result, - case_description: `${county} local meaning.`, - match_reason, - concepts: [{ - ...meaning, - scope: 'county', - scope_name: county, - text: `${county} local meaning.` - }] - }], - total: 1, - corrected_terms: [], - } - }); - } - if (params.has('group')) return route.fulfill({ - json: { - courts: [{ - code: 'vt:orange', - name: 'Orange Unit' - }, { - code: 'cook:cvd1', - name: 'Chittenden Unit' - }], - } - }); - return route.fulfill({ - json: { - groups: [{ - key: 'eviction', - name: 'Eviction complaint', - variants: ['Eviction complaint'], - concepts: [meaning], - match_reason - }], - total: 1, - corrected_terms: [], - } - }); + return [{ + ...result, + case_description: `${county} local meaning.` + }]; + }, }); await openSavedDraft(page); await page.locator('#court_code').evaluate(input => input.value = ''); - await page.getByRole('button', { - name: 'Find the right filing code' - }).click(); - await page.getByRole('searchbox', { - name: 'Search filing codes' - }).fill('unlawful detainer'); + const dialog = await openSearch(page); + await searchbox(page).fill('unlawful detainer'); const group = page.locator('.code-search__group'); - await expect(group).toContainText('Meaning in Vermont: Statewide eviction meaning.'); - const why = group.locator('.code-search__match-reason'); - await expect(why.locator('p').first()).not.toBeVisible(); - await why.locator('summary').focus(); + await expect(group.locator('.code-search__meta')).toHaveText('Related to eviction'); + const about = group.locator('details'); + await expect(about.locator('p').first()).not.toBeVisible(); + await about.locator('summary').focus(); await page.keyboard.press('Enter'); - await expect(why.locator('p').first()).toBeVisible(); - await expect(why).toContainText('Your search for “unlawful detainer” relates to Eviction'); + await expect(about).toContainText('Meaning in Vermont: Statewide eviction meaning.'); + await expect(group.getByRole('radio')).not.toBeChecked(); await expect(group.getByRole('link', { name: 'About this concept' })).toHaveAttribute('href', meaning.source); await auditCodeSearch(page, 'synonym-explanation'); - await group.getByRole('button', { - name: 'Choose a court for Eviction complaint' - }).click(); - await group.getByLabel('Court', { - exact: true - }).selectOption('vt:orange'); - await expect(group).toContainText('Orange local meaning.'); - await group.getByLabel('Court', { - exact: true - }).selectOption('cook:cvd1'); - await expect(group).toContainText('Chittenden local meaning.'); - await expect(group).not.toContainText('Orange local meaning.'); - await page.getByRole('searchbox', { - name: 'Search filing codes' - }).fill('eviction'); - await expect(group.locator('.code-search__match-reason')).toHaveCount(0); + await group.getByRole('radio').check(); + await toCourts(page); + await dialog.getByRole('radio', { + name: 'Orange Unit' + }).check(); + await expect(dialog).toContainText('Orange local meaning.'); + await dialog.getByRole('radio', { + name: 'Chittenden Unit' + }).check(); + await expect(dialog).toContainText('Chittenden local meaning.'); + await expect(dialog).not.toContainText('Orange local meaning.'); + await button(page, 'Back').click(); + await searchbox(page).fill('eviction'); + await expect(group.locator('.code-search__meta')).toHaveCount(0); await expect(group).toContainText('Statewide eviction meaning.'); }); - test('contextual radios and claim amount dropdown reset when the case area changes', async ({ + test('case questions are chips that reset when the case area changes', async ({ page }) => { await mockCourtLists(page, { flat: true }); const queries = []; - await page.route('**/api/filing-code-search/**', route => { - const params = new URL(route.request().url()).searchParams; - queries.push({ - ...Object.fromEntries(params), - choices: JSON.parse(params.get('case_filters') || '{}') - }); - const topic = params.get('case_topic') || (params.get('q') === 'eviction' ? 'eviction' : 'small_claims'); - const case_facets = topic === 'eviction' ? [{ - key: 'role', - label: 'Your role', - options: [{ - value: 'tenant', - label: 'Tenant / occupant' - }, { - value: 'landlord', - label: 'Landlord / owner' - }] - }, { - key: 'property', - label: 'Property', - options: [{ - value: 'residential', - label: 'Home' - }, { - value: 'commercial', - label: 'Business' - }] - }, ] : [{ - key: 'amount', - label: 'Claim amount', - control: 'select', - options: [{ - value: '0:250000', - label: 'Up to $2,500' - }, { - value: '250001:1000000', - label: '$2,500.01–$10,000' - }, ], - help: 'State-specific limits and exceptions.', - source: 'https://www.illinoiscourts.gov/' - }]; - return route.fulfill({ - json: { - groups: [{ - key: 'answer', - name: 'Answer', - variants: ['Answer'] - }], - total: 1, - corrected_terms: [], + await courtRoutes(page, { + queries, + groups: [{ + key: 'answer', + name: 'Answer', + variants: ['Answer'] + }], + extra: params => { + const topic = params.get('case_topic') || (params.get('q') === 'eviction' ? 'eviction' : 'small_claims'); + return { case_topic: topic, - case_facets, case_topics: [{ value: 'eviction', label: 'Eviction' }, { value: 'small_claims', label: 'Small claims' - }] - } - }); + }], + case_facets: topic === 'eviction' ? [{ + key: 'role', + label: 'Your role', + options: [{ + value: 'tenant', + label: 'Tenant / occupant' + }, { + value: 'landlord', + label: 'Landlord / owner' + }] + }, { + key: 'property', + label: 'Property', + options: [{ + value: 'residential', + label: 'Home' + }, { + value: 'commercial', + label: 'Business' + }] + }] : [{ + key: 'amount', + label: 'Claim amount', + options: [{ + value: '0:250000', + label: 'Up to $2,500' + }, { + value: '250001:1000000', + label: '$2,500.01–$10,000' + }], + help: 'State-specific limits and exceptions.', + source: 'https://www.illinoiscourts.gov/' + }], + }; + }, }); await openSavedDraft(page); - await page.getByRole('button', { - name: 'Find the right filing code' - }).click(); - await page.getByRole('searchbox', { - name: 'Search filing codes' - }).fill('eviction'); + const dialog = await openSearch(page); + await searchbox(page).fill('eviction'); const facets = page.locator('#code-search-case-filters'); - await expect(facets).toBeVisible(); - await facets.getByRole('radio', { - name: 'Tenant / occupant', + await expect(facets.getByRole('group', { + name: 'Your role' + })).toBeVisible(); + await expect(facets.getByRole('radio')).toHaveCount(0); + await expect(facets.getByRole('heading')).toHaveCount(0); + const chip = name => facets.getByRole('button', { + name, exact: true - }).check(); + }); + await chip('Tenant / occupant').click(); await expect.poll(() => queries.at(-1)?.choices.role).toBe('tenant'); - await facets.getByRole('radio', { - name: 'Home', - exact: true - }).check(); + await expect(chip('Tenant / occupant')).toHaveAttribute('aria-pressed', 'true'); + await chip('Home').click(); await expect.poll(() => queries.at(-1)?.choices.property).toBe('residential'); - const business = facets.getByRole('radio', { - name: 'Business', - exact: true - }); - await expect(business).not.toBeChecked(); - await facets.getByRole('radio', { - name: 'Home', - exact: true - }).focus(); - await page.keyboard.press('ArrowRight'); - await expect(business).toBeChecked(); + await chip('Business').focus(); + await page.keyboard.press('Enter'); await expect.poll(() => queries.at(-1)?.choices.property).toBe('commercial'); - await expect(business).toBeFocused(); - await auditCodeSearch(page, 'contextual-radios-desktop'); + await expect(chip('Business')).toBeFocused(); + await expect(chip('Business')).toHaveAttribute('aria-pressed', 'true'); + await expect(chip('Home')).toHaveAttribute('aria-pressed', 'false'); + await auditCodeSearch(page, 'case-chips-desktop'); await page.setViewportSize({ width: 390, height: 844 }); - expect(await page.getByRole('dialog').evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true); - await auditCodeSearch(page, 'contextual-radios-mobile'); - await facets.getByLabel('Case area', { - exact: true - }).selectOption('small_claims'); - await expect(facets.getByLabel('Claim amount', { - exact: true - })).toBeVisible(); + expect(await dialog.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true); + await auditCodeSearch(page, 'case-chips-mobile'); + await expect(chip('Eviction')).toHaveAttribute('aria-pressed', 'true'); + await facets.getByRole('group', { + name: 'Kind of case' + }).getByRole('button', { + name: 'Small claims' + }).click(); + await expect(chip('Up to $2,500')).toBeVisible(); expect(queries.at(-1).choices).toEqual({}); - await facets.getByLabel('Claim amount', { - exact: true - }).selectOption('250001:1000000'); + await chip('$2,500.01–$10,000').click(); await expect.poll(() => queries.at(-1)?.choices.amount).toBe('250001:1000000'); - await expect(facets.getByLabel('Claim amount', { - exact: true - })).toHaveValue('250001:1000000'); - const amount = facets.getByLabel('Claim amount', { - exact: true - }); - await amount.focus(); - await page.keyboard.press('ArrowUp'); - await expect.poll(() => queries.at(-1)?.choices.amount).toBe('0:250000'); - await expect(amount).toBeFocused(); + await expect(chip('$2,500.01–$10,000')).toHaveAttribute('aria-pressed', 'true'); const amountHelp = facets.getByText('About claim amounts', { exact: true }); @@ -833,15 +846,18 @@ test.describe('filing code search', () => { width: 1366, height: 768 }); - await auditCodeSearch(page, 'claim-amount-help-desktop'); - await page.getByRole('searchbox', { - name: 'Search filing codes' - }).fill('small claims'); + await dialog.getByRole('radio', { + name: 'Answer' + }).check(); + await toCourts(page); + await expect(dialog.locator('#code-search-summary')).toContainText('Small claims · $2,500.01–$10,000'); + await button(page, 'Back').click(); + await searchbox(page).fill('small claims'); await expect.poll(() => queries.at(-1)?.q).toBe('small claims'); expect(queries.at(-1).choices).toEqual({}); }); - test('court paths emphasize case types without repeating shared filing information', async ({ + test('case types are the choice in step two, each with its own description', async ({ page }) => { await page.setViewportSize({ @@ -853,109 +869,46 @@ test.describe('filing code search', () => { }); const filingName = 'Answer/Response to Complaint/Petition'; const caseTypes = ['Commercial', 'Commercial - Possession Only', 'Ejectment', 'Residential - Eviction', 'Residential - Eviction Possession Only']; - await page.route('**/api/filing-code-search/**', route => { - const params = new URL(route.request().url()).searchParams; - if (params.has('court')) return route.fulfill({ - json: { - results: caseTypes.map((name, i) => ({ - ...result, - id: i + 100, - filing_type: { - ...result.filing_type, - name: filingName - }, - case_type: { - code: `case-${i}`, - name - }, - case_context: `Eviction › ${name}`, - case_description: "An existing plain-language case description.", - facets: { - purpose: 'responding', - document: 'main' - }, - })), - total: caseTypes.length, - corrected_terms: [], - } - }); - if (params.has('group')) return route.fulfill({ - json: { - courts: [{ - code: 'vt:orange', - name: 'Orange Unit' - }] - } - }); - return route.fulfill({ - json: { - groups: [{ - key: 'answer', - name: filingName, - variants: [filingName] - }], - total: 1, - corrected_terms: [], - case_topic: 'eviction', - case_topics: [{ - value: 'eviction', - label: 'Eviction' - }], - case_facets: [{ - key: 'role', - label: 'Your role', - options: [{ - value: 'tenant', - label: 'Tenant / occupant' - }, { - value: 'landlord', - label: 'Landlord / owner' - }] - }, { - key: 'property', - label: 'Property', - options: [{ - value: 'residential', - label: 'Home' - }, { - value: 'commercial', - label: 'Business' - }] - }, { - key: 'relief', - label: 'What the landlord is asking for', - options: [{ - value: 'possession', - label: 'Property back only' - }, { - value: 'money', - label: 'Money too' - }] - }, ] - } - }); + await courtRoutes(page, { + groups: [{ + key: 'answer', + name: filingName, + variants: [filingName] + }], + paths: () => caseTypes.map((name, i) => ({ + ...result, + id: i + 100, + filing_type: { + ...result.filing_type, + name: i === 2 ? 'Answer - Ejectment' : filingName + }, + case_type: { + code: `case-${i}`, + name + }, + case_context: `Eviction › ${name}`, + case_description: 'An existing plain-language case description.', + })), }); await openSavedDraft(page); - await page.getByRole('button', { - name: 'Find the right filing code' - }).click(); - await page.getByRole('searchbox', { - name: 'Search filing codes' - }).fill('answer'); - await page.getByRole('button', { - name: `Choose a court for ${filingName}` - }).click(); - await page.getByLabel('Court', { - exact: true - }).selectOption('vt:orange'); + const dialog = await openSearch(page); + await searchbox(page).fill('answer'); + await dialog.getByRole('radio', { + name: filingName + }).check(); + await toCourts(page); + await expect(dialog.getByRole('radio', { + name: 'Orange Unit' + })).toBeChecked(); const choices = page.locator('.code-search__path-choice'); await expect(choices).toHaveCount(5); + await expect(page.locator('#code-search-path-status')).toHaveText('5 case types fit'); for (const name of caseTypes) await expect(page.getByRole('radio', { name: `Eviction › ${name}`, exact: true })).toBeVisible(); - await expect(choices.locator('label')).not.toContainText([filingName, filingName, filingName, filingName, filingName]); - expect(await choices.evaluateAll(rows => rows.every(row => row.getBoundingClientRect().height < 65))).toBe(true); + await expect(choices.nth(2)).toContainText('This court calls it “Answer - Ejectment”.'); + await expect(choices.nth(0)).not.toContainText('This court calls it'); const residential = page.getByRole('radio', { name: 'Eviction › Residential - Eviction', exact: true @@ -964,41 +917,17 @@ test.describe('filing code search', () => { await page.keyboard.press('Space'); await expect(residential).toBeChecked(); await expect(residential).toHaveAccessibleDescription('An existing plain-language case description.'); - await auditCodeSearch(page, 'case-paths-collapsed-desktop'); - await expect(choices.locator('details[open]')).toHaveCount(0); - const visible = await choices.evaluateAll(rows => { - const bounds = document.getElementById('code-search-results').getBoundingClientRect(); - return rows.filter(row => row.getBoundingClientRect().top >= bounds.top && row.getBoundingClientRect().bottom <= bounds.bottom).length; - }); - expect(visible).toBeGreaterThanOrEqual(3); - const toggle = choices.nth(3).getByText('Details', { - exact: true - }); - const before = await toggle.boundingBox(); - await expect(choices.nth(3).getByText('An existing plain-language case description.')).toBeVisible(); - await toggle.focus(); - await page.keyboard.press('Enter'); - await expect(toggle).toBeFocused(); - expect(await toggle.boundingBox()).toEqual(before); - await expect(choices.nth(3).locator('ol')).not.toContainText('case-3'); - await expect(choices.locator('.code-search__match-reason')).toHaveCount(0); - await expect(choices.nth(3).locator('ol')).toContainText(filingName); - await auditCodeSearch(page, 'case-path-details-desktop'); + await expect(button(page, 'Next: check and use')).toBeEnabled(); + await auditCodeSearch(page, 'case-types-desktop'); await page.setViewportSize({ width: 390, height: 844 }); expect(await page.getByRole('dialog').evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true); - await auditCodeSearch(page, 'case-path-details-mobile'); - await page.keyboard.press('Enter'); - await expect(toggle).toBeFocused(); - await expect(choices.nth(3).locator('ol')).not.toBeVisible(); - await expect(page.getByRole('button', { - name: 'Use this path' - })).toBeEnabled(); + await auditCodeSearch(page, 'case-types-mobile'); }); - test('compact filters keep four results visible and apply case stage only with a path', async ({ + test('case stage and document filters sit behind a short line and apply case stage only with a path', async ({ page }) => { await page.setViewportSize({ @@ -1009,46 +938,14 @@ test.describe('filing code search', () => { flat: true }); const queries = []; - await page.route('**/api/filing-code-search/**', route => { - const params = new URL(route.request().url()).searchParams; - queries.push(Object.fromEntries(params)); - const path = { - ...result, - initial: params.get('existing_case') !== 'yes' - }; - if (params.has('path_id')) return route.fulfill({ - json: { - path: { - ...path, - options - } - } - }); - if (params.has('court')) return route.fulfill({ - json: { - results: [path], - total: 1, - corrected_terms: [] - } - }); - if (params.has('group')) return route.fulfill({ - json: { - courts: [{ - code: 'vt:orange', - name: 'Orange Unit' - }] - } - }); - const groups = Array.from({ + await courtRoutes(page, { + queries, + groups: Array.from({ length: 6 }, (_, i) => ({ key: `type-${i}`, name: `Complaint / Petition - Eviction - Residential - Possession Only - Fee ${i}`, variants: ['Complaint', 'Petition'], - facets: { - purpose: 'starting', - document: 'main' - }, case_contexts: ['Small claims', 'Civil › Eviction'], case_context_count: 2, concepts: [{ @@ -1057,277 +954,390 @@ test.describe('filing code search', () => { text: 'A short explanation of this concept.', scope: 'general' }], - })); - return route.fulfill({ - json: { - groups, - total: 6, - corrected_terms: [] - } - }); + })), }); await openSavedDraft(page); - await page.getByRole('button', { - name: 'Find the right filing code' - }).click(); - await page.getByRole('searchbox', { - name: 'Search filing codes' - }).fill('eviction'); + const dialog = await openSearch(page); + await searchbox(page).fill('eviction'); await expect(page.locator('.code-search__group')).toHaveCount(6); await expect(page.locator('.code-search__group').first().locator('.code-search__meta')).toContainText('2 case types, including Small claims'); - const visible = await page.locator('#code-search-results').evaluate(container => { - const bounds = container.getBoundingClientRect(); - return Array.from(container.querySelectorAll('.code-search__group')).filter(row => { - const rect = row.getBoundingClientRect(); - return rect.top >= bounds.top && rect.bottom <= bounds.bottom; - }).length; - }); - expect(visible).toBeGreaterThanOrEqual(4); + await expect(dialog.getByLabel('Your task')).toHaveCount(0); + await expect(dialog.getByRole('button', { + name: 'Main document' + })).not.toBeVisible(); const before = await page.locator('input[name="existing_case"]:checked').inputValue(); - await page.getByLabel('Case stage', { - exact: true - }).selectOption('yes'); + await expect(page.locator('#code-search-stage-label')).toHaveText('New case'); + await button(page, 'Change case stage').click(); + await expect(page.locator('#code-search-stage-label')).toHaveText('Existing case'); await expect.poll(() => queries.at(-1)?.existing_case).toBe('yes'); - await page.getByLabel('Your task', { - exact: true - }).selectOption('responding'); - await expect.poll(() => queries.at(-1)?.purpose).toBe('responding'); - await page.getByLabel('Document', { - exact: true - }).selectOption('main'); + const toggle = dialog.locator('#code-search-more-filters'); + await expect(toggle).toHaveText('More filters'); + await expect(toggle).toHaveAttribute('aria-expanded', 'false'); + await toggle.click(); + await expect(toggle).toHaveText('Fewer filters'); + await expect(toggle).toHaveAttribute('aria-expanded', 'true'); + await button(page, 'Main document').click(); await expect.poll(() => queries.at(-1)?.document).toBe('main'); + await auditCodeSearch(page, 'more-filters-open'); + await toggle.click(); + await expect(toggle).toHaveText('More filters (1)'); + await expect(button(page, 'Main document')).toBeHidden(); await expect(page.locator('input[name="existing_case"]:checked')).toHaveValue(before); - const first = page.locator('.code-search__group').first(); - await first.getByRole('button', { - name: /Choose a court/ - }).click(); - await first.getByLabel('Court', { - exact: true - }).selectOption('vt:orange'); - await first.getByRole('radio').check(); - await page.getByRole('button', { - name: 'Use this path' - }).click(); - await expect(page.getByRole('dialog')).not.toBeVisible(); + await dialog.getByRole('radio').first().check(); + await toCourts(page); + await expect(dialog.locator('#code-search-summary')).toContainText('Existing case'); + await dialog.locator('input[name="code-search-result"]').check(); + await toCheck(page); + await useCode(page); + await expect(dialog).not.toBeVisible(); await expect(page.locator('input[name="existing_case"]:checked')).toHaveValue('existing'); await expect(page.locator('#case_type_code')).toHaveValue('7100'); }); - test('ZIP filter updates the search and clearing it restores all counties', async ({ + test('ZIP narrows the search, offers the filer\'s ZIP as a shortcut, and clears back to every county', async ({ page }) => { await mockCourtLists(page, { flat: true }); const queries = []; - await page.route('**/api/filing-code-search/**', route => { - const params = new URL(route.request().url()).searchParams; - queries.push(Object.fromEntries(params)); - return route.fulfill({ - json: { - groups: [{ - key: 'petition', - name: 'Petition', - variants: ['Petition'] - }], - total: 1, - corrected_terms: [], - location_counties: params.get('zip') ? ['Cook'] : [], - } - }); + await courtRoutes(page, { + queries, + groups: [{ + key: 'petition', + name: 'Petition', + variants: ['Petition'] + }], + extra: params => ({ + location_counties: params.get('zip') ? ['Cook'] : [] + }), }); await openSavedDraft(page); - await page.getByRole('button', { - name: 'Find the right filing code' - }).click(); - await page.getByRole('searchbox', { - name: 'Search filing codes' - }).fill('eviction'); - await expect(page.locator('.code-search__group')).toHaveCount(1); - await page.getByLabel('Case location ZIP', { + const dialog = await openSearch(page); + const zip = dialog.getByLabel('Case ZIP code', { exact: true - }).fill('60601'); - await expect(page.locator('#code-search-status')).toContainText('Counties: Cook.'); + }); + await expect(zip).toHaveValue(''); + await searchbox(page).fill('eviction'); + await expect(page.locator('.code-search__group')).toHaveCount(1); + const shortcut = button(page, 'Use your ZIP (60601)'); + await expect(shortcut).toBeVisible(); + await zip.fill('606'); + await expect(page.locator('#code-search-zip-status')).toHaveText('Enter all 5 digits.'); + expect(queries.at(-1).zip).toBe(''); + await shortcut.click(); + await expect(zip).toHaveValue('60601'); + await expect(zip).toBeFocused(); + await expect(page.locator('#code-search-zip-status')).toHaveText('60601 is in Cook County.'); + await expect(shortcut).toBeHidden(); expect(queries.at(-1).zip).toBe('60601'); - await page.getByLabel('Case location ZIP', { - exact: true - }).fill(''); - await expect(page.locator('#code-search-status')).not.toContainText('Counties:'); + await auditCodeSearch(page, 'zip-shortcut'); + await dialog.getByRole('radio', { + name: 'Petition' + }).check(); + await toCourts(page); + await expect(dialog.locator('#code-search-summary')).toContainText('60601 · Cook'); + await button(page, 'Back').click(); + await zip.fill(''); + await expect(page.locator('#code-search-zip-status')).toHaveText(''); await expect.poll(() => queries.at(-1)?.zip).toBe(''); + await expect(shortcut).toBeVisible(); }); - test('case-type links narrow courts and paths and can be cleared', async ({ + test('many results ask what the filer wants to do, with counts, and show name matches first', async ({ page }) => { await mockCourtLists(page, { flat: true }); const queries = []; - await page.route('**/api/filing-code-search/**', async route => { - const params = new URL(route.request().url()).searchParams; - queries.push(Object.fromEntries(params)); - const context = params.get('context'); - if (params.has('court')) return route.fulfill({ - json: { - results: [{ - ...result, - case_type: { - code: context || 'all', - name: context === 'small' ? 'Small claims' : 'Eviction' - } - }], - total: 1, - corrected_terms: [], - } - }); - if (params.has('group')) { - if (context === 'eviction') await new Promise(resolve => setTimeout(resolve, 150)); - const courts = [{ - code: 'vt:orange', - name: 'Orange Unit' + const all = Array.from({ + length: 12 + }, (_, i) => ({ + key: `type-${i}`, + name: i < 3 ? `Small Claims Complaint ${i}` : `Affidavit ${i}`, + variants: [`Filing ${i}`], + })); + await courtRoutes(page, { + queries, + groups: params => { + const offset = Number(params.get('offset')); + const shown = params.get('strong') === 'true' ? all.slice(0, 3) : all; + return shown.slice(offset, offset + 20); + }, + extra: params => ({ + total: params.get('action') === 'start' ? 3 : 12, + strong_total: 3, + actions: [{ + value: 'start', + label: 'Start a case', + count: 3 }, { - code: 'cook:cvd1', - name: 'Chittenden Unit' - }]; - return route.fulfill({ - json: { - courts: context === 'small' ? courts.slice(0, 1) : context === 'eviction' ? courts.slice(1) : courts - } - }); - } - return route.fulfill({ - json: { - groups: [{ - key: 'petition', - name: 'Petition', - variants: ['Petition'], - case_contexts: ['Small claims', 'Civil › Eviction'], - case_context_count: 2, - case_context_options: [{ - key: 'small', - label: 'Small claims' - }, { - key: 'eviction', - label: 'Civil › Eviction' - }] - }], - total: 1, - corrected_terms: [], - } - }); + value: 'proof', + label: 'Supporting papers', + count: 9 + }], + case_topics: [], + case_facets: [], + }), }); await openSavedDraft(page); - await page.getByRole('button', { - name: 'Find the right filing code' + const dialog = await openSearch(page); + await searchbox(page).fill('small claims'); + await expect(dialog.locator('.code-search__group')).toHaveCount(3); + await expect(page.locator('#code-search-status')).toHaveText('3 filing types match “small claims”'); + expect(queries.at(-1).strong).toBe('true'); + const question = dialog.getByRole('group', { + name: 'What do you want to do?' + }); + await expect(question.getByRole('button', { + name: 'Supporting papers 9' + })).toBeVisible(); + await auditCodeSearch(page, 'action-question'); + await dialog.getByRole('button', { + name: 'Show 9 more filing types used in these case types' }).click(); - await page.getByRole('searchbox', { - name: 'Search filing codes' - }).fill('petition'); - const group = page.locator('.code-search__group'); - await group.locator('summary').click(); - await auditCodeSearch(page, 'case-type-context-links'); - const small = group.getByRole('button', { - name: 'Small claims', - exact: true + await expect(dialog.locator('.code-search__group')).toHaveCount(12); + expect(queries.at(-1)).toMatchObject({ + strong: 'false', + offset: '3' }); - await small.focus(); - await page.keyboard.press('Enter'); - const court = group.getByLabel('Court', { - exact: true + await question.getByRole('button', { + name: 'Start a case 3' + }).click(); + await expect.poll(() => queries.at(-1)?.action).toBe('start'); + await expect(question).toBeVisible(); + // The card's padding, outside its label, still chooses it. + await dialog.locator('.code-search__group').first().click({ + position: { + x: 4, + y: 4 + } }); - await expect(court).toBeEnabled(); - await expect(court.locator('option')).toHaveText(['Choose a court', 'Orange Unit']); - await court.selectOption('vt:orange'); - await group.getByRole('radio').check(); - await expect(page.getByRole('button', { - name: 'Use this path' - })).toBeEnabled(); - await group.locator('summary').first().click(); - await group.getByRole('button', { - name: 'Civil › Eviction', + await expect(dialog.locator('.code-search__group').first().getByRole('radio')).toBeChecked(); + await toCourts(page); + await expect(dialog.locator('#code-search-summary')).toContainText('Start a case'); + }); + + test('the court step asks only about choices its case types differ on', async ({ + page + }) => { + await mockCourtLists(page, { + flat: true + }); + const over = { + value: '1000001:', + label: 'More than $10,000' + }; + const rows = [ + ['Civil - Amount Claimed Greater Than $10,000', 'Other Personal Injury Complaint - Jury', '(Jury - 12) (Govn\'t)', '12', 'lawyer', 'yes', over], + ['Civil - Amount Claimed Greater Than $10,000', 'Other Personal Injury Complaint - Jury', '(Jury - 12)', '12', 'lawyer', 'no', over], + ['Civil - Amount Claimed Greater Than $10,000', 'Other Personal Injury Complaint - Jury', '(Jury - 6)', '6', 'lawyer', 'no', over], + ['Civil', 'Other Personal Injury Complaint - Jury - Self-Represented Litigant', '(Jury - 12)', '12', 'self', 'no', null], + ['Civil', 'Other Personal Injury Complaint - Jury - Self-Represented Litigant', '(Jury - 6)', '6', 'self', 'no', null], + ['Civil - Amount Claimed Greater Than $10,000', 'Other Personal Injury Complaint - Non-Jury', '', 'none', 'lawyer', 'no', over], + ['Civil', 'Other Personal Injury Complaint - Non-Jury - Self-Represented Litigant', '', 'none', 'self', 'no', null], + ['Civil', 'Personal Injury Complaint - Jury', '(Jury - 12) (Govn\'t)', '12', 'lawyer', 'yes', null], + ]; + const filingName = 'Complaint / Petition - Personal Injury'; + await courtRoutes(page, { + groups: [{ + key: 'pi', + name: filingName, + variants: [filingName] + }], + courts: [{ + code: 'vt:orange', + name: 'Orange Unit' + }], + paths: () => rows.map(([category, caseType, qualifier, jury, representation, government, amount], i) => ({ + ...result, + id: 300 + i, + case_context: `${category} › ${caseType}`, + filing_type: { + code: `pi-${i}`, + name: `${filingName} ${qualifier} - Fee` + }, + filing_label: filingName, + qualifiers: { + jury, + representation, + government, + amount + }, + })), + }); + await openSavedDraft(page); + const dialog = await openSearch(page); + await searchbox(page).fill('personal injury'); + await dialog.getByRole('radio', { + name: filingName + }).check(); + await toCourts(page); + const questions = page.locator('#code-search-path-questions'); + const status = page.locator('#code-search-path-status'); + const government = questions.getByRole('group', { + name: 'Filing for a government body?' + }); + await expect(government.getByRole('button', { + name: 'No', exact: true + })).toHaveAttribute('aria-pressed', 'true'); + await expect(questions.getByRole('group', { + name: 'How much are you asking for?' + })).toHaveCount(0); + await expect(status).toHaveText('6 case types fit'); + await questions.getByRole('button', { + name: "No, I'm representing myself" }).click(); - await expect(page.getByRole('button', { - name: 'Use this path' - })).toBeDisabled(); - await expect(court).toBeEnabled(); - await expect(court.locator('option')).toHaveText(['Choose a court', 'Chittenden Unit']); - await court.selectOption('cook:cvd1'); - await expect(group.getByRole('radio')).toHaveCount(1); - expect(queries.filter(item => item.court).at(-1).context).toBe('eviction'); - await group.getByRole('button', { - name: 'Show all case types' + await expect(status).toHaveText('3 case types fit'); + await expect(government).toHaveCount(0); + await questions.getByRole('button', { + name: 'Jury of 12' }).click(); - await expect(court.locator('option')).toHaveCount(3); - await expect.poll(() => queries.at(-1)?.context).toBe(''); + await expect(status).toHaveText('1 case type fits'); + await expect(dialog.locator('.code-search__path-choice')).not.toContainText('This court calls it'); + await auditCodeSearch(page, 'court-step-questions'); + await dialog.locator('input[name="code-search-result"]').check(); + await toCheck(page); + await expect(dialog.locator('#code-search-confirm')).toContainText('(Jury - 12)'); }); - test('earlier groups remain usable after loading another page of groups', async ({ + test('long choice rows collapse, categories filter, and court terms explain themselves', async ({ page }) => { await mockCourtLists(page, { flat: true }); - await page.route('**/api/filing-code-search/**', route => { - const params = new URL(route.request().url()).searchParams; - if (params.has('court')) return route.fulfill({ - json: { - results: [result], - total: 1, - corrected_terms: [] - } - }); - if (params.has('group')) return route.fulfill({ - json: { - courts: [{ - code: 'vt:orange', - name: 'Orange Unit' - }] - } - }); - const offset = Number(params.get('offset')); - const groups = Array.from({ - length: offset ? 1 : 20 - }, (_, i) => ({ - key: `type-${offset + i}`, - name: `Filing ${offset + i}`, - variants: [`Filing ${offset + i}`], - path_count: 100, - })); - return route.fulfill({ - json: { - groups, - total: 21, - corrected_terms: [], - stale: false - } - }); + const queries = []; + const categories = ['Personal Injury/Wrongful Death', 'Civil', 'Civil - Amount Claimed Greater Than $10,000', 'Probate', 'Chancery', 'Law', 'Other Actions', 'Commercial Litigation']; + const subrogation = { + key: 'subrogation', + label: 'Subrogation', + text: 'When an insurance company that paid for someone\'s loss sues the person who caused it.', + source: '' + }; + await courtRoutes(page, { + queries, + groups: ['Personal Injury', 'Personal Injury - Subrogation', 'Dram Shop'].map((name, i) => ({ + key: `g-${i}`, + name: `Complaint / Petition - ${name}`, + variants: [`Complaint / Petition - ${name}`], + glossary: i === 1 ? [subrogation] : [], + })), + extra: { + categories: categories.map((label, i) => ({ + value: label, + label, + count: 10 - i + })), + }, }); await openSavedDraft(page); - await page.getByRole('button', { - name: 'Find the right filing code' + const dialog = await openSearch(page); + await searchbox(page).fill('complaint'); + const row = dialog.getByRole('group', { + name: 'Case category' + }); + await expect(row.getByRole('button')).toHaveCount(7); + await row.getByRole('button', { + name: 'Show 3 more choices for Case category' }).click(); - await page.getByRole('button', { - name: 'Complaint', + await expect(row.getByRole('button')).toHaveCount(9); + await expect(row.getByRole('button', { + name: 'Law 5' + })).toBeFocused(); + await row.getByRole('button', { + name: 'Civil 9', exact: true }).click(); - await expect(page.locator('.code-search__group')).toHaveCount(20); - await page.getByRole('button', { - name: 'Show more results', + await expect.poll(() => queries.at(-1)?.category).toBe('Civil'); + const prefix = dialog.locator('.code-search__name-prefix').first(); + await expect(prefix).toHaveClass(/is-shared/); + await expect(dialog.getByRole('radio', { + name: 'Complaint / Petition - Personal Injury', exact: true + })).toBeVisible(); + const term = dialog.locator('.code-search__glossary summary'); + await expect(term).toHaveText('What does “subrogation” mean?'); + await term.click(); + await expect(dialog.locator('.code-search__glossary')).toContainText('insurance company'); + await expect(dialog.getByRole('radio', { + name: 'Complaint / Petition - Personal Injury - Subrogation' + })).not.toBeChecked(); + await auditCodeSearch(page, 'categories-and-glossary'); + }); + + test('a search with matches only in the other case stage links to them', async ({ + page + }) => { + await mockCourtLists(page, { + flat: true + }); + const queries = []; + await courtRoutes(page, { + queries, + groups: params => params.get('existing_case') === 'yes' ? [{ + key: 'seal', + name: 'Impound/Seal', + variants: ['Impound/Seal'] + }] : [], + extra: params => ({ + other_stage_total: params.get('existing_case') === 'yes' ? 0 : 10 + }), + }); + await openSavedDraft(page); + const dialog = await openSearch(page); + await searchbox(page).fill('seal eviction'); + await expect(page.locator('#code-search-status')).toContainText('No matching filing types'); + const link = button(page, '10 results in “existing case” filings'); + await expect(link).toBeVisible(); + await auditCodeSearch(page, 'other-stage-link'); + await link.click(); + await expect(page.locator('#code-search-stage-label')).toHaveText('Existing case'); + await expect(dialog.getByRole('radio', { + name: 'Impound/Seal' + })).toBeVisible(); + expect(queries.at(-1)).toMatchObject({ + existing_case: 'yes', + q: 'seal eviction' + }); + await expect(dialog.locator('#code-search-other-stage')).toBeHidden(); + }); + + test('earlier groups remain usable after loading another page of groups', async ({ + page + }) => { + await mockCourtLists(page, { + flat: true + }); + await courtRoutes(page, { + groups: params => Array.from({ + length: Number(params.get('offset')) ? 1 : 20 + }, (_, i) => ({ + key: `type-${Number(params.get('offset')) + i}`, + name: `Filing ${Number(params.get('offset')) + i}`, + variants: [`Filing ${Number(params.get('offset')) + i}`], + })), + extra: { + total: 21 + }, + }); + await openSavedDraft(page); + const dialog = await openSearch(page); + await button(page, 'Complaint').click(); + await expect(page.locator('.code-search__group')).toHaveCount(20); + await dialog.getByRole('button', { + name: /^Show more/ }).click(); await expect(page.locator('.code-search__group')).toHaveCount(21); - await page.getByRole('button', { - name: 'Choose a court for Filing 0', + await dialog.getByRole('radio', { + name: 'Filing 0', exact: true - }).click(); - await page.locator('.code-search__group').first().getByLabel('Court', { - exact: true - }).selectOption('vt:orange'); - await page.getByRole('dialog').getByRole('radio').check(); - await expect(page.getByRole('button', { - name: 'Use this path' - })).toBeEnabled(); + }).check(); + await toCourts(page); + await dialog.locator('input[name="code-search-result"]').check(); + await expect(button(page, 'Next: check and use')).toBeEnabled(); }); test('rejects a retired path without changing form choices and restores focus on Escape', async ({ @@ -1340,16 +1350,11 @@ test.describe('filing code search', () => { reject: true }); await openSavedDraft(page); - await page.getByRole('button', { - name: 'Find the right filing code' - }).click(); - await page.getByRole('searchbox', { - name: 'Search filing codes' - }).fill('eviction'); + await openSearch(page); + await searchbox(page).fill('eviction'); await page.getByRole('dialog').getByRole('radio').check(); - await page.getByRole('button', { - name: 'Use this path' - }).click(); + await toCheck(page); + await useCode(page); await expect(page.getByRole('dialog')).toContainText('The code list has changed'); await expect(page.locator('#court_code')).toHaveValue('cook:cvd1'); await page.keyboard.press('Escape'); @@ -1388,32 +1393,20 @@ test.describe('filing code search', () => { delivered = true; }); await openSavedDraft(page); - await page.getByRole('button', { - name: 'Find the right filing code' - }).click(); - await page.getByRole('searchbox', { - name: 'Search filing codes' - }).fill('eviction'); + await openSearch(page); + await searchbox(page).fill('eviction'); await page.getByRole('dialog').getByRole('radio').check(); const waiting = page.waitForRequest(request => new URL(request.url()).searchParams.get('q') === 'delayed'); - await page.getByRole('searchbox', { - name: 'Search filing codes' - }).fill('delayed'); - await expect(page.getByRole('button', { - name: 'Use this path' - })).toBeDisabled(); + await searchbox(page).fill('delayed'); + await expect(button(page, 'Next: court and case type')).toBeDisabled(); await waiting; - await page.getByRole('searchbox', { - name: 'Search filing codes' - }).fill('eviction'); + await searchbox(page).fill('eviction'); await expect(page.getByRole('dialog').getByRole('radio', { name: 'Complaint' })).toBeVisible(); await expect.poll(() => delivered).toBe(true); await expect(page.getByRole('dialog')).not.toContainText('Outdated result'); - await expect(page.getByRole('button', { - name: 'Use this path' - })).toBeDisabled(); + await expect(button(page, 'Next: court and case type')).toBeDisabled(); }); test('shows unavailable paths without allowing selection, and keeps focus in the modal', async ({ @@ -1426,17 +1419,11 @@ test.describe('filing code search', () => { unavailable: true }); await openSavedDraft(page); - await page.getByRole('button', { - name: 'Find the right filing code' - }).click(); - await page.getByRole('searchbox', { - name: 'Search filing codes' - }).fill('eviction'); + await openSearch(page); + await searchbox(page).fill('eviction'); await expect(page.getByRole('dialog').getByRole('radio')).toBeDisabled(); - await expect(page.getByRole('button', { - name: 'Use this path' - })).toBeDisabled(); - for (let i = 0; i < 8; i++) { + await expect(button(page, 'Next: court and case type')).toBeDisabled(); + for (let i = 0; i < 12; i++) { await page.keyboard.press('Tab'); expect(await page.evaluate(() => document.querySelector('#code-search-dialog').contains(document.activeElement))).toBe(true); } diff --git a/fly.toml b/fly.toml index cf864f25..024802e3 100644 --- a/fly.toml +++ b/fly.toml @@ -10,8 +10,15 @@ primary_region = 'lax' [env] DJANGO_SETTINGS_MODULE = "efile.settings_staging" - # Keep existing code imports until the proxy filing_catalog API is deployed. - FILING_CODE_SYNC_MODE = "legacy" + # Long-running workers' progress reaches fly logs as it happens. + PYTHONUNBUFFERED = "1" + # Copy filing codes straight from the EFSP proxy's codes database, read + # only. Set the connection with: fly secrets set EFSP_CODES_DATABASE_URL=... + FILING_CODE_SYNC_MODE = "database" + # Staging uses the test EFSP, which updates its codes from Tyler daily at + # 19:35 Eastern; copy them two hours later. (Production EFSP: 02:13, so 04:30.) + FILING_CODE_SYNC_TIME = "21:30" + FILING_CODE_SYNC_TIMEZONE = "America/New_York" [deploy] # Run database migrations before each release @@ -21,7 +28,8 @@ primary_region = 'lax' [processes] app = "uv run gunicorn efile.asgi:application -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000 --workers 2 --timeout 60 --access-logfile - --error-logfile - --log-level debug" extraction_worker = "uv run python manage.py process_document_extractions" - code_index_worker = "uv run python manage.py refresh_filing_code_index --interval 86400" + # Daily copy + index at FILING_CODE_SYNC_TIME, plus staff-requested resyncs and rebuilds. + code_index_worker = "uv run python manage.py refresh_filing_code_index --daily --retry-interval 900" [http_service] internal_port = 8000 diff --git a/run_all.sh b/run_all.sh index 45f6bd7d..93effc8f 100755 --- a/run_all.sh +++ b/run_all.sh @@ -299,7 +299,7 @@ fi if [ "$START_CODE_INDEX_WORKER" = true ]; then log_info "Starting filing code index worker in background..." - CODE_INDEX_ARGS=(--interval 86400 --verbosity 2) + CODE_INDEX_ARGS=(--daily --retry-interval 900 --verbosity 2) if [ "$LEGACY_CODE_CRAWL" = true ]; then log_warn "Legacy code crawling is enabled: every refresh downloads and rebuilds the full catalog." CODE_INDEX_ARGS+=(--legacy-crawl)