Initial backend code: src/kpaa, runtime data, requirements
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +2 -0
- LICENSE +21 -0
- NOTICE +36 -0
- data/cases.sqlite +3 -0
- data/chain_specs.yaml +171 -0
- data/guides.sqlite +3 -0
- data/guides_meta.json +22 -0
- data/keyword_intents.yaml +175 -0
- data/related_laws.yaml +323 -0
- data/seed_laws.json +20 -0
- pyproject.toml +90 -0
- requirements.txt +35 -0
- src/kpaa/__init__.py +1 -0
- src/kpaa/__main__.py +4 -0
- src/kpaa/cases/__init__.py +46 -0
- src/kpaa/cases/index.py +340 -0
- src/kpaa/cases/models.py +43 -0
- src/kpaa/cases/scraper.py +268 -0
- src/kpaa/cli.py +416 -0
- src/kpaa/cli_eval.py +182 -0
- src/kpaa/config.py +68 -0
- src/kpaa/guides/__init__.py +57 -0
- src/kpaa/guides/builder.py +140 -0
- src/kpaa/guides/extractor.py +157 -0
- src/kpaa/guides/index.py +241 -0
- src/kpaa/guides/models.py +48 -0
- src/kpaa/law_api/__init__.py +362 -0
- src/kpaa/law_api/acr.py +45 -0
- src/kpaa/law_api/admin_rule.py +72 -0
- src/kpaa/law_api/aliases.py +254 -0
- src/kpaa/law_api/article_history.py +141 -0
- src/kpaa/law_api/client.py +154 -0
- src/kpaa/law_api/constitutional.py +135 -0
- src/kpaa/law_api/endpoints.py +228 -0
- src/kpaa/law_api/english.py +40 -0
- src/kpaa/law_api/ftc.py +45 -0
- src/kpaa/law_api/interpretation.py +96 -0
- src/kpaa/law_api/jo.py +134 -0
- src/kpaa/law_api/law.py +255 -0
- src/kpaa/law_api/models.py +228 -0
- src/kpaa/law_api/nlrc.py +41 -0
- src/kpaa/law_api/oldnew.py +48 -0
- src/kpaa/law_api/ordinance.py +55 -0
- src/kpaa/law_api/parsers.py +30 -0
- src/kpaa/law_api/pipc.py +94 -0
- src/kpaa/law_api/precedent.py +62 -0
- src/kpaa/law_api/raw.py +83 -0
- src/kpaa/law_api/terms.py +46 -0
- src/kpaa/law_api/treaty.py +43 -0
- src/kpaa/llm/__init__.py +20 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
data/cases.sqlite filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
data/guides.sqlite filter=lfs diff=lfs merge=lfs -text
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 KPAA contributors
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
NOTICE
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
korean-privacy-ai-assistant (KPAA)
|
| 2 |
+
============================
|
| 3 |
+
|
| 4 |
+
This project draws inspiration from korean-law-mcp
|
| 5 |
+
(https://github.com/chrisryugj/korean-law-mcp) for the surface area of
|
| 6 |
+
법제처 OPEN API endpoints to wrap. No source code is vendored; only the
|
| 7 |
+
factual mapping of which government endpoints exist for which legal
|
| 8 |
+
domains was used as a starting point.
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
Data sources
|
| 12 |
+
------------
|
| 13 |
+
|
| 14 |
+
* 법제처 OPEN API (https://open.law.go.kr)
|
| 15 |
+
Requires an individual API key (LAW_OC) issued free at the URL above.
|
| 16 |
+
Government data, generally usable under 공공누리(KOGL).
|
| 17 |
+
|
| 18 |
+
* 개인정보보호위원회 개인정보 상담사례 (https://www.privacy.go.kr)
|
| 19 |
+
Scraped via the public AJAX endpoint
|
| 20 |
+
/front/case/onMadangListAjax.do
|
| 21 |
+
Government data presumed under 공공누리 제1유형 (출처표시); the snapshot
|
| 22 |
+
shipped with this repo is a verbatim copy of the public board content,
|
| 23 |
+
trimmed only to fit chatbot context windows. Each citation in the
|
| 24 |
+
chatbot's answers includes the case number and the privacy.go.kr URL.
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
Model
|
| 28 |
+
-----
|
| 29 |
+
|
| 30 |
+
Gemma 4 is licensed under the Gemma Terms of Use
|
| 31 |
+
https://ai.google.dev/gemma/terms
|
| 32 |
+
End users are responsible for compliance with the Gemma terms when running
|
| 33 |
+
this project. The GGUF distribution used by default is
|
| 34 |
+
bartowski/google_gemma-4-E2B-it-GGUF
|
| 35 |
+
on Hugging Face; downloading the GGUF on first run is subject to the same
|
| 36 |
+
Gemma terms.
|
data/cases.sqlite
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:a84162716f015395c4aa07d962d4e5441095840e2a7c19e07eff77a2822123aa
|
| 3 |
+
size 14213120
|
data/chain_specs.yaml
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# KPAA chain orchestrator 메타데이터 (v2.2 — 16 chain)
|
| 2 |
+
#
|
| 3 |
+
# 원작 korean-law-mcp v3.5.1 의 16개 도구 직접 노출 패턴을 *결정적 fan-out*
|
| 4 |
+
# 으로 미러링. 우리 시스템은 LLM 도구 호출 0회 — chain 안의 소스 조합은
|
| 5 |
+
# 이 yaml + chains.py 가 결정한다.
|
| 6 |
+
#
|
| 7 |
+
# 라우팅 흐름:
|
| 8 |
+
# 사용자 질문 → LLM 분류기(llm_router.py) → chain key 1개 선택 → chains.py 가
|
| 9 |
+
# 해당 spec 의 sources 를 병렬 fan-out → fetcher 들이 Excerpt 리스트 반환.
|
| 10 |
+
#
|
| 11 |
+
# chain 추가/조정 시 이 파일만 손대면 충분 (chains.py 는 spec 기반 dispatch).
|
| 12 |
+
# 단, llm_router.CHAIN_DESCRIPTIONS 와 _format_stage 의 한국어 라벨 매핑은
|
| 13 |
+
# 별도로 갱신해야 LLM 분류 정확도 + UI 표시가 따라온다.
|
| 14 |
+
#
|
| 15 |
+
# 항목 형식:
|
| 16 |
+
# key: chain 식별자 (snake_case)
|
| 17 |
+
# ko: 한국어 라벨 (UI 표시용)
|
| 18 |
+
# description: LLM 분류기 prompt 의 *언제 이 chain 을 쓰는지* 설명 (자연어)
|
| 19 |
+
# jo_hints: 본법(개인정보보호법) 조문 번호 기본값. LLM 명시 안 하면 사용
|
| 20 |
+
# sources: 호출할 fetcher 목록. _fetch_<source>() 와 매핑.
|
| 21 |
+
# - case : 상담사례 (로컬 SQLite)
|
| 22 |
+
# - guide : PIPC 발간 안내서 청크 (로컬 SQLite, 인터랙티브 큐레이션)
|
| 23 |
+
# - law : 본법 조문 (jo_targets 만큼)
|
| 24 |
+
# - related_law : 관련 법령 본문 (plan.mst_targets 라이브)
|
| 25 |
+
# - related_law_static : 정적 관련 법령 리스트 (related_laws.yaml)
|
| 26 |
+
# - pipc : PIPC 결정문
|
| 27 |
+
# - interpretation : 법령해석례
|
| 28 |
+
# - precedent : 판례
|
| 29 |
+
# - admin_rule : 행정규칙(고시)
|
| 30 |
+
|
| 31 |
+
chains:
|
| 32 |
+
|
| 33 |
+
- key: definition
|
| 34 |
+
ko: 정의·개요
|
| 35 |
+
description: |
|
| 36 |
+
법령·용어의 정의·개요·목적·원칙을 묻는 질문.
|
| 37 |
+
예: "개인정보보호법이 뭐야", "정보주체란?", "가명정보 정의", "개인정보처리자 누구를 말해?"
|
| 38 |
+
jo_hints: ["1", "2", "3"]
|
| 39 |
+
# constitutional 추가: 자기결정권·기본권 헌재 판단이 정의 질의에 깊이 보탬.
|
| 40 |
+
sources: [law, related_law, constitutional]
|
| 41 |
+
|
| 42 |
+
- key: related_laws
|
| 43 |
+
ko: 관련 법령 안내
|
| 44 |
+
description: |
|
| 45 |
+
개인정보보호법과 함께 적용되는 *다른 법률* 안내 요청.
|
| 46 |
+
예: "관련된 법은?", "다른 법 알려줘", "함께 봐야 할 법령".
|
| 47 |
+
특정 법령 본문 질의(예: '신용정보법 제32조')는 여기보다 general_practice/precedent_search 가 적합.
|
| 48 |
+
jo_hints: []
|
| 49 |
+
sources: [related_law, related_law_static]
|
| 50 |
+
|
| 51 |
+
- key: general_practice
|
| 52 |
+
ko: 일반 실무 (catch-all)
|
| 53 |
+
description: |
|
| 54 |
+
위 카테고리 어디에도 명확히 들어가지 않는 *일반 실무 질문* 의 catch-all.
|
| 55 |
+
모호하면 이걸 선택. 동의·CCTV·처리방침·유출·위탁·제3자제공·채용·아동·파기 등 어디에 둬야 할지
|
| 56 |
+
애매한 복합 질문에도 적합.
|
| 57 |
+
# catch-all 안전망: 모호 질문에도 본법 핵심 4개 조문이 항상 컨텍스트에 들어감.
|
| 58 |
+
# 제3조(원칙), 제15조(수집·이용), 제17조(제공), 제29조(안전조치).
|
| 59 |
+
jo_hints: ["3", "15", "17", "29"]
|
| 60 |
+
sources: [case, law, related_law, pipc, interpretation, precedent]
|
| 61 |
+
|
| 62 |
+
- key: precedent_search
|
| 63 |
+
ko: 판례·처벌 사례
|
| 64 |
+
description: |
|
| 65 |
+
판례·처벌 사례·위반 사례·손해배상·형사 판결 검색.
|
| 66 |
+
예: "○○ 위반 판례", "처벌 사례 알려줘", "어떤 형사 판결이 있어?"
|
| 67 |
+
jo_hints: []
|
| 68 |
+
sources: [law, related_law, pipc, precedent]
|
| 69 |
+
|
| 70 |
+
- key: safety_compliance
|
| 71 |
+
ko: 안전조치·고시
|
| 72 |
+
description: |
|
| 73 |
+
개인정보의 안전성 확보조치·기술적·관리적·물리적 보호조치·암호화·접근통제·
|
| 74 |
+
개인정보보호위원회 고시 관련.
|
| 75 |
+
예: "안전조치 기준", "암호화 어떻게", "접근권한 관리".
|
| 76 |
+
jo_hints: ["29"]
|
| 77 |
+
# three_tier 추가: 본법 제29조와 함께 *시행령 제30조 (안전성 확보조치 기준)* 자동 포함.
|
| 78 |
+
sources: [law, three_tier, admin_rule, pipc, guide]
|
| 79 |
+
|
| 80 |
+
- key: consent_collection
|
| 81 |
+
ko: 동의 수집·철회
|
| 82 |
+
description: |
|
| 83 |
+
개인정보 수집 동의·동의 철회·동의 방식(서면/전자) 관련 실무 질문.
|
| 84 |
+
예: "동의서 어떻게 받아야", "마케팅 동의 분리해야 하나", "동의 철회 절차".
|
| 85 |
+
jo_hints: ["15", "17", "22"]
|
| 86 |
+
sources: [law, case, pipc, interpretation, guide]
|
| 87 |
+
|
| 88 |
+
- key: cctv_video
|
| 89 |
+
ko: CCTV·영상정보처리기기
|
| 90 |
+
description: |
|
| 91 |
+
CCTV·영상정보처리기기 설치·안내문·녹음·운영 관련. 매장·아파트·병원·학원 등
|
| 92 |
+
현장 설치 사례.
|
| 93 |
+
예: "CCTV 어디 설치해야", "안내문 어떻게 써", "영상정보 보관기간", "녹음 같이 해도 되나".
|
| 94 |
+
jo_hints: ["25"]
|
| 95 |
+
sources: [law, admin_rule, pipc, case, guide]
|
| 96 |
+
|
| 97 |
+
- key: breach_notification
|
| 98 |
+
ko: 유출 신고·통지
|
| 99 |
+
description: |
|
| 100 |
+
개인정보 유출·분실·도난·침해 시 신고 절차·정보주체 통지·보호위원회 신고 기한.
|
| 101 |
+
예: "유출되면 며칠 안에 신고", "침해 통지 의무", "유출 신고 어디에".
|
| 102 |
+
jo_hints: ["34"]
|
| 103 |
+
sources: [law, admin_rule, pipc, interpretation, guide]
|
| 104 |
+
|
| 105 |
+
- key: processing_consignment
|
| 106 |
+
ko: 처리 위탁
|
| 107 |
+
description: |
|
| 108 |
+
개인정보 처리 위탁·외주·클라우드·수탁자 관리 관련.
|
| 109 |
+
예: "클라우드에 자료 맡겨도 되나", "위탁업체 관리 어떻게", "재위탁 가능?"
|
| 110 |
+
jo_hints: ["26"]
|
| 111 |
+
sources: [law, case, pipc, interpretation, guide]
|
| 112 |
+
|
| 113 |
+
- key: third_party_provision
|
| 114 |
+
ko: 제3자 제공·공유
|
| 115 |
+
description: |
|
| 116 |
+
개인정보 제3자 제공·공유·양도·매각·계열사 공유 관련.
|
| 117 |
+
예: "제3자 제공 동의", "계열사에 공유해도", "양수도 시 개인정보".
|
| 118 |
+
jo_hints: ["17", "18"]
|
| 119 |
+
sources: [law, case, pipc, precedent, guide]
|
| 120 |
+
|
| 121 |
+
- key: data_subject_rights
|
| 122 |
+
ko: 정보주체 권리 (열람·정정·삭제)
|
| 123 |
+
description: |
|
| 124 |
+
정보주체의 권리 행사 — 열람·정정·삭제·처리정지·이의제기.
|
| 125 |
+
예: "내 정보 보여줘 요청 받았어", "삭제 요청 거부 가능", "처리정지 절차".
|
| 126 |
+
jo_hints: ["35", "36", "37", "39"]
|
| 127 |
+
# constitutional 추가: 자기결정권 헌재 판단이 권리 범위 해석의 핵심 근거.
|
| 128 |
+
sources: [law, case, interpretation, pipc, guide, constitutional]
|
| 129 |
+
|
| 130 |
+
- key: retention_destruction
|
| 131 |
+
ko: 보유·파기
|
| 132 |
+
description: |
|
| 133 |
+
개인정보 보유기간·보존·파기·폐기 관련.
|
| 134 |
+
예: "1년 지나면 파기해야", "보관기간 어떻게 정해", "파기 방법".
|
| 135 |
+
jo_hints: ["21", "39의6"]
|
| 136 |
+
sources: [law, case, interpretation, guide]
|
| 137 |
+
|
| 138 |
+
- key: medical_health
|
| 139 |
+
ko: 의료·건강·민감정보
|
| 140 |
+
description: |
|
| 141 |
+
의료기관·병원·진료기록·건강정보·민감정보 처리 관련 (의료법 연계).
|
| 142 |
+
예: "환자 진료기록 마케팅 활용", "병원 CCTV", "의료법상 비밀유지", "건강정보 동의".
|
| 143 |
+
※ 병원 CCTV 설치 위치 같은 *시설 설치* 질문은 cctv_video 가 더 적합.
|
| 144 |
+
jo_hints: ["23"]
|
| 145 |
+
# constitutional 추가: 의료정보·민감정보 자기결정권 헌재 판단 (예: 92헌마68 등).
|
| 146 |
+
sources: [law, related_law, case, pipc, constitutional]
|
| 147 |
+
|
| 148 |
+
- key: employment_recruitment
|
| 149 |
+
ko: 채용·근로
|
| 150 |
+
description: |
|
| 151 |
+
직원 채용·이력서·면접·인사·근로 과정의 개인정보 처리 (근로기준법 연계).
|
| 152 |
+
예: "이력서에 주민번호 받아도", "직원 동의 어떻게", "퇴사자 정보 보관".
|
| 153 |
+
jo_hints: ["15", "24의2"]
|
| 154 |
+
sources: [law, related_law, case, pipc, guide]
|
| 155 |
+
|
| 156 |
+
- key: policy_disclosure
|
| 157 |
+
ko: 처리방침·공개
|
| 158 |
+
description: |
|
| 159 |
+
개인정보 처리방침 작성·게시·공개 관련.
|
| 160 |
+
예: "처리방침 만들어야", "1인 사업자도 게시 의무", "처리방침 어떻게 써".
|
| 161 |
+
jo_hints: ["30"]
|
| 162 |
+
# three_tier 추가: 본법 제30조와 함께 *시행령 제31조 (처리방침 공개)* 자동 포함.
|
| 163 |
+
sources: [law, three_tier, admin_rule, case, pipc, guide]
|
| 164 |
+
|
| 165 |
+
- key: minor_protection
|
| 166 |
+
ko: 아동·미성년 보호
|
| 167 |
+
description: |
|
| 168 |
+
만 14세 미만 아동·미성년자·청소년 개인정보 처리·법정대리인 동의 관련.
|
| 169 |
+
예: "14세 미만 동의", "어린이 정보 수집", "법정대리인 동의 받는 법".
|
| 170 |
+
jo_hints: ["22의2"]
|
| 171 |
+
sources: [law, case, pipc, interpretation, guide]
|
data/guides.sqlite
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:3b9bea8e93e7b1024a7d807f9ed66511744d02c779dfbf25e4bb1d920662600a
|
| 3 |
+
size 1601536
|
data/guides_meta.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"고정형_영상정보처리기기_설치_운영_안내서": {
|
| 3 |
+
"doc_title": "고정형 영상정보처리기기 설치·운영 안내서(공공 및 민간분야 통합본)",
|
| 4 |
+
"doc_date": "2024.12",
|
| 5 |
+
"source_pdf": "★고정형 영상정보처리기기 설치 운영 안내서(2024.12).pdf",
|
| 6 |
+
"chunks_count": 71
|
| 7 |
+
},
|
| 8 |
+
"소상공인을_위한_개인정보_보호_핸드북": {
|
| 9 |
+
"doc_title": "소상공인을 위한 개인정보 보호 핸드북",
|
| 10 |
+
"doc_date": "2024.12",
|
| 11 |
+
"source_pdf": "★소상공인을 위한 개인정보 보호 핸드북(2024.12).pdf",
|
| 12 |
+
"chunks_count": 41
|
| 13 |
+
},
|
| 14 |
+
"질의응답_모음집": {
|
| 15 |
+
"doc_title": "개인정보 질의응답 모음집",
|
| 16 |
+
"doc_date": "2025.12",
|
| 17 |
+
"source_pdf": "1. 개인정보 질의응답 모음집(2025.12.).pdf",
|
| 18 |
+
"chunks_count": 99
|
| 19 |
+
},
|
| 20 |
+
"_built_at": "2026-05-01 09:24:34",
|
| 21 |
+
"_total_chunks": 211
|
| 22 |
+
}
|
data/keyword_intents.yaml
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 의도 메타데이터 — KPAA v2 라우팅 데이터.
|
| 2 |
+
#
|
| 3 |
+
# v2부터 라우팅 1차는 **LLM 의도 분류기**(Gemma 4 E2B JSON-mode 1샷)가 담당.
|
| 4 |
+
# 이 yaml은 (1) LLM 분류 결과의 intent name → 조문 힌트(jo_hints)/소스 보강용 메타데이터,
|
| 5 |
+
# (2) LLM 호출 실패·timeout 시 rule_route fallback 의 키워드 사전 (keywords 필드).
|
| 6 |
+
#
|
| 7 |
+
# 즉 keywords 큐레이션 부담은 사라졌고 (LLM이 자연어 의도 파악) keywords는 *fallback 안전망*만 됨.
|
| 8 |
+
# intent 이름과 jo_hints/sources는 LLM 분류기 결과와 직접 연결되므로 정확하게 유지.
|
| 9 |
+
#
|
| 10 |
+
# 항목 형식:
|
| 11 |
+
# - intent: 의도 식별자 (snake_case) — llm_router.INTENT_LIST 와 1:1 매칭되어야 함
|
| 12 |
+
# keywords: rule_route fallback 매칭 키워드 (소문자/대소문자 무관, 부분일치)
|
| 13 |
+
# jo_hints: 개인정보 보호법 조문 번호 (없으면 빈 리스트)
|
| 14 |
+
# sources: 보강용 데이터 카테고리 (chain orchestrator 가 기본값을 결정하고, 여기 sources는 union으로 병합)
|
| 15 |
+
# - case : 상담사례 1,745건 (로컬 SQLite)
|
| 16 |
+
# - law : 본법(개인정보보호법) 조문 + jo_targets 명시 시 라이브
|
| 17 |
+
# - related_law : data/related_laws.yaml 의 매칭된 법령 본문 (라이브)
|
| 18 |
+
# - pipc : 개인정보보호위원회 결정문
|
| 19 |
+
# - interpretation: 법령해석례
|
| 20 |
+
# - precedent : 판례 (대법원·하급심)
|
| 21 |
+
# - admin_rule : 행정규칙(고시)
|
| 22 |
+
|
| 23 |
+
- intent: 동의_수집
|
| 24 |
+
keywords: [동의, 수집, 받아도, 받아도되, 마케팅, 광고, 홍보]
|
| 25 |
+
jo_hints: ["15", "22"]
|
| 26 |
+
sources: [case, law, pipc, precedent]
|
| 27 |
+
|
| 28 |
+
- intent: 민감정보
|
| 29 |
+
keywords: [민감정보, 진료기록, 건강, 질병, 병력, 환자, 사진, 안면, 지문, 홍채, 생체]
|
| 30 |
+
jo_hints: ["23"]
|
| 31 |
+
# 의료기관 사례 → 의료법까지 가져오게 related_law 활성. 환자/병원 키워드 매칭 시.
|
| 32 |
+
sources: [case, law, related_law, pipc, precedent]
|
| 33 |
+
|
| 34 |
+
- intent: 고유식별정보
|
| 35 |
+
keywords: [주민등록번호, 주민번호, 외국인등록번호, 운전면허, 여권번호, 고유식별, 식별번호]
|
| 36 |
+
jo_hints: ["24", "24의2"]
|
| 37 |
+
# 주민등록법까지 → related_law 활성
|
| 38 |
+
sources: [case, law, related_law, pipc, precedent]
|
| 39 |
+
|
| 40 |
+
- intent: 영상정보처리
|
| 41 |
+
keywords: [CCTV, cctv, 영상정보, 영상정보처리기기, 안내문, 안내판, 매장, 카메라, 녹음]
|
| 42 |
+
jo_hints: ["25"]
|
| 43 |
+
# CCTV 녹음은 통신비밀보호법까지 → related_law 활성
|
| 44 |
+
sources: [case, law, related_law, pipc, precedent]
|
| 45 |
+
|
| 46 |
+
- intent: 유출신고
|
| 47 |
+
keywords: [유출, 분실, 도난, 신고, 통지, 침해, 누출]
|
| 48 |
+
jo_hints: ["34"]
|
| 49 |
+
# 안전성 확보조치 기준 고시까지 → admin_rule 활성
|
| 50 |
+
sources: [case, law, pipc, admin_rule]
|
| 51 |
+
|
| 52 |
+
- intent: 처리위탁
|
| 53 |
+
keywords: [위탁, 외주, 클라우드, 처리위탁, 수탁]
|
| 54 |
+
jo_hints: ["26"]
|
| 55 |
+
sources: [case, law, pipc, admin_rule]
|
| 56 |
+
|
| 57 |
+
- intent: 제3자제공
|
| 58 |
+
keywords: [제3자, 제공, 공유, 양도, 매각]
|
| 59 |
+
jo_hints: ["17", "18"]
|
| 60 |
+
sources: [case, law, pipc, precedent]
|
| 61 |
+
|
| 62 |
+
- intent: 채용
|
| 63 |
+
keywords: [채용, 이력서, 직원, 입사지원, 면접, 인사, 근로]
|
| 64 |
+
jo_hints: ["15", "23", "24의2"]
|
| 65 |
+
sources: [case, law, pipc]
|
| 66 |
+
|
| 67 |
+
- intent: 처리방침
|
| 68 |
+
keywords: [처리방침, 개인정보처리방침, 방침, 게시, 공개]
|
| 69 |
+
jo_hints: ["30"]
|
| 70 |
+
sources: [case, law, admin_rule]
|
| 71 |
+
|
| 72 |
+
- intent: 파기
|
| 73 |
+
keywords: [파기, 보관기간, 보유기간, 보존, 폐기, 보관]
|
| 74 |
+
jo_hints: ["21"]
|
| 75 |
+
sources: [case, law, interpretation]
|
| 76 |
+
|
| 77 |
+
- intent: 아동
|
| 78 |
+
keywords: [아동, 미성년, 14세, 만14세, 어린이, 청소년, 학생]
|
| 79 |
+
jo_hints: ["22의2"]
|
| 80 |
+
sources: [case, law, pipc]
|
| 81 |
+
|
| 82 |
+
- intent: 정보주체_권리
|
| 83 |
+
keywords: [열람, 정정, 삭제, 처리정지, 권리, 본인요청]
|
| 84 |
+
jo_hints: ["35", "36", "37"]
|
| 85 |
+
sources: [case, law, pipc, interpretation]
|
| 86 |
+
|
| 87 |
+
- intent: 안전조치
|
| 88 |
+
keywords: [안전조치, 암호화, 접근권한, 접근통제, 안전성, 보안]
|
| 89 |
+
jo_hints: ["29"]
|
| 90 |
+
# 안전성 확보조치 기준 고시가 핵심 → admin_rule 우선
|
| 91 |
+
sources: [law, admin_rule, pipc]
|
| 92 |
+
|
| 93 |
+
- intent: 보호책임자
|
| 94 |
+
keywords: [개인정보보호책임자, CPO, 책임자, 보호책임자]
|
| 95 |
+
jo_hints: ["31"]
|
| 96 |
+
sources: [law, admin_rule]
|
| 97 |
+
|
| 98 |
+
# 판례·처벌 사례 요청 — "○○법 위반 판례", "처벌 사례", "어떻게 판결됐어"
|
| 99 |
+
# precedent 카테고리를 명시적으로 활성화하기 위한 intent.
|
| 100 |
+
- intent: 판례_요청
|
| 101 |
+
keywords:
|
| 102 |
+
- 판례
|
| 103 |
+
- 판결
|
| 104 |
+
- 판시
|
| 105 |
+
- 사건번호
|
| 106 |
+
- 위반
|
| 107 |
+
- 위반사례
|
| 108 |
+
- 처벌
|
| 109 |
+
- 처분
|
| 110 |
+
- 손해배상
|
| 111 |
+
- 형사
|
| 112 |
+
jo_hints: []
|
| 113 |
+
sources: [law, precedent, pipc]
|
| 114 |
+
|
| 115 |
+
# 메타/정의/일반 질문 — "○○법이 뭐야", "정의", "원칙"
|
| 116 |
+
- intent: 법령_일반
|
| 117 |
+
keywords:
|
| 118 |
+
- 개인정보보호법
|
| 119 |
+
- 개인정보 보호법
|
| 120 |
+
- 보호법
|
| 121 |
+
- 개인정보가
|
| 122 |
+
- 개인정보란
|
| 123 |
+
- 정보주체
|
| 124 |
+
- 개인정보처리자
|
| 125 |
+
- 가명정보
|
| 126 |
+
- 민감정보가
|
| 127 |
+
- 정의
|
| 128 |
+
- 원칙
|
| 129 |
+
- 개념
|
| 130 |
+
- 의미
|
| 131 |
+
- 뭐야
|
| 132 |
+
- 뭐예요
|
| 133 |
+
- 무엇
|
| 134 |
+
- 알려줘
|
| 135 |
+
- 설명
|
| 136 |
+
- 무엇인가요
|
| 137 |
+
jo_hints: ["1", "2", "3"]
|
| 138 |
+
sources: [law] # 정의 질문엔 본법 조문만 — 노이즈 방지
|
| 139 |
+
|
| 140 |
+
# 관련 법령 — 사용자가 "다른 법", "함께 적용" 등을 묻거나
|
| 141 |
+
# 정통망법·신용정보법·의료법 같은 특정 관련 법령을 언급하면 매칭.
|
| 142 |
+
# (실제 법령명별 keywords는 data/related_laws.yaml 의 각 item에 있음 — 라우터가 거기서 자동 매칭)
|
| 143 |
+
- intent: 관련_법령
|
| 144 |
+
keywords:
|
| 145 |
+
- 관련 법
|
| 146 |
+
- 관련된 법
|
| 147 |
+
- 다른 법
|
| 148 |
+
- 함께 적용
|
| 149 |
+
- 참조 법
|
| 150 |
+
- 어떤 법
|
| 151 |
+
- 적용되는 법
|
| 152 |
+
jo_hints: []
|
| 153 |
+
sources: [related_law]
|
| 154 |
+
|
| 155 |
+
# 개정 이력 — "개정 내용", "바뀐 점", "신·구법 비교" 같이 *언제·어떻게 바뀌었는지*
|
| 156 |
+
# 묻는 질문. 매칭되면 신구법비교(oldnew) 소스가 union 으로 활성화 — 어느 chain
|
| 157 |
+
# 에서든 본법 신구법 본문이 추가로 붙는다.
|
| 158 |
+
- intent: 개정_이력
|
| 159 |
+
keywords:
|
| 160 |
+
- 개정
|
| 161 |
+
- 바뀐
|
| 162 |
+
- 바뀌었
|
| 163 |
+
- 변경된
|
| 164 |
+
- 신구법
|
| 165 |
+
- 신·구
|
| 166 |
+
- 신구
|
| 167 |
+
- 개정안
|
| 168 |
+
- 개정 이력
|
| 169 |
+
- 개정사항
|
| 170 |
+
- 어떻게 바
|
| 171 |
+
jo_hints: []
|
| 172 |
+
# oldnew=법령 단위 신구비교, article_history=조문 단위 시점별 변경 이력.
|
| 173 |
+
# 둘은 *세분화 수준이 다른 보완 관계* — 함께 활성화. article_history 는
|
| 174 |
+
# plan.jo_targets 가 명시될 때만 fetch (구현 측에서 gating).
|
| 175 |
+
sources: [oldnew, law, article_history]
|
data/related_laws.yaml
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
_meta:
|
| 2 |
+
_generated_at: '2026-04-29T12:05:21'
|
| 3 |
+
_sources:
|
| 4 |
+
- https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=116
|
| 5 |
+
- https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117
|
| 6 |
+
_count: 34
|
| 7 |
+
_count_law: 12
|
| 8 |
+
_count_admin_rule: 22
|
| 9 |
+
items:
|
| 10 |
+
- name: 개인정보 보호법
|
| 11 |
+
short: 개인정보보호법
|
| 12 |
+
keywords:
|
| 13 |
+
- 개인정보 보호법
|
| 14 |
+
- 개인정보보호법
|
| 15 |
+
kind: law
|
| 16 |
+
kind_label: 개인정보 관련 법률·시행령
|
| 17 |
+
url: https://www.law.go.kr/%EB%B2%95%EB%A0%B9/%EA%B0%9C%EC%9D%B8%EC%A0%95%EB%B3%B4%20%EB%B3%B4%ED%98%B8%EB%B2%95
|
| 18 |
+
source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=116
|
| 19 |
+
mst: '270351'
|
| 20 |
+
- name: 개인정보 보호법 시행령
|
| 21 |
+
short: 개인정보보호법 시행령
|
| 22 |
+
keywords:
|
| 23 |
+
- 개인정보 보호법 시행령
|
| 24 |
+
- 개인정보보호법 시행령
|
| 25 |
+
- 보호법 시행령
|
| 26 |
+
kind: law
|
| 27 |
+
kind_label: 개인정보 관련 법률·시행령
|
| 28 |
+
url: https://www.law.go.kr/%EB%B2%95%EB%A0%B9/%EA%B0%9C%EC%9D%B8%EC%A0%95%EB%B3%B4%20%EB%B3%B4%ED%98%B8%EB%B2%95%20%EC%8B%9C%ED%96%89%EB%A0%B9
|
| 29 |
+
source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=116
|
| 30 |
+
mst: '273745'
|
| 31 |
+
- name: 신용정보의 이용 및 보호에 관한 법률
|
| 32 |
+
short: 신용정보법
|
| 33 |
+
keywords:
|
| 34 |
+
- 신용정보의 이용 및 보호에 관한 법률
|
| 35 |
+
- 신용정보법
|
| 36 |
+
kind: law
|
| 37 |
+
kind_label: 개인정보 관련 법률·시행령
|
| 38 |
+
url: https://www.law.go.kr/%EB%B2%95%EB%A0%B9/%EC%8B%A0%EC%9A%A9%EC%A0%95%EB%B3%B4%EC%9D%98%20%EC%9D%B4%EC%9A%A9%20%EB%B0%8F%20%EB%B3%B4%ED%98%B8%EC%97%90%20%EA%B4%80%ED%95%9C%20%EB%B2%95%EB%A5%A0
|
| 39 |
+
source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=116
|
| 40 |
+
mst: '260423'
|
| 41 |
+
- name: 국가인권위원회법
|
| 42 |
+
short: 인권위법
|
| 43 |
+
keywords:
|
| 44 |
+
- 국가인권위원회법
|
| 45 |
+
- 인권위법
|
| 46 |
+
kind: law
|
| 47 |
+
kind_label: 개인정보 관련 법률·시행령
|
| 48 |
+
url: https://www.law.go.kr/%EB%B2%95%EB%A0%B9/%EA%B5%AD%EA%B0%80%EC%9D%B8%EA%B6%8C%EC%9C%84%EC%9B%90%ED%9A%8C%EB%B2%95
|
| 49 |
+
source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=116
|
| 50 |
+
mst: '266711'
|
| 51 |
+
- name: 공공기관의 운영에 관한 법률
|
| 52 |
+
short: 공공기관운영법
|
| 53 |
+
keywords:
|
| 54 |
+
- 공공기관의 운영에 관한 법률
|
| 55 |
+
- 공공기관운영법
|
| 56 |
+
kind: law
|
| 57 |
+
kind_label: 개인정보 관련 법률·시행령
|
| 58 |
+
url: https://www.law.go.kr/%EB%B2%95%EB%A0%B9/%EA%B3%B5%EA%B3%B5%EA%B8%B0%EA%B4%80%EC%9D%98%20%EC%9A%B4%EC%98%81%EC%97%90%20%EA%B4%80%ED%95%9C%20%EB%B2%95%EB%A5%A0
|
| 59 |
+
source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=116
|
| 60 |
+
mst: '276057'
|
| 61 |
+
- name: 지방공기업법
|
| 62 |
+
short: 지방공기업법
|
| 63 |
+
keywords:
|
| 64 |
+
- 지방공기업법
|
| 65 |
+
kind: law
|
| 66 |
+
kind_label: 개인정보 관련 법률·시행령
|
| 67 |
+
url: https://www.law.go.kr/%EB%B2%95%EB%A0%B9/%EC%A7%80%EB%B0%A9%EA%B3%B5%EA%B8%B0%EC%97%85%EB%B2%95
|
| 68 |
+
source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=116
|
| 69 |
+
mst: '276345'
|
| 70 |
+
- name: 초·중등교육법
|
| 71 |
+
short: 초중등교육법
|
| 72 |
+
keywords:
|
| 73 |
+
- 초·중등교육법
|
| 74 |
+
- 초중등교육법
|
| 75 |
+
kind: law
|
| 76 |
+
kind_label: 개인정보 관련 법률·시행령
|
| 77 |
+
url: https://www.law.go.kr/%EB%B2%95%EB%A0%B9/%EC%B4%88%C2%B7%EC%A4%91%EB%93%B1%EA%B5%90%EC%9C%A1%EB%B2%95
|
| 78 |
+
source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=116
|
| 79 |
+
mst: ''
|
| 80 |
+
- name: 고등교육법
|
| 81 |
+
short: 고등교육법
|
| 82 |
+
keywords:
|
| 83 |
+
- 고등교육법
|
| 84 |
+
kind: law
|
| 85 |
+
kind_label: 개인정보 관련 법률·시행령
|
| 86 |
+
url: https://www.law.go.kr/%EB%B2%95%EB%A0%B9/%EA%B3%A0%EB%93%B1%EA%B5%90%EC%9C%A1%EB%B2%95
|
| 87 |
+
source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=116
|
| 88 |
+
mst: '268513'
|
| 89 |
+
- name: 주민등록법
|
| 90 |
+
short: 주민등록법
|
| 91 |
+
keywords:
|
| 92 |
+
- 주민등록법
|
| 93 |
+
kind: law
|
| 94 |
+
kind_label: 개인정보 관련 법률·시행령
|
| 95 |
+
url: https://www.law.go.kr/%EB%B2%95%EB%A0%B9/%EC%A3%BC%EB%AF%BC%EB%93%B1%EB%A1%9D%EB%B2%95
|
| 96 |
+
source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=116
|
| 97 |
+
mst: '268555'
|
| 98 |
+
- name: 전자정부법
|
| 99 |
+
short: 전자정부법
|
| 100 |
+
keywords:
|
| 101 |
+
- 전자정부법
|
| 102 |
+
kind: law
|
| 103 |
+
kind_label: 개인정보 관련 법률·시행령
|
| 104 |
+
url: https://www.law.go.kr/%EB%B2%95%EB%A0%B9/%EC%A0%84%EC%9E%90%EC%A0%95%EB%B6%80%EB%B2%95
|
| 105 |
+
source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=116
|
| 106 |
+
mst: '268103'
|
| 107 |
+
- name: 전자서명법
|
| 108 |
+
short: 전자서명법
|
| 109 |
+
keywords:
|
| 110 |
+
- 전자서명법
|
| 111 |
+
kind: law
|
| 112 |
+
kind_label: 개인정보 관련 법률·시행령
|
| 113 |
+
url: https://www.law.go.kr/%EB%B2%95%EB%A0%B9/%EC%A0%84%EC%9E%90%EC%84%9C%EB%AA%85%EB%B2%95
|
| 114 |
+
source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=116
|
| 115 |
+
mst: '236201'
|
| 116 |
+
- name: 공공기관의 정보공개에 관한 법률
|
| 117 |
+
short: 정보공개법
|
| 118 |
+
keywords:
|
| 119 |
+
- 공공기관의 정보공개에 관한 법률
|
| 120 |
+
- 정보공개법
|
| 121 |
+
kind: law
|
| 122 |
+
kind_label: 개인정보 관련 법률·시행령
|
| 123 |
+
url: https://www.law.go.kr/%EB%B2%95%EB%A0%B9/%EA%B3%B5%EA%B3%B5%EA%B8%B0%EA%B4%80%EC%9D%98%20%EC%A0%95%EB%B3%B4%EA%B3%B5%EA%B0%9C%EC%97%90%20%EA%B4%80%ED%95%9C%20%EB%B2%95%EB%A5%A0
|
| 124 |
+
source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=116
|
| 125 |
+
mst: '251019'
|
| 126 |
+
- name: 개인정보처리방법에관한고시
|
| 127 |
+
short: ''
|
| 128 |
+
keywords:
|
| 129 |
+
- 개인정보처리방법에관한고시
|
| 130 |
+
kind: admin_rule
|
| 131 |
+
kind_label: 개인정보 관련 행정규칙(고시·지침)
|
| 132 |
+
url: https://www.law.go.kr/행정규칙/개인정보처리방법에관한고시
|
| 133 |
+
source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117
|
| 134 |
+
mst: ''
|
| 135 |
+
- name: 개인정보의안전성확보조치기준
|
| 136 |
+
short: ''
|
| 137 |
+
keywords:
|
| 138 |
+
- 개인정보의안전성확보조치기준
|
| 139 |
+
kind: admin_rule
|
| 140 |
+
kind_label: 개인정보 관련 행정규칙(고시·지침)
|
| 141 |
+
url: https://www.law.go.kr/행정규칙/개인정보의안전성확보조치기준
|
| 142 |
+
source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117
|
| 143 |
+
mst: ''
|
| 144 |
+
- name: 개인정보의기술적·관리적보호조치
|
| 145 |
+
short: ''
|
| 146 |
+
keywords:
|
| 147 |
+
- 개인정보의기술적·관리적보호조치
|
| 148 |
+
kind: admin_rule
|
| 149 |
+
kind_label: 개인정보 관련 행정규칙(고시·지침)
|
| 150 |
+
url: https://www.law.go.kr/행정규칙/(개인정보보호위원회)개인정보의기술적·관리적보호조치/(2023-7,20230922)
|
| 151 |
+
source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117
|
| 152 |
+
mst: ''
|
| 153 |
+
- name: 표준개인정보보호지침
|
| 154 |
+
short: ''
|
| 155 |
+
keywords:
|
| 156 |
+
- 표준개인정보보호지침
|
| 157 |
+
kind: admin_rule
|
| 158 |
+
kind_label: 개인정보 관련 행정규칙(고시·지침)
|
| 159 |
+
url: https://www.law.go.kr/행정규칙/(개인정보보호위원회)표준개인정보보호지침
|
| 160 |
+
source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117
|
| 161 |
+
mst: ''
|
| 162 |
+
- name: 개인정보영향평가에관한고시
|
| 163 |
+
short: ''
|
| 164 |
+
keywords:
|
| 165 |
+
- 개인정보영향평가에관한고시
|
| 166 |
+
kind: admin_rule
|
| 167 |
+
kind_label: 개인정보 관련 행정규칙(고시·지침)
|
| 168 |
+
url: https://www.law.go.kr/행정규칙/(개인정보보호위원회)개인정보영향평가에관한고시
|
| 169 |
+
source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117
|
| 170 |
+
mst: ''
|
| 171 |
+
- name: 개인정보보호자율규제단체지정등에관한규정
|
| 172 |
+
short: ''
|
| 173 |
+
keywords:
|
| 174 |
+
- 개인정보보호자율규제단체지정등에관한규정
|
| 175 |
+
kind: admin_rule
|
| 176 |
+
kind_label: 개인정보 관련 행정규칙(고시·지침)
|
| 177 |
+
url: https://www.law.go.kr/행정규칙/(개인정보보호위원회)개인정보보호자율규제단체지정등에관한규정
|
| 178 |
+
source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117
|
| 179 |
+
mst: ''
|
| 180 |
+
- name: 정보보호및개인정보보호관리체계인증등에관한고시
|
| 181 |
+
short: ''
|
| 182 |
+
keywords:
|
| 183 |
+
- 정보보호및개인정보보호관리체계인증등에관한고시
|
| 184 |
+
kind: admin_rule
|
| 185 |
+
kind_label: 개인정보 관련 행정규칙(고시·지침)
|
| 186 |
+
url: https://www.law.go.kr/행정규칙/정보보호및개인정보보호관리체계인증등에관한고시
|
| 187 |
+
source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117
|
| 188 |
+
mst: ''
|
| 189 |
+
- name: 가명정보의결합및반출등에관한고시
|
| 190 |
+
short: ''
|
| 191 |
+
keywords:
|
| 192 |
+
- 가명정보의결합및반출등에관한고시
|
| 193 |
+
kind: admin_rule
|
| 194 |
+
kind_label: 개인정보 관련 행정규칙(고시·지침)
|
| 195 |
+
url: https://www.law.go.kr/행정규칙/가명정보의결합및반출등에관한고시
|
| 196 |
+
source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117
|
| 197 |
+
mst: ''
|
| 198 |
+
- name: 경찰청개인정보보호규칙
|
| 199 |
+
short: ''
|
| 200 |
+
keywords:
|
| 201 |
+
- 경찰청개인정보보호규칙
|
| 202 |
+
kind: admin_rule
|
| 203 |
+
kind_label: 개인정보 관련 행정규칙(고시·지침)
|
| 204 |
+
url: https://www.law.go.kr/행정규칙/경찰청개인정보보호규칙
|
| 205 |
+
source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117
|
| 206 |
+
mst: ''
|
| 207 |
+
- name: 경찰청영상정보처리기기운영규칙
|
| 208 |
+
short: ''
|
| 209 |
+
keywords:
|
| 210 |
+
- 경찰청영상정보처리기기운영규칙
|
| 211 |
+
kind: admin_rule
|
| 212 |
+
kind_label: 개인정보 관련 행정규칙(고시·지침)
|
| 213 |
+
url: https://www.law.go.kr/행정규칙/경찰청영상정보처리기기운영규칙
|
| 214 |
+
source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117
|
| 215 |
+
mst: ''
|
| 216 |
+
- name: 주민등록증발급신청서등의관리에관한규칙
|
| 217 |
+
short: ''
|
| 218 |
+
keywords:
|
| 219 |
+
- 주민등록증발급신청서등의관리에관한규칙
|
| 220 |
+
kind: admin_rule
|
| 221 |
+
kind_label: 개인정보 관련 행정규칙(고시·지침)
|
| 222 |
+
url: https://www.law.go.kr/행정규칙/주민등록증발급신청서등의관리에관한규칙
|
| 223 |
+
source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117
|
| 224 |
+
mst: ''
|
| 225 |
+
- name: 국토교통부개인정보보호세부지침
|
| 226 |
+
short: ''
|
| 227 |
+
keywords:
|
| 228 |
+
- 국토교통부개인정보보호세부지침
|
| 229 |
+
kind: admin_rule
|
| 230 |
+
kind_label: 개인정보 관련 행정규칙(고시·지침)
|
| 231 |
+
url: https://www.law.go.kr/행정규칙/국토교통부개인정보보호세부지침
|
| 232 |
+
source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117
|
| 233 |
+
mst: ''
|
| 234 |
+
- name: 농림축산식품부개인정보보호지침
|
| 235 |
+
short: ''
|
| 236 |
+
keywords:
|
| 237 |
+
- 농림축산식품부개인정보보호지침
|
| 238 |
+
kind: admin_rule
|
| 239 |
+
kind_label: 개인정보 관련 행정규칙(고시·지침)
|
| 240 |
+
url: https://www.law.go.kr/행정규칙/농림축산식품부개인정보보호지침
|
| 241 |
+
source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117
|
| 242 |
+
mst: ''
|
| 243 |
+
- name: 문화체육관광부개인정보보호지침
|
| 244 |
+
short: ''
|
| 245 |
+
keywords:
|
| 246 |
+
- 문화체육관광부개인정보보호지침
|
| 247 |
+
kind: admin_rule
|
| 248 |
+
kind_label: 개인정보 관련 행정규칙(고시·지침)
|
| 249 |
+
url: https://www.law.go.kr/행정규칙/문화체육관광부개인정보보호지침
|
| 250 |
+
source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117
|
| 251 |
+
mst: ''
|
| 252 |
+
- name: 법무부개인정보보호지침
|
| 253 |
+
short: ''
|
| 254 |
+
keywords:
|
| 255 |
+
- 법무부개인정보보호지침
|
| 256 |
+
kind: admin_rule
|
| 257 |
+
kind_label: 개인정보 관련 행정규칙(고시·지침)
|
| 258 |
+
url: https://www.law.go.kr/행정규칙/법무부개인정보보호지침
|
| 259 |
+
source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117
|
| 260 |
+
mst: ''
|
| 261 |
+
- name: 병무청개인정보보호관리규정
|
| 262 |
+
short: ''
|
| 263 |
+
keywords:
|
| 264 |
+
- 병무청개인정보보호관리규정
|
| 265 |
+
kind: admin_rule
|
| 266 |
+
kind_label: 개인정보 관련 행정규칙(고시·지침)
|
| 267 |
+
url: https://www.law.go.kr/행정규칙/병무청개인정보보호관리규정
|
| 268 |
+
source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117
|
| 269 |
+
mst: ''
|
| 270 |
+
- name: 병무행정정보업무관리규정
|
| 271 |
+
short: ''
|
| 272 |
+
keywords:
|
| 273 |
+
- 병무행정정보업무관리규정
|
| 274 |
+
kind: admin_rule
|
| 275 |
+
kind_label: 개인정보 관련 행정규칙(고시·지침)
|
| 276 |
+
url: https://www.law.go.kr/행정규칙/병무행정정보업무관리규정
|
| 277 |
+
source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117
|
| 278 |
+
mst: ''
|
| 279 |
+
- name: 산림청개인정보보호지침
|
| 280 |
+
short: ''
|
| 281 |
+
keywords:
|
| 282 |
+
- 산림청개인정보보호지침
|
| 283 |
+
kind: admin_rule
|
| 284 |
+
kind_label: 개인정보 관련 행정규칙(고시·지침)
|
| 285 |
+
url: https://www.law.go.kr/행정규칙/산림청개인정보보호지침
|
| 286 |
+
source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117
|
| 287 |
+
mst: ''
|
| 288 |
+
- name: 중소벤처기업부개인정보보호지침
|
| 289 |
+
short: ''
|
| 290 |
+
keywords:
|
| 291 |
+
- 중소벤처기업부개인정보보호지침
|
| 292 |
+
kind: admin_rule
|
| 293 |
+
kind_label: 개인정보 관련 행정규칙(고시·지침)
|
| 294 |
+
url: https://www.law.go.kr/행정규칙/중소벤처기업부개인정보보호지침
|
| 295 |
+
source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117
|
| 296 |
+
mst: ''
|
| 297 |
+
- name: 통계청개인정보보호지침
|
| 298 |
+
short: ''
|
| 299 |
+
keywords:
|
| 300 |
+
- 통계청개인정보보호지침
|
| 301 |
+
kind: admin_rule
|
| 302 |
+
kind_label: 개인정보 관련 행정규칙(고시·지침)
|
| 303 |
+
url: https://www.law.go.kr/행정규칙/통계청개인정보보호지침
|
| 304 |
+
source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117
|
| 305 |
+
mst: ''
|
| 306 |
+
- name: 행정안전부개인정보보호지침
|
| 307 |
+
short: ''
|
| 308 |
+
keywords:
|
| 309 |
+
- 행정안전부개인정보보호지침
|
| 310 |
+
kind: admin_rule
|
| 311 |
+
kind_label: 개인정보 관련 행정규칙(고시·지침)
|
| 312 |
+
url: https://www.law.go.kr/행정규칙/행정안전부개인정보보호지침
|
| 313 |
+
source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117
|
| 314 |
+
mst: ''
|
| 315 |
+
- name: 환경부개인정보보호지침
|
| 316 |
+
short: ''
|
| 317 |
+
keywords:
|
| 318 |
+
- 환경부개인정보보호지침
|
| 319 |
+
kind: admin_rule
|
| 320 |
+
kind_label: 개인정보 관련 행정규칙(고시·지침)
|
| 321 |
+
url: https://www.law.go.kr/행정규칙/환경부개인정보보호지침
|
| 322 |
+
source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117
|
| 323 |
+
mst: ''
|
data/seed_laws.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"_doc": "라이브 검증 2026-04-29 — 법제처 OPEN API search_law 결과 캡쳐",
|
| 3 |
+
"personal_info_protection_act": {
|
| 4 |
+
"name": "개인정보 보호법",
|
| 5 |
+
"name_short": "개인정보보호법",
|
| 6 |
+
"mst": "270351",
|
| 7 |
+
"law_id": "011357",
|
| 8 |
+
"type_name": "법률",
|
| 9 |
+
"department": "개인정보보호위원회",
|
| 10 |
+
"promulgate_date": "20250401",
|
| 11 |
+
"enforce_date": "20251002"
|
| 12 |
+
},
|
| 13 |
+
"personal_info_protection_act_enforcement_decree": {
|
| 14 |
+
"name": "개인정보 보호법 시행령",
|
| 15 |
+
"mst": "273745",
|
| 16 |
+
"type_name": "대통령령",
|
| 17 |
+
"department": "개인정보보호위원회",
|
| 18 |
+
"enforce_date": "20251002"
|
| 19 |
+
}
|
| 20 |
+
}
|
pyproject.toml
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["hatchling>=1.21"]
|
| 3 |
+
build-backend = "hatchling.build"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "korean-privacy-ai-assistant"
|
| 7 |
+
version = "0.1.0"
|
| 8 |
+
description = "Korean Privacy Law (개인정보보호법) consultation chatbot for individuals, SMBs, and small clinics"
|
| 9 |
+
readme = "README.md"
|
| 10 |
+
license = "MIT"
|
| 11 |
+
requires-python = ">=3.11"
|
| 12 |
+
keywords = ["korean-law", "privacy", "개인정보보호법", "rag", "chatbot", "gemma"]
|
| 13 |
+
classifiers = [
|
| 14 |
+
"Development Status :: 3 - Alpha",
|
| 15 |
+
"Intended Audience :: End Users/Desktop",
|
| 16 |
+
"License :: OSI Approved :: MIT License",
|
| 17 |
+
"Natural Language :: Korean",
|
| 18 |
+
"Operating System :: OS Independent",
|
| 19 |
+
"Programming Language :: Python :: 3.11",
|
| 20 |
+
"Programming Language :: Python :: 3.12",
|
| 21 |
+
]
|
| 22 |
+
dependencies = [
|
| 23 |
+
"httpx[http2]>=0.27",
|
| 24 |
+
"pydantic>=2.6",
|
| 25 |
+
"pydantic-settings>=2.2",
|
| 26 |
+
"lxml>=5",
|
| 27 |
+
"diskcache>=5.6",
|
| 28 |
+
"tenacity>=8",
|
| 29 |
+
"platformdirs>=4",
|
| 30 |
+
"fastapi>=0.110",
|
| 31 |
+
"uvicorn[standard]>=0.27",
|
| 32 |
+
"python-dotenv>=1",
|
| 33 |
+
"pyyaml>=6",
|
| 34 |
+
"huggingface-hub>=0.23",
|
| 35 |
+
"tqdm>=4.66",
|
| 36 |
+
"sse-starlette>=2.1",
|
| 37 |
+
"truststore>=0.10",
|
| 38 |
+
]
|
| 39 |
+
|
| 40 |
+
[project.optional-dependencies]
|
| 41 |
+
llm = ["llama-cpp-python>=0.3"]
|
| 42 |
+
# HF Spaces (ZeroGPU) 데모 배포용 — 같은 가중치 `google/gemma-4-E2B-it` 를
|
| 43 |
+
# transformers + @spaces.GPU 로 돌린다. 로컬 사용자는 설치 불필요.
|
| 44 |
+
hf = [
|
| 45 |
+
"gradio>=5.0",
|
| 46 |
+
"transformers>=4.45",
|
| 47 |
+
"torch>=2.4",
|
| 48 |
+
"accelerate>=0.34",
|
| 49 |
+
"spaces>=0.30",
|
| 50 |
+
]
|
| 51 |
+
# 빌드 타임 docling 의존성 — 두 용도가 같은 패키지 셋을 공유:
|
| 52 |
+
# 1) PIPC 결정문 별지(이미지) → markdown OCR. 옵트인:
|
| 53 |
+
# pip install -e ".[pipc-ocr]"
|
| 54 |
+
# export KPAA_PIPC_OCR=1
|
| 55 |
+
# EasyOCR 한국어 모델로 한글 별지 정확도 ↑ (docling 기본 ocrmac 대비).
|
| 56 |
+
# 2) `kpaa extract-guide` (안내서 PDF → markdown). born-digital 이라 OCR 없이
|
| 57 |
+
# 텍스트 직접 추출 → easyocr 가중치 다운로드는 발생하지 않음. 이후 청킹은
|
| 58 |
+
# 사용자 + Claude 인터랙티브 큐레이션 → `data/guide/chunks/*.jsonl`.
|
| 59 |
+
# 런타임(검색·답변)은 sqlite3 만 필요 — 일반 사용자는 이 extra 설치 불필요.
|
| 60 |
+
pipc-ocr = ["docling>=2.0", "easyocr>=1.7"]
|
| 61 |
+
dev = [
|
| 62 |
+
"pytest>=8",
|
| 63 |
+
"pytest-asyncio>=0.23",
|
| 64 |
+
"vcrpy>=6",
|
| 65 |
+
"ruff>=0.4",
|
| 66 |
+
"invoke>=2.2",
|
| 67 |
+
]
|
| 68 |
+
|
| 69 |
+
[project.scripts]
|
| 70 |
+
kpaa = "kpaa.cli:main"
|
| 71 |
+
|
| 72 |
+
[project.urls]
|
| 73 |
+
Homepage = "https://github.com/sz1-kca/korean-privacy-ai-assistant"
|
| 74 |
+
Issues = "https://github.com/sz1-kca/korean-privacy-ai-assistant/issues"
|
| 75 |
+
|
| 76 |
+
[tool.hatch.build.targets.wheel]
|
| 77 |
+
packages = ["src/kpaa"]
|
| 78 |
+
|
| 79 |
+
[tool.ruff]
|
| 80 |
+
line-length = 100
|
| 81 |
+
target-version = "py311"
|
| 82 |
+
|
| 83 |
+
[tool.ruff.lint]
|
| 84 |
+
select = ["E", "F", "W", "I", "B", "UP"]
|
| 85 |
+
ignore = ["E501"]
|
| 86 |
+
# SIM 룰은 가독성 호불호가 갈려서 select에서 제외 (한국어 분기 코드 가독성 우선)
|
| 87 |
+
|
| 88 |
+
[tool.pytest.ini_options]
|
| 89 |
+
asyncio_mode = "auto"
|
| 90 |
+
testpaths = ["tests"]
|
requirements.txt
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Hugging Face Spaces (Gradio SDK + ZeroGPU) 빌드용 평탄화 의존성.
|
| 2 |
+
# 로컬 노트북 사용자는 이 파일을 쓰지 말고 `pip install -e ".[llm]"` (또는 pyproject.toml) 사용.
|
| 3 |
+
# 이 파일은 HF Spaces 가 자동으로 `pip install -r requirements.txt` 로 처리.
|
| 4 |
+
#
|
| 5 |
+
# llama-cpp-python 은 *의도적으로* 빠짐 — HF Spaces 에서는 ZeroGPU 백엔드
|
| 6 |
+
# (transformers + @spaces.GPU) 가 활성화되므로 필요 없음.
|
| 7 |
+
|
| 8 |
+
# ── core (pyproject.toml dependencies 와 동기화) ──
|
| 9 |
+
httpx[http2]>=0.27
|
| 10 |
+
pydantic>=2.6
|
| 11 |
+
pydantic-settings>=2.2
|
| 12 |
+
lxml>=5
|
| 13 |
+
diskcache>=5.6
|
| 14 |
+
tenacity>=8
|
| 15 |
+
platformdirs>=4
|
| 16 |
+
fastapi>=0.110
|
| 17 |
+
uvicorn[standard]>=0.27
|
| 18 |
+
python-dotenv>=1
|
| 19 |
+
pyyaml>=6
|
| 20 |
+
huggingface-hub>=1.3 # transformers 5.x 가 요구. Gradio 5.20+ 은 HfFolder 의존 제거.
|
| 21 |
+
tqdm>=4.66
|
| 22 |
+
sse-starlette>=2.1
|
| 23 |
+
truststore>=0.10
|
| 24 |
+
|
| 25 |
+
# ── HF Spaces (ZeroGPU + Gradio) ──
|
| 26 |
+
gradio>=5.0
|
| 27 |
+
transformers>=5.0 # Gemma 4 토크나이저는 5.x 부터 (extra_special_tokens 리스트 포맷)
|
| 28 |
+
torch>=2.4
|
| 29 |
+
accelerate>=0.34
|
| 30 |
+
spaces>=0.30
|
| 31 |
+
|
| 32 |
+
# ── 패키지 자체 ──
|
| 33 |
+
# HF Spaces 는 requirements.txt 처리 시점에 app 파일이 아직 /home/user/app 에
|
| 34 |
+
# mount 되어 있지 않아 `-e .` 가 동작하지 않는다. 대신 app.py 에서
|
| 35 |
+
# `src/` 를 sys.path 에 prepend 한다.
|
src/kpaa/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
__version__ = "0.1.0"
|
src/kpaa/__main__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from kpaa.cli import main
|
| 2 |
+
|
| 3 |
+
if __name__ == "__main__":
|
| 4 |
+
raise SystemExit(main())
|
src/kpaa/cases/__init__.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""개인정보보호위원회 상담사례 (privacy.go.kr) — 로컬 SQLite FTS5 인덱스.
|
| 2 |
+
|
| 3 |
+
from kpaa.cases import CasesIndex
|
| 4 |
+
idx = CasesIndex.default()
|
| 5 |
+
hits = idx.search("CCTV 안내문구", k=3)
|
| 6 |
+
for h in hits:
|
| 7 |
+
print(h.citation(), h.title)
|
| 8 |
+
|
| 9 |
+
리포 동봉 스냅샷은 `data/cases.sqlite`. 갱신은 `kpaa refresh-cases`.
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
from kpaa.cases.index import (
|
| 16 |
+
count as _count,
|
| 17 |
+
)
|
| 18 |
+
from kpaa.cases.index import (
|
| 19 |
+
default_db_path,
|
| 20 |
+
default_meta_path,
|
| 21 |
+
)
|
| 22 |
+
from kpaa.cases.index import (
|
| 23 |
+
search as _search,
|
| 24 |
+
)
|
| 25 |
+
from kpaa.cases.models import Case
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class CasesIndex:
|
| 29 |
+
"""사용자 친화적 진입점."""
|
| 30 |
+
|
| 31 |
+
def __init__(self, db_path: Path | None = None) -> None:
|
| 32 |
+
self.db_path = db_path or default_db_path()
|
| 33 |
+
|
| 34 |
+
@classmethod
|
| 35 |
+
def default(cls) -> CasesIndex:
|
| 36 |
+
return cls()
|
| 37 |
+
|
| 38 |
+
@property
|
| 39 |
+
def total(self) -> int:
|
| 40 |
+
return _count(self.db_path)
|
| 41 |
+
|
| 42 |
+
def search(self, query: str, *, k: int = 5) -> list[Case]:
|
| 43 |
+
return _search(query, k=k, db_path=self.db_path)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
__all__ = ["CasesIndex", "Case", "default_db_path", "default_meta_path"]
|
src/kpaa/cases/index.py
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SQLite FTS5 인덱스 — 한국어 본문은 `unicode61` tokenizer 사용.
|
| 2 |
+
|
| 3 |
+
(trigram도 시도했으나 길이 2 한국어 단어 — "병원", "환자", "동의", "유출",
|
| 4 |
+
"신고" — 가 인덱싱되지 않아 부적합. unicode61은 띄어쓰기 단위 토큰화로
|
| 5 |
+
한국어 검색에 안정적이며 SQLite 표준 기본값이라 추가 의존 없음.)
|
| 6 |
+
|
| 7 |
+
DB 스키마:
|
| 8 |
+
cases(ntt_id PK, ntt_no, title, body, summary, type_code, type_label,
|
| 9 |
+
category1, category2, category3, reg_dt, case_year,
|
| 10 |
+
source_note, detail_url)
|
| 11 |
+
cases_fts (FTS5 가상테이블, 검색용)
|
| 12 |
+
|
| 13 |
+
검색 결과 정렬은 `bm25(cases_fts)` 기본. 더 높은 점수 = 덜 관련 → 오름차순.
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import json
|
| 18 |
+
import sqlite3
|
| 19 |
+
from collections.abc import Iterable
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
|
| 22 |
+
from kpaa.cases.models import Case
|
| 23 |
+
|
| 24 |
+
SCHEMA_SQL = """
|
| 25 |
+
CREATE TABLE IF NOT EXISTS cases (
|
| 26 |
+
ntt_id TEXT PRIMARY KEY,
|
| 27 |
+
ntt_no TEXT,
|
| 28 |
+
title TEXT,
|
| 29 |
+
body TEXT,
|
| 30 |
+
summary TEXT,
|
| 31 |
+
type_code TEXT,
|
| 32 |
+
type_label TEXT,
|
| 33 |
+
category1 TEXT,
|
| 34 |
+
category2 TEXT,
|
| 35 |
+
category3 TEXT,
|
| 36 |
+
reg_dt TEXT,
|
| 37 |
+
case_year TEXT,
|
| 38 |
+
source_note TEXT,
|
| 39 |
+
detail_url TEXT,
|
| 40 |
+
chunk_context TEXT
|
| 41 |
+
);
|
| 42 |
+
|
| 43 |
+
CREATE VIRTUAL TABLE IF NOT EXISTS cases_fts USING fts5(
|
| 44 |
+
ntt_id UNINDEXED,
|
| 45 |
+
title,
|
| 46 |
+
body,
|
| 47 |
+
category UNINDEXED,
|
| 48 |
+
tokenize = "unicode61 remove_diacritics 2"
|
| 49 |
+
);
|
| 50 |
+
"""
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def default_db_path() -> Path:
|
| 54 |
+
"""리포 동봉 스냅샷 경로(`data/cases.sqlite`)."""
|
| 55 |
+
return _data_dir() / "cases.sqlite"
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def default_meta_path() -> Path:
|
| 59 |
+
return _data_dir() / "cases_meta.json"
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def default_jsonl_path() -> Path:
|
| 63 |
+
"""기본 JSONL export 경로 — `default_db_path()`와 같은 폴더."""
|
| 64 |
+
return default_db_path().parent / "cases_chunks.jsonl"
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
# JSONL export 컬럼 — 변경 시 cases_chunks.jsonl 스키마가 바뀜
|
| 68 |
+
_EXPORT_COLUMNS = (
|
| 69 |
+
"ntt_id", "ntt_no", "title", "summary", "body",
|
| 70 |
+
"type_code", "type_label",
|
| 71 |
+
"category1", "category2", "category3",
|
| 72 |
+
"reg_dt", "case_year", "source_note", "detail_url",
|
| 73 |
+
"chunk_context",
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _data_dir() -> Path:
|
| 78 |
+
"""레포 루트의 data/ 폴더 우선, 없으면 사용자 캐시."""
|
| 79 |
+
repo_data = Path(__file__).resolve().parents[3] / "data"
|
| 80 |
+
if repo_data.exists():
|
| 81 |
+
return repo_data
|
| 82 |
+
from kpaa.config import get_settings
|
| 83 |
+
|
| 84 |
+
p = get_settings().cache_root / "cases"
|
| 85 |
+
p.mkdir(parents=True, exist_ok=True)
|
| 86 |
+
return p
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _connect(db_path: Path) -> sqlite3.Connection:
|
| 90 |
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
| 91 |
+
conn = sqlite3.connect(db_path)
|
| 92 |
+
conn.row_factory = sqlite3.Row
|
| 93 |
+
conn.executescript(SCHEMA_SQL)
|
| 94 |
+
return conn
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _fts_body(case: Case) -> str:
|
| 98 |
+
"""FTS body 컬럼: chunk_context가 있으면 body 앞에 prepend (Anthropic Contextual Retrieval)."""
|
| 99 |
+
if case.chunk_context:
|
| 100 |
+
return f"{case.chunk_context}\n\n{case.body}"
|
| 101 |
+
return case.body
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def export_jsonl(
|
| 105 |
+
*, db_path: Path | None = None, out_path: Path | None = None
|
| 106 |
+
) -> int:
|
| 107 |
+
"""cases.sqlite를 JSONL로 export — `cases_chunks.jsonl` 동기화용.
|
| 108 |
+
|
| 109 |
+
각 라인 = 1 사례, ntt_id 정수 오름차순, 모든 컬럼(chunk_context 포함).
|
| 110 |
+
`build_index()` / `upsert_cases()` 끝에서 자동 호출되므로 DB와 jsonl이
|
| 111 |
+
항상 동기화. chunk_context를 직접 SQL UPDATE한 경우엔 수동으로 한번 더
|
| 112 |
+
호출하면 jsonl에 반영됨.
|
| 113 |
+
|
| 114 |
+
out_path 미지정 시 `db_path` 옆에 `cases_chunks.jsonl`로 저장.
|
| 115 |
+
"""
|
| 116 |
+
path = db_path or default_db_path()
|
| 117 |
+
if not path.exists():
|
| 118 |
+
return 0
|
| 119 |
+
out = out_path or (path.parent / "cases_chunks.jsonl")
|
| 120 |
+
out.parent.mkdir(parents=True, exist_ok=True)
|
| 121 |
+
|
| 122 |
+
conn = _connect(path)
|
| 123 |
+
n = 0
|
| 124 |
+
try:
|
| 125 |
+
cur = conn.execute(
|
| 126 |
+
f"SELECT {', '.join(_EXPORT_COLUMNS)} FROM cases "
|
| 127 |
+
"ORDER BY CAST(ntt_id AS INTEGER)"
|
| 128 |
+
)
|
| 129 |
+
with out.open("w", encoding="utf-8") as f:
|
| 130 |
+
for r in cur:
|
| 131 |
+
rec = {c: (r[c] or "") for c in _EXPORT_COLUMNS}
|
| 132 |
+
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
| 133 |
+
n += 1
|
| 134 |
+
finally:
|
| 135 |
+
conn.close()
|
| 136 |
+
return n
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def build_index(cases: Iterable[Case], *, db_path: Path) -> None:
|
| 140 |
+
"""전체 다시 빌드 — 단순화를 위해 기존 데이터 wipe 후 재삽입.
|
| 141 |
+
|
| 142 |
+
빌드 후 `cases_chunks.jsonl`을 자동 export해 DB와 동기화.
|
| 143 |
+
"""
|
| 144 |
+
conn = _connect(db_path)
|
| 145 |
+
try:
|
| 146 |
+
with conn:
|
| 147 |
+
conn.execute("DELETE FROM cases")
|
| 148 |
+
conn.execute("DELETE FROM cases_fts")
|
| 149 |
+
for case in cases:
|
| 150 |
+
conn.execute(
|
| 151 |
+
"""INSERT INTO cases
|
| 152 |
+
(ntt_id, ntt_no, title, body, summary,
|
| 153 |
+
type_code, type_label, category1, category2, category3,
|
| 154 |
+
reg_dt, case_year, source_note, detail_url, chunk_context)
|
| 155 |
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
| 156 |
+
(
|
| 157 |
+
case.ntt_id, case.ntt_no, case.title, case.body, case.summary,
|
| 158 |
+
case.type_code, case.type_label,
|
| 159 |
+
case.category1, case.category2, case.category3,
|
| 160 |
+
case.reg_dt, case.case_year, case.source_note, case.detail_url,
|
| 161 |
+
case.chunk_context,
|
| 162 |
+
),
|
| 163 |
+
)
|
| 164 |
+
category = " > ".join(filter(None, (case.category1, case.category2, case.category3)))
|
| 165 |
+
conn.execute(
|
| 166 |
+
"INSERT INTO cases_fts (ntt_id, title, body, category) VALUES (?,?,?,?)",
|
| 167 |
+
(case.ntt_id, case.title, _fts_body(case), category),
|
| 168 |
+
)
|
| 169 |
+
conn.execute("VACUUM")
|
| 170 |
+
finally:
|
| 171 |
+
conn.close()
|
| 172 |
+
export_jsonl(db_path=db_path)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def upsert_cases(cases: Iterable[Case], *, db_path: Path) -> int:
|
| 176 |
+
"""증분 갱신: 기존 ntt_id 충돌 시 덮어씀. jsonl도 자동 동기화."""
|
| 177 |
+
conn = _connect(db_path)
|
| 178 |
+
n = 0
|
| 179 |
+
try:
|
| 180 |
+
with conn:
|
| 181 |
+
for case in cases:
|
| 182 |
+
# 기존 ntt_id 행 삭제 후 재삽입 (FTS5 재인덱싱 위함)
|
| 183 |
+
conn.execute("DELETE FROM cases WHERE ntt_id = ?", (case.ntt_id,))
|
| 184 |
+
conn.execute("DELETE FROM cases_fts WHERE ntt_id = ?", (case.ntt_id,))
|
| 185 |
+
conn.execute(
|
| 186 |
+
"""INSERT INTO cases
|
| 187 |
+
(ntt_id, ntt_no, title, body, summary,
|
| 188 |
+
type_code, type_label, category1, category2, category3,
|
| 189 |
+
reg_dt, case_year, source_note, detail_url, chunk_context)
|
| 190 |
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
| 191 |
+
(
|
| 192 |
+
case.ntt_id, case.ntt_no, case.title, case.body, case.summary,
|
| 193 |
+
case.type_code, case.type_label,
|
| 194 |
+
case.category1, case.category2, case.category3,
|
| 195 |
+
case.reg_dt, case.case_year, case.source_note, case.detail_url,
|
| 196 |
+
case.chunk_context,
|
| 197 |
+
),
|
| 198 |
+
)
|
| 199 |
+
category = " > ".join(filter(None, (case.category1, case.category2, case.category3)))
|
| 200 |
+
conn.execute(
|
| 201 |
+
"INSERT INTO cases_fts (ntt_id, title, body, category) VALUES (?,?,?,?)",
|
| 202 |
+
(case.ntt_id, case.title, _fts_body(case), category),
|
| 203 |
+
)
|
| 204 |
+
n += 1
|
| 205 |
+
finally:
|
| 206 |
+
conn.close()
|
| 207 |
+
if n:
|
| 208 |
+
export_jsonl(db_path=db_path)
|
| 209 |
+
return n
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def search(
|
| 213 |
+
query: str, *, k: int = 5, db_path: Path | None = None
|
| 214 |
+
) -> list[Case]:
|
| 215 |
+
"""trigram FTS5 검색.
|
| 216 |
+
|
| 217 |
+
Args:
|
| 218 |
+
query: 한국어 키워드 (단일 단어 또는 다중 단어)
|
| 219 |
+
k: 최대 결과 수
|
| 220 |
+
"""
|
| 221 |
+
path = db_path or default_db_path()
|
| 222 |
+
if not path.exists():
|
| 223 |
+
return []
|
| 224 |
+
if not query.strip():
|
| 225 |
+
return []
|
| 226 |
+
conn = _connect(path)
|
| 227 |
+
try:
|
| 228 |
+
# 따옴표/구두점을 sanitize 후 토큰 단위로 분리.
|
| 229 |
+
# 다중 토큰은 OR로 결합 → 한 단어만 일치해도 후보로 끌어옴.
|
| 230 |
+
# 단일 토큰은 그대로 phrase 처리.
|
| 231 |
+
safe = "".join(ch if ch.isalnum() or ord(ch) > 127 else " " for ch in query)
|
| 232 |
+
tokens = [t for t in safe.split() if t]
|
| 233 |
+
if not tokens:
|
| 234 |
+
return []
|
| 235 |
+
# prefix matching(`token*`)을 모든 토큰에 적용 — 한국어 조사 결합
|
| 236 |
+
# ("유출된", "검색사이트에") 도 매칭되게 함.
|
| 237 |
+
if len(tokens) == 1:
|
| 238 |
+
fts_query = f"{tokens[0]}*"
|
| 239 |
+
else:
|
| 240 |
+
fts_query = " OR ".join(f"{t}*" for t in tokens)
|
| 241 |
+
cur = conn.execute(
|
| 242 |
+
"""SELECT c.* FROM cases_fts f
|
| 243 |
+
JOIN cases c ON c.ntt_id = f.ntt_id
|
| 244 |
+
WHERE cases_fts MATCH ?
|
| 245 |
+
ORDER BY bm25(cases_fts) ASC
|
| 246 |
+
LIMIT ?""",
|
| 247 |
+
(fts_query, k),
|
| 248 |
+
)
|
| 249 |
+
rows = cur.fetchall()
|
| 250 |
+
finally:
|
| 251 |
+
conn.close()
|
| 252 |
+
return [
|
| 253 |
+
Case(
|
| 254 |
+
ntt_id=r["ntt_id"], ntt_no=r["ntt_no"],
|
| 255 |
+
title=r["title"], summary=r["summary"], body=r["body"],
|
| 256 |
+
type_code=r["type_code"], type_label=r["type_label"],
|
| 257 |
+
category1=r["category1"], category2=r["category2"], category3=r["category3"],
|
| 258 |
+
reg_dt=r["reg_dt"], case_year=r["case_year"],
|
| 259 |
+
source_note=r["source_note"], detail_url=r["detail_url"],
|
| 260 |
+
chunk_context=(r["chunk_context"] if "chunk_context" in r.keys() else "") or "",
|
| 261 |
+
)
|
| 262 |
+
for r in rows
|
| 263 |
+
]
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
def count(db_path: Path | None = None) -> int:
|
| 267 |
+
path = db_path or default_db_path()
|
| 268 |
+
if not path.exists():
|
| 269 |
+
return 0
|
| 270 |
+
conn = _connect(path)
|
| 271 |
+
try:
|
| 272 |
+
cur = conn.execute("SELECT COUNT(*) FROM cases")
|
| 273 |
+
return cur.fetchone()[0]
|
| 274 |
+
finally:
|
| 275 |
+
conn.close()
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def list_all_cases(*, db_path: Path | None = None) -> list[Case]:
|
| 279 |
+
"""기존 cases 테이블에서 모든 행을 Case 객체로 반환 (FTS5 재빌드 등에 사용)."""
|
| 280 |
+
path = db_path or default_db_path()
|
| 281 |
+
if not path.exists():
|
| 282 |
+
return []
|
| 283 |
+
conn = _connect(path)
|
| 284 |
+
try:
|
| 285 |
+
cur = conn.execute("SELECT * FROM cases")
|
| 286 |
+
rows = cur.fetchall()
|
| 287 |
+
finally:
|
| 288 |
+
conn.close()
|
| 289 |
+
return [
|
| 290 |
+
Case(
|
| 291 |
+
ntt_id=r["ntt_id"], ntt_no=r["ntt_no"],
|
| 292 |
+
title=r["title"], summary=r["summary"], body=r["body"],
|
| 293 |
+
type_code=r["type_code"], type_label=r["type_label"],
|
| 294 |
+
category1=r["category1"], category2=r["category2"], category3=r["category3"],
|
| 295 |
+
reg_dt=r["reg_dt"], case_year=r["case_year"],
|
| 296 |
+
source_note=r["source_note"], detail_url=r["detail_url"],
|
| 297 |
+
chunk_context=(r["chunk_context"] if "chunk_context" in r.keys() else "") or "",
|
| 298 |
+
)
|
| 299 |
+
for r in rows
|
| 300 |
+
]
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
def rebuild_fts(*, db_path: Path | None = None) -> int:
|
| 304 |
+
"""cases 테이블 데이터로 cases_fts만 재생성. tokenizer 변경 후 사용.
|
| 305 |
+
|
| 306 |
+
스크래이프 없이 빠르게 인덱스만 갱신. chunk_context 일괄 UPDATE 후
|
| 307 |
+
호출하면 jsonl도 자동 동기화.
|
| 308 |
+
"""
|
| 309 |
+
path = db_path or default_db_path()
|
| 310 |
+
cases = list_all_cases(db_path=path)
|
| 311 |
+
if not cases:
|
| 312 |
+
return 0
|
| 313 |
+
conn = _connect(path)
|
| 314 |
+
try:
|
| 315 |
+
with conn:
|
| 316 |
+
conn.execute("DROP TABLE IF EXISTS cases_fts")
|
| 317 |
+
# _connect가 schema를 다시 생성 — close & re-open 으로 새 schema 적용
|
| 318 |
+
finally:
|
| 319 |
+
conn.close()
|
| 320 |
+
conn = _connect(path)
|
| 321 |
+
try:
|
| 322 |
+
with conn:
|
| 323 |
+
for case in cases:
|
| 324 |
+
category = " > ".join(filter(None, (case.category1, case.category2, case.category3)))
|
| 325 |
+
conn.execute(
|
| 326 |
+
"INSERT INTO cases_fts (ntt_id, title, body, category) VALUES (?,?,?,?)",
|
| 327 |
+
(case.ntt_id, case.title, _fts_body(case), category),
|
| 328 |
+
)
|
| 329 |
+
conn.execute("VACUUM")
|
| 330 |
+
finally:
|
| 331 |
+
conn.close()
|
| 332 |
+
export_jsonl(db_path=path)
|
| 333 |
+
return len(cases)
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
__all__ = [
|
| 337 |
+
"build_index", "upsert_cases", "search", "count",
|
| 338 |
+
"list_all_cases", "rebuild_fts", "export_jsonl",
|
| 339 |
+
"default_db_path", "default_meta_path", "default_jsonl_path",
|
| 340 |
+
]
|
src/kpaa/cases/models.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel, ConfigDict
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class Case(BaseModel):
|
| 7 |
+
"""개인정보보호위원회 상담사례 1건.
|
| 8 |
+
|
| 9 |
+
privacy.go.kr `/front/case/onMadangListAjax.do`의 `resultDocuments` 1개 항목
|
| 10 |
+
에 1:1 매핑.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
model_config = ConfigDict(frozen=True)
|
| 14 |
+
|
| 15 |
+
ntt_no: str # 케이스 번호 (예: "311")
|
| 16 |
+
ntt_id: str # 내부 ID (예: "10561")
|
| 17 |
+
title: str # NTT_SJ — 질문(제목)
|
| 18 |
+
summary: str # NTT_CN — 짧은 요약
|
| 19 |
+
body: str # NTT_CN_FULL — 답변 본문 (HTML 엔티티 디코딩 후)
|
| 20 |
+
type_code: str # NTT_TYPE — "INT" | "GUI"
|
| 21 |
+
type_label: str # NTT_TYPE_NM — 한글 라벨
|
| 22 |
+
category1: str # CASE_CLASS1_NM — 정보주체/처리자(민간/공공)
|
| 23 |
+
category2: str # CASE_CLASS2_NM — 분야 (수집·이용 등)
|
| 24 |
+
category3: str # CASE_CLASS3_NM — 업종 (경영·사무 등)
|
| 25 |
+
reg_dt: str # YYYYMMDD
|
| 26 |
+
case_year: str # YYYY
|
| 27 |
+
source_note: str # ETC_CN
|
| 28 |
+
detail_url: str # /front/case/view.do?ntt_id=...&nttno=...
|
| 29 |
+
chunk_context: str = "" # Anthropic Contextual Retrieval prefix — 인덱싱 시 body 앞에 prepend (답변 출력은 body만)
|
| 30 |
+
|
| 31 |
+
def citation(self) -> str:
|
| 32 |
+
"""답변에 박을 인용 태그.
|
| 33 |
+
|
| 34 |
+
예: '개인정보 상담사례 #311 (2024)'
|
| 35 |
+
"""
|
| 36 |
+
if self.case_year:
|
| 37 |
+
return f"개인정보 상담사례 #{self.ntt_no} ({self.case_year})"
|
| 38 |
+
return f"개인정보 상담사례 #{self.ntt_no}"
|
| 39 |
+
|
| 40 |
+
def absolute_url(self) -> str:
|
| 41 |
+
if self.detail_url.startswith("http"):
|
| 42 |
+
return self.detail_url
|
| 43 |
+
return f"https://www.privacy.go.kr{self.detail_url}"
|
src/kpaa/cases/scraper.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""privacy.go.kr 상담사례 1,745건 스크래퍼.
|
| 2 |
+
|
| 3 |
+
엔드포인트(라이브 검증 2026-04-29):
|
| 4 |
+
https://www.privacy.go.kr/front/case/onMadangListAjax.do?page=N
|
| 5 |
+
|
| 6 |
+
응답 JSON에서 `resultSet.result`는 7개 facet 그룹이 있고 그중
|
| 7 |
+
**index 2 (totalSize=1745)** 가 케이스 본문 포함 그룹이다.
|
| 8 |
+
다른 그룹은 통계/이벤트/게시판 ID들로 무관.
|
| 9 |
+
|
| 10 |
+
각 페이지당 10건. 총 ~175페이지. 폴라이트 레이트 1.5 req/s.
|
| 11 |
+
|
| 12 |
+
본문(`NTT_CN_FULL`)에는 HTML 엔티티(`(`, `·` 등)가 들어 있어
|
| 13 |
+
`html.unescape()`로 풀어 저장한다.
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import asyncio
|
| 18 |
+
import html
|
| 19 |
+
import json
|
| 20 |
+
import logging
|
| 21 |
+
import math
|
| 22 |
+
import time
|
| 23 |
+
from datetime import datetime
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
from typing import Any
|
| 26 |
+
|
| 27 |
+
import httpx
|
| 28 |
+
|
| 29 |
+
from kpaa.cases.models import Case
|
| 30 |
+
from kpaa.config import get_settings
|
| 31 |
+
|
| 32 |
+
logger = logging.getLogger("kpaa.cases")
|
| 33 |
+
|
| 34 |
+
LIST_URL = "https://www.privacy.go.kr/front/case/onMadangListAjax.do"
|
| 35 |
+
ROBOTS_URL = "https://www.privacy.go.kr/robots.txt"
|
| 36 |
+
|
| 37 |
+
# 폴라이트 레이트
|
| 38 |
+
MIN_REQ_INTERVAL = 1.0 / 1.5 # 1.5 req/s → 0.667s
|
| 39 |
+
TIMEOUT = httpx.Timeout(30.0, connect=10.0)
|
| 40 |
+
USER_AGENT = "kpaa/0.1 (+https://github.com/sz1-kca/korean-privacy-ai-assistant; cases-scraper)"
|
| 41 |
+
|
| 42 |
+
# resultSet.result 의 index 2 그룹이 본문 포함 (라이브 검증으로 확인)
|
| 43 |
+
CASE_GROUP_INDEX = 2
|
| 44 |
+
|
| 45 |
+
# 한 답변 본문 trim (Plan: ≤1500자 컨텍스트 안전)
|
| 46 |
+
BODY_LIMIT = 1500
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _enable_truststore() -> None:
|
| 50 |
+
"""macOS Python.framework SSL 이슈 회피 — 시스템 신뢰 저장소 사용."""
|
| 51 |
+
try:
|
| 52 |
+
import truststore
|
| 53 |
+
|
| 54 |
+
truststore.inject_into_ssl()
|
| 55 |
+
except ImportError:
|
| 56 |
+
logger.debug("truststore not available; relying on certifi")
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _decode_body(raw: str) -> str:
|
| 60 |
+
"""HTML 엔티티(`(`, `·`) 디코드 + 다중 공백 정리."""
|
| 61 |
+
if not raw:
|
| 62 |
+
return ""
|
| 63 |
+
text = html.unescape(raw)
|
| 64 |
+
# 너무 긴 본문은 trim (chatbot 컨텍스트 안전)
|
| 65 |
+
if len(text) > BODY_LIMIT:
|
| 66 |
+
text = text[: BODY_LIMIT].rstrip() + "\n…(중략)…"
|
| 67 |
+
return text
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _to_case(doc: dict[str, Any]) -> Case | None:
|
| 71 |
+
"""resultDocuments 1개 항목을 Case로 변환. 본문 필드 없으면 None."""
|
| 72 |
+
body_raw = doc.get("NTT_CN_FULL") or doc.get("NTT_CN") or ""
|
| 73 |
+
if not body_raw:
|
| 74 |
+
return None
|
| 75 |
+
return Case(
|
| 76 |
+
ntt_no=str(doc.get("NTT_NO") or "").strip(),
|
| 77 |
+
ntt_id=str(doc.get("NTT_ID") or "").strip(),
|
| 78 |
+
title=str(doc.get("NTT_SJ") or "").strip(),
|
| 79 |
+
summary=_decode_body(str(doc.get("NTT_CN") or "")),
|
| 80 |
+
body=_decode_body(str(body_raw)),
|
| 81 |
+
type_code=str(doc.get("NTT_TYPE") or "").strip(),
|
| 82 |
+
type_label=str(doc.get("NTT_TYPE_NM") or "").strip(),
|
| 83 |
+
category1=str(doc.get("CASE_CLASS1_NM") or "").strip(),
|
| 84 |
+
category2=str(doc.get("CASE_CLASS2_NM") or "").strip(),
|
| 85 |
+
category3=str(doc.get("CASE_CLASS3_NM") or "").strip(),
|
| 86 |
+
reg_dt=str(doc.get("REG_DT") or "").strip(),
|
| 87 |
+
case_year=str(doc.get("CASE_YEAR") or "").strip(),
|
| 88 |
+
source_note=str(doc.get("ETC_CN") or "").strip(),
|
| 89 |
+
detail_url=str(doc.get("URL_ADDR") or "").strip(),
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
async def check_robots(client: httpx.AsyncClient) -> tuple[bool, str]:
|
| 94 |
+
"""robots.txt 확인. (/front/case/ 경로 허용 여부, 원문)."""
|
| 95 |
+
r = await client.get(ROBOTS_URL)
|
| 96 |
+
text = r.text
|
| 97 |
+
# 매우 보수적 파서: 명시적 disallow에 /front/case가 포함되면 False
|
| 98 |
+
in_wildcard = False
|
| 99 |
+
forbidden = []
|
| 100 |
+
for line in text.splitlines():
|
| 101 |
+
line = line.strip()
|
| 102 |
+
if not line or line.startswith("#"):
|
| 103 |
+
continue
|
| 104 |
+
if line.lower().startswith("user-agent:"):
|
| 105 |
+
in_wildcard = line.split(":", 1)[1].strip() == "*"
|
| 106 |
+
elif in_wildcard and line.lower().startswith("disallow:"):
|
| 107 |
+
path = line.split(":", 1)[1].strip()
|
| 108 |
+
if path:
|
| 109 |
+
forbidden.append(path)
|
| 110 |
+
case_blocked = any(p.startswith("/front/case") for p in forbidden)
|
| 111 |
+
return (not case_blocked, text)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
async def fetch_page(
|
| 115 |
+
client: httpx.AsyncClient, page: int
|
| 116 |
+
) -> tuple[list[Case], int]:
|
| 117 |
+
"""1 페이지 호출 → (cases, total_count)."""
|
| 118 |
+
r = await client.get(
|
| 119 |
+
LIST_URL,
|
| 120 |
+
params={"page": str(page)},
|
| 121 |
+
headers={
|
| 122 |
+
"User-Agent": USER_AGENT,
|
| 123 |
+
"X-Requested-With": "XMLHttpRequest",
|
| 124 |
+
"Accept": "application/json",
|
| 125 |
+
},
|
| 126 |
+
)
|
| 127 |
+
r.raise_for_status()
|
| 128 |
+
data = r.json()
|
| 129 |
+
result = data.get("resultSet", {}).get("result", [])
|
| 130 |
+
if len(result) <= CASE_GROUP_INDEX:
|
| 131 |
+
logger.warning("page %d: result group %d missing", page, CASE_GROUP_INDEX)
|
| 132 |
+
return ([], 0)
|
| 133 |
+
grp = result[CASE_GROUP_INDEX]
|
| 134 |
+
total = int(float(grp.get("totalSize", 0)))
|
| 135 |
+
docs = grp.get("resultDocuments", [])
|
| 136 |
+
cases = []
|
| 137 |
+
for doc in docs:
|
| 138 |
+
case = _to_case(doc)
|
| 139 |
+
if case is not None:
|
| 140 |
+
cases.append(case)
|
| 141 |
+
return (cases, total)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
async def fetch_all(
|
| 145 |
+
*,
|
| 146 |
+
since: str | None = None,
|
| 147 |
+
respect_robots: bool = False,
|
| 148 |
+
progress: bool = True,
|
| 149 |
+
) -> tuple[list[Case], dict[str, Any]]:
|
| 150 |
+
"""전수 페이지네이션 스크래이프.
|
| 151 |
+
|
| 152 |
+
Args:
|
| 153 |
+
since: "YYYY-MM-DD"면 그 일자 이후 등록된 케이스만 keep (증분).
|
| 154 |
+
respect_robots: True면 robots.txt 명시 disallow 발견시 중단.
|
| 155 |
+
progress: True면 페이지 진행률 한 줄씩 출력.
|
| 156 |
+
"""
|
| 157 |
+
_enable_truststore()
|
| 158 |
+
since_yyyymmdd = ""
|
| 159 |
+
if since:
|
| 160 |
+
# "YYYY-MM-DD" → "YYYYMMDD"
|
| 161 |
+
since_yyyymmdd = since.replace("-", "").strip()
|
| 162 |
+
|
| 163 |
+
async with httpx.AsyncClient(timeout=TIMEOUT, follow_redirects=True) as c:
|
| 164 |
+
if respect_robots:
|
| 165 |
+
allowed, _txt = await check_robots(c)
|
| 166 |
+
if not allowed:
|
| 167 |
+
raise PermissionError(
|
| 168 |
+
"robots.txt에서 /front/case 경로를 disallow하고 있습니다. "
|
| 169 |
+
"스크래이프를 중단합니다."
|
| 170 |
+
)
|
| 171 |
+
# 1페이지로 totalCnt 확보
|
| 172 |
+
first_cases, total = await fetch_page(c, 1)
|
| 173 |
+
if total == 0:
|
| 174 |
+
return ([], {"total": 0, "pages": 0, "fetched": 0})
|
| 175 |
+
|
| 176 |
+
pages = math.ceil(total / 10)
|
| 177 |
+
all_cases: list[Case] = list(first_cases)
|
| 178 |
+
last_req_at = time.monotonic()
|
| 179 |
+
|
| 180 |
+
for page in range(2, pages + 1):
|
| 181 |
+
# 폴라이트 레이트
|
| 182 |
+
elapsed = time.monotonic() - last_req_at
|
| 183 |
+
wait = MIN_REQ_INTERVAL - elapsed
|
| 184 |
+
if wait > 0:
|
| 185 |
+
await asyncio.sleep(wait)
|
| 186 |
+
last_req_at = time.monotonic()
|
| 187 |
+
try:
|
| 188 |
+
cases, _ = await fetch_page(c, page)
|
| 189 |
+
except httpx.HTTPError as e:
|
| 190 |
+
logger.warning("page %d failed: %s — skipping", page, e)
|
| 191 |
+
continue
|
| 192 |
+
all_cases.extend(cases)
|
| 193 |
+
if progress and page % 10 == 0:
|
| 194 |
+
print(
|
| 195 |
+
f" …page {page}/{pages} "
|
| 196 |
+
f"({len(all_cases)} cases fetched)",
|
| 197 |
+
flush=True,
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
# 증분 필터
|
| 201 |
+
if since_yyyymmdd:
|
| 202 |
+
all_cases = [c for c in all_cases if c.reg_dt >= since_yyyymmdd]
|
| 203 |
+
|
| 204 |
+
# ntt_id 중복 제거 (안전장치)
|
| 205 |
+
seen: set[str] = set()
|
| 206 |
+
deduped: list[Case] = []
|
| 207 |
+
for case in all_cases:
|
| 208 |
+
key = case.ntt_id or f"no:{case.ntt_no}"
|
| 209 |
+
if key in seen:
|
| 210 |
+
continue
|
| 211 |
+
seen.add(key)
|
| 212 |
+
deduped.append(case)
|
| 213 |
+
|
| 214 |
+
meta = {
|
| 215 |
+
"total": total,
|
| 216 |
+
"pages": pages,
|
| 217 |
+
"fetched": len(deduped),
|
| 218 |
+
"scraped_at": datetime.now().isoformat(timespec="seconds"),
|
| 219 |
+
"source_url": LIST_URL,
|
| 220 |
+
"since": since or "",
|
| 221 |
+
}
|
| 222 |
+
return (deduped, meta)
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
async def refresh(
|
| 226 |
+
*,
|
| 227 |
+
since: str | None = None,
|
| 228 |
+
respect_robots: bool = False,
|
| 229 |
+
) -> int:
|
| 230 |
+
"""스크래이프 → SQLite 인덱스 빌드 → meta 갱신. CLI 진입점."""
|
| 231 |
+
from kpaa.cases.index import build_index, default_db_path, default_meta_path
|
| 232 |
+
|
| 233 |
+
print(f"▶ privacy.go.kr 상담사례 스크래이프 시작 (since={since or 'ALL'})")
|
| 234 |
+
cases, meta = await fetch_all(since=since, respect_robots=respect_robots)
|
| 235 |
+
if not cases:
|
| 236 |
+
print("(가져온 케이스 없음)")
|
| 237 |
+
return 0
|
| 238 |
+
|
| 239 |
+
db_path = default_db_path()
|
| 240 |
+
meta_path = default_meta_path()
|
| 241 |
+
print(f"▶ {len(cases)}건 → SQLite FTS5 인덱스 빌드: {db_path}")
|
| 242 |
+
build_index(cases, db_path=db_path)
|
| 243 |
+
|
| 244 |
+
# 메타 저장
|
| 245 |
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
| 246 |
+
with meta_path.open("w", encoding="utf-8") as f:
|
| 247 |
+
json.dump(meta, f, ensure_ascii=False, indent=2)
|
| 248 |
+
print(f"▶ 메타: {meta_path}")
|
| 249 |
+
print(
|
| 250 |
+
f" total(remote)={meta['total']} fetched={meta['fetched']} "
|
| 251 |
+
f"scraped_at={meta['scraped_at']}"
|
| 252 |
+
)
|
| 253 |
+
return 0
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
__all__ = ["fetch_all", "fetch_page", "refresh", "check_robots"]
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
# 사전 스냅샷 경로 헬퍼 (data/cases.sqlite) — index.py에서 사용
|
| 260 |
+
def _project_data_dir() -> Path:
|
| 261 |
+
"""리포 안의 data/ 폴더를 우선 사용, 그 외에는 user_cache."""
|
| 262 |
+
repo_data = Path(__file__).resolve().parents[3] / "data"
|
| 263 |
+
if repo_data.exists():
|
| 264 |
+
return repo_data
|
| 265 |
+
s = get_settings()
|
| 266 |
+
p = s.cache_root / "cases"
|
| 267 |
+
p.mkdir(parents=True, exist_ok=True)
|
| 268 |
+
return p
|
src/kpaa/cli.py
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import asyncio
|
| 5 |
+
import sys
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _utf8_console() -> None:
|
| 9 |
+
for stream in (sys.stdout, sys.stderr):
|
| 10 |
+
try:
|
| 11 |
+
stream.reconfigure(encoding="utf-8") # type: ignore[attr-defined]
|
| 12 |
+
except (AttributeError, OSError):
|
| 13 |
+
pass
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _build_parser() -> argparse.ArgumentParser:
|
| 17 |
+
p = argparse.ArgumentParser(prog="kpaa", description="개인정보보호법 미니 상담 챗봇 CLI")
|
| 18 |
+
sub = p.add_subparsers(dest="cmd", required=True)
|
| 19 |
+
|
| 20 |
+
# ── serve / smoke / eval ────────────────────────────────────────
|
| 21 |
+
p_serve = sub.add_parser("serve", help="FastAPI 백엔드 실행")
|
| 22 |
+
p_serve.add_argument("--host", default=None)
|
| 23 |
+
p_serve.add_argument("--port", type=int, default=None)
|
| 24 |
+
|
| 25 |
+
p_smoke = sub.add_parser("smoke", help="단일 질문으로 RAG 파이프라인 종단 검증 (LLM 포함)")
|
| 26 |
+
p_smoke.add_argument("query")
|
| 27 |
+
|
| 28 |
+
p_retrieve = sub.add_parser(
|
| 29 |
+
"retrieve",
|
| 30 |
+
help="RAG 컨텍스트 블록만 빌드 (LLM 호출 없음 — 검증용)",
|
| 31 |
+
)
|
| 32 |
+
p_retrieve.add_argument("query")
|
| 33 |
+
|
| 34 |
+
p_route = sub.add_parser(
|
| 35 |
+
"route",
|
| 36 |
+
help="라우터 결과만 출력 (intents/jo/mst/sources, LLM·검색 없음)",
|
| 37 |
+
)
|
| 38 |
+
p_route.add_argument("query")
|
| 39 |
+
|
| 40 |
+
p_eval = sub.add_parser("eval", help="골든 질문 일괄 평가 (LLM 라이브)")
|
| 41 |
+
p_eval.add_argument("--limit", type=int, default=None, help="첫 N건만 실행")
|
| 42 |
+
p_eval.add_argument("--show-answers", action="store_true", help="모든 답변 본문 출력")
|
| 43 |
+
p_eval.add_argument("--out", default=None, help="결과 JSON 저장 경로")
|
| 44 |
+
|
| 45 |
+
# ── law ─────────────────────────────────────────────────────────
|
| 46 |
+
p_law = sub.add_parser("law", help="법제처 SDK 직접 호출 (법령)")
|
| 47 |
+
law_sub = p_law.add_subparsers(dest="law_cmd", required=True)
|
| 48 |
+
sp = law_sub.add_parser("search", help="법령 검색")
|
| 49 |
+
sp.add_argument("query")
|
| 50 |
+
sp.add_argument("--display", type=int, default=10)
|
| 51 |
+
|
| 52 |
+
sp = law_sub.add_parser("text", help="법령 본문 또는 특정 조문")
|
| 53 |
+
sp.add_argument("mst", help="법령일련번호 (예: 270351 = 개인정보 보호법)")
|
| 54 |
+
sp.add_argument("--jo", help='조문 번호 (예: "15", "24의2")')
|
| 55 |
+
|
| 56 |
+
sp = law_sub.add_parser("annexes", help="별표·별지서식 검색")
|
| 57 |
+
sp.add_argument("--law-id", help="관련법령ID(6자리, 예: 011357)", default=None)
|
| 58 |
+
sp.add_argument("--query", default="")
|
| 59 |
+
sp.add_argument("--display", type=int, default=10)
|
| 60 |
+
|
| 61 |
+
# ── pipc ────────────────────────────────────────────────────────
|
| 62 |
+
p_pipc = sub.add_parser("pipc", help="개인정보보호위원회 결정문")
|
| 63 |
+
pipc_sub = p_pipc.add_subparsers(dest="pipc_cmd", required=True)
|
| 64 |
+
sp = pipc_sub.add_parser("search")
|
| 65 |
+
sp.add_argument("query")
|
| 66 |
+
sp.add_argument("--display", type=int, default=10)
|
| 67 |
+
sp = pipc_sub.add_parser("text")
|
| 68 |
+
sp.add_argument("decision_id")
|
| 69 |
+
|
| 70 |
+
# ── expc (interpretation) ───────────────────────────────────────
|
| 71 |
+
p_expc = sub.add_parser("expc", help="법령해석례")
|
| 72 |
+
expc_sub = p_expc.add_subparsers(dest="expc_cmd", required=True)
|
| 73 |
+
sp = expc_sub.add_parser("search")
|
| 74 |
+
sp.add_argument("query")
|
| 75 |
+
sp.add_argument("--display", type=int, default=10)
|
| 76 |
+
sp = expc_sub.add_parser("text")
|
| 77 |
+
sp.add_argument("interpretation_id")
|
| 78 |
+
|
| 79 |
+
# ── cases (privacy.go.kr 상담사례) ──────────────────────────────
|
| 80 |
+
p_cases = sub.add_parser("cases", help="개인정보 상담사례 검색")
|
| 81 |
+
cases_sub = p_cases.add_subparsers(dest="cases_cmd", required=True)
|
| 82 |
+
sp = cases_sub.add_parser("search")
|
| 83 |
+
sp.add_argument("query")
|
| 84 |
+
sp.add_argument("-k", type=int, default=5)
|
| 85 |
+
|
| 86 |
+
p_refresh = sub.add_parser("refresh-cases", help="privacy.go.kr 상담사례 재스크래이프")
|
| 87 |
+
p_refresh.add_argument("--since", help="YYYY-MM-DD 이후만 갱신")
|
| 88 |
+
p_refresh.add_argument("--respect-robots", action="store_true")
|
| 89 |
+
|
| 90 |
+
# ── guides (PIPC 발간 안내서) ───────────────────────────────────
|
| 91 |
+
p_guides = sub.add_parser("guides", help="개인정보 보호 안내서 청크 검색")
|
| 92 |
+
guides_sub = p_guides.add_subparsers(dest="guides_cmd", required=True)
|
| 93 |
+
sp = guides_sub.add_parser("search")
|
| 94 |
+
sp.add_argument("query")
|
| 95 |
+
sp.add_argument("-k", type=int, default=5)
|
| 96 |
+
|
| 97 |
+
p_extract = sub.add_parser(
|
| 98 |
+
"extract-guide",
|
| 99 |
+
help="PDF → markdown 추출 (인터랙티브 청킹의 사전 단계)",
|
| 100 |
+
)
|
| 101 |
+
p_extract.add_argument("pdf_path", help="data/guide/*.pdf 한 건")
|
| 102 |
+
|
| 103 |
+
p_build_guides = sub.add_parser(
|
| 104 |
+
"build-guides",
|
| 105 |
+
help="data/guide/chunks/*.jsonl → guides.sqlite 빌드",
|
| 106 |
+
)
|
| 107 |
+
p_build_guides.add_argument("--rebuild", action="store_true", default=True)
|
| 108 |
+
|
| 109 |
+
sub.add_parser(
|
| 110 |
+
"refresh-related-laws",
|
| 111 |
+
help="privacy.go.kr 개인정보 관련 법령·행정규칙 목록 재스크래이프 (data/related_laws.yaml)",
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
return p
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
# ───────────────────────── command handlers ─────────────────────────
|
| 118 |
+
|
| 119 |
+
async def _law_search(query: str, display: int) -> int:
|
| 120 |
+
from kpaa.law_api import KoreanLawClient
|
| 121 |
+
|
| 122 |
+
async with KoreanLawClient() as client:
|
| 123 |
+
hits = await client.law.search(query, display=display)
|
| 124 |
+
if not hits:
|
| 125 |
+
print(f'(검색 결과 없음: "{query}")')
|
| 126 |
+
return 0
|
| 127 |
+
for hit in hits:
|
| 128 |
+
print(
|
| 129 |
+
f"- [{hit.mst}] {hit.name} "
|
| 130 |
+
f"({hit.type_name or '-'}; 시행 {hit.enforce_date or '-'}; "
|
| 131 |
+
f"소관 {hit.department or '-'})"
|
| 132 |
+
)
|
| 133 |
+
return 0
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
async def _law_text(mst: str, jo: str | None) -> int:
|
| 137 |
+
from kpaa.law_api import KoreanLawClient
|
| 138 |
+
|
| 139 |
+
async with KoreanLawClient() as client:
|
| 140 |
+
text = await client.law.get_text(mst=mst, jo=jo)
|
| 141 |
+
print(f"# {text.name} (MST={text.mst}, 시행 {text.enforce_date}, 소관 {text.department})")
|
| 142 |
+
if not text.articles:
|
| 143 |
+
print("(조문 없음)")
|
| 144 |
+
return 0
|
| 145 |
+
for art in text.articles:
|
| 146 |
+
print(f"\n## {art.citation(law_name=text.name)} {art.title}")
|
| 147 |
+
print(art.raw_text)
|
| 148 |
+
return 0
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
async def _law_annexes(law_id: str | None, query: str, display: int) -> int:
|
| 152 |
+
from kpaa.law_api import KoreanLawClient
|
| 153 |
+
|
| 154 |
+
async with KoreanLawClient() as client:
|
| 155 |
+
hits = await client.law.get_annexes(
|
| 156 |
+
related_law_id=law_id, query=query, display=display
|
| 157 |
+
)
|
| 158 |
+
for h in hits:
|
| 159 |
+
print(
|
| 160 |
+
f"- [{h.annex_id}] {h.name} ({h.annex_kind}; "
|
| 161 |
+
f"관련법령 {h.related_law_name}; 공포 {h.promulgate_date})"
|
| 162 |
+
)
|
| 163 |
+
return 0
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
async def _pipc_search(query: str, display: int) -> int:
|
| 167 |
+
from kpaa.law_api import KoreanLawClient
|
| 168 |
+
|
| 169 |
+
async with KoreanLawClient() as client:
|
| 170 |
+
hits = await client.pipc.search_decisions(query, display=display)
|
| 171 |
+
for h in hits:
|
| 172 |
+
print(
|
| 173 |
+
f"- [{h.decision_id}] {h.title}\n"
|
| 174 |
+
f" (의안 {h.decision_no or '-'}; 의결 {h.decision_date or '-'}; "
|
| 175 |
+
f"{h.decision_kind or '-'})"
|
| 176 |
+
)
|
| 177 |
+
return 0
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
async def _pipc_text(decision_id: str) -> int:
|
| 181 |
+
from kpaa.law_api import KoreanLawClient
|
| 182 |
+
|
| 183 |
+
async with KoreanLawClient() as client:
|
| 184 |
+
d = await client.pipc.get_decision_text(decision_id=decision_id)
|
| 185 |
+
print(f"# {d.citation()} {d.title}")
|
| 186 |
+
print(f" 의결: {d.decision_date or '-'} | 결정구분: {d.decision_kind or '-'} | {d.agency or '-'}")
|
| 187 |
+
if d.main_text:
|
| 188 |
+
print(f"\n## 주문\n{d.main_text}")
|
| 189 |
+
if d.reason:
|
| 190 |
+
print(f"\n## 이유\n{d.reason}")
|
| 191 |
+
return 0
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
async def _expc_search(query: str, display: int) -> int:
|
| 195 |
+
from kpaa.law_api import KoreanLawClient
|
| 196 |
+
|
| 197 |
+
async with KoreanLawClient() as client:
|
| 198 |
+
hits = await client.interpretation.search(query, display=display)
|
| 199 |
+
for h in hits:
|
| 200 |
+
print(
|
| 201 |
+
f"- [{h.interpretation_id}] {h.title}\n"
|
| 202 |
+
f" (안건 {h.case_no or '-'}; 회신 {h.decided_date or '-'}; "
|
| 203 |
+
f"질의 {h.inquirer} → {h.responder})"
|
| 204 |
+
)
|
| 205 |
+
return 0
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
async def _expc_text(interpretation_id: str) -> int:
|
| 209 |
+
from kpaa.law_api import KoreanLawClient
|
| 210 |
+
|
| 211 |
+
async with KoreanLawClient() as client:
|
| 212 |
+
e = await client.interpretation.get_text(interpretation_id=interpretation_id)
|
| 213 |
+
print(f"# {e.citation()} {e.title}")
|
| 214 |
+
print(f" 해석: {e.decided_date or '-'} | 질의 {e.inquirer} → 회신 {e.responder}")
|
| 215 |
+
if e.question:
|
| 216 |
+
print(f"\n## 질의요지\n{e.question}")
|
| 217 |
+
if e.answer:
|
| 218 |
+
print(f"\n## 회답\n{e.answer}")
|
| 219 |
+
if e.reason:
|
| 220 |
+
print(f"\n## 이유\n{e.reason}")
|
| 221 |
+
return 0
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
# ───────────────────────── entry ─────────────────────────
|
| 225 |
+
|
| 226 |
+
def main(argv: list[str] | None = None) -> int:
|
| 227 |
+
_utf8_console()
|
| 228 |
+
parser = _build_parser()
|
| 229 |
+
args = parser.parse_args(argv)
|
| 230 |
+
|
| 231 |
+
if args.cmd == "serve":
|
| 232 |
+
from kpaa.config import get_settings
|
| 233 |
+
|
| 234 |
+
s = get_settings()
|
| 235 |
+
host = args.host or s.kpaa_host
|
| 236 |
+
port = args.port or s.kpaa_port
|
| 237 |
+
try:
|
| 238 |
+
from kpaa.server import run as run_server
|
| 239 |
+
except ImportError:
|
| 240 |
+
print("(server 모듈은 Day 6에 구현됩니다)", file=sys.stderr)
|
| 241 |
+
return 2
|
| 242 |
+
run_server(host=host, port=port)
|
| 243 |
+
return 0
|
| 244 |
+
|
| 245 |
+
if args.cmd == "smoke":
|
| 246 |
+
from kpaa.pipeline import smoke as smoke_fn
|
| 247 |
+
|
| 248 |
+
return asyncio.run(smoke_fn(args.query))
|
| 249 |
+
|
| 250 |
+
if args.cmd == "retrieve":
|
| 251 |
+
from kpaa.pipeline import smoke as smoke_fn
|
| 252 |
+
|
| 253 |
+
# smoke와 동일하게 컨텍스트 출력 (LLM은 아직 통합 전이라 둘이 같음)
|
| 254 |
+
return asyncio.run(smoke_fn(args.query))
|
| 255 |
+
|
| 256 |
+
if args.cmd == "route":
|
| 257 |
+
from kpaa.retrieval.router import (
|
| 258 |
+
load_intents,
|
| 259 |
+
load_related_laws,
|
| 260 |
+
)
|
| 261 |
+
from kpaa.retrieval.router import (
|
| 262 |
+
route as route_fn,
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
load_intents.cache_clear()
|
| 266 |
+
load_related_laws.cache_clear()
|
| 267 |
+
plan = asyncio.run(route_fn(args.query))
|
| 268 |
+
print(f"질문: {args.query!r}\n")
|
| 269 |
+
print(f"chain : {plan.chain or '(미결정)'}")
|
| 270 |
+
print(f"routed_by : {plan.routed_by}")
|
| 271 |
+
print(f"intents : {[i.name for i in plan.intents] or '(없음)'}")
|
| 272 |
+
print(f"jo_targets : {plan.jo_targets or '-'}")
|
| 273 |
+
print(f"mst_targets : {plan.mst_targets or '-'}")
|
| 274 |
+
print(f"name_targets : {plan.name_targets or '-'}")
|
| 275 |
+
print(f"active_sources : {plan.active_sources or '-'}")
|
| 276 |
+
print(f"search_keywords: {plan.search_keywords or '-'}")
|
| 277 |
+
print(f"top_keyword : {plan.top_keyword!r}")
|
| 278 |
+
return 0
|
| 279 |
+
|
| 280 |
+
if args.cmd == "eval":
|
| 281 |
+
from pathlib import Path as _P
|
| 282 |
+
|
| 283 |
+
from kpaa.cli_eval import run as run_eval
|
| 284 |
+
|
| 285 |
+
return asyncio.run(
|
| 286 |
+
run_eval(
|
| 287 |
+
limit=args.limit,
|
| 288 |
+
show_answers=args.show_answers,
|
| 289 |
+
out_path=_P(args.out) if args.out else None,
|
| 290 |
+
)
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
if args.cmd == "law":
|
| 294 |
+
if args.law_cmd == "search":
|
| 295 |
+
return asyncio.run(_law_search(args.query, args.display))
|
| 296 |
+
if args.law_cmd == "text":
|
| 297 |
+
return asyncio.run(_law_text(args.mst, args.jo))
|
| 298 |
+
if args.law_cmd == "annexes":
|
| 299 |
+
return asyncio.run(_law_annexes(args.law_id, args.query, args.display))
|
| 300 |
+
|
| 301 |
+
if args.cmd == "pipc":
|
| 302 |
+
if args.pipc_cmd == "search":
|
| 303 |
+
return asyncio.run(_pipc_search(args.query, args.display))
|
| 304 |
+
if args.pipc_cmd == "text":
|
| 305 |
+
return asyncio.run(_pipc_text(args.decision_id))
|
| 306 |
+
|
| 307 |
+
if args.cmd == "expc":
|
| 308 |
+
if args.expc_cmd == "search":
|
| 309 |
+
return asyncio.run(_expc_search(args.query, args.display))
|
| 310 |
+
if args.expc_cmd == "text":
|
| 311 |
+
return asyncio.run(_expc_text(args.interpretation_id))
|
| 312 |
+
|
| 313 |
+
if args.cmd == "cases":
|
| 314 |
+
from kpaa.cases import CasesIndex
|
| 315 |
+
|
| 316 |
+
idx = CasesIndex.default()
|
| 317 |
+
if idx.total == 0:
|
| 318 |
+
print(
|
| 319 |
+
"(상담사례 인덱스가 비어 있습니다 — `kpaa refresh-cases` 로 빌드하세요)",
|
| 320 |
+
file=sys.stderr,
|
| 321 |
+
)
|
| 322 |
+
return 1
|
| 323 |
+
if args.cases_cmd == "search":
|
| 324 |
+
hits = idx.search(args.query, k=args.k)
|
| 325 |
+
if not hits:
|
| 326 |
+
print(f'(검색 결과 없음: "{args.query}")')
|
| 327 |
+
return 0
|
| 328 |
+
for h in hits:
|
| 329 |
+
cat = " > ".join(filter(None, (h.category1, h.category2, h.category3)))
|
| 330 |
+
print(f"\n# {h.citation()} {h.title}")
|
| 331 |
+
print(f" 분류: {cat or '-'} | 등록: {h.reg_dt or '-'} | {h.type_label or '-'}")
|
| 332 |
+
if h.body:
|
| 333 |
+
print(f" 본문:\n {h.body[:500]}")
|
| 334 |
+
if h.source_note:
|
| 335 |
+
print(f" 출처: {h.source_note}")
|
| 336 |
+
print(f" 링크: {h.absolute_url() if h.detail_url else '-'}")
|
| 337 |
+
return 0
|
| 338 |
+
|
| 339 |
+
if args.cmd == "refresh-cases":
|
| 340 |
+
from kpaa.cases.scraper import refresh
|
| 341 |
+
|
| 342 |
+
return asyncio.run(refresh(since=args.since, respect_robots=args.respect_robots))
|
| 343 |
+
|
| 344 |
+
if args.cmd == "guides":
|
| 345 |
+
from kpaa.guides import GuidesIndex
|
| 346 |
+
|
| 347 |
+
idx = GuidesIndex.default()
|
| 348 |
+
if idx.total == 0:
|
| 349 |
+
print(
|
| 350 |
+
"(가이드 인덱스가 비어 있습니다 — `kpaa build-guides` 로 빌드하세요)",
|
| 351 |
+
file=sys.stderr,
|
| 352 |
+
)
|
| 353 |
+
return 1
|
| 354 |
+
if args.guides_cmd == "search":
|
| 355 |
+
hits = idx.search(args.query, k=args.k)
|
| 356 |
+
if not hits:
|
| 357 |
+
print(f'(검색 결과 없음: "{args.query}")')
|
| 358 |
+
return 0
|
| 359 |
+
for h in hits:
|
| 360 |
+
print(f"\n# {h.citation()}")
|
| 361 |
+
print(f" 문서: {h.doc_title} ({h.doc_date or '-'})")
|
| 362 |
+
print(f" 섹션: {h.section or '-'} | chunk #{h.chunk_no}")
|
| 363 |
+
if h.body:
|
| 364 |
+
print(f" 본문:\n {h.body[:500]}")
|
| 365 |
+
print(f" 원본 PDF: {h.source_pdf or '-'}")
|
| 366 |
+
return 0
|
| 367 |
+
|
| 368 |
+
if args.cmd == "extract-guide":
|
| 369 |
+
from pathlib import Path as _P
|
| 370 |
+
|
| 371 |
+
from kpaa.guides.extractor import (
|
| 372 |
+
derive_doc_id,
|
| 373 |
+
derive_doc_title,
|
| 374 |
+
extract,
|
| 375 |
+
)
|
| 376 |
+
from kpaa.guides.index import extracted_dir
|
| 377 |
+
|
| 378 |
+
pdf = _P(args.pdf_path)
|
| 379 |
+
if not pdf.exists():
|
| 380 |
+
print(f"(PDF 없음: {pdf})", file=sys.stderr)
|
| 381 |
+
return 1
|
| 382 |
+
doc_id = derive_doc_id(pdf.name)
|
| 383 |
+
title = derive_doc_title(pdf.name)
|
| 384 |
+
print(f"PDF: {pdf.name}", file=sys.stderr)
|
| 385 |
+
print(f" doc_id : {doc_id}", file=sys.stderr)
|
| 386 |
+
print(f" doc_title: {title}", file=sys.stderr)
|
| 387 |
+
print("docling 변환 중 ... (born-digital, OCR off)", file=sys.stderr)
|
| 388 |
+
try:
|
| 389 |
+
md = extract(pdf)
|
| 390 |
+
except RuntimeError as e:
|
| 391 |
+
print(f"✗ {e}", file=sys.stderr)
|
| 392 |
+
return 1
|
| 393 |
+
out_dir = extracted_dir()
|
| 394 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 395 |
+
out = out_dir / f"{doc_id}.md"
|
| 396 |
+
out.write_text(md, encoding="utf-8")
|
| 397 |
+
print(
|
| 398 |
+
f"✓ {len(md):,}자 → {out}\n"
|
| 399 |
+
f" 다음: 새 Claude 세션에서 '다음 PDF 청킹: {doc_id}' 라고 지시.",
|
| 400 |
+
file=sys.stderr,
|
| 401 |
+
)
|
| 402 |
+
return 0
|
| 403 |
+
|
| 404 |
+
if args.cmd == "build-guides":
|
| 405 |
+
from kpaa.guides.builder import build
|
| 406 |
+
|
| 407 |
+
build()
|
| 408 |
+
return 0
|
| 409 |
+
|
| 410 |
+
if args.cmd == "refresh-related-laws":
|
| 411 |
+
from kpaa.related_laws import refresh as refresh_rl
|
| 412 |
+
|
| 413 |
+
return asyncio.run(refresh_rl())
|
| 414 |
+
|
| 415 |
+
parser.print_help()
|
| 416 |
+
return 2
|
src/kpaa/cli_eval.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""골든 질문 자동 평가 (`kpaa eval`).
|
| 2 |
+
|
| 3 |
+
`tests/eval_questions.yaml`의 10개 질문을 라이브 파이프라인에 던지고,
|
| 4 |
+
각 질문의 `expected_phrases`(모두 매칭)와 `forbidden_phrases`(하나라도 매칭 시 실패),
|
| 5 |
+
면책 문구 부착 여부를 검사한다.
|
| 6 |
+
|
| 7 |
+
라이브 LLM 추론이 들어가므로 10건 × ~30~60초 = 5~10분 소요.
|
| 8 |
+
빠른 검증은 `--limit N`로 일부만.
|
| 9 |
+
|
| 10 |
+
CI에서는 LLM 호출이 너무 무거우므로 이 스크립트는 사용자 로컬 검증용.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import re
|
| 15 |
+
import time
|
| 16 |
+
from dataclasses import dataclass
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
from typing import Any
|
| 19 |
+
|
| 20 |
+
import yaml
|
| 21 |
+
|
| 22 |
+
from kpaa.pipeline import generate
|
| 23 |
+
|
| 24 |
+
_DISCLAIMER_RE = re.compile(r"※[\s\S]{0,400}법률\s*자문")
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@dataclass
|
| 28 |
+
class CaseResult:
|
| 29 |
+
id: int
|
| 30 |
+
question: str
|
| 31 |
+
answer: str
|
| 32 |
+
elapsed_s: float
|
| 33 |
+
missed_expected: list[str]
|
| 34 |
+
hit_forbidden: list[str]
|
| 35 |
+
has_disclaimer: bool
|
| 36 |
+
|
| 37 |
+
@property
|
| 38 |
+
def passed(self) -> bool:
|
| 39 |
+
return (
|
| 40 |
+
not self.missed_expected
|
| 41 |
+
and not self.hit_forbidden
|
| 42 |
+
and self.has_disclaimer
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _load_eval(path: Path | None = None) -> list[dict[str, Any]]:
|
| 47 |
+
p = path or (Path(__file__).resolve().parents[2] / "tests" / "eval_questions.yaml")
|
| 48 |
+
if not p.exists():
|
| 49 |
+
raise FileNotFoundError(f"eval yaml not found: {p}")
|
| 50 |
+
raw = yaml.safe_load(p.read_text(encoding="utf-8"))
|
| 51 |
+
return list(raw or [])
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _check(answer: str, item: dict[str, Any]) -> tuple[list[str], list[str], bool]:
|
| 55 |
+
"""답변에서 expected/forbidden 정규식 매칭 + 면책 문구 검사."""
|
| 56 |
+
missed = []
|
| 57 |
+
for pat in item.get("expected_phrases", []) or []:
|
| 58 |
+
if not re.search(pat, answer):
|
| 59 |
+
missed.append(pat)
|
| 60 |
+
hit = []
|
| 61 |
+
for pat in item.get("forbidden_phrases", []) or []:
|
| 62 |
+
if re.search(pat, answer):
|
| 63 |
+
hit.append(pat)
|
| 64 |
+
has_disclaimer = bool(_DISCLAIMER_RE.search(answer))
|
| 65 |
+
return missed, hit, has_disclaimer
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
async def _generate_answer(query: str) -> tuple[str, float]:
|
| 69 |
+
"""파이프라인 종단 호출 → 최종 답변과 경과 시간."""
|
| 70 |
+
t0 = time.monotonic()
|
| 71 |
+
final = ""
|
| 72 |
+
chunks: list[str] = []
|
| 73 |
+
async for evt in generate(query):
|
| 74 |
+
if evt["event"] == "token":
|
| 75 |
+
chunks.append(evt["delta"])
|
| 76 |
+
elif evt["event"] == "done":
|
| 77 |
+
final = evt["answer"]
|
| 78 |
+
if not final:
|
| 79 |
+
final = "".join(chunks)
|
| 80 |
+
return final, time.monotonic() - t0
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
async def run(
|
| 84 |
+
*,
|
| 85 |
+
limit: int | None = None,
|
| 86 |
+
eval_path: Path | None = None,
|
| 87 |
+
show_answers: bool = False,
|
| 88 |
+
out_path: Path | None = None,
|
| 89 |
+
) -> int:
|
| 90 |
+
items = _load_eval(eval_path)
|
| 91 |
+
if limit is not None:
|
| 92 |
+
items = items[:limit]
|
| 93 |
+
print(f"▶ kpaa eval — 골든 질문 {len(items)}건 평가 시작")
|
| 94 |
+
print(f" (각 질문 LLM 추론 ~30–60초, 총 {len(items) * 45 / 60:.1f}분 소요 예상)\n")
|
| 95 |
+
|
| 96 |
+
results: list[CaseResult] = []
|
| 97 |
+
for i, item in enumerate(items, 1):
|
| 98 |
+
q = item["question"]
|
| 99 |
+
qid = item.get("id", i)
|
| 100 |
+
print(f"[{i}/{len(items)}] #{qid} {q}")
|
| 101 |
+
try:
|
| 102 |
+
answer, secs = await _generate_answer(q)
|
| 103 |
+
except Exception as e:
|
| 104 |
+
print(f" ✗ ERROR: {type(e).__name__}: {e}\n")
|
| 105 |
+
results.append(
|
| 106 |
+
CaseResult(
|
| 107 |
+
id=qid, question=q, answer="", elapsed_s=0.0,
|
| 108 |
+
missed_expected=item.get("expected_phrases", []) or [],
|
| 109 |
+
hit_forbidden=[],
|
| 110 |
+
has_disclaimer=False,
|
| 111 |
+
)
|
| 112 |
+
)
|
| 113 |
+
continue
|
| 114 |
+
|
| 115 |
+
missed, hit, has_dc = _check(answer, item)
|
| 116 |
+
r = CaseResult(
|
| 117 |
+
id=qid, question=q, answer=answer, elapsed_s=secs,
|
| 118 |
+
missed_expected=missed, hit_forbidden=hit, has_disclaimer=has_dc,
|
| 119 |
+
)
|
| 120 |
+
results.append(r)
|
| 121 |
+
|
| 122 |
+
flag = "✅" if r.passed else "❌"
|
| 123 |
+
print(f" {flag} ({secs:.1f}s) "
|
| 124 |
+
f"missed={len(missed)} forbidden={len(hit)} disclaimer={has_dc}")
|
| 125 |
+
if missed:
|
| 126 |
+
print(f" missed: {missed}")
|
| 127 |
+
if hit:
|
| 128 |
+
print(f" forbidden hit: {hit}")
|
| 129 |
+
if not has_dc:
|
| 130 |
+
print(" disclaimer absent")
|
| 131 |
+
if show_answers or not r.passed:
|
| 132 |
+
preview = answer.replace("\n", "\n ")
|
| 133 |
+
print(f" ─── 답변 ─────────────\n {preview}")
|
| 134 |
+
print()
|
| 135 |
+
|
| 136 |
+
# 종합
|
| 137 |
+
passed = sum(1 for r in results if r.passed)
|
| 138 |
+
total = len(results)
|
| 139 |
+
print("─" * 60)
|
| 140 |
+
print(f"결과: {passed}/{total} 통과 ({100*passed/total if total else 0:.0f}%)")
|
| 141 |
+
print("─" * 60)
|
| 142 |
+
if passed < total:
|
| 143 |
+
print("\n실패한 질문:")
|
| 144 |
+
for r in results:
|
| 145 |
+
if not r.passed:
|
| 146 |
+
print(f" #{r.id}: {r.question}")
|
| 147 |
+
if r.missed_expected:
|
| 148 |
+
print(f" missing: {r.missed_expected}")
|
| 149 |
+
if r.hit_forbidden:
|
| 150 |
+
print(f" forbidden: {r.hit_forbidden}")
|
| 151 |
+
if not r.has_disclaimer:
|
| 152 |
+
print(" disclaimer absent")
|
| 153 |
+
|
| 154 |
+
if out_path is not None:
|
| 155 |
+
import json
|
| 156 |
+
|
| 157 |
+
out_path.write_text(
|
| 158 |
+
json.dumps(
|
| 159 |
+
[
|
| 160 |
+
{
|
| 161 |
+
"id": r.id,
|
| 162 |
+
"question": r.question,
|
| 163 |
+
"answer": r.answer,
|
| 164 |
+
"elapsed_s": round(r.elapsed_s, 2),
|
| 165 |
+
"passed": r.passed,
|
| 166 |
+
"missed_expected": r.missed_expected,
|
| 167 |
+
"hit_forbidden": r.hit_forbidden,
|
| 168 |
+
"has_disclaimer": r.has_disclaimer,
|
| 169 |
+
}
|
| 170 |
+
for r in results
|
| 171 |
+
],
|
| 172 |
+
ensure_ascii=False,
|
| 173 |
+
indent=2,
|
| 174 |
+
),
|
| 175 |
+
encoding="utf-8",
|
| 176 |
+
)
|
| 177 |
+
print(f"\n결과 저장: {out_path}")
|
| 178 |
+
|
| 179 |
+
return 0 if passed == total else 1
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
__all__ = ["run", "CaseResult"]
|
src/kpaa/config.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from functools import lru_cache
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
from platformdirs import user_cache_dir, user_config_dir
|
| 7 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class Settings(BaseSettings):
|
| 11 |
+
model_config = SettingsConfigDict(
|
| 12 |
+
env_file=".env",
|
| 13 |
+
env_file_encoding="utf-8",
|
| 14 |
+
case_sensitive=False,
|
| 15 |
+
extra="ignore",
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
law_oc: str = ""
|
| 19 |
+
|
| 20 |
+
kpaa_model_repo: str = "bartowski/google_gemma-4-E2B-it-GGUF"
|
| 21 |
+
kpaa_model_file: str = "google_gemma-4-E2B-it-Q4_K_M.gguf"
|
| 22 |
+
kpaa_model_dir: Path | None = None
|
| 23 |
+
|
| 24 |
+
kpaa_host: str = "127.0.0.1"
|
| 25 |
+
kpaa_port: int = 8000
|
| 26 |
+
|
| 27 |
+
kpaa_rewrite: bool = False
|
| 28 |
+
kpaa_max_context_tokens: int = 16384
|
| 29 |
+
|
| 30 |
+
# LLM 백엔드 선택. None=auto: HF Spaces 환경(SPACE_ID 설정)이면 zerogpu,
|
| 31 |
+
# 아니면 llama_cpp. 강제 override: "llama_cpp" | "zerogpu".
|
| 32 |
+
kpaa_llm_backend: str | None = None
|
| 33 |
+
|
| 34 |
+
# ZeroGPU 백엔드용 — 같은 가중치의 transformers repo (게이트 X, Apache 2.0).
|
| 35 |
+
kpaa_hf_model_repo: str = "google/gemma-4-E2B-it"
|
| 36 |
+
kpaa_hf_model_dtype: str = "bfloat16"
|
| 37 |
+
# @spaces.GPU 함수당 GPU 점유 한도(초). 기본 60s 는 긴 답변에 부족할 수 있어
|
| 38 |
+
# 120s 사용. ZeroGPU Pro 는 300s 까지 허용.
|
| 39 |
+
kpaa_hf_gpu_duration: int = 120
|
| 40 |
+
|
| 41 |
+
@property
|
| 42 |
+
def cache_root(self) -> Path:
|
| 43 |
+
p = Path(user_cache_dir("kpaa"))
|
| 44 |
+
p.mkdir(parents=True, exist_ok=True)
|
| 45 |
+
return p
|
| 46 |
+
|
| 47 |
+
@property
|
| 48 |
+
def config_root(self) -> Path:
|
| 49 |
+
p = Path(user_config_dir("kpaa"))
|
| 50 |
+
p.mkdir(parents=True, exist_ok=True)
|
| 51 |
+
return p
|
| 52 |
+
|
| 53 |
+
@property
|
| 54 |
+
def model_dir(self) -> Path:
|
| 55 |
+
p = self.kpaa_model_dir or (self.cache_root / "models")
|
| 56 |
+
p.mkdir(parents=True, exist_ok=True)
|
| 57 |
+
return p
|
| 58 |
+
|
| 59 |
+
@property
|
| 60 |
+
def law_cache_dir(self) -> Path:
|
| 61 |
+
p = self.cache_root / "law"
|
| 62 |
+
p.mkdir(parents=True, exist_ok=True)
|
| 63 |
+
return p
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@lru_cache(maxsize=1)
|
| 67 |
+
def get_settings() -> Settings:
|
| 68 |
+
return Settings()
|
src/kpaa/guides/__init__.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PIPC 발간 안내서 — 로컬 SQLite FTS5 인덱스.
|
| 2 |
+
|
| 3 |
+
from kpaa.guides import GuidesIndex
|
| 4 |
+
idx = GuidesIndex.default()
|
| 5 |
+
hits = idx.search("CCTV 안내문구", k=3)
|
| 6 |
+
for h in hits:
|
| 7 |
+
print(h.citation(), h.section)
|
| 8 |
+
|
| 9 |
+
리포 동봉 스냅샷은 `data/guides.sqlite`. 갱신은 `kpaa build-guides`.
|
| 10 |
+
|
| 11 |
+
청크 원본(`data/guide/chunks/*.jsonl`)은 사용자 + Claude 의 인터랙티브
|
| 12 |
+
큐레이션 결과 — 자동 휴리스틱으로 만들어지지 않으므로 PDF 변경 시 jsonl 도
|
| 13 |
+
함께 갱신해야 한다.
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
from kpaa.guides.index import (
|
| 20 |
+
count as _count,
|
| 21 |
+
)
|
| 22 |
+
from kpaa.guides.index import (
|
| 23 |
+
default_db_path,
|
| 24 |
+
default_meta_path,
|
| 25 |
+
)
|
| 26 |
+
from kpaa.guides.index import (
|
| 27 |
+
doc_count as _doc_count,
|
| 28 |
+
)
|
| 29 |
+
from kpaa.guides.index import (
|
| 30 |
+
search as _search,
|
| 31 |
+
)
|
| 32 |
+
from kpaa.guides.models import GuideChunk
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class GuidesIndex:
|
| 36 |
+
"""사용자 친화적 진입점."""
|
| 37 |
+
|
| 38 |
+
def __init__(self, db_path: Path | None = None) -> None:
|
| 39 |
+
self.db_path = db_path or default_db_path()
|
| 40 |
+
|
| 41 |
+
@classmethod
|
| 42 |
+
def default(cls) -> GuidesIndex:
|
| 43 |
+
return cls()
|
| 44 |
+
|
| 45 |
+
@property
|
| 46 |
+
def total(self) -> int:
|
| 47 |
+
return _count(self.db_path)
|
| 48 |
+
|
| 49 |
+
@property
|
| 50 |
+
def doc_total(self) -> int:
|
| 51 |
+
return _doc_count(self.db_path)
|
| 52 |
+
|
| 53 |
+
def search(self, query: str, *, k: int = 5) -> list[GuideChunk]:
|
| 54 |
+
return _search(query, k=k, db_path=self.db_path)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
__all__ = ["GuidesIndex", "GuideChunk", "default_db_path", "default_meta_path"]
|
src/kpaa/guides/builder.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""`data/guide/chunks/*.jsonl` → `data/guides.sqlite` 빌드.
|
| 2 |
+
|
| 3 |
+
청크 자체는 사용자 + Claude 의 인터랙티브 큐레이션 결과(jsonl). 본 모듈은 그
|
| 4 |
+
파일들을 읽어 sqlite/FTS5 인덱스에 적재하고 manifest 를 갱신할 뿐, PDF 청킹
|
| 5 |
+
로직은 수행하지 않는다.
|
| 6 |
+
|
| 7 |
+
manifest(`data/guides_meta.json`):
|
| 8 |
+
{
|
| 9 |
+
"doc_id": {
|
| 10 |
+
"doc_title": ..., "doc_date": ...,
|
| 11 |
+
"source_pdf": ..., "chunks_count": N
|
| 12 |
+
}, ...
|
| 13 |
+
"_built_at": "YYYY-MM-DD HH:MM:SS",
|
| 14 |
+
"_total_chunks": N
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
실행: `kpaa build-guides` (cli.py 의 핸들러가 이 모듈을 호출).
|
| 18 |
+
"""
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import json
|
| 22 |
+
import logging
|
| 23 |
+
import sys
|
| 24 |
+
from datetime import datetime
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
|
| 27 |
+
from kpaa.guides.index import (
|
| 28 |
+
build_index,
|
| 29 |
+
chunks_dir,
|
| 30 |
+
default_db_path,
|
| 31 |
+
default_meta_path,
|
| 32 |
+
)
|
| 33 |
+
from kpaa.guides.models import GuideChunk
|
| 34 |
+
|
| 35 |
+
logger = logging.getLogger("kpaa.guides.builder")
|
| 36 |
+
|
| 37 |
+
_STALE_MONTHS = 18
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _load_jsonl(path: Path) -> list[GuideChunk]:
|
| 41 |
+
out: list[GuideChunk] = []
|
| 42 |
+
with path.open("r", encoding="utf-8") as f:
|
| 43 |
+
for ln, line in enumerate(f, 1):
|
| 44 |
+
line = line.strip()
|
| 45 |
+
if not line:
|
| 46 |
+
continue
|
| 47 |
+
try:
|
| 48 |
+
row = json.loads(line)
|
| 49 |
+
except json.JSONDecodeError as e:
|
| 50 |
+
raise RuntimeError(f"{path.name}:{ln} JSON parse error: {e}") from e
|
| 51 |
+
try:
|
| 52 |
+
out.append(GuideChunk.model_validate(row))
|
| 53 |
+
except Exception as e:
|
| 54 |
+
raise RuntimeError(f"{path.name}:{ln} schema error: {e}") from e
|
| 55 |
+
return out
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _months_since(doc_date: str) -> int | None:
|
| 59 |
+
"""'2024.10' → 현재 시점 기준 경과 개월. parse 실패면 None."""
|
| 60 |
+
if not doc_date:
|
| 61 |
+
return None
|
| 62 |
+
parts = doc_date.split(".")
|
| 63 |
+
try:
|
| 64 |
+
year = int(parts[0])
|
| 65 |
+
month = int(parts[1]) if len(parts) > 1 else 6
|
| 66 |
+
except (ValueError, IndexError):
|
| 67 |
+
return None
|
| 68 |
+
now = datetime.now()
|
| 69 |
+
return (now.year - year) * 12 + (now.month - month)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def build(*, db_path: Path | None = None, meta_path: Path | None = None) -> int:
|
| 73 |
+
"""`data/guide/chunks/*.jsonl` 전부 읽어 sqlite 빌드. 청크 총 개수 반환."""
|
| 74 |
+
cdir = chunks_dir()
|
| 75 |
+
cdir.mkdir(parents=True, exist_ok=True)
|
| 76 |
+
files = sorted(cdir.glob("*.jsonl"))
|
| 77 |
+
|
| 78 |
+
db = db_path or default_db_path()
|
| 79 |
+
meta = meta_path or default_meta_path()
|
| 80 |
+
|
| 81 |
+
all_chunks: list[GuideChunk] = []
|
| 82 |
+
manifest: dict = {}
|
| 83 |
+
|
| 84 |
+
if not files:
|
| 85 |
+
logger.warning(
|
| 86 |
+
"%s 에 청크 jsonl 이 없습니다. `kpaa extract-guide` 후 인터랙티브 "
|
| 87 |
+
"청킹으로 jsonl 을 먼저 작성하세요.",
|
| 88 |
+
cdir,
|
| 89 |
+
)
|
| 90 |
+
# 빈 인덱스로라도 빌드 — 회귀 안전망 (검색은 0건 반환)
|
| 91 |
+
build_index([], db_path=db)
|
| 92 |
+
manifest["_built_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
| 93 |
+
manifest["_total_chunks"] = 0
|
| 94 |
+
meta.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
| 95 |
+
return 0
|
| 96 |
+
|
| 97 |
+
for f in files:
|
| 98 |
+
chunks = _load_jsonl(f)
|
| 99 |
+
if not chunks:
|
| 100 |
+
logger.warning("%s 비어 있음 — skip", f.name)
|
| 101 |
+
continue
|
| 102 |
+
first = chunks[0]
|
| 103 |
+
# doc_id 일관성 검증
|
| 104 |
+
for c in chunks:
|
| 105 |
+
if c.doc_id != first.doc_id:
|
| 106 |
+
raise RuntimeError(
|
| 107 |
+
f"{f.name} 안에서 doc_id 가 섞여 있습니다: "
|
| 108 |
+
f"{first.doc_id!r} vs {c.doc_id!r}"
|
| 109 |
+
)
|
| 110 |
+
all_chunks.extend(chunks)
|
| 111 |
+
manifest[first.doc_id] = {
|
| 112 |
+
"doc_title": first.doc_title,
|
| 113 |
+
"doc_date": first.doc_date,
|
| 114 |
+
"source_pdf": first.source_pdf,
|
| 115 |
+
"chunks_count": len(chunks),
|
| 116 |
+
}
|
| 117 |
+
# 18개월 경과 경고
|
| 118 |
+
age = _months_since(first.doc_date)
|
| 119 |
+
if age is not None and age > _STALE_MONTHS:
|
| 120 |
+
print(
|
| 121 |
+
f"⚠ {first.doc_title} ({first.doc_date}) — 발간 {age}개월 경과. "
|
| 122 |
+
f"PIPC 최신본 확인 권장.",
|
| 123 |
+
file=sys.stderr,
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
build_index(all_chunks, db_path=db)
|
| 127 |
+
|
| 128 |
+
manifest["_built_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
| 129 |
+
manifest["_total_chunks"] = len(all_chunks)
|
| 130 |
+
meta.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
| 131 |
+
|
| 132 |
+
print(
|
| 133 |
+
f"✓ guides.sqlite 빌드 완료: {len(all_chunks)} 청크, "
|
| 134 |
+
f"{len(files)} 문서 → {db}",
|
| 135 |
+
file=sys.stderr,
|
| 136 |
+
)
|
| 137 |
+
return len(all_chunks)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
__all__ = ["build"]
|
src/kpaa/guides/extractor.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""docling 으로 PDF → 원시 markdown 추출만 담당 (청킹은 인터랙티브 단계).
|
| 2 |
+
|
| 3 |
+
청크 경계 결정은 사용자 + Claude 의 대화로 진행되므로, 이 모듈은 'PDF 한 권을
|
| 4 |
+
가능한 한 충실한 markdown 으로 변환' 까지만 책임진다. 결과는 작업용 파일로
|
| 5 |
+
`data/guide/extracted/{doc_id}.md` 에 떨어지고, Claude 가 다음 세션에서 이
|
| 6 |
+
파일을 읽어 청크 후보를 사용자에게 제안한다.
|
| 7 |
+
|
| 8 |
+
페이지 마커 — docling 의 `page_break_placeholder` 옵션을 사용해
|
| 9 |
+
`<!-- PAGE: N -->` 마커를 페이지 경계마다 삽입. 청킹 시 각 청크의 시작/끝
|
| 10 |
+
페이지를 추적해 `pages` 메타로 기록하기 위함.
|
| 11 |
+
|
| 12 |
+
옵트인 의존성: `pip install -e ".[pipc-ocr]"` (docling). PIPC 별지 OCR 과 같은
|
| 13 |
+
extra 를 재사용하지만 본 모듈은 OCR 을 *끄고* 동작 — privacy.go.kr 안내서는
|
| 14 |
+
born-digital 이라 텍스트 직접 추출이 가능하다.
|
| 15 |
+
|
| 16 |
+
추출 결과가 500자 미만이면 RuntimeError → 운영자가 PDF 가 스캔본인지 직접
|
| 17 |
+
확인하도록 강제 (은밀한 OCR 폴백으로 인덱스를 오염시키지 않는다).
|
| 18 |
+
"""
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import logging
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
|
| 24 |
+
logger = logging.getLogger("kpaa.guides.extractor")
|
| 25 |
+
|
| 26 |
+
_MIN_CHARS = 500
|
| 27 |
+
|
| 28 |
+
# 페이지 경계 마커. 청킹 시 청크의 시작/끝 페이지 추적에 사용.
|
| 29 |
+
# 형식: '<!-- PAGE: N -->' (N = 1-based page number).
|
| 30 |
+
PAGE_MARKER_PREFIX = "<!-- PAGE: "
|
| 31 |
+
PAGE_MARKER_SUFFIX = " -->"
|
| 32 |
+
|
| 33 |
+
_converter = None # lazy singleton
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _get_converter():
|
| 37 |
+
"""docling DocumentConverter (lazy + singleton, OCR off).
|
| 38 |
+
|
| 39 |
+
PIPC 별지용 컨버터(retrieval/pipc_annex.py)는 EasyOCR 를 켜는 반면,
|
| 40 |
+
안내서 PDF 는 born-digital 이라 do_ocr=False 로 충분하고 빠르다.
|
| 41 |
+
"""
|
| 42 |
+
global _converter
|
| 43 |
+
if _converter is not None:
|
| 44 |
+
return _converter
|
| 45 |
+
try:
|
| 46 |
+
from docling.datamodel.base_models import InputFormat
|
| 47 |
+
from docling.datamodel.pipeline_options import PdfPipelineOptions
|
| 48 |
+
from docling.document_converter import DocumentConverter, PdfFormatOption
|
| 49 |
+
except ImportError as e:
|
| 50 |
+
raise RuntimeError(
|
| 51 |
+
"docling 이 설치돼 있지 않습니다. `pip install -e \".[pipc-ocr]\"` "
|
| 52 |
+
"로 설치 후 다시 실행하세요."
|
| 53 |
+
) from e
|
| 54 |
+
|
| 55 |
+
pdf_opts = PdfPipelineOptions()
|
| 56 |
+
pdf_opts.do_ocr = False
|
| 57 |
+
pdf_opts.do_table_structure = True
|
| 58 |
+
|
| 59 |
+
_converter = DocumentConverter(
|
| 60 |
+
format_options={
|
| 61 |
+
InputFormat.PDF: PdfFormatOption(pipeline_options=pdf_opts),
|
| 62 |
+
}
|
| 63 |
+
)
|
| 64 |
+
return _converter
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def extract(pdf_path: Path) -> str:
|
| 68 |
+
"""PDF → markdown 문자열. 페이지 경계마다 `<!-- PAGE: N -->` 마커 삽입.
|
| 69 |
+
|
| 70 |
+
Raises:
|
| 71 |
+
RuntimeError: docling 미설치 / 추출 실패 / 500자 미만.
|
| 72 |
+
"""
|
| 73 |
+
if not pdf_path.exists():
|
| 74 |
+
raise FileNotFoundError(pdf_path)
|
| 75 |
+
conv = _get_converter()
|
| 76 |
+
try:
|
| 77 |
+
result = conv.convert(str(pdf_path))
|
| 78 |
+
except Exception as e:
|
| 79 |
+
raise RuntimeError(f"docling 변환 실패 ({pdf_path.name}): {e}") from e
|
| 80 |
+
|
| 81 |
+
doc = result.document
|
| 82 |
+
# 페이지별로 export 해서 페이지 번호 마커를 사이에 삽입.
|
| 83 |
+
# docling 자체 page_break_placeholder 는 동일 문자열만 끼워줘서 페이지 번호가
|
| 84 |
+
# 명시되지 않으므로, 페이지별 export 결과를 직접 합친다.
|
| 85 |
+
parts: list[str] = []
|
| 86 |
+
for pno in sorted(doc.pages.keys()):
|
| 87 |
+
page_md = doc.export_to_markdown(page_no=pno).strip()
|
| 88 |
+
if not page_md:
|
| 89 |
+
continue
|
| 90 |
+
parts.append(f"{PAGE_MARKER_PREFIX}{pno}{PAGE_MARKER_SUFFIX}")
|
| 91 |
+
parts.append(page_md)
|
| 92 |
+
md = "\n\n".join(parts).strip()
|
| 93 |
+
|
| 94 |
+
if len(md) < _MIN_CHARS:
|
| 95 |
+
raise RuntimeError(
|
| 96 |
+
f"PDF 추출 결과가 {len(md)}자 (< {_MIN_CHARS}). "
|
| 97 |
+
f"스캔본일 가능성 — `{pdf_path.name}` 확인 후 별도 OCR 처리 필요."
|
| 98 |
+
)
|
| 99 |
+
return md
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def derive_doc_id(pdf_filename: str) -> str:
|
| 103 |
+
"""파일명 → 짧은 슬러그.
|
| 104 |
+
|
| 105 |
+
예: '★개인정보의 안전성 확보조치 기준 안내서(2024.10).pdf' →
|
| 106 |
+
'안전성_확보조치_안내서'
|
| 107 |
+
예: '1. 개인정보 질의응답 모음집(2025.12.).pdf' → '질의응답_모음집'
|
| 108 |
+
"""
|
| 109 |
+
name = pdf_filename
|
| 110 |
+
if name.lower().endswith(".pdf"):
|
| 111 |
+
name = name[:-4]
|
| 112 |
+
# 1) 선행 번호 prefix 제거 ("1. ", "2026 ")
|
| 113 |
+
import re
|
| 114 |
+
name = re.sub(r"^[★☆\d\s.\-_]+", "", name).strip()
|
| 115 |
+
# 2) 끝의 (YYYY.MM) / (YYYY.MM.) / (YYYY) 제거
|
| 116 |
+
name = re.sub(r"\([\d.\s]+\)\s*$", "", name).strip()
|
| 117 |
+
# 3) "개인정보의 ", "개인정보 ", "고정형 " 같은 흔한 접두 제거
|
| 118 |
+
for prefix in ("개인정보의 ", "개인정보 ", "고정형 "):
|
| 119 |
+
if name.startswith(prefix):
|
| 120 |
+
name = name[len(prefix):]
|
| 121 |
+
break
|
| 122 |
+
# 4) 공백 → underscore
|
| 123 |
+
name = re.sub(r"\s+", "_", name).strip("_")
|
| 124 |
+
return name or "guide"
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def derive_doc_date(pdf_filename: str) -> str:
|
| 128 |
+
"""파일명 끝의 (YYYY.MM) 또는 (YYYY) 추출. 없으면 빈 문자열."""
|
| 129 |
+
import re
|
| 130 |
+
m = re.search(r"\((\d{4})(?:[.\-](\d{1,2}))?[.\s]*\)", pdf_filename)
|
| 131 |
+
if not m:
|
| 132 |
+
# "2026 개인정보 처리방침..." 같이 prefix 에 연도만 있는 경우
|
| 133 |
+
m2 = re.search(r"(?:^|\s)(\d{4})(?:\s|$)", pdf_filename)
|
| 134 |
+
if m2:
|
| 135 |
+
return m2.group(1)
|
| 136 |
+
return ""
|
| 137 |
+
year = m.group(1)
|
| 138 |
+
month = m.group(2)
|
| 139 |
+
if month:
|
| 140 |
+
return f"{year}.{int(month):02d}"
|
| 141 |
+
return year
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def derive_doc_title(pdf_filename: str) -> str:
|
| 145 |
+
"""파일명 → 사람이 읽을 한국어 제목 (★, 번호, 확장자 제거)."""
|
| 146 |
+
name = pdf_filename
|
| 147 |
+
if name.lower().endswith(".pdf"):
|
| 148 |
+
name = name[:-4]
|
| 149 |
+
import re
|
| 150 |
+
name = re.sub(r"^[★☆]+\s*", "", name)
|
| 151 |
+
name = re.sub(r"^\d+\.\s*", "", name)
|
| 152 |
+
# 끝의 괄호 날짜 제거
|
| 153 |
+
name = re.sub(r"\s*\([\d.\s]+\)\s*$", "", name).strip()
|
| 154 |
+
return name
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
__all__ = ["extract", "derive_doc_id", "derive_doc_date", "derive_doc_title"]
|
src/kpaa/guides/index.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SQLite FTS5 인덱스 — PIPC 발간 안내서 청크용.
|
| 2 |
+
|
| 3 |
+
cases/index.py와 동일한 패턴(unicode61 tokenizer, BM25, 임베딩 없음).
|
| 4 |
+
|
| 5 |
+
DB 스키마:
|
| 6 |
+
guides(chunk_id PK, doc_id, doc_title, doc_date, section,
|
| 7 |
+
chunk_no, body, source_pdf)
|
| 8 |
+
guides_fts (FTS5 가상테이블, 검색용 — title 컬럼은 doc_title+section 합성)
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import sqlite3
|
| 13 |
+
from collections.abc import Iterable
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
from kpaa.guides.models import GuideChunk
|
| 17 |
+
|
| 18 |
+
SCHEMA_SQL = """
|
| 19 |
+
CREATE TABLE IF NOT EXISTS guides (
|
| 20 |
+
chunk_id TEXT PRIMARY KEY,
|
| 21 |
+
doc_id TEXT,
|
| 22 |
+
doc_title TEXT,
|
| 23 |
+
doc_date TEXT,
|
| 24 |
+
section TEXT,
|
| 25 |
+
chunk_no INTEGER,
|
| 26 |
+
body TEXT,
|
| 27 |
+
pages TEXT,
|
| 28 |
+
source_pdf TEXT,
|
| 29 |
+
chunk_context TEXT
|
| 30 |
+
);
|
| 31 |
+
|
| 32 |
+
CREATE VIRTUAL TABLE IF NOT EXISTS guides_fts USING fts5(
|
| 33 |
+
chunk_id UNINDEXED,
|
| 34 |
+
title,
|
| 35 |
+
body,
|
| 36 |
+
section UNINDEXED,
|
| 37 |
+
tokenize = "unicode61 remove_diacritics 2"
|
| 38 |
+
);
|
| 39 |
+
"""
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def default_db_path() -> Path:
|
| 43 |
+
return _data_dir() / "guides.sqlite"
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def default_meta_path() -> Path:
|
| 47 |
+
return _data_dir() / "guides_meta.json"
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def chunks_dir() -> Path:
|
| 51 |
+
return _data_dir() / "guide" / "chunks"
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def extracted_dir() -> Path:
|
| 55 |
+
return _data_dir() / "guide" / "extracted"
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _data_dir() -> Path:
|
| 59 |
+
repo_data = Path(__file__).resolve().parents[3] / "data"
|
| 60 |
+
if repo_data.exists():
|
| 61 |
+
return repo_data
|
| 62 |
+
from kpaa.config import get_settings
|
| 63 |
+
|
| 64 |
+
p = get_settings().cache_root / "guides"
|
| 65 |
+
p.mkdir(parents=True, exist_ok=True)
|
| 66 |
+
return p
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _connect(db_path: Path) -> sqlite3.Connection:
|
| 70 |
+
db_path.parent.mkdir(parents=True, exist_ok=True)
|
| 71 |
+
conn = sqlite3.connect(db_path)
|
| 72 |
+
conn.row_factory = sqlite3.Row
|
| 73 |
+
conn.executescript(SCHEMA_SQL)
|
| 74 |
+
return conn
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _fts_title(c: GuideChunk) -> str:
|
| 78 |
+
"""FTS title 컬럼: 문서명 + 섹션명 합성으로 매칭률 ↑."""
|
| 79 |
+
if c.section:
|
| 80 |
+
return f"{c.doc_title} {c.section}"
|
| 81 |
+
return c.doc_title
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _fts_body(c: GuideChunk) -> str:
|
| 85 |
+
"""FTS body 컬럼: chunk_context가 있으면 body 앞에 prepend (Anthropic Contextual Retrieval)."""
|
| 86 |
+
if c.chunk_context:
|
| 87 |
+
return f"{c.chunk_context}\n\n{c.body}"
|
| 88 |
+
return c.body
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def build_index(chunks: Iterable[GuideChunk], *, db_path: Path) -> None:
|
| 92 |
+
"""전체 다시 빌드 — wipe 후 재삽입."""
|
| 93 |
+
conn = _connect(db_path)
|
| 94 |
+
try:
|
| 95 |
+
with conn:
|
| 96 |
+
conn.execute("DELETE FROM guides")
|
| 97 |
+
conn.execute("DELETE FROM guides_fts")
|
| 98 |
+
for c in chunks:
|
| 99 |
+
conn.execute(
|
| 100 |
+
"""INSERT INTO guides
|
| 101 |
+
(chunk_id, doc_id, doc_title, doc_date,
|
| 102 |
+
section, chunk_no, body, pages, source_pdf, chunk_context)
|
| 103 |
+
VALUES (?,?,?,?,?,?,?,?,?,?)""",
|
| 104 |
+
(
|
| 105 |
+
c.chunk_id, c.doc_id, c.doc_title, c.doc_date,
|
| 106 |
+
c.section, c.chunk_no, c.body, c.pages, c.source_pdf,
|
| 107 |
+
c.chunk_context,
|
| 108 |
+
),
|
| 109 |
+
)
|
| 110 |
+
conn.execute(
|
| 111 |
+
"INSERT INTO guides_fts (chunk_id, title, body, section) VALUES (?,?,?,?)",
|
| 112 |
+
(c.chunk_id, _fts_title(c), _fts_body(c), c.section),
|
| 113 |
+
)
|
| 114 |
+
conn.execute("VACUUM")
|
| 115 |
+
finally:
|
| 116 |
+
conn.close()
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def search(
|
| 120 |
+
query: str, *, k: int = 5, db_path: Path | None = None
|
| 121 |
+
) -> list[GuideChunk]:
|
| 122 |
+
"""FTS5 검색 (cases.search 패턴 그대로: prefix matching + 다중 토큰 OR)."""
|
| 123 |
+
path = db_path or default_db_path()
|
| 124 |
+
if not path.exists():
|
| 125 |
+
return []
|
| 126 |
+
if not query.strip():
|
| 127 |
+
return []
|
| 128 |
+
conn = _connect(path)
|
| 129 |
+
try:
|
| 130 |
+
safe = "".join(ch if ch.isalnum() or ord(ch) > 127 else " " for ch in query)
|
| 131 |
+
tokens = [t for t in safe.split() if t]
|
| 132 |
+
if not tokens:
|
| 133 |
+
return []
|
| 134 |
+
if len(tokens) == 1:
|
| 135 |
+
fts_query = f"{tokens[0]}*"
|
| 136 |
+
else:
|
| 137 |
+
fts_query = " OR ".join(f"{t}*" for t in tokens)
|
| 138 |
+
cur = conn.execute(
|
| 139 |
+
"""SELECT g.* FROM guides_fts f
|
| 140 |
+
JOIN guides g ON g.chunk_id = f.chunk_id
|
| 141 |
+
WHERE guides_fts MATCH ?
|
| 142 |
+
ORDER BY bm25(guides_fts) ASC
|
| 143 |
+
LIMIT ?""",
|
| 144 |
+
(fts_query, k),
|
| 145 |
+
)
|
| 146 |
+
rows = cur.fetchall()
|
| 147 |
+
finally:
|
| 148 |
+
conn.close()
|
| 149 |
+
return [
|
| 150 |
+
GuideChunk(
|
| 151 |
+
chunk_id=r["chunk_id"], doc_id=r["doc_id"],
|
| 152 |
+
doc_title=r["doc_title"], doc_date=r["doc_date"],
|
| 153 |
+
section=r["section"], chunk_no=r["chunk_no"],
|
| 154 |
+
body=r["body"], pages=r["pages"] or "",
|
| 155 |
+
source_pdf=r["source_pdf"],
|
| 156 |
+
chunk_context=(r["chunk_context"] if "chunk_context" in r.keys() else "") or "",
|
| 157 |
+
)
|
| 158 |
+
for r in rows
|
| 159 |
+
]
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def count(db_path: Path | None = None) -> int:
|
| 163 |
+
path = db_path or default_db_path()
|
| 164 |
+
if not path.exists():
|
| 165 |
+
return 0
|
| 166 |
+
conn = _connect(path)
|
| 167 |
+
try:
|
| 168 |
+
cur = conn.execute("SELECT COUNT(*) FROM guides")
|
| 169 |
+
return cur.fetchone()[0]
|
| 170 |
+
finally:
|
| 171 |
+
conn.close()
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def list_all_chunks(*, db_path: Path | None = None) -> list[GuideChunk]:
|
| 175 |
+
path = db_path or default_db_path()
|
| 176 |
+
if not path.exists():
|
| 177 |
+
return []
|
| 178 |
+
conn = _connect(path)
|
| 179 |
+
try:
|
| 180 |
+
cur = conn.execute("SELECT * FROM guides ORDER BY doc_id, chunk_no")
|
| 181 |
+
rows = cur.fetchall()
|
| 182 |
+
finally:
|
| 183 |
+
conn.close()
|
| 184 |
+
return [
|
| 185 |
+
GuideChunk(
|
| 186 |
+
chunk_id=r["chunk_id"], doc_id=r["doc_id"],
|
| 187 |
+
doc_title=r["doc_title"], doc_date=r["doc_date"],
|
| 188 |
+
section=r["section"], chunk_no=r["chunk_no"],
|
| 189 |
+
body=r["body"], pages=r["pages"] or "",
|
| 190 |
+
source_pdf=r["source_pdf"],
|
| 191 |
+
chunk_context=(r["chunk_context"] if "chunk_context" in r.keys() else "") or "",
|
| 192 |
+
)
|
| 193 |
+
for r in rows
|
| 194 |
+
]
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def rebuild_fts(*, db_path: Path | None = None) -> int:
|
| 198 |
+
"""guides 테이블 데이터로 guides_fts만 재생성."""
|
| 199 |
+
path = db_path or default_db_path()
|
| 200 |
+
chunks = list_all_chunks(db_path=path)
|
| 201 |
+
if not chunks:
|
| 202 |
+
return 0
|
| 203 |
+
conn = _connect(path)
|
| 204 |
+
try:
|
| 205 |
+
with conn:
|
| 206 |
+
conn.execute("DROP TABLE IF EXISTS guides_fts")
|
| 207 |
+
finally:
|
| 208 |
+
conn.close()
|
| 209 |
+
conn = _connect(path)
|
| 210 |
+
try:
|
| 211 |
+
with conn:
|
| 212 |
+
for c in chunks:
|
| 213 |
+
conn.execute(
|
| 214 |
+
"INSERT INTO guides_fts (chunk_id, title, body, section) VALUES (?,?,?,?)",
|
| 215 |
+
(c.chunk_id, _fts_title(c), _fts_body(c), c.section),
|
| 216 |
+
)
|
| 217 |
+
conn.execute("VACUUM")
|
| 218 |
+
finally:
|
| 219 |
+
conn.close()
|
| 220 |
+
return len(chunks)
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def doc_count(db_path: Path | None = None) -> int:
|
| 224 |
+
"""고유 doc_id 개수 — meta 표시용."""
|
| 225 |
+
path = db_path or default_db_path()
|
| 226 |
+
if not path.exists():
|
| 227 |
+
return 0
|
| 228 |
+
conn = _connect(path)
|
| 229 |
+
try:
|
| 230 |
+
cur = conn.execute("SELECT COUNT(DISTINCT doc_id) FROM guides")
|
| 231 |
+
return cur.fetchone()[0]
|
| 232 |
+
finally:
|
| 233 |
+
conn.close()
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
__all__ = [
|
| 237 |
+
"build_index", "search", "count", "doc_count",
|
| 238 |
+
"list_all_chunks", "rebuild_fts",
|
| 239 |
+
"default_db_path", "default_meta_path",
|
| 240 |
+
"chunks_dir", "extracted_dir",
|
| 241 |
+
]
|
src/kpaa/guides/models.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel, ConfigDict
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class GuideChunk(BaseModel):
|
| 7 |
+
"""PIPC 발간 안내서 1청크.
|
| 8 |
+
|
| 9 |
+
`data/guide/chunks/{doc_id}.jsonl`의 한 줄과 1:1 매핑.
|
| 10 |
+
인터랙티브 큐레이션(사용자 + Claude)으로 생성된 canonical chunk source.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
model_config = ConfigDict(frozen=True)
|
| 14 |
+
|
| 15 |
+
chunk_id: str # "{doc_id}_{chunk_no:04d}"
|
| 16 |
+
doc_id: str # "안전성_확보조치_안내서"
|
| 17 |
+
doc_title: str # "개인정보의 안전성 확보조치 기준 안내서"
|
| 18 |
+
doc_date: str # "2024.10" (YYYY.MM)
|
| 19 |
+
section: str # "II.3 접근권한 관리"
|
| 20 |
+
chunk_no: int # 0부터 순번
|
| 21 |
+
body: str # 청크 본문 (≤1500자)
|
| 22 |
+
pages: str = "" # 원본 PDF 페이지 범위 (예: "p.8" 또는 "p.8-9")
|
| 23 |
+
source_pdf: str # 원본 PDF 파일명
|
| 24 |
+
chunk_context: str = "" # Anthropic Contextual Retrieval prefix — 인덱싱 시 body 앞에 prepend (답변 출력은 body만)
|
| 25 |
+
|
| 26 |
+
def citation(self) -> str:
|
| 27 |
+
"""답변에 박을 인용 태그.
|
| 28 |
+
|
| 29 |
+
예: '안전성 확보조치 안내서 §II.3 접근권한 관리, 2024.10, p.8-9'
|
| 30 |
+
"""
|
| 31 |
+
short = self.doc_title
|
| 32 |
+
# 흔한 접두 표현 제거해 짧게
|
| 33 |
+
for prefix in ("개인정보의 ", "개인정보 ", "고정형 "):
|
| 34 |
+
if short.startswith(prefix):
|
| 35 |
+
short = short[len(prefix):]
|
| 36 |
+
break
|
| 37 |
+
bits = [short]
|
| 38 |
+
if self.section:
|
| 39 |
+
bits.append(f"§{self.section}")
|
| 40 |
+
out = " ".join(bits)
|
| 41 |
+
tail: list[str] = []
|
| 42 |
+
if self.doc_date:
|
| 43 |
+
tail.append(self.doc_date)
|
| 44 |
+
if self.pages:
|
| 45 |
+
tail.append(self.pages)
|
| 46 |
+
if tail:
|
| 47 |
+
out = f"{out}, {', '.join(tail)}"
|
| 48 |
+
return out
|
src/kpaa/law_api/__init__.py
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""한국 법제처 OPEN API의 Python SDK.
|
| 2 |
+
|
| 3 |
+
`korean-law-mcp`(MCP 서버)의 도구 표면을 참조하되, MCP 프로토콜 의존 없이
|
| 4 |
+
일반 Python 라이브러리로 사용한다.
|
| 5 |
+
|
| 6 |
+
async with KoreanLawClient(oc="my_id") as client:
|
| 7 |
+
hits = await client.law.search("개인정보보호법")
|
| 8 |
+
text = await client.law.get_text(mst=hits[0].mst, jo="15")
|
| 9 |
+
decisions = await client.pipc.search_decisions("동의 철회")
|
| 10 |
+
# 미구현 카테고리도 generic raw 호출 가능
|
| 11 |
+
rows = await client.raw.search("ftc", query="과징금")
|
| 12 |
+
|
| 13 |
+
라이브 검증 / 라이선스 / 미확정 target 정보는 `endpoints.py`를 참조.
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
from typing import Any
|
| 18 |
+
|
| 19 |
+
from kpaa.law_api.client import LawApiCall, LawGoKrClient
|
| 20 |
+
|
| 21 |
+
# ───────────────────────── 네임스페이스 ─────────────────────────
|
| 22 |
+
|
| 23 |
+
class _LawNamespace:
|
| 24 |
+
def __init__(self, c: LawGoKrClient) -> None:
|
| 25 |
+
self._c = c
|
| 26 |
+
|
| 27 |
+
async def search(self, query: str, *, display: int = 20):
|
| 28 |
+
from kpaa.law_api.law import search_law
|
| 29 |
+
|
| 30 |
+
return await search_law(self._c, query=query, display=display)
|
| 31 |
+
|
| 32 |
+
async def get_text(self, *, mst: str, jo: str | int | None = None):
|
| 33 |
+
from kpaa.law_api.law import get_law_text
|
| 34 |
+
|
| 35 |
+
return await get_law_text(self._c, mst=mst, jo=jo)
|
| 36 |
+
|
| 37 |
+
async def get_article(self, *, mst: str, article_no: str | int):
|
| 38 |
+
from kpaa.law_api.law import get_article_detail
|
| 39 |
+
|
| 40 |
+
return await get_article_detail(self._c, mst=mst, article_no=article_no)
|
| 41 |
+
|
| 42 |
+
async def get_annexes(
|
| 43 |
+
self, *, related_law_id: str | None = None, query: str = "", display: int = 20
|
| 44 |
+
):
|
| 45 |
+
from kpaa.law_api.law import get_annexes
|
| 46 |
+
|
| 47 |
+
return await get_annexes(
|
| 48 |
+
self._c, related_law_id=related_law_id, query=query, display=display
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class _PIPCNamespace:
|
| 53 |
+
def __init__(self, c: LawGoKrClient) -> None:
|
| 54 |
+
self._c = c
|
| 55 |
+
|
| 56 |
+
async def search_decisions(self, query: str, *, display: int = 20):
|
| 57 |
+
from kpaa.law_api.pipc import search_decisions
|
| 58 |
+
|
| 59 |
+
return await search_decisions(self._c, query=query, display=display)
|
| 60 |
+
|
| 61 |
+
async def get_decision_text(self, *, decision_id: str):
|
| 62 |
+
from kpaa.law_api.pipc import get_decision_text
|
| 63 |
+
|
| 64 |
+
return await get_decision_text(self._c, decision_id=decision_id)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class _InterpretationNamespace:
|
| 68 |
+
def __init__(self, c: LawGoKrClient) -> None:
|
| 69 |
+
self._c = c
|
| 70 |
+
|
| 71 |
+
async def search(self, query: str, *, display: int = 20):
|
| 72 |
+
from kpaa.law_api.interpretation import search_interpretations
|
| 73 |
+
|
| 74 |
+
return await search_interpretations(self._c, query=query, display=display)
|
| 75 |
+
|
| 76 |
+
async def get_text(self, *, interpretation_id: str):
|
| 77 |
+
from kpaa.law_api.interpretation import get_interpretation_text
|
| 78 |
+
|
| 79 |
+
return await get_interpretation_text(self._c, interpretation_id=interpretation_id)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class _AdminRuleNamespace:
|
| 83 |
+
def __init__(self, c: LawGoKrClient) -> None:
|
| 84 |
+
self._c = c
|
| 85 |
+
|
| 86 |
+
async def search(self, query: str, *, knd: int | None = None, display: int = 20):
|
| 87 |
+
from kpaa.law_api.admin_rule import search_admin_rules
|
| 88 |
+
|
| 89 |
+
return await search_admin_rules(self._c, query=query, knd=knd, display=display)
|
| 90 |
+
|
| 91 |
+
async def get_text(self, *, rule_id: str):
|
| 92 |
+
from kpaa.law_api.admin_rule import get_admin_rule_text
|
| 93 |
+
|
| 94 |
+
return await get_admin_rule_text(self._c, rule_id=rule_id)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
class _PrecedentNamespace:
|
| 98 |
+
def __init__(self, c: LawGoKrClient) -> None:
|
| 99 |
+
self._c = c
|
| 100 |
+
|
| 101 |
+
async def search(self, query: str, *, display: int = 20):
|
| 102 |
+
from kpaa.law_api.precedent import search_precedents
|
| 103 |
+
|
| 104 |
+
return await search_precedents(self._c, query=query, display=display)
|
| 105 |
+
|
| 106 |
+
async def get_text(self, *, precedent_id: str):
|
| 107 |
+
from kpaa.law_api.precedent import get_precedent_text
|
| 108 |
+
|
| 109 |
+
return await get_precedent_text(self._c, precedent_id=precedent_id)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
class _OrdinanceNamespace:
|
| 113 |
+
def __init__(self, c: LawGoKrClient) -> None:
|
| 114 |
+
self._c = c
|
| 115 |
+
|
| 116 |
+
async def search(self, query: str, *, display: int = 20):
|
| 117 |
+
from kpaa.law_api.ordinance import search_ordinances
|
| 118 |
+
|
| 119 |
+
return await search_ordinances(self._c, query=query, display=display)
|
| 120 |
+
|
| 121 |
+
async def get_text(self, *, ordinance_id: str):
|
| 122 |
+
from kpaa.law_api.ordinance import get_ordinance_text
|
| 123 |
+
|
| 124 |
+
return await get_ordinance_text(self._c, ordinance_id=ordinance_id)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
class _TreatyNamespace:
|
| 128 |
+
def __init__(self, c: LawGoKrClient) -> None:
|
| 129 |
+
self._c = c
|
| 130 |
+
|
| 131 |
+
async def search(self, query: str, *, display: int = 20):
|
| 132 |
+
from kpaa.law_api.treaty import search_treaties
|
| 133 |
+
|
| 134 |
+
return await search_treaties(self._c, query=query, display=display)
|
| 135 |
+
|
| 136 |
+
async def get_text(self, *, treaty_id: str):
|
| 137 |
+
from kpaa.law_api.treaty import get_treaty_text
|
| 138 |
+
|
| 139 |
+
return await get_treaty_text(self._c, treaty_id=treaty_id)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
class _FTCNamespace:
|
| 143 |
+
def __init__(self, c: LawGoKrClient) -> None:
|
| 144 |
+
self._c = c
|
| 145 |
+
|
| 146 |
+
async def search(self, query: str, *, display: int = 20):
|
| 147 |
+
from kpaa.law_api.ftc import search_ftc_decisions
|
| 148 |
+
|
| 149 |
+
return await search_ftc_decisions(self._c, query=query, display=display)
|
| 150 |
+
|
| 151 |
+
async def get_text(self, *, decision_id: str):
|
| 152 |
+
from kpaa.law_api.ftc import get_ftc_decision_text
|
| 153 |
+
|
| 154 |
+
return await get_ftc_decision_text(self._c, decision_id=decision_id)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
class _NLRCNamespace:
|
| 158 |
+
def __init__(self, c: LawGoKrClient) -> None:
|
| 159 |
+
self._c = c
|
| 160 |
+
|
| 161 |
+
async def search(self, query: str, *, display: int = 20):
|
| 162 |
+
from kpaa.law_api.nlrc import search_nlrc_decisions
|
| 163 |
+
|
| 164 |
+
return await search_nlrc_decisions(self._c, query=query, display=display)
|
| 165 |
+
|
| 166 |
+
async def get_text(self, *, decision_id: str):
|
| 167 |
+
from kpaa.law_api.nlrc import get_nlrc_decision_text
|
| 168 |
+
|
| 169 |
+
return await get_nlrc_decision_text(self._c, decision_id=decision_id)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
class _ACRNamespace:
|
| 173 |
+
def __init__(self, c: LawGoKrClient) -> None:
|
| 174 |
+
self._c = c
|
| 175 |
+
|
| 176 |
+
async def search(self, query: str, *, display: int = 20):
|
| 177 |
+
from kpaa.law_api.acr import search_acr_decisions
|
| 178 |
+
|
| 179 |
+
return await search_acr_decisions(self._c, query=query, display=display)
|
| 180 |
+
|
| 181 |
+
async def get_text(self, *, decision_id: str):
|
| 182 |
+
from kpaa.law_api.acr import get_acr_decision_text
|
| 183 |
+
|
| 184 |
+
return await get_acr_decision_text(self._c, decision_id=decision_id)
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
class _TermsNamespace:
|
| 188 |
+
def __init__(self, c: LawGoKrClient) -> None:
|
| 189 |
+
self._c = c
|
| 190 |
+
|
| 191 |
+
async def search(self, query: str, *, display: int = 20):
|
| 192 |
+
from kpaa.law_api.terms import search_legal_terms
|
| 193 |
+
|
| 194 |
+
return await search_legal_terms(self._c, query=query, display=display)
|
| 195 |
+
|
| 196 |
+
async def get_detail(self, *, term_id: str):
|
| 197 |
+
from kpaa.law_api.terms import get_legal_term_detail
|
| 198 |
+
|
| 199 |
+
return await get_legal_term_detail(self._c, term_id=term_id)
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
class _EnglishNamespace:
|
| 203 |
+
def __init__(self, c: LawGoKrClient) -> None:
|
| 204 |
+
self._c = c
|
| 205 |
+
|
| 206 |
+
async def search(self, query: str, *, display: int = 20):
|
| 207 |
+
from kpaa.law_api.english import search_english_laws
|
| 208 |
+
|
| 209 |
+
return await search_english_laws(self._c, query=query, display=display)
|
| 210 |
+
|
| 211 |
+
async def get_text(self, *, mst: str):
|
| 212 |
+
from kpaa.law_api.english import get_english_law_text
|
| 213 |
+
|
| 214 |
+
return await get_english_law_text(self._c, mst=mst)
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
class _ArticleHistoryNamespace:
|
| 218 |
+
def __init__(self, c: LawGoKrClient) -> None:
|
| 219 |
+
self._c = c
|
| 220 |
+
|
| 221 |
+
async def search(
|
| 222 |
+
self,
|
| 223 |
+
*,
|
| 224 |
+
law_id: str,
|
| 225 |
+
jo: str | int | None = None,
|
| 226 |
+
from_date: str | None = None,
|
| 227 |
+
to_date: str | None = None,
|
| 228 |
+
on_date: str | None = None,
|
| 229 |
+
org: str | None = None,
|
| 230 |
+
page: int = 1,
|
| 231 |
+
):
|
| 232 |
+
from kpaa.law_api.article_history import search_article_history
|
| 233 |
+
|
| 234 |
+
return await search_article_history(
|
| 235 |
+
self._c,
|
| 236 |
+
law_id=law_id,
|
| 237 |
+
jo=jo,
|
| 238 |
+
from_date=from_date,
|
| 239 |
+
to_date=to_date,
|
| 240 |
+
on_date=on_date,
|
| 241 |
+
org=org,
|
| 242 |
+
page=page,
|
| 243 |
+
)
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
class _ConstitutionalNamespace:
|
| 247 |
+
def __init__(self, c: LawGoKrClient) -> None:
|
| 248 |
+
self._c = c
|
| 249 |
+
|
| 250 |
+
async def search(
|
| 251 |
+
self,
|
| 252 |
+
query: str,
|
| 253 |
+
*,
|
| 254 |
+
display: int = 20,
|
| 255 |
+
case_number: str | None = None,
|
| 256 |
+
sort: str | None = None,
|
| 257 |
+
):
|
| 258 |
+
from kpaa.law_api.constitutional import search_constitutional_decisions
|
| 259 |
+
|
| 260 |
+
return await search_constitutional_decisions(
|
| 261 |
+
self._c,
|
| 262 |
+
query=query,
|
| 263 |
+
display=display,
|
| 264 |
+
case_number=case_number,
|
| 265 |
+
sort=sort,
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
async def get_text(self, *, decision_id: str):
|
| 269 |
+
from kpaa.law_api.constitutional import get_constitutional_decision_text
|
| 270 |
+
|
| 271 |
+
return await get_constitutional_decision_text(
|
| 272 |
+
self._c, decision_id=decision_id
|
| 273 |
+
)
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
class _OldNewNamespace:
|
| 277 |
+
def __init__(self, c: LawGoKrClient) -> None:
|
| 278 |
+
self._c = c
|
| 279 |
+
|
| 280 |
+
async def search(self, query: str, *, display: int = 20):
|
| 281 |
+
from kpaa.law_api.oldnew import search_old_new
|
| 282 |
+
|
| 283 |
+
return await search_old_new(self._c, query=query, display=display)
|
| 284 |
+
|
| 285 |
+
async def compare(self, *, mst: str):
|
| 286 |
+
from kpaa.law_api.oldnew import compare_old_new
|
| 287 |
+
|
| 288 |
+
return await compare_old_new(self._c, mst=mst)
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
class _RawNamespace:
|
| 292 |
+
"""Generic 호출 — 풀구현된 카테고리 외 임의 target에 사용."""
|
| 293 |
+
|
| 294 |
+
def __init__(self, c: LawGoKrClient) -> None:
|
| 295 |
+
self._c = c
|
| 296 |
+
|
| 297 |
+
async def search(
|
| 298 |
+
self,
|
| 299 |
+
target: str,
|
| 300 |
+
*,
|
| 301 |
+
query: str = "",
|
| 302 |
+
display: int = 20,
|
| 303 |
+
extra_params: dict[str, Any] | None = None,
|
| 304 |
+
):
|
| 305 |
+
from kpaa.law_api.raw import raw_search
|
| 306 |
+
|
| 307 |
+
return await raw_search(
|
| 308 |
+
self._c, target, query=query, display=display, extra_params=extra_params
|
| 309 |
+
)
|
| 310 |
+
|
| 311 |
+
async def get(
|
| 312 |
+
self,
|
| 313 |
+
target: str,
|
| 314 |
+
*,
|
| 315 |
+
id: str | None = None,
|
| 316 |
+
mst: str | None = None,
|
| 317 |
+
extra_params: dict[str, Any] | None = None,
|
| 318 |
+
) -> str:
|
| 319 |
+
from kpaa.law_api.raw import raw_get
|
| 320 |
+
|
| 321 |
+
return await raw_get(
|
| 322 |
+
self._c, target, id=id, mst=mst, extra_params=extra_params
|
| 323 |
+
)
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
# ───────────────────────── 진입점 ─────────────────────────
|
| 327 |
+
|
| 328 |
+
class KoreanLawClient:
|
| 329 |
+
"""High-level 법제처 SDK.
|
| 330 |
+
|
| 331 |
+
카테고리별 네임스페이스로 노출 (라이브 검증된 15개 target 토큰 기반).
|
| 332 |
+
미구현 카테고리는 `client.raw.search/get`으로 임의 target 호출 가능.
|
| 333 |
+
"""
|
| 334 |
+
|
| 335 |
+
def __init__(self, oc: str | None = None) -> None:
|
| 336 |
+
self._client = LawGoKrClient(oc=oc)
|
| 337 |
+
self.law = _LawNamespace(self._client)
|
| 338 |
+
self.pipc = _PIPCNamespace(self._client)
|
| 339 |
+
self.interpretation = _InterpretationNamespace(self._client)
|
| 340 |
+
self.admin_rule = _AdminRuleNamespace(self._client)
|
| 341 |
+
self.precedent = _PrecedentNamespace(self._client)
|
| 342 |
+
self.ordinance = _OrdinanceNamespace(self._client)
|
| 343 |
+
self.treaty = _TreatyNamespace(self._client)
|
| 344 |
+
self.ftc = _FTCNamespace(self._client)
|
| 345 |
+
self.nlrc = _NLRCNamespace(self._client)
|
| 346 |
+
self.acr = _ACRNamespace(self._client)
|
| 347 |
+
self.terms = _TermsNamespace(self._client)
|
| 348 |
+
self.english = _EnglishNamespace(self._client)
|
| 349 |
+
self.old_new = _OldNewNamespace(self._client)
|
| 350 |
+
self.constitutional = _ConstitutionalNamespace(self._client)
|
| 351 |
+
self.article_history = _ArticleHistoryNamespace(self._client)
|
| 352 |
+
self.raw = _RawNamespace(self._client)
|
| 353 |
+
|
| 354 |
+
async def __aenter__(self) -> KoreanLawClient:
|
| 355 |
+
await self._client.__aenter__()
|
| 356 |
+
return self
|
| 357 |
+
|
| 358 |
+
async def __aexit__(self, *exc) -> None:
|
| 359 |
+
await self._client.__aexit__(*exc)
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
__all__ = ["KoreanLawClient", "LawGoKrClient", "LawApiCall"]
|
src/kpaa/law_api/acr.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""국민권익위원회 결정 (target=acr).
|
| 2 |
+
|
| 3 |
+
라이브 검증 row 필드: 결정문일련번호 / 제목 / 민원표시명 / 의안번호 /
|
| 4 |
+
회의종류 / 결정구분 / 의결일.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from kpaa.law_api.client import DETAIL_PATH, LIST_PATH, LawApiCall, LawGoKrClient
|
| 9 |
+
from kpaa.law_api.endpoints import ACR
|
| 10 |
+
from kpaa.law_api.parsers import child_text, parse_xml
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
async def search_acr_decisions(
|
| 14 |
+
client: LawGoKrClient, *, query: str, display: int = 20
|
| 15 |
+
) -> list[dict[str, str]]:
|
| 16 |
+
if not 1 <= display <= 100:
|
| 17 |
+
raise ValueError("display must be in [1, 100]")
|
| 18 |
+
body = await client.fetch(
|
| 19 |
+
LawApiCall(target=ACR.target, path=LIST_PATH,
|
| 20 |
+
params={"query": query, "display": str(display)},
|
| 21 |
+
cache_ttl=3600)
|
| 22 |
+
)
|
| 23 |
+
root = parse_xml(body)
|
| 24 |
+
return [
|
| 25 |
+
{
|
| 26 |
+
"decision_id": child_text(el, "결정문일련번호"),
|
| 27 |
+
"title": child_text(el, "제목"),
|
| 28 |
+
"subject": child_text(el, "민원표시명"),
|
| 29 |
+
"decision_no": child_text(el, "의안번호"),
|
| 30 |
+
"meeting_kind": child_text(el, "회의종류"),
|
| 31 |
+
"decision_kind": child_text(el, "결정구분"),
|
| 32 |
+
"decision_date": child_text(el, "의결일"),
|
| 33 |
+
}
|
| 34 |
+
for el in root.findall(f".//{ACR.row_tag}")
|
| 35 |
+
]
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
async def get_acr_decision_text(client: LawGoKrClient, *, decision_id: str) -> str:
|
| 39 |
+
return await client.fetch(
|
| 40 |
+
LawApiCall(target=ACR.target, path=DETAIL_PATH,
|
| 41 |
+
params={"ID": str(decision_id)}, cache_ttl=24 * 3600)
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
__all__ = ["search_acr_decisions", "get_acr_decision_text"]
|
src/kpaa/law_api/admin_rule.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""행정규칙 (훈령/예규/고시) — target=admrul.
|
| 2 |
+
|
| 3 |
+
knd 파라미터: 1=훈령, 2=예규, 3=고시 (필터, 미지정시 전체).
|
| 4 |
+
챗봇은 개인정보보호위원회 고시(knd=3)에 관심.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from kpaa.law_api.client import DETAIL_PATH, LIST_PATH, LawApiCall, LawGoKrClient
|
| 9 |
+
from kpaa.law_api.endpoints import ADMRUL
|
| 10 |
+
from kpaa.law_api.parsers import child_text, parse_xml
|
| 11 |
+
|
| 12 |
+
CACHE_TTL_SEARCH = 3600
|
| 13 |
+
CACHE_TTL_DETAIL = 24 * 3600
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
async def search_admin_rules(
|
| 17 |
+
client: LawGoKrClient,
|
| 18 |
+
*,
|
| 19 |
+
query: str,
|
| 20 |
+
knd: int | None = None,
|
| 21 |
+
display: int = 20,
|
| 22 |
+
) -> list[dict[str, str]]:
|
| 23 |
+
"""행정규칙 목록 검색.
|
| 24 |
+
|
| 25 |
+
Args:
|
| 26 |
+
knd: 1=훈령, 2=예규, 3=고시. None이면 전체.
|
| 27 |
+
"""
|
| 28 |
+
if not 1 <= display <= 100:
|
| 29 |
+
raise ValueError("display must be in [1, 100]")
|
| 30 |
+
params: dict[str, str] = {"query": query, "display": str(display)}
|
| 31 |
+
if knd is not None:
|
| 32 |
+
if knd not in (1, 2, 3):
|
| 33 |
+
raise ValueError("knd must be 1(훈령), 2(예규), or 3(고시)")
|
| 34 |
+
params["knd"] = str(knd)
|
| 35 |
+
call = LawApiCall(
|
| 36 |
+
target=ADMRUL.target, path=LIST_PATH, params=params, cache_ttl=CACHE_TTL_SEARCH
|
| 37 |
+
)
|
| 38 |
+
body = await client.fetch(call)
|
| 39 |
+
root = parse_xml(body)
|
| 40 |
+
out: list[dict[str, str]] = []
|
| 41 |
+
for el in root.findall(f".//{ADMRUL.row_tag}"):
|
| 42 |
+
out.append(
|
| 43 |
+
{
|
| 44 |
+
"rule_id": child_text(el, ADMRUL.id_field),
|
| 45 |
+
"name": child_text(el, "행정규칙명"),
|
| 46 |
+
"kind": child_text(el, "행정규칙종류"),
|
| 47 |
+
"issue_date": child_text(el, "발령일자"),
|
| 48 |
+
"issue_no": child_text(el, "발령번호"),
|
| 49 |
+
"department": child_text(el, "소관부처명"),
|
| 50 |
+
"status": child_text(el, "현행연혁구분"),
|
| 51 |
+
"amend_type": child_text(el, "제개정구분명"),
|
| 52 |
+
"enforce_date": child_text(el, "시행일자"),
|
| 53 |
+
}
|
| 54 |
+
)
|
| 55 |
+
return out
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
async def get_admin_rule_text(
|
| 59 |
+
client: LawGoKrClient,
|
| 60 |
+
*,
|
| 61 |
+
rule_id: str,
|
| 62 |
+
) -> str:
|
| 63 |
+
"""행정규칙 본문 — raw XML 반환."""
|
| 64 |
+
call = LawApiCall(
|
| 65 |
+
target=ADMRUL.target, path=DETAIL_PATH,
|
| 66 |
+
params={"ID": str(rule_id)},
|
| 67 |
+
cache_ttl=CACHE_TTL_DETAIL,
|
| 68 |
+
)
|
| 69 |
+
return await client.fetch(call)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
__all__ = ["search_admin_rules", "get_admin_rule_text"]
|
src/kpaa/law_api/aliases.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""법령 약칭 ↔ 정식명 매핑.
|
| 2 |
+
|
| 3 |
+
법제처 OPEN API는 검색어로 *약칭*을 받으면 0건이 자주 나는 알려진 함정이 있다
|
| 4 |
+
(예: "신용정보법" 직접 검색 → 빈 결과). 이 모듈은:
|
| 5 |
+
|
| 6 |
+
1. 정식명 → 약칭 리스트(`LAW_ALIASES`)와 그 역방향(`ABBR_TO_OFFICIAL`)을 동시 제공.
|
| 7 |
+
2. 사용자 질의에서 약칭을 *정식명* 으로 치환(`normalize_law_name`).
|
| 8 |
+
3. 단일 쿼리를 *검색 후보 다중화*해 폴백 큐로 활용(`expand_search_terms`).
|
| 9 |
+
|
| 10 |
+
쿼리 시점·LLM 라우터·rule fallback 모두 이 모듈을 import해 일관 처리한다.
|
| 11 |
+
원작 chrisryugj/korean-law-mcp `LAW_ALIAS_ENTRIES`(52종) 의 매핑 사실을 참고하되,
|
| 12 |
+
KPAA 도메인(개인정보·정보통신·금융·의료) 항목을 우선 보강했다.
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import re
|
| 17 |
+
from functools import lru_cache
|
| 18 |
+
|
| 19 |
+
# ────────────────────────────────────────────────────────────────────────────
|
| 20 |
+
# 정식명 → 약칭 리스트
|
| 21 |
+
# ────────────────────────────────────────────────────────────────────────────
|
| 22 |
+
# 원칙: 정식 법령명(법제처 등록명)을 키로, 통용 약칭을 값으로.
|
| 23 |
+
# 약칭은 띄어쓰기·중점(·) 변형까지 포함해 매칭 누수를 줄인다.
|
| 24 |
+
# 도메인 분류는 주석으로만 남김 (코드는 단일 dict).
|
| 25 |
+
LAW_ALIASES: dict[str, list[str]] = {
|
| 26 |
+
# ── 개인정보 도메인 (KPAA 핵심) ──────────────────────────────────────
|
| 27 |
+
"개인정보 보호법": ["개인정보보호법", "개보법", "개인정보법"],
|
| 28 |
+
"개인정보 보호법 시행령": ["개인정보보호법 시행령", "개보법 시행령", "보호법 시행령"],
|
| 29 |
+
"개인정보 보호법 시행규칙": ["개인정보보호법 시행규칙", "개보법 시행규칙"],
|
| 30 |
+
"정보통신망 이용촉진 및 정보보호 등에 관한 법률": [
|
| 31 |
+
"정보통신망법",
|
| 32 |
+
"정통망법",
|
| 33 |
+
"정보통신망 이용촉진법",
|
| 34 |
+
],
|
| 35 |
+
"신용정보의 이용 및 보호에 관한 법률": ["신용정보법", "신정법"],
|
| 36 |
+
"위치정보의 보호 및 이용 등에 관한 법률": ["위치정보법"],
|
| 37 |
+
"통신비밀보호법": ["통비법"],
|
| 38 |
+
"전자정부법": ["전정법"],
|
| 39 |
+
"전자서명법": [],
|
| 40 |
+
"공공기관의 정보공개에 관한 법률": ["정보공개법", "정공법"],
|
| 41 |
+
"공공기관의 운영에 관한 법률": ["공공기관운영법", "공운법"],
|
| 42 |
+
"주민등록법": [],
|
| 43 |
+
"전자금융거래법": ["전금법"],
|
| 44 |
+
"특정 금융거래정보의 보고 및 이용 등에 관한 법률": ["특금법", "특정금융거래법"],
|
| 45 |
+
|
| 46 |
+
# ── 의료·보건 (개인정보 민감정보 인접) ─────────────────────────────
|
| 47 |
+
"의료법": [],
|
| 48 |
+
"약사법": [],
|
| 49 |
+
"감염병의 예방 및 관리에 관한 법률": ["감염병예방법"],
|
| 50 |
+
"정신건강증진 및 정신질환자 복지서비스 지원에 관한 법률": ["정신건강복지법"],
|
| 51 |
+
"국민건강보험법": ["건강보험법", "국건법"],
|
| 52 |
+
"보건의료기본법": [],
|
| 53 |
+
|
| 54 |
+
# ── 청소년·아동·교육 ────────────────────────────────────────────
|
| 55 |
+
"아동복지법": [],
|
| 56 |
+
"청소년 보호법": ["청보법"],
|
| 57 |
+
"초·중등교육법": ["초중등교육법"],
|
| 58 |
+
"고등교육법": [],
|
| 59 |
+
"유아교육법": [],
|
| 60 |
+
|
| 61 |
+
# ── 노무·고용 (채용 chain) ─────────────────────────────────────────
|
| 62 |
+
"근로기준법": ["근기법"],
|
| 63 |
+
"직업안정법": [],
|
| 64 |
+
"남녀고용평등과 일·가정 양립 지원에 관한 법률": ["남녀고용평등법"],
|
| 65 |
+
"산업안전보건법": ["산안법"],
|
| 66 |
+
"중대재해 처벌 등에 관한 법률": ["중처법", "중대재해처벌법"],
|
| 67 |
+
"기간제 및 단시간근로자 보호 등에 관한 법률": ["기간제법"],
|
| 68 |
+
"근로자퇴직급여 보장법": ["퇴직급여보장법"],
|
| 69 |
+
"산업재해보상보험법": ["산재보험법"],
|
| 70 |
+
"고용보험법": ["고보법"],
|
| 71 |
+
|
| 72 |
+
# ── 금융·소비자 ───────────────────────────────────────────────
|
| 73 |
+
"자본시장과 금융투자업에 관한 법률": ["자본시장법", "자금법"],
|
| 74 |
+
"약관의 규제에 관한 법률": ["약관법"],
|
| 75 |
+
"할부거래에 관한 법률": ["할부거래법"],
|
| 76 |
+
"전자상거래 등에서의 소비자보호에 관한 법률": ["전자상거래법", "전상법"],
|
| 77 |
+
|
| 78 |
+
# ── 공정거래 ──────��────────────────────────────────────────────
|
| 79 |
+
"독점규제 및 공정거래에 관한 법률": ["공정거래법", "독규법"],
|
| 80 |
+
"하도급거래 공정화에 관한 법률": ["하도급법"],
|
| 81 |
+
"표시·광고의 공정화에 관한 법률": ["표시광고법"],
|
| 82 |
+
"가맹사업거래의 공정화에 관한 법률": ["가맹사업법"],
|
| 83 |
+
|
| 84 |
+
# ── 부동산·임대차 ──────────────────────────────────────────────
|
| 85 |
+
"주택임대차보호법": ["주임법"],
|
| 86 |
+
"상가건물 임대차보호법": ["상임법"],
|
| 87 |
+
"부동산 거래신고 등에 관한 법률": ["부거법", "부동산거래신고법"],
|
| 88 |
+
|
| 89 |
+
# ── 행정·일반 ──────────────────────────────────────────────────
|
| 90 |
+
"행정기본법": ["행기법"],
|
| 91 |
+
"행정절차법": ["행절법"],
|
| 92 |
+
"행정심판법": [],
|
| 93 |
+
"행정소송법": [],
|
| 94 |
+
"민원 처리에 관한 법률": ["민원처리법"],
|
| 95 |
+
|
| 96 |
+
# ── 형사·민사 절차 ───────────────────────────────────────────
|
| 97 |
+
"형법": [],
|
| 98 |
+
"민법": [],
|
| 99 |
+
"형사소송법": ["형소법"],
|
| 100 |
+
"민사소송법": ["민소법"],
|
| 101 |
+
"민사집행법": ["민집법"],
|
| 102 |
+
"상법": [],
|
| 103 |
+
|
| 104 |
+
# ── 부패·청렴 ──────────────────────────────────────────────────
|
| 105 |
+
"부정청탁 및 금품등 수수의 금지에 관한 법률": ["청탁금지법", "김영란법"],
|
| 106 |
+
"공직자의 이해충돌 방지법": ["이해충돌방지법"],
|
| 107 |
+
"공익신고자 보호법": [],
|
| 108 |
+
|
| 109 |
+
# ── 공무원 ────────────────────────────────────────────────────
|
| 110 |
+
"국가공무원법": [],
|
| 111 |
+
"지방공무원법": ["지공법"],
|
| 112 |
+
|
| 113 |
+
# ── 인권 ───────────────────────────────────────────────────────
|
| 114 |
+
"국가인권위원회법": ["인권위법"],
|
| 115 |
+
|
| 116 |
+
# ── 기타 KPAA-related ──────────────────────────────────────────
|
| 117 |
+
"지방공기업법": [],
|
| 118 |
+
"지능정보화 기본법": [],
|
| 119 |
+
"전기통신사업법": ["전사법"],
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
# ────────────────────────────────────────────────────────────────────────────
|
| 123 |
+
# 역인덱스: 약칭 → 정식명 (1:1)
|
| 124 |
+
# ────────────────────────────────────────────────────────────────────────────
|
| 125 |
+
def _build_reverse() -> dict[str, str]:
|
| 126 |
+
"""약칭 한 개가 두 개 이상의 정식명에 매핑되면 *나중에 등록된 것* 으로 덮어쓴다.
|
| 127 |
+
|
| 128 |
+
실제 LAW_ALIASES는 충돌이 없도록 큐레이션되어야 한다 (충돌은 의도적 선택).
|
| 129 |
+
"""
|
| 130 |
+
out: dict[str, str] = {}
|
| 131 |
+
for official, abbrevs in LAW_ALIASES.items():
|
| 132 |
+
for ab in abbrevs:
|
| 133 |
+
ab_norm = ab.strip()
|
| 134 |
+
if not ab_norm:
|
| 135 |
+
continue
|
| 136 |
+
out[ab_norm] = official
|
| 137 |
+
return out
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
ABBR_TO_OFFICIAL: dict[str, str] = _build_reverse()
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
# ────────────────────────────────────────────────────────────────────────────
|
| 144 |
+
# 매칭 유틸
|
| 145 |
+
# ────────────────────────────────────────────────────────────────────────────
|
| 146 |
+
@lru_cache(maxsize=1)
|
| 147 |
+
def _abbrev_pattern() -> re.Pattern[str]:
|
| 148 |
+
"""모든 약칭을 한 번에 잡는 정규식. 긴 약칭부터 매칭(부분매칭 우선순위)."""
|
| 149 |
+
keys = sorted(ABBR_TO_OFFICIAL.keys(), key=len, reverse=True)
|
| 150 |
+
if not keys:
|
| 151 |
+
# 빈 사전이면 절대 매칭 안 되는 패턴
|
| 152 |
+
return re.compile(r"(?!.*)")
|
| 153 |
+
escaped = [re.escape(k) for k in keys]
|
| 154 |
+
return re.compile("|".join(escaped))
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def normalize_law_name(text: str) -> str:
|
| 158 |
+
"""텍스트 안의 *약칭 토큰* 을 정식 법령명으로 치환.
|
| 159 |
+
|
| 160 |
+
예시:
|
| 161 |
+
"신용정보법 위반 사례" → "신용정보의 이용 및 보호에 관한 법률 위반 사례"
|
| 162 |
+
"정통망법 제22조" → "정보통신망 이용촉진 및 정보보호 등에 관한 법률 제22조"
|
| 163 |
+
|
| 164 |
+
이미 ���식명 형태면 그대로 반환. 약칭이 다른 단어의 부분문자열이면 매칭하지 않게
|
| 165 |
+
조심해야 하지만, 한국어 법률 약칭은 대부분 단어 경계 의미가 모호하므로 *최장
|
| 166 |
+
매칭 우선* 전략으로만 처리한다 (충분히 안전).
|
| 167 |
+
"""
|
| 168 |
+
if not text:
|
| 169 |
+
return text
|
| 170 |
+
pat = _abbrev_pattern()
|
| 171 |
+
return pat.sub(lambda m: ABBR_TO_OFFICIAL[m.group(0)], text)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def aliases_for(official_name: str) -> list[str]:
|
| 175 |
+
"""정식명 → 약칭 리스트. 등록 안 됐으면 빈 리스트."""
|
| 176 |
+
return list(LAW_ALIASES.get(official_name, []))
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def is_known_alias(token: str) -> bool:
|
| 180 |
+
"""token 이 등록된 약칭인지."""
|
| 181 |
+
return token.strip() in ABBR_TO_OFFICIAL
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def official_name_for(abbrev: str) -> str | None:
|
| 185 |
+
"""약칭 → 정식명. 등록 안 됐으면 None."""
|
| 186 |
+
return ABBR_TO_OFFICIAL.get(abbrev.strip())
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
# ────────────────────────────────────────────────────────────────────────────
|
| 190 |
+
# 검색어 확장 (폴백 큐 생성용)
|
| 191 |
+
# ────────────────────────────────────────────────────────────────────────────
|
| 192 |
+
_LAW_ABBR_SUFFIX_RE = re.compile(r"법$")
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def expand_search_terms(query: str, *, max_variants: int = 5) -> list[str]:
|
| 196 |
+
"""단일 쿼리를 *검색 폴백 후보들* 로 확장.
|
| 197 |
+
|
| 198 |
+
법제처 API 검색 미스가 잦은 케이스 대응 용도. 호출자는 반환된 후보들을
|
| 199 |
+
순서대로 시도해 첫 번째 비어있지 않은 결과를 채택한다.
|
| 200 |
+
|
| 201 |
+
확장 규칙:
|
| 202 |
+
1) 원본 쿼리 (그대로)
|
| 203 |
+
2) 약칭이 포함되었으면 *정식명 치환본*
|
| 204 |
+
3) 본질 명사만 (예: "신용정보법" → "신용정보")
|
| 205 |
+
4) 정식명에서 본질 명사만 (예: "...신용정보의 이용..." → "신용정보 이용")
|
| 206 |
+
5) 약칭 자체 (정식명이 입력된 경우 역으로 약칭도 시도)
|
| 207 |
+
|
| 208 |
+
중복은 제거하되 *원본 우선* 순서를 보존.
|
| 209 |
+
"""
|
| 210 |
+
if not query.strip():
|
| 211 |
+
return []
|
| 212 |
+
seen: set[str] = set()
|
| 213 |
+
out: list[str] = []
|
| 214 |
+
|
| 215 |
+
def _push(t: str) -> None:
|
| 216 |
+
t = t.strip()
|
| 217 |
+
if t and t not in seen:
|
| 218 |
+
seen.add(t)
|
| 219 |
+
out.append(t)
|
| 220 |
+
|
| 221 |
+
# (1) 원본
|
| 222 |
+
_push(query)
|
| 223 |
+
|
| 224 |
+
# (2) 약칭 → 정식명 치환
|
| 225 |
+
normalized = normalize_law_name(query)
|
| 226 |
+
if normalized != query:
|
| 227 |
+
_push(normalized)
|
| 228 |
+
|
| 229 |
+
# (3) "...법" 약칭에서 "법" 떼고 본질 명사만 (예: 신용정보법 → 신용정보)
|
| 230 |
+
for abbrev in ABBR_TO_OFFICIAL:
|
| 231 |
+
if abbrev in query:
|
| 232 |
+
stem = _LAW_ABBR_SUFFIX_RE.sub("", abbrev).strip()
|
| 233 |
+
if stem and stem != abbrev:
|
| 234 |
+
_push(query.replace(abbrev, stem))
|
| 235 |
+
_push(stem)
|
| 236 |
+
|
| 237 |
+
# (4) 약칭 자체도 후보로 (정식명이 직접 들어온 경우 등)
|
| 238 |
+
for official, abbrevs in LAW_ALIASES.items():
|
| 239 |
+
if official in query and abbrevs:
|
| 240 |
+
for ab in abbrevs:
|
| 241 |
+
_push(query.replace(official, ab))
|
| 242 |
+
|
| 243 |
+
return out[:max_variants]
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
__all__ = [
|
| 247 |
+
"LAW_ALIASES",
|
| 248 |
+
"ABBR_TO_OFFICIAL",
|
| 249 |
+
"normalize_law_name",
|
| 250 |
+
"aliases_for",
|
| 251 |
+
"is_known_alias",
|
| 252 |
+
"official_name_for",
|
| 253 |
+
"expand_search_terms",
|
| 254 |
+
]
|
src/kpaa/law_api/article_history.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""조문 변경 이력 (target=lsJoHstInf) — *list endpoint 전용*.
|
| 2 |
+
|
| 3 |
+
라이브 검증 완료 (2026-05-01 LAW_OC 환경):
|
| 4 |
+
path = lawSearch.do
|
| 5 |
+
params:
|
| 6 |
+
- ID=<법령ID> (필수, MST 아님 주의)
|
| 7 |
+
- JO=<6자리코드> (선택)
|
| 8 |
+
- **fromRegDt + toRegDt** = YYYYMMDD (사실상 필수 — 없으면 0건 반환)
|
| 9 |
+
- regDt = 단일 일자 (선택)
|
| 10 |
+
- page = 페이지 (선택)
|
| 11 |
+
응답 root → `<LawSearch>` → `<law id="N">` 다수
|
| 12 |
+
- `<법령정보>` → 법령명한글, 법령ID, 법령일련번호, 공포일자, 공포번호,
|
| 13 |
+
제개정구분명, 소관부처명, 법령구분명, 시행일자
|
| 14 |
+
- `<조문정보>` → `<jo num="N">` 다수
|
| 15 |
+
- 조문번호(6자리), 변경사유, 조문링크, 조문변경이력상세링크,
|
| 16 |
+
조문개정일, 조문시행일
|
| 17 |
+
|
| 18 |
+
⚠️ 라이브 검증 시 발견한 quirk:
|
| 19 |
+
- fromRegDt+toRegDt 미지정 시 totalCnt=0 응답 (endpoint 가 무한 검색 거부)
|
| 20 |
+
- `<jo>` 가 `<law>` 직속이 아니라 `<조문정보>` 아래에 중첩
|
| 21 |
+
이 두 가지를 코드가 흡수해 호출자는 *그냥 search* 만 하면 동작.
|
| 22 |
+
|
| 23 |
+
KPAA 활용: `개정_이력` intent 매칭 + plan.jo_targets 명시 시 활성화.
|
| 24 |
+
oldnew(법령 단위 신구비교) 와는 *세분화 수준* 이 달라 함께 호출되어도 의미 있음.
|
| 25 |
+
"""
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
from datetime import date, timedelta
|
| 29 |
+
|
| 30 |
+
from kpaa.law_api.client import LIST_PATH, LawApiCall, LawGoKrClient
|
| 31 |
+
from kpaa.law_api.endpoints import LSJOHSTINF
|
| 32 |
+
from kpaa.law_api.jo import decode_jo, encode_jo
|
| 33 |
+
from kpaa.law_api.models import ArticleHistoryItem
|
| 34 |
+
from kpaa.law_api.parsers import child_text, parse_xml
|
| 35 |
+
|
| 36 |
+
CACHE_TTL_LIST = 24 * 3600 # 24h — 개정 이력은 자주 안 바뀜
|
| 37 |
+
|
| 38 |
+
# 기간 default — endpoint 의 *기간 필터 필수* 특성 흡수용. KPAA 도메인은 *최근*
|
| 39 |
+
# 개정에 관심이 큼 → 기본 10년. 호출자가 명시하면 override.
|
| 40 |
+
_DEFAULT_LOOKBACK_DAYS = 365 * 10
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _default_period() -> tuple[str, str]:
|
| 44 |
+
today = date.today()
|
| 45 |
+
past = today - timedelta(days=_DEFAULT_LOOKBACK_DAYS)
|
| 46 |
+
return past.strftime("%Y%m%d"), today.strftime("%Y%m%d")
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
async def search_article_history(
|
| 50 |
+
client: LawGoKrClient,
|
| 51 |
+
*,
|
| 52 |
+
law_id: str,
|
| 53 |
+
jo: str | int | None = None,
|
| 54 |
+
from_date: str | None = None,
|
| 55 |
+
to_date: str | None = None,
|
| 56 |
+
on_date: str | None = None,
|
| 57 |
+
org: str | None = None,
|
| 58 |
+
page: int = 1,
|
| 59 |
+
) -> list[ArticleHistoryItem]:
|
| 60 |
+
"""조문 개정 이력 list 조회.
|
| 61 |
+
|
| 62 |
+
Args:
|
| 63 |
+
law_id: 법령ID. **MST 아님 주의** — `client.law.get_text(mst=...)`의
|
| 64 |
+
응답 LawText.law_id 에서 얻을 수 있다.
|
| 65 |
+
jo: 조문 번호 ("15", "24의2", 15, …). encode_jo 로 6자리 코드 변환.
|
| 66 |
+
None 이면 모든 조문 이력.
|
| 67 |
+
on_date / from_date / to_date: YYYYMMDD 형식. 시점·기간 필터.
|
| 68 |
+
**from_date+to_date 미지정 시 자동으로 최근 10년 default 적용**
|
| 69 |
+
(endpoint quirk 흡수).
|
| 70 |
+
org: 소관부처코드.
|
| 71 |
+
page: 페이지 번호.
|
| 72 |
+
"""
|
| 73 |
+
if not law_id:
|
| 74 |
+
raise ValueError("law_id required (lsJoHstInf 는 lawId 기반)")
|
| 75 |
+
params: dict[str, str] = {"ID": str(law_id), "page": str(page)}
|
| 76 |
+
if jo is not None:
|
| 77 |
+
# encode_jo 는 이미 6자리면 그대로 통과. "15" → "001500"
|
| 78 |
+
params["JO"] = encode_jo(jo)
|
| 79 |
+
if on_date:
|
| 80 |
+
params["regDt"] = str(on_date)
|
| 81 |
+
elif not (from_date or to_date):
|
| 82 |
+
# 기간 미지정 + on_date 미지정 시 default 기간 박음 (endpoint quirk).
|
| 83 |
+
f, t = _default_period()
|
| 84 |
+
params["fromRegDt"] = f
|
| 85 |
+
params["toRegDt"] = t
|
| 86 |
+
if from_date:
|
| 87 |
+
params["fromRegDt"] = str(from_date)
|
| 88 |
+
if to_date:
|
| 89 |
+
params["toRegDt"] = str(to_date)
|
| 90 |
+
if org:
|
| 91 |
+
params["org"] = str(org)
|
| 92 |
+
|
| 93 |
+
body = await client.fetch(
|
| 94 |
+
LawApiCall(
|
| 95 |
+
target=LSJOHSTINF.target,
|
| 96 |
+
path=LIST_PATH,
|
| 97 |
+
params=params,
|
| 98 |
+
cache_ttl=CACHE_TTL_LIST,
|
| 99 |
+
)
|
| 100 |
+
)
|
| 101 |
+
root = parse_xml(body)
|
| 102 |
+
|
| 103 |
+
out: list[ArticleHistoryItem] = []
|
| 104 |
+
for law_el in root.findall(".//law"):
|
| 105 |
+
info = law_el.find("법령정보")
|
| 106 |
+
if info is None:
|
| 107 |
+
continue
|
| 108 |
+
law_name = child_text(info, "법령명한글")
|
| 109 |
+
rid = child_text(info, "법령ID") or law_id
|
| 110 |
+
mst = child_text(info, "법령일련번호")
|
| 111 |
+
promul = child_text(info, "공포일자")
|
| 112 |
+
change_type = child_text(info, "제개정구분명")
|
| 113 |
+
enforce = child_text(info, "시행일자")
|
| 114 |
+
|
| 115 |
+
# `<jo>` 는 `<law>` 직속이 아니라 `<조문정보>` 아래 중첩 (라이브 검증).
|
| 116 |
+
# `.//jo` 로 descendant 매칭하여 양쪽 구조 다 흡수.
|
| 117 |
+
for jo_el in law_el.findall(".//jo"):
|
| 118 |
+
jo_code = child_text(jo_el, "조문번호")
|
| 119 |
+
try:
|
| 120 |
+
jo_decoded = decode_jo(jo_code) if jo_code and len(jo_code) == 6 else jo_code
|
| 121 |
+
except ValueError:
|
| 122 |
+
jo_decoded = jo_code
|
| 123 |
+
out.append(
|
| 124 |
+
ArticleHistoryItem(
|
| 125 |
+
law_name=law_name,
|
| 126 |
+
law_id=rid,
|
| 127 |
+
mst=mst,
|
| 128 |
+
article_no=jo_decoded,
|
| 129 |
+
article_jo_code=jo_code,
|
| 130 |
+
change_type=change_type,
|
| 131 |
+
change_reason=child_text(jo_el, "변경사유"),
|
| 132 |
+
article_amend_date=child_text(jo_el, "조문개정일"),
|
| 133 |
+
article_enforce_date=child_text(jo_el, "조문시행일"),
|
| 134 |
+
promulgate_date=promul,
|
| 135 |
+
enforce_date=enforce,
|
| 136 |
+
)
|
| 137 |
+
)
|
| 138 |
+
return out
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
__all__ = ["search_article_history"]
|
src/kpaa/law_api/client.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""법제처 OPEN API 베이스 HTTP 클라이언트.
|
| 2 |
+
|
| 3 |
+
상위 모듈(`law.py`, `pipc.py`, …)이 `LawApiCall`을 만들어 `fetch()`로 보내고,
|
| 4 |
+
이 클래스는 인증·재시도·캐시·동시성 캡을 통일 처리한다. XML 파싱은 호출자에서
|
| 5 |
+
`parsers.py`를 사용해 진행한다.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import asyncio
|
| 10 |
+
import hashlib
|
| 11 |
+
import json
|
| 12 |
+
import logging
|
| 13 |
+
from dataclasses import dataclass
|
| 14 |
+
from typing import Any
|
| 15 |
+
|
| 16 |
+
import diskcache
|
| 17 |
+
import httpx
|
| 18 |
+
from tenacity import (
|
| 19 |
+
AsyncRetrying,
|
| 20 |
+
retry_if_exception_type,
|
| 21 |
+
stop_after_attempt,
|
| 22 |
+
wait_exponential_jitter,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
from kpaa.config import get_settings
|
| 26 |
+
|
| 27 |
+
logger = logging.getLogger("kpaa.law_api")
|
| 28 |
+
|
| 29 |
+
BASE_URL = "https://www.law.go.kr/DRF"
|
| 30 |
+
LIST_PATH = "/lawSearch.do"
|
| 31 |
+
DETAIL_PATH = "/lawService.do"
|
| 32 |
+
|
| 33 |
+
_DEFAULT_TIMEOUT = httpx.Timeout(30.0, connect=10.0)
|
| 34 |
+
_USER_AGENT = "kpaa/0.1 (+https://github.com/sz1-kca/korean-privacy-ai-assistant)"
|
| 35 |
+
|
| 36 |
+
_RETRYABLE_STATUS = {408, 425, 429, 500, 502, 503, 504}
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class _RetryableHTTPError(Exception):
|
| 40 |
+
"""HTTP 상태가 일시적 오류군일 때 재시도 트리거용."""
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@dataclass(frozen=True)
|
| 44 |
+
class LawApiCall:
|
| 45 |
+
target: str # e.g. "law", "ppc", "expc", "prec", "admrul"
|
| 46 |
+
path: str # LIST_PATH or DETAIL_PATH
|
| 47 |
+
params: dict[str, Any]
|
| 48 |
+
cache_ttl: int # seconds (e.g. 3600 for search, 86400 for detail)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class LawGoKrClient:
|
| 52 |
+
def __init__(
|
| 53 |
+
self,
|
| 54 |
+
*,
|
| 55 |
+
oc: str | None = None,
|
| 56 |
+
base_url: str = BASE_URL,
|
| 57 |
+
timeout: httpx.Timeout = _DEFAULT_TIMEOUT,
|
| 58 |
+
max_concurrency: int = 4,
|
| 59 |
+
) -> None:
|
| 60 |
+
s = get_settings()
|
| 61 |
+
self.oc = oc if oc is not None else s.law_oc
|
| 62 |
+
self.base_url = base_url
|
| 63 |
+
self.timeout = timeout
|
| 64 |
+
self._http: httpx.AsyncClient | None = None
|
| 65 |
+
self._sem = asyncio.Semaphore(max_concurrency)
|
| 66 |
+
self._cache = diskcache.Cache(str(s.law_cache_dir))
|
| 67 |
+
|
| 68 |
+
async def __aenter__(self) -> LawGoKrClient:
|
| 69 |
+
self._http = httpx.AsyncClient(
|
| 70 |
+
base_url=self.base_url,
|
| 71 |
+
timeout=self.timeout,
|
| 72 |
+
http2=True,
|
| 73 |
+
headers={"User-Agent": _USER_AGENT, "Accept": "*/*"},
|
| 74 |
+
)
|
| 75 |
+
return self
|
| 76 |
+
|
| 77 |
+
async def __aexit__(self, *exc) -> None:
|
| 78 |
+
if self._http is not None:
|
| 79 |
+
await self._http.aclose()
|
| 80 |
+
self._http = None
|
| 81 |
+
|
| 82 |
+
def _ensure_oc(self) -> str:
|
| 83 |
+
if not self.oc:
|
| 84 |
+
raise RuntimeError(
|
| 85 |
+
"법제처 OPEN API 인증 ID(LAW_OC)가 비어 있습니다. "
|
| 86 |
+
".env 파일에 LAW_OC=<발급받은_id>를 입력하거나 "
|
| 87 |
+
"KoreanLawClient(oc=...)에 직접 전달하세요. "
|
| 88 |
+
"발급(무료): https://open.law.go.kr"
|
| 89 |
+
)
|
| 90 |
+
return self.oc
|
| 91 |
+
|
| 92 |
+
@staticmethod
|
| 93 |
+
def _cache_key(call: LawApiCall) -> str:
|
| 94 |
+
normalized = json.dumps(
|
| 95 |
+
{
|
| 96 |
+
"target": call.target,
|
| 97 |
+
"path": call.path,
|
| 98 |
+
"params": sorted(call.params.items()),
|
| 99 |
+
},
|
| 100 |
+
ensure_ascii=False,
|
| 101 |
+
sort_keys=True,
|
| 102 |
+
)
|
| 103 |
+
return hashlib.sha1(normalized.encode("utf-8")).hexdigest()
|
| 104 |
+
|
| 105 |
+
async def fetch(self, call: LawApiCall) -> str:
|
| 106 |
+
if self._http is None:
|
| 107 |
+
raise RuntimeError("LawGoKrClient is not entered (use 'async with').")
|
| 108 |
+
|
| 109 |
+
key = self._cache_key(call)
|
| 110 |
+
cached = self._cache.get(key)
|
| 111 |
+
if cached is not None:
|
| 112 |
+
return cached # type: ignore[return-value]
|
| 113 |
+
|
| 114 |
+
params: dict[str, Any] = {
|
| 115 |
+
"OC": self._ensure_oc(),
|
| 116 |
+
"type": "XML",
|
| 117 |
+
"target": call.target,
|
| 118 |
+
**call.params,
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
body: str | None = None
|
| 122 |
+
async with self._sem:
|
| 123 |
+
async for attempt in AsyncRetrying(
|
| 124 |
+
stop=stop_after_attempt(4),
|
| 125 |
+
wait=wait_exponential_jitter(initial=1, max=8),
|
| 126 |
+
retry=retry_if_exception_type(
|
| 127 |
+
(httpx.TransportError, httpx.TimeoutException, _RetryableHTTPError)
|
| 128 |
+
),
|
| 129 |
+
reraise=True,
|
| 130 |
+
):
|
| 131 |
+
with attempt:
|
| 132 |
+
resp = await self._http.get(call.path, params=params)
|
| 133 |
+
if resp.status_code in _RETRYABLE_STATUS:
|
| 134 |
+
raise _RetryableHTTPError(
|
| 135 |
+
f"law.go.kr returned {resp.status_code} for target={call.target}"
|
| 136 |
+
)
|
| 137 |
+
resp.raise_for_status()
|
| 138 |
+
body = resp.text
|
| 139 |
+
|
| 140 |
+
assert body is not None
|
| 141 |
+
# Sanity cap — *XML 안전한 byte 단순 절단은 불가능* (CDATA·태그 중간을
|
| 142 |
+
# 잘라 파서가 깨짐 — 라이브 검증 2026-05-01: 시행령 mst=273745 응답이
|
| 143 |
+
# 272K 라 이전 200K cap 에서 CDATA 미종료로 파싱 실패). 정책: *왜곡 없이
|
| 144 |
+
# 전달* 우선이므로 절단 안 함. 대신 *비정상적으로 큰 응답*(>5MB)은 경고
|
| 145 |
+
# 만 남기고 그대로 ���시 — 메모리 과사용 방어는 실용적 5MB 선에서.
|
| 146 |
+
if len(body) > 5_000_000:
|
| 147 |
+
logger.warning(
|
| 148 |
+
"law.go.kr response very large (%d chars) for target=%s — passing through anyway",
|
| 149 |
+
len(body),
|
| 150 |
+
call.target,
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
self._cache.set(key, body, expire=call.cache_ttl)
|
| 154 |
+
return body
|
src/kpaa/law_api/constitutional.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""헌법재판소 결정문 (target=detc).
|
| 2 |
+
|
| 3 |
+
라이브 검증 (원작 chrisryugj/korean-law-mcp 의 parseConstitutionalXML 매핑 기준):
|
| 4 |
+
list : `lawSearch.do?target=detc&query=...&type=XML`
|
| 5 |
+
root=`<DetcSearch>` row=`<Detc>` (대문자 D)
|
| 6 |
+
필드: 헌재결정례일련번호 / 사건명 / 사건번호 / 종국일자 /
|
| 7 |
+
헌재결정례상세링크
|
| 8 |
+
detail : `lawService.do?target=detc&ID=...&type=JSON`
|
| 9 |
+
JSON 컨테이너: `DetcService` 또는 `헌재결정례`
|
| 10 |
+
필드: 사건명·사건번호·종국일자·청구인·피청구인·
|
| 11 |
+
판시사항·결정요지·참조조문·참조판례·전문(판례내용/결정내용)
|
| 12 |
+
|
| 13 |
+
KPAA 활용: data_subject_rights/definition/medical_health chain 에서 자기결정권·
|
| 14 |
+
기본권 헌재 판단 근거 보강.
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import json
|
| 19 |
+
|
| 20 |
+
from kpaa.law_api.client import DETAIL_PATH, LIST_PATH, LawApiCall, LawGoKrClient
|
| 21 |
+
from kpaa.law_api.endpoints import DETC
|
| 22 |
+
from kpaa.law_api.models import ConstitutionalDecisionHit, ConstitutionalDecisionText
|
| 23 |
+
from kpaa.law_api.parsers import child_text, parse_xml
|
| 24 |
+
|
| 25 |
+
CACHE_TTL_SEARCH = 3600 # 1h
|
| 26 |
+
CACHE_TTL_DETAIL = 24 * 3600 # 24h
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
async def search_constitutional_decisions(
|
| 30 |
+
client: LawGoKrClient,
|
| 31 |
+
*,
|
| 32 |
+
query: str,
|
| 33 |
+
display: int = 20,
|
| 34 |
+
case_number: str | None = None,
|
| 35 |
+
sort: str | None = None,
|
| 36 |
+
) -> list[ConstitutionalDecisionHit]:
|
| 37 |
+
"""헌재 결정 검색.
|
| 38 |
+
|
| 39 |
+
Args:
|
| 40 |
+
query: 검색 키워드 (예: "자기결정권", "개인정보 자기결정권")
|
| 41 |
+
display: 1~100
|
| 42 |
+
case_number: 사건번호로 직접 조회 시 (예: "2020헌바123")
|
| 43 |
+
sort: 정렬 — lasc/ldes/dasc/ddes/nasc/ndes
|
| 44 |
+
"""
|
| 45 |
+
if not 1 <= display <= 100:
|
| 46 |
+
raise ValueError("display must be in [1, 100]")
|
| 47 |
+
params: dict[str, str] = {"display": str(display)}
|
| 48 |
+
if query:
|
| 49 |
+
params["query"] = query
|
| 50 |
+
if case_number:
|
| 51 |
+
params["nb"] = case_number
|
| 52 |
+
if sort:
|
| 53 |
+
params["sort"] = sort
|
| 54 |
+
|
| 55 |
+
body = await client.fetch(
|
| 56 |
+
LawApiCall(
|
| 57 |
+
target=DETC.target,
|
| 58 |
+
path=LIST_PATH,
|
| 59 |
+
params=params,
|
| 60 |
+
cache_ttl=CACHE_TTL_SEARCH,
|
| 61 |
+
)
|
| 62 |
+
)
|
| 63 |
+
root = parse_xml(body)
|
| 64 |
+
hits: list[ConstitutionalDecisionHit] = []
|
| 65 |
+
for el in root.findall(f".//{DETC.row_tag}"):
|
| 66 |
+
hits.append(
|
| 67 |
+
ConstitutionalDecisionHit(
|
| 68 |
+
decision_id=child_text(el, DETC.id_field),
|
| 69 |
+
title=child_text(el, "사건명"),
|
| 70 |
+
case_no=child_text(el, "사건번호"),
|
| 71 |
+
decision_date=child_text(el, "종국일자"),
|
| 72 |
+
detail_url=child_text(el, "헌재결정례상세링크"),
|
| 73 |
+
)
|
| 74 |
+
)
|
| 75 |
+
return hits
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
async def get_constitutional_decision_text(
|
| 79 |
+
client: LawGoKrClient,
|
| 80 |
+
*,
|
| 81 |
+
decision_id: str,
|
| 82 |
+
) -> ConstitutionalDecisionText | None:
|
| 83 |
+
"""헌재 결정문 본문 (JSON detail).
|
| 84 |
+
|
| 85 |
+
응답이 비어있거나 형식 이상이면 None. 호출자는 None 처리.
|
| 86 |
+
"""
|
| 87 |
+
if not decision_id:
|
| 88 |
+
raise ValueError("decision_id required")
|
| 89 |
+
# detail 응답은 JSON — params 의 type 키가 client.fetch 의 default("XML") 를 덮어씀.
|
| 90 |
+
body = await client.fetch(
|
| 91 |
+
LawApiCall(
|
| 92 |
+
target=DETC.target,
|
| 93 |
+
path=DETAIL_PATH,
|
| 94 |
+
params={"ID": str(decision_id), "type": "JSON"},
|
| 95 |
+
cache_ttl=CACHE_TTL_DETAIL,
|
| 96 |
+
)
|
| 97 |
+
)
|
| 98 |
+
if not body or not body.strip():
|
| 99 |
+
return None
|
| 100 |
+
try:
|
| 101 |
+
data = json.loads(body)
|
| 102 |
+
except json.JSONDecodeError:
|
| 103 |
+
return None
|
| 104 |
+
payload = data.get("DetcService") or data.get("헌재결정례") or {}
|
| 105 |
+
if not isinstance(payload, dict) or not payload:
|
| 106 |
+
return None
|
| 107 |
+
|
| 108 |
+
return ConstitutionalDecisionText(
|
| 109 |
+
decision_id=str(decision_id),
|
| 110 |
+
title=str(payload.get("사건명", "") or ""),
|
| 111 |
+
case_no=str(payload.get("사건번호", "") or ""),
|
| 112 |
+
decision_date=str(
|
| 113 |
+
payload.get("종국일자") or payload.get("선고일자") or ""
|
| 114 |
+
),
|
| 115 |
+
petitioner=str(payload.get("청구인", "") or ""),
|
| 116 |
+
respondent=str(payload.get("피청구인", "") or ""),
|
| 117 |
+
issues=str(payload.get("판시사항", "") or ""),
|
| 118 |
+
summary=str(
|
| 119 |
+
payload.get("결정요지") or payload.get("판결요지") or ""
|
| 120 |
+
),
|
| 121 |
+
refs_law=str(payload.get("참조조문", "") or ""),
|
| 122 |
+
refs_precedent=str(payload.get("참조판례", "") or ""),
|
| 123 |
+
body=str(
|
| 124 |
+
payload.get("판례내용")
|
| 125 |
+
or payload.get("결정내용")
|
| 126 |
+
or payload.get("전문")
|
| 127 |
+
or ""
|
| 128 |
+
),
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
__all__ = [
|
| 133 |
+
"search_constitutional_decisions",
|
| 134 |
+
"get_constitutional_decision_text",
|
| 135 |
+
]
|
src/kpaa/law_api/endpoints.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""법제처 OPEN API의 target 토큰과 응답 row 태그 매핑.
|
| 2 |
+
|
| 3 |
+
라이브 검증 완료 (2026-04-29):
|
| 4 |
+
https://www.law.go.kr/DRF/lawSearch.do?OC=...&target=<TARGET>&query=...&type=XML
|
| 5 |
+
https://www.law.go.kr/DRF/lawService.do?OC=...&target=<TARGET>&{ID|MST}=...&type=XML
|
| 6 |
+
|
| 7 |
+
`detail_key`는 detail 호출에서 사용하는 1차 식별자 파라미터명이다:
|
| 8 |
+
- target=law → MST (법령일련번호)
|
| 9 |
+
- 그 외 → ID
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
from dataclasses import dataclass
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass(frozen=True)
|
| 17 |
+
class TargetSpec:
|
| 18 |
+
target: str # URL 파라미터 값 (예: "law", "ppc")
|
| 19 |
+
row_tag: str # 응답 XML의 1건 row 태그 (예: "law", "ppc")
|
| 20 |
+
id_field: str # row 안의 detail ID 필드명 (예: "법령일련번호")
|
| 21 |
+
title_field: str # row 안의 제목 필드명
|
| 22 |
+
detail_key: str # detail 호출 파라미터명 ("MST" or "ID")
|
| 23 |
+
label: str # 사람용 한국어 라벨
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# 법령 (target=law)
|
| 27 |
+
LAW = TargetSpec(
|
| 28 |
+
target="law",
|
| 29 |
+
row_tag="law",
|
| 30 |
+
id_field="법령일련번호",
|
| 31 |
+
title_field="법령명한글",
|
| 32 |
+
detail_key="MST",
|
| 33 |
+
label="법령",
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
# 개인정보보호위원회 결정문 (target=ppc)
|
| 37 |
+
PPC = TargetSpec(
|
| 38 |
+
target="ppc",
|
| 39 |
+
row_tag="ppc",
|
| 40 |
+
id_field="결정문일련번호",
|
| 41 |
+
title_field="안건명",
|
| 42 |
+
detail_key="ID",
|
| 43 |
+
label="개인정보보호위원회 결정",
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
# 법령해석례 (target=expc)
|
| 47 |
+
EXPC = TargetSpec(
|
| 48 |
+
target="expc",
|
| 49 |
+
row_tag="expc",
|
| 50 |
+
id_field="법령해석례일련번호",
|
| 51 |
+
title_field="안건명",
|
| 52 |
+
detail_key="ID",
|
| 53 |
+
label="법령해석례",
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
# 행정규칙(훈령/예규/고시) (target=admrul)
|
| 57 |
+
# knd 파라미터: 1=훈령, 2=예규, 3=고시 (chatbot은 개보위 고시 위주)
|
| 58 |
+
ADMRUL = TargetSpec(
|
| 59 |
+
target="admrul",
|
| 60 |
+
row_tag="admrul",
|
| 61 |
+
id_field="행정규칙일련번호",
|
| 62 |
+
title_field="행정규칙명",
|
| 63 |
+
detail_key="ID",
|
| 64 |
+
label="행정규칙",
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
# 판례 (target=prec)
|
| 68 |
+
PREC = TargetSpec(
|
| 69 |
+
target="prec",
|
| 70 |
+
row_tag="prec",
|
| 71 |
+
id_field="판례일련번호",
|
| 72 |
+
title_field="사건명",
|
| 73 |
+
detail_key="ID",
|
| 74 |
+
label="판례",
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
# 별표/별지서식 (target=licbyl) — `lawSearch.do`로 검색
|
| 78 |
+
LICBYL = TargetSpec(
|
| 79 |
+
target="licbyl",
|
| 80 |
+
row_tag="licbyl",
|
| 81 |
+
id_field="별표일련번호",
|
| 82 |
+
title_field="별표명",
|
| 83 |
+
detail_key="ID",
|
| 84 |
+
label="별표서식",
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
# 자치법규 (target=ordin) — row tag 가 "law" (재사용)
|
| 88 |
+
ORDIN = TargetSpec(
|
| 89 |
+
target="ordin",
|
| 90 |
+
row_tag="law",
|
| 91 |
+
id_field="자치법규일련번호",
|
| 92 |
+
title_field="자치법규명",
|
| 93 |
+
detail_key="ID",
|
| 94 |
+
label="자치법규",
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
# 조약 (target=trty)
|
| 98 |
+
TRTY = TargetSpec(
|
| 99 |
+
target="trty",
|
| 100 |
+
row_tag="Trty",
|
| 101 |
+
id_field="조약일련번호",
|
| 102 |
+
title_field="조약명",
|
| 103 |
+
detail_key="ID",
|
| 104 |
+
label="조약",
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
# 영문법령 (target=elaw)
|
| 108 |
+
ELAW = TargetSpec(
|
| 109 |
+
target="elaw",
|
| 110 |
+
row_tag="law",
|
| 111 |
+
id_field="법령일련번호",
|
| 112 |
+
title_field="법령명영문",
|
| 113 |
+
detail_key="MST",
|
| 114 |
+
label="영문법령",
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
# 신구법비교 (target=oldAndNew)
|
| 118 |
+
OLDNEW = TargetSpec(
|
| 119 |
+
target="oldAndNew",
|
| 120 |
+
row_tag="oldAndNew",
|
| 121 |
+
id_field="법령일련번호",
|
| 122 |
+
title_field="법령명한글",
|
| 123 |
+
detail_key="MST",
|
| 124 |
+
label="신구법비교",
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
# 시행일자별 법령 (target=eflaw)
|
| 128 |
+
EFLAW = TargetSpec(
|
| 129 |
+
target="eflaw",
|
| 130 |
+
row_tag="law",
|
| 131 |
+
id_field="법령일련번호",
|
| 132 |
+
title_field="법령명한글",
|
| 133 |
+
detail_key="MST",
|
| 134 |
+
label="시행일자별 법령",
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
# 공정거래위원회 결정 (target=ftc)
|
| 138 |
+
FTC = TargetSpec(
|
| 139 |
+
target="ftc",
|
| 140 |
+
row_tag="ftc",
|
| 141 |
+
id_field="결정문일련번호",
|
| 142 |
+
title_field="사건명",
|
| 143 |
+
detail_key="ID",
|
| 144 |
+
label="공정거래위원회 결정",
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
# 노동위원회 결정 (target=nlrc)
|
| 148 |
+
NLRC = TargetSpec(
|
| 149 |
+
target="nlrc",
|
| 150 |
+
row_tag="nlrc",
|
| 151 |
+
id_field="결정문일련번호",
|
| 152 |
+
title_field="제목",
|
| 153 |
+
detail_key="ID",
|
| 154 |
+
label="노동위원회 결정",
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
# 국민권익위원회 결정 (target=acr)
|
| 158 |
+
ACR = TargetSpec(
|
| 159 |
+
target="acr",
|
| 160 |
+
row_tag="acr",
|
| 161 |
+
id_field="결정문일련번호",
|
| 162 |
+
title_field="제목",
|
| 163 |
+
detail_key="ID",
|
| 164 |
+
label="국민권익위원회 결정",
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
# 법령용어 (target=lstrm)
|
| 168 |
+
LSTRM = TargetSpec(
|
| 169 |
+
target="lstrm",
|
| 170 |
+
row_tag="lstrm",
|
| 171 |
+
id_field="법령용어ID",
|
| 172 |
+
title_field="법령용어명",
|
| 173 |
+
detail_key="trmSeqs", # detail은 trmSeqs 파라미터 (복수 ID 전달 가능)
|
| 174 |
+
label="법령용어",
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
# 헌법재판소 결정문 (target=detc)
|
| 178 |
+
# 라이브 검증 (원작 xml-parser 기반): 검색 응답 root=`<DetcSearch>`, row tag=`<Detc>`.
|
| 179 |
+
# detail 응답은 *JSON* — `data.DetcService` 또는 `data.헌재결정례` 키 아래 본문.
|
| 180 |
+
# row 필드: 헌재결정례일련번호, 사건명, 사건번호, 종국일자, 헌재결정례상세링크.
|
| 181 |
+
DETC = TargetSpec(
|
| 182 |
+
target="detc",
|
| 183 |
+
row_tag="Detc",
|
| 184 |
+
id_field="헌재결정례일련번호",
|
| 185 |
+
title_field="사건명",
|
| 186 |
+
detail_key="ID",
|
| 187 |
+
label="헌법재판소 결정",
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
# 조문 변경 이력 (target=lsJoHstInf) — *list endpoint 만 존재* (detail 없음).
|
| 191 |
+
# 라이브 검증 (원작 api-client.ts/article-history.ts 기준):
|
| 192 |
+
# path = lawSearch.do
|
| 193 |
+
# 필수 params: ID=<법령ID>, optional JO=<6자리코드>, regDt/fromRegDt/toRegDt, page
|
| 194 |
+
# 응답 구조: root → <law> 다수 → <법령정보> + <jo> 다수
|
| 195 |
+
# <법령정보>: 법령명한글 / 법령ID / 법령일련번호 / 공포일자 / 제개정구분명 / 시행일자
|
| 196 |
+
# <jo>: 조문번호(6자리코드) / 변경사유 / 조문개정일 / 조문시행일
|
| 197 |
+
# 주의: ID 는 *법령ID* (예: "011357") — *법령일련번호(MST)* 와 다르다.
|
| 198 |
+
LSJOHSTINF = TargetSpec(
|
| 199 |
+
target="lsJoHstInf",
|
| 200 |
+
row_tag="law",
|
| 201 |
+
id_field="법령ID",
|
| 202 |
+
title_field="법령명한글",
|
| 203 |
+
detail_key="ID", # detail endpoint 는 없지만 spec 일관성 위해 채워둠
|
| 204 |
+
label="조문 변경 이력",
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
ALL: tuple[TargetSpec, ...] = (
|
| 209 |
+
LAW, PPC, EXPC, ADMRUL, PREC, LICBYL,
|
| 210 |
+
ORDIN, TRTY, ELAW, OLDNEW, EFLAW,
|
| 211 |
+
FTC, NLRC, ACR, LSTRM, DETC, LSJOHSTINF,
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
# Day 3 시점에서 라이브 검증 실패 — 정확한 target 토큰 미확정.
|
| 216 |
+
# v1 SDK에는 미포함. 사용자 SDK 피드백으로 후속 보강.
|
| 217 |
+
PENDING: dict[str, str] = {
|
| 218 |
+
"헌재 결정문": "search_constitutional_decisions",
|
| 219 |
+
"행정심판": "search_admin_appeals",
|
| 220 |
+
"이의신청 결정": "search_appeal_review_decisions",
|
| 221 |
+
"조세심판": "search_tax_tribunal_decisions",
|
| 222 |
+
"관세 해석": "search_customs_interpretations",
|
| 223 |
+
"학교 학칙": "search_school_rules",
|
| 224 |
+
"공공기관 규정": "search_public_institution_rules",
|
| 225 |
+
"공기업 규정": "search_public_corp_rules",
|
| 226 |
+
"권익위 특별행심": "search_acr_special_appeals",
|
| 227 |
+
"생활법령(daily)": "get_daily_term / get_daily_to_legal / get_legal_to_daily",
|
| 228 |
+
}
|
src/kpaa/law_api/english.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""영문 법령 (target=elaw)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from kpaa.law_api.client import DETAIL_PATH, LIST_PATH, LawApiCall, LawGoKrClient
|
| 5 |
+
from kpaa.law_api.endpoints import ELAW
|
| 6 |
+
from kpaa.law_api.parsers import child_text, parse_xml
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
async def search_english_laws(
|
| 10 |
+
client: LawGoKrClient, *, query: str, display: int = 20
|
| 11 |
+
) -> list[dict[str, str]]:
|
| 12 |
+
if not 1 <= display <= 100:
|
| 13 |
+
raise ValueError("display must be in [1, 100]")
|
| 14 |
+
body = await client.fetch(
|
| 15 |
+
LawApiCall(target=ELAW.target, path=LIST_PATH,
|
| 16 |
+
params={"query": query, "display": str(display)},
|
| 17 |
+
cache_ttl=3600)
|
| 18 |
+
)
|
| 19 |
+
root = parse_xml(body)
|
| 20 |
+
return [
|
| 21 |
+
{
|
| 22 |
+
"mst": child_text(el, "법령일련번호"),
|
| 23 |
+
"name_en": child_text(el, "법령명영문"),
|
| 24 |
+
"name_ko": child_text(el, "법령명한글"),
|
| 25 |
+
"promulgate_date": child_text(el, "공포일자"),
|
| 26 |
+
"enforce_date": child_text(el, "시행일자"),
|
| 27 |
+
"department": child_text(el, "소관부처명"),
|
| 28 |
+
}
|
| 29 |
+
for el in root.findall(f".//{ELAW.row_tag}")
|
| 30 |
+
]
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
async def get_english_law_text(client: LawGoKrClient, *, mst: str) -> str:
|
| 34 |
+
return await client.fetch(
|
| 35 |
+
LawApiCall(target=ELAW.target, path=DETAIL_PATH,
|
| 36 |
+
params={"MST": str(mst)}, cache_ttl=24 * 3600)
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
__all__ = ["search_english_laws", "get_english_law_text"]
|
src/kpaa/law_api/ftc.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""공정거래위원회 결정 (target=ftc).
|
| 2 |
+
|
| 3 |
+
라이브 검증 row 필드: 결정문일련번호 / 사건명 / 사건번호 / 문서유형 /
|
| 4 |
+
회의종류 / 결정번호 / 결정일자.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from kpaa.law_api.client import DETAIL_PATH, LIST_PATH, LawApiCall, LawGoKrClient
|
| 9 |
+
from kpaa.law_api.endpoints import FTC
|
| 10 |
+
from kpaa.law_api.parsers import child_text, parse_xml
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
async def search_ftc_decisions(
|
| 14 |
+
client: LawGoKrClient, *, query: str, display: int = 20
|
| 15 |
+
) -> list[dict[str, str]]:
|
| 16 |
+
if not 1 <= display <= 100:
|
| 17 |
+
raise ValueError("display must be in [1, 100]")
|
| 18 |
+
body = await client.fetch(
|
| 19 |
+
LawApiCall(target=FTC.target, path=LIST_PATH,
|
| 20 |
+
params={"query": query, "display": str(display)},
|
| 21 |
+
cache_ttl=3600)
|
| 22 |
+
)
|
| 23 |
+
root = parse_xml(body)
|
| 24 |
+
return [
|
| 25 |
+
{
|
| 26 |
+
"decision_id": child_text(el, "결정문일련번호"),
|
| 27 |
+
"case_name": child_text(el, "사건명"),
|
| 28 |
+
"case_no": child_text(el, "사건번호"),
|
| 29 |
+
"document_kind": child_text(el, "문서유형"),
|
| 30 |
+
"meeting_kind": child_text(el, "회의종류"),
|
| 31 |
+
"decision_no": child_text(el, "결정번호"),
|
| 32 |
+
"decision_date": child_text(el, "결정일자"),
|
| 33 |
+
}
|
| 34 |
+
for el in root.findall(f".//{FTC.row_tag}")
|
| 35 |
+
]
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
async def get_ftc_decision_text(client: LawGoKrClient, *, decision_id: str) -> str:
|
| 39 |
+
return await client.fetch(
|
| 40 |
+
LawApiCall(target=FTC.target, path=DETAIL_PATH,
|
| 41 |
+
params={"ID": str(decision_id)}, cache_ttl=24 * 3600)
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
__all__ = ["search_ftc_decisions", "get_ftc_decision_text"]
|
src/kpaa/law_api/interpretation.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""법령해석례 — `target=expc`.
|
| 2 |
+
|
| 3 |
+
라이브 검증(2026-04-29):
|
| 4 |
+
- 검색: lawSearch.do?target=expc&query=...&display=...
|
| 5 |
+
root=<Expc>, row=<expc>, id=법령해석례일련번호
|
| 6 |
+
- 본문: lawService.do?target=expc&ID=...
|
| 7 |
+
root=<ExpcService>, fields: 안건명/안건번호/해석일자/질의기관명/회신기관명/질의요지/회답/이유
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from kpaa.law_api.client import DETAIL_PATH, LIST_PATH, LawApiCall, LawGoKrClient
|
| 12 |
+
from kpaa.law_api.endpoints import EXPC
|
| 13 |
+
from kpaa.law_api.models import InterpretationHit, InterpretationText
|
| 14 |
+
from kpaa.law_api.parsers import child_text, parse_xml
|
| 15 |
+
|
| 16 |
+
CACHE_TTL_SEARCH = 3600
|
| 17 |
+
CACHE_TTL_DETAIL = 24 * 3600
|
| 18 |
+
|
| 19 |
+
INTERPRETATION_BODY_LIMIT = 8000
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
async def search_interpretations(
|
| 23 |
+
client: LawGoKrClient,
|
| 24 |
+
*,
|
| 25 |
+
query: str,
|
| 26 |
+
display: int = 20,
|
| 27 |
+
) -> list[InterpretationHit]:
|
| 28 |
+
"""법령해석례 목록 검색."""
|
| 29 |
+
if not 1 <= display <= 100:
|
| 30 |
+
raise ValueError("display must be in [1, 100]")
|
| 31 |
+
call = LawApiCall(
|
| 32 |
+
target=EXPC.target,
|
| 33 |
+
path=LIST_PATH,
|
| 34 |
+
params={"query": query, "display": str(display)},
|
| 35 |
+
cache_ttl=CACHE_TTL_SEARCH,
|
| 36 |
+
)
|
| 37 |
+
body = await client.fetch(call)
|
| 38 |
+
root = parse_xml(body)
|
| 39 |
+
|
| 40 |
+
hits: list[InterpretationHit] = []
|
| 41 |
+
for el in root.findall(f".//{EXPC.row_tag}"):
|
| 42 |
+
hits.append(
|
| 43 |
+
InterpretationHit(
|
| 44 |
+
interpretation_id=child_text(el, EXPC.id_field),
|
| 45 |
+
title=child_text(el, "안건명"),
|
| 46 |
+
case_no=child_text(el, "안건번호"),
|
| 47 |
+
decided_date=child_text(el, "회신일자"),
|
| 48 |
+
inquirer=child_text(el, "질의기관명"),
|
| 49 |
+
responder=child_text(el, "회신기관명"),
|
| 50 |
+
)
|
| 51 |
+
)
|
| 52 |
+
return hits
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
async def get_interpretation_text(
|
| 56 |
+
client: LawGoKrClient,
|
| 57 |
+
*,
|
| 58 |
+
interpretation_id: str,
|
| 59 |
+
) -> InterpretationText:
|
| 60 |
+
"""법령해석례 본문."""
|
| 61 |
+
call = LawApiCall(
|
| 62 |
+
target=EXPC.target,
|
| 63 |
+
path=DETAIL_PATH,
|
| 64 |
+
params={"ID": str(interpretation_id)},
|
| 65 |
+
cache_ttl=CACHE_TTL_DETAIL,
|
| 66 |
+
)
|
| 67 |
+
body = await client.fetch(call)
|
| 68 |
+
root = parse_xml(body)
|
| 69 |
+
|
| 70 |
+
question = child_text(root, "질의요지")
|
| 71 |
+
answer = child_text(root, "회답")
|
| 72 |
+
reason = child_text(root, "이유")
|
| 73 |
+
|
| 74 |
+
used = len(question) + len(answer) + len(reason)
|
| 75 |
+
if used > INTERPRETATION_BODY_LIMIT:
|
| 76 |
+
# 회답 우선, 그 다음 질의요지, 마지막 이유 잘라냄
|
| 77 |
+
budget = INTERPRETATION_BODY_LIMIT - len(answer) - len(question)
|
| 78 |
+
if budget < 0:
|
| 79 |
+
budget = 0
|
| 80 |
+
if budget < len(reason):
|
| 81 |
+
reason = reason[:budget] + "\n…(중략)…"
|
| 82 |
+
|
| 83 |
+
return InterpretationText(
|
| 84 |
+
interpretation_id=str(interpretation_id),
|
| 85 |
+
title=child_text(root, "안건명"),
|
| 86 |
+
case_no=child_text(root, "안건번호"),
|
| 87 |
+
decided_date=child_text(root, "해석일자"),
|
| 88 |
+
inquirer=child_text(root, "질의기관명"),
|
| 89 |
+
responder=child_text(root, "해석기관명") or child_text(root, "회신기관명"),
|
| 90 |
+
question=question,
|
| 91 |
+
answer=answer,
|
| 92 |
+
reason=reason,
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
__all__ = ["search_interpretations", "get_interpretation_text"]
|
src/kpaa/law_api/jo.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""법제처 OPEN API의 JO(조문) 6자리 코드 인코딩 + 항/조 번호 정규화.
|
| 2 |
+
|
| 3 |
+
라이브 검증(2026-04-29):
|
| 4 |
+
"15" → "001500" (제15조)
|
| 5 |
+
"15의2" → "001502" (제15조의2)
|
| 6 |
+
"24의2" → "002402" (제24조의2 = 주민등록번호 처리의 제한)
|
| 7 |
+
"22의2" → "002202" (제22조의2 = 아동의 개인정보 보호)
|
| 8 |
+
|
| 9 |
+
규칙: 4자리 조 번호 + 2자리 가지 번호 ("의 N"이 없으면 "00").
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import re
|
| 14 |
+
|
| 15 |
+
_RE = re.compile(
|
| 16 |
+
r"^\s*(?:제)?\s*(\d+)\s*(?:조)?\s*(?:의\s*(\d+)|[-_]\s*(\d+))?\s*(?:조)?\s*$"
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def encode_jo(article_no: str | int) -> str:
|
| 21 |
+
"""Encode an article number (조문 번호) to the 6-digit JO code.
|
| 22 |
+
|
| 23 |
+
Accepts:
|
| 24 |
+
15, "15", "제15조", "15조", "15의2", "15-2", "제24조의2", ...
|
| 25 |
+
|
| 26 |
+
Returns 6-digit string suitable for `JO=<code>` parameter.
|
| 27 |
+
"""
|
| 28 |
+
if isinstance(article_no, int):
|
| 29 |
+
return f"{article_no:04d}00"
|
| 30 |
+
s = str(article_no).strip()
|
| 31 |
+
m = _RE.match(s)
|
| 32 |
+
if not m:
|
| 33 |
+
raise ValueError(f"unrecognized article number: {article_no!r}")
|
| 34 |
+
base = int(m.group(1))
|
| 35 |
+
branch_str = m.group(2) or m.group(3) or "0"
|
| 36 |
+
branch = int(branch_str)
|
| 37 |
+
if not 0 <= base <= 9999:
|
| 38 |
+
raise ValueError(f"article number out of range: {base}")
|
| 39 |
+
if not 0 <= branch <= 99:
|
| 40 |
+
raise ValueError(f"branch number out of range: {branch}")
|
| 41 |
+
return f"{base:04d}{branch:02d}"
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def decode_jo(code: str) -> str:
|
| 45 |
+
"""Decode a 6-digit JO code back to a human-readable form.
|
| 46 |
+
|
| 47 |
+
"001500" → "15", "002402" → "24의2".
|
| 48 |
+
"""
|
| 49 |
+
if not (isinstance(code, str) and len(code) == 6 and code.isdigit()):
|
| 50 |
+
raise ValueError(f"invalid JO code: {code!r}")
|
| 51 |
+
base = int(code[:4])
|
| 52 |
+
branch = int(code[4:])
|
| 53 |
+
return f"{base}" if branch == 0 else f"{base}의{branch}"
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# ────────────────────────────────────────────────────────────────────────────
|
| 57 |
+
# 원숫자(①②③) 파서 + 일반화된 항번호 파서
|
| 58 |
+
# ────────────────────────────────────────────────────────────────────────────
|
| 59 |
+
# 법제처 응답의 `<항번호>` 값은 한글 원숫자로 옴 — 예: "①", "② ", "(1)", "1.".
|
| 60 |
+
# 단순 `int(raw)` / `int(raw.replace(...))` 는 NaN 또는 ValueError 를 낸다.
|
| 61 |
+
# 이 매핑은 Unicode 코드포인트 동시 커버:
|
| 62 |
+
# U+2460..U+2473 ① ~ ⑳ (1~20)
|
| 63 |
+
# U+2474..U+2487 ⑴ ~ ⒇ (1~20, parenthesized)
|
| 64 |
+
# U+24EA ⓪ (0)
|
| 65 |
+
# U+3251..U+325F ㉑ ~ ㉟ (21~35)
|
| 66 |
+
# U+32B1..U+32BF ㊱ ~ ㊿ (36~50)
|
| 67 |
+
_CIRCLED_DIGIT_MAP: dict[str, int] = {}
|
| 68 |
+
for _i in range(20):
|
| 69 |
+
_CIRCLED_DIGIT_MAP[chr(0x2460 + _i)] = _i + 1 # ①..⑳
|
| 70 |
+
_CIRCLED_DIGIT_MAP[chr(0x2474 + _i)] = _i + 1 # ⑴..⒇
|
| 71 |
+
_CIRCLED_DIGIT_MAP[chr(0x24EA)] = 0
|
| 72 |
+
for _i in range(15):
|
| 73 |
+
_CIRCLED_DIGIT_MAP[chr(0x3251 + _i)] = _i + 21 # ㉑..㉟
|
| 74 |
+
for _i in range(15):
|
| 75 |
+
_CIRCLED_DIGIT_MAP[chr(0x32B1 + _i)] = _i + 36 # ㊱..㊿
|
| 76 |
+
del _i
|
| 77 |
+
|
| 78 |
+
_PLAIN_DIGITS_RE = re.compile(r"\d+")
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def parse_hang_number(raw: str | int | None) -> int | None:
|
| 82 |
+
"""`<항번호>` 등 한국 법령 텍스트의 항/호 번호를 정수로 정규화.
|
| 83 |
+
|
| 84 |
+
수용 가능 입력:
|
| 85 |
+
① ② ⑩ → 1, 2, 10
|
| 86 |
+
"① " → 1 (뒤 공백 OK)
|
| 87 |
+
"(1)" "1." "1) " "제1항" "제1호" "1" → 1
|
| 88 |
+
⓪ → 0
|
| 89 |
+
None / "" → None
|
| 90 |
+
|
| 91 |
+
빈/이해 불가 입력은 None. 값 비교 시 `if (n := parse_hang_number(x)) == 1`
|
| 92 |
+
패턴으로 사용.
|
| 93 |
+
|
| 94 |
+
원작 article-parser.ts 의 `parseHangNumber()` 동등 — 작은 모델 응답에서
|
| 95 |
+
"최대 제0항" 식의 NaN→0 오판정 회귀 방지가 도입 동기.
|
| 96 |
+
"""
|
| 97 |
+
if raw is None:
|
| 98 |
+
return None
|
| 99 |
+
if isinstance(raw, int):
|
| 100 |
+
return raw
|
| 101 |
+
s = str(raw).strip()
|
| 102 |
+
if not s:
|
| 103 |
+
return None
|
| 104 |
+
# 1) 원숫자 직접 매칭 (가장 흔한 케이스)
|
| 105 |
+
for ch in s:
|
| 106 |
+
if ch in _CIRCLED_DIGIT_MAP:
|
| 107 |
+
return _CIRCLED_DIGIT_MAP[ch]
|
| 108 |
+
# 2) 일반 숫자 fallback — 첫 번째 숫자 시퀀스
|
| 109 |
+
m = _PLAIN_DIGITS_RE.search(s)
|
| 110 |
+
if m:
|
| 111 |
+
try:
|
| 112 |
+
return int(m.group(0))
|
| 113 |
+
except ValueError:
|
| 114 |
+
return None
|
| 115 |
+
return None
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def normalize_jo(raw: str | int) -> str:
|
| 119 |
+
"""다양한 조문 표기를 *decoded JO* 형태로 통일.
|
| 120 |
+
|
| 121 |
+
"제24조의2", "24-2", "24의2", "24조의2", "24_2" 모두 → "24의2".
|
| 122 |
+
"제15조", "15조", " 15 " 모두 → "15".
|
| 123 |
+
|
| 124 |
+
실패 시 ValueError. encode_jo + decode_jo 를 거치므로 같은 검증을 재사용.
|
| 125 |
+
"""
|
| 126 |
+
return decode_jo(encode_jo(raw))
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
__all__ = [
|
| 130 |
+
"encode_jo",
|
| 131 |
+
"decode_jo",
|
| 132 |
+
"normalize_jo",
|
| 133 |
+
"parse_hang_number",
|
| 134 |
+
]
|
src/kpaa/law_api/law.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""법령 카테고리 — 법제처 OPEN API `target=law` + 별표 `target=licbyl`.
|
| 2 |
+
|
| 3 |
+
핵심 함수 (Day 2):
|
| 4 |
+
- search_law : 법령 목록 검색
|
| 5 |
+
- get_law_text : 법령 본문 (전체 또는 특정 조문)
|
| 6 |
+
- get_article_detail : 단일 조문 (편의 래퍼)
|
| 7 |
+
- get_annexes : 별표·별지서식 목록
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from kpaa.law_api.client import DETAIL_PATH, LIST_PATH, LawApiCall, LawGoKrClient
|
| 12 |
+
from kpaa.law_api.endpoints import LAW, LICBYL
|
| 13 |
+
from kpaa.law_api.jo import decode_jo, encode_jo
|
| 14 |
+
from kpaa.law_api.models import (
|
| 15 |
+
AnnexHit,
|
| 16 |
+
Article,
|
| 17 |
+
LawHit,
|
| 18 |
+
LawText,
|
| 19 |
+
)
|
| 20 |
+
from kpaa.law_api.parsers import child_text, parse_xml
|
| 21 |
+
|
| 22 |
+
CACHE_TTL_SEARCH = 3600 # 1h
|
| 23 |
+
CACHE_TTL_DETAIL = 24 * 3600 # 24h
|
| 24 |
+
|
| 25 |
+
# 조문 1개 본문 길이 컷 (캐시 전 적용). 정책: *왜곡 없이 전달* 우선이므로
|
| 26 |
+
# 일반 조문(평균 500~2000자) 은 절대 안 잘리는 너그러운 상한. 별표·서식이 본문에
|
| 27 |
+
# 함께 오는 비정상적 응답에 대한 sanity cap 역할만.
|
| 28 |
+
ARTICLE_BODY_LIMIT = 12000
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
async def search_law(
|
| 32 |
+
client: LawGoKrClient,
|
| 33 |
+
*,
|
| 34 |
+
query: str,
|
| 35 |
+
display: int = 20,
|
| 36 |
+
) -> list[LawHit]:
|
| 37 |
+
"""법령 목록 검색 (`lawSearch.do?target=law`)."""
|
| 38 |
+
if not 1 <= display <= 100:
|
| 39 |
+
raise ValueError("display must be in [1, 100]")
|
| 40 |
+
call = LawApiCall(
|
| 41 |
+
target=LAW.target,
|
| 42 |
+
path=LIST_PATH,
|
| 43 |
+
params={"query": query, "display": str(display)},
|
| 44 |
+
cache_ttl=CACHE_TTL_SEARCH,
|
| 45 |
+
)
|
| 46 |
+
body = await client.fetch(call)
|
| 47 |
+
root = parse_xml(body)
|
| 48 |
+
|
| 49 |
+
hits: list[LawHit] = []
|
| 50 |
+
for el in root.findall(f".//{LAW.row_tag}"):
|
| 51 |
+
hits.append(
|
| 52 |
+
LawHit(
|
| 53 |
+
mst=child_text(el, LAW.id_field),
|
| 54 |
+
name=child_text(el, "법령명한글"),
|
| 55 |
+
name_short=child_text(el, "법령약칭명"),
|
| 56 |
+
promulgate_date=child_text(el, "공포일자"),
|
| 57 |
+
promulgate_no=child_text(el, "공포번호"),
|
| 58 |
+
enforce_date=child_text(el, "시행일자"),
|
| 59 |
+
type_name=child_text(el, "법령구분명"),
|
| 60 |
+
department=child_text(el, "소관부처명"),
|
| 61 |
+
)
|
| 62 |
+
)
|
| 63 |
+
return hits
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
async def get_law_text(
|
| 67 |
+
client: LawGoKrClient,
|
| 68 |
+
*,
|
| 69 |
+
mst: str,
|
| 70 |
+
jo: str | int | None = None,
|
| 71 |
+
) -> LawText:
|
| 72 |
+
"""법령 본문 (`lawService.do?target=law&MST=...`).
|
| 73 |
+
|
| 74 |
+
Args:
|
| 75 |
+
mst: 법령일련번호
|
| 76 |
+
jo: 특정 조문만 받으려면 "15", "24의2", 15 등으로 지정. None이면 전체.
|
| 77 |
+
"""
|
| 78 |
+
params: dict[str, str] = {"MST": mst}
|
| 79 |
+
if jo is not None:
|
| 80 |
+
params["JO"] = encode_jo(jo) if not (isinstance(jo, str) and jo.isdigit() and len(jo) == 6) else jo
|
| 81 |
+
call = LawApiCall(
|
| 82 |
+
target=LAW.target,
|
| 83 |
+
path=DETAIL_PATH,
|
| 84 |
+
params=params,
|
| 85 |
+
cache_ttl=CACHE_TTL_DETAIL,
|
| 86 |
+
)
|
| 87 |
+
body = await client.fetch(call)
|
| 88 |
+
root = parse_xml(body)
|
| 89 |
+
return _parse_law_text(root, mst=mst)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
async def get_article_detail(
|
| 93 |
+
client: LawGoKrClient,
|
| 94 |
+
*,
|
| 95 |
+
mst: str,
|
| 96 |
+
article_no: str | int,
|
| 97 |
+
) -> Article:
|
| 98 |
+
"""단일 조문 (편의 래퍼).
|
| 99 |
+
|
| 100 |
+
내부적으로 `get_law_text(mst, jo=article_no)`를 호출하고 첫 조문을 반환.
|
| 101 |
+
조문이 없으면 빈 Article 반환 (raw_text="").
|
| 102 |
+
"""
|
| 103 |
+
text = await get_law_text(client, mst=mst, jo=article_no)
|
| 104 |
+
for art in text.articles:
|
| 105 |
+
return art
|
| 106 |
+
return Article(mst=mst, article_no=str(article_no), title="", raw_text="")
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
async def get_annexes(
|
| 110 |
+
client: LawGoKrClient,
|
| 111 |
+
*,
|
| 112 |
+
related_law_id: str | None = None,
|
| 113 |
+
query: str = "",
|
| 114 |
+
display: int = 20,
|
| 115 |
+
) -> list[AnnexHit]:
|
| 116 |
+
"""별표·별지서식 검색 (`lawSearch.do?target=licbyl`).
|
| 117 |
+
|
| 118 |
+
Args:
|
| 119 |
+
related_law_id: 특정 법령의 별표만 추리려면 법령ID(6자리, 예: "011357").
|
| 120 |
+
주의: MST 가 아니라 법령ID. korean-law-mcp의 별표 도구도 동일.
|
| 121 |
+
query: 별표명/관련법령명 키워드 (선택)
|
| 122 |
+
display: 1~100
|
| 123 |
+
"""
|
| 124 |
+
if not 1 <= display <= 100:
|
| 125 |
+
raise ValueError("display must be in [1, 100]")
|
| 126 |
+
params: dict[str, str] = {"display": str(display)}
|
| 127 |
+
if query:
|
| 128 |
+
params["query"] = query
|
| 129 |
+
if related_law_id:
|
| 130 |
+
params["LSID"] = related_law_id
|
| 131 |
+
|
| 132 |
+
call = LawApiCall(
|
| 133 |
+
target=LICBYL.target,
|
| 134 |
+
path=LIST_PATH,
|
| 135 |
+
params=params,
|
| 136 |
+
cache_ttl=CACHE_TTL_SEARCH,
|
| 137 |
+
)
|
| 138 |
+
body = await client.fetch(call)
|
| 139 |
+
root = parse_xml(body)
|
| 140 |
+
|
| 141 |
+
hits: list[AnnexHit] = []
|
| 142 |
+
for el in root.findall(f".//{LICBYL.row_tag}"):
|
| 143 |
+
hits.append(
|
| 144 |
+
AnnexHit(
|
| 145 |
+
annex_id=child_text(el, LICBYL.id_field),
|
| 146 |
+
name=child_text(el, "별표명"),
|
| 147 |
+
related_law_mst=child_text(el, "관련법령일련번호"),
|
| 148 |
+
related_law_name=child_text(el, "관련법령명"),
|
| 149 |
+
annex_no=child_text(el, "별표번호"),
|
| 150 |
+
annex_kind=child_text(el, "별표종류"),
|
| 151 |
+
promulgate_date=child_text(el, "공포일자"),
|
| 152 |
+
department=child_text(el, "소관부처명"),
|
| 153 |
+
file_url=child_text(el, "별표서식파일링크"),
|
| 154 |
+
)
|
| 155 |
+
)
|
| 156 |
+
return hits
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
# ───────────────────────── parsers (private) ─────────────────────────
|
| 160 |
+
|
| 161 |
+
def _parse_law_text(root, *, mst: str) -> LawText:
|
| 162 |
+
"""`<법령>` 응답을 LawText로 파싱."""
|
| 163 |
+
info = root.find("기본정보")
|
| 164 |
+
name = ""
|
| 165 |
+
law_id = ""
|
| 166 |
+
enforce_date = ""
|
| 167 |
+
promulgate_date = ""
|
| 168 |
+
department = ""
|
| 169 |
+
if info is not None:
|
| 170 |
+
name = child_text(info, "법령명_한글")
|
| 171 |
+
law_id = child_text(info, "법령ID")
|
| 172 |
+
enforce_date = child_text(info, "시행일자")
|
| 173 |
+
promulgate_date = child_text(info, "공포일자")
|
| 174 |
+
soguan = info.find("소관부처")
|
| 175 |
+
if soguan is not None and soguan.text:
|
| 176 |
+
department = soguan.text.strip()
|
| 177 |
+
|
| 178 |
+
articles: list[Article] = []
|
| 179 |
+
for unit in root.findall(".//조문/조문단위"):
|
| 180 |
+
# `조문여부`가 "조문"인 단위만 본문 (전문/체계는 패스)
|
| 181 |
+
kind = child_text(unit, "조문여부")
|
| 182 |
+
if kind != "조문":
|
| 183 |
+
continue
|
| 184 |
+
article_no_raw = child_text(unit, "조문번호")
|
| 185 |
+
article_branch = child_text(unit, "조문가지번호") or "0"
|
| 186 |
+
if article_branch and article_branch != "0":
|
| 187 |
+
article_no = f"{article_no_raw}의{article_branch}"
|
| 188 |
+
else:
|
| 189 |
+
# 일부 응답은 조문가지번호 없이 조문키 7자리("0024020")로만 가지를 표현
|
| 190 |
+
key = unit.get("조문키", "")
|
| 191 |
+
if len(key) >= 7:
|
| 192 |
+
branch = int(key[4:6])
|
| 193 |
+
if branch:
|
| 194 |
+
article_no = f"{int(key[:4])}의{branch}"
|
| 195 |
+
else:
|
| 196 |
+
article_no = article_no_raw
|
| 197 |
+
else:
|
| 198 |
+
article_no = article_no_raw
|
| 199 |
+
|
| 200 |
+
title = child_text(unit, "조문제목")
|
| 201 |
+
enforce = child_text(unit, "조문시행일자") or enforce_date
|
| 202 |
+
|
| 203 |
+
# 본문: 조문내용 + 항(번호+내용) + 호(번호+내용)
|
| 204 |
+
parts: list[str] = []
|
| 205 |
+
body = child_text(unit, "조문내용")
|
| 206 |
+
if body:
|
| 207 |
+
parts.append(body)
|
| 208 |
+
para_texts: list[str] = []
|
| 209 |
+
for para in unit.findall("항"):
|
| 210 |
+
num = child_text(para, "항번호")
|
| 211 |
+
txt = child_text(para, "항내용")
|
| 212 |
+
line = (f"{num} {txt}" if num and not txt.startswith(num.strip()) else txt).strip()
|
| 213 |
+
if line:
|
| 214 |
+
parts.append(line)
|
| 215 |
+
para_texts.append(line)
|
| 216 |
+
for ho in para.findall("호"):
|
| 217 |
+
hno = child_text(ho, "호번호")
|
| 218 |
+
hcn = child_text(ho, "호내용")
|
| 219 |
+
if hcn:
|
| 220 |
+
parts.append(hcn if hcn.startswith(hno.strip()) else f"{hno} {hcn}")
|
| 221 |
+
|
| 222 |
+
raw_text = "\n".join(parts)
|
| 223 |
+
if len(raw_text) > ARTICLE_BODY_LIMIT:
|
| 224 |
+
raw_text = raw_text[:ARTICLE_BODY_LIMIT] + "\n…(중략)…"
|
| 225 |
+
|
| 226 |
+
articles.append(
|
| 227 |
+
Article(
|
| 228 |
+
mst=mst,
|
| 229 |
+
article_no=article_no,
|
| 230 |
+
title=title,
|
| 231 |
+
enforce_date=enforce,
|
| 232 |
+
paragraphs=tuple(para_texts),
|
| 233 |
+
raw_text=raw_text,
|
| 234 |
+
)
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
return LawText(
|
| 238 |
+
mst=mst,
|
| 239 |
+
law_id=law_id,
|
| 240 |
+
name=name,
|
| 241 |
+
enforce_date=enforce_date,
|
| 242 |
+
promulgate_date=promulgate_date,
|
| 243 |
+
department=department,
|
| 244 |
+
articles=tuple(articles),
|
| 245 |
+
)
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
__all__ = [
|
| 249 |
+
"search_law",
|
| 250 |
+
"get_law_text",
|
| 251 |
+
"get_article_detail",
|
| 252 |
+
"get_annexes",
|
| 253 |
+
"encode_jo",
|
| 254 |
+
"decode_jo",
|
| 255 |
+
]
|
src/kpaa/law_api/models.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel, ConfigDict
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class LawHit(BaseModel):
|
| 7 |
+
"""법령 검색 1건 (`lawSearch.do?target=law`)."""
|
| 8 |
+
|
| 9 |
+
model_config = ConfigDict(frozen=True)
|
| 10 |
+
|
| 11 |
+
mst: str
|
| 12 |
+
name: str
|
| 13 |
+
name_short: str = ""
|
| 14 |
+
promulgate_date: str = ""
|
| 15 |
+
promulgate_no: str = ""
|
| 16 |
+
enforce_date: str = ""
|
| 17 |
+
type_name: str = ""
|
| 18 |
+
department: str = ""
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class Article(BaseModel):
|
| 22 |
+
"""법령 본문에서 추출된 1개 조문."""
|
| 23 |
+
|
| 24 |
+
model_config = ConfigDict(frozen=True)
|
| 25 |
+
|
| 26 |
+
mst: str
|
| 27 |
+
article_no: str # "15", "24의2"
|
| 28 |
+
title: str # "개인정보의 수집ㆍ이용"
|
| 29 |
+
enforce_date: str = ""
|
| 30 |
+
paragraphs: tuple[str, ...] = () # 항 본문 (각 ① ② … 그대로)
|
| 31 |
+
raw_text: str = "" # 사람이 읽기 좋은 결합 본문 (조문내용 + 항 + 호)
|
| 32 |
+
|
| 33 |
+
def citation(self, *, law_name: str = "개인정보보호법") -> str:
|
| 34 |
+
"""답변에 박을 인용 태그. 예: '개인정보보호법 제15조'."""
|
| 35 |
+
# 24의2 → 제24조의2
|
| 36 |
+
ano = self.article_no
|
| 37 |
+
if "의" in ano:
|
| 38 |
+
base, branch = ano.split("의", 1)
|
| 39 |
+
return f"{law_name} 제{base}조의{branch}"
|
| 40 |
+
return f"{law_name} 제{ano}조"
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class LawText(BaseModel):
|
| 44 |
+
"""법령 본문 응답 (조문 다수 또는 특정 조문)."""
|
| 45 |
+
|
| 46 |
+
model_config = ConfigDict(frozen=True)
|
| 47 |
+
|
| 48 |
+
mst: str
|
| 49 |
+
law_id: str = ""
|
| 50 |
+
name: str
|
| 51 |
+
enforce_date: str = ""
|
| 52 |
+
promulgate_date: str = ""
|
| 53 |
+
department: str = ""
|
| 54 |
+
articles: tuple[Article, ...] = ()
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class PIPCDecisionHit(BaseModel):
|
| 58 |
+
"""PIPC 결정문 검색 1건 (`lawSearch.do?target=ppc`)."""
|
| 59 |
+
|
| 60 |
+
model_config = ConfigDict(frozen=True)
|
| 61 |
+
|
| 62 |
+
decision_id: str
|
| 63 |
+
title: str
|
| 64 |
+
decision_no: str = "" # 안건번호 / 의안번호
|
| 65 |
+
decision_date: str = "" # 의결일 YYYYMMDD or YYYY.MM.DD
|
| 66 |
+
decision_kind: str = "" # 결정구분
|
| 67 |
+
agency: str = "" # 기관명
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class PIPCDecisionText(BaseModel):
|
| 71 |
+
"""PIPC 결정문 본문 (`lawService.do?target=ppc&ID=`)."""
|
| 72 |
+
|
| 73 |
+
model_config = ConfigDict(frozen=True)
|
| 74 |
+
|
| 75 |
+
decision_id: str
|
| 76 |
+
title: str
|
| 77 |
+
decision_no: str = ""
|
| 78 |
+
decision_date: str = ""
|
| 79 |
+
decision_kind: str = ""
|
| 80 |
+
agency: str = ""
|
| 81 |
+
main_text: str = "" # 주문
|
| 82 |
+
reason: str = "" # 이유
|
| 83 |
+
|
| 84 |
+
def citation(self) -> str:
|
| 85 |
+
"""답변에 박을 인용 태그.
|
| 86 |
+
|
| 87 |
+
의안번호가 있으면 'PIPC 결정 YYYY-NNN' 형태로, 없으면 ID 사용.
|
| 88 |
+
"""
|
| 89 |
+
if self.decision_no and self.decision_no.lower() != "null":
|
| 90 |
+
return f"PIPC 결정 {self.decision_no}"
|
| 91 |
+
return f"PIPC 결정문 #{self.decision_id}"
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
class InterpretationHit(BaseModel):
|
| 95 |
+
"""법령해석례 검색 1건 (`lawSearch.do?target=expc`)."""
|
| 96 |
+
|
| 97 |
+
model_config = ConfigDict(frozen=True)
|
| 98 |
+
|
| 99 |
+
interpretation_id: str
|
| 100 |
+
title: str
|
| 101 |
+
case_no: str = "" # 안건번호 (예: "20-0370")
|
| 102 |
+
decided_date: str = "" # 회신일자
|
| 103 |
+
inquirer: str = "" # 질의기관명
|
| 104 |
+
responder: str = "" # 회신기관명
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
class InterpretationText(BaseModel):
|
| 108 |
+
"""법령해석례 본문 (`lawService.do?target=expc&ID=`)."""
|
| 109 |
+
|
| 110 |
+
model_config = ConfigDict(frozen=True)
|
| 111 |
+
|
| 112 |
+
interpretation_id: str
|
| 113 |
+
title: str
|
| 114 |
+
case_no: str = ""
|
| 115 |
+
decided_date: str = ""
|
| 116 |
+
inquirer: str = ""
|
| 117 |
+
responder: str = ""
|
| 118 |
+
question: str = "" # 질의요지
|
| 119 |
+
answer: str = "" # 회답
|
| 120 |
+
reason: str = "" # 이유
|
| 121 |
+
|
| 122 |
+
def citation(self) -> str:
|
| 123 |
+
if self.case_no:
|
| 124 |
+
return f"법령해석례 안건 {self.case_no}"
|
| 125 |
+
return f"법령해석례 #{self.interpretation_id}"
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
class ConstitutionalDecisionHit(BaseModel):
|
| 129 |
+
"""헌재 결정문 검색 1건 (`lawSearch.do?target=detc`).
|
| 130 |
+
|
| 131 |
+
응답 row tag: `<Detc>` (대문자 D). 라이브 검증 (원작 korean-law-mcp
|
| 132 |
+
parseConstitutionalXML 기반).
|
| 133 |
+
"""
|
| 134 |
+
|
| 135 |
+
model_config = ConfigDict(frozen=True)
|
| 136 |
+
|
| 137 |
+
decision_id: str # 헌재결정례일련번호
|
| 138 |
+
title: str # 사건명
|
| 139 |
+
case_no: str = "" # 사건번호 (예: "2020헌바123")
|
| 140 |
+
decision_date: str = "" # 종국일자
|
| 141 |
+
detail_url: str = "" # 헌재결정례상세링크
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
class ConstitutionalDecisionText(BaseModel):
|
| 145 |
+
"""헌재 결정문 본문 (`lawService.do?target=detc&type=JSON&ID=`).
|
| 146 |
+
|
| 147 |
+
응답은 *JSON* 으로 옴 — `data.DetcService` 또는 `data.헌재결정례` 키 아래.
|
| 148 |
+
본문 비교적 길어 판시사항·결정요지·전문 분리 보존.
|
| 149 |
+
"""
|
| 150 |
+
|
| 151 |
+
model_config = ConfigDict(frozen=True)
|
| 152 |
+
|
| 153 |
+
decision_id: str
|
| 154 |
+
title: str # 사건명
|
| 155 |
+
case_no: str = "" # 사건번호
|
| 156 |
+
decision_date: str = "" # 종국일자 또는 선고일자
|
| 157 |
+
petitioner: str = "" # 청구인
|
| 158 |
+
respondent: str = "" # 피청구인
|
| 159 |
+
issues: str = "" # 판시사항
|
| 160 |
+
summary: str = "" # 결정요지/판결요지
|
| 161 |
+
refs_law: str = "" # 참조조문
|
| 162 |
+
refs_precedent: str = "" # 참조판례
|
| 163 |
+
body: str = "" # 판례내용/결정내용/전문
|
| 164 |
+
|
| 165 |
+
def citation(self) -> str:
|
| 166 |
+
"""답변에 박을 인용 태그.
|
| 167 |
+
|
| 168 |
+
사건번호가 있으면 '헌재 YYYY헌○NNN' 형태, 없으면 ID 사용.
|
| 169 |
+
"""
|
| 170 |
+
if self.case_no:
|
| 171 |
+
return f"헌재 {self.case_no}"
|
| 172 |
+
return f"헌법재판소 결정문 #{self.decision_id}"
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
class ArticleHistoryItem(BaseModel):
|
| 176 |
+
"""조문 개정 이력 1건 (`lawSearch.do?target=lsJoHstInf`).
|
| 177 |
+
|
| 178 |
+
한 법령의 *특정 시점* 개정에서 *특정 조문* 의 변경 사항. 같은 조가 여러
|
| 179 |
+
번 바뀌었으면 같은 article_no 로 여러 item.
|
| 180 |
+
"""
|
| 181 |
+
|
| 182 |
+
model_config = ConfigDict(frozen=True)
|
| 183 |
+
|
| 184 |
+
law_name: str # 법령명한글
|
| 185 |
+
law_id: str # 법령ID
|
| 186 |
+
mst: str # 법령일련번호
|
| 187 |
+
article_no: str # 디코드된 조문 번호 (예: "15", "24의2")
|
| 188 |
+
article_jo_code: str # 6자리 JO 코드 (예: "001500")
|
| 189 |
+
change_type: str = "" # 제개정구분명 (예: "일부개정")
|
| 190 |
+
change_reason: str = "" # 변경사유
|
| 191 |
+
article_amend_date: str = "" # 조문개정일 YYYYMMDD
|
| 192 |
+
article_enforce_date: str = "" # 조문시행일 YYYYMMDD
|
| 193 |
+
promulgate_date: str = "" # 공포일자 (법령 단위)
|
| 194 |
+
enforce_date: str = "" # 시행일자 (법령 단위)
|
| 195 |
+
|
| 196 |
+
def citation(self) -> str:
|
| 197 |
+
"""답변에 박을 인용 태그.
|
| 198 |
+
|
| 199 |
+
예: '개인정보보호법 제15조 (2020-08-05 개정)'.
|
| 200 |
+
"""
|
| 201 |
+
# article_no → 표시 형태
|
| 202 |
+
ano = self.article_no
|
| 203 |
+
if "의" in ano:
|
| 204 |
+
base, branch = ano.split("의", 1)
|
| 205 |
+
disp = f"제{base}조의{branch}"
|
| 206 |
+
else:
|
| 207 |
+
disp = f"제{ano}조"
|
| 208 |
+
date_part = ""
|
| 209 |
+
d = self.article_amend_date or self.promulgate_date
|
| 210 |
+
if d and len(d) == 8:
|
| 211 |
+
date_part = f" ({d[:4]}-{d[4:6]}-{d[6:]} 개정)"
|
| 212 |
+
return f"{self.law_name} {disp}{date_part}"
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
class AnnexHit(BaseModel):
|
| 216 |
+
"""별표·별지서식 검색 1건 (`lawSearch.do?target=licbyl`)."""
|
| 217 |
+
|
| 218 |
+
model_config = ConfigDict(frozen=True)
|
| 219 |
+
|
| 220 |
+
annex_id: str
|
| 221 |
+
name: str
|
| 222 |
+
related_law_mst: str = ""
|
| 223 |
+
related_law_name: str = ""
|
| 224 |
+
annex_no: str = ""
|
| 225 |
+
annex_kind: str = "" # 별표/별지/서식
|
| 226 |
+
promulgate_date: str = ""
|
| 227 |
+
department: str = ""
|
| 228 |
+
file_url: str = ""
|
src/kpaa/law_api/nlrc.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""중앙노동위원회 결정 (target=nlrc).
|
| 2 |
+
|
| 3 |
+
라이브 검증 row 필드: 결정문일련번호 / 제목 / 사건번호 / 등록일.
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
from kpaa.law_api.client import DETAIL_PATH, LIST_PATH, LawApiCall, LawGoKrClient
|
| 8 |
+
from kpaa.law_api.endpoints import NLRC
|
| 9 |
+
from kpaa.law_api.parsers import child_text, parse_xml
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
async def search_nlrc_decisions(
|
| 13 |
+
client: LawGoKrClient, *, query: str, display: int = 20
|
| 14 |
+
) -> list[dict[str, str]]:
|
| 15 |
+
if not 1 <= display <= 100:
|
| 16 |
+
raise ValueError("display must be in [1, 100]")
|
| 17 |
+
body = await client.fetch(
|
| 18 |
+
LawApiCall(target=NLRC.target, path=LIST_PATH,
|
| 19 |
+
params={"query": query, "display": str(display)},
|
| 20 |
+
cache_ttl=3600)
|
| 21 |
+
)
|
| 22 |
+
root = parse_xml(body)
|
| 23 |
+
return [
|
| 24 |
+
{
|
| 25 |
+
"decision_id": child_text(el, "결정문일련번호"),
|
| 26 |
+
"title": child_text(el, "제목"),
|
| 27 |
+
"case_no": child_text(el, "사건번호"),
|
| 28 |
+
"registered_date": child_text(el, "등록일"),
|
| 29 |
+
}
|
| 30 |
+
for el in root.findall(f".//{NLRC.row_tag}")
|
| 31 |
+
]
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
async def get_nlrc_decision_text(client: LawGoKrClient, *, decision_id: str) -> str:
|
| 35 |
+
return await client.fetch(
|
| 36 |
+
LawApiCall(target=NLRC.target, path=DETAIL_PATH,
|
| 37 |
+
params={"ID": str(decision_id)}, cache_ttl=24 * 3600)
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
__all__ = ["search_nlrc_decisions", "get_nlrc_decision_text"]
|
src/kpaa/law_api/oldnew.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""신구법비교 (target=oldAndNew) — 동일 법령의 개정 전후 조문 비교.
|
| 2 |
+
|
| 3 |
+
라이브 검증 row 필드: 신구법일련번호 / 신구법명 / 신구법ID / 공포일자 /
|
| 4 |
+
공포번호 / 시행일자 / 제개정구분명 / 법령구분명 / 소관부처명.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from kpaa.law_api.client import DETAIL_PATH, LIST_PATH, LawApiCall, LawGoKrClient
|
| 9 |
+
from kpaa.law_api.endpoints import OLDNEW
|
| 10 |
+
from kpaa.law_api.parsers import child_text, parse_xml
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
async def search_old_new(
|
| 14 |
+
client: LawGoKrClient, *, query: str, display: int = 20
|
| 15 |
+
) -> list[dict[str, str]]:
|
| 16 |
+
if not 1 <= display <= 100:
|
| 17 |
+
raise ValueError("display must be in [1, 100]")
|
| 18 |
+
body = await client.fetch(
|
| 19 |
+
LawApiCall(target=OLDNEW.target, path=LIST_PATH,
|
| 20 |
+
params={"query": query, "display": str(display)},
|
| 21 |
+
cache_ttl=3600)
|
| 22 |
+
)
|
| 23 |
+
root = parse_xml(body)
|
| 24 |
+
return [
|
| 25 |
+
{
|
| 26 |
+
"mst": child_text(el, "신구법일련번호"),
|
| 27 |
+
"law_id": child_text(el, "신구법ID"),
|
| 28 |
+
"name": child_text(el, "신구법명"),
|
| 29 |
+
"amend_type": child_text(el, "제개정구분명"),
|
| 30 |
+
"law_kind": child_text(el, "법령구분명"),
|
| 31 |
+
"promulgate_date": child_text(el, "공포일자"),
|
| 32 |
+
"promulgate_no": child_text(el, "공포번호"),
|
| 33 |
+
"enforce_date": child_text(el, "시행일자"),
|
| 34 |
+
"department": child_text(el, "소관부처명"),
|
| 35 |
+
}
|
| 36 |
+
for el in root.findall(f".//{OLDNEW.row_tag}")
|
| 37 |
+
]
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
async def compare_old_new(client: LawGoKrClient, *, mst: str) -> str:
|
| 41 |
+
"""신·구 법조문 비교 본문 (raw XML)."""
|
| 42 |
+
return await client.fetch(
|
| 43 |
+
LawApiCall(target=OLDNEW.target, path=DETAIL_PATH,
|
| 44 |
+
params={"MST": str(mst)}, cache_ttl=24 * 3600)
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
__all__ = ["search_old_new", "compare_old_new"]
|
src/kpaa/law_api/ordinance.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""자치법규 (target=ordin)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from kpaa.law_api.client import DETAIL_PATH, LIST_PATH, LawApiCall, LawGoKrClient
|
| 5 |
+
from kpaa.law_api.endpoints import ORDIN
|
| 6 |
+
from kpaa.law_api.parsers import child_text, parse_xml
|
| 7 |
+
|
| 8 |
+
CACHE_TTL_SEARCH = 3600
|
| 9 |
+
CACHE_TTL_DETAIL = 24 * 3600
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
async def search_ordinances(
|
| 13 |
+
client: LawGoKrClient,
|
| 14 |
+
*,
|
| 15 |
+
query: str,
|
| 16 |
+
display: int = 20,
|
| 17 |
+
) -> list[dict[str, str]]:
|
| 18 |
+
if not 1 <= display <= 100:
|
| 19 |
+
raise ValueError("display must be in [1, 100]")
|
| 20 |
+
call = LawApiCall(
|
| 21 |
+
target=ORDIN.target, path=LIST_PATH,
|
| 22 |
+
params={"query": query, "display": str(display)},
|
| 23 |
+
cache_ttl=CACHE_TTL_SEARCH,
|
| 24 |
+
)
|
| 25 |
+
body = await client.fetch(call)
|
| 26 |
+
root = parse_xml(body)
|
| 27 |
+
out: list[dict[str, str]] = []
|
| 28 |
+
for el in root.findall(f".//{ORDIN.row_tag}"):
|
| 29 |
+
out.append(
|
| 30 |
+
{
|
| 31 |
+
"ordinance_id": child_text(el, "자치법규일련번호") or child_text(el, "법령일련번호"),
|
| 32 |
+
"name": child_text(el, "자치법규명") or child_text(el, "법령명한글"),
|
| 33 |
+
"promulgate_date": child_text(el, "공포일자"),
|
| 34 |
+
"enforce_date": child_text(el, "시행일자"),
|
| 35 |
+
"agency": child_text(el, "소관부처명") or child_text(el, "지자체기관명"),
|
| 36 |
+
"type": child_text(el, "자치법규종류") or child_text(el, "법령구분명"),
|
| 37 |
+
}
|
| 38 |
+
)
|
| 39 |
+
return out
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
async def get_ordinance_text(
|
| 43 |
+
client: LawGoKrClient,
|
| 44 |
+
*,
|
| 45 |
+
ordinance_id: str,
|
| 46 |
+
) -> str:
|
| 47 |
+
call = LawApiCall(
|
| 48 |
+
target=ORDIN.target, path=DETAIL_PATH,
|
| 49 |
+
params={"ID": str(ordinance_id)},
|
| 50 |
+
cache_ttl=CACHE_TTL_DETAIL,
|
| 51 |
+
)
|
| 52 |
+
return await client.fetch(call)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
__all__ = ["search_ordinances", "get_ordinance_text"]
|
src/kpaa/law_api/parsers.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""법제처 OPEN API XML 응답 파싱 헬퍼.
|
| 2 |
+
|
| 3 |
+
법제처 응답은 한글 태그 이름을 그대로 사용하므로 lxml의 일반 find/findall 와
|
| 4 |
+
이 모듈의 가벼운 보조 함수로 충분하다.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from lxml import etree
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def parse_xml(body: str) -> etree._Element:
|
| 12 |
+
return etree.fromstring(body.encode("utf-8"))
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def text_of(el: etree._Element | None, default: str = "") -> str:
|
| 16 |
+
if el is None or el.text is None:
|
| 17 |
+
return default
|
| 18 |
+
return el.text.strip()
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def child_text(parent: etree._Element, tag: str, default: str = "") -> str:
|
| 22 |
+
return text_of(parent.find(tag), default)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def all_text(el: etree._Element | None) -> str:
|
| 26 |
+
"""Return concatenated text of an element and all descendants, joined by newline."""
|
| 27 |
+
if el is None:
|
| 28 |
+
return ""
|
| 29 |
+
parts = [t.strip() for t in el.itertext() if t and t.strip()]
|
| 30 |
+
return "\n".join(parts)
|
src/kpaa/law_api/pipc.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""개인정보보호위원회 결정문 (PIPC) — `target=ppc`.
|
| 2 |
+
|
| 3 |
+
라이브 검증(2026-04-29):
|
| 4 |
+
- 검색: lawSearch.do?target=ppc&query=...&display=...
|
| 5 |
+
root=<Ppc>, row=<ppc>, id=결정문일련번호
|
| 6 |
+
- 본문: lawService.do?target=ppc&ID=...
|
| 7 |
+
root=<PpcService>, container=<의결서>
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from kpaa.law_api.client import DETAIL_PATH, LIST_PATH, LawApiCall, LawGoKrClient
|
| 12 |
+
from kpaa.law_api.endpoints import PPC
|
| 13 |
+
from kpaa.law_api.models import PIPCDecisionHit, PIPCDecisionText
|
| 14 |
+
from kpaa.law_api.parsers import child_text, parse_xml
|
| 15 |
+
|
| 16 |
+
CACHE_TTL_SEARCH = 3600
|
| 17 |
+
CACHE_TTL_DETAIL = 24 * 3600
|
| 18 |
+
|
| 19 |
+
DECISION_BODY_LIMIT = 10_000
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
async def search_decisions(
|
| 23 |
+
client: LawGoKrClient,
|
| 24 |
+
*,
|
| 25 |
+
query: str,
|
| 26 |
+
display: int = 20,
|
| 27 |
+
) -> list[PIPCDecisionHit]:
|
| 28 |
+
"""PIPC 결정문 목록 검색."""
|
| 29 |
+
if not 1 <= display <= 100:
|
| 30 |
+
raise ValueError("display must be in [1, 100]")
|
| 31 |
+
call = LawApiCall(
|
| 32 |
+
target=PPC.target,
|
| 33 |
+
path=LIST_PATH,
|
| 34 |
+
params={"query": query, "display": str(display)},
|
| 35 |
+
cache_ttl=CACHE_TTL_SEARCH,
|
| 36 |
+
)
|
| 37 |
+
body = await client.fetch(call)
|
| 38 |
+
root = parse_xml(body)
|
| 39 |
+
agency = child_text(root, "기관명")
|
| 40 |
+
|
| 41 |
+
hits: list[PIPCDecisionHit] = []
|
| 42 |
+
for el in root.findall(f".//{PPC.row_tag}"):
|
| 43 |
+
hits.append(
|
| 44 |
+
PIPCDecisionHit(
|
| 45 |
+
decision_id=child_text(el, PPC.id_field),
|
| 46 |
+
title=child_text(el, "안건명"),
|
| 47 |
+
decision_no=child_text(el, "의안번호"),
|
| 48 |
+
decision_date=child_text(el, "의결일"),
|
| 49 |
+
decision_kind=child_text(el, "결정구분"),
|
| 50 |
+
agency=agency,
|
| 51 |
+
)
|
| 52 |
+
)
|
| 53 |
+
return hits
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
async def get_decision_text(
|
| 57 |
+
client: LawGoKrClient,
|
| 58 |
+
*,
|
| 59 |
+
decision_id: str,
|
| 60 |
+
) -> PIPCDecisionText:
|
| 61 |
+
"""PIPC 결정문 본문."""
|
| 62 |
+
call = LawApiCall(
|
| 63 |
+
target=PPC.target,
|
| 64 |
+
path=DETAIL_PATH,
|
| 65 |
+
params={"ID": str(decision_id)},
|
| 66 |
+
cache_ttl=CACHE_TTL_DETAIL,
|
| 67 |
+
)
|
| 68 |
+
body = await client.fetch(call)
|
| 69 |
+
root = parse_xml(body)
|
| 70 |
+
container = root.find("의결서")
|
| 71 |
+
if container is None:
|
| 72 |
+
container = root
|
| 73 |
+
|
| 74 |
+
main_text = child_text(container, "주문")
|
| 75 |
+
reason = child_text(container, "이유")
|
| 76 |
+
if len(main_text) + len(reason) > DECISION_BODY_LIMIT:
|
| 77 |
+
# 주문 우선 보존, 이유 잘라내기
|
| 78 |
+
budget = max(0, DECISION_BODY_LIMIT - len(main_text))
|
| 79 |
+
if budget < len(reason):
|
| 80 |
+
reason = reason[:budget] + "\n…(중략)…"
|
| 81 |
+
|
| 82 |
+
return PIPCDecisionText(
|
| 83 |
+
decision_id=str(decision_id),
|
| 84 |
+
title=child_text(container, "안건명"),
|
| 85 |
+
decision_no=child_text(container, "의안번호") or child_text(container, "안건번호"),
|
| 86 |
+
decision_date=child_text(container, "의결연월일"),
|
| 87 |
+
decision_kind=child_text(container, "결정"),
|
| 88 |
+
agency=child_text(container, "기관명"),
|
| 89 |
+
main_text=main_text,
|
| 90 |
+
reason=reason,
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
__all__ = ["search_decisions", "get_decision_text"]
|
src/kpaa/law_api/precedent.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""판례 (target=prec) — thin wrapper.
|
| 2 |
+
|
| 3 |
+
검색은 list endpoint, 본문은 detail endpoint. 각 row를 dict로 반환.
|
| 4 |
+
챗봇 RAG는 핵심 8개 외에서만 보조적으로 사용.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from kpaa.law_api.client import DETAIL_PATH, LIST_PATH, LawApiCall, LawGoKrClient
|
| 9 |
+
from kpaa.law_api.endpoints import PREC
|
| 10 |
+
from kpaa.law_api.parsers import child_text, parse_xml
|
| 11 |
+
|
| 12 |
+
CACHE_TTL_SEARCH = 3600
|
| 13 |
+
CACHE_TTL_DETAIL = 24 * 3600
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
async def search_precedents(
|
| 17 |
+
client: LawGoKrClient,
|
| 18 |
+
*,
|
| 19 |
+
query: str,
|
| 20 |
+
display: int = 20,
|
| 21 |
+
) -> list[dict[str, str]]:
|
| 22 |
+
"""판례 목록 검색. dict 리스트 반환 (사건명/사건번호/선고일자/법원명/판례일련번호 등)."""
|
| 23 |
+
if not 1 <= display <= 100:
|
| 24 |
+
raise ValueError("display must be in [1, 100]")
|
| 25 |
+
call = LawApiCall(
|
| 26 |
+
target=PREC.target, path=LIST_PATH,
|
| 27 |
+
params={"query": query, "display": str(display)},
|
| 28 |
+
cache_ttl=CACHE_TTL_SEARCH,
|
| 29 |
+
)
|
| 30 |
+
body = await client.fetch(call)
|
| 31 |
+
root = parse_xml(body)
|
| 32 |
+
out: list[dict[str, str]] = []
|
| 33 |
+
for el in root.findall(f".//{PREC.row_tag}"):
|
| 34 |
+
out.append(
|
| 35 |
+
{
|
| 36 |
+
"precedent_id": child_text(el, PREC.id_field),
|
| 37 |
+
"case_name": child_text(el, "사건명"),
|
| 38 |
+
"case_no": child_text(el, "사건번호"),
|
| 39 |
+
"decision_date": child_text(el, "선고일자"),
|
| 40 |
+
"court_name": child_text(el, "법원명"),
|
| 41 |
+
"case_kind": child_text(el, "사건종류명"),
|
| 42 |
+
"verdict_type": child_text(el, "판결유형"),
|
| 43 |
+
}
|
| 44 |
+
)
|
| 45 |
+
return out
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
async def get_precedent_text(
|
| 49 |
+
client: LawGoKrClient,
|
| 50 |
+
*,
|
| 51 |
+
precedent_id: str,
|
| 52 |
+
) -> str:
|
| 53 |
+
"""판례 본문 — raw XML 반환 (구조 다양성 큼)."""
|
| 54 |
+
call = LawApiCall(
|
| 55 |
+
target=PREC.target, path=DETAIL_PATH,
|
| 56 |
+
params={"ID": str(precedent_id)},
|
| 57 |
+
cache_ttl=CACHE_TTL_DETAIL,
|
| 58 |
+
)
|
| 59 |
+
return await client.fetch(call)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
__all__ = ["search_precedents", "get_precedent_text"]
|
src/kpaa/law_api/raw.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Generic raw 호출 인터페이스.
|
| 2 |
+
|
| 3 |
+
특정 카테고리 풀구현(법령/PIPC/해석례)이 노출하지 않는 target에 대해
|
| 4 |
+
임의 target/파라미터로 list 또는 detail을 호출할 수 있다.
|
| 5 |
+
SDK 사용자가 KoreanLawClient의 미구현 카테고리를 직접 채워 쓸 수 있도록.
|
| 6 |
+
|
| 7 |
+
rows = await client.raw.search("ftc", query="개인정보", display=10)
|
| 8 |
+
detail = await client.raw.get("ftc", id="12345")
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from typing import Any
|
| 13 |
+
|
| 14 |
+
from kpaa.law_api.client import (
|
| 15 |
+
DETAIL_PATH,
|
| 16 |
+
LIST_PATH,
|
| 17 |
+
LawApiCall,
|
| 18 |
+
LawGoKrClient,
|
| 19 |
+
)
|
| 20 |
+
from kpaa.law_api.parsers import parse_xml
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _row_to_dict(el) -> dict[str, str]:
|
| 24 |
+
out: dict[str, str] = {}
|
| 25 |
+
for child in el:
|
| 26 |
+
txt = (child.text or "").strip()
|
| 27 |
+
if txt and not list(child):
|
| 28 |
+
out[child.tag] = txt
|
| 29 |
+
return out
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
async def raw_search(
|
| 33 |
+
client: LawGoKrClient,
|
| 34 |
+
target: str,
|
| 35 |
+
*,
|
| 36 |
+
query: str = "",
|
| 37 |
+
display: int = 20,
|
| 38 |
+
extra_params: dict[str, Any] | None = None,
|
| 39 |
+
cache_ttl: int = 3600,
|
| 40 |
+
) -> list[dict[str, str]]:
|
| 41 |
+
"""임의 target에 대한 lawSearch.do 호출."""
|
| 42 |
+
params: dict[str, Any] = {"display": str(display)}
|
| 43 |
+
if query:
|
| 44 |
+
params["query"] = query
|
| 45 |
+
if extra_params:
|
| 46 |
+
params.update(extra_params)
|
| 47 |
+
call = LawApiCall(
|
| 48 |
+
target=target, path=LIST_PATH, params=params, cache_ttl=cache_ttl
|
| 49 |
+
)
|
| 50 |
+
body = await client.fetch(call)
|
| 51 |
+
if not body.strip():
|
| 52 |
+
return []
|
| 53 |
+
root = parse_xml(body)
|
| 54 |
+
meta = {
|
| 55 |
+
"target", "키워드", "section", "totalCnt", "page", "key",
|
| 56 |
+
"numOfRows", "resultCode", "resultMsg", "기관명",
|
| 57 |
+
}
|
| 58 |
+
return [_row_to_dict(ch) for ch in root if ch.tag not in meta and len(ch) > 0]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
async def raw_get(
|
| 62 |
+
client: LawGoKrClient,
|
| 63 |
+
target: str,
|
| 64 |
+
*,
|
| 65 |
+
id: str | None = None,
|
| 66 |
+
mst: str | None = None,
|
| 67 |
+
extra_params: dict[str, Any] | None = None,
|
| 68 |
+
cache_ttl: int = 24 * 3600,
|
| 69 |
+
) -> str:
|
| 70 |
+
"""임의 target에 대한 lawService.do 호출. raw XML 본문 반환."""
|
| 71 |
+
params: dict[str, Any] = {}
|
| 72 |
+
if mst is not None:
|
| 73 |
+
params["MST"] = str(mst)
|
| 74 |
+
if id is not None:
|
| 75 |
+
params["ID"] = str(id)
|
| 76 |
+
if extra_params:
|
| 77 |
+
params.update(extra_params)
|
| 78 |
+
if not params:
|
| 79 |
+
raise ValueError("raw_get requires at least one of id/mst/extra_params")
|
| 80 |
+
call = LawApiCall(
|
| 81 |
+
target=target, path=DETAIL_PATH, params=params, cache_ttl=cache_ttl
|
| 82 |
+
)
|
| 83 |
+
return await client.fetch(call)
|
src/kpaa/law_api/terms.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""법령용어 (target=lstrm).
|
| 2 |
+
|
| 3 |
+
라이브 검증 row 필드: 법령용어ID / 법령용어명 / 사전구분코드 / 법령종류코드 /
|
| 4 |
+
법령용어상세링크 / 법령용어상세검색.
|
| 5 |
+
|
| 6 |
+
detail은 trmSeqs 파라미터를 쓰며 ID가 콤마로 여러 개 결합되어 있을 수 있음.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from kpaa.law_api.client import DETAIL_PATH, LIST_PATH, LawApiCall, LawGoKrClient
|
| 11 |
+
from kpaa.law_api.endpoints import LSTRM
|
| 12 |
+
from kpaa.law_api.parsers import child_text, parse_xml
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
async def search_legal_terms(
|
| 16 |
+
client: LawGoKrClient, *, query: str, display: int = 20
|
| 17 |
+
) -> list[dict[str, str]]:
|
| 18 |
+
if not 1 <= display <= 100:
|
| 19 |
+
raise ValueError("display must be in [1, 100]")
|
| 20 |
+
body = await client.fetch(
|
| 21 |
+
LawApiCall(target=LSTRM.target, path=LIST_PATH,
|
| 22 |
+
params={"query": query, "display": str(display)},
|
| 23 |
+
cache_ttl=3600)
|
| 24 |
+
)
|
| 25 |
+
root = parse_xml(body)
|
| 26 |
+
return [
|
| 27 |
+
{
|
| 28 |
+
"term_id": child_text(el, "법령용어ID"),
|
| 29 |
+
"term": child_text(el, "법령용어명"),
|
| 30 |
+
"dict_kind_code": child_text(el, "사전구분코드"),
|
| 31 |
+
"law_kind_code": child_text(el, "법령종류코드"),
|
| 32 |
+
"detail_url": child_text(el, "법령용어상세링크"),
|
| 33 |
+
}
|
| 34 |
+
for el in root.findall(f".//{LSTRM.row_tag}")
|
| 35 |
+
]
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
async def get_legal_term_detail(client: LawGoKrClient, *, term_id: str) -> str:
|
| 39 |
+
"""법령용어 상세 — `trmSeqs` 파라미터 사용 (콤마 결합 ID 허용)."""
|
| 40 |
+
return await client.fetch(
|
| 41 |
+
LawApiCall(target=LSTRM.target, path=DETAIL_PATH,
|
| 42 |
+
params={"trmSeqs": str(term_id)}, cache_ttl=24 * 3600)
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
__all__ = ["search_legal_terms", "get_legal_term_detail"]
|
src/kpaa/law_api/treaty.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""조약 (target=trty)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from kpaa.law_api.client import DETAIL_PATH, LIST_PATH, LawApiCall, LawGoKrClient
|
| 5 |
+
from kpaa.law_api.endpoints import TRTY
|
| 6 |
+
from kpaa.law_api.parsers import child_text, parse_xml
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
async def search_treaties(
|
| 10 |
+
client: LawGoKrClient, *, query: str, display: int = 20
|
| 11 |
+
) -> list[dict[str, str]]:
|
| 12 |
+
if not 1 <= display <= 100:
|
| 13 |
+
raise ValueError("display must be in [1, 100]")
|
| 14 |
+
body = await client.fetch(
|
| 15 |
+
LawApiCall(target=TRTY.target, path=LIST_PATH,
|
| 16 |
+
params={"query": query, "display": str(display)},
|
| 17 |
+
cache_ttl=3600)
|
| 18 |
+
)
|
| 19 |
+
root = parse_xml(body)
|
| 20 |
+
return [
|
| 21 |
+
{
|
| 22 |
+
"treaty_id": child_text(el, "조약일련번호"),
|
| 23 |
+
"name": child_text(el, "조약명"),
|
| 24 |
+
"kind": child_text(el, "조약구분명"),
|
| 25 |
+
"treaty_no": child_text(el, "조약번호"),
|
| 26 |
+
"country_no": child_text(el, "국가번호"),
|
| 27 |
+
"signed_date": child_text(el, "서명일자"),
|
| 28 |
+
"effective_date": child_text(el, "발효일자"),
|
| 29 |
+
"gazette_date": child_text(el, "관보게제일자"),
|
| 30 |
+
}
|
| 31 |
+
for el in root.findall(f".//{TRTY.row_tag}")
|
| 32 |
+
]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
async def get_treaty_text(client: LawGoKrClient, *, treaty_id: str) -> str:
|
| 36 |
+
return await client.fetch(
|
| 37 |
+
LawApiCall(target=TRTY.target, path=DETAIL_PATH,
|
| 38 |
+
params={"ID": str(treaty_id)},
|
| 39 |
+
cache_ttl=24 * 3600)
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
__all__ = ["search_treaties", "get_treaty_text"]
|
src/kpaa/llm/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LLM 백엔드 추상화.
|
| 2 |
+
|
| 3 |
+
두 백엔드를 지원하며 같은 `LLMBackend` Protocol 을 만족:
|
| 4 |
+
|
| 5 |
+
- `llama_cpp_backend` — 로컬 노트북용. Gemma 4 E2B GGUF 임베드, GGUF 는 첫
|
| 6 |
+
호출 시 Hugging Face 에서 자동 다운로드되어 `platformdirs.user_cache_dir`
|
| 7 |
+
에 캐시. 별도 데몬·외부 서비스 의존 없음.
|
| 8 |
+
|
| 9 |
+
- `zerogpu_backend` — HF Spaces 데모용. 같은 가중치(`google/gemma-4-E2B-it`)
|
| 10 |
+
를 transformers 로 로드, `@spaces.GPU` 데코레이터로 함수 단위 GPU 할당.
|
| 11 |
+
|
| 12 |
+
`get_backend()` 가 환경(`SPACE_ID` 존재 → zerogpu, 그 외 → llama_cpp)을
|
| 13 |
+
자동 감지. `KPAA_LLM_BACKEND` 환경변수로 강제 override 가능.
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
from kpaa.llm.base import ChatMessage, LLMBackend, LLMOptions
|
| 18 |
+
from kpaa.llm.factory import get_backend
|
| 19 |
+
|
| 20 |
+
__all__ = ["ChatMessage", "LLMBackend", "LLMOptions", "get_backend"]
|