moleg-api 0.2.0__tar.gz → 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {moleg_api-0.2.0 → moleg_api-0.2.1}/PKG-INFO +1 -1
- {moleg_api-0.2.0 → moleg_api-0.2.1}/moleg_api/cli.py +30 -6
- {moleg_api-0.2.0 → moleg_api-0.2.1}/moleg_api/laws.py +206 -4
- {moleg_api-0.2.0 → moleg_api-0.2.1}/moleg_api/normalization.py +55 -28
- {moleg_api-0.2.0 → moleg_api-0.2.1}/moleg_api.egg-info/PKG-INFO +1 -1
- {moleg_api-0.2.0 → moleg_api-0.2.1}/moleg_api.egg-info/SOURCES.txt +1 -0
- {moleg_api-0.2.0 → moleg_api-0.2.1}/pyproject.toml +1 -1
- {moleg_api-0.2.0 → moleg_api-0.2.1}/tests/test_laws.py +8 -1
- {moleg_api-0.2.0 → moleg_api-0.2.1}/tests/test_live_smoke.py +65 -0
- moleg_api-0.2.1/tests/test_sdk_fixes_0_2_1.py +375 -0
- {moleg_api-0.2.0 → moleg_api-0.2.1}/LICENSE +0 -0
- {moleg_api-0.2.0 → moleg_api-0.2.1}/README.md +0 -0
- {moleg_api-0.2.0 → moleg_api-0.2.1}/moleg_api/__init__.py +0 -0
- {moleg_api-0.2.0 → moleg_api-0.2.1}/moleg_api/__main__.py +0 -0
- {moleg_api-0.2.0 → moleg_api-0.2.1}/moleg_api/errors.py +0 -0
- {moleg_api-0.2.0 → moleg_api-0.2.1}/moleg_api/models.py +0 -0
- {moleg_api-0.2.0 → moleg_api-0.2.1}/moleg_api/py.typed +0 -0
- {moleg_api-0.2.0 → moleg_api-0.2.1}/moleg_api/source.py +0 -0
- {moleg_api-0.2.0 → moleg_api-0.2.1}/moleg_api.egg-info/dependency_links.txt +0 -0
- {moleg_api-0.2.0 → moleg_api-0.2.1}/moleg_api.egg-info/entry_points.txt +0 -0
- {moleg_api-0.2.0 → moleg_api-0.2.1}/moleg_api.egg-info/requires.txt +0 -0
- {moleg_api-0.2.0 → moleg_api-0.2.1}/moleg_api.egg-info/top_level.txt +0 -0
- {moleg_api-0.2.0 → moleg_api-0.2.1}/setup.cfg +0 -0
- {moleg_api-0.2.0 → moleg_api-0.2.1}/tests/test_cli.py +0 -0
- {moleg_api-0.2.0 → moleg_api-0.2.1}/tests/test_models.py +0 -0
- {moleg_api-0.2.0 → moleg_api-0.2.1}/tests/test_source.py +0 -0
- {moleg_api-0.2.0 → moleg_api-0.2.1}/tests/test_versions.py +0 -0
|
@@ -28,6 +28,7 @@ from __future__ import annotations
|
|
|
28
28
|
import argparse
|
|
29
29
|
import json
|
|
30
30
|
import sys
|
|
31
|
+
from importlib.metadata import PackageNotFoundError, version as _dist_version
|
|
31
32
|
from typing import Any
|
|
32
33
|
|
|
33
34
|
from .errors import (
|
|
@@ -42,6 +43,14 @@ from .errors import (
|
|
|
42
43
|
from .laws import MolegApi
|
|
43
44
|
from .models import DeferredLookup
|
|
44
45
|
|
|
46
|
+
|
|
47
|
+
def _pkg_version() -> str:
|
|
48
|
+
try:
|
|
49
|
+
return _dist_version("moleg-api")
|
|
50
|
+
except PackageNotFoundError:
|
|
51
|
+
return "unknown"
|
|
52
|
+
|
|
53
|
+
|
|
45
54
|
# Exit codes distinguish outcomes a shell/agent must branch on.
|
|
46
55
|
EXIT_OK = 0 # includes a zero-hit search (ok:true, count:0)
|
|
47
56
|
EXIT_AMBIGUOUS = 2 # multiple plausible identities — surface, don't pick
|
|
@@ -464,9 +473,19 @@ def signals_for(command: str, result: Any, args: argparse.Namespace) -> dict[str
|
|
|
464
473
|
discipline.append("선택 조문의 전후 문구 델타만 — 개정이유·입법의도·전체 변경 조문 망라 아님. trace-law-history·국회 법안자료로 보강.")
|
|
465
474
|
|
|
466
475
|
elif tname == "DelegationGraph":
|
|
467
|
-
|
|
468
|
-
|
|
476
|
+
rules = getattr(result, "rules", None) or []
|
|
477
|
+
flags["count"] = len(rules)
|
|
478
|
+
if not rules:
|
|
469
479
|
discipline.append("이 조회로 위임규정 못 찾음 ≠ 위임 없음 — 법령 체계도·다른 조문 범위·행정규칙·별표 경로를 더 확인.")
|
|
480
|
+
else:
|
|
481
|
+
discipline.append("위임 목록은 하위법령·인용법령 위임만 — 별표(과태료·범칙금액·기준표 등) 자체는 여기 없다. 금액·기준표는 위임된 시행령·시행규칙의 별표를 search-annex-forms(또는 load-delegated-criteria)로 확인.")
|
|
482
|
+
ident = getattr(result, "identity", None)
|
|
483
|
+
name = (getattr(ident, "name", None) or getattr(ident, "law_id", None) or "") if ident else ""
|
|
484
|
+
if name:
|
|
485
|
+
next_cmds.append({
|
|
486
|
+
"why": "위임된 별표·금액·기준표 확인",
|
|
487
|
+
"cmd": f"moleg search-annex-forms {json.dumps(name, ensure_ascii=False)} --source law",
|
|
488
|
+
})
|
|
470
489
|
|
|
471
490
|
elif tname == "LawStructure":
|
|
472
491
|
discipline.append("체계도는 계층 컨텍스트일 뿐 — 시행령·규칙·행정규칙이 이 법 아래 있음은 보이나, 조문 단위 위임·하위규칙 본문·운영기준 증명 아님. find-delegated-rules로 확인.")
|
|
@@ -632,6 +651,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
632
651
|
description="법제처 law.go.kr 법령 자료 조회 CLI. 항상 JSON 엔벨로프 1개를 stdout으로 출력.",
|
|
633
652
|
)
|
|
634
653
|
parser.add_argument("--raw", action="store_true", help="원본 소스 payload(raw)까지 직렬화(디버그).")
|
|
654
|
+
parser.add_argument(
|
|
655
|
+
"--version",
|
|
656
|
+
action="version",
|
|
657
|
+
version=f"moleg-api {_pkg_version()}",
|
|
658
|
+
help="설치된 moleg-api 버전을 출력하고 종료.",
|
|
659
|
+
)
|
|
635
660
|
sub = parser.add_subparsers(dest="command", metavar="<command>")
|
|
636
661
|
|
|
637
662
|
sub.add_parser("catalog", help="27개 서브커맨드·규약·kind 목록을 한 번에.")
|
|
@@ -740,9 +765,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
740
765
|
p.add_argument("--date-from", dest="date_from", default=None)
|
|
741
766
|
p.add_argument("--date-to", dest="date_to", default=None)
|
|
742
767
|
|
|
743
|
-
p = sub.add_parser("compare-law-versions", help="개정 전후 조문 문구
|
|
744
|
-
_add_law(p); p.add_argument("--
|
|
745
|
-
p.add_argument("--article", default=None)
|
|
768
|
+
p = sub.add_parser("compare-law-versions", help="개정 전후 조문 문구 비교(소스 제공 전후 쌍).")
|
|
769
|
+
_add_law(p); p.add_argument("--article", default=None)
|
|
746
770
|
|
|
747
771
|
p = sub.add_parser("find-delegated-rules", help="위임 규정·하위 법령.")
|
|
748
772
|
_add_law(p); p.add_argument("--article", default=None)
|
|
@@ -834,7 +858,7 @@ def _call(api: MolegApi, args: argparse.Namespace) -> Any:
|
|
|
834
858
|
date_range = (args.date_from, args.date_to) if (args.date_from and args.date_to) else None
|
|
835
859
|
return api.trace_law_history(args.law, date_range=date_range, article=args.article)
|
|
836
860
|
if c == "compare-law-versions":
|
|
837
|
-
return api.compare_law_versions(args.law,
|
|
861
|
+
return api.compare_law_versions(args.law, article=args.article)
|
|
838
862
|
if c == "find-delegated-rules":
|
|
839
863
|
return api.find_delegated_rules(args.law, article=args.article)
|
|
840
864
|
if c == "get-law-structure":
|
|
@@ -59,6 +59,7 @@ from .models import (
|
|
|
59
59
|
LawHistory,
|
|
60
60
|
LawIdentity,
|
|
61
61
|
LawStructure,
|
|
62
|
+
LawStructureNode,
|
|
62
63
|
LawText,
|
|
63
64
|
StructuredTableData,
|
|
64
65
|
)
|
|
@@ -249,6 +250,9 @@ INTERPRETATION_SOURCE_VALUES = ("moleg", "ministry", "all", "all_ministries")
|
|
|
249
250
|
COURT_VALUES = ("all", "supreme", "lower")
|
|
250
251
|
BUNDLE_MODE_VALUES = ("question", "promulgated_bill", "statute_review")
|
|
251
252
|
BUNDLE_BUDGET_VALUES = ("minimal", "standard", "broad")
|
|
253
|
+
# 헌재 사건번호 shape, e.g. 2005헌마1139 / 2015헌바9 (digits + 헌 + class letter(s)
|
|
254
|
+
# + digits) — distinct from the pure-digit 헌재결정례일련번호 detail id.
|
|
255
|
+
_CONSTITUTIONAL_CASE_NUMBER_RE = re.compile(r"^\d+헌[가-힣]+\d+$")
|
|
252
256
|
|
|
253
257
|
|
|
254
258
|
@dataclass(frozen=True)
|
|
@@ -951,6 +955,7 @@ class MolegApi:
|
|
|
951
955
|
`resolve_promulgated_law` before history when starting from a bill.
|
|
952
956
|
"""
|
|
953
957
|
identity = identity_from_identifier(law_identifier, basis="effective")
|
|
958
|
+
identity = self._resolve_identity_name(identity)
|
|
954
959
|
bill_id_map = normalize_history_bill_id_map(promulgation_bridge)
|
|
955
960
|
if article is not None:
|
|
956
961
|
params = identity_params(identity, as_of=None, basis="effective")
|
|
@@ -1017,10 +1022,7 @@ class MolegApi:
|
|
|
1017
1022
|
# The lsHistory list search matches on the law *name*; a bare law_id
|
|
1018
1023
|
# identity carries the id as its name, which returns a "no results"
|
|
1019
1024
|
# table and a parse failure. Recover the real name first.
|
|
1020
|
-
|
|
1021
|
-
resolved_name = self._law_name_for(identity)
|
|
1022
|
-
if resolved_name:
|
|
1023
|
-
identity = replace(identity, name=resolved_name)
|
|
1025
|
+
identity = self._resolve_identity_name(identity)
|
|
1024
1026
|
rows: list[dict[str, Any]] = []
|
|
1025
1027
|
page = 1
|
|
1026
1028
|
pages_seen = 0
|
|
@@ -1118,6 +1120,15 @@ class MolegApi:
|
|
|
1118
1120
|
return str(name)
|
|
1119
1121
|
return None
|
|
1120
1122
|
|
|
1123
|
+
def _resolve_identity_name(self, identity: LawIdentity) -> LawIdentity:
|
|
1124
|
+
"""Backfill the real statute name when an identity still carries a bare
|
|
1125
|
+
law_id as its name (degrades to the input unchanged on lookup failure)."""
|
|
1126
|
+
if not identity.name or str(identity.name).isdigit():
|
|
1127
|
+
resolved_name = self._law_name_for(identity)
|
|
1128
|
+
if resolved_name:
|
|
1129
|
+
return replace(identity, name=resolved_name)
|
|
1130
|
+
return identity
|
|
1131
|
+
|
|
1121
1132
|
def _load_versioned_law_raw(self, identity: LawIdentity, mst: str) -> dict[str, Any]:
|
|
1122
1133
|
"""Load one statute version's raw payload by MST (promulgated detail)."""
|
|
1123
1134
|
versioned = replace(identity, mst=mst)
|
|
@@ -1212,6 +1223,7 @@ class MolegApi:
|
|
|
1212
1223
|
payload = self.source.service("lsDelegated", params)
|
|
1213
1224
|
raw_delegation = unwrap_service_payload(payload, "lsDelegated")
|
|
1214
1225
|
root_identity = maybe_identity(raw_delegation.get("법령정보"), basis="effective") or identity
|
|
1226
|
+
root_identity = self._resolve_identity_name(root_identity)
|
|
1215
1227
|
rules = normalize_delegated_rules(raw_delegation)
|
|
1216
1228
|
if article is not None:
|
|
1217
1229
|
wanted = article_label_for_filter(article)
|
|
@@ -1385,6 +1397,10 @@ class MolegApi:
|
|
|
1385
1397
|
)
|
|
1386
1398
|
if not text.strip():
|
|
1387
1399
|
raise NoResultError("No annex/form body text returned")
|
|
1400
|
+
# A bare-id identity keeps the numeric id as its title (and no
|
|
1401
|
+
# related_name/annex_type); recover the authoritative label from the
|
|
1402
|
+
# body header before deciding on structuring or returning the identity.
|
|
1403
|
+
identity = enrich_annex_identity_from_body(identity, text)
|
|
1388
1404
|
structured_data = (
|
|
1389
1405
|
structure_annex_form_text(text, identity)
|
|
1390
1406
|
if attempt_structuring and annex_form_is_table_like(identity)
|
|
@@ -1896,6 +1912,19 @@ class MolegApi:
|
|
|
1896
1912
|
Related: call `search_constitutional_decisions` first; use `get_case`
|
|
1897
1913
|
for ordinary judicial decisions.
|
|
1898
1914
|
"""
|
|
1915
|
+
# The detail endpoint keys on the internal 헌재결정례일련번호 (a pure-digit
|
|
1916
|
+
# serial), a different system from the 사건번호 (e.g. 2005헌마1139). When a
|
|
1917
|
+
# 사건번호 is passed, resolve it to the serial via search first.
|
|
1918
|
+
if isinstance(identifier, str) and _CONSTITUTIONAL_CASE_NUMBER_RE.match(identifier.strip()):
|
|
1919
|
+
case_number = identifier.strip()
|
|
1920
|
+
resolved = self.search_constitutional_decisions(
|
|
1921
|
+
case_number, case_number=case_number, display=1
|
|
1922
|
+
)
|
|
1923
|
+
if not resolved:
|
|
1924
|
+
raise NoResultError(
|
|
1925
|
+
f"No Constitutional Court decision found for case number {case_number!r}"
|
|
1926
|
+
)
|
|
1927
|
+
identifier = resolved[0].identity
|
|
1899
1928
|
identity_hint = judicial_decision_identity_from_identifier(
|
|
1900
1929
|
identifier,
|
|
1901
1930
|
source_type="constitutional",
|
|
@@ -2912,6 +2941,49 @@ class MolegApi:
|
|
|
2912
2941
|
annex_form_candidates = list(bundle.candidates.annex_forms)
|
|
2913
2942
|
delegated_scope = delegated_criteria_target_scope(bundle)
|
|
2914
2943
|
|
|
2944
|
+
# Criteria/amount 별표 live inside the delegated 시행령·시행규칙, not the
|
|
2945
|
+
# parent Act, and law.go.kr's lsDelegated never surfaces 별표 pointers. On
|
|
2946
|
+
# the bare-anchor path (no --query) the inherited discovery searches only
|
|
2947
|
+
# the parent law's own name and finds nothing, so reach the subordinate
|
|
2948
|
+
# legislation's annexes directly by their resolved names. With an explicit
|
|
2949
|
+
# query the block below already drives annex discovery by that query.
|
|
2950
|
+
delegated_rule_names = (
|
|
2951
|
+
delegated_subordinate_rule_names(bundle, delegated_scope)
|
|
2952
|
+
if not explicit_query
|
|
2953
|
+
else []
|
|
2954
|
+
)
|
|
2955
|
+
if delegated_rule_names:
|
|
2956
|
+
subordinate_annex_limit = candidate_limits["annex_forms"]
|
|
2957
|
+
# Restrict to 별표 (criteria/amount tables); 서식·별지 forms are not
|
|
2958
|
+
# operational criteria and would otherwise crowd out the penalty/fine
|
|
2959
|
+
# tables the anchor articles delegate.
|
|
2960
|
+
delegated_annex_candidates = dedupe_candidates(
|
|
2961
|
+
[
|
|
2962
|
+
hit
|
|
2963
|
+
for rule_name in delegated_rule_names
|
|
2964
|
+
for hit in safe_list(
|
|
2965
|
+
lambda rule_name=rule_name: self.search_annex_forms(
|
|
2966
|
+
rule_name,
|
|
2967
|
+
source="law",
|
|
2968
|
+
search_scope="source",
|
|
2969
|
+
annex_type="별표",
|
|
2970
|
+
display=subordinate_annex_limit,
|
|
2971
|
+
),
|
|
2972
|
+
source_notes,
|
|
2973
|
+
f"Delegated-criteria subordinate-rule annex/form search for {rule_name}",
|
|
2974
|
+
gaps=gaps,
|
|
2975
|
+
deferred=deferred,
|
|
2976
|
+
query=rule_name,
|
|
2977
|
+
recommended_interface="search_annex_forms",
|
|
2978
|
+
source_type="annex_form",
|
|
2979
|
+
filters={"source": "law", "search_scope": "source", "annex_type": "별표"},
|
|
2980
|
+
)
|
|
2981
|
+
]
|
|
2982
|
+
)
|
|
2983
|
+
annex_form_candidates = dedupe_candidates(
|
|
2984
|
+
[*delegated_annex_candidates, *annex_form_candidates]
|
|
2985
|
+
)[:subordinate_annex_limit]
|
|
2986
|
+
|
|
2915
2987
|
if explicit_query:
|
|
2916
2988
|
query_administrative_candidates = dedupe_candidates(
|
|
2917
2989
|
[
|
|
@@ -3084,6 +3156,12 @@ class MolegApi:
|
|
|
3084
3156
|
)
|
|
3085
3157
|
)
|
|
3086
3158
|
|
|
3159
|
+
# websearch_required is a generic "no first-party path succeeded" prompt
|
|
3160
|
+
# inherited from load_institutional_system. When the delegated annex
|
|
3161
|
+
# search did surface first-party 별표, that prompt is misleading — drop it.
|
|
3162
|
+
if loaded_annex_forms or annex_form_candidates:
|
|
3163
|
+
gaps = [gap for gap in gaps if getattr(gap, "kind", None) != "websearch_required"]
|
|
3164
|
+
|
|
3087
3165
|
return replace(
|
|
3088
3166
|
bundle,
|
|
3089
3167
|
loaded=replace(
|
|
@@ -3929,6 +4007,82 @@ def annex_form_is_table_like(identity: AnnexFormIdentity) -> bool:
|
|
|
3929
4007
|
return any(token in signals for token in ("별표", "기준표", "표", "기준", "부과기준"))
|
|
3930
4008
|
|
|
3931
4009
|
|
|
4010
|
+
_ANNEX_HEADER_RE = re.compile(
|
|
4011
|
+
r"^\s*■\s*(?P<law>.+?)\s*\[\s*(?P<label>(?:별표|별지)[^\]]*?)\s*\]"
|
|
4012
|
+
)
|
|
4013
|
+
_ANNEX_BOX_CHARS = "┏┓┗┛┃┠┨┯┷━│─┌┐└┘├┤┼╋┣┫┳┻╂┿"
|
|
4014
|
+
# Adopt a header's source-law as related_name only when it reads like a statute
|
|
4015
|
+
# or subordinate/administrative rule, so junk header text is not treated as a
|
|
4016
|
+
# citable source reference (which feeds delegated-criteria source verification).
|
|
4017
|
+
_LEGISLATION_SUFFIXES = ("법", "법률", "령", "규칙", "조례", "규정", "고시", "훈령", "예규", "지침", "준칙")
|
|
4018
|
+
|
|
4019
|
+
|
|
4020
|
+
def _looks_like_legislation_name(name: str) -> bool:
|
|
4021
|
+
return name.endswith(_LEGISLATION_SUFFIXES)
|
|
4022
|
+
|
|
4023
|
+
|
|
4024
|
+
def enrich_annex_identity_from_body(
|
|
4025
|
+
identity: AnnexFormIdentity, text: str
|
|
4026
|
+
) -> AnnexFormIdentity:
|
|
4027
|
+
"""Recover the authoritative annex/form label from the loaded body header.
|
|
4028
|
+
|
|
4029
|
+
The text-download endpoint returns plain text with no title field, so a
|
|
4030
|
+
bare-id identity (loaded via ``--id`` with no title) keeps the numeric id as
|
|
4031
|
+
its title, which also silences table structuring. Parse the
|
|
4032
|
+
``■ <법령> [별표 N] ...`` header to backfill title/related_name/annex_type,
|
|
4033
|
+
filling only fields currently missing so a rich search-hit identity is never
|
|
4034
|
+
overwritten.
|
|
4035
|
+
"""
|
|
4036
|
+
lines = text.splitlines()
|
|
4037
|
+
header_idx: int | None = None
|
|
4038
|
+
match: re.Match[str] | None = None
|
|
4039
|
+
for idx, line in enumerate(lines[:6]):
|
|
4040
|
+
m = _ANNEX_HEADER_RE.match(line)
|
|
4041
|
+
if m:
|
|
4042
|
+
header_idx = idx
|
|
4043
|
+
match = m
|
|
4044
|
+
break
|
|
4045
|
+
if match is None or header_idx is None:
|
|
4046
|
+
return identity
|
|
4047
|
+
|
|
4048
|
+
law = match.group("law").strip()
|
|
4049
|
+
label = match.group("label").strip()
|
|
4050
|
+
annex_type = "별표" if label.startswith("별표") else "별지"
|
|
4051
|
+
|
|
4052
|
+
updates: dict[str, Any] = {}
|
|
4053
|
+
if not identity.related_name and _looks_like_legislation_name(law):
|
|
4054
|
+
updates["related_name"] = law
|
|
4055
|
+
if not identity.annex_type:
|
|
4056
|
+
updates["annex_type"] = annex_type
|
|
4057
|
+
if not identity.annex_number:
|
|
4058
|
+
number = re.search(r"\d+", label)
|
|
4059
|
+
if number:
|
|
4060
|
+
updates["annex_number"] = number.group(0)
|
|
4061
|
+
|
|
4062
|
+
if not identity.title or identity.title == identity.annex_id:
|
|
4063
|
+
# 별표 bodies place the annex name on its own line after the header;
|
|
4064
|
+
# forms carry it inside the bracket. Prefer the name line, fall back to
|
|
4065
|
+
# the bracketed "<법령> [별표 N]" label.
|
|
4066
|
+
name_line: str | None = None
|
|
4067
|
+
for line in lines[header_idx + 1 : header_idx + 8]:
|
|
4068
|
+
stripped = line.strip()
|
|
4069
|
+
if not stripped or stripped in ("(앞쪽)", "(뒤쪽)"):
|
|
4070
|
+
continue
|
|
4071
|
+
if stripped[0] in _ANNEX_BOX_CHARS or stripped.startswith("■"):
|
|
4072
|
+
break
|
|
4073
|
+
if any("가" <= ch <= "힣" for ch in stripped):
|
|
4074
|
+
name_line = stripped
|
|
4075
|
+
break
|
|
4076
|
+
if name_line and annex_type == "별표":
|
|
4077
|
+
updates["title"] = f"{law} [{label}] {name_line}"
|
|
4078
|
+
else:
|
|
4079
|
+
updates["title"] = f"{law} [{label}]"
|
|
4080
|
+
|
|
4081
|
+
if not updates:
|
|
4082
|
+
return identity
|
|
4083
|
+
return replace(identity, **updates)
|
|
4084
|
+
|
|
4085
|
+
|
|
3932
4086
|
def structure_annex_form_text(text: str, identity: AnnexFormIdentity) -> StructuredTableData:
|
|
3933
4087
|
lines = [line.strip() for line in text.splitlines() if line.strip()]
|
|
3934
4088
|
pipe_table = parse_pipe_table(lines, identity)
|
|
@@ -4526,6 +4680,54 @@ def delegated_criteria_target_scope(bundle: LegalContextBundle) -> dict[str, set
|
|
|
4526
4680
|
return {"law_ids": law_ids, "law_msts": law_msts, "law_names": law_names, "articles": articles}
|
|
4527
4681
|
|
|
4528
4682
|
|
|
4683
|
+
def flatten_structure_nodes(nodes: list[LawStructureNode]) -> list[LawStructureNode]:
|
|
4684
|
+
flat: list[LawStructureNode] = []
|
|
4685
|
+
for node in nodes:
|
|
4686
|
+
flat.append(node)
|
|
4687
|
+
if node.children:
|
|
4688
|
+
flat.extend(flatten_structure_nodes(node.children))
|
|
4689
|
+
return flat
|
|
4690
|
+
|
|
4691
|
+
|
|
4692
|
+
def delegated_subordinate_rule_names(
|
|
4693
|
+
bundle: LegalContextBundle, scope: dict[str, set[str]]
|
|
4694
|
+
) -> list[str]:
|
|
4695
|
+
"""Names of subordinate legislation (시행령·시행규칙) delegated from the target
|
|
4696
|
+
statute, used to search their own annexes — criteria/amount 별표 live inside
|
|
4697
|
+
the lower rules, and lsDelegated never surfaces 별표 pointers.
|
|
4698
|
+
|
|
4699
|
+
Delegations whose source article is in the requested scope are preferred; the
|
|
4700
|
+
law-structure 시행령·시행규칙 nodes are always included as a fallback so a bare
|
|
4701
|
+
anchor still reaches them.
|
|
4702
|
+
"""
|
|
4703
|
+
names: list[str] = []
|
|
4704
|
+
seen: set[str] = set()
|
|
4705
|
+
scope_articles = scope.get("articles") or set()
|
|
4706
|
+
|
|
4707
|
+
def add(name: str | None) -> None:
|
|
4708
|
+
if not name:
|
|
4709
|
+
return
|
|
4710
|
+
key = str(name).strip()
|
|
4711
|
+
if not key or key in seen:
|
|
4712
|
+
return
|
|
4713
|
+
seen.add(key)
|
|
4714
|
+
names.append(key)
|
|
4715
|
+
|
|
4716
|
+
for graph in bundle.loaded.delegations:
|
|
4717
|
+
for rule in graph.rules:
|
|
4718
|
+
if not rule.delegated_name:
|
|
4719
|
+
continue
|
|
4720
|
+
if scope_articles and rule.source_article:
|
|
4721
|
+
if comparable_article_label(rule.source_article) not in scope_articles:
|
|
4722
|
+
continue
|
|
4723
|
+
add(rule.delegated_name)
|
|
4724
|
+
for structure in bundle.loaded.law_structures:
|
|
4725
|
+
for node in flatten_structure_nodes(structure.instruments):
|
|
4726
|
+
if node.instrument_type in ("enforcement_decree", "enforcement_rule"):
|
|
4727
|
+
add(node.name)
|
|
4728
|
+
return names
|
|
4729
|
+
|
|
4730
|
+
|
|
4529
4731
|
def delegated_criteria_scope_label(scope: dict[str, set[str]]) -> str:
|
|
4530
4732
|
law_label = ", ".join(sorted(scope["law_names"] or scope["law_ids"]))
|
|
4531
4733
|
article_label = ", ".join(sorted(scope["articles"]))
|
|
@@ -975,7 +975,23 @@ def unwrap_search_interpretations(payload: dict[str, Any], target: str) -> list[
|
|
|
975
975
|
def unwrap_search_judicial_decisions(payload: dict[str, Any], target: str) -> list[dict[str, Any]]:
|
|
976
976
|
for envelope in ("PrecSearch", "precSearch", "DetcSearch", "detcSearch"):
|
|
977
977
|
if isinstance(payload.get(envelope), dict):
|
|
978
|
-
|
|
978
|
+
env = payload[envelope]
|
|
979
|
+
# law.go.kr names the row-element key by its own casing, not the
|
|
980
|
+
# request target: "prec" (lowercase) for cases but "Detc"
|
|
981
|
+
# (capitalized) for constitutional decisions. Match it
|
|
982
|
+
# case-insensitively, excluding the scalar "target" echo field.
|
|
983
|
+
rows = env.get(target)
|
|
984
|
+
if rows is None:
|
|
985
|
+
rows = next(
|
|
986
|
+
(
|
|
987
|
+
env[key]
|
|
988
|
+
for key in env
|
|
989
|
+
if isinstance(key, str)
|
|
990
|
+
and key.lower() == target.lower()
|
|
991
|
+
and key != "target"
|
|
992
|
+
),
|
|
993
|
+
None,
|
|
994
|
+
)
|
|
979
995
|
return [row for row in ensure_list(rows) if isinstance(row, dict)]
|
|
980
996
|
rows = payload.get(target)
|
|
981
997
|
if rows is not None:
|
|
@@ -1545,36 +1561,47 @@ def normalize_delegated_rules(payload: dict[str, Any]) -> list[DelegatedRule]:
|
|
|
1545
1561
|
continue
|
|
1546
1562
|
if "위임정보" in row or "조정보" in row:
|
|
1547
1563
|
source = row.get("조정보") if isinstance(row.get("조정보"), dict) else {}
|
|
1548
|
-
|
|
1564
|
+
raw_info = row.get("위임정보")
|
|
1565
|
+
# 위임정보 is list-valued when one article delegates to several
|
|
1566
|
+
# targets (e.g. 과태료·범칙금액 amount tables). Collapsing it to {}
|
|
1567
|
+
# silently drops every multi-target article, so emit one rule per
|
|
1568
|
+
# delegation target instead.
|
|
1569
|
+
if isinstance(raw_info, list):
|
|
1570
|
+
infos = [item for item in raw_info if isinstance(item, dict)]
|
|
1571
|
+
elif isinstance(raw_info, dict):
|
|
1572
|
+
infos = [raw_info]
|
|
1573
|
+
else:
|
|
1574
|
+
infos = [{}]
|
|
1549
1575
|
else:
|
|
1550
1576
|
source = row
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
source_article_title=string_or_none(first_value(source, "조문제목", "조제목")),
|
|
1558
|
-
delegated_type=string_or_none(first_value(info, "위임구분", "법종구분")),
|
|
1559
|
-
delegated_name=string_or_none(
|
|
1560
|
-
first_value(info, "위임법령제목", "위임행정규칙제목", "위임자치법규제목", "위임행정규칙명", "법령명")
|
|
1561
|
-
),
|
|
1562
|
-
delegated_law_id=string_or_none(first_value(info, "법령ID", "위임법령ID")),
|
|
1563
|
-
delegated_mst=string_or_none(first_value(info, "위임법령일련번호", "위임행정규칙일련번호", "위임자치법규일련번호", "법령일련번호")),
|
|
1564
|
-
delegated_article=article_label_from_parts(
|
|
1565
|
-
first_value(info, "위임법령조문번호", "위임행정규칙조번호"),
|
|
1566
|
-
first_value(
|
|
1567
|
-
info,
|
|
1568
|
-
"위임법령조문가지번호",
|
|
1569
|
-
"위임행정규칙조가지번호",
|
|
1570
|
-
"위임행정규칙조문가지번호",
|
|
1577
|
+
infos = [row]
|
|
1578
|
+
for info in infos:
|
|
1579
|
+
rule = DelegatedRule(
|
|
1580
|
+
source_article=article_label_from_parts(
|
|
1581
|
+
first_value(source, "조문번호", "조번호", "조항호목"),
|
|
1582
|
+
first_value(source, "조문가지번호", "조가지번호"),
|
|
1571
1583
|
),
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1584
|
+
source_article_title=string_or_none(first_value(source, "조문제목", "조제목")),
|
|
1585
|
+
delegated_type=string_or_none(first_value(info, "위임구분", "법종구분")),
|
|
1586
|
+
delegated_name=string_or_none(
|
|
1587
|
+
first_value(info, "위임법령제목", "위임행정규칙제목", "위임자치법규제목", "위임행정규칙명", "법령명")
|
|
1588
|
+
),
|
|
1589
|
+
delegated_law_id=string_or_none(first_value(info, "법령ID", "위임법령ID")),
|
|
1590
|
+
delegated_mst=string_or_none(first_value(info, "위임법령일련번호", "위임행정규칙일련번호", "위임자치법규일련번호", "법령일련번호")),
|
|
1591
|
+
delegated_article=article_label_from_parts(
|
|
1592
|
+
first_value(info, "위임법령조문번호", "위임행정규칙조번호"),
|
|
1593
|
+
first_value(
|
|
1594
|
+
info,
|
|
1595
|
+
"위임법령조문가지번호",
|
|
1596
|
+
"위임행정규칙조가지번호",
|
|
1597
|
+
"위임행정규칙조문가지번호",
|
|
1598
|
+
),
|
|
1599
|
+
),
|
|
1600
|
+
text=string_or_none(first_value(info, "라인텍스트", "조내용", "링크텍스트")),
|
|
1601
|
+
raw=row,
|
|
1602
|
+
)
|
|
1603
|
+
if rule.delegated_name or rule.text:
|
|
1604
|
+
rules.append(rule)
|
|
1578
1605
|
return rules
|
|
1579
1606
|
|
|
1580
1607
|
|
|
@@ -2124,7 +2124,14 @@ def test_get_annex_form_body_loads_law_text_export_from_candidate():
|
|
|
2124
2124
|
|
|
2125
2125
|
body = MolegApi(source).get_annex_form_body(identity)
|
|
2126
2126
|
|
|
2127
|
-
|
|
2127
|
+
# Caller-supplied identity fields are preserved; missing metadata
|
|
2128
|
+
# (annex_type/annex_number) is backfilled from the loaded body header.
|
|
2129
|
+
assert body.identity.annex_id == "17677511"
|
|
2130
|
+
assert body.identity.title == "과태료의 부과기준(제67조 관련)"
|
|
2131
|
+
assert body.identity.related_name == "식품위생법 시행령"
|
|
2132
|
+
assert body.identity.source_target == "licbyl"
|
|
2133
|
+
assert body.identity.annex_type == "별표"
|
|
2134
|
+
assert body.identity.annex_number == "2"
|
|
2128
2135
|
assert "과태료의 부과기준" in body.text
|
|
2129
2136
|
assert body.file_type == "text/plain"
|
|
2130
2137
|
assert body.extraction_method == "lsBylTextDownLoad.do"
|
|
@@ -184,3 +184,68 @@ def test_live_expand_legal_query_smoke():
|
|
|
184
184
|
|
|
185
185
|
assert expansion.original_query == "자동차 방치"
|
|
186
186
|
assert expansion.follow_up_searches
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def test_live_constitutional_search_high_frequency_term_smoke():
|
|
190
|
+
# 0.2.1 #4A: capitalized 'Detc' row key was dropping all hits to 0.
|
|
191
|
+
api = live_api()
|
|
192
|
+
|
|
193
|
+
hits = api.search_constitutional_decisions("개인정보", display=20)
|
|
194
|
+
|
|
195
|
+
assert hits, "high-frequency 헌재 term should return hits after the row-key fix"
|
|
196
|
+
assert hits[0].identity.decision_id
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_live_constitutional_detail_by_case_number_smoke():
|
|
200
|
+
# 0.2.1 #4B: get_constitutional_decision resolves a 사건번호 to its serial.
|
|
201
|
+
api = live_api()
|
|
202
|
+
|
|
203
|
+
hits = api.search_constitutional_decisions("개인정보", display=5)
|
|
204
|
+
hit = first_hit_or_skip(hits, "constitutional decision")
|
|
205
|
+
case_number = hit.identity.case_number
|
|
206
|
+
if not case_number:
|
|
207
|
+
pytest.skip("No 사건번호 on the sampled hit")
|
|
208
|
+
|
|
209
|
+
text = api.get_constitutional_decision(case_number)
|
|
210
|
+
|
|
211
|
+
assert text.identity.decision_id
|
|
212
|
+
assert text.text
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def test_live_find_delegated_rules_name_and_recall_smoke():
|
|
216
|
+
# 0.2.1 #2: resolved statute name + multi-target recall.
|
|
217
|
+
api = live_api()
|
|
218
|
+
|
|
219
|
+
graph = api.find_delegated_rules("001638") # 도로교통법
|
|
220
|
+
|
|
221
|
+
assert graph.identity.name and not graph.identity.name.isdigit()
|
|
222
|
+
articles = {rule.source_article for rule in graph.rules}
|
|
223
|
+
assert "제160조" in articles and "제162조" in articles
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_live_load_delegated_criteria_reaches_subordinate_annex_smoke():
|
|
227
|
+
# 0.2.1 #1: 별표 in the delegated 시행령·시행규칙 are surfaced first-party,
|
|
228
|
+
# not pushed to websearch.
|
|
229
|
+
api = live_api()
|
|
230
|
+
|
|
231
|
+
bundle = api.load_delegated_criteria("001638", articles=["제160조", "제162조"])
|
|
232
|
+
|
|
233
|
+
annex_titles = [annex.identity.title for annex in bundle.loaded.annex_forms]
|
|
234
|
+
candidate_titles = [c.identity.title for c in bundle.candidates.annex_forms]
|
|
235
|
+
all_titles = " ".join(annex_titles + candidate_titles)
|
|
236
|
+
assert bundle.candidates.annex_forms or bundle.loaded.annex_forms
|
|
237
|
+
assert "과태료" in all_titles or "범칙" in all_titles
|
|
238
|
+
gap_kinds = [gap.kind for gap in bundle.gaps]
|
|
239
|
+
assert gap_kinds != ["websearch_required"]
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def test_live_annex_form_body_recovers_label_smoke():
|
|
243
|
+
# 0.2.1 #3: a bare-id annex body load recovers its authoritative label.
|
|
244
|
+
api = live_api()
|
|
245
|
+
|
|
246
|
+
hits = api.search_annex_forms("도로교통법 시행령", source="law", annex_type="별표", display=5)
|
|
247
|
+
hit = first_hit_or_skip(hits, "law annex")
|
|
248
|
+
body = api.get_annex_form_body(hit.identity.annex_id) # bare id, no title
|
|
249
|
+
|
|
250
|
+
assert body.identity.title and body.identity.title != body.identity.annex_id
|
|
251
|
+
assert body.identity.related_name
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"""Regression tests for the 0.2.1 defect fixes.
|
|
2
|
+
|
|
3
|
+
Each group locks one consumer-facing defect surfaced by the published-package
|
|
4
|
+
end-to-end pass:
|
|
5
|
+
|
|
6
|
+
* #1 load-delegated-criteria reached 별표 in the delegated 시행령·시행규칙
|
|
7
|
+
* #2 find-delegated-rules recall, resolved identity name, populated signals
|
|
8
|
+
* #3 get-annex-form-body recovers the authoritative label from the body
|
|
9
|
+
* #4 헌재 search parses capitalized row keys; detail accepts a 사건번호
|
|
10
|
+
* #5 CLI: no dead --before/--after, a top-level --version
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
from moleg_api import MolegApi
|
|
18
|
+
from moleg_api.cli import build_parser, signals_for
|
|
19
|
+
from moleg_api.laws import (
|
|
20
|
+
_CONSTITUTIONAL_CASE_NUMBER_RE,
|
|
21
|
+
delegated_criteria_target_scope,
|
|
22
|
+
delegated_subordinate_rule_names,
|
|
23
|
+
enrich_annex_identity_from_body,
|
|
24
|
+
)
|
|
25
|
+
from moleg_api.models import (
|
|
26
|
+
AnnexFormIdentity,
|
|
27
|
+
BundleRequest,
|
|
28
|
+
CandidateContext,
|
|
29
|
+
DelegatedRule,
|
|
30
|
+
DelegationGraph,
|
|
31
|
+
LawIdentity,
|
|
32
|
+
LawStructure,
|
|
33
|
+
LawStructureNode,
|
|
34
|
+
LegalContextBundle,
|
|
35
|
+
LoadedContext,
|
|
36
|
+
)
|
|
37
|
+
from moleg_api.normalization import (
|
|
38
|
+
normalize_delegated_rules,
|
|
39
|
+
unwrap_search_judicial_decisions,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class FakeSource:
|
|
44
|
+
def __init__(self, *, search_payloads=None, service_payloads=None):
|
|
45
|
+
self.search_payloads = list(search_payloads or [])
|
|
46
|
+
self.service_payloads = list(service_payloads or [])
|
|
47
|
+
self.calls = []
|
|
48
|
+
|
|
49
|
+
def search(self, target, params):
|
|
50
|
+
self.calls.append(("search", target, params))
|
|
51
|
+
return self.search_payloads.pop(0)
|
|
52
|
+
|
|
53
|
+
def service(self, target, params):
|
|
54
|
+
self.calls.append(("service", target, params))
|
|
55
|
+
return self.service_payloads.pop(0)
|
|
56
|
+
|
|
57
|
+
def search_html(self, target, params): # pragma: no cover - unused here
|
|
58
|
+
raise AssertionError("search_html not expected")
|
|
59
|
+
|
|
60
|
+
def post_text(self, path, params): # pragma: no cover - unused here
|
|
61
|
+
raise AssertionError("post_text not expected")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _args(**kw):
|
|
65
|
+
ns = argparse.Namespace(as_of=None)
|
|
66
|
+
for key, value in kw.items():
|
|
67
|
+
setattr(ns, key, value)
|
|
68
|
+
return ns
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# --------------------------------------------------------------------------- #
|
|
72
|
+
# #4A — 헌재 search row-key casing (Detc vs prec)
|
|
73
|
+
# --------------------------------------------------------------------------- #
|
|
74
|
+
|
|
75
|
+
def test_unwrap_detc_capitalized_row_key_returns_rows():
|
|
76
|
+
payload = {
|
|
77
|
+
"DetcSearch": {
|
|
78
|
+
"totalCnt": 29,
|
|
79
|
+
"target": "detc",
|
|
80
|
+
"Detc": [
|
|
81
|
+
{"헌재결정례일련번호": "48654", "사건번호": "2015헌마1140"},
|
|
82
|
+
{"헌재결정례일련번호": "50600", "사건번호": "2016헌마492"},
|
|
83
|
+
],
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
rows = unwrap_search_judicial_decisions(payload, "detc")
|
|
87
|
+
assert [row["헌재결정례일련번호"] for row in rows] == ["48654", "50600"]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_unwrap_prec_lowercase_row_key_unchanged():
|
|
91
|
+
payload = {"PrecSearch": {"prec": [{"판례일련번호": "1"}]}}
|
|
92
|
+
assert len(unwrap_search_judicial_decisions(payload, "prec")) == 1
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_unwrap_detc_single_object_is_wrapped():
|
|
96
|
+
payload = {"DetcSearch": {"Detc": {"헌재결정례일련번호": "137475"}}}
|
|
97
|
+
rows = unwrap_search_judicial_decisions(payload, "detc")
|
|
98
|
+
assert len(rows) == 1 and rows[0]["헌재결정례일련번호"] == "137475"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_unwrap_detc_scalar_target_echo_not_treated_as_rows():
|
|
102
|
+
payload = {"DetcSearch": {"target": "detc", "Detc": [{"헌재결정례일련번호": "1"}]}}
|
|
103
|
+
rows = unwrap_search_judicial_decisions(payload, "detc")
|
|
104
|
+
assert len(rows) == 1
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# --------------------------------------------------------------------------- #
|
|
108
|
+
# #4B — get-constitutional-decision accepts a 사건번호
|
|
109
|
+
# --------------------------------------------------------------------------- #
|
|
110
|
+
|
|
111
|
+
def test_constitutional_case_number_regex():
|
|
112
|
+
assert _CONSTITUTIONAL_CASE_NUMBER_RE.match("2005헌마1139")
|
|
113
|
+
assert _CONSTITUTIONAL_CASE_NUMBER_RE.match("2015헌바9")
|
|
114
|
+
assert not _CONSTITUTIONAL_CASE_NUMBER_RE.match("48654") # a bare serial
|
|
115
|
+
assert not _CONSTITUTIONAL_CASE_NUMBER_RE.match("개인정보") # free text
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_get_constitutional_decision_resolves_case_number_via_search():
|
|
119
|
+
source = FakeSource(
|
|
120
|
+
search_payloads=[
|
|
121
|
+
{
|
|
122
|
+
"DetcSearch": {
|
|
123
|
+
"Detc": [
|
|
124
|
+
{
|
|
125
|
+
"헌재결정례일련번호": "137475",
|
|
126
|
+
"사건명": "공직자병역사항 위헌확인",
|
|
127
|
+
"사건번호": "2005헌마1139",
|
|
128
|
+
"종국일자": "20070101",
|
|
129
|
+
}
|
|
130
|
+
]
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
],
|
|
134
|
+
service_payloads=[
|
|
135
|
+
{
|
|
136
|
+
"detc": {
|
|
137
|
+
"헌재결정례일련번호": "137475",
|
|
138
|
+
"사건명": "공직자병역사항 위헌확인",
|
|
139
|
+
"사건번호": "2005헌마1139",
|
|
140
|
+
"종국일자": "20070101",
|
|
141
|
+
"전문": "결정 전문",
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
],
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
text = MolegApi(source).get_constitutional_decision("2005헌마1139")
|
|
148
|
+
|
|
149
|
+
assert text.identity.decision_id == "137475"
|
|
150
|
+
assert source.calls[0][:2] == ("search", "detc")
|
|
151
|
+
assert source.calls[1] == ("service", "detc", {"ID": "137475"})
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_get_constitutional_decision_numeric_serial_skips_search():
|
|
155
|
+
source = FakeSource(
|
|
156
|
+
service_payloads=[
|
|
157
|
+
{
|
|
158
|
+
"detc": {
|
|
159
|
+
"헌재결정례일련번호": "58400",
|
|
160
|
+
"사건명": "X",
|
|
161
|
+
"사건번호": "2020헌마1",
|
|
162
|
+
"종국일자": "20240229",
|
|
163
|
+
"전문": "전문",
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
]
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
MolegApi(source).get_constitutional_decision("58400")
|
|
170
|
+
|
|
171
|
+
assert source.calls[0] == ("service", "detc", {"ID": "58400"})
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# --------------------------------------------------------------------------- #
|
|
175
|
+
# #2a — normalize_delegated_rules keeps multi-target (list-valued) delegations
|
|
176
|
+
# --------------------------------------------------------------------------- #
|
|
177
|
+
|
|
178
|
+
def test_normalize_delegated_rules_splits_list_valued_info():
|
|
179
|
+
payload = {
|
|
180
|
+
"위임조문정보": [
|
|
181
|
+
{
|
|
182
|
+
"조정보": {"조문번호": "160", "조문제목": "과태료"},
|
|
183
|
+
"위임정보": [
|
|
184
|
+
{"위임구분": "위임규칙", "위임행정규칙제목": "도로교통법 시행규칙"},
|
|
185
|
+
{"위임구분": "위임법령", "위임법령제목": "도로교통법 시행령"},
|
|
186
|
+
],
|
|
187
|
+
}
|
|
188
|
+
]
|
|
189
|
+
}
|
|
190
|
+
rules = normalize_delegated_rules(payload)
|
|
191
|
+
assert len(rules) == 2
|
|
192
|
+
assert {rule.delegated_name for rule in rules} == {"도로교통법 시행규칙", "도로교통법 시행령"}
|
|
193
|
+
assert all(rule.source_article == "제160조" for rule in rules)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_normalize_delegated_rules_dict_valued_single_target_unchanged():
|
|
197
|
+
payload = {
|
|
198
|
+
"위임조문정보": [
|
|
199
|
+
{"조정보": {"조문번호": "163"}, "위임정보": {"위임법령제목": "도로교통법"}}
|
|
200
|
+
]
|
|
201
|
+
}
|
|
202
|
+
rules = normalize_delegated_rules(payload)
|
|
203
|
+
assert len(rules) == 1 and rules[0].delegated_name == "도로교통법"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_normalize_delegated_rules_flat_row_shape_preserved():
|
|
207
|
+
payload = {"위임법령": [{"위임법령제목": "어떤 시행령", "조문번호": "5"}]}
|
|
208
|
+
rules = normalize_delegated_rules(payload)
|
|
209
|
+
assert len(rules) == 1 and rules[0].delegated_name == "어떤 시행령"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# --------------------------------------------------------------------------- #
|
|
213
|
+
# #2b / #5c — resolved statute name replaces a bare law_id
|
|
214
|
+
# --------------------------------------------------------------------------- #
|
|
215
|
+
|
|
216
|
+
class _NamedApi(MolegApi):
|
|
217
|
+
def _law_name_for(self, identity):
|
|
218
|
+
return "주택임대차보호법" if str(identity.name).isdigit() else identity.name
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def test_resolve_identity_name_backfills_digit_name():
|
|
222
|
+
api = _NamedApi(source=object())
|
|
223
|
+
out = api._resolve_identity_name(LawIdentity(law_id="001248", name="001248", basis="effective"))
|
|
224
|
+
assert out.name == "주택임대차보호법"
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def test_resolve_identity_name_keeps_real_name():
|
|
228
|
+
api = _NamedApi(source=object())
|
|
229
|
+
ident = LawIdentity(law_id="001248", name="주택임대차보호법", basis="effective")
|
|
230
|
+
assert api._resolve_identity_name(ident) is ident
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# --------------------------------------------------------------------------- #
|
|
234
|
+
# #2c — DelegationGraph signals are populated for a non-empty result
|
|
235
|
+
# --------------------------------------------------------------------------- #
|
|
236
|
+
|
|
237
|
+
def test_delegation_graph_signals_populated_when_rules_present():
|
|
238
|
+
graph = DelegationGraph(
|
|
239
|
+
identity=LawIdentity(law_id="001638", name="도로교통법", basis="effective"),
|
|
240
|
+
rules=[DelegatedRule(source_article="제160조", delegated_name="도로교통법 시행령")],
|
|
241
|
+
)
|
|
242
|
+
sig = signals_for("find-delegated-rules", graph, _args())
|
|
243
|
+
assert sig["flags"]["count"] == 1
|
|
244
|
+
assert any("별표" in line for line in sig["discipline"])
|
|
245
|
+
assert any("search-annex-forms" in cmd["cmd"] for cmd in sig["next"])
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def test_delegation_graph_signals_empty_keeps_absence_discipline():
|
|
249
|
+
graph = DelegationGraph(
|
|
250
|
+
identity=LawIdentity(law_id="x", name="x", basis="effective"), rules=[]
|
|
251
|
+
)
|
|
252
|
+
sig = signals_for("find-delegated-rules", graph, _args())
|
|
253
|
+
assert sig["flags"]["count"] == 0
|
|
254
|
+
assert sig["discipline"]
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# --------------------------------------------------------------------------- #
|
|
258
|
+
# #3 — enrich_annex_identity_from_body recovers the authoritative label
|
|
259
|
+
# --------------------------------------------------------------------------- #
|
|
260
|
+
|
|
261
|
+
def _bare_annex(annex_id="18177371"):
|
|
262
|
+
return AnnexFormIdentity(
|
|
263
|
+
annex_id=annex_id, title=annex_id, source_type="law", source_target="licbyl"
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def test_enrich_annex_byeolpyo_recovers_title_and_metadata():
|
|
268
|
+
text = (
|
|
269
|
+
"■ 도로교통법 시행령 [별표 8] <개정 2025. 6. 2.>\n"
|
|
270
|
+
"\n"
|
|
271
|
+
"범칙행위 및 범칙금액(운전자)(제93조제1항 관련)\n"
|
|
272
|
+
"┏━━━┓"
|
|
273
|
+
)
|
|
274
|
+
out = enrich_annex_identity_from_body(_bare_annex(), text)
|
|
275
|
+
assert out.related_name == "도로교통법 시행령"
|
|
276
|
+
assert out.annex_type == "별표"
|
|
277
|
+
assert out.annex_number == "8"
|
|
278
|
+
assert "범칙행위 및 범칙금액(운전자)" in out.title
|
|
279
|
+
assert out.title != out.annex_id
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def test_enrich_annex_form_uses_bracket_label():
|
|
283
|
+
text = "■ 도로교통법 시행규칙 [별지 제159호의2서식] <개정 2024. 7. 31.>\n서식 본문"
|
|
284
|
+
out = enrich_annex_identity_from_body(_bare_annex("18148709"), text)
|
|
285
|
+
assert out.related_name == "도로교통법 시행규칙"
|
|
286
|
+
assert out.annex_type == "별지"
|
|
287
|
+
assert out.title == "도로교통법 시행규칙 [별지 제159호의2서식]"
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def test_enrich_annex_does_not_clobber_rich_identity():
|
|
291
|
+
ident = AnnexFormIdentity(
|
|
292
|
+
annex_id="1",
|
|
293
|
+
title="진짜 제목",
|
|
294
|
+
source_type="law",
|
|
295
|
+
source_target="licbyl",
|
|
296
|
+
related_name="식품위생법 시행령",
|
|
297
|
+
annex_type="별표",
|
|
298
|
+
)
|
|
299
|
+
out = enrich_annex_identity_from_body(ident, "■ 다른법 시행령 [별표 3]\n다른 이름")
|
|
300
|
+
assert out.title == "진짜 제목"
|
|
301
|
+
assert out.related_name == "식품위생법 시행령"
|
|
302
|
+
assert out.annex_type == "별표"
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def test_enrich_annex_without_header_leaves_identity():
|
|
306
|
+
out = enrich_annex_identity_from_body(_bare_annex("9"), "헤더 없는 그냥 본문")
|
|
307
|
+
assert out.title == "9"
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def test_enrich_annex_non_legislation_source_not_adopted_as_reference():
|
|
311
|
+
# A header that is not a statute/rule name must not become a citable
|
|
312
|
+
# related_name (it feeds delegated-criteria source verification).
|
|
313
|
+
out = enrich_annex_identity_from_body(
|
|
314
|
+
AnnexFormIdentity(annex_id="44", title="무단방치 기준", source_type="law", source_target="licbyl"),
|
|
315
|
+
"■ 출처 미상 [별표]\n표 내용",
|
|
316
|
+
)
|
|
317
|
+
assert out.related_name is None
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# --------------------------------------------------------------------------- #
|
|
321
|
+
# #1 — delegated_subordinate_rule_names resolves the 시행령·시행규칙 to search
|
|
322
|
+
# --------------------------------------------------------------------------- #
|
|
323
|
+
|
|
324
|
+
def test_delegated_subordinate_rule_names_scopes_and_includes_structures():
|
|
325
|
+
ident = LawIdentity(law_id="001638", name="도로교통법", basis="effective")
|
|
326
|
+
delegation = DelegationGraph(
|
|
327
|
+
identity=ident,
|
|
328
|
+
rules=[
|
|
329
|
+
DelegatedRule(source_article="제160조", delegated_name="도로교통법 시행규칙"),
|
|
330
|
+
DelegatedRule(source_article="제999조", delegated_name="무관 시행령"),
|
|
331
|
+
],
|
|
332
|
+
)
|
|
333
|
+
structure = LawStructure(
|
|
334
|
+
identity=ident,
|
|
335
|
+
instruments=[
|
|
336
|
+
LawStructureNode(name="도로교통법 시행령", source_type="law", instrument_type="enforcement_decree"),
|
|
337
|
+
LawStructureNode(name="도로교통법 시행규칙", source_type="law", instrument_type="enforcement_rule"),
|
|
338
|
+
LawStructureNode(name="상위 법률", source_type="law", instrument_type="related_law"),
|
|
339
|
+
],
|
|
340
|
+
)
|
|
341
|
+
bundle = LegalContextBundle(
|
|
342
|
+
request=BundleRequest(query=None, mode="institutional_system", budget="standard", articles=["제160조"]),
|
|
343
|
+
loaded=LoadedContext(delegations=[delegation], law_structures=[structure]),
|
|
344
|
+
candidates=CandidateContext(),
|
|
345
|
+
)
|
|
346
|
+
names = delegated_subordinate_rule_names(bundle, delegated_criteria_target_scope(bundle))
|
|
347
|
+
assert "도로교통법 시행령" in names
|
|
348
|
+
assert "도로교통법 시행규칙" in names
|
|
349
|
+
assert "무관 시행령" not in names # source article out of requested scope
|
|
350
|
+
assert "상위 법률" not in names # related_law instrument excluded
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
# --------------------------------------------------------------------------- #
|
|
354
|
+
# #5a / #5b — CLI: dead flags removed, --version added
|
|
355
|
+
# --------------------------------------------------------------------------- #
|
|
356
|
+
|
|
357
|
+
def test_compare_law_versions_rejects_removed_before_after():
|
|
358
|
+
with pytest.raises(SystemExit):
|
|
359
|
+
build_parser().parse_args(
|
|
360
|
+
["compare-law-versions", "--law", "001248", "--before", "2020-01-01"]
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def test_compare_law_versions_accepts_law_and_article():
|
|
365
|
+
ns = build_parser().parse_args(
|
|
366
|
+
["compare-law-versions", "--law", "001248", "--article", "제3조"]
|
|
367
|
+
)
|
|
368
|
+
assert ns.law == "001248" and ns.article == "제3조"
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def test_top_level_version_flag_prints_and_exits_zero(capsys):
|
|
372
|
+
with pytest.raises(SystemExit) as exc:
|
|
373
|
+
build_parser().parse_args(["--version"])
|
|
374
|
+
assert exc.value.code == 0
|
|
375
|
+
assert "moleg-api" in capsys.readouterr().out
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|