cert-study-app / scripts /export_hf_seed.py
Kentlo's picture
Sync from GitHub f8e98d3f03952efd3d7c1b217df60c77c3992aec
87b5875 verified
Raw
History Blame Contribute Delete
11.4 kB
from __future__ import annotations
import argparse
import json
import re
import shutil
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parent.parent
DEFAULT_SOURCE_JSON = ROOT / "data" / "Json" / "questions.json"
DEFAULT_PARENT_SOURCE_JSON = ROOT / "data" / "parsed_json" / "reparsed_multipage_images_20260601_205501.json"
DEFAULT_IMAGE_ROOT = ROOT / "data" / "images"
DEFAULT_SEED_PATH = ROOT / "cert_study_app" / "demo_data" / "questions_seed.json"
DEFAULT_ASSET_ROOT = ROOT / "static" / "question_assets"
DEFAULT_REPORT_PATH = ROOT / "cert_study_app" / "demo_data" / "questions_seed.report.json"
def _load_rows(path: Path) -> list[dict[str, Any]]:
with path.open("r", encoding="utf-8") as file:
payload = json.load(file)
if isinstance(payload, dict):
payload = payload.get("questions", [])
return [row for row in payload if isinstance(row, dict)]
def _option_text(option: Any, fallback_key: str) -> str:
if isinstance(option, dict):
key = str(option.get("key") or fallback_key).strip()
text = str(option.get("text") or option.get("value") or "").strip()
return f"{key}. {text}".strip()
return str(option).strip()
def _normalize_options(options: Any) -> list[str] | dict[str, str]:
if isinstance(options, list):
normalized = []
for index, option in enumerate(options):
normalized.append(_option_text(option, chr(65 + index)))
return normalized
if isinstance(options, dict):
return {str(key): str(value) for key, value in options.items()}
return []
def _first_existing_page_image(source_pages: list[Any], image_root: Path) -> Path | None:
for page in source_pages:
try:
page_number = int(page)
except Exception:
continue
matches = sorted(image_root.glob(f"*/page_{page_number}.jpg"))
if matches:
return matches[0]
return None
def _copy_image(source_path: Path, asset_root: Path) -> str:
relative_parent = source_path.parent.name
target_dir = asset_root / relative_parent
target_dir.mkdir(parents=True, exist_ok=True)
target_path = target_dir / source_path.name
if not target_path.exists() or source_path.stat().st_size != target_path.stat().st_size:
shutil.copy2(source_path, target_path)
return target_path.relative_to(ROOT).as_posix()
def _copy_image_reference(image_path: Any, asset_root: Path) -> str | None:
if not image_path:
return None
source_path = Path(str(image_path))
if not source_path.is_absolute():
source_path = ROOT / source_path
if not source_path.exists():
return str(image_path)
return _copy_image(source_path, asset_root)
def _asset_exists(path: Any) -> bool:
if not path:
return False
asset_path = Path(str(path))
if not asset_path.is_absolute():
asset_path = ROOT / asset_path
return asset_path.exists()
def _question_number(row: dict[str, Any]) -> Any:
return row.get("question_number") or row.get("question_id") or row.get("number")
def _parent_context_by_number(parent_rows: list[dict[str, Any]]) -> dict[int, dict[str, Any]]:
contexts = {}
for row in parent_rows:
number = _question_number(row)
try:
number = int(number)
except Exception:
continue
if not row.get("parent_stem"):
continue
contexts[number] = row
return contexts
def _parent_context_by_scenario(
rows: list[dict[str, Any]], parent_contexts: dict[int, dict[str, Any]]
) -> dict[str, dict[str, Any]]:
contexts = {}
for row in rows:
scenario = row.get("scenario")
if not scenario or scenario == "단일문제" or scenario in contexts:
continue
try:
number = int(_question_number(row))
except Exception:
continue
parent_context = parent_contexts.get(number)
if parent_context:
contexts[str(scenario)] = parent_context
return contexts
def _meaningful_parent_stem(value: Any) -> str | None:
text = str(value or "").strip()
if not text or text == "단일문제":
return None
if len(text) < 40 and not re.search(r"[\n:]", text):
return None
return text
def _export_row(
row: dict[str, Any],
image_root: Path,
asset_root: Path,
parent_contexts: dict[int, dict[str, Any]],
scenario_contexts: dict[str, dict[str, Any]],
) -> dict[str, Any]:
source_pages = row.get("source_pages") or []
if not isinstance(source_pages, list):
source_pages = [source_pages]
image_path = _copy_image_reference(row.get("image_path"), asset_root)
if not image_path:
source_image = _first_existing_page_image(source_pages, image_root)
if source_image:
image_path = _copy_image(source_image, asset_root)
question_number = _question_number(row)
page = source_pages[0] if source_pages else row.get("page")
try:
parent_context = parent_contexts.get(int(question_number)) or {}
except Exception:
parent_context = {}
if not parent_context and row.get("scenario"):
parent_context = scenario_contexts.get(str(row.get("scenario"))) or {}
parent_image_paths = []
for parent_image_path in parent_context.get("parent_image_paths") or row.get("parent_image_paths") or []:
copied_path = _copy_image_reference(parent_image_path, asset_root)
if copied_path:
parent_image_paths.append(copied_path)
parent_stem = (
_meaningful_parent_stem(parent_context.get("parent_stem"))
or _meaningful_parent_stem(row.get("parent_stem"))
)
return {
"question_number": question_number,
"stem": row.get("stem") or row.get("question") or "",
"options": _normalize_options(row.get("options")),
"answer": row.get("answer"),
"explanation": row.get("explanation"),
"question_type": row.get("question_type") or "MCQ",
"category": row.get("category"),
"subcategory": row.get("subcategory"),
"source": row.get("source") or "AZ-104 imported seed",
"page": page,
"image_path": image_path,
"raw_text": row.get("raw_text") or row.get("stem") or row.get("question") or "",
"group_id": parent_context.get("group_id") or row.get("group_id"),
"parent_stem": parent_stem,
"parent_image_paths": parent_image_paths,
"concept_tags": row.get("concept_tags") or [],
"parse_status": row.get("parse_status") or "approved",
"quality_score": row.get("quality_score") or 100,
"quality_status": row.get("quality_status") or "seed",
"quality_issues": row.get("quality_issues") or [],
"chunk_key": row.get("chunk_key"),
"chunk_index": row.get("chunk_index"),
"parser_version": row.get("parser_version") or "hf-seed-export-v1",
"source_pages": source_pages,
}
def export_seed(
source_json: Path,
parent_source_json: Path,
image_root: Path,
seed_path: Path,
asset_root: Path,
report_path: Path | None = DEFAULT_REPORT_PATH,
) -> int:
rows = _load_rows(source_json)
parent_rows = _load_rows(parent_source_json) if parent_source_json.exists() else []
parent_contexts = _parent_context_by_number(parent_rows)
scenario_contexts = _parent_context_by_scenario(rows, parent_contexts)
asset_root.mkdir(parents=True, exist_ok=True)
seed_path.parent.mkdir(parents=True, exist_ok=True)
exported = [
_export_row(row, image_root, asset_root, parent_contexts, scenario_contexts)
for row in rows
]
with seed_path.open("w", encoding="utf-8") as file:
json.dump(exported, file, ensure_ascii=False, indent=2)
file.write("\n")
if report_path:
report_path.parent.mkdir(parents=True, exist_ok=True)
with report_path.open("w", encoding="utf-8") as file:
json.dump(build_seed_report(exported, seed_path), file, ensure_ascii=False, indent=2)
file.write("\n")
return len(exported)
def build_seed_report(rows: list[dict[str, Any]], seed_path: Path) -> dict[str, Any]:
numbers = []
duplicate_numbers = []
seen_numbers = set()
type_counts: dict[str, int] = {}
missing_images = []
missing_answers = []
missing_options = []
common_passage_count = 0
for row in rows:
number = row.get("question_number")
if number in seen_numbers:
duplicate_numbers.append(number)
seen_numbers.add(number)
numbers.append(number)
question_type = str(row.get("question_type") or "unparsed")
type_counts[question_type] = type_counts.get(question_type, 0) + 1
if row.get("parent_stem"):
common_passage_count += 1
if not row.get("answer"):
missing_answers.append(number)
if not row.get("options"):
missing_options.append(number)
image_paths = [row.get("image_path")] + list(row.get("parent_image_paths") or [])
for image_path in image_paths:
if image_path and not _asset_exists(image_path):
missing_images.append({"question_number": number, "image_path": image_path})
numeric_numbers = []
for number in numbers:
try:
numeric_numbers.append(int(number))
except Exception:
continue
return {
"schema_version": 1,
"seed_path": _display_path(seed_path),
"question_count": len(rows),
"first_question_number": min(numeric_numbers) if numeric_numbers else None,
"last_question_number": max(numeric_numbers) if numeric_numbers else None,
"common_passage_count": common_passage_count,
"type_counts": dict(sorted(type_counts.items())),
"duplicate_numbers": duplicate_numbers,
"missing_answers": missing_answers,
"missing_options": missing_options,
"missing_images": missing_images,
"ready": not duplicate_numbers and not missing_answers and not missing_images,
}
def _display_path(path: Path) -> str:
try:
resolved = path.resolve()
return resolved.relative_to(ROOT).as_posix()
except Exception:
return path.as_posix()
def main() -> None:
parser = argparse.ArgumentParser(description="Export deployable question seed data for Hugging Face Space.")
parser.add_argument("--source-json", type=Path, default=DEFAULT_SOURCE_JSON)
parser.add_argument("--parent-source-json", type=Path, default=DEFAULT_PARENT_SOURCE_JSON)
parser.add_argument("--image-root", type=Path, default=DEFAULT_IMAGE_ROOT)
parser.add_argument("--seed-path", type=Path, default=DEFAULT_SEED_PATH)
parser.add_argument("--asset-root", type=Path, default=DEFAULT_ASSET_ROOT)
parser.add_argument("--report-path", type=Path, default=DEFAULT_REPORT_PATH)
args = parser.parse_args()
count = export_seed(
source_json=args.source_json,
parent_source_json=args.parent_source_json,
image_root=args.image_root,
seed_path=args.seed_path,
asset_root=args.asset_root,
report_path=args.report_path,
)
print(f"Exported {count} questions to {args.seed_path}")
print(f"Wrote seed report to {args.report_path}")
if __name__ == "__main__":
main()