From b74dcc6603f181cf641f0ebf42e4ff9c025ebad0 Mon Sep 17 00:00:00 2001 From: seongjin Date: Sat, 4 Jul 2026 13:11:32 +0900 Subject: [PATCH 1/8] test(refactor): lock sdk compatibility before splitting modules --- tests/test_refactor_compat.py | 68 +++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 tests/test_refactor_compat.py diff --git a/tests/test_refactor_compat.py b/tests/test_refactor_compat.py new file mode 100644 index 0000000..09fa572 --- /dev/null +++ b/tests/test_refactor_compat.py @@ -0,0 +1,68 @@ +from dataclasses import is_dataclass + +import moleg_api +import moleg_api.models as models +from moleg_api import LawGoKrClient, MolegApi +from moleg_api.cli import CATALOG, build_parser, main, signals_for +from moleg_api.laws import ( + MINISTRY_INTERPRETATION_SOURCES, + delegated_criteria_target_scope, + delegated_subordinate_rule_names, + enrich_annex_identity_from_body, + structured_table_from_rows, +) +from moleg_api.normalization import ( + format_article_jo, + mask_oc_param, + normalize_delegated_rules, + parse_law_history_html, + unwrap_search_judicial_decisions, +) +from moleg_api.source import DEFAULT_OC + + +def test_legacy_public_imports_survive_refactor(): + assert MolegApi is moleg_api.MolegApi + assert LawGoKrClient is moleg_api.LawGoKrClient + assert callable(build_parser) + assert callable(main) + assert callable(signals_for) + assert "경찰청" in MINISTRY_INTERPRETATION_SOURCES + assert callable(delegated_criteria_target_scope) + assert callable(delegated_subordinate_rule_names) + assert callable(enrich_annex_identity_from_body) + assert callable(structured_table_from_rows) + assert format_article_jo("제10조의2") == "001002" + assert callable(mask_oc_param) + assert callable(normalize_delegated_rules) + assert callable(parse_law_history_html) + assert callable(unwrap_search_judicial_decisions) + + +def test_model_dataclasses_keep_public_module_identity(): + model_classes = [ + value + for value in vars(models).values() + if isinstance(value, type) and is_dataclass(value) + ] + + assert model_classes + assert all(model_class.__module__ == "moleg_api.models" for model_class in model_classes) + + +def test_cli_parser_and_catalog_command_surfaces_match(): + parser = build_parser() + subparsers = [ + action for action in parser._actions if getattr(action, "choices", None) + ] + parser_commands = set(subparsers[0].choices) + catalog_commands = {cmd for group in CATALOG["commands"].values() for cmd in group} + + assert parser_commands - {"catalog"} == catalog_commands + assert "catalog" in parser_commands + assert len(parser_commands - {"catalog"}) == 27 + + +def test_shared_oc_default_remains_registered_for_zero_config_use(): + assert DEFAULT_OC == "chunghun1" + assert LawGoKrClient().oc == "chunghun1" From 408b645757e829b57d2a876a50a10932478570be Mon Sep 17 00:00:00 2001 From: seongjin Date: Sat, 4 Jul 2026 13:12:51 +0900 Subject: [PATCH 2/8] refactor(models): split model internals behind public facade --- moleg_api/_models/__init__.py | 15 + moleg_api/_models/admin.py | 85 +++ moleg_api/_models/annex.py | 68 +++ moleg_api/_models/authority.py | 102 ++++ moleg_api/_models/bundles.py | 112 ++++ moleg_api/_models/followups.py | 45 ++ moleg_api/_models/laws.py | 193 +++++++ moleg_api/_models/query.py | 84 +++ moleg_api/_models/serialization.py | 123 +++++ moleg_api/_models/types.py | 26 + moleg_api/models.py | 806 +++-------------------------- 11 files changed, 927 insertions(+), 732 deletions(-) create mode 100644 moleg_api/_models/__init__.py create mode 100644 moleg_api/_models/admin.py create mode 100644 moleg_api/_models/annex.py create mode 100644 moleg_api/_models/authority.py create mode 100644 moleg_api/_models/bundles.py create mode 100644 moleg_api/_models/followups.py create mode 100644 moleg_api/_models/laws.py create mode 100644 moleg_api/_models/query.py create mode 100644 moleg_api/_models/serialization.py create mode 100644 moleg_api/_models/types.py diff --git a/moleg_api/_models/__init__.py b/moleg_api/_models/__init__.py new file mode 100644 index 0000000..9ec6a16 --- /dev/null +++ b/moleg_api/_models/__init__.py @@ -0,0 +1,15 @@ +"""Private model implementation package.""" + +from __future__ import annotations + +from .admin import AdministrativeRuleArticleText, AdministrativeRuleContext, AdministrativeRuleHit, AdministrativeRuleIdentity, AdministrativeRuleText +from .annex import AnnexFormHit, AnnexFormIdentity, AnnexFormText, StructuredTableData +from .authority import ArticleReference, InterpretationHit, InterpretationIdentity, InterpretationText, JudicialDecisionHit, JudicialDecisionIdentity, JudicialDecisionText +from .bundles import AuthorityContext, BundleRequest, CandidateContext, LegalContextBundle, LoadedContext +from .followups import Ambiguity, ContextGap, DeferredLookup, FollowUpSearch +from .laws import ArticleContext, ArticleText, DelegatedRule, DelegationGraph, HistoryEvent, LawDiff, LawDiffChange, LawHit, LawHistory, LawIdentity, LawStructure, LawStructureNode, LawText, SupplementaryProvision +from .query import LegalArticleCandidate, LegalLawCandidate, LegalQueryExpansion, LegalTermCandidate +from .serialization import install_serialization_methods +from .types import AnnexFormSource, AnnexSearchScope, AnnexType, Basis, BundleBudget, BundleMode, BundleRequestMode, CaseCourt, InterpretationSearchSource + +__all__ = [name for name in globals() if not name.startswith("_")] diff --git a/moleg_api/_models/admin.py b/moleg_api/_models/admin.py new file mode 100644 index 0000000..ed9b9ab --- /dev/null +++ b/moleg_api/_models/admin.py @@ -0,0 +1,85 @@ +"""Administrative-rule model group.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from .followups import ContextGap, DeferredLookup +from .laws import SupplementaryProvision + +@dataclass(frozen=True) +class AdministrativeRuleIdentity: + """Normalized administrative-rule identity.""" + + serial_id: str | None + name: str + rule_id: str | None = None + rule_type: str | None = None + issuing_date: str | None = None + issuing_number: str | None = None + effective_date: str | None = None + ministry: str | None = None + ministry_code: str | None = None + current_status: str | None = None + revision_type: str | None = None + source_law_id: str | None = None + source_law_name: str | None = None + source_article: str | None = None + source_article_title: str | None = None + raw_keys: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class AdministrativeRuleHit: + """Search result carrying a normalized administrative-rule identity.""" + + identity: AdministrativeRuleIdentity + raw: dict[str, Any] = field(default_factory=dict) + follow_up: DeferredLookup | None = None + + +@dataclass(frozen=True) +class AdministrativeRuleArticleText: + """Normalized administrative-rule article or text section.""" + + identity: AdministrativeRuleIdentity + article: str | None + text: str + title: str | None = None + effective_date: str | None = None + article_kind: str | None = None + revision_type: str | None = None + moved_from: str | None = None + moved_to: str | None = None + has_changes: bool | None = None + is_deleted: bool = False + source_law_id: str | None = None + source_law_name: str | None = None + source_article: str | None = None + source_article_title: str | None = None + raw: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class AdministrativeRuleText: + """Normalized administrative-rule text.""" + + identity: AdministrativeRuleIdentity + text: str + articles: list[AdministrativeRuleArticleText] + supplementary_provisions: list[SupplementaryProvision] = field(default_factory=list) + raw: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class AdministrativeRuleContext: + """Administrative-rule lookup context with article-status guardrails.""" + + rule: AdministrativeRuleText + requested_articles: list[AdministrativeRuleArticleText] = field(default_factory=list) + current_articles: list[AdministrativeRuleArticleText] = field(default_factory=list) + loaded_articles: list[AdministrativeRuleArticleText] = field(default_factory=list) + deferred: list[DeferredLookup] = field(default_factory=list) + gaps: list[ContextGap] = field(default_factory=list) + source_notes: list[str] = field(default_factory=list) diff --git a/moleg_api/_models/annex.py b/moleg_api/_models/annex.py new file mode 100644 index 0000000..3ff00e7 --- /dev/null +++ b/moleg_api/_models/annex.py @@ -0,0 +1,68 @@ +"""Annex and form model group.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from .followups import DeferredLookup + +@dataclass(frozen=True) +class AnnexFormIdentity: + """Normalized law or administrative-rule annex/form identity.""" + + annex_id: str | None + title: str + source_type: str + source_target: str + related_name: str | None = None + related_id: str | None = None + related_serial_id: str | None = None + annex_number: str | None = None + annex_type: str | None = None + ministry: str | None = None + promulgation_date: str | None = None + promulgation_number: str | None = None + issued_on: str | None = None + issuing_number: str | None = None + revision_type: str | None = None + law_type: str | None = None + rule_type: str | None = None + file_link: str | None = None + pdf_link: str | None = None + detail_link: str | None = None + raw_keys: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class AnnexFormHit: + """Search result carrying a normalized annex/form identity.""" + + identity: AnnexFormIdentity + raw: dict[str, Any] = field(default_factory=dict) + follow_up: DeferredLookup | None = None + + +@dataclass(frozen=True) +class StructuredTableData: + """Best-effort structured rows extracted from a text-export annex/form body.""" + + title: str | None + headers: list[str] + rows: list[dict[str, str]] + units: list[str] = field(default_factory=list) + parsing_confidence: str = "low" + notes: list[str] = field(default_factory=list) + + +@dataclass(frozen=True) +class AnnexFormText: + """Extracted annex/form body text.""" + + identity: AnnexFormIdentity + text: str + file_type: str + extraction_method: str + extraction_confidence: str + structured_data: StructuredTableData | None = None + raw: dict[str, Any] = field(default_factory=dict) diff --git a/moleg_api/_models/authority.py b/moleg_api/_models/authority.py new file mode 100644 index 0000000..e51c129 --- /dev/null +++ b/moleg_api/_models/authority.py @@ -0,0 +1,102 @@ +"""Interpretation and judicial authority models.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from .followups import DeferredLookup + +@dataclass(frozen=True) +class InterpretationIdentity: + """Normalized legal-interpretation identity.""" + + interpretation_id: str | None + title: str + source_type: str + source_target: str + case_number: str | None = None + interpretation_date: str | None = None + reply_agency: str | None = None + reply_agency_code: str | None = None + inquiry_agency: str | None = None + inquiry_agency_code: str | None = None + ministry: str | None = None + data_timestamp: str | None = None + raw_keys: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class InterpretationHit: + """Search result carrying a normalized interpretation identity.""" + + identity: InterpretationIdentity + raw: dict[str, Any] = field(default_factory=dict) + follow_up: DeferredLookup | None = None + + +@dataclass(frozen=True) +class ArticleReference: + """Structured reference to a statute article parsed from source text.""" + + law_name: str + article: str + law_id: str | None = None + + +@dataclass(frozen=True) +class InterpretationText: + """Normalized legal-interpretation full text.""" + + identity: InterpretationIdentity + question: str | None = None + answer: str | None = None + reason: str | None = None + related_laws: str | None = None + referenced_articles: list[ArticleReference] = field(default_factory=list) + text: str = "" + raw: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class JudicialDecisionIdentity: + """Normalized court case or Constitutional Court decision identity.""" + + decision_id: str | None + title: str + source_type: str + source_target: str + case_number: str | None = None + decision_date: str | None = None + court: str | None = None + court_type_code: str | None = None + case_type: str | None = None + decision_type: str | None = None + data_source: str | None = None + raw_keys: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class JudicialDecisionHit: + """Search result carrying normalized judicial decision identity.""" + + identity: JudicialDecisionIdentity + raw: dict[str, Any] = field(default_factory=dict) + follow_up: DeferredLookup | None = None + + +@dataclass(frozen=True) +class JudicialDecisionText: + """Normalized court case or Constitutional Court decision text.""" + + identity: JudicialDecisionIdentity + holdings: str | None = None + summary: str | None = None + full_text: str | None = None + referenced_statutes: str | None = None + reviewed_statutes: str | None = None + referenced_cases: str | None = None + referenced_articles: list[ArticleReference] = field(default_factory=list) + reviewed_articles: list[ArticleReference] = field(default_factory=list) + text: str = "" + raw: dict[str, Any] = field(default_factory=dict) diff --git a/moleg_api/_models/bundles.py b/moleg_api/_models/bundles.py new file mode 100644 index 0000000..5045de3 --- /dev/null +++ b/moleg_api/_models/bundles.py @@ -0,0 +1,112 @@ +"""Context bundle and authority aggregate models.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from .admin import AdministrativeRuleHit, AdministrativeRuleText +from .annex import AnnexFormHit, AnnexFormText +from .authority import InterpretationHit, InterpretationText, JudicialDecisionHit, JudicialDecisionText +from .followups import Ambiguity, ContextGap, DeferredLookup +from .laws import ArticleText, DelegationGraph, LawIdentity, LawStructure, LawText +from .query import LegalQueryExpansion +from .types import BundleBudget, BundleRequestMode + +@dataclass(frozen=True) +class BundleRequest: + """Request metadata for a legal context bundle.""" + + query: str | None + mode: BundleRequestMode + budget: BundleBudget + articles: list[str | int] = field(default_factory=list) + statute_ids: list[str] = field(default_factory=list) + promulgation_bridge: dict[str, Any] = field(default_factory=dict) + law_identifier: Any = None + as_of: str | None = None + + +@dataclass(frozen=True) +class LoadedContext: + """Official context already loaded for Claude to inspect.""" + + laws: list[LawText] = field(default_factory=list) + articles: list[ArticleText] = field(default_factory=list) + delegations: list[DelegationGraph] = field(default_factory=list) + law_structures: list[LawStructure] = field(default_factory=list) + administrative_rules: list[AdministrativeRuleText] = field(default_factory=list) + annex_forms: list[AnnexFormText] = field(default_factory=list) + interpretations: list[InterpretationText] = field(default_factory=list) + cases: list[JudicialDecisionText] = field(default_factory=list) + constitutional_decisions: list[JudicialDecisionText] = field(default_factory=list) + + +@dataclass(frozen=True) +class CandidateContext: + """Candidate context discovered but not necessarily loaded.""" + + query_expansion: LegalQueryExpansion | None = None + laws: list[LawIdentity] = field(default_factory=list) + administrative_rules: list[AdministrativeRuleHit] = field(default_factory=list) + annex_forms: list[AnnexFormHit] = field(default_factory=list) + interpretations: list[InterpretationHit] = field(default_factory=list) + cases: list[JudicialDecisionHit] = field(default_factory=list) + constitutional_decisions: list[JudicialDecisionHit] = field(default_factory=list) + + +@dataclass(frozen=True) +class DeferredLookup: + """A bounded follow-up lookup the skill may choose to run later.""" + + interface: str + query: str + reason: str + source_type: str | None = None + filters: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class Ambiguity: + """Ambiguity that must not be silently resolved.""" + + kind: str + message: str + candidates: list[Any] = field(default_factory=list) + + +@dataclass(frozen=True) +class ContextGap: + """Context that should be filled by another source or human review.""" + + kind: str + reason: str + query: str | None = None + recommended_interface: str | None = None + + +@dataclass(frozen=True) +class LegalContextBundle: + """Staged legal context for the legislative-expert skill.""" + + request: BundleRequest + loaded: LoadedContext + candidates: CandidateContext + deferred: list[DeferredLookup] = field(default_factory=list) + ambiguities: list[Ambiguity] = field(default_factory=list) + gaps: list[ContextGap] = field(default_factory=list) + source_notes: list[str] = field(default_factory=list) + + +@dataclass(frozen=True) +class AuthorityContext: + """Authority lookup context scoped to loaded target articles.""" + + request: BundleRequest + target_articles: list[ArticleText] = field(default_factory=list) + loaded: LoadedContext = field(default_factory=LoadedContext) + current_authorities: LoadedContext = field(default_factory=LoadedContext) + candidates: CandidateContext = field(default_factory=CandidateContext) + deferred: list[DeferredLookup] = field(default_factory=list) + gaps: list[ContextGap] = field(default_factory=list) + source_notes: list[str] = field(default_factory=list) diff --git a/moleg_api/_models/followups.py b/moleg_api/_models/followups.py new file mode 100644 index 0000000..e81bf0a --- /dev/null +++ b/moleg_api/_models/followups.py @@ -0,0 +1,45 @@ +"""Follow-up and gap model primitives.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +@dataclass(frozen=True) +class FollowUpSearch: + """Recommended next search for the legislative-expert skill.""" + + interface: str + query: str + reason: str + source_type: str | None = None + filters: dict[str, Any] = field(default_factory=dict) + +@dataclass(frozen=True) +class DeferredLookup: + """A bounded follow-up lookup the skill may choose to run later.""" + + interface: str + query: str + reason: str + source_type: str | None = None + filters: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class Ambiguity: + """Ambiguity that must not be silently resolved.""" + + kind: str + message: str + candidates: list[Any] = field(default_factory=list) + + +@dataclass(frozen=True) +class ContextGap: + """Context that should be filled by another source or human review.""" + + kind: str + reason: str + query: str | None = None + recommended_interface: str | None = None diff --git a/moleg_api/_models/laws.py b/moleg_api/_models/laws.py new file mode 100644 index 0000000..c22a79a --- /dev/null +++ b/moleg_api/_models/laws.py @@ -0,0 +1,193 @@ +"""Core law, article, history, and hierarchy models.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Literal + +from .followups import ContextGap, DeferredLookup +from .types import Basis + +@dataclass(frozen=True) +class LawIdentity: + """Normalized law identity exposed to skill callers.""" + + law_id: str | None + name: str + basis: Basis + mst: str | None = None + lid: str | None = None + promulgation_date: str | None = None + effective_date: str | None = None + promulgation_number: str | None = None + law_type: str | None = None + ministry: str | None = None + raw_keys: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class LawHit: + """Search result carrying a normalized identity and source row.""" + + identity: LawIdentity + raw: dict[str, Any] = field(default_factory=dict) + follow_up: DeferredLookup | None = None + + +@dataclass(frozen=True) +class ArticleText: + """Normalized article text.""" + + identity: LawIdentity + article: str + text: str + title: str | None = None + effective_date: str | None = None + article_kind: str | None = None + revision_type: str | None = None + moved_from: str | None = None + moved_to: str | None = None + has_changes: bool | None = None + is_deleted: bool = False + raw: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class ArticleContext: + """Article lookup context with movement/deletion guardrails.""" + + requested_article: ArticleText + current_article: ArticleText | None + loaded_articles: list[ArticleText] = field(default_factory=list) + deferred: list[DeferredLookup] = field(default_factory=list) + gaps: list[ContextGap] = field(default_factory=list) + source_notes: list[str] = field(default_factory=list) + + +@dataclass(frozen=True) +class SupplementaryProvision: + """Normalized supplementary provision/addendum text.""" + + source_type: Literal["law", "administrative_rule"] + text: str + promulgation_date: str | None = None + promulgation_number: str | None = None + title: str | None = None + raw: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class LawText: + """Normalized law text with article list and raw source payload.""" + + identity: LawIdentity + articles: list[ArticleText] + supplementary_provisions: list[SupplementaryProvision] = field(default_factory=list) + raw: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class HistoryEvent: + """Normalized law or article change event.""" + + identity: LawIdentity + changed_date: str | None = None + effective_date: str | None = None + promulgation_law_name: str | None = None + promulgation_date: str | None = None + promulgation_number: str | None = None + bill_id: str | None = None + revision_type: str | None = None + article: str | None = None + article_text: str | None = None + article_link: str | None = None + reason: str | None = None + raw: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class LawHistory: + """Normalized history result.""" + + identity: LawIdentity + events: list[HistoryEvent] + source_failures: list[ContextGap] = field(default_factory=list) + raw: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class LawDiffChange: + """One before/after article comparison row.""" + + article: str | None + before_text: str + after_text: str + title: str | None = None + raw: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class LawDiff: + """Normalized before/after law comparison.""" + + identity: LawIdentity + before_identity: LawIdentity | None + after_identity: LawIdentity | None + changes: list[LawDiffChange] + raw: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class DelegatedRule: + """One delegated rule or lower-law relationship.""" + + source_article: str | None = None + source_article_title: str | None = None + delegated_type: str | None = None + delegated_name: str | None = None + delegated_law_id: str | None = None + delegated_mst: str | None = None + delegated_article: str | None = None + text: str | None = None + raw: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class DelegationGraph: + """Delegation graph rooted at one law identity.""" + + identity: LawIdentity + rules: list[DelegatedRule] + raw: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class LawStructureNode: + """One law-structure node from the MOLEG structural hierarchy.""" + + name: str + source_type: str + instrument_type: str + law_id: str | None = None + mst: str | None = None + serial_id: str | None = None + rule_id: str | None = None + law_type: str | None = None + effective_date: str | None = None + promulgation_date: str | None = None + promulgation_number: str | None = None + issuing_date: str | None = None + issuing_number: str | None = None + ministry: str | None = None + detail_link: str | None = None + children: list[LawStructureNode] = field(default_factory=list) + raw: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class LawStructure: + """Normalized law hierarchy from the MOLEG law-structure view.""" + + identity: LawIdentity + instruments: list[LawStructureNode] + raw: dict[str, Any] = field(default_factory=dict) diff --git a/moleg_api/_models/query.py b/moleg_api/_models/query.py new file mode 100644 index 0000000..86641f6 --- /dev/null +++ b/moleg_api/_models/query.py @@ -0,0 +1,84 @@ +"""Query-expansion candidate models.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from .followups import ContextGap, FollowUpSearch +from .laws import LawIdentity + +@dataclass(frozen=True) +class LegalTermCandidate: + """Legal or everyday term candidate for query planning.""" + + term: str + source_type: str + source_target: str + term_id: str | None = None + relation: str | None = None + note: str | None = None + definition: str | None = None + source_title: str | None = None + raw: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class LegalArticleCandidate: + """Article candidate discovered during query expansion.""" + + law_name: str | None + law_id: str | None = None + article: str | None = None + title: str | None = None + text: str | None = None + source_target: str | None = None + term: str | None = None + raw: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class LegalLawCandidate: + """Law or administrative-rule candidate discovered during query expansion.""" + + name: str + law_id: str | None = None + mst: str | None = None + source_type: str = "law" + source_target: str | None = None + relation: str | None = None + article: str | None = None + article_title: str | None = None + promulgation_date: str | None = None + effective_date: str | None = None + promulgation_number: str | None = None + law_type: str | None = None + ministry: str | None = None + raw: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class FollowUpSearch: + """Recommended next search for the legislative-expert skill.""" + + interface: str + query: str + reason: str + source_type: str | None = None + filters: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class LegalQueryExpansion: + """Query-planning result; not final legal authority.""" + + original_query: str + law_candidates: list[LawIdentity] + term_candidates: list[LegalTermCandidate] + related_terms: list[LegalTermCandidate] + related_articles: list[LegalArticleCandidate] + related_laws: list[LegalLawCandidate] + follow_up_searches: list[FollowUpSearch] + empty_sources: list[str] = field(default_factory=list) + source_failures: list[ContextGap] = field(default_factory=list) + raw: dict[str, Any] = field(default_factory=dict) diff --git a/moleg_api/_models/serialization.py b/moleg_api/_models/serialization.py new file mode 100644 index 0000000..4af0061 --- /dev/null +++ b/moleg_api/_models/serialization.py @@ -0,0 +1,123 @@ +"""Serialization helpers shared by public dataclass models.""" + +from __future__ import annotations + +import json +from dataclasses import fields, is_dataclass +from typing import Any + +def _model_to_dict(self: Any, *, include_raw: bool = False) -> dict[str, Any]: + return _serialize_dataclass(self, include_raw=include_raw) + + +def _model_to_json_string(self: Any, *, include_raw: bool = False) -> str: + return json.dumps( + self.to_dict(include_raw=include_raw), + ensure_ascii=False, + sort_keys=True, + ) + + +def _serialize_dataclass(value: Any, *, include_raw: bool) -> dict[str, Any]: + data: dict[str, Any] = {} + for item in fields(value): + if item.name == "raw" and not include_raw: + continue + data[item.name] = _serialize_value(getattr(value, item.name), include_raw=include_raw) + return data + + +def _serialize_value(value: Any, *, include_raw: bool) -> Any: + if is_dataclass(value) and not isinstance(value, type): + return _serialize_dataclass(value, include_raw=include_raw) + if isinstance(value, list): + return [_serialize_value(item, include_raw=include_raw) for item in value] + if isinstance(value, tuple): + return [_serialize_value(item, include_raw=include_raw) for item in value] + if isinstance(value, set): + serialized_items = [_serialize_value(item, include_raw=include_raw) for item in value] + return sorted(serialized_items, key=_json_sort_key) + if isinstance(value, dict): + return _serialize_dict(value, include_raw=include_raw) + return value + + +def _json_sort_key(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, sort_keys=True, default=str) + + +def _serialize_dict(value: dict[Any, Any], *, include_raw: bool) -> dict[str, Any]: + entries: list[dict[str, Any]] = [] + groups: dict[str, list[dict[str, Any]]] = {} + for key, item in value.items(): + serialized_key = _serialize_key(key) + entry = { + "key": key, + "item": item, + "serialized_key": serialized_key, + "disambiguated_key": _serialize_disambiguated_key(key), + "output_key": serialized_key, + } + entries.append(entry) + groups.setdefault(serialized_key, []).append(entry) + + for group in groups.values(): + if len(group) > 1: + for entry in group: + entry["output_key"] = entry["disambiguated_key"] + + while True: + output_groups: dict[str, list[dict[str, Any]]] = {} + for entry in entries: + output_groups.setdefault(entry["output_key"], []).append(entry) + conflicting_entries = [ + entry + for group in output_groups.values() + if len(group) > 1 + for entry in group + ] + promoted = False + for entry in conflicting_entries: + if entry["output_key"] != entry["disambiguated_key"]: + entry["output_key"] = entry["disambiguated_key"] + promoted = True + if not promoted: + break + + data: dict[str, Any] = {} + for entry in sorted(entries, key=_serialized_entry_sort_key): + output_key = _dedupe_key(entry["output_key"], data) + data[output_key] = _serialize_value(entry["item"], include_raw=include_raw) + return data + + +def _serialize_key(key: Any) -> str: + if isinstance(key, str): + return key + return str(key) + + +def _serialize_disambiguated_key(key: Any) -> str: + return f"{type(key).__name__}:{key!r}" + + +def _serialized_entry_sort_key(entry: dict[str, Any]) -> tuple[str, str]: + key = entry["key"] + return (entry["output_key"], f"{type(key).__module__}.{type(key).__qualname__}:{key!r}") + + +def _dedupe_key(key: str, data: dict[str, Any]) -> str: + if key not in data: + return key + counter = 2 + while f"{key}#{counter}" in data: + counter += 1 + return f"{key}#{counter}" + + +def install_serialization_methods(namespace: dict[str, Any], *, public_module: str) -> None: + for value in list(namespace.values()): + if isinstance(value, type) and is_dataclass(value): + value.__module__ = public_module + setattr(value, "to_dict", _model_to_dict) + setattr(value, "to_json_string", _model_to_json_string) diff --git a/moleg_api/_models/types.py b/moleg_api/_models/types.py new file mode 100644 index 0000000..efc2dce --- /dev/null +++ b/moleg_api/_models/types.py @@ -0,0 +1,26 @@ +"""Literal type aliases for public MOLEG models.""" + +from __future__ import annotations + +from typing import Literal + +Basis = Literal["effective", "promulgated"] +AnnexFormSource = Literal["law", "administrative_rule"] +AnnexSearchScope = Literal["title", "source", "body"] +AnnexType = Literal[ + "annex", + "별표", + "form", + "서식", + "attached_form", + "별지", + "separate", + "별도", + "appendix", + "부록", +] +InterpretationSearchSource = Literal["moleg", "ministry", "all", "all_ministries"] +CaseCourt = Literal["all", "supreme", "lower"] +BundleMode = Literal["question", "promulgated_bill", "statute_review"] +BundleRequestMode = Literal["question", "promulgated_bill", "statute_review", "institutional_system", "delegated_criteria"] +BundleBudget = Literal["minimal", "standard", "broad"] diff --git a/moleg_api/models.py b/moleg_api/models.py index 2c4097c..308ec63 100644 --- a/moleg_api/models.py +++ b/moleg_api/models.py @@ -6,736 +6,78 @@ from dataclasses import dataclass, field, fields, is_dataclass from typing import Any, Literal - -Basis = Literal["effective", "promulgated"] -AnnexFormSource = Literal["law", "administrative_rule"] -AnnexSearchScope = Literal["title", "source", "body"] -AnnexType = Literal[ - "annex", - "별표", - "form", - "서식", - "attached_form", - "별지", - "separate", - "별도", - "appendix", - "부록", +from ._models import ( + AdministrativeRuleArticleText, + AdministrativeRuleContext, + AdministrativeRuleHit, + AdministrativeRuleIdentity, + AdministrativeRuleText, + Ambiguity, + AnnexFormHit, + AnnexFormIdentity, + AnnexFormSource, + AnnexFormText, + AnnexSearchScope, + AnnexType, + ArticleContext, + ArticleReference, + ArticleText, + AuthorityContext, + Basis, + BundleBudget, + BundleMode, + BundleRequest, + BundleRequestMode, + CandidateContext, + CaseCourt, + ContextGap, + DeferredLookup, + DelegatedRule, + DelegationGraph, + FollowUpSearch, + HistoryEvent, + InterpretationHit, + InterpretationIdentity, + InterpretationSearchSource, + InterpretationText, + JudicialDecisionHit, + JudicialDecisionIdentity, + JudicialDecisionText, + LawDiff, + LawDiffChange, + LawHit, + LawHistory, + LawIdentity, + LawStructure, + LawStructureNode, + LawText, + LegalArticleCandidate, + LegalContextBundle, + LegalLawCandidate, + LegalQueryExpansion, + LegalTermCandidate, + LoadedContext, + StructuredTableData, + SupplementaryProvision, + install_serialization_methods, +) +from ._models.serialization import ( + _dedupe_key, + _json_sort_key, + _model_to_dict, + _model_to_json_string, + _serialize_dataclass, + _serialize_dict, + _serialize_disambiguated_key, + _serialize_key, + _serialize_value, + _serialized_entry_sort_key, +) + +install_serialization_methods(globals(), public_module=__name__) + +__all__ = [ + name + for name, value in globals().items() + if not name.startswith("_") and name not in {"annotations", "json"} ] -InterpretationSearchSource = Literal["moleg", "ministry", "all", "all_ministries"] -CaseCourt = Literal["all", "supreme", "lower"] -BundleMode = Literal["question", "promulgated_bill", "statute_review"] -BundleRequestMode = Literal["question", "promulgated_bill", "statute_review", "institutional_system", "delegated_criteria"] -BundleBudget = Literal["minimal", "standard", "broad"] - - -def _model_to_dict(self: Any, *, include_raw: bool = False) -> dict[str, Any]: - return _serialize_dataclass(self, include_raw=include_raw) - - -def _model_to_json_string(self: Any, *, include_raw: bool = False) -> str: - return json.dumps( - self.to_dict(include_raw=include_raw), - ensure_ascii=False, - sort_keys=True, - ) - - -def _serialize_dataclass(value: Any, *, include_raw: bool) -> dict[str, Any]: - data: dict[str, Any] = {} - for item in fields(value): - if item.name == "raw" and not include_raw: - continue - data[item.name] = _serialize_value(getattr(value, item.name), include_raw=include_raw) - return data - - -def _serialize_value(value: Any, *, include_raw: bool) -> Any: - if is_dataclass(value) and not isinstance(value, type): - return _serialize_dataclass(value, include_raw=include_raw) - if isinstance(value, list): - return [_serialize_value(item, include_raw=include_raw) for item in value] - if isinstance(value, tuple): - return [_serialize_value(item, include_raw=include_raw) for item in value] - if isinstance(value, set): - serialized_items = [_serialize_value(item, include_raw=include_raw) for item in value] - return sorted(serialized_items, key=_json_sort_key) - if isinstance(value, dict): - return _serialize_dict(value, include_raw=include_raw) - return value - - -def _json_sort_key(value: Any) -> str: - return json.dumps(value, ensure_ascii=False, sort_keys=True, default=str) - - -def _serialize_dict(value: dict[Any, Any], *, include_raw: bool) -> dict[str, Any]: - entries: list[dict[str, Any]] = [] - groups: dict[str, list[dict[str, Any]]] = {} - for key, item in value.items(): - serialized_key = _serialize_key(key) - entry = { - "key": key, - "item": item, - "serialized_key": serialized_key, - "disambiguated_key": _serialize_disambiguated_key(key), - "output_key": serialized_key, - } - entries.append(entry) - groups.setdefault(serialized_key, []).append(entry) - - for group in groups.values(): - if len(group) > 1: - for entry in group: - entry["output_key"] = entry["disambiguated_key"] - - while True: - output_groups: dict[str, list[dict[str, Any]]] = {} - for entry in entries: - output_groups.setdefault(entry["output_key"], []).append(entry) - conflicting_entries = [ - entry - for group in output_groups.values() - if len(group) > 1 - for entry in group - ] - promoted = False - for entry in conflicting_entries: - if entry["output_key"] != entry["disambiguated_key"]: - entry["output_key"] = entry["disambiguated_key"] - promoted = True - if not promoted: - break - - data: dict[str, Any] = {} - for entry in sorted(entries, key=_serialized_entry_sort_key): - output_key = _dedupe_key(entry["output_key"], data) - data[output_key] = _serialize_value(entry["item"], include_raw=include_raw) - return data - - -def _serialize_key(key: Any) -> str: - if isinstance(key, str): - return key - return str(key) - - -def _serialize_disambiguated_key(key: Any) -> str: - return f"{type(key).__name__}:{key!r}" - - -def _serialized_entry_sort_key(entry: dict[str, Any]) -> tuple[str, str]: - key = entry["key"] - return (entry["output_key"], f"{type(key).__module__}.{type(key).__qualname__}:{key!r}") - - -def _dedupe_key(key: str, data: dict[str, Any]) -> str: - if key not in data: - return key - counter = 2 - while f"{key}#{counter}" in data: - counter += 1 - return f"{key}#{counter}" - - -def _install_serialization_methods() -> None: - for value in list(globals().values()): - if isinstance(value, type) and value.__module__ == __name__ and is_dataclass(value): - setattr(value, "to_dict", _model_to_dict) - setattr(value, "to_json_string", _model_to_json_string) - - -@dataclass(frozen=True) -class LawIdentity: - """Normalized law identity exposed to skill callers.""" - - law_id: str | None - name: str - basis: Basis - mst: str | None = None - lid: str | None = None - promulgation_date: str | None = None - effective_date: str | None = None - promulgation_number: str | None = None - law_type: str | None = None - ministry: str | None = None - raw_keys: dict[str, Any] = field(default_factory=dict) - - -@dataclass(frozen=True) -class LawHit: - """Search result carrying a normalized identity and source row.""" - - identity: LawIdentity - raw: dict[str, Any] = field(default_factory=dict) - follow_up: DeferredLookup | None = None - - -@dataclass(frozen=True) -class ArticleText: - """Normalized article text.""" - - identity: LawIdentity - article: str - text: str - title: str | None = None - effective_date: str | None = None - article_kind: str | None = None - revision_type: str | None = None - moved_from: str | None = None - moved_to: str | None = None - has_changes: bool | None = None - is_deleted: bool = False - raw: dict[str, Any] = field(default_factory=dict) - - -@dataclass(frozen=True) -class ArticleContext: - """Article lookup context with movement/deletion guardrails.""" - - requested_article: ArticleText - current_article: ArticleText | None - loaded_articles: list[ArticleText] = field(default_factory=list) - deferred: list[DeferredLookup] = field(default_factory=list) - gaps: list[ContextGap] = field(default_factory=list) - source_notes: list[str] = field(default_factory=list) - - -@dataclass(frozen=True) -class SupplementaryProvision: - """Normalized supplementary provision/addendum text.""" - - source_type: Literal["law", "administrative_rule"] - text: str - promulgation_date: str | None = None - promulgation_number: str | None = None - title: str | None = None - raw: dict[str, Any] = field(default_factory=dict) - - -@dataclass(frozen=True) -class LawText: - """Normalized law text with article list and raw source payload.""" - - identity: LawIdentity - articles: list[ArticleText] - supplementary_provisions: list[SupplementaryProvision] = field(default_factory=list) - raw: dict[str, Any] = field(default_factory=dict) - - -@dataclass(frozen=True) -class HistoryEvent: - """Normalized law or article change event.""" - - identity: LawIdentity - changed_date: str | None = None - effective_date: str | None = None - promulgation_law_name: str | None = None - promulgation_date: str | None = None - promulgation_number: str | None = None - bill_id: str | None = None - revision_type: str | None = None - article: str | None = None - article_text: str | None = None - article_link: str | None = None - reason: str | None = None - raw: dict[str, Any] = field(default_factory=dict) - - -@dataclass(frozen=True) -class LawHistory: - """Normalized history result.""" - - identity: LawIdentity - events: list[HistoryEvent] - source_failures: list[ContextGap] = field(default_factory=list) - raw: dict[str, Any] = field(default_factory=dict) - - -@dataclass(frozen=True) -class LawDiffChange: - """One before/after article comparison row.""" - - article: str | None - before_text: str - after_text: str - title: str | None = None - raw: dict[str, Any] = field(default_factory=dict) - - -@dataclass(frozen=True) -class LawDiff: - """Normalized before/after law comparison.""" - - identity: LawIdentity - before_identity: LawIdentity | None - after_identity: LawIdentity | None - changes: list[LawDiffChange] - raw: dict[str, Any] = field(default_factory=dict) - - -@dataclass(frozen=True) -class DelegatedRule: - """One delegated rule or lower-law relationship.""" - - source_article: str | None = None - source_article_title: str | None = None - delegated_type: str | None = None - delegated_name: str | None = None - delegated_law_id: str | None = None - delegated_mst: str | None = None - delegated_article: str | None = None - text: str | None = None - raw: dict[str, Any] = field(default_factory=dict) - - -@dataclass(frozen=True) -class DelegationGraph: - """Delegation graph rooted at one law identity.""" - - identity: LawIdentity - rules: list[DelegatedRule] - raw: dict[str, Any] = field(default_factory=dict) - - -@dataclass(frozen=True) -class LawStructureNode: - """One law-structure node from the MOLEG structural hierarchy.""" - - name: str - source_type: str - instrument_type: str - law_id: str | None = None - mst: str | None = None - serial_id: str | None = None - rule_id: str | None = None - law_type: str | None = None - effective_date: str | None = None - promulgation_date: str | None = None - promulgation_number: str | None = None - issuing_date: str | None = None - issuing_number: str | None = None - ministry: str | None = None - detail_link: str | None = None - children: list[LawStructureNode] = field(default_factory=list) - raw: dict[str, Any] = field(default_factory=dict) - - -@dataclass(frozen=True) -class LawStructure: - """Normalized law hierarchy from the MOLEG law-structure view.""" - - identity: LawIdentity - instruments: list[LawStructureNode] - raw: dict[str, Any] = field(default_factory=dict) - - -@dataclass(frozen=True) -class AdministrativeRuleIdentity: - """Normalized administrative-rule identity.""" - - serial_id: str | None - name: str - rule_id: str | None = None - rule_type: str | None = None - issuing_date: str | None = None - issuing_number: str | None = None - effective_date: str | None = None - ministry: str | None = None - ministry_code: str | None = None - current_status: str | None = None - revision_type: str | None = None - source_law_id: str | None = None - source_law_name: str | None = None - source_article: str | None = None - source_article_title: str | None = None - raw_keys: dict[str, Any] = field(default_factory=dict) - - -@dataclass(frozen=True) -class AdministrativeRuleHit: - """Search result carrying a normalized administrative-rule identity.""" - - identity: AdministrativeRuleIdentity - raw: dict[str, Any] = field(default_factory=dict) - follow_up: DeferredLookup | None = None - - -@dataclass(frozen=True) -class AdministrativeRuleArticleText: - """Normalized administrative-rule article or text section.""" - - identity: AdministrativeRuleIdentity - article: str | None - text: str - title: str | None = None - effective_date: str | None = None - article_kind: str | None = None - revision_type: str | None = None - moved_from: str | None = None - moved_to: str | None = None - has_changes: bool | None = None - is_deleted: bool = False - source_law_id: str | None = None - source_law_name: str | None = None - source_article: str | None = None - source_article_title: str | None = None - raw: dict[str, Any] = field(default_factory=dict) - - -@dataclass(frozen=True) -class AdministrativeRuleText: - """Normalized administrative-rule text.""" - - identity: AdministrativeRuleIdentity - text: str - articles: list[AdministrativeRuleArticleText] - supplementary_provisions: list[SupplementaryProvision] = field(default_factory=list) - raw: dict[str, Any] = field(default_factory=dict) - - -@dataclass(frozen=True) -class AdministrativeRuleContext: - """Administrative-rule lookup context with article-status guardrails.""" - - rule: AdministrativeRuleText - requested_articles: list[AdministrativeRuleArticleText] = field(default_factory=list) - current_articles: list[AdministrativeRuleArticleText] = field(default_factory=list) - loaded_articles: list[AdministrativeRuleArticleText] = field(default_factory=list) - deferred: list[DeferredLookup] = field(default_factory=list) - gaps: list[ContextGap] = field(default_factory=list) - source_notes: list[str] = field(default_factory=list) - - -@dataclass(frozen=True) -class AnnexFormIdentity: - """Normalized law or administrative-rule annex/form identity.""" - - annex_id: str | None - title: str - source_type: str - source_target: str - related_name: str | None = None - related_id: str | None = None - related_serial_id: str | None = None - annex_number: str | None = None - annex_type: str | None = None - ministry: str | None = None - promulgation_date: str | None = None - promulgation_number: str | None = None - issued_on: str | None = None - issuing_number: str | None = None - revision_type: str | None = None - law_type: str | None = None - rule_type: str | None = None - file_link: str | None = None - pdf_link: str | None = None - detail_link: str | None = None - raw_keys: dict[str, Any] = field(default_factory=dict) - - -@dataclass(frozen=True) -class AnnexFormHit: - """Search result carrying a normalized annex/form identity.""" - - identity: AnnexFormIdentity - raw: dict[str, Any] = field(default_factory=dict) - follow_up: DeferredLookup | None = None - - -@dataclass(frozen=True) -class StructuredTableData: - """Best-effort structured rows extracted from a text-export annex/form body.""" - - title: str | None - headers: list[str] - rows: list[dict[str, str]] - units: list[str] = field(default_factory=list) - parsing_confidence: str = "low" - notes: list[str] = field(default_factory=list) - - -@dataclass(frozen=True) -class AnnexFormText: - """Extracted annex/form body text.""" - - identity: AnnexFormIdentity - text: str - file_type: str - extraction_method: str - extraction_confidence: str - structured_data: StructuredTableData | None = None - raw: dict[str, Any] = field(default_factory=dict) - - -@dataclass(frozen=True) -class InterpretationIdentity: - """Normalized legal-interpretation identity.""" - - interpretation_id: str | None - title: str - source_type: str - source_target: str - case_number: str | None = None - interpretation_date: str | None = None - reply_agency: str | None = None - reply_agency_code: str | None = None - inquiry_agency: str | None = None - inquiry_agency_code: str | None = None - ministry: str | None = None - data_timestamp: str | None = None - raw_keys: dict[str, Any] = field(default_factory=dict) - - -@dataclass(frozen=True) -class InterpretationHit: - """Search result carrying a normalized interpretation identity.""" - - identity: InterpretationIdentity - raw: dict[str, Any] = field(default_factory=dict) - follow_up: DeferredLookup | None = None - - -@dataclass(frozen=True) -class ArticleReference: - """Structured reference to a statute article parsed from source text.""" - - law_name: str - article: str - law_id: str | None = None - - -@dataclass(frozen=True) -class InterpretationText: - """Normalized legal-interpretation full text.""" - - identity: InterpretationIdentity - question: str | None = None - answer: str | None = None - reason: str | None = None - related_laws: str | None = None - referenced_articles: list[ArticleReference] = field(default_factory=list) - text: str = "" - raw: dict[str, Any] = field(default_factory=dict) - - -@dataclass(frozen=True) -class JudicialDecisionIdentity: - """Normalized court case or Constitutional Court decision identity.""" - - decision_id: str | None - title: str - source_type: str - source_target: str - case_number: str | None = None - decision_date: str | None = None - court: str | None = None - court_type_code: str | None = None - case_type: str | None = None - decision_type: str | None = None - data_source: str | None = None - raw_keys: dict[str, Any] = field(default_factory=dict) - - -@dataclass(frozen=True) -class JudicialDecisionHit: - """Search result carrying normalized judicial decision identity.""" - - identity: JudicialDecisionIdentity - raw: dict[str, Any] = field(default_factory=dict) - follow_up: DeferredLookup | None = None - - -@dataclass(frozen=True) -class JudicialDecisionText: - """Normalized court case or Constitutional Court decision text.""" - - identity: JudicialDecisionIdentity - holdings: str | None = None - summary: str | None = None - full_text: str | None = None - referenced_statutes: str | None = None - reviewed_statutes: str | None = None - referenced_cases: str | None = None - referenced_articles: list[ArticleReference] = field(default_factory=list) - reviewed_articles: list[ArticleReference] = field(default_factory=list) - text: str = "" - raw: dict[str, Any] = field(default_factory=dict) - - -@dataclass(frozen=True) -class LegalTermCandidate: - """Legal or everyday term candidate for query planning.""" - - term: str - source_type: str - source_target: str - term_id: str | None = None - relation: str | None = None - note: str | None = None - definition: str | None = None - source_title: str | None = None - raw: dict[str, Any] = field(default_factory=dict) - - -@dataclass(frozen=True) -class LegalArticleCandidate: - """Article candidate discovered during query expansion.""" - - law_name: str | None - law_id: str | None = None - article: str | None = None - title: str | None = None - text: str | None = None - source_target: str | None = None - term: str | None = None - raw: dict[str, Any] = field(default_factory=dict) - - -@dataclass(frozen=True) -class LegalLawCandidate: - """Law or administrative-rule candidate discovered during query expansion.""" - - name: str - law_id: str | None = None - mst: str | None = None - source_type: str = "law" - source_target: str | None = None - relation: str | None = None - article: str | None = None - article_title: str | None = None - promulgation_date: str | None = None - effective_date: str | None = None - promulgation_number: str | None = None - law_type: str | None = None - ministry: str | None = None - raw: dict[str, Any] = field(default_factory=dict) - - -@dataclass(frozen=True) -class FollowUpSearch: - """Recommended next search for the legislative-expert skill.""" - - interface: str - query: str - reason: str - source_type: str | None = None - filters: dict[str, Any] = field(default_factory=dict) - - -@dataclass(frozen=True) -class LegalQueryExpansion: - """Query-planning result; not final legal authority.""" - - original_query: str - law_candidates: list[LawIdentity] - term_candidates: list[LegalTermCandidate] - related_terms: list[LegalTermCandidate] - related_articles: list[LegalArticleCandidate] - related_laws: list[LegalLawCandidate] - follow_up_searches: list[FollowUpSearch] - empty_sources: list[str] = field(default_factory=list) - source_failures: list[ContextGap] = field(default_factory=list) - raw: dict[str, Any] = field(default_factory=dict) - - -@dataclass(frozen=True) -class BundleRequest: - """Request metadata for a legal context bundle.""" - - query: str | None - mode: BundleRequestMode - budget: BundleBudget - articles: list[str | int] = field(default_factory=list) - statute_ids: list[str] = field(default_factory=list) - promulgation_bridge: dict[str, Any] = field(default_factory=dict) - law_identifier: Any = None - as_of: str | None = None - - -@dataclass(frozen=True) -class LoadedContext: - """Official context already loaded for Claude to inspect.""" - - laws: list[LawText] = field(default_factory=list) - articles: list[ArticleText] = field(default_factory=list) - delegations: list[DelegationGraph] = field(default_factory=list) - law_structures: list[LawStructure] = field(default_factory=list) - administrative_rules: list[AdministrativeRuleText] = field(default_factory=list) - annex_forms: list[AnnexFormText] = field(default_factory=list) - interpretations: list[InterpretationText] = field(default_factory=list) - cases: list[JudicialDecisionText] = field(default_factory=list) - constitutional_decisions: list[JudicialDecisionText] = field(default_factory=list) - - -@dataclass(frozen=True) -class CandidateContext: - """Candidate context discovered but not necessarily loaded.""" - - query_expansion: LegalQueryExpansion | None = None - laws: list[LawIdentity] = field(default_factory=list) - administrative_rules: list[AdministrativeRuleHit] = field(default_factory=list) - annex_forms: list[AnnexFormHit] = field(default_factory=list) - interpretations: list[InterpretationHit] = field(default_factory=list) - cases: list[JudicialDecisionHit] = field(default_factory=list) - constitutional_decisions: list[JudicialDecisionHit] = field(default_factory=list) - - -@dataclass(frozen=True) -class DeferredLookup: - """A bounded follow-up lookup the skill may choose to run later.""" - - interface: str - query: str - reason: str - source_type: str | None = None - filters: dict[str, Any] = field(default_factory=dict) - - -@dataclass(frozen=True) -class Ambiguity: - """Ambiguity that must not be silently resolved.""" - - kind: str - message: str - candidates: list[Any] = field(default_factory=list) - - -@dataclass(frozen=True) -class ContextGap: - """Context that should be filled by another source or human review.""" - - kind: str - reason: str - query: str | None = None - recommended_interface: str | None = None - - -@dataclass(frozen=True) -class LegalContextBundle: - """Staged legal context for the legislative-expert skill.""" - - request: BundleRequest - loaded: LoadedContext - candidates: CandidateContext - deferred: list[DeferredLookup] = field(default_factory=list) - ambiguities: list[Ambiguity] = field(default_factory=list) - gaps: list[ContextGap] = field(default_factory=list) - source_notes: list[str] = field(default_factory=list) - - -@dataclass(frozen=True) -class AuthorityContext: - """Authority lookup context scoped to loaded target articles.""" - - request: BundleRequest - target_articles: list[ArticleText] = field(default_factory=list) - loaded: LoadedContext = field(default_factory=LoadedContext) - current_authorities: LoadedContext = field(default_factory=LoadedContext) - candidates: CandidateContext = field(default_factory=CandidateContext) - deferred: list[DeferredLookup] = field(default_factory=list) - gaps: list[ContextGap] = field(default_factory=list) - source_notes: list[str] = field(default_factory=list) - - -_install_serialization_methods() From 146f4654ee849e77974419948399662a2ac57d07 Mon Sep 17 00:00:00 2001 From: seongjin Date: Sat, 4 Jul 2026 13:16:23 +0900 Subject: [PATCH 3/8] refactor(normalization): split payload parsing modules --- moleg_api/_normalization/__init__.py | 19 + moleg_api/_normalization/annex.py | 85 + moleg_api/_normalization/article_units.py | 187 ++ moleg_api/_normalization/articles.py | 137 ++ moleg_api/_normalization/authority.py | 211 +++ moleg_api/_normalization/candidates.py | 99 ++ moleg_api/_normalization/delegation.py | 90 + moleg_api/_normalization/history_events.py | 114 ++ moleg_api/_normalization/history_html.py | 170 ++ moleg_api/_normalization/identities.py | 94 + moleg_api/_normalization/primitives.py | 258 +++ moleg_api/_normalization/references.py | 122 ++ moleg_api/_normalization/row_format.py | 118 ++ moleg_api/_normalization/source_refs.py | 56 + moleg_api/_normalization/structure.py | 157 ++ moleg_api/_normalization/unwrap.py | 113 ++ moleg_api/normalization.py | 1851 +------------------- 17 files changed, 2035 insertions(+), 1846 deletions(-) create mode 100644 moleg_api/_normalization/__init__.py create mode 100644 moleg_api/_normalization/annex.py create mode 100644 moleg_api/_normalization/article_units.py create mode 100644 moleg_api/_normalization/articles.py create mode 100644 moleg_api/_normalization/authority.py create mode 100644 moleg_api/_normalization/candidates.py create mode 100644 moleg_api/_normalization/delegation.py create mode 100644 moleg_api/_normalization/history_events.py create mode 100644 moleg_api/_normalization/history_html.py create mode 100644 moleg_api/_normalization/identities.py create mode 100644 moleg_api/_normalization/primitives.py create mode 100644 moleg_api/_normalization/references.py create mode 100644 moleg_api/_normalization/row_format.py create mode 100644 moleg_api/_normalization/source_refs.py create mode 100644 moleg_api/_normalization/structure.py create mode 100644 moleg_api/_normalization/unwrap.py diff --git a/moleg_api/_normalization/__init__.py b/moleg_api/_normalization/__init__.py new file mode 100644 index 0000000..1049431 --- /dev/null +++ b/moleg_api/_normalization/__init__.py @@ -0,0 +1,19 @@ +"""Private normalization implementation package.""" + +from __future__ import annotations + +from .annex import * +from .article_units import * +from .articles import * +from .authority import * +from .candidates import * +from .delegation import * +from .history_events import * +from .history_html import * +from .identities import * +from .primitives import * +from .references import * +from .row_format import * +from .source_refs import * +from .structure import * +from .unwrap import * diff --git a/moleg_api/_normalization/annex.py b/moleg_api/_normalization/annex.py new file mode 100644 index 0000000..aeb8ed8 --- /dev/null +++ b/moleg_api/_normalization/annex.py @@ -0,0 +1,85 @@ +"""Annex and form identity normalization.""" + +from __future__ import annotations + +from typing import Any, Literal + +from moleg_api.errors import ParseFailureError +from moleg_api.models import AnnexFormIdentity + +from .row_format import annex_number_from_parts +from .primitives import compact_date, compact_promulgation_number, first_value, string_or_none + +def normalize_annex_form_identity( + row: dict[str, Any], + *, + source_type: str, + source_target: str, +) -> AnnexFormIdentity: + info = row.get("기본정보") if isinstance(row.get("기본정보"), dict) else row + title = first_value(info, "별표명", "별표서식명", "LM") + if not title: + raise ParseFailureError("Annex/form identity is missing a title") + + raw_keys = { + key: info.get(key) + for key in ( + "licbyl id", + "admrulbyl id", + "별표일련번호", + "별표가지번호", + "별표서식 가지번호", + "관련법령일련번호", + "관련행정규칙 일련번호", + "관련행정규칙일련번호", + "관련법령ID", + "별표법령 상세링크", + "별표행정규칙 상세링크", + "별표행정규칙상세링크", + ) + if info.get(key) not in (None, "") + } + return AnnexFormIdentity( + annex_id=string_or_none(first_value(info, "licbyl id", "admrulbyl id", "별표일련번호", "ID")), + title=str(title), + source_type=source_type, + source_target=source_target, + related_name=string_or_none(first_value(info, "관련법령명", "관련행정규칙명", "관련자치법규명")), + related_id=string_or_none(first_value(info, "관련법령ID", "관련행정규칙ID", "관련자치법규ID")), + related_serial_id=string_or_none( + first_value( + info, + "관련법령일련번호", + "관련행정규칙 일련번호", + "관련행정규칙일련번호", + "관련자치법규일련번호", + ) + ), + annex_number=annex_number_from_parts( + first_value(info, "별표번호"), + first_value(info, "별표가지번호", "별표서식 가지번호"), + ), + annex_type=string_or_none(first_value(info, "별표종류")), + ministry=string_or_none(first_value(info, "소관부처명", "소관부처")), + promulgation_date=string_or_none(compact_date(first_value(info, "공포일자"))), + promulgation_number=string_or_none(first_value(info, "공포번호")), + issued_on=string_or_none(compact_date(first_value(info, "발령일자"))), + issuing_number=string_or_none(first_value(info, "발령번호")), + revision_type=string_or_none(first_value(info, "제개정구분명")), + law_type=string_or_none(first_value(info, "법령종류", "법령구분명")), + rule_type=string_or_none(first_value(info, "행정규칙종류", "행정규칙 종류명")), + file_link=string_or_none(first_value(info, "별표서식 파일링크", "별표서식파일링크")), + pdf_link=string_or_none(first_value(info, "별표서식 PDF파일링크", "별표서식PDF파일링크")), + detail_link=string_or_none( + first_value( + info, + "별표법령 상세링크", + "별표법령상세링크", + "별표행정규칙 상세링크", + "별표행정규칙상세링크", + "별표자치법규 상세링크", + "별표자치법규상세링크", + ) + ), + raw_keys=raw_keys, + ) diff --git a/moleg_api/_normalization/article_units.py b/moleg_api/_normalization/article_units.py new file mode 100644 index 0000000..a13998f --- /dev/null +++ b/moleg_api/_normalization/article_units.py @@ -0,0 +1,187 @@ +"""Article unit normalization.""" + +from __future__ import annotations + +import re +from typing import Any, Literal + +from moleg_api.models import AdministrativeRuleArticleText, AdministrativeRuleIdentity, ArticleText, LawIdentity + +from .row_format import article_label, article_label_from_parts +from .primitives import compact_date, content_value, ensure_list, first_value, string_or_none +from .source_refs import administrative_rule_source_reference + +def normalize_article(row: dict[str, Any], identity: LawIdentity) -> ArticleText | None: + number = first_value(row, "조문번호", "조번호", "JO") + branch = first_value(row, "조문가지번호", "조가지번호") + text = first_value(row, "조문내용", "조문본문", "내용") + title = first_value(row, "조문제목", "제목") + if number is None and text is None and title is None: + return None + return ArticleText( + identity=identity, + article=article_label_from_parts(number, branch) or "", + title=string_or_none(title), + text=join_article_text(row, str(text or "")), + effective_date=string_or_none(compact_date(first_value(row, "조문시행일자", "시행일자"))), + article_kind=string_or_none(first_value(row, "조문여부")), + revision_type=string_or_none(first_value(row, "조문제개정유형", "제개정유형")), + moved_from=article_label(first_value(row, "조문이동이전", "조문이동이전번호")), + moved_to=article_label(first_value(row, "조문이동이후", "조문이동이후번호")), + has_changes=yes_no_or_none(first_value(row, "조문변경여부")), + is_deleted=is_deleted_article( + str(text or ""), + title=string_or_none(title), + revision_type=string_or_none(first_value(row, "조문제개정유형", "제개정유형")), + ), + raw=row, + ) + + +def normalize_administrative_rule_article( + row: dict[str, Any], + identity: AdministrativeRuleIdentity, +) -> AdministrativeRuleArticleText | None: + number = first_value(row, "조문번호", "조번호", "JO") + branch = first_value(row, "조문가지번호", "조가지번호") + text = first_value(row, "조문내용", "조문본문", "내용", "content") + title = first_value(row, "조문제목", "제목", "title") + if number is None and text is None and title is None: + return None + source_reference = administrative_rule_source_reference(row) + if not any(source_reference.values()): + source_reference = { + "source_law_id": identity.source_law_id, + "source_law_name": identity.source_law_name, + "source_article": identity.source_article, + "source_article_title": identity.source_article_title, + } + else: + source_reference = { + "source_law_id": source_reference["source_law_id"] or identity.source_law_id, + "source_law_name": source_reference["source_law_name"] or identity.source_law_name, + "source_article": source_reference["source_article"], + "source_article_title": source_reference["source_article_title"], + } + return AdministrativeRuleArticleText( + identity=identity, + article=article_label_from_parts(number, branch), + title=string_or_none(title), + text=join_article_text(row, str(text or "")), + effective_date=string_or_none(compact_date(first_value(row, "조문시행일자", "시행일자"))), + article_kind=string_or_none(first_value(row, "조문여부")), + revision_type=string_or_none(first_value(row, "조문제개정유형", "제개정유형")), + moved_from=article_label(first_value(row, "조문이동이전", "조문이동이전번호")), + moved_to=article_label(first_value(row, "조문이동이후", "조문이동이후번호")), + has_changes=yes_no_or_none(first_value(row, "조문변경여부")), + is_deleted=is_deleted_article( + str(text or ""), + title=string_or_none(title), + revision_type=string_or_none(first_value(row, "조문제개정유형", "제개정유형")), + ), + **source_reference, + raw=row, + ) + + +def join_article_text(row: dict[str, Any], base_text: str) -> str: + lines: list[str] = [] + if base_text: + lines.append(base_text) + for line in nested_article_lines(row): + if line and not article_line_already_present(line, lines): + lines.append(line) + return "\n".join(lines) + + +def article_line_already_present(line: str, lines: list[str]) -> bool: + compact_line = compact_whitespace(line) + return any(compact_line in compact_whitespace(existing) for existing in lines) + + +def nested_article_lines(row: dict[str, Any]) -> list[str]: + lines: list[str] = [] + lines.extend(nested_unit_lines(row, *PARAGRAPH_SPEC)) + lines.extend(nested_unit_lines(row, *SUBPARAGRAPH_SPEC)) + lines.extend(nested_unit_lines(row, *ITEM_SPEC)) + return lines + + +NestedSpec = tuple[tuple[str, ...], tuple[str, ...], "NestedSpec | None"] +ITEM_SPEC: NestedSpec = (("목", "목단위"), ("목내용",), None) +SUBPARAGRAPH_SPEC: NestedSpec = (("호", "호단위"), ("호내용",), ITEM_SPEC) +PARAGRAPH_SPEC: NestedSpec = (("항", "항단위"), ("항내용",), SUBPARAGRAPH_SPEC) + + +def nested_unit_lines( + row: dict[str, Any], + container_keys: tuple[str, ...], + text_keys: tuple[str, ...], + child_spec: NestedSpec | None, +) -> list[str]: + lines: list[str] = [] + for unit in child_rows(row, container_keys): + line = string_or_none(first_value(unit, *text_keys, "내용", "content", "text")) + if line: + lines.append(line) + if child_spec: + lines.extend(nested_unit_lines(unit, *child_spec)) + return lines + + +def child_rows(row: dict[str, Any], keys: tuple[str, ...]) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for key in keys: + rows.extend(child_rows_from_container(row.get(key), keys)) + return rows + + +def child_rows_from_container(value: Any, keys: tuple[str, ...]) -> list[dict[str, Any]]: + if value in (None, ""): + return [] + content = content_value(value) + if isinstance(content, dict) and any(key in content for key in keys): + return child_rows(content, keys) + rows: list[dict[str, Any]] = [] + for item in ensure_list(content): + if isinstance(item, dict): + rows.append(item) + return rows + + +def compact_whitespace(text: str) -> str: + return re.sub(r"\s+", "", text) + + +def yes_no_or_none(value: Any) -> bool | None: + if value in (None, ""): + return None + normalized = str(value).strip().upper() + if normalized in {"Y", "YES", "TRUE", "1"}: + return True + if normalized in {"N", "NO", "FALSE", "0"}: + return False + return None + + +def is_deleted_article( + text: str, + *, + title: str | None = None, + revision_type: str | None = None, +) -> bool: + if revision_type and "삭제" in revision_type: + return True + if title and title.strip() == "삭제": + return True + return article_text_marks_deleted(text) + + +def article_text_marks_deleted(text: str) -> bool: + compact = compact_whitespace(text) + return bool( + re.fullmatch( + r"제\d+조(?:의\d+)?(?:\([^)]*\))?삭제(?:[<[【(].*)?", + compact, + ) + ) diff --git a/moleg_api/_normalization/articles.py b/moleg_api/_normalization/articles.py new file mode 100644 index 0000000..d75818d --- /dev/null +++ b/moleg_api/_normalization/articles.py @@ -0,0 +1,137 @@ +"""Article and supplementary-provision extraction.""" + +from __future__ import annotations + +from typing import Any, Literal + +from moleg_api.models import AdministrativeRuleArticleText, AdministrativeRuleIdentity, ArticleText, LawIdentity, SupplementaryProvision + +from .article_units import normalize_administrative_rule_article, normalize_article +from .primitives import compact_date, compact_promulgation_number, ensure_list, first_value, string_or_none +from .row_format import article_label_from_parts + +def extract_articles(raw_law: dict[str, Any], identity: LawIdentity) -> list[ArticleText]: + article_container = raw_law.get("조문") + rows: list[Any] + if isinstance(article_container, dict): + rows = ensure_list( + article_container.get("조문단위") + or article_container.get("조문") + or article_container + ) + else: + rows = ensure_list(article_container) + + articles: list[ArticleText] = [] + for row in rows: + if not isinstance(row, dict): + continue + article = normalize_article(row, identity) + if article: + articles.append(article) + return articles + + +def extract_supplementary_provisions( + raw_source: dict[str, Any], + source_type: Literal["law", "administrative_rule"], +) -> list[SupplementaryProvision]: + """Extract 부칙 rows from law or administrative-rule detail payloads.""" + + rows: list[Any] = [] + top_level_text = first_value(raw_source, "부칙내용", "부칙") + top_level_has_supplement = ( + top_level_text is not None + and not isinstance(top_level_text, (dict, list)) + ) or any(first_value(raw_source, key) is not None for key in ("부칙공포일자", "부칙공포번호")) + if top_level_has_supplement: + rows.append(raw_source) + else: + rows.extend(supplementary_rows(raw_source.get("부칙"))) + rows.extend(supplementary_rows(raw_source.get("부칙단위"))) + + provisions: list[SupplementaryProvision] = [] + for row in rows: + if isinstance(row, dict): + text = first_value(row, "부칙내용", "부칙", "내용", "text") + if text is None or isinstance(text, (dict, list)): + continue + provisions.append( + SupplementaryProvision( + source_type=source_type, + title=string_or_none(first_value(row, "부칙제목", "제목")), + text=str(text), + promulgation_date=string_or_none(compact_date(first_value(row, "부칙공포일자"))), + promulgation_number=compact_promulgation_number(first_value(row, "부칙공포번호")), + raw=row, + ) + ) + elif row not in (None, ""): + provisions.append( + SupplementaryProvision( + source_type=source_type, + text=str(row), + ) + ) + return provisions + + +def supplementary_rows(value: Any) -> list[Any]: + if value in (None, ""): + return [] + if isinstance(value, list): + return value + if isinstance(value, dict): + if any(key in value for key in ("부칙내용", "부칙공포일자", "부칙공포번호")): + return [value] + for key in ("부칙단위", "부칙"): + if key in value: + return supplementary_rows(value[key]) + return [value] + return [value] + + +def extract_administrative_rule_articles( + raw_rule: dict[str, Any], + identity: AdministrativeRuleIdentity, +) -> list[AdministrativeRuleArticleText]: + article_container = raw_rule.get("조문") + if isinstance(article_container, dict): + rows = ensure_list( + article_container.get("조문단위") + or article_container.get("조문") + or article_container + ) + else: + rows = ensure_list(article_container) + + articles: list[AdministrativeRuleArticleText] = [] + for row in rows: + if isinstance(row, dict): + article = normalize_administrative_rule_article(row, identity) + if article: + articles.append(article) + + if articles: + return articles + + flat_text = first_value(raw_rule, "조문내용", "본문", "내용") + if flat_text: + articles.append( + AdministrativeRuleArticleText( + identity=identity, + article=article_label_from_parts( + first_value(raw_rule, "조문번호", "조번호"), + first_value(raw_rule, "조문가지번호", "조가지번호"), + ), + title=string_or_none(first_value(raw_rule, "조문제목", "제목")), + text=str(flat_text), + effective_date=string_or_none(compact_date(first_value(raw_rule, "시행일자"))), + source_law_id=identity.source_law_id, + source_law_name=identity.source_law_name, + source_article=identity.source_article, + source_article_title=identity.source_article_title, + raw=raw_rule, + ) + ) + return articles diff --git a/moleg_api/_normalization/authority.py b/moleg_api/_normalization/authority.py new file mode 100644 index 0000000..ca3f5f3 --- /dev/null +++ b/moleg_api/_normalization/authority.py @@ -0,0 +1,211 @@ +"""Legal interpretation and judicial decision normalization.""" + +from __future__ import annotations + +import re +from typing import Any, Literal + +from moleg_api.errors import ParseFailureError +from moleg_api.models import InterpretationIdentity, InterpretationText, JudicialDecisionIdentity, JudicialDecisionText + +from .primitives import compact_date, first_value, string_or_none +from .references import parse_article_references + +def normalize_interpretation_identity( + row: dict[str, Any], + *, + source_type: str, + source_target: str, + ministry: str | None = None, +) -> InterpretationIdentity: + info = row.get("기본정보") if isinstance(row.get("기본정보"), dict) else row + title = first_value(info, "안건명", "법령해석례명", "법령해석명", "LM") + if not title: + raise ParseFailureError("Interpretation identity is missing a title") + + raw_keys = { + key: info.get(key) + for key in ( + "법령해석례일련번호", + "법령해석일련번호", + "ID", + "expc id", + "법령해석례 상세링크", + "법령해석 상세링크", + ) + if info.get(key) not in (None, "") + } + return InterpretationIdentity( + interpretation_id=string_or_none(first_value(info, "법령해석례일련번호", "법령해석일련번호", "ID", "expc id")), + title=str(title), + source_type=source_type, + source_target=source_target, + case_number=string_or_none(first_value(info, "안건번호")), + interpretation_date=string_or_none(compact_date(first_value(info, "해석일자", "회신일자"))), + reply_agency=string_or_none(first_value(info, "회신기관명", "해석기관명")), + reply_agency_code=string_or_none(first_value(info, "회신기관코드", "해석기관코드")), + inquiry_agency=string_or_none(first_value(info, "질의기관명")), + inquiry_agency_code=string_or_none(first_value(info, "질의기관코드")), + ministry=ministry, + data_timestamp=string_or_none(first_value(info, "데이터기준일시", "등록일시")), + raw_keys=raw_keys, + ) + + +def normalize_interpretation_text( + row: dict[str, Any], + *, + source_type: str, + source_target: str, + ministry: str | None = None, +) -> InterpretationText: + identity = normalize_interpretation_identity( + row, + source_type=source_type, + source_target=source_target, + ministry=ministry, + ) + question = string_or_none(first_value(row, "질의요지", "질의")) + answer = string_or_none(first_value(row, "회답", "답변", "해석")) + reason = string_or_none(first_value(row, "이유", "해석이유")) + related_laws = string_or_none(first_value(row, "관련법령", "관련 법령")) + parts = [] + if question: + parts.append(f"질의요지\n{question}") + if answer: + parts.append(f"회답\n{answer}") + if reason: + parts.append(f"이유\n{reason}") + if related_laws: + parts.append(f"관련법령\n{related_laws}") + return InterpretationText( + identity=identity, + question=question, + answer=answer, + reason=reason, + related_laws=related_laws, + referenced_articles=parse_article_references(related_laws), + text="\n\n".join(parts), + raw=row, + ) + + +def normalize_judicial_decision_identity( + row: dict[str, Any], + *, + source_type: str, + source_target: str, +) -> JudicialDecisionIdentity: + info = row.get("기본정보") if isinstance(row.get("기본정보"), dict) else row + title = first_value(info, "사건명", "판례명", "헌재결정례명", "LM") + if not title: + raise ParseFailureError("Judicial decision identity is missing a title") + + raw_keys = { + key: info.get(key) + for key in ( + "판례일련번호", + "판례정보일련번호", + "헌재결정례일련번호", + "ID", + "판례상세링크", + "헌재결정례 상세링크", + ) + if info.get(key) not in (None, "") + } + return JudicialDecisionIdentity( + decision_id=string_or_none(first_value(info, "판례일련번호", "판례정보일련번호", "헌재결정례일련번호", "ID")), + title=str(title), + source_type=source_type, + source_target=source_target, + case_number=string_or_none(first_value(info, "사건번호")), + decision_date=string_or_none(compact_date(first_value(info, "선고일자", "종국일자"))), + court=string_or_none(first_value(info, "법원명")), + court_type_code=string_or_none(first_value(info, "법원종류코드", "재판부구분코드")), + case_type=string_or_none(first_value(info, "사건종류명")), + decision_type=string_or_none(first_value(info, "판결유형", "선고")), + data_source=string_or_none(first_value(info, "데이터출처명")), + raw_keys=raw_keys, + ) + + +_DISPOSITION_RULES: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("헌법불합치", (r"헌법에\s*합치되지\s*아니한다", r"헌법불합치")), + ("한정위헌", (r"한정위헌",)), + ("한정합헌", (r"한정합헌",)), + ("합헌", (r"헌법에\s*위반되지\s*아니한다", r"헌법에\s*위배되지\s*아니한다")), + ("위헌", (r"위반된다",)), + ("각하", (r"각하한다",)), + ("기각", (r"기각한다",)), + ("인용", (r"인용한다", r"취소한다")), +) + + +def parse_constitutional_disposition(full_text: str | None) -> str | None: + """Disposition(s) (합헌/위헌/헌법불합치/각하/기각 …) from the 주문 of a 헌재 + decision's 전문. Scans ONLY the 주문 slice — the 이유 section repeats these + verbs (dissents) and would mislabel. Returns None when the 주문 is absent.""" + if not full_text: + return None + marker = re.search(r"[【\[]\s*주\s*문\s*[】\]]", full_text) + if not marker: + return None + rest = full_text[marker.end():] + reason = re.search(r"[【\[]\s*이\s*유", rest) + section = rest[: reason.start()] if reason else rest[:2000] + found: list[str] = [] + for label, patterns in _DISPOSITION_RULES: + if label not in found and any(re.search(p, section) for p in patterns): + found.append(label) + return " ".join(found) if found else None + + +def normalize_judicial_decision_text( + row: dict[str, Any], + *, + source_type: str, + source_target: str, +) -> JudicialDecisionText: + identity = normalize_judicial_decision_identity( + row, + source_type=source_type, + source_target=source_target, + ) + holdings = string_or_none(first_value(row, "판시사항")) + summary = string_or_none(first_value(row, "판결요지", "결정요지")) + full_text = string_or_none(first_value(row, "판례내용", "전문")) + # 헌재 detail carries no 판결유형/선고 key, so decision_type is always null — + # recover the disposition from the 주문 so 각하/기각 aren't mistaken for merits. + if source_target == "detc" and not identity.decision_type: + disposition = parse_constitutional_disposition(full_text) + if disposition: + identity = replace(identity, decision_type=disposition) + referenced_statutes = string_or_none(first_value(row, "참조조문")) + reviewed_statutes = string_or_none(first_value(row, "심판대상조문")) + referenced_cases = string_or_none(first_value(row, "참조판례")) + parts = [] + if holdings: + parts.append(f"판시사항\n{holdings}") + if summary: + parts.append(f"요지\n{summary}") + if referenced_statutes: + parts.append(f"참조조문\n{referenced_statutes}") + if reviewed_statutes: + parts.append(f"심판대상조문\n{reviewed_statutes}") + if referenced_cases: + parts.append(f"참조판례\n{referenced_cases}") + if full_text: + parts.append(f"전문\n{full_text}") + return JudicialDecisionText( + identity=identity, + holdings=holdings, + summary=summary, + full_text=full_text, + referenced_statutes=referenced_statutes, + reviewed_statutes=reviewed_statutes, + referenced_cases=referenced_cases, + referenced_articles=parse_article_references(referenced_statutes), + reviewed_articles=parse_article_references(reviewed_statutes), + text="\n\n".join(parts), + raw=row, + ) diff --git a/moleg_api/_normalization/candidates.py b/moleg_api/_normalization/candidates.py new file mode 100644 index 0000000..3f25fa9 --- /dev/null +++ b/moleg_api/_normalization/candidates.py @@ -0,0 +1,99 @@ +"""Query-expansion candidate normalization.""" + +from __future__ import annotations + +from typing import Any, Literal + +from moleg_api.models import LegalArticleCandidate, LegalLawCandidate, LegalTermCandidate + +from .row_format import article_label_from_parts +from .primitives import compact_date, first_value, string_or_none + +def normalize_term_candidate( + row: dict[str, Any], + *, + source_type: str, + source_target: str, +) -> LegalTermCandidate | None: + if source_type == "everyday_term": + term = first_value(row, "일상용어명", "법령용어명", "법령용어명_한글") + else: + term = first_value(row, "법령용어명", "법령용어명_한글", "일상용어명") + if not term: + return None + return LegalTermCandidate( + term=str(term), + source_type=source_type, + source_target=source_target, + term_id=string_or_none( + first_value( + row, + "법령용어 id", + "법령용어ID", + "법령용어 일련번호", + "일상용어 id", + "연계용어 id", + ) + ), + relation=string_or_none(first_value(row, "용어관계", "용어구분")), + note=string_or_none(first_value(row, "비고", "동음이의어 내용")), + definition=string_or_none(first_value(row, "법령용어정의")), + source_title=string_or_none(first_value(row, "출처")), + raw=row, + ) + + +def normalize_related_article_candidate( + row: dict[str, Any], + *, + source_target: str, +) -> LegalArticleCandidate | None: + law_name = string_or_none(first_value(row, "법령명", "행정규칙명")) + text = string_or_none(first_value(row, "조문내용")) + title = string_or_none(first_value(row, "조문제목")) + article = article_label_from_parts( + first_value(row, "조문번호", "조번호"), + first_value(row, "조문가지번호", "조가지번호"), + ) + if not law_name and not text and not title: + return None + return LegalArticleCandidate( + law_name=law_name, + law_id=string_or_none(first_value(row, "법령ID", "행정규칙ID")), + article=article, + title=title, + text=text, + source_target=source_target, + term=string_or_none(first_value(row, "법령용어명", "일상용어명")), + raw=row, + ) + + +def normalize_related_law_candidate( + row: dict[str, Any], + *, + source_target: str, +) -> LegalLawCandidate | None: + name = first_value(row, "관련법령명", "법령명", "행정규칙명") + if not name: + return None + source_type = "administrative_rule" if first_value(row, "행정규칙ID", "행정규칙명") else "law" + return LegalLawCandidate( + name=str(name), + law_id=string_or_none(first_value(row, "관련법령ID", "법령ID", "행정규칙ID")), + mst=string_or_none(first_value(row, "MST", "법령일련번호", "lsi_seq")), + source_type=source_type, + source_target=source_target, + relation=string_or_none(first_value(row, "법령간관계", "제개정구분명", "행정규칙 종류명")), + article=article_label_from_parts( + first_value(row, "조문번호", "조번호"), + first_value(row, "조문가지번호", "조가지번호"), + ), + article_title=string_or_none(first_value(row, "조문제목")), + promulgation_date=string_or_none(compact_date(first_value(row, "공포일자"))), + effective_date=string_or_none(compact_date(first_value(row, "시행일자"))), + promulgation_number=string_or_none(first_value(row, "공포번호")), + law_type=string_or_none(first_value(row, "법령종류명", "법령구분명")), + ministry=string_or_none(first_value(row, "소관부처명", "소관부처")), + raw=row, + ) diff --git a/moleg_api/_normalization/delegation.py b/moleg_api/_normalization/delegation.py new file mode 100644 index 0000000..a0243dc --- /dev/null +++ b/moleg_api/_normalization/delegation.py @@ -0,0 +1,90 @@ +"""Delegated-rule normalization.""" + +from __future__ import annotations + +from typing import Any, Literal + +from moleg_api.models import DelegatedRule + +from .primitives import first_value, string_or_none +from .row_format import article_label_from_parts, collect_rows + +_DELEGATION_TARGET_KEYS = ( + "위임구분", "법종구분", + "위임법령제목", "위임행정규칙제목", "위임자치법규제목", "위임행정규칙명", "법령명", + "법령ID", "위임법령ID", + "위임법령일련번호", "위임행정규칙일련번호", "위임자치법규일련번호", "법령일련번호", + "위임법령조문번호", "위임행정규칙조번호", + "라인텍스트", "조내용", "링크텍스트", +) + + +def _transpose_delegation_info(info: dict[str, Any]) -> list[dict[str, Any]]: + """A single 위임정보 dict can carry parallel lists (one article delegating to + N targets). Transpose into N per-target dicts (scalars broadcast); otherwise + return it unchanged. Prevents delegated_type/name/mst from serializing as a + stringified list and recovers every collapsed multi-target delegation.""" + lengths = [len(info[k]) for k in _DELEGATION_TARGET_KEYS if isinstance(info.get(k), list)] + if not lengths: + return [info] + n = max(lengths) + out: list[dict[str, Any]] = [] + for i in range(n): + item = dict(info) + for key, value in info.items(): + if isinstance(value, list): + item[key] = value[i] if i < len(value) else None + out.append(item) + return out + + +def normalize_delegated_rules(payload: dict[str, Any]) -> list[DelegatedRule]: + rows = collect_rows(payload, "위임조문정보", "위임법령", "위임행정규칙", "위임자치법규") + rules: list[DelegatedRule] = [] + for row in rows: + if not isinstance(row, dict): + continue + if "위임정보" in row or "조정보" in row: + source = row.get("조정보") if isinstance(row.get("조정보"), dict) else {} + raw_info = row.get("위임정보") + # 위임정보 has three shapes when one article delegates to several + # targets: a list of dicts, OR a single dict whose target fields are + # parallel lists. Both must emit one rule per target — collapsing to + # {} (or a single dict) drops/ stringifies multi-target delegations. + if isinstance(raw_info, list): + infos = [item for item in raw_info if isinstance(item, dict)] + elif isinstance(raw_info, dict): + infos = _transpose_delegation_info(raw_info) + else: + infos = [{}] + else: + source = row + infos = [row] + for info in infos: + rule = DelegatedRule( + source_article=article_label_from_parts( + first_value(source, "조문번호", "조번호", "조항호목"), + first_value(source, "조문가지번호", "조가지번호"), + ), + source_article_title=string_or_none(first_value(source, "조문제목", "조제목")), + delegated_type=string_or_none(first_value(info, "위임구분", "법종구분")), + delegated_name=string_or_none( + first_value(info, "위임법령제목", "위임행정규칙제목", "위임자치법규제목", "위임행정규칙명", "법령명") + ), + delegated_law_id=string_or_none(first_value(info, "법령ID", "위임법령ID")), + delegated_mst=string_or_none(first_value(info, "위임법령일련번호", "위임행정규칙일련번호", "위임자치법규일련번호", "법령일련번호")), + delegated_article=article_label_from_parts( + first_value(info, "위임법령조문번호", "위임행정규칙조번호"), + first_value( + info, + "위임법령조문가지번호", + "위임행정규칙조가지번호", + "위임행정규칙조문가지번호", + ), + ), + text=string_or_none(first_value(info, "라인텍스트", "조내용", "링크텍스트")), + raw=row, + ) + if rule.delegated_name or rule.text: + rules.append(rule) + return rules diff --git a/moleg_api/_normalization/history_events.py b/moleg_api/_normalization/history_events.py new file mode 100644 index 0000000..2c51243 --- /dev/null +++ b/moleg_api/_normalization/history_events.py @@ -0,0 +1,114 @@ +"""Law history event normalization.""" + +from __future__ import annotations + +from typing import Any, Literal + +from moleg_api.errors import ParseFailureError +from moleg_api.models import HistoryEvent, LawIdentity + +from .identities import normalize_law_identity +from .primitives import compact_date, compact_promulgation_number, first_value, mask_oc_param, string_or_none +from .row_format import article_label_from_parts, collect_rows + +def normalize_history_events( + payload: dict[str, Any], + identity: LawIdentity, + *, + article_text_map: dict[str, str | None] | None = None, + bill_id_map: dict[tuple[str, str, str], str] | None = None, +) -> list[HistoryEvent]: + rows = collect_rows( + payload, + "law", + "법령", + "조문변경이력", + "조문변경이력목록", + "lsJoHstInf", + "lsHstInf", + "lsHistory", + ) + events: list[HistoryEvent] = [] + for row in rows: + if not isinstance(row, dict): + continue + # The article-scoped (lsJoHstInf) payload nests fields under 조문정보 + # /법령정보; the whole-law (lsHistory HTML) payload is already flat. + # Merge both so the flat first_value reads below work on either shape — + # otherwise the article path loses changed_date/effective_date/revision + # _type/reason and lets `article` fall through to a dict-repr string. + jo = row.get("조문정보") if isinstance(row.get("조문정보"), dict) else {} + li = row.get("법령정보") if isinstance(row.get("법령정보"), dict) else {} + lookup = {**row, **jo, **li} if (jo or li) else row + try: + row_identity = normalize_law_identity(row, basis=identity.basis) + except ParseFailureError: + row_identity = identity + changed_date = string_or_none(compact_date(first_value(lookup, "조문변경일", "조문개정일", "regDt", "공포일자"))) + effective_date = string_or_none(compact_date(first_value(lookup, "조문시행일", "시행일자"))) + article_text = history_event_article_text( + lookup, + changed_date=changed_date, + effective_date=effective_date, + article_text_map=article_text_map, + ) + promulgation_law_name = string_or_none( + first_value(lookup, "법령명한글", "법령명_한글", "법령명") + ) or row_identity.name + promulgation_date = string_or_none(compact_date(first_value(lookup, "공포일자"))) + promulgation_number = compact_promulgation_number(first_value(lookup, "공포번호")) + event = HistoryEvent( + identity=row_identity, + changed_date=changed_date, + effective_date=effective_date, + promulgation_law_name=promulgation_law_name, + promulgation_date=promulgation_date, + promulgation_number=promulgation_number, + bill_id=history_event_bill_id( + bill_id_map, + law_name=promulgation_law_name, + promulgation_number=promulgation_number, + promulgation_date=promulgation_date, + ), + revision_type=string_or_none(first_value(lookup, "제개정구분명", "제개정구분")), + article=article_label_from_parts( + first_value(lookup, "조문번호", "JO"), + first_value(lookup, "조문가지번호", "조가지번호"), + ), + article_text=article_text, + article_link=mask_oc_param(string_or_none(first_value(lookup, "조문링크"))), + reason=string_or_none(first_value(lookup, "변경사유")), + raw=row, + ) + events.append(event) + return events + + +def history_event_article_text( + row: dict[str, Any], + *, + changed_date: str | None, + effective_date: str | None, + article_text_map: dict[str, str | None] | None, +) -> str | None: + source_text = string_or_none(first_value(row, "조문내용", "조문본문", "현행조문내용", "개정조문내용")) + if source_text: + return source_text + if not article_text_map: + return None + for key in (effective_date, changed_date): + if key and key in article_text_map: + return article_text_map[key] + return None + + +def history_event_bill_id( + bill_id_map: dict[tuple[str, str, str], str] | None, + *, + law_name: str | None, + promulgation_number: str | None, + promulgation_date: str | None, +) -> str | None: + if not bill_id_map or not law_name or not promulgation_number or not promulgation_date: + return None + return bill_id_map.get((law_name, promulgation_number, promulgation_date)) diff --git a/moleg_api/_normalization/history_html.py b/moleg_api/_normalization/history_html.py new file mode 100644 index 0000000..426976f --- /dev/null +++ b/moleg_api/_normalization/history_html.py @@ -0,0 +1,170 @@ +"""HTML history and diff normalization.""" + +from __future__ import annotations + +from typing import Any, Literal + +import re +from html.parser import HTMLParser +from urllib.parse import parse_qs, urlparse + +from moleg_api.errors import ParseFailureError +from moleg_api.models import LawDiffChange, LawIdentity + +from .history_events import normalize_history_events +from .primitives import _digits, compact_date, first_value, string_or_none +from .row_format import article_key, article_label, article_rows_from_diff + +def parse_law_history_html(html: str) -> list[dict[str, Any]]: + parser = LawHistoryTableParser() + parser.feed(html) + data_rows = [row for row in parser.rows if row] + # A results table has 9-column rows; a "no results" page carries a single + # message cell. Treat 1-column rows as non-data (empty result), but a row + # that looks like data with the wrong width is a genuine parse breakage. + structured = [row for row in data_rows if len(row) == 9] + suspicious = [row for row in data_rows if len(row) not in (1, 9)] + if suspicious: + raise ParseFailureError("Could not parse lsHistory HTML table: unexpected column count") + rows: list[dict[str, Any]] = [] + for row in structured: + href = row[1]["href"] + link_params = parse_link_params(href) + mst = first_query_value(link_params, "MST") + rows.append( + { + "순번": row[0]["text"], + "법령명한글": row[1]["text"], + "소관부처명": row[2]["text"], + "제개정구분명": row[3]["text"], + "법령구분명": row[4]["text"], + "공포번호": row[5]["text"], + "공포일자": row[6]["text"], + "시행일자": row[7]["text"], + "현행연혁구분": row[8]["text"], + "MST": mst, + "법령일련번호": mst, + "법령상세링크": href, + } + ) + # An empty result (no structured rows) is a legitimate "no history" page, + # not a parse failure — let the caller surface NoResultError instead. + return rows + + +def parse_law_history_total_count(html: str) -> int | None: + match = re.search(r"총\s*\s*([0-9,]+)\s*\s*건", html) + if not match: + return None + return int(match.group(1).replace(",", "")) + + +def parse_link_params(href: str | None) -> dict[str, list[str]]: + if not href: + return {} + return parse_qs(urlparse(href).query) + + +def first_query_value(params: dict[str, list[str]], key: str) -> str | None: + values = params.get(key) + if not values: + return None + return values[0] + + +class LawHistoryTableParser(HTMLParser): + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.rows: list[list[dict[str, str | None]]] = [] + self._current_row: list[dict[str, str | None]] | None = None + self._current_cell_text: list[str] | None = None + self._current_cell_href: str | None = None + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag == "tr": + self._current_row = [] + return + if tag == "td" and self._current_row is not None: + self._current_cell_text = [] + self._current_cell_href = None + return + if tag == "a" and self._current_cell_text is not None: + attr_map = dict(attrs) + self._current_cell_href = attr_map.get("href") + + def handle_data(self, data: str) -> None: + if self._current_cell_text is not None: + self._current_cell_text.append(data) + + def handle_endtag(self, tag: str) -> None: + if tag == "td" and self._current_row is not None and self._current_cell_text is not None: + text = re.sub(r"\s+", " ", "".join(self._current_cell_text)).strip() + self._current_row.append({"text": text, "href": self._current_cell_href}) + self._current_cell_text = None + self._current_cell_href = None + return + if tag == "tr" and self._current_row is not None: + if self._current_row: + self.rows.append(self._current_row) + self._current_row = None + + +def _diff_article_header(text: str) -> str | None: + """The real 제N조[의M] label from the start of a diff row's content, if any.""" + match = re.match(r"\s*제\s*(\d+)\s*조(?:\s*의\s*(\d+))?", text or "") + if not match: + return None + main = int(match.group(1)) + branch = int(match.group(2) or 0) + return f"제{main}조의{branch}" if branch else f"제{main}조" + + +def _diff_row_has_real_article(row: dict[str, Any]) -> bool: + """True when a diff row carries a genuine article identifier (제N조 in `no`, a + 6-digit 조문번호, or explicit 조문번호/조가지번호 fields) rather than a bare + running row index — the latter must not be treated as an article number.""" + no = str(first_value(row, "no") or "").strip() + if no.startswith("제") or "조" in no or re.fullmatch(r"\d{6}", no): + return True + return first_value(row, "조문번호", "조가지번호", "조문가지번호") not in (None, "") + + +def normalize_diff_changes(payload: dict[str, Any], *, article: str | int | None = None) -> list[LawDiffChange]: + before_rows = article_rows_from_diff(payload, "구조문목록") + after_rows = article_rows_from_diff(payload, "신조문목록") + wanted = article_label(article) if article is not None else None + + before_by_no = {article_key(row): row for row in before_rows} + after_by_no = {article_key(row): row for row in after_rows} + # `no` is a sequential ROW index (1,2,3…), a correct index-parallel JOIN key + # for the before/after lists — but NOT the real article number. Walk rows in + # document order (numeric `no`) and derive the DISPLAYED article label from + # the content's 제N조 header, carrying it forward across continuation rows + # (항·호 fragments with no header). Never emit the sequential index as 제N조. + keys = sorted(set(before_by_no) | set(after_by_no), key=lambda k: int(_digits(k) or "0")) + changes: list[LawDiffChange] = [] + current_article: str | None = None + for key in keys: + before_row = before_by_no.get(key, {}) + after_row = after_by_no.get(key, {}) + before_text = str(first_value(before_row, "content", "조문내용", "text") or "") + after_text = str(first_value(after_row, "content", "조문내용", "text") or "") + header = _diff_article_header(after_text) or _diff_article_header(before_text) + if header: + current_article = header + elif (_diff_row_has_real_article(after_row) or _diff_row_has_real_article(before_row)) and key: + # The row carries a genuine article number (not the sequential index). + current_article = key + label = current_article + if wanted and label != wanted: + continue + changes.append( + LawDiffChange( + article=label, + title=string_or_none(first_value(after_row, "title", "조문제목") or first_value(before_row, "title", "조문제목")), + before_text=before_text, + after_text=after_text, + raw={"before": before_row, "after": after_row}, + ) + ) + return changes diff --git a/moleg_api/_normalization/identities.py b/moleg_api/_normalization/identities.py new file mode 100644 index 0000000..b0ff98b --- /dev/null +++ b/moleg_api/_normalization/identities.py @@ -0,0 +1,94 @@ +"""Law and administrative-rule identity normalization.""" + +from __future__ import annotations + +from typing import Any, Literal + +from moleg_api.errors import ParseFailureError +from moleg_api.models import AdministrativeRuleIdentity, Basis, LawIdentity + +from .primitives import ( + ADMINISTRATIVE_RULE_SOURCE_KEYS, + compact_date, + compact_promulgation_number, + first_value, + string_or_none, +) +from .source_refs import administrative_rule_source_reference + +def normalize_law_identity(row: dict[str, Any], *, basis: Basis) -> LawIdentity: + info = row.get("기본정보") if isinstance(row.get("기본정보"), dict) else row + name = first_value(info, "법령명_한글", "법령명한글", "법령명", "법령명약칭") + if not name: + raise ParseFailureError("Law identity is missing a law name") + + raw_keys = { + key: info.get(key) + for key in ( + "법령ID", + "ID", + "MST", + "LID", + "법령일련번호", + "lsi_seq", + "법령상세링크", + ) + if info.get(key) not in (None, "") + } + return LawIdentity( + law_id=string_or_none(first_value(info, "법령ID", "ID")), + mst=string_or_none(first_value(info, "MST", "법령일련번호", "lsi_seq")), + lid=string_or_none(first_value(info, "LID")), + name=str(name), + basis=basis, + promulgation_date=string_or_none(compact_date(first_value(info, "공포일자"))), + effective_date=string_or_none(compact_date(first_value(info, "시행일자"))), + promulgation_number=compact_promulgation_number(first_value(info, "공포번호", "prom_no")), + law_type=string_or_none(first_value(info, "법종구분", "법령구분명")), + ministry=string_or_none(first_value(info, "소관부처", "소관부처명")), + raw_keys=raw_keys, + ) + + +def normalize_administrative_rule_identity(row: dict[str, Any]) -> AdministrativeRuleIdentity: + if isinstance(row.get("기본정보"), dict): + info = row["기본정보"] + elif isinstance(row.get("행정규칙기본정보"), dict): + info = row["행정규칙기본정보"] + else: + info = row + source_info = {**row, **info} if info is not row else info + name = first_value(info, "행정규칙명", "신구법명", "LM") + if not name: + raise ParseFailureError("Administrative-rule identity is missing a name") + + raw_keys = { + key: source_info.get(key) + for key in ( + "행정규칙 일련번호", + "행정규칙일련번호", + "행정규칙ID", + "ID", + "LID", + "행정규칙 상세링크", + "신구법 상세링크", + *ADMINISTRATIVE_RULE_SOURCE_KEYS, + ) + if source_info.get(key) not in (None, "") + } + source_reference = administrative_rule_source_reference(source_info) + return AdministrativeRuleIdentity( + serial_id=string_or_none(first_value(info, "행정규칙 일련번호", "행정규칙일련번호", "ID", "admrul id")), + rule_id=string_or_none(first_value(info, "행정규칙ID", "LID")), + name=str(name), + rule_type=string_or_none(first_value(info, "행정규칙종류", "법령구분명")), + issuing_date=string_or_none(compact_date(first_value(info, "발령일자"))), + issuing_number=string_or_none(first_value(info, "발령번호")), + effective_date=string_or_none(compact_date(first_value(info, "시행일자"))), + ministry=string_or_none(first_value(info, "소관부처명")), + ministry_code=string_or_none(first_value(info, "소관부처코드")), + current_status=string_or_none(first_value(info, "현행여부", "현행연혁구분")), + revision_type=string_or_none(first_value(info, "제개정구분명")), + **source_reference, + raw_keys=raw_keys, + ) diff --git a/moleg_api/_normalization/primitives.py b/moleg_api/_normalization/primitives.py new file mode 100644 index 0000000..0dba947 --- /dev/null +++ b/moleg_api/_normalization/primitives.py @@ -0,0 +1,258 @@ +"""Primitive normalization values and scalar helpers.""" + +from __future__ import annotations + +import re +from datetime import date, datetime +from typing import Any + +LAW_SEARCH_ENVELOPES = ("LawSearch", "lawSearch", "LawSearchService") +ARTICLE_REFERENCE_RE = re.compile(r"제\s*(?P\d+)\s*조(?:\s*의\s*(?P\d+))?") +LAW_NAME_RE = re.compile( + r"[가-힣A-Za-z0-9ㆍ·().\s]+(?:법률|시행령|시행규칙|법|령|규칙|조례|고시|예규|훈령|규정|지침)" +) +MAX_EXPANDED_ARTICLE_RANGE = 100 + +ADMINISTRATIVE_RULE_SOURCE_LAW_ID_KEYS = ( + "위임법령ID", + "위임법령 ID", + "위임 법령ID", + "위임 법령 ID", + "근거법령ID", + "근거법령 ID", + "근거 법령ID", + "근거 법령 ID", + "수권법령ID", + "수권법령 ID", + "수권 법령ID", + "수권 법령 ID", + "상위법령ID", + "상위법령 ID", + "상위 법령ID", + "상위 법령 ID", + "모법령ID", + "모법령 ID", + "모법 ID", + "법적근거법령ID", + "법적근거 법령ID", +) + +ADMINISTRATIVE_RULE_SOURCE_LAW_NAME_KEYS = ( + "위임법령명", + "위임법령", + "위임 법령명", + "위임 법령", + "근거법령명", + "근거법령", + "근거 법령명", + "근거 법령", + "수권법령명", + "수권법령", + "수권 법령명", + "수권 법령", + "상위법령명", + "상위법령", + "상위 법령명", + "상위 법령", + "모법령명", + "모법령", + "모법명", + "모법", + "법적근거법령명", + "법적근거법령", + "법적근거 법령명", + "법적근거 법령", +) + +ADMINISTRATIVE_RULE_SOURCE_ARTICLE_KEYS = ( + "위임조문번호", + "위임조문", + "위임 조문번호", + "위임 조문", + "위임근거조문", + "위임근거 조문", + "근거조문번호", + "근거조문", + "근거 조문번호", + "근거 조문", + "수권조문번호", + "수권조문", + "수권 조문번호", + "수권 조문", + "상위조문번호", + "상위조문", + "상위 조문번호", + "상위 조문", + "모법령조문", + "모법조문", + "법적근거조문", + "법적근거 조문", +) + +ADMINISTRATIVE_RULE_SOURCE_ARTICLE_BRANCH_KEYS = ( + "위임조문가지번호", + "위임 조문가지번호", + "위임 조문 가지번호", + "위임근거조문가지번호", + "위임근거 조문가지번호", + "위임근거 조문 가지번호", + "근거조문가지번호", + "근거 조문가지번호", + "근거 조문 가지번호", + "수권조문가지번호", + "수권 조문가지번호", + "수권 조문 가지번호", + "상위조문가지번호", + "상위 조문가지번호", + "상위 조문 가지번호", + "모법령조문가지번호", + "모법령 조문가지번호", + "모법령 조문 가지번호", + "모법조문가지번호", + "모법 조문가지번호", + "모법 조문 가지번호", + "법적근거조문가지번호", + "법적근거 조문가지번호", + "법적근거 조문 가지번호", +) + +ADMINISTRATIVE_RULE_SOURCE_ARTICLE_TITLE_KEYS = ( + "위임조문제목", + "위임 조문제목", + "위임 조문 제목", + "근거조문제목", + "근거 조문제목", + "근거 조문 제목", + "수권조문제목", + "수권 조문제목", + "상위조문제목", + "상위 조문제목", + "모법령조문제목", + "모법조문제목", + "법적근거조문제목", +) + +ADMINISTRATIVE_RULE_SOURCE_BASIS_KEYS = ( + "위임근거", + "위임 근거", + "근거", + "법적근거", + "법적 근거", + "수권근거", + "수권 근거", +) + +ADMINISTRATIVE_RULE_SOURCE_KEYS = ( + *ADMINISTRATIVE_RULE_SOURCE_LAW_ID_KEYS, + *ADMINISTRATIVE_RULE_SOURCE_LAW_NAME_KEYS, + *ADMINISTRATIVE_RULE_SOURCE_ARTICLE_KEYS, + *ADMINISTRATIVE_RULE_SOURCE_ARTICLE_BRANCH_KEYS, + *ADMINISTRATIVE_RULE_SOURCE_ARTICLE_TITLE_KEYS, + *ADMINISTRATIVE_RULE_SOURCE_BASIS_KEYS, +) +AI_ROW_KEYS = ("법령조문", "법령별표서식", "행정규칙조문", "행정규칙별표서식") + + +def ensure_list(value: Any) -> list[Any]: + if value is None: + return [] + if isinstance(value, list): + return value + return [value] + + +def compact_date(value: str | date | None) -> str | None: + if value is None: + return None + if isinstance(value, datetime): + return value.date().strftime("%Y%m%d") + if isinstance(value, date): + return value.strftime("%Y%m%d") + text = str(value).strip() + if not text: + return None + dotted = re.fullmatch(r"(\d{4})\D+(\d{1,2})\D+(\d{1,2})", text) + if dotted: + year, month, day = dotted.groups() + return f"{year}{int(month):02d}{int(day):02d}" + digits = re.sub(r"\D", "", text) + if len(digits) == 8: + return digits + return text + +def compact_promulgation_number(value: Any) -> str | None: + text = string_or_none(value) + if text is None: + return None + text = re.sub(r"\s+", "", text) + text = re.sub(r"^제", "", text) + text = re.sub(r"호$", "", text) + return text or None + + +def first_value(row: dict[str, Any], *keys: str) -> Any: + for key in keys: + value = row.get(key) + if value not in (None, ""): + return content_value(value) + return None + + +def content_value(value: Any) -> Any: + if isinstance(value, dict) and "content" in value: + return value.get("content") + return value + + +def compact_whitespace(text: str) -> str: + return re.sub(r"\s+", "", text) + + +def yes_no_or_none(value: Any) -> bool | None: + if value in (None, ""): + return None + normalized = str(value).strip().upper() + if normalized in {"Y", "YES", "TRUE", "1"}: + return True + if normalized in {"N", "NO", "FALSE", "0"}: + return False + return None + + +def is_deleted_article( + text: str, + *, + title: str | None = None, + revision_type: str | None = None, +) -> bool: + if revision_type and "삭제" in revision_type: + return True + if title and title.strip() == "삭제": + return True + return article_text_marks_deleted(text) + + +def article_text_marks_deleted(text: str) -> bool: + compact = compact_whitespace(text) + return bool( + re.fullmatch( + r"제\d+조(?:의\d+)?(?:\([^)]*\))?삭제(?:[<[【(].*)?", + compact, + ) + ) + + +def mask_oc_param(link: str | None) -> str | None: + """Mask the OC credential in a law.go.kr link before it is surfaced.""" + if not link: + return link + return re.sub(r"(OC=)[^&]*", r"\1***", link) + +def _digits(value: Any) -> str: + return "".join(ch for ch in str(value) if ch.isdigit()) + + +def string_or_none(value: Any) -> str | None: + if value in (None, ""): + return None + return str(value) diff --git a/moleg_api/_normalization/references.py b/moleg_api/_normalization/references.py new file mode 100644 index 0000000..226a08b --- /dev/null +++ b/moleg_api/_normalization/references.py @@ -0,0 +1,122 @@ +"""Article reference parsing.""" + +from __future__ import annotations + +import re + +from moleg_api.models import ArticleReference + +from .primitives import ARTICLE_REFERENCE_RE, LAW_NAME_RE, MAX_EXPANDED_ARTICLE_RANGE + +def parse_article_references(text: str | None) -> list[ArticleReference]: + """Parse conservative law-name/article references from Korean source text.""" + + if not text: + return [] + source_text = re.sub(r"\s+", " ", str(text)).strip() + if not source_text: + return [] + + references: list[ArticleReference] = [] + current_law_name: str | None = None + previous_end = 0 + matches = list(ARTICLE_REFERENCE_RE.finditer(source_text)) + index = 0 + while index < len(matches): + match = matches[index] + law_name = law_name_before_article(source_text[previous_end : match.start()]) + if law_name: + current_law_name = law_name + article = article_label_from_reference_match(match) + + if not current_law_name: + previous_end = match.end() + index += 1 + continue + + if index + 1 < len(matches): + next_match = matches[index + 1] + delimiter = source_text[match.end() : next_match.start()] + if is_article_range_delimiter(delimiter): + for expanded in expand_article_range(article, article_label_from_reference_match(next_match)): + references.append(ArticleReference(law_name=current_law_name, article=expanded)) + previous_end = next_match.end() + index += 2 + continue + + references.append(ArticleReference(law_name=current_law_name, article=article)) + previous_end = match.end() + index += 1 + + return dedupe_article_references(references) + + +def law_name_before_article(segment: str) -> str | None: + # '및'/'또는' are ambiguous: name-internal (부정청탁 및 …에 관한 법률) OR a + # separator between statutes (제15조 및 데이터기본법). Splitting on them chops + # the internal case; not splitting keeps a leading separator glued on. Resolve + # by not splitting but stripping only a LEADING 및/또는 from the candidate. Also + # drop a trailing promulgation parenthetical ("(2015. 3. 27. 법률 제…호…)") so + # LAW_NAME_RE doesn't consume it as the name. + pieces = re.split(r"[:;,\n/]", segment) + for piece in reversed(pieces): + candidate = piece.strip(" \t\r\n,.;[]{}「」『』\"'") + candidate = re.sub(r"^(?:및|또는)\s+", "", candidate) + candidate = re.split(r"\(\s*\d{4}\.", candidate)[0].strip() + if not candidate: + continue + matches = list(LAW_NAME_RE.finditer(candidate)) + if matches: + return re.sub(r"\s+", " ", matches[-1].group(0)).strip() + return None + + +def article_label_from_reference_match(match: re.Match[str]) -> str: + number = int(match.group("number")) + branch = match.group("branch") + if branch is not None: + return f"제{number}조의{int(branch)}" + return f"제{number}조" + + +def is_article_range_delimiter(delimiter: str) -> bool: + compacted = re.sub(r"\s+", "", delimiter) + return compacted in {"~", "-", "부터", "내지"} + + +def expand_article_range(start: str, end: str) -> list[str]: + start_parts = article_label_parts(start) + end_parts = article_label_parts(end) + if not start_parts or not end_parts: + return [start, end] + + start_number, start_branch = start_parts + end_number, end_branch = end_parts + if start_branch is None and end_branch is None: + if start_number <= end_number <= start_number + MAX_EXPANDED_ARTICLE_RANGE: + return [f"제{number}조" for number in range(start_number, end_number + 1)] + return [start, end] + if start_number == end_number and start_branch is not None and end_branch is not None: + if start_branch <= end_branch <= start_branch + MAX_EXPANDED_ARTICLE_RANGE: + return [f"제{start_number}조의{branch}" for branch in range(start_branch, end_branch + 1)] + return [start, end] + + +def article_label_parts(article: str) -> tuple[int, int | None] | None: + match = re.fullmatch(r"제(\d+)조(?:의(\d+))?", article) + if not match: + return None + branch = match.group(2) + return int(match.group(1)), int(branch) if branch is not None else None + + +def dedupe_article_references(references: list[ArticleReference]) -> list[ArticleReference]: + seen: set[tuple[str, str, str | None]] = set() + deduped: list[ArticleReference] = [] + for reference in references: + key = (reference.law_name, reference.article, reference.law_id) + if key in seen: + continue + seen.add(key) + deduped.append(reference) + return deduped diff --git a/moleg_api/_normalization/row_format.py b/moleg_api/_normalization/row_format.py new file mode 100644 index 0000000..3e716e8 --- /dev/null +++ b/moleg_api/_normalization/row_format.py @@ -0,0 +1,118 @@ +"""Row traversal and article label formatting helpers.""" + +from __future__ import annotations + +from typing import Any, Literal + +import re + +from moleg_api.errors import ParseFailureError + +from .primitives import _digits, ensure_list, first_value + +def collect_rows(obj: Any, *keys: str) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + if isinstance(obj, list): + for item in obj: + rows.extend(collect_rows(item, *keys)) + elif isinstance(obj, dict): + for key in keys: + value = obj.get(key) + if isinstance(value, list): + rows.extend([item for item in value if isinstance(item, dict)]) + elif isinstance(value, dict): + if any(isinstance(value.get(k), (list, dict)) for k in keys): + rows.extend(collect_rows(value, *keys)) + else: + rows.append(value) + if not rows: + for value in obj.values(): + if isinstance(value, (list, dict)): + rows.extend(collect_rows(value, *keys)) + return rows + + +def article_rows_from_diff(payload: dict[str, Any], key: str) -> list[dict[str, Any]]: + container = payload.get(key) + if isinstance(container, dict): + return [row for row in ensure_list(container.get("조문")) if isinstance(row, dict)] + return [] + + +def article_key(row: dict[str, Any]) -> str: + return article_label_from_parts( + first_value(row, "no", "조문번호", "JO"), + first_value(row, "조문가지번호", "조가지번호"), + ) or "" + + +def article_label(value: Any) -> str | None: + if value in (None, ""): + return None + text = str(value).strip() + if text.startswith("제"): + return text + if re.fullmatch(r"\d{6}", text): + main = int(text[:4]) + branch = int(text[4:]) + return f"제{main}조의{branch}" if branch else f"제{main}조" + match = re.fullmatch(r"(\d+)\s*조(?:\s*의\s*(\d+))?", text) + if match: + main = int(match.group(1)) + branch = int(match.group(2) or 0) + return f"제{main}조의{branch}" if branch else f"제{main}조" + if text.isdigit(): + return f"제{int(text)}조" + return text + + +def annex_number_from_parts(number: Any, branch: Any = None) -> str | None: + if number in (None, ""): + return None + text = str(number).strip() + branch_text = str(branch or "").strip() + if not branch_text or not branch_text.isdigit() or int(branch_text) == 0: + return text + if re.search(r"의\s*\d+", text): + return text + return f"{text}의{int(branch_text)}" + + +def article_label_from_parts(number: Any, branch: Any = None) -> str | None: + if number in (None, ""): + return None + text = str(number).strip() + branch_text = str(branch or "").strip() + branch_number = int(branch_text) if branch_text.isdigit() and int(branch_text) != 0 else None + if branch_number is None and re.fullmatch(r"\d{6}", text): + main = int(text[:4]) + source_branch = int(text[4:]) + return f"제{main}조의{source_branch}" if source_branch else f"제{main}조" + if text.startswith("제"): + if branch_number is not None: + match = re.fullmatch(r"제\s*(\d+)\s*조", text) + if match: + return f"제{int(match.group(1))}조의{branch_number}" + return text + if not text.isdigit(): + return text + main = int(text) + if branch_number is not None: + return f"제{main}조의{branch_number}" + return f"제{main}조" + + +def format_article_jo(article: str | int) -> str: + if isinstance(article, int): + return f"{article:04d}00" + text = str(article).strip() + if re.fullmatch(r"\d{6}", text): + return text + match = re.search(r"제?\s*(\d+)\s*조(?:\s*의\s*(\d+))?", text) + if match: + main = int(match.group(1)) + branch = int(match.group(2) or 0) + return f"{main:04d}{branch:02d}" + if text.isdigit(): + return f"{int(text):04d}00" + raise ParseFailureError(f"Unsupported article notation: {article}") diff --git a/moleg_api/_normalization/source_refs.py b/moleg_api/_normalization/source_refs.py new file mode 100644 index 0000000..d5f02d4 --- /dev/null +++ b/moleg_api/_normalization/source_refs.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import re +from typing import Any + +from .primitives import ( + ADMINISTRATIVE_RULE_SOURCE_ARTICLE_BRANCH_KEYS, + ADMINISTRATIVE_RULE_SOURCE_ARTICLE_KEYS, + ADMINISTRATIVE_RULE_SOURCE_ARTICLE_TITLE_KEYS, + ADMINISTRATIVE_RULE_SOURCE_BASIS_KEYS, + ADMINISTRATIVE_RULE_SOURCE_LAW_ID_KEYS, + ADMINISTRATIVE_RULE_SOURCE_LAW_NAME_KEYS, + first_value, + string_or_none, +) +from .row_format import article_label_from_parts + + +def administrative_rule_source_reference(row: dict[str, Any]) -> dict[str, str | None]: + basis_text = string_or_none(first_value(row, *ADMINISTRATIVE_RULE_SOURCE_BASIS_KEYS)) + return { + "source_law_id": string_or_none(first_value(row, *ADMINISTRATIVE_RULE_SOURCE_LAW_ID_KEYS)), + "source_law_name": string_or_none( + first_value(row, *ADMINISTRATIVE_RULE_SOURCE_LAW_NAME_KEYS) + ) + or quoted_law_name(basis_text), + "source_article": article_label_from_parts( + first_value(row, *ADMINISTRATIVE_RULE_SOURCE_ARTICLE_KEYS), + first_value(row, *ADMINISTRATIVE_RULE_SOURCE_ARTICLE_BRANCH_KEYS), + ) + or article_from_source_basis(basis_text), + "source_article_title": string_or_none( + first_value(row, *ADMINISTRATIVE_RULE_SOURCE_ARTICLE_TITLE_KEYS) + ), + } + + +def quoted_law_name(text: str | None) -> str | None: + if not text: + return None + match = re.search(r"[「『](.+?)[」』]", text) + if not match: + return None + return match.group(1).strip() or None + + +def article_from_source_basis(text: str | None) -> str | None: + if not text: + return None + match = re.search( + r"제\s*\d+\s*조(?:\s*의\s*\d+)?(?:\s*제\s*\d+\s*항)?(?:\s*제\s*\d+\s*호)?", + text, + ) + if not match: + return None + return re.sub(r"\s+", "", match.group(0)) diff --git a/moleg_api/_normalization/structure.py b/moleg_api/_normalization/structure.py new file mode 100644 index 0000000..2624d8f --- /dev/null +++ b/moleg_api/_normalization/structure.py @@ -0,0 +1,157 @@ +"""Law hierarchy normalization.""" + +from __future__ import annotations + +from typing import Any, Literal + +from moleg_api.errors import ParseFailureError +from moleg_api.models import LawIdentity, LawStructure, LawStructureNode + +from .identities import normalize_administrative_rule_identity, normalize_law_identity +from .primitives import first_value, string_or_none + +def normalize_law_structure(raw_structure: dict[str, Any], *, max_depth: int = 0) -> LawStructure: + if not isinstance(raw_structure, dict): + raise ParseFailureError("Law structure payload must be an object") + root_info = raw_structure.get("기본정보") + if not isinstance(root_info, dict): + raise ParseFailureError("Law structure payload is missing 기본정보") + hierarchy = raw_structure.get("상하위법") + if hierarchy in (None, ""): + hierarchy = {} + if not isinstance(hierarchy, dict): + raise ParseFailureError("Law structure 상하위법 must be an object") + + root_identity = normalize_law_identity(root_info, basis="effective") + root_node = structure_root_node(hierarchy, root_identity) + instruments = law_structure_children(root_node, depth_remaining=max_depth) + return LawStructure(identity=root_identity, instruments=instruments, raw=raw_structure) + + +def structure_root_node(hierarchy: dict[str, Any], root_identity: LawIdentity) -> dict[str, Any]: + for value in hierarchy.values(): + for node in structure_node_values(value): + info = node.get("기본정보") if isinstance(node, dict) else None + if isinstance(info, dict): + identity = normalize_law_identity(info, basis="effective") + if ( + identity.law_id == root_identity.law_id + or identity.mst == root_identity.mst + or identity.name == root_identity.name + ): + return node + for value in hierarchy.values(): + for node in structure_node_values(value): + if isinstance(node, dict) and isinstance(node.get("기본정보"), dict): + return node + return {} + + +def law_structure_children(container: dict[str, Any], *, depth_remaining: int) -> list[LawStructureNode]: + if not isinstance(container, dict): + raise ParseFailureError("Law structure node must be an object") + children: list[LawStructureNode] = [] + for key, value in container.items(): + if key in {"기본정보", "자치법규"}: + continue + if key in {"시행령", "시행규칙", "법률"}: + children.extend(law_structure_law_nodes(value, key, depth_remaining=depth_remaining)) + elif key == "행정규칙": + children.extend(law_structure_administrative_nodes(value)) + return children + + +def law_structure_law_nodes(value: Any, key: str, *, depth_remaining: int) -> list[LawStructureNode]: + nodes: list[LawStructureNode] = [] + for node in structure_node_values(value): + info = node.get("기본정보") + if not isinstance(info, dict): + raise ParseFailureError(f"Law structure {key} node is missing 기본정보") + identity = normalize_law_identity(info, basis="effective") + children = law_structure_children(node, depth_remaining=depth_remaining - 1) if depth_remaining > 0 else [] + nodes.append( + LawStructureNode( + name=identity.name, + source_type="law", + instrument_type=instrument_type_for_law_node(key, identity.law_type), + law_id=identity.law_id, + mst=identity.mst, + law_type=identity.law_type, + effective_date=identity.effective_date, + promulgation_date=identity.promulgation_date, + promulgation_number=identity.promulgation_number, + ministry=identity.ministry, + detail_link=string_or_none(first_value(info, "본문상세링크", "법령상세링크")), + children=children, + raw=node, + ) + ) + return nodes + + +def law_structure_administrative_nodes(value: Any) -> list[LawStructureNode]: + if value in (None, ""): + return [] + if not isinstance(value, dict): + raise ParseFailureError("Law structure 행정규칙 must be an object") + nodes: list[LawStructureNode] = [] + for rule_type, rule_value in value.items(): + for node in structure_node_values(rule_value): + info = node.get("기본정보") + if not isinstance(info, dict): + raise ParseFailureError(f"Law structure {rule_type} node is missing 기본정보") + identity = normalize_administrative_rule_identity(info) + nodes.append( + LawStructureNode( + name=identity.name, + source_type="administrative_rule", + instrument_type=instrument_type_for_admin_rule(rule_type), + serial_id=identity.serial_id, + rule_id=identity.rule_id, + law_type=identity.rule_type, + effective_date=identity.effective_date, + issuing_date=identity.issuing_date, + issuing_number=identity.issuing_number, + ministry=identity.ministry, + detail_link=string_or_none(first_value(info, "본문상세링크", "행정규칙 상세링크")), + raw=node, + ) + ) + return nodes + + +def structure_node_values(value: Any) -> list[dict[str, Any]]: + if value in (None, ""): + return [] + if isinstance(value, list): + if not all(isinstance(item, dict) for item in value): + raise ParseFailureError("Law structure node list contains non-object entries") + return value + if isinstance(value, dict): + if "기본정보" in value: + return [value] + nodes: list[dict[str, Any]] = [] + for child in value.values(): + nodes.extend(structure_node_values(child)) + return nodes + raise ParseFailureError("Law structure node must be an object or list") + + +def instrument_type_for_law_node(key: str, law_type: str | None) -> str: + if key == "시행령": + return "enforcement_decree" + if key == "시행규칙": + return "enforcement_rule" + if key == "법률": + return "related_law" + if law_type: + return law_type + return key + + +def instrument_type_for_admin_rule(rule_type: str) -> str: + return { + "고시": "notice", + "훈령": "directive", + "예규": "established_rule", + }.get(rule_type, rule_type) diff --git a/moleg_api/_normalization/unwrap.py b/moleg_api/_normalization/unwrap.py new file mode 100644 index 0000000..a8a4cb5 --- /dev/null +++ b/moleg_api/_normalization/unwrap.py @@ -0,0 +1,113 @@ +"""Payload unwrapping helpers for MOLEG search/service responses.""" + +from __future__ import annotations + +from typing import Any, Literal + +from moleg_api.errors import NoResultError + +from .primitives import AI_ROW_KEYS, LAW_SEARCH_ENVELOPES, ensure_list +from .row_format import collect_rows + +def unwrap_search_laws(payload: dict[str, Any]) -> list[dict[str, Any]]: + for envelope in LAW_SEARCH_ENVELOPES: + if isinstance(payload.get(envelope), dict): + rows = payload[envelope].get("law") + return [row for row in ensure_list(rows) if isinstance(row, dict)] + rows = payload.get("law") + return [row for row in ensure_list(rows) if isinstance(row, dict)] + + +def unwrap_search_administrative_rules(payload: dict[str, Any]) -> list[dict[str, Any]]: + for envelope in ("AdmRulSearch", "admrulSearch", "AdmrulSearch", "AdmRulSearchService"): + if isinstance(payload.get(envelope), dict): + rows = payload[envelope].get("admrul") + return [row for row in ensure_list(rows) if isinstance(row, dict)] + rows = payload.get("admrul") + if rows is not None: + return [row for row in ensure_list(rows) if isinstance(row, dict)] + return collect_rows(payload, "admrul") + + +def unwrap_search_interpretations(payload: dict[str, Any], target: str) -> list[dict[str, Any]]: + row_keys = tuple(dict.fromkeys((target, "expc", "cgmExpc"))) + for envelope in ( + "ExpcSearch", + "expcSearch", + "Expc", + "expc", + "CgmExpcSearch", + "cgmExpcSearch", + "CgmExpc", + "cgmExpc", + ): + if isinstance(payload.get(envelope), dict): + rows = next((payload[envelope].get(key) for key in row_keys if key in payload[envelope]), None) + return [row for row in ensure_list(rows) if isinstance(row, dict)] + rows = next((payload.get(key) for key in row_keys if key in payload), None) + if rows is not None: + return [row for row in ensure_list(rows) if isinstance(row, dict)] + return collect_rows(payload, *row_keys) + + +def unwrap_search_judicial_decisions(payload: dict[str, Any], target: str) -> list[dict[str, Any]]: + for envelope in ("PrecSearch", "precSearch", "DetcSearch", "detcSearch"): + if isinstance(payload.get(envelope), dict): + env = payload[envelope] + # law.go.kr names the row-element key by its own casing, not the + # request target: "prec" (lowercase) for cases but "Detc" + # (capitalized) for constitutional decisions. Match it + # case-insensitively, excluding the scalar "target" echo field. + rows = env.get(target) + if rows is None: + rows = next( + ( + env[key] + for key in env + if isinstance(key, str) + and key.lower() == target.lower() + and key != "target" + ), + None, + ) + return [row for row in ensure_list(rows) if isinstance(row, dict)] + rows = payload.get(target) + if rows is not None: + return [row for row in ensure_list(rows) if isinstance(row, dict)] + return collect_rows(payload, target) + + +def unwrap_target_rows(payload: dict[str, Any], target: str) -> list[dict[str, Any]]: + if target in ("aiSearch", "aiRltLs"): + rows = collect_rows(payload, *AI_ROW_KEYS) + if rows or isinstance(payload.get(target), dict): + return rows + rows = payload.get(target) + if rows is not None: + return [row for row in ensure_list(rows) if isinstance(row, dict)] + for value in payload.values(): + if isinstance(value, dict): + nested = value.get(target) + if nested is not None: + return [row for row in ensure_list(nested) if isinstance(row, dict)] + return collect_rows(payload, target) + + +def unwrap_service_payload(payload: dict[str, Any], target: str) -> dict[str, Any]: + if isinstance(payload.get(target), dict): + return payload[target] + if len(payload) == 1: + only = next(iter(payload.values())) + if isinstance(only, dict): + return only + if is_no_result_message(only): + raise NoResultError(str(only)) + if "기본정보" in payload or "조문" in payload: + return payload + raise ParseFailureError(f"Could not unwrap service payload for target {target}") + + +def is_no_result_message(value: Any) -> bool: + if not isinstance(value, str): + return False + return "일치하는" in value and "없습니다" in value diff --git a/moleg_api/normalization.py b/moleg_api/normalization.py index 902977e..692acea 100644 --- a/moleg_api/normalization.py +++ b/moleg_api/normalization.py @@ -2,1849 +2,8 @@ from __future__ import annotations -import re -from dataclasses import replace -from datetime import date, datetime -from html.parser import HTMLParser -from typing import Any, Literal -from urllib.parse import parse_qs, urlparse - -from .errors import NoResultError, ParseFailureError -from .models import ( - AdministrativeRuleArticleText, - AdministrativeRuleIdentity, - AnnexFormIdentity, - ArticleReference, - ArticleText, - Basis, - DelegatedRule, - HistoryEvent, - InterpretationIdentity, - InterpretationText, - JudicialDecisionIdentity, - JudicialDecisionText, - LegalArticleCandidate, - LegalLawCandidate, - LegalTermCandidate, - LawDiffChange, - LawIdentity, - LawStructure, - LawStructureNode, - SupplementaryProvision, -) - - -LAW_SEARCH_ENVELOPES = ("LawSearch", "lawSearch", "LawSearchService") -ARTICLE_REFERENCE_RE = re.compile(r"제\s*(?P\d+)\s*조(?:\s*의\s*(?P\d+))?") -LAW_NAME_RE = re.compile( - r"[가-힣A-Za-z0-9ㆍ·().\s]+(?:법률|시행령|시행규칙|법|령|규칙|조례|고시|예규|훈령|규정|지침)" -) -MAX_EXPANDED_ARTICLE_RANGE = 100 - -ADMINISTRATIVE_RULE_SOURCE_LAW_ID_KEYS = ( - "위임법령ID", - "위임법령 ID", - "위임 법령ID", - "위임 법령 ID", - "근거법령ID", - "근거법령 ID", - "근거 법령ID", - "근거 법령 ID", - "수권법령ID", - "수권법령 ID", - "수권 법령ID", - "수권 법령 ID", - "상위법령ID", - "상위법령 ID", - "상위 법령ID", - "상위 법령 ID", - "모법령ID", - "모법령 ID", - "모법 ID", - "법적근거법령ID", - "법적근거 법령ID", -) - -ADMINISTRATIVE_RULE_SOURCE_LAW_NAME_KEYS = ( - "위임법령명", - "위임법령", - "위임 법령명", - "위임 법령", - "근거법령명", - "근거법령", - "근거 법령명", - "근거 법령", - "수권법령명", - "수권법령", - "수권 법령명", - "수권 법령", - "상위법령명", - "상위법령", - "상위 법령명", - "상위 법령", - "모법령명", - "모법령", - "모법명", - "모법", - "법적근거법령명", - "법적근거법령", - "법적근거 법령명", - "법적근거 법령", -) - -ADMINISTRATIVE_RULE_SOURCE_ARTICLE_KEYS = ( - "위임조문번호", - "위임조문", - "위임 조문번호", - "위임 조문", - "위임근거조문", - "위임근거 조문", - "근거조문번호", - "근거조문", - "근거 조문번호", - "근거 조문", - "수권조문번호", - "수권조문", - "수권 조문번호", - "수권 조문", - "상위조문번호", - "상위조문", - "상위 조문번호", - "상위 조문", - "모법령조문", - "모법조문", - "법적근거조문", - "법적근거 조문", -) - -ADMINISTRATIVE_RULE_SOURCE_ARTICLE_BRANCH_KEYS = ( - "위임조문가지번호", - "위임 조문가지번호", - "위임 조문 가지번호", - "위임근거조문가지번호", - "위임근거 조문가지번호", - "위임근거 조문 가지번호", - "근거조문가지번호", - "근거 조문가지번호", - "근거 조문 가지번호", - "수권조문가지번호", - "수권 조문가지번호", - "수권 조문 가지번호", - "상위조문가지번호", - "상위 조문가지번호", - "상위 조문 가지번호", - "모법령조문가지번호", - "모법령 조문가지번호", - "모법령 조문 가지번호", - "모법조문가지번호", - "모법 조문가지번호", - "모법 조문 가지번호", - "법적근거조문가지번호", - "법적근거 조문가지번호", - "법적근거 조문 가지번호", -) - -ADMINISTRATIVE_RULE_SOURCE_ARTICLE_TITLE_KEYS = ( - "위임조문제목", - "위임 조문제목", - "위임 조문 제목", - "근거조문제목", - "근거 조문제목", - "근거 조문 제목", - "수권조문제목", - "수권 조문제목", - "상위조문제목", - "상위 조문제목", - "모법령조문제목", - "모법조문제목", - "법적근거조문제목", -) - -ADMINISTRATIVE_RULE_SOURCE_BASIS_KEYS = ( - "위임근거", - "위임 근거", - "근거", - "법적근거", - "법적 근거", - "수권근거", - "수권 근거", -) - -ADMINISTRATIVE_RULE_SOURCE_KEYS = ( - *ADMINISTRATIVE_RULE_SOURCE_LAW_ID_KEYS, - *ADMINISTRATIVE_RULE_SOURCE_LAW_NAME_KEYS, - *ADMINISTRATIVE_RULE_SOURCE_ARTICLE_KEYS, - *ADMINISTRATIVE_RULE_SOURCE_ARTICLE_BRANCH_KEYS, - *ADMINISTRATIVE_RULE_SOURCE_ARTICLE_TITLE_KEYS, - *ADMINISTRATIVE_RULE_SOURCE_BASIS_KEYS, -) -AI_ROW_KEYS = ("법령조문", "법령별표서식", "행정규칙조문", "행정규칙별표서식") - - -def ensure_list(value: Any) -> list[Any]: - if value is None: - return [] - if isinstance(value, list): - return value - return [value] - - -def compact_date(value: str | date | None) -> str | None: - if value is None: - return None - if isinstance(value, datetime): - return value.date().strftime("%Y%m%d") - if isinstance(value, date): - return value.strftime("%Y%m%d") - text = str(value).strip() - if not text: - return None - dotted = re.fullmatch(r"(\d{4})\D+(\d{1,2})\D+(\d{1,2})", text) - if dotted: - year, month, day = dotted.groups() - return f"{year}{int(month):02d}{int(day):02d}" - digits = re.sub(r"\D", "", text) - if len(digits) == 8: - return digits - return text - - -def parse_article_references(text: str | None) -> list[ArticleReference]: - """Parse conservative law-name/article references from Korean source text.""" - - if not text: - return [] - source_text = re.sub(r"\s+", " ", str(text)).strip() - if not source_text: - return [] - - references: list[ArticleReference] = [] - current_law_name: str | None = None - previous_end = 0 - matches = list(ARTICLE_REFERENCE_RE.finditer(source_text)) - index = 0 - while index < len(matches): - match = matches[index] - law_name = law_name_before_article(source_text[previous_end : match.start()]) - if law_name: - current_law_name = law_name - article = article_label_from_reference_match(match) - - if not current_law_name: - previous_end = match.end() - index += 1 - continue - - if index + 1 < len(matches): - next_match = matches[index + 1] - delimiter = source_text[match.end() : next_match.start()] - if is_article_range_delimiter(delimiter): - for expanded in expand_article_range(article, article_label_from_reference_match(next_match)): - references.append(ArticleReference(law_name=current_law_name, article=expanded)) - previous_end = next_match.end() - index += 2 - continue - - references.append(ArticleReference(law_name=current_law_name, article=article)) - previous_end = match.end() - index += 1 - - return dedupe_article_references(references) - - -def law_name_before_article(segment: str) -> str | None: - # '및'/'또는' are ambiguous: name-internal (부정청탁 및 …에 관한 법률) OR a - # separator between statutes (제15조 및 데이터기본법). Splitting on them chops - # the internal case; not splitting keeps a leading separator glued on. Resolve - # by not splitting but stripping only a LEADING 및/또는 from the candidate. Also - # drop a trailing promulgation parenthetical ("(2015. 3. 27. 법률 제…호…)") so - # LAW_NAME_RE doesn't consume it as the name. - pieces = re.split(r"[:;,\n/]", segment) - for piece in reversed(pieces): - candidate = piece.strip(" \t\r\n,.;[]{}「」『』\"'") - candidate = re.sub(r"^(?:및|또는)\s+", "", candidate) - candidate = re.split(r"\(\s*\d{4}\.", candidate)[0].strip() - if not candidate: - continue - matches = list(LAW_NAME_RE.finditer(candidate)) - if matches: - return re.sub(r"\s+", " ", matches[-1].group(0)).strip() - return None - - -def article_label_from_reference_match(match: re.Match[str]) -> str: - number = int(match.group("number")) - branch = match.group("branch") - if branch is not None: - return f"제{number}조의{int(branch)}" - return f"제{number}조" - - -def is_article_range_delimiter(delimiter: str) -> bool: - compacted = re.sub(r"\s+", "", delimiter) - return compacted in {"~", "-", "부터", "내지"} - - -def expand_article_range(start: str, end: str) -> list[str]: - start_parts = article_label_parts(start) - end_parts = article_label_parts(end) - if not start_parts or not end_parts: - return [start, end] - - start_number, start_branch = start_parts - end_number, end_branch = end_parts - if start_branch is None and end_branch is None: - if start_number <= end_number <= start_number + MAX_EXPANDED_ARTICLE_RANGE: - return [f"제{number}조" for number in range(start_number, end_number + 1)] - return [start, end] - if start_number == end_number and start_branch is not None and end_branch is not None: - if start_branch <= end_branch <= start_branch + MAX_EXPANDED_ARTICLE_RANGE: - return [f"제{start_number}조의{branch}" for branch in range(start_branch, end_branch + 1)] - return [start, end] - - -def article_label_parts(article: str) -> tuple[int, int | None] | None: - match = re.fullmatch(r"제(\d+)조(?:의(\d+))?", article) - if not match: - return None - branch = match.group(2) - return int(match.group(1)), int(branch) if branch is not None else None - - -def dedupe_article_references(references: list[ArticleReference]) -> list[ArticleReference]: - seen: set[tuple[str, str, str | None]] = set() - deduped: list[ArticleReference] = [] - for reference in references: - key = (reference.law_name, reference.article, reference.law_id) - if key in seen: - continue - seen.add(key) - deduped.append(reference) - return deduped - - -def compact_promulgation_number(value: Any) -> str | None: - text = string_or_none(value) - if text is None: - return None - text = re.sub(r"\s+", "", text) - text = re.sub(r"^제", "", text) - text = re.sub(r"호$", "", text) - return text or None - - -def first_value(row: dict[str, Any], *keys: str) -> Any: - for key in keys: - value = row.get(key) - if value not in (None, ""): - return content_value(value) - return None - - -def content_value(value: Any) -> Any: - if isinstance(value, dict) and "content" in value: - return value.get("content") - return value - - -def administrative_rule_source_reference(row: dict[str, Any]) -> dict[str, str | None]: - basis_text = string_or_none(first_value(row, *ADMINISTRATIVE_RULE_SOURCE_BASIS_KEYS)) - return { - "source_law_id": string_or_none(first_value(row, *ADMINISTRATIVE_RULE_SOURCE_LAW_ID_KEYS)), - "source_law_name": string_or_none( - first_value(row, *ADMINISTRATIVE_RULE_SOURCE_LAW_NAME_KEYS) - ) - or quoted_law_name(basis_text), - "source_article": article_label_from_parts( - first_value(row, *ADMINISTRATIVE_RULE_SOURCE_ARTICLE_KEYS), - first_value(row, *ADMINISTRATIVE_RULE_SOURCE_ARTICLE_BRANCH_KEYS), - ) - or article_from_source_basis(basis_text), - "source_article_title": string_or_none( - first_value(row, *ADMINISTRATIVE_RULE_SOURCE_ARTICLE_TITLE_KEYS) - ), - } - - -def quoted_law_name(text: str | None) -> str | None: - if not text: - return None - match = re.search(r"[「『](.+?)[」』]", text) - if not match: - return None - return match.group(1).strip() or None - - -def article_from_source_basis(text: str | None) -> str | None: - if not text: - return None - match = re.search( - r"제\s*\d+\s*조(?:\s*의\s*\d+)?(?:\s*제\s*\d+\s*항)?(?:\s*제\s*\d+\s*호)?", - text, - ) - if not match: - return None - return re.sub(r"\s+", "", match.group(0)) - - -def normalize_law_identity(row: dict[str, Any], *, basis: Basis) -> LawIdentity: - info = row.get("기본정보") if isinstance(row.get("기본정보"), dict) else row - name = first_value(info, "법령명_한글", "법령명한글", "법령명", "법령명약칭") - if not name: - raise ParseFailureError("Law identity is missing a law name") - - raw_keys = { - key: info.get(key) - for key in ( - "법령ID", - "ID", - "MST", - "LID", - "법령일련번호", - "lsi_seq", - "법령상세링크", - ) - if info.get(key) not in (None, "") - } - return LawIdentity( - law_id=string_or_none(first_value(info, "법령ID", "ID")), - mst=string_or_none(first_value(info, "MST", "법령일련번호", "lsi_seq")), - lid=string_or_none(first_value(info, "LID")), - name=str(name), - basis=basis, - promulgation_date=string_or_none(compact_date(first_value(info, "공포일자"))), - effective_date=string_or_none(compact_date(first_value(info, "시행일자"))), - promulgation_number=compact_promulgation_number(first_value(info, "공포번호", "prom_no")), - law_type=string_or_none(first_value(info, "법종구분", "법령구분명")), - ministry=string_or_none(first_value(info, "소관부처", "소관부처명")), - raw_keys=raw_keys, - ) - - -def normalize_administrative_rule_identity(row: dict[str, Any]) -> AdministrativeRuleIdentity: - if isinstance(row.get("기본정보"), dict): - info = row["기본정보"] - elif isinstance(row.get("행정규칙기본정보"), dict): - info = row["행정규칙기본정보"] - else: - info = row - source_info = {**row, **info} if info is not row else info - name = first_value(info, "행정규칙명", "신구법명", "LM") - if not name: - raise ParseFailureError("Administrative-rule identity is missing a name") - - raw_keys = { - key: source_info.get(key) - for key in ( - "행정규칙 일련번호", - "행정규칙일련번호", - "행정규칙ID", - "ID", - "LID", - "행정규칙 상세링크", - "신구법 상세링크", - *ADMINISTRATIVE_RULE_SOURCE_KEYS, - ) - if source_info.get(key) not in (None, "") - } - source_reference = administrative_rule_source_reference(source_info) - return AdministrativeRuleIdentity( - serial_id=string_or_none(first_value(info, "행정규칙 일련번호", "행정규칙일련번호", "ID", "admrul id")), - rule_id=string_or_none(first_value(info, "행정규칙ID", "LID")), - name=str(name), - rule_type=string_or_none(first_value(info, "행정규칙종류", "법령구분명")), - issuing_date=string_or_none(compact_date(first_value(info, "발령일자"))), - issuing_number=string_or_none(first_value(info, "발령번호")), - effective_date=string_or_none(compact_date(first_value(info, "시행일자"))), - ministry=string_or_none(first_value(info, "소관부처명")), - ministry_code=string_or_none(first_value(info, "소관부처코드")), - current_status=string_or_none(first_value(info, "현행여부", "현행연혁구분")), - revision_type=string_or_none(first_value(info, "제개정구분명")), - **source_reference, - raw_keys=raw_keys, - ) - - -def normalize_law_structure(raw_structure: dict[str, Any], *, max_depth: int = 0) -> LawStructure: - if not isinstance(raw_structure, dict): - raise ParseFailureError("Law structure payload must be an object") - root_info = raw_structure.get("기본정보") - if not isinstance(root_info, dict): - raise ParseFailureError("Law structure payload is missing 기본정보") - hierarchy = raw_structure.get("상하위법") - if hierarchy in (None, ""): - hierarchy = {} - if not isinstance(hierarchy, dict): - raise ParseFailureError("Law structure 상하위법 must be an object") - - root_identity = normalize_law_identity(root_info, basis="effective") - root_node = structure_root_node(hierarchy, root_identity) - instruments = law_structure_children(root_node, depth_remaining=max_depth) - return LawStructure(identity=root_identity, instruments=instruments, raw=raw_structure) - - -def structure_root_node(hierarchy: dict[str, Any], root_identity: LawIdentity) -> dict[str, Any]: - for value in hierarchy.values(): - for node in structure_node_values(value): - info = node.get("기본정보") if isinstance(node, dict) else None - if isinstance(info, dict): - identity = normalize_law_identity(info, basis="effective") - if ( - identity.law_id == root_identity.law_id - or identity.mst == root_identity.mst - or identity.name == root_identity.name - ): - return node - for value in hierarchy.values(): - for node in structure_node_values(value): - if isinstance(node, dict) and isinstance(node.get("기본정보"), dict): - return node - return {} - - -def law_structure_children(container: dict[str, Any], *, depth_remaining: int) -> list[LawStructureNode]: - if not isinstance(container, dict): - raise ParseFailureError("Law structure node must be an object") - children: list[LawStructureNode] = [] - for key, value in container.items(): - if key in {"기본정보", "자치법규"}: - continue - if key in {"시행령", "시행규칙", "법률"}: - children.extend(law_structure_law_nodes(value, key, depth_remaining=depth_remaining)) - elif key == "행정규칙": - children.extend(law_structure_administrative_nodes(value)) - return children - - -def law_structure_law_nodes(value: Any, key: str, *, depth_remaining: int) -> list[LawStructureNode]: - nodes: list[LawStructureNode] = [] - for node in structure_node_values(value): - info = node.get("기본정보") - if not isinstance(info, dict): - raise ParseFailureError(f"Law structure {key} node is missing 기본정보") - identity = normalize_law_identity(info, basis="effective") - children = law_structure_children(node, depth_remaining=depth_remaining - 1) if depth_remaining > 0 else [] - nodes.append( - LawStructureNode( - name=identity.name, - source_type="law", - instrument_type=instrument_type_for_law_node(key, identity.law_type), - law_id=identity.law_id, - mst=identity.mst, - law_type=identity.law_type, - effective_date=identity.effective_date, - promulgation_date=identity.promulgation_date, - promulgation_number=identity.promulgation_number, - ministry=identity.ministry, - detail_link=string_or_none(first_value(info, "본문상세링크", "법령상세링크")), - children=children, - raw=node, - ) - ) - return nodes - - -def law_structure_administrative_nodes(value: Any) -> list[LawStructureNode]: - if value in (None, ""): - return [] - if not isinstance(value, dict): - raise ParseFailureError("Law structure 행정규칙 must be an object") - nodes: list[LawStructureNode] = [] - for rule_type, rule_value in value.items(): - for node in structure_node_values(rule_value): - info = node.get("기본정보") - if not isinstance(info, dict): - raise ParseFailureError(f"Law structure {rule_type} node is missing 기본정보") - identity = normalize_administrative_rule_identity(info) - nodes.append( - LawStructureNode( - name=identity.name, - source_type="administrative_rule", - instrument_type=instrument_type_for_admin_rule(rule_type), - serial_id=identity.serial_id, - rule_id=identity.rule_id, - law_type=identity.rule_type, - effective_date=identity.effective_date, - issuing_date=identity.issuing_date, - issuing_number=identity.issuing_number, - ministry=identity.ministry, - detail_link=string_or_none(first_value(info, "본문상세링크", "행정규칙 상세링크")), - raw=node, - ) - ) - return nodes - - -def structure_node_values(value: Any) -> list[dict[str, Any]]: - if value in (None, ""): - return [] - if isinstance(value, list): - if not all(isinstance(item, dict) for item in value): - raise ParseFailureError("Law structure node list contains non-object entries") - return value - if isinstance(value, dict): - if "기본정보" in value: - return [value] - nodes: list[dict[str, Any]] = [] - for child in value.values(): - nodes.extend(structure_node_values(child)) - return nodes - raise ParseFailureError("Law structure node must be an object or list") - - -def instrument_type_for_law_node(key: str, law_type: str | None) -> str: - if key == "시행령": - return "enforcement_decree" - if key == "시행규칙": - return "enforcement_rule" - if key == "법률": - return "related_law" - if law_type: - return law_type - return key - - -def instrument_type_for_admin_rule(rule_type: str) -> str: - return { - "고시": "notice", - "훈령": "directive", - "예규": "established_rule", - }.get(rule_type, rule_type) - - -def normalize_annex_form_identity( - row: dict[str, Any], - *, - source_type: str, - source_target: str, -) -> AnnexFormIdentity: - info = row.get("기본정보") if isinstance(row.get("기본정보"), dict) else row - title = first_value(info, "별표명", "별표서식명", "LM") - if not title: - raise ParseFailureError("Annex/form identity is missing a title") - - raw_keys = { - key: info.get(key) - for key in ( - "licbyl id", - "admrulbyl id", - "별표일련번호", - "별표가지번호", - "별표서식 가지번호", - "관련법령일련번호", - "관련행정규칙 일련번호", - "관련행정규칙일련번호", - "관련법령ID", - "별표법령 상세링크", - "별표행정규칙 상세링크", - "별표행정규칙상세링크", - ) - if info.get(key) not in (None, "") - } - return AnnexFormIdentity( - annex_id=string_or_none(first_value(info, "licbyl id", "admrulbyl id", "별표일련번호", "ID")), - title=str(title), - source_type=source_type, - source_target=source_target, - related_name=string_or_none(first_value(info, "관련법령명", "관련행정규칙명", "관련자치법규명")), - related_id=string_or_none(first_value(info, "관련법령ID", "관련행정규칙ID", "관련자치법규ID")), - related_serial_id=string_or_none( - first_value( - info, - "관련법령일련번호", - "관련행정규칙 일련번호", - "관련행정규칙일련번호", - "관련자치법규일련번호", - ) - ), - annex_number=annex_number_from_parts( - first_value(info, "별표번호"), - first_value(info, "별표가지번호", "별표서식 가지번호"), - ), - annex_type=string_or_none(first_value(info, "별표종류")), - ministry=string_or_none(first_value(info, "소관부처명", "소관부처")), - promulgation_date=string_or_none(compact_date(first_value(info, "공포일자"))), - promulgation_number=string_or_none(first_value(info, "공포번호")), - issued_on=string_or_none(compact_date(first_value(info, "발령일자"))), - issuing_number=string_or_none(first_value(info, "발령번호")), - revision_type=string_or_none(first_value(info, "제개정구분명")), - law_type=string_or_none(first_value(info, "법령종류", "법령구분명")), - rule_type=string_or_none(first_value(info, "행정규칙종류", "행정규칙 종류명")), - file_link=string_or_none(first_value(info, "별표서식 파일링크", "별표서식파일링크")), - pdf_link=string_or_none(first_value(info, "별표서식 PDF파일링크", "별표서식PDF파일링크")), - detail_link=string_or_none( - first_value( - info, - "별표법령 상세링크", - "별표법령상세링크", - "별표행정규칙 상세링크", - "별표행정규칙상세링크", - "별표자치법규 상세링크", - "별표자치법규상세링크", - ) - ), - raw_keys=raw_keys, - ) - - -def normalize_interpretation_identity( - row: dict[str, Any], - *, - source_type: str, - source_target: str, - ministry: str | None = None, -) -> InterpretationIdentity: - info = row.get("기본정보") if isinstance(row.get("기본정보"), dict) else row - title = first_value(info, "안건명", "법령해석례명", "법령해석명", "LM") - if not title: - raise ParseFailureError("Interpretation identity is missing a title") - - raw_keys = { - key: info.get(key) - for key in ( - "법령해석례일련번호", - "법령해석일련번호", - "ID", - "expc id", - "법령해석례 상세링크", - "법령해석 상세링크", - ) - if info.get(key) not in (None, "") - } - return InterpretationIdentity( - interpretation_id=string_or_none(first_value(info, "법령해석례일련번호", "법령해석일련번호", "ID", "expc id")), - title=str(title), - source_type=source_type, - source_target=source_target, - case_number=string_or_none(first_value(info, "안건번호")), - interpretation_date=string_or_none(compact_date(first_value(info, "해석일자", "회신일자"))), - reply_agency=string_or_none(first_value(info, "회신기관명", "해석기관명")), - reply_agency_code=string_or_none(first_value(info, "회신기관코드", "해석기관코드")), - inquiry_agency=string_or_none(first_value(info, "질의기관명")), - inquiry_agency_code=string_or_none(first_value(info, "질의기관코드")), - ministry=ministry, - data_timestamp=string_or_none(first_value(info, "데이터기준일시", "등록일시")), - raw_keys=raw_keys, - ) - - -def normalize_interpretation_text( - row: dict[str, Any], - *, - source_type: str, - source_target: str, - ministry: str | None = None, -) -> InterpretationText: - identity = normalize_interpretation_identity( - row, - source_type=source_type, - source_target=source_target, - ministry=ministry, - ) - question = string_or_none(first_value(row, "질의요지", "질의")) - answer = string_or_none(first_value(row, "회답", "답변", "해석")) - reason = string_or_none(first_value(row, "이유", "해석이유")) - related_laws = string_or_none(first_value(row, "관련법령", "관련 법령")) - parts = [] - if question: - parts.append(f"질의요지\n{question}") - if answer: - parts.append(f"회답\n{answer}") - if reason: - parts.append(f"이유\n{reason}") - if related_laws: - parts.append(f"관련법령\n{related_laws}") - return InterpretationText( - identity=identity, - question=question, - answer=answer, - reason=reason, - related_laws=related_laws, - referenced_articles=parse_article_references(related_laws), - text="\n\n".join(parts), - raw=row, - ) - - -def normalize_judicial_decision_identity( - row: dict[str, Any], - *, - source_type: str, - source_target: str, -) -> JudicialDecisionIdentity: - info = row.get("기본정보") if isinstance(row.get("기본정보"), dict) else row - title = first_value(info, "사건명", "판례명", "헌재결정례명", "LM") - if not title: - raise ParseFailureError("Judicial decision identity is missing a title") - - raw_keys = { - key: info.get(key) - for key in ( - "판례일련번호", - "판례정보일련번호", - "헌재결정례일련번호", - "ID", - "판례상세링크", - "헌재결정례 상세링크", - ) - if info.get(key) not in (None, "") - } - return JudicialDecisionIdentity( - decision_id=string_or_none(first_value(info, "판례일련번호", "판례정보일련번호", "헌재결정례일련번호", "ID")), - title=str(title), - source_type=source_type, - source_target=source_target, - case_number=string_or_none(first_value(info, "사건번호")), - decision_date=string_or_none(compact_date(first_value(info, "선고일자", "종국일자"))), - court=string_or_none(first_value(info, "법원명")), - court_type_code=string_or_none(first_value(info, "법원종류코드", "재판부구분코드")), - case_type=string_or_none(first_value(info, "사건종류명")), - decision_type=string_or_none(first_value(info, "판결유형", "선고")), - data_source=string_or_none(first_value(info, "데이터출처명")), - raw_keys=raw_keys, - ) - - -_DISPOSITION_RULES: tuple[tuple[str, tuple[str, ...]], ...] = ( - ("헌법불합치", (r"헌법에\s*합치되지\s*아니한다", r"헌법불합치")), - ("한정위헌", (r"한정위헌",)), - ("한정합헌", (r"한정합헌",)), - ("합헌", (r"헌법에\s*위반되지\s*아니한다", r"헌법에\s*위배되지\s*아니한다")), - ("위헌", (r"위반된다",)), - ("각하", (r"각하한다",)), - ("기각", (r"기각한다",)), - ("인용", (r"인용한다", r"취소한다")), -) - - -def parse_constitutional_disposition(full_text: str | None) -> str | None: - """Disposition(s) (합헌/위헌/헌법불합치/각하/기각 …) from the 주문 of a 헌재 - decision's 전문. Scans ONLY the 주문 slice — the 이유 section repeats these - verbs (dissents) and would mislabel. Returns None when the 주문 is absent.""" - if not full_text: - return None - marker = re.search(r"[【\[]\s*주\s*문\s*[】\]]", full_text) - if not marker: - return None - rest = full_text[marker.end():] - reason = re.search(r"[【\[]\s*이\s*유", rest) - section = rest[: reason.start()] if reason else rest[:2000] - found: list[str] = [] - for label, patterns in _DISPOSITION_RULES: - if label not in found and any(re.search(p, section) for p in patterns): - found.append(label) - return " ".join(found) if found else None - - -def normalize_judicial_decision_text( - row: dict[str, Any], - *, - source_type: str, - source_target: str, -) -> JudicialDecisionText: - identity = normalize_judicial_decision_identity( - row, - source_type=source_type, - source_target=source_target, - ) - holdings = string_or_none(first_value(row, "판시사항")) - summary = string_or_none(first_value(row, "판결요지", "결정요지")) - full_text = string_or_none(first_value(row, "판례내용", "전문")) - # 헌재 detail carries no 판결유형/선고 key, so decision_type is always null — - # recover the disposition from the 주문 so 각하/기각 aren't mistaken for merits. - if source_target == "detc" and not identity.decision_type: - disposition = parse_constitutional_disposition(full_text) - if disposition: - identity = replace(identity, decision_type=disposition) - referenced_statutes = string_or_none(first_value(row, "참조조문")) - reviewed_statutes = string_or_none(first_value(row, "심판대상조문")) - referenced_cases = string_or_none(first_value(row, "참조판례")) - parts = [] - if holdings: - parts.append(f"판시사항\n{holdings}") - if summary: - parts.append(f"요지\n{summary}") - if referenced_statutes: - parts.append(f"참조조문\n{referenced_statutes}") - if reviewed_statutes: - parts.append(f"심판대상조문\n{reviewed_statutes}") - if referenced_cases: - parts.append(f"참조판례\n{referenced_cases}") - if full_text: - parts.append(f"전문\n{full_text}") - return JudicialDecisionText( - identity=identity, - holdings=holdings, - summary=summary, - full_text=full_text, - referenced_statutes=referenced_statutes, - reviewed_statutes=reviewed_statutes, - referenced_cases=referenced_cases, - referenced_articles=parse_article_references(referenced_statutes), - reviewed_articles=parse_article_references(reviewed_statutes), - text="\n\n".join(parts), - raw=row, - ) - - -def normalize_term_candidate( - row: dict[str, Any], - *, - source_type: str, - source_target: str, -) -> LegalTermCandidate | None: - if source_type == "everyday_term": - term = first_value(row, "일상용어명", "법령용어명", "법령용어명_한글") - else: - term = first_value(row, "법령용어명", "법령용어명_한글", "일상용어명") - if not term: - return None - return LegalTermCandidate( - term=str(term), - source_type=source_type, - source_target=source_target, - term_id=string_or_none( - first_value( - row, - "법령용어 id", - "법령용어ID", - "법령용어 일련번호", - "일상용어 id", - "연계용어 id", - ) - ), - relation=string_or_none(first_value(row, "용어관계", "용어구분")), - note=string_or_none(first_value(row, "비고", "동음이의어 내용")), - definition=string_or_none(first_value(row, "법령용어정의")), - source_title=string_or_none(first_value(row, "출처")), - raw=row, - ) - - -def normalize_related_article_candidate( - row: dict[str, Any], - *, - source_target: str, -) -> LegalArticleCandidate | None: - law_name = string_or_none(first_value(row, "법령명", "행정규칙명")) - text = string_or_none(first_value(row, "조문내용")) - title = string_or_none(first_value(row, "조문제목")) - article = article_label_from_parts( - first_value(row, "조문번호", "조번호"), - first_value(row, "조문가지번호", "조가지번호"), - ) - if not law_name and not text and not title: - return None - return LegalArticleCandidate( - law_name=law_name, - law_id=string_or_none(first_value(row, "법령ID", "행정규칙ID")), - article=article, - title=title, - text=text, - source_target=source_target, - term=string_or_none(first_value(row, "법령용어명", "일상용어명")), - raw=row, - ) - - -def normalize_related_law_candidate( - row: dict[str, Any], - *, - source_target: str, -) -> LegalLawCandidate | None: - name = first_value(row, "관련법령명", "법령명", "행정규칙명") - if not name: - return None - source_type = "administrative_rule" if first_value(row, "행정규칙ID", "행정규칙명") else "law" - return LegalLawCandidate( - name=str(name), - law_id=string_or_none(first_value(row, "관련법령ID", "법령ID", "행정규칙ID")), - mst=string_or_none(first_value(row, "MST", "법령일련번호", "lsi_seq")), - source_type=source_type, - source_target=source_target, - relation=string_or_none(first_value(row, "법령간관계", "제개정구분명", "행정규칙 종류명")), - article=article_label_from_parts( - first_value(row, "조문번호", "조번호"), - first_value(row, "조문가지번호", "조가지번호"), - ), - article_title=string_or_none(first_value(row, "조문제목")), - promulgation_date=string_or_none(compact_date(first_value(row, "공포일자"))), - effective_date=string_or_none(compact_date(first_value(row, "시행일자"))), - promulgation_number=string_or_none(first_value(row, "공포번호")), - law_type=string_or_none(first_value(row, "법령종류명", "법령구분명")), - ministry=string_or_none(first_value(row, "소관부처명", "소관부처")), - raw=row, - ) - - -def unwrap_search_laws(payload: dict[str, Any]) -> list[dict[str, Any]]: - for envelope in LAW_SEARCH_ENVELOPES: - if isinstance(payload.get(envelope), dict): - rows = payload[envelope].get("law") - return [row for row in ensure_list(rows) if isinstance(row, dict)] - rows = payload.get("law") - return [row for row in ensure_list(rows) if isinstance(row, dict)] - - -def unwrap_search_administrative_rules(payload: dict[str, Any]) -> list[dict[str, Any]]: - for envelope in ("AdmRulSearch", "admrulSearch", "AdmrulSearch", "AdmRulSearchService"): - if isinstance(payload.get(envelope), dict): - rows = payload[envelope].get("admrul") - return [row for row in ensure_list(rows) if isinstance(row, dict)] - rows = payload.get("admrul") - if rows is not None: - return [row for row in ensure_list(rows) if isinstance(row, dict)] - return collect_rows(payload, "admrul") - - -def unwrap_search_interpretations(payload: dict[str, Any], target: str) -> list[dict[str, Any]]: - row_keys = tuple(dict.fromkeys((target, "expc", "cgmExpc"))) - for envelope in ( - "ExpcSearch", - "expcSearch", - "Expc", - "expc", - "CgmExpcSearch", - "cgmExpcSearch", - "CgmExpc", - "cgmExpc", - ): - if isinstance(payload.get(envelope), dict): - rows = next((payload[envelope].get(key) for key in row_keys if key in payload[envelope]), None) - return [row for row in ensure_list(rows) if isinstance(row, dict)] - rows = next((payload.get(key) for key in row_keys if key in payload), None) - if rows is not None: - return [row for row in ensure_list(rows) if isinstance(row, dict)] - return collect_rows(payload, *row_keys) - - -def unwrap_search_judicial_decisions(payload: dict[str, Any], target: str) -> list[dict[str, Any]]: - for envelope in ("PrecSearch", "precSearch", "DetcSearch", "detcSearch"): - if isinstance(payload.get(envelope), dict): - env = payload[envelope] - # law.go.kr names the row-element key by its own casing, not the - # request target: "prec" (lowercase) for cases but "Detc" - # (capitalized) for constitutional decisions. Match it - # case-insensitively, excluding the scalar "target" echo field. - rows = env.get(target) - if rows is None: - rows = next( - ( - env[key] - for key in env - if isinstance(key, str) - and key.lower() == target.lower() - and key != "target" - ), - None, - ) - return [row for row in ensure_list(rows) if isinstance(row, dict)] - rows = payload.get(target) - if rows is not None: - return [row for row in ensure_list(rows) if isinstance(row, dict)] - return collect_rows(payload, target) - - -def unwrap_target_rows(payload: dict[str, Any], target: str) -> list[dict[str, Any]]: - if target in ("aiSearch", "aiRltLs"): - rows = collect_rows(payload, *AI_ROW_KEYS) - if rows or isinstance(payload.get(target), dict): - return rows - rows = payload.get(target) - if rows is not None: - return [row for row in ensure_list(rows) if isinstance(row, dict)] - for value in payload.values(): - if isinstance(value, dict): - nested = value.get(target) - if nested is not None: - return [row for row in ensure_list(nested) if isinstance(row, dict)] - return collect_rows(payload, target) - - -def unwrap_service_payload(payload: dict[str, Any], target: str) -> dict[str, Any]: - if isinstance(payload.get(target), dict): - return payload[target] - if len(payload) == 1: - only = next(iter(payload.values())) - if isinstance(only, dict): - return only - if is_no_result_message(only): - raise NoResultError(str(only)) - if "기본정보" in payload or "조문" in payload: - return payload - raise ParseFailureError(f"Could not unwrap service payload for target {target}") - - -def is_no_result_message(value: Any) -> bool: - if not isinstance(value, str): - return False - return "일치하는" in value and "없습니다" in value - - -def extract_articles(raw_law: dict[str, Any], identity: LawIdentity) -> list[ArticleText]: - article_container = raw_law.get("조문") - rows: list[Any] - if isinstance(article_container, dict): - rows = ensure_list( - article_container.get("조문단위") - or article_container.get("조문") - or article_container - ) - else: - rows = ensure_list(article_container) - - articles: list[ArticleText] = [] - for row in rows: - if not isinstance(row, dict): - continue - article = normalize_article(row, identity) - if article: - articles.append(article) - return articles - - -def extract_supplementary_provisions( - raw_source: dict[str, Any], - source_type: Literal["law", "administrative_rule"], -) -> list[SupplementaryProvision]: - """Extract 부칙 rows from law or administrative-rule detail payloads.""" - - rows: list[Any] = [] - top_level_text = first_value(raw_source, "부칙내용", "부칙") - top_level_has_supplement = ( - top_level_text is not None - and not isinstance(top_level_text, (dict, list)) - ) or any(first_value(raw_source, key) is not None for key in ("부칙공포일자", "부칙공포번호")) - if top_level_has_supplement: - rows.append(raw_source) - else: - rows.extend(supplementary_rows(raw_source.get("부칙"))) - rows.extend(supplementary_rows(raw_source.get("부칙단위"))) - - provisions: list[SupplementaryProvision] = [] - for row in rows: - if isinstance(row, dict): - text = first_value(row, "부칙내용", "부칙", "내용", "text") - if text is None or isinstance(text, (dict, list)): - continue - provisions.append( - SupplementaryProvision( - source_type=source_type, - title=string_or_none(first_value(row, "부칙제목", "제목")), - text=str(text), - promulgation_date=string_or_none(compact_date(first_value(row, "부칙공포일자"))), - promulgation_number=compact_promulgation_number(first_value(row, "부칙공포번호")), - raw=row, - ) - ) - elif row not in (None, ""): - provisions.append( - SupplementaryProvision( - source_type=source_type, - text=str(row), - ) - ) - return provisions - - -def supplementary_rows(value: Any) -> list[Any]: - if value in (None, ""): - return [] - if isinstance(value, list): - return value - if isinstance(value, dict): - if any(key in value for key in ("부칙내용", "부칙공포일자", "부칙공포번호")): - return [value] - for key in ("부칙단위", "부칙"): - if key in value: - return supplementary_rows(value[key]) - return [value] - return [value] - - -def extract_administrative_rule_articles( - raw_rule: dict[str, Any], - identity: AdministrativeRuleIdentity, -) -> list[AdministrativeRuleArticleText]: - article_container = raw_rule.get("조문") - if isinstance(article_container, dict): - rows = ensure_list( - article_container.get("조문단위") - or article_container.get("조문") - or article_container - ) - else: - rows = ensure_list(article_container) - - articles: list[AdministrativeRuleArticleText] = [] - for row in rows: - if isinstance(row, dict): - article = normalize_administrative_rule_article(row, identity) - if article: - articles.append(article) - - if articles: - return articles - - flat_text = first_value(raw_rule, "조문내용", "본문", "내용") - if flat_text: - articles.append( - AdministrativeRuleArticleText( - identity=identity, - article=article_label_from_parts( - first_value(raw_rule, "조문번호", "조번호"), - first_value(raw_rule, "조문가지번호", "조가지번호"), - ), - title=string_or_none(first_value(raw_rule, "조문제목", "제목")), - text=str(flat_text), - effective_date=string_or_none(compact_date(first_value(raw_rule, "시행일자"))), - source_law_id=identity.source_law_id, - source_law_name=identity.source_law_name, - source_article=identity.source_article, - source_article_title=identity.source_article_title, - raw=raw_rule, - ) - ) - return articles - - -def normalize_article(row: dict[str, Any], identity: LawIdentity) -> ArticleText | None: - number = first_value(row, "조문번호", "조번호", "JO") - branch = first_value(row, "조문가지번호", "조가지번호") - text = first_value(row, "조문내용", "조문본문", "내용") - title = first_value(row, "조문제목", "제목") - if number is None and text is None and title is None: - return None - return ArticleText( - identity=identity, - article=article_label_from_parts(number, branch) or "", - title=string_or_none(title), - text=join_article_text(row, str(text or "")), - effective_date=string_or_none(compact_date(first_value(row, "조문시행일자", "시행일자"))), - article_kind=string_or_none(first_value(row, "조문여부")), - revision_type=string_or_none(first_value(row, "조문제개정유형", "제개정유형")), - moved_from=article_label(first_value(row, "조문이동이전", "조문이동이전번호")), - moved_to=article_label(first_value(row, "조문이동이후", "조문이동이후번호")), - has_changes=yes_no_or_none(first_value(row, "조문변경여부")), - is_deleted=is_deleted_article( - str(text or ""), - title=string_or_none(title), - revision_type=string_or_none(first_value(row, "조문제개정유형", "제개정유형")), - ), - raw=row, - ) - - -def normalize_administrative_rule_article( - row: dict[str, Any], - identity: AdministrativeRuleIdentity, -) -> AdministrativeRuleArticleText | None: - number = first_value(row, "조문번호", "조번호", "JO") - branch = first_value(row, "조문가지번호", "조가지번호") - text = first_value(row, "조문내용", "조문본문", "내용", "content") - title = first_value(row, "조문제목", "제목", "title") - if number is None and text is None and title is None: - return None - source_reference = administrative_rule_source_reference(row) - if not any(source_reference.values()): - source_reference = { - "source_law_id": identity.source_law_id, - "source_law_name": identity.source_law_name, - "source_article": identity.source_article, - "source_article_title": identity.source_article_title, - } - else: - source_reference = { - "source_law_id": source_reference["source_law_id"] or identity.source_law_id, - "source_law_name": source_reference["source_law_name"] or identity.source_law_name, - "source_article": source_reference["source_article"], - "source_article_title": source_reference["source_article_title"], - } - return AdministrativeRuleArticleText( - identity=identity, - article=article_label_from_parts(number, branch), - title=string_or_none(title), - text=join_article_text(row, str(text or "")), - effective_date=string_or_none(compact_date(first_value(row, "조문시행일자", "시행일자"))), - article_kind=string_or_none(first_value(row, "조문여부")), - revision_type=string_or_none(first_value(row, "조문제개정유형", "제개정유형")), - moved_from=article_label(first_value(row, "조문이동이전", "조문이동이전번호")), - moved_to=article_label(first_value(row, "조문이동이후", "조문이동이후번호")), - has_changes=yes_no_or_none(first_value(row, "조문변경여부")), - is_deleted=is_deleted_article( - str(text or ""), - title=string_or_none(title), - revision_type=string_or_none(first_value(row, "조문제개정유형", "제개정유형")), - ), - **source_reference, - raw=row, - ) - - -def join_article_text(row: dict[str, Any], base_text: str) -> str: - lines: list[str] = [] - if base_text: - lines.append(base_text) - for line in nested_article_lines(row): - if line and not article_line_already_present(line, lines): - lines.append(line) - return "\n".join(lines) - - -def article_line_already_present(line: str, lines: list[str]) -> bool: - compact_line = compact_whitespace(line) - return any(compact_line in compact_whitespace(existing) for existing in lines) - - -def nested_article_lines(row: dict[str, Any]) -> list[str]: - lines: list[str] = [] - lines.extend(nested_unit_lines(row, *PARAGRAPH_SPEC)) - lines.extend(nested_unit_lines(row, *SUBPARAGRAPH_SPEC)) - lines.extend(nested_unit_lines(row, *ITEM_SPEC)) - return lines - - -NestedSpec = tuple[tuple[str, ...], tuple[str, ...], "NestedSpec | None"] -ITEM_SPEC: NestedSpec = (("목", "목단위"), ("목내용",), None) -SUBPARAGRAPH_SPEC: NestedSpec = (("호", "호단위"), ("호내용",), ITEM_SPEC) -PARAGRAPH_SPEC: NestedSpec = (("항", "항단위"), ("항내용",), SUBPARAGRAPH_SPEC) - - -def nested_unit_lines( - row: dict[str, Any], - container_keys: tuple[str, ...], - text_keys: tuple[str, ...], - child_spec: NestedSpec | None, -) -> list[str]: - lines: list[str] = [] - for unit in child_rows(row, container_keys): - line = string_or_none(first_value(unit, *text_keys, "내용", "content", "text")) - if line: - lines.append(line) - if child_spec: - lines.extend(nested_unit_lines(unit, *child_spec)) - return lines - - -def child_rows(row: dict[str, Any], keys: tuple[str, ...]) -> list[dict[str, Any]]: - rows: list[dict[str, Any]] = [] - for key in keys: - rows.extend(child_rows_from_container(row.get(key), keys)) - return rows - - -def child_rows_from_container(value: Any, keys: tuple[str, ...]) -> list[dict[str, Any]]: - if value in (None, ""): - return [] - content = content_value(value) - if isinstance(content, dict) and any(key in content for key in keys): - return child_rows(content, keys) - rows: list[dict[str, Any]] = [] - for item in ensure_list(content): - if isinstance(item, dict): - rows.append(item) - return rows - - -def compact_whitespace(text: str) -> str: - return re.sub(r"\s+", "", text) - - -def yes_no_or_none(value: Any) -> bool | None: - if value in (None, ""): - return None - normalized = str(value).strip().upper() - if normalized in {"Y", "YES", "TRUE", "1"}: - return True - if normalized in {"N", "NO", "FALSE", "0"}: - return False - return None - - -def is_deleted_article( - text: str, - *, - title: str | None = None, - revision_type: str | None = None, -) -> bool: - if revision_type and "삭제" in revision_type: - return True - if title and title.strip() == "삭제": - return True - return article_text_marks_deleted(text) - - -def article_text_marks_deleted(text: str) -> bool: - compact = compact_whitespace(text) - return bool( - re.fullmatch( - r"제\d+조(?:의\d+)?(?:\([^)]*\))?삭제(?:[<[【(].*)?", - compact, - ) - ) - - -def mask_oc_param(link: str | None) -> str | None: - """Mask the OC credential in a law.go.kr link before it is surfaced.""" - if not link: - return link - return re.sub(r"(OC=)[^&]*", r"\1***", link) - - -def normalize_history_events( - payload: dict[str, Any], - identity: LawIdentity, - *, - article_text_map: dict[str, str | None] | None = None, - bill_id_map: dict[tuple[str, str, str], str] | None = None, -) -> list[HistoryEvent]: - rows = collect_rows( - payload, - "law", - "법령", - "조문변경이력", - "조문변경이력목록", - "lsJoHstInf", - "lsHstInf", - "lsHistory", - ) - events: list[HistoryEvent] = [] - for row in rows: - if not isinstance(row, dict): - continue - # The article-scoped (lsJoHstInf) payload nests fields under 조문정보 - # /법령정보; the whole-law (lsHistory HTML) payload is already flat. - # Merge both so the flat first_value reads below work on either shape — - # otherwise the article path loses changed_date/effective_date/revision - # _type/reason and lets `article` fall through to a dict-repr string. - jo = row.get("조문정보") if isinstance(row.get("조문정보"), dict) else {} - li = row.get("법령정보") if isinstance(row.get("법령정보"), dict) else {} - lookup = {**row, **jo, **li} if (jo or li) else row - try: - row_identity = normalize_law_identity(row, basis=identity.basis) - except ParseFailureError: - row_identity = identity - changed_date = string_or_none(compact_date(first_value(lookup, "조문변경일", "조문개정일", "regDt", "공포일자"))) - effective_date = string_or_none(compact_date(first_value(lookup, "조문시행일", "시행일자"))) - article_text = history_event_article_text( - lookup, - changed_date=changed_date, - effective_date=effective_date, - article_text_map=article_text_map, - ) - promulgation_law_name = string_or_none( - first_value(lookup, "법령명한글", "법령명_한글", "법령명") - ) or row_identity.name - promulgation_date = string_or_none(compact_date(first_value(lookup, "공포일자"))) - promulgation_number = compact_promulgation_number(first_value(lookup, "공포번호")) - event = HistoryEvent( - identity=row_identity, - changed_date=changed_date, - effective_date=effective_date, - promulgation_law_name=promulgation_law_name, - promulgation_date=promulgation_date, - promulgation_number=promulgation_number, - bill_id=history_event_bill_id( - bill_id_map, - law_name=promulgation_law_name, - promulgation_number=promulgation_number, - promulgation_date=promulgation_date, - ), - revision_type=string_or_none(first_value(lookup, "제개정구분명", "제개정구분")), - article=article_label_from_parts( - first_value(lookup, "조문번호", "JO"), - first_value(lookup, "조문가지번호", "조가지번호"), - ), - article_text=article_text, - article_link=mask_oc_param(string_or_none(first_value(lookup, "조문링크"))), - reason=string_or_none(first_value(lookup, "변경사유")), - raw=row, - ) - events.append(event) - return events - - -def history_event_article_text( - row: dict[str, Any], - *, - changed_date: str | None, - effective_date: str | None, - article_text_map: dict[str, str | None] | None, -) -> str | None: - source_text = string_or_none(first_value(row, "조문내용", "조문본문", "현행조문내용", "개정조문내용")) - if source_text: - return source_text - if not article_text_map: - return None - for key in (effective_date, changed_date): - if key and key in article_text_map: - return article_text_map[key] - return None - - -def history_event_bill_id( - bill_id_map: dict[tuple[str, str, str], str] | None, - *, - law_name: str | None, - promulgation_number: str | None, - promulgation_date: str | None, -) -> str | None: - if not bill_id_map or not law_name or not promulgation_number or not promulgation_date: - return None - return bill_id_map.get((law_name, promulgation_number, promulgation_date)) - - -def parse_law_history_html(html: str) -> list[dict[str, Any]]: - parser = LawHistoryTableParser() - parser.feed(html) - data_rows = [row for row in parser.rows if row] - # A results table has 9-column rows; a "no results" page carries a single - # message cell. Treat 1-column rows as non-data (empty result), but a row - # that looks like data with the wrong width is a genuine parse breakage. - structured = [row for row in data_rows if len(row) == 9] - suspicious = [row for row in data_rows if len(row) not in (1, 9)] - if suspicious: - raise ParseFailureError("Could not parse lsHistory HTML table: unexpected column count") - rows: list[dict[str, Any]] = [] - for row in structured: - href = row[1]["href"] - link_params = parse_link_params(href) - mst = first_query_value(link_params, "MST") - rows.append( - { - "순번": row[0]["text"], - "법령명한글": row[1]["text"], - "소관부처명": row[2]["text"], - "제개정구분명": row[3]["text"], - "법령구분명": row[4]["text"], - "공포번호": row[5]["text"], - "공포일자": row[6]["text"], - "시행일자": row[7]["text"], - "현행연혁구분": row[8]["text"], - "MST": mst, - "법령일련번호": mst, - "법령상세링크": href, - } - ) - # An empty result (no structured rows) is a legitimate "no history" page, - # not a parse failure — let the caller surface NoResultError instead. - return rows - - -def parse_law_history_total_count(html: str) -> int | None: - match = re.search(r"총\s*\s*([0-9,]+)\s*\s*건", html) - if not match: - return None - return int(match.group(1).replace(",", "")) - - -def parse_link_params(href: str | None) -> dict[str, list[str]]: - if not href: - return {} - return parse_qs(urlparse(href).query) - - -def first_query_value(params: dict[str, list[str]], key: str) -> str | None: - values = params.get(key) - if not values: - return None - return values[0] - - -class LawHistoryTableParser(HTMLParser): - def __init__(self) -> None: - super().__init__(convert_charrefs=True) - self.rows: list[list[dict[str, str | None]]] = [] - self._current_row: list[dict[str, str | None]] | None = None - self._current_cell_text: list[str] | None = None - self._current_cell_href: str | None = None - - def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: - if tag == "tr": - self._current_row = [] - return - if tag == "td" and self._current_row is not None: - self._current_cell_text = [] - self._current_cell_href = None - return - if tag == "a" and self._current_cell_text is not None: - attr_map = dict(attrs) - self._current_cell_href = attr_map.get("href") - - def handle_data(self, data: str) -> None: - if self._current_cell_text is not None: - self._current_cell_text.append(data) - - def handle_endtag(self, tag: str) -> None: - if tag == "td" and self._current_row is not None and self._current_cell_text is not None: - text = re.sub(r"\s+", " ", "".join(self._current_cell_text)).strip() - self._current_row.append({"text": text, "href": self._current_cell_href}) - self._current_cell_text = None - self._current_cell_href = None - return - if tag == "tr" and self._current_row is not None: - if self._current_row: - self.rows.append(self._current_row) - self._current_row = None - - -def _diff_article_header(text: str) -> str | None: - """The real 제N조[의M] label from the start of a diff row's content, if any.""" - match = re.match(r"\s*제\s*(\d+)\s*조(?:\s*의\s*(\d+))?", text or "") - if not match: - return None - main = int(match.group(1)) - branch = int(match.group(2) or 0) - return f"제{main}조의{branch}" if branch else f"제{main}조" - - -def _diff_row_has_real_article(row: dict[str, Any]) -> bool: - """True when a diff row carries a genuine article identifier (제N조 in `no`, a - 6-digit 조문번호, or explicit 조문번호/조가지번호 fields) rather than a bare - running row index — the latter must not be treated as an article number.""" - no = str(first_value(row, "no") or "").strip() - if no.startswith("제") or "조" in no or re.fullmatch(r"\d{6}", no): - return True - return first_value(row, "조문번호", "조가지번호", "조문가지번호") not in (None, "") - - -def normalize_diff_changes(payload: dict[str, Any], *, article: str | int | None = None) -> list[LawDiffChange]: - before_rows = article_rows_from_diff(payload, "구조문목록") - after_rows = article_rows_from_diff(payload, "신조문목록") - wanted = article_label(article) if article is not None else None - - before_by_no = {article_key(row): row for row in before_rows} - after_by_no = {article_key(row): row for row in after_rows} - # `no` is a sequential ROW index (1,2,3…), a correct index-parallel JOIN key - # for the before/after lists — but NOT the real article number. Walk rows in - # document order (numeric `no`) and derive the DISPLAYED article label from - # the content's 제N조 header, carrying it forward across continuation rows - # (항·호 fragments with no header). Never emit the sequential index as 제N조. - keys = sorted(set(before_by_no) | set(after_by_no), key=lambda k: int(_digits(k) or "0")) - changes: list[LawDiffChange] = [] - current_article: str | None = None - for key in keys: - before_row = before_by_no.get(key, {}) - after_row = after_by_no.get(key, {}) - before_text = str(first_value(before_row, "content", "조문내용", "text") or "") - after_text = str(first_value(after_row, "content", "조문내용", "text") or "") - header = _diff_article_header(after_text) or _diff_article_header(before_text) - if header: - current_article = header - elif (_diff_row_has_real_article(after_row) or _diff_row_has_real_article(before_row)) and key: - # The row carries a genuine article number (not the sequential index). - current_article = key - label = current_article - if wanted and label != wanted: - continue - changes.append( - LawDiffChange( - article=label, - title=string_or_none(first_value(after_row, "title", "조문제목") or first_value(before_row, "title", "조문제목")), - before_text=before_text, - after_text=after_text, - raw={"before": before_row, "after": after_row}, - ) - ) - return changes - - -def _digits(value: Any) -> str: - return "".join(ch for ch in str(value) if ch.isdigit()) - - -# Target-defining keys used to detect/size a parallel-array 위임정보 dict. Does -# NOT include 위임법령조문정보 (article-detail, a different length). -_DELEGATION_TARGET_KEYS = ( - "위임구분", "법종구분", - "위임법령제목", "위임행정규칙제목", "위임자치법규제목", "위임행정규칙명", "법령명", - "법령ID", "위임법령ID", - "위임법령일련번호", "위임행정규칙일련번호", "위임자치법규일련번호", "법령일련번호", - "위임법령조문번호", "위임행정규칙조번호", - "라인텍스트", "조내용", "링크텍스트", -) - - -def _transpose_delegation_info(info: dict[str, Any]) -> list[dict[str, Any]]: - """A single 위임정보 dict can carry parallel lists (one article delegating to - N targets). Transpose into N per-target dicts (scalars broadcast); otherwise - return it unchanged. Prevents delegated_type/name/mst from serializing as a - stringified list and recovers every collapsed multi-target delegation.""" - lengths = [len(info[k]) for k in _DELEGATION_TARGET_KEYS if isinstance(info.get(k), list)] - if not lengths: - return [info] - n = max(lengths) - out: list[dict[str, Any]] = [] - for i in range(n): - item = dict(info) - for key, value in info.items(): - if isinstance(value, list): - item[key] = value[i] if i < len(value) else None - out.append(item) - return out - - -def normalize_delegated_rules(payload: dict[str, Any]) -> list[DelegatedRule]: - rows = collect_rows(payload, "위임조문정보", "위임법령", "위임행정규칙", "위임자치법규") - rules: list[DelegatedRule] = [] - for row in rows: - if not isinstance(row, dict): - continue - if "위임정보" in row or "조정보" in row: - source = row.get("조정보") if isinstance(row.get("조정보"), dict) else {} - raw_info = row.get("위임정보") - # 위임정보 has three shapes when one article delegates to several - # targets: a list of dicts, OR a single dict whose target fields are - # parallel lists. Both must emit one rule per target — collapsing to - # {} (or a single dict) drops/ stringifies multi-target delegations. - if isinstance(raw_info, list): - infos = [item for item in raw_info if isinstance(item, dict)] - elif isinstance(raw_info, dict): - infos = _transpose_delegation_info(raw_info) - else: - infos = [{}] - else: - source = row - infos = [row] - for info in infos: - rule = DelegatedRule( - source_article=article_label_from_parts( - first_value(source, "조문번호", "조번호", "조항호목"), - first_value(source, "조문가지번호", "조가지번호"), - ), - source_article_title=string_or_none(first_value(source, "조문제목", "조제목")), - delegated_type=string_or_none(first_value(info, "위임구분", "법종구분")), - delegated_name=string_or_none( - first_value(info, "위임법령제목", "위임행정규칙제목", "위임자치법규제목", "위임행정규칙명", "법령명") - ), - delegated_law_id=string_or_none(first_value(info, "법령ID", "위임법령ID")), - delegated_mst=string_or_none(first_value(info, "위임법령일련번호", "위임행정규칙일련번호", "위임자치법규일련번호", "법령일련번호")), - delegated_article=article_label_from_parts( - first_value(info, "위임법령조문번호", "위임행정규칙조번호"), - first_value( - info, - "위임법령조문가지번호", - "위임행정규칙조가지번호", - "위임행정규칙조문가지번호", - ), - ), - text=string_or_none(first_value(info, "라인텍스트", "조내용", "링크텍스트")), - raw=row, - ) - if rule.delegated_name or rule.text: - rules.append(rule) - return rules - - -def collect_rows(obj: Any, *keys: str) -> list[dict[str, Any]]: - rows: list[dict[str, Any]] = [] - if isinstance(obj, list): - for item in obj: - rows.extend(collect_rows(item, *keys)) - elif isinstance(obj, dict): - for key in keys: - value = obj.get(key) - if isinstance(value, list): - rows.extend([item for item in value if isinstance(item, dict)]) - elif isinstance(value, dict): - if any(isinstance(value.get(k), (list, dict)) for k in keys): - rows.extend(collect_rows(value, *keys)) - else: - rows.append(value) - if not rows: - for value in obj.values(): - if isinstance(value, (list, dict)): - rows.extend(collect_rows(value, *keys)) - return rows - - -def article_rows_from_diff(payload: dict[str, Any], key: str) -> list[dict[str, Any]]: - container = payload.get(key) - if isinstance(container, dict): - return [row for row in ensure_list(container.get("조문")) if isinstance(row, dict)] - return [] - - -def article_key(row: dict[str, Any]) -> str: - return article_label_from_parts( - first_value(row, "no", "조문번호", "JO"), - first_value(row, "조문가지번호", "조가지번호"), - ) or "" - - -def article_label(value: Any) -> str | None: - if value in (None, ""): - return None - text = str(value).strip() - if text.startswith("제"): - return text - if re.fullmatch(r"\d{6}", text): - main = int(text[:4]) - branch = int(text[4:]) - return f"제{main}조의{branch}" if branch else f"제{main}조" - match = re.fullmatch(r"(\d+)\s*조(?:\s*의\s*(\d+))?", text) - if match: - main = int(match.group(1)) - branch = int(match.group(2) or 0) - return f"제{main}조의{branch}" if branch else f"제{main}조" - if text.isdigit(): - return f"제{int(text)}조" - return text - - -def annex_number_from_parts(number: Any, branch: Any = None) -> str | None: - if number in (None, ""): - return None - text = str(number).strip() - branch_text = str(branch or "").strip() - if not branch_text or not branch_text.isdigit() or int(branch_text) == 0: - return text - if re.search(r"의\s*\d+", text): - return text - return f"{text}의{int(branch_text)}" - - -def article_label_from_parts(number: Any, branch: Any = None) -> str | None: - if number in (None, ""): - return None - text = str(number).strip() - branch_text = str(branch or "").strip() - branch_number = int(branch_text) if branch_text.isdigit() and int(branch_text) != 0 else None - if branch_number is None and re.fullmatch(r"\d{6}", text): - main = int(text[:4]) - source_branch = int(text[4:]) - return f"제{main}조의{source_branch}" if source_branch else f"제{main}조" - if text.startswith("제"): - if branch_number is not None: - match = re.fullmatch(r"제\s*(\d+)\s*조", text) - if match: - return f"제{int(match.group(1))}조의{branch_number}" - return text - if not text.isdigit(): - return text - main = int(text) - if branch_number is not None: - return f"제{main}조의{branch_number}" - return f"제{main}조" - - -def format_article_jo(article: str | int) -> str: - if isinstance(article, int): - return f"{article:04d}00" - text = str(article).strip() - if re.fullmatch(r"\d{6}", text): - return text - match = re.search(r"제?\s*(\d+)\s*조(?:\s*의\s*(\d+))?", text) - if match: - main = int(match.group(1)) - branch = int(match.group(2) or 0) - return f"{main:04d}{branch:02d}" - if text.isdigit(): - return f"{int(text):04d}00" - raise ParseFailureError(f"Unsupported article notation: {article}") - - -def string_or_none(value: Any) -> str | None: - if value in (None, ""): - return None - return str(value) +from ._normalization import * +from ._normalization.authority import _DISPOSITION_RULES +from ._normalization.delegation import _DELEGATION_TARGET_KEYS, _transpose_delegation_info +from ._normalization.history_html import _diff_article_header, _diff_row_has_real_article +from ._normalization.primitives import _digits From 957ecc990f317c913fca5002646816eed7b69272 Mon Sep 17 00:00:00 2001 From: seongjin Date: Sat, 4 Jul 2026 13:18:44 +0900 Subject: [PATCH 4/8] refactor(laws): keep public facade under size limit --- moleg_api/_laws/__init__.py | 6 + moleg_api/_laws/admin_scope.py | 131 + moleg_api/_laws/annex_tables.py | 215 + moleg_api/_laws/api.py | 44 + moleg_api/_laws/api_admin_rules.py | 210 + moleg_api/_laws/api_annex.py | 168 + moleg_api/_laws/api_article_context.py | 126 + moleg_api/_laws/api_authority_context.py | 305 + moleg_api/_laws/api_bundle.py | 771 ++ moleg_api/_laws/api_comparable.py | 127 + moleg_api/_laws/api_compare.py | 51 + moleg_api/_laws/api_delegated_criteria.py | 307 + moleg_api/_laws/api_followups.py | 279 + moleg_api/_laws/api_history.py | 89 + moleg_api/_laws/api_institutional.py | 363 + moleg_api/_laws/api_interpretations.py | 126 + moleg_api/_laws/api_judicial.py | 214 + moleg_api/_laws/api_law_loaders.py | 134 + moleg_api/_laws/api_query_expansion.py | 157 + moleg_api/_laws/api_search.py | 98 + moleg_api/_laws/api_structure.py | 65 + moleg_api/_laws/api_versions.py | 144 + moleg_api/_laws/article_gaps.py | 244 + moleg_api/_laws/authority_article_gaps.py | 123 + moleg_api/_laws/authority_sources.py | 170 + moleg_api/_laws/authority_temporal_filters.py | 119 + moleg_api/_laws/authority_temporal_gaps.py | 206 + moleg_api/_laws/bridge.py | 246 + moleg_api/_laws/candidates.py | 264 + moleg_api/_laws/config.py | 228 + moleg_api/_laws/context_load_gaps.py | 157 + moleg_api/_laws/delegated_scope.py | 178 + moleg_api/_laws/followup_basic.py | 233 + moleg_api/_laws/followup_hits.py | 134 + moleg_api/_laws/followup_identities.py | 139 + moleg_api/_laws/followup_searches.py | 166 + moleg_api/_laws/foundation.py | 98 + moleg_api/_laws/history_identity.py | 134 + moleg_api/_laws/identity_params.py | 205 + moleg_api/_laws/limits_intents.py | 108 + moleg_api/_laws/ranking.py | 242 + moleg_api/_laws/requested_load_gaps.py | 186 + moleg_api/_laws/source_matching.py | 228 + moleg_api/_laws/support.py | 30 + moleg_api/_laws/temporal_gaps.py | 144 + moleg_api/_laws/validation.py | 91 + moleg_api/laws.py | 7387 +---------------- 47 files changed, 8204 insertions(+), 7386 deletions(-) create mode 100644 moleg_api/_laws/__init__.py create mode 100644 moleg_api/_laws/admin_scope.py create mode 100644 moleg_api/_laws/annex_tables.py create mode 100644 moleg_api/_laws/api.py create mode 100644 moleg_api/_laws/api_admin_rules.py create mode 100644 moleg_api/_laws/api_annex.py create mode 100644 moleg_api/_laws/api_article_context.py create mode 100644 moleg_api/_laws/api_authority_context.py create mode 100644 moleg_api/_laws/api_bundle.py create mode 100644 moleg_api/_laws/api_comparable.py create mode 100644 moleg_api/_laws/api_compare.py create mode 100644 moleg_api/_laws/api_delegated_criteria.py create mode 100644 moleg_api/_laws/api_followups.py create mode 100644 moleg_api/_laws/api_history.py create mode 100644 moleg_api/_laws/api_institutional.py create mode 100644 moleg_api/_laws/api_interpretations.py create mode 100644 moleg_api/_laws/api_judicial.py create mode 100644 moleg_api/_laws/api_law_loaders.py create mode 100644 moleg_api/_laws/api_query_expansion.py create mode 100644 moleg_api/_laws/api_search.py create mode 100644 moleg_api/_laws/api_structure.py create mode 100644 moleg_api/_laws/api_versions.py create mode 100644 moleg_api/_laws/article_gaps.py create mode 100644 moleg_api/_laws/authority_article_gaps.py create mode 100644 moleg_api/_laws/authority_sources.py create mode 100644 moleg_api/_laws/authority_temporal_filters.py create mode 100644 moleg_api/_laws/authority_temporal_gaps.py create mode 100644 moleg_api/_laws/bridge.py create mode 100644 moleg_api/_laws/candidates.py create mode 100644 moleg_api/_laws/config.py create mode 100644 moleg_api/_laws/context_load_gaps.py create mode 100644 moleg_api/_laws/delegated_scope.py create mode 100644 moleg_api/_laws/followup_basic.py create mode 100644 moleg_api/_laws/followup_hits.py create mode 100644 moleg_api/_laws/followup_identities.py create mode 100644 moleg_api/_laws/followup_searches.py create mode 100644 moleg_api/_laws/foundation.py create mode 100644 moleg_api/_laws/history_identity.py create mode 100644 moleg_api/_laws/identity_params.py create mode 100644 moleg_api/_laws/limits_intents.py create mode 100644 moleg_api/_laws/ranking.py create mode 100644 moleg_api/_laws/requested_load_gaps.py create mode 100644 moleg_api/_laws/source_matching.py create mode 100644 moleg_api/_laws/support.py create mode 100644 moleg_api/_laws/temporal_gaps.py create mode 100644 moleg_api/_laws/validation.py diff --git a/moleg_api/_laws/__init__.py b/moleg_api/_laws/__init__.py new file mode 100644 index 0000000..92d4d7d --- /dev/null +++ b/moleg_api/_laws/__init__.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +from .api import MolegApi +from .support import * + +__all__ = [name for name in globals() if not name.startswith("__")] diff --git a/moleg_api/_laws/admin_scope.py b/moleg_api/_laws/admin_scope.py new file mode 100644 index 0000000..8530950 --- /dev/null +++ b/moleg_api/_laws/admin_scope.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +from .foundation import * +from .config import * + +def administrative_rule_text_from_articles(articles: list[AdministrativeRuleArticleText]) -> str: + return "\n\n".join( + f"{article.article or ''} {article.title or ''}\n{article.text}".strip() + for article in articles + ) + + +def administrative_rule_text_from_current_articles( + context: AdministrativeRuleContext, +) -> AdministrativeRuleText: + current_articles = list(context.current_articles) + return replace( + context.rule, + text=administrative_rule_text_from_articles(current_articles), + articles=current_articles, + ) + + +def filter_administrative_rule_text_to_delegated_scope( + rule: AdministrativeRuleText, + scope: dict[str, set[str]], + gaps: list[ContextGap], + source_notes: list[str], +) -> AdministrativeRuleText: + if not rule.articles or (not scope["law_names"] and not scope["law_ids"]): + return rule + + article_states = [ + (article, administrative_rule_article_source_match_state(article, scope)) + for article in rule.articles + ] + matching_articles = [ + article + for article, (match_state, _) in article_states + if match_state == "matched" + ] + if not matching_articles: + return rule + + query = delegated_criteria_scope_label(scope) + for article, (match_state, source_label) in article_states: + if match_state == "matched": + continue + article_label = article.article or "unlabeled article" + if match_state == "unverified": + reason = ( + f"{rule.identity.name} {article_label} was excluded from target delegated criteria " + f"because its source-law or source-article reference is missing for {query}." + ) + kind = "delegated_criteria_source_unverified" + else: + reason = ( + f"{rule.identity.name} {article_label} was excluded from target delegated criteria " + f"because its explicit source reference ({source_label}) does not match {query}." + ) + kind = "delegated_criteria_source_mismatch" + gaps.append( + ContextGap( + kind=kind, + reason=reason, + query=query, + recommended_interface="find_delegated_rules", + ) + ) + source_notes.append(reason) + + return replace( + rule, + text=administrative_rule_text_from_articles(matching_articles), + articles=matching_articles, + ) + + +def administrative_rule_article_source_match_state( + article: AdministrativeRuleArticleText, + scope: dict[str, set[str]], +) -> tuple[str, str]: + reference = { + "law_id": article.source_law_id, + "law_name": article.source_law_name, + "article": comparable_article_label(article.source_article), + } + if not any(reference.values()): + return ("unverified", "missing source reference") + source_label = " ".join( + part + for part in ( + reference["law_name"] or reference["law_id"] or "unknown law", + reference["article"], + ) + if part + ) + if not source_law_matches_scope(reference, scope): + return ("mismatch", source_label) + if not scope["articles"]: + return ("matched", source_label) + if not reference["article"]: + return ("unverified", source_label) + if any(articles_overlap(reference["article"], target_article) for target_article in scope["articles"]): + return ("matched", source_label) + return ("mismatch", source_label) + +from .validation import * +from .annex_tables import * +from .identity_params import * +from .temporal_gaps import * +from .delegated_scope import * +from .source_matching import * +from .article_gaps import * +from .history_identity import * +from .authority_sources import * +from .candidates import * +from .followup_searches import * +from .followup_hits import * +from .limits_intents import * +from .authority_article_gaps import * +from .authority_temporal_gaps import * +from .authority_temporal_filters import * +from .followup_basic import * +from .followup_identities import * +from .bridge import * +from .requested_load_gaps import * +from .context_load_gaps import * +from .ranking import * + +__all__ = [name for name in globals() if not name.startswith("__")] diff --git a/moleg_api/_laws/annex_tables.py b/moleg_api/_laws/annex_tables.py new file mode 100644 index 0000000..6050472 --- /dev/null +++ b/moleg_api/_laws/annex_tables.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +from .foundation import * +from .config import * + +_ANNEX_HEADER_RE = re.compile( + r"^\s*■\s*(?P.+?)\s*\[\s*(?P