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 033f4c2..7941179 100644 Binary files a/crawler_platform.db and b/crawler_platform.db differ 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;