From 93980da14d815ac1b89cfd8622c9a7bb95e83c61 Mon Sep 17 00:00:00 2001 From: "LASTA_DEV01\\lasta" Date: Fri, 22 May 2026 19:07:07 +0900 Subject: [PATCH] 1 --- .../crawler_platform/app/api/routes.py | 140 +++++++-- .../app/core/crawler/page_classifier.py | 61 ++-- .../app/core/extractor/hybrid.py | 61 +++- ontology_platform/data/crawler_platform.db | Bin 25300992 -> 25300992 bytes ...05_22_PHASE_INDEX_engine_respect_backup.md | 100 +++++++ .../phase_01_001_current_flow_boundary.md | 51 ++++ .../phase_01_current_flow_boundary_report.md | 276 ++++++++++++++++++ .../phase_02_001_taxonomy_result_model.md | 53 ++++ .../phase_03_001_signal_extraction_layer.md | 93 ++++++ ...hase_04_001_evidence_scoring_classifier.md | 99 +++++++ .../phase_05_001_analysis_llm_policy.md | 45 +++ .../phase_06_001_pipeline_integration.md | 45 +++ .../phase_07_001_unknown_pattern_storage.md | 47 +++ .../phase_08_001_tests_regression.md | 59 ++++ .../docs/phases/PHASE0_ACCEPTANCE_GATE.md | 132 --------- .../docs/phases/PHASE0_NEXT_STEPS.md | 124 -------- .../docs/phases/PHASE1_ACCEPTANCE_GATE.md | 33 --- .../docs/phases/PHASE1_NEXT_STEPS.md | 162 ---------- .../docs/phases/PHASE2_ACCEPTANCE_GATE.md | 35 --- .../docs/phases/PHASE2_NEXT_STEPS.md | 27 -- .../docs/phases/PHASE3_NEXT_STEPS.md | 28 -- ontology_platform/docs/phases/PHASE_INDEX.md | 127 ++++---- .../unit/test_phase7_hybrid_extraction.py | 61 +++- 23 files changed, 1211 insertions(+), 648 deletions(-) create mode 100644 ontology_platform/docs/phases/26_05_22_PHASE_INDEX_engine_respect_backup.md create mode 100644 ontology_platform/docs/phases/26_05_22_semantic_page_classification/phase_01_001_current_flow_boundary.md create mode 100644 ontology_platform/docs/phases/26_05_22_semantic_page_classification/phase_01_current_flow_boundary_report.md create mode 100644 ontology_platform/docs/phases/26_05_22_semantic_page_classification/phase_02_001_taxonomy_result_model.md create mode 100644 ontology_platform/docs/phases/26_05_22_semantic_page_classification/phase_03_001_signal_extraction_layer.md create mode 100644 ontology_platform/docs/phases/26_05_22_semantic_page_classification/phase_04_001_evidence_scoring_classifier.md create mode 100644 ontology_platform/docs/phases/26_05_22_semantic_page_classification/phase_05_001_analysis_llm_policy.md create mode 100644 ontology_platform/docs/phases/26_05_22_semantic_page_classification/phase_06_001_pipeline_integration.md create mode 100644 ontology_platform/docs/phases/26_05_22_semantic_page_classification/phase_07_001_unknown_pattern_storage.md create mode 100644 ontology_platform/docs/phases/26_05_22_semantic_page_classification/phase_08_001_tests_regression.md delete mode 100644 ontology_platform/docs/phases/PHASE0_ACCEPTANCE_GATE.md delete mode 100644 ontology_platform/docs/phases/PHASE0_NEXT_STEPS.md delete mode 100644 ontology_platform/docs/phases/PHASE1_ACCEPTANCE_GATE.md delete mode 100644 ontology_platform/docs/phases/PHASE1_NEXT_STEPS.md delete mode 100644 ontology_platform/docs/phases/PHASE2_ACCEPTANCE_GATE.md delete mode 100644 ontology_platform/docs/phases/PHASE2_NEXT_STEPS.md delete mode 100644 ontology_platform/docs/phases/PHASE3_NEXT_STEPS.md diff --git a/ontology_platform/crawler_platform/app/api/routes.py b/ontology_platform/crawler_platform/app/api/routes.py index bfb20d3..6a59e25 100644 --- a/ontology_platform/crawler_platform/app/api/routes.py +++ b/ontology_platform/crawler_platform/app/api/routes.py @@ -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): diff --git a/ontology_platform/crawler_platform/app/core/crawler/page_classifier.py b/ontology_platform/crawler_platform/app/core/crawler/page_classifier.py index 024cb7b..efd759c 100644 --- a/ontology_platform/crawler_platform/app/core/crawler/page_classifier.py +++ b/ontology_platform/crawler_platform/app/core/crawler/page_classifier.py @@ -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 diff --git a/ontology_platform/crawler_platform/app/core/extractor/hybrid.py b/ontology_platform/crawler_platform/app/core/extractor/hybrid.py index f5cde9a..17e03e0 100644 --- a/ontology_platform/crawler_platform/app/core/extractor/hybrid.py +++ b/ontology_platform/crawler_platform/app/core/extractor/hybrid.py @@ -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, diff --git a/ontology_platform/data/crawler_platform.db b/ontology_platform/data/crawler_platform.db index b546ca17106d0924bdc92439e1ac246f079c0d08..5e445ee5825a25cba02ed4614c4819650df8feb4 100644 GIT binary patch delta 175748 zcmeFacbpVe+6G#ua>wchh9NV6gduhg1eqX7R4{@G>8J<-!Vm?T8IVB)1PKKyG#D7O zAVP@&!K|oY&I&px$gqNO*RbBF&gq$HT;2V4_kO?o$NdK0dQPRcPL9uedj6)x_W6(% ztsUbzu0;oqEBv10gwt1c3zPBN#@?yfdF=-dcR1~5?FZd^fb8D>nyZ`EhU1*i%N(bE zAzE=;((V?t$Fw)KueEL3-G+OUv{YNQv5&R6Din|k{3+Ctnpz>Z5Q0Q6+VOC+Rd4k1 zIV5ctr+ut_r@gK{r)4{o#K=0MXp`j9^a=Asn?tb)t;yunl=QS~Qu3PiqfqcW7g@F=o7Z zv{fUo=*Dy0s@Yc*7g$8nlqgZJ&n;7@45w?=qgM=Tk4U#_PxIObQ1PPnns!9{39-$x=rgY351At^QgGZqhO_1Y}BpNqhcOq*QzfjwNri0U!k|azREuNGF8(w zizYaK-9CBpGG_~1o?ST%*`s@9DUN+Bqa5dYtHts0`16l=ZbqZ3*1%Z zVx@)Xn+FXmTvfEvb-H<}snCPet*bnC|D$yaDv#g1_}IPIPc<{o0r>__`%3#%yIQ-# z>9k&Noo)HWa>6p&a+amU5>UG-=dJ3vDN>}CD&w_xv{$ufwTrCRTCcK>v3z0aZYi=f zS60Z6$!k|l-qcD}TPb^$GnCM(C7X7ish*_MQCVl_=jUZ56jR-1&*KpXCFKRlP;oFCSaAY4hOif7yJi zSYQ`g^CMY?>ob?$j7h7#YOaA;+Z0e+m`q`JCY?Cn0&r3}o zmzu#nm*;*qU*R(jp)Efmk%&zlH)$gG^eJb1&6i3OiAhtE+@AcGp0WyseA$%P#H79- zA8Pc5eTZD#;uIZ5zNd!BZ7$67$o4$V2!B1AqO@arTwhC_VA0huRXDeWS4w< z2^qY-=ITx;1*EzN?rW;s;rMfPTO7ZtMs3)vvbq(HbE;e7 zc&OSeFKTsj9NShG;OMVzhU3N6O>vx8-2}&_)h--ws&;T(^J&#~9G6zxP_j-`t2lmF zjVhqIQ4Pe%Ur0}cC4|JBH7x~@`PpX4j<`c z>501i>^>s7oENXEsF*ypZ=XIDSEW$%^fqslh$NG{VV>$AKUW*pOP;9}QS6;%qa{+= zOW*9S?W|k+)%KG4(YV@4-_YT+Y3BV)D?3W0O5fb)vrhV+)rV&r5gU0;v{y>FcbPU` zdByse(Zoemm3R8Gw_Su}z*}y4kXY#;**&)pAtmypo9)l@4r5yj(v;73zTgs4A|ZAP zwiuSyw0txCvVO>|dODf*`)NC&5>iD<`hbV=HA!#R@Fk<-X;yvAt~ zUr2cF{lYsp>hzmi^- z?v<8ES4(F~-Nl3A9&wX6UmPozh(*FNVV`mJ6cQr?%wkPFNxY_$28?*jy3Py}>C zcjNtDx%+za3i6Dv@p{XK_gXZ*SJ-&JdHs8~X{1eteXB{sJMPAJT#fH&4fmam_Z^M* z?G5*BjrXmM_bmw<<@AdPQw^>1>&HN46Fz2QFhYs39t8ZZ9b zaPcRj{d7{>QY2pG1w&auz9kt;#xlb)$#SLT3d<P>33no(z{lhiBKE7Vcy2z7`$Pz|en)UIlK zwNTYmMfpwnQTbZ=Qu$PQSJ|(;AU<}=KV7?->@a$-BJ0JBBJezS0DfcW$zdOguaVw4 z?3Kfw6kjDha@d{XK2n^+ZaM5q@nzB_hn;hH8pW4LryOQF<{TX;zCem{*gl8tC_Ycx z=CDl;TT^_7w8~-29JZkN6e-MM^BfjXe3CTFVN;4vWJnX{Aa06}5myei96BlPB90u| zb7-UZD6!_yl0%i^4x;2x&Y?u{5hCVLpjiG0>MS$rx1sOGkYmXGM)5)J?>YQ6hrdv~ zpZht7KN;2Q$sE~6ylS!8eg9tj+Hh_XF5}nUPM$S--+}6qR*i1rHLE~3@i6Gapf`hF z40XVg}t9bY;+mL1zZ1G3dmgBZCeMisTIaZhLmxjzL=nZ5Xs>(27Az1}zvA zGHA}AfI%|`O-*o-CJfvRTnsb@PFcv9&p6nroq>&km4StU%0OWtGmsdF3^3nA87=I06w`yT!?vg(7AcKwY*Uo|sM?B$Dy z(fb+xY-LQd_Ekgh%%G}4_^4D>j-yhAF-<|FY9NljssT6-t?Gy4ger`33Z_4V1l^DqwMVooo%6L; z&R3m&qs0NfjZ@&9OY|>i)(z9=f5ML>BXv96BF0DS^!{s(3^mf9@*7>kP-U^9oZ!#p z_0b(ki9Yrlezno_TmCXpU)+`4aG^|sEL{Q&{0w{yXmOTVah3@!&N89JSthhN%Y+tZ znb6`a6Iz^QBmGx?qrwkW(9J%N&%QU`VSbqx@MS^xK%gyxa1R-3RxiLIIUG#!PBJKm zXHZ;E$|>rkEQh5O*OC&7Ysf%~tI6p(9FW8Q6jzac6mKSdbC`+d91)5uNjQfgir0}K z#aSefLqElt#7A)kYs!S_gf?YB+LQrlQzoF1r_WUo+LQ?utSJN1rc9X1lVU?wg}--Y ziQ^GYO&q^)S$%an)Jz698Ny&NgFy_=U{KDWj6o@b5(WbqoX%hXgZ>QqG3d)6${@lZ zEMGyt8!}H_EQh!V%^@yAbBK%39O7aXzKi7$7t0|omP1@Dhqzb{aj_iYVmZWRq$9#c z1>t{MS4xDMBK0_ZR!4lzKQlt)5SF20a znd)TqBIOIUM0F~ks6Ms3@}k;Y`A#{Yk1aDZS-G%Q%t!*qvn<9v$Fdl=pA4o~X$Ir= z5}Lud-K3merP+&ng_P2>5_%j+k2GW9d781f7fFA5wI4mwOocDdJjFdrBJ^rl$(T*; zUP7}J_W%jf%QQoAj}t#V^Uf?L=O{8#vo|L^vHTc z3kmBD-I(hQWl3ja&rsnEvkV&YNV5>W$O_?kX8fElv})*<(L#vs87+iBS_rw!TVOBfhp)(mBg`sRRYIoRUF4DRWa1;ovOy7PCr_8Io!onm*V=g zs!LG6+p8|d(CtLkC=5#HS6yg~882)Rg>mw^hB8^WkJq2&zwegqSdt)p49r3f74#LN zAZAckj-yq}o#ZNt>&cZAb&{sImZT`IAxVm>NrK`k5~p}GiBVih#!_5HuAq1w8AEXv zxt!umav8-L$$tA$HJnFWK>4ge1n&MP4isB@4k#Tam(A0u3Y&MknW*D}9R%3=? zKFQ?5u(=C4x1UVLn6-*bE@yBVgG(7)!r)>CqZy21aFNlgT6kB*+~P-OdNtSWD}Y_Q|gwq!+5ZH&xSF$QE*T=pkaojU|1!(3)YR5 zL_hhp&{SBdtkRVe!b2Gwv3_g)()xz=8S6vV&DNVShd9f6rS($l2y3a;Yi(A)*&7I0+j$`p4$yPumEysw!^y7J1%DU+_5RDl^l`pCU5k+jdd$lcRKB%SDm zIP>>?>N&oQ^aZONTXh~eZe_Cd^GVq%iJGxuI@KJyNnNDA

9gCLilj5IjF@s|Y~r}tG1EKTW=$k*ajkxWauZW_4m=ChS^9QH!D))y$W)n=qud7O z;j_j~tr$1)N~--f=BGi^Qxhw=TbY_c6D!75%;IigA30-E0(1VjyPo;6eA2{3YH|g) zj>S>=q-(C3HWANLbvk>eJXVp)TsdjVEN*RqNQ!xd&WfI2fh+W}HSDYODz}>HEu8|- zPe^gAm?v@*tT)p$IhTqnb7w3SSI|_H^HT99cAs9!%;1(&FUqGT<1(6zjTgD4G*-+D z4as;T^-}#MZVB}+PR-c8A$LMkaWVT~E)}bDuhCS@q_{;i?qwR}>uEB|rzGP-8e4Kh zGA>}+a>-c5-lnNo$=oy*=Ofsih@6*-8Qd)-GEKyJ@Lo%}CuYno+YPp>Y(vnb^s*J%n%ZRR->h}kx2-Q)AGbb;nr5~23hQ~+ zLDq=1ht+C1YWc{r-}1C&hvja|t(N5mR?99?Gv<5`X6;cKnA7FC#VqcD{FD3hPwvY< zxi|mhp8S)$^G|B>Pqs9C@h-SB?BjRlKXFIH6Sp_GHs?REDgPv!f3h+EWJCVRZ4Ih! zZE)RU5i+^ZuWz`xF8?h(|730c$(sC=)%ho@@=tDV(7Q7K@{0PC45|_CrusAUWO@F{ zviy^!`6oB#pDf8gxuM~ciyK_k`422=xO}}OQ=j|`^PgCde^QlyQkj1;KX3grV=keZ z8EIaY&dR$nGtWIE&pkcQJOoH6Yc`**g*R@r%h_LTig+jW+|I?J>N>~Gt~Sq?ix z+GhJpw#zN=Ig7Q`_9tv3EiXIUX*bxn*#=p5JDX`4dyTE1WxG?UuHdKi97aaJ?!UO z4_QV#9?{y{hgjdT40qh4HM94(zGxZf$ZA%*&-$b#;8>$^_HNdPEya!-oZs2oSnsm5 zam;eo+U?fW7Mo+d^BvpYt=Fr+I4*WRYpb(PQIB?Yoa5YOJ7i6(A3Fv*@3p;Qy-0o4 z5pZU0FIdl1_d1Fl?QBn2PgnI0E52GyI{k=4nG)L5qLx0#{>BFXvkO40W@{-uy&;Q*UM~xXqPxj+TGw zi`(5oXa1DdVe=`=eVa@bxt1Yo=4#q5Y&2c0Z8!au*Cuec(eLLPcJt->X5Ca<-ym@7 zP2V&$>1OCpX%0Bue7e3h;MO)3irEThUR!PkGuM)v${N~n8vpgpxaooVMx45HP5AH9 z;wJN6t`oNWAJbylscEtF_h~_Eiu$z3ENM)P8yeGMvH6Mm20K43XkEeD>VHU!g{P#& z0#ijUEvg#QqOu_^=4(PHa#p6Xn}w#Qkg(?2DzqdlCFa_Nwxm8G=9opkzFl?;UE$7e zmCZ-%Q(;yUp_u%xMK<-b_Sn?JlE9IgZhnlVz%=8D*5c4+Xp5KXibs4XWAAP6WN%@2 z*m>KJwmRD(+dH;>wmr5-Yj=dZ}Zqrv$e3k;cm z)&tgOt&dvow{AkayVyF%I@y}Ej<%jA9X#VyNoFU@n`nCD)S=e{A&O^aAg zXLa87MS1S)O?M{u(89bMv~cCFSLI!=%yZ8-Gd6n$wOqY(UZZnvqjOH9^SVaoZ0hvU ztT(Ii^2|o(j7BFdN9=RcjA(yxpCGQZr#Zc3PfcIFtVEoqKXve{=DqHAYlrL$uz*1ogGvVTDeY(g=KH`tnn#aw z>2VG{UPq6!>2Vf4&ZNf~^f;X!rx_>D7Mt=eQL97IZtl%Z7}OcfMvCugm??01Psen@ z1`loqI$}1yS2L^jB6o)EN?jQ%4l8jCafBV69nBrQ{agD-_I-{ij?s=vN6P-V{XzR` z`y9K^-qG%|6O0BvvAtm1X}{cFZl7QuY1?93kHN&1W<0)YZC5CgK17*1ZCphxJ|Q)D z(iFz}8H$BnKT-5^-FG+5451?wW$L5}$+B64G5%_gH^yBvZbEFz1(OD0XxM=$SH-4Y zFln;czMg?a`d;(`Ymtmuj)6@_X#m35O#HoDGGaMoZdfq-z@^)!If}D3)+o~06PEB(D;!HR%ah~fO z>UhYp#j(M$S}&R`dipr~J3Y?RoGqMoC+GOiam4YF|$H+j+B%@+I|2n%KE_UN`=XJa>7XyR6Zz@6<$J$NB+8|9VHidPm=! zL&u!RMPnP^OL_C$o;-J-Ja@iddev*}S?}mk@919dC^kU*Tt_lz*iLfV_0AQJTkTu4 z8O|!lGW$9#q)l$umx11a&1^J&Mc_V=`VwGW*4I(FFiXclKPM|*pBumCNN zX7={ZFC6xh_FwFZ^MK<+`xiE2+Z3ThG-J7=qJOwdEFeSmlgq@y;vr^Loh#A~p*?BH z#)F(f2ZDT6Uz~OCa_EzR9#1=sA#;dXsq9cb#ud9KWw`Wy2p zC^{`kuDnt&vC?YltIwf*9fG|*X~>{a{lIdBBf`QFZVX50lyC%34M%{5!=E1x-#>)I zn;#C(@50gNlyLMuH5|Pf!_l)b96j>G(f!nL6w`1RCzgrLc;O6(RaaJs!}Jy&v3qmJ zOvg2721lX1lsZC=9*!b3e^#@~o^98!-Y8o2$pgie#=-&O>BRVaqu7B6XE+nOvIz=C z?x`usc070%dCN@o-|YIiy-D&i8EZsntHSgcqDL$b;YxrWu{Z=b7Kd=e;t-A=dhA1w zSQx?;EDYh;lOB7}BW38y#nqi267^0GNrx~^{v{f-W`(+at2Ek^tWcR<0uq4U(&Wa z7hn{ArPkVU)M&9sdL5M#=Zx#t#aMz4y(Zm8&eGp}O&W|Pc#ac0>Swhp8>(NqU%I8x z`3@&L?icW}qs|+hrH=cxs~taRJFtu`9dTr@ykQKMhf6+@*74kG{ew@WA)xAUtIq&c z&!QLYmP&A{9vO^NUcYvAaT&ha^l0|e1!KryRR--Bi+ulIEO8%^%q4EwkWWZhUfa6& zQo6b>nXBv4YPQBrhOz}nGK4L!k-=CcweNI$l`M*6blH4@D&uaO8_UL#?)yhcK7d5r|w@)`-SV*zy|jvgI}6Vasdim(VqC(wi->p;JNExJgg8yheJkUte7$wTB~&%AH;$aCk` z<&Djq<%ISB*W3_m{W8miSaUB?AIB>D0~VszsfX1A>Pza=>LZ{rFHwI|zg1USR1loE zsGsO#Z+Ne zMy^65Y<|MW$W=Z@t_m}9RfLhNe2iS>W8^A7BUkwuxynb1tFK-!LllS#u|TX48^jKA zK%5W_;)1v#O(0Dn%^(Gk=8!^23rI^yD@bcd8%SG7JLBs0?QdDs;xuU>r#i;O8nMTaHH`2{!FqMv+weLvYGEeZD+8@d!Vlh2}kI!`nb z^?JR@bSxYUr;_P-I2`jOyuOq#?F$5Bi9j+Q^`z3tSTN}i1ic}D-0SlOW2s~~5>2O5 z0YRrm9r~g1y-M@}y^7w>srC6_Lzj;G;(hSGtkC*V!_ z{pnad7LCTj@o2~w^ZRi*?hl29dzji4i$;_f8+#X}3F)Xyo}%Jrwv|d3n`nE_a)+h4 zx){b}k6547`+ADn7-Kv|Ev=&c0dBB4Xpd4~L^ZA3_uLhCRV}Je=^Q{egHO>w5(+>evPL5652fOQ7LcsdgFN77zT+-K^IMU&}hES?GnQod-XxZj&f1zL^2Rgr#vB_ zC+hRZiHGXZPo9a4+~0XUr4C z!x69Fi%f?C8IQ%I@T*sgrb5AJC>{tVqCp=y-Dtb3xK{St)^WW^ov1ET2V3IyuD0{6 z^OQ$zcHu+mbn!>wY;hbcHd|ZnQF@T$*5{<_h1Kd?de1$@Et*@yydS2)6YjH3pL1`?5Y+80Xs zLa|^v>`4S-{y^G?S|H(x5FZVzFC7b{q9Nq2Xxf`Z>czszXfP1K6P}C z9NIl7$Nrd4^M^eCXv~Y}!@(HorN>_?emm!>L<|uc4hIvc0HTO4zc&>0`y#0b-bi>; z2`_5ADB=(wLbeTrBghHJ2+^o7jPy;B!OT-(q*U6Mj3pA$WHjaVN5V)iWTZ$skVwFd z8YPqndcDXj31l3P$9GW>S5u*ABJB@`(xe~NgD)fdh0{r2%;!x<5>ZbAzDRoDGfz6; zL7ojKypd=C9}h<(ffTBiASz@JeC79rA}Qk6-JONu#;AS8_nvX7)z5V&cW~M+=V517 z818gC_BpP{lDukLtG+JYZ#k}(Tee8^VTIH}UBb^$epPlVla*rmGwB8SR_k8*0@=xX zq`u;}{BAKNwin(PZWbi+6}iJULLcku-qM)n>i(LN%gL!k0`+z%5cS7Vdq+~CxGx$F zrlKB%*pC8~fL~L-a5S9=CW9ew2=$^j91evN{$wf=L#ogS@cI#=6rv#*P5M&-ggu-L z$HI{W%7xz(3kTBipg-mfM11LxHZHhLLjV1nFVC-lBUF#(2y1aI5Z1 znD0=Dm^Dx$7!9Q1$&fc4i^e_3J$`Q_iOLyOegN^9jCmvJB&w*S$BT$YT^5V_WAQ|g zoI!o(Nq9Zp5E{T_ESX5dggox|`w~dk2-+%NJc^nP^-~SoHGc>8Nu|a|t-E&*o1}Ghe(GH7JliQbo^(_=dfIF4x7jbS+iXwUX4`z$uR!{` z*y^^tWT~`7V2M`);(LMein2iID}N*3BgfIdYfq zAwS#R$+pJ2*LIH8Z&S$_OP#PbYw%)U(JY-BLDet24 zq`+7`wA@Wt`3s;ZgwgpyG4moj#lxt<(upv#0(zIJKp-9pqN9+GrcmFbZcn3KPo~f( zLY1CGzac^f(ef9FnN55u88TJ*yh*b+l|W;MRyl?~K?r>bw4=#j3VlhBKZM>~ER#YT zoI>v)7>K6AxpIt33cXgem#7B9K@`NaH;gVtIGhOKlc7*Ljg~l=OrYfZkafdpkJl4K zOPmOx;*W$9Ggjlf5-b65!Fdsx^=||Iueo-Wb76ertI>WdZ3!vgeXDERVi{F<- z!-*{@g6Uu=l=2h(U`JtyanA7a?Wc*31zaCpnq1zAcaGM(O)l@!%+g*i6mD_4h4ZCy zai@GaKaZR=l9S6HS6P1*Q5`f}C-iW9s3PM*FFxW!w;&zzB*N)1eC0<6E#*mh(XmHs zlL&fJ=6`e~{D^VV+eptUZ!Zemaz5MobRHS{#ku9@WI`TPAc1Hil1QLWlZu4UefI|w z@tDsSM=KT&r_ijX{o!cJgATJVYW7fk=oq2{mG(s9DN@E_IfRZ2V&Ck#dD0jNq*6Zg z7=ox=(dR+B2T>h+Jm@|}qj7WvLNPBI9DhoSd!l$efc_w>EsI{3N~;ggD~Fr`83Y*& z83Gv!83s8MG8}RiI@hKz?yfLsHa2$=+#47nCE1u_*<0htDw4w(U&37G|% z4Y|%ZJa0~$Yc*6sL2|KdIi{A$4sx;XJTa$@lYavFV5Rf0wo4GmCf##lPIs$tCi&93 z$99fmfIjiWoMX9xQQR!2QBN`%Pa>P6Z-g=wK*Xklo_GrV(*&}iCm8mJqOky;Lq9EJzcPmiE~n@*!t#!}t@ddkp_ zUVYe$wmcdPM}l#5L9pYCS*ij_t1pESXE5Rys+so|Q11<$&$I@DX|!HuM>!rx109Ze zeQ9+0(teBr6RD6d6bwfbD3Sq`jUWaz5q}bGb^yLZJrPd}GmQ7Xos+VNW`3(K{dEpx zE@U1g1DOx0gj7KmKo&x-hb%Hmf31F|Nn{3b2&9$vB7c_HR9j6(NTam#$$sH>tt~cb zAWwZCP5S$u^rvG1|( zAdiZp?f0N<>a=UFNloE)mWY*v*PT!P)Q{}Ym$h5m z$MJ|c^_!S9dlvT!w_Sg+{o-)jZS+ep9)LXox7B3l9lQI{x+QlWue_goNWZwdj>lW5+yuYaKwuN=O)^Yc@5~4(3dB(6pJEFRK_Zmg60=Jr#+^6Woqep27lWAR{VxLDbetQxyu2=!M(xVW2* z6-^f_q}tVcvEk}{!*`{EhfnLu*g{bS#cp zlN2hqv^jAX2!_$nrvm20S}JKarHOPngxM8e8nX?g53LF#(FmqD(jjz_0vG^?Q2m<& z^Kc{{@?m@%#@rU>RZwwzljxyg;vwa|G=+;{jN4*KOh=IJR8J_9Mx7b+m{UF}bL1Zg zVaf{~wisG(jA_sj^P?$24+jILR4SE72a+L-)gxX{Bppt9NHJ@>H%xVv=%eN>Th3ai za2&mg2*z?r4Dy5MX7~fX7>1G|%s0hDVUI5rL&%fpWMU{1LTn^4s`G{-81`Tw1GRmP zo|$FuooEs;4c^|DHwdtN<7Z0!tb6rllb1(2s&6Shlt-0or5E|9#3J5noo!oV|Iz-C z<7MY4t%tK#+ht6iygZ0uCTG-KyZq(c6j&sNmMHE=+k^R=Py~ajxYwWbrV!ueI6i_| zE6hotLmG=kqQNA_b;)=L1H4o^gf`COuaEsuG=LN{=fEN%^v66Hnqmqhhz1vYW>4!$Jj74V%f2898iFBbKXe!BZ@X*fE0%bO9C0x)ae7Z|2Awu_6%Y~XtE%YVhxV`2( z0ulzdO7D~0vRZFFul5ps|E$_}`bn*(sk~Fn8Jx%9 zT&+Kct$pYMj#U__>rd5=)Au}9+fAR_yXGk~NY5H2eQNfuP{1ll$S-l({wFRWzh<8y zSCHdRy>{qQvYQsieMk4V7L3Q9ujzp3yg_Ipt5*9_a)EFsmpy@C97M36I)JD55ZV5j z{X_d(`hi3H2kG87YX<0(5AE+L3LAvAYf5T7Z7gp%XF9KSUJYx~XF0YdhA`jsEOrzz z?tHVRi)_4pbiXF(&)RET@Sxz*kAGA%0WQ+QxcuW9jD8j2HX1tNRtC2)SjRwTu$I9Z z2CEsYVsJA9O5arkO5arkO5arkO5arkO5arkM&A_}eOF-g-HgEay8`3y3XH!iF#fK< z_`AYF^Aobb_`3q*?+TUd;yea(8O&jD9fR2nW-*w_UJ4fYP2^9F(i1h$5&g(F zHC5TIuCZi{XnEE-(^&m&%{QV|Ay=9oQ((-yqFYUXIPFI{#Vu@L7vC{*symZ;q`=BQ zZeF(Xk1^PVWG*z0|59@k$+(4$=0!J4V>e4Rmeg*R)NYp4ZkE)p3`=VlOKTTPY8Ojt7fWgvOKKNOY8Ojt z7fWgvOKKNOY8T7dE`hGUx&)f=E`cVzOP~qw5@^D^1e)+J;T)RsE`g@JOQ0$55@^c1 z1e)+JfhN35pb761Xu`V$n(!`xCcI0a3GWgnR=@aLjq&1dclY1Z&dL48ISt1ud)(%> zysoZMjwutq-|?1+P+6+*8u}s7Fy<(mdeJ~3q_d> zso$~F6YP!Qnm=Gpmo)77Nq6k5zZ0(N*ry3cIzds}9#=HzVYEzr#RcLgrKc*xD!`v8 z^8_MQ9eYDrdnoIWr!4z*FhOch)f2(k`H%JBPPod`3wkLk{}aL)q^SZQ+7pdj3e_-Ww#!UddmCN>-hnC zuKt}K42%ERKbXyqRu1hBJ#7oPVerq820fdj39{sOdc2rL`@?Wy^Hon}aWV9?XW<&y zpl3qg1R3Asl;rYb7VVGq_``m$vF62lCMiN|?mS&Na8KJ-%30tHo+d38zY#|X&j~($ zGj@2H$sOj-TUpx7U8z+P><(Mdn#*N2{fZj-q5%!t!`Gd?x??452s*00P z(f@ZU(AE1B6}VH`JOd5LuQ^X-AAEQ?8PhL6)L0NPm#6;76Sxzubj*W>!h(7Ydg)if z1;6t{2=gC*q5(@6mCm`)fP7US5wCwGT=;tpU;_M!2A?-n>6!x#$O-it+`Ht_rv?J6 zHGiCRe)CSHb{!P7Zd7pK-hYTl%u4-9THubavc+s@=-L>O%XSPWV}Iudbe8`(5Lg5Z zR5qPu2BJZO{`zC`rN2|)#k%(&rUa&s{go|eLP2qZg6!^{!%6H^4Oj;LleEO0aHVSo zG!!;y(EB_fkNRCoV1@Y~cPyrv6?cM_O{POb>jn+kMzJsd6~!o@IZ7Q7 z{s_e||0Tr`{{_V$f0$x`uchecKd0#9Kcnd74^i~+2PyX9Kc(24|Ab;M{$q+g`Hv{} z;6J3;o&SJhG0(qWeSYDq#`%S>ZM%wJFBnv5ZA7 zkVY<$My_xti(DX$Twy(nTtR1%3#5@NtYwiatYMKWtY(o5q>&4xkt?iXkt^KHA{WRa z7Y-V^!b%poKpMHibu4m)SuApaG;)QREOLbzEOLeEEOLP~a)C5*g=s8ug$fqAKpMF~ z8oAiH43RtI`qzvZ*Y96;iMU$)4mN~*%}4qt-_*!ib9{8QalxVew-J34sVOl$pYMN` z=*2y1ZWtx=hs{2x%-1sboWW-d4ly{$;8O;lF!-3kM+`n>@BxGOtKT}jA94WlI^+$= zn~=94Z$sXJybE~`Vm|u;WG~POV$kP|oj7OZ6XJv;niGRYeAL|@Vly358 z!g~Z8=U!ED&4i9U*|Df&5}VG@>4mTd^CSsyE70rl#FIFLePOJXdlC^$kfpIyiA6}P z#mBtn!e83c1mpxP7p6hcz?z6R~R8^xl!A9Gd7G}hsLSnNb=4&Iv|G&oEkC6iICQFy&E5Erm$hea-Mh8RP7Q0x2-%Kp6rH!Gnd8C_bA4@g^2GCyoMfOd^8r0@_C$L@2C>fWU*P zE==}d#w`s3mv9H2iOFVWk0T{dbscpHd0*XG^tpJ#+Mb)up#<`(bO`qm zH=7V{C+KWfkq-KJSFI++Ft<9;+;#diz37U=VPkufTI@M~$U?Tsl^$$3eYx)5cBq4X z@Qp(^=$$)!Hd8NZT3f6K-GA{L>1MUJUaDC3k%!IcjH2zAkdU!ya_x3rf9hmSDb`PG zeC7m(N=HAk_#4f_;@=tk%HS6UKT}}y0g9xUXNn}#i;~2E&Yyry$<8pgM&XhO12&KF zXJ0UPN|iSo|#pfPYVa4@hluraVQpu3|gl4|UkT{}X+bPX3b zn$D~Ji&xQBkf+QHvIkQJ&5dUbAd~wxxgjTT;LLEE^vu77q~>43tS@21ul{10+;x; z-tqC;V;Q$ZlfW&}BydYK3EUD*0=Gnyz%9`va7#1^+}P_Bes)VV3EV6N+!9Rzw?tC_ zJG!w)XbQL`ngVW#rhr?bDd3i93b-$lXcD+3mAyd=k((7F7b`@tZlRIH3Xw~qg$O&R zvx~Vnrp467im6Nd)%fg*+6S@oKgW6X)lb!W5los<-7KTJC7My)63wV?iDp!{L^G;e zq8ZgK(TwVr+FsVfdB84?j~zETu5$Pt9Oyz%+N~`Dh*gfh} zTN~@w)~(hV)^3(>Ejul9EM*p(`u15Mx?iCdDTkEXm4tGdd|2KhUnz&>mXteGF7e`C zZ0|lwEEfKTv1Ud%2V2L~@diJg50dZ5b}|JfXEL|X@fvpYKB%wQQ`^tnhQFWKPbAay zL(kRvEna^}=-&r>`0IQ3)^^ZK-d}3rg#r4?*J?}6!vKBRYqcekKkN}A=6Qd8IPO@y zfv7M%Ad)NfLo#Wp&wuVaOLl(O`R0zoXPj~wIbcvvk&I%`jV0TVv?U&qT%m7!uC~3t zNndJLgzz9#%L=sw@j_64;JI3l*h>Tt0k0P6n^s(pwD;FP zSfr0^@}`p)0{VexYeS}2+v@YrTiKBp%Gk+)=Wpt$4|uwEutdpGria?=B?ah^3%uk}&Hs+-Z^z5G6h!7F3 zqz=8w-r9abNEpX12ZiybW2)j8uIVD;CkIT8+02%xxl8k_SQ+Refj9d0^R?gN7XnD7 zTdzEMXKQ_H`HANG-(SElJS;tVr;DDh{p4^{;}KWwV6x>vZBfQ7B7OJ=IkBa1Kv*o4 z@IUeof?GFH>+Jl%uxbzoqQ)oOil2M1y$gkek0y1#`qN9hP+9ZkW%ii+(Yo?fwkD1{AXgts7il*)sbQUY`s>URL{J- z|BDW!%os7RZX0P}d1(~2WjJ#_1lB@J3UrWSqAtw%o91-y~}Rk=13v&9x<*>acWqK zSzxVj^tV24|G|F0w#(Yfh;KdoS1k7QFfM!KaCvT_GKIxbOiI860leHK)>4yLg^YPj zIu7V9sT7E>n4rkDH6RDSXK6CxX)*1kF06`SA-(36Dwe0b5R(ZS;EVFp>1_4<9beCmD+g{gtP z1qg>A&?P~|#*7hcHn5DENWfqvhOoh}=)_}P6dZ8ScY|JcESXG$cutX!ux87i z!y_|R`*0XV^d#t@t`{oA5mI+>qz@wU&rwDELc?0ku=C;}exxYdcG)J|x{_(uPpoUK zXGv3~C#+yYSRNDCS+2!$Lr-)Gdsw$x{9OYyh^bg*Sz1@F zx7#lb)3a5uHos+Q-B2<>_b#Ygr;jpk*%#KW*I!!>;wUnUSWcRSN{8DZ59zqB8c%5hHVW-q2PdV5k`JZ z1~L1G;U9Paps|6}5Y5B_QP3Vha=;MPi$NmF0r(Iw5`ZZTN=*`lGDITy5uB9{UN&t} zz}pUiDT%^~x4htygVhOYeH@iQBH{}`F_`voZydbbP|&nj@GmORK*NlHj{)x`!TXPc^_~bMPz?}&PSbzM ze$RmZp3?*tKIr)A6ey(T=o>V>R2*LeVKIduqSgUJ70g_i?0_5!DryRx3k={x$v`5V z^uz+hi#5h;5KCYmVjP{0f(;Dgj5w&ccmb^4Bxo6^hRoUrJlI$$3|oy1Xemk941uPC z0XNvZu_P9EJUK7@_Y9DnqCaKjX8y;t+?*!Qe{Gt<6p6^wkx)=?RaMu2QPCHRioV=g zbc9UK(CTrwAnX<@q#oiSSyIndMdfizH}zB29U!|!89Tx4;y*L1$6V$3ggC+~i0k-~ z@;SVsOtCy>xmKF06!YulgF+8kSJ$d%J0_tbyam-Qs!Q&3S;B5jAEN?3gb(KZ=>ras zR`r$Rzi&GR)@r`#nDRLHAu5;u3AOtlnBAFEsNMA$;@@Xd{fR@4-NP=vZ@T4=aqJdFY*koLR0tR~* zGBBqJ-52n*d;#$Lqo!$T+MhsO;z^(f0J^hjkme7-_R|~kpsI!$A&h+BM3aDk#C+)} z!LNwWuz^tZr^b(&|C#k;E}c_oR1g)Y+K~xiatd~xAGSEb5C~);e*!$@s3#Oh1LH++ zCIqe>oNWk>Mu0g*fyn>dX5xxJ zG@9@>MAN@E@cifYo%-ks6a|bzZd|nK$d*kQL@2bBhIMQ^%{0YHk@hVgE_J$$8km-9AD5tG$b~&$iw=kHjr)ZFcK(miw#`%Qwnn z!bs9ueMhj0SHsrgS$-}bwtXsJtW*fPqXat&PStvnd$1eOLb=Jx(zYDRAz=9LTJFrNP5@t3IvuSe> z6N*LC7`UKYZw`WBo9qw478;yhu$X;Vb%J#sI=`??3&cVxFRbxB8MJ&bT8@!YI?#?q zzywDdh4nHIh}WQ?!=^QWF$~PyqA=otRgh^J6a}x?=fU-0A_4OmjOXFm2nfQYgsO?A z(U`#?I*L6J;3Yps5g3HQ!Ur93SXBoxv`63G12ei7K94sTk77p#^u_(K7K+A_Se+vS z^+Rt+gN=LK#~vVMScpnQL$GM|M9=|-4SWg%o^%-dOu&`&`8}r166PdeUym?hPX-JE zOrurUCWc_8oF+r`SKpO}>(k$tO7+v4fmY0z#AELTPkCFjV?!l*6E}$HCbKwe#fE2R zWnJASVoxycC=HIjZO;gt+|1sZJfy5dsW^ak;Vqt0nDuP)V>jhgR^C@!D$$p+uN=bw z*w|HgEW%>X}x@mN8}j{eVfo=1nQL&qc$C@IM|98V6e+YelkuTR4UcF-qGW1F)O` zJFciNk^rF{%z5mT5s$!988aU~OhVxt>~c&>!BQGKW?&izMhxKi!*~<(Q!qD(gvjaH zzf?k5cEpWiFyC3azPM~r(XpLHb)pBBse?FWEE#0(5r}!$r7dKYD`>6vDJ`wQ@0PF1 z6t>*%X!W9W3W?0`LnrHx$t$=MHpY&syR*fh4zkWQ%zv(Op6Mjm(Gx=$`^Ol#3^xZr zs>lXFO0ji~^0+bun_3Izz{eaascX$SB~|y%CvD8`fltnjX<&Q_lMQt5Op|~BLJVW< z2#O}IWBi`PlB;Q2AH$pi2Io->dwpK4PKVGb#4nYD&rgQ3j*gOq4J-;#95*pm#-1E_ z(2IR9K-%}GFto?>u@quC1QQNCfXR!fFPbsq*&B*`VX;Ss{6 z75$T0o6MhV`SZJJn|e+EWEB3N4Z<`34}0GMA62oozq4mcc2jmkItj@p^cJ?4&=OD( zQ3Sz`z@~9gN@zC3O#-2pAms!ECU{Ybh(VEnA`ANIwFBR+c2KY1y(B?UuLZpNe`e0v zmO}O3`+fhP{>)*|nK|#wyfbIYTb}o7PvQ8yfFfgqun<1~ZU~q$PXi=`iP8Xp068T# zW+jkgx&e#;+#^`_n4y{|a+SafAILK}JNJ|tr@<#{V~P$qu)*UUxh-Z7K5!R4_#!Jl zSRim=Cxh)7@ByrBgJAQNLZedxM|&W5d2FOtfDjO2gBDD4;1><~1|*(P;S|As z$5{;Iol?k2;AK|o@IeRz*R3MRobfec_J9DyL2_kESKubtx^Syaw?*G2`z^{Ny6Z}3Rs4KgyCwCk085Y&_{CPu2K*alEYjwijpq`qtpyVNv;x{6fcU8bz$*I zkdQ2{ijY?Ix*WhRCVd<>j0s;Zz8Ay(t9wWs9i@_XVww|7KNs~07eKeN7sKY#WnIN)@8@M^}Y{cdY>lh|WkFCg!?I;6Ifa3%nlt5SF#A4+tE(-3&uIO+A%}?0r zL}Huv>d|~;LPhXbb(COE@H%W*TT9@3>U9I`0Uo}kUT9#=ZXfX6pb&v2Byft%us;Mi zh|2+kNUYgVUy*BNN>`7zBoo59HvF9HyMlwROzA=jwM)~dfe_z|{bD-4V)$EQ;lm_f zWCq9*&a*hYut^8XLMWVoxB@jK#Mv&u!U6RJ&?vrAc&uPLW|C)^R1&A$xXV;vwynRs z$U)wB2`BkL&t39azOAEtfKb>`(065L!2qQzm39P$9f7^rFQ$3EOb2RTD<0HxfqZI5meE>FaFr^37w2%$q;Db{FfcRj93@A5P zIWR*H>?B~;fh`YIG|=k9RNF&(NL9KlHE%0gUSCMf`^&4trD`47pr|7Rc3YaB=}`D4;BX zbM4_E!8m_2<1U;6fDR3JA!uB1n1(nL&?kk!S%I)ls1jGaa(W1PMD)FSdT2nf?EsP~ zkWt|a3FI=IrQLuS11=A&yl`GXWdIgaT#5uhPNSg^^1*(=;xI$K11*48SX>|b+UYZ7 zI2V)4(gfJ4@zo9gml?W0Fn#mI={ z_0t9Qi(dy>slpo<9)9yQ{=Td~{N|Z!ssI`IvV!W%#*x9|@R4W#PCu+24=16V{Ar|? z395IiRv+CO?IYKPg}Wp}!*7O!-&`Ahb4~aSA}0l7my$u@Hwc;(O34qu85p=>@qfvH zz#Y5kAAZv>{HAaCOE7Bxk~KeNr<5os<$=#2H}%JDdzB- zKH)dH;Wxd*Z+eB_Ab3)0h+L2Gr0xp~Z$FE_ukiOZ{=UKAKk)Z0{=UQC_xSsvzVP;Q zkKZQTrtB?h>&}&2udUL40Da>K?G$ZK^>NLE8iVG)w42mx)pu(fH9u(+G{vg-)P0nH zQI1o+qIya>Lp4e0EYu4{Dy8bI^6$z$%HHaH*t?Gs9%ovX`$>CvXQkhy)XITrTf&re zP#jB8!>?YcWwGgk;Bo`-+PzAxIO?Ty$@Cdu%$anGce~(WUpySK|6G>o9k_%fA(%aT zO#+YWi{z4EQ4$QBPVrWMJf0+xaz?!wAY6Cy`egA~=A!J(Hxkt(ElsJFLYf2+!kQbwJ{x zM&bnHqJ6(q<6zKv8Tn7*k0ye*`()m7Vlr=^mv}2v&hX@3gtoNcY;-zB5aRJ6h4a@t;tdUR_#(PQ2kT&ih6R? zqpFcAqc#;@5ASJj)2`G0QL{n)m9mFYh|W>0i@rO$CZ-*^DdtA9B5qk+Z0wepGqDq6 zd&mBXyc6?6%tMCC=-Y!iah25h5zzYWTsL}k8dy#i{9e| zG?R+VVGl+ai7QHYRKE327{P8=$+u^R-o$RJ<=g2ahOyg~%;^R1p4{RgS#&uZT?|pe z3g%9NhpT>TAX!nu@^lq(V1%7~>+{4YVHq!8BNq>A`lXQ%T*8vb2X0|;01^mz46h>j*a%)F9tYstwZ-J*m!%-aXIQpM|L7)WxI@DT3> zyz+58>xBmcx0@5$?E~^{{Qbk(?fud%(x-#*vpMWGIDR(q@gu#4@w1VSpTO59kDte+@q^|{<7WdOKia_f zShgjW4#kNiymjQ;5yVMVV^YZQ_`q9MTdot4 z2OKv41U#qF>bp?I7yQ|#Byzhzjk{AwGOfKUow#V+EDh;OGapDN4~jQAoLV~e{&X^g zo|v6Z>@8YNS*r)pe_qp>yyyS<`7H7bX+6&nzv;nrlB(L|&w-8g#N@YPf-P*LareXU* zQ$tO)>D2SP8n$iC&CS&}?AqTruc8P3(}x?Yxbp1wJhNHP@Q3t`oAx$5yQopbd(YK2 zJh7jZwW*wuG&O8n*0^O4@O2yLON`v91LdcD&%V?eJ%>!sqDPfLsB!7OMsZQY@)}da zf%%O~51JU+MY!AfBCGTKjf@_OZmeC~f}A_g>Xylw^$n}4S@pK;q#`WbWeB3ym{Xlsjd$5ZvCaXEmVwOvC>ZQ8P$tm;Bm??huN_5A~i!`LW^wrT1 zx@M6P;>jobWeBhk7qi>PrmFD6B4(#lW+>edDp<8?^bFp3K&*cM({}Vm4CYmI>7>r& zX?pmi8Um5(>eSnJ_soLR-Qk^4x+n90s#|B&YBU83-5G?Fz=qwS_EjuSuDlIjp>kkW zMQmktwxR&0vcGw-aK@zK@iS&mE*U?0@|5u<56qh3Et)lL27b?+HM@AyG??jK)scQq zjzm-Z%fxLd;;OHr()&7L|7Z#V+aiza&~O^JBSYaf~0Ldic`Fmw^z2=#%3A@>q;#Mo|D3>4%<7G|6E^zs8vL_^=Gq|saWUfH1*n%RD>xuD$a?SHxpAwJbWDwpJERUitpricJg-&if@s;L-gKEdSczsJS0}jzlMu` z`B#Q`TKc7K5<840Y@@4wM!ah@$zZ=$igQNuchwWbouf%QdsZO6AwO#;elPt}eI|At z!}F>3inq$Ys>CY%LIqW~qJmYdg3*ejas?}S1)~-3BH2~{`!S?<^pp8NAB%|v8oN2WJgwLBn)s z%SC_;gomO9ixWvlfB#i#lBJCIG;TV`*JLxMSA93DnHq+t5Uam-wVK?dTKCL3T?Rr5 zyDL^sTQD=8SNK6*-~NhtFJinoL!pFAbmXG!e<|{Au-z3czrDX85DwHN$?x4#Nh+GQ(Vi zb(vte*)Z7PFmyoxc3s@LxQ4io;@*w($L)!GJZ^PdRos1X6JefxeO%wT-f@|6#yBOy zuYUmu+c#ogjNKKx8Tbz6a9J;k9TPhw)`jrv?PH@6Bm8vCCo#ujUWOU>))?Qqn1wM9 z07L(-m>XjT#F%5UW7@^2qQ8y)pXfhFzaG6mdPns7=*5U4b5Hc0(Zi$rM)!=)fVKCJ z`X>EH`gin)^t<&`zf$khm+2?yN9qgo7Jat9onEQ?O81%W_c%T7)osq>OD z>W1iCx~{qu1WNpS)DKbbMZHiI^=8yg2wWeFniDlPYI4*KQN5%3M|IHt6cwjEtv#y! zMC;eqYU{KW+Ev;@xEkLK;IhHMNl&6+{F{g;<~X%737^a{_Q}MP zK63KAa`xQ7y_~(boc&!XdoMZp9XYwDocxZI+{2*u@oD8PnOt`{|66kYZgT#&r2Jjw z|%V_RezlH>K>I>UI5a`qf#_p!#kA?44O(|;%DHwEtH{8=*j-$~gs z<>c4o>>UF4a`p^4`)gA6bUFDIIeUA4?^7yXk@Kg?`CpOpr^@;La{iRSy_`K+&hD48 zC&|e#$;pXw@=H>3f}DJiCmTsSIoWrRr5j1Soc;nY%?Me8G~vG>=QqgtUy!oL$;k)g z?6HA+IeUzp{eYA`T29_CXV(Yr3w?Y&Av!t#ekp&Hoc_F=UmLiW$!p~7&r8_>B*%x* zUOBrea4%;U`FO#53>7;(0CGfAm>-e`SA^E}ioIQ=FP$Q2ZbcxS1r2j+=?mx08`DDE=Ym2`-)T{SN+)LGg`T#11L@ zSAk?+A%o&;`F^{c{j8jQyOjOQK(d_ujC{YX%*Q4HgW|NDf18xQF_13jZ<6nykjXd5 z*`JWI|2>c_XFnz1Zi%S$PLLH?ZwyWz8*Yzd4(Z8ZwY%4zwb= z7QAm@y#X6D3v6Ez1QLdH02=`6g4u)MfDRV#(F;5Tgsy~BE?o5y+n@wScFsb#n^w35 z;%{reT01D6nEi`z-Ka{#&c<4?=Z?zTgEcv6VKtdWPrB?RGyf`szRnTLp8O_})(uT3 zJKLxaJF=Pm{f4EJM71GYM1t)YjekHxhWZUVHKdqCp|1U~ja1O-J+eqodZvqp#EP{O zyE?`4oa{I{^7n+?PRq;Fi9vt>TD0_tN*pw1TG@zcvr5>(XIkMsB}MFvkyCQtq~emP zMJ3~B;S9ykL?zyt)26bdnX`E=)2s)}N=$>;geqp=YflkCN32+r(0B9c3=&0+&!qqj zs;r~lK#!r+E&j3d)5*Pr#(olmD&=>XJcXXf)sThsy`6ZlZf5Qd|Aa;9WR24Q&5Cpq zL;UY-%OKH8(cWuj`R3oI6KvqO9&RTd{)1-#bycO~#9wND`Vin3Ibp}dj`w4yhHt%PnabFyOsFz;DwlzQ5^N*cD~@6_D0wle z@dyUmtO&1zWwNsbRx0}k-duPF&pccaSrJbV`4dtCM$cPf_u*~0dla`?I+MWg(k;An z-YBU+1Sxj1Iux-s!Ge&L1iv9xMk#BcTovG1u@uCHL5jo^;PS|&L!xINOUria00soD(RJ8lhFRV8 z?C)YoqPWno&ggTL1ey%LIaCXnMMcuih~?jhU)TCw4GKL@-6%d$BLaJQ#h8zEjliwW~F^sh?7v zP&tGJ%F!g3Nt*N|xiV(}LvoJ<%-4nh^X+*yOP<4)>jDfzB$#(ZinY?*A9R`l=4*q1 z`C*yi$rS7i4DN^&TSc#aAkz#mUmF6<59S6GD)45)fW0G9Y!%)5hUf$U^R+Yxm>-tg z83_;`kz%X3E)R5?0p>#y5dh2&%gy4mwq74tMOJk8fKD^Od@bdG`3!hJLyK^(v~UIw&SWv+wq1r_mx#cL2Bczeg0 zh!_$G8x8>AR@;N%h~~;{JIF|*FCvBnTVhv@QGFCyCj?H6=&KNOSW4v(8;JF0JRTZi zPW?OY?ucsEHkdB8!DOV^Dif`s!y;tF_q?rdAV7vVeYEON5!JB+_o(%@A;oR3>|_BM zz~o27$Dnsvi^vxdZnEf$HURs1$|0jbRK_T!a9E+6EgWXw|6U<$sim@e5(H36We3Ih z^om%mYhd?+0>z_uXyB}`*ey=~OI!k-HXwy$iuteJ6gBWF1B!`f);D#ahkqYWVq(H& z*kZwV=Tmz~qfXj{Ku8ri-=LF1Df9?p!lj8P(tfy&ZD9H+Me~jy+tae4MgUPe>E_=h zk}(LGG-`NCdbs$0!0U-5(O@0JJ`|al%PDS|otiFAxaq8lmLq);@zD6_OwwJoTwB?V z9v);QN&Xdp?3vL;%O+N%N)Hi_hnzR8R89~GE-Lbja)KJrlh{U{oF0_wt1oa5C27PVj+(c&gW#|^#FMXSbLo^XQeeAQH~D0;zt0CrK%-(C z!`ly4tAR)HqSA!xN>!a2sMvKHcG0p+6uaoyMb9qLwk<>*U z69#Los?%&F*AU}8Lq+dsgknfYh_8xAloj#D3FluCO+_&`inUm=#f%?zM~S80^Zn$| z3Q%FP0~H|T7?PnAN{!x97!bQH&tlC5q9I@y=5?*;1EOhJS@Bg}8PV}4KOH4*ZRmKL zSpC(PhShi9H$Z&(?Y~aHc5Wn%;OMe64}Zd_n{!zR4Yz%MeuV|p(`QeQUjPqyP5npS z^E#zeKRYy1?p)X&BD#4b?XVQry!I7lQ0S1JE;oAPYr6L0+du4fNR=$#r;8CM9MyTm zSKt2Y)HZ7g=c#68NVRkVDAGAEzap2_a>}Im`2!gbvWw4C=~zNdFUOHP=!w_jptoh} zEf3B8IEFX|M109?f{P>{U=KnH1DDS-uWv;!RzlaV@m2j<2{7+Tte;YPzKqB-oZMN6 zU)Y>dFvVqFc`M?N;u1^zw|*E$c0cEgQ_oXGRcc?>Dm4Su^9*@$3u9l4RmWTtU9R`* ze$oLwO0g<-@kI6+y3z^eJw@$A>1#@&d1ZY_e6V=k^Zf+7wJYUG=5$$G8m6_w8s_$871~y`$Lb- zQ@W6M>BzGk$j!t?D|Qy}&+6p&o3g2KU?!RBABhR*MZrH{y@9-};ffHV{ewdL2kk8J z_>$+2inURHvu{q!1a$lADlrwhN+&&j*8?P*niF%#YqT~chukFYoBcxqHS6b)1pn!F znFN8XA3yt5Dy{n}gLD>achqcafV)nIrFc*Hq#>r4xLY*w4D@G>>X{Mm}XjAB|F zZtcWvd}zk@6f#>nrs5M~-GPbDZlxdKc^1vt2t!2wpYXezR+;QRR0ow=ZM#mwqa>yz zwnCK^cWd0|v9HJMqA??l#MC~{5c9RV92mD>tDaVs#%&Vb6CRDtGt7v|r1>m^zYJHu zPD#%CEq5BpSHxEYkA4_EI{K+EE1~e6T+yE#7 zaAE+9w%KfEy&Jf%aG7@rPtecD84<^zmi8Qve|_sY-68~D#>5UcNDDw>3W42$Lm+~f z7u#XhjRgUZp{0VS18`qUQCVP^AU-Mlpsk1ukHtW!k*kN;rLfS3VKo2>fcy&Ji(-ch zE=hLSLl*+H1!%J@q^AcS%=mE4b|WBdVC)Pa5V$%!?ZRrgdX;kZ=16p}QurjymRbp$ za<8)($TTn@hf8%Scg};`whIQ^@al#GbqVakVK)w+NH-vGy+(KnG5$FaPTpzr3e#6I z?)g6-7f&+0ZniR~DsE839gbTPHzqDK=5I02##HLR(;w0Kpw-c8k87r=dm{h}fWn1a zV9@_1IY8zqZa^?9U!|>z#qCil=S{-2p(xOjA;J!r5(HNgW|6}TKTPNX69V3z+P}zT zX;``oMi>n%VaBj}FH9*;)vbs9N5k?u6KqUyLWgC<+NK(Ar4gcKHd!s$14y*2CT`*C zL!99nn0_!jl2iK^bF&dxr|jjXADdtSQMZGUYuvaMh9K-n9VBOQnP4eok;$>+uvBIF zxm;y4BZPXw+Jcn=b!NlT?IxJJHaxitRc-RES#;{zWm91HJARgT;!M+|sYR1#7njU5 zT`PsA4p3!FDvmJDJhLU&<;0ibu~p5+T(4AS@fku(t`)Xg?Y*!NG(J|{>@7#0-GPM| zI}cB#wQ82c?xJfMyK}VU8ewaAyI4t{JiCXn35YRn6J2$yf%Nck9&F|ICBe2@A?ygS z6nR}H^Z;w(le-$WY=p_n(=g;QHTaj|0c$Z?9VCc$ck9fxgIBq5s76>|Fn2m*Zme?N6lVE{ z!CWAS{vFprPEF)W$=Ly+YqwREMHG@Rtg|@VEX0zF3CLrbxr(em-2Zq`$DnUvd2y(w=mdxNOhkxd8xS zubfadndhEgFj#7SKY_-NO(SXk_lA!lT|zwIuVCkFvsK+2QLVnhYDXKR*p86_TRFgQ z?RX2_y8Kg^~H9R|Zi{{D! zRlkvPPa7rWb_zz0B5JtXRO}ou{oT zT4OW29KEZc55l}8eQFxw5au-d)U0`dqk)aVd{h;O@k~SP!h@}T zYSLTS@;d=QV#B9a#CR6`)HK8!u_p&Gk6QaJr^ukoM!fc4x141npwE7?bp~2j5W!OPi?$3+@w!UL;48!$)B3M zU&B<(a%2B&UU4j*tT@(=96FXjjCD%oaCUn0DBBHOFqLf^9T>3kVVIw3Gs34jv@mcM z#fm;>?#ktriNwFWGKt*3DQ2?zW`+JG{nPqQ`sIGTDH-Q~e{ps)NmrA2e|eW=600&B z5aFZbzG?TC%w(<>WJt67f@b$N|4TiR$xyZG1K}ab1uLC)G$#WNP_Y*^L)15``>CcNq~)Tx)Y!9{a`id& zeAOA%DpjyAYqh$bR-d{f-gE^T;glasjc`O{vPaxmBkn8_cV>y4kMwE15xEh$ zdq>>$YPGsOTYcIi;?3?6cimD2{{lxcc_i=GLFt|O>!2k6YX)T%2IWqnhhiqxnp23U zy?V4d1wkA~3ttPxLO12>%IlOdWEU|geosxc<4BtE?n0ma zY^=O|>0q-uQ+qf)l^Q8uikxp?-xQMGu|>X$v`V#}5lpJMP0l$NIUmYtp3`x6r7EoC zy~Z+n0)p7y;+grTc)5Ht4C`|NwB-WKX{8WW{$3;H<+H63>KT@AZlzK#K8qE<*T`%z zMDoJfVb+2FvpeyjlS6k$E;wN?+wQI;VO82=6pwo!5AZ@3)>#oy!c(aTtIHm_E?on4 z;X*y+Ggm}cAcha;MPF8#JyK?WC7uFr&`6^Tu56e~P&8mQ${{+PniNRqx5$<>HbOd;k-6BY-w6f3tnQV1@o?2^VV?b#)rT{76E1G{9hOBTDBh<~din@re= zrRi0wJ&{eaqm-}4bkR%@VrVbi=KITVy+!Hcf`Y5Zew4^uJ{G*885q2O^k>=R>Be={ zC)IJv97XJbXs=GEd090_>CetDCWJ6$Kpg2f|CY+!6gC=^%Je=&nU<7R5!+L%D=1hH zt0B3F>#lg7p8O)7q>gqg_DL)9)Y+3KOE&jcgQ~$mHR$QLvWZDjzhFnmH*0`DiqJy_ zKX@E)ZK9=OU``ICM=*hzIc@d~n7vFYW`_7qJSg){lyBYic*l+;O;Wf72wObjk?7oX z>VG?%{FC&ihhNJkDXALL8&T^Odu5l`z$V8^E1t?A-Nm|uKHB`&VKFWp6@wmsIh(YX zJeUF>HY{D5>E1#qw0hO8q*IRBa*}5WA zx}gSZF|o@j^w{5rp_e_L%V&YZZ58)?6rcXsfLqCV-;cO2!VZKCi&5D=u8gm%PxJ`#mp`icCoUHja}^Q;$RmiySUiJP5f%Jhx|MZ;6z;&(Tfb@;-1yN zt=H-XMLnW@LG!J~s-6mQ!WT%C|9h+`LL<2^AwnZ58%aod#|vvD*B-tlHSzrYizmiG zvLfp@E57xFlZ#ENc&uMUhRWv$hiCZZG@uEjT}9u;Oo&gWWkc6mpcoAmUzoc+-MrmH z60eo!ELs2D%0LgKz=WsSP(g|%7uL%F8KNiQI%i69IObO5FP_QDoRbwSlY8^iq8)$^ zkV}(fXHd37n-wvA$Wk@H+q)5qJbYr2_4rPNc8Dx(M)T5`vp4O!6Qx}vwM~+_Aub~* zM$!zV*qhrY1*Cf(vqL03lvybj1SVo_V{-fOwsBu_+7MA1+s`2mgu9vUTHcT`OuJ?l zT0yCx%|+vv_9CWaxwNv#(wKb+*xT=HZ9b(5#9&7H$Rv;UD3YT4At!X%Im>UuiZYk|c8+n#1q zZy*KORBdgv(6a2G!%AbiiSSc81X+$!b7$XP<(!y3Rkm72--~{Snby~yQRy5SN1oK(E()u@0mlxLC_pvl_YpuN5{(+>7sFrA-R8O)?pH=Lrw#D7Q^Mow{2L zJXQ~80R$YfHtGQ_qSUrDKK`GCsR*A+rKzOy&EA;682m&sJ@;@Q(oXKWOPQLR4-^}d zWni!(-pXZ~ku31_fk(+(?>?14>+my0{KvabbxDR^TUpUcD} zNEWC&(f7@&6ik6Fdb7Te`3B^+@Se|H%(PAfr*wI4myFVSYzt265=n%swKJIY@X0io zf>T9nDa`uJY7>vY^;DLCu2}i=GaiCV9`XPDOkbiLd3Vf2MNjf~1^K)7Ky1Gl6HxM2 z3o3Pm@Lu!{!tiKy)KI-&cvSx&aPyjU+jL&#OHnIy=}~`FeG{cnzpkC3*`)bA<{vS; zmG@~3%0Akjnp@R~UX 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 delta 70711 zcmeFacYGDa`#-v~JA3M$vjGBxA|&+A>4lm?uc1jVrvg$ELZ|{EfRLadNEt-ofGrl3 zAj+bm21Uh2`6wS13lI*7QUtq*df&U7fPVP=?!B+?{que~KJ%WkJLP$HW}caOCNAAk zA}s~2YhatqacLPGS0})6blUn>elmH_q{n0nk$>kn&i=OiJ9C~U6?>1Qw33-)A{Fa$ zoasEZAW@KC#((lh@^|uXdAEYc4%k`dX>x4C_Kuc1v5AGT*@dD-s7K~a2~7#bXqw<9 zEd9mjQ#bspqsuPH$2s{+`A7M<{FeOIt+#*K@KSf{hA+BHpjHh`)<-y-LE2({L~158 z*=_&W9=E)t{r1*yY_;rtsoxiJ3Z-6 zXQ!Jf9;XKlt|YbY@K+VH;lY9K4TA<6T08yf+a6!g;r9Q>+tn#9pBHcMjSt#R8k*#3 zoP4jmNuDO}mlw+)%je}cF^%TrZ{<_+UGhY`-Exm*q4|dS zs(F-ofH~9b+0bG~ppN`+`9t}Te5YlWWr}5j`MkM}Io(`m!;&5KH|*H)TCV+1yI@{v z`oL5n_qTs#?`%G4wwSisJO59A8_IWf+fcDHZSR$x57D9-G441zf}9&Gh|fXb7y^8C z;3&TNpQ-~#5V(+C5Skg~K7{wn>i7>3I945aAAvWk1MeZQPk-ZGc%P|`Ka9Y3Jx~uB?9QEd&-+2M!@Hy*ltF0u!qPZy+#KfBhi5y{qFZ5a^`GzYcH5>iE|X zXi^<`wFsfw)u96jn5qM>a2)@8b>L+LuIQP)1n=4E`27g{yE>pC@UH$4z6$=e>i8EC z*i#*N0fEQ#hxWod?7`}U=MlQEIEZ&yM#t zWbNJhd&w5cfw5#g<-k(1nsQ(#Sx!0dku0JdI7sGD4!k2XDF?oh$&>@f$Rx^v zTLKwP_a1-6?XlE0+-{}0a08{;aEG``F&L9}r7hsLC{2T#UW&1W z)GtwsOf69}wU>%mHf0qX&n;8}R&s<&ei3Qod`ERg-E24|2@!L3m!|1SS5Uz0D&=P)?G zqtwqN2k|KzmYLb9!6d^D{&K(ZjX3#x`D^*4V$UU)NHe}Bua>~cB43yA+%x$FQzJ2n z``s?`n6S8KW`<_Oltx3zQ5(D5{jXa4CJ`Y;ymd7pYEO%StmR^3dFicAwtUgOWkgT|f4Vq?gdWy~;2hF=UH8lE+*G0ZRwG_*IQiNA`U ziu=V!#pPl|%n_R^3k%2)(mzJr7NIV$HHNrUZ&)4WiPJ39=G7(<$F1~^x6(V@OmAPE z&P;{mP1nu%dbb`fTJRQyt0vC?pMw^cvK>^-_(SPo`8q86)zoboip4S*ehwSet`R_;yG4k!TIVz~Vs*ar9m_XcSVya+^p=g$IL0zc;} z2u2P5BOrz_{(WFG;G5iQq$%)~B6ym>vlj@Zn16TUMA3+`EkHHz~S6}0{w~~089n;0794IyA*K> zsR@qT}sAKUZpb3}+ zVrU{?aC?XW_%RT@pM1cTV~XO)A)o-vIRFp(Gz5$s&U)Q4@$v^GRl59(k}6#OIZ2f*UnXgy-63g`eNfT_`+%g$^k3Je>S9R~>Rd^a zX`ZBsbc&=2G$d*A9D}hPlPy0?(!@DH(xll_(uCPf(q!qEG*LPvO_Hr8O^{6_O^^*F zO^&IOCPtg2NzovwLL`4lstn1mk}5%RO;QC&zLiw@k+YI2KJta6O0S5VkW}fBBa$jS z@{Xj*u0qm8_mZSZ?s-WQ+%u9Ux5p$+Y}+JFYMUiZX!lE+%t|CpV#Sgsu!WK)ui26& zuIZ8{t(c?Wg%v=8Y7Uck5oAJE#-wwGk4AM8QxBDD^dktNtBc~_(oX5zriw0>$Xr#8tcmZoA^ea9b3s8d-Yr-Eiv{FNB*~jOq}3@qD741t=i2bnqf;`#2RIX?L5V}v= zF}|UFK+W_%P{?dN4Y{4}SjZg+x%?r&!{?8LqOL&58+U{vj!-P(aE4q?uixeO`aQmw zFYFFEL(WLp9}fBAeplQZAqzOR^eaA>tY(*fPWHi=XxhS7&Mxh%#82_tQ-oHgDT?tb zKZIu~mGon!-C5d2RHppOKWyY%nO-yqLwUA?7w%)_ywFE!CkQ8nBGN;iCRB(ENDgp6 zFdJAd&L>&GCxMy39l-9uE#f@V4Y&^26<7l70$eK2C7pruft`T)z#wpnIEMs)lYoBU zD4-9RD;5$ju#Z?&NIdXlBEk&}h_i_c=m0u_t$+?-x>!Iu0@HvUfGNQCK$AF&v;%U$ zw(>OoS7009RWYBm23`QR0-gr81b!;!kru#Xz~;ciyqKpLOv0Znb>2CIo4j)iIz6eg6s9M2mcsO;&Qh43)L9DClR8UbdQxX8Oi!u;g6T=sKOT^jctF^0MAIK( z#!R71Ig%+nP1ufX^%KcfKam_^GcD4ZI!9}19#^V0bu#y$*4BwyQ%56KZR-&3Nv*Mc zwYKIUR&8u&?nAA$ZmqHH5v#VgIai`KSGYrMtxy-SexQt4G*>XHtrZAjJ?cdG8~3i( z;A>iYFCtoP@LBF{t;L^f4L*TbwZ$JIR&DZIT8m#ttlDG+Ra4u%TN~}4LaeHB?L;gz zE>XZ5xRp`BI$;HAso}k(g@y^zT&d_I^iVsHV#w8c&`^~XuHUjq%acFGr)Y!b-(BxPFBJQq^n1#ShBIXw4 z&B!a9nm1E@ARlpK{uY;qa6c^^of9ckpO}f5z|EK$2o<$agILl|N1ze>(?gNiH27=6 zA1gpT?m|G+0_tYrRQp)&7le@66nHNYad5%p(9Ed|LuxH1BkD6mMIxcu2*we7kBEa% z!$>TKz^hsy7KQhDA`Z;Uixx%TFC*fBsj~~G&YY~~8%E3~BKDjcn^_owzZCwNg;NU` zOhRB05qsoCP?4F16A_$2#GJgDkyw7=1cWB4p&2vg%*4ZLPU8{RA910=*yOx|1>+Fx zLPTx*Y*-;8@Jy@NSj4uhiX8)g10rS>pu*E*qY<(p6p7>&L>2q*!p~F~DS0;;see^QC0F(1YTBSr@*sE^-PB6$*R;i z{0~?8WALx5@<-ucQRR=oKflT!hJU)6NeCX5f3ZpMj8Q!k;TfWOCcx7tzmwkL@d$LO zdU+iDjw=6H_?uPv$H1Rf9rFZSE<&Dw-dn372+G9_Zi=;9;mGdNK`(2JpYi-}lakPxz_E#hs)vnD(sb2BCn=?xLOHCSvSQdM{c z(zvQ|ViWhXHcAvtvv1-qO;gP?u0%-Y~nun=kTzJd*#;Pu!(!FdT7|h zJqj-dhE3d7by$eNv;H4r!X~b`YDCz?&C|w%A`A$dxaqfsgH7DTn}fk7ZrFbe1)I36 zTLZx+&hw990HeSrE<=kN17F>nBfuuks`vlDF!YAGtTzd<~qNa57EvDxsp*1ZMrHqyPb;H{;5^Win=-aPvo+#kAk zCcNM4-Wl+It$U}#`-SeE2Jdm*dl$TK>)xsGz7i_ZN}d9LxgI?k-p6%s9Nw+EHwN$h zx;F~%3f&ulcY*E=!#h*=hTx6p-bwI|(!CSm9jJRJz?-dm$G3yuulvWr+fMh6g*QX@ zj)Aw1?j5ZJyU_OqI?{Fxa@e8DA(PGt^tJuZeu<2Vaio>j!=xhh$WSblhjCAev*A7> z7QlU2oCSBYm=AZImNESs`^2A$0} zY-XR%;c)eR9MZld|AM2{b8@*{SGLQd{RVU7Q%9mn`2x~U<;lNn>dCb-ZwXyP`Zu$E z&dK-K*U9tj%dl!KkjL0#av%FJxwE~O+`{gZ>)M;hFUsfaFUkpdqdkHAzqh|3KP{iM zKP{W>sW{~{+t1ss*^k+j8dpzO@@ffBTiCUGX>H}|8k))r&FptE<2u^+^(Og)No}G& zAlkGpq7-hY>xj@yZpw^1^aklC^L1A>-A!53-QpQjJJ||$)M(74a&eSo-*@Y$`hiI8;=K;EV~o#{AGjT7A)%nclt7{!MOOcP8_qoF8+qqIdo{w{;s^&z8urYWVycG zqoh@c$87^^T(4T42mHRUKOPM^yxxF25QOV-yWP>K#}#z?T`{D^1DdNP3HnD2=AFwP8Wr)BrCaI}e@wUBV!KY?Upz0Na0c5h#Mb)8ey=^tw#)u++t2be>>7v{Y;4&xtutBXSK=1lbb+1xO6+ZxU*!7r zz)`iCrN1U-VrP`A{SEu;3$Yin;QFyW$HZPHgSxT$-}eD7i0VFoQ88Q<)s2AfMJ!{! z7qN`_LEI>)OWg~kkG9r5O?qpK-A_p`ZMA!h^widXhe;1@6<9%Xv{m4KlC3R$%1M^C z^m&qGYD=FTq`S8C*+RN$OP_V5tG4thAziek&r;G^Tl&l=owTJ-J_(W{b?q|+x~;k# zoJ9QE+GiB;X=|Tc;?>qZeTYX}`(zTgw)P1Sm$vkA5T~~EX+<2`(kGpC)RsPJq=UBf zNg?gErH_fUQF1JrSSj5QrKkZQJ1z7cZv*qZKK(+ ztD>82OgGeLN74;Hk$YJW>nac{4mhJp|J905V)l8g?^js1_b_Gn}i12j2`2(!v98h6iZjqW(7{ z`rXRAua?&5W>Rl0-0NnzrxxyUGn{kl)od*(>t<5st)%W+Qn#B)U6ItJsx@7-w9Yrv zI^B9r+1cC>vHbsa3(K&*?*H!2{NL`*7+zC3^uHUx|ECS$`G!C13a!j{;keg;4d5LQ z7%(+HV8GP4!7xEA5`1JhW(@}!2Dc3v3bz><0=E&#g_}m8cL+7fAh;442-l#h0+?m` zW5T~q`oaB?^o4sxonHA1>g0-p3T!0vU#Jr+|8I3#<&UY8D*v83rSgZ=36+0Molg1C zeswnGpI7HnewR9v@=vJqD8ECUMft7j9LjIhHjme8d&ec}n_Z4(ui0*IfWZJ>@?b=QLikP&18nL!qi6(oUdAUj9~rGRRHYJzHkQbDyrbwG7N zX`p(b`k)4&hM+q@jg-3{Y`k@4Y7?YmuI65bZU&-aI=qt$n6ZoLps!f&lBg7LTWQObo~J)a%~`v0sGs@et*Pcfjc)z5SY&*eG~}IusM`~X zc!CkHFX;9;QSV5|9SVA4UR50q1$>cU% zJwejh_%e9y^ap&-KrHO>d2kRG2sk~?SlkteMw}tP&*Klr-ELLa~55?)7^7(TLCC4m-U~!=8vI;tPg+ zv5>>#M!iEpBnCV#uQN(!YWc8h{aR;JbA&|LYf9_mMdpYqd~d*k26;pNSS;=jMPpuH z#2bvn!(j(R?286{k*G85iAJ5yXuuuyV5#76JHw%f%i(o}Ls1gY>*)0dT<&1R=?O=` zses=Tj>QAsxF;HS_`OaZb;H5hV6z?hMM9B z{6fKCoW)ld|Ki=+m($+10G7HVzIY_!jye4iuP5&G`<(t*$P-Y7>F`CP(U{xo!=Y%@9ruI+@rWzp z!Mci!*0b?=-CkEb=J0y(O2{2WAnc7h-M$#U*8tk@j=DTCKg912L}DJ7Ba#~OhvJc- zKjif}-7Yd%m*4d7liBR4cU!M5LVZv0t_Z$1pEn$gM!W%^+X-2_Jgxw`WjGQJc^&xT z!R?Sg8uJI80l&-b3PmHXKsbtS5cR~p#Gz&D4&m_D6NttFE>{qhcLd@w9O1@&e!tV< zKt~9p_3m)c8FRQ@(ExIA-iaJ=up9M+e9i5q5?nafc)B_Xk5>KUu_TG#C0SBj0cRbPvBNhigtA z<>Z&_=j;igzrBX-ecL_K9_wb)ak|TV+5CjK)I8Cg#?LeUVcciTH?}rZitiX6Vwovz z>a$5HZR)YSlr|P+PD-02#v+Sb{nDM$pf}?8#6yuVI#Co%^7!4(U>HN5%i#~G5k6-e zlJmL!5p4aquzdD=BWQfc@Atb%KmAKbJN)5*GX`sOuvitF7o80K*Wr$%FF9P{fE&HQ ziB64g#D!s0b~;^=nA7LLw-ND^nac6BHc`T(ANuRJSzToP0>X4e9WlQbERILx;g~z% z55}Bvtk@woSj-0^PH;6I3q|m);E9kI1GP60!uJVI`!QUT@zPyrq$7fW2mK7 zcnrgJFcgmXW1e^nCm;b|JmkPr7{~*W2u7pOShNHmU?||nJm4icS}PqGSNuT?rBO!| zJaFPDKkV{_VlI!*<-kZ7_F!0xdhqo}{1_Kvm>96D5Ol}G?r0<&c9X&S%yaC0F-!TP zL7P_8lmP<|V&n6eJ-tmM)*-!3jxr;?%}WNJG-Rz>f)D6__==(quR9jUu#2x2GQr^E zi+O_{U%>72#-rG@P>H}`>GyS7RgTwg1B4~u)6^eNs z&S)Hc%ja}p@WyxS4Md{8I69^}6M&%@k6eym0N)0BRm>43o>iHx+JM@E+JV}GI)FNY z93Usi1#*MbXT2aF$PWsDf}l>I&Y&)!uApw9?x0L1vsKo?uZx6UoCB(qt-#hss^niF zy`=~3Ur0kFv-O~LzC6?Fuw3B(u&JV=F?DJaL zrBrUtJ;3sgiapqrM_Tt|jgN|b*f(#&Q5t9WRkwrsvOSNr?#Tu=%Fbm!r)6iaOwZnz zo=J=FwW{$(e z^YfLBeR66LHe&9Y+-zS2Q%*b@QMbaPevcbH1wA+D3HTi`p9j-1bRmq)=-TRM6N4HN zMz;w$v76?PMWSKoVqec)lT)O9Y91&7&Io#Lz~%Rcp;}<~E#wVhTF1=i_V}QV#4zPY z(ThXgXwVgo`!Ia^LylM&-74bZzS6SsdgH!`%jH2=^f@pWd!Uv@RQ&*xq}S(&2K_N# zD1wP6>h`&zPxu27ITnh!UEX*w>UVlQWEDHpMChdq>YuZ>88tWN+A%RdrzvZbpVNXG z>Iz%gsQjEpN`8LMZc}gDa`ZJX#tIi^2TVv%RAT7vPA_%}FM7q=MCi(VrsFx?~3de9sC=v^L9dU0EW1c(Y zazXcUM;xv=`mH;L88#RX1Rpi!XFpfRAapmCt_pb4Ofph=(*C=7~# zqM#Tk4w?*_0-6fC3p5Qh9W(3DmR0LWIDh4eBEeEXt-2+;wWc{(~&6@S&{9_z0QEilm z$#=-IOzhv=U$w8XkLG`3T~bP%wLX9q=%*ZrRCCGwmGFFU9y&27xRx< z^QVURt^4;*Q7D8VX$rn{@qj8f)IZ()JJBuI_vl;4z&%u72olv_mwDQj^cHx=Yj?$r+*W;>Rxgej8ezqM zo!>BNc`u%OUuD~X48(+PvW?sLPc9;{hsH&5(M^Vm3p)!7cs8zQU!Q@y&`DT8RKK6L z7Dn)FXV1RPcrM98?MqsVS+rQ-Hyli)v66 zl>V3{v=xprV}9w#M3da{pq|E(NUy-K(6KeP72LQb%>+_&Awh9b$Zm z)y!Yx;msj7Ab(9m$*{xpG0hN8a;3rsWoG^wE6>}yDt8yGdBakKQRf8j_d3EczXvLA zAR32i2em#Hb3q}*2B2#;syoFTSOP@ik!U33$6^ioBc>LI!!b6F*~R1V#ju8oIk`WzLD27p z?j8sRJXqkl<1q|-P|Ur4H&%`TRLbvw-iJZj>xWW@dOEx@EZss5ELgCH@Vlbid0n*I zW~XGbkxSOB?JXBzZ1u&l#0)@zig_Gi7lzfK3-3gsXt_fe)Ugr^JJE12+T@I5ffI~l z5)3<_Fk>bFgSg+6c13GGy_(XJOd!H%hHcm?n!wK&JF`0ZYXi-UZNolEr@(ce@ zKTJVuy)cQLrIgKD`<|$-_}20PpWo&4hr#AJbn6H-KumKnEFWB+px+e;I>AMM&>IMQ zvAp$ou(ZKa)ai3YuqqGwLUHa_jr&lFJ>FmlnYf{%I-of^pon8_3|$(ts7F-^9U&Jq z%&;5lCoJ#Xvdb3;<5> zsmq_3Hync!?2ZH?J_r^{Lp%zxJKbKanO)Fex$jwx6JmeNl5113O4@{W`mv^rc_Y|K zhZ7*2=o+H z%ZNn0?jR=J2=*($KrAq!vPax;%(a1F(1~3EY)yo);F7V@LWXXvW1}HA_aigiy>?A6 zX}6Y}!wu_QpSpgFVAghGzr!7iVF`dW2|hzdP*Z_|_&DNj7iM1=^g3g)NF)&OL>%D| zVV~c<_G4;$k6X(&&t99|=Oeui!2F z!)n?Yj$X6=9VqC$VP7s?k&M5bR#w}kQjDn=xF?ER@4u!Et z;)^@c5bWqgeNe@q@nCl)jBmz)?E>hpsWEI{Mcgj%0zd>UA9m_RduAW?k|P=#fE2?X zWlSyR_erU+nX}-IS7JxX zB$BUWrk8E!S^2SVIwh9YnnY5SDNV~F1}c5S1=u^UBm)*B*`}KQ8_B~;jW%VU^3?Vf z_Zf4yFWbb9w=U~df4G)Phv_g>2W-=GTUq;8l3UrL4rLW=ZnEsqK1Uf^W&WJ(qq8{0 zvV)u<%6A#C)f8sYS!}+WWS5=)=BS{2_;y)_V8@>ru=6|x7*}-B|1ZB>#&m8qJuzswP zbFFL!VN#Q__jLX<@Lyv4zb{)@$JXk<{WgDus%`wC?0agl3NE!yR>7&PYgN{rDEz>0 z|KuC0aTfl74zK9&vJNjPq5j`aC2Z)QWo=Z+XpPvwW;P=mSo{9pdR9F7!c(*&#bDaR z*%jLcDQvA_K5p8is;0#zWz*rO@{EGrc$yiHJ#~i~dBC{p^e0b&o(4Sw+68(R^c-k6 zs2sEh^gL)U=mpS=pnV_(v>)^m=w;9=paY;+L9c;c2UUO$g5ChV2|5IN3-mVV9nfLW zyP)?#?<=Q2`Jh>VuLom`ln*^TTUztOI9pfq!(Phynjg*(ifj`|3HLkS8Mu_Y&UXUN z$IaCsFrWLC4*;idzwmzGB<^S42OP!y#Cw6c+>g8m*oXUpcLOuI?|Bz6z+K~=KnHh~ zcK};)-|-!R>D(2*12B!d%(n-oaF_U^cJP?Ei+o!kF0t@!Y!kR&xo`N^z^mK^z7_BS z_ch-Vc$z!Uw*Y?1o#UGWk8w%98SpT7mTwBI;41kh!2R4AJ_A_Jo#xYlPjaXD#=sri zS9~Mj7Vb;_4&XZO3!ZPdYFOP5m0@*{?3uzZ5EaAHBR}$)JTDgW8&$h|aVfu{3Lnt0 zh+kiY>oi=#-(Q7mHC)WEF$zkjF9-CY{4zim<%)N?+z_I{`QJiw2Xp9{E5`8iUkl0Q%RLIh7yem3A!$`=5R zQ+^iUJ<8_;-lTjU-~i=k0$!y248U`gpAIOa{4~H0%HIWekn&Rj4^Vyz;9km4UiI<0 zqo8A;CxeSJRslMU23o$H<0IbjURGKxL)mCNooYG#TV<~k&r7&BxH$u@M;^_x&h z7aWkzH`jsgm00>~dy+X=oQV*$ogma@2lnZ(f^#_nSl{@tDd!Ek{W!n$LzQx1A%)Yc zpac4~8@p|BkEW!>!#Ki=c|G)MFTDsUl7&5yg^6RM#*kj3esDUjHbGp!*T;_@Lp;1F z#ZX}4%dumy4Im~y0j{Oe4`@XBI5dI?iD!WQ$=iwb!w`NwFn$dAktCL(wLL|gs$<{O z)_~+BUYa`-rz9OUg(X5d{~|w(Tqh6X`UK(jCDJENAsK91N>Wyq z9!V}GtilXKq7ZK7b!TT%lFiw~f*TE$ij?FmB5Hp;fnBSESYM4RE+w9td}yqZ!@`&@ z=9Ee0$uwShZC27vYWwMJKbf)}1<7{Ujn}#hEwaxdBPm@1$fa~Kpf9D10NIq@4d_hi zLV$vkl+FRvptKNRp>#F?yC4PjS=`Ny1K3RsD*#VY!*ak* zYFGx?N)5$;4b-p{a33`k0q&uOCCcK$1&5Y~8`KhHOkh(x)^kPhR5t*&ne#gst{9_LcS`J2;?o&#H6Js&mh( zbI+=C&#H6Js&mh(bI(fWD@XPwe>Sku9m<-qEA3EJi&v6gvv%#v9#(b4uFClX$)|av z+Hxa(fL&@|HnqrNJj`Wt3C>t#bQ&#&6NX1@&)e=bHRTdGDfpB-Noq@1Z2au0f6?he5A5i?YL^XZ_FVgV(IEQ7F9n-fET9kaZwk-x%WX&2XVqefie$S)`Ih=b zVnrV$JF_G2Cp!xPVKV#fy<~ef@o+Me9Xyo0gXIrsnjr**{%WWj%X}-@n|7yUF*{?x z((km@mca{sSkarwM(kLL)Pfg!vtlzoI1eS0^=Fp8^o2M9MUdU04%2M;}#WLPZwx|DiD9s>r_fj&o>aF9?)HMj*I#M!Tk4T9NH4H-6 z&Xmm2BTnDF@(y0;qSnf9^7)0%H=j*heZD2B!3&+VH0tsQLA}Wi*RxG{A;6BkmF&Ck z!(^4649)x;&(C9>>J*%$95yNg&M3!}6)ux$vlW4(W;Ec_0hY5YzyJ(0emApuv0t4}mYOv>)7 zhK8waY{u?u`bP>c3}vr=kjyb;Qc}c=Y)IFp0em-ub(T*0szwu5_y)e9?+zxjL@xv} z0K7~(shg5mmFPwrYa0abt%%cFgr_|vqxC1s$Ee?jTNR9-9XoO{#UQxupd_fKrtyOF ziDSw1o;K55j#3|Ij2rHR(X#KKKcP9F%KwSK$#RukdcJ~5BW#xlS)iWWs53K*Jj&xUowg?9dP^E{kt#K7O@HE@+5{V z?nrvEKPpboW)3=HGA$moHZ>8dmq*g+GHG4PwquJ(7q;}=bN)odJqw62@!^iem~3Bx zd$0OHJN1FCY|-TBTPAXcEF^Uj+cM_jfqUU<5BL+O37&X;UbL9}g}auVadjdSIgLmh zol;B=A;K_2&7(8>)PdNZ+Ephxr|U^V zS3T*%>gTI#oi#&G;@j&E=zlR zQvWI8@^ilmyg8wad*NJ;NaeQJ;2Lu6zAS?=O>WD-t@(LlU8ViY=ZHv7RHfBMm)4)- zjj62s`ExyaW34AD&gHgMm#$VA#@V;o!jfP)Y%VpmFuWp;6#j%kTo#wXEo)dZ4aekU z*;E$V@m(u+wceGk>{8Flq3rUy$~v33e@j_j;@c*y$y*njT9$=M?$RC}&uZ+#!>9js zW_ve}uagIVC(jXZIdMj=|CK$LBi#|`bTb8ZO1GtWop{ITVM~Xf>&9|xpGjqt4qi5x zhL=p$N?_OQe>7#epPn(Y(=949SpWW&V_5lPm(n_W{2gE`g#-HAveZ*>n0R}NT0xJ8 zg@*pUF!P9#DOzDY_2#7XxnW@UFRn~y?+>gTsg>5nscVu}2j-O9`c4o0EYIhKA^E!OD^H@vq1bzSIBS1;m8fN5~ zs?06RI+sK>e$-&aZ(MH6j@C#{(xvOxc&D=bKijOQxUe~YTZ&pIH`rl$R(BJozY@0_T*k)#?K0F;a18Tb%Y(&9kQEO<_qQ_4{*q8t)wMqM>sMzA zHO8w<#Mb(gCwo^Tv@p}yqP$y(JM z#4gxg(M(RxNL#oYQm$mOR*;_7dDafxUZ{u{W~b?bX@_YP`NWi>o^KAv z?f_vAT~7{Si5tm22?ur@vsv@MlDTa8FR)CT`BxH4g9w?Tbe~fiCG3~)lYQB-ACi5I zhR3+RHeN`Wg)VsG+DSf_W&D`TR2mhOP9aoQ$aJ=PcIj7aPhn|a_QTJ~QnvFWez21I zYw~`cUAmrJ&0haKc}n^EMzR;rUjGXU+;eK)nLi}^KrQF`*oeT&Gd!96kk41{xbVb| zZyYVukygeZO|mC!?InwKsAY?JtLb6mcEb+w5&D?$1pg#?n%ku|Yo(=lVJY@Q2`QHK zfoI?YmHrkU{8iCF2d^^-Yoq^=Ms`u8%@pFY=wUETa` zT4%2Z1q7Vxsop<^eiWdkIB<}0TZ&pKw=*%MVF77yvjE}m1q?V*RX`W7s{<4g^_cOt zY}6E25T=8-rKm;woLcwA0IV~K|9k$g{qVKkfG%D~2iWRj__#e6Eya(EY`5jFmW-dAn zGPmf$^{9MwxLmi3P)~8XT(=dV6^(Z+F%{XP4@gOz^uv+;m|nuYT1?`tVIxVt?C zwL!Fp&Fb`ZHPcI${5|)~W8YI&WI5kR`*gZ^R4)3VV&0yWmV!gq+f&ryU49?i^UQZD z=Svp5*dFr!R|`_mDAMhDY;uMBT`jH zpd=q1=@l`PcwG@6mbjn-_( zw#u<;6I{*?e!r@?-Ij@-f`RDv6tzlT^sQN)&i;Mcnpv_yEn?X`eMr+L$WsfUw5h{H zCugvOU)8{Fm#ChCZgP7`sGTps%J)25t>la^na?^t^leABbY!KArL=&Ss^;DWdEk)u zzp+VA3H%oZs^w}E+~20(*7aj<|(l^7QVP7 z!Lp8s*<4pjqeVU3 z#;uGvj{b4-9d+SvnsLABbu99)%w(1Z7yIhOheaE^P`ux*6( zK6Z~`e1Ze{K-dR62=&eg1cY;S=;KaY!p24Vug3|?T0j4#HmR@H~=+PI?)({=8G&hXcFx6WeI8(#c) zfIXpRi<<{9D2?I|uz5TI*l*x=32b{{xc~za7mQP*L6~-+wlMF3y#&l8J?S3#Q`)`=F#gVS~h$nML|V5$wr3WA1Rk5pv^R0Zcs6A8_vo z*CSx%+~%OPlV&?K7jx_@=DLd2&npDLb-#04Yk zb1m67vHkfBlF44)bYUoagI(yWjM#i3PK>>=b*E~?_chJV#75wmjTdg(Z6{vSboS$4 z@zqfxTk?GCY<6nP1p|&Tay13rk7Q-mgBMO}W_DZI11O6%`yOidkQ4mi#KRXdNEUl- z!-YZ0m;_h?>{aSRG=i!gYbfA*y9J!m@fius9>0!fpV@JVhJZQ2Q6B&*;>8*3bUE}<8 z*poLtpHpRoP7{saQJeo&o%3eqs&ziQI-V_U-X@#9TI>84=BRzXA9FUon9a_lp3h|? z>YVTUQ2IEPXSL5i#P+9TXB%y8xzE+L$^G}>?;G5-Z@-jrIg`CtH+u-7i6xaa@WxAZ z&ktd*-hU~F4NN;fnEmjfn4_FZJ3n1ur|Y1k5vizHHRspn`!dN`_F&EPeX%~_PpHz^ zsbGwxx`{iCt$-Z@$Xs)2Z_T6~;(kW&*b7@_oNjg3x)z)#Fz)WW^ z*;n0p?virnxy#R-GzuJd2T9sLDjA{-a{R!Eiz-IgJM}Zzz^wyQ$)c6MqtnoGq6oQKF?JHLawJbnXpTlZYSEa|{ zD_&l-8r2ueJ(YN|a8<>7S3ut>74Kc`aSz>W3Udid{sfZ4`sZHzMrm?&RHpVfJg(3U zM<27Unlb7c=zGu)pdUd$fqn-40{Rv7n=)h6bv9CPnas>sem$o4{%uCZy#M+yt0Z+5 zNoWB_3#hvbBcH>mDjtnj7NYwFJyXJGQo+UVs@%(?Zv0qdhnkF94Temv7V8-qXY0%6jJ2_MYb za9JS)(=j(L;Y35w7+|ERnu@_p50etCKpikV#5f&*!5r+&a36p>%o^3dUWZ~09$JSq3v4;&fw6VU?son?kIoO(u+sr%xb-j)Z$-Hcc(53cBn>fIMp0it61Y-&v=N7 zb?3VGNrc*ZF-cL1*i;rNv)%H(^WR zSyk9fK2Vv9)}|jk(?Wf^z1rH-gU&W&d0kF+WJ9K%tIKka{AOY24^;MMz55PGQF6DR zDkn<$m<>TeYYSU@_^OFLl~_N5-S0a&*pAB$mFflq>@VNRx^}1un918!ORKu> zlbJVg@(p%uH+C)8eXy|snsRmNmjRY&31-dppX{$!d;H6lqglAq$#Lx9Qiv~> z@(|dW&L?LPCtJTSBb81D_7{whb6#_6$mG#)-foH7-r?VaY z`b=49#g~M^nov~eq~C9J)cc&U9{+a^A%kTc`_9P@{B+|ZmVW$Pj#Bp7)gz?FjM6-< z5Scs-13ANA6Sqtxjjgmg2?T2fi&|r}aB_u|ql( z-NS5Y+V$F^7uLv*o4v`SdJdcQz^Qsl`db@cC+y80kb?HTyKnUd zcE_)00*p7EZK?JeAM0>?`GR%z2s+lHzx9FsDbR!PJ%wP>d$TLFVvzx7 zTUK=e#Thu+Ts`8EUs$>0_kBBV(50y+ZZ&87!1{&x1>>)Vnl#EU<5sgBXTL5;U0%4l zMv6L!!F)kwr;S@NmK|SkqY3-v;Tzpq4R>-7Tf7b1-9w9D)t`IjT!Xq<&gQ|K<~_5T zXZxFH2b%ZHZk`=%9`LZ;7tb{hv~S+CNAsYkc{ZNNZXUpYcp^vj1apv<-8{=*6&37J z^-$1_G?x2pGL2=9NYc7mj*jL5hniKu**q(!qaT;l+0I$t3`$)-VRedDUyRLF^$oIX z+ir|wLl0dTWD9n3xH>t#9h~T99!5rA?8v$gPuhF-(rTL1JcwGOLRu9A9<4$MqrRwF zuxImNu&VGZEhgKCT4)d9i7YQ$YN@QlhCQ3CtyPo_OP)&=u*70}ioO|~y1dP5S*uy| z>Y6RbQLoC$d-89zV?#HBliLzEB;Ms`Sv@M<%r~}DZi*^-!91;4m5Um)vYKb9bpzW_ zfgIMT@?2dp%YjIhOAW=2xGvyhJJ%<{j__)`R%p-aLRWNU8MAIo(97Gp0p)qnJqA<; zyMvi%p(d~_AGoTPiChD!%&KGvUlnV#L|xE1&G9&@@qh959^g?`TO07~GcA24$xH$X zgoMybGA&aAgh^=96FMT04kAiGkrE({P$dKuwn#YvQLqq$g=5VGz1QwFDE1aaMMW{7 zS3eQqfA=|OlF3Z?@&CT(`5#y_C$sn2Ywh0FdKb%mJQBg*E-@miHj`BD%mR=az6$G{C`1HC0tglS2OPOTT#M=Qvur?23U-x$B2X^zo%vf|XXAi)M!X zH~?evx1sdKeP^fjbUAvv%x$G0%E-!K@YE@mR0~YHCUU#g z%dNV58@H}FiN0czisZ#*S3;B)yP1;UbN$=6O`)5=IvYA!YzxQ{Y6Y!Rtc-Rk+G~Y1 z<_;YI_2yy?MJgS!h*_(qsr%1%<SmnZ({Di7&p2!GbkH6tUcV@PwKc!`e!njS_7= zk9AL}@uJkxDcUN6b)6)smZa)JEH+-qMU#br!-2!bgD1a-Vf)rIR{P-T-VsUhOxR6RvzIL)5li zj*5vf7@#?>J7?@#5w6`bd1iko9qcPrg#ol=mUI)2%3&m6?Qq=KA;jj%Z0JJzsj2CaD_!X?Ya=xDjzQ&9IWulq z=jvv%^^p7FWBEGMwDuHXutQJY)<2%|SBGxp-<>>;BvR80mqMSfC8|UgiC z7j@aLffbXa>?F(CnJQ&xc6)a6?kr-MT8ho>IdWQHzhrk6GJ`0Xq?-zG9tP7>9LyA_ z*^6ATAuomr2RjghnGS+r0#t-`HcUO(<|eIR_>Vt%j?bAnT=--pkm~&k1t+Z?PO94r z1tB(pWDbs%%OtVexY_>2q4zD0G#lF6@wp=s=;U|1ZsMEE#*rRkdGrt07AsU*lOjAq z_k!+jt*P^W-&WNFOQK8_8K+)Dk3XQS>1M4SAIgH*?<`=8 zCc|zS1e>#;_SW*AuFFWwWE;9Y6gLj|M%%0z))vk8AlQ{|ai!zV)((SV7ki6c*!wcx zr13=dOCecYf!LP3vF#2kpnR}#^$}&`4S6u5e{7%M^nGiCW~j~O%|K2ZG7yL3au)5r zj-n{O)eIF-25yd@jcDOzIOn&T;cwuIZ*tW>$@yp0T0ONx+3nQ0uTv;iQ@Y8PVgj6m z$%aBX+EEg+L*xGGCE=+Dlo=c_3s@iXzQF8)P%ds?k!o+q2Ik+$h%&_Yt?ioOZNl-_ zVwVwPyg3cVTPT>*e&W<X#E{p$~^%MN`>vK zS*Wl2Y$100RiACy(oE``2{8pr<5efUJms55S^-Z|6h&bXvMb?T?e*}`_Quh(2(Et zj5x%3Lug%%KAG;Ca6U?J8I;(w1PJHzZ!PSZo`?ZI^QQA%BfKM=^a4E@8qtk5y*cF5 zkpn7bdUKbo;EZg3l5z)sfFFsuld+pSR5%;l+lO{KqWb_h^*p2Q3a}1&t`Zs9X=}k{ zLy+`ra`2~q7)z3=XVUe=Y@3(FH_}}6_&bU5^sApDzAWoiF~ciocLf*QE+=Z?Lw`9% zx0LsalWMz^c9{4AuL1wrnLa=pit}+(1LPclKmj%g(-6#RF)z8$Jc0g$`=WeHTAB;@ zI?jS(C4QlK$JPF|0#u2>*;tewAvE*!#bl~l+WL=b#Y!6n8@kcScXs)>*IKl?>FxRT zYjq!l!7RO2Y<09X|B&413hgc%GhhZXlF5pbewba_U_1zr7KZ19UTBA^4sJfomVDU8 zLZu-OxMH|(axeqa{Cua_=;+aftjF0*4qCU6^*F3O#kQ5Wva?*YOgi7StOL9<&2YC7ZWaA&8{I_Ogf{AJ<5%0-0FWMUz!W@ zMY_7dUNPbebFYf2Uhx7N)%*V9u|`D?y3gl`?Ld~r?o1O)3bNplF8v%8KI_hZCW_rb zkB6tmi#1NSl(t61_zy4dg{d*rvWgx~H?1Avma1ssqRToZW(S(P=yEKUbaDyoTGJWZ z%$2QOI}U_tCM&H7>icV>n`jqMkm0n5o8WOx*5xktO zeQyg^ZPQlvv5o9B#HLa9f~EVRkVEKU!i=XVNMkULB?dAMR$bEi1z#v+mjsO_^8%c#LwM4m1Obmf#u{ZUmF`jw4Qfk65t?_ znk;LgY2%Q~0}tHZhy2H*Pf7sjL1V2X^=!K1=I{G^zP!|(^aLPC?+?3rGA?!_lz3V* zG6^9y4Qh}Eqy+_mf=0gZ}^yOJxSE7%hl4e$kp zz`6mj5TN4#j%LP9Cyc#u3k;KNYmvG&;v2vh;^v;g$l=NrVB8F{APuHUP^|?v1kwa{ zA;w!1Zm)~-3+%uwvN&ScUIs`1McB45tRD4Gux|f=<1a3nO(1F!{(^~A%P1TBYo2KN zP6tI3_gM85QSN-88CZZ<0F!lC(*v9cKD(T-u!k)?;1jVQPJ`uq9zf{9^=A!}z%#S1==CZat$CJ0YjLan0Y4BqSoqJjCJ93wN zBBZ7{lGHRuk;dj|qHkagZ!NcdTcv?`wpAL*iv5~f)l(E9FM0HdF=QIyTk)mw6eh)x zcs0zc%4fuoVJgo{17gS+wW?8lw{$BM&nKH=$Rm6+J|B*PV&v$EF5wq7Lh@OhnfK{;Q`8k>vF(JLXpCrm*-Gi zLo#DH-0VTu#R9NO8)kC`6>0%0AiTZ-Jfsk+4aGnU0kR%Y5V7+BsvOob1_whRgaR?} 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

elj9!cB*XJ88r00E$pyMSuv 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 diff --git a/ontology_platform/docs/phases/26_05_22_PHASE_INDEX_engine_respect_backup.md b/ontology_platform/docs/phases/26_05_22_PHASE_INDEX_engine_respect_backup.md new file mode 100644 index 0000000..e51c766 --- /dev/null +++ b/ontology_platform/docs/phases/26_05_22_PHASE_INDEX_engine_respect_backup.md @@ -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 선택 지원 [신규] diff --git a/ontology_platform/docs/phases/26_05_22_semantic_page_classification/phase_01_001_current_flow_boundary.md b/ontology_platform/docs/phases/26_05_22_semantic_page_classification/phase_01_001_current_flow_boundary.md new file mode 100644 index 0000000..d37afc2 --- /dev/null +++ b/ontology_platform/docs/phases/26_05_22_semantic_page_classification/phase_01_001_current_flow_boundary.md @@ -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가 명확해야 한다. diff --git a/ontology_platform/docs/phases/26_05_22_semantic_page_classification/phase_01_current_flow_boundary_report.md b/ontology_platform/docs/phases/26_05_22_semantic_page_classification/phase_01_current_flow_boundary_report.md new file mode 100644 index 0000000..cc9cfee --- /dev/null +++ b/ontology_platform/docs/phases/26_05_22_semantic_page_classification/phase_01_current_flow_boundary_report.md @@ -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`를 우선한다. diff --git a/ontology_platform/docs/phases/26_05_22_semantic_page_classification/phase_02_001_taxonomy_result_model.md b/ontology_platform/docs/phases/26_05_22_semantic_page_classification/phase_02_001_taxonomy_result_model.md new file mode 100644 index 0000000..959450a --- /dev/null +++ b/ontology_platform/docs/phases/26_05_22_semantic_page_classification/phase_02_001_taxonomy_result_model.md @@ -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이 테스트로 보호되어야 한다. diff --git a/ontology_platform/docs/phases/26_05_22_semantic_page_classification/phase_03_001_signal_extraction_layer.md b/ontology_platform/docs/phases/26_05_22_semantic_page_classification/phase_03_001_signal_extraction_layer.md new file mode 100644 index 0000000..23b9c79 --- /dev/null +++ b/ontology_platform/docs/phases/26_05_22_semantic_page_classification/phase_03_001_signal_extraction_layer.md @@ -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 흐름은 정상 동작해야 한다. diff --git a/ontology_platform/docs/phases/26_05_22_semantic_page_classification/phase_04_001_evidence_scoring_classifier.md b/ontology_platform/docs/phases/26_05_22_semantic_page_classification/phase_04_001_evidence_scoring_classifier.md new file mode 100644 index 0000000..98339b4 --- /dev/null +++ b/ontology_platform/docs/phases/26_05_22_semantic_page_classification/phase_04_001_evidence_scoring_classifier.md @@ -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를 후속 확장할 수 있는 구조여야 한다. diff --git a/ontology_platform/docs/phases/26_05_22_semantic_page_classification/phase_05_001_analysis_llm_policy.md b/ontology_platform/docs/phases/26_05_22_semantic_page_classification/phase_05_001_analysis_llm_policy.md new file mode 100644 index 0000000..3951896 --- /dev/null +++ b/ontology_platform/docs/phases/26_05_22_semantic_page_classification/phase_05_001_analysis_llm_policy.md @@ -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 테스트가 있어야 한다. diff --git a/ontology_platform/docs/phases/26_05_22_semantic_page_classification/phase_06_001_pipeline_integration.md b/ontology_platform/docs/phases/26_05_22_semantic_page_classification/phase_06_001_pipeline_integration.md new file mode 100644 index 0000000..8d8f763 --- /dev/null +++ b/ontology_platform/docs/phases/26_05_22_semantic_page_classification/phase_06_001_pipeline_integration.md @@ -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 때문에 깨지지 않는다. diff --git a/ontology_platform/docs/phases/26_05_22_semantic_page_classification/phase_07_001_unknown_pattern_storage.md b/ontology_platform/docs/phases/26_05_22_semantic_page_classification/phase_07_001_unknown_pattern_storage.md new file mode 100644 index 0000000..94e0339 --- /dev/null +++ b/ontology_platform/docs/phases/26_05_22_semantic_page_classification/phase_07_001_unknown_pattern_storage.md @@ -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로 넘길 수 있는 구조다. diff --git a/ontology_platform/docs/phases/26_05_22_semantic_page_classification/phase_08_001_tests_regression.md b/ontology_platform/docs/phases/26_05_22_semantic_page_classification/phase_08_001_tests_regression.md new file mode 100644 index 0000000..7a669e5 --- /dev/null +++ b/ontology_platform/docs/phases/26_05_22_semantic_page_classification/phase_08_001_tests_regression.md @@ -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 테스트가 통과한다. +- 테스트 결과와 미실행 사유가 작업 완료 보고에 명확히 기록된다. diff --git a/ontology_platform/docs/phases/PHASE0_ACCEPTANCE_GATE.md b/ontology_platform/docs/phases/PHASE0_ACCEPTANCE_GATE.md deleted file mode 100644 index f909b13..0000000 --- a/ontology_platform/docs/phases/PHASE0_ACCEPTANCE_GATE.md +++ /dev/null @@ -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 필요로 대기. | -| ____-__-__ | ________________ | __________________________________ | diff --git a/ontology_platform/docs/phases/PHASE0_NEXT_STEPS.md b/ontology_platform/docs/phases/PHASE0_NEXT_STEPS.md deleted file mode 100644 index acffaf8..0000000 --- a/ontology_platform/docs/phases/PHASE0_NEXT_STEPS.md +++ /dev/null @@ -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)를 참조. diff --git a/ontology_platform/docs/phases/PHASE1_ACCEPTANCE_GATE.md b/ontology_platform/docs/phases/PHASE1_ACCEPTANCE_GATE.md deleted file mode 100644 index 0975e62..0000000 --- a/ontology_platform/docs/phases/PHASE1_ACCEPTANCE_GATE.md +++ /dev/null @@ -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 항목만 명시적으로 선택해 작업한다. diff --git a/ontology_platform/docs/phases/PHASE1_NEXT_STEPS.md b/ontology_platform/docs/phases/PHASE1_NEXT_STEPS.md deleted file mode 100644 index e5e0bad..0000000 --- a/ontology_platform/docs/phases/PHASE1_NEXT_STEPS.md +++ /dev/null @@ -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)를 참조. diff --git a/ontology_platform/docs/phases/PHASE2_ACCEPTANCE_GATE.md b/ontology_platform/docs/phases/PHASE2_ACCEPTANCE_GATE.md deleted file mode 100644 index 7bd0a5b..0000000 --- a/ontology_platform/docs/phases/PHASE2_ACCEPTANCE_GATE.md +++ /dev/null @@ -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 항목만 명시적으로 선택해 작업한다. diff --git a/ontology_platform/docs/phases/PHASE2_NEXT_STEPS.md b/ontology_platform/docs/phases/PHASE2_NEXT_STEPS.md deleted file mode 100644 index fa02845..0000000 --- a/ontology_platform/docs/phases/PHASE2_NEXT_STEPS.md +++ /dev/null @@ -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 테스트가 계속 통과하는지 회귀 검증한다. diff --git a/ontology_platform/docs/phases/PHASE3_NEXT_STEPS.md b/ontology_platform/docs/phases/PHASE3_NEXT_STEPS.md deleted file mode 100644 index 568cf6c..0000000 --- a/ontology_platform/docs/phases/PHASE3_NEXT_STEPS.md +++ /dev/null @@ -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 테스트가 계속 통과하는지 회귀 검증한다. diff --git a/ontology_platform/docs/phases/PHASE_INDEX.md b/ontology_platform/docs/phases/PHASE_INDEX.md index e51c766..c5ba6f5 100644 --- a/ontology_platform/docs/phases/PHASE_INDEX.md +++ b/ontology_platform/docs/phases/PHASE_INDEX.md @@ -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] diff --git a/ontology_platform/tests/unit/test_phase7_hybrid_extraction.py b/ontology_platform/tests/unit/test_phase7_hybrid_extraction.py index 3db3fb5..0f6b195 100644 --- a/ontology_platform/tests/unit/test_phase7_hybrid_extraction.py +++ b/ontology_platform/tests/unit/test_phase7_hybrid_extraction.py @@ -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