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