1
This commit is contained in:
@@ -44,6 +44,100 @@ 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()
|
||||
COMPARISON_KEYS = ("both_agree", "rule_only", "llm_only", "conflict", "rejected_by_validation")
|
||||
|
||||
|
||||
def extraction_log_summary(raw_output: dict[str, Any]) -> dict[str, Any]:
|
||||
candidate_claims = raw_output.get("candidate_claims")
|
||||
if not isinstance(candidate_claims, list):
|
||||
candidate_claims = []
|
||||
|
||||
raw_comparison = raw_output.get("comparison")
|
||||
if not isinstance(raw_comparison, dict):
|
||||
raw_comparison = {}
|
||||
comparison = {key: int(raw_comparison.get(key) or 0) for key in COMPARISON_KEYS}
|
||||
if not any(comparison.values()):
|
||||
comparison.update(comparison_from_candidate_claims(candidate_claims))
|
||||
validation = raw_output.get("validation")
|
||||
if isinstance(validation, dict) and comparison["rejected_by_validation"] == 0:
|
||||
comparison["rejected_by_validation"] = number_or_default(validation.get("rejected_claim_count"), 0)
|
||||
|
||||
return {
|
||||
"candidate_count": len(candidate_claims),
|
||||
"comparison": comparison,
|
||||
"rule_entity_count": number_or_none(raw_output.get("rule_entity_count")),
|
||||
"rule_claim_count": number_or_derived(
|
||||
raw_output.get("rule_claim_count"),
|
||||
candidate_claims,
|
||||
source="rule",
|
||||
),
|
||||
"llm_entity_count": number_or_none(raw_output.get("llm_entity_count")),
|
||||
"llm_claim_count": number_or_derived(
|
||||
raw_output.get("llm_claim_count"),
|
||||
candidate_claims,
|
||||
source="llm",
|
||||
),
|
||||
"agreement_claim_count": number_or_default(raw_output.get("agreement_claim_count"), comparison["both_agree"]),
|
||||
"rule_only_claim_count": number_or_default(raw_output.get("rule_only_claim_count"), comparison["rule_only"]),
|
||||
"llm_only_claim_count": number_or_default(raw_output.get("llm_only_claim_count"), comparison["llm_only"]),
|
||||
"conflict_claim_count": number_or_default(raw_output.get("conflict_claim_count"), comparison["conflict"]),
|
||||
}
|
||||
|
||||
|
||||
def comparison_from_candidate_claims(candidate_claims: list[Any]) -> dict[str, int]:
|
||||
comparison = {key: 0 for key in COMPARISON_KEYS}
|
||||
for claim in candidate_claims:
|
||||
metadata = claim_metadata(claim)
|
||||
agreement = str(metadata.get("agreement") or metadata.get("claim_kind") or "").lower()
|
||||
if agreement == "rule_and_llm":
|
||||
comparison["both_agree"] += 1
|
||||
elif agreement == "rule_only":
|
||||
comparison["rule_only"] += 1
|
||||
elif agreement == "llm_only":
|
||||
comparison["llm_only"] += 1
|
||||
elif agreement == "conflict":
|
||||
comparison["conflict"] += 1
|
||||
return comparison
|
||||
|
||||
|
||||
def claim_metadata(claim: Any) -> dict[str, Any]:
|
||||
if not isinstance(claim, dict):
|
||||
return {}
|
||||
metadata = claim.get("metadata")
|
||||
return metadata if isinstance(metadata, dict) else {}
|
||||
|
||||
|
||||
def number_or_none(value: Any) -> int | None:
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
return int(value)
|
||||
return None
|
||||
|
||||
|
||||
def number_or_default(value: Any, default: int) -> int:
|
||||
parsed = number_or_none(value)
|
||||
return default if parsed is None else parsed
|
||||
|
||||
|
||||
def number_or_derived(value: Any, candidate_claims: list[Any], *, source: str) -> int:
|
||||
parsed = number_or_none(value)
|
||||
if parsed is not None:
|
||||
return parsed
|
||||
return sum(1 for claim in candidate_claims if claim_matches_source(claim, source))
|
||||
|
||||
|
||||
def claim_matches_source(claim: Any, source: str) -> bool:
|
||||
metadata = claim_metadata(claim)
|
||||
extraction_source = str(metadata.get("extraction_source") or "").lower()
|
||||
agreement = str(metadata.get("agreement") or "").lower()
|
||||
if extraction_source == source:
|
||||
return True
|
||||
if source == "rule":
|
||||
return agreement in {"rule_only", "rule_and_llm"}
|
||||
if source == "llm":
|
||||
return agreement in {"llm_only", "rule_and_llm"}
|
||||
return False
|
||||
|
||||
|
||||
class CrawlRequest(BaseModel):
|
||||
@@ -2490,35 +2584,37 @@ def register_routes(app, database_url: str) -> None:
|
||||
.order_by(models.ExtractionLog.created_at.desc())
|
||||
.limit(limit)
|
||||
).all()
|
||||
return [
|
||||
{
|
||||
payload = []
|
||||
for log, page in rows:
|
||||
raw_output = log.raw_output or {}
|
||||
summary = extraction_log_summary(raw_output)
|
||||
payload.append({
|
||||
"id": log.id,
|
||||
"page_url": page.url if page else None,
|
||||
"extractor_name": log.extractor_name,
|
||||
"provider": log.provider,
|
||||
"error": log.error,
|
||||
"created_at": log.created_at.isoformat(),
|
||||
"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"),
|
||||
"validation": raw_output.get("validation"),
|
||||
"page_context": raw_output.get("page_context"),
|
||||
"candidate_count": summary["candidate_count"],
|
||||
"extraction_mode": raw_output.get("extraction_mode"),
|
||||
"effective_extraction_mode": raw_output.get("effective_extraction_mode"),
|
||||
"comparison": summary["comparison"],
|
||||
"rule_entity_count": summary["rule_entity_count"],
|
||||
"rule_claim_count": summary["rule_claim_count"],
|
||||
"llm_entity_count": summary["llm_entity_count"],
|
||||
"llm_claim_count": summary["llm_claim_count"],
|
||||
"agreement_claim_count": summary["agreement_claim_count"],
|
||||
"rule_only_claim_count": summary["rule_only_claim_count"],
|
||||
"llm_only_claim_count": summary["llm_only_claim_count"],
|
||||
"conflict_claim_count": summary["conflict_claim_count"],
|
||||
"llm_skipped": raw_output.get("llm_skipped"),
|
||||
"llm_skip_reason": raw_output.get("llm_skip_reason"),
|
||||
"fallback": raw_output.get("fallback"),
|
||||
"raw_output": log.raw_output,
|
||||
}
|
||||
for log, page in rows
|
||||
]
|
||||
})
|
||||
return payload
|
||||
|
||||
@app.patch("/claims/{claim_id}/confidence")
|
||||
def update_claim_confidence(claim_id: int, request: UpdateClaimConfidenceRequest):
|
||||
|
||||
@@ -3,6 +3,14 @@ from __future__ import annotations
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from crawler_platform.app.core.crawler.content_zone import is_claim_allowed_zone
|
||||
from crawler_platform.app.core.crawler.page_type_taxonomy import (
|
||||
PageClassificationResult,
|
||||
build_classification_result_from_legacy,
|
||||
get_legacy_page_type,
|
||||
normalize_page_type,
|
||||
)
|
||||
from crawler_platform.app.core.crawler.page_signal_extractor import extract_page_signals_from_page
|
||||
from crawler_platform.app.core.crawler.page_type_scorer import score_page_type
|
||||
|
||||
|
||||
PRODUCT_DETAIL_PREDICATES = {
|
||||
@@ -95,6 +103,38 @@ def classify_page(
|
||||
return "UnknownPage"
|
||||
|
||||
|
||||
def classify_page_semantic(
|
||||
url: str,
|
||||
title: str | None = None,
|
||||
text: str = "",
|
||||
html: str | None = None,
|
||||
source_zones: list[dict[str, object]] | None = None,
|
||||
) -> PageClassificationResult:
|
||||
signals = extract_page_signals_from_page(
|
||||
url=url,
|
||||
title=title,
|
||||
text=text,
|
||||
html=html,
|
||||
source_zones=source_zones,
|
||||
)
|
||||
result = score_page_type(url, signals)
|
||||
if result.primary_page_type == "UnknownPage" and not result.alternatives:
|
||||
legacy_page_type = classify_page(
|
||||
url=url,
|
||||
title=title,
|
||||
text=text,
|
||||
html=html,
|
||||
source_zones=source_zones,
|
||||
)
|
||||
return build_classification_result_from_legacy(
|
||||
url=url,
|
||||
legacy_page_type=legacy_page_type,
|
||||
confidence=0.35,
|
||||
source="legacy_classifier_fallback",
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def relation_allowed_for_page_type(page_type: str | None, predicate: str) -> bool:
|
||||
if page_type in PRODUCT_DETAIL_PAGE_TYPES:
|
||||
return True
|
||||
@@ -109,26 +149,9 @@ def claim_allowed_for_context(page_type: str | None, predicate: str, zone_type:
|
||||
return relation_allowed_for_page_type(page_type, predicate) and is_claim_allowed_zone(zone_type)
|
||||
|
||||
|
||||
def normalize_page_type(value: str | None) -> str:
|
||||
aliases = {
|
||||
"product": "ProductPage",
|
||||
"brand": "BrandStoryPage",
|
||||
"review": "ReviewPage",
|
||||
"listing": "CategoryPage",
|
||||
"category": "CategoryPage",
|
||||
"community": "BoardPage",
|
||||
"board": "BoardPage",
|
||||
"communitypage": "BoardPage",
|
||||
"listingpage": "CategoryPage",
|
||||
"promotionpage": "PromotionPage",
|
||||
}
|
||||
clean = str(value or "").strip()
|
||||
return aliases.get(clean.lower(), aliases.get(clean, clean or "UnknownPage"))
|
||||
|
||||
|
||||
def should_analyze_page(page_type: str, analyze_page_types: set[str]) -> bool:
|
||||
def should_analyze_page(page_type: object, analyze_page_types: set[str] | None) -> bool:
|
||||
normalized_page_type = normalize_page_type(page_type)
|
||||
normalized = {normalize_page_type(item) for item in analyze_page_types}
|
||||
normalized = {normalize_page_type(item) for item in (analyze_page_types or set())}
|
||||
return normalized_page_type in normalized
|
||||
|
||||
|
||||
|
||||
@@ -82,6 +82,13 @@ class HybridExtractor(Extractor):
|
||||
"effective_extraction_mode": "rule_only",
|
||||
"llm_skipped": True,
|
||||
"llm_skip_reason": "rule_only mode",
|
||||
**count_payload(
|
||||
rule_entity_count=len(rule_bundle.entities),
|
||||
rule_claim_count=len(rule_bundle.claims),
|
||||
llm_entity_count=0,
|
||||
llm_claim_count=0,
|
||||
comparison=comparison_payload(rule_only=len(rule_bundle.claims)),
|
||||
),
|
||||
}
|
||||
return rule_bundle
|
||||
|
||||
@@ -103,8 +110,13 @@ class HybridExtractor(Extractor):
|
||||
**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),
|
||||
**count_payload(
|
||||
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),
|
||||
comparison=comparison_payload(llm_only=len(llm_bundle.claims)),
|
||||
),
|
||||
}
|
||||
return llm_bundle
|
||||
|
||||
@@ -202,6 +214,13 @@ def fallback_bundle(rule_bundle: ExtractionBundle, extractor: HybridExtractor, e
|
||||
"ai_model": extractor.model,
|
||||
"ai_warning": reason,
|
||||
"fallback": "rule_based",
|
||||
**count_payload(
|
||||
rule_entity_count=len(bundle.entities),
|
||||
rule_claim_count=len(bundle.claims),
|
||||
llm_entity_count=0,
|
||||
llm_claim_count=0,
|
||||
comparison=comparison_payload(rule_only=len(bundle.claims)),
|
||||
),
|
||||
}
|
||||
for entity in bundle.entities:
|
||||
entity.metadata["ai_fallback_reason"] = reason
|
||||
@@ -265,6 +284,44 @@ def llm_skipped_bundle(
|
||||
return bundle
|
||||
|
||||
|
||||
def comparison_payload(
|
||||
*,
|
||||
both_agree: int = 0,
|
||||
rule_only: int = 0,
|
||||
llm_only: int = 0,
|
||||
conflict: int = 0,
|
||||
rejected_by_validation: int = 0,
|
||||
) -> dict[str, int]:
|
||||
return {
|
||||
"both_agree": both_agree,
|
||||
"rule_only": rule_only,
|
||||
"llm_only": llm_only,
|
||||
"conflict": conflict,
|
||||
"rejected_by_validation": rejected_by_validation,
|
||||
}
|
||||
|
||||
|
||||
def count_payload(
|
||||
*,
|
||||
rule_entity_count: int,
|
||||
rule_claim_count: int,
|
||||
llm_entity_count: int,
|
||||
llm_claim_count: int,
|
||||
comparison: dict[str, int],
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"rule_entity_count": rule_entity_count,
|
||||
"rule_claim_count": rule_claim_count,
|
||||
"llm_entity_count": llm_entity_count,
|
||||
"llm_claim_count": llm_claim_count,
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
def llm_skip_reason(
|
||||
context: ExtractionPageContext | None,
|
||||
page_text: str,
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,100 @@
|
||||
# PHASE INDEX - ontology_platform engine-respect roadmap
|
||||
|
||||
?묒꽦?? 2026-05-19
|
||||
|
||||
踰붿쐞: `ontology_platform` ?꾩슜. `crawler_platform`? ?대쾲 ?묒뾽 踰붿쐞?먯꽌 ?쒖쇅?쒕떎.
|
||||
|
||||
湲곗? 臾몄꽌:
|
||||
- `ontology_platform/docs/?듯빀?ㅺ퀎??md`
|
||||
- `ontology_platform/README.md`
|
||||
- `ontology_platform/docs/phases/PHASE0_ACCEPTANCE_GATE.md`
|
||||
- `ontology_platform/docs/phases/PHASE1_NEXT_STEPS.md`
|
||||
- `ontology_platform/docs/phases/PHASE1_ACCEPTANCE_GATE.md`
|
||||
- `ontology_platform/docs/phases/PHASE2_ACCEPTANCE_GATE.md`
|
||||
|
||||
?듭떖 ?먯튃:
|
||||
- OntoCast??Base ?붿쭊?쇰줈 議댁쨷?쒕떎.
|
||||
- vendored OntoCast 肄붿뼱???듯빀?ㅺ퀎?쒓? ?덉슜??踰붿쐞 ?몄뿉???섏젙?섏? ?딅뒗??
|
||||
- Trafilatura, Crawl4AI, Guardrails, Neo4j GraphRAG??吏곸젒 ?ш뎄?꾪븯吏 ?딄퀬 ?뉗? adapter/facade濡?媛먯떬??
|
||||
- Firecrawl, OpenDeepResearcher 肄붾뱶???ы븿?섏? ?딅뒗??
|
||||
- Acceptance Gate瑜??듦낵?섍린 ???ㅼ쓬 ?듯빀?쇰줈 ?섏뼱媛吏 ?딅뒗??
|
||||
|
||||
---
|
||||
|
||||
PHASE 0. ?붿쭊 寃쎄퀎 媛먯궗 諛?Phase Gate 蹂듦뎄
|
||||
FILE: ./26_05_19_engine_respect_plan/phase_00_001_engine_boundary_gate.md
|
||||
|
||||
1) ?꾩옱 `ont_platform` 紐⑤뱢??Base/Adapter/Draft/Excluded 梨낆엫?쇰줈 遺꾨쪟 [?꾨즺]
|
||||
2) Phase 0?먯꽌 誘몃옒 Phase ?섏〈?깆씠 import?섏뼱 ???쒖옉??源⑥? ?딅룄濡?寃뚯씠???뺣━ [?꾨즺]
|
||||
3) Phase 0 unit/integration 寃利??덉감 怨좎젙 [?꾨즺]
|
||||
4) `PHASE0_ACCEPTANCE_GATE.md` 媛깆떊 湲곗? ?뺣━ [?꾨즺]
|
||||
|
||||
---
|
||||
|
||||
PHASE 1. Trafilatura 湲곕컲 URL/HTML ?낅젰 ?뺣젹
|
||||
FILE: ./26_05_19_engine_respect_plan/phase_01_001_trafilatura_ingestion.md
|
||||
|
||||
1) `web_extractor.py`瑜?Trafilatura adapter 梨낆엫?쇰줈 ?뺣━ [?꾨즺]
|
||||
2) `SourceDocument`, `EvidenceSpan`, Content metadata ???寃쎄퀎 ?곌껐 [?꾨즺]
|
||||
3) `/process/url` ?먮뒗 ?숇벑??URL ?낅젰 API ?ㅺ퀎 [?꾨즺]
|
||||
4) ?쒓뎅??URL/HTML fixture 湲곕컲 異붿텧 ?뚯뒪?몄? dedup 湲곗? ?묒꽦 [?꾨즺]
|
||||
|
||||
---
|
||||
|
||||
PHASE 2. Candidate Storage 諛?Review 梨낆엫 寃쎄퀎
|
||||
FILE: ./26_05_19_engine_respect_plan/phase_02_001_candidate_review_boundary.md
|
||||
|
||||
1) `storage/models.py`???꾨낫 紐⑤뜽???뺤떇 Review Queue 怨꾩빟?쇰줈 ?뺤젙 [?꾨즺]
|
||||
2) OntoCast 寃곌낵? lightweight extraction 寃곌낵?????寃쎈줈 遺꾨━ [?꾨즺]
|
||||
3) ?뱀씤/諛섎젮/?먮룞?뱀씤 ?곹깭 ?꾩씠 洹쒖튃 ?뺤쓽 [?꾨즺]
|
||||
4) evidence ?녿뒗 ?꾨낫媛 ?뺤젙 graph濡??ㅼ뼱媛吏 紐삵븯寃?李⑤떒 [?꾨즺]
|
||||
|
||||
---
|
||||
|
||||
PHASE 3. Crawl4AI ?섏쭛 怨꾩링 諛?Job Orchestration
|
||||
FILE: ./26_05_19_engine_respect_plan/phase_03_001_crawl4ai_acquisition_jobs.md
|
||||
|
||||
1) `crawl4ai_adapter.py`瑜??숈쟻/????섏쭛 adapter濡??쒗븳 [?꾨즺]
|
||||
2) crawler profile, robots policy, cache policy瑜??ㅼ젙 湲곕컲?쇰줈 遺꾨━ [?꾨즺]
|
||||
3) Job ?곹깭 紐⑤뜽怨?progress API/WebSocket 寃쎄퀎 ?뺣━ [?꾨즺]
|
||||
4) Trafilatura ?꾩쿂由ъ? SourceDocument ??μ쑝濡??곌껐 [?꾨즺]
|
||||
|
||||
---
|
||||
|
||||
PHASE 4. Guardrails Validation Gate
|
||||
FILE: ./26_05_19_engine_respect_plan/phase_04_001_guardrails_validation_gate.md
|
||||
|
||||
1) `core/validation`??Pydantic lightweight? Guardrails facade濡?遺꾨━ [?꾨즺]
|
||||
2) OntoCast LLM 異쒕젰 ?섑븨 吏?먯쓣 vendored ?섏젙 ?놁씠 ?곗꽑 ?ㅺ퀎 [?꾨즺]
|
||||
3) schema violation, endpoint missing, confidence range ?뚯뒪???묒꽦 [?꾨즺]
|
||||
4) Guard ?ㅽ뙣 寃곌낵瑜?candidate/review issue濡????[?꾨즺]
|
||||
|
||||
---
|
||||
|
||||
PHASE 5. Neo4j Projection 諛?GraphRAG 寃??FILE: ./26_05_19_engine_respect_plan/phase_05_001_neo4j_projection_graphrag.md
|
||||
|
||||
1) RDF/Fuseki瑜?canonical store, Neo4j瑜?projection/search store濡?怨좎젙 [?꾨즺]
|
||||
2) `core/graph` 湲곗〈 紐⑤뱢??projection/search adapter 梨낆엫?쇰줈 ?щ텇瑜?[?꾨즺]
|
||||
3) read-only Text2Cypher? vector/hybrid retriever API ?ㅺ퀎 [?꾨즺]
|
||||
4) provenance媛 search result源뚯? ?댁뼱吏??寃利?湲곗? ?묒꽦 [?꾨즺]
|
||||
|
||||
---
|
||||
|
||||
PHASE 6. Maintenance Loop 諛??댁쁺 湲곕뒫 ?뺣━
|
||||
FILE: ./26_05_19_engine_respect_plan/phase_06_001_maintenance_loop_operations.md
|
||||
|
||||
1) Knowledge Agent??肄붾뱶媛 ?꾨땲???꾨\?꾪듃/?뚰겕?뚮줈???⑦꽩留?李⑥슜 [?꾨즺]
|
||||
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 선택 지원 [신규]
|
||||
@@ -0,0 +1,51 @@
|
||||
# PHASE 1. 현재 흐름 기준선 고정 및 영향 범위 정리
|
||||
|
||||
## 목표
|
||||
|
||||
`Semantic Page Understanding Layer`를 얹기 전에 현재 page classification 흐름과 page_type 문자열 의존 지점을 정확히 고정한다.
|
||||
|
||||
## 기준 문서
|
||||
|
||||
- `README.md`
|
||||
- `ontology_platform/README.md`
|
||||
- `ontology_platform/docs/semantic_page_classification_codex_spec.md`
|
||||
|
||||
## 작업 범위
|
||||
|
||||
1. `crawler_platform/app/core/crawler/page_classifier.py`의 현재 public API 확인
|
||||
- `classify_page(...)`
|
||||
- `normalize_page_type(...)`
|
||||
- `should_analyze_page(...)`
|
||||
- `relation_allowed_for_page_type(...)`
|
||||
- `claim_allowed_for_context(...)`
|
||||
|
||||
2. crawler 호출부 확인
|
||||
- `site_crawler.py`
|
||||
- `pipeline.py`
|
||||
- `page_cleaner.py`
|
||||
- `domain_discovery.py`
|
||||
- `relevance_engine.py`
|
||||
|
||||
3. extractor 연결 확인
|
||||
- `ExtractionPageContext.page_type`
|
||||
- `HybridExtractor.llm_skip_reason(...)`
|
||||
- `LLM_PAGE_TYPES`, `SKIP_LLM_PAGE_TYPES`, `RULE_ONLY_PAGE_TYPES`
|
||||
- validation metadata의 `page_type`
|
||||
|
||||
4. 기존 page_type 문자열 기대 코드 목록화
|
||||
- config `analyze_page_types`
|
||||
- ontology relation rule `allowed_page_types`
|
||||
- frontend display
|
||||
- tests
|
||||
- adapters
|
||||
|
||||
## 수정 금지
|
||||
|
||||
- 이 phase에서는 구현 변경을 하지 않는다.
|
||||
- 기존 page_type 문자열 의미를 변경하지 않는다.
|
||||
- 기존 테스트 기대값을 바꾸지 않는다.
|
||||
|
||||
## 완료 기준
|
||||
|
||||
- 현재 흐름과 의존 지점이 다음 phase 구현의 기준선으로 정리되어 있어야 한다.
|
||||
- 신규 semantic API를 추가할 때 깨뜨리면 안 되는 legacy contract가 명확해야 한다.
|
||||
@@ -0,0 +1,276 @@
|
||||
# Phase 1 Current Flow Boundary Report
|
||||
|
||||
작성일: 2026-05-22
|
||||
|
||||
범위: Semantic Page Classification Layer 구현 전, 현재 `page_type` 문자열 흐름과 의존 지점을 고정한다.
|
||||
|
||||
## 1. page_classifier.py public API
|
||||
|
||||
파일: `ontology_platform/crawler_platform/app/core/crawler/page_classifier.py`
|
||||
|
||||
현재 public API와 contract:
|
||||
|
||||
- `classify_page(url, title=None, text="", html=None, source_zones=None) -> str`
|
||||
- 단일 문자열 page_type을 반환한다.
|
||||
- 호출부는 반환값이 `PageClassificationResult`가 아니라 `str`이라고 가정한다.
|
||||
- `normalize_page_type(value: str | None) -> str`
|
||||
- 짧은 alias를 legacy page_type 문자열로 변환한다.
|
||||
- 현재 alias 예: `product -> ProductPage`, `listing/category -> CategoryPage`, `community/board -> BoardPage`.
|
||||
- `should_analyze_page(page_type: str, analyze_page_types: set[str]) -> bool`
|
||||
- page_type과 allowlist를 각각 normalize한 뒤 포함 여부만 본다.
|
||||
- `relation_allowed_for_page_type(page_type, predicate) -> bool`
|
||||
- `ProductPage`는 product detail predicate를 모두 허용한다.
|
||||
- `UnknownPage`, `SearchPage`, `CategoryPage`, `BoardPage`는 non-merge page로 간주한다.
|
||||
- `BrandStoryPage`, `NoticePage`, `EventPage`, `PromotionPage`는 content page로 간주한다.
|
||||
- `claim_allowed_for_context(page_type, predicate, zone_type) -> bool`
|
||||
- page_type relation policy와 source zone policy를 함께 적용한다.
|
||||
|
||||
현재 legacy page_type 문자열:
|
||||
|
||||
- `ProductPage`
|
||||
- `CategoryPage`
|
||||
- `SearchPage`
|
||||
- `BoardPage`
|
||||
- `NoticePage`
|
||||
- `BrandStoryPage`
|
||||
- `PromotionPage`
|
||||
- `EventPage`
|
||||
- `ReviewPage`
|
||||
- `UnknownPage`
|
||||
- 보조/외부 상태 문자열: `unknown`, `external`, `entity`
|
||||
|
||||
Phase 2 이후에도 기존 `classify_page(...) -> str` contract는 유지해야 한다. 신규 semantic result는 별도 API로 추가하거나 compatibility wrapper 뒤에 두어야 한다.
|
||||
|
||||
## 2. crawler 호출 흐름
|
||||
|
||||
### site_crawler.py
|
||||
|
||||
파일: `ontology_platform/crawler_platform/app/core/crawler/site_crawler.py`
|
||||
|
||||
흐름:
|
||||
|
||||
1. fetcher가 HTML을 가져온다.
|
||||
2. parser가 `analysis_html`을 parse한다.
|
||||
3. `classify_page(final_url or url, title, raw_text or text, analysis_html, source_zones)`를 호출한다.
|
||||
4. 반환된 `page_type` 문자열을 page metadata와 `SiteCrawlPageResult`에 저장한다.
|
||||
5. `should_analyze_page(page_type, analyze_page_types)`가 true일 때만 `ExtractionPageContext`를 만들고 extractor를 실행한다.
|
||||
6. context의 `page_type` 문자열이 HybridExtractor, validation, repository 저장까지 이어진다.
|
||||
|
||||
현재 기본 analyze allowlist:
|
||||
|
||||
```txt
|
||||
ProductPage
|
||||
BrandStoryPage
|
||||
ReviewPage
|
||||
```
|
||||
|
||||
따라서 현재 기본 흐름에서는 `CategoryPage`, `SearchPage`, `BoardPage`, `UnknownPage`가 extractor 실행에서 빠진다.
|
||||
|
||||
### pipeline.py
|
||||
|
||||
파일: `ontology_platform/crawler_platform/app/core/crawler/pipeline.py`
|
||||
|
||||
흐름:
|
||||
|
||||
1. 단일 URL fetch/parse 후 `classify_page(...)`를 호출한다.
|
||||
2. `metadata["page_type"]`에 문자열을 저장한다.
|
||||
3. fetch/extraction 실패가 아니면 `ExtractionPageContext.page_type`에 같은 문자열을 넣고 extractor를 실행한다.
|
||||
|
||||
주의:
|
||||
|
||||
- `pipeline.py`에는 `should_analyze_page()` gate가 없다.
|
||||
- 단일 URL pipeline은 현재 모든 정상 parse page를 extractor로 보낸다.
|
||||
|
||||
### page_cleaner.py / html_cleaner.py / plugins.py
|
||||
|
||||
파일:
|
||||
|
||||
- `ontology_platform/crawler_platform/app/core/crawler/page_cleaner.py`
|
||||
- `ontology_platform/crawler_platform/app/core/crawler/html_cleaner.py`
|
||||
- `ontology_platform/crawler_platform/app/core/crawler/plugins.py`
|
||||
|
||||
흐름:
|
||||
|
||||
- `PageCleaner.clean()`은 page_type이 없으면 내부에서 `classify_page()`를 호출한다.
|
||||
- `ZONE_PRIORITY_BY_PAGE_TYPE`가 legacy page_type 문자열에 의존한다.
|
||||
- `ProductPage`, `BrandStoryPage`, `NoticePage`, `BoardPage`, `EventPage`, `PromotionPage`, `CategoryPage`별로 source zone 우선순위가 다르다.
|
||||
|
||||
Phase 6에서 semantic type을 추가할 때 `ProductDetailPage -> ProductPage`, `CategoryListingPage -> CategoryPage` 같은 zone compatibility가 필요하다.
|
||||
|
||||
## 3. research/discovery 호출 흐름
|
||||
|
||||
### graph_research_loop.py
|
||||
|
||||
파일: `ontology_platform/crawler_platform/app/core/research/graph_research_loop.py`
|
||||
|
||||
흐름:
|
||||
|
||||
1. 기본 analyze allowlist는 `ProductPage`, `BrandStoryPage`, `ReviewPage`다.
|
||||
2. explored page에서 `classify_page(...)`를 호출한다.
|
||||
3. metadata에 `page_type`을 저장한다.
|
||||
4. relevance score와 `should_analyze_page(page_type, analyze_page_types)`를 모두 통과해야 extractor를 실행한다.
|
||||
5. link 후보 metadata에도 relevance engine이 산출한 `score.page_type`을 저장한다.
|
||||
|
||||
### relevance_engine.py
|
||||
|
||||
파일: `ontology_platform/crawler_platform/app/core/research/relevance_engine.py`
|
||||
|
||||
흐름:
|
||||
|
||||
- `score_url()` 내부에서 `classify_page(...)`를 호출한다.
|
||||
- `HIGH_VALUE_PAGE_TYPES` 점수표가 legacy page_type 문자열에 의존한다.
|
||||
- 신규 semantic page type이 들어오면 점수표 또는 legacy normalization이 필요하다.
|
||||
|
||||
현재 주요 점수:
|
||||
|
||||
- `ProductPage`: 0.95
|
||||
- `BrandStoryPage`: 0.88
|
||||
- `ReviewPage`: 0.82
|
||||
- `CategoryPage`: 0.34
|
||||
- `SearchPage`: 0.22
|
||||
- `BoardPage`: 0.18
|
||||
- `UnknownPage`: 0.25
|
||||
|
||||
### domain_discovery.py
|
||||
|
||||
파일: `ontology_platform/crawler_platform/app/core/ontology/domain_discovery.py`
|
||||
|
||||
흐름:
|
||||
|
||||
- discovery job에서 `classify_page(...)`를 호출한다.
|
||||
- `mine_schema_candidates(..., page_type=page_type)`로 page_type을 evidence metadata에 넣는다.
|
||||
- `entity_type_from_page_type()`가 legacy mapping을 사용한다.
|
||||
|
||||
현재 mapping:
|
||||
|
||||
- `ProductPage -> Product`
|
||||
- `CategoryPage -> Category`
|
||||
- `BrandStoryPage -> Brand`
|
||||
- `NoticePage -> Notice`
|
||||
- `PromotionPage -> Promotion`
|
||||
- `ReviewPage -> Review`
|
||||
- `BoardPage -> Article`
|
||||
|
||||
## 4. Extractor / HybridExtractor 연결 흐름
|
||||
|
||||
### ExtractionPageContext
|
||||
|
||||
파일: `ontology_platform/crawler_platform/app/core/extractor/base.py`
|
||||
|
||||
현재 `ExtractionPageContext.page_type: str`는 필수 문자열 필드다. `to_payload()`도 `page_type`을 그대로 내보낸다.
|
||||
|
||||
Phase 6에서 이 필드는 유지해야 하며, semantic 정보는 `metadata`에 추가하는 방식이 가장 안전하다.
|
||||
|
||||
### HybridExtractor
|
||||
|
||||
파일: `ontology_platform/crawler_platform/app/core/extractor/hybrid.py`
|
||||
|
||||
현재 LLM routing 상수:
|
||||
|
||||
- `LLM_PAGE_TYPES = {"ProductPage", "BrandStoryPage", "ReviewPage"}`
|
||||
- `SKIP_LLM_PAGE_TYPES = {"CategoryPage", "SearchPage", "ListingPage"}`
|
||||
- `RULE_ONLY_PAGE_TYPES = {"BoardPage", "CommunityPage", "UnknownPage"}`
|
||||
|
||||
현재 `llm_skip_reason()` 판단 순서:
|
||||
|
||||
1. mode가 `hybrid`가 아니거나 context가 없으면 skip 판단 없음.
|
||||
2. page_type이 `SKIP_LLM_PAGE_TYPES`면 LLM skip.
|
||||
3. page_type이 `RULE_ONLY_PAGE_TYPES`면 rule-only.
|
||||
4. page_type이 `LLM_PAGE_TYPES`에 없으면 LLM allowlist 밖으로 skip.
|
||||
5. clean text 길이가 너무 짧거나 길면 skip.
|
||||
|
||||
Phase 5/6에서 `LLMPolicy`가 있으면 이를 우선하고, 없으면 이 legacy fallback을 유지해야 한다.
|
||||
|
||||
### ai_provider.py
|
||||
|
||||
파일: `ontology_platform/crawler_platform/app/core/extractor/ai_provider.py`
|
||||
|
||||
LLM prompt에 page_type semantics가 직접 들어간다.
|
||||
|
||||
현재 prompt contract:
|
||||
|
||||
- `ProductPage`는 product detail claim 가능
|
||||
- `CategoryPage`는 detailed product claim 제한
|
||||
- `BrandStoryPage`는 product price/note claim 제한
|
||||
- `UnknownPage`는 ontology claim을 반환하지 않도록 지시
|
||||
|
||||
신규 semantic type을 추가할 때 prompt가 `ProductDetailPage`, `CategoryListingPage`를 이해하도록 하거나, prompt에는 legacy page_type을 계속 넘기는 compatibility가 필요하다.
|
||||
|
||||
### validation.py
|
||||
|
||||
파일: `ontology_platform/crawler_platform/app/core/extractor/validation.py`
|
||||
|
||||
흐름:
|
||||
|
||||
- `attach_page_context()`가 entity/claim metadata에 `page_type`을 저장한다.
|
||||
- `claim_status_for_bundle()`은 `UnknownPage`, `SearchPage`, `CategoryPage`, `BoardPage`를 candidate claim으로 낮춘다.
|
||||
- `invalid_claim_reason()`은 relation schema compatibility에 `page_type`을 넘긴다.
|
||||
|
||||
신규 semantic type은 validation status와 relation schema의 allowed page type 검증에 영향을 준다.
|
||||
|
||||
## 5. 기존 page_type 문자열 기대 지점
|
||||
|
||||
### API request/response
|
||||
|
||||
파일: `ontology_platform/crawler_platform/app/api/routes.py`
|
||||
|
||||
- 여러 request model의 기본 `analyze_page_types`가 `["ProductPage", "BrandStoryPage", "ReviewPage"]`다.
|
||||
- relation/quality 관련 request에 `allowed_page_types`가 있다.
|
||||
- API response 및 progress payload에서 `page_type` 문자열을 그대로 노출한다.
|
||||
|
||||
### Config
|
||||
|
||||
파일: `ontology_platform/configs/perfume_subscription.yaml`
|
||||
|
||||
- `analyze_page_types` 또는 ontology rule에서 legacy page type이 쓰일 수 있다.
|
||||
- target entity/ontology에는 `ProductPage`, `BrandStoryPage`, `ListingPage`, `PromotionPage`, `ReviewPage` 같은 page entity type이 포함되어 있다.
|
||||
|
||||
### Adapters
|
||||
|
||||
파일: `ontology_platform/crawler_platform/app/adapters/ecommerce/perfume.py`
|
||||
|
||||
- relation rules가 `page_types={"ProductPage"}` 또는 `{"ProductPage", "ReviewPage"}` 같은 legacy set에 의존한다.
|
||||
|
||||
### Ontology relation schema
|
||||
|
||||
파일: `ontology_platform/crawler_platform/app/core/ontology/relation_schema.py`
|
||||
|
||||
- configured relation rule의 `allowed_page_types`/`page_types`를 set으로 읽는다.
|
||||
- `relation_schema_compatible()`은 `page_type not in rule.page_types`면 reject한다.
|
||||
- semantic type 도입 시 legacy alias expansion 없이는 기존 relation rules가 거부될 수 있다.
|
||||
|
||||
### Frontend
|
||||
|
||||
주요 파일:
|
||||
|
||||
- `ontology_platform/web/frontend/src/pages/CrawlPage.tsx`
|
||||
- `ontology_platform/web/frontend/src/pages/ReviewPage.tsx`
|
||||
- `ontology_platform/web/frontend/src/pages/BuildPipelinePage.tsx`
|
||||
- `ontology_platform/web/frontend/src/pages/QualityInspectorPage.tsx`
|
||||
- `ontology_platform/web/frontend/src/lib/api/crawl.ts`
|
||||
- `ontology_platform/web/frontend/src/lib/api/claims.ts`
|
||||
- `ontology_platform/web/frontend/src/lib/api/platform.ts`
|
||||
- legacy JS files under `web/frontend/src/legacy`
|
||||
|
||||
현재 frontend는 `page_type`을 optional string으로 표시하거나, allowed page types mismatch 검사에 사용한다.
|
||||
|
||||
### Tests
|
||||
|
||||
주요 테스트:
|
||||
|
||||
- `ontology_platform/tests/unit/test_phase7_hybrid_extraction.py`
|
||||
- `CategoryPage`가 LLM skip 되는 기존 behavior를 검증한다.
|
||||
- integration/e2e tests는 API response의 `page_type` 문자열 contract에 간접 의존한다.
|
||||
|
||||
Phase 8에서 semantic classifier 전용 테스트를 추가하되, 기존 문자열 contract 회귀 테스트도 유지해야 한다.
|
||||
|
||||
## 6. Phase 2 이후 유지해야 할 legacy contract
|
||||
|
||||
1. `classify_page(...)`는 계속 `str`을 반환해야 한다.
|
||||
2. 신규 API는 `classify_page_semantic(...) -> PageClassificationResult`처럼 분리하는 편이 안전하다.
|
||||
3. `normalize_page_type()`은 `PageClassificationResult`도 받을 수 있게 확장하되, 기존 string 입력 결과를 바꾸면 안 된다.
|
||||
4. `should_analyze_page(str, set)` 기존 호출은 계속 동작해야 한다.
|
||||
5. API/metadata의 `page_type` 필드는 legacy string으로 유지하고, semantic 결과는 별도 `page_classification` payload로 저장한다.
|
||||
6. `ExtractionPageContext.page_type`은 legacy string으로 유지하고, `analyze_strategy`/`llm_policy`/evidence는 `metadata`로 전달한다.
|
||||
7. relation schema, adapter page_types, frontend allowed page type 검증에는 legacy/semantic alias compatibility가 필요하다.
|
||||
8. `CategoryPage`, `SearchPage`, `BoardPage`, `UnknownPage`의 기존 skip/rule-only 동작은 Phase 5에서 strategy 기반으로 확장하되, LLM 호출이 늘지 않도록 `LLMPolicy`를 우선한다.
|
||||
@@ -0,0 +1,53 @@
|
||||
# PHASE 2. Taxonomy와 Classification Result 모델 추가
|
||||
|
||||
## 목표
|
||||
|
||||
기존 단일 문자열 page_type을 대체하지 않고, 그 위에 semantic classification result 모델을 추가한다.
|
||||
|
||||
## 작업 범위
|
||||
|
||||
1. 신규 taxonomy 모듈 추가
|
||||
- 권장 위치: `crawler_platform/app/core/crawler/page_type_taxonomy.py`
|
||||
- 기존 프로젝트 구조와 import 경계를 우선한다.
|
||||
|
||||
2. 다음 상수 또는 enum 정의
|
||||
- `PageDomain`
|
||||
- `PageArchetype`
|
||||
- `PageType`
|
||||
- `EntityType`
|
||||
- `ActionIntent`
|
||||
- `GraphRole`
|
||||
- `AnalyzeStrategy`
|
||||
- `LLMPolicy`
|
||||
|
||||
3. classification result 모델 추가
|
||||
- `EvidenceItem`
|
||||
- `PageClassificationResult`
|
||||
|
||||
4. legacy compatibility helper 추가
|
||||
- `normalize_page_type(result_or_page_type)`
|
||||
- `get_legacy_page_type(result_or_page_type)`
|
||||
- `classify_page_semantic(...)`
|
||||
- 기존 `classify_page(...) -> str` 유지
|
||||
|
||||
5. 기존 page type alias 유지
|
||||
- `ProductPage`
|
||||
- `CategoryPage`
|
||||
- `SearchPage`
|
||||
- `BoardPage`
|
||||
- `NoticePage`
|
||||
- `BrandStoryPage`
|
||||
- `PromotionPage`
|
||||
- `UnknownPage`
|
||||
- `ReviewPage`
|
||||
|
||||
## 수정 금지
|
||||
|
||||
- 기존 `classify_page(...)` 호출부를 한 번에 모두 semantic result 기반으로 바꾸지 않는다.
|
||||
- legacy 문자열을 제거하지 않는다.
|
||||
|
||||
## 완료 기준
|
||||
|
||||
- 기존 호출부가 string page_type을 그대로 받을 수 있어야 한다.
|
||||
- semantic result API를 신규 테스트에서 직접 호출할 수 있어야 한다.
|
||||
- legacy alias mapping이 테스트로 보호되어야 한다.
|
||||
@@ -0,0 +1,93 @@
|
||||
# PHASE 3. Raw Snapshot 및 Signal Extraction 레이어 추가
|
||||
|
||||
## 목표
|
||||
|
||||
URL substring 중심 분류를 줄이고, HTML/DOM/metadata/link/form/text 기반 signal을 별도 레이어에서 추출한다.
|
||||
|
||||
## 작업 범위
|
||||
|
||||
1. `RawPageSnapshot` 모델 추가
|
||||
|
||||
* `url`, `final_url`, `status_code`, `content_type`
|
||||
* `title`, `text`, `html`, `rendered_html`
|
||||
* metadata, structured data, headings, links, images, forms, buttons, inputs, tables
|
||||
* source_zones, screenshot_path
|
||||
|
||||
2. `PageSignals` 모델 추가
|
||||
|
||||
* structured data signals
|
||||
* commerce signals
|
||||
* listing signals
|
||||
* editorial signals
|
||||
* community signals
|
||||
* knowledge/docs signals
|
||||
* corporate/legal signals
|
||||
* transaction/protected signals
|
||||
* graph/link signals
|
||||
* text/layout keyword signals
|
||||
* visual/layout block signals
|
||||
* external collector signals
|
||||
|
||||
3. signal extractor 추가
|
||||
|
||||
* 권장 위치: `crawler_platform/app/core/crawler/page_signal_extractor.py`
|
||||
* BeautifulSoup 사용 가능 시 DOM parsing
|
||||
* BeautifulSoup 미설치/HTML 깨짐 시 regex/text fallback
|
||||
|
||||
4. 다국어 확장 고려
|
||||
|
||||
* 한국어/영어 키워드 dictionary를 분리 가능한 구조로 둔다.
|
||||
* 인코딩 깨짐이 있어도 예외 없이 동작한다.
|
||||
|
||||
5. visual/layout signal 세부화 고려
|
||||
|
||||
* screenshot 기반 정밀 분석은 이번 phase의 필수 구현 범위가 아니지만, 향후 visual block classification을 붙일 수 있도록 signal 구조를 열어둔다.
|
||||
* DOM class/id/role/aria/heading 구조와 반복 레이아웃을 통해 가능한 범위에서 visual/layout block 후보를 추출한다.
|
||||
* visual/layout block signal 예시는 다음과 같다.
|
||||
|
||||
* hero block
|
||||
* product card grid
|
||||
* article body block
|
||||
* left filter sidebar
|
||||
* top navigation
|
||||
* footer navigation
|
||||
* sticky buy box
|
||||
* review/comment block
|
||||
* FAQ accordion
|
||||
* media player area
|
||||
* map area
|
||||
* calendar/availability grid
|
||||
* dashboard card grid
|
||||
* form wizard / stepper
|
||||
* pricing table
|
||||
* comparison table
|
||||
* 초기 구현은 실제 computer vision까지 요구하지 않는다.
|
||||
* 다만 `PageSignals`에는 visual/layout 후보를 담을 수 있는 필드를 둔다.
|
||||
* 예시 필드:
|
||||
|
||||
* `layout_blocks: list[str]`
|
||||
* `has_hero_block: bool`
|
||||
* `has_card_grid: bool`
|
||||
* `has_filter_sidebar: bool`
|
||||
* `has_sticky_action_box: bool`
|
||||
* `has_media_player_area: bool`
|
||||
* `has_map_area: bool`
|
||||
* `has_calendar_grid: bool`
|
||||
* `has_pricing_table: bool`
|
||||
* `has_comparison_table: bool`
|
||||
* visual/layout signal은 page type을 단독 확정하지 않고, evidence scoring의 보조 신호로 사용한다.
|
||||
|
||||
## 수정 금지
|
||||
|
||||
* signal extractor에서 page_type을 확정 반환하지 않는다.
|
||||
* 이 phase에서 extractor나 crawler의 분석 여부 정책을 바꾸지 않는다.
|
||||
* Crawl4AI, Firecrawl, Trafilatura 같은 외부 도구에 핵심 classifier가 강하게 종속되도록 만들지 않는다.
|
||||
* screenshot 또는 visual analysis가 없다는 이유로 기본 signal extraction이 실패하면 안 된다.
|
||||
|
||||
## 완료 기준
|
||||
|
||||
* HTML이 비어도 `PageSignals`가 생성되어야 한다.
|
||||
* JSON-LD/OpenGraph/form/input/button/link/text signal이 evidence scorer에서 사용할 수 있는 형태로 정리되어야 한다.
|
||||
* 외부 수집 도구 결과를 `RawPageSnapshot`에 매핑할 수 있는 구조 또는 adapter hook이 있어야 한다.
|
||||
* visual/layout block 후보를 담을 수 있는 `PageSignals` 필드가 있어야 한다.
|
||||
* visual/layout signal이 없어도 기존 signal extraction과 scoring 흐름은 정상 동작해야 한다.
|
||||
@@ -0,0 +1,99 @@
|
||||
# PHASE 4. Evidence Scoring 기반 Semantic Classification 구현
|
||||
|
||||
## 목표
|
||||
|
||||
단일 if-return 방식이 아니라 signal별 evidence weight를 합산해 semantic page type을 결정한다.
|
||||
|
||||
## 작업 범위
|
||||
|
||||
1. `page_type_scorer.py` 추가
|
||||
|
||||
* score accumulator
|
||||
* evidence item 생성 helper
|
||||
* normalize 및 confidence 계산
|
||||
* alternatives 산출
|
||||
|
||||
2. 최소 구현 page type
|
||||
|
||||
* `ProductDetailPage`
|
||||
* `CategoryListingPage`
|
||||
* `SearchResultsPage`
|
||||
* `ArticlePage`
|
||||
* `BlogPostPage`
|
||||
* `QAPage`
|
||||
* `FAQPage`
|
||||
* `ForumBoardPage`
|
||||
* `ForumThreadPage`
|
||||
* `BrandStoryPage`
|
||||
* `AboutPage`
|
||||
* `ContactPage`
|
||||
* `DocumentationPage`
|
||||
* `APIReferencePage`
|
||||
* `JobPostingPage`
|
||||
* `PricingPage`
|
||||
* `LoginPage`
|
||||
* `CheckoutPage`
|
||||
* `PaymentPage`
|
||||
* `TermsPage`
|
||||
* `PrivacyPolicyPage`
|
||||
* `SitemapPage`
|
||||
* `RSSFeedPage`
|
||||
* `ErrorPage`
|
||||
* `AccessDeniedPage`
|
||||
* `CaptchaPage`
|
||||
* `UnknownPage`
|
||||
|
||||
3. 범용 taxonomy 확장 기준
|
||||
|
||||
* 위의 최소 구현 page type은 1차 구현 범위로 본다.
|
||||
* 장기 목표는 인터넷에 존재하는 다양한 페이지를 포괄할 수 있는 전체 taxonomy catalog를 유지하는 것이다.
|
||||
* 따라서 `page_type_scorer.py`와 taxonomy 정의는 아래 계열을 나중에 확장할 수 있는 구조로 작성한다.
|
||||
|
||||
* Site / Navigation
|
||||
* Commerce / Marketplace
|
||||
* Editorial / Article
|
||||
* Community / UGC
|
||||
* Knowledge / Documentation
|
||||
* Corporate / Organization
|
||||
* Local / Place / Travel / Real Estate
|
||||
* Education / Learning
|
||||
* Jobs / Career
|
||||
* Media / Entertainment
|
||||
* Software / SaaS / App
|
||||
* Finance / Legal / Government
|
||||
* Healthcare / Medical
|
||||
* Transaction / Account / Protected
|
||||
* System / Technical / Machine-readable
|
||||
* 현재 phase에서는 위 전체 계열을 모두 scoring 구현하지 않아도 된다.
|
||||
* 다만 enum, alias, mapping, scorer 구조는 특정 몇 개 타입에 고정하지 말고, 전체 taxonomy catalog가 추가되어도 깨지지 않도록 확장 가능해야 한다.
|
||||
* Phase 4의 최소 구현 page type은 1차 안정화 대상이며, 전체 taxonomy catalog는 별도 taxonomy 문서 또는 후속 phase에서 보강한다.
|
||||
* `UnknownPage`는 전체 taxonomy에 아직 포함되지 않은 신규 페이지 패턴을 발견하기 위한 후보로 유지한다.
|
||||
|
||||
4. result enrichment
|
||||
|
||||
* `domain`
|
||||
* `archetype`
|
||||
* `main_entity_type`
|
||||
* `action_intents`
|
||||
* `graph_roles`
|
||||
* `confidence`
|
||||
* `alternatives`
|
||||
* `evidence`
|
||||
|
||||
5. low confidence 처리
|
||||
|
||||
* threshold 아래는 `UnknownPage`
|
||||
* alternatives/evidence는 유지
|
||||
|
||||
## 수정 금지
|
||||
|
||||
* URL 문자열 조건만 추가해서 바로 return하지 않는다.
|
||||
* Product/Category/Search/Board만 처리하는 구조로 고정하지 않는다.
|
||||
* 1차 최소 구현 page type만을 전체 taxonomy의 전부로 간주하지 않는다.
|
||||
|
||||
## 완료 기준
|
||||
|
||||
* classification result가 항상 evidence를 포함해야 한다.
|
||||
* 보호 페이지가 commerce/detail page로 오분류되지 않아야 한다.
|
||||
* category/search/board 계열은 skip 여부가 아니라 semantic type과 graph role이 남아야 한다.
|
||||
* 최소 구현 page type은 동작해야 하며, 전체 taxonomy catalog를 후속 확장할 수 있는 구조여야 한다.
|
||||
@@ -0,0 +1,45 @@
|
||||
# PHASE 5. Analyze Strategy 및 LLM Policy 분리
|
||||
|
||||
## 목표
|
||||
|
||||
분석 여부를 단순 boolean allowlist에서 page type별 strategy와 LLM policy로 분리한다.
|
||||
|
||||
## 작업 범위
|
||||
|
||||
1. `page_analysis_policy.py` 추가
|
||||
- `decide_analyze_strategy(result)`
|
||||
- `decide_llm_policy(result)`
|
||||
- `is_protected_strategy(strategy)`
|
||||
- `is_noise_strategy(strategy)`
|
||||
|
||||
2. strategy mapping
|
||||
- `ProductDetailPage`, `ArticlePage`, `FAQPage`, `QAPage`, `BrandStoryPage` -> `AnalyzeFull`
|
||||
- `CategoryListingPage`, `ForumBoardPage` -> `AnalyzeRelationsOnly`
|
||||
- `SearchResultsPage`, `SitemapPage` -> `AnalyzeDiscoveryOnly`
|
||||
- `TermsPage`, `PrivacyPolicyPage` -> `AnalyzeDocumentOnly`
|
||||
- `LoginPage`, `CheckoutPage`, `PaymentPage`, `CaptchaPage`, `AccessDeniedPage` -> `SkipProtected`
|
||||
- `ErrorPage`, `NotFoundPage` -> `SkipNoise`
|
||||
- `UnknownPage` -> `AnalyzeMetadataOnly`
|
||||
|
||||
3. LLM policy mapping
|
||||
- full content page -> `LLMFull` 또는 `LLMLight`
|
||||
- listing/search/board -> `LLMForAmbiguityOnly` 또는 `RuleOnly`
|
||||
- sitemap/rss/robots/protected -> `RuleOnly`, `NoLLM`, 또는 `Skip`
|
||||
|
||||
4. compatibility
|
||||
- `should_analyze_page(result_or_page_type, analyze_page_types=None)`
|
||||
- 기존 allowlist가 들어오면 legacy behavior를 최대한 유지하되 protected/noise는 안전하게 skip
|
||||
- semantic result가 들어오면 strategy 우선
|
||||
|
||||
## 수정 금지
|
||||
|
||||
- CategoryPage/SearchPage/BoardPage를 무조건 skip하지 않는다.
|
||||
- protected page에서 extractor/LLM이 개인정보를 추출하도록 두지 않는다.
|
||||
|
||||
## 완료 기준
|
||||
|
||||
- `CategoryListingPage.should_analyze == True`
|
||||
- `SearchResultsPage.should_analyze == True`
|
||||
- `ForumBoardPage.should_analyze == True`
|
||||
- `LoginPage`, `CheckoutPage`, `PaymentPage`는 `should_analyze == False`
|
||||
- LLM 호출이 기존보다 불필요하게 증가하지 않도록 policy 테스트가 있어야 한다.
|
||||
@@ -0,0 +1,45 @@
|
||||
# PHASE 6. Crawler, Cleaner, Extractor, Discovery/Relevance 통합
|
||||
|
||||
## 목표
|
||||
|
||||
Semantic classification result를 crawler와 extractor 흐름에 연결하되, 기존 page_type 문자열 기반 contract는 유지한다.
|
||||
|
||||
## 작업 범위
|
||||
|
||||
1. `site_crawler.py`
|
||||
- `classify_page_semantic(...)` 호출
|
||||
- 기존 `page_type` 필드는 legacy string으로 유지
|
||||
- metadata에 `page_classification` 저장
|
||||
- `should_analyze_page(result, analyze_page_types)` 사용
|
||||
|
||||
2. `pipeline.py`
|
||||
- 단일 URL 처리에서도 semantic payload 저장
|
||||
- `ExtractionPageContext.metadata`에 strategy/policy 전달
|
||||
|
||||
3. `ExtractionPageContext`
|
||||
- 기존 `page_type: str`는 유지
|
||||
- metadata 기반 `analyze_strategy`, `llm_policy`, evidence payload 전달
|
||||
|
||||
4. `HybridExtractor`
|
||||
- metadata의 `llm_policy`를 우선 사용
|
||||
- 없으면 기존 `LLM_PAGE_TYPES`, `SKIP_LLM_PAGE_TYPES`, `RULE_ONLY_PAGE_TYPES` fallback
|
||||
- protected/skip policy는 LLM 호출 금지
|
||||
|
||||
5. compatibility update
|
||||
- `page_cleaner.py` zone priority에 semantic aliases 추가
|
||||
- `domain_discovery.py` page_type entity mapping에 semantic aliases 추가
|
||||
- `relevance_engine.py` high value score mapping에 semantic aliases 추가
|
||||
- relation schema allowed page type 검증에서 legacy/semantic alias 고려
|
||||
|
||||
## 수정 금지
|
||||
|
||||
- extractor 전체를 새 구조로 갈아엎지 않는다.
|
||||
- DB schema 변경을 필수로 만들지 않는다.
|
||||
- frontend 표시용 기존 `page_type` 필드를 제거하지 않는다.
|
||||
|
||||
## 완료 기준
|
||||
|
||||
- 기존 API response의 `page_type`은 문자열로 유지된다.
|
||||
- semantic classification payload가 metadata에 남는다.
|
||||
- HybridExtractor가 LLMPolicy에 따라 LLM 호출을 줄일 수 있다.
|
||||
- 기존 page_type 문자열 기반 relation rule이 신규 semantic type 때문에 깨지지 않는다.
|
||||
@@ -0,0 +1,47 @@
|
||||
# PHASE 7. Unknown Pattern 저장 기반 추가
|
||||
|
||||
## 목표
|
||||
|
||||
`UnknownPage`를 분석 실패나 폐기 대상으로 두지 않고, taxonomy 확장 후보로 저장 가능한 evidence payload를 만든다.
|
||||
|
||||
## 작업 범위
|
||||
|
||||
1. unknown payload 정의
|
||||
- url
|
||||
- title
|
||||
- text sample
|
||||
- html fingerprint
|
||||
- dom fingerprint
|
||||
- schema types
|
||||
- link pattern summary
|
||||
- button labels
|
||||
- forms summary
|
||||
- top keywords
|
||||
- alternatives
|
||||
- evidence
|
||||
|
||||
2. fingerprint hook
|
||||
- text fingerprint
|
||||
- html/dom fingerprint
|
||||
- link pattern fingerprint
|
||||
|
||||
3. 저장 위치
|
||||
- 초기 구현은 `metadata_json["page_classification"]["unknown_pattern"]`
|
||||
- DB migration 없이 시작
|
||||
|
||||
4. 향후 확장 hook
|
||||
- embedding input 생성 함수
|
||||
- cluster candidate payload 생성 함수
|
||||
- 실제 clustering은 이번 phase 범위에서 제외
|
||||
|
||||
## 수정 금지
|
||||
|
||||
- UnknownPage를 무조건 extractor 대상에서 제외하지 않는다.
|
||||
- low confidence 결과의 evidence와 alternatives를 버리지 않는다.
|
||||
- 이 phase에서 clustering 알고리즘을 새로 도입하지 않는다.
|
||||
|
||||
## 완료 기준
|
||||
|
||||
- UnknownPage result가 evidence를 가진다.
|
||||
- metadata에 unknown pattern summary가 저장 가능하다.
|
||||
- 향후 clustering/관리자 검토 UI로 넘길 수 있는 구조다.
|
||||
@@ -0,0 +1,59 @@
|
||||
# PHASE 8. 테스트 Fixture 및 회귀 검증
|
||||
|
||||
## 목표
|
||||
|
||||
Semantic Page Understanding Layer가 기존 흐름을 깨지 않고, page type별 strategy와 LLM policy를 정확히 산출하는지 검증한다.
|
||||
|
||||
## 작업 범위
|
||||
|
||||
1. fixture 추가
|
||||
- `tests/fixtures/pages/product_detail.html`
|
||||
- `tests/fixtures/pages/category_listing.html`
|
||||
- `tests/fixtures/pages/search_results.html`
|
||||
- `tests/fixtures/pages/article.html`
|
||||
- `tests/fixtures/pages/qapage.html`
|
||||
- `tests/fixtures/pages/faq.html`
|
||||
- `tests/fixtures/pages/forum_thread.html`
|
||||
- `tests/fixtures/pages/documentation.html`
|
||||
- `tests/fixtures/pages/job_posting.html`
|
||||
- `tests/fixtures/pages/login.html`
|
||||
- `tests/fixtures/pages/checkout.html`
|
||||
- `tests/fixtures/pages/terms.html`
|
||||
- `tests/fixtures/pages/sitemap.xml`
|
||||
- `tests/fixtures/pages/unknown.html`
|
||||
|
||||
2. unit test 추가
|
||||
- primary_page_type 확인
|
||||
- confidence 최소 기준 확인
|
||||
- evidence non-empty 확인
|
||||
- analyze_strategy 확인
|
||||
- llm_policy 확인
|
||||
- protected page skip 확인
|
||||
- UnknownPage 예외 없는 처리 확인
|
||||
|
||||
3. compatibility test 추가
|
||||
- 기존 `classify_page(...) -> str`
|
||||
- 신규 `classify_page_semantic(...) -> PageClassificationResult`
|
||||
- `should_analyze_page(str, set)`
|
||||
- `should_analyze_page(result, set)`
|
||||
- legacy aliases
|
||||
|
||||
4. extractor policy 회귀 테스트
|
||||
- HybridExtractor가 metadata `llm_policy`를 우선 사용
|
||||
- `Skip`, `NoLLM`, `RuleOnly`에서 LLM 호출 금지
|
||||
- 기존 legacy page_type fallback 유지
|
||||
|
||||
5. 회귀 테스트 실행
|
||||
- 기본 명령: `pytest`
|
||||
- 필요 시 변경 범위 우선: `pytest tests/unit`
|
||||
|
||||
## 수정 금지
|
||||
|
||||
- 기존 테스트 기대값을 불필요하게 변경하지 않는다.
|
||||
- LLM live 호출이 필요한 테스트를 기본 회귀 테스트에 포함하지 않는다.
|
||||
|
||||
## 완료 기준
|
||||
|
||||
- 새 semantic classifier 테스트가 통과한다.
|
||||
- 기존 unit/integration 테스트가 통과한다.
|
||||
- 테스트 결과와 미실행 사유가 작업 완료 보고에 명확히 기록된다.
|
||||
@@ -1,132 +0,0 @@
|
||||
# Phase 0 — Acceptance Gate 결과
|
||||
|
||||
본 문서는 통합설계서 §5 Phase 0의 Acceptance Gate를 객관적으로 점검한 결과다. Phase 1 진입 전에 모든 체크가 통과되어야 한다.
|
||||
|
||||
## 결과 요약
|
||||
|
||||
| # | Acceptance Gate 항목 | 상태 | 검증 방법 |
|
||||
|---|---|---|---|
|
||||
| 1 | 단일 PDF/JSON 입력 → ontology TTL + facts TTL이 filesystem에 생성됨 | ⚠️ **e2e 검증 대기** (로컬 LLM/API 키 필요) | `tests/e2e/test_phase0_full_pipeline.py` |
|
||||
| 2 | `/health`, `/info`, `/process` (FastAPI) 정상 동작 | ✅ **통합 테스트 11/11 통과** (2026-05-19) | `tests/integration/test_api_smoke.py` |
|
||||
| 3 | BudgetTracker가 LLM call/triple count를 정확히 기록 | ⚠️ **e2e 검증 대기** (mock 검증은 통합 테스트로 통과) | e2e 테스트가 실제 검증 |
|
||||
| 4 | LangGraph 워크플로우 (CONVERT→CHUNK→...→SERIALIZE) 전 노드 traceable | ✅ **OntoCast 원본 워크플로우 무수정 채택** | `vendored/ontocast/ontocast/stategraph/` 그대로 사용 |
|
||||
|
||||
추가로 **단위 테스트 16/16 통과** (test_convert_document 7, test_platform_config 5, test_select_ontology 4).
|
||||
자동 검증 기준으로는 **unit + integration 27/27 통과**가 현재 Phase 0 기본선이다.
|
||||
|
||||
**현재 진척 (2026-05-19)**:
|
||||
- Python 3.14.5 `.venv` 환경에서 unit + integration 27/27 통과
|
||||
- `python-multipart`를 Phase 0 FastAPI multipart upload 필수 의존성으로 추가
|
||||
- Phase 0 production app에서 Phase 1 Trafilatura route가 기본 mount되지 않도록 lazy phase route gate 적용
|
||||
- `pip install -e ".[dev]"` 또는 동등한 의존성 설치 필요
|
||||
- `pip install -e vendored/ontocast` 로 OntoCast 의존성 설치 완료
|
||||
- 패키지 이름 충돌 수정: `platform/` → `ont_platform/` (Python 내장 `platform` 모듈과 충돌)
|
||||
- **남은 작업**: e2e 테스트 (Acceptance Gate #1, #3) 실행 — 로컬 Ollama 또는 OpenAI 키 필요
|
||||
|
||||
## 다음 작업자가 실행할 검증 절차
|
||||
|
||||
### 1) 환경 준비
|
||||
|
||||
```powershell
|
||||
# Python 3.12+ 설치 (예: https://www.python.org/downloads/)
|
||||
python --version # Python 3.12.x 이상 확인
|
||||
|
||||
cd C:\Users\lasta\MyProject\AI\ontology_platform
|
||||
|
||||
# 가상환경 + 의존성 설치
|
||||
python -m venv .venv
|
||||
.venv\Scripts\activate
|
||||
pip install --upgrade pip
|
||||
pip install -e ".[dev]"
|
||||
|
||||
# .env 생성 (실제 LLM 키 채우기)
|
||||
Copy-Item .env.example .env
|
||||
# 그 다음 .env 파일을 편집하여 LLM_API_KEY 등 채움
|
||||
```
|
||||
|
||||
### 2) 자동 검증 (Acceptance Gate #2)
|
||||
|
||||
```powershell
|
||||
# 단위 + 통합 테스트만 (LLM 호출 없음, 빠름)
|
||||
.venv\Scripts\python.exe -m pytest tests/unit tests/integration -v
|
||||
```
|
||||
|
||||
**기대 결과**: 모든 케이스 PASS.
|
||||
|
||||
- `tests/unit/test_select_ontology.py` (4 케이스) — Phase 0.2 검증
|
||||
- `tests/unit/test_convert_document.py` (7 케이스) — Phase 0.3 검증
|
||||
- `tests/unit/test_platform_config.py` (5 케이스) — Phase 0.5 검증
|
||||
- `tests/integration/test_api_smoke.py` (11 케이스) — Phase 0.4 + 0.6 mock 검증, Phase 0 future dependency route gate 검증
|
||||
|
||||
Windows에서 `%TEMP%` 권한 문제 또는 `.pytest_cache` 쓰기 문제가 발생하면 아래처럼 pytest temp/cache 위치를 workspace 내부로 고정한다.
|
||||
|
||||
```powershell
|
||||
$env:TMP=(Join-Path (Resolve-Path '.').Path 'pytest_tmp')
|
||||
$env:TEMP=$env:TMP
|
||||
New-Item -ItemType Directory -Force -Path $env:TMP | Out-Null
|
||||
.venv\Scripts\python.exe -m pytest tests/unit tests/integration -v --basetemp "$env:TMP\basetemp" -o cache_dir="$env:TMP\cache"
|
||||
```
|
||||
|
||||
### 3) End-to-end 검증 (Acceptance Gate #1, #3, #4)
|
||||
|
||||
LLM 호출이 실제로 일어남. OpenAI는 비용 발생, Ollama는 로컬에서 무료.
|
||||
|
||||
```powershell
|
||||
# (A) Ollama 로컬 사용 (권장 — 비용 무료)
|
||||
# 사전: Ollama 설치 후 `ollama pull qwen2.5`
|
||||
$env:LLM_PROVIDER = "ollama"
|
||||
$env:LLM_MODEL_NAME = "qwen2.5"
|
||||
$env:LLM_BASE_URL = "http://localhost:11434"
|
||||
pytest tests/e2e -m e2e -v
|
||||
|
||||
# (B) OpenAI 사용
|
||||
$env:LLM_PROVIDER = "openai"
|
||||
$env:LLM_MODEL_NAME = "gpt-4o-mini"
|
||||
$env:LLM_API_KEY = "sk-..."
|
||||
pytest tests/e2e -m e2e -v
|
||||
```
|
||||
|
||||
**기대 결과**:
|
||||
- `test_full_pipeline_writes_ontology_and_facts` PASS
|
||||
- 응답에서 ontology TTL과 facts TTL이 비어 있지 않음
|
||||
- `metadata.budget.calls_count > 0`
|
||||
- `metadata.budget.ontology_triples_generated > 0` 또는 `facts_triples_generated > 0`
|
||||
- `tmp_path / "work"` 아래 `.ttl` 또는 `.rdf` 파일 생성됨
|
||||
|
||||
### 4) 수동 smoke (선택)
|
||||
|
||||
```powershell
|
||||
# 서버 기동
|
||||
uvicorn ont_platform.api.main:app --reload
|
||||
|
||||
# 다른 셸에서
|
||||
curl http://localhost:8000/health
|
||||
curl http://localhost:8000/info
|
||||
curl -X POST http://localhost:8000/process `
|
||||
-H "Content-Type: application/json" `
|
||||
-d '{"text":"Alice works at Acme in Berlin."}'
|
||||
```
|
||||
|
||||
## 통과 시 처리
|
||||
|
||||
위 모든 검증을 통과하면 **이 문서의 표 상태 컬럼을 ✅로 갱신**하고 git에 commit한다.
|
||||
|
||||
이후 Phase 1 작업은 [PHASE1_NEXT_STEPS.md](PHASE1_NEXT_STEPS.md)를 따른다.
|
||||
|
||||
## 실패 시 처리
|
||||
|
||||
- **단위 테스트 실패**: 어느 케이스가 실패했는지 확인. Phase 0.2/0.3/0.5의 vendored 수정 또는 platform/ 코드에 회귀가 발생했을 가능성. PR 단위로 롤백 후 재시도.
|
||||
- **통합 테스트 실패**: FastAPI 라우팅/의존성 주입 문제. `platform/api/main.py` 또는 `platform/api/deps.py` 확인.
|
||||
- **E2E 테스트 실패**:
|
||||
- `LLM_API_KEY`, `LLM_PROVIDER`, `LLM_MODEL_NAME` 환경변수 확인
|
||||
- 워크플로우가 timeout: `ServerConfig.base_recursion_limit` 조정 검토
|
||||
- OntoCast `select_ontology.py` 또는 `convert_document.py` 수정에 회귀가 있는지 점검 (VENDORED_MODIFICATIONS.md 참조)
|
||||
|
||||
## 검증 이력
|
||||
|
||||
| 일자 | 검증자 | 결과 |
|
||||
|---|---|---|
|
||||
| 2026-05-13 | (코드 작성: ontology-platform agent) | 코드 준비 완료. 실 환경 검증 보류. |
|
||||
| 2026-05-14 | lasta + Claude | **unit 16/16, integration 10/10 통과** (Gate #2 ✅). 패키지 이름 충돌 수정 (`platform`→`ont_platform`). e2e는 LLM 필요로 대기. |
|
||||
| 2026-05-19 | Codex | **unit 16/16, integration 11/11, 총 27/27 통과**. Phase 0 route gate 추가로 Trafilatura route는 PHASE>=1에서만 lazy mount. e2e는 LLM 필요로 대기. |
|
||||
| ____-__-__ | ________________ | __________________________________ |
|
||||
@@ -1,124 +0,0 @@
|
||||
# Phase 0 — 다음 작업자 핸드오프
|
||||
|
||||
본 문서는 이 프로젝트를 이어받는 AI 에이전트 또는 개발자가 즉시 작업을 시작하기 위한 핸드오프 노트다.
|
||||
|
||||
## 현재 상태 (지금까지 완료된 것)
|
||||
|
||||
- [x] **0.1 일부**: 폴더 골격, `pyproject.toml`, `README.md`, `.env.example`, `.gitignore`, `NOTICE` 생성
|
||||
- [x] **설계 문서**: [../통합설계서.md](../통합설계서.md) 배치 완료 (모든 작업의 기준)
|
||||
|
||||
## 즉시 시작할 작업 (순서대로)
|
||||
|
||||
### 0.1 (잔여): OntoCast vendored copy
|
||||
|
||||
**근거**: 통합설계서 §9.1, OntoCast 분석 §13.1
|
||||
|
||||
```powershell
|
||||
# 1. 원본을 vendored/ontocast로 복사 (.git 제외)
|
||||
Copy-Item -Path "C:\Users\lasta\MyProject\AI\참고\ontocast-main\*" `
|
||||
-Destination "C:\Users\lasta\MyProject\AI\ontology_platform\vendored\ontocast\" `
|
||||
-Recurse -Exclude ".git",".github",".venv","node_modules"
|
||||
|
||||
# 2. 원본 LICENSE 및 NOTICE를 vendored/ontocast/ 안에 그대로 유지
|
||||
# 3. NOTICE 파일의 "(원본 저장소 URL 기입)" 부분을 실제 URL로 채우기
|
||||
# 4. git init (아직 안 했다면)
|
||||
cd C:\Users\lasta\MyProject\AI\ontology_platform
|
||||
git init
|
||||
git add .
|
||||
git commit -m "Initial scaffold: folder skeleton, design doc, NOTICE"
|
||||
```
|
||||
|
||||
**확인 사항**:
|
||||
- [ ] `vendored/ontocast/` 안에 원본 LICENSE 파일이 있어야 한다
|
||||
- [ ] `vendored/ontocast/pyproject.toml`은 그대로 두되, 우리 `pyproject.toml`이 우선
|
||||
- [ ] NOTICE 파일의 OntoCast 항목에 실제 source URL 기입
|
||||
|
||||
### 0.2: `select_ontology.py` 버그 수정
|
||||
|
||||
**근거**: 통합설계서 §5 Phase 0, OntoCast 분석 §13.1 / §9.1 (1번)
|
||||
|
||||
**문제**: `vendored/ontocast/ontocast/agent/select_ontology.py`에서 None 선택 인덱스 불일치.
|
||||
- 코드는 `answer_index == 0`을 None으로 처리
|
||||
- 그러나 dynamic model은 `1..num_ontologies+1` 범위 사용
|
||||
- 실제 None 선택은 `num_ontologies + 1`이어야 자연스러움
|
||||
|
||||
**조치**:
|
||||
1. 해당 함수의 분기 로직을 `answer_index == num_ontologies + 1` 또는 동등한 표현으로 수정
|
||||
2. **수정 사실을 파일 상단 주석으로 명시** (Apache 2.0 의무): 예) `# MODIFIED 2026-MM-DD: Fixed None index inconsistency, see docs/통합설계서.md §5 Phase 0`
|
||||
3. 회귀 테스트 작성: `tests/unit/test_select_ontology.py`
|
||||
- 케이스 1: ontology가 0개일 때 → None 반환
|
||||
- 케이스 2: ontology가 N개, LLM이 1~N 선택 → 해당 ontology 반환
|
||||
- 케이스 3: ontology가 N개, LLM이 N+1 선택 → None 반환
|
||||
|
||||
### 0.3: `convert_document.py` 다중 파일 처리 확장
|
||||
|
||||
**근거**: 통합설계서 §5 Phase 0, OntoCast 분석 §13.1 (3번) / §21.1 (4번)
|
||||
|
||||
**문제**: `convert_document()`가 "processing only one file"로 주석 처리되어 있고, 다중 파일 처리 시 마지막 파일 기준으로만 상태가 업데이트됨.
|
||||
|
||||
**조치**:
|
||||
1. 입력 파일 목록을 순회하며 각 파일을 독립 `ContentUnit`으로 만들어 `AgentState.content_units`에 누적
|
||||
2. 동일 corpus 내 파일들이 함께 처리되도록 보장 (각 파일이 별도 doc IRI를 가짐)
|
||||
3. 회귀 테스트: 2개 PDF를 한 번에 처리 → 둘 다 처리되어야 함
|
||||
|
||||
### 0.4: Robyn → FastAPI 재작성
|
||||
|
||||
**근거**: 통합설계서 §11 (기술 스택), OntoCast 분석 §13 (API 명세)
|
||||
|
||||
**조치**:
|
||||
1. `platform/api/main.py` 생성 (FastAPI app 인스턴스)
|
||||
2. OntoCast 분석 §13.1~§13.4의 4개 endpoint를 FastAPI로 동일 시맨틱 재작성:
|
||||
- `GET /health`
|
||||
- `GET /info`
|
||||
- `POST /process` (JSON + multipart)
|
||||
- `POST /flush` (관리자 권한 + confirmation token, 분석 §21.1 #6)
|
||||
3. OntoCast의 `ToolBox` 의존성 주입은 FastAPI `Depends`로 변환
|
||||
4. `uvicorn platform.api.main:app --reload`로 기동 가능해야 함
|
||||
|
||||
**중요**: OntoCast 코어 모듈(`stategraph/`, `agent/`, `onto/`, `tool/`)은 **건드리지 않는다**. API 레이어만 재작성.
|
||||
|
||||
### 0.5: Pydantic Settings 정리
|
||||
|
||||
**근거**: 통합설계서 §5 Phase 0 (5번), OntoCast 분석 §15
|
||||
|
||||
**조치**:
|
||||
1. `platform/config.py` 생성
|
||||
2. `pydantic-settings`의 `BaseSettings`로 `.env` 로딩
|
||||
3. **Phase 0에서는 filesystem 모드만 활성화** (Fuseki/Neo4j는 Phase 4에서):
|
||||
- `STORAGE_BACKEND=filesystem` 강제
|
||||
- Neo4j/Fuseki 변수가 채워져 있어도 무시
|
||||
4. OntoCast의 기존 `Config` 클래스는 우리 `Settings`에서 만들어 주입
|
||||
|
||||
### 0.6: End-to-end 통합 테스트
|
||||
|
||||
**근거**: 통합설계서 §5 Phase 0 Acceptance Gate
|
||||
|
||||
**조치**:
|
||||
1. `vendored/ontocast/data/`의 예제 JSON 또는 PDF 1개를 fixture로 복사 → `tests/fixtures/`
|
||||
2. `tests/integration/test_phase0_e2e.py` 작성:
|
||||
- FastAPI `TestClient`로 `/process` 호출
|
||||
- 응답에 `ontology` TTL과 `facts` TTL 둘 다 포함
|
||||
- `working_directory/`에 ontology/facts 파일 생성 확인
|
||||
- BudgetTracker가 LLM call/triple count를 0보다 큰 값으로 기록
|
||||
|
||||
### 0.7: Acceptance Gate 0 체크
|
||||
|
||||
통합설계서 §5 Phase 0 Acceptance Gate의 4개 체크박스를 PR에 인용하며 모두 확인:
|
||||
|
||||
- [ ] 단일 PDF 또는 JSON 입력 → ontology TTL + facts TTL이 filesystem에 생성됨
|
||||
- [ ] `/health`, `/info`, `/process` (FastAPI 버전) 정상 동작
|
||||
- [ ] BudgetTracker가 LLM call/triple count를 정확히 기록
|
||||
- [ ] LangGraph 워크플로우(CONVERT→CHUNK→...→SERIALIZE) 전 노드가 traceable
|
||||
|
||||
## 작업 시 준수사항
|
||||
|
||||
1. **PR 단위**: 위의 0.1~0.7 각각을 별도 PR/커밋으로 분리. 하나의 PR에 여러 단계를 섞지 않는다.
|
||||
2. **PR 설명에 근거 인용**: 예) "통합설계서 §5 Phase 0 (3번)에 따라 다중 파일 처리 확장. OntoCast 분석 §13.1 인용."
|
||||
3. **vendored/ 수정 시 라이선스 의무**:
|
||||
- 수정한 파일 상단에 `# MODIFIED YYYY-MM-DD: <한 줄 설명>` 주석 추가
|
||||
- 원본 LICENSE/NOTICE 파일은 절대 삭제하지 않는다
|
||||
4. **Phase 1로 넘어가지 말 것**: Acceptance Gate 0 통과 전까지 Trafilatura/Crawl4AI/Guardrails/Neo4j GraphRAG 의존성을 활성화하거나 import하지 않는다. (`pyproject.toml`에 명시되어 있더라도 코드에서 사용 금지)
|
||||
|
||||
## Phase 1 이후 핸드오프
|
||||
|
||||
Phase 0 완료 후, 본 폴더에 `PHASE1_NEXT_STEPS.md`를 작성하여 다음 작업자에게 동일한 형식으로 핸드오프한다. 통합설계서 §12 Phase 1 작업 단위(1.1~1.7)를 참조.
|
||||
@@ -1,33 +0,0 @@
|
||||
# Phase 1 Acceptance Gate 결과
|
||||
|
||||
작성일: 2026-05-19
|
||||
|
||||
범위: Trafilatura 기반 URL/HTML 입력 정렬, SourceDocument/EvidenceSpan 계약, URL 입력 API, fixture 기반 dedup 검증.
|
||||
|
||||
## 결과 요약
|
||||
|
||||
| # | Acceptance Gate 항목 | 상태 | 검증 방법 |
|
||||
|---|---|---|---|
|
||||
| 1 | URL/HTML 입력이 정제 문서로 변환됨 | 통과 | `tests/unit/test_web_extractor.py` |
|
||||
| 2 | source URL, title, language, content hash, fingerprint 보존 | 통과 | `test_extract_from_korean_html_preserves_document_contract` |
|
||||
| 3 | `SourceDocument`, `EvidenceSpan`, Content metadata 경계 연결 | 통과 | `test_extracted_content_maps_to_source_document_and_evidence_spans`, `test_content_unit.py` |
|
||||
| 4 | `/process/url`, `/api/v1/extract/url` URL 입력 API 제공 | 통과 | `tests/integration/test_url_ingest.py` |
|
||||
| 5 | 같은 본문 중복 입력은 fingerprint 기반으로 skip | 통과 | `test_same_clean_body_gets_same_hash_and_fingerprint`, `test_process_url_skips_duplicate_payload_by_fingerprint` |
|
||||
| 6 | Phase 0 회귀 없음 | 통과 | `python -m pytest tests/unit tests/integration -q` |
|
||||
|
||||
## 검증 이력
|
||||
|
||||
| 일자 | 검증자 | 결과 |
|
||||
|---|---|---|
|
||||
| 2026-05-19 | Codex | Phase 1 신규 테스트 7/7 통과. 전체 unit/integration 34/34 통과. |
|
||||
|
||||
## 구현 메모
|
||||
|
||||
- `ont_platform/core/extractors/web_extractor.py`는 Trafilatura 2.x `bare_extraction`을 사용하되, local HTML fixture에서 Trafilatura fingerprint가 비어 있는 경우 normalized text 기반 `sha1:` fingerprint를 생성한다.
|
||||
- `ont_platform/storage/models.py`의 SQLAlchemy 예약어 충돌을 피하기 위해 DB 컬럼명은 `metadata`로 유지하고 Python attribute는 `metadata_`로 정리했다.
|
||||
- `/process/url`, `/api/v1/process/url`, `/api/v1/extract/url`은 같은 Phase 1 응답 계약을 사용한다.
|
||||
- OntoCast vendored core는 수정하지 않았다.
|
||||
|
||||
## 다음 Gate
|
||||
|
||||
Phase 2는 Candidate Storage 및 Review 책임 경계를 다룬다. 진행 전 `PHASE_INDEX.md`에서 Phase 2 항목만 명시적으로 선택해 작업한다.
|
||||
@@ -1,162 +0,0 @@
|
||||
# Phase 1 — Trafilatura 통합 (다음 작업자 핸드오프)
|
||||
|
||||
본 문서는 Phase 0이 완료된 시점에서 Phase 1 작업을 이어받는 AI 에이전트 또는 개발자가 즉시 작업을 시작하기 위한 핸드오프 노트다.
|
||||
|
||||
## 시작 전 확인 사항
|
||||
|
||||
- [ ] **Phase 0 Acceptance Gate**가 모두 ✅인가? [PHASE0_ACCEPTANCE_GATE.md](PHASE0_ACCEPTANCE_GATE.md) 참조. 통과 전에는 Phase 1 진행 금지.
|
||||
- [ ] `tests/unit`와 `tests/integration` 전체가 PASS인가?
|
||||
- [ ] git log에 Phase 0 commit들이 PR 단위로 분리되어 있는가? (0.1 vendored / 0.2 bug fix / 0.3 multi-file / 0.4 FastAPI / 0.5 config / 0.6 e2e tests / 0.7 gate)
|
||||
|
||||
## Phase 1 목표
|
||||
|
||||
URL이 입력일 때 원본 페이지에서 본문, 제목, 저자, 날짜, 언어, canonical URL을 정확히 뽑아 OntoCast의 `ContentUnit` metadata에 채워 넣는다.
|
||||
|
||||
**근거**: 통합설계서 §5 Phase 1, Trafilatura 분석 §11~§17.
|
||||
|
||||
**왜 Trafilatura를 가장 먼저 통합하는가**: 가장 작은 통합 — 단일 함수 호출(`bare_extraction`)만으로 끝남. 의존성도 명확하며 라이선스 동일 (Apache 2.0).
|
||||
|
||||
## 작업 단위 (PR 분해)
|
||||
|
||||
### 1.1: Trafilatura 의존성 활성화
|
||||
|
||||
`pyproject.toml`에 이미 `trafilatura[all]>=2.0.0`이 명시되어 있다. 활성화 절차:
|
||||
|
||||
```powershell
|
||||
pip install -e ".[dev]" # 의존성 재설치 시 trafilatura 자동 설치
|
||||
python -c "import trafilatura; print(trafilatura.__version__)"
|
||||
```
|
||||
|
||||
**확인**: `2.0.0` 이상이 출력되어야 한다.
|
||||
|
||||
### 1.2: `web_extractor.py` 어댑터 작성
|
||||
|
||||
**위치**: `platform/core/extractors/web_extractor.py`
|
||||
|
||||
**근거**: Trafilatura 분석 §17의 `extract_for_ontology` 함수를 거의 그대로 사용.
|
||||
|
||||
**필수 동작**:
|
||||
- 입력: `html: str`, `url: str`, `lang: str | None = None`
|
||||
- 출력: `ExtractedWebDocument` (dataclass)
|
||||
- `url`, `title`, `author`, `date`, `sitename`, `description`
|
||||
- `text` (정제 본문)
|
||||
- `body_xml` (Trafilatura `Document.body`)
|
||||
- `metadata` (raw dict)
|
||||
- `fingerprint` (SimHash)
|
||||
- 실패 시 `None` 반환
|
||||
|
||||
**호출 옵션** (Trafilatura 분석 §12 권장값 그대로):
|
||||
```python
|
||||
Extractor(
|
||||
output_format="python",
|
||||
url=url,
|
||||
with_metadata=True,
|
||||
comments=False,
|
||||
tables=True,
|
||||
formatting=True,
|
||||
links=True,
|
||||
images=True,
|
||||
dedup=True,
|
||||
lang=lang,
|
||||
)
|
||||
```
|
||||
|
||||
### 1.3: `ContentUnit` 모델 확장
|
||||
|
||||
OntoCast의 `vendored/ontocast/ontocast/onto/content_unit.py`를 **직접 수정하지 말고**, 우리 쪽에 wrapper 모델을 만든다.
|
||||
|
||||
**위치**: `platform/models/content_unit.py`
|
||||
|
||||
**필드** (통합설계서 §7.1 참조):
|
||||
- 기존 OntoCast 필드 (`text`, `index`, `doc_iri`, `graph`, `type`, `iri`) 유지/위임
|
||||
- 추가: `source_url`, `title`, `author`, `publish_date`, `language`, `sitename`, `fingerprint`, `content_hash`, `metadata`, `retrieved_at`, `extracted_by`
|
||||
|
||||
**호환성**: 기존 OntoCast 코드가 받는 `ContentUnit`과 인터페이스 호환되도록 `as_ontocast()` 메서드 제공.
|
||||
|
||||
### 1.4: OntoCast `ConverterTool` 분기 추가 (URL/HTML 입력)
|
||||
|
||||
**문제**: OntoCast `ConverterTool`은 PDF/DOCX/MD만 처리. URL 또는 HTML 입력은 처리 못 함.
|
||||
|
||||
**조치 옵션**:
|
||||
- **옵션 A (권장)**: OntoCast의 `convert_document.py` 모듈에 새 분기 추가 — `.html`, `.htm` 확장자 또는 `state.source_url`이 있으면 Trafilatura로 처리. **vendored 수정이지만 매우 작음**.
|
||||
- **옵션 B**: API 레이어(`platform/api/`)에서 입력이 URL이면 미리 fetch + Trafilatura 처리한 뒤 그 결과를 JSON envelope로 ToolBox에 넘김.
|
||||
|
||||
**권장**: 옵션 B. vendored 수정을 늘리지 않고 platform 코드로 끝낼 수 있음.
|
||||
|
||||
새 endpoint:
|
||||
- `POST /process/url` — body: `{"url": "...", "ontology_user_instruction": "...", ...}` — 내부적으로 `web_extractor`로 본문 추출 후 OntoCast workflow 실행.
|
||||
|
||||
### 1.5: Fingerprint 기반 dedup
|
||||
|
||||
- `tests/fixtures/`에 같은 본문의 두 URL fixture 만들기
|
||||
- `web_extractor` 결과의 `fingerprint`가 일치하면 OntoCast 처리 skip
|
||||
- 저장 위치: 일단 in-memory set (`platform/storage/dedup_cache.py`), Phase 2에서 Redis로 이전
|
||||
|
||||
### 1.6: 한국어 페이지 3종 추출 검증
|
||||
|
||||
**테스트 fixture 수집**:
|
||||
- 한국어 뉴스 1개 (예: 연합뉴스/조선/한겨레)
|
||||
- 한국어 블로그 1개 (예: 네이버 블로그)
|
||||
- 한국어 쇼핑 페이지 1개 (예: 쿠팡 상품 페이지)
|
||||
|
||||
각각 raw HTML을 `tests/fixtures/korean/`에 저장 (실제 fetch는 운영 환경에서 한 번만, 그 결과를 fixture로 박제).
|
||||
|
||||
**테스트**: `tests/integration/test_web_extractor_korean.py`
|
||||
- 본문 길이 > 200자
|
||||
- title 추출 성공
|
||||
- language 감지: `ko`
|
||||
- author 또는 date 중 하나 이상 추출
|
||||
|
||||
### 1.7: Acceptance Gate 1 체크
|
||||
|
||||
통합설계서 §5 Phase 1 Acceptance Gate 4개 항목:
|
||||
|
||||
- [ ] URL 입력 → 본문/메타데이터가 정확히 추출되어 `ContentUnit`에 저장됨
|
||||
- [ ] 한국어 뉴스/블로그/쇼핑 페이지 각각 1개씩 본문 추출 정확도 수동 검증
|
||||
- [ ] 동일 URL 재입력 시 fingerprint 기반 dedup으로 skip
|
||||
- [ ] Phase 0의 모든 기능이 여전히 정상 동작 (회귀 없음)
|
||||
|
||||
Phase 0의 `tests/unit/`, `tests/integration/` 전체가 여전히 PASS여야 함.
|
||||
|
||||
## Phase 1에서 만들 새 산출물
|
||||
|
||||
```
|
||||
platform/
|
||||
core/
|
||||
extractors/
|
||||
web_extractor.py ← 1.2
|
||||
models/
|
||||
content_unit.py ← 1.3
|
||||
storage/
|
||||
dedup_cache.py ← 1.5
|
||||
api/
|
||||
routes/
|
||||
url_ingest.py ← 1.4 (POST /process/url)
|
||||
tests/
|
||||
fixtures/
|
||||
korean/ ← 1.6
|
||||
news_yonhap.html
|
||||
blog_naver.html
|
||||
shop_coupang.html
|
||||
unit/
|
||||
test_web_extractor.py ← 1.2
|
||||
test_dedup_cache.py ← 1.5
|
||||
integration/
|
||||
test_url_ingest.py ← 1.4
|
||||
test_web_extractor_korean.py ← 1.6
|
||||
docs/
|
||||
phases/
|
||||
PHASE1_ACCEPTANCE_GATE.md ← 1.7 (PHASE0과 동일 형식)
|
||||
PHASE2_NEXT_STEPS.md ← 다음 작업자에게 넘김
|
||||
```
|
||||
|
||||
## 작업 시 준수사항 (PHASE0과 동일)
|
||||
|
||||
1. **PR 단위 분리**: 1.1~1.7 각각 별도 PR/커밋.
|
||||
2. **PR 설명에 근거 인용**: 예) "통합설계서 §5 Phase 1 (1.2)에 따라 Trafilatura adapter 작성. Trafilatura 분석 §17 인용."
|
||||
3. **vendored/ontocast/** 수정 최소화. 본 Phase에서는 옵션 B 사용 시 vendored 수정 0건이 목표.
|
||||
4. **Phase 2로 넘어가지 말 것**: Acceptance Gate 1 통과 전까지 Crawl4AI 의존성을 코드에서 import하지 않는다.
|
||||
|
||||
## Phase 2 이후 핸드오프
|
||||
|
||||
Phase 1 완료 후 다음 작업자에게 동일한 형식의 `PHASE2_NEXT_STEPS.md`를 작성한다. 통합설계서 §12 Phase 2 작업 단위(2.1~2.8)를 참조.
|
||||
@@ -1,35 +0,0 @@
|
||||
# Phase 2 Acceptance Gate 결과
|
||||
|
||||
작성일: 2026-05-19
|
||||
|
||||
범위: Candidate Storage 및 Review 책임 경계. Lightweight/OntoCast 후보 저장 경로, review 상태 전이, audit trail, evidence 기반 promotion gate.
|
||||
|
||||
## 결과 요약
|
||||
|
||||
| # | Acceptance Gate 항목 | 상태 | 검증 방법 |
|
||||
|---|---|---|---|
|
||||
| 1 | extraction 결과가 candidate로 저장됨 | 통과 | `tests/unit/test_candidate_repository.py` |
|
||||
| 2 | lightweight와 OntoCast 저장 경로가 분리됨 | 통과 | `test_repository_saves_lightweight_candidates_with_evidence`, `test_repository_saves_ontocast_candidates_on_separate_source_path` |
|
||||
| 3 | 승인/반려/자동승인 상태 변경 이력이 남음 | 통과 | `tests/unit/test_review_service.py` |
|
||||
| 4 | evidence 없는 항목은 승인 및 graph commit 대상이 아님 | 통과 | `test_candidate_without_evidence_cannot_be_approved`, `test_promotion_plan_blocks_approved_candidate_without_evidence` |
|
||||
| 5 | Review API가 ingest/list/detail/approve/reject/promote 흐름을 제공함 | 통과 | `tests/integration/test_review_api.py` |
|
||||
| 6 | Phase 0-1 회귀 없음 | 통과 | `python -m pytest tests/unit tests/integration -q` |
|
||||
|
||||
## 검증 이력
|
||||
|
||||
| 일자 | 검증자 | 결과 |
|
||||
|---|---|---|
|
||||
| 2026-05-19 | Codex | Phase 2 신규 테스트 9/9 통과. 전체 unit/integration 43/43 통과. 변경 파일 대상 ruff 통과. |
|
||||
|
||||
## 구현 메모
|
||||
|
||||
- `CandidateEntity`, `CandidateRelation`에 `source_type`, `created_by`, `validation_passed`, `promoted_at`을 추가해 review queue 계약을 명확히 했다.
|
||||
- `ReviewDecision`으로 상태 변경 audit trail을 남긴다.
|
||||
- `CandidateRepository.save_lightweight_result()`와 `save_ontocast_result()`를 분리해 두 입력 경로가 같은 candidate contract로 정규화되되, 출처는 유지된다.
|
||||
- `ReviewService`는 `pending -> approved/rejected/auto_approved`, `approved/auto_approved -> rejected`만 허용한다.
|
||||
- `CandidatePromotionService`는 `approved` 또는 `auto_approved`이면서 evidence가 실제 존재하는 후보만 commit plan에 포함한다.
|
||||
- OntoCast vendored core는 수정하지 않았다.
|
||||
|
||||
## 다음 Gate
|
||||
|
||||
Phase 3은 Crawl4AI 수집 계층 및 Job Orchestration이다. 진행 전 `PHASE_INDEX.md`에서 Phase 3 항목만 명시적으로 선택해 작업한다.
|
||||
@@ -1,27 +0,0 @@
|
||||
# Phase 2 — Candidate Storage 및 Review 책임 경계
|
||||
|
||||
본 문서는 Phase 1 완료 후 다음 작업자가 Phase 2를 시작할 때 참고할 핸드오프 노트다. 자동으로 Phase 2를 진행하지 않는다.
|
||||
|
||||
## 시작 전 확인
|
||||
|
||||
- `PHASE_INDEX.md`에서 Phase 2 진행 요청이 명시되어 있는지 확인한다.
|
||||
- `PHASE1_ACCEPTANCE_GATE.md`의 unit/integration 34/34 통과 상태를 기준선으로 삼는다.
|
||||
- vendored OntoCast core는 계속 직접 수정하지 않는다.
|
||||
|
||||
## Phase 2 목표
|
||||
|
||||
추출 결과를 바로 확정 그래프로 보내지 않고, 사람이 검토할 수 있는 candidate/review queue 계약으로 분리한다. SourceDocument와 EvidenceSpan이 없는 후보는 확정 graph로 들어가지 못하게 한다.
|
||||
|
||||
## 작업 범위
|
||||
|
||||
1. `storage/models.py`의 `CandidateEntity`, `CandidateRelation`을 review queue 계약으로 확정한다.
|
||||
2. OntoCast 결과와 lightweight extraction 결과의 저장 경로를 분리한다.
|
||||
3. `pending`, `approved`, `auto_approved`, `rejected` 상태 전이 규칙을 문서와 테스트로 고정한다.
|
||||
4. evidence 없는 후보가 확정 graph로 승격되지 못하도록 validation boundary를 둔다.
|
||||
|
||||
## 권장 테스트
|
||||
|
||||
- 후보 생성 시 `document_id`와 `evidence_ids`가 필수로 연결되는지 검증한다.
|
||||
- 승인/반려/자동승인 상태 전이가 허용된 경로로만 움직이는지 검증한다.
|
||||
- evidence 없는 entity/relation이 commit 단계에 도달하지 못하는지 검증한다.
|
||||
- Phase 1 URL/HTML ingestion 테스트가 계속 통과하는지 회귀 검증한다.
|
||||
@@ -1,28 +0,0 @@
|
||||
# Phase 3 — Crawl4AI 수집 계층 및 Job Orchestration
|
||||
|
||||
본 문서는 Phase 2 완료 후 다음 작업자가 Phase 3을 시작할 때 참고할 핸드오프 노트다. 자동으로 Phase 3을 진행하지 않는다.
|
||||
|
||||
## 시작 전 확인
|
||||
|
||||
- `PHASE_INDEX.md`에서 Phase 3 진행 요청이 명시되어 있는지 확인한다.
|
||||
- `PHASE2_ACCEPTANCE_GATE.md`의 unit/integration 43/43 통과 상태를 기준선으로 삼는다.
|
||||
- 수집 계층은 SourceDocument 생성 전 단계까지만 책임진다. Candidate 저장과 Review Queue는 Phase 2 계약을 사용한다.
|
||||
- vendored OntoCast core는 계속 직접 수정하지 않는다.
|
||||
|
||||
## Phase 3 목표
|
||||
|
||||
정적 URL 1건 처리를 넘어 동적 페이지와 대량 수집을 job 단위로 관리한다. Crawl4AI는 acquisition adapter로 감싸고, 본문 정제는 Phase 1 Trafilatura adapter, 후보 저장은 Phase 2 Review Queue로 넘긴다.
|
||||
|
||||
## 작업 범위
|
||||
|
||||
1. `crawl4ai_adapter.py`를 동적/대량 수집 adapter로 제한한다.
|
||||
2. crawler profile, robots policy, cache policy를 설정 기반으로 분리한다.
|
||||
3. Job 상태 모델과 progress API/WebSocket 경계를 정리한다.
|
||||
4. 수집 결과를 Trafilatura 후처리와 SourceDocument 저장으로 연결한다.
|
||||
|
||||
## 권장 테스트
|
||||
|
||||
- 정적 HTML/동적 페이지 profile이 같은 SourceDocument 계약으로 이어지는지 검증한다.
|
||||
- robots/cache policy가 설정값에 따라 선택되는지 검증한다.
|
||||
- job 상태가 pending/running/completed/failed로 전이되는지 검증한다.
|
||||
- Phase 1 extraction 및 Phase 2 review queue 테스트가 계속 통과하는지 회귀 검증한다.
|
||||
@@ -1,100 +1,101 @@
|
||||
# PHASE INDEX - ontology_platform engine-respect roadmap
|
||||
# PHASE INDEX - Semantic Page Classification Layer
|
||||
|
||||
?묒꽦?? 2026-05-19
|
||||
작성일: 2026-05-22
|
||||
|
||||
踰붿쐞: `ontology_platform` ?꾩슜. `crawler_platform`? ?대쾲 ?묒뾽 踰붿쐞?먯꽌 ?쒖쇅?쒕떎.
|
||||
범위: `ontology_platform`의 `crawler_platform.app.core.crawler.page_classifier` 및 page classification과 직접 연결된 crawler/extractor 흐름.
|
||||
|
||||
湲곗? 臾몄꽌:
|
||||
- `ontology_platform/docs/?듯빀?ㅺ퀎??md`
|
||||
기준 문서:
|
||||
- `README.md`
|
||||
- `docs/PHASE_PLANNING.md`
|
||||
- `ontology_platform/README.md`
|
||||
- `ontology_platform/docs/phases/PHASE0_ACCEPTANCE_GATE.md`
|
||||
- `ontology_platform/docs/phases/PHASE1_NEXT_STEPS.md`
|
||||
- `ontology_platform/docs/phases/PHASE1_ACCEPTANCE_GATE.md`
|
||||
- `ontology_platform/docs/phases/PHASE2_ACCEPTANCE_GATE.md`
|
||||
- `ontology_platform/docs/semantic_page_classification_codex_spec.md`
|
||||
|
||||
?듭떖 ?먯튃:
|
||||
- OntoCast??Base ?붿쭊?쇰줈 議댁쨷?쒕떎.
|
||||
- vendored OntoCast 肄붿뼱???듯빀?ㅺ퀎?쒓? ?덉슜??踰붿쐞 ?몄뿉???섏젙?섏? ?딅뒗??
|
||||
- Trafilatura, Crawl4AI, Guardrails, Neo4j GraphRAG??吏곸젒 ?ш뎄?꾪븯吏 ?딄퀬 ?뉗? adapter/facade濡?媛먯떬??
|
||||
- Firecrawl, OpenDeepResearcher 肄붾뱶???ы븿?섏? ?딅뒗??
|
||||
- Acceptance Gate瑜??듦낵?섍린 ???ㅼ쓬 ?듯빀?쇰줈 ?섏뼱媛吏 ?딅뒗??
|
||||
핵심 원칙:
|
||||
- 기존 엔진을 폐기하거나 대규모로 교체하지 않는다.
|
||||
- 기존 `classify_page(...) -> str` 호출부가 깨지지 않도록 legacy compatibility를 유지한다.
|
||||
- 기존 `ProductPage`, `CategoryPage`, `SearchPage`, `BoardPage`, `BrandStoryPage`, `UnknownPage` 문자열은 alias 또는 compatibility mapping으로 유지한다.
|
||||
- URL substring 중심 if-return 확장이 아니라 signal extraction -> evidence scoring -> classification result -> analyze strategy -> LLM policy 구조로 확장한다.
|
||||
- protected page는 안전하게 skip하고, UnknownPage는 evidence와 confidence를 남긴다.
|
||||
- pytest 또는 현재 프로젝트 테스트 명령으로 회귀 검증한다.
|
||||
|
||||
---
|
||||
|
||||
PHASE 0. ?붿쭊 寃쎄퀎 媛먯궗 諛?Phase Gate 蹂듦뎄
|
||||
FILE: ./26_05_19_engine_respect_plan/phase_00_001_engine_boundary_gate.md
|
||||
PHASE 1. 현재 흐름 기준선 고정 및 영향 범위 정리
|
||||
FILE: ./26_05_22_semantic_page_classification/phase_01_001_current_flow_boundary.md
|
||||
|
||||
1) ?꾩옱 `ont_platform` 紐⑤뱢??Base/Adapter/Draft/Excluded 梨낆엫?쇰줈 遺꾨쪟 [?꾨즺]
|
||||
2) Phase 0?먯꽌 誘몃옒 Phase ?섏〈?깆씠 import?섏뼱 ???쒖옉??源⑥? ?딅룄濡?寃뚯씠???뺣━ [?꾨즺]
|
||||
3) Phase 0 unit/integration 寃利??덉감 怨좎젙 [?꾨즺]
|
||||
4) `PHASE0_ACCEPTANCE_GATE.md` 媛깆떊 湲곗? ?뺣━ [?꾨즺]
|
||||
1) `page_classifier.py`의 현재 public API와 legacy page_type 문자열 목록 고정 [완료]
|
||||
2) `should_analyze_page()` 호출부와 crawler의 `classify_page()` 호출 위치 문서화 [완료]
|
||||
3) Extractor/HybridExtractor에서 page_type과 LLM skip 정책이 연결되는 흐름 정리 [완료]
|
||||
4) 기존 page_type 문자열을 기대하는 테스트, config, adapter, ontology rule 경로 목록화 [완료]
|
||||
|
||||
---
|
||||
|
||||
PHASE 1. Trafilatura 湲곕컲 URL/HTML ?낅젰 ?뺣젹
|
||||
FILE: ./26_05_19_engine_respect_plan/phase_01_001_trafilatura_ingestion.md
|
||||
PHASE 2. Taxonomy와 Classification Result 모델 추가
|
||||
FILE: ./26_05_22_semantic_page_classification/phase_02_001_taxonomy_result_model.md
|
||||
|
||||
1) `web_extractor.py`瑜?Trafilatura adapter 梨낆엫?쇰줈 ?뺣━ [?꾨즺]
|
||||
2) `SourceDocument`, `EvidenceSpan`, Content metadata ???寃쎄퀎 ?곌껐 [?꾨즺]
|
||||
3) `/process/url` ?먮뒗 ?숇벑??URL ?낅젰 API ?ㅺ퀎 [?꾨즺]
|
||||
4) ?쒓뎅??URL/HTML fixture 湲곕컲 異붿텧 ?뚯뒪?몄? dedup 湲곗? ?묒꽦 [?꾨즺]
|
||||
1) PageDomain/PageArchetype/PageType/EntityType/ActionIntent/GraphRole/AnalyzeStrategy/LLMPolicy 상수 또는 enum 추가 [완료]
|
||||
2) `EvidenceItem`, `PageClassificationResult` dataclass 추가 [완료]
|
||||
3) legacy alias 및 normalize helper 추가 [완료]
|
||||
4) 기존 `classify_page()` 문자열 반환 호환을 유지하면서 semantic result API 추가 [완료]
|
||||
|
||||
---
|
||||
|
||||
PHASE 2. Candidate Storage 諛?Review 梨낆엫 寃쎄퀎
|
||||
FILE: ./26_05_19_engine_respect_plan/phase_02_001_candidate_review_boundary.md
|
||||
PHASE 3. Raw Snapshot 및 Signal Extraction 레이어 추가
|
||||
FILE: ./26_05_22_semantic_page_classification/phase_03_001_signal_extraction_layer.md
|
||||
|
||||
1) `storage/models.py`???꾨낫 紐⑤뜽???뺤떇 Review Queue 怨꾩빟?쇰줈 ?뺤젙 [?꾨즺]
|
||||
2) OntoCast 寃곌낵? lightweight extraction 寃곌낵?????寃쎈줈 遺꾨━ [?꾨즺]
|
||||
3) ?뱀씤/諛섎젮/?먮룞?뱀씤 ?곹깭 ?꾩씠 洹쒖튃 ?뺤쓽 [?꾨즺]
|
||||
4) evidence ?녿뒗 ?꾨낫媛 ?뺤젙 graph濡??ㅼ뼱媛吏 紐삵븯寃?李⑤떒 [?꾨즺]
|
||||
1) `RawPageSnapshot`와 `PageSignals` 모델 추가 [완료]
|
||||
2) JSON-LD, OpenGraph, Twitter Card, meta, headings, links, forms, buttons, inputs 추출 [완료]
|
||||
3) commerce/listing/editorial/community/docs/corporate/protected/system signal 추출 [완료]
|
||||
4) HTML 일부가 깨지거나 필드가 누락되어도 예외 없이 빈 값으로 처리 [완료]
|
||||
|
||||
---
|
||||
|
||||
PHASE 3. Crawl4AI ?섏쭛 怨꾩링 諛?Job Orchestration
|
||||
FILE: ./26_05_19_engine_respect_plan/phase_03_001_crawl4ai_acquisition_jobs.md
|
||||
PHASE 4. Evidence Scoring 기반 Semantic Classification 구현
|
||||
FILE: ./26_05_22_semantic_page_classification/phase_04_001_evidence_scoring_classifier.md
|
||||
|
||||
1) `crawl4ai_adapter.py`瑜??숈쟻/????섏쭛 adapter濡??쒗븳 [?꾨즺]
|
||||
2) crawler profile, robots policy, cache policy瑜??ㅼ젙 湲곕컲?쇰줈 遺꾨━ [?꾨즺]
|
||||
3) Job ?곹깭 紐⑤뜽怨?progress API/WebSocket 寃쎄퀎 ?뺣━ [?꾨즺]
|
||||
4) Trafilatura ?꾩쿂由ъ? SourceDocument ??μ쑝濡??곌껐 [?꾨즺]
|
||||
1) 주요 page type별 scoring function과 evidence recording 구조 추가 [완료]
|
||||
2) 최소 20개 semantic page type 분류 구현 [완료]
|
||||
3) confidence, alternatives, secondary_page_types 산출 [완료]
|
||||
4) low confidence 또는 모호한 결과를 evidence 포함 UnknownPage로 처리 [완료]
|
||||
|
||||
---
|
||||
|
||||
PHASE 4. Guardrails Validation Gate
|
||||
FILE: ./26_05_19_engine_respect_plan/phase_04_001_guardrails_validation_gate.md
|
||||
PHASE 5. Analyze Strategy 및 LLM Policy 분리
|
||||
FILE: ./26_05_22_semantic_page_classification/phase_05_001_analysis_llm_policy.md
|
||||
|
||||
1) `core/validation`??Pydantic lightweight? Guardrails facade濡?遺꾨━ [?꾨즺]
|
||||
2) OntoCast LLM 異쒕젰 ?섑븨 吏?먯쓣 vendored ?섏젙 ?놁씠 ?곗꽑 ?ㅺ퀎 [?꾨즺]
|
||||
3) schema violation, endpoint missing, confidence range ?뚯뒪???묒꽦 [?꾨즺]
|
||||
4) Guard ?ㅽ뙣 寃곌낵瑜?candidate/review issue濡????[?꾨즺]
|
||||
1) PageClassificationResult 기반 `decide_analyze_strategy()` 추가 [TODO]
|
||||
2) PageClassificationResult 기반 `decide_llm_policy()` 추가 [TODO]
|
||||
3) `should_analyze_page(result_or_page_type, analyze_page_types=None)` compatibility 구현 [TODO]
|
||||
4) Category/Search/Board 계열을 무조건 skip하지 않고 strategy 기반으로 처리 [TODO]
|
||||
5) Login/Checkout/Payment/Captcha/AccessDenied 계열은 SkipProtected/Skip 정책으로 처리 [TODO]
|
||||
|
||||
---
|
||||
|
||||
PHASE 5. Neo4j Projection 諛?GraphRAG 寃??FILE: ./26_05_19_engine_respect_plan/phase_05_001_neo4j_projection_graphrag.md
|
||||
PHASE 6. Crawler, Cleaner, Extractor, Discovery/Relevance 통합
|
||||
FILE: ./26_05_22_semantic_page_classification/phase_06_001_pipeline_integration.md
|
||||
|
||||
1) RDF/Fuseki瑜?canonical store, Neo4j瑜?projection/search store濡?怨좎젙 [?꾨즺]
|
||||
2) `core/graph` 湲곗〈 紐⑤뱢??projection/search adapter 梨낆엫?쇰줈 ?щ텇瑜?[?꾨즺]
|
||||
3) read-only Text2Cypher? vector/hybrid retriever API ?ㅺ퀎 [?꾨즺]
|
||||
4) provenance媛 search result源뚯? ?댁뼱吏??寃利?湲곗? ?묒꽦 [?꾨즺]
|
||||
1) `site_crawler.py`와 `pipeline.py` metadata에 semantic classification payload 저장 [TODO]
|
||||
2) `ExtractionPageContext` 또는 metadata를 통해 analyze_strategy/llm_policy 전달 [TODO]
|
||||
3) `HybridExtractor`가 LLMPolicy를 우선 사용하고 legacy page_type fallback을 유지하도록 수정 [TODO]
|
||||
4) `page_cleaner.py`, `domain_discovery.py`, `relevance_engine.py`의 legacy page_type 기대 경로와 신규 semantic type을 호환 [TODO]
|
||||
|
||||
---
|
||||
|
||||
PHASE 6. Maintenance Loop 諛??댁쁺 湲곕뒫 ?뺣━
|
||||
FILE: ./26_05_19_engine_respect_plan/phase_06_001_maintenance_loop_operations.md
|
||||
PHASE 7. Unknown Pattern 저장 기반 추가
|
||||
FILE: ./26_05_22_semantic_page_classification/phase_07_001_unknown_pattern_storage.md
|
||||
|
||||
1) Knowledge Agent??肄붾뱶媛 ?꾨땲???꾨\?꾪듃/?뚰겕?뚮줈???⑦꽩留?李⑥슜 [?꾨즺]
|
||||
2) Analyst/Researcher/Curator/Auditor/Fixer/Advisor 梨낆엫 ?뺤쓽 [?꾨즺]
|
||||
3) `auth`, `audit`, `billing`, `realtime` 珥덉븞 紐⑤뱢???댁쁺 寃쎄퀎 ?뺣━ [?꾨즺]
|
||||
4) destructive fix???щ엺 ?뱀씤 寃뚯씠?몃? 諛섎뱶???듦낵?섎룄濡??ㅺ퀎 [?꾨즺]
|
||||
1) UnknownPage 또는 low confidence 페이지의 evidence payload 정의 [TODO]
|
||||
2) text/html/link/schema/button/form summary와 fingerprint hook 추가 [TODO]
|
||||
3) DB schema 변경 없이 metadata_json에 저장 가능한 초기 구조 구현 [TODO]
|
||||
4) 향후 clustering/embedding 확장을 위한 hook만 추가하고 실제 clustering은 이번 범위에서 제외 [TODO]
|
||||
|
||||
---
|
||||
|
||||
PHASE 7. Hybrid Rule + LLM Extraction
|
||||
FILE: ./26_05_19_engine_respect_plan/phase_07_001_hybrid_rule_llm_extraction.md
|
||||
PHASE 8. 테스트 Fixture 및 회귀 검증
|
||||
FILE: ./26_05_22_semantic_page_classification/phase_08_001_tests_regression.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 선택 지원 [신규]
|
||||
1) 최소 10개 이상의 HTML fixture 추가 [TODO]
|
||||
2) ProductDetailPage, CategoryListingPage, SearchResultsPage, ArticlePage, QAPage, FAQPage, ForumThreadPage, DocumentationPage, JobPostingPage, LoginPage, CheckoutPage, TermsPage, SitemapPage, UnknownPage 단위 테스트 추가 [TODO]
|
||||
3) legacy `classify_page()`와 `should_analyze_page()` 호환성 테스트 추가 [TODO]
|
||||
4) HybridExtractor LLMPolicy 회귀 테스트 추가 [TODO]
|
||||
5) pytest 또는 현재 프로젝트 테스트 명령 실행 및 결과 기록 [TODO]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from crawler_platform.app.config.loader import ProjectConfig
|
||||
from crawler_platform.app.api.routes import extraction_log_summary
|
||||
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.hybrid import HybridExtractor, fallback_bundle, mark_bundle, merge_bundles
|
||||
from crawler_platform.app.core.extractor.validation import validate_extraction_bundle
|
||||
|
||||
|
||||
@@ -142,3 +143,61 @@ def test_hybrid_smart_routing_skips_llm_for_category_page() -> None:
|
||||
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"]
|
||||
|
||||
|
||||
def test_rule_only_mode_records_rule_llm_summary_counts() -> None:
|
||||
rule_bundle = ExtractionBundle(
|
||||
claims=[
|
||||
ExtractedClaim("A", "Thing", "color", object_value="red"),
|
||||
ExtractedClaim("A", "Thing", "size", object_value="large"),
|
||||
]
|
||||
)
|
||||
|
||||
class FixedRuleExtractor:
|
||||
def extract(self, page_text, project_config): # noqa: ANN001
|
||||
return rule_bundle
|
||||
|
||||
def extract_from_context(self, context, project_config): # noqa: ANN001
|
||||
return rule_bundle
|
||||
|
||||
extractor = HybridExtractor("generic", mode="rule_only")
|
||||
extractor.rule_extractor = FixedRuleExtractor()
|
||||
|
||||
bundle = extractor.extract("Alpha color red size large", project_config())
|
||||
|
||||
assert bundle.raw_output["rule_claim_count"] == 2
|
||||
assert bundle.raw_output["llm_claim_count"] == 0
|
||||
assert bundle.raw_output["comparison"]["rule_only"] == 2
|
||||
|
||||
|
||||
def test_fallback_records_rule_counts_and_comparison() -> None:
|
||||
rule_bundle = ExtractionBundle(
|
||||
claims=[
|
||||
ExtractedClaim("A", "Thing", "color", object_value="red"),
|
||||
ExtractedClaim("A", "Thing", "size", object_value="large"),
|
||||
]
|
||||
)
|
||||
mark_bundle(rule_bundle, source="rule", mode="hybrid")
|
||||
|
||||
bundle = fallback_bundle(rule_bundle, HybridExtractor("generic", mode="hybrid"), RuntimeError("boom"))
|
||||
|
||||
assert bundle.raw_output["rule_claim_count"] == 2
|
||||
assert bundle.raw_output["llm_claim_count"] == 0
|
||||
assert bundle.raw_output["comparison"]["rule_only"] == 2
|
||||
|
||||
|
||||
def test_extraction_log_summary_backfills_legacy_rule_only_counts() -> None:
|
||||
summary = extraction_log_summary(
|
||||
{
|
||||
"validation": {"rejected_claim_count": 4},
|
||||
"candidate_claims": [
|
||||
{"metadata": {"agreement": "rule_only", "extraction_source": "rule"}},
|
||||
{"metadata": {"agreement": "rule_only", "extraction_source": "rule"}},
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
assert summary["rule_claim_count"] == 2
|
||||
assert summary["llm_claim_count"] == 0
|
||||
assert summary["comparison"]["rule_only"] == 2
|
||||
assert summary["comparison"]["rejected_by_validation"] == 4
|
||||
|
||||
Reference in New Issue
Block a user