From 03e8435dff72d25a0009f5ed85af7e03cd4fdc35 Mon Sep 17 00:00:00 2001 From: seongjin Date: Sun, 19 Jul 2026 23:05:32 +0900 Subject: [PATCH] =?UTF-8?q?feat(laws):=20reach=20agency=20adjudications=20?= =?UTF-8?q?=E2=80=94=20committee=20decisions=20and=20=ED=96=89=EC=A0=95?= =?UTF-8?q?=EC=8B=AC=ED=8C=90=20=EC=9E=AC=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The oversight question the package could not answer was "did the supervising body actually act?" The statute was reachable and the court ruling was reachable, but the disposition in between was outside the surface entirely: a 개인정보보호위원회 의결, a 공정위 과징금 처분, a 조세심판원 재결. The audit doc had them all listed as optional, gated on demand being proven — and a harness whose whole purpose is asking whether a regulator did its job is that proof. Seventeen bodies, one code path: twelve committees (ppc ftc fsc sfc kcc nhrck acr nlrc eiac iaciac oclt ecc), the general 행정심판 docket, and four special tribunals. The request shape is identical everywhere; only the vocabulary differs, and that is normalization's job rather than seventeen near-copies of one method. All seventeen were probed live on 2026-07-19 with the shared default OC — every one answers list and detail, no 활용신청 needed. Authority is a field, not decoration. A 위원회 결정 is an administrative disposition by the regulator itself; an 행정심판 재결 reviews another agency's disposition and can itself be overturned in court. Neither is precedent. They get separate kinds (committee_decision_text vs administrative_appeal_text) and separate authority sentences, because sharing one is how a reviewable disposition ends up cited as a settled one. The zero-hit discipline matters more here than anywhere else in the package. This family gets used to ask whether a regulator failed to act, and reading 0건 as "never disposed of anything" is wrong in exactly that direction — 비공개, 미접수, and wrong-docket all return zero. The signal says so, and points at the special tribunals, whose 소청·조세·해양안전 cases are absent from the general decc list. Four normalization traps, all found by probing rather than by reading: - The special tribunals answer a request for `acrSpecialDecc` with rows keyed `decc`, so matching on the requested target returns nothing and reads as "this tribunal has no records". Rows are found by shape instead. - Several targets return the literal string "null" for absent fields, which renders as a plausible case number in an answer. - Committees write dates as `2020.6.8.` — unpadded, trailing dot — which the shared compact_date passes through untouched. In an oversight question the date is the finding, so it is normalized here. - Field values arrive as bare strings, arrays, or single-key objects for the same concept depending on the body; `str()` on those yields a repr. Also fixes brief mode to reset each field to its own declared default rather than a blanket "": `text: str` and `full_text: str | None` mean different things by absence, and blanking a nullable field to "" turns "not loaded" into "loaded and empty" — a claim about the document rather than about the request. --brief now also drops `reasoning`, the bulk of an adjudication. Verified live: ppc search+load (id 9745), ftc search+load (id 17363), decc search+load (id 273321), and the 조세심판원 special docket. Co-Authored-By: Claude Opus 4.8 (1M context) --- moleg_api/__init__.py | 6 + moleg_api/_cli/brief_mode.py | 21 +- moleg_api/_cli/catalog.py | 7 +- moleg_api/_cli/constants.py | 2 + moleg_api/_cli/dispatch.py | 8 + moleg_api/_cli/parser.py | 22 ++ moleg_api/_cli/signals.py | 27 +++ moleg_api/_cli/signals_meta.py | 20 ++ moleg_api/_laws/adjudication_registry.py | 76 ++++++ moleg_api/_laws/api.py | 2 + moleg_api/_laws/api_adjudications.py | 117 +++++++++ moleg_api/_laws/foundation.py | 7 + moleg_api/_models/__init__.py | 1 + moleg_api/_models/adjudications.py | 75 ++++++ moleg_api/_normalization/__init__.py | 1 + moleg_api/_normalization/adjudications.py | 164 +++++++++++++ moleg_api/models.py | 3 + tests/test_adjudications_0_3_0.py | 278 ++++++++++++++++++++++ tests/test_context_efficiency_0_3_0.py | 6 +- 19 files changed, 833 insertions(+), 10 deletions(-) create mode 100644 moleg_api/_laws/adjudication_registry.py create mode 100644 moleg_api/_laws/api_adjudications.py create mode 100644 moleg_api/_models/adjudications.py create mode 100644 moleg_api/_normalization/adjudications.py create mode 100644 tests/test_adjudications_0_3_0.py diff --git a/moleg_api/__init__.py b/moleg_api/__init__.py index 93f65ec..a9026ff 100644 --- a/moleg_api/__init__.py +++ b/moleg_api/__init__.py @@ -14,6 +14,9 @@ ) from .laws import MolegApi from .models import ( + AdjudicationHit, + AdjudicationIdentity, + AdjudicationText, AdministrativeRuleArticleText, AdministrativeRuleContext, AdministrativeRuleHit, @@ -124,6 +127,9 @@ "LawIdentity", "LawStructure", "LawStructureNode", + "AdjudicationHit", + "AdjudicationIdentity", + "AdjudicationText", "LawText", "LawToc", "LawTocEntry", diff --git a/moleg_api/_cli/brief_mode.py b/moleg_api/_cli/brief_mode.py index 6a91508..ab81467 100644 --- a/moleg_api/_cli/brief_mode.py +++ b/moleg_api/_cli/brief_mode.py @@ -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` @@ -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: @@ -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 diff --git a/moleg_api/_cli/catalog.py b/moleg_api/_cli/catalog.py index 74abf95..aeaa14a 100644 --- a/moleg_api/_cli/catalog.py +++ b/moleg_api/_cli/catalog.py @@ -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를 얻어라.", @@ -22,6 +22,7 @@ "이 법 아래 무엇이 있나: 계층 조망=get-law-structure(위임 증명 아님) / 조문 단위 위임 규정=find-delegated-rules.", "넓은 탐색: 넓은 질의의 용어·관련법 조사계획=expand-legal-query / 유사 제도(비슷한 기제)를 가진 법 후보(설계용)=find-comparable-mechanisms.", "묶음 로더 — authority=특정 조문의 해석/판례/헌재 권위 / bundle=진입점 모를 때 단일법·넓은 질문(--mode) / institutional=명시된 다법령 집합(--statute 반복) / delegated=단일법의 하위규칙·별표 집행기준 본문.", + "감독기관이 실제로 판단했나(견제): 규제기관의 처분·의결서=search-committee-decisions --committee / 그 처분에 불복한 재결=search-administrative-appeals --tribunal . 둘 다 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": { @@ -29,11 +30,13 @@ "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"], @@ -41,6 +44,8 @@ "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", diff --git a/moleg_api/_cli/constants.py b/moleg_api/_cli/constants.py index 7655fd2..d1bb8ab 100644 --- a/moleg_api/_cli/constants.py +++ b/moleg_api/_cli/constants.py @@ -57,6 +57,8 @@ "search-interpretations", "search-cases", "search-constitutional-decisions", + "search-committee-decisions", + "search-administrative-appeals", } ) diff --git a/moleg_api/_cli/dispatch.py b/moleg_api/_cli/dispatch.py index 247892c..0846bb5 100644 --- a/moleg_api/_cli/dispatch.py +++ b/moleg_api/_cli/dispatch.py @@ -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": diff --git a/moleg_api/_cli/parser.py b/moleg_api/_cli/parser.py index 356e6ea..5d9c570 100644 --- a/moleg_api/_cli/parser.py +++ b/moleg_api/_cli/parser.py @@ -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") diff --git a/moleg_api/_cli/signals.py b/moleg_api/_cli/signals.py index 2f12dda..c12dfda 100644 --- a/moleg_api/_cli/signals.py +++ b/moleg_api/_cli/signals.py @@ -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) diff --git a/moleg_api/_cli/signals_meta.py b/moleg_api/_cli/signals_meta.py index f33d45b..b0d4f35 100644 --- a/moleg_api/_cli/signals_meta.py +++ b/moleg_api/_cli/signals_meta.py @@ -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 @@ -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": @@ -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]] = { @@ -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", "법제처 / 법령 체계도"), diff --git a/moleg_api/_laws/adjudication_registry.py b/moleg_api/_laws/adjudication_registry.py new file mode 100644 index 0000000..8f303cc --- /dev/null +++ b/moleg_api/_laws/adjudication_registry.py @@ -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("__")] diff --git a/moleg_api/_laws/api.py b/moleg_api/_laws/api.py index c937b38..cdacb3a 100644 --- a/moleg_api/_laws/api.py +++ b/moleg_api/_laws/api.py @@ -1,5 +1,6 @@ from __future__ import annotations +from .api_adjudications import AdjudicationMixin from .api_admin_rules import AdministrativeRuleMixin from .api_annex import AnnexMixin from .api_article_context import ArticleContextMixin @@ -36,6 +37,7 @@ class MolegApi( AdministrativeRuleMixin, InterpretationMixin, JudicialDecisionMixin, + AdjudicationMixin, AuthorityContextMixin, QueryExpansionMixin, ComparableMechanismsMixin, diff --git a/moleg_api/_laws/api_adjudications.py b/moleg_api/_laws/api_adjudications.py new file mode 100644 index 0000000..ba7992b --- /dev/null +++ b/moleg_api/_laws/api_adjudications.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +from .support import * +from .adjudication_registry import appeal_spec, committee_spec + +class AdjudicationMixin: + """Committee decisions and administrative appeals. + + The oversight question this package could not answer was "did the supervising + body actually act?" — the statute was reachable, the court ruling was + reachable, but the disposition in between was not. A 개인정보보호위원회 + 의결서 or a 조세심판원 재결 is the primary record of an agency applying the law + it administers, and until now every one of them was outside the surface. + + Search and load are one code path across seventeen bodies because the request + shape is identical; only the vocabulary differs, and that is normalization's + job rather than seventeen near-copies of the same method. + """ + + def search_committee_decisions( + self, + query: str | None = None, + *, + committee: str, + display: int = 20, + ) -> list[AdjudicationHit]: + """Search one committee's decisions. + + Use when: the question is whether a regulator disposed of something — a + 과징금, a 시정명령, a 인권침해 판단 — rather than what a statute says. + Returns: `AdjudicationHit` candidates carrying the authority label. Never + quotable; load the decision first. + Related: `get_committee_decision` loads one; + `search_administrative_appeals` covers 행정심판 재결 instead. + """ + return self._search_adjudications(committee_spec(committee), query, display) + + def get_committee_decision(self, decision_id: str, *, committee: str) -> AdjudicationText: + """Load one committee decision (주문·요지·이유·당사자).""" + return self._load_adjudication(committee_spec(committee), decision_id) + + def search_administrative_appeals( + self, + query: str | None = None, + *, + tribunal: str = "decc", + display: int = 20, + ) -> list[AdjudicationHit]: + """Search 행정심판 재결례, general or from a special tribunal. + + Use when: the question is whether someone contested an agency's + disposition and how the review body ruled — the record of a disposition + being tested short of court. + Returns: `AdjudicationHit` candidates carrying the authority label. + Related: the four special tribunals (`acr`, `adap`, `tt`, `kmst`) hold + 재결 that the general `decc` docket does not, so a 소청·조세·해양안전 + question searched only against `decc` reads as absence. + """ + return self._search_adjudications(appeal_spec(tribunal), query, display) + + def get_administrative_appeal(self, decision_id: str, *, tribunal: str = "decc") -> AdjudicationText: + """Load one 행정심판 재결 (주문·재결요지·이유·청구취지).""" + return self._load_adjudication(appeal_spec(tribunal), decision_id) + + # ---- shared implementation ------------------------------------------ # + + def _search_adjudications(self, spec: dict[str, str], query: str | None, display: int) -> list[AdjudicationHit]: + params: dict[str, Any] = {"display": display} + if query and query.strip(): + params["query"] = query.strip() + payload = self.source.search(spec["target"], params) + hits: list[AdjudicationHit] = [] + for row in adjudication_rows(payload, spec["target"]): + identity = normalize_adjudication_identity(row, spec=spec) + if not identity.decision_id: + continue + hits.append( + AdjudicationHit( + identity=identity, + follow_up=FollowUpSearch( + interface="get_committee_decision" + if spec["source_type"] == "committee_decision" + else "get_administrative_appeal", + query=identity.decision_id, + reason="선택한 결정문 본문 로드", + filters={"body": spec["code"]}, + ), + raw=row, + ) + ) + return hits + + def _load_adjudication(self, spec: dict[str, str], decision_id: str) -> AdjudicationText: + identifier = str(decision_id or "").strip() + if not identifier: + raise NoResultError("결정문 일련번호가 필요하다 — search 결과의 decision_id를 넘겨라.") + payload = self.source.service(spec["target"], {"ID": identifier}) + body = adjudication_detail(payload) + if not body or not any(_has_content(v) for v in body.values()): + raise NoResultError( + f"{spec['name']}에 이 일련번호({identifier})의 결정문 본문이 없다 — " + "search 결과의 decision_id인지, 기관 코드가 맞는지 확인하라." + ) + identity = normalize_adjudication_identity(body, spec=spec) + if not identity.decision_id: + identity = replace(identity, decision_id=identifier) + return normalize_adjudication_text(body, identity) + + +def _has_content(value: Any) -> bool: + if isinstance(value, str): + # law.go.kr returns the literal string "null" for absent fields on several + # of these targets, so an all-"null" body is an empty record, not a hit. + return bool(value.strip()) and value.strip() != "null" + return value not in (None, [], {}) + +__all__ = [name for name in globals() if not name.startswith("__")] diff --git a/moleg_api/_laws/foundation.py b/moleg_api/_laws/foundation.py index d8a5570..997bb86 100644 --- a/moleg_api/_laws/foundation.py +++ b/moleg_api/_laws/foundation.py @@ -14,6 +14,9 @@ UnsupportedFormatError, ) from ..models import ( + AdjudicationHit, + AdjudicationIdentity, + AdjudicationText, AdministrativeRuleHit, AdministrativeRuleArticleText, AdministrativeRuleContext, @@ -66,6 +69,10 @@ StructuredTableData, ) from ..normalization import ( + adjudication_detail, + adjudication_rows, + normalize_adjudication_identity, + normalize_adjudication_text, compact_date, compact_promulgation_number, extract_administrative_rule_articles, diff --git a/moleg_api/_models/__init__.py b/moleg_api/_models/__init__.py index 947f548..c4fd382 100644 --- a/moleg_api/_models/__init__.py +++ b/moleg_api/_models/__init__.py @@ -7,6 +7,7 @@ from .authority import ArticleReference, InterpretationHit, InterpretationIdentity, InterpretationText, JudicialDecisionHit, JudicialDecisionIdentity, JudicialDecisionText from .bundles import AuthorityContext, BundleRequest, CandidateContext, LegalContextBundle, LoadedContext from .followups import Ambiguity, ContextGap, DeferredLookup, FollowUpSearch +from .adjudications import AdjudicationHit, AdjudicationIdentity, AdjudicationText from .laws import ArticleContext, ArticleText, DelegatedRule, DelegationGraph, HistoryEvent, LawDiff, LawDiffChange, LawHit, LawHistory, LawIdentity, LawStructure, LawStructureNode, LawText, LawToc, LawTocEntry, RevisionReason, SupplementaryProvision from .query import LegalArticleCandidate, LegalLawCandidate, LegalQueryExpansion, LegalTermCandidate from .serialization import install_serialization_methods diff --git a/moleg_api/_models/adjudications.py b/moleg_api/_models/adjudications.py new file mode 100644 index 0000000..6f98c9c --- /dev/null +++ b/moleg_api/_models/adjudications.py @@ -0,0 +1,75 @@ +"""Committee decisions and administrative appeals — agency adjudications. + +One family, not two, because the access pattern and the failure mode are the +same: search a body's docket, load one document, and do not let it be mistaken +for a court ruling. The bodies differ (개인정보보호위원회 disposes, 조세심판원 +adjudicates an appeal) but the discipline a reader needs is identical, so the +authority label is a field rather than a type. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from .followups import FollowUpSearch + + +@dataclass(frozen=True) +class AdjudicationIdentity: + """Who decided what, and with what authority. + + `source_type` and `source_authority` are not decoration. A 위원회 결정 is an + administrative disposition by the agency that regulates the respondent; an + 행정심판 재결 reviews another agency's disposition and can itself be overturned + in court. Neither is precedent. Citing either as 판례 overstates what it + settles, which is the specific error this family is most likely to invite. + """ + + decision_id: str + body: str + body_name: str + source_type: str + source_authority: str + title: str | None = None + case_number: str | None = None + decided_on: str | None = None + disposition_date: str | None = None + agency: str | None = None + review_agency: str | None = None + respondent_agency: str | None = None + decision_category: str | None = None + detail_link: str | None = None + raw: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class AdjudicationHit: + """A search candidate — an identity, never a body to quote from.""" + + identity: AdjudicationIdentity + follow_up: FollowUpSearch | None = None + raw: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class AdjudicationText: + """One loaded decision or 재결. + + Field names are normalized across bodies that spell the same concept + differently — 결정요지 / 재결요지 / 판단요지 / 판정요지 all land in `summary`, + 주문 in `disposition`, 이유 in `reasoning`. Without that, every consumer would + have to learn twelve agencies' vocabularies to ask one question. + """ + + identity: AdjudicationIdentity + disposition: str | None = None + summary: str | None = None + reasoning: str | None = None + claim: str | None = None + background: str | None = None + applicant: str | None = None + respondent: str | None = None + related_laws: str | None = None + text: str = "" + raw: dict[str, Any] = field(default_factory=dict) diff --git a/moleg_api/_normalization/__init__.py b/moleg_api/_normalization/__init__.py index 1049431..ef7f243 100644 --- a/moleg_api/_normalization/__init__.py +++ b/moleg_api/_normalization/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations +from .adjudications import * from .annex import * from .article_units import * from .articles import * diff --git a/moleg_api/_normalization/adjudications.py b/moleg_api/_normalization/adjudications.py new file mode 100644 index 0000000..e475302 --- /dev/null +++ b/moleg_api/_normalization/adjudications.py @@ -0,0 +1,164 @@ +"""Normalization for committee decisions and administrative appeals. + +Twelve committees and five appeal tribunals each name the same concepts +differently — a decision's gist is 결정요지 at 개인정보보호위원회, 판단요지 at +국가인권위원회, 판정요지 at 노동위원회, 재결요지 at every 심판기관. Left raw, a +consumer would have to learn all seventeen vocabularies to ask one question, and +would silently get None from the sixteen it did not know about. +""" + +from __future__ import annotations + +from typing import Any + +from moleg_api.models import AdjudicationIdentity, AdjudicationText + +from .primitives import compact_date, ensure_list, first_value, string_or_none + +# Ordered by specificity: the first key present wins, so a body that carries both +# a precise and a generic field is read the precise way. +_ID_KEYS = ("결정문일련번호", "행정심판례일련번호", "행정심판재결례일련번호", "특별행정심판재결례일련번호") +_TITLE_KEYS = ("사건명", "안건명", "제목", "소청사례명", "민원표시명", "사건") +_CASE_NO_KEYS = ("사건번호", "의안번호", "의결번호", "안건번호", "청구번호", "재결번호", "결정번호") +_DECIDED_KEYS = ("의결일자", "의결일", "결정일자", "의결연월일", "등록일", "데이터기준일시") +_DISPOSITION_DATE_KEYS = ("처분일자",) +_AGENCY_KEYS = ("기관명", "위원회명", "재결위원회") +_REVIEW_AGENCY_KEYS = ("재결청",) +_RESPONDENT_AGENCY_KEYS = ("처분청", "원처분기관", "상위처분청", "관계기관") +_CATEGORY_KEYS = ("결정구분명", "재결구분명", "결정구분", "재결례유형명", "결정유형", "의결서종류", "의결서유형", "회의종류") +_LINK_KEYS = ("결정문상세링크", "행정심판례상세링크", "행정심판재결례상세링크", "바로보기URL") + +_SUMMARY_KEYS = ("결정요지", "재결요지", "판단요지", "판정요지", "주문요지", "쟁점") +_DISPOSITION_KEYS = ("주문", "결정", "조치내용", "판정결과", "판단") +_REASONING_KEYS = ("이유", "조치이유", "내용", "평가의견", "판정사항", "관련법리") +_CLAIM_KEYS = ("청구취지",) +_BACKGROUND_KEYS = ("개요", "사건의개요", "배경", "주요내용", "분쟁의경과", "사실조사결과", "처분요지", "원처분") +_APPLICANT_KEYS = ("신청인", "청구인", "소청인", "피해자") +_RESPONDENT_KEYS = ("피신청인", "피청구인", "피심인", "피소청인", "피조사자", "조치대상자의인적사항", "피심정보", "해양사고관련자") +_RELATED_LAW_KEYS = ("관계법령", "관련법령", "세목", "사건의분류", "분류명") + + +def adjudication_rows(payload: dict[str, Any], target: str) -> list[dict[str, Any]]: + """Pull the row list out of a search envelope. + + Matched by shape, not by name: the four special tribunals answer a request for + `acrSpecialDecc` with rows keyed `decc`, so keying on the requested target + would return nothing and read as "this tribunal has no records". + """ + for container in (payload, *(v for v in payload.values() if isinstance(v, dict))): + if not isinstance(container, dict): + continue + for key in (target, "decc", *container): + value = container.get(key) + if isinstance(value, list) and value: + return [row for row in value if isinstance(row, dict)] + return [] + + +def adjudication_detail(payload: dict[str, Any]) -> dict[str, Any]: + """Unwrap a detail response, through the 의결서 wrapper some bodies add.""" + body = payload + for _ in range(3): + if len(body) == 1: + only = next(iter(body.values())) + if isinstance(only, dict): + body = only + continue + break + return body + + +def normalize_adjudication_identity(row: dict[str, Any], *, spec: dict[str, str]) -> AdjudicationIdentity: + return AdjudicationIdentity( + decision_id=str(first_value(row, *_ID_KEYS) or "").strip(), + body=spec["code"], + body_name=spec["name"], + source_type=spec["source_type"], + source_authority=spec["authority"], + title=_clean(first_value(row, *_TITLE_KEYS)), + case_number=_clean(first_value(row, *_CASE_NO_KEYS)), + decided_on=_adjudication_date(first_value(row, *_DECIDED_KEYS)), + disposition_date=_adjudication_date(first_value(row, *_DISPOSITION_DATE_KEYS)), + agency=_clean(first_value(row, *_AGENCY_KEYS)) or spec["name"], + review_agency=_clean(first_value(row, *_REVIEW_AGENCY_KEYS)), + respondent_agency=_clean(first_value(row, *_RESPONDENT_AGENCY_KEYS)), + decision_category=_clean(first_value(row, *_CATEGORY_KEYS)), + detail_link=_clean(first_value(row, *_LINK_KEYS)), + raw=row, + ) + + +def normalize_adjudication_text(body: dict[str, Any], identity: AdjudicationIdentity) -> AdjudicationText: + disposition = _flatten(first_value(body, *_DISPOSITION_KEYS)) + summary = _flatten(first_value(body, *_SUMMARY_KEYS)) + reasoning = _flatten(first_value(body, *_REASONING_KEYS)) + claim = _flatten(first_value(body, *_CLAIM_KEYS)) + background = _flatten(first_value(body, *_BACKGROUND_KEYS)) + sections = [ + ("주문", disposition), + ("요지", summary), + ("청구취지", claim), + ("개요", background), + ("이유", reasoning), + ] + return AdjudicationText( + identity=identity, + disposition=disposition, + summary=summary, + reasoning=reasoning, + claim=claim, + background=background, + applicant=_flatten(first_value(body, *_APPLICANT_KEYS)), + respondent=_flatten(first_value(body, *_RESPONDENT_KEYS)), + related_laws=_flatten(first_value(body, *_RELATED_LAW_KEYS)), + text="\n\n".join(f"{label}\n{value}" for label, value in sections if value), + raw=body, + ) + + +def _adjudication_date(value: Any) -> str | None: + """Normalize the dotted, unpadded dates these bodies write. + + Committees emit `2020.6.8.` — trailing dot, no zero padding — which the shared + `compact_date` passes through untouched because it expects `2017.07.31`. An + un-normalized date sorts and compares wrong, and in an oversight question the + date *is* the finding: when the regulator knew, and how long it then took. + """ + text = _flatten(value) + if not text: + return None + parts = [p for p in text.replace("-", ".").split(".") if p.strip()] + if len(parts) >= 3 and all(p.strip().isdigit() for p in parts[:3]): + year, month, day = (p.strip() for p in parts[:3]) + if len(year) == 4: + return f"{year}{month.zfill(2)}{day.zfill(2)}" + return string_or_none(compact_date(text)) + + +def _clean(value: Any) -> str | None: + text = _flatten(value) + # "null" arrives as a literal four-character string on several targets; kept + # as-is it would render as a real case number or title in an answer. + return None if text in (None, "", "null") else text + + +def _flatten(value: Any) -> str | None: + """Render a field to text, whatever nesting the body wrapped it in. + + Committee payloads mix bare strings, string arrays, and single-key objects + for the same concept across bodies. `str()` on those yields a Python repr + that then travels as if it were the agency's own wording. + """ + if value is None: + return None + if isinstance(value, str): + return value.strip() or None + if isinstance(value, dict): + parts = [p for p in (_flatten(v) for v in value.values()) if p] + return "\n".join(parts) or None + if isinstance(value, (list, tuple)): + parts = [p for p in (_flatten(v) for v in ensure_list(value)) if p] + return "\n".join(parts) or None + return str(value).strip() or None + +__all__ = [name for name in globals() if not name.startswith("_")] diff --git a/moleg_api/models.py b/moleg_api/models.py index f99635c..6cd81c4 100644 --- a/moleg_api/models.py +++ b/moleg_api/models.py @@ -9,6 +9,9 @@ from ._models import ( AdministrativeRuleArticleText, AdministrativeRuleContext, + AdjudicationHit, + AdjudicationIdentity, + AdjudicationText, AdministrativeRuleHit, AdministrativeRuleIdentity, AdministrativeRuleText, diff --git a/tests/test_adjudications_0_3_0.py b/tests/test_adjudications_0_3_0.py new file mode 100644 index 0000000..750cf09 --- /dev/null +++ b/tests/test_adjudications_0_3_0.py @@ -0,0 +1,278 @@ +"""Committee decisions and administrative appeals (WI-P7). + +The oversight question this package could not answer was "did the supervising +body actually act?" The statute was reachable and the court ruling was +reachable, but the disposition in between — a 개인정보보호위원회 의결, a 공정위 +과징금 처분, a 조세심판원 재결 — was outside the surface entirely. + +Seventeen bodies, one code path: the request shape is identical and only the +vocabulary differs. So most of what these tests protect is the normalization +(every body's spelling of the same concept lands in the same field) and the +authority labelling (none of these is precedent, and the two groups are not +interchangeable with each other either). +""" + +import json + +import pytest + +from moleg_api import AdjudicationText, MolegApi, NoResultError +from moleg_api._laws.adjudication_registry import APPEAL_BODIES, COMMITTEES, appeal_spec, committee_spec +from moleg_api._normalization.adjudications import ( + _adjudication_date, + _clean, + _flatten, + adjudication_detail, + adjudication_rows, + normalize_adjudication_identity, +) +from moleg_api.cli import main +from moleg_api.errors import UnsupportedFormatError + +PPC_ROW = { + "결정문일련번호": "9745", + "안건명": "「산업기술의 유출방지 및 보호에 관한 법률 시행규칙」 일부개정안", + "의결일": "2025.4.23.", + "결정구분": "심의ㆍ의결", +} + +PPC_DETAIL = { + "PpcService": { + "의결서": { + "결정문일련번호": "9745", + "안건명": "개인정보 침해요인 평가에 관한 건", + "의결일자": "2025.4.23.", + "주문": "원안 동의한다.", + "결정요지": "요지 본문", + "이유": "이유 본문", + "기관명": "개인정보보호위원회", + } + } +} + +DECC_DETAIL = { + "PrecService": { + "재결청": "국민권익위원회", + "처분청": "○○시장", + "행정심판례일련번호": "273321", + "사건명": "정보공개 거부처분 취소청구", + "의결일자": "2025-07-01", + "주문": "청구인의 청구를 기각한다.", + "재결요지": "재결 요지", + "이유": "재결 이유", + "청구취지": "거부처분을 취소한다.", + } +} + + +class FakeSource: + def __init__(self, *, search_payload=None, service_payload=None): + self.search_payload = search_payload or {} + self.service_payload = service_payload or {} + self.calls = [] + + def search(self, target, params): + self.calls.append(("search", target, params)) + return self.search_payload + + def service(self, target, params): + self.calls.append(("service", target, params)) + return self.service_payload + + def search_html(self, target, params): + return "" + + def post_text(self, path, params): + return "" + + +# --- registry ------------------------------------------------------------------- + + +def test_every_committee_probed_live_is_registered(): + # All twelve answered list and detail on 2026-07-19 with the shared default OC. + assert set(COMMITTEES) == { + "ppc", "ftc", "fsc", "sfc", "kcc", "nhrck", + "acr", "nlrc", "eiac", "iaciac", "oclt", "ecc", + } + + +def test_special_tribunals_are_registered_alongside_the_general_docket(): + # Omitting them would make a 소청·조세·해양안전 question return nothing from + # decc and read as absence rather than as "wrong docket". + assert set(APPEAL_BODIES) == {"decc", "acr", "adap", "tt", "kmst"} + assert APPEAL_BODIES["tt"]["target"] == "ttSpecialDecc" + + +def test_committee_and_appeal_authority_labels_are_distinct(): + committee = committee_spec("ppc")["authority"] + appeal = appeal_spec("decc")["authority"] + assert committee != appeal + assert "판결이 아니다" in committee and "판결이 아니다" in appeal + assert "행정소송" in committee and "행정소송" in appeal + + +def test_unknown_body_code_is_a_caller_mistake_not_an_empty_docket(): + # Mapping a typo to "no records" would let a misspelled agency read as an + # agency that never acted — the exact wrong conclusion for oversight. + with pytest.raises(UnsupportedFormatError): + committee_spec("nope") + with pytest.raises(UnsupportedFormatError): + appeal_spec("nope") + + +# --- normalization -------------------------------------------------------------- + + +def test_rows_are_found_by_shape_not_by_target_name(): + """The four special tribunals answer a request for `acrSpecialDecc` with rows + keyed `decc`. Keying on the requested target returns nothing, which reads as + "this tribunal has no records".""" + payload = {"Decc": {"decc": [{"특별행정심판재결례일련번호": "1"}]}} + assert adjudication_rows(payload, "acrSpecialDecc") == [{"특별행정심판재결례일련번호": "1"}] + + +def test_detail_unwraps_through_the_uigyeolseo_wrapper(): + # ppc and acr nest the document one level deeper than the other ten. + body = adjudication_detail(PPC_DETAIL) + assert body["주문"] == "원안 동의한다." + + +@pytest.mark.parametrize( + "field, key", + [("summary", "결정요지"), ("summary", "판단요지"), ("summary", "판정요지"), ("summary", "재결요지")], +) +def test_every_body_s_word_for_the_gist_lands_in_summary(field, key): + from moleg_api._normalization.adjudications import normalize_adjudication_text + + identity = normalize_adjudication_identity({}, spec=committee_spec("ppc")) + text = normalize_adjudication_text({key: "가치 있는 요지"}, identity) + assert getattr(text, field) == "가치 있는 요지" + + +def test_dotted_unpadded_dates_are_normalized(): + """Committees emit `2020.6.8.`, which the shared compact_date passes through + untouched. An un-normalized date sorts wrong, and in an oversight question the + date is the finding: when the regulator knew, and how long it then took.""" + assert _adjudication_date("2020.6.8.") == "20200608" + assert _adjudication_date("2025.4.23.") == "20250423" + assert _adjudication_date("2017.07.31") == "20170731" + assert _adjudication_date("2025-04-23") == "20250423" + assert _adjudication_date(None) is None + + +def test_literal_null_strings_are_not_passed_off_as_content(): + # Several targets return the four-character string "null" for absent fields; + # rendered as-is it becomes a plausible-looking case number in an answer. + assert _clean("null") is None + assert _clean(" ") is None + assert _clean("2023서카2662") == "2023서카2662" + + +def test_nested_field_values_are_flattened_not_repr_d(): + assert _flatten(["가", ["나", "다"]]) == "가\n나\n다" + assert _flatten({"피심정보명": "갑", "피심정보내용": "을"}) == "갑\n을" + + +def test_identity_carries_the_body_and_its_authority(): + identity = normalize_adjudication_identity(PPC_ROW, spec=committee_spec("ppc")) + assert identity.decision_id == "9745" + assert identity.body == "ppc" and identity.body_name == "개인정보보호위원회" + assert identity.source_type == "committee_decision" + assert identity.decided_on == "20250423" + + +# --- SDK ------------------------------------------------------------------------ + + +def test_committee_search_returns_candidates_with_a_load_follow_up(): + source = FakeSource(search_payload={"Ppc": {"ppc": [PPC_ROW]}}) + hits = MolegApi(source).search_committee_decisions("유출", committee="ppc") + assert len(hits) == 1 + assert hits[0].follow_up.interface == "get_committee_decision" + assert hits[0].follow_up.filters == {"body": "ppc"} + assert source.calls[0][1] == "ppc" + + +def test_rows_without_an_id_are_dropped_rather_than_returned_unloadable(): + source = FakeSource(search_payload={"Ppc": {"ppc": [{"안건명": "제목만 있는 행"}]}}) + assert MolegApi(source).search_committee_decisions(None, committee="ppc") == [] + + +def test_committee_load_normalizes_across_the_wrapper(): + result = MolegApi(FakeSource(service_payload=PPC_DETAIL)).get_committee_decision("9745", committee="ppc") + assert isinstance(result, AdjudicationText) + assert result.disposition == "원안 동의한다." + assert result.summary == "요지 본문" and result.reasoning == "이유 본문" + assert "주문" in result.text and "이유" in result.text + + +def test_appeal_load_keeps_both_agencies_apart(): + # 재결청 (who reviewed) and 처분청 (who acted) answer different oversight + # questions; collapsing them loses which agency is actually on the hook. + result = MolegApi(FakeSource(service_payload=DECC_DETAIL)).get_administrative_appeal("273321") + assert result.identity.review_agency == "국민권익위원회" + assert result.identity.respondent_agency == "○○시장" + assert result.claim == "거부처분을 취소한다." + + +def test_empty_detail_body_is_no_result_not_a_blank_document(): + source = FakeSource(service_payload={"PpcService": {"의결서": {"주문": "null", "이유": ""}}}) + with pytest.raises(NoResultError): + MolegApi(source).get_committee_decision("999999", committee="ppc") + + +def test_missing_id_is_rejected_before_a_request_is_spent(): + source = FakeSource() + with pytest.raises(NoResultError): + MolegApi(source).get_committee_decision(" ", committee="ppc") + assert source.calls == [] + + +# --- envelope ------------------------------------------------------------------- + + +def test_committee_envelope_refuses_to_be_read_as_precedent(capsys): + code = main(["get-committee-decision", "--id", "9745", "--committee", "ppc"], + api=MolegApi(FakeSource(service_payload=PPC_DETAIL))) + out = json.loads(capsys.readouterr().out) + assert code == 0 + assert out["kind"] == "committee_decision_text" + assert out["flags"]["source_type"] == "committee_decision" + assert "판례" not in out["kind"] + assert any("법원 판결이 아니다" in d for d in out["discipline"]) + + +def test_appeal_envelope_gets_its_own_kind(capsys): + # Sharing one kind with committee decisions is how a reviewable disposition + # gets treated as a settled one. + main(["get-administrative-appeal", "--id", "273321"], + api=MolegApi(FakeSource(service_payload=DECC_DETAIL))) + out = json.loads(capsys.readouterr().out) + assert out["kind"] == "administrative_appeal_text" + assert out["flags"]["source_type"] == "administrative_appeal" + + +def test_zero_hits_are_not_sold_as_the_agency_never_acting(capsys): + code = main(["search-committee-decisions", "없는질의", "--committee", "ppc"], + api=MolegApi(FakeSource(search_payload={"Ppc": {"ppc": []}}))) + out = json.loads(capsys.readouterr().out) + assert code == 0 and out["ok"] is True and out["count"] == 0 + discipline = " ".join(out["discipline"]) + assert "처분한 적 없음" in discipline and "증명이 아님" in discipline + + +def test_appeal_search_points_at_the_special_dockets(capsys): + main(["search-administrative-appeals", "소청"], + api=MolegApi(FakeSource(search_payload={"Decc": {"decc": []}}))) + out = json.loads(capsys.readouterr().out) + assert any("특별행정심판" in d for d in out["discipline"]) + + +def test_brief_drops_the_reasoning_which_is_the_bulk(capsys): + main(["get-committee-decision", "--id", "9745", "--committee", "ppc", "--brief"], + api=MolegApi(FakeSource(service_payload=PPC_DETAIL))) + out = json.loads(capsys.readouterr().out) + assert out["data"]["reasoning"] is None + assert out["data"]["summary"] == "요지 본문" + assert "reasoning" in out["flags"]["brief"]["withheld"] diff --git a/tests/test_context_efficiency_0_3_0.py b/tests/test_context_efficiency_0_3_0.py index 606ab5a..a9b7522 100644 --- a/tests/test_context_efficiency_0_3_0.py +++ b/tests/test_context_efficiency_0_3_0.py @@ -175,7 +175,11 @@ def _decision(**overrides): def test_brief_blanks_the_full_body_and_keeps_the_precis(): brief = to_brief(_decision()) - assert brief.text == "" and brief.full_text == "" + # Each field resets to its own declared default, not a blanket "": `text: str` + # is empty-string-absent, `full_text: str | None` is None-absent. Blanking a + # nullable field to "" turns "not loaded" into "loaded and empty", which a + # caller reads as a fact about the document rather than about the request. + assert brief.text == "" and brief.full_text is None assert brief.summary == "결정요지" and brief.holdings == "판시사항"