Compare commits

...

2 Commits

Author SHA1 Message Date
lasta
c89edecf8c 1 2026-05-22 00:23:31 +09:00
lasta
d841fb823a graph 2026-05-22 00:22:03 +09:00
49 changed files with 2733 additions and 3763 deletions

3
.gitignore vendored
View File

@@ -7,4 +7,7 @@ __pycache__/
*.sqlite3-*
uvicorn.*.log
.server-logs/
.env
.vs
ontology_platform/.env
/ontology_platform/data/ui_projects.json

View File

@@ -2,12 +2,13 @@ PHASE_PLANNING.md
요청된 작업을 다음 순서로 진행한다.
0. 이전 PHASE_INDEX.md 다른이름 으로 변경. (예 : 26_05_19_PHASE_INDEX.md)
1. 요청된 작업 폴더 생성 (예 : 26_05_19_기능개선 )
2. 해당 작업을 분석후 Phase 계획
3. Phase별로 상세 작업 계획을 세운후 PHASE_INDEX.md 새로 생성후 작성. 작성 방법은 아래 # PHASE INDEX EXAMPLE 참조
4. Phase별 상세작업 계획은 1에 의해 생성된 폴더아래 파일을 만들어 저장( 예 : phase_01_001_pipeline.md)
5. 생성된 파일은 PHASE_INDEX.md의 해당 PHASE의 FILE: 에 기록
0. 이전 PHASE_INDEX.md 파일은 백업을 목적으로 다른이름 으로 변경. (예 : 26_05_19_PHASE_INDEX.md)
1. 새로운 PHASE_INDEX.md 파일 생성. 이전 내용은 완전히 삭제된 상태로 시작.
2. 새로운 작업은 반드시 새로운 폴더를 생성해서 요청된 작업 폴더 생성 (예 : 26_05_19_기능개선 )
3. 해당 작업을 분석후 Phase 계획
4. Phase별 상세 작업 계획을 세운후 PHASE_INDEX.md 파일에 작성. 작성 방법은 아래 # PHASE INDEX EXAMPLE 참조. 세부 항목의 끝에는 반드시 [TODO] 표시를 해서 작업이 완료되지 않았음을 명확히 한다. (예 : 1) Source Crawl, Page Clean [TODO])
5. Phase별 상세작업 계획은 2에 의해 생성된 폴더아래 파일을 만들어 저장( 예 : phase_01_001_pipeline.md)
6. 5번 항목에 의해 생성된 파일은 PHASE_INDEX.md의 해당 PHASE의 FILE: 에 기록해 참조 하도록 한다. (예 : FILE: ./26_05_19_기능개선/phase_01_001_pipeline.md)
# PHASE INDEX EXAMPLE

View File

@@ -64,7 +64,7 @@ phase 파일은 상태 기록 용도로 사용하지 않는다.
를 의미한다.
이 경우 반드시 작업 전,후 PHASE_INDEX.md에 아래 예처럼 기록한다.
이 경우 반드시 작업 전,후 PHASE_INDEX.md파일의 해당 phase의 세부항목인 [진행중] 항목 아래에 다음 처럼 기록한다.
PHASE 3. Claim Review 개선
FILE: ./26_05_19_기능개선/phase_03_002_metadata_display.md

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

View File

@@ -0,0 +1,415 @@
# Phase 7. Hybrid Rule + LLM Extraction
작성일: 2026-05-21
범위: `ontology_platform`에 포함된 product backend, 특히 `crawler_platform/app/core/extractor`, crawl/research API, Review/Page Analysis UI.
주의: 이 phase는 OntoCast core engine을 바꾸지 않는다. 룰 기반 product extraction과 LLM JSON extraction을 product backend 레벨에서 결합한다. `/process` OntoCast workflow는 후처리/RDF 변환 후보로만 연결한다.
## 목표
룰 기반 추출의 장점인 빠름, 비용 없음, 예측 가능성, 명확한 패턴 인식을 살리고, LLM 추출의 장점인 의미 해석, 타입 추론, 관계 추출, 한국어/비정형 문장 대응력을 더한다.
핵심 원칙:
- 룰은 먼저 실행되는 baseline extractor이자 검증 가드레일이다.
- LLM은 의미 추출기이며, evidence/ontology/source zone 검증을 통과해야 한다.
- LLM 실패는 전체 실패가 아니라 rule fallback으로 처리한다.
- 룰과 LLM이 같은 claim을 찾으면 신뢰도를 올린다.
- 룰과 LLM이 충돌하면 자동 승인하지 않고 review로 보낸다.
## 현재 기준선
현재 확인된 동작:
- `ont_platform/api/routes/extraction.py`
- `/api/v1/extract/url`, `/api/v1/process/url`, `/process/url`
- `LightweightExtractor(use_llm=False)` 고정
- 룰 기반 기초 후보만 생성
- `ont_platform/core/crawler/jobs.py`
- `/api/v1/crawl/jobs`
- `LightweightExtractor(use_llm=False)` 고정
- 기본 수집 + 기초 후보 저장
- `crawler_platform/app/core/extractor/factory.py`
- `provider in {"openai", "ollama", "lm_studio"}`이면 `LLMJsonExtractor`
- 그 외에는 domain별 rule extractor
- `crawler_platform/app/core/extractor/ai_provider.py`
- LLM primary, compact retry, JSON repair, rule fallback이 이미 일부 존재
- LLM 결과에 rule claim을 merge하는 `_merge_rule_fallback_claims()`가 이미 존재
- `crawler_platform/app/core/extractor/validation.py`
- evidence zone, ontology predicate, source zone, confidence breakdown 검증이 존재
- `web/frontend/src/pages/CrawlPage.tsx`, `ResearchPage.tsx`
- request type에는 `extractor_provider` 필드가 있으나 화면에서 선택 UI는 없음
## Phase 7.1 Baseline Audit
목표: 기존 동작을 깨지 않기 위해 현재 추출 결과와 저장 구조를 고정한다.
작업:
- `rule_based`, `lm_studio`, `openai`, `ollama` provider별 request/response 샘플을 만든다.
- crawl, crawl-site, research/run 경로에서 extractor가 어떻게 선택되는지 문서화한다.
- `Claim`, `Entity`, `ExtractionLog`에 저장되는 `extractor_name`, `provider`, `metadata_json`, `confidence_breakdown` 구조를 샘플로 기록한다.
- LM Studio가 꺼진 상태, 켜진 상태, 모델명 누락 상태를 각각 재현한다.
주요 파일:
- `crawler_platform/app/core/extractor/factory.py`
- `crawler_platform/app/core/extractor/ai_provider.py`
- `crawler_platform/app/core/extractor/rule_based.py`
- `crawler_platform/app/core/database/repository.py`
- `crawler_platform/app/api/routes.py`
완료 기준:
- 기존 `rule_based``lm_studio` 동작이 재현 가능하다.
- 최소 1개 상품 페이지 fixture로 rule 결과와 LLM 결과 샘플이 있다.
- LM Studio 장애 시 현재 fallback 결과가 확인되어 있다.
## Phase 7.2 Extraction Mode Contract
목표: provider와 실행 전략을 분리한다.
현재 문제:
- `extractor_provider`가 provider이면서 실행 전략 역할도 한다.
- 사용자는 rule only, LLM only, hybrid, compare를 명시적으로 선택할 수 없다.
제안 계약:
```json
{
"extraction_mode": "hybrid",
"extractor_provider": "lm_studio",
"extractor_model": "deepseek-r1-distill-qwen-7b",
"extractor_base_url": "http://localhost:1234/v1",
"fallback_to_rules": true
}
```
지원 mode:
- `rule_only`: rule extractor만 실행
- `llm_only`: LLM extractor만 실행, fallback 선택 가능
- `hybrid`: rule 먼저 실행, LLM 실행, 병합/검증
- `compare`: rule 결과와 LLM 결과를 모두 보존하고 차이를 metadata/log에 저장
호환성:
- 기존 `extractor_provider: "rule_based"``extraction_mode="rule_only"`로 해석한다.
- 기존 `extractor_provider: "lm_studio" | "openai" | "ollama"`는 당분간 `extraction_mode="hybrid"`로 해석한다.
- 기존 UI가 provider를 보내지 않는 경우 기본값은 `hybrid + lm_studio`로 유지한다.
수정 파일:
- `crawler_platform/app/api/routes.py`
- `web/frontend/src/lib/api/crawl.ts`
- `web/frontend/src/lib/api/research.ts`
완료 기준:
- 기존 요청이 깨지지 않는다.
- 새 요청 필드로 `rule_only`, `llm_only`, `hybrid`, `compare`가 구분된다.
- API response/log에 실제 실행 mode가 남는다.
## Phase 7.3 Hybrid Extractor
목표: 명시적인 `HybridExtractor`를 추가한다.
신규 파일:
- `crawler_platform/app/core/extractor/hybrid.py`
실행 순서:
1. domain에 맞는 rule extractor 실행
2. LLM extractor 실행
3. entity dedupe
4. claim dedupe
5. rule-LLM agreement 계산
6. conflict 계산
7. metadata에 extraction mode와 comparison 결과 기록
8. validation pipeline으로 전달
필수 metadata:
```json
{
"extraction_mode": "hybrid",
"rule_entity_count": 12,
"rule_claim_count": 8,
"llm_entity_count": 15,
"llm_claim_count": 13,
"agreement_claim_count": 6,
"rule_only_claim_count": 2,
"llm_only_claim_count": 7,
"conflict_claim_count": 1
}
```
병합 규칙:
- 동일 subject/predicate/object claim은 하나로 합친다.
- rule과 LLM이 모두 찾은 claim은 `agreement="rule_and_llm"`로 표시한다.
- rule만 찾은 claim은 `agreement="rule_only"``claim_kind="rule_candidate"`로 표시한다.
- LLM만 찾은 claim은 evidence 검증 전까지 `agreement="llm_only"`로 표시한다.
- 같은 subject/predicate인데 object가 다르면 `conflict_status="rule_llm_conflict"`로 표시하고 review로 보낸다.
수정 파일:
- `crawler_platform/app/core/extractor/factory.py`
- `crawler_platform/app/core/extractor/ai_provider.py`
- `crawler_platform/app/core/extractor/base.py`
완료 기준:
- `extractor_provider="hybrid"` 또는 `extraction_mode="hybrid"`로 실행 가능하다.
- LLM 실패 시 rule 결과만으로 성공 response가 나온다.
- rule/LLM agreement가 claim metadata에 남는다.
## Phase 7.4 Confidence And Validation Upgrade
목표: LLM hallucination을 줄이고, rule agreement를 신뢰도에 반영한다.
현재 confidence 구성:
- `llm_confidence`
- `schema_confidence`
- `evidence_confidence`
- `ontology_confidence`
- `source_zone_confidence`
- `source_trust`
- `final_confidence`
추가 항목:
```json
{
"rule_confidence": 0.75,
"rule_agreement_confidence": 0.95,
"llm_confidence": 0.82,
"evidence_confidence": 0.95,
"ontology_confidence": 0.95,
"source_zone_confidence": 0.9,
"final_confidence": 0.89
}
```
정책:
- rule+LLM agreement가 있으면 confidence bonus를 준다.
- LLM-only claim은 evidence가 없으면 자동 승인하지 않는다.
- source zone을 찾지 못한 claim은 review로 보낸다.
- ontology에 없는 predicate는 reject한다.
- rule-only claim은 기본적으로 `rule_candidate`로 남긴다.
- 충돌 claim은 `review_required=true`로 남긴다.
수정 파일:
- `crawler_platform/app/core/ontology/relation_schema.py`
- `crawler_platform/app/core/extractor/validation.py`
- `crawler_platform/app/core/database/repository.py`
완료 기준:
- Review 화면에서 `confidence_breakdown.rule_agreement_confidence`를 볼 수 있다.
- evidence 없는 LLM-only claim이 `validated_claim`으로 자동 저장되지 않는다.
- rule+LLM 일치 claim은 더 높은 final confidence를 받는다.
## Phase 7.5 Compare Mode
목표: rule 결과와 LLM 결과를 나란히 비교하여 품질 튜닝에 사용한다.
작업:
- `compare` mode에서 rule bundle과 LLM bundle을 모두 실행한다.
- 최종 저장은 병합 결과로 하되, `ExtractionLog.raw_output`에 원본 두 결과를 보존한다.
- Page Analysis에서 diff summary를 표시한다.
diff category:
- `both_agree`
- `rule_only`
- `llm_only`
- `conflict`
- `rejected_by_validation`
UI 표시:
- Page Analysis: page별 extractor run 요약, rule/LLM candidate count, conflict count
- Review: claim detail에서 agreement badge 표시
수정 파일:
- `crawler_platform/app/core/database/models.py`
- `crawler_platform/app/core/database/repository.py`
- `crawler_platform/app/api/routes.py`
- `web/frontend/src/pages/PageAnalysisPage.tsx`
- `web/frontend/src/pages/ReviewPage.tsx`
- `web/frontend/src/lib/api/platform.ts`
완료 기준:
- 같은 페이지에서 rule과 LLM의 차이를 확인할 수 있다.
- `llm_only``conflict` claim을 review에서 필터링할 수 있다.
## Phase 7.6 UI Controls
목표: 사용자가 화면에서 mode/provider/model/base URL을 선택할 수 있게 한다.
대상 화면:
- Crawl Page
- Research Page
추가 컨트롤:
- Extraction mode select
- Rule only
- Hybrid
- LLM only
- Compare
- LLM provider select
- LM Studio
- OpenAI
- Ollama
- Model input
- Base URL input
- Fallback to rules toggle
- Optional model list refresh button
동작:
- mode가 `rule_only`이면 provider/model/base URL 입력을 숨긴다.
- provider가 `lm_studio`이면 기본 base URL은 `http://localhost:1234/v1`이다.
- provider가 `ollama`이면 기본 base URL은 `http://localhost:11434/api/chat`이다.
- 모델 목록은 `/extractors/models`를 사용한다.
수정 파일:
- `web/frontend/src/pages/CrawlPage.tsx`
- `web/frontend/src/pages/ResearchPage.tsx`
- `web/frontend/src/lib/api/crawl.ts`
- `web/frontend/src/lib/api/research.ts`
완료 기준:
- UI에서 mode/provider/model/base URL을 지정할 수 있다.
- 지정한 값이 `/crawl-site/by-project`, `/research/run/by-project` 요청에 포함된다.
- rule only 선택 시 LM Studio가 꺼져 있어도 작업이 시작된다.
## Phase 7.7 Smart Routing Policy
목표: 모든 페이지에 LLM을 쓰지 않고 가치 있는 페이지에 집중한다.
정책:
- `ProductPage`: hybrid 기본
- `BrandStoryPage`: hybrid 기본
- `ReviewPage`: hybrid 또는 llm_only
- `CategoryPage`: rule_only 또는 skip LLM
- `SearchPage`: skip LLM
- `BoardPage`: rule_only 또는 review 후보
- 본문 길이가 너무 짧으면 rule_only
- rule 결과가 충분하고 deterministic field만 필요한 경우 LLM 생략 가능
- research goal이 있으면 LLM 우선
추가 설정:
```json
{
"llm_page_types": ["ProductPage", "BrandStoryPage", "ReviewPage"],
"skip_llm_page_types": ["CategoryPage", "SearchPage"],
"min_clean_text_chars_for_llm": 300,
"max_llm_pages_per_job": 30
}
```
수정 파일:
- `crawler_platform/app/core/crawler/site_crawler.py`
- `crawler_platform/app/core/crawler/pipeline.py`
- `crawler_platform/app/core/research/graph_research_loop.py`
완료 기준:
- LLM 호출 수가 page type 정책에 따라 제한된다.
- 중요 페이지는 hybrid 추출을 받는다.
- crawl job metadata에 LLM skipped reason이 남는다.
## Phase 7.8 OntoCast `/process` Handoff
목표: product crawl/research 결과를 OntoCast RDF 변환과 연결한다.
역할 분리:
- product crawl/research: web acquisition, entity/claim 후보 수집
- validation/review: claim 품질 관리
- `/process`: 검증된 문서나 claim 묶음을 RDF/Turtle로 정리하는 후처리
작업:
- validated claim 묶음을 OntoCast input JSON으로 변환한다.
- 프로젝트 단위 또는 selected document 단위로 `/process`를 호출할 수 있게 한다.
- `/process` 결과 Turtle을 export/graph view와 연결한다.
완료 기준:
- 사용자가 검증된 claim subset을 RDF/Turtle로 내보낼 수 있다.
- `/process`는 모든 crawl page마다 자동 실행되지 않는다.
- 비용 큰 LLM workflow는 명시적 후처리로만 실행된다.
## Phase 7.9 Tests And Observability
목표: rule/LLM/hybrid/compare 동작을 재현 가능하게 만든다.
테스트:
- rule only는 LM Studio 없이 통과
- llm only는 mock LLM으로 deterministic response 검증
- hybrid는 rule+LLM agreement metadata 생성
- LLM 실패 시 fallback으로 성공
- evidence 없는 LLM-only claim은 자동 승인되지 않음
- conflict claim은 review_required가 true
- UI payload에 mode/provider/model/base URL 포함
관측성:
- ExtractionLog에 mode/provider/model/base URL 저장
- job metadata에 LLM call count, fallback count, skipped count 저장
- Review/Page Analysis에서 agreement/fallback/conflict를 표시
완료 기준:
- local LM Studio가 꺼져 있어도 rule/hybrid fallback 테스트가 통과한다.
- mock LLM 기반 테스트가 CI에서 안정적으로 돈다.
- crawl/research 결과에서 어떤 extractor가 어떤 이유로 사용됐는지 추적 가능하다.
## 최종 Acceptance Gate
- [ ] UI에서 Rule only, Hybrid, LLM only, Compare 선택 가능
- [ ] Hybrid mode가 rule baseline과 LLM semantic extraction을 병합
- [ ] LLM 장애 시 rule fallback으로 job이 실패하지 않음
- [ ] evidence 없는 LLM-only claim이 자동 validated로 들어가지 않음
- [ ] rule+LLM agreement가 confidence에 반영됨
- [ ] conflict claim이 review_required로 표시됨
- [ ] Page Analysis에서 rule/LLM 비교 결과 확인 가능
- [ ] Review에서 extraction method, agreement, confidence breakdown 확인 가능
- [ ] `/process`는 후처리 RDF 변환 경로로 분리 유지
## 권장 구현 순서
1. Phase 7.1 Baseline Audit
2. Phase 7.2 Extraction Mode Contract
3. Phase 7.3 Hybrid Extractor
4. Phase 7.4 Confidence And Validation Upgrade
5. Phase 7.6 UI Controls
6. Phase 7.5 Compare Mode
7. Phase 7.7 Smart Routing Policy
8. Phase 7.8 OntoCast `/process` Handoff
9. Phase 7.9 Tests And Observability

View File

@@ -87,3 +87,14 @@ FILE: ./26_05_19_engine_respect_plan/phase_06_001_maintenance_loop_operations.md
2) Analyst/Researcher/Curator/Auditor/Fixer/Advisor 梨낆엫 ?뺤쓽 [?꾨즺]
3) `auth`, `audit`, `billing`, `realtime` 珥덉븞 紐⑤뱢???댁쁺 寃쎄퀎 ?뺣━ [?꾨즺]
4) destructive fix???щ엺 ?뱀씤 寃뚯씠?몃? 諛섎뱶???듦낵?섎룄濡??ㅺ퀎 [?꾨즺]
---
PHASE 7. Hybrid Rule + LLM Extraction
FILE: ./26_05_19_engine_respect_plan/phase_07_001_hybrid_rule_llm_extraction.md
1) rule baseline, LLM extraction, fallback, validation, Review UI 흐름을 기준선으로 고정 [신규]
2) `rule_only`, `llm_only`, `hybrid`, `compare` extraction mode 계약 정의 [신규]
3) product backend에 명시적 HybridExtractor와 rule/LLM agreement metadata 추가 [신규]
4) confidence breakdown에 rule agreement와 conflict/review 정책 반영 [신규]
5) Crawl/Research UI에서 mode/provider/model/base URL 선택 지원 [신규]

View File

@@ -49,6 +49,7 @@ from ont_platform.api.product_backend import include_product_backend # noqa: E4
platform_config = importlib.import_module("ont_platform.config")
logger = logging.getLogger(__name__)
_startup_error: str | None = None
def _resolve_ontocast_version() -> str:
@@ -124,8 +125,14 @@ def _include_phase_routers(app: FastAPI) -> None:
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"""FastAPI lifespan: build ToolBox + workflow once on startup."""
global _startup_error
settings = platform_config.load_settings()
await initialize_app_context(settings)
try:
await initialize_app_context(settings)
_startup_error = None
except Exception as exc: # noqa: BLE001
_startup_error = str(exc)
logger.exception("App context initialization failed; product backend will remain available")
try:
yield
finally:
@@ -149,8 +156,23 @@ def create_app() -> FastAPI:
# ─── /health ──────────────────────────────────────────────────────
@app.get("/health", tags=["meta"])
async def health(ctx: Annotated[AppContext, Depends(get_app_context)]) -> JSONResponse:
"""Liveness check. 503 if the LLM isn't wired."""
async def health() -> JSONResponse:
"""Liveness check for the HTTP service and optional LLM readiness."""
settings = platform_config.load_settings()
if _startup_error:
return JSONResponse(
status_code=503,
content={
"status": "degraded",
"error": _startup_error,
"platform_version": PLATFORM_VERSION,
"ontocast_version": ONTOCAST_VERSION,
"phase": int(settings.phase),
"storage_backend": settings.storage_backend,
},
)
ctx = get_app_context()
if ctx.tools.llm is None:
return JSONResponse(
status_code=503,
@@ -170,8 +192,16 @@ def create_app() -> FastAPI:
# ─── /info ────────────────────────────────────────────────────────
@app.get("/info", tags=["meta"])
async def info(ctx: Annotated[AppContext, Depends(get_app_context)]) -> JSONResponse:
async def info() -> JSONResponse:
"""Service-level capabilities (mirrors OntoCast /info semantics)."""
settings = platform_config.load_settings()
phase = int(settings.phase)
storage_backend = settings.storage_backend
if not _startup_error:
ctx = get_app_context()
phase = int(ctx.settings.phase)
storage_backend = ctx.settings.storage_backend
return JSONResponse(
status_code=200,
content={
@@ -185,8 +215,9 @@ def create_app() -> FastAPI:
"capabilities": ["text-to-triples", "ontology-extraction"],
"input_types": ["text", "json", "pdf", "markdown"],
"output_types": ["turtle", "json"],
"phase": int(ctx.settings.phase),
"storage_backend": ctx.settings.storage_backend,
"phase": phase,
"storage_backend": storage_backend,
"startup_error": _startup_error,
},
)

View File

@@ -71,7 +71,7 @@ dependencies = [
# PHASE3 Acceptance Gate 통과 후 활성화
# 버전 고정: experimental API 변경 위험 (Neo4j GraphRAG 분석 §13.1)
# "neo4j-graphrag[openai,experimental]==1.16.0",
# "neo4j>=5.18.1,<7",
"neo4j>=5.18.1,<7",
# ─── 운영 인프라 ───────────────────────────────────────────────────
# Job Queue

View File

@@ -0,0 +1,79 @@
from crawler_platform.app.config.loader import ProjectConfig
from crawler_platform.app.core.extractor.base import ExtractedClaim, ExtractionBundle
from crawler_platform.app.core.extractor.validation import validate_extraction_bundle
from crawler_platform.app.domains.perfume.extractor import PerfumeRuleBasedExtractor
def perfume_config() -> ProjectConfig:
return ProjectConfig(
project_name="perfume-test",
domain="perfume",
target_entities=["Perfume"],
fields=[],
sources=[],
ontology={
"entity_types": [
"Perfume",
"Product",
"Brand",
"Note",
"Accord",
"Mood",
"Season",
"Occasion",
"Review",
],
"predicates": ["hasBrand", "hasPrice"],
},
)
def test_forment_product_price_line_is_not_extracted_as_brand() -> None:
text = "\n".join(
[
"포맨트 시그니처 퍼퓸 코튼메모리",
"62,000원",
"49,000원",
"기억의 표면을 어루만지다",
]
)
bundle = PerfumeRuleBasedExtractor().extract(text, perfume_config())
brands = [entity.name for entity in bundle.entities if entity.entity_type == "Brand"]
assert brands == ["FORMENT"]
assert "62,000원" not in brands
assert any(
claim.predicate == "hasBrand"
and claim.object_name == "FORMENT"
and claim.evidence_text == "포맨트 시그니처 퍼퓸 코튼메모리"
for claim in bundle.claims
)
assert any(
claim.predicate == "hasPrice"
and claim.object_value == {"amount": 62000.0, "currency": ""}
for claim in bundle.claims
)
def test_validation_rejects_price_like_brand_object() -> None:
bundle = ExtractionBundle(
claims=[
ExtractedClaim(
"포맨트 시그니처 퍼퓸 코튼메모리",
"Perfume",
"hasBrand",
"62,000원",
"Brand",
evidence_text="포맨트 시그니처 퍼퓸 코튼메모리 62,000원",
confidence=0.9,
)
],
extractor_name="rule_only_extractor",
raw_output={"extraction_mode": "rule_only"},
)
result = validate_extraction_bundle(bundle, perfume_config())
assert result.bundle.claims == []
assert result.rejected_claims[0]["reason"] == "brand object looks like a price"

View File

@@ -0,0 +1,144 @@
from crawler_platform.app.config.loader import ProjectConfig
from crawler_platform.app.core.extractor.base import ExtractedClaim, ExtractionBundle, ExtractionPageContext
from crawler_platform.app.core.extractor.hybrid import HybridExtractor, mark_bundle, merge_bundles
from crawler_platform.app.core.extractor.validation import validate_extraction_bundle
def project_config() -> ProjectConfig:
return ProjectConfig(
project_name="phase7-test",
domain="generic",
target_entities=["Thing"],
fields=[],
sources=[],
ontology={
"entity_types": ["Thing"],
"predicates": ["hasPart", "color", "size"],
"relation_types": {
"hasPart": {
"allowed_subject_types": ["Thing"],
"allowed_object_types": ["Thing"],
"confidence_rules": {"min_confidence": 0.1},
},
"color": {
"allowed_subject_types": ["Thing"],
"literal_value": True,
"confidence_rules": {"min_confidence": 0.1},
},
"size": {
"allowed_subject_types": ["Thing"],
"literal_value": True,
"confidence_rules": {"min_confidence": 0.1},
},
},
},
)
def test_hybrid_merge_marks_agreement_conflict_and_counts() -> None:
rule = ExtractionBundle(
claims=[
ExtractedClaim("A", "Thing", "hasPart", "B", "Thing", confidence=0.7),
ExtractedClaim("A", "Thing", "color", object_value="red", confidence=0.6),
]
)
llm = ExtractionBundle(
claims=[
ExtractedClaim("A", "Thing", "hasPart", "B", "Thing", confidence=0.8),
ExtractedClaim("A", "Thing", "color", object_value="blue", confidence=0.65),
ExtractedClaim("A", "Thing", "size", object_value="large", confidence=0.5),
]
)
mark_bundle(rule, source="rule", mode="compare")
mark_bundle(llm, source="llm", mode="compare")
merged = merge_bundles(rule, llm, HybridExtractor("generic", mode="compare"))
assert merged.extractor_name == "compare_rule_llm_extractor"
assert merged.raw_output["comparison"] == {
"both_agree": 1,
"rule_only": 0,
"llm_only": 1,
"conflict": 1,
"rejected_by_validation": 0,
}
by_predicate = {(claim.predicate, claim.object_name or claim.object_value): claim for claim in merged.claims}
assert by_predicate[("hasPart", "B")].metadata["agreement"] == "rule_and_llm"
assert by_predicate[("color", "red")].metadata["review_required"] is True
assert by_predicate[("color", "blue")].metadata["conflict_status"] == "rule_llm_conflict"
assert by_predicate[("size", "large")].metadata["agreement"] == "llm_only"
def test_validation_keeps_evidence_missing_llm_only_claim_as_candidate() -> None:
bundle = ExtractionBundle(
claims=[
ExtractedClaim(
"A",
"Thing",
"color",
object_value="blue",
confidence=0.8,
metadata={
"agreement": "llm_only",
"extraction_source": "llm",
"llm_confidence": 0.8,
},
)
],
extractor_name="hybrid_rule_llm_extractor",
provider="lm_studio",
raw_output={"extraction_mode": "hybrid"},
)
result = validate_extraction_bundle(bundle, project_config())
assert len(result.bundle.claims) == 1
claim = result.bundle.claims[0]
assert claim.metadata["validation_status"] == "candidate_claim"
assert claim.metadata["review_required"] is True
assert claim.metadata["review_reason"] == "claim has no evidence"
def test_validation_marks_rule_only_claim_as_rule_candidate() -> None:
bundle = ExtractionBundle(
claims=[
ExtractedClaim(
"Alpha",
"Thing",
"color",
object_value="red",
evidence_text="Alpha color red",
confidence=0.7,
metadata={
"agreement": "rule_only",
"extraction_source": "rule",
"rule_confidence": 0.7,
},
)
],
extractor_name="hybrid_rule_llm_extractor",
provider="lm_studio",
raw_output={"extraction_mode": "hybrid"},
)
result = validate_extraction_bundle(bundle, project_config())
assert result.bundle.claims[0].metadata["validation_status"] == "rule_candidate"
assert result.bundle.claims[0].metadata["claim_kind"] == "rule_candidate"
def test_hybrid_smart_routing_skips_llm_for_category_page() -> None:
context = ExtractionPageContext(
url="https://example.test/category",
final_url=None,
title="Category",
page_type="CategoryPage",
clean_text="Category listing " * 40,
)
bundle = HybridExtractor("generic", mode="hybrid").extract_from_context(context, project_config())
assert bundle.extractor_name == "hybrid_rule_only_routed"
assert bundle.raw_output["llm_skipped"] is True
assert bundle.raw_output["effective_extraction_mode"] == "rule_only"
assert "CategoryPage" in bundle.raw_output["llm_skip_reason"]

View File

@@ -28,6 +28,11 @@ export const claimSchema = z
graph_merge_status: z.string().nullable().optional(),
graph_merge_reason: z.string().nullable().optional(),
confidence_breakdown: z.record(z.string(), z.unknown()).nullable().optional(),
agreement: z.string().nullable().optional(),
extraction_source: z.string().nullable().optional(),
claim_kind: z.string().nullable().optional(),
rule_confidence: z.number().nullable().optional(),
llm_confidence: z.number().nullable().optional(),
review_required: z.boolean().nullable().optional(),
review_reason: z.string().nullable().optional(),
conflict_status: z.string().nullable().optional(),

View File

@@ -9,10 +9,27 @@ export const crawlPageItemSchema = z
status: z.string().optional(),
page_type: z.string().optional(),
title: z.string().nullable().optional(),
extraction_mode: z.string().nullable().optional(),
effective_extraction_mode: z.string().nullable().optional(),
llm_skipped: z.boolean().optional(),
llm_skip_reason: z.string().nullable().optional(),
fallback_used: z.boolean().optional(),
agreement_claim_count: z.number().optional(),
conflict_claim_count: z.number().optional(),
error: z.string().nullable().optional(),
})
.passthrough();
export const crawlExtractionSummarySchema = z
.object({
llm_skipped_count: z.number().default(0),
fallback_count: z.number().default(0),
conflict_claim_count: z.number().default(0),
agreement_claim_count: z.number().default(0),
llm_call_count: z.number().default(0),
})
.passthrough();
export const crawlProgressSchema = z
.object({
seed_url: z.string().optional(),
@@ -23,6 +40,7 @@ export const crawlProgressSchema = z
errors: z.array(z.string()).optional(),
pages: z.array(crawlPageItemSchema).optional(),
latest_page: crawlPageItemSchema.optional(),
extraction_summary: crawlExtractionSummarySchema.optional(),
})
.passthrough();
@@ -49,9 +67,11 @@ export interface StartSiteCrawlRequest {
max_pages?: number;
same_domain_only?: boolean;
analyze_page_types?: string[];
extraction_mode?: "rule_only" | "llm_only" | "hybrid" | "compare";
extractor_provider?: string;
extractor_model?: string | null;
extractor_base_url?: string | null;
fallback_to_rules?: boolean;
check_robots_txt?: boolean;
respect_robots_txt?: boolean | null;
}

View File

@@ -65,6 +65,20 @@ export const extractionLogSchema = z
validation: z.unknown().optional(),
page_context: z.unknown().optional(),
candidate_count: z.number().default(0),
extraction_mode: z.string().nullable().optional(),
effective_extraction_mode: z.string().nullable().optional(),
comparison: recordSchema.nullable().optional(),
rule_entity_count: z.number().nullable().optional(),
rule_claim_count: z.number().nullable().optional(),
llm_entity_count: z.number().nullable().optional(),
llm_claim_count: z.number().nullable().optional(),
agreement_claim_count: z.number().nullable().optional(),
rule_only_claim_count: z.number().nullable().optional(),
llm_only_claim_count: z.number().nullable().optional(),
conflict_claim_count: z.number().nullable().optional(),
llm_skipped: z.boolean().nullable().optional(),
llm_skip_reason: z.string().nullable().optional(),
fallback: z.string().nullable().optional(),
raw_output: z.unknown().optional(),
})
.passthrough();

View File

@@ -62,9 +62,11 @@ export interface StartResearchRequest {
min_relevance?: number;
same_domain_only?: boolean;
analyze_page_types?: string[];
extraction_mode?: "rule_only" | "llm_only" | "hybrid" | "compare";
extractor_provider?: string;
extractor_model?: string | null;
extractor_base_url?: string | null;
fallback_to_rules?: boolean;
check_robots_txt?: boolean;
respect_robots_txt?: boolean | null;
}

View File

@@ -44,6 +44,11 @@ const startCrawlSchema = z.object({
max_depth: z.number().int().min(0).max(10),
max_pages: z.number().int().min(1).max(500),
same_domain_only: z.boolean(),
extraction_mode: z.enum(["rule_only", "llm_only", "hybrid", "compare"]),
extractor_provider: z.enum(["lm_studio", "openai", "ollama"]),
extractor_model: z.string().optional(),
extractor_base_url: z.string().optional(),
fallback_to_rules: z.boolean(),
});
type StartCrawlFormValues = z.infer<typeof startCrawlSchema>;
@@ -96,14 +101,22 @@ export default function CrawlPage() {
max_depth: 2,
max_pages: 30,
same_domain_only: true,
extraction_mode: "hybrid",
extractor_provider: "lm_studio",
extractor_model: "",
extractor_base_url: "http://localhost:1234/v1",
fallback_to_rules: true,
},
});
const onStart = async (values: StartCrawlFormValues) => {
try {
const usesLlm = values.extraction_mode !== "rule_only";
const created = await startCrawl.mutateAsync({
project_name: projectName,
...values,
extractor_model: usesLlm ? values.extractor_model || null : null,
extractor_base_url: usesLlm ? values.extractor_base_url || null : null,
});
setActiveJobId(created.job_id);
toast.success(
@@ -136,6 +149,8 @@ export default function CrawlPage() {
const sources = project?.sources ?? [];
const sourceName = watch("source_name");
const extractionMode = watch("extraction_mode");
const usesLlm = extractionMode !== "rule_only";
const selectedSource = sources.find((s) => s.name === sourceName);
const progress = job?.progress;
const visited = progress?.visited_count ?? 0;
@@ -340,6 +355,60 @@ export default function CrawlPage() {
</span>
</label>
<div className="space-y-3 rounded-md border bg-background-subtle p-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="extraction_mode">Extraction mode</Label>
<Select id="extraction_mode" {...register("extraction_mode")}>
<option value="hybrid">Hybrid</option>
<option value="rule_only">Rule only</option>
<option value="llm_only">LLM only</option>
<option value="compare">Compare</option>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="extractor_provider">Provider</Label>
<Select
id="extractor_provider"
disabled={!usesLlm}
{...register("extractor_provider")}
>
<option value="lm_studio">LM Studio</option>
<option value="openai">OpenAI</option>
<option value="ollama">Ollama</option>
</Select>
</div>
</div>
{usesLlm && (
<>
<div className="space-y-1.5">
<Label htmlFor="extractor_model">Model</Label>
<Input
id="extractor_model"
placeholder="deepseek-r1-distill-qwen-7b"
{...register("extractor_model")}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="extractor_base_url">Base URL</Label>
<Input
id="extractor_base_url"
placeholder="http://localhost:1234/v1"
{...register("extractor_base_url")}
/>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
className="h-4 w-4 rounded border-input"
{...register("fallback_to_rules")}
/>
<span>Fallback to rules</span>
</label>
</>
)}
</div>
<Button
type="submit"
className="w-full"

View File

@@ -1,7 +1,16 @@
import { useMemo, useState } from "react";
import {
useEffect,
useMemo,
useRef,
useState,
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent,
} from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
AlertCircle,
ChevronDown,
ChevronRight,
CircleDot,
Eye,
EyeOff,
@@ -58,6 +67,18 @@ const COLORS = [
const VIEW_W = 860;
const VIEW_H = 600;
const GRAPH_PADDING = 64;
const CLUSTER_CELL_W = 220;
const CLUSTER_CELL_H = 176;
const NODE_MIN_R = 8;
const NODE_MAX_R = 16;
const LITERAL_MIN_R = 6;
const LITERAL_MAX_R = 11;
interface GraphCanvas {
width: number;
height: number;
}
interface VisualNode {
id: string;
@@ -68,6 +89,7 @@ interface VisualNode {
radius: number;
color: string;
source: "entity" | "literal";
isHub?: boolean;
raw?: GraphNode;
}
@@ -85,6 +107,243 @@ function nodeColor(type: string, types: string[]): string {
return COLORS[index % COLORS.length];
}
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
function hashToUnit(input: string) {
let hash = 2166136261;
for (let i = 0; i < input.length; i += 1) {
hash ^= input.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0) / 4294967295;
}
function predicateAngle(predicate: string) {
const normalized = predicate.toLowerCase();
if (normalized.includes("brand")) return -Math.PI / 6;
if (normalized.includes("price") || normalized.includes("amount")) {
return Math.PI / 2;
}
if (normalized.includes("note") || normalized.includes("description")) {
return Math.PI;
}
if (normalized.includes("occasion") || normalized.includes("tag")) {
return -Math.PI / 2;
}
return hashToUnit(predicate) * Math.PI * 2;
}
function layoutGraph(nodes: VisualNode[], edges: VisualEdge[]): GraphCanvas {
const count = nodes.length;
if (!count) return { width: VIEW_W, height: VIEW_H };
nodes.forEach((node) => {
node.isHub = false;
});
if (count === 1) {
const centerX = VIEW_W / 2;
const centerY = VIEW_H / 2;
nodes[0].x = centerX;
nodes[0].y = centerY;
return { width: VIEW_W, height: VIEW_H };
}
const nodeById = new Map(nodes.map((node) => [node.id, node]));
const outgoing = new Map<string, VisualEdge[]>();
const incoming = new Map<string, VisualEdge[]>();
const degree = new Map<string, number>();
edges.forEach((edge) => {
outgoing.set(edge.source, [...(outgoing.get(edge.source) ?? []), edge]);
incoming.set(edge.target, [...(incoming.get(edge.target) ?? []), edge]);
degree.set(edge.source, (degree.get(edge.source) ?? 0) + 1);
degree.set(edge.target, (degree.get(edge.target) ?? 0) + 1);
});
let centers = nodes.filter(
(node) => node.source === "entity" && (outgoing.get(node.id)?.length ?? 0) > 0,
);
if (!centers.length) {
centers = [...nodes]
.sort((a, b) => (degree.get(b.id) ?? 0) - (degree.get(a.id) ?? 0))
.slice(0, Math.max(1, Math.ceil(Math.sqrt(count))));
}
centers.sort((a, b) => {
const scoreA = (outgoing.get(a.id)?.length ?? 0) * 3 + (degree.get(a.id) ?? 0);
const scoreB = (outgoing.get(b.id)?.length ?? 0) * 3 + (degree.get(b.id) ?? 0);
return scoreB - scoreA || a.name.localeCompare(b.name);
});
const centerIds = new Set(centers.map((node) => node.id));
centers.forEach((node) => {
node.isHub = true;
node.radius = clamp(node.radius + 2, NODE_MIN_R + 3, NODE_MAX_R + 2);
});
const columns = centers.length <= 2 ? centers.length : Math.ceil(Math.sqrt(centers.length * 1.35));
const rows = Math.ceil(centers.length / columns);
const canvasWidth = Math.max(
VIEW_W,
GRAPH_PADDING * 2 + columns * CLUSTER_CELL_W,
);
const canvasHeight = Math.max(
VIEW_H,
GRAPH_PADDING * 2 + rows * CLUSTER_CELL_H,
);
const centerX = canvasWidth / 2;
const centerY = canvasHeight / 2;
const width = canvasWidth - GRAPH_PADDING * 2;
const height = canvasHeight - GRAPH_PADDING * 2;
const cellW = width / Math.max(columns, 1);
const cellH = height / Math.max(rows, 1);
const anchors = new Map<string, { x: number; y: number; strength: number }>();
centers.forEach((node, index) => {
const row = Math.floor(index / columns);
const col = index % columns;
node.x = GRAPH_PADDING + cellW * (col + 0.5);
node.y = GRAPH_PADDING + cellH * (row + 0.5);
if (rows === 1) {
node.y = centerY;
}
anchors.set(node.id, { x: node.x, y: node.y, strength: 0.55 });
});
centers.forEach((center) => {
const childEdges = (outgoing.get(center.id) ?? [])
.filter((edge) => !centerIds.has(edge.target) && nodeById.has(edge.target))
.sort((a, b) => {
const angleA = predicateAngle(a.predicate);
const angleB = predicateAngle(b.predicate);
return angleA - angleB || a.predicate.localeCompare(b.predicate);
});
const groups = new Map<string, VisualEdge[]>();
childEdges.forEach((edge) => {
groups.set(edge.predicate, [...(groups.get(edge.predicate) ?? []), edge]);
});
Array.from(groups.entries()).forEach(([predicate, group]) => {
const baseAngle = predicateAngle(predicate);
const spread = Math.min(0.78, 0.18 * Math.max(group.length - 1, 0));
group.forEach((edge, index) => {
const child = nodeById.get(edge.target);
if (!child) return;
const parentCount = (incoming.get(child.id) ?? []).filter((incomingEdge) =>
centerIds.has(incomingEdge.source),
).length;
if (parentCount > 1) return;
const offset =
group.length === 1
? 0
: -spread / 2 + (spread * index) / Math.max(group.length - 1, 1);
const distance =
76 +
Math.min(childEdges.length, 8) * 3 +
hashToUnit(`${center.id}:${child.id}`) * 20;
const x = center.x + Math.cos(baseAngle + offset) * distance;
const y = center.y + Math.sin(baseAngle + offset) * distance;
child.x = x;
child.y = y;
anchors.set(child.id, { x, y, strength: 0.4 });
});
});
});
nodes
.filter((node) => !centerIds.has(node.id))
.forEach((node) => {
if (anchors.has(node.id)) return;
const parentEdges = (incoming.get(node.id) ?? []).filter((edge) =>
centerIds.has(edge.source),
);
if (parentEdges.length) {
const parents = parentEdges
.map((edge) => nodeById.get(edge.source))
.filter((parent): parent is VisualNode => Boolean(parent));
const avgX =
parents.reduce((sum, parent) => sum + parent.x, 0) / parents.length;
const avgY =
parents.reduce((sum, parent) => sum + parent.y, 0) / parents.length;
const isSharedTarget = parentEdges.length > 1;
const angle = predicateAngle(parentEdges[0].predicate);
const x = isSharedTarget ? avgX : avgX + Math.cos(angle) * 58;
const y = isSharedTarget ? avgY : avgY + Math.sin(angle) * 58;
node.x = x;
node.y = y;
anchors.set(node.id, { x, y, strength: isSharedTarget ? 0.46 : 0.32 });
return;
}
const relatedEdges = edges.filter(
(edge) => edge.source === node.id || edge.target === node.id,
);
const related = relatedEdges
.map((edge) =>
edge.source === node.id
? nodeById.get(edge.target)
: nodeById.get(edge.source),
)
.filter((relatedNode): relatedNode is VisualNode => Boolean(relatedNode));
if (related.length) {
const avgX =
related.reduce((sum, relatedNode) => sum + relatedNode.x, 0) /
related.length;
const avgY =
related.reduce((sum, relatedNode) => sum + relatedNode.y, 0) /
related.length;
const angle = hashToUnit(node.id) * Math.PI * 2;
node.x = avgX + Math.cos(angle) * 64;
node.y = avgY + Math.sin(angle) * 64;
} else {
const angle = hashToUnit(node.id) * Math.PI * 2;
node.x = centerX + Math.cos(angle) * 120;
node.y = centerY + Math.sin(angle) * 120;
}
anchors.set(node.id, { x: node.x, y: node.y, strength: 0.24 });
});
for (let tick = 0; tick < 80; tick += 1) {
nodes.forEach((node) => {
const anchor = anchors.get(node.id);
if (!anchor) return;
node.x += (anchor.x - node.x) * anchor.strength * 0.12;
node.y += (anchor.y - node.y) * anchor.strength * 0.12;
});
for (let i = 0; i < count; i += 1) {
const a = nodes[i];
for (let j = i + 1; j < count; j += 1) {
const b = nodes[j];
let dx = a.x - b.x;
let dy = a.y - b.y;
let distance = Math.sqrt(dx * dx + dy * dy);
if (distance < 0.01) {
dx = hashToUnit(`${a.id}:${b.id}:x`) - 0.5;
dy = hashToUnit(`${a.id}:${b.id}:y`) - 0.5;
distance = Math.sqrt(dx * dx + dy * dy);
}
const minDistance = a.radius + b.radius + 22;
if (distance >= minDistance) continue;
const force = ((minDistance - distance) / distance) * 0.32;
const ax = dx * force;
const ay = dy * force;
const aAnchor = anchors.get(a.id)?.strength ?? 0.2;
const bAnchor = anchors.get(b.id)?.strength ?? 0.2;
a.x += ax * (1 - aAnchor);
a.y += ay * (1 - aAnchor);
b.x -= ax * (1 - bAnchor);
b.y -= ay * (1 - bAnchor);
}
}
nodes.forEach((node) => {
node.x = clamp(node.x, GRAPH_PADDING, canvasWidth - GRAPH_PADDING);
node.y = clamp(node.y, GRAPH_PADDING, canvasHeight - GRAPH_PADDING);
});
}
return { width: canvasWidth, height: canvasHeight };
}
function buildGraph(
nodes: GraphNode[],
edges: GraphEdge[],
@@ -170,15 +429,21 @@ function buildGraph(
visualNodes = visualNodes.filter((node) => connectedIds.has(node.id));
}
const centerX = VIEW_W / 2;
const centerY = VIEW_H / 2;
const radius = Math.max(160, Math.min(260, visualNodes.length * 13));
visualNodes.forEach((node, index) => {
const angle = (Math.PI * 2 * index) / Math.max(visualNodes.length, 1);
node.x = centerX + Math.cos(angle) * radius;
node.y = centerY + Math.sin(angle) * radius;
const degree = new Map<string, number>();
filteredEdges.forEach((edge) => {
degree.set(edge.source, (degree.get(edge.source) ?? 0) + 1);
degree.set(edge.target, (degree.get(edge.target) ?? 0) + 1);
});
return { nodes: visualNodes, edges: filteredEdges, types };
visualNodes.forEach((node) => {
const links = degree.get(node.id) ?? 0;
const base =
node.source === "literal"
? clamp(LITERAL_MIN_R + Math.sqrt(links) * 1.4, LITERAL_MIN_R, LITERAL_MAX_R)
: clamp(NODE_MIN_R + Math.sqrt(links) * 1.8, NODE_MIN_R, NODE_MAX_R);
node.radius = base;
});
const canvas = layoutGraph(visualNodes, filteredEdges);
return { nodes: visualNodes, edges: filteredEdges, types, canvas };
}
function edgeEndpoint(edge: VisualEdge, nodes: VisualNode[]) {
@@ -187,6 +452,26 @@ function edgeEndpoint(edge: VisualEdge, nodes: VisualNode[]) {
return { source, target };
}
function edgeLinePoints(source: VisualNode, target: VisualNode) {
const dx = target.x - source.x;
const dy = target.y - source.y;
const distance = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
const nx = dx / distance;
const ny = dy / distance;
const x1 = source.x + nx * (source.radius + 2);
const y1 = source.y + ny * (source.radius + 2);
const x2 = target.x - nx * (target.radius + 7);
const y2 = target.y - ny * (target.radius + 7);
return {
x1,
y1,
x2,
y2,
midX: (x1 + x2) / 2,
midY: (y1 + y2) / 2,
};
}
export default function GraphViewPage() {
const navigate = useNavigate();
const { projectId } = useParams<{ projectId: string }>();
@@ -202,8 +487,17 @@ export default function GraphViewPage() {
const [drawerOpen, setDrawerOpen] = useState(false);
const [hiddenTypes, setHiddenTypes] = useState<Set<string>>(new Set());
const [showLegend, setShowLegend] = useState(true);
const [legendCollapsed, setLegendCollapsed] = useState(true);
const [showMinimap, setShowMinimap] = useState(true);
const [shortcutsOpen, setShortcutsOpen] = useState(false);
const canvasRef = useRef<HTMLDivElement | null>(null);
const panRef = useRef({
active: false,
x: 0,
y: 0,
scrollLeft: 0,
scrollTop: 0,
});
const graph = useGraphNeighborhood(projectName, {
includeCandidates: includeCandidates && statusScope === "validated_claim",
@@ -267,6 +561,72 @@ export default function GraphViewPage() {
? visibleEdges.reduce((sum, edge) => sum + edge.confidence, 0) /
visibleEdges.length
: 0;
const showEdgeLabels = visibleEdges.length <= 70;
const showNodeLabels = visibleNodes.length <= 180;
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || graph.isLoading || visibleNodes.length === 0) return;
window.requestAnimationFrame(() => {
canvas.scrollLeft = Math.max(0, (canvas.scrollWidth - canvas.clientWidth) / 2);
canvas.scrollTop = Math.max(0, (canvas.scrollHeight - canvas.clientHeight) / 2);
});
}, [
graph.isLoading,
projectName,
visibleNodes.length,
visual.canvas.height,
visual.canvas.width,
]);
const startCanvasPan = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return;
const target = event.target as HTMLElement;
if (target.closest("[data-graph-node], [data-graph-edge]")) return;
panRef.current = {
active: true,
x: event.clientX,
y: event.clientY,
scrollLeft: event.currentTarget.scrollLeft,
scrollTop: event.currentTarget.scrollTop,
};
event.currentTarget.setPointerCapture(event.pointerId);
};
const moveCanvasPan = (event: ReactPointerEvent<HTMLDivElement>) => {
if (!panRef.current.active) return;
event.currentTarget.scrollLeft =
panRef.current.scrollLeft - (event.clientX - panRef.current.x);
event.currentTarget.scrollTop =
panRef.current.scrollTop - (event.clientY - panRef.current.y);
event.preventDefault();
};
const endCanvasPan = (event: ReactPointerEvent<HTMLDivElement>) => {
if (!panRef.current.active) return;
panRef.current.active = false;
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
};
const jumpCanvasTo = (x: number, y: number) => {
const canvas = canvasRef.current;
if (!canvas) return;
canvas.scrollTo({
left: clamp(
x - canvas.clientWidth / 2,
0,
Math.max(0, canvas.scrollWidth - canvas.clientWidth),
),
top: clamp(
y - canvas.clientHeight / 2,
0,
Math.max(0, canvas.scrollHeight - canvas.clientHeight),
),
behavior: "smooth",
});
};
const selectNode = (node: VisualNode | VisualEdge) => {
setSelected(node);
@@ -530,61 +890,79 @@ export default function GraphViewPage() {
/>
) : (
<>
<svg
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
className="block h-[640px] w-full bg-background-subtle text-muted-foreground"
role="img"
aria-label="Ontology graph"
<div
ref={canvasRef}
className="relative h-[640px] cursor-grab overflow-auto bg-background-subtle active:cursor-grabbing"
onPointerDown={startCanvasPan}
onPointerMove={moveCanvasPan}
onPointerUp={endCanvasPan}
onPointerCancel={endCanvasPan}
>
<svg
viewBox={`0 0 ${visual.canvas.width} ${visual.canvas.height}`}
width={visual.canvas.width}
height={visual.canvas.height}
className="block min-h-full min-w-full text-muted-foreground"
role="img"
aria-label="Ontology graph"
>
<defs>
<marker
id="arrow"
markerWidth="10"
markerHeight="10"
refX="10"
refY="3"
markerWidth="6"
markerHeight="6"
refX="5.5"
refY="2.5"
orient="auto"
markerUnits="strokeWidth"
>
<path d="M0,0 L0,6 L9,3 z" fill="currentColor" />
<path d="M0,0 L0,5 L5.5,2.5 z" fill="currentColor" />
</marker>
</defs>
{visibleEdges.map((edge) => {
const { source, target } = edgeEndpoint(edge, visibleNodes);
if (!source || !target) return null;
const midX = (source.x + target.x) / 2;
const midY = (source.y + target.y) / 2;
const { x1, y1, x2, y2, midX, midY } = edgeLinePoints(
source,
target,
);
const isSelected =
selected && "predicate" in selected && selected.id === edge.id;
return (
<g
key={edge.id}
data-graph-edge
className="cursor-pointer"
onClick={() => selectNode(edge)}
>
<line
x1={source.x}
y1={source.y}
x2={target.x}
y2={target.y}
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke="currentColor"
strokeWidth={1 + edge.confidence * 3}
strokeOpacity={isSelected ? 1 : 0.55}
strokeWidth={isSelected ? 1.7 : 0.65 + edge.confidence * 0.75}
strokeOpacity={isSelected ? 0.95 : 0.42}
markerEnd="url(#arrow)"
className={isSelected ? "text-brand-600 dark:text-brand-300" : ""}
/>
<text
x={midX}
y={midY}
textAnchor="middle"
className="fill-muted-foreground text-[10px]"
paintOrder="stroke"
stroke="hsl(var(--background))"
strokeWidth="3"
strokeLinejoin="round"
>
{edge.predicate}
</text>
{(showEdgeLabels || isSelected) && (
<text
x={midX}
y={midY - 2}
textAnchor="middle"
className="fill-muted-foreground text-[7px]"
opacity={isSelected ? 0.95 : 0.62}
paintOrder="stroke"
stroke="hsl(var(--background))"
strokeWidth="2"
strokeLinejoin="round"
>
{edge.predicate.length > 18
? `${edge.predicate.slice(0, 18)}...`
: edge.predicate}
</text>
)}
</g>
);
})}
@@ -594,6 +972,7 @@ export default function GraphViewPage() {
return (
<g
key={node.id}
data-graph-node
className="cursor-pointer"
onClick={() => selectNode(node)}
>
@@ -602,31 +981,41 @@ export default function GraphViewPage() {
cy={node.y}
r={node.radius}
fill={node.color}
className={
stroke={
isSelected
? "stroke-foreground"
: "stroke-background"
? "hsl(var(--foreground))"
: node.isHub
? "hsl(var(--muted-foreground))"
: "hsl(var(--background))"
}
strokeWidth={isSelected ? 3 : 2}
strokeWidth={isSelected ? 2.5 : node.isHub ? 2 : 1.3}
/>
<text
x={node.x}
y={node.y + node.radius + 14}
textAnchor="middle"
className="fill-foreground text-[11px] font-medium"
paintOrder="stroke"
stroke="hsl(var(--background))"
strokeWidth="3"
strokeLinejoin="round"
>
{node.name.length > 24
? `${node.name.slice(0, 24)}...`
: node.name}
</text>
{(showNodeLabels || isSelected) && (
<text
x={node.x}
y={node.y + node.radius + 10}
textAnchor="middle"
className={
node.isHub
? "fill-foreground text-[8px] font-semibold"
: "fill-foreground text-[8px] font-medium"
}
opacity={node.source === "literal" ? 0.74 : 0.86}
paintOrder="stroke"
stroke="hsl(var(--background))"
strokeWidth="2"
strokeLinejoin="round"
>
{node.name.length > 18
? `${node.name.slice(0, 18)}...`
: node.name}
</text>
)}
</g>
);
})}
</svg>
</svg>
</div>
{/* Legend overlay (top-right) */}
{showLegend && (
@@ -635,12 +1024,18 @@ export default function GraphViewPage() {
typeCounts={typeCounts}
hiddenTypes={hiddenTypes}
onToggle={toggleType}
collapsed={legendCollapsed}
onCollapsedChange={setLegendCollapsed}
/>
)}
{/* Minimap overlay (bottom-right) */}
{showMinimap && visibleNodes.length > 0 && (
<Minimap nodes={visibleNodes} edges={visibleEdges} />
<Minimap
nodes={visibleNodes}
edges={visibleEdges}
onJump={jumpCanvasTo}
/>
)}
</>
)}
@@ -803,58 +1198,83 @@ function LegendPanel({
typeCounts,
hiddenTypes,
onToggle,
collapsed,
onCollapsedChange,
}: {
types: string[];
typeCounts: Map<string, number>;
hiddenTypes: Set<string>;
onToggle: (type: string) => void;
collapsed: boolean;
onCollapsedChange: (collapsed: boolean) => void;
}) {
return (
<div className="absolute right-3 top-3 w-52 rounded-md border border-border bg-popover/95 shadow-md backdrop-blur-sm">
<div className="border-b border-border px-3 py-1.5 text-2xs font-semibold uppercase tracking-wider text-muted-foreground">
Legend
</div>
<ul className="max-h-64 overflow-y-auto py-1">
{types.map((type) => {
const hidden = hiddenTypes.has(type);
const count = typeCounts.get(type) ?? 0;
return (
<li key={type}>
<button
type="button"
onClick={() => onToggle(type)}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors hover:bg-accent/60"
aria-pressed={!hidden}
>
<span
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: nodeColor(type, types),
opacity: hidden ? 0.3 : 1,
}}
/>
<span
className={
hidden
? "flex-1 truncate text-muted-foreground/60 line-through"
: "flex-1 truncate text-foreground"
}
<div
className={
collapsed
? "absolute right-3 top-3 w-32 rounded-md border border-border bg-popover/95 shadow-md backdrop-blur-sm"
: "absolute right-3 top-3 w-52 rounded-md border border-border bg-popover/95 shadow-md backdrop-blur-sm"
}
>
<button
type="button"
onClick={() => onCollapsedChange(!collapsed)}
className="flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-2xs font-semibold uppercase tracking-wider text-muted-foreground transition-colors hover:bg-accent/60"
aria-expanded={!collapsed}
>
<span>Legend</span>
<span className="flex items-center gap-1 font-mono normal-case">
{collapsed && types.length}
{collapsed ? (
<ChevronRight className="h-3.5 w-3.5" />
) : (
<ChevronDown className="h-3.5 w-3.5" />
)}
</span>
</button>
{!collapsed && (
<ul className="max-h-64 overflow-y-auto border-t border-border py-1">
{types.map((type) => {
const hidden = hiddenTypes.has(type);
const count = typeCounts.get(type) ?? 0;
return (
<li key={type}>
<button
type="button"
onClick={() => onToggle(type)}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors hover:bg-accent/60"
aria-pressed={!hidden}
>
{type}
</span>
<span className="text-2xs text-muted-foreground tabular-nums">
{count}
</span>
{hidden ? (
<EyeOff className="h-3 w-3 text-muted-foreground/60" />
) : (
<Eye className="h-3 w-3 text-muted-foreground" />
)}
</button>
</li>
);
})}
</ul>
<span
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: nodeColor(type, types),
opacity: hidden ? 0.3 : 1,
}}
/>
<span
className={
hidden
? "flex-1 truncate text-muted-foreground/60 line-through"
: "flex-1 truncate text-foreground"
}
>
{type}
</span>
<span className="text-2xs text-muted-foreground tabular-nums">
{count}
</span>
{hidden ? (
<EyeOff className="h-3 w-3 text-muted-foreground/60" />
) : (
<Eye className="h-3 w-3 text-muted-foreground" />
)}
</button>
</li>
);
})}
</ul>
)}
</div>
);
}
@@ -865,9 +1285,11 @@ function LegendPanel({
function Minimap({
nodes,
edges,
onJump,
}: {
nodes: VisualNode[];
edges: VisualEdge[];
onJump: (x: number, y: number) => void;
}) {
const W = 180;
const H = 130;
@@ -886,6 +1308,14 @@ function Minimap({
const offsetY = (H - h * scale) / 2;
const tx = (x: number) => (x - minX) * scale + offsetX;
const ty = (y: number) => (y - minY) * scale + offsetY;
const jumpFromMinimap = (event: ReactMouseEvent<SVGSVGElement>) => {
const rect = event.currentTarget.getBoundingClientRect();
const localX = event.clientX - rect.left;
const localY = event.clientY - rect.top;
const graphX = clamp((localX - offsetX) / scale + minX, minX, maxX);
const graphY = clamp((localY - offsetY) / scale + minY, minY, maxY);
onJump(graphX, graphY);
};
return (
<div className="absolute bottom-3 right-3 rounded-md border border-border bg-popover/95 shadow-md backdrop-blur-sm">
@@ -898,8 +1328,11 @@ function Minimap({
<svg
width={W}
height={H}
className="block text-muted-foreground"
aria-hidden="true"
className="block cursor-crosshair text-muted-foreground"
role="button"
aria-label="Jump to minimap position"
tabIndex={0}
onClick={jumpFromMinimap}
>
{edges.map((e) => {
const s = nodes.find((n) => n.id === e.source);

View File

@@ -48,6 +48,21 @@ function candidateClaims(log: ExtractionLog | undefined): unknown[] {
return Array.isArray(nested) ? nested : [];
}
function numberFrom(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
function comparisonRecord(log: ExtractionLog | undefined): Record<string, unknown> {
return {
...asRecord(asRecord(log?.raw_output).comparison),
...asRecord(log?.comparison),
};
}
function comparisonCount(log: ExtractionLog | undefined, key: string): number {
return numberFrom(comparisonRecord(log)[key]);
}
export default function PageAnalysisPage() {
const navigate = useNavigate();
const { projectId } = useParams<{ projectId: string }>();
@@ -211,6 +226,29 @@ export default function PageAnalysisPage() {
label="Validation"
value={humanizeValue(selectedLog.validation)}
/>
<InfoBox
label="Mode"
value={humanizeValue(
selectedLog.effective_extraction_mode ??
selectedLog.extraction_mode ??
rawOutput.effective_extraction_mode ??
rawOutput.extraction_mode,
)}
/>
<InfoBox
label="Rule / LLM"
value={`${numberFrom(selectedLog.rule_claim_count ?? rawOutput.rule_claim_count)} / ${numberFrom(
selectedLog.llm_claim_count ?? rawOutput.llm_claim_count,
)}`}
/>
<InfoBox
label="LLM"
value={
selectedLog.llm_skipped || rawOutput.llm_skipped
? `skipped: ${humanizeValue(selectedLog.llm_skip_reason ?? rawOutput.llm_skip_reason)}`
: humanizeValue(selectedLog.provider)
}
/>
</div>
)}
{selectedLog?.error && (
@@ -221,6 +259,30 @@ export default function PageAnalysisPage() {
</CardContent>
</Card>
{selectedLog && (
<Card>
<CardHeader>
<CardTitle>Rule / LLM Comparison</CardTitle>
<CardDescription>
Hybrid and compare runs keep agreement, difference, and conflict counts.
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
<ComparisonBox label="Both agree" value={comparisonCount(selectedLog, "both_agree")} tone="success" />
<ComparisonBox label="Rule only" value={comparisonCount(selectedLog, "rule_only")} tone="warning" />
<ComparisonBox label="LLM only" value={comparisonCount(selectedLog, "llm_only")} tone="info" />
<ComparisonBox label="Conflict" value={comparisonCount(selectedLog, "conflict")} tone="danger" />
<ComparisonBox
label="Rejected"
value={comparisonCount(selectedLog, "rejected_by_validation")}
tone="muted"
/>
</div>
</CardContent>
</Card>
)}
<div className="grid gap-6 xl:grid-cols-2">
<Card>
<CardHeader>
@@ -331,3 +393,30 @@ function InfoBox({ label, value }: { label: string; value: string }) {
);
}
function ComparisonBox({
label,
value,
tone,
}: {
label: string;
value: number;
tone: "success" | "warning" | "info" | "danger" | "muted";
}) {
const toneClass =
tone === "success"
? "border-green-200 bg-green-50 text-green-900"
: tone === "warning"
? "border-yellow-200 bg-yellow-50 text-yellow-900"
: tone === "danger"
? "border-red-200 bg-red-50 text-red-900"
: tone === "info"
? "border-blue-200 bg-blue-50 text-blue-900"
: "border-border bg-background text-foreground";
return (
<div className={`rounded-md border px-3 py-2 ${toneClass}`}>
<div className="text-xs opacity-80">{label}</div>
<div className="mt-1 text-2xl font-semibold tabular-nums">{value}</div>
</div>
);
}

View File

@@ -52,6 +52,11 @@ const startResearchSchema = z.object({
max_branch: z.number().int().min(1).max(30),
min_relevance: z.number().min(0).max(1),
same_domain_only: z.boolean(),
extraction_mode: z.enum(["rule_only", "llm_only", "hybrid", "compare"]),
extractor_provider: z.enum(["lm_studio", "openai", "ollama"]),
extractor_model: z.string().optional(),
extractor_base_url: z.string().optional(),
fallback_to_rules: z.boolean(),
});
type StartResearchFormValues = z.infer<typeof startResearchSchema>;
@@ -89,6 +94,7 @@ export default function ResearchPage() {
register,
handleSubmit,
setValue,
watch,
formState: { errors, isSubmitting },
} = useForm<StartResearchFormValues>({
resolver: zodResolver(startResearchSchema),
@@ -101,12 +107,18 @@ export default function ResearchPage() {
max_branch: 8,
min_relevance: 0.35,
same_domain_only: true,
extraction_mode: "hybrid",
extractor_provider: "lm_studio",
extractor_model: "",
extractor_base_url: "http://localhost:1234/v1",
fallback_to_rules: true,
},
});
const onSubmit = async (values: StartResearchFormValues) => {
try {
setLastResult(null);
const usesLlm = values.extraction_mode !== "rule_only";
const res = await startResearch.mutateAsync({
project_name: projectName,
source_name: values.source_name,
@@ -117,6 +129,11 @@ export default function ResearchPage() {
max_branch: values.max_branch,
min_relevance: values.min_relevance,
same_domain_only: values.same_domain_only,
extraction_mode: values.extraction_mode,
extractor_provider: values.extractor_provider,
extractor_model: usesLlm ? values.extractor_model || null : null,
extractor_base_url: usesLlm ? values.extractor_base_url || null : null,
fallback_to_rules: values.fallback_to_rules,
});
setLastResult(res);
toast.success(t("research.completed", "자율 연구가 완료되었습니다"));
@@ -130,6 +147,8 @@ export default function ResearchPage() {
};
const sources = project?.sources ?? [];
const extractionMode = watch("extraction_mode");
const usesLlm = extractionMode !== "rule_only";
return (
<div className="mx-auto max-w-6xl px-6 py-10">
@@ -344,6 +363,60 @@ export default function ResearchPage() {
</span>
</label>
<div className="space-y-3 rounded-md border bg-background-subtle p-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="extraction_mode">Extraction mode</Label>
<Select id="extraction_mode" {...register("extraction_mode")}>
<option value="hybrid">Hybrid</option>
<option value="rule_only">Rule only</option>
<option value="llm_only">LLM only</option>
<option value="compare">Compare</option>
</Select>
</div>
<div className="space-y-1.5">
<Label htmlFor="extractor_provider">Provider</Label>
<Select
id="extractor_provider"
disabled={!usesLlm}
{...register("extractor_provider")}
>
<option value="lm_studio">LM Studio</option>
<option value="openai">OpenAI</option>
<option value="ollama">Ollama</option>
</Select>
</div>
</div>
{usesLlm && (
<>
<div className="space-y-1.5">
<Label htmlFor="extractor_model">Model</Label>
<Input
id="extractor_model"
placeholder="deepseek-r1-distill-qwen-7b"
{...register("extractor_model")}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="extractor_base_url">Base URL</Label>
<Input
id="extractor_base_url"
placeholder="http://localhost:1234/v1"
{...register("extractor_base_url")}
/>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
className="h-4 w-4 rounded border-input"
{...register("fallback_to_rules")}
/>
<span>Fallback to rules</span>
</label>
</>
)}
</div>
<Button
type="submit"
className="w-full"

View File

@@ -60,12 +60,37 @@ function claimObjectText(claim: Claim): string {
return humanizeValue(claim.object ?? claim.object_value);
}
function agreementText(claim: Claim): string {
return humanizeValue(claim.agreement ?? claim.claim_kind ?? "unknown");
}
function agreementVariant(claim: Claim): BadgeProps["variant"] {
switch ((claim.agreement ?? "").toLowerCase()) {
case "rule_and_llm":
return "success";
case "conflict":
return "destructive";
case "rule_only":
case "llm_only":
return "warning";
default:
return "outline";
}
}
const STATUS_LABELS: Record<string, string> = {
candidate: "Candidate",
approved: "Approved",
rejected: "Rejected",
};
const AGREEMENT_LABELS: Record<string, string> = {
rule_and_llm: "Rule + LLM",
rule_only: "Rule only",
llm_only: "LLM only",
conflict: "Conflict",
};
export default function ReviewPage() {
const navigate = useNavigate();
const { projectId } = useParams<{ projectId: string }>();
@@ -78,6 +103,7 @@ export default function ReviewPage() {
const updateStatus = useUpdateClaimStatus(projectName);
const [statusFilter, setStatusFilter] = useState<string>("");
const [agreementFilter, setAgreementFilter] = useState<string>("");
const [search, setSearch] = useState("");
const [selectedId, setSelectedId] = useState<string | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
@@ -90,6 +116,9 @@ export default function ReviewPage() {
if (statusFilter && reviewLabel(claim.status) !== statusFilter) {
return false;
}
if (agreementFilter && (claim.agreement ?? "") !== agreementFilter) {
return false;
}
if (!query) return true;
return [
claim.subject,
@@ -102,7 +131,7 @@ export default function ReviewPage() {
.map((value) => humanizeValue(value, "").toLowerCase())
.some((value) => value.includes(query));
});
}, [claims.data, search, statusFilter]);
}, [agreementFilter, claims.data, search, statusFilter]);
const selectedClaim = useMemo(() => {
if (!selectedId) return null;
@@ -122,6 +151,16 @@ export default function ReviewPage() {
};
}, [claims.data]);
const agreementCounts = useMemo(() => {
const result: Record<string, number> = {};
for (const claim of claims.data ?? []) {
const key = claim.agreement ?? "";
if (!key) continue;
result[key] = (result[key] ?? 0) + 1;
}
return result;
}, [claims.data]);
/* --------------------- Actions --------------------- */
const applyStatus = async (claim: Claim, status: string) => {
@@ -166,9 +205,17 @@ export default function ReviewPage() {
value: STATUS_LABELS[statusFilter] ?? statusFilter,
onClear: () => setStatusFilter(""),
});
if (agreementFilter)
chips.push({
id: "agreement",
label: "Agreement",
value: AGREEMENT_LABELS[agreementFilter] ?? agreementFilter,
onClear: () => setAgreementFilter(""),
});
const clearAll = () => {
setSearch("");
setStatusFilter("");
setAgreementFilter("");
};
/* --------------------- Table columns --------------------- */
@@ -186,6 +233,28 @@ export default function ReviewPage() {
size: 110,
enablePinning: true,
},
{
id: "agreement",
header: "Agreement",
accessorFn: (row) => row.agreement ?? "",
cell: ({ row }) => (
<Badge variant={agreementVariant(row.original)}>
{agreementText(row.original)}
</Badge>
),
size: 130,
},
{
id: "method",
header: "Method",
accessorFn: (row) => row.extraction_source ?? row.claim_kind ?? "",
cell: ({ row }) => (
<span className="text-xs text-muted-foreground">
{humanizeValue(row.original.extraction_source ?? row.original.claim_kind)}
</span>
),
size: 110,
},
{
id: "subject",
header: "Subject",
@@ -377,6 +446,38 @@ export default function ReviewPage() {
{ value: "rejected", label: "Rejected", hint: counts.rejected, swatch: "hsl(var(--danger))" },
]}
/>
<FilterSelect
label="Agreement"
value={agreementFilter}
onChange={setAgreementFilter}
placeholder="All agreements"
options={[
{
value: "rule_and_llm",
label: "Rule + LLM",
hint: agreementCounts.rule_and_llm ?? 0,
swatch: "hsl(var(--success))",
},
{
value: "rule_only",
label: "Rule only",
hint: agreementCounts.rule_only ?? 0,
swatch: "hsl(var(--warning))",
},
{
value: "llm_only",
label: "LLM only",
hint: agreementCounts.llm_only ?? 0,
swatch: "hsl(var(--info))",
},
{
value: "conflict",
label: "Conflict",
hint: agreementCounts.conflict ?? 0,
swatch: "hsl(var(--danger))",
},
]}
/>
</FilterPanel>
<DataTable<Claim>
@@ -498,14 +599,28 @@ export default function ReviewPage() {
{/* Detail grid */}
<div className="grid gap-3 sm:grid-cols-2">
<DetailRow label="Source" value={selectedClaim.source} />
<DetailRow
label="Agreement"
value={agreementText(selectedClaim)}
/>
<DetailRow
label="Page Type"
value={selectedClaim.page_type}
/>
<DetailRow
label="Extraction Source"
value={selectedClaim.extraction_source ?? selectedClaim.claim_kind}
/>
<DetailRow
label="Extraction Method"
value={selectedClaim.extraction_method}
/>
<DetailRow
label="Rule / LLM Confidence"
value={`${formatPercent(selectedClaim.rule_confidence)} / ${formatPercent(
selectedClaim.llm_confidence,
)}`}
/>
<DetailRow
label="Validation"
value={
@@ -539,6 +654,13 @@ export default function ReviewPage() {
full
/>
)}
{selectedClaim.review_reason && (
<DetailRow
label="Review Reason"
value={selectedClaim.review_reason}
full
/>
)}
</div>
{/* Evidence */}

View File

@@ -43,6 +43,184 @@ function Get-CommandPath {
return $null
}
function Test-PythonModule {
param(
[string] $PythonPath,
[string] $ModuleName
)
if ([string]::IsNullOrWhiteSpace($PythonPath)) {
return $false
}
try {
$proc = Start-Process `
-FilePath $PythonPath `
-ArgumentList @("-c", "import $ModuleName") `
-WindowStyle Hidden `
-Wait `
-PassThru
return $proc.ExitCode -eq 0
} catch {
return $false
}
}
function Resolve-ProjectPython {
param([string] $ProjectRoot)
$projectVenvPython = Join-Path $ProjectRoot ".venv\Scripts\python.exe"
if (Test-Path -LiteralPath $projectVenvPython) {
return (Resolve-Path -LiteralPath $projectVenvPython).Path
}
$rootVenvPython = Join-Path $root ".venv\Scripts\python.exe"
if (Test-Path -LiteralPath $rootVenvPython) {
return (Resolve-Path -LiteralPath $rootVenvPython).Path
}
$candidates = @()
if ($env:VIRTUAL_ENV) {
$candidates += Join-Path $env:VIRTUAL_ENV "Scripts\python.exe"
}
$pyLauncher = Get-CommandPath @("py.exe", "py")
if ($pyLauncher) {
try {
$resolvedFromPy = & $pyLauncher -c "import sys; print(sys.executable)" 2>$null
if ($LASTEXITCODE -eq 0 -and -not [string]::IsNullOrWhiteSpace($resolvedFromPy)) {
$candidates += $resolvedFromPy.Trim()
}
} catch {
}
}
$pathPython = Get-CommandPath @("python.exe", "python")
if ($pathPython) {
$candidates += $pathPython
}
$seen = @{}
foreach ($candidate in $candidates) {
if ([string]::IsNullOrWhiteSpace($candidate)) {
continue
}
$resolved = $null
if (Test-Path -LiteralPath $candidate) {
$resolved = (Resolve-Path -LiteralPath $candidate).Path
} else {
$cmd = Get-Command $candidate -ErrorAction SilentlyContinue
if ($cmd) {
$resolved = $cmd.Source
}
}
if (-not $resolved -or $seen.ContainsKey($resolved)) {
continue
}
$seen[$resolved] = $true
if (Test-PythonModule -PythonPath $resolved -ModuleName "uvicorn") {
return $resolved
}
}
return $null
}
function Resolve-SystemPython {
$candidates = @()
if ($env:VIRTUAL_ENV) {
$candidates += Join-Path $env:VIRTUAL_ENV "Scripts\python.exe"
}
$pathPython = Get-CommandPath @("python.exe", "python")
if ($pathPython) {
$candidates += $pathPython
}
$pyLauncher = Get-CommandPath @("py.exe", "py")
if ($pyLauncher) {
try {
$resolvedFromPy = & $pyLauncher -c "import sys; print(sys.executable)" 2>$null
if ($LASTEXITCODE -eq 0 -and -not [string]::IsNullOrWhiteSpace($resolvedFromPy)) {
$candidates += $resolvedFromPy.Trim()
}
} catch {
}
}
$seen = @{}
foreach ($candidate in $candidates) {
if ([string]::IsNullOrWhiteSpace($candidate)) {
continue
}
$resolved = $null
if (Test-Path -LiteralPath $candidate) {
$resolved = (Resolve-Path -LiteralPath $candidate).Path
} else {
$cmd = Get-Command $candidate -ErrorAction SilentlyContinue
if ($cmd) {
$resolved = $cmd.Source
}
}
if (-not $resolved -or $seen.ContainsKey($resolved)) {
continue
}
$seen[$resolved] = $true
return $resolved
}
return $null
}
function Ensure-ProjectVenv {
param([string] $ProjectRoot)
$venvPython = Join-Path $ProjectRoot ".venv\Scripts\python.exe"
if (Test-PythonModule -PythonPath $venvPython -ModuleName "uvicorn") {
return $venvPython
}
$bootstrapPython = Resolve-SystemPython
if (-not $bootstrapPython) {
Write-Step "No system Python was found for bootstrapping the backend venv."
return $null
}
Write-Step "Bootstrapping backend virtual environment in $ProjectRoot\\.venv"
if (-not (Test-Path -LiteralPath $venvPython)) {
Invoke-LoggedCommand `
-FilePath $bootstrapPython `
-Arguments @("-m", "venv", ".venv") `
-WorkingDirectory $ProjectRoot `
-LogName "backend-venv-create.log"
}
Invoke-LoggedCommand `
-FilePath $venvPython `
-Arguments @("-m", "pip", "install", "--upgrade", "pip") `
-WorkingDirectory $ProjectRoot `
-LogName "backend-pip-upgrade.log"
Invoke-LoggedCommand `
-FilePath $venvPython `
-Arguments @("-m", "pip", "install", "-e", ".") `
-WorkingDirectory $ProjectRoot `
-LogName "backend-pip-install.log"
if (Test-PythonModule -PythonPath $venvPython -ModuleName "uvicorn") {
return $venvPython
}
Write-Step "Backend venv was created, but uvicorn is still unavailable. Check .server-logs\\backend-*.log"
return $null
}
function Read-DotEnv {
param([string] $Path)
@@ -185,6 +363,15 @@ function Start-LoggedServer {
}
}
function Clear-ServerLogs {
param([string] $Name)
Remove-Item -LiteralPath `
(Join-Path $logDir "$Name-stdout.log"), `
(Join-Path $logDir "$Name-stderr.log") `
-Force -ErrorAction SilentlyContinue
}
function Wait-Port {
param(
[int] $Port,
@@ -209,6 +396,28 @@ function Wait-Port {
return $false
}
function Resolve-FrontendDevCommand {
param(
[string] $FrontendDir,
[string] $NpmPath
)
$node = Get-CommandPath @("node.exe", "node")
$viteCli = Join-Path $FrontendDir "node_modules\vite\bin\vite.js"
if ($node -and (Test-Path -LiteralPath $viteCli)) {
return [pscustomobject]@{
FilePath = $node
Arguments = @($viteCli, "--host", "127.0.0.1", "--port", "8000")
}
}
return [pscustomobject]@{
FilePath = $NpmPath
Arguments = @("run", "dev", "--", "--host", "127.0.0.1", "--port", "8000")
}
}
function Start-Neo4jIfAvailable {
$composeFile = Join-Path $root "docker-compose.neo4j.yml"
if (-not (Test-Path -LiteralPath $composeFile)) {
@@ -249,13 +458,11 @@ if ((Test-Path -LiteralPath (Join-Path $frontendDir "package.json")) -and $npm)
$servers = @()
$ontologyRoot = Join-Path $root "ontology_platform"
$ontologyPython = Get-CommandPath @(
(Join-Path $ontologyRoot ".venv\Scripts\python.exe"),
(Join-Path $root ".venv\Scripts\python.exe"),
"C:\Users\lasta\AppData\Local\Python\bin\python.exe",
"C:\Users\lasta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe",
"python"
)
$ontologyPython = Resolve-ProjectPython -ProjectRoot $ontologyRoot
if ((-not $ontologyPython) -and (Test-Path -LiteralPath (Join-Path $ontologyRoot "pyproject.toml"))) {
$ontologyPython = Ensure-ProjectVenv -ProjectRoot $ontologyRoot
}
if ((Test-Path -LiteralPath (Join-Path $ontologyRoot "ont_platform\api\main.py")) -and $ontologyPython) {
$ontologyEnv = Read-DotEnv -Path (Join-Path $ontologyRoot ".env")
@@ -271,15 +478,18 @@ if ((Test-Path -LiteralPath (Join-Path $ontologyRoot "ont_platform\api\main.py")
-WorkingDirectory $ontologyRoot `
-Environment $ontologyEnv
} else {
Write-Step "ontology_platform server entrypoint or Python was not found; skipped."
Clear-ServerLogs -Name "ontology-platform-8001"
Write-Step "ontology_platform server entrypoint was not found, or no Python with uvicorn is available for this project."
Write-Step "Create a venv under ontology_platform\\.venv (or .\\.venv) and install dependencies before rerunning."
}
if ($frontendReady) {
$frontendDev = Resolve-FrontendDevCommand -FrontendDir $frontendDir -NpmPath $npm
$servers += Start-LoggedServer `
-Name "ontology-frontend-8000" `
-Port 8000 `
-FilePath $npm `
-Arguments @("run", "dev", "--", "--host", "127.0.0.1", "--port", "8000") `
-FilePath $frontendDev.FilePath `
-Arguments $frontendDev.Arguments `
-WorkingDirectory $frontendDir
}
@@ -290,7 +500,7 @@ $servers | ForEach-Object {
$missingPorts = @()
foreach ($port in ($servers | Select-Object -ExpandProperty Port -Unique)) {
if (Wait-Port -Port $port -Seconds 25) {
if (Wait-Port -Port $port -Seconds 120) {
Write-Step ("Port {0} is listening." -f $port)
} else {
Write-Step ("Port {0} did not open. Check logs in {1}" -f $port, $logDir)
@@ -304,6 +514,10 @@ if ($missingPorts.Count -gt 0) {
Write-Host ""
Write-Step "Done."
Write-Host " Ontology UI: http://127.0.0.1:8000/static/"
Write-Host " Ontology API: http://127.0.0.1:8001/docs"
if ($servers | Where-Object { $_.Port -eq 8000 }) {
Write-Host " Ontology UI: http://127.0.0.1:8000/static/"
}
if ($servers | Where-Object { $_.Port -eq 8001 }) {
Write-Host " Ontology API: http://127.0.0.1:8001/docs"
}
Write-Host " Logs: $logDir"

View File

@@ -1,448 +0,0 @@
"""Phase 5 GraphRAG API endpoint tests.
Tests for:
- Entity duplicate detection endpoint
- Subgraph extraction endpoints (N-hop and semantic)
- Pattern matching endpoints
- Graph analytics endpoints
- Health check endpoints
"""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from datetime import datetime, UTC
from ont_platform.api.phase5_app import app, graph_router
from ont_platform.core.graph import EntityCluster, EntityResolver
from fastapi.testclient import TestClient
@pytest.fixture
def client():
"""FastAPI test client."""
return TestClient(app)
class TestHealthCheck:
"""Test health check endpoint."""
def test_health_check_endpoint(self, client):
"""Test GET /health endpoint."""
response = client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "healthy"
assert data["version"] == "0.5.0"
assert data["phase"] == "5 (GraphRAG)"
assert "components" in data
def test_platform_info_endpoint(self, client):
"""Test GET /info endpoint."""
response = client.get("/info")
assert response.status_code == 200
data = response.json()
assert data["platform"] == "Ontology System Construction Platform"
assert data["phase"] == "5 (GraphRAG)"
assert data["version"] == "0.5.0"
assert "features" in data
class TestEntityResolutionEndpoint:
"""Test entity duplicate detection endpoint."""
@pytest.mark.asyncio
async def test_resolve_duplicates_success(self, client):
"""Test successful entity duplicate detection."""
with patch("ont_platform.api.phase5_app.entity_resolver") as mock_resolver:
# Setup mock
mock_cluster = EntityCluster(
cluster_id="C_1_2",
canonical_id=1,
duplicates=[2],
confidence=0.92,
reason="combined",
metadata={"vector_similarity": 0.95, "text_similarity": 0.89},
)
mock_resolver.embedder = MagicMock()
mock_resolver.initialize_embedder = AsyncMock(return_value=True)
mock_resolver.detect_duplicates = AsyncMock(return_value=[mock_cluster])
mock_resolver.get_resolution_report = MagicMock(
return_value={
"total_clusters": 1,
"total_duplicates": 1,
"avg_confidence": 0.92,
"by_reason": {"combined": 1},
"timestamp": datetime.now(UTC).isoformat(),
}
)
# Test
entities_json = [
{"id": 1, "label": "Apple Inc", "type": "company"},
{"id": 2, "label": "Apple Incorporated", "type": "company"},
]
# NOTE: TestClient doesn't support Query params in POST body directly
# In real usage, these would be query parameters or request body
response = client.post(
"/api/v1/graph/resolve",
json={"entities": entities_json},
)
# The endpoint expects Query params, so this test validates the API structure
# Actual integration testing would use proper query parameters
if response.status_code == 422: # Validation error expected with TestClient
assert "detail" in response.json()
def test_resolve_duplicates_missing_entities(self, client):
"""Test resolve endpoint with missing entities parameter."""
response = client.post("/api/v1/graph/resolve")
assert response.status_code == 422 # Unprocessable entity
class TestSubgraphExtractionEndpoint:
"""Test subgraph extraction endpoints."""
@pytest.mark.asyncio
async def test_extract_neighborhood_success(self, client):
"""Test N-hop neighborhood extraction."""
with patch("ont_platform.api.phase5_app.subgraph_retriever") as mock_retriever:
mock_retriever.retrieve_neighborhood = AsyncMock(
return_value={
"center_entity": {"id": 1, "label": "Apple", "type": "company"},
"nodes": [
{"id": 1, "label": "Apple", "type": "company"},
{"id": 2, "label": "Tim Cook", "type": "person"},
],
"edges": [
{
"source_id": 1,
"target_id": 2,
"predicate": "HAS_CEO",
"confidence": 0.95,
}
],
"hop_count": 1,
"node_count": 2,
"edge_count": 1,
}
)
response = client.post(
"/api/v1/graph/subgraph?entity_id=1&hops=1&min_confidence=0.0"
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "success"
assert data["entity_id"] == 1
assert data["hops"] == 1
def test_extract_neighborhood_missing_entity_id(self, client):
"""Test subgraph extraction without entity_id."""
response = client.post("/api/v1/graph/subgraph")
assert response.status_code == 422
@pytest.mark.asyncio
async def test_extract_semantic_subgraph_success(self, client):
"""Test semantic subgraph extraction."""
with patch("ont_platform.api.phase5_app.subgraph_retriever") as mock_retriever:
mock_retriever.embedder = MagicMock()
mock_retriever.retrieve_by_semantic_query = AsyncMock(
return_value={
"query": "tech companies",
"query_embedding_dimension": 384,
"matched_entities": [
{"id": 1, "label": "Apple", "similarity": 0.92},
{"id": 2, "label": "Microsoft", "similarity": 0.89},
],
"nodes": [
{"id": 1, "label": "Apple", "type": "company"},
{"id": 2, "label": "Microsoft", "type": "company"},
],
"edges": [],
"matched_count": 2,
"node_count": 2,
"edge_count": 0,
}
)
response = client.post(
"/api/v1/graph/subgraph/semantic?query=tech+companies&top_k=10&min_similarity=0.6&hops=1"
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "success"
assert data["query"] == "tech companies"
def test_extract_semantic_subgraph_missing_query(self, client):
"""Test semantic subgraph without query parameter."""
response = client.post("/api/v1/graph/subgraph/semantic")
assert response.status_code == 422
class TestPatternMatchingEndpoints:
"""Test pattern matching endpoints."""
@pytest.mark.asyncio
async def test_find_paths_success(self, client):
"""Test path finding between entities."""
with patch("ont_platform.api.phase5_app.pattern_matcher") as mock_matcher:
mock_matcher.find_paths = AsyncMock(
return_value=[
{
"path": [1, "rel1", 2, "rel2", 3],
"length": 2,
"confidence": 0.85,
}
]
)
response = client.post(
"/api/v1/graph/patterns/paths?start_id=1&end_id=3&max_length=5"
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "success"
assert data["start_id"] == 1
assert data["end_id"] == 3
assert data["paths_found"] == 1
def test_find_paths_missing_parameters(self, client):
"""Test path finding without required parameters."""
response = client.post("/api/v1/graph/patterns/paths")
assert response.status_code == 422
@pytest.mark.asyncio
async def test_find_cycles_success(self, client):
"""Test cycle detection."""
with patch("ont_platform.api.phase5_app.pattern_matcher") as mock_matcher:
mock_matcher.find_cycles = AsyncMock(
return_value=[
{
"cycle": [1, 2, 3, 1],
"length": 3,
"confidence": 0.80,
}
]
)
response = client.post("/api/v1/graph/patterns/cycles")
assert response.status_code == 200
data = response.json()
assert data["status"] == "success"
assert data["cycles_found"] == 1
class TestGraphAnalyticsEndpoints:
"""Test graph analytics endpoints."""
@pytest.mark.asyncio
async def test_analyze_centrality_pagerank(self, client):
"""Test PageRank centrality analysis."""
with patch("ont_platform.api.phase5_app.graph_analytics") as mock_analytics:
mock_analytics.calculate_centrality = AsyncMock(
return_value=[
{"entity_id": 1, "label": "Apple", "score": 0.35},
{"entity_id": 2, "label": "Microsoft", "score": 0.28},
]
)
response = client.post(
"/api/v1/graph/analytics/centrality?centrality_type=pagerank&top_n=10"
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "success"
assert data["centrality_type"] == "pagerank"
assert data["top_n"] == 10
@pytest.mark.asyncio
async def test_analyze_centrality_degree(self, client):
"""Test degree centrality analysis."""
with patch("ont_platform.api.phase5_app.graph_analytics") as mock_analytics:
mock_analytics.calculate_centrality = AsyncMock(return_value=[])
response = client.post(
"/api/v1/graph/analytics/centrality?centrality_type=degree&top_n=5"
)
assert response.status_code == 200
data = response.json()
assert data["centrality_type"] == "degree"
def test_analyze_centrality_invalid_type(self, client):
"""Test centrality with invalid type parameter."""
response = client.post(
"/api/v1/graph/analytics/centrality?centrality_type=invalid_type&top_n=10"
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_detect_communities_louvain(self, client):
"""Test community detection with Louvain."""
with patch("ont_platform.api.phase5_app.graph_analytics") as mock_analytics:
mock_analytics.detect_communities = AsyncMock(
return_value=[
{
"community_id": "C1",
"size": 15,
"density": 0.72,
},
{
"community_id": "C2",
"size": 12,
"density": 0.65,
},
]
)
response = client.post(
"/api/v1/graph/analytics/communities?algorithm=louvain"
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "success"
assert data["algorithm"] == "louvain"
assert data["communities_found"] == 2
@pytest.mark.asyncio
async def test_detect_communities_leiden(self, client):
"""Test community detection with Leiden."""
with patch("ont_platform.api.phase5_app.graph_analytics") as mock_analytics:
mock_analytics.detect_communities = AsyncMock(return_value=[])
response = client.post(
"/api/v1/graph/analytics/communities?algorithm=leiden"
)
assert response.status_code == 200
data = response.json()
assert data["algorithm"] == "leiden"
def test_detect_communities_invalid_algorithm(self, client):
"""Test community detection with invalid algorithm."""
response = client.post(
"/api/v1/graph/analytics/communities?algorithm=invalid_algo"
)
assert response.status_code == 422
class TestAPIErrorHandling:
"""Test error handling in API endpoints."""
@pytest.mark.asyncio
async def test_resolve_duplicates_error_handling(self, client):
"""Test error handling in duplicate resolution."""
with patch("ont_platform.api.phase5_app.entity_resolver") as mock_resolver:
mock_resolver.embedder = MagicMock()
mock_resolver.initialize_embedder = AsyncMock(
side_effect=RuntimeError("Model load failed")
)
# Since the endpoint calls initialize_embedder and handles exceptions,
# we expect the error to be caught and returned as HTTP 500
response = client.post(
"/api/v1/graph/resolve",
json={"entities": [{"id": 1, "label": "Test"}]},
)
# Validation error due to Query param mismatch
assert response.status_code in [422, 500]
@pytest.mark.asyncio
async def test_subgraph_extraction_error_handling(self, client):
"""Test error handling in subgraph extraction."""
with patch("ont_platform.api.phase5_app.subgraph_retriever") as mock_retriever:
mock_retriever.retrieve_neighborhood = AsyncMock(
side_effect=Exception("Neo4j connection failed")
)
response = client.post(
"/api/v1/graph/subgraph?entity_id=999&hops=1&min_confidence=0.0"
)
assert response.status_code == 500
class TestParameterValidation:
"""Test parameter validation for all endpoints."""
def test_subgraph_hops_validation(self, client):
"""Test hops parameter validation (1-3 range)."""
# hops = 0 (below minimum)
response = client.post(
"/api/v1/graph/subgraph?entity_id=1&hops=0&min_confidence=0.0"
)
assert response.status_code == 422
# hops = 4 (above maximum)
response = client.post(
"/api/v1/graph/subgraph?entity_id=1&hops=4&min_confidence=0.0"
)
assert response.status_code == 422
def test_confidence_validation(self, client):
"""Test min_confidence parameter validation (0-1 range)."""
# Negative confidence
response = client.post(
"/api/v1/graph/subgraph?entity_id=1&hops=1&min_confidence=-0.1"
)
assert response.status_code == 422
# Confidence > 1
response = client.post(
"/api/v1/graph/subgraph?entity_id=1&hops=1&min_confidence=1.5"
)
assert response.status_code == 422
def test_similarity_validation(self, client):
"""Test min_similarity parameter validation."""
# Valid similarity
response = client.post(
"/api/v1/graph/subgraph/semantic?query=test&min_similarity=0.5"
)
# Will fail due to missing embedder, but validation passes
assert response.status_code in [200, 500]
# Invalid similarity
response = client.post(
"/api/v1/graph/subgraph/semantic?query=test&min_similarity=-0.1"
)
assert response.status_code == 422
def test_top_k_validation(self, client):
"""Test top_k parameter validation."""
# top_k = 0 (invalid)
response = client.post(
"/api/v1/graph/subgraph/semantic?query=test&top_k=0"
)
assert response.status_code == 422
# top_k = 150 (above maximum 100)
response = client.post(
"/api/v1/graph/subgraph/semantic?query=test&top_k=150"
)
assert response.status_code == 422
class TestEndpointRouting:
"""Test API endpoint routing and versioning."""
def test_api_version_prefix(self, client):
"""Test API routes use /api/v1/graph prefix."""
# Test that health endpoint is not under graph prefix
response = client.get("/health")
assert response.status_code == 200
# Test graph endpoints use correct prefix
response = client.post("/api/v1/graph/resolve")
assert response.status_code != 404 # Endpoint exists
def test_semantic_subgraph_separate_route(self, client):
"""Test semantic subgraph has separate route."""
# /subgraph/semantic should be separate from /subgraph
response = client.post(
"/api/v1/graph/subgraph/semantic?query=test"
)
# May fail due to missing embedder, but route should exist
assert response.status_code in [200, 500]
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

View File

@@ -1,338 +0,0 @@
"""Phase 5 Entity Resolver tests.
Tests vector + text similarity-based duplicate detection:
- Normalization
- Jaro-Winkler similarity
- Vector embedding similarity
- Duplicate detection and merging
"""
import pytest
from datetime import datetime
from ont_platform.core.graph import EntityResolver, EntityCluster
class TestEntityNormalization:
"""Test label normalization."""
def test_normalize_label_lowercase(self):
"""Test lowercase normalization."""
resolver = EntityResolver()
assert resolver._normalize_label("Apple Inc") == "apple inc"
assert resolver._normalize_label("SAMSUNG") == "samsung"
def test_normalize_label_special_chars(self):
"""Test special character removal."""
resolver = EntityResolver()
assert resolver._normalize_label("Apple-Inc") == "apple inc"
assert resolver._normalize_label("Samsung_Electronics") == "samsung electronics"
assert resolver._normalize_label("IBM@Corp!") == "ibmcorp"
def test_normalize_label_articles(self):
"""Test article removal."""
resolver = EntityResolver()
assert resolver._normalize_label("The Apple Inc") == "apple inc"
assert resolver._normalize_label("A Samsung") == "samsung"
assert resolver._normalize_label("An IBM") == "ibm"
def test_normalize_label_whitespace(self):
"""Test whitespace collapse."""
resolver = EntityResolver()
assert resolver._normalize_label("Apple Inc") == "apple inc"
assert resolver._normalize_label(" Samsung ") == "samsung"
class TestJaroWinklerSimilarity:
"""Test Jaro-Winkler text similarity."""
def test_exact_match(self):
"""Test exact string match."""
resolver = EntityResolver()
assert resolver._jaro_winkler_similarity("apple", "apple") == 1.0
def test_partial_match(self):
"""Test partial string match."""
resolver = EntityResolver()
sim = resolver._jaro_winkler_similarity("apple", "aple")
assert 0.8 < sim < 1.0
def test_different_strings(self):
"""Test different strings."""
resolver = EntityResolver()
sim = resolver._jaro_winkler_similarity("apple", "banana")
assert 0 <= sim < 0.5 # Adjusted threshold based on actual Jaro-Winkler
def test_case_sensitivity(self):
"""Test that Jaro-Winkler is case-sensitive."""
resolver = EntityResolver()
# Fallback SequenceMatcher is case-sensitive
sim1 = resolver._jaro_winkler_similarity("Apple", "apple")
sim2 = resolver._jaro_winkler_similarity("apple", "apple")
# sim1 should be less than sim2
assert sim1 <= sim2
class TestTextSimilarity:
"""Test text similarity computation."""
def test_exact_match(self):
"""Test exact label match."""
resolver = EntityResolver()
assert resolver._compute_text_similarity("samsung", "samsung") == 1.0
def test_jaro_winkler_dominance(self):
"""Test that Jaro-Winkler dominates (70% weight)."""
resolver = EntityResolver()
# Slightly different strings
sim = resolver._compute_text_similarity("samsung", "samsu")
# Should be close but less than 1.0
assert 0.65 < sim < 1.0 # Adjusted based on actual similarity
def test_token_overlap_single_word(self):
"""Test token overlap with single word."""
resolver = EntityResolver()
# Both are single tokens
sim = resolver._compute_text_similarity("apple", "apple")
assert sim == 1.0
def test_token_overlap_multiword(self):
"""Test token overlap with multi-word labels."""
resolver = EntityResolver()
# Partial token overlap
sim = resolver._compute_text_similarity("apple inc", "apple corp")
# Should be > 0 due to "apple" token overlap
assert sim > 0.5
class TestEntityResolverInit:
"""Test EntityResolver initialization."""
def test_init_default_thresholds(self):
"""Test default threshold values."""
resolver = EntityResolver()
assert resolver.vector_threshold == 0.85
assert resolver.text_threshold == 0.88
assert resolver.model_name == "all-MiniLM-L6-v2"
def test_init_custom_thresholds(self):
"""Test custom threshold values."""
resolver = EntityResolver(
vector_threshold=0.80,
text_threshold=0.90,
model_name="sentence-transformers/all-MiniLM-L6-v2"
)
assert resolver.vector_threshold == 0.80
assert resolver.text_threshold == 0.90
assert resolver.model_name == "sentence-transformers/all-MiniLM-L6-v2"
@pytest.mark.asyncio
async def test_initialize_embedder(self):
"""Test embedder initialization."""
resolver = EntityResolver()
result = await resolver.initialize_embedder()
assert result is True
assert resolver.embedder is not None
class TestDuplicateDetection:
"""Test duplicate detection (requires embedder)."""
@pytest.mark.asyncio
async def test_detect_duplicates_exact_match(self):
"""Test detection of exact duplicate labels."""
resolver = EntityResolver()
await resolver.initialize_embedder()
entities = [
{"id": 1, "label": "Apple Inc", "type": "company"},
{"id": 2, "label": "Apple Inc", "type": "company"}, # Exact duplicate
{"id": 3, "label": "Microsoft Corp", "type": "company"},
]
clusters = await resolver.detect_duplicates(entities)
assert len(clusters) >= 1
# Should detect at least one duplicate pair
assert any(c.canonical_id == 1 and 2 in c.duplicates for c in clusters)
@pytest.mark.asyncio
async def test_detect_duplicates_similar_labels(self):
"""Test detection of similar labels."""
resolver = EntityResolver()
await resolver.initialize_embedder()
entities = [
{"id": 1, "label": "Apple Inc", "type": "company"},
{"id": 2, "label": "Apple Incorporated", "type": "company"},
{"id": 3, "label": "Samsung", "type": "company"},
]
clusters = await resolver.detect_duplicates(entities)
# May or may not detect depending on similarity thresholds
assert isinstance(clusters, list)
@pytest.mark.asyncio
async def test_detect_duplicates_empty_list(self):
"""Test with empty entity list - should handle gracefully."""
resolver = EntityResolver()
await resolver.initialize_embedder()
# Empty list with no embedder should return empty clusters
# (The method checks if embedder exists before processing)
try:
clusters = await resolver.detect_duplicates([])
assert isinstance(clusters, list)
except (ValueError, Exception):
# May raise error due to numpy handling empty arrays
pass
@pytest.mark.asyncio
async def test_detect_duplicates_single_entity(self):
"""Test with single entity."""
resolver = EntityResolver()
await resolver.initialize_embedder()
entities = [
{"id": 1, "label": "Apple Inc", "type": "company"},
]
clusters = await resolver.detect_duplicates(entities)
assert clusters == []
class TestClusterResolution:
"""Test cluster merging."""
@pytest.mark.asyncio
async def test_resolve_cluster_basic(self):
"""Test basic cluster resolution."""
resolver = EntityResolver()
cluster = EntityCluster(
cluster_id="C_1_2",
canonical_id=1,
duplicates=[2],
confidence=0.92,
reason="combined",
metadata={},
)
entities_map = {
1: {
"id": 1,
"label": "Apple Inc",
"type": "company",
"aliases": ["Apple"],
"evidence": [{"source": "source1"}],
},
2: {
"id": 2,
"label": "Apple Incorporated",
"type": "company",
"aliases": ["Apple Inc"],
"evidence": [{"source": "source2"}],
},
}
merged = await resolver.resolve_cluster(cluster, entities_map)
assert merged["id"] == 1
assert merged["label"] == "Apple Inc"
assert merged["merged_from"] == [2]
assert merged["merge_confidence"] == 0.92
assert len(merged["aliases"]) >= 3
assert len(merged["evidence"]) == 2
@pytest.mark.asyncio
async def test_resolve_cluster_missing_entity(self):
"""Test resolution with missing entity."""
resolver = EntityResolver()
cluster = EntityCluster(
cluster_id="C_1_2",
canonical_id=1,
duplicates=[2],
confidence=0.92,
reason="combined",
metadata={},
)
entities_map = {
1: {"id": 1, "label": "Apple Inc"},
# Missing entity 2
}
merged = await resolver.resolve_cluster(cluster, entities_map)
assert merged["id"] == 1
assert "aliases" in merged
class TestResolutionReport:
"""Test resolution report generation."""
def test_get_resolution_report_empty(self):
"""Test report with no clusters."""
resolver = EntityResolver()
report = resolver.get_resolution_report([])
assert report["total_clusters"] == 0
assert report["total_duplicates"] == 0
assert report["avg_confidence"] == 0
def test_get_resolution_report_single_cluster(self):
"""Test report with one cluster."""
resolver = EntityResolver()
clusters = [
EntityCluster(
cluster_id="C_1_2",
canonical_id=1,
duplicates=[2],
confidence=0.90,
reason="combined",
metadata={},
),
]
report = resolver.get_resolution_report(clusters)
assert report["total_clusters"] == 1
assert report["total_duplicates"] == 1
assert report["avg_confidence"] == 0.90
assert report["by_reason"]["combined"] == 1
def test_get_resolution_report_multiple_clusters(self):
"""Test report with multiple clusters."""
resolver = EntityResolver()
clusters = [
EntityCluster(
cluster_id="C_1_2",
canonical_id=1,
duplicates=[2],
confidence=0.90,
reason="combined",
metadata={},
),
EntityCluster(
cluster_id="C_3_4",
canonical_id=3,
duplicates=[4],
confidence=0.85,
reason="vector_similarity",
metadata={},
),
]
report = resolver.get_resolution_report(clusters)
assert report["total_clusters"] == 2
assert report["total_duplicates"] == 2
assert abs(report["avg_confidence"] - 0.875) < 0.01
assert report["by_reason"]["combined"] == 1
assert report["by_reason"]["vector_similarity"] == 1
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@@ -1,30 +0,0 @@
"""Phase 5 RDF Converter tests.
Tests RDF ↔ Property Graph conversion:
- Triple to node/edge conversion
- Graph roundtrip integrity
"""
import pytest
from ont_platform.core.graph import RDFToPropertyGraphConverter
class TestRDFConverter:
"""Test RDF to Property Graph conversion."""
def test_converter_init(self):
"""Test converter initialization."""
converter = RDFToPropertyGraphConverter()
assert converter is not None
def test_converter_has_required_methods(self):
"""Test that converter has required methods."""
converter = RDFToPropertyGraphConverter()
assert hasattr(converter, 'convert_triples_to_graph')
assert hasattr(converter, 'to_rdf_triples')
assert callable(converter.convert_triples_to_graph)
assert callable(converter.to_rdf_triples)
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@@ -1,458 +0,0 @@
"""Phase 5 Subgraph Retriever tests.
Tests semantic-based subgraph extraction:
- N-hop neighborhood retrieval
- Context retrieval between multiple entities
- Semantic query-based entity search
- Induced subgraph extraction
"""
import pytest
from unittest.mock import AsyncMock, MagicMock
import numpy as np
from ont_platform.core.graph import SubgraphRetriever
@pytest.fixture
def mock_adapter():
"""Mock Neo4j adapter."""
adapter = AsyncMock()
return adapter
@pytest.fixture
def mock_embedder():
"""Mock sentence transformer embedder."""
embedder = MagicMock()
# Return 384-dim embeddings (all-MiniLM-L6-v2 default)
embedder.encode = MagicMock(
return_value=np.random.randn(384).astype(np.float32)
)
return embedder
@pytest.fixture
def subgraph_retriever(mock_adapter, mock_embedder):
"""Create SubgraphRetriever with mocks."""
retriever = SubgraphRetriever(adapter=mock_adapter, embedder=mock_embedder)
return retriever
class TestSubgraphRetrieverInit:
"""Test SubgraphRetriever initialization."""
def test_init_with_adapter_only(self, mock_adapter):
"""Test initialization with adapter only."""
retriever = SubgraphRetriever(adapter=mock_adapter)
assert retriever.adapter is mock_adapter
assert retriever.embedder is None
def test_init_with_adapter_and_embedder(self, mock_adapter, mock_embedder):
"""Test initialization with adapter and embedder."""
retriever = SubgraphRetriever(adapter=mock_adapter, embedder=mock_embedder)
assert retriever.adapter is mock_adapter
assert retriever.embedder is mock_embedder
class TestSemanticQuery:
"""Test semantic query-based entity search."""
@pytest.mark.asyncio
async def test_retrieve_by_semantic_query_success(
self, subgraph_retriever, mock_adapter, mock_embedder
):
"""Test successful semantic query retrieval."""
# Setup mock responses
mock_adapter.execute_cypher = AsyncMock(
side_effect=[
# First call: fetch entities with embeddings
[
{
"entity": {
"id": 1,
"label": "Apple Inc",
"type": "company",
"confidence": 0.95,
"embedding": np.random.randn(384).tolist(),
}
},
{
"entity": {
"id": 2,
"label": "Microsoft Corp",
"type": "company",
"confidence": 0.92,
"embedding": np.random.randn(384).tolist(),
}
},
],
# Second call: fetch neighbors
[{"id": 3}, {"id": 4}],
# Third call: fetch all nodes
[
{
"node": {
"id": 1,
"label": "Apple Inc",
"type": "company",
"confidence": 0.95,
}
},
{
"node": {
"id": 2,
"label": "Microsoft Corp",
"type": "company",
"confidence": 0.92,
}
},
],
# Fourth call: fetch edges
[
{
"edge": {
"source_id": 1,
"target_id": 2,
"predicate": "COMPETES_WITH",
"confidence": 0.85,
}
}
],
]
)
result = await subgraph_retriever.retrieve_by_semantic_query(
query="tech companies",
top_k=10,
min_similarity=0.6,
hops=1,
)
assert "error" not in result
assert result["query"] == "tech companies"
assert "matched_entities" in result
assert "nodes" in result
assert "edges" in result
@pytest.mark.asyncio
async def test_semantic_query_without_embedder(self, mock_adapter):
"""Test semantic query without embedder returns error."""
retriever = SubgraphRetriever(adapter=mock_adapter, embedder=None)
result = await retriever.retrieve_by_semantic_query(
query="test",
top_k=10,
)
assert result["error"] == "Embedder not initialized"
assert result["matched_count"] == 0
@pytest.mark.asyncio
async def test_semantic_query_empty_query(self, subgraph_retriever):
"""Test semantic query with empty query string."""
result = await subgraph_retriever.retrieve_by_semantic_query(
query="",
top_k=10,
)
assert result["error"] == "Empty query"
assert result["matched_count"] == 0
@pytest.mark.asyncio
async def test_semantic_query_whitespace_only(self, subgraph_retriever):
"""Test semantic query with whitespace-only query."""
result = await subgraph_retriever.retrieve_by_semantic_query(
query=" ",
top_k=10,
)
assert result["error"] == "Empty query"
@pytest.mark.asyncio
async def test_semantic_query_no_entities_with_embeddings(
self, subgraph_retriever, mock_adapter
):
"""Test semantic query when no entities have embeddings."""
mock_adapter.execute_cypher = AsyncMock(return_value=[])
result = await subgraph_retriever.retrieve_by_semantic_query(
query="test",
top_k=10,
)
assert "warning" in result
assert result["matched_count"] == 0
@pytest.mark.asyncio
async def test_semantic_query_similarity_filtering(
self, subgraph_retriever, mock_adapter, mock_embedder
):
"""Test similarity threshold filtering."""
# Create deterministic embeddings for testing
query_vec = np.ones(384, dtype=np.float32)
query_vec = query_vec / np.linalg.norm(query_vec)
mock_embedder.encode = MagicMock(return_value=query_vec)
# Create entity embeddings with varying similarities
high_sim_vec = np.ones(384, dtype=np.float32)
high_sim_vec = high_sim_vec / np.linalg.norm(high_sim_vec)
# Similarity will be 1.0
low_sim_vec = -np.ones(384, dtype=np.float32)
low_sim_vec = low_sim_vec / np.linalg.norm(low_sim_vec)
# Similarity will be -1.0
mock_adapter.execute_cypher = AsyncMock(
side_effect=[
# Entities with different similarities
[
{
"entity": {
"id": 1,
"label": "High Sim",
"type": "test",
"confidence": 0.9,
"embedding": high_sim_vec.tolist(),
}
},
{
"entity": {
"id": 2,
"label": "Low Sim",
"type": "test",
"confidence": 0.9,
"embedding": low_sim_vec.tolist(),
}
},
],
# Neighbors for matched entities only
[],
# Nodes
[{"node": {"id": 1, "label": "High Sim", "type": "test"}}],
# Edges
[],
]
)
result = await subgraph_retriever.retrieve_by_semantic_query(
query="test",
top_k=10,
min_similarity=0.5,
)
# Only high similarity entity should be matched
assert result["matched_count"] == 1
@pytest.mark.asyncio
async def test_semantic_query_top_k_limiting(
self, subgraph_retriever, mock_adapter
):
"""Test top_k parameter limits results."""
# Create 5 entities, request top_k=2
mock_adapter.execute_cypher = AsyncMock(
side_effect=[
# 5 entities
[
{"entity": {"id": i, "label": f"E{i}", "embedding": np.random.randn(384).tolist()}}
for i in range(1, 6)
],
# Neighbors
[],
# Nodes
[{"node": {"id": i, "label": f"E{i}", "type": "test"}} for i in range(1, 3)],
# Edges
[],
]
)
result = await subgraph_retriever.retrieve_by_semantic_query(
query="test",
top_k=2,
min_similarity=0.0, # Accept all
)
# Should return at most top_k matches
assert result["matched_count"] <= 2
@pytest.mark.asyncio
async def test_semantic_query_with_hops(self, subgraph_retriever, mock_adapter):
"""Test semantic query with N-hop neighborhood expansion."""
# Create a deterministic vector for the query
query_vec = np.ones(384, dtype=np.float32)
query_vec = query_vec / np.linalg.norm(query_vec)
subgraph_retriever.embedder.encode = MagicMock(return_value=query_vec)
entity_vec = np.ones(384, dtype=np.float32)
entity_vec = entity_vec / np.linalg.norm(entity_vec)
mock_adapter.execute_cypher = AsyncMock(
side_effect=[
# Entities with embeddings (must include 'type' field)
[
{
"entity": {
"id": 1,
"label": "Center",
"type": "company",
"confidence": 0.9,
"embedding": entity_vec.tolist(),
}
}
],
# Neighbors (2-hop)
[{"id": 2}, {"id": 3}],
# Nodes
[
{"node": {"id": 1, "label": "Center", "type": "company", "confidence": 0.9}},
{"node": {"id": 2, "label": "N1", "type": "person", "confidence": 0.85}},
{"node": {"id": 3, "label": "N2", "type": "person", "confidence": 0.8}},
],
# Edges
[],
]
)
result = await subgraph_retriever.retrieve_by_semantic_query(
query="test",
hops=2,
)
# Should include center and neighbors
assert result["node_count"] > 0
class TestNeighborhoodRetrieval:
"""Test N-hop neighborhood extraction."""
@pytest.mark.asyncio
async def test_retrieve_neighborhood_success(self, subgraph_retriever, mock_adapter):
"""Test successful neighborhood retrieval."""
mock_adapter.execute_cypher = AsyncMock(
side_effect=[
# Center entity query
[
{
"result": {
"center": {
"id": 1,
"label": "Apple",
"type": "company",
"confidence": 0.95,
},
"neighbor_ids": [2, 3],
"neighbor_count": 2,
}
}
],
# Nodes fetch
[
{"node": {"id": 1, "label": "Apple"}},
{"node": {"id": 2, "label": "Tim Cook"}},
{"node": {"id": 3, "label": "Steve Wozniak"}},
],
# Edges fetch
[
{
"edge": {
"source_id": 1,
"target_id": 2,
"predicate": "HAS_CEO",
"confidence": 0.95,
}
}
],
]
)
result = await subgraph_retriever.retrieve_neighborhood(
entity_id=1,
hops=2,
)
assert result["center_entity"]["id"] == 1
assert result["node_count"] == 3
assert len(result["edges"]) > 0
@pytest.mark.asyncio
async def test_retrieve_neighborhood_invalid_hops(self, subgraph_retriever):
"""Test neighborhood retrieval with invalid hops."""
# hops < 1
with pytest.raises(ValueError):
await subgraph_retriever.retrieve_neighborhood(
entity_id=1,
hops=0,
)
# hops > 3
with pytest.raises(ValueError):
await subgraph_retriever.retrieve_neighborhood(
entity_id=1,
hops=4,
)
@pytest.mark.asyncio
async def test_retrieve_neighborhood_entity_not_found(
self, subgraph_retriever, mock_adapter
):
"""Test neighborhood retrieval for non-existent entity."""
mock_adapter.execute_cypher = AsyncMock(return_value=[])
result = await subgraph_retriever.retrieve_neighborhood(entity_id=999)
assert result["center_entity"] is None
assert "error" in result
class TestInducedSubgraph:
"""Test induced subgraph extraction."""
@pytest.mark.asyncio
async def test_retrieve_induced_subgraph_success(
self, subgraph_retriever, mock_adapter
):
"""Test successful induced subgraph extraction."""
# Set up mock to return appropriate responses for each call
def side_effect_func(cypher, params):
if "WHERE n.id IN" in cypher and "RELATES" not in cypher:
# Nodes fetch
return [
{"node": {"id": 1, "label": "Apple"}},
{"node": {"id": 2, "label": "Microsoft"}},
]
elif "RELATES" in cypher:
# Edges fetch
return [
{
"edge": {
"source_id": 1,
"target_id": 2,
"predicate": "COMPETES_WITH",
"confidence": 0.85,
}
}
]
return []
mock_adapter.execute_cypher = AsyncMock(side_effect=side_effect_func)
result = await subgraph_retriever.retrieve_induced_subgraph(
entity_ids=[1, 2],
)
assert result["node_count"] >= 0 # May have 0 if mock doesn't match cypher
assert isinstance(result["edges"], list)
@pytest.mark.asyncio
async def test_retrieve_induced_subgraph_empty_list(self, subgraph_retriever):
"""Test induced subgraph with empty entity list."""
result = await subgraph_retriever.retrieve_induced_subgraph(
entity_ids=[],
)
assert "error" in result
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@@ -1,17 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<title>Neroli Summer Eau de Parfum</title>
</head>
<body>
<main>
<h1>Neroli Summer Eau de Parfum</h1>
<p>Brand: Example Maison</p>
<p>Top notes: Bergamot, Neroli</p>
<p>Middle notes: Jasmine</p>
<p>Base notes: Musk</p>
<p>A fresh daily perfume for summer office wear.</p>
<p>Price $89</p>
</main>
</body>
</html>

View File

@@ -1,221 +0,0 @@
"""Phase 5 GraphRAG + Phase 7 LLM integration tests.
Validates that Phase 5 enhances Phase 7 LLM's RAG pipeline:
- Entity deduplication improves graph quality
- Semantic query retrieves relevant context for LLM
- Pattern analysis detects data inconsistencies
"""
import pytest
from unittest.mock import AsyncMock, MagicMock
from datetime import datetime, UTC
class TestEntityResolutionEnhancesLLM:
"""Test how entity resolution improves LLM context."""
@pytest.mark.asyncio
async def test_duplicate_entities_merged_before_rag(self):
"""Test that duplicate entities are merged before RAG context retrieval."""
from ont_platform.core.graph import EntityResolver
resolver = EntityResolver(
vector_threshold=0.85,
text_threshold=0.88,
)
# Simulate entities that are duplicates with slight variations
entities = [
{"id": 1, "label": "Apple Inc", "type": "company"},
{"id": 2, "label": "Apple Incorporated", "type": "company"},
{"id": 3, "label": "Microsoft Corporation", "type": "company"},
{"id": 4, "label": "Microsoft Corp", "type": "company"},
]
# Initialize embedder
result = await resolver.initialize_embedder()
assert result is True
# Detect duplicates
clusters = await resolver.detect_duplicates(entities)
# Should find at least 2 clusters (Apple duplicates and Microsoft duplicates)
assert len(clusters) >= 0 # May vary based on similarity thresholds
assert all(c.confidence >= 0.5 for c in clusters)
class TestSemanticQueryForLLMContext:
"""Test semantic query retrieval for LLM RAG."""
@pytest.mark.asyncio
async def test_semantic_query_finds_relevant_context(self):
"""Test that semantic queries find relevant entities for LLM context."""
from ont_platform.core.graph import SubgraphRetriever
from unittest.mock import AsyncMock
import numpy as np
mock_adapter = AsyncMock()
embedder = MagicMock()
# Create embedder that returns consistent vectors
query_vec = np.ones(384, dtype=np.float32)
query_vec = query_vec / np.linalg.norm(query_vec)
embedder.encode = MagicMock(return_value=query_vec)
retriever = SubgraphRetriever(adapter=mock_adapter, embedder=embedder)
# Setup mock to return relevant entities
entity_vec = np.ones(384, dtype=np.float32)
entity_vec = entity_vec / np.linalg.norm(entity_vec)
mock_adapter.execute_cypher = AsyncMock(
side_effect=[
# Entities matching query "tech companies"
[
{
"entity": {
"id": 1,
"label": "Apple Inc",
"type": "company",
"confidence": 0.95,
"embedding": entity_vec.tolist(),
}
},
{
"entity": {
"id": 2,
"label": "Microsoft",
"type": "company",
"confidence": 0.92,
"embedding": entity_vec.tolist(),
}
},
],
# Neighbors
[],
# Nodes
[
{"node": {"id": 1, "label": "Apple Inc", "type": "company"}},
{"node": {"id": 2, "label": "Microsoft", "type": "company"}},
],
# Edges
[],
]
)
result = await retriever.retrieve_by_semantic_query(
query="tech companies",
top_k=10,
min_similarity=0.6,
)
# Should find relevant entities
assert result["matched_count"] >= 0
assert "matched_entities" in result
assert len(result["nodes"]) >= 0
class TestGraphAnalyticsForDataQuality:
"""Test graph analytics for data quality assurance."""
@pytest.mark.asyncio
async def test_centrality_identifies_important_entities(self):
"""Test that centrality analysis identifies important entities for RAG."""
# This test validates that graph analytics can identify
# which entities are most relevant for RAG context
assert True # Placeholder for integration with actual GraphAnalytics
@pytest.mark.asyncio
async def test_pattern_matching_detects_inconsistencies(self):
"""Test that pattern matching detects data inconsistencies."""
# This test validates that cycles/paths detection helps ensure
# graph integrity before using it for RAG
assert True # Placeholder for integration with actual PatternMatcher
class TestPhase5EnhancesPhase7RAGPipeline:
"""Integration test: Phase 5 + Phase 7 RAG pipeline."""
@pytest.mark.asyncio
async def test_rag_context_quality_with_phase5(self):
"""Test that Phase 5 improves RAG context quality."""
# Expected workflow:
# 1. Extract entities from documents (Phase 0-4)
# 2. Detect and merge duplicates (Phase 5 EntityResolver)
# 3. Retrieve semantic context (Phase 5 SubgraphRetriever)
# 4. Pass to LLM for generation (Phase 7)
# Verify components work together
from ont_platform.core.graph import EntityResolver
resolver = EntityResolver()
assert resolver.vector_threshold == 0.85
assert resolver.text_threshold == 0.88
@pytest.mark.asyncio
async def test_end_to_end_entity_resolution_pipeline(self):
"""Test complete entity resolution pipeline."""
from ont_platform.core.graph import EntityResolver, EntityCluster
resolver = EntityResolver()
# Initialize embedder
await resolver.initialize_embedder()
# Test data: duplicate entities with variations
entities = [
{"id": 1, "label": "Apple", "type": "company"},
{"id": 2, "label": "Apple Inc", "type": "company"}, # Duplicate
{"id": 3, "label": "Google", "type": "company"},
{"id": 4, "label": "Alphabet", "type": "company"}, # Potential duplicate
]
# Detect duplicates
clusters = await resolver.detect_duplicates(entities)
# Generate report
report = resolver.get_resolution_report(clusters)
# Validate report structure
assert "total_clusters" in report
assert "total_duplicates" in report
assert "avg_confidence" in report
assert "by_reason" in report
assert "timestamp" in report
# Validate each cluster can be resolved
entities_map = {e["id"]: e for e in entities}
for cluster in clusters:
merged = await resolver.resolve_cluster(cluster, entities_map)
# Validate merged entity has required fields
assert "id" in merged
assert "label" in merged
assert "merged_from" in merged or merged.get("id") in [e["id"] for e in entities]
print(f"✅ Entity resolution pipeline: {len(clusters)} clusters, "
f"{report['total_duplicates']} duplicates detected")
class TestRAGContextRelevance:
"""Test that Phase 5 improves RAG context relevance."""
@pytest.mark.asyncio
async def test_semantic_context_more_relevant_than_random(self):
"""Test that semantic context selection is better than random."""
# This demonstrates that Phase 5 semantic queries should return
# more relevant entities than a random selection would
assert True # Conceptual test - validates the approach
@pytest.mark.asyncio
async def test_deduplicated_graph_smaller_and_cleaner(self):
"""Test that deduplication reduces graph noise."""
# Before deduplication: 100 entities (with duplicates)
# After deduplication: 80 entities (20 removed as duplicates)
# Result: Smaller, cleaner graph for RAG
assert True # Conceptual test - validates the benefit
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@@ -1,12 +0,0 @@
from pathlib import Path
from crawler_platform.app.config.loader import load_project_config
def test_load_perfume_config():
config = load_project_config(Path("configs/perfume_subscription.yaml"))
assert config.project_name == "perfume_subscription"
assert config.domain == "perfume"
assert config.source_by_name("official_brand_site").trust_level == 0.95

View File

@@ -1,457 +0,0 @@
import json
from crawler_platform.app.config.loader import load_project_config
from crawler_platform.app.core.database import models
from crawler_platform.app.core.database.repository import KnowledgeRepository
from crawler_platform.app.core.database.session import make_engine
from crawler_platform.app.core.crawler.html_cleaner import clean_html_with_metadata
from crawler_platform.app.core.extractor.base import (
ExtractedClaim,
ExtractedEntity,
ExtractionPageContext,
ExtractionBundle,
)
from crawler_platform.app.core.extractor.ai_provider import parse_json_content
from crawler_platform.app.core.extractor.ai_provider import LLMJsonExtractor
from crawler_platform.app.core.extractor.validation import attach_page_context, validate_extraction_bundle
from crawler_platform.app.domains.perfume.extractor import PerfumeRuleBasedExtractor
def test_cleaner_prefers_product_content_and_removes_boilerplate():
html = """
<html>
<head><title>Sample Product</title></head>
<body>
<header>CAFE24 Login Cart Low price Product count</header>
<nav>Home Board Event Shipping</nav>
<main class="product-detail">
<h1>Neroli Summer Eau de Parfum</h1>
<p>Brand: Example Maison</p>
<p>Top notes: Bergamot, Neroli</p>
<p>Base notes: Musk</p>
<p>Price $89</p>
</main>
<footer>Powered by CAFE24 Copyright Shipping Country List</footer>
</body>
</html>
"""
cleaned = clean_html_with_metadata(html)
assert "Neroli Summer Eau de Parfum" in cleaned.text
assert "Bergamot" in cleaned.text
assert "CAFE24" not in cleaned.text
assert "Low price" not in cleaned.text
assert cleaned.page_type == "ProductPage"
assert cleaned.extraction_status == "success"
assert any(zone["zone_type"] == "product_detail" for zone in cleaned.source_zones)
def test_cleaner_keeps_raw_and_clean_text_separate_with_noise_debug():
html = """
<html>
<head><title>Sample Product</title></head>
<body>
<header>CAFE24 Login Cart Low price Product count</header>
<main class="product-detail">
<h1>Neroli Summer Eau de Parfum</h1>
<p>Brand: Example Maison</p>
<p>Top notes: Bergamot, Neroli</p>
<p>Price $89</p>
</main>
<section class="shipping">Shipping delivery return exchange country list Korea USA Japan Canada</section>
</body>
</html>
"""
cleaned = clean_html_with_metadata(html, url="https://example.com/product/detail.html")
assert "CAFE24" in cleaned.raw_text
assert "CAFE24" not in cleaned.text
assert "Shipping delivery" not in cleaned.text
assert cleaned.clean_markdown
assert cleaned.metadata["removed_noise_zones_count"] >= 1
def test_cleaner_handles_empty_dom_attributes():
html = """
<html>
<body>
<div id class aria-label>
<p>Brand: Example Maison</p>
<p>Top notes: Bergamot, Neroli</p>
</div>
</body>
</html>
"""
cleaned = clean_html_with_metadata(html)
assert "Example Maison" in cleaned.text
def test_cleaner_skips_decomposed_nodes_during_boilerplate_scan():
from bs4 import BeautifulSoup
soup = BeautifulSoup("<div id='footer'><span class='child'>x</span></div>", "html.parser")
tag = soup.span
assert tag is not None
tag.decompose()
from crawler_platform.app.core.crawler.html_cleaner import node_attr
assert node_attr(tag, "class") == ""
def test_validation_rejects_placeholders_and_marks_rule_candidates():
config = load_project_config("configs/perfume_subscription.yaml")
bundle = ExtractionBundle(
entities=[
ExtractedEntity("Brand", "CAFE24"),
ExtractedEntity("Perfume", "Neroli Summer"),
ExtractedEntity("Accord", "Green"),
],
claims=[
ExtractedClaim("Neroli Summer", "Perfume", "hasBrand", "CAFE24", "Brand", evidence_text="Brand: CAFE24"),
ExtractedClaim(
"Neroli Summer",
"Perfume",
"hasTopNote",
"Bergamot",
"Note",
evidence_text="Top notes: Bergamot",
confidence=0.82,
),
ExtractedClaim("Neroli Summer", "Perfume", "hasAccord", "Green", "Accord", evidence_text="Accord: Green"),
],
extractor_name="perfume_rule_based",
provider="rule_based",
raw_output={},
)
result = validate_extraction_bundle(bundle, config)
assert result.claim_status == "rule_candidate"
assert [claim.object_name for claim in result.bundle.claims] == ["Bergamot"]
assert all(claim.metadata["validation_status"] == "rule_candidate" for claim in result.bundle.claims)
assert len(result.rejected_claims) == 2
def test_validation_rejects_product_detail_claims_on_category_page_context():
config = load_project_config("configs/perfume_subscription.yaml")
bundle = ExtractionBundle(
entities=[ExtractedEntity("Perfume", "Neroli Summer", evidence_text="Neroli Summer")],
claims=[
ExtractedClaim(
"Neroli Summer",
"Perfume",
"hasPrice",
object_value={"amount": 89, "currency": "USD"},
evidence_text="Neroli Summer Price $89",
)
],
extractor_name="llm_json_extractor",
provider="lm_studio",
raw_output={"extraction_mode": "primary"},
)
context = ExtractionPageContext(
url="https://example.com/category/perfume",
final_url="https://example.com/category/perfume",
title="Perfume list",
page_type="CategoryPage",
clean_text="Neroli Summer Price $89",
source_zones=[
{
"zone_type": "product_summary",
"selector": ".product-list",
"text": "Neroli Summer Price $89",
"claim_allowed": True,
}
],
)
result = validate_extraction_bundle(attach_page_context(bundle, context), config)
assert result.claim_status == "candidate_claim"
assert result.bundle.claims == []
assert result.rejected_claims[0]["reason"] == "predicate hasPrice is not allowed for page type CategoryPage"
def test_repository_persists_rule_candidates_without_merging_graph_relations():
config = load_project_config("configs/perfume_subscription.yaml")
engine = make_engine("sqlite:///:memory:")
models.Base.metadata.create_all(engine)
from sqlalchemy.orm import Session
session = Session(engine)
repo = KnowledgeRepository(session)
project = repo.upsert_project(config)
source = repo.get_source(project.id, "official_brand_site")
page = repo.upsert_page(project.id, source.id, "https://example.com/product", "Product", 200, "Neroli Summer")
bundle = ExtractionBundle(
entities=[ExtractedEntity("Perfume", "Neroli Summer")],
claims=[
ExtractedClaim(
"Neroli Summer",
"Perfume",
"hasTopNote",
"Bergamot",
"Note",
evidence_text="Top notes: Bergamot",
confidence=0.9,
)
],
extractor_name="perfume_rule_based",
provider="rule_based",
raw_output={},
)
context = ExtractionPageContext(
url="https://example.com/product",
final_url="https://example.com/product",
title="Product",
page_type="ProductPage",
clean_text="Neroli Summer\nTop notes: Bergamot",
source_zones=[
{
"zone_type": "product_description",
"selector": ".description",
"text": "Top notes: Bergamot",
"claim_allowed": True,
}
],
)
claims = repo.save_extraction_bundle(project.id, source, page, attach_page_context(bundle, context), config)
session.commit()
assert len(claims) == 1
assert claims[0].status == "rule_candidate"
assert session.query(models.Claim).count() == 1
assert session.query(models.Entity).count() == 2
assert session.query(models.ExtractionLog).count() == 1
assert session.query(models.Relation).count() == 0
session.close()
def test_parse_json_content_accepts_markdown_fenced_json():
raw = """```json
{"entities": [], "claims": []}
```"""
assert parse_json_content(raw) == {"entities": [], "claims": []}
def test_lm_studio_extractor_merges_rule_claims_even_when_ai_claim_is_invalid():
config = load_project_config("configs/perfume_subscription.yaml")
class StubExtractor(LLMJsonExtractor):
def complete_json(self, page_text, project_config, compact=False, context=None):
return {
"entities": [
{
"entity_type": "Brand",
"name": "912",
"attributes": {},
"confidence": 0.9,
"evidence_text": "912",
}
],
"claims": [
{
"subject_name": "912",
"subject_type": "Brand",
"predicate": "hasBrand",
"object_name": "912",
"object_type": "Brand",
"object_value": None,
"evidence_text": "912",
"evidence_summary": "bad self-brand claim",
"confidence": 0.9,
"confidence_reason": "stub",
"source_zone": "product_title",
}
],
}
text = "Neroli Summer\nTop notes: Bergamot\nPrice $89"
bundle = StubExtractor("perfume", "lm_studio", model="stub").extract(text, config)
assert any(claim.predicate == "hasTopNote" for claim in bundle.claims)
def test_the912_product_rule_fallback_uses_product_title_brand_and_price():
config = load_project_config("configs/perfume_subscription.yaml")
text = """
새로운 향수의 시작, 클론 향수
[2+1 기획] 912 클론 니치향수 모음 40ml
4.8
114,414
일반 구매가격
72,000원
할인 적용금액
37,000원
최종 구매금액
35,000원
현재 위치
전체 상품
니치향수
오드 퍼퓸
"""
bundle = PerfumeRuleBasedExtractor().extract(text, config)
context = ExtractionPageContext(
url="https://the912.co.kr/product/detail.html?product_no=513",
final_url="https://the912.co.kr/product/detail.html?product_no=513",
title="[2+1 기획] 912 클론 니치향수 모음 40ml - 912",
page_type="ProductPage",
clean_text=text,
source_zones=[
{
"zone_type": "product_title",
"selector": "h1",
"text": "[2+1 기획] 912 클론 니치향수 모음 40ml",
"claim_allowed": True,
},
{
"zone_type": "product_summary",
"selector": ".detail",
"text": "일반 구매가격\n72,000원\n할인 적용금액\n37,000원\n최종 구매금액\n35,000원",
"claim_allowed": True,
},
],
)
result = validate_extraction_bundle(attach_page_context(bundle, context), config)
predicates = {claim.predicate for claim in result.bundle.claims}
perfume_names = {entity.name for entity in result.bundle.entities if entity.entity_type == "Perfume"}
assert "[2+1 기획] 912 클론 니치향수 모음 40ml" in perfume_names
assert {"hasBrand", "hasPrice"} <= predicates
def test_parse_json_content_requires_structured_claim_schema():
raw = {
"entities": [
{
"entity_type": "Perfume",
"name": "Neroli Summer",
"attributes": {},
"confidence": 0.9,
"evidence_text": "Neroli Summer",
}
],
"claims": [
{
"subject_name": "Neroli Summer",
"subject_type": "Perfume",
"predicate": "hasTopNote",
"object_name": "Bergamot",
"object_type": "FragranceNote",
"object_value": None,
"evidence_text": "Top notes: Bergamot",
"evidence_summary": "explicit note listing",
"confidence": 0.9,
"confidence_reason": "directly stated",
"source_zone": "product_description",
}
],
}
parsed = parse_json_content(json.dumps(raw))
assert parsed["claims"][0]["source_zone"] == "product_description"
def test_parse_json_content_rejects_claim_without_evidence():
raw = {
"entities": [],
"claims": [
{
"subject_name": "Neroli Summer",
"subject_type": "Perfume",
"predicate": "hasTopNote",
"object_name": "Bergamot",
"object_type": "Note",
"object_value": None,
"evidence_text": "",
"evidence_summary": "missing evidence",
"confidence": 0.9,
"confidence_reason": "bad",
"source_zone": "product_description",
}
],
}
try:
parse_json_content(json.dumps(raw))
except ValueError as exc:
assert "schema_validation_failed" in str(exc)
else:
raise AssertionError("expected schema validation failure")
def test_repository_merges_only_validated_typed_claims_to_graph():
config = load_project_config("configs/perfume_subscription.yaml")
engine = make_engine("sqlite:///:memory:")
models.Base.metadata.create_all(engine)
from sqlalchemy.orm import Session
session = Session(engine)
repo = KnowledgeRepository(session)
project = repo.upsert_project(config)
source = repo.get_source(project.id, "official_brand_site")
page = repo.upsert_page(
project.id,
source.id,
"https://example.com/product/detail.html",
"Neroli Summer",
200,
"Neroli Summer\nTop notes: Bergamot",
)
bundle = ExtractionBundle(
entities=[
ExtractedEntity("Perfume", "Neroli Summer", evidence_text="Neroli Summer", confidence=0.9),
ExtractedEntity("FragranceNote", "Bergamot", evidence_text="Top notes: Bergamot", confidence=0.9),
],
claims=[
ExtractedClaim(
"Neroli Summer",
"Perfume",
"hasTopNote",
"Bergamot",
"FragranceNote",
evidence_text="Top notes: Bergamot",
confidence=0.92,
confidence_reason="directly stated",
)
],
extractor_name="llm_json_extractor",
provider="lm_studio",
raw_output={"extraction_mode": "primary"},
)
context = ExtractionPageContext(
url="https://example.com/product/detail.html",
final_url="https://example.com/product/detail.html",
title="Neroli Summer",
page_type="ProductPage",
clean_text="Neroli Summer\nTop notes: Bergamot",
source_zones=[
{
"zone_type": "product_description",
"selector": ".description",
"text": "Top notes: Bergamot",
"claim_allowed": True,
}
],
)
claims = repo.save_extraction_bundle(project.id, source, page, attach_page_context(bundle, context), config)
session.commit()
assert len(claims) == 1
assert claims[0].status == "validated_claim"
assert claims[0].metadata_json["graph_merge_status"] == "merged"
assert session.query(models.Relation).count() == 1
assert session.query(models.Entity).filter(models.Entity.entity_type == "Note").count() == 1
session.close()

View File

@@ -1,208 +0,0 @@
from sqlalchemy.orm import Session
from crawler_platform.app.config.loader import ProjectConfig, SourceConfig, load_project_config
from crawler_platform.app.core.database import models
from crawler_platform.app.core.database.repository import KnowledgeRepository
from crawler_platform.app.core.database.session import make_engine
from crawler_platform.app.core.extractor.base import ExtractedClaim, ExtractedEntity, ExtractionBundle, ExtractionPageContext
from crawler_platform.app.core.extractor.validation import attach_page_context
from crawler_platform.app.core.ontology.gap_detector import KnowledgeGapDetector
from crawler_platform.app.core.ontology.registry import OntologyRegistry
from crawler_platform.app.core.research.graph_query import SemanticGraphQuery
def make_repo():
config = load_project_config("configs/perfume_subscription.yaml")
engine = make_engine("sqlite:///:memory:")
models.Base.metadata.create_all(engine)
session = Session(engine)
repo = KnowledgeRepository(session)
project = repo.upsert_project(config)
source = repo.get_source(project.id, "official_brand_site")
return config, session, repo, project, source
def save_validated_note_claim(repo, project, source, config):
page = repo.upsert_page(
project.id,
source.id,
"https://example.com/product/cotton",
"Cotton Example",
200,
"Cotton Example\nTop notes: Bergamot",
)
bundle = ExtractionBundle(
entities=[
ExtractedEntity("Perfume", "Cotton Example", evidence_text="Cotton Example", confidence=0.95),
ExtractedEntity("Note", "Bergamot", evidence_text="Top notes: Bergamot", confidence=0.95),
],
claims=[
ExtractedClaim(
"Cotton Example",
"Perfume",
"hasTopNote",
"Bergamot",
"Note",
evidence_text="Top notes: Bergamot",
confidence=0.96,
confidence_reason="direct evidence",
)
],
extractor_name="llm_json_extractor",
provider="lm_studio",
raw_output={"extraction_mode": "primary"},
)
context = ExtractionPageContext(
url=page.url,
final_url=page.url,
title=page.title,
page_type="ProductPage",
clean_text=page.cleaned_text_summary,
source_zones=[
{
"zone_type": "product_description",
"selector": "main",
"text": "Top notes: Bergamot",
"claim_allowed": True,
}
],
)
return repo.save_extraction_bundle(project.id, source, page, attach_page_context(bundle, context), config)
def test_project_upsert_seeds_schema_registry():
_config, session, _repo, project, _source = make_repo()
registry = OntologyRegistry(session).registry_payload(project.id)
entity_names = {row["name"] for row in registry["entity_types"]}
relation_names = {row["name"] for row in registry["relation_types"]}
assert {"Entity", "Product", "Perfume"}.issubset(entity_names)
assert {"relatedTo", "hasTopNote", "hasBrand"}.issubset(relation_names)
session.close()
def test_validated_claim_creates_separate_ontology_triple():
config, session, repo, project, source = make_repo()
claims = save_validated_note_claim(repo, project, source, config)
triples = session.query(models.OntologyTriple).all()
assert len(claims) == 1
assert len(triples) == 1
assert triples[0].predicate == "hasTopNote"
assert triples[0].status == "merged"
assert claims[0].metadata_json["ontology_triple_id"] == triples[0].id
session.close()
def test_schema_proposal_and_knowledge_gap_are_governance_records():
_config, session, _repo, project, _source = make_repo()
registry = OntologyRegistry(session)
proposal = registry.propose_schema_change(
project.id,
proposal_type="relation_type",
name="influencedBy",
reason="New cross-domain relation observed in evidence.",
evidence="A research paper influenced a product strategy.",
confidence=0.73,
)
gaps = KnowledgeGapDetector(session).list_open(project.id)
assert proposal.status == "pending_review"
assert any(gap["gap_type"] == "schema_governance" and gap["target_name"] == "influencedBy" for gap in gaps)
session.close()
def test_generic_graph_query_uses_ontology_triples():
config, session, repo, project, source = make_repo()
save_validated_note_claim(repo, project, source, config)
rows = SemanticGraphQuery(session).relation_summary(project.id)
assert rows[0]["predicate"] == "hasTopNote"
assert rows[0]["support_count"] == 1
session.close()
def test_config_driven_academic_relation_without_perfume_adapter():
config = ProjectConfig(
project_name="academic_demo",
domain="academic",
target_entities=["ResearchPaper", "Person"],
fields=["title", "author"],
sources=[SourceConfig(name="research_site", type="paper_index", trust_level=0.9)],
ontology={
"entity_types": ["ResearchPaper", "Person"],
"predicates": ["authoredBy"],
"relation_types": {
"authoredBy": {
"allowed_subject_types": ["ResearchPaper"],
"allowed_object_types": ["Person"],
"allowed_page_types": ["ArticlePage"],
"allowed_source_zones": ["article_body"],
"confidence_rules": {"min_confidence": 0.82},
}
},
},
)
engine = make_engine("sqlite:///:memory:")
models.Base.metadata.create_all(engine)
session = Session(engine)
repo = KnowledgeRepository(session)
project = repo.upsert_project(config)
source = repo.get_source(project.id, "research_site")
page = repo.upsert_page(
project.id,
source.id,
"https://example.org/papers/semantic-systems",
"Semantic Systems",
200,
"Semantic Systems was authored by Ada Kim.",
)
bundle = ExtractionBundle(
entities=[
ExtractedEntity("ResearchPaper", "Semantic Systems", evidence_text="Semantic Systems", confidence=0.95),
ExtractedEntity("Person", "Ada Kim", evidence_text="authored by Ada Kim", confidence=0.95),
],
claims=[
ExtractedClaim(
"Semantic Systems",
"ResearchPaper",
"authoredBy",
"Ada Kim",
"Person",
evidence_text="Semantic Systems was authored by Ada Kim.",
confidence=0.97,
confidence_reason="directly stated",
)
],
extractor_name="llm_json_extractor",
provider="lm_studio",
raw_output={"extraction_mode": "primary"},
)
context = ExtractionPageContext(
url=page.url,
final_url=page.url,
title=page.title,
page_type="ArticlePage",
clean_text=page.cleaned_text_summary,
source_zones=[
{
"zone_type": "article_body",
"selector": "article",
"text": "Semantic Systems was authored by Ada Kim.",
"claim_allowed": True,
}
],
)
claims = repo.save_extraction_bundle(project.id, source, page, attach_page_context(bundle, context), config)
triples = session.query(models.OntologyTriple).all()
assert len(claims) == 1
assert triples[0].predicate == "authoredBy"
assert triples[0].status == "merged"
assert session.query(models.OntologyRelationType).filter_by(name="authoredBy").one().domain == "academic"
session.close()

View File

@@ -1,31 +0,0 @@
from crawler_platform.app.config.loader import ProjectConfig, SourceConfig
from crawler_platform.app.domains.perfume.extractor import PerfumeRuleBasedExtractor
def test_perfume_rule_based_extractor_maps_claims():
config = ProjectConfig(
project_name="perfume_subscription",
domain="perfume",
target_entities=["Perfume", "Brand", "Note"],
fields=["name", "brand", "top_notes", "middle_notes", "base_notes"],
sources=[SourceConfig(name="official_brand_site", trust_level=0.95)],
ontology={"aliases": {"top_notes": "hasTopNote"}},
)
text = """
Neroli Summer Eau de Parfum
Brand: Example Maison
Top notes: Bergamot, Neroli
Middle notes: Jasmine
Base notes: Musk
A fresh daily perfume for summer office wear. Price $89.
"""
bundle = PerfumeRuleBasedExtractor().extract(text, config)
predicates = {claim.predicate for claim in bundle.claims}
entity_names = {entity.name for entity in bundle.entities}
assert "Neroli Summer Eau de Parfum" in entity_names
assert "Bergamot" in entity_names
assert "hasTopNote" in predicates
assert "suitableForSeason" in predicates
assert "hasPrice" in predicates

View File

@@ -1,541 +0,0 @@
"""Phase 7 LLM Integration Tests.
Tests for:
- LLM provider abstraction
- Streaming responses
- Caching mechanism
- RAG + LLM pipeline
- Error handling
"""
import asyncio
import json
import pytest
from typing import AsyncGenerator
from unittest.mock import AsyncMock, MagicMock, patch
from ont_platform.llm.llm_integration import (
LLMProvider,
LLMConfig,
LLMManager,
)
# ============================================================================
# Fixtures
# ============================================================================
@pytest.fixture
def openai_config():
"""OpenAI configuration."""
return LLMConfig(
provider=LLMProvider.OPENAI,
api_key="sk-test-key",
model="gpt-4",
temperature=0.7,
max_tokens=500,
)
@pytest.fixture
def anthropic_config():
"""Anthropic configuration."""
return LLMConfig(
provider=LLMProvider.ANTHROPIC,
api_key="sk-ant-test-key",
model="claude-3-opus",
temperature=0.7,
max_tokens=500,
)
@pytest.fixture
def local_config():
"""Local LLM configuration."""
return LLMConfig(
provider=LLMProvider.LOCAL,
model="llama2",
base_url="http://localhost:1234/v1",
temperature=0.7,
max_tokens=500,
)
# ============================================================================
# LLMConfig Tests
# ============================================================================
class TestLLMConfig:
"""LLMConfig initialization and validation."""
def test_openai_config_creation(self, openai_config):
"""OpenAI config should be created successfully."""
assert openai_config.provider == LLMProvider.OPENAI
assert openai_config.model == "gpt-4"
assert openai_config.temperature == 0.7
assert openai_config.max_tokens == 500
def test_anthropic_config_creation(self, anthropic_config):
"""Anthropic config should be created successfully."""
assert anthropic_config.provider == LLMProvider.ANTHROPIC
assert anthropic_config.model == "claude-3-opus"
def test_local_config_creation(self, local_config):
"""Local config should be created successfully."""
assert local_config.provider == LLMProvider.LOCAL
assert local_config.base_url == "http://localhost:1234/v1"
def test_config_temperature_bounds(self):
"""Temperature should be valid (0.0 - 2.0)."""
config = LLMConfig(
provider=LLMProvider.OPENAI,
api_key="test",
temperature=0.0, # Min
)
assert config.temperature == 0.0
config = LLMConfig(
provider=LLMProvider.OPENAI,
api_key="test",
temperature=2.0, # Max
)
assert config.temperature == 2.0
# ============================================================================
# LLMManager Tests
# ============================================================================
class TestLLMManager:
"""LLMManager client selection and orchestration."""
def test_openai_manager_creation(self, openai_config):
"""LLMManager should create OpenAI client (or handle import error)."""
try:
manager = LLMManager(openai_config)
assert manager.config == openai_config
assert manager.client is not None
except ImportError as e:
# openai not installed, which is fine for testing
assert "openai" in str(e).lower()
def test_anthropic_manager_creation(self, anthropic_config):
"""LLMManager should create Anthropic client (or handle import error)."""
try:
manager = LLMManager(anthropic_config)
assert manager.config == anthropic_config
assert manager.client is not None
except ImportError as e:
# anthropic not installed, which is fine for testing
assert "anthropic" in str(e).lower()
def test_local_manager_creation(self, local_config):
"""LLMManager should create Local client (or handle import error)."""
try:
manager = LLMManager(local_config)
assert manager.config == local_config
assert manager.client is not None
except ImportError as e:
# httpx not installed, which is fine for testing
assert "httpx" in str(e).lower()
def test_manager_config_update(self, openai_config):
"""LLMManager config should be updatable."""
try:
manager = LLMManager(openai_config)
manager.config.temperature = 0.5
assert manager.config.temperature == 0.5
manager.config.max_tokens = 1000
assert manager.config.max_tokens == 1000
except ImportError:
# Libraries not installed, which is fine for testing
pass
# ============================================================================
# OpenAI Client Tests
# ============================================================================
class TestOpenAIClient:
"""OpenAI client generation and streaming."""
def test_openai_generate_non_streaming(self, openai_config):
"""OpenAI client initialization should work (or handle import error)."""
try:
from ont_platform.llm.llm_integration import OpenAIClient
# Just test that it can be instantiated
client = OpenAIClient(openai_config)
assert client.config == openai_config
except ImportError:
# openai not installed, which is fine
pass
def test_openai_generate_streaming(self, openai_config):
"""OpenAI client should support streaming interface."""
try:
from ont_platform.llm.llm_integration import OpenAIClient
# Test that the streaming method is defined
client = OpenAIClient(openai_config)
assert hasattr(client, 'generate_stream')
assert callable(client.generate_stream)
except ImportError:
# openai not installed, which is fine
pass
# ============================================================================
# Streaming Tests
# ============================================================================
class TestStreamingResponses:
"""Server-Sent Events streaming functionality."""
def test_stream_format(self):
"""Streaming should produce valid SSE format."""
# SSE format: "data: {json}\n\n"
stream_data = "data: {\"type\": \"token\", \"content\": \"hello\"}\n\n"
lines = stream_data.strip().split("\n\n")
assert len(lines) == 1
data_line = lines[0]
assert data_line.startswith("data: ")
json_str = data_line[6:] # Remove "data: "
parsed = json.loads(json_str)
assert parsed["type"] == "token"
assert parsed["content"] == "hello"
def test_metadata_streaming(self):
"""Streaming should include metadata."""
metadata = {
"type": "metadata",
"query": "What is AI?",
"context_nodes": 50,
"relevant_entities": ["AI", "Machine Learning", "Deep Learning"],
}
sse_line = f"data: {json.dumps(metadata)}\n\n"
assert "metadata" in sse_line
assert "query" in sse_line
def test_completion_signal_streaming(self):
"""Streaming should send completion signal."""
completion = {
"type": "complete",
"total_tokens": 100,
}
sse_line = f"data: {json.dumps(completion)}\n\n"
assert "complete" in sse_line
assert "100" in sse_line
# ============================================================================
# Caching Tests
# ============================================================================
class TestCaching:
"""Response caching with Redis."""
def test_cache_key_generation(self):
"""Cache keys should be deterministic and consistent."""
import hashlib
query = "What is the meaning of life?"
context_hops = 2
key_data = f"{query}:{context_hops}"
key_hash = hashlib.sha256(key_data.encode()).hexdigest()[:16]
cache_key = f"phase7:rag:{key_hash}"
# Same input should produce same key
key_data2 = f"{query}:{context_hops}"
key_hash2 = hashlib.sha256(key_data2.encode()).hexdigest()[:16]
cache_key2 = f"phase7:rag:{key_hash2}"
assert cache_key == cache_key2
def test_cache_key_uniqueness(self):
"""Different queries should produce different cache keys."""
import hashlib
def make_key(query, hops):
key_data = f"{query}:{hops}"
key_hash = hashlib.sha256(key_data.encode()).hexdigest()[:16]
return f"phase7:rag:{key_hash}"
key1 = make_key("What is AI?", 2)
key2 = make_key("What is ML?", 2)
key3 = make_key("What is AI?", 3)
assert key1 != key2
assert key1 != key3
assert key2 != key3
def test_cache_hit_detection(self):
"""Cached response should be detected."""
cached_response = {
"query": "Test query",
"answer": "Test answer",
"context_size": 10,
"relevant_entities": ["Entity1"],
"latency_ms": 100,
"cached": False,
"model": "gpt-4",
"provider": "openai",
}
# Simulate Redis cache hit
assert cached_response is not None
assert isinstance(cached_response, dict)
assert "answer" in cached_response
def test_response_serialization(self):
"""Cached response should be JSON serializable."""
response = {
"query": "What is ontology?",
"answer": "Ontology is...",
"context_size": 25,
"relevant_entities": ["Entity1", "Entity2"],
"latency_ms": 150.5,
"cached": False,
"model": "gpt-4",
"provider": "openai",
}
# Should serialize to JSON without errors
json_str = json.dumps(response, default=str)
parsed = json.loads(json_str)
assert parsed["query"] == response["query"]
assert parsed["latency_ms"] == response["latency_ms"]
# ============================================================================
# RAG Pipeline Tests
# ============================================================================
class TestRAGPipeline:
"""RAG context extraction and prompt building."""
def test_rag_prompt_structure(self):
"""RAG prompt should include context and query."""
query = "What are the main features?"
context = {
"relevant_entities": ["Feature1", "Feature2", "Feature3"],
"nodes": [
{"label": "Feature1"},
{"label": "Feature2"},
],
}
prompt = f"""당신은 지식 그래프 기반 질문 답변 어시스턴트입니다.
다음 지식 그래프 정보를 참고하여 질문에 답변해주세요.
=== 지식 그래프 컨텍스트 ===
관련 엔티티:
- Feature1
- Feature2
=== 사용자 질문 ===
{query}
위의 지식 그래프 정보를 바탕으로 명확하고 정확한 답변을 제공해주세요."""
assert query in prompt
assert "지식 그래프" in prompt
assert "Feature1" in prompt or "관련 엔티티" in prompt
def test_rag_context_formatting(self):
"""RAG context should be properly formatted."""
context = {
"relevant_entities": ["Apple", "iPhone", "Steve Jobs"],
"nodes": [
{"label": "Apple", "type": "Company"},
{"label": "iPhone", "type": "Product"},
],
}
# Check context structure
assert "relevant_entities" in context
assert "nodes" in context
assert len(context["relevant_entities"]) == 3
assert len(context["nodes"]) == 2
def test_rag_metadata_inclusion(self):
"""RAG metadata should be included in response."""
metadata = {
"query": "What is Apple?",
"context_nodes": 45,
"relevant_entities": ["Apple", "iPhone"],
"extraction_time_ms": 120.5,
"llm_provider": "openai",
"llm_model": "gpt-4",
}
assert metadata["context_nodes"] > 0
assert len(metadata["relevant_entities"]) > 0
assert metadata["extraction_time_ms"] > 0
# ============================================================================
# Error Handling Tests
# ============================================================================
class TestErrorHandling:
"""Error handling and edge cases."""
def test_invalid_provider(self):
"""Invalid provider should be handled."""
# LLMConfig accepts string for provider (no validation at init)
# but Manager will fail when trying to create client
config = LLMConfig(
provider=LLMProvider.OPENAI,
api_key="test",
model="gpt-4",
)
assert config.provider == LLMProvider.OPENAI
def test_missing_api_key_openai(self):
"""OpenAI config should warn about missing API key."""
config = LLMConfig(
provider=LLMProvider.OPENAI,
api_key=None,
model="gpt-4",
)
# Should not crash, but API key will be None
assert config.api_key is None
def test_empty_query_handling(self):
"""Empty query should be handled gracefully."""
query = ""
assert query == ""
assert len(query) == 0
def test_very_long_query_handling(self):
"""Very long queries should be handled."""
query = "What is " * 1000 # Very long query
assert len(query) > 1000
# ============================================================================
# Integration Tests
# ============================================================================
class TestPhase7Integration:
"""End-to-end Phase 7 workflow."""
def test_rag_to_llm_workflow(self):
"""RAG context should flow to LLM correctly."""
# 1. RAG context extraction
rag_context = {
"query": "What is Apple?",
"nodes": [
{"label": "Apple", "type": "Company"},
{"label": "iPhone", "type": "Product"},
],
"relevant_entities": ["Apple", "iPhone", "Steve Jobs"],
}
# 2. Prompt building
prompt = f"""Knowledge Graph Context:
Entities: {', '.join(rag_context['relevant_entities'])}
Query: {rag_context['query']}
"""
# 3. Should be ready for LLM
assert len(prompt) > 0
assert rag_context["query"] in prompt
assert "Apple" in prompt
def test_cache_to_llm_selection(self):
"""Should choose cached response or call LLM."""
cached_response = {
"answer": "Cached response",
"cached": True,
}
# If cached, use it
if cached_response.get("cached"):
response = cached_response
else:
response = {"answer": "New LLM response"}
assert response["answer"] == "Cached response"
def test_streaming_to_cache_flow(self):
"""Streaming response should be cacheable after completion."""
tokens = ["Hello", " ", "World"]
full_response = "".join(tokens)
# After streaming completes, can cache
cache_data = {
"answer": full_response,
"cached": False,
}
assert cache_data["answer"] == "Hello World"
# ============================================================================
# Performance Tests
# ============================================================================
class TestPerformance:
"""Performance characteristics."""
def test_cache_lookup_speed(self):
"""Cache lookup should be very fast."""
# Simulate cache lookup
cache = {
"key1": {"answer": "Response 1"},
"key2": {"answer": "Response 2"},
}
import time
start = time.time()
result = cache.get("key1")
elapsed = (time.time() - start) * 1000
assert result is not None
assert elapsed < 10 # Should be < 10ms
def test_prompt_building_speed(self):
"""Prompt building should be fast."""
context = {
"relevant_entities": ["E1", "E2", "E3"] * 100, # 300 entities
"nodes": [{"label": f"Node{i}"} for i in range(100)],
}
import time
start = time.time()
prompt = f"""Context: {', '.join(context['relevant_entities'][:50])}
Query: What is this?
"""
elapsed = (time.time() - start) * 1000
assert len(prompt) > 0
assert elapsed < 100 # Should be < 100ms
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@@ -1,491 +0,0 @@
"""Phase 8 엔터프라이즈 기능 테스트.
테스트:
- 멀티테넌트 인증
- 감시 로그
- WebSocket 실시간
- 비용 관리
"""
import pytest
from datetime import datetime, timedelta
from ont_platform.auth.models import Organization, User, APIKey, CurrentUser
from ont_platform.auth.auth import JWTAuth, APIKeyAuth, PasswordHasher
from ont_platform.auth.rbac import RBAC, Role, Permission
from ont_platform.audit.logger import AuditLogger
from ont_platform.audit.models import AuditLog, AuditAction, ResourceType
from ont_platform.billing.calculator import CostCalculator
from ont_platform.billing.models import OperationType, SubscriptionTier, Subscription
from ont_platform.realtime.websocket import ConnectionManager
from ont_platform.realtime.broadcaster import EventBroadcaster
# ============================================================================
# 인증 테스트
# ============================================================================
class TestMultitenantAuth:
"""멀티테넌트 인증."""
def test_organization_creation(self):
"""조직 생성."""
org = Organization(name="Test Organization")
assert org.name == "Test Organization"
assert org.subscription_tier == "free"
assert org.is_active
def test_user_creation(self):
"""사용자 생성."""
user = User(
org_id="org_123",
email="user@example.com",
username="testuser",
role="editor",
)
assert user.org_id == "org_123"
assert user.email == "user@example.com"
assert user.role == "editor"
def test_api_key_generation(self):
"""API 키 생성."""
api_key = APIKeyAuth.generate_key()
assert api_key.startswith("sk_")
assert len(api_key) > 20
def test_api_key_hashing(self):
"""API 키 해싱."""
api_key = "sk_test123"
hash1 = APIKeyAuth.hash_key(api_key)
hash2 = APIKeyAuth.hash_key(api_key)
assert hash1 == hash2 # 같은 키는 같은 해시
def test_password_hashing(self):
"""비밀번호 해싱."""
password = "my_secure_password"
hashed = PasswordHasher.hash_password(password)
assert hashed != password
assert PasswordHasher.verify_password(password, hashed)
assert not PasswordHasher.verify_password("wrong_password", hashed)
def test_jwt_token_creation(self):
"""JWT 토큰 생성."""
token = JWTAuth.create_token(
user_id="user_123",
org_id="org_123",
email="user@example.com",
role="editor",
)
assert isinstance(token, str)
assert len(token) > 50
def test_jwt_token_verification(self):
"""JWT 토큰 검증."""
token = JWTAuth.create_token(
user_id="user_123",
org_id="org_123",
email="user@example.com",
role="editor",
)
payload = JWTAuth.verify_token(token)
assert payload.user_id == "user_123"
assert payload.org_id == "org_123"
assert payload.role == "editor"
def test_jwt_token_expiration(self):
"""JWT 토큰 만료."""
token = JWTAuth.create_token(
user_id="user_123",
org_id="org_123",
email="user@example.com",
role="editor",
expires_delta=timedelta(seconds=-1), # 이미 만료됨
)
from fastapi import HTTPException
with pytest.raises(HTTPException):
JWTAuth.verify_token(token)
def test_current_user_creation(self):
"""현재 사용자 객체 생성."""
user = CurrentUser(
user_id="user_123",
org_id="org_123",
email="user@example.com",
username="testuser",
role="editor",
is_active=True,
)
assert user.user_id == "user_123"
assert user.org_id == "org_123"
# ============================================================================
# RBAC 테스트
# ============================================================================
class TestRBAC:
"""역할 기반 액세스 제어."""
def test_admin_permissions(self):
"""관리자 권한."""
rbac = RBAC()
permissions = rbac.get_permissions(Role.ADMIN.value)
assert Permission.READ_ENTITY in permissions
assert Permission.DELETE_ENTITY in permissions
assert Permission.MANAGE_USERS in permissions
assert Permission.VIEW_AUDIT_LOG in permissions
def test_editor_permissions(self):
"""편집자 권한."""
rbac = RBAC()
permissions = rbac.get_permissions(Role.EDITOR.value)
assert Permission.READ_ENTITY in permissions
assert Permission.CREATE_ENTITY in permissions
assert Permission.DELETE_ENTITY in permissions
assert Permission.MANAGE_USERS not in permissions
def test_viewer_permissions(self):
"""뷰어 권한."""
rbac = RBAC()
permissions = rbac.get_permissions(Role.VIEWER.value)
assert Permission.READ_ENTITY in permissions
assert Permission.CREATE_ENTITY not in permissions
assert Permission.DELETE_ENTITY not in permissions
def test_permission_check(self):
"""권한 확인."""
rbac = RBAC()
assert rbac.has_permission(Role.ADMIN.value, Permission.DELETE_ENTITY.value)
assert not rbac.has_permission(
Role.VIEWER.value, Permission.DELETE_ENTITY.value
)
def test_all_permissions_retrieval(self):
"""모든 권한 조회."""
rbac = RBAC()
all_perms = rbac.get_all_permissions()
assert "admin" in all_perms
assert "editor" in all_perms
assert "viewer" in all_perms
assert "api" in all_perms
# ============================================================================
# 감시 로그 테스트
# ============================================================================
class TestAuditLogging:
"""감시 로깅."""
@pytest.mark.asyncio
async def test_audit_log_creation(self):
"""감시 로그 생성."""
logger = AuditLogger()
log = await logger.log_action(
org_id="org_123",
user_id="user_456",
action=AuditAction.CREATE,
resource_type=ResourceType.ENTITY,
resource_id="entity_789",
)
assert log.org_id == "org_123"
assert log.user_id == "user_456"
assert log.action == AuditAction.CREATE
@pytest.mark.asyncio
async def test_audit_log_retrieval(self):
"""감시 로그 조회."""
logger = AuditLogger()
await logger.log_action(
org_id="org_123",
user_id="user_456",
action=AuditAction.UPDATE,
resource_type=ResourceType.ENTITY,
resource_id="entity_789",
)
logs = await logger.get_audit_trail(
org_id="org_123",
resource_id="entity_789",
)
assert len(logs) > 0
assert logs[0].resource_id == "entity_789"
@pytest.mark.asyncio
async def test_audit_statistics(self):
"""감시 통계."""
logger = AuditLogger()
for i in range(3):
await logger.log_action(
org_id="org_123",
user_id="user_456",
action=AuditAction.READ,
resource_type=ResourceType.ENTITY,
resource_id="entity_789",
)
stats = await logger.get_statistics(org_id="org_123")
assert stats["total_logs"] >= 3
assert "by_action" in stats
# ============================================================================
# 비용 관리 테스트
# ============================================================================
class TestBillingAndQuota:
"""비용 관리 및 할당량."""
@pytest.mark.asyncio
async def test_cost_calculation(self):
"""비용 계산."""
calc = CostCalculator()
cost = await calc.calculate_cost(
operation_type=OperationType.LLM_CALL,
quantity=1000, # 1000 토큰
)
assert cost == 1.0 # 1000 * $0.001
@pytest.mark.asyncio
async def test_usage_recording(self):
"""사용량 기록."""
calc = CostCalculator()
usage = await calc.record_usage(
org_id="org_123",
user_id="user_456",
operation_type=OperationType.LLM_CALL,
quantity=500,
)
assert usage.org_id == "org_123"
assert usage.quantity == 500
assert usage.cost == 0.5
@pytest.mark.asyncio
async def test_quota_check_within_limit(self):
"""할당량 확인 (범위 내)."""
calc = CostCalculator()
subscription = Subscription(
org_id="org_123",
tier=SubscriptionTier.PRO,
monthly_limit=100.0,
current_month_cost=50.0,
)
allowed, msg = await calc.check_quota(
org_id="org_123",
subscription=subscription,
estimated_cost=30.0,
)
assert allowed
assert "OK" in msg
@pytest.mark.asyncio
async def test_quota_check_exceeded(self):
"""할당량 확인 (초과)."""
calc = CostCalculator()
subscription = Subscription(
org_id="org_123",
tier=SubscriptionTier.FREE,
monthly_limit=10.0,
current_month_cost=9.0,
)
allowed, msg = await calc.check_quota(
org_id="org_123",
subscription=subscription,
estimated_cost=5.0,
)
assert not allowed
assert "Quota exceeded" in msg
@pytest.mark.asyncio
async def test_usage_statistics(self):
"""사용량 통계."""
calc = CostCalculator()
await calc.record_usage(
org_id="org_123",
user_id="user_456",
operation_type=OperationType.API_CALL,
quantity=10,
)
await calc.record_usage(
org_id="org_123",
user_id="user_456",
operation_type=OperationType.LLM_CALL,
quantity=1000,
)
stats = await calc.get_usage_statistics(org_id="org_123")
assert stats.total_cost > 0
assert stats.api_calls >= 1
assert stats.llm_tokens >= 1000
# ============================================================================
# WebSocket 테스트
# ============================================================================
class TestWebSocketAndBroadcasting:
"""WebSocket 및 브로드캐스팅."""
@pytest.mark.asyncio
async def test_connection_tracking(self):
"""연결 추적."""
manager = ConnectionManager()
# 연결 수 확인
assert manager.get_connection_count("org_123") == 0
assert "org_123" not in manager.get_org_ids()
@pytest.mark.asyncio
async def test_broadcaster_entity_created(self):
"""엔티티 생성 이벤트."""
manager = ConnectionManager()
broadcaster = EventBroadcaster(manager)
entity = {"id": "entity_123", "label": "Test Entity"}
sent = await broadcaster.broadcast_entity_created(
org_id="org_123",
entity=entity,
user_id="user_456",
)
# 연결이 없으므로 0
assert sent == 0
@pytest.mark.asyncio
async def test_broadcaster_graph_analyzed(self):
"""그래프 분석 이벤트."""
manager = ConnectionManager()
broadcaster = EventBroadcaster(manager)
results = {"centrality": {"entity_1": 0.95}}
sent = await broadcaster.broadcast_graph_analyzed(
org_id="org_123",
analysis_type="pagerank",
results=results,
)
assert sent == 0 # 연결 없음
@pytest.mark.asyncio
async def test_broadcaster_notification(self):
"""일반 알림."""
manager = ConnectionManager()
broadcaster = EventBroadcaster(manager)
sent = await broadcaster.broadcast_notification(
org_id="org_123",
title="Test Alert",
message="This is a test",
severity="info",
)
assert sent == 0
# ============================================================================
# 통합 테스트
# ============================================================================
class TestPhase8Integration:
"""Phase 8 통합 시나리오."""
@pytest.mark.asyncio
async def test_complete_workflow(self):
"""완전한 워크플로우."""
# 1. 조직 생성
org = Organization(name="Test Org")
assert org.is_active
# 2. 사용자 생성
user = User(
org_id=org.id,
email="user@example.com",
username="testuser",
role="editor",
)
# 3. 토큰 생성
token = JWTAuth.create_token(
user_id=user.id,
org_id=org.id,
email=user.email,
role=user.role,
)
assert token
# 4. 토큰 검증
payload = JWTAuth.verify_token(token)
assert payload.org_id == org.id
# 5. 감시 로그
logger = AuditLogger()
log = await logger.log_action(
org_id=org.id,
user_id=user.id,
action=AuditAction.CREATE,
resource_type=ResourceType.ENTITY,
resource_id="entity_123",
)
assert log.org_id == org.id
# 6. 비용 기록
calc = CostCalculator()
usage = await calc.record_usage(
org_id=org.id,
user_id=user.id,
operation_type=OperationType.API_CALL,
quantity=1,
)
assert usage.cost > 0
def test_rbac_integration(self):
"""RBAC 통합."""
rbac = RBAC()
# 역할별 권한 확인
assert rbac.has_permission(Role.ADMIN.value, Permission.MANAGE_USERS.value)
assert not rbac.has_permission(
Role.VIEWER.value, Permission.DELETE_ENTITY.value
)
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@@ -1,151 +0,0 @@
from pathlib import Path
from sqlalchemy.orm import Session
from crawler_platform.app.config.loader import load_project_config
from crawler_platform.app.core.database import models
from crawler_platform.app.core.database.repository import KnowledgeRepository
from crawler_platform.app.core.database.session import make_engine
from crawler_platform.app.core.extractor.base import ExtractedClaim, ExtractedEntity, ExtractionBundle, ExtractionPageContext
from crawler_platform.app.core.extractor.validation import attach_page_context
from crawler_platform.app.core.research.graph_query import SemanticGraphQuery
from crawler_platform.app.core.research.graph_research_loop import GraphResearchLoop
from crawler_platform.app.core.research.relevance_engine import RelevanceEngine
from crawler_platform.app.domains.perfume.extractor import PerfumeRuleBasedExtractor
def make_repo():
config = load_project_config("configs/perfume_subscription.yaml")
engine = make_engine("sqlite:///:memory:")
models.Base.metadata.create_all(engine)
session = Session(engine)
repo = KnowledgeRepository(session)
project = repo.upsert_project(config)
source = repo.get_source(project.id, "official_brand_site")
return config, session, repo, project, source
def seed_validated_claim(repo, project, source, config):
page = repo.upsert_page(
project.id,
source.id,
"https://example.com/product/neroli",
"Neroli Summer",
200,
"Neroli Summer\nTop notes: Bergamot",
)
bundle = ExtractionBundle(
entities=[
ExtractedEntity("Perfume", "Neroli Summer", evidence_text="Neroli Summer", confidence=0.9),
ExtractedEntity("FragranceNote", "Bergamot", evidence_text="Top notes: Bergamot", confidence=0.9),
],
claims=[
ExtractedClaim(
"Neroli Summer",
"Perfume",
"hasTopNote",
"Bergamot",
"FragranceNote",
evidence_text="Top notes: Bergamot",
confidence=0.93,
confidence_reason="directly stated",
)
],
extractor_name="llm_json_extractor",
provider="lm_studio",
raw_output={"extraction_mode": "primary"},
)
context = ExtractionPageContext(
url=page.url,
final_url=page.url,
title=page.title,
page_type="ProductPage",
clean_text=page.cleaned_text_summary,
source_zones=[
{
"zone_type": "product_description",
"selector": "main",
"text": "Top notes: Bergamot",
"claim_allowed": True,
}
],
)
repo.save_extraction_bundle(project.id, source, page, attach_page_context(bundle, context), config)
def test_relevance_engine_prefers_graph_related_product_urls():
config, session, repo, project, source = make_repo()
seed_validated_claim(repo, project, source, config)
engine = RelevanceEngine(session)
product = engine.score_url(
project_id=project.id,
url="https://example.com/product/neroli-bergamot",
label="Neroli Summer Bergamot perfume",
source_trust=source.trust_level,
)
login = engine.score_url(
project_id=project.id,
url="https://example.com/member/login.html",
label="Login",
source_trust=source.trust_level,
)
assert product.score > login.score
assert product.breakdown["entity_overlap"] > 0
session.close()
def test_semantic_graph_query_returns_trend_summary():
config, session, repo, project, source = make_repo()
seed_validated_claim(repo, project, source, config)
rows = SemanticGraphQuery(session).trend_summary(project.id)
assert rows[0]["name"] == "Bergamot"
assert rows[0]["predicate"] == "hasTopNote" or rows[0]["support_count"] >= 1
session.close()
def test_graph_research_loop_records_session_memory():
config, session, repo, project, _source = make_repo()
fixture = Path("tests/fixtures/sample_perfume.html").resolve()
loop = GraphResearchLoop(repo, PerfumeRuleBasedExtractor())
result = loop.run(
project_config=config,
source_name="official_brand_site",
seed_url=str(fixture),
goal="Fixture semantic exploration",
max_depth=0,
max_steps=1,
min_relevance=0.0,
)
session.commit()
assert result.explored_count == 1
assert result.history
assert result.memory["visited_targets"]
jobs = session.query(models.CrawlJob).all()
assert any((job.metadata_json or {}).get("kind") == "research_session" for job in jobs)
session.close()
def test_graph_research_loop_can_start_from_knowledge_gaps():
config, session, repo, _project, _source = make_repo()
loop = GraphResearchLoop(repo, PerfumeRuleBasedExtractor())
result = loop.run(
project_config=config,
source_name="official_brand_site",
goal="Gap-driven semantic exploration",
max_depth=0,
max_steps=1,
max_branch=3,
min_relevance=0.0,
)
assert result.explored_count == 1
assert result.history
assert result.history[0]["item"]["target_type"] == "knowledge_gap"
session.close()

View File

@@ -1,162 +0,0 @@
from crawler_platform.app.core.crawler.site_crawler import (
classify_page,
is_failed_fetch_status,
normalize_url,
should_analyze_page,
)
from crawler_platform.app.core.crawler.discovery import discover_links, normalize_cafe24_product_url
from crawler_platform.app.core.crawler.fetchers import FallbackFetcher, FetchResult, RobotsPolicy, detect_crawl_status
from crawler_platform.app.api.routes import CrawlRequest
def test_classify_perfume_product_page():
text = "Top notes: Bergamot, Neroli\nMiddle notes: Jasmine\nBase notes: Musk\nPrice $89"
assert classify_page("https://example.com/products/neroli", "Neroli Summer", text) == "ProductPage"
def test_normalize_url_removes_fragment_and_trailing_slash():
assert normalize_url("https://example.com/path/#details") == "https://example.com/path"
def test_failed_fetch_status_detection():
assert is_failed_fetch_status(None)
assert is_failed_fetch_status(404)
assert is_failed_fetch_status(500)
assert not is_failed_fetch_status(200)
assert not is_failed_fetch_status(302)
def test_crawl_status_ignores_captcha_mentions_inside_scripts():
html = "<html><body><h1>Product</h1><script>var path='recaptcha';</script></body></html>"
status, warnings = detect_crawl_status(200, html)
assert status == "success"
assert warnings == []
def test_should_analyze_page_supports_legacy_names():
assert should_analyze_page("ProductPage", {"product"})
assert not should_analyze_page("CommunityPage", {"product", "brand", "review"})
def test_robots_policy_disabled_skips_check():
decision = RobotsPolicy().check("https://example.com/products/1", respect_robots_txt=False)
assert decision.allowed
assert decision.status == "disabled"
assert not decision.checked
def test_crawl_request_defaults_to_no_robots_check():
request = CrawlRequest(
config_path="configs/perfume_subscription.yaml",
source_name="official_brand_site",
url="https://example.com",
)
assert request.check_robots_txt is False
def test_robots_policy_allows_when_robots_unavailable(monkeypatch):
class UnavailableRobotsParser:
def set_url(self, url):
self.url = url
def read(self):
raise OSError("network unavailable")
monkeypatch.setattr(
"crawler_platform.app.core.crawler.fetchers.RobotFileParser",
UnavailableRobotsParser,
)
decision = RobotsPolicy().check("https://example.com/products/1")
assert decision.allowed
assert decision.status == "unavailable"
assert "allowing crawl" in decision.reason
def test_robots_policy_reports_block_reason(monkeypatch):
class BlockingRobotsParser:
def set_url(self, url):
self.url = url
def read(self):
return None
def can_fetch(self, user_agent, url):
return False
monkeypatch.setattr(
"crawler_platform.app.core.crawler.fetchers.RobotFileParser",
BlockingRobotsParser,
)
decision = RobotsPolicy().check("https://example.com/private")
assert not decision.allowed
assert decision.status == "blocked"
assert "blocks crawling" in decision.reason
def test_classify_board_page_before_content_analysis():
assert classify_page("https://example.com/board/free/read.html", "Notice", "Price $89") == "NoticePage"
def test_classify_promotion_homepage_before_product_analysis():
text = "BLACK FRIDAY SALE\n회원가입 쿠폰\n무료배송 이벤트\n제품 보기"
assert classify_page("https://example.com/", "Brand", text) == "PromotionPage"
def test_classify_product_list_and_search_as_non_detail_pages():
text = "Price $89\nTop notes: Bergamot\nAdd to cart"
assert classify_page("https://example.com/product/list.html?cate_no=24", "Perfume", text) == "CategoryPage"
assert classify_page("https://example.com/product/search.html?keyword=cotton", "Search", text) == "SearchPage"
assert not should_analyze_page("CategoryPage", {"ProductPage", "BrandStoryPage", "ReviewPage"})
assert not should_analyze_page("SearchPage", {"ProductPage", "BrandStoryPage", "ReviewPage"})
def test_cafe24_product_detail_with_category_segment_is_product_page():
url = "https://the912.co.kr/product/21-기획-912-클론-니치향수-모음-40ml/513/category/1/display/2/"
assert classify_page(url, "912 clone perfume", "") == "ProductPage"
def test_discovery_skips_utility_pages_seen_on_the912():
html = """
<a href="/myshop/wish_list.html">wish</a>
<a href="/board/faq/list.html?board_no=3">faq</a>
<a href="/event/list.html?cate_no=42">event</a>
<a href="/product/search.html?keyword=">search</a>
<a href="/product/sample/123/category/1/display/2/">product</a>
"""
links = discover_links(html, "https://the912.co.kr", limit=10)
assert [link.url for link in links] == ["https://the912.co.kr/product/detail.html?product_no=123"]
def test_cafe24_product_urls_are_canonicalized_for_dedupe():
url = "https://the912.co.kr/product/21-기획-912-클론-니치향수-모음-40ml/513/category/1/display/2/?icid=x"
assert normalize_cafe24_product_url(url) == "https://the912.co.kr/product/detail.html?product_no=513"
def test_fallback_fetcher_continues_when_primary_fails():
class FailingFetcher:
def fetch(self, url):
raise PermissionError("[WinError 5] access denied")
class WorkingFetcher:
def fetch(self, url):
return FetchResult(url=url, status_code=200, html="<html><body>ok</body></html>")
result = FallbackFetcher(FailingFetcher(), WorkingFetcher(), fallback_label="requests").fetch("https://example.com")
assert result.status_code == 200
assert any("primary fetcher failed" in warning for warning in result.warnings)

View File

@@ -1,21 +0,0 @@
import unittest
from crawler_platform.app.config.loader import load_project_config
from crawler_platform.app.core.crawler.fetchers import RequestsFetcher
from crawler_platform.app.domains.perfume.extractor import PerfumeRuleBasedExtractor
class SmokeTest(unittest.TestCase):
def test_config_fetch_and_extract_without_network(self):
config = load_project_config("configs/perfume_subscription.yaml")
fetch_result = RequestsFetcher().fetch("tests/fixtures/sample_perfume.html")
bundle = PerfumeRuleBasedExtractor().extract(fetch_result.html, config)
self.assertEqual(config.domain, "perfume")
self.assertEqual(fetch_result.status_code, 200)
self.assertIn("hasTopNote", {claim.predicate for claim in bundle.claims})
self.assertIn("hasPrice", {claim.predicate for claim in bundle.claims})
if __name__ == "__main__":
unittest.main()