cert-study-app / pdf_parser_adaptive.py
github-actions
Sync from GitHub d2682fe6d3fcffe93aa302c286320962009f6436
9381502
Raw
History Blame Contribute Delete
40.7 kB
# ==============================================
# pdf_parser_adaptive.py (v6.3 CPU-Stable + SmartSkip)
# OCR + LLM ๊ธฐ๋ฐ˜ Adaptive PDF Parser
# โœ… ๊ธฐ์กด OCR/์ด๋ฏธ์ง€ ๊ฐ์ง€ โ†’ OCR ๋‹จ๊ณ„ ์ž๋™ ์Šคํ‚ต
# โœ… LLM ํŒŒ์‹ฑ๋งŒ ์žฌ์‹คํ–‰ ๊ฐ€๋Šฅ
# Compatible with app_v2025 + models_v2025 + llm_parse_v3.1
# ==============================================
import hashlib
import os, re, json, time, traceback
from pathlib import Path
from typing import Dict, List, Tuple, Optional
from dataclasses import dataclass
from concurrent.futures import ThreadPoolExecutor, as_completed
import torch
from pdf2image import convert_from_path
from pdf2image.exceptions import PDFInfoNotInstalledError, PDFPageCountError
from paddleocr import PaddleOCR
from transformers import AutoTokenizer, AutoModelForCausalLM, pipeline
from langchain_huggingface import HuggingFacePipeline
from PIL import Image, ImageOps, ImageEnhance, ImageFilter
# LangChain parser
from cert_study_app.chains.question_parser_chain import parse_question_with_chain
from cert_study_app.services.text_cleanup_service import clean_question_text, is_noise_line
# ----------------------------------------------
# ๊ฒฝ๋กœ ์„ค์ •
# ----------------------------------------------
DATA_DIR = Path("data")
IMAGE_DIR = DATA_DIR / "images"
QUESTION_IMAGE_DIR = DATA_DIR / "question_images"
OCR_LOG_DIR = DATA_DIR / "ocr_logs"
RUN_LOG_DIR = DATA_DIR / "run_logs"
for p in [IMAGE_DIR, QUESTION_IMAGE_DIR, OCR_LOG_DIR, RUN_LOG_DIR]:
p.mkdir(parents=True, exist_ok=True)
def _log(level, msg):
print(f"[{level}] {msg}")
def _emit(callback, **payload):
if callback:
callback(payload)
def _job_name(pdf_path: str) -> str:
stem = Path(pdf_path).stem
safe = re.sub(r"[^0-9A-Za-z๊ฐ€-ํžฃ._-]+", "_", stem).strip("._-") or "pdf"
digest = hashlib.sha1(str(Path(pdf_path).resolve()).encode("utf-8")).hexdigest()[:8]
return f"{safe}_{digest}"
def _item_key(page: int, text: str) -> str:
return hashlib.sha1(f"{page}:{text[:600]}".encode("utf-8")).hexdigest()
def _page_images(directory: Path) -> List[Path]:
paths = sorted(directory.glob("page_*.jpg")) + sorted(directory.glob("page_*.png"))
return [p for p in paths if not p.stem.endswith(("_light", "_heavy"))]
# ----------------------------------------------
# Config
# ----------------------------------------------
@dataclass
class ParserConfig:
pdf_path: str
output_json: str
use_llm: bool = True
lang: str = "korean"
dpi: int = 200
cpu_threads: int = max(1, (os.cpu_count() or 4) // 4)
llm_provider: str = os.getenv("CERT_STUDY_LLM_PROVIDER", "ollama")
llm_model: str = "Qwen/Qwen2.5-1.8B"
ollama_model: str = os.getenv("OLLAMA_MODEL", "qwen2.5:14b")
ollama_base_url: str = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
max_new_tokens: int = 192
ocr_workers: int = 1
job_name: str = ""
image_dir: Path = IMAGE_DIR
question_image_dir: Path = QUESTION_IMAGE_DIR
ocr_log_dir: Path = OCR_LOG_DIR
partial_jsonl: Path = RUN_LOG_DIR / "parsed.partial.jsonl"
@dataclass
class PdfTextLine:
page: int
text: str
bbox: Tuple[float, float, float, float]
@dataclass
class QuestionRange:
number: int
start: int
end: int
# ----------------------------------------------
# OCR ์ƒ์„ฑ๊ธฐ
# ----------------------------------------------
def create_ocr(lang: str, cpu_threads: int) -> PaddleOCR:
return PaddleOCR(
use_angle_cls=True,
lang=lang,
rec_char_type="korean_english",
rec_algorithm="SVTR_LCNet",
det_limit_side_len=1280,
det_db_box_thresh=0.3,
use_gpu=False,
enable_mkldnn=False,
cpu_threads=cpu_threads
)
# ----------------------------------------------
# ์ „์ฒ˜๋ฆฌ
# ----------------------------------------------
def preprocess_light(img_path: Path) -> Path:
img = Image.open(img_path)
img = ImageOps.exif_transpose(img).convert("L")
img = ImageEnhance.Contrast(img).enhance(1.8)
img = ImageEnhance.Brightness(img).enhance(1.1)
img = img.filter(ImageFilter.MedianFilter(size=3))
out_path = img_path.with_name(img_path.stem + "_light.jpg")
img.save(out_path, "JPEG", quality=90)
return out_path
def preprocess_heavy(img_path: Path) -> Path:
img = Image.open(img_path)
img = ImageOps.exif_transpose(img).convert("L")
w, h = img.size
img = img.resize((int(w * 1.2), int(h * 1.2)))
img = ImageEnhance.Contrast(img).enhance(2.0)
img = ImageEnhance.Sharpness(img).enhance(1.7)
img = img.filter(ImageFilter.GaussianBlur(radius=0.7))
out_path = img_path.with_name(img_path.stem + "_heavy.jpg")
img.save(out_path, "JPEG", quality=90)
return out_path
def ocr_quality_score(text: str) -> float:
if not text.strip():
return 0.0
valid = re.findall(r"[๊ฐ€-ํžฃA-Za-z0-9]", text)
ratio = len(valid) / max(len(text), 1)
lines = [ln for ln in text.splitlines() if ln.strip()]
avg_len = sum(len(l) for l in lines) / max(1, len(lines))
return min(1.0, ratio * (avg_len / 10 + 0.5))
def clean_text(t: str) -> str:
t = re.sub(r"[\r\t]+", " ", t)
t = re.sub(r"[ \u00A0]+", " ", t)
t = re.sub(r"[ ]{2,}", " ", t)
return t.strip()
def clean_pdf_line(text: str) -> str:
text = clean_text(text)
if not text:
return ""
if is_noise_line(text):
return ""
if "The safer" in text and "IT exams" in text:
return ""
if re.match(r"^\d+\s*/\s*\d+$", text):
return ""
return text
def extract_pdf_text_lines(pdf_path: str) -> List[PdfTextLine]:
try:
import fitz
except Exception:
return []
lines: List[PdfTextLine] = []
try:
with fitz.open(pdf_path) as doc:
for page_index, page in enumerate(doc, 1):
data = page.get_text("dict")
for block in data.get("blocks", []):
if block.get("type") != 0:
continue
for line in block.get("lines", []):
text = "".join(span.get("text", "") for span in line.get("spans", []))
text = clean_pdf_line(text)
if text:
lines.append(
PdfTextLine(
page=page_index,
text=text,
bbox=tuple(line.get("bbox", (0, 0, 0, 0))),
)
)
except Exception as exc:
_log("WARN", f"PDF ํ…์ŠคํŠธ ์ถ”์ถœ ์‹คํŒจ: {exc}")
return []
return lines
def _question_start_number(text: str) -> Optional[int]:
match = re.match(r"^\s*(\d{1,3})\s*[.)]\s*\S+", text)
if not match:
return None
return int(match.group(1))
def split_pdf_lines_to_ranges(lines: List[PdfTextLine]) -> List[QuestionRange]:
candidates = []
for index, line in enumerate(lines):
number = _question_start_number(line.text)
if number is None:
continue
candidates.append((index, number))
selected = []
expected = 1
for index, number in candidates:
if number == expected:
selected.append((index, number))
expected += 1
ranges: List[QuestionRange] = []
for pos, (start, number) in enumerate(selected):
end = selected[pos + 1][0] if pos + 1 < len(selected) else len(lines)
chunk = "\n".join(line.text for line in lines[start:end])
if "Answer:" not in chunk and "์ •๋‹ต" not in chunk and len(chunk) < 80:
continue
ranges.append(QuestionRange(number=number, start=start, end=end))
return ranges
def _split_explanation(text: str) -> Tuple[str, str]:
match = re.search(r"(?im)^\s*Explanation\s*:\s*", text)
if not match:
match = re.search(r"(?im)^\s*ํ•ด์„ค\s*:\s*", text)
if not match:
return text.strip(), ""
return text[: match.start()].strip(), text[match.end() :].strip()
def _extract_answer(text: str) -> Tuple[str, str]:
match = re.search(r"(?im)^\s*(?:Answer|์ •๋‹ต)\s*:\s*(.*?)\s*$", text)
if not match:
return "", text
explanation_match = re.search(r"(?im)^\s*(?:Explanation|ํ•ด์„ค)\s*:\s*", text[match.end() :])
if explanation_match:
answer_end = match.end() + explanation_match.start()
else:
answer_end = len(text)
answer = (match.group(1) + "\n" + text[match.end() : answer_end]).strip()
answer = re.sub(r"\n+", " ", answer)
answer = re.sub(r"\s+", " ", answer).strip()
without = (text[: match.start()] + "\n" + text[answer_end:]).strip()
return answer, without
def _extract_options(text: str) -> Tuple[str, List[str]]:
option_matches = list(re.finditer(r"(?m)^\s*([A-H])\.\s*(.+)$", text))
if len(option_matches) < 2:
return text.strip(), []
options = []
for pos, match in enumerate(option_matches):
end = option_matches[pos + 1].start() if pos + 1 < len(option_matches) else len(text)
body = text[match.start() : end].strip()
body = re.sub(r"\n+", " ", body)
body = re.sub(r"\s+\d+\s*[.)]\s*[ใ€Œใ€Ž].*$", "", body)
body = re.sub(r"\s+", " ", body).strip()
options.append(body)
stem = text[: option_matches[0].start()].strip()
return stem, options
def _looks_like_multi_select(text: str, answer: str, options: List[str]) -> bool:
if len(options) < 2:
return False
answer_labels = re.findall(r"\b[A-H]\b", answer or "")
compact_answer = re.sub(r"[^A-H]", "", (answer or "").upper())
has_multiple_answers = len(set(answer_labels)) > 1 or len(set(compact_answer)) > 1
if has_multiple_answers:
return True
return bool(
re.search(
r"(์–ด๋–ค\s*(๋‘|์„ธ|๋„ค)\s*(๊ฐ€์ง€|๊ฐœ)|๋‘\s*๊ฐ€์ง€\s*์˜ต์…˜|์„ธ\s*๊ฐ€์ง€\s*์ž‘์—…|๊ฐ\s*์ •๋‹ต|๊ฐ\s*์˜ฌ๋ฐ”๋ฅธ\s*์„ ํƒ|choose\s+two|choose\s+three|select\s+two|select\s+three)",
text or "",
re.I,
)
)
def _detect_structured_type(text: str, options: List[str], answer: str = "") -> str:
lowered = text.lower()
if "ํ•ซ์ŠคํŒŸ" in text or "hotspot" in lowered or "๋‹ต๋ณ€ ์˜์—ญ" in text:
return "hotspot"
if "๋Œ์–ด" in text or "drag" in lowered or "drop" in lowered:
return "matching"
if "์ˆœ์„œ" in text or "์ •๋ ฌ" in text or "๋ฐฐ์น˜" in text:
return "ordering"
if "๋‹ค์Œ ๊ฐ ์ง„์ˆ " in text or "๊ฐ ์ง„์ˆ " in text:
return "yes_no"
if "์˜ˆ" in text and "์•„๋‹ˆ์˜ค" in text:
return "yes_no"
if "ํ‘œ" in text and ("์„ ํƒ" in text or "๊ฐ ํ–‰" in text):
return "table_choice"
if _looks_like_multi_select(text, answer, options):
return "multi_select"
if len(options) >= 2 and any(marker in text for marker in ["๊ฐœ์š”", "๊ธฐ์กด ํ™˜๊ฒฝ", "์š”๊ตฌ ์‚ฌํ•ญ", "๊ณ„ํš๋œ ๋ณ€๊ฒฝ", "์‹œ๋‚˜๋ฆฌ์˜ค"]):
return "case_study"
if len(options) >= 2:
return "mcq"
return "unparsed"
def _group_marker(text: str) -> Optional[Tuple[int, int]]:
match = re.search(r"(\d{1,3})\s*[~๏ฝž-]\s*(\d{1,3})\s*๋ฒˆ\s*๋ฌธ์ œ", text)
if not match:
match = re.search(r"\(\s*(\d{1,3})\s*[~๏ฝž-]\s*(\d{1,3})\s*\)", text)
if not match:
return None
return int(match.group(1)), int(match.group(2))
def _topic_intro_number(text: str) -> Optional[int]:
match = re.match(r"^\s*(\d{1,3})\s*[.)]\s*์ฃผ์ œ\s*\d+", text)
if not match:
return None
return int(match.group(1))
def _split_parent_and_child(stem: str) -> Tuple[Optional[str], str]:
lines = [line.strip() for line in stem.splitlines() if line.strip()]
if len(lines) < 4:
return None, stem.strip()
marker_index = None
for index, line in enumerate(lines):
if _group_marker(line):
marker_index = index
break
if marker_index is None:
return None, stem.strip()
child_start = max(marker_index + 1, len(lines) - 3)
parent = _clean_parent_stem("\n".join(lines[:child_start]))
child = "\n".join(lines[child_start:]).strip()
return parent, child or stem.strip()
def _split_topic_parent_and_child(stem: str) -> Tuple[Optional[str], str]:
lines = [line.strip() for line in stem.splitlines() if line.strip()]
if len(lines) < 8 or not _topic_intro_number(lines[0]):
return None, stem.strip()
cue_indexes = [
index
for index, line in enumerate(lines)
if any(cue in line for cue in ["ํ•ซ์ŠคํŒŸ", "๋“œ๋ž˜๊ทธ ์•ค ๋“œ๋กญ", "๋“œ๋ž˜๊ทธ ๋“œ๋กญ", "๋Œ์–ด ๋†“๊ธฐ"])
]
cue_indexes = [index for index in cue_indexes if index > 2]
if not cue_indexes:
return None, stem.strip()
child_start = cue_indexes[-1]
parent = _clean_parent_stem("\n".join(lines[:child_start]))
child = "\n".join(lines[child_start:]).strip()
return parent, child or stem.strip()
def _clean_parent_stem(text: str) -> str:
cleaned = []
for raw_line in (text or "").splitlines():
line = raw_line.strip()
if not line:
continue
topic_match = re.match(r"^\s*\d{1,3}\s*[.)]\s*(์ฃผ์ œ\s+\d+\s*,?\s*.+)$", line)
if topic_match:
line = topic_match.group(1).strip()
if re.search(r"\d{1,3}\s*[~๏ฝž-]\s*\d{1,3}\s*๋ฒˆ\s*๋ฌธ์ œ\)?", line):
continue
if re.fullmatch(r"\(?\s*\d{1,3}\s*[~๏ฝž-]\s*\d{1,3}\s*\)?", line):
continue
cleaned.append(line)
return "\n".join(cleaned).strip()
def _nonempty_line_count(text: Optional[str]) -> int:
return len([line for line in (text or "").splitlines() if line.strip()])
def _page_image_paths_for_lines(cfg: ParserConfig, lines: List[PdfTextLine], start: int, end: int) -> List[str]:
pages = []
for line in lines[start:end]:
if line.page not in pages:
pages.append(line.page)
paths = []
for page in pages:
path = cfg.image_dir / f"page_{page}.jpg"
if path.exists():
paths.append(path.as_posix())
return paths
def crop_pdf_question_image(cfg: ParserConfig, lines: List[PdfTextLine], qrange: QuestionRange) -> Optional[str]:
cfg.question_image_dir.mkdir(parents=True, exist_ok=True)
start_line = lines[qrange.start]
answer_start = None
for line_index in range(qrange.start, qrange.end):
if re.match(r"(?i)^\s*(Answer|Explanation|Reference)\s*:", lines[line_index].text) or re.match(r"^\s*(์ •๋‹ต|ํ•ด์„ค)\s*:", lines[line_index].text):
answer_start = line_index
break
answer_line = lines[answer_start] if answer_start is not None else None
crop_end = answer_start + 1 if answer_start is not None else qrange.end
answer_lines = [
lines[line_index]
for line_index in range(qrange.start, crop_end)
if re.match(r"(?i)^\s*Answer\s*:", lines[line_index].text) or re.match(r"^\s*์ •๋‹ต\s*:", lines[line_index].text)
]
end_line = lines[crop_end - 1] if crop_end - 1 < len(lines) else start_line
pages = []
for line in lines[qrange.start : crop_end]:
if line.page not in pages:
pages.append(line.page)
if not pages:
pages = [start_line.page]
crops = []
page_count = len(pages)
scale = cfg.dpi / 72
for page in pages:
page_image = cfg.image_dir / f"page_{page}.jpg"
if not page_image.exists():
continue
with Image.open(page_image) as img:
width, height = img.size
if page == start_line.page:
top = max(0, int(start_line.bbox[1] * scale) - 24)
else:
top = 0
if answer_line and page == answer_line.page:
bottom = max(top + 80, min(height, int(answer_line.bbox[1] * scale) - 12))
elif page == end_line.page:
bottom = min(height, int(end_line.bbox[3] * scale) + 56)
else:
bottom = height
if bottom <= top + 40:
bottom = min(height, top + 240)
crop = img.crop((0, top, width, bottom)).convert("RGB")
page_answer_lines = [line for line in answer_lines if line.page == page]
if page_answer_lines:
from PIL import ImageDraw
draw = ImageDraw.Draw(crop)
for answer_line in page_answer_lines:
mask_top = max(0, int(answer_line.bbox[1] * scale) - top - 8)
mask_bottom = min(crop.height, int(answer_line.bbox[3] * scale) - top + 12)
if mask_bottom > mask_top:
draw.rectangle((0, mask_top, crop.width, mask_bottom), fill=(255, 255, 255))
crops.append(crop.copy())
if not crops:
return None
if len(crops) == 1:
stitched = crops[0]
else:
divider = 18
width = max(crop.width for crop in crops)
height = sum(crop.height for crop in crops) + divider * (len(crops) - 1)
stitched = Image.new("RGB", (width, height), (255, 255, 255))
y = 0
for index, crop in enumerate(crops):
if crop.width != width:
padded = Image.new("RGB", (width, crop.height), (255, 255, 255))
padded.paste(crop, (0, 0))
crop = padded
stitched.paste(crop, (0, y))
y += crop.height
if index < len(crops) - 1:
y += divider
suffix = f"pages_{pages[0]}-{pages[-1]}" if page_count > 1 else f"page_{pages[0]}"
out_path = cfg.question_image_dir / f"q_{qrange.number}_{suffix}.jpg"
stitched.save(out_path, "JPEG", quality=90)
return out_path.as_posix()
def parse_pdf_text_first(cfg: ParserConfig, progress_callback=None) -> List[dict]:
_log("INFO", "[STEP 2] PDF ๋‚ด์žฅ ํ…์ŠคํŠธ ๊ธฐ๋ฐ˜ ํŒŒ์‹ฑ ์‹œ๋„")
lines = extract_pdf_text_lines(cfg.pdf_path)
ranges = split_pdf_lines_to_ranges(lines)
answer_count = sum(1 for line in lines if re.match(r"(?i)^Answer\s*:", line.text))
if len(ranges) < 20 or answer_count < 20:
_log("INFO", f"๋‚ด์žฅ ํ…์ŠคํŠธ ๋ฌธํ•ญ ํ›„๋ณด ๋ถ€์กฑ(lines={len(lines)}, ranges={len(ranges)}, answers={answer_count})")
return []
results = []
active_group = None
for index, qrange in enumerate(ranges, 1):
chunk = clean_question_text("\n".join(line.text for line in lines[qrange.start : qrange.end]))
answer, without_answer = _extract_answer(chunk)
before_expl, explanation = _split_explanation(without_answer)
stem, options = _extract_options(before_expl)
question_type = _detect_structured_type(chunk, options, answer)
marker = _group_marker(stem)
parent_stem = None
parent_image_paths = []
group_id = None
topic_intro_number = _topic_intro_number(stem)
if marker:
group_start, group_end = marker
parent_stem, stem = _split_parent_and_child(stem)
parent_line_end = qrange.start + max(_nonempty_line_count(parent_stem), 1)
parent_image_paths = _page_image_paths_for_lines(cfg, lines, qrange.start, min(parent_line_end, qrange.end))
active_group = {
"end": group_end,
"group_id": f"q{group_start}-{group_end}",
"parent_stem": parent_stem or _clean_parent_stem(before_expl),
"parent_image_paths": parent_image_paths,
}
elif topic_intro_number and answer:
parent_stem, split_stem = _split_topic_parent_and_child(stem)
if parent_stem:
stem = split_stem
parent_line_end = qrange.start + max(_nonempty_line_count(parent_stem), 1)
parent_image_paths = _page_image_paths_for_lines(cfg, lines, qrange.start, min(parent_line_end, qrange.end))
active_group = {
"end": None,
"group_id": f"q{topic_intro_number}-case",
"parent_stem": _clean_parent_stem(parent_stem),
"parent_image_paths": parent_image_paths,
}
elif topic_intro_number and not answer and not options:
parent_image_paths = _page_image_paths_for_lines(cfg, lines, qrange.start, qrange.end)
active_group = {
"end": None,
"group_id": f"q{topic_intro_number}-case",
"parent_stem": _clean_parent_stem(before_expl),
"parent_image_paths": parent_image_paths,
}
_emit(
progress_callback,
stage="text_parse",
message=f"๊ณตํ†ต ์ง€๋ฌธ ์ €์žฅ q.{topic_intro_number}",
current=index,
total=len(ranges),
)
continue
elif topic_intro_number and active_group and active_group.get("end") is None:
active_group = None
if active_group and active_group.get("end") is None:
group_id = active_group["group_id"]
parent_stem = parent_stem or active_group["parent_stem"]
parent_image_paths = parent_image_paths or active_group.get("parent_image_paths", [])
elif active_group and qrange.number <= active_group["end"]:
group_id = active_group["group_id"]
parent_stem = parent_stem or active_group["parent_stem"]
parent_image_paths = parent_image_paths or active_group.get("parent_image_paths", [])
elif active_group and qrange.number > active_group["end"]:
active_group = None
if marker and not answer and not options:
_emit(
progress_callback,
stage="text_parse",
message=f"๊ณตํ†ต ์ง€๋ฌธ ์ €์žฅ q.{qrange.number}",
current=index,
total=len(ranges),
)
continue
image_path = crop_pdf_question_image(cfg, lines, qrange)
result = {
"page": lines[qrange.start].page,
"number": qrange.number,
"group_id": group_id,
"parent_stem": parent_stem,
"parent_image_paths": parent_image_paths,
"stem": stem or before_expl[:1200],
"options": options,
"answer": answer,
"explanation": explanation,
"question_type": question_type,
"image_path": image_path,
"raw_text": chunk,
"parse_status": "draft"
if stem and answer and (options or question_type in {"hotspot", "yes_no", "ordering", "table_choice", "matching"})
else "needs_review",
}
results.append(result)
_emit(
progress_callback,
stage="text_parse",
message=f"PDF ํ…์ŠคํŠธ ๋ฌธํ•ญ ํŒŒ์‹ฑ {index}/{len(ranges)}",
current=index,
total=len(ranges),
)
_log("INFO", f"[STEP 2] PDF ๋‚ด์žฅ ํ…์ŠคํŠธ ํŒŒ์‹ฑ ์™„๋ฃŒ ({len(results)}๊ฐœ)")
return results
# ----------------------------------------------
# PDF โ†’ ์ด๋ฏธ์ง€ ๋ณ€ํ™˜
# ----------------------------------------------
def _pdf_page_count(pdf_path: str) -> int:
try:
from pdf2image.pdf2image import pdfinfo_from_path
return int(pdfinfo_from_path(pdf_path).get("Pages") or 0)
except Exception:
import fitz
with fitz.open(pdf_path) as doc:
return doc.page_count
def pdf_to_images(cfg: ParserConfig, progress_callback=None) -> List[Path]:
_log("INFO", f"[STEP 1] PDF โ†’ ์ด๋ฏธ์ง€ ๋ณ€ํ™˜ ์ค‘... ({cfg.dpi}dpi)")
cfg.image_dir.mkdir(parents=True, exist_ok=True)
try:
page_count = _pdf_page_count(cfg.pdf_path)
except (PDFInfoNotInstalledError, PDFPageCountError) as exc:
_log("WARN", f"PDF ํŽ˜์ด์ง€ ์ˆ˜ ํ™•์ธ ์‹คํŒจ, PyMuPDF๋กœ ์ „ํ™˜ํ•ฉ๋‹ˆ๋‹ค: {exc}")
import fitz
with fitz.open(cfg.pdf_path) as doc:
page_count = doc.page_count
existing = {int(m.group(1)): path for path in _page_images(cfg.image_dir) if (m := re.search(r"page_(\d+)", path.name))}
if page_count and len(existing) >= page_count:
_log("INFO", f"[STEP 1] ๊ธฐ์กด ์ด๋ฏธ์ง€ {len(existing)}๊ฐœ ๋ฐœ๊ฒฌ โ†’ ๋ณ€ํ™˜ ์Šคํ‚ต")
_emit(
progress_callback,
stage="render",
message=f"๊ธฐ์กด ํŽ˜์ด์ง€ ์ด๋ฏธ์ง€ {len(existing)}๊ฐœ๋ฅผ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.",
current=len(existing),
total=page_count,
)
return [existing[i] for i in sorted(existing)]
paths = []
for page_num in range(1, page_count + 1):
path = cfg.image_dir / f"page_{page_num}.jpg"
if path.exists():
paths.append(path)
_emit(
progress_callback,
stage="render",
message=f"๊ธฐ์กด ํŽ˜์ด์ง€ ์ด๋ฏธ์ง€ ์‚ฌ์šฉ p.{page_num}",
current=page_num,
total=page_count,
)
continue
try:
pages = convert_from_path(
cfg.pdf_path,
dpi=cfg.dpi,
first_page=page_num,
last_page=page_num,
thread_count=1,
)
if pages:
pages[0].save(path, "JPEG", quality=90)
paths.append(path)
except PDFInfoNotInstalledError:
_log("WARN", "Poppler๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์–ด PyMuPDF ๋ Œ๋”๋ง์œผ๋กœ ์ „ํ™˜ํ•ฉ๋‹ˆ๋‹ค.")
import fitz
zoom = cfg.dpi / 72
matrix = fitz.Matrix(zoom, zoom)
with fitz.open(cfg.pdf_path) as doc:
page = doc.load_page(page_num - 1)
pix = page.get_pixmap(matrix=matrix, alpha=False)
pix.save(path)
paths.append(path)
_emit(
progress_callback,
stage="render",
message=f"ํŽ˜์ด์ง€ ์ด๋ฏธ์ง€ ๋ณ€ํ™˜ p.{page_num}",
current=page_num,
total=page_count,
)
_log("INFO", f"[STEP 1] ์™„๋ฃŒ ({len(paths)}ํŽ˜์ด์ง€)")
return paths
# ----------------------------------------------
# OCR ์‹คํ–‰ (์ž๋™ ์Šคํ‚ต)
# ----------------------------------------------
def run_ocr(cfg: ParserConfig, ocr: PaddleOCR, img_paths: List[Path]) -> Dict[int, str]:
_log("INFO", "[STEP 2] OCR ๋‹จ๊ณ„ ์‹œ์ž‘ (์ž๋™ ์Šคํ‚ต ๊ฐ์ง€)")
ocr_map = {}
cfg.ocr_log_dir.mkdir(parents=True, exist_ok=True)
existing_txts = sorted(cfg.ocr_log_dir.glob("page_*.txt"))
# โœ… ๊ธฐ์กด OCR ํ…์ŠคํŠธ ๊ฒฐ๊ณผ ์กด์žฌ ์‹œ์—๋งŒ ์Šคํ‚ต
if existing_txts:
_log("INFO", f"๊ธฐ์กด OCR ๊ฒฐ๊ณผ ๊ฐ์ง€๋จ โ†’ OCR ๋‹จ๊ณ„ ์Šคํ‚ตํ•˜๊ณ  ๊ธฐ์กด ๊ฒฐ๊ณผ ์‚ฌ์šฉ")
# ํ…์ŠคํŠธ ํŒŒ์ผ ์šฐ์„  ๋กœ๋“œ
for txt_path in existing_txts:
m = re.search(r"page[_-]?(\d+)", txt_path.name)
page_num = int(m.group(1)) if m else 0
try:
with open(txt_path, "r", encoding="utf-8") as f:
text = clean_text(f.read())
ocr_map[page_num] = text
except Exception as e:
_log("WARN", f"OCR ํ…์ŠคํŠธ ๋กœ๋“œ ์‹คํŒจ: {txt_path} ({e})")
return ocr_map
# โœ… OCR ์‹ ๊ทœ ์ˆ˜ํ–‰
_log("INFO", "[STEP 2] OCR ์‹ ๊ทœ ์ˆ˜ํ–‰ ์ค‘...")
def ocr_one(img_path: Path):
m = re.search(r"page[_-]?(\d+)", img_path.name)
page = int(m.group(1)) if m else 0
cache_path = cfg.ocr_log_dir / f"page_{page}.txt"
for pre_fn in [preprocess_light, preprocess_heavy]:
try:
processed = pre_fn(img_path)
res = ocr.ocr(str(processed), cls=True)
text = "\n".join([ln[1][0] for ln in (res[0] or [])]) if res and res[0] else ""
text = clean_text(text)
if text and ocr_quality_score(text) >= 0.3:
cache_path.write_text(text, encoding="utf-8")
return page, text
except Exception as e:
_log("WARN", f"OCR ์‹คํŒจ (p{page}): {e}")
cache_path.write_text("", encoding="utf-8")
return page, ""
with ThreadPoolExecutor(max_workers=cfg.ocr_workers) as ex:
futures = [ex.submit(ocr_one, p) for p in img_paths]
for i, f in enumerate(as_completed(futures), 1):
p, txt = f.result()
ocr_map[p] = txt
if i % 2 == 0:
_log("INFO", f"[OCR] {i}/{len(img_paths)} ์™„๋ฃŒ (p{p}, score={ocr_quality_score(txt):.2f})")
return ocr_map
# ----------------------------------------------
# ๋ณ‘ํ•ฉ ๋ฐ LLM ํŒŒ์‹ฑ
# ----------------------------------------------
Q_SPLIT = re.compile(r"(?:๋ฌธ์ œ\s*\d+\.?|Q\s*\d+\.?|^\s*\d+\.\s|๋‹ค์Œ\s*์ค‘|์‹œ๋‚˜๋ฆฌ์˜ค|Case\s*\d+|Explanation\s*:?)", re.IGNORECASE | re.MULTILINE)
def merge_pages_to_questions(ocr_map: Dict[int, str]) -> List[Tuple[int, str]]:
merged = []
for p in sorted(ocr_map.keys()):
txt = clean_text(ocr_map[p])
if not txt:
continue
chunks = [c.strip() for c in re.split(Q_SPLIT, txt) if c.strip()]
for c in chunks:
if len(c) >= 50 and any(k in c for k in ["์ •๋‹ต", "๋ณด๊ธฐ", "Answer", "Explanation", "๋ฌธ์ œ"]):
merged.append((p, c))
if not merged:
for p in sorted(ocr_map.keys()):
txt = clean_text(ocr_map[p])
if len(txt) >= 50 and not txt.startswith("[SKIP OCR]"):
merged.append((p, txt))
return merged
def load_partial_results(partial_jsonl: Path) -> Tuple[List[dict], set]:
results = []
keys = set()
if not partial_jsonl.exists():
return results, keys
with partial_jsonl.open("r", encoding="utf-8") as f:
for line in f:
try:
row = json.loads(line)
except Exception:
continue
item = row.get("result") if isinstance(row, dict) else None
key = row.get("key") if isinstance(row, dict) else None
if item and key:
results.append(item)
keys.add(key)
return results, keys
def append_partial_result(partial_jsonl: Path, key: str, result: dict) -> None:
partial_jsonl.parent.mkdir(parents=True, exist_ok=True)
with partial_jsonl.open("a", encoding="utf-8") as f:
f.write(json.dumps({"key": key, "result": result}, ensure_ascii=False) + "\n")
def create_question_crops(
cfg: ParserConfig,
items: List[Tuple[int, str]],
progress_callback=None,
) -> Dict[int, str]:
cfg.question_image_dir.mkdir(parents=True, exist_ok=True)
by_page: Dict[int, List[int]] = {}
for idx, (page, _text) in enumerate(items):
by_page.setdefault(page, []).append(idx)
image_paths: Dict[int, str] = {}
total = max(len(items), 1)
done = 0
for page, item_indexes in by_page.items():
page_image = cfg.image_dir / f"page_{page}.jpg"
if not page_image.exists():
continue
with Image.open(page_image) as img:
width, height = img.size
band_count = len(item_indexes)
for position, item_index in enumerate(item_indexes):
top = max(0, int(height * position / band_count) - 60)
bottom = min(height, int(height * (position + 1) / band_count) + 60)
crop = img.crop((0, top, width, bottom))
out_path = cfg.question_image_dir / f"page_{page}_q{position + 1}.jpg"
crop.save(out_path, "JPEG", quality=88)
image_paths[item_index] = out_path.as_posix()
done += 1
_emit(
progress_callback,
stage="crop",
message=f"๋ฌธํ•ญ ์ด๋ฏธ์ง€ ์ƒ์„ฑ p.{page} ({done}/{total})",
current=done,
total=total,
)
return image_paths
def load_llm(cfg: ParserConfig):
if cfg.llm_provider == "ollama":
_log("INFO", f"[STEP 4] Ollama LLM ์—ฐ๊ฒฐ ์ค‘... ({cfg.ollama_model}, {cfg.ollama_base_url})")
from langchain_community.llms import Ollama
return Ollama(
model=cfg.ollama_model,
base_url=cfg.ollama_base_url,
temperature=0,
)
_log("INFO", f"[STEP 4] Hugging Face LLM ๋กœ๋“œ ์ค‘... ({cfg.llm_model})")
tok = AutoTokenizer.from_pretrained(cfg.llm_model)
mdl = AutoModelForCausalLM.from_pretrained(cfg.llm_model, torch_dtype="auto", low_cpu_mem_usage=True)
pipe = pipeline("text-generation", model=mdl, tokenizer=tok, device=-1, max_new_tokens=cfg.max_new_tokens, temperature=0.0)
_log("INFO", "[STEP 4] LLM ๋กœ๋“œ ์™„๋ฃŒ")
return HuggingFacePipeline(pipeline=pipe)
# ----------------------------------------------
# ๋ฉ”์ธ ํŒŒ์ดํ”„๋ผ์ธ
# ----------------------------------------------
def parse_pdf(
pdf_path: str,
output_json: str,
use_llm=True,
lang="korean",
dpi: Optional[int] = None,
llm_provider: Optional[str] = None,
llm_model: Optional[str] = None,
ollama_base_url: Optional[str] = None,
progress_callback=None,
):
torch.set_num_threads(1)
cfg = ParserConfig(pdf_path, output_json, use_llm, lang)
cfg.job_name = _job_name(pdf_path)
cfg.image_dir = IMAGE_DIR / cfg.job_name
cfg.question_image_dir = QUESTION_IMAGE_DIR / cfg.job_name
cfg.ocr_log_dir = OCR_LOG_DIR / cfg.job_name
cfg.partial_jsonl = RUN_LOG_DIR / f"{cfg.job_name}.partial.jsonl"
if dpi:
cfg.dpi = dpi
if llm_provider:
cfg.llm_provider = llm_provider
if llm_model:
if cfg.llm_provider == "ollama":
cfg.ollama_model = llm_model
else:
cfg.llm_model = llm_model
if ollama_base_url:
cfg.ollama_base_url = ollama_base_url
_log("INFO", f"[START] {pdf_path} (dpi={cfg.dpi}, threads={cfg.cpu_threads})")
_emit(progress_callback, stage="start", message="ํŒŒ์‹ฑ์„ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค.", current=0, total=1)
# STEP 1: PDF โ†’ ์ด๋ฏธ์ง€ (์žˆ์œผ๋ฉด ์Šคํ‚ต)
_emit(progress_callback, stage="render", message="PDF๋ฅผ ํŽ˜์ด์ง€ ์ด๋ฏธ์ง€๋กœ ๋ณ€ํ™˜ํ•ฉ๋‹ˆ๋‹ค.", current=0, total=1)
pages = pdf_to_images(cfg, progress_callback=progress_callback)
_emit(progress_callback, stage="render", message=f"ํŽ˜์ด์ง€ ์ด๋ฏธ์ง€ {len(pages)}๊ฐœ ์ค€๋น„ ์™„๋ฃŒ", current=len(pages), total=len(pages) or 1)
text_results = parse_pdf_text_first(cfg, progress_callback=progress_callback)
if text_results:
Path(output_json).write_text(json.dumps(text_results, ensure_ascii=False, indent=2), encoding="utf-8")
_log("INFO", f"[DONE] PDF ํ…์ŠคํŠธ ๊ธฐ๋ฐ˜ ์ด {len(text_results)} ๋ฌธํ•ญ ์ €์žฅ โ†’ {output_json}")
_emit(
progress_callback,
stage="done",
message=f"PDF ํ…์ŠคํŠธ ๊ธฐ๋ฐ˜ {len(text_results)}๋ฌธํ•ญ ํŒŒ์‹ฑ ์™„๋ฃŒ",
current=len(text_results),
total=len(text_results),
)
return text_results
# STEP 2: OCR (์žˆ์œผ๋ฉด ์Šคํ‚ต)
_emit(progress_callback, stage="ocr", message="OCR ํ…์ŠคํŠธ๋ฅผ ์ค€๋น„ํ•ฉ๋‹ˆ๋‹ค.", current=0, total=len(pages) or 1)
ocr = create_ocr(cfg.lang, cfg.cpu_threads)
ocr_map = run_ocr(cfg, ocr, pages)
_emit(progress_callback, stage="ocr", message=f"OCR ํ…์ŠคํŠธ {len(ocr_map)}ํŽ˜์ด์ง€ ์ค€๋น„ ์™„๋ฃŒ", current=len(ocr_map), total=len(pages) or len(ocr_map) or 1)
# STEP 3: ๋ฌธํ•ญ ๋ณ‘ํ•ฉ
items = merge_pages_to_questions(ocr_map)
_log("INFO", f"[STEP 3] ๋ฌธํ•ญ ๋ณ‘ํ•ฉ ์™„๋ฃŒ ({len(items)}๊ฐœ)")
_emit(progress_callback, stage="split", message=f"๋ฌธํ•ญ ํ›„๋ณด {len(items)}๊ฐœ๋ฅผ ์ฐพ์•˜์Šต๋‹ˆ๋‹ค.", current=0, total=len(items) or 1)
item_images = create_question_crops(cfg, items, progress_callback=progress_callback)
# STEP 4: LLM ํŒŒ์‹ฑ
results, parsed_keys = load_partial_results(cfg.partial_jsonl)
if results:
_log("INFO", f"[RESUME] ๋ถ€๋ถ„ ์ €์žฅ ๊ฒฐ๊ณผ {len(results)}๊ฐœ ๋กœ๋“œ โ†’ ์ด๋ฏธ ์ฒ˜๋ฆฌํ•œ ๋ฌธํ•ญ ์Šคํ‚ต")
_emit(
progress_callback,
stage="resume",
message=f"์ด์ „ ๋ถ€๋ถ„ ์ €์žฅ {len(results)}๊ฐœ๋ฅผ ์ด์–ด์„œ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.",
current=len(results),
total=len(items) or len(results) or 1,
)
if use_llm:
try:
llm = load_llm(cfg)
except Exception as e:
_log("WARN", f"LLM ๋กœ๋“œ ์‹คํŒจ โ†’ ๊ธฐ๋ณธ ํŒŒ์‹ฑ์œผ๋กœ ๊ณ„์† ์ง„ํ–‰ํ•ฉ๋‹ˆ๋‹ค: {e}")
use_llm = False
else:
for index, (p, t) in enumerate(items, 1):
key = _item_key(p, t)
if key in parsed_keys:
_emit(
progress_callback,
stage="llm",
message=f"์ด๋ฏธ ์ฒ˜๋ฆฌํ•œ ๋ฌธํ•ญ์„ ๊ฑด๋„ˆ๋œ๋‹ˆ๋‹ค. ({index}/{len(items)})",
current=index,
total=len(items),
)
continue
_emit(
progress_callback,
stage="llm",
message=f"Qwen์œผ๋กœ ๋ฌธํ•ญ์„ ์ •์ œํ•ฉ๋‹ˆ๋‹ค. p.{p} ({index}/{len(items)})",
current=index - 1,
total=len(items),
)
try:
parsed = parse_question_with_chain(llm, p, t)
except Exception as e:
parsed = {
"page": p,
"stem": t[:400],
"options": [],
"answer": [],
"explanation": f"LLM ์‹คํŒจ: {e}",
"question_type": "mcq"
}
parsed["raw_text"] = parsed.get("raw_text") or t
parsed["chunk_index"] = index
parsed["chunk_key"] = key
if index - 1 in item_images:
parsed["image_path"] = item_images[index - 1]
results.append(parsed)
parsed_keys.add(key)
append_partial_result(cfg.partial_jsonl, key, parsed)
Path(output_json).write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8")
_emit(
progress_callback,
stage="llm",
message=f"๋ฌธํ•ญ {len(results)}๊ฐœ ์ €์žฅ๋จ",
current=index,
total=len(items),
)
if not use_llm:
for index, (p, t) in enumerate(items, 1):
key = _item_key(p, t)
if key in parsed_keys:
continue
parsed = {
"page": p,
"stem": t[:400],
"options": [],
"answer": [],
"explanation": "",
"question_type": "mcq",
"image_path": item_images.get(index - 1),
"raw_text": t,
"chunk_index": index,
"chunk_key": key,
}
results.append(parsed)
parsed_keys.add(key)
append_partial_result(cfg.partial_jsonl, key, parsed)
_emit(progress_callback, stage="basic", message=f"๊ธฐ๋ณธ ํŒŒ์‹ฑ {index}/{len(items)}", current=index, total=len(items))
if not use_llm and not results:
for index, (p, t) in enumerate(items, 1):
results.append({
"page": p,
"stem": t[:400],
"options": [],
"answer": [],
"explanation": "",
"question_type": "mcq",
"raw_text": t,
"chunk_index": index,
"chunk_key": _item_key(p, t),
})
Path(output_json).write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8")
_log("INFO", f"[DONE] ์ด {len(results)} ๋ฌธํ•ญ ์ €์žฅ โ†’ {output_json}")
_emit(progress_callback, stage="done", message=f"์ด {len(results)}๋ฌธํ•ญ ํŒŒ์‹ฑ ์™„๋ฃŒ", current=len(results), total=len(results) or 1)
return results
# ----------------------------------------------
# CLI ๋””๋ฒ„๊ทธ ์‹คํ–‰
# ----------------------------------------------
if __name__ == "__main__":
import argparse
ap = argparse.ArgumentParser()
ap.add_argument("--pdf", required=True)
ap.add_argument("--out", default="data/parsed.json")
ap.add_argument("--no-llm", action="store_true")
ap.add_argument("--dpi", type=int, default=None)
args = ap.parse_args()
try:
parse_pdf(args.pdf, args.out, use_llm=not args.no_llm, dpi=args.dpi)
except Exception as e:
_log("ERROR", f"์‹คํ–‰ ์ค‘ ์˜ค๋ฅ˜: {e}")
print(traceback.format_exc())