graph
This commit is contained in:
@@ -4,12 +4,14 @@ import csv
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from fastapi import BackgroundTasks, HTTPException, Response
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
from crawler_platform.app.config.loader import (
|
||||
ProjectConfig,
|
||||
@@ -41,15 +43,18 @@ from crawler_platform.app.core.research.memory_store import ResearchMemoryStore,
|
||||
|
||||
|
||||
DOMAIN_NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{1,79}$")
|
||||
SITE_CRAWL_CANCEL_REQUESTS: set[int] = set()
|
||||
|
||||
|
||||
class CrawlRequest(BaseModel):
|
||||
config_path: str
|
||||
source_name: str
|
||||
url: str
|
||||
extraction_mode: str = "hybrid"
|
||||
extractor_provider: str = "lm_studio"
|
||||
extractor_model: str | None = None
|
||||
extractor_base_url: str | None = "http://localhost:1234/v1"
|
||||
fallback_to_rules: bool = True
|
||||
check_robots_txt: bool = False
|
||||
respect_robots_txt: bool | None = None
|
||||
|
||||
@@ -67,9 +72,11 @@ class SiteCrawlByProjectRequest(BaseModel):
|
||||
project_name: str
|
||||
source_name: str
|
||||
url: str
|
||||
extraction_mode: str = "hybrid"
|
||||
extractor_provider: str = "lm_studio"
|
||||
extractor_model: str | None = None
|
||||
extractor_base_url: str | None = "http://localhost:1234/v1"
|
||||
fallback_to_rules: bool = True
|
||||
check_robots_txt: bool = False
|
||||
respect_robots_txt: bool | None = None
|
||||
max_depth: int = 2
|
||||
@@ -85,9 +92,11 @@ class SiteCrawlByProjectRequest(BaseModel):
|
||||
config_path=config_path_placeholder,
|
||||
source_name=self.source_name,
|
||||
url=self.url,
|
||||
extraction_mode=self.extraction_mode,
|
||||
extractor_provider=self.extractor_provider,
|
||||
extractor_model=self.extractor_model,
|
||||
extractor_base_url=self.extractor_base_url,
|
||||
fallback_to_rules=self.fallback_to_rules,
|
||||
check_robots_txt=self.check_robots_txt,
|
||||
respect_robots_txt=self.respect_robots_txt,
|
||||
max_depth=self.max_depth,
|
||||
@@ -535,9 +544,11 @@ class ResearchRunByProjectRequest(BaseModel):
|
||||
url: str | None = None
|
||||
seed_entity_id: int | None = None
|
||||
goal: str = "Semantic ontology exploration"
|
||||
extraction_mode: str = "hybrid"
|
||||
extractor_provider: str = "lm_studio"
|
||||
extractor_model: str | None = None
|
||||
extractor_base_url: str | None = "http://localhost:1234/v1"
|
||||
fallback_to_rules: bool = True
|
||||
check_robots_txt: bool = False
|
||||
respect_robots_txt: bool | None = None
|
||||
max_depth: int = 2
|
||||
@@ -622,6 +633,20 @@ def site_crawl_progress_payload(result, latest_page=None) -> dict[str, Any]:
|
||||
payload = asdict(result)
|
||||
if latest_page is not None:
|
||||
payload["latest_page"] = asdict(latest_page)
|
||||
pages = payload.get("pages") or []
|
||||
payload["extraction_summary"] = {
|
||||
"llm_skipped_count": sum(1 for page in pages if page.get("llm_skipped")),
|
||||
"fallback_count": sum(1 for page in pages if page.get("fallback_used")),
|
||||
"conflict_claim_count": sum(int(page.get("conflict_claim_count") or 0) for page in pages),
|
||||
"agreement_claim_count": sum(int(page.get("agreement_claim_count") or 0) for page in pages),
|
||||
"llm_call_count": sum(
|
||||
1
|
||||
for page in pages
|
||||
if page.get("extraction_mode") in {"hybrid", "llm_only", "compare"}
|
||||
and not page.get("llm_skipped")
|
||||
and not page.get("fallback_used")
|
||||
),
|
||||
}
|
||||
return payload
|
||||
|
||||
|
||||
@@ -696,7 +721,58 @@ def update_site_crawl_job_metadata(job: models.CrawlJob, **updates: Any) -> None
|
||||
job.metadata_json = metadata
|
||||
|
||||
|
||||
def mark_orphan_site_crawl_jobs_canceled(database_url: str) -> None:
|
||||
with session_scope(database_url) as session:
|
||||
rows = session.scalars(
|
||||
select(models.CrawlJob).where(
|
||||
models.CrawlJob.status.in_(["running", "cancel_requested"]),
|
||||
)
|
||||
).all()
|
||||
for job in rows:
|
||||
if (job.metadata_json or {}).get("kind") != "site_crawl":
|
||||
continue
|
||||
job.status = "canceled"
|
||||
job.error = job.error or "canceled after server restart/interrupted crawl"
|
||||
job.finished_at = models.utcnow()
|
||||
|
||||
|
||||
def request_site_crawl_cancel(database_url: str, job_id: int) -> dict[str, Any]:
|
||||
SITE_CRAWL_CANCEL_REQUESTS.add(job_id)
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(3):
|
||||
try:
|
||||
with session_scope(database_url) as session:
|
||||
job = session.get(models.CrawlJob, job_id)
|
||||
if job is None or (job.metadata_json or {}).get("kind") != "site_crawl":
|
||||
raise HTTPException(status_code=404, detail="site crawl job not found")
|
||||
if job.status in {"completed", "failed", "canceled"}:
|
||||
SITE_CRAWL_CANCEL_REQUESTS.discard(job_id)
|
||||
return crawl_job_response(job)
|
||||
job.status = "cancel_requested"
|
||||
job.error = "cancel requested by user"
|
||||
payload = crawl_job_response(job)
|
||||
return payload
|
||||
except OperationalError as exc:
|
||||
last_error = exc
|
||||
if "database is locked" not in str(exc).lower():
|
||||
raise
|
||||
time.sleep(0.25 * (attempt + 1))
|
||||
return {
|
||||
"job_id": job_id,
|
||||
"status": "cancel_requested",
|
||||
"url": None,
|
||||
"error": f"cancel requested in memory; database was locked: {last_error}",
|
||||
"scheduled_at": None,
|
||||
"started_at": None,
|
||||
"finished_at": None,
|
||||
"progress": {},
|
||||
"request": {},
|
||||
}
|
||||
|
||||
|
||||
def is_site_crawl_cancel_requested(session, job_id: int) -> bool:
|
||||
if job_id in SITE_CRAWL_CANCEL_REQUESTS:
|
||||
return True
|
||||
session.expire_all()
|
||||
job = session.get(models.CrawlJob, job_id)
|
||||
return job is None or job.status == "cancel_requested"
|
||||
@@ -744,6 +820,8 @@ def run_site_crawl_job(database_url: str, job_id: int, request_data: dict[str, A
|
||||
provider=request.extractor_provider,
|
||||
model=request.extractor_model,
|
||||
base_url=request.extractor_base_url,
|
||||
extraction_mode=request.extraction_mode,
|
||||
fallback_to_rules=request.fallback_to_rules,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -775,12 +853,17 @@ def run_site_crawl_job(database_url: str, job_id: int, request_data: dict[str, A
|
||||
|
||||
finished_job = session.get(models.CrawlJob, job_id)
|
||||
if finished_job is not None:
|
||||
finished_job.status = "canceled" if finished_job.status == "cancel_requested" else "completed"
|
||||
finished_job.status = (
|
||||
"canceled"
|
||||
if finished_job.status == "cancel_requested" or job_id in SITE_CRAWL_CANCEL_REQUESTS
|
||||
else "completed"
|
||||
)
|
||||
finished_job.finished_at = models.utcnow()
|
||||
update_site_crawl_job_metadata(
|
||||
finished_job,
|
||||
progress=site_crawl_progress_payload(result),
|
||||
)
|
||||
SITE_CRAWL_CANCEL_REQUESTS.discard(job_id)
|
||||
except Exception as exc:
|
||||
with session_scope(database_url) as session:
|
||||
job = session.get(models.CrawlJob, job_id)
|
||||
@@ -792,10 +875,12 @@ def run_site_crawl_job(database_url: str, job_id: int, request_data: dict[str, A
|
||||
progress = dict(metadata.get("progress") or {})
|
||||
progress["errors"] = [*progress.get("errors", []), str(exc)]
|
||||
update_site_crawl_job_metadata(job, progress=progress)
|
||||
SITE_CRAWL_CANCEL_REQUESTS.discard(job_id)
|
||||
|
||||
|
||||
def register_routes(app, database_url: str) -> None:
|
||||
recover_interrupted_domain_discovery_jobs(database_url)
|
||||
mark_orphan_site_crawl_jobs_canceled(database_url)
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
@@ -1536,6 +1621,8 @@ def register_routes(app, database_url: str) -> None:
|
||||
provider=request.extractor_provider,
|
||||
model=request.extractor_model,
|
||||
base_url=request.extractor_base_url,
|
||||
extraction_mode=request.extraction_mode,
|
||||
fallback_to_rules=request.fallback_to_rules,
|
||||
),
|
||||
)
|
||||
try:
|
||||
@@ -1546,6 +1633,13 @@ def register_routes(app, database_url: str) -> None:
|
||||
"page_id": result.page_id,
|
||||
"claim_count": result.claim_count,
|
||||
"entity_count": result.entity_count,
|
||||
"extraction_mode": result.extraction_mode,
|
||||
"effective_extraction_mode": result.effective_extraction_mode,
|
||||
"llm_skipped": result.llm_skipped,
|
||||
"llm_skip_reason": result.llm_skip_reason,
|
||||
"fallback_used": result.fallback_used,
|
||||
"agreement_claim_count": result.agreement_claim_count,
|
||||
"conflict_claim_count": result.conflict_claim_count,
|
||||
"crawl_status": result.crawl_status,
|
||||
"extraction_status": result.extraction_status,
|
||||
"page_type": result.page_type,
|
||||
@@ -1648,15 +1742,7 @@ def register_routes(app, database_url: str) -> None:
|
||||
|
||||
@app.post("/crawl-site/jobs/{job_id}/cancel")
|
||||
def cancel_crawl_site_job(job_id: int):
|
||||
with session_scope(database_url) as session:
|
||||
job = session.get(models.CrawlJob, job_id)
|
||||
if job is None or (job.metadata_json or {}).get("kind") != "site_crawl":
|
||||
raise HTTPException(status_code=404, detail="site crawl job not found")
|
||||
if job.status in {"completed", "failed", "canceled"}:
|
||||
return crawl_job_response(job)
|
||||
job.status = "cancel_requested"
|
||||
job.error = "cancel requested by user"
|
||||
return crawl_job_response(job)
|
||||
return request_site_crawl_cancel(database_url, job_id)
|
||||
|
||||
@app.post("/discover")
|
||||
def discover(request: DiscoverRequest):
|
||||
@@ -1705,6 +1791,8 @@ def register_routes(app, database_url: str) -> None:
|
||||
provider=request.extractor_provider,
|
||||
model=request.extractor_model,
|
||||
base_url=request.extractor_base_url,
|
||||
extraction_mode=request.extraction_mode,
|
||||
fallback_to_rules=request.fallback_to_rules,
|
||||
),
|
||||
)
|
||||
try:
|
||||
@@ -1749,6 +1837,8 @@ def register_routes(app, database_url: str) -> None:
|
||||
provider=request.extractor_provider,
|
||||
model=request.extractor_model,
|
||||
base_url=request.extractor_base_url,
|
||||
extraction_mode=request.extraction_mode,
|
||||
fallback_to_rules=request.fallback_to_rules,
|
||||
),
|
||||
)
|
||||
try:
|
||||
@@ -2080,6 +2170,11 @@ def register_routes(app, database_url: str) -> None:
|
||||
"graph_merge_status": (claim.metadata_json or {}).get("graph_merge_status"),
|
||||
"graph_merge_reason": (claim.metadata_json or {}).get("graph_merge_reason"),
|
||||
"confidence_breakdown": (claim.metadata_json or {}).get("confidence_breakdown"),
|
||||
"agreement": (claim.metadata_json or {}).get("agreement"),
|
||||
"extraction_source": (claim.metadata_json or {}).get("extraction_source"),
|
||||
"claim_kind": (claim.metadata_json or {}).get("claim_kind"),
|
||||
"rule_confidence": (claim.metadata_json or {}).get("rule_confidence"),
|
||||
"llm_confidence": (claim.metadata_json or {}).get("llm_confidence"),
|
||||
"review_required": (claim.metadata_json or {}).get("review_required"),
|
||||
"review_reason": (claim.metadata_json or {}).get("review_reason"),
|
||||
"conflict_status": (claim.metadata_json or {}).get("conflict_status"),
|
||||
@@ -2243,9 +2338,11 @@ def register_routes(app, database_url: str) -> None:
|
||||
config_path="",
|
||||
source_name=source.name,
|
||||
url=url,
|
||||
extraction_mode=str(latest_request.get("extraction_mode") or "hybrid"),
|
||||
extractor_provider=str(latest_request.get("extractor_provider") or "lm_studio"),
|
||||
extractor_model=latest_request.get("extractor_model"),
|
||||
extractor_base_url=latest_request.get("extractor_base_url") or "http://localhost:1234/v1",
|
||||
fallback_to_rules=bool(latest_request.get("fallback_to_rules", True)),
|
||||
check_robots_txt=bool(latest_request.get("check_robots_txt") or False),
|
||||
respect_robots_txt=latest_request.get("respect_robots_txt"),
|
||||
max_depth=int(latest_request.get("max_depth") or 2),
|
||||
@@ -2404,6 +2501,20 @@ def register_routes(app, database_url: str) -> None:
|
||||
"validation": (log.raw_output or {}).get("validation"),
|
||||
"page_context": (log.raw_output or {}).get("page_context"),
|
||||
"candidate_count": len((log.raw_output or {}).get("candidate_claims") or []),
|
||||
"extraction_mode": (log.raw_output or {}).get("extraction_mode"),
|
||||
"effective_extraction_mode": (log.raw_output or {}).get("effective_extraction_mode"),
|
||||
"comparison": (log.raw_output or {}).get("comparison"),
|
||||
"rule_entity_count": (log.raw_output or {}).get("rule_entity_count"),
|
||||
"rule_claim_count": (log.raw_output or {}).get("rule_claim_count"),
|
||||
"llm_entity_count": (log.raw_output or {}).get("llm_entity_count"),
|
||||
"llm_claim_count": (log.raw_output or {}).get("llm_claim_count"),
|
||||
"agreement_claim_count": (log.raw_output or {}).get("agreement_claim_count"),
|
||||
"rule_only_claim_count": (log.raw_output or {}).get("rule_only_claim_count"),
|
||||
"llm_only_claim_count": (log.raw_output or {}).get("llm_only_claim_count"),
|
||||
"conflict_claim_count": (log.raw_output or {}).get("conflict_claim_count"),
|
||||
"llm_skipped": (log.raw_output or {}).get("llm_skipped"),
|
||||
"llm_skip_reason": (log.raw_output or {}).get("llm_skip_reason"),
|
||||
"fallback": (log.raw_output or {}).get("fallback"),
|
||||
"raw_output": log.raw_output,
|
||||
}
|
||||
for log, page in rows
|
||||
|
||||
@@ -37,6 +37,8 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
crawl.add_argument("--extractor-provider", default="rule_based", choices=["rule_based", "openai", "ollama", "lm_studio"])
|
||||
crawl.add_argument("--extractor-model")
|
||||
crawl.add_argument("--extractor-base-url")
|
||||
crawl.add_argument("--extraction-mode", default=None, choices=["rule_only", "llm_only", "hybrid", "compare"])
|
||||
crawl.add_argument("--no-rule-fallback", action="store_true")
|
||||
|
||||
claims = sub.add_parser("claims")
|
||||
claims.add_argument("--project", required=True)
|
||||
@@ -81,6 +83,8 @@ def main() -> None:
|
||||
provider=args.extractor_provider,
|
||||
model=args.extractor_model,
|
||||
base_url=args.extractor_base_url,
|
||||
extraction_mode=args.extraction_mode,
|
||||
fallback_to_rules=not args.no_rule_fallback,
|
||||
),
|
||||
)
|
||||
result = pipeline.crawl_url(config, args.source, args.url)
|
||||
|
||||
@@ -16,6 +16,13 @@ class CrawlResult:
|
||||
page_id: int
|
||||
claim_count: int
|
||||
entity_count: int
|
||||
extraction_mode: str | None = None
|
||||
effective_extraction_mode: str | None = None
|
||||
llm_skipped: bool = False
|
||||
llm_skip_reason: str | None = None
|
||||
fallback_used: bool = False
|
||||
agreement_claim_count: int = 0
|
||||
conflict_claim_count: int = 0
|
||||
crawl_status: str = "success"
|
||||
extraction_status: str = "success"
|
||||
page_type: str = "UnknownPage"
|
||||
@@ -112,10 +119,12 @@ class CrawlPipeline:
|
||||
bundle = self.extractor.extract_from_context(context, project_config)
|
||||
bundle = attach_page_context(bundle, context)
|
||||
claims = self.repository.save_extraction_bundle(project.id, source, page, bundle, project_config)
|
||||
extraction_summary = extraction_summary_from_raw(bundle.raw_output)
|
||||
return CrawlResult(
|
||||
page_id=page.id,
|
||||
claim_count=len(claims),
|
||||
entity_count=len(bundle.entities),
|
||||
**extraction_summary,
|
||||
crawl_status=fetch_result.crawl_status,
|
||||
extraction_status=parsed.extraction_status,
|
||||
page_type=page_type,
|
||||
@@ -125,3 +134,15 @@ class CrawlPipeline:
|
||||
robots_status=robots_decision.status,
|
||||
robots_reason=robots_decision.reason,
|
||||
)
|
||||
|
||||
|
||||
def extraction_summary_from_raw(raw_output: dict[str, object]) -> dict[str, object]:
|
||||
return {
|
||||
"extraction_mode": raw_output.get("extraction_mode"),
|
||||
"effective_extraction_mode": raw_output.get("effective_extraction_mode"),
|
||||
"llm_skipped": bool(raw_output.get("llm_skipped")),
|
||||
"llm_skip_reason": raw_output.get("llm_skip_reason"),
|
||||
"fallback_used": bool(raw_output.get("fallback")),
|
||||
"agreement_claim_count": int(raw_output.get("agreement_claim_count") or 0),
|
||||
"conflict_claim_count": int(raw_output.get("conflict_claim_count") or 0),
|
||||
}
|
||||
|
||||
@@ -28,6 +28,13 @@ class SiteCrawlPageResult:
|
||||
page_id: int | None = None
|
||||
claim_count: int = 0
|
||||
entity_count: int = 0
|
||||
extraction_mode: str | None = None
|
||||
effective_extraction_mode: str | None = None
|
||||
llm_skipped: bool = False
|
||||
llm_skip_reason: str | None = None
|
||||
fallback_used: bool = False
|
||||
agreement_claim_count: int = 0
|
||||
conflict_claim_count: int = 0
|
||||
discovered_count: int = 0
|
||||
crawl_status: str = "success"
|
||||
extraction_status: str = "unknown"
|
||||
@@ -331,6 +338,7 @@ class SiteCrawler:
|
||||
bundle = self.extractor.extract_from_context(context, project_config)
|
||||
bundle = attach_page_context(bundle, context)
|
||||
claims = self.repository.save_extraction_bundle(source.project_id, source, page, bundle, project_config)
|
||||
extraction_summary = extraction_summary_from_raw(bundle.raw_output)
|
||||
self._finish_job(job, "completed")
|
||||
result.analyzed_count += 1
|
||||
page_result = SiteCrawlPageResult(
|
||||
@@ -338,6 +346,7 @@ class SiteCrawler:
|
||||
status="completed",
|
||||
claim_count=len(claims),
|
||||
entity_count=len(bundle.entities),
|
||||
**extraction_summary,
|
||||
)
|
||||
else:
|
||||
self._finish_job(job, "discovered")
|
||||
@@ -441,3 +450,15 @@ def classify_page(
|
||||
source_zones: list[dict[str, object]] | None = None,
|
||||
) -> str:
|
||||
return classify_page_type(url, title, text, html=html, source_zones=source_zones)
|
||||
|
||||
|
||||
def extraction_summary_from_raw(raw_output: dict[str, object]) -> dict[str, object]:
|
||||
return {
|
||||
"extraction_mode": raw_output.get("extraction_mode"),
|
||||
"effective_extraction_mode": raw_output.get("effective_extraction_mode"),
|
||||
"llm_skipped": bool(raw_output.get("llm_skipped")),
|
||||
"llm_skip_reason": raw_output.get("llm_skip_reason"),
|
||||
"fallback_used": bool(raw_output.get("fallback")),
|
||||
"agreement_claim_count": int(raw_output.get("agreement_claim_count") or 0),
|
||||
"conflict_claim_count": int(raw_output.get("conflict_claim_count") or 0),
|
||||
}
|
||||
|
||||
@@ -197,6 +197,7 @@ class KnowledgeRepository:
|
||||
|
||||
claims: list[models.Claim] = []
|
||||
for extracted_claim in bundle.claims:
|
||||
claim_status_for_row = claim_status
|
||||
subject = self._entity_for_claim(project_id, extracted_claim.subject_type, extracted_claim.subject_name, entity_index)
|
||||
object_entity = None
|
||||
if extracted_claim.object_name and extracted_claim.object_type:
|
||||
@@ -211,6 +212,9 @@ class KnowledgeRepository:
|
||||
**extracted_claim.metadata,
|
||||
"source_trust": source.trust_level,
|
||||
}
|
||||
metadata_status = str(claim_metadata.get("validation_status") or "")
|
||||
if metadata_status in {"active", "validated_claim", "candidate_claim", "rule_candidate"}:
|
||||
claim_status_for_row = metadata_status
|
||||
claim_metadata["source_history"] = [
|
||||
{
|
||||
"source_id": source.id,
|
||||
@@ -272,7 +276,7 @@ class KnowledgeRepository:
|
||||
confidence=confidence,
|
||||
confidence_reason=extracted_claim.confidence_reason,
|
||||
extraction_method=bundle.extractor_name,
|
||||
status=claim_status,
|
||||
status=claim_status_for_row,
|
||||
metadata_json=claim_metadata,
|
||||
)
|
||||
self.session.add(claim)
|
||||
@@ -284,8 +288,8 @@ class KnowledgeRepository:
|
||||
claim.last_seen_at = models.utcnow()
|
||||
claim.confidence = max(claim.confidence, confidence)
|
||||
claim.confidence_reason = extracted_claim.confidence_reason or claim.confidence_reason
|
||||
if claim_status == "active" or claim.status != "active":
|
||||
claim.status = claim_status
|
||||
if claim_status_for_row == "active" or claim.status != "active":
|
||||
claim.status = claim_status_for_row
|
||||
claim.metadata_json = {**existing_metadata, **claim_metadata}
|
||||
claim.metadata_json["source_history"] = merge_source_history(
|
||||
existing_history,
|
||||
|
||||
@@ -31,6 +31,8 @@ class LLMJsonExtractor(AIExtractor):
|
||||
model: str | None = None,
|
||||
base_url: str | None = None,
|
||||
timeout_seconds: int = 300,
|
||||
fallback_to_rules: bool = True,
|
||||
merge_rule_claims: bool = True,
|
||||
):
|
||||
# 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.
|
||||
@@ -41,6 +43,8 @@ class LLMJsonExtractor(AIExtractor):
|
||||
self.model = model
|
||||
self.base_url = base_url
|
||||
self.timeout_seconds = timeout_seconds
|
||||
self.fallback_to_rules = fallback_to_rules
|
||||
self.merge_rule_claims = merge_rule_claims
|
||||
|
||||
def extract(self, page_text: str, project_config: ProjectConfig) -> ExtractionBundle:
|
||||
return self._extract_text(page_text, project_config, context=None)
|
||||
@@ -64,7 +68,11 @@ class LLMJsonExtractor(AIExtractor):
|
||||
try:
|
||||
raw = self.complete_json(page_text, project_config, compact=compact_mode, context=context)
|
||||
bundle = self._bundle_from_raw(raw, mode_name)
|
||||
enriched = self._merge_rule_fallback_claims(bundle, page_text, project_config, mode_name)
|
||||
enriched = (
|
||||
self._merge_rule_fallback_claims(bundle, page_text, project_config, mode_name)
|
||||
if self.merge_rule_claims
|
||||
else bundle
|
||||
)
|
||||
if enriched.entities and enriched.claims:
|
||||
return self.normalize_to_ontology(enriched, project_config.ontology)
|
||||
if bundle.entities and bundle.claims:
|
||||
@@ -72,6 +80,8 @@ class LLMJsonExtractor(AIExtractor):
|
||||
errors.append(f"{mode_name}: AI returned no usable entities or claims")
|
||||
except Exception as exc:
|
||||
errors.append(f"{mode_name}: {exc}")
|
||||
if not self.fallback_to_rules:
|
||||
raise RuntimeError(" | ".join(errors))
|
||||
return self._fallback_bundle(page_text, project_config, " | ".join(errors))
|
||||
|
||||
def extract_entities(self, page_text: str, project_config: ProjectConfig) -> list[ExtractedEntity]:
|
||||
|
||||
@@ -2,18 +2,71 @@ from __future__ import annotations
|
||||
|
||||
from crawler_platform.app.core.extractor.ai_provider import LLMJsonExtractor
|
||||
from crawler_platform.app.core.extractor.base import Extractor
|
||||
from crawler_platform.app.core.extractor.hybrid import HybridExtractor
|
||||
from crawler_platform.app.core.extractor.rule_based import GenericRuleBasedExtractor
|
||||
from crawler_platform.app.domains.perfume.extractor import PerfumeRuleBasedExtractor
|
||||
|
||||
|
||||
LLM_PROVIDERS = {"openai", "ollama", "lm_studio"}
|
||||
|
||||
|
||||
def extractor_for_domain(
|
||||
domain: str,
|
||||
provider: str = "rule_based",
|
||||
model: str | None = None,
|
||||
base_url: str | None = None,
|
||||
extraction_mode: str | None = None,
|
||||
fallback_to_rules: bool = True,
|
||||
) -> Extractor:
|
||||
if provider in {"openai", "ollama", "lm_studio"}:
|
||||
return LLMJsonExtractor(domain=domain, provider=provider, model=model, base_url=base_url)
|
||||
mode = normalize_extraction_mode(provider, extraction_mode)
|
||||
llm_provider = normalize_llm_provider(provider)
|
||||
if mode in {"hybrid", "compare"}:
|
||||
return HybridExtractor(
|
||||
domain=domain,
|
||||
llm_provider=llm_provider,
|
||||
model=model,
|
||||
base_url=base_url,
|
||||
mode=mode,
|
||||
fallback_to_rules=fallback_to_rules,
|
||||
)
|
||||
if mode == "llm_only":
|
||||
return LLMJsonExtractor(
|
||||
domain=domain,
|
||||
provider=llm_provider,
|
||||
model=model,
|
||||
base_url=base_url,
|
||||
fallback_to_rules=fallback_to_rules,
|
||||
merge_rule_claims=False,
|
||||
)
|
||||
return rule_extractor_for_domain(domain)
|
||||
|
||||
|
||||
def normalize_extraction_mode(provider: str, extraction_mode: str | None = None) -> str:
|
||||
if extraction_mode:
|
||||
value = extraction_mode.strip().lower().replace("-", "_")
|
||||
else:
|
||||
value = provider.strip().lower().replace("-", "_")
|
||||
aliases = {
|
||||
"rule": "rule_only",
|
||||
"rules": "rule_only",
|
||||
"rule_based": "rule_only",
|
||||
"llm": "llm_only",
|
||||
"ai": "llm_only",
|
||||
}
|
||||
value = aliases.get(value, value)
|
||||
if value in {"rule_only", "llm_only", "hybrid", "compare"}:
|
||||
return value
|
||||
if value in LLM_PROVIDERS:
|
||||
return "hybrid"
|
||||
return "rule_only"
|
||||
|
||||
|
||||
def normalize_llm_provider(provider: str) -> str:
|
||||
value = provider.strip().lower()
|
||||
return value if value in LLM_PROVIDERS else "lm_studio"
|
||||
|
||||
|
||||
def rule_extractor_for_domain(domain: str) -> Extractor:
|
||||
if domain == "perfume":
|
||||
return PerfumeRuleBasedExtractor()
|
||||
return GenericRuleBasedExtractor()
|
||||
|
||||
438
ontology_platform/crawler_platform/app/core/extractor/hybrid.py
Normal file
438
ontology_platform/crawler_platform/app/core/extractor/hybrid.py
Normal file
@@ -0,0 +1,438 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import replace
|
||||
from typing import Any
|
||||
|
||||
from crawler_platform.app.config.loader import ProjectConfig
|
||||
from crawler_platform.app.core.extractor.ai_provider import LLMJsonExtractor, dedupe_entities
|
||||
from crawler_platform.app.core.extractor.base import (
|
||||
ExtractedClaim,
|
||||
ExtractedEntity,
|
||||
ExtractionBundle,
|
||||
ExtractionPageContext,
|
||||
Extractor,
|
||||
)
|
||||
from crawler_platform.app.core.extractor.rule_based import GenericRuleBasedExtractor
|
||||
from crawler_platform.app.domains.perfume.extractor import PerfumeRuleBasedExtractor
|
||||
|
||||
|
||||
LLM_PAGE_TYPES = {"ProductPage", "BrandStoryPage", "ReviewPage"}
|
||||
SKIP_LLM_PAGE_TYPES = {"CategoryPage", "SearchPage", "ListingPage"}
|
||||
RULE_ONLY_PAGE_TYPES = {"BoardPage", "CommunityPage", "UnknownPage"}
|
||||
MIN_CLEAN_TEXT_CHARS_FOR_LLM = 300
|
||||
MAX_CLEAN_TEXT_CHARS_FOR_LLM = 60000
|
||||
|
||||
|
||||
class HybridExtractor(Extractor):
|
||||
"""Run rule extraction first, then LLM extraction, then merge with agreement metadata."""
|
||||
|
||||
name = "hybrid_rule_llm_extractor"
|
||||
provider = "hybrid"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
domain: str,
|
||||
llm_provider: str = "lm_studio",
|
||||
model: str | None = None,
|
||||
base_url: str | None = None,
|
||||
mode: str = "hybrid",
|
||||
fallback_to_rules: bool = True,
|
||||
):
|
||||
self.domain = domain
|
||||
self.llm_provider = llm_provider
|
||||
self.model = model
|
||||
self.base_url = base_url
|
||||
self.mode = normalize_mode(mode)
|
||||
self.fallback_to_rules = fallback_to_rules
|
||||
self.rule_extractor = rule_extractor_for_domain(domain)
|
||||
self.llm_extractor = LLMJsonExtractor(
|
||||
domain=domain,
|
||||
provider=llm_provider,
|
||||
model=model,
|
||||
base_url=base_url,
|
||||
fallback_to_rules=False,
|
||||
merge_rule_claims=False,
|
||||
)
|
||||
|
||||
def extract(self, page_text: str, project_config: ProjectConfig) -> ExtractionBundle:
|
||||
return self._extract_text(page_text, project_config, context=None)
|
||||
|
||||
def extract_from_context(
|
||||
self,
|
||||
context: ExtractionPageContext,
|
||||
project_config: ProjectConfig,
|
||||
) -> ExtractionBundle:
|
||||
return self._extract_text(context.clean_text, project_config, context=context)
|
||||
|
||||
def _extract_text(
|
||||
self,
|
||||
page_text: str,
|
||||
project_config: ProjectConfig,
|
||||
context: ExtractionPageContext | None,
|
||||
) -> ExtractionBundle:
|
||||
rule_bundle = self._rule_bundle(page_text, project_config, context)
|
||||
mark_bundle(rule_bundle, source="rule", mode=self.mode)
|
||||
|
||||
if self.mode == "rule_only":
|
||||
rule_bundle.extractor_name = "rule_only_extractor"
|
||||
rule_bundle.raw_output = {
|
||||
**rule_bundle.raw_output,
|
||||
"extraction_mode": self.mode,
|
||||
"effective_extraction_mode": "rule_only",
|
||||
"llm_skipped": True,
|
||||
"llm_skip_reason": "rule_only mode",
|
||||
}
|
||||
return rule_bundle
|
||||
|
||||
skip_reason = llm_skip_reason(context, page_text, self.mode)
|
||||
if skip_reason:
|
||||
return llm_skipped_bundle(rule_bundle, self, skip_reason, context)
|
||||
|
||||
try:
|
||||
llm_bundle = self._llm_bundle(page_text, project_config, context)
|
||||
mark_bundle(llm_bundle, source="llm", mode=self.mode)
|
||||
except Exception as exc:
|
||||
if not self.fallback_to_rules:
|
||||
raise
|
||||
return fallback_bundle(rule_bundle, self, exc)
|
||||
|
||||
if self.mode == "llm_only":
|
||||
llm_bundle.extractor_name = "llm_only_extractor"
|
||||
llm_bundle.raw_output = {
|
||||
**llm_bundle.raw_output,
|
||||
"extraction_mode": self.mode,
|
||||
"effective_extraction_mode": "llm_only",
|
||||
"rule_entity_count": len(rule_bundle.entities),
|
||||
"rule_claim_count": len(rule_bundle.claims),
|
||||
}
|
||||
return llm_bundle
|
||||
|
||||
return merge_bundles(rule_bundle, llm_bundle, self)
|
||||
|
||||
def _rule_bundle(
|
||||
self,
|
||||
page_text: str,
|
||||
project_config: ProjectConfig,
|
||||
context: ExtractionPageContext | None,
|
||||
) -> ExtractionBundle:
|
||||
if context is not None:
|
||||
return self.rule_extractor.extract_from_context(context, project_config)
|
||||
return self.rule_extractor.extract(page_text, project_config)
|
||||
|
||||
def _llm_bundle(
|
||||
self,
|
||||
page_text: str,
|
||||
project_config: ProjectConfig,
|
||||
context: ExtractionPageContext | None,
|
||||
) -> ExtractionBundle:
|
||||
if context is not None:
|
||||
return self.llm_extractor.extract_from_context(context, project_config)
|
||||
return self.llm_extractor.extract(page_text, project_config)
|
||||
|
||||
def extract_entities(self, page_text: str, project_config: ProjectConfig) -> list[ExtractedEntity]:
|
||||
return self.extract(page_text, project_config).entities
|
||||
|
||||
def extract_attributes(
|
||||
self,
|
||||
entity: ExtractedEntity,
|
||||
page_text: str,
|
||||
project_config: ProjectConfig,
|
||||
) -> dict[str, Any]:
|
||||
return entity.attributes
|
||||
|
||||
def extract_relations(
|
||||
self,
|
||||
entities: list[ExtractedEntity],
|
||||
page_text: str,
|
||||
project_config: ProjectConfig,
|
||||
) -> list[ExtractedClaim]:
|
||||
return []
|
||||
|
||||
def normalize_to_ontology(self, bundle: ExtractionBundle, ontology: dict[str, Any]) -> ExtractionBundle:
|
||||
return bundle
|
||||
|
||||
|
||||
def normalize_mode(mode: str | None) -> str:
|
||||
value = (mode or "hybrid").strip().lower().replace("-", "_")
|
||||
aliases = {
|
||||
"rule": "rule_only",
|
||||
"rules": "rule_only",
|
||||
"rule_based": "rule_only",
|
||||
"llm": "llm_only",
|
||||
"ai": "llm_only",
|
||||
}
|
||||
return aliases.get(value, value)
|
||||
|
||||
|
||||
def rule_extractor_for_domain(domain: str) -> Extractor:
|
||||
if domain == "perfume":
|
||||
return PerfumeRuleBasedExtractor()
|
||||
return GenericRuleBasedExtractor()
|
||||
|
||||
|
||||
def mark_bundle(bundle: ExtractionBundle, *, source: str, mode: str) -> None:
|
||||
for entity in bundle.entities:
|
||||
entity.metadata = {
|
||||
**entity.metadata,
|
||||
"extraction_source": source,
|
||||
f"{source}_confidence": entity.confidence,
|
||||
}
|
||||
for claim in bundle.claims:
|
||||
claim.metadata = {
|
||||
**claim.metadata,
|
||||
"extraction_source": source,
|
||||
"agreement": f"{source}_only",
|
||||
f"{source}_confidence": claim.confidence,
|
||||
"extraction_mode": mode,
|
||||
}
|
||||
|
||||
|
||||
def fallback_bundle(rule_bundle: ExtractionBundle, extractor: HybridExtractor, exc: Exception) -> ExtractionBundle:
|
||||
reason = str(exc)
|
||||
bundle = clone_bundle(rule_bundle)
|
||||
bundle.extractor_name = "hybrid_rule_fallback"
|
||||
bundle.provider = extractor.llm_provider
|
||||
bundle.raw_output = {
|
||||
**bundle.raw_output,
|
||||
"extraction_mode": "fallback",
|
||||
"effective_extraction_mode": "rule_only",
|
||||
"requested_extraction_mode": extractor.mode,
|
||||
"ai_provider": extractor.llm_provider,
|
||||
"ai_model": extractor.model,
|
||||
"ai_warning": reason,
|
||||
"fallback": "rule_based",
|
||||
}
|
||||
for entity in bundle.entities:
|
||||
entity.metadata["ai_fallback_reason"] = reason
|
||||
for claim in bundle.claims:
|
||||
claim.metadata = {
|
||||
**claim.metadata,
|
||||
"ai_fallback_reason": reason,
|
||||
"fallback": "rule_based",
|
||||
"agreement": "rule_only",
|
||||
}
|
||||
claim.confidence_reason = (
|
||||
f"{claim.confidence_reason}; AI fallback: {reason}"
|
||||
if claim.confidence_reason
|
||||
else f"AI fallback: {reason}"
|
||||
)
|
||||
return bundle
|
||||
|
||||
|
||||
def llm_skipped_bundle(
|
||||
rule_bundle: ExtractionBundle,
|
||||
extractor: HybridExtractor,
|
||||
reason: str,
|
||||
context: ExtractionPageContext | None,
|
||||
) -> ExtractionBundle:
|
||||
bundle = clone_bundle(rule_bundle)
|
||||
bundle.extractor_name = "hybrid_rule_only_routed"
|
||||
bundle.provider = extractor.llm_provider
|
||||
bundle.raw_output = {
|
||||
**bundle.raw_output,
|
||||
"extraction_mode": extractor.mode,
|
||||
"effective_extraction_mode": "rule_only",
|
||||
"requested_extraction_mode": extractor.mode,
|
||||
"ai_provider": extractor.llm_provider,
|
||||
"ai_model": extractor.model,
|
||||
"llm_skipped": True,
|
||||
"llm_skip_reason": reason,
|
||||
"page_type": context.page_type if context is not None else None,
|
||||
"rule_entity_count": len(bundle.entities),
|
||||
"rule_claim_count": len(bundle.claims),
|
||||
"llm_entity_count": 0,
|
||||
"llm_claim_count": 0,
|
||||
"agreement_claim_count": 0,
|
||||
"rule_only_claim_count": len(bundle.claims),
|
||||
"llm_only_claim_count": 0,
|
||||
"conflict_claim_count": 0,
|
||||
"comparison": {
|
||||
"both_agree": 0,
|
||||
"rule_only": len(bundle.claims),
|
||||
"llm_only": 0,
|
||||
"conflict": 0,
|
||||
"rejected_by_validation": 0,
|
||||
},
|
||||
}
|
||||
for claim in bundle.claims:
|
||||
claim.metadata = {
|
||||
**claim.metadata,
|
||||
"llm_skipped": True,
|
||||
"llm_skip_reason": reason,
|
||||
"agreement": "rule_only",
|
||||
}
|
||||
return bundle
|
||||
|
||||
|
||||
def llm_skip_reason(
|
||||
context: ExtractionPageContext | None,
|
||||
page_text: str,
|
||||
mode: str,
|
||||
) -> str | None:
|
||||
if mode != "hybrid" or context is None:
|
||||
return None
|
||||
page_type = str(context.page_type or "UnknownPage")
|
||||
clean_length = len(context.clean_text or page_text or "")
|
||||
if page_type in SKIP_LLM_PAGE_TYPES:
|
||||
return f"page type {page_type} is configured to skip LLM extraction"
|
||||
if page_type in RULE_ONLY_PAGE_TYPES:
|
||||
return f"page type {page_type} is routed to rule-only extraction"
|
||||
if page_type not in LLM_PAGE_TYPES:
|
||||
return f"page type {page_type} is not in the LLM page-type allowlist"
|
||||
if clean_length < MIN_CLEAN_TEXT_CHARS_FOR_LLM:
|
||||
return f"clean text is too short for LLM extraction: {clean_length} chars"
|
||||
if clean_length > MAX_CLEAN_TEXT_CHARS_FOR_LLM:
|
||||
return f"clean text is too long for LLM extraction: {clean_length} chars"
|
||||
return None
|
||||
|
||||
|
||||
def merge_bundles(
|
||||
rule_bundle: ExtractionBundle,
|
||||
llm_bundle: ExtractionBundle,
|
||||
extractor: HybridExtractor,
|
||||
) -> ExtractionBundle:
|
||||
merged_claims: dict[tuple[str, str, str], ExtractedClaim] = {}
|
||||
rule_by_subject_predicate: dict[tuple[str, str], list[ExtractedClaim]] = {}
|
||||
comparison = {
|
||||
"both_agree": 0,
|
||||
"rule_only": 0,
|
||||
"llm_only": 0,
|
||||
"conflict": 0,
|
||||
"rejected_by_validation": 0,
|
||||
}
|
||||
|
||||
for claim in clone_claims(rule_bundle.claims):
|
||||
key = claim_key(claim)
|
||||
merged_claims[key] = claim
|
||||
rule_by_subject_predicate.setdefault(subject_predicate_key(claim), []).append(claim)
|
||||
|
||||
for llm_claim in clone_claims(llm_bundle.claims):
|
||||
key = claim_key(llm_claim)
|
||||
existing = merged_claims.get(key)
|
||||
if existing is not None:
|
||||
comparison["both_agree"] += 1
|
||||
merge_agreement(existing, llm_claim)
|
||||
continue
|
||||
|
||||
possible_conflicts = rule_by_subject_predicate.get(subject_predicate_key(llm_claim), [])
|
||||
if possible_conflicts:
|
||||
comparison["conflict"] += 1
|
||||
mark_conflict(llm_claim, possible_conflicts)
|
||||
for rule_claim in possible_conflicts:
|
||||
rule_claim.metadata = {
|
||||
**rule_claim.metadata,
|
||||
"agreement": "conflict",
|
||||
"conflict_status": "rule_llm_conflict",
|
||||
"review_required": True,
|
||||
"review_reason": "Rule and LLM produced different objects for the same subject and predicate",
|
||||
}
|
||||
else:
|
||||
comparison["llm_only"] += 1
|
||||
llm_claim.metadata = {
|
||||
**llm_claim.metadata,
|
||||
"agreement": "llm_only",
|
||||
"review_reason": "LLM-only claim; evidence and ontology validation required",
|
||||
}
|
||||
merged_claims[key] = llm_claim
|
||||
|
||||
for claim in merged_claims.values():
|
||||
if claim.metadata.get("agreement") == "rule_only":
|
||||
comparison["rule_only"] += 1
|
||||
|
||||
mode = extractor.mode
|
||||
return ExtractionBundle(
|
||||
entities=dedupe_entities([*clone_entities(rule_bundle.entities), *clone_entities(llm_bundle.entities)]),
|
||||
claims=list(merged_claims.values()),
|
||||
extractor_name="hybrid_rule_llm_extractor" if mode == "hybrid" else "compare_rule_llm_extractor",
|
||||
provider=extractor.llm_provider,
|
||||
raw_output={
|
||||
"extraction_mode": mode,
|
||||
"provider": extractor.llm_provider,
|
||||
"model": extractor.model,
|
||||
"rule_entity_count": len(rule_bundle.entities),
|
||||
"rule_claim_count": len(rule_bundle.claims),
|
||||
"llm_entity_count": len(llm_bundle.entities),
|
||||
"llm_claim_count": len(llm_bundle.claims),
|
||||
"agreement_claim_count": comparison["both_agree"],
|
||||
"rule_only_claim_count": comparison["rule_only"],
|
||||
"llm_only_claim_count": comparison["llm_only"],
|
||||
"conflict_claim_count": comparison["conflict"],
|
||||
"comparison": comparison,
|
||||
"rule_raw_output": safe_raw_output(rule_bundle.raw_output),
|
||||
"llm_raw_output": safe_raw_output(llm_bundle.raw_output),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def merge_agreement(rule_claim: ExtractedClaim, llm_claim: ExtractedClaim) -> None:
|
||||
rule_confidence = rule_claim.metadata.get("rule_confidence", rule_claim.confidence)
|
||||
llm_confidence = llm_claim.metadata.get("llm_confidence", llm_claim.confidence)
|
||||
rule_claim.confidence = max(rule_claim.confidence, llm_claim.confidence)
|
||||
rule_claim.evidence_text = llm_claim.evidence_text or rule_claim.evidence_text
|
||||
rule_claim.evidence_summary = llm_claim.evidence_summary or rule_claim.evidence_summary
|
||||
rule_claim.metadata = {
|
||||
**rule_claim.metadata,
|
||||
**llm_claim.metadata,
|
||||
"agreement": "rule_and_llm",
|
||||
"rule_confidence": rule_confidence,
|
||||
"llm_confidence": llm_confidence,
|
||||
"rule_claim_merge": True,
|
||||
}
|
||||
reasons = [rule_claim.confidence_reason, llm_claim.confidence_reason, "Rule and LLM agreed"]
|
||||
rule_claim.confidence_reason = "; ".join(reason for reason in reasons if reason)
|
||||
|
||||
|
||||
def mark_conflict(llm_claim: ExtractedClaim, rule_claims: list[ExtractedClaim]) -> None:
|
||||
llm_claim.metadata = {
|
||||
**llm_claim.metadata,
|
||||
"agreement": "conflict",
|
||||
"conflict_status": "rule_llm_conflict",
|
||||
"review_required": True,
|
||||
"review_reason": "Rule and LLM produced different objects for the same subject and predicate",
|
||||
"conflicting_rule_objects": [claim_object_key(claim) for claim in rule_claims],
|
||||
}
|
||||
|
||||
|
||||
def clone_bundle(bundle: ExtractionBundle) -> ExtractionBundle:
|
||||
return ExtractionBundle(
|
||||
entities=clone_entities(bundle.entities),
|
||||
claims=clone_claims(bundle.claims),
|
||||
extractor_name=bundle.extractor_name,
|
||||
provider=bundle.provider,
|
||||
raw_output=dict(bundle.raw_output),
|
||||
)
|
||||
|
||||
|
||||
def clone_entities(entities: list[ExtractedEntity]) -> list[ExtractedEntity]:
|
||||
return [replace(entity, attributes=dict(entity.attributes), metadata=dict(entity.metadata)) for entity in entities]
|
||||
|
||||
|
||||
def clone_claims(claims: list[ExtractedClaim]) -> list[ExtractedClaim]:
|
||||
return [replace(claim, metadata=dict(claim.metadata)) for claim in claims]
|
||||
|
||||
|
||||
def subject_predicate_key(claim: ExtractedClaim) -> tuple[str, str]:
|
||||
return (
|
||||
claim.subject_name.strip().lower(),
|
||||
claim.predicate.strip().lower(),
|
||||
)
|
||||
|
||||
|
||||
def claim_key(claim: ExtractedClaim) -> tuple[str, str, str]:
|
||||
subject, predicate = subject_predicate_key(claim)
|
||||
return subject, predicate, claim_object_key(claim)
|
||||
|
||||
|
||||
def claim_object_key(claim: ExtractedClaim) -> str:
|
||||
if claim.object_name:
|
||||
return claim.object_name.strip().lower()
|
||||
return json.dumps(claim.object_value, ensure_ascii=False, sort_keys=True, default=str).strip().lower()
|
||||
|
||||
|
||||
def safe_raw_output(raw_output: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
key: value
|
||||
for key, value in raw_output.items()
|
||||
if key not in {"candidate_entities", "candidate_claims"}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from crawler_platform.app.config.loader import ProjectConfig
|
||||
@@ -134,6 +135,9 @@ def validate_extraction_bundle(bundle: ExtractionBundle, config: ProjectConfig)
|
||||
claims = []
|
||||
rejected_claims = []
|
||||
for claim in bundle.claims:
|
||||
agreement = str(claim.metadata.get("agreement") or "")
|
||||
extraction_source = str(claim.metadata.get("extraction_source") or "")
|
||||
claim_status_for_row = status_for_claim(claim_status, agreement)
|
||||
claim.predicate = normalize_predicate(claim.predicate, config.ontology)
|
||||
claim.subject_type = normalize_entity_type(claim.subject_type, config.domain)
|
||||
claim.subject_name = normalize_entity_name(claim.subject_name, claim.subject_type, config.domain)
|
||||
@@ -147,36 +151,52 @@ def validate_extraction_bundle(bundle: ExtractionBundle, config: ProjectConfig)
|
||||
}
|
||||
reason = invalid_claim_reason(claim, config, allowed_types, page_context)
|
||||
if reason:
|
||||
rejected_claims.append(
|
||||
{
|
||||
"subject": claim.subject_name,
|
||||
"predicate": claim.predicate,
|
||||
"object": claim.object_name if claim.object_name is not None else claim.object_value,
|
||||
"reason": reason,
|
||||
"page_type": claim.metadata.get("page_type") or page_context.get("page_type"),
|
||||
"source_zone": claim.metadata.get("source_zone"),
|
||||
rejected = rejected_claim_payload(claim, reason, page_context)
|
||||
rejected_claims.append(rejected)
|
||||
if is_reviewable_claim_reason(reason):
|
||||
claim.metadata = {
|
||||
**claim.metadata,
|
||||
"claim_kind": claim_kind_for(claim_status_for_row, agreement),
|
||||
"validation_status": "candidate_claim",
|
||||
"review_required": True,
|
||||
"review_reason": reason,
|
||||
"schema_validated": False,
|
||||
}
|
||||
)
|
||||
claim.metadata["confidence_breakdown"] = claim_confidence_breakdown(
|
||||
claim,
|
||||
agreement,
|
||||
extraction_source,
|
||||
ontology_compatible=True,
|
||||
)
|
||||
claim.confidence = claim.metadata["confidence_breakdown"]["final_confidence"]
|
||||
claims.append(claim)
|
||||
continue
|
||||
claim.metadata = {
|
||||
**claim.metadata,
|
||||
"claim_kind": "rule_candidate" if claim_status == "rule_candidate" else "ai_claim",
|
||||
"validation_status": claim_status,
|
||||
"claim_kind": claim_kind_for(claim_status_for_row, agreement),
|
||||
"validation_status": claim_status_for_row,
|
||||
}
|
||||
breakdown = confidence_breakdown(
|
||||
llm_confidence=claim.confidence,
|
||||
evidence_found=bool(claim.metadata.get("evidence_found")),
|
||||
ontology_compatible=True,
|
||||
source_zone_allowed=bool(claim.metadata.get("source_zone_allowed")),
|
||||
)
|
||||
if agreement == "conflict":
|
||||
claim.metadata = {
|
||||
**claim.metadata,
|
||||
"validation_status": "candidate_claim",
|
||||
"review_required": True,
|
||||
"review_reason": claim.metadata.get("review_reason")
|
||||
or "Rule and LLM produced conflicting claims",
|
||||
}
|
||||
breakdown = claim_confidence_breakdown(claim, agreement, extraction_source)
|
||||
claim.metadata["confidence_breakdown"] = breakdown
|
||||
claim.confidence = breakdown["final_confidence"]
|
||||
claims.append(claim)
|
||||
|
||||
comparison = dict(bundle.raw_output.get("comparison") or {})
|
||||
if comparison:
|
||||
comparison["rejected_by_validation"] = len(rejected_claims)
|
||||
bundle.entities = entities
|
||||
bundle.claims = claims
|
||||
bundle.raw_output = {
|
||||
**bundle.raw_output,
|
||||
**({"comparison": comparison} if comparison else {}),
|
||||
"validation": {
|
||||
"claim_status": claim_status,
|
||||
"accepted_claim_count": len(claims),
|
||||
@@ -196,9 +216,19 @@ def validate_extraction_bundle(bundle: ExtractionBundle, config: ProjectConfig)
|
||||
|
||||
def claim_status_for_bundle(bundle: ExtractionBundle) -> str:
|
||||
mode = str(bundle.raw_output.get("extraction_mode", "")).lower()
|
||||
effective_mode = str(bundle.raw_output.get("effective_extraction_mode", "")).lower()
|
||||
fallback = str(bundle.raw_output.get("fallback", "")).lower()
|
||||
if bundle.provider == "rule_based" or "rule_fallback" in bundle.extractor_name or mode == "fallback" or fallback:
|
||||
if (
|
||||
bundle.provider == "rule_based"
|
||||
or "rule_fallback" in bundle.extractor_name
|
||||
or mode == "fallback"
|
||||
or effective_mode == "rule_only"
|
||||
or bundle.raw_output.get("llm_skipped")
|
||||
or fallback
|
||||
):
|
||||
return "rule_candidate"
|
||||
if mode == "compare":
|
||||
return "candidate_claim"
|
||||
page_context = dict(bundle.raw_output.get("page_context") or {})
|
||||
page_type = str(page_context.get("page_type") or "")
|
||||
if page_type in {"UnknownPage", "SearchPage", "CategoryPage", "BoardPage"}:
|
||||
@@ -206,6 +236,77 @@ def claim_status_for_bundle(bundle: ExtractionBundle) -> str:
|
||||
return "validated_claim"
|
||||
|
||||
|
||||
def status_for_claim(bundle_status: str, agreement: str) -> str:
|
||||
if bundle_status in {"rule_candidate", "candidate_claim"}:
|
||||
return bundle_status
|
||||
if agreement == "rule_only":
|
||||
return "rule_candidate"
|
||||
if agreement == "conflict":
|
||||
return "candidate_claim"
|
||||
return bundle_status
|
||||
|
||||
|
||||
def claim_kind_for(claim_status: str, agreement: str) -> str:
|
||||
if claim_status == "rule_candidate" or agreement == "rule_only":
|
||||
return "rule_candidate"
|
||||
if agreement == "conflict":
|
||||
return "conflict_candidate"
|
||||
return "ai_claim"
|
||||
|
||||
|
||||
def claim_confidence_breakdown(
|
||||
claim: ExtractedClaim,
|
||||
agreement: str,
|
||||
extraction_source: str,
|
||||
*,
|
||||
ontology_compatible: bool = True,
|
||||
) -> dict[str, float]:
|
||||
rule_confidence = _optional_float(claim.metadata.get("rule_confidence"))
|
||||
llm_confidence = _optional_float(claim.metadata.get("llm_confidence"))
|
||||
if llm_confidence is None:
|
||||
llm_confidence = 0.0 if extraction_source == "rule" else claim.confidence
|
||||
return confidence_breakdown(
|
||||
llm_confidence=llm_confidence,
|
||||
evidence_found=bool(claim.metadata.get("evidence_found")),
|
||||
ontology_compatible=ontology_compatible,
|
||||
source_zone_allowed=bool(claim.metadata.get("source_zone_allowed")),
|
||||
rule_confidence=rule_confidence,
|
||||
rule_agreement=agreement == "rule_and_llm",
|
||||
)
|
||||
|
||||
|
||||
def rejected_claim_payload(
|
||||
claim: ExtractedClaim,
|
||||
reason: str,
|
||||
page_context: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"subject": claim.subject_name,
|
||||
"predicate": claim.predicate,
|
||||
"object": claim.object_name if claim.object_name is not None else claim.object_value,
|
||||
"reason": reason,
|
||||
"page_type": claim.metadata.get("page_type") or page_context.get("page_type"),
|
||||
"source_zone": claim.metadata.get("source_zone"),
|
||||
}
|
||||
|
||||
|
||||
def is_reviewable_claim_reason(reason: str) -> bool:
|
||||
return reason in {
|
||||
"claim has no source zone",
|
||||
"evidence was not found in clean source zones",
|
||||
"claim has no evidence",
|
||||
} or reason.startswith("source zone is not claim-allowed")
|
||||
|
||||
|
||||
def _optional_float(value: Any) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def allowed_entity_types(config: ProjectConfig) -> set[str]:
|
||||
configured = set(config.target_entities)
|
||||
ontology_types = set((config.ontology or {}).get("entity_types", []))
|
||||
@@ -267,6 +368,8 @@ def invalid_claim_reason(
|
||||
if claim.object_name is not None:
|
||||
if claim.object_type not in allowed_types:
|
||||
return "object type is outside ontology"
|
||||
if claim.object_type == "Brand" and looks_like_price_text(claim.object_name):
|
||||
return "brand object looks like a price"
|
||||
if not is_meaningful_text(claim.object_name):
|
||||
return "object is empty, generic, or boilerplate"
|
||||
if canonical_entity_key(claim.subject_name, claim.subject_type) == canonical_entity_key(
|
||||
@@ -316,6 +419,19 @@ def is_meaningful_value(value: Any) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def looks_like_price_text(value: str | None) -> bool:
|
||||
if not value:
|
||||
return False
|
||||
clean = " ".join(str(value).strip().split())
|
||||
return bool(
|
||||
re.fullmatch(
|
||||
r"(?:[$€£]\s*)?\d{1,3}(?:,\d{3})*(?:\.\d+)?\s*(?:원|KRW|USD|EUR|JPY|\$|€|£)?",
|
||||
clean,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def is_meaningful_text(value: str | None, allow_short: bool = False) -> bool:
|
||||
if value is None:
|
||||
return False
|
||||
|
||||
@@ -110,23 +110,31 @@ def confidence_breakdown(
|
||||
evidence_found: bool,
|
||||
ontology_compatible: bool,
|
||||
source_zone_allowed: bool,
|
||||
rule_confidence: float | None = None,
|
||||
rule_agreement: bool = False,
|
||||
source_trust: float | None = None,
|
||||
) -> dict[str, float]:
|
||||
schema_confidence = 1.0
|
||||
evidence_confidence = 0.95 if evidence_found else 0.0
|
||||
ontology_confidence = 0.95 if ontology_compatible else 0.0
|
||||
zone_confidence = 0.9 if source_zone_allowed else 0.0
|
||||
rule_value = rule_confidence if rule_confidence is not None else 0.0
|
||||
agreement_confidence = 0.95 if rule_agreement else 0.0
|
||||
trust = source_trust if source_trust is not None else 0.8
|
||||
final = (
|
||||
llm_confidence * 0.35
|
||||
+ schema_confidence * 0.15
|
||||
+ evidence_confidence * 0.2
|
||||
+ ontology_confidence * 0.2
|
||||
llm_confidence * 0.30
|
||||
+ rule_value * 0.10
|
||||
+ agreement_confidence * 0.10
|
||||
+ schema_confidence * 0.10
|
||||
+ evidence_confidence * 0.18
|
||||
+ ontology_confidence * 0.12
|
||||
+ zone_confidence * 0.05
|
||||
+ trust * 0.05
|
||||
)
|
||||
return {
|
||||
"llm_confidence": round(llm_confidence, 4),
|
||||
"rule_confidence": round(rule_value, 4),
|
||||
"rule_agreement_confidence": agreement_confidence,
|
||||
"schema_confidence": schema_confidence,
|
||||
"evidence_confidence": evidence_confidence,
|
||||
"ontology_confidence": ontology_confidence,
|
||||
|
||||
@@ -284,6 +284,7 @@ class GraphResearchLoop:
|
||||
analyzed = False
|
||||
claim_count = 0
|
||||
entity_count = 0
|
||||
extraction_summary: dict[str, Any] = {}
|
||||
if (
|
||||
fetch_result.crawl_status == "success"
|
||||
and parser_result.extraction_status != "failed"
|
||||
@@ -311,6 +312,7 @@ class GraphResearchLoop:
|
||||
analyzed = True
|
||||
claim_count = len(claims)
|
||||
entity_count = len(bundle.entities)
|
||||
extraction_summary = extraction_summary_from_raw(bundle.raw_output)
|
||||
|
||||
return {
|
||||
"status": "explored",
|
||||
@@ -320,6 +322,7 @@ class GraphResearchLoop:
|
||||
"analyzed": analyzed,
|
||||
"claim_count": claim_count,
|
||||
"entity_count": entity_count,
|
||||
"extraction": extraction_summary,
|
||||
"enqueued": enqueued,
|
||||
"scored_links": scored_links[:20],
|
||||
}
|
||||
@@ -369,3 +372,15 @@ def normalize_url(url: str) -> str:
|
||||
|
||||
def normalized_host(url: str) -> str:
|
||||
return urlparse(str(url)).netloc.lower()
|
||||
|
||||
|
||||
def extraction_summary_from_raw(raw_output: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"extraction_mode": raw_output.get("extraction_mode"),
|
||||
"effective_extraction_mode": raw_output.get("effective_extraction_mode"),
|
||||
"llm_skipped": bool(raw_output.get("llm_skipped")),
|
||||
"llm_skip_reason": raw_output.get("llm_skip_reason"),
|
||||
"fallback_used": bool(raw_output.get("fallback")),
|
||||
"agreement_claim_count": int(raw_output.get("agreement_claim_count") or 0),
|
||||
"conflict_claim_count": int(raw_output.get("conflict_claim_count") or 0),
|
||||
}
|
||||
|
||||
@@ -167,6 +167,7 @@ class PerfumeRuleBasedExtractor(GenericRuleBasedExtractor):
|
||||
brand = next((entity for entity in entities if entity.entity_type == "Brand"), None)
|
||||
for card in product_cards:
|
||||
if brand:
|
||||
evidence = brand_evidence_for_product(brand, str(card["name"]), page_text) or brand.evidence_text or brand.name
|
||||
claims.append(
|
||||
ExtractedClaim(
|
||||
str(card["name"]),
|
||||
@@ -174,7 +175,7 @@ class PerfumeRuleBasedExtractor(GenericRuleBasedExtractor):
|
||||
"hasBrand",
|
||||
brand.name,
|
||||
"Brand",
|
||||
evidence_text=brand.evidence_text or brand.name,
|
||||
evidence_text=evidence,
|
||||
confidence=0.72,
|
||||
confidence_reason="site brand inferred from listing page",
|
||||
)
|
||||
@@ -199,6 +200,7 @@ class PerfumeRuleBasedExtractor(GenericRuleBasedExtractor):
|
||||
claims: list[ExtractedClaim] = []
|
||||
brand = next((entity for entity in entities if entity.entity_type == "Brand"), None)
|
||||
if brand:
|
||||
evidence = brand_evidence_for_product(brand, perfume.name, page_text) or brand.evidence_text or brand.name
|
||||
claims.append(
|
||||
ExtractedClaim(
|
||||
perfume.name,
|
||||
@@ -206,7 +208,7 @@ class PerfumeRuleBasedExtractor(GenericRuleBasedExtractor):
|
||||
"hasBrand",
|
||||
brand.name,
|
||||
"Brand",
|
||||
evidence_text=brand.evidence_text or brand.name,
|
||||
evidence_text=evidence,
|
||||
confidence=0.78,
|
||||
confidence_reason="brand pattern matched",
|
||||
)
|
||||
@@ -273,14 +275,16 @@ def extract_brand(page_text: str, product_name: str) -> str | None:
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, page_text, flags=re.IGNORECASE)
|
||||
if match:
|
||||
return cleanup_value(match.group("brand"))
|
||||
inferred = infer_site_brand(page_text)
|
||||
brand = valid_brand(cleanup_value(match.group("brand")))
|
||||
if brand:
|
||||
return brand
|
||||
inferred = infer_site_brand(page_text, product_name)
|
||||
if inferred:
|
||||
return inferred
|
||||
lines = [line.strip() for line in page_text.splitlines() if line.strip()]
|
||||
if len(lines) >= 2 and lines[1].lower() not in product_name.lower():
|
||||
candidate = cleanup_value(lines[1])
|
||||
if len(candidate) <= 80 and not looks_like_navigation(candidate) and product_line_score(candidate) <= 0:
|
||||
if valid_brand(candidate) and len(candidate) <= 80 and not looks_like_navigation(candidate) and product_line_score(candidate) <= 0:
|
||||
return candidate
|
||||
return None
|
||||
|
||||
@@ -301,6 +305,19 @@ def product_line_score(value: str) -> int:
|
||||
return score
|
||||
|
||||
|
||||
def looks_like_price(value: str | None) -> bool:
|
||||
if not value:
|
||||
return False
|
||||
clean = cleanup_value(str(value))
|
||||
return bool(
|
||||
re.fullmatch(
|
||||
r"(?:[$€£]\s*)?\d{1,3}(?:,\d{3})*(?:\.\d+)?\s*(?:원|KRW|USD|EUR|JPY|\$|€|£)?",
|
||||
clean,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def looks_like_metric_or_price(value: str) -> bool:
|
||||
clean = value.replace(",", "").strip()
|
||||
if re.fullmatch(r"\d+(?:\.\d+)?", clean):
|
||||
@@ -385,7 +402,10 @@ def dedupe_product_cards(cards: list[dict[str, object]]) -> list[dict[str, objec
|
||||
return result
|
||||
|
||||
|
||||
def infer_site_brand(page_text: str) -> str | None:
|
||||
def infer_site_brand(page_text: str, product_name: str = "") -> str | None:
|
||||
brand_context = f"{product_name}\n{page_text[:1200]}".lower()
|
||||
if "forment" in brand_context or "포맨트" in brand_context:
|
||||
return "FORMENT"
|
||||
if "912 공식 홈페이지" in page_text or "912" in page_text[:500]:
|
||||
return "912"
|
||||
return None
|
||||
@@ -394,9 +414,19 @@ def infer_site_brand(page_text: str) -> str | None:
|
||||
def valid_brand(value: str | None) -> str | None:
|
||||
if not value or is_template_placeholder(value):
|
||||
return None
|
||||
if looks_like_price(value) or looks_like_metric_or_price(value):
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
def brand_evidence_for_product(brand: ExtractedEntity, product_name: str, page_text: str) -> str | None:
|
||||
if brand.name == "FORMENT" and "포맨트" in product_name:
|
||||
return product_name
|
||||
if brand.name.lower() in page_text.lower():
|
||||
return brand.name
|
||||
return None
|
||||
|
||||
|
||||
def extract_field_values(field: str, page_text: str) -> list[tuple[str, str]]:
|
||||
if field in NOTE_LABELS:
|
||||
return extract_labeled_values(page_text, NOTE_LABELS[field])
|
||||
|
||||
Reference in New Issue
Block a user