""" src/data/generate_router_dataset.py ──────────────────────────────────────────────────────────────────────────── Router + E2E 통합 레이블드 데이터셋 생성기 흐름: 1. QA_ONLY : ChromaDB 청크에서 SAP 개념/정책 질문 이메일 생성 2. ACTION_ONLY : SQLite DB의 실제 주문 데이터로 ERP 액션 이메일 생성 3. BOTH : 실제 주문 데이터 + RAG 청크를 결합한 복합 이메일 생성 각 클래스를 n_per_label개씩 균등 생성합니다. 출력 스키마 (router 평가 + e2e 평가 겸용): [ { "id": "r_001", "user_input": "...", // 이메일 원문 (router·worker·e2e 평가 공통 입력) "label": "QA_ONLY", // router 정답 레이블 "erp_evidence": {...} | null, "rag_evidence": "..." | null, "qa_question": "..." | null } ] 실행: python -m src.data.generate_router_dataset python -m src.data.generate_router_dataset --n-per-label 15 --output data/eval/router_test_cases_gen.json """ from __future__ import annotations import argparse import json import logging import random import re import sys import time from pathlib import Path from dataclasses import asdict from dotenv import load_dotenv load_dotenv() sys.stdout.reconfigure(encoding="utf-8", errors="replace") from langchain_core.prompts import ChatPromptTemplate from src.data._llm_client import build_llm, invoke_with_retry from src.data.generate_text2sql_dataset import _sample_cases from src.config import get_config _ROOT = Path(__file__).resolve().parent.parent.parent _LOG_DIR = _ROOT / "logs" _LOG_DIR.mkdir(parents=True, exist_ok=True) logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", handlers=[ logging.FileHandler(_LOG_DIR / "data_generation.log", encoding="utf-8"), logging.StreamHandler(sys.stdout), ], force=True, ) logger = logging.getLogger(__name__) DEFAULT_OUTPUT = str(_ROOT / "data" / "eval" / "router_test_cases_gen.json") DEFAULT_DB = str(_ROOT / "data" / "sap_erp.db") # --------------------------------------------------------------------------- # ERP 액션 템플릿 # --------------------------------------------------------------------------- # 각 템플릿이 action_type을 명시적으로 들고 있어 정규식 추측 없이 정확히 라벨링된다. # CHANGE_QTY는 절대("change to {qty}") / 상대("reduce by {dec}") 두 모드를 구분한다. _ERP_ACTIONS = [ {"text": "Please change the quantity to {qty} units.", "type": "CHANGE_QTY", "qty_mode": "absolute"}, {"text": "Kindly update the delivery date for this order.", "type": "CHANGE_DATE"}, {"text": "We would like to cancel this order item.", "type": "CANCEL_ITEM"}, {"text": "Please update the shipping address for this order.", "type": "CHANGE_ADDR"}, {"text": "Reduce the order quantity by {dec} units.", "type": "CHANGE_QTY", "qty_mode": "relative"}, {"text": "Please reschedule the delivery by two weeks.", "type": "CHANGE_DATE"}, {"text": "Unblock the delivery for this order.", "type": "OTHER"}, {"text": "Assign batch number B-{batch} to this item.", "type": "OTHER"}, {"text": "Please set the payment terms to Net 60.", "type": "OTHER"}, {"text": "Change the shipping method to express courier.", "type": "OTHER"}, ] def _make_action(rng: random.Random, expected_values: dict | None) -> tuple[str, str, str]: """랜덤 액션을 골라 (이메일 문장, action_type, expected_erp_action_status)를 반환한다. action_type은 템플릿이 명시적으로 들고 있고, status는 실제 운영 룰로 산출한다(아래).""" spec = rng.choice(_ERP_ACTIONS) qty = rng.randint(50, 500) dec = rng.randint(10, 150) batch = rng.randint(10000, 99999) sentence = spec["text"].format(qty=qty, dec=dec, batch=batch) status = _derive_status(spec, qty, dec, expected_values) return sentence, spec["type"], status # --------------------------------------------------------------------------- # Worker A 골든 상태 산출 — 운영 코드를 그대로 호출 (단일 출처, 드리프트 방지) # --------------------------------------------------------------------------- def _derive_status(spec: dict, qty: int, dec: int, expected_values: dict | None) -> str: """expected_erp_action_status를 운영 코드(resolve_quantity → check_business_rules)로 그대로 산출한다. 정규식으로 룰을 재구현하지 않으므로 골든이 worker_a의 실제 판정과 항상 1:1로 일치한다 — delta 재고검사·BLOCKED_INVALID_QTY·CANCEL+B 차단 등 모두 반영.""" if not expected_values: return "BLOCKED_NO_DATA" from src.api.schemas import ERPActionRequest from src.graph.worker_a import resolve_quantity, check_business_rules action_type = spec["type"] kwargs: dict = {"order_id": "1", "item_no": "10", "action_type": action_type} if action_type == "CHANGE_QTY": if spec.get("qty_mode") == "relative": kwargs["quantity_change"] = -dec # "reduce by {dec}" else: kwargs["new_quantity"] = qty # "change to {qty}" elif action_type == "CHANGE_DATE": kwargs["new_date"] = "2030-01-01" try: req = ERPActionRequest(**kwargs) except Exception: return "PENDING_APPROVAL" # 운영과 동일: 상대수량 환산 → 비즈니스 룰. 둘 다 통과면 PENDING_APPROVAL. return (resolve_quantity(req, expected_values) or check_business_rules(req, expected_values) or "PENDING_APPROVAL") # --------------------------------------------------------------------------- # E3 다양성 타깃팅 — 각 BLOCKED_* 상태를 의도적으로 유도 # --------------------------------------------------------------------------- def _forced_action(case: dict, target: str, rng: random.Random) -> tuple[str, str, str] | None: """특정 BLOCKED_* 상태를 유도하는 액션을 만들어 (sentence, action_type, status)를 반환한다. status는 실제 룰(_derive_status)로 산출하며, 의도(target)와 다르면 None(채택 안 함). - SHIPPED/PARTIAL : CANCEL_ITEM (케이스의 WBSTA가 각각 C/B여야 성립) - NO_STOCK : 절대 증가량이 (현재+재고)를 넘게 → delta > 재고 - INVALID_QTY : 상대 감소량이 현재 수량을 넘게 → 음수 """ ev = case.get("expected_values") or {} cur = float(ev.get("quantity") or 0) stock = float(ev.get("available_stock") or 0) if target in ("SHIPPED", "PARTIAL"): spec = {"text": "We would like to cancel this order item.", "type": "CANCEL_ITEM"} status = _derive_status(spec, 0, 0, ev) want = "BLOCKED_SHIPPED" if target == "SHIPPED" else "BLOCKED_PARTIALLY_PROCESSED" return (spec["text"], "CANCEL_ITEM", status) if status == want else None if target == "NO_STOCK": qty = int(cur + stock) + rng.randint(10, 100) spec = {"text": "Please change the quantity to {qty} units.", "type": "CHANGE_QTY", "qty_mode": "absolute"} sentence = spec["text"].format(qty=qty) status = _derive_status(spec, qty, 0, ev) return (sentence, "CHANGE_QTY", status) if status == "BLOCKED_NO_STOCK" else None if target == "INVALID_QTY": dec = int(cur) + rng.randint(1, 50) spec = {"text": "Reduce the order quantity by {dec} units.", "type": "CHANGE_QTY", "qty_mode": "relative"} sentence = spec["text"].format(dec=dec) status = _derive_status(spec, 0, dec, ev) return (sentence, "CHANGE_QTY", status) if status == "BLOCKED_INVALID_QTY" else None return None def _build_action_plan( sql_cases: list[dict], action_slots: list[dict], n_per_label: int, rng: random.Random, ) -> list[tuple[dict, tuple[str, str, str] | None]]: """ACTION_ONLY 슬롯 배정 계획. 앞쪽에 각 BLOCKED_* 상태를 유도하는 타깃 케이스를 넣고, 나머지는 랜덤 액션(forced=None)으로 채운다. 길이는 n_per_label. 데이터에 해당 시나리오(WBSTA=C/B 등)가 없으면 그 타깃은 자동 생략(best-effort). 반환: [(sql_case, forced|None)] — forced = (sentence, action_type, expected_status) """ def ds(c): return (c.get("expected_values") or {}).get("delivery_status") def stock(c): return float((c.get("expected_values") or {}).get("available_stock") or 0) cases_C = [c for c in sql_cases if ds(c) == "C"] cases_B = [c for c in sql_cases if ds(c) == "B"] cases_nonC = [c for c in sql_cases if ds(c) != "C"] cases_lowstock = sorted(cases_nonC, key=stock) # 재고 적은 순 → NO_STOCK 수치를 현실적으로 per_target = max(2, n_per_label // 12) plan: list[tuple[dict, tuple[str, str, str] | None]] = [] def _add(case, target): forced = _forced_action(case, target, rng) if forced is not None: plan.append((case, forced)) for c in rng.sample(cases_C, min(per_target, len(cases_C))): _add(c, "SHIPPED") for c in rng.sample(cases_B, min(per_target, len(cases_B))): _add(c, "PARTIAL") for c in cases_lowstock[:per_target]: _add(c, "NO_STOCK") for c in rng.sample(cases_nonC, min(per_target, len(cases_nonC))): _add(c, "INVALID_QTY") rng.shuffle(plan) plan = plan[:n_per_label] n_targeted = len(plan) for i in range(max(0, n_per_label - n_targeted)): plan.append((action_slots[i % len(action_slots)], None)) logger.info( "ACTION_ONLY targeting: %d targeted slots (C=%d B=%d lowstock=%d) + %d random", n_targeted, len(cases_C), len(cases_B), len(cases_lowstock), n_per_label - n_targeted, ) return plan # --------------------------------------------------------------------------- # 프롬프트 — 각 레이블별 단일 이메일 생성 # --------------------------------------------------------------------------- _QA_PROMPT = ChatPromptTemplate.from_messages([ ("system", """\ You are an expert at simulating realistic B2B SAP ERP email scenarios. Write ONE email from a B2B customer or internal user asking a practical question about how to use the SAP ERP system or how a specific system process works. Rules: 1. The email must NOT contain any specific ERP data modification or action requests (e.g., do not ask to change an order). 2. Focus ONLY on asking a "how-to" or system-related question directly inspired by the provided SAP manual chunk. Make it sound like a user trying to understand or use the system. 3. Use formal business English. 4. Output ONLY a valid JSON object with NO markdown formatting, using this structure: {{ "draft_knowledge_question": "The exact question you are going to ask", "final_email": "The full email text (3-5 sentences)" }} """), ("human", "SAP Manual Chunk (for inspiration):\n\n{chunk}"), ]) _QA_MULTI_PROMPT = ChatPromptTemplate.from_messages([ ("system", """\ You are an expert at simulating realistic B2B SAP ERP email scenarios. Write ONE email from a B2B customer or internal user asking a question that naturally spans BOTH of the provided SAP manual passages. Rules: 1. The email must NOT contain any ERP data modification or action requests. 2. The question should require information from BOTH passages to answer fully — not answerable from either alone. 3. Use formal business English. 4. Output ONLY a valid JSON object with NO markdown formatting, using this structure: {{ "draft_knowledge_question": "The exact combined question you are going to ask", "final_email": "The full email text (3-5 sentences)" }} """), ("human", "SAP Manual Passage A:\n\n{chunk_a}\n\n---\n\nSAP Manual Passage B:\n\n{chunk_b}"), ]) _ACTION_PROMPT = ChatPromptTemplate.from_messages([ ("system", """\ You are writing a realistic B2B customer email requesting a specific SAP ERP action. Rules: 1. Reference the actual order number and item number naturally in the email. 2. Make the action request clear and specific. 3. Do NOT include any policy or concept questions. 4. Use formal business English. 5. The "Order Scenario" is internal background for YOU only — NEVER mention internal database table names (MARD, VBAP, VBAK, etc.), JOINs, SQL, or technical scenario labels in the email. Write exactly as a real customer would (they don't know the backend). 6. Output ONLY the email text (3-5 sentences). No labels, no JSON, no explanation. """), ("human", """\ Order ID: {order_id} Item No: {item_no} Requested Action: {action} Order Scenario: {description} """), ]) _BOTH_PROMPT = ChatPromptTemplate.from_messages([ ("system", """\ You are writing a realistic B2B customer email that naturally combines: 1. A specific SAP ERP action request (referencing real order details) 2. A practical system usage or "how-to" question about the SAP ERP system (inspired by the provided content) Rules: 1. Both the action request and the system usage question must appear naturally in a single email. 2. Reference the actual order number and item number for the action request. 3. Make the system question directly related to how a user interacts with the ERP based on the chunk. 4. Use formal business English. 5. The "Order Scenario" is internal background for YOU only — NEVER mention internal database table names (MARD, VBAP, VBAK, etc.), JOINs, SQL, or technical scenario labels in the email. Write exactly as a real customer would (they don't know the backend). 6. Output ONLY a valid JSON object with NO markdown formatting, using this structure: {{ "draft_action_request": "The specific ERP request you are making", "draft_knowledge_question": "The specific how-to question you are asking", "final_email": "The full email text combining both (4-7 sentences)" }} """), ("human", """\ ERP Action Request: Order ID: {order_id} / Item No: {item_no} Action: {action} Order Scenario: {description} SAP Content (for the system usage question): {chunk} """), ]) # --------------------------------------------------------------------------- # 데이터 로드 # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # 청크 품질 필터 # --------------------------------------------------------------------------- # 흔한 영어 단어(불용어 + 초고빈도 내용어) — OCR garble 판별용 앵커. 사전이 아니므로 # SAP 기술용어(Fiori/VBELN/ATP 등)는 오컷하지 않는다. 정상 영어 산문은 이 단어들이 # 35~50% 깔리지만(median 0.45), 유창 garble("epoteni Legareni Freedood")은 거의 없다(<0.15). _COMMON_WORDS = frozenset(( "the of to and a in is for that on with as are be this by an it or from at can you we have " "not will your they use which used when each all data system order customer document process " "create change between has more their if into other about its using based such may also where " "these new one two first see following example figure note must should within across after " "before during over under via per both then than only any our no but" ).split()) # 법적 고지/면책 front-matter 보일러플레이트 마커 (저작권 페이지 본문) — 정답 근거로 부적합. # 매뉴얼 표준 disclaimer 문구라 교육 본문엔 등장하지 않아 false-positive 위험이 낮다. _BOILERPLATE_MARKERS = ( "forward-looking statements", "without representation or warranty", "shall not be liable", "is not a commitment, promise, or legal obligation", "these materials are provided by sap", "national product specifications may vary", ) # 자가진단 퀴즈/연습문제(O/X) 마커 — 지식 답변이 아닌 평가 문항이라 정답 근거로 부적합. _QUIZ_MARKERS = ( "determine whether this statement is true or false", "x true x false", ) def _is_quality_chunk(text: str, min_length: int = 250) -> bool: """ rag_evidence로 사용하기에 충분한 정보 밀도를 가진 청크인지 검사. 아래 케이스를 필터링: - 너무 짧은 청크 (< min_length 문자) - SAP 저작권 URL만 있는 청크 (법적 고지문) - 법적 고지/면책 front-matter 보일러플레이트 (_BOILERPLATE_MARKERS: forward-looking statements / without representation or warranty / shall not be liable 등) - 학습 평가 답변/문제 청크 ("Learning Assessment") - 자가진단 O/X 퀴즈 문항 (_QUIZ_MARKERS: "Determine whether this statement is true or false" / "X True X False") - 단원 도입부 헤더 ("LESSON OBJECTIVES") - TOC 패턴: "UNIT N" all-caps 또는 "Unit N\nM\n© Copyright" 형태 - OCR garble: 알파벳 비율이 낮거나(숫자·기호 범벅 도표) 1~2글자 토큰이 과다(조각난 인식) - 유창 garble: 흔한 영어 단어 비율 < 0.15 (의미 없는 비단어 범벅 OCR; 실데이터 캘리브레이션) contextual_header("[Source: … | p.N]")가 본문에 prepend된 경우 헤더 줄을 제외한 '본문'으로 판단한다 — 헤더가 길이·토큰 카운트를 부풀려 저품질 청크를 통과시키는 것 방지. """ # contextual_header가 있으면 본문만 분리 (텍스트·OCR 청크 모두 "[Source:"로 시작) if text.startswith("[Source:"): sep = text.find("\n\n") text = text[sep + 2:] if sep != -1 else text.split("\n", 1)[-1] text = text.strip() if len(text) < min_length: return False if "https://www.sap.com/corporate/en/legal/copyright.html" in text: return False if "Learning Assessment" in text: return False low = text.lower() # 법적 고지/면책 보일러플레이트 (front-matter 저작권 페이지) — 답변 근거가 될 수 없음 if any(p in low for p in _BOILERPLATE_MARKERS): return False # 자가진단 퀴즈/연습문제(O/X) — 평가 문항이라 정답 근거로 부적합 if any(p in low for p in _QUIZ_MARKERS): return False if "LESSON OBJECTIVES" in text: return False # all-caps UNIT TOC 패턴: "UNIT 5\nControlling Sales Documents\nLesson 1\n..." if re.match(r'^UNIT\s+\d+', text): return False # TOC 패턴: 줄 대부분이 "Unit X", "Lesson N", 숫자, copyright만으로 구성 lines = [l.strip() for l in text.split('\n') if l.strip()] non_trivial = sum( 1 for l in lines if len(l) > 15 and not l.isdigit() and not l.startswith('Unit ') and not l.startswith('Lesson') and not l.startswith('©') and 'All rights reserved' not in l ) if non_trivial < 2: return False # ── OCR garble 휴리스틱 (정상 산문은 alpha≈0.7+, 단문토큰≈0.3 이라 여유 있게 통과) ── nonspace = [c for c in text if not c.isspace()] if nonspace and sum(c.isalpha() for c in nonspace) / len(nonspace) < 0.5: return False # 숫자·기호 범벅 = 도표/표 OCR garble tokens = text.split() if tokens and sum(1 for t in tokens if len(t) <= 2) / len(tokens) > 0.5: return False # 1~2글자 토큰 과다 = 조각난 OCR # ── 유창 garble 컷: 고빈도 영어 단어 비율 (실데이터 캘리브레이션 임계 0.15) ── words = re.findall(r"[A-Za-z]+", text) if words and sum(1 for w in words if w.lower() in _COMMON_WORDS) / len(words) < 0.15: return False # 비단어 범벅 = 의미 없는 OCR garble return True def _email_has_order(email: str, order_id: str) -> bool: """ERP grounding 검증: 생성된 이메일 본문에 실제 order_id가 들어있는지 확인. RAG 쪽 build_eval_ids의 'evidence ↔ 실제 청크' 매칭과 대칭되는 ERP 충실성 체크로, LLM이 프롬프트의 order_id를 무시/변형해 만든 이메일(r_013/r_023류 누수)을 걸러낸다.""" return bool(order_id) and order_id in (email or "") def load_chunks() -> list[dict]: """ChromaDB 컬렉션에 적재된 청크를 {text, source} 형태로 로드. (.get() 만 사용하므로 임베딩 모델 로딩 불필요 → chromadb 클라이언트 직접 사용)""" cfg = get_config() import chromadb try: client = chromadb.PersistentClient(path=cfg.paths.chroma_db) col = client.get_collection(cfg.rag.collection_name) except Exception as e: logger.warning( "ChromaDB 컬렉션 '%s' 로드 실패 (%s). 먼저 `python -m src.rag.ingest` 로 인제스트하세요.", cfg.rag.collection_name, e, ) return [] got = col.get(include=["documents", "metadatas"]) docs = got.get("documents") or [] metas = got.get("metadatas") or [] return [ {"text": d, "source": (m or {}).get("source", "")} for d, m in zip(docs, metas) ] def sample_chunk_pairs( chunks: list[dict], n: int, rng: random.Random ) -> list[tuple[dict, dict]]: """멀티-청크 복합 QA용으로 청크 2개씩 n쌍 샘플링 (가능하면 서로 다른 source).""" if len(chunks) < 2 or n <= 0: return [] pairs: list[tuple[dict, dict]] = [] for _ in range(n): a = rng.choice(chunks) others = [c for c in chunks if c.get("source") != a.get("source")] or chunks b = rng.choice(others) pairs.append((a, b)) return pairs def _load_chunks() -> list[dict]: chunks = load_chunks() before = len(chunks) chunks = [c for c in chunks if _is_quality_chunk(c["text"])] logger.info("Chunk quality filter: %d → %d (removed %d)", before, len(chunks), before - len(chunks)) return chunks def _load_sql_cases(db_path: str, n: int, seed: int) -> list[dict]: """SQLite DB에서 text2sql 케이스를 샘플링합니다.""" if not Path(db_path).exists(): logger.warning("DB file not found: %s", db_path) return [] try: per_scenario = max(2, n // 6 + 2) cases = _sample_cases(db_path, per_scenario, seed) records = [asdict(c) for c in cases] # negative case 제외 return [r for r in records if r.get("order_id") and r["order_id"] != "9999999999"] except Exception as e: logger.warning("DB sampling failed: %s", e) return [] # --------------------------------------------------------------------------- # 메인 생성 함수 # --------------------------------------------------------------------------- def generate_router_dataset( n_per_label: int = 30, output_path: str = DEFAULT_OUTPUT, db_path: str = DEFAULT_DB, delay: float = 1.5, max_chunks: int | None = None, model_name: str | None = None, append: bool = False, seed: int = 42, multi_chunk_ratio: float = 0.0, enrich: bool = True, ) -> list[dict]: """ Router + E2E 통합 레이블드 이메일 데이터셋을 생성합니다. QA_ONLY, ACTION_ONLY, BOTH 각 n_per_label개씩 균등 생성합니다. - QA_ONLY : ChromaDB RAG 단일 청크 기반 개념/정책 질문 이메일 (multi_chunk_ratio > 0.0이면 해당 비율만큼 2-청크 복합 질문 포함) - ACTION_ONLY: SQLite DB 실제 주문 데이터 기반 ERP 액션 이메일 - BOTH : 실제 주문 데이터 + RAG 청크 결합 복합 이메일 Parameters ---------- n_per_label : 클래스별 생성 목표 수 (총 ≈ n_per_label × 3) output_path : 출력 JSON 경로 db_path : SQLite DB 경로 (sap_erp.db) delay : API 호출 간격(초) max_chunks : 사용할 최대 청크 수 model_name : OpenRouter 모델명 오버라이드 append : 기존 파일에 추가 여부 seed : 랜덤 시드 multi_chunk_ratio : QA_ONLY 슬롯 중 2-청크 복합 질문 비율 (0.0~1.0, 기본 0.3) """ llm = build_llm(model_name=model_name) chunks = _load_chunks() if max_chunks is not None: chunks = chunks[:max_chunks] rng = random.Random(seed) sql_cases = _load_sql_cases(db_path, n_per_label * 4, seed) if not sql_cases: raise RuntimeError( f"No valid order cases loaded from DB: {db_path}\n" "ACTION_ONLY / BOTH generation requires real DB data.\n" "Run 'python -m src.db.setup_sqlite' first." ) # 각 슬롯에 사용할 청크/sql 케이스 미리 결정 shuffled_chunks = list(chunks) rng.shuffle(shuffled_chunks) shuffled_sql = list(sql_cases) rng.shuffle(shuffled_sql) qa_slots = [shuffled_chunks[i % len(shuffled_chunks)] for i in range(n_per_label)] action_slots = [shuffled_sql[i % len(shuffled_sql)] for i in range(n_per_label)] both_sql = [shuffled_sql[(i + n_per_label) % len(shuffled_sql)] for i in range(n_per_label)] both_chunks = [shuffled_chunks[(i + seed) % len(shuffled_chunks)] for i in range(n_per_label)] # QA_ONLY multi-chunk 슬롯 결정 n_multi = max(0, int(n_per_label * multi_chunk_ratio)) multi_indices = set(rng.sample(range(n_per_label), min(n_multi, n_per_label))) multi_pairs = sample_chunk_pairs(chunks, len(multi_indices), rng) dataset: list[dict] = [] _id_counter = 1 # skip된 케이스와 무관하게 연속 ID 보장 def _parse_json_response(raw_text: str) -> dict: if not raw_text: return {} text = raw_text.strip() if text.startswith("```json"): text = text[7:] if text.endswith("```"): text = text[:-3] try: return json.loads(text.strip()) except json.JSONDecodeError: logger.error("JSON parse failed: %s", raw_text) return {} # ── QA_ONLY ────────────────────────────────────────────────────────────── logger.info( "Generating QA_ONLY (target: %d, multi-chunk: %d)", n_per_label, len(multi_indices), ) qa_chain = _QA_PROMPT | llm qa_multi_chain = _QA_MULTI_PROMPT | llm multi_pair_iter = iter(multi_pairs) for i, chunk in enumerate(qa_slots): if i in multi_indices: pair = next(multi_pair_iter, None) if pair is None: pair = (chunk, shuffled_chunks[(i + 1) % len(shuffled_chunks)]) chunk_a, chunk_b = pair logger.info( " QA_ONLY [%d/%d] MULTI src=%s+%s", i + 1, n_per_label, chunk_a["source"], chunk_b["source"], ) raw = invoke_with_retry( qa_multi_chain, {"chunk_a": chunk_a["text"], "chunk_b": chunk_b["text"]}, label="router/QA_MULTI", ) rag_evidence = [chunk_a["text"], chunk_b["text"]] else: logger.info(" QA_ONLY [%d/%d] source=%s", i + 1, n_per_label, chunk["source"]) raw = invoke_with_retry( qa_chain, {"chunk": chunk["text"]}, label="router/QA_ONLY", ) rag_evidence = chunk["text"] time.sleep(delay) if raw is None: continue parsed = _parse_json_response(raw) email = parsed.get("final_email", "").strip() if not email: continue qa_question = parsed.get("draft_knowledge_question", "") dataset.append({ "id": f"r_{_id_counter:03d}", "user_input": email, "label": "QA_ONLY", "erp_evidence": None, "rag_evidence": rag_evidence, "qa_question": qa_question, "expected_action_type": None, "expected_erp_action_status": None, }) _id_counter += 1 logger.info("QA_ONLY done: %d", sum(1 for d in dataset if d["label"] == "QA_ONLY")) # ── QA_ONLY fill-up (retry failed slots) ───────────────────────────────── _fill_attempts = 0 while sum(1 for d in dataset if d["label"] == "QA_ONLY") < n_per_label and _fill_attempts < n_per_label: _fill_attempts += 1 chunk = rng.choice(shuffled_chunks) logger.info(" QA_ONLY fill-up [%d] source=%s", _fill_attempts, chunk["source"]) raw = invoke_with_retry(qa_chain, {"chunk": chunk["text"]}, label="router/QA_ONLY/fill") time.sleep(delay) if raw is None: continue parsed = _parse_json_response(raw) email = parsed.get("final_email", "").strip() if not email: continue dataset.append({ "id": f"r_{_id_counter:03d}", "user_input": email, "label": "QA_ONLY", "erp_evidence": None, "rag_evidence": chunk["text"], "qa_question": parsed.get("draft_knowledge_question", ""), "expected_action_type": None, "expected_erp_action_status": None, }) _id_counter += 1 if _fill_attempts: logger.info("QA_ONLY after fill-up: %d (fill attempts: %d)", sum(1 for d in dataset if d["label"] == "QA_ONLY"), _fill_attempts) # ── ACTION_ONLY ─────────────────────────────────────────────────────────── logger.info("Generating ACTION_ONLY (target: %d)", n_per_label) action_chain = _ACTION_PROMPT | llm # E3 다양성 보장: 각 BLOCKED_* 상태를 유도하는 (케이스, 강제 액션)을 일부 슬롯에 배정. # 나머지는 랜덤 액션. 데이터에 해당 시나리오가 없으면 그 타깃은 자동 생략된다. action_plan = _build_action_plan(sql_cases, action_slots, n_per_label, rng) for i, (sql_case, forced) in enumerate(action_plan): order_id = str(sql_case.get("order_id", "4500012345")) item_no = int(sql_case.get("item_no", 10)) if forced is not None: action, action_type, erp_status = forced else: action, action_type, erp_status = _make_action(rng, sql_case.get("expected_values")) description = sql_case.get("description", "Standard sales order") logger.info(" ACTION_ONLY [%d/%d] order=%s", i + 1, n_per_label, order_id) raw = invoke_with_retry( action_chain, { "order_id": order_id, "item_no": item_no, "action": action, "description": description, }, label="router/ACTION_ONLY", ) time.sleep(delay) if raw is None: continue email = raw.strip() if not email or not _email_has_order(email, order_id): if email: logger.warning("[ground] ACTION_ONLY order_id=%s 가 이메일에 없음 → 슬롯 폐기", order_id) continue dataset.append({ "id": f"r_{_id_counter:03d}", "user_input": email, "label": "ACTION_ONLY", "erp_evidence": { "order_id": order_id, "item_no": item_no, "action": action, "description": description, "expected_values": sql_case.get("expected_values"), }, "rag_evidence": None, "qa_question": None, "expected_action_type": action_type, "expected_erp_action_status": erp_status, }) _id_counter += 1 logger.info("ACTION_ONLY done: %d", sum(1 for d in dataset if d["label"] == "ACTION_ONLY")) # ── ACTION_ONLY fill-up (retry failed slots) ────────────────────────────── _fill_attempts = 0 while sum(1 for d in dataset if d["label"] == "ACTION_ONLY") < n_per_label and _fill_attempts < n_per_label * 3: _fill_attempts += 1 sql_case = rng.choice(shuffled_sql) order_id = str(sql_case.get("order_id")) item_no = int(sql_case.get("item_no", 10)) action, action_type, erp_status = _make_action(rng, sql_case.get("expected_values")) description = sql_case.get("description", "Standard sales order") logger.info(" ACTION_ONLY fill-up [%d] order=%s", _fill_attempts, order_id) raw = invoke_with_retry( action_chain, {"order_id": order_id, "item_no": item_no, "action": action, "description": description}, label="router/ACTION_ONLY/fill", ) time.sleep(delay) if raw is None: continue email = raw.strip() if not email or not _email_has_order(email, order_id): if email: logger.warning("[ground] ACTION_ONLY(fill) order_id=%s 가 이메일에 없음 → 슬롯 폐기", order_id) continue dataset.append({ "id": f"r_{_id_counter:03d}", "user_input": email, "label": "ACTION_ONLY", "erp_evidence": { "order_id": order_id, "item_no": item_no, "action": action, "description": description, "expected_values": sql_case.get("expected_values"), }, "rag_evidence": None, "qa_question": None, "expected_action_type": action_type, "expected_erp_action_status": erp_status, }) _id_counter += 1 if _fill_attempts: logger.info("ACTION_ONLY after fill-up: %d (fill attempts: %d)", sum(1 for d in dataset if d["label"] == "ACTION_ONLY"), _fill_attempts) # ── BOTH ────────────────────────────────────────────────────────────────── logger.info("Generating BOTH (target: %d)", n_per_label) both_chain = _BOTH_PROMPT | llm for i, (sql_case, chunk) in enumerate(zip(both_sql, both_chunks)): order_id = str(sql_case.get("order_id", "4500012345")) item_no = int(sql_case.get("item_no", 10)) action, action_type, erp_status = _make_action(rng, sql_case.get("expected_values")) description = sql_case.get("description", "Standard sales order") logger.info(" BOTH [%d/%d] order=%s source=%s", i + 1, n_per_label, order_id, chunk["source"]) raw = invoke_with_retry( both_chain, { "order_id": order_id, "item_no": item_no, "action": action, "description": description, "chunk": chunk["text"], }, label="router/BOTH", ) time.sleep(delay) if raw is None: continue parsed = _parse_json_response(raw) email = parsed.get("final_email", "").strip() if not email or not _email_has_order(email, order_id): if email: logger.warning("[ground] BOTH order_id=%s 가 이메일에 없음 → 슬롯 폐기", order_id) continue qa_question = parsed.get("draft_knowledge_question", "") dataset.append({ "id": f"r_{_id_counter:03d}", "user_input": email, "label": "BOTH", "erp_evidence": { "order_id": order_id, "item_no": item_no, "action": action, "description": description, "expected_values": sql_case.get("expected_values"), }, "rag_evidence": chunk["text"], "qa_question": qa_question, "expected_action_type": action_type, "expected_erp_action_status": erp_status, }) _id_counter += 1 logger.info("BOTH done: %d", sum(1 for d in dataset if d["label"] == "BOTH")) # ── BOTH fill-up (retry failed slots) ───────────────────────────────────── _fill_attempts = 0 while sum(1 for d in dataset if d["label"] == "BOTH") < n_per_label and _fill_attempts < n_per_label * 3: _fill_attempts += 1 sql_case = rng.choice(shuffled_sql) chunk = rng.choice(shuffled_chunks) order_id = str(sql_case.get("order_id")) item_no = int(sql_case.get("item_no", 10)) action, action_type, erp_status = _make_action(rng, sql_case.get("expected_values")) description = sql_case.get("description", "Standard sales order") logger.info(" BOTH fill-up [%d] order=%s source=%s", _fill_attempts, order_id, chunk["source"]) raw = invoke_with_retry( both_chain, {"order_id": order_id, "item_no": item_no, "action": action, "description": description, "chunk": chunk["text"]}, label="router/BOTH/fill", ) time.sleep(delay) if raw is None: continue parsed = _parse_json_response(raw) email = parsed.get("final_email", "").strip() if not email or not _email_has_order(email, order_id): if email: logger.warning("[ground] BOTH(fill) order_id=%s 가 이메일에 없음 → 슬롯 폐기", order_id) continue dataset.append({ "id": f"r_{_id_counter:03d}", "user_input": email, "label": "BOTH", "erp_evidence": { "order_id": order_id, "item_no": item_no, "action": action, "description": description, "expected_values": sql_case.get("expected_values"), }, "rag_evidence": chunk["text"], "qa_question": parsed.get("draft_knowledge_question", ""), "expected_action_type": action_type, "expected_erp_action_status": erp_status, }) _id_counter += 1 if _fill_attempts: logger.info("BOTH after fill-up: %d (fill attempts: %d)", sum(1 for d in dataset if d["label"] == "BOTH"), _fill_attempts) # ── append 처리 ─────────────────────────────────────────────────────────── if append: existing_path = Path(output_path) if existing_path.exists(): try: existing = json.loads(existing_path.read_text(encoding="utf-8")) max_id = max( (int(e["id"].split("_")[1]) for e in existing if "id" in e), default=0, ) for idx, item in enumerate(dataset, start=max_id + 1): item["id"] = f"r_{idx:03d}" dataset = existing + dataset logger.info("Appended %d to existing %d", len(dataset) - len(existing), len(existing)) except Exception as e: logger.warning("Failed to read existing file — overwriting: %s", e) # ── 저장 ────────────────────────────────────────────────────────────────── out = Path(output_path) out.parent.mkdir(parents=True, exist_ok=True) out.write_text(json.dumps(dataset, indent=2, ensure_ascii=False), encoding="utf-8") # ── 통계 ────────────────────────────────────────────────────────────────── from collections import Counter counts = Counter(d["label"] for d in dataset) n_multi_qa = sum( 1 for d in dataset if d["label"] == "QA_ONLY" and isinstance(d.get("rag_evidence"), list) ) print("\n" + "=" * 60) print(" Router + E2E Dataset Generation Results") print("=" * 60) print(f" QA_ONLY : {counts.get('QA_ONLY', 0)} (multi-chunk: {n_multi_qa})") print(f" ACTION_ONLY : {counts.get('ACTION_ONLY', 0)}") print(f" BOTH : {counts.get('BOTH', 0)}") print(f" Total : {len(dataset)}") print(f" Output : {output_path}") print("=" * 60) # ── enrich: 같은 경로에 evidence_chunk_id + golden_response 이어붙여 완성본 생성 ── # 기본 ON (--no-enrich로 끔). 1회 명령으로 평가용 완전 파일이 나온다. # build_eval_ids는 ChromaDB 인제스트가, golden_responses는 추가 LLM 호출이 필요하다. # 각 단계는 실패해도 흐름을 끊지 않고 경고만 남긴다(generate_all의 step 정책과 동일). if enrich: print("\n[enrich] build_eval_ids → evidence_chunk_id 부여 …") try: from src.rag.build_eval_ids import build_eval_ids build_eval_ids(Path(output_path), Path(output_path)) except Exception as e: logger.warning("[enrich] build_eval_ids 실패 (ChromaDB 인제스트 확인) — 건너뜀: %s", e) print("[enrich] generate_golden_responses → golden_response 생성 …") try: from src.data.generate_golden_responses import generate_golden_responses generate_golden_responses(path=output_path, delay=delay, model_name=model_name) except Exception as e: logger.warning("[enrich] generate_golden_responses 실패 — 건너뜀: %s", e) # enrich 결과를 반환값에도 반영 try: dataset = json.loads(Path(output_path).read_text(encoding="utf-8")) except Exception: pass return dataset # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- def _parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Router + E2E 통합 레이블드 데이터셋 생성") parser.add_argument( "--n-per-label", type=int, default=30, help="클래스별 생성 목표 수 (기본: 30, 총 ≈ 90)", ) parser.add_argument( "--output", default=DEFAULT_OUTPUT, help=f"출력 JSON 경로 (기본: {DEFAULT_OUTPUT})", ) parser.add_argument( "--db", default=DEFAULT_DB, help=f"SQLite DB 경로 (기본: {DEFAULT_DB})", ) parser.add_argument( "--delay", type=float, default=1.5, help="API 호출 간격 초 (기본: 1.5)", ) parser.add_argument( "--max-chunks", type=int, default=None, help="사용할 최대 청크 수 (기본: 전체)", ) parser.add_argument( "--model", default=None, help="OpenRouter 모델명 오버라이드", ) parser.add_argument( "--append", action="store_true", help="기존 출력 파일에 결과를 추가", ) parser.add_argument( "--seed", type=int, default=42, help="랜덤 시드 (기본: 42)", ) parser.add_argument( "--multi-chunk-ratio", type=float, default=0.0, help="QA_ONLY 슬롯 중 2-청크 복합 질문 비율 (0.0~1.0, 기본: 0.0)", ) parser.add_argument( "--no-enrich", action="store_false", dest="enrich", help="보강 단계를 건너뜀(base 스키마만). 기본은 enrich 자동 실행 — 생성 후 같은 경로에 " "evidence_chunk_id + golden_response까지 붙여 완성본 생성. 스모크 테스트/저비용 시 사용.", ) return parser.parse_args() if __name__ == "__main__": args = _parse_args() generate_router_dataset( n_per_label=args.n_per_label, output_path=args.output, db_path=args.db, delay=args.delay, max_chunks=args.max_chunks, model_name=args.model, append=args.append, seed=args.seed, multi_chunk_ratio=args.multi_chunk_ratio, enrich=args.enrich, )