From 15b9dd0907aba32cf92c8e747e8d8bbaceecbe20 Mon Sep 17 00:00:00 2001 From: seokhyun Seo Date: Tue, 6 Jan 2026 00:50:28 +0000 Subject: [PATCH 1/4] Add checkpointing support and fix bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add LangGraph checkpointing for resumable execution - SQLite-based persistent checkpoints - Auto-generated thread IDs from image paths - Checkpoint management utilities (list, show, delete, clear) - Resume from checkpoint support - Add CLI checkpointing options - --checkpoint: Enable checkpointing - --checkpoint-dir: Custom checkpoint directory - --thread-id: Custom thread ID - --resume: Resume from existing checkpoint - --list-checkpoints, --show-checkpoint, --delete-checkpoint, --clear-checkpoints - --resume-checkpoint: Direct resume from thread ID - Fix bugs - Fix async kwargs mutation in api_pool.py (kwargs.pop modifying original dict) - Fix error list mutation in flow.py (state errors list being modified in place) - Move imports to top of file in flow.py (PEP8) - Remove global logging.basicConfig from api_pool.py (library code) ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- generate_synthetic_table/flow.py | 380 ++++++++++++++++++++++++++--- generate_synthetic_table/runner.py | 219 ++++++++++++++++- polling_gemini/api_pool.py | 19 +- 3 files changed, 569 insertions(+), 49 deletions(-) diff --git a/generate_synthetic_table/flow.py b/generate_synthetic_table/flow.py index 3fd7825..a567393 100644 --- a/generate_synthetic_table/flow.py +++ b/generate_synthetic_table/flow.py @@ -4,23 +4,32 @@ import base64 import json +import logging import os from pathlib import Path from typing import Dict, List, TypedDict, Callable, Optional +import fitz # PyMuPDF +import yaml +from dotenv import load_dotenv from langchain_core.messages import HumanMessage from langchain_openai import ChatOpenAI from langgraph.graph import END, START, StateGraph -from dotenv import load_dotenv -import fitz # PyMuPDF -import yaml +from langgraph.checkpoint.memory import MemorySaver +from langgraph.checkpoint.sqlite import SqliteSaver +from .validators import ( + robust_json_parse, + validate_html, + parse_html_table_to_json, + compare_tables_with_sql, + compare_tables_with_repl, +) -MAX_ATTEMPTS = 2 # ์ตœ๋Œ€ ์žฌ์ƒ์„ฑ ์‹œ๋„ ํšŸ์ˆ˜ -_PROMPTS_CACHE = {} # Cache for loaded prompt files +logger = logging.getLogger(__name__) -# ํ”„๋กฌํ”„ํŠธ ์บ์‹œ (YAML ํŒŒ์ผ ๋กœ๋”ฉ ๊ฒฐ๊ณผ ์ €์žฅ), ์บ์‹œ๊ฐ€ ์„ ์–ธ์•ˆ๋˜์–ด์žˆ์–ด์„œ ๋„ฃ์Œ -_PROMPTS_CACHE: Dict[str, Dict[str, str]] = {} +MAX_ATTEMPTS = 2 # ์ตœ๋Œ€ ์žฌ์ƒ์„ฑ ์‹œ๋„ ํšŸ์ˆ˜ +_PROMPTS_CACHE: Dict[str, Dict[str, str]] = {} # ํ”„๋กฌํ”„ํŠธ ์บ์‹œ (YAML ํŒŒ์ผ ๋กœ๋”ฉ ๊ฒฐ๊ณผ ์ €์žฅ) class TableState(TypedDict, total=False): @@ -138,7 +147,7 @@ def _node(state: TableState) -> TableState: image_path = Path(state["image_path"]) if not image_path.exists(): - errors = state.get("errors", []) + errors = list(state.get("errors", [])) errors.append(f"Image not found: {image_path}") return {**state, "errors": errors, "attempts": attempts} @@ -245,14 +254,14 @@ def _node(state: TableState) -> TableState: html = state.get("html_table") if not html: - errors = state.get("errors", []) + errors = list(state.get("errors", [])) errors.append("No HTML table to analyze.") return {**state, "errors": errors} try: prompt = prompt_template.format(html=html) except KeyError as e: - errors = state.get("errors", []) + errors = list(state.get("errors", [])) errors.append(f"Analysis prompt missing placeholder: {e}") return {**state, "errors": errors} @@ -276,7 +285,7 @@ def _node(state: "TableState") -> "TableState": summary = state.get("table_summary") if not html: - errors = state.get("errors", []) + errors = list(state.get("errors", [])) errors.append("Insufficient information to generate synthetic table.") return {**state, "errors": errors} @@ -294,7 +303,7 @@ def _node(state: "TableState") -> "TableState": # ๋งŒ์•ฝ ํ”„๋กฌํ”„ํŠธ ํŒŒ์ผ์— {summary}๊ฐ€ ์—†๋‹ค๋ฉด format์—์„œ ๋ฌด์‹œ๋˜๊ฑฐ๋‚˜ ์—๋Ÿฌ๊ฐ€ ๋‚  ์ˆ˜ ์žˆ์Œ. # ์—ฌ๊ธฐ์„œ๋Š” ํ”„๋กฌํ”„ํŠธ ํŒŒ์ผ๋„ ํ™•์ธ/์ˆ˜์ •ํ•ด์•ผ ํ•  ์ˆ˜ ์žˆ์Œ. # ์ผ๋‹จ ์—๋Ÿฌ ์ฒ˜๋ฆฌ. - errors = state.get("errors", []) + errors = list(state.get("errors", [])) errors.append(f"Prompt template missing placeholder: {e}") return {**state, "errors": errors} @@ -316,7 +325,7 @@ def _node(state: "TableState") -> "TableState": image_path = Path(state["image_path"]) if not image_path.exists(): - errors = state.get("errors", []) + errors = list(state.get("errors", [])) errors.append(f"Image not found: {image_path}") return {**state, "errors": errors} @@ -331,10 +340,6 @@ def _node(state: "TableState") -> "TableState": return _node -from .validators import robust_json_parse, validate_html, parse_html_table_to_json, compare_tables_with_sql, compare_tables_with_repl -import logging - -logger = logging.getLogger(__name__) def self_reflection_node(llm: ChatOpenAI) -> Callable[[TableState], TableState]: @@ -357,7 +362,7 @@ def _node(state: TableState) -> TableState: image_path = Path(state["image_path"]) if not synthetic_html: - errors = state.get("errors", []) + errors = list(state.get("errors", [])) errors.append("Synthetic table generation failed.") return {**state, "errors": errors} @@ -425,7 +430,7 @@ def _node(state: TableState) -> TableState: # If we don't have the original HTML (direct path), use the image for reflection if not html: if not image_path.exists(): - errors = state.get("errors", []) + errors = list(state.get("errors", [])) errors.append("Original image missing for reflection.") return {**state, "errors": errors} @@ -433,7 +438,7 @@ def _node(state: TableState) -> TableState: try: prompt = prompt_template_image.format(synthetic_html=synthetic_html) except KeyError as e: - errors = state.get("errors", []) + errors = list(state.get("errors", [])) errors.append(f"Reflection prompt missing placeholder: {e}") return {**state, "errors": errors} @@ -443,7 +448,7 @@ def _node(state: TableState) -> TableState: try: prompt = prompt_template.format(synthetic_html=synthetic_html, html=html) except KeyError as e: - errors = state.get("errors", []) + errors = list(state.get("errors", [])) errors.append(f"Prompt template missing placeholder: {e}") return {**state, "errors": errors} @@ -452,7 +457,7 @@ def _node(state: TableState) -> TableState: reflection_json = robust_json_parse(reflection_text) if reflection_json is None: - errors = state.get("errors", []) + errors = list(state.get("errors", [])) errors.append("Self-reflection did not return valid JSON.") return {**state, "errors": errors, "reflection": reflection_text} @@ -500,7 +505,7 @@ def _node(state: TableState) -> TableState: image_path = Path(state["image_path"]) if not synthetic_html: - errors = state.get("errors", []) + errors = list(state.get("errors", [])) errors.append("No synthetic table to revise.") return {**state, "errors": errors} @@ -508,7 +513,7 @@ def _node(state: TableState) -> TableState: if not html or not summary: # Use image-based revision if not image_path.exists(): - errors = state.get("errors", []) + errors = list(state.get("errors", [])) errors.append("Original image missing for revision.") return {**state, "errors": errors} @@ -519,7 +524,7 @@ def _node(state: TableState) -> TableState: revision_instructions=instructions ) except KeyError as e: - errors = state.get("errors", []) + errors = list(state.get("errors", [])) errors.append(f"Revision prompt missing placeholder: {e}") return {**state, "errors": errors} @@ -535,7 +540,7 @@ def _node(state: TableState) -> TableState: revision_instructions=instructions, ) except KeyError as e: - errors = state.get("errors", []) + errors = list(state.get("errors", [])) errors.append(f"Revision prompt missing placeholder: {e}") return {**state, "errors": errors} @@ -562,7 +567,7 @@ def _node(state: TableState) -> TableState: synthetic_html = state.get("synthetic_table") if not synthetic_html: - errors = state.get("errors", []) + errors = list(state.get("errors", [])) errors.append("No synthetic table to parse.") return {**state, "errors": errors} @@ -590,14 +595,14 @@ def _node(state: TableState) -> TableState: synthetic_html = state.get("synthetic_table") if not synthetic_html: - errors = state.get("errors", []) + errors = list(state.get("errors", [])) errors.append("No synthetic table for QA generation.") return {**state, "errors": errors} try: prompt = prompt_template.format(synthetic_html=synthetic_html) except KeyError as e: - errors = state.get("errors", []) + errors = list(state.get("errors", [])) errors.append(f"QA prompt missing placeholder: {e}") return {**state, "errors": errors} @@ -630,7 +635,7 @@ def _node(state: TableState) -> TableState: else: image_path = Path(state["image_path"]) if not image_path.exists(): - errors = state.get("errors", []) + errors = list(state.get("errors", [])) errors.append(f"Image not found: {image_path}") return {**state, "errors": errors} image_paths = [image_path] @@ -682,7 +687,7 @@ def load_html_input_node(state: TableState) -> TableState: html_content = image_path.read_text(encoding="utf-8") return {**state, "html_table": html_content} except Exception as e: - errors = state.get("errors", []) + errors = list(state.get("errors", [])) errors.append(f"Failed to load HTML file: {e}") return {**state, "errors": errors} @@ -776,6 +781,59 @@ def route_start(state: TableState) -> str: from .llm_factory import get_llm +# ์ „์—ญ ์ฒดํฌํฌ์ธํ„ฐ ์ €์žฅ์†Œ (thread_id๋ณ„ ์ƒํƒœ ๊ด€๋ฆฌ) +_checkpointer_cache: Dict[str, SqliteSaver] = {} + + +def get_checkpointer( + checkpoint_dir: str | None = None, + use_memory: bool = False, +) -> MemorySaver | SqliteSaver: + """ + ์ฒดํฌํฌ์ธํ„ฐ ์ธ์Šคํ„ด์Šค๋ฅผ ์ƒ์„ฑํ•˜๊ฑฐ๋‚˜ ์บ์‹œ์—์„œ ๊ฐ€์ ธ์˜ต๋‹ˆ๋‹ค. + + Args: + checkpoint_dir: SQLite ์ฒดํฌํฌ์ธํŠธ ํŒŒ์ผ ์ €์žฅ ๋””๋ ‰ํ† ๋ฆฌ (None์ด๋ฉด ๊ธฐ๋ณธ ๊ฒฝ๋กœ) + use_memory: True๋ฉด ์ธ๋ฉ”๋ชจ๋ฆฌ ์ฒดํฌํฌ์ธํ„ฐ ์‚ฌ์šฉ (ํ…Œ์ŠคํŠธ์šฉ) + + Returns: + ์ฒดํฌํฌ์ธํ„ฐ ์ธ์Šคํ„ด์Šค + """ + if use_memory: + return MemorySaver() + + if checkpoint_dir is None: + base_dir = Path(__file__).parent.parent + checkpoint_dir = str(base_dir / "checkpoints") + + # ๋””๋ ‰ํ† ๋ฆฌ ์ƒ์„ฑ + checkpoint_path = Path(checkpoint_dir) + checkpoint_path.mkdir(parents=True, exist_ok=True) + + db_path = str(checkpoint_path / "langgraph_checkpoints.db") + + if db_path not in _checkpointer_cache: + _checkpointer_cache[db_path] = SqliteSaver.from_conn_string(db_path) + logger.info(f"Created checkpointer at: {db_path}") + + return _checkpointer_cache[db_path] + + +def generate_thread_id(image_path: str) -> str: + """ + ์ด๋ฏธ์ง€ ๊ฒฝ๋กœ ๊ธฐ๋ฐ˜์œผ๋กœ ๊ณ ์œ ํ•œ thread_id ์ƒ์„ฑ. + + Args: + image_path: ์ž…๋ ฅ ์ด๋ฏธ์ง€ ๊ฒฝ๋กœ + + Returns: + 8์ž๋ฆฌ ํ•ด์‹œ ๊ธฐ๋ฐ˜ thread_id + """ + import hashlib + path_hash = hashlib.md5(image_path.encode()).hexdigest()[:8] + return path_hash + + def run_synthetic_table_flow( image_path: str, *, @@ -787,6 +845,11 @@ def run_synthetic_table_flow( qa_only: bool = False, image_paths: List[str] | None = None, domain: str | None = None, + # ์ฒดํฌํฌ์ธํŒ… ์˜ต์…˜ + enable_checkpointing: bool = False, + thread_id: str | None = None, + checkpoint_dir: str | None = None, + resume: bool = False, ) -> TableState: """ Run the synthetic table generation flow. @@ -801,6 +864,10 @@ def run_synthetic_table_flow( qa_only: If True, skip synthetic data generation and only generate QA from image image_paths: Optional list of image paths for multi-image processing domain: Optional domain for prompt customization (e.g. 'public') + enable_checkpointing: ์ฒดํฌํฌ์ธํŒ… ํ™œ์„ฑํ™” ์—ฌ๋ถ€ + thread_id: ์ฒดํฌํฌ์ธํŠธ ์‹๋ณ„์ž (None์ด๋ฉด ์ด๋ฏธ์ง€ ๊ฒฝ๋กœ ๊ธฐ๋ฐ˜ ์ž๋™ ์ƒ์„ฑ) + checkpoint_dir: ์ฒดํฌํฌ์ธํŠธ ์ €์žฅ ๋””๋ ‰ํ† ๋ฆฌ + resume: True๋ฉด ๊ธฐ์กด ์ฒดํฌํฌ์ธํŠธ์—์„œ ์žฌ๊ฐœ ์‹œ๋„ Returns: Final TableState with results @@ -815,7 +882,33 @@ def run_synthetic_table_flow( config_path=config_path, ) - app = build_synthetic_table_graph(llm, provider=provider, qa_only=qa_only).compile() + graph = build_synthetic_table_graph(llm, provider=provider, qa_only=qa_only) + + # ์ฒดํฌํฌ์ธํŒ… ์„ค์ • + if enable_checkpointing: + checkpointer = get_checkpointer(checkpoint_dir) + app = graph.compile(checkpointer=checkpointer) + + # thread_id ์„ค์ • + if thread_id is None: + thread_id = generate_thread_id(image_path) + + run_config = {"configurable": {"thread_id": thread_id}} + logger.info(f"Checkpointing enabled with thread_id: {thread_id}") + + # ์žฌ๊ฐœ ๋ชจ๋“œ + if resume: + existing_state = get_checkpoint_state(thread_id, checkpoint_dir) + if existing_state: + logger.info(f"Resuming from checkpoint: {thread_id}") + # ๊ธฐ์กด ์ƒํƒœ์—์„œ ์žฌ๊ฐœ (invoke์— None ์ „๋‹ฌํ•˜๋ฉด ๋งˆ์ง€๋ง‰ ์ƒํƒœ์—์„œ ๊ณ„์†) + final_state: TableState = app.invoke(None, config=run_config) + return final_state + else: + logger.warning(f"No checkpoint found for thread_id: {thread_id}, starting fresh") + else: + app = graph.compile() + run_config = {} initial_state = { "image_path": image_path, @@ -827,7 +920,220 @@ def run_synthetic_table_flow( if domain: initial_state["domain"] = domain - final_state: TableState = app.invoke(initial_state) + final_state: TableState = app.invoke(initial_state, config=run_config if enable_checkpointing else None) + return final_state + + +def get_checkpoint_state( + thread_id: str, + checkpoint_dir: str | None = None, +) -> Optional[TableState]: + """ + ์ €์žฅ๋œ ์ฒดํฌํฌ์ธํŠธ ์ƒํƒœ๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. + + Args: + thread_id: ์ฒดํฌํฌ์ธํŠธ ์‹๋ณ„์ž + checkpoint_dir: ์ฒดํฌํฌ์ธํŠธ ์ €์žฅ ๋””๋ ‰ํ† ๋ฆฌ + + Returns: + ์ €์žฅ๋œ ์ƒํƒœ ๋˜๋Š” None + """ + try: + checkpointer = get_checkpointer(checkpoint_dir) + config = {"configurable": {"thread_id": thread_id}} + checkpoint = checkpointer.get(config) + + if checkpoint and "channel_values" in checkpoint: + return checkpoint["channel_values"] + return None + except Exception as e: + logger.warning(f"Failed to get checkpoint state: {e}") + return None + + +def list_checkpoints(checkpoint_dir: str | None = None) -> List[Dict[str, Any]]: + """ + ์ €์žฅ๋œ ๋ชจ๋“  ์ฒดํฌํฌ์ธํŠธ ๋ชฉ๋ก์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + + Args: + checkpoint_dir: ์ฒดํฌํฌ์ธํŠธ ์ €์žฅ ๋””๋ ‰ํ† ๋ฆฌ + + Returns: + ์ฒดํฌํฌ์ธํŠธ ์ •๋ณด ๋ฆฌ์ŠคํŠธ + """ + import sqlite3 + + if checkpoint_dir is None: + base_dir = Path(__file__).parent.parent + checkpoint_dir = str(base_dir / "checkpoints") + + db_path = Path(checkpoint_dir) / "langgraph_checkpoints.db" + + if not db_path.exists(): + return [] + + try: + conn = sqlite3.connect(str(db_path)) + cursor = conn.cursor() + + # LangGraph SQLite ์Šคํ‚ค๋งˆ์—์„œ ์ฒดํฌํฌ์ธํŠธ ์กฐํšŒ + cursor.execute(""" + SELECT DISTINCT thread_id, created_at, parent_id + FROM checkpoints + ORDER BY created_at DESC + """) + + checkpoints = [] + for row in cursor.fetchall(): + checkpoints.append({ + "thread_id": row[0], + "created_at": row[1], + "parent_id": row[2], + }) + + conn.close() + return checkpoints + except Exception as e: + logger.warning(f"Failed to list checkpoints: {e}") + return [] + + +def delete_checkpoint( + thread_id: str, + checkpoint_dir: str | None = None, +) -> bool: + """ + ํŠน์ • ์ฒดํฌํฌ์ธํŠธ๋ฅผ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค. + + Args: + thread_id: ์‚ญ์ œํ•  ์ฒดํฌํฌ์ธํŠธ์˜ thread_id + checkpoint_dir: ์ฒดํฌํฌ์ธํŠธ ์ €์žฅ ๋””๋ ‰ํ† ๋ฆฌ + + Returns: + ์‚ญ์ œ ์„ฑ๊ณต ์—ฌ๋ถ€ + """ + import sqlite3 + + if checkpoint_dir is None: + base_dir = Path(__file__).parent.parent + checkpoint_dir = str(base_dir / "checkpoints") + + db_path = Path(checkpoint_dir) / "langgraph_checkpoints.db" + + if not db_path.exists(): + return False + + try: + conn = sqlite3.connect(str(db_path)) + cursor = conn.cursor() + + cursor.execute("DELETE FROM checkpoints WHERE thread_id = ?", (thread_id,)) + cursor.execute("DELETE FROM writes WHERE thread_id = ?", (thread_id,)) + + deleted_count = cursor.rowcount + conn.commit() + conn.close() + + logger.info(f"Deleted checkpoint: {thread_id}") + return deleted_count > 0 + except Exception as e: + logger.error(f"Failed to delete checkpoint: {e}") + return False + + +def clear_all_checkpoints(checkpoint_dir: str | None = None) -> int: + """ + ๋ชจ๋“  ์ฒดํฌํฌ์ธํŠธ๋ฅผ ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค. + + Args: + checkpoint_dir: ์ฒดํฌํฌ์ธํŠธ ์ €์žฅ ๋””๋ ‰ํ† ๋ฆฌ + + Returns: + ์‚ญ์ œ๋œ ์ฒดํฌํฌ์ธํŠธ ์ˆ˜ + """ + import sqlite3 + + if checkpoint_dir is None: + base_dir = Path(__file__).parent.parent + checkpoint_dir = str(base_dir / "checkpoints") + + db_path = Path(checkpoint_dir) / "langgraph_checkpoints.db" + + if not db_path.exists(): + return 0 + + try: + conn = sqlite3.connect(str(db_path)) + cursor = conn.cursor() + + cursor.execute("SELECT COUNT(*) FROM checkpoints") + count = cursor.fetchone()[0] + + cursor.execute("DELETE FROM checkpoints") + cursor.execute("DELETE FROM writes") + + conn.commit() + conn.close() + + logger.info(f"Cleared {count} checkpoints") + return count + except Exception as e: + logger.error(f"Failed to clear checkpoints: {e}") + return 0 + + +def resume_from_checkpoint( + thread_id: str, + *, + provider: str = "openai", + model: str = "gpt-4o-mini", + temperature: float = 0.2, + base_url: str | None = None, + config_path: str | None = None, + qa_only: bool = False, + checkpoint_dir: str | None = None, +) -> Optional[TableState]: + """ + ์ฒดํฌํฌ์ธํŠธ์—์„œ ์‹คํ–‰์„ ์žฌ๊ฐœํ•ฉ๋‹ˆ๋‹ค. + + Args: + thread_id: ์žฌ๊ฐœํ•  ์ฒดํฌํฌ์ธํŠธ์˜ thread_id + provider: LLM provider + model: Model name + temperature: Sampling temperature + base_url: Custom base URL for vLLM + config_path: Config path for gemini_pool + qa_only: QA-only ๋ชจ๋“œ ์—ฌ๋ถ€ + checkpoint_dir: ์ฒดํฌํฌ์ธํŠธ ์ €์žฅ ๋””๋ ‰ํ† ๋ฆฌ + + Returns: + ์ตœ์ข… ์ƒํƒœ ๋˜๋Š” None (์ฒดํฌํฌ์ธํŠธ ์—†์Œ) + """ + # ๊ธฐ์กด ์ฒดํฌํฌ์ธํŠธ ํ™•์ธ + existing_state = get_checkpoint_state(thread_id, checkpoint_dir) + if not existing_state: + logger.error(f"No checkpoint found for thread_id: {thread_id}") + return None + + load_dotenv() + + llm = get_llm( + provider=provider, + model=model, + temperature=temperature, + base_url=base_url, + config_path=config_path, + ) + + graph = build_synthetic_table_graph(llm, provider=provider, qa_only=qa_only) + checkpointer = get_checkpointer(checkpoint_dir) + app = graph.compile(checkpointer=checkpointer) + + run_config = {"configurable": {"thread_id": thread_id}} + + logger.info(f"Resuming execution from checkpoint: {thread_id}") + final_state: TableState = app.invoke(None, config=run_config) + return final_state @@ -835,4 +1141,12 @@ def run_synthetic_table_flow( "TableState", "build_synthetic_table_graph", "run_synthetic_table_flow", + # ์ฒดํฌํฌ์ธํŒ… API + "get_checkpointer", + "generate_thread_id", + "get_checkpoint_state", + "list_checkpoints", + "delete_checkpoint", + "clear_all_checkpoints", + "resume_from_checkpoint", ] diff --git a/generate_synthetic_table/runner.py b/generate_synthetic_table/runner.py index b53e42b..52e266c 100644 --- a/generate_synthetic_table/runner.py +++ b/generate_synthetic_table/runner.py @@ -12,7 +12,15 @@ from dotenv import load_dotenv -from .flow import TableState, run_synthetic_table_flow +from .flow import ( + TableState, + run_synthetic_table_flow, + list_checkpoints, + get_checkpoint_state, + delete_checkpoint, + clear_all_checkpoints, + resume_from_checkpoint, +) try: from data_organizer import TableDataOrganizer @@ -115,6 +123,56 @@ def build_arg_parser() -> argparse.ArgumentParser: action="store_true", help="Enable sequential pair processing (0-1, 2-3...) for Public data. Overrides sampling.", ) + + # ์ฒดํฌํฌ์ธํŒ… ์˜ต์…˜ + checkpoint_group = parser.add_argument_group("Checkpointing Options") + checkpoint_group.add_argument( + "--checkpoint", + action="store_true", + help="Enable checkpointing for resumable execution", + ) + checkpoint_group.add_argument( + "--checkpoint-dir", + type=Path, + help="Directory to store checkpoint files (default: ./checkpoints)", + ) + checkpoint_group.add_argument( + "--thread-id", + help="Custom thread ID for checkpoint (auto-generated from image path if not provided)", + ) + checkpoint_group.add_argument( + "--resume", + action="store_true", + help="Resume from existing checkpoint if available", + ) + + # ์ฒดํฌํฌ์ธํŠธ ๊ด€๋ฆฌ ์„œ๋ธŒ์ปค๋งจ๋“œ + checkpoint_group.add_argument( + "--list-checkpoints", + action="store_true", + help="List all saved checkpoints and exit", + ) + checkpoint_group.add_argument( + "--show-checkpoint", + metavar="THREAD_ID", + help="Show details of a specific checkpoint and exit", + ) + checkpoint_group.add_argument( + "--delete-checkpoint", + metavar="THREAD_ID", + help="Delete a specific checkpoint and exit", + ) + checkpoint_group.add_argument( + "--clear-checkpoints", + action="store_true", + help="Delete all checkpoints and exit", + ) + checkpoint_group.add_argument( + "--resume-checkpoint", + metavar="THREAD_ID", + help="Resume execution from a specific checkpoint thread ID", + ) + return parser @@ -128,8 +186,31 @@ def run_flow_for_image( config_path: str | None = None, qa_only: bool = False, domain: str | None = None, + # ์ฒดํฌํฌ์ธํŒ… ์˜ต์…˜ + enable_checkpointing: bool = False, + thread_id: str | None = None, + checkpoint_dir: str | None = None, + resume: bool = False, ) -> TableState: - """Execute the synthetic table flow for a given image path.""" + """Execute the synthetic table flow for a given image path. + + Args: + image: Path to the input image or HTML file + provider: LLM provider + model: Model name + temperature: Sampling temperature + base_url: Custom base URL + config_path: Config path for gemini_pool + qa_only: Generate QA only without synthetic data + domain: Domain for prompt customization + enable_checkpointing: Enable checkpointing for resumable execution + thread_id: Custom thread ID for checkpoint + checkpoint_dir: Directory to store checkpoint files + resume: Resume from existing checkpoint if available + + Returns: + Final TableState with results + """ load_dotenv() @@ -154,6 +235,10 @@ def run_flow_for_image( config_path=config_path, qa_only=qa_only, domain=domain, + enable_checkpointing=enable_checkpointing, + thread_id=thread_id, + checkpoint_dir=checkpoint_dir, + resume=resume, ) @@ -186,11 +271,97 @@ def _filter_json_safe_state(state: TableState, *, html_paths: Iterable[tuple[str return payload -def run_with_args(args: argparse.Namespace) -> TableState | Dict: +def run_with_args(args: argparse.Namespace) -> TableState | Dict | None: """Run the flow using parsed CLI arguments and handle optional persistence.""" + # ์ฒดํฌํฌ์ธํŠธ ๋””๋ ‰ํ† ๋ฆฌ ์„ค์ • + checkpoint_dir = str(args.checkpoint_dir) if getattr(args, 'checkpoint_dir', None) else None + + # ======================================== + # ์ฒดํฌํฌ์ธํŠธ ๊ด€๋ฆฌ ๋ช…๋ น ์ฒ˜๋ฆฌ + # ======================================== + + # --list-checkpoints: ๋ชจ๋“  ์ฒดํฌํฌ์ธํŠธ ๋ชฉ๋ก ์ถœ๋ ฅ + if getattr(args, 'list_checkpoints', False): + checkpoints = list_checkpoints(checkpoint_dir) + if not checkpoints: + print("No checkpoints found.") + else: + print(f"Found {len(checkpoints)} checkpoint(s):\n") + print(f"{'Thread ID':<12} {'Created At':<25} {'Parent ID':<12}") + print("-" * 50) + for cp in checkpoints: + print(f"{cp['thread_id']:<12} {cp['created_at'] or 'N/A':<25} {cp['parent_id'] or 'N/A':<12}") + return None + + # --show-checkpoint: ํŠน์ • ์ฒดํฌํฌ์ธํŠธ ์ƒ์„ธ ์ •๋ณด + if getattr(args, 'show_checkpoint', None): + thread_id = args.show_checkpoint + state = get_checkpoint_state(thread_id, checkpoint_dir) + if state: + print(f"Checkpoint: {thread_id}\n") + print(f"Image Path: {state.get('image_path', 'N/A')}") + print(f"Attempts: {state.get('attempts', 0)}") + print(f"Passed: {state.get('passed', 'N/A')}") + print(f"Errors: {state.get('errors', [])}") + print(f"\nHas HTML Table: {bool(state.get('html_table'))}") + print(f"Has Synthetic Table: {bool(state.get('synthetic_table'))}") + print(f"Has QA Results: {bool(state.get('qa_results'))}") + if state.get('qa_results'): + print(f"QA Count: {len(state.get('qa_results', []))}") + else: + print(f"Checkpoint not found: {thread_id}") + return None + + # --delete-checkpoint: ํŠน์ • ์ฒดํฌํฌ์ธํŠธ ์‚ญ์ œ + if getattr(args, 'delete_checkpoint', None): + thread_id = args.delete_checkpoint + if delete_checkpoint(thread_id, checkpoint_dir): + print(f"โœ… Deleted checkpoint: {thread_id}") + else: + print(f"โŒ Failed to delete checkpoint: {thread_id}") + return None + + # --clear-checkpoints: ๋ชจ๋“  ์ฒดํฌํฌ์ธํŠธ ์‚ญ์ œ + if getattr(args, 'clear_checkpoints', False): + count = clear_all_checkpoints(checkpoint_dir) + print(f"โœ… Cleared {count} checkpoint(s)") + return None + + # --resume-checkpoint: ํŠน์ • ์ฒดํฌํฌ์ธํŠธ์—์„œ ์žฌ๊ฐœ + if getattr(args, 'resume_checkpoint', None): + thread_id = args.resume_checkpoint + print(f"Resuming from checkpoint: {thread_id}") + result = resume_from_checkpoint( + thread_id, + provider=args.provider, + model=args.model, + temperature=args.temperature, + base_url=args.base_url, + config_path=str(args.config_path) if args.config_path else None, + qa_only=getattr(args, 'qa_only', False), + checkpoint_dir=checkpoint_dir, + ) + if result: + print(f"โœ… Resumed and completed execution") + payload = _filter_json_safe_state(result, html_paths=[]) + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return result + else: + print(f"โŒ Failed to resume from checkpoint: {thread_id}") + return None + + # ======================================== + # ์ผ๋ฐ˜ ์‹คํ–‰ ํ๋ฆ„ + # ======================================== + input_path = args.image + # ์ฒดํฌํฌ์ธํŒ… ์˜ต์…˜ ์ถ”์ถœ + enable_checkpointing = getattr(args, 'checkpoint', False) + thread_id = getattr(args, 'thread_id', None) + resume = getattr(args, 'resume', False) + # Check if input is a folder -> batch processing if input_path.is_dir(): return run_batch_for_folder( @@ -207,9 +378,11 @@ def run_with_args(args: argparse.Namespace) -> TableState | Dict: min_k=getattr(args, 'min_k', 2), max_k=getattr(args, 'max_k', 3), num_samples=getattr(args, 'num_samples', 1), - domain=args.domain, pair_mode=getattr(args, 'pair_mode', False), + # ์ฒดํฌํฌ์ธํŒ… ์˜ต์…˜ + enable_checkpointing=enable_checkpointing, + checkpoint_dir=checkpoint_dir, ) # Single file processing @@ -222,6 +395,12 @@ def run_with_args(args: argparse.Namespace) -> TableState | Dict: domain = "insurance" print(f"Auto-detected domain: {domain}") + # ์ฒดํฌํฌ์ธํŒ… ํ™œ์„ฑํ™” ์‹œ ๋ฉ”์‹œ์ง€ ์ถœ๋ ฅ + if enable_checkpointing: + print(f"๐Ÿ”„ Checkpointing enabled" + (f" (thread_id: {thread_id})" if thread_id else "")) + if resume: + print(f" Resume mode: ON") + result = run_flow_for_image( input_path, provider=args.provider, @@ -231,6 +410,11 @@ def run_with_args(args: argparse.Namespace) -> TableState | Dict: config_path=str(args.config_path) if args.config_path else None, qa_only=getattr(args, 'qa_only', False), domain=domain, + # ์ฒดํฌํฌ์ธํŒ… ์˜ต์…˜ + enable_checkpointing=enable_checkpointing, + thread_id=thread_id, + checkpoint_dir=checkpoint_dir, + resume=resume, ) html_refs: list[tuple[str, Path | None]] = [] @@ -279,6 +463,9 @@ def run_batch_for_folder( num_samples: int = 1, domain: str | None = None, pair_mode: bool = False, + # ์ฒดํฌํฌ์ธํŒ… ์˜ต์…˜ + enable_checkpointing: bool = False, + checkpoint_dir: str | None = None, ) -> Dict[str, any]: """ Execute the flow for all images in a folder (batch processing). @@ -293,6 +480,14 @@ def run_batch_for_folder( qa_only: Generate QA only without synthetic data output_dir: Output directory for results max_workers: Number of parallel workers + sampling: Enable random sampling + min_k: Minimum images per sample + max_k: Maximum images per sample + num_samples: Number of samples per table + domain: Domain for prompt customization + pair_mode: Enable pair processing mode + enable_checkpointing: Enable checkpointing for each task + checkpoint_dir: Directory to store checkpoint files Returns: Summary dict with results and statistics @@ -328,6 +523,8 @@ def run_batch_for_folder( print(f"Using {max_workers} parallel workers") if pair_mode: print("Pair Mode: ENABLED (0-1, 2-3...)") + if enable_checkpointing: + print(f"๐Ÿ”„ Checkpointing: ENABLED (dir: {checkpoint_dir or 'default'})") print() # Try to organize data if using Data Organizer naming convention @@ -388,10 +585,13 @@ def process_task(task: Dict) -> Dict: """Process a task (single image or batch).""" images = task["images"] name = task["name"] - + # Primary image is the first one for naming/path purposes if needed primary_image_path = Path(images[0]) - + + # ๋ฐฐ์น˜ ์ฒ˜๋ฆฌ์—์„œ๋Š” task name์„ thread_id๋กœ ์‚ฌ์šฉ + task_thread_id = name if enable_checkpointing else None + try: result = run_synthetic_table_flow( image_path=str(primary_image_path), # Pass first image as primary "path" (legacy) @@ -403,6 +603,11 @@ def process_task(task: Dict) -> Dict: config_path=config_path, qa_only=qa_only, domain=domain, + # ์ฒดํฌํฌ์ธํŒ… ์˜ต์…˜ + enable_checkpointing=enable_checkpointing, + thread_id=task_thread_id, + checkpoint_dir=checkpoint_dir, + resume=True, # ๋ฐฐ์น˜์—์„œ๋Š” ํ•ญ์ƒ resume ์‹œ๋„ ) # Save individual result @@ -479,6 +684,8 @@ def process_task(task: Dict) -> Dict: "qa_only": qa_only, "provider": provider, "model": model, + "checkpointing_enabled": enable_checkpointing, + "checkpoint_dir": checkpoint_dir, "results": results, } diff --git a/polling_gemini/api_pool.py b/polling_gemini/api_pool.py index e8c59ae..10a7799 100644 --- a/polling_gemini/api_pool.py +++ b/polling_gemini/api_pool.py @@ -16,11 +16,7 @@ from google.api_core import exceptions as google_exceptions -# ๋กœ๊น… ์„ค์ • -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' -) +# ๋ชจ๋“ˆ ๋ ˆ๋ฒจ ๋กœ๊ฑฐ (์ „์—ญ ์„ค์ •์€ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์—์„œ ๊ด€๋ฆฌ) logger = logging.getLogger(__name__) @@ -286,16 +282,19 @@ async def agenerate_content( while attempts < max_attempts: current_key = self.api_keys[self.current_key_index] - + try: - # kwargs์—์„œ temperature ์ถ”์ถœ (์žˆ์œผ๋ฉด ์‚ฌ์šฉ, ์—†์œผ๋ฉด ์„ค์ •๊ฐ’ ์‚ฌ์šฉ) - temperature = kwargs.pop('temperature', self.settings.get('temperature', 0.7)) - + # kwargs ๋ณต์‚ฌ๋ณธ ์ƒ์„ฑ (์žฌ์‹œ๋„ ์‹œ ์›๋ณธ ์œ ์ง€) + call_kwargs = kwargs.copy() + + # call_kwargs์—์„œ temperature ์ถ”์ถœ (์žˆ์œผ๋ฉด ์‚ฌ์šฉ, ์—†์œผ๋ฉด ์„ค์ •๊ฐ’ ์‚ฌ์šฉ) + temperature = call_kwargs.pop('temperature', self.settings.get('temperature', 0.7)) + # generation_config ๊ตฌ์„ฑ generation_config = { 'temperature': temperature, } - generation_config.update(kwargs.pop('generation_config', {})) + generation_config.update(call_kwargs.pop('generation_config', {})) # ๋น„๋™๊ธฐ API ํ˜ธ์ถœ (๋™๊ธฐ ๋ฉ”์„œ๋“œ๋ฅผ asyncio๋กœ ๋ž˜ํ•‘) loop = asyncio.get_event_loop() From a2f4ec5bc65c80b0b0e76a25433e5ce1ffaadb01 Mon Sep 17 00:00:00 2001 From: seokhyun Seo Date: Tue, 6 Jan 2026 13:08:56 +0000 Subject: [PATCH 2/4] Add evaluation module and Claude runner script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add eval/ module for Table QA evaluation - dataset.py: Load QA data, create eval datasets (JSON/JSONL) - metrics.py: Evaluation metrics (EM, F1, BLEU, Contains Match) - inference.py: Inference clients (vLLM, OpenAI, Anthropic/Claude) - evaluate.py: Main evaluation pipeline - cli.py: CLI interface for prepare/run/evaluate commands - Add run_claude_public.sh for Claude Sonnet with public domain ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- eval/__init__.py | 53 +++++ eval/cli.py | 313 ++++++++++++++++++++++++++ eval/dataset.py | 298 +++++++++++++++++++++++++ eval/evaluate.py | 315 ++++++++++++++++++++++++++ eval/inference.py | 520 +++++++++++++++++++++++++++++++++++++++++++ eval/metrics.py | 351 +++++++++++++++++++++++++++++ run_claude_public.sh | 35 +++ 7 files changed, 1885 insertions(+) create mode 100644 eval/__init__.py create mode 100644 eval/cli.py create mode 100644 eval/dataset.py create mode 100644 eval/evaluate.py create mode 100644 eval/inference.py create mode 100644 eval/metrics.py create mode 100755 run_claude_public.sh diff --git a/eval/__init__.py b/eval/__init__.py new file mode 100644 index 0000000..65cc49f --- /dev/null +++ b/eval/__init__.py @@ -0,0 +1,53 @@ +""" +TableMagnifier Evaluation Module + +vLLM ๋ฐ ๊ธฐํƒ€ LLM ์„œ๋ฒ„์—์„œ Table QA ์„ฑ๋Šฅ์„ ํ‰๊ฐ€ํ•˜๊ธฐ ์œ„ํ•œ ๋ชจ๋“ˆ +""" + +from .dataset import ( + QAItem, + EvalDataset, + load_qa_from_file, + load_qa_from_folder, + create_eval_dataset, +) +from .metrics import ( + exact_match, + f1_score, + normalize_answer, + compute_metrics, + EvalResult, +) +from .inference import ( + InferenceClient, + VLLMClient, + OpenAIClient, + run_inference, +) +from .evaluate import ( + evaluate_predictions, + run_evaluation, +) + +__all__ = [ + # Dataset + "QAItem", + "EvalDataset", + "load_qa_from_file", + "load_qa_from_folder", + "create_eval_dataset", + # Metrics + "exact_match", + "f1_score", + "normalize_answer", + "compute_metrics", + "EvalResult", + # Inference + "InferenceClient", + "VLLMClient", + "OpenAIClient", + "run_inference", + # Evaluate + "evaluate_predictions", + "run_evaluation", +] diff --git a/eval/cli.py b/eval/cli.py new file mode 100644 index 0000000..b8407a5 --- /dev/null +++ b/eval/cli.py @@ -0,0 +1,313 @@ +""" +CLI for Table QA evaluation. + +Usage: + # ๋ฐ์ดํ„ฐ์…‹ ์ค€๋น„ + python -m eval.cli prepare ./data/qa_output --output ./eval_data.jsonl + + # vLLM ์„œ๋ฒ„๋กœ ํ‰๊ฐ€ + python -m eval.cli run ./data/qa_output --provider vllm --base-url http://localhost:8000/v1 + + # OpenAI API๋กœ ํ‰๊ฐ€ + python -m eval.cli run ./data/qa_output --provider openai --model gpt-4o-mini + + # ๊ธฐ์กด ์˜ˆ์ธก ๊ฒฐ๊ณผ ํ‰๊ฐ€ + python -m eval.cli evaluate ./predictions.json --output ./report.json +""" + +from __future__ import annotations + +import argparse +import asyncio +import logging +import sys +from pathlib import Path + +from .dataset import create_eval_dataset, load_qa_from_folder +from .evaluate import run_evaluation, evaluate_from_file + + +def setup_logging(verbose: bool = False): + """๋กœ๊น… ์„ค์ •""" + level = logging.DEBUG if verbose else logging.INFO + logging.basicConfig( + level=level, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + + +def cmd_prepare(args: argparse.Namespace) -> int: + """๋ฐ์ดํ„ฐ์…‹ ์ค€๋น„ ์ปค๋งจ๋“œ""" + source = Path(args.source) + output = Path(args.output) if args.output else source / "eval_dataset.jsonl" + format = "jsonl" if output.suffix == ".jsonl" else "json" + + print(f"Preparing evaluation dataset from: {source}") + + dataset = create_eval_dataset(source, output, format=format) + + print(f"\nDataset created: {output}") + print(f"Total items: {len(dataset)}") + print(f"Type distribution:") + for qa_type, count in sorted(dataset.get_type_distribution().items()): + print(f" {qa_type}: {count}") + + return 0 + + +def cmd_run(args: argparse.Namespace) -> int: + """ํ‰๊ฐ€ ์‹คํ–‰ ์ปค๋งจ๋“œ""" + source = Path(args.source) + output_dir = Path(args.output_dir) if args.output_dir else source / "eval_results" + + print(f"Running evaluation on: {source}") + print(f"Provider: {args.provider}") + print(f"Output directory: {output_dir}") + + # ํด๋ผ์ด์–ธํŠธ ์„ค์ • ๊ตฌ์„ฑ + client_kwargs = {} + + if args.provider == "vllm": + client_kwargs["base_url"] = args.base_url or "http://localhost:8000/v1" + client_kwargs["model"] = args.model or "default" + elif args.provider == "openai": + client_kwargs["model"] = args.model or "gpt-4o-mini" + if args.api_key: + client_kwargs["api_key"] = args.api_key + elif args.provider in ["anthropic", "claude"]: + client_kwargs["model"] = args.model or "claude-sonnet-4-20250514" + if args.api_key: + client_kwargs["api_key"] = args.api_key + + client_kwargs["max_tokens"] = args.max_tokens + client_kwargs["temperature"] = args.temperature + client_kwargs["max_concurrent"] = args.max_concurrent + + # ํ”„๋กฌํ”„ํŠธ ํ…œํ”Œ๋ฆฟ + prompt_template = None + if args.prompt_template: + prompt_template = Path(args.prompt_template).read_text(encoding="utf-8") + + # ๋น„๋™๊ธฐ ์‹คํ–‰ + async def run(): + return await run_evaluation( + data_source=source, + provider=args.provider, + output_dir=output_dir, + prompt_template=prompt_template, + include_images=args.include_images, + **client_kwargs, + ) + + results, aggregated = asyncio.run(run()) + + print(f"\nโœ… Evaluation complete!") + print(f"Results saved to: {output_dir}") + + return 0 + + +def cmd_evaluate(args: argparse.Namespace) -> int: + """์˜ˆ์ธก ๊ฒฐ๊ณผ ํ‰๊ฐ€ ์ปค๋งจ๋“œ""" + predictions_path = Path(args.predictions) + ground_truth_path = Path(args.ground_truth) if args.ground_truth else None + output_path = Path(args.output) if args.output else None + + print(f"Evaluating predictions from: {predictions_path}") + + results, aggregated = evaluate_from_file( + predictions_path, + ground_truth_path, + output_path, + ) + + if output_path: + print(f"\nโœ… Report saved to: {output_path}") + + return 0 + + +def cmd_info(args: argparse.Namespace) -> int: + """๋ฐ์ดํ„ฐ์…‹ ์ •๋ณด ์ถœ๋ ฅ ์ปค๋งจ๋“œ""" + source = Path(args.source) + + print(f"Loading dataset from: {source}") + + dataset = load_qa_from_folder(source) + + print(f"\nDataset Information:") + print(f" Total QA items: {len(dataset)}") + print(f" Source: {dataset.metadata.get('source_folder', 'N/A')}") + print(f" Files: {dataset.metadata.get('file_count', 'N/A')}") + + print(f"\nQA Type Distribution:") + for qa_type, count in sorted(dataset.get_type_distribution().items()): + pct = count / len(dataset) * 100 if len(dataset) > 0 else 0 + print(f" {qa_type:<20} {count:>5} ({pct:>5.1f}%)") + + # ์ƒ˜ํ”Œ ์ถœ๋ ฅ + if args.show_samples and len(dataset) > 0: + print(f"\nSample QA Items:") + for i, item in enumerate(dataset[:min(3, len(dataset))]): + print(f"\n [{i+1}] ID: {item.id}") + print(f" Type: {item.qa_type}") + print(f" Q: {item.question[:80]}...") + print(f" A: {item.answer[:80]}...") + + return 0 + + +def build_parser() -> argparse.ArgumentParser: + """CLI ํŒŒ์„œ ๊ตฌ์„ฑ""" + parser = argparse.ArgumentParser( + description="Table QA Evaluation Tool", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output") + + subparsers = parser.add_subparsers(dest="command", help="Available commands") + + # prepare ์ปค๋งจ๋“œ + prepare_parser = subparsers.add_parser( + "prepare", + help="Prepare evaluation dataset from QA output files", + ) + prepare_parser.add_argument( + "source", + type=str, + help="Path to QA output folder or file", + ) + prepare_parser.add_argument( + "-o", "--output", + type=str, + help="Output file path (default: source/eval_dataset.jsonl)", + ) + + # run ์ปค๋งจ๋“œ + run_parser = subparsers.add_parser( + "run", + help="Run full evaluation pipeline (inference + evaluation)", + ) + run_parser.add_argument( + "source", + type=str, + help="Path to QA output folder", + ) + run_parser.add_argument( + "--provider", + choices=["vllm", "openai", "anthropic", "claude"], + default="vllm", + help="Inference provider (default: vllm)", + ) + run_parser.add_argument( + "--base-url", + type=str, + help="Base URL for vLLM server (default: http://localhost:8000/v1)", + ) + run_parser.add_argument( + "--model", + type=str, + help="Model name", + ) + run_parser.add_argument( + "--api-key", + type=str, + help="API key (for OpenAI/Anthropic)", + ) + run_parser.add_argument( + "--max-tokens", + type=int, + default=512, + help="Max tokens for generation (default: 512)", + ) + run_parser.add_argument( + "--temperature", + type=float, + default=0.0, + help="Sampling temperature (default: 0.0)", + ) + run_parser.add_argument( + "--max-concurrent", + type=int, + default=10, + help="Max concurrent requests (default: 10)", + ) + run_parser.add_argument( + "--output-dir", + type=str, + help="Output directory for results", + ) + run_parser.add_argument( + "--prompt-template", + type=str, + help="Path to prompt template file", + ) + run_parser.add_argument( + "--include-images", + action="store_true", + help="Include images in prompts (for multimodal models)", + ) + + # evaluate ์ปค๋งจ๋“œ + eval_parser = subparsers.add_parser( + "evaluate", + help="Evaluate existing prediction results", + ) + eval_parser.add_argument( + "predictions", + type=str, + help="Path to predictions file (JSON or JSONL)", + ) + eval_parser.add_argument( + "--ground-truth", + type=str, + help="Path to ground truth file (if not in predictions)", + ) + eval_parser.add_argument( + "-o", "--output", + type=str, + help="Output report path", + ) + + # info ์ปค๋งจ๋“œ + info_parser = subparsers.add_parser( + "info", + help="Show dataset information", + ) + info_parser.add_argument( + "source", + type=str, + help="Path to QA output folder", + ) + info_parser.add_argument( + "--show-samples", + action="store_true", + help="Show sample QA items", + ) + + return parser + + +def main() -> int: + """๋ฉ”์ธ ์—”ํŠธ๋ฆฌํฌ์ธํŠธ""" + parser = build_parser() + args = parser.parse_args() + + setup_logging(args.verbose) + + if args.command is None: + parser.print_help() + return 1 + + commands = { + "prepare": cmd_prepare, + "run": cmd_run, + "evaluate": cmd_evaluate, + "info": cmd_info, + } + + return commands[args.command](args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/eval/dataset.py b/eval/dataset.py new file mode 100644 index 0000000..74a131b --- /dev/null +++ b/eval/dataset.py @@ -0,0 +1,298 @@ +""" +Dataset utilities for Table QA evaluation. + +QA ๋ฐ์ดํ„ฐ๋ฅผ ๋กœ๋“œํ•˜๊ณ  ํ‰๊ฐ€์šฉ ๋ฐ์ดํ„ฐ์…‹์œผ๋กœ ๋ณ€ํ™˜ํ•ฉ๋‹ˆ๋‹ค. +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass, field +from pathlib import Path +from typing import List, Dict, Any, Optional, Iterator + +logger = logging.getLogger(__name__) + + +@dataclass +class QAItem: + """๋‹จ์ผ QA ํ•ญ๋ชฉ""" + id: str + question: str + answer: str + qa_type: str + image_paths: List[str] = field(default_factory=list) + reasoning_annotation: Optional[str] = None + context: Optional[str] = None + source_file: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + return { + "id": self.id, + "question": self.question, + "answer": self.answer, + "type": self.qa_type, + "image_paths": self.image_paths, + "reasoning_annotation": self.reasoning_annotation, + "context": self.context, + "source_file": self.source_file, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any], id: str, image_paths: List[str] = None, source_file: str = None) -> "QAItem": + return cls( + id=id, + question=data.get("question", ""), + answer=data.get("answer", ""), + qa_type=data.get("type", "unknown"), + image_paths=image_paths or [], + reasoning_annotation=data.get("reasoning_annotation"), + context=data.get("context"), + source_file=source_file, + ) + + +@dataclass +class EvalDataset: + """ํ‰๊ฐ€์šฉ ๋ฐ์ดํ„ฐ์…‹""" + items: List[QAItem] = field(default_factory=list) + metadata: Dict[str, Any] = field(default_factory=dict) + + def __len__(self) -> int: + return len(self.items) + + def __iter__(self) -> Iterator[QAItem]: + return iter(self.items) + + def __getitem__(self, idx: int) -> QAItem: + return self.items[idx] + + def add(self, item: QAItem) -> None: + self.items.append(item) + + def filter_by_type(self, qa_type: str) -> "EvalDataset": + """ํŠน์ • QA ์œ ํ˜•๋งŒ ํ•„ํ„ฐ๋ง""" + filtered = [item for item in self.items if item.qa_type == qa_type] + return EvalDataset(items=filtered, metadata={**self.metadata, "filtered_by": qa_type}) + + def get_type_distribution(self) -> Dict[str, int]: + """QA ์œ ํ˜•๋ณ„ ๋ถ„ํฌ""" + dist = {} + for item in self.items: + dist[item.qa_type] = dist.get(item.qa_type, 0) + 1 + return dist + + def to_jsonl(self, path: Path) -> None: + """JSONL ํ˜•์‹์œผ๋กœ ์ €์žฅ (vLLM batch inference์šฉ)""" + with open(path, "w", encoding="utf-8") as f: + for item in self.items: + f.write(json.dumps(item.to_dict(), ensure_ascii=False) + "\n") + logger.info(f"Saved {len(self.items)} items to {path}") + + def to_json(self, path: Path) -> None: + """JSON ํ˜•์‹์œผ๋กœ ์ €์žฅ""" + data = { + "metadata": self.metadata, + "items": [item.to_dict() for item in self.items], + } + with open(path, "w", encoding="utf-8") as f: + json.dump(data, ensure_ascii=False, indent=2, fp=f) + logger.info(f"Saved {len(self.items)} items to {path}") + + @classmethod + def from_jsonl(cls, path: Path) -> "EvalDataset": + """JSONL ํŒŒ์ผ์—์„œ ๋กœ๋“œ""" + items = [] + with open(path, "r", encoding="utf-8") as f: + for line in f: + if line.strip(): + data = json.loads(line) + item = QAItem( + id=data["id"], + question=data["question"], + answer=data["answer"], + qa_type=data.get("type", "unknown"), + image_paths=data.get("image_paths", []), + reasoning_annotation=data.get("reasoning_annotation"), + context=data.get("context"), + source_file=data.get("source_file"), + ) + items.append(item) + return cls(items=items, metadata={"source": str(path)}) + + @classmethod + def from_json(cls, path: Path) -> "EvalDataset": + """JSON ํŒŒ์ผ์—์„œ ๋กœ๋“œ""" + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + + items = [] + for item_data in data.get("items", []): + item = QAItem( + id=item_data["id"], + question=item_data["question"], + answer=item_data["answer"], + qa_type=item_data.get("type", "unknown"), + image_paths=item_data.get("image_paths", []), + reasoning_annotation=item_data.get("reasoning_annotation"), + context=item_data.get("context"), + source_file=item_data.get("source_file"), + ) + items.append(item) + + return cls(items=items, metadata=data.get("metadata", {})) + + +def load_qa_from_file(file_path: Path) -> List[QAItem]: + """ + ๋‹จ์ผ QA ๊ฒฐ๊ณผ ํŒŒ์ผ์—์„œ QA ํ•ญ๋ชฉ๋“ค์„ ๋กœ๋“œํ•ฉ๋‹ˆ๋‹ค. + + Args: + file_path: *_qa.json ํŒŒ์ผ ๊ฒฝ๋กœ + + Returns: + QAItem ๋ฆฌ์ŠคํŠธ + """ + items = [] + + try: + with open(file_path, "r", encoding="utf-8") as f: + data = json.load(f) + + name = data.get("name", file_path.stem) + image_paths = data.get("image_paths", []) + qa_results = data.get("qa_results", []) + + for idx, qa in enumerate(qa_results): + item_id = f"{name}_{idx}" + item = QAItem.from_dict( + qa, + id=item_id, + image_paths=image_paths, + source_file=str(file_path), + ) + items.append(item) + + except Exception as e: + logger.warning(f"Failed to load {file_path}: {e}") + + return items + + +def load_qa_from_folder(folder_path: Path, pattern: str = "*_qa.json") -> EvalDataset: + """ + ํด๋”์—์„œ ๋ชจ๋“  QA ๊ฒฐ๊ณผ ํŒŒ์ผ์„ ๋กœ๋“œํ•ฉ๋‹ˆ๋‹ค. + + Args: + folder_path: qa_output ํด๋” ๊ฒฝ๋กœ + pattern: ํŒŒ์ผ ํŒจํ„ด (๊ธฐ๋ณธ: *_qa.json) + + Returns: + EvalDataset + """ + folder = Path(folder_path) + all_items = [] + + # ์žฌ๊ท€์ ์œผ๋กœ ๋ชจ๋“  QA ํŒŒ์ผ ์ฐพ๊ธฐ + qa_files = list(folder.rglob(pattern)) + logger.info(f"Found {len(qa_files)} QA files in {folder}") + + for qa_file in qa_files: + items = load_qa_from_file(qa_file) + all_items.extend(items) + + metadata = { + "source_folder": str(folder), + "file_count": len(qa_files), + "total_qa_count": len(all_items), + } + + return EvalDataset(items=all_items, metadata=metadata) + + +def create_eval_dataset( + source: str | Path, + output_path: Optional[Path] = None, + format: str = "jsonl", +) -> EvalDataset: + """ + ํ‰๊ฐ€์šฉ ๋ฐ์ดํ„ฐ์…‹์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. + + Args: + source: QA ํŒŒ์ผ ๋˜๋Š” ํด๋” ๊ฒฝ๋กœ + output_path: ์ €์žฅํ•  ๊ฒฝ๋กœ (None์ด๋ฉด ์ €์žฅํ•˜์ง€ ์•Š์Œ) + format: ์ €์žฅ ํ˜•์‹ (jsonl ๋˜๋Š” json) + + Returns: + EvalDataset + """ + source = Path(source) + + if source.is_file(): + items = load_qa_from_file(source) + dataset = EvalDataset( + items=items, + metadata={"source": str(source), "total_qa_count": len(items)} + ) + elif source.is_dir(): + dataset = load_qa_from_folder(source) + else: + raise ValueError(f"Invalid source: {source}") + + logger.info(f"Created dataset with {len(dataset)} items") + logger.info(f"Type distribution: {dataset.get_type_distribution()}") + + if output_path: + output_path = Path(output_path) + if format == "jsonl": + dataset.to_jsonl(output_path) + else: + dataset.to_json(output_path) + + return dataset + + +def create_inference_prompts( + dataset: EvalDataset, + prompt_template: Optional[str] = None, + include_image: bool = False, +) -> List[Dict[str, Any]]: + """ + ์ถ”๋ก ์šฉ ํ”„๋กฌํ”„ํŠธ๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. + + Args: + dataset: ํ‰๊ฐ€ ๋ฐ์ดํ„ฐ์…‹ + prompt_template: ํ”„๋กฌํ”„ํŠธ ํ…œํ”Œ๋ฆฟ (None์ด๋ฉด ๊ธฐ๋ณธ ํ…œํ”Œ๋ฆฟ ์‚ฌ์šฉ) + include_image: ์ด๋ฏธ์ง€ ๊ฒฝ๋กœ ํฌํ•จ ์—ฌ๋ถ€ + + Returns: + ์ถ”๋ก  ์š”์ฒญ ๋ฆฌ์ŠคํŠธ + """ + if prompt_template is None: + prompt_template = """๋‹ค์Œ ์งˆ๋ฌธ์— ๋Œ€ํ•ด ๊ฐ„๊ฒฐํ•˜๊ฒŒ ๋‹ต๋ณ€ํ•ด์ฃผ์„ธ์š”. + +์งˆ๋ฌธ: {question} + +๋‹ต๋ณ€:""" + + prompts = [] + for item in dataset: + prompt = prompt_template.format( + question=item.question, + context=item.context or "", + ) + + request = { + "id": item.id, + "prompt": prompt, + "ground_truth": item.answer, + "qa_type": item.qa_type, + } + + if include_image and item.image_paths: + request["image_paths"] = item.image_paths + + prompts.append(request) + + return prompts diff --git a/eval/evaluate.py b/eval/evaluate.py new file mode 100644 index 0000000..194d9c5 --- /dev/null +++ b/eval/evaluate.py @@ -0,0 +1,315 @@ +""" +Main evaluation logic for Table QA. + +์ถ”๋ก  ๊ฒฐ๊ณผ๋ฅผ ํ‰๊ฐ€ํ•˜๊ณ  ๋ฆฌํฌํŠธ๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from dataclasses import asdict +from datetime import datetime +from pathlib import Path +from typing import List, Dict, Any, Optional + +from .dataset import ( + EvalDataset, + QAItem, + load_qa_from_folder, + create_inference_prompts, +) +from .metrics import ( + compute_metrics, + aggregate_metrics, + EvalResult, + AggregatedMetrics, +) +from .inference import ( + InferenceClient, + InferenceRequest, + InferenceResponse, + get_client, + run_inference, +) + +logger = logging.getLogger(__name__) + + +def evaluate_predictions( + predictions: List[Dict[str, Any]], + ground_truths: Optional[List[Dict[str, Any]]] = None, +) -> tuple[List[EvalResult], AggregatedMetrics]: + """ + ์˜ˆ์ธก ๊ฒฐ๊ณผ๋ฅผ ํ‰๊ฐ€ํ•ฉ๋‹ˆ๋‹ค. + + Args: + predictions: ์˜ˆ์ธก ๊ฒฐ๊ณผ ๋ฆฌ์ŠคํŠธ + [{"id": "...", "prediction": "...", "ground_truth": "...", "qa_type": "..."}] + ground_truths: ์ •๋‹ต ๋ฆฌ์ŠคํŠธ (์˜ต์…˜, predictions์— ground_truth๊ฐ€ ์—†์„ ๋•Œ ์‚ฌ์šฉ) + + Returns: + (๊ฐœ๋ณ„ ๊ฒฐ๊ณผ ๋ฆฌ์ŠคํŠธ, ์ง‘๊ณ„ ๋ฉ”ํŠธ๋ฆญ) + """ + results = [] + + # ground_truths๊ฐ€ ๋ณ„๋„๋กœ ์ œ๊ณต๋œ ๊ฒฝ์šฐ ๋งคํ•‘ + gt_map = {} + if ground_truths: + gt_map = {gt["id"]: gt for gt in ground_truths} + + for pred in predictions: + pred_id = pred.get("id", "") + prediction = pred.get("prediction", "") + qa_type = pred.get("qa_type", "unknown") + + # ground_truth ์ฐพ๊ธฐ + if "ground_truth" in pred: + ground_truth = pred["ground_truth"] + elif pred_id in gt_map: + ground_truth = gt_map[pred_id].get("answer", "") + qa_type = gt_map[pred_id].get("type", qa_type) + else: + logger.warning(f"No ground truth found for {pred_id}") + ground_truth = "" + + result = compute_metrics( + prediction=prediction, + ground_truth=ground_truth, + qa_type=qa_type, + id=pred_id, + ) + results.append(result) + + aggregated = aggregate_metrics(results) + return results, aggregated + + +def load_predictions(path: Path) -> List[Dict[str, Any]]: + """ + ์˜ˆ์ธก ๊ฒฐ๊ณผ ํŒŒ์ผ์„ ๋กœ๋“œํ•ฉ๋‹ˆ๋‹ค. + + ์ง€์› ํ˜•์‹: + - JSON: {"responses": [...]} ๋˜๋Š” [...] + - JSONL: ๊ฐ ์ค„์ด ํ•˜๋‚˜์˜ ์˜ˆ์ธก + """ + path = Path(path) + + if path.suffix == ".jsonl": + predictions = [] + with open(path, "r", encoding="utf-8") as f: + for line in f: + if line.strip(): + predictions.append(json.loads(line)) + return predictions + + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + + if isinstance(data, list): + return data + elif isinstance(data, dict) and "responses" in data: + return data["responses"] + else: + raise ValueError(f"Unknown prediction file format: {path}") + + +def generate_report( + results: List[EvalResult], + aggregated: AggregatedMetrics, + metadata: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """ + ํ‰๊ฐ€ ๋ฆฌํฌํŠธ๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. + + Args: + results: ๊ฐœ๋ณ„ ํ‰๊ฐ€ ๊ฒฐ๊ณผ + aggregated: ์ง‘๊ณ„ ๋ฉ”ํŠธ๋ฆญ + metadata: ์ถ”๊ฐ€ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ + + Returns: + ๋ฆฌํฌํŠธ ๋”•์…”๋„ˆ๋ฆฌ + """ + report = { + "timestamp": datetime.now().isoformat(), + "metadata": metadata or {}, + "summary": aggregated.to_dict(), + "detailed_results": [r.to_dict() for r in results], + } + + return report + + +def print_report(aggregated: AggregatedMetrics) -> None: + """์ฝ˜์†”์— ํ‰๊ฐ€ ๊ฒฐ๊ณผ ์ถœ๋ ฅ""" + print("\n" + "=" * 60) + print(" Table QA Evaluation Results") + print("=" * 60) + + print(f"\nTotal Samples: {aggregated.total_count}") + print(f"\nOverall Metrics:") + print(f" Exact Match: {aggregated.exact_match_avg:.4f} ({aggregated.exact_match_avg * 100:.2f}%)") + print(f" F1 Score: {aggregated.f1_score_avg:.4f} ({aggregated.f1_score_avg * 100:.2f}%)") + print(f" Contains Match: {aggregated.contains_match_avg:.4f} ({aggregated.contains_match_avg * 100:.2f}%)") + print(f" BLEU Score: {aggregated.bleu_score_avg:.4f}") + + if aggregated.by_type: + print(f"\nMetrics by QA Type:") + print(f" {'Type':<20} {'Count':>6} {'EM':>8} {'F1':>8} {'CM':>8}") + print(" " + "-" * 52) + for qa_type, metrics in sorted(aggregated.by_type.items()): + print(f" {qa_type:<20} {metrics['count']:>6} " + f"{metrics['exact_match']:>8.4f} " + f"{metrics['f1_score']:>8.4f} " + f"{metrics['contains_match']:>8.4f}") + + print("\n" + "=" * 60) + + +async def run_evaluation( + data_source: str | Path, + provider: str = "vllm", + output_dir: Optional[Path] = None, + prompt_template: Optional[str] = None, + include_images: bool = False, + **client_kwargs, +) -> tuple[List[EvalResult], AggregatedMetrics]: + """ + ์ „์ฒด ํ‰๊ฐ€ ํŒŒ์ดํ”„๋ผ์ธ์„ ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค. + + Args: + data_source: QA ๋ฐ์ดํ„ฐ ํด๋” ๋˜๋Š” ํŒŒ์ผ ๊ฒฝ๋กœ + provider: ์ถ”๋ก  ์ œ๊ณต์ž (vllm, openai, anthropic) + output_dir: ๊ฒฐ๊ณผ ์ €์žฅ ๋””๋ ‰ํ† ๋ฆฌ + prompt_template: ํ”„๋กฌํ”„ํŠธ ํ…œํ”Œ๋ฆฟ (์˜ต์…˜) + include_images: ์ด๋ฏธ์ง€ ํฌํ•จ ์—ฌ๋ถ€ (๋ฉ€ํ‹ฐ๋ชจ๋‹ฌ) + **client_kwargs: ํด๋ผ์ด์–ธํŠธ ์„ค์ • + + Returns: + (๊ฐœ๋ณ„ ๊ฒฐ๊ณผ, ์ง‘๊ณ„ ๋ฉ”ํŠธ๋ฆญ) + """ + # 1. ๋ฐ์ดํ„ฐ์…‹ ๋กœ๋“œ + logger.info(f"Loading dataset from {data_source}...") + dataset = load_qa_from_folder(Path(data_source)) + logger.info(f"Loaded {len(dataset)} QA items") + logger.info(f"Type distribution: {dataset.get_type_distribution()}") + + # 2. ์ถ”๋ก  ์š”์ฒญ ์ƒ์„ฑ + prompts = create_inference_prompts( + dataset, + prompt_template=prompt_template, + include_image=include_images, + ) + + requests = [ + InferenceRequest( + id=p["id"], + prompt=p["prompt"], + ground_truth=p["ground_truth"], + qa_type=p["qa_type"], + image_paths=p.get("image_paths"), + ) + for p in prompts + ] + + # 3. ์ถ”๋ก  ํด๋ผ์ด์–ธํŠธ ์ƒ์„ฑ + client = get_client(provider, **client_kwargs) + + # 4. ์ถ”๋ก  ์‹คํ–‰ + inference_output = None + if output_dir: + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + inference_output = output_dir / "inference_results.json" + + responses = await run_inference( + client, + requests, + output_path=inference_output, + ) + + # 5. ํ‰๊ฐ€ + predictions = [ + { + "id": r.id, + "prediction": r.prediction, + "ground_truth": r.ground_truth, + "qa_type": r.qa_type, + } + for r in responses + ] + + results, aggregated = evaluate_predictions(predictions) + + # 6. ๋ฆฌํฌํŠธ ์ƒ์„ฑ ๋ฐ ์ €์žฅ + print_report(aggregated) + + if output_dir: + report = generate_report( + results, + aggregated, + metadata={ + "data_source": str(data_source), + "provider": provider, + "total_items": len(dataset), + "client_config": {k: str(v) for k, v in client_kwargs.items()}, + } + ) + + report_path = output_dir / "evaluation_report.json" + with open(report_path, "w", encoding="utf-8") as f: + json.dump(report, ensure_ascii=False, indent=2, fp=f) + logger.info(f"Saved evaluation report to {report_path}") + + return results, aggregated + + +def evaluate_from_file( + predictions_path: Path, + ground_truth_path: Optional[Path] = None, + output_path: Optional[Path] = None, +) -> tuple[List[EvalResult], AggregatedMetrics]: + """ + ํŒŒ์ผ์—์„œ ์˜ˆ์ธก ๊ฒฐ๊ณผ๋ฅผ ๋กœ๋“œํ•˜์—ฌ ํ‰๊ฐ€ํ•ฉ๋‹ˆ๋‹ค. + + Args: + predictions_path: ์˜ˆ์ธก ๊ฒฐ๊ณผ ํŒŒ์ผ ๊ฒฝ๋กœ + ground_truth_path: ์ •๋‹ต ํŒŒ์ผ ๊ฒฝ๋กœ (์˜ต์…˜) + output_path: ๋ฆฌํฌํŠธ ์ €์žฅ ๊ฒฝ๋กœ (์˜ต์…˜) + + Returns: + (๊ฐœ๋ณ„ ๊ฒฐ๊ณผ, ์ง‘๊ณ„ ๋ฉ”ํŠธ๋ฆญ) + """ + # ์˜ˆ์ธก ๋กœ๋“œ + predictions = load_predictions(predictions_path) + logger.info(f"Loaded {len(predictions)} predictions from {predictions_path}") + + # ์ •๋‹ต ๋กœ๋“œ (ํ•„์š”์‹œ) + ground_truths = None + if ground_truth_path: + ground_truths = load_predictions(ground_truth_path) + logger.info(f"Loaded {len(ground_truths)} ground truths from {ground_truth_path}") + + # ํ‰๊ฐ€ + results, aggregated = evaluate_predictions(predictions, ground_truths) + + # ๊ฒฐ๊ณผ ์ถœ๋ ฅ + print_report(aggregated) + + # ๋ฆฌํฌํŠธ ์ €์žฅ + if output_path: + report = generate_report( + results, + aggregated, + metadata={ + "predictions_file": str(predictions_path), + "ground_truth_file": str(ground_truth_path) if ground_truth_path else None, + } + ) + + with open(output_path, "w", encoding="utf-8") as f: + json.dump(report, ensure_ascii=False, indent=2, fp=f) + logger.info(f"Saved evaluation report to {output_path}") + + return results, aggregated diff --git a/eval/inference.py b/eval/inference.py new file mode 100644 index 0000000..5bde766 --- /dev/null +++ b/eval/inference.py @@ -0,0 +1,520 @@ +""" +Inference clients for Table QA evaluation. + +vLLM, OpenAI API, ๋ฐ ๊ธฐํƒ€ LLM ์„œ๋ฒ„์—์„œ ์ถ”๋ก ์„ ์ˆ˜ํ–‰ํ•ฉ๋‹ˆ๋‹ค. +""" + +from __future__ import annotations + +import asyncio +import base64 +import json +import logging +import time +from abc import ABC, abstractmethod +from dataclasses import dataclass +from pathlib import Path +from typing import List, Dict, Any, Optional + +import httpx + +logger = logging.getLogger(__name__) + + +@dataclass +class InferenceRequest: + """์ถ”๋ก  ์š”์ฒญ""" + id: str + prompt: str + ground_truth: str + qa_type: str + image_paths: Optional[List[str]] = None + + +@dataclass +class InferenceResponse: + """์ถ”๋ก  ์‘๋‹ต""" + id: str + prediction: str + ground_truth: str + qa_type: str + latency_ms: float + error: Optional[str] = None + + +class InferenceClient(ABC): + """์ถ”๋ก  ํด๋ผ์ด์–ธํŠธ ๋ฒ ์ด์Šค ํด๋ž˜์Šค""" + + @abstractmethod + async def generate(self, prompt: str, **kwargs) -> str: + """๋‹จ์ผ ํ”„๋กฌํ”„ํŠธ์— ๋Œ€ํ•œ ์‘๋‹ต ์ƒ์„ฑ""" + pass + + @abstractmethod + async def generate_batch( + self, + requests: List[InferenceRequest], + **kwargs + ) -> List[InferenceResponse]: + """๋ฐฐ์น˜ ์ถ”๋ก """ + pass + + +class VLLMClient(InferenceClient): + """ + vLLM OpenAI-compatible API ํด๋ผ์ด์–ธํŠธ + + Usage: + client = VLLMClient(base_url="http://localhost:8000/v1") + responses = await client.generate_batch(requests) + """ + + def __init__( + self, + base_url: str = "http://localhost:8000/v1", + model: str = "default", + api_key: str = "EMPTY", + max_tokens: int = 512, + temperature: float = 0.0, + timeout: float = 60.0, + max_concurrent: int = 10, + ): + self.base_url = base_url.rstrip("/") + self.model = model + self.api_key = api_key + self.max_tokens = max_tokens + self.temperature = temperature + self.timeout = timeout + self.max_concurrent = max_concurrent + + self.headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}", + } + + async def generate(self, prompt: str, **kwargs) -> str: + """๋‹จ์ผ ํ”„๋กฌํ”„ํŠธ์— ๋Œ€ํ•œ ์‘๋‹ต ์ƒ์„ฑ""" + async with httpx.AsyncClient(timeout=self.timeout) as client: + payload = { + "model": kwargs.get("model", self.model), + "messages": [{"role": "user", "content": prompt}], + "max_tokens": kwargs.get("max_tokens", self.max_tokens), + "temperature": kwargs.get("temperature", self.temperature), + } + + response = await client.post( + f"{self.base_url}/chat/completions", + json=payload, + headers=self.headers, + ) + response.raise_for_status() + data = response.json() + + return data["choices"][0]["message"]["content"] + + async def generate_batch( + self, + requests: List[InferenceRequest], + show_progress: bool = True, + **kwargs + ) -> List[InferenceResponse]: + """๋ฐฐ์น˜ ์ถ”๋ก  (๋™์‹œ์„ฑ ์ œ์–ด)""" + semaphore = asyncio.Semaphore(self.max_concurrent) + responses = [] + + async def process_one(req: InferenceRequest, idx: int) -> InferenceResponse: + async with semaphore: + start_time = time.time() + error = None + prediction = "" + + try: + prediction = await self.generate(req.prompt, **kwargs) + except Exception as e: + error = str(e) + logger.warning(f"Request {req.id} failed: {e}") + + latency = (time.time() - start_time) * 1000 + + if show_progress: + logger.info(f"[{idx+1}/{len(requests)}] {req.id} - {latency:.0f}ms") + + return InferenceResponse( + id=req.id, + prediction=prediction, + ground_truth=req.ground_truth, + qa_type=req.qa_type, + latency_ms=latency, + error=error, + ) + + tasks = [process_one(req, i) for i, req in enumerate(requests)] + responses = await asyncio.gather(*tasks) + + return list(responses) + + +class OpenAIClient(InferenceClient): + """ + OpenAI API ํด๋ผ์ด์–ธํŠธ (GPT-4 ๋“ฑ) + + Usage: + client = OpenAIClient(api_key="sk-...") + responses = await client.generate_batch(requests) + """ + + def __init__( + self, + api_key: Optional[str] = None, + model: str = "gpt-4o-mini", + max_tokens: int = 512, + temperature: float = 0.0, + timeout: float = 60.0, + max_concurrent: int = 5, + ): + import os + self.api_key = api_key or os.getenv("OPENAI_API_KEY") + self.model = model + self.max_tokens = max_tokens + self.temperature = temperature + self.timeout = timeout + self.max_concurrent = max_concurrent + + if not self.api_key: + raise ValueError("OpenAI API key is required") + + self.base_url = "https://api.openai.com/v1" + self.headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.api_key}", + } + + async def generate( + self, + prompt: str, + image_paths: Optional[List[str]] = None, + **kwargs + ) -> str: + """๋‹จ์ผ ํ”„๋กฌํ”„ํŠธ์— ๋Œ€ํ•œ ์‘๋‹ต ์ƒ์„ฑ (์ด๋ฏธ์ง€ ์ง€์›)""" + async with httpx.AsyncClient(timeout=self.timeout) as client: + content = [{"type": "text", "text": prompt}] + + # ์ด๋ฏธ์ง€ ์ถ”๊ฐ€ (๋ฉ€ํ‹ฐ๋ชจ๋‹ฌ) + if image_paths: + for img_path in image_paths: + img_data = self._encode_image(img_path) + if img_data: + content.append({ + "type": "image_url", + "image_url": {"url": img_data} + }) + + payload = { + "model": kwargs.get("model", self.model), + "messages": [{"role": "user", "content": content}], + "max_tokens": kwargs.get("max_tokens", self.max_tokens), + "temperature": kwargs.get("temperature", self.temperature), + } + + response = await client.post( + f"{self.base_url}/chat/completions", + json=payload, + headers=self.headers, + ) + response.raise_for_status() + data = response.json() + + return data["choices"][0]["message"]["content"] + + def _encode_image(self, image_path: str) -> Optional[str]: + """์ด๋ฏธ์ง€๋ฅผ base64๋กœ ์ธ์ฝ”๋”ฉ""" + try: + path = Path(image_path) + if not path.exists(): + return None + + mime = "image/png" + suffix = path.suffix.lower() + if suffix in {".jpg", ".jpeg"}: + mime = "image/jpeg" + elif suffix == ".webp": + mime = "image/webp" + + encoded = base64.b64encode(path.read_bytes()).decode("utf-8") + return f"data:{mime};base64,{encoded}" + except Exception as e: + logger.warning(f"Failed to encode image {image_path}: {e}") + return None + + async def generate_batch( + self, + requests: List[InferenceRequest], + show_progress: bool = True, + **kwargs + ) -> List[InferenceResponse]: + """๋ฐฐ์น˜ ์ถ”๋ก """ + semaphore = asyncio.Semaphore(self.max_concurrent) + responses = [] + + async def process_one(req: InferenceRequest, idx: int) -> InferenceResponse: + async with semaphore: + start_time = time.time() + error = None + prediction = "" + + try: + prediction = await self.generate( + req.prompt, + image_paths=req.image_paths, + **kwargs + ) + except Exception as e: + error = str(e) + logger.warning(f"Request {req.id} failed: {e}") + + latency = (time.time() - start_time) * 1000 + + if show_progress: + logger.info(f"[{idx+1}/{len(requests)}] {req.id} - {latency:.0f}ms") + + return InferenceResponse( + id=req.id, + prediction=prediction, + ground_truth=req.ground_truth, + qa_type=req.qa_type, + latency_ms=latency, + error=error, + ) + + tasks = [process_one(req, i) for i, req in enumerate(requests)] + responses = await asyncio.gather(*tasks) + + return list(responses) + + +class AnthropicClient(InferenceClient): + """ + Anthropic Claude API ํด๋ผ์ด์–ธํŠธ + + Usage: + client = AnthropicClient(api_key="sk-ant-...") + responses = await client.generate_batch(requests) + """ + + def __init__( + self, + api_key: Optional[str] = None, + model: str = "claude-sonnet-4-20250514", + max_tokens: int = 512, + temperature: float = 0.0, + timeout: float = 60.0, + max_concurrent: int = 5, + ): + import os + self.api_key = api_key or os.getenv("ANTHROPIC_API_KEY") + self.model = model + self.max_tokens = max_tokens + self.temperature = temperature + self.timeout = timeout + self.max_concurrent = max_concurrent + + if not self.api_key: + raise ValueError("Anthropic API key is required") + + self.base_url = "https://api.anthropic.com/v1" + self.headers = { + "Content-Type": "application/json", + "x-api-key": self.api_key, + "anthropic-version": "2023-06-01", + } + + async def generate( + self, + prompt: str, + image_paths: Optional[List[str]] = None, + **kwargs + ) -> str: + """๋‹จ์ผ ํ”„๋กฌํ”„ํŠธ์— ๋Œ€ํ•œ ์‘๋‹ต ์ƒ์„ฑ""" + async with httpx.AsyncClient(timeout=self.timeout) as client: + content = [] + + # ์ด๋ฏธ์ง€ ์ถ”๊ฐ€ + if image_paths: + for img_path in image_paths: + img_data = self._encode_image(img_path) + if img_data: + content.append(img_data) + + content.append({"type": "text", "text": prompt}) + + payload = { + "model": kwargs.get("model", self.model), + "messages": [{"role": "user", "content": content}], + "max_tokens": kwargs.get("max_tokens", self.max_tokens), + "temperature": kwargs.get("temperature", self.temperature), + } + + response = await client.post( + f"{self.base_url}/messages", + json=payload, + headers=self.headers, + ) + response.raise_for_status() + data = response.json() + + return data["content"][0]["text"] + + def _encode_image(self, image_path: str) -> Optional[Dict[str, Any]]: + """์ด๋ฏธ์ง€๋ฅผ Anthropic ํ˜•์‹์œผ๋กœ ์ธ์ฝ”๋”ฉ""" + try: + path = Path(image_path) + if not path.exists(): + return None + + media_type = "image/png" + suffix = path.suffix.lower() + if suffix in {".jpg", ".jpeg"}: + media_type = "image/jpeg" + elif suffix == ".webp": + media_type = "image/webp" + elif suffix == ".gif": + media_type = "image/gif" + + encoded = base64.b64encode(path.read_bytes()).decode("utf-8") + return { + "type": "image", + "source": { + "type": "base64", + "media_type": media_type, + "data": encoded, + } + } + except Exception as e: + logger.warning(f"Failed to encode image {image_path}: {e}") + return None + + async def generate_batch( + self, + requests: List[InferenceRequest], + show_progress: bool = True, + **kwargs + ) -> List[InferenceResponse]: + """๋ฐฐ์น˜ ์ถ”๋ก """ + semaphore = asyncio.Semaphore(self.max_concurrent) + + async def process_one(req: InferenceRequest, idx: int) -> InferenceResponse: + async with semaphore: + start_time = time.time() + error = None + prediction = "" + + try: + prediction = await self.generate( + req.prompt, + image_paths=req.image_paths, + **kwargs + ) + except Exception as e: + error = str(e) + logger.warning(f"Request {req.id} failed: {e}") + + latency = (time.time() - start_time) * 1000 + + if show_progress: + logger.info(f"[{idx+1}/{len(requests)}] {req.id} - {latency:.0f}ms") + + return InferenceResponse( + id=req.id, + prediction=prediction, + ground_truth=req.ground_truth, + qa_type=req.qa_type, + latency_ms=latency, + error=error, + ) + + tasks = [process_one(req, i) for i, req in enumerate(requests)] + responses = await asyncio.gather(*tasks) + + return list(responses) + + +def get_client( + provider: str, + **kwargs +) -> InferenceClient: + """ + ์ œ๊ณต์ž์— ๋”ฐ๋ฅธ ํด๋ผ์ด์–ธํŠธ ์ƒ์„ฑ + + Args: + provider: "vllm", "openai", "anthropic" + **kwargs: ํด๋ผ์ด์–ธํŠธ๋ณ„ ์„ค์ • + + Returns: + InferenceClient ์ธ์Šคํ„ด์Šค + """ + provider = provider.lower() + + if provider == "vllm": + return VLLMClient(**kwargs) + elif provider == "openai": + return OpenAIClient(**kwargs) + elif provider in ["anthropic", "claude"]: + return AnthropicClient(**kwargs) + else: + raise ValueError(f"Unknown provider: {provider}") + + +async def run_inference( + client: InferenceClient, + requests: List[InferenceRequest], + output_path: Optional[Path] = None, + **kwargs +) -> List[InferenceResponse]: + """ + ์ถ”๋ก ์„ ์‹คํ–‰ํ•˜๊ณ  ๊ฒฐ๊ณผ๋ฅผ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค. + + Args: + client: ์ถ”๋ก  ํด๋ผ์ด์–ธํŠธ + requests: ์ถ”๋ก  ์š”์ฒญ ๋ฆฌ์ŠคํŠธ + output_path: ๊ฒฐ๊ณผ ์ €์žฅ ๊ฒฝ๋กœ (์˜ต์…˜) + **kwargs: ์ถ”๊ฐ€ ์„ค์ • + + Returns: + InferenceResponse ๋ฆฌ์ŠคํŠธ + """ + logger.info(f"Running inference on {len(requests)} requests...") + + responses = await client.generate_batch(requests, **kwargs) + + # ํ†ต๊ณ„ + success_count = sum(1 for r in responses if r.error is None) + avg_latency = sum(r.latency_ms for r in responses) / len(responses) if responses else 0 + + logger.info(f"Completed: {success_count}/{len(responses)} successful") + logger.info(f"Average latency: {avg_latency:.0f}ms") + + # ๊ฒฐ๊ณผ ์ €์žฅ + if output_path: + output_data = { + "total": len(responses), + "success": success_count, + "avg_latency_ms": avg_latency, + "responses": [ + { + "id": r.id, + "prediction": r.prediction, + "ground_truth": r.ground_truth, + "qa_type": r.qa_type, + "latency_ms": r.latency_ms, + "error": r.error, + } + for r in responses + ], + } + + with open(output_path, "w", encoding="utf-8") as f: + json.dump(output_data, ensure_ascii=False, indent=2, fp=f) + + logger.info(f"Saved results to {output_path}") + + return responses diff --git a/eval/metrics.py b/eval/metrics.py new file mode 100644 index 0000000..2135814 --- /dev/null +++ b/eval/metrics.py @@ -0,0 +1,351 @@ +""" +Evaluation metrics for Table QA. + +๋‹ค์–‘ํ•œ ํ‰๊ฐ€ ๋ฉ”ํŠธ๋ฆญ์„ ๊ณ„์‚ฐํ•ฉ๋‹ˆ๋‹ค: +- Exact Match (EM) +- F1 Score (token-level) +- BLEU Score +- Contains Match (ํฌํ•จ ์—ฌ๋ถ€) +""" + +from __future__ import annotations + +import re +import string +import logging +from dataclasses import dataclass, field +from typing import List, Dict, Any, Optional +from collections import Counter + +logger = logging.getLogger(__name__) + + +def normalize_answer(text: str) -> str: + """ + ์ •๋‹ต ํ…์ŠคํŠธ๋ฅผ ์ •๊ทœํ™”ํ•ฉ๋‹ˆ๋‹ค. + + - ์†Œ๋ฌธ์ž ๋ณ€ํ™˜ + - ๊ตฌ๋‘์  ์ œ๊ฑฐ + - ๊ด€์‚ฌ ์ œ๊ฑฐ (์˜์–ด) + - ๊ณต๋ฐฑ ์ •๊ทœํ™” + - ํ•œ๊ตญ์–ด ์กฐ์‚ฌ ์ฒ˜๋ฆฌ + """ + if not text: + return "" + + text = str(text).lower() + + # ๊ตฌ๋‘์  ์ œ๊ฑฐ + text = text.translate(str.maketrans("", "", string.punctuation)) + + # ์˜์–ด ๊ด€์‚ฌ ์ œ๊ฑฐ + text = re.sub(r"\b(a|an|the)\b", " ", text) + + # ํ•œ๊ตญ์–ด ์ข…๊ฒฐ์–ด๋ฏธ ์ •๊ทœํ™” + text = re.sub(r"(์ž…๋‹ˆ๋‹ค|์Šต๋‹ˆ๋‹ค|๋‹ˆ๋‹ค|์˜ˆ์š”|์ด์—์š”|์—์š”)\.?$", "", text) + text = re.sub(r"(์ด๋‹ค|๋‹ค)\.?$", "", text) + + # ๊ณต๋ฐฑ ์ •๊ทœํ™” + text = " ".join(text.split()) + + return text.strip() + + +def get_tokens(text: str) -> List[str]: + """ํ…์ŠคํŠธ๋ฅผ ํ† ํฐ์œผ๋กœ ๋ถ„๋ฆฌ (๊ณต๋ฐฑ ๊ธฐ๋ฐ˜)""" + normalized = normalize_answer(text) + return normalized.split() if normalized else [] + + +def exact_match(prediction: str, ground_truth: str) -> float: + """ + Exact Match ์ ์ˆ˜๋ฅผ ๊ณ„์‚ฐํ•ฉ๋‹ˆ๋‹ค. + + Args: + prediction: ์˜ˆ์ธก ๋‹ต๋ณ€ + ground_truth: ์ •๋‹ต + + Returns: + 1.0 (์ผ์น˜) ๋˜๋Š” 0.0 (๋ถˆ์ผ์น˜) + """ + return float(normalize_answer(prediction) == normalize_answer(ground_truth)) + + +def contains_match(prediction: str, ground_truth: str) -> float: + """ + ์ •๋‹ต์ด ์˜ˆ์ธก์— ํฌํ•จ๋˜์–ด ์žˆ๋Š”์ง€ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค. + + Args: + prediction: ์˜ˆ์ธก ๋‹ต๋ณ€ + ground_truth: ์ •๋‹ต + + Returns: + 1.0 (ํฌํ•จ) ๋˜๋Š” 0.0 (๋ฏธํฌํ•จ) + """ + pred_norm = normalize_answer(prediction) + gt_norm = normalize_answer(ground_truth) + return float(gt_norm in pred_norm or pred_norm in gt_norm) + + +def f1_score(prediction: str, ground_truth: str) -> float: + """ + Token-level F1 ์ ์ˆ˜๋ฅผ ๊ณ„์‚ฐํ•ฉ๋‹ˆ๋‹ค. + + Args: + prediction: ์˜ˆ์ธก ๋‹ต๋ณ€ + ground_truth: ์ •๋‹ต + + Returns: + F1 ์ ์ˆ˜ (0.0 ~ 1.0) + """ + pred_tokens = get_tokens(prediction) + gt_tokens = get_tokens(ground_truth) + + if not pred_tokens and not gt_tokens: + return 1.0 + if not pred_tokens or not gt_tokens: + return 0.0 + + common = Counter(pred_tokens) & Counter(gt_tokens) + num_common = sum(common.values()) + + if num_common == 0: + return 0.0 + + precision = num_common / len(pred_tokens) + recall = num_common / len(gt_tokens) + f1 = (2 * precision * recall) / (precision + recall) + + return f1 + + +def bleu_score(prediction: str, ground_truth: str, max_n: int = 4) -> float: + """ + ๊ฐ„๋‹จํ•œ BLEU ์ ์ˆ˜๋ฅผ ๊ณ„์‚ฐํ•ฉ๋‹ˆ๋‹ค. + + Args: + prediction: ์˜ˆ์ธก ๋‹ต๋ณ€ + ground_truth: ์ •๋‹ต + max_n: ์ตœ๋Œ€ n-gram ํฌ๊ธฐ + + Returns: + BLEU ์ ์ˆ˜ (0.0 ~ 1.0) + """ + pred_tokens = get_tokens(prediction) + gt_tokens = get_tokens(ground_truth) + + if not pred_tokens or not gt_tokens: + return 0.0 + + # Brevity penalty + bp = min(1.0, len(pred_tokens) / len(gt_tokens)) if gt_tokens else 0.0 + + # N-gram precision + precisions = [] + for n in range(1, min(max_n + 1, len(pred_tokens) + 1)): + pred_ngrams = Counter( + tuple(pred_tokens[i:i+n]) for i in range(len(pred_tokens) - n + 1) + ) + gt_ngrams = Counter( + tuple(gt_tokens[i:i+n]) for i in range(len(gt_tokens) - n + 1) + ) + + matches = sum((pred_ngrams & gt_ngrams).values()) + total = sum(pred_ngrams.values()) + + if total > 0: + precisions.append(matches / total) + else: + precisions.append(0.0) + + if not precisions or all(p == 0 for p in precisions): + return 0.0 + + # Geometric mean of precisions + import math + log_precisions = [math.log(p) if p > 0 else -float('inf') for p in precisions] + avg_log_precision = sum(log_precisions) / len(log_precisions) + + if avg_log_precision == -float('inf'): + return 0.0 + + bleu = bp * math.exp(avg_log_precision) + return bleu + + +def numeric_match(prediction: str, ground_truth: str, tolerance: float = 0.01) -> float: + """ + ์ˆซ์ž ๊ฐ’์˜ ์ผ์น˜ ์—ฌ๋ถ€๋ฅผ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค. + + Args: + prediction: ์˜ˆ์ธก ๋‹ต๋ณ€ + ground_truth: ์ •๋‹ต + tolerance: ํ—ˆ์šฉ ์˜ค์ฐจ (์ƒ๋Œ€์ ) + + Returns: + 1.0 (์ผ์น˜) ๋˜๋Š” 0.0 (๋ถˆ์ผ์น˜) + """ + # ์ˆซ์ž ์ถ”์ถœ + pred_numbers = re.findall(r'[\d,]+\.?\d*', prediction.replace(',', '')) + gt_numbers = re.findall(r'[\d,]+\.?\d*', ground_truth.replace(',', '')) + + if not pred_numbers or not gt_numbers: + return 0.0 + + try: + pred_val = float(pred_numbers[0].replace(',', '')) + gt_val = float(gt_numbers[0].replace(',', '')) + + if gt_val == 0: + return float(pred_val == 0) + + rel_diff = abs(pred_val - gt_val) / abs(gt_val) + return float(rel_diff <= tolerance) + except (ValueError, IndexError): + return 0.0 + + +@dataclass +class EvalResult: + """๋‹จ์ผ ํ‰๊ฐ€ ๊ฒฐ๊ณผ""" + id: str + prediction: str + ground_truth: str + qa_type: str + exact_match: float + f1_score: float + contains_match: float + bleu_score: float = 0.0 + numeric_match: Optional[float] = None + + def to_dict(self) -> Dict[str, Any]: + result = { + "id": self.id, + "prediction": self.prediction, + "ground_truth": self.ground_truth, + "qa_type": self.qa_type, + "exact_match": self.exact_match, + "f1_score": self.f1_score, + "contains_match": self.contains_match, + "bleu_score": self.bleu_score, + } + if self.numeric_match is not None: + result["numeric_match"] = self.numeric_match + return result + + +@dataclass +class AggregatedMetrics: + """์ง‘๊ณ„๋œ ๋ฉ”ํŠธ๋ฆญ""" + total_count: int = 0 + exact_match_avg: float = 0.0 + f1_score_avg: float = 0.0 + contains_match_avg: float = 0.0 + bleu_score_avg: float = 0.0 + by_type: Dict[str, Dict[str, float]] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + return { + "total_count": self.total_count, + "exact_match": self.exact_match_avg, + "f1_score": self.f1_score_avg, + "contains_match": self.contains_match_avg, + "bleu_score": self.bleu_score_avg, + "by_type": self.by_type, + } + + +def compute_metrics( + prediction: str, + ground_truth: str, + qa_type: str, + id: str = "", +) -> EvalResult: + """ + ๋‹จ์ผ ์˜ˆ์ธก์— ๋Œ€ํ•œ ๋ชจ๋“  ๋ฉ”ํŠธ๋ฆญ์„ ๊ณ„์‚ฐํ•ฉ๋‹ˆ๋‹ค. + + Args: + prediction: ์˜ˆ์ธก ๋‹ต๋ณ€ + ground_truth: ์ •๋‹ต + qa_type: QA ์œ ํ˜• + id: ํ•ญ๋ชฉ ID + + Returns: + EvalResult + """ + em = exact_match(prediction, ground_truth) + f1 = f1_score(prediction, ground_truth) + cm = contains_match(prediction, ground_truth) + bleu = bleu_score(prediction, ground_truth) + + # ๊ณ„์‚ฐ ์œ ํ˜•์˜ ๊ฒฝ์šฐ ์ˆซ์ž ๋งค์นญ๋„ ํ™•์ธ + num_match = None + if qa_type in ["calculation", "arithmetic", "aggregate"]: + num_match = numeric_match(prediction, ground_truth) + + return EvalResult( + id=id, + prediction=prediction, + ground_truth=ground_truth, + qa_type=qa_type, + exact_match=em, + f1_score=f1, + contains_match=cm, + bleu_score=bleu, + numeric_match=num_match, + ) + + +def aggregate_metrics(results: List[EvalResult]) -> AggregatedMetrics: + """ + ์—ฌ๋Ÿฌ ํ‰๊ฐ€ ๊ฒฐ๊ณผ๋ฅผ ์ง‘๊ณ„ํ•ฉ๋‹ˆ๋‹ค. + + Args: + results: EvalResult ๋ฆฌ์ŠคํŠธ + + Returns: + AggregatedMetrics + """ + if not results: + return AggregatedMetrics() + + total = len(results) + em_sum = sum(r.exact_match for r in results) + f1_sum = sum(r.f1_score for r in results) + cm_sum = sum(r.contains_match for r in results) + bleu_sum = sum(r.bleu_score for r in results) + + # ์œ ํ˜•๋ณ„ ์ง‘๊ณ„ + by_type: Dict[str, Dict[str, List[float]]] = {} + for r in results: + if r.qa_type not in by_type: + by_type[r.qa_type] = { + "exact_match": [], + "f1_score": [], + "contains_match": [], + "count": 0, + } + by_type[r.qa_type]["exact_match"].append(r.exact_match) + by_type[r.qa_type]["f1_score"].append(r.f1_score) + by_type[r.qa_type]["contains_match"].append(r.contains_match) + by_type[r.qa_type]["count"] += 1 + + # ์œ ํ˜•๋ณ„ ํ‰๊ท  ๊ณ„์‚ฐ + type_metrics = {} + for qa_type, metrics in by_type.items(): + count = metrics["count"] + type_metrics[qa_type] = { + "count": count, + "exact_match": sum(metrics["exact_match"]) / count if count > 0 else 0.0, + "f1_score": sum(metrics["f1_score"]) / count if count > 0 else 0.0, + "contains_match": sum(metrics["contains_match"]) / count if count > 0 else 0.0, + } + + return AggregatedMetrics( + total_count=total, + exact_match_avg=em_sum / total, + f1_score_avg=f1_sum / total, + contains_match_avg=cm_sum / total, + bleu_score_avg=bleu_sum / total, + by_type=type_metrics, + ) diff --git a/run_claude_public.sh b/run_claude_public.sh new file mode 100755 index 0000000..333b9b2 --- /dev/null +++ b/run_claude_public.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +# ============================================================================== +# TableMagnifier - Claude Sonnet (Public Domain) +# ============================================================================== + +# Default Configuration +TARGET="data" +DEFAULT_ARGS="--provider claude --model claude-sonnet-4.5 --domain public" + +# Check if the first argument is a path (i.e., does not start with '-') +if [[ "$1" != -* ]] && [[ -n "$1" ]]; then + TARGET="$1" + shift +fi + +echo "==============================================" +echo " TableMagnifier - Claude Sonnet (Public)" +echo "==============================================" +echo "Target: $TARGET" +echo "Provider: claude" +echo "Model: claude-sonnet-4.5" +echo "Domain: public" +echo "" + +# Check for ANTHROPIC_API_KEY +if [[ -z "$ANTHROPIC_API_KEY" ]]; then + echo "โš ๏ธ Warning: ANTHROPIC_API_KEY is not set." + echo " Please set it in your environment or .env file." + echo "" +fi + +# Run the pipeline +# Note: "$@" appends any remaining arguments, allowing overrides of defaults +uv run python -m generate_synthetic_table.cli "$TARGET" $DEFAULT_ARGS "$@" From 43a25f4bf0e7db633f9d19a8e5f73452b8c92131 Mon Sep 17 00:00:00 2001 From: srrk-GreenMan Date: Tue, 6 Jan 2026 23:35:06 +0900 Subject: [PATCH 3/4] some fixes with prompts especially with reasoning annotations --- .../prompts/academic.yaml | 8 +-- .../prompts/business.yaml | 8 +-- generate_synthetic_table/prompts/default.yaml | 4 +- generate_synthetic_table/prompts/finance.yaml | 8 +-- .../prompts/insurance.yaml | 8 +-- generate_synthetic_table/prompts/medical.yaml | 4 +- generate_synthetic_table/prompts/public.yaml | 4 +- generate_synthetic_table/runner.py | 4 +- pyproject.toml | 67 ++++++++--------- run_claude_public.sh | 71 ++++++++++--------- uv.lock | 37 ++++++++++ 11 files changed, 131 insertions(+), 92 deletions(-) diff --git a/generate_synthetic_table/prompts/academic.yaml b/generate_synthetic_table/prompts/academic.yaml index e36fb3b..bbb19d5 100644 --- a/generate_synthetic_table/prompts/academic.yaml +++ b/generate_synthetic_table/prompts/academic.yaml @@ -13,7 +13,7 @@ generate_qa: | - Each QA pair must correspond to exactly one Reasoning Type. - Output format must strictly follow JSON. - Questions and Answers MUST be written in Korean. - - reasoning_annotation MUST be written in English. + - reasoning_annotation MUST be written in English and MUST be a single string (not a list). ### [Validation Criteria] - Is the answer uniquely determined within the table? @@ -39,7 +39,7 @@ generate_qa: | "question": "Question text", "answer": "Answer text", "type": "lookup", - "reasoning_annotation": "Step-by-step logic to derive answer", + "reasoning_annotation": "Step-by-step logic to derive answer (MUST be a string, not a list)", "context": null }}, ... (One per Reasoning Type => Total 10) @@ -58,7 +58,7 @@ generate_qa_from_image: | - Each QA pair must correspond to exactly one Reasoning Type. - Output format must strictly follow JSON. - Questions and Answers MUST be written in Korean. - - reasoning_annotation MUST be written in English. + - reasoning_annotation MUST be written in English and MUST be a single string (not a list). ### [Validation Criteria] - Is the answer uniquely determined within the table? @@ -84,7 +84,7 @@ generate_qa_from_image: | "question": "Question text", "answer": "Answer text", "type": "lookup", - "reasoning_annotation": "Step-by-step logic to derive answer", + "reasoning_annotation": "Step-by-step logic to derive answer (MUST be a string, not a list)", "context": null }}, ... (One per Reasoning Type => Total 10) diff --git a/generate_synthetic_table/prompts/business.yaml b/generate_synthetic_table/prompts/business.yaml index 399e30a..9a3efe5 100644 --- a/generate_synthetic_table/prompts/business.yaml +++ b/generate_synthetic_table/prompts/business.yaml @@ -13,7 +13,7 @@ generate_qa: | - Each QA pair must correspond to exactly one Reasoning Type. - Output format must strictly follow JSON. - Questions and Answers MUST be written in Korean. - - reasoning_annotation MUST be written in English. + - reasoning_annotation MUST be written in English and MUST be a single string (not a list). ### [Validation Criteria] - Is the answer uniquely determined within the table? @@ -39,7 +39,7 @@ generate_qa: | "question": "Question text", "answer": "Answer text", "type": "lookup", - "reasoning_annotation": "Step-by-step logic to derive answer", + "reasoning_annotation": "Step-by-step logic to derive answer (MUST be a string, not a list)", "context": null }}, ... (One per Reasoning Type => Total 10) @@ -58,7 +58,7 @@ generate_qa_from_image: | - Each QA pair must correspond to exactly one Reasoning Type. - Output format must strictly follow JSON. - Questions and Answers MUST be written in Korean. - - reasoning_annotation MUST be written in English. + - reasoning_annotation MUST be written in English and MUST be a single string (not a list). ### [Validation Criteria] - Is the answer uniquely determined within the table? @@ -84,7 +84,7 @@ generate_qa_from_image: | "question": "Question text", "answer": "Answer text", "type": "lookup", - "reasoning_annotation": "Step-by-step logic to derive answer", + "reasoning_annotation": "Step-by-step logic to derive answer (MUST be a string, not a list)", "context": null }}, ... (One per Reasoning Type => Total 10) diff --git a/generate_synthetic_table/prompts/default.yaml b/generate_synthetic_table/prompts/default.yaml index 19abc44..e877e0a 100644 --- a/generate_synthetic_table/prompts/default.yaml +++ b/generate_synthetic_table/prompts/default.yaml @@ -14,7 +14,7 @@ generate_qa: | - **Reasoning:** Inferring information based on the data. 2. **Format:** Output the result as a JSON object with a key "qa_pairs", which is a list of objects containing "question", "answer", and "type". 3. **Language:** The questions and answers should be in Korean (since the table is likely Korean). - 4. **Reasoning Language:** reasoning_annotation MUST be written in English. + 4. **Reasoning Language:** reasoning_annotation MUST be written in English and MUST be a single string (not a list). **Output Format (JSON):** {{ @@ -41,7 +41,7 @@ generate_qa_from_image: | - **Reasoning:** Inferring information based on the data. 3. **Format:** Output the result as a JSON object with a key "qa_pairs", which is a list of objects containing "question", "answer", and "type". 4. **Language:** The questions and answers MUST be in Korean (since the table is likely Korean). - 5. **Reasoning Language:** reasoning_annotation MUST be written in English. + 5. **Reasoning Language:** reasoning_annotation MUST be written in English and MUST be a single string (not a list). 6. **Accuracy:** Ensure all answers are factually correct based on the data visible in the image. **Output Format (JSON):** diff --git a/generate_synthetic_table/prompts/finance.yaml b/generate_synthetic_table/prompts/finance.yaml index fd9b767..d610704 100644 --- a/generate_synthetic_table/prompts/finance.yaml +++ b/generate_synthetic_table/prompts/finance.yaml @@ -13,7 +13,7 @@ generate_qa: | - Each QA pair must correspond to exactly one Reasoning Type. - Output format must strictly follow JSON. - Questions and Answers MUST be written in Korean. - - reasoning_annotation MUST be written in English. + - reasoning_annotation MUST be written in English and MUST be a single string (not a list). ### [Validation Criteria] - Is the answer uniquely determined within the table? @@ -39,7 +39,7 @@ generate_qa: | "question": "Question text", "answer": "Answer text", "type": "lookup", - "reasoning_annotation": "Step-by-step logic to derive answer", + "reasoning_annotation": "Step-by-step logic to derive answer (MUST be a string, not a list)", "context": null }}, ... (One per Reasoning Type => Total 10) @@ -58,7 +58,7 @@ generate_qa_from_image: | - Each QA pair must correspond to exactly one Reasoning Type. - Output format must strictly follow JSON. - Questions and Answers MUST be written in Korean. - - reasoning_annotation MUST be written in English. + - reasoning_annotation MUST be written in English and MUST be a single string (not a list). ### [Validation Criteria] - Is the answer uniquely determined within the table? @@ -84,7 +84,7 @@ generate_qa_from_image: | "question": "Question text", "answer": "Answer text", "type": "lookup", - "reasoning_annotation": "Step-by-step logic to derive answer", + "reasoning_annotation": "Step-by-step logic to derive answer (MUST be a string, not a list)", "context": null }}, ... (One per Reasoning Type => Total 10) diff --git a/generate_synthetic_table/prompts/insurance.yaml b/generate_synthetic_table/prompts/insurance.yaml index 1863169..fae3ff8 100644 --- a/generate_synthetic_table/prompts/insurance.yaml +++ b/generate_synthetic_table/prompts/insurance.yaml @@ -13,8 +13,8 @@ generate_qa: | - **Reasoning:** Inferring eligibility or coverage scope (e.g., "Is dental treatment covered under the Basic plan?"). 2. **Format:** Output the result as a JSON object with a key "qa_pairs", which is a list of objects containing "question", "answer", and "type". 3. **Language:** The questions and answers MUST be in Korean. - 4. **Reasoning Language:** reasoning_annotation MUST be written in English. - 4. **Accuracy:** Ensure all answers are factually correct based on the data in the table. + 4. **Reasoning Language:** reasoning_annotation MUST be written in English and MUST be a single string (not a list). + 5. **Accuracy:** Ensure all answers are factually correct based on the data in the table. **Output Format (JSON):** {{ @@ -41,8 +41,8 @@ generate_qa_from_image: | - **Reasoning:** Inferring eligibility or coverage scope/exclusions. 3. **Format:** Output the result as a JSON object with a key "qa_pairs", which is a list of objects containing "question", "answer", and "type". 4. **Language:** The questions and answers MUST be in Korean. - 5. **Reasoning Language:** reasoning_annotation MUST be written in English. - 5. **Accuracy:** Ensure 100% factual correctness. + 5. **Reasoning Language:** reasoning_annotation MUST be written in English and MUST be a single string (not a list). + 6. **Accuracy:** Ensure 100% factual correctness. **Output Format (JSON):** {{ diff --git a/generate_synthetic_table/prompts/medical.yaml b/generate_synthetic_table/prompts/medical.yaml index 5a3a0c2..d11f8f8 100644 --- a/generate_synthetic_table/prompts/medical.yaml +++ b/generate_synthetic_table/prompts/medical.yaml @@ -14,7 +14,7 @@ generate_qa: | - Output format must strictly follow JSON. - **Privacy**: Assume patient names/IDs are pseudonymized synthetic data. - Questions and Answers MUST be written in Korean. - - reasoning_annotation MUST be written in English. + - reasoning_annotation MUST be written in English and MUST be a single string (not a list). ### [Validation Criteria] - Is the answer uniquely determined within the table? @@ -40,7 +40,7 @@ generate_qa: | "question": "Question text", "answer": "Answer text", "type": "lookup", - "reasoning_annotation": "Step-by-step logic to derive answer", + "reasoning_annotation": "Step-by-step logic to derive answer (MUST be a string, not a list)", "context": null }}, ... (One per Reasoning Type => Total 10) diff --git a/generate_synthetic_table/prompts/public.yaml b/generate_synthetic_table/prompts/public.yaml index 24a87c2..2850db7 100644 --- a/generate_synthetic_table/prompts/public.yaml +++ b/generate_synthetic_table/prompts/public.yaml @@ -13,7 +13,7 @@ generate_qa: | - Each QA pair must correspond to exactly one Reasoning Type. - Output format must strictly follow JSON. - Questions and Answers MUST be written in Korean. - - reasoning_annotation MUST be written in English. + - reasoning_annotation MUST be written in English and MUST be a single string (not a list). ### [Validation Criteria] - Is the answer uniquely determined within the table? @@ -39,7 +39,7 @@ generate_qa: | "question": "Question text", "answer": "Answer text", "type": "lookup", - "reasoning_annotation": "Step-by-step logic to derive answer", + "reasoning_annotation": "Step-by-step logic to derive answer (MUST be a string, not a list)", "context": null }}, ... (One per Reasoning Type => Total 10) diff --git a/generate_synthetic_table/runner.py b/generate_synthetic_table/runner.py index 52e266c..8ac2266 100644 --- a/generate_synthetic_table/runner.py +++ b/generate_synthetic_table/runner.py @@ -494,10 +494,10 @@ def run_batch_for_folder( """ load_dotenv() - # Find all image files + # Find all image files (recursive) image_extensions = {".png", ".jpg", ".jpeg", ".webp", ".gif"} image_files = sorted([ - f for f in folder.iterdir() + f for f in folder.rglob("*") if f.is_file() and f.suffix.lower() in image_extensions ]) diff --git a/pyproject.toml b/pyproject.toml index 161a52b..9a469ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,33 +1,34 @@ -[project] -name = "tablemagnifier" -version = "0.1.0" -description = "Add your description here" -readme = "README.md" -requires-python = ">=3.12" -dependencies = [ - "beautifulsoup4>=4.14.3", - "dotenv>=0.9.9", - "google-generativeai>=0.3.0", - "ipykernel>=7.1.0", - "langchain>=0.1.0", - "langchain-core>=0.1.0", - "langchain-google-genai>=4.0.0", - "langchain-openai>=1.1.0", - "langgraph>=0.0.30", - "pymupdf>=1.26.7", - "pyyaml>=6.0", - "typing-extensions>=4.5.0", - "fastapi>=0.115.0", - "uvicorn>=0.32.0", - "python-multipart>=0.0.12", - "langchain-anthropic>=0.3.0", - "json-repair>=0.25.3", - "pandas>=2.3.3", - "pandasql>=0.7.3", - "langchain-experimental>=0.4.1", - "agentsjson==0.1.13", - "dspydantic>=0.0.7", - "pydantic>=2.12.4", - "dspy-ai>=3.0.4", - "pymongo>=4.6.1", -] +[project] +name = "tablemagnifier" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "beautifulsoup4>=4.14.3", + "dotenv>=0.9.9", + "google-generativeai>=0.3.0", + "ipykernel>=7.1.0", + "langchain>=0.1.0", + "langchain-core>=0.1.0", + "langchain-google-genai>=4.0.0", + "langchain-openai>=1.1.0", + "langgraph>=0.0.30", + "pymupdf>=1.26.7", + "pyyaml>=6.0", + "typing-extensions>=4.5.0", + "fastapi>=0.115.0", + "uvicorn>=0.32.0", + "python-multipart>=0.0.12", + "langchain-anthropic>=0.3.0", + "json-repair>=0.25.3", + "pandas>=2.3.3", + "pandasql>=0.7.3", + "langchain-experimental>=0.4.1", + "agentsjson==0.1.13", + "dspydantic>=0.0.7", + "pydantic>=2.12.4", + "dspy-ai>=3.0.4", + "pymongo>=4.6.1", + "langgraph-checkpoint-sqlite>=3.0.1", +] diff --git a/run_claude_public.sh b/run_claude_public.sh index 333b9b2..2dadf21 100755 --- a/run_claude_public.sh +++ b/run_claude_public.sh @@ -1,35 +1,36 @@ -#!/bin/bash - -# ============================================================================== -# TableMagnifier - Claude Sonnet (Public Domain) -# ============================================================================== - -# Default Configuration -TARGET="data" -DEFAULT_ARGS="--provider claude --model claude-sonnet-4.5 --domain public" - -# Check if the first argument is a path (i.e., does not start with '-') -if [[ "$1" != -* ]] && [[ -n "$1" ]]; then - TARGET="$1" - shift -fi - -echo "==============================================" -echo " TableMagnifier - Claude Sonnet (Public)" -echo "==============================================" -echo "Target: $TARGET" -echo "Provider: claude" -echo "Model: claude-sonnet-4.5" -echo "Domain: public" -echo "" - -# Check for ANTHROPIC_API_KEY -if [[ -z "$ANTHROPIC_API_KEY" ]]; then - echo "โš ๏ธ Warning: ANTHROPIC_API_KEY is not set." - echo " Please set it in your environment or .env file." - echo "" -fi - -# Run the pipeline -# Note: "$@" appends any remaining arguments, allowing overrides of defaults -uv run python -m generate_synthetic_table.cli "$TARGET" $DEFAULT_ARGS "$@" +#!/bin/bash + +# ============================================================================== +# TableMagnifier - Claude Sonnet (Public Domain) +# ============================================================================== + +# Default Configuration +TARGET="data/Public/Table/P_origin_0" +DEFAULT_ARGS="--provider claude --model claude-sonnet-4-5 --domain public --qa-only --pair-mode" + +# Check if the first argument is a path (i.e., does not start with '-') +if [[ "$1" != -* ]] && [[ -n "$1" ]]; then + TARGET="$1" + shift +fi + +echo "==============================================" +echo " TableMagnifier - Claude Sonnet (Public)" +echo "==============================================" +echo "Target: $TARGET" +echo "Provider: claude" +echo "Model: claude-sonnet-4.5" +echo "Domain: public" +echo "" + +# Check for ANTHROPIC_API_KEY +if [[ -z "$ANTHROPIC_API_KEY" ]]; then + echo "โš ๏ธ Warning: ANTHROPIC_API_KEY is not set." + echo " Please set it in your environment or .env file." + echo "" +fi + +# Run the pipeline +# Note: "$@" appends any remaining arguments, allowing overrides of defaults +uv run python -m generate_synthetic_table.cli "$TARGET" $DEFAULT_ARGS "$@" + diff --git a/uv.lock b/uv.lock index 2a32e7c..7826219 100644 --- a/uv.lock +++ b/uv.lock @@ -138,6 +138,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, ] +[[package]] +name = "aiosqlite" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, +] + [[package]] name = "alembic" version = "1.17.2" @@ -1678,6 +1687,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/e3/616e3a7ff737d98c1bbb5700dd62278914e2a9ded09a79a1fa93cf24ce12/langgraph_checkpoint-3.0.1-py3-none-any.whl", hash = "sha256:9b04a8d0edc0474ce4eaf30c5d731cee38f11ddff50a6177eead95b5c4e4220b", size = 46249, upload-time = "2025-11-04T21:55:46.472Z" }, ] +[[package]] +name = "langgraph-checkpoint-sqlite" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiosqlite" }, + { name = "langgraph-checkpoint" }, + { name = "sqlite-vec" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/a0/106a0d6c4f02385b11d9cb1dacedae3beee72023448c619702fc62745f22/langgraph_checkpoint_sqlite-3.0.1.tar.gz", hash = "sha256:c6580138e6abfd2ade7ea49186c664d47ef28dc44538674fa47e50a8a5f8af83", size = 110351, upload-time = "2025-12-09T22:03:13.112Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/95/41be362f3eb25d7b4efe5b034efe8b9ce152a37ffe8a3a2fe1595f40272c/langgraph_checkpoint_sqlite-3.0.1-py3-none-any.whl", hash = "sha256:616124676e5827294966997ed853f5d41490cc61f73b3c79359f4ff307728508", size = 33382, upload-time = "2025-12-09T22:03:12.245Z" }, +] + [[package]] name = "langgraph-prebuilt" version = "1.0.5" @@ -3262,6 +3285,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bf/e1/3ccb13c643399d22289c6a9786c1a91e3dcbb68bce4beb44926ac2c557bf/sqlalchemy-2.0.45-py3-none-any.whl", hash = "sha256:5225a288e4c8cc2308dbdd874edad6e7d0fd38eac1e9e5f23503425c8eee20d0", size = 1936672, upload-time = "2025-12-09T21:54:52.608Z" }, ] +[[package]] +name = "sqlite-vec" +version = "0.1.6" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/ed/aabc328f29ee6814033d008ec43e44f2c595447d9cccd5f2aabe60df2933/sqlite_vec-0.1.6-py3-none-macosx_10_6_x86_64.whl", hash = "sha256:77491bcaa6d496f2acb5cc0d0ff0b8964434f141523c121e313f9a7d8088dee3", size = 164075, upload-time = "2024-11-20T16:40:29.847Z" }, + { url = "https://files.pythonhosted.org/packages/a7/57/05604e509a129b22e303758bfa062c19afb020557d5e19b008c64016704e/sqlite_vec-0.1.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fdca35f7ee3243668a055255d4dee4dea7eed5a06da8cad409f89facf4595361", size = 165242, upload-time = "2024-11-20T16:40:31.206Z" }, + { url = "https://files.pythonhosted.org/packages/f2/48/dbb2cc4e5bad88c89c7bb296e2d0a8df58aab9edc75853728c361eefc24f/sqlite_vec-0.1.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b0519d9cd96164cd2e08e8eed225197f9cd2f0be82cb04567692a0a4be02da3", size = 103704, upload-time = "2024-11-20T16:40:33.729Z" }, + { url = "https://files.pythonhosted.org/packages/80/76/97f33b1a2446f6ae55e59b33869bed4eafaf59b7f4c662c8d9491b6a714a/sqlite_vec-0.1.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux1_x86_64.whl", hash = "sha256:823b0493add80d7fe82ab0fe25df7c0703f4752941aee1c7b2b02cec9656cb24", size = 151556, upload-time = "2024-11-20T16:40:35.387Z" }, + { url = "https://files.pythonhosted.org/packages/6a/98/e8bc58b178266eae2fcf4c9c7a8303a8d41164d781b32d71097924a6bebe/sqlite_vec-0.1.6-py3-none-win_amd64.whl", hash = "sha256:c65bcfd90fa2f41f9000052bcb8bb75d38240b2dae49225389eca6c3136d3f0c", size = 281540, upload-time = "2024-11-20T16:40:37.296Z" }, +] + [[package]] name = "stack-data" version = "0.6.3" @@ -3323,6 +3358,7 @@ dependencies = [ { name = "langchain-google-genai" }, { name = "langchain-openai" }, { name = "langgraph" }, + { name = "langgraph-checkpoint-sqlite" }, { name = "pandas" }, { name = "pandasql" }, { name = "pydantic" }, @@ -3352,6 +3388,7 @@ requires-dist = [ { name = "langchain-google-genai", specifier = ">=4.0.0" }, { name = "langchain-openai", specifier = ">=1.1.0" }, { name = "langgraph", specifier = ">=0.0.30" }, + { name = "langgraph-checkpoint-sqlite", specifier = ">=3.0.1" }, { name = "pandas", specifier = ">=2.3.3" }, { name = "pandasql", specifier = ">=0.7.3" }, { name = "pydantic", specifier = ">=2.12.4" }, From b49077a820f1193c0f9fa23a2ee3f0a0bada97ae Mon Sep 17 00:00:00 2001 From: seokhyun Seo Date: Wed, 7 Jan 2026 14:12:14 +0000 Subject: [PATCH 4/4] add vlm inference --- eval/config_loader.py | 116 +++++ eval/dataset.py | 1 + eval/evaluate.py | 77 +++- eval/evaluate_public.py | 426 ++++++++++++++++++ eval/evaluate_public.sh | 295 ++++++++++++ eval/inference.py | 69 ++- eval/llm_judge.py | 251 +++++++++++ eval/metrics.py | 48 +- eval/vllm_config.json | 19 + pipeline_ui/backend/checkpoints/8c63b7c0.json | 23 - pipeline_ui/backend/checkpoints/966964e7.json | 92 ---- .../backend/output/be5456ef_result.json | 70 --- 12 files changed, 1291 insertions(+), 196 deletions(-) create mode 100644 eval/config_loader.py create mode 100644 eval/evaluate_public.py create mode 100755 eval/evaluate_public.sh create mode 100644 eval/llm_judge.py create mode 100644 eval/vllm_config.json delete mode 100644 pipeline_ui/backend/checkpoints/8c63b7c0.json delete mode 100644 pipeline_ui/backend/checkpoints/966964e7.json delete mode 100644 pipeline_ui/backend/output/be5456ef_result.json diff --git a/eval/config_loader.py b/eval/config_loader.py new file mode 100644 index 0000000..7c93e32 --- /dev/null +++ b/eval/config_loader.py @@ -0,0 +1,116 @@ +""" +์„ค์ • ํŒŒ์ผ ๋กœ๋” + +vLLM ์„ค์ •์„ JSON ํŒŒ์ผ์—์„œ ๋กœ๋“œํ•ฉ๋‹ˆ๋‹ค. +""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import Dict, Any, Optional + +logger = logging.getLogger(__name__) + + +DEFAULT_CONFIG_PATH = Path(__file__).parent / "vllm_config.json" + + +def load_config(config_path: Optional[Path] = None) -> Dict[str, Any]: + """ + ์„ค์ • ํŒŒ์ผ์„ ๋กœ๋“œํ•ฉ๋‹ˆ๋‹ค. + + Args: + config_path: ์„ค์ • ํŒŒ์ผ ๊ฒฝ๋กœ (None์ด๋ฉด ๊ธฐ๋ณธ ๊ฒฝ๋กœ ์‚ฌ์šฉ) + + Returns: + ์„ค์ • ๋”•์…”๋„ˆ๋ฆฌ + """ + if config_path is None: + config_path = DEFAULT_CONFIG_PATH + + config_path = Path(config_path) + + if not config_path.exists(): + logger.warning(f"Config file not found: {config_path}, using defaults") + return get_default_config() + + try: + with open(config_path, "r", encoding="utf-8") as f: + config = json.load(f) + + # ๊ธฐ๋ณธ๊ฐ’ ๋ณ‘ํ•ฉ + default = get_default_config() + config = merge_config(default, config) + + logger.info(f"Loaded config from {config_path}") + return config + except Exception as e: + logger.error(f"Failed to load config from {config_path}: {e}") + logger.info("Using default config") + return get_default_config() + + +def get_default_config() -> Dict[str, Any]: + """๊ธฐ๋ณธ ์„ค์ •์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.""" + return { + "base_url": "http://localhost:8000/v1", + "model": "default", + "api_key": "EMPTY", + "max_tokens": 512, + "temperature": 0.0, + "max_concurrent": 10, + "judge": { + "enabled": False, + "provider": "vllm", + "model": "default", + "base_url": "http://localhost:8000/v1", + "api_key": "EMPTY", + "max_tokens": 512, + "temperature": 0.0, + "max_concurrent": 5, + } + } + + +def merge_config(default: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any]: + """๊ธฐ๋ณธ ์„ค์ •๊ณผ ์‚ฌ์šฉ์ž ์„ค์ •์„ ๋ณ‘ํ•ฉํ•ฉ๋‹ˆ๋‹ค.""" + merged = default.copy() + + for key, value in user.items(): + if key in merged and isinstance(merged[key], dict) and isinstance(value, dict): + merged[key] = merge_config(merged[key], value) + else: + merged[key] = value + + return merged + + +def get_inference_config(config: Dict[str, Any]) -> Dict[str, Any]: + """์ถ”๋ก ์šฉ ์„ค์ •์„ ์ถ”์ถœํ•ฉ๋‹ˆ๋‹ค.""" + return { + "base_url": config.get("base_url", "http://localhost:8000/v1"), + "model": config.get("model", "default"), + "api_key": config.get("api_key", "EMPTY"), + "max_tokens": config.get("max_tokens", 512), + "temperature": config.get("temperature", 0.0), + "max_concurrent": config.get("max_concurrent", 10), + } + + +def get_judge_config(config: Dict[str, Any]) -> Dict[str, Any]: + """Judge์šฉ ์„ค์ •์„ ์ถ”์ถœํ•ฉ๋‹ˆ๋‹ค.""" + judge_config = config.get("judge", {}) + + return { + "enabled": judge_config.get("enabled", False), + "provider": judge_config.get("provider", "vllm"), + "model": judge_config.get("model", config.get("model", "default")), + "base_url": judge_config.get("base_url", config.get("base_url", "http://localhost:8000/v1")), + "api_key": judge_config.get("api_key", config.get("api_key", "EMPTY")), + "max_tokens": judge_config.get("max_tokens", 512), + "temperature": judge_config.get("temperature", 0.0), + "max_concurrent": judge_config.get("max_concurrent", 5), + } + diff --git a/eval/dataset.py b/eval/dataset.py index 74a131b..b1ed041 100644 --- a/eval/dataset.py +++ b/eval/dataset.py @@ -285,6 +285,7 @@ def create_inference_prompts( request = { "id": item.id, + "question": item.question, "prompt": prompt, "ground_truth": item.answer, "qa_type": item.qa_type, diff --git a/eval/evaluate.py b/eval/evaluate.py index 194d9c5..8206c9b 100644 --- a/eval/evaluate.py +++ b/eval/evaluate.py @@ -33,21 +33,28 @@ get_client, run_inference, ) +from .llm_judge import judge_batch, create_judge_client logger = logging.getLogger(__name__) -def evaluate_predictions( +async def evaluate_predictions( predictions: List[Dict[str, Any]], ground_truths: Optional[List[Dict[str, Any]]] = None, + use_judge: bool = False, + judge_client: Optional[InferenceClient] = None, + questions: Optional[List[str]] = None, ) -> tuple[List[EvalResult], AggregatedMetrics]: """ ์˜ˆ์ธก ๊ฒฐ๊ณผ๋ฅผ ํ‰๊ฐ€ํ•ฉ๋‹ˆ๋‹ค. Args: predictions: ์˜ˆ์ธก ๊ฒฐ๊ณผ ๋ฆฌ์ŠคํŠธ - [{"id": "...", "prediction": "...", "ground_truth": "...", "qa_type": "..."}] + [{"id": "...", "prediction": "...", "ground_truth": "...", "qa_type": "...", "question": "..."}] ground_truths: ์ •๋‹ต ๋ฆฌ์ŠคํŠธ (์˜ต์…˜, predictions์— ground_truth๊ฐ€ ์—†์„ ๋•Œ ์‚ฌ์šฉ) + use_judge: LLM-as-Judge ์‚ฌ์šฉ ์—ฌ๋ถ€ + judge_client: Judge์šฉ ํด๋ผ์ด์–ธํŠธ (use_judge=True์ผ ๋•Œ ํ•„์š”) + questions: ์งˆ๋ฌธ ๋ฆฌ์ŠคํŠธ (use_judge=True์ผ ๋•Œ ํ•„์š”, predictions์— question์ด ์—†์œผ๋ฉด ์‚ฌ์šฉ) Returns: (๊ฐœ๋ณ„ ๊ฒฐ๊ณผ ๋ฆฌ์ŠคํŠธ, ์ง‘๊ณ„ ๋ฉ”ํŠธ๋ฆญ) @@ -58,11 +65,20 @@ def evaluate_predictions( gt_map = {} if ground_truths: gt_map = {gt["id"]: gt for gt in ground_truths} + + # ์งˆ๋ฌธ ๋งคํ•‘ ์ƒ์„ฑ + question_map = {} + if questions: + for i, q in enumerate(questions): + if i < len(predictions): + pred_id = predictions[i].get("id", f"item_{i}") + question_map[pred_id] = q for pred in predictions: pred_id = pred.get("id", "") prediction = pred.get("prediction", "") qa_type = pred.get("qa_type", "unknown") + question = pred.get("question", question_map.get(pred_id, "")) # ground_truth ์ฐพ๊ธฐ if "ground_truth" in pred: @@ -82,6 +98,32 @@ def evaluate_predictions( ) results.append(result) + # LLM-as-Judge ํ‰๊ฐ€ + if use_judge and judge_client: + logger.info("Running LLM-as-Judge evaluation...") + judge_items = [ + { + "id": r.id, + "question": predictions[i].get("question", question_map.get(r.id, "")), + "ground_truth": r.ground_truth, + "prediction": r.prediction, + } + for i, r in enumerate(results) + ] + judge_results = await judge_batch(judge_client, judge_items, show_progress=True) + + # Judge ๊ฒฐ๊ณผ๋ฅผ EvalResult์— ๋ณ‘ํ•ฉ + judge_map = {jr.id: jr for jr in judge_results} + for result in results: + if result.id in judge_map: + jr = judge_map[result.id] + result.judge_correctness = jr.correctness + result.judge_completeness = jr.completeness + result.judge_relevance = jr.relevance + result.judge_overall_score = jr.overall_score + result.judge_is_correct = jr.is_correct + result.judge_explanation = jr.explanation + aggregated = aggregate_metrics(results) return results, aggregated @@ -153,6 +195,14 @@ def print_report(aggregated: AggregatedMetrics) -> None: print(f" F1 Score: {aggregated.f1_score_avg:.4f} ({aggregated.f1_score_avg * 100:.2f}%)") print(f" Contains Match: {aggregated.contains_match_avg:.4f} ({aggregated.contains_match_avg * 100:.2f}%)") print(f" BLEU Score: {aggregated.bleu_score_avg:.4f}") + + if aggregated.judge_overall_avg is not None: + print(f"\nLLM-as-Judge Metrics:") + print(f" Overall Score: {aggregated.judge_overall_avg:.4f} ({aggregated.judge_overall_avg * 100:.2f}%)") + print(f" Correctness: {aggregated.judge_correctness_avg:.4f} ({aggregated.judge_correctness_avg * 100:.2f}%)") + print(f" Completeness: {aggregated.judge_completeness_avg:.4f} ({aggregated.judge_completeness_avg * 100:.2f}%)") + print(f" Relevance: {aggregated.judge_relevance_avg:.4f} ({aggregated.judge_relevance_avg * 100:.2f}%)") + print(f" Accuracy: {aggregated.judge_accuracy:.4f} ({aggregated.judge_accuracy * 100:.2f}%)") if aggregated.by_type: print(f"\nMetrics by QA Type:") @@ -173,6 +223,10 @@ async def run_evaluation( output_dir: Optional[Path] = None, prompt_template: Optional[str] = None, include_images: bool = False, + use_judge: bool = False, + judge_provider: str = "openai", + judge_model: Optional[str] = None, + judge_api_key: Optional[str] = None, **client_kwargs, ) -> tuple[List[EvalResult], AggregatedMetrics]: """ @@ -236,11 +290,26 @@ async def run_evaluation( "prediction": r.prediction, "ground_truth": r.ground_truth, "qa_type": r.qa_type, + "question": p.get("question", ""), } - for r in responses + for r, p in zip(responses, prompts) ] - results, aggregated = evaluate_predictions(predictions) + judge_client = None + if use_judge: + judge_client = create_judge_client( + provider=judge_provider, + model=judge_model, + api_key=judge_api_key, + ) + + questions = [p.get("question", "") for p in prompts] + results, aggregated = await evaluate_predictions( + predictions, + use_judge=use_judge, + judge_client=judge_client, + questions=questions, + ) # 6. ๋ฆฌํฌํŠธ ์ƒ์„ฑ ๋ฐ ์ €์žฅ print_report(aggregated) diff --git a/eval/evaluate_public.py b/eval/evaluate_public.py new file mode 100644 index 0000000..54edb73 --- /dev/null +++ b/eval/evaluate_public.py @@ -0,0 +1,426 @@ +#!/usr/bin/env python3 +""" +Public ๋ฐ์ดํ„ฐ์…‹ ํ‰๊ฐ€ ์Šคํฌ๋ฆฝํŠธ + +data/public/public_0/qa_output์˜ JSON ํŒŒ์ผ๋“ค์„ ํ‰๊ฐ€ํ•ฉ๋‹ˆ๋‹ค. +๋ฉ€ํ‹ฐ ์ด๋ฏธ์ง€ ์ง€์›์„ ํฌํ•จํ•ฉ๋‹ˆ๋‹ค. + +์‚ฌ์šฉ๋ฒ•: + # uv run์œผ๋กœ ์‹คํ–‰ (๊ถŒ์žฅ) + uv run python -m eval.evaluate_public data/public/public_0/qa_output --provider vllm + + # ๋˜๋Š” bash ์Šคํฌ๋ฆฝํŠธ ์‚ฌ์šฉ + ./eval/evaluate_public.sh + + # OpenAI API ์‚ฌ์šฉ + uv run python -m eval.evaluate_public data/public/public_0/qa_output \\ + --provider openai --model gpt-4o-mini --api-key $OPENAI_API_KEY +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import logging +import sys +from pathlib import Path +from typing import Optional + +from .dataset import load_qa_from_folder, create_inference_prompts +from .evaluate import run_evaluation, evaluate_from_file +from .inference import get_client, run_inference, InferenceRequest +from .llm_judge import create_judge_client, judge_batch +from .config_loader import load_config, get_inference_config, get_judge_config + + +def setup_logging(verbose: bool = False): + """๋กœ๊น… ์„ค์ •""" + level = logging.DEBUG if verbose else logging.INFO + logging.basicConfig( + level=level, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + + +async def evaluate_public_dataset( + qa_output_dir: Path, + provider: str = "vllm", + output_dir: Optional[Path] = None, + base_url: Optional[str] = None, + model: Optional[str] = None, + api_key: Optional[str] = None, + max_tokens: Optional[int] = None, + temperature: Optional[float] = None, + max_concurrent: Optional[int] = None, + include_images: bool = True, + prompt_template: Optional[str] = None, + verbose: bool = False, + use_judge: Optional[bool] = None, + judge_provider: Optional[str] = None, + judge_model: Optional[str] = None, + judge_api_key: Optional[str] = None, + config_path: Optional[Path] = None, +): + """ + Public ๋ฐ์ดํ„ฐ์…‹ ํ‰๊ฐ€ ์‹คํ–‰ + + Args: + qa_output_dir: qa_output ํด๋” ๊ฒฝ๋กœ + provider: ์ถ”๋ก  ์ œ๊ณต์ž (vllm, openai, anthropic) + output_dir: ๊ฒฐ๊ณผ ์ €์žฅ ๋””๋ ‰ํ† ๋ฆฌ + base_url: vLLM ์„œ๋ฒ„ URL + model: ๋ชจ๋ธ ์ด๋ฆ„ + api_key: API ํ‚ค (OpenAI/Anthropic์šฉ) + max_tokens: ์ตœ๋Œ€ ํ† ํฐ ์ˆ˜ + temperature: ์ƒ˜ํ”Œ๋ง ์˜จ๋„ + max_concurrent: ์ตœ๋Œ€ ๋™์‹œ ์š”์ฒญ ์ˆ˜ + include_images: ์ด๋ฏธ์ง€ ํฌํ•จ ์—ฌ๋ถ€ + prompt_template: ํ”„๋กฌํ”„ํŠธ ํ…œํ”Œ๋ฆฟ + verbose: ์ƒ์„ธ ๋กœ๊ทธ ์ถœ๋ ฅ + config_path: ์„ค์ • ํŒŒ์ผ ๊ฒฝ๋กœ (None์ด๋ฉด ๊ธฐ๋ณธ ์„ค์ • ํŒŒ์ผ ์‚ฌ์šฉ) + """ + setup_logging(verbose) + logger = logging.getLogger(__name__) + + # ์„ค์ • ํŒŒ์ผ ๋กœ๋“œ + config = load_config(config_path) + inference_config = get_inference_config(config) + judge_config = get_judge_config(config) + + # ๋ช…๋ น์ค„ ์ธ์ž๊ฐ€ ์žˆ์œผ๋ฉด ์šฐ์„  ์ ์šฉ, ์—†์œผ๋ฉด config ์‚ฌ์šฉ + if base_url is None: + base_url = inference_config.get("base_url") + if model is None: + model = inference_config.get("model") + if api_key is None: + api_key = inference_config.get("api_key") + if max_tokens is None: + max_tokens = inference_config.get("max_tokens", 512) + if temperature is None: + temperature = inference_config.get("temperature", 0.0) + if max_concurrent is None: + max_concurrent = inference_config.get("max_concurrent", 10) + + # Judge ์„ค์ • (๋ช…๋ น์ค„ ์ธ์ž๊ฐ€ ์—†์œผ๋ฉด config ์‚ฌ์šฉ) + if use_judge is None: + use_judge = judge_config.get("enabled", False) + if judge_provider is None: + judge_provider = judge_config.get("provider", "vllm") + if judge_model is None: + judge_model = judge_config.get("model") + if judge_api_key is None: + judge_api_key = judge_config.get("api_key") + + # ๊ฒฝ๋กœ ํ™•์ธ + qa_output_dir = Path(qa_output_dir) + if not qa_output_dir.exists(): + logger.error(f"QA output directory not found: {qa_output_dir}") + return None + + # ์ถœ๋ ฅ ๋””๋ ‰ํ† ๋ฆฌ ์„ค์ • + if output_dir is None: + output_dir = qa_output_dir.parent / "eval_results" + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + logger.info(f"Loading QA dataset from: {qa_output_dir}") + logger.info(f"Using config: base_url={base_url}, model={model}, use_judge={use_judge}") + + # 1. ๋ฐ์ดํ„ฐ์…‹ ๋กœ๋“œ + dataset = load_qa_from_folder(qa_output_dir, pattern="*_qa.json") + logger.info(f"Loaded {len(dataset)} QA items") + logger.info(f"Type distribution: {dataset.get_type_distribution()}") + + # 2. ์ถ”๋ก  ์š”์ฒญ ์ƒ์„ฑ + prompts = create_inference_prompts( + dataset, + prompt_template=prompt_template, + include_image=include_images, + ) + + requests = [ + InferenceRequest( + id=p["id"], + prompt=p["prompt"], + ground_truth=p["ground_truth"], + qa_type=p["qa_type"], + image_paths=p.get("image_paths"), + ) + for p in prompts + ] + + # ์ด๋ฏธ์ง€ ๊ฒฝ๋กœ ํ™•์ธ ๋ฐ ์ˆ˜์ • (์ƒ๋Œ€ ๊ฒฝ๋กœ ์ฒ˜๋ฆฌ) + workspace_root = Path(__file__).resolve().parents[1] + for req in requests: + if req.image_paths: + corrected_paths = [] + for img_path in req.image_paths: + path = Path(img_path) + if not path.exists(): + # ์ƒ๋Œ€ ๊ฒฝ๋กœ์ธ ๊ฒฝ์šฐ workspace ๊ธฐ์ค€์œผ๋กœ ์‹œ๋„ + workspace_path = workspace_root / img_path + if workspace_path.exists(): + corrected_paths.append(str(workspace_path)) + else: + # data/Public/Table/... ํ˜•์‹์ธ ๊ฒฝ์šฐ + public_path = workspace_root / img_path + if public_path.exists(): + corrected_paths.append(str(public_path)) + else: + logger.warning(f"Image not found: {img_path}") + corrected_paths.append(img_path) # ์›๋ณธ ์œ ์ง€ + else: + corrected_paths.append(img_path) + req.image_paths = corrected_paths + + # 3. ํด๋ผ์ด์–ธํŠธ ์„ค์ • + client_kwargs = { + "max_tokens": max_tokens, + "temperature": temperature, + "max_concurrent": max_concurrent, + } + + if provider == "vllm": + client_kwargs["base_url"] = base_url or "http://localhost:8000/v1" + client_kwargs["model"] = model or "default" + client_kwargs["api_key"] = api_key or "EMPTY" + elif provider == "openai": + client_kwargs["model"] = model or "gpt-4o-mini" + if api_key: + client_kwargs["api_key"] = api_key + elif provider in ["anthropic", "claude"]: + client_kwargs["model"] = model or "claude-sonnet-4-20250514" + if api_key: + client_kwargs["api_key"] = api_key + + client = get_client(provider, **client_kwargs) + + # 4. ์ถ”๋ก  ์‹คํ–‰ + logger.info(f"Running inference with {provider} provider...") + inference_output = output_dir / "inference_results.json" + + responses = await run_inference( + client, + requests, + output_path=inference_output, + ) + + # 5. ํ‰๊ฐ€ + from .evaluate import evaluate_predictions + from .metrics import aggregate_metrics + + predictions = [ + { + "id": r.id, + "prediction": r.prediction, + "ground_truth": r.ground_truth, + "qa_type": r.qa_type, + "question": p.get("question", ""), + } + for r, p in zip(responses, prompts) + ] + + # Judge ํด๋ผ์ด์–ธํŠธ ์ƒ์„ฑ (ํ•„์š”์‹œ) + judge_client = None + if use_judge: + judge_client = create_judge_client( + provider=judge_provider, + model=judge_model, + api_key=judge_api_key, + ) + + questions = [p.get("question", "") for p in prompts] + results, aggregated = await evaluate_predictions( + predictions, + use_judge=use_judge, + judge_client=judge_client, + questions=questions, + ) + + # 6. ๋ฆฌํฌํŠธ ์ƒ์„ฑ ๋ฐ ์ €์žฅ + from .evaluate import generate_report, print_report + + print_report(aggregated) + + report_path = output_dir / "evaluation_report.json" + report = generate_report( + results, + aggregated, + metadata={ + "qa_output_dir": str(qa_output_dir), + "provider": provider, + "model": model, + "include_images": include_images, + } + ) + + with open(report_path, "w", encoding="utf-8") as f: + json.dump(report, ensure_ascii=False, indent=2, fp=f) + + logger.info(f"Evaluation report saved to: {report_path}") + + return results, aggregated + + +def main(): + """ + ๋ฉ”์ธ ์—”ํŠธ๋ฆฌํฌ์ธํŠธ + + ์‚ฌ์šฉ ์˜ˆ์‹œ: + # uv run์œผ๋กœ ์‹คํ–‰ (๊ถŒ์žฅ) + uv run python -m eval.evaluate_public data/public/public_0/qa_output --provider vllm + + # ๋˜๋Š” bash ์Šคํฌ๋ฆฝํŠธ ์‚ฌ์šฉ + ./eval/evaluate_public.sh + + # OpenAI API ์‚ฌ์šฉ + uv run python -m eval.evaluate_public data/public/public_0/qa_output \\ + --provider openai --model gpt-4o-mini --api-key $OPENAI_API_KEY + """ + parser = argparse.ArgumentParser( + description="Public ๋ฐ์ดํ„ฐ์…‹ ํ‰๊ฐ€ ์Šคํฌ๋ฆฝํŠธ", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + + parser.add_argument( + "qa_output_dir", + type=str, + help="QA output ๋””๋ ‰ํ† ๋ฆฌ ๊ฒฝ๋กœ (์˜ˆ: data/public/public_0/qa_output)", + ) + parser.add_argument( + "--config", + type=str, + help="์„ค์ • ํŒŒ์ผ ๊ฒฝ๋กœ (default: eval/vllm_config.json)", + ) + parser.add_argument( + "--provider", + choices=["vllm", "openai", "anthropic", "claude"], + default="vllm", + help="์ถ”๋ก  ์ œ๊ณต์ž (default: vllm, ์„ค์ • ํŒŒ์ผ์—์„œ ์˜ค๋ฒ„๋ผ์ด๋“œ ๊ฐ€๋Šฅ)", + ) + parser.add_argument( + "--output-dir", + type=str, + help="๊ฒฐ๊ณผ ์ €์žฅ ๋””๋ ‰ํ† ๋ฆฌ (default: qa_output_dir/../eval_results)", + ) + parser.add_argument( + "--base-url", + type=str, + help="vLLM ์„œ๋ฒ„ URL (default: http://localhost:8000/v1)", + ) + parser.add_argument( + "--model", + type=str, + help="๋ชจ๋ธ ์ด๋ฆ„", + ) + parser.add_argument( + "--api-key", + type=str, + help="API ํ‚ค (OpenAI/Anthropic์šฉ)", + ) + parser.add_argument( + "--max-tokens", + type=int, + default=None, + help="์ตœ๋Œ€ ํ† ํฐ ์ˆ˜ (๋ฏธ์ง€์ • ์‹œ ์„ค์ • ํŒŒ์ผ/๊ธฐ๋ณธ๊ฐ’ ์‚ฌ์šฉ)", + ) + parser.add_argument( + "--temperature", + type=float, + default=None, + help="์ƒ˜ํ”Œ๋ง ์˜จ๋„ (๋ฏธ์ง€์ • ์‹œ ์„ค์ • ํŒŒ์ผ/๊ธฐ๋ณธ๊ฐ’ ์‚ฌ์šฉ)", + ) + parser.add_argument( + "--max-concurrent", + type=int, + default=None, + help="์ตœ๋Œ€ ๋™์‹œ ์š”์ฒญ ์ˆ˜ (๋ฏธ์ง€์ • ์‹œ ์„ค์ • ํŒŒ์ผ/๊ธฐ๋ณธ๊ฐ’ ์‚ฌ์šฉ)", + ) + parser.add_argument( + "--no-images", + action="store_true", + help="์ด๋ฏธ์ง€ ํฌํ•จํ•˜์ง€ ์•Š๊ธฐ", + ) + parser.add_argument( + "--prompt-template", + type=str, + help="ํ”„๋กฌํ”„ํŠธ ํ…œํ”Œ๋ฆฟ ํŒŒ์ผ ๊ฒฝ๋กœ", + ) + parser.add_argument( + "-v", "--verbose", + action="store_true", + help="์ƒ์„ธ ๋กœ๊ทธ ์ถœ๋ ฅ", + ) + + # LLM-as-Judge ์˜ต์…˜ + parser.add_argument( + "--use-judge", + action="store_true", + default=None, + help="LLM-as-Judge ํ‰๊ฐ€ ์‚ฌ์šฉ (๋ฏธ์ง€์ • ์‹œ ์„ค์ • ํŒŒ์ผ์˜ judge.enabled ๋”ฐ๋ฆ„)", + ) + parser.add_argument( + "--judge-provider", + choices=["vllm", "openai", "anthropic", "claude"], + default=None, + help="Judge ์ œ๊ณต์ž (๋ฏธ์ง€์ • ์‹œ ์„ค์ • ํŒŒ์ผ/๊ธฐ๋ณธ๊ฐ’ ์‚ฌ์šฉ)", + ) + parser.add_argument( + "--judge-model", + type=str, + default=None, + help="Judge ๋ชจ๋ธ ์ด๋ฆ„ (๋ฏธ์ง€์ • ์‹œ ์„ค์ • ํŒŒ์ผ/๊ธฐ๋ณธ๊ฐ’ ์‚ฌ์šฉ)", + ) + parser.add_argument( + "--judge-api-key", + type=str, + default=None, + help="Judge API ํ‚ค (๋ฏธ์ง€์ • ์‹œ ์„ค์ • ํŒŒ์ผ/ํ™˜๊ฒฝ๋ณ€์ˆ˜ ์‚ฌ์šฉ)", + ) + + args = parser.parse_args() + + # ํ”„๋กฌํ”„ํŠธ ํ…œํ”Œ๋ฆฟ ๋กœ๋“œ + prompt_template = None + if args.prompt_template: + prompt_template = Path(args.prompt_template).read_text(encoding="utf-8") + + # ๋น„๋™๊ธฐ ์‹คํ–‰ + try: + config_path = Path(args.config) if getattr(args, 'config', None) else None + + results, aggregated = asyncio.run( + evaluate_public_dataset( + qa_output_dir=Path(args.qa_output_dir), + provider=args.provider, + output_dir=Path(args.output_dir) if args.output_dir else None, + base_url=args.base_url, + model=args.model, + api_key=args.api_key, + max_tokens=args.max_tokens, + temperature=args.temperature, + max_concurrent=args.max_concurrent, + include_images=not args.no_images, + prompt_template=prompt_template, + verbose=args.verbose, + use_judge=args.use_judge, + judge_provider=args.judge_provider, + judge_model=args.judge_model, + judge_api_key=args.judge_api_key, + config_path=config_path, + ) + ) + + if results is None: + return 1 + + return 0 + except Exception as e: + logging.error(f"Evaluation failed: {e}", exc_info=True) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) + diff --git a/eval/evaluate_public.sh b/eval/evaluate_public.sh new file mode 100755 index 0000000..c6f7cde --- /dev/null +++ b/eval/evaluate_public.sh @@ -0,0 +1,295 @@ +#!/bin/bash +# +# Public ๋ฐ์ดํ„ฐ์…‹ ํ‰๊ฐ€ ์Šคํฌ๋ฆฝํŠธ +# +# ์‚ฌ์šฉ๋ฒ•: +# ./eval/evaluate_public.sh [์˜ต์…˜] +# ๋˜๋Š” +# uv run python -m eval.evaluate_public [์˜ต์…˜] +# +# ์˜ˆ์‹œ: +# # vLLM ์„œ๋ฒ„ ์‚ฌ์šฉ (๊ธฐ๋ณธ) +# ./eval/evaluate_public.sh +# +# # OpenAI API ์‚ฌ์šฉ +# ./eval/evaluate_public.sh --provider openai --model gpt-4o-mini --api-key $OPENAI_API_KEY +# +# # Anthropic Claude ์‚ฌ์šฉ +# ./eval/evaluate_public.sh --provider anthropic --model claude-sonnet-4-20250514 --api-key $ANTHROPIC_API_KEY +# +# # uv run์œผ๋กœ ์ง์ ‘ ์‹คํ–‰ +# uv run python -m eval.evaluate_public data/public/public_0/qa_output --provider vllm +# + +set -e # ์—๋Ÿฌ ๋ฐœ์ƒ ์‹œ ์ค‘๋‹จ + +# ๊ธฐ๋ณธ ์„ค์ • +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +CONFIG_FILE="${SCRIPT_DIR}/vllm_config.json" +QA_OUTPUT_DIR="${PROJECT_ROOT}/data/public/public_0/qa_output" +OUTPUT_DIR="${PROJECT_ROOT}/data/public/public_0/eval_results" +PROVIDER="vllm" +BASE_URL="" +MODEL="" +API_KEY="" +MAX_TOKENS="" +TEMPERATURE="" +MAX_CONCURRENT="" +INCLUDE_IMAGES=true +PROMPT_TEMPLATE="" +VERBOSE=false +USE_JUDGE="" +JUDGE_PROVIDER="" +JUDGE_MODEL="" +JUDGE_API_KEY="" +CONFIG_PATH="$CONFIG_FILE" + +# ์ƒ‰์ƒ ์ถœ๋ ฅ +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# ๋„์›€๋ง ์ถœ๋ ฅ +show_help() { + cat << EOF +Public ๋ฐ์ดํ„ฐ์…‹ ํ‰๊ฐ€ ์Šคํฌ๋ฆฝํŠธ + +์‚ฌ์šฉ๋ฒ•: + $0 [์˜ต์…˜] + +์˜ต์…˜: + --config FILE ์„ค์ • ํŒŒ์ผ ๊ฒฝ๋กœ + (๊ธฐ๋ณธ: eval/vllm_config.json) + --qa-output-dir DIR QA output ๋””๋ ‰ํ† ๋ฆฌ ๊ฒฝ๋กœ + (๊ธฐ๋ณธ: data/public/public_0/qa_output) + --output-dir DIR ๊ฒฐ๊ณผ ์ €์žฅ ๋””๋ ‰ํ† ๋ฆฌ + (๊ธฐ๋ณธ: data/public/public_0/eval_results) + --provider PROVIDER ์ถ”๋ก  ์ œ๊ณต์ž: vllm, openai, anthropic, claude + (๊ธฐ๋ณธ: vllm) + --base-url URL vLLM ์„œ๋ฒ„ URL + (๊ธฐ๋ณธ: http://localhost:8000/v1) + --model MODEL ๋ชจ๋ธ ์ด๋ฆ„ + --api-key KEY API ํ‚ค (OpenAI/Anthropic์šฉ) + --max-tokens N ์ตœ๋Œ€ ํ† ํฐ ์ˆ˜ (๊ธฐ๋ณธ: 512) + --temperature T ์ƒ˜ํ”Œ๋ง ์˜จ๋„ (๊ธฐ๋ณธ: 0.0) + --max-concurrent N ์ตœ๋Œ€ ๋™์‹œ ์š”์ฒญ ์ˆ˜ (๊ธฐ๋ณธ: 10) + --no-images ์ด๋ฏธ์ง€ ํฌํ•จํ•˜์ง€ ์•Š๊ธฐ + --prompt-template FILE ํ”„๋กฌํ”„ํŠธ ํ…œํ”Œ๋ฆฟ ํŒŒ์ผ ๊ฒฝ๋กœ + -v, --verbose ์ƒ์„ธ ๋กœ๊ทธ ์ถœ๋ ฅ + -h, --help ์ด ๋„์›€๋ง ์ถœ๋ ฅ + +์˜ˆ์‹œ: + # vLLM ์„œ๋ฒ„ ์‚ฌ์šฉ + $0 + + # OpenAI API ์‚ฌ์šฉ + $0 --provider openai --model gpt-4o-mini --api-key \$OPENAI_API_KEY + + # Anthropic Claude ์‚ฌ์šฉ + $0 --provider anthropic --model claude-sonnet-4-20250514 --api-key \$ANTHROPIC_API_KEY + + # ์ปค์Šคํ…€ QA ๋””๋ ‰ํ† ๋ฆฌ ์‚ฌ์šฉ + $0 --qa-output-dir data/public/public_1/qa_output + + # uv run์œผ๋กœ ์ง์ ‘ ์‹คํ–‰ (bash ์Šคํฌ๋ฆฝํŠธ ์—†์ด) + uv run python -m eval.evaluate_public data/public/public_0/qa_output --provider vllm + +EOF +} + +# ์ธ์ž ํŒŒ์‹ฑ +while [[ $# -gt 0 ]]; do + case $1 in + --config) + CONFIG_FILE="$2" + CONFIG_PATH="$2" + shift 2 + ;; + --qa-output-dir) + QA_OUTPUT_DIR="$2" + shift 2 + ;; + --output-dir) + OUTPUT_DIR="$2" + shift 2 + ;; + --provider) + PROVIDER="$2" + shift 2 + ;; + --base-url) + BASE_URL="$2" + shift 2 + ;; + --model) + MODEL="$2" + shift 2 + ;; + --api-key) + API_KEY="$2" + shift 2 + ;; + --max-tokens) + MAX_TOKENS="$2" + shift 2 + ;; + --temperature) + TEMPERATURE="$2" + shift 2 + ;; + --max-concurrent) + MAX_CONCURRENT="$2" + shift 2 + ;; + --no-images) + INCLUDE_IMAGES=false + shift + ;; + --prompt-template) + PROMPT_TEMPLATE="$2" + shift 2 + ;; + --use-judge) + USE_JUDGE=true + shift + ;; + --judge-provider) + JUDGE_PROVIDER="$2" + shift 2 + ;; + --judge-model) + JUDGE_MODEL="$2" + shift 2 + ;; + --judge-api-key) + JUDGE_API_KEY="$2" + shift 2 + ;; + -v|--verbose) + VERBOSE=true + shift + ;; + -h|--help) + show_help + exit 0 + ;; + *) + echo -e "${RED}์•Œ ์ˆ˜ ์—†๋Š” ์˜ต์…˜: $1${NC}" >&2 + show_help + exit 1 + ;; + esac +done + +# ํ”„๋กœ์ ํŠธ ๋ฃจํŠธ๋กœ ์ด๋™ +cd "$PROJECT_ROOT" + +# uv ์„ค์น˜ ํ™•์ธ +if ! command -v uv &> /dev/null; then + echo -e "${YELLOW}๊ฒฝ๊ณ : uv๊ฐ€ ์„ค์น˜๋˜์–ด ์žˆ์ง€ ์•Š์Šต๋‹ˆ๋‹ค. uv๋ฅผ ์„ค์น˜ํ•˜๊ฑฐ๋‚˜ python์„ ์ง์ ‘ ์‚ฌ์šฉํ•˜์„ธ์š”.${NC}" >&2 + echo -e "${YELLOW}uv ์„ค์น˜: curl -LsSf https://astral.sh/uv/install.sh | sh${NC}" >&2 + echo -e "${YELLOW}๋˜๋Š” python์„ ์ง์ ‘ ์‚ฌ์šฉ: python -m eval.evaluate_public ...${NC}" >&2 + # uv๊ฐ€ ์—†์–ด๋„ ๊ณ„์† ์ง„ํ–‰ (python ์ง์ ‘ ์‚ฌ์šฉ) + USE_UV=false +else + USE_UV=true +fi + +# QA output ๋””๋ ‰ํ† ๋ฆฌ ํ™•์ธ +if [ ! -d "$QA_OUTPUT_DIR" ]; then + echo -e "${RED}์˜ค๋ฅ˜: QA output ๋””๋ ‰ํ† ๋ฆฌ๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค: $QA_OUTPUT_DIR${NC}" >&2 + exit 1 +fi + +# QA ํŒŒ์ผ ํ™•์ธ +QA_FILES=$(find "$QA_OUTPUT_DIR" -name "*_qa.json" | wc -l) +if [ "$QA_FILES" -eq 0 ]; then + echo -e "${YELLOW}๊ฒฝ๊ณ : QA ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค: $QA_OUTPUT_DIR/*_qa.json${NC}" >&2 + exit 1 +fi + +echo -e "${GREEN}=== Public ๋ฐ์ดํ„ฐ์…‹ ํ‰๊ฐ€ ์‹œ์ž‘ ===${NC}" +echo "QA Output ๋””๋ ‰ํ† ๋ฆฌ: $QA_OUTPUT_DIR" +echo "QA ํŒŒ์ผ ์ˆ˜: $QA_FILES" +echo "Provider: $PROVIDER" +echo "Output ๋””๋ ‰ํ† ๋ฆฌ: $OUTPUT_DIR" +echo "" + +# Python ์Šคํฌ๋ฆฝํŠธ ์‹คํ–‰ (uv run ์‚ฌ์šฉ, ์—†์œผ๋ฉด python ์ง์ ‘ ์‚ฌ์šฉ) +if [ "$USE_UV" = true ]; then + PYTHON_CMD="uv run python -m eval.evaluate_public" +else + PYTHON_CMD="python -m eval.evaluate_public" +fi +PYTHON_CMD="$PYTHON_CMD \"$QA_OUTPUT_DIR\"" +PYTHON_CMD="$PYTHON_CMD --provider $PROVIDER" +PYTHON_CMD="$PYTHON_CMD --output-dir \"$OUTPUT_DIR\"" +PYTHON_CMD="$PYTHON_CMD --config \"$CONFIG_PATH\"" + +if [ -n "$BASE_URL" ]; then + PYTHON_CMD="$PYTHON_CMD --base-url \"$BASE_URL\"" +fi + +if [ -n "$MODEL" ]; then + PYTHON_CMD="$PYTHON_CMD --model \"$MODEL\"" +fi + +if [ -n "$API_KEY" ]; then + PYTHON_CMD="$PYTHON_CMD --api-key \"$API_KEY\"" +fi + +if [ -n "$MAX_TOKENS" ]; then + PYTHON_CMD="$PYTHON_CMD --max-tokens $MAX_TOKENS" +fi + +if [ -n "$TEMPERATURE" ]; then + PYTHON_CMD="$PYTHON_CMD --temperature $TEMPERATURE" +fi + +if [ -n "$MAX_CONCURRENT" ]; then + PYTHON_CMD="$PYTHON_CMD --max-concurrent $MAX_CONCURRENT" +fi + +if [ "$INCLUDE_IMAGES" = false ]; then + PYTHON_CMD="$PYTHON_CMD --no-images" +fi + +if [ -n "$PROMPT_TEMPLATE" ]; then + PYTHON_CMD="$PYTHON_CMD --prompt-template \"$PROMPT_TEMPLATE\"" +fi + +if [ "$VERBOSE" = true ]; then + PYTHON_CMD="$PYTHON_CMD --verbose" +fi + +if [ "$USE_JUDGE" = true ]; then + PYTHON_CMD="$PYTHON_CMD --use-judge" + PYTHON_CMD="$PYTHON_CMD --judge-provider \"$JUDGE_PROVIDER\"" + if [ -n "$JUDGE_MODEL" ]; then + PYTHON_CMD="$PYTHON_CMD --judge-model \"$JUDGE_MODEL\"" + fi + if [ -n "$JUDGE_API_KEY" ]; then + PYTHON_CMD="$PYTHON_CMD --judge-api-key \"$JUDGE_API_KEY\"" + fi +fi + +# ์‹คํ–‰ +echo -e "${GREEN}ํ‰๊ฐ€ ์‹คํ–‰ ์ค‘...${NC}" +eval $PYTHON_CMD + +EXIT_CODE=$? + +if [ $EXIT_CODE -eq 0 ]; then + echo "" + echo -e "${GREEN}=== ํ‰๊ฐ€ ์™„๋ฃŒ ===${NC}" + echo "๊ฒฐ๊ณผ ์ €์žฅ ์œ„์น˜: $OUTPUT_DIR" + echo " - inference_results.json: ์ถ”๋ก  ๊ฒฐ๊ณผ" + echo " - evaluation_report.json: ํ‰๊ฐ€ ๋ฆฌํฌํŠธ" +else + echo "" + echo -e "${RED}=== ํ‰๊ฐ€ ์‹คํŒจ ===${NC}" + exit $EXIT_CODE +fi + diff --git a/eval/inference.py b/eval/inference.py index 5bde766..7c01f4a 100644 --- a/eval/inference.py +++ b/eval/inference.py @@ -92,12 +92,30 @@ def __init__( "Authorization": f"Bearer {api_key}", } - async def generate(self, prompt: str, **kwargs) -> str: - """๋‹จ์ผ ํ”„๋กฌํ”„ํŠธ์— ๋Œ€ํ•œ ์‘๋‹ต ์ƒ์„ฑ""" + async def generate( + self, + prompt: str, + image_paths: Optional[List[str]] = None, + **kwargs + ) -> str: + """๋‹จ์ผ ํ”„๋กฌํ”„ํŠธ์— ๋Œ€ํ•œ ์‘๋‹ต ์ƒ์„ฑ (๋ฉ€ํ‹ฐ ์ด๋ฏธ์ง€ ์ง€์›)""" async with httpx.AsyncClient(timeout=self.timeout) as client: + # ๋ฉ€ํ‹ฐ ์ด๋ฏธ์ง€ ์ง€์›: content๋ฅผ ๋ฐฐ์—ด๋กœ ๊ตฌ์„ฑ + content = [{"type": "text", "text": prompt}] + + # ์ด๋ฏธ์ง€ ์ถ”๊ฐ€ (๋ฉ€ํ‹ฐ๋ชจ๋‹ฌ) + if image_paths: + for img_path in image_paths: + img_data = self._encode_image(img_path) + if img_data: + content.append({ + "type": "image_url", + "image_url": {"url": img_data} + }) + payload = { "model": kwargs.get("model", self.model), - "messages": [{"role": "user", "content": prompt}], + "messages": [{"role": "user", "content": content}], "max_tokens": kwargs.get("max_tokens", self.max_tokens), "temperature": kwargs.get("temperature", self.temperature), } @@ -111,6 +129,31 @@ async def generate(self, prompt: str, **kwargs) -> str: data = response.json() return data["choices"][0]["message"]["content"] + + def _encode_image(self, image_path: str) -> Optional[str]: + """์ด๋ฏธ์ง€๋ฅผ base64๋กœ ์ธ์ฝ”๋”ฉ""" + try: + path = Path(image_path) + if not path.exists(): + # ์ƒ๋Œ€ ๊ฒฝ๋กœ ์‹œ workspace ๊ธฐ์ค€์œผ๋กœ ์žฌ์‹œ๋„ + workspace_path = Path("/workspace/TableMagnifier") / image_path + if workspace_path.exists(): + path = workspace_path + else: + return None + + mime = "image/png" + suffix = path.suffix.lower() + if suffix in {".jpg", ".jpeg"}: + mime = "image/jpeg" + elif suffix == ".webp": + mime = "image/webp" + + encoded = base64.b64encode(path.read_bytes()).decode("utf-8") + return f"data:{mime};base64,{encoded}" + except Exception as e: + logger.warning(f"Failed to encode image {image_path}: {e}") + return None async def generate_batch( self, @@ -129,7 +172,11 @@ async def process_one(req: InferenceRequest, idx: int) -> InferenceResponse: prediction = "" try: - prediction = await self.generate(req.prompt, **kwargs) + prediction = await self.generate( + req.prompt, + image_paths=req.image_paths, + **kwargs + ) except Exception as e: error = str(e) logger.warning(f"Request {req.id} failed: {e}") @@ -231,7 +278,12 @@ def _encode_image(self, image_path: str) -> Optional[str]: try: path = Path(image_path) if not path.exists(): - return None + # ์ƒ๋Œ€ ๊ฒฝ๋กœ ์‹œ workspace ๊ธฐ์ค€์œผ๋กœ ์žฌ์‹œ๋„ + workspace_path = Path("/workspace/TableMagnifier") / image_path + if workspace_path.exists(): + path = workspace_path + else: + return None mime = "image/png" suffix = path.suffix.lower() @@ -369,7 +421,12 @@ def _encode_image(self, image_path: str) -> Optional[Dict[str, Any]]: try: path = Path(image_path) if not path.exists(): - return None + # ์ƒ๋Œ€ ๊ฒฝ๋กœ ์‹œ workspace ๊ธฐ์ค€์œผ๋กœ ์žฌ์‹œ๋„ + workspace_path = Path("/workspace/TableMagnifier") / image_path + if workspace_path.exists(): + path = workspace_path + else: + return None media_type = "image/png" suffix = path.suffix.lower() diff --git a/eval/llm_judge.py b/eval/llm_judge.py new file mode 100644 index 0000000..c51cdd0 --- /dev/null +++ b/eval/llm_judge.py @@ -0,0 +1,251 @@ +""" +LLM-as-Judge ํ‰๊ฐ€ ๋ชจ๋“ˆ + +์˜ˆ์ธก ๋‹ต๋ณ€๊ณผ ์ •๋‹ต์„ ๋น„๊ตํ•˜์—ฌ ์˜๋ฏธ์  ์ •ํ™•๋„๋ฅผ ํ‰๊ฐ€ํ•ฉ๋‹ˆ๋‹ค. +""" + +from __future__ import annotations + +import json +import logging +import asyncio +from dataclasses import dataclass +from typing import List, Dict, Any, Optional +from pathlib import Path + +from .inference import InferenceClient, get_client, InferenceRequest + +logger = logging.getLogger(__name__) + + +# LLM-as-Judge ํ”„๋กฌํ”„ํŠธ ํ…œํ”Œ๋ฆฟ +JUDGE_PROMPT_TEMPLATE = """๋‹ค์Œ ์งˆ๋ฌธ์— ๋Œ€ํ•œ ๋ชจ๋ธ์˜ ์˜ˆ์ธก ๋‹ต๋ณ€๊ณผ ์ •๋‹ต์„ ๋น„๊ตํ•˜์—ฌ ํ‰๊ฐ€ํ•ด์ฃผ์„ธ์š”. + +## ์งˆ๋ฌธ +{question} + +## ์ •๋‹ต +{ground_truth} + +## ๋ชจ๋ธ ์˜ˆ์ธก ๋‹ต๋ณ€ +{prediction} + +## ํ‰๊ฐ€ ๊ธฐ์ค€ +1. **์ •ํ™•์„ฑ (Correctness)**: ์˜ˆ์ธก ๋‹ต๋ณ€์ด ์ •๋‹ต๊ณผ ์˜๋ฏธ์ ์œผ๋กœ ์ผ์น˜ํ•˜๋Š”๊ฐ€? + - ์™„์ „ ์ผ์น˜: 1.0 + - ๋ถ€๋ถ„ ์ผ์น˜ (ํ•ต์‹ฌ ๋‚ด์šฉ์€ ๋งž์ง€๋งŒ ํ‘œํ˜„์ด ๋‹ค๋ฆ„): 0.5-0.9 + - ๊ด€๋ จ ์žˆ์ง€๋งŒ ๋ถ€์ •ํ™•: 0.1-0.4 + - ์™„์ „ํžˆ ํ‹€๋ฆผ: 0.0 + +2. **์™„์ „์„ฑ (Completeness)**: ์ •๋‹ต์˜ ๋ชจ๋“  ์ •๋ณด๋ฅผ ํฌํ•จํ•˜๋Š”๊ฐ€? + - ๋ชจ๋“  ์ •๋ณด ํฌํ•จ: 1.0 + - ์ผ๋ถ€ ์ •๋ณด ํฌํ•จ: 0.3-0.7 + - ํ•ต์‹ฌ ์ •๋ณด ๋ˆ„๋ฝ: 0.0-0.2 + +3. **๊ด€๋ จ์„ฑ (Relevance)**: ๋‹ต๋ณ€์ด ์งˆ๋ฌธ๊ณผ ๊ด€๋ จ์ด ์žˆ๋Š”๊ฐ€? + - ๋งค์šฐ ๊ด€๋ จ ์žˆ์Œ: 1.0 + - ๊ด€๋ จ ์žˆ์Œ: 0.5-0.9 + - ๊ด€๋ จ ์—†์Œ: 0.0-0.4 + +## ์ถœ๋ ฅ ํ˜•์‹ (JSON) +๋‹ค์Œ ํ˜•์‹์œผ๋กœ ํ‰๊ฐ€ ๊ฒฐ๊ณผ๋ฅผ ์ถœ๋ ฅํ•ด์ฃผ์„ธ์š”: +```json +{{ + "correctness": 0.0-1.0, + "completeness": 0.0-1.0, + "relevance": 0.0-1.0, + "overall_score": 0.0-1.0, + "is_correct": true/false, + "explanation": "ํ‰๊ฐ€ ๊ทผ๊ฑฐ ์„ค๋ช…" +}} +``` + +ํ‰๊ฐ€ ๊ฒฐ๊ณผ๋งŒ ์ถœ๋ ฅํ•ด์ฃผ์„ธ์š”.""" + + +@dataclass +class JudgeResult: + """LLM-as-Judge ํ‰๊ฐ€ ๊ฒฐ๊ณผ""" + id: str + correctness: float # 0.0-1.0 + completeness: float # 0.0-1.0 + relevance: float # 0.0-1.0 + overall_score: float # 0.0-1.0 + is_correct: bool + explanation: str + error: Optional[str] = None + + +def parse_judge_response(response: str) -> Optional[Dict[str, Any]]: + """LLM ์‘๋‹ต์„ ํŒŒ์‹ฑํ•˜์—ฌ ํ‰๊ฐ€ ๊ฒฐ๊ณผ ์ถ”์ถœ""" + try: + # JSON ์ฝ”๋“œ ๋ธ”๋ก ์ œ๊ฑฐ + response = response.strip() + if "```json" in response: + start = response.find("```json") + 7 + end = response.find("```", start) + response = response[start:end].strip() + elif "```" in response: + start = response.find("```") + 3 + end = response.find("```", start) + response = response[start:end].strip() + + # JSON ํŒŒ์‹ฑ + result = json.loads(response) + return result + except Exception as e: + logger.warning(f"Failed to parse judge response: {e}\nResponse: {response[:200]}") + return None + + +async def judge_single_answer( + client: InferenceClient, + question: str, + ground_truth: str, + prediction: str, + item_id: str = "", +) -> JudgeResult: + """ + ๋‹จ์ผ ๋‹ต๋ณ€์„ LLM-as-Judge๋กœ ํ‰๊ฐ€ํ•ฉ๋‹ˆ๋‹ค. + + Args: + client: ์ถ”๋ก  ํด๋ผ์ด์–ธํŠธ + question: ์งˆ๋ฌธ + ground_truth: ์ •๋‹ต + prediction: ์˜ˆ์ธก ๋‹ต๋ณ€ + item_id: ํ•ญ๋ชฉ ID + + Returns: + JudgeResult + """ + prompt = JUDGE_PROMPT_TEMPLATE.format( + question=question, + ground_truth=ground_truth, + prediction=prediction, + ) + + try: + response_text = await client.generate(prompt) + result = parse_judge_response(response_text) + + if result: + return JudgeResult( + id=item_id, + correctness=float(result.get("correctness", 0.0)), + completeness=float(result.get("completeness", 0.0)), + relevance=float(result.get("relevance", 0.0)), + overall_score=float(result.get("overall_score", 0.0)), + is_correct=bool(result.get("is_correct", False)), + explanation=str(result.get("explanation", "")), + ) + else: + return JudgeResult( + id=item_id, + correctness=0.0, + completeness=0.0, + relevance=0.0, + overall_score=0.0, + is_correct=False, + explanation="Failed to parse judge response", + error="Parse error", + ) + except Exception as e: + logger.error(f"Judge evaluation failed for {item_id}: {e}") + return JudgeResult( + id=item_id, + correctness=0.0, + completeness=0.0, + relevance=0.0, + overall_score=0.0, + is_correct=False, + explanation="", + error=str(e), + ) + + +async def judge_batch( + client: InferenceClient, + items: List[Dict[str, Any]], + max_concurrent: int = 5, + show_progress: bool = True, +) -> List[JudgeResult]: + """ + ๋ฐฐ์น˜๋กœ ๋‹ต๋ณ€๋“ค์„ LLM-as-Judge๋กœ ํ‰๊ฐ€ํ•ฉ๋‹ˆ๋‹ค. + + Args: + client: ์ถ”๋ก  ํด๋ผ์ด์–ธํŠธ + items: ํ‰๊ฐ€ํ•  ํ•ญ๋ชฉ ๋ฆฌ์ŠคํŠธ + [{"id": "...", "question": "...", "ground_truth": "...", "prediction": "..."}] + max_concurrent: ์ตœ๋Œ€ ๋™์‹œ ์š”์ฒญ ์ˆ˜ + show_progress: ์ง„ํ–‰ ์ƒํ™ฉ ์ถœ๋ ฅ ์—ฌ๋ถ€ + + Returns: + JudgeResult ๋ฆฌ์ŠคํŠธ + """ + semaphore = asyncio.Semaphore(max_concurrent) + results = [] + + async def judge_one(item: Dict[str, Any], idx: int) -> JudgeResult: + async with semaphore: + result = await judge_single_answer( + client=client, + question=item.get("question", ""), + ground_truth=item.get("ground_truth", ""), + prediction=item.get("prediction", ""), + item_id=item.get("id", f"item_{idx}"), + ) + + if show_progress: + logger.info( + f"[Judge {idx+1}/{len(items)}] {result.id} - " + f"Score: {result.overall_score:.2f}, " + f"Correct: {result.is_correct}" + ) + + return result + + tasks = [judge_one(item, i) for i, item in enumerate(items)] + results = await asyncio.gather(*tasks) + + return list(results) + + +def create_judge_client( + provider: str = "openai", + model: Optional[str] = None, + api_key: Optional[str] = None, + **kwargs +) -> InferenceClient: + """ + LLM-as-Judge์šฉ ํด๋ผ์ด์–ธํŠธ๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. + + Args: + provider: ์ œ๊ณต์ž (openai, anthropic, vllm) + model: ๋ชจ๋ธ ์ด๋ฆ„ + api_key: API ํ‚ค + **kwargs: ์ถ”๊ฐ€ ์„ค์ • + + Returns: + InferenceClient + """ + judge_kwargs = { + "max_tokens": kwargs.get("max_tokens", 512), + "temperature": kwargs.get("temperature", 0.0), + "max_concurrent": kwargs.get("max_concurrent", 5), + } + + if provider == "openai": + judge_kwargs["model"] = model or "gpt-4o-mini" + if api_key: + judge_kwargs["api_key"] = api_key + elif provider in ["anthropic", "claude"]: + judge_kwargs["model"] = model or "claude-sonnet-4-20250514" + if api_key: + judge_kwargs["api_key"] = api_key + elif provider == "vllm": + judge_kwargs["base_url"] = kwargs.get("base_url", "http://localhost:8000/v1") + judge_kwargs["model"] = model or "default" + judge_kwargs["api_key"] = api_key or "EMPTY" + + return get_client(provider, **judge_kwargs) + diff --git a/eval/metrics.py b/eval/metrics.py index 2135814..d84c8f3 100644 --- a/eval/metrics.py +++ b/eval/metrics.py @@ -217,6 +217,13 @@ class EvalResult: contains_match: float bleu_score: float = 0.0 numeric_match: Optional[float] = None + # LLM-as-Judge ๊ฒฐ๊ณผ + judge_correctness: Optional[float] = None + judge_completeness: Optional[float] = None + judge_relevance: Optional[float] = None + judge_overall_score: Optional[float] = None + judge_is_correct: Optional[bool] = None + judge_explanation: Optional[str] = None def to_dict(self) -> Dict[str, Any]: result = { @@ -231,6 +238,13 @@ def to_dict(self) -> Dict[str, Any]: } if self.numeric_match is not None: result["numeric_match"] = self.numeric_match + if self.judge_overall_score is not None: + result["judge_correctness"] = self.judge_correctness + result["judge_completeness"] = self.judge_completeness + result["judge_relevance"] = self.judge_relevance + result["judge_overall_score"] = self.judge_overall_score + result["judge_is_correct"] = self.judge_is_correct + result["judge_explanation"] = self.judge_explanation return result @@ -242,10 +256,15 @@ class AggregatedMetrics: f1_score_avg: float = 0.0 contains_match_avg: float = 0.0 bleu_score_avg: float = 0.0 + judge_overall_avg: Optional[float] = None + judge_correctness_avg: Optional[float] = None + judge_completeness_avg: Optional[float] = None + judge_relevance_avg: Optional[float] = None + judge_accuracy: Optional[float] = None # judge_is_correct์˜ ๋น„์œจ by_type: Dict[str, Dict[str, float]] = field(default_factory=dict) def to_dict(self) -> Dict[str, Any]: - return { + result = { "total_count": self.total_count, "exact_match": self.exact_match_avg, "f1_score": self.f1_score_avg, @@ -253,6 +272,13 @@ def to_dict(self) -> Dict[str, Any]: "bleu_score": self.bleu_score_avg, "by_type": self.by_type, } + if self.judge_overall_avg is not None: + result["judge_overall_avg"] = self.judge_overall_avg + result["judge_correctness_avg"] = self.judge_correctness_avg + result["judge_completeness_avg"] = self.judge_completeness_avg + result["judge_relevance_avg"] = self.judge_relevance_avg + result["judge_accuracy"] = self.judge_accuracy + return result def compute_metrics( @@ -315,6 +341,21 @@ def aggregate_metrics(results: List[EvalResult]) -> AggregatedMetrics: cm_sum = sum(r.contains_match for r in results) bleu_sum = sum(r.bleu_score for r in results) + # LLM-as-Judge ๋ฉ”ํŠธ๋ฆญ ์ง‘๊ณ„ + judge_results = [r for r in results if r.judge_overall_score is not None] + judge_overall_avg = None + judge_correctness_avg = None + judge_completeness_avg = None + judge_relevance_avg = None + judge_accuracy = None + + if judge_results: + judge_overall_avg = sum(r.judge_overall_score for r in judge_results) / len(judge_results) + judge_correctness_avg = sum(r.judge_correctness for r in judge_results) / len(judge_results) + judge_completeness_avg = sum(r.judge_completeness for r in judge_results) / len(judge_results) + judge_relevance_avg = sum(r.judge_relevance for r in judge_results) / len(judge_results) + judge_accuracy = sum(1 for r in judge_results if r.judge_is_correct) / len(judge_results) + # ์œ ํ˜•๋ณ„ ์ง‘๊ณ„ by_type: Dict[str, Dict[str, List[float]]] = {} for r in results: @@ -347,5 +388,10 @@ def aggregate_metrics(results: List[EvalResult]) -> AggregatedMetrics: f1_score_avg=f1_sum / total, contains_match_avg=cm_sum / total, bleu_score_avg=bleu_sum / total, + judge_overall_avg=judge_overall_avg, + judge_correctness_avg=judge_correctness_avg, + judge_completeness_avg=judge_completeness_avg, + judge_relevance_avg=judge_relevance_avg, + judge_accuracy=judge_accuracy, by_type=type_metrics, ) diff --git a/eval/vllm_config.json b/eval/vllm_config.json new file mode 100644 index 0000000..a6499d3 --- /dev/null +++ b/eval/vllm_config.json @@ -0,0 +1,19 @@ +{ + "base_url": "", + "model": "", + "api_key": "EMPTY", + "max_tokens": 512, + "temperature": 0.0, + "max_concurrent": 2, + "judge": { + "enabled": false, + "provider": "vllm", + "model": "", + "base_url": "", + "api_key": "EMPTY", + "max_tokens": 512, + "temperature": 0.0, + "max_concurrent": 2 + } +} + diff --git a/pipeline_ui/backend/checkpoints/8c63b7c0.json b/pipeline_ui/backend/checkpoints/8c63b7c0.json deleted file mode 100644 index 7779c14..0000000 --- a/pipeline_ui/backend/checkpoints/8c63b7c0.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "id": "8c63b7c0", - "items": [ - { - "id": "aba45dc3", - "image_path": "/Users/jaehyeokchoi/Desktop/TableMagnifier/pipeline_ui/backend/uploads/396b6af2_image.png", - "image_name": "396b6af2_image.png", - "status": "running", - "current_node": null, - "progress": 10, - "result": null, - "error": null, - "created_at": "2025-12-23T21:31:18.814684", - "updated_at": "2025-12-23T21:31:18.815773" - } - ], - "status": "running", - "total": 1, - "completed": 0, - "failed": 0, - "created_at": "2025-12-23T21:31:18.814684", - "updated_at": "2025-12-23T21:31:18.815795" -} \ No newline at end of file diff --git a/pipeline_ui/backend/checkpoints/966964e7.json b/pipeline_ui/backend/checkpoints/966964e7.json deleted file mode 100644 index 92b14ba..0000000 --- a/pipeline_ui/backend/checkpoints/966964e7.json +++ /dev/null @@ -1,92 +0,0 @@ -{ - "id": "966964e7", - "items": [ - { - "id": "be5456ef", - "image_path": "/Users/jaehyeokchoi/Desktop/TableMagnifier/pipeline_ui/backend/uploads/d4524a1c_image.png", - "image_name": "d4524a1c_image.png", - "status": "completed", - "current_node": null, - "progress": 100, - "result": { - "image_path": "/Users/jaehyeokchoi/Desktop/TableMagnifier/pipeline_ui/backend/uploads/d4524a1c_image.png", - "html_table": "", - "synthetic_table": "```html\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
๊ฒฝ๊ณผ๊ธฐ๊ฐ„๋‚ฉ์ž…๋ณดํ—˜๋ฃŒ ๋ˆ„๊ณ„ํ•ด์ง€ํ™˜๊ธ‰๊ธˆํ™˜๊ธ‰๋ฅ 
1๋…„60000000%
3๋…„180000054000030%
5๋…„3000000165000055%
10๋…„6000000480000080%
15๋…„9000000855000095%
20๋…„(๋งŒ๊ธฐ)1200000012000000100%
\n```", - "synthetic_json": [ - { - "๊ฒฝ๊ณผ๊ธฐ๊ฐ„": "1๋…„", - "๋‚ฉ์ž…๋ณดํ—˜๋ฃŒ ๋ˆ„๊ณ„": 600000, - "ํ•ด์ง€ํ™˜๊ธ‰๊ธˆ": 0, - "ํ™˜๊ธ‰๋ฅ ": 0 - }, - { - "๊ฒฝ๊ณผ๊ธฐ๊ฐ„": "3๋…„", - "๋‚ฉ์ž…๋ณดํ—˜๋ฃŒ ๋ˆ„๊ณ„": 1800000, - "ํ•ด์ง€ํ™˜๊ธ‰๊ธˆ": 540000, - "ํ™˜๊ธ‰๋ฅ ": 30 - }, - { - "๊ฒฝ๊ณผ๊ธฐ๊ฐ„": "5๋…„", - "๋‚ฉ์ž…๋ณดํ—˜๋ฃŒ ๋ˆ„๊ณ„": 3000000, - "ํ•ด์ง€ํ™˜๊ธ‰๊ธˆ": 1650000, - "ํ™˜๊ธ‰๋ฅ ": 55 - }, - { - "๊ฒฝ๊ณผ๊ธฐ๊ฐ„": "10๋…„", - "๋‚ฉ์ž…๋ณดํ—˜๋ฃŒ ๋ˆ„๊ณ„": 6000000, - "ํ•ด์ง€ํ™˜๊ธ‰๊ธˆ": 4800000, - "ํ™˜๊ธ‰๋ฅ ": 80 - }, - { - "๊ฒฝ๊ณผ๊ธฐ๊ฐ„": "15๋…„", - "๋‚ฉ์ž…๋ณดํ—˜๋ฃŒ ๋ˆ„๊ณ„": 9000000, - "ํ•ด์ง€ํ™˜๊ธ‰๊ธˆ": 8550000, - "ํ™˜๊ธ‰๋ฅ ": 95 - }, - { - "๊ฒฝ๊ณผ๊ธฐ๊ฐ„": "20๋…„(๋งŒ๊ธฐ)", - "๋‚ฉ์ž…๋ณดํ—˜๋ฃŒ ๋ˆ„๊ณ„": 12000000, - "ํ•ด์ง€ํ™˜๊ธ‰๊ธˆ": 12000000, - "ํ™˜๊ธ‰๋ฅ ": 100 - } - ], - "qa_results": [ - { - "question": "10๋…„ ๊ฒฝ๊ณผ ์‹œ์ ์˜ ํ•ด์ง€ํ™˜๊ธ‰๊ธˆ์€ ์–ผ๋งˆ์ธ๊ฐ€์š”?", - "answer": "4,800,000์›์ž…๋‹ˆ๋‹ค.", - "type": "lookup" - }, - { - "question": "3๋…„ ๊ฒฝ๊ณผ ์‹œ์ ๊ณผ 5๋…„ ๊ฒฝ๊ณผ ์‹œ์  ์ค‘ ํ™˜๊ธ‰๋ฅ ์ด ๋” ๋†’์€ ์‹œ์ ์€ ์–ธ์ œ์ธ๊ฐ€์š”?", - "answer": "5๋…„ ๊ฒฝ๊ณผ ์‹œ์ ์˜ ํ™˜๊ธ‰๋ฅ ์ด 55%๋กœ 3๋…„ ๊ฒฝ๊ณผ ์‹œ์ ์˜ 30%๋ณด๋‹ค ๋” ๋†’์Šต๋‹ˆ๋‹ค.", - "type": "comparison" - }, - { - "question": "15๋…„ ๊ฒฝ๊ณผ ์‹œ์ ์˜ ๋‚ฉ์ž…๋ณดํ—˜๋ฃŒ ๋ˆ„๊ณ„์™€ ํ•ด์ง€ํ™˜๊ธ‰๊ธˆ์˜ ์ฐจ์ด๋Š” ์–ผ๋งˆ์ธ๊ฐ€์š”?", - "answer": "9,000,000์›์—์„œ 8,550,000์›์„ ๋บ€ 450,000์›์ž…๋‹ˆ๋‹ค.", - "type": "calculation" - }, - { - "question": "์ด ํ‘œ์— ๋”ฐ๋ฅด๋ฉด, ๋ณดํ—˜ ๊ฐ€์ž… ํ›„ 1๋…„ ์ด๋‚ด์— ํ•ด์ง€ํ•  ๊ฒฝ์šฐ ํ™˜๊ธ‰๊ธˆ์€ ์–ด๋–ป๊ฒŒ ๋  ๊ฒƒ์œผ๋กœ ์˜ˆ์ƒํ•  ์ˆ˜ ์žˆ๋‚˜์š”?", - "answer": "1๋…„ ๊ฒฝ๊ณผ ์‹œ์ ์˜ ํ•ด์ง€ํ™˜๊ธ‰๊ธˆ์ด 0์›์ธ ๊ฒƒ์œผ๋กœ ๋ณด์•„, 1๋…„ ์ด๋‚ด ํ•ด์ง€ ์‹œ ํ™˜๊ธ‰๊ธˆ์€ ์—†์„ ๊ฒƒ์œผ๋กœ ์ถ”์ •๋ฉ๋‹ˆ๋‹ค.", - "type": "reasoning" - }, - { - "question": "5๋…„ ๊ฒฝ๊ณผ ์‹œ์ ๋ถ€ํ„ฐ 10๋…„ ๊ฒฝ๊ณผ ์‹œ์ ๊นŒ์ง€ ๋‚ฉ์ž…๋ณดํ—˜๋ฃŒ ๋ˆ„๊ณ„๋Š” ์–ผ๋งˆ๊ฐ€ ์ถ”๊ฐ€๋กœ ๋‚ฉ์ž…๋˜์—ˆ๋‚˜์š”?", - "answer": "10๋…„ ๊ฒฝ๊ณผ ์‹œ์ ์˜ ๋‚ฉ์ž…๋ณดํ—˜๋ฃŒ ๋ˆ„๊ณ„์ธ 6,000,000์›์—์„œ 5๋…„ ๊ฒฝ๊ณผ ์‹œ์ ์˜ ๋‚ฉ์ž…๋ณดํ—˜๋ฃŒ ๋ˆ„๊ณ„์ธ 3,000,000์›์„ ๋นผ๋ฉด 3,000,000์›์ด ์ถ”๊ฐ€๋กœ ๋‚ฉ์ž…๋˜์—ˆ์Šต๋‹ˆ๋‹ค.", - "type": "calculation" - } - ] - }, - "error": null, - "created_at": "2025-12-23T21:27:14.754582", - "updated_at": "2025-12-23T21:28:26.030433" - } - ], - "status": "completed", - "total": 1, - "completed": 1, - "failed": 0, - "created_at": "2025-12-23T21:27:14.754582", - "updated_at": "2025-12-23T21:28:26.030466" -} \ No newline at end of file diff --git a/pipeline_ui/backend/output/be5456ef_result.json b/pipeline_ui/backend/output/be5456ef_result.json deleted file mode 100644 index 30b638f..0000000 --- a/pipeline_ui/backend/output/be5456ef_result.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "image_path": "/Users/jaehyeokchoi/Desktop/TableMagnifier/pipeline_ui/backend/uploads/d4524a1c_image.png", - "html_table": "", - "synthetic_table": "```html\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
๊ฒฝ๊ณผ๊ธฐ๊ฐ„๋‚ฉ์ž…๋ณดํ—˜๋ฃŒ ๋ˆ„๊ณ„ํ•ด์ง€ํ™˜๊ธ‰๊ธˆํ™˜๊ธ‰๋ฅ 
1๋…„60000000%
3๋…„180000054000030%
5๋…„3000000165000055%
10๋…„6000000480000080%
15๋…„9000000855000095%
20๋…„(๋งŒ๊ธฐ)1200000012000000100%
\n```", - "synthetic_json": [ - { - "๊ฒฝ๊ณผ๊ธฐ๊ฐ„": "1๋…„", - "๋‚ฉ์ž…๋ณดํ—˜๋ฃŒ ๋ˆ„๊ณ„": 600000, - "ํ•ด์ง€ํ™˜๊ธ‰๊ธˆ": 0, - "ํ™˜๊ธ‰๋ฅ ": 0 - }, - { - "๊ฒฝ๊ณผ๊ธฐ๊ฐ„": "3๋…„", - "๋‚ฉ์ž…๋ณดํ—˜๋ฃŒ ๋ˆ„๊ณ„": 1800000, - "ํ•ด์ง€ํ™˜๊ธ‰๊ธˆ": 540000, - "ํ™˜๊ธ‰๋ฅ ": 30 - }, - { - "๊ฒฝ๊ณผ๊ธฐ๊ฐ„": "5๋…„", - "๋‚ฉ์ž…๋ณดํ—˜๋ฃŒ ๋ˆ„๊ณ„": 3000000, - "ํ•ด์ง€ํ™˜๊ธ‰๊ธˆ": 1650000, - "ํ™˜๊ธ‰๋ฅ ": 55 - }, - { - "๊ฒฝ๊ณผ๊ธฐ๊ฐ„": "10๋…„", - "๋‚ฉ์ž…๋ณดํ—˜๋ฃŒ ๋ˆ„๊ณ„": 6000000, - "ํ•ด์ง€ํ™˜๊ธ‰๊ธˆ": 4800000, - "ํ™˜๊ธ‰๋ฅ ": 80 - }, - { - "๊ฒฝ๊ณผ๊ธฐ๊ฐ„": "15๋…„", - "๋‚ฉ์ž…๋ณดํ—˜๋ฃŒ ๋ˆ„๊ณ„": 9000000, - "ํ•ด์ง€ํ™˜๊ธ‰๊ธˆ": 8550000, - "ํ™˜๊ธ‰๋ฅ ": 95 - }, - { - "๊ฒฝ๊ณผ๊ธฐ๊ฐ„": "20๋…„(๋งŒ๊ธฐ)", - "๋‚ฉ์ž…๋ณดํ—˜๋ฃŒ ๋ˆ„๊ณ„": 12000000, - "ํ•ด์ง€ํ™˜๊ธ‰๊ธˆ": 12000000, - "ํ™˜๊ธ‰๋ฅ ": 100 - } - ], - "qa_results": [ - { - "question": "10๋…„ ๊ฒฝ๊ณผ ์‹œ์ ์˜ ํ•ด์ง€ํ™˜๊ธ‰๊ธˆ์€ ์–ผ๋งˆ์ธ๊ฐ€์š”?", - "answer": "4,800,000์›์ž…๋‹ˆ๋‹ค.", - "type": "lookup" - }, - { - "question": "3๋…„ ๊ฒฝ๊ณผ ์‹œ์ ๊ณผ 5๋…„ ๊ฒฝ๊ณผ ์‹œ์  ์ค‘ ํ™˜๊ธ‰๋ฅ ์ด ๋” ๋†’์€ ์‹œ์ ์€ ์–ธ์ œ์ธ๊ฐ€์š”?", - "answer": "5๋…„ ๊ฒฝ๊ณผ ์‹œ์ ์˜ ํ™˜๊ธ‰๋ฅ ์ด 55%๋กœ 3๋…„ ๊ฒฝ๊ณผ ์‹œ์ ์˜ 30%๋ณด๋‹ค ๋” ๋†’์Šต๋‹ˆ๋‹ค.", - "type": "comparison" - }, - { - "question": "15๋…„ ๊ฒฝ๊ณผ ์‹œ์ ์˜ ๋‚ฉ์ž…๋ณดํ—˜๋ฃŒ ๋ˆ„๊ณ„์™€ ํ•ด์ง€ํ™˜๊ธ‰๊ธˆ์˜ ์ฐจ์ด๋Š” ์–ผ๋งˆ์ธ๊ฐ€์š”?", - "answer": "9,000,000์›์—์„œ 8,550,000์›์„ ๋บ€ 450,000์›์ž…๋‹ˆ๋‹ค.", - "type": "calculation" - }, - { - "question": "์ด ํ‘œ์— ๋”ฐ๋ฅด๋ฉด, ๋ณดํ—˜ ๊ฐ€์ž… ํ›„ 1๋…„ ์ด๋‚ด์— ํ•ด์ง€ํ•  ๊ฒฝ์šฐ ํ™˜๊ธ‰๊ธˆ์€ ์–ด๋–ป๊ฒŒ ๋  ๊ฒƒ์œผ๋กœ ์˜ˆ์ƒํ•  ์ˆ˜ ์žˆ๋‚˜์š”?", - "answer": "1๋…„ ๊ฒฝ๊ณผ ์‹œ์ ์˜ ํ•ด์ง€ํ™˜๊ธ‰๊ธˆ์ด 0์›์ธ ๊ฒƒ์œผ๋กœ ๋ณด์•„, 1๋…„ ์ด๋‚ด ํ•ด์ง€ ์‹œ ํ™˜๊ธ‰๊ธˆ์€ ์—†์„ ๊ฒƒ์œผ๋กœ ์ถ”์ •๋ฉ๋‹ˆ๋‹ค.", - "type": "reasoning" - }, - { - "question": "5๋…„ ๊ฒฝ๊ณผ ์‹œ์ ๋ถ€ํ„ฐ 10๋…„ ๊ฒฝ๊ณผ ์‹œ์ ๊นŒ์ง€ ๋‚ฉ์ž…๋ณดํ—˜๋ฃŒ ๋ˆ„๊ณ„๋Š” ์–ผ๋งˆ๊ฐ€ ์ถ”๊ฐ€๋กœ ๋‚ฉ์ž…๋˜์—ˆ๋‚˜์š”?", - "answer": "10๋…„ ๊ฒฝ๊ณผ ์‹œ์ ์˜ ๋‚ฉ์ž…๋ณดํ—˜๋ฃŒ ๋ˆ„๊ณ„์ธ 6,000,000์›์—์„œ 5๋…„ ๊ฒฝ๊ณผ ์‹œ์ ์˜ ๋‚ฉ์ž…๋ณดํ—˜๋ฃŒ ๋ˆ„๊ณ„์ธ 3,000,000์›์„ ๋นผ๋ฉด 3,000,000์›์ด ์ถ”๊ฐ€๋กœ ๋‚ฉ์ž…๋˜์—ˆ์Šต๋‹ˆ๋‹ค.", - "type": "calculation" - } - ] -} \ No newline at end of file