diff --git a/.gitignore b/.gitignore index b42ff18..f65e743 100644 --- a/.gitignore +++ b/.gitignore @@ -62,6 +62,8 @@ dmypy.json # database info/ token.json +*.json +test_input.json # env .bemad/ diff --git a/NOTION_UPLOAD_GUIDE.md b/NOTION_UPLOAD_GUIDE.md new file mode 100644 index 0000000..82eebef --- /dev/null +++ b/NOTION_UPLOAD_GUIDE.md @@ -0,0 +1,252 @@ +# Notion 업로드 가이드 + +TableMagnifier 파이프라인 결과를 Notion 데이터베이스에 업로드하는 방법을 안내합니다. + +## 📋 목차 + +1. [개요](#개요) +2. [설정](#설정) +3. [사용 방법](#사용-방법) +4. [FAQ](#faq) + +--- + +## 개요 + +QA 생성 결과를 Notion 데이터베이스에 업로드하는 두 가지 방법이 있습니다: + +### 방법 1: 파이프라인 실행 중 자동 업로드 +- 파이프라인 실행과 동시에 Notion에 업로드 +- `--upload-to-notion` 플래그 사용 + +### 방법 2: 기존 결과 선택적 업로드 (권장 ⭐) +- 이미 생성된 결과에서 원하는 것만 선택해서 업로드 +- `upload_to_notion_from_json.py` 스크립트 사용 +- **중복 업로드 걱정 없음** - 원하는 폴더만 선택적으로 업로드 + +--- + +## 설정 + +### 1. Notion API Key 준비 + +1. [Notion Developers](https://www.notion.so/my-integrations)에서 Integration 생성 +2. API Key 복사 + +### 2. Database ID 확인 + +1. Notion에서 데이터베이스 페이지 열기 +2. URL에서 Database ID 복사 + ``` + https://www.notion.so/your-workspace/?v=... + ``` + +### 3. 설정 파일 업데이트 + +`apis/gemini_keys.yaml` 파일에 Notion 설정 추가: + +```yaml +# 기존 Gemini/OpenAI 설정들... + +# Notion 설정 +notion_key: "secret_xxxxxxxxxxxxxxxxxxxxx" + +notion_databases: + public: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + finance: "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy" + insurance: "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" +``` + +--- + +## 사용 방법 + +### 🎯 방법 1: 파이프라인 실행 중 업로드 + +파이프라인 실행 시 `--upload-to-notion` 플래그를 추가하세요. + +#### JSON 파이프라인 (OpenAI/Claude) + +```bash +# run_openai_public.sh 사용 시 +./run_openai_public.sh test_input.json --upload-to-notion + +# 또는 직접 실행 +python run_pipeline_json.py \ + --input test_input.json \ + --output-dir output_test_public \ + --provider openai \ + --model gpt-5-mini \ + --domain public \ + --qa-only \ + --upload-to-notion +``` + +#### 폴더 기반 파이프라인 + +```bash +python -m generate_synthetic_table.cli \ + data/Public/Table/P_origin_0 \ + --provider gemini_pool \ + --domain public \ + --qa-only \ + --upload-to-notion +``` + +--- + +### ⭐ 방법 2: 기존 결과 선택적 업로드 (권장) + +이미 생성된 결과를 나중에 선택적으로 업로드할 수 있습니다. + +#### 기본 사용법 + +```bash +# 폴더 경로 지정 (자동으로 pipeline_output.json 찾음) +python upload_to_notion_from_json.py output_test_public + +# 또는 JSON 파일 직접 지정 +python upload_to_notion_from_json.py output_test_public/pipeline_output.json +``` + +#### Dry-run 모드 (테스트) + +실제로 업로드하지 않고 무엇이 업로드될지 미리 확인: + +```bash +python upload_to_notion_from_json.py output_test_public --dry-run +``` + +출력 예시: +``` +📂 Loaded 10 result(s) from output_test_public/pipeline_output.json +✅ Notion uploader initialized + +🔍 [1/10] Would upload P_origin_0_1: + - Domain: public + - Image: P_origin_0_1 + - QA pairs: 5 + - Provider: openai +... +``` + +#### 조용히 실행 + +진행 메시지 없이 조용히 실행: + +```bash +python upload_to_notion_from_json.py output_test_public --quiet +``` + +#### 다른 설정 파일 사용 + +```bash +python upload_to_notion_from_json.py output_test_public \ + --config-path path/to/custom_config.yaml +``` + +--- + +## 실전 예시 워크플로우 + +### 시나리오: 여러 파이프라인 실행 후 선택적 업로드 + +```bash +# 1. 여러 파이프라인 실행 (Notion 업로드 없이) +./run_openai_public.sh test_input.json +# → 결과: output_public/pipeline_output.json + +./run_openai_public.sh multi_input.json +# → 결과: output_public/pipeline_output.json (덮어쓰기됨) + +# 2. 각 결과를 별도 폴더로 저장 +./run_openai_public.sh test_input.json --output-dir output_test_1 +./run_openai_public.sh multi_input.json --output-dir output_test_2 +./run_openai_public.sh another_input.json --output-dir output_test_3 + +# 3. 결과 확인 후 원하는 것만 선택적으로 업로드 +python upload_to_notion_from_json.py output_test_1 --dry-run # 먼저 확인 +python upload_to_notion_from_json.py output_test_1 # 업로드 + +python upload_to_notion_from_json.py output_test_3 --dry-run # 확인 +python upload_to_notion_from_json.py output_test_3 # 업로드 + +# output_test_2는 품질이 안 좋아서 업로드 안 함 +``` + +--- + +## FAQ + +### Q1: 같은 결과를 여러 번 업로드하면 어떻게 되나요? + +**A:** 현재는 중복 체크 없이 새로운 레코드가 계속 생성됩니다. +- **해결책**: `--dry-run`으로 먼저 확인하거나, Notion에서 수동으로 중복 제거 +- **권장**: 방법 2 (선택적 업로드)를 사용하여 원하는 폴더만 한 번씩 업로드 + +### Q2: 업로드 중 에러가 발생하면? + +**A:** 스크립트는 에러가 발생해도 계속 진행됩니다. +- 성공/실패 카운트가 마지막에 표시됨 +- QA가 없는 결과는 자동으로 스킵됨 + +### Q3: 특정 QA만 선택해서 업로드할 수 있나요? + +**A:** 현재는 `pipeline_output.json` 전체를 업로드합니다. +- **대안**: JSON 파일을 직접 편집하거나 필터링 후 업로드 + +### Q4: 업로드된 데이터의 구조는? + +**A:** 각 QA 쌍이 Notion 데이터베이스의 한 행(row)으로 저장됩니다: +- **Name/Title**: pair_id (예: `P_origin_0_1`) +- **Domain**: 도메인 (예: `public`) +- **Image**: 이미지 파일명 +- **Question**: 질문 +- **Answer**: 답변 +- **Type**: QA 타입 (예: `reasoning`) +- **Count**: QA 순번 (1, 2, 3...) +- **Provider**: 사용한 LLM (예: `openai`) +- **Token**: 사용한 토큰 수 +- **reasoning_annotation**: 추론 주석 +- **context**: 컨텍스트 + +### Q5: 여러 도메인을 동시에 업로드할 수 있나요? + +**A:** 네, `pipeline_output.json`에 여러 도메인이 섞여 있어도 자동으로 각 도메인의 데이터베이스에 분산 업로드됩니다. + +--- + +## 팁 💡 + +1. **항상 `--dry-run`으로 먼저 확인하세요** + ```bash + python upload_to_notion_from_json.py output_test --dry-run + ``` + +2. **출력 폴더를 명확하게 구분하세요** + ```bash + --output-dir output_test_openai_v1 + --output-dir output_test_claude_v2 + ``` + +3. **중요한 결과는 백업하세요** + ```bash + cp -r output_test_public output_test_public_backup + ``` + +4. **Notion 데이터베이스 속성이 자동 생성됩니다** + - 처음 업로드 시 필요한 속성들이 자동으로 추가됨 + - Database 권한 확인 필요 (Integration이 접근 가능해야 함) + +--- + +## 관련 파일 + +- `upload_to_notion_from_json.py` - 선택적 업로드 스크립트 +- `run_pipeline_json.py` - JSON 파이프라인 (파라미터로 `--upload-to-notion` 지원) +- `generate_synthetic_table/notion_uploader.py` - Notion 업로더 클래스 +- `apis/gemini_keys.yaml` - 설정 파일 + +--- + +문제가 있거나 추가 기능이 필요하면 이슈를 열어주세요! 🚀 diff --git a/README.md b/README.md index 3d32509..8707a51 100644 --- a/README.md +++ b/README.md @@ -316,7 +316,146 @@ uv run python -m generate_synthetic_table.cli path/to/table.png \ --domain medical --qa-only --save-json output.json ``` -### 실행 예시 및 결과 +### 2. JSON 입력 기반 배치 처리 (run_pipeline_json.py) + +JSON 파일로 이미지 경로 쌍을 정의하여 여러 pair를 한 번에 처리할 수 있습니다. 이 방식은 대량의 데이터를 구조화된 형태로 처리할 때 유용합니다. + +#### 2.1 JSON 입력 형식 + +**구조화된 형식 (권장):** +```json +[ + { + "pair_id": "P_origin_0_1", + "image_paths": [ + "data/Public/Table/P_origin_0/P_origin_0_1_0.png", + "data/Public/Table/P_origin_0/P_origin_0_1_1.png" + ], + "domain": "public" + }, + { + "pair_id": "F_table_1", + "image_paths": [ + "data/Finance/Table/F_table_1/F_table_1_0.png", + "data/Finance/Table/F_table_1/F_table_1_1.png" + ], + "domain": "finance" + } +] +``` + +**레거시 형식 (배열 - 하위 호환성 지원):** +```json +[ + [ + "data/Public/Table/P_origin_0/P_origin_0_1_0.png", + "data/Public/Table/P_origin_0/P_origin_0_1_1.png" + ], + [ + "data/Finance/Table/F_table_1/F_table_1_0.png", + "data/Finance/Table/F_table_1/F_table_1_1.png" + ] +] +``` + +**구조화된 형식의 장점:** +- `pair_id`: 원하는 식별자를 직접 지정 가능 +- `domain`: 각 pair마다 다른 domain 지정 가능 (CLI `--domain`보다 우선) +- 출력 결과와 입력 형식의 일관성 + +#### 2.2 기본 사용법 + +```bash +# 기본 실행 (구조화된 출력) +uv run python run_pipeline_json.py \ + --input test_input.json \ + --output-dir output_results \ + --domain public + +# QA만 생성 (테이블 생성 스킵) +uv run python run_pipeline_json.py \ + --input test_input.json \ + --output-dir output_qa_only \ + --qa-only + +# Notion 데이터베이스에 자동 업로드 +uv run python run_pipeline_json.py \ + --input test_input.json \ + --output-dir output_with_notion \ + --domain public \ + --upload-to-notion +``` + +#### 2.3 옵션 설명 + +| 옵션 | 설명 | 기본값 | +|------|------|--------| +| `--input` | JSON 입력 파일 경로 | (필수) | +| `--data-root` | 이미지 파일 검색 기준 디렉토리 | `data` | +| `--output-dir` | 결과 JSON 저장 디렉토리 | `output_json` | +| `--provider` | LLM 제공자 | `gemini_pool` | +| `--model` | 사용할 모델명 | `gemini-1.5-flash` | +| `--config-path` | Gemini Pool 설정 파일 경로 | `apis/gemini_keys.yaml` | +| `--domain` | 도메인 강제 지정 | 자동 감지 | +| `--qa-only` | 테이블 생성 스킵, QA만 생성 | `false` | +| `--upload-to-notion` | QA 결과를 Notion DB에 업로드 | `false` | + +#### 2.4 출력 형식 + +결과는 `{output-dir}/pipeline_output.json`에 다음과 같은 구조로 저장됩니다: + +```json +[ + { + "pair_id": "P_origin_0_1_0", + "image_paths": [ + "data/Public/Table/P_origin_0/P_origin_0_1_0.png", + "data/Public/Table/P_origin_0/P_origin_0_1_1.png" + ], + "domain": "public", + "tables": [null, null], + "qa_results": [ + { + "question": "필기 과목명 '디지털 전자회로'의 문제수는 몇 문제인가요?", + "answer": "20문제", + "type": "lookup", + "reasoning_annotation": "...", + "context": null + } + ], + "metadata": { + "provider": "gemini_pool", + "model": "gemini-1.5-flash", + "qa_only": true + }, + "notion_upload": { + "success": true, + "created_count": 10 + } + } +] +``` + +#### 2.5 Notion 업로드 설정 + +`--upload-to-notion` 플래그를 사용하려면 `apis/gemini_keys.yaml`에 Notion 관련 설정이 필요합니다: + +```yaml +# Notion API 키 +notion_key: secret_... + +# 도메인별 데이터베이스 ID +notion_databases: + public: your_database_id_here + finance: another_database_id + insurance: yet_another_database_id +``` + +**Notion 업로드 시 주의사항:** +- Notion database에 자동으로 필요한 속성(Domain, Image, Question, Answer, Type 등)이 생성됩니다 +- 업로드 실패 시에도 파이프라인은 중단되지 않으며, 결과 JSON에 에러 정보가 기록됩니다 + +### 3. 실행 예시 및 결과 ```bash $ uv run python -m generate_synthetic_table.cli ./image.png --provider gemini_pool diff --git a/run_openai_public.sh b/run_openai_public.sh new file mode 100644 index 0000000..92bf58d --- /dev/null +++ b/run_openai_public.sh @@ -0,0 +1,43 @@ +#!/bin/bash + +# ============================================================================== +# TableMagnifier - JSON Pipeline (Public Domain) +# ============================================================================== + +# Default Configuration +INPUT_JSON="test_business_input.json" +OUTPUT_DIR="output_business" +DEFAULT_ARGS="--provider openai --model gpt-5-mini --domain business" + +# Check if the first argument is a JSON file path +if [[ "$1" == *.json ]]; then + INPUT_JSON="$1" + shift +fi + +echo "==============================================" +echo " TableMagnifier - JSON Pipeline (Public)" +echo "==============================================" +echo "Input JSON: $INPUT_JSON" +echo "Output Dir: $OUTPUT_DIR" +echo "Provider: openai" +echo "Model: gpt-5-mini" +echo "Domain: business" +echo "" +echo "💡 Tip: To upload to Notion during pipeline execution:" +echo " Add --upload-to-notion flag to the command" +echo "" +echo "💡 To upload existing results later:" +echo " python upload_to_notion_from_json.py $OUTPUT_DIR" +echo "" + +# Check for OPENAI_API_KEY +if [[ -z "$OPENAI_API_KEY" ]]; then + echo "⚠️ Warning: OPENAI_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 run_pipeline_json.py --input "$INPUT_JSON" --output-dir "$OUTPUT_DIR" $DEFAULT_ARGS "$@" diff --git a/run_pipeline_json.py b/run_pipeline_json.py new file mode 100644 index 0000000..0ff7276 --- /dev/null +++ b/run_pipeline_json.py @@ -0,0 +1,338 @@ +import argparse +import json +import os +import sys +from pathlib import Path +from typing import List, Dict, Any, Tuple +from concurrent.futures import ThreadPoolExecutor, as_completed + +from dotenv import load_dotenv + +# Add parent directory to path to allow imports if running from root +sys.path.append(str(Path(__file__).parent)) + +from generate_synthetic_table.runner import run_synthetic_table_flow, _auto_detect_domain +from generate_synthetic_table.flow import TableState +from generate_synthetic_table.notion_uploader import NotionUploader + +def resolve_paths(pair: List[str], data_root: Path) -> List[Path]: + """Resolves a list of relative paths to absolute Paths.""" + paths = [] + for pid in pair: + # Prioritize using the path exactly as given in the JSON + p = Path(pid) + if p.exists(): + paths.append(p) + continue + + # Fallback: Try joining with data_root if provided (for backward compatibility or convenience) + p_joined = data_root / pid + if p_joined.exists(): + paths.append(p_joined) + else: + print(f"Warning: File not found: {pid} (checked {p.absolute()} and {p_joined.absolute()})") + return [] + return paths + +def process_single_pair( + pair_input: Any, + index: int, + total_count: int, + data_root: Path, + provider: str, + model: str, + config_path: str, + arg_domain: str, + qa_only: bool, + notion_uploader: Any +) -> Dict: + """Process a single pair of images.""" + + # Extract pair info + if isinstance(pair_input, dict): + # Structured format + pair_id_override = pair_input.get("pair_id") + pair_ids = pair_input.get("image_paths", []) + domain_override = pair_input.get("domain") + + print(f"\n[Pair {index+1}/{total_count}] Processing: {pair_id_override or pair_ids}") + else: + # Legacy format (array) + pair_id_override = None + pair_ids = pair_input + domain_override = None + + print(f"\n[Pair {index+1}/{total_count}] Processing: {pair_ids}") + + paths = resolve_paths(pair_ids, data_root) + if len(paths) != len(pair_ids): + print(f"[Pair {index+1}] Skipping due to missing files.") + return { + "pair_id": pair_id_override or f"error_{index}", + "image_paths": pair_ids, + "error": "Missing files" + } + + # Detect domain: priority order is domain_override > arg_domain > auto_detect + domain = domain_override or arg_domain + if not domain: + domain = _auto_detect_domain(pair_ids[0]) + if not domain: + # fallback try second + domain = _auto_detect_domain(pair_ids[1]) if len(pair_ids) > 1 else None + + # Generate pair_id from override or common path elements + if pair_id_override: + pair_id = pair_id_override + else: + pair_id = Path(paths[0]).stem if paths else f"pair_{index}" + + # Store paths as strings + image_paths_str = [str(p) for p in paths] + + # Initialize result structure + pair_tables = [] + pair_qa = [] + + # Logic Branching: Use qa_only flag OR domain-based logic + should_skip_tables = qa_only or (domain == "public") + + try: + if should_skip_tables: + # QA only mode + print(f"[Pair {index+1}] Mode: QA only (tables skipped)") + + # QA Generation (Pair) + qa_state = run_synthetic_table_flow( + image_path=str(paths[0]), # Primary path + image_paths=image_paths_str, + provider=provider, + model=model, + config_path=config_path, + qa_only=True, + domain=domain + ) + + if qa_state.get("qa_results"): + pair_qa = qa_state["qa_results"] + + pair_tables = [None] * len(paths) + + else: + # Full mode: Both Tables + Pair QA + print(f"[Pair {index+1}] Mode: Synthetic Table + QA") + + # 1. Generate Tables Individually + temp_tables = [] + for path in paths: + print(f" [Pair {index+1}] Generating table for {path.name}...") + table_state = run_synthetic_table_flow( + image_path=str(path), + provider=provider, + model=model, + config_path=config_path, + qa_only=False, # We want the table + domain=domain + ) + + # Check errors + if table_state.get("errors"): + print(f" [Pair {index+1}] Error generating table: {table_state['errors']}") + + # Filter state + safe_state = { + "image_path": str(path), + "synthetic_table": table_state.get("synthetic_table"), + "synthetic_json": table_state.get("synthetic_json"), + "table_summary": table_state.get("table_summary"), + } + temp_tables.append(safe_state) + + pair_tables = temp_tables + + # 2. Generate QA for the Pair + print(f" [Pair {index+1}] Generating QA for pair...") + qa_state = run_synthetic_table_flow( + image_path=str(paths[0]), + image_paths=image_paths_str, + provider=provider, + model=model, + config_path=config_path, + qa_only=True, # Focus on QA from these images + domain=domain + ) + + if qa_state.get("qa_results"): + pair_qa = qa_state["qa_results"] + + # Create structured result with keys + result_item = { + "pair_id": pair_id, + "image_paths": image_paths_str, + "domain": domain, + "tables": pair_tables, + "qa_results": pair_qa, + "metadata": { + "provider": provider, + "model": model, + "qa_only": should_skip_tables + } + } + + # Upload to Notion if enabled + if notion_uploader and pair_qa: + try: + print(f" [Pair {index+1}] Uploading to Notion database...") + upload_result = notion_uploader.upload_qa_result( + domain=domain or "unknown", + image_path=pair_id, # Use pair_id as identifier + qa_results=pair_qa, + provider=provider + ) + result_item["notion_upload"] = { + "success": True, + "created_count": upload_result.get("created_count", 0) + } + print(f" ✅ [Pair {index+1}] Uploaded {upload_result.get('created_count', 0)} QA rows to Notion") + except Exception as e: + result_item["notion_upload"] = { + "success": False, + "error": str(e) + } + print(f" ❌ [Pair {index+1}] Notion upload failed: {e}") + + return result_item + + except Exception as e: + print(f"❌ [Pair {index+1}] Critical error: {e}") + return { + "pair_id": pair_id, + "image_paths": image_paths_str, + "error": str(e) + } + +def run_pipeline( + json_input: List[List[str]], + data_root: Path, + output_dir: Path, + provider: str = "gemini_pool", + model: str = "gemini-2.5-flash", + config_path: str = "apis/gemini_keys.yaml", + arg_domain: str = None, + qa_only: bool = False, + upload_to_notion: bool = False, + max_workers: int = 3 +): + output_dir.mkdir(parents=True, exist_ok=True) + + # Initialize Notion uploader if needed + notion_uploader = None + if upload_to_notion: + try: + notion_uploader = NotionUploader(config_path=config_path) + print("✅ Notion uploader initialized") + except Exception as e: + print(f"⚠️ Warning: Failed to initialize Notion uploader: {e}") + print(" Continuing without Notion upload...") + + final_results = [] + total_count = len(json_input) + + print(f"Starting pipeline with {max_workers} workers for {total_count} pairs...") + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + # Create futures + futures = { + executor.submit( + process_single_pair, + item, + i, + total_count, + data_root, + provider, + model, + config_path, + arg_domain, + qa_only, + notion_uploader + ): i for i, item in enumerate(json_input) + } + + # Collect results as they finish + for future in as_completed(futures): + i = futures[future] + try: + result = future.result() + final_results.append(result) + + # Save intermediate per pair (optional) + # pair_id = result.get("pair_id", f"pair_{i}") + # safe_name = "".join([c for c in pair_id if c.isalnum() or c in ('-','_')]) + # (output_dir / f"{safe_name}.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") + + except Exception as e: + print(f"❌ Failed to process pair index {i}: {e}") + + # Sort results by original index for consistency (optional but nice) + # Note: final_results might process out of order. If we want original order, we'd need to track it better, + # but strictly speaking JSON lists don't guarantee order if we are just dumping a collection. + # Let's just dump. + + # Save Final JSON + output_file = output_dir / "pipeline_output.json" + with open(output_file, "w", encoding="utf-8") as f: + json.dump(final_results, f, ensure_ascii=False, indent=2) + + print(f"\nPipeline Complete. Saved to {output_file}") + + +def main(): + parser = argparse.ArgumentParser(description="Run Table/QA pipeline from JSON input") + parser.add_argument("--input", required=True, help="Path to JSON input file or JSON string") + parser.add_argument("--data-root", default="data", help="Root directory to scan for images") + parser.add_argument("--output-dir", default="output_json", help="Directory to save results") + parser.add_argument("--provider", default="gemini_pool", help="LLM provider (default: gemini_pool)") + parser.add_argument("--model", default="gemini-1.5-flash", help="Model name (default: gemini-1.5-flash)") + parser.add_argument("--config-path", default="apis/gemini_keys.yaml", help="Path to gemini_keys.yaml") + parser.add_argument("--domain", help="Force specific domain") + parser.add_argument("--qa-only", action="store_true", help="Skip table generation, only generate QA (applies to all domains)") + parser.add_argument("--upload-to-notion", action="store_true", help="Upload QA results to Notion database") + parser.add_argument("--max-workers", type=int, default=3, help="Maximum number of parallel workers (default: 3)") + + args = parser.parse_args() + + # Parse JSON input + input_data = [] + if os.path.isfile(args.input): + with open(args.input, "r", encoding="utf-8") as f: + input_data = json.load(f) + else: + try: + input_data = json.loads(args.input) + except json.JSONDecodeError: + print("Error: Input is neither a valid file path nor a valid JSON string.") + return + + # Validate structure roughly + if not isinstance(input_data, list): + print("Error: Input JSON must be a list of pairs.") + return + + data_root = Path(args.data_root) + output_dir = Path(args.output_dir) + + run_pipeline( + json_input=input_data, + data_root=data_root, + output_dir=output_dir, + provider=args.provider, + model=args.model, + config_path=args.config_path, + arg_domain=args.domain, + qa_only=args.qa_only, + upload_to_notion=args.upload_to_notion, + max_workers=args.max_workers + ) + +if __name__ == "__main__": + main() diff --git a/test_input.json b/test_input.json new file mode 100644 index 0000000..f51ecd0 --- /dev/null +++ b/test_input.json @@ -0,0 +1,119 @@ +[ + { + "index": 0, + "pair_id": "P_origin_0_1", + "image_paths": [ + "data/Public/Table/P_origin_0/P_origin_0_1_0.png", + "data/Public/Table/P_origin_0/P_origin_0_1_1.png" + ], + "domain": "public" + }, + { + "index": 1, + "pair_id": "P_origin_0_2", + "image_paths": [ + "data/Public/Table/P_origin_0/P_origin_0_2_1.png", + "data/Public/Table/P_origin_0/P_origin_0_2_2.png" + ], + "domain": "public" + }, + { + "index": 2, + "pair_id": "P_origin_1_0", + "image_paths": [ + "data/Public/Table/P_origin_1/P_origin_1_0.png" + ], + "domain": "public" + }, + { + "index": 3, + "pair_id": "P_origin_1_2", + "image_paths": [ + "data/Public/Table/P_origin_1/P_origin_1_2.png" + ], + "domain": "public" + }, + { + "index": 4, + "pair_id": "P_origin_1_4", + "image_paths": [ + "data/Public/Table/P_origin_1/P_origin_1_4.png" + ], + "domain": "public" + }, + { + "index": 5, + "pair_id": "P_origin_1_5", + "image_paths": [ + "data/Public/Table/P_origin_1/P_origin_1_5.png" + ], + "domain": "public" + }, + { + "index": 6, + "pair_id": "P_origin_1_7", + "image_paths": [ + "data/Public/Table/P_origin_1/P_origin_1_7.png" + ], + "domain": "public" + }, + { + "index": 7, + "pair_id": "P_origin_1_9", + "image_paths": [ + "data/Public/Table/P_origin_1/P_origin_1_9_0.png", + "data/Public/Table/P_origin_1/P_origin_1_9_1.png" + ], + "domain": "public" + }, + { + "index": 8, + "pair_id": "P_origin_1_10", + "image_paths": [ + "data/Public/Table/P_origin_1/P_origin_1_10_0.png", + "data/Public/Table/P_origin_1/P_origin_1_10_1.png" + ], + "domain": "public" + }, + { + "index": 9, + "pair_id": "P_origin_1_12", + "image_paths": [ + "data/Public/Table/P_origin_1/P_origin_1_12_0.png", + "data/Public/Table/P_origin_1/P_origin_1_12_1.png" + ], + "domain": "public" + }, + { + "index": 10, + "pair_id": "P_origin_1_13", + "image_paths": [ + "data/Public/Table/P_origin_1/P_origin_1_13_0.png" + ], + "domain": "public" + }, + { + "index": 11, + "pair_id": "P_origin_1_14", + "image_paths": [ + "data/Public/Table/P_origin_1/P_origin_1_14_0.png" + ], + "domain": "public" + }, + { + "index": 12, + "pair_id": "P_origin_1_23", + "image_paths": [ + "data/Public/Table/P_origin_1/P_origin_1_23_0.png" + ], + "domain": "public" + }, + { + "index": 13, + "pair_id": "P_origin_4_6", + "image_paths": [ + "data/Public/Table/P_origin_4/P_origin_4_6.png" + ], + "domain": "public" + } +] \ No newline at end of file diff --git a/upload_to_notion_from_json.py b/upload_to_notion_from_json.py new file mode 100644 index 0000000..fc15735 --- /dev/null +++ b/upload_to_notion_from_json.py @@ -0,0 +1,186 @@ +""" +Upload QA results to Notion from pipeline_output.json files. + +This script reads pipeline_output.json from a specified directory +and uploads only the QA results to Notion database. +""" + +import argparse +import json +import sys +from pathlib import Path +from typing import List, Dict, Any + +sys.path.append(str(Path(__file__).parent)) + +from generate_synthetic_table.notion_uploader import NotionUploader + + +def upload_from_json( + json_path: Path, + config_path: str = "apis/gemini_keys.yaml", + dry_run: bool = False, + verbose: bool = True +) -> Dict[str, Any]: + """ + Upload QA results from pipeline_output.json to Notion. + + Args: + json_path: Path to pipeline_output.json + config_path: Path to config file with Notion credentials + dry_run: If True, only print what would be uploaded without actually uploading + verbose: Print detailed progress + + Returns: + Upload summary + """ + if not json_path.exists(): + raise FileNotFoundError(f"JSON file not found: {json_path}") + + # Load JSON data + with open(json_path, "r", encoding="utf-8") as f: + results = json.load(f) + + if not isinstance(results, list): + raise ValueError("JSON must contain a list of result items") + + if verbose: + print(f"📂 Loaded {len(results)} result(s) from {json_path}") + + # Initialize Notion uploader + if not dry_run: + uploader = NotionUploader(config_path=config_path) + if verbose: + print("✅ Notion uploader initialized\n") + + total_uploaded = 0 + total_qa_count = 0 + skipped_count = 0 + failed_count = 0 + + for i, result in enumerate(results, 1): + pair_id = result.get("pair_id", f"unknown_{i}") + domain = result.get("domain", "unknown") + qa_results = result.get("qa_results", []) + provider = result.get("metadata", {}).get("provider", "unknown") + image_paths = result.get("image_paths", []) + + # Use first image path or pair_id as identifier + image_identifier = image_paths[0] if image_paths else pair_id + + if not qa_results: + if verbose: + print(f"⏭️ [{i}/{len(results)}] Skipped {pair_id}: No QA results") + skipped_count += 1 + continue + + qa_count = len(qa_results) + + if dry_run: + if verbose: + print(f"🔍 [{i}/{len(results)}] Would upload {pair_id}:") + print(f" - Domain: {domain}") + print(f" - Image: {image_identifier}") + print(f" - QA pairs: {qa_count}") + print(f" - Provider: {provider}") + else: + try: + if verbose: + print(f"⬆️ [{i}/{len(results)}] Uploading {pair_id}...") + print(f" - Domain: {domain}") + print(f" - Image: {image_identifier}") + print(f" - QA pairs: {qa_count}") + + upload_result = uploader.upload_qa_result( + domain=domain, + image_path=image_identifier, + qa_results=qa_results, + provider=provider + ) + + created_count = upload_result.get("created_count", 0) + total_qa_count += created_count + total_uploaded += 1 + + if verbose: + print(f" ✅ Uploaded {created_count} QA rows\n") + + except Exception as e: + failed_count += 1 + if verbose: + print(f" ❌ Failed: {e}\n") + + summary = { + "total_results": len(results), + "uploaded": total_uploaded, + "skipped": skipped_count, + "failed": failed_count, + "total_qa_rows": total_qa_count + } + + if verbose: + print("=" * 50) + print("📊 Upload Summary:") + print(f" Total results: {summary['total_results']}") + print(f" Uploaded: {summary['uploaded']}") + print(f" Skipped: {summary['skipped']}") + print(f" Failed: {summary['failed']}") + print(f" Total QA rows: {summary['total_qa_rows']}") + if dry_run: + print("\n⚠️ DRY RUN: No data was actually uploaded") + + return summary + + +def main(): + parser = argparse.ArgumentParser( + description="Upload QA results from pipeline_output.json to Notion database" + ) + parser.add_argument( + "json_path", + type=str, + help="Path to pipeline_output.json or directory containing it" + ) + parser.add_argument( + "--config-path", + default="apis/gemini_keys.yaml", + help="Path to config file with Notion credentials (default: apis/gemini_keys.yaml)" + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print what would be uploaded without actually uploading" + ) + parser.add_argument( + "--quiet", + action="store_true", + help="Suppress progress messages" + ) + + args = parser.parse_args() + + # Convert to Path + json_path = Path(args.json_path) + + # If directory is given, look for pipeline_output.json inside + if json_path.is_dir(): + json_path = json_path / "pipeline_output.json" + if not json_path.exists(): + print(f"❌ Error: pipeline_output.json not found in {args.json_path}") + print(f" Looking for: {json_path}") + sys.exit(1) + + try: + upload_from_json( + json_path=json_path, + config_path=args.config_path, + dry_run=args.dry_run, + verbose=not args.quiet + ) + except Exception as e: + print(f"❌ Error: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main()