This commit is contained in:
lasta
2026-05-22 00:22:03 +09:00
parent 8d77bc659f
commit d841fb823a
49 changed files with 2732 additions and 3763 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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"}
}

View File

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

View File

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

View File

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

View File

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