fix: 사례 URL을 camelCase(nttNo/nttId)로 교정 — 사례 카드 '원문 페이지 열기' 활성화
Browse filesprivacy.go.kr/front/case/view.do GET endpoint은 camelCase 파라미터(nttNo, nttId)만 인식. snake_case(ntt_id, nttno)는 default 사례로 폴백되어 모든 사례 링크가 동일한 잘못된 페이지로 이동하던 문제 수정.
- data/cases.sqlite: 1,745건 detail_url snake_case → camelCase
- src/kpaa/retrieval/retriever.py: case Excerpt metadata에 absolute_url() 채움 (이전엔 빈 문자열)
- src/kpaa/cases/scraper.py: API URL_ADDR 무시하고 camelCase로 직접 구성 (재스크래이프 시 회귀 방지)
- src/kpaa/cases/models.py: detail_url 필드 주석 업데이트
효과: 참고한 자료 패널에서 사례 카드 하단에 법조문과 동일한 '원문 페이지 열기 ↗' 링크 노출.
- data/cases.sqlite +2 -2
- src/kpaa/cases/models.py +1 -1
- src/kpaa/cases/scraper.py +11 -3
- src/kpaa/retrieval/retriever.py +8 -12
data/cases.sqlite
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:63232b3922ec028677debdaf5588869ad077ed8560ad7af74d81294d3c4a59ee
|
| 3 |
+
size 14217216
|
src/kpaa/cases/models.py
CHANGED
|
@@ -25,7 +25,7 @@ class Case(BaseModel):
|
|
| 25 |
reg_dt: str # YYYYMMDD
|
| 26 |
case_year: str # YYYY
|
| 27 |
source_note: str # ETC_CN
|
| 28 |
-
detail_url: str # /front/case/view.do?
|
| 29 |
chunk_context: str = "" # Anthropic Contextual Retrieval prefix — 인덱싱 시 body 앞에 prepend (답변 출력은 body만)
|
| 30 |
|
| 31 |
def citation(self) -> str:
|
|
|
|
| 25 |
reg_dt: str # YYYYMMDD
|
| 26 |
case_year: str # YYYY
|
| 27 |
source_note: str # ETC_CN
|
| 28 |
+
detail_url: str # /front/case/view.do?nttNo=...&nttId=... (camelCase 필수, snake_case는 default로 폴백)
|
| 29 |
chunk_context: str = "" # Anthropic Contextual Retrieval prefix — 인덱싱 시 body 앞에 prepend (답변 출력은 body만)
|
| 30 |
|
| 31 |
def citation(self) -> str:
|
src/kpaa/cases/scraper.py
CHANGED
|
@@ -72,9 +72,17 @@ def _to_case(doc: dict[str, Any]) -> Case | None:
|
|
| 72 |
body_raw = doc.get("NTT_CN_FULL") or doc.get("NTT_CN") or ""
|
| 73 |
if not body_raw:
|
| 74 |
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
return Case(
|
| 76 |
-
ntt_no=
|
| 77 |
-
ntt_id=
|
| 78 |
title=str(doc.get("NTT_SJ") or "").strip(),
|
| 79 |
summary=_decode_body(str(doc.get("NTT_CN") or "")),
|
| 80 |
body=_decode_body(str(body_raw)),
|
|
@@ -86,7 +94,7 @@ def _to_case(doc: dict[str, Any]) -> Case | None:
|
|
| 86 |
reg_dt=str(doc.get("REG_DT") or "").strip(),
|
| 87 |
case_year=str(doc.get("CASE_YEAR") or "").strip(),
|
| 88 |
source_note=str(doc.get("ETC_CN") or "").strip(),
|
| 89 |
-
detail_url=
|
| 90 |
)
|
| 91 |
|
| 92 |
|
|
|
|
| 72 |
body_raw = doc.get("NTT_CN_FULL") or doc.get("NTT_CN") or ""
|
| 73 |
if not body_raw:
|
| 74 |
return None
|
| 75 |
+
ntt_no = str(doc.get("NTT_NO") or "").strip()
|
| 76 |
+
ntt_id = str(doc.get("NTT_ID") or "").strip()
|
| 77 |
+
# API의 URL_ADDR 필드는 snake_case(ntt_id, nttno)를 주는데
|
| 78 |
+
# 서버 view.do는 camelCase(nttId, nttNo)만 인식한다 (snake_case는 default 페이지로 폴백).
|
| 79 |
+
# 직접 camelCase로 구성한다.
|
| 80 |
+
detail_url = (
|
| 81 |
+
f"/front/case/view.do?nttNo={ntt_no}&nttId={ntt_id}" if ntt_id and ntt_no else ""
|
| 82 |
+
)
|
| 83 |
return Case(
|
| 84 |
+
ntt_no=ntt_no,
|
| 85 |
+
ntt_id=ntt_id,
|
| 86 |
title=str(doc.get("NTT_SJ") or "").strip(),
|
| 87 |
summary=_decode_body(str(doc.get("NTT_CN") or "")),
|
| 88 |
body=_decode_body(str(body_raw)),
|
|
|
|
| 94 |
reg_dt=str(doc.get("REG_DT") or "").strip(),
|
| 95 |
case_year=str(doc.get("CASE_YEAR") or "").strip(),
|
| 96 |
source_note=str(doc.get("ETC_CN") or "").strip(),
|
| 97 |
+
detail_url=detail_url,
|
| 98 |
)
|
| 99 |
|
| 100 |
|
src/kpaa/retrieval/retriever.py
CHANGED
|
@@ -106,15 +106,11 @@ _PREC_BODY_LIMIT = 8000
|
|
| 106 |
|
| 107 |
|
| 108 |
def _related_laws_excerpts() -> list[Excerpt]:
|
| 109 |
-
"""data/related_laws.
|
| 110 |
|
| 111 |
`관련_법령` intent 매칭 시 retriever가 라이브 검색 대신 이 리스트를 그대로
|
| 112 |
컨텍스트에 박는다. 본 챗봇 scope는 개인정보보호법이지만, 사용자에게 *관련
|
| 113 |
법령의 존재*를 안내해 적절한 곳에서 추가 확인하도록 돕는 용도.
|
| 114 |
-
|
| 115 |
-
yaml 두 형식 모두 지원:
|
| 116 |
-
- 레거시 list (수동 작성): name/short/description/url
|
| 117 |
-
- 신규 dict (자동 갱신, privacy.go.kr 스크래이프): items/[name/kind/kind_label/url/source]
|
| 118 |
"""
|
| 119 |
from kpaa.related_laws import load_items
|
| 120 |
|
|
@@ -324,10 +320,10 @@ async def _fetch_cases(
|
|
| 324 |
"year": h.case_year,
|
| 325 |
"type": h.type_label,
|
| 326 |
"source_note": h.source_note,
|
| 327 |
-
# privacy.go.kr
|
| 328 |
-
#
|
| 329 |
-
#
|
| 330 |
-
"url": "",
|
| 331 |
},
|
| 332 |
sort_priority=0,
|
| 333 |
recency_score=_recency_score(_yyyy_mmdd_to_year(h.case_year or h.reg_dt)),
|
|
@@ -786,7 +782,7 @@ async def _fetch_admin_rules(
|
|
| 786 |
def _seed_three_tier() -> tuple[str, str | None, str | None]:
|
| 787 |
"""본법 + 시행령 + 시행규칙 MST 묶음 lookup.
|
| 788 |
|
| 789 |
-
related_laws.
|
| 790 |
(2026-05) 개인정보보호법은 시행규칙이 없어 None 으로 떨어지는 게 정상.
|
| 791 |
|
| 792 |
반환: (본법_mst, 시행령_mst | None, 시행규칙_mst | None)
|
|
@@ -799,7 +795,7 @@ def _seed_three_tier() -> tuple[str, str | None, str | None]:
|
|
| 799 |
|
| 800 |
items = _rl.load_items()
|
| 801 |
except Exception as e:
|
| 802 |
-
logger.warning("_seed_three_tier related_laws.
|
| 803 |
return primary_mst, None, None
|
| 804 |
for it in items:
|
| 805 |
name = str(it.get("name", ""))
|
|
@@ -932,7 +928,7 @@ def _primary_law_id_lock() -> asyncio.Lock:
|
|
| 932 |
async def _resolve_primary_law_id(client: KoreanLawClient) -> str | None:
|
| 933 |
"""본법의 *법령ID* 를 lazy resolve. 실패 시 None.
|
| 934 |
|
| 935 |
-
KPAA 시점(2026-05) related_laws.
|
| 936 |
별도 lookup 필요. get_text 응답의 LawText.law_id 에서 회수 후 캐시.
|
| 937 |
|
| 938 |
Resolve 전략 (순서대로 시도):
|
|
|
|
| 106 |
|
| 107 |
|
| 108 |
def _related_laws_excerpts() -> list[Excerpt]:
|
| 109 |
+
"""data/related_laws.jsonl 의 portal_corpus 라인을 Excerpt 로 변환.
|
| 110 |
|
| 111 |
`관련_법령` intent 매칭 시 retriever가 라이브 검색 대신 이 리스트를 그대로
|
| 112 |
컨텍스트에 박는다. 본 챗봇 scope는 개인정보보호법이지만, 사용자에게 *관련
|
| 113 |
법령의 존재*를 안내해 적절한 곳에서 추가 확인하도록 돕는 용도.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
"""
|
| 115 |
from kpaa.related_laws import load_items
|
| 116 |
|
|
|
|
| 320 |
"year": h.case_year,
|
| 321 |
"type": h.type_label,
|
| 322 |
"source_note": h.source_note,
|
| 323 |
+
# privacy.go.kr view.do GET은 camelCase 파라미터(nttNo, nttId)만
|
| 324 |
+
# 인식 (snake_case는 default 사례로 폴백). detail_url이 camelCase
|
| 325 |
+
# 포맷이면 absolute_url()이 정상 deep-link를 반환.
|
| 326 |
+
"url": h.absolute_url() if h.detail_url else "",
|
| 327 |
},
|
| 328 |
sort_priority=0,
|
| 329 |
recency_score=_recency_score(_yyyy_mmdd_to_year(h.case_year or h.reg_dt)),
|
|
|
|
| 782 |
def _seed_three_tier() -> tuple[str, str | None, str | None]:
|
| 783 |
"""본법 + 시행령 + 시행규칙 MST 묶음 lookup.
|
| 784 |
|
| 785 |
+
related_laws.jsonl 에서 *개인정보 보호법* 패밀리만 골라낸다. KPAA 시점
|
| 786 |
(2026-05) 개인정보보호법은 시행규칙이 없어 None 으로 떨어지는 게 정상.
|
| 787 |
|
| 788 |
반환: (본법_mst, 시행령_mst | None, 시행규칙_mst | None)
|
|
|
|
| 795 |
|
| 796 |
items = _rl.load_items()
|
| 797 |
except Exception as e:
|
| 798 |
+
logger.warning("_seed_three_tier related_laws.jsonl 로드 실패: %s", e)
|
| 799 |
return primary_mst, None, None
|
| 800 |
for it in items:
|
| 801 |
name = str(it.get("name", ""))
|
|
|
|
| 928 |
async def _resolve_primary_law_id(client: KoreanLawClient) -> str | None:
|
| 929 |
"""본법의 *법령ID* 를 lazy resolve. 실패 시 None.
|
| 930 |
|
| 931 |
+
KPAA 시점(2026-05) related_laws.jsonl 에는 mst 만 저장되어 있어 lawId 는
|
| 932 |
별도 lookup 필요. get_text 응답의 LawText.law_id 에서 회수 후 캐시.
|
| 933 |
|
| 934 |
Resolve 전략 (순서대로 시도):
|