scvcoder commited on
Commit
94f1300
·
verified ·
1 Parent(s): a3104ff

Initial backend code: src/kpaa, runtime data, requirements

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +2 -0
  2. LICENSE +21 -0
  3. NOTICE +36 -0
  4. data/cases.sqlite +3 -0
  5. data/chain_specs.yaml +171 -0
  6. data/guides.sqlite +3 -0
  7. data/guides_meta.json +22 -0
  8. data/keyword_intents.yaml +175 -0
  9. data/related_laws.yaml +323 -0
  10. data/seed_laws.json +20 -0
  11. pyproject.toml +90 -0
  12. requirements.txt +35 -0
  13. src/kpaa/__init__.py +1 -0
  14. src/kpaa/__main__.py +4 -0
  15. src/kpaa/cases/__init__.py +46 -0
  16. src/kpaa/cases/index.py +340 -0
  17. src/kpaa/cases/models.py +43 -0
  18. src/kpaa/cases/scraper.py +268 -0
  19. src/kpaa/cli.py +416 -0
  20. src/kpaa/cli_eval.py +182 -0
  21. src/kpaa/config.py +68 -0
  22. src/kpaa/guides/__init__.py +57 -0
  23. src/kpaa/guides/builder.py +140 -0
  24. src/kpaa/guides/extractor.py +157 -0
  25. src/kpaa/guides/index.py +241 -0
  26. src/kpaa/guides/models.py +48 -0
  27. src/kpaa/law_api/__init__.py +362 -0
  28. src/kpaa/law_api/acr.py +45 -0
  29. src/kpaa/law_api/admin_rule.py +72 -0
  30. src/kpaa/law_api/aliases.py +254 -0
  31. src/kpaa/law_api/article_history.py +141 -0
  32. src/kpaa/law_api/client.py +154 -0
  33. src/kpaa/law_api/constitutional.py +135 -0
  34. src/kpaa/law_api/endpoints.py +228 -0
  35. src/kpaa/law_api/english.py +40 -0
  36. src/kpaa/law_api/ftc.py +45 -0
  37. src/kpaa/law_api/interpretation.py +96 -0
  38. src/kpaa/law_api/jo.py +134 -0
  39. src/kpaa/law_api/law.py +255 -0
  40. src/kpaa/law_api/models.py +228 -0
  41. src/kpaa/law_api/nlrc.py +41 -0
  42. src/kpaa/law_api/oldnew.py +48 -0
  43. src/kpaa/law_api/ordinance.py +55 -0
  44. src/kpaa/law_api/parsers.py +30 -0
  45. src/kpaa/law_api/pipc.py +94 -0
  46. src/kpaa/law_api/precedent.py +62 -0
  47. src/kpaa/law_api/raw.py +83 -0
  48. src/kpaa/law_api/terms.py +46 -0
  49. src/kpaa/law_api/treaty.py +43 -0
  50. 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/행정규칙/(개인정보보호위원회)개인정보의기술적&middot;관리적보호조치/(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 엔티티(`&#40;`, `&middot;` 등)가 들어 있어
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 엔티티(`&#40;`, `&middot;`) 디코드 + 다중 공백 정리."""
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"]