Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ python -m moleg_api get-article --law 001248 제3조 # load the current artic
python -m moleg_api get-article --law 001248 --as-of 2021-01-01 제3조 # the version in force then
```

The CLI exposes 27 task subcommands plus `catalog`; `catalog` is the source of truth for the command list, routing rules, and result `kind` values.

Public dataclasses serialize recursively with `to_dict()` / `to_json_string()` (raw payloads omitted unless `include_raw=True`).

## What it covers
Expand All @@ -58,6 +60,7 @@ Full documentation is in the [`wiki/`](wiki/Home.md) folder:
- [CLI Reference](wiki/CLI-Reference.md) · [API Reference](wiki/API-Reference.md)
- [Historical Versions](wiki/Historical-Versions.md) · [Sources & Coverage](wiki/Sources-and-Coverage.md)
- [Gotchas](wiki/Gotchas.md) · [Error Handling](wiki/Error-Handling.md)
- [Maintainer Notes](wiki/Maintainer-Notes.md)

## Status

Expand Down
14 changes: 14 additions & 0 deletions moleg_api/_cli/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from __future__ import annotations

from .foundation import *
from .constants import *
from .data import *
from .signals_meta import *
from .signal_helpers import *
from .signals import *
from .parser import *
from .dispatch import *
from .catalog import *
from .main import *

__all__ = [name for name in globals() if not name.startswith("__")]
51 changes: 51 additions & 0 deletions moleg_api/_cli/catalog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
from __future__ import annotations

from .foundation import *

CATALOG = {
"convention": [
"kind 접미사가 후보≠본문을 구조로 표시: *_hit/*_candidate/*_planning = 검색 후보(인용 불가), *_text/*_context/*_identity = 로드된 본문.",
"인용은 항상 로드된 본문에서만 — search/expand/find-comparable 결과는 '다음에 무엇을 로드할지의 메뉴'일 뿐.",
"출처 권위 보존: 법제처 법령 > 법제처 해석 > 부처 해석 ≠ 판례 ≠ 헌재. flags.source_type/라벨을 답에 반영.",
"0건·호출 실패 ≠ 부재. 종료코드로 구분: 0 ok(0건 포함) · 2 모호 · 3 소스접근실패 · 4 no-result · 5 usage/순서위반.",
"load 계열에 법령명을 주면 needs_search_first(exit 5) — 먼저 search-laws로 law_id를 얻어라.",
"deferred는 data.deferred[i]를 load-followup --json -로 파이프(손타이핑 금지).",
"로더(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).",
"검색 스코프 뉘앙스: 헌재·판례는 기본이 제목(사건명) 검색이라 doctrine·본문 키워드는 --search-body(본문 전체)라야 매칭(0건이면 next가 재시도 제시). 별표는 --search-scope title(별표 제목)/source(소관 법령명, 토큰 근접)이라 0건이면 반대 스코프로. 부처 해석은 --source all_ministries(법제처+부처 혼합)로만 나오고, 부처 본문 로드는 --source ministry --ministry <기관> 필수(검색 hit의 follow_up.filters를 그대로 로더 인자로).",
"개정 전후 델타=compare-law-versions는 소스가 주는 최근 두 버전만 비교하고 changes[].article은 본문에서 파싱한 실제 조문번호(매핑 불가 시 null). 임의 두 시점 델타는 get-article --as-of를 전후 날짜로 두 번 로드해 대조.",
],
"routing_rules": [
"본문 로드: 살아있는 조문=get-article / 이동·삭제 가능성 있는 조문=load-article-context(기본이 이동추적).",
"개정: 무엇이 바뀌었나(전후 문구 델타)=compare-law-versions / 어떤 개정들이 있었나(연혁)=trace-law-history.",
"이 법 아래 무엇이 있나: 계층 조망=get-law-structure(위임 증명 아님) / 조문 단위 위임 규정=find-delegated-rules.",
"넓은 탐색: 넓은 질의의 용어·관련법 조사계획=expand-legal-query / 유사 제도(비슷한 기제)를 가진 법 후보(설계용)=find-comparable-mechanisms.",
"묶음 로더 — authority=특정 조문의 해석/판례/헌재 권위 / bundle=진입점 모를 때 단일법·넓은 질문(--mode) / institutional=명시된 다법령 집합(--statute 반복) / delegated=단일법의 하위규칙·별표 집행기준 본문.",
"별표 금액·기준표: 위임된 시행령·시행규칙의 별표를 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 등 링크 메타를 복구하지 못하니 현행성은 모법 버전으로 확인.",
],
"commands": {
"검색·계획(후보)": [
"search-laws", "resolve-promulgated-law", "search-administrative-rules", "search-annex-forms",
"search-interpretations", "search-cases", "search-constitutional-decisions",
"expand-legal-query", "find-comparable-mechanisms",
],
"본문 로드": [
"get-law", "get-article", "load-article-context", "get-administrative-rule",
"load-administrative-rule-context", "get-annex-form-body", "get-interpretation",
"get-case", "get-constitutional-decision",
],
"연혁·체계·위임": ["trace-law-history", "compare-law-versions", "find-delegated-rules", "get-law-structure"],
"권위·묶음": ["load-authority-context", "load-legal-context-bundle", "load-institutional-system", "load-delegated-criteria", "load-followup"],
},
"kinds": [
"law_hit_list", "admin_rule_hit_list", "annex_form_hit_list", "interpretation_hit_list",
"case_hit_list", "constitutional_hit_list", "comparable_planning_list", "query_expansion_planning",
"law_text", "article_text", "article_context", "admin_rule_text", "admin_rule_context",
"annex_form_text", "interpretation_text", "case_text", "constitutional_text", "law_identity",
"law_history", "law_diff", "delegation_graph", "law_structure_hierarchy_only",
"legal_context_bundle", "authority_context",
"ambiguous", "source_access_error", "parse_error", "no_result",
"needs_search_first", "usage_error", "unsupported", "error",
],
}

__all__ = [name for name in globals() if not name.startswith("__")]
71 changes: 71 additions & 0 deletions moleg_api/_cli/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
from __future__ import annotations

from .foundation import *

EXIT_OK = 0 # includes a zero-hit search (ok:true, count:0)
EXIT_AMBIGUOUS = 2 # multiple plausible identities — surface, don't pick
EXIT_SOURCE = 3 # transient source access failure (rate limit / retry / source)
EXIT_NO_RESULT = 4 # a load found no source text for a valid identifier
EXIT_USAGE = 5 # bad arguments, or a loader was handed a law name (search first)

# Public MolegApi methods reachable through load-followup rehydration. Guards the
# candidate->body discipline: an arbitrary interface string cannot smuggle a
# candidate in as if it were loaded text.
FOLLOWUP_INTERFACES = frozenset(
{
"expand_legal_query",
"find_comparable_mechanisms",
"resolve_promulgated_law",
"search_laws",
"get_law",
"get_article",
"load_article_context",
"search_administrative_rules",
"get_administrative_rule",
"load_administrative_rule_context",
"search_annex_forms",
"get_annex_form_body",
"search_interpretations",
"get_interpretation",
"search_cases",
"get_case",
"search_constitutional_decisions",
"get_constitutional_decision",
"load_authority_context",
"find_delegated_rules",
"get_law_structure",
"trace_law_history",
"compare_law_versions",
"load_legal_context_bundle",
"load_institutional_system",
"load_delegated_criteria",
}
)
# Handoffs that are valid follow-up interfaces but belong to other sources.
FOLLOWUP_HANDOFFS = frozenset({"websearch", "congress-db"})

# Searches return a list; a zero-hit search is a scoped ok:true result (count 0),
# never an error — even when the SDK signals "nothing found" by raising.
SEARCH_COMMANDS = frozenset(
{
"search-laws",
"search-administrative-rules",
"search-annex-forms",
"search-interpretations",
"search-cases",
"search-constitutional-decisions",
}
)


class CliError(Exception):
"""A CLI-level failure carrying the envelope kind and exit code to emit."""

def __init__(self, message: str, *, kind: str, exit_code: int, extra: dict[str, Any] | None = None):
super().__init__(message)
self.message = message
self.kind = kind
self.exit_code = exit_code
self.extra = extra or {}

__all__ = [name for name in globals() if not name.startswith("__")]
22 changes: 22 additions & 0 deletions moleg_api/_cli/data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from __future__ import annotations

from .foundation import *

def _to_data(result: Any, *, include_raw: bool) -> Any:
if isinstance(result, list):
return [_to_data(item, include_raw=include_raw) for item in result]
if hasattr(result, "to_dict"):
return result.to_dict(include_raw=include_raw)
return result


def _statute_args(values: list[str]) -> list[str]:
if not values:
raise CliError(
"load-institutional-system needs at least one --statute law_id",
kind="usage",
exit_code=EXIT_USAGE,
)
return values

__all__ = [name for name in globals() if not name.startswith("__")]
114 changes: 114 additions & 0 deletions moleg_api/_cli/dispatch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
from __future__ import annotations

from .foundation import *
from .constants import CliError, EXIT_USAGE, FOLLOWUP_HANDOFFS, FOLLOWUP_INTERFACES
from .signals_meta import parse_as_of

def _call(api: MolegApi, args: argparse.Namespace) -> Any:
c = args.command
# Strictly validate --as-of once, for every command that carries it, so a
# malformed date is a usage error rather than a silent wrong-version load.
if getattr(args, "as_of", None):
args.as_of = parse_as_of(args.as_of)
if c == "search-laws":
return api.search_laws(args.query, as_of=args.as_of, basis=args.basis,
law_type=args.law_type, ministry=args.ministry, display=args.display)
if c == "resolve-promulgated-law":
return api.resolve_promulgated_law(prom_law_nm=args.prom_law_nm, prom_no=args.prom_no, promulgation_dt=args.promulgation_dt)
if c == "search-administrative-rules":
return api.search_administrative_rules(args.query, ministry=args.ministry, rule_type=args.rule_type,
issued_on=args.issued_on, include_history=args.include_history, display=args.display)
if c == "search-annex-forms":
return api.search_annex_forms(args.query, source=args.source, search_scope=args.search_scope,
annex_type=args.annex_type, ministry=args.ministry, display=args.display)
if c == "search-interpretations":
return api.search_interpretations(args.query, source=args.source, ministry=args.ministry,
search_body=args.search_body, interpreted_on=args.interpreted_on, display=args.display)
if c == "search-cases":
return api.search_cases(args.query, court=args.court, court_name=args.court_name, search_body=args.search_body,
decided_on=args.decided_on, case_number=args.case_number, display=args.display)
if c == "search-constitutional-decisions":
return api.search_constitutional_decisions(args.query, search_body=args.search_body,
decided_on=args.decided_on, case_number=args.case_number, display=args.display)
if c == "expand-legal-query":
return api.expand_legal_query(args.query, display=args.display, include_websearch_hint=not args.no_websearch_hint)
if c == "find-comparable-mechanisms":
return api.find_comparable_mechanisms(args.concept, display=args.display)
if c == "get-law":
return api.get_law(args.law, as_of=args.as_of, basis=args.basis,
articles=args.article or None, include_metadata=not args.no_metadata)
if c == "get-article":
return api.get_article(args.law, args.article, as_of=args.as_of, basis=args.basis)
if c == "load-article-context":
return api.load_article_context(args.law, args.article,
as_of=args.as_of, basis=args.basis, follow_moved=not args.no_follow_moved)
if c == "get-administrative-rule":
return api.get_administrative_rule(args.identifier, articles=args.article or None, include_metadata=not args.no_metadata)
if c == "load-administrative-rule-context":
return api.load_administrative_rule_context(args.identifier, articles=args.article or None,
include_metadata=not args.no_metadata, follow_moved=not args.no_follow_moved)
if c == "get-annex-form-body":
return api.get_annex_form_body(args.identifier, source=args.source, title=args.title,
include_metadata=not args.no_metadata, attempt_structuring=not args.no_structuring)
if c == "get-interpretation":
return api.get_interpretation(args.identifier, source=args.source, ministry=args.ministry, include_metadata=not args.no_metadata)
if c == "get-case":
return api.get_case(args.identifier, include_metadata=not args.no_metadata)
if c == "get-constitutional-decision":
return api.get_constitutional_decision(args.identifier, include_metadata=not args.no_metadata)
if c == "trace-law-history":
date_range = (args.date_from, args.date_to) if (args.date_from and args.date_to) else None
return api.trace_law_history(args.law, date_range=date_range, article=args.article)
if c == "compare-law-versions":
return api.compare_law_versions(args.law, article=args.article)
if c == "find-delegated-rules":
return api.find_delegated_rules(args.law, article=args.article)
if c == "get-law-structure":
return api.get_law_structure(args.law, depth=args.depth)
if c == "load-authority-context":
return api.load_authority_context(args.law, articles=args.article,
query=args.query, budget=args.budget, as_of=args.as_of)
if c == "load-legal-context-bundle":
bridge = _bridge(args)
law = args.law or None
return api.load_legal_context_bundle(args.query, promulgation_bridge=bridge, law_identifier=law,
articles=args.article or None, mode=args.mode, budget=args.budget, as_of=args.as_of)
if c == "load-institutional-system":
return api.load_institutional_system(_statute_args(args.statute), articles=args.article or None, budget=args.budget, as_of=args.as_of)
if c == "load-delegated-criteria":
return api.load_delegated_criteria(args.law, articles=args.article or None,
query=args.query, budget=args.budget, as_of=args.as_of)
if c == "load-followup":
return api.load_followup(_read_followup(args.json_arg))
raise CliError(f"unknown command: {c}", kind="usage", exit_code=EXIT_USAGE)


def _bridge(args: argparse.Namespace) -> dict[str, Any] | None:
keys = {"prom_law_nm": args.prom_law_nm, "prom_no": args.prom_no, "promulgation_dt": args.promulgation_dt}
keys = {k: v for k, v in keys.items() if v}
return keys or None


def _read_followup(json_arg: str) -> DeferredLookup:
text = sys.stdin.read() if json_arg == "-" else json_arg
try:
obj = json.loads(text)
except json.JSONDecodeError as exc:
raise CliError(f"--json is not valid JSON: {exc}", kind="usage", exit_code=EXIT_USAGE) from exc
if isinstance(obj, list):
raise CliError("--json expects one deferred object (data.deferred[i]), not a list", kind="usage", exit_code=EXIT_USAGE)
interface = obj.get("interface")
if interface not in FOLLOWUP_INTERFACES and interface not in FOLLOWUP_HANDOFFS and not any(str(interface).startswith(h) for h in FOLLOWUP_HANDOFFS):
raise CliError(
f"unknown follow-up interface {interface!r} — pass a deferred object from a prior bundle/expand response, not a hand-written one",
kind="usage", exit_code=EXIT_USAGE,
)
return DeferredLookup(
interface=str(interface),
query=str(obj.get("query", "")),
reason=str(obj.get("reason", "")),
source_type=obj.get("source_type"),
filters=obj.get("filters") or {},
)

__all__ = [name for name in globals() if not name.startswith("__")]
30 changes: 30 additions & 0 deletions moleg_api/_cli/foundation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from __future__ import annotations

import argparse
import json
import sys
from datetime import date, datetime
from importlib.metadata import PackageNotFoundError, version as _dist_version
from typing import Any

from ..errors import (
AmbiguousLawError,
AsOfBeforeCoverageError,
MolegApiError,
NoResultError,
ParseFailureError,
RateLimitError,
RetryExhaustedError,
UnsupportedFormatError,
)
from ..laws import MolegApi
from ..models import DeferredLookup


def _pkg_version() -> str:
try:
return _dist_version("moleg-api")
except PackageNotFoundError:
return "unknown"

__all__ = [name for name in globals() if not name.startswith("__")]
Loading
Loading