moleg-api 0.2.1__tar.gz → 0.2.2__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.1 → moleg_api-0.2.2}/PKG-INFO +1 -1
- {moleg_api-0.2.1 → moleg_api-0.2.2}/moleg_api/__init__.py +2 -0
- {moleg_api-0.2.1 → moleg_api-0.2.2}/moleg_api/cli.py +126 -23
- {moleg_api-0.2.1 → moleg_api-0.2.2}/moleg_api/errors.py +22 -0
- {moleg_api-0.2.1 → moleg_api-0.2.2}/moleg_api/laws.py +41 -4
- {moleg_api-0.2.1 → moleg_api-0.2.2}/moleg_api/models.py +3 -2
- {moleg_api-0.2.1 → moleg_api-0.2.2}/moleg_api/normalization.py +153 -22
- {moleg_api-0.2.1 → moleg_api-0.2.2}/moleg_api.egg-info/PKG-INFO +1 -1
- {moleg_api-0.2.1 → moleg_api-0.2.2}/moleg_api.egg-info/SOURCES.txt +1 -0
- {moleg_api-0.2.1 → moleg_api-0.2.2}/pyproject.toml +1 -1
- {moleg_api-0.2.1 → moleg_api-0.2.2}/tests/test_cli.py +2 -1
- {moleg_api-0.2.1 → moleg_api-0.2.2}/tests/test_laws.py +7 -5
- {moleg_api-0.2.1 → moleg_api-0.2.2}/tests/test_live_smoke.py +47 -0
- {moleg_api-0.2.1 → moleg_api-0.2.2}/tests/test_models.py +1 -0
- moleg_api-0.2.2/tests/test_sdk_fixes_0_2_2.py +344 -0
- {moleg_api-0.2.1 → moleg_api-0.2.2}/LICENSE +0 -0
- {moleg_api-0.2.1 → moleg_api-0.2.2}/README.md +0 -0
- {moleg_api-0.2.1 → moleg_api-0.2.2}/moleg_api/__main__.py +0 -0
- {moleg_api-0.2.1 → moleg_api-0.2.2}/moleg_api/py.typed +0 -0
- {moleg_api-0.2.1 → moleg_api-0.2.2}/moleg_api/source.py +0 -0
- {moleg_api-0.2.1 → moleg_api-0.2.2}/moleg_api.egg-info/dependency_links.txt +0 -0
- {moleg_api-0.2.1 → moleg_api-0.2.2}/moleg_api.egg-info/entry_points.txt +0 -0
- {moleg_api-0.2.1 → moleg_api-0.2.2}/moleg_api.egg-info/requires.txt +0 -0
- {moleg_api-0.2.1 → moleg_api-0.2.2}/moleg_api.egg-info/top_level.txt +0 -0
- {moleg_api-0.2.1 → moleg_api-0.2.2}/setup.cfg +0 -0
- {moleg_api-0.2.1 → moleg_api-0.2.2}/tests/test_sdk_fixes_0_2_1.py +0 -0
- {moleg_api-0.2.1 → moleg_api-0.2.2}/tests/test_source.py +0 -0
- {moleg_api-0.2.1 → moleg_api-0.2.2}/tests/test_versions.py +0 -0
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from .errors import (
|
|
4
4
|
AmbiguousLawError,
|
|
5
|
+
AsOfBeforeCoverageError,
|
|
5
6
|
MolegApiError,
|
|
6
7
|
NoResultError,
|
|
7
8
|
ParseFailureError,
|
|
@@ -124,6 +125,7 @@ __all__ = [
|
|
|
124
125
|
"MolegApiError",
|
|
125
126
|
"NoResultError",
|
|
126
127
|
"ParseFailureError",
|
|
128
|
+
"AsOfBeforeCoverageError",
|
|
127
129
|
"RateLimitError",
|
|
128
130
|
"RetryExhaustedError",
|
|
129
131
|
"SourceApiError",
|
|
@@ -28,11 +28,13 @@ from __future__ import annotations
|
|
|
28
28
|
import argparse
|
|
29
29
|
import json
|
|
30
30
|
import sys
|
|
31
|
+
from datetime import date, datetime
|
|
31
32
|
from importlib.metadata import PackageNotFoundError, version as _dist_version
|
|
32
33
|
from typing import Any
|
|
33
34
|
|
|
34
35
|
from .errors import (
|
|
35
36
|
AmbiguousLawError,
|
|
37
|
+
AsOfBeforeCoverageError,
|
|
36
38
|
MolegApiError,
|
|
37
39
|
NoResultError,
|
|
38
40
|
ParseFailureError,
|
|
@@ -287,6 +289,34 @@ def _deferred_next(deferred: list[Any], *, cap: int = 3) -> tuple[list[dict[str,
|
|
|
287
289
|
return items, overflow
|
|
288
290
|
|
|
289
291
|
|
|
292
|
+
def _today_compact() -> str:
|
|
293
|
+
return date.today().strftime("%Y%m%d")
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _compact_digits(value: Any) -> str:
|
|
297
|
+
return "".join(ch for ch in str(value) if ch.isdigit()) if value else ""
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def parse_as_of(raw: str) -> str:
|
|
301
|
+
"""Strictly parse an --as-of value to canonical YYYYMMDD.
|
|
302
|
+
|
|
303
|
+
Accepts YYYY-MM-DD or YYYYMMDD only, with real calendar validation (rejects
|
|
304
|
+
month 13, Feb 30, day 99, and non-dates). A malformed date is a usage error,
|
|
305
|
+
not a silent fallback to the current version.
|
|
306
|
+
"""
|
|
307
|
+
text = raw.strip()
|
|
308
|
+
for fmt in ("%Y-%m-%d", "%Y%m%d"):
|
|
309
|
+
try:
|
|
310
|
+
return datetime.strptime(text, fmt).strftime("%Y%m%d")
|
|
311
|
+
except ValueError:
|
|
312
|
+
continue
|
|
313
|
+
raise CliError(
|
|
314
|
+
f"--as-of 값 {raw!r}을(를) 날짜로 해석할 수 없다 — YYYY-MM-DD 또는 YYYYMMDD 형식만 허용(월 1~12·일 유효 범위).",
|
|
315
|
+
kind="usage_error",
|
|
316
|
+
exit_code=EXIT_USAGE,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
|
|
290
320
|
def _law_time_flags(identity: Any, as_of: str | None) -> tuple[dict[str, Any], list[str]]:
|
|
291
321
|
flags: dict[str, Any] = {}
|
|
292
322
|
lines: list[str] = []
|
|
@@ -296,18 +326,28 @@ def _law_time_flags(identity: Any, as_of: str | None) -> tuple[dict[str, Any], l
|
|
|
296
326
|
eff = getattr(identity, "effective_date", None)
|
|
297
327
|
if eff:
|
|
298
328
|
flags["effective_date"] = eff
|
|
299
|
-
|
|
300
|
-
#
|
|
301
|
-
#
|
|
329
|
+
e = _compact_digits(eff)
|
|
330
|
+
# 공포됐어도 시행일이 미래면 아직 효력이 없다. 반환 버전의 시행일을 오늘과 직접
|
|
331
|
+
# 대조해 '미시행'을 신호한다 — as_of 유무와 무관(미래 버전은 어느 경로로도 올 수 있다).
|
|
332
|
+
if len(e) == 8 and e > _today_compact():
|
|
333
|
+
flags["not_effective_as_of"] = True
|
|
334
|
+
lines.append(
|
|
335
|
+
"이 버전은 시행일이 미래 — 공포됐으나 아직 미시행이다. 현행 시행 문구로 인용 금지; "
|
|
336
|
+
"현재 시행 중 본문은 --as-of 없이(현행) 로드하라."
|
|
337
|
+
)
|
|
338
|
+
# --as-of는 그 시점에 시행 중이던 버전을 로드한다(로더가 역사 버전을 해결). 반환
|
|
339
|
+
# effective_date를 확인하라 — 요청 시점과 다르면 인접 버전이 반환된 것이다.
|
|
302
340
|
if as_of:
|
|
303
341
|
flags["as_of"] = as_of
|
|
304
|
-
a =
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
342
|
+
a = _compact_digits(as_of)
|
|
343
|
+
if a and e and e != a:
|
|
344
|
+
flags["version_mismatch"] = {"requested": a, "loaded": e}
|
|
345
|
+
if e > a:
|
|
346
|
+
# 하위호환: SKILL.md·catalog가 참조하는 기존 플래그 유지.
|
|
347
|
+
flags["version_request_unfulfilled"] = True
|
|
308
348
|
lines.append(
|
|
309
|
-
"
|
|
310
|
-
"
|
|
349
|
+
"요청한 시점(as_of)과 반환된 버전의 시행일이 다르다 — 반환 effective_date 기준으로 해석하라. "
|
|
350
|
+
"그 시점의 정확한 시행 버전 이력은 trace-law-history로 확인."
|
|
311
351
|
)
|
|
312
352
|
return flags, lines
|
|
313
353
|
|
|
@@ -356,6 +396,17 @@ def signals_for(command: str, result: Any, args: argparse.Namespace) -> dict[str
|
|
|
356
396
|
if count == 0:
|
|
357
397
|
flags["searched"] = _searched_scope(command, args)
|
|
358
398
|
discipline.append("0건 — 이 검색어·범위로 못 찾음일 뿐, 부재의 증명 아님.")
|
|
399
|
+
query = getattr(args, "query", None)
|
|
400
|
+
# 헌재·판례 검색 기본은 제목(사건명) — doctrine·법리 키워드는 --search-body(본문 전체)라야 매칭된다.
|
|
401
|
+
if eff_command in ("search-constitutional-decisions", "search-cases") and not getattr(args, "search_body", False) and query:
|
|
402
|
+
next_cmds.append({"why": "제목이 아닌 본문(판시·주문·이유) 전체 검색으로 재시도", "cmd": f"moleg {eff_command} {json.dumps(query, ensure_ascii=False)} --search-body"})
|
|
403
|
+
discipline.append("헌재·판례 검색 기본은 제목 검색 — doctrine·본문 키워드로 0건이면 --search-body로 재시도하라.")
|
|
404
|
+
# 별표 검색은 스코프(title=별표 제목 / source=소관 법령명)에 따라 결과가 갈린다 — 반대 스코프로 재시도.
|
|
405
|
+
elif eff_command == "search-annex-forms" and query:
|
|
406
|
+
scope = getattr(args, "search_scope", "title") or "title"
|
|
407
|
+
other = "source" if scope == "title" else "title"
|
|
408
|
+
next_cmds.append({"why": f"별표 검색 스코프 전환({scope}→{other})으로 재시도", "cmd": f"moleg search-annex-forms {json.dumps(query, ensure_ascii=False)} --search-scope {other}"})
|
|
409
|
+
discipline.append("별표 검색 title=별표 제목 / source=소관 법령명 매칭(토큰 근접) — 0건이면 반대 스코프로 재시도하라.")
|
|
359
410
|
# mechanical alternate: drop a narrowing filter if one was set.
|
|
360
411
|
broaden = _broaden_next(command, args)
|
|
361
412
|
if broaden:
|
|
@@ -388,6 +439,8 @@ def signals_for(command: str, result: Any, args: argparse.Namespace) -> dict[str
|
|
|
388
439
|
if tname == "LawText":
|
|
389
440
|
tf, tl = _law_time_flags(result.identity, as_of)
|
|
390
441
|
flags.update(tf); discipline.extend(tl)
|
|
442
|
+
if flags.get("not_effective_as_of"):
|
|
443
|
+
source = "법제처 / 공포본(장래 시행 — 아직 미시행)"
|
|
391
444
|
if _has_supplementary(result):
|
|
392
445
|
flags["has_supplementary"] = True
|
|
393
446
|
discipline.append("시행일·적용례·경과조치는 supplementary_provisions에 별도 — 조문 본문·법령 effective_date만으로 전환 범위 답 금지.")
|
|
@@ -401,6 +454,8 @@ def signals_for(command: str, result: Any, args: argparse.Namespace) -> dict[str
|
|
|
401
454
|
flags.update(af); discipline.extend(al)
|
|
402
455
|
tf, tl = _law_time_flags(result.identity, as_of)
|
|
403
456
|
flags.update(tf); discipline.extend(tl)
|
|
457
|
+
if flags.get("not_effective_as_of"):
|
|
458
|
+
source = "법제처 / 공포본 조문(장래 시행 — 아직 미시행)"
|
|
404
459
|
discipline.append("정의·예외·적용대상·요건은 text의 항·호·목 중첩에 있다 — 조문제목·상위 조문내용만으로 요약 금지.")
|
|
405
460
|
moved = _real_moved_to(result)
|
|
406
461
|
if moved:
|
|
@@ -460,6 +515,11 @@ def signals_for(command: str, result: Any, args: argparse.Namespace) -> dict[str
|
|
|
460
515
|
if command == "get-constitutional-decision" or (st and "detc" in str(st)):
|
|
461
516
|
kind, source = "constitutional_text", "법제처 / 헌재결정 본문"
|
|
462
517
|
discipline.append("헌재 결정 — 판시사항·심판대상조문을 로드된 본문에서만 인용, doctrine 망라 단정 금지.")
|
|
518
|
+
disposition = getattr(result.identity, "decision_type", None)
|
|
519
|
+
if disposition:
|
|
520
|
+
flags["disposition"] = disposition
|
|
521
|
+
elif not (getattr(result, "holdings", None) or getattr(result, "summary", None) or getattr(result, "reviewed_statutes", None)):
|
|
522
|
+
discipline.append("주문·처분 결과가 구조화 필드에 없음 — full_text의 【주 문】을 직접 읽어 각하/기각/합헌/위헌을 판단하고, 각하·기각을 본안(merits) 판단으로 오인하지 마라.")
|
|
463
523
|
else:
|
|
464
524
|
kind, source = "case_text", "법제처 / 판례 본문"
|
|
465
525
|
|
|
@@ -540,22 +600,44 @@ def _law_list_signals(hits: list[Any]) -> dict[str, Any]:
|
|
|
540
600
|
flags: dict[str, Any] = {}
|
|
541
601
|
discipline: list[str] = []
|
|
542
602
|
next_cmds: list[dict[str, str]] = []
|
|
603
|
+
today = _today_compact()
|
|
543
604
|
by_name: dict[str, list[Any]] = {}
|
|
544
605
|
for h in hits:
|
|
545
606
|
by_name.setdefault(getattr(h.identity, "name", ""), []).append(h)
|
|
546
607
|
ambiguous = any(len(v) > 1 for v in by_name.values())
|
|
608
|
+
# The current in-force version is the LATEST 시행일 that is ≤ today; every
|
|
609
|
+
# other candidate (older OR future) is not currently in force. bare
|
|
610
|
+
# `get-law --law` returns that current version, so only it gets the bare
|
|
611
|
+
# steer; the rest get an --as-of targeting their own effective_date.
|
|
612
|
+
effs_all = [_compact_digits(getattr(h.identity, "effective_date", None)) for h in hits]
|
|
613
|
+
in_force_effs = [e for e in effs_all if len(e) == 8 and e <= today]
|
|
614
|
+
current_eff = max(in_force_effs) if in_force_effs else None
|
|
615
|
+
first_eff = effs_all[0] if effs_all else ""
|
|
616
|
+
future_top = len(first_eff) == 8 and first_eff > today
|
|
617
|
+
if future_top:
|
|
618
|
+
flags["top_candidate_not_yet_effective"] = True
|
|
547
619
|
if ambiguous:
|
|
548
620
|
flags["ambiguous_versions"] = True
|
|
549
|
-
discipline.append(
|
|
550
|
-
|
|
621
|
+
discipline.append(
|
|
622
|
+
"동명 후보가 시행일 다름 — 현행본은 그냥 get-law --law(로더가 현행 시행본 해결), 특정 시점 버전은 --as-of <시행일>. "
|
|
623
|
+
"목록은 최신 공포순이라 상단이 미시행(장래 시행)일 수 있으니 effective_date를 오늘과 대조하라."
|
|
624
|
+
)
|
|
625
|
+
elif future_top:
|
|
626
|
+
discipline.append(
|
|
627
|
+
"최신 후보가 아직 미시행(장래 시행)이다 — get-law --law는 현행 시행본을 반환한다. effective_date를 오늘과 대조해 인용하라."
|
|
628
|
+
)
|
|
629
|
+
for h in hits[:3]:
|
|
551
630
|
ident = h.identity
|
|
552
631
|
law_id = getattr(ident, "law_id", "") or ""
|
|
553
632
|
eff = getattr(ident, "effective_date", None)
|
|
633
|
+
e = _compact_digits(eff)
|
|
634
|
+
is_current = bool(current_eff) and e == current_eff
|
|
635
|
+
status = "현행" if is_current else ("미시행" if (len(e) == 8 and e > today) else "과거")
|
|
554
636
|
cmd = f"moleg get-law --law {law_id}"
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
next_cmds.append({"why":
|
|
637
|
+
if not is_current and e:
|
|
638
|
+
cmd += f" --as-of {e}"
|
|
639
|
+
why = f"후보 로드: {getattr(ident, 'name', '')}" + (f" (시행 {eff}, {status})" if eff else "")
|
|
640
|
+
next_cmds.append({"why": why, "cmd": cmd})
|
|
559
641
|
return {"flags": flags, "discipline": discipline, "next": next_cmds}
|
|
560
642
|
|
|
561
643
|
|
|
@@ -589,13 +671,19 @@ def _id_next(hits: list[Any], command: str) -> list[dict[str, str]]:
|
|
|
589
671
|
for h in hits[:2]:
|
|
590
672
|
val = getattr(h.identity, id_field, None)
|
|
591
673
|
if val:
|
|
592
|
-
|
|
674
|
+
cmd = f"moleg {command} --id {val}"
|
|
675
|
+
# 부처 해석 본문 로드는 --id만으론 안 되고 --source ministry --ministry <기관>이 필수다.
|
|
676
|
+
if command == "get-interpretation" and getattr(h.identity, "source_type", None) == "ministry":
|
|
677
|
+
ministry = getattr(h.identity, "ministry", None)
|
|
678
|
+
if ministry:
|
|
679
|
+
cmd += f" --source ministry --ministry {ministry}"
|
|
680
|
+
out.append({"why": f"본문 로드: {getattr(h.identity, 'title', getattr(h.identity, 'name', val))}", "cmd": cmd})
|
|
593
681
|
return out
|
|
594
682
|
|
|
595
683
|
|
|
596
684
|
def _searched_scope(command: str, args: argparse.Namespace) -> dict[str, Any]:
|
|
597
685
|
scope: dict[str, Any] = {}
|
|
598
|
-
for key in ("query", "concept", "basis", "ministry", "law_type", "court", "source", "annex_type", "rule_type", "as_of"):
|
|
686
|
+
for key in ("query", "concept", "basis", "ministry", "law_type", "court", "source", "search_scope", "annex_type", "rule_type", "search_body", "as_of"):
|
|
599
687
|
val = getattr(args, key, None)
|
|
600
688
|
if val:
|
|
601
689
|
scope[key] = val
|
|
@@ -682,7 +770,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
682
770
|
p = sub.add_parser("search-annex-forms", help="별표·서식 후보 검색.")
|
|
683
771
|
p.add_argument("query")
|
|
684
772
|
p.add_argument("--source", choices=["law", "administrative_rule"], default="law")
|
|
685
|
-
p.add_argument("--search-scope", dest="search_scope", choices=["title", "source", "body"], default="
|
|
773
|
+
p.add_argument("--search-scope", dest="search_scope", choices=["title", "source", "body"], default="title",
|
|
774
|
+
help="title=별표 제목 매칭(기본) / source=소관 법령명 매칭 / body=본문 전체. 제목으로 0건이면 source로 재시도.")
|
|
686
775
|
p.add_argument("--annex-type", dest="annex_type", default=None)
|
|
687
776
|
p.add_argument("--ministry", default=None); p.add_argument("--display", type=int, default=20)
|
|
688
777
|
|
|
@@ -690,7 +779,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
690
779
|
p.add_argument("query")
|
|
691
780
|
p.add_argument("--source", choices=["moleg", "ministry", "all", "all_ministries"], default="moleg")
|
|
692
781
|
p.add_argument("--ministry", default=None)
|
|
693
|
-
p.add_argument("--search-body", dest="search_body", action="store_true")
|
|
782
|
+
p.add_argument("--search-body", dest="search_body", action="store_true", help="제목이 아니라 본문(판시사항·주문·이유·해석 전문) 전체를 검색. doctrine·본문 키워드로 0건이면 이 옵션으로 재시도.")
|
|
694
783
|
p.add_argument("--interpreted-on", dest="interpreted_on", default=None)
|
|
695
784
|
p.add_argument("--display", type=int, default=20)
|
|
696
785
|
|
|
@@ -698,14 +787,14 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
698
787
|
p.add_argument("query")
|
|
699
788
|
p.add_argument("--court", choices=["all", "supreme", "lower"], default="all")
|
|
700
789
|
p.add_argument("--court-name", dest="court_name", default=None)
|
|
701
|
-
p.add_argument("--search-body", dest="search_body", action="store_true")
|
|
790
|
+
p.add_argument("--search-body", dest="search_body", action="store_true", help="제목이 아니라 본문(판시사항·주문·이유·해석 전문) 전체를 검색. doctrine·본문 키워드로 0건이면 이 옵션으로 재시도.")
|
|
702
791
|
p.add_argument("--decided-on", dest="decided_on", default=None)
|
|
703
792
|
p.add_argument("--case-number", dest="case_number", default=None)
|
|
704
793
|
p.add_argument("--display", type=int, default=20)
|
|
705
794
|
|
|
706
795
|
p = sub.add_parser("search-constitutional-decisions", help="헌재 결정 검색.")
|
|
707
796
|
p.add_argument("query")
|
|
708
|
-
p.add_argument("--search-body", dest="search_body", action="store_true")
|
|
797
|
+
p.add_argument("--search-body", dest="search_body", action="store_true", help="제목이 아니라 본문(판시사항·주문·이유·해석 전문) 전체를 검색. doctrine·본문 키워드로 0건이면 이 옵션으로 재시도.")
|
|
709
798
|
p.add_argument("--decided-on", dest="decided_on", default=None)
|
|
710
799
|
p.add_argument("--case-number", dest="case_number", default=None)
|
|
711
800
|
p.add_argument("--display", type=int, default=20)
|
|
@@ -739,7 +828,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
739
828
|
p.add_argument("--no-follow-moved", dest="no_follow_moved", action="store_true")
|
|
740
829
|
|
|
741
830
|
p = sub.add_parser("get-annex-form-body", help="별표·서식 본문 로드.")
|
|
742
|
-
p.add_argument("--id", dest="identifier", required=True, help="annex_id(검색이 준 값).")
|
|
831
|
+
p.add_argument("--id", "--annex-id", dest="identifier", required=True, help="annex_id(검색이 준 값). --annex-id로도 받음.")
|
|
743
832
|
p.add_argument("--source", choices=["law", "administrative_rule"], default="law")
|
|
744
833
|
p.add_argument("--title", default=None)
|
|
745
834
|
p.add_argument("--no-metadata", dest="no_metadata", action="store_true")
|
|
@@ -808,6 +897,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
808
897
|
|
|
809
898
|
def _call(api: MolegApi, args: argparse.Namespace) -> Any:
|
|
810
899
|
c = args.command
|
|
900
|
+
# Strictly validate --as-of once, for every command that carries it, so a
|
|
901
|
+
# malformed date is a usage error rather than a silent wrong-version load.
|
|
902
|
+
if getattr(args, "as_of", None):
|
|
903
|
+
args.as_of = parse_as_of(args.as_of)
|
|
811
904
|
if c == "search-laws":
|
|
812
905
|
return api.search_laws(args.query, as_of=args.as_of, basis=args.basis,
|
|
813
906
|
law_type=args.law_type, ministry=args.ministry, display=args.display)
|
|
@@ -922,7 +1015,9 @@ CATALOG = {
|
|
|
922
1015
|
"0건·호출 실패 ≠ 부재. 종료코드로 구분: 0 ok(0건 포함) · 2 모호 · 3 소스접근실패 · 4 no-result · 5 usage/순서위반.",
|
|
923
1016
|
"load 계열에 법령명을 주면 needs_search_first(exit 5) — 먼저 search-laws로 law_id를 얻어라.",
|
|
924
1017
|
"deferred는 data.deferred[i]를 load-followup --json -로 파이프(손타이핑 금지).",
|
|
925
|
-
"로더(get-law/get-article)는 기본이 현행 통합본, --as-of
|
|
1018
|
+
"로더(get-law/get-article)는 기본이 현행 통합본, --as-of <날짜>(YYYY-MM-DD/YYYYMMDD만)면 그 시점 시행 버전을 로드한다. 반환 effective_date를 오늘과 대조해 현행/미시행을 판정하라 — flags.not_effective_as_of=공포됐으나 미시행(미래 시행일, 현행 인용 금지), version_mismatch={requested,loaded}=요청 시점과 반환 버전 시행일 불일치, version_request_unfulfilled=요청보다 이후 버전이 반환됨. 요청 시점이 통합본 커버리지보다 이르면 kind:version_request_unfulfilled+earliest_available(→trace-law-history).",
|
|
1019
|
+
"검색 스코프 뉘앙스: 헌재·판례는 기본이 제목(사건명) 검색이라 doctrine·본문 키워드는 --search-body(본문 전체)라야 매칭(0건이면 next가 재시도 제시). 별표는 --search-scope title(별표 제목)/source(소관 법령명, 토큰 근접)이라 0건이면 반대 스코프로. 부처 해석은 --source all_ministries(법제처+부처 혼합)로만 나오고, 부처 본문 로드는 --source ministry --ministry <기관> 필수(검색 hit의 follow_up.filters를 그대로 로더 인자로).",
|
|
1020
|
+
"개정 전후 델타=compare-law-versions는 소스가 주는 최근 두 버전만 비교하고 changes[].article은 본문에서 파싱한 실제 조문번호(매핑 불가 시 null). 임의 두 시점 델타는 get-article --as-of를 전후 날짜로 두 번 로드해 대조.",
|
|
926
1021
|
],
|
|
927
1022
|
"routing_rules": [
|
|
928
1023
|
"본문 로드: 살아있는 조문=get-article / 이동·삭제 가능성 있는 조문=load-article-context(기본이 이동추적).",
|
|
@@ -930,6 +1025,7 @@ CATALOG = {
|
|
|
930
1025
|
"이 법 아래 무엇이 있나: 계층 조망=get-law-structure(위임 증명 아님) / 조문 단위 위임 규정=find-delegated-rules.",
|
|
931
1026
|
"넓은 탐색: 넓은 질의의 용어·관련법 조사계획=expand-legal-query / 유사 제도(비슷한 기제)를 가진 법 후보(설계용)=find-comparable-mechanisms.",
|
|
932
1027
|
"묶음 로더 — authority=특정 조문의 해석/판례/헌재 권위 / bundle=진입점 모를 때 단일법·넓은 질문(--mode) / institutional=명시된 다법령 집합(--statute 반복) / delegated=단일법의 하위규칙·별표 집행기준 본문.",
|
|
1028
|
+
"별표 금액·기준표: 위임된 시행령·시행규칙의 별표를 search-annex-forms --search-scope source <법령명> → get-annex-form-body --id(=--annex-id)로 로드. 표 파싱이 무너지면 structured_data.parsing_confidence=low — 금액은 text를 1차로 인용하라. bare id 로드는 소관법령ID·pdf_link 등 링크 메타를 복구하지 못하니 현행성은 모법 버전으로 확인.",
|
|
933
1029
|
],
|
|
934
1030
|
"commands": {
|
|
935
1031
|
"검색·계획(후보)": [
|
|
@@ -999,6 +1095,13 @@ def main(argv: list[str] | None = None, *, api: MolegApi | None = None) -> int:
|
|
|
999
1095
|
_emit({"ok": False, "command": args.command, "kind": "source_access_error", "error": str(exc),
|
|
1000
1096
|
"discipline": ["일시적 소스 접근 실패이지 법령 부재 아님 — 잠시 후 재시도. 시행일 등은 폴백 규율로 채우되 위헌·판례류는 '1차 확인 필요'로 남겨라."]})
|
|
1001
1097
|
return EXIT_SOURCE
|
|
1098
|
+
except AsOfBeforeCoverageError as exc:
|
|
1099
|
+
law_id = getattr(exc, "law_id", None) or getattr(args, "law", "")
|
|
1100
|
+
_emit({"ok": False, "command": args.command, "kind": "version_request_unfulfilled", "error": str(exc),
|
|
1101
|
+
"flags": {"as_of": getattr(args, "as_of", None), "earliest_available": getattr(exc, "earliest_available", None)},
|
|
1102
|
+
"discipline": ["요청 시점이 이 법령의 통합본 제공 최초 시행일보다 앞선다(일시적 실패 아님) — 그 이전 본문은 통합본으로 제공되지 않으니 연혁으로 확인하라."],
|
|
1103
|
+
"next": [{"why": "가용 최초 버전·개정 연혁 확인", "cmd": f"moleg trace-law-history --law {law_id}"}]})
|
|
1104
|
+
return EXIT_NO_RESULT
|
|
1002
1105
|
except ParseFailureError as exc:
|
|
1003
1106
|
_emit({"ok": False, "command": args.command, "kind": "parse_error", "error": str(exc),
|
|
1004
1107
|
"discipline": ["소스 응답을 정규화하지 못함(소스접근 실패·법령 부재 아님) — 다른 경로/커맨드로 확인하라."]})
|
|
@@ -47,3 +47,25 @@ class RetryExhaustedError(SourceApiError):
|
|
|
47
47
|
|
|
48
48
|
class ParseFailureError(MolegApiError):
|
|
49
49
|
"""A source response could not be normalized into the public model."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AsOfBeforeCoverageError(MolegApiError):
|
|
53
|
+
"""The requested as_of date predates law.go.kr's consolidated-version coverage.
|
|
54
|
+
|
|
55
|
+
A valid identifier and a well-formed date, but no consolidated version text
|
|
56
|
+
exists at (or before) that date — a permanent coverage-floor condition, not a
|
|
57
|
+
transient source failure. Carries the earliest available version date so the
|
|
58
|
+
caller can steer to amendment history instead of retrying.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
message: str,
|
|
64
|
+
*,
|
|
65
|
+
law_id: str | None = None,
|
|
66
|
+
earliest_available: str | None = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
super().__init__(message)
|
|
69
|
+
self.message = message
|
|
70
|
+
self.law_id = law_id
|
|
71
|
+
self.earliest_available = earliest_available
|
|
@@ -8,6 +8,7 @@ from typing import Any
|
|
|
8
8
|
|
|
9
9
|
from .errors import (
|
|
10
10
|
AmbiguousLawError,
|
|
11
|
+
AsOfBeforeCoverageError,
|
|
11
12
|
MolegApiError,
|
|
12
13
|
NoResultError,
|
|
13
14
|
ParseFailureError,
|
|
@@ -644,6 +645,11 @@ class MolegApi:
|
|
|
644
645
|
query = require_query(query)
|
|
645
646
|
target = target_for(basis, "list")
|
|
646
647
|
params: dict[str, Any] = {"query": query, "display": display}
|
|
648
|
+
if basis == "promulgated":
|
|
649
|
+
# The `law` search endpoint hides 시행예정(future-effective) rows by
|
|
650
|
+
# default; a promulgated-basis search (and the promulgation bridge)
|
|
651
|
+
# must see just-promulgated future amendments, so include them.
|
|
652
|
+
params["nw"] = 1
|
|
647
653
|
if as_of:
|
|
648
654
|
params["efYd" if basis == "effective" else "date"] = compact_date(as_of)
|
|
649
655
|
if law_type:
|
|
@@ -747,6 +753,26 @@ class MolegApi:
|
|
|
747
753
|
# failed, so current-law loads keep their single source call.
|
|
748
754
|
mst = identity_hint.mst or (self._resolve_version_mst(identity_hint, as_of) if as_of else None)
|
|
749
755
|
if not mst:
|
|
756
|
+
# No version in force at as_of. If the law has version rows but
|
|
757
|
+
# all postdate the request, the requested date is before
|
|
758
|
+
# law.go.kr's consolidated coverage for this law — a permanent
|
|
759
|
+
# coverage-floor condition, not a transient parse failure.
|
|
760
|
+
if as_of:
|
|
761
|
+
effs = sorted(
|
|
762
|
+
e
|
|
763
|
+
for e in (
|
|
764
|
+
compact_date(str(row.get("시행일자") or ""))
|
|
765
|
+
for row in self._law_version_rows(identity_hint)
|
|
766
|
+
)
|
|
767
|
+
if e and len(e) == 8
|
|
768
|
+
)
|
|
769
|
+
if effs and compact_date(as_of) < effs[0]:
|
|
770
|
+
raise AsOfBeforeCoverageError(
|
|
771
|
+
f"요청 시점({compact_date(as_of)})은 law.go.kr이 이 법령의 통합본으로 제공하는 "
|
|
772
|
+
f"가장 이른 시행일({effs[0]})보다 앞선다 — 그 이전 시점 본문은 통합본으로 제공되지 않는다.",
|
|
773
|
+
law_id=identity_hint.law_id,
|
|
774
|
+
earliest_available=effs[0],
|
|
775
|
+
)
|
|
750
776
|
raise
|
|
751
777
|
raw_law = self._load_versioned_law_raw(identity_hint, mst)
|
|
752
778
|
identity = normalize_law_identity(raw_law, basis=basis)
|
|
@@ -1311,7 +1337,7 @@ class MolegApi:
|
|
|
1311
1337
|
query: str,
|
|
1312
1338
|
*,
|
|
1313
1339
|
source: AnnexFormSource = "law",
|
|
1314
|
-
search_scope: AnnexSearchScope = "
|
|
1340
|
+
search_scope: AnnexSearchScope = "title",
|
|
1315
1341
|
annex_type: AnnexType | None = None,
|
|
1316
1342
|
ministry: str | None = None,
|
|
1317
1343
|
display: int = 20,
|
|
@@ -3164,6 +3190,7 @@ class MolegApi:
|
|
|
3164
3190
|
|
|
3165
3191
|
return replace(
|
|
3166
3192
|
bundle,
|
|
3193
|
+
request=replace(bundle.request, query=explicit_query, mode="delegated_criteria"),
|
|
3167
3194
|
loaded=replace(
|
|
3168
3195
|
bundle.loaded,
|
|
3169
3196
|
administrative_rules=loaded_administrative_rules,
|
|
@@ -4134,6 +4161,12 @@ def structured_table_from_rows(
|
|
|
4134
4161
|
body_rows = table_rows[1:]
|
|
4135
4162
|
if not body_rows or any(len(row) != len(headers) for row in body_rows):
|
|
4136
4163
|
return None
|
|
4164
|
+
# A cleanly delimited table has its separators stripped. Residual box-drawing
|
|
4165
|
+
# glyphs (┃│┏…) inside cells mean the splitter matched the wrong delimiter and
|
|
4166
|
+
# produced a junk table (the real amount rows get dropped). Never report that
|
|
4167
|
+
# as high confidence — fall back to plain text so amounts are read from .text.
|
|
4168
|
+
if any(ch in _ANNEX_BOX_CHARS for row in table_rows for cell in row for ch in cell):
|
|
4169
|
+
return low_confidence_table(identity)
|
|
4137
4170
|
keys = normalized_table_keys(headers)
|
|
4138
4171
|
rows = [dict(zip(keys, row, strict=True)) for row in body_rows]
|
|
4139
4172
|
return StructuredTableData(
|
|
@@ -5264,8 +5297,9 @@ def interpretation_sources_for(source: str, ministry: str | None) -> list[Interp
|
|
|
5264
5297
|
return [ministry_interpretation_source(ministry)]
|
|
5265
5298
|
if source == "all":
|
|
5266
5299
|
if not ministry:
|
|
5267
|
-
raise
|
|
5268
|
-
"
|
|
5300
|
+
raise UnsupportedFormatError(
|
|
5301
|
+
"source='all'에는 --ministry가 필요하다 — 법제처 해석만이면 source='moleg', "
|
|
5302
|
+
"부처 해석까지 한 번에 보려면 source='all_ministries'."
|
|
5269
5303
|
)
|
|
5270
5304
|
specs = [OFFICIAL_INTERPRETATION_SOURCE]
|
|
5271
5305
|
specs.append(ministry_interpretation_source(ministry))
|
|
@@ -5277,7 +5311,10 @@ def interpretation_sources_for(source: str, ministry: str | None) -> list[Interp
|
|
|
5277
5311
|
|
|
5278
5312
|
def ministry_interpretation_source(ministry: str | None) -> InterpretationSourceSpec:
|
|
5279
5313
|
if not ministry:
|
|
5280
|
-
raise
|
|
5314
|
+
raise UnsupportedFormatError(
|
|
5315
|
+
"부처 해석 검색·로드에는 --source ministry와 함께 --ministry <기관명>이 필요하다 "
|
|
5316
|
+
"(부처 해석을 법제처 해석과 한 번에 보려면 --source all_ministries)."
|
|
5317
|
+
)
|
|
5281
5318
|
if ministry in MINISTRY_INTERPRETATION_SOURCES:
|
|
5282
5319
|
return MINISTRY_INTERPRETATION_SOURCES[ministry]
|
|
5283
5320
|
for spec in MINISTRY_INTERPRETATION_SOURCES.values():
|
|
@@ -25,7 +25,7 @@ AnnexType = Literal[
|
|
|
25
25
|
InterpretationSearchSource = Literal["moleg", "ministry", "all", "all_ministries"]
|
|
26
26
|
CaseCourt = Literal["all", "supreme", "lower"]
|
|
27
27
|
BundleMode = Literal["question", "promulgated_bill", "statute_review"]
|
|
28
|
-
BundleRequestMode = Literal["question", "promulgated_bill", "statute_review", "institutional_system"]
|
|
28
|
+
BundleRequestMode = Literal["question", "promulgated_bill", "statute_review", "institutional_system", "delegated_criteria"]
|
|
29
29
|
BundleBudget = Literal["minimal", "standard", "broad"]
|
|
30
30
|
|
|
31
31
|
|
|
@@ -237,6 +237,7 @@ class HistoryEvent:
|
|
|
237
237
|
revision_type: str | None = None
|
|
238
238
|
article: str | None = None
|
|
239
239
|
article_text: str | None = None
|
|
240
|
+
article_link: str | None = None
|
|
240
241
|
reason: str | None = None
|
|
241
242
|
raw: dict[str, Any] = field(default_factory=dict)
|
|
242
243
|
|
|
@@ -255,7 +256,7 @@ class LawHistory:
|
|
|
255
256
|
class LawDiffChange:
|
|
256
257
|
"""One before/after article comparison row."""
|
|
257
258
|
|
|
258
|
-
article: str
|
|
259
|
+
article: str | None
|
|
259
260
|
before_text: str
|
|
260
261
|
after_text: str
|
|
261
262
|
title: str | None = None
|