This commit is contained in:
lasta
2026-05-11 22:55:01 +09:00
parent 3663734781
commit 0f34a451fc
10 changed files with 634 additions and 29 deletions

View 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 건수의 일별 지표화

View 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

View 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.

View File

@@ -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:

View File

@@ -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,

View File

@@ -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",

View File

@@ -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);

View File

@@ -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>

View File

@@ -151,6 +151,10 @@ h2 {
gap: 8px;
}
#initProjectBtn {
margin-top: 8px;
}
.list {
display: grid;
gap: 8px;