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.
Files changed (28) hide show
  1. {moleg_api-0.2.1 → moleg_api-0.2.2}/PKG-INFO +1 -1
  2. {moleg_api-0.2.1 → moleg_api-0.2.2}/moleg_api/__init__.py +2 -0
  3. {moleg_api-0.2.1 → moleg_api-0.2.2}/moleg_api/cli.py +126 -23
  4. {moleg_api-0.2.1 → moleg_api-0.2.2}/moleg_api/errors.py +22 -0
  5. {moleg_api-0.2.1 → moleg_api-0.2.2}/moleg_api/laws.py +41 -4
  6. {moleg_api-0.2.1 → moleg_api-0.2.2}/moleg_api/models.py +3 -2
  7. {moleg_api-0.2.1 → moleg_api-0.2.2}/moleg_api/normalization.py +153 -22
  8. {moleg_api-0.2.1 → moleg_api-0.2.2}/moleg_api.egg-info/PKG-INFO +1 -1
  9. {moleg_api-0.2.1 → moleg_api-0.2.2}/moleg_api.egg-info/SOURCES.txt +1 -0
  10. {moleg_api-0.2.1 → moleg_api-0.2.2}/pyproject.toml +1 -1
  11. {moleg_api-0.2.1 → moleg_api-0.2.2}/tests/test_cli.py +2 -1
  12. {moleg_api-0.2.1 → moleg_api-0.2.2}/tests/test_laws.py +7 -5
  13. {moleg_api-0.2.1 → moleg_api-0.2.2}/tests/test_live_smoke.py +47 -0
  14. {moleg_api-0.2.1 → moleg_api-0.2.2}/tests/test_models.py +1 -0
  15. moleg_api-0.2.2/tests/test_sdk_fixes_0_2_2.py +344 -0
  16. {moleg_api-0.2.1 → moleg_api-0.2.2}/LICENSE +0 -0
  17. {moleg_api-0.2.1 → moleg_api-0.2.2}/README.md +0 -0
  18. {moleg_api-0.2.1 → moleg_api-0.2.2}/moleg_api/__main__.py +0 -0
  19. {moleg_api-0.2.1 → moleg_api-0.2.2}/moleg_api/py.typed +0 -0
  20. {moleg_api-0.2.1 → moleg_api-0.2.2}/moleg_api/source.py +0 -0
  21. {moleg_api-0.2.1 → moleg_api-0.2.2}/moleg_api.egg-info/dependency_links.txt +0 -0
  22. {moleg_api-0.2.1 → moleg_api-0.2.2}/moleg_api.egg-info/entry_points.txt +0 -0
  23. {moleg_api-0.2.1 → moleg_api-0.2.2}/moleg_api.egg-info/requires.txt +0 -0
  24. {moleg_api-0.2.1 → moleg_api-0.2.2}/moleg_api.egg-info/top_level.txt +0 -0
  25. {moleg_api-0.2.1 → moleg_api-0.2.2}/setup.cfg +0 -0
  26. {moleg_api-0.2.1 → moleg_api-0.2.2}/tests/test_sdk_fixes_0_2_1.py +0 -0
  27. {moleg_api-0.2.1 → moleg_api-0.2.2}/tests/test_source.py +0 -0
  28. {moleg_api-0.2.1 → 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.1
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,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
- # --as-of loads the version in force at that date (the loader resolves the
300
- # historical version). Verify the returned effective_date; a returned version
301
- # 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를 확인하라 — 요청 시점과 다르면 인접 버전이 반환된 것이다.
302
340
  if as_of:
303
341
  flags["as_of"] = as_of
304
- a = "".join(ch for ch in as_of if ch.isdigit())
305
- e = "".join(ch for ch in str(eff) if ch.isdigit()) if eff else ""
306
- if a and e and e > a:
307
- 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
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("동명 후보가 시행일 다름 — 현행본은 그냥 get-law --law, 특정 시점 버전은 --as-of <시행일>로 로드.")
550
- 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]:
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
- # first (latest) candidate is current; older candidates load by --as-of
556
- if ambiguous and i > 0 and eff:
557
- cmd += f" --as-of {eff}"
558
- 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})
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
- 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})
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="source")
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 <날짜>면시점에 시행 중이던 역사 버전 본문을 로드한다(버전 자동 해결). 반환 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를 전후 날짜로 두 번 로드해 대조.",
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 = "source",
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 NoResultError(
5268
- "ministry is required for source='all'; use source='moleg' or source='all_ministries'"
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 NoResultError("ministry is required for ministry interpretation search")
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