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
6 changes: 6 additions & 0 deletions moleg_api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
)
from .laws import MolegApi
from .models import (
AdjudicationHit,
AdjudicationIdentity,
AdjudicationText,
AdministrativeRuleArticleText,
AdministrativeRuleContext,
AdministrativeRuleHit,
Expand Down Expand Up @@ -124,6 +127,9 @@
"LawIdentity",
"LawStructure",
"LawStructureNode",
"AdjudicationHit",
"AdjudicationIdentity",
"AdjudicationText",
"LawText",
"LawToc",
"LawTocEntry",
Expand Down
21 changes: 13 additions & 8 deletions moleg_api/_cli/brief_mode.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations

from dataclasses import replace
from dataclasses import MISSING, fields, replace
from typing import Any

# A decision detail ships the same reasoning twice over: `text` and `full_text`
Expand All @@ -13,7 +13,7 @@
# Brief mode drops the full-body fields and keeps the extract. It does *not* drop
# `summary`: 요지 is the entire reason to ask for a brief, and cutting it would
# shrink the payload further while removing the only thing brief mode is for.
_BRIEF_DROPPED_FIELDS = ("text", "full_text")
_BRIEF_DROPPED_FIELDS = ("text", "full_text", "reasoning")


def to_brief(result: Any) -> Any:
Expand All @@ -23,12 +23,17 @@ def to_brief(result: Any) -> Any:
every consumer's field access stay valid — the difference is signalled on the
envelope, not encoded in a second shape of the same thing.
"""
blanked = {}
for name in _BRIEF_DROPPED_FIELDS:
if not hasattr(result, name):
continue
current = getattr(result, name)
blanked[name] = "" if isinstance(current, str) else None
# Reset each field to its declared default rather than to a blanket "" or
# None. `text: str = ""` and `reasoning: str | None = None` mean different
# things by absence, and blanking a nullable field to "" turns "not loaded"
# into "loaded and empty" — a distinction a caller reads as a fact about the
# document rather than about the request.
defaults = {f.name: f.default for f in fields(result)}
blanked = {
name: defaults[name]
for name in _BRIEF_DROPPED_FIELDS
if name in defaults and defaults[name] is not MISSING
}
return replace(result, **blanked) if blanked else result


Expand Down
7 changes: 6 additions & 1 deletion moleg_api/_cli/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"convention": [
"kind 접미사가 후보≠본문을 구조로 표시: *_hit/*_candidate/*_planning = 검색 후보(인용 불가), *_text/*_context/*_identity = 로드된 본문.",
"인용은 항상 로드된 본문에서만 — search/expand/find-comparable 결과는 '다음에 무엇을 로드할지의 메뉴'일 뿐.",
"출처 권위 보존: 법제처 법령 > 법제처 해석 > 부처 해석 ≠ 판례 ≠ 헌재. flags.source_type/라벨을 답에 반영.",
"출처 권위 보존: 법제처 법령 > 법제처 해석 > 부처 해석 ≠ 판례 ≠ 헌재 ≠ 위원회 결정 ≠ 행정심판 재결. flags.source_type/source_authority를 답에 반영하고 평탄화 금지 — 위원회 결정·행정심판 재결은 행정기관의 판단이라 행정소송으로 뒤집힐 수 있고 판례가 아니다.",
"0건·호출 실패 ≠ 부재. 종료코드로 구분: 0 ok(0건 포함) · 2 모호 · 3 소스 접근·응답 문제 · 4 no-result · 5 usage/순서위반.",
"exit 3의 두 얼굴을 kind로 갈라라 — source_access_error=진짜 일시 장애(타임아웃·429·5xx)라 재시도가 맞고, parse_error=인식 불가 응답이라 재시도해도 그대로다(식별자 오류부터 의심). 불량 식별자로 본문이 없으면 exit 3이 아니라 no_result(exit 4)로 온다 — '일시 장애'로 읽지 마라.",
"load 계열에 법령명을 주면 needs_search_first(exit 5) — 먼저 search-laws로 law_id를 얻어라.",
Expand All @@ -22,25 +22,30 @@
"이 법 아래 무엇이 있나: 계층 조망=get-law-structure(위임 증명 아님) / 조문 단위 위임 규정=find-delegated-rules.",
"넓은 탐색: 넓은 질의의 용어·관련법 조사계획=expand-legal-query / 유사 제도(비슷한 기제)를 가진 법 후보(설계용)=find-comparable-mechanisms.",
"묶음 로더 — authority=특정 조문의 해석/판례/헌재 권위 / bundle=진입점 모를 때 단일법·넓은 질문(--mode) / institutional=명시된 다법령 집합(--statute 반복) / delegated=단일법의 하위규칙·별표 집행기준 본문.",
"감독기관이 실제로 판단했나(견제): 규제기관의 처분·의결서=search-committee-decisions --committee <ppc|ftc|fsc|sfc|kcc|nhrck|acr|nlrc|eiac|iaciac|oclt|ecc> / 그 처분에 불복한 재결=search-administrative-appeals --tribunal <decc|acr|adap|tt|kmst>. 둘 다 0건이어도 부재의 증명이 아니다(비공개·미접수 가능).",
"별표 금액·기준표: 위임된 시행령·시행규칙의 별표를 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",
"search-committee-decisions", "search-administrative-appeals",
],
"본문 로드": [
"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",
"get-committee-decision", "get-administrative-appeal",
],
"연혁·체계·위임": ["trace-law-history", "get-revision-reason", "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",
"committee_decision_hit_list", "administrative_appeal_hit_list",
"committee_decision_text", "administrative_appeal_text",
"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", "revision_reason_text", "law_toc_map", "law_diff", "delegation_graph", "law_structure_hierarchy_only",
Expand Down
2 changes: 2 additions & 0 deletions moleg_api/_cli/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@
"search-interpretations",
"search-cases",
"search-constitutional-decisions",
"search-committee-decisions",
"search-administrative-appeals",
}
)

Expand Down
8 changes: 8 additions & 0 deletions moleg_api/_cli/dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ def _dispatch(api: MolegApi, args: argparse.Namespace) -> Any:
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 == "search-committee-decisions":
return api.search_committee_decisions(args.query, committee=args.committee, display=args.display)
if c == "get-committee-decision":
return api.get_committee_decision(args.identifier, committee=args.committee)
if c == "search-administrative-appeals":
return api.search_administrative_appeals(args.query, tribunal=args.tribunal, display=args.display)
if c == "get-administrative-appeal":
return api.get_administrative_appeal(args.identifier, tribunal=args.tribunal)
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":
Expand Down
22 changes: 22 additions & 0 deletions moleg_api/_cli/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,28 @@ def build_parser() -> argparse.ArgumentParser:
p.add_argument("--case-number", dest="case_number", default=None)
p.add_argument("--display", type=int, default=20)

p = sub.add_parser("search-committee-decisions", help="위원회 결정문 검색(개보위·공정위·금융위·인권위 등 12종).")
p.add_argument("query", nargs="?", default=None, help="안건명·사건명 키워드(생략 시 최신 목록).")
p.add_argument("--committee", required=True,
help="기관 코드: ppc(개인정보보호위) ftc(공정위) fsc(금융위) sfc(증선위) kcc(방통위) nhrck(인권위) acr(권익위) nlrc(노동위) eiac(고용보험심사위) iaciac(산재재심사위) oclt(중앙토지수용위) ecc(중앙환경분쟁조정위).")
p.add_argument("--display", type=int, default=20)

p = sub.add_parser("get-committee-decision", help="위원회 결정문 본문 로드(--brief면 요지만).")
p.add_argument("--id", dest="identifier", required=True, help="decision_id(검색이 준 값).")
p.add_argument("--committee", required=True, help="검색에 쓴 기관 코드와 동일해야 한다.")
_add_brief(p)

p = sub.add_parser("search-administrative-appeals", help="행정심판 재결례 검색(일반 + 특별심판 4종).")
p.add_argument("query", nargs="?", default=None, help="사건명 키워드(생략 시 최신 목록).")
p.add_argument("--tribunal", default="decc",
help="decc(일반 행정심판위) acr(권익위 특별) adap(소청심사위) tt(조세심판원) kmst(해양안전심판원).")
p.add_argument("--display", type=int, default=20)

p = sub.add_parser("get-administrative-appeal", help="행정심판 재결 본문 로드(--brief면 요지만).")
p.add_argument("--id", dest="identifier", required=True)
p.add_argument("--tribunal", default="decc", help="검색에 쓴 심판기관 코드와 동일해야 한다.")
_add_brief(p)

p = sub.add_parser("expand-legal-query", help="질의 확장·관련법/용어/조문 조사 계획.")
p.add_argument("query"); p.add_argument("--display", type=int, default=5)
p.add_argument("--no-websearch-hint", dest="no_websearch_hint", action="store_true")
Expand Down
27 changes: 27 additions & 0 deletions moleg_api/_cli/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,33 @@ def signals_for(command: str, result: Any, args: argparse.Namespace) -> dict[str
if moved:
next_cmds.append({"why": "이동 목적지 로드", "cmd": f"moleg load-article-context --law {result.identity.law_id or ''} {moved}"})

elif tname == "AdjudicationText":
ident = result.identity
# The kind carries the authority, not just the shape. An 행정심판 재결 and
# a 위원회 처분 are both "an agency decided something" structurally, and
# collapsing them into one kind is how a reader ends up treating a
# reviewable disposition as a settled one.
kind = ("administrative_appeal_text" if "appeal" in (ident.source_type or "")
else "committee_decision_text")
source = f"법제처 / {ident.body_name}"
flags["source_type"] = ident.source_type
flags["source_authority"] = ident.source_authority
flags["body"] = {"code": ident.body, "name": ident.body_name}
if ident.respondent_agency:
flags["respondent_agency"] = ident.respondent_agency
if ident.review_agency:
flags["review_agency"] = ident.review_agency
discipline.append(ident.source_authority)
# 감독기관이 "확인했는가"를 묻는 것이 이 계열의 용도다. 처분이 있었다는 사실은
# 그 기관이 그 사안을 인지·판단했다는 1차 증거이고, 없다는 것은 부재의 증명이
# 아니라 이 검색 범위에서 못 찾았다는 뜻일 뿐이다.
discipline.append(
"이 결정의 존재는 해당 기관이 그 사안을 실제로 판단했다는 1차 기록 — "
"다만 '이 기관에 기록이 없다'가 '문제가 없었다'는 뜻은 아니다(미접수·미조사·비공개 가능)."
)
if ident.decided_on:
flags["decided_on"] = ident.decided_on

elif tname == "LawToc":
tf, tl = _law_time_flags(result.identity, as_of)
flags.update(tf); discipline.extend(tl)
Expand Down
20 changes: 20 additions & 0 deletions moleg_api/_cli/signals_meta.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
"search-cases": ("case_hit", "법제처 / 판례 검색"),
"search-constitutional-decisions": ("constitutional_hit", "법제처 / 헌재결정 검색"),
"find-comparable-mechanisms": ("comparable_planning", "법제처 / 유사제도 탐색"),
"search-committee-decisions": ("committee_decision_hit", "법제처 / 위원회 결정문 검색"),
"search-administrative-appeals": ("administrative_appeal_hit", "법제처 / 행정심판 재결례 검색"),
}

# element dataclass name -> (canonical command, element kind, source). Keys
Expand All @@ -34,6 +36,14 @@ def _resolve_list_kind(command: str, result: list[Any]) -> tuple[str, str, str]:
"""
if result:
et = type(result[0]).__name__
if et == "AdjudicationHit":
# Split by the body that decided, not by the invoking command: a hit
# reached through load-followup must still carry the authority it was
# decided under, and 위원회 처분 ≠ 행정심판 재결.
st = str(getattr(result[0].identity, "source_type", "") or "")
if "appeal" in st:
return "search-administrative-appeals", "administrative_appeal_hit", "법제처 / 행정심판 재결례 검색"
return "search-committee-decisions", "committee_decision_hit", "법제처 / 위원회 결정문 검색"
if et == "JudicialDecisionHit":
st = getattr(result[0].identity, "source_type", "") or ""
if "detc" in str(st) or command == "search-constitutional-decisions":
Expand Down Expand Up @@ -63,6 +73,15 @@ def _standing_list_discipline(eff_command: str, flags: dict[str, Any]) -> list[s
flags["source_authority"] = "법제처 해석 ≠ 부처 1차 해석 — 답에서 출처 유형 보존"
elif eff_command == "search-administrative-rules":
flags["issued_on_is"] = "발령일자 필터(시행일 아님)"
elif eff_command == "search-committee-decisions":
flags["source_authority"] = "행정기관 처분·의결 ≠ 판례 — 답에서 출처 유형 보존"
# 이 계열은 감독기관의 부작위를 묻는 자리에서 쓰인다. 0건을 '처분한 적 없다'로
# 읽으면 정확히 반대 방향으로 틀린 결론에 도달한다 — 미공개·미접수·타 기관 소관
# 모두 0건으로 나온다.
lines.append("0건은 '그 기관이 처분한 적 없음'의 증명이 아님 — 비공개·미접수·타 기관 소관일 수 있다. 기관 코드를 바꿔 재검색하거나 자료요구로 확인하라.")
elif eff_command == "search-administrative-appeals":
flags["source_authority"] = "행정심판 재결 ≠ 법원 판결 — 답에서 출처 유형 보존"
lines.append("일반(decc)에 없으면 특별행정심판(--tribunal acr/adap/tt/kmst)도 확인하라 — 소청·조세·해양안전 사건은 일반 목록에 없다.")
return lines

SINGLE_META: dict[str, tuple[str, str]] = {
Expand All @@ -78,6 +97,7 @@ def _standing_list_discipline(eff_command: str, flags: dict[str, Any]) -> list[s
"LawHistory": ("law_history", "법제처 / 개정 연혁"),
"RevisionReason": ("revision_reason_text", "법제처 / 개정이유·공포문 원문"),
"LawToc": ("law_toc_map", "법제처 / 조문 목차(본문 아님)"),
"AdjudicationText": ("adjudication_text", "법제처 / 행정기관 의결·재결 본문"),
"LawDiff": ("law_diff", "법제처 / 개정 전후 비교"),
"DelegationGraph": ("delegation_graph", "법제처 / 위임 규정"),
"LawStructure": ("law_structure_hierarchy_only", "법제처 / 법령 체계도"),
Expand Down
76 changes: 76 additions & 0 deletions moleg_api/_laws/adjudication_registry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
from __future__ import annotations

from .foundation import *

# Probed live on 2026-07-19 with the shared default OC: every target below
# answers both list and detail with no 활용신청 required.
#
# The authority sentence travels with the body rather than being written once for
# the family, because the two groups settle genuinely different things and a
# reader who conflates them draws a wrong conclusion about what is final.

_COMMITTEE_AUTHORITY = (
"행정기관(위원회)의 처분·의결 — 법원 판결이 아니다. 해당 기관의 법 집행 기준·처분 사례를 보여줄 뿐, "
"판례처럼 법 해석을 확정하지 않으며 행정소송으로 뒤집힐 수 있다."
)
_APPEAL_AUTHORITY = (
"행정심판 재결 — 행정기관 내부의 쟁송 판단이지 법원 판결이 아니다. 처분청의 처분을 심사한 결과이며, "
"재결에 불복하면 행정소송으로 다툴 수 있다. 판례로 인용 금지."
)

COMMITTEES: dict[str, dict[str, str]] = {
"ppc": {"target": "ppc", "name": "개인정보보호위원회"},
"ftc": {"target": "ftc", "name": "공정거래위원회"},
"fsc": {"target": "fsc", "name": "금융위원회"},
"sfc": {"target": "sfc", "name": "증권선물위원회"},
"kcc": {"target": "kcc", "name": "방송통신위원회"},
"nhrck": {"target": "nhrck", "name": "국가인권위원회"},
"acr": {"target": "acr", "name": "국민권익위원회"},
"nlrc": {"target": "nlrc", "name": "노동위원회"},
"eiac": {"target": "eiac", "name": "고용보험심사위원회"},
"iaciac": {"target": "iaciac", "name": "산업재해보상보험재심사위원회"},
"oclt": {"target": "oclt", "name": "중앙토지수용위원회"},
"ecc": {"target": "ecc", "name": "중앙환경분쟁조정위원회"},
}

# The four special tribunals cost almost nothing beyond the general 행정심판례:
# same request shape, same field vocabulary, a handful of extra keys each. Left
# out, a 소청·조세·해양안전 question would silently return nothing from decc and
# read as absence.
APPEAL_BODIES: dict[str, dict[str, str]] = {
"decc": {"target": "decc", "name": "행정심판위원회(일반)", "kind": "administrative_appeal"},
"acr": {"target": "acrSpecialDecc", "name": "국민권익위원회 특별행정심판", "kind": "special_administrative_appeal"},
"adap": {"target": "adapSpecialDecc", "name": "소청심사위원회", "kind": "special_administrative_appeal"},
"tt": {"target": "ttSpecialDecc", "name": "조세심판원", "kind": "special_administrative_appeal"},
"kmst": {"target": "kmstSpecialDecc", "name": "해양안전심판원", "kind": "special_administrative_appeal"},
}


def committee_spec(code: str) -> dict[str, str]:
spec = COMMITTEES.get(str(code).strip().lower())
if not spec:
raise CliOrUsageError(
f"알 수 없는 위원회 코드 {code!r} — 가능한 값: {', '.join(sorted(COMMITTEES))}"
)
return {**spec, "code": str(code).strip().lower(),
"source_type": "committee_decision", "authority": _COMMITTEE_AUTHORITY}


def appeal_spec(code: str) -> dict[str, str]:
spec = APPEAL_BODIES.get(str(code).strip().lower())
if not spec:
raise CliOrUsageError(
f"알 수 없는 심판기관 코드 {code!r} — 가능한 값: {', '.join(sorted(APPEAL_BODIES))}"
)
return {**spec, "code": str(code).strip().lower(),
"source_type": spec["kind"], "authority": _APPEAL_AUTHORITY}


class CliOrUsageError(UnsupportedFormatError):
"""An unknown body code — a caller mistake, not a source failure.

Raised as UnsupportedFormatError so the CLI maps it to a usage exit rather
than letting a typo look like the agency has no records.
"""

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