test
This commit is contained in:
88
PROCESSING_REPORT_2026-05-11.md
Normal file
88
PROCESSING_REPORT_2026-05-11.md
Normal file
@@ -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 건수의 일별 지표화
|
||||
81
PROCESS_OWNER_ONLY_2026-05-11.md
Normal file
81
PROCESS_OWNER_ONLY_2026-05-11.md
Normal file
@@ -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
|
||||
263
RESPONSIBILITY_REFACTOR_REPORT_2026-05-11.md
Normal file
263
RESPONSIBILITY_REFACTOR_REPORT_2026-05-11.md
Normal file
@@ -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/병합 규칙 직접 처리 제거
|
||||
|
||||
Binary file not shown.
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
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)
|
||||
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")
|
||||
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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<input id="configPath" value="configs/perfume_subscription.yaml" aria-label="Config path" />
|
||||
<button id="createProjectBtn" title="Create project">+</button>
|
||||
</div>
|
||||
<button id="initProjectBtn" class="full">Reset current project data</button>
|
||||
<div id="projectList" class="list"></div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -151,6 +151,10 @@ h2 {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#initProjectBtn {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
|
||||
Reference in New Issue
Block a user