moleg-api 0.2.0__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.
Files changed (28) hide show
  1. {moleg_api-0.2.0 → moleg_api-0.2.2}/PKG-INFO +1 -1
  2. {moleg_api-0.2.0 → moleg_api-0.2.2}/moleg_api/__init__.py +2 -0
  3. {moleg_api-0.2.0 → moleg_api-0.2.2}/moleg_api/cli.py +156 -29
  4. {moleg_api-0.2.0 → moleg_api-0.2.2}/moleg_api/errors.py +22 -0
  5. {moleg_api-0.2.0 → moleg_api-0.2.2}/moleg_api/laws.py +247 -8
  6. {moleg_api-0.2.0 → moleg_api-0.2.2}/moleg_api/models.py +3 -2
  7. {moleg_api-0.2.0 → moleg_api-0.2.2}/moleg_api/normalization.py +203 -45
  8. {moleg_api-0.2.0 → moleg_api-0.2.2}/moleg_api.egg-info/PKG-INFO +1 -1
  9. {moleg_api-0.2.0 → moleg_api-0.2.2}/moleg_api.egg-info/SOURCES.txt +2 -0
  10. {moleg_api-0.2.0 → moleg_api-0.2.2}/pyproject.toml +1 -1
  11. {moleg_api-0.2.0 → moleg_api-0.2.2}/tests/test_cli.py +2 -1
  12. {moleg_api-0.2.0 → moleg_api-0.2.2}/tests/test_laws.py +15 -6
  13. {moleg_api-0.2.0 → moleg_api-0.2.2}/tests/test_live_smoke.py +112 -0
  14. {moleg_api-0.2.0 → moleg_api-0.2.2}/tests/test_models.py +1 -0
  15. moleg_api-0.2.2/tests/test_sdk_fixes_0_2_1.py +375 -0
  16. moleg_api-0.2.2/tests/test_sdk_fixes_0_2_2.py +344 -0
  17. {moleg_api-0.2.0 → moleg_api-0.2.2}/LICENSE +0 -0
  18. {moleg_api-0.2.0 → moleg_api-0.2.2}/README.md +0 -0
  19. {moleg_api-0.2.0 → moleg_api-0.2.2}/moleg_api/__main__.py +0 -0
  20. {moleg_api-0.2.0 → moleg_api-0.2.2}/moleg_api/py.typed +0 -0
  21. {moleg_api-0.2.0 → moleg_api-0.2.2}/moleg_api/source.py +0 -0
  22. {moleg_api-0.2.0 → moleg_api-0.2.2}/moleg_api.egg-info/dependency_links.txt +0 -0
  23. {moleg_api-0.2.0 → moleg_api-0.2.2}/moleg_api.egg-info/entry_points.txt +0 -0
  24. {moleg_api-0.2.0 → moleg_api-0.2.2}/moleg_api.egg-info/requires.txt +0 -0
  25. {moleg_api-0.2.0 → moleg_api-0.2.2}/moleg_api.egg-info/top_level.txt +0 -0
  26. {moleg_api-0.2.0 → moleg_api-0.2.2}/setup.cfg +0 -0
  27. {moleg_api-0.2.0 → moleg_api-0.2.2}/tests/test_source.py +0 -0
  28. {moleg_api-0.2.0 → moleg_api-0.2.2}/tests/test_versions.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: moleg-api
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Task-level Python interface for loading Korean legal sources from law.go.kr
5
5
  Author: MOLEG-API maintainers
6
6
  License-Expression: MIT
@@ -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,10 +28,13 @@ from __future__ import annotations
28
28
  import argparse
29
29
  import json
30
30
  import sys
31
+ from datetime import date, datetime
32
+ from importlib.metadata import PackageNotFoundError, version as _dist_version
31
33
  from typing import Any
32
34
 
33
35
  from .errors import (
34
36
  AmbiguousLawError,
37
+ AsOfBeforeCoverageError,
35
38
  MolegApiError,
36
39
  NoResultError,
37
40
  ParseFailureError,
@@ -42,6 +45,14 @@ from .errors import (
42
45
  from .laws import MolegApi
43
46
  from .models import DeferredLookup
44
47
 
48
+
49
+ def _pkg_version() -> str:
50
+ try:
51
+ return _dist_version("moleg-api")
52
+ except PackageNotFoundError:
53
+ return "unknown"
54
+
55
+
45
56
  # Exit codes distinguish outcomes a shell/agent must branch on.
46
57
  EXIT_OK = 0 # includes a zero-hit search (ok:true, count:0)
47
58
  EXIT_AMBIGUOUS = 2 # multiple plausible identities — surface, don't pick
@@ -278,6 +289,34 @@ def _deferred_next(deferred: list[Any], *, cap: int = 3) -> tuple[list[dict[str,
278
289
  return items, overflow
279
290
 
280
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
+
281
320
  def _law_time_flags(identity: Any, as_of: str | None) -> tuple[dict[str, Any], list[str]]:
282
321
  flags: dict[str, Any] = {}
283
322
  lines: list[str] = []
@@ -287,18 +326,28 @@ def _law_time_flags(identity: Any, as_of: str | None) -> tuple[dict[str, Any], l
287
326
  eff = getattr(identity, "effective_date", None)
288
327
  if eff:
289
328
  flags["effective_date"] = eff
290
- # --as-of loads the version in force at that date (the loader resolves the
291
- # historical version). Verify the returned effective_date; a returned version
292
- # newer than the requested date means no version was in force then.
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를 확인하라 — 요청 시점과 다르면 인접 버전이 반환된 것이다.
293
340
  if as_of:
294
341
  flags["as_of"] = as_of
295
- a = "".join(ch for ch in as_of if ch.isdigit())
296
- e = "".join(ch for ch in str(eff) if ch.isdigit()) if eff else ""
297
- if a and e and e > a:
298
- flags["version_request_unfulfilled"] = True
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
299
348
  lines.append(
300
- "요청 시점에 시행 중이던 버전을 찾지 못해 이후 버전이 반환됨 시점 이전엔 시행 이력이 없을 수 있다. "
301
- "조회 기준일을 답에 명기하라."
349
+ "요청한 시점(as_of)과 반환된 버전의 시행일이 다르다반환 effective_date 기준으로 해석하라. "
350
+ " 시점의 정확한 시행 버전 이력은 trace-law-history로 확인."
302
351
  )
303
352
  return flags, lines
304
353
 
@@ -347,6 +396,17 @@ def signals_for(command: str, result: Any, args: argparse.Namespace) -> dict[str
347
396
  if count == 0:
348
397
  flags["searched"] = _searched_scope(command, args)
349
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건이면 반대 스코프로 재시도하라.")
350
410
  # mechanical alternate: drop a narrowing filter if one was set.
351
411
  broaden = _broaden_next(command, args)
352
412
  if broaden:
@@ -379,6 +439,8 @@ def signals_for(command: str, result: Any, args: argparse.Namespace) -> dict[str
379
439
  if tname == "LawText":
380
440
  tf, tl = _law_time_flags(result.identity, as_of)
381
441
  flags.update(tf); discipline.extend(tl)
442
+ if flags.get("not_effective_as_of"):
443
+ source = "법제처 / 공포본(장래 시행 — 아직 미시행)"
382
444
  if _has_supplementary(result):
383
445
  flags["has_supplementary"] = True
384
446
  discipline.append("시행일·적용례·경과조치는 supplementary_provisions에 별도 — 조문 본문·법령 effective_date만으로 전환 범위 답 금지.")
@@ -392,6 +454,8 @@ def signals_for(command: str, result: Any, args: argparse.Namespace) -> dict[str
392
454
  flags.update(af); discipline.extend(al)
393
455
  tf, tl = _law_time_flags(result.identity, as_of)
394
456
  flags.update(tf); discipline.extend(tl)
457
+ if flags.get("not_effective_as_of"):
458
+ source = "법제처 / 공포본 조문(장래 시행 — 아직 미시행)"
395
459
  discipline.append("정의·예외·적용대상·요건은 text의 항·호·목 중첩에 있다 — 조문제목·상위 조문내용만으로 요약 금지.")
396
460
  moved = _real_moved_to(result)
397
461
  if moved:
@@ -451,6 +515,11 @@ def signals_for(command: str, result: Any, args: argparse.Namespace) -> dict[str
451
515
  if command == "get-constitutional-decision" or (st and "detc" in str(st)):
452
516
  kind, source = "constitutional_text", "법제처 / 헌재결정 본문"
453
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) 판단으로 오인하지 마라.")
454
523
  else:
455
524
  kind, source = "case_text", "법제처 / 판례 본문"
456
525
 
@@ -464,9 +533,19 @@ def signals_for(command: str, result: Any, args: argparse.Namespace) -> dict[str
464
533
  discipline.append("선택 조문의 전후 문구 델타만 — 개정이유·입법의도·전체 변경 조문 망라 아님. trace-law-history·국회 법안자료로 보강.")
465
534
 
466
535
  elif tname == "DelegationGraph":
467
- if not getattr(result, "rules", None):
468
- flags["count"] = 0
536
+ rules = getattr(result, "rules", None) or []
537
+ flags["count"] = len(rules)
538
+ if not rules:
469
539
  discipline.append("이 조회로 위임규정 못 찾음 ≠ 위임 없음 — 법령 체계도·다른 조문 범위·행정규칙·별표 경로를 더 확인.")
540
+ else:
541
+ discipline.append("위임 목록은 하위법령·인용법령 위임만 — 별표(과태료·범칙금액·기준표 등) 자체는 여기 없다. 금액·기준표는 위임된 시행령·시행규칙의 별표를 search-annex-forms(또는 load-delegated-criteria)로 확인.")
542
+ ident = getattr(result, "identity", None)
543
+ name = (getattr(ident, "name", None) or getattr(ident, "law_id", None) or "") if ident else ""
544
+ if name:
545
+ next_cmds.append({
546
+ "why": "위임된 별표·금액·기준표 확인",
547
+ "cmd": f"moleg search-annex-forms {json.dumps(name, ensure_ascii=False)} --source law",
548
+ })
470
549
 
471
550
  elif tname == "LawStructure":
472
551
  discipline.append("체계도는 계층 컨텍스트일 뿐 — 시행령·규칙·행정규칙이 이 법 아래 있음은 보이나, 조문 단위 위임·하위규칙 본문·운영기준 증명 아님. find-delegated-rules로 확인.")
@@ -521,22 +600,44 @@ def _law_list_signals(hits: list[Any]) -> dict[str, Any]:
521
600
  flags: dict[str, Any] = {}
522
601
  discipline: list[str] = []
523
602
  next_cmds: list[dict[str, str]] = []
603
+ today = _today_compact()
524
604
  by_name: dict[str, list[Any]] = {}
525
605
  for h in hits:
526
606
  by_name.setdefault(getattr(h.identity, "name", ""), []).append(h)
527
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
528
619
  if ambiguous:
529
620
  flags["ambiguous_versions"] = True
530
- discipline.append("동명 후보가 시행일 다름 — 현행본은 그냥 get-law --law, 특정 시점 버전은 --as-of <시행일>로 로드.")
531
- for i, h in enumerate(hits[:3]):
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]:
532
630
  ident = h.identity
533
631
  law_id = getattr(ident, "law_id", "") or ""
534
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 "과거")
535
636
  cmd = f"moleg get-law --law {law_id}"
536
- # first (latest) candidate is current; older candidates load by --as-of
537
- if ambiguous and i > 0 and eff:
538
- cmd += f" --as-of {eff}"
539
- next_cmds.append({"why": f"후보 로드: {getattr(ident, 'name', '')}" + (f" (시행 {eff})" if eff else ""), "cmd": cmd})
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})
540
641
  return {"flags": flags, "discipline": discipline, "next": next_cmds}
541
642
 
542
643
 
@@ -570,13 +671,19 @@ def _id_next(hits: list[Any], command: str) -> list[dict[str, str]]:
570
671
  for h in hits[:2]:
571
672
  val = getattr(h.identity, id_field, None)
572
673
  if val:
573
- out.append({"why": f"본문 로드: {getattr(h.identity, 'title', getattr(h.identity, 'name', val))}", "cmd": f"moleg {command} --id {val}"})
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})
574
681
  return out
575
682
 
576
683
 
577
684
  def _searched_scope(command: str, args: argparse.Namespace) -> dict[str, Any]:
578
685
  scope: dict[str, Any] = {}
579
- 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"):
580
687
  val = getattr(args, key, None)
581
688
  if val:
582
689
  scope[key] = val
@@ -632,6 +739,12 @@ def build_parser() -> argparse.ArgumentParser:
632
739
  description="법제처 law.go.kr 법령 자료 조회 CLI. 항상 JSON 엔벨로프 1개를 stdout으로 출력.",
633
740
  )
634
741
  parser.add_argument("--raw", action="store_true", help="원본 소스 payload(raw)까지 직렬화(디버그).")
742
+ parser.add_argument(
743
+ "--version",
744
+ action="version",
745
+ version=f"moleg-api {_pkg_version()}",
746
+ help="설치된 moleg-api 버전을 출력하고 종료.",
747
+ )
635
748
  sub = parser.add_subparsers(dest="command", metavar="<command>")
636
749
 
637
750
  sub.add_parser("catalog", help="27개 서브커맨드·규약·kind 목록을 한 번에.")
@@ -657,7 +770,8 @@ def build_parser() -> argparse.ArgumentParser:
657
770
  p = sub.add_parser("search-annex-forms", help="별표·서식 후보 검색.")
658
771
  p.add_argument("query")
659
772
  p.add_argument("--source", choices=["law", "administrative_rule"], default="law")
660
- p.add_argument("--search-scope", dest="search_scope", choices=["title", "source", "body"], default="source")
773
+ p.add_argument("--search-scope", dest="search_scope", choices=["title", "source", "body"], default="title",
774
+ help="title=별표 제목 매칭(기본) / source=소관 법령명 매칭 / body=본문 전체. 제목으로 0건이면 source로 재시도.")
661
775
  p.add_argument("--annex-type", dest="annex_type", default=None)
662
776
  p.add_argument("--ministry", default=None); p.add_argument("--display", type=int, default=20)
663
777
 
@@ -665,7 +779,7 @@ def build_parser() -> argparse.ArgumentParser:
665
779
  p.add_argument("query")
666
780
  p.add_argument("--source", choices=["moleg", "ministry", "all", "all_ministries"], default="moleg")
667
781
  p.add_argument("--ministry", default=None)
668
- 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건이면 이 옵션으로 재시도.")
669
783
  p.add_argument("--interpreted-on", dest="interpreted_on", default=None)
670
784
  p.add_argument("--display", type=int, default=20)
671
785
 
@@ -673,14 +787,14 @@ def build_parser() -> argparse.ArgumentParser:
673
787
  p.add_argument("query")
674
788
  p.add_argument("--court", choices=["all", "supreme", "lower"], default="all")
675
789
  p.add_argument("--court-name", dest="court_name", default=None)
676
- 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건이면 이 옵션으로 재시도.")
677
791
  p.add_argument("--decided-on", dest="decided_on", default=None)
678
792
  p.add_argument("--case-number", dest="case_number", default=None)
679
793
  p.add_argument("--display", type=int, default=20)
680
794
 
681
795
  p = sub.add_parser("search-constitutional-decisions", help="헌재 결정 검색.")
682
796
  p.add_argument("query")
683
- 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건이면 이 옵션으로 재시도.")
684
798
  p.add_argument("--decided-on", dest="decided_on", default=None)
685
799
  p.add_argument("--case-number", dest="case_number", default=None)
686
800
  p.add_argument("--display", type=int, default=20)
@@ -714,7 +828,7 @@ def build_parser() -> argparse.ArgumentParser:
714
828
  p.add_argument("--no-follow-moved", dest="no_follow_moved", action="store_true")
715
829
 
716
830
  p = sub.add_parser("get-annex-form-body", help="별표·서식 본문 로드.")
717
- 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로도 받음.")
718
832
  p.add_argument("--source", choices=["law", "administrative_rule"], default="law")
719
833
  p.add_argument("--title", default=None)
720
834
  p.add_argument("--no-metadata", dest="no_metadata", action="store_true")
@@ -740,9 +854,8 @@ def build_parser() -> argparse.ArgumentParser:
740
854
  p.add_argument("--date-from", dest="date_from", default=None)
741
855
  p.add_argument("--date-to", dest="date_to", default=None)
742
856
 
743
- p = sub.add_parser("compare-law-versions", help="개정 전후 조문 문구 비교.")
744
- _add_law(p); p.add_argument("--before", default=None); p.add_argument("--after", default=None)
745
- p.add_argument("--article", default=None)
857
+ p = sub.add_parser("compare-law-versions", help="개정 전후 조문 문구 비교(소스 제공 전후 쌍).")
858
+ _add_law(p); p.add_argument("--article", default=None)
746
859
 
747
860
  p = sub.add_parser("find-delegated-rules", help="위임 규정·하위 법령.")
748
861
  _add_law(p); p.add_argument("--article", default=None)
@@ -784,6 +897,10 @@ def build_parser() -> argparse.ArgumentParser:
784
897
 
785
898
  def _call(api: MolegApi, args: argparse.Namespace) -> Any:
786
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)
787
904
  if c == "search-laws":
788
905
  return api.search_laws(args.query, as_of=args.as_of, basis=args.basis,
789
906
  law_type=args.law_type, ministry=args.ministry, display=args.display)
@@ -834,7 +951,7 @@ def _call(api: MolegApi, args: argparse.Namespace) -> Any:
834
951
  date_range = (args.date_from, args.date_to) if (args.date_from and args.date_to) else None
835
952
  return api.trace_law_history(args.law, date_range=date_range, article=args.article)
836
953
  if c == "compare-law-versions":
837
- return api.compare_law_versions(args.law, before=args.before, after=args.after, article=args.article)
954
+ return api.compare_law_versions(args.law, article=args.article)
838
955
  if c == "find-delegated-rules":
839
956
  return api.find_delegated_rules(args.law, article=args.article)
840
957
  if c == "get-law-structure":
@@ -898,7 +1015,9 @@ CATALOG = {
898
1015
  "0건·호출 실패 ≠ 부재. 종료코드로 구분: 0 ok(0건 포함) · 2 모호 · 3 소스접근실패 · 4 no-result · 5 usage/순서위반.",
899
1016
  "load 계열에 법령명을 주면 needs_search_first(exit 5) — 먼저 search-laws로 law_id를 얻어라.",
900
1017
  "deferred는 data.deferred[i]를 load-followup --json -로 파이프(손타이핑 금지).",
901
- "로더(get-law/get-article)는 기본이 현행 통합본, --as-of <날짜>면시점에 시행 중이던 역사 버전 본문을 로드한다(버전 자동 해결). 반환 effective_date를 확인하라 flags.version_request_unfulfilled 뜨면 시점 이전엔 시행 이력이 없다는 뜻. 개정 전후 델타는 compare-law-versions로.",
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를 전후 날짜로 두 번 로드해 대조.",
902
1021
  ],
903
1022
  "routing_rules": [
904
1023
  "본문 로드: 살아있는 조문=get-article / 이동·삭제 가능성 있는 조문=load-article-context(기본이 이동추적).",
@@ -906,6 +1025,7 @@ CATALOG = {
906
1025
  "이 법 아래 무엇이 있나: 계층 조망=get-law-structure(위임 증명 아님) / 조문 단위 위임 규정=find-delegated-rules.",
907
1026
  "넓은 탐색: 넓은 질의의 용어·관련법 조사계획=expand-legal-query / 유사 제도(비슷한 기제)를 가진 법 후보(설계용)=find-comparable-mechanisms.",
908
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 등 링크 메타를 복구하지 못하니 현행성은 모법 버전으로 확인.",
909
1029
  ],
910
1030
  "commands": {
911
1031
  "검색·계획(후보)": [
@@ -975,6 +1095,13 @@ def main(argv: list[str] | None = None, *, api: MolegApi | None = None) -> int:
975
1095
  _emit({"ok": False, "command": args.command, "kind": "source_access_error", "error": str(exc),
976
1096
  "discipline": ["일시적 소스 접근 실패이지 법령 부재 아님 — 잠시 후 재시도. 시행일 등은 폴백 규율로 채우되 위헌·판례류는 '1차 확인 필요'로 남겨라."]})
977
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
978
1105
  except ParseFailureError as exc:
979
1106
  _emit({"ok": False, "command": args.command, "kind": "parse_error", "error": str(exc),
980
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