/** * 한국 법률 인용 파서. * - 원숫자(①②③…) 항번호 파싱 (법제처 API quirk) * - 조·항·호·목 파싱 ("제15조제1항제2호가목") * - 30자 lookback 인용 추출 (verify_pipa_citation에서 사용) */ const CIRCLED_NUMBERS = "①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳"; /** * 원숫자 또는 ASCII 숫자에서 정수 추출. * 법제처 API가 항번호를 "① "로 리턴하는데 parseInt로는 NaN. */ export function parseCircledNumber(text: string): number | null { const trimmed = text.trim(); // Circled first for (const ch of trimmed) { const idx = CIRCLED_NUMBERS.indexOf(ch); if (idx >= 0) return idx + 1; } // ASCII digits const digits = trimmed.match(/\d+/); if (digits) { const n = parseInt(digits[0], 10); return Number.isFinite(n) ? n : null; } return null; } /** 조·항·호·목 구조화 표현 */ export interface JoCode { /** 조 (제15조의 15) */ jo: number; /** 조의N — 추가 분기 (제24조의2의 2) */ jo_branch?: number; /** 항 (제○항) */ hang?: number; /** 호 (제○호) */ ho?: number; /** 목 (가/나/다…) */ mok?: string; } /** "제15조제1항제2호가목" 같은 문자열을 JoCode로 파싱. 실패 시 null. */ export function parseJoCode(citation: string): JoCode | null { const cleaned = citation.replace(/\s+/g, ""); const re = /제(\d+)조(?:의(\d+))?(?:제(\d+)항)?(?:제(\d+)호)?(?:([가-힣])목)?/; const m = cleaned.match(re); if (!m || !m[1]) return null; const result: JoCode = { jo: parseInt(m[1], 10) }; if (m[2]) result.jo_branch = parseInt(m[2], 10); if (m[3]) result.hang = parseInt(m[3], 10); if (m[4]) result.ho = parseInt(m[4], 10); if (m[5]) result.mok = m[5]; return result; } /** JoCode를 정규형 문자열로 (역포맷) */ export function formatJoCode(code: JoCode): string { let s = `제${code.jo}조`; if (code.jo_branch !== undefined) s += `의${code.jo_branch}`; if (code.hang !== undefined) s += `제${code.hang}항`; if (code.ho !== undefined) s += `제${code.ho}호`; if (code.mok !== undefined) s += `${code.mok}목`; return s; } /** * 인용 문자열 정규화 — `§·①·호` 같은 약식 표기를 한국 법령 정식 표기로. * * 입력: "PIPA §15 ① 6호" / "개인정보 보호법 제15조제1항제6호" / 「○○법」 §22 등 * 출력: "개인정보 보호법 제15조제1항제6호" * * verify_pipa_citation의 입력 파싱에 사용. */ export function normalizeCitationText(input: string): string { let s = input; // 「」, 「」 괄호 제거 s = s.replace(/[「」「」]/g, ""); // 원숫자 항번호 → 제N항 const circled = "①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳"; for (let i = 0; i < circled.length; i++) { s = s.split(circled[i]!).join(`제${i + 1}항`); } // §15 → 제15조 (의2 분기 포함: §15의2 → 제15조의2) s = s.replace(/§\s*(\d+)(?:의\s*(\d+))?/g, (_m, n, br) => br ? `제${n}조의${br}` : `제${n}조` ); // "1호", "6호" → 제1호 (이미 제N호이면 두 번 변환 안 되도록 lookbehind) s = s.replace(/(? best.endIndex || (endIndex === best.endIndex && name.length > best.length) ) { best = { name, endIndex, length: name.length }; } } if (best) return best.name; // 일반 패턴 fallback: "○○법", "○○령", "○○규칙", "○○고시" 등 const generic = lookback.match( /([가-힣A-Za-z·\s]+(?:법(?:률)?|령|규칙|고시|예규|훈령|지침))(?:[\s,(]|$)/ ); if (generic && generic[1]) return generic[1].trim(); return null; }