tsKim
feat: schoolbridge spaces deploy (extract-text endpoint added)
7f105c8
"""
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"(?<![\d.])(?<!mm)(?<!cm)(?<!원)(?<!시)"
r"(\d{1,2})[./](\d{1,2})"
r"(?!\d)(?![mc]m)(?!kg)",
)
_DATE_REL = re.compile(
r"(다음\s*주\s*[월화수목금토일]요일|"
r"이번\s*주\s*[월화수목금토일]요일|"
r"매주\s*[월화수목금토일]요일|"
r"오늘|내일|모레)"
)
_DEADLINE = re.compile(r"([\w가-힣\s]+?)\s*까지")
# \d+\s*원 형태 → "원하시는"·"원인" 오탐 없음
_MONEY = re.compile(r"(\d{1,3}(?:,\d{3})+|\d+)\s*원")
_ACTION_PATTERNS: list[tuple[re.Pattern, str]] = [
(re.compile(r"납부|입금"), "납부"),
(re.compile(r"제출"), "제출"),
(re.compile(r"신청"), "신청"),
(re.compile(r"참여|참가|출석"), "참여"),
(re.compile(r"준비|지참|챙겨"), "준비"),
(re.compile(r"확인|숙지|참고"), "확인"),
]
def extract_due_date(sentence: str) -> 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)}개 후보 문장 추출")