""" model/extraction/file/predict.py ============================ A단계: 가정통신문 → 할 일 및 중요 일정 후보 문장 추출기 담당: 윤정 역할: 원문 텍스트에서 학부모가 인지·행동해야 할 문장만 이진 분류로 추출. 카테고리 분류·중요도 계산은 B단계(경이 모델)로 위임. 출력: list[dict] — B단계 입력 스키마와 1:1 대응 ───────────────────────────────────────── 입력 ───────────────────────────────────────── OCR 모듈(pdfplumber / pymupdf 등)이 추출한 가정통신문 텍스트 (str). OCR은 A단계 이전에 처리되므로 이 스크립트는 항상 순수 str 을 입력받습니다. ───────────────────────────────────────── 파이프라인 ───────────────────────────────────────── OCR 추출 텍스트 (str) ↓ [0] extract_title() 원본 줄에서 제목 감지 (heuristic) → split_sentences() 이전에 실행해야 함 (_HEADER_ONLY 필터가 제목 줄을 걸러내므로) ↓ [1] split_sentences() 줄글 → 문장 리스트 (OCR 아티팩트 줄 조기 차단 포함) ↓ [2] is_likely_todo() ① 정규식 빠른 제외 ② KoELECTRA 이진 분류 0: 노이즈 1: 할 일·중요 일정 ↓ [3] extract_due_date() 정규식: 날짜·마감 추출 has_money() 정규식: 금액 여부 추출 ↓ predict() → list[dict] {"text": str, "source": str|None, "due_date": str|None, "amount": int|None, "confidence": float, "action_hint": str|None} 제목 추출: extract_title(notice_text) → str | None (predict()와 별도 호출) ───────────────────────────────────────── """ import datetime import os import re import warnings from typing import Optional import torch warnings.filterwarnings("ignore", category=UserWarning, module="torch") from transformers import AutoTokenizer, AutoModelForSequenceClassification # ───────────────────────────────────────── # 0. 제목 감지 heuristic # ───────────────────────────────────────── _TITLE_ENDING_RE = re.compile(r"(안내|공지|알림|통보|조사|신청|수납|모집)\s*(제\s*\d{4,}-\d+호)?$") _TITLE_MAX_LEN = 80 _TITLE_SENT_END_RE = re.compile(r"[.!?。?!]") _TITLE_SECTION_RE = re.compile(r"^[\d가나다라마바사아자차카타파하][.)]\s") # koelectra-title 체크포인트 경로 — 없으면 heuristic fallback _TITLE_CHECKPOINT_DIR = os.path.join( os.path.dirname(__file__), "..", "checkpoints", "koelectra-title" ) TITLE_THRESHOLD = 0.4 # 임계값: train_koelectra_title.ipynb 임계값 분석 결과로 조정 _title_tokenizer: Optional[AutoTokenizer] = None _title_model: Optional[AutoModelForSequenceClassification] = None def _load_title_model() -> bool: """koelectra-title 체크포인트가 있으면 로드하고 True 반환. 없으면 False.""" global _title_tokenizer, _title_model if _title_model is not None: return True local_ready = ( any( os.path.exists(os.path.join(_TITLE_CHECKPOINT_DIR, f)) for f in ("pytorch_model.bin", "model.safetensors") ) and os.path.exists(os.path.join(_TITLE_CHECKPOINT_DIR, "config.json")) ) if not local_ready: return False _title_tokenizer = AutoTokenizer.from_pretrained(_TITLE_CHECKPOINT_DIR) _title_model = AutoModelForSequenceClassification.from_pretrained( _TITLE_CHECKPOINT_DIR, num_labels=2 ) _title_model.to(_device) _title_model.eval() return True def is_title_heuristic(text: str) -> bool: """rule 기반 제목 감지. 조건 (모두 만족해야 True): - 10자 이상, 80자 이하 - 문장 부호(. ! ?) 없음 - 항목 번호(1. 가. 등)로 시작하지 않음 - '안내/공지/알림/통보/조사/신청/수납/모집'으로 끝남 """ if not (10 <= len(text) <= _TITLE_MAX_LEN): return False if _TITLE_SENT_END_RE.search(text): return False if _TITLE_SECTION_RE.match(text): return False return bool(_TITLE_ENDING_RE.search(text)) def extract_title(notice_text: str) -> Optional[str]: """가정통신문에서 제목 문장을 추출. 없으면 None. - koelectra-title 체크포인트가 있으면 ML 모델로 전체 줄 스코어링 → 최고점 반환 - 없으면 heuristic(is_title_heuristic) fallback → 첫 번째 통과 줄 반환 split_sentences()의 _HEADER_ONLY 필터가 제목 줄을 차단하므로 반드시 predict() 와 별도로, 원문에 대해 호출할 것. 사용 예: title = extract_title(notice_text) items = predict(notice_text, source=filename) """ lines = [line.strip() for line in notice_text.splitlines() if line.strip()] if _load_title_model(): best_line: Optional[str] = None best_prob = TITLE_THRESHOLD for line in lines: if len(line) < 10: continue inputs = _title_tokenizer( line, return_tensors="pt", truncation=True, max_length=128 ).to(_device) with torch.no_grad(): prob = float(torch.softmax(_title_model(**inputs).logits, dim=-1)[0][1].item()) if prob > best_prob: best_prob = prob best_line = line return best_line # heuristic fallback for line in lines: if is_title_heuristic(line): return line return None # ───────────────────────────────────────── # 1. 모델 로드 (lazy, 최초 1회) # ───────────────────────────────────────── # CPU 시연 환경을 위해 small 변형 사용 _BASE_MODEL_ID = "yunjeong116/koelectra-extractor" # HF Hub 파인튜닝 모델 # HF Hub repo 내 모델 파일이 koelectra-extractor/ 서브폴더에 위치 _HF_SUBFOLDER = "koelectra-extractor" _LOCAL_CHECKPOINT_DIR = os.path.join( os.path.dirname(__file__), "..", "checkpoints", "koelectra-binary" ) # file/../checkpoints = extraction/checkpoints (이전: file/checkpoints — 경로 오류 수정) # label-1 (할 일) 확률 임계값 — v2 평가(2026-04-30) 최적값 0.65로 업데이트 BINARY_THRESHOLD = 0.65 _tokenizer: Optional[AutoTokenizer] = None _model: Optional[AutoModelForSequenceClassification] = None _device = "cuda" if torch.cuda.is_available() else "cpu" def _load_model() -> None: global _tokenizer, _model if _model is not None: return # 로컬 파인튜닝 체크포인트 우선, 없으면 HF Hub 모델 # weights + config 둘 다 있어야 로컬 사용 (config 없으면 로드 실패) _local_ready = ( any( os.path.exists(os.path.join(_LOCAL_CHECKPOINT_DIR, fname)) for fname in ("pytorch_model.bin", "model.safetensors") ) and os.path.exists(os.path.join(_LOCAL_CHECKPOINT_DIR, "config.json")) ) if _local_ready: _tokenizer = AutoTokenizer.from_pretrained(_LOCAL_CHECKPOINT_DIR) _model = AutoModelForSequenceClassification.from_pretrained( _LOCAL_CHECKPOINT_DIR, num_labels=2 ) else: # HF Hub: 파일이 koelectra-extractor/ 서브폴더에 위치 _tokenizer = AutoTokenizer.from_pretrained( _BASE_MODEL_ID, subfolder=_HF_SUBFOLDER ) _model = AutoModelForSequenceClassification.from_pretrained( _BASE_MODEL_ID, num_labels=2, subfolder=_HF_SUBFOLDER ) _model.to(_device) _model.eval() # ───────────────────────────────────────── # 2. PDF 줄 끊김 복원 # ───────────────────────────────────────── def _join_broken_lines(text: str) -> str: """PDF 추출 시 발생하는 단어 중간 줄 끊김 복원. "다문화가정 학\\n생" → "다문화가정 학생" "지원하기\\n위해" → "지원하기 위해" """ text = re.sub(r" ([가-힣])\n([가-힣])", r" \1\2", text) # 단어 중간 끊김 text = re.sub(r"([^.!?\n])\n([^\n])", r"\1 \2", text) # 문장 이어짐 text = re.sub(r"[ \t]+", " ", text) return text.strip() # ───────────────────────────────────────── # 2-1. 특수기호 정제 (문장 단위) # ───────────────────────────────────────── # split_sentences()가 ◆●▪○ 등을 분리 기준으로 사용하므로 # 마커 제거는 split 이후 문장 단위로 수행 — 학습 데이터 clean_text()와 동일 정제 _SYMBOL_PATTERN = re.compile(r"[▪▫▸▹◆◇●○◎□■★☆※◁▷△▽→←↑↓·•…❏‧∙∘․]+") _CIRCLE_NUM_PATTERN = re.compile(r"[①②③④⑤⑥⑦⑧⑨⑩➊➋➌➍➎➏]") def _clean_symbols(sentence: str) -> str: sentence = _SYMBOL_PATTERN.sub(" ", sentence) sentence = _CIRCLE_NUM_PATTERN.sub("", sentence) return re.sub(r"\s+", " ", sentence).strip() # ───────────────────────────────────────── # 3. 문장 분리 # ───────────────────────────────────────── _HEADER_ONLY = re.compile( r"^[^.,!?~]{2,40}(안내|공지|알림|공개수업|상담|학습|행사|일정)\s*$" ) # OCR 출력에서 줄 단위로 나타나는 노이즈 패턴 (문장이 될 수 없는 줄) # 모든 패턴은 줄 시작(^) anchor — URL·전화·시간이 본문 안에 섞인 줄은 통과시킴 _OCR_LINE_NOISE = re.compile( r"^https?://" # URL 전용 줄 r"|^www\." # 도메인 전용 줄 r"|^☎\s*\d" # 전화번호 전용 줄 r"|^\d{1,2}:\d{2}\s*[~\-–]\s*\d{1,2}:\d{2}\s*$" # 시간 범위만 있는 줄 r"|^[→←↑↓]+\s*$" # 화살표 전용 줄 ) def split_sentences(text: str) -> list[str]: """OCR 추출 텍스트를 문장 리스트로 분리. 줄 단위 노이즈는 이 단계에서 차단.""" lines = [line.strip() for line in text.splitlines() if line.strip()] sentences: list[str] = [] for line in lines: if _HEADER_ONLY.match(line) or _OCR_LINE_NOISE.search(line): continue # 제목성 줄·OCR 아티팩트 줄 조기 차단 parts = re.split( r"(?<=[.!?])\s+|" r"(?<=다\.)\s+|(?<=요\.)\s+|(?<=니다\.)\s+|" r"(?<=까\?)\s+|(?<=요\?)\s+|" r"\s+(?=\d+[.)]\s)|\s+(?=[가-힣]\.\s)|" r"\s+(?=[❏○◆●▪◎□■])|" # 리스트 마커 앞 분리 r"\s+(?=운영시간|운영방법|신청방법|신청기간|" r"준비물|제출|기타\s*안내|접수방법|참가방법)", # 반복 레이블 앞 분리 line, ) sentences.extend(parts) return [s.strip() for s in sentences if s.strip() and len(s.strip()) > 3] # ───────────────────────────────────────── # 3. 이진 분류 필터 (정규식 1차 → KoELECTRA 2차) # ───────────────────────────────────────── _NON_TODO_PATTERNS: list[str] = [ r"^학부모님\s*안녕하십니까", r"^안녕하십니까", r"^학부모님\s*안녕하세요", # Bug 1 수정 r"^안녕하세요", # Bug 1 수정 r"^.*님\s*안녕하(세요|십니까)", # Bug 1 수정 (일반화) r"^학부모님께\s*안내드립니다", r"^학부모님께\s*드립니다", r"안내드립니다\s*\.?\s*$", r"드립니다\s*\.?\s*$", r"^[^.,!?]{1,30}\s*안내\s*$", r"서울갈산초등학교장$", r"교장$", r"^\d{4}\.\s*\d{1,2}\.\s*\d{1,2}\.?\s*$", r"담당\s*[::]", r"^\(.\s*\d{4}-\d{4}", r"^08\d{3}\s*서울특별시", r"공익제보센터", r"자살예방상담", r"청소년상담", ] def _classify(sentence: str) -> Optional[float]: """ ① 정규식으로 명백한 노이즈 제외 → None 반환. ② 통과 시 KoELECTRA label-1(할 일) 확률 반환 (0.0~1.0). """ if len(sentence) < 7: return None for pat in _NON_TODO_PATTERNS: if re.search(pat, sentence): return None _load_model() inputs = _tokenizer( sentence, return_tensors="pt", truncation=True, padding=True, max_length=128, ).to(_device) with torch.no_grad(): logits = _model(**inputs).logits return float(torch.softmax(logits, dim=-1)[0][1].item()) def is_likely_todo(sentence: str) -> bool: """외부 호환용 래퍼. 임계값 이상이면 True.""" prob = _classify(sentence) return prob is not None and prob >= BINARY_THRESHOLD # ───────────────────────────────────────── # 4. 정규식 기반 구조 추출 # ───────────────────────────────────────── _DATE_ABS = re.compile( r"(?:(\d{1,2})\s*월\s*(\d{1,2})\s*일)|" r"(? Optional[str]: """문장에서 마감일/일정 날짜 추출. 없으면 None.""" m = _DATE_ABS.search(sentence) if m: month = m.group(1) or m.group(3) day = m.group(2) or m.group(4) if month and day: try: return f"{datetime.date.today().year}-{int(month):02d}-{int(day):02d}" except ValueError: pass m = _DATE_REL.search(sentence) if m: return m.group(1).strip() m = _DEADLINE.search(sentence) if m: deadline_text = m.group(1).strip() if deadline_text and len(deadline_text) < 20: return deadline_text return None def extract_amount(sentence: str) -> Optional[int]: """문장에서 첫 번째 금액(원) 추출. 없으면 None.""" m = _MONEY.search(sentence) if not m: return None try: return int(m.group(1).replace(",", "")) except ValueError: return None def extract_action_hint(sentence: str) -> Optional[str]: """납부·제출·신청 등 행동 키워드 힌트 추출. 없으면 None.""" for pattern, hint in _ACTION_PATTERNS: if pattern.search(sentence): return hint return None # ───────────────────────────────────────── # 5. 메인 함수 # ───────────────────────────────────────── def predict(notice_text: str, source: Optional[str] = None) -> list[dict]: """ 가정통신문 원문 → 할 일 및 중요 일정 후보 문장 리스트. Args: notice_text: OCR 모듈이 추출한 가정통신문 텍스트 str. source: 원본 파일명 등 출처 식별자 (선택). Returns: B단계(경이 모델) 입력 스키마: [{"text", "source", "due_date", "amount", "confidence", "action_hint"}, ...] """ if not notice_text or not notice_text.strip(): return [] notice_text = _join_broken_lines(notice_text) results: list[dict] = [] for sentence in split_sentences(notice_text): sentence = _clean_symbols(sentence) if not sentence: continue confidence = _classify(sentence) if confidence is None or confidence < BINARY_THRESHOLD: continue results.append({ "text": sentence, "source": source, "due_date": extract_due_date(sentence), "amount": extract_amount(sentence), "confidence": round(confidence, 4), "action_hint": extract_action_hint(sentence), }) return results # ───────────────────────────────────────── # 6. 직접 실행 테스트 # ───────────────────────────────────────── if __name__ == "__main__": # 실제 pdfplumber OCR 출력 형식 (sample_pdfplumber.txt 발췌) sample = """다문화 학부모님 2023.3.6.(월) 다문화가정 학생(학부모)을 위한 온라인 한국어학습 신청 안내 안녕하세요? 사랑합니다. 의정부교육지원청에서 다문화가정 학생의 학습 격차 해소와 학부모님의 한국 생활 조기 정착을 지원하기 위해 온라인에서 한국어를 학습할 수 있는 프로그램을 무료로 제공합니다. 모국어를 사용하는 강사가 단계별로 친절히 가르치는 동영상 강의(VOD)를 PC나 모바일 기기로 접속하여 언제든지 원하는 장소에서 편리하게 학습할 수 있는 좋은 기회이오니, 한국어 학습이 필요한 다문화가정 학 생 및 학부모(보호자) 모두 기한 내 신청하여 주시기 바랍니다. 2. 신청 기간: 2023. 3. 6. (월) ~ 3. 17. (금) 정기 모집 기간 신청 시 3.20.(월)까지 승인 5. 신청 관련 문의: ☎031-627-7916 (한컴지니케이/살랑코리아) * 자세한 사항은 3월 20일에 학습자들에게 E-mail로 개별 공지 예정입니다. http://bit.ly/sarlang www.sarlang.com 의정부신곡초등학교장""" # print("=" * 60) # print("A단계 추출 결과 — OCR 텍스트 입력 (B단계 입력용)") # print("=" * 60) # candidates = predict(sample, source="sample_pdfplumber.txt") # for i, item in enumerate(candidates, 1): # print(f"\n{i}. {item['text']}") # print(f" source : {item['source']}") # print(f" due_date : {item['due_date']}") # print(f" amount : {item['amount']}") # print(f" confidence : {item['confidence']}") # print(f" action_hint: {item['action_hint']}") # print(f"\n총 {len(candidates)}개 후보 문장 추출")