From 0f34a451fcfce3cc144ad91cdcfc49b1339a4f8a Mon Sep 17 00:00:00 2001 From: lasta Date: Mon, 11 May 2026 22:55:01 +0900 Subject: [PATCH] test --- PROCESSING_REPORT_2026-05-11.md | 88 ++++++ PROCESS_OWNER_ONLY_2026-05-11.md | 81 ++++++ RESPONSIBILITY_REFACTOR_REPORT_2026-05-11.md | 263 ++++++++++++++++++ crawler_platform.db | Bin 335872 -> 335872 bytes crawler_platform/app/api/routes.py | 42 +++ .../app/core/database/repository.py | 24 ++ .../app/core/extractor/ai_provider.py | 132 +++++++-- crawler_platform/app/web/static/app.js | 28 ++ crawler_platform/app/web/static/index.html | 1 + crawler_platform/app/web/static/styles.css | 4 + 10 files changed, 634 insertions(+), 29 deletions(-) create mode 100644 PROCESSING_REPORT_2026-05-11.md create mode 100644 PROCESS_OWNER_ONLY_2026-05-11.md create mode 100644 RESPONSIBILITY_REFACTOR_REPORT_2026-05-11.md diff --git a/PROCESSING_REPORT_2026-05-11.md b/PROCESSING_REPORT_2026-05-11.md new file mode 100644 index 0000000..026e323 --- /dev/null +++ b/PROCESSING_REPORT_2026-05-11.md @@ -0,0 +1,88 @@ +# 크롤링 처리 흐름 및 DB 미적재 이슈 리포트 + +작성일: 2026-05-11 +대상 프로젝트: C:\Users\lasta\MyProject\AI +테스트 사이트: https://the912.co.kr/ + +## 1) 결론 요약 +- 현재 구조는 `연결 테스트`와 `실제 크롤링/추출/저장`이 분리되어 있다. +- 연결 테스트(`GET /v1/models`)는 모델 목록 확인만 하므로 DB에 아무것도 저장되지 않는다. +- 사이트 크롤링 시에도 모든 페이지를 저장하지 않는다. 기본적으로 `product/brand/review` 타입만 추출/저장 대상이다. +- `listing`으로 분류된 페이지는 `discovered`로 끝나며 엔티티/클레임이 저장되지 않는다. +- LM Studio 응답 지연/타임아웃, 무효 JSON 응답, 중복 해시(upsert) 조건 때문에 “동작은 하는데 DB가 거의 안 쌓여 보이는” 현상이 발생한다. + +## 2) 처음 연결 시 처리 주체 +### 2-1. UI +- 프론트에서 Analyzer 테스트 버튼 실행 시 `/extractors/models` 호출. +- 파일: `crawler_platform/app/web/static/app.js` + +### 2-2. API +- 백엔드는 provider별 모델 목록만 조회해서 반환. +- LM Studio는 OpenAI 호환 엔드포인트의 `/v1/models`만 호출됨. +- 파일: `crawler_platform/app/api/routes.py` + +### 2-3. DB +- 이 단계는 크롤링/추출/저장이 아니므로 DB 적재 없음. + +## 3) 실제 처리(크롤링) 흐름 +### 3-1. 요청 진입 +- `/crawl-site` 요청으로 `SiteCrawler` 인스턴스 생성 후 동기 처리. +- 파일: `crawler_platform/app/api/routes.py` + +### 3-2. 페이지 처리 파이프라인 +- queue 기반으로 URL 순회 +- robots 검사 +- fetch(requests/playwright) +- parse(clean_html) +- classify_page(product/review/brand/listing) +- 파일: `crawler_platform/app/core/crawler/site_crawler.py` + +### 3-3. 추출기 선택 주체 +- provider가 `lm_studio/openai/ollama`면 `LLMJsonExtractor` 사용 +- 그 외 domain 기반 rule-based 사용 +- 파일: `crawler_platform/app/core/extractor/factory.py` + +### 3-4. 저장 주체 +- `KnowledgeRepository.save_extraction_bundle()`에서 entities/claims/evidence/extraction_logs 저장 +- page/entity/claim은 upsert/dedupe 규칙이 있어 신규 건수가 작을 수 있음 +- 파일: `crawler_platform/app/core/database/repository.py` + +## 4) “DB가 안 쌓이는 것처럼 보이는” 주요 원인 +1. 분석 대상 제한 +- 기본 분석 대상이 `product/brand/review`로 고정되어 있음. +- listing/네비게이션/정책 페이지는 저장 대상에서 제외됨. + +2. 타임아웃/클라이언트 disconnect +- 로그에 `read timeout=300` 및 `Client disconnected` 패턴 확인. +- 모델 생성이 느리면 API 클라이언트가 먼저 끊기고, 해당 건은 실패/부분 처리될 수 있음. + +3. AI 응답 품질 문제 +- 일부 응답은 JSON 파손/무효 엔티티/무효 클레임으로 실질 저장 0건 발생. + +4. 중복 제거 정책 +- 동일 canonical entity, 동일 claim_hash는 업데이트로 처리되어 "신규 카운트"가 늘지 않음. + +## 5) 적용된 안정화(현재 코드 기준) +`crawler_platform/app/core/extractor/ai_provider.py`에 다음이 반영됨: +- LM Studio 기본 타임아웃을 300s -> 120s로 조정 +- 2단계 추출 시도(primary -> compact_retry) +- AI 결과 무효/오류 시 rule-based fallback 수행 +- fallback 결과도 provider=`lm_studio`로 extraction_log에 남겨 추적 가능 +- 프롬프트 노이즈 감소 및 입력 길이/출력 토큰 제한 + +## 6) 2건 timeout + 1건 no-usable 오류의 해석 +- `read timeout=300`: 모델 응답이 늦어 클라이언트가 먼저 끊긴 건 +- `AI returned no usable entities or claims`: 모델이 응답은 했지만 저장 가능한 구조를 만들지 못한 건 +- 현재 구조에서는 fallback으로 정상 저장 가능하도록 보완되어야 하며, 해당 보완이 반영되어 있음 + +## 7) 운영 체크포인트 +1. 처리량 확인은 `visited_count`가 아니라 `analyzed_count` 기준으로 본다. +2. 저장 확인은 entities/claims 증가 + extraction_logs(raw_output.extraction_mode)로 함께 본다. +3. listing 비중이 높은 사이트는 page_type 분류 규칙 또는 analyze_page_types 정책 재조정이 필요하다. +4. 동시 실행 테스트 시 SQLite lock 가능성이 있어 순차 테스트를 권장한다. + +## 8) 다음 개선 권장 +- 분류 규칙(classify_page) 한국어 토큰 정비(깨진 인코딩 토큰 정리 포함) +- 페이지 유형별 프롬프트 분기(상품/브랜드/리뷰) +- crawl_jobs 대시보드에 `failed/discovered/completed` 원인별 집계 표시 +- timeout, fallback, no-usable 건수의 일별 지표화 diff --git a/PROCESS_OWNER_ONLY_2026-05-11.md b/PROCESS_OWNER_ONLY_2026-05-11.md new file mode 100644 index 0000000..9ea4532 --- /dev/null +++ b/PROCESS_OWNER_ONLY_2026-05-11.md @@ -0,0 +1,81 @@ +# 처리 주체 정리 문서 + +작성일: 2026-05-11 +범위: "처리 주체"와 "연결 직후 처리 흐름"만 정리 + +## 1) 한 줄 요약 +- 이 시스템은 **FastAPI 서버 내부에서 동기 처리**되며, 주체는 `API 라우트 -> SiteCrawler/Pipeline -> Extractor -> Repository(DB)` 순서다. +- LM Studio(Qwen)는 **외부 AI 추론 주체**이고, 크롤링 제어/저장은 백엔드가 담당한다. + +## 2) 주체별 역할 + +### A. 프론트(UI) 주체 +- 사용자 입력(config/source/url/provider/model/base_url) 수집 +- API 호출 시작(`/extractors/models`, `/crawl`, `/crawl-site`) +- 파일: `crawler_platform/app/web/static/app.js` + +### B. API 라우트 주체(FastAPI) +- 요청 검증 및 처리 경로 분기 +- `/crawl-site`에서 `SiteCrawler` 생성 및 실행 +- `/crawl`에서 `CrawlPipeline` 생성 및 실행 +- 파일: `crawler_platform/app/api/routes.py` + +### C. 크롤링 실행 주체 +- `SiteCrawler`: 사이트 단위(queue 기반), 링크 확장, 페이지 분류, 분석 여부 판단 +- `CrawlPipeline`: 단일 URL 단위 처리 +- 파일: + - `crawler_platform/app/core/crawler/site_crawler.py` + - `crawler_platform/app/core/crawler/pipeline.py` + +### D. 수집(Fetch) 주체 +- `RequestsFetcher` 또는 `PlaywrightFetcher` +- robots.txt 검사: `RobotsPolicy` +- 파일: `crawler_platform/app/core/crawler/fetchers.py` + +### E. 파싱 주체 +- HTML 정리/텍스트 추출: `GenericProductParser -> clean_html` +- 파일: + - `crawler_platform/app/core/crawler/plugins.py` + - `crawler_platform/app/core/crawler/html_cleaner.py` + +### F. 추출(엔티티/클레임 생성) 주체 +- provider가 `lm_studio/openai/ollama`면 `LLMJsonExtractor` +- 아니면 domain 기반 rule-based extractor +- 파일: + - `crawler_platform/app/core/extractor/factory.py` + - `crawler_platform/app/core/extractor/ai_provider.py` + - `crawler_platform/app/domains/perfume/extractor.py` + +### G. 저장(DB) 주체 +- `KnowledgeRepository`가 pages/entities/claims/evidence/extraction_logs 저장 +- 중복은 upsert/hash로 병합됨 +- 파일: `crawler_platform/app/core/database/repository.py` + +### H. DB 세션/트랜잭션 주체 +- `session_scope`에서 commit/rollback 책임 +- 파일: `crawler_platform/app/core/database/session.py` + +## 3) "처음 연결" 시 처리 주체 + +### 3-1. Analyzer 연결 테스트 +- UI -> `/extractors/models` +- 백엔드 -> provider 모델 목록 조회 (`/v1/models`) +- 이 단계 주체: **API + 모델 목록 조회 함수** +- 이 단계에서 **크롤링/추출/DB 저장은 수행되지 않음** + +관련 파일: +- `crawler_platform/app/web/static/app.js` +- `crawler_platform/app/api/routes.py` +- `crawler_platform/app/core/extractor/ai_provider.py` + +## 4) "실제 처리" 시작 시 주체 체인 +- UI `/crawl-site` 호출 +- API 라우트가 `SiteCrawler` 실행 +- Fetcher가 페이지 수집, Parser가 텍스트화 +- Extractor(LLM 또는 룰기반)가 엔티티/클레임 생성 +- Repository가 DB 저장 + +즉 최종 책임: +- **제어 책임**: FastAPI + SiteCrawler +- **AI 생성 책임**: LM Studio(Qwen) 또는 OpenAI/Ollama +- **저장 책임**: KnowledgeRepository diff --git a/RESPONSIBILITY_REFACTOR_REPORT_2026-05-11.md b/RESPONSIBILITY_REFACTOR_REPORT_2026-05-11.md new file mode 100644 index 0000000..a5ea458 --- /dev/null +++ b/RESPONSIBILITY_REFACTOR_REPORT_2026-05-11.md @@ -0,0 +1,263 @@ +# crawler_platform 책임 분리 리포트 (AI=Semantic Extractor) + +작성일: 2026-05-11 +대상: `C:\Users\lasta\MyProject\AI\crawler_platform` + +## 1. 결론 요약 + +현재 구조는 동작은 하지만, **제어 책임이 API Route / SiteCrawler / CrawlPipeline / Repository에 분산**되어 있습니다. +특히 `SiteCrawler`와 `CrawlPipeline`이 탐색, 분석 판단, AI 호출, 저장까지 동시에 수행하고 있어 확장성과 테스트 경계가 약합니다. + +핵심 정리: + +- AI(`LLMJsonExtractor`)는 현재 DB/크롤 흐름을 직접 알지 않으며, 대체로 “의미 추출기” 역할을 수행 중. +- 하지만 시스템 전체 orchestration 주체가 부재하여 Route/Crawler/Pipeline에 제어가 분산됨. +- Repository는 저장소를 넘어 일부 정책(신뢰도 결합/병합 전략)을 포함. + +--- + +## 2. 구성요소별 실제 책임 진단 + +## API Route (`app/api/routes.py`) + +현재 책임: + +- 요청/응답 처리 외에 다음까지 수행 +- config 로딩 및 의존 객체 생성 (`CrawlPipeline`, `SiteCrawler`) + - 근거: 149-195 +- crawl 파라미터 정책 적용(`max_depth/max_pages` 보정) + - 근거: 188-191 +- discovery 흐름 직접 수행(robots/fetch/discover) + - 근거: 197-212 +- claim confidence 갱신 정책(0~1 clamp) + - 근거: 274-284 +- entity merge 비즈니스 로직 직접 수행(Claim/Relation 재매핑) + - 근거: 286-308 +- 추천 태그 집계 규칙 직접 수행 + - 근거: 310-343 + +판단: + +- Route가 단순 진입점을 넘어 **서비스/도메인 로직 조립 및 정책 수행자** 역할까지 맡음. +- “얇은 Route + Service 호출” 원칙과 불일치. + +--- + +## SiteCrawler (`app/core/crawler/site_crawler.py`) + +현재 책임: + +- 사이트 탐색(queue/depth/visited/discover) + - 근거: 70-117 +- same-domain 필터, robots 차단 판단 + - 근거: 89-100 +- 페이지 유형 분류 및 분석 여부 판단 + - 근거: 105, 119-120, 201-224 +- AI/Extractor 호출 + - 근거: 120 +- 저장 호출(page/claim/evidence/log) + - 근거: 121-135 +- crawl_jobs 상태 생성/완료 처리 + - 근거: 83, 172-188 + +판단: + +- `SiteCrawler`가 “웹 탐색기”를 넘어 **페이지 처리기 + 저장 오케스트레이터**까지 수행. +- 요청하신 기준(탐색 전용) 대비 책임 과다. + +분리 후보: + +- `classify_page`, `analyze_page_types` 판단 로직 +- `extractor.extract(...)` 호출 +- `repository.upsert_page/save_extraction_bundle(...)` 저장 호출 +- `_create_job/_finish_job` 실행 추적 + +--- + +## CrawlPipeline (`app/core/crawler/pipeline.py`) + +현재 책임: + +- robots 정책 판단 + - 근거: 34-35 +- fetch + parse + extract + - 근거: 37-41 +- 프로젝트/소스 동기화 및 페이지/추출 결과 저장 + - 근거: 43-55 + +판단: + +- 단일 URL 처리기 역할을 하면서도 저장 정책까지 포함. +- 이름은 Pipeline이지만 사실상 **PageProcessor + 저장 orchestration**을 동시에 수행. + +분리 후보: + +- `crawl_url`를 `PageProcessor.process(url)`와 `CrawlService.persist(processed_page)`로 분리 +- robots 허용/재시도/저장 여부 판단은 Service 계층으로 이동 + +--- + +## Extractor (`app/core/extractor/ai_provider.py`, `factory.py`) + +현재 책임: + +- LLM 호출 및 JSON 파싱/복구 +- ontology predicate normalize +- 실패 시 rule-based fallback + +주요 근거: + +- AI 추출 핵심: 42-107, 148-247 +- ontology normalize: 75-78 +- fallback: 109-131 +- provider 선택(facade): `factory.py` 11-17 + +판단: + +- 크롤링 큐, 링크 탐색, DB 저장을 직접 알지 않음(좋음). +- 다만 `LLMJsonExtractor` 내부 fallback은 “추출 품질 보완” 범주로는 허용 가능하나, 책임을 더 엄격히 분리하려면 fallback도 외부 orchestration(Service)로 이동 가능. + +요약: + +- **치명적 위반 없음**(crawl/storage/pipeline orchestration은 알지 않음). + +--- + +## Repository (`app/core/database/repository.py`) + +현재 책임: + +- pages/entities/claims/evidence/extraction_logs 저장 및 upsert +- claim hash 기반 dedup/merge +- relation upsert 및 support_count 증가 +- confidence 결합 규칙(extraction + source trust) + +주요 근거: + +- 저장/병합 중심: 140-226, 228-306 +- 신뢰도 결합 규칙: 163, 322-323 +- claim hash 전략: 164-176, 326-342 + +판단: + +- 저장 인터페이스 역할은 수행하지만, **정책성 로직(신뢰도 결합 비율 0.7/0.3, max merge, relation support 전략)**이 포함됨. +- “Repository는 저장소” 원칙을 엄격히 적용하면, 정책 계산은 Service(또는 Domain Policy)로 이동하는 것이 바람직. + +분리 후보: + +- `combine_confidence` +- claim update 시 `max(confidence)` 전략 +- relation `support_count` 증가 규칙 + +--- + +## session_scope (`app/core/database/session.py`) + +현재 책임: + +- commit/rollback/close 트랜잭션 경계 + +근거: 40-51 + +판단: + +- 요청하신 기준과 일치. 변경 우선순위 낮음. + +--- + +## 3. 현재 가장 큰 책임 혼재 지점 + +1. `SiteCrawler`가 탐색기 + 처리기 + 저장 오케스트레이터를 모두 수행 +2. `CrawlPipeline`이 처리기 + 저장기를 동시에 수행 +3. `API Route`가 서비스 조립/정책/집계/병합 로직을 직접 수행 +4. `Repository`가 저장소를 넘어 정책 일부까지 포함 + +--- + +## 4. 목표 아키텍처 제안 + +권장 호출 구조: + +`FastAPI Route -> CrawlService -> SiteCrawler -> PageProcessor -> Fetcher -> Parser -> Extractor -> Repository` + +역할 재정의: + +- Route: request 검증, service 호출, response 변환 +- CrawlService: 전체 orchestration/정책 판단/재시도/저장 여부 결정 +- SiteCrawler: 링크 탐색(queue/depth/domain/link discovery)만 수행 +- PageProcessor: 단일 URL의 fetch/parse/extract만 수행 +- Extractor: 텍스트 -> 구조화 JSON/Entity/Claim 변환만 수행 +- Repository: 저장/upsert 인터페이스만 수행 (정책 계산 제외) + +--- + +## 5. 리팩터링 설계(코드 대규모 변경 전) + +## Phase 0: 인터페이스 고정 + +- `PageProcessorResult` DTO 정의 + - `url/final_url/status_code/title/clean_text/page_type/entities/claims/raw_output/errors` +- `CrawlDecisionPolicy`(분석 여부/저장 여부 판단) 초안 분리 + +## Phase 1: Service 계층 도입 + +- `app/core/services/crawl_service.py` 신설 +- Route는 `CrawlService.crawl_url(...)`, `CrawlService.crawl_site(...)`만 호출 +- 기존 로직은 내부적으로 재사용하되 외부 인터페이스 먼저 고정 + +## Phase 2: SiteCrawler 축소 + +- `SiteCrawler` 반환을 “발견된 URL 작업 목록” 중심으로 전환 +- 페이지 분류/분석 여부/AI 호출/저장은 `CrawlService`로 이동 + +## Phase 3: CrawlPipeline -> PageProcessor 전환 + +- `CrawlPipeline.crawl_url`를 `PageProcessor.process`로 대체 +- PageProcessor는 fetch/parse/extract까지만 수행, DB 접근 제거 + +## Phase 4: Repository 정책 분리 + +- `combine_confidence`, merge rule을 `app/core/services/policies/*.py`로 이동 +- Repository는 저장/조회/upsert만 수행 + +## Phase 5: Route 슬림화 + +- `/crawl`, `/crawl-site`, `/discover`, `/entities/merge`, `/claims/{id}/confidence`를 Service 호출형으로 변환 + +--- + +## 6. 안전한 단위 리팩터링 파일 목록과 변경 순서 + +1) `app/core/services/crawl_service.py` (신규) +2) `app/core/services/crawl_dto.py` (신규) +3) `app/core/services/policies.py` (신규; confidence/merge 정책) +4) `app/core/crawler/pipeline.py` (PageProcessor 역할로 축소 또는 `page_processor.py`로 분리) +5) `app/core/crawler/site_crawler.py` (탐색 전용으로 축소) +6) `app/core/database/repository.py` (정책 제거, 저장 전용화) +7) `app/api/routes.py` (Service 호출만 남기기) +8) `app/cli/main.py` (Route와 동일 Service 재사용) +9) `tests/` (서비스 단위/계층 경계 테스트 추가) + +--- + +## 7. 테스트 전략(리팩터링 안전장치) + +- 계약 테스트: `Extractor` 입력/출력 계약 유지 +- 단위 테스트: + - `SiteCrawler`: URL discovery/queue/depth/domain 필터만 검증 + - `PageProcessor`: fetch/parse/extract 파이프만 검증(저장 없음) + - `CrawlService`: 분석 여부 판단/저장 호출/재시도 정책 검증 + - `Repository`: pure upsert/조회만 검증 +- 회귀 테스트: + - `/crawl`, `/crawl-site` API 응답 필드 변화 없음 + - claim/entity 수 및 dedup 결과 일관성 확인 + +--- + +## 8. 즉시 적용 가능한 최소 원칙 + +- AI는 `clean_text -> structured data`만 담당 +- “저장 여부, 재시도, 정책 판단”은 Service가 담당 +- Repository에서 정책 계산 로직 분리 +- Route에서 SQL/병합 규칙 직접 처리 제거 + diff --git a/crawler_platform.db b/crawler_platform.db index 033f4c27adf8e33abffd03c1228ac5fcbf9fd1e7..79411792986d989d8ce64951818a8453f9d188b1 100644 GIT binary patch delta 27816 zcmeHw3z!tunP_)cKc-)GW|$d2Z}hL7-dz~$FjSUi`7-tNSFsZGZ+cM zqKDCof)RGaM(^-2vWi(ufB^@MNlfi)f zHq`YkYHM3%cBeLVm;-D7)QW!>ZP~h|d84+@T)R>0?KQhQn-?dVH+FX!oAlnb?dyAb z*LSXKUYN-IgsC0`1Y=z-X-(eyL7D`pUzvD*x1$G zyM*O7g zsInr@l()>uH?e&RjdAMeD8cEsXP5!_ou@b{D#r?yal%bf9~Z|sty@=u(~EyvGyuQz z6(>m&xhgn8li1f1!#L@B2~OcNmkhw~e8q|5B&7;Y94B*qmq#&9Jv}8jJ@h_50KfAT zCs|SWD!hlAq&_KvaVpgjnf(W;R&qFBv647t3|1EqEg?gb*taSi2dvUmnkpHq-ZPgD z!0&v;idV*B^;sJ$Xp+(!Yw%djom-4kw(qOC{o6*4Z5ui9Wbyg=sorHqk@#{E`?QTB zG>PdO{D5LpeSKYDeOueIZmrW$Sq}cow663w`94N_#HouJzJ&#SpvdI_UrGR$RDl!H zH-zw*%$rx2UYEDAqx<@E2aYCkgZ;T@_LW?0*_!#mAE{{{he74V#UkG#F}x(Rr3mn% zm2cu#1tXsD^v1@zy0*5POs%J@^ZKq%vlD*6L1i|Y2Q#g-Ff3@vGTmR)B5V#ok2B}b zuT%2`S!VY1CRTK7>tH~2Gik=)GUZ<4p-n8ey#SZ$0$Z-NoG&n{%1Yo7>4Zt(3uQc# zxV5ySvBT$fq^N?hnF3IoF1ZAQN;+Ep7dKpgRbs`BZCBsecK!0J^md>_8KetKJ|K&- zQ0A|=ACN>r9(wUE+1rfb84o&!zJ-2D-xJRufBZ-Eb@V^eQ_zDbhptDrqwPa=iNA}- zFAIPs_7*v6G+F|;I`X%`$D!%PaBE%u7Pts>T!CA9{uWqRI(7xz>dD^%14Tzmx+m%# zMXm{AHJN`7l%o}%4zJF2Zx!p5ni`-k0xX5dB5ayoXOn7#_`)m?3Kz_sn zbO46W>kgeCx^23|zSP_45|UE`KYd_9Goq(@=>MRnqU-2u>EmcRZJ`&@v*>@Jx6pC) zd-NrA5D55o+|N@Ryx6%7Ac5-eVwW&Lv(!2-cBBIFR!a9`_ahLKsU9yj+JPv3gODN^ zxxx>Gz~*61xR#phEkV4Fn&&OWw}xu;5w z=oSz9C3*#h|M%zxbP7F*_M<)MZnPP7APwDuz76=aA}dPGbSa36&gr7_c+q*R=sa3< z9w|Bx7oFD>orfaSbZ7X2?(JzXUkHS$CVOc7`7>%D_qAyhLQfBcj{TpdbO7Yhi~a~l z&sFG;F!wl3S)ZZ60EbuNdu<+1-R-@-8+#VFw)Wm`0%2P8u9gkmty&V#4Q}u4XfM}K zwtC_ik>9d)SI}1rU5TDc({W$hK$oX4T&XwcHZd}EDz|fgV)Wr112??)R|sYmt}F_y zA`YE=Z)M=Zg)qy(v!33~8%;dr={E0JZ+;$TI?aZ;vG;bk#cUm#{@yPGjR5*mUj&YR z)Z>9s`Zan2^}x93$m0Fs_1;QQwCY-~xvr~wb8GHUf9}M|MY-+i(ZAo(DhU1y{o$3B z-zm_6n(hya6(90Ji|itv`5p33iTUZbJm~9a6KbX30y26<&%cOr3}Ro3_;;UA!(*~k(HBv(u(cEz4cYv2HyEr{pGWRS9;L@KpW7-^gHy|=p;Qg z{`2^XI2}6`*(6l*$bnsstnV@!0_vvy1UIzeKTf?l{c<#-n#USuQq() zKd%02DRa8>R9@l)X_nn@6ZD(JzDhH2Ve^>xtda>XyvV0LGr{n9!;R@n;MGp8!)#_R zVH0aMX(rFC*ZUwd;^k!F&W$XV zs3?laOH=c7iJygAiphfCcttvbrHpkJvfV!x|IiQ6vWDGxd3T#Kubxcu(Fu#i7USP+RP*T%t3W9=Zj% zV&rvB#cZO9m>z7I*a*zJQmHBD4h%E9x-L(RXgM|bYTRLch%Qk;TQgE^o*L2dQiJ7H zB^^QzIn+>Fw4y4pTwS7z2AdZbE{RnMhzlkSXcYKgiLNQ)6=94@8ABWZgr`3c6p|9K)9Fy*v?q23Nm;OmPQs8nUx}Ws)WZ&6?l(HH-;UmShs}Q z!WG%Wi?Fa@?U4i>GKMdbDNHY0=K>R9_9;+`WvcLsRfzEdMv^Sm$eHw-7 zx9KzVYxKX;Kc>GyAEuAf!xtR$Pw@{W5?4kt2iJOomhEiKjs?6;VXy5W&58!Rjc|d% zoM|`M-p35*g7Ct0`zhiBOtTwoW8CCikf)~FPZ68gXgAmanY_n2tKSoT&6uZUN z-F40d>8#d%ifB+|H`q#yb}qXg2bo-=Ig(R&*O$jxK@`%fUS1E3}0tazsDP#%BcNS!6zo2PNM) zBi}gPZJd^GY|1xIbsHP=jScz6dbe>(zOgQBxgV`{TT#B1&c7OW8)Ny#XudJxHiq-J zYTR2P_ax|^1l$upIq?B8bC!1+5EIYC@touxlm!yLihcq_d=dRKkn!sP-)^e%{auMT8t}y8EgtkeV4*eiuVE5) zC*4Lz;y;WJ#?5%kaN>C{9k;|7)P|zUF%tfr$hdwZ!P`e=$_kqO4W`%g_2lE zAju*zndCKkoUgHp%80}slGozoHS0q^$0(po#c&?V*!VujNUSW0#8{EfMJvBnKF5N% zD-SUTVywt}k;?CtIZ_hC2)rZ`vqWACkLxv2wZKPUL}G!+do|;HPh=V8a$y6;xnhk$fy%{INOYYo#A^-;xwX&V27B z?`5-M=mM+~e-6={k98rb-FgYih&`&g>pJ+lq(S*UwZ$2P|coysXchTG6 z;=B%g@Cvv&FMy--JbLz2!uJTe4tE0PAC5HXJWe`~kW@Ft*!+Hq*bOQ}G|g`PgpIykCr2=|nIFam@`Ec-z|AJaa2X6U{r! z&fZpBjX}bsOL25@ZD-dKfiu?k=v{Z1-KJrbGQF!@B~>cROXOQbi1#s8dGUqZBH-!^ zW=lR0%5F`6Du7{JKj$nkR*iV-T^$?S&E7E(+CH--F)}NcrPf?`S`^f>1XBTco-ctA z`7wkm&jw*3zFGv5RZ@Lot217PR9ShljJW5#3^}jz7AL_5X&$u%q%eg+T5`65ohw_* zst%#6TOqMj{z-eTz;R;|#V!I8L}p8X@%u0^;u!i^g+P1}RZ55g`jZ!1cu|&^GC1-r zEL+B8F6!m5yK6HA$$b6O+;$}`Gghx9GSFsxDn z2ljx1=rs?DI-zLvCVCA7@+I39iUhH*6u<`(nXVd3YiHpc zh!4FBN6#|QUGIk9sChj!GjJnyn>RB&>v`W6ueE(eWGW1W7tBlJ^mIJbH*@uLko@#I zf59YlZfb7_kze1tSu#?FX(k0RB|{h<AqG9q7 zc-MIj#NK2%S$18op7p(E0=xK@kyziEG9k!qKqjuMBjFTzC2%_A(nQ-eiImpfp49XW zixZPj@712U?IoXhaUlwzf{2%~l`Z5VZppm-M{4@yq{+8;g`IKm{LM@}4)fh{z=n8L zQGUR}ft$0$ZQ6mh-C8>*iMj?cW`<2Fph9(xG4&)bBq492%Q|anyu{0bsA=F5N`@gT zj4FcBxmYYn^fMn=8AZO0F~E6yd@61W6TMBBTAmf8kA&FeaI`FZrbT zMMI*DQGf1`K#MSmr3>3v)Krm;f^%fchJ}SgG&w<#ARw#hqF?~oAx{NyD*?m+@?8vM zz63=!O_LSiQWPLBrGX^LvYBFd$bXq*gk$`&ar0yd$C!Zt+pU%=G4s3Xu@qMS;ZNljHH8IoD5$jh3lYl6vWh6?6{kxe5h z@r-`n4+O8uRR#iL?~^$YeV$+gu@{J*670kndeYOe7H)I#E9l^^?)B@|cWUixNvsOzWo2$5gX6opWH2OUkG){XmId{v8(@D794r9+VQ4%D zX>yifmu4eNHa_;_h)ajTgk5r%$liY}xBuj5|FH~B1wM_*;7&VL9B`=Ml&~ta@HQFJ zU+~+b?Dzz?7E!?tp%lh8rKSV}^ce>QR0gD5l2E|}@p(oPG=rz#!_c$ZI>hfBDu`0-HFi$j*jjmudON((3f!G)B-3Xo@1^&~5%1l3SAxRFYV zTrw$%tYIXf-T-1Es>X;4FX{}aeZ$b0Bn!IUjWXmx|5WZ_V9!H?P;-&nzc0_VfKukz z$ceFI#PC98R)y(@pb(UXr@rX>h3_&z?8+%{xFUjG$i_&EC;AFn7`>LRjclS<$NOUc z8Qp_=;{OuAG)kdoV^7fsVsj$T#Zr+L`d7o;%RXPkN^y`nN$E1wQ5dqOB%y*LWdb`w zx=BxJfGuyTs+vkEm_gViIDNX1R7`{AO_Aeut#L&l3ck zp|hNlWK$aCEX0&*n1W%XR37dcYEo8B)q><2xQc?>VE3cT!2BtAfaNttF`!e^lnf1w zD98j0xk5;VKrgZeWg{Y|@=z^oKnaO%2r>^|OtRkYM&@(|@-4uQhOQf+b-;1eQ%MtY zM|BWP#IxX;V||eppaSFzWDCRZ|&LF;(CKh}y8|YZ^{jrF5XHqGJyaJaTX` zAn#?s(1 zLz60Kpto5lkrjayA_%uGnmldy1H%mNJ?4{CGHHOVl|@mPl1Wtpt))SY4-2YZW}vJ` z(?Ecdylyhdq$s6yfCmT|UlFh{^QfQ-BvvsPSa@JSm8AxAI~lxI(=byepX8Ik5~ji$ zNtuPp5)F7uG{IjLINk)>iF(WihnJ!71Q1bDJd-pTQw2ASNh+X{M2LFw3JZfQN>DTe z6-Yofkuf>R(o#TQ9b{Kj#HiiRL}xh>yPI)&;MQ4O@UY#_ME%MdyPL{hku`p|DCD3u zL2DkgyK#)4mA8H$)%XSMekN#u{=)D(r=Xk6=Ub^Oho61bcWo`zi1rObM&k@> zX+(E_gnZ5^j=IUt-}p&B#!vDwev*&zlYESyrOiLsE1`f`?NCLBWKu~rWx){;)e?mU$c?i_5M>il5%|u@M4G7?VWtgj_ zPSKVVKP8jZ#^PXVLvc%eam$qAmb&7W+Ts>e+(H+(#0xD~b{cMwn1E0*NK9JJ5+o*} z;JI#im^>XQd;&++3vuDs=TM23#>jF{^bOP+y_If?+)IBpzBBeg^ii}a{)6~c(MI(3 z*faEFu|<)WVm*=N^jpJRf|~kgFuJ6U@my`LV!1ZguBx1<$tAsv+v0-3wJKU%(#?bo zF6pJZy(Q+>`fz(ox|y)KCA}0`TgTX1AJN*9ZYFGONiW3IS`)Fgq?>awwFOISfvF|k zOxV(rUdCl;i|wpc7V8R=ZYFGJNiUVnY^jxnvajmVhmeiaM6E38rF@3z*jQ`aGt2<# zW`Z`>u&cKV^D039TqahT-d1WiHmtZ*VBs*&P$tUk`@q)@CUzID5c(DRKKdtg8a;)+ z4CN8KAmoixYPfvjd*~bJ1eD4TLea#l!`v0r-y+LTdAyE^#ix#i#ix#e#ix#a#ix#W z#ive+51)?7@C6}S?}1teoCM65I(WQ3KjjUR%6-Qlm^aP>k6~_=>k%LVE+QTQP2i=8 zM-V3f$B9P}BM`@kM-U|-LlqFf0_8vW=vHXJdAO)RBT!VJVP_8g1&<&ObIWOgNWeoM zj=um?%B_(==)VN-2>hG>HR>KHX8#;De2-39!P>q}I5X#6&8FkqFZHzfaRhO0W>F_K z#hmB@fGy2-24XD3N?`gagPJ!dADD&`3{Fz} zdU2}HMp^Z=Q3aHZb6KrWyjWoj%Ip}FnU4YE#W;xOWEEkG%A{|>nM5x;Cmm1oo;IHw zaa(KN+1st@y=vK@{5!t4v>|NBt)IoQ8Sm=E7;sr zod9u_^es3|_-Fspl;X8=9OU8{X83R?6u8Sn-%qcJKOCoGo1!P8pNe!xRzzlmcZF}O zd9`M!W>Jkd)F1lm;7@{21s4SV6u4_x+D&~XF!Qb^jI}q7i%L9gjsARSY;giFBeN2% ziP61>a{Et@?%JJw;xL5#ur$Dmk=%dVT^ibR^V5((Ru@c#C81t#J?XT;4^hC%&x()% zPkS0|5SEaq>x)6)2HrkX83tBe2n#f-0s)C*X+c8~5L2oHfkkU;y$yp*V5lpF;roAD zSdk9g*;k+Yg9}8YIuMX~;?vQn4aH3aif}O$c!gv|7=S53zN^3hL3M~sFTk4( zxG>aIgF#d!2!JjK7d;y)efE;#9YX|F;?nbLV7(zoGL>!uklQFBpX`iI<(ee<%9mW0De>^QsoNU0dR`V+*M zN!Nu4!EQ=V$Da@Sa0q?pQ4)hbaVU3Rru`t*VC7+Q0m$OE!=v||o|oPKT=u>P6WN_d zat{GegNf1mp2;2B3riYtA_hZ1FJyTH60C* zaqH^Agb4V`^ry8rVJB#SPq20^=v6zn1Gk;T3SgfdI_Xf#?j?~u_CNxj00`amOYZ3h zb59SJ4vd|Z0CNL0N!Jsn0T`nL!{d*XF~*uoj8UM`JW$^ANKTWG(WLSGsV9U|5cZT<*BFkI9;EFGhkJCQI_ zrR*nF%75Odn2-3tK2kkbF)DU>ag}lty3_R!@KOX^t@nyssfSKNk^2#tcnqS4pqTMK z)Q|2$cMeO>Q_E~58VM2&1c`d$$WI}Td>wJ*Yl$O|h$BxEM?OBrk&hIWTGtRqK1608 z6hR_Lkf4ejd3X8|$o%0Ysy2>**YMs=SHo}laKp>g+o;Cpuk}Qx`)k3A_xtCKb5-*| z+k3`@)-k^km>G{Xc_QA7haQYijm?bCC@M|DD>lCPNoCR}l}VpeCRN*|?32o*(h{*x zDw966eDdQd5&NVv>66N&Pb!nhCSADT?Ellsq!Hgqf1oxTx!FV0v8y6CL;P$-#M<83 z=$1IIE-i7!>zYWabzEDZIMs6>wQ#AmymF~(h4aT-;4HE5w~SHTz#YVxC$pS$`-+At zb+v_+W_Dd1DR4gLe6y2oKF{lFVYxiiUzX0=-GShtKAsdfcTGaYd-K-qUG=VLlvV#{ z+GYhBV4H@jTi-a+FwwfXvkHT6-N#n$FTpaAa<%CW0w)W$G~6;NgYb01@2$mwx*BhT zt+;Ngxi7fS|B~-#-iCp?+V^3x%#8+n1PtzcbZNkz9a2+$&Ole#LSwu&?q z)*u&Ij}NKI`OuP_G2(E3EsufvT_{z=9# z$H%gEtgxIOKWx)?M$=`@BgiA6j^WB+KGi77t2@NORzgLv?!CQuU5pW z2uERKiX!2d=<0y;UN|^doK|@tphja-=YL(%D#C#me-{f!=JFSPGd7Uj@*D?hVill@ zSXB-_=GiywBEVG<18A|uK%12L1}>}f1+Ml)=LfEa{M)4gYv)=>$HC4P9v<&1yrPxk~_4Gy!hm{ z(Vfr}lqo=Iz#&*xcM{(o-Fq5V3XZ0aW*>i!TxSkL;lf4PV-LdHiR{--+jR}NNshoW z`Hmjw{g6#9__l?2MTY8wNbxS)k?cKxpZ~WEB2cjaDCFNDFFeyfa%y)Z_vo>a<0rF2 z{fUti+j86QjV!Y7>ZyUJx{WxqK{<3$=z?>OV2cG+27A*I2_H>?m)$+M*%v?plC(F3G%Y;SvOyTD$ z?A)uaoU3wk6$SQs9(d)0i>#0Q7OvMT*_ztf2!Jefb@rxax88d*g&B9Hy&d+KR(ksQ zii{g6?0N!8DDQ7uwHE}Z?@sBy(4XjbzNOXqDMD7t=^tQhql4&v2S&l z9k9CB>}iSoa@%0;%R6D%6TpWs;J6CLxq*lOp;Nj2{fX=oyRrjM0S{+)9&Txl6kf;; zK8Nd4@Ow}9yYIm&xTl9 z>;wIY(J!6O?cFiD7Yc7c9PD1RPwdDZg_W>Jv%3!^pe!hnJFo+u%|5z6;=qc_gzkSZ z_sDT5M}fMPG%2(>IWlwzR-PWo-oFp`hr1XUv~VdInZ%o1oUe51X;|B3!qLNO=Q#i36A|obN#0=fkF83a6pMv z_HMYbFZakXQa}ach*{^EM{q@zwf*iqZvDrBSt6v;X%DEWg;-sUoO+5>7s3BQ1?C<; zjnT+Hwky|1inwy82Fa)n9EbWRrzwHk9(fXfF>^EmF9Mm|rqO+SMjzRgxCZ_u2^jU$ zxyP;j7{C?R+Sc3cP>@4_YT{B504?~jS84?NUdCwr4Ob}gPfQY)XtT@R`B<8tE5V=_h zgLhH%vIg===^_tTKXth(xF~al{T6&(b(Pn8eV`()3a*HV`?1({$#7RrlK=IgXmfA6m5c9P*LIRYmP?iI%93|skg*hJ6aO% zD`HJu?QMxP)XJfrMX7x@RE@~1)yO3UB4JN}+|`U+=lH#zU}$+2pu&o$OyR<2Bsv@C zwxbXZ*5Ii(E3RoW z<*GUtxr0N;3Y^g%Mwum91=Uqt>~xZ9H!A2|H2m~_zmS=T2`MUaI-}8P?cIrK+<8nF z>RHrt7BXGtIV@)j!tvOP!T2utC zO2lYDnjxfOs!#z}hznCp@zI8c_RcuRSajOM1=#$x&hKiLO*I*KQ>?4CsVyNnXon)+ z!JJx@LlGq$QJ?6%G1l7N4T~hYIe|Mp##qeT3PfIq>1$Cu&VnBWz- z0dK_j((}TQ=4>!z zdJ+bDDg!Nx0$qR`%tU5PwL(u3NLy)#C1Ak599RKPv#S(NQCZjiPu3Sx>y8TeD79`2 zZo?no{dg~a5wF3&rkDlzR{RKlgjzQl_p(u$cVIedrp=8??}8fB+FX1)Qy{bIwrs%4 zI+?4iW`rmVXD%x@jV++eh^!%H8a9+^LAC$rra~y|8LFeB_dLQwMeiQDdV>| z9U~NYXI$(b@y!Yw-#LR3x&wN)Z2a;XkSaO?KbCA(nmYhKEa1a9^~_FKsBLjO)`;jF zq=v{|3|x8D;y9#Usdgv2y1S|yn~EqwCHpYQBd0Ks6o9u2cst%s=cNftQ&Ca_WM>(u zCELotrIdO?z$d73&b^;v(y<93rwE~{nxmdhE8#SaWi#9?;Qz+WxJ3UzU#5q&6WRtk z!tI)UwxS9uPz&Z-qB?o(GBA$ZHyxCc^*12J;qm%B5euig${2m$bTFEeoQyP6rX&>< zOt(bE4&ph4#ws<3p1o_%_`g3#fFilJU2Z1&PPB$>T5)(hEaFO~O7{dF!F;h-ZO6YxLqG5ikx2=AkRFXAWZ zNcUlKbTLq?73M))K8qzTPabG-N8@5MsMOJ3P{@RS&DRtEjYi2~{2@Mo-=so!;BzDh zfUfYb1woZHG>L!77u6s)oAWdLSI+%D<@}@R6}bxK0T$L@lqJXjiF{TK3UaeXWPBge zi5u|KjUsoc{sTM8M*#eifM3F^@N8VBAJ#YN3-qw|wYF8eMXO1h!ab9ls~ zif7y>!=}4Hi0055E93StZj$Urc~v!snWsH_*YGUCpvPOI4q?fywf->A`79jNYBo&Q z{HE?$eM{n^_RjRC!c58^mG-|tfDRF4(V=qN2TfOazurtgKh-|KL!~TjdwM^r`S|0O zJEz>89Nq#S6(3>&q>fIr)y29JeE+KX^p~HloY%Se%#1XydMRgln)8aLc)W%4O5e}x zWj67!<2KQCn$_OH#zf!G=V5%}z+5oCa+4AM5F>eM5135djYiioniw|VJJpn>ow zJQY{qn0_MV-U|T6X;-lo%ghe|_X5S51?Bcy)gnr)1~kE)zII9urHlk1Ynd!giYfzu zYMWjWIhSHabIibGOD>8l=eVqCL}_tZGX!%S<`9E8a_k_;r?&k~paEWmAEK6hiJ!-l z@ge*MZo*gMWXkpr9V*#W3SGU54o7BbYtdlYpr$g0qZyRZQgXs_ z1`le882g|ma0sZuk?PI=k@Y5;xaj+J9bUWKm z>^AnbS=rah_a&8YQwra0EOfU4K;}D@M2?-}=9_tmPC;hJ*^mDR@1YC){8Mi6HyG2c zO=oPH#=qp{P2fC8Q(em95Km%Ra0mi!rkh&~Pe|qfJ9X?U%Rn?56~<<==q=sz>6x&) zi(kFwcBHRPC#%GBuA>*9bR^PVcS@YF^GQ-lG8nILC0jam_ zk^>aqD(U&uIsi(9xy4D%k{o+&5j8XE6g5)4OAOb>BY}7*mI%i@i9jgO7>|2mfnXpO ziG<@G8iT=rLGNUtKp-46B1WUp5I4fUP%IP*L=ushFCHMjejSyNIU$6}<)u<=T;@v1 z_UEB9lTGPQ{<4sKS}IlcE^VM@Fa<{fR}oXNd_ciLf709Lx3nbMx|_Ne2K@e*-_z(1 z1>y-`B3K{t)+ZtfPq@K|cw(Ml#ODwC;{IU0-ybo80e@rI@CL&5L7&eTPZ)uCyyj!k ze#y4$1FbZp%~^2o{8(eKZat=^iqdMg@7j2F-f&N@CLmnEg)M`VzLMdeJO!U~<^q+qf+Q|El%gASmX?XdJLN1$y9_I`L6U~y!@C=MX zC;De7kDPlonxD!)36F?g7Gfgq5^xvZroD_O*c&KbAjd|5+%#~60Xb!{XZPXi-ArGPCB^gTcUYZ~xurGT0SP5}xi zX&}0q0+uwe8B#z_1OGOQ0#X{d=4uL{G;o28Y7wLWSx^lxOl>o?&;DX#-QrOz5ngwF3ILdTHIm%!}CNb}+hb}!I}=2$J8M$Y>jj2tF0|Lk9! zn1_t{#pfWGPQiQu&&S)feYo1*r9Yre<D*fm7Q}(CjZ}kUk_ba(3KIL`8eAD-3 zmw8`gt}o}AZRYuMuGwaeFXx!;0l$}>CW5)W>@eG=*Yoy|c6u)5tbN|_<_<{&nyDCb zdD(8Zna9gEv&|e{wwi6`@3LyPnY+u1*=F7@Tg*0dc3C#t%-7}gO`Ey8j7&W9bkbc7 z`3*$8+hG=xRJ8q&a<`YoPG&tCW8%khDfw-(OEy#T0Oyu!s>xOn;$MF*tMPxOnhgrER; z0O=ZQp6wY8aWRmK5fKEcXl}p!URwV3Fo!lC$Mk{f33}IlnCiS1-;?srfLCF5f+M$Y z0v;aK=_6hL^E+w_f&QSNKdAjfJEhIC|ITu~e6#cl`VcOoFDf^|)UpQHE2sK2)Gm@8 zEpRj;%}BS4DGVA>ZtW+g(HP?2wbJw31Udp?k>VaU|nR8fQn2WSvSLZAP0< zV`4{(WSK&K(SlZ-HVz|^cix3hoyNir9mzVGe9?%u0$*y*I`{}M)4~Qiwf{s~*Z|k0 zc5H^*bkbS_9c1|?V08@*dSj_dW;^#9O( zl==XkLyI@dQo)_D4)+!^FSix2uQi{2RTujzJh&`8xMUt&688?ogA1}Qde+Uv&V$Rw z^|JB{kjgJW3cmnZ_ytJj7a)mWfDjKZ2qp6M9_Y~W=z`YJzl-T#Z7OdsJdSHig*gJg zUcXm6s+G}m%^&SI*pY3cZMOApb+pVKG@NBC8oTm!3yX?uUOcF@6?Em6MoL ztRs8gKz5j{i!evnO4if&PK(#$^CZi{Z2kKghSDz#k@wz(Tgidd^kSK;3$lgYO!^K$ z#8Kk309n_D>PXEjWEX$xC&${*x57c3^Ux%+sR_wkgB%a-F}s}in8KVY zib<>i6_fUMR8CIml3kxO&ZKhX^#N)wd9NMin_c{4d6x>xPq!NK@P1gvltY$K*a#XG zV@xkPxeBTZy&wmJ$xBC@ZbVU5ODGfxB`ZgnZggrDR4hS;sT*m!(jcbHZMqV}^af}j z_9P9gCk5-t?CY=^ur$lJNq3_i@bD@JU>8kQEio^eeOG(fO}MX_J$kTeia2=5Y%l`B zhDbc_rOx@0{Oi4)ue>=Ir5rHd8v^1Dz=m#?2l!2I#vpx?V3LF!Db zLdZ(t?9H$)T?NZji-xOyGxj3R{v(Rt0|j+f#ZJ$Hm%fB-btB-&RbLaJuuHHll4c6K ztmW!!>TCT@$J3*hiz-#Ph}KD&!sZk2RlLA%mdCQy%_q1L*~!E6kT%-z8ximAixp;V zK>=rG58fBCq9m`ooq5%V5oBSNVC#{80$#9*pi)?Mw@2y~U6}qDpq&?M z5G&S@l{cV#3u`3{bz1uGG)YE=G={a73-*CQqfzsT{tR{@Z^RSgb6vp|S&nR!wIn^8 znflyxUX?X#kUuajf{#s0b=3lm7MhF8%bk&EG+q6hX8!uYTpY6unf@t6e{!*#gk(ua n!LW}e8R|_APtb2H7p+VibN2pHfeHp^FKa0!uK7bV(BA(CAj+-7 diff --git a/crawler_platform/app/api/routes.py b/crawler_platform/app/api/routes.py index b67b601..c4860de 100644 --- a/crawler_platform/app/api/routes.py +++ b/crawler_platform/app/api/routes.py @@ -56,6 +56,11 @@ class CreateProjectRequest(BaseModel): config_path: str +class ResetProjectRequest(BaseModel): + config_path: str + project_name: str | None = None + + class UpdateClaimConfidenceRequest(BaseModel): confidence: float reason: str | None = None @@ -99,6 +104,43 @@ def register_routes(app, database_url: str) -> None: project = KnowledgeRepository(session).upsert_project(config) return {"id": project.id, "name": project.name, "domain": project.domain} + @app.post("/projects/reset") + def reset_project(request: ResetProjectRequest): + config = load_project_config(request.config_path) + if request.project_name and request.project_name != config.project_name: + raise HTTPException( + status_code=400, + detail=( + f"Selected project '{request.project_name}' does not match " + f"config project '{config.project_name}'." + ), + ) + + with session_scope(database_url) as session: + repo = KnowledgeRepository(session) + project = session.scalar(select(models.Project).where(models.Project.name == config.project_name)) + if project is None: + project = repo.upsert_project(config) + return { + "ok": True, + "name": project.name, + "domain": project.domain, + "created": True, + "reset": False, + "deleted": {}, + } + + deleted = repo.reset_project_runtime_data(project.id) + project = repo.upsert_project(config) + return { + "ok": True, + "name": project.name, + "domain": project.domain, + "created": False, + "reset": True, + "deleted": deleted, + } + @app.get("/projects/{project_name}") def project_detail(project_name: str): with session_scope(database_url) as session: diff --git a/crawler_platform/app/core/database/repository.py b/crawler_platform/app/core/database/repository.py index 8e3f601..e7a6216 100644 --- a/crawler_platform/app/core/database/repository.py +++ b/crawler_platform/app/core/database/repository.py @@ -82,6 +82,30 @@ class KnowledgeRepository: raise KeyError(f"Source not found: {source_name}") return source + def reset_project_runtime_data(self, project_id: int) -> dict[str, int]: + delete_order = [ + models.FeedbackLog, + models.UserPreference, + models.UserProfile, + models.CrawlJob, + models.ExtractionLog, + models.Evidence, + models.Relation, + models.Claim, + models.Attribute, + models.Page, + models.Entity, + ] + deleted: dict[str, int] = {} + for table_model in delete_order: + count = ( + self.session.query(table_model) + .filter(table_model.project_id == project_id) + .delete(synchronize_session=False) + ) + deleted[table_model.__tablename__] = int(count or 0) + return deleted + def upsert_page( self, project_id: int, diff --git a/crawler_platform/app/core/extractor/ai_provider.py b/crawler_platform/app/core/extractor/ai_provider.py index dc933ff..60f8baf 100644 --- a/crawler_platform/app/core/extractor/ai_provider.py +++ b/crawler_platform/app/core/extractor/ai_provider.py @@ -29,6 +29,10 @@ class LLMJsonExtractor(AIExtractor): base_url: str | None = None, timeout_seconds: int = 300, ): + # Local LM Studio runs on consumer hardware; keep a shorter timeout so + # we can quickly fallback instead of stalling a crawl worker for 5+ min. + if provider == "lm_studio" and timeout_seconds == 300: + timeout_seconds = 120 self.domain = domain self.provider = provider self.model = model @@ -36,25 +40,18 @@ class LLMJsonExtractor(AIExtractor): self.timeout_seconds = timeout_seconds def extract(self, page_text: str, project_config: ProjectConfig) -> ExtractionBundle: - try: - raw = self.complete_json(page_text, project_config) - except Exception as exc: - return self._fallback_bundle(page_text, project_config, str(exc)) - bundle = ExtractionBundle( - entities=parse_entities(raw.get("entities", [])), - claims=parse_claims(raw.get("claims", [])), - extractor_name=self.name, - provider=self.provider, - raw_output={ - "provider": self.provider, - "model": self.model, - "entity_count": len(raw.get("entities", [])), - "claim_count": len(raw.get("claims", [])), - }, - ) - if not bundle.entities or not bundle.claims: - return self._fallback_bundle(page_text, project_config, "AI returned no usable entities or claims") - return self.normalize_to_ontology(bundle, project_config.ontology) + errors: list[str] = [] + for compact_mode in (False, True): + mode_name = "compact_retry" if compact_mode else "primary" + try: + raw = self.complete_json(page_text, project_config, compact=compact_mode) + bundle = self._bundle_from_raw(raw, mode_name) + if bundle.entities and bundle.claims: + return self.normalize_to_ontology(bundle, project_config.ontology) + errors.append(f"{mode_name}: AI returned no usable entities or claims") + except Exception as exc: + errors.append(f"{mode_name}: {exc}") + return self._fallback_bundle(page_text, project_config, " | ".join(errors)) def extract_entities(self, page_text: str, project_config: ProjectConfig) -> list[ExtractedEntity]: return self.extract(page_text, project_config).entities @@ -80,10 +77,22 @@ class LLMJsonExtractor(AIExtractor): claim.predicate = normalize_predicate(claim.predicate, ontology) return bundle - def complete_json(self, page_text: str, project_config: ProjectConfig) -> dict[str, Any]: - prompt = build_extraction_prompt(page_text, project_config) + def complete_json(self, page_text: str, project_config: ProjectConfig, compact: bool = False) -> dict[str, Any]: + if self.provider == "lm_studio": + char_limit = 1200 if compact else 2200 + max_tokens = 220 if compact else 420 + else: + char_limit = 2200 if compact else 4000 + max_tokens = 400 if compact else 800 + prompt = build_extraction_prompt(page_text, project_config, char_limit=char_limit) if self.provider == "openai": - return self._complete_openai_compatible(prompt, "OPENAI_API_KEY", "OPENAI_MODEL", self.base_url) + return self._complete_openai_compatible( + prompt, + "OPENAI_API_KEY", + "OPENAI_MODEL", + self.base_url, + max_tokens=max_tokens, + ) if self.provider == "lm_studio": return self._complete_openai_compatible( prompt, @@ -91,9 +100,10 @@ class LLMJsonExtractor(AIExtractor): "LM_STUDIO_MODEL", normalize_openai_chat_url(self.base_url or "http://localhost:1234/v1"), api_key_optional=True, + max_tokens=max_tokens, ) if self.provider == "ollama": - return self._complete_ollama(prompt) + return self._complete_ollama(prompt, max_tokens=max_tokens) raise ValueError(f"Unsupported AI extractor provider: {self.provider}") def _fallback_bundle(self, page_text: str, project_config: ProjectConfig, error: str) -> ExtractionBundle: @@ -104,13 +114,14 @@ class LLMJsonExtractor(AIExtractor): bundle = GenericRuleBasedExtractor().extract(page_text, project_config) bundle.extractor_name = f"{self.name}_with_rule_fallback" - bundle.provider = f"{self.provider}_fallback" + bundle.provider = self.provider bundle.raw_output = { **bundle.raw_output, "ai_provider": self.provider, "ai_model": self.model, - "ai_error": error, + "ai_warning": error, "fallback": "rule_based", + "extraction_mode": "fallback", } for entity in bundle.entities: entity.metadata["ai_fallback_reason"] = error @@ -119,6 +130,21 @@ class LLMJsonExtractor(AIExtractor): claim.confidence_reason = f"{claim.confidence_reason}; AI fallback: {error}" if claim.confidence_reason else error return bundle + def _bundle_from_raw(self, raw: dict[str, Any], mode: str) -> ExtractionBundle: + return ExtractionBundle( + entities=parse_entities(raw.get("entities", [])), + claims=parse_claims(raw.get("claims", [])), + extractor_name=self.name, + provider=self.provider, + raw_output={ + "provider": self.provider, + "model": self.model, + "entity_count": len(raw.get("entities", [])), + "claim_count": len(raw.get("claims", [])), + "extraction_mode": mode, + }, + ) + def _complete_openai_compatible( self, prompt: str, @@ -126,6 +152,7 @@ class LLMJsonExtractor(AIExtractor): model_env: str, endpoint: str | None, api_key_optional: bool = False, + max_tokens: int | None = None, ) -> dict[str, Any]: api_key = os.getenv(api_key_env) model = self.model or os.getenv(model_env) @@ -150,6 +177,7 @@ class LLMJsonExtractor(AIExtractor): ], "temperature": 0, "response_format": extraction_response_format(), + "max_tokens": max_tokens, }, timeout=self.timeout_seconds, ) @@ -159,7 +187,7 @@ class LLMJsonExtractor(AIExtractor): content = data["choices"][0]["message"]["content"] return parse_json_content(content, retry=lambda bad: self._repair_json_with_model(bad, endpoint, headers, model)) - def _complete_ollama(self, prompt: str) -> dict[str, Any]: + def _complete_ollama(self, prompt: str, max_tokens: int | None = None) -> dict[str, Any]: model = self.model or os.getenv("OLLAMA_MODEL") if not model: raise RuntimeError("Ollama model is required. Set UI model field or OLLAMA_MODEL.") @@ -174,6 +202,7 @@ class LLMJsonExtractor(AIExtractor): ], "stream": False, "format": "json", + "options": {"num_predict": max_tokens} if max_tokens else {}, }, timeout=self.timeout_seconds, ) @@ -208,6 +237,7 @@ class LLMJsonExtractor(AIExtractor): }, ], "temperature": 0, + "max_tokens": 300, }, timeout=self.timeout_seconds, ) @@ -216,8 +246,8 @@ class LLMJsonExtractor(AIExtractor): return parse_json_content(response.json()["choices"][0]["message"]["content"]) -def build_extraction_prompt(page_text: str, project_config: ProjectConfig) -> str: - clipped_text = page_text[:6000] +def build_extraction_prompt(page_text: str, project_config: ProjectConfig, char_limit: int = 4000) -> str: + clipped_text = prepare_page_text_for_prompt(page_text, char_limit) ontology = project_config.ontology or {} return f""" Project domain: {project_config.domain} @@ -259,14 +289,58 @@ Rules: - Use only ontology predicates when possible. - If object is a simple value like price, put it in object_value and leave object_name/object_type null. - If unsure, lower confidence instead of inventing. -- Extract at most 20 entities and 30 claims. +- Extract at most 10 entities and 15 claims. - For perfume, prioritize name, brand, top/middle/base notes, accords, mood, season, occasion, price, review keywords. +- Skip navigation, cart, coupon, pagination, login, and policy boilerplate unless it contains product facts. Page text: {clipped_text} """.strip() +def prepare_page_text_for_prompt(page_text: str, char_limit: int) -> str: + noisy_terms = { + "first page", + "previous page", + "next page", + "last page", + "add to cart", + "cart", + "checkout", + "coupon", + "login", + "sign in", + "privacy policy", + "terms", + "review write", + "all reviews", + "first", + "previous", + "next", + "last", + } + lines = [] + for raw in page_text.splitlines(): + line = raw.strip() + if not line: + continue + lowered = line.lower() + if lowered in noisy_terms: + continue + if line.isdigit(): + continue + if len(line) <= 2: + continue + lines.append(line) + + compact = "\n".join(lines) if lines else page_text + if len(compact) <= char_limit: + return compact + head_len = int(char_limit * 0.7) + tail_len = char_limit - head_len + return f"{compact[:head_len]}\n...\n{compact[-tail_len:]}" + + def extraction_response_format() -> dict[str, Any]: return { "type": "json_schema", diff --git a/crawler_platform/app/web/static/app.js b/crawler_platform/app/web/static/app.js index 7f1db76..37305d8 100644 --- a/crawler_platform/app/web/static/app.js +++ b/crawler_platform/app/web/static/app.js @@ -128,6 +128,33 @@ async function createProject() { await loadProjects(); } +async function initializeCurrentProject() { + const configPath = $("configPath").value.trim(); + if (!configPath) { + toast("Config path is required"); + return; + } + if (!window.confirm("Reset current project crawl data (pages/entities/claims)?")) return; + const result = await api("/projects/reset", { + method: "POST", + body: JSON.stringify({ + config_path: configPath, + project_name: state.selectedProject, + }), + }); + state.selectedProject = result.name; + await loadProjects(); + if (result.reset) { + const removedClaims = result.deleted?.claims ?? 0; + const removedEntities = result.deleted?.entities ?? 0; + const removedPages = result.deleted?.pages ?? 0; + $("crawlResult").textContent = `Reset done: pages ${removedPages}, entities ${removedEntities}, claims ${removedClaims}`; + toast(`Reset completed: ${result.name}`); + return; + } + $("crawlResult").textContent = `Project created: ${result.name}`; + toast(`Project created: ${result.name}`); +} async function crawl() { if (!state.selectedProject) return; $("crawlResult").textContent = "Crawling one URL..."; @@ -414,6 +441,7 @@ document.querySelectorAll(".tab").forEach((tab) => { $("refreshBtn").addEventListener("click", loadProjects); $("createProjectBtn").addEventListener("click", createProject); +$("initProjectBtn").addEventListener("click", initializeCurrentProject); $("discoverBtn").addEventListener("click", discover); $("crawlBtn").addEventListener("click", crawl); $("siteCrawlBtn").addEventListener("click", crawlSite); diff --git a/crawler_platform/app/web/static/index.html b/crawler_platform/app/web/static/index.html index 1ca1e3a..a26d8eb 100644 --- a/crawler_platform/app/web/static/index.html +++ b/crawler_platform/app/web/static/index.html @@ -25,6 +25,7 @@ +
diff --git a/crawler_platform/app/web/static/styles.css b/crawler_platform/app/web/static/styles.css index c19f3ca..ff2bb36 100644 --- a/crawler_platform/app/web/static/styles.css +++ b/crawler_platform/app/web/static/styles.css @@ -151,6 +151,10 @@ h2 { gap: 8px; } +#initProjectBtn { + margin-top: 8px; +} + .list { display: grid; gap: 8px;