diff --git a/.gitattributes b/.gitattributes index a6344aac8c09253b3b630fb776ae94478aa0275b..92eb778051c108814819ad44aeeaf14f121ec063 100644 --- a/.gitattributes +++ b/.gitattributes @@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text *.zip filter=lfs diff=lfs merge=lfs -text *.zst filter=lfs diff=lfs merge=lfs -text *tfevents* filter=lfs diff=lfs merge=lfs -text +data/cases.sqlite filter=lfs diff=lfs merge=lfs -text +data/guides.sqlite filter=lfs diff=lfs merge=lfs -text diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..2ed9e0dc5857052ad246e5b848f3cd59bfee5b55 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 KPAA contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000000000000000000000000000000000000..bbff8d1c8265f8c98cc6f01c60d4b0c0de48341a --- /dev/null +++ b/NOTICE @@ -0,0 +1,36 @@ +korean-privacy-ai-assistant (KPAA) +============================ + +This project draws inspiration from korean-law-mcp +(https://github.com/chrisryugj/korean-law-mcp) for the surface area of +법제처 OPEN API endpoints to wrap. No source code is vendored; only the +factual mapping of which government endpoints exist for which legal +domains was used as a starting point. + + +Data sources +------------ + +* 법제처 OPEN API (https://open.law.go.kr) + Requires an individual API key (LAW_OC) issued free at the URL above. + Government data, generally usable under 공공누리(KOGL). + +* 개인정보보호위원회 개인정보 상담사례 (https://www.privacy.go.kr) + Scraped via the public AJAX endpoint + /front/case/onMadangListAjax.do + Government data presumed under 공공누리 제1유형 (출처표시); the snapshot + shipped with this repo is a verbatim copy of the public board content, + trimmed only to fit chatbot context windows. Each citation in the + chatbot's answers includes the case number and the privacy.go.kr URL. + + +Model +----- + +Gemma 4 is licensed under the Gemma Terms of Use + https://ai.google.dev/gemma/terms +End users are responsible for compliance with the Gemma terms when running +this project. The GGUF distribution used by default is + bartowski/google_gemma-4-E2B-it-GGUF +on Hugging Face; downloading the GGUF on first run is subject to the same +Gemma terms. diff --git a/data/cases.sqlite b/data/cases.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..73bc000860be0ca26c28428c3fdf706e38825774 --- /dev/null +++ b/data/cases.sqlite @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a84162716f015395c4aa07d962d4e5441095840e2a7c19e07eff77a2822123aa +size 14213120 diff --git a/data/chain_specs.yaml b/data/chain_specs.yaml new file mode 100644 index 0000000000000000000000000000000000000000..84a96e9070afe611abbfed3c887c5f305f99c4a1 --- /dev/null +++ b/data/chain_specs.yaml @@ -0,0 +1,171 @@ +# KPAA chain orchestrator 메타데이터 (v2.2 — 16 chain) +# +# 원작 korean-law-mcp v3.5.1 의 16개 도구 직접 노출 패턴을 *결정적 fan-out* +# 으로 미러링. 우리 시스템은 LLM 도구 호출 0회 — chain 안의 소스 조합은 +# 이 yaml + chains.py 가 결정한다. +# +# 라우팅 흐름: +# 사용자 질문 → LLM 분류기(llm_router.py) → chain key 1개 선택 → chains.py 가 +# 해당 spec 의 sources 를 병렬 fan-out → fetcher 들이 Excerpt 리스트 반환. +# +# chain 추가/조정 시 이 파일만 손대면 충분 (chains.py 는 spec 기반 dispatch). +# 단, llm_router.CHAIN_DESCRIPTIONS 와 _format_stage 의 한국어 라벨 매핑은 +# 별도로 갱신해야 LLM 분류 정확도 + UI 표시가 따라온다. +# +# 항목 형식: +# key: chain 식별자 (snake_case) +# ko: 한국어 라벨 (UI 표시용) +# description: LLM 분류기 prompt 의 *언제 이 chain 을 쓰는지* 설명 (자연어) +# jo_hints: 본법(개인정보보호법) 조문 번호 기본값. LLM 명시 안 하면 사용 +# sources: 호출할 fetcher 목록. _fetch_() 와 매핑. +# - case : 상담사례 (로컬 SQLite) +# - guide : PIPC 발간 안내서 청크 (로컬 SQLite, 인터랙티브 큐레이션) +# - law : 본법 조문 (jo_targets 만큼) +# - related_law : 관련 법령 본문 (plan.mst_targets 라이브) +# - related_law_static : 정적 관련 법령 리스트 (related_laws.yaml) +# - pipc : PIPC 결정문 +# - interpretation : 법령해석례 +# - precedent : 판례 +# - admin_rule : 행정규칙(고시) + +chains: + + - key: definition + ko: 정의·개요 + description: | + 법령·용어의 정의·개요·목적·원칙을 묻는 질문. + 예: "개인정보보호법이 뭐야", "정보주체란?", "가명정보 정의", "개인정보처리자 누구를 말해?" + jo_hints: ["1", "2", "3"] + # constitutional 추가: 자기결정권·기본권 헌재 판단이 정의 질의에 깊이 보탬. + sources: [law, related_law, constitutional] + + - key: related_laws + ko: 관련 법령 안내 + description: | + 개인정보보호법과 함께 적용되는 *다른 법률* 안내 요청. + 예: "관련된 법은?", "다른 법 알려줘", "함께 봐야 할 법령". + 특정 법령 본문 질의(예: '신용정보법 제32조')는 여기보다 general_practice/precedent_search 가 적합. + jo_hints: [] + sources: [related_law, related_law_static] + + - key: general_practice + ko: 일반 실무 (catch-all) + description: | + 위 카테고리 어디에도 명확히 들어가지 않는 *일반 실무 질문* 의 catch-all. + 모호하면 이걸 선택. 동의·CCTV·처리방침·유출·위탁·제3자제공·채용·아동·파기 등 어디에 둬야 할지 + 애매한 복합 질문에도 적합. + # catch-all 안전망: 모호 질문에도 본법 핵심 4개 조문이 항상 컨텍스트에 들어감. + # 제3조(원칙), 제15조(수집·이용), 제17조(제공), 제29조(안전조치). + jo_hints: ["3", "15", "17", "29"] + sources: [case, law, related_law, pipc, interpretation, precedent] + + - key: precedent_search + ko: 판례·처벌 사례 + description: | + 판례·처벌 사례·위반 사례·손해배상·형사 판결 검색. + 예: "○○ 위반 판례", "처벌 사례 알려줘", "어떤 형사 판결이 있어?" + jo_hints: [] + sources: [law, related_law, pipc, precedent] + + - key: safety_compliance + ko: 안전조치·고시 + description: | + 개인정보의 안전성 확보조치·기술적·관리적·물리적 보호조치·암호화·접근통제· + 개인정보보호위원회 고시 관련. + 예: "안전조치 기준", "암호화 어떻게", "접근권한 관리". + jo_hints: ["29"] + # three_tier 추가: 본법 제29조와 함께 *시행령 제30조 (안전성 확보조치 기준)* 자동 포함. + sources: [law, three_tier, admin_rule, pipc, guide] + + - key: consent_collection + ko: 동의 수집·철회 + description: | + 개인정보 수집 동의·동의 철회·동의 방식(서면/전자) 관련 실무 질문. + 예: "동의서 어떻게 받아야", "마케팅 동의 분리해야 하나", "동의 철회 절차". + jo_hints: ["15", "17", "22"] + sources: [law, case, pipc, interpretation, guide] + + - key: cctv_video + ko: CCTV·영상정보처리기기 + description: | + CCTV·영상정보처리기기 설치·안내문·녹음·운영 관련. 매장·아파트·병원·학원 등 + 현장 설치 사례. + 예: "CCTV 어디 설치해야", "안내문 어떻게 써", "영상정보 보관기간", "녹음 같이 해도 되나". + jo_hints: ["25"] + sources: [law, admin_rule, pipc, case, guide] + + - key: breach_notification + ko: 유출 신고·통지 + description: | + 개인정보 유출·분실·도난·침해 시 신고 절차·정보주체 통지·보호위원회 신고 기한. + 예: "유출되면 며칠 안에 신고", "침해 통지 의무", "유출 신고 어디에". + jo_hints: ["34"] + sources: [law, admin_rule, pipc, interpretation, guide] + + - key: processing_consignment + ko: 처리 위탁 + description: | + 개인정보 처리 위탁·외주·클라우드·수탁자 관리 관련. + 예: "클라우드에 자료 맡겨도 되나", "위탁업체 관리 어떻게", "재위탁 가능?" + jo_hints: ["26"] + sources: [law, case, pipc, interpretation, guide] + + - key: third_party_provision + ko: 제3자 제공·공유 + description: | + 개인정보 제3자 제공·공유·양도·매각·계열사 공유 관련. + 예: "제3자 제공 동의", "계열사에 공유해도", "양수도 시 개인정보". + jo_hints: ["17", "18"] + sources: [law, case, pipc, precedent, guide] + + - key: data_subject_rights + ko: 정보주체 권리 (열람·정정·삭제) + description: | + 정보주체의 권리 행사 — 열람·정정·삭제·처리정지·이의제기. + 예: "내 정보 보여줘 요청 받았어", "삭제 요청 거부 가능", "처리정지 절차". + jo_hints: ["35", "36", "37", "39"] + # constitutional 추가: 자기결정권 헌재 판단이 권리 범위 해석의 핵심 근거. + sources: [law, case, interpretation, pipc, guide, constitutional] + + - key: retention_destruction + ko: 보유·파기 + description: | + 개인정보 보유기간·보존·파기·폐기 관련. + 예: "1년 지나면 파기해야", "보관기간 어떻게 정해", "파기 방법". + jo_hints: ["21", "39의6"] + sources: [law, case, interpretation, guide] + + - key: medical_health + ko: 의료·건강·민감정보 + description: | + 의료기관·병원·진료기록·건강정보·민감정보 처리 관련 (의료법 연계). + 예: "환자 진료기록 마케팅 활용", "병원 CCTV", "의료법상 비밀유지", "건강정보 동의". + ※ 병원 CCTV 설치 위치 같은 *시설 설치* 질문은 cctv_video 가 더 적합. + jo_hints: ["23"] + # constitutional 추가: 의료정보·민감정보 자기결정권 헌재 판단 (예: 92헌마68 등). + sources: [law, related_law, case, pipc, constitutional] + + - key: employment_recruitment + ko: 채용·근로 + description: | + 직원 채용·이력서·면접·인사·근로 과정의 개인정보 처리 (근로기준법 연계). + 예: "이력서에 주민번호 받아도", "직원 동의 어떻게", "퇴사자 정보 보관". + jo_hints: ["15", "24의2"] + sources: [law, related_law, case, pipc, guide] + + - key: policy_disclosure + ko: 처리방침·공개 + description: | + 개인정보 처리방침 작성·게시·공개 관련. + 예: "처리방침 만들어야", "1인 사업자도 게시 의무", "처리방침 어떻게 써". + jo_hints: ["30"] + # three_tier 추가: 본법 제30조와 함께 *시행령 제31조 (처리방침 공개)* 자동 포함. + sources: [law, three_tier, admin_rule, case, pipc, guide] + + - key: minor_protection + ko: 아동·미성년 보호 + description: | + 만 14세 미만 아동·미성년자·청소년 개인정보 처리·법정대리인 동의 관련. + 예: "14세 미만 동의", "어린이 정보 수집", "법정대리인 동의 받는 법". + jo_hints: ["22의2"] + sources: [law, case, pipc, interpretation, guide] diff --git a/data/guides.sqlite b/data/guides.sqlite new file mode 100644 index 0000000000000000000000000000000000000000..889f55cf474ce25dd9f978fba56f146d1cb2afea --- /dev/null +++ b/data/guides.sqlite @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3b9bea8e93e7b1024a7d807f9ed66511744d02c779dfbf25e4bb1d920662600a +size 1601536 diff --git a/data/guides_meta.json b/data/guides_meta.json new file mode 100644 index 0000000000000000000000000000000000000000..c8d961bb2172b7677745859a48f62526450d1479 --- /dev/null +++ b/data/guides_meta.json @@ -0,0 +1,22 @@ +{ + "고정형_영상정보처리기기_설치_운영_안내서": { + "doc_title": "고정형 영상정보처리기기 설치·운영 안내서(공공 및 민간분야 통합본)", + "doc_date": "2024.12", + "source_pdf": "★고정형 영상정보처리기기 설치 운영 안내서(2024.12).pdf", + "chunks_count": 71 + }, + "소상공인을_위한_개인정보_보호_핸드북": { + "doc_title": "소상공인을 위한 개인정보 보호 핸드북", + "doc_date": "2024.12", + "source_pdf": "★소상공인을 위한 개인정보 보호 핸드북(2024.12).pdf", + "chunks_count": 41 + }, + "질의응답_모음집": { + "doc_title": "개인정보 질의응답 모음집", + "doc_date": "2025.12", + "source_pdf": "1. 개인정보 질의응답 모음집(2025.12.).pdf", + "chunks_count": 99 + }, + "_built_at": "2026-05-01 09:24:34", + "_total_chunks": 211 +} \ No newline at end of file diff --git a/data/keyword_intents.yaml b/data/keyword_intents.yaml new file mode 100644 index 0000000000000000000000000000000000000000..f2e9b1a6b8d5d8fa40b379a63c55b212789ac4a0 --- /dev/null +++ b/data/keyword_intents.yaml @@ -0,0 +1,175 @@ +# 의도 메타데이터 — KPAA v2 라우팅 데이터. +# +# v2부터 라우팅 1차는 **LLM 의도 분류기**(Gemma 4 E2B JSON-mode 1샷)가 담당. +# 이 yaml은 (1) LLM 분류 결과의 intent name → 조문 힌트(jo_hints)/소스 보강용 메타데이터, +# (2) LLM 호출 실패·timeout 시 rule_route fallback 의 키워드 사전 (keywords 필드). +# +# 즉 keywords 큐레이션 부담은 사라졌고 (LLM이 자연어 의도 파악) keywords는 *fallback 안전망*만 됨. +# intent 이름과 jo_hints/sources는 LLM 분류기 결과와 직접 연결되므로 정확하게 유지. +# +# 항목 형식: +# - intent: 의도 식별자 (snake_case) — llm_router.INTENT_LIST 와 1:1 매칭되어야 함 +# keywords: rule_route fallback 매칭 키워드 (소문자/대소문자 무관, 부분일치) +# jo_hints: 개인정보 보호법 조문 번호 (없으면 빈 리스트) +# sources: 보강용 데이터 카테고리 (chain orchestrator 가 기본값을 결정하고, 여기 sources는 union으로 병합) +# - case : 상담사례 1,745건 (로컬 SQLite) +# - law : 본법(개인정보보호법) 조문 + jo_targets 명시 시 라이브 +# - related_law : data/related_laws.yaml 의 매칭된 법령 본문 (라이브) +# - pipc : 개인정보보호위원회 결정문 +# - interpretation: 법령해석례 +# - precedent : 판례 (대법원·하급심) +# - admin_rule : 행정규칙(고시) + +- intent: 동의_수집 + keywords: [동의, 수집, 받아도, 받아도되, 마케팅, 광고, 홍보] + jo_hints: ["15", "22"] + sources: [case, law, pipc, precedent] + +- intent: 민감정보 + keywords: [민감정보, 진료기록, 건강, 질병, 병력, 환자, 사진, 안면, 지문, 홍채, 생체] + jo_hints: ["23"] + # 의료기관 사례 → 의료법까지 가져오게 related_law 활성. 환자/병원 키워드 매칭 시. + sources: [case, law, related_law, pipc, precedent] + +- intent: 고유식별정보 + keywords: [주민등록번호, 주민번호, 외국인등록번호, 운전면허, 여권번호, 고유식별, 식별번호] + jo_hints: ["24", "24의2"] + # 주민등록법까지 → related_law 활성 + sources: [case, law, related_law, pipc, precedent] + +- intent: 영상정보처리 + keywords: [CCTV, cctv, 영상정보, 영상정보처리기기, 안내문, 안내판, 매장, 카메라, 녹음] + jo_hints: ["25"] + # CCTV 녹음은 통신비밀보호법까지 → related_law 활성 + sources: [case, law, related_law, pipc, precedent] + +- intent: 유출신고 + keywords: [유출, 분실, 도난, 신고, 통지, 침해, 누출] + jo_hints: ["34"] + # 안전성 확보조치 기준 고시까지 → admin_rule 활성 + sources: [case, law, pipc, admin_rule] + +- intent: 처리위탁 + keywords: [위탁, 외주, 클라우드, 처리위탁, 수탁] + jo_hints: ["26"] + sources: [case, law, pipc, admin_rule] + +- intent: 제3자제공 + keywords: [제3자, 제공, 공유, 양도, 매각] + jo_hints: ["17", "18"] + sources: [case, law, pipc, precedent] + +- intent: 채용 + keywords: [채용, 이력서, 직원, 입사지원, 면접, 인사, 근로] + jo_hints: ["15", "23", "24의2"] + sources: [case, law, pipc] + +- intent: 처리방침 + keywords: [처리방침, 개인정보처리방침, 방침, 게시, 공개] + jo_hints: ["30"] + sources: [case, law, admin_rule] + +- intent: 파기 + keywords: [파기, 보관기간, 보유기간, 보존, 폐기, 보관] + jo_hints: ["21"] + sources: [case, law, interpretation] + +- intent: 아동 + keywords: [아동, 미성년, 14세, 만14세, 어린이, 청소년, 학생] + jo_hints: ["22의2"] + sources: [case, law, pipc] + +- intent: 정보주체_권리 + keywords: [열람, 정정, 삭제, 처리정지, 권리, 본인요청] + jo_hints: ["35", "36", "37"] + sources: [case, law, pipc, interpretation] + +- intent: 안전조치 + keywords: [안전조치, 암호화, 접근권한, 접근통제, 안전성, 보안] + jo_hints: ["29"] + # 안전성 확보조치 기준 고시가 핵심 → admin_rule 우선 + sources: [law, admin_rule, pipc] + +- intent: 보호책임자 + keywords: [개인정보보호책임자, CPO, 책임자, 보호책임자] + jo_hints: ["31"] + sources: [law, admin_rule] + +# 판례·처벌 사례 요청 — "○○법 위반 판례", "처벌 사례", "어떻게 판결됐어" +# precedent 카테고리를 명시적으로 활성화하기 위한 intent. +- intent: 판례_요청 + keywords: + - 판례 + - 판결 + - 판시 + - 사건번호 + - 위반 + - 위반사례 + - 처벌 + - 처분 + - 손해배상 + - 형사 + jo_hints: [] + sources: [law, precedent, pipc] + +# 메타/정의/일반 질문 — "○○법이 뭐야", "정의", "원칙" +- intent: 법령_일반 + keywords: + - 개인정보보호법 + - 개인정보 보호법 + - 보호법 + - 개인정보가 + - 개인정보란 + - 정보주체 + - 개인정보처리자 + - 가명정보 + - 민감정보가 + - 정의 + - 원칙 + - 개념 + - 의미 + - 뭐야 + - 뭐예요 + - 무엇 + - 알려줘 + - 설명 + - 무엇인가요 + jo_hints: ["1", "2", "3"] + sources: [law] # 정의 질문엔 본법 조문만 — 노이즈 방지 + +# 관련 법령 — 사용자가 "다른 법", "함께 적용" 등을 묻거나 +# 정통망법·신용정보법·의료법 같은 특정 관련 법령을 언급하면 매칭. +# (실제 법령명별 keywords는 data/related_laws.yaml 의 각 item에 있음 — 라우터가 거기서 자동 매칭) +- intent: 관련_법령 + keywords: + - 관련 법 + - 관련된 법 + - 다른 법 + - 함께 적용 + - 참조 법 + - 어떤 법 + - 적용되는 법 + jo_hints: [] + sources: [related_law] + +# 개정 이력 — "개정 내용", "바뀐 점", "신·구법 비교" 같이 *언제·어떻게 바뀌었는지* +# 묻는 질문. 매칭되면 신구법비교(oldnew) 소스가 union 으로 활성화 — 어느 chain +# 에서든 본법 신구법 본문이 추가로 붙는다. +- intent: 개정_이력 + keywords: + - 개정 + - 바뀐 + - 바뀌었 + - 변경된 + - 신구법 + - 신·구 + - 신구 + - 개정안 + - 개정 이력 + - 개정사항 + - 어떻게 바 + jo_hints: [] + # oldnew=법령 단위 신구비교, article_history=조문 단위 시점별 변경 이력. + # 둘은 *세분화 수준이 다른 보완 관계* — 함께 활성화. article_history 는 + # plan.jo_targets 가 명시될 때만 fetch (구현 측에서 gating). + sources: [oldnew, law, article_history] diff --git a/data/related_laws.yaml b/data/related_laws.yaml new file mode 100644 index 0000000000000000000000000000000000000000..327f283783df2b84c827eb8e85e57773db8ad2d7 --- /dev/null +++ b/data/related_laws.yaml @@ -0,0 +1,323 @@ +_meta: + _generated_at: '2026-04-29T12:05:21' + _sources: + - https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=116 + - https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117 + _count: 34 + _count_law: 12 + _count_admin_rule: 22 +items: +- name: 개인정보 보호법 + short: 개인정보보호법 + keywords: + - 개인정보 보호법 + - 개인정보보호법 + kind: law + kind_label: 개인정보 관련 법률·시행령 + 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 + source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=116 + mst: '270351' +- name: 개인정보 보호법 시행령 + short: 개인정보보호법 시행령 + keywords: + - 개인정보 보호법 시행령 + - 개인정보보호법 시행령 + - 보호법 시행령 + kind: law + kind_label: 개인정보 관련 법률·시행령 + 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 + source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=116 + mst: '273745' +- name: 신용정보의 이용 및 보호에 관한 법률 + short: 신용정보법 + keywords: + - 신용정보의 이용 및 보호에 관한 법률 + - 신용정보법 + kind: law + kind_label: 개인정보 관련 법률·시행령 + 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 + source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=116 + mst: '260423' +- name: 국가인권위원회법 + short: 인권위법 + keywords: + - 국가인권위원회법 + - 인권위법 + kind: law + kind_label: 개인정보 관련 법률·시행령 + 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 + source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=116 + mst: '266711' +- name: 공공기관의 운영에 관한 법률 + short: 공공기관운영법 + keywords: + - 공공기관의 운영에 관한 법률 + - 공공기관운영법 + kind: law + kind_label: 개인정보 관련 법률·시행령 + 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 + source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=116 + mst: '276057' +- name: 지방공기업법 + short: 지방공기업법 + keywords: + - 지방공기업법 + kind: law + kind_label: 개인정보 관련 법률·시행령 + 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 + source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=116 + mst: '276345' +- name: 초·중등교육법 + short: 초중등교육법 + keywords: + - 초·중등교육법 + - 초중등교육법 + kind: law + kind_label: 개인정보 관련 법률·시행령 + 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 + source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=116 + mst: '' +- name: 고등교육법 + short: 고등교육법 + keywords: + - 고등교육법 + kind: law + kind_label: 개인정보 관련 법률·시행령 + 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 + source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=116 + mst: '268513' +- name: 주민등록법 + short: 주민등록법 + keywords: + - 주민등록법 + kind: law + kind_label: 개인정보 관련 법률·시행령 + 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 + source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=116 + mst: '268555' +- name: 전자정부법 + short: 전자정부법 + keywords: + - 전자정부법 + kind: law + kind_label: 개인정보 관련 법률·시행령 + 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 + source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=116 + mst: '268103' +- name: 전자서명법 + short: 전자서명법 + keywords: + - 전자서명법 + kind: law + kind_label: 개인정보 관련 법률·시행령 + 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 + source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=116 + mst: '236201' +- name: 공공기관의 정보공개에 관한 법률 + short: 정보공개법 + keywords: + - 공공기관의 정보공개에 관한 법률 + - 정보공개법 + kind: law + kind_label: 개인정보 관련 법률·시행령 + 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 + source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=116 + mst: '251019' +- name: 개인정보처리방법에관한고시 + short: '' + keywords: + - 개인정보처리방법에관한고시 + kind: admin_rule + kind_label: 개인정보 관련 행정규칙(고시·지침) + url: https://www.law.go.kr/행정규칙/개인정보처리방법에관한고시 + source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117 + mst: '' +- name: 개인정보의안전성확보조치기준 + short: '' + keywords: + - 개인정보의안전성확보조치기준 + kind: admin_rule + kind_label: 개인정보 관련 행정규칙(고시·지침) + url: https://www.law.go.kr/행정규칙/개인정보의안전성확보조치기준 + source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117 + mst: '' +- name: 개인정보의기술적·관리적보호조치 + short: '' + keywords: + - 개인정보의기술적·관리적보호조치 + kind: admin_rule + kind_label: 개인정보 관련 행정규칙(고시·지침) + url: https://www.law.go.kr/행정규칙/(개인정보보호위원회)개인정보의기술적·관리적보호조치/(2023-7,20230922) + source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117 + mst: '' +- name: 표준개인정보보호지침 + short: '' + keywords: + - 표준개인정보보호지침 + kind: admin_rule + kind_label: 개인정보 관련 행정규칙(고시·지침) + url: https://www.law.go.kr/행정규칙/(개인정보보호위원회)표준개인정보보호지침 + source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117 + mst: '' +- name: 개인정보영향평가에관한고시 + short: '' + keywords: + - 개인정보영향평가에관한고시 + kind: admin_rule + kind_label: 개인정보 관련 행정규칙(고시·지침) + url: https://www.law.go.kr/행정규칙/(개인정보보호위원회)개인정보영향평가에관한고시 + source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117 + mst: '' +- name: 개인정보보호자율규제단체지정등에관한규정 + short: '' + keywords: + - 개인정보보호자율규제단체지정등에관한규정 + kind: admin_rule + kind_label: 개인정보 관련 행정규칙(고시·지침) + url: https://www.law.go.kr/행정규칙/(개인정보보호위원회)개인정보보호자율규제단체지정등에관한규정 + source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117 + mst: '' +- name: 정보보호및개인정보보호관리체계인증등에관한고시 + short: '' + keywords: + - 정보보호및개인정보보호관리체계인증등에관한고시 + kind: admin_rule + kind_label: 개인정보 관련 행정규칙(고시·지침) + url: https://www.law.go.kr/행정규칙/정보보호및개인정보보호관리체계인증등에관한고시 + source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117 + mst: '' +- name: 가명정보의결합및반출등에관한고시 + short: '' + keywords: + - 가명정보의결합및반출등에관한고시 + kind: admin_rule + kind_label: 개인정보 관련 행정규칙(고시·지침) + url: https://www.law.go.kr/행정규칙/가명정보의결합및반출등에관한고시 + source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117 + mst: '' +- name: 경찰청개인정보보호규칙 + short: '' + keywords: + - 경찰청개인정보보호규칙 + kind: admin_rule + kind_label: 개인정보 관련 행정규칙(고시·지침) + url: https://www.law.go.kr/행정규칙/경찰청개인정보보호규칙 + source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117 + mst: '' +- name: 경찰청영상정보처리기기운영규칙 + short: '' + keywords: + - 경찰청영상정보처리기기운영규칙 + kind: admin_rule + kind_label: 개인정보 관련 행정규칙(고시·지침) + url: https://www.law.go.kr/행정규칙/경찰청영상정보처리기기운영규칙 + source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117 + mst: '' +- name: 주민등록증발급신청서등의관리에관한규칙 + short: '' + keywords: + - 주민등록증발급신청서등의관리에관한규칙 + kind: admin_rule + kind_label: 개인정보 관련 행정규칙(고시·지침) + url: https://www.law.go.kr/행정규칙/주민등록증발급신청서등의관리에관한규칙 + source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117 + mst: '' +- name: 국토교통부개인정보보호세부지침 + short: '' + keywords: + - 국토교통부개인정보보호세부지침 + kind: admin_rule + kind_label: 개인정보 관련 행정규칙(고시·지침) + url: https://www.law.go.kr/행정규칙/국토교통부개인정보보호세부지침 + source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117 + mst: '' +- name: 농림축산식품부개인정보보호지침 + short: '' + keywords: + - 농림축산식품부개인정보보호지침 + kind: admin_rule + kind_label: 개인정보 관련 행정규칙(고시·지침) + url: https://www.law.go.kr/행정규칙/농림축산식품부개인정보보호지침 + source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117 + mst: '' +- name: 문화체육관광부개인정보보호지침 + short: '' + keywords: + - 문화체육관광부개인정보보호지침 + kind: admin_rule + kind_label: 개인정보 관련 행정규칙(고시·지침) + url: https://www.law.go.kr/행정규칙/문화체육관광부개인정보보호지침 + source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117 + mst: '' +- name: 법무부개인정보보호지침 + short: '' + keywords: + - 법무부개인정보보호지침 + kind: admin_rule + kind_label: 개인정보 관련 행정규칙(고시·지침) + url: https://www.law.go.kr/행정규칙/법무부개인정보보호지침 + source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117 + mst: '' +- name: 병무청개인정보보호관리규정 + short: '' + keywords: + - 병무청개인정보보호관리규정 + kind: admin_rule + kind_label: 개인정보 관련 행정규칙(고시·지침) + url: https://www.law.go.kr/행정규칙/병무청개인정보보호관리규정 + source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117 + mst: '' +- name: 병무행정정보업무관리규정 + short: '' + keywords: + - 병무행정정보업무관리규정 + kind: admin_rule + kind_label: 개인정보 관련 행정규칙(고시·지침) + url: https://www.law.go.kr/행정규칙/병무행정정보업무관리규정 + source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117 + mst: '' +- name: 산림청개인정보보호지침 + short: '' + keywords: + - 산림청개인정보보호지침 + kind: admin_rule + kind_label: 개인정보 관련 행정규칙(고시·지침) + url: https://www.law.go.kr/행정규칙/산림청개인정보보호지침 + source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117 + mst: '' +- name: 중소벤처기업부개인정보보호지침 + short: '' + keywords: + - 중소벤처기업부개인정보보호지침 + kind: admin_rule + kind_label: 개인정보 관련 행정규칙(고시·지침) + url: https://www.law.go.kr/행정규칙/중소벤처기업부개인정보보호지침 + source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117 + mst: '' +- name: 통계청개인정보보호지침 + short: '' + keywords: + - 통계청개인정보보호지침 + kind: admin_rule + kind_label: 개인정보 관련 행정규칙(고시·지침) + url: https://www.law.go.kr/행정규칙/통계청개인정보보호지침 + source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117 + mst: '' +- name: 행정안전부개인정보보호지침 + short: '' + keywords: + - 행정안전부개인정보보호지침 + kind: admin_rule + kind_label: 개인정보 관련 행정규칙(고시·지침) + url: https://www.law.go.kr/행정규칙/행정안전부개인정보보호지침 + source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117 + mst: '' +- name: 환경부개인정보보호지침 + short: '' + keywords: + - 환경부개인정보보호지침 + kind: admin_rule + kind_label: 개인정보 관련 행정규칙(고시·지침) + url: https://www.law.go.kr/행정규칙/환경부개인정보보호지침 + source: https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117 + mst: '' diff --git a/data/seed_laws.json b/data/seed_laws.json new file mode 100644 index 0000000000000000000000000000000000000000..ded08e2949d3b6db0e2a7772b12343ed5588c266 --- /dev/null +++ b/data/seed_laws.json @@ -0,0 +1,20 @@ +{ + "_doc": "라이브 검증 2026-04-29 — 법제처 OPEN API search_law 결과 캡쳐", + "personal_info_protection_act": { + "name": "개인정보 보호법", + "name_short": "개인정보보호법", + "mst": "270351", + "law_id": "011357", + "type_name": "법률", + "department": "개인정보보호위원회", + "promulgate_date": "20250401", + "enforce_date": "20251002" + }, + "personal_info_protection_act_enforcement_decree": { + "name": "개인정보 보호법 시행령", + "mst": "273745", + "type_name": "대통령령", + "department": "개인정보보호위원회", + "enforce_date": "20251002" + } +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..7204f6558a61087421c67299adb37e4f50926cb4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,90 @@ +[build-system] +requires = ["hatchling>=1.21"] +build-backend = "hatchling.build" + +[project] +name = "korean-privacy-ai-assistant" +version = "0.1.0" +description = "Korean Privacy Law (개인정보보호법) consultation chatbot for individuals, SMBs, and small clinics" +readme = "README.md" +license = "MIT" +requires-python = ">=3.11" +keywords = ["korean-law", "privacy", "개인정보보호법", "rag", "chatbot", "gemma"] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: MIT License", + "Natural Language :: Korean", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] +dependencies = [ + "httpx[http2]>=0.27", + "pydantic>=2.6", + "pydantic-settings>=2.2", + "lxml>=5", + "diskcache>=5.6", + "tenacity>=8", + "platformdirs>=4", + "fastapi>=0.110", + "uvicorn[standard]>=0.27", + "python-dotenv>=1", + "pyyaml>=6", + "huggingface-hub>=0.23", + "tqdm>=4.66", + "sse-starlette>=2.1", + "truststore>=0.10", +] + +[project.optional-dependencies] +llm = ["llama-cpp-python>=0.3"] +# HF Spaces (ZeroGPU) 데모 배포용 — 같은 가중치 `google/gemma-4-E2B-it` 를 +# transformers + @spaces.GPU 로 돌린다. 로컬 사용자는 설치 불필요. +hf = [ + "gradio>=5.0", + "transformers>=4.45", + "torch>=2.4", + "accelerate>=0.34", + "spaces>=0.30", +] +# 빌드 타임 docling 의존성 — 두 용도가 같은 패키지 셋을 공유: +# 1) PIPC 결정문 별지(이미지) → markdown OCR. 옵트인: +# pip install -e ".[pipc-ocr]" +# export KPAA_PIPC_OCR=1 +# EasyOCR 한국어 모델로 한글 별지 정확도 ↑ (docling 기본 ocrmac 대비). +# 2) `kpaa extract-guide` (안내서 PDF → markdown). born-digital 이라 OCR 없이 +# 텍스트 직접 추출 → easyocr 가중치 다운로드는 발생하지 않음. 이후 청킹은 +# 사용자 + Claude 인터랙티브 큐레이션 → `data/guide/chunks/*.jsonl`. +# 런타임(검색·답변)은 sqlite3 만 필요 — 일반 사용자는 이 extra 설치 불필요. +pipc-ocr = ["docling>=2.0", "easyocr>=1.7"] +dev = [ + "pytest>=8", + "pytest-asyncio>=0.23", + "vcrpy>=6", + "ruff>=0.4", + "invoke>=2.2", +] + +[project.scripts] +kpaa = "kpaa.cli:main" + +[project.urls] +Homepage = "https://github.com/sz1-kca/korean-privacy-ai-assistant" +Issues = "https://github.com/sz1-kca/korean-privacy-ai-assistant/issues" + +[tool.hatch.build.targets.wheel] +packages = ["src/kpaa"] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "B", "UP"] +ignore = ["E501"] +# SIM 룰은 가독성 호불호가 갈려서 select에서 제외 (한국어 분기 코드 가독성 우선) + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..6abcd0eb99a62891cc3be9878502953734226a5b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,35 @@ +# Hugging Face Spaces (Gradio SDK + ZeroGPU) 빌드용 평탄화 의존성. +# 로컬 노트북 사용자는 이 파일을 쓰지 말고 `pip install -e ".[llm]"` (또는 pyproject.toml) 사용. +# 이 파일은 HF Spaces 가 자동으로 `pip install -r requirements.txt` 로 처리. +# +# llama-cpp-python 은 *의도적으로* 빠짐 — HF Spaces 에서는 ZeroGPU 백엔드 +# (transformers + @spaces.GPU) 가 활성화되므로 필요 없음. + +# ── core (pyproject.toml dependencies 와 동기화) ── +httpx[http2]>=0.27 +pydantic>=2.6 +pydantic-settings>=2.2 +lxml>=5 +diskcache>=5.6 +tenacity>=8 +platformdirs>=4 +fastapi>=0.110 +uvicorn[standard]>=0.27 +python-dotenv>=1 +pyyaml>=6 +huggingface-hub>=1.3 # transformers 5.x 가 요구. Gradio 5.20+ 은 HfFolder 의존 제거. +tqdm>=4.66 +sse-starlette>=2.1 +truststore>=0.10 + +# ── HF Spaces (ZeroGPU + Gradio) ── +gradio>=5.0 +transformers>=5.0 # Gemma 4 토크나이저는 5.x 부터 (extra_special_tokens 리스트 포맷) +torch>=2.4 +accelerate>=0.34 +spaces>=0.30 + +# ── 패키지 자체 ── +# HF Spaces 는 requirements.txt 처리 시점에 app 파일이 아직 /home/user/app 에 +# mount 되어 있지 않아 `-e .` 가 동작하지 않는다. 대신 app.py 에서 +# `src/` 를 sys.path 에 prepend 한다. diff --git a/src/kpaa/__init__.py b/src/kpaa/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..3dc1f76bc69e3f559bee6253b24fc93acee9e1f9 --- /dev/null +++ b/src/kpaa/__init__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/src/kpaa/__main__.py b/src/kpaa/__main__.py new file mode 100644 index 0000000000000000000000000000000000000000..21bb7c35923f5d555dcf094537c50472d26619f4 --- /dev/null +++ b/src/kpaa/__main__.py @@ -0,0 +1,4 @@ +from kpaa.cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/kpaa/cases/__init__.py b/src/kpaa/cases/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..fcf6a7ca685e3b5d020e82ecf8779e2fad4d20f0 --- /dev/null +++ b/src/kpaa/cases/__init__.py @@ -0,0 +1,46 @@ +"""개인정보보호위원회 상담사례 (privacy.go.kr) — 로컬 SQLite FTS5 인덱스. + + from kpaa.cases import CasesIndex + idx = CasesIndex.default() + hits = idx.search("CCTV 안내문구", k=3) + for h in hits: + print(h.citation(), h.title) + +리포 동봉 스냅샷은 `data/cases.sqlite`. 갱신은 `kpaa refresh-cases`. +""" +from __future__ import annotations + +from pathlib import Path + +from kpaa.cases.index import ( + count as _count, +) +from kpaa.cases.index import ( + default_db_path, + default_meta_path, +) +from kpaa.cases.index import ( + search as _search, +) +from kpaa.cases.models import Case + + +class CasesIndex: + """사용자 친화적 진입점.""" + + def __init__(self, db_path: Path | None = None) -> None: + self.db_path = db_path or default_db_path() + + @classmethod + def default(cls) -> CasesIndex: + return cls() + + @property + def total(self) -> int: + return _count(self.db_path) + + def search(self, query: str, *, k: int = 5) -> list[Case]: + return _search(query, k=k, db_path=self.db_path) + + +__all__ = ["CasesIndex", "Case", "default_db_path", "default_meta_path"] diff --git a/src/kpaa/cases/index.py b/src/kpaa/cases/index.py new file mode 100644 index 0000000000000000000000000000000000000000..787d78f35eae970ff6f85a5ae629599652afd83f --- /dev/null +++ b/src/kpaa/cases/index.py @@ -0,0 +1,340 @@ +"""SQLite FTS5 인덱스 — 한국어 본문은 `unicode61` tokenizer 사용. + +(trigram도 시도했으나 길이 2 한국어 단어 — "병원", "환자", "동의", "유출", +"신고" — 가 인덱싱되지 않아 부적합. unicode61은 띄어쓰기 단위 토큰화로 +한국어 검색에 안정적이며 SQLite 표준 기본값이라 추가 의존 없음.) + +DB 스키마: + cases(ntt_id PK, ntt_no, title, body, summary, type_code, type_label, + category1, category2, category3, reg_dt, case_year, + source_note, detail_url) + cases_fts (FTS5 가상테이블, 검색용) + +검색 결과 정렬은 `bm25(cases_fts)` 기본. 더 높은 점수 = 덜 관련 → 오름차순. +""" +from __future__ import annotations + +import json +import sqlite3 +from collections.abc import Iterable +from pathlib import Path + +from kpaa.cases.models import Case + +SCHEMA_SQL = """ +CREATE TABLE IF NOT EXISTS cases ( + ntt_id TEXT PRIMARY KEY, + ntt_no TEXT, + title TEXT, + body TEXT, + summary TEXT, + type_code TEXT, + type_label TEXT, + category1 TEXT, + category2 TEXT, + category3 TEXT, + reg_dt TEXT, + case_year TEXT, + source_note TEXT, + detail_url TEXT, + chunk_context TEXT +); + +CREATE VIRTUAL TABLE IF NOT EXISTS cases_fts USING fts5( + ntt_id UNINDEXED, + title, + body, + category UNINDEXED, + tokenize = "unicode61 remove_diacritics 2" +); +""" + + +def default_db_path() -> Path: + """리포 동봉 스냅샷 경로(`data/cases.sqlite`).""" + return _data_dir() / "cases.sqlite" + + +def default_meta_path() -> Path: + return _data_dir() / "cases_meta.json" + + +def default_jsonl_path() -> Path: + """기본 JSONL export 경로 — `default_db_path()`와 같은 폴더.""" + return default_db_path().parent / "cases_chunks.jsonl" + + +# JSONL export 컬럼 — 변경 시 cases_chunks.jsonl 스키마가 바뀜 +_EXPORT_COLUMNS = ( + "ntt_id", "ntt_no", "title", "summary", "body", + "type_code", "type_label", + "category1", "category2", "category3", + "reg_dt", "case_year", "source_note", "detail_url", + "chunk_context", +) + + +def _data_dir() -> Path: + """레포 루트의 data/ 폴더 우선, 없으면 사용자 캐시.""" + repo_data = Path(__file__).resolve().parents[3] / "data" + if repo_data.exists(): + return repo_data + from kpaa.config import get_settings + + p = get_settings().cache_root / "cases" + p.mkdir(parents=True, exist_ok=True) + return p + + +def _connect(db_path: Path) -> sqlite3.Connection: + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + conn.executescript(SCHEMA_SQL) + return conn + + +def _fts_body(case: Case) -> str: + """FTS body 컬럼: chunk_context가 있으면 body 앞에 prepend (Anthropic Contextual Retrieval).""" + if case.chunk_context: + return f"{case.chunk_context}\n\n{case.body}" + return case.body + + +def export_jsonl( + *, db_path: Path | None = None, out_path: Path | None = None +) -> int: + """cases.sqlite를 JSONL로 export — `cases_chunks.jsonl` 동기화용. + + 각 라인 = 1 사례, ntt_id 정수 오름차순, 모든 컬럼(chunk_context 포함). + `build_index()` / `upsert_cases()` 끝에서 자동 호출되므로 DB와 jsonl이 + 항상 동기화. chunk_context를 직접 SQL UPDATE한 경우엔 수동으로 한번 더 + 호출하면 jsonl에 반영됨. + + out_path 미지정 시 `db_path` 옆에 `cases_chunks.jsonl`로 저장. + """ + path = db_path or default_db_path() + if not path.exists(): + return 0 + out = out_path or (path.parent / "cases_chunks.jsonl") + out.parent.mkdir(parents=True, exist_ok=True) + + conn = _connect(path) + n = 0 + try: + cur = conn.execute( + f"SELECT {', '.join(_EXPORT_COLUMNS)} FROM cases " + "ORDER BY CAST(ntt_id AS INTEGER)" + ) + with out.open("w", encoding="utf-8") as f: + for r in cur: + rec = {c: (r[c] or "") for c in _EXPORT_COLUMNS} + f.write(json.dumps(rec, ensure_ascii=False) + "\n") + n += 1 + finally: + conn.close() + return n + + +def build_index(cases: Iterable[Case], *, db_path: Path) -> None: + """전체 다시 빌드 — 단순화를 위해 기존 데이터 wipe 후 재삽입. + + 빌드 후 `cases_chunks.jsonl`을 자동 export해 DB와 동기화. + """ + conn = _connect(db_path) + try: + with conn: + conn.execute("DELETE FROM cases") + conn.execute("DELETE FROM cases_fts") + for case in cases: + conn.execute( + """INSERT INTO cases + (ntt_id, ntt_no, title, body, summary, + type_code, type_label, category1, category2, category3, + reg_dt, case_year, source_note, detail_url, chunk_context) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + case.ntt_id, case.ntt_no, case.title, case.body, case.summary, + case.type_code, case.type_label, + case.category1, case.category2, case.category3, + case.reg_dt, case.case_year, case.source_note, case.detail_url, + case.chunk_context, + ), + ) + category = " > ".join(filter(None, (case.category1, case.category2, case.category3))) + conn.execute( + "INSERT INTO cases_fts (ntt_id, title, body, category) VALUES (?,?,?,?)", + (case.ntt_id, case.title, _fts_body(case), category), + ) + conn.execute("VACUUM") + finally: + conn.close() + export_jsonl(db_path=db_path) + + +def upsert_cases(cases: Iterable[Case], *, db_path: Path) -> int: + """증분 갱신: 기존 ntt_id 충돌 시 덮어씀. jsonl도 자동 동기화.""" + conn = _connect(db_path) + n = 0 + try: + with conn: + for case in cases: + # 기존 ntt_id 행 삭제 후 재삽입 (FTS5 재인덱싱 위함) + conn.execute("DELETE FROM cases WHERE ntt_id = ?", (case.ntt_id,)) + conn.execute("DELETE FROM cases_fts WHERE ntt_id = ?", (case.ntt_id,)) + conn.execute( + """INSERT INTO cases + (ntt_id, ntt_no, title, body, summary, + type_code, type_label, category1, category2, category3, + reg_dt, case_year, source_note, detail_url, chunk_context) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + case.ntt_id, case.ntt_no, case.title, case.body, case.summary, + case.type_code, case.type_label, + case.category1, case.category2, case.category3, + case.reg_dt, case.case_year, case.source_note, case.detail_url, + case.chunk_context, + ), + ) + category = " > ".join(filter(None, (case.category1, case.category2, case.category3))) + conn.execute( + "INSERT INTO cases_fts (ntt_id, title, body, category) VALUES (?,?,?,?)", + (case.ntt_id, case.title, _fts_body(case), category), + ) + n += 1 + finally: + conn.close() + if n: + export_jsonl(db_path=db_path) + return n + + +def search( + query: str, *, k: int = 5, db_path: Path | None = None +) -> list[Case]: + """trigram FTS5 검색. + + Args: + query: 한국어 키워드 (단일 단어 또는 다중 단어) + k: 최대 결과 수 + """ + path = db_path or default_db_path() + if not path.exists(): + return [] + if not query.strip(): + return [] + conn = _connect(path) + try: + # 따옴표/구두점을 sanitize 후 토큰 단위로 분리. + # 다중 토큰은 OR로 결합 → 한 단어만 일치해도 후보로 끌어옴. + # 단일 토큰은 그대로 phrase 처리. + safe = "".join(ch if ch.isalnum() or ord(ch) > 127 else " " for ch in query) + tokens = [t for t in safe.split() if t] + if not tokens: + return [] + # prefix matching(`token*`)을 모든 토큰에 적용 — 한국어 조사 결합 + # ("유출된", "검색사이트에") 도 매칭되게 함. + if len(tokens) == 1: + fts_query = f"{tokens[0]}*" + else: + fts_query = " OR ".join(f"{t}*" for t in tokens) + cur = conn.execute( + """SELECT c.* FROM cases_fts f + JOIN cases c ON c.ntt_id = f.ntt_id + WHERE cases_fts MATCH ? + ORDER BY bm25(cases_fts) ASC + LIMIT ?""", + (fts_query, k), + ) + rows = cur.fetchall() + finally: + conn.close() + return [ + Case( + ntt_id=r["ntt_id"], ntt_no=r["ntt_no"], + title=r["title"], summary=r["summary"], body=r["body"], + type_code=r["type_code"], type_label=r["type_label"], + category1=r["category1"], category2=r["category2"], category3=r["category3"], + reg_dt=r["reg_dt"], case_year=r["case_year"], + source_note=r["source_note"], detail_url=r["detail_url"], + chunk_context=(r["chunk_context"] if "chunk_context" in r.keys() else "") or "", + ) + for r in rows + ] + + +def count(db_path: Path | None = None) -> int: + path = db_path or default_db_path() + if not path.exists(): + return 0 + conn = _connect(path) + try: + cur = conn.execute("SELECT COUNT(*) FROM cases") + return cur.fetchone()[0] + finally: + conn.close() + + +def list_all_cases(*, db_path: Path | None = None) -> list[Case]: + """기존 cases 테이블에서 모든 행을 Case 객체로 반환 (FTS5 재빌드 등에 사용).""" + path = db_path or default_db_path() + if not path.exists(): + return [] + conn = _connect(path) + try: + cur = conn.execute("SELECT * FROM cases") + rows = cur.fetchall() + finally: + conn.close() + return [ + Case( + ntt_id=r["ntt_id"], ntt_no=r["ntt_no"], + title=r["title"], summary=r["summary"], body=r["body"], + type_code=r["type_code"], type_label=r["type_label"], + category1=r["category1"], category2=r["category2"], category3=r["category3"], + reg_dt=r["reg_dt"], case_year=r["case_year"], + source_note=r["source_note"], detail_url=r["detail_url"], + chunk_context=(r["chunk_context"] if "chunk_context" in r.keys() else "") or "", + ) + for r in rows + ] + + +def rebuild_fts(*, db_path: Path | None = None) -> int: + """cases 테이블 데이터로 cases_fts만 재생성. tokenizer 변경 후 사용. + + 스크래이프 없이 빠르게 인덱스만 갱신. chunk_context 일괄 UPDATE 후 + 호출하면 jsonl도 자동 동기화. + """ + path = db_path or default_db_path() + cases = list_all_cases(db_path=path) + if not cases: + return 0 + conn = _connect(path) + try: + with conn: + conn.execute("DROP TABLE IF EXISTS cases_fts") + # _connect가 schema를 다시 생성 — close & re-open 으로 새 schema 적용 + finally: + conn.close() + conn = _connect(path) + try: + with conn: + for case in cases: + category = " > ".join(filter(None, (case.category1, case.category2, case.category3))) + conn.execute( + "INSERT INTO cases_fts (ntt_id, title, body, category) VALUES (?,?,?,?)", + (case.ntt_id, case.title, _fts_body(case), category), + ) + conn.execute("VACUUM") + finally: + conn.close() + export_jsonl(db_path=path) + return len(cases) + + +__all__ = [ + "build_index", "upsert_cases", "search", "count", + "list_all_cases", "rebuild_fts", "export_jsonl", + "default_db_path", "default_meta_path", "default_jsonl_path", +] diff --git a/src/kpaa/cases/models.py b/src/kpaa/cases/models.py new file mode 100644 index 0000000000000000000000000000000000000000..3fc1c8c5fa10e47749c7b0f904d92f0ac0b90ce6 --- /dev/null +++ b/src/kpaa/cases/models.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + + +class Case(BaseModel): + """개인정보보호위원회 상담사례 1건. + + privacy.go.kr `/front/case/onMadangListAjax.do`의 `resultDocuments` 1개 항목 + 에 1:1 매핑. + """ + + model_config = ConfigDict(frozen=True) + + ntt_no: str # 케이스 번호 (예: "311") + ntt_id: str # 내부 ID (예: "10561") + title: str # NTT_SJ — 질문(제목) + summary: str # NTT_CN — 짧은 요약 + body: str # NTT_CN_FULL — 답변 본문 (HTML 엔티티 디코딩 후) + type_code: str # NTT_TYPE — "INT" | "GUI" + type_label: str # NTT_TYPE_NM — 한글 라벨 + category1: str # CASE_CLASS1_NM — 정보주체/처리자(민간/공공) + category2: str # CASE_CLASS2_NM — 분야 (수집·이용 등) + category3: str # CASE_CLASS3_NM — 업종 (경영·사무 등) + reg_dt: str # YYYYMMDD + case_year: str # YYYY + source_note: str # ETC_CN + detail_url: str # /front/case/view.do?ntt_id=...&nttno=... + chunk_context: str = "" # Anthropic Contextual Retrieval prefix — 인덱싱 시 body 앞에 prepend (답변 출력은 body만) + + def citation(self) -> str: + """답변에 박을 인용 태그. + + 예: '개인정보 상담사례 #311 (2024)' + """ + if self.case_year: + return f"개인정보 상담사례 #{self.ntt_no} ({self.case_year})" + return f"개인정보 상담사례 #{self.ntt_no}" + + def absolute_url(self) -> str: + if self.detail_url.startswith("http"): + return self.detail_url + return f"https://www.privacy.go.kr{self.detail_url}" diff --git a/src/kpaa/cases/scraper.py b/src/kpaa/cases/scraper.py new file mode 100644 index 0000000000000000000000000000000000000000..72cf6a708fc6350a65a198571ccd27cc784b06be --- /dev/null +++ b/src/kpaa/cases/scraper.py @@ -0,0 +1,268 @@ +"""privacy.go.kr 상담사례 1,745건 스크래퍼. + +엔드포인트(라이브 검증 2026-04-29): + https://www.privacy.go.kr/front/case/onMadangListAjax.do?page=N + +응답 JSON에서 `resultSet.result`는 7개 facet 그룹이 있고 그중 +**index 2 (totalSize=1745)** 가 케이스 본문 포함 그룹이다. +다른 그룹은 통계/이벤트/게시판 ID들로 무관. + +각 페이지당 10건. 총 ~175페이지. 폴라이트 레이트 1.5 req/s. + +본문(`NTT_CN_FULL`)에는 HTML 엔티티(`(`, `·` 등)가 들어 있어 +`html.unescape()`로 풀어 저장한다. +""" +from __future__ import annotations + +import asyncio +import html +import json +import logging +import math +import time +from datetime import datetime +from pathlib import Path +from typing import Any + +import httpx + +from kpaa.cases.models import Case +from kpaa.config import get_settings + +logger = logging.getLogger("kpaa.cases") + +LIST_URL = "https://www.privacy.go.kr/front/case/onMadangListAjax.do" +ROBOTS_URL = "https://www.privacy.go.kr/robots.txt" + +# 폴라이트 레이트 +MIN_REQ_INTERVAL = 1.0 / 1.5 # 1.5 req/s → 0.667s +TIMEOUT = httpx.Timeout(30.0, connect=10.0) +USER_AGENT = "kpaa/0.1 (+https://github.com/sz1-kca/korean-privacy-ai-assistant; cases-scraper)" + +# resultSet.result 의 index 2 그룹이 본문 포함 (라이브 검증으로 확인) +CASE_GROUP_INDEX = 2 + +# 한 답변 본문 trim (Plan: ≤1500자 컨텍스트 안전) +BODY_LIMIT = 1500 + + +def _enable_truststore() -> None: + """macOS Python.framework SSL 이슈 회피 — 시스템 신뢰 저장소 사용.""" + try: + import truststore + + truststore.inject_into_ssl() + except ImportError: + logger.debug("truststore not available; relying on certifi") + + +def _decode_body(raw: str) -> str: + """HTML 엔티티(`(`, `·`) 디코드 + 다중 공백 정리.""" + if not raw: + return "" + text = html.unescape(raw) + # 너무 긴 본문은 trim (chatbot 컨텍스트 안전) + if len(text) > BODY_LIMIT: + text = text[: BODY_LIMIT].rstrip() + "\n…(중략)…" + return text + + +def _to_case(doc: dict[str, Any]) -> Case | None: + """resultDocuments 1개 항목을 Case로 변환. 본문 필드 없으면 None.""" + body_raw = doc.get("NTT_CN_FULL") or doc.get("NTT_CN") or "" + if not body_raw: + return None + return Case( + ntt_no=str(doc.get("NTT_NO") or "").strip(), + ntt_id=str(doc.get("NTT_ID") or "").strip(), + title=str(doc.get("NTT_SJ") or "").strip(), + summary=_decode_body(str(doc.get("NTT_CN") or "")), + body=_decode_body(str(body_raw)), + type_code=str(doc.get("NTT_TYPE") or "").strip(), + type_label=str(doc.get("NTT_TYPE_NM") or "").strip(), + category1=str(doc.get("CASE_CLASS1_NM") or "").strip(), + category2=str(doc.get("CASE_CLASS2_NM") or "").strip(), + category3=str(doc.get("CASE_CLASS3_NM") or "").strip(), + reg_dt=str(doc.get("REG_DT") or "").strip(), + case_year=str(doc.get("CASE_YEAR") or "").strip(), + source_note=str(doc.get("ETC_CN") or "").strip(), + detail_url=str(doc.get("URL_ADDR") or "").strip(), + ) + + +async def check_robots(client: httpx.AsyncClient) -> tuple[bool, str]: + """robots.txt 확인. (/front/case/ 경로 허용 여부, 원문).""" + r = await client.get(ROBOTS_URL) + text = r.text + # 매우 보수적 파서: 명시적 disallow에 /front/case가 포함되면 False + in_wildcard = False + forbidden = [] + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + if line.lower().startswith("user-agent:"): + in_wildcard = line.split(":", 1)[1].strip() == "*" + elif in_wildcard and line.lower().startswith("disallow:"): + path = line.split(":", 1)[1].strip() + if path: + forbidden.append(path) + case_blocked = any(p.startswith("/front/case") for p in forbidden) + return (not case_blocked, text) + + +async def fetch_page( + client: httpx.AsyncClient, page: int +) -> tuple[list[Case], int]: + """1 페이지 호출 → (cases, total_count).""" + r = await client.get( + LIST_URL, + params={"page": str(page)}, + headers={ + "User-Agent": USER_AGENT, + "X-Requested-With": "XMLHttpRequest", + "Accept": "application/json", + }, + ) + r.raise_for_status() + data = r.json() + result = data.get("resultSet", {}).get("result", []) + if len(result) <= CASE_GROUP_INDEX: + logger.warning("page %d: result group %d missing", page, CASE_GROUP_INDEX) + return ([], 0) + grp = result[CASE_GROUP_INDEX] + total = int(float(grp.get("totalSize", 0))) + docs = grp.get("resultDocuments", []) + cases = [] + for doc in docs: + case = _to_case(doc) + if case is not None: + cases.append(case) + return (cases, total) + + +async def fetch_all( + *, + since: str | None = None, + respect_robots: bool = False, + progress: bool = True, +) -> tuple[list[Case], dict[str, Any]]: + """전수 페이지네이션 스크래이프. + + Args: + since: "YYYY-MM-DD"면 그 일자 이후 등록된 케이스만 keep (증분). + respect_robots: True면 robots.txt 명시 disallow 발견시 중단. + progress: True면 페이지 진행률 한 줄씩 출력. + """ + _enable_truststore() + since_yyyymmdd = "" + if since: + # "YYYY-MM-DD" → "YYYYMMDD" + since_yyyymmdd = since.replace("-", "").strip() + + async with httpx.AsyncClient(timeout=TIMEOUT, follow_redirects=True) as c: + if respect_robots: + allowed, _txt = await check_robots(c) + if not allowed: + raise PermissionError( + "robots.txt에서 /front/case 경로를 disallow하고 있습니다. " + "스크래이프를 중단합니다." + ) + # 1페이지로 totalCnt 확보 + first_cases, total = await fetch_page(c, 1) + if total == 0: + return ([], {"total": 0, "pages": 0, "fetched": 0}) + + pages = math.ceil(total / 10) + all_cases: list[Case] = list(first_cases) + last_req_at = time.monotonic() + + for page in range(2, pages + 1): + # 폴라이트 레이트 + elapsed = time.monotonic() - last_req_at + wait = MIN_REQ_INTERVAL - elapsed + if wait > 0: + await asyncio.sleep(wait) + last_req_at = time.monotonic() + try: + cases, _ = await fetch_page(c, page) + except httpx.HTTPError as e: + logger.warning("page %d failed: %s — skipping", page, e) + continue + all_cases.extend(cases) + if progress and page % 10 == 0: + print( + f" …page {page}/{pages} " + f"({len(all_cases)} cases fetched)", + flush=True, + ) + + # 증분 필터 + if since_yyyymmdd: + all_cases = [c for c in all_cases if c.reg_dt >= since_yyyymmdd] + + # ntt_id 중복 제거 (안전장치) + seen: set[str] = set() + deduped: list[Case] = [] + for case in all_cases: + key = case.ntt_id or f"no:{case.ntt_no}" + if key in seen: + continue + seen.add(key) + deduped.append(case) + + meta = { + "total": total, + "pages": pages, + "fetched": len(deduped), + "scraped_at": datetime.now().isoformat(timespec="seconds"), + "source_url": LIST_URL, + "since": since or "", + } + return (deduped, meta) + + +async def refresh( + *, + since: str | None = None, + respect_robots: bool = False, +) -> int: + """스크래이프 → SQLite 인덱스 빌드 → meta 갱신. CLI 진입점.""" + from kpaa.cases.index import build_index, default_db_path, default_meta_path + + print(f"▶ privacy.go.kr 상담사례 스크래이프 시작 (since={since or 'ALL'})") + cases, meta = await fetch_all(since=since, respect_robots=respect_robots) + if not cases: + print("(가져온 케이스 없음)") + return 0 + + db_path = default_db_path() + meta_path = default_meta_path() + print(f"▶ {len(cases)}건 → SQLite FTS5 인덱스 빌드: {db_path}") + build_index(cases, db_path=db_path) + + # 메타 저장 + db_path.parent.mkdir(parents=True, exist_ok=True) + with meta_path.open("w", encoding="utf-8") as f: + json.dump(meta, f, ensure_ascii=False, indent=2) + print(f"▶ 메타: {meta_path}") + print( + f" total(remote)={meta['total']} fetched={meta['fetched']} " + f"scraped_at={meta['scraped_at']}" + ) + return 0 + + +__all__ = ["fetch_all", "fetch_page", "refresh", "check_robots"] + + +# 사전 스냅샷 경로 헬퍼 (data/cases.sqlite) — index.py에서 사용 +def _project_data_dir() -> Path: + """리포 안의 data/ 폴더를 우선 사용, 그 외에는 user_cache.""" + repo_data = Path(__file__).resolve().parents[3] / "data" + if repo_data.exists(): + return repo_data + s = get_settings() + p = s.cache_root / "cases" + p.mkdir(parents=True, exist_ok=True) + return p diff --git a/src/kpaa/cli.py b/src/kpaa/cli.py new file mode 100644 index 0000000000000000000000000000000000000000..33f9e2823ac6ce604e6c63bcee4cf2955464903f --- /dev/null +++ b/src/kpaa/cli.py @@ -0,0 +1,416 @@ +from __future__ import annotations + +import argparse +import asyncio +import sys + + +def _utf8_console() -> None: + for stream in (sys.stdout, sys.stderr): + try: + stream.reconfigure(encoding="utf-8") # type: ignore[attr-defined] + except (AttributeError, OSError): + pass + + +def _build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(prog="kpaa", description="개인정보보호법 미니 상담 챗봇 CLI") + sub = p.add_subparsers(dest="cmd", required=True) + + # ── serve / smoke / eval ──────────────────────────────────────── + p_serve = sub.add_parser("serve", help="FastAPI 백엔드 실행") + p_serve.add_argument("--host", default=None) + p_serve.add_argument("--port", type=int, default=None) + + p_smoke = sub.add_parser("smoke", help="단일 질문으로 RAG 파이프라인 종단 검증 (LLM 포함)") + p_smoke.add_argument("query") + + p_retrieve = sub.add_parser( + "retrieve", + help="RAG 컨텍스트 블록만 빌드 (LLM 호출 없음 — 검증용)", + ) + p_retrieve.add_argument("query") + + p_route = sub.add_parser( + "route", + help="라우터 결과만 출력 (intents/jo/mst/sources, LLM·검색 없음)", + ) + p_route.add_argument("query") + + p_eval = sub.add_parser("eval", help="골든 질문 일괄 평가 (LLM 라이브)") + p_eval.add_argument("--limit", type=int, default=None, help="첫 N건만 실행") + p_eval.add_argument("--show-answers", action="store_true", help="모든 답변 본문 출력") + p_eval.add_argument("--out", default=None, help="결과 JSON 저장 경로") + + # ── law ───────────────────────────────────────────────────────── + p_law = sub.add_parser("law", help="법제처 SDK 직접 호출 (법령)") + law_sub = p_law.add_subparsers(dest="law_cmd", required=True) + sp = law_sub.add_parser("search", help="법령 검색") + sp.add_argument("query") + sp.add_argument("--display", type=int, default=10) + + sp = law_sub.add_parser("text", help="법령 본문 또는 특정 조문") + sp.add_argument("mst", help="법령일련번호 (예: 270351 = 개인정보 보호법)") + sp.add_argument("--jo", help='조문 번호 (예: "15", "24의2")') + + sp = law_sub.add_parser("annexes", help="별표·별지서식 검색") + sp.add_argument("--law-id", help="관련법령ID(6자리, 예: 011357)", default=None) + sp.add_argument("--query", default="") + sp.add_argument("--display", type=int, default=10) + + # ── pipc ──────────────────────────────────────────────────────── + p_pipc = sub.add_parser("pipc", help="개인정보보호위원회 결정문") + pipc_sub = p_pipc.add_subparsers(dest="pipc_cmd", required=True) + sp = pipc_sub.add_parser("search") + sp.add_argument("query") + sp.add_argument("--display", type=int, default=10) + sp = pipc_sub.add_parser("text") + sp.add_argument("decision_id") + + # ── expc (interpretation) ─────────────────────────────────────── + p_expc = sub.add_parser("expc", help="법령해석례") + expc_sub = p_expc.add_subparsers(dest="expc_cmd", required=True) + sp = expc_sub.add_parser("search") + sp.add_argument("query") + sp.add_argument("--display", type=int, default=10) + sp = expc_sub.add_parser("text") + sp.add_argument("interpretation_id") + + # ── cases (privacy.go.kr 상담사례) ────────────────────────────── + p_cases = sub.add_parser("cases", help="개인정보 상담사례 검색") + cases_sub = p_cases.add_subparsers(dest="cases_cmd", required=True) + sp = cases_sub.add_parser("search") + sp.add_argument("query") + sp.add_argument("-k", type=int, default=5) + + p_refresh = sub.add_parser("refresh-cases", help="privacy.go.kr 상담사례 재스크래이프") + p_refresh.add_argument("--since", help="YYYY-MM-DD 이후만 갱신") + p_refresh.add_argument("--respect-robots", action="store_true") + + # ── guides (PIPC 발간 안내서) ─────────────────────────────────── + p_guides = sub.add_parser("guides", help="개인정보 보호 안내서 청크 검색") + guides_sub = p_guides.add_subparsers(dest="guides_cmd", required=True) + sp = guides_sub.add_parser("search") + sp.add_argument("query") + sp.add_argument("-k", type=int, default=5) + + p_extract = sub.add_parser( + "extract-guide", + help="PDF → markdown 추출 (인터랙티브 청킹의 사전 단계)", + ) + p_extract.add_argument("pdf_path", help="data/guide/*.pdf 한 건") + + p_build_guides = sub.add_parser( + "build-guides", + help="data/guide/chunks/*.jsonl → guides.sqlite 빌드", + ) + p_build_guides.add_argument("--rebuild", action="store_true", default=True) + + sub.add_parser( + "refresh-related-laws", + help="privacy.go.kr 개인정보 관련 법령·행정규칙 목록 재스크래이프 (data/related_laws.yaml)", + ) + + return p + + +# ───────────────────────── command handlers ───────────────────────── + +async def _law_search(query: str, display: int) -> int: + from kpaa.law_api import KoreanLawClient + + async with KoreanLawClient() as client: + hits = await client.law.search(query, display=display) + if not hits: + print(f'(검색 결과 없음: "{query}")') + return 0 + for hit in hits: + print( + f"- [{hit.mst}] {hit.name} " + f"({hit.type_name or '-'}; 시행 {hit.enforce_date or '-'}; " + f"소관 {hit.department or '-'})" + ) + return 0 + + +async def _law_text(mst: str, jo: str | None) -> int: + from kpaa.law_api import KoreanLawClient + + async with KoreanLawClient() as client: + text = await client.law.get_text(mst=mst, jo=jo) + print(f"# {text.name} (MST={text.mst}, 시행 {text.enforce_date}, 소관 {text.department})") + if not text.articles: + print("(조문 없음)") + return 0 + for art in text.articles: + print(f"\n## {art.citation(law_name=text.name)} {art.title}") + print(art.raw_text) + return 0 + + +async def _law_annexes(law_id: str | None, query: str, display: int) -> int: + from kpaa.law_api import KoreanLawClient + + async with KoreanLawClient() as client: + hits = await client.law.get_annexes( + related_law_id=law_id, query=query, display=display + ) + for h in hits: + print( + f"- [{h.annex_id}] {h.name} ({h.annex_kind}; " + f"관련법령 {h.related_law_name}; 공포 {h.promulgate_date})" + ) + return 0 + + +async def _pipc_search(query: str, display: int) -> int: + from kpaa.law_api import KoreanLawClient + + async with KoreanLawClient() as client: + hits = await client.pipc.search_decisions(query, display=display) + for h in hits: + print( + f"- [{h.decision_id}] {h.title}\n" + f" (의안 {h.decision_no or '-'}; 의결 {h.decision_date or '-'}; " + f"{h.decision_kind or '-'})" + ) + return 0 + + +async def _pipc_text(decision_id: str) -> int: + from kpaa.law_api import KoreanLawClient + + async with KoreanLawClient() as client: + d = await client.pipc.get_decision_text(decision_id=decision_id) + print(f"# {d.citation()} {d.title}") + print(f" 의결: {d.decision_date or '-'} | 결정구분: {d.decision_kind or '-'} | {d.agency or '-'}") + if d.main_text: + print(f"\n## 주문\n{d.main_text}") + if d.reason: + print(f"\n## 이유\n{d.reason}") + return 0 + + +async def _expc_search(query: str, display: int) -> int: + from kpaa.law_api import KoreanLawClient + + async with KoreanLawClient() as client: + hits = await client.interpretation.search(query, display=display) + for h in hits: + print( + f"- [{h.interpretation_id}] {h.title}\n" + f" (안건 {h.case_no or '-'}; 회신 {h.decided_date or '-'}; " + f"질의 {h.inquirer} → {h.responder})" + ) + return 0 + + +async def _expc_text(interpretation_id: str) -> int: + from kpaa.law_api import KoreanLawClient + + async with KoreanLawClient() as client: + e = await client.interpretation.get_text(interpretation_id=interpretation_id) + print(f"# {e.citation()} {e.title}") + print(f" 해석: {e.decided_date or '-'} | 질의 {e.inquirer} → 회신 {e.responder}") + if e.question: + print(f"\n## 질의요지\n{e.question}") + if e.answer: + print(f"\n## 회답\n{e.answer}") + if e.reason: + print(f"\n## 이유\n{e.reason}") + return 0 + + +# ───────────────────────── entry ───────────────────────── + +def main(argv: list[str] | None = None) -> int: + _utf8_console() + parser = _build_parser() + args = parser.parse_args(argv) + + if args.cmd == "serve": + from kpaa.config import get_settings + + s = get_settings() + host = args.host or s.kpaa_host + port = args.port or s.kpaa_port + try: + from kpaa.server import run as run_server + except ImportError: + print("(server 모듈은 Day 6에 구현됩니다)", file=sys.stderr) + return 2 + run_server(host=host, port=port) + return 0 + + if args.cmd == "smoke": + from kpaa.pipeline import smoke as smoke_fn + + return asyncio.run(smoke_fn(args.query)) + + if args.cmd == "retrieve": + from kpaa.pipeline import smoke as smoke_fn + + # smoke와 동일하게 컨텍스트 출력 (LLM은 아직 통합 전이라 둘이 같음) + return asyncio.run(smoke_fn(args.query)) + + if args.cmd == "route": + from kpaa.retrieval.router import ( + load_intents, + load_related_laws, + ) + from kpaa.retrieval.router import ( + route as route_fn, + ) + + load_intents.cache_clear() + load_related_laws.cache_clear() + plan = asyncio.run(route_fn(args.query)) + print(f"질문: {args.query!r}\n") + print(f"chain : {plan.chain or '(미결정)'}") + print(f"routed_by : {plan.routed_by}") + print(f"intents : {[i.name for i in plan.intents] or '(없음)'}") + print(f"jo_targets : {plan.jo_targets or '-'}") + print(f"mst_targets : {plan.mst_targets or '-'}") + print(f"name_targets : {plan.name_targets or '-'}") + print(f"active_sources : {plan.active_sources or '-'}") + print(f"search_keywords: {plan.search_keywords or '-'}") + print(f"top_keyword : {plan.top_keyword!r}") + return 0 + + if args.cmd == "eval": + from pathlib import Path as _P + + from kpaa.cli_eval import run as run_eval + + return asyncio.run( + run_eval( + limit=args.limit, + show_answers=args.show_answers, + out_path=_P(args.out) if args.out else None, + ) + ) + + if args.cmd == "law": + if args.law_cmd == "search": + return asyncio.run(_law_search(args.query, args.display)) + if args.law_cmd == "text": + return asyncio.run(_law_text(args.mst, args.jo)) + if args.law_cmd == "annexes": + return asyncio.run(_law_annexes(args.law_id, args.query, args.display)) + + if args.cmd == "pipc": + if args.pipc_cmd == "search": + return asyncio.run(_pipc_search(args.query, args.display)) + if args.pipc_cmd == "text": + return asyncio.run(_pipc_text(args.decision_id)) + + if args.cmd == "expc": + if args.expc_cmd == "search": + return asyncio.run(_expc_search(args.query, args.display)) + if args.expc_cmd == "text": + return asyncio.run(_expc_text(args.interpretation_id)) + + if args.cmd == "cases": + from kpaa.cases import CasesIndex + + idx = CasesIndex.default() + if idx.total == 0: + print( + "(상담사례 인덱스가 비어 있습니다 — `kpaa refresh-cases` 로 빌드하세요)", + file=sys.stderr, + ) + return 1 + if args.cases_cmd == "search": + hits = idx.search(args.query, k=args.k) + if not hits: + print(f'(검색 결과 없음: "{args.query}")') + return 0 + for h in hits: + cat = " > ".join(filter(None, (h.category1, h.category2, h.category3))) + print(f"\n# {h.citation()} {h.title}") + print(f" 분류: {cat or '-'} | 등록: {h.reg_dt or '-'} | {h.type_label or '-'}") + if h.body: + print(f" 본문:\n {h.body[:500]}") + if h.source_note: + print(f" 출처: {h.source_note}") + print(f" 링크: {h.absolute_url() if h.detail_url else '-'}") + return 0 + + if args.cmd == "refresh-cases": + from kpaa.cases.scraper import refresh + + return asyncio.run(refresh(since=args.since, respect_robots=args.respect_robots)) + + if args.cmd == "guides": + from kpaa.guides import GuidesIndex + + idx = GuidesIndex.default() + if idx.total == 0: + print( + "(가이드 인덱스가 비어 있습니다 — `kpaa build-guides` 로 빌드하세요)", + file=sys.stderr, + ) + return 1 + if args.guides_cmd == "search": + hits = idx.search(args.query, k=args.k) + if not hits: + print(f'(검색 결과 없음: "{args.query}")') + return 0 + for h in hits: + print(f"\n# {h.citation()}") + print(f" 문서: {h.doc_title} ({h.doc_date or '-'})") + print(f" 섹션: {h.section or '-'} | chunk #{h.chunk_no}") + if h.body: + print(f" 본문:\n {h.body[:500]}") + print(f" 원본 PDF: {h.source_pdf or '-'}") + return 0 + + if args.cmd == "extract-guide": + from pathlib import Path as _P + + from kpaa.guides.extractor import ( + derive_doc_id, + derive_doc_title, + extract, + ) + from kpaa.guides.index import extracted_dir + + pdf = _P(args.pdf_path) + if not pdf.exists(): + print(f"(PDF 없음: {pdf})", file=sys.stderr) + return 1 + doc_id = derive_doc_id(pdf.name) + title = derive_doc_title(pdf.name) + print(f"PDF: {pdf.name}", file=sys.stderr) + print(f" doc_id : {doc_id}", file=sys.stderr) + print(f" doc_title: {title}", file=sys.stderr) + print("docling 변환 중 ... (born-digital, OCR off)", file=sys.stderr) + try: + md = extract(pdf) + except RuntimeError as e: + print(f"✗ {e}", file=sys.stderr) + return 1 + out_dir = extracted_dir() + out_dir.mkdir(parents=True, exist_ok=True) + out = out_dir / f"{doc_id}.md" + out.write_text(md, encoding="utf-8") + print( + f"✓ {len(md):,}자 → {out}\n" + f" 다음: 새 Claude 세션에서 '다음 PDF 청킹: {doc_id}' 라고 지시.", + file=sys.stderr, + ) + return 0 + + if args.cmd == "build-guides": + from kpaa.guides.builder import build + + build() + return 0 + + if args.cmd == "refresh-related-laws": + from kpaa.related_laws import refresh as refresh_rl + + return asyncio.run(refresh_rl()) + + parser.print_help() + return 2 diff --git a/src/kpaa/cli_eval.py b/src/kpaa/cli_eval.py new file mode 100644 index 0000000000000000000000000000000000000000..ed0b8f2bea1e9f48c5d61baa11b89e4c8d044e14 --- /dev/null +++ b/src/kpaa/cli_eval.py @@ -0,0 +1,182 @@ +"""골든 질문 자동 평가 (`kpaa eval`). + +`tests/eval_questions.yaml`의 10개 질문을 라이브 파이프라인에 던지고, +각 질문의 `expected_phrases`(모두 매칭)와 `forbidden_phrases`(하나라도 매칭 시 실패), +면책 문구 부착 여부를 검사한다. + +라이브 LLM 추론이 들어가므로 10건 × ~30~60초 = 5~10분 소요. +빠른 검증은 `--limit N`로 일부만. + +CI에서는 LLM 호출이 너무 무거우므로 이 스크립트는 사용자 로컬 검증용. +""" +from __future__ import annotations + +import re +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml + +from kpaa.pipeline import generate + +_DISCLAIMER_RE = re.compile(r"※[\s\S]{0,400}법률\s*자문") + + +@dataclass +class CaseResult: + id: int + question: str + answer: str + elapsed_s: float + missed_expected: list[str] + hit_forbidden: list[str] + has_disclaimer: bool + + @property + def passed(self) -> bool: + return ( + not self.missed_expected + and not self.hit_forbidden + and self.has_disclaimer + ) + + +def _load_eval(path: Path | None = None) -> list[dict[str, Any]]: + p = path or (Path(__file__).resolve().parents[2] / "tests" / "eval_questions.yaml") + if not p.exists(): + raise FileNotFoundError(f"eval yaml not found: {p}") + raw = yaml.safe_load(p.read_text(encoding="utf-8")) + return list(raw or []) + + +def _check(answer: str, item: dict[str, Any]) -> tuple[list[str], list[str], bool]: + """답변에서 expected/forbidden 정규식 매칭 + 면책 문구 검사.""" + missed = [] + for pat in item.get("expected_phrases", []) or []: + if not re.search(pat, answer): + missed.append(pat) + hit = [] + for pat in item.get("forbidden_phrases", []) or []: + if re.search(pat, answer): + hit.append(pat) + has_disclaimer = bool(_DISCLAIMER_RE.search(answer)) + return missed, hit, has_disclaimer + + +async def _generate_answer(query: str) -> tuple[str, float]: + """파이프라인 종단 호출 → 최종 답변과 경과 시간.""" + t0 = time.monotonic() + final = "" + chunks: list[str] = [] + async for evt in generate(query): + if evt["event"] == "token": + chunks.append(evt["delta"]) + elif evt["event"] == "done": + final = evt["answer"] + if not final: + final = "".join(chunks) + return final, time.monotonic() - t0 + + +async def run( + *, + limit: int | None = None, + eval_path: Path | None = None, + show_answers: bool = False, + out_path: Path | None = None, +) -> int: + items = _load_eval(eval_path) + if limit is not None: + items = items[:limit] + print(f"▶ kpaa eval — 골든 질문 {len(items)}건 평가 시작") + print(f" (각 질문 LLM 추론 ~30–60초, 총 {len(items) * 45 / 60:.1f}분 소요 예상)\n") + + results: list[CaseResult] = [] + for i, item in enumerate(items, 1): + q = item["question"] + qid = item.get("id", i) + print(f"[{i}/{len(items)}] #{qid} {q}") + try: + answer, secs = await _generate_answer(q) + except Exception as e: + print(f" ✗ ERROR: {type(e).__name__}: {e}\n") + results.append( + CaseResult( + id=qid, question=q, answer="", elapsed_s=0.0, + missed_expected=item.get("expected_phrases", []) or [], + hit_forbidden=[], + has_disclaimer=False, + ) + ) + continue + + missed, hit, has_dc = _check(answer, item) + r = CaseResult( + id=qid, question=q, answer=answer, elapsed_s=secs, + missed_expected=missed, hit_forbidden=hit, has_disclaimer=has_dc, + ) + results.append(r) + + flag = "✅" if r.passed else "❌" + print(f" {flag} ({secs:.1f}s) " + f"missed={len(missed)} forbidden={len(hit)} disclaimer={has_dc}") + if missed: + print(f" missed: {missed}") + if hit: + print(f" forbidden hit: {hit}") + if not has_dc: + print(" disclaimer absent") + if show_answers or not r.passed: + preview = answer.replace("\n", "\n ") + print(f" ─── 답변 ─────────────\n {preview}") + print() + + # 종합 + passed = sum(1 for r in results if r.passed) + total = len(results) + print("─" * 60) + print(f"결과: {passed}/{total} 통과 ({100*passed/total if total else 0:.0f}%)") + print("─" * 60) + if passed < total: + print("\n실패한 질문:") + for r in results: + if not r.passed: + print(f" #{r.id}: {r.question}") + if r.missed_expected: + print(f" missing: {r.missed_expected}") + if r.hit_forbidden: + print(f" forbidden: {r.hit_forbidden}") + if not r.has_disclaimer: + print(" disclaimer absent") + + if out_path is not None: + import json + + out_path.write_text( + json.dumps( + [ + { + "id": r.id, + "question": r.question, + "answer": r.answer, + "elapsed_s": round(r.elapsed_s, 2), + "passed": r.passed, + "missed_expected": r.missed_expected, + "hit_forbidden": r.hit_forbidden, + "has_disclaimer": r.has_disclaimer, + } + for r in results + ], + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + print(f"\n결과 저장: {out_path}") + + return 0 if passed == total else 1 + + +__all__ = ["run", "CaseResult"] diff --git a/src/kpaa/config.py b/src/kpaa/config.py new file mode 100644 index 0000000000000000000000000000000000000000..198c5722221b45b1fb749bffbfc09c6d4852c4f4 --- /dev/null +++ b/src/kpaa/config.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from functools import lru_cache +from pathlib import Path + +from platformdirs import user_cache_dir, user_config_dir +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore", + ) + + law_oc: str = "" + + kpaa_model_repo: str = "bartowski/google_gemma-4-E2B-it-GGUF" + kpaa_model_file: str = "google_gemma-4-E2B-it-Q4_K_M.gguf" + kpaa_model_dir: Path | None = None + + kpaa_host: str = "127.0.0.1" + kpaa_port: int = 8000 + + kpaa_rewrite: bool = False + kpaa_max_context_tokens: int = 16384 + + # LLM 백엔드 선택. None=auto: HF Spaces 환경(SPACE_ID 설정)이면 zerogpu, + # 아니면 llama_cpp. 강제 override: "llama_cpp" | "zerogpu". + kpaa_llm_backend: str | None = None + + # ZeroGPU 백엔드용 — 같은 가중치의 transformers repo (게이트 X, Apache 2.0). + kpaa_hf_model_repo: str = "google/gemma-4-E2B-it" + kpaa_hf_model_dtype: str = "bfloat16" + # @spaces.GPU 함수당 GPU 점유 한도(초). 기본 60s 는 긴 답변에 부족할 수 있어 + # 120s 사용. ZeroGPU Pro 는 300s 까지 허용. + kpaa_hf_gpu_duration: int = 120 + + @property + def cache_root(self) -> Path: + p = Path(user_cache_dir("kpaa")) + p.mkdir(parents=True, exist_ok=True) + return p + + @property + def config_root(self) -> Path: + p = Path(user_config_dir("kpaa")) + p.mkdir(parents=True, exist_ok=True) + return p + + @property + def model_dir(self) -> Path: + p = self.kpaa_model_dir or (self.cache_root / "models") + p.mkdir(parents=True, exist_ok=True) + return p + + @property + def law_cache_dir(self) -> Path: + p = self.cache_root / "law" + p.mkdir(parents=True, exist_ok=True) + return p + + +@lru_cache(maxsize=1) +def get_settings() -> Settings: + return Settings() diff --git a/src/kpaa/guides/__init__.py b/src/kpaa/guides/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..fb4b9d677aa707a029bbadd70aaf5f47b373340a --- /dev/null +++ b/src/kpaa/guides/__init__.py @@ -0,0 +1,57 @@ +"""PIPC 발간 안내서 — 로컬 SQLite FTS5 인덱스. + + from kpaa.guides import GuidesIndex + idx = GuidesIndex.default() + hits = idx.search("CCTV 안내문구", k=3) + for h in hits: + print(h.citation(), h.section) + +리포 동봉 스냅샷은 `data/guides.sqlite`. 갱신은 `kpaa build-guides`. + +청크 원본(`data/guide/chunks/*.jsonl`)은 사용자 + Claude 의 인터랙티브 +큐레이션 결과 — 자동 휴리스틱으로 만들어지지 않으므로 PDF 변경 시 jsonl 도 +함께 갱신해야 한다. +""" +from __future__ import annotations + +from pathlib import Path + +from kpaa.guides.index import ( + count as _count, +) +from kpaa.guides.index import ( + default_db_path, + default_meta_path, +) +from kpaa.guides.index import ( + doc_count as _doc_count, +) +from kpaa.guides.index import ( + search as _search, +) +from kpaa.guides.models import GuideChunk + + +class GuidesIndex: + """사용자 친화적 진입점.""" + + def __init__(self, db_path: Path | None = None) -> None: + self.db_path = db_path or default_db_path() + + @classmethod + def default(cls) -> GuidesIndex: + return cls() + + @property + def total(self) -> int: + return _count(self.db_path) + + @property + def doc_total(self) -> int: + return _doc_count(self.db_path) + + def search(self, query: str, *, k: int = 5) -> list[GuideChunk]: + return _search(query, k=k, db_path=self.db_path) + + +__all__ = ["GuidesIndex", "GuideChunk", "default_db_path", "default_meta_path"] diff --git a/src/kpaa/guides/builder.py b/src/kpaa/guides/builder.py new file mode 100644 index 0000000000000000000000000000000000000000..b47fb6c1af53d8eecd9b9d4450182003ce566886 --- /dev/null +++ b/src/kpaa/guides/builder.py @@ -0,0 +1,140 @@ +"""`data/guide/chunks/*.jsonl` → `data/guides.sqlite` 빌드. + +청크 자체는 사용자 + Claude 의 인터랙티브 큐레이션 결과(jsonl). 본 모듈은 그 +파일들을 읽어 sqlite/FTS5 인덱스에 적재하고 manifest 를 갱신할 뿐, PDF 청킹 +로직은 수행하지 않는다. + +manifest(`data/guides_meta.json`): + { + "doc_id": { + "doc_title": ..., "doc_date": ..., + "source_pdf": ..., "chunks_count": N + }, ... + "_built_at": "YYYY-MM-DD HH:MM:SS", + "_total_chunks": N + } + +실행: `kpaa build-guides` (cli.py 의 핸들러가 이 모듈을 호출). +""" +from __future__ import annotations + +import json +import logging +import sys +from datetime import datetime +from pathlib import Path + +from kpaa.guides.index import ( + build_index, + chunks_dir, + default_db_path, + default_meta_path, +) +from kpaa.guides.models import GuideChunk + +logger = logging.getLogger("kpaa.guides.builder") + +_STALE_MONTHS = 18 + + +def _load_jsonl(path: Path) -> list[GuideChunk]: + out: list[GuideChunk] = [] + with path.open("r", encoding="utf-8") as f: + for ln, line in enumerate(f, 1): + line = line.strip() + if not line: + continue + try: + row = json.loads(line) + except json.JSONDecodeError as e: + raise RuntimeError(f"{path.name}:{ln} JSON parse error: {e}") from e + try: + out.append(GuideChunk.model_validate(row)) + except Exception as e: + raise RuntimeError(f"{path.name}:{ln} schema error: {e}") from e + return out + + +def _months_since(doc_date: str) -> int | None: + """'2024.10' → 현재 시점 기준 경과 개월. parse 실패면 None.""" + if not doc_date: + return None + parts = doc_date.split(".") + try: + year = int(parts[0]) + month = int(parts[1]) if len(parts) > 1 else 6 + except (ValueError, IndexError): + return None + now = datetime.now() + return (now.year - year) * 12 + (now.month - month) + + +def build(*, db_path: Path | None = None, meta_path: Path | None = None) -> int: + """`data/guide/chunks/*.jsonl` 전부 읽어 sqlite 빌드. 청크 총 개수 반환.""" + cdir = chunks_dir() + cdir.mkdir(parents=True, exist_ok=True) + files = sorted(cdir.glob("*.jsonl")) + + db = db_path or default_db_path() + meta = meta_path or default_meta_path() + + all_chunks: list[GuideChunk] = [] + manifest: dict = {} + + if not files: + logger.warning( + "%s 에 청크 jsonl 이 없습니다. `kpaa extract-guide` 후 인터랙티브 " + "청킹으로 jsonl 을 먼저 작성하세요.", + cdir, + ) + # 빈 인덱스로라도 빌드 — 회귀 안전망 (검색은 0건 반환) + build_index([], db_path=db) + manifest["_built_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + manifest["_total_chunks"] = 0 + meta.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") + return 0 + + for f in files: + chunks = _load_jsonl(f) + if not chunks: + logger.warning("%s 비어 있음 — skip", f.name) + continue + first = chunks[0] + # doc_id 일관성 검증 + for c in chunks: + if c.doc_id != first.doc_id: + raise RuntimeError( + f"{f.name} 안에서 doc_id 가 섞여 있습니다: " + f"{first.doc_id!r} vs {c.doc_id!r}" + ) + all_chunks.extend(chunks) + manifest[first.doc_id] = { + "doc_title": first.doc_title, + "doc_date": first.doc_date, + "source_pdf": first.source_pdf, + "chunks_count": len(chunks), + } + # 18개월 경과 경고 + age = _months_since(first.doc_date) + if age is not None and age > _STALE_MONTHS: + print( + f"⚠ {first.doc_title} ({first.doc_date}) — 발간 {age}개월 경과. " + f"PIPC 최신본 확인 권장.", + file=sys.stderr, + ) + + build_index(all_chunks, db_path=db) + + manifest["_built_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + manifest["_total_chunks"] = len(all_chunks) + meta.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") + + print( + f"✓ guides.sqlite 빌드 완료: {len(all_chunks)} 청크, " + f"{len(files)} 문서 → {db}", + file=sys.stderr, + ) + return len(all_chunks) + + +__all__ = ["build"] diff --git a/src/kpaa/guides/extractor.py b/src/kpaa/guides/extractor.py new file mode 100644 index 0000000000000000000000000000000000000000..1eb9dbba7f29613e494b591e6eb5174bd1f99d17 --- /dev/null +++ b/src/kpaa/guides/extractor.py @@ -0,0 +1,157 @@ +"""docling 으로 PDF → 원시 markdown 추출만 담당 (청킹은 인터랙티브 단계). + +청크 경계 결정은 사용자 + Claude 의 대화로 진행되므로, 이 모듈은 'PDF 한 권을 +가능한 한 충실한 markdown 으로 변환' 까지만 책임진다. 결과는 작업용 파일로 +`data/guide/extracted/{doc_id}.md` 에 떨어지고, Claude 가 다음 세션에서 이 +파일을 읽어 청크 후보를 사용자에게 제안한다. + +페이지 마커 — docling 의 `page_break_placeholder` 옵션을 사용해 +`` 마커를 페이지 경계마다 삽입. 청킹 시 각 청크의 시작/끝 +페이지를 추적해 `pages` 메타로 기록하기 위함. + +옵트인 의존성: `pip install -e ".[pipc-ocr]"` (docling). PIPC 별지 OCR 과 같은 +extra 를 재사용하지만 본 모듈은 OCR 을 *끄고* 동작 — privacy.go.kr 안내서는 +born-digital 이라 텍스트 직접 추출이 가능하다. + +추출 결과가 500자 미만이면 RuntimeError → 운영자가 PDF 가 스캔본인지 직접 +확인하도록 강제 (은밀한 OCR 폴백으로 인덱스를 오염시키지 않는다). +""" +from __future__ import annotations + +import logging +from pathlib import Path + +logger = logging.getLogger("kpaa.guides.extractor") + +_MIN_CHARS = 500 + +# 페이지 경계 마커. 청킹 시 청크의 시작/끝 페이지 추적에 사용. +# 형식: '' (N = 1-based page number). +PAGE_MARKER_PREFIX = "" + +_converter = None # lazy singleton + + +def _get_converter(): + """docling DocumentConverter (lazy + singleton, OCR off). + + PIPC 별지용 컨버터(retrieval/pipc_annex.py)는 EasyOCR 를 켜는 반면, + 안내서 PDF 는 born-digital 이라 do_ocr=False 로 충분하고 빠르다. + """ + global _converter + if _converter is not None: + return _converter + try: + from docling.datamodel.base_models import InputFormat + from docling.datamodel.pipeline_options import PdfPipelineOptions + from docling.document_converter import DocumentConverter, PdfFormatOption + except ImportError as e: + raise RuntimeError( + "docling 이 설치돼 있지 않습니다. `pip install -e \".[pipc-ocr]\"` " + "로 설치 후 다시 실행하세요." + ) from e + + pdf_opts = PdfPipelineOptions() + pdf_opts.do_ocr = False + pdf_opts.do_table_structure = True + + _converter = DocumentConverter( + format_options={ + InputFormat.PDF: PdfFormatOption(pipeline_options=pdf_opts), + } + ) + return _converter + + +def extract(pdf_path: Path) -> str: + """PDF → markdown 문자열. 페이지 경계마다 `` 마커 삽입. + + Raises: + RuntimeError: docling 미설치 / 추출 실패 / 500자 미만. + """ + if not pdf_path.exists(): + raise FileNotFoundError(pdf_path) + conv = _get_converter() + try: + result = conv.convert(str(pdf_path)) + except Exception as e: + raise RuntimeError(f"docling 변환 실패 ({pdf_path.name}): {e}") from e + + doc = result.document + # 페이지별로 export 해서 페이지 번호 마커를 사이에 삽입. + # docling 자체 page_break_placeholder 는 동일 문자열만 끼워줘서 페이지 번호가 + # 명시되지 않으므로, 페이지별 export 결과를 직접 합친다. + parts: list[str] = [] + for pno in sorted(doc.pages.keys()): + page_md = doc.export_to_markdown(page_no=pno).strip() + if not page_md: + continue + parts.append(f"{PAGE_MARKER_PREFIX}{pno}{PAGE_MARKER_SUFFIX}") + parts.append(page_md) + md = "\n\n".join(parts).strip() + + if len(md) < _MIN_CHARS: + raise RuntimeError( + f"PDF 추출 결과가 {len(md)}자 (< {_MIN_CHARS}). " + f"스캔본일 가능성 — `{pdf_path.name}` 확인 후 별도 OCR 처리 필요." + ) + return md + + +def derive_doc_id(pdf_filename: str) -> str: + """파일명 → 짧은 슬러그. + + 예: '★개인정보의 안전성 확보조치 기준 안내서(2024.10).pdf' → + '안전성_확보조치_안내서' + 예: '1. 개인정보 질의응답 모음집(2025.12.).pdf' → '질의응답_모음집' + """ + name = pdf_filename + if name.lower().endswith(".pdf"): + name = name[:-4] + # 1) 선행 번호 prefix 제거 ("1. ", "2026 ") + import re + name = re.sub(r"^[★☆\d\s.\-_]+", "", name).strip() + # 2) 끝의 (YYYY.MM) / (YYYY.MM.) / (YYYY) 제거 + name = re.sub(r"\([\d.\s]+\)\s*$", "", name).strip() + # 3) "개인정보의 ", "개인정보 ", "고정형 " 같은 흔한 접두 제거 + for prefix in ("개인정보의 ", "개인정보 ", "고정형 "): + if name.startswith(prefix): + name = name[len(prefix):] + break + # 4) 공백 → underscore + name = re.sub(r"\s+", "_", name).strip("_") + return name or "guide" + + +def derive_doc_date(pdf_filename: str) -> str: + """파일명 끝의 (YYYY.MM) 또는 (YYYY) 추출. 없으면 빈 문자열.""" + import re + m = re.search(r"\((\d{4})(?:[.\-](\d{1,2}))?[.\s]*\)", pdf_filename) + if not m: + # "2026 개인정보 처리방침..." 같이 prefix 에 연도만 있는 경우 + m2 = re.search(r"(?:^|\s)(\d{4})(?:\s|$)", pdf_filename) + if m2: + return m2.group(1) + return "" + year = m.group(1) + month = m.group(2) + if month: + return f"{year}.{int(month):02d}" + return year + + +def derive_doc_title(pdf_filename: str) -> str: + """파일명 → 사람이 읽을 한국어 제목 (★, 번호, 확장자 제거).""" + name = pdf_filename + if name.lower().endswith(".pdf"): + name = name[:-4] + import re + name = re.sub(r"^[★☆]+\s*", "", name) + name = re.sub(r"^\d+\.\s*", "", name) + # 끝의 괄호 날짜 제거 + name = re.sub(r"\s*\([\d.\s]+\)\s*$", "", name).strip() + return name + + +__all__ = ["extract", "derive_doc_id", "derive_doc_date", "derive_doc_title"] diff --git a/src/kpaa/guides/index.py b/src/kpaa/guides/index.py new file mode 100644 index 0000000000000000000000000000000000000000..f2c1c90b634cf9df983fcfc06452f49c36d27383 --- /dev/null +++ b/src/kpaa/guides/index.py @@ -0,0 +1,241 @@ +"""SQLite FTS5 인덱스 — PIPC 발간 안내서 청크용. + +cases/index.py와 동일한 패턴(unicode61 tokenizer, BM25, 임베딩 없음). + +DB 스키마: + guides(chunk_id PK, doc_id, doc_title, doc_date, section, + chunk_no, body, source_pdf) + guides_fts (FTS5 가상테이블, 검색용 — title 컬럼은 doc_title+section 합성) +""" +from __future__ import annotations + +import sqlite3 +from collections.abc import Iterable +from pathlib import Path + +from kpaa.guides.models import GuideChunk + +SCHEMA_SQL = """ +CREATE TABLE IF NOT EXISTS guides ( + chunk_id TEXT PRIMARY KEY, + doc_id TEXT, + doc_title TEXT, + doc_date TEXT, + section TEXT, + chunk_no INTEGER, + body TEXT, + pages TEXT, + source_pdf TEXT, + chunk_context TEXT +); + +CREATE VIRTUAL TABLE IF NOT EXISTS guides_fts USING fts5( + chunk_id UNINDEXED, + title, + body, + section UNINDEXED, + tokenize = "unicode61 remove_diacritics 2" +); +""" + + +def default_db_path() -> Path: + return _data_dir() / "guides.sqlite" + + +def default_meta_path() -> Path: + return _data_dir() / "guides_meta.json" + + +def chunks_dir() -> Path: + return _data_dir() / "guide" / "chunks" + + +def extracted_dir() -> Path: + return _data_dir() / "guide" / "extracted" + + +def _data_dir() -> Path: + repo_data = Path(__file__).resolve().parents[3] / "data" + if repo_data.exists(): + return repo_data + from kpaa.config import get_settings + + p = get_settings().cache_root / "guides" + p.mkdir(parents=True, exist_ok=True) + return p + + +def _connect(db_path: Path) -> sqlite3.Connection: + db_path.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(db_path) + conn.row_factory = sqlite3.Row + conn.executescript(SCHEMA_SQL) + return conn + + +def _fts_title(c: GuideChunk) -> str: + """FTS title 컬럼: 문서명 + 섹션명 합성으로 매칭률 ↑.""" + if c.section: + return f"{c.doc_title} {c.section}" + return c.doc_title + + +def _fts_body(c: GuideChunk) -> str: + """FTS body 컬럼: chunk_context가 있으면 body 앞에 prepend (Anthropic Contextual Retrieval).""" + if c.chunk_context: + return f"{c.chunk_context}\n\n{c.body}" + return c.body + + +def build_index(chunks: Iterable[GuideChunk], *, db_path: Path) -> None: + """전체 다시 빌드 — wipe 후 재삽입.""" + conn = _connect(db_path) + try: + with conn: + conn.execute("DELETE FROM guides") + conn.execute("DELETE FROM guides_fts") + for c in chunks: + conn.execute( + """INSERT INTO guides + (chunk_id, doc_id, doc_title, doc_date, + section, chunk_no, body, pages, source_pdf, chunk_context) + VALUES (?,?,?,?,?,?,?,?,?,?)""", + ( + c.chunk_id, c.doc_id, c.doc_title, c.doc_date, + c.section, c.chunk_no, c.body, c.pages, c.source_pdf, + c.chunk_context, + ), + ) + conn.execute( + "INSERT INTO guides_fts (chunk_id, title, body, section) VALUES (?,?,?,?)", + (c.chunk_id, _fts_title(c), _fts_body(c), c.section), + ) + conn.execute("VACUUM") + finally: + conn.close() + + +def search( + query: str, *, k: int = 5, db_path: Path | None = None +) -> list[GuideChunk]: + """FTS5 검색 (cases.search 패턴 그대로: prefix matching + 다중 토큰 OR).""" + path = db_path or default_db_path() + if not path.exists(): + return [] + if not query.strip(): + return [] + conn = _connect(path) + try: + safe = "".join(ch if ch.isalnum() or ord(ch) > 127 else " " for ch in query) + tokens = [t for t in safe.split() if t] + if not tokens: + return [] + if len(tokens) == 1: + fts_query = f"{tokens[0]}*" + else: + fts_query = " OR ".join(f"{t}*" for t in tokens) + cur = conn.execute( + """SELECT g.* FROM guides_fts f + JOIN guides g ON g.chunk_id = f.chunk_id + WHERE guides_fts MATCH ? + ORDER BY bm25(guides_fts) ASC + LIMIT ?""", + (fts_query, k), + ) + rows = cur.fetchall() + finally: + conn.close() + return [ + GuideChunk( + chunk_id=r["chunk_id"], doc_id=r["doc_id"], + doc_title=r["doc_title"], doc_date=r["doc_date"], + section=r["section"], chunk_no=r["chunk_no"], + body=r["body"], pages=r["pages"] or "", + source_pdf=r["source_pdf"], + chunk_context=(r["chunk_context"] if "chunk_context" in r.keys() else "") or "", + ) + for r in rows + ] + + +def count(db_path: Path | None = None) -> int: + path = db_path or default_db_path() + if not path.exists(): + return 0 + conn = _connect(path) + try: + cur = conn.execute("SELECT COUNT(*) FROM guides") + return cur.fetchone()[0] + finally: + conn.close() + + +def list_all_chunks(*, db_path: Path | None = None) -> list[GuideChunk]: + path = db_path or default_db_path() + if not path.exists(): + return [] + conn = _connect(path) + try: + cur = conn.execute("SELECT * FROM guides ORDER BY doc_id, chunk_no") + rows = cur.fetchall() + finally: + conn.close() + return [ + GuideChunk( + chunk_id=r["chunk_id"], doc_id=r["doc_id"], + doc_title=r["doc_title"], doc_date=r["doc_date"], + section=r["section"], chunk_no=r["chunk_no"], + body=r["body"], pages=r["pages"] or "", + source_pdf=r["source_pdf"], + chunk_context=(r["chunk_context"] if "chunk_context" in r.keys() else "") or "", + ) + for r in rows + ] + + +def rebuild_fts(*, db_path: Path | None = None) -> int: + """guides 테이블 데이터로 guides_fts만 재생성.""" + path = db_path or default_db_path() + chunks = list_all_chunks(db_path=path) + if not chunks: + return 0 + conn = _connect(path) + try: + with conn: + conn.execute("DROP TABLE IF EXISTS guides_fts") + finally: + conn.close() + conn = _connect(path) + try: + with conn: + for c in chunks: + conn.execute( + "INSERT INTO guides_fts (chunk_id, title, body, section) VALUES (?,?,?,?)", + (c.chunk_id, _fts_title(c), _fts_body(c), c.section), + ) + conn.execute("VACUUM") + finally: + conn.close() + return len(chunks) + + +def doc_count(db_path: Path | None = None) -> int: + """고유 doc_id 개수 — meta 표시용.""" + path = db_path or default_db_path() + if not path.exists(): + return 0 + conn = _connect(path) + try: + cur = conn.execute("SELECT COUNT(DISTINCT doc_id) FROM guides") + return cur.fetchone()[0] + finally: + conn.close() + + +__all__ = [ + "build_index", "search", "count", "doc_count", + "list_all_chunks", "rebuild_fts", + "default_db_path", "default_meta_path", + "chunks_dir", "extracted_dir", +] diff --git a/src/kpaa/guides/models.py b/src/kpaa/guides/models.py new file mode 100644 index 0000000000000000000000000000000000000000..21353dfc9f7d85e330bd45eecfd8e546feb1e839 --- /dev/null +++ b/src/kpaa/guides/models.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + + +class GuideChunk(BaseModel): + """PIPC 발간 안내서 1청크. + + `data/guide/chunks/{doc_id}.jsonl`의 한 줄과 1:1 매핑. + 인터랙티브 큐레이션(사용자 + Claude)으로 생성된 canonical chunk source. + """ + + model_config = ConfigDict(frozen=True) + + chunk_id: str # "{doc_id}_{chunk_no:04d}" + doc_id: str # "안전성_확보조치_안내서" + doc_title: str # "개인정보의 안전성 확보조치 기준 안내서" + doc_date: str # "2024.10" (YYYY.MM) + section: str # "II.3 접근권한 관리" + chunk_no: int # 0부터 순번 + body: str # 청크 본문 (≤1500자) + pages: str = "" # 원본 PDF 페이지 범위 (예: "p.8" 또는 "p.8-9") + source_pdf: str # 원본 PDF 파일명 + chunk_context: str = "" # Anthropic Contextual Retrieval prefix — 인덱싱 시 body 앞에 prepend (답변 출력은 body만) + + def citation(self) -> str: + """답변에 박을 인용 태그. + + 예: '안전성 확보조치 안내서 §II.3 접근권한 관리, 2024.10, p.8-9' + """ + short = self.doc_title + # 흔한 접두 표현 제거해 짧게 + for prefix in ("개인정보의 ", "개인정보 ", "고정형 "): + if short.startswith(prefix): + short = short[len(prefix):] + break + bits = [short] + if self.section: + bits.append(f"§{self.section}") + out = " ".join(bits) + tail: list[str] = [] + if self.doc_date: + tail.append(self.doc_date) + if self.pages: + tail.append(self.pages) + if tail: + out = f"{out}, {', '.join(tail)}" + return out diff --git a/src/kpaa/law_api/__init__.py b/src/kpaa/law_api/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..d878d4ced21e06497276e66ac4876c0f5e5776c4 --- /dev/null +++ b/src/kpaa/law_api/__init__.py @@ -0,0 +1,362 @@ +"""한국 법제처 OPEN API의 Python SDK. + +`korean-law-mcp`(MCP 서버)의 도구 표면을 참조하되, MCP 프로토콜 의존 없이 +일반 Python 라이브러리로 사용한다. + + async with KoreanLawClient(oc="my_id") as client: + hits = await client.law.search("개인정보보호법") + text = await client.law.get_text(mst=hits[0].mst, jo="15") + decisions = await client.pipc.search_decisions("동의 철회") + # 미구현 카테고리도 generic raw 호출 가능 + rows = await client.raw.search("ftc", query="과징금") + +라이브 검증 / 라이선스 / 미확정 target 정보는 `endpoints.py`를 참조. +""" +from __future__ import annotations + +from typing import Any + +from kpaa.law_api.client import LawApiCall, LawGoKrClient + +# ───────────────────────── 네임스페이스 ───────────────────────── + +class _LawNamespace: + def __init__(self, c: LawGoKrClient) -> None: + self._c = c + + async def search(self, query: str, *, display: int = 20): + from kpaa.law_api.law import search_law + + return await search_law(self._c, query=query, display=display) + + async def get_text(self, *, mst: str, jo: str | int | None = None): + from kpaa.law_api.law import get_law_text + + return await get_law_text(self._c, mst=mst, jo=jo) + + async def get_article(self, *, mst: str, article_no: str | int): + from kpaa.law_api.law import get_article_detail + + return await get_article_detail(self._c, mst=mst, article_no=article_no) + + async def get_annexes( + self, *, related_law_id: str | None = None, query: str = "", display: int = 20 + ): + from kpaa.law_api.law import get_annexes + + return await get_annexes( + self._c, related_law_id=related_law_id, query=query, display=display + ) + + +class _PIPCNamespace: + def __init__(self, c: LawGoKrClient) -> None: + self._c = c + + async def search_decisions(self, query: str, *, display: int = 20): + from kpaa.law_api.pipc import search_decisions + + return await search_decisions(self._c, query=query, display=display) + + async def get_decision_text(self, *, decision_id: str): + from kpaa.law_api.pipc import get_decision_text + + return await get_decision_text(self._c, decision_id=decision_id) + + +class _InterpretationNamespace: + def __init__(self, c: LawGoKrClient) -> None: + self._c = c + + async def search(self, query: str, *, display: int = 20): + from kpaa.law_api.interpretation import search_interpretations + + return await search_interpretations(self._c, query=query, display=display) + + async def get_text(self, *, interpretation_id: str): + from kpaa.law_api.interpretation import get_interpretation_text + + return await get_interpretation_text(self._c, interpretation_id=interpretation_id) + + +class _AdminRuleNamespace: + def __init__(self, c: LawGoKrClient) -> None: + self._c = c + + async def search(self, query: str, *, knd: int | None = None, display: int = 20): + from kpaa.law_api.admin_rule import search_admin_rules + + return await search_admin_rules(self._c, query=query, knd=knd, display=display) + + async def get_text(self, *, rule_id: str): + from kpaa.law_api.admin_rule import get_admin_rule_text + + return await get_admin_rule_text(self._c, rule_id=rule_id) + + +class _PrecedentNamespace: + def __init__(self, c: LawGoKrClient) -> None: + self._c = c + + async def search(self, query: str, *, display: int = 20): + from kpaa.law_api.precedent import search_precedents + + return await search_precedents(self._c, query=query, display=display) + + async def get_text(self, *, precedent_id: str): + from kpaa.law_api.precedent import get_precedent_text + + return await get_precedent_text(self._c, precedent_id=precedent_id) + + +class _OrdinanceNamespace: + def __init__(self, c: LawGoKrClient) -> None: + self._c = c + + async def search(self, query: str, *, display: int = 20): + from kpaa.law_api.ordinance import search_ordinances + + return await search_ordinances(self._c, query=query, display=display) + + async def get_text(self, *, ordinance_id: str): + from kpaa.law_api.ordinance import get_ordinance_text + + return await get_ordinance_text(self._c, ordinance_id=ordinance_id) + + +class _TreatyNamespace: + def __init__(self, c: LawGoKrClient) -> None: + self._c = c + + async def search(self, query: str, *, display: int = 20): + from kpaa.law_api.treaty import search_treaties + + return await search_treaties(self._c, query=query, display=display) + + async def get_text(self, *, treaty_id: str): + from kpaa.law_api.treaty import get_treaty_text + + return await get_treaty_text(self._c, treaty_id=treaty_id) + + +class _FTCNamespace: + def __init__(self, c: LawGoKrClient) -> None: + self._c = c + + async def search(self, query: str, *, display: int = 20): + from kpaa.law_api.ftc import search_ftc_decisions + + return await search_ftc_decisions(self._c, query=query, display=display) + + async def get_text(self, *, decision_id: str): + from kpaa.law_api.ftc import get_ftc_decision_text + + return await get_ftc_decision_text(self._c, decision_id=decision_id) + + +class _NLRCNamespace: + def __init__(self, c: LawGoKrClient) -> None: + self._c = c + + async def search(self, query: str, *, display: int = 20): + from kpaa.law_api.nlrc import search_nlrc_decisions + + return await search_nlrc_decisions(self._c, query=query, display=display) + + async def get_text(self, *, decision_id: str): + from kpaa.law_api.nlrc import get_nlrc_decision_text + + return await get_nlrc_decision_text(self._c, decision_id=decision_id) + + +class _ACRNamespace: + def __init__(self, c: LawGoKrClient) -> None: + self._c = c + + async def search(self, query: str, *, display: int = 20): + from kpaa.law_api.acr import search_acr_decisions + + return await search_acr_decisions(self._c, query=query, display=display) + + async def get_text(self, *, decision_id: str): + from kpaa.law_api.acr import get_acr_decision_text + + return await get_acr_decision_text(self._c, decision_id=decision_id) + + +class _TermsNamespace: + def __init__(self, c: LawGoKrClient) -> None: + self._c = c + + async def search(self, query: str, *, display: int = 20): + from kpaa.law_api.terms import search_legal_terms + + return await search_legal_terms(self._c, query=query, display=display) + + async def get_detail(self, *, term_id: str): + from kpaa.law_api.terms import get_legal_term_detail + + return await get_legal_term_detail(self._c, term_id=term_id) + + +class _EnglishNamespace: + def __init__(self, c: LawGoKrClient) -> None: + self._c = c + + async def search(self, query: str, *, display: int = 20): + from kpaa.law_api.english import search_english_laws + + return await search_english_laws(self._c, query=query, display=display) + + async def get_text(self, *, mst: str): + from kpaa.law_api.english import get_english_law_text + + return await get_english_law_text(self._c, mst=mst) + + +class _ArticleHistoryNamespace: + def __init__(self, c: LawGoKrClient) -> None: + self._c = c + + async def search( + self, + *, + law_id: str, + jo: str | int | None = None, + from_date: str | None = None, + to_date: str | None = None, + on_date: str | None = None, + org: str | None = None, + page: int = 1, + ): + from kpaa.law_api.article_history import search_article_history + + return await search_article_history( + self._c, + law_id=law_id, + jo=jo, + from_date=from_date, + to_date=to_date, + on_date=on_date, + org=org, + page=page, + ) + + +class _ConstitutionalNamespace: + def __init__(self, c: LawGoKrClient) -> None: + self._c = c + + async def search( + self, + query: str, + *, + display: int = 20, + case_number: str | None = None, + sort: str | None = None, + ): + from kpaa.law_api.constitutional import search_constitutional_decisions + + return await search_constitutional_decisions( + self._c, + query=query, + display=display, + case_number=case_number, + sort=sort, + ) + + async def get_text(self, *, decision_id: str): + from kpaa.law_api.constitutional import get_constitutional_decision_text + + return await get_constitutional_decision_text( + self._c, decision_id=decision_id + ) + + +class _OldNewNamespace: + def __init__(self, c: LawGoKrClient) -> None: + self._c = c + + async def search(self, query: str, *, display: int = 20): + from kpaa.law_api.oldnew import search_old_new + + return await search_old_new(self._c, query=query, display=display) + + async def compare(self, *, mst: str): + from kpaa.law_api.oldnew import compare_old_new + + return await compare_old_new(self._c, mst=mst) + + +class _RawNamespace: + """Generic 호출 — 풀구현된 카테고리 외 임의 target에 사용.""" + + def __init__(self, c: LawGoKrClient) -> None: + self._c = c + + async def search( + self, + target: str, + *, + query: str = "", + display: int = 20, + extra_params: dict[str, Any] | None = None, + ): + from kpaa.law_api.raw import raw_search + + return await raw_search( + self._c, target, query=query, display=display, extra_params=extra_params + ) + + async def get( + self, + target: str, + *, + id: str | None = None, + mst: str | None = None, + extra_params: dict[str, Any] | None = None, + ) -> str: + from kpaa.law_api.raw import raw_get + + return await raw_get( + self._c, target, id=id, mst=mst, extra_params=extra_params + ) + + +# ───────────────────────── 진입점 ───────────────────────── + +class KoreanLawClient: + """High-level 법제처 SDK. + + 카테고리별 네임스페이스로 노출 (라이브 검증된 15개 target 토큰 기반). + 미구현 카테고리는 `client.raw.search/get`으로 임의 target 호출 가능. + """ + + def __init__(self, oc: str | None = None) -> None: + self._client = LawGoKrClient(oc=oc) + self.law = _LawNamespace(self._client) + self.pipc = _PIPCNamespace(self._client) + self.interpretation = _InterpretationNamespace(self._client) + self.admin_rule = _AdminRuleNamespace(self._client) + self.precedent = _PrecedentNamespace(self._client) + self.ordinance = _OrdinanceNamespace(self._client) + self.treaty = _TreatyNamespace(self._client) + self.ftc = _FTCNamespace(self._client) + self.nlrc = _NLRCNamespace(self._client) + self.acr = _ACRNamespace(self._client) + self.terms = _TermsNamespace(self._client) + self.english = _EnglishNamespace(self._client) + self.old_new = _OldNewNamespace(self._client) + self.constitutional = _ConstitutionalNamespace(self._client) + self.article_history = _ArticleHistoryNamespace(self._client) + self.raw = _RawNamespace(self._client) + + async def __aenter__(self) -> KoreanLawClient: + await self._client.__aenter__() + return self + + async def __aexit__(self, *exc) -> None: + await self._client.__aexit__(*exc) + + +__all__ = ["KoreanLawClient", "LawGoKrClient", "LawApiCall"] diff --git a/src/kpaa/law_api/acr.py b/src/kpaa/law_api/acr.py new file mode 100644 index 0000000000000000000000000000000000000000..d1e74728250fed2bcd45dd4586caeab70eff5f06 --- /dev/null +++ b/src/kpaa/law_api/acr.py @@ -0,0 +1,45 @@ +"""국민권익위원회 결정 (target=acr). + +라이브 검증 row 필드: 결정문일련번호 / 제목 / 민원표시명 / 의안번호 / +회의종류 / 결정구분 / 의결일. +""" +from __future__ import annotations + +from kpaa.law_api.client import DETAIL_PATH, LIST_PATH, LawApiCall, LawGoKrClient +from kpaa.law_api.endpoints import ACR +from kpaa.law_api.parsers import child_text, parse_xml + + +async def search_acr_decisions( + client: LawGoKrClient, *, query: str, display: int = 20 +) -> list[dict[str, str]]: + if not 1 <= display <= 100: + raise ValueError("display must be in [1, 100]") + body = await client.fetch( + LawApiCall(target=ACR.target, path=LIST_PATH, + params={"query": query, "display": str(display)}, + cache_ttl=3600) + ) + root = parse_xml(body) + return [ + { + "decision_id": child_text(el, "결정문일련번호"), + "title": child_text(el, "제목"), + "subject": child_text(el, "민원표시명"), + "decision_no": child_text(el, "의안번호"), + "meeting_kind": child_text(el, "회의종류"), + "decision_kind": child_text(el, "결정구분"), + "decision_date": child_text(el, "의결일"), + } + for el in root.findall(f".//{ACR.row_tag}") + ] + + +async def get_acr_decision_text(client: LawGoKrClient, *, decision_id: str) -> str: + return await client.fetch( + LawApiCall(target=ACR.target, path=DETAIL_PATH, + params={"ID": str(decision_id)}, cache_ttl=24 * 3600) + ) + + +__all__ = ["search_acr_decisions", "get_acr_decision_text"] diff --git a/src/kpaa/law_api/admin_rule.py b/src/kpaa/law_api/admin_rule.py new file mode 100644 index 0000000000000000000000000000000000000000..2742b152016f2d878e5d494dd6fa849c7ef96579 --- /dev/null +++ b/src/kpaa/law_api/admin_rule.py @@ -0,0 +1,72 @@ +"""행정규칙 (훈령/예규/고시) — target=admrul. + +knd 파라미터: 1=훈령, 2=예규, 3=고시 (필터, 미지정시 전체). +챗봇은 개인정보보호위원회 고시(knd=3)에 관심. +""" +from __future__ import annotations + +from kpaa.law_api.client import DETAIL_PATH, LIST_PATH, LawApiCall, LawGoKrClient +from kpaa.law_api.endpoints import ADMRUL +from kpaa.law_api.parsers import child_text, parse_xml + +CACHE_TTL_SEARCH = 3600 +CACHE_TTL_DETAIL = 24 * 3600 + + +async def search_admin_rules( + client: LawGoKrClient, + *, + query: str, + knd: int | None = None, + display: int = 20, +) -> list[dict[str, str]]: + """행정규칙 목록 검색. + + Args: + knd: 1=훈령, 2=예규, 3=고시. None이면 전체. + """ + if not 1 <= display <= 100: + raise ValueError("display must be in [1, 100]") + params: dict[str, str] = {"query": query, "display": str(display)} + if knd is not None: + if knd not in (1, 2, 3): + raise ValueError("knd must be 1(훈령), 2(예규), or 3(고시)") + params["knd"] = str(knd) + call = LawApiCall( + target=ADMRUL.target, path=LIST_PATH, params=params, cache_ttl=CACHE_TTL_SEARCH + ) + body = await client.fetch(call) + root = parse_xml(body) + out: list[dict[str, str]] = [] + for el in root.findall(f".//{ADMRUL.row_tag}"): + out.append( + { + "rule_id": child_text(el, ADMRUL.id_field), + "name": child_text(el, "행정규칙명"), + "kind": child_text(el, "행정규칙종류"), + "issue_date": child_text(el, "발령일자"), + "issue_no": child_text(el, "발령번호"), + "department": child_text(el, "소관부처명"), + "status": child_text(el, "현행연혁구분"), + "amend_type": child_text(el, "제개정구분명"), + "enforce_date": child_text(el, "시행일자"), + } + ) + return out + + +async def get_admin_rule_text( + client: LawGoKrClient, + *, + rule_id: str, +) -> str: + """행정규칙 본문 — raw XML 반환.""" + call = LawApiCall( + target=ADMRUL.target, path=DETAIL_PATH, + params={"ID": str(rule_id)}, + cache_ttl=CACHE_TTL_DETAIL, + ) + return await client.fetch(call) + + +__all__ = ["search_admin_rules", "get_admin_rule_text"] diff --git a/src/kpaa/law_api/aliases.py b/src/kpaa/law_api/aliases.py new file mode 100644 index 0000000000000000000000000000000000000000..c57fa88a1a37371b108b3c7475c5cd1fef2746ea --- /dev/null +++ b/src/kpaa/law_api/aliases.py @@ -0,0 +1,254 @@ +"""법령 약칭 ↔ 정식명 매핑. + +법제처 OPEN API는 검색어로 *약칭*을 받으면 0건이 자주 나는 알려진 함정이 있다 +(예: "신용정보법" 직접 검색 → 빈 결과). 이 모듈은: + +1. 정식명 → 약칭 리스트(`LAW_ALIASES`)와 그 역방향(`ABBR_TO_OFFICIAL`)을 동시 제공. +2. 사용자 질의에서 약칭을 *정식명* 으로 치환(`normalize_law_name`). +3. 단일 쿼리를 *검색 후보 다중화*해 폴백 큐로 활용(`expand_search_terms`). + +쿼리 시점·LLM 라우터·rule fallback 모두 이 모듈을 import해 일관 처리한다. +원작 chrisryugj/korean-law-mcp `LAW_ALIAS_ENTRIES`(52종) 의 매핑 사실을 참고하되, +KPAA 도메인(개인정보·정보통신·금융·의료) 항목을 우선 보강했다. +""" +from __future__ import annotations + +import re +from functools import lru_cache + +# ──────────────────────────────────────────────────────────────────────────── +# 정식명 → 약칭 리스트 +# ──────────────────────────────────────────────────────────────────────────── +# 원칙: 정식 법령명(법제처 등록명)을 키로, 통용 약칭을 값으로. +# 약칭은 띄어쓰기·중점(·) 변형까지 포함해 매칭 누수를 줄인다. +# 도메인 분류는 주석으로만 남김 (코드는 단일 dict). +LAW_ALIASES: dict[str, list[str]] = { + # ── 개인정보 도메인 (KPAA 핵심) ────────────────────────────────────── + "개인정보 보호법": ["개인정보보호법", "개보법", "개인정보법"], + "개인정보 보호법 시행령": ["개인정보보호법 시행령", "개보법 시행령", "보호법 시행령"], + "개인정보 보호법 시행규칙": ["개인정보보호법 시행규칙", "개보법 시행규칙"], + "정보통신망 이용촉진 및 정보보호 등에 관한 법률": [ + "정보통신망법", + "정통망법", + "정보통신망 이용촉진법", + ], + "신용정보의 이용 및 보호에 관한 법률": ["신용정보법", "신정법"], + "위치정보의 보호 및 이용 등에 관한 법률": ["위치정보법"], + "통신비밀보호법": ["통비법"], + "전자정부법": ["전정법"], + "전자서명법": [], + "공공기관의 정보공개에 관한 법률": ["정보공개법", "정공법"], + "공공기관의 운영에 관한 법률": ["공공기관운영법", "공운법"], + "주민등록법": [], + "전자금융거래법": ["전금법"], + "특정 금융거래정보의 보고 및 이용 등에 관한 법률": ["특금법", "특정금융거래법"], + + # ── 의료·보건 (개인정보 민감정보 인접) ───────────────────────────── + "의료법": [], + "약사법": [], + "감염병의 예방 및 관리에 관한 법률": ["감염병예방법"], + "정신건강증진 및 정신질환자 복지서비스 지원에 관한 법률": ["정신건강복지법"], + "국민건강보험법": ["건강보험법", "국건법"], + "보건의료기본법": [], + + # ── 청소년·아동·교육 ──────────────────────────────────────────── + "아동복지법": [], + "청소년 보호법": ["청보법"], + "초·중등교육법": ["초중등교육법"], + "고등교육법": [], + "유아교육법": [], + + # ── 노무·고용 (채용 chain) ───────────────────────────────────────── + "근로기준법": ["근기법"], + "직업안정법": [], + "남녀고용평등과 일·가정 양립 지원에 관한 법률": ["남녀고용평등법"], + "산업안전보건법": ["산안법"], + "중대재해 처벌 등에 관한 법률": ["중처법", "중대재해처벌법"], + "기간제 및 단시간근로자 보호 등에 관한 법률": ["기간제법"], + "근로자퇴직급여 보장법": ["퇴직급여보장법"], + "산업재해보상보험법": ["산재보험법"], + "고용보험법": ["고보법"], + + # ── 금융·소비자 ─────────────────────────────────────────────── + "자본시장과 금융투자업에 관한 법률": ["자본시장법", "자금법"], + "약관의 규제에 관한 법률": ["약관법"], + "할부거래에 관한 법률": ["할부거래법"], + "전자상거래 등에서의 소비자보호에 관한 법률": ["전자상거래법", "전상법"], + + # ── 공정거래 ─────────────────────────────────────────────────── + "독점규제 및 공정거래에 관한 법률": ["공정거래법", "독규법"], + "하도급거래 공정화에 관한 법률": ["하도급법"], + "표시·광고의 공정화에 관한 법률": ["표시광고법"], + "가맹사업거래의 공정화에 관한 법률": ["가맹사업법"], + + # ── 부동산·임대차 ────────────────────────────────────────────── + "주택임대차보호법": ["주임법"], + "상가건물 임대차보호법": ["상임법"], + "부동산 거래신고 등에 관한 법률": ["부거법", "부동산거래신고법"], + + # ── 행정·일반 ────────────────────────────────────────────────── + "행정기본법": ["행기법"], + "행정절차법": ["행절법"], + "행정심판법": [], + "행정소송법": [], + "민원 처리에 관한 법률": ["민원처리법"], + + # ── 형사·민사 절차 ─────────────────────────────────────────── + "형법": [], + "민법": [], + "형사소송법": ["형소법"], + "민사소송법": ["민소법"], + "민사집행법": ["민집법"], + "상법": [], + + # ── 부패·청렴 ────────────────────────────────────────────────── + "부정청탁 및 금품등 수수의 금지에 관한 법률": ["청탁금지법", "김영란법"], + "공직자의 이해충돌 방지법": ["이해충돌방지법"], + "공익신고자 보호법": [], + + # ── 공무원 ──────────────────────────────────────────────────── + "국가공무원법": [], + "지방공무원법": ["지공법"], + + # ── 인권 ─────────────────────────────────────────────────────── + "국가인권위원회법": ["인권위법"], + + # ── 기타 KPAA-related ────────────────────────────────────────── + "지방공기업법": [], + "지능정보화 기본법": [], + "전기통신사업법": ["전사법"], +} + +# ──────────────────────────────────────────────────────────────────────────── +# 역인덱스: 약칭 → 정식명 (1:1) +# ──────────────────────────────────────────────────────────────────────────── +def _build_reverse() -> dict[str, str]: + """약칭 한 개가 두 개 이상의 정식명에 매핑되면 *나중에 등록된 것* 으로 덮어쓴다. + + 실제 LAW_ALIASES는 충돌이 없도록 큐레이션되어야 한다 (충돌은 의도적 선택). + """ + out: dict[str, str] = {} + for official, abbrevs in LAW_ALIASES.items(): + for ab in abbrevs: + ab_norm = ab.strip() + if not ab_norm: + continue + out[ab_norm] = official + return out + + +ABBR_TO_OFFICIAL: dict[str, str] = _build_reverse() + + +# ──────────────────────────────────────────────────────────────────────────── +# 매칭 유틸 +# ──────────────────────────────────────────────────────────────────────────── +@lru_cache(maxsize=1) +def _abbrev_pattern() -> re.Pattern[str]: + """모든 약칭을 한 번에 잡는 정규식. 긴 약칭부터 매칭(부분매칭 우선순위).""" + keys = sorted(ABBR_TO_OFFICIAL.keys(), key=len, reverse=True) + if not keys: + # 빈 사전이면 절대 매칭 안 되는 패턴 + return re.compile(r"(?!.*)") + escaped = [re.escape(k) for k in keys] + return re.compile("|".join(escaped)) + + +def normalize_law_name(text: str) -> str: + """텍스트 안의 *약칭 토큰* 을 정식 법령명으로 치환. + + 예시: + "신용정보법 위반 사례" → "신용정보의 이용 및 보호에 관한 법률 위반 사례" + "정통망법 제22조" → "정보통신망 이용촉진 및 정보보호 등에 관한 법률 제22조" + + 이미 정식명 형태면 그대로 반환. 약칭이 다른 단어의 부분문자열이면 매칭하지 않게 + 조심해야 하지만, 한국어 법률 약칭은 대부분 단어 경계 의미가 모호하므로 *최장 + 매칭 우선* 전략으로만 처리한다 (충분히 안전). + """ + if not text: + return text + pat = _abbrev_pattern() + return pat.sub(lambda m: ABBR_TO_OFFICIAL[m.group(0)], text) + + +def aliases_for(official_name: str) -> list[str]: + """정식명 → 약칭 리스트. 등록 안 됐으면 빈 리스트.""" + return list(LAW_ALIASES.get(official_name, [])) + + +def is_known_alias(token: str) -> bool: + """token 이 등록된 약칭인지.""" + return token.strip() in ABBR_TO_OFFICIAL + + +def official_name_for(abbrev: str) -> str | None: + """약칭 → 정식명. 등록 안 됐으면 None.""" + return ABBR_TO_OFFICIAL.get(abbrev.strip()) + + +# ──────────────────────────────────────────────────────────────────────────── +# 검색어 확장 (폴백 큐 생성용) +# ──────────────────────────────────────────────────────────────────────────── +_LAW_ABBR_SUFFIX_RE = re.compile(r"법$") + + +def expand_search_terms(query: str, *, max_variants: int = 5) -> list[str]: + """단일 쿼리를 *검색 폴백 후보들* 로 확장. + + 법제처 API 검색 미스가 잦은 케이스 대응 용도. 호출자는 반환된 후보들을 + 순서대로 시도해 첫 번째 비어있지 않은 결과를 채택한다. + + 확장 규칙: + 1) 원본 쿼리 (그대로) + 2) 약칭이 포함되었으면 *정식명 치환본* + 3) 본질 명사만 (예: "신용정보법" → "신용정보") + 4) 정식명에서 본질 명사만 (예: "...신용정보의 이용..." → "신용정보 이용") + 5) 약칭 자체 (정식명이 입력된 경우 역으로 약칭도 시도) + + 중복은 제거하되 *원본 우선* 순서를 보존. + """ + if not query.strip(): + return [] + seen: set[str] = set() + out: list[str] = [] + + def _push(t: str) -> None: + t = t.strip() + if t and t not in seen: + seen.add(t) + out.append(t) + + # (1) 원본 + _push(query) + + # (2) 약칭 → 정식명 치환 + normalized = normalize_law_name(query) + if normalized != query: + _push(normalized) + + # (3) "...법" 약칭에서 "법" 떼고 본질 명사만 (예: 신용정보법 → 신용정보) + for abbrev in ABBR_TO_OFFICIAL: + if abbrev in query: + stem = _LAW_ABBR_SUFFIX_RE.sub("", abbrev).strip() + if stem and stem != abbrev: + _push(query.replace(abbrev, stem)) + _push(stem) + + # (4) 약칭 자체도 후보로 (정식명이 직접 들어온 경우 등) + for official, abbrevs in LAW_ALIASES.items(): + if official in query and abbrevs: + for ab in abbrevs: + _push(query.replace(official, ab)) + + return out[:max_variants] + + +__all__ = [ + "LAW_ALIASES", + "ABBR_TO_OFFICIAL", + "normalize_law_name", + "aliases_for", + "is_known_alias", + "official_name_for", + "expand_search_terms", +] diff --git a/src/kpaa/law_api/article_history.py b/src/kpaa/law_api/article_history.py new file mode 100644 index 0000000000000000000000000000000000000000..1b3e7b567a06781329f953e80396c23f9932b412 --- /dev/null +++ b/src/kpaa/law_api/article_history.py @@ -0,0 +1,141 @@ +"""조문 변경 이력 (target=lsJoHstInf) — *list endpoint 전용*. + +라이브 검증 완료 (2026-05-01 LAW_OC 환경): + path = lawSearch.do + params: + - ID=<법령ID> (필수, MST 아님 주의) + - JO=<6자리코드> (선택) + - **fromRegDt + toRegDt** = YYYYMMDD (사실상 필수 — 없으면 0건 반환) + - regDt = 단일 일자 (선택) + - page = 페이지 (선택) + 응답 root → `` → `` 다수 + - `<법령정보>` → 법령명한글, 법령ID, 법령일련번호, 공포일자, 공포번호, + 제개정구분명, 소관부처명, 법령구분명, 시행일자 + - `<조문정보>` → `` 다수 + - 조문번호(6자리), 변경사유, 조문링크, 조문변경이력상세링크, + 조문개정일, 조문시행일 + +⚠️ 라이브 검증 시 발견한 quirk: + - fromRegDt+toRegDt 미지정 시 totalCnt=0 응답 (endpoint 가 무한 검색 거부) + - `` 가 `` 직속이 아니라 `<조문정보>` 아래에 중첩 + 이 두 가지를 코드가 흡수해 호출자는 *그냥 search* 만 하면 동작. + +KPAA 활용: `개정_이력` intent 매칭 + plan.jo_targets 명시 시 활성화. +oldnew(법령 단위 신구비교) 와는 *세분화 수준* 이 달라 함께 호출되어도 의미 있음. +""" +from __future__ import annotations + +from datetime import date, timedelta + +from kpaa.law_api.client import LIST_PATH, LawApiCall, LawGoKrClient +from kpaa.law_api.endpoints import LSJOHSTINF +from kpaa.law_api.jo import decode_jo, encode_jo +from kpaa.law_api.models import ArticleHistoryItem +from kpaa.law_api.parsers import child_text, parse_xml + +CACHE_TTL_LIST = 24 * 3600 # 24h — 개정 이력은 자주 안 바뀜 + +# 기간 default — endpoint 의 *기간 필터 필수* 특성 흡수용. KPAA 도메인은 *최근* +# 개정에 관심이 큼 → 기본 10년. 호출자가 명시하면 override. +_DEFAULT_LOOKBACK_DAYS = 365 * 10 + + +def _default_period() -> tuple[str, str]: + today = date.today() + past = today - timedelta(days=_DEFAULT_LOOKBACK_DAYS) + return past.strftime("%Y%m%d"), today.strftime("%Y%m%d") + + +async def search_article_history( + client: LawGoKrClient, + *, + law_id: str, + jo: str | int | None = None, + from_date: str | None = None, + to_date: str | None = None, + on_date: str | None = None, + org: str | None = None, + page: int = 1, +) -> list[ArticleHistoryItem]: + """조문 개정 이력 list 조회. + + Args: + law_id: 법령ID. **MST 아님 주의** — `client.law.get_text(mst=...)`의 + 응답 LawText.law_id 에서 얻을 수 있다. + jo: 조문 번호 ("15", "24의2", 15, …). encode_jo 로 6자리 코드 변환. + None 이면 모든 조문 이력. + on_date / from_date / to_date: YYYYMMDD 형식. 시점·기간 필터. + **from_date+to_date 미지정 시 자동으로 최근 10년 default 적용** + (endpoint quirk 흡수). + org: 소관부처코드. + page: 페이지 번호. + """ + if not law_id: + raise ValueError("law_id required (lsJoHstInf 는 lawId 기반)") + params: dict[str, str] = {"ID": str(law_id), "page": str(page)} + if jo is not None: + # encode_jo 는 이미 6자리면 그대로 통과. "15" → "001500" + params["JO"] = encode_jo(jo) + if on_date: + params["regDt"] = str(on_date) + elif not (from_date or to_date): + # 기간 미지정 + on_date 미지정 시 default 기간 박음 (endpoint quirk). + f, t = _default_period() + params["fromRegDt"] = f + params["toRegDt"] = t + if from_date: + params["fromRegDt"] = str(from_date) + if to_date: + params["toRegDt"] = str(to_date) + if org: + params["org"] = str(org) + + body = await client.fetch( + LawApiCall( + target=LSJOHSTINF.target, + path=LIST_PATH, + params=params, + cache_ttl=CACHE_TTL_LIST, + ) + ) + root = parse_xml(body) + + out: list[ArticleHistoryItem] = [] + for law_el in root.findall(".//law"): + info = law_el.find("법령정보") + if info is None: + continue + law_name = child_text(info, "법령명한글") + rid = child_text(info, "법령ID") or law_id + mst = child_text(info, "법령일련번호") + promul = child_text(info, "공포일자") + change_type = child_text(info, "제개정구분명") + enforce = child_text(info, "시행일자") + + # `` 는 `` 직속이 아니라 `<조문정보>` 아래 중첩 (라이브 검증). + # `.//jo` 로 descendant 매칭하여 양쪽 구조 다 흡수. + for jo_el in law_el.findall(".//jo"): + jo_code = child_text(jo_el, "조문번호") + try: + jo_decoded = decode_jo(jo_code) if jo_code and len(jo_code) == 6 else jo_code + except ValueError: + jo_decoded = jo_code + out.append( + ArticleHistoryItem( + law_name=law_name, + law_id=rid, + mst=mst, + article_no=jo_decoded, + article_jo_code=jo_code, + change_type=change_type, + change_reason=child_text(jo_el, "변경사유"), + article_amend_date=child_text(jo_el, "조문개정일"), + article_enforce_date=child_text(jo_el, "조문시행일"), + promulgate_date=promul, + enforce_date=enforce, + ) + ) + return out + + +__all__ = ["search_article_history"] diff --git a/src/kpaa/law_api/client.py b/src/kpaa/law_api/client.py new file mode 100644 index 0000000000000000000000000000000000000000..29407ea5362e2d456df0a8fa5e30033ca0d35e69 --- /dev/null +++ b/src/kpaa/law_api/client.py @@ -0,0 +1,154 @@ +"""법제처 OPEN API 베이스 HTTP 클라이언트. + +상위 모듈(`law.py`, `pipc.py`, …)이 `LawApiCall`을 만들어 `fetch()`로 보내고, +이 클래스는 인증·재시도·캐시·동시성 캡을 통일 처리한다. XML 파싱은 호출자에서 +`parsers.py`를 사용해 진행한다. +""" +from __future__ import annotations + +import asyncio +import hashlib +import json +import logging +from dataclasses import dataclass +from typing import Any + +import diskcache +import httpx +from tenacity import ( + AsyncRetrying, + retry_if_exception_type, + stop_after_attempt, + wait_exponential_jitter, +) + +from kpaa.config import get_settings + +logger = logging.getLogger("kpaa.law_api") + +BASE_URL = "https://www.law.go.kr/DRF" +LIST_PATH = "/lawSearch.do" +DETAIL_PATH = "/lawService.do" + +_DEFAULT_TIMEOUT = httpx.Timeout(30.0, connect=10.0) +_USER_AGENT = "kpaa/0.1 (+https://github.com/sz1-kca/korean-privacy-ai-assistant)" + +_RETRYABLE_STATUS = {408, 425, 429, 500, 502, 503, 504} + + +class _RetryableHTTPError(Exception): + """HTTP 상태가 일시적 오류군일 때 재시도 트리거용.""" + + +@dataclass(frozen=True) +class LawApiCall: + target: str # e.g. "law", "ppc", "expc", "prec", "admrul" + path: str # LIST_PATH or DETAIL_PATH + params: dict[str, Any] + cache_ttl: int # seconds (e.g. 3600 for search, 86400 for detail) + + +class LawGoKrClient: + def __init__( + self, + *, + oc: str | None = None, + base_url: str = BASE_URL, + timeout: httpx.Timeout = _DEFAULT_TIMEOUT, + max_concurrency: int = 4, + ) -> None: + s = get_settings() + self.oc = oc if oc is not None else s.law_oc + self.base_url = base_url + self.timeout = timeout + self._http: httpx.AsyncClient | None = None + self._sem = asyncio.Semaphore(max_concurrency) + self._cache = diskcache.Cache(str(s.law_cache_dir)) + + async def __aenter__(self) -> LawGoKrClient: + self._http = httpx.AsyncClient( + base_url=self.base_url, + timeout=self.timeout, + http2=True, + headers={"User-Agent": _USER_AGENT, "Accept": "*/*"}, + ) + return self + + async def __aexit__(self, *exc) -> None: + if self._http is not None: + await self._http.aclose() + self._http = None + + def _ensure_oc(self) -> str: + if not self.oc: + raise RuntimeError( + "법제처 OPEN API 인증 ID(LAW_OC)가 비어 있습니다. " + ".env 파일에 LAW_OC=<발급받은_id>를 입력하거나 " + "KoreanLawClient(oc=...)에 직접 전달하세요. " + "발급(무료): https://open.law.go.kr" + ) + return self.oc + + @staticmethod + def _cache_key(call: LawApiCall) -> str: + normalized = json.dumps( + { + "target": call.target, + "path": call.path, + "params": sorted(call.params.items()), + }, + ensure_ascii=False, + sort_keys=True, + ) + return hashlib.sha1(normalized.encode("utf-8")).hexdigest() + + async def fetch(self, call: LawApiCall) -> str: + if self._http is None: + raise RuntimeError("LawGoKrClient is not entered (use 'async with').") + + key = self._cache_key(call) + cached = self._cache.get(key) + if cached is not None: + return cached # type: ignore[return-value] + + params: dict[str, Any] = { + "OC": self._ensure_oc(), + "type": "XML", + "target": call.target, + **call.params, + } + + body: str | None = None + async with self._sem: + async for attempt in AsyncRetrying( + stop=stop_after_attempt(4), + wait=wait_exponential_jitter(initial=1, max=8), + retry=retry_if_exception_type( + (httpx.TransportError, httpx.TimeoutException, _RetryableHTTPError) + ), + reraise=True, + ): + with attempt: + resp = await self._http.get(call.path, params=params) + if resp.status_code in _RETRYABLE_STATUS: + raise _RetryableHTTPError( + f"law.go.kr returned {resp.status_code} for target={call.target}" + ) + resp.raise_for_status() + body = resp.text + + assert body is not None + # Sanity cap — *XML 안전한 byte 단순 절단은 불가능* (CDATA·태그 중간을 + # 잘라 파서가 깨짐 — 라이브 검증 2026-05-01: 시행령 mst=273745 응답이 + # 272K 라 이전 200K cap 에서 CDATA 미종료로 파싱 실패). 정책: *왜곡 없이 + # 전달* 우선이므로 절단 안 함. 대신 *비정상적으로 큰 응답*(>5MB)은 경고 + # 만 남기고 그대로 캐시 — 메모리 과사용 방어는 실용적 5MB 선에서. + if len(body) > 5_000_000: + logger.warning( + "law.go.kr response very large (%d chars) for target=%s — passing through anyway", + len(body), + call.target, + ) + + self._cache.set(key, body, expire=call.cache_ttl) + return body diff --git a/src/kpaa/law_api/constitutional.py b/src/kpaa/law_api/constitutional.py new file mode 100644 index 0000000000000000000000000000000000000000..000bb0cc80195524211e296f521e079b6c645c64 --- /dev/null +++ b/src/kpaa/law_api/constitutional.py @@ -0,0 +1,135 @@ +"""헌법재판소 결정문 (target=detc). + +라이브 검증 (원작 chrisryugj/korean-law-mcp 의 parseConstitutionalXML 매핑 기준): + list : `lawSearch.do?target=detc&query=...&type=XML` + root=`` row=`` (대문자 D) + 필드: 헌재결정례일련번호 / 사건명 / 사건번호 / 종국일자 / + 헌재결정례상세링크 + detail : `lawService.do?target=detc&ID=...&type=JSON` + JSON 컨테이너: `DetcService` 또는 `헌재결정례` + 필드: 사건명·사건번호·종국일자·청구인·피청구인· + 판시사항·결정요지·참조조문·참조판례·전문(판례내용/결정내용) + +KPAA 활용: data_subject_rights/definition/medical_health chain 에서 자기결정권· +기본권 헌재 판단 근거 보강. +""" +from __future__ import annotations + +import json + +from kpaa.law_api.client import DETAIL_PATH, LIST_PATH, LawApiCall, LawGoKrClient +from kpaa.law_api.endpoints import DETC +from kpaa.law_api.models import ConstitutionalDecisionHit, ConstitutionalDecisionText +from kpaa.law_api.parsers import child_text, parse_xml + +CACHE_TTL_SEARCH = 3600 # 1h +CACHE_TTL_DETAIL = 24 * 3600 # 24h + + +async def search_constitutional_decisions( + client: LawGoKrClient, + *, + query: str, + display: int = 20, + case_number: str | None = None, + sort: str | None = None, +) -> list[ConstitutionalDecisionHit]: + """헌재 결정 검색. + + Args: + query: 검색 키워드 (예: "자기결정권", "개인정보 자기결정권") + display: 1~100 + case_number: 사건번호로 직접 조회 시 (예: "2020헌바123") + sort: 정렬 — lasc/ldes/dasc/ddes/nasc/ndes + """ + if not 1 <= display <= 100: + raise ValueError("display must be in [1, 100]") + params: dict[str, str] = {"display": str(display)} + if query: + params["query"] = query + if case_number: + params["nb"] = case_number + if sort: + params["sort"] = sort + + body = await client.fetch( + LawApiCall( + target=DETC.target, + path=LIST_PATH, + params=params, + cache_ttl=CACHE_TTL_SEARCH, + ) + ) + root = parse_xml(body) + hits: list[ConstitutionalDecisionHit] = [] + for el in root.findall(f".//{DETC.row_tag}"): + hits.append( + ConstitutionalDecisionHit( + decision_id=child_text(el, DETC.id_field), + title=child_text(el, "사건명"), + case_no=child_text(el, "사건번호"), + decision_date=child_text(el, "종국일자"), + detail_url=child_text(el, "헌재결정례상세링크"), + ) + ) + return hits + + +async def get_constitutional_decision_text( + client: LawGoKrClient, + *, + decision_id: str, +) -> ConstitutionalDecisionText | None: + """헌재 결정문 본문 (JSON detail). + + 응답이 비어있거나 형식 이상이면 None. 호출자는 None 처리. + """ + if not decision_id: + raise ValueError("decision_id required") + # detail 응답은 JSON — params 의 type 키가 client.fetch 의 default("XML") 를 덮어씀. + body = await client.fetch( + LawApiCall( + target=DETC.target, + path=DETAIL_PATH, + params={"ID": str(decision_id), "type": "JSON"}, + cache_ttl=CACHE_TTL_DETAIL, + ) + ) + if not body or not body.strip(): + return None + try: + data = json.loads(body) + except json.JSONDecodeError: + return None + payload = data.get("DetcService") or data.get("헌재결정례") or {} + if not isinstance(payload, dict) or not payload: + return None + + return ConstitutionalDecisionText( + decision_id=str(decision_id), + title=str(payload.get("사건명", "") or ""), + case_no=str(payload.get("사건번호", "") or ""), + decision_date=str( + payload.get("종국일자") or payload.get("선고일자") or "" + ), + petitioner=str(payload.get("청구인", "") or ""), + respondent=str(payload.get("피청구인", "") or ""), + issues=str(payload.get("판시사항", "") or ""), + summary=str( + payload.get("결정요지") or payload.get("판결요지") or "" + ), + refs_law=str(payload.get("참조조문", "") or ""), + refs_precedent=str(payload.get("참조판례", "") or ""), + body=str( + payload.get("판례내용") + or payload.get("결정내용") + or payload.get("전문") + or "" + ), + ) + + +__all__ = [ + "search_constitutional_decisions", + "get_constitutional_decision_text", +] diff --git a/src/kpaa/law_api/endpoints.py b/src/kpaa/law_api/endpoints.py new file mode 100644 index 0000000000000000000000000000000000000000..cd723b69e161824e354c22d97a6321ced6935653 --- /dev/null +++ b/src/kpaa/law_api/endpoints.py @@ -0,0 +1,228 @@ +"""법제처 OPEN API의 target 토큰과 응답 row 태그 매핑. + +라이브 검증 완료 (2026-04-29): + https://www.law.go.kr/DRF/lawSearch.do?OC=...&target=&query=...&type=XML + https://www.law.go.kr/DRF/lawService.do?OC=...&target=&{ID|MST}=...&type=XML + +`detail_key`는 detail 호출에서 사용하는 1차 식별자 파라미터명이다: +- target=law → MST (법령일련번호) +- 그 외 → ID +""" +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class TargetSpec: + target: str # URL 파라미터 값 (예: "law", "ppc") + row_tag: str # 응답 XML의 1건 row 태그 (예: "law", "ppc") + id_field: str # row 안의 detail ID 필드명 (예: "법령일련번호") + title_field: str # row 안의 제목 필드명 + detail_key: str # detail 호출 파라미터명 ("MST" or "ID") + label: str # 사람용 한국어 라벨 + + +# 법령 (target=law) +LAW = TargetSpec( + target="law", + row_tag="law", + id_field="법령일련번호", + title_field="법령명한글", + detail_key="MST", + label="법령", +) + +# 개인정보보호위원회 결정문 (target=ppc) +PPC = TargetSpec( + target="ppc", + row_tag="ppc", + id_field="결정문일련번호", + title_field="안건명", + detail_key="ID", + label="개인정보보호위원회 결정", +) + +# 법령해석례 (target=expc) +EXPC = TargetSpec( + target="expc", + row_tag="expc", + id_field="법령해석례일련번호", + title_field="안건명", + detail_key="ID", + label="법령해석례", +) + +# 행정규칙(훈령/예규/고시) (target=admrul) +# knd 파라미터: 1=훈령, 2=예규, 3=고시 (chatbot은 개보위 고시 위주) +ADMRUL = TargetSpec( + target="admrul", + row_tag="admrul", + id_field="행정규칙일련번호", + title_field="행정규칙명", + detail_key="ID", + label="행정규칙", +) + +# 판례 (target=prec) +PREC = TargetSpec( + target="prec", + row_tag="prec", + id_field="판례일련번호", + title_field="사건명", + detail_key="ID", + label="판례", +) + +# 별표/별지서식 (target=licbyl) — `lawSearch.do`로 검색 +LICBYL = TargetSpec( + target="licbyl", + row_tag="licbyl", + id_field="별표일련번호", + title_field="별표명", + detail_key="ID", + label="별표서식", +) + +# 자치법규 (target=ordin) — row tag 가 "law" (재사용) +ORDIN = TargetSpec( + target="ordin", + row_tag="law", + id_field="자치법규일련번호", + title_field="자치법규명", + detail_key="ID", + label="자치법규", +) + +# 조약 (target=trty) +TRTY = TargetSpec( + target="trty", + row_tag="Trty", + id_field="조약일련번호", + title_field="조약명", + detail_key="ID", + label="조약", +) + +# 영문법령 (target=elaw) +ELAW = TargetSpec( + target="elaw", + row_tag="law", + id_field="법령일련번호", + title_field="법령명영문", + detail_key="MST", + label="영문법령", +) + +# 신구법비교 (target=oldAndNew) +OLDNEW = TargetSpec( + target="oldAndNew", + row_tag="oldAndNew", + id_field="법령일련번호", + title_field="법령명한글", + detail_key="MST", + label="신구법비교", +) + +# 시행일자별 법령 (target=eflaw) +EFLAW = TargetSpec( + target="eflaw", + row_tag="law", + id_field="법령일련번호", + title_field="법령명한글", + detail_key="MST", + label="시행일자별 법령", +) + +# 공정거래위원회 결정 (target=ftc) +FTC = TargetSpec( + target="ftc", + row_tag="ftc", + id_field="결정문일련번호", + title_field="사건명", + detail_key="ID", + label="공정거래위원회 결정", +) + +# 노동위원회 결정 (target=nlrc) +NLRC = TargetSpec( + target="nlrc", + row_tag="nlrc", + id_field="결정문일련번호", + title_field="제목", + detail_key="ID", + label="노동위원회 결정", +) + +# 국민권익위원회 결정 (target=acr) +ACR = TargetSpec( + target="acr", + row_tag="acr", + id_field="결정문일련번호", + title_field="제목", + detail_key="ID", + label="국민권익위원회 결정", +) + +# 법령용어 (target=lstrm) +LSTRM = TargetSpec( + target="lstrm", + row_tag="lstrm", + id_field="법령용어ID", + title_field="법령용어명", + detail_key="trmSeqs", # detail은 trmSeqs 파라미터 (복수 ID 전달 가능) + label="법령용어", +) + +# 헌법재판소 결정문 (target=detc) +# 라이브 검증 (원작 xml-parser 기반): 검색 응답 root=``, row tag=``. +# detail 응답은 *JSON* — `data.DetcService` 또는 `data.헌재결정례` 키 아래 본문. +# row 필드: 헌재결정례일련번호, 사건명, 사건번호, 종국일자, 헌재결정례상세링크. +DETC = TargetSpec( + target="detc", + row_tag="Detc", + id_field="헌재결정례일련번호", + title_field="사건명", + detail_key="ID", + label="헌법재판소 결정", +) + +# 조문 변경 이력 (target=lsJoHstInf) — *list endpoint 만 존재* (detail 없음). +# 라이브 검증 (원작 api-client.ts/article-history.ts 기준): +# path = lawSearch.do +# 필수 params: ID=<법령ID>, optional JO=<6자리코드>, regDt/fromRegDt/toRegDt, page +# 응답 구조: root → 다수 → <법령정보> + 다수 +# <법령정보>: 법령명한글 / 법령ID / 법령일련번호 / 공포일자 / 제개정구분명 / 시행일자 +# : 조문번호(6자리코드) / 변경사유 / 조문개정일 / 조문시행일 +# 주의: ID 는 *법령ID* (예: "011357") — *법령일련번호(MST)* 와 다르다. +LSJOHSTINF = TargetSpec( + target="lsJoHstInf", + row_tag="law", + id_field="법령ID", + title_field="법령명한글", + detail_key="ID", # detail endpoint 는 없지만 spec 일관성 위해 채워둠 + label="조문 변경 이력", +) + + +ALL: tuple[TargetSpec, ...] = ( + LAW, PPC, EXPC, ADMRUL, PREC, LICBYL, + ORDIN, TRTY, ELAW, OLDNEW, EFLAW, + FTC, NLRC, ACR, LSTRM, DETC, LSJOHSTINF, +) + + +# Day 3 시점에서 라이브 검증 실패 — 정확한 target 토큰 미확정. +# v1 SDK에는 미포함. 사용자 SDK 피드백으로 후속 보강. +PENDING: dict[str, str] = { + "헌재 결정문": "search_constitutional_decisions", + "행정심판": "search_admin_appeals", + "이의신청 결정": "search_appeal_review_decisions", + "조세심판": "search_tax_tribunal_decisions", + "관세 해석": "search_customs_interpretations", + "학교 학칙": "search_school_rules", + "공공기관 규정": "search_public_institution_rules", + "공기업 규정": "search_public_corp_rules", + "권익위 특별행심": "search_acr_special_appeals", + "생활법령(daily)": "get_daily_term / get_daily_to_legal / get_legal_to_daily", +} diff --git a/src/kpaa/law_api/english.py b/src/kpaa/law_api/english.py new file mode 100644 index 0000000000000000000000000000000000000000..0a2f0a4a0586a2bdaff21ac79a63513fd42dbcf5 --- /dev/null +++ b/src/kpaa/law_api/english.py @@ -0,0 +1,40 @@ +"""영문 법령 (target=elaw).""" +from __future__ import annotations + +from kpaa.law_api.client import DETAIL_PATH, LIST_PATH, LawApiCall, LawGoKrClient +from kpaa.law_api.endpoints import ELAW +from kpaa.law_api.parsers import child_text, parse_xml + + +async def search_english_laws( + client: LawGoKrClient, *, query: str, display: int = 20 +) -> list[dict[str, str]]: + if not 1 <= display <= 100: + raise ValueError("display must be in [1, 100]") + body = await client.fetch( + LawApiCall(target=ELAW.target, path=LIST_PATH, + params={"query": query, "display": str(display)}, + cache_ttl=3600) + ) + root = parse_xml(body) + return [ + { + "mst": child_text(el, "법령일련번호"), + "name_en": child_text(el, "법령명영문"), + "name_ko": child_text(el, "법령명한글"), + "promulgate_date": child_text(el, "공포일자"), + "enforce_date": child_text(el, "시행일자"), + "department": child_text(el, "소관부처명"), + } + for el in root.findall(f".//{ELAW.row_tag}") + ] + + +async def get_english_law_text(client: LawGoKrClient, *, mst: str) -> str: + return await client.fetch( + LawApiCall(target=ELAW.target, path=DETAIL_PATH, + params={"MST": str(mst)}, cache_ttl=24 * 3600) + ) + + +__all__ = ["search_english_laws", "get_english_law_text"] diff --git a/src/kpaa/law_api/ftc.py b/src/kpaa/law_api/ftc.py new file mode 100644 index 0000000000000000000000000000000000000000..ec02b9fbfd74945a784dd9dbb843a5a8e5e727b3 --- /dev/null +++ b/src/kpaa/law_api/ftc.py @@ -0,0 +1,45 @@ +"""공정거래위원회 결정 (target=ftc). + +라이브 검증 row 필드: 결정문일련번호 / 사건명 / 사건번호 / 문서유형 / +회의종류 / 결정번호 / 결정일자. +""" +from __future__ import annotations + +from kpaa.law_api.client import DETAIL_PATH, LIST_PATH, LawApiCall, LawGoKrClient +from kpaa.law_api.endpoints import FTC +from kpaa.law_api.parsers import child_text, parse_xml + + +async def search_ftc_decisions( + client: LawGoKrClient, *, query: str, display: int = 20 +) -> list[dict[str, str]]: + if not 1 <= display <= 100: + raise ValueError("display must be in [1, 100]") + body = await client.fetch( + LawApiCall(target=FTC.target, path=LIST_PATH, + params={"query": query, "display": str(display)}, + cache_ttl=3600) + ) + root = parse_xml(body) + return [ + { + "decision_id": child_text(el, "결정문일련번호"), + "case_name": child_text(el, "사건명"), + "case_no": child_text(el, "사건번호"), + "document_kind": child_text(el, "문서유형"), + "meeting_kind": child_text(el, "회의종류"), + "decision_no": child_text(el, "결정번호"), + "decision_date": child_text(el, "결정일자"), + } + for el in root.findall(f".//{FTC.row_tag}") + ] + + +async def get_ftc_decision_text(client: LawGoKrClient, *, decision_id: str) -> str: + return await client.fetch( + LawApiCall(target=FTC.target, path=DETAIL_PATH, + params={"ID": str(decision_id)}, cache_ttl=24 * 3600) + ) + + +__all__ = ["search_ftc_decisions", "get_ftc_decision_text"] diff --git a/src/kpaa/law_api/interpretation.py b/src/kpaa/law_api/interpretation.py new file mode 100644 index 0000000000000000000000000000000000000000..b54acf91e6d5ecf2dbf9f28624b71585a4d63a31 --- /dev/null +++ b/src/kpaa/law_api/interpretation.py @@ -0,0 +1,96 @@ +"""법령해석례 — `target=expc`. + +라이브 검증(2026-04-29): +- 검색: lawSearch.do?target=expc&query=...&display=... + root=, row=, id=법령해석례일련번호 +- 본문: lawService.do?target=expc&ID=... + root=, fields: 안건명/안건번호/해석일자/질의기관명/회신기관명/질의요지/회답/이유 +""" +from __future__ import annotations + +from kpaa.law_api.client import DETAIL_PATH, LIST_PATH, LawApiCall, LawGoKrClient +from kpaa.law_api.endpoints import EXPC +from kpaa.law_api.models import InterpretationHit, InterpretationText +from kpaa.law_api.parsers import child_text, parse_xml + +CACHE_TTL_SEARCH = 3600 +CACHE_TTL_DETAIL = 24 * 3600 + +INTERPRETATION_BODY_LIMIT = 8000 + + +async def search_interpretations( + client: LawGoKrClient, + *, + query: str, + display: int = 20, +) -> list[InterpretationHit]: + """법령해석례 목록 검색.""" + if not 1 <= display <= 100: + raise ValueError("display must be in [1, 100]") + call = LawApiCall( + target=EXPC.target, + path=LIST_PATH, + params={"query": query, "display": str(display)}, + cache_ttl=CACHE_TTL_SEARCH, + ) + body = await client.fetch(call) + root = parse_xml(body) + + hits: list[InterpretationHit] = [] + for el in root.findall(f".//{EXPC.row_tag}"): + hits.append( + InterpretationHit( + interpretation_id=child_text(el, EXPC.id_field), + title=child_text(el, "안건명"), + case_no=child_text(el, "안건번호"), + decided_date=child_text(el, "회신일자"), + inquirer=child_text(el, "질의기관명"), + responder=child_text(el, "회신기관명"), + ) + ) + return hits + + +async def get_interpretation_text( + client: LawGoKrClient, + *, + interpretation_id: str, +) -> InterpretationText: + """법령해석례 본문.""" + call = LawApiCall( + target=EXPC.target, + path=DETAIL_PATH, + params={"ID": str(interpretation_id)}, + cache_ttl=CACHE_TTL_DETAIL, + ) + body = await client.fetch(call) + root = parse_xml(body) + + question = child_text(root, "질의요지") + answer = child_text(root, "회답") + reason = child_text(root, "이유") + + used = len(question) + len(answer) + len(reason) + if used > INTERPRETATION_BODY_LIMIT: + # 회답 우선, 그 다음 질의요지, 마지막 이유 잘라냄 + budget = INTERPRETATION_BODY_LIMIT - len(answer) - len(question) + if budget < 0: + budget = 0 + if budget < len(reason): + reason = reason[:budget] + "\n…(중략)…" + + return InterpretationText( + interpretation_id=str(interpretation_id), + title=child_text(root, "안건명"), + case_no=child_text(root, "안건번호"), + decided_date=child_text(root, "해석일자"), + inquirer=child_text(root, "질의기관명"), + responder=child_text(root, "해석기관명") or child_text(root, "회신기관명"), + question=question, + answer=answer, + reason=reason, + ) + + +__all__ = ["search_interpretations", "get_interpretation_text"] diff --git a/src/kpaa/law_api/jo.py b/src/kpaa/law_api/jo.py new file mode 100644 index 0000000000000000000000000000000000000000..962b1a8e1a9495234ed22e8af2c2c057b1c03d2c --- /dev/null +++ b/src/kpaa/law_api/jo.py @@ -0,0 +1,134 @@ +"""법제처 OPEN API의 JO(조문) 6자리 코드 인코딩 + 항/조 번호 정규화. + +라이브 검증(2026-04-29): + "15" → "001500" (제15조) + "15의2" → "001502" (제15조의2) + "24의2" → "002402" (제24조의2 = 주민등록번호 처리의 제한) + "22의2" → "002202" (제22조의2 = 아동의 개인정보 보호) + +규칙: 4자리 조 번호 + 2자리 가지 번호 ("의 N"이 없으면 "00"). +""" +from __future__ import annotations + +import re + +_RE = re.compile( + r"^\s*(?:제)?\s*(\d+)\s*(?:조)?\s*(?:의\s*(\d+)|[-_]\s*(\d+))?\s*(?:조)?\s*$" +) + + +def encode_jo(article_no: str | int) -> str: + """Encode an article number (조문 번호) to the 6-digit JO code. + + Accepts: + 15, "15", "제15조", "15조", "15의2", "15-2", "제24조의2", ... + + Returns 6-digit string suitable for `JO=` parameter. + """ + if isinstance(article_no, int): + return f"{article_no:04d}00" + s = str(article_no).strip() + m = _RE.match(s) + if not m: + raise ValueError(f"unrecognized article number: {article_no!r}") + base = int(m.group(1)) + branch_str = m.group(2) or m.group(3) or "0" + branch = int(branch_str) + if not 0 <= base <= 9999: + raise ValueError(f"article number out of range: {base}") + if not 0 <= branch <= 99: + raise ValueError(f"branch number out of range: {branch}") + return f"{base:04d}{branch:02d}" + + +def decode_jo(code: str) -> str: + """Decode a 6-digit JO code back to a human-readable form. + + "001500" → "15", "002402" → "24의2". + """ + if not (isinstance(code, str) and len(code) == 6 and code.isdigit()): + raise ValueError(f"invalid JO code: {code!r}") + base = int(code[:4]) + branch = int(code[4:]) + return f"{base}" if branch == 0 else f"{base}의{branch}" + + +# ──────────────────────────────────────────────────────────────────────────── +# 원숫자(①②③) 파서 + 일반화된 항번호 파서 +# ──────────────────────────────────────────────────────────────────────────── +# 법제처 응답의 `<항번호>` 값은 한글 원숫자로 옴 — 예: "①", "② ", "(1)", "1.". +# 단순 `int(raw)` / `int(raw.replace(...))` 는 NaN 또는 ValueError 를 낸다. +# 이 매핑은 Unicode 코드포인트 동시 커버: +# U+2460..U+2473 ① ~ ⑳ (1~20) +# U+2474..U+2487 ⑴ ~ ⒇ (1~20, parenthesized) +# U+24EA ⓪ (0) +# U+3251..U+325F ㉑ ~ ㉟ (21~35) +# U+32B1..U+32BF ㊱ ~ ㊿ (36~50) +_CIRCLED_DIGIT_MAP: dict[str, int] = {} +for _i in range(20): + _CIRCLED_DIGIT_MAP[chr(0x2460 + _i)] = _i + 1 # ①..⑳ + _CIRCLED_DIGIT_MAP[chr(0x2474 + _i)] = _i + 1 # ⑴..⒇ +_CIRCLED_DIGIT_MAP[chr(0x24EA)] = 0 +for _i in range(15): + _CIRCLED_DIGIT_MAP[chr(0x3251 + _i)] = _i + 21 # ㉑..㉟ +for _i in range(15): + _CIRCLED_DIGIT_MAP[chr(0x32B1 + _i)] = _i + 36 # ㊱..㊿ +del _i + +_PLAIN_DIGITS_RE = re.compile(r"\d+") + + +def parse_hang_number(raw: str | int | None) -> int | None: + """`<항번호>` 등 한국 법령 텍스트의 항/호 번호를 정수로 정규화. + + 수용 가능 입력: + ① ② ⑩ → 1, 2, 10 + "① " → 1 (뒤 공백 OK) + "(1)" "1." "1) " "제1항" "제1호" "1" → 1 + ⓪ → 0 + None / "" → None + + 빈/이해 불가 입력은 None. 값 비교 시 `if (n := parse_hang_number(x)) == 1` + 패턴으로 사용. + + 원작 article-parser.ts 의 `parseHangNumber()` 동등 — 작은 모델 응답에서 + "최대 제0항" 식의 NaN→0 오판정 회귀 방지가 도입 동기. + """ + if raw is None: + return None + if isinstance(raw, int): + return raw + s = str(raw).strip() + if not s: + return None + # 1) 원숫자 직접 매칭 (가장 흔한 케이스) + for ch in s: + if ch in _CIRCLED_DIGIT_MAP: + return _CIRCLED_DIGIT_MAP[ch] + # 2) 일반 숫자 fallback — 첫 번째 숫자 시퀀스 + m = _PLAIN_DIGITS_RE.search(s) + if m: + try: + return int(m.group(0)) + except ValueError: + return None + return None + + +def normalize_jo(raw: str | int) -> str: + """다양한 조문 표기를 *decoded JO* 형태로 통일. + + "제24조의2", "24-2", "24의2", "24조의2", "24_2" 모두 → "24의2". + "제15조", "15조", " 15 " 모두 → "15". + + 실패 시 ValueError. encode_jo + decode_jo 를 거치므로 같은 검증을 재사용. + """ + return decode_jo(encode_jo(raw)) + + +__all__ = [ + "encode_jo", + "decode_jo", + "normalize_jo", + "parse_hang_number", +] diff --git a/src/kpaa/law_api/law.py b/src/kpaa/law_api/law.py new file mode 100644 index 0000000000000000000000000000000000000000..e81459feccb91f8fc40187aa059edb83c30d8be6 --- /dev/null +++ b/src/kpaa/law_api/law.py @@ -0,0 +1,255 @@ +"""법령 카테고리 — 법제처 OPEN API `target=law` + 별표 `target=licbyl`. + +핵심 함수 (Day 2): +- search_law : 법령 목록 검색 +- get_law_text : 법령 본문 (전체 또는 특정 조문) +- get_article_detail : 단일 조문 (편의 래퍼) +- get_annexes : 별표·별지서식 목록 +""" +from __future__ import annotations + +from kpaa.law_api.client import DETAIL_PATH, LIST_PATH, LawApiCall, LawGoKrClient +from kpaa.law_api.endpoints import LAW, LICBYL +from kpaa.law_api.jo import decode_jo, encode_jo +from kpaa.law_api.models import ( + AnnexHit, + Article, + LawHit, + LawText, +) +from kpaa.law_api.parsers import child_text, parse_xml + +CACHE_TTL_SEARCH = 3600 # 1h +CACHE_TTL_DETAIL = 24 * 3600 # 24h + +# 조문 1개 본문 길이 컷 (캐시 전 적용). 정책: *왜곡 없이 전달* 우선이므로 +# 일반 조문(평균 500~2000자) 은 절대 안 잘리는 너그러운 상한. 별표·서식이 본문에 +# 함께 오는 비정상적 응답에 대한 sanity cap 역할만. +ARTICLE_BODY_LIMIT = 12000 + + +async def search_law( + client: LawGoKrClient, + *, + query: str, + display: int = 20, +) -> list[LawHit]: + """법령 목록 검색 (`lawSearch.do?target=law`).""" + if not 1 <= display <= 100: + raise ValueError("display must be in [1, 100]") + call = LawApiCall( + target=LAW.target, + path=LIST_PATH, + params={"query": query, "display": str(display)}, + cache_ttl=CACHE_TTL_SEARCH, + ) + body = await client.fetch(call) + root = parse_xml(body) + + hits: list[LawHit] = [] + for el in root.findall(f".//{LAW.row_tag}"): + hits.append( + LawHit( + mst=child_text(el, LAW.id_field), + name=child_text(el, "법령명한글"), + name_short=child_text(el, "법령약칭명"), + promulgate_date=child_text(el, "공포일자"), + promulgate_no=child_text(el, "공포번호"), + enforce_date=child_text(el, "시행일자"), + type_name=child_text(el, "법령구분명"), + department=child_text(el, "소관부처명"), + ) + ) + return hits + + +async def get_law_text( + client: LawGoKrClient, + *, + mst: str, + jo: str | int | None = None, +) -> LawText: + """법령 본문 (`lawService.do?target=law&MST=...`). + + Args: + mst: 법령일련번호 + jo: 특정 조문만 받으려면 "15", "24의2", 15 등으로 지정. None이면 전체. + """ + params: dict[str, str] = {"MST": mst} + if jo is not None: + params["JO"] = encode_jo(jo) if not (isinstance(jo, str) and jo.isdigit() and len(jo) == 6) else jo + call = LawApiCall( + target=LAW.target, + path=DETAIL_PATH, + params=params, + cache_ttl=CACHE_TTL_DETAIL, + ) + body = await client.fetch(call) + root = parse_xml(body) + return _parse_law_text(root, mst=mst) + + +async def get_article_detail( + client: LawGoKrClient, + *, + mst: str, + article_no: str | int, +) -> Article: + """단일 조문 (편의 래퍼). + + 내부적으로 `get_law_text(mst, jo=article_no)`를 호출하고 첫 조문을 반환. + 조문이 없으면 빈 Article 반환 (raw_text=""). + """ + text = await get_law_text(client, mst=mst, jo=article_no) + for art in text.articles: + return art + return Article(mst=mst, article_no=str(article_no), title="", raw_text="") + + +async def get_annexes( + client: LawGoKrClient, + *, + related_law_id: str | None = None, + query: str = "", + display: int = 20, +) -> list[AnnexHit]: + """별표·별지서식 검색 (`lawSearch.do?target=licbyl`). + + Args: + related_law_id: 특정 법령의 별표만 추리려면 법령ID(6자리, 예: "011357"). + 주의: MST 가 아니라 법령ID. korean-law-mcp의 별표 도구도 동일. + query: 별표명/관련법령명 키워드 (선택) + display: 1~100 + """ + if not 1 <= display <= 100: + raise ValueError("display must be in [1, 100]") + params: dict[str, str] = {"display": str(display)} + if query: + params["query"] = query + if related_law_id: + params["LSID"] = related_law_id + + call = LawApiCall( + target=LICBYL.target, + path=LIST_PATH, + params=params, + cache_ttl=CACHE_TTL_SEARCH, + ) + body = await client.fetch(call) + root = parse_xml(body) + + hits: list[AnnexHit] = [] + for el in root.findall(f".//{LICBYL.row_tag}"): + hits.append( + AnnexHit( + annex_id=child_text(el, LICBYL.id_field), + name=child_text(el, "별표명"), + related_law_mst=child_text(el, "관련법령일련번호"), + related_law_name=child_text(el, "관련법령명"), + annex_no=child_text(el, "별표번호"), + annex_kind=child_text(el, "별표종류"), + promulgate_date=child_text(el, "공포일자"), + department=child_text(el, "소관부처명"), + file_url=child_text(el, "별표서식파일링크"), + ) + ) + return hits + + +# ───────────────────────── parsers (private) ───────────────────────── + +def _parse_law_text(root, *, mst: str) -> LawText: + """`<법령>` 응답을 LawText로 파싱.""" + info = root.find("기본정보") + name = "" + law_id = "" + enforce_date = "" + promulgate_date = "" + department = "" + if info is not None: + name = child_text(info, "법령명_한글") + law_id = child_text(info, "법령ID") + enforce_date = child_text(info, "시행일자") + promulgate_date = child_text(info, "공포일자") + soguan = info.find("소관부처") + if soguan is not None and soguan.text: + department = soguan.text.strip() + + articles: list[Article] = [] + for unit in root.findall(".//조문/조문단위"): + # `조문여부`가 "조문"인 단위만 본문 (전문/체계는 패스) + kind = child_text(unit, "조문여부") + if kind != "조문": + continue + article_no_raw = child_text(unit, "조문번호") + article_branch = child_text(unit, "조문가지번호") or "0" + if article_branch and article_branch != "0": + article_no = f"{article_no_raw}의{article_branch}" + else: + # 일부 응답은 조문가지번호 없이 조문키 7자리("0024020")로만 가지를 표현 + key = unit.get("조문키", "") + if len(key) >= 7: + branch = int(key[4:6]) + if branch: + article_no = f"{int(key[:4])}의{branch}" + else: + article_no = article_no_raw + else: + article_no = article_no_raw + + title = child_text(unit, "조문제목") + enforce = child_text(unit, "조문시행일자") or enforce_date + + # 본문: 조문내용 + 항(번호+내용) + 호(번호+내용) + parts: list[str] = [] + body = child_text(unit, "조문내용") + if body: + parts.append(body) + para_texts: list[str] = [] + for para in unit.findall("항"): + num = child_text(para, "항번호") + txt = child_text(para, "항내용") + line = (f"{num} {txt}" if num and not txt.startswith(num.strip()) else txt).strip() + if line: + parts.append(line) + para_texts.append(line) + for ho in para.findall("호"): + hno = child_text(ho, "호번호") + hcn = child_text(ho, "호내용") + if hcn: + parts.append(hcn if hcn.startswith(hno.strip()) else f"{hno} {hcn}") + + raw_text = "\n".join(parts) + if len(raw_text) > ARTICLE_BODY_LIMIT: + raw_text = raw_text[:ARTICLE_BODY_LIMIT] + "\n…(중략)…" + + articles.append( + Article( + mst=mst, + article_no=article_no, + title=title, + enforce_date=enforce, + paragraphs=tuple(para_texts), + raw_text=raw_text, + ) + ) + + return LawText( + mst=mst, + law_id=law_id, + name=name, + enforce_date=enforce_date, + promulgate_date=promulgate_date, + department=department, + articles=tuple(articles), + ) + + +__all__ = [ + "search_law", + "get_law_text", + "get_article_detail", + "get_annexes", + "encode_jo", + "decode_jo", +] diff --git a/src/kpaa/law_api/models.py b/src/kpaa/law_api/models.py new file mode 100644 index 0000000000000000000000000000000000000000..81142ac317a5d12e4448613849ee6c3604b6f2c5 --- /dev/null +++ b/src/kpaa/law_api/models.py @@ -0,0 +1,228 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + + +class LawHit(BaseModel): + """법령 검색 1건 (`lawSearch.do?target=law`).""" + + model_config = ConfigDict(frozen=True) + + mst: str + name: str + name_short: str = "" + promulgate_date: str = "" + promulgate_no: str = "" + enforce_date: str = "" + type_name: str = "" + department: str = "" + + +class Article(BaseModel): + """법령 본문에서 추출된 1개 조문.""" + + model_config = ConfigDict(frozen=True) + + mst: str + article_no: str # "15", "24의2" + title: str # "개인정보의 수집ㆍ이용" + enforce_date: str = "" + paragraphs: tuple[str, ...] = () # 항 본문 (각 ① ② … 그대로) + raw_text: str = "" # 사람이 읽기 좋은 결합 본문 (조문내용 + 항 + 호) + + def citation(self, *, law_name: str = "개인정보보호법") -> str: + """답변에 박을 인용 태그. 예: '개인정보보호법 제15조'.""" + # 24의2 → 제24조의2 + ano = self.article_no + if "의" in ano: + base, branch = ano.split("의", 1) + return f"{law_name} 제{base}조의{branch}" + return f"{law_name} 제{ano}조" + + +class LawText(BaseModel): + """법령 본문 응답 (조문 다수 또는 특정 조문).""" + + model_config = ConfigDict(frozen=True) + + mst: str + law_id: str = "" + name: str + enforce_date: str = "" + promulgate_date: str = "" + department: str = "" + articles: tuple[Article, ...] = () + + +class PIPCDecisionHit(BaseModel): + """PIPC 결정문 검색 1건 (`lawSearch.do?target=ppc`).""" + + model_config = ConfigDict(frozen=True) + + decision_id: str + title: str + decision_no: str = "" # 안건번호 / 의안번호 + decision_date: str = "" # 의결일 YYYYMMDD or YYYY.MM.DD + decision_kind: str = "" # 결정구분 + agency: str = "" # 기관명 + + +class PIPCDecisionText(BaseModel): + """PIPC 결정문 본문 (`lawService.do?target=ppc&ID=`).""" + + model_config = ConfigDict(frozen=True) + + decision_id: str + title: str + decision_no: str = "" + decision_date: str = "" + decision_kind: str = "" + agency: str = "" + main_text: str = "" # 주문 + reason: str = "" # 이유 + + def citation(self) -> str: + """답변에 박을 인용 태그. + + 의안번호가 있으면 'PIPC 결정 YYYY-NNN' 형태로, 없으면 ID 사용. + """ + if self.decision_no and self.decision_no.lower() != "null": + return f"PIPC 결정 {self.decision_no}" + return f"PIPC 결정문 #{self.decision_id}" + + +class InterpretationHit(BaseModel): + """법령해석례 검색 1건 (`lawSearch.do?target=expc`).""" + + model_config = ConfigDict(frozen=True) + + interpretation_id: str + title: str + case_no: str = "" # 안건번호 (예: "20-0370") + decided_date: str = "" # 회신일자 + inquirer: str = "" # 질의기관명 + responder: str = "" # 회신기관명 + + +class InterpretationText(BaseModel): + """법령해석례 본문 (`lawService.do?target=expc&ID=`).""" + + model_config = ConfigDict(frozen=True) + + interpretation_id: str + title: str + case_no: str = "" + decided_date: str = "" + inquirer: str = "" + responder: str = "" + question: str = "" # 질의요지 + answer: str = "" # 회답 + reason: str = "" # 이유 + + def citation(self) -> str: + if self.case_no: + return f"법령해석례 안건 {self.case_no}" + return f"법령해석례 #{self.interpretation_id}" + + +class ConstitutionalDecisionHit(BaseModel): + """헌재 결정문 검색 1건 (`lawSearch.do?target=detc`). + + 응답 row tag: `` (대문자 D). 라이브 검증 (원작 korean-law-mcp + parseConstitutionalXML 기반). + """ + + model_config = ConfigDict(frozen=True) + + decision_id: str # 헌재결정례일련번호 + title: str # 사건명 + case_no: str = "" # 사건번호 (예: "2020헌바123") + decision_date: str = "" # 종국일자 + detail_url: str = "" # 헌재결정례상세링크 + + +class ConstitutionalDecisionText(BaseModel): + """헌재 결정문 본문 (`lawService.do?target=detc&type=JSON&ID=`). + + 응답은 *JSON* 으로 옴 — `data.DetcService` 또는 `data.헌재결정례` 키 아래. + 본문 비교적 길어 판시사항·결정요지·전문 분리 보존. + """ + + model_config = ConfigDict(frozen=True) + + decision_id: str + title: str # 사건명 + case_no: str = "" # 사건번호 + decision_date: str = "" # 종국일자 또는 선고일자 + petitioner: str = "" # 청구인 + respondent: str = "" # 피청구인 + issues: str = "" # 판시사항 + summary: str = "" # 결정요지/판결요지 + refs_law: str = "" # 참조조문 + refs_precedent: str = "" # 참조판례 + body: str = "" # 판례내용/결정내용/전문 + + def citation(self) -> str: + """답변에 박을 인용 태그. + + 사건번호가 있으면 '헌재 YYYY헌○NNN' 형태, 없으면 ID 사용. + """ + if self.case_no: + return f"헌재 {self.case_no}" + return f"헌법재판소 결정문 #{self.decision_id}" + + +class ArticleHistoryItem(BaseModel): + """조문 개정 이력 1건 (`lawSearch.do?target=lsJoHstInf`). + + 한 법령의 *특정 시점* 개정에서 *특정 조문* 의 변경 사항. 같은 조가 여러 + 번 바뀌었으면 같은 article_no 로 여러 item. + """ + + model_config = ConfigDict(frozen=True) + + law_name: str # 법령명한글 + law_id: str # 법령ID + mst: str # 법령일련번호 + article_no: str # 디코드된 조문 번호 (예: "15", "24의2") + article_jo_code: str # 6자리 JO 코드 (예: "001500") + change_type: str = "" # 제개정구분명 (예: "일부개정") + change_reason: str = "" # 변경사유 + article_amend_date: str = "" # 조문개정일 YYYYMMDD + article_enforce_date: str = "" # 조문시행일 YYYYMMDD + promulgate_date: str = "" # 공포일자 (법령 단위) + enforce_date: str = "" # 시행일자 (법령 단위) + + def citation(self) -> str: + """답변에 박을 인용 태그. + + 예: '개인정보보호법 제15조 (2020-08-05 개정)'. + """ + # article_no → 표시 형태 + ano = self.article_no + if "의" in ano: + base, branch = ano.split("의", 1) + disp = f"제{base}조의{branch}" + else: + disp = f"제{ano}조" + date_part = "" + d = self.article_amend_date or self.promulgate_date + if d and len(d) == 8: + date_part = f" ({d[:4]}-{d[4:6]}-{d[6:]} 개정)" + return f"{self.law_name} {disp}{date_part}" + + +class AnnexHit(BaseModel): + """별표·별지서식 검색 1건 (`lawSearch.do?target=licbyl`).""" + + model_config = ConfigDict(frozen=True) + + annex_id: str + name: str + related_law_mst: str = "" + related_law_name: str = "" + annex_no: str = "" + annex_kind: str = "" # 별표/별지/서식 + promulgate_date: str = "" + department: str = "" + file_url: str = "" diff --git a/src/kpaa/law_api/nlrc.py b/src/kpaa/law_api/nlrc.py new file mode 100644 index 0000000000000000000000000000000000000000..beaaa123cc4a5721e8b36bc5b0bed8669c91d2d2 --- /dev/null +++ b/src/kpaa/law_api/nlrc.py @@ -0,0 +1,41 @@ +"""중앙노동위원회 결정 (target=nlrc). + +라이브 검증 row 필드: 결정문일련번호 / 제목 / 사건번호 / 등록일. +""" +from __future__ import annotations + +from kpaa.law_api.client import DETAIL_PATH, LIST_PATH, LawApiCall, LawGoKrClient +from kpaa.law_api.endpoints import NLRC +from kpaa.law_api.parsers import child_text, parse_xml + + +async def search_nlrc_decisions( + client: LawGoKrClient, *, query: str, display: int = 20 +) -> list[dict[str, str]]: + if not 1 <= display <= 100: + raise ValueError("display must be in [1, 100]") + body = await client.fetch( + LawApiCall(target=NLRC.target, path=LIST_PATH, + params={"query": query, "display": str(display)}, + cache_ttl=3600) + ) + root = parse_xml(body) + return [ + { + "decision_id": child_text(el, "결정문일련번호"), + "title": child_text(el, "제목"), + "case_no": child_text(el, "사건번호"), + "registered_date": child_text(el, "등록일"), + } + for el in root.findall(f".//{NLRC.row_tag}") + ] + + +async def get_nlrc_decision_text(client: LawGoKrClient, *, decision_id: str) -> str: + return await client.fetch( + LawApiCall(target=NLRC.target, path=DETAIL_PATH, + params={"ID": str(decision_id)}, cache_ttl=24 * 3600) + ) + + +__all__ = ["search_nlrc_decisions", "get_nlrc_decision_text"] diff --git a/src/kpaa/law_api/oldnew.py b/src/kpaa/law_api/oldnew.py new file mode 100644 index 0000000000000000000000000000000000000000..194b270eee2873715e35ad0fc29b65f2b586da36 --- /dev/null +++ b/src/kpaa/law_api/oldnew.py @@ -0,0 +1,48 @@ +"""신구법비교 (target=oldAndNew) — 동일 법령의 개정 전후 조문 비교. + +라이브 검증 row 필드: 신구법일련번호 / 신구법명 / 신구법ID / 공포일자 / +공포번호 / 시행일자 / 제개정구분명 / 법령구분명 / 소관부처명. +""" +from __future__ import annotations + +from kpaa.law_api.client import DETAIL_PATH, LIST_PATH, LawApiCall, LawGoKrClient +from kpaa.law_api.endpoints import OLDNEW +from kpaa.law_api.parsers import child_text, parse_xml + + +async def search_old_new( + client: LawGoKrClient, *, query: str, display: int = 20 +) -> list[dict[str, str]]: + if not 1 <= display <= 100: + raise ValueError("display must be in [1, 100]") + body = await client.fetch( + LawApiCall(target=OLDNEW.target, path=LIST_PATH, + params={"query": query, "display": str(display)}, + cache_ttl=3600) + ) + root = parse_xml(body) + return [ + { + "mst": child_text(el, "신구법일련번호"), + "law_id": child_text(el, "신구법ID"), + "name": child_text(el, "신구법명"), + "amend_type": child_text(el, "제개정구분명"), + "law_kind": child_text(el, "법령구분명"), + "promulgate_date": child_text(el, "공포일자"), + "promulgate_no": child_text(el, "공포번호"), + "enforce_date": child_text(el, "시행일자"), + "department": child_text(el, "소관부처명"), + } + for el in root.findall(f".//{OLDNEW.row_tag}") + ] + + +async def compare_old_new(client: LawGoKrClient, *, mst: str) -> str: + """신·구 법조문 비교 본문 (raw XML).""" + return await client.fetch( + LawApiCall(target=OLDNEW.target, path=DETAIL_PATH, + params={"MST": str(mst)}, cache_ttl=24 * 3600) + ) + + +__all__ = ["search_old_new", "compare_old_new"] diff --git a/src/kpaa/law_api/ordinance.py b/src/kpaa/law_api/ordinance.py new file mode 100644 index 0000000000000000000000000000000000000000..181f81312dee724465055a034c7e6cd1beed893f --- /dev/null +++ b/src/kpaa/law_api/ordinance.py @@ -0,0 +1,55 @@ +"""자치법규 (target=ordin).""" +from __future__ import annotations + +from kpaa.law_api.client import DETAIL_PATH, LIST_PATH, LawApiCall, LawGoKrClient +from kpaa.law_api.endpoints import ORDIN +from kpaa.law_api.parsers import child_text, parse_xml + +CACHE_TTL_SEARCH = 3600 +CACHE_TTL_DETAIL = 24 * 3600 + + +async def search_ordinances( + client: LawGoKrClient, + *, + query: str, + display: int = 20, +) -> list[dict[str, str]]: + if not 1 <= display <= 100: + raise ValueError("display must be in [1, 100]") + call = LawApiCall( + target=ORDIN.target, path=LIST_PATH, + params={"query": query, "display": str(display)}, + cache_ttl=CACHE_TTL_SEARCH, + ) + body = await client.fetch(call) + root = parse_xml(body) + out: list[dict[str, str]] = [] + for el in root.findall(f".//{ORDIN.row_tag}"): + out.append( + { + "ordinance_id": child_text(el, "자치법규일련번호") or child_text(el, "법령일련번호"), + "name": child_text(el, "자치법규명") or child_text(el, "법령명한글"), + "promulgate_date": child_text(el, "공포일자"), + "enforce_date": child_text(el, "시행일자"), + "agency": child_text(el, "소관부처명") or child_text(el, "지자체기관명"), + "type": child_text(el, "자치법규종류") or child_text(el, "법령구분명"), + } + ) + return out + + +async def get_ordinance_text( + client: LawGoKrClient, + *, + ordinance_id: str, +) -> str: + call = LawApiCall( + target=ORDIN.target, path=DETAIL_PATH, + params={"ID": str(ordinance_id)}, + cache_ttl=CACHE_TTL_DETAIL, + ) + return await client.fetch(call) + + +__all__ = ["search_ordinances", "get_ordinance_text"] diff --git a/src/kpaa/law_api/parsers.py b/src/kpaa/law_api/parsers.py new file mode 100644 index 0000000000000000000000000000000000000000..b8f9b1e3e23ebdde4c2094b70cacf28a0ad8de34 --- /dev/null +++ b/src/kpaa/law_api/parsers.py @@ -0,0 +1,30 @@ +"""법제처 OPEN API XML 응답 파싱 헬퍼. + +법제처 응답은 한글 태그 이름을 그대로 사용하므로 lxml의 일반 find/findall 와 +이 모듈의 가벼운 보조 함수로 충분하다. +""" +from __future__ import annotations + +from lxml import etree + + +def parse_xml(body: str) -> etree._Element: + return etree.fromstring(body.encode("utf-8")) + + +def text_of(el: etree._Element | None, default: str = "") -> str: + if el is None or el.text is None: + return default + return el.text.strip() + + +def child_text(parent: etree._Element, tag: str, default: str = "") -> str: + return text_of(parent.find(tag), default) + + +def all_text(el: etree._Element | None) -> str: + """Return concatenated text of an element and all descendants, joined by newline.""" + if el is None: + return "" + parts = [t.strip() for t in el.itertext() if t and t.strip()] + return "\n".join(parts) diff --git a/src/kpaa/law_api/pipc.py b/src/kpaa/law_api/pipc.py new file mode 100644 index 0000000000000000000000000000000000000000..2d5f29e68724f58b5bbb34039c4c2a8f7a1ad593 --- /dev/null +++ b/src/kpaa/law_api/pipc.py @@ -0,0 +1,94 @@ +"""개인정보보호위원회 결정문 (PIPC) — `target=ppc`. + +라이브 검증(2026-04-29): +- 검색: lawSearch.do?target=ppc&query=...&display=... + root=, row=, id=결정문일련번호 +- 본문: lawService.do?target=ppc&ID=... + root=, container=<의결서> +""" +from __future__ import annotations + +from kpaa.law_api.client import DETAIL_PATH, LIST_PATH, LawApiCall, LawGoKrClient +from kpaa.law_api.endpoints import PPC +from kpaa.law_api.models import PIPCDecisionHit, PIPCDecisionText +from kpaa.law_api.parsers import child_text, parse_xml + +CACHE_TTL_SEARCH = 3600 +CACHE_TTL_DETAIL = 24 * 3600 + +DECISION_BODY_LIMIT = 10_000 + + +async def search_decisions( + client: LawGoKrClient, + *, + query: str, + display: int = 20, +) -> list[PIPCDecisionHit]: + """PIPC 결정문 목록 검색.""" + if not 1 <= display <= 100: + raise ValueError("display must be in [1, 100]") + call = LawApiCall( + target=PPC.target, + path=LIST_PATH, + params={"query": query, "display": str(display)}, + cache_ttl=CACHE_TTL_SEARCH, + ) + body = await client.fetch(call) + root = parse_xml(body) + agency = child_text(root, "기관명") + + hits: list[PIPCDecisionHit] = [] + for el in root.findall(f".//{PPC.row_tag}"): + hits.append( + PIPCDecisionHit( + decision_id=child_text(el, PPC.id_field), + title=child_text(el, "안건명"), + decision_no=child_text(el, "의안번호"), + decision_date=child_text(el, "의결일"), + decision_kind=child_text(el, "결정구분"), + agency=agency, + ) + ) + return hits + + +async def get_decision_text( + client: LawGoKrClient, + *, + decision_id: str, +) -> PIPCDecisionText: + """PIPC 결정문 본문.""" + call = LawApiCall( + target=PPC.target, + path=DETAIL_PATH, + params={"ID": str(decision_id)}, + cache_ttl=CACHE_TTL_DETAIL, + ) + body = await client.fetch(call) + root = parse_xml(body) + container = root.find("의결서") + if container is None: + container = root + + main_text = child_text(container, "주문") + reason = child_text(container, "이유") + if len(main_text) + len(reason) > DECISION_BODY_LIMIT: + # 주문 우선 보존, 이유 잘라내기 + budget = max(0, DECISION_BODY_LIMIT - len(main_text)) + if budget < len(reason): + reason = reason[:budget] + "\n…(중략)…" + + return PIPCDecisionText( + decision_id=str(decision_id), + title=child_text(container, "안건명"), + decision_no=child_text(container, "의안번호") or child_text(container, "안건번호"), + decision_date=child_text(container, "의결연월일"), + decision_kind=child_text(container, "결정"), + agency=child_text(container, "기관명"), + main_text=main_text, + reason=reason, + ) + + +__all__ = ["search_decisions", "get_decision_text"] diff --git a/src/kpaa/law_api/precedent.py b/src/kpaa/law_api/precedent.py new file mode 100644 index 0000000000000000000000000000000000000000..4b82bf3291ea716c0fc18730beaaf1cc7cc63ff2 --- /dev/null +++ b/src/kpaa/law_api/precedent.py @@ -0,0 +1,62 @@ +"""판례 (target=prec) — thin wrapper. + +검색은 list endpoint, 본문은 detail endpoint. 각 row를 dict로 반환. +챗봇 RAG는 핵심 8개 외에서만 보조적으로 사용. +""" +from __future__ import annotations + +from kpaa.law_api.client import DETAIL_PATH, LIST_PATH, LawApiCall, LawGoKrClient +from kpaa.law_api.endpoints import PREC +from kpaa.law_api.parsers import child_text, parse_xml + +CACHE_TTL_SEARCH = 3600 +CACHE_TTL_DETAIL = 24 * 3600 + + +async def search_precedents( + client: LawGoKrClient, + *, + query: str, + display: int = 20, +) -> list[dict[str, str]]: + """판례 목록 검색. dict 리스트 반환 (사건명/사건번호/선고일자/법원명/판례일련번호 등).""" + if not 1 <= display <= 100: + raise ValueError("display must be in [1, 100]") + call = LawApiCall( + target=PREC.target, path=LIST_PATH, + params={"query": query, "display": str(display)}, + cache_ttl=CACHE_TTL_SEARCH, + ) + body = await client.fetch(call) + root = parse_xml(body) + out: list[dict[str, str]] = [] + for el in root.findall(f".//{PREC.row_tag}"): + out.append( + { + "precedent_id": child_text(el, PREC.id_field), + "case_name": child_text(el, "사건명"), + "case_no": child_text(el, "사건번호"), + "decision_date": child_text(el, "선고일자"), + "court_name": child_text(el, "법원명"), + "case_kind": child_text(el, "사건종류명"), + "verdict_type": child_text(el, "판결유형"), + } + ) + return out + + +async def get_precedent_text( + client: LawGoKrClient, + *, + precedent_id: str, +) -> str: + """판례 본문 — raw XML 반환 (구조 다양성 큼).""" + call = LawApiCall( + target=PREC.target, path=DETAIL_PATH, + params={"ID": str(precedent_id)}, + cache_ttl=CACHE_TTL_DETAIL, + ) + return await client.fetch(call) + + +__all__ = ["search_precedents", "get_precedent_text"] diff --git a/src/kpaa/law_api/raw.py b/src/kpaa/law_api/raw.py new file mode 100644 index 0000000000000000000000000000000000000000..adabc264156bcb11b911f2b6d899c128782612c2 --- /dev/null +++ b/src/kpaa/law_api/raw.py @@ -0,0 +1,83 @@ +"""Generic raw 호출 인터페이스. + +특정 카테고리 풀구현(법령/PIPC/해석례)이 노출하지 않는 target에 대해 +임의 target/파라미터로 list 또는 detail을 호출할 수 있다. +SDK 사용자가 KoreanLawClient의 미구현 카테고리를 직접 채워 쓸 수 있도록. + + rows = await client.raw.search("ftc", query="개인정보", display=10) + detail = await client.raw.get("ftc", id="12345") +""" +from __future__ import annotations + +from typing import Any + +from kpaa.law_api.client import ( + DETAIL_PATH, + LIST_PATH, + LawApiCall, + LawGoKrClient, +) +from kpaa.law_api.parsers import parse_xml + + +def _row_to_dict(el) -> dict[str, str]: + out: dict[str, str] = {} + for child in el: + txt = (child.text or "").strip() + if txt and not list(child): + out[child.tag] = txt + return out + + +async def raw_search( + client: LawGoKrClient, + target: str, + *, + query: str = "", + display: int = 20, + extra_params: dict[str, Any] | None = None, + cache_ttl: int = 3600, +) -> list[dict[str, str]]: + """임의 target에 대한 lawSearch.do 호출.""" + params: dict[str, Any] = {"display": str(display)} + if query: + params["query"] = query + if extra_params: + params.update(extra_params) + call = LawApiCall( + target=target, path=LIST_PATH, params=params, cache_ttl=cache_ttl + ) + body = await client.fetch(call) + if not body.strip(): + return [] + root = parse_xml(body) + meta = { + "target", "키워드", "section", "totalCnt", "page", "key", + "numOfRows", "resultCode", "resultMsg", "기관명", + } + return [_row_to_dict(ch) for ch in root if ch.tag not in meta and len(ch) > 0] + + +async def raw_get( + client: LawGoKrClient, + target: str, + *, + id: str | None = None, + mst: str | None = None, + extra_params: dict[str, Any] | None = None, + cache_ttl: int = 24 * 3600, +) -> str: + """임의 target에 대한 lawService.do 호출. raw XML 본문 반환.""" + params: dict[str, Any] = {} + if mst is not None: + params["MST"] = str(mst) + if id is not None: + params["ID"] = str(id) + if extra_params: + params.update(extra_params) + if not params: + raise ValueError("raw_get requires at least one of id/mst/extra_params") + call = LawApiCall( + target=target, path=DETAIL_PATH, params=params, cache_ttl=cache_ttl + ) + return await client.fetch(call) diff --git a/src/kpaa/law_api/terms.py b/src/kpaa/law_api/terms.py new file mode 100644 index 0000000000000000000000000000000000000000..e328a3b4df7f470c46f9df36481bd76d48ab3cf0 --- /dev/null +++ b/src/kpaa/law_api/terms.py @@ -0,0 +1,46 @@ +"""법령용어 (target=lstrm). + +라이브 검증 row 필드: 법령용어ID / 법령용어명 / 사전구분코드 / 법령종류코드 / +법령용어상세링크 / 법령용어상세검색. + +detail은 trmSeqs 파라미터를 쓰며 ID가 콤마로 여러 개 결합되어 있을 수 있음. +""" +from __future__ import annotations + +from kpaa.law_api.client import DETAIL_PATH, LIST_PATH, LawApiCall, LawGoKrClient +from kpaa.law_api.endpoints import LSTRM +from kpaa.law_api.parsers import child_text, parse_xml + + +async def search_legal_terms( + client: LawGoKrClient, *, query: str, display: int = 20 +) -> list[dict[str, str]]: + if not 1 <= display <= 100: + raise ValueError("display must be in [1, 100]") + body = await client.fetch( + LawApiCall(target=LSTRM.target, path=LIST_PATH, + params={"query": query, "display": str(display)}, + cache_ttl=3600) + ) + root = parse_xml(body) + return [ + { + "term_id": child_text(el, "법령용어ID"), + "term": child_text(el, "법령용어명"), + "dict_kind_code": child_text(el, "사전구분코드"), + "law_kind_code": child_text(el, "법령종류코드"), + "detail_url": child_text(el, "법령용어상세링크"), + } + for el in root.findall(f".//{LSTRM.row_tag}") + ] + + +async def get_legal_term_detail(client: LawGoKrClient, *, term_id: str) -> str: + """법령용어 상세 — `trmSeqs` 파라미터 사용 (콤마 결합 ID 허용).""" + return await client.fetch( + LawApiCall(target=LSTRM.target, path=DETAIL_PATH, + params={"trmSeqs": str(term_id)}, cache_ttl=24 * 3600) + ) + + +__all__ = ["search_legal_terms", "get_legal_term_detail"] diff --git a/src/kpaa/law_api/treaty.py b/src/kpaa/law_api/treaty.py new file mode 100644 index 0000000000000000000000000000000000000000..5a1b2cd8239fbc75324b316023b374196ccddebc --- /dev/null +++ b/src/kpaa/law_api/treaty.py @@ -0,0 +1,43 @@ +"""조약 (target=trty).""" +from __future__ import annotations + +from kpaa.law_api.client import DETAIL_PATH, LIST_PATH, LawApiCall, LawGoKrClient +from kpaa.law_api.endpoints import TRTY +from kpaa.law_api.parsers import child_text, parse_xml + + +async def search_treaties( + client: LawGoKrClient, *, query: str, display: int = 20 +) -> list[dict[str, str]]: + if not 1 <= display <= 100: + raise ValueError("display must be in [1, 100]") + body = await client.fetch( + LawApiCall(target=TRTY.target, path=LIST_PATH, + params={"query": query, "display": str(display)}, + cache_ttl=3600) + ) + root = parse_xml(body) + return [ + { + "treaty_id": child_text(el, "조약일련번호"), + "name": child_text(el, "조약명"), + "kind": child_text(el, "조약구분명"), + "treaty_no": child_text(el, "조약번호"), + "country_no": child_text(el, "국가번호"), + "signed_date": child_text(el, "서명일자"), + "effective_date": child_text(el, "발효일자"), + "gazette_date": child_text(el, "관보게제일자"), + } + for el in root.findall(f".//{TRTY.row_tag}") + ] + + +async def get_treaty_text(client: LawGoKrClient, *, treaty_id: str) -> str: + return await client.fetch( + LawApiCall(target=TRTY.target, path=DETAIL_PATH, + params={"ID": str(treaty_id)}, + cache_ttl=24 * 3600) + ) + + +__all__ = ["search_treaties", "get_treaty_text"] diff --git a/src/kpaa/llm/__init__.py b/src/kpaa/llm/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..71e44ebe204c76713d5a414098bb0421c49faf20 --- /dev/null +++ b/src/kpaa/llm/__init__.py @@ -0,0 +1,20 @@ +"""LLM 백엔드 추상화. + +두 백엔드를 지원하며 같은 `LLMBackend` Protocol 을 만족: + +- `llama_cpp_backend` — 로컬 노트북용. Gemma 4 E2B GGUF 임베드, GGUF 는 첫 + 호출 시 Hugging Face 에서 자동 다운로드되어 `platformdirs.user_cache_dir` + 에 캐시. 별도 데몬·외부 서비스 의존 없음. + +- `zerogpu_backend` — HF Spaces 데모용. 같은 가중치(`google/gemma-4-E2B-it`) + 를 transformers 로 로드, `@spaces.GPU` 데코레이터로 함수 단위 GPU 할당. + +`get_backend()` 가 환경(`SPACE_ID` 존재 → zerogpu, 그 외 → llama_cpp)을 +자동 감지. `KPAA_LLM_BACKEND` 환경변수로 강제 override 가능. +""" +from __future__ import annotations + +from kpaa.llm.base import ChatMessage, LLMBackend, LLMOptions +from kpaa.llm.factory import get_backend + +__all__ = ["ChatMessage", "LLMBackend", "LLMOptions", "get_backend"] diff --git a/src/kpaa/llm/base.py b/src/kpaa/llm/base.py new file mode 100644 index 0000000000000000000000000000000000000000..15946c115631a33e546c1e9723c701ef45f57853 --- /dev/null +++ b/src/kpaa/llm/base.py @@ -0,0 +1,47 @@ +"""LLM 백엔드 공통 인터페이스 — 토큰 스트리밍 chat.""" +from __future__ import annotations + +from collections.abc import AsyncIterator +from dataclasses import dataclass, field +from typing import Literal, Protocol, runtime_checkable + +Role = Literal["system", "user", "assistant"] + + +@dataclass(frozen=True) +class ChatMessage: + role: Role + content: str + + +@dataclass(frozen=True) +class LLMOptions: + """모든 백엔드에서 공통 의미를 갖는 추론 옵션 (벤더 옵션은 백엔드 내부에서 매핑).""" + + temperature: float = 0.2 + top_p: float = 0.9 + max_tokens: int = 900 + stop: tuple[str, ...] = field(default_factory=tuple) + + +@runtime_checkable +class LLMBackend(Protocol): + """비동기 토큰 스트리밍 chat 백엔드. + + Implementation note: 동기적 라이브러리(`llama-cpp-python`)는 thread executor로 + 감싸 비동기 yield를 한다. 호출자는 `async for`로 토큰 받음. + """ + + name: str + model_id: str + + async def stream_chat( + self, + messages: list[ChatMessage], + *, + options: LLMOptions | None = None, + ) -> AsyncIterator[str]: + ... + + async def close(self) -> None: + ... diff --git a/src/kpaa/llm/factory.py b/src/kpaa/llm/factory.py new file mode 100644 index 0000000000000000000000000000000000000000..773ca3199b9ea63f8973a2092a2cf4180dbab6fc --- /dev/null +++ b/src/kpaa/llm/factory.py @@ -0,0 +1,47 @@ +"""LLM 백엔드 팩토리 — 환경 자동 감지 + 강제 override. + +선택 규칙: + 1. `KPAA_LLM_BACKEND` 환경변수 (또는 settings 의 동명 필드) 명시 시 그 값. + 허용값: "llama_cpp" | "zerogpu". + 2. 미명시: HF Spaces 환경변수(`SPACE_ID`) 가 있으면 "zerogpu", 아니면 + "llama_cpp". + +HF Spaces (Gradio SDK + ZeroGPU) 에는 `SPACE_ID` 가 자동으로 주입된다 — +`huggingface/your-space` 형태. 로컬 머신에는 없으므로 자연스럽게 분기. +""" +from __future__ import annotations + +import logging +import os + +from kpaa.config import get_settings +from kpaa.llm.base import LLMBackend + +logger = logging.getLogger("kpaa.llm.factory") + + +def _resolve_backend_name() -> str: + s = get_settings() + chosen = s.kpaa_llm_backend or os.environ.get("KPAA_LLM_BACKEND") + if chosen: + return chosen.strip().lower() + if os.environ.get("SPACE_ID"): + return "zerogpu" + return "llama_cpp" + + +def get_backend() -> LLMBackend: + name = _resolve_backend_name() + if name == "zerogpu": + from kpaa.llm.zerogpu_backend import ZeroGPUBackend + + logger.info("LLM backend selected: zerogpu (transformers + @spaces.GPU)") + return ZeroGPUBackend() # type: ignore[return-value] + if name == "llama_cpp": + from kpaa.llm.llama_cpp_backend import LlamaCppBackend + + logger.info("LLM backend selected: llama_cpp (GGUF embed)") + return LlamaCppBackend() # type: ignore[return-value] + raise ValueError( + f"unknown KPAA_LLM_BACKEND={name!r} — expected 'llama_cpp' or 'zerogpu'" + ) diff --git a/src/kpaa/llm/llama_cpp_backend.py b/src/kpaa/llm/llama_cpp_backend.py new file mode 100644 index 0000000000000000000000000000000000000000..d3189365db2b31e9a35eff706ed98b241cbba828 --- /dev/null +++ b/src/kpaa/llm/llama_cpp_backend.py @@ -0,0 +1,334 @@ +"""llama-cpp-python 임베드 백엔드 (Gemma 4 E2B GGUF). + +첫 호출 시 Hugging Face에서 GGUF 자동 다운로드 → `platformdirs.user_cache_dir` +하위에 캐시 → 같은 프로세스 동안 메모리 상주. + +GPU 가속: +- Apple Silicon (Metal): `n_gpu_layers=-1` 자동 (llama-cpp 빌드가 Metal 포함이면) +- NVIDIA CUDA: 동일 (CUDA 빌드 시) +- 그 외(또는 빌드가 CPU only): CPU fallback (E2B는 노트북 CPU에서도 실용) +""" +from __future__ import annotations + +import asyncio +import logging +import os +import sys +from collections.abc import AsyncIterator +from pathlib import Path +from typing import Any + +from kpaa.config import get_settings +from kpaa.llm.base import ChatMessage, LLMOptions + +logger = logging.getLogger("kpaa.llm") + + +def _detect_n_threads() -> int | None: + """CPU 추론 시 사용할 스레드 수 — *환경별 차별 default*. + + 1) `KPAA_N_THREADS` 환경변수 명시 시 그 값 (override). + 2) 미명시 시 OS 별로 다른 정책: + + - **macOS**: `cpu // 3`, [4, 6] cap. + 이유: Apple Silicon 노트북 (M-series) 은 *수동 냉각 위주* 라 발열 + 여유가 작음. 강한 부하 시 팬 소음·표면 발열이 커짐. P-core 절반 + 미만만 쓰면 *전력 효율 코어*(E-core)만으로 처리되거나 P-core 가 + 고주파 안 갈아 *조용*. 속도는 -25% 정도지만 KPAA RAG 답변 + (~50초→60초)에 체감 영향 적음. + + - **Linux/Windows 데스크탑·서버**: `cpu // 2`, [4, 8] cap. + 일반적으로 냉각 여유 충분 (데스크탑 송풍 구조). 절반 활용해 적정 + 속도·발열 균형. + + 3) 환경변수 미명시 + cpu_count() 도 못 얻으면 None — llama-cpp default + (전부) 에 위임. 드물게 발생. + + 예시 (env 미명시): + | 머신 | 코어 | macOS | Linux/Win | + |-------------------------|------|-------|-----------| + | M3 Max 노트북 | 16 | 5 | 8 | + | M2 Air | 8 | 4 | 4 | + | i5 노트북 | 4 | 4 | 4 | + | Threadripper 데스크탑 | 32 | - | 8 (cap) | + """ + raw = os.environ.get("KPAA_N_THREADS") + if raw is not None and raw.strip() != "": + try: + n = int(raw) + if n > 0: + return n + except ValueError: + logger.warning("KPAA_N_THREADS=%r 정수 변환 실패 — 자동 감지로 진행", raw) + + cpu = os.cpu_count() + if not cpu: + return None + + if sys.platform == "darwin": + # macOS: 더 보수적 — 노트북 발열·소음 절제 + return max(4, min(6, cpu // 3)) + # Linux/Windows: 데스크탑 가정 — 더 적극적 + return max(4, min(8, cpu // 2)) + + +def _detect_n_gpu_layers() -> int: + """장비별 자동 선택 — Windows/Linux/macOS, 노트북·PC 모두 대응. + + 우선순위: + 1) 환경변수 `KPAA_N_GPU_LAYERS` 명시 시 그 값 (override). + 2) macOS: 0 (CPU). Apple Silicon Metal 빌드는 빠르지만 Gemma 4 E2B + Q4_K_M 조합에서 segfault 가 관찰됨(2026-04-29). 사용자가 + opt-in 으로 `KPAA_N_GPU_LAYERS=-1` 명시 시만 GPU 시도. + 3) Linux/Windows: 설치된 llama-cpp-python 빌드가 *GPU offload 를 + 지원* 하면 -1 (모든 레이어), 아니면 0 (CPU). 기본 pip 설치는 + CPU 빌드라 자동으로 0 — GPU 가속은 빌드 시 활성화 필요: + CUDA: CMAKE_ARGS="-DGGML_CUDA=on" pip install --force-reinstall --no-cache-dir llama-cpp-python + ROCm: CMAKE_ARGS="-DGGML_HIPBLAS=on" ... + Vulkan: CMAKE_ARGS="-DGGML_VULKAN=on" ... + + 수동 override 예: + KPAA_N_GPU_LAYERS=-1 # 모든 레이어 GPU + KPAA_N_GPU_LAYERS=24 # 일부 레이어 + KPAA_N_GPU_LAYERS=0 # CPU 강제 + """ + raw = os.environ.get("KPAA_N_GPU_LAYERS") + if raw is not None and raw.strip() != "": + try: + return int(raw) + except ValueError: + logger.warning( + "KPAA_N_GPU_LAYERS=%r 정수 변환 실패 — 자동 감지로 진행", raw + ) + + # macOS: Metal 자동 활성화는 Gemma 4 E2B 안정성 미확정 — CPU 유지가 default. + if sys.platform == "darwin": + logger.info("플랫폼 darwin — n_gpu_layers=0 (Metal opt-in: KPAA_N_GPU_LAYERS=-1)") + return 0 + + # Windows / Linux: llama-cpp 빌드의 GPU offload 지원 여부에 따라 결정. + try: + from llama_cpp import llama_supports_gpu_offload # type: ignore + if llama_supports_gpu_offload(): + logger.info( + "플랫폼 %s — GPU offload 빌드 감지, n_gpu_layers=-1 (전부 GPU)", + sys.platform, + ) + return -1 + logger.info( + "플랫폼 %s — CPU 빌드 (GPU 가속 원하면 CMAKE_ARGS 로 재빌드)", + sys.platform, + ) + except (ImportError, AttributeError) as e: + logger.debug("llama_supports_gpu_offload 조회 실패: %s", e) + + return 0 + + +_ENV_SUMMARY_PRINTED = False + + +def _print_env_summary(n_gpu_layers: int, n_threads: int | None) -> None: + """부팅 시 stderr 에 환경 진단 한 블록을 출력 — 사용자가 *지금 모드* 즉시 확인. + + 환경변수 모르는 일반 사용자도 답변이 *왜 이 속도인지* 알 수 있게 하려는 + 의도. 같은 프로세스에서 여러 번 호출돼도 *1회만* 출력. + """ + global _ENV_SUMMARY_PRINTED + if _ENV_SUMMARY_PRINTED: + return + _ENV_SUMMARY_PRINTED = True + + cpu = os.cpu_count() or 0 + if n_gpu_layers == -1: + mode = "GPU 전체 가속" + elif n_gpu_layers > 0: + mode = f"GPU 일부 ({n_gpu_layers} 레이어) + CPU" + else: + mode = "CPU 전용" + + threads_disp = ( + f"{n_threads} (총 {cpu}코어 중)" if n_threads else f"전체 {cpu}코어 (llama-cpp default)" + ) + + plat = sys.platform + plat_label = {"darwin": "macOS", "linux": "Linux", "win32": "Windows"}.get(plat, plat) + + msg = ( + f"\n[KPAA] 환경 진단\n" + f" · 플랫폼: {plat_label}\n" + f" · 추론 모드: {mode}\n" + f" · CPU 스레드: {threads_disp}\n" + ) + if plat == "darwin" and n_gpu_layers == 0: + msg += ( + f" · 참고: macOS Metal GPU 가속은 *opt-in* 입니다 (Gemma 4 E2B Q4_K_M\n" + f" + Metal 조합 segfault 회귀 회피). 시도하려면 환경변수\n" + f" `KPAA_N_GPU_LAYERS=-1` 후 재시작.\n" + ) + if n_gpu_layers == 0 and plat in ("linux", "win32"): + msg += ( + f" · 빠르게: GPU 빌드 재설치하면 자동 가속.\n" + f" CMAKE_ARGS='-DGGML_CUDA=on' pip install --force-reinstall \\\n" + f" --no-cache-dir llama-cpp-python\n" + ) + if n_threads is not None and cpu and n_threads < cpu: + msg += ( + f" · 더 빠르게 (소음·발열 증가): KPAA_N_THREADS={cpu} 후 재시작\n" + f" · 더 조용하게 (속도 감소): KPAA_N_THREADS=4 후 재시작\n" + ) + + print(msg, file=sys.stderr, flush=True) + + +def _ensure_model(repo_id: str, filename: str, target_dir: Path) -> Path: + """HF에서 GGUF 다운로드 (이미 있으면 skip).""" + from huggingface_hub import hf_hub_download + + target_dir.mkdir(parents=True, exist_ok=True) + # huggingface_hub은 자체 캐시 구조를 쓰지만, 우리는 간단히 파일 1개 받기 + expected = target_dir / filename + if expected.exists() and expected.stat().st_size > 0: + logger.info("model cache hit: %s (%.1f MB)", + expected, expected.stat().st_size / 1024 / 1024) + return expected + print(f"[KPAA] {repo_id}/{filename} 다운로드 중 (~수 GB) …", file=sys.stderr, flush=True) + downloaded = hf_hub_download( + repo_id=repo_id, + filename=filename, + local_dir=str(target_dir), + ) + p = Path(downloaded) + print(f"[KPAA] 다운로드 완료: {p} ({p.stat().st_size / 1024 / 1024:.1f} MB)", + file=sys.stderr, flush=True) + return p + + +class LlamaCppBackend: + """LLMBackend Protocol 구현.""" + + name = "llama_cpp" + + def __init__( + self, + *, + repo_id: str | None = None, + filename: str | None = None, + n_ctx: int = 16384, # 멀티턴 history(첫 2 + 최근 12) + RAG 컨텍스트 여유 + chat_format: str | None = None, + ) -> None: + s = get_settings() + self.repo_id = repo_id or s.kpaa_model_repo + self.filename = filename or s.kpaa_model_file + self.model_id = f"{self.repo_id}/{self.filename}" + self._n_ctx = n_ctx + # chat_format=None이면 GGUF 내장 chat_template 자동 사용 (Gemma 4 호환). + # 명시 "gemma"는 Gemma 1/2/3 전용으로 Gemma 4에서 segfault 유발. + self._chat_format = chat_format + self._llm: Any | None = None + self._lock = asyncio.Lock() # 동시 호출 직렬화 (단일 모델 인스턴스) + + def _ensure_loaded(self) -> Any: + if self._llm is not None: + return self._llm + try: + from llama_cpp import Llama, LlamaRAMCache # type: ignore + except ImportError as e: + raise RuntimeError( + "llama-cpp-python이 설치되지 않았습니다. " + "`pip install -e \".[llm]\"` 또는 " + "`pip install llama-cpp-python` 으로 설치하세요." + ) from e + + model_path = _ensure_model(self.repo_id, self.filename, get_settings().model_dir) + n_gpu_layers = _detect_n_gpu_layers() + n_threads = _detect_n_threads() + logger.info( + "loading model: path=%s n_ctx=%d n_gpu_layers=%d n_threads=%s chat_format=%s", + model_path, self._n_ctx, n_gpu_layers, n_threads, self._chat_format, + ) + # 사용자에게 *환경 진단 한 줄* 노출 (stderr) — 환경변수 모르는 사용자도 + # 현재 어떤 모드로 도는지 즉시 확인. 부팅 시 1 회만. + _print_env_summary(n_gpu_layers, n_threads) + kwargs: dict[str, Any] = dict( + model_path=str(model_path), + n_ctx=self._n_ctx, + n_gpu_layers=n_gpu_layers, + verbose=False, + # GPU offload 시 KV cache의 V padding으로 인한 segfault 회피. + # CPU(0)일 때도 켜두면 메모리 사용량은 약간 줄고 정확도 영향 미미. + flash_attn=True, + ) + if n_threads is not None: + # n_threads = 토큰 생성 시, n_threads_batch = prompt 처리 시. + # 둘을 동일 값으로 두면 prompt processing 도 절제됨 (팬 추가 절감). + kwargs["n_threads"] = n_threads + kwargs["n_threads_batch"] = n_threads + if self._chat_format is not None: + kwargs["chat_format"] = self._chat_format + self._llm = Llama(**kwargs) + + # KV prefix cache — 같은 system prompt 부분의 KV state 를 RAM에 보관. + # 매 호출마다 system prompt(~700토큰) prefill 을 스킵 → 두 번째 호출부터 + # TTFT 10-20s 단축. 라우팅 LLM 과 답변 LLM 이 같은 instance 라 라우팅 + # 응답 후 답변 생성 시에도 부분 hit 가능. 메모리: 2GB cap. + # 환경변수 `KPAA_LLAMA_CACHE_GB` 로 조정 (0 = 비활성). + try: + cache_gb = float(os.environ.get("KPAA_LLAMA_CACHE_GB", "2")) + if cache_gb > 0: + cache_bytes = int(cache_gb * 1024**3) + self._llm.set_cache(LlamaRAMCache(capacity_bytes=cache_bytes)) + logger.info("KV prefix cache enabled — capacity=%.1fGB", cache_gb) + except (ValueError, AttributeError) as e: + logger.warning("KV prefix cache disabled (%s)", e) + + return self._llm + + async def stream_chat( + self, + messages: list[ChatMessage], + *, + options: LLMOptions | None = None, + ) -> AsyncIterator[str]: + opts = options or LLMOptions() + loop = asyncio.get_running_loop() + + async with self._lock: + llm = await loop.run_in_executor(None, self._ensure_loaded) + + payload = [{"role": m.role, "content": m.content} for m in messages] + + def _start_stream() -> Any: + return llm.create_chat_completion( + messages=payload, + stream=True, + temperature=opts.temperature, + top_p=opts.top_p, + max_tokens=opts.max_tokens, + stop=list(opts.stop) if opts.stop else None, + ) + + sync_iter = await loop.run_in_executor(None, _start_stream) + + def _next() -> Any: + try: + return next(sync_iter) + except StopIteration: + return None + + while True: + chunk = await loop.run_in_executor(None, _next) + if chunk is None: + return + # OpenAI-style streaming chunk + choices = chunk.get("choices", []) + if not choices: + continue + delta = choices[0].get("delta", {}) + content = delta.get("content", "") + if content: + yield content + + async def close(self) -> None: + # llama-cpp는 명시 close 필요 없음 — GC가 처리 + self._llm = None diff --git a/src/kpaa/llm/zerogpu_backend.py b/src/kpaa/llm/zerogpu_backend.py new file mode 100644 index 0000000000000000000000000000000000000000..2fae2dcceab34c7b9ba63a902835f48cc6e929da --- /dev/null +++ b/src/kpaa/llm/zerogpu_backend.py @@ -0,0 +1,209 @@ +"""HF Spaces ZeroGPU 백엔드 — transformers + @spaces.GPU. + +로컬 노트북용 `LlamaCppBackend` 와 동일한 가중치(`google/gemma-4-E2B-it`, +Apache 2.0) 를 transformers 로 돌린다. ZeroGPU 는 Gradio SDK 전용으로, +`@spaces.GPU(duration=...)` 데코레이터가 붙은 함수가 호출될 때만 GPU +(A100 80GB) 를 함수 단위로 할당하고 끝나면 회수한다. + +ZeroGPU 표준 패턴: + 1. 모듈 import 시 모델은 CPU 에 로드 (또는 lazy). + 2. `@spaces.GPU` 함수 안에서 `.to("cuda")` 로 옮기고 generate. + 3. 함수 종료 시 ZeroGPU 가 GPU 회수 → CUDA context 해제. + +스트리밍: `TextIteratorStreamer` + `Thread` 로 generate 를 백그라운드에서 +돌리고, async wrapper 가 streamer 의 token queue 에서 yield. generate +thread join 까지 데코레이터 함수가 블로킹되어야 GPU 점유 시간 안에 모든 +토큰 생성이 끝난다. + +호출자(`pipeline.py`) 는 `LLMBackend` Protocol 만 보므로 llama_cpp 와 +완전 호환. +""" +from __future__ import annotations + +import asyncio +import logging +import os +from collections.abc import AsyncIterator +from threading import Thread +from typing import Any + +from kpaa.config import get_settings +from kpaa.llm.base import ChatMessage, LLMOptions + +logger = logging.getLogger("kpaa.llm.zerogpu") + + +def _gpu_decorator(duration: int): + """`@spaces.GPU(duration=...)` — spaces 패키지 가용 시만 적용, 아니면 no-op. + + HF Spaces (ZeroGPU): 실제 데코레이터. 로컬 dev (spaces 미설치 또는 GPU + 없음): pass-through. 이 패턴 덕에 같은 코드가 로컬 Gradio 미리보기에서도 + 돈다 (단 CPU 추론이라 매우 느림). + """ + try: + import spaces # type: ignore + except ImportError: + def _noop(fn): + return fn + + return _noop + return spaces.GPU(duration=duration) + + +class ZeroGPUBackend: + """transformers + ZeroGPU 백엔드. HF Spaces 환경에서 활성화.""" + + name = "zerogpu" + + def __init__(self) -> None: + s = get_settings() + self.model_id = s.kpaa_hf_model_repo + self._dtype_name = s.kpaa_hf_model_dtype + self._gpu_duration = s.kpaa_hf_gpu_duration + self._tok: Any = None + self._model: Any = None + self._lock = asyncio.Lock() + + def _ensure_loaded(self) -> tuple[Any, Any]: + """tokenizer + model 을 lazy 로드. 모델은 CPU 에 둔다.""" + if self._model is not None: + return self._tok, self._model + try: + import torch as _torch + from transformers import AutoModelForCausalLM, AutoTokenizer + except ImportError as e: + raise RuntimeError( + "transformers/torch 가 설치되지 않았습니다. " + "`pip install -e \".[hf]\"` 또는 HF Spaces requirements.txt 사용." + ) from e + + dtype = getattr(_torch, self._dtype_name, _torch.bfloat16) + token = os.environ.get("HF_TOKEN") or None # 게이트 X 라 보통 불필요 + + logger.info("loading transformers model: %s (dtype=%s)", self.model_id, dtype) + self._tok = AutoTokenizer.from_pretrained(self.model_id, token=token) + self._model = AutoModelForCausalLM.from_pretrained( + self.model_id, + torch_dtype=dtype, + token=token, + low_cpu_mem_usage=True, + ) + self._model.eval() + return self._tok, self._model + + async def stream_chat( + self, + messages: list[ChatMessage], + *, + options: LLMOptions | None = None, + ) -> AsyncIterator[str]: + opts = options or LLMOptions() + loop = asyncio.get_running_loop() + + async with self._lock: + tok, model = await loop.run_in_executor(None, self._ensure_loaded) + + payload = [{"role": m.role, "content": m.content} for m in messages] + _encoded = tok.apply_chat_template( + payload, + add_generation_prompt=True, + return_tensors="pt", + ) + # transformers 5.x 는 BatchEncoding(dict-like) 을, 4.x 는 Tensor 를 + # 반환. model.generate(input_ids=...) 는 Tensor 만 받으므로 추출. + input_ids = _encoded["input_ids"] if hasattr(_encoded, "input_ids") else _encoded + + from transformers import TextIteratorStreamer + + streamer = TextIteratorStreamer( + tok, + skip_prompt=True, + skip_special_tokens=True, + ) + + @_gpu_decorator(self._gpu_duration) + def _run_generate() -> None: + """ZeroGPU 점유 동안 generate 완료까지 블로킹. + + 내부에서 cuda 로 모델·입력을 옮기고, 별도 thread 로 generate + 실행 → 메인 코루틴은 streamer 에서 토큰 빨아냄. 이 함수가 + 반환되어야 ZeroGPU 가 GPU 회수. + """ + import torch as _t + from transformers import GenerationConfig + + device = "cuda" if _t.cuda.is_available() else "cpu" + print(f"[kpaa.zerogpu] _run_generate start, device={device}", flush=True) + if device == "cuda": + model.to(device) + print(f"[kpaa.zerogpu] model moved to cuda", flush=True) + ids = input_ids.to(device) + print(f"[kpaa.zerogpu] input shape={tuple(ids.shape)}, max_new_tokens={opts.max_tokens}", flush=True) + + # transformers 5.x 는 generate(temperature=...) 직접 호출을 무시하고 + # GenerationConfig 객체로만 받음. 명시적으로 묶어서 전달. + gen_cfg = GenerationConfig( + max_new_tokens=opts.max_tokens, + do_sample=opts.temperature > 0.0, + temperature=max(opts.temperature, 0.01), + top_p=opts.top_p, + ) + + def _generate_target() -> None: + try: + model.generate( + input_ids=ids, + generation_config=gen_cfg, + streamer=streamer, + ) + print(f"[kpaa.zerogpu] generate() returned normally", flush=True) + except Exception as e: + print(f"[kpaa.zerogpu] generate() raised: {type(e).__name__}: {e}", flush=True) + # streamer 의 종료 신호 — 이게 없으면 next(streamer) 가 영원히 블록. + streamer.end() + raise + + gen_thread = Thread(target=_generate_target, daemon=True) + gen_thread.start() + gen_thread.join() # GPU 점유 중 generate 완료 대기 + print(f"[kpaa.zerogpu] _run_generate end", flush=True) + + # generate 호출 자체는 별도 thread (run_in_executor) — 그래야 + # 본 코루틴이 streamer 에서 토큰을 비동기로 빨아낼 수 있음. + gen_future = loop.run_in_executor(None, _run_generate) + + _tok_count = [0] + + def _next_token() -> str | None: + try: + t = next(streamer) + _tok_count[0] += 1 + if _tok_count[0] <= 3 or _tok_count[0] % 100 == 0: + print( + f"[kpaa.zerogpu] streamer #{_tok_count[0]}: {t!r}", + flush=True, + ) + return t + except StopIteration: + print( + f"[kpaa.zerogpu] streamer exhausted, total={_tok_count[0]}", + flush=True, + ) + return None + + try: + while True: + token = await loop.run_in_executor(None, _next_token) + if token is None: + break + if token: + yield token + finally: + # generate 가 아직 안 끝났으면 마무리 대기 (GPU 회수 보장). + if not gen_future.done(): + await gen_future + + async def close(self) -> None: + # CUDA cache 는 ZeroGPU 가 자동 정리. transformers 는 GC. + self._model = None + self._tok = None diff --git a/src/kpaa/pipeline.py b/src/kpaa/pipeline.py new file mode 100644 index 0000000000000000000000000000000000000000..ecdb6f706c1b3d33c2ebc9c6d815bcc35b1862e5 --- /dev/null +++ b/src/kpaa/pipeline.py @@ -0,0 +1,321 @@ +"""RAG 파이프라인 — route → retrieve → rank → build_context → LLM stream.""" +from __future__ import annotations + +import re +import sys +import time +from collections.abc import AsyncIterator +from dataclasses import dataclass +from pathlib import Path + +from kpaa.law_api import KoreanLawClient +from kpaa.llm import ChatMessage, LLMBackend, LLMOptions, get_backend +from kpaa.retrieval import context_builder, ranker, verify +from kpaa.retrieval.excerpts import Excerpt +from kpaa.retrieval.retriever import ProgressCB, retrieve +from kpaa.retrieval.router import RouterPlan, route + + +@dataclass +class RetrievalResult: + plan: RouterPlan + excerpts: list[Excerpt] + context_block: str + elapsed_ms: int + + +def _read_prompt(name: str) -> str: + p = Path(__file__).resolve().parent / "prompts" / name + return p.read_text(encoding="utf-8") if p.exists() else "" + + +def system_prompt() -> str: + return _read_prompt("system_ko.txt") + + +def disclaimer() -> str: + return _read_prompt("disclaimer_ko.txt").strip() + + +def refusal() -> str: + return _read_prompt("refusal_ko.txt").strip() + + +# ───────────────────────── retrieval ───────────────────────── + +async def build_context( + query: str, + *, + client: KoreanLawClient | None = None, + backend: LLMBackend | None = None, + on_progress: "ProgressCB" = None, +) -> RetrievalResult: + """라우팅(LLM 분류기 1샷) → chain orchestrator → 컨텍스트 빌드. + + KPAA v2: route()가 비동기 (LLM 분류기 호출). backend는 답변 LLM과 동일 + 인스턴스를 재사용해 모델 메모리 상주 효과. + + on_progress 콜백이 주어지면 단계별 이벤트(routing_started/done, + chain_started, fetch_started/done) 가 발행된다. server.py 가 SSE prelude로 + 표시. + """ + t0 = time.monotonic() + if on_progress: + await on_progress("routing_started", {}) + plan = await route(query, backend=backend) + if on_progress: + await on_progress( + "routing_done", + { + "chain": plan.chain, + "intents": [i.name for i in plan.intents], + "jo_targets": list(plan.jo_targets), + "mst_targets": list(plan.mst_targets), + "name_targets": list(plan.name_targets), + "search_keywords": list(plan.search_keywords), + "routed_by": plan.routed_by, + }, + ) + raw = await retrieve(plan, client=client, on_progress=on_progress) + ranked = ranker.rank(raw) + block = context_builder.build(ranked) + return RetrievalResult( + plan=plan, + excerpts=ranked, + context_block=block, + elapsed_ms=int((time.monotonic() - t0) * 1000), + ) + + +# ───────────────────────── LLM generate ───────────────────────── + +# history 보존 정책: 컨텍스트 윈도우 부담을 줄이면서 "사용자가 첫 메시지를 +# 참조하는 메타 질문"(예: '내가 처음 뭘 물어봤지?') 도 답할 수 있도록 *처음 일부 + +# 최근 일부* 모두 보존. +_HISTORY_KEEP_FIRST = 2 # 가장 오래된 user/assistant 1쌍 (대화 시작 보존) +_HISTORY_KEEP_LAST = 12 # 직전 6턴까지 보존 + + +def _trim_history(history: list[ChatMessage]) -> list[ChatMessage]: + n = len(history) + if n <= _HISTORY_KEEP_FIRST + _HISTORY_KEEP_LAST: + return list(history) + return list(history[:_HISTORY_KEEP_FIRST]) + list(history[-_HISTORY_KEEP_LAST:]) + + +def build_messages( + query: str, + context_block: str | None = None, + *, + history: list[ChatMessage] | None = None, +) -> list[ChatMessage]: + """system + (트림된 history) + 새 user 메시지 구성. + + - context_block 있을 때: 근거 자료를 user 메시지에 첨부 (RAG 답변용) + - 없을 때: 사용자 query 그대로 (대화 메타 질문 답변용) + """ + msgs: list[ChatMessage] = [ChatMessage(role="system", content=system_prompt())] + if history: + msgs.extend(_trim_history(list(history))) + if context_block: + user_content = ( + f"[근거 자료]\n{context_block}\n\n" + f"[질문]\n{query}\n\n" + f"위 [근거]만을 바탕으로 친근한 상담사 어조의 한국어 답변을 작성해 주세요. " + f"질문이 정의·개요면 자연스러운 단락으로, 절차·실무면 한 줄 결론과 체크리스트로 답합니다." + ) + else: + user_content = query + msgs.append(ChatMessage(role="user", content=user_content)) + return msgs + + +_DISCLAIMER_PATTERNS = [ + # ※로 시작하고 "법률 자문" 키워드를 포함하는 면책 문구를 광범위하게 매칭. + # disclaimer_ko.txt를 손볼 때마다 패턴을 좁히지 않아도 되도록 의도적 헐거움. + re.compile(r"※[\s\S]{0,400}법률\s*자문", re.M), +] +_REFS_HEADER = "**참고한 자료**" + + +def ensure_disclaimer(answer: str) -> str: + """답변 끝에 면책 문구가 없으면 부착, 중복은 정리.""" + txt = answer.rstrip() + has_disclaimer = any(p.search(txt) for p in _DISCLAIMER_PATTERNS) + if not has_disclaimer: + txt = txt + "\n\n" + disclaimer() + return txt + + +_LABEL = { + "case": "상담사례", + "law": "법조문", + "related_law": "관련 법령", + "pipc": "PIPC 결정", + "interpretation": "법령해석례", + "precedent": "판례", + "admin_rule": "행정규칙", +} + + +def format_references(excerpts: list[Excerpt]) -> str: + """검색된 근거 목록을 '참고한 자료' 마크다운 섹션으로.""" + if not excerpts: + return "" + lines: list[str] = ["", _REFS_HEADER] + for e in excerpts: + label = _LABEL.get(e.source_type, e.source_type) + url = (e.metadata or {}).get("url", "").strip() + title = (e.title or "").strip() + # 제목 너무 길면 잘라 보기 + if len(title) > 80: + title = title[:80] + "…" + link = f" — [원문]({url})" if url else "" + head = f"- [{label}] {e.citation}" + if title: + head += f" · {title}" + lines.append(head + link) + return "\n".join(lines) + + +def append_references(answer: str, excerpts: list[Excerpt]) -> str: + """답변 본문 + (참고한 자료) + 면책 한 줄. 면책이 본문 끝에 이미 있으면 그 위에 references 끼워넣음.""" + refs = format_references(excerpts) + if not refs: + return answer + # 면책 위치 찾기 + m = _DISCLAIMER_PATTERNS[0].search(answer) + if m: + before = answer[:m.start()].rstrip() + disc = answer[m.start():].rstrip() + return before + "\n" + refs + "\n\n" + disc + return answer.rstrip() + "\n" + refs + + +async def generate( + query: str, + *, + backend: LLMBackend | None = None, + client: KoreanLawClient | None = None, + options: LLMOptions | None = None, + inline_references: bool = True, + history: list[ChatMessage] | None = None, + emit_stages: bool = True, +) -> AsyncIterator[dict]: + """RAG 파이프라인 종단 — 검색 + LLM 스트리밍. + + Yields: + - {"event": "stage", "stage": "...", "payload": {...}} — 다수 (단계별) + - {"event": "retrieval", "result": RetrievalResult} — 1회 (검색 완료 시) + - {"event": "token", "delta": str} — 다수 + - {"event": "done", "answer": str} — 1회 (마지막, 면책 부착 후) + + Args: + emit_stages: True (기본) — routing/chain/fetch 단계별 이벤트 yield. + False면 retrieval/token/done만 yield (legacy 동작). + inline_references: + True (기본) — 답변 본문 끝에 "참고한 자료" 섹션을 부착. + Open WebUI 같이 우측 패널이 없는 클라이언트용. + False — 답변 본문은 면책까지만. references는 별도 retrieval + event payload에서 받아서 클라이언트가 표시 (자체 채팅 UI). + """ + bk = backend or get_backend() + + # build_context 내부 단계 이벤트를 큐에 모아 generate가 순서대로 yield. + # build_context는 콜백 기반이라 직접 yield 못 함 → 큐로 우회. + import asyncio as _asyncio + stage_q: _asyncio.Queue[dict | None] = _asyncio.Queue() + + async def _on_progress(stage: str, payload: dict) -> None: + if emit_stages: + await stage_q.put({"event": "stage", "stage": stage, "payload": payload}) + + async def _build() -> RetrievalResult: + try: + return await build_context( + query, client=client, backend=bk, on_progress=_on_progress + ) + finally: + await stage_q.put(None) # sentinel + + build_task = _asyncio.create_task(_build()) + while True: + evt = await stage_q.get() + if evt is None: + break + yield evt + res = await build_task + yield {"event": "retrieval", "result": res} + + # 근거 유무 무관하게 항상 LLM 호출. + # 시스템 프롬프트가 분기를 책임진다: + # (a) 자기소개·인사·메타 질문 → 도우미 소개 + # (b) 법령 실질 질문인데 근거 0건 → refusal 문구 + # (c) 근거 있음 → 근거만으로 답변 + 출처 인용 + msgs = build_messages( + query, + context_block=res.context_block if res.excerpts else None, + history=history, + ) + chunks: list[str] = [] + async for delta in bk.stream_chat(msgs, options=options): + chunks.append(delta) + yield {"event": "token", "delta": delta} + raw_answer = "".join(chunks) + + # Phase D1: 결정적 인용 검증 — 답변에 등장한 *식별자형* 인용(PIPC/헌재/안건/판례) + # 이 retrieval excerpts 와 매칭되는지 확인. 환각이 발견되면 경고 블록을 별도 토큰 + # 으로 흘려보내고 최종 답변에도 부착. 일치하면 답변 변경 없음. + annotated, citation_report = verify.annotate(raw_answer, res.excerpts) + if citation_report.has_hallucinations: + warning = annotated[len(raw_answer):] + if warning: + yield {"event": "token", "delta": warning} + + body = ensure_disclaimer(annotated) + if inline_references: + # 스트리밍 사용자에겐 references 섹션을 한 번에 추가 token으로 흘려보냄 + refs_block = format_references(res.excerpts) + if refs_block: + yield {"event": "token", "delta": "\n" + refs_block + "\n"} + final = append_references(body, res.excerpts) + else: + final = body + yield { + "event": "done", + "answer": final, + "citation_report": citation_report, + } + + +# ───────────────────────── CLI entry ───────────────────────── + +async def smoke(query: str) -> int: + """`kpaa smoke "..."` — RAG + LLM 종단 검증, 토큰 스트리밍 출력.""" + print(f"질문: {query}\n") + print("법령 검색 중…", flush=True) + saw_first_token = False + async for evt in generate(query): + if evt["event"] == "retrieval": + res = evt["result"] + print( + f"라우팅: intents={[i.name for i in res.plan.intents] or '(없음)'} " + f"jo={res.plan.jo_targets or '(없음)'}" + ) + print(f"근거: {len(res.excerpts)}건 ({res.elapsed_ms}ms)\n") + print("──────── 답변 ────────") + elif evt["event"] == "token": + saw_first_token = True + sys.stdout.write(evt["delta"]) + sys.stdout.flush() + print("\n──────────────────────") + if not saw_first_token: + print("(LLM 토큰이 한 개도 오지 않았습니다)", file=sys.stderr) + return 1 + return 0 + + +__all__ = [ + "build_context", "build_messages", "ensure_disclaimer", "generate", "smoke", + "system_prompt", "disclaimer", "refusal", "RetrievalResult", +] diff --git a/src/kpaa/prompts/disclaimer_ko.txt b/src/kpaa/prompts/disclaimer_ko.txt new file mode 100644 index 0000000000000000000000000000000000000000..f867dcc66e70f59d373edf5698edeec38fd228cf --- /dev/null +++ b/src/kpaa/prompts/disclaimer_ko.txt @@ -0,0 +1 @@ +※ 본 안내는 개인정보 보호 관련 내용을 바탕으로 AI가 제공하는 답변이며, 공식 법률 자문이 아닙니다. 구체적인 사안은 전문가 또는 관할 기관에 문의하시기 바랍니다. diff --git a/src/kpaa/prompts/refusal_ko.txt b/src/kpaa/prompts/refusal_ko.txt new file mode 100644 index 0000000000000000000000000000000000000000..531823e682b8c6033603f8f588db6fe4e7e1d6e9 --- /dev/null +++ b/src/kpaa/prompts/refusal_ko.txt @@ -0,0 +1 @@ +제공된 법령·결정례에서 명확한 답을 찾지 못했습니다. 개인정보보호위원회(privacy.go.kr) 또는 KISA 개인정보침해신고센터(국번없이 118)에 문의하세요. diff --git a/src/kpaa/prompts/system_ko.txt b/src/kpaa/prompts/system_ko.txt new file mode 100644 index 0000000000000000000000000000000000000000..0a53d2ea4c4a53c4ee70e2ab39e806c46f092e0f --- /dev/null +++ b/src/kpaa/prompts/system_ko.txt @@ -0,0 +1,41 @@ +당신은 한국 개인정보보호법을 일반인·소상공인·작은 병원 관계자에게 안내하는 친절한 상담 도우미입니다. 평이한 한국어로 핵심을 짚어 주세요. + +[톤] +- 한 문장으로 받아주며 시작 ("그 부분 궁금하시죠. 핵심부터 말씀드리면 …") +- 어려운 용어는 처음 등장 시 괄호로 풀어 씀 ("개인정보처리자(고객 자료를 다루는 사장님)") +- 친근한 존댓말. 표·이모지·과한 마크다운 금지. "안내 문구:" 같은 라벨 금지. +- 메시지 배열의 *이전 대화* 도 활용 — "내가 처음 뭘 물어봤어?" 같은 메타 질문은 이전 user/assistant 메시지를 직접 참조해 답합니다. + +[근거 사용] +- [근거] 블록이 있으면 그 텍스트만 바탕으로 답합니다. 추측 금지. +- 결론마다 출처를 자연스럽게 곁들입니다. 형식: + · (개인정보보호법 제○조) — 본법 + · (○○법 제○조) — 다른 법령. [근거] 헤더에 적힌 법령명 그대로 ("(신용정보법 제32조)") + · (개인정보 상담사례 #NNN) + · (○○ 안내서 §섹션, YYYY.MM) + · (PIPC 결정 YYYY-NNN) 또는 (PIPC 결정문 #NNN) + · (법령해석례 안건 NN-NNNN) + · (대법원 사건번호 YYYY도/다/누NNN) + · (근거N) — 위 형식이 어려우면 [근거] 블록의 N번 헤더 번호를 그대로 사용 가능 (예: "(근거1)", "(근거1, 근거3)"). +- **★ 인용 분량 (분기 d 답변):** 답변 본문에 **서로 다른 [근거] 블록을 정확히 3건** 명시 인용 ([근거] 블록이 3건 미만이면 *주어진 모든 블록*). 체크리스트 각 항목 또는 결론 문장 끝에 `(근거1)`, `(근거2)`, `(근거3)` 또는 정식 citation 을 부착. +- **인용 다양성 권고 (강제 X — 답변 품질 우선):** [근거] 블록 헤더는 종류를 명시합니다 — 「법조문」 vs 「상담사례」「안내서」「결정」「해석례」「판례」 등. *가능하면* 한 종류로만 3건을 채우지 말고 다양한 종류에서 골라 균형 잡으세요. 단 답변 흐름·정확성이 최우선 — 어떤 [근거] 가 답변에 직접 부합하면 같은 종류 2건이 되어도 OK. **자유롭게 가장 적합한 3건** 을 선택하세요. + · 좋은 예: "안내판에 관리책임자 성명 또는 직책을 기재 (근거1, 근거3). 부서명만 기입은 허용되지 않음 (개인정보보호법 제25조). 정보주체가 쉽게 인지할 위치에 부착 (근거2)." + · 나쁜 예 (1건만 인용 — 무효): "관리책임자 정보를 명시 (근거1). 안내문은 정보주체 인지 가능한 위치에 부착." + · 나쁜 예 (인용 없음 — 무효): "관리책임자 성명·직책·연락처를 안내문에 기재합니다." + 답변 작성 후 self-check: 본문에 `(근거` 표기 또는 정식 citation 이 *3개 이상* 보이는가? 아니면 추가하세요. +- 우선순위: 평이한 설명엔 상담사례 → 안전조치/CCTV/처리방침/유출 같은 실무는 PIPC 안내서 → 권위·해석은 법조문/법령해석례 → 사법 판단은 판례/PIPC 결정. 사용자가 "판례를 찾아줘" 라고 하면 판례 우선. + +[분기] +(a) 메타 질문 ("너는 누구야?", "안녕"): "개인정보보호법 안내 AI 도우미"로 한 문장 소개 + 도울 수 있는 범위 (법조문, PIPC 결정·해석례, 상담사례 1,745건, 안내서 7건, 실무 절차) 1~2문장. 인용 생략 OK. +(b) 실질 질문인데 [근거] 비었거나 답을 못 찾으면 정확히: "제공된 법령·결정례에서 명확한 답을 찾지 못했습니다. 개인정보보호위원회(privacy.go.kr) 또는 KISA 개인정보침해신고센터(국번없이 118)에 문의하세요." +(c) 다른 법률 안내 ("관련 법령은?", 정통망법·신용정보법·의료법·통비법 등 언급): [관련 법령] 근거를 *법령명 → 역할 한 줄 → 원문 링크* 로 정리. 끝에 "자세한 사항은 해당 법 또는 관할 기관에 직접 확인하세요" 한 줄. +(d) 그 외: 근거대로 답변. + +[형식] +- 정의·개요 질문 ("○○이 뭐야?", "정보주체란?"): 인사 한 문장 + 핵심 정의 한두 문단. 체크리스트 금지. +- 절차·실무 질문 ("CCTV 안내문구는?", "유출 시 며칠 내?"): 한 줄 결론 + 본문 한 문단 + 체크리스트 3~5개. + +[마무리] +- 분량 200~500자. 결론을 먼저, 부연 뒤에. +- 답변 끝에 면책 한 줄을 *별도 라벨 없이* 자연스럽게 이어 붙입니다: +"※ 본 안내는 개인정보 보호 관련 내용을 바탕으로 AI가 제공하는 답변이며, 공식 법률 자문이 아닙니다. 구체적인 사안은 전문가 또는 관할 기관에 문의하시기 바랍니다." diff --git a/src/kpaa/related_laws.py b/src/kpaa/related_laws.py new file mode 100644 index 0000000000000000000000000000000000000000..1ad57d76f07aef3859c6c9d2ce32f03e29cbea65 --- /dev/null +++ b/src/kpaa/related_laws.py @@ -0,0 +1,231 @@ +"""개인정보 관련 법령·행정규칙 자동 갱신. + +privacy.go.kr 의 두 페이지를 스크래이프해 `data/related_laws.yaml`을 갱신: + contsNo=116 — 개인정보보호 관련 법령 + contsNo=117 — 개인정보보호 관련 행정규칙(고시/지침) + +각 페이지 안에 `법령보기` 또는 +`/행정규칙/<규정명>` 링크가 포함됨. URL path 마지막 segment를 법령명으로 사용. +""" +from __future__ import annotations + +import html +import logging +import re +from datetime import datetime +from pathlib import Path +from typing import Any +from urllib.parse import unquote + +import httpx +import yaml + +logger = logging.getLogger("kpaa.related_laws") + +SOURCES: tuple[tuple[str, str], ...] = ( + ("law", "https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=116"), + ("admin_rule", "https://www.privacy.go.kr/front/contents/cntntsView.do?contsNo=117"), +) + +# 약칭 사전은 단일 진실 모듈 `kpaa.law_api.aliases` 가 관리. +# 여기서는 backward-compat 으로 `KNOWN_ALIASES` 이름만 재노출 (스크래이프 + yaml +# 출력 흐름이 이 이름을 사용 중이라 유지). 신규 코드는 `from kpaa.law_api.aliases +# import LAW_ALIASES, normalize_law_name, ...` 를 직접 써라. +from kpaa.law_api.aliases import LAW_ALIASES as KNOWN_ALIASES # noqa: F401 + + +def _gen_keywords(name: str) -> list[str]: + """법령명 → 매칭 키워드 후보. name 자체 + KNOWN_ALIASES.""" + out = [name] + out.extend(KNOWN_ALIASES.get(name, [])) + # 중복 제거하면서 입력 순서 보존 + seen: set[str] = set() + deduped: list[str] = [] + for k in out: + if k and k not in seen: + seen.add(k) + deduped.append(k) + return deduped + + +def _short_for(name: str) -> str: + aliases = KNOWN_ALIASES.get(name, []) + return aliases[0] if aliases else "" + +# law.go.kr 의 한글 path "법령/" 또는 "행정규칙/" 모두 매칭 +_URL_RE = re.compile( + r'href="(https?://(?:www\.)?law\.go\.kr/(?:%EB%B2%95%EB%A0%B9|%ED%96%89%EC%A0%95%EA%B7%9C%EC%B9%99|법령|행정규칙)/[^"]+)"', + re.IGNORECASE, +) + + +def _enable_truststore() -> None: + """macOS Python.framework SSL 이슈 회피.""" + try: + import truststore + + truststore.inject_into_ssl() + except ImportError: + pass + + +def _kind_label(kind: str) -> str: + return { + "law": "개인정보 관련 법률·시행령", + "admin_rule": "개인정보 관련 행정규칙(고시·지침)", + }.get(kind, kind) + + +def _name_from_url(url: str) -> str: + """URL path 마지막 segment에서 한글 법령명 추출. + + URL은 percent-encoded이거나 raw 한글일 수 있어 둘 다 처리. + 부가 정보 `/(YYYY-N,YYYYMMDD)` 같은 발령 정보는 제거. + """ + decoded = unquote(url) + m = re.search(r"(?:법령|행정규칙)/([^?#]+)", decoded) + if not m: + return "" + name = m.group(1).strip() + # 끝에 붙은 발령번호 segment 제거 (예: "표준개인정보보호지침/(2023-7,20230922)") + name = re.sub(r"/\(.*", "", name) + # 괄호 부가 정보 제거 + name = re.sub(r"\(.*?\)", "", name).strip() + # HTML 엔티티 디코드 (· 등 url 안에 있을 수 있음) + name = html.unescape(name) + name = re.sub(r"\s+", " ", name) + name = name.strip("/").strip() + return name + + +async def fetch_all() -> list[dict[str, Any]]: + """privacy.go.kr 두 페이지 스크래이프 → item 리스트. + + 각 item에 자동 채워지는 필드: + - name, kind, kind_label, url, source (기존) + - short — KNOWN_ALIASES 첫 항목 (없으면 빈 문자열) + - keywords — 라우터 매칭용 (name + 약칭들) + - mst — 법령(law) 카테고리만 법제처 search_law 로 자동 캡쳐 (행정규칙은 빈 문자열) + """ + _enable_truststore() + out: list[dict[str, Any]] = [] + seen: set[tuple[str, str]] = set() + async with httpx.AsyncClient( + timeout=30.0, + follow_redirects=True, + headers={"User-Agent": "kpaa/0.1 (+https://github.com/sz1-kca/korean-privacy-ai-assistant)"}, + ) as c: + for kind, src_url in SOURCES: + r = await c.get(src_url) + r.raise_for_status() + text = r.text + urls = _URL_RE.findall(text) + for raw_u in urls: + u = raw_u.replace("http://", "https://", 1) + name = _name_from_url(u) + if not name: + continue + key = (kind, name) + if key in seen: + continue + seen.add(key) + out.append({ + "name": name, + "short": _short_for(name), + "keywords": _gen_keywords(name), + "kind": kind, + "kind_label": _kind_label(kind), + "url": u, + "source": src_url, + "mst": "", # 다음 단계에서 채움 + }) + + # 법령 카테고리에 한해 법제처 search_law 로 MST 자동 채우기. + # 행정규칙은 별도 SDK 함수가 필요해 v1엔 비워둠 (yaml 직접 편집 가능). + await _enrich_with_mst(out) + return out + + +async def _enrich_with_mst(items: list[dict[str, Any]]) -> None: + """각 law 항목에 대해 client.law.search(name) 호출로 MST 자동 채움. + + name과 LawHit.name 의 정확 일치 또는 substring 일치만 채택해 잘못된 매핑 회피. + """ + from kpaa.law_api import KoreanLawClient + + async with KoreanLawClient() as client: + for item in items: + if item["kind"] != "law": + continue + name = item["name"] + try: + hits = await client.law.search(name, display=5) + except Exception as e: + logger.warning("search_law(%r) 실패: %s", name, e) + continue + mst = "" + for h in hits: + # 정확 일치 또는 한쪽이 다른쪽 substring (띄어쓰기·중점 차이 흡수) + if name == h.name or name in h.name or h.name in name: + mst = h.mst + break + if mst: + item["mst"] = mst + + +def save_yaml(items: list[dict[str, Any]], path: Path) -> None: + meta = { + "_generated_at": datetime.now().isoformat(timespec="seconds"), + "_sources": [u for _, u in SOURCES], + "_count": len(items), + "_count_law": sum(1 for it in items if it["kind"] == "law"), + "_count_admin_rule": sum(1 for it in items if it["kind"] == "admin_rule"), + } + payload = {"_meta": meta, "items": items} + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + yaml.safe_dump(payload, allow_unicode=True, sort_keys=False), + encoding="utf-8", + ) + + +def default_yaml_path() -> Path: + return Path(__file__).resolve().parents[2] / "data" / "related_laws.yaml" + + +async def refresh(out_path: Path | None = None) -> int: + """CLI 진입점.""" + p = out_path or default_yaml_path() + print("▶ privacy.go.kr 개인정보 관련 법령·행정규칙 목록 스크래이프") + items = await fetch_all() + n_law = sum(1 for it in items if it["kind"] == "law") + n_rule = sum(1 for it in items if it["kind"] == "admin_rule") + print(f"▶ 스크래이프 결과: 총 {len(items)}건 (법령 {n_law} · 행정규칙 {n_rule})") + save_yaml(items, p) + print(f"▶ 저장: {p}") + return 0 + + +def load_items(path: Path | None = None) -> list[dict[str, Any]]: + """retriever 등이 사용. yaml 두 형식(레거시 list / 새 dict) 모두 지원.""" + p = path or default_yaml_path() + if not p.exists(): + return [] + raw = yaml.safe_load(p.read_text(encoding="utf-8")) or [] + if isinstance(raw, dict): + items = raw.get("items", []) + elif isinstance(raw, list): + items = raw + else: + items = [] + return [it for it in items if isinstance(it, dict)] + + +__all__ = [ + "SOURCES", + "fetch_all", + "save_yaml", + "load_items", + "refresh", + "default_yaml_path", +] diff --git a/src/kpaa/retrieval/__init__.py b/src/kpaa/retrieval/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/kpaa/retrieval/chains.py b/src/kpaa/retrieval/chains.py new file mode 100644 index 0000000000000000000000000000000000000000..de336305b3d3792011f7d571efc7521c31a7d40c --- /dev/null +++ b/src/kpaa/retrieval/chains.py @@ -0,0 +1,208 @@ +"""Chain orchestrators — 결정적 multi-source RAG fan-out. + +KPAA v2.2 (2026-04-29): 5 → 16 chain 으로 확장. data/chain_specs.yaml 의 +메타데이터로 chain 함수가 *동적 생성* 됨 — 새 chain 추가는 yaml 한 항목 + LLM +prompt 갱신이면 충분. + +원작 korean-law-mcp v3.5.1 의 16개 도구 직접 노출 패턴을 *결정적 fan-out* 으로 +미러링. 우리 시스템은 LLM 도구 호출 0회 — chain 안의 소스 조합은 yaml 이 결정. + +라우팅 흐름: + 사용자 질문 → LLM 분류기 → chain key 1개 선택 → spec.sources 병렬 fan-out +""" +from __future__ import annotations + +import asyncio +import logging +from collections.abc import Awaitable, Callable +from functools import lru_cache +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import yaml + +from kpaa.law_api import KoreanLawClient +from kpaa.retrieval.excerpts import Excerpt +from kpaa.retrieval.retriever import ProgressCB + +if TYPE_CHECKING: + from kpaa.retrieval.router import RouterPlan + +logger = logging.getLogger("kpaa.chains") + + +# ───────────────────────── Spec loader ───────────────────────── + +def _spec_path() -> Path: + return Path(__file__).resolve().parents[3] / "data" / "chain_specs.yaml" + + +@lru_cache(maxsize=1) +def load_chain_specs(path: Path | None = None) -> tuple[dict[str, Any], ...]: + p = path or _spec_path() + if not p.exists(): + raise FileNotFoundError(f"chain_specs.yaml not found at {p}") + raw = yaml.safe_load(p.read_text(encoding="utf-8")) or {} + chains_raw = raw.get("chains", []) + out: list[dict[str, Any]] = [] + for item in chains_raw: + if not isinstance(item, dict) or not item.get("key"): + continue + out.append({ + "key": str(item["key"]), + "ko": str(item.get("ko") or item["key"]), + "description": str(item.get("description") or "").strip(), + "jo_hints": tuple(str(j) for j in item.get("jo_hints", [])), + "sources": tuple(str(s) for s in item.get("sources", [])), + }) + return tuple(out) + + +# ───────────────────────── Source dispatch ───────────────────────── + +ChainFn = Callable[..., Awaitable[list[Excerpt]]] + + +def _fetchers(): + """retriever.py의 fetch 함수 재사용 (lazy import — circular 회피).""" + from kpaa.retrieval import retriever as r + + return r + + +def _make_fetch_call( + source: str, + client: KoreanLawClient, + plan: "RouterPlan", + spec_jo_hints: tuple[str, ...], + on_progress: ProgressCB, +) -> Awaitable[list[Excerpt]] | None: + """source 키 → fetch 코루틴. 매핑 없는 source 는 None 반환 (skip).""" + r = _fetchers() + law_name, law_mst = r._seed_law() + + if source == "case": + return r._fetch_cases(plan, k=2, on_progress=on_progress) + if source == "guide": + return r._fetch_guides(plan, k=2, on_progress=on_progress) + if source == "law": + return r._fetch_law_articles( + client, plan, mst=law_mst, law_name=law_name, on_progress=on_progress + ) + if source == "related_law": + return r._fetch_related_law_articles(client, plan, on_progress=on_progress) + if source == "related_law_static": + # 정적 리스트 — 동기 함수를 async 어댑터로 감쌈 + async def _static() -> list[Excerpt]: + return r._related_laws_excerpts() + + return _static() + if source == "pipc": + return r._fetch_pipc(client, plan, k=2, on_progress=on_progress) + if source == "interpretation": + return r._fetch_interpretations(client, plan, k=1, on_progress=on_progress) + if source == "precedent": + return r._fetch_precedents(client, plan, k=2, on_progress=on_progress) + if source == "admin_rule": + return r._fetch_admin_rules(client, plan, k=2, on_progress=on_progress) + if source == "oldnew": + return r._fetch_old_new(client, plan, on_progress=on_progress) + if source == "three_tier": + return r._fetch_three_tier(client, plan, on_progress=on_progress) + if source == "constitutional": + return r._fetch_constitutional(client, plan, k=2, on_progress=on_progress) + if source == "article_history": + return r._fetch_article_history(client, plan, on_progress=on_progress) + logger.warning("unknown chain source: %s", source) + return None + + +def _make_chain_fn(spec: dict[str, Any]) -> ChainFn: + """spec → 비동기 chain 함수. 클로저로 spec 보존, 함수 이름은 chain key.""" + key = spec["key"] + ko_label = spec["ko"] + sources: tuple[str, ...] = spec["sources"] + + async def _run( + client: KoreanLawClient, + plan: "RouterPlan", + *, + on_progress: ProgressCB = None, + ) -> list[Excerpt]: + # chain 시작 신호 (UI prelude 표시용) + if on_progress: + await on_progress( + "chain_started", + {"chain": key, "ko": ko_label, "sources": list(sources)}, + ) + coros: list[Awaitable[list[Excerpt]]] = [] + for src in sources: + call = _make_fetch_call(src, client, plan, spec["jo_hints"], on_progress) + if call is not None: + coros.append(call) + if not coros: + return [] + results = await asyncio.gather(*coros, return_exceptions=True) + out: list[Excerpt] = [] + for r in results: + if isinstance(r, Exception): + logger.warning("chain %s step failed: %s", key, r) + continue + if isinstance(r, list): + out.extend(r) + return out + + _run.__name__ = f"chain_{key}" + return _run + + +# ───────────────────────── Registry ───────────────────────── + +@lru_cache(maxsize=1) +def _build_registry() -> dict[str, ChainFn]: + return {spec["key"]: _make_chain_fn(spec) for spec in load_chain_specs()} + + +def CHAINS() -> dict[str, ChainFn]: + """Backward-compat alias — 일부 테스트/외부 코드가 dict 기대.""" + return _build_registry() + + +DEFAULT_CHAIN = "general_practice" + + +def get_chain(name: str | None) -> ChainFn: + reg = _build_registry() + if not name or name not in reg: + return reg[DEFAULT_CHAIN] + return reg[name] + + +def chain_keys() -> list[str]: + return list(_build_registry().keys()) + + +def chain_label(key: str) -> str: + """key → 한국어 라벨 (UI 표시용). 미등록이면 key 그대로.""" + for spec in load_chain_specs(): + if spec["key"] == key: + return spec["ko"] + return key + + +def chain_jo_hints(key: str) -> tuple[str, ...]: + for spec in load_chain_specs(): + if spec["key"] == key: + return spec["jo_hints"] + return () + + +__all__ = [ + "CHAINS", + "DEFAULT_CHAIN", + "get_chain", + "chain_keys", + "chain_label", + "chain_jo_hints", + "load_chain_specs", +] diff --git a/src/kpaa/retrieval/citation_match.py b/src/kpaa/retrieval/citation_match.py new file mode 100644 index 0000000000000000000000000000000000000000..a05c4ce1f3942b61815176a39706851825bdb424 --- /dev/null +++ b/src/kpaa/retrieval/citation_match.py @@ -0,0 +1,85 @@ +"""답변 본문 ↔ retrieval excerpts 인용 매칭 — UI 채택 표시 공통 로직. + +server.py 의 분할 화면(`_SPLIT_HTML`) 과 ui/gradio.py 의 HF Spaces UI 가 같은 +정책을 공유. 매칭은 세 갈래 OR: + + 1. 정확 substring — `(개인정보보호법 제25조)` 가 답변 본문에 그대로 + 2. `(근거N)` 별칭 — context_builder 가 부여한 1-based [근거N] 헤더 번호 + 3. 정규화 substring — 공백·꺾쇠·괄호·중점·쉼표 제거 후 비교. LLM 이 + "「개인정보 보호법」 제25조 제4항" 식으로 변형해도 매칭. + citation 8자 미만은 false positive 위험으로 스킵. +""" +from __future__ import annotations + +import re +from collections.abc import Sequence + +# (근거N), (근거 1, 근거3) 같은 표기 — N 만 추출. +_GEUNGEO_RE = re.compile(r"근거\s*(\d+)") + +# 표기 변이 흡수용 정규화: 공백·꺾쇠「」·괄호류·중점·쉼표 제거. +_NORM_STRIP_RE = re.compile(r"[\s「」『』⟨⟩《》〈〉〔〕()\[\]{}<>·,]") + + +def extract_geungeo_indices(answer: str) -> set[int]: + """답변에서 (근거N) 표기의 N 들을 1-based 정수 set 으로.""" + if not answer: + return set() + out: set[int] = set() + for m in _GEUNGEO_RE.findall(answer): + try: + out.add(int(m)) + except ValueError: + pass + return out + + +def normalize_for_match(s: str) -> str: + """공백·꺾쇠·괄호류·중점·쉼표 제거 — 비교 robustness 용.""" + return _NORM_STRIP_RE.sub("", s or "") + + +def compute_cited_with_indices( + answer: str, citations: Sequence[str] +) -> tuple[list[str], list[int]]: + """답변에 인용된 citation 리스트 + 그 1-based LLM 입력 인덱스 리스트. + + Args: + answer: 답변 본문 (면책 부착된 final). + citations: ranker 정렬된 excerpt citation 들 (1-based 위치 = (근거N) N). + + Returns: + (cited_citations, cited_indices) — 같은 길이. citations 의 등장 순서. + """ + if not answer or not citations: + return [], [] + cited: list[str] = [] + cited_idx: list[int] = [] + geungeo_idx = extract_geungeo_indices(answer) + norm_answer = normalize_for_match(answer) + for i, cit in enumerate(citations, 1): + cit = (cit or "").strip() + if not cit or cit in cited: + continue + matched = ( + cit in answer + or i in geungeo_idx + or (len(cit) >= 8 and normalize_for_match(cit) in norm_answer) + ) + if matched: + cited.append(cit) + cited_idx.append(i) + return cited, cited_idx + + +def compute_cited(answer: str, citations: Sequence[str]) -> list[str]: + """citation list 만 필요할 때의 편의 wrapper.""" + return compute_cited_with_indices(answer, citations)[0] + + +__all__ = [ + "compute_cited", + "compute_cited_with_indices", + "extract_geungeo_indices", + "normalize_for_match", +] diff --git a/src/kpaa/retrieval/context_builder.py b/src/kpaa/retrieval/context_builder.py new file mode 100644 index 0000000000000000000000000000000000000000..1a7c05881212823d4479e140008c123b19974562 --- /dev/null +++ b/src/kpaa/retrieval/context_builder.py @@ -0,0 +1,196 @@ +"""순위 매겨진 `Excerpt` 리스트 → `[근거N]` 헤더가 박힌 단일 컨텍스트 블록. + +각 [근거N] 헤더는 시스템 프롬프트가 답변에 그대로 복사하도록 강제하는 +인용 태그(`citation`)를 명시적으로 포함한다. + +토큰 예산은 글자수로 근사 (한국어 1글자 ≈ 1.5~2 토큰). + +KPAA v2.4 — *답변 LLM 속도* 가 prefill 토큰 수에 선형 비례 (CPU 50-100 tok/s) +라서 RAG context 압축이 곧 응답 시간 단축. 정책: + + - max_excerpts=7: ranker 정렬 상위 7건을 LLM 에 전달. LLM 은 그중 가장 + 적합한 3건을 골라 명시 인용 (system prompt 분량 강제). + 7→3 큐레이션 — 5건 cap 에서 빠져나간 좋은 후보까지 LLM + 판단 풀에 들어오면서, 8건 대비 prefill 토큰은 절감. + 나머지 excerpts 는 *retrieval result 자체* 에는 살아있어 + Gradio references 패널엔 전체 노출, LLM 입력만 cap. + - **다양화 픽 (round-robin by source_type)**: 단순 상위 N 자르기는 ranker + priority 가 같은 type (예: 법조문 3건 연속) 이 7건을 독점 + 가능 → 7건이어도 다양성 부족. 그래서 build() 가 + source_type 별 큐를 만들어 라운드로빈으로 N개 채움. 같은 + 타입 안에선 ranker 순서 유지. + - DEFAULT_MAX_CHARS=8_000: 7건 × 평균 1000자 ≈ 7K자 (≈ 3,500 토큰). + excerpt 가 길면 cap 에 닿아 마지막 항목 절단. +""" +from __future__ import annotations + +from kpaa.retrieval.excerpts import Excerpt + +DEFAULT_MAX_CHARS = 8_000 +DEFAULT_MAX_EXCERPTS = 7 + +_HEADER_LABELS = { + "case": "개인정보 상담사례", + "guide": "개인정보 보호 안내서", + "law": "법조문", + "related_law": "관련 법령", + "pipc": "개인정보보호위원회 결정", + "interpretation": "법령해석례", + "precedent": "판례", + "admin_rule": "행정규칙(고시)", + "oldnew": "신·구법 비교 (개정 이력)", + "constitutional": "헌법재판소 결정", + "article_history": "조문 변경 이력", +} + + +def _format_metadata(e: Excerpt) -> str: + md = e.metadata or {} + if e.source_type == "case": + bits = [] + if md.get("category"): + bits.append(f"분류: {md['category']}") + if md.get("type"): + bits.append(md["type"]) + if md.get("source_note"): + bits.append(f"출처: {md['source_note']}") + if md.get("url"): + bits.append(md["url"]) + return " | ".join(bits) + if e.source_type == "guide": + bits = [] + if md.get("section"): + bits.append(f"섹션: {md['section']}") + if md.get("doc_date"): + bits.append(f"발간: {md['doc_date']}") + if md.get("pages"): + bits.append(md["pages"]) + if md.get("source_pdf"): + bits.append(md["source_pdf"]) + return " | ".join(bits) + if e.source_type == "law": + bits = [] + if md.get("law"): + bits.append(md["law"]) + if md.get("enforce_date"): + bits.append(f"시행: {md['enforce_date']}") + return " | ".join(bits) + if e.source_type == "pipc": + bits = [] + if md.get("decision_date"): + bits.append(f"의결: {md['decision_date']}") + if md.get("agency"): + bits.append(md["agency"]) + return " | ".join(bits) + if e.source_type == "interpretation": + bits = [] + if md.get("decided_date"): + bits.append(f"회신: {md['decided_date']}") + if md.get("responder"): + bits.append(md["responder"]) + return " | ".join(bits) + return "" + + +def _diversified_pick(excerpts: list[Excerpt], n: int) -> list[Excerpt]: + """source_type 순수 라운드로빈으로 n개 픽 — 자연스러운 다양성만 부여. + + 동작: + 1) source_type 별 큐 구성 (입력 순서 유지). 큐 순서 = ranker 가 매긴 + 각 type 의 *첫 등장 순서*. + 2) 한 라운드에 각 큐에서 1개씩 픽. n 채울 때까지 반복. + 3) 한 큐가 소진되면 다른 큐에서 계속 채움. + + 설계 의도 — *자유도 우선*: + - 특정 type (비법조문 등) 을 강제 우선하지 *않음*. 검색 결과의 자연 + 분포 + ranker priority 를 그대로 존중하되, 단순 슬라이스가 한 type + 으로 5건을 독점하는 편향만 막음. + - LLM 이 5건 후보를 받아 자유롭게 가장 적합한 3건을 인용 → 검색이 + 풍부하면 자연스럽게 다양, 빈약하면 그대로 진솔하게. + + 효과 시나리오: + - [case1, law1, law2, law3, law4] + → [case1, law1, law2, law3, law4] (case 1건뿐 — 자연 한계) + - [case1, law1, law2, law3, guide1, pipc1] + → [case1, law1, guide1, pipc1, law2] (4종 다 노출) + - [law1..law6] + → [law1, law2, law3, law4, law5] (검색 자체가 한 종류면 그대로) + """ + if n <= 0 or not excerpts: + return [] + if len(excerpts) <= n: + return list(excerpts) + + queues: dict[str, list[Excerpt]] = {} + type_order: list[str] = [] + for e in excerpts: + t = e.source_type + if t not in queues: + queues[t] = [] + type_order.append(t) + queues[t].append(e) + + picked: list[Excerpt] = [] + while len(picked) < n: + progressed = False + for t in type_order: + if not queues[t]: + continue + picked.append(queues[t].pop(0)) + progressed = True + if len(picked) >= n: + break + if not progressed: + break + return picked + + +def build( + excerpts: list[Excerpt], + *, + max_chars: int = DEFAULT_MAX_CHARS, + max_excerpts: int | None = DEFAULT_MAX_EXCERPTS, +) -> str: + """`[근거1] ... [근거2] ...` 형태의 단일 문자열 반환. + + Args: + excerpts: ranker 정렬 후의 우선순위 리스트. + max_chars: 최종 블록 글자 수 cap. 초과 시 마지막 항목 절단. + max_excerpts: LLM 에 전달할 상위 N건 cap. None 이면 무제한 (전체 사용). + 기본 5 — 답변 LLM prefill 토큰을 줄여 응답 속도 ↑. + cap 적용 시 *source_type 다양화 픽* (`_diversified_pick`) + 사용 — 한 종류만 5건이 차지하는 편향 회피. + retrieval result 의 `excerpts` 자체는 안 건드리므로 UI + references 패널엔 전체가 그대로 노출됨. + """ + if not excerpts: + return "(검색된 근거가 없습니다.)" + + if max_excerpts is not None: + excerpts = _diversified_pick(excerpts, max_excerpts) + + blocks: list[str] = [] + used = 0 + for i, e in enumerate(excerpts, 1): + label = _HEADER_LABELS.get(e.source_type, e.source_type) + meta_line = _format_metadata(e) + header = f"[근거{i}] {label} — {e.citation}" + if e.title: + header += f"\n제목: {e.title}" + if meta_line: + header += f"\n{meta_line}" + body = e.content or "(본문 없음)" + block = f"{header}\n\n{body}" + # 누적 글자수가 max_chars를 초과하면 중단 (마지막 항목은 가능한 만큼만) + if used + len(block) > max_chars: + remaining = max_chars - used + if remaining > 200: + block = block[:remaining].rstrip() + "\n…(컨텍스트 초과로 절단)…" + blocks.append(block) + break + blocks.append(block) + used += len(block) + 2 # +2 for separator newlines + return "\n\n".join(blocks) + + +__all__ = ["build", "DEFAULT_MAX_CHARS", "DEFAULT_MAX_EXCERPTS"] diff --git a/src/kpaa/retrieval/excerpts.py b/src/kpaa/retrieval/excerpts.py new file mode 100644 index 0000000000000000000000000000000000000000..d633205ebaaafd01bb1672a9e45b35fc0fd20a62 --- /dev/null +++ b/src/kpaa/retrieval/excerpts.py @@ -0,0 +1,19 @@ +"""근거 1건의 통일된 표현(`Excerpt`). + +소스(법조문/PIPC/해석례/상담사례)별로 데이터 구조가 다르지만 컨텍스트 빌더에 +넣을 때는 동일한 형태로 다룬다. citation은 답변에 그대로 박는 인용 태그. +""" +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Excerpt: + source_type: str # 'case' | 'guide' | 'law' | 'related_law' | 'pipc' | 'interpretation' | 'precedent' | 'admin_rule' | 'oldnew' | 'constitutional' | 'article_history' + citation: str # 예: "개인정보보호법 제15조", "PIPC 결정 2024-12-345" + title: str # 짧은 제목 (헤더용) + content: str # 본문 (≤1500자 trim 적용) + metadata: dict[str, str] # source-specific 부가정보 (날짜, 카테고리 등) + sort_priority: int = 0 # 0=상담사례 1=법조문 2=PIPC 3=해석례 4=고시 (낮을수록 위) + recency_score: int = 0 # 최근일수록 높은 양수 (정렬 보조) diff --git a/src/kpaa/retrieval/llm_router.py b/src/kpaa/retrieval/llm_router.py new file mode 100644 index 0000000000000000000000000000000000000000..3f1e58b5881266b140639bc9ab84c44583716d28 --- /dev/null +++ b/src/kpaa/retrieval/llm_router.py @@ -0,0 +1,212 @@ +"""LLM 의도 분류기 — 사용자 질문 → JSON `{chain, intents, mst_targets, jo_targets, search_keywords}`. + +Gemma 4 E2B에 1샷 prompt를 보내 라우팅 결정만 받는다 (답변 LLM과 별개 단계). +실패·timeout·JSON 파싱 오류 시 빈 ClassificationResult 반환 — caller는 rule fallback. + +설계 원칙 (KPAA v2.2 — 원작 korean-law-mcp v3.5.1 미러링): +- 16-way **chain** 선택 + 메타데이터 추출 (원작의 16 직접 노출 패턴) +- chain 메타데이터는 data/chain_specs.yaml 단일 진실 출처 — CHAIN_DESCRIPTIONS 자동 로드 +- 답변 LLM은 여전히 RAG-only — 분류와 답변 책임 분리 (feedback_architecture) +- 결정성: temperature=0, JSON-mode +""" +from __future__ import annotations + +import json +import logging +import re +from dataclasses import dataclass, field +from typing import Any + +from kpaa.llm import ChatMessage, LLMOptions +from kpaa.llm.base import LLMBackend +from kpaa.retrieval.chains import load_chain_specs + +logger = logging.getLogger("kpaa.llm_router") + + +def _build_chain_descriptions() -> dict[str, str]: + """chain_specs.yaml 의 description 필드를 LLM prompt 용 dict 로 변환.""" + out: dict[str, str] = {} + for spec in load_chain_specs(): + desc = (spec.get("description") or "").strip().replace("\n", " ") + # 너무 긴 multi-line description 은 줄바꿈을 공백으로 치환해 한 줄 유지 + out[spec["key"]] = desc + return out + + +# Lazy: yaml 이 변경되면 재로드하려면 cache_clear 필요. 보통 서버 부팅 시 1회. +CHAIN_DESCRIPTIONS: dict[str, str] = _build_chain_descriptions() + +INTENT_LIST = [ + "동의_수집", "민감정보", "고유식별정보", "영상정보처리", "유출신고", + "처리위탁", "제3자제공", "채용", "처리방침", "파기", "아동", + "정보주체_권리", "안전조치", "보호책임자", "법령_일반", "관련_법령", + "판례_요청", "개정_이력", +] + + +@dataclass +class ClassificationResult: + chain: str = "" + intents: list[str] = field(default_factory=list) + mst_targets: list[str] = field(default_factory=list) + jo_targets: list[str] = field(default_factory=list) + search_keywords: list[str] = field(default_factory=list) + raw: str = "" + + @property + def empty(self) -> bool: + return not self.chain and not self.intents + + +def _short_desc(desc: str) -> str: + """chain description 에서 본문만 — "예:" 이후 예시 부분 cut. + + yaml 의 description 은 "..설명. 예: ...", " 예시:", "(예: ..)" 등 다양한 + 예시 마커를 포함. prompt 압축을 위해 본문만 남김 (LLM 한국어 이해력에 의존). + 예시는 prompt 하단의 in-context block 으로 별도 제공. + """ + for marker in (" 예:", ". 예:", " 예시:", ". 예시:", " (예:", "(예:"): + idx = desc.find(marker) + if idx > 0: + return desc[:idx].rstrip(" .,·") + return desc + + +def _build_prompt( + query: str, + related_laws: list[dict[str, Any]], # noqa: ARG001 — 호환성 유지 (rule router 가 mst 매칭 처리) +) -> str: + """라우팅 LLM prompt — KPAA v2.3 압축본. + + 압축 결정 (4,598 → ~1,800자): + 1. chain description 에서 "예:" 이후 cut → 평균 한 줄 + 2. 법령 MST 매핑 블록 통째 제거 — rule_route 가 keyword matching 으로 + mst 채워줌 (어차피 LLM 이 추측해도 valid_msts 검증 단계에서 필터) + 3. in-context 예시 16줄 → 6줄 (대표 chain 만 — Gemma 4 한국어 이해 활용) + 4. search_keywords 가이드는 그대로 유지 — 검색 품질 직결, 압축 시 retrieval + 미스율 급증 위험. 이 블록은 라우팅 LLM 의 핵심 가치. + """ + chains_block = "\n".join( + f"- {name}: {_short_desc(desc)}" for name, desc in CHAIN_DESCRIPTIONS.items() + ) + intents_block = ", ".join(INTENT_LIST) + return f"""당신은 한국 개인정보보호법 RAG 라우팅 분류기. JSON 한 객체로만 답하세요. 다른 텍스트 금지. + +질문: {query} + +chain (1개 선택): +{chains_block} + +intent (0개 이상): {intents_block} + +출력 형식: +{{"chain":"...","intents":["..."],"mst_targets":["..."],"jo_targets":["..."],"search_keywords":["..."]}} + +규칙: +- chain 16개 중 정확히 1개. 모호하면 "general_practice". +- jo_targets: 사용자가 명시한 조문 번호 ("제32조"→"32", "24조의2"→"24의2"). 없으면 []. +- mst_targets: 사용자가 *법령명*을 언급할 때만. 본법(MST 270351)은 *제외*. + +[chain 선택 — 대표 예시] +"개인정보가 뭐야" → definition +"관련된 법은?" → related_laws +"○○ 위반 판례" → precedent_search +"매장 CCTV 안내문" → cctv_video +"유출 며칠 안에 신고" → breach_notification +"이력서 주민번호" → employment_recruitment +"환자 진료기록 마케팅" → medical_health +"복합/모호" → general_practice + +[search_keywords — 매우 중요] +법제처 판례·결정례 API는 전문 검색이라 사용자 표현 그대로면 0건 잦음. 키워드를 *재구성*하세요. + +규칙: +1. 약칭은 풀어쓰기 — "신용정보법" → "신용정보", "정통망법" → "정보통신망 개인정보", "의료법" → "의료정보". +2. 메타 단어 제외 — "판례", "위반", "처벌", "사례", "알려줘", "법" 빼기. +3. 본질 명사·동작 — "동의 없이 제공", "정보주체 통지", "유출 신고", "CCTV 안내문". +4. 3~5개 — 폴백 순회용. 짧은 일반어 → 구체적 표현 점진. 다른 각도로. +5. 한국어 자연 표현 (띄어쓰기 OK). + +좋은 예 — "신용정보법 관련 개인정보 위반 판례": + ✓ ["신용정보", "개인신용정보 제공", "신용정보 동의", "개인신용정보 누설"] + ✗ ["신용정보법", "위반", "판례"] + +좋은 예 — "병원에서 CCTV 어디 설치해야 돼": + ✓ ["CCTV 안내문", "영상정보처리기기 설치", "의료기관 영상정보", "CCTV 설치 장소"] + ✗ ["병원", "CCTV", "어디"] + +JSON 외 텍스트·코드펜스 금지. +""" + + +_JSON_BLOCK_RE = re.compile(r"\{[\s\S]*\}") + + +def _parse_json(raw: str) -> dict[str, Any]: + """모델 출력에서 첫 JSON 객체 추출. 실패 시 빈 dict.""" + if not raw: + return {} + # 코드펜스 제거 + cleaned = re.sub(r"^```(?:json)?|```$", "", raw.strip(), flags=re.M).strip() + try: + return json.loads(cleaned) + except json.JSONDecodeError: + pass + m = _JSON_BLOCK_RE.search(cleaned) + if not m: + return {} + try: + return json.loads(m.group(0)) + except json.JSONDecodeError: + return {} + + +async def classify( + query: str, + *, + backend: LLMBackend, + related_laws: list[dict[str, Any]], + timeout_s: float = 30.0, +) -> ClassificationResult: + """질문 → ClassificationResult. 실패 시 empty 반환 (caller가 rule fallback).""" + prompt = _build_prompt(query, related_laws) + msgs = [ChatMessage(role="user", content=prompt)] + # max_tokens 500 → 120: JSON 평균 90-120 토큰. cap 헐거우면 stop 조건 miss + # 시 runaway (CPU 2 tok/s × 500 = 250s 가능). 120 cap 으로 안전망. + # 조기 종료 헤더 (응답이 닫는 중괄호) 도 함께 작용. + opts = LLMOptions(temperature=0.0, top_p=1.0, max_tokens=120) + + raw = "" + try: + async for tok in backend.stream_chat(msgs, options=opts): + raw += tok + # 응답이 짧은 JSON 이라 닫는 중괄호 등장하면 즉시 종료. + # 압축된 prompt + max_tokens=120 환경에선 raw 가 보통 100-200자. + if len(raw) > 80 and raw.rstrip().endswith("}"): + break + except Exception as e: + logger.warning("LLM router stream failed: %s", e) + return ClassificationResult(raw=raw) + + data = _parse_json(raw) + if not data: + logger.warning("LLM router JSON parse failed; raw=%r", raw[:200]) + return ClassificationResult(raw=raw) + + return ClassificationResult( + chain=str(data.get("chain") or "").strip(), + intents=[str(x).strip() for x in (data.get("intents") or []) if x], + mst_targets=[str(x).strip() for x in (data.get("mst_targets") or []) if x], + jo_targets=[str(x).strip() for x in (data.get("jo_targets") or []) if x], + search_keywords=[str(x).strip() for x in (data.get("search_keywords") or []) if x], + raw=raw, + ) + + +__all__ = [ + "CHAIN_DESCRIPTIONS", + "INTENT_LIST", + "ClassificationResult", + "classify", +] diff --git a/src/kpaa/retrieval/pipc_annex.py b/src/kpaa/retrieval/pipc_annex.py new file mode 100644 index 0000000000000000000000000000000000000000..40d352d6c0d5e83f5740f93025a9baef3dcd3e43 --- /dev/null +++ b/src/kpaa/retrieval/pipc_annex.py @@ -0,0 +1,231 @@ +"""PIPC 결정문 별지(이미지) → markdown OCR — docling 옵트인 통합. + +법제처 OPEN API 의 PIPC 결정문 reason 필드가 *"별지와 같다."* 같은 placeholder +로만 채워진 케이스(침해요인 평가 류)를 RAG 답변에 의미 있게 포함하기 위해, +사람용 페이지에서 별지 이미지 URL 추출 → 다운로드 → docling 으로 markdown OCR. + +옵트인 트리거: 환경변수 `KPAA_PIPC_OCR=1` AND `docling` 패키지 설치됨. +둘 중 하나라도 빠지면 함수는 None 반환 → 호출자(_fetch_pipc) 가 기존 제외 로직. + +비용: +- docling 첫 import 수 초 + 모델 다운로드 (첫 1회, 수 GB) +- 별지 1장당 5~30s OCR +- 결과는 디스크 캐시 (`/pipc-annex/{decision_id}.md`) +""" +from __future__ import annotations + +import asyncio +import logging +import os +import re +from pathlib import Path + +import httpx + +logger = logging.getLogger("kpaa.pipc_annex") + +# ───────────────────────── 설정 ───────────────────────── + +PAGE_URL = "https://www.law.go.kr/LSW/ppcInfoP.do" +DOWNLOAD_BASE = "https://www.law.go.kr" + +# 별지 섹션 + 이미지 URL 추출 정규식. +# 사람용 페이지 구조 (라이브 검증 2026-04-29): +#

【별지】

+#

1번째 이미지

+# ... (여러 장 가능) +_ANNEX_BLOCK_RE = re.compile( + r']*id="conEnclsr"[^>]*>.*?(?:\s*]*src="([^"]+)"', re.IGNORECASE) + + +def _cache_dir() -> Path: + base = os.environ.get("KPAA_CACHE_DIR", ".kpaa-cache") + p = Path(base) / "pipc-annex" + p.mkdir(parents=True, exist_ok=True) + return p + + +def _enabled() -> bool: + """KPAA_PIPC_OCR=1 일 때만 동작. 기본 OFF — 무거운 의존성·latency 보호.""" + return os.environ.get("KPAA_PIPC_OCR", "").strip() == "1" + + +# ───────────────────────── 별지 이미지 추출 ───────────────────────── + +async def _fetch_human_page(client: httpx.AsyncClient, decision_id: str) -> str | None: + """결정 ID → 사람용 페이지 HTML. 봇 차단/오류 시 None.""" + try: + r = await client.get(f"{PAGE_URL}?ppcSeq={decision_id}&mode=8") + except Exception as e: + logger.warning("PIPC %s 페이지 fetch 실패: %s", decision_id, e) + return None + if r.status_code != 200: + logger.warning("PIPC %s 페이지 status=%s", decision_id, r.status_code) + return None + html = r.text + if "사용자가 많아" in html or "서비스 이용에 불편" in html: + logger.warning("PIPC %s: 봇 차단/일시 오류 페이지 응답", decision_id) + return None + return html + + +def _extract_annex_image_urls(html: str) -> list[str]: + """HTML 에서 【별지】 섹션의 URL 들 추출. 없으면 빈 리스트.""" + m = _ANNEX_BLOCK_RE.search(html) + if not m: + return [] + block = m.group(0) + out: list[str] = [] + for src in _IMG_SRC_RE.findall(block): + # 상대 경로 → 절대 URL + if src.startswith("//"): + src = "https:" + src + elif src.startswith("/"): + src = DOWNLOAD_BASE + src + out.append(src) + return out + + +async def _download_image(client: httpx.AsyncClient, url: str, dest: Path) -> bool: + try: + r = await client.get(url) + except Exception as e: + logger.warning("annex image fetch 실패 %s: %s", url, e) + return False + if r.status_code != 200 or not r.content: + logger.warning("annex image status=%s url=%s", r.status_code, url) + return False + dest.write_bytes(r.content) + return True + + +# ───────────────────────── docling OCR ───────────────────────── + +_converter = None # lazy singleton + + +def _get_converter(): + """docling DocumentConverter 인스턴스 (lazy + singleton, 한국어 OCR 설정). + + docling 기본 OCR(macOS = ocrmac, Vision API)은 한국어 정확도가 낮음. + EasyOCR + lang=["ko","en"] 로 명시해 한국어 인식 강화. + + 첫 import 가 무거우므로(transformers·torch) 옵트인 첫 호출 때만 로드. + """ + global _converter + if _converter is not None: + return _converter + try: + from docling.datamodel.base_models import InputFormat + from docling.datamodel.pipeline_options import ( + EasyOcrOptions, + PdfPipelineOptions, + ) + from docling.document_converter import ( + DocumentConverter, + ImageFormatOption, + PdfFormatOption, + ) + except ImportError: + logger.warning("docling 미설치 — `pip install docling` 또는 옵션 extras 사용") + return None + + pdf_opts = PdfPipelineOptions() + pdf_opts.ocr_options = EasyOcrOptions(lang=["ko", "en"]) + pdf_opts.do_ocr = True + pdf_opts.do_table_structure = True + + _converter = DocumentConverter( + format_options={ + InputFormat.IMAGE: ImageFormatOption(pipeline_options=pdf_opts), + InputFormat.PDF: PdfFormatOption(pipeline_options=pdf_opts), + } + ) + return _converter + + +def _ocr_to_markdown_sync(image_paths: list[Path]) -> str: + """blocking docling 변환. asyncio.to_thread 로 감싸서 호출.""" + conv = _get_converter() + if conv is None: + return "" + parts: list[str] = [] + for ip in image_paths: + try: + result = conv.convert(str(ip)) + md = result.document.export_to_markdown().strip() + if md: + parts.append(md) + except Exception as e: + logger.warning("docling 변환 실패 %s: %s", ip, e) + return "\n\n".join(parts).strip() + + +# ───────────────────────── public API ───────────────────────── + +async def fetch_annex_markdown(decision_id: str) -> str | None: + """결정 ID → 별지 OCR markdown. + + 반환 시나리오: + - None : 옵트인 비활성 / docling 미설치 / 별지 없음 / 봇 차단 / OCR 실패 + - str : OCR 결과 markdown (캐시됨) + + `KPAA_PIPC_OCR=1` 환경변수 켜져 있을 때만 *호출* 자체가 의미 있음. + """ + if not _enabled(): + return None + decision_id = str(decision_id) + cache = _cache_dir() / f"{decision_id}.md" + if cache.exists(): + try: + cached = cache.read_text(encoding="utf-8") + if cached.strip(): + return cached + except OSError: + pass + + # 1. 사람용 페이지 fetch + async with httpx.AsyncClient( + verify=False, # 법제처 인증서 체인 이슈 회피 (라이브 검증) + timeout=30, + headers={"User-Agent": "Mozilla/5.0 (KPAA bot)"}, + ) as client: + html = await _fetch_human_page(client, decision_id) + if html is None: + return None + urls = _extract_annex_image_urls(html) + if not urls: + logger.info("PIPC %s: 별지 이미지 없음", decision_id) + return None + + # 2. 이미지 다운로드 (tmp 디렉토리) + tmp = _cache_dir() / "tmp" / decision_id + tmp.mkdir(parents=True, exist_ok=True) + img_paths: list[Path] = [] + for i, url in enumerate(urls): + ext = ".png" # 추후 content-type 으로 더 정확히 + ip = tmp / f"page-{i:02d}{ext}" + ok = await _download_image(client, url, ip) + if ok: + img_paths.append(ip) + + if not img_paths: + return None + + # 3. docling OCR (CPU bound → asyncio.to_thread) + md = await asyncio.to_thread(_ocr_to_markdown_sync, img_paths) + if not md: + return None + + # 4. 캐시 저장 + try: + cache.write_text(md, encoding="utf-8") + except OSError as e: + logger.warning("annex 캐시 저장 실패 %s: %s", cache, e) + return md + + +__all__ = ["fetch_annex_markdown"] diff --git a/src/kpaa/retrieval/ranker.py b/src/kpaa/retrieval/ranker.py new file mode 100644 index 0000000000000000000000000000000000000000..8083b73f6104055073ec29f8f071b3c5cc171abd --- /dev/null +++ b/src/kpaa/retrieval/ranker.py @@ -0,0 +1,54 @@ +"""Excerpt 리스트를 컨텍스트 빌더에 넘기기 전 *정렬* 과 *중복 제거*. + +정렬 키 = (sort_priority asc, -recency_score desc, citation asc). + 1순위: 소스 우선순위 (상담사례 → 법조문 → PIPC → 해석례), + 2순위: 같은 소스 안에서는 최근성 큰 순. + +본문 *절단은 하지 않는다* (정책: 가이드·상담사례·OpenAPI 응답을 왜곡 없이 +LLM 에 그대로 전달). 본문 길이 제한은 (a) API fetch 단계의 카테고리별 cap +(law.ARTICLE_BODY_LIMIT 등) 과 (b) context_builder 의 총 예산(`max_chars`)이 +담당. ranker 는 *어떤 excerpt 를 살릴지* 만 결정한다. + +Dedup 정책: +- 법조문(law/related_law) — `metadata.mst + metadata.jo` 가 동일하면 같은 조문. + citation 표기가 변형(괄호 제목 유무 등) 되어도 같은 항목으로 처리. +- 그 외 소스 — `(source_type, citation)` 동일하면 중복. +- 첫 등장 항목을 살리고 이후 동일 키는 버린다 (insertion order 유지). +""" +from __future__ import annotations + +from kpaa.retrieval.excerpts import Excerpt + + +def _key(e: Excerpt) -> tuple[int, int, str]: + # primary: priority asc, secondary: recency desc, tiebreak: citation asc + return (e.sort_priority, -e.recency_score, e.citation) + + +def _dedup_key(e: Excerpt) -> tuple: + """중복 판정 키. 법조문은 (mst, jo) 우선, 그 외는 citation.""" + if e.source_type in ("law", "related_law"): + md = e.metadata or {} + mst = md.get("mst") or "" + jo = md.get("jo") or "" + if mst or jo: + return (e.source_type, "mstjo", mst, jo) + return (e.source_type, "cit", e.citation) + + +def rank(excerpts: list[Excerpt]) -> list[Excerpt]: + if not excerpts: + return [] + seen: set[tuple] = set() + deduped: list[Excerpt] = [] + for e in excerpts: + key = _dedup_key(e) + if key in seen: + continue + seen.add(key) + deduped.append(e) + deduped.sort(key=_key) + return deduped + + +__all__ = ["rank"] diff --git a/src/kpaa/retrieval/retriever.py b/src/kpaa/retrieval/retriever.py new file mode 100644 index 0000000000000000000000000000000000000000..7d335b1fd760da61f3a5fb0f8a037bacf46a40e8 --- /dev/null +++ b/src/kpaa/retrieval/retriever.py @@ -0,0 +1,1202 @@ +"""병렬 fan-out — 라우터 결과(`RouterPlan`)에 따라 4개 소스를 동시에 조회해 +`Excerpt` 리스트로 통일. + +소스: + 1. 상담사례 (로컬 SQLite, 가장 빠름·평이) + 2. 법조문 (개인정보보호법 본법, jo_targets 만큼) + 3. PIPC 결정문 (search_keywords 폴백 큐) + 4. 법령해석례 (search_keywords 폴백 큐) + +라이브 호출이 실패하면 그 소스만 결과 0건으로 처리하고 나머지는 사용. + +키워드 폴백 큐 (2026-04 v2.1): + LLM이 추출한 search_keywords 를 순서대로 시도해 *첫 번째 비어있지 않은* 결과를 + 채택. 약칭("신용정보법") 직접 검색 시 0건이 나는 법제처 API 특성 때문에 도입. + 각 fetcher는 plan.search_keywords 가 있으면 그 리스트를, 없으면 [top_keyword] + 하나만 시도. +""" +from __future__ import annotations + +import asyncio +import json +import logging +import re +from collections.abc import Awaitable, Callable +from datetime import datetime +from functools import lru_cache +from pathlib import Path +from typing import Any + +from kpaa.cases import CasesIndex +from kpaa.guides import GuidesIndex +from kpaa.law_api import KoreanLawClient +from kpaa.retrieval.excerpts import Excerpt +from kpaa.retrieval.router import RouterPlan + +# Progress callback signature: async fn(stage: str, payload: dict). +# 단계별 SSE prelude 표시용. None 이면 silent. +ProgressCB = Callable[[str, dict[str, Any]], Awaitable[None]] | None + +logger = logging.getLogger("kpaa.retrieval") + +_DEFAULT_LAW_NAME = "개인정보보호법" +_DEFAULT_LAW_MST = "270351" +_CASES_PER_QUERY = 2 +_GUIDES_PER_QUERY = 2 +_PIPC_PER_QUERY = 2 +_INTERP_PER_QUERY = 1 +_PREC_PER_QUERY = 2 + +# 판례 본문 cap — 정책: *왜곡 없이 전달* 우선. 8000자는 *일반적 판례 전체* 가 들어 +# 가는 너그러운 상한 (대법원 판례 평균 3000~6000자, 헌재 결정문은 별도 카테고리). +# 사실상 제거에 가까운 sanity cap 이며, 절단되면 "…(중략)…" 마커로 명시. +_PREC_BODY_LIMIT = 8000 + + +def _related_laws_excerpts() -> list[Excerpt]: + """data/related_laws.yaml 의 정적 리스트를 Excerpt로 변환. + + `관련_법령` intent 매칭 시 retriever가 라이브 검색 대신 이 리스트를 그대로 + 컨텍스트에 박는다. 본 챗봇 scope는 개인정보보호법이지만, 사용자에게 *관련 + 법령의 존재*를 안내해 적절한 곳에서 추가 확인하도록 돕는 용도. + + yaml 두 형식 모두 지원: + - 레거시 list (수동 작성): name/short/description/url + - 신규 dict (자동 갱신, privacy.go.kr 스크래이프): items/[name/kind/kind_label/url/source] + """ + from kpaa.related_laws import load_items + + out: list[Excerpt] = [] + for item in load_items(): + name = item.get("name", "") + # 본문: description(레거시) 우선, 없으면 kind_label(신규) + content = (item.get("description") or item.get("kind_label") or "").strip() + out.append( + Excerpt( + source_type="related_law", + citation=item.get("short") or name, + title=name, + content=content, + metadata={ + "url": item.get("url", ""), + "kind": item.get("kind", ""), + "source": item.get("source", ""), + }, + sort_priority=1, + recency_score=5, + ) + ) + return out + + +def _seed_law() -> tuple[str, str]: + p = Path(__file__).resolve().parents[3] / "data" / "seed_laws.json" + if p.exists(): + try: + data = json.loads(p.read_text(encoding="utf-8")) + entry = data.get("personal_info_protection_act", {}) + return ( + str(entry.get("name_short") or entry.get("name") or _DEFAULT_LAW_NAME), + str(entry.get("mst") or _DEFAULT_LAW_MST), + ) + except (json.JSONDecodeError, OSError): + pass + return (_DEFAULT_LAW_NAME, _DEFAULT_LAW_MST) + + +def _yyyy_mmdd_to_year(s: str) -> int: + s = (s or "").replace("-", "").replace(".", "").strip() + if len(s) >= 4 and s[:4].isdigit(): + return int(s[:4]) + return 0 + + +def _recency_score(year: int) -> int: + """최근일수록 양수 큰 점수. 5년 이내는 +5, 그보다 오래되면 점진 감점.""" + if year <= 0: + return 0 + current = datetime.now().year + delta = current - year + if delta <= 5: + return 5 - delta + return max(-5, -(delta - 5)) + + +def _keyword_queue(plan: RouterPlan) -> list[str]: + """폴백 시도 순서: LLM search_keywords → matched_terms → top_keyword → query. + + 중복 제거하되 순서 보존. 빈 문자열·공백 단독은 제외. + """ + seen: set[str] = set() + out: list[str] = [] + for src in (plan.search_keywords, plan.matched_terms, [plan.top_keyword], [plan.query]): + for kw in src or []: + kw = (kw or "").strip() + if kw and kw not in seen: + seen.add(kw) + out.append(kw) + return out + + +def _fallback_meta(used_kw: str, queue: list[str]) -> dict[str, str]: + """search_keywords 폴백 정보 — 디버깅·신뢰도 추적용 metadata 헬퍼. + + 답변 LLM 이 사용자에게 "왜 이 결과가 나왔는가" 를 설명할 수 있도록 *시도한 + 키워드 큐* 와 *실제 hit한 키워드* 를 Excerpt 에 부착한다. 컨텍스트 표시는 + 별도 옵션 (KPAA_DEBUG_FALLBACK 등) 으로 토글 가능. + """ + return { + "fallback_keyword": used_kw or "", + "tried_keywords": " | ".join(k for k in queue if k), + } + + +def _is_pipc_body_empty(body) -> bool: + """PIPC 결정문 본문이 *텍스트 인용 가치 없음* 으로 판정. + + 법제처 OPEN API 의 PIPC 결정문은 main_text + reason 두 텍스트 필드만 노출. + 실제 본문이 *별지(이미지)* 로 대체된 케이스 — 침해요인 평가, 단순 의결 등 — + 에서는 reason 이 "별지와 같다.", "주문과 같다." 같은 placeholder 만 들어옴. + 이런 결정을 RAG 컨텍스트에 박으면 LLM 이 무의미한 출처를 신뢰하므로 제외. + + 판정 기준: + - reason 에 "별지와 같다" 또는 "별지 와 같다" 가 등장 + - main_text + reason 합산 100자 미만 + """ + main = (body.main_text or "").strip() + reason = (body.reason or "").strip() + if "별지와 같다" in reason or "별지 와 같다" in reason: + return True + if len(main) + len(reason) < 100: + return True + return False + + +async def _try_keywords( + search_fn: Callable[[str], Awaitable[list[Any]]], + keywords: list[str], + *, + label: str, +) -> tuple[list[Any], str]: + """키워드 큐를 순회해 첫 비어있지 않은 검색 결과를 반환. + + 반환: (hits, keyword_used). 모두 실패면 ([], ""). + """ + for kw in keywords: + try: + hits = await search_fn(kw) + except Exception as e: + logger.warning("%s.search %r 실패: %s", label, kw, e) + continue + if hits: + if kw != keywords[0]: + logger.info("%s 폴백 hit: %r → %d건 (1차 %r 0건)", + label, kw, len(hits), keywords[0]) + return hits, kw + return [], "" + + +# ───────────────────────── per-source coroutines ───────────────────────── + +async def _fetch_cases( + plan: RouterPlan, *, k: int, on_progress: ProgressCB = None +) -> list[Excerpt]: + if not plan.search_keywords and not plan.query: + return [] + if on_progress: + await on_progress("fetch_started", {"source": "case", "keyword": plan.top_keyword}) + idx = CasesIndex.default() + if idx.total == 0: + logger.warning("cases.sqlite 비어 있음 — 'kpaa refresh-cases'로 빌드하세요") + if on_progress: + await on_progress("fetch_done", {"source": "case", "count": 0, "keyword": ""}) + return [] + # 매칭된 키워드 1~3개를 공백으로 합쳐 전달 → search 내부에서 OR 결합 + query = " ".join((plan.search_keywords or [plan.query])[:3]) + hits = idx.search(query, k=k) + out: list[Excerpt] = [] + for h in hits: + category = " > ".join(filter(None, (h.category1, h.category2, h.category3))) + body = h.body or h.summary or "" + out.append( + Excerpt( + source_type="case", + citation=h.citation(), + title=h.title, + content=body, + metadata={ + "category": category, + "reg_dt": h.reg_dt, + "year": h.case_year, + "type": h.type_label, + "source_note": h.source_note, + # privacy.go.kr의 view.do GET은 SPA 동작이라 ntt_id/nttno + # deep-link를 무시하고 항상 동일 페이지를 반환(라이브 검증 + # 2026-04-29). 잘못된 사례로 이동하지 않게 url 제거. + "url": "", + }, + sort_priority=0, + recency_score=_recency_score(_yyyy_mmdd_to_year(h.case_year or h.reg_dt)), + ) + ) + if on_progress: + await on_progress( + "fetch_done", {"source": "case", "count": len(out), "keyword": plan.top_keyword} + ) + return out + + +async def _fetch_guides( + plan: RouterPlan, *, k: int, on_progress: ProgressCB = None +) -> list[Excerpt]: + """PIPC 발간 안내서 청크 검색 — cases 와 동일한 FTS5 패턴.""" + if not plan.search_keywords and not plan.query: + return [] + if on_progress: + await on_progress("fetch_started", {"source": "guide", "keyword": plan.top_keyword}) + idx = GuidesIndex.default() + if idx.total == 0: + logger.warning("guides.sqlite 비어 있음 — 'kpaa build-guides' 로 빌드하세요") + if on_progress: + await on_progress("fetch_done", {"source": "guide", "count": 0, "keyword": ""}) + return [] + query = " ".join((plan.search_keywords or [plan.query])[:3]) + hits = idx.search(query, k=k) + out: list[Excerpt] = [] + for h in hits: + # doc_date "YYYY.MM" → 연도만 추출해 recency 점수에 사용 + year = 0 + if h.doc_date: + head = h.doc_date.split(".")[0] + if head.isdigit(): + year = int(head) + out.append( + Excerpt( + source_type="guide", + citation=h.citation(), + title=h.doc_title, + content=h.body, + metadata={ + "section": h.section, + "doc_date": h.doc_date, + "doc_title": h.doc_title, + "pages": h.pages, + "source_pdf": h.source_pdf, + "url": "", + }, + sort_priority=1, # case(0) < guide(1) < law(1) < pipc(2)... + recency_score=_recency_score(year), + ) + ) + if on_progress: + await on_progress( + "fetch_done", + {"source": "guide", "count": len(out), "keyword": plan.top_keyword}, + ) + return out + + +async def _fetch_law_articles( + client: KoreanLawClient, + plan: RouterPlan, + *, + mst: str, + law_name: str, + on_progress: ProgressCB = None, +) -> list[Excerpt]: + if not plan.jo_targets: + return [] + if on_progress: + await on_progress( + "fetch_started", + {"source": "law", "law": law_name, "jo": list(plan.jo_targets)}, + ) + coros = [client.law.get_article(mst=mst, article_no=jo) for jo in plan.jo_targets] + results = await asyncio.gather(*coros, return_exceptions=True) + out: list[Excerpt] = [] + for jo, res in zip(plan.jo_targets, results, strict=False): + if isinstance(res, Exception): + logger.warning("get_article(jo=%s) 실패: %s", jo, res) + continue + if not res or not res.raw_text: + continue + out.append( + Excerpt( + source_type="law", + citation=res.citation(law_name=law_name), + title=res.title, + content=res.raw_text, + metadata={ + "law": law_name, + "mst": mst, + "jo": jo, + "enforce_date": res.enforce_date or "", + "url": f"https://www.law.go.kr/lsInfoP.do?lsiSeq={mst}", + }, + sort_priority=1, + recency_score=5, # 법령은 항상 최신 (시행일자 기준) + ) + ) + if on_progress: + await on_progress( + "fetch_done", {"source": "law", "count": len(out), "law": law_name} + ) + return out + + +async def _fetch_pipc( + client: KoreanLawClient, + plan: RouterPlan, + *, + k: int, + on_progress: ProgressCB = None, +) -> list[Excerpt]: + """PIPC 결정문 — search_keywords 큐 순회 + 본문 충실도 체크. + + 폴백 정책: + 1. 큐의 첫 키워드로 검색 + 2. 본문 부실(별지 대체) 케이스 제외 + 3. 살아남은 충실 본문이 < k 이면 다음 키워드로 재검색 + 합산 + 4. 큐 끝나거나 k 채워지면 종료 + + *별지 대체* 케이스는 침해요인 평가 류라 검색어 "침해" 계열에 무더기로 잡힘. + 검색 hit 수만 보는 _try_keywords 폴백은 이 케이스에서 무용지물 → 별도 처리. + """ + queue = _keyword_queue(plan) + if not queue: + return [] + if on_progress: + await on_progress("fetch_started", {"source": "pipc", "keyword": queue[0]}) + + out: list[Excerpt] = [] + seen_ids: set[str] = set() + used_kw = "" + + for kw in queue: + if len(out) >= k: + break + try: + hits = await client.pipc.search_decisions(kw, display=10) + except Exception as e: + logger.warning("pipc.search_decisions %r 실패: %s", kw, e) + continue + if not hits: + continue + if not used_kw: + used_kw = kw + # 새 키워드의 후보 본문 fetch (이미 본 ID 제외) + fresh = [h for h in hits if str(h.decision_id) not in seen_ids] + candidate_n = min(len(fresh), max(k, 3) * 3) + coros = [ + client.pipc.get_decision_text(decision_id=h.decision_id) + for h in fresh[:candidate_n] + ] + bodies = await asyncio.gather(*coros, return_exceptions=True) + for hit, body in zip(fresh[:candidate_n], bodies, strict=False): + if len(out) >= k: + break + seen_ids.add(str(hit.decision_id)) + if isinstance(body, Exception) or not body: + logger.warning("pipc.get_decision_text 실패 id=%s: %s", hit.decision_id, body) + continue + if _is_pipc_body_empty(body): + # 옵트인: KPAA_PIPC_OCR=1 면 별지 이미지를 docling 으로 OCR. + # 성공 시 텍스트 본문 대신 OCR markdown 사용. 실패/비활성이면 제외. + from kpaa.retrieval.pipc_annex import fetch_annex_markdown + + annex_md = await fetch_annex_markdown(str(body.decision_id)) + if annex_md and len(annex_md) >= 100: + text = ( + (body.main_text or "") + + "\n\n[별지 (이미지 OCR)]\n" + + annex_md + ) + logger.info( + "pipc %s: 별지 OCR %d자 → RAG 포함", + body.decision_id, len(annex_md), + ) + else: + logger.info( + "pipc %s: 본문 별지 대체로 부실 → RAG 제외 (main=%d reason=%d)", + body.decision_id, len(body.main_text or ""), len(body.reason or ""), + ) + continue + else: + text = (body.main_text or "") + ("\n\n[이유]\n" + body.reason if body.reason else "") + out.append( + Excerpt( + source_type="pipc", + citation=body.citation(), + title=body.title or hit.title, + content=text.strip(), + metadata={ + "decision_id": body.decision_id, + "decision_no": body.decision_no, + "decision_date": body.decision_date, + "agency": body.agency, + # 법제처 사람용 페이지(`/LSW/ppcInfoP.do?ppcSeq=...&mode=8`) + # 가 *간헐적 봇 차단* 페이지를 반환하는 사례가 있어 url 부착 + # 보류. 안정 URL 확인 후 채울 예정. + "url": "", + **_fallback_meta(used_kw, queue), + }, + sort_priority=2, + recency_score=_recency_score( + _yyyy_mmdd_to_year(body.decision_date or hit.decision_date) + ), + ) + ) + if on_progress: + await on_progress( + "fetch_done", {"source": "pipc", "count": len(out), "keyword": used_kw} + ) + return out + + +async def _fetch_interpretations( + client: KoreanLawClient, + plan: RouterPlan, + *, + k: int, + on_progress: ProgressCB = None, +) -> list[Excerpt]: + queue = _keyword_queue(plan) + if not queue: + return [] + if on_progress: + await on_progress("fetch_started", {"source": "interpretation", "keyword": queue[0]}) + hits, used_kw = await _try_keywords( + lambda kw: client.interpretation.search(kw, display=5), + queue, + label="interpretation", + ) + if not hits: + if on_progress: + await on_progress( + "fetch_done", {"source": "interpretation", "count": 0, "keyword": ""} + ) + return [] + coros = [ + client.interpretation.get_text(interpretation_id=h.interpretation_id) + for h in hits[:k] + ] + bodies = await asyncio.gather(*coros, return_exceptions=True) + out: list[Excerpt] = [] + for hit, body in zip(hits[:k], bodies, strict=False): + if isinstance(body, Exception) or not body: + continue + # 회답을 우선, 부족하면 질의요지·이유 부분 결합 + parts: list[str] = [] + if body.answer: + parts.append(f"[회답]\n{body.answer}") + if body.question: + parts.append(f"[질의요지]\n{body.question}") + text = "\n\n".join(parts).strip() + if not text: + continue + out.append( + Excerpt( + source_type="interpretation", + citation=body.citation(), + title=body.title or hit.title, + content=text, + metadata={ + "case_no": body.case_no, + "decided_date": body.decided_date, + "responder": body.responder, + # 법령해석례 사람용 페이지: 라이브 검증(2026-04-29). + "url": f"https://www.law.go.kr/expcInfoP.do?expcSeq={body.interpretation_id}", + **_fallback_meta(used_kw, queue), + }, + sort_priority=3, + recency_score=_recency_score( + _yyyy_mmdd_to_year(body.decided_date or hit.decided_date) + ), + ) + ) + if on_progress: + await on_progress( + "fetch_done", + {"source": "interpretation", "count": len(out), "keyword": used_kw}, + ) + return out + + +async def _fetch_precedents( + client: KoreanLawClient, + plan: RouterPlan, + *, + k: int, + on_progress: ProgressCB = None, +) -> list[Excerpt]: + queue = _keyword_queue(plan) + if not queue: + return [] + if on_progress: + await on_progress("fetch_started", {"source": "precedent", "keyword": queue[0]}) + hits, used_kw = await _try_keywords( + lambda kw: client.precedent.search(kw, display=10), + queue, + label="precedent", + ) + if not hits: + if on_progress: + await on_progress( + "fetch_done", {"source": "precedent", "count": 0, "keyword": ""} + ) + return [] + coros = [client.precedent.get_text(precedent_id=h["precedent_id"]) for h in hits[:k]] + bodies = await asyncio.gather(*coros, return_exceptions=True) + out: list[Excerpt] = [] + for hit, body in zip(hits[:k], bodies, strict=False): + if isinstance(body, Exception) or not body: + continue + # raw XML → 평문 (판례 detail은 lxml 파싱 가치 대비 노이즈가 많아 단순 strip) + plain = re.sub(r"<[^>]+>", " ", str(body)) + plain = re.sub(r"\s+", " ", plain).strip() + if not plain: + continue + if len(plain) > _PREC_BODY_LIMIT: + plain = plain[:_PREC_BODY_LIMIT].rstrip() + " …(중략)…" + + case_no = hit.get("case_no", "").strip() + court = hit.get("court_name", "").strip() + date = hit.get("decision_date", "").strip() + if court and case_no: + citation = f"{court} {case_no}" + if date: + citation += f" ({date} 선고)" + else: + citation = f"판례 #{hit['precedent_id']}" + + out.append( + Excerpt( + source_type="precedent", + citation=citation, + title=hit.get("case_name", ""), + content=plain, + metadata={ + "precedent_id": hit["precedent_id"], + "case_no": case_no, + "court": court, + "decision_date": date, + "url": f"https://www.law.go.kr/precInfoP.do?precSeq={hit['precedent_id']}", + **_fallback_meta(used_kw, queue), + }, + sort_priority=4, # cases list[Excerpt]: + """행정규칙(고시) — chain_safety_compliance 등이 사용. 개보위 고시 위주.""" + queue = _keyword_queue(plan) + if not queue: + return [] + if on_progress: + await on_progress("fetch_started", {"source": "admin_rule", "keyword": queue[0]}) + hits, used_kw = await _try_keywords( + lambda kw: client.admin_rule.search(kw, knd=3, display=10), + queue, + label="admin_rule", + ) + if not hits: + if on_progress: + await on_progress( + "fetch_done", {"source": "admin_rule", "count": 0, "keyword": ""} + ) + return [] + out: list[Excerpt] = [] + for hit in hits[:k]: + out.append( + Excerpt( + source_type="admin_rule", + citation=f"{hit.get('name', '행정규칙')} ({hit.get('issue_date', '')})", + title=hit.get("name", ""), + content=( + f"발령번호 {hit.get('issue_no', '')} · " + f"소관 {hit.get('department', '')} · " + f"종류 {hit.get('kind', '')}" + ), + metadata={ + "rule_id": hit.get("rule_id", ""), + "department": hit.get("department", ""), + "issue_date": hit.get("issue_date", ""), + **_fallback_meta(used_kw, queue), + }, + sort_priority=2, + recency_score=_recency_score(_yyyy_mmdd_to_year(hit.get("issue_date", ""))), + ) + ) + if on_progress: + await on_progress( + "fetch_done", {"source": "admin_rule", "count": len(out), "keyword": used_kw} + ) + return out + + +@lru_cache(maxsize=1) +def _seed_three_tier() -> tuple[str, str | None, str | None]: + """본법 + 시행령 + 시행규칙 MST 묶음 lookup. + + related_laws.yaml 에서 *개인정보 보호법* 패밀리만 골라낸다. KPAA 시점 + (2026-05) 개인정보보호법은 시행규칙이 없어 None 으로 떨어지는 게 정상. + + 반환: (본법_mst, 시행령_mst | None, 시행규칙_mst | None) + """ + _, primary_mst = _seed_law() + decree_mst: str | None = None + rule_mst: str | None = None + try: + from kpaa import related_laws as _rl + + items = _rl.load_items() + except Exception as e: + logger.warning("_seed_three_tier related_laws.yaml 로드 실패: %s", e) + return primary_mst, None, None + for it in items: + name = str(it.get("name", "")) + mst = str(it.get("mst") or "") + if not mst: + continue + if "개인정보" not in name: + continue + if "시행령" in name and not decree_mst: + decree_mst = mst + elif "시행규칙" in name and not rule_mst: + rule_mst = mst + return primary_mst, decree_mst, rule_mst + + +async def _fetch_three_tier( + client: KoreanLawClient, + plan: RouterPlan, + *, + on_progress: ProgressCB = None, +) -> list[Excerpt]: + """본법의 *시행령·시행규칙* 을 묶어 retrieve (3단비교 = 법률·시행령·시행규칙). + + 본법 자체는 `_fetch_law_articles` 가 처리하므로 여기서는 *하위 법령* 만. + 원작 korean-law-mcp 의 `target=thdCmp` JSON endpoint 대신, 검증된 기존 + `client.law.get_text()` 를 조합하는 *결정적* 경로를 택했다. + + 조문 선택 규칙: + 1. plan.search_keywords 로 시행령 본문 grep — 매칭 최대 3개 + 2. 매칭 0건이면 시행령 처음 5개 조문 (목적·정의·총칙) + 3. 시행규칙도 동일 (MST 등록되어 있을 때만) + + citation 은 "개인정보보호법 시행령 제30조" 처럼 *시행령 임을 명시*. dedup 은 + (mst, jo) 키이므로 본법과 자동 분리된다. + """ + _, decree_mst, rule_mst = _seed_three_tier() + if not decree_mst and not rule_mst: + return [] + + if on_progress: + await on_progress( + "fetch_started", + { + "source": "three_tier", + "decree_mst": decree_mst or "", + "rule_mst": rule_mst or "", + }, + ) + + out: list[Excerpt] = [] + keywords = [k for k in (plan.search_keywords or []) if k] + + for mst, label in ( + (decree_mst, "개인정보보호법 시행령"), + (rule_mst, "개인정보보호법 시행규칙"), + ): + if not mst: + continue + try: + text = await client.law.get_text(mst=mst) + except Exception as e: + logger.warning("three_tier get_text(mst=%s, label=%s) 실패: %s", mst, label, e) + continue + if not text or not text.articles: + continue + + # 1차: search_keywords grep + matched: list = [] + if keywords: + for art in text.articles: + body = art.raw_text or "" + if any(kw in body for kw in keywords): + matched.append(art) + if len(matched) >= 3: + break + # 2차: 폴백 — 처음 5개 (목적·정의·총칙 위주) + if not matched: + matched = list(text.articles[:5]) + + for art in matched: + if not art.raw_text: + continue + out.append( + Excerpt( + source_type="law", + citation=art.citation(law_name=label), + title=art.title, + content=art.raw_text, + metadata={ + "law": label, + "mst": mst, + "jo": art.article_no, + "enforce_date": art.enforce_date or "", + "url": f"https://www.law.go.kr/lsInfoP.do?lsiSeq={mst}", + }, + sort_priority=1, # 본법과 같은 우선순위 + recency_score=4, # 본법(5) 다음 + ) + ) + + if on_progress: + await on_progress( + "fetch_done", {"source": "three_tier", "count": len(out)} + ) + return out + + +# 본법(개인정보보호법) lawId 캐시. 첫 호출 시 client.law.get_text 로 resolve 후 보관. +# law_id 는 lsJoHstInf endpoint 의 ID 파라미터에 필요 — MST 로 호출 불가. +# +# `_PRIMARY_LAW_ID_LOCK`: asyncio 동시 호출 시 cache 미반영 상태에서 둘이 동시에 +# fetch 트리거되는 race condition 방지 — *같은 워커 내 multi-coroutine* 보호. +# 멀티 워커(gunicorn 등)는 각자 캐시를 가지므로 lock 무관 (각 워커별 1회 fetch). +_PRIMARY_LAW_ID_CACHE: str | None = None +_PRIMARY_LAW_ID_LOCK: asyncio.Lock | None = None + + +def _primary_law_id_lock() -> asyncio.Lock: + """현재 이벤트 루프에 맞는 Lock 을 lazy 생성. + + 모듈 import 시점에 `asyncio.Lock()` 을 만들면 *현재 실행 루프* 가 없을 수 + 있어 RuntimeError. 함수 내에서 호출 시점에 만들고 reuse. + """ + global _PRIMARY_LAW_ID_LOCK + if _PRIMARY_LAW_ID_LOCK is None: + _PRIMARY_LAW_ID_LOCK = asyncio.Lock() + return _PRIMARY_LAW_ID_LOCK + + +async def _resolve_primary_law_id(client: KoreanLawClient) -> str | None: + """본법의 *법령ID* 를 lazy resolve. 실패 시 None. + + KPAA 시점(2026-05) related_laws.yaml 에는 mst 만 저장되어 있어 lawId 는 + 별도 lookup 필요. get_text 응답의 LawText.law_id 에서 회수 후 캐시. + + Resolve 전략 (순서대로 시도): + 1. `get_text(mst, jo=1)` — 가벼운 단일 조문 호출, 응답에 기본정보 포함 + 2. `get_text(mst, jo=None)` — fallback. 전체 본문 (무거움) 이지만 첫 호출 + 실패 시 한 번 더 시도. 본법은 jo=1 이 항상 존재하므로 거의 안 씀. + + 동시성: `_primary_law_id_lock()` 으로 보호. 첫 코루틴이 fetch 진행하면 + 뒷 코루틴은 lock 을 기다리고 캐시 hit. + """ + global _PRIMARY_LAW_ID_CACHE + if _PRIMARY_LAW_ID_CACHE is not None: + return _PRIMARY_LAW_ID_CACHE or None + + async with _primary_law_id_lock(): + # double-checked: 첫 코루틴이 채웠을 수 있음 + if _PRIMARY_LAW_ID_CACHE is not None: + return _PRIMARY_LAW_ID_CACHE or None + + _, mst = _seed_law() + for jo_arg in (1, None): + try: + text = await client.law.get_text(mst=mst, jo=jo_arg) + except Exception as e: + logger.warning( + "primary lawId resolve 실패 (mst=%s, jo=%s): %s", + mst, jo_arg, e, + ) + continue + if text and text.law_id: + _PRIMARY_LAW_ID_CACHE = text.law_id + return text.law_id + logger.debug("primary lawId 응답에 law_id 없음 (jo=%s) — fallback 시도", jo_arg) + return None + + +async def _fetch_article_history( + client: KoreanLawClient, + plan: RouterPlan, + *, + on_progress: ProgressCB = None, +) -> list[Excerpt]: + """본법 조문의 *시점별 개정 이력* fetcher. + + 동작 조건: `plan.jo_targets` 명시 시에만 호출 (조문 단위 endpoint 라 불특정 + 질문에 fan-out 하면 응답이 너무 광범위). 조문 미명시면 빈 결과. + + 한 시점·한 조문 변경 = 1 Excerpt. citation 에 개정일자 포함. + """ + if not plan.jo_targets: + if on_progress: + await on_progress("fetch_done", {"source": "article_history", "count": 0}) + return [] + + law_id = await _resolve_primary_law_id(client) + if not law_id: + logger.warning("article_history skipped: primary lawId 미 resolve") + if on_progress: + await on_progress("fetch_done", {"source": "article_history", "count": 0}) + return [] + + law_name, mst = _seed_law() + if on_progress: + await on_progress( + "fetch_started", + { + "source": "article_history", + "law_id": law_id, + "jo_targets": list(plan.jo_targets), + }, + ) + + out: list[Excerpt] = [] + seen: set[tuple[str, str]] = set() # (jo, amend_date) dedup + + for jo in plan.jo_targets: + try: + items = await client.article_history.search(law_id=law_id, jo=jo) + except Exception as e: + logger.warning("article_history.search(law_id=%s, jo=%s) 실패: %s", law_id, jo, e) + continue + for it in items: + key = (it.article_no, it.article_amend_date or it.promulgate_date) + if key in seen: + continue + seen.add(key) + # 본문 합성 — 시점·구분·사유·시행일 + sections: list[str] = [] + if it.change_type: + sections.append(f"개정 구분: {it.change_type}") + if it.change_reason: + sections.append(f"변경 사유: {it.change_reason}") + if it.article_amend_date: + sections.append(f"조문 개정일: {it.article_amend_date}") + if it.article_enforce_date: + sections.append(f"조문 시행일: {it.article_enforce_date}") + if it.promulgate_date and it.promulgate_date != it.article_amend_date: + sections.append(f"법령 공포일: {it.promulgate_date}") + text = "\n".join(sections).strip() + if not text: + continue + out.append( + Excerpt( + source_type="article_history", + citation=it.citation(), + title=it.law_name, + content=text, + metadata={ + "law": it.law_name, + "law_id": it.law_id, + "mst": it.mst or mst, + "jo": it.article_no, + "amend_date": it.article_amend_date, + "enforce_date": it.article_enforce_date, + "url": f"https://www.law.go.kr/lsInfoP.do?lsiSeq={it.mst or mst}", + }, + sort_priority=1, # 본법 조문과 같은 priority — 조문 정합 정보 + recency_score=_recency_score(_yyyy_mmdd_to_year(it.article_amend_date)), + ) + ) + + if on_progress: + await on_progress( + "fetch_done", {"source": "article_history", "count": len(out)} + ) + return out + + +async def _fetch_constitutional( + client: KoreanLawClient, + plan: RouterPlan, + *, + k: int = 2, + on_progress: ProgressCB = None, +) -> list[Excerpt]: + """헌법재판소 결정문 fetcher. + + KPAA 활용: data_subject_rights/definition/medical_health chain 에서 + *자기결정권·기본권·비례성* 헌재 판단 보강. 결정문 수가 많아 k=2 로 제한. + + 실패 처리: 검색 0건·detail JSON 형식 이상 모두 *조용히 빈 결과* — chain + 의 다른 source 가 답변 가능하면 그쪽으로 진행. + """ + queue = _keyword_queue(plan) + if not queue: + return [] + if on_progress: + await on_progress( + "fetch_started", {"source": "constitutional", "keyword": queue[0]} + ) + + out: list[Excerpt] = [] + seen_ids: set[str] = set() + used_kw = "" + + for kw in queue: + if len(out) >= k: + break + try: + hits = await client.constitutional.search(kw, display=10) + except Exception as e: + logger.warning("constitutional.search %r 실패: %s", kw, e) + continue + if not hits: + continue + if not used_kw: + used_kw = kw + + fresh = [h for h in hits if str(h.decision_id) not in seen_ids] + candidate_n = min(len(fresh), max(k, 3) * 3) + coros = [ + client.constitutional.get_text(decision_id=h.decision_id) + for h in fresh[:candidate_n] + ] + bodies = await asyncio.gather(*coros, return_exceptions=True) + for hit, body in zip(fresh[:candidate_n], bodies, strict=False): + if len(out) >= k: + break + seen_ids.add(str(hit.decision_id)) + if isinstance(body, Exception) or body is None: + logger.warning( + "constitutional.get_text 실패 id=%s: %s", hit.decision_id, body + ) + continue + # 본문 합성 — 판시·요지·전문 모두 노출 (왜곡 없이) + sections: list[str] = [] + if body.issues: + sections.append(f"[판시사항]\n{body.issues}") + if body.summary: + sections.append(f"[결정요지]\n{body.summary}") + if body.refs_law: + sections.append(f"[참조조문]\n{body.refs_law}") + if body.refs_precedent: + sections.append(f"[참조판례]\n{body.refs_precedent}") + if body.body: + sections.append(f"[전문]\n{body.body}") + text = "\n\n".join(sections).strip() + if not text: + continue + out.append( + Excerpt( + source_type="constitutional", + citation=body.citation(), + title=body.title or hit.title, + content=text, + metadata={ + "decision_id": body.decision_id, + "case_no": body.case_no, + "decision_date": body.decision_date, + "petitioner": body.petitioner, + "respondent": body.respondent, + "url": hit.detail_url + or f"https://www.law.go.kr/detcInfoP.do?detcSeq={body.decision_id}", + **_fallback_meta(used_kw, queue), + }, + sort_priority=3, # 판례·해석례 사이 (4=판례, 3=해석례) + recency_score=_recency_score( + _yyyy_mmdd_to_year(body.decision_date or hit.decision_date) + ), + ) + ) + + if on_progress: + await on_progress( + "fetch_done", + {"source": "constitutional", "count": len(out), "keyword": used_kw}, + ) + return out + + +async def _fetch_old_new( + client: KoreanLawClient, + plan: RouterPlan, + *, + on_progress: ProgressCB = None, +) -> list[Excerpt]: + """본법(개인정보보호법) 신·구법 비교 — 개정 이력 질문에 활성화. + + intent 'amendment_track' 매칭 시 chain 의 active_sources 에 'oldnew' 가 + union 으로 들어오고 chain orchestrator 가 이 fetcher 를 호출. 응답은 + raw XML 한 덩어리이므로 가독 텍스트로 변환해 단일 Excerpt 로 반환한다. + + *왜 v1 은 단일 Excerpt 인가:* law.go.kr `target=oldAndNew` detail 응답의 + XML 구조(조문 단위 신/구 분리)가 라이브 검증 안 됐다. 추측 파서 대신 + 전체 본문을 LLM 에 그대로 넘겨 결정성을 우선. 구조 검증 후 Phase 2 에서 + 조문 단위 분해로 발전시킬 수 있다. + """ + law_name, mst = _seed_law() + if on_progress: + await on_progress( + "fetch_started", {"source": "oldnew", "mst": mst, "law": law_name} + ) + + try: + raw = await client.old_new.compare(mst=mst) + except Exception as e: + logger.warning("old_new.compare(mst=%s) 실패: %s", mst, e) + if on_progress: + await on_progress("fetch_done", {"source": "oldnew", "count": 0}) + return [] + + if not raw: + if on_progress: + await on_progress("fetch_done", {"source": "oldnew", "count": 0}) + return [] + # HTML 에러 페이지 (인증 실패·HTTP 500 등) 회피 + if raw.lstrip().lower().startswith((" list[Excerpt]: + """plan.mst_targets 의 각 법령 본문을 라이브로 가져옴. + + 조문 결정 로직: + - 사용자 질문에서 명시 조문(plan.jo_targets)이 있으면 그 조문만 + - 없으면 핵심 조문 추정 — 제1조(목적), 제2조(정의), 제3조(원칙) + - 단, 본법(개인정보보호법) MST는 제외 (이미 _fetch_law_articles 가 처리) + """ + if not plan.mst_targets: + return [] + _, primary_mst = _seed_law() + # 본법 외 MST만 + targets = [ + (mst, name) + for mst, name in zip(plan.mst_targets, plan.name_targets, strict=False) + if mst != primary_mst + ] + if not targets: + return [] + if on_progress: + await on_progress( + "fetch_started", + {"source": "related_law", "laws": [n for _, n in targets]}, + ) + # 조문 결정 + jo_list = list(plan.jo_targets) if plan.jo_targets else ["1", "2", "3"] + out: list[Excerpt] = [] + for mst, name in targets: + coros = [client.law.get_article(mst=mst, article_no=jo) for jo in jo_list] + results = await asyncio.gather(*coros, return_exceptions=True) + for jo, res in zip(jo_list, results, strict=False): + if isinstance(res, Exception): + logger.warning("get_article(%s, jo=%s) 실패: %s", name, jo, res) + continue + if not res or not res.raw_text: + continue + out.append( + Excerpt( + source_type="law", + citation=res.citation(law_name=name), + title=res.title, + content=res.raw_text, + metadata={ + "law": name, + "mst": mst, + "jo": jo, + "enforce_date": res.enforce_date or "", + "url": f"https://www.law.go.kr/lsInfoP.do?lsiSeq={mst}", + }, + sort_priority=1, # 본법과 같은 위치 + recency_score=4, # 본법(5)보다 약간 낮춰 본법 우선 정렬 + ) + ) + if on_progress: + await on_progress( + "fetch_done", {"source": "related_law", "count": len(out)} + ) + return out + + +# ───────────────────────── public entry ───────────────────────── + +async def retrieve( + plan: RouterPlan, + *, + client: KoreanLawClient | None = None, + on_progress: ProgressCB = None, +) -> list[Excerpt]: + """Chain dispatcher — `plan.chain` 이 결정한 결정적 orchestrator 호출. + + KPAA v2: fan-out 정책은 `chains.py` 의 chain 함수가 책임. 이 함수는 단순 + 디스패처 + KoreanLawClient 라이프사이클 관리만. on_progress 가 주어지면 + chain 안의 각 fetcher가 fetch_started/fetch_done 이벤트를 발행한다. + """ + from kpaa.retrieval.chains import get_chain + + chain_fn = get_chain(plan.chain) + own_client = client is None + if own_client: + client = KoreanLawClient() + await client.__aenter__() + try: + return await chain_fn(client, plan, on_progress=on_progress) + finally: + if own_client: + await client.__aexit__(None, None, None) + + +__all__ = ["retrieve", "Excerpt"] diff --git a/src/kpaa/retrieval/router.py b/src/kpaa/retrieval/router.py new file mode 100644 index 0000000000000000000000000000000000000000..0fa88c6572c50407c36c5bd64bfbd90b2e1b8bce --- /dev/null +++ b/src/kpaa/retrieval/router.py @@ -0,0 +1,419 @@ +"""라우터 — 사용자 질문 → RouterPlan(`chain` + 메타데이터). + +KPAA v2 (2026-04~): **LLM 의도 분류기**가 일차. 룰 키워드 매칭은 fallback. + +원작 korean-law-mcp v3 패턴 채택: +- 작은 LLM(Gemma 4 E2B, JSON-mode 1샷)이 chain·intent·mst·jo·keywords 분류 +- chain orchestrator(`chains.py`)는 결정적 fan-out — LLM 호출 없음 +- 답변 LLM은 RAG-only — 분류 LLM과 별개 단계 (`feedback_architecture.md` 유지) + +라우팅 모드 (`KPAA_ROUTER_MODE` 환경변수): +- `auto` (기본): LLM 분류기 우선. 실패·timeout이면 rule fallback. +- `always` : LLM만. 실패해도 빈 plan (디버깅용). +- `rule` : LLM 미사용, 룰만 (CI·테스트용). +""" +from __future__ import annotations + +import os +import re +from dataclasses import dataclass, field +from functools import lru_cache +from pathlib import Path + +import yaml + +# 기본 활성 source 집합 — chain이 실제 호출을 결정하지만 backward-compat용으로 남김. +_DEFAULT_SOURCES: tuple[str, ...] = ("case", "law", "pipc", "interpretation", "precedent") + +# 사용자 메시지에서 본법 조문 표현 추출 (rule 모드 + LLM 결과 보강용). +_ARTICLE_RE = re.compile(r"제?\s*(\d{1,3})\s*조(?:\s*의\s*(\d+))?") + + +@dataclass(frozen=True) +class Intent: + """yaml의 intent 메타데이터. v2부터 keywords 필드 사용 안 함 (LLM이 분류).""" + name: str + jo_hints: tuple[str, ...] = () + sources: tuple[str, ...] = () + keywords: tuple[str, ...] = () # legacy — rule fallback에서만 사용 + + +@dataclass +class RelatedLawEntry: + name: str + short: str + keywords: tuple[str, ...] + mst: str + kind: str + + +@dataclass +class RouterPlan: + """chain orchestrator(`chains.py`) 입력.""" + query: str + chain: str = "" # ★ 핵심 — chains.CHAINS 키 + intents: list[Intent] = field(default_factory=list) + jo_targets: list[str] = field(default_factory=list) + search_keywords: list[str] = field(default_factory=list) + matched_terms: list[str] = field(default_factory=list) + mst_targets: list[str] = field(default_factory=list) + name_targets: list[str] = field(default_factory=list) + active_sources: list[str] = field(default_factory=list) + routed_by: str = "rule" # "llm" | "rule" | "rule_fallback" + + @property + def empty(self) -> bool: + return not self.chain and not self.intents + + @property + def top_keyword(self) -> str: + if self.search_keywords: + return self.search_keywords[0] + if self.matched_terms: + return self.matched_terms[0] + return self.query.strip() + + +def _intents_yaml_path() -> Path: + return Path(__file__).resolve().parents[3] / "data" / "keyword_intents.yaml" + + +def _related_laws_yaml_path() -> Path: + return Path(__file__).resolve().parents[3] / "data" / "related_laws.yaml" + + +@lru_cache(maxsize=1) +def load_intents(path: Path | None = None) -> tuple[Intent, ...]: + p = path or _intents_yaml_path() + if not p.exists(): + return () + raw = yaml.safe_load(p.read_text(encoding="utf-8")) + out: list[Intent] = [] + for item in raw or []: + if not isinstance(item, dict): + continue + out.append( + Intent( + name=str(item["intent"]), + jo_hints=tuple(str(j) for j in item.get("jo_hints", [])), + sources=tuple(str(s) for s in item.get("sources", []) or []), + keywords=tuple(str(k) for k in item.get("keywords", []) or []), + ) + ) + return tuple(out) + + +@lru_cache(maxsize=1) +def load_related_laws(path: Path | None = None) -> tuple[RelatedLawEntry, ...]: + p = path or _related_laws_yaml_path() + if not p.exists(): + return () + raw = yaml.safe_load(p.read_text(encoding="utf-8")) or {} + items = raw.get("items", []) if isinstance(raw, dict) else raw + out: list[RelatedLawEntry] = [] + for item in items: + if not isinstance(item, dict): + continue + mst = str(item.get("mst") or "").strip() + if not mst: + continue + out.append( + RelatedLawEntry( + name=str(item.get("name", "")), + short=str(item.get("short", "")), + keywords=tuple(str(k) for k in item.get("keywords", []) or []), + mst=mst, + kind=str(item.get("kind", "")), + ) + ) + return tuple(out) + + +def extract_articles(query: str) -> list[str]: + out: list[str] = [] + for m in _ARTICLE_RE.finditer(query): + token = f"{m.group(1)}의{m.group(2)}" if m.group(2) else m.group(1) + if token not in out: + out.append(token) + return out + + +def _intents_by_name(names: list[str], all_intents: tuple[Intent, ...]) -> list[Intent]: + by_name = {i.name: i for i in all_intents} + return [by_name[n] for n in names if n in by_name] + + +def _name_for_mst(mst: str, all_related: tuple[RelatedLawEntry, ...]) -> str: + for e in all_related: + if e.mst == mst: + return e.name + return "" + + +def _active_sources_for_chain(chain: str, intents: list[Intent]) -> list[str]: + """chain별 기본 source 세트 — chain_specs.yaml 의 sources 필드 = single source of truth. + + chains.py 가 실제 호출을 결정하지만 디버깅·UI 표시용으로 같은 정보를 plan 에 둔다. + yaml 에 없는 chain (rule fallback 매칭 0건 등) 은 backward-compat 기본값. + """ + from kpaa.retrieval.chains import load_chain_specs + + for spec in load_chain_specs(): + if spec["key"] == chain: + base = list(spec["sources"]) + break + else: + base = list(_DEFAULT_SOURCES) + # intent.sources 도 합집합으로 참고 (yaml 보강) + extra = [] + for it in intents: + for s in it.sources: + if s not in base and s not in extra: + extra.append(s) + return base + extra + + +# ───────────────────────── Rule fallback ───────────────────────── + +def rule_route(query: str) -> RouterPlan: + """LLM 미사용 라우팅 — 키워드 룰 + 명시 조문 추출 + related_laws 매칭. + + KPAA_ROUTER_MODE=rule 또는 LLM 분류 실패 시 사용. + """ + plan = RouterPlan(query=query, routed_by="rule") + if not query.strip(): + return plan + intents_all = load_intents() + related_all = load_related_laws() + q_lower = query.lower() + + seen_jo: set[str] = set() + seen_intent: set[str] = set() + matched: list[str] = [] + for intent in intents_all: + hit_kw = [kw for kw in intent.keywords if kw.lower() in q_lower] + if not hit_kw: + continue + if intent.name not in seen_intent: + plan.intents.append(intent) + seen_intent.add(intent.name) + for jo in intent.jo_hints: + if jo not in seen_jo: + plan.jo_targets.append(jo) + seen_jo.add(jo) + for kw in hit_kw: + if kw not in matched: + matched.append(kw) + plan.matched_terms = matched + + for jo in extract_articles(query): + if jo not in seen_jo: + plan.jo_targets.append(jo) + seen_jo.add(jo) + + seen_mst: set[str] = set() + for entry in related_all: + # 본법(개인정보보호법, MST=270351)은 chain이 _seed_law()로 직접 처리하므로 + # mst_targets에는 *제외*. LLM router도 동일 필터. + if entry.mst == "270351": + continue + for kw in sorted(entry.keywords, key=lambda k: -len(k)): + if kw and kw.lower() in q_lower: + if entry.mst and entry.mst not in seen_mst: + plan.mst_targets.append(entry.mst) + plan.name_targets.append(entry.name) + seen_mst.add(entry.mst) + if entry.short and entry.short not in matched: + matched.append(entry.short) + break + + if matched: + plan.search_keywords = sorted(set(matched), key=lambda k: (-len(k), k)) + + # chain 결정 (rule fallback) — 16개 chain 매핑. + # 우선순위: 명시적 의도 > 광범위 catch-all. LLM 실패 시에만 동작. + plan.chain = _rule_pick_chain(plan, q_lower) + + # chain spec 의 기본 jo_hints 도 병합 — LLM 모드(classify_with_llm)와 동등. + # general_practice 같은 catch-all 도 본법 핵심 조문을 항상 포함하게 함. + from kpaa.retrieval.chains import chain_jo_hints + for jo in chain_jo_hints(plan.chain): + if jo not in seen_jo: + plan.jo_targets.append(jo) + seen_jo.add(jo) + + plan.active_sources = _active_sources_for_chain(plan.chain, plan.intents) + return plan + + +# Rule fallback 의 intent → chain 매핑 우선순위 표. +# 같은 질문에 여러 intent 매칭 시 위에서부터 먼저 매칭된 것이 chain 결정. +# 원칙: *더 구체적인* intent 가 위로 — "동의_수집" 같은 광범위 카테고리는 아래. +_INTENT_TO_CHAIN: tuple[tuple[str, str], ...] = ( + ("관련_법령", "related_laws"), + ("판례_요청", "precedent_search"), + ("안전조치", "safety_compliance"), + ("유출신고", "breach_notification"), + ("영상정보처리", "cctv_video"), + ("아동", "minor_protection"), + ("채용", "employment_recruitment"), # "이력서/주민번호" 같은 구체 케이스 + ("민감정보", "medical_health"), # "진료기록 마케팅" 같은 의료 케이스 + ("처리위탁", "processing_consignment"), + ("제3자제공", "third_party_provision"), + ("정보주체_권리", "data_subject_rights"), + ("파기", "retention_destruction"), + ("처리방침", "policy_disclosure"), + ("고유식별정보", "general_practice"), # 별도 chain 없음 + ("보호책임자", "general_practice"), # 별도 chain 없음 + ("동의_수집", "consent_collection"), # 가장 광범위 — 마지막 +) + + +def _rule_pick_chain(plan: RouterPlan, q_lower: str) -> str: + intent_names = {i.name for i in plan.intents} + + # definition 은 *법령_일반 단독* 일 때만 (정의·개요 질문) + if "법령_일반" in intent_names and len(intent_names) == 1: + return "definition" + + for name, chain in _INTENT_TO_CHAIN: + if name in intent_names: + return chain + + # 비매칭 + 비어있지 않은 쿼리는 catch-all 로. 이래야 모호 질문에도 + # general_practice 의 jo_hints 가 본법 핵심 조문을 시드한다 (rule mode catch-all 안전망). + if q_lower.strip(): + return "general_practice" + return "" + + +# ───────────────────────── LLM router (primary) ───────────────────────── + +def _rule_high_confidence(plan: RouterPlan) -> bool: + """rule_route 결과가 LLM 라우터를 우회해도 될 만큼 신뢰 가능한지. + + 기준 (둘 다 충족): + 1) chain 이 결정됨 + general_practice (catch-all) 가 아님 + 2) intent 1개 이상 매칭 + + 이 둘이 맞으면 룰이 *명확한 카테고리* 를 잡았다는 뜻 — LLM 분류기 30s prefill + 스킵 가능. 미충족(모호 질문, 이력서·동의 같은 일반어)은 기존대로 LLM 호출. + + Why: 흔한 질문(CCTV 안내, 유출 신고, 직원 동의)은 룰이 잘 잡지만 v2 에서는 + 무조건 LLM 거쳤음 → 라우팅이 답변 시작 전 가장 큰 병목. 룰 high-confidence + short-circuit 으로 일상적 케이스 60-80% 의 라우팅을 ~0초로 단축. + """ + if not plan.chain or plan.chain == "general_practice": + return False + return bool(plan.intents) + + +async def route( + query: str, + *, + backend=None, + mode: str | None = None, +) -> RouterPlan: + """KPAA v2 진입점 — rule short-circuit → LLM 분류기 → rule fallback. + + 동작: + auto (기본): rule_route 먼저 → high-confidence 면 즉시 반환 (LLM 스킵). + 약하면 LLM 분류기 호출. LLM 실패·empty 시 rule fallback. + rule : 룰 only (CI·테스트용). + always : LLM 강제, 실패 시 raise. + + - backend=None 이면 자동으로 `kpaa.llm.get_backend()` 호출. + - mode 우선순위: 인자 > KPAA_ROUTER_MODE > "auto" + """ + mode = (mode or os.environ.get("KPAA_ROUTER_MODE") or "auto").lower() + + if mode == "rule": + return rule_route(query) + + # ★ Short-circuit: auto 모드에서 룰이 명확하게 카테고리를 잡으면 LLM 스킵. + # rule_route 자체는 ~50ms 라 LLM fallback 시에도 부담 없음. + rule_plan: RouterPlan | None = None + if mode == "auto": + rule_plan = rule_route(query) + if _rule_high_confidence(rule_plan): + rule_plan.routed_by = "rule_fast" + return rule_plan + + # LLM 분류기 호출 + try: + from kpaa.llm import get_backend + from kpaa.related_laws import load_items + from kpaa.retrieval.llm_router import classify + + bk = backend or get_backend() + related_items = [it for it in load_items() if it.get("mst")] + result = await classify(query, backend=bk, related_laws=related_items) + except Exception: + if mode == "always": + raise + # auto — fallback. 위에서 이미 계산한 rule_plan 재사용. + plan = rule_plan if rule_plan is not None else rule_route(query) + plan.routed_by = "rule_fallback" + return plan + + if result.empty: + if mode == "always": + return RouterPlan(query=query, chain="", routed_by="llm", search_keywords=result.search_keywords) + plan = rule_plan if rule_plan is not None else rule_route(query) + plan.routed_by = "rule_fallback" + return plan + + # ClassificationResult → RouterPlan + intents_all = load_intents() + matched_intents = _intents_by_name(result.intents, intents_all) + + # mst_targets에 본법(MST=270351) 들어와도 chain이 처리하니 제거 + related_all = load_related_laws() + valid_msts = {e.mst for e in related_all} + msts = [m for m in result.mst_targets if m and m != "270351" and m in valid_msts] + names = [_name_for_mst(m, related_all) for m in msts] + + plan = RouterPlan( + query=query, + chain=result.chain or "general_practice", + intents=matched_intents, + jo_targets=list(result.jo_targets), + search_keywords=list(result.search_keywords), + matched_terms=list(result.search_keywords), + mst_targets=msts, + name_targets=names, + routed_by="llm", + ) + # jo 추가 보강 — (1) LLM이 빠뜨린 명시 조문은 정규식으로, + # (2) intent metadata 의 jo_hints 도 병합 (예: 영상정보처리 → 25), + # (3) chain_spec 의 기본 jo_hints 도 병합 (LLM 이 jo 안 골랐을 때 안전망). + seen_jo = set(plan.jo_targets) + for jo in extract_articles(query): + if jo not in seen_jo: + plan.jo_targets.append(jo) + seen_jo.add(jo) + for it in matched_intents: + for jo in it.jo_hints: + if jo not in seen_jo: + plan.jo_targets.append(jo) + seen_jo.add(jo) + from kpaa.retrieval.chains import chain_jo_hints + for jo in chain_jo_hints(plan.chain): + if jo not in seen_jo: + plan.jo_targets.append(jo) + seen_jo.add(jo) + + plan.active_sources = _active_sources_for_chain(plan.chain, plan.intents) + return plan + + +__all__ = [ + "Intent", + "RelatedLawEntry", + "RouterPlan", + "load_intents", + "load_related_laws", + "extract_articles", + "rule_route", + "route", +] diff --git a/src/kpaa/retrieval/verify.py b/src/kpaa/retrieval/verify.py new file mode 100644 index 0000000000000000000000000000000000000000..a50a5c2634ec9f00ba87f63c89322363f75fc98b --- /dev/null +++ b/src/kpaa/retrieval/verify.py @@ -0,0 +1,231 @@ +"""답변 LLM 출력의 *결정적* 인용 검증 (Phase D1). + +답변 LLM(Gemma 4 E2B) 이 컨텍스트에 없는 결정문·해석례·헌재·판례 번호를 *지어내면* +사용자가 알아채기 어렵다. 이 모듈은 답변 텍스트에서 인용 식별자(번호 형식)를 +정규식으로 추출하고 retrieval 단의 `excerpts` 와 대조해 환각 가능성이 있는 +인용에 경고를 부착한다. + +원작 chrisryugj/korean-law-mcp v3.5 의 `verify_citations` 패턴 참고. 단 KPAA 는 +*RAG-only* 정책이므로 *답변 LLM 추가 호출 없음* — 순수 정규식 + excerpts metadata +대조만으로 판정한다 (sub-second cost). + +검증 대상: + 1. PIPC 결정 YYYY-NNN + 2. 헌재 YYYY헌○NNN (한글 분류명 포함, 예: 2020헌바123) + 3. 법령해석례 안건 NN-NNNN + 4. 판례 사건번호 (예: 2018두56077, 2020다12345 — 법원명 prefix 없어도 매칭) + +검증 *제외*: + - 법령 조문 인용 (`개인정보보호법 제15조`) — false positive 너무 많음 + - 상담사례 #NNN — 1,745건이라 답변에 거의 안 나옴 + +판정 규칙: + - 추출된 식별자가 excerpts 중 *어느 하나의* citation/metadata 와 매칭 → ✓ verified + - 매칭 안 됨 → ✗ hallucinated (경고 부착) +""" +from __future__ import annotations + +import re +from dataclasses import dataclass, field + +from kpaa.retrieval.excerpts import Excerpt + + +# ─────────────────────────────── 정규식 ─────────────────────────────── + +# PIPC 결정 번호 — "PIPC 결정 2024-12-345", "개인정보보호위원회 결정 2024-2345" +_PIPC_RE = re.compile( + r"(?:PIPC|개인정보\s*보호\s*위원회)\s*결정(?:문)?\s*(?:#)?\s*" + r"(?P\d{4}-\d{1,6}(?:-\d{1,6})?)" +) + +# 헌재 사건번호 — "헌재 2020헌바123", "헌법재판소 2020헌마567" 등 +# 헌법 분류 코드: 가/나/마/바/사/아/자 등 한글 1글자 +_HUNJAE_RE = re.compile( + r"(?:헌재|헌법재판소)\s*(?P\d{4}헌[가-힣]\d{1,5})" +) + +# 법령해석례 안건번호 — "법령해석례 안건 20-0370", "안건 NN-NNNN" +_INTERP_RE = re.compile( + r"(?:법령해석례\s*)?안건\s*(?P\d{2}-\d{3,5})" +) + +# 판례 사건번호 — 단독 식별 가능한 사건번호. 예: "2018두56077", "2020다12345" +# 판례 분류 한글: 가, 나, 다, 라, 두, 누, 부, 도 등. 헌재(`YYYY헌○...`)와 충돌 +# 방지를 위해 *YYYY 직후가 "헌" 이면 제외* (헌재 분류는 별도 _HUNJAE_RE 가 잡음). +_PREC_RE = re.compile( + r"(?\d{4}(?!헌)[가-힣]{1,2}\d{2,6})" + r"(?![\dA-Za-z])" +) + + +# ─────────────────────────────── 데이터 구조 ─────────────────────────────── + +@dataclass(frozen=True) +class Citation: + kind: str # 'pipc' | 'hunjae' | 'interpretation' | 'precedent' + identifier: str # 정규식이 잡은 식별자 (예: "2024-12-345", "2020헌바123") + raw_match: str # 답변 텍스트의 매칭 부분 (디버깅용) + + +@dataclass +class CitationReport: + verified: list[Citation] = field(default_factory=list) + hallucinated: list[Citation] = field(default_factory=list) + + @property + def has_hallucinations(self) -> bool: + return bool(self.hallucinated) + + +# ─────────────────────────────── 추출 ─────────────────────────────── + +def extract_citations(text: str) -> list[Citation]: + """답변 텍스트에서 검증 가능한 *식별자형* 인용을 추출. + + 같은 식별자가 여러 번 나와도 *첫 매칭만* 반환 (kind+id dedup). + """ + if not text: + return [] + out: list[Citation] = [] + seen: set[tuple[str, str]] = set() + + def _push(kind: str, m: re.Match[str]) -> None: + ident = m.group("no").strip() + key = (kind, ident) + if key in seen: + return + seen.add(key) + out.append(Citation(kind=kind, identifier=ident, raw_match=m.group(0))) + + for m in _PIPC_RE.finditer(text): + _push("pipc", m) + for m in _HUNJAE_RE.finditer(text): + _push("hunjae", m) + for m in _INTERP_RE.finditer(text): + _push("interpretation", m) + for m in _PREC_RE.finditer(text): + _push("precedent", m) + return out + + +# ─────────────────────────────── 검증 ─────────────────────────────── + +def _excerpts_index(excerpts: list[Excerpt]) -> dict[str, set[str]]: + """excerpts 의 식별자 인덱스 — kind → set of identifiers. + + 빠른 lookup 을 위해 한 번 빌드. + """ + idx: dict[str, set[str]] = { + "pipc": set(), + "hunjae": set(), + "interpretation": set(), + "precedent": set(), + } + for e in excerpts: + md = e.metadata or {} + cit = e.citation or "" + if e.source_type == "pipc": + no = md.get("decision_no") or "" + if no and no.lower() != "null": + idx["pipc"].add(no.strip()) + # citation 형태 "PIPC 결정 2024-12-345" 도 매칭 후보로 + for m in _PIPC_RE.finditer(cit): + idx["pipc"].add(m.group("no").strip()) + elif e.source_type == "constitutional": + no = md.get("case_no") or "" + if no: + idx["hunjae"].add(no.strip()) + for m in _HUNJAE_RE.finditer(cit): + idx["hunjae"].add(m.group("no").strip()) + elif e.source_type == "interpretation": + no = md.get("case_no") or "" + if no: + idx["interpretation"].add(no.strip()) + for m in _INTERP_RE.finditer(cit): + idx["interpretation"].add(m.group("no").strip()) + elif e.source_type == "precedent": + no = md.get("case_no") or "" + if no: + idx["precedent"].add(no.strip()) + for m in _PREC_RE.finditer(cit): + idx["precedent"].add(m.group("no").strip()) + return idx + + +def verify_citations(answer: str, excerpts: list[Excerpt]) -> CitationReport: + """답변의 식별자 인용을 retrieval excerpts 와 대조. + + Args: + answer: 답변 LLM 출력 텍스트. + excerpts: 같은 turn 의 retrieval 결과 (RAG 컨텍스트). + + Returns: + CitationReport — verified / hallucinated 분리. + """ + citations = extract_citations(answer) + if not citations: + return CitationReport() + + idx = _excerpts_index(excerpts or []) + report = CitationReport() + for c in citations: + bucket = idx.get(c.kind, set()) + if c.identifier in bucket: + report.verified.append(c) + else: + report.hallucinated.append(c) + return report + + +# ─────────────────────────────── 경고 부착 ─────────────────────────────── + +_KIND_LABEL = { + "pipc": "PIPC 결정", + "hunjae": "헌재", + "interpretation": "법령해석례 안건", + "precedent": "판례 사건번호", +} + + +def format_warning(report: CitationReport) -> str: + """환각 인용이 있으면 답변 끝에 부착할 경고 블록을 생성. + + 환각 0건이면 빈 문자열. + """ + if not report.has_hallucinations: + return "" + lines = [ + "", + "⚠️ **인용 검증 안내** — 다음 인용은 본 답변의 *검색 결과* 에서 직접 확인되지 않았습니다.", + "정확성을 보장할 수 없으니 원문 확인을 권합니다:", + ] + for c in report.hallucinated: + label = _KIND_LABEL.get(c.kind, c.kind) + lines.append(f"- {label} {c.identifier}") + return "\n".join(lines) + + +def annotate(answer: str, excerpts: list[Excerpt]) -> tuple[str, CitationReport]: + """답변에 검증 경고를 부착하고 보고서를 반환. + + 환각 0건이면 답변 변경 없음. + """ + report = verify_citations(answer, excerpts) + if not report.has_hallucinations: + return answer, report + warning = format_warning(report) + if not warning: + return answer, report + return answer.rstrip() + "\n\n" + warning, report + + +__all__ = [ + "Citation", + "CitationReport", + "extract_citations", + "verify_citations", + "format_warning", + "annotate", +] diff --git a/src/kpaa/server.py b/src/kpaa/server.py new file mode 100644 index 0000000000000000000000000000000000000000..5a994a80a3cac836849c8e154516aff86d8f6eef --- /dev/null +++ b/src/kpaa/server.py @@ -0,0 +1,1122 @@ +"""OpenAI-호환 FastAPI 서버 — Open WebUI에 등록할 OpenAI provider 역할. + +엔드포인트: + GET /healthz — liveness + GET /v1/models — 모델 1개 (kpaa-privacy-ko) + POST /v1/chat/completions — 비스트리밍/스트리밍 모두 지원 + (stream=true 일 때 SSE) + +요청의 `model`, `system` 메시지는 무시한다 — 항상 RAG 파이프라인을 거치므로 +시스템 프롬프트는 서버에서 주입한다. 사용자 질문은 마지막 `role=user` 메시지의 +`content`를 사용 (멀티턴 history는 v1 미지원). +""" +from __future__ import annotations + +import asyncio +import contextlib +import json +import time +import uuid +from collections.abc import AsyncIterator +from typing import Any, Literal + +from fastapi import FastAPI, HTTPException, Query +from fastapi.responses import HTMLResponse, StreamingResponse +from pydantic import BaseModel, ConfigDict, Field + +from kpaa import __version__ +from kpaa.llm import ChatMessage as LLMChatMessage +from kpaa.llm import LLMOptions +from kpaa.pipeline import generate +from kpaa.retrieval.excerpts import Excerpt + +MODEL_ID = "kpaa-privacy-ko" + + +def _excerpt_to_dict(e: Excerpt) -> dict[str, Any]: + return { + "source_type": e.source_type, + "citation": e.citation, + "title": e.title, + "content": e.content, + "url": (e.metadata or {}).get("url", ""), + "metadata": dict(e.metadata or {}), + } + + +# 모듈 레벨 single-user 캐시 — `/v1/chat/completions` 호출 (Open WebUI에서 들어옴) +# 시 retrieval 결과를 저장 → /api/last-references 에서 polling, `/` (분할) UI에서 +# 우측 패널이 갱신. cited_citations 는 답변 완료 시점에 final answer 와 매칭해 +# 채워짐 (검색 직후엔 빈 리스트). llm_excerpt_citations 는 retrieval 시점에 +# 상위 DEFAULT_MAX_EXCERPTS 건의 citation — 사용자가 "어느 게 LLM 입력으로 +# 갔는지" 패널에서 즉시 인지하도록. 멀티유저 가정 안 함 (로컬 단일 유저용). +_last_refs: dict[str, Any] = { + "ts": 0.0, + "query": "", + "intents": [], + "jo_targets": [], + "elapsed_ms": 0, + "excerpts": [], + "cited_citations": [], + "llm_excerpt_citations": [], + # 답변 본문에서 추출된 (근거N) 의 N 들 — UI 가 카드 배지에 [근거N] 표시. + "geungeo_indices_in_answer": [], +} + + +from kpaa.retrieval.citation_match import ( + compute_cited as _compute_cited_helper, + extract_geungeo_indices as _extract_geungeo_indices, +) + + +def _compute_cited(answer: str, excerpts: list[Excerpt]) -> list[str]: + """[backward-compat wrapper] excerpts → citations 추출 후 공통 헬퍼 호출.""" + return _compute_cited_helper(answer, [e.citation for e in excerpts]) + + +def _mark_cited(answer: str) -> None: + """답변 완료 후 _last_refs 에 cited_citations + 인용 N 업데이트 + ts 갱신. + + polling 측 (1초 주기) 이 ts 변경을 감지해 다시 그림. 매칭 정책은 + `kpaa.retrieval.citation_match` 의 공통 헬퍼 사용 — Gradio UI 와 동일. + """ + excerpts_dicts = _last_refs.get("excerpts") or [] + citations = [str(ed.get("citation") or "") for ed in excerpts_dicts] + cited = _compute_cited_helper(answer, citations) + _last_refs["cited_citations"] = cited + # UI 카드에 [근거N] 표시용 — answer 에 실제 등장한 N 만. + _last_refs["geungeo_indices_in_answer"] = sorted(_extract_geungeo_indices(answer)) + _last_refs["ts"] = time.time() + + +_META_PROMPT_MARKERS = ( + "### Task:", + "", + "follow-ups", + "follow_ups", + "JSON object", + "JSON format:", + "### Output:", + "Generate 1-3", # tag generation + "autocomplet", # autocomplete (대소문자 무관) + "Concise Title", # chat title generation +) + + +def _is_meta_query(q: str) -> bool: + """Open WebUI가 follow-up/tag/title 자동 생성용으로 보내는 메타 프롬프트인지 감지. + 이런 호출은 우측 references 패널에 노이즈가 되니 캐시를 갱신하지 않는다. + """ + if not q: + return True + s = q.lower() + return any(m.lower() in s for m in _META_PROMPT_MARKERS) + + +def _update_last_refs(query: str, retrieval_result) -> None: + if _is_meta_query(query): + return + from kpaa.retrieval.context_builder import DEFAULT_MAX_EXCERPTS + + _last_refs["ts"] = time.time() + _last_refs["query"] = query + _last_refs["intents"] = [i.name for i in retrieval_result.plan.intents] + _last_refs["jo_targets"] = list(retrieval_result.plan.jo_targets) + _last_refs["elapsed_ms"] = retrieval_result.elapsed_ms + _last_refs["excerpts"] = [_excerpt_to_dict(e) for e in retrieval_result.excerpts] + # 상위 N건이 LLM 입력으로 — context_builder.build() 의 cap 과 동일 정책. + _last_refs["llm_excerpt_citations"] = [ + e.citation for e in retrieval_result.excerpts[:DEFAULT_MAX_EXCERPTS] + ] + # 검색 완료 시점엔 답변 아직 없음 — done 시점에 _mark_cited 가 채움. + _last_refs["cited_citations"] = [] + _last_refs["geungeo_indices_in_answer"] = [] + + +# ───────────────────────── OpenAI request/response ───────────────────────── + +class ChatMessage(BaseModel): + role: Literal["system", "user", "assistant", "tool"] + content: str + + +class ChatRequest(BaseModel): + model_config = ConfigDict(extra="ignore") # 모르는 필드는 무시 (Open WebUI가 보내는 필드 다양) + + model: str = MODEL_ID + messages: list[ChatMessage] + stream: bool = False + temperature: float = 0.2 + top_p: float = 0.9 + max_tokens: int | None = 900 + stop: list[str] | str | None = None + + +class ChatChoiceMessage(BaseModel): + role: Literal["assistant"] = "assistant" + content: str + + +class ChatChoice(BaseModel): + index: int = 0 + message: ChatChoiceMessage + finish_reason: Literal["stop", "length", "content_filter"] = "stop" + + +class ChatUsage(BaseModel): + prompt_tokens: int = 0 + completion_tokens: int = 0 + total_tokens: int = 0 + + +class ChatResponse(BaseModel): + id: str + object: Literal["chat.completion"] = "chat.completion" + created: int + model: str + choices: list[ChatChoice] + usage: ChatUsage = Field(default_factory=ChatUsage) + + +class ModelInfo(BaseModel): + id: str + object: Literal["model"] = "model" + created: int + owned_by: str = "kpaa" + + +class ModelList(BaseModel): + object: Literal["list"] = "list" + data: list[ModelInfo] + + +# ───────────────────────── helpers ───────────────────────── + +def _last_user_query(messages: list[ChatMessage]) -> str: + for m in reversed(messages): + if m.role == "user" and m.content.strip(): + return m.content.strip() + raise HTTPException(400, "no user message with content") + + +def _split_history_and_query(messages: list[ChatMessage]) -> tuple[list[LLMChatMessage], str]: + """ChatRequest.messages → (history, last_user_query). + + - history: 마지막 user 이전의 user/assistant 쌍만 (system 제외 — 우리가 자체 + system_prompt를 주입하므로). 빈 메시지는 스킵. + - last_user_query: 마지막 role=user 메시지의 content. + """ + if not messages: + raise HTTPException(400, "no messages") + last_idx = -1 + for i in range(len(messages) - 1, -1, -1): + if messages[i].role == "user" and messages[i].content.strip(): + last_idx = i + break + if last_idx < 0: + raise HTTPException(400, "no user message with content") + query = messages[last_idx].content.strip() + history: list[LLMChatMessage] = [] + for m in messages[:last_idx]: + if m.role in ("user", "assistant") and m.content.strip(): + history.append(LLMChatMessage(role=m.role, content=m.content.strip())) + return history, query + + +def _options_from_request(req: ChatRequest) -> LLMOptions: + stop: tuple[str, ...] + if req.stop is None: + stop = () + elif isinstance(req.stop, str): + stop = (req.stop,) + else: + stop = tuple(req.stop) + return LLMOptions( + temperature=max(0.0, min(2.0, req.temperature)), + top_p=max(0.0, min(1.0, req.top_p)), + max_tokens=req.max_tokens or 900, + stop=stop, + ) + + +def _new_id() -> str: + return f"chatcmpl-{uuid.uuid4().hex[:24]}" + + +def _sse(data: dict[str, Any] | str) -> str: + if isinstance(data, str): + return f"data: {data}\n\n" + return f"data: {json.dumps(data, ensure_ascii=False)}\n\n" + + +# ───────────────────────── streaming generator ───────────────────────── + +# 단계별 prelude 라벨 +_SOURCE_LABEL = { + "case": "상담사례", + "guide": "안내서", + "law": "본법 조문", + "related_law": "관련법령", + "related_law_static": "관련법령(목록)", + "pipc": "PIPC 결정", + "interpretation": "법령해석례", + "precedent": "판례", + "admin_rule": "행정규칙", +} + + +def _format_stage(stage: str, payload: dict) -> str | None: + """단계 이벤트 → 마크다운 한 줄. None 이면 표시 생략. + + 표시 정책: 라우팅·검색 단계를 사용자가 *볼 수 있게* 하되 한 줄씩만. + chain_started 는 근거 검색 시작 헤더, fetch_done 만 카운트 표시 + (fetch_started 는 너무 잦아 생략). + """ + if stage == "routing_started": + # trailing space 만 두고 줄바꿈 안 함 → spinner 가 같은 줄 끝에 점 누적. + return "🔎 _질문을 분석하고 적합한 검색 도구를 선정 중…_ " + if stage == "routing_done": + from kpaa.retrieval.chains import chain_label + + chain = payload.get("chain") or "" + chain_ko = chain_label(chain) if chain else "(미결정)" + intents = payload.get("intents") or [] + jo = payload.get("jo_targets") or [] + names = payload.get("name_targets") or [] + kws = payload.get("search_keywords") or [] + routed_by = payload.get("routed_by") or "?" + # 사용자 친화 표시 — 한국어 라벨 우선, 영문 chain key 는 코드블럭에 작게. + bits = [f"검색 전략: **{chain_ko}** (`{chain}`)"] + if jo: + bits.append(f"조문=제{','.join(jo)}조") + if names: + bits.append(f"법령={names}") + if kws: + bits.append(f"검색어={kws}") + if intents: + bits.append(f"의도={intents}") + # spinner 가 누적한 점이 있는 라인 다음 줄로 분리하기 위해 앞에 \n. + return f"\n🧭 _{' · '.join(bits)}_ (분류기: {routed_by})\n\n" + if stage == "chain_started": + srcs = payload.get("sources") or [] + labeled = [_SOURCE_LABEL.get(s, s) for s in srcs] + chain_ko = payload.get("ko") or "" + prefix = f"📂 _근거 검색 중 — {chain_ko}_" if chain_ko else "📂 _근거 검색 중…_" + return f"{prefix} ({' · '.join(labeled)})\n" + if stage == "fetch_done": + src = payload.get("source") or "" + cnt = payload.get("count", 0) + kw = payload.get("keyword") or "" + law = payload.get("law") or "" + laws = payload.get("laws") or [] + label = _SOURCE_LABEL.get(src, src) + mark = "✓" if cnt > 0 else "·" + suffix = "" + if kw: + suffix = f" — `{kw}`" + elif law: + suffix = f" — {law}" + elif laws: + suffix = f" — {', '.join(laws)}" + # 들여쓰기 없이 트리 글자만 — Open WebUI 등 일부 렌더러가 HTML 엔티티 + # ( ) 를 raw 텍스트로 보여주는 문제 회피. + return f"├ _{label} {mark} {cnt}건{suffix}_\n" + return None + + +async def _stream_chat( + req: ChatRequest, + query: str, + history: list[LLMChatMessage], +) -> AsyncIterator[str]: + """OpenAI SSE chunk 형식으로 토큰 스트리밍.""" + completion_id = _new_id() + created = int(time.time()) + options = _options_from_request(req) + + def _delta(content: str) -> dict: + return { + "id": completion_id, + "object": "chat.completion.chunk", + "created": created, + "model": MODEL_ID, + "choices": [{"index": 0, "delta": {"content": content}, "finish_reason": None}], + } + + def _reasoning_delta(reasoning: str) -> dict: + """OpenAI 호환 reasoning_content delta — Open WebUI가 reasoning UI 로 자동 변환. + + DeepSeek-R1, o1 식 패턴. delta.content 와 별개 채널이라 `
` HTML + 파싱 이슈 회피 + 클라이언트가 자동 collapse 처리. + """ + return { + "id": completion_id, + "object": "chat.completion.chunk", + "created": created, + "model": MODEL_ID, + "choices": [ + { + "index": 0, + "delta": {"reasoning_content": reasoning}, + "finish_reason": None, + } + ], + } + + # 첫 chunk: role 설정 + yield _sse({ + "id": completion_id, + "object": "chat.completion.chunk", + "created": created, + "model": MODEL_ID, + "choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}], + }) + + # 진행 표시 정책: + # - "...중…" 으로 끝나는 phase 라인 뒤에 점이 천천히 누적되어 *동작 중* + # 이라는 시각 신호를 줌. SSE chunk = append-only 라 ChatGPT 스타일 in-place + # 단일 점 펄스는 불가능 (Open WebUI 의 `
` 도 + # 라벨 i18n 고정 + 두 번째 블럭 raw HTML 노출 문제로 사용 불가). 그래서 + # dot accumulation 으로 대체. + # - 두 phase 에서 동작: + # routing : 🔎 라인 직후 ~ 🧭 라인 도착 전 (LLM 분류기 1샷 ~7s) + # thinking : 💭 라인 직후 ~ 첫 답변 토큰 도착 전 (답변 LLM prefill ~5-20s) + q: asyncio.Queue[tuple[str, Any]] = asyncio.Queue() + SENTINEL = ("end", None) + DOT_INTERVAL_S = 0.5 + DOT_MAX = 60 # 약 30초 분량 (라우팅+thinking 모두 커버) + + async def _producer() -> None: + try: + async for evt in generate( + query, + options=options, + inline_references=False, + history=history, + ): + await q.put(("evt", evt)) + finally: + await q.put(SENTINEL) + + async def _ticker() -> None: + try: + for _ in range(DOT_MAX): + await asyncio.sleep(DOT_INTERVAL_S) + await q.put(("tick", None)) + except asyncio.CancelledError: + pass + + producer = asyncio.create_task(_producer()) + ticker: asyncio.Task | None = None + phase = "init" # init → routing → between → thinking → answering + sent_summary = False + finish_reason: str | None = None + saw_token = False + + DOT_PHASES = {"routing", "thinking"} + + # prelude 전체를 reasoning_content 채널로 송출. Open WebUI 가 자동으로 + # 펄스 + 자동 collapse + 라벨(i18n "처리 과정 중...") 처리. + # 첫 답변 토큰 도착하면 채널을 content 로 전환 → 답변 본문 정상 영역에 표시. + try: + while True: + kind, payload = await q.get() + if kind == "end": + break + if kind == "tick": + if phase in DOT_PHASES: + yield _sse(_reasoning_delta(".")) + continue + evt = payload + if evt["event"] == "stage": + stage = evt["stage"] + line = _format_stage(stage, evt.get("payload") or {}) + if line: + yield _sse(_reasoning_delta(line)) + if stage == "routing_started": + phase = "routing" + if ticker is None or ticker.done(): + ticker = asyncio.create_task(_ticker()) + elif stage == "routing_done": + if ticker and not ticker.done(): + ticker.cancel() + ticker = None + phase = "between" + elif evt["event"] == "retrieval": + _update_last_refs(query, evt["result"]) + if not sent_summary: + res = evt["result"] + msg = ( + f"\n✅ 검색 완료 — 총 {len(res.excerpts)}건 ({res.elapsed_ms}ms)\n\n" + f"💭 답변을 준비하는 중… " + ) + yield _sse(_reasoning_delta(msg)) + sent_summary = True + phase = "thinking" + ticker = asyncio.create_task(_ticker()) + elif evt["event"] == "token": + if not saw_token: + saw_token = True + if ticker and not ticker.done(): + ticker.cancel() + ticker = None + phase = "answering" + # reasoning_content 채널 → content 채널 전환은 자동 + # (delta 필드만 다르게 보내면 됨). Open WebUI 는 첫 content + # 도착 시 reasoning pill 을 자동 collapse. + yield _sse(_delta(evt["delta"])) + elif evt["event"] == "done": + finish_reason = "stop" + # 답변 완료 — references 패널이 다음 polling 에서 채택 표시 그리도록 갱신. + if not _is_meta_query(query): + _mark_cited(evt.get("answer") or "") + finally: + if ticker and not ticker.done(): + ticker.cancel() + with contextlib.suppress(asyncio.CancelledError): + await ticker + if not producer.done(): + producer.cancel() + with contextlib.suppress(asyncio.CancelledError): + await producer + + if not saw_token and finish_reason is None: + finish_reason = "stop" + + # 마지막 chunk: finish_reason + yield _sse({ + "id": completion_id, + "object": "chat.completion.chunk", + "created": created, + "model": MODEL_ID, + "choices": [{"index": 0, "delta": {}, "finish_reason": finish_reason or "stop"}], + }) + yield _sse("[DONE]") + + +# ───────────────────────── app ───────────────────────── + +def create_app() -> FastAPI: + app = FastAPI( + title="KPAA — 개인정보보호법 미니 상담", + version=__version__, + description="OpenAI-호환 chat completions API (Open WebUI provider 등록용)", + ) + + @app.get("/", response_class=HTMLResponse) + async def index() -> str: + # 루트 = Open WebUI + 참고자료 분할 화면. 백엔드 정보 페이지는 /info. + return _SPLIT_HTML + + @app.get("/info", response_class=HTMLResponse) + async def info_page() -> str: + return f""" + +KPAA — 백엔드 정보 + + +

KPAA — 개인정보보호법 미니 상담 백엔드

+

버전 {__version__} · 모델 {MODEL_ID}

+ +

+👉 Open WebUI + 참고자료 분할 화면 (홈)   ·   +자체 채팅 UI +

+ +

Endpoints

+ + + + + + + + + +
MethodPath설명
GET/ — Open WebUI + 참고자료 분할 화면
GET/chat자체 채팅 UI (답변 + 참고자료 좌/우 분할)
GET/api/chat?q=…SSE 스트림 (자체 UI용)
GET/healthzLiveness
GET/v1/modelsOpenAI-호환 모델 목록
POST/v1/chat/completionsOpenAI-호환 chat (Open WebUI용)
GET/docsSwagger UI
+ +

Open WebUI 연결

+

Settings → Connections → OpenAI API → URL http://localhost:8000/v1, Key local

+ +

curl 예시

+
curl -N -X POST http://localhost:8000/v1/chat/completions \\
+  -H 'Content-Type: application/json' \\
+  -d '{{"model":"{MODEL_ID}","messages":[{{"role":"user","content":"매장 CCTV 안내문구는?"}}],"stream":true}}'
+ +

※ 본 챗봇 답변은 일반적 정보 제공이며 법률 자문이 아닙니다. 신고: KISA 118 / privacy.go.kr

+""" + + @app.get("/healthz") + async def healthz() -> dict[str, str]: + return {"status": "ok", "version": __version__, "model": MODEL_ID} + + @app.get("/v1/models") + async def list_models() -> ModelList: + return ModelList( + data=[ModelInfo(id=MODEL_ID, created=int(time.time()))] + ) + + @app.post("/v1/chat/completions") + async def chat_completions(req: ChatRequest): + history, query = _split_history_and_query(req.messages) + + if req.stream: + return StreamingResponse( + _stream_chat(req, query, history), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + }, + ) + + # 비스트리밍: 모두 모아서 한 번에 응답 + options = _options_from_request(req) + chunks: list[str] = [] + final_answer: str | None = None + async for evt in generate( + query, + options=options, + inline_references=False, + history=history, + ): + if evt["event"] == "retrieval": + _update_last_refs(query, evt["result"]) + elif evt["event"] == "token": + chunks.append(evt["delta"]) + elif evt["event"] == "done": + final_answer = evt["answer"] + text = final_answer if final_answer is not None else "".join(chunks) + # 비스트리밍 분기도 답변 완료 후 cited 표시 갱신. + if text and not _is_meta_query(query): + _mark_cited(text) + return ChatResponse( + id=_new_id(), + created=int(time.time()), + model=MODEL_ID, + choices=[ChatChoice(message=ChatChoiceMessage(content=text))], + ) + + # ───────────────────────── 자체 채팅 UI (좌/우 분할) ───────────────────────── + + @app.get("/chat", response_class=HTMLResponse) + async def chat_ui() -> str: + return _CHAT_HTML + + @app.get("/api/last-references") + async def api_last_refs() -> dict[str, Any]: + return dict(_last_refs) + + @app.get("/api/chat") + async def api_chat(q: str = Query(..., min_length=1, max_length=2000)): + """SSE: token + references + done events. EventSource 호환.""" + + async def gen(): + opts = LLMOptions() + refs_sent = False + # 자체 UI는 우측 패널에 참고자료를 별도 표시하므로 답변 본문에는 부착 X + async for evt in generate(q.strip(), options=opts, inline_references=False): + if evt["event"] == "stage": + payload = {"stage": evt["stage"], **(evt.get("payload") or {})} + yield f"event: stage\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n" + elif evt["event"] == "retrieval": + res = evt["result"] + payload = { + "intents": [i.name for i in res.plan.intents], + "jo_targets": list(res.plan.jo_targets), + "elapsed_ms": res.elapsed_ms, + "excerpts": [_excerpt_to_dict(e) for e in res.excerpts], + } + yield f"event: references\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n" + refs_sent = True + elif evt["event"] == "token": + yield f"event: token\ndata: {json.dumps({'delta': evt['delta']}, ensure_ascii=False)}\n\n" + elif evt["event"] == "done": + yield f"event: done\ndata: {json.dumps({'answer': evt['answer']}, ensure_ascii=False)}\n\n" + if not refs_sent: + yield "event: references\ndata: {\"excerpts\": []}\n\n" + + return StreamingResponse( + gen(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + return app + + +def run(*, host: str = "127.0.0.1", port: int = 8000) -> None: + """`kpaa serve`의 진입점 — uvicorn으로 앱을 띄운다.""" + import uvicorn + + uvicorn.run(create_app(), host=host, port=port, log_level="info") + + +__all__ = ["create_app", "run", "MODEL_ID"] + + +_SPLIT_HTML = """ + +KPAA — Open WebUI + 참고자료 + + + + +
+
+ +
+
+
+

참고한 자료

+ +
+
Open WebUI에서 질문하면 LLM이 본 근거가 여기에 표시됩니다 (1초마다 갱신).
+
+
아직 답변이 없습니다.
+
+
+
+ +""" + + +_CHAT_HTML = """ + +KPAA — 개인정보보호법 상담 + + + +
+
+
+

KPAA — 개인정보보호법 상담

+ 모델: kpaa-privacy-ko +
+
+
+
상담 도우미
+
안녕하세요. 개인 · 소상공인 · 작은 병원의 개인정보보호법 궁금증을 평이한 한국어로 안내해 드립니다. 무엇이 궁금하신가요?
+
+
+
+ + +
+
+
+
+

참고한 자료

+ +
+
질문을 보내면 LLM이 본 근거가 여기에 표시됩니다.
+
+
아직 답변이 없습니다.
+
+
+
+ +""" diff --git a/src/kpaa/ui/__init__.py b/src/kpaa/ui/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..2f259344f44cdc72b2c97e3397a832d7d3fe9aad --- /dev/null +++ b/src/kpaa/ui/__init__.py @@ -0,0 +1,5 @@ +"""KPAA UI 패키지. + +- `gradio` 모듈 — HF Spaces 데모 (Gradio Blocks). +- 로컬 노트북용 자체 채팅 UI 는 `kpaa.server` 의 `/chat` 엔드포인트. +""" diff --git a/src/kpaa/ui/gradio.py b/src/kpaa/ui/gradio.py new file mode 100644 index 0000000000000000000000000000000000000000..809884dd23f307c0fc0ee5b363471286700f303e --- /dev/null +++ b/src/kpaa/ui/gradio.py @@ -0,0 +1,449 @@ +"""HF Spaces (ZeroGPU) 데모용 Gradio Blocks UI. + +레이아웃: + ┌──────────────────────────┬──────────────────┐ + │ 💬 채팅 (질문/답변) │ 📚 참고한 자료 │ + │ + 진행 표시 (라우팅 등) │ (excerpts 카드) │ + │ [Textbox] [Send] │ │ + └──────────────────────────┴──────────────────┘ + +특징: +- `gr.Chatbot(type="messages")` + 비동기 generator 로 토큰 스트리밍. +- 우측 references 패널은 `gr.HTML` 로 렌더 (자체 `/chat` UI 카드 스타일 미러). +- 모든 state 는 Gradio 함수 인자 (history) 와 컴포넌트 출력 — 모듈 레벨 + 글로벌 *사용 안 함* → 다중 사용자 자동 격리. +- LAW_OC 은 Space Secret 으로 환경변수 주입 (사용자 입력 X). + +로컬 미리보기: + pip install -e ".[dev,llm,hf]" + KPAA_LLM_BACKEND=llama_cpp python app.py +""" +from __future__ import annotations + +import html +import logging +from typing import Any + +from kpaa.llm import ChatMessage as LLMChatMessage +from kpaa.llm import LLMOptions +from kpaa.pipeline import generate +from kpaa.retrieval.citation_match import ( + compute_cited_with_indices, + extract_geungeo_indices, +) +from kpaa.retrieval.context_builder import DEFAULT_MAX_EXCERPTS +from kpaa.retrieval.excerpts import Excerpt + +logger = logging.getLogger("kpaa.ui.gradio") + +_SOURCE_LABEL = { + "case": "상담사례", + "guide": "안내서", + "law": "법조문", + "related_law": "관련 법령", + "related_law_static": "관련 법령(목록)", + "pipc": "PIPC 결정", + "interpretation": "법령해석례", + "precedent": "판례", + "admin_rule": "행정규칙", + "oldnew": "구·신 비교", + "constitutional": "헌법재판소", + "article_history": "조문 변천", +} + +_BADGE_COLOR = { + "case": "#0a66c2", + "guide": "#107869", # related 와 시각 분리되도록 더 짙은 teal + "law": "#15833a", + "related_law": "#0d8e8a", + "related_law_static": "#0d8e8a", + "pipc": "#ad7100", + "interpretation": "#6633bb", + "precedent": "#b03060", + "admin_rule": "#555", +} + +_EXAMPLE_QUESTIONS = [ + "매장 CCTV 안내문구는 어떻게 작성하나요?", + "직원 동의 없이 메신저 대화 모니터링 가능한가요?", + "개인정보 유출 시 통지 의무는 며칠 안에?", + "병원에서 환자 정보 보관 기간은 얼마인가요?", + "택배 송장에 휴대전화번호 마스킹 의무 있나요?", +] + + +def _cited_excerpts(answer: str, excerpts: list[Excerpt]) -> set[str]: + """[backward-compat] 답변에 인용된 excerpt citation 집합 — 공통 헬퍼 wrap.""" + from kpaa.retrieval.citation_match import compute_cited + + return set(compute_cited(answer, [e.citation for e in excerpts])) + + +def _render_references_html( + excerpts: list[Excerpt], + elapsed_ms: int, + cited_citations: set[str] | None = None, + llm_passed_citations: set[str] | None = None, + geungeo_indices: set[int] | None = None, +) -> str: + """우측 패널 HTML 카드 묶음 — server.py 분할 화면과 동일 정책. + + 표시 단계 (3-tier): + - 채택 (cited_citations): 좌측 녹색 border + 녹색 배경 + ✓ 배지 + [근거N] + - LLM 전달 (llm_passed_citations): 좌측 회색 표지 + 회색 배지 + [근거N] + - 검색만: 표시 없음 (회색 카드) + + Args: + excerpts: 검색된 전체 (ranker 정렬 순서). 1-based 위치 = (근거N) N. + elapsed_ms: 검색 시간. + cited_citations: 답변에 인용된 citation set. None 이면 강조 X. + llm_passed_citations: LLM 입력으로 전달된 상위 N건 citation set. + None 이면 그 표시 X. + geungeo_indices: 답변에 (근거N) 으로 등장한 N 들 (1-based). 카드 배지에 + [근거N] 태그 추가용. None 이면 태그 없음. + """ + if not excerpts: + return ( + '
' + "근거가 검색되지 않았습니다." + "
" + ) + cited_set = cited_citations or set() + llm_set = llm_passed_citations or set() + geungeo_set = geungeo_indices or set() + + cited_count = sum(1 for e in excerpts if (e.citation or "").strip() in cited_set) + llm_count = sum(1 for e in excerpts if (e.citation or "").strip() in llm_set) + + # 카드별 원본 LLM 입력 순서(1-based) 보존 — 정렬 후에도 [근거N] 매핑 유지. + indexed: list[tuple[int, Excerpt]] = list(enumerate(excerpts, 1)) + + # 3-tier 정렬 (stable): 채택 → LLM 후보 → 나머지. + def _tier(item: tuple[int, Excerpt]) -> int: + c = (item[1].citation or "").strip() + if c in cited_set: + return 0 + if c in llm_set: + return 1 + return 2 + + sorted_items = sorted(indexed, key=_tier) + + # 헤더 요약 + summary_parts = [f"총 {len(excerpts)}건 · 검색 {elapsed_ms}ms"] + if llm_count: + summary_parts.append( + f'LLM 전달 {llm_count}건' + ) + if cited_count: + summary_parts.append( + f'답변에 채택 {cited_count}건' + ) + summary = " · ".join(summary_parts) + parts: list[str] = [ + f'
{summary}
' + ] + + for idx, e in sorted_items: + label = _SOURCE_LABEL.get(e.source_type, e.source_type) + color = _BADGE_COLOR.get(e.source_type, "#666") + url = (e.metadata or {}).get("url", "").strip() + cit = (e.citation or "").strip() + is_cited = cit in cited_set + is_llm_passed = cit in llm_set + show_idx = idx in geungeo_set + + link_html = ( + f'' + "원문 페이지 열기 ↗" + "" + if url + else '' + "원문 페이지 미제공" + "" + ) + title = html.escape(e.title or "") + content = html.escape(e.content or "") + citation = html.escape(e.citation) + + # 짝 배지 — 답변 본문에 (근거N) 표기로 등장한 카드만 인덱스 chip 추가. + # 채택/LLM 전달 색상에 맞춰 outline 톤으로. + def _idx_chip(scheme: str) -> str: + # scheme: "cited" (녹) 또는 "llm" (회) + if scheme == "cited": + bg, fg, bd = "#e6f4ea", "#15833a", "#15833a" + else: + bg, fg, bd = "#f1f3f4", "#5f6368", "#9aa0a6" + return ( + '근거{idx}' + ) + + # 카드 스타일 + 상태 배지 (cited > llm-passed > none) + if is_cited: + card_style = ( + "background:#f3fbf5;border:1px solid #b9e3c5;border-left:4px solid #15833a;" + "border-radius:10px;padding:12px 14px;margin:8px 12px;" + ) + state_badge = ( + '✓ 답변에 채택' + ) + if show_idx: + state_badge += _idx_chip("cited") + elif is_llm_passed: + card_style = ( + "background:#fff;border:1px solid #e5e5e5;border-left:4px solid #9aa0a6;" + "border-radius:10px;padding:12px 14px;margin:8px 12px;" + ) + state_badge = ( + 'LLM 전달' + ) + if show_idx: + state_badge += _idx_chip("llm") + else: + card_style = ( + "background:#fff;border:1px solid #e5e5e5;border-radius:10px;" + "padding:12px 14px;margin:8px 12px;" + ) + state_badge = "" + + parts.append( + f"""
+
+ {label} + {citation} + {state_badge} +
+
{title}
+
{content}
+
{link_html}
+
""" + ) + return "".join(parts) + + +def _format_progress_line(stage: str, payload: dict) -> str | None: + """단계 이벤트 → 채팅창에 누적 표시할 한 줄 (Gradio messages 모드용).""" + if stage == "routing_started": + return "🔎 _질문을 분석하고 적합한 검색 도구를 선정 중…_" + if stage == "routing_done": + try: + from kpaa.retrieval.chains import chain_label + + chain = payload.get("chain") or "" + chain_ko = chain_label(chain) if chain else "(미결정)" + except Exception: + chain_ko = payload.get("chain") or "(미결정)" + intents = payload.get("intents") or [] + bits = [f"검색 전략: **{chain_ko}**"] + if intents: + bits.append(f"의도={intents}") + return "🧭 _" + " · ".join(bits) + "_" + if stage == "fetch_done": + src = payload.get("source") or "" + cnt = payload.get("count", 0) + label = _SOURCE_LABEL.get(src, src) + mark = "✓" if cnt > 0 else "·" + return f" ├ _{label} {mark} {cnt}건_" + return None + + +async def _stream_answer( + message: str, + history: list[dict[str, Any]], +): + """Gradio ChatInterface 호환 비동기 generator. + + Yields: + (chatbot_messages, references_html) 튜플. + """ + if not message or not message.strip(): + yield history, "
질문을 입력해 주세요.
" + return + + query = message.strip() + + # history 는 Gradio 가 넘겨주는 [{role, content}] 리스트. LLM 호출용으로 변환. + llm_history: list[LLMChatMessage] = [] + for m in history: + role = m.get("role") + content = (m.get("content") or "").strip() + if role in ("user", "assistant") and content: + llm_history.append(LLMChatMessage(role=role, content=content)) + + # 사용자 메시지를 즉시 표시 + 진행용 빈 어시스턴트 메시지 자리 잡기. + chatbot = list(history) + [ + {"role": "user", "content": query}, + {"role": "assistant", "content": ""}, + ] + progress_lines: list[str] = [] + answer_text = "" + refs_html = "
법령 검색 중…
" + retrieval_excerpts: list[Excerpt] = [] + retrieval_elapsed_ms = 0 + + # 즉시 한 번 yield (사용자 메시지 + 빈 답변 자리 + '검색 중' 우측 패널) + yield chatbot, refs_html + + options = LLMOptions(temperature=0.2, top_p=0.9, max_tokens=900) + + async for evt in generate( + query, + options=options, + inline_references=False, # 우측 패널이 별도 렌더하므로 본문에 inline X + history=llm_history, + ): + kind = evt["event"] + if kind == "stage": + line = _format_progress_line(evt["stage"], evt.get("payload") or {}) + if line: + progress_lines.append(line) + # 답변 시작 전엔 진행 표시만 어시스턴트 메시지에 보여줌. + if not answer_text: + chatbot[-1]["content"] = "\n".join(progress_lines) + yield chatbot, refs_html + elif kind == "retrieval": + res = evt["result"] + # excerpts 와 메타를 다음 done 단계에서 재렌더링에 사용하기 위해 보존. + retrieval_excerpts = res.excerpts + retrieval_elapsed_ms = res.elapsed_ms + # 검색 직후 — 상위 N건을 LLM 입력 후보로 표시 (회색 배지). + # 채택은 답변 완료 시점에 추가 강조. + llm_passed = { + e.citation for e in retrieval_excerpts[:DEFAULT_MAX_EXCERPTS] if e.citation + } + refs_html = _render_references_html( + retrieval_excerpts, + retrieval_elapsed_ms, + llm_passed_citations=llm_passed, + ) + progress_lines.append( + f"\n✅ 검색 완료 — 총 {len(res.excerpts)}건 ({res.elapsed_ms}ms)" + ) + progress_lines.append("💭 _답변을 준비하는 중…_") + chatbot[-1]["content"] = "\n".join(progress_lines) + yield chatbot, refs_html + elif kind == "token": + # 첫 token 이 도착하면 진행 표시를 지우고 본문으로 전환. + if not answer_text: + chatbot[-1]["content"] = "" + answer_text += evt["delta"] + chatbot[-1]["content"] = answer_text + yield chatbot, refs_html + elif kind == "done": + final_answer = evt["answer"] + chatbot[-1]["content"] = final_answer + # ★ 답변 완료 — 공통 매칭 헬퍼로 cited + (근거N) indices 추출 후 재렌더. + citations = [e.citation for e in retrieval_excerpts] + cited_list, _cited_idx = compute_cited_with_indices(final_answer, citations) + cited = set(cited_list) + geungeo = extract_geungeo_indices(final_answer) + llm_passed = { + e.citation for e in retrieval_excerpts[:DEFAULT_MAX_EXCERPTS] if e.citation + } + refs_html = _render_references_html( + retrieval_excerpts, + retrieval_elapsed_ms, + cited_citations=cited, + llm_passed_citations=llm_passed, + geungeo_indices=geungeo, + ) + yield chatbot, refs_html + + +def build_app(): + """Gradio Blocks 앱 빌드. HF Spaces app.py 에서 호출.""" + import gradio as gr + + css = """ + .references-pane { background:#f6f7f9; border-left:1px solid #e5e5e5; } + .examples-row .gr-button { font-size: 0.85em !important; } + footer { visibility: hidden; } + """ + + with gr.Blocks( + title="KPAA — 개인정보보호법 미니 상담", + css=css, + theme=gr.themes.Soft(primary_hue="blue"), + ) as app: + gr.Markdown( + """ + # ⚖️ 개인정보보호법 미니 상담 (KPAA) + + 한국 **개인정보보호법** 을 평이한 한국어로 안내합니다. + 법제처 OPEN API + 개인정보보호위원회 상담사례 1,745건 + 안내서를 + 근거로 **Gemma 4 E2B** 가 답변합니다. 모든 답변에 **인용·면책** 자동 부착. + """ + ) + + with gr.Row(): + with gr.Column(scale=3): + chatbot = gr.Chatbot( + type="messages", + height=560, + label="💬 상담", + show_copy_button=True, + show_label=False, + ) + with gr.Row(): + txt = gr.Textbox( + placeholder="예: 매장 CCTV 안내문구는 어떻게 작성하나요?", + scale=8, + show_label=False, + autofocus=True, + lines=1, + max_lines=4, + ) + send = gr.Button("보내기", scale=1, variant="primary") + gr.Examples( + examples=_EXAMPLE_QUESTIONS, + inputs=txt, + label="질문 예시", + ) + with gr.Column(scale=2, elem_classes=["references-pane"]): + gr.Markdown("### 📚 참고한 자료") + refs = gr.HTML( + value=( + "
" + "질문을 보내면 LLM 이 본 근거가 여기에 표시됩니다." + "
" + ), + ) + + gr.Markdown( + """ + --- + ※ 본 답변은 **일반적 정보 제공** 이며 법률 자문이 아닙니다. + 구체적 사안은 [개인정보보호위원회](https://www.privacy.go.kr) 또는 변호사 상담을 권합니다. + 신고: KISA 개인정보침해신고센터 국번없이 **118**. + """ + ) + + async def _on_submit(message: str, history: list[dict[str, Any]]): + async for chatbot_state, refs_html in _stream_answer(message, history): + yield chatbot_state, refs_html, "" + + # Submit: textbox enter or send button click. Both clear textbox after. + send.click( + _on_submit, + inputs=[txt, chatbot], + outputs=[chatbot, refs, txt], + queue=True, + ) + txt.submit( + _on_submit, + inputs=[txt, chatbot], + outputs=[chatbot, refs, txt], + queue=True, + ) + + return app