diff --git a/README.md b/README.md index d30aa1271be38bd81eb3c6c1be496f35b0fb3885..a3f39b079bc3b357d3c5ed0a07ef31f239b5b4f8 100644 --- a/README.md +++ b/README.md @@ -10,19 +10,14 @@ pinned: false # 조선일보 본문 교열 데모 -Upstage **solar-mini4** 기반 교열 파이프라인 데모입니다. 평가 하네스와 **같은 엔진 코드**를 -실행합니다 — 데모가 보여주는 결과는 평가 run 과 같은 경로에서 나옵니다. +FT 교열 + Opus 기사 검토 구성(`pipeline_exp_261021_ft_hybrid`) 데모입니다. 평가 하네스와 같은 엔진 코드를 실행합니다. ``` -[규칙] 사전 치환 -[LLM] FT 교열 (ft:solar-news-correction) -[LLM] 판정 — FT 결과를 keep/revert -[LLM] 문맥 교정 · 어휘 -[LLM] 문맥 교정 · 문법 -[규칙] 사전 재적용 · 복합어 복원 · 율/률 규칙 · 후처리 · 조사 교정 · 오탐 되돌림 +[규칙] 용어 사전 치환 +[LLM] FT 교열 (ft:solar-news-correction, 문단 묶음별 동시 호출) +[LLM] 기사 전체 검토 1회 (Claude Opus): FT 오교정 되돌림 + 사실·숫자·문맥 교정 목록 +[규칙] 되돌림·교정 적용 · 따옴표 방향 · 원문 따옴표 보존 · 율/률 · 후처리 ``` -LLM 호출 4회, 요청당 중앙 3.2초(단락 기준). 긴 기사는 문서 단위로 분할해 병렬 처리합니다. - -**실행 전 필요**: Space Settings → Secrets 에 `UPSTAGE_API_KEY` 를 넣으세요 -(없으면 사이드바 입력을 사용합니다). +**실행 전 필요**: Space Settings → Secrets 에 `UPSTAGE_API_KEY` 와 `TIMELY_API_KEY` 를 넣으세요 +(없으면 사이드바 입력을 사용합니다). 기사 검토 단계는 Timely 라우터를 거쳐 Claude Opus 를 부릅니다. diff --git a/__pycache__/logbook.cpython-312.pyc b/__pycache__/logbook.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3c3277e614923e196d67bac2a576012231c8ba18 Binary files /dev/null and b/__pycache__/logbook.cpython-312.pyc differ diff --git a/__pycache__/runner.cpython-312.pyc b/__pycache__/runner.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e0a6452ca1c791dd8f0ad3819af042b5b3318b1c Binary files /dev/null and b/__pycache__/runner.cpython-312.pyc differ diff --git a/app.py b/app.py index cefee7eb5cf52e0a730591ac589ebb3617b246b1..b73f17023fde1f85398526cea9a27271f768df09 100644 --- a/app.py +++ b/app.py @@ -1,12 +1,9 @@ """조선일보 본문 교열 데모 (Streamlit / HF Space). -solar-mini4 교열 파이프라인을 solar-eval 엔진 그대로 실행한다 — FT 교열 → -판정형 step1 → 문맥 교정 어휘/문법 2스텝 → 결정론 규칙 6층. 구성 정의와 실행은 -전부 runner.py 이고 이 파일은 UI 만 담당한다. 사용자 입력과 결과는 logbook.py 가 -Supabase 에 남긴다 (시크릿 없으면 조용히 건너뜀). - -모델 셀렉터는 두지 않는다 — 파이프라인·프롬프트·모델은 한 묶음이고, 모델만 -갈아끼우면 다른 구성이 된다 (runner.CONFIGS 주석 참조). +FT 교열 + Opus 기사 검토 구성을 solar-eval 엔진 그대로 실행한다 - FT 교열 모델이 문단 묶음마다 1차 교열하고, +Claude Opus 가 기사 전체를 한 번 보고 FT 오교정을 되돌리며 사실·숫자·문맥 오류를 고친 뒤 결정론 규칙을 건다. +구성 정의와 실행은 전부 runner.py 이고 이 파일은 UI 만 담당한다. 사용자 입력과 결과는 logbook.py 가 +Supabase 에 남긴다(시크릿이 없으면 조용히 건너뜀). """ from __future__ import annotations @@ -24,33 +21,29 @@ PANE_STYLE = ( "padding:14px 16px;line-height:1.9;font-size:1.02rem;min-height:120px" ) -# 합성 예문 — 평가셋 gold 는 쓰지 않는다(유출 방지). 각 층의 표적을 일부러 심어 둔 문장이다: -# 비률/갱신률/연체률(사전·율률 규칙) · WSJ은·160㎞으로(조사 교정) · ▶ 와 곡선 따옴표(오탐 되돌림 가드). +# 합성 예문 - 평가셋 gold 는 쓰지 않는다(유출 방지). 각 층의 표적을 일부러 심어 둔 문장이다: +# 비률/갱신률/연체률(사전·율률 규칙) · 의문형 어미 · 160㎞으로(조사) · 곧은 따옴표(원문 따옴표 보존). SAMPLE_TEXT = ( "▶정부의 강력한 부동산 규제에도 불구하고 수도권 아파트 매매 거래 비률은 " "오히려 증가세를 보였다. 시중 은행의 주택 담보 대출 금리가 낮아지면서 " "실수요자의 구매 심리가 되살아났기 때문이라는 분석이 나온다. " "국토부 관계자는 “대출 갱신률과 연체률을 함께 봐야 한다”며 “당분간 이런 " - "흐름이 이어질 것”이라고 말했다. WSJ은 한국의 주택 시장이 수도권 중심으로 " - "재편되고 있다고 보도했다. 신규 광역철도는 최고 시속 160㎞으로 설계됐다." + "흐름이 이어질 것”이라고 말했다. 업계에선 'K-주거' 모델이 해외에서도 통할지 " + "주목하고 있다. 신규 광역철도는 최고 시속 160㎞으로 설계됐다." ) -# 스텝 이름 → 사용자에게 보여줄 라벨. 세 구성의 스텝을 모두 담는다 (모르는 스텝은 이름 그대로). -# basic_correction 의 라벨이 구성마다 다르다 — v44 에서는 생성이 아니라 FT 결과 판정이다. +# 스텝 이름 → 사용자에게 보여줄 라벨 (모르는 스텝은 이름 그대로) STEP_LABELS = { "vocabulary_sub_pre": "용어 사전 치환", - "proofread": "1차 교열 (교열 전용 FT 모델)", - "basic_correction": "기초 교정 / FT 결과 판정", - "context_correction": "문맥 교정", - "context_vocab": "문맥 교정 · 어휘", - "context_grammar": "문맥 교정 · 문법", - "style_correction": "문체 교정", - "vocabulary_reapply": "용어 사전 재적용", - "compound_whitelist_restore": "복합어 화이트리스트 복원", + "proofread": "1차 교열 (교열 전용 FT 모델, 문단별)", + "ft_hunks": "FT 수정 목록 정리", + "review": "기사 전체 검토 (Claude Opus 1회)", + "revert_ft": "FT 오교정 되돌림", + "apply_review": "사실·숫자·문맥 교정 적용", + "quote_direction": "따옴표 방향 정리", + "quote_preserve": "원문 따옴표 보존", "yul_ryul_normalize": "율/률 규칙 적용", "post_process": "후처리 (단위·통화·쉼표)", - "josa_fix": "조사 교정 (기호·숫자·단위 뒤)", - "diff_revert": "오탐 되돌림 (글리프·구조문자·쉼표)", } @@ -76,64 +69,32 @@ def diff_panes(src: str, dst: str) -> tuple[str, str, int]: class ProgressView: - """스텝 진행을 st.status 안에 그린다 — 단계별 출력은 보여주지 않고 '어디까지 왔나' 만. - - 문서 단위 chunk 모드에서는 chunk 들이 **병렬**로 돌아 이벤트가 섞여 들어오므로, chunk 마다 줄을 하나씩 - 두고(몇 단계까지 왔나) 전체 진행 바는 (끝난 단계 수) / (chunk 수 × 단계 수) 로 잡는다. - """ + """스텝 진행을 st.status 안에 그린다 - 단계별 출력은 보여주지 않고 '어디까지 왔나' 만.""" def __init__(self, names: list[str]) -> None: self.names = names - self.total_steps = len(names) - self.n_chunks = 1 - self.done: dict[int, int] = {1: 0} # chunk 번호 → 끝난 단계 수 - self.current: dict[int, str] = {} # chunk 번호 → 진행 중 단계 라벨 + self.done = 0 self.status = st.status("교열 중입니다…", expanded=True) - self.bar = self.status.progress(0.0, text=f"0 / {self.total_steps} 단계") + self.bar = self.status.progress(0.0, text=f"0 / {len(names)} 단계") self.lines = self.status.empty() self._render() - def on_step(self, event: str, index: int, total: int, name: str, doc_chunk=None) -> None: - ci = doc_chunk[0] if doc_chunk else 1 - if doc_chunk: - self.n_chunks = doc_chunk[1] - self.done.setdefault(ci, 0) - label = STEP_LABELS.get(name, name) - if event == "start": - self.current[ci] = label - elif event == "chunk": - self.current[ci] = f"{label} · 조각 {index}/{total}" - elif event == "done": - self.done[ci] = index - self.current.pop(ci, None) - finished = sum(self.done.values()) - grand = self.n_chunks * self.total_steps - unit = "단계" if self.n_chunks == 1 else f"단계 (문서 조각 {self.n_chunks}개 병렬)" - self.bar.progress(min(finished / grand, 1.0), text=f"{finished} / {grand} {unit}") + def on_step(self, event: str, index: int, total: int, name: str) -> None: + if event == "done": + self.done = index + self.bar.progress(min(self.done / total, 1.0), text=f"{self.done} / {total} 단계") self._render() def _render(self) -> None: - if self.n_chunks == 1: - done = self.done.get(1, 0) - rows = [] - for i, name in enumerate(self.names, 1): - label = STEP_LABELS.get(name, name) - if i <= done: - rows.append(f"✅ {label}") - elif i == done + 1: - rows.append(f"⏳ **{self.current.get(1, label)}**") - else: - rows.append(f"▫️ {label}") - else: - rows = [] - for ci in range(1, self.n_chunks + 1): - done = self.done.get(ci, 0) - if done >= self.total_steps: - rows.append(f"✅ 조각 {ci}: 완료") - elif ci in self.current: - rows.append(f"⏳ 조각 {ci}: **{self.current[ci]}** ({done}/{self.total_steps})") - else: - rows.append(f"▫️ 조각 {ci}: 대기") + rows = [] + for i, name in enumerate(self.names, 1): + label = STEP_LABELS.get(name, name) + if i <= self.done: + rows.append(f"✅ {label}") + elif i == self.done + 1: + rows.append(f"⏳ **{label}**") + else: + rows.append(f"▫️ {label}") self.lines.markdown(" \n".join(rows)) def finish(self, elapsed_s: float) -> None: @@ -144,49 +105,47 @@ class ProgressView: @st.cache_resource(show_spinner="파이프라인 준비 중…") -def get_pipeline(label: str): - """구성 라벨로 캐시한다 — 구성이 늘면 그 구성의 파이프라인이 따로 조립된다.""" - return runner.build_pipeline(label) +def get_pipeline(): + return runner.build_pipeline() st.set_page_config(page_title="조선일보 본문 교열 데모", page_icon="📰", layout="wide") st.title("📰 조선일보 본문 교열 데모") -st.caption("solar-mini4 파이프라인 · 평가 하네스와 같은 엔진 코드") +st.caption(f"{runner.CONFIG.label} · 평가 하네스와 같은 엔진 코드") with st.sidebar: st.subheader("설정") - if not os.environ.get("UPSTAGE_API_KEY"): - key = st.text_input( - "UPSTAGE_API_KEY", - type="password", - help="HF Space 에서는 Settings → Secrets 로 설정하세요.", - ) - if key: - os.environ["UPSTAGE_API_KEY"] = key - label = runner.DEFAULT_LABEL - cfg = runner.BY_LABEL[label] + for env_key in runner.REQUIRED_KEYS: + if not os.environ.get(env_key): + value = st.text_input( + env_key, type="password", help="HF Space 에서는 Settings → Secrets 로 설정하세요." + ) + if value: + os.environ[env_key] = value + cfg = runner.CONFIG st.markdown(f"**{cfg.label}**") st.caption(cfg.note) - st.markdown(f"- 모델 `{cfg.model}`\n- 파이프라인 `{cfg.pipeline}`\n- 프롬프트 `{cfg.prompt}`") + st.markdown(f"- 파이프라인 `{cfg.pipeline}`\n- 프롬프트 `{cfg.prompt}`") -text = st.text_area("기사 본문", value=SAMPLE_TEXT, height=320, max_chars=4000) +text = st.text_area("기사 본문", value=SAMPLE_TEXT, height=320, max_chars=12000) if st.button("교열 실행", type="primary", disabled=not text.strip()): - if not os.environ.get("UPSTAGE_API_KEY"): + missing = [k for k in runner.REQUIRED_KEYS if not os.environ.get(k)] + if missing: st.error( - "UPSTAGE_API_KEY 가 필요합니다 — 사이드바에 입력하거나 Space Secrets 에 설정하세요." + f"{', '.join(missing)} 가 필요합니다 - 사이드바에 입력하거나 Space Secrets 에 설정하세요." ) st.stop() source = text.strip() - pipeline = get_pipeline(label) - # 입력은 실행 전에 남긴다 — 실행이 실패해도 무엇이 들어왔는지는 알 수 있게 + pipeline = get_pipeline() + # 입력은 실행 전에 남긴다 - 실행이 실패해도 무엇이 들어왔는지는 알 수 있게 article_id = logbook.save_article(source) progress = ProgressView(runner.step_names(pipeline)) try: - result = runner.run_proofread(pipeline, source, label=label, on_step=progress.on_step) - except Exception as e: # noqa: BLE001 — 데모 표면에서는 원인 요약만 보여준다 + result = runner.run_proofread(pipeline, source, on_step=progress.on_step) + except Exception as e: # noqa: BLE001 - 데모 표면에서는 원인 요약만 보여준다 progress.fail() st.error(f"실행 실패: {type(e).__name__}: {e}") st.stop() @@ -204,7 +163,6 @@ if st.button("교열 실행", type="primary", disabled=not text.strip()): st.caption(f"기록 실패 (결과에는 영향 없음): {logbook.last_error()}") left, right, _n_changed = diff_panes(source, result["output"]) - c1, c2 = st.columns(2, gap="medium") with c1: st.subheader("원문") diff --git a/assets/pipelines/pipeline_dev_v40.yaml b/assets/pipelines/pipeline_dev_v40.yaml deleted file mode 100644 index 81a86d5c0c2fbac2c5e665debc227b8025e012fb..0000000000000000000000000000000000000000 --- a/assets/pipelines/pipeline_dev_v40.yaml +++ /dev/null @@ -1,71 +0,0 @@ -name: pipeline_dev_v40 -description: "v38(C1) + 규칙 층 3종 (2026-09-18 라운드): ① vocabulary_v2.csv — 표준어/사이시옷/외래어 혼동 71쌍 등재 (C3 실패로 소거된 'LLM 기초교열 추가'의 규칙 대체), ② josa_fix — 기호·숫자·단위 뒤 조사 결정론 교정 (풀셋 gold 15건 중 모델 적중 1건이던 클래스), ③ diff_revert — 글리프 변환·쉼표 삽입·금지 치환·격식 전환 오탐 되돌림 (paragraph 오탐 410건 중 ~100건이 이 클래스). 풀셋 재생(replay) 실측: paragraph +4.71 / ci-v0 +3.67 / ci-v1 +1.78, rep-small 3반복 재생 +2.47. 스텝 추가라 extends 불가, 전체 파일." -text_split_size: 500 -steps: - - name: vocabulary_sub_pre - type: rule - rule_type: vocabulary_sub - config: - vocab_dataset: resources/vocabulary_v2.csv - - - name: proofread - type: llm - model: ft:solar-news-correction - prompt_key: proofread - max_tokens: 2000 - - - name: basic_correction - type: llm - prompt_key: step1 - user_template: | - <원문> - {steps.vocabulary_sub_pre} - - - <교열_모델_수정결과> - {current} - - - - name: context_vocab - type: llm - prompt_key: step2a - - - name: context_grammar - type: llm - prompt_key: step2b - - - name: vocabulary_reapply - type: rule - config: - vocab_dataset: resources/vocabulary_v2.csv - - - name: compound_whitelist_restore - type: rule - config: - whitelist_path: resources/compound_whitelist.txt - - - name: yul_ryul_normalize - type: rule - config: {} - - - name: post_process - type: rule - config: - pronoun_replacements: - 이재명 대표: 이재명 대통령 - currency_compact: true - comma_removal: true - unit_unicode: true - - - name: josa_fix - type: rule - config: {} - - - name: diff_revert - type: rule - config: - glyph_restore: true - comma_insertion_revert: true - forbidden_insertions: - - 청와대 - register_guard: true diff --git a/assets/pipelines/pipeline_dev_v44.yaml b/assets/pipelines/pipeline_dev_v44.yaml deleted file mode 100644 index 7ccb7b4a050fbe3de9668b7e99d3d74da76c9c0e..0000000000000000000000000000000000000000 --- a/assets/pipelines/pipeline_dev_v44.yaml +++ /dev/null @@ -1,6 +0,0 @@ -name: pipeline_dev_v44 -description: "v41 + 문서 단위 chunk — 데모(HF Space) 표면용. 내용상 `pipeline_dev_v41` 과 동일하고 `document_chunking: true` 하나만 다르다. **base 가 v40 인 이유**: 상속은 1단계로 제한돼 있어(core/pipeline_compose.py) v41(= v40 + punct 플래그)을 다시 extends 할 수 없다 — 그래서 v41 이 얹는 플래그를 여기서 함께 선언한다. 두 파일은 diff_revert 설정이 갈라지면 안 되므로 v41 을 고칠 때 이 파일도 같이 본다. chunk 근거: v35(= v34 + chunk)가 long-article 매칭 ΔF1 −0.9 → +3.5 (2026-09-10, `#260910_long_article_regression_set`). 단 v41 계열에서의 긴 기사 검증은 아직 없다(long-article 셋 미확보)." -extends: pipeline_dev_v40 -overrides: - steps.diff_revert.config.punct_deletion_revert: true - document_chunking: true diff --git a/assets/pipelines/pipeline_exp_261021_ft_hybrid.yaml b/assets/pipelines/pipeline_exp_261021_ft_hybrid.yaml new file mode 100644 index 0000000000000000000000000000000000000000..a01fede73030f19f5ee4d55a34a0a858cfa07c33 --- /dev/null +++ b/assets/pipelines/pipeline_exp_261021_ft_hybrid.yaml @@ -0,0 +1,66 @@ +name: pipeline_exp_261021_ft_hybrid +description: 'FT 문단 층 + 기사 층 1회 (2026-10-21) - 표기 사전 치환 뒤 ft:solar-news-correction 을 학습 시스템 프롬프트(ft_system)로 줄바꿈 500자 묶음마다 동시에 부르고(묶음 동시 8), 원문 대비 변경 목록(사전 치환 포함, 띄어쓰기만 다른 것은 뺌)과 FT 교정본 전문을 기사 층 1회(review: FT·사전 사고 되돌림 번호 + FT 가 못 보는 사실·숫자·문맥 낱말 교정 목록)에 준다. 결정론 되돌림·적용 뒤 따옴표 방향·율/률·후처리 규칙. 기본 기사 층은 Opus, Solar 만이면 --set steps.review.model=solar-pro4 --set steps.review.prompt_key=review_sp4. 측정·변이 기록은 insights/261021_ft_hybrid_paragraph_article.md.' +text_split_size: 500 +text_split_mode: lines +steps: + - name: vocabulary_sub_pre + type: rule + rule_type: vocabulary_sub + config: + vocab_dataset: resources/vocabulary.csv + - name: proofread + type: llm + model: ft:solar-news-correction + prompt_key: ft_system + max_tokens: 2000 + temperature: 0.0 + bulk_concurrency: 8 + - name: ft_hunks + type: diff_hunks + config: + key: ft_diff + context: 20 + max_hunks: 80 + skip_spacing: true + - name: review + type: llm + model: timely:claude-opus-5-5 + prompt_key: review + output_mode: json + max_tokens: 4000 + user_template: | + + {steps.ft_diff} + + + <본문> + {current} + + - name: revert_ft + type: revert_hunks + config: + hunks_from: ft_hunks + from_step: review + - name: apply_review + type: apply_corrections + config: + from_step: review + max_items: 12 + max_shrink: 0.6 + - name: quote_direction + type: rule + config: {} + - name: quote_preserve + type: rule + config: {} + - name: yul_ryul_normalize + type: rule + config: {} + - name: post_process + type: rule + config: + pronoun_replacements: + 이재명 대표: 이재명 대통령 + currency_compact: true + comma_removal: true + unit_unicode: true diff --git a/assets/prompts/prompt_dev_260917_vsplit/step1.txt b/assets/prompts/prompt_dev_260917_vsplit/step1.txt deleted file mode 100644 index 2de7af4e2da08ad666ba39bc118fd4ab6cd79fce..0000000000000000000000000000000000000000 --- a/assets/prompts/prompt_dev_260917_vsplit/step1.txt +++ /dev/null @@ -1,85 +0,0 @@ -🚨🚨🚨 **최상위 핵심 지시사항 (PRIME DIRECTIVE)** 🚨🚨🚨 -**사실 보존이 최우선이다.** `<원문>` 이 가리키는 인물·기관·직책·숫자·날짜·사건은 너의 지식과 다르더라도 절대 다른 대상으로 바꾸지 않는다. 이를 위반하는 것이 가장 중대한 오류다. - -## Task -너는 한국어 뉴스 교열의 **검증자(verifier)** 다. 두 텍스트를 받는다: -- `<원문>`: 교열되지 않은 원본. **모든 사실 정보(이름, 직책, 숫자 등)의 기준.** -- `<교열_모델_수정결과>`: 1차 교열 모델이 수정한 결과. 올바른 교정과 잘못된 수정이 섞여 있을 수 있다. - -너의 임무는 **새로 교정하는 것이 아니라, 1차 교열의 수정을 하나씩 판정하는 것**이다. -다음 3단계를 순서대로 수행하라: - -1. **차이 나열**: `<원문>` 과 `<교열_모델_수정결과>` 를 어절 단위로 대조해, 1차 교열이 바꾼 지점을 **빠짐없이** 나열한다. 형식: `'원문 표현' -> '수정 표현'` -2. **판정**: 각 차이에 대해 `keep`(올바른 교정, 유지) 또는 `revert`(잘못된 수정, 원문 복원) 를 판정하고 근거를 한 줄로 적는다. -3. **최종 조립**: `keep` 판정만 반영하고 `revert` 판정은 원문 표현으로 되돌린 최종 텍스트를 만든다. - -**새 교정을 추가하는 것은 너의 임무가 아니다.** 1차 교열이 놓친 오류는 이후 단계가 처리한다. 차이가 하나도 없으면 입력을 그대로 출력한다. - -## 판정 기준 - -**keep (올바른 교정 — 유지):** -- 맞춤법·오탈자 교정 (예: `권의주의` -> `권위주의`, `기렷다` -> `기렸다`) -- 사이시옷 교정 (예: `코수염` -> `콧수염`, `댓가` -> `대가` — 넣을 곳과 뺄 곳 모두) -- 조사·어미 교정 (예: `1를` -> `1을`, `~했다고 했다` 의 호응 교정) -- 명백한 띄어쓰기 오류 교정 -- 같은 대상의 표기 오류 교정 (예: `오니카와` -> `오키나와`, `쉥겐` -> `솅겐` — 대상이 같고 표기만 틀렸을 때) - -**revert (잘못된 수정 — 원문 복원):** -- **대상 교체**: 인물·기관·직책·사건을 다른 대상으로 바꾼 것 (예: `이재명 대통령` -> `윤석열 대통령`, `기시다 총리` -> `다카이치 총리`). 너의 지식상 원문이 틀려 보여도 **무조건 revert**. -- **숫자·날짜·단위 값 변경**: 수치 자체를 바꾼 것 (표기 정규화가 아니라 값이 달라진 경우) -- **동의어 치환·윤문**: 원문 표현도 표준어이고 뜻이 통하는데 다른 표현으로 바꾼 것 (예: `재차` -> `다시`) -- **문장 압축·내용 삭제·내용 추가**: 원문에 있던 내용이 사라지거나 없던 내용(괄호 부연 포함)이 생긴 것 -- **허용되는 표기를 바꾼 것**: 붙여 씀/띄어 씀이 모두 허용되는 복합어의 재조정 -- **문단·문장 구조 변경**: `` 나 줄바꿈의 추가·삭제·이동 - -판단이 확실하지 않으면 **revert** 한다 — 원문 유지가 기본값이다. - -## 출력 형식 - -먼저 판정 목록을 자유 텍스트로 적은 뒤, **마지막에 JSON 하나**를 출력한다. -- `output`: 최종 텍스트 전체. `` 와 줄바꿈 구조는 원문과 100% 동일하게 유지 (`[IMG:~~]` 태그 포함). -- `explanation`: revert 한 항목 요약. 없으면 `"모든 수정 유지"`. 줄바꿈은 `\n` 으로 표기. - -## 예시 - -**Input:** -```xml -<원문> -윤석열 대통령은 이날 점오에 모여 회의를 기렷다. - -<교열_모델_수정결과> -이재명 대통령은 이날 정오에 모여 회의를 기렸다. - -``` - -**CORRECT EXAMPLE:** -차이 판정: -1. '윤석열 대통령은' -> '이재명 대통령은' : revert (대상 교체 — 원문의 인물을 다른 인물로 변경) -2. '점오에' -> '정오에' : keep (오탈자 교정) -3. '기렷다' -> '기렸다' : keep (맞춤법 교정) - -{"output": "윤석열 대통령은 이날 정오에 모여 회의를 기렸다.", "explanation": "'이재명 대통령은' -> '윤석열 대통령은' (대상 교체 복원)"} - -**Input:** -```xml -<원문> -그는 젊은 시절 권위주의에 맞서 재차 투쟁했다. - -<교열_모델_수정결과> -그는 젊은 시절 권위주의에 맞서 다시 투쟁했다. - -``` - -**CORRECT EXAMPLE:** -차이 판정: -1. '재차' -> '다시' : revert (동의어 치환 — 원문 표현도 표준어) - -{"output": "그는 젊은 시절 권위주의에 맞서 재차 투쟁했다.", "explanation": "'다시' -> '재차' (동의어 치환 복원)"} - ------------------------------------- - -⚠️ 최종 확인: -- 판정 목록 다음, 마지막에 JSON 한 개만 출력한다 (JSON 뒤에 다른 텍스트 금지) -- `output` 의 `` 개수·위치·줄바꿈은 원문과 100% 동일 -- 차이가 없으면: `{"output": "[입력 그대로]", "explanation": "수정된 내용이 없습니다."}` -- 새 교정 추가 금지 — 나열·판정·조립만 한다 diff --git a/assets/prompts/prompt_dev_260917_vsplit/step2a.txt b/assets/prompts/prompt_dev_260917_vsplit/step2a.txt deleted file mode 100644 index 220f11e76a3ff635002b4b91cee902910e2278f1..0000000000000000000000000000000000000000 --- a/assets/prompts/prompt_dev_260917_vsplit/step2a.txt +++ /dev/null @@ -1,36 +0,0 @@ -🚨🚨🚨 **최상위 핵심 지시사항 (PRIME DIRECTIVE)** 🚨🚨🚨 -1. **사실 정보 수정 절대 금지**: 인물, 직책, 숫자, 고유명사는 너의 지식과 다르더라도 절대 바꾸지 않는다. (입력 `이재명 대통령` → 출력 `이재명 대통령`) -2. **구조 절대 유지**: 문단 수·문장 순서·줄바꿈·``·`[IMG:~~]` 태그를 그대로 유지한다. 내용 추가(괄호 부연 포함)·삭제 금지. -3. **수정하지 않는 것이 기본값이다**: 동의어 치환, 문장 압축, 띄어쓰기 재조정, 표현 다듬기는 전부 금지. 아래 임무에 해당하는 교정만 한다. - -# 역할 -당신은 뉴스 기사 교열가 중 **어휘 오식(誤植) 전담**입니다. 기초 교열이 끝난 텍스트에서 **단 한 가지**만 찾습니다: **문맥에 뜻이 통하지 않는 단어**. - -신문 제작 과정에서는 발음·표기가 비슷한 다른 단어로 잘못 찍히는 오식이 잦습니다. 문장을 읽으며 뜻이 어긋나는 단어를 발견하면, 발음이 비슷하고(대개 한두 글자 차이) 뜻이 명백히 통하는 단어로 교정하세요. **현재 단어로도 뜻이 통하면 절대 바꾸지 않습니다** — 이 작업은 오식 복원이지 어휘 선호 교체가 아닙니다. - -# 교정 예시 (오식 복원) -- 곤혹을 치르다 → 곤욕을 치르다 (치르는 것은 '곤욕') -- 겉잡을 수 없이 → 걷잡을 수 없이 -- 정답을 맞추다 → 정답을 맞히다 (적중은 '맞히다') -- 수출량이 급등하다 → 수출량이 급증하다 (수량은 급증/급감, 가격·지수는 급등/급락) -- 실수가 번복되다 → 실수가 반복되다 (되풀이는 '반복', 결정 뒤집기는 '번복') -- 신문에 개재된 기사 → 신문에 게재된 기사 -- 유래없는 폭염 → 유례없는 폭염 (비슷한 사례는 '유례') -- 승전보를 울리다 → 승전고를 울리다 -- 각출하다 → 갹출하다 (여럿이 나누어 낼 때) -- 매여 → 메어 (어깨에 걸 때는 '메다') - -# 바꾸면 안 되는 예 (뜻이 통함 — 유지) -- '재차 강조했다' → 그대로 유지 ('재차'도 표준어다. '다시'로 바꾸지 마라) -- '구별하다' → 문맥상 뜻이 통하면 그대로 유지 -- 율/률 표기, 띄어쓰기, 사이시옷 → 이 단계의 대상이 아니다. 손대지 마라 - -# 출력 형식 -JSON 하나만 출력한다 (마크다운 코드 블록·설명문 금지): -{"output": "[교정 완료된 전체 텍스트 - 수정 없어도 입력 전체 포함, ·줄바꿈은 입력과 100% 동일]", "explanation": "['기존' -> '수정' (이유)' 형식, 여러 개는 \\n 구분, 없으면 '수정된 내용이 없습니다.']"} - -**Input:** 사고 여파가 겉잡을 수 없이 커지자 시는 곤혹을 치렀다. -**CORRECT:** {"output": "사고 여파가 걷잡을 수 없이 커지자 시는 곤욕을 치렀다.", "explanation": "'겉잡을' -> '걷잡을' (오식 복원)\n'곤혹을' -> '곤욕을' (치르는 것은 '곤욕')"} - -**Input:** K팝 그룹이 컴백했다. -**CORRECT:** {"output": "K팝 그룹이 컴백했다.", "explanation": "수정된 내용이 없습니다."} diff --git a/assets/prompts/prompt_dev_260917_vsplit/step2b.txt b/assets/prompts/prompt_dev_260917_vsplit/step2b.txt deleted file mode 100644 index e28e3858baec07426dc034c94f5e86828a425b1b..0000000000000000000000000000000000000000 --- a/assets/prompts/prompt_dev_260917_vsplit/step2b.txt +++ /dev/null @@ -1,33 +0,0 @@ -🚨🚨🚨 **최상위 핵심 지시사항 (PRIME DIRECTIVE)** 🚨🚨🚨 -1. **사실 정보 수정 절대 금지**: 인물, 직책, 숫자, 고유명사는 너의 지식과 다르더라도 절대 바꾸지 않는다. (입력 `이재명 대통령` → 출력 `이재명 대통령`) -2. **구조 절대 유지**: 문단 수·문장 순서·줄바꿈·``·`[IMG:~~]` 태그를 그대로 유지한다. 내용 추가(괄호 부연 포함)·삭제 금지. -3. **수정하지 않는 것이 기본값이다**: 동의어 치환, 문장 압축, 어순 재배치, 표현 다듬기, 띄어쓰기 재조정은 전부 금지. 아래 임무에 해당하는 교정만 한다. - -# 역할 -당신은 뉴스 기사 교열가 중 **문법 호응 전담**입니다. 기초 교열이 끝난 텍스트에서 다음 네 가지만 찾습니다: - -1. **조사 오류·누락**: 받침에 맞지 않는 조사, 문맥에 안 맞는 조사, 빠진 조사 - - 예: `1를` → `1을` / `하르그섬이 원유 수출 심장부` → `하르그섬의 원유 수출 심장부` (소유 관계) -2. **어미·용언 활용 오류**: 잘못된 활용형, 겹친 어미 - - 예: `가겠다"며며` → `가겠다"며` / `슬프자` → `슬퍼서` / `모자라안 하다` → `모자라지 않다` -3. **문장 성분 호응 오류**: 주어-서술어, 목적어-서술어가 어긋나는 경우 — **어긋난 성분만 최소 수정** - - 예: `어떻게 관리하는가 중요하다` → `어떻게 관리하는지가 중요하다` -4. **단위 누락**: 수치 뒤에 단위가 빠진 경우 — 문맥에 명시된 단위만 보충 - - 예: `시장 규모는 1조7000억이다` → `시장 규모는 1조7000억원이다` (앞 문맥이 금액일 때) - -**사동/피동 혼용**은 명백히 문법이 틀린 경우만 고칩니다 (예: `보여지다` → `보이다`). 둘 다 허용되는 표현은 그대로 둡니다. - -# 바꾸면 안 되는 예 (유지) -- 뜻이 통하는 문장의 어순 → 그대로 유지 (재배치는 너의 임무가 아니다) -- '~에도 불구하고', 번역투로 보이는 표현 → 문법이 틀리지 않았으면 유지 -- 띄어쓰기·사이시옷·맞춤법 → 이 단계의 대상이 아니다. 손대지 마라 - -# 출력 형식 -JSON 하나만 출력한다 (마크다운 코드 블록·설명문 금지): -{"output": "[교정 완료된 전체 텍스트 - 수정 없어도 입력 전체 포함, ·줄바꿈은 입력과 100% 동일]", "explanation": "['기존' -> '수정' (이유)' 형식, 여러 개는 \\n 구분, 없으면 '수정된 내용이 없습니다.']"} - -**Input:** 법원은 혐의 11개에 대해 유죄 판결을 내렸고 조국은 "가시밭길을 가겠다"며며 항소했다. -**CORRECT:** {"output": "법원은 혐의 11개에 대해 유죄 판결을 내렸고 조국은 \"가시밭길을 가겠다\"며 항소했다.", "explanation": "'가겠다\"며며' -> '가겠다\"며' (겹친 어미 교정)"} - -**Input:** K팝 그룹이 컴백했다. -**CORRECT:** {"output": "K팝 그룹이 컴백했다.", "explanation": "수정된 내용이 없습니다."} diff --git a/assets/prompts/prompt_exp_261021_ft_hybrid/ft_system.txt b/assets/prompts/prompt_exp_261021_ft_hybrid/ft_system.txt new file mode 100644 index 0000000000000000000000000000000000000000..355f067e35a03851fd8896ef584a4a9d3d012720 --- /dev/null +++ b/assets/prompts/prompt_exp_261021_ft_hybrid/ft_system.txt @@ -0,0 +1 @@ +입력된 문서에 대한 교열 결과를 생성해 주세요. diff --git a/assets/prompts/prompt_exp_261021_ft_hybrid/review.txt b/assets/prompts/prompt_exp_261021_ft_hybrid/review.txt new file mode 100644 index 0000000000000000000000000000000000000000..03c5cc3a5a1a3b961ae7ca31a41bc8f5d15ce252 --- /dev/null +++ b/assets/prompts/prompt_exp_261021_ft_hybrid/review.txt @@ -0,0 +1,41 @@ +너는 한국 신문사 어문팀의 **최종 교열 데스크**다. 1차 교열 모델(FT)이 기사를 문단(500자)씩 나눠 이미 고쳤다. 너는 기사 전체를 한 번에 보고 두 가지 일을 한다. +(A) FT 가 고친 자리 가운데 **FT 가 잘못 고친 것만** 골라 되돌린다. (B) FT 가 구조적으로 **못 보는 오류**를 찾아 짧은 교정 항목으로 낸다. 본문을 다시 쓰지 않는다. + +## FT 를 알고 시작한다 +- FT 는 규범 교열을 잘한다: 맞춤법·오탈자, 조사, 어미·활용, 주술 호응·비문, 사이시옷, 띄어쓰기, 겹친 말 삭제, 단위·문장부호 표기. 조선일보 관례(`N개국`→`N국`, `슈퍼`→`수퍼`, 곧은 따옴표→둥근 따옴표)도 FT 가 따른다. **이 종류의 FT 변경은 되돌리지 않는다.** 네가 보기에 다른 표현도 가능하더라도 그대로 둔다. +- 단, 위 규범 교정은 **인용문 밖, 일반 낱말**에 대한 것이다. FT 는 따옴표 안 말, 고유명사 철자, 짧은 관형어·조사 하나에 담긴 뜻을 규범 교정인 척 바꾸는 사고를 낸다. 문단 밖을 못 보고 기사 작성 시점도 모른다. (A)에서 되돌릴 것은 아래 사고뿐이다. +- FT 는 기사 전체의 사실·숫자·이름 관계와 문맥상 낱말 혼동을 거의 고치지 못한다 — (B)에서 네가 맡는다. + +## (A) 되돌릴 FT 변경 — `` 의 번호 중 아래에 해당하는 것 +판정 순서: ① 바뀐 자리가 따옴표 안인가 → 3번. ② 고유명사인가 → 2번. ③ 지워지거나 더해진 말이 뜻을 갖는가 → 4·5번. ④ 남은 것이 맞춤법·조사·어미·호응 정리면 둔다. +1. **숫자 바꿈**: 연도·날짜·금액·비율·순위·인원 등의 숫자(0~9)가 다른 숫자로 바뀜. 예: `2026`→`2023`, `20곳`→`18곳`. (값은 같고 표기만 바뀐 것, `30001명`→`3001명`처럼 자릿수가 겹친 오타, **기사 안 다른 숫자로 검산하면 FT 쪽 값이 맞는 것**(합·차·비율), 기사 다른 곳 숫자와 맞춘 것은 둔다.) +2. **고유명사 바꿈**: 인명·지명·기관명·회사명·작품명·직함이 다른 이름이 되거나 한 글자라도 바뀜, 순서 이동, 정식 명칭의 일부를 잘라냄(`경성제국대학`→`경성제대`). **상호·브랜드·제품명·시설명·행사 이름은 외래어 표기법과 달라도 그 이름의 표기가 정답이다**: `뚜레쥬르`→`뚜레주르`, `K-팝 페스티벌`의 하이픈 삭제 같은 표기 '교정'은 되돌린다. 외국 인명·지명의 한 글자 바꿈도 되돌린다. (기사 안 다른 곳 표기와 맞춘 것, 없는 글자 조합 같은 명백한 오타 교정은 둔다.) +3. **인용 훼손**: 큰따옴표·작은따옴표 안 변경은 **원문 그대로 화자가 그렇게 말했을 법한가**로 가른다. + - 되돌림 — 말투 정리: 원문이 구어로 자연스럽고 뜻이 통하는데 다듬은 것. 축약형 풀기(`“그건 아니다”`→`“그것은 아니다”`, `“안 했다”`→`“하지 않았다”`), 복수 `들` 삭제(`“선수들이”`→`“선수가”`), 구어 조사 바꾸기(`“동생한테”`→`“동생에게”`), 약어·줄임말 풀기, 낱말·어순 취향, 글자를 넣고 뺌 — 규범상 더 낫더라도 되돌린다. 인용문·인용 뒤 절 삭제, `”라며`·`”라고`의 `라` 삭제도 되돌린다. + - 둔다 — 오류 교정: 원문 그대로는 문맥에 안 맞는 명백한 오류를 고친 것. 사전에 없는 오탈자, 한 글자가 달라 뜻이 엉뚱해진 낱말(`“의견을 수렵하겠다”`→`“수렴하겠다”`), 인용 끝 어미가 문장과 안 맞는 것(`“늘려야 하자”고`→`“늘려야 한다”고`). +4. **뜻 있는 말 삭제**: 어절·구·절·문장·괄호 설명·사진 설명이 사라짐(`삼성전자가`→`∅`). 짧은 말도 뜻이 있으면 되돌린다: 시점 관형어(`오는`·`지난`·`약`), 복수 `들`(`이들 사안`→`이 사안`), 기간 `간`(`5년간은`→`5년은`), 날짜 범위의 `일`(`28일~다음 달 2일`), `시·군` 같은 가운뎃점(`시군`으로 붙어 뜻이 흐려짐), `와의`·`인`처럼 지우면 비문이 되는 말, 숫자 천 단위 쉼표(`152,000원`), 나열 사이 쉼표. `개`를 지워 `12군`·`20항`처럼 서수·부대명으로 읽히게 된 것도 되돌린다(`개국`→`국`은 관례라 둔다). (바로 앞뒤에 똑같은 말이 겹쳐 하나를 지운 것은 둔다.) +5. **추가·뜻 바꿈**: 원문에 없던 정보·문장·`(사진)`이 붙음, 마침표 없는 중간제목 줄에 `이다.` 같은 서술어를 덧붙임, 맞는 낱말을 뜻이 다른 낱말로 바꿈(`연구개발비를`→`R&D 비용을`, `이달`→`다음 달`, `방문 계기`→`방문차`처럼 계기가 목적으로 바뀜). 조사 교체도 원문이 맞고 뜻이 바뀌면 되돌린다: 첨가의 `도`→`는`, 사람·그룹 뒤 `에게`→`에`, 주어 `이`→목적어 `을`로 서술어 호응이 깨짐. (원문 조사가 틀린 것을 고친 것 — `정부에게`→`정부에`, `학생들이 책이`→`책을` — 은 둔다.) +6. **외래어 역교정**: 표준 외래어 표기를 비표준으로 바꿈. 예: `로봇`→`로보트`, `디지털`→`디지탈`, `리더십`→`리더쉽`, `센서`→`쎈서`(된소리), `패널`→`패넬`. (비표준을 표준으로 고친 것, 조선일보 사전 치환은 둔다.) +7. **이동**: 같은 말이 한 곳에서 지워지고 다른 곳에 생긴 쌍 — 두 번호를 함께 낸다. +8. **표기 사전 치환의 문맥 오적용**: 목록에는 조선일보 표기 사전의 기계 치환(예: `대통령실`→`청와대`, `환경부`→`기후에너지환경부`, `슈퍼`→`수퍼`)도 섞여 있다. 사전 치환은 조선일보 표기 규칙이므로 두되, **과거 시점의 당시 이름**(예: 윤석열 정부 '대통령실', 문재인 정부 '환경부 블랙리스트'), **이미 치환된 이름에 또 붙은 것**(`기후에너지기후에너지환경부`), **방송·작품·상품의 고유 제목**(예: '슈퍼맨이 돌아왔다') 안이면 되돌린다. +인용문 밖 일반 낱말의 맞춤법·조사·어미 교정이 애매하면 두어라. 따옴표 안 말투 정리·고유명사·뜻 있는 말 삭제가 애매하면 원문 쪽(되돌림)을 택한다. 되돌릴 번호는 보통 목록의 15% 이하이고 0개도 흔하다. + +## (B) 기사 전체에서 새로 고칠 것 — `<본문>`(FT 교정본)을 처음부터 끝까지 훑으며 아래 표적을 하나씩 확인한다 +**FT 가 고친 자리는 다시 고치지 않는다**: `` 의 「바뀐 뒤」 구간(되돌리지 않은 번호)은 FT 가 이미 판단한 자리다. 그 구간을 `original` 에 넣지 않는다. 그 자리가 틀렸다고 보면 (A)에서 되돌릴지만 정한다. +1. **기사 안 불일치**(inconsistency): 같은 인물·기관·사건의 이름·직함·숫자·날짜가 기사 안 다른 곳(제목·사진 설명 포함)과 어긋남. 더 여러 번 나오거나 풀네임·정식 명칭이 적힌 쪽에 맞춘다. +2. **이름·명칭·사실**(fact): 고유명사가 나올 때마다 글자를 확인한다. + - 외국 지명·인명·단체·제도의 표준 한글 표기에서 글자가 틀림. 예: `스칸디니비아`→`스칸디나비아`, `베네주엘라`→`베네수엘라`. + - 행사·시상식·작품·법률·국제기구의 공식 명칭에서 글자가 빠지거나 바뀜. 예: `국제통화기구(IMF)`→`국제통화기금(IMF)`. + - 약어와 풀이가 서로 바뀌었거나 짝이 어긋남. 예: `국민총소득(GDP)`→`국민총소득(GNI)`. + - 널리 알려진 인물 이름의 글자 순서·한 글자 오기, 역사적 사건의 연도. 예: `한국전쟁이 일어난 1953년`→`1950년`. + 단, 회사·상점·시설·상품의 상호는 외래어 표기법과 달라도 공식 표기일 수 있으니 둔다. 최근 인사·직책·수치는 네 지식이 낡았을 수 있으니 기사 안 근거가 없으면 둔다. +3. **검산**(calc): 기사 안 숫자끼리 이어지는 값을 실제로 계산한다 — 비율·증감률·합계·차이, 두 비율의 차(%포인트), 금액·단위 환산의 자릿수(10배·100배 어긋남), 날짜와 요일, 기사 안 두 시점으로 정해지는 기간·나이. `A에서 B로 늘었다`처럼 비교하는 두 값의 단위가 다르면(명↔%) 기사 안의 맞는 값으로 맞춘다. 계산은 두 번 해서 같을 때만 고치고, 기사 밖 기준 시점(작성일·'올해')에 기대는 값은 고치지 않는다. +4. **문맥상 틀린 낱말**(word): 동음이의어·비슷한 소리·한자어·동사 혼동으로 문맥에 안 맞는 낱말. 문장마다 서술어가 주어·목적어와 뜻이 맞는지 본다. 예: `결재`↔`결제`, `한참`↔`한창`, `지양`↔`지향`, `정답을 맞추다`→`맞히다`, `대체제`→`대체재`. +5. **남은 규범 오류**(grammar): FT 가 놓친 조사·어미·호응·비문·맞춤법 오류 가운데 **틀렸다고 단정할 수 있는 것**. 숫자·영문 뒤 조사는 읽는 소리에 맞춘다(예: `코드 9을`→`9를`). 한두 글자 오타로 보이면 문장을 다시 쓰지 말고 뜻에 가장 가까운 형태로 최소한만 고친다. +고치지 말 것: 띄어쓰기만의 차이, 따옴표 방향(뒤 규칙이 맡는다), 문체·어순·표현 취향, 인용문 안 말투, 문단·줄바꿈, **`evidence` 에 기사 안 다른 곳·계산·확실한 상식 중 하나로 근거를 댈 수 없는 숫자·이름·낱말 변경**. + +## 출력 — JSON 한 객체만, 다른 말·코드펜스 없이 +{"revert_why": {"<번호>": "숫자|고유명사|인용|삭제|추가|외래어|이동|사전"}, "revert": [<번호>...], "corrections": [{"original": "<본문에 글자 그대로 있는 2~30자 구간>", "corrected": "<그 구간을 고친 것>", "category": "inconsistency|fact|calc|word|grammar", "evidence": "<근거 한 줄>"}]} +- `revert` 의 번호는 `revert_why` 에 사유를 적은 번호와 같다. `` 에 있는 번호만 쓴다. +- `original` 은 `<본문>` 에서 그대로 복사하고, 본문에서 한 번만 나오도록 앞뒤 글자를 넣는다. `original` 과 `corrected` 는 달라야 한다. 최대 12개. +- 둘 다 없으면 {"revert_why": {}, "revert": [], "corrections": []}. diff --git a/assets/prompts/prompt_exp_261021_ft_hybrid/review_sp4.txt b/assets/prompts/prompt_exp_261021_ft_hybrid/review_sp4.txt new file mode 100644 index 0000000000000000000000000000000000000000..5abc849c6937b4b58415a2a5151733c3e4d30076 --- /dev/null +++ b/assets/prompts/prompt_exp_261021_ft_hybrid/review_sp4.txt @@ -0,0 +1,38 @@ +너는 신문 교열 데스크다. 1차 교열 모델(FT)이 기사를 이미 고쳤다. FT 는 맞춤법·오탈자·조사·어미·비문 교정을 아주 잘한다. 너는 아래 두 일만 한다. 본문을 다시 쓰지 않는다. +(A) `` 에서 FT 가 사고를 낸 번호만 골라 되돌린다. **FT 변경은 기본으로 모두 유지한다.** +(B) `<본문>` 에서 기사 안에 근거가 글자로 있는 오류만 찾아 고친다. + +## (A) 되돌림 절차 — 목록의 각 번호마다 1→5 순서로 확인한다 +목록 한 줄은 `#번호 …앞문맥「바뀌기 전」→「바뀐 뒤」뒷문맥…` 이다(∅=빈 문자열, ⏎=줄바꿈). +1. 줄바꿈(⏎)·문장부호·따옴표 모양·띄어쓰기만 바뀌었으면 → 유지. 다음 번호로. +2. 숫자 확인: 「바뀌기 전」과 「바뀐 뒤」의 아라비아 숫자를 차례로 적어 비교한다. 숫자가 다르면(예: `2025년`→`2022년`, `30곳`→`28곳`, `7번째`→`6번째`) → 되돌림(숫자). 단 다음은 유지: 숫자가 같고 단위 글자(만·억·조·원)만 정리됨, `19987년`→`1987년`처럼 겹친 자릿수 오타 정리, 비율 값이 바뀌었는데 기사 안 두 숫자로 직접 나눠 보니 「바뀐 뒤」 값이 나옴. +3. 이름 확인: 사람·기관·지명·회사·작품 이름이 바뀌었으면, `<본문>` 을 검색한다. + - 「바뀌기 전」 이름이 본문 다른 곳에 그대로 나오면 → 되돌림(고유명사). 예: 본문에 '파월'이 여러 번 있는데 한 곳이 `파월`→`파울`. + - 완전히 다른 이름(두 글자 이상 다름)이나 다른 직함으로 바뀌었거나, 이름·직함 순서가 바뀌었으면 → 되돌림(고유명사). + - 그 밖(한 글자 오타 정리, 외래어 표기 정리 `도날드`→`도널드`류, 「바뀐 뒤」 이름이 본문 다른 곳에 있음) → 유지. +4. 오탈자 확인: 「바뀌기 전」이 틀린 말이면 → **유지**. 틀린 말이란 사전에 없는 말·글자 빠짐·글자 겹침·틀린 조사·틀린 어미다. 예: `역활`→`역할`, `회사을`→`회사를`, `되요`→`돼요`, `하였읍니다`→`하였습니다`, `알맞는`→`알맞은`, 문맥상 다른 낱말 오타 `결재 대금`→`결제 대금`. 이런 것은 인용문 안이든 이름처럼 보이든 되돌리지 않는다. +5. 「바뀌기 전」이 맞는 말인데 FT 가 바꾼 경우만 아래를 본다. 하나에 **분명히** 맞을 때만 되돌린다. + - 인용: “ ” 나 ‘ ’ 안의 맞는 말을 다른 말·어미로 바꾸거나 지움. 예: “제가 한 걸로 압니다”의 `걸로`→`것으로`, “사람들이 많았죠”의 `들이`→`이`. → 되돌림(인용) + - 삭제: 뜻 있는 어절·구·문장·괄호 설명이 ∅ 이 됨. 예: `오는 15일`의 `오는`→∅, `현대차가`→∅. 바로 옆에 같은 말이 겹쳐 하나를 지운 것은 유지. → 되돌림(삭제) + - 추가·바꿔 쓰기: 원문에 없던 정보가 붙음, 또는 맞는 낱말이 뜻이 다른 낱말로 바뀜. 예: `지난주`→`이번 주`, `수출액이`→`수입액이`, `인건비를`→`HR 비용을`. 같은 뜻의 다른 표현(`이외`→`이 밖에`, `되어`→`돼`)은 유지. → 되돌림(추가) + - 이동: 같은 말이 한 번호에서 지워지고 다른 번호에서 생김 → 두 번호 모두 되돌림(이동). + - 사전: 표기 사전 기계 치환(`대통령실`→`청와대`, `환경부`→`기후에너지환경부`, `슈퍼`→`수퍼` 등)은 조선일보 규칙이라 유지한다. 단 과거 정부·과거 사건의 당시 이름(예: 지난 정부 '대통령실' 인사, 옛 부처 시절 사건명), 이미 치환된 이름에 또 붙음(`기후에너지기후에너지환경부`), 방송·노래·상품 고유 제목 안이면 → 되돌림(사전). +판단이 애매하면 유지한다. 되돌릴 번호는 기사 하나에 보통 0~2개다. 목록의 대부분은 4번(오탈자 정리)에 해당하니 유지해야 한다. + +## (B) 새 교정 — 아래 네 가지만, 근거가 `<본문>` 안에 글자로 있을 때만 +1. inconsistency(기사 안 불일치): 같은 인물·기관·사건의 이름·직함·숫자·날짜가 본문 두 곳에서 다르게 적힘. 더 여러 번 나오거나 풀네임이 적힌 쪽에 맞춰 적은 쪽을 고친다. evidence 에 두 곳의 글자를 모두 쓴다. +2. calc(검산): 본문의 두 숫자로 계산되는 비율·증감률·합계·차이가 틀림. 계산을 두 번 해서 같을 때만 고치고, evidence 에 식을 쓴다(예: "42÷350=12.0%"). 괄호 안 환산액, 'N년 만'처럼 오늘 날짜가 필요한 값은 고치지 않는다. +3. word(문맥 낱말): 비슷한 소리·한자어 혼동으로 문맥에 안 맞는 낱말. 예: 돈 내는 문맥의 `결재`→`결제`, `지양해야 할 목표`→`지향해야 할 목표`, `체제비`→`체재비`. +4. grammar(남은 오탈자): FT 가 놓친 오탈자·틀린 조사·틀린 어미 가운데 틀렸다고 단정할 수 있는 것. 예: `정부은`→`정부는`, `하겠읍니다`→`하겠습니다`. +하지 말 것: 네 지식만으로 이름·직함·숫자·연도·날짜를 바꾸기(기사 안 근거가 없으면 맞다고 본다), 기관·단체 이름 목록을 늘리기, 띄어쓰기, 문체·어순, 인용문 말투, FT 변경을 되돌리는 교정(되돌림은 (A)로만). + +### corrections 항목 쓰는 법 +- original: `<본문>` 에서 글자를 **그대로 복사**한다. `` 의 글자나 네가 기억하는 글자를 쓰지 않는다. 고칠 낱말과 앞뒤 한두 어절, 4~25자. 문장 전체를 넣지 않는다. 본문에서 한 번만 나오도록 한다. +- corrected: original 에서 틀린 글자만 바꾼 것. 나머지 글자는 그대로 둔다. +- 다 쓰고 확인: original 과 corrected 가 같으면 그 항목을 지운다. original 이 본문에 없으면 지운다. 최대 6개. 없으면 빈 배열이 정상이다. + +## 출력 — JSON 한 객체만, 다른 말·코드펜스 없이 +{"revert_why": {"<번호>": "숫자|고유명사|인용|삭제|추가|이동|사전"}, "revert": [<번호>...], "corrections": [{"original": "<본문 복사 구간>", "corrected": "<고친 구간>", "category": "inconsistency|calc|word|grammar", "evidence": "<근거 한 줄>"}]} +- `revert` 의 번호와 `revert_why` 의 번호는 같다. 목록에 있는 번호만 쓴다. +- 예: {"revert_why": {"3": "숫자"}, "revert": [3], "corrections": [{"original": "김민수 대표는 이날", "corrected": "김민준 대표는 이날", "category": "inconsistency", "evidence": "본문 다른 두 곳은 '김민준 대표'"}]} +- 둘 다 없으면 {"revert_why": {}, "revert": [], "corrections": []}. diff --git a/assets/resources/compound_whitelist.txt b/assets/resources/compound_whitelist.txt deleted file mode 100644 index 2bbb7988eeb87e08648054a999a64acd756e000d..0000000000000000000000000000000000000000 --- a/assets/resources/compound_whitelist.txt +++ /dev/null @@ -1,3136 +0,0 @@ -삼성전자 34 -상설특검 25 -때문이다 20 -국민의힘 19 -기록했다 15 -시중은행 15 -현대차그룹 14 -계획이다 14 -알려졌다 13 -나타났다 13 -흡혈박쥐 13 -우리나라 12 -싱가포르 12 -과정에서 12 -대상으로 11 -예정이다 11 -인터넷은행 11 -프로그램 10 -시작했다 10 -조국혁신당 10 -상황에서 9 -주장했다 8 -더불어민주당 8 -것이라고 7 -허리케인 7 -집계됐다 7 -알레르기 7 -증가했다 7 -국정감사 7 -분석이다 7 -상황이다 7 -상대적으로 7 -대통령실 7 -위험운전치사상 7 -노벨문학상 7 -문학평론가 7 -수수료율 7 -서울에서 6 -스마트폰 6 -투자자들 6 -중심으로 6 -만들었다 6 -발표했다 6 -연금저축 6 -미디어텍 6 -적극적으로 6 -여론조사 6 -토스증권 6 -레지던스 6 -파드리스 6 -캔자스시티 6 -인도법인 6 -영업이익 5 -지적했다 5 -만들어진 5 -사이에서 5 -전망이다 5 -시내버스 5 -플레이오프 5 -지금까지 5 -원천징수 5 -일각에선 5 -와일드카드 5 -포스트시즌 5 -파리바게뜨 5 -어포스트로피 5 -한성도서본 5 -이어졌다 4 -문씨에게 4 -버튼으로 4 -보도했다 4 -이해하지 4 -상태에서 4 -하이브리드 4 -기준으로 4 -설명했다 4 -대회에서 4 -내년부터 4 -진행했다 4 -콜레오스 4 -방식이다 4 -골프클럽 4 -조사됐다 4 -허사비스 4 -시가총액 4 -이끌었다 4 -추진하고 4 -유튜버들 4 -국내에서 4 -늘어나고 4 -신에너지 4 -난양이공대 4 -전문가들 4 -알츠하이머병 4 -차지했다 4 -공동주택 4 -사전점검 4 -영광군수 4 -커뮤니티 4 -영등포로터리 4 -디자이너 4 -넷플릭스 4 -내셔널리그 4 -아메리칸리그 4 -타이거스 4 -네트워크 4 -카카오게임즈 4 -배드민턴 4 -알테오젠 4 -무너졌다 4 -비트코인 4 -옥스퍼드대 4 -서울대병원 4 -부산대병원 4 -시범경기 4 -레이커스 4 -무엇보다 3 -마감했다 3 -모건스탠리 3 -이태원동 3 -가능하다 3 -업계에서 3 -터치스크린 3 -제공하고 3 -실리콘밸리 3 -스타트업 3 -플로리다주 3 -기자회견 3 -중요하다고 3 -이스라엘 3 -디스커버리 3 -검토한다 3 -방식으로 3 -입장에서 3 -프로축구 3 -경기에서 3 -벗어나지 3 -르노코리아 3 -설문조사 3 -공개했다 3 -판단했다 3 -홈페이지 3 -활용하고 3 -예상된다 3 -주식시장 3 -한국에서 3 -딥마인드 3 -창업했다 3 -알파폴드 3 -청약통장 3 -시작으로 3 -들어봤다 3 -전해졌다 3 -징검다리 3 -유통업계 3 -전국에서 3 -검토하고 3 -미국에서 3 -그러면서 3 -적용된다 3 -제기했다 3 -수사요구안 3 -정치권에선 3 -원내대표 3 -필요하다고 3 -규정하고 3 -내부에서 3 -아이디어 3 -집행유예 3 -많을수록 3 -직장인들 3 -처음이다 3 -계속해서 3 -순이었다 3 -영광에서 3 -호우특보 3 -늘어났다 3 -명태균씨 3 -공개하지 3 -이어지고 3 -문제였다 3 -손해배상 3 -마이너스 3 -현대건설 3 -샌디에이고 3 -야마모토 3 -요시노부 3 -에르난데스 3 -선발투수 3 -무실점으로 3 -역전승했다 3 -클리블랜드 3 -가디언스 3 -디트로이트 3 -이승만대통령기념관 3 -시정명령 3 -집행정지 3 -블룸버그 3 -시장에서 3 -시장으로 3 -수준이다 3 -최고인민회의 3 -주식투자자금 3 -원점으로 3 -한국시리즈 3 -날카로운 3 -모습으로 3 -한국문학번역원 3 -문학평론 3 -시중은행들 3 -인터넷은행들 3 -그렇다고 3 -라이프치히 3 -오리올스 3 -과학자들 3 -기요사키 3 -것이었다 3 -영어식으로 3 -만리장성 3 -장편소설 3 -확인됐다 3 -중앙서림본 3 -대학생들 3 -스티로폼 3 -방침이다 2 -개발하고 2 -배터리팩 2 -지속적으로 2 -발전시켜 2 -처음으로 2 -조정했다 2 -공시했다 2 -목표주가 2 -유진투자증권 2 -이어지며 2 -생각하지 2 -음주운전 2 -파악됐다 2 -체납으로 2 -차량으로 2 -추가됐다 2 -특징이다 2 -가전제품 2 -제품에서 2 -폴크스바겐 2 -내놓으며 2 -유지하기 2 -일반적으로 2 -도입된다 2 -도로에서 2 -조사에서 2 -정도라고 2 -걱정해야 2 -못지않게 2 -플로리다 2 -힐즈버러 2 -유세에서 2 -주민에게 2 -끈질기게 2 -주도하고 2 -브렌트유 2 -과도하다 2 -분석했다 2 -출시했다 2 -버스중앙차로 2 -지나치게 2 -대상이다 2 -이상으로 2 -떨어졌다 2 -포르투갈 2 -투입했다 2 -뽑아내며 2 -뛰어올랐다 2 -올잉글랜드 2 -팬들에게 2 -드러나기 2 -방향으로 2 -선보였다 2 -구매하고 2 -분석하고 2 -헌법재판관 2 -진행되지 2 -공개됐다 2 -화이트큐브 2 -하루하루 2 -뉴욕에서 2 -전국적으로 2 -시작하던 2 -필요하다 2 -예년보다 2 -많았는데 2 -자본시장 2 -폐지까지 2 -유예한다고 2 -게임에서 2 -태어났다 2 -등극했다 2 -캠브리지 2 -스튜디오 2 -당첨되기 2 -문화체육관광부 2 -성희롱으로 2 -노벨의학상 2 -노벨위원회 2 -결정적인 2 -연구자들 2 -밝혀졌다 2 -조선일보 2 -스마트인컴 2 -공개된다 2 -업로드된다 2 -연금수령 2 -필명으로 2 -기준금리 2 -진입했다 2 -스냅드래곤 2 -채택했다 2 -홍명보호 2 -터뜨렸다 2 -에레디비시 2 -이어지면서 2 -경쟁에서 2 -보이면서 2 -대형마트 2 -북적였다 2 -소비자들 2 -분위기다 2 -본격적으로 2 -접어들자 2 -프랜차이즈 2 -급증했다 2 -주력하고 2 -과세당국 2 -적용하면 2 -해외에서 2 -기획재정부 2 -이용자들 2 -성냥갑들 2 -때문이라 2 -자원순환형 2 -분야에서 2 -순위에서 2 -진행한다 2 -플라스틱 2 -수소전기차 2 -공동으로 2 -투자하고 2 -증시에서 2 -사모펀드 2 -제출했다 2 -결정했다 2 -보도자료 2 -보관하던 2 -규명하자며 2 -특검법안 2 -증언감정법 2 -상설특검으로 2 -수사하게 2 -재발의할 2 -확보하려 2 -처리한다 2 -무력화하 2 -이끌어낼 2 -장담하기 2 -상황에선 2 -노림수로 2 -사법리스크 2 -잡히면서 2 -제기되고 2 -민주당으로선 2 -결집하고 2 -끌어올려야 2 -반복되며 2 -상설특검법 2 -판단하거나 2 -발의했다 2 -법원행정처 2 -대한변협회장 2 -추천하도록 2 -발의했던 2 -명시했던 2 -거론된다 2 -맞닥뜨릴 2 -유지하고 2 -연구개발 2 -사건에서 2 -연구위원 2 -전략으로 2 -강조했다 2 -기반으로 2 -지원하고 2 -치열해지면서 2 -급증하고 2 -엔지니어 2 -구축하고 2 -기록했는데 2 -해당한다 2 -터바인홀 2 -프로젝트 2 -품질관리단 2 -원칙적으로 2 -대행업체 2 -건설사들 2 -못하도록 2 -정상회의 2 -세계에서 2 -국제사회 2 -총선에서 2 -리얼미터 2 -선거에서 2 -영광으로 2 -돌아다니며 2 -사이에선 2 -당선되든 2 -졸업했다 2 -집중호우 2 -폭발적으로 2 -쏟아내기 2 -늘어나지 2 -내다봤다 2 -개혁신당 2 -과시하고 2 -국회의원 2 -보궐선거 2 -입장이다 2 -정치인들 2 -들어갔다 2 -만들어졌다 2 -않았다고 2 -시도하거나 2 -두부김치 2 -교통사고 2 -고가차도 2 -제품으로 2 -인테리어 2 -국내에선 2 -중요하게 2 -여러모로 2 -전화번호 2 -압도적인 2 -명씨에게 2 -차근차근 2 -했습니다 2 -리더라면 2 -법원에서 2 -예정이라고 2 -파라시스 2 -하락했다 2 -한국거래소 2 -중소형주 2 -프로배구 2 -벗어났다 2 -득점으로 2 -바뀌었다 2 -시즌부터 2 -정규리그 2 -페어플레 2 -메이저리그 2 -안방에서 2 -제압했다 2 -순간이었다 2 -뒤집었다 2 -다르빗슈 2 -필라델피아 2 -브루어스 2 -대한민국 2 -엔씨소프트 2 -기록하며 2 -이어갔다 2 -알고리즘 2 -업계에선 2 -지난해엔 2 -논의하기 2 -예고했다 2 -국무위원장 2 -공개적으로 2 -증권투자자금 2 -남편이나 2 -휴대전화 2 -프로야구 2 -수원에서 2 -적시타로 2 -엘리에이저 2 -아시아나항공 2 -가리킨다 2 -집중했다 2 -대한배드민턴협회 2 -천위페이 2 -작품이라 2 -평가했다 2 -수준으로 2 -대중문화 2 -신용대출 2 -주택담보대출 2 -천연가스 2 -분데스리가 2 -민주주의 2 -시작된다 2 -미세전류 2 -챔피언십 2 -월드시리즈 2 -카를로스 2 -에스테베즈 2 -레드삭스 2 -터뜨리며 2 -관점에서 2 -돌아섰다 2 -드라큘라 2 -인터내셔널 2 -맨부커상 2 -메디치상 2 -말레이시아 2 -브루나이 2 -세계적인 2 -투자해야 2 -증가한다 2 -높아지고 2 -친아버지 2 -공부해야 2 -하이트진로 2 -뿌리쳤다 2 -우승하고 2 -드라이브샷 2 -지키느냐 2 -해결했다 2 -잡아당겨 2 -평균자책점 2 -가져왔다 2 -가르시아 2 -샌프란시스코 2 -정규시즌 2 -애틀랜타 2 -독일어정서법위원회 2 -흔들도록 2 -시달렸다 2 -니코바르 2 -유능하게 2 -설치했다 2 -당선되며 2 -이상문학상 2 -광주민주화운동 2 -문장으로 2 -트라우마 2 -비서실장 2 -부산에서 2 -행동강령 2 -성공했다 2 -실망스러운 2 -비교하면 2 -윤전식으로 2 -국가유산으로 2 -북한연구소 2 -탈북민들 2 -총참모부 2 -플랫폼들 2 -쿠팡이츠 2 -아미노산들 2 -아미노산 2 -개발했다 2 -이산화탄소 2 -혁신기업 2 -평균기온 2 -떨어지지 2 -최고기온 2 -집행위원장 2 -확대하고 1 -안심점검 1 -무상보증기간 1 -법인차로 1 -전기차엔 1 -통보되지 1 -등록하면 1 -진입하기 1 -발생하면 1 -투입된다 1 -소방청에선 1 -시작하고 1 -특수구조대 1 -기증한다 1 -수압으로 1 -안심하고 1 -대표주인 1 -신한투자증권 1 -해당되지 1 -소외되고 1 -순매도했지 1 -순매수했다 1 -고대역폭 1 -그레이드 1 -증권사들 1 -집계됐다고 1 -하회했다 1 -현대차증권 1 -발표에서 1 -지켜지지 1 -비메모리 1 -비용으로 1 -연말까지 1 -근접하며 1 -하회하고 1 -동기보다 1 -상승하도록 1 -초입이라 1 -맞추면서 1 -빡빡해질 1 -예상했지 1 -스마트폰용 1 -기대하기 1 -어려워졌다고 1 -추격이다 1 -성공하든 1 -실패하든 1 -엔비디아 1 -통과하면 1 -유의미한 1 -떨어지게 1 -상태였던 1 -모델이다 1 -개인적으로 1 -사용하기 1 -자동차등록원부 1 -소유였던 1 -위반으로 1 -제주에서 1 -양도했다 1 -명의였다 1 -이전됐고 1 -문씨에서 1 -대통령으로 1 -이전됐다 1 -이면도로 1 -돌아왔다 1 -해밀톤호텔 1 -운전하던 1 -변경하다 1 -뒤따라오던 1 -피해자인 1 -혈중알코올농도 1 -수준이었다 1 -카메라용 1 -쓸어주면 1 -추가했는데 1 -사라졌던 1 -돌아오고 1 -되돌아가고 1 -애플마저 1 -가져오고 1 -기사에서 1 -쓸어넘기던 1 -디스플레이 1 -장착했던 1 -등장하고 1 -주목받고 1 -도입하겠다고 1 -차종에서 1 -회귀했다 1 -올리거나 1 -헤리티지 1 -브레이크 1 -추가되고 1 -선보이며 1 -추가했다 1 -저장해둘 1 -스톱워치 1 -단축키로 1 -장점이다 1 -넘기게끔 1 -감지되고 1 -주시해야 1 -화면에서 1 -프로그램인 1 -받으려면 1 -책임자인 1 -에이버리 1 -더타임스와 1 -제조업체 1 -터치스크린으로 1 -산만으로 1 -마찬가지다 1 -가스레인지 1 -동일하게 1 -흘러넘치면 1 -작동하지 1 -트렌드와 1 -맥락이다 1 -태블릿용 1 -진동으로 1 -탑재하고 1 -가르치며 1 -천수답이어서 1 -생소했기 1 -설명하자 1 -그제서야 1 -끄덕였다 1 -지적됐다 1 -못한다고 1 -코미디같 1 -벌어진다 1 -설문에서 1 -대답했다 1 -걱정하기 1 -질문이나 1 -이해할까 1 -지경이라고 1 -파악한다 1 -알아듣고 1 -끄덕인다 1 -한자어이기 1 -이해력인 1 -멀리하고 1 -읽으라고 1 -추상적인 1 -모르고선 1 -언어철학자인 1 -넓어진다 1 -세상으로 1 -사망했다고 1 -저기압으로 1 -약화됐지 1 -밀턴으로 1 -늘었다고 1 -나뭇가지 1 -추정된다 1 -구출했다 1 -탬파에서 1 -구조단체 1 -구조했다 1 -구조대원 1 -떨어졌고 1 -약화됐다 1 -지역에서 1 -대서양으로 1 -빠져나갔다 1 -담화에서 1 -불안하게 1 -무모하고 1 -무책임하며 1 -비판했다 1 -퍼뜨리고 1 -비난하면서 1 -뉴스레터 1 -구독하기 1 -나오면서 1 -급등했다 1 -서부텍사스산 1 -거래일보다 1 -전날보다 1 -보복으로 1 -나서면서 1 -부추겼다 1 -지오바니 1 -스타우노보 1 -애널리스트 1 -정리하면서 1 -산유국인 1 -재규어랜드로버코리아 1 -인제니움 1 -표방한다 1 -리클라인 1 -엇비슷하지 1 -활용한다 1 -수납공간 1 -오프로드 1 -강점이다 1 -서스펜션 1 -편안하게 1 -노면에서 1 -수심까지 1 -안전하게 1 -판매된다 1 -개편한다 1 -준공영제 1 -도입하면서 1 -재설계해 1 -서울시버스운송사업조합 1 -노선체계 1 -발주했다 1 -시행한다 1 -신설됐는데 1 -그대로라 1 -위례신사선 1 -개통하고 1 -연장된다 1 -이곳저곳 1 -통과하다 1 -구불구불한 1 -노선으로 1 -고덕강일지구 1 -그었는데 1 -편리해질 1 -예상한다 1 -서울시내 1 -바뀌었는데 1 -반영하지 1 -백지에서 1 -효율적이라고 1 -강등됐다 1 -영입한다 1 -코린치안스 1 -출신으로 1 -리그에서 1 -끌어올렸다 1 -구단이라 1 -넉넉하지 1 -스트라이커 1 -득점포로 1 -코리아컵 1 -올려놓았다 1 -대구에서 1 -레전드다 1 -어렵겠지 1 -세징야에겐 1 -튀르키예 1 -전전하다 1 -활약하고 1 -여전하다 1 -무대였다 1 -허용하면서 1 -절체절명 1 -활용해보겠다 1 -파고들어 1 -정확하고 1 -재역전승 1 -만끽했다 1 -자동으로 1 -떨어지고 1 -어떻게든 1 -매진시키며 1 -우리에게 1 -행복하게 1 -결승전이란 1 -대회부터 1 -시행한다고 1 -카메라로 1 -입체적으로 1 -재구성하 1 -중반부터 1 -익숙하다 1 -극대화할 1 -대회까지 1 -고수했다 1 -제한적으로 1 -활용했다 1 -예선부터 1 -결승까지 1 -대신한다 1 -심판협회 1 -언제까지 1 -사용됐다 1 -오픈에선 1 -운영했다 1 -코트에서 1 -설치되어 1 -참고용으로 1 -진화하고 1 -리그에선 1 -시스템으로 1 -오프사이드 1 -잡아내고 1 -리플레이 1 -홈런이나 1 -판독에서 1 -올해부터 1 -서비스센터 1 -개최하고 1 -만족했다고 1 -응답했고 1 -응답했다 1 -차량에서 1 -만족스러운 1 -기능으로 1 -조성하기 1 -탑재했다 1 -감지하고 1 -뛰어나고 1 -우수하다 1 -수준이고 1 -구간에서 1 -고속도로나 1 -주말마다 1 -방송통신위원장 1 -마무리하면서 1 -헌재소장 1 -선출해야 1 -대립으로 1 -출석해야 1 -열겠다고 1 -밝히면서 1 -헌법재판소법 1 -알아봐야 1 -선출하지 1 -검토해보라 1 -출신이다 1 -작업했던 1 -마르세유 1 -흩뿌리고 1 -작품이다 1 -시리즈로 1 -생각으로 1 -전시된다 1 -발행된다 1 -오브리스트 1 -서펜타인 1 -예술감독 1 -진행했던 1 -차별이라고 1 -제한하지 1 -권고했다 1 -아내에게 1 -사주려고 1 -남성에게 1 -거부했다고 1 -제기했고 1 -가입자격 1 -명시하고 1 -탓이라고 1 -제한한다 1 -여성용으로 1 -부족하다고 1 -설립됐던 1 -다수였음 1 -고려하더라 1 -추진되면 1 -가정주부 1 -따가움으로 1 -알레르기성 1 -주범으로 1 -분포하며 1 -주변이나 1 -산책하면 1 -심해진다 1 -시즌마다 1 -환자이기 1 -긴장하며 1 -시달리고 1 -하순까지 1 -삼성서울병원 1 -알레르기내 1 -건조해지고 1 -선선해지기 1 -건강보험심사평가원 1 -추산된다 1 -아열대성 1 -변하면서 1 -선선하고 1 -과민반응으로 1 -재채기나 1 -건조해지기 1 -지속되면서 1 -요인으로 1 -작용하면서 1 -많아졌다 1 -예상되니 1 -질병관리청 1 -질병이나 1 -쯔쯔가무시병 1 -길어지므로 1 -가을까지 1 -풀밭이나 1 -서식한다 1 -생겼으면 1 -고려대구로병원 1 -쌀쌀해지면서 1 -밀접해지면서 1 -유행하는데 1 -유행하던 1 -만성질환자들 1 -금융투자소득세 1 -유예해야 1 -것이었지 1 -고려하다 1 -유예까지 1 -시행하자 1 -유예하고 1 -선진지수 1 -편입이라든지 1 -판단하자 1 -유예하자 1 -정상화될 1 -않는다면 1 -마찬가지라 1 -일각에서 1 -갑론을박하기보다 1 -폐지하자 1 -유예한다면 1 -집권해서 1 -시행해야 1 -예정이던 1 -미뤄졌고 1 -의총에서 1 -위임하기로 1 -기울어졌는데 1 -무너뜨릴 1 -신중해야 1 -부담스럽다 1 -키프로스계 1 -그리스인 1 -싱가포르인 1 -시작했고 1 -보드게임 1 -뛰어나다 1 -상금으로 1 -구입하고 1 -프로그래밍 1 -대학에서 1 -컴퓨터과학 1 -유니버시티 1 -신경과학 1 -박사학위 1 -기억상실증 1 -사이언스지 1 -두뇌게임 1 -올림피아드인 1 -올림피아드 1 -런던에서 1 -무스타파 1 -슐레이만 1 -스타트업인 1 -인수했지 1 -위험처럼 1 -무주택으로 1 -강남권에선 1 -커트라인 1 -가점보다 1 -리얼투데이 1 -만점이며 1 -부양가족 1 -산정된다 1 -웃돌았다 1 -공공기관 1 -기관장에게 1 -근절되지 1 -한국도박문제예방치유원 1 -드러났다 1 -문화체육관광위원회 1 -해임됐다 1 -명목으로 1 -법률자문 1 -국감에서 1 -카지노공기업인 1 -지적되었음 1 -가능하더라 1 -해임되었는데 1 -세포에서 1 -돌아갔다 1 -이뤄졌다 1 -카롤린스카 1 -생리의학상 1 -수상자로 1 -생물학자인 1 -매사추세츠 1 -비밀열쇠로 1 -발견하여 1 -기여했기 1 -수상하게 1 -수상자인 1 -앰브로스 1 -미생물인 1 -예쁜꼬마선충 1 -발견했다 1 -비정상적으로 1 -작동되면 1 -자가면역 1 -발견으로 1 -이해하게 1 -조절이라 1 -물리학상 1 -발표되며 1 -경제학상으로 1 -전업투자자다 1 -강연에선 1 -불합리한 1 -주식으로 1 -무엇인지 1 -들어보자 1 -화요일인 1 -퇴직급여 1 -연금으로 1 -소득세법상 1 -연금수령액 1 -퇴직소득세율 1 -과세하고 1 -넘어가면서부터 1 -과세한다 1 -유리한지 1 -미래에셋 1 -투자와연금센터 1 -부와지식의배움터 1 -영상이다 1 -내다보면서 1 -움직여서 1 -프리즘투자자문 1 -단행하면서 1 -본격적인 1 -자산시장 1 -유망할지 1 -국민연금공단 1 -기금운용본부 1 -투자운용팀장 1 -국민은행 1 -수석이코노미스트 1 -전문가다 1 -신공정으로 1 -모바일용 1 -출시되고 1 -아이폰용 1 -끌어올린 1 -탑재되면서 1 -저전력으로 1 -중요해졌다 1 -강조하고 1 -전력효율 1 -향상됐다 1 -약점으로 1 -거론되던 1 -강화됐다 1 -전망된다 1 -제외하고 1 -안드로이드 1 -스마트폰들 1 -향상되면서 1 -떨어지면 1 -고객사들 1 -들어가면서 1 -좋아지고 1 -고부가가치 1 -집중하면서 1 -월가에서 1 -동갑내기로 1 -국가대표팀 1 -네덜란드 1 -세르비아 1 -즈베즈다 1 -페예노르트로 1 -챔피언스리그 1 -미드필더로 1 -맹활약하며 1 -분데스리 1 -프랑크푸르트 1 -측면에서 1 -오른발로 1 -마무리해 1 -주니오르 1 -허용하며 1 -미드필더 1 -부상으로 1 -빠지면서 1 -소속팀으로 1 -파트너로 1 -수비수로 1 -이뤘는데 1 -대표팀엔 1 -포함됐다 1 -입성했다 1 -중동에서 1 -요르단으로 1 -합류한다 1 -개천절인 1 -만석이었다 1 -손님으로 1 -근처에서 1 -쇼핑객들로 1 -신선식품 1 -계산대마다 1 -기다렸다 1 -비수기로 1 -공휴일로 1 -지정되고 1 -대형마트마다 1 -북적였고 1 -여행지로 1 -매진됐다 1 -살아나지 1 -진작하기 1 -임시공휴일 1 -소비시장 1 -회복될지 1 -유통업체에선 1 -품목에서 1 -떨어지면서 1 -장만하려 1 -롯데백화점 1 -해외여행 1 -덕분이라 1 -신세계백화점 1 -늘어났다고 1 -즉석식품 1 -돼지고기 1 -여행객들로 1 -신제주점 1 -서귀포점 1 -기간보다 1 -불꽃축제 1 -몰리면서 1 -서울세계불꽃축제 1 -한강공원 1 -놀이공원 1 -할인행사 1 -매장에서 1 -진행하고 1 -롯데마트 1 -샤인머스캣 1 -저렴하게 1 -먹거리들 1 -개최한다 1 -내야하니 1 -유튜브로 1 -발라냈다 1 -제출하지 1 -경고까지 1 -보냈다고 1 -시청자들 1 -수익에서 1 -기여분으로 1 -금액만큼 1 -입장에선 1 -세무당국 1 -법인이라 1 -징수하기 1 -실질적으로 1 -한국재무관리학회 1 -동등하게 1 -과세해야 1 -국세까지 1 -논의조차 1 -원천징수하기 1 -개정하면서 1 -정책협력 1 -발생하고 1 -납부하게 1 -조세조약 1 -따져보겠다 1 -입장이었다 1 -들어줬다 1 -시청자들로 1 -가능해야 1 -구글코리아 1 -도널드슨 1 -전무하다 1 -소득세법 1 -규정하지 1 -규정하더라 1 -원천소득 1 -이행하지 1 -가속화되면서 1 -국세청으로 1 -종합소득세 1 -벌어들인 1 -시청시간 1 -다방이나 1 -가져가게 1 -가져오다 1 -빼곡하게 1 -습관이었다 1 -취미생활이라고 1 -유행이었어요 1 -수집했다 1 -자연스럽게 1 -됐습니다 1 -이후라고 1 -기억했다 1 -다채로운 1 -떠들썩하게 1 -마시거나 1 -고형필씨 1 -사용하거나 1 -사람에게 1 -싸움까지 1 -회고했다 1 -식당이나 1 -열광하고 1 -아날로그 1 -현대자동차그룹 1 -난양대학 1 -정부까지 1 -비즈니스 1 -체결했다고 1 -공과대학 1 -연구중심 1 -대학이다 1 -발전사업 1 -쓰레기나 1 -모빌리티 1 -밸류체인 1 -설립하기로 1 -통상산업부 1 -조직으로 1 -연구기관 1 -로보틱스 1 -혁신제조 1 -국가연구펀드 1 -다가왔지 1 -불투명해 1 -최하위권 1 -금투세란 1 -금융상품 1 -양도소득 1 -도입되면 1 -어려워질 1 -빠져나가면서 1 -한국기업거버넌스포럼 1 -결과적으로 1 -빠져나갈 1 -관측하고 1 -부자감세 1 -투자자들에게 1 -희망이자 1 -금투세로 1 -금융소득종합과세로 1 -시행으로 1 -낮아진다 1 -법제사법위원회 1 -증인으로 1 -아트센터 1 -동아시아문화센터 1 -요구하기로 1 -법사위원장인 1 -소재파악 1 -동행명령장 1 -방안까지 1 -아내이자 1 -떠올랐다 1 -약속어음 1 -제시하며 1 -인정된다면서 1 -부인하고 1 -국면으로 1 -양상이다 1 -농협중앙회 1 -적발되자 1 -주식거래 1 -적발됐지 1 -이뤄지지 1 -살펴보고 1 -발의하자 1 -처리했지 1 -가로막혔다 1 -끌어올리려 1 -공직선거법 1 -다가오면서 1 -압박하기 1 -재발의했던 1 -것들이다 1 -의결하면 1 -제정하지 1 -소속되거나 1 -소속됐던 1 -국회규칙 1 -구성해야 1 -입맛대로 1 -임명하기 1 -바꾸겠다 1 -느슨해진 1 -결과라고 1 -내부적으로 1 -심화했다 1 -공공연히 1 -자랑이었지 1 -옅어지고 1 -유일하게 1 -우선순위고 1 -제조업인 1 -청년에게 1 -학과까지 1 -전자공학 1 -전공자들 1 -학교에서 1 -동결되면서 1 -따라잡겠다고 1 -존재하지 1 -최상층부 1 -장악하고 1 -투자보다 1 -바뀌었고 1 -임직원들 1 -해결할지 1 -쏟았는데 1 -잘못인지 1 -색출해내서 1 -이기주의 1 -털어놨다 1 -삼성그룹 1 -출석하고 1 -요구했고 1 -부랴부랴 1 -시스템반도체 1 -경쟁사인 1 -자극했고 1 -느슨해졌다 1 -과도하게 1 -무임승차하며 1 -경쟁자인 1 -시가총액으로 1 -넘어섰다 1 -창업자인 1 -친분으로 1 -파운드리 1 -전념했다 1 -빼닮았다 1 -재계에서 1 -정신으로 1 -추월하고 1 -중요하다 1 -한림대학교 1 -동탄성심병원 1 -대표적인 1 -평가하며 1 -인지기능과 1 -인지기능 1 -맥락정보 1 -혈관질환 1 -신체활동 1 -집단보다 1 -유전자인 1 -벌어졌다 1 -한림대동탄성심병원 1 -정신건강의학 1 -촉진하고 1 -긍정적인 1 -에디스코완대학 1 -사람보다 1 -아밀로이드 1 -목적에서 1 -응답자들 1 -연령별로 1 -다가올수록 1 -기혼자들보다 1 -대비해야 1 -결과에선 1 -직장인보다 1 -저축하고 1 -체계적인 1 -미친다고 1 -단기적인 1 -집중해서 1 -지적이다 1 -납입한다 1 -세액공제 1 -중국으로 1 -확보하기 1 -중요합니다 1 -기술적인 1 -오픈소스 1 -본사에서 1 -부사장으로 1 -근무했고 1 -마이크로소프트 1 -과학자로 1 -선도하고 1 -오픈소스로 1 -혁신적인 1 -프랑스뿐 1 -출신들로 1 -전쟁이다 1 -충분하지 1 -조직에게 1 -치열해지고 1 -효율적인 1 -아키텍처 1 -자원으로 1 -개발해야 1 -제공자들과 1 -파트너십 1 -유럽으로 1 -확대해나간다 1 -경제에서 1 -매출에서 1 -차지한다 1 -수출에서 1 -두드러진다 1 -반도체고 1 -생활가전 1 -살림살이 1 -좌우한다 1 -납부했다 1 -유지해왔다 1 -공중에서 1 -나풀거리고 1 -회전한다 1 -공간으로 1 -재탄생했다 1 -현대미술관 1 -작가이자 1 -부르주아 1 -올라푸르 1 -엘리아손 1 -아이웨이웨 1 -내로라하 1 -현대미술 1 -거쳐갔다 1 -현대자동차 1 -지원으로 1 -느껴지지 1 -않았느냐 1 -질문인데 1 -무서워하기 1 -아름다움 1 -공존한다 1 -바꾸면서 1 -떨어뜨린다 1 -발전소였던 1 -주목했다 1 -사용하지 1 -재가동했고 1 -현장으로 1 -탈바꿈시켰다 1 -재가동해 1 -장소였다 1 -소개했다 1 -끌어왔다 1 -조각으로 1 -매달리게 1 -추가되면서 1 -들어가기 1 -횡포라고 1 -상처에서 1 -무기력함 1 -아름답다 1 -감동한다 1 -움직인다 1 -역경이나 1 -아름답다고 1 -들어서며 1 -공간이다 1 -큐레이터 1 -부드러움 1 -감각적으로 1 -경험하게 1 -예방하기 1 -손질해야 1 -국토교통부 1 -하자심사분쟁조정위원회 1 -육박했다 1 -증가하면서 1 -처리해야 1 -실시해야 1 -전문가로 1 -품질점검단 1 -점검해야 1 -작년부터 1 -입주예정자 1 -동행하지 1 -금지했다 1 -사전방문 1 -명시하기로 1 -담합한다 1 -제기됐다 1 -진행하면 1 -입주예정자들 1 -실시하는데 1 -변경했다 1 -사전방문일 1 -주간별로 1 -균등하게 1 -전산시스템 1 -일방적으로 1 -개선해야 1 -지자체마다 1 -천차만별이었다 1 -강화해야 1 -방문하고 1 -참석하기 1 -출국했다 1 -방문국인 1 -참배하고 1 -맺어왔으며 1 -최초이자 1 -자유대한민국 1 -순방에서 1 -용인하지 1 -겨냥하기 1 -모니터링 1 -형성되어 1 -양상으로 1 -흘러가고 1 -예상됐지 1 -여론조사상 1 -나타나면서 1 -정가에서 1 -예측하기 1 -결정으로 1 -통합진보당 1 -후신으로 1 -위성정당 1 -확보했다 1 -네거티브 1 -야권에선 1 -진보당식 1 -남도일보 1 -지지한다 1 -조사에선 1 -일주일여 1 -네거티브전 1 -벌이면서 1 -난타전식 1 -진보당으로 1 -학도호국단 1 -총학생장 1 -공격했고 1 -집회시위법 1 -부각되지 1 -밀어내고 1 -당선됐던 1 -자극했는데 1 -조직에서 1 -당선무효형 1 -확정되자 1 -읍내에선 1 -들어주고 1 -부축해주 1 -영광군농민회 1 -시절부터 1 -집집마다 1 -논밭까지 1 -요구하며 1 -포퓰리즘 1 -경쟁으로 1 -영광사랑지원금 1 -공약으로 1 -판박이로 1 -영광군노인복지관 1 -얻어놓고 1 -내려갔고 1 -상임대표 1 -최연소다 1 -주주총회 1 -대표이사 1 -선임했다 1 -제품총괄로 1 -카네기멜론대 1 -엔비욘드 1 -이베이코리아 1 -비금융권 1 -회사에서 1 -해외주식 1 -추진하며 1 -플랫폼으로서 1 -대표이사로 1 -물러났다 1 -기간으로 1 -한정하면 1 -가속화할수록 1 -시간동안 1 -비구름대 1 -수증기량 1 -토해내듯 1 -속출했다 1 -장마기간 1 -많아지며 1 -편성하고 1 -운영하고 1 -복기조로 1 -운영된다 1 -이어지겠다 1 -쌀쌀하고 1 -보이겠다 1 -이어지겠다고 1 -경남지역 1 -브로커로 1 -술렁이고 1 -비대위원장 1 -얘기하면서 1 -정치자금법 1 -창원지검 1 -구속하면 1 -하야하고 1 -감당되겠느냐고 1 -검사에게 1 -응하면서 1 -곤혹스러워하 1 -정치브로커 1 -국민께서 1 -한심하게 1 -생각하실 1 -수사기관 1 -엄정하고 1 -신속하게 1 -수사해야 1 -자택으로 1 -조언으로 1 -텔레그램 1 -나타나고 1 -부적절한 1 -부정확한 1 -설명했는데 1 -당사자들 1 -반박했다 1 -만나자고 1 -페이스북 1 -빅테크들 1 -파악해야 1 -유럽연합 1 -대항하기 1 -공개하도록 1 -회계연도 1 -종료되면 1 -공개해야 1 -당국끼리 1 -공유하던 1 -국제협약인 1 -이행하기 1 -국가별보고서 1 -도입했고 1 -정보교환 1 -교환하고 1 -빅테크로 1 -가공해서 1 -국가에서 1 -유럽처럼 1 -추진되고 1 -공개하기 1 -선행돼야 1 -새벽까지 1 -서울경찰청 1 -현장에서 1 -없었으며 1 -거부하지 1 -부축하려 1 -포착되기 1 -들여다보고 1 -불가능한 1 -이전까지 1 -차선에서 1 -난폭운전 1 -접수하고 1 -주문했다 1 -꾸벅꾸벅 1 -철거된다 1 -개통했다 1 -교차로로 1 -도로교통공단 1 -불가피할 1 -철거하고 1 -조성한다 1 -연결한다 1 -여의도나 1 -도심으로 1 -출퇴근하 1 -반도체뿐 1 -밀어내기 1 -지지부진한 1 -선두주자 1 -실적으로 1 -감소했는데 1 -영업이익으로 1 -제외하면 1 -적자였다 1 -영향이다 1 -수조원대 1 -체결했다 1 -따내면서 1 -포트폴리오 1 -다변화로 1 -편광필름 1 -양도했고 1 -구조조정 1 -최대치였지 1 -해운에서 1 -영향으로 1 -분석됐다 1 -길어지고 1 -비상경영 1 -돌입했지 1 -미나토구 1 -펜트하우스이자 1 -일본에서 1 -레지던스라 1 -들어섰는데 1 -화룡점정 1 -디자인했다 1 -스카닝으로 1 -가구업체 1 -꾸몄다고 1 -옷장까지 1 -디자이너들 1 -고심해서 1 -소개하고 1 -갤러리스트 1 -컨설턴트 1 -건축가나 1 -이어진다 1 -갤러리에선 1 -건설업체 1 -투자회사들 1 -현상까지 1 -벌어지고 1 -조연이었다 1 -먼저였고 1 -이때부터 1 -시작됐다 1 -간결하면서 1 -초부터다 1 -대중적으로 1 -디자이너로 1 -앤트워프 1 -관능적으로 1 -윈트레베르트 1 -유기적인 1 -각광받고 1 -어퍼하우스 1 -르페이드 1 -나인원한남 1 -실내가구 1 -배치하느냐 1 -달라진다 1 -조사기관 1 -할리팩스 1 -걸렸는데 1 -구조만큼 1 -뉴욕이나 1 -레지던스일수록 1 -지역민보단 1 -외국인들 1 -귀띔했다 1 -아자다부힐스 1 -중동이나 1 -유럽에서 1 -외국인들이고 1 -고객이라 1 -핏플레이스 1 -만족시킬 1 -차별적인 1 -필요하고 1 -디자인한 1 -설명이다 1 -퍼니처로 1 -레지던스라고 1 -하이엔드 1 -오피스텔 1 -자금난으로 1 -가라앉지 1 -여전하기 1 -신중하게 1 -따져보고 1 -유출됐다 1 -착수한다고 1 -인터뷰하면서 1 -비상대책위원장 1 -실시했다면서 1 -책임당원 1 -미래한국연구소 1 -비교했고 1 -가상대결 1 -실시했으며 1 -사무총장 1 -최고위원 1 -회의에서 1 -만들어서 1 -적법하게 1 -흘러갔는지 1 -넘어갔는지 1 -조사하겠다 1 -안심번호 1 -캠프에서 1 -관련해서 1 -뚜렷해야 1 -시간가량 1 -결론입니다 1 -서바이벌 1 -미션에서 1 -승리하며 1 -끌었습니다 1 -해산물로 1 -싹쓸이하며 1 -셰프에게 1 -물었더니 1 -방송에서 1 -익숙하면서 1 -만들어야겠다 1 -주저하지 1 -뚜렷했기 1 -리더에게 1 -다양합니다 1 -들었습니다 1 -아름다웠다면 1 -중요하지 1 -좋습니다 1 -염두해둔 1 -안타깝지 1 -아닐까요 1 -도로교통법상 1 -특정범죄가중처벌법상 1 -혐의까지 1 -처하도록 1 -용산경찰서 1 -운전하다 1 -판단되면 1 -가능하다고 1 -식당에서 1 -지속됐기 1 -적용되면 1 -붉어지고 1 -비틀거리며 1 -주행하다 1 -오토바이 1 -발생했다 1 -위험치사상 1 -기소했고 1 -선고했다 1 -제조사인 1 -메르세데스벤츠 1 -수입사인 1 -메르세데스벤츠코리아 1 -한성자동차 1 -벤츠파이낸셜서비스코리아 1 -서울중앙지법 1 -제출했다고 1 -공정거래위원회 1 -포함되지 1 -소유자들 1 -주장하고 1 -탑재됐지 1 -크리스토프 1 -스타진스키 1 -장착된다 1 -알았다면 1 -부담해야 1 -손해보다 1 -배상하게 1 -소외됐다 1 -단적으로 1 -엠에프엠코리아 1 -현대사료 1 -퀄리타스반도체 1 -큐라티스 1 -삼성증권 1 -반도체주 1 -이어가고 1 -통영체육관 1 -맞붙었다 1 -후위에서 1 -스파이크 1 -바깥으로 1 -벤치에서 1 -뛰어올랐던 1 -나갔다고 1 -운용하고 1 -인정하고 1 -선수에게 1 -주어진다 1 -불필요한 1 -경기에선 1 -여자부에선 1 -조별리그 1 -페퍼저축은행 1 -주인공이었다 1 -네이션스 1 -도입했다 1 -지급한다 1 -보상으로 1 -페어플레이 1 -유도하자 1 -페어플레이상 1 -반영한다 1 -반영하기로 1 -금전적으로 1 -유인으로 1 -작용하지 1 -홍보팀장 1 -정착하고 1 -홈런포로 1 -앞장섰다 1 -기회에서 1 -내야플라 1 -가라앉았던 1 -받아쳤다 1 -홈런이었다 1 -에이드리언 1 -부러졌지 1 -테오스카 1 -미쳤으나 1 -불펜투수 1 -집어삼킬 1 -끌려가다 1 -무안타로 1 -침묵했다 1 -기부하기로 1 -약정했다고 1 -출생으로 1 -졸업하고 1 -미국으로 1 -상장시켰다 1 -한미상호방위조약 1 -대국으로 1 -마련했기 1 -참여했고 1 -조성됐다고 1 -출시했다고 1 -오세아니아 1 -실시했다 1 -야심작으로 1 -출시됐다 1 -크래프톤 1 -진행한다고 1 -기부그라운드 1 -배틀그라운드 1 -즐기면서 1 -공정거래위원회로 1 -서울고법 1 -공정거래법 1 -부과했다 1 -조작으로 1 -부당하게 1 -소송했고 1 -회복하기 1 -기각했다 1 -성격이기 1 -정당한지 1 -최종적인 1 -재판에서 1 -내려진다 1 -유력하다 1 -기업공개 1 -마무리될 1 -인도증권거래위원회 1 -예상대로 1 -상장하면 1 -판매했다 1 -장부가액 1 -삭제하면서 1 -최고인민회 1 -시정연설 1 -지시했었다 1 -파기하고 1 -개성공단 1 -남북기본합의서 1 -잠정적으로 1 -깎아내렸는데 1 -제기된다 1 -해상국경선 1 -수역화하려 1 -관측한다 1 -구체적으로 1 -명시하기보다 1 -모호하게 1 -종료됐다 1 -노동신문 1 -한국은행 1 -불확실성 1 -지정학적 1 -미쳤다고 1 -확대되고 1 -중장기채권 1 -계속되면서 1 -순유입됐다 1 -순유출로 1 -선고받았다 1 -인천지법 1 -정보통신망 1 -이용촉진 1 -정보보호 1 -선고했다고 1 -사회봉사 1 -명령했다 1 -남자친구 1 -알려주고 1 -넘겨졌다 1 -제안으로 1 -모집하면 1 -관리했다 1 -애플리케이션 1 -개인정보 1 -입력하면 1 -들렀는지 1 -친구에게 1 -고소하기 1 -봉인했다 1 -연발하며 1 -자멸했다 1 -설욕하면서 1 -수비에서 1 -돋보였다 1 -호수비로 1 -같아졌다 1 -뼈아팠다 1 -이어가지 1 -활발하게 1 -공격했다 1 -더블스틸로 1 -성공하면서 1 -실패하면서 1 -상황이면 1 -역전했다 1 -희생번트 1 -때렸는데 1 -서두르던 1 -빠뜨렸다 1 -밟으면서 1 -기울어지 1 -마무리투수 1 -내야안타 1 -몰렸으나 1 -뛰어오르면서 1 -대등했다 1 -성공했고 1 -희생플라이로 1 -수비에선 1 -부진했다 1 -구원투수로 1 -인상한다 1 -부담하지 1 -비행시간 1 -인상된다 1 -오르면서 1 -대한항공 1 -제주항공 1 -노선별로 1 -인상했다 1 -파리올림픽 1 -금메달리스트 1 -복귀했다 1 -쏟아지자 1 -밀양에서 1 -전국체육대회 1 -예선에서 1 -부산광역시 1 -인천광역시 1 -모습이었다 1 -많았다고 1 -진정시킨 1 -사랑해준 1 -감사하다 1 -불참하고 1 -연호하며 1 -유도하며 1 -마무리했다 1 -관중에게 1 -화답했다 1 -감사했고 1 -그들에게 1 -조금이라 1 -표현하고 1 -기다렸던 1 -만족스럽다 1 -병행하며 1 -좋아졌다 1 -국제대회 1 -보여드리겠다 1 -이뤘으니 1 -비판하면서 1 -이뤄지고 1 -이뤄지는지 1 -않았었고 1 -발표하고 1 -출전하지 1 -본모습으로 1 -않을까라고 1 -생각하고 1 -덧붙였다 1 -머물렀던 1 -늦게나마 1 -아쉬웠는데 1 -지역적인 1 -못했다면 1 -인식하게 1 -음식까지 1 -한국으로 1 -쏠리면서 1 -주목하지 1 -소설인데 1 -섬세하고 1 -겪어왔던 1 -정치적인 1 -형상화해온 1 -근원적인 1 -작가로서 1 -억눌려왔던 1 -번역가들 1 -차원에서 1 -제안했다 1 -보내느냐 1 -이야기해왔지 1 -한몫했다 1 -축적돼온 1 -양성하고 1 -국제무대 1 -세계문학 1 -되어가고 1 -재평가되며 1 -역사까지 1 -심층적으로 1 -들여다보며 1 -나라인지 1 -인터넷전문은행보다 1 -취급하고 1 -전업주부 1 -분류된다 1 -포용하지 1 -못한다면 1 -거둬들이고 1 -포용하자 1 -내세우지 1 -인터넷은행에 1 -떠넘겨버린다 1 -인터넷전문은행 1 -자산으로 1 -인터넷은행보다 1 -가계대출 1 -인터넷은행에선 1 -인터넷은행으로 1 -달려가고 1 -팍팍해진 1 -중저신용자들 1 -중저신용대출 1 -취약계층 1 -밀려나고 1 -방증이다 1 -넘어서기 1 -금융당국 1 -관리하고 1 -인터넷은행만 1 -이자이익 1 -당기순이익 1 -고민해야 1 -마무리할 1 -산업통상자원부 1 -개발권인 1 -부여하고 1 -특별수당 1 -해저광물자원 1 -입법예고한다고 1 -개정하기로 1 -생산하면 1 -조광료로 1 -거두도록 1 -제도에서 1 -늘어날수록 1 -급증하면 1 -신설된다 1 -공식적으로 1 -광구에서 1 -발견하면 1 -계약에서 1 -넘어서면 1 -관리자로 1 -복귀한다 1 -관장하고 1 -전략적인 1 -잉글랜드 1 -감독으로 1 -물러나자 1 -관장하며 1 -바이에른 1 -소유하고 1 -바뀌겠지 1 -유망주들 1 -발전시키고 1 -반발하고 1 -쏟아부어 1 -팬들에겐 1 -분데스리가에선 1 -말이라고 1 -강조하며 1 -몸담았던 1 -도르트문트 1 -비아냥거리고 1 -끌어내려야 1 -강화군수 1 -심각하다면 1 -도중에라 1 -끌어내리 1 -민주주의고 1 -발언이었지 1 -만들겠다고 1 -권좌에서 1 -구성하고 1 -의원회관 1 -정면으로 1 -반헌법적 1 -반발했다 1 -아니었다고 1 -피감기관 1 -예고하고 1 -키즈카페 1 -생각나서 1 -찾아보니 1 -씁쓸했다 1 -어르신들 1 -경로당엔 1 -장기요양인정등급 1 -어린이집 1 -감소했다 1 -서울시교육청 1 -실버타운 1 -실버타운으로 1 -셔틀버스로 1 -데려오고 1 -데려다준다 1 -비슷하다 1 -유치원장 1 -노치원으로 1 -편이라고 1 -시도하고 1 -학습지로 1 -배달하고 1 -단지에서 1 -기부채납 1 -제시하자 1 -소유주들 1 -접어들어 1 -분명하다 1 -선진국에선 1 -시설이기 1 -늘려가야 1 -최근부터 1 -내려가면서 1 -심해지면 1 -멜라토닌 1 -빌려보고 1 -사용해봤다 1 -의료기기 1 -삼성메디슨 1 -연구소장 1 -제품이다 1 -슬리피솔 1 -거슬리지 1 -이곳으로 1 -전달한다 1 -스트레스 1 -비약물적 1 -치료법이라고 1 -서울대학교 1 -분당병원 1 -사용했더니 1 -호전됐다고 1 -답했다고 1 -설치하고 1 -블루투스로 1 -기본이다 1 -스마트기기 1 -분석해주고 1 -적응하기 1 -어려웠으나 1 -시티필드 1 -지목됐던 1 -프란시스코 1 -투입하자 1 -직선으로 1 -넘겨버렸다 1 -올라가야 1 -감격했다 1 -뛰어들었다 1 -볼티모어 1 -팀이었다 1 -막판까지 1 -기사회생한 1 -모양새다 1 -극적으로 1 -확정하더니 1 -원정경기로 1 -가려진다 1 -달아났다 1 -홈런으로 1 -유린했다 1 -예고됐다 1 -외나무다리 1 -승부에서 1 -등판한다 1 -가까스로 1 -가라앉아 1 -고민이다 1 -신약개발업체인 1 -알테오젠인 1 -거래되던 1 -면역항암제 1 -적용됐다 1 -작용했다 1 -견제하기 1 -통과되면 1 -메리츠증권 1 -아모레퍼시픽 1 -한화에어로스페이스 1 -신한지주 1 -삼성생명 1 -대장주인 1 -순매도하면서 1 -순매도로 1 -하락하면서 1 -국제회의 1 -열었어요 1 -회의에선 1 -기후변화로 1 -악마화되고 1 -포유동물 1 -종류뿐이고 1 -부풀려졌다 1 -아담해요 1 -아르헨티나 1 -분포하죠 1 -중남미로 1 -발견하고 1 -떠올려서 1 -펄럭이며 1 -내려앉아 1 -모습이요 1 -야행성이긴 1 -다르답니다 1 -저공비행 1 -점프하거나 1 -올라가요 1 -박쥐보다 1 -능숙하게 1 -흘러나오 1 -혓바닥으로 1 -오목하게 1 -들이마시기 1 -구조예요 1 -무게까지 1 -있답니다 1 -빵빵해진 1 -널브러져 1 -발견되죠 1 -만들어내 1 -자기에게 1 -일어났는지 1 -잔답니다 1 -물렸다고 1 -이리저리 1 -다니면서 1 -만들어요 1 -구성원들끼리 1 -게워주기 1 -있거든요 1 -먹인답니다 1 -영토에서 1 -가와바타 1 -배출했고 1 -배출했다 1 -등장했지 1 -국제사회에서 1 -중론이었다 1 -수상하면서 1 -성장하고 1 -글로벌하게 1 -공동수상한 1 -문학은커녕 1 -선진국인 1 -통화에서 1 -확실하다 1 -페인트칠 1 -탐미주의자 1 -효과적으로 1 -이탈리아 1 -말라파르테 1 -한국문학 1 -접근하고 1 -작동하고 1 -간과돼서 1 -진출한다 1 -이에따라 1 -늘어나게 1 -맺었다고 1 -베이커리 1 -거점으로 1 -증가하고 1 -계약으로 1 -동남아시아에서 1 -진출하게 1 -발언으로 1 -일으킨다 1 -집중시키기 1 -송도에서 1 -시장이다 1 -파산이다 1 -실버게이트은행 1 -위기에서 1 -인수되며 1 -크레디트스위스 1 -위험하기 1 -생각한다 1 -고민하자 1 -하락한다 1 -지날수록 1 -올라간다 1 -메트갈프 1 -법칙이란 1 -메트칼프 1 -직선적으로 1 -기하급수적으로 1 -법칙이다 1 -가속화될수록 1 -화폐보다 1 -암호화폐 1 -트럼프나 1 -스탠퍼드대 1 -노스웨스턴 1 -안정적인 1 -윤택해진다고 1 -시작하면서 1 -일반적인 1 -봉급으로 1 -아버지인 1 -월급으로 1 -투자하기 1 -키워나간 1 -붕괴였다 1 -이전보다 1 -두려워할 1 -나타난다 1 -부족하다 1 -레이스란 1 -벗어나려면 1 -사회에서 1 -기다리던 1 -페어웨이 1 -작성했다 1 -블루헤런 1 -잡아내며 1 -허용했으나 1 -한국여자오픈 1 -문턱까지 1 -모르겠다 1 -우승하고자 1 -임하는데 1 -싶었는데 1 -지나가길 1 -장타자이면서 1 -아이언샷 1 -빼어나다 1 -좋아하지 1 -어려워서 1 -조건이니 1 -있었다고 1 -초반부터 1 -훈련해오면서 1 -노력했다 1 -흔들리지 1 -단단하게 1 -올라섰다 1 -제주삼다수 1 -마스터스 1 -번뿐이지 1 -상금에서 1 -모습이다 1 -자리했다 1 -따라왔다 1 -합작했고 1 -야구보다 1 -생각했는데 1 -들어맞으면서 1 -통과하고 1 -장소에서 1 -쿠에바스 1 -선발투수로 1 -슬라이더 1 -신고했다 1 -부응했다 1 -시도했다 1 -기대했던 1 -정규리그에선 1 -이날까지 1 -부진하다 1 -아웃카운트 1 -물러나기 1 -모두에게 1 -절실했다 1 -딘이었다 1 -대결에서 1 -환호했다 1 -장거리포 1 -등판했다 1 -쿠에바스보다 1 -초반까지 1 -플라이로 1 -추격했다 1 -공략하지 1 -도전하게 1 -재무제표 1 -적극적이지 1 -기업분석연구소 1 -리더스인덱스 1 -추진하면서 1 -독려하고 1 -한정됐다 1 -기업별로 1 -배당했다 1 -장군멍군 1 -일으켰던 1 -양키스타디움 1 -원정경기 1 -되갚았다 1 -선두타자로 1 -살바도르 1 -터뜨렸고 1 -성공하자 1 -처리하며 1 -타석에선 1 -종횡무진했다 1 -카우프만필드 1 -자이언츠 1 -원정으로 1 -볼티모어로 1 -뉴욕으로 1 -제압하며 1 -에마누엘 1 -침묵시켰다 1 -기록하면서 1 -마무리로 1 -이어지면 1 -내놓으면서 1 -세계적으로 1 -문법까지 1 -내버려두 1 -규정이라 1 -공영방송 1 -독일어로 1 -표기한다 1 -고유명사 1 -허용하겠다 1 -공식적인 1 -위원회인 1 -공용언어로 1 -독일인들 1 -익숙하게 1 -내렸다고 1 -전통이었지 1 -간판에서 1 -비판하고 1 -내버려두고 1 -평론에서 1 -머리카락 1 -사람이라면 1 -프랑프푸르트 1 -일간지인 1 -중간중간 1 -어색하게 1 -끼워넣으며 1 -불가피하다고 1 -베를린자유대학 1 -언어학자 1 -스테파노비치 1 -프랑스어 1 -부임했다 1 -떨어졌던 1 -들려줬다 1 -등장했고 1 -캐럴이라 1 -출간한다 1 -셰익스피어 1 -다음으로 1 -탄생이었다 1 -등장시킨다 1 -삽화에서 1 -표현됐다 1 -베스트셀러 1 -애니메이션으로 1 -만들어지면서 1 -유명해졌지 1 -멸종했기 1 -모리셔스에 1 -서식했다 1 -모리셔스 1 -도착하면서 1 -지구에서 1 -사라졌다 1 -멸종시키고 1 -뒤뚱거리면서 1 -못했으며 1 -멍청하고 1 -기록으로 1 -포르투갈어로 1 -의미에서 1 -유래했다 1 -국제학술지 1 -있었으며 1 -살펴보자 1 -벌채하면서 1 -멍청해서 1 -월드처럼 1 -소망이었다 1 -산타크루즈 1 -교수이자 1 -생명공학 1 -바이오사이언스 1 -최고과학책임자 1 -줄기세포 1 -코펜하겐 1 -표본에서 1 -얻었다고 1 -엇갈린다 1 -공통조상 1 -진화하기 1 -도도인지 1 -자신한다 1 -태즈메이니아 1 -흰코뿔소 1 -영화감독 1 -투자했다 1 -유명인들 1 -구성원으로 1 -굴종적인 1 -울산시장 1 -불구하고 1 -꼽으라면 1 -문제이지 1 -상식적인 1 -거스르고 1 -상쇄되고 1 -심각해졌다 1 -지지자들 1 -극단적인 1 -내로남불로 1 -콘크리트 1 -생활인들 1 -돌아서게 1 -최대치인 1 -걷어붙이고 1 -금융위원회 1 -정책실장 1 -경제수석 1 -틀어쥐고 1 -불러오긴 1 -가계부채 1 -환기시킬 1 -금융감독원장일 1 -아우성치 1 -장차관들 1 -뛰어들어 1 -끈질지게 1 -노력하고 1 -식당주들 1 -신음하고 1 -마련하려고 1 -클러스터 1 -초래할지 1 -송배전망 1 -해결되고 1 -추진하다 1 -맞추었거나 1 -삐끗했다간 1 -주도적으로 1 -당연하다 1 -사람이라 1 -두드러지 1 -들었다고 1 -정권에서 1 -분류되길 1 -현안이라 1 -말해준다 1 -상층부에서 1 -사라지면 1 -만무하다 1 -책임지고 1 -장벽으로 1 -아메넴헤트 1 -바빌로니아 1 -네부카드네자르 1 -토목공사라 1 -언제까지나 1 -막아주지 1 -방지용으로 1 -테러단체 1 -센서까지 1 -요르단강 1 -하드리아누스 1 -변질됐다 1 -구축했다 1 -서독으로 1 -넘으려다 1 -아프가니스탄 1 -봉쇄하고 1 -모양이다 1 -장벽이다 1 -콘트리트 1 -감옥으로 1 -로마인들 1 -가능하지 1 -광주광역시 1 -졸업하던 1 -시인으로 1 -등단했다 1 -이듬해인 1 -단편소설 1 -서울신문 1 -신춘문예 1 -소설가로 1 -데뷔했다 1 -작가이거나 1 -평론가인 1 -이름이라 1 -한동림씨 1 -소설가이고 1 -경희사이버대 1 -미디어문예창작 1 -문학평론가다 1 -문예창작과 1 -동리문학상 1 -황순원문학상 1 -받았는데 1 -한승원씨 1 -좋아했다 1 -고등학교 1 -쓰겠다며 1 -선택하더니 1 -시달리다 1 -거부하게 1 -되어간다고 1 -트라우마로 1 -시선에서 1 -연작소설 1 -베스트셀러다 1 -수상했다 1 -대중소설가이기보다 1 -전위소설가다 1 -처음부터 1 -다룸으로써 1 -주목받지 1 -허덕이면서 1 -순간부터 1 -축적해온 1 -일어나기 1 -이사했던 1 -시발점으로 1 -들어왔다 1 -주제의식 1 -작품으로 1 -무고하게 1 -강연에서 1 -들여다보면 1 -맹세이자 1 -궁극적인 1 -시선으로 1 -풀어냈다 1 -문학상인 1 -움베르토 1 -처음이었다 1 -의료진들 1 -않았는데 1 -보건복지위원회 1 -징계위원회 1 -상태이며 1 -인사위원회 1 -국민권익위원회 1 -조치이다 1 -특혜이며 1 -것이므로 1 -대상이라고 1 -처리했다 1 -서울대병원행 1 -수술하면 1 -문의하자 1 -수술받기 1 -의학적으로 1 -출동하도록 1 -명확하게 1 -재정비해야 1 -심장마비로 1 -복귀했고 1 -선발에서 1 -지명됐다 1 -내비쳤기 1 -현재까지 1 -서머리그 1 -정도라면 1 -미네소타 1 -팀버울브스 1 -맞대결에선 1 -역사에서 1 -기록됐다 1 -외면했다 1 -전문매체 1 -드래프트 1 -되어있지 1 -레이커스에선 1 -혹평했다 1 -선배이자 1 -브로니라면 1 -해달라고 1 -한국문화재보존과학회 1 -총판매소 1 -인문학술원 1 -객원교수 1 -자유교양학부 1 -반도체생산설비 1 -웨이퍼마스터 1 -대표이기 1 -소프트웨어 1 -비교했다 1 -겹쳐보면 1 -즈려밟고 1 -구절에서 1 -조판해서 1 -현상이라 1 -활판인쇄 1 -한쪽으로 1 -인쇄된다 1 -평압식으로 1 -미루어봤 1 -진달래꽃 1 -학계에서 1 -초간본으로 1 -첨예하게 1 -문학서지 1 -발견했다며 1 -같습니다 1 -동참해주길 1 -몬트리올 1 -인권단체 1 -개인사정으로 1 -화상연결로 1 -강제북송 1 -증언했다 1 -국제활동팀 1 -소속으로 1 -이야기했다 1 -놀라워했다고 1 -중국에서 1 -북송시킨 1 -사과하고 1 -밝혔다고 1 -익숙하지 1 -가르쳐주고 1 -김일혁씨 1 -탈북했다 1 -유엔본부 1 -안전보장이사회 1 -비판하기 1 -지속하도록 1 -한보이스 1 -규탄하고 1 -공동성명 1 -상원의원 1 -실현된다면 1 -영구적으로 1 -선언했다 1 -조선인민군 1 -끊어버리고 1 -방어축성물로 1 -요새화하 1 -전화통지문 1 -군사훈련 1 -핵전략자산 1 -열거하며 1 -이어오고 1 -물리적으로 1 -보여주기 1 -합동참모본부 1 -현상변경 1 -좌시하지 1 -사라지고 1 -공고해지면서 1 -구축하기 1 -활성화될 1 -대응하기 1 -집중한다 1 -발표하며 1 -제시했다 1 -저장하고 1 -이동시키 1 -전체에서 1 -판매하고 1 -확장하기로 1 -주력인데 1 -미드니켈 1 -중저가형 1 -식당에게 1 -수준까지 1 -완화하겠다 1 -통제하자 1 -배달업계 1 -유력하게 1 -영세업체일수록 1 -낮춰준다 1 -구체적인 1 -확정되지 1 -않았으나 1 -수수료율까지 1 -고려하고 1 -문제의식하 1 -출범했다 1 -좁혀지지 1 -규율해야 1 -소극적인 1 -강력하게 1 -제재해야 1 -취지였다 1 -상한제란 1 -지역에선 1 -적용하고 1 -도입한다 1 -신호였다 1 -전방위적 1 -독과점적 1 -식당으로 1 -이듬해엔 1 -정률제로 1 -전환했다 1 -반박한다 1 -우버이츠 1 -저스트잇 1 -치킨이나 1 -한정됐던 1 -다양해졌다 1 -넓어졌다 1 -과점하면서 1 -증가시킨 1 -진단한다 1 -감당하지 1 -부담해서 1 -발의했지 1 -무산됐다 1 -파견검사 1 -후보자추천위 1 -구성에서 1 -상설특검법상 1 -구성된다 1 -추천하게 1 -삼부토건 1 -도이치모터스 1 -주가조작 1 -빠졌는데 1 -병행하겠다 1 -해석됐다 1 -형태인데 1 -결합될지 1 -결정된다 1 -분석하면 1 -결합하면 1 -개발이나 1 -사용된다 1 -수석연구원 1 -분석한다 1 -불과했다 1 -예측했다 1 -데이비드 1 -워싱턴대 1 -개발했고 1 -나노소재 1 -생명과학부 1 -로제타폴드 1 -주도하며 1 -연구단계 1 -해결하겠다 1 -화학물질 1 -먹어치우 1 -발생하지 1 -메탄가스 1 -화학비료 1 -농식품부 1 -계획하고 1 -기여하고 1 -무선으로 1 -로봇청소기인 1 -빨아들인 1 -걸러내고 1 -면적이나 1 -유용하다 1 -고용노동부 1 -소셜벤처경연대회 1 -최우수상 1 -해양수산부 1 -인정받았다 1 -경찰인재개발원 1 -서투르게 1 -들려왔다 1 -읽으면서 1 -고쳐줬다 1 -소화하고 1 -활성화하면서 1 -만들어달라고 1 -요청했고 1 -이수한다 1 -안팎이다 1 -투입하고 1 -범죄자들 1 -일어난다 1 -베트남으로 1 -도주했다 1 -송환됐다 1 -도피했다고 1 -수강생들 1 -한국인들 1 -다낭경찰서 1 -공안이라고 1 -무서워하 1 -한국어로 1 -친밀감이라 1 -신청하게 1 -좋아하고 1 -방탄소년단 1 -담당자인 1 -단계에서 1 -습득력들 1 -현지에서 1 -극단적으로 1 -기상학적 1 -당겨졌다 1 -늦어졌다 1 -기상학적으로 1 -여름이고 1 -빼앗기고 1 -길어지면서 1 -올라가고 1 -길어지기 1 -독해지기 1 -상승으로 1 -오전부터 1 -치솟았던 1 -원인으로 1 -등장으로 1 -색깔이기 1 -역할까지 1 -맡아가며 1 -지휘했다 1 -부위원장 1 -연출하고 1 -한국예술종합학교 1 -개막작인 1 -재미있게 1 -작년까지 1 -내우외환 1 -진행됐나 1 -프로그래머 1 -부국제밖 1 -공석이다 1 -비현실적 1 -시작해서 1 -지나치다 1 -영화제인 1 -외면하느냐 1 -자본이라서 1 -쏟아졌을까 1 -성사됐다 1 -옥외광고물법 1 -구청에서 1 -받아오면 1 -영화제라 1 -가능했던 1 -계약이기 1 -내년대로 1 -전국체전 1 -옮기기로 1 -재미없으면 1 -관객에게 1 -외면당한다 1 -떠나가게 1 diff --git a/assets/resources/vocabulary_v2.csv b/assets/resources/vocabulary.csv similarity index 69% rename from assets/resources/vocabulary_v2.csv rename to assets/resources/vocabulary.csv index 7b4dc9a7f06023ffe753830015b37a6506891b40..2dbfd8c392d87a07ad19cda65f3c66e527dca6f0 100644 --- a/assets/resources/vocabulary_v2.csv +++ b/assets/resources/vocabulary.csv @@ -144,74 +144,4 @@ 12·12 계엄,12·3 계엄, 진척율,진척률, 갱신률,갱신율, -울란바토르,울란바타르,윗층,위층, -아랫층,아래층, -윗쪽,위쪽, -아랫쪽,아래쪽, -뒷쪽,뒤쪽, -갯수,개수, -촛점,초점, -댓가,대가, -싯가,시가, -헛점,허점, -잇점,이점, -숫가락,숟가락, -깍뚜기,깍두기, -수도물,수돗물, -존대말,존댓말, -인삿말,인사말, -혼자말,혼잣말, -노래말,노랫말, -최소값,최솟값, -절대값,절댓값, -등교길,등굣길, -하교길,하굣길, -만두국,만둣국, -순대국,순댓국, -북어국,북엇국, -뒷풀이,뒤풀이, -오랫만,오랜만, -오랜동안,오랫동안, -웬지,왠지, -왠만,웬만, -왠일,웬일, -왠걸,웬걸, -금새,금세, -어의없,어이없, -희안하,희한하, -곰곰히,곰곰이, -일일히,일일이, -틈틈히,틈틈이, -깨끗히,깨끗이, -몇일,며칠, -역활,역할, -할려고,하려고, -귀뜸,귀띔, -귀띰,귀띔, -발자욱,발자국, -눈꼽,눈곱, -곱배기,곱빼기, -덮히,덮이, -덮혀,덮여, -통채로,통째로, -하마트면,하마터면, -어물쩡,어물쩍, -웅큼,움큼, -일사분란,일사불란, -절대절명,절체절명, -홀홀단신,혈혈단신, -동거동락,동고동락, -성대묘사,성대모사, -뒤치닥거리,뒤치다꺼리, -안성마춤,안성맞춤, -되물림,대물림, -삼가하,삼가, -첼린지,챌린지, -리더쉽,리더십, -멤버쉽,멤버십, -파트너쉽,파트너십, -컨텐츠,콘텐츠, -메세지,메시지, -어플리케이션,애플리케이션, -초콜렛,초콜릿, -팜플렛,팸플릿, +울란바토르,울란바타르, \ No newline at end of file diff --git a/runner.py b/runner.py index 5d3c11992d2764d1fc249b1b99edc09cf3c47183..c7e47ecdaa2d821713278e45a34cc07eb6b383cc 100644 --- a/runner.py +++ b/runner.py @@ -1,20 +1,18 @@ -"""교열 구성(파이프라인 × 프롬프트 × 모델)을 텍스트 1건에 실행하는 프로그래매틱 러너. - -교열 로직을 재구현하지 않고 solar-eval 엔진을 그대로 import 한다 — 데모가 -보여주는 동작이 평가 run 과 바이트 단위로 같아야 하기 때문이다 (사본 로직은 -반드시 갈라진다). 로컬에서는 uv 워크스페이스의 solar_eval 을, HF Space 에서는 -`build_space.py` 가 벤더링한 사본을 쓴다 (sys.path 상 스크립트 옆이 먼저라 -Space 에서는 벤더 사본이 이긴다). - -**구성은 (pipeline, prompt, model) 한 묶음이다** — 모델만 갈아끼우는 선택지는 -두지 않는다. mini4 의 이득은 모델 교체가 아니라 파이프라인·프롬프트 구조 변경에서 -나왔고(step1 을 생성에서 판정으로, step2 를 어휘/문법으로 분할, 규칙 층 2개 추가), -옛 combo 프롬프트에 mini4 만 꽂으면 2026-09-16 에 이관 불가 판정을 받은 그 조합이 -된다. 그래서 세 축을 `Config` 하나로 묶고, 데모는 그 구성만 노출한다. - -config 자산(파이프라인 yaml·프롬프트·치환 사전·화이트리스트)은 - 1. `./assets/` — HF Space 레이아웃 (build_space.py 가 조립) - 2. `../03-evaluation/` — 레포 레이아웃 (로컬 개발) +"""고객 데모(HF Space) 교열 구성을 텍스트 1건에 실행하는 프로그래매틱 러너. + +교열 로직을 재구현하지 않고 solar-eval 엔진을 그대로 import 한다 - 데모가 보여주는 동작이 평가 run 과 +바이트 단위로 같아야 하기 때문이다(사본 로직은 반드시 갈라진다). 로컬에서는 uv 워크스페이스의 solar_eval 을, +HF Space 에서는 `build_space.py` 가 벤더링한 사본을 쓴다(sys.path 상 스크립트 옆이 먼저라 Space 에서는 벤더 +사본이 이긴다). + +구성은 (pipeline, prompt, model) 한 묶음이다. 2026-10-29 부터 FT 교열 + Opus 기사 검토 +(`pipeline_exp_261021_ft_hybrid`, 근거 `03-evaluation/insights/261021_ft_hybrid_paragraph_article.md`) +하나만 노출한다. 기사 검토 스텝은 `timely:` 모델이라 Timely 라우터를 거친다 - `UPSTAGE_API_KEY` 와 +`TIMELY_API_KEY` 가 둘 다 있어야 한다. + +config 자산(파이프라인 yaml·프롬프트·치환 사전)은 + 1. `./assets/` - HF Space 레이아웃 (build_space.py 가 조립) + 2. `../03-evaluation/` - 레포 레이아웃 (로컬 개발) 순서로 찾는다. """ @@ -32,70 +30,63 @@ import yaml @dataclass(frozen=True) class Config: - """데모가 고를 수 있는 한 구성. `base` 는 pipeline 이 extends 하는 파일(번들 대상).""" + """데모가 노출하는 한 구성. `assets` 는 Space 번들에 들어갈 config 파일(03-evaluation 기준 상대 경로).""" label: str pipeline: str prompt: str model: str note: str - base: str | None = None - - -#: 데모에 노출되는 구성. 지금은 하나뿐이지만 tuple 로 둔다 — `build_space.required_assets` -#: 가 이 목록에서 번들 자산을 유도하므로, 구성이 늘어도 조립 코드를 고칠 필요가 없다. -CONFIGS: tuple[Config, ...] = ( - Config( - label="solar-mini4 파이프라인", - pipeline="pipeline_dev_v44", # = v41 + document_chunking - prompt="prompt_dev_260917_vsplit", - model="solar-mini4", - note=( - "FT 교열 → 판정형 step1(5KB) → 문맥 교정 어휘/문법 2스텝 → 결정론 규칙 6층. " - "입력 3.3k 토큰." - ), - base="pipeline_dev_v40", + assets: tuple[str, ...] + + +CONFIG = Config( + label="FT 교열 + Opus 기사 검토", + pipeline="pipeline_exp_261021_ft_hybrid", + prompt="prompt_exp_261021_ft_hybrid", + model="solar-pro4", # 모델을 지정하지 않은 스텝용 기본값 - 이 구성의 LLM 스텝은 FT·Opus 를 직접 지정한다 + note=( + "FT 교열 모델이 문단 묶음마다 1차 교열 → Claude Opus 가 기사 전체를 한 번 보고 FT 오교정을 되돌리고 " + "사실·숫자·문맥 오류를 고침 → 따옴표·율/률·후처리 규칙." ), + assets=("resources/vocabulary.csv",), ) -#: 라벨 → Config -BY_LABEL: dict[str, Config] = {c.label: c for c in CONFIGS} -DEFAULT_LABEL = CONFIGS[0].label +#: 필요한 키. 기사 검토(Opus)가 Timely 를 거친다. +REQUIRED_KEYS = ("UPSTAGE_API_KEY", "TIMELY_API_KEY") -# 엔진의 StepCallback 과 같은 모양: (event, index, total, step_name, doc_chunk) -StepCallback = Callable[[str, int, int, str, tuple[int, int] | None], None] +# (event, index, total, step_name) - 스텝이 끝날 때마다 "done" 으로 불린다 +StepCallback = Callable[[str, int, int, str], None] -def find_config_dir(pipeline: str) -> Path: - """그 파이프라인 yaml 을 실제로 가진 자산 루트. Space 번들이 레포보다 먼저다.""" +def find_config_dir() -> Path: + """파이프라인 yaml 을 실제로 가진 자산 루트. Space 번들이 레포보다 먼저다.""" here = Path(__file__).resolve().parent for cand in (here / "assets", here.parent / "03-evaluation"): - if (cand / "pipelines" / f"{pipeline}.yaml").is_file(): + if (cand / "pipelines" / f"{CONFIG.pipeline}.yaml").is_file(): return cand raise FileNotFoundError( - f"config 자산을 찾을 수 없습니다 — {here}/assets 또는 ../03-evaluation 에 " - f"pipelines/{pipeline}.yaml 이 있어야 합니다 (build_space.py 참조)." + f"config 자산을 찾을 수 없습니다 - {here}/assets 또는 ../03-evaluation 에 " + f"pipelines/{CONFIG.pipeline}.yaml 이 있어야 합니다 (build_space.py 참조)." ) -def build_pipeline(label: str = DEFAULT_LABEL) -> Any: - """구성 하나의 파이프라인 인스턴스. 앱에서 구성별로 1회 만들어 재사용한다.""" - import solar_eval.pipelines.steps # noqa: F401 — STEP_REGISTRY 등록 +def build_pipeline() -> Any: + """파이프라인 인스턴스. 앱에서 1회 만들어 재사용한다.""" + import solar_eval.pipelines.steps # noqa: F401 - STEP_REGISTRY 등록 from solar_eval.core.dataset_loader import DatasetLoader from solar_eval.core.pipeline_compose import compose_pipeline from solar_eval.models.prompt_version import load_step_prompts from solar_eval.pipelines.registry import create_pipeline - cfg = BY_LABEL[label] - config_dir = find_config_dir(cfg.pipeline) + config_dir = find_config_dir() pipelines_dir = config_dir / "pipelines" def load_base(name: str) -> dict[str, Any]: return yaml.safe_load((pipelines_dir / f"{name}.yaml").read_text(encoding="utf-8")) - composed = compose_pipeline(load_base(cfg.pipeline), load_base=load_base) - prompts = load_step_prompts(config_dir / "prompts" / cfg.prompt) - + composed = compose_pipeline(load_base(CONFIG.pipeline), load_base=load_base) + prompts = load_step_prompts(config_dir / "prompts" / CONFIG.prompt) return create_pipeline( "multi_step", input_fields=["original"], @@ -111,47 +102,44 @@ def step_names(pipeline: Any) -> list[str]: return [step.name for step in pipeline.steps] -async def _run_async( - pipeline: Any, text: str, cfg: Config, on_step: StepCallback | None -) -> dict[str, Any]: +async def _run_async(pipeline: Any, text: str, on_step: StepCallback | None) -> dict[str, Any]: from solar_eval.models.sample import EvalSample + from solar_eval.providers import INFERENCE_ROUTES, RoutedProvider from solar_eval.providers.upstage import UpstageProvider + names = step_names(pipeline) + + def hook(name: str, _sample: Any, _ctx: Any) -> None: + if on_step is not None: + on_step("done", names.index(name) + 1, len(names), name) + sample = EvalSample(input={"original": text}) + provider = RoutedProvider(UpstageProvider(), {k: c() for k, c in INFERENCE_ROUTES.items()}) start = time.monotonic() - # 평가 run 과 같은 조건: temp 0.0, reasoning off (60초 지연 예산 제약) + # 평가 run 과 같은 조건: temp 0.0, reasoning off result = await pipeline.run( sample, prompts="", - provider=UpstageProvider(), - model=cfg.model, + provider=provider, + model=CONFIG.model, temperature=0.0, max_tokens=8000, reasoning_effort=None, - on_step=on_step, + on_step=hook, ) - elapsed = time.monotonic() - start return { "output": result.output, - "step_outputs": result.artifacts.get("step_outputs", {}), "usage": result.artifacts.get("usage", {}), - "elapsed_s": elapsed, - "pipeline_key": cfg.pipeline, - "prompt_key": cfg.prompt, - "model": cfg.model, - "config_label": cfg.label, + "elapsed_s": time.monotonic() - start, + "pipeline_key": CONFIG.pipeline, + "prompt_key": CONFIG.prompt, + "model": CONFIG.model, } -def run_proofread( - pipeline: Any, - text: str, - label: str = DEFAULT_LABEL, - on_step: StepCallback | None = None, -) -> dict[str, Any]: - """텍스트 1건 교열. {output, step_outputs, usage, elapsed_s, pipeline_key, prompt_key, model, config_label}. +def run_proofread(pipeline: Any, text: str, on_step: StepCallback | None = None) -> dict[str, Any]: + """텍스트 1건 교열. {output, usage, elapsed_s, pipeline_key, prompt_key, model}. - `pipeline` 은 같은 `label` 로 `build_pipeline` 한 인스턴스여야 한다 — 앱이 - 구성별로 캐시한다. `on_step` 은 스텝 시작/종료와 chunk 진행마다 불린다(UI 진행 표시). + `on_step` 은 스텝이 끝날 때마다 ("done", 몇 번째, 전체, 스텝 이름) 으로 불린다(UI 진행 표시). """ - return asyncio.run(_run_async(pipeline, text, BY_LABEL[label], on_step)) + return asyncio.run(_run_async(pipeline, text, on_step)) diff --git a/solar_eval/__pycache__/__init__.cpython-312.pyc b/solar_eval/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6d0fc20b2ad738a1c9858850c96d71cf3e34ac27 Binary files /dev/null and b/solar_eval/__pycache__/__init__.cpython-312.pyc differ diff --git a/solar_eval/__pycache__/_version.cpython-312.pyc b/solar_eval/__pycache__/_version.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f3bb926029111c94bed30862c41f001b161e2b1f Binary files /dev/null and b/solar_eval/__pycache__/_version.cpython-312.pyc differ diff --git a/solar_eval/__pycache__/config.cpython-312.pyc b/solar_eval/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..79ae30c775e7a65788b4fd84d103f129ad5e4990 Binary files /dev/null and b/solar_eval/__pycache__/config.cpython-312.pyc differ diff --git a/solar_eval/cli/commands/compare.py b/solar_eval/cli/commands/compare.py index 4b2f18916454094ad48c1670be0ba002911c2618..09c5d4e7d4204d4c993f9d975fca9557ff4ad748 100644 --- a/solar_eval/cli/commands/compare.py +++ b/solar_eval/cli/commands/compare.py @@ -1,42 +1,105 @@ -"""Compare runs side by side.""" +"""Compare runs side by side. + +비교 대상 run 들의 행 선택(`run.json` `dataset.path`·`selection`·`rows_selected`)이 서로 다르면 각 run 의 +선택을 헤더에 싣고 "다른 행 집합의 점수다" 경고를 낸다(계획 2026-09-24-phase1a D19) — `--where` 슬라이스 +run 과 전체셋 run 의 점수가 같은 표에서 조용히 나란히 서지 않게. quick-run 이 직후에 부르는 compare 도 +같은 경고를 받는다. 선택이 모두 같으면 출력은 예전 그대로다. + +데이터셋 파일 바이트(`dataset.sha256`)만 다른 경우는 **선택 차이가 아니다** — 행 출처 키를 더한 재빌드처럼 +행 집합은 같고 바이트만 바뀔 수 있다. 그래도 정답이 바뀌었을 수 있으므로 따로 경고하고, 라벨에 sha 앞자리를 +실어 사람이 이유를 볼 수 있게 한다. +""" import json +from pathlib import Path +from typing import Any import click from tabulate import tabulate +#: 비교 대상 행 집합이 다를 때의 경고. +DIFFERENT_ROWS_WARNING = ( + " ⚠ 비교 대상 run 들의 행 선택이 다릅니다 — 다른 행 집합의 점수라 직접 비교할 수 없습니다." +) -@click.command("compare") -@click.argument("project") -@click.option("--task", "-t", default=None, help="Filter by task name") -@click.option( - "--runs", - "run_ids", - default=None, - help="Comma-separated run IDs to compare", +#: 행 선택은 같은데 데이터셋 파일 바이트가 다를 때의 경고. +DIFFERENT_BYTES_WARNING = ( + " ⚠ 행 선택은 같지만 데이터셋 파일(sha256)이 다릅니다 — 행 내용(정답 등)이 바뀌었을 수 있으니 " + "점수를 비교하기 전에 두 파일의 차이를 확인하세요." ) -@click.option("--last", "-n", default=None, type=int, help="Compare last N runs for a task") -@click.pass_context -def compare_command(ctx, project, task, run_ids, last): - """Compare evaluation scores across runs. - Examples: - solar-eval compare my-project --task ci-v1-all --last 5 - solar-eval compare my-project --runs "run1,run2" - """ - cfg = ctx.obj["config"] - if cfg.is_remote: - raise click.ClickException("Compare is local-mode only") +#: 라벨에 싣는 sha256 앞자리 수 — legacy `prompt_sha` 표시와 맞춘다. +SHA_DISPLAY_CHARS = 12 + + +def _dataset(run_meta: dict[str, Any]) -> dict[str, Any] | None: + dataset = run_meta.get("dataset") + return dataset if isinstance(dataset, dict) else None + + +def _selection_signature(run_meta: dict[str, Any]) -> str | None: + """행 집합의 비교 키 — `dataset` 블록이 없는 옛 run 은 None(모름). 파일 바이트(sha)는 넣지 않는다.""" + dataset = _dataset(run_meta) + if dataset is None: + return None + key = { + "path": dataset.get("path"), + "selection": dataset.get("selection"), + "rows_selected": dataset.get("rows_selected"), + } + return json.dumps(key, sort_keys=True, ensure_ascii=False) + + +def _is_filtered(run_meta: dict[str, Any]) -> bool: + selection = (_dataset(run_meta) or {}).get("selection") or {} + return selection.get("where") is not None or selection.get("limit") is not None - artifacts_dir = cfg.artifacts_dir(project) - if not artifacts_dir.exists(): - click.secho("No artifacts found.", fg="yellow") - return - # Collect runs to compare +def _selection_label(run_meta: dict[str, Any], show_sha: bool = False) -> str: + """사람용 선택 표기: `where tags=a · 12/159 rows` · `all 159/159 rows` · `selection unknown`. + + `show_sha` 면 ` · sha <12>` 를 덧붙인다(파일 바이트가 다른 run 들을 비교할 때). + """ + dataset = _dataset(run_meta) + if dataset is None: + return "selection unknown (no dataset block)" + selection = dataset.get("selection") or {} + parts = [] + where = selection.get("where") + if where: + parts.append("where " + " ".join(f"{k}={','.join(v)}" for k, v in sorted(where.items()))) + if selection.get("limit") is not None: + parts.append(f"limit {selection['limit']}") + label = " ".join(parts) or "all" + label = f"{label} · {dataset.get('rows_selected')}/{dataset.get('rows_total')} rows" + sha = dataset.get("sha256") + if show_sha and isinstance(sha, str): + label = f"{label} · sha {sha[:SHA_DISPLAY_CHARS]}" + return label + + +def selections_differ(run_metas: list[dict[str, Any]]) -> bool: + """비교 대상의 행 집합이 다른가 — 알려진 키가 둘 이상이거나, 모르는 run 과 필터 run 이 섞였을 때.""" + signatures = [_selection_signature(m) for m in run_metas] + known = {s for s in signatures if s is not None} + if len(known) > 1: + return True + unknown = any(s is None for s in signatures) + return unknown and any(_is_filtered(m) for m in run_metas) + + +def dataset_bytes_differ(run_metas: list[dict[str, Any]]) -> bool: + """기록된 데이터셋 sha256 이 둘 이상인가 — 행 선택과 별개로 파일 내용이 바뀌었다는 신호.""" + shas = {(_dataset(m) or {}).get("sha256") for m in run_metas} + return len(shas - {None}) > 1 + + +def _collect_run_dirs( + artifacts_dir: Path, task: str | None, run_ids: str | None, last: int | None +) -> list[Path]: + """비교할 run 디렉토리 — `--runs` 가 있으면 그 목록, 없으면 (태스크로 거른) 전체.""" + run_dirs: list[Path] = [] if run_ids: - # Explicit run IDs - run_dirs = [] for rid in run_ids.split(","): rid = rid.strip() d = artifacts_dir / rid @@ -45,33 +108,25 @@ def compare_command(ctx, project, task, run_ids, last): else: click.secho(f" Run not found: {rid}, skipping", fg="yellow") else: - # Scan all runs, optionally filtered by task - run_dirs = [] for d in sorted(artifacts_dir.iterdir()): if not d.is_dir() or not (d / "run.json").exists(): continue - if task: - run_meta = json.loads((d / "run.json").read_text()) - if run_meta.get("task") != task: - continue + if task and json.loads((d / "run.json").read_text()).get("task") != task: + continue run_dirs.append(d) - if last and last < len(run_dirs): run_dirs = run_dirs[-last:] + return run_dirs - if not run_dirs: - click.secho("No runs found to compare.", fg="yellow") - return - # Load run metadata and evaluation data +def _load_runs_info(run_dirs: list[Path]) -> list[dict[str, Any]]: + """평가가 있는 run 만 (메타 + evaluation.json) 으로 읽는다.""" runs_info = [] - all_categories = set() for d in run_dirs: - run_meta = json.loads((d / "run.json").read_text()) eval_file = d / "evaluation.json" if not eval_file.exists(): continue - eval_data = json.loads(eval_file.read_text()) + run_meta = json.loads((d / "run.json").read_text()) runs_info.append( { "run_id": d.name, @@ -79,18 +134,19 @@ def compare_command(ctx, project, task, run_ids, last): "model": run_meta.get("model", "-"), "prompt": run_meta.get("prompt", run_meta.get("prompt_version", "-")), "pipeline": run_meta.get("pipeline", "-"), - "eval_data": eval_data, + "eval_data": json.loads(eval_file.read_text()), + "run_meta": run_meta, } ) - all_categories.update(eval_data.get("scores", {}).keys()) + return runs_info - if not runs_info: - click.secho("No evaluation data available for comparison.", fg="yellow") - return - # Build comparison table +def _score_table(runs_info: list[dict[str, Any]]) -> str: + """카테고리별 점수 + Overall 표.""" + all_categories: set[str] = set() + for r in runs_info: + all_categories.update(r["eval_data"].get("scores", {}).keys()) headers = ["Category"] + [f"{r['prompt']}/{r['model']}" for r in runs_info] - rows = [] for cat in sorted(all_categories): row = [cat] @@ -98,26 +154,31 @@ def compare_command(ctx, project, task, run_ids, last): score = r["eval_data"].get("scores", {}).get(cat) row.append(f"{score:.4f}" if score is not None else "-") rows.append(row) - - # Separator and overall row rows.append(["─" * 20] + ["─" * 8] * len(runs_info)) overall_row = ["Overall"] for r in runs_info: overall = r["eval_data"].get("overall_score") overall_row.append(f"{overall:.4f}" if overall is not None else "-") rows.append(overall_row) + return tabulate(rows, headers=headers, tablefmt="simple") - task_label = task or "all tasks" - click.secho(f"\nComparison: {project}/{task_label}", fg="blue", bold=True) - # Show run details +def _echo_run_details(runs_info: list[dict[str, Any]]) -> None: + """run 별 한 줄 — 행 선택이나 데이터셋 바이트가 다르면 각 run 의 선택을 함께 싣는다(D19).""" + metas = [r["run_meta"] for r in runs_info] + rows_differ = selections_differ(metas) + bytes_differ = dataset_bytes_differ(metas) for r in runs_info: - click.echo(f" {r['prompt']} ({r['model']}, {r['pipeline']})") - click.echo() + label = _selection_label(r["run_meta"], show_sha=bytes_differ) + suffix = f" [{label}]" if rows_differ or bytes_differ else "" + click.echo(f" {r['prompt']} ({r['model']}, {r['pipeline']}){suffix}") + if rows_differ: + click.secho(DIFFERENT_ROWS_WARNING, fg="yellow", bold=True) + elif bytes_differ: + click.secho(DIFFERENT_BYTES_WARNING, fg="yellow", bold=True) - click.echo(tabulate(rows, headers=headers, tablefmt="simple")) - # Highlight best +def _echo_best(runs_info: list[dict[str, Any]]) -> None: best_run = max(runs_info, key=lambda r: r["eval_data"].get("overall_score", 0)) best_score = best_run["eval_data"].get("overall_score", 0) click.secho( @@ -125,3 +186,45 @@ def compare_command(ctx, project, task, run_ids, last): fg="green", bold=True, ) + + +@click.command("compare") +@click.argument("project") +@click.option("--task", "-t", default=None, help="Filter by task name") +@click.option( + "--runs", + "run_ids", + default=None, + help="Comma-separated run IDs to compare", +) +@click.option("--last", "-n", default=None, type=int, help="Compare last N runs for a task") +@click.pass_context +def compare_command(ctx, project, task, run_ids, last): + """Compare evaluation scores across runs. + + Examples: + solar-eval compare my-project --task ci-v1-all --last 5 + solar-eval compare my-project --runs "run1,run2" + """ + cfg = ctx.obj["config"] + if cfg.is_remote: + raise click.ClickException("Compare is local-mode only") + + artifacts_dir = cfg.artifacts_dir(project) + if not artifacts_dir.exists(): + click.secho("No artifacts found.", fg="yellow") + return + run_dirs = _collect_run_dirs(artifacts_dir, task, run_ids, last) + if not run_dirs: + click.secho("No runs found to compare.", fg="yellow") + return + runs_info = _load_runs_info(run_dirs) + if not runs_info: + click.secho("No evaluation data available for comparison.", fg="yellow") + return + + click.secho(f"\nComparison: {project}/{task or 'all tasks'}", fg="blue", bold=True) + _echo_run_details(runs_info) + click.echo() + click.echo(_score_table(runs_info)) + _echo_best(runs_info) diff --git a/solar_eval/cli/commands/failure_keys.py b/solar_eval/cli/commands/failure_keys.py new file mode 100644 index 0000000000000000000000000000000000000000..1f14eb399d88e42913c05126a2afcf229bff590b --- /dev/null +++ b/solar_eval/cli/commands/failure_keys.py @@ -0,0 +1,143 @@ +"""추론 실패 샘플의 지문 — run.json `failed_sample_keys` (LP5 D-LP5-4 · 계획 §2.4). + +러너는 실패 위치(`failed_sample_indices`)와 예외 타입(`failed_sample_errors`)만 run.json 에 쓴다 — 러너 경로는 +`poc_bundle` 을 import 하지 않는다(D18). 이 모듈은 CLI 층이라 `poc_bundle.keys` 로 지문을 뜰 수 있다: +`execute_plan` 이 러너가 **정상 반환한 뒤** 실패 위치마다 준비된 데이터셋의 원본 행에 결과 행과 같은 +`extract_input` · `extract_golden` 을 적용해 `{sample_idx, input_key, golden_key, error_type}` 를 더한다. + +텍스트는 싣지 않는다 — hex 지문과 타입 이름뿐이다. 계산할 수 없으면(위치가 선택 밖 · 입력이 빈 객체 · +JCS 가 거부하는 값) **아무것도 쓰지 않는다**(= 모름). wrap 은 그때 `failures.jsonl` 을 생략한다. +단 재개 전 시도가 남긴 목록이 run 문서에 있으면 `[]` 로 덮는다(`record_failure_keys` — 낡은 지문을 남기지 않는다). +경고 한 줄에는 이유만 적고 값은 적지 않는다. +""" + +from __future__ import annotations + +import asyncio +import inspect +import json +from collections.abc import Mapping +from typing import Any + +import click + +from poc_bundle.keys import golden_key, input_key +from poc_bundle.v11 import FAILED_SAMPLE_KEYS_KEY +from solar_eval.core.run_records import FAILED_SAMPLE_ERRORS_KEY, extract_golden, extract_input + +#: `failed_sample_errors` 에 그 위치가 없을 때의 타입 이름(wrap 은 `exception:unknown` 으로 적는다). +UNKNOWN_ERROR_TYPE = "unknown" + + +class _KeysUnknown(Exception): + """지문을 뜰 수 없다 — 이유 한 마디만 싣는다(값은 싣지 않는다).""" + + +def failure_keys( + run_doc: Mapping[str, Any] | None, prepared: Any, task_config: Mapping[str, Any] +) -> list[dict[str, Any]] | None: + """run 문서의 실패 위치 → `failed_sample_keys` 원소들(sample_idx 순). 모르면 None. + + `prepared` 는 `PreparedDataset`(또는 `selected` 가 `(원본 위치, 행)` 목록인 객체)이다. + 실패가 없으면 `[]` 이다. + """ + try: + indices = _failed_indices(run_doc) + rows = dict(prepared.selected) + types = _error_types(run_doc.get(FAILED_SAMPLE_ERRORS_KEY)) + return [_entry(idx, rows, task_config, types) for idx in sorted(indices)] + except _KeysUnknown as unknown: + click.secho(f" failure keys not recorded ({unknown})", fg="yellow", err=True) + return None + + +def record_failure_keys( + store: Any, run_id: str, prepared: Any, task_config: Mapping[str, Any] +) -> None: + """run 문서를 읽어 실패 지문을 계산하고 `update_run` 으로 더한다. + + run 문서에 앞선 시도(재개 전)가 남긴 비지 않은 목록이 있으면 **늘 덮는다** — 저장소 `update_run` 은 병합이라 + 덮지 않으면 낡은 목록이 남는다. 새 목록이 있으면 그것으로, 실패가 없으면(`[]`, 재개가 실패를 모두 회복) `[]` 로 + (wrap 이 0행을 쓴다), 모르면(None) 역시 `[]` 로 덮는다 — `[]` 는 비지 않은 `failed_sample_indices` 와 집합이 달라 + wrap 이 생략(모름)하므로, 낡은 지문이 우연히 같은 위치 집합과 맞아 틀린 쌍으로 쓰이는 길이 없다. + 낡은 목록이 없으면: 모르면 쓰지 않고, 실패가 없어도 쓰지 않는다 — 실패 없음은 `failed_sample_indices = []` 가 + 이미 말한다(성공 run 마다 run.json 을 한 번 더 쓰지 않는다). run 문서를 읽을 수 없는 저장소(`load_run_meta` + 없음)는 모름이다. + """ + loader = getattr(store, "load_run_meta", None) + run_doc = loader() if callable(loader) else None + keys = failure_keys(run_doc, prepared, task_config) + if _has_stale_keys(run_doc): + keys = keys or [] + elif not keys: + return + pending = store.update_run(run_id, {FAILED_SAMPLE_KEYS_KEY: keys}) + if inspect.isawaitable(pending): + asyncio.run(pending) + + +def _has_stale_keys(run_doc: Mapping[str, Any] | None) -> bool: + """run 문서에 `[]` 가 아닌 `failed_sample_keys` 가 이미 있다(앞선 시도의 목록 — 모양은 따지지 않는다).""" + return isinstance(run_doc, Mapping) and run_doc.get(FAILED_SAMPLE_KEYS_KEY, []) != [] + + +def _failed_indices(run_doc: Mapping[str, Any] | None) -> list[int]: + if not isinstance(run_doc, Mapping): + raise _KeysUnknown("run document unreadable") + indices = run_doc.get("failed_sample_indices") + if not isinstance(indices, list): + raise _KeysUnknown("no failed_sample_indices list") + if not all(isinstance(i, int) and not isinstance(i, bool) and i >= 0 for i in indices): + raise _KeysUnknown("failed_sample_indices is not a list of positions") + if len(set(indices)) != len(indices): + raise _KeysUnknown("failed_sample_indices has duplicates") + return indices + + +def _error_types(errors: Any) -> dict[int, str]: + """`failed_sample_errors` → `{sample_idx: 타입 이름}`. 모양이 틀린 원소는 건너뛴다(→ unknown).""" + if not isinstance(errors, list): + return {} + types: dict[int, str] = {} + for element in errors: + if not isinstance(element, Mapping): + continue + idx, name = element.get("sample_idx"), element.get("error_type") + if isinstance(idx, int) and not isinstance(idx, bool) and isinstance(name, str) and name: + types[idx] = name + return types + + +def _entry( + idx: int, rows: Mapping[int, Any], task_config: Mapping[str, Any], types: Mapping[int, str] +) -> dict[str, Any]: + sample = rows.get(idx) + if not isinstance(sample, Mapping): + raise _KeysUnknown("failed position outside the selection") + input_data = _as_stored(extract_input(sample, task_config.get("input_fields", []))) + if not input_data: + raise _KeysUnknown("empty input") + golden = _as_stored(extract_golden(sample, task_config)) + try: + # items 규칙과 같다 — 엔진 run 의 입력 정체성은 엔진이 고른 input 객체 전체(fields=None). + ik, gk = input_key(input_data, None), golden_key(golden) + except (TypeError, ValueError) as exc: # CanonicalizationError 는 ValueError 다 + raise _KeysUnknown(f"input not fingerprintable: {type(exc).__name__}") from None + return { + "sample_idx": idx, + "input_key": ik, + "golden_key": gk, + "error_type": types.get(idx, UNKNOWN_ERROR_TYPE), + } + + +def _as_stored(value: Any) -> Any: + """결과 저장소(`JsonlStore.insert_run_result`)가 쓰는 직렬화를 한 번 거친 값. + + items 는 results.jsonl 을 다시 읽어 지문을 뜨므로, 같은 원본 행이면 같은 키가 나오게 같은 + `json.dumps` 왕복을 거친다(튜플 → 목록 · 문자열이 아닌 키 → 문자열). 직렬화할 수 없으면 모름. + """ + try: + return json.loads(json.dumps(value, ensure_ascii=False)) + except (TypeError, ValueError) as exc: + raise _KeysUnknown(f"input not serializable: {type(exc).__name__}") from None diff --git a/solar_eval/cli/commands/run_eval.py b/solar_eval/cli/commands/run_eval.py new file mode 100644 index 0000000000000000000000000000000000000000..7f4df2675c440112167a7fa06f866f27c76492a1 --- /dev/null +++ b/solar_eval/cli/commands/run_eval.py @@ -0,0 +1,340 @@ +"""`solar-eval runs eval` — 새 추론 없이 디스크의 결과만 다시 채점한다 (계획 2026-09-24-phase1a D11). + +`runs.py` 에서 떼어 냈다(E1). 옮기면서 바뀐 것 두 가지: + +- evaluation.json `eval_model` 은 예전의 `"local"` 이 아니라 평가기가 보고하는 judge 모델이다 + (judge 가 없는 lcs_diff·rule_based 는 null). 러너와 같은 규칙이다. +- run.json 에 `snapshot` 이 있으면 `snapshot.config.evaluator_config` 를 새 평가기로 다시 쓰고 + `config_fingerprint` 를 다시 계산하며 `provenance.re_evaluated_at` 을 남긴다 — 번들로 감쌀 때 + 평가 파일과 스냅샷이 어긋나지 않게(SPEC §4 "평가 정체성 연기"의 최소 대응, Q14). + +**두 경로(계획 2026-09-27 L6 §2.12 B4 · 마스터 D-M2-6).** hub 대상 lcs_diff 계열의 완료된 run 은 **run 디렉토리를 건드리지 +않는다** — 채택된 run 번들의 결과로 채점해 평가 디렉토리에 남기고 `purpose=rescore_after_fix` 채점 번들로 발행한 뒤 +(opt-in) hub 에 싣는다(`solar_eval.scoring_publish`). 그 밖(hub 대상 아님·비-lcs·미완료)은 위의 오늘 동작(in-place) + +안내 한 줄이다. hub 대상 run 을 옛 동작으로 돌리던 탈출구 플래그는 L10 컷오버에서 지웠다(계획 2026-09-28 L10 §2.6 · D-L10-7 — 주면 click 사용 +오류, 종료 2). 종료 코드: 채점을 마쳤으면 0(발행·hub 실패와 무관), `--strict-ingest`/ +`SOLAR_EVAL_STRICT_INGEST=1` 이고 실패 다리가 있으면 3, 새 경로에서 번들이 없거나 채점을 못 했으면 1. + +**judge 경로(계획 2026-09-28 L12 §2.5 · D-L12-6·9·13).** 태스크 평가기가 `rubric_judge` 인 hub 대상 완료 run 은 기준별 judge 채점 +번들을 발행한다(`scoring_publish.judge`). `--purpose {primary,variance_check}` 와 `--retry-of ` 는 이 경로에서만 +받는다(함께 쓰거나 다른 경로에서 쓰면 사용 오류, 종료 2). judge 설정은 경로 판정 전에 확인한다. +judge 가지의 종료 코드: 발행 사전 거부 1 · retry 대상 없음 0 · 채점 뒤 실패는 위 규칙 그대로. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Mapping +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import click + +from solar_eval.cli.commands import run_meta as run_meta_module +from solar_eval.cli.commands.run_meta import PREP_ERRORS, find_task, load_run +from solar_eval.cli.formatters import format_scores +from solar_eval.core.run_records import ( + build_eval_detail_docs, + build_eval_sample_from_result, + build_evaluation_doc, +) +from solar_eval.core.snapshot import self_check, with_evaluator +from solar_eval.hub_hook import exit_code_for +from solar_eval.hub_hook.optin import strict_requested +from solar_eval.hub_hook.types import LEG_PUBLISH, PUBLISH_NO_BUNDLE_PREFIX, STATUS_SKIPPED +from solar_eval.scoring_publish import FAMILY_JUDGE, EvalJob, Route, decide_route, publish_run_eval +from solar_eval.scoring_publish import judge as judge_publish +from solar_eval.scoring_publish.eval_dir import REPORT_SAMPLES +from solar_eval.scoring_publish.publish import PublishOutcome, Scored + +#: `--no-ingest`·`--strict-ingest` 도움말(계획 L6 §2.2 `runs eval` 행). +NO_INGEST_HELP = "새 경로에서 hub 다리(적재)만 건너뛴다 — 평가 디렉토리·채점 번들 발행은 한다" +STRICT_INGEST_HELP = ( + "새 경로의 번들·발행·hub 다리가 하나라도 실패하면 종료 코드 3 (기본 0). " + "SOLAR_EVAL_STRICT_INGEST=1 과 같다" +) +#: judge 경로 옵션 도움말(계획 2026-09-28 L12 D-L12-9). +PURPOSE_HELP = ( + "rubric judge 채점 번들의 purpose. 기본 primary — 같은 설정의 primary 가 이미 있으면 거부한다. " + "같은 설정을 다시 채점해 판정 분산을 재려면 variance_check" +) +RETRY_OF_HELP = "이 평가 실행(evaluation_run_id, ULID)의 error 판정만 다시 부른다 — purpose=retry" +#: judge 경로에서만 받는 옵션 — 함께 쓰거나 다른 경로에서 쓰면 사용 오류(종료 2). +USAGE_PURPOSE_WITH_RETRY = "--purpose 와 --retry-of 는 함께 쓸 수 없다" +USAGE_JUDGE_ONLY = "--purpose/--retry-of 는 rubric judge 채점 번들 경로에서만 쓴다" +#: 이 run 이 judge 경로가 아닌 이유(경로 안내)가 있으면 사용 오류 뒤에 붙인다. +USAGE_NOT_JUDGE_REASON = " — 이 run 은 judge 경로가 아니다: {notice}" +#: judge 가지에서 채점하지 못했을 때 — 종료 1. 플래그 권유는 그 플래그가 푸는 실패에만 붙인다(아래 둘). +JUDGE_NOT_SCORED = "채점하지 않았다 — 위 요약의 실패 다리와 그 안내를 본다" +#: 같은 설정의 primary 가 이미 있다(`publish_judge:primary_exists`) — purpose·retry 플래그가 푸는 유일한 실패. +JUDGE_NOT_SCORED_PRIMARY_EXISTS = ( + f"{JUDGE_NOT_SCORED}. 같은 설정을 다시 채점하려면 --purpose variance_check, " + "error 만 다시 부르려면 --retry-of " +) +#: 채택된 run 번들이 없다(`publish_no_bundle:*`) — 플래그로는 풀리지 않는다. +JUDGE_NOT_SCORED_NO_BUNDLE = f"{JUDGE_NOT_SCORED}. 채택된 run 번들이 없다 — 요약의 백필 명령으로 부류를 확인한 뒤 다시 돌린다" +#: retry 대상이 없을 때(종료 0). +JUDGE_RETRY_NOTHING = "다시 부를 error 판정이 없다 — 원 평가 실행의 error 가 이미 모두 풀렸다" + + +def _ulid_option(ctx: click.Context, param: click.Parameter, value: str | None) -> str | None: + """`--retry-of` 는 ULID 모양만 받는다 — 틀리면 사용 오류(종료 2).""" + from poc_bundle.mirror import ULID_RE + + if value is None or ULID_RE.fullmatch(value) is not None: + return value + raise click.BadParameter("evaluation_run_id 는 ULID(26자 Crockford base32)여야 한다") + + +async def _score_all(evaluator, judge_provider, results) -> tuple[list[dict], list[dict]]: + """샘플별 채점. 채점 실패는 점수가 아니라 실패로 남긴다 — aggregate() 에는 성공분만 넘긴다.""" + eval_results: list[dict[str, Any]] = [] + eval_failures: list[dict[str, Any]] = [] + for result in results: + # 리스트 위치가 아니라 원본 sample_idx 가 정본이다(F6). + sample_idx = result["sample_idx"] + try: + eval_sample = build_eval_sample_from_result(result, reference=result.get("golden")) + evaluator.validate_required_fields(eval_sample) + eval_result = await evaluator.evaluate(sample=eval_sample, provider=judge_provider) + eval_results.append({**eval_result, "sample_idx": sample_idx}) + except Exception as e: + click.secho(f" Sample {sample_idx} eval failed: {e}", fg="yellow") + eval_failures.append({"sample_idx": sample_idx, "error": str(e)}) + return eval_results, eval_failures + + +def _updated_snapshot( + run_meta: dict[str, Any], evaluator_config: dict[str, Any] | None, judge_provider: Any +) -> dict[str, Any] | None: + """스냅샷이 있으면 새 평가기로 다시 쓴 스냅샷, 없으면 None(옛 run 에 스냅샷을 새로 쓰지 않는다). + + 채점 **전에** 부른다 — 기록된 스냅샷이 깨졌거나 새 평가기로 지문을 다시 뜰 수 없으면 유료 judge + 호출을 하기 전에 멈춘다. `judge_provider` 는 이번 재채점이 실제로 쓰는 것이다 — judge 가 없던 run 을 + llm_judge 로 다시 채점하면 그 프로바이더 모듈이 `runtime_impl_hash` 에 들어가야 한다(D7). + `re_evaluated_at` 은 채점이 끝난 뒤 `_stamp` 가 덮어쓴다. + """ + snapshot = run_meta.get("snapshot") + if snapshot is None: + return None + started = datetime.now(timezone.utc).isoformat() + try: + # 기록된 스냅샷부터 검사한다 — `config` 가 없는 스냅샷이 맨 KeyError 로 죽지 않게. + self_check(snapshot) + updated = with_evaluator( + snapshot, evaluator_config, re_evaluated_at=started, judge_provider=judge_provider + ) + self_check(updated) + except PREP_ERRORS as e: + raise click.ClickException(f"cannot update the run snapshot's evaluator: {e}") from None + return updated + + +def _stamp(snapshot: dict[str, Any], at: str) -> dict[str, Any]: + """`provenance.re_evaluated_at` 을 채점이 끝난 시각으로 — provenance 는 지문 밖이다.""" + return {**snapshot, "provenance": {**snapshot["provenance"], "re_evaluated_at": at}} + + +def _make_evaluator(evaluator_config: dict[str, Any] | None): + """러너·스냅샷과 같은 규칙 — 키가 없을 때만 기본 평가기. 잘못된 설정은 채점 전에 멈춘다.""" + from solar_eval.evaluators.registry import DEFAULT_EVALUATOR_CONFIG, create_evaluator + + raw = DEFAULT_EVALUATOR_CONFIG if evaluator_config is None else evaluator_config + try: + return create_evaluator(raw) + except ValueError as e: + raise click.ClickException(f"invalid evaluator config: {e}") from None + + +async def _evaluate_run(store, run_id, run_meta, task_config, results) -> dict[str, Any]: + """채점 → evaluation.json·eval_details·run.json(상태·스냅샷) 저장. 집계를 돌려준다.""" + evaluator_config = task_config.get("evaluator") + evaluator = _make_evaluator(evaluator_config) + # judge provider 를 안 넘기면 llm_judge 계열은 전 샘플에서 실패한다 -- 그리고 + # 그 실패가 예전엔 0점 placeholder 로 덮여 조용히 completed 로 끝났다(F8). + _, judge_provider = run_meta_module.make_providers(evaluator_config) + snapshot = _updated_snapshot(run_meta, evaluator_config, judge_provider) + eval_results, eval_failures = await _score_all(evaluator, judge_provider, results) + now = datetime.now(timezone.utc) + if snapshot is not None: + snapshot = _stamp(snapshot, now.isoformat()) + + aggregated = evaluator.aggregate(eval_results) + # eval_model = 평가기가 보고하는 judge 모델(없으면 None) — 예전의 "local" 은 거짓이었다(D11). + doc = build_evaluation_doc( + run_id, aggregated, eval_results, eval_failures, evaluator.judge_model + ) + eval_id = await store.create_evaluation(doc) + await store.insert_eval_details(build_eval_detail_docs(eval_id, eval_results, eval_failures)) + update: dict[str, Any] = {"status": "completed", "completed_at": now} + if snapshot is not None: + update["snapshot"] = snapshot + await store.update_run(run_id, update) + return {**aggregated, "overall_score": doc["overall_score"], "failed": len(eval_failures)} + + +def _report(aggregated: Mapping[str, Any], run_dir: Path) -> None: + click.echo() + click.secho("Evaluation completed!", fg="green", bold=True) + overall = aggregated.get("overall_score") + if overall is None: + # 채점이 한 건도 성공하지 못했다 -- 숫자를 찍으면 "0점"으로 읽힌다. + click.secho(" Overall Score: 측정 없음 (전 샘플 채점 실패)", fg="red", bold=True) + else: + click.echo(f" Overall Score: {overall:.4f}") + samples = aggregated.get(REPORT_SAMPLES) + if samples is not None: + click.echo(f" Samples: {samples} (채택된 run 번들 기준)") + failed = aggregated.get("failed", 0) + if failed: + click.secho(f" 채점 실패: {failed}건 (score: null 로 기록됨)", fg="yellow") + scores = aggregated.get("scores", {}) + if scores: + click.echo(f" Scores: {format_scores(scores)}") + click.echo(f"\nResults saved to: {run_dir}/") + + +@click.command("eval") +@click.argument("project") +@click.option("--run-id", "-r", required=True, help="Run ID to evaluate") +@click.option("--no-ingest", is_flag=True, help=NO_INGEST_HELP) +@click.option("--strict-ingest", is_flag=True, help=STRICT_INGEST_HELP) +@click.option( + "--purpose", type=click.Choice(judge_publish.CLI_PURPOSES), default=None, help=PURPOSE_HELP +) +@click.option( + "--retry-of", + "retry_of", + default=None, + metavar="EVALUATION_RUN_ID", + callback=_ulid_option, + help=RETRY_OF_HELP, +) +@click.pass_context +def eval_run( + ctx: click.Context, + project: str, + run_id: str, + no_ingest: bool, + strict_ingest: bool, + purpose: str | None, + retry_of: str | None, +) -> None: + """Run evaluation only on existing inference results (no new inference).""" + cfg = ctx.obj["config"] + if cfg.is_remote: + raise click.ClickException("Eval is local-mode only") + if purpose is not None and retry_of is not None: + raise click.UsageError(USAGE_PURPOSE_WITH_RETRY) + + run_dir, store, run_meta = load_run(cfg, project, run_id) + results = store.load_existing_results() + if not results: + raise click.ClickException(f"No inference results found in {run_dir}/results.jsonl") + task = run_meta["task"] + click.secho(f"Evaluating run: {run_id}", fg="blue", bold=True) + + _, task_config = find_task(cfg, project, task) + run_meta_module.preflight_judge_config(task_config) + route = decide_route(cfg, project, run_dir, run_meta, task_config) + if route.family != FAMILY_JUDGE and (purpose is not None or retry_of is not None): + raise click.UsageError(_judge_only_message(route)) + if route.target is None: + click.secho(f" Task: {task}, Samples: {len(results)}", fg="blue") + _echo_route(route) + # 이전 평가를 미리 지우지 않는다(F3) -- 재평가가 실패해도 옛 점수는 남는다. + aggregated = asyncio.run(_evaluate_run(store, run_id, run_meta, task_config, results)) + _report(aggregated, run_dir) + return + # 새 경로의 채점 입력은 채택된 run 번들 사본이다 — 원본 행 수(중복 포함)는 찍지 않고 채점 뒤 보고에 싣는다(리뷰 지적 4). + click.secho(f" Task: {task}, Samples: 채택된 run 번들 기준(아래 보고)", fg="blue") + strict = strict_requested(strict_ingest) + judge_options = (purpose, retry_of) + scored, code = _publish(route, project, run_dir, task_config, no_ingest, strict, judge_options) + if scored is not None: + _report(scored.aggregated, scored.path) + if code: + ctx.exit(code) + + +def _judge_only_message(route: Route) -> str: + """judge 전용 플래그를 다른 경로에서 쓴 사용 오류 — 경로 안내(미완료 status·대상 아님 등)가 있으면 이유로 붙인다.""" + if route.notice is None: + return USAGE_JUDGE_ONLY + return USAGE_JUDGE_ONLY + USAGE_NOT_JUDGE_REASON.format(notice=route.notice) + + +def _echo_route(route: Route) -> None: + """옛 경로 안내 한 줄(standalone 은 없다).""" + if route.notice is not None: + click.secho(f" ⚠ {route.notice}", fg="blue") + + +def _publish( + route: Route, + project: str, + run_dir: Path, + task_config: dict[str, Any], + no_ingest: bool, + strict: bool, + judge_options: tuple[str | None, str | None] = (None, None), +) -> tuple[Scored | None, int]: + """새 경로(`scoring_publish.publish_run_eval`) → (채점, 종료 코드). 채점을 못 했으면 ClickException(종료 1). + + `judge_options` = (`--purpose`, `--retry-of`) — judge 경로만 쓴다. judge 가지는 `_judge_outcome` 이 종료 코드를 정한다 + (retry 대상 없음이면 채점 없이 종료 0 — 그때 채점은 None). + """ + evaluator_config = task_config.get("evaluator") + evaluator = _make_evaluator(evaluator_config) # 잘못된 설정은 잠금·백필 전에 멈춘다 + _, judge_provider = run_meta_module.make_providers(evaluator_config) + purpose, retry_of = judge_options + job = EvalJob( + project=project, + run_dir_name=run_dir.name, + evaluator=evaluator, + evaluator_config=evaluator_config, + judge_provider=judge_provider, + score_all=_score_all, + no_ingest=no_ingest, + strict=strict, + family=route.family, + purpose=purpose, + retry_of=retry_of, + ) + outcome = publish_run_eval(route.target, job) + if route.family == FAMILY_JUDGE: + return _judge_outcome(outcome, strict) + if outcome.scored is None: + raise click.ClickException( + "채점하지 않았다 — 위 요약의 실패 다리와 되살리기를 본다(run 디렉토리는 그대로다)" + ) + return outcome.scored, exit_code_for(outcome.result, strict) + + +def _judge_outcome(outcome: PublishOutcome, strict: bool) -> tuple[Scored | None, int]: + """judge 가지의 종료 코드(§2.5 · 검토 F8). 채점했으면 L6b 규칙(0, strict 이고 실패 다리면 3). + + 채점하지 못했으면: retry 대상 없음 → 안내 한 줄 뒤 (None, 0), 그 밖(발행 사전 거부·번들 없음·잠금 등) → judge 전용 문구로 + 종료 1. purpose·retry 플래그는 그것이 푸는 실패(`primary_exists`)에만 권한다 — 번들 없음·잠금 실패에는 요약의 안내를 가리킨다. + """ + if outcome.scored is not None: + return outcome.scored, exit_code_for(outcome.result, strict) + leg = outcome.result.legs.get(LEG_PUBLISH) + code = leg.code if leg is not None else None + if leg is not None and (leg.status, code) == ( + STATUS_SKIPPED, + judge_publish.RETRY_NOTHING_TO_DO, + ): + click.secho(f" {JUDGE_RETRY_NOTHING}", fg="blue") + return None, 0 + raise click.ClickException(_judge_not_scored_message(code)) + + +def _judge_not_scored_message(code: str | None) -> str: + """발행 다리 코드 → 종료 1 문구. 코드 문자열은 `scoring_publish.judge`·`hub_hook.types` 의 상수만 쓴다.""" + if code == f"{judge_publish.PUBLISH_JUDGE_PREFIX}{judge_publish.JUDGE_PRIMARY_EXISTS}": + return JUDGE_NOT_SCORED_PRIMARY_EXISTS + if isinstance(code, str) and code.startswith(PUBLISH_NO_BUNDLE_PREFIX): + return JUDGE_NOT_SCORED_NO_BUNDLE + return JUDGE_NOT_SCORED diff --git a/solar_eval/cli/commands/run_meta.py b/solar_eval/cli/commands/run_meta.py new file mode 100644 index 0000000000000000000000000000000000000000..0f4ee78d5e94d1dde078eddd277fdfd02ec50d65 --- /dev/null +++ b/solar_eval/cli/commands/run_meta.py @@ -0,0 +1,538 @@ +"""run 시작·resume 이 공유하는 준비 단계와 run.json 메타 조립 (계획 2026-09-24-phase1a D3·D5·D12·D17·D19). + +`runs start` 는 run 디렉토리를 만들기 **전에** 이 모듈로 모든 것을 정한다: +태스크·프롬프트·파이프라인 해석 → 행 선택(`where`·`limit`) → 데이터셋 한 번 읽기 → 파이프라인 생성 → +스냅샷(run.json `dataset`·`snapshot` 블록, 자기 검증까지). 그래서 0행 선택·라벨 누락·없는 사전·깨진 lock +같은 오류가 빈 run 디렉토리를 남기지 않고, run.json 이 적은 sha256·스냅샷과 러너가 실제로 도는 +데이터·파이프라인이 **같은 객체**에서 나온다. + +resume 은 같은 조립 함수(`build_snapshot`)로 지금 디스크 기준 스냅샷을 다시 떠 설정 드리프트를 막는다(D17). + +`poc_bundle` import 는 이 모듈(과 `run_resume`·`run_eval`)과 `core/snapshot/**` 에서만 한다(D18). +""" + +from __future__ import annotations + +import subprocess +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import click +from poc_bundle.data.errors import DataError + +from solar_eval.core.dataset_loader import DatasetLoader, LoadedJsonl +from solar_eval.core.dataset_prep import ( + PreparedDataset, + build_selection, + prepare_dataset, + reject_defaults_where, +) +from solar_eval.core.pipeline_factory import build_task_pipeline +from solar_eval.core.row_filter import RowSelection, parse_cli_where +from solar_eval.core.snapshot import EngineSnapshotInputs, build_engine_snapshot, self_check +from solar_eval.core.snapshot import environment +from solar_eval.core.snapshot.dataset_block import describe_dataset +from solar_eval.evaluators.judge_config import JudgeConfigError, validate_judge_config + +#: run 을 시작하기 전에 사람이 읽을 메시지로 바꿔 멈추는 오류 — 행 선택·meta_fields·defaults.where +#: (ValueError 계열), 없는 데이터셋·지식 자산(FileNotFoundError 계열), 깨진 datasets.lock(DataError). +PREP_ERRORS: tuple[type[Exception], ...] = (ValueError, FileNotFoundError, DataError) + +#: 지문을 사람이 눈으로 대조할 때 보여 주는 앞자리 수 — legacy `prompt_sha` 형식과 맞춘다. +FINGERPRINT_DISPLAY_CHARS = 12 + + +def resolve_prompt(prompts_dir: Path, prompt_name: str) -> Path: + """Resolve prompt from prompts/ directory (directory or YAML file). + + Resolution order: + 1. prompts/{name}/ (directory TXT format) + 2. prompts/{name}.yaml (flat YAML layout) + 3. prompts/_library/{name}.yaml (legacy layout) + """ + dir_path = prompts_dir / prompt_name + if dir_path.is_dir(): + return dir_path + yaml_path = prompts_dir / f"{prompt_name}.yaml" + if yaml_path.exists(): + return yaml_path + legacy_path = prompts_dir / "_library" / f"{prompt_name}.yaml" + if legacy_path.exists(): + return legacy_path + raise click.ClickException( + f"Prompt '{prompt_name}' not found. Searched:\n" + f" {dir_path}/ (directory)\n" + f" {yaml_path} (YAML)\n" + f" {legacy_path} (legacy)" + ) + + +def resolve_pipeline_file(project_dir: Path, pipeline_name: str) -> Path: + """Resolve pipeline YAML from pipelines/ directory.""" + path = project_dir / "pipelines" / f"{pipeline_name}.yaml" + if not path.exists(): + raise click.ClickException(f"Pipeline '{pipeline_name}' not found: {path}") + return path + + +def compose_pipeline_config(project_dir: Path, pipeline_name: str, overrides: dict) -> dict: + """파이프라인을 `extends`/override 까지 해석해 돌려준다. + + 조립 실패(없는 base, 없는 스텝을 가리키는 override 등)는 run 을 시작하기 전에 + 죽인다 — 효과 없는 override 로 돌아간 run 은 결론을 조용히 오염시킨다. + """ + from solar_eval.core.pipeline_compose import PipelineCompositionError, compose_pipeline + from solar_eval.core.project_loader import load_pipeline_file + + pipelines_dir = resolve_pipeline_file(project_dir, pipeline_name).parent + try: + composed = load_pipeline_file(pipelines_dir, pipeline_name) + if overrides: + composed = compose_pipeline(composed, overrides=overrides) + return composed + except (PipelineCompositionError, FileNotFoundError) as e: + raise click.ClickException(str(e)) from None + + +def config_source(cfg, project: str) -> str: + """run 의 config 출처 — 레포 관리 프로젝트면 레포 상대 경로, 아니면 데이터 홈 절대 경로.""" + config_dir = cfg.config_dir(project) + if cfg.is_repo_managed(project) and cfg.repo_root is not None: + return str(config_dir.relative_to(cfg.repo_root)) + return str(config_dir) + + +def git_commit(repo_root: Path | None) -> str | None: + """재현용 도장: 레포 HEAD 커밋. 작업 트리가 더러우면 '-dirty' 를 붙인다 (레포 전체 — D8 불변).""" + if repo_root is None: + return None + try: + head = subprocess.run( + ["git", "-C", str(repo_root), "rev-parse", "--short", "HEAD"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + status = subprocess.run( + ["git", "-C", str(repo_root), "status", "--porcelain"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + return f"{head}-dirty" if status else head + except (OSError, subprocess.CalledProcessError): + return None + + +def find_task(cfg, project: str, task: str) -> tuple[dict[str, Any], dict[str, Any]]: + """로드된 프로젝트 설정과 태스크 설정. 없으면 ClickException.""" + from solar_eval.core.project_loader import load_all_project_configs + + configs = load_all_project_configs(cfg.projects_dir, cfg.config_dirs) + project_config = next((c for c in configs if c["name"] == project), None) + if not project_config: + raise click.ClickException(f"Project not found in {cfg.projects_dir}: {project}") + task_config = next((t for t in project_config.get("tasks", []) if t["name"] == task), None) + if not task_config: + raise click.ClickException(f"Task '{task}' not found in project '{project}'") + return project_config, task_config + + +def load_run(cfg, project: str, run_id: str) -> tuple[Path, Any, dict[str, Any]]: + """(run 디렉토리, 스토어, run.json). 없으면 ClickException.""" + from solar_eval.stores import JsonlStore + + run_dir = cfg.artifacts_dir(project) / run_id + if not run_dir.exists(): + raise click.ClickException(f"Run directory not found: {run_dir}") + store = JsonlStore(run_dir) + run_meta = store.load_run_meta() + if not run_meta: + raise click.ClickException(f"No run.json found in {run_dir}") + if not run_meta.get("task"): + raise click.ClickException("Cannot determine task from run.json") + return run_dir, store, run_meta + + +def load_prompt( + prompt_path: Path, + model: str, + temperature: float, + max_tokens: int, + reasoning_effort: str | None, +) -> tuple[dict[str, Any], str]: + """프롬프트 파일을 러너가 받는 dict 로 — (prompt, 포맷 `multi_step`|`single_step`).""" + from solar_eval.models.prompt_version import ( + detect_prompt_format, + load_prompt_messages, + load_step_prompts, + ) + + fmt = detect_prompt_format(prompt_path) + if fmt == "multi_step": + prompt: dict[str, Any] = {"step_prompts": load_step_prompts(prompt_path)} + else: + prompt = {"messages": load_prompt_messages(prompt_path)} + prompt.update({"model": model, "temperature": temperature, "max_tokens": max_tokens}) + if reasoning_effort: + prompt["reasoning_effort"] = reasoning_effort + return prompt, fmt + + +def make_providers(evaluator_config: Mapping[str, Any] | None = None) -> tuple[Any, Any]: + """(추론 프로바이더, judge 프로바이더). 테스트는 이 함수를 바꿔 끼워 네트워크 없이 돈다. + + `evaluator_config` 는 태스크의 평가기 설정(키가 없으면 None) — judge 인스턴스를 선언(`judge_provider`)으로 고르는 + 자리다(계획 2026-09-28 L12 §2.8 · D-L12-10). 선언이 없거나 판정 없는 평가기는 오늘처럼 `OpenAIProvider()` — + run.json 스냅샷 `provenance.providers.judge` 바이트가 바뀌지 않는다. 추론 프로바이더는 늘 `UpstageProvider()`. + + Raises: + JudgeConfigError: 모르는 provider·합성 평가기 안의 provider 충돌. + """ + from solar_eval.evaluators.judge_config import ( + PROVIDER_OPENAI, + PROVIDER_UPSTAGE, + judge_provider_name, + ) + from solar_eval.providers import UpstageProvider + from solar_eval.providers.openai_provider import OpenAIProvider + + judge_classes = {PROVIDER_UPSTAGE: UpstageProvider, PROVIDER_OPENAI: OpenAIProvider} + name = judge_provider_name(evaluator_config) or PROVIDER_OPENAI + return UpstageProvider(), judge_classes[name]() + + +def route_inference_provider(provider: Any, step_models: Sequence[str]) -> Any: + """스텝 모델에 라우트 접두어(`timely:<모델>`)가 하나라도 있으면 추론 제공자를 `RoutedProvider` 로 감싼다. + + 없으면 그대로 돌려준다 — 라우트 없는 run 의 provenance(`providers.inference` 모듈)가 예전 바이트 그대로여야 한다. + 외부 라우트를 써도 되는가는 엔진이 아니라 case 의 데이터 정책(계약)이 정한다 — 2026-10 judge egress 정책 폐지(#353)와 같은 자리. + """ + from solar_eval.providers import INFERENCE_ROUTES, RoutedProvider + from solar_eval.providers.routed import route_prefixes + + if not route_prefixes(list(step_models), INFERENCE_ROUTES): + return provider + return RoutedProvider(provider, {k: cls() for k, cls in INFERENCE_ROUTES.items()}) + + +@dataclass(frozen=True) +class RunPlan: + """run 하나가 무엇으로 도는가 — run 디렉토리를 만들기 전에 전부 정해진다.""" + + project: str + task: str + project_config: dict[str, Any] + task_config: dict[str, Any] + prompt_name: str + prompt_path: Path + prompt_format: str + prompt: dict[str, Any] + model: str + temperature: float + max_tokens: int + reasoning_effort: str | None + pipeline_name: str + pipeline_config: dict[str, Any] + pipeline_overrides: dict[str, Any] | None + max_workers: int + dataset_loader: DatasetLoader + prepared: PreparedDataset + pipeline: Any + provider: Any + judge_provider: Any + + @property + def selection(self) -> RowSelection | None: + return self.prepared.selection + + +def resolve_pipeline( + cfg, project: str, task_config: dict[str, Any], pipeline_name: str | None, overrides: dict +) -> tuple[dict[str, Any], str, dict[str, Any]]: + """(파이프라인을 얹은 task_config, 실효 파이프라인 이름, 해석된 파이프라인 dict). + + `-P` 가 있으면 config 정본의 pipelines/ 에서 `extends`·`--set` 까지 해석하고, 없으면 + project.yaml 기본값(project_loader 가 이미 해석)을 쓴다. config 는 레포 관리 프로젝트면 git + 쪽이 정본이다. `extends` 상속과 `--set` override 는 여기서 전부 해석돼, 실행 엔진에는 완성된 + steps 목록만 넘어간다. + """ + if pipeline_name: + pipeline_config = compose_pipeline_config(cfg.config_dir(project), pipeline_name, overrides) + return {**task_config, "pipeline_config": pipeline_config}, pipeline_name, pipeline_config + if overrides: + raise click.ClickException("--set requires an explicit --pipeline/-P") + pc = task_config.get("pipeline_config", {}) + effective = pc.get("name", "default") if isinstance(pc, dict) else str(pc) + return task_config, effective, pc if isinstance(pc, dict) else {} + + +class _CheckedReader: + """`prepare_dataset` 에 넘기는 얇은 대리자 — 파일을 읽은 **직후**, 행을 고르기 **전에** `on_read` 를 부른다. + + resume 의 데이터셋 sha 가드(D17 순서의 첫째)가 선택·라벨 검사보다 먼저 걸리게 하려는 것이다 — + 데이터셋이 바뀌어 선택이 0행이 되면 원인은 "where 가 0행" 이 아니라 "데이터셋이 바뀌었다" 다. + 바이트는 여전히 한 번만 읽는다(D16). + """ + + def __init__(self, loader: DatasetLoader, on_read: Callable[[LoadedJsonl], None]) -> None: + self._loader = loader + self._on_read = on_read + + def read_jsonl(self, repo: str, path: str, source: str) -> LoadedJsonl: + loaded = self._loader.read_jsonl(repo, path, source=source) + self._on_read(loaded) + return loaded + + +def _step_models(pipeline: Any) -> list[str]: + """파이프라인 스텝들이 명시한 모델 문자열 — 라우트 접두어 검사용(없는 스텝은 run 의 `-m` 모델 = Upstage).""" + return [ + str(step.config["model"]) + for step in getattr(pipeline, "steps", []) + if isinstance(getattr(step, "config", None), dict) and step.config.get("model") + ] + + +def assemble_plan( + cfg, + selection: RowSelection | None, + *, + on_read: Callable[[LoadedJsonl], None] | None = None, + **fields: Any, +) -> RunPlan: + """데이터셋을 한 번 읽어 고르고, 러너가 돌 파이프라인 인스턴스를 만든다(D6·D16). + + `fields` 는 `RunPlan` 의 나머지 필드(project·task_config·prompt 등)다. `on_read` 는 데이터셋을 + 읽은 직후·선택 전에 불린다(resume 의 sha 가드 — 거기서 난 ClickException 은 그대로 올라간다). + + judge 설정 오류는 데이터셋 읽기·파이프라인·provider 생성보다 먼저 확인한다. + `runs start`·`quick-run`·`runs batch`(프롬프트마다)·`runs resume` 이 모두 이 자리를 지난다. + + Raises: + click.ClickException: judge 설정·선택·meta_fields·데이터셋·파이프라인 생성 오류(run 시작 전). + """ + preflight_judge_config(fields["task_config"]) + loader = DatasetLoader(base_dir=cfg.projects_dir) + reader: Any = loader if on_read is None else _CheckedReader(loader, on_read) + try: + prepared = prepare_dataset( + reader, fields["project_config"], fields["task_config"], selection + ) + pipeline = build_task_pipeline( + fields["project_config"], fields["task_config"], fields["prompt"], loader + ) + except PREP_ERRORS as e: + raise click.ClickException(str(e)) from None + provider, judge_provider = make_providers(fields["task_config"].get("evaluator")) + provider = route_inference_provider(provider, _step_models(pipeline)) + return RunPlan( + dataset_loader=loader, + prepared=prepared, + pipeline=pipeline, + provider=provider, + judge_provider=judge_provider, + **fields, + ) + + +def preflight_judge_config(task_config: Mapping[str, Any]) -> None: + """Judge 설정 오류를 CLI 오류로 바꾸되 provider 는 만들지 않는다.""" + try: + validate_judge_config(task_config) + except JudgeConfigError as exc: + raise click.ClickException(str(exc)) from None + + +def start_options( + project_config: dict[str, Any], + task_config: dict[str, Any], + set_options: tuple[str, ...], + where: tuple[str, ...], + limit: int | None, +) -> tuple[dict[str, Any], RowSelection]: + """(`--set` override, 행 선택) — 프롬프트와 무관한 시작 검사. `runs batch` 는 루프 전에 한 번 부른다. + + Raises: + click.ClickException: `defaults.where`·where 문법·`--set` 문법 오류. + """ + from solar_eval.core.pipeline_compose import PipelineCompositionError, parse_set_option + + try: + reject_defaults_where(project_config) + overrides = parse_set_option(set_options) + selection = build_selection(task_config, parse_cli_where(where), limit) + except (PipelineCompositionError, *PREP_ERRORS) as e: + raise click.ClickException(str(e)) from None + return overrides, selection + + +def plan_run( + cfg, + *, + project: str, + task: str, + prompt_name: str, + model: str, + pipeline_name: str | None, + reasoning_effort: str | None, + temperature: float, + max_tokens: int, + max_workers: int, + limit: int | None, + set_options: tuple[str, ...] = (), + where: tuple[str, ...] = (), +) -> RunPlan: + """`runs start` 의 준비 단계 전부 — `reject_defaults_where` → 선택 → `prepare_dataset` → + `build_task_pipeline` (계획 §9 G2 순서). 모든 오류는 run 디렉토리를 만들기 전에 난다. + """ + project_config, task_config = find_task(cfg, project, task) + overrides, selection = start_options(project_config, task_config, set_options, where, limit) + prompt_path = resolve_prompt(cfg.prompts_dir(project), prompt_name) + prompt, fmt = load_prompt(prompt_path, model, temperature, max_tokens, reasoning_effort) + task_config, effective, pipeline_config = resolve_pipeline( + cfg, project, task_config, pipeline_name, overrides + ) + return assemble_plan( + cfg, + selection, + project=project, + task=task, + project_config=project_config, + task_config=task_config, + prompt_name=prompt_name, + prompt_path=prompt_path, + prompt_format=fmt, + prompt=prompt, + model=model, + temperature=temperature, + max_tokens=max_tokens, + reasoning_effort=reasoning_effort, + pipeline_name=effective, + pipeline_config=pipeline_config, + pipeline_overrides=overrides or None, + max_workers=max_workers, + ) + + +def dataset_block(cfg, plan: RunPlan) -> dict[str, Any]: + """run.json `dataset` 블록 — 읽은 바이트의 sha256·행 수·정규화된 선택·lock 대조(S6).""" + prepared = plan.prepared + selection = plan.selection or RowSelection() + try: + return describe_dataset( + sha256=prepared.sha256, + rows_total=prepared.rows_total, + rows_selected=prepared.rows_selected, + selection=selection.to_json(), + meta_fields=prepared.meta_fields, + task_config=plan.task_config, + loader_cfg=plan.project_config.get("dataset") or {}, + config_dir=cfg.config_dir(plan.project), + project_name=plan.project, + repo_root=environment.engine_repo(), + ) + except PREP_ERRORS as e: + raise click.ClickException(f"dataset lock check failed: {e}") from None + + +def build_snapshot( + cfg, + plan: RunPlan, + dataset: dict[str, Any] | None, + *, + source: str | None, + commit: str | None, +) -> dict[str, Any]: + """run.json `snapshot` 블록(engine-snapshot/v1). start 와 resume 이 같은 함수를 쓴다(D17). + + Raises: + click.ClickException: 선언한 자원이 없거나 스냅샷이 자기 검증(D5)을 통과하지 못할 때. + """ + inputs = EngineSnapshotInputs( + model=plan.model, + temperature=plan.temperature, + max_tokens=plan.max_tokens, + reasoning_effort=plan.reasoning_effort, + pipeline_type=plan.task_config.get("pipeline", "single_step"), + input_fields=plan.task_config.get("input_fields", []), + resolved_pipeline=plan.pipeline_config, + prompt_path=plan.prompt_path, + pipeline=plan.pipeline, + evaluator_config=plan.task_config.get("evaluator"), + inference_provider=plan.provider, + judge_provider=plan.judge_provider, + prompt_name=plan.prompt_name, + pipeline_name=plan.pipeline_name, + config_source=source, + git_commit=commit, + pipeline_overrides=plan.pipeline_overrides, + max_workers=plan.max_workers, + config_dir=cfg.config_dir(plan.project), + repo_managed=cfg.is_repo_managed(plan.project), + dataset=dataset, + ) + try: + snapshot = build_engine_snapshot(inputs) + self_check(snapshot) + except PREP_ERRORS as e: + raise click.ClickException(f"cannot snapshot this run's config: {e}") from None + return snapshot + + +def auto_note(note: str | None, plan: RunPlan) -> str | None: + """`--where` run 의 note 자동 기입(D19) — Experiment Hub 목록에서 전체셋 반복과 사람이 구분하게. + + note 가 비면 `where tags=yul-ryul · 12/159 rows`, 사용자가 주면 그 뒤에 ` [where …]`. + where 가 없는 run 은 note 를 그대로 둔다(다른 run 의 run.json 바이트 불변). + """ + selection = plan.selection + if selection is None or selection.where is None: + return note + prepared = plan.prepared + label = ( + f"where {selection.where.describe()} · {prepared.rows_selected}/{prepared.rows_total} rows" + ) + return f"{note} [{label}]" if note else label + + +def build_run_meta(cfg, plan: RunPlan, note: str | None) -> dict[str, Any]: + """run.json 초기 내용 — legacy 키(값 규칙 불변, D12) 뒤에 `dataset`·`snapshot`(계획 §3.2 순서).""" + from solar_eval.core.fingerprint import pipeline_fingerprint, prompt_fingerprint + + source = config_source(cfg, plan.project) + commit = git_commit(cfg.repo_root) if cfg.is_repo_managed(plan.project) else None + dataset = dataset_block(cfg, plan) + return { + "task": plan.task, + "model": plan.model, + "prompt": plan.prompt_name, + "pipeline": plan.pipeline_name, + "temperature": plan.temperature, + "max_tokens": plan.max_tokens, + "reasoning_effort": plan.reasoning_effort, + # 왜 돌렸는지 — 예전에는 별도 랩노트(experiments/*.yaml)가 담았지만 run 과 분리돼 있어 + # 참조가 끊어지고 썩었다. run 과 같은 파일에 두면 끊어질 수가 없다. where run 은 자동 기입(D19). + "note": auto_note(note, plan), + # provenance: 이 run 의 config(프롬프트·파이프라인)가 어디서 왔는지 (W&B/DVC 식 도장) + "config_source": source, + "git_commit": commit, + # 내용 지문 — 이름이 아니라 내용으로 설정을 식별한다. 이름만 다른 사본, + # `extends` 로 조립한 것, `--set` 으로 즉석에서 만든 것이 모두 같은 값이면 + # 같은 지문을 갖는다 (core/fingerprint.py). legacy 값 규칙 그대로(D12) — 새 지문은 snapshot 에. + "prompt_sha": prompt_fingerprint(plan.prompt_path), + "pipeline_sha": pipeline_fingerprint(plan.pipeline_config), + # 파일 없이 돌린 실험이면 무엇을 바꿨는지가 여기 남는다. + "pipeline_overrides": plan.pipeline_overrides, + "dataset": dataset, + "snapshot": build_snapshot(cfg, plan, dataset, source=source, commit=commit), + } + + +def short_fingerprint(value: str | None) -> str: + """64-hex 지문을 `sha256:<12>` 로 — legacy `prompt_sha` 와 같은 모양으로 눈 대조.""" + if not value: + return "-" + return f"sha256:{value[:FINGERPRINT_DISPLAY_CHARS]}" diff --git a/solar_eval/cli/commands/run_progress.py b/solar_eval/cli/commands/run_progress.py new file mode 100644 index 0000000000000000000000000000000000000000..14a5acffb727470abec44c6ee2b7b907630ee91b --- /dev/null +++ b/solar_eval/cli/commands/run_progress.py @@ -0,0 +1,230 @@ +"""run 실행 + 진행 표시 (Rich progress bar) — `runs start`·`runs resume` 이 공유한다. + +`runs.py` 가 800줄 상한을 넘어 떼어 냈다(계획 2026-09-24-phase1a E1). 동작은 옮기기 전과 같고, +러너에 선택·준비된 데이터셋·파이프라인 인스턴스를 넘기는 인자만 더했다(D3·D6). +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +import click +from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn + +from solar_eval.cli.formatters import format_scores + +#: 진행 표시 위로 끌어올려 찍는 로거들. +_LOG_NAMES = ( + "solar_eval.pipelines.pipeline", + "solar_eval.pipelines.steps.llm", + "solar_eval.core.runner", + "solar_eval.providers.upstage", +) + + +class _ProgressView: + """Rich 진행 표시(가능할 때)와 평문 출력 사이를 가르는 작은 상태 묶음.""" + + def __init__(self) -> None: + from rich.console import Console + from rich.live import Live + + self.bar = Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + BarColumn(), + TextColumn("{task.completed}/{task.total}"), + TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), + TimeElapsedColumn(), + ) + self.live: Live | None = None + self.use_rich = True + self.inference_task_id: Any = None + self.eval_task_id: Any = None + self._handler: logging.Handler | None = None + self._closed = False + try: + self.live = Live(self.bar, console=Console(stderr=True), refresh_per_second=8) + self.live.start() + except Exception as e: # 터미널이 Rich 를 못 받으면 평문으로 — 원인은 한 줄 남긴다. + click.secho(f" (진행 표시 없이 평문으로 출력합니다: {e})", fg="yellow") + self.use_rich = False + if self.use_rich: + self._redirect_logs() + + def _redirect_logs(self) -> None: + """파이프라인·러너 로그를 진행 표시 위로 올린다.""" + from rich.text import Text + + live = self.live + + class _LiveLogHandler(logging.Handler): + def emit(self, record): + if live: + live.console.print(Text(record.getMessage(), style="dim")) + + self._handler = _LiveLogHandler() + for name in _LOG_NAMES: + lg = logging.getLogger(name) + lg.addHandler(self._handler) + lg.propagate = False + logging.getLogger("httpx").setLevel(logging.WARNING) + + def cleanup(self) -> None: + """진행 표시를 닫고 로거를 되돌린다. 여러 번 불러도 된다(끝 이벤트 + finally).""" + if self.use_rich and self.live is not None and not self._closed: + self._closed = True + self.live.stop() + for name in _LOG_NAMES: + logging.getLogger(name).removeHandler(self._handler) + logging.getLogger(name).propagate = True + + +def _on_inference(view: _ProgressView, etype: str, event: dict[str, Any]) -> None: + if etype == "dataset_loaded": + if view.use_rich: + view.inference_task_id = view.bar.add_task("Inference", total=event["total"]) + else: + click.secho(f" Dataset loaded: {event['total']} samples", fg="blue") + elif etype == "inference_progress": + if view.use_rich and view.inference_task_id is not None: + view.bar.update(view.inference_task_id, completed=event["completed"]) + elif not view.use_rich: + c, t = event["completed"], event["total"] + if c % 10 == 0 or c == t: + click.echo(f" Inference: {c}/{t}") + elif etype == "inference_complete": + if view.use_rich and view.inference_task_id is not None: + view.bar.update(view.inference_task_id, completed=event["total"]) + else: + click.secho(f" Inference complete: {event['total']} samples", fg="green") + + +def _on_evaluation(view: _ProgressView, etype: str, event: dict[str, Any]) -> None: + if etype == "evaluation_start": + if view.use_rich: + view.eval_task_id = view.bar.add_task("Evaluation", total=event.get("total", 0) or 1) + else: + click.secho(" Starting evaluation...", fg="blue") + elif etype == "evaluation_progress" and view.use_rich and view.eval_task_id is not None: + view.bar.update(view.eval_task_id, completed=event["completed"]) + + +def _on_finish(view: _ProgressView, etype: str, event: dict[str, Any]) -> None: + if etype == "done": + if view.use_rich and view.eval_task_id is not None: + view.bar.update(view.eval_task_id, completed=view.bar.tasks[view.eval_task_id].total) + view.cleanup() + click.echo() + click.secho("Run completed!", fg="green", bold=True) + overall = event.get("overall_score") + if overall is not None: + click.echo(f" Overall Score: {overall:.4f}") + scores = event.get("scores", {}) + if scores: + click.echo(f" Scores: {format_scores(scores)}") + elif etype == "error": + view.cleanup() + click.echo() + click.secho(f" Run failed: {event.get('error')}", fg="red", bold=True) + + +def _run_with_progress( + runner, + run_id, + project_config, + task_config, + prompt, + max_workers, + limit, + completed_results: list[dict[str, Any]] | None = None, + selection=None, + prepared=None, + pipeline=None, +): + """Execute a run with Rich progress bar display. + + `completed_results` 는 resume 이 이미 끝난 샘플을 다시 돌리지 않게 하는 다리다 -- + 여기서 안 넘기면 runner 가 파라미터를 갖고 있어도 소용이 없다(F2). `selection`·`prepared`· + `pipeline` 은 CLI 가 run 시작 전에 정한 행 선택·데이터셋·파이프라인 인스턴스다(D3·D6). + + `execute_run` 은 치명적 실패에서 raise 하므로 이 함수도 raise 한다 -- + 일부러 감싸지 않는다. "error" 이벤트가 이미 사람이 읽을 메시지를 찍었고, + 여기서 또 감싸면 같은 내용이 두 번 나온다. + """ + view = _ProgressView() + + async def on_progress(event): + etype = event.get("type") + _on_inference(view, etype, event) + _on_evaluation(view, etype, event) + _on_finish(view, etype, event) + + try: + asyncio.run( + runner.execute_run( + run_id=run_id, + project_config=project_config, + task_config=task_config, + prompt=prompt, + on_progress=on_progress, + max_workers=max_workers, + limit=limit, + completed_results=completed_results, + selection=selection, + prepared=prepared, + pipeline=pipeline, + ) + ) + finally: + # 끝 이벤트 없이 끝나도(러너가 이벤트 전에 죽음) 터미널·로거를 원래대로 돌린다. + view.cleanup() + + +def execute_plan( + plan, run_id: str, store, completed_results: list[dict[str, Any]] | None = None +) -> None: + """준비된 `RunPlan` 을 러너로 돌린다 — `runs start`·`runs resume` 공용. + + 러너는 plan 이 쥔 **같은 인스턴스**(준비된 데이터셋·파이프라인·프로바이더)로 돈다(D6·D16). + `limit` 은 `prepared.selection` 안에 있으므로 따로 넘기지 않는다. + """ + from solar_eval.core.runner import BatchRunner + + runner = BatchRunner( + store=store, + inference_provider=plan.provider, + judge_provider=plan.judge_provider, + dataset_loader=plan.dataset_loader, + ) + _run_with_progress( + runner, + run_id, + plan.project_config, + plan.task_config, + plan.prompt, + plan.max_workers, + limit=None, + completed_results=completed_results, + prepared=plan.prepared, + pipeline=plan.pipeline, + ) + # 러너가 raise 하면(FAILED) 여기 오지 않는다. 훅은 호출자가 이 함수 **뒤에** 부른다 — 순서 그대로. + _record_failure_keys(plan, run_id, store) + + +def _record_failure_keys(plan, run_id: str, store) -> None: + """실패 샘플 지문을 run.json 에 더한다(LP5 D-LP5-4). 못 해도 run 은 그대로 끝난다. + + 실패 지문은 없어도 되는 정보다 — 없으면 wrap 이 `failures.jsonl` 을 생략(모름)한다. 그래서 도우미의 + 예외는 경고 한 줄(예외 **타입만** — 메시지는 입력 조각을 담을 수 있다)로 드러내고 run 은 계속 끝낸다. + `poc_bundle` 은 여기서 처음 import 한다 — CLI 를 띄우는 것만으로 번들 계층을 싣지 않는다. + """ + from solar_eval.cli.commands import failure_keys + + try: + failure_keys.record_failure_keys(store, run_id, plan.prepared, plan.task_config) + except Exception as exc: # 실패 지문은 선택 정보 — run 을 실패로 만들지 않는다(계획 §2.4) + click.secho(f" failure keys not recorded ({type(exc).__name__})", fg="yellow") diff --git a/solar_eval/cli/commands/run_resume.py b/solar_eval/cli/commands/run_resume.py new file mode 100644 index 0000000000000000000000000000000000000000..0733e8d661cbc7109386b7cd9c2d893a8918381c --- /dev/null +++ b/solar_eval/cli/commands/run_resume.py @@ -0,0 +1,298 @@ +"""`solar-eval runs resume` — 끊긴 run 을 이어 돌린다 (계획 2026-09-24-phase1a D3·D17). + +`runs.py` 에서 떼어 냈다(E1). resume 은 **run.json 이 기록한 것**으로 이어 간다 — 지금의 project.yaml·CLI 에서 +선택을 다시 만들지 않는다. 러너 호출 **전에** 네 가드를 차례로 건다(실패해도 results 가 늘지 않는다): + +1. 데이터셋 sha 가드 — 읽은 파일 sha256 이 run.json `dataset.sha256` 과 다르면 멈춘다. 파일을 읽은 + 직후·행을 고르기 전에 건다 — 바뀐 데이터셋이 선택·라벨 오류로 잘못 보고되지 않게. +2. 설정 드리프트 가드 — 시작 때와 같은 조립 함수로 지금 디스크 기준 스냅샷을 다시 떠 + `config_fingerprint` 가 다르면 멈춘다. 우회 플래그는 없다 — 두 설정이 섞인 results 가 한 스냅샷 아래 + 묶이는 것이 이 가드가 막는 거짓 그 자체다(검토 F2·M4). +3. 선택 밖 idx 가드 — 이미 끝난 `sample_idx` 가 선택 밖이면 멈춘다. +4. 정답 키 가드 — 끝난 행의 저장된 `golden`·`input` 이 지금 태스크 설정(`golden_field`·`golden_fields`· + `input_fields`)으로 뽑은 값과 다르면 멈춘다. `golden_field` 는 지문에도 sha 에도 들어가지 않기 때문이다. + +config 가 같으면 `snapshot.provenance.resumes` 에 이번 엔진 상태를 덧붙이고(dirty OR, 트리 해시가 바뀌면 +`resumed_with_engine_change`) 이어 간다. `dataset`·`snapshot` 블록이 없는 옛 run 은 오늘처럼 전체 행으로 이어 +가고 경고만 남긴다 — 시작 시점 설정을 모르므로 스냅샷을 새로 쓰지 않는다. + +끝나면 `runs start` 와 같은 run 종료 훅(`hub_hook.run_hook`, command=`resume`)을 부른다(계획 2026-09-27 L6a §2.2) — +`--no-ingest` 는 훅 전체를 건너뛰고, `--strict-ingest`(또는 `SOLAR_EVAL_STRICT_INGEST=1`)면 실패 다리가 있을 때 종료 3. +스모크 판정은 run.json 이 기록한 선택(`dataset.selection.limit`)으로 한다(D-L6-10). +""" + +from __future__ import annotations + +import asyncio +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import click + +from solar_eval.cli.commands.run_meta import ( + PREP_ERRORS, + RunPlan, + assemble_plan, + build_snapshot, + compose_pipeline_config, + config_source, + find_task, + git_commit, + load_prompt, + load_run, + resolve_prompt, +) +from solar_eval.cli.commands.run_progress import execute_plan +from solar_eval.core.dataset_prep import check_resume_indices +from solar_eval.core.row_filter import RowSelection, format_indices +from solar_eval.core.run_records import extract_golden, extract_input +from solar_eval.core.snapshot import config_diff_keys +from solar_eval.core.snapshot.reproducibility import merge_resume +from solar_eval.hub_hook import HookOptions, exit_code_for, run_hook +from solar_eval.hub_hook.optin import strict_requested +from solar_eval.hub_hook.target import is_smoke_run + + +def _recorded_selection(run_meta: dict[str, Any]) -> RowSelection | None: + """run.json 이 기록한 선택. `dataset` 블록이 없는 옛 run 은 None(전체 — 오늘 동작).""" + dataset = run_meta.get("dataset") + if dataset is None: + return None + try: + return RowSelection.from_json(dataset.get("selection") or {"where": None, "limit": None}) + except PREP_ERRORS as e: + raise click.ClickException(f"run.json dataset.selection is invalid: {e}") from None + + +def _resume_task_config(run_meta: dict[str, Any], task_config: dict[str, Any]) -> dict[str, Any]: + """run.json 이 기록한 `meta_fields` 로 맞춘다 — 한 run 의 results 행 모양이 섞이지 않게. + + `dataset` 블록이 없는 옛 run 은 `meta_fields` 가 없던 시절 run 이라 meta 를 싣지 않는다. + """ + dataset = run_meta.get("dataset") + recorded = dataset.get("meta_fields") if dataset is not None else None + base = {k: v for k, v in task_config.items() if k != "meta_fields"} + return {**base, "meta_fields": list(recorded)} if recorded else base + + +def _resume_prompt(cfg, project: str, run_meta: dict[str, Any]) -> dict[str, Any]: + """run.json 의 프롬프트 이름·모델·파라미터로 러너 prompt 를 다시 만든다(현재 파일 기준).""" + prompt_name = run_meta.get("prompt") + if not prompt_name: + raise click.ClickException( + "Cannot determine prompt from run.json. Only runs created with the new CLI can be resumed." + ) + model = run_meta.get("model", "solar-pro2") + temperature = run_meta.get("temperature", 0.0) + max_tokens = run_meta.get("max_tokens", 4000) + effort = run_meta.get("reasoning_effort") + prompt_path = resolve_prompt(cfg.prompts_dir(project), prompt_name) + prompt, fmt = load_prompt(prompt_path, model, temperature, max_tokens, effort) + return { + "prompt_name": prompt_name, + "prompt_path": prompt_path, + "prompt_format": fmt, + "prompt": prompt, + "model": model, + "temperature": temperature, + "max_tokens": max_tokens, + "reasoning_effort": effort, + } + + +def _resume_pipeline( + cfg, project: str, run_meta: dict[str, Any], task_config: dict[str, Any] +) -> dict[str, Any]: + """run.json 의 파이프라인 이름(+`--set` overrides)으로 파이프라인을 다시 해석한다.""" + overrides = run_meta.get("pipeline_overrides") or {} + pipeline_name = run_meta.get("pipeline") or "default" + if pipeline_name != "default": + # 원래 run 이 `--set` 으로 바꿔 돌린 것이면 그 override 까지 복원해야 같은 설정이다. + pipeline_config = compose_pipeline_config(cfg.config_dir(project), pipeline_name, overrides) + task_config = {**task_config, "pipeline_config": pipeline_config} + else: + pc = task_config.get("pipeline_config") + pipeline_config = pc if isinstance(pc, dict) else {} + return { + "task_config": task_config, + "pipeline_name": pipeline_name, + "pipeline_config": pipeline_config, + "pipeline_overrides": overrides or None, + } + + +def _plan_resume( + cfg, project: str, run_meta: dict[str, Any], max_workers: int +) -> tuple[RunPlan, RowSelection | None]: + """run.json 의 모델·프롬프트·파이프라인(+overrides)·선택으로 실행 계획을 다시 만든다. + + 데이터셋 sha 가드는 파일을 읽은 직후·행을 고르기 전에 건다(`on_read`) — 데이터셋이 바뀌어 + 선택이 0행이 되거나 라벨이 빠졌을 때 "where 가 틀렸다" 가 아니라 원인을 알린다(D17 순서). + """ + project_config, task_config = find_task(cfg, project, run_meta["task"]) + task_config = _resume_task_config(run_meta, task_config) + selection = _recorded_selection(run_meta) + plan = assemble_plan( + cfg, + selection, + on_read=lambda loaded: _guard_dataset_sha(loaded.sha256, run_meta), + project=project, + task=run_meta["task"], + project_config=project_config, + max_workers=max_workers, + **_resume_prompt(cfg, project, run_meta), + **_resume_pipeline(cfg, project, run_meta, task_config), + ) + return plan, selection + + +def _guard_dataset_sha(current: str, run_meta: dict[str, Any]) -> None: + recorded = (run_meta.get("dataset") or {}).get("sha256") + if recorded is not None and recorded != current: + raise click.ClickException( + "the dataset changed since the run started " + f"(sha256 {recorded[:12]}… → {current[:12]}…) — start a new run" + ) + + +def _guard_config_drift(cfg, plan: RunPlan, run_meta: dict[str, Any]) -> dict[str, Any] | None: + """지금 디스크 기준 스냅샷을 다시 떠 지문을 대조한다. 같으면 resume 을 반영한 새 스냅샷(D17).""" + snapshot = run_meta.get("snapshot") + if snapshot is None: + click.secho( + " ⚠ run.json 에 snapshot 이 없는 옛 run 입니다 — 설정 드리프트를 검사하지 못하고 이어 갑니다.", + fg="yellow", + ) + return None + current = build_snapshot( + cfg, + plan, + run_meta.get("dataset"), + source=config_source(cfg, plan.project), + commit=git_commit(cfg.repo_root) if cfg.is_repo_managed(plan.project) else None, + ) + if current["config_fingerprint"] != snapshot.get("config_fingerprint"): + changed = config_diff_keys(snapshot.get("config") or {}, current["config"]) + raise click.ClickException( + f"the run's config changed since it started: {', '.join(changed) or '?'} " + "— start a new run (mixing two configs in one run is not allowed)" + ) + at = datetime.now(timezone.utc).isoformat() + return merge_resume(snapshot, current["provenance"]["engine"], at) + + +def _guard_selection(plan: RunPlan, completed_indices: set[int]) -> None: + try: + check_resume_indices(plan.prepared, completed_indices) + except PREP_ERRORS as e: + raise click.ClickException(str(e)) from None + + +def _drifted_rows(plan: RunPlan, completed_results: list[dict[str, Any]]) -> list[int]: + """이미 끝난 행 중 저장된 `input`·`golden` 이 지금 태스크 설정으로 뽑은 값과 다른 sample_idx.""" + records = plan.prepared.loaded.records + input_fields = plan.task_config.get("input_fields", []) + drifted = set() + for result in completed_results: + row = records[result["sample_idx"]] + if "golden" in result and result["golden"] != extract_golden(row, plan.task_config): + drifted.add(result["sample_idx"]) + if "input" in result and result["input"] != extract_input(row, input_fields): + drifted.add(result["sample_idx"]) + return sorted(drifted) + + +def _guard_answer_key(plan: RunPlan, completed_results: list[dict[str, Any]]) -> None: + """정답 키(`golden_field`·`golden_fields`)·입력 필드가 run 시작 뒤 바뀌었으면 멈춘다(D17 보강). + + `golden_field` 는 스냅샷 config 에도 run.json `dataset` 블록에도 없어 지문·sha 가드를 지나간다 — + 그대로 이어 가면 한 run 에 두 정답 키가 섞이고, 끝난 행까지 새 정답으로 채점돼 results 의 `golden` + 과 점수가 어긋난다. 그래서 저장된 값과 지금 뽑은 값을 행마다 직접 대조한다(옛 run 도 같은 규칙). + 선택 밖 idx 가드 뒤에 부른다 — 끝난 idx 가 모두 파일 안에 있다. + """ + drifted = _drifted_rows(plan, completed_results) + if drifted: + raise click.ClickException( + "the task's answer key or input fields changed since the run started " + f"(stored golden/input differs for {format_indices(drifted)}) — start a new run" + ) + + +@click.command("resume") +@click.argument("project") +@click.option("--run-id", "-r", required=True, help="Run ID to resume") +@click.option("--max-workers", "-w", default=5, type=int, help="Max parallel workers") +@click.option( + "--no-ingest", + is_flag=True, + help="run 이 끝난 뒤 hub 훅(번들·hub 적재)을 통째로 건너뛴다 — 되살리는 명령을 한 줄로 알려 준다", +) +@click.option( + "--strict-ingest", + is_flag=True, + help="hub 훅 다리가 하나라도 실패하면 종료 코드 3 (기본 0). SOLAR_EVAL_STRICT_INGEST=1 과 같다", +) +@click.pass_context +def resume_run( + ctx: click.Context, + project: str, + run_id: str, + max_workers: int, + no_ingest: bool, + strict_ingest: bool, +) -> None: + """Resume an interrupted evaluation run.""" + cfg = ctx.obj["config"] + if cfg.is_remote: + raise click.ClickException("Resume is local-mode only") + run_dir, store, run_meta = load_run(cfg, project, run_id) + if run_meta.get("status", "unknown") == "completed": + raise click.ClickException(f"Run {run_id} is already completed. Nothing to resume.") + + existing_results = store.load_existing_results() + completed = {r["sample_idx"] for r in existing_results} + click.secho(f"Resuming run: {run_id}", fg="blue", bold=True) + click.secho( + f" Task: {run_meta['task']}, Already completed: {len(completed)} samples", fg="blue" + ) + + plan, selection = _plan_resume(cfg, project, run_meta, max_workers) + if selection is None: + click.secho( + " ⚠ run.json 에 dataset 블록이 없는 옛 run — 전체 행으로 이어 갑니다.", fg="yellow" + ) + # 순서: 데이터셋 sha(읽는 즉시, _plan_resume 안) → 설정 드리프트 → 선택 밖 idx → 정답 키 (D17). + # 모두 러너 호출 전이라 실패해도 results 가 늘지 않는다. + merged = _guard_config_drift(cfg, plan, run_meta) + _guard_selection(plan, completed) + _guard_answer_key(plan, existing_results) + if merged is not None: + asyncio.run(store.update_run(run_id, {"snapshot": merged})) + click.secho(f" Model: {plan.model}", fg="blue") + + # 이전 평가를 미리 지우지 않는다(F3) -- create_evaluation() 이 원자적 교체라 안전하다. + # 이미 끝난 샘플은 건너뛴다 -- completed_results 를 안 넘기면 resume 이 전 샘플을 다시 유료 + # 호출하고 results.jsonl 에 같은 sample_idx 를 중복 append 한다(F2). + execute_plan(plan, run_id, store, completed_results=existing_results) + click.echo(f"\nResults saved to: {run_dir}/") + _finish_with_hook(ctx, project, run_dir, run_meta, no_ingest, strict_ingest) + + +def _finish_with_hook( + ctx: click.Context, + project: str, + run_dir: Path, + run_meta: dict[str, Any], + no_ingest: bool, + strict_ingest: bool, +) -> None: + """run 종료 훅(command=`resume`). 스모크는 run.json 이 기록한 선택으로 본다(D-L6-10).""" + strict = strict_requested(strict_ingest) + options = HookOptions( + command="resume", no_ingest=no_ingest, strict=strict, smoke=is_smoke_run(run_meta) + ) + # 훅 실패는 기본 종료 0 — strict 에서만 3(D-L6-11 · 마스터 D-M2-3). + code = exit_code_for(run_hook(ctx.obj["config"], project, run_dir, options), strict) + if code: + ctx.exit(code) diff --git a/solar_eval/cli/commands/runs.py b/solar_eval/cli/commands/runs.py index 104be2789be9eec83399f2683dd9c2929774e848..157f1722dec20cc14bfc6101cdf94482c3a16b85 100644 --- a/solar_eval/cli/commands/runs.py +++ b/solar_eval/cli/commands/runs.py @@ -1,23 +1,41 @@ -"""Run management commands — local execution.""" +"""Run management commands — local execution. + +`resume`·`eval` 명령과 진행 표시·run 준비(run.json 메타) 는 800줄 상한 때문에 옆 모듈로 떼어 냈다 +(계획 2026-09-24-phase1a E1): `run_resume`·`run_eval`·`run_progress`·`run_meta`. 이 모듈은 `list`·`start`· +`batch`·`results` 와 run 디렉토리 할당을 맡는다. run 이 끝난 뒤의 적재는 `solar_eval.hub_hook` 훅이 한다 +(계획 2026-09-27 L6a — 번들 다리 → hub 다리, 레거시 적재는 부르지 않는다: 마스터 D-M2-4 (b)). +""" -import asyncio import json -import subprocess import time +from dataclasses import replace from datetime import datetime from pathlib import Path from typing import Any import click -from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn, TimeElapsedColumn from solar_eval.cli.commands.projects import _resolve_project_id -from solar_eval.cli.formatters import format_runs_table, format_results_table, format_scores - - -#: run 이 끝난 뒤 자동 적재에 허용하는 시간. 전체 아카이브가 아니라 프로젝트 하나만 -#: 적재하므로 보통 수 초면 끝난다 — 넘어가면 뭔가 잘못된 것이라 기다리지 않는다. -INGEST_TIMEOUT_SECONDS = 180 +from solar_eval.cli.commands.run_eval import eval_run +from solar_eval.cli.commands.run_meta import find_task, preflight_judge_config, start_options +from solar_eval.cli.commands.run_meta import resolve_prompt as _resolve_prompt # noqa: F401 -- prompts.py 가 쓴다 +from solar_eval.cli.commands.run_progress import execute_plan +from solar_eval.cli.commands.run_resume import resume_run +from solar_eval.cli.formatters import format_results_table, format_runs_table +from solar_eval.hub_hook import HookOptions, HookResult, exit_code_for, finish_batch, run_hook +from solar_eval.hub_hook.console import echo_batch_summary +from solar_eval.hub_hook.optin import strict_requested +from solar_eval.hub_hook.target import is_smoke_run +from solar_eval.hub_hook.types import ( + BATCH_PENDING, + COMMAND_BATCH, + LEG_BUNDLE, + LEG_HUB, + NO_BUNDLE, + HookMode, + LegResult, + bundle_ready, +) @click.group("runs") @@ -25,6 +43,31 @@ def runs_group() -> None: """Manage evaluation runs.""" +#: `--where` 옵션 도움말 — start·batch 가 같은 문법을 쓴다(계획 D1). +WHERE_HELP = ( + "행 필터 key=v1,v2 (반복 가능, AND). tags 는 모두 가진 행, category 는 목록 중 하나. " + "예: --where tags=yul-ryul --where category=a,b" +) + +#: `--no-ingest`·`--strict-ingest` 도움말 — start·resume 이 같은 뜻으로 쓴다(계획 L6 §2.2). +NO_INGEST_HELP = ( + "run 이 끝난 뒤 hub 훅(번들·hub 적재)을 통째로 건너뛴다 — 되살리는 명령을 한 줄로 알려 준다" +) +STRICT_INGEST_HELP = ( + "hub 훅 다리가 하나라도 실패하면 종료 코드 3 (기본은 0 — run 결과는 디스크에 있다). " + "SOLAR_EVAL_STRICT_INGEST=1 과 같다" +) + +#: Experiment Hub 묶음 지문이 선택을 보지 않는다는 경고(계획 D19 · §11-2). +FILTERED_RUN_EXPERIMENT_HUB_WARNING = ( + " ⚠ Experiment Hub 는 아직 필터 run 을 같은 태스크 전체셋의 '같은 설정 반복' 과 구분하지 못합니다 " + "(§11-2) — 이 run 의 점수는 위 CLI 출력과 note 로 읽으세요." +) + +#: rubric judge 태스크의 `runs start` 뒤 안내(계획 2026-09-28 L12 D-L12-6) — in-run 은 레거시 채점만, 채점 번들은 `runs eval` 이 낸다. +JUDGE_BUNDLE_HINT = "judge 채점 번들은 `solar-eval runs eval {project} -r {run}` 이 발행한다" + + @runs_group.command("list") @click.argument("project") @click.option("--task", "-t", default=None, help="Filter by task name") @@ -102,7 +145,7 @@ def list_runs(ctx: click.Context, project: str, task: str | None) -> None: @click.option( "--note", default=None, - help="이 run 을 왜 돌리는지 한 줄 (run.json 에 기록되고 evalhub 가 그대로 읽는다)", + help="이 run 을 왜 돌리는지 한 줄 (run.json 에 기록되고 Experiment Hub 가 그대로 읽는다)", ) @click.option( "--set", @@ -114,28 +157,11 @@ def list_runs(ctx: click.Context, project: str, task: str | None) -> None: "예: --set steps.basic_correction.reasoning_effort=high. 여러 번 쓸 수 있다." ), ) -@click.option( - "--no-ingest", - is_flag=True, - help="run 이 끝난 뒤 evalhub 자동 적재를 건너뛴다 (여러 run 을 돌리고 한 번에 적재할 때)", -) +@click.option("--no-ingest", is_flag=True, help=NO_INGEST_HELP) +@click.option("--strict-ingest", is_flag=True, help=STRICT_INGEST_HELP) +@click.option("--where", "where", multiple=True, metavar="KEY=V1,V2", help=WHERE_HELP) @click.pass_context -def start_run( - ctx: click.Context, - project: str, - task: str, - prompt_name: str, - model: str, - pipeline_name: str | None, - reasoning_effort: str | None, - temperature: float, - max_tokens: int, - max_workers: int, - limit: int | None, - note: str | None, - set_options: tuple[str, ...], - no_ingest: bool, -) -> None: +def start_run(ctx: click.Context, project: str, **options: Any) -> None: """Start an evaluation run. Example: @@ -147,80 +173,17 @@ def start_run( -p prompt_dev_v23 -m solar-pro3 -P pipeline_dev_v24 \\ --set steps.basic_correction.repeat_temperatures='[0.0, 0.5]' \\ --note "SC 온도 폭 스윕" - """ - cfg = ctx.obj["config"] - - _start_local( - cfg, - project=project, - task=task, - prompt_name=prompt_name, - model=model, - pipeline_name=pipeline_name, - reasoning_effort=reasoning_effort, - temperature=temperature, - max_tokens=max_tokens, - max_workers=max_workers, - limit=limit, - note=note, - set_options=set_options, - no_ingest=no_ingest, - ) - - -def _resolve_prompt(prompts_dir: Path, prompt_name: str) -> Path: - """Resolve prompt from prompts/ directory (directory or YAML file). - Resolution order: - 1. prompts/{name}/ (directory TXT format) - 2. prompts/{name}.yaml (flat YAML layout) - 3. prompts/_library/{name}.yaml (legacy layout) + solar-eval runs start chosun-proofreading -t ci-v2-all \\ + -p prompt_prod_251231 -m solar-pro3 --where tags=yul-ryul """ - # Directory TXT format (new) - dir_path = prompts_dir / prompt_name - if dir_path.is_dir(): - return dir_path - # Flat YAML layout - yaml_path = prompts_dir / f"{prompt_name}.yaml" - if yaml_path.exists(): - return yaml_path - # Legacy _library layout - legacy_path = prompts_dir / "_library" / f"{prompt_name}.yaml" - if legacy_path.exists(): - return legacy_path - raise click.ClickException( - f"Prompt '{prompt_name}' not found. Searched:\n" - f" {dir_path}/ (directory)\n" - f" {yaml_path} (YAML)\n" - f" {legacy_path} (legacy)" - ) - - -def _resolve_pipeline_file(project_dir: Path, pipeline_name: str) -> Path: - """Resolve pipeline YAML from pipelines/ directory.""" - path = project_dir / "pipelines" / f"{pipeline_name}.yaml" - if not path.exists(): - raise click.ClickException(f"Pipeline '{pipeline_name}' not found: {path}") - return path - - -def _compose_pipeline_config(project_dir: Path, pipeline_name: str, overrides: dict) -> dict: - """파이프라인을 `extends`/override 까지 해석해 돌려준다. - - 조립 실패(없는 base, 없는 스텝을 가리키는 override 등)는 run 을 시작하기 전에 - 죽인다 — 효과 없는 override 로 돌아간 run 은 결론을 조용히 오염시킨다. - """ - from solar_eval.core.pipeline_compose import PipelineCompositionError, compose_pipeline - from solar_eval.core.project_loader import load_pipeline_file - - pipelines_dir = _resolve_pipeline_file(project_dir, pipeline_name).parent - try: - composed = load_pipeline_file(pipelines_dir, pipeline_name) - if overrides: - composed = compose_pipeline(composed, overrides=overrides) - return composed - except (PipelineCompositionError, FileNotFoundError) as e: - raise click.ClickException(str(e)) from None + # click 옵션 이름이 `_start_local` 키워드 인자 이름과 같다 — 그대로 넘긴다. + strict = strict_requested(options.pop("strict_ingest")) + result = _start_local(ctx.obj["config"], project=project, strict_ingest=strict, **options) + # 훅 실패는 기본 종료 0 — strict 에서만 3(D-L6-11 · 마스터 D-M2-3). + code = exit_code_for(result, strict) + if code: + ctx.exit(code) def _build_run_name(task: str, pipeline_name: str, prompt_name: str) -> str: @@ -241,7 +204,7 @@ def _make_run_dir( 같은 초에 같은 (task, pipeline, prompt) 조합으로 두 run 이 시작되면 `_build_run_name` 이 같은 이름을 낸다. `mkdir(exist_ok=False)` 를 쓰면 두 프로세스가 동시에 같은 이름으로 mkdir 해도 정확히 하나만 성공하고, 진 쪽은 새 타임스탬프로 - 재시도한다 — 파일 포맷 자체는 안 바꾸므로 evalhub 의 run_id 파싱과 호환된다. + 재시도한다 — 파일 포맷 자체는 안 바꾸므로 Experiment Hub 의 run_id 파싱과 호환된다. """ for _ in range(_RUN_DIR_MAX_ATTEMPTS): run_name = _build_run_name(task, pipeline_name, prompt_name) @@ -254,254 +217,104 @@ def _make_run_dir( raise click.ClickException(f"Could not allocate a unique run directory under {artifacts_dir}") -def _config_source(cfg, project: str) -> str: - """run 의 config 출처 — 레포 관리 프로젝트면 레포 상대 경로, 아니면 데이터 홈 절대 경로.""" - config_dir = cfg.config_dir(project) - if cfg.is_repo_managed(project) and cfg.repo_root is not None: - return str(config_dir.relative_to(cfg.repo_root)) - return str(config_dir) - - -def _git_commit(repo_root: Path | None) -> str | None: - """재현용 도장: 레포 HEAD 커밋. 작업 트리가 더러우면 '-dirty' 를 붙인다.""" - if repo_root is None: - return None - try: - head = subprocess.run( - ["git", "-C", str(repo_root), "rev-parse", "--short", "HEAD"], - capture_output=True, - text=True, - check=True, - ).stdout.strip() - status = subprocess.run( - ["git", "-C", str(repo_root), "status", "--porcelain"], - capture_output=True, - text=True, - check=True, - ).stdout.strip() - return f"{head}-dirty" if status else head - except (OSError, subprocess.CalledProcessError): - return None - - def _start_local( cfg, - project: str, - task: str, - prompt_name: str, - model: str, - pipeline_name: str | None, - reasoning_effort: str | None, - temperature: float, - max_tokens: int, - max_workers: int, - limit: int | None, + *, note: str | None = None, - set_options: tuple[str, ...] = (), no_ingest: bool = False, -) -> None: - """Start run locally with the new prompt/model/pipeline interface.""" - from solar_eval.core.dataset_loader import DatasetLoader - from solar_eval.core.fingerprint import pipeline_fingerprint, prompt_fingerprint - from solar_eval.core.pipeline_compose import PipelineCompositionError, parse_set_option - from solar_eval.core.project_loader import load_all_project_configs - from solar_eval.core.runner import BatchRunner - from solar_eval.models.prompt_version import ( - detect_prompt_format, - load_prompt_messages, - load_step_prompts, - ) - from solar_eval.providers import UpstageProvider - from solar_eval.providers.openai_provider import OpenAIProvider + strict_ingest: bool = False, + hook_options: HookOptions | None = None, + **plan_options: Any, +) -> HookResult | None: + """run 하나를 로컬에서 돌린다 (계획 §9 G2 순서). + + `plan_options` 는 `run_meta.plan_run` 의 키워드 인자 그대로다 — `project`·`task`·`prompt_name`· + `model`·`pipeline_name`·`reasoning_effort`·`temperature`·`max_tokens`·`max_workers`·`limit` + (필수), `set_options`·`where`(선택). 모르는 키는 `plan_run` 이 TypeError 로 거부한다. + + `reject_defaults_where` → 선택(`--where`·`limit`) → `prepare_dataset` → `build_task_pipeline` + → run.json 메타(`dataset`·`snapshot` + 자기 검증) → `_make_run_dir` → 러너에 준비된 데이터셋· + 선택·파이프라인 **같은 인스턴스**를 넘긴다. run.json 메타는 디렉토리를 만들기 전에 조립한다 — + 없는 사전·깨진 lock 같은 오류가 빈 run 디렉토리를 남기지 않게. + + 끝나면 run 종료 훅(`hub_hook.run_hook`)을 부르고 그 결과를 돌려준다(hub 대상이 아니면 None 일 수 있다). + 훅은 실패해도 예외를 내지 않는다 — 종료 코드는 호출자가 `exit_code_for` 로 정한다. + `hook_options` 를 주면(batch — `HookMode.BATCH_ITEM`) `no_ingest`·`strict_ingest` 대신 그것을 쓴다(스모크 판정만 덧씌운다). + """ + from solar_eval.cli.commands import run_meta from solar_eval.stores import JsonlStore - # Load project config - configs = load_all_project_configs(cfg.projects_dir, cfg.config_dirs) - project_config = next((c for c in configs if c["name"] == project), None) - if not project_config: - raise click.ClickException(f"Project not found in {cfg.projects_dir}: {project}") - - task_config = next((t for t in project_config.get("tasks", []) if t["name"] == task), None) - if not task_config: - raise click.ClickException(f"Task '{task}' not found in project '{project}'") - - # Resolve prompt file - prompts_dir = cfg.prompts_dir(project) - prompt_path = _resolve_prompt(prompts_dir, prompt_name) - - fmt = detect_prompt_format(prompt_path) - if fmt == "multi_step": - step_prompts = load_step_prompts(prompt_path) - prompt = { - "step_prompts": step_prompts, - "model": model, - "temperature": temperature, - "max_tokens": max_tokens, - } - else: - messages = load_prompt_messages(prompt_path) - prompt = { - "messages": messages, - "model": model, - "temperature": temperature, - "max_tokens": max_tokens, - } - - if reasoning_effort: - prompt["reasoning_effort"] = reasoning_effort - - # Resolve pipeline — config 는 레포 관리 프로젝트면 git 쪽이 정본. - # `extends` 상속과 `--set` override 는 여기서 전부 해석돼, 실행 엔진에는 완성된 - # steps 목록만 넘어간다. - try: - cli_overrides = parse_set_option(set_options) - except PipelineCompositionError as e: - raise click.ClickException(str(e)) from None - - effective_pipeline = pipeline_name - if pipeline_name: - pipeline_config = _compose_pipeline_config( - cfg.config_dir(project), pipeline_name, cli_overrides - ) - task_config = {**task_config, "pipeline_config": pipeline_config} - else: - # Use default from project.yaml (already resolved by project_loader) - pc = task_config.get("pipeline_config", {}) - effective_pipeline = pc.get("name", "default") if isinstance(pc, dict) else str(pc) - if cli_overrides: - raise click.ClickException("--set requires an explicit --pipeline/-P") - pipeline_config = pc if isinstance(pc, dict) else {} - - # Build run name and directory -- 이름 충돌은 여기서 원자적으로 걸러진다(F10). + plan = run_meta.plan_run(cfg, **plan_options) + project = plan.project + meta = run_meta.build_run_meta(cfg, plan, note) + # 이름 충돌은 여기서 원자적으로 걸러진다(F10). run.json 은 그 직후 한 번에 쓴다. run_name, run_dir = _make_run_dir( - cfg.artifacts_dir(project), task, effective_pipeline, prompt_name - ) - store = JsonlStore(run_dir) - - # Save run metadata - run_meta = { - "task": task, - "model": model, - "prompt": prompt_name, - "pipeline": effective_pipeline, - "temperature": temperature, - "max_tokens": max_tokens, - "reasoning_effort": reasoning_effort, - # 왜 돌렸는지 — 예전에는 별도 랩노트(experiments/*.yaml)가 담았지만 run 과 분리돼 있어 - # 참조가 끊어지고 썩었다. run 과 같은 파일에 두면 끊어질 수가 없다. - "note": note, - # provenance: 이 run 의 config(프롬프트·파이프라인)가 어디서 왔는지 (W&B/DVC 식 도장) - "config_source": _config_source(cfg, project), - "git_commit": _git_commit(cfg.repo_root) if cfg.is_repo_managed(project) else None, - # 내용 지문 — 이름이 아니라 내용으로 설정을 식별한다. 이름만 다른 사본, - # `extends` 로 조립한 것, `--set` 으로 즉석에서 만든 것이 모두 같은 값이면 - # 같은 지문을 갖는다 (core/fingerprint.py). - "prompt_sha": prompt_fingerprint(prompt_path), - "pipeline_sha": pipeline_fingerprint(pipeline_config), - # 파일 없이 돌린 실험이면 무엇을 바꿨는지가 여기 남는다. - "pipeline_overrides": cli_overrides or None, - } - # run_dir 은 _make_run_dir 이 이미 원자적으로 만들었다 -- 여기서 다시 mkdir 하지 않는다. - (run_dir / "run.json").write_text(json.dumps(run_meta, ensure_ascii=False, indent=2)) - - # Log run info - click.secho(f"Prompt: {prompt_name} [{fmt}]", fg="blue") - click.secho(f"Model: {model}", fg="blue") - click.secho(f"Pipeline: {effective_pipeline}", fg="blue") - for path, value in cli_overrides.items(): - click.secho(f" override: {path} = {value!r}", fg="magenta") - click.secho( - f"Fingerprint: prompt {run_meta['prompt_sha']} / pipeline {run_meta['pipeline_sha']}", - fg="blue", + cfg.artifacts_dir(project), plan.task, plan.pipeline_name, plan.prompt_name ) - if reasoning_effort: - click.secho(f"Reasoning effort: {reasoning_effort}", fg="blue") - if limit: - click.secho(f"Limit: {limit} samples", fg="yellow") - click.secho(f"Starting local run: {project}/{run_name}", fg="blue") - click.secho(f"Output: {run_dir}", fg="blue") - - # Create runner - provider = UpstageProvider() - judge_provider = OpenAIProvider() - dataset_loader = DatasetLoader(base_dir=cfg.projects_dir) - runner = BatchRunner( - store=store, - inference_provider=provider, - judge_provider=judge_provider, - dataset_loader=dataset_loader, - ) - - # Run with progress display - _run_with_progress(runner, run_name, project_config, task_config, prompt, max_workers, limit) + (run_dir / "run.json").write_text(json.dumps(meta, ensure_ascii=False, indent=2)) + _echo_run_info(plan, meta, f"{project}/{run_name}", run_dir) + execute_plan(plan, run_name, JsonlStore(run_dir)) _warn_if_incomplete(run_dir) + if plan.selection is not None and plan.selection.where is not None: + click.secho(FILTERED_RUN_EXPERIMENT_HUB_WARNING, fg="yellow") click.echo(f"\nResults saved to: {run_dir}/") - if not no_ingest: - _ingest_into_evalhub(cfg, project, run_dir) + if hook_options is None: + hook_options = HookOptions(command="start", no_ingest=no_ingest, strict=strict_ingest) + options = replace(hook_options, smoke=is_smoke_run(plan)) + result = run_hook(cfg, project, run_dir, options) + _echo_judge_bundle_hint(plan, project, run_name) + return result -def _ingest_into_evalhub(cfg, project: str, run_dir: Path) -> None: - """방금 만든 run 을 evalhub 로 적재한다. +def _echo_judge_bundle_hint(plan, project: str, run_name: str) -> None: + """rubric judge 태스크면 훅 뒤 안내 한 줄 — 훅(eval-wrap)은 lcs 전용이라 judge 채점 번들을 내지 않는다(D-L12-6).""" + from solar_eval.evaluators.judge_config import RUBRIC_JUDGE_TYPE + from solar_eval.scoring_publish.publish import evaluator_type - **실패해도 run 은 성공이다** — 결과는 이미 디스크에 있고, 적재는 나중에 다시 - 돌리면 되는 멱등 작업이다. 그래서 어떤 실패도 exit code 를 바꾸지 않고 - "무엇이 안 됐고 어떻게 되살리는지"만 알린다. 적재를 못 했다는 사실 자체를 - 조용히 넘기지도 않는다 — 그러면 evalhub 화면이 낡은 채로 남는다. + if evaluator_type(plan.task_config.get("evaluator")) == RUBRIC_JUDGE_TYPE: + click.secho(f" {JUDGE_BUNDLE_HINT.format(project=project, run=run_name)}", fg="blue") - 레포 밖 standalone 설치에는 evalhub 자체가 없으므로 조용히 건너뛴다. - """ - if cfg.repo_root is None: - return - click.secho("\nevalhub 적재 중...", fg="blue") - try: - result = subprocess.run( - ["pnpm", "--filter", "@poc/eval-store", "ingest", "--only", project], - cwd=str(cfg.repo_root), - capture_output=True, - text=True, - timeout=INGEST_TIMEOUT_SECONDS, - ) - except FileNotFoundError: - _warn_ingest_skipped(project, run_dir, "pnpm 을 찾을 수 없습니다") - return - except subprocess.TimeoutExpired: - _warn_ingest_skipped(project, run_dir, f"{INGEST_TIMEOUT_SECONDS}초 안에 끝나지 않았습니다") - return - except OSError as e: - _warn_ingest_skipped(project, run_dir, str(e)) - return - - if result.returncode == 0: - click.secho(f" evalhub 적재 완료 — {project}", fg="green") - return - _warn_ingest_skipped(project, run_dir, _ingest_failure_reason(result)) - - -def _ingest_failure_reason(result: subprocess.CompletedProcess[str]) -> str: - """ingest 실패 출력에서 사람이 읽을 한 줄을 뽑는다. +def _echo_run_info(plan, meta: dict, run_ref: str, run_dir: Path) -> None: + """run 시작 요약 — 설정·지문(config sha256:<12>)·선택·재현 가능성.""" + from solar_eval.cli.commands.run_meta import short_fingerprint - stderr 전체는 node 스택트레이스라 그대로 보여주면 정작 원인이 묻힌다. - 가장 흔한 원인(백엔드 미기동)은 연결 거부로 나타나므로 먼저 짚는다. - """ - output = f"{result.stderr}\n{result.stdout}" - if "ECONNREFUSED" in output or "connect ECONNREFUSED" in output: - return "Postgres 에 연결할 수 없습니다 (백엔드가 꺼져 있는 것 같습니다)" - for line in result.stderr.splitlines(): - stripped = line.strip() - if stripped and not stripped.startswith("at ") and "node_modules" not in stripped: - return stripped[:160] - return f"ingest 가 exit {result.returncode} 로 끝났습니다" + snapshot = meta["snapshot"] + dataset = meta["dataset"] + click.secho(f"Prompt: {plan.prompt_name} [{plan.prompt_format}]", fg="blue") + click.secho(f"Model: {plan.model}", fg="blue") + click.secho(f"Pipeline: {plan.pipeline_name}", fg="blue") + for path, value in (plan.pipeline_overrides or {}).items(): + click.secho(f" override: {path} = {value!r}", fg="magenta") + click.secho( + f"Fingerprint: config {short_fingerprint(snapshot['config_fingerprint'])}" + f" / prompt {meta['prompt_sha']} / pipeline {meta['pipeline_sha']}", + fg="blue", + ) + if plan.reasoning_effort: + click.secho(f"Reasoning effort: {plan.reasoning_effort}", fg="blue") + selection = plan.selection + if selection is not None and selection.where is not None: + click.secho(f"Where: {selection.where.describe()}", fg="yellow") + if selection is not None and selection.limit: + click.secho(f"Limit: {selection.limit} samples", fg="yellow") + click.secho(f"Rows: {dataset['rows_selected']}/{dataset['rows_total']}", fg="blue") + _echo_reproducibility(snapshot) + click.secho(f"Starting local run: {run_ref}", fg="blue") + click.secho(f"Output: {run_dir}", fg="blue") -def _warn_ingest_skipped(project: str, run_dir: Path, reason: str) -> None: - """적재 실패를 알리고 되살리는 명령을 그대로 실어 준다 (exit code 는 0 유지).""" - click.secho(f"\n ⚠ evalhub 적재를 건너뛰었습니다 — {reason}", fg="yellow", bold=True) - click.secho(f" run 결과는 그대로 있습니다: {run_dir}", fg="yellow") - click.secho(" 백엔드를 띄운 뒤 아래를 실행하면 반영됩니다:", fg="yellow") - click.secho(" make evalhub-up", fg="yellow") - click.secho(f" make evalhub-ingest ONLY={project}", fg="yellow") +def _echo_reproducibility(snapshot: dict) -> None: + """재현 가능성 한 줄 — 막는 이유를 그대로 보여 준다(계획 R5).""" + repro = snapshot.get("reproducibility") or {} + verdict = repro.get("rerunnable") + label = {True: "true", False: "false", None: "unknown"}[verdict] + blockers = repro.get("rerunnable_blockers") or [] + suffix = f" — {', '.join(blockers)}" if blockers else "" + click.secho(f"Rerunnable: {label}{suffix}", fg="green" if verdict else "yellow") + if snapshot.get("missing"): + click.secho(f" snapshot missing: {', '.join(snapshot['missing'])}", fg="yellow") def _warn_if_incomplete(run_dir: Path) -> None: @@ -535,141 +348,6 @@ def _warn_if_incomplete(run_dir: Path) -> None: ) -def _run_with_progress( - runner, - run_id, - project_config, - task_config, - prompt, - max_workers, - limit, - completed_results: list[dict[str, Any]] | None = None, -): - """Execute a run with Rich progress bar display. - - `completed_results` 는 resume 이 이미 끝난 샘플을 다시 돌리지 않게 하는 다리다 -- - 여기서 안 넘기면 runner 가 파라미터를 갖고 있어도 소용이 없다(F2). - - `execute_run` 은 치명적 실패에서 raise 하므로 이 함수도 raise 한다 -- - 일부러 감싸지 않는다. 위쪽 `on_progress` 의 "error" 이벤트가 이미 사람이 읽을 - 메시지를 찍었고, 여기서 또 감싸면 같은 내용이 두 번 나온다. - """ - import logging as _logging - - from rich.console import Console - from rich.live import Live - from rich.text import Text - - console = Console(stderr=True) - use_rich = True - live_ctx: Live | None = None - - progress_bar = Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - BarColumn(), - TextColumn("{task.completed}/{task.total}"), - TextColumn("[progress.percentage]{task.percentage:>3.0f}%"), - TimeElapsedColumn(), - ) - - try: - live_ctx = Live(progress_bar, console=console, refresh_per_second=8) - live_ctx.start() - except Exception: - use_rich = False - - # Redirect pipeline/runner logs above the progress bar - class _LiveLogHandler(_logging.Handler): - def emit(self, record): - if live_ctx: - live_ctx.console.print(Text(record.getMessage(), style="dim")) - - _log_names = ( - "solar_eval.pipelines.pipeline", - "solar_eval.pipelines.steps.llm", - "solar_eval.core.runner", - "solar_eval.providers.upstage", - ) - if use_rich: - _live_handler = _LiveLogHandler() - for name in _log_names: - lg = _logging.getLogger(name) - lg.addHandler(_live_handler) - lg.propagate = False - _logging.getLogger("httpx").setLevel(_logging.WARNING) - - def _cleanup_live(): - if use_rich: - live_ctx.stop() - for name in _log_names: - _logging.getLogger(name).removeHandler(_live_handler) - _logging.getLogger(name).propagate = True - - inference_task_id = None - eval_task_id = None - - async def on_progress(event): - nonlocal inference_task_id, eval_task_id - etype = event.get("type") - if etype == "dataset_loaded": - total = event["total"] - if use_rich: - inference_task_id = progress_bar.add_task("Inference", total=total) - else: - click.secho(f" Dataset loaded: {total} samples", fg="blue") - elif etype == "inference_progress": - if use_rich and inference_task_id is not None: - progress_bar.update(inference_task_id, completed=event["completed"]) - elif not use_rich: - c, t = event["completed"], event["total"] - if c % 10 == 0 or c == t: - click.echo(f" Inference: {c}/{t}") - elif etype == "inference_complete": - if use_rich and inference_task_id is not None: - progress_bar.update(inference_task_id, completed=event["total"]) - else: - click.secho(f" Inference complete: {event['total']} samples", fg="green") - elif etype == "evaluation_start": - total = event.get("total", 0) - if use_rich: - eval_task_id = progress_bar.add_task("Evaluation", total=total or 1) - else: - click.secho(" Starting evaluation...", fg="blue") - elif etype == "evaluation_progress": - if use_rich and eval_task_id is not None: - progress_bar.update(eval_task_id, completed=event["completed"]) - elif etype == "done": - if use_rich and eval_task_id is not None: - progress_bar.update(eval_task_id, completed=progress_bar.tasks[eval_task_id].total) - _cleanup_live() - click.echo() - click.secho("Run completed!", fg="green", bold=True) - overall = event.get("overall_score") - if overall is not None: - click.echo(f" Overall Score: {overall:.4f}") - scores = event.get("scores", {}) - if scores: - click.echo(f" Scores: {format_scores(scores)}") - elif etype == "error": - _cleanup_live() - click.echo() - click.secho(f" Run failed: {event.get('error')}", fg="red", bold=True) - - asyncio.run( - runner.execute_run( - run_id=run_id, - project_config=project_config, - task_config=task_config, - prompt=prompt, - on_progress=on_progress, - max_workers=max_workers, - limit=limit, - completed_results=completed_results, - ) - ) - - @runs_group.command("batch") @click.argument("project") @click.option("--task", "-t", required=True, help="Task name") @@ -684,331 +362,101 @@ def _run_with_progress( ) @click.option("--reasoning-effort", "-re", default=None, help="Reasoning effort") @click.option("--max-workers", "-w", default=5, type=int, help="Max parallel workers") +@click.option("--where", "where", multiple=True, metavar="KEY=V1,V2", help=WHERE_HELP) +@click.option("--no-ingest", is_flag=True, help=NO_INGEST_HELP) +@click.option("--strict-ingest", is_flag=True, help=STRICT_INGEST_HELP) @click.pass_context -def batch_runs(ctx, project, task, prompts, model, pipeline_name, reasoning_effort, max_workers): - """Run evaluation for multiple prompts sequentially.""" +def batch_runs(ctx, project, task, prompts, no_ingest, strict_ingest, **run_options): + """Run evaluation for multiple prompts sequentially. + + run 마다 번들 다리만 돌고(`HookMode.BATCH_ITEM`), 끝에 hub 다리를 한 번 부른다(계획 L6 D-L6-12). + 종료 코드: 프롬프트 실패가 있으면 1(먼저), 아니고 strict 이며 훅이 실패한 run 이 있으면 3, 그 밖 0(D-L6-11). + """ cfg = ctx.obj["config"] if cfg.is_remote: raise click.ClickException("Batch is local-mode only") prompt_list = [p.strip() for p in prompts.split(",")] + # 프롬프트와 무관한 검사(defaults.where·--where 문법·태스크 where 합치기)는 루프 전에 한 번 — + # 틀렸으면 프롬프트마다 같은 오류를 되풀이하지 않고 바로 멈춘다(D3 "실패는 크게"). + project_config, task_config = find_task(cfg, project, task) + start_options(project_config, task_config, (), run_options["where"], None) + preflight_judge_config(task_config) click.secho(f"Batch run: {project}/{task} — prompts: {prompt_list}", fg="blue", bold=True) + options = HookOptions( + command=COMMAND_BATCH, + mode=HookMode.BATCH_ITEM, + no_ingest=no_ingest, + strict=strict_requested(strict_ingest), + ) + results, failed = _run_batch_prompts(cfg, project, task, prompt_list, options, run_options) + results = _with_batch_hub_leg(results, finish_batch(cfg, project, results, options)) + click.secho(f"\n{'=' * 50}", fg="blue") + # standalone(레포 밖 — 훅이 None 만 돌려준다)은 훅 콘솔이 없다(§2.3 "오늘과 같다", 리뷰 지적 3). + if cfg.repo_root is not None or any(r is not None for r in results): + echo_batch_summary(results) + if failed: + raise click.ClickException( + f"{len(failed)} of {len(prompt_list)} prompts failed: {', '.join(failed)}" + ) + code = max((exit_code_for(r, options.strict) for r in results), default=0) + if code: + ctx.exit(code) + click.secho("Batch complete!", fg="green", bold=True) + + +def _run_batch_prompts( + cfg, project: str, task: str, prompt_list: list[str], options: HookOptions, run_options: dict +) -> tuple[list[HookResult | None], list[str]]: + """프롬프트마다 run 하나 + 번들 다리. 훅 결과와 실패한 프롬프트 이름을 돌려준다. + + 훅은 예외를 내지 않는다(`run_hook` 이 봉쇄한다, D-L6-2) — 여기서 잡히는 예외는 run 자체의 실패뿐이다. + """ + results: list[HookResult | None] = [] + failed: list[str] = [] for pname in prompt_list: click.secho(f"\n{'=' * 50}", fg="blue") click.secho(f"Running prompt: {pname}...", fg="blue", bold=True) click.secho(f"{'=' * 50}", fg="blue") try: - _start_local( - cfg, - project=project, - task=task, - prompt_name=pname, - model=model, - pipeline_name=pipeline_name, - reasoning_effort=reasoning_effort, - temperature=0.0, - max_tokens=4000, - max_workers=max_workers, - limit=None, + results.append( + _start_local( + cfg, + project=project, + task=task, + prompt_name=pname, + temperature=0.0, + max_tokens=4000, + limit=None, + hook_options=options, + **run_options, + ) ) except Exception as e: + # 한 프롬프트의 실패가 나머지를 막지 않게 이어 가되, 끝에서 exit code 로 알린다. click.secho(f" {pname} failed: {e}", fg="red") - - click.secho(f"\n{'=' * 50}", fg="blue") - click.secho("Batch complete!", fg="green", bold=True) + failed.append(pname) + return results, failed -@runs_group.command("resume") -@click.argument("project") -@click.option("--run-id", "-r", required=True, help="Run ID to resume") -@click.option("--max-workers", "-w", default=5, type=int, help="Max parallel workers") -@click.pass_context -def resume_run(ctx: click.Context, project: str, run_id: str, max_workers: int) -> None: - """Resume an interrupted evaluation run.""" - cfg = ctx.obj["config"] - if cfg.is_remote: - raise click.ClickException("Resume is local-mode only") - - from solar_eval.core.dataset_loader import DatasetLoader - from solar_eval.core.project_loader import load_all_project_configs - from solar_eval.core.runner import BatchRunner - from solar_eval.providers import UpstageProvider - from solar_eval.providers.openai_provider import OpenAIProvider - from solar_eval.stores import JsonlStore - - # Find the run directory - run_dir = cfg.artifacts_dir(project) / run_id - if not run_dir.exists(): - raise click.ClickException(f"Run directory not found: {run_dir}") - - store = JsonlStore(run_dir) - run_meta = store.load_run_meta() - - if not run_meta: - raise click.ClickException(f"No run.json found in {run_dir}") - - status = run_meta.get("status", "unknown") - if status == "completed": - raise click.ClickException(f"Run {run_id} is already completed. Nothing to resume.") - - task = run_meta.get("task") - if not task: - raise click.ClickException("Cannot determine task from run.json") - - # Load existing results - existing_results = store.load_existing_results() - completed_indices = {r["sample_idx"] for r in existing_results} - click.secho(f"Resuming run: {run_id}", fg="blue", bold=True) - click.secho(f" Task: {task}, Already completed: {len(completed_indices)} samples", fg="blue") - - # Load project/task config - configs = load_all_project_configs(cfg.projects_dir, cfg.config_dirs) - project_config = next((c for c in configs if c["name"] == project), None) - if not project_config: - raise click.ClickException(f"Project not found: {project}") - - task_config = next((t for t in project_config.get("tasks", []) if t["name"] == task), None) - if not task_config: - raise click.ClickException(f"Task '{task}' not found in project '{project}'") - - # Resolve prompt from run.json - prompt_name = run_meta.get("prompt") - pipeline_name = run_meta.get("pipeline") - model = run_meta.get("model", "solar-pro2") - - if not prompt_name: - raise click.ClickException( - "Cannot determine prompt from run.json. Only runs created with the new CLI can be resumed." - ) +def _with_batch_hub_leg( + results: list[HookResult | None], hub: LegResult | None +) -> list[HookResult | None]: + """batch 끝 hub 다리 결과를 run 별 결과에 얹는다(요약·종료 코드용 — 상태 파일은 `finish_batch` 가 고친다). - from solar_eval.models.prompt_version import ( - detect_prompt_format, - load_prompt_messages, - load_step_prompts, - ) - - prompt_path = _resolve_prompt(cfg.prompts_dir(project), prompt_name) - fmt = detect_prompt_format(prompt_path) - if fmt == "multi_step": - prompt = { - "step_prompts": load_step_prompts(prompt_path), - "model": model, - "temperature": run_meta.get("temperature", 0.0), - "max_tokens": run_meta.get("max_tokens", 4000), - } - else: - prompt = { - "messages": load_prompt_messages(prompt_path), - "model": model, - "temperature": run_meta.get("temperature", 0.0), - "max_tokens": run_meta.get("max_tokens", 4000), - } - if run_meta.get("reasoning_effort"): - prompt["reasoning_effort"] = run_meta["reasoning_effort"] - - # Override pipeline if specified — 원래 run 이 `--set` 으로 바꿔 돌린 것이면 - # 그 override 까지 복원해야 같은 설정으로 이어진다. - if pipeline_name and pipeline_name != "default": - task_config = { - **task_config, - "pipeline_config": _compose_pipeline_config( - cfg.config_dir(project), - pipeline_name, - run_meta.get("pipeline_overrides") or {}, - ), - } - - click.secho(f" Model: {model}", fg="blue") - - # 이전 평가를 미리 지우지 않는다(F3) -- 재평가가 도중에 죽으면 복구할 점수가 - # 없어진다. create_evaluation() 이 원자적 교체라 지우지 않아도 안전하다. - - # Setup providers and runner - provider = UpstageProvider() - judge_provider = OpenAIProvider() - dataset_loader = DatasetLoader(base_dir=cfg.projects_dir) - runner = BatchRunner( - store=store, - inference_provider=provider, - judge_provider=judge_provider, - dataset_loader=dataset_loader, - ) - - _run_with_progress( - runner, - run_id, - project_config, - task_config, - prompt, - max_workers, - limit=None, - # 이미 끝난 샘플은 건너뛴다 -- 안 넘기면 resume 이 전 샘플을 다시 유료 호출하고 - # results.jsonl 에 같은 sample_idx 를 중복 append 한다(F2). - completed_results=existing_results, - ) - - click.echo(f"\nResults saved to: {run_dir}/") - - -@runs_group.command("eval") -@click.argument("project") -@click.option( - "--run-id", - "-r", - required=True, - help="Run ID to evaluate", -) -@click.pass_context -def eval_run(ctx: click.Context, project: str, run_id: str) -> None: - """Run evaluation only on existing inference results (no new inference).""" - cfg = ctx.obj["config"] - if cfg.is_remote: - raise click.ClickException("Eval is local-mode only") - - from solar_eval.core.project_loader import load_all_project_configs - from solar_eval.core.runner import build_eval_sample_from_result - from solar_eval.evaluators.registry import create_evaluator - from solar_eval.stores import JsonlStore - - # Find run directory - run_dir = cfg.artifacts_dir(project) / run_id - if not run_dir.exists(): - raise click.ClickException(f"Run directory not found: {run_dir}") - - store = JsonlStore(run_dir) - run_meta = store.load_run_meta() - if not run_meta: - raise click.ClickException(f"No run.json found in {run_dir}") - - task = run_meta.get("task") - if not task: - raise click.ClickException("Cannot determine task from run.json") - - # Load results - results = store.load_existing_results() - if not results: - raise click.ClickException(f"No inference results found in {run_dir}/results.jsonl") - - click.secho(f"Evaluating run: {run_id}", fg="blue", bold=True) - click.secho(f" Task: {task}, Samples: {len(results)}", fg="blue") - - # Load task config for evaluator settings - configs = load_all_project_configs(cfg.projects_dir, cfg.config_dirs) - project_config = next((c for c in configs if c["name"] == project), None) - if not project_config: - raise click.ClickException(f"Project not found: {project}") - - task_config = next((t for t in project_config.get("tasks", []) if t["name"] == task), None) - if not task_config: - raise click.ClickException(f"Task '{task}' not found in project '{project}'") - - # 이전 평가를 미리 지우지 않는다(F3) -- 아래 재평가가 실패해도 옛 점수는 남는다. - - # Run evaluation - evaluator = create_evaluator(task_config.get("evaluator", {"type": "llm_judge"})) - # judge provider 를 안 넘기면 llm_judge 계열은 전 샘플에서 실패한다 -- 그리고 - # 그 실패가 예전엔 0점 placeholder 로 덮여 조용히 completed 로 끝났다(F8). - from solar_eval.providers.openai_provider import OpenAIProvider - - judge_provider = OpenAIProvider() - - from datetime import timezone - - async def run_eval(): - # 채점 실패는 점수가 아니라 실패로 남긴다 -- aggregate() 에는 성공분만 넘긴다. - eval_results: list[dict[str, Any]] = [] - eval_failures: list[dict[str, Any]] = [] - - for result in results: - # 리스트 위치가 아니라 원본 sample_idx 가 정본이다(F6). - sample_idx = result["sample_idx"] - try: - eval_sample = build_eval_sample_from_result(result, reference=result.get("golden")) - evaluator.validate_required_fields(eval_sample) - eval_result = await evaluator.evaluate(sample=eval_sample, provider=judge_provider) - eval_results.append({**eval_result, "sample_idx": sample_idx}) - except Exception as e: - click.secho(f" Sample {sample_idx} eval failed: {e}", fg="yellow") - eval_failures.append({"sample_idx": sample_idx, "error": str(e)}) - - # Aggregate and save - aggregated = evaluator.aggregate(eval_results) - # 성공한 채점이 하나도 없으면 점수 자리를 비운다 -- 0.0 은 "0점"이라는 측정값이라 - # "측정이 없었다"와 구분되지 않는다(runner.py 와 같은 규칙). - overall_score = aggregated.get("overall_score", 0.0) if eval_results else None - eval_id = await store.create_evaluation( - { - "run_id": run_id, - "scores": aggregated.get("scores", {}), - "overall_score": overall_score, - "eval_model": "local", - "eval_success_count": len(eval_results), - "eval_failed_count": len(eval_failures), - "failed_sample_indices": [f["sample_idx"] for f in eval_failures], - } - ) - - # Save per-sample eval details -- 성공/실패 둘 다 한 행씩 남긴다. - eval_detail_docs = [] - for er in eval_results: - eval_detail_docs.append( - { - "evaluation_id": eval_id, - "sample_idx": er["sample_idx"], - "category_scores": er.get("category_scores", {}), - "error_counts": { - k: v.get("error_count", 0) - for k, v in er.get("details", {}).items() - if isinstance(v, dict) - }, - "severity": er.get("severity", ""), - "score": er.get("score", 0.0), - } - ) - for f in eval_failures: - eval_detail_docs.append( - { - "evaluation_id": eval_id, - "sample_idx": f["sample_idx"], - "category_scores": {}, - "error_counts": {}, - "severity": None, - # 0.0 이 아니라 None -- 채점 실패를 최저 점수와 구분한다. - "score": None, - "error": f["error"], - } - ) - await store.insert_eval_details(eval_detail_docs) - - # Update run status - await store.update_run( - run_id, - { - "status": "completed", - "completed_at": datetime.now(timezone.utc), - }, - ) - - return {**aggregated, "overall_score": overall_score, "failed": len(eval_failures)} - - aggregated = asyncio.run(run_eval()) - - click.echo() - click.secho("Evaluation completed!", fg="green", bold=True) - overall = aggregated.get("overall_score") - if overall is None: - # 채점이 한 건도 성공하지 못했다 -- 숫자를 찍으면 "0점"으로 읽힌다. - click.secho(" Overall Score: 측정 없음 (전 샘플 채점 실패)", fg="red", bold=True) - else: - click.echo(f" Overall Score: {overall:.4f}") - failed = aggregated.get("failed", 0) - if failed: - click.secho(f" 채점 실패: {failed}건 (score: null 로 기록됨)", fg="yellow") - scores = aggregated.get("scores", {}) - if scores: - click.echo(f" Scores: {format_scores(scores)}") - click.echo(f"\nResults saved to: {run_dir}/") + 번들이 생긴 run 은 끝 다리 결과를, 번들이 없는 run 은 `skipped:no_bundle` 을 받는다(`hook._legs_in_lock` 과 같은 규칙). + """ + merged: list[HookResult | None] = [] + for result in results: + pending = result.legs.get(LEG_HUB) if result is not None else None + if result is None or pending is None or pending.code != BATCH_PENDING: + merged.append(result) + continue + leg = hub if hub is not None and bundle_ready(result.legs.get(LEG_BUNDLE)) else None + leg = leg or LegResult.skipped(NO_BUNDLE) + merged.append(replace(result, legs={**result.legs, LEG_HUB: leg})) + return merged @runs_group.command("results") @@ -1048,3 +496,8 @@ def show_results(ctx: click.Context, run_ref: str, limit: int | None) -> None: results = results[:limit] click.secho(format_results_table(results), fg="blue") click.echo(f"\nShowing {len(results)} result(s)") + + +# 떼어 낸 명령 등록 — `solar-eval runs resume|eval` 표면은 그대로다. +runs_group.add_command(resume_run) +runs_group.add_command(eval_run) diff --git a/solar_eval/cli/commands/score.py b/solar_eval/cli/commands/score.py new file mode 100644 index 0000000000000000000000000000000000000000..4ec0740a368d176895f77f54557b11c1a1c6c2e0 --- /dev/null +++ b/solar_eval/cli/commands/score.py @@ -0,0 +1,187 @@ +"""`solar-eval score` — 오프라인 재채점 명령 그룹 (계획 2026-09-26 L3b §2.1). + +`score proofread-diff` 는 run 번들의 `results.jsonl` 을 레거시 `lcs_diff` 와 같은 함수로 다시 채점해 채점 번들 +(`poc-scoring/v1`, `purpose=rescore_after_fix`)로 낸다. 두 모드: + +- `…` 모드: 번들이 `…/eval/bundles/` 에 있으면 기본 출력은 `…/eval/evaluations`, 아니면 `--out` 필수. + `--mirror` 면 각 번들 매니페스트 `case` 의 미러 `evaluations` 에 놓는다. +- `--case a/c --all` 모드: 미러의 그 case run 번들 전부(`poc_bundle.adopt` 로 채택). `--mirror` 또는 `--out` 이 필요하다. + `--run `(반복)를 주면 그 run 들만(계획 2026-09-27 L6 §2.11 — 채택 규칙은 같고, 영역에 없는 run 은 오류 + `run_not_in_mirror`). `--run` 은 이 모드에서만 받고, 빈 값·공백만·제어 문자·512자 초과는 사용 오류다(ES + `hub ingest --expect-run` 과 같은 범위 — 없는 run 이 stderr 에 그대로 찍히므로 줄 위조를 막는다). +- `--out DIR` 과 `--mirror` 는 함께 쓰지 않는다. `--mirror-root` 는 미러 **버킷의 부모**다(§2.1 "미러 루트 층"). + 디렉토리 모드에서는 `--mirror` 와 함께만 받는다. 없는 run 번들 경로는 사용 오류(종료 2)다. + +stdout 은 run 마다 한 줄 `written ` · `exists ` · `skip ` 이고, case 모드는 마지막 줄에 JSON 요약 +`{written, exists, skipped:{code:n}, errors, n_original_missing}` 을 낸다. 오류는 stderr 에 `error ` 한 줄씩 +(case 전체 오류는 run_id 자리에 `-`, `case_area_missing`·`case_area_empty` 는 뒤에 해석한 영역 경로를 붙인다). +어느 쪽에도 입력·출력·정답 원문은 싣지 않는다. + +종료 코드: 0 = 전부 written/exists/규칙 skip · 1 = 오류 1건 이상 · 2 = 사용 오류. + +import 경계(D1): 이 모듈은 click 만 모듈 최상위에서 import 한다. `solar_eval.rescore`(그리고 그것이 끌어오는 +`poc_bundle`)는 **명령 함수 안에서** import 한다 — `solar-eval` CLI 를 띄우는 것만으로 재채점기·번들 writer 가 +로딩되지 않게. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any + +import click + +_DIR = click.Path(file_okay=False, path_type=Path) +# 입력 run 번들은 있어야 한다 — 경로 오타가 규칙 skip(`latest_bundle_invalid`)·종료 0 으로 끝나지 않게 click 이 종료 2 로 막는다. +_EXISTING_DIR = click.Path(exists=True, file_okay=False, path_type=Path) +EXIT_ERRORS = 1 +EXIT_USAGE = 2 + + +@click.group("score") +def score_group() -> None: + """Offline rescoring of run bundles (scoring bundles, poc-scoring/v1).""" + + +@score_group.command("proofread-diff") +@click.argument("run_bundle_dirs", nargs=-1, type=_EXISTING_DIR) +@click.option("--case", "case_key", default=None, help="accounts// as a/c (with --all).") +@click.option("--all", "all_runs", is_flag=True, default=False, help="Every adopted run bundle.") +@click.option("--out", "out_dir", type=_DIR, default=None, help="Parent dir for scoring bundles.") +@click.option("--mirror", is_flag=True, default=False, help="Write into the local mirror.") +@click.option( + "--mirror-root", + type=_DIR, + default=None, + help="Parent of the mirror bucket (default: data home blob-mirror).", +) +@click.option( + "--run", + "run_ids", + multiple=True, + help="Only this run_id (repeatable; with --case --all).", +) +def proofread_diff( + run_bundle_dirs: tuple[Path, ...], + case_key: str | None, + all_runs: bool, + out_dir: Path | None, + mirror: bool, + mirror_root: Path | None, + run_ids: tuple[str, ...], +) -> None: + """Rescore run bundles with proofread_diff@2 (same scoring as legacy lcs_diff).""" + _check_usage(run_bundle_dirs, case_key, all_runs, out_dir, mirror, mirror_root, run_ids) + if case_key is not None: + code = _run_case(case_key, out_dir, mirror_root, frozenset(run_ids) or None) + else: + code = _run_dirs(run_bundle_dirs, out_dir, mirror_root if mirror else None, mirror) + sys.exit(code) + + +def _usage(message: str) -> None: + click.echo(f"usage error: {message}", err=True) + sys.exit(EXIT_USAGE) + + +def _check_usage( + dirs: tuple[Path, ...], + case_key: str | None, + all_runs: bool, + out_dir: Path | None, + mirror: bool, + mirror_root: Path | None, + run_ids: tuple[str, ...] = (), +) -> None: + """두 모드의 인자 조합 — 어긋나면 종료 2.""" + if out_dir is not None and mirror: + _usage("--out and --mirror are mutually exclusive") + if case_key is not None or all_runs: + if dirs: + _usage("give run bundle directories or --case/--all, not both") + if case_key is None or not all_runs: + _usage("--case a/c needs --all (and --all needs --case)") + if out_dir is None and not mirror: + _usage("--case --all needs --mirror or --out DIR") + elif not dirs: + _usage("give run bundle directories, or --case a/c --all") + elif mirror_root is not None and not mirror: + # 디렉토리 모드에서 --mirror-root 는 --mirror 의 출력 자리일 뿐이다 — 조용히 버리지 않는다 + _usage("--mirror-root needs --mirror when rescoring run bundle directories") + if run_ids: + _check_run_filter(case_key, run_ids) + + +def _check_run_filter(case_key: str | None, run_ids: tuple[str, ...]) -> None: + """`--run` 은 case 모드의 run 필터다(L6 §2.11) — 디렉토리 모드에서 조용히 버리지 않고, 값은 경계에서 거른다. + + case 모드의 `--case`/`--all` 짝은 `_check_usage` 가 먼저 막으므로 여기서 `case_key is None` 이면 디렉토리 모드다. + 값 규칙은 라이브러리 경계(`rescore_case`)와 같은 `is_valid_run_id` 다. 거부 문구에 값 원문은 싣지 않는다. + """ + from solar_eval.rescore.case import MAX_RUN_ID_LENGTH, is_valid_run_id + + if case_key is None: + _usage("--run needs --case a/c --all (not run bundle directories)") + if not all(is_valid_run_id(r) for r in run_ids): + _usage( + "--run needs a non-blank run_id without control characters," + f" at most {MAX_RUN_ID_LENGTH} characters" + ) + + +def _run_case( + case_key: str, + out_dir: Path | None, + mirror_root: Path | None, + run_ids: frozenset[str] | None = None, +) -> int: + from solar_eval.rescore.case import rescore_case + from solar_eval.rescore.paths import RescorePathError, parse_case_key + + try: + account, case = parse_case_key(case_key) + except RescorePathError as exc: + _usage(str(exc)) + summary = rescore_case(account, case, mirror_root=mirror_root, out_dir=out_dir, run_ids=run_ids) + for outcome in summary.outcomes: + _echo_outcome(outcome) + for failure in summary.failures: + suffix = f" {failure.detail}" if failure.detail else "" + click.echo(f"error {failure.run_id or '-'} {failure.code}{suffix}", err=True) + report: dict[str, Any] = summary.to_json() + click.echo(json.dumps(report, ensure_ascii=False, sort_keys=False)) + return EXIT_ERRORS if report["errors"] else 0 + + +def _run_dirs( + dirs: tuple[Path, ...], out_dir: Path | None, mirror_root: Path | None, mirror: bool +) -> int: + from solar_eval.rescore.evaluator_doc import current_evaluator_document + from solar_eval.rescore.from_bundle import RescoreError, rescore_run_bundle + from solar_eval.rescore.paths import default_evaluations_dir, resolve_mirror_root + + targets = [(d, out_dir or (None if mirror else default_evaluations_dir(d))) for d in dirs] + missing = [d for d, target in targets if target is None and not mirror] + if missing: + _usage(f"{missing[0]} is not under …/eval/bundles/ — give --out DIR") + root = resolve_mirror_root(mirror_root) if mirror else None + evaluator = current_evaluator_document() + errors = 0 + for bundle_dir, target in targets: + try: + outcome = rescore_run_bundle(bundle_dir, target, mirror_root=root, evaluator=evaluator) + except RescoreError as exc: + click.echo(f"error {exc.run_id or bundle_dir.name} {exc.code}", err=True) + errors += 1 + continue + _echo_outcome(outcome, fallback=bundle_dir.name) + return EXIT_ERRORS if errors else 0 + + +def _echo_outcome(outcome: Any, fallback: str = "-") -> None: + if outcome.status == "skip": + click.echo(f"skip {outcome.run_id or fallback} {outcome.code}") + else: + click.echo(f"{outcome.status} {outcome.path}") diff --git a/solar_eval/cli/commands/sync.py b/solar_eval/cli/commands/sync.py index 93644bb952ffde8f47d32da690a772dd03495509..8d62454e238237ab07d6ee7c3f51b2db7667e155 100644 --- a/solar_eval/cli/commands/sync.py +++ b/solar_eval/cli/commands/sync.py @@ -17,7 +17,7 @@ logger = logging.getLogger(__name__) @click.option( "--mongo-uri", envvar="MONGODB_URI", - default="mongodb://localhost:27017", + default="mongodb://localhost:57017", help="MongoDB connection URI", ) @click.option("--db", "database", default="solar_eval", help="MongoDB database name") @@ -122,9 +122,10 @@ def _sync_single_run(store, project: str, run_dir: Path, cfg) -> dict[str, int]: "input": result.get("input", {}), "output": result.get("output", ""), "golden": result.get("golden", ""), - "input_tokens": result.get("input_tokens", 0), - "output_tokens": result.get("output_tokens", 0), - "inference_time_ms": result.get("inference_time_ms", 0), + # 사용량·지연이 없는 행은 None 그대로 -- 0 을 지어내지 않는다(L6b B1). + "input_tokens": result.get("input_tokens"), + "output_tokens": result.get("output_tokens"), + "inference_time_ms": result.get("inference_time_ms"), } ) sample_count = store.bulk_insert_samples(samples) diff --git a/solar_eval/cli/config.py b/solar_eval/cli/config.py index c0df5ada87347cf21bb6fae92628c0fc3f0febd3..1f0aa6578535d62bfb8a1ad600443ce9d282c4b8 100644 --- a/solar_eval/cli/config.py +++ b/solar_eval/cli/config.py @@ -1,28 +1,155 @@ -"""CLI configuration from YAML file and environment variables.""" +"""CLI configuration from YAML file and environment variables. +**`hub:` 절(계획 2026-09-28 L10 §2.6 · D-L10-6).** 사용자 설정 파일의 `hub.database_url`·`hub.confirm` 은 run 종료 훅의 hub +다리 opt-in 이다. 여기서는 모양만 읽어 `CLIConfig.hub`(`HubConfig`)에 두고, 환경 변수와의 우선순위·에이전트 세션 판정은 +`solar_eval.hub_hook.optin.resolve_hub_optin` 한 곳이 한다. 모양 오류(매핑이 아님·URL 모양이 아님·모르는 키·설정 파일이 +소유자 전용 권한이 아님)는 `problems` 에 **키 이름만** 싣는다 — 값은 로그·예외·`repr` 어디에도 없다. +""" + +import functools import logging import os +import stat +from collections.abc import Mapping +from dataclasses import dataclass, field from pathlib import Path from typing import Any import yaml -DEFAULT_CONFIG_PATH = Path.home() / ".upstage-cloud-dev" / "solar-eval-config.yaml" +#: 2026-10-02 전 위치 — 새 경로에 파일이 없을 때만 경고와 함께 읽는다(`make dev-docker-up` 이 새 경로로 복사한다). +LEGACY_CONFIG_PATH = Path.home() / ".upstage-cloud-dev" / "solar-eval-config.yaml" + + +def _config_home() -> Path: + """레포 사용자 설정 홈 — `CBS_ARSENAL_CONFIG_HOME` > `$XDG_CONFIG_HOME/cbs-arsenal` > `~/.config/cbs-arsenal`. + + Go `internal/config.ConfigHome` 과 같은 규칙이다(monorepo-conventions.md "사용자 설정 홈"). + """ + override = os.environ.get("CBS_ARSENAL_CONFIG_HOME") + if override: + return Path(override) + xdg = os.environ.get("XDG_CONFIG_HOME") + return Path(xdg) / "cbs-arsenal" if xdg else Path.home() / ".config" / "cbs-arsenal" + + +DEFAULT_CONFIG_PATH = _config_home() / "solar-eval.yaml" + +#: `hub:` 절이 받는 키. +HUB_CONFIG_KEYS = ("database_url", "confirm") +#: `hub.database_url` 이 받는 URL 모양(hub CLI `connect.ts` 와 같은 두 스킴). +HUB_URL_SCHEMES = ("postgres://", "postgresql://") +#: 그룹·다른 사용자 권한 비트 — 자격증명 URL 을 담은 설정 파일은 소유자만 읽고 써야 한다(ssh 키와 같은 규칙). +LOOSE_MODE_BITS = stat.S_IRWXG | stat.S_IRWXO +#: 문제 목록에 싣는 키 이름 길이 상한(값이 아니라 이름이지만 길게 싣지 않는다). +_KEY_NAME_MAX = 40 logger = logging.getLogger(__name__) +@dataclass(frozen=True) +class HubConfig: + """설정 파일 `hub:` 절 — 값은 `repr` 에 싣지 않는다. `problems` 가 있으면 설정 출처 opt-in 은 꺼진다(optin 이 판정).""" + + database_url: str | None = field(default=None, repr=False) + confirm: str | None = field(default=None, repr=False) + problems: tuple[str, ...] = () + + +def config_file_path(config_path: Path | None = None) -> Path: + """읽을 설정 파일 — 명시 경로 > `SOLAR_EVAL_CONFIG` > 기본 경로(`<설정 홈>/solar-eval.yaml`) > 옛 경로(있을 때만, 경고). + + 기본 경로에 파일이 없고 옛 `~/.upstage-cloud-dev/solar-eval-config.yaml` 이 있으면 옛 것을 읽는다 — 옮기기 전의 설정이 + 조용히 사라지지 않게. 둘 다 없으면 기본 경로(없는 파일 = 빈 설정)다. + """ + if config_path: + return config_path + env_path = os.environ.get("SOLAR_EVAL_CONFIG") + if env_path is not None: + return Path(env_path) + if DEFAULT_CONFIG_PATH.exists() or not LEGACY_CONFIG_PATH.exists(): + return DEFAULT_CONFIG_PATH + _warn_legacy_once(LEGACY_CONFIG_PATH, DEFAULT_CONFIG_PATH) + return LEGACY_CONFIG_PATH + + +@functools.cache +def _warn_legacy_once(legacy: Path, primary: Path) -> None: + """옛 경로 경고는 경로 쌍마다 프로세스당 한 번 — `CLIConfig` 가 여러 번 만들어져도 반복하지 않는다.""" + logger.warning( + "옛 설정 경로 %s 를 읽는다 — %s 로 옮긴다(make dev-docker-up 이 복사한다)", legacy, primary + ) + + +def parse_hub(yaml_cfg: Mapping[str, Any], path: Path | None = None) -> HubConfig | None: + """`hub:` 절 → `HubConfig`(없거나 비었으면 None). 모양 오류는 값 없이 `problems` 로(§2.6 "설정 모양 오류").""" + raw = yaml_cfg.get("hub") + if raw is None: + return None + if not isinstance(raw, Mapping): + return HubConfig(problems=("hub: 매핑이 아니다",)) + problems = [f"hub.{_key_name(key)}: 모르는 키" for key in raw if key not in HUB_CONFIG_KEYS] + url = _hub_url(raw.get("database_url"), problems) + confirm = _hub_confirm(raw.get("confirm"), problems) + if url is not None and path is not None: + problems += _permission_problems(path) + return HubConfig(url, confirm, tuple(problems)) + + +def _key_name(key: Any) -> str: + return str(key)[:_KEY_NAME_MAX] if isinstance(key, str) else f"<{type(key).__name__}>" + + +def _hub_url(value: Any, problems: list[str]) -> str | None: + """URL 문자열(앞뒤 공백 제거). 모양이 틀려도 문자열이면 돌려준다 — 끈 설정의 값도 가림·거부 대상이다.""" + if value is None: + return None + if not isinstance(value, str): + problems.append("hub.database_url: 문자열이 아니다") + return None + url = value.strip() + if not url: + return None + if not url.startswith(HUB_URL_SCHEMES): + problems.append("hub.database_url: postgres:// 또는 postgresql:// URL 이 아니다") + return url + + +def _hub_confirm(value: Any, problems: list[str]) -> str | None: + if value is None: + return None + if not isinstance(value, str): + problems.append("hub.confirm: 문자열이 아니다") + return None + return value.strip() or None + + +def _permission_problems(path: Path) -> list[str]: + """자격증명 URL 을 담은 설정 파일이 그룹·다른 사용자에게 열려 있으면 문제 한 줄(값·경로 없음).""" + if os.name == "nt": # POSIX 권한 비트가 뜻을 갖지 않는다 + return [] + try: + mode = path.stat().st_mode + except OSError as exc: + return [f"설정 파일 권한을 확인할 수 없다({type(exc).__name__})"] + if mode & LOOSE_MODE_BITS: + return ["설정 파일이 소유자 전용이 아니다 — chmod 600 <설정 파일>"] + return [] + + def default_projects_dir() -> Path: """CBS 공용 데이터 홈 아래의 프로젝트 루트 (huggingface 캐시와 같은 방식). - 기본값: ``~/.cache/cbs-data/solar-eval-projects`` - ``CBS_DATA_HOME`` 이 설정되면 데이터 홈 자체를 옮긴다: ``$CBS_DATA_HOME/solar-eval-projects`` - ``XDG_CACHE_HOME`` 이 설정되면 캐시 루트로 존중한다: ``$XDG_CACHE_HOME/cbs-data/solar-eval-projects`` + - 빈 ``XDG_CACHE_HOME`` 은 XDG 규약대로 없는 것으로 본다 — ``poc_bundle.home.data_home`` 과 같은 규칙이다 + (계획 2026-09-27 L6 Q-L6-17 — 전에는 상대 경로 ``cbs-data/…`` 로 풀려 호출 위치마다 달랐다). """ data_home = os.environ.get("CBS_DATA_HOME") if data_home: return Path(data_home).expanduser() / "solar-eval-projects" - cache_root = os.environ.get("XDG_CACHE_HOME", "~/.cache") + cache_root = os.environ.get("XDG_CACHE_HOME") or "~/.cache" return Path(cache_root).expanduser() / "cbs-data" / "solar-eval-projects" @@ -66,7 +193,7 @@ def discover_config_dirs(repo_root: Path | None) -> dict[str, Path]: def _load_yaml_config(config_path: Path | None = None) -> dict[str, Any]: """YAML 설정 파일 로드. 파일 없으면 빈 dict 반환.""" - path = config_path or Path(os.environ.get("SOLAR_EVAL_CONFIG", str(DEFAULT_CONFIG_PATH))) + path = config_file_path(config_path) if not path.is_file(): return {} try: @@ -74,7 +201,8 @@ def _load_yaml_config(config_path: Path | None = None) -> dict[str, Any]: data = yaml.safe_load(f) return data if isinstance(data, dict) else {} except Exception as e: - logger.warning("Failed to load config from %s: %s", path, e) + # 예외 원문은 싣지 않는다 — YAML 오류 문구가 줄 조각(자격증명 URL 포함)을 담을 수 있다(L10 §2.6 "비밀"). + logger.warning("Failed to load config from %s: %s", path, type(e).__name__) return {} @@ -83,13 +211,16 @@ class CLIConfig: Priority (low → high): 1. Hard-coded defaults (projects_dir: ~/.cache/cbs-data/solar-eval-projects) - 2. YAML config file (~/.upstage-cloud-dev/solar-eval-config.yaml) - 3. Environment variables (SOLAR_EVAL_URL, CBS_DATA_HOME, SOLAR_EVAL_PROJECTS, etc.) + 2. YAML config file (~/.config/cbs-arsenal/solar-eval.yaml, 옛 ~/.upstage-cloud-dev/solar-eval-config.yaml) — `hub:` 절 포함(모듈 설명) + 3. Environment variables (SOLAR_EVAL_URL, CBS_DATA_HOME, SOLAR_EVAL_PROJECTS, HUB_DATABASE_URL, etc.) 4. CLI options (--remote, etc.) """ def __init__(self, config_path: Path | None = None) -> None: - yaml_cfg = _load_yaml_config(config_path) + path = config_file_path(config_path) + yaml_cfg = _load_yaml_config(path) + #: run 종료 훅 hub 다리 opt-in(설정 출처) — 해석은 `hub_hook.optin.resolve_hub_optin`. + self.hub: HubConfig | None = parse_hub(yaml_cfg, path) yaml_remote = yaml_cfg.get("remote_url") or yaml_cfg.get("remote") self.remote_url: str | None = os.environ.get("SOLAR_EVAL_URL", yaml_remote) @@ -126,6 +257,10 @@ class CLIConfig: if openai_cfg.get("api_key") and not os.environ.get("OPENAI_API_KEY"): os.environ["OPENAI_API_KEY"] = openai_cfg["api_key"] + timely_cfg = yaml_cfg.get("timely", {}) + if timely_cfg.get("api_key") and not os.environ.get("TIMELY_API_KEY"): + os.environ["TIMELY_API_KEY"] = timely_cfg["api_key"] + @property def is_remote(self) -> bool: return self.remote_url is not None diff --git a/solar_eval/cli/formatters.py b/solar_eval/cli/formatters.py index aece2d8da0bc0cadfa60f8983a14f51a605648b8..b48f78372898fee590a45b9cbe2761abf5692d04 100644 --- a/solar_eval/cli/formatters.py +++ b/solar_eval/cli/formatters.py @@ -29,36 +29,69 @@ def format_overall_score(score: float | None) -> str: def format_projects_table(projects: list[dict[str, Any]]) -> str: - rows = [[ - p.get("name", ""), - p.get("display_name", ""), - p.get("description", "")[:50], - p.get("task_count", 0), - ] for p in projects] - return tabulate(rows, headers=["Name", "Display Name", "Description", "Tasks"], tablefmt="simple") + rows = [ + [ + p.get("name", ""), + p.get("display_name", ""), + p.get("description", "")[:50], + p.get("task_count", 0), + ] + for p in projects + ] + return tabulate( + rows, headers=["Name", "Display Name", "Description", "Tasks"], tablefmt="simple" + ) def format_prompts_table(prompts: list[dict[str, Any]]) -> str: - rows = [[ - p.get("id", "")[:12], - p.get("task", ""), - f"v{p.get('version', '?')}", - p.get("model", ""), - p.get("temperature", ""), - (p.get("system_prompt", "") or "")[:40] + "...", - ] for p in prompts] - return tabulate(rows, headers=["ID", "Task", "Version", "Model", "Temp", "Prompt Preview"], tablefmt="simple") + rows = [ + [ + p.get("id", "")[:12], + p.get("task", ""), + f"v{p.get('version', '?')}", + p.get("model", ""), + p.get("temperature", ""), + (p.get("system_prompt", "") or "")[:40] + "...", + ] + for p in prompts + ] + return tabulate( + rows, + headers=["ID", "Task", "Version", "Model", "Temp", "Prompt Preview"], + tablefmt="simple", + ) def format_runs_table(runs: list[dict[str, Any]]) -> str: - rows = [[ - r.get("id", "")[:12], - r.get("task", ""), - format_status(r.get("status", "")), - f"{r.get('completed_samples', 0)}/{r.get('total_samples', 0)}", - r.get("created_at", "")[:19], - ] for r in runs] - return tabulate(rows, headers=["ID", "Task", "Status", "Progress", "Created"], tablefmt="simple") + rows = [ + [ + r.get("id", "")[:12], + r.get("task", ""), + format_status(r.get("status", "")), + f"{r.get('completed_samples', 0)}/{r.get('total_samples', 0)}", + r.get("created_at", "")[:19], + ] + for r in runs + ] + return tabulate( + rows, headers=["ID", "Task", "Status", "Progress", "Created"], tablefmt="simple" + ) + + +# 사용량·지연이 측정되지 않은 칸(`None`) -- 0 과 구분되게 찍는다(L6b B1 · D-M2-24). +MISSING_CELL = "—" + + +def _usage_cell(value: Any) -> Any: + """토큰 칸 -- `None` 이면 `—`, 그 밖은 그대로(키가 아예 없을 때의 빈 칸 포함).""" + return MISSING_CELL if value is None else value + + +def format_latency(value: Any) -> str: + """지연 칸 -- 숫자일 때만 `…ms`, 그 밖(`None`·키 없음·문자열·불리언)은 `—`.""" + if isinstance(value, bool) or not isinstance(value, (int, float)): + return MISSING_CELL + return f"{value:.0f}ms" def format_results_table(results: list[dict[str, Any]]) -> str: @@ -66,15 +99,19 @@ def format_results_table(results: list[dict[str, Any]]) -> str: for r in results: inp = str(r.get("input", ""))[:40] out = str(r.get("output", ""))[:40] - rows.append([ - r.get("sample_idx", ""), - inp + ("..." if len(inp) >= 40 else ""), - out + ("..." if len(out) >= 40 else ""), - r.get("input_tokens", ""), - r.get("output_tokens", ""), - f"{r.get('inference_time_ms', 0):.0f}ms", - ]) - return tabulate(rows, headers=["#", "Input", "Output", "In Tok", "Out Tok", "Time"], tablefmt="simple") + rows.append( + [ + r.get("sample_idx", ""), + inp + ("..." if len(inp) >= 40 else ""), + out + ("..." if len(out) >= 40 else ""), + _usage_cell(r.get("input_tokens", "")), + _usage_cell(r.get("output_tokens", "")), + format_latency(r.get("inference_time_ms")), + ] + ) + return tabulate( + rows, headers=["#", "Input", "Output", "In Tok", "Out Tok", "Time"], tablefmt="simple" + ) def format_evaluation_detail(evaluation: dict[str, Any]) -> str: @@ -91,14 +128,21 @@ def format_evaluation_detail(evaluation: dict[str, Any]) -> str: def format_dashboard_table(dashboard: dict[str, Any]) -> str: - rows = [[ - r.get("run_id", "")[:12], - r.get("task", ""), - f"v{r.get('prompt_version', '?')}", - r.get("model", ""), - format_overall_score(r.get("overall_score")), - format_scores(r.get("scores")), - r.get("total_samples", ""), - r.get("date", "")[:10], - ] for r in dashboard.get("runs", [])] - return tabulate(rows, headers=["Run ID", "Task", "Version", "Model", "Score", "Metrics", "Samples", "Date"], tablefmt="simple") + rows = [ + [ + r.get("run_id", "")[:12], + r.get("task", ""), + f"v{r.get('prompt_version', '?')}", + r.get("model", ""), + format_overall_score(r.get("overall_score")), + format_scores(r.get("scores")), + r.get("total_samples", ""), + r.get("date", "")[:10], + ] + for r in dashboard.get("runs", []) + ] + return tabulate( + rows, + headers=["Run ID", "Task", "Version", "Model", "Score", "Metrics", "Samples", "Date"], + tablefmt="simple", + ) diff --git a/solar_eval/cli/main.py b/solar_eval/cli/main.py index bea6a7de3c78a23cc1ff831317c99156f07d8c62..b5c901f701048fded3808b3625a0ec8ac70e5fd2 100644 --- a/solar_eval/cli/main.py +++ b/solar_eval/cli/main.py @@ -59,6 +59,7 @@ from solar_eval.cli.commands.runs import runs_group from solar_eval.cli.commands.eval import eval_group from solar_eval.cli.commands.dashboard import dashboard_group from solar_eval.cli.commands.datasets import datasets_group +from solar_eval.cli.commands.score import score_group from solar_eval.cli.commands.compare import compare_command from solar_eval.cli.commands.quickrun import quick_run @@ -73,6 +74,8 @@ solar_eval_cli.add_command(datasets_group) solar_eval_cli.add_command(compare_command) solar_eval_cli.add_command(quick_run) solar_eval_cli.add_command(sync_to_mongodb) +# 오프라인 재채점(`score proofread-diff`) — 계획 2026-09-26 L3b §2.1. +solar_eval_cli.add_command(score_group) def entrypoint() -> None: diff --git a/solar_eval/config.py b/solar_eval/config.py index 679d9ccb85fecc6ae7465b0833f92a7adae1e6b8..921d9afa2aebcd8d342bb8eee022bb7ad380a53f 100644 --- a/solar_eval/config.py +++ b/solar_eval/config.py @@ -7,6 +7,9 @@ class ProviderConfig(BaseSettings): upstage_api_key: str = "" upstage_api_base: str = "https://api.upstage.ai/v1" openai_api_key: str = "" + # Timely Router (OpenAI 호환 멀티모델 라우터) — 스텝 `model: timely:<모델>` 이 쓴다 + timely_api_key: str = "" + timely_api_base: str = "https://api.timelyrouter.ai/v1" hf_token: str = "" default_model: str = "solar-pro2" diff --git a/solar_eval/core/__pycache__/__init__.cpython-312.pyc b/solar_eval/core/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..452334e170db0b4b51bef04de4a1d7147bdab1ed Binary files /dev/null and b/solar_eval/core/__pycache__/__init__.cpython-312.pyc differ diff --git a/solar_eval/core/__pycache__/dataset_loader.cpython-312.pyc b/solar_eval/core/__pycache__/dataset_loader.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c602155b3ad6d716b02079198175faa33a8bc8e3 Binary files /dev/null and b/solar_eval/core/__pycache__/dataset_loader.cpython-312.pyc differ diff --git a/solar_eval/core/__pycache__/pipeline_compose.cpython-312.pyc b/solar_eval/core/__pycache__/pipeline_compose.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..52055932813ecf6cb8fc5cbcf901e73e926be9dd Binary files /dev/null and b/solar_eval/core/__pycache__/pipeline_compose.cpython-312.pyc differ diff --git a/solar_eval/core/dataset_loader.py b/solar_eval/core/dataset_loader.py index 0671e7567b79b32f33deef074681c7de4b7bf7ca..4dffb258bd8f6cede138df3a19536bb579b51524 100644 --- a/solar_eval/core/dataset_loader.py +++ b/solar_eval/core/dataset_loader.py @@ -1,6 +1,9 @@ import csv +import hashlib +import io import json import logging +from dataclasses import dataclass from pathlib import Path from huggingface_hub import hf_hub_download, list_repo_files @@ -10,6 +13,34 @@ from solar_eval.config import get_provider_config logger = logging.getLogger(__name__) +@dataclass(frozen=True) +class LoadedJsonl: + """한 번 읽은 JSONL -- 파싱한 레코드와 **같은 바이트**의 sha256 (계획 D16). + + `sha256` 은 64자 소문자 hex 다(`sha256:` 접두어 없음). 파일 sha 와 파싱이 서로 + 다른 read 에서 나오면 그 사이에 파일이 바뀌었을 때 run.json 이 거짓을 적는다. + """ + + records: list[dict] + sha256: str + local_path: Path + + +def parse_jsonl_bytes(data: bytes) -> list[dict]: + """JSONL 바이트를 레코드 목록으로. 오늘 `open()` 반복과 **같은 줄 규칙**이다. + + universal newlines(`\n`·`\r\n`·단독 `\r` 모두 줄 끝)로 줄을 나누고, `strip()` 뒤 + 빈 줄은 건너뛴다 -- `sample_idx`(레코드 위치)가 옛 run 과 어긋나지 않아야 한다. + """ + records = [] + with io.TextIOWrapper(io.BytesIO(data), encoding="utf-8") as f: + for line in f: + line = line.strip() + if line: + records.append(json.loads(line)) + return records + + class DatasetLoader: """Loads datasets from HuggingFace repos or local filesystem.""" @@ -37,24 +68,34 @@ class DatasetLoader: return self.base_dir / repo / path return repo_path / path - def load_jsonl( + def read_jsonl( self, repo: str, path: str, source: str = "huggingface", force: bool = False - ) -> list[dict]: - """Load and parse a JSONL file from HuggingFace or local filesystem.""" + ) -> LoadedJsonl: + """JSONL 을 **바이트로 한 번** 읽어 sha256 과 레코드를 함께 돌려준다 (D16). + + sha 는 `hashlib` 로 계산한다 -- 이 모듈은 벤더 사본(HF Space)에 실리므로 + poc-bundle 패키지를 가져오지 않는다(D18). + """ if source == "local": local_path = self._resolve_local_path(repo, path) if not local_path.exists(): raise FileNotFoundError(f"Local dataset not found: {local_path}") else: local_path = self.download_file(repo, path, force=force) - records = [] - with open(local_path, encoding="utf-8") as f: - for line in f: - line = line.strip() - if line: - records.append(json.loads(line)) + data = Path(local_path).read_bytes() + records = parse_jsonl_bytes(data) logger.info(f"Loaded {len(records)} records from {local_path}") - return records + return LoadedJsonl( + records=records, + sha256=hashlib.sha256(data).hexdigest(), + local_path=Path(local_path), + ) + + def load_jsonl( + self, repo: str, path: str, source: str = "huggingface", force: bool = False + ) -> list[dict]: + """Load and parse a JSONL file from HuggingFace or local filesystem.""" + return self.read_jsonl(repo, path, source=source, force=force).records def load_csv( self, repo: str, path: str, source: str = "huggingface", force: bool = False diff --git a/solar_eval/core/dataset_prep.py b/solar_eval/core/dataset_prep.py new file mode 100644 index 0000000000000000000000000000000000000000..969085b95e2fb8647a9cb5623b33fe89065507ff --- /dev/null +++ b/solar_eval/core/dataset_prep.py @@ -0,0 +1,169 @@ +"""run 시작 전 데이터셋 준비 -- 한 번 읽고, 고르고, 행 메타 규칙을 검증한다 (계획 D3·D13·D16). + +CLI(`runs start/batch`)는 run 디렉토리를 만들기 **전에** `prepare_dataset` 을 불러 +0행 선택·라벨 누락·`meta_fields` 오류를 먼저 터뜨리고, 같은 `PreparedDataset` 을 +러너(`execute_run(prepared=...)`)와 run.json 기록(sha256·행 수·선택)에 함께 넘긴다. +그래서 run.json 의 sha256 과 실제로 돈 행이 **같은 바이트**에서 나온다. + +러너는 `prepared` 가 없으면(서비스·옛 호출자) 같은 함수로 스스로 준비한다. + +이 모듈은 poc-bundle 패키지와 packaging 을 가져오지 않는다(D18). +""" + +from __future__ import annotations + +import copy +from dataclasses import dataclass +from typing import Any, Mapping + +from solar_eval.core.dataset_loader import DatasetLoader, LoadedJsonl +from solar_eval.core.row_filter import ( + RowSelection, + SelectionError, + Where, + combine, + format_indices, + parse_where, + select, +) + + +class DatasetPrepError(ValueError): + """태스크의 행 메타·where 선언이 규칙에 맞지 않을 때 -- run 을 시작하지 않는다.""" + + +@dataclass(frozen=True) +class PreparedDataset: + """run 이 돌 행 집합. `selected` 는 `(원본 파일의 0-based 위치, 행)` 튜플들이다.""" + + loaded: LoadedJsonl + selected: tuple[tuple[int, dict[str, Any]], ...] + rows_total: int + meta_fields: tuple[str, ...] | None + selection: RowSelection | None = None + + @property + def rows_selected(self) -> int: + return len(self.selected) + + @property + def sha256(self) -> str: + return self.loaded.sha256 + + @property + def selected_indices(self) -> frozenset[int]: + return frozenset(idx for idx, _ in self.selected) + + +def reject_defaults_where(project_config: Mapping[str, Any]) -> None: + """`defaults.where` 를 거부한다 (D1). + + `_resolve_defaults` 는 키 단위 얕은 병합이라 `defaults.where` 가 라벨 없는 태스크에도 + 퍼져 그 run 이 전부 죽고, 태스크에 where 가 있으면 통째로 무시된다("병합"이 아니라 + "대체"). 로드된 dict 에 `defaults` 키가 병합 뒤에도 남아 있으므로 그것을 본다. + 로드 시점이 아니라 run 시작 시 검사한다 -- 한 프로젝트의 잘못된 선언이 다른 + 프로젝트 로드를 막지 않게. + """ + defaults = project_config.get("defaults") or {} + if isinstance(defaults, Mapping) and "where" in defaults: + name = project_config.get("name", "?") + raise DatasetPrepError( + f"project {name!r}: defaults.where is not allowed -- " + "declare where on a task or pass --where on the CLI" + ) + + +def _data_keys(task_config: Mapping[str, Any]) -> set[str]: + """입력·정답으로 이미 쓰는 데이터 행 키 -- meta 로 중복 복제하지 않는다.""" + keys = set(task_config.get("input_fields") or []) + golden_field = task_config.get("golden_field") + if golden_field: + keys.add(golden_field) + golden_fields = task_config.get("golden_fields") or {} + if isinstance(golden_fields, Mapping): + keys.update(v for v in golden_fields.values() if isinstance(v, str)) + return keys + + +def validate_meta_fields(task_config: Mapping[str, Any]) -> tuple[str, ...] | None: + """태스크 `meta_fields` 검증 (D13) -- 비어 있지 않은 문자열 리스트, 중복·입력/정답 겹침 불가. + + 선언이 없으면 `None`(results 행에 `meta` 키를 쓰지 않는다 -- 다른 프로젝트의 바이트 불변). + """ + raw = task_config.get("meta_fields") + if raw is None: + return None + task = task_config.get("name", "?") + if not isinstance(raw, list) or not raw: + raise DatasetPrepError(f"task {task!r}: meta_fields must be a non-empty list of strings") + if not all(isinstance(f, str) and f.strip() for f in raw): + raise DatasetPrepError(f"task {task!r}: meta_fields values must be non-empty strings") + duplicates = sorted({f for f in raw if raw.count(f) > 1}) + if duplicates: + raise DatasetPrepError(f"task {task!r}: meta_fields has duplicates: {duplicates}") + overlap = sorted(set(raw) & _data_keys(task_config)) + if overlap: + raise DatasetPrepError(f"task {task!r}: meta_fields overlap input/golden fields: {overlap}") + return tuple(raw) + + +def row_meta(row: Mapping[str, Any], meta_fields: tuple[str, ...] | None) -> dict[str, Any] | None: + """데이터 행에서 `meta_fields` 키만 골라 복사한다. 행에 없는 키는 **생략**한다. + + 값은 깊은 복사본이다 -- 결과 행이 데이터셋 행 객체를 공유하지 않게. + """ + if meta_fields is None: + return None + return {key: copy.deepcopy(row[key]) for key in meta_fields if key in row} + + +def build_selection( + task_config: Mapping[str, Any], cli_where: Where | None = None, limit: int | None = None +) -> RowSelection: + """태스크 `where` 와 CLI where(`Where` 또는 `None`)를 합쳐 run 의 선택을 만든다. + + 선택은 CLI 가 **명시적으로** 만들어 러너에 넘긴다 -- 러너는 `task_config["where"]` 를 + 스스로 읽지 않는다(D3). + """ + task_name = task_config.get("name", "?") + task_where = parse_where(task_config.get("where"), origin=f"task {task_name!r}") + return RowSelection(where=combine(task_where, cli_where), limit=limit or None) + + +def prepare_dataset( + loader: DatasetLoader, + project_config: Mapping[str, Any], + task_config: Mapping[str, Any], + selection: RowSelection | None, +) -> PreparedDataset: + """데이터셋을 한 번 읽고 선택을 적용한다. 0행·라벨 누락·meta 오류는 여기서 터진다.""" + meta_fields = validate_meta_fields(task_config) + dataset_config = project_config.get("dataset") or {} + loaded = loader.read_jsonl( + dataset_config.get("repo", ""), + task_config.get("dataset_path", ""), + source=dataset_config.get("source", "huggingface"), + ) + selected = select(loaded.records, selection) + return PreparedDataset( + loaded=loaded, + selected=tuple(selected), + rows_total=len(loaded.records), + meta_fields=meta_fields, + selection=selection, + ) + + +def check_resume_indices(prepared: PreparedDataset, completed_indices: set[int]) -> None: + """이미 끝난 `sample_idx` 가 선택 밖이면 실패한다 (D3 resume 가드). + + 선택 밖 결과가 섞인 results 는 이 run 의 선택으로 설명되지 않는다 -- 선택이나 + 데이터셋이 run 시작 뒤 바뀌었다는 뜻이다. + """ + outside = sorted(completed_indices - prepared.selected_indices) + if outside: + raise SelectionError( + "completed results are outside this run's selection " + f"({format_indices(outside)}) " + "-- the selection or dataset changed since the run started; start a new run" + ) diff --git a/solar_eval/core/fingerprint.py b/solar_eval/core/fingerprint.py index 43760a3cef0a7c3be5d6306df3edb13195eb41b0..86f2fc858d44e2c54b248da6daa1b20fa5919c8a 100644 --- a/solar_eval/core/fingerprint.py +++ b/solar_eval/core/fingerprint.py @@ -3,7 +3,7 @@ 왜 필요한가: 이 엔진의 자산은 오래 이름으로만 식별돼 왔고(`prompt_dev_v23` ↔ `pipeline_dev_v24` 처럼 짝의 번호가 어긋나는 일이 잦았다), 그래서 "이름이 다른 두 run 이 사실 같은 설정" 인지를 사람이 해시를 떠서 대조해야 했다 -(`insights/lab_notes_archive.md#260803_etd_regression_prod_vs_v24` 의 '이름 매핑' 절이 그 흔적이다). +(`insights/260803_etd_regression_prod_vs_v24.md` 의 '이름 매핑' 절이 그 흔적이다). run 에 지문을 찍어 두면 그 대조가 조회 한 번으로 끝난다. 지문은 **해석이 끝난 설정**을 대상으로 계산한다. `extends`/override 로 조립한 @@ -16,7 +16,7 @@ run 에 지문을 찍어 두면 그 대조가 조회 한 번으로 끝난다. import hashlib import json -from collections.abc import Mapping, Sequence +from collections.abc import Iterator, Mapping, Sequence from pathlib import Path from typing import Any @@ -84,16 +84,28 @@ def prompt_fingerprint(path: Path) -> str | None: return None -def _collect_prompt_assets(root: Path) -> dict[str, str]: - """프롬프트 디렉토리의 `{상대경로: 파일지문}` 맵. 숨김 파일은 제외한다.""" - assets: dict[str, str] = {} +def iter_prompt_asset_files(root: Path) -> Iterator[Path]: + """프롬프트 디렉토리에서 지문 대상 파일을 경로 정렬 순서로 낸다. + + 규칙: `PROMPT_ASSET_SUFFIXES` 확장자(대소문자 무시)의 일반 파일, `root` 기준 경로의 + 어느 부분이든 `.` 으로 시작하면(숨김) 제외. legacy `prompt_sha` 와 스냅샷의 + `prompt_assets`(`core/snapshot/resources.py`)가 **같은 파일 집합**을 보도록 이 함수 + 하나를 공유한다(계획 2026-09-24-phase1a D12). + """ for file in sorted(root.rglob("*")): if not file.is_file() or file.suffix.lower() not in PROMPT_ASSET_SUFFIXES: continue if any(part.startswith(".") for part in file.relative_to(root).parts): continue - assets[file.relative_to(root).as_posix()] = _file_digest(file) - return assets + yield file + + +def _collect_prompt_assets(root: Path) -> dict[str, str]: + """프롬프트 디렉토리의 `{상대경로: 파일지문}` 맵. 숨김 파일은 제외한다.""" + return { + file.relative_to(root).as_posix(): _file_digest(file) + for file in iter_prompt_asset_files(root) + } def _file_digest(path: Path) -> str: diff --git a/solar_eval/core/pipeline_factory.py b/solar_eval/core/pipeline_factory.py new file mode 100644 index 0000000000000000000000000000000000000000..5adf6d683bd38d3d5f1e44d1383da3da506037f4 --- /dev/null +++ b/solar_eval/core/pipeline_factory.py @@ -0,0 +1,54 @@ +"""태스크 → 파이프라인 생성 한 곳 (계획 D6·검토 F11). + +CLI 와 러너가 파이프라인을 따로 만들면 `project_dir`·`config_dir` 해석이 갈릴 수 있다. +그래서 생성 규칙을 여기 하나로 두고, CLI 는 한 번 만든 인스턴스를 스냅샷 +(`step_impl_hash`·`resources`)과 `execute_run(pipeline=...)` 에 **같이** 넘긴다. +러너는 `pipeline` 인자가 없으면(서비스·옛 호출자) 이 함수로 만든다. + +이 모듈은 poc-bundle 패키지와 packaging 을 가져오지 않는다(D18). +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Mapping + +from solar_eval.core.dataset_loader import DatasetLoader +from solar_eval.pipelines.base import BasePipeline +from solar_eval.pipelines.registry import create_pipeline + + +def task_project_dir( + project_config: Mapping[str, Any], dataset_loader: DatasetLoader +) -> Path | None: + """데이터 루트 기준 프로젝트 디렉토리 -- v24 `tool_calling_judge` 가 `pmi_lookup` 상대경로를 풀 때 쓴다. + + `dataset_loader.base_dir` 이 projects 루트이므로 project 이름을 붙이면 project_dir 이 + 된다 -- CLI(`_start_local`)가 이미 쓰는 것과 같은 관례(§5-F). + """ + project_name = project_config.get("name") + if dataset_loader.base_dir and project_name: + return dataset_loader.base_dir / project_name + return None + + +def build_task_pipeline( + project_config: Mapping[str, Any], + task_config: Mapping[str, Any], + prompt: Mapping[str, Any], + dataset_loader: DatasetLoader, +) -> BasePipeline: + """태스크 설정으로 실행할 파이프라인을 만든다. 모르는 스텝 이름은 여기서 죽는다(run 전). + + `config_dir` 은 레포 관리 프로젝트면 project_loader 가 채워 둔 config 정본 경로 + (03-evaluation) -- 치환 사전 같은 지식 자산이 여기서 온다. + """ + return create_pipeline( + pipeline_type=task_config.get("pipeline", "single_step"), + input_fields=task_config.get("input_fields", []), + pipeline_config=task_config.get("pipeline_config"), + prompts=prompt.get("step_prompts", {}), + dataset_loader=dataset_loader, + project_dir=task_project_dir(project_config, dataset_loader), + config_dir=project_config.get("config_dir"), + ) diff --git a/solar_eval/core/row_filter.py b/solar_eval/core/row_filter.py new file mode 100644 index 0000000000000000000000000000000000000000..e0e954f3cbeabb25ead7e6ec27b7984de5ef5577 --- /dev/null +++ b/solar_eval/core/row_filter.py @@ -0,0 +1,288 @@ +"""태그 행 필터 -- `where: {tags, category}` 문법과 행 선택 (계획 D1~D3). + +문법은 두 키로 닫혀 있다: + +- `tags`: **AND** -- 행의 `tags` 가 나열한 태그를 모두 가져야 한다(다중값 라벨). +- `category`: **OR** -- 행의 `category`(행마다 하나)가 목록 중 하나여야 한다. +- 둘 다 있으면 AND. + +선택 순서는 `where → limit(앞에서 N개)` 이고, 선택된 행은 **원본 파일의 0-based 레코드 +위치**(`sample_idx`)를 그대로 들고 간다 -- Experiment Hub 조인·resume·eval_details 가 모두 이 +번호를 정본으로 쓰기 때문이다(D2). + +오류 메시지는 `sample_idx` 와 키 이름·타입 이름만 싣고 **행 본문을 싣지 않는다** +(고객 텍스트가 로그·예외로 새지 않게). + +이 모듈은 poc-bundle 패키지와 packaging 을 가져오지 않는다(D18 -- HF Space 벤더 사본). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Iterable, Mapping, Sequence + +WHERE_KEYS: tuple[str, ...] = ("tags", "category") +# 오류 메시지에 싣는 문제 행 번호의 최대 개수 -- 전부 나열하면 메시지가 데이터셋 크기만큼 길어진다. +_MAX_REPORTED_IDX = 5 + + +class SelectionError(ValueError): + """where 문법 오류, 라벨이 덜 붙은 데이터셋, 0행 선택 등 -- run 을 시작하지 않는다.""" + + +@dataclass(frozen=True) +class Where: + """정규화된 행 필터. 값은 정렬·중복 제거된 튜플이고, 지정 안 한 키는 `None` 이다.""" + + tags: tuple[str, ...] | None = None + category: tuple[str, ...] | None = None + + def is_empty(self) -> bool: + return self.tags is None and self.category is None + + def to_json(self) -> dict[str, list[str]]: + """정규화 JSON -- 키 정렬(D3: category → tags), 값 정렬, 지정 안 한 키는 생략.""" + out: dict[str, list[str]] = {} + for key in sorted(WHERE_KEYS): + values = getattr(self, key) + if values is not None: + out[key] = list(values) + return out + + def describe(self) -> str: + """사람용 한 줄 표기: `category=c tags=a,b` -- 키 순서는 `to_json` 과 같다(정렬).""" + return " ".join(f"{k}={','.join(v)}" for k, v in self.to_json().items()) + + +@dataclass(frozen=True) +class RowSelection: + """run 이 돌 행 집합의 정의: `where`(없으면 전체) 뒤 앞에서 `limit` 개.""" + + where: Where | None = None + limit: int | None = None + + def __post_init__(self) -> None: + if self.where is not None and self.where.is_empty(): + # 빈 Where 와 None 이 같은 뜻인데 서로 다르게 비교되면 resume 가드가 흔들린다. + object.__setattr__(self, "where", None) + limit = self.limit + if limit is not None and (isinstance(limit, bool) or not isinstance(limit, int)): + raise SelectionError(f"limit must be a positive integer, got {type(limit).__name__}") + if limit is not None and limit < 1: + raise SelectionError(f"limit must be a positive integer, got {limit}") + + def to_json(self) -> dict[str, Any]: + """run.json `dataset.selection` 에 기록하는 정규화 형태.""" + return { + "where": self.where.to_json() if self.where is not None else None, + "limit": self.limit, + } + + @classmethod + def from_json(cls, obj: Any) -> RowSelection: + """`to_json()` 의 역. resume 이 run.json 에 기록된 선택을 되살릴 때 쓴다.""" + if not isinstance(obj, Mapping): + raise SelectionError(f"selection must be an object, got {type(obj).__name__}") + unknown = sorted(set(obj) - {"where", "limit"}) + if unknown: + raise SelectionError(f"selection has unknown keys: {unknown}") + where = parse_where(obj.get("where"), origin="recorded selection") + return cls(where=where, limit=obj.get("limit")) + + +def _parse_values(key: str, raw: Any, origin: str) -> tuple[str, ...]: + """where 값 하나 검증: 비어 있지 않은 문자열의 비어 있지 않은 리스트, 중복 불가. + + 값은 앞뒤 공백을 떼어 저장한다 -- CLI(`parse_cli_where`)도 떼므로 YAML 과 CLI 가 같은 + 값으로 정규화된다. 중복 검사도 뗀 값으로 한다. + """ + if not isinstance(raw, list) or not raw: + raise SelectionError(f"{origin}: where.{key} must be a non-empty list of strings") + for value in raw: + if not isinstance(value, str) or not value.strip(): + raise SelectionError(f"{origin}: where.{key} values must be non-empty strings") + values = [value.strip() for value in raw] + duplicates = sorted({v for v in values if values.count(v) > 1}) + if duplicates: + raise SelectionError(f"{origin}: where.{key} has duplicate values: {duplicates}") + return tuple(sorted(values)) + + +def parse_where(obj: Any, origin: str) -> Where | None: + """`project.yaml` 태스크의 `where` 객체(또는 기록된 선택)를 검증해 `Where` 로 만든다. + + `None` 은 "필터 없음". 모르는 키·빈 리스트·중복은 오류다(문법이 닫혀 있어야 오타가 + 조용히 0행이 되지 않는다). + """ + if obj is None: + return None + if not isinstance(obj, Mapping): + raise SelectionError(f"{origin}: where must be an object, got {type(obj).__name__}") + unknown = sorted(str(k) for k in set(obj) - set(WHERE_KEYS)) + if unknown: + raise SelectionError( + f"{origin}: where has unknown keys {unknown} (allowed: tags, category)" + ) + if not obj: + raise SelectionError(f"{origin}: where is empty -- omit it instead") + parsed = {key: _parse_values(key, obj[key], origin) for key in WHERE_KEYS if key in obj} + return Where(**parsed) + + +def parse_cli_where(values: Iterable[str]) -> Where | None: + """CLI `--where key=v1,v2` (반복 가능)를 하나의 `Where` 로 접는다. + + 반복은 AND 다: `tags` 는 합집합(모두 가져야 함), `category` 는 교집합 -- `combine` 과 + 같은 규칙이다. + """ + result: Where | None = None + for raw in values: + key, sep, rest = raw.partition("=") + key = key.strip() + if not sep: + raise SelectionError(f"--where {raw!r}: expected key=value1,value2") + if key not in WHERE_KEYS: + raise SelectionError(f"--where {raw!r}: unknown key {key!r} (allowed: tags, category)") + items = [part.strip() for part in rest.split(",")] + if not rest.strip() or any(not part for part in items): + raise SelectionError(f"--where {raw!r}: empty value") + one = parse_where({key: items}, origin=f"--where {raw!r}") + result = combine(result, one) + return result + + +def combine(task: Where | None, cli: Where | None) -> Where | None: + """태스크 where 와 CLI where 를 AND 로 합친다. + + `tags` 는 합집합(모두 가져야 함), `category` 는 교집합(둘 다 있을 때). 교집합이 비면 + 어떤 행도 고를 수 없으므로 run 을 시작하기 전에 실패한다. + """ + if task is None or task.is_empty(): + return cli if cli is not None and not cli.is_empty() else None + if cli is None or cli.is_empty(): + return task + tags = _merge_optional(task.tags, cli.tags, lambda a, b: a | b) + category = _merge_optional(task.category, cli.category, lambda a, b: a & b) + if category is not None and not category: + raise SelectionError( + f"where category has no overlap: {list(task.category or ())} " + f"vs {list(cli.category or ())}" + ) + return Where(tags=tags, category=category) + + +def _merge_optional(a, b, op) -> tuple[str, ...] | None: + if a is None: + return b + if b is None: + return a + return tuple(sorted(op(set(a), set(b)))) + + +def format_indices(indices: Sequence[int]) -> str: + """오류 메시지용 `sample_idx 1, 4, ... (+N more)` -- 행 본문 없이 번호만.""" + shown = ", ".join(str(i) for i in indices[:_MAX_REPORTED_IDX]) + more = ( + f" (+{len(indices) - _MAX_REPORTED_IDX} more)" if len(indices) > _MAX_REPORTED_IDX else "" + ) + return f"sample_idx {shown}{more}" + + +def _check_labels(records: Sequence[Any], where: Where) -> None: + """where 가 보는 키가 모든 행에 올바른 타입으로 있는지 -- 덜 붙은 라벨을 조용히 자르지 않는다.""" + problems: dict[str, list[int]] = {} + for idx, row in enumerate(records): + if not isinstance(row, Mapping): + problems.setdefault("row is not an object", []).append(idx) + continue + if where.tags is not None: + _check_tags(row, idx, problems) + if where.category is not None: + _check_category(row, idx, problems) + if problems: + details = "; ".join( + f"{reason} at {format_indices(idx)}" for reason, idx in problems.items() + ) + raise SelectionError( + f"dataset rows cannot be filtered by where {where.describe()}: {details}" + ) + + +def _check_tags(row: Mapping[str, Any], idx: int, problems: dict[str, list[int]]) -> None: + if "tags" not in row: + problems.setdefault("missing 'tags'", []).append(idx) + elif not isinstance(row["tags"], list): + problems.setdefault("'tags' is not a list", []).append(idx) + elif not all(isinstance(t, str) for t in row["tags"]): + problems.setdefault("'tags' has a non-string element", []).append(idx) + + +def _check_category(row: Mapping[str, Any], idx: int, problems: dict[str, list[int]]) -> None: + if "category" not in row: + problems.setdefault("missing 'category'", []).append(idx) + elif row["category"] is not None and not isinstance(row["category"], str): + # `category: null`(apply --lenient 가 남기는 행)은 타입 오류가 아니라 불일치다. + problems.setdefault("'category' is not a string", []).append(idx) + + +def _absent_values(records: Sequence[Mapping[str, Any]], where: Where) -> dict[str, list[str]]: + """where 값 중 데이터셋의 **어느 행에도** 없는 값 -- 오타·대소문자 차이를 잡는다. + + 필터 결과가 아니라 파일 전체와 대조한다: `tags=a, category=x,y` 에서 `y` 가 파일엔 있지만 + `a` 태그 행에만 없는 것은 정당한 빈 조합이지 오타가 아니다. 라벨 검사(`_check_labels`)를 + 통과한 뒤에 부르므로 타입은 이미 맞다. + """ + absent: dict[str, list[str]] = {} + if where.category is not None: + present = {row["category"] for row in records} + missing = [v for v in where.category if v not in present] + if missing: + absent["category"] = missing + if where.tags is not None: + present = {tag for row in records for tag in row["tags"]} + missing = [v for v in where.tags if v not in present] + if missing: + absent["tags"] = missing + return absent + + +def _describe_absent(absent: dict[str, list[str]]) -> str: + return "; ".join(f"{key} {values}" for key, values in absent.items()) + + +def _matches(row: Mapping[str, Any], where: Where) -> bool: + if where.tags is not None and not set(where.tags).issubset(row["tags"]): + return False + return where.category is None or row["category"] in where.category + + +def select( + records: Sequence[dict[str, Any]], selection: RowSelection | None +) -> list[tuple[int, dict[str, Any]]]: + """선택을 적용해 `(원본 idx, 행)` 목록을 돌려준다. 순서는 원본 순서. + + `selection` 이 `None` 이면 전체. where 가 있는데 0행이면 `SelectionError` + (조용히 빈 run 을 만들지 않는다). where 없이 빈 파일인 경우는 오늘 동작 그대로 둔다. + where 값 중 파일의 어느 행에도 없는 값이 있어도 `SelectionError` 다 -- `category` 는 + OR 라 오타 하나가 0행이 아니라 "조용히 줄어든 선택"이 되기 때문이다(D1 이 표현식 + 문자열을 버린 것과 같은 이유). + """ + indexed = list(enumerate(records)) + if selection is None: + return indexed + where = selection.where + if where is not None: + _check_labels(records, where) + absent = _absent_values(records, where) + indexed = [(idx, row) for idx, row in indexed if _matches(row, where)] + if not indexed: + hint = f" (not in any row: {_describe_absent(absent)})" if absent else "" + raise SelectionError(f"where {where.describe()} matched 0 of {len(records)} rows{hint}") + if absent: + raise SelectionError( + f"where value(s) not in any of {len(records)} rows: {_describe_absent(absent)} " + "-- check spelling/case, or drop them" + ) + if selection.limit is not None: + indexed = indexed[: selection.limit] + return indexed diff --git a/solar_eval/core/run_records.py b/solar_eval/core/run_records.py new file mode 100644 index 0000000000000000000000000000000000000000..4a6c5175d763f6958d41c18d8548e92ad9a6ac2b --- /dev/null +++ b/solar_eval/core/run_records.py @@ -0,0 +1,205 @@ +"""run 산출물 레코드 조립 -- results 행 · eval_details 행 · evaluation 문서. + +러너(`execute_run`)와 `runs eval`(재채점) CLI 가 같은 모양을 쓰도록 한 곳에 둔다 +(예전엔 두 곳에 복붙돼 있었다). 저장 키 이름은 eval-store 의 `resultRowSchema`· +`evaluationJsonSchema` 와 하위 호환이어야 하므로 바꾸지 않는다. + +`evaluation` 문서의 `eval_model` 은 **인자**다 -- 어떤 judge 모델로 채점했는지는 +호출자(평가기)가 안다. 이 모듈이 값을 지어내지 않는다. + +이 모듈은 poc-bundle 패키지와 packaging 을 가져오지 않는다(D18). +""" + +from __future__ import annotations + +from typing import Any, Mapping, Sequence + +from solar_eval.models.sample import EvalSample + +#: run.json 에서 추론 실패 샘플의 예외 타입 목록이 사는 키(LP5 D-LP5-4). 이름의 정본은 여기 한 곳이다 -- +#: CLI 의 실패 지문 도우미(`cli/commands/failure_keys.py`)도 이 상수로 읽는다. +FAILED_SAMPLE_ERRORS_KEY = "failed_sample_errors" + + +def build_failure_errors(errors: Sequence[tuple[int, BaseException]]) -> list[dict[str, Any]]: + """추론 실패 `(원본 sample_idx, 예외)` 목록 → run.json `failed_sample_errors` 원소들. + + 원소는 `{"sample_idx", "error_type"}` 두 키이고 `error_type` 은 예외 **타입 이름**뿐이다 -- + 메시지는 입력 조각을 담을 수 있어 싣지 않는다. `sample_idx` 오름차순, 실패가 없으면 `[]`. + """ + ordered = sorted(errors, key=lambda pair: pair[0]) + return [{"sample_idx": idx, "error_type": type(exc).__name__} for idx, exc in ordered] + + +def extract_input(sample: Mapping[str, Any], input_fields: Sequence[str]) -> dict[str, Any]: + """데이터 행에서 `input_fields` 만 골라 입력 dict 를 만든다(없는 키는 빈 문자열).""" + return {field: sample.get(field, "") for field in input_fields} + + +def extract_golden(sample: Mapping[str, Any], task_config: Mapping[str, Any]) -> Any: + """데이터 행에서 정답(golden)을 꺼낸다 -- `golden_field` 우선, 다음 `golden_fields`.""" + golden_field = task_config.get("golden_field") + golden_fields = task_config.get("golden_fields") + if golden_field: + return sample.get(golden_field, "") + if golden_fields: + return {k: sample.get(v, "") for k, v in golden_fields.items()} + return "" + + +def build_eval_sample_from_result(result: dict[str, Any], reference: Any) -> EvalSample: + """저장된 결과 dict(레거시 8+trace 키)에서 채점용 `EvalSample` 을 되살린다. + + `insert_run_result` 가 쓰는 키 이름(`golden`/`trace`)은 `EvalSample` 필드 이름 + (`reference`/`artifacts`)과 다르다 -- eval-store 하위 호환을 위해 저장 스키마는 + 그대로 두고(마이그레이션 계획 §9-1) 채점 직전에만 여기서 되돌린다. `execute_run` + 내부 루프와 `runs eval` CLI(재채점, 새 추론 없이 디스크의 `results.jsonl` 을 + 그대로 씀) 양쪽에서 공유한다. + + Args: + result: `insert_run_result` 에 넘긴 것과 같은 형태(또는 `JsonlStore. + load_existing_results()` 로 디스크에서 다시 읽은 것). `input`/`output`/ + `trace` 키를 읽는다. + reference: 정답(golden) 값. 호출자가 직접 넘긴다 -- 두 호출자의 golden + 추출 경로가 다르기 때문이다(아래 참고). 이 함수는 어느 쪽인지 모른 채 + 값만 받아 그대로 `sample.reference` 에 채운다. + + - `execute_run` 내부 루프: `_golden_raw`(디스크에 안 남는 실행 중 + 임시 키, resume 시에도 매 샘플마다 새로 계산됨)를 넘긴다. + - `runs eval` CLI: 디스크에 저장된 `golden` 키를 그대로 넘긴다 + (`_golden_raw` 는 애초에 저장되지 않아 CLI 쪽엔 없다). + + Returns: + `input`/`output`/`reference`/`artifacts` 가 채워진 `EvalSample`. + `contexts` 는 아직 아무도 안 써서 기본값(`None`) 그대로다. + """ + trace = result.get("trace") or {} + return EvalSample( + input=result.get("input"), + output=result.get("output"), + reference=reference, + artifacts=trace, + ) + + +def _usage_mapping(usage: Any) -> Mapping[str, Any]: + """artifacts 의 `usage` 를 읽을 수 있는 dict 로 -- dict 가 아니면(없음·`None` 포함) 빈 dict. + + 빈 dict 에서 꺼낸 토큰 칸은 `None` 이 된다(측정 없음). 예전에는 `usage: None` 인 샘플이 + `None.get` 으로 행 조립 자체를 죽였다. + """ + return usage if isinstance(usage, Mapping) else {} + + +def build_result_row( + run_id: str, + sample_idx: int, + input_data: dict[str, Any], + golden: Any, + eval_sample: EvalSample, + meta: dict[str, Any] | None = None, +) -> dict[str, Any]: + """results.jsonl 한 행. 오늘의 8키 + `trace`, 그리고 `meta_fields` 가 있을 때만 `meta`. + + `sample_idx` 는 **원본 파일의 0-based 레코드 위치**다(필터·limit 뒤에도, D2). + + trace 는 artifacts 전체를 그대로 넣는다 -- step_outputs 뿐 아니라 v24 가 채우는 + judge_decisions/judge_tool_calls/self_consistency_runs/corrections 도 여기 안 넣으면 + 저장 직전에 통째로 버려진다 (실제 judge LLM 호출·self-consistency 반복 호출 비용이 + 나간 산출물이다). 특정 키만 하드코딩해 옮기면 다음에 파이프라인이 새 artifacts 키를 + 추가할 때마다 여기를 또 고쳐야 하므로 통째로 넘긴다 -- resultRowSchema.trace 는 + .passthrough() 라 여분 키를 그대로 받는다 (source-schemas.ts:223-228). + + `meta` 는 input 이 아니다 -- 데이터 행의 태그·출처 복사본이고(D13), `meta_fields` + 선언이 없으면 키 자체를 쓰지 않아 다른 프로젝트의 results 바이트가 그대로다. + + 사용량·지연(`input_tokens`·`output_tokens`·`inference_time_ms`)은 artifacts 에 없으면 + `None` 이다(L6b B1 · D-M2-24) -- `0` 을 지어 넣으면 "측정 없음" 이 "0 을 썼다" 는 + 측정값과 구분되지 않는다. 값이 있으면 그대로 옮긴다(`0` 도 그대로) -- 사용량이 있는 + 행의 바이트는 예전과 같다. + """ + usage = _usage_mapping(eval_sample.artifacts.get("usage")) + row: dict[str, Any] = { + "run_id": run_id, + "sample_idx": sample_idx, + "input": input_data, + "output": eval_sample.output, + "golden": golden, + "input_tokens": usage.get("prompt_tokens"), + "output_tokens": usage.get("completion_tokens"), + "inference_time_ms": eval_sample.artifacts.get("inference_time_ms"), + "trace": { + "step_outputs": {}, + **eval_sample.artifacts, + }, + } + if meta is not None: + row["meta"] = meta + return row + + +def build_evaluation_doc( + run_id: str, + aggregated: Mapping[str, Any], + eval_results: Sequence[Mapping[str, Any]], + eval_failures: Sequence[Mapping[str, Any]], + eval_model: str | None, +) -> dict[str, Any]: + """evaluation.json 문서. + + 채점에 성공한 샘플이 하나도 없으면 점수 자리를 비운다 -- aggregate() 는 빈 입력에 + 0.0 을 돌려주는데, 그건 "0점을 받았다"는 측정값이라 "측정 자체가 없었다"와 구분되지 + 않는다. judge 가 통째로 죽은 run 이 Experiment Hub 차트에서 품질 급락으로 보이면 안 된다. + """ + overall_score = aggregated.get("overall_score", 0.0) if eval_results else None + return { + "run_id": run_id, + "scores": aggregated.get("scores", {}), + "overall_score": overall_score, + "eval_model": eval_model, + "eval_success_count": len(eval_results), + "eval_failed_count": len(eval_failures), + "failed_sample_indices": [f["sample_idx"] for f in eval_failures], + } + + +def build_eval_detail_docs( + evaluation_id: Any, + eval_results: Sequence[Mapping[str, Any]], + eval_failures: Sequence[Mapping[str, Any]], +) -> list[dict[str, Any]]: + """eval_details.jsonl 행들 -- 성공/실패 둘 다 한 행씩 남긴다. + + results.jsonl 과 eval_details.jsonl 이 항상 같은 sample_idx 집합을 가리키게 해서 + 부분적으로만 채점된 run 에서 "이 샘플은 왜 안 보이지"를 없앤다. + """ + docs: list[dict[str, Any]] = [] + for er in eval_results: + docs.append( + { + "evaluation_id": evaluation_id, + "sample_idx": er["sample_idx"], + "category_scores": er.get("category_scores", {}), + "error_counts": { + k: v.get("error_count", 0) + for k, v in er.get("details", {}).items() + if isinstance(v, dict) + }, + "severity": er.get("severity", ""), + "score": er.get("score", 0.0), + } + ) + for f in eval_failures: + docs.append( + { + "evaluation_id": evaluation_id, + "sample_idx": f["sample_idx"], + "category_scores": {}, + "error_counts": {}, + "severity": None, + # 0.0 이 아니라 None -- 채점 실패를 최저 점수와 구분한다 (F1). + "score": None, + "error": f["error"], + } + ) + return docs diff --git a/solar_eval/core/runner.py b/solar_eval/core/runner.py index 2568cc5e6ee251bb990d2d33fc15ca2ebb683442..faf25ec486939760302ead545e25d955b4b8f256 100644 --- a/solar_eval/core/runner.py +++ b/solar_eval/core/runner.py @@ -1,6 +1,7 @@ import asyncio import logging import sys +from dataclasses import dataclass from datetime import datetime, timezone from typing import Any, Callable @@ -8,51 +9,106 @@ from typing import Any, Callable logging.basicConfig(stream=sys.stderr, level=logging.INFO, format="%(message)s") from solar_eval.core.dataset_loader import DatasetLoader +from solar_eval.core.dataset_prep import ( + PreparedDataset, + check_resume_indices, + prepare_dataset, + row_meta, +) +from solar_eval.core.pipeline_factory import build_task_pipeline +from solar_eval.core.row_filter import RowSelection +from solar_eval.core.run_records import ( + FAILED_SAMPLE_ERRORS_KEY, + build_eval_detail_docs, + build_eval_sample_from_result, + build_evaluation_doc, + build_failure_errors, + build_result_row, + extract_golden, + extract_input, +) from solar_eval.evaluators.registry import create_evaluator from solar_eval.models.enums import RunStatus from solar_eval.models.sample import EvalSample -from solar_eval.pipelines.registry import create_pipeline +from solar_eval.pipelines.base import BasePipeline from solar_eval.providers.base import BaseProvider from solar_eval.stores.base import ResultStore +# `build_eval_sample_from_result` 는 run_records 로 옮겼지만 `runs eval` 등 기존 호출자가 +# 이 모듈에서 import 하므로 그대로 노출한다. +__all__ = ["BatchRunner", "build_eval_sample_from_result", "resolve_selection"] + logger = logging.getLogger(__name__) OnProgress = Callable[[dict[str, Any]], Any] +# 태스크에 evaluator 선언이 없을 때 쓰는 기본 평가기. +DEFAULT_EVALUATOR_CONFIG: dict[str, Any] = {"type": "llm_judge"} -def build_eval_sample_from_result(result: dict[str, Any], reference: Any) -> EvalSample: - """저장된 결과 dict(레거시 8+trace 키)에서 채점용 `EvalSample` 을 되살린다. - - `insert_run_result` 가 쓰는 키 이름(`golden`/`trace`)은 `EvalSample` 필드 이름 - (`reference`/`artifacts`)과 다르다 -- eval-store 하위 호환을 위해 저장 스키마는 - 그대로 두고(마이그레이션 계획 §9-1) 채점 직전에만 여기서 되돌린다. `execute_run` - 내부 루프와 `runs eval` CLI(재채점, 새 추론 없이 디스크의 `results.jsonl` 을 - 그대로 씀) 양쪽에서 공유한다. - - Args: - result: `insert_run_result` 에 넘긴 것과 같은 형태(또는 `JsonlStore. - load_existing_results()` 로 디스크에서 다시 읽은 것). `input`/`output`/ - `trace` 키를 읽는다. - reference: 정답(golden) 값. 호출자가 직접 넘긴다 -- 두 호출자의 golden - 추출 경로가 다르기 때문이다(아래 참고). 이 함수는 어느 쪽인지 모른 채 - 값만 받아 그대로 `sample.reference` 에 채운다. - - `execute_run` 내부 루프: `_golden_raw`(디스크에 안 남는 실행 중 - 임시 키, resume 시에도 매 샘플마다 새로 계산됨)를 넘긴다. - - `runs eval` CLI: 디스크에 저장된 `golden` 키를 그대로 넘긴다 - (`_golden_raw` 는 애초에 저장되지 않아 CLI 쪽엔 없다). +def resolve_selection( + task_config: dict[str, Any], + selection: RowSelection | None, + prepared: PreparedDataset | None, + limit: int | None, +) -> RowSelection | None: + """러너가 따를 선택을 정한다 -- 러너는 넘겨받은 선택만 따른다(계획 D3). - Returns: - `input`/`output`/`reference`/`artifacts` 가 채워진 `EvalSample`. - `contexts` 는 아직 아무도 안 써서 기본값(`None`) 그대로다. + - `prepared` 가 있으면 그 선택이 정본이다. `selection`·`limit` 가 함께 오면 같아야 한다. + - 옛 `limit` 인자만 오면 `RowSelection(limit=...)` 로 바꾼다(0·None = 전체, 오늘 동작). + - 러너는 `task_config["where"]` 를 스스로 읽지 않는다. 호출자가 선택을 넘기지 않았는데 + 태스크에 where 가 있으면 경고만 남기고 전체 행을 돈다(서비스 경로 -- 조용한 필터 run 금지). """ - trace = result.get("trace") or {} - return EvalSample( - input=result.get("input"), - output=result.get("output"), - reference=reference, - artifacts=trace, - ) + legacy_limit = limit or None + if prepared is not None: + if selection is not None and selection != prepared.selection: + raise ValueError("execute_run: selection differs from prepared.selection") + selection = prepared.selection + prepared_limit = selection.limit if selection is not None else None + if legacy_limit is not None and legacy_limit != prepared_limit: + raise ValueError("execute_run: limit differs from prepared.selection.limit") + elif selection is not None: + if legacy_limit is not None and legacy_limit != selection.limit: + raise ValueError("execute_run: limit differs from selection.limit") + elif legacy_limit is not None: + selection = RowSelection(limit=legacy_limit) + + # CLI 는 태스크 where 를 선택에 합쳐 넘기므로, 태스크에 where 가 있는데 적용할 선택에 + # where 가 없다면 호출자가 where 를 모르는 경로(서비스·옛 호출자)다. + if task_config.get("where") is not None and (selection is None or selection.where is None): + logger.warning( + "Task %r declares where, but where is not applied on this path -- running all rows", + task_config.get("name"), + ) + return selection + + +def _index_completed( + completed_results: list[dict[str, Any]] | None, prepared: PreparedDataset +) -> dict[int, dict[str, Any]]: + """resume 용 `{sample_idx: 결과}`. 선택 밖 idx 가 섞여 있으면 실패한다(D3).""" + completed_by_idx = {r["sample_idx"]: r for r in completed_results or []} + check_resume_indices(prepared, set(completed_by_idx)) + return completed_by_idx + + +@dataclass +class _RunContext: + """한 run 의 실행 상태 -- 샘플 처리 함수들이 공유한다.""" + + run_id: str + task_config: dict[str, Any] + prompt: dict[str, Any] + pipeline: BasePipeline + prepared: PreparedDataset + completed_by_idx: dict[int, dict[str, Any]] + on_progress: OnProgress | None + semaphore: asyncio.Semaphore + completed: int = 0 + + @property + def total(self) -> int: + return self.prepared.rows_selected class BatchRunner: @@ -80,27 +136,31 @@ class BatchRunner: max_workers: int = 5, limit: int | None = None, completed_results: list[dict[str, Any]] | None = None, + selection: RowSelection | None = None, + prepared: PreparedDataset | None = None, + pipeline: BasePipeline | None = None, ) -> None: """Execute a full inference + evaluation run. Args: completed_results: Previously completed results for resume. Samples with matching sample_idx will be skipped. + selection: 돌 행의 선택(`where`·`limit`). `None` = 전체. 러너는 태스크의 + `where` 를 스스로 읽지 않는다 -- 호출자(CLI)가 만들어 넘긴다(D3). + prepared: 호출자가 미리 읽고 고른 데이터셋. 주면 러너가 다시 읽지 않는다 -- + run.json 의 sha256 과 실제로 돈 행이 같은 바이트에서 나온다(D16). + pipeline: 호출자가 만든 파이프라인. 스냅샷을 뜬 것과 **같은 인스턴스**를 + 돌리기 위해 받는다(D6). 없으면 `build_task_pipeline` 으로 만든다. + + `sample_idx` 는 필터·limit 뒤에도 원본 파일의 0-based 레코드 위치다(D2). 샘플 단위 실패(추론/평가)는 삼키고 상태(PARTIAL/eval_failed_count)로 기록하지만, - run 을 통째로 못 돌게 만드는 예외(데이터셋 로드 실패, 파이프라인/평가기 생성 - 실패, 스토어 쓰기 실패 등)는 상태를 FAILED 로 남긴 뒤 그대로 재전파한다 -- - "무슨 일이 있어도 예외 없이 반환"이 아니다. 호출자는 이 함수가 raise 할 수 - 있다고 가정해야 한다. + run 을 통째로 못 돌게 만드는 예외(데이터셋 로드 실패, 0행 선택, 선택 밖 resume, + 파이프라인/평가기 생성 실패, 스토어 쓰기 실패 등)는 상태를 FAILED 로 남긴 뒤 + 그대로 재전파한다 -- "무슨 일이 있어도 예외 없이 반환"이 아니다. 호출자는 이 + 함수가 raise 할 수 있다고 가정해야 한다. """ try: - # Build set of already-completed sample indices for resume - completed_by_idx: dict[int, dict[str, Any]] = {} - if completed_results: - for r in completed_results: - completed_by_idx[r["sample_idx"]] = r - - # Update status to running await self.store.update_run( run_id, { @@ -108,275 +168,28 @@ class BatchRunner: "started_at": datetime.now(timezone.utc), }, ) - - # Load dataset - dataset_config = project_config.get("dataset", {}) - source = dataset_config.get("source", "huggingface") - repo_name = dataset_config.get("repo", "") - dataset_path = task_config.get("dataset_path", "") - data = self.dataset_loader.load_jsonl(repo_name, dataset_path, source=source) - - if limit and limit < len(data): - data = data[:limit] - - remaining = len(data) - len(completed_by_idx) - await self.store.update_run(run_id, {"total_samples": len(data)}) - if on_progress: - await on_progress({"type": "dataset_loaded", "total": len(data)}) - if completed_by_idx: - logger.info(f"Resuming: {len(completed_by_idx)} done, {remaining} remaining") - - # Create pipeline - # project_dir: v24 의 tool_calling_judge 가 pmi_lookup 상대경로를 풀 때만 - # 쓴다 (§5-F). dataset_loader.base_dir 이 projects 루트이므로 project 이름을 - # 붙이면 project_dir 이 된다 -- CLI(`_start_local`)가 이미 쓰는 것과 같은 관례. - project_name = project_config.get("name") - project_dir = ( - self.dataset_loader.base_dir / project_name - if self.dataset_loader.base_dir and project_name - else None - ) - # config_dir: 레포 관리 프로젝트면 project_loader 가 채워 둔 config 정본 - # 경로(03-evaluation). 치환 사전 같은 지식 자산이 여기서 온다. - pipeline = create_pipeline( - pipeline_type=task_config.get("pipeline", "single_step"), - input_fields=task_config.get("input_fields", []), - pipeline_config=task_config.get("pipeline_config"), - prompts=prompt.get("step_prompts", {}), - dataset_loader=self.dataset_loader, - project_dir=project_dir, - config_dir=project_config.get("config_dir"), - ) - - # Run inference with concurrency control - semaphore = asyncio.Semaphore(max_workers) - completed = len(completed_by_idx) - - async def process_sample(idx: int, sample: dict) -> dict[str, Any]: - nonlocal completed - - # Skip already-completed samples (resume) - if idx in completed_by_idx: - existing = completed_by_idx[idx] - golden_field = task_config.get("golden_field") - golden = sample.get(golden_field, "") if golden_field else "" - return {**existing, "_golden_raw": golden} - - async with semaphore: - input_data = { - field: sample.get(field, "") - for field in task_config.get("input_fields", []) - } - - # Get golden reference - golden_field = task_config.get("golden_field") - golden_fields = task_config.get("golden_fields") - if golden_field: - golden = sample.get(golden_field, "") - elif golden_fields: - golden = {k: sample.get(v, "") for k, v in golden_fields.items()} - else: - golden = "" - - eval_sample = EvalSample(input=input_data, reference=golden) - - try: - eval_sample = await pipeline.run( - sample=eval_sample, - prompts=prompt.get("system_prompt", ""), - provider=self.inference_provider, - model=prompt.get("model", "solar-pro2"), - temperature=prompt.get("temperature", 0.0), - max_tokens=prompt.get("max_tokens", 8000), - reasoning_effort=prompt.get("reasoning_effort"), - messages=prompt.get("messages"), - ) - except Exception as e: - ts = datetime.now().strftime("%H:%M:%S") - logger.warning(f"[{ts}] Sample {idx} failed: {type(e).__name__}: {e}") - raise - - # 저장 형태는 이전과 같은 8키 dict (eval-store 의 resultRowSchema 가 - # 읽는 이름들과 하위 호환) + trace 를 추가한다. trace 는 artifacts - # 전체를 그대로 넣는다 -- step_outputs 뿐 아니라 v24 가 채우는 - # judge_decisions/judge_tool_calls/self_consistency_runs/corrections - # 도 여기 안 넣으면 저장 직전에 통째로 버려진다 (실제 judge LLM 호출· - # self-consistency 반복 호출 비용이 나간 산출물이다). 특정 키만 - # 하드코딩해 옮기면 다음에 파이프라인이 새 artifacts 키를 추가할 - # 때마다 여기를 또 고쳐야 하므로 통째로 넘긴다 -- - # resultRowSchema.trace 는 .passthrough() 라 여분 키를 그대로 받는다 - # (source-schemas.ts:223-228). - run_result = { - "run_id": run_id, - "sample_idx": idx, - "input": input_data, - "output": eval_sample.output, - "golden": golden, - "input_tokens": eval_sample.artifacts.get("usage", {}).get( - "prompt_tokens", 0 - ), - "output_tokens": eval_sample.artifacts.get("usage", {}).get( - "completion_tokens", 0 - ), - "inference_time_ms": eval_sample.artifacts.get("inference_time_ms", 0), - "trace": { - "step_outputs": {}, - **eval_sample.artifacts, - }, - } - await self.store.insert_run_result(run_result) - - completed += 1 - ts = datetime.now().strftime("%H:%M:%S") - logger.info(f"[{ts}] Sample {idx} completed ({completed}/{len(data)})") - await self.store.update_run(run_id, {"completed_samples": completed}) - if on_progress: - await on_progress( - { - "type": "inference_progress", - "completed": completed, - "total": len(data), - "sample_idx": idx, - } - ) - - return {**run_result, "_golden_raw": golden} - - tasks = [process_sample(i, sample) for i, sample in enumerate(data)] - results = await asyncio.gather(*tasks, return_exceptions=True) - - # Filter out exceptions - valid_results = [r for r in results if isinstance(r, dict)] - errors = [(i, r) for i, r in enumerate(results) if isinstance(r, Exception)] - if errors: - logger.warning(f"Run {run_id}: {len(errors)}/{len(results)} samples failed") - for sample_idx, err in errors: - logger.warning(f" Sample {sample_idx} error: {type(err).__name__}: {err}") - - if on_progress: - await on_progress({"type": "inference_complete", "total": len(valid_results)}) - - # Run evaluation - await self.store.update_run(run_id, {"status": RunStatus.EVALUATING}) - if on_progress: - await on_progress({"type": "evaluation_start", "total": len(valid_results)}) - - evaluator = create_evaluator(task_config.get("evaluator", {"type": "llm_judge"})) - # eval_results: 성공한 evaluate() 반환값 + "sample_idx"(정본, §0.5). - # eval_failures: 채점 자체가 안 된 샘플 -- aggregate() 에는 절대 안 넘긴다 - # (lcs_diff.aggregate() 처럼 r["details"]["tp"] 를 직접 읽는 구현이 실패 - # 항목을 만나면 KeyError 로 죽는다, §0.1). - eval_results: list[dict[str, Any]] = [] - eval_failures: list[dict[str, Any]] = [] - - for i, result in enumerate(valid_results): - # F6: enumerate 위치가 아니라 result["sample_idx"] 가 정본이다 -- - # 중간 샘플이 추론에서 실패하면 valid_results 의 리스트 위치와 원본 - # sample_idx 가 어긋난다. - sample_idx = result["sample_idx"] - try: - eval_sample = build_eval_sample_from_result( - result, reference=result["_golden_raw"] - ) - evaluator.validate_required_fields(eval_sample) - eval_result = await evaluator.evaluate( - sample=eval_sample, - provider=self.judge_provider, - ) - eval_results.append({**eval_result, "sample_idx": sample_idx}) - - if on_progress and i % 10 == 0: - await on_progress( - { - "type": "evaluation_progress", - "completed": i + 1, - "total": len(valid_results), - } - ) - except Exception as e: - logger.warning(f"Evaluation failed for sample {sample_idx}: {e}") - eval_failures.append({"sample_idx": sample_idx, "error": str(e)}) - - # Aggregate and save evaluation -- eval_results 에는 실패 항목이 안 섞여 - # 있으므로 기존 aggregate() 구현이 그대로 동작한다. - aggregated = evaluator.aggregate(eval_results) - # 채점에 성공한 샘플이 하나도 없으면 점수 자리를 비운다 -- aggregate() 는 빈 - # 입력에 0.0 을 돌려주는데, 그건 "0점을 받았다"는 측정값이라 "측정 자체가 - # 없었다"와 구분되지 않는다. judge 가 통째로 죽은 run 이 evalhub 차트에서 - # 품질 급락으로 보이면 F1 을 반만 고친 셈이다. - overall_score = aggregated.get("overall_score", 0.0) if eval_results else None - eval_id = await self.store.create_evaluation( - { - "run_id": run_id, - "scores": aggregated.get("scores", {}), - "overall_score": overall_score, - "eval_model": "gpt-4o", - "eval_success_count": len(eval_results), - "eval_failed_count": len(eval_failures), - "failed_sample_indices": [f["sample_idx"] for f in eval_failures], - } - ) - - # Save per-sample eval details -- 성공/실패 둘 다 한 행씩 남긴다. - # results.jsonl 과 eval_details.jsonl 이 항상 같은 sample_idx 집합을 - # 가리키게 해서 부분적으로만 채점된 run 에서 "이 샘플은 왜 안 보이지"를 - # 없앤다. - eval_detail_docs = [] - for er in eval_results: - eval_detail_docs.append( - { - "evaluation_id": eval_id, - "sample_idx": er["sample_idx"], - "category_scores": er.get("category_scores", {}), - "error_counts": { - k: v.get("error_count", 0) - for k, v in er.get("details", {}).items() - if isinstance(v, dict) - }, - "severity": er.get("severity", ""), - "score": er.get("score", 0.0), - } + effective = resolve_selection(task_config, selection, prepared, limit) + if prepared is None: + prepared = prepare_dataset( + self.dataset_loader, project_config, task_config, effective ) - for f in eval_failures: - eval_detail_docs.append( - { - "evaluation_id": eval_id, - "sample_idx": f["sample_idx"], - "category_scores": {}, - "error_counts": {}, - "severity": None, - # 0.0 이 아니라 None -- 채점 실패를 최저 점수와 구분한다 (F1). - "score": None, - "error": f["error"], - } + completed_by_idx = _index_completed(completed_results, prepared) + if pipeline is None: + pipeline = build_task_pipeline( + project_config, task_config, prompt, self.dataset_loader ) - await self.store.insert_eval_details(eval_detail_docs) - - # Mark run as completed/partial -- 추론이 일부 샘플에서 실패했으면 - # PARTIAL, 평가 실패는 이 상태에 영향을 주지 않는다(평가 성공/실패는 - # 위 eval_success_count/eval_failed_count 로만 표현한다, §0.4). errors - # 의 인덱스는 asyncio.gather 가 tasks 순서를 보존하므로 이미 진짜 - # sample_idx 다. - final_status = RunStatus.PARTIAL if errors else RunStatus.COMPLETED - await self.store.update_run( - run_id, - { - "status": final_status, - "completed_at": datetime.now(timezone.utc), - "failed_samples": len(errors), - "failed_sample_indices": [i for i, _ in errors], - }, + ctx = _RunContext( + run_id=run_id, + task_config=task_config, + prompt=prompt, + pipeline=pipeline, + prepared=prepared, + completed_by_idx=completed_by_idx, + on_progress=on_progress, + completed=len(completed_by_idx), + semaphore=asyncio.Semaphore(max_workers), ) - - if on_progress: - await on_progress( - { - "type": "done", - "overall_score": aggregated.get("overall_score", 0.0), - "scores": aggregated.get("scores", {}), - } - ) - + await self._run_all(ctx) except Exception as e: logger.exception(f"Run {run_id} failed") await self.store.update_run( @@ -389,6 +202,185 @@ class BatchRunner: if on_progress: await on_progress({"type": "error", "error": str(e)}) # F4: 상태를 FAILED 로 남기고 진행 상황까지 알린 뒤 **그대로 재전파한다**. - # 여기서 삼키면 호출자(CLI)는 "Results saved to ..." 를 찍고 evalhub 적재까지 + # 여기서 삼키면 호출자(CLI)는 "Results saved to ..." 를 찍고 Experiment Hub 적재까지 # 시도한 다음 exit 0 을 낸다 -- 데이터셋조차 못 읽은 run 이 성공으로 보인다. raise + + async def _run_all(self, ctx: _RunContext) -> None: + """추론 → 채점 → 상태 마감. 치명적 예외는 호출자(`execute_run`)가 FAILED 로 남긴다.""" + await self.store.update_run(ctx.run_id, {"total_samples": ctx.total}) + if ctx.on_progress: + await ctx.on_progress({"type": "dataset_loaded", "total": ctx.total}) + if ctx.completed_by_idx: + remaining = ctx.total - len(ctx.completed_by_idx) + logger.info(f"Resuming: {len(ctx.completed_by_idx)} done, {remaining} remaining") + + valid_results, errors = await self._infer_all(ctx) + aggregated = await self._evaluate_all(ctx, valid_results) + + # Mark run as completed/partial -- 추론이 일부 샘플에서 실패했으면 PARTIAL, 평가 + # 실패는 이 상태에 영향을 주지 않는다(평가 성공/실패는 eval_success_count/ + # eval_failed_count 로만 표현한다, §0.4). errors 의 인덱스는 `_infer_all` 이 + # gather 위치를 원본 sample_idx 로 매핑해 둔 값이다(D2). + final_status = RunStatus.PARTIAL if errors else RunStatus.COMPLETED + await self.store.update_run( + ctx.run_id, + { + "status": final_status, + "completed_at": datetime.now(timezone.utc), + "failed_samples": len(errors), + "failed_sample_indices": [i for i, _ in errors], + # 같은 호출에서 쓴다 -- 두 목록의 위치 집합이 늘 같다. 타입 이름만(LP5 D-LP5-4). + FAILED_SAMPLE_ERRORS_KEY: build_failure_errors(errors), + }, + ) + if ctx.on_progress: + await ctx.on_progress( + { + "type": "done", + "overall_score": aggregated.get("overall_score", 0.0), + "scores": aggregated.get("scores", {}), + } + ) + + async def _infer_all( + self, ctx: _RunContext + ) -> tuple[list[dict[str, Any]], list[tuple[int, BaseException]]]: + """선택된 행을 동시에 추론한다. 실패는 `(원본 sample_idx, 예외)` 로 돌려준다.""" + selected = ctx.prepared.selected + tasks = [self._infer_one(ctx, idx, sample) for idx, sample in selected] + results = await asyncio.gather(*tasks, return_exceptions=True) + + valid_results = [r for r in results if isinstance(r, dict)] + # gather 는 tasks 순서를 보존하므로 결과 위치 = selected 위치다. 위치를 그대로 + # 쓰면 필터·limit run 에서 엉뚱한 행을 실패로 적는다 -- 원본 idx 로 매핑한다(D2). + errors = [ + (selected[pos][0], r) for pos, r in enumerate(results) if isinstance(r, BaseException) + ] + if errors: + logger.warning(f"Run {ctx.run_id}: {len(errors)}/{len(results)} samples failed") + for sample_idx, err in errors: + logger.warning(f" Sample {sample_idx} error: {type(err).__name__}: {err}") + + if ctx.on_progress: + await ctx.on_progress({"type": "inference_complete", "total": len(valid_results)}) + return valid_results, errors + + async def _infer_one(self, ctx: _RunContext, idx: int, sample: dict) -> dict[str, Any]: + """샘플 하나 추론·저장. `idx` 는 원본 파일 위치다.""" + golden = extract_golden(sample, ctx.task_config) + # Skip already-completed samples (resume) + if idx in ctx.completed_by_idx: + return {**ctx.completed_by_idx[idx], "_golden_raw": golden} + + async with ctx.semaphore: + input_data = extract_input(sample, ctx.task_config.get("input_fields", [])) + eval_sample = EvalSample(input=input_data, reference=golden) + prompt = ctx.prompt + try: + eval_sample = await ctx.pipeline.run( + sample=eval_sample, + prompts=prompt.get("system_prompt", ""), + provider=self.inference_provider, + model=prompt.get("model", "solar-pro2"), + temperature=prompt.get("temperature", 0.0), + max_tokens=prompt.get("max_tokens", 8000), + reasoning_effort=prompt.get("reasoning_effort"), + messages=prompt.get("messages"), + ) + except Exception as e: + ts = datetime.now().strftime("%H:%M:%S") + logger.warning(f"[{ts}] Sample {idx} failed: {type(e).__name__}: {e}") + raise + + run_result = build_result_row( + ctx.run_id, + idx, + input_data, + golden, + eval_sample, + meta=row_meta(sample, ctx.prepared.meta_fields), + ) + await self.store.insert_run_result(run_result) + await self._report_inference(ctx, idx) + return {**run_result, "_golden_raw": golden} + + async def _report_inference(self, ctx: _RunContext, idx: int) -> None: + ctx.completed += 1 + ts = datetime.now().strftime("%H:%M:%S") + logger.info(f"[{ts}] Sample {idx} completed ({ctx.completed}/{ctx.total})") + await self.store.update_run(ctx.run_id, {"completed_samples": ctx.completed}) + if ctx.on_progress: + await ctx.on_progress( + { + "type": "inference_progress", + "completed": ctx.completed, + "total": ctx.total, + "sample_idx": idx, + } + ) + + async def _evaluate_all( + self, ctx: _RunContext, valid_results: list[dict[str, Any]] + ) -> dict[str, Any]: + """채점하고 evaluation·eval_details 를 저장한다. 집계 dict 를 돌려준다.""" + await self.store.update_run(ctx.run_id, {"status": RunStatus.EVALUATING}) + if ctx.on_progress: + await ctx.on_progress({"type": "evaluation_start", "total": len(valid_results)}) + + evaluator = create_evaluator(ctx.task_config.get("evaluator", DEFAULT_EVALUATOR_CONFIG)) + eval_results, eval_failures = await self._score(ctx, evaluator, valid_results) + + # eval_results 에는 실패 항목이 안 섞여 있으므로 기존 aggregate() 구현이 그대로 동작한다. + aggregated = evaluator.aggregate(eval_results) + eval_id = await self.store.create_evaluation( + build_evaluation_doc( + # eval_model = 평가기가 보고하는 judge 모델(없으면 None) -- 예전의 하드코딩 + # "gpt-4o" 는 lcs_diff run 에도 judge 가 있는 것처럼 적었다(계획 D11). + ctx.run_id, + aggregated, + eval_results, + eval_failures, + evaluator.judge_model, + ) + ) + await self.store.insert_eval_details( + build_eval_detail_docs(eval_id, eval_results, eval_failures) + ) + return aggregated + + async def _score( + self, ctx: _RunContext, evaluator: Any, valid_results: list[dict[str, Any]] + ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + """샘플별 채점. + + eval_results: 성공한 evaluate() 반환값 + "sample_idx"(정본, §0.5). + eval_failures: 채점 자체가 안 된 샘플 -- aggregate() 에는 절대 안 넘긴다 + (lcs_diff.aggregate() 처럼 r["details"]["tp"] 를 직접 읽는 구현이 실패 항목을 + 만나면 KeyError 로 죽는다, §0.1). + """ + eval_results: list[dict[str, Any]] = [] + eval_failures: list[dict[str, Any]] = [] + for i, result in enumerate(valid_results): + # F6: enumerate 위치가 아니라 result["sample_idx"] 가 정본이다. + sample_idx = result["sample_idx"] + try: + eval_sample = build_eval_sample_from_result(result, reference=result["_golden_raw"]) + evaluator.validate_required_fields(eval_sample) + eval_result = await evaluator.evaluate( + sample=eval_sample, + provider=self.judge_provider, + ) + eval_results.append({**eval_result, "sample_idx": sample_idx}) + if ctx.on_progress and i % 10 == 0: + await ctx.on_progress( + { + "type": "evaluation_progress", + "completed": i + 1, + "total": len(valid_results), + } + ) + except Exception as e: + logger.warning(f"Evaluation failed for sample {sample_idx}: {e}") + eval_failures.append({"sample_idx": sample_idx, "error": str(e)}) + return eval_results, eval_failures diff --git a/solar_eval/core/snapshot/__init__.py b/solar_eval/core/snapshot/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..84810d4bce8750e06141733bdfa4381901e9aec4 --- /dev/null +++ b/solar_eval/core/snapshot/__init__.py @@ -0,0 +1,400 @@ +"""run.json `snapshot` 블록(engine-snapshot/v1) — "무엇으로 돌렸는가"를 완전히 적는다. + +계획 2026-09-24-phase1a D5~D12. 모양의 정본은 poc-bundle(SPEC §3.4 완전성 규범 키 8개)이고, 지문은 +`poc_bundle.snapshot.config_fingerprint`(JCS) 를 그대로 쓴다 — 규칙을 두 벌 짜지 않는다. + +- `config` = 지문 대상(기능). `provenance` = 지문 밖(이름·위치·커밋·실행 옵션). +- `build_engine_snapshot(inputs)` 는 run 시작 **전에** 부른다. 선언했는데 없는 자원은 여기서 죽는다. +- `self_check(snapshot)` = 규범 키 누락 없음 + 지문 재계산 일치 (엔진 자기 검증, D5). + +`poc_bundle`·`packaging` import 는 이 패키지(와 cli 의 run 메타 모듈) 안에서만 한다 — HF Space 벤더 +사본이 import 하는 모듈은 둘 없이 돌아야 한다(D18). +""" + +from __future__ import annotations + +import copy +import subprocess +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from poc_bundle.canonical import json_fingerprint +from poc_bundle.snapshot import completeness_gaps, config_fingerprint + +from solar_eval.core.snapshot import environment, impl_hash, reproducibility, resources +from solar_eval.core.snapshot.impl_hash import ImplRoots +from solar_eval.evaluators.registry import DEFAULT_EVALUATOR_CONFIG, create_evaluator + +ENGINE_SNAPSHOT_SCHEMA = "engine-snapshot/v1" + +#: `snapshot.config` 키 순서 (계획 §3.2). 지문은 JCS 라 순서와 무관하지만 사람이 읽는 순서를 고정한다. +CONFIG_KEYS = ( + "model", + "temperature", + "max_tokens", + "reasoning_effort", + "pipeline_type", + "input_fields", + "resolved_pipeline", + "prompt_assets", + "resources", + "evaluator_config", + "step_impl_hash", + "runtime_impl_hash", + "dependency_lock", + "runner_version", +) + +#: 런타임·평가기 폐포 목록을 `provenance.impl_modules` 에 남길 때의 키. +RUNTIME_MODULES_KEY = "@runtime" +EVALUATOR_MODULES_KEY = "@evaluator" + +#: 프로바이더 모듈 이름을 남기는 provenance 키 — 재채점이 런타임 해시를 다시 뜰 재료. +PROVIDERS_KEY = "providers" + +#: 엔진 레포를 자동으로 찾으라는 표시(`EngineSnapshotInputs.repo` 기본값). +AUTO = "auto" + + +class SnapshotError(ValueError): + """스냅샷이 스스로의 규칙을 어겼다 — 규범 키 누락·지문 불일치·모르는 schema.""" + + +@dataclass(frozen=True) +class EngineSnapshotInputs: + """스냅샷 재료. 파일·git 읽기는 `roots`·`engine`·`repo`·`git_run` 으로 바꿔 끼울 수 있다. + + Attributes: + pipeline: 러너가 **실제로 돌릴** `Pipeline` 인스턴스(D6) — `step_impl_hash`·`resources` 재료. + resolved_pipeline: `extends`·`--set` 까지 해석된 파이프라인 dict(없으면 None/{}). + prompt_path: 프롬프트 디렉토리 또는 단일 YAML 파일. + evaluator_config: 태스크의 evaluator 설정. None 은 "키 없음"으로 읽어 러너 기본값 + (`llm_judge`)을 쓴다. 키가 있는데 비었으면(`{}`) 여기서 ValueError — 러너가 죽을 설정이다. + dataset: `dataset_block.describe_dataset` 결과(재현 판정 재료). None = 모름. + engine: 미리 계산한 `environment.engine_state` 결과. None 이면 여기서 계산한다. + repo: 엔진 레포(uv.lock·engine_state 용). `AUTO` 면 `environment.engine_repo()`. + """ + + model: str + temperature: float + max_tokens: int + reasoning_effort: str | None + pipeline_type: str + input_fields: Sequence[str] + resolved_pipeline: Mapping[str, Any] | None + prompt_path: Path + pipeline: Any + evaluator_config: Mapping[str, Any] | None + inference_provider: Any + judge_provider: Any = None + prompt_name: str | None = None + pipeline_name: str | None = None + config_source: str | None = None + git_commit: str | None = None + pipeline_overrides: Mapping[str, Any] | None = None + max_workers: int | None = None + config_dir: Path | None = None + repo_managed: bool = False + dataset: Mapping[str, Any] | None = None + roots: ImplRoots | None = None + engine: Mapping[str, Any] | None = None + repo: Path | str | None = AUTO + git_run: Any = field(default=subprocess.run) + + +def evaluator_block( + evaluator_config: Mapping[str, Any] | None, roots: ImplRoots | None = None +) -> tuple[dict[str, Any], list[str]]: + """`config.evaluator_config = {config, judge_model, impl_hash}` 와 폐포 라벨. + + 기본값은 `evaluator_config is None`(태스크에 키가 없음)일 때만 쓴다 — 러너의 + `task_config.get("evaluator", DEFAULT)` 와 같은 규칙. `{}`·`type` 없는 설정은 기본값으로 덮지 않고 + `create_evaluator` 가 ValueError 로 거부한다(추론 전에 죽는다, D5). + + Raises: + ValueError: 평가기 설정이 객체가 아니거나 `type` 이 없거나 모르는 값일 때. + """ + raw = DEFAULT_EVALUATOR_CONFIG if evaluator_config is None else evaluator_config + evaluator = create_evaluator(copy.deepcopy(raw)) + effective = copy.deepcopy(dict(raw)) + digest, modules = impl_hash.evaluator_impl_hash(evaluator, roots) + block = {"config": effective, "judge_model": evaluator.judge_model, "impl_hash": digest} + return block, modules + + +def _resolve_repo(inputs: EngineSnapshotInputs) -> Path | None: + if inputs.repo == AUTO: + return environment.engine_repo() + return Path(inputs.repo) if inputs.repo is not None else None + + +def _engine(inputs: EngineSnapshotInputs, repo: Path | None, roots: ImplRoots) -> dict[str, Any]: + """`provenance.engine` — 주입값이 있으면 그것, 레포가 없으면 트리 해시만(commit·dirty null).""" + if inputs.engine is not None: + return copy.deepcopy(dict(inputs.engine)) + if repo is None: + tree = impl_hash.engine_tree_sha256(roots) + return {"commit": None, "tree_sha256": tree, "dirty": None, "dirty_scope": []} + return environment.engine_state( + inputs.config_dir, + inputs.repo_managed, + repo=repo, + roots=roots, + run=inputs.git_run, + ) + + +def _recorded_providers(inputs: EngineSnapshotInputs) -> dict[str, str | None]: + """`provenance.providers = {inference, judge}` — 이번 run 에 주입된 프로바이더의 모듈 이름. + + judge 는 쓰든 안 쓰든 적는다 — 재채점(`with_evaluator`)이 judge 를 새로 쓰게 되면 이 이름으로 + 런타임 해시를 다시 뜬다. + """ + judge = inputs.judge_provider + return { + "inference": impl_hash.provider_module(inputs.inference_provider), + "judge": None if judge is None else impl_hash.provider_module(judge), + } + + +def _runtime( + providers: Mapping[str, str | None], judge_model: str | None, roots: ImplRoots +) -> tuple[str, list[str]]: + """런타임 해시 — 추론 프로바이더 + (평가기가 judge 를 실제로 부를 때만) judge 프로바이더. + + lcs_diff run 은 judge 를 안 쓰므로 넣지 않는다. 빌드와 재채점이 같은 규칙을 쓴다. + """ + used = [providers["inference"]] + if judge_model is not None and providers.get("judge") is not None: + used.append(providers["judge"]) + return impl_hash.runtime_impl_hash_for_modules([m for m in used if m is not None], roots) + + +@dataclass(frozen=True) +class _Materials: + """`config` 와, 지문 밖으로 가는 부산물(자원 해석·폐포 라벨·프로바이더 모듈).""" + + config: dict[str, Any] + collected: resources.CollectedResources + impl_modules: dict[str, list[str]] + providers: dict[str, str | None] + + +def _collect_config(inputs: EngineSnapshotInputs, roots: ImplRoots) -> _Materials: + """지문 대상 `config` 를 `CONFIG_KEYS` 순서로 모은다 — 파일·자원·구현 해시를 여기서 읽는다.""" + collected = resources.collect_resources(inputs.pipeline) + evaluator, evaluator_modules = evaluator_block(inputs.evaluator_config, roots) + step_hashes, step_modules = impl_hash.step_impl_hashes(inputs.pipeline, roots) + providers = _recorded_providers(inputs) + runtime_hash, runtime_modules = _runtime(providers, evaluator["judge_model"], roots) + config = { + "model": inputs.model, + "temperature": inputs.temperature, + "max_tokens": inputs.max_tokens, + "reasoning_effort": inputs.reasoning_effort, + "pipeline_type": inputs.pipeline_type, + "input_fields": list(inputs.input_fields), + "resolved_pipeline": copy.deepcopy(dict(inputs.resolved_pipeline or {})), + "prompt_assets": resources.prompt_assets(inputs.prompt_path), + "resources": collected.hashes, + "evaluator_config": evaluator, + "step_impl_hash": step_hashes, + "runtime_impl_hash": runtime_hash, + "dependency_lock": environment.dependency_lock(), + "runner_version": environment.runner_version(), + } + impl_modules = { + **step_modules, + EVALUATOR_MODULES_KEY: evaluator_modules, + RUNTIME_MODULES_KEY: runtime_modules, + } + config = {key: config[key] for key in CONFIG_KEYS} + return _Materials(config, collected, impl_modules, providers) + + +def _provenance( + inputs: EngineSnapshotInputs, + repo: Path | None, + engine: dict[str, Any], + materials: _Materials, + steps_use_model: bool, +) -> dict[str, Any]: + """지문 밖 출처 — 이름·위치·커밋·엔진 상태·설치 폐포·실행 옵션 (계획 §3.2 순서).""" + return { + "prompt": inputs.prompt_name, + "pipeline": inputs.pipeline_name, + "config_source": inputs.config_source, + "git_commit": inputs.git_commit, + "pipeline_overrides": copy.deepcopy(inputs.pipeline_overrides), + "engine": engine, + "uv_lock_sha256": environment.uv_lock_sha256(repo), + "installed_closure": environment.installed_closure(), + "impl_modules": materials.impl_modules, + PROVIDERS_KEY: materials.providers, + "steps_use_model": steps_use_model, + "execution": {"max_workers": inputs.max_workers}, + "resumes": [], + } + + +def _reproducibility( + inputs: EngineSnapshotInputs, + materials: _Materials, + engine: dict[str, Any], + steps_use_model: bool, +) -> dict[str, Any]: + verdict, blockers = reproducibility.rerunnable( + engine_dirty=engine.get("dirty"), + repo_managed=inputs.repo_managed, + dataset=inputs.dataset, + resources=materials.collected.refs, + ) + config = materials.config + return { + "engine_dirty": engine.get("dirty"), + "rerunnable": verdict, + "rerunnable_blockers": blockers, + "deterministic": reproducibility.deterministic( + temperature=inputs.temperature, + resolved_pipeline=config["resolved_pipeline"], + steps_use_model=steps_use_model, + judge_model=config["evaluator_config"]["judge_model"], + ), + } + + +def build_engine_snapshot(inputs: EngineSnapshotInputs) -> dict[str, Any]: + """`{schema, config, config_fingerprint, missing, provenance, reproducibility}` 를 조립한다. + + Raises: + KnowledgeAssetError: 스텝이 선언한 자원을 로컬에서 찾을 수 없을 때(run 시작 전). + FileNotFoundError / ValueError: 프롬프트가 없거나 비었을 때, 구현 모듈을 풀 수 없을 때. + """ + roots = inputs.roots or impl_hash.default_roots() + materials = _collect_config(inputs, roots) + repo = _resolve_repo(inputs) + engine = _engine(inputs, repo, roots) + steps_use_model = any(step.uses_model for step in inputs.pipeline.steps) + return { + "schema": ENGINE_SNAPSHOT_SCHEMA, + "config": materials.config, + "config_fingerprint": config_fingerprint(materials.config), + "missing": ["resources"] if materials.collected.unresolved else [], + "provenance": _provenance(inputs, repo, engine, materials, steps_use_model), + "reproducibility": _reproducibility(inputs, materials, engine, steps_use_model), + } + + +def self_check(snapshot: Mapping[str, Any]) -> None: + """엔진 자기 검증(D5) — 이미 있는 poc-bundle 함수만 쓴다. + + Raises: + SnapshotError: schema 가 다르거나, 규범 키가 빠졌거나, 지문이 재계산과 다를 때. + """ + if snapshot.get("schema") != ENGINE_SNAPSHOT_SCHEMA: + raise SnapshotError(f"unknown snapshot schema {snapshot.get('schema')!r}") + config = snapshot.get("config") + if not isinstance(config, Mapping): + raise SnapshotError("snapshot.config must be an object") + gaps = completeness_gaps(config) + if gaps: + raise SnapshotError(f"snapshot.config lacks normative keys {gaps} (SPEC §3.4)") + expected = config_fingerprint(config) + if snapshot.get("config_fingerprint") != expected: + raise SnapshotError( + f"config_fingerprint {snapshot.get('config_fingerprint')!r} != recomputed {expected!r}" + ) + + +def config_diff_keys(a: Mapping[str, Any], b: Mapping[str, Any]) -> list[str]: + """두 `snapshot.config` 의 값이 다른 최상위 키(정렬) — resume 드리프트 메시지용(D17). + + 값 비교는 JCS 지문으로 한다(`0.0` ≡ `0`). + """ + keys = set(a) | set(b) + return sorted( + k + for k in keys + if k not in a or k not in b or json_fingerprint(a[k]) != json_fingerprint(b[k]) + ) + + +def _rescore_providers(provenance: Mapping[str, Any], judge_provider: Any) -> dict[str, str | None]: + """재채점의 프로바이더 모듈 — 추론은 기록된 이름, judge 는 넘겨받은 것(없으면 기록된 이름). + + Raises: + SnapshotError: 기록된 추론 프로바이더 모듈이 없어 런타임 해시를 다시 뜰 수 없을 때. + """ + recorded = provenance.get(PROVIDERS_KEY) + if not isinstance(recorded, Mapping) or not isinstance(recorded.get("inference"), str): + raise SnapshotError( + "snapshot.provenance.providers.inference is missing — cannot recompute " + "runtime_impl_hash for the new evaluator" + ) + judge = recorded.get("judge") + if judge_provider is not None: + judge = impl_hash.provider_module(judge_provider) + return {"inference": recorded["inference"], "judge": judge} + + +def with_evaluator( + snapshot: Mapping[str, Any], + evaluator_config: Mapping[str, Any] | None, + re_evaluated_at: str, + roots: ImplRoots | None = None, + judge_provider: Any = None, +) -> dict[str, Any]: + """`runs eval`(재채점)용 — 평가기 블록을 새 평가기로 바꾸고 지문을 다시 계산한 **새** 스냅샷(D11). + + judge 유무가 바뀌면 judge 프로바이더 모듈이 런타임 해시에 들어가거나 빠져야 하므로 + `runtime_impl_hash`·`impl_modules["@runtime"]` 도 빌드와 같은 규칙(`_runtime`)으로 다시 뜬다 — + 재채점 run 이 같은 기능 설정의 새 run 과 같은 지문을 갖게. `judge_provider` 를 안 주면 빌드 때 + 기록한 judge 모듈을 쓴다. `provenance.re_evaluated_at` 을 남기고 `deterministic` 도 다시 판정한다. + + Raises: + ValueError: 평가기 설정이 잘못됐을 때(`evaluator_block`). + SnapshotError: 기록된 프로바이더 모듈이 없어 런타임 해시를 다시 뜰 수 없을 때. + """ + roots = roots or impl_hash.default_roots() + updated = copy.deepcopy(dict(snapshot)) + block, modules = evaluator_block(evaluator_config, roots) + provenance = updated.setdefault("provenance", {}) + providers = _rescore_providers(provenance, judge_provider) + runtime_hash, runtime_modules = _runtime(providers, block["judge_model"], roots) + config = updated["config"] + config["evaluator_config"] = block + config["runtime_impl_hash"] = runtime_hash + updated["config_fingerprint"] = config_fingerprint(config) + impl_modules = provenance.setdefault("impl_modules", {}) + impl_modules[EVALUATOR_MODULES_KEY] = modules + impl_modules[RUNTIME_MODULES_KEY] = runtime_modules + provenance[PROVIDERS_KEY] = providers + provenance["re_evaluated_at"] = re_evaluated_at + steps_use_model = provenance.get("steps_use_model") + if isinstance(steps_use_model, bool): + updated.setdefault("reproducibility", {})["deterministic"] = reproducibility.deterministic( + temperature=config.get("temperature"), + resolved_pipeline=config.get("resolved_pipeline") or {}, + steps_use_model=steps_use_model, + judge_model=block["judge_model"], + ) + return updated + + +__all__ = [ + "AUTO", + "CONFIG_KEYS", + "DEFAULT_EVALUATOR_CONFIG", + "ENGINE_SNAPSHOT_SCHEMA", + "EngineSnapshotInputs", + "ImplRoots", + "PROVIDERS_KEY", + "SnapshotError", + "build_engine_snapshot", + "config_diff_keys", + "evaluator_block", + "self_check", + "with_evaluator", +] diff --git a/solar_eval/core/snapshot/dataset_block.py b/solar_eval/core/snapshot/dataset_block.py new file mode 100644 index 0000000000000000000000000000000000000000..75e0c6ca52ad571ba4981a9d668e5e9673463b47 --- /dev/null +++ b/solar_eval/core/snapshot/dataset_block.py @@ -0,0 +1,179 @@ +"""run.json `dataset` 블록(engine-dataset/v1)과 lock 대조 (계획 2026-09-24-phase1a S6 · D15). + +lock 대조의 정의(검토 F8) — `matched = true` 는 셋이 **모두** 맞을 때다: + +1. 읽은 파일 sha256 == lock 항목 `sha256` +2. 이 run 의 태스크 이름 ∈ lock 항목 `dataset.tasks` +3. 태스크의 `input_fields`·`golden_field`·`golden_fields` == lock 항목의 `inputFields`·`goldenField`· + `goldenFields` (lock `datasetVersion` 은 이 스펙으로 계산됐다) + +하나라도 다르면 `matched: false` + `mismatch: ["sha256" | "task" | "fields", …]`. 그래야 "전체셋 run 의 +`dataset_version` == lock `datasetVersion`" 이 조용히 깨지지 않는다. + +이 모듈은 engine-filter 의 타입(`PreparedDataset`)에 기대지 않고 **원시 값**을 받는다. +""" + +from __future__ import annotations + +import copy +import logging +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import Any + +from poc_bundle.data.lockfile import read_lock +from poc_bundle.data.lock_model import DatasetsLock, LockDatasetEntry + +logger = logging.getLogger(__name__) + +ENGINE_DATASET_SCHEMA = "engine-dataset/v1" + +#: config_dir 기준 lock 위치 (`poc-bundle data push` 가 쓴다). +LOCK_RELATIVE_PATH = Path("dataset") / "datasets.lock.yaml" + +#: lock 대조가 적용되는 로더 — lock 은 로컬 데이터 홈(cbs-data 에서 pull 한 datasets 영역)의 원장이다. +LOCAL_SOURCE = "local" + +MISMATCH_SHA = "sha256" +MISMATCH_TASK = "task" +MISMATCH_FIELDS = "fields" + + +def lock_path_for(config_dir: Path) -> Path: + return Path(config_dir) / LOCK_RELATIVE_PATH + + +def task_field_spec( + task_config: Mapping[str, Any], +) -> tuple[list[str], str | None, dict[str, str] | None]: + """(input_fields, golden_field, golden_fields) — lock 계산(`poc_bundle.data.taskfields._golden`)과 + 같은 정규화: 빈 값은 None, 둘 다 있으면 golden_field 가 이긴다(엔진 runner 규칙). + """ + inputs = list(task_config.get("input_fields") or []) + field = task_config.get("golden_field") or None + fields = task_config.get("golden_fields") or None + if field and fields: + fields = None + return inputs, field, dict(fields) if fields else None + + +def _display_path(path: Path, repo_root: Path | None) -> str: + if repo_root is not None: + try: + return path.resolve().relative_to(repo_root.resolve()).as_posix() + except ValueError: + pass + return path.resolve().as_posix() + + +def _find_entry(lock: DatasetsLock, dataset_path: str) -> LockDatasetEntry | None: + return next((e for e in lock.datasets if e.path == dataset_path), None) + + +def compare_entry( + entry: LockDatasetEntry, sha256: str, task_name: str, task_config: Mapping[str, Any] +) -> list[str]: + """lock 항목과 이 run 의 불일치 사유(순서 고정: sha256 → task → fields). 빈 목록 = matched.""" + mismatch: list[str] = [] + if entry.sha256 != sha256: + mismatch.append(MISMATCH_SHA) + info = entry.dataset + if info is None: + # dataset 블록이 없는 항목(.jsonl 이 아님)은 태스크·필드를 대조할 수 없다. + return [*mismatch, MISMATCH_TASK, MISMATCH_FIELDS] + if task_name not in info.tasks: + mismatch.append(MISMATCH_TASK) + inputs, field, fields = task_field_spec(task_config) + lock_fields = dict(info.golden_fields) if info.golden_fields else None + if (inputs, field, fields) != (list(info.input_fields), info.golden_field, lock_fields): + mismatch.append(MISMATCH_FIELDS) + return mismatch + + +def lock_block( + *, + sha256: str, + dataset_path: str, + task_config: Mapping[str, Any], + config_dir: Path | None, + project_name: str, + repo_root: Path | None = None, +) -> dict[str, Any] | None: + """`dataset.lock` — lock 이 없거나·항목이 없거나·프로젝트 이름이 다르면 None. + + Raises: + poc_bundle.data.errors.LockError: lock 파일이 규칙(L01~L20)을 어길 때 — 깨진 원장으로 대조한 + run 을 만들지 않는다(`make data-lint` 가 같은 오류를 낸다). + """ + if config_dir is None: + return None + path = lock_path_for(config_dir) + if not path.is_file(): + return None + lock = read_lock(path) + if lock.project is not None and lock.project != project_name: + logger.warning( + "datasets.lock.yaml project %r != run project %r — lock not used for %s", + lock.project, + project_name, + dataset_path, + ) + return None + entry = _find_entry(lock, dataset_path) + if entry is None: + return None + mismatch = compare_entry(entry, sha256, str(task_config.get("name", "")), task_config) + info = entry.dataset + return { + "path": _display_path(path, repo_root), + "matched": not mismatch, + "mismatch": mismatch, + "dataset_version": info.dataset_version if info else None, + "source": info.source.model_dump(mode="json") if info and info.source else None, + "builder": info.builder.model_dump(mode="json") if info and info.builder else None, + } + + +def describe_dataset( + *, + sha256: str, + rows_total: int, + rows_selected: int, + selection: Mapping[str, Any] | None, + meta_fields: Sequence[str] | None, + task_config: Mapping[str, Any], + loader_cfg: Mapping[str, Any], + config_dir: Path | None, + project_name: str, + repo_root: Path | None = None, +) -> dict[str, Any]: + """run.json `dataset` 블록. `selection` 은 engine-filter 가 정규화한 JSON(`{where, limit}`). + + Raises: + ValueError: 행 수가 음수이거나 선택 행이 전체보다 많을 때(호출자 버그를 크게 알린다). + """ + if rows_total < 0 or rows_selected < 0 or rows_selected > rows_total: + raise ValueError(f"invalid row counts: selected={rows_selected}, total={rows_total}") + source = loader_cfg.get("source", "huggingface") + dataset_path = str(task_config.get("dataset_path", "")) + lock = None + if source == LOCAL_SOURCE: + lock = lock_block( + sha256=sha256, + dataset_path=dataset_path, + task_config=task_config, + config_dir=config_dir, + project_name=project_name, + repo_root=repo_root, + ) + return { + "schema": ENGINE_DATASET_SCHEMA, + "loader": {"source": source, "repo": loader_cfg.get("repo", "")}, + "path": dataset_path, + "sha256": sha256, + "rows_total": rows_total, + "rows_selected": rows_selected, + "selection": copy.deepcopy(dict(selection)) if selection is not None else None, + "meta_fields": list(meta_fields) if meta_fields else None, + "lock": lock, + } diff --git a/solar_eval/core/snapshot/environment.py b/solar_eval/core/snapshot/environment.py new file mode 100644 index 0000000000000000000000000000000000000000..b0d4fb6095bf50dbea619552d5ac47289039f4f2 --- /dev/null +++ b/solar_eval/core/snapshot/environment.py @@ -0,0 +1,210 @@ +"""실행 환경 재료 — 의존 버전·엔진 버전·엔진 레포 상태 (계획 2026-09-24-phase1a D8·D9). + +config 에 들어가는 것(지문 대상)은 출력 경로에 있는 **직접** 런타임 의존과 엔진 버전·파이썬 부버전뿐이다. +전이 설치 폐포·uv.lock·엔진 커밋·트리 해시·dirty 는 provenance(지문 밖)로 간다 — 무관한 워크스페이스 +멤버의 변화가 설정 지문을 흔들지 않게. +""" + +from __future__ import annotations + +import logging +import subprocess +import sys +from collections.abc import Callable +from importlib import metadata +from pathlib import Path +from typing import Any + +from packaging.requirements import InvalidRequirement, Requirement +from packaging.utils import canonicalize_name +from poc_bundle.canonical import content_sha256 + +import solar_eval +from solar_eval._version import __version__ +from solar_eval.core.snapshot.impl_hash import ImplRoots, engine_tree_sha256 + +logger = logging.getLogger(__name__) + +#: 엔진 배포 이름. +ENGINE_DISTRIBUTION = "solar-eval" + +#: 모델 입출력에 닿지 않는 직접 의존 — CLI 표시·설정 로드·번들 도구. `dependency_lock` 에서 뺀다. +#: 새 직접 의존을 추가하면 이 목록이나 출력 의존 중 하나로 분류하기 전까지 테스트가 깨진다(의도). +NON_OUTPUT_DEPENDENCIES = frozenset( + {"click", "tabulate", "rich", "pydantic-settings", "poc-bundle", "packaging"} +) + +DEPENDENCY_LOCK_SCHEME = "direct-runtime/v1" + +#: config_dir 안에서 출력에 영향을 주는 경로(dirty 범위). scripts/·insights/·README 는 뺀다(검토 F12·m3). +CONFIG_DIRTY_ENTRIES = ( + "project.yaml", + "prompts", + "pipelines", + "resources", + "dataset/datasets.lock.yaml", +) + +GitRun = Callable[..., Any] + + +def _marker_env() -> dict[str, str]: + return {"extra": ""} + + +def direct_requirements(distribution: str = ENGINE_DISTRIBUTION) -> list[str]: + """배포의 직접 `Requires-Dist` 중 현재 환경에서 켜지는 것(extra 없음)의 정규화 이름(정렬).""" + names: set[str] = set() + for raw in metadata.requires(distribution) or []: + try: + req = Requirement(raw) + except InvalidRequirement as exc: + raise ValueError(f"{distribution}: unparsable requirement {raw!r}: {exc}") from exc + if req.marker is not None and not req.marker.evaluate(_marker_env()): + continue + names.add(canonicalize_name(req.name)) + return sorted(names) + + +def direct_runtime_dependencies() -> dict[str, str]: + """`dependency_lock.packages` — 출력 경로에 있는 직접 의존의 설치 버전.""" + return { + name: metadata.version(name) + for name in direct_requirements() + if name not in NON_OUTPUT_DEPENDENCIES + } + + +def dependency_lock() -> dict[str, Any]: + return { + "scheme": DEPENDENCY_LOCK_SCHEME, + "root": ENGINE_DISTRIBUTION, + "packages": direct_runtime_dependencies(), + } + + +def installed_closure(distribution: str = ENGINE_DISTRIBUTION) -> dict[str, str | None]: + """엔진에서 닿는 설치 폐포 `{이름: 버전}` — provenance 전용(환경 재구성·디버그). + + 켜진 요구인데 설치돼 있지 않으면 버전 None 으로 남긴다(숨기지 않는다). + """ + seen: dict[str, str | None] = {} + queue: list[str] = [str(canonicalize_name(distribution))] + while queue: + name = queue.pop() + if name in seen: + continue + try: + seen[name] = metadata.version(name) + except metadata.PackageNotFoundError: + seen[name] = None + continue + queue.extend(n for n in direct_requirements(name) if n not in seen) + return dict(sorted(seen.items())) + + +def runner_version() -> dict[str, str]: + """`{"package", "version", "python": "."}` — 패치 버전은 넣지 않는다.""" + return { + "package": ENGINE_DISTRIBUTION, + "version": __version__, + "python": f"{sys.version_info.major}.{sys.version_info.minor}", + } + + +def engine_src_dir() -> Path: + """엔진 소스 루트(`packages/solar-eval-python/src`) — `solar_eval.__file__` 기준.""" + return Path(solar_eval.__file__).resolve().parent.parent + + +def engine_repo(start: Path | None = None) -> Path | None: + """엔진 코드가 든 git 레포 — `start`(기본 엔진 소스)에서 위로 `.git` 을 찾는다. + + `cfg.repo_root` 를 쓰지 않는다: `--projects-dir` 면 None 이 된다(검토 F13). 워크트리의 `.git` 은 + 파일이라 존재만 본다. + """ + here = (start or engine_src_dir()).resolve() + for candidate in (here, *here.parents): + if (candidate / ".git").exists(): + return candidate + return None + + +def dirty_scope( + repo: Path, config_dir: Path | None, repo_managed: bool, src_dir: Path | None = None +) -> list[str]: + """dirty 판정 범위(레포 상대 POSIX, 정렬). 레포 밖 경로는 넣지 않는다.""" + src = (src_dir or engine_src_dir()).resolve() + candidates = [src, src.parent / "pyproject.toml", repo / "uv.lock"] + if repo_managed and config_dir is not None: + candidates += [Path(config_dir).resolve() / entry for entry in CONFIG_DIRTY_ENTRIES] + root = repo.resolve() + scope = set() + for path in candidates: + try: + scope.add(path.resolve().relative_to(root).as_posix()) + except ValueError: + logger.warning("dirty scope path outside the engine repo, skipped: %s", path) + return sorted(scope) + + +def _git(run: GitRun, repo: Path, *args: str) -> str | None: + """git 한 번. 실패(git 없음·레포 아님·비정상 종료)는 None — 호출자가 null 로 기록한다.""" + try: + result = run(["git", "-C", str(repo), *args], capture_output=True, text=True, check=True) + except (OSError, subprocess.CalledProcessError) as exc: + logger.warning("git %s failed in %s: %s", " ".join(args[:2]), repo, exc) + return None + return result.stdout + + +def engine_state( + config_dir: Path | None, + repo_managed: bool, + *, + repo: Path | None = None, + src_dir: Path | None = None, + roots: ImplRoots | None = None, + run: GitRun = subprocess.run, +) -> dict[str, Any]: + """`provenance.engine = {commit, tree_sha256, dirty, dirty_scope}` (D8). + + `repo` 를 안 주면 `engine_repo()`. 레포를 못 찾거나 git 이 실패하면 `commit`·`dirty` 는 null, + `tree_sha256` 은 git 없이도 계산된다. + """ + repo = repo if repo is not None else engine_repo(src_dir) + tree = engine_tree_sha256(roots) + if repo is None: + return {"commit": None, "tree_sha256": tree, "dirty": None, "dirty_scope": []} + scope = dirty_scope(repo, config_dir, repo_managed, src_dir) + head = _git(run, repo, "rev-parse", "HEAD") + # 빈 범위로 status 를 부르면 레포 전체를 본다 — 범위가 없으면 판정하지 않는다(null). + # `--literal-pathspecs`: config_dir 이름의 `[`·`*`·`?` 를 glob 으로 읽지 않게(범위 밖 변경이 dirty 로). + status = ( + _git( + run, + repo, + "--literal-pathspecs", + "status", + "--porcelain", + "--untracked-files=normal", + "--", + *scope, + ) + if scope + else None + ) + return { + "commit": head.strip() if head else None, + "tree_sha256": tree, + "dirty": None if status is None else bool(status.strip()), + "dirty_scope": scope, + } + + +def uv_lock_sha256(repo: Path | None) -> str | None: + """레포 `uv.lock` 의 sha256 — provenance 전용(모노레포 lock 은 무관한 멤버로도 바뀐다).""" + if repo is None: + return None + lock = repo / "uv.lock" + return content_sha256(lock) if lock.is_file() else None diff --git a/solar_eval/core/snapshot/impl_hash.py b/solar_eval/core/snapshot/impl_hash.py new file mode 100644 index 0000000000000000000000000000000000000000..955f96071550ad9478534df3b93f9c60122fbd64 --- /dev/null +++ b/solar_eval/core/snapshot/impl_hash.py @@ -0,0 +1,365 @@ +"""구현 해시 — 모듈의 AST import 폐포 × 파일 바이트 (계획 2026-09-24-phase1a D7). + +세 갈래가 같은 재료를 쓴다: + +- **스텝**(`step_impl_hashes`): 스텝 클래스 모듈에서 시작한 import 폐포. +- **평가기**(`evaluator_impl_hash`): 평가기 클래스(합성이면 하위 전부)의 모듈에서 시작한 폐포. +- **런타임**(`runtime_impl_hash`): `RUNTIME_MODULES` + 실제로 쓴 프로바이더 모듈 — 폐포를 돌지 않고 + 목록의 파일 바이트만 본다(목록이 곧 정의). + +모듈 → 파일은 **이름으로** 푼다(`ImplRoots`). 이미 import 된 모듈의 `__file__` 이나 importlib spec 을 +쓰지 않으므로, 테스트가 기본 루트를 tmp 사본으로 바꿔 끼우면 재import 없이 "구현만 바꾼" 상황이 +만들어진다. 해시는 계산하는 순간 디스크의 파일을 읽는다. +""" + +from __future__ import annotations + +import ast +import os +import sys +import sysconfig +from collections.abc import Iterable, Sequence +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from poc_bundle.canonical import content_sha256, json_fingerprint + +import solar_eval + +#: 엔진 패키지 이름 — 패키지 루트에서는 이 이름 아래 모듈만 푼다(서드파티 차단). +ENGINE_PACKAGE = "solar_eval" + +#: 러너·파이프라인 실행기·프롬프트 조립 — 모든 스텝에 똑같이 영향을 주는 프레임워크 모듈. +#: 여기에 이번 run 이 쓰는 프로바이더 모듈을 더해 `runtime_impl_hash` 를 뜬다. 테스트가 집합을 고정한다. +#: 러너에서 떼어 낸 도우미(입력·정답 추출 `run_records`, 행 선택 `dataset_prep`·`row_filter`, 파이프라인 +#: 생성 `pipeline_factory`)도 러너 로직이라 함께 본다 — `solar_eval.core.*` 는 스텝·평가기 폐포에서 빠지므로 +#: 여기 없으면 어떤 해시도 이 파일들을 보지 않는다. 테스트가 러너의 core import 가 전부 여기 있는지 지킨다. +RUNTIME_MODULES: tuple[str, ...] = ( + "solar_eval.core.runner", + "solar_eval.core.dataset_loader", + "solar_eval.core.dataset_prep", + "solar_eval.core.row_filter", + "solar_eval.core.pipeline_factory", + "solar_eval.core.run_records", + "solar_eval.pipelines.pipeline", + "solar_eval.pipelines.registry", + "solar_eval.pipelines.base", + "solar_eval.evaluators.base", + "solar_eval.evaluators.registry", + "solar_eval.providers.base", + "solar_eval.models.prompt_version", + "solar_eval.models.sample", +) + +#: 스텝·평가기 폐포에서 빼는 공용 기반 — 런타임 해시가 한 번 보거나 출력과 무관한 것. +_EXCLUDED_EXACT = frozenset( + {*RUNTIME_MODULES, "solar_eval.pipelines.errors", "solar_eval.config", "solar_eval._version"} +) +_EXCLUDED_PREFIXES = ( + "solar_eval.providers.", + "solar_eval.cli.", + "solar_eval.stores.", + "solar_eval.core.", +) + +#: 구현 해시 라벨 접두어 — `pkg:<루트 기준 POSIX 경로>`. +LABEL_PREFIX = "pkg:" +#: 루트에서 풀 수 없는 프로바이더 모듈(테스트 mock 등)의 라벨 접두어. 값은 null 로 들어간다. +UNRESOLVED_PREFIX = "unresolved:" + + +class ImplRootError(ValueError): + """구현 해시 루트로 쓸 수 없는 디렉토리(서드파티·표준 라이브러리 영역).""" + + +def _forbidden_bases() -> list[Path]: + """등록 루트로 거부하는 영역 — site-packages·표준 라이브러리·인터프리터 prefix.""" + paths = sysconfig.get_paths() + bases = [paths.get(k) for k in ("purelib", "platlib", "stdlib")] + bases += [sys.prefix, sys.base_prefix] + return [Path(b).resolve() for b in bases if b] + + +def _is_within(path: Path, base: Path) -> bool: + try: + path.relative_to(base) + except ValueError: + return False + return True + + +@dataclass(frozen=True) +class ResolvedModule: + """이름으로 푼 모듈 하나. `label` 은 지문 키(`pkg:solar_eval/pipelines/rules.py`).""" + + name: str + path: Path + label: str + + +@dataclass(frozen=True) +class ImplRoots: + """모듈 이름 → 파일 해석 루트. + + - `package_parent`: `solar_eval` 패키지의 부모 디렉토리. 여기서는 `solar_eval.*` 이름만 푼다 — + 엔진이 site-packages 에 설치돼 있어도 서드파티가 폐포에 들어오지 않는다. + - `extra`: 테스트·확장이 명시 등록한 디렉토리. 여기서는 어떤 이름이든 푼다. 서드파티·표준 + 라이브러리 영역(`sysconfig` purelib/platlib/stdlib, `sys.prefix`, `sys.base_prefix`)은 거부한다 + (uv 워크스페이스 venv 가 레포 안에 있어 "레포 안" 규칙이면 httpx 가 폐포에 들어온다 — 검토 F3). + """ + + package_parent: Path + extra: tuple[Path, ...] = field(default=()) + + def __post_init__(self) -> None: + forbidden = _forbidden_bases() + for root in self.extra: + resolved = Path(root).resolve() + if any(_is_within(resolved, base) for base in forbidden): + raise ImplRootError( + f"{root} is inside the interpreter/site-packages area — third-party code is " + "covered by dependency_lock, not by implementation hashes" + ) + + @classmethod + def for_engine(cls, extra: Iterable[Path] = ()) -> ImplRoots: + """지금 import 된 `solar_eval` 패키지의 부모 + 등록 디렉토리.""" + parent = Path(solar_eval.__file__).resolve().parent.parent + return cls(parent, tuple(Path(p) for p in extra)) + + def with_extra(self, *dirs: Path) -> ImplRoots: + return ImplRoots(self.package_parent, (*self.extra, *(Path(d) for d in dirs))) + + def resolve(self, name: str) -> ResolvedModule | None: + """모듈 이름을 파일로 푼다: `R//.py` 또는 `R///__init__.py`. 없으면 None.""" + parts = name.split(".") + if not all(p.isidentifier() for p in parts): + return None + roots: list[Path] = [] + if parts[0] == ENGINE_PACKAGE: + roots.append(self.package_parent) + roots.extend(self.extra) + for root in roots: + base = Path(root).joinpath(*parts) + for candidate in (base.with_suffix(".py"), base / "__init__.py"): + if candidate.is_file() and _exact_case(Path(root), candidate): + rel = candidate.relative_to(root).as_posix() + return ResolvedModule(name, candidate, LABEL_PREFIX + rel) + return None + + +def _exact_case(root: Path, candidate: Path) -> bool: + """`candidate` 의 경로 성분이 디렉토리 목록의 이름과 **대소문자까지** 같은가. + + macOS APFS 의 `is_file()` 은 대소문자를 무시한다 — `from pkg import Helper`(클래스)가 + 형제 모듈 `pkg/helper.py` 로 잘못 풀려, 같은 코드가 Linux 와 다른 폐포·라벨을 낸다. + 파이썬 import 는 대소문자를 구분하므로 여기서도 목록과 정확히 대조해 Linux 와 같게 한다. + """ + current = root + for part in candidate.relative_to(root).parts: + try: + names = os.listdir(current) + except OSError: + return False + if part not in names: + return False + current = current / part + return True + + +# 기본 루트 — None 이면 `ImplRoots.for_engine()`. 테스트는 `set_default_roots` 로 사본을 끼운다. +_DEFAULT_ROOTS: ImplRoots | None = None + + +def default_roots() -> ImplRoots: + """기본 해석 루트. `set_default_roots` 로 바꾼 값이 있으면 그것.""" + return _DEFAULT_ROOTS if _DEFAULT_ROOTS is not None else ImplRoots.for_engine() + + +def set_default_roots(roots: ImplRoots | None) -> ImplRoots | None: + """기본 루트를 바꾸고 이전 값을 돌려준다(복원용). None 이면 엔진 기본으로 되돌린다.""" + global _DEFAULT_ROOTS + previous = _DEFAULT_ROOTS + _DEFAULT_ROOTS = roots + return previous + + +def is_excluded(module: ResolvedModule) -> bool: + """스텝·평가기 폐포에서 빼는가 — 런타임 목록·공용 기반·모든 패키지 `__init__`.""" + if module.path.name == "__init__.py": + return True + if module.name in _EXCLUDED_EXACT: + return True + return module.name.startswith(_EXCLUDED_PREFIXES) + + +def _package_of(module: ResolvedModule) -> str: + if module.path.name == "__init__.py": + return module.name + return module.name.rpartition(".")[0] + + +def _absolute_from(module: ResolvedModule, node: ast.ImportFrom) -> str | None: + """`from ..x import y` 의 기준 모듈 이름. 최상위 밖으로 나가면 None.""" + if node.level == 0: + return node.module + package = _package_of(module).split(".") if _package_of(module) else [] + keep = len(package) - (node.level - 1) + if keep < 0 or (keep == 0 and not node.module): + return None + base = package[:keep] + if node.module: + base += node.module.split(".") + return ".".join(base) if base else None + + +def imported_names(module: ResolvedModule, roots: ImplRoots) -> list[str]: + """모듈 소스의 **모든** import(함수 안·TYPE_CHECKING 블록 포함)가 가리키는 모듈 이름. + + `from P import X` 는 `P.X` 가 모듈이면 그 모듈, 아니면 `P`. + """ + tree = ast.parse(module.path.read_bytes(), filename=str(module.path)) + names: list[str] = [] + for node in ast.walk(tree): + if isinstance(node, ast.Import): + names.extend(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom): + base = _absolute_from(module, node) + if base is None: + continue + for alias in node.names: + candidate = f"{base}.{alias.name}" + is_module = alias.name != "*" and roots.resolve(candidate) is not None + names.append(candidate if is_module else base) + return names + + +def import_closure( + start: Iterable[str], roots: ImplRoots, *, exclude: bool = True +) -> dict[str, ResolvedModule]: + """시작 모듈들에서 루트 안으로 닿는 import 폐포. 시작 모듈은 제외 규칙과 무관하게 들어간다. + + Raises: + ValueError: 시작 모듈을 루트에서 풀 수 없을 때(구현을 볼 수 없는 스텝·평가기). + """ + closure: dict[str, ResolvedModule] = {} + queue: list[ResolvedModule] = [] + for name in start: + resolved = roots.resolve(name) + if resolved is None: + raise ValueError( + f"module {name!r} is not under the implementation roots " + f"({roots.package_parent}, extra={list(roots.extra)}) — register its directory" + ) + queue.append(resolved) + while queue: + module = queue.pop() + if module.name in closure: + continue + closure[module.name] = module + for name in imported_names(module, roots): + found = roots.resolve(name) + if found is None or found.name in closure: + continue + if exclude and is_excluded(found): + continue + queue.append(found) + return closure + + +def impl_hash(modules: Iterable[ResolvedModule]) -> str: + """`json_fingerprint({label: content_sha256(파일 바이트)})` — 주석 변경도 해시를 바꾼다(의도).""" + return json_fingerprint({m.label: content_sha256(m.path) for m in modules}) + + +def closure_hash(start: Iterable[str], roots: ImplRoots) -> tuple[str, list[str]]: + """(폐포 해시, 정렬된 라벨 목록).""" + closure = import_closure(start, roots) + return impl_hash(closure.values()), sorted(m.label for m in closure.values()) + + +def step_impl_hashes( + pipeline: Any, roots: ImplRoots | None = None +) -> tuple[dict[str, str], dict[str, list[str]]]: + """생성된 `Pipeline` 의 스텝마다 (키 → 해시, 키 → 폐포 라벨). 키는 레지스트리 조회 이름. + + single_step 의 암묵 `llm_completion` 도 파이프라인 객체에 있으므로 들어간다(검토 F10). + """ + from solar_eval.pipelines.registry import step_key + + roots = roots or default_roots() + hashes: dict[str, str] = {} + modules: dict[str, list[str]] = {} + for step in pipeline.steps: + key = step_key(step.config) + if key in hashes: + continue + hashes[key], modules[key] = closure_hash([type(step).__module__], roots) + return dict(sorted(hashes.items())), dict(sorted(modules.items())) + + +def _evaluator_modules(evaluator: Any) -> list[str]: + names = [type(evaluator).__module__] + for child in evaluator.sub_evaluators(): + names.extend(_evaluator_modules(child)) + return names + + +def evaluator_impl_hash(evaluator: Any, roots: ImplRoots | None = None) -> tuple[str, list[str]]: + """평가기(합성이면 하위 전부) 모듈에서 시작한 폐포의 (해시, 라벨).""" + return closure_hash(_evaluator_modules(evaluator), roots or default_roots()) + + +def provider_module(provider: Any) -> str: + """프로바이더(인스턴스나 클래스)가 정의된 모듈 이름.""" + return provider.__module__ if isinstance(provider, type) else type(provider).__module__ + + +def runtime_impl_hash( + providers: Sequence[Any], roots: ImplRoots | None = None +) -> tuple[str, list[str]]: + """`RUNTIME_MODULES` + 프로바이더 모듈의 파일 바이트 지문 (폐포 없음). + + 프로바이더는 인스턴스나 클래스를 받는다. 루트에서 풀 수 없는 프로바이더 모듈(테스트 mock 등)은 + `unresolved:<모듈>` 키에 null 로 들어간다 — 모르는 코드를 조용히 빼지 않는다. + + Raises: + ValueError: `RUNTIME_MODULES` 중 하나를 루트에서 풀 수 없을 때. + """ + return runtime_impl_hash_for_modules([provider_module(p) for p in providers], roots) + + +def runtime_impl_hash_for_modules( + provider_modules: Iterable[str], roots: ImplRoots | None = None +) -> tuple[str, list[str]]: + """`runtime_impl_hash` 의 모듈 이름판 — 재채점(`with_evaluator`)이 기록된 모듈 이름으로 다시 뜬다.""" + roots = roots or default_roots() + entries: dict[str, str | None] = {} + for name in RUNTIME_MODULES: + resolved = roots.resolve(name) + if resolved is None: + raise ValueError(f"runtime module {name!r} not found under {roots.package_parent}") + entries[resolved.label] = content_sha256(resolved.path) + for name in sorted(set(provider_modules)): + resolved = roots.resolve(name) + if resolved is None: + entries[UNRESOLVED_PREFIX + name] = None + else: + entries[resolved.label] = content_sha256(resolved.path) + return json_fingerprint(entries), sorted(entries) + + +def engine_tree_sha256(roots: ImplRoots | None = None) -> str: + """엔진 패키지 트리(`solar_eval/**/*.py`, `__pycache__` 제외)의 파일 바이트 지문 — provenance 용. + + git 없이도 계산된다. 키는 패키지 부모 기준 POSIX 경로(`solar_eval/core/runner.py`). + """ + parent = (roots or default_roots()).package_parent + package = Path(parent) / ENGINE_PACKAGE + entries = { + path.relative_to(parent).as_posix(): content_sha256(path) + for path in sorted(package.rglob("*.py")) + if "__pycache__" not in path.relative_to(parent).parts + } + return json_fingerprint(entries) diff --git a/solar_eval/core/snapshot/reproducibility.py b/solar_eval/core/snapshot/reproducibility.py new file mode 100644 index 0000000000000000000000000000000000000000..09c74557f4f665a62cf075a6df8317a8859ce091 --- /dev/null +++ b/solar_eval/core/snapshot/reproducibility.py @@ -0,0 +1,149 @@ +"""run.json `snapshot.reproducibility` — 다시 돌릴 수 있는가·결정적인가 (계획 2026-09-24-phase1a D10·D17). + +두 필드 모두 run.json(과 번들 `config_snapshot.json` provenance)에만 둔다. 매니페스트 노출은 +`poc-bundle/v2` 로 미룬다(Q8). +""" + +from __future__ import annotations + +import copy +from collections.abc import Iterable, Mapping, Sequence +from typing import Any + +from solar_eval.pipelines.pipeline import ResourceRef + +BLOCKER_ENGINE_DIRTY = "engine_dirty" +BLOCKER_CONFIG_OUTSIDE_REPO = "config_outside_repo" +BLOCKER_DATASET_NOT_LOCKED = "dataset_not_locked" +BLOCKER_DATASET_LOCK_MISMATCH = "dataset_lock_mismatch" +BLOCKER_DATASET_REMOTE = "dataset_remote_source" +BLOCKER_RESUMED_ENGINE_CHANGE = "resumed_with_engine_change" +BLOCKER_RESOURCE_UNRESOLVED = "resource_unresolved:" +BLOCKER_RESOURCE_OUTSIDE_REPO = "resource_outside_repo:" + +_LOCAL_SOURCE = "local" + + +def _dataset_blockers(dataset: Mapping[str, Any] | None) -> list[str]: + if dataset is None: + return [BLOCKER_DATASET_NOT_LOCKED] + if dataset.get("loader", {}).get("source") != _LOCAL_SOURCE: + return [BLOCKER_DATASET_REMOTE] + lock = dataset.get("lock") + if lock is None: + return [BLOCKER_DATASET_NOT_LOCKED] + if not lock.get("matched"): + return [BLOCKER_DATASET_LOCK_MISMATCH] + return [] + + +def _resource_blockers(refs: Mapping[str, ResourceRef]) -> list[str]: + blockers: list[str] = [] + for label, ref in sorted(refs.items()): + if ref.path is None: + blockers.append(BLOCKER_RESOURCE_UNRESOLVED + label) + elif ref.kind != "config": + # data: 자원(PMI 등)은 레포 밖 데이터 홈이고 lock 대상도 아니다 — 커밋으로 복원할 수 없다. + blockers.append(BLOCKER_RESOURCE_OUTSIDE_REPO + label) + return blockers + + +def _verdict(blockers: Sequence[str], engine_dirty: bool | None) -> bool | None: + if blockers: + return False + return None if engine_dirty is None else True + + +def rerunnable( + *, + engine_dirty: bool | None, + repo_managed: bool, + dataset: Mapping[str, Any] | None, + resources: Mapping[str, ResourceRef], +) -> tuple[bool | None, list[str]]: + """(rerunnable, 막는 이유 목록). true = 기록된 정보만으로 같은 입력을 다시 만들 수 있다. + + 막는 이유가 하나라도 있으면 false, 없는데 `engine_dirty` 가 null(git 불명)이면 null. + """ + blockers: list[str] = [] + if engine_dirty: + blockers.append(BLOCKER_ENGINE_DIRTY) + if not repo_managed: + blockers.append(BLOCKER_CONFIG_OUTSIDE_REPO) + blockers += _dataset_blockers(dataset) + blockers += _resource_blockers(resources) + return _verdict(blockers, engine_dirty), blockers + + +def _positive(value: Any) -> bool: + return isinstance(value, int | float) and not isinstance(value, bool) and value > 0 + + +def _step_temperatures(step: Mapping[str, Any]) -> Iterable[Any]: + yield step.get("temperature") + temps = step.get("repeat_temperatures") + if isinstance(temps, Sequence) and not isinstance(temps, str | bytes): + yield from temps + + +def deterministic( + *, + temperature: Any, + resolved_pipeline: Mapping[str, Any], + steps_use_model: bool, + judge_model: str | None, +) -> bool | None: + """true = 모델 호출이 전혀 없다 · false = 샘플링 온도 > 0 이 확인됨 · null = 온도 0 의 LLM 호출. + + API 는 온도 0 에서도 결정적이라는 보장이 없으므로 그 경우는 모른다(null, Q7). + """ + if not steps_use_model and judge_model is None: + return True + temps: list[Any] = [temperature] + for step in resolved_pipeline.get("steps") or []: + if isinstance(step, Mapping): + temps.extend(_step_temperatures(step)) + if any(_positive(t) for t in temps): + return False + return None + + +def _or3(values: Iterable[bool | None]) -> bool | None: + """하나라도 true → true, 아니고 하나라도 null → null, 모두 false → false.""" + items = list(values) + if any(v is True for v in items): + return True + if any(v is None for v in items): + return None + return False + + +def merge_resume(snapshot: Mapping[str, Any], resume_engine: Mapping[str, Any], at: str) -> dict: + """resume 1회를 스냅샷에 반영한 **새** 스냅샷(입력은 바꾸지 않는다, D17). + + - `provenance.resumes` 에 `{at, engine: {commit, tree_sha256, dirty}}` 를 덧붙인다. + - `reproducibility.engine_dirty` = 시작·모든 resume 의 dirty OR. + - 엔진 트리 해시가 시작과 다르면 `resumed_with_engine_change` 를 막는 이유에 더한다. + config·지문은 건드리지 않는다 — 설정 드리프트는 호출자가 지문 비교로 먼저 막는다. + """ + merged = copy.deepcopy(dict(snapshot)) + provenance = merged.setdefault("provenance", {}) + start = provenance.get("engine") or {} + entry = { + "at": at, + "engine": {k: resume_engine.get(k) for k in ("commit", "tree_sha256", "dirty")}, + } + resumes = [*provenance.get("resumes", []), entry] + provenance["resumes"] = resumes + repro = merged.setdefault("reproducibility", {}) + dirty = _or3([start.get("dirty"), *(r["engine"]["dirty"] for r in resumes)]) + blockers = list(repro.get("rerunnable_blockers", [])) + if dirty and BLOCKER_ENGINE_DIRTY not in blockers: + blockers.insert(0, BLOCKER_ENGINE_DIRTY) + changed = any(r["engine"]["tree_sha256"] != start.get("tree_sha256") for r in resumes) + if changed and BLOCKER_RESUMED_ENGINE_CHANGE not in blockers: + blockers.append(BLOCKER_RESUMED_ENGINE_CHANGE) + repro["engine_dirty"] = dirty + repro["rerunnable_blockers"] = blockers + repro["rerunnable"] = _verdict(blockers, dirty) + return merged diff --git a/solar_eval/core/snapshot/resources.py b/solar_eval/core/snapshot/resources.py new file mode 100644 index 0000000000000000000000000000000000000000..21404ecca1c45053321b03ec394039cd9c40bc61 --- /dev/null +++ b/solar_eval/core/snapshot/resources.py @@ -0,0 +1,78 @@ +"""스냅샷의 파일 재료 — 스텝이 읽을 자원(`resources`)과 프롬프트 파일(`prompt_assets`). + +둘 다 **내용**만 본다: 디렉토리·파일 이름은 값에 들어가지 않으므로(프롬프트는 디렉토리 기준 상대경로가 +키) 이름만 바꾼 사본은 같은 값이다 (계획 2026-09-24-phase1a D6). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from poc_bundle.canonical import content_sha256 + +from solar_eval.core.fingerprint import iter_prompt_asset_files +from solar_eval.pipelines.pipeline import ResourceRef + +#: 단일 YAML 프롬프트 파일의 `prompt_assets` 키 — 파일 이름이 지문에 들어가지 않게. +SINGLE_FILE_KEY = "@file" + + +@dataclass(frozen=True) +class CollectedResources: + """`resources` 값과 그 해석 결과. + + Attributes: + hashes: `{라벨: sha256_64 | None}`(라벨 정렬). None = 해석 못 함(`hf:` 선언 등). + refs: 라벨 → 원래 `ResourceRef`(재현 판정이 종류를 본다). + """ + + hashes: dict[str, str | None] + refs: dict[str, ResourceRef] + + @property + def unresolved(self) -> list[str]: + """값이 None 인 라벨 — 하나라도 있으면 `snapshot.missing` 에 `resources` 가 오른다.""" + return [label for label, value in self.hashes.items() if value is None] + + +def collect_resources(pipeline: Any) -> CollectedResources: + """`Pipeline` 인스턴스의 스텝마다 `resource_refs()` 를 모아 파일 sha256 을 뜬다. + + 같은 라벨(같은 사전을 두 스텝이 쓰는 prod 파이프라인)은 한 번만 적는다. `hf:` 자원은 네트워크를 + 타지 않고 null 이다. 선언했는데 로컬에 없는 자원은 스텝의 `resource_refs()` 가 + `KnowledgeAssetError` 로 죽인다 — run 시작 전에. + """ + refs: dict[str, ResourceRef] = {} + for step in pipeline.steps: + for ref in step.resource_refs(): + refs.setdefault(ref.label, ref) + hashes: dict[str, str | None] = {} + for label in sorted(refs): + path = refs[label].path + hashes[label] = content_sha256(path) if path is not None else None + return CollectedResources(hashes, {label: refs[label] for label in sorted(refs)}) + + +def prompt_assets(prompt_path: Path) -> dict[str, str]: + """프롬프트의 `{상대경로: sha256_64}` — 디렉토리면 legacy `prompt_sha` 와 같은 파일 집합. + + 단일 파일(YAML 프롬프트)은 `{"@file": sha256_64}`. + + Raises: + FileNotFoundError: 경로가 없을 때. + ValueError: 디렉토리에 지문 대상 파일이 하나도 없을 때(빈 프롬프트로 돈 run 을 막는다). + """ + path = Path(prompt_path) + if path.is_dir(): + assets = { + file.relative_to(path).as_posix(): content_sha256(file) + for file in iter_prompt_asset_files(path) + } + if not assets: + raise ValueError(f"prompt directory has no prompt asset files: {path}") + return assets + if path.is_file(): + return {SINGLE_FILE_KEY: content_sha256(path)} + raise FileNotFoundError(f"prompt not found: {path}") diff --git a/solar_eval/core/task_discovery.py b/solar_eval/core/task_discovery.py index ece075da96560c078e21af52e9e744e431c33889..57cfbb7101bbe7ca94793ede9fa620ae06d6fe13 100644 --- a/solar_eval/core/task_discovery.py +++ b/solar_eval/core/task_discovery.py @@ -15,7 +15,7 @@ **명시 태스크가 이긴다.** 이미 `tasks:` 에 있는 dataset_path 는 발견 대상에서 빠지므로, 규칙에 안 맞는 이름(`paragraph`, `yul-ryul`)은 예전 그대로 적어 두면 -된다. 과거 run 이 그 이름으로 기록돼 있어 이름을 바꾸면 evalhub 에서 계보가 +된다. 과거 run 이 그 이름으로 기록돼 있어 이름을 바꾸면 Experiment Hub 에서 계보가 끊어지기 때문에, 이 우선순위는 타협 대상이 아니다. """ diff --git a/solar_eval/evaluators/base.py b/solar_eval/evaluators/base.py index f467747f735556434cf3195cdee7066d10862c72..9c45b267f44b79ef0b550363ce2c6bbe01f80402 100644 --- a/solar_eval/evaluators/base.py +++ b/solar_eval/evaluators/base.py @@ -19,15 +19,33 @@ class BaseEvaluator(ABC): required_fields: frozenset[str] = frozenset() + @property + def judge_model(self) -> str | None: + """채점에 쓰는 judge 모델 — 모델을 부르지 않는 평가기는 None. + + evaluation.json 의 `eval_model` 과 run.json 스냅샷의 `evaluator_config.judge_model` + 이 이 값을 그대로 쓴다(계획 2026-09-24-phase1a D11). 예전에는 러너가 평가기와 + 무관하게 `"gpt-4o"` 를 적어 lcs_diff run 에도 judge 가 있는 것처럼 보였다. + """ + return None + + def sub_evaluators(self) -> list["BaseEvaluator"]: + """합성 평가기의 하위 평가기 — 스냅샷의 구현 해시가 하위 모듈까지 보도록. 기본 [].""" + return [] + @abstractmethod async def evaluate( self, sample: EvalSample, provider: BaseProvider | None = None, - judge_model: str = "gpt-4o", + judge_model: str | None = None, ) -> dict[str, Any]: """`sample` 한 건을 채점한다. `{score, details}` 를 반환한다. + `judge_model` 은 호출자가 judge 모델을 **덮어쓸** 때만 준다. None 이면 평가기가 + 자기 설정값(`self.judge_model`)을 쓴다 — 러너·`runs eval` 은 넘기지 않으므로 보고값과 + 실제 호출 모델이 어긋나지 않는다. + 채점에 실패하면(judge 호출 예외, 잘못된 응답 등) 그 실패는 **예외로 전파한다** -- 점수(0.0 이든 1.0 이든)로 치환해 반환하지 않는다. fail-open(장애를 만점으로 둔갑시키는 것)을 만들지 않는 것이 이 계약의 핵심이다. 이 예외를 잡아 실패 diff --git a/solar_eval/evaluators/composite.py b/solar_eval/evaluators/composite.py index 33c5f6c63f1646e31df4800ce0a51d81b9e69511..a5d507b1a2957d6a2985bfddb52592d6c02ddbd0 100644 --- a/solar_eval/evaluators/composite.py +++ b/solar_eval/evaluators/composite.py @@ -28,12 +28,27 @@ class CompositeEvaluator(BaseEvaluator): fields |= comp["evaluator"].required_fields return fields + @property + def judge_model(self) -> str | None: + """하위 평가기의 non-null judge 모델: 0개 → None, 1개 → 그 값, 여럿 → 정렬해 `+` 로 잇는다.""" + models = sorted( + {m for comp in self.components if (m := comp["evaluator"].judge_model) is not None} + ) + if not models: + return None + return "+".join(models) + + def sub_evaluators(self) -> list[BaseEvaluator]: + return [comp["evaluator"] for comp in self.components] + async def evaluate( self, sample: EvalSample, provider: BaseProvider | None = None, - judge_model: str = "gpt-4o", + judge_model: str | None = None, ) -> dict[str, Any]: + # judge_model 은 그대로(None 이면 None) 넘긴다 — 하위는 None 이면 자기 설정값을 쓴다. + # 예전에는 자기 기본값 "gpt-4o" 를 명시 전달해 하위 설정을 덮어썼다(검토 F9). component_results = [] weighted_score = 0.0 diff --git a/solar_eval/evaluators/judge_config.py b/solar_eval/evaluators/judge_config.py new file mode 100644 index 0000000000000000000000000000000000000000..66e5caa7839a9a156b8904c9381b41453ab032d3 --- /dev/null +++ b/solar_eval/evaluators/judge_config.py @@ -0,0 +1,243 @@ +"""judge 설정 공용 규칙 — provider 이름·평가기별 기본 provider·판정 선언·endpoint (계획 2026-09-28 L12 §2.8 · D-L12-10·11). + +평가기(`registry`)·채점 번들 발행(`scoring_publish`)이 함께 읽는 계약이다. 규칙은 이 모듈 한 곳이다 — +다른 모듈은 사본을 두지 않는다. 인정된 사본은 둘뿐이고 시험이 대조한다: + +- `DEFAULT_EVALUATOR_TYPE` ↔ `evaluators.registry.DEFAULT_EVALUATOR_CONFIG["type"]`(registry 를 import 하면 평가기 전부를 끌어온다). +- `DEFAULT_LLM_JUDGE_MODEL` ↔ `evaluators.llm_judge.DEFAULT_JUDGE_MODEL`. +- `PROVIDER_MODULES` ↔ `evaluators.rubric_judge._PROVIDER_MODULES`(rubric_judge 는 이 모듈을 import 하지 않는다 — + 설정 모듈 편집이 모든 rubric judge 구현 해시를 바꾸지 않게, 검토 F9). + +설정 오류는 provider 인스턴스를 만들기 전에 검사한다. endpoint 는 `ProviderConfig().upstage_api_base`(대소문자 무시 env)이고, +콘솔·번들에는 `normalize_endpoint` 를 지난 값만 싣는다(userinfo·쿼리·조각이 로그·미러에 남지 않게, 검토 F18). + +import 는 표준 라이브러리 + `solar_eval.config` 뿐이다 — runner 경로에서 불려도 `poc_bundle`·`packaging` 을 끌어오지 않는다(D18). +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from types import MappingProxyType +from typing import Any +from urllib.parse import urlsplit, urlunsplit + +from solar_eval.config import ProviderConfig + +# ── 평가기 type ───────────────────────────────────────────────────────── + +LLM_JUDGE_TYPE = "llm_judge" +RUBRIC_JUDGE_TYPE = "rubric_judge" +COMPOSITE_TYPE = "composite" +#: judge 를 부르지 않는 평가기. +NO_JUDGE_TYPES = ("lcs_diff", "rule_based") +#: 태스크에 `evaluator` 키가 없을 때의 평가기 — `registry.DEFAULT_EVALUATOR_CONFIG` 와 같다(시험 대조). +DEFAULT_EVALUATOR_TYPE = LLM_JUDGE_TYPE + +EVALUATOR_KEY = "evaluator" +TYPE_KEY = "type" +COMPONENTS_KEY = "components" +JUDGE_PROVIDER_KEY = "judge_provider" +JUDGE_MODEL_KEY = "judge_model" + +# ── provider ──────────────────────────────────────────────────────────── + +PROVIDER_UPSTAGE = "upstage" +PROVIDER_OPENAI = "openai" +#: `judge_provider` 의 닫힌 집합 — 그 밖 값은 `judge_provider_unknown`. +JUDGE_PROVIDERS = (PROVIDER_UPSTAGE, PROVIDER_OPENAI) +#: 평가기별 기본 provider — llm_judge 는 오늘 동작(OpenAI), rubric_judge 는 Upstage(D-L12-10). +DEFAULT_JUDGE_PROVIDER: Mapping[str, str] = MappingProxyType( + {LLM_JUDGE_TYPE: PROVIDER_OPENAI, RUBRIC_JUDGE_TYPE: PROVIDER_UPSTAGE} +) +#: `llm_judge` 의 judge 모델 기본값 — `llm_judge.DEFAULT_JUDGE_MODEL` 과 같다(시험 대조). +DEFAULT_LLM_JUDGE_MODEL = "gpt-4o" +#: OpenAI judge 의 endpoint — `providers/openai_provider.py` 가 부르는 URL 의 base. +OPENAI_ENDPOINT = "https://api.openai.com/v1" +#: provider 이름 → 실제 provider 클래스의 모듈 — `rubric_judge._PROVIDER_MODULES` 와 같다(시험 대조, D-L12-18). +PROVIDER_MODULES: Mapping[str, str] = MappingProxyType( + { + PROVIDER_UPSTAGE: "solar_eval.providers.upstage", + PROVIDER_OPENAI: "solar_eval.providers.openai_provider", + } +) + +# ── 오류 사유 코드(§2.15 — CLI 종료 1 첫 줄 머리) ────────────────────────── + +JUDGE_PROVIDER_UNKNOWN = "judge_provider_unknown" +JUDGE_PROVIDER_CONFLICT = "judge_provider_conflict" +EVALUATOR_CONFIG_INVALID = "evaluator_config_invalid" +JUDGE_ENDPOINT_INVALID = "judge_endpoint_invalid" + +#: `task_config` 에 `evaluator` 키가 없음 — `evaluator: null` 과 구별한다(검토 F7). +_ABSENT = object() + + +class JudgeConfigError(ValueError): + """judge 설정 오류 — `reason` 은 위 코드 중 하나. 메시지는 코드와 이름·타입만 싣는다(설정 값·URL 원문 없음).""" + + def __init__(self, reason: str, detail: str = "") -> None: + super().__init__(f"{reason}: {detail}" if detail else reason) + self.reason = reason + + +@dataclass(frozen=True) +class JudgeDecl: + """평가기 설정이 선언한 judge 하나 — (평가기 type, provider 이름, 모델). 모델을 모르면 None.""" + + evaluator_type: str + provider: str + model: str | None + + +def evaluator_of(task_config: Mapping[str, Any]) -> Mapping[str, Any] | None: + """태스크의 평가기 설정. 키가 없으면 None(= 기본 llm_judge), `null`·`{}`·매핑 아님은 `evaluator_config_invalid`. + + registry 와 같은 규칙이다 — 키가 **없을 때만** 기본값이고, 비었거나 null 인 선언을 기본값으로 바꾸지 않는다. + """ + if not isinstance(task_config, Mapping): + raise JudgeConfigError(EVALUATOR_CONFIG_INVALID, "task config is not a mapping") + raw = task_config.get(EVALUATOR_KEY, _ABSENT) + if raw is _ABSENT: + return None + if not isinstance(raw, Mapping) or not raw: + raise JudgeConfigError( + EVALUATOR_CONFIG_INVALID, f"evaluator must be a non-empty mapping, got {_kind(raw)}" + ) + return raw + + +def judge_decls(evaluator_config: Mapping[str, Any] | None) -> tuple[JudgeDecl, ...]: + """평가기 설정이 부르는 judge 전부(합성은 하위를 순서대로). None 은 기본 평가기(`DEFAULT_EVALUATOR_TYPE`). + + Raises: + JudgeConfigError: 매핑 아님·type 없음·모르는 type(`evaluator_config_invalid`), provider 가 닫힌 집합 밖 + (`judge_provider_unknown`). + """ + config = {TYPE_KEY: DEFAULT_EVALUATOR_TYPE} if evaluator_config is None else evaluator_config + return _decls(config) + + +def _decls(config: Any) -> tuple[JudgeDecl, ...]: + if not isinstance(config, Mapping): + raise JudgeConfigError(EVALUATOR_CONFIG_INVALID, f"evaluator is {_kind(config)}") + kind = config.get(TYPE_KEY) + if not isinstance(kind, str) or not kind: + raise JudgeConfigError(EVALUATOR_CONFIG_INVALID, "evaluator needs a non-empty 'type'") + if kind in NO_JUDGE_TYPES: + return () + if kind == LLM_JUDGE_TYPE: + model = _model(config.get(JUDGE_MODEL_KEY, DEFAULT_LLM_JUDGE_MODEL)) + return (JudgeDecl(kind, _provider(config, kind), model),) + if kind == RUBRIC_JUDGE_TYPE: + # judge_model 은 필수지만(기본값 없음) 없다는 판정은 평가기 생성자가 한다 — 여기서는 모델을 None 으로 둔다. + return (JudgeDecl(kind, _provider(config, kind), _model(config.get(JUDGE_MODEL_KEY))),) + if kind == COMPOSITE_TYPE: + components = config.get(COMPONENTS_KEY, []) + if not isinstance(components, list): + raise JudgeConfigError(EVALUATOR_CONFIG_INVALID, "composite components must be a list") + return tuple(decl for component in components for decl in _decls(component)) + raise JudgeConfigError(EVALUATOR_CONFIG_INVALID, f"unknown evaluator type {kind!r}") + + +def _provider(config: Mapping[str, Any], kind: str) -> str: + value = config.get(JUDGE_PROVIDER_KEY, DEFAULT_JUDGE_PROVIDER[kind]) + if value not in JUDGE_PROVIDERS: + allowed = " · ".join(JUDGE_PROVIDERS) + shown = repr(value) if isinstance(value, str) else _kind(value) + raise JudgeConfigError(JUDGE_PROVIDER_UNKNOWN, f"{kind} {shown} (allowed: {allowed})") + return value + + +def _model(value: Any) -> str | None: + return value if isinstance(value, str) and value else None + + +def _kind(value: Any) -> str: + return "null" if value is None else type(value).__name__ + + +def validate_judge_config(task_config: Mapping[str, Any]) -> None: + """Judge 설정 오류를 provider 생성 전에 판정한다. 전송 대상에 따른 허용/거부 정책은 없다.""" + evaluator = evaluator_of(task_config) + judge_provider_name(evaluator) + require_upstage_judge_models(evaluator) + for decl in judge_decls(evaluator): + judge_endpoint(decl.provider) + + +def require_upstage_judge_models(evaluator_config: Mapping[str, Any] | None) -> None: + """`llm_judge` 가 `judge_provider: upstage` 인데 `judge_model` 선언이 없으면 `evaluator_config_invalid`(합성은 하위까지). + + `llm_judge` 의 모델 기본값(`DEFAULT_LLM_JUDGE_MODEL`)은 OpenAI 모델이라 Upstage 로 보내면 추론을 다 돌린 뒤 판정 호출이 + 실패한다 — 그 조합은 모델을 적게 한다. 모델을 **명시**했으면 그 값은 보지 않는다(사람이 고른 값이다). + """ + config = {TYPE_KEY: DEFAULT_EVALUATOR_TYPE} if evaluator_config is None else evaluator_config + for kind, component in _judge_components(config): + if kind != LLM_JUDGE_TYPE or component.get(JUDGE_PROVIDER_KEY) != PROVIDER_UPSTAGE: + continue + if _model(component.get(JUDGE_MODEL_KEY)) is None: + raise JudgeConfigError( + EVALUATOR_CONFIG_INVALID, + f"{LLM_JUDGE_TYPE} with {JUDGE_PROVIDER_KEY}: {PROVIDER_UPSTAGE} needs a " + f"{JUDGE_MODEL_KEY} (the default {DEFAULT_LLM_JUDGE_MODEL} is an OpenAI model)", + ) + + +def _judge_components(config: Any) -> tuple[tuple[str, Mapping[str, Any]], ...]: + """(type, 설정) — 합성은 하위를 순서대로 펼친다. 모양 검사는 `_decls` 몫이라 매핑이 아닌 값은 건너뛴다.""" + if not isinstance(config, Mapping): + return () + kind = config.get(TYPE_KEY) + if kind == COMPOSITE_TYPE: + components = config.get(COMPONENTS_KEY, []) + items = components if isinstance(components, list) else [] + return tuple(pair for component in items for pair in _judge_components(component)) + return ((kind, config),) if isinstance(kind, str) else () + + +def judge_provider_name(evaluator_config: Mapping[str, Any] | None) -> str | None: + """평가기 설정이 쓰는 judge provider 이름 하나. judge 가 없으면 None, 하위 judge 들이 서로 다르면 `judge_provider_conflict`.""" + names = sorted({decl.provider for decl in judge_decls(evaluator_config)}) + if not names: + return None + if len(names) > 1: + raise JudgeConfigError(JUDGE_PROVIDER_CONFLICT, " vs ".join(names)) + return names[0] + + +def normalize_endpoint(url: str) -> str: + """`scheme://host[:port]/path` — userinfo·쿼리·조각·경로 세그먼트의 `;` 매개변수·끝 `/` 를 떼고 scheme·host 를 소문자로. + scheme·host 가 없으면 `judge_endpoint_invalid`. + + 포트는 적힌 그대로 남긴다(기본 포트도 지우지 않는다). 메시지에는 URL 원문을 싣지 않는다(userinfo 토큰이 로그에 남지 않게). + `urlsplit` 은 `;tok=…` 를 경로에 남기므로 세그먼트마다 `;` 뒤를 지운다(프록시 토큰 누출 방지, F18). 경로 세그먼트 자체 + (`/<토큰>/v1` 모양)는 구분할 수 없어 그대로 남는다 — RUBRIC_JUDGE.md §4. + """ + if not isinstance(url, str): + raise JudgeConfigError(JUDGE_ENDPOINT_INVALID, f"endpoint is {_kind(url)}") + try: + parts = urlsplit(url.strip()) + host = parts.hostname + port = parts.port + except ValueError: + raise JudgeConfigError(JUDGE_ENDPOINT_INVALID, "endpoint is not a URL") from None + scheme = parts.scheme.lower() + if not scheme or not host: + raise JudgeConfigError(JUDGE_ENDPOINT_INVALID, "endpoint needs a scheme and a host") + shown_host = f"[{host}]" if ":" in host else host + netloc = shown_host if port is None else f"{shown_host}:{port}" + path = "/".join(segment.split(";", 1)[0] for segment in parts.path.split("/")) + return urlunsplit((scheme, netloc, path.rstrip("/"), "", "")) + + +def judge_endpoint(provider: str, *, upstage_api_base: str | None = None) -> str: + """judge provider 가 실제로 부를 endpoint(정규화 값). upstage 는 주입값, 없으면 `ProviderConfig().upstage_api_base`.""" + if provider == PROVIDER_UPSTAGE: + base = ProviderConfig().upstage_api_base if upstage_api_base is None else upstage_api_base + elif provider == PROVIDER_OPENAI: + base = OPENAI_ENDPOINT + else: + shown = repr(provider) if isinstance(provider, str) else _kind(provider) + raise JudgeConfigError(JUDGE_PROVIDER_UNKNOWN, shown) + return normalize_endpoint(base) diff --git a/solar_eval/evaluators/lcs_diff.py b/solar_eval/evaluators/lcs_diff.py index f2bf6641db2d13d3f8aebc6bb837b0391be27a18..d7df7b25b8350d7a078ee4c64f93d8ae8b4c03d4 100644 --- a/solar_eval/evaluators/lcs_diff.py +++ b/solar_eval/evaluators/lcs_diff.py @@ -24,7 +24,7 @@ class LCSDiffEvaluator(BaseEvaluator): self, sample: EvalSample, provider: BaseProvider | None = None, - judge_model: str = "gpt-4o", + judge_model: str | None = None, ) -> dict[str, Any]: original = sample.input.get("original", "") result = compute_proofread_metrics(original, str(sample.reference), sample.output) diff --git a/solar_eval/evaluators/llm_judge.py b/solar_eval/evaluators/llm_judge.py index e9f8d67bf9cfacf80a7806113c8f02f712503b0a..bd97329ca2e64b016c2cfea075e2c2390c6b9e93 100644 --- a/solar_eval/evaluators/llm_judge.py +++ b/solar_eval/evaluators/llm_judge.py @@ -10,6 +10,38 @@ from solar_eval.evaluators.scoring import ( from solar_eval.models.sample import EvalSample from solar_eval.providers.base import BaseProvider +#: 설정(`evaluator.judge_model`)이 없을 때의 judge 모델 — 이 PR 전의 하드코딩 값 그대로(동작 불변). +DEFAULT_JUDGE_MODEL = "gpt-4o" + +#: 카테고리 키가 없을 때 읽는 폴백 키 — 기본 프롬프트(`_default_prompt`)가 이 키를 요구한다. +FALLBACK_COUNT_KEY = "error_count" + +#: 채점 실패 어휘(보고서 §5.4 측정 쪽 — `grader_failed:<사유>:`, 계획 Q-L6-11). +JUDGE_KEY_MISSING = "grader_failed:judge_key_missing" +JUDGE_COUNT_INVALID = "grader_failed:judge_count_invalid" + + +class JudgeResponseError(ValueError): + """judge 응답에서 개수를 읽을 수 없다 — 만점·0점으로 치환하지 않고 채점 실패로 남긴다. + + 메시지는 고정 어휘 + 카테고리 이름뿐이다. judge 응답 원문(고객 텍스트가 섞일 수 있다)은 + 싣지 않는다 — 이 메시지가 `eval_details` 의 `error` 와 러너 로그로 그대로 간다. + """ + + +def _is_count(value: Any) -> bool: + """0 이상의 정수 값인가. `2.0` 처럼 `is_integer()` 인 float 도 받는다(오늘 `good` 과 같다). + + bool 은 int 의 하위형이지만 개수가 아니다 — 오늘 `True` 가 `excellent` 로 채점되던 구멍을 막는다. + """ + if isinstance(value, bool): + return False + if isinstance(value, int): + return value >= 0 + if isinstance(value, float): + return value.is_integer() and value >= 0 + return False + class LLMJudgeEvaluator(BaseEvaluator): """LLM-as-Judge evaluator with weighted categories. @@ -24,19 +56,29 @@ class LLMJudgeEvaluator(BaseEvaluator): self, categories: list[dict[str, Any]], judge_prompts: dict[str, str] | None = None, + judge_model: str = DEFAULT_JUDGE_MODEL, ) -> None: + if not isinstance(judge_model, str) or not judge_model: + raise ValueError(f"judge_model must be a non-empty string, got {judge_model!r}") self.categories = categories # [{name, weight}] self.weights = {c["name"]: c["weight"] for c in categories} self.judge_prompts = judge_prompts or {} + self._judge_model = judge_model + + @property + def judge_model(self) -> str: + return self._judge_model async def evaluate( self, sample: EvalSample, provider: BaseProvider | None = None, - judge_model: str = "gpt-4o", + judge_model: str | None = None, ) -> dict[str, Any]: if provider is None: raise ValueError("LLMJudgeEvaluator requires a provider") + # 호출자가 덮어쓰지 않으면 자기 설정값 — 보고값(`self.judge_model`)과 호출 모델이 같다. + judge_model = judge_model or self._judge_model scores = {} details = {} @@ -62,8 +104,8 @@ class LLMJudgeEvaluator(BaseEvaluator): ], model=judge_model, ) - count_key = self._get_count_key(cat_name) - error_count = result.get(count_key, result.get("error_count", 0)) + # 개수 키가 없거나 값이 개수가 아니면 JudgeResponseError — 예전의 기본값 0(만점)은 없다. + error_count = self._read_count(result, cat_name) severity = error_count_to_severity(error_count) score = severity_to_score(severity) @@ -108,6 +150,24 @@ class LLMJudgeEvaluator(BaseEvaluator): "num_samples": len(results), } + def _read_count(self, result: Any, cat_name: str) -> int | float: + """judge 응답에서 카테고리의 개수를 읽는다(계획 §2.12 B2). + + 카테고리 키(`_get_count_key`) 값, 없거나 `null` 이면 `error_count` 값. 둘 다 없거나 `null` + 이면 `judge_key_missing`, 값이 0 이상 정수 값이 아니면 `judge_count_invalid`. 받은 값은 + 그대로 돌려준다(`2.0` 은 `2.0` — `eval_details` 의 `error_counts` 바이트가 오늘과 같다). + """ + # JSON 파싱 결과가 객체가 아니면(배열·null 등) 읽을 키 자체가 없다. + fields = result if isinstance(result, dict) else {} + value = fields.get(self._get_count_key(cat_name)) + if value is None: + value = fields.get(FALLBACK_COUNT_KEY) + if value is None: + raise JudgeResponseError(f"{JUDGE_KEY_MISSING}:{cat_name}") + if not _is_count(value): + raise JudgeResponseError(f"{JUDGE_COUNT_INVALID}:{cat_name}") + return value + def _get_count_key(self, category: str) -> str: key_map = { "factual_accuracy": "error_count", diff --git a/solar_eval/evaluators/registry.py b/solar_eval/evaluators/registry.py index 465338a40cc81054de3c460bdd90c3ebaa8eaf34..8724b41bd7c13196bed04340b31c2ba0b3023406 100644 --- a/solar_eval/evaluators/registry.py +++ b/solar_eval/evaluators/registry.py @@ -1,30 +1,90 @@ +from collections.abc import Mapping from typing import Any from solar_eval.evaluators.base import BaseEvaluator from solar_eval.evaluators.composite import CompositeEvaluator +from solar_eval.evaluators.judge_config import ( + DEFAULT_JUDGE_PROVIDER, + JUDGE_MODEL_KEY, + JUDGE_PROVIDER_KEY, + RUBRIC_JUDGE_TYPE, +) from solar_eval.evaluators.lcs_diff import LCSDiffEvaluator -from solar_eval.evaluators.llm_judge import LLMJudgeEvaluator +from solar_eval.evaluators.llm_judge import DEFAULT_JUDGE_MODEL, LLMJudgeEvaluator +from solar_eval.evaluators.rubric_judge import CONFIG_KEYS as RUBRIC_JUDGE_KEYS +from solar_eval.evaluators.rubric_judge import ( + DEFAULT_MAX_TOKENS, + RUBRIC_JUDGE_IN_COMPOSITE, + UNKNOWN_KEYS_PREFIX, + RubricJudgeEvaluator, +) from solar_eval.evaluators.rule_based import RuleBasedEvaluator +#: 태스크에 `evaluator` 키가 **없을 때** 쓰는 기본 평가기 — 러너·`runs eval`·스냅샷이 공유할 정본. +#: 키가 있는데 비었거나(`{}`) null 이면 기본값으로 바꾸지 않는다(`create_evaluator` 가 거부한다). +DEFAULT_EVALUATOR_CONFIG: dict[str, Any] = {"type": "llm_judge"} +#: 받는 평가기 type — 오류 문구에 싣는다. +EVALUATOR_TYPES = ("llm_judge", "lcs_diff", "rule_based", "composite", RUBRIC_JUDGE_TYPE) -def create_evaluator(config: dict[str, Any]) -> BaseEvaluator: - """Factory function to create evaluator from config dict.""" - eval_type = config["type"] + +def create_evaluator(config: Mapping[str, Any]) -> BaseEvaluator: + """Factory function to create evaluator from config dict. + + Raises: + ValueError: 설정이 객체가 아니거나 `type` 이 없거나 모르는 값일 때 — 추론을 다 돌린 뒤 + 평가 단계에서 맨 KeyError/TypeError 로 죽지 않게 경계에서 이유를 적어 거부한다. + """ + if not isinstance(config, Mapping): + raise ValueError( + f"evaluator config must be a mapping with a 'type' key, got {type(config).__name__}" + ) + eval_type = config.get("type") + if not isinstance(eval_type, str) or not eval_type: + raise ValueError( + f"evaluator config needs a non-empty 'type' " + f"({' · '.join(EVALUATOR_TYPES)}), got {dict(config)!r}" + ) if eval_type == "llm_judge": return LLMJudgeEvaluator( categories=config.get("categories", []), judge_prompts=config.get("judge_prompts"), + judge_model=config.get("judge_model", DEFAULT_JUDGE_MODEL), ) elif eval_type == "lcs_diff": return LCSDiffEvaluator(metrics=config.get("metrics")) elif eval_type == "rule_based": return RuleBasedEvaluator(rules=config.get("rules", [])) + elif eval_type == RUBRIC_JUDGE_TYPE: + return _rubric_judge(config) elif eval_type == "composite": components = [] for comp in config.get("components", []): + # 합성은 판정 점수를 가중 평균할 뿐이라 기준별 행을 담을 자리가 없다(계획 2026-09-28 L12 D-L12-1). + if isinstance(comp, Mapping) and comp.get("type") == RUBRIC_JUDGE_TYPE: + raise ValueError(RUBRIC_JUDGE_IN_COMPOSITE) sub_evaluator = create_evaluator(comp) components.append({"evaluator": sub_evaluator, "weight": comp.get("weight", 0.5)}) return CompositeEvaluator(components=components) else: - raise ValueError(f"Unknown evaluator type: {eval_type}") + raise ValueError( + f"Unknown evaluator type: {eval_type} (allowed: {' · '.join(EVALUATOR_TYPES)})" + ) + + +def _rubric_judge(config: Mapping[str, Any]) -> RubricJudgeEvaluator: + """`rubric_judge` — 닫힌 키(오타가 기본값으로 묻히지 않게), 기본 provider 는 여기서 채운다. + + `judge_config` 는 이 레지스트리만 import 한다 — `rubric_judge` 모듈이 import 하면 `impl_hash` 폐포에 들어가 + 설정 모듈 편집이 모든 rubric judge 평가기 해시를 바꾼다(계획 2026-09-28 L12 D-L12-10 · 검토 F9). + """ + unknown = sorted(str(key) for key in config if key not in RUBRIC_JUDGE_KEYS) + if unknown: + allowed = sorted(RUBRIC_JUDGE_KEYS) + raise ValueError(f"{UNKNOWN_KEYS_PREFIX} {unknown} (allowed: {allowed})") + return RubricJudgeEvaluator( + rubric=config.get("rubric"), + judge_model=config.get(JUDGE_MODEL_KEY), + judge_provider=config.get(JUDGE_PROVIDER_KEY, DEFAULT_JUDGE_PROVIDER[RUBRIC_JUDGE_TYPE]), + max_tokens=config.get("max_tokens", DEFAULT_MAX_TOKENS), + ) diff --git a/solar_eval/evaluators/rubric_judge.py b/solar_eval/evaluators/rubric_judge.py new file mode 100644 index 0000000000000000000000000000000000000000..311a744658fc2cc5b9709976aa07d846d0735c27 --- /dev/null +++ b/solar_eval/evaluators/rubric_judge.py @@ -0,0 +1,763 @@ +"""`rubric_judge` 평가기 — `rubric/v1` 기준마다 judge 에 한 번씩 묻는다 (계획 2026-09-28 L12 §2.1~§2.3 · D-L12-1~4·18). + +- **생성자(§2.1)** 가 rubric 구조를 표준 라이브러리만으로 본다 — 닫힌 키·라벨·기준 id·점수 부호(`rubric_points_sign`)·집계 + 조합(`rubric_all_pass_with_negative`). 실패는 `ValueError(<고정 코드>: …)` 로 추론 전에 멈춘다. 계약 전부(RB0~RB4)는 발행 + 직전 PB `parse_rubric_doc` 가 다시 본다(이 모듈은 runner 경로라 `poc_bundle` 을 import 하지 않는다). +- **프롬프트(§2.2)** `rubric-pointwise@1` — 기준 하나 × 샘플 하나 = 메시지 둘. 치환은 한 번에(자리 이름 정규식) 해서 입력·출력에 + 든 `{…}` 글자가 다른 자리를 건드리지 않는다. +- **파서(§2.3)** `rubric-verdict-json@1` — 엄격 JSON(중복 키·NaN 거부), 통째로 코드 블록 하나만 벗긴다(중괄호 사냥 없음). +- **판정(`judge`)** 기준 순서대로 순차 호출(temperature 0, `chat_completion` 원문). 파싱·호출 실패는 그 기준만 error 이고 + 나머지 기준은 부른다. 출력이 없으면 부르지 않고 skipped. **레거시 어댑터(`evaluate`)** 는 보수적이다 — 한 기준이라도 + error·라벨 미정이면 `RubricVerdictIncomplete(<코드>)`(부분 점수를 내면 fail-open 이다, D-L12-4). + +**폐포 규칙(검토 F9).** 이 모듈은 `solar_eval.evaluators.judge_config` 를 import 하지 않는다 — `impl_hash` 폐포가 함수 안 import 까지 +따라가므로, 설정 모듈 편집이 모든 rubric judge 구현 해시를 바꾸지 않게 한다. 기본 provider 는 레지스트리가 채워 넘긴다. +그래서 provider 모듈 표(`_PROVIDER_MODULES`)는 `judge_config.PROVIDER_MODULES` 의 **인정된 사본**이다(시험이 대조). + +**누출 금지.** 로그·예외 메시지·`eval_details` 에는 코드·기준 id·`sample_idx` 만 싣는다 — judge 원문·근거·고객 문장은 +`CriterionVerdict.raw`·`rationale` 로만 흐르고, 채점 번들에서는 `payloads.jsonl` 에만 간다(보고서 R6, D-L12-8). + +runner 경로 모듈이다 — `poc_bundle`·`packaging` 을 import 하지 않는다(마스터 §4 규칙 11). 표준 라이브러리와 `solar_eval` 만. +""" + +from __future__ import annotations + +import hashlib +import json +import logging +import math +import re +from collections.abc import Collection, Mapping, Sequence +from dataclasses import dataclass +from types import MappingProxyType +from typing import Any, Literal + +from solar_eval.evaluators.base import BaseEvaluator +from solar_eval.models.sample import EvalSample +from solar_eval.providers.base import BaseProvider + +_LOG = logging.getLogger(__name__) +# ── 정체성 ────────────────────────────────────────────────────────────── + +RUBRIC_JUDGE_TYPE = "rubric_judge" +#: 프롬프트 틀 id(§2.2) — 번들 `params.prompt.template`. +TEMPLATE_ID = "rubric-pointwise@1" +#: 응답 파서 id(§2.3) — 번들 `params.parser`. +PARSER_ID = "rubric-verdict-json@1" +#: 판정 행 `actor`(§2.4). +ACTOR = "solar_eval.rubric_judge@1" +#: 호출 방식 — 기준 하나 × 샘플 하나마다 호출 하나(D-L12-3). +MODE = "pointwise_per_criterion" +#: 파싱·호출 실패는 기준별 error 행으로 남긴다(D-L12-4) — 번들 `params.on_parse_failure`. +ON_PARSE_FAILURE = "record_error" +#: 라벨 → `passed`(D-L12-2 고정). 그 밖 라벨(예 `unknown`)은 `passed` 를 싣지 않는다. +LABEL_PASSED: Mapping[str, bool] = MappingProxyType({"met": True, "not_met": False}) +REQUIRED_LABELS = ("met", "not_met") +RUBRIC_SCHEMA = "rubric/v1" +RUBRIC_KINDS = ("criteria", "checklist") +RUBRIC_SCOPE = "task" +POLARITIES = ("positive", "negative") +AGGREGATIONS = ("points_clip@1", "all_pass@1") +#: item 지표 키(D-L12-5). +ITEM_METRIC_KEY = "rubric_score" + +# ── sampling ──────────────────────────────────────────────────────────── + +TEMPERATURE = 0.0 +DEFAULT_MAX_TOKENS = 512 +MAX_TOKENS_LIMIT = 8000 +#: 닫힌 설정 키(§2.1) — 모르는 키는 `rubric_judge: unknown key(s)`. +CONFIG_KEYS = frozenset({"type", "judge_provider", "judge_model", "max_tokens", "rubric"}) + +# ── 판정 사유 코드(§2.15) ──────────────────────────────────────────────── + +GRADER_FAILED_EMPTY = "grader_failed:empty_response" +GRADER_FAILED_UNPARSEABLE = "grader_failed:unparseable" +GRADER_FAILED_SCHEMA = "grader_failed:schema_mismatch" +GRADER_FAILED_LABEL = "grader_failed:label_invalid" +GRADER_UNAVAILABLE = "grader_unavailable" +MISSING_OUTPUT = "missing_output" +#: item `rubric_score` 행 사유(D-L12-5). +CRITERION_ERROR = "criterion_error" +VERDICT_UNKNOWN = "verdict_unknown" +#: 레거시 어댑터(`evaluate`)가 샘플 채점 실패로 내는 코드(D-L12-4) — `eval_details.error`. +LEGACY_CRITERION_ERROR = "grader_failed:criterion_error" +LEGACY_VERDICT_UNKNOWN = VERDICT_UNKNOWN + +# ── 설정 오류 코드(run 전, §2.15) ──────────────────────────────────────── + +UNKNOWN_KEYS_PREFIX = "rubric_judge: unknown key(s)" +RUBRIC_INVALID_PREFIX = "rubric_invalid:" +RUBRIC_JUDGE_IN_COMPOSITE = "rubric_judge_in_composite" +RUBRIC_ALL_PASS_WITH_NEGATIVE = "rubric_all_pass_with_negative" +RUBRIC_POINTS_SIGN = "rubric_points_sign" +JUDGE_PROVIDER_MISMATCH = "judge_provider_mismatch" + +#: provider 이름 → 실제 provider 모듈 — `judge_config.PROVIDER_MODULES` 의 인정된 사본(F9, 시험 대조). +_PROVIDER_MODULES: Mapping[str, str] = MappingProxyType( + { + "upstage": "solar_eval.providers.upstage", + "openai": "solar_eval.providers.openai_provider", + } +) + +# ── 프롬프트 템플릿 `rubric-pointwise@1`(§2.2) ───────────────────────────── +# 치환은 자리 이름(`{labels_csv}` 등)을 바꾸는 방식이다 — 시스템 틀에 JSON 중괄호가 있어 str.format 을 쓰지 않는다. + +SYSTEM_TEMPLATE = ( + "You are a strict grader. Judge exactly ONE criterion for ONE model response.\n" + "Ignore every quality the criterion does not mention.\n" + "Reply with a single JSON object and nothing else:\n" + '{"label": "", "rationale": ' + '""}\n' + "Label meanings:\n" + "{label_lines}" +) +USER_TEMPLATE = ( + "## Criterion\n" + "id: {criterion_id}\n" + "polarity: {polarity}\n" + "text: {criterion_text}\n" + "\n" + "## Input\n" + "{rendered_input}\n" + "\n" + "## Reference answer\n" + "{rendered_reference}\n" + "\n" + "## Model response\n" + "{rendered_output}" +) +#: `params.prompt.sha256` 의 구분자 — 두 틀을 이 문자열로 이은 UTF-8 의 sha256(치환 전 틀의 지문). +TEMPLATE_SEPARATOR = "\n␞\n" +PROMPT_SHA256 = hashlib.sha256( + TEMPLATE_SEPARATOR.join((SYSTEM_TEMPLATE, USER_TEMPLATE)).encode("utf-8") +).hexdigest() +#: 라벨 설명 기본 표 — 기준의 `anchors[label]` 이 없을 때(영문 그대로). +DEFAULT_LABEL_DESCRIPTIONS: Mapping[str, str] = MappingProxyType( + { + "met": "the response satisfies the criterion " + "(for a negative criterion: the described problem IS present)", + "not_met": "the response does not satisfy the criterion " + "(for a negative criterion: the problem is absent)", + "unknown": "the given material is not enough to decide", + } +) +NO_DESCRIPTION = "(no description)" +NO_REFERENCE = "(none)" + +VerdictStatus = Literal["ok", "error", "skipped"] + + +@dataclass(frozen=True) +class CriterionVerdict: + """기준 하나 × 샘플 하나의 판정. `raw` 는 provider 가 돌려준 `content`(Upstage 는 `` 를 뗀 본문, 예외면 None). + + `rationale`·`raw` 는 payload 로만 간다 — 판정 행·로그·오류 문자열에 싣지 않는다(D-L12-8). + """ + + sample_idx: int + criterion_id: str + status: VerdictStatus + label: str | None + passed: bool | None + reason: str | None + rationale: str | None + raw: str | None + exception: str | None + prompt_tokens: int | None + completion_tokens: int | None + + +@dataclass(frozen=True) +class SampleVerdicts: + """샘플 하나의 판정들 — rubric 항목 순서(retry 면 대상 기준만).""" + + sample_idx: int + verdicts: tuple[CriterionVerdict, ...] + + +class VerdictParseError(ValueError): + """응답을 판정으로 읽을 수 없다 — `reason` 은 `grader_failed:*` 코드. 메시지는 코드뿐이다(원문 없음).""" + + def __init__(self, reason: str) -> None: + super().__init__(reason) + self.reason = reason + + +class RubricVerdictIncomplete(ValueError): + """레거시 어댑터: 한 기준이라도 error·라벨 미정이면 샘플 채점 실패(점수 null) — 메시지 = 코드만(D-L12-4).""" + + def __init__(self, code: str) -> None: + super().__init__(code) + self.code = code + + +class JudgeProviderMismatch(ValueError): + """선언 provider 와 다른 **실제** provider 가 주입됐다 — 부르기 전에 멈춘다(설정 오류, D-L12-18).""" + + def __init__(self, declared: str | None = None, actual_module: str | None = None) -> None: + # 메시지는 코드 + 이름(provider 이름·모듈 이름)만 — 설정 값·URL 은 싣지 않는다. + detail = f": declared {declared}, got {actual_module}" if declared and actual_module else "" + super().__init__(f"{JUDGE_PROVIDER_MISMATCH}{detail}") + self.declared = declared + self.actual_module = actual_module + + +# ── 생성자 검사(§2.1 — 구조만, 표준 라이브러리) ───────────────────────────── + +#: rubric 문서·config·기준 항목의 닫힌 키 — PB 모델(`RubricDoc`·`RubricConfig`·`Criterion`)과 같은 집합. +RUBRIC_KEYS = frozenset({"schema", "config", "provenance"}) +RUBRIC_CONFIG_KEYS = frozenset({"kind", "scope", "labels", "criteria", "aggregations"}) +CRITERION_KEYS = frozenset({"id", "text", "points", "polarity", "required", "anchors", "tags"}) +#: `provenance` 의 닫힌 키 — PB `RubricProvenance` 와 같은 집합. +PROVENANCE_KEYS = frozenset({"alias", "supersedes"}) +#: item `rubric_score` 의 파생 — `criteria` 는 점수 합, `checklist` 는 전부 통과(D-L12-5). +ITEM_AGGREGATION = MappingProxyType({"criteria": "points_clip@1", "checklist": "all_pass@1"}) +#: 생성자 설정 오류(§2.15 밖 — 평가기 인자) — 메시지 머리. +JUDGE_MODEL_REQUIRED = "rubric_judge: judge_model is required (a non-empty string, no default)" +MAX_TOKENS_INVALID = f"rubric_judge: max_tokens must be an integer in 1..{MAX_TOKENS_LIMIT}" +#: `judge_config.JUDGE_PROVIDER_UNKNOWN` 의 사본(F9 — import 하지 않는다). +JUDGE_PROVIDER_UNKNOWN = "judge_provider_unknown" +JUDGE_PROVIDER_REQUIRED = "rubric_judge: a judge provider is required" +#: 레거시 어댑터가 판정에 붙이는 샘플 번호 — `evaluate` 는 샘플 위치를 모른다(로그 전용). +LEGACY_SAMPLE_IDX = -1 + +_CRITERION_ID_RE = re.compile(r"^[a-z][a-z0-9_]*$") +_HEX64_RE = re.compile(r"[0-9a-f]{64}") +#: JCS 가 정확히 표현하는 정수의 절댓값 상한 — PB `canonical.SAFE_INTEGER_MAX` 의 사본(import 하지 않는다). +_SAFE_INTEGER_MAX = 2**53 - 1 +_CODE_BLOCK_RE = re.compile(r"\A```(?:json)?\s*(.*?)\s*```\Z", re.DOTALL) + + +def _invalid(part: str, detail: str = "") -> ValueError: + """`rubric_invalid:<항목>` — 메시지에는 키·id·타입 이름만 싣는다(기준 문장 없음).""" + code = f"{RUBRIC_INVALID_PREFIX}{part}" + return ValueError(f"{code}: {detail}" if detail else code) + + +def _is_number(value: Any) -> bool: + return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) + + +def _closed(value: Any, allowed: frozenset[str], part: str) -> Mapping[str, Any]: + if not isinstance(value, Mapping): + raise _invalid(part, f"must be a mapping, got {type(value).__name__}") + unknown = sorted(str(k) for k in value if k not in allowed) + if unknown: + raise _invalid(part, f"unknown key(s) {unknown}") + return value + + +@dataclass(frozen=True) +class RubricCriterion: + """검사를 통과한 rubric 항목 — 문장은 프롬프트에만 쓴다(로그·오류에 싣지 않는다).""" + + id: str + text: str + polarity: str + points: int | float | None + anchors: Mapping[str, str] + + +@dataclass(frozen=True) +class _Rubric: + document: dict[str, Any] + kind: str + labels: tuple[str, ...] + criteria: tuple[RubricCriterion, ...] + + +def _json_copy(rubric: Any) -> dict[str, Any]: + """rubric 의 깊은 JSON 사본 — JSON 으로 옮길 수 없는 값(NaN·날짜 등)은 `rubric_invalid:document`.""" + if not isinstance(rubric, Mapping): + raise _invalid("document", f"must be a mapping, got {type(rubric).__name__}") + try: + return json.loads(json.dumps(dict(rubric), ensure_ascii=False, allow_nan=False)) + except (TypeError, ValueError): + raise _invalid("document", "is not JSON-representable") from None + + +def _labels(value: Any) -> tuple[str, ...]: + ok = isinstance(value, list) and all(isinstance(v, str) and v.strip() for v in value) + if not ok or not value or len(set(value)) != len(value): + raise _invalid("labels", "must be a non-empty list of unique non-empty strings") + missing = [label for label in REQUIRED_LABELS if label not in value] + if missing: + raise _invalid("labels", f"must include {list(REQUIRED_LABELS)}, missing {missing}") + return tuple(value) + + +def _anchors(value: Any, labels: Sequence[str], criterion_id: str) -> Mapping[str, str]: + if value is None: + return MappingProxyType({}) + if not isinstance(value, Mapping) or not all( + isinstance(v, str) and v.strip() for v in value.values() + ): + raise _invalid("anchors", f"{criterion_id}: must map labels to non-empty strings") + outside = sorted(str(k) for k in value if k not in labels) + if outside: + raise _invalid("anchors", f"{criterion_id}: keys outside the labels {outside}") + return MappingProxyType(dict(value)) + + +def _points(raw: Mapping[str, Any], kind: str, polarity: str, criterion_id: str) -> Any: + points = raw.get("points") + if kind == "checklist": + if "points" in raw: + raise _invalid("points", f"{criterion_id}: checklist criteria carry no points (RB4)") + return None + if not _is_number(points): + raise _invalid("points", f"{criterion_id}: criteria rubrics need a finite number (RB4)") + if isinstance(points, int) and abs(points) > _SAFE_INTEGER_MAX: + raise _invalid("points", f"{criterion_id}: integer outside the JCS safe range (RB0)") + if (polarity == "positive") != (points > 0) or points == 0: + raise ValueError(f"{RUBRIC_POINTS_SIGN}: {criterion_id} is {polarity} with points {points}") + return points + + +def _optional_fields(item: Mapping[str, Any], criterion_id: str) -> None: + """선택 칸의 타입 — PB `Criterion` 과 같은 규칙(RB0). 선택 칸은 생략만 뜻을 가지므로 `null` 도 거부한다.""" + if "required" in item and not isinstance(item["required"], bool): + raise _invalid("required", f"{criterion_id}: must be a boolean (omit instead of null)") + tags = item.get("tags") + if "tags" in item and not ( + isinstance(tags, list) and all(isinstance(tag, str) and tag for tag in tags) + ): + raise _invalid("tags", f"{criterion_id}: must be a list of non-empty strings") + if "anchors" in item and item["anchors"] is None: + raise _invalid("anchors", f"{criterion_id}: omit the key instead of null") + + +def _criterion(raw: Any, kind: str, labels: Sequence[str]) -> RubricCriterion: + item = _closed(raw, CRITERION_KEYS, "criterion") + criterion_id = item.get("id") + if not isinstance(criterion_id, str) or _CRITERION_ID_RE.fullmatch(criterion_id) is None: + raise _invalid("criterion_id", f"must match {_CRITERION_ID_RE.pattern}") + _optional_fields(item, criterion_id) + text = item.get("text") + if not isinstance(text, str) or not text.strip(): + raise _invalid("criterion_text", f"{criterion_id}: must be a non-empty string") + polarity = item.get("polarity") + if polarity not in POLARITIES: + raise _invalid("polarity", f"{criterion_id}: must be one of {list(POLARITIES)}") + return RubricCriterion( + id=criterion_id, + text=text, + polarity=polarity, + points=_points(item, kind, polarity, criterion_id), + anchors=_anchors(item.get("anchors"), labels, criterion_id), + ) + + +def _aggregations(value: Any, kind: str, criteria: Sequence[RubricCriterion]) -> None: + ok = isinstance(value, list) and value and all(v in AGGREGATIONS for v in value) + if not ok or len(set(value)) != len(value): + raise _invalid("aggregations", f"must be a non-empty unique subset of {list(AGGREGATIONS)}") + if kind == "checklist" and "points_clip@1" in value: + raise _invalid( + "aggregations", "checklist rubrics have no points — points_clip@1 is refused" + ) + if "all_pass@1" in value and any(c.polarity == "negative" for c in criteria): + raise ValueError(RUBRIC_ALL_PASS_WITH_NEGATIVE) + + +def _provenance(value: Any) -> None: + """rubric/v1 필수 칸(빈 매핑 가능) — PB `RubricProvenance` 와 같은 닫힌 키·타입(RB0).""" + provenance = _closed(value, PROVENANCE_KEYS, "provenance") + alias = provenance.get("alias") + if "alias" in provenance and not (isinstance(alias, str) and alias): + raise _invalid("provenance", "alias must be a non-empty string (omit instead of null)") + supersedes = provenance.get("supersedes") + if supersedes is not None and not ( + isinstance(supersedes, str) and _HEX64_RE.fullmatch(supersedes) + ): + raise _invalid("provenance", "supersedes must be a 64-hex rubric hash or null") + + +def _parse_rubric(rubric: Any) -> _Rubric: + """§2.1 생성자 검사 — 구조와 칸 타입은 PB `parse_rubric_doc`(RB0~RB4)가 거부하는 것을 모두 여기서 먼저 거부한다. + + `runs start` 는 in-run 레거시 채점으로 judge 를 먼저 부르므로(D-L12-6), 발행 직전 PB 검사에서야 걸리면 유료 호출이 헛돈다. + PB 는 발행 직전에 한 번 더 본다(`publish_judge:rubric_invalid`). + """ + document = _json_copy(rubric) + _closed(document, RUBRIC_KEYS, "document") + if document.get("schema") != RUBRIC_SCHEMA: + raise _invalid("schema", f"must be {RUBRIC_SCHEMA!r}") + config = _closed(document.get("config"), RUBRIC_CONFIG_KEYS, "config") + kind = config.get("kind") + if kind not in RUBRIC_KINDS: + raise _invalid("kind", f"must be one of {list(RUBRIC_KINDS)}") + if config.get("scope") != RUBRIC_SCOPE: + raise _invalid("scope", f"must be {RUBRIC_SCOPE!r}") + labels = _labels(config.get("labels")) + raw_criteria = config.get("criteria") + if not isinstance(raw_criteria, list) or not raw_criteria: + raise _invalid("criteria", "must be a non-empty list") + criteria = tuple(_criterion(raw, kind, labels) for raw in raw_criteria) + ids = [c.id for c in criteria] + if len(set(ids)) != len(ids): + raise _invalid("criterion_id", "criterion ids must be unique") + if kind == "criteria" and not any(c.points > 0 for c in criteria): + raise _invalid("points", "a criteria rubric needs at least one positive criterion") + _aggregations(config.get("aggregations"), kind, criteria) + _provenance(document.get("provenance")) + return _Rubric(document, kind, labels, criteria) + + +# ── 프롬프트(§2.2) ───────────────────────────────────────────────────────── + + +def _fill(template: str, values: Mapping[str, str]) -> str: + """자리 이름을 한 번에 치환한다 — 값 안의 `{…}` 글자가 다른 자리로 다시 치환되지 않는다.""" + pattern = re.compile(r"\{(" + "|".join(re.escape(k) for k in values) + r")\}") + return pattern.sub(lambda m: values[m.group(1)], template) + + +def _render(value: Any) -> str: + """값 규칙 — 문자열은 그대로, 그 밖은 `json.dumps(ensure_ascii=False, sort_keys=True)`.""" + if isinstance(value, str): + return value + return json.dumps(value, ensure_ascii=False, sort_keys=True) + + +def _render_input(value: Any) -> str: + """매핑이면 키 순서대로 `"{key}: {value}"` 줄, 아니면 값 규칙.""" + if isinstance(value, Mapping): + return "\n".join(f"{key}: {_render(item)}" for key, item in value.items()) + return _render(value) + + +# ── 판정 ──────────────────────────────────────────────────────────────── + + +def _tokens(response: Any) -> tuple[int | None, int | None]: + usage = response.get("usage") if isinstance(response, Mapping) else None + if not isinstance(usage, Mapping): + return None, None + + def count(key: str) -> int | None: + value = usage.get(key) + return value if isinstance(value, int) and not isinstance(value, bool) else None + + return count("prompt_tokens"), count("completion_tokens") + + +def _verdict( + sample_idx: int, criterion_id: str, status: VerdictStatus, **fields: Any +) -> CriterionVerdict: + """판정 하나 — 주지 않은 칸은 None.""" + base: dict[str, Any] = dict.fromkeys( + ("label", "passed", "reason", "rationale", "raw", "exception"), None + ) + base.update(prompt_tokens=None, completion_tokens=None) + base.update(fields) + return CriterionVerdict(sample_idx=sample_idx, criterion_id=criterion_id, status=status, **base) + + +def incomplete_code(verdicts: Sequence[CriterionVerdict]) -> str | None: + """레거시 어댑터의 샘플 실패 코드 — error > 출력 없음 > 라벨 미정 순. 모두 판정됐으면 None(D-L12-4).""" + if any(v.status == "error" for v in verdicts): + return LEGACY_CRITERION_ERROR + if any(v.status == "skipped" for v in verdicts): + return MISSING_OUTPUT + if any(v.passed is None for v in verdicts): + return LEGACY_VERDICT_UNKNOWN + return None + + +class RubricJudgeEvaluator(BaseEvaluator): + """rubric 기준마다 judge 에 묻는 평가기(§2.1~§2.3). 기본 provider 는 레지스트리가 채워 넘긴다(F9).""" + + required_fields = frozenset({"input", "output"}) + + def __init__( + self, + *, + rubric: Mapping[str, Any], + judge_model: str, + judge_provider: str, + max_tokens: int = DEFAULT_MAX_TOKENS, + ) -> None: + self._rubric = _parse_rubric(rubric) + if not isinstance(judge_model, str) or not judge_model.strip(): + raise ValueError(JUDGE_MODEL_REQUIRED) + if judge_provider not in _PROVIDER_MODULES: + shown = repr(judge_provider) if isinstance(judge_provider, str) else "non-string" + allowed = " · ".join(_PROVIDER_MODULES) + raise ValueError(f"{JUDGE_PROVIDER_UNKNOWN}: rubric_judge {shown} (allowed: {allowed})") + if isinstance(max_tokens, bool) or not isinstance(max_tokens, int): + raise ValueError(MAX_TOKENS_INVALID) + if not 1 <= max_tokens <= MAX_TOKENS_LIMIT: + raise ValueError(MAX_TOKENS_INVALID) + self._judge_model = judge_model + self._judge_provider = judge_provider + self._max_tokens = max_tokens + self._by_id = {c.id: c for c in self._rubric.criteria} + + # ── 읽기 ── + + @property + def judge_model(self) -> str: + return self._judge_model + + @property + def judge_provider(self) -> str: + """선언 provider 이름(`upstage`·`openai`).""" + return self._judge_provider + + @property + def max_tokens(self) -> int: + return self._max_tokens + + @property + def kind(self) -> str: + return self._rubric.kind + + @property + def labels(self) -> tuple[str, ...]: + return self._rubric.labels + + @property + def criterion_ids(self) -> tuple[str, ...]: + return tuple(c.id for c in self._rubric.criteria) + + @property + def item_aggregation(self) -> str: + """item `rubric_score` 의 파생 id(D-L12-5).""" + return ITEM_AGGREGATION[self._rubric.kind] + + @property + def points(self) -> dict[str, int | float]: + """항목 id → 점수(`criteria` 만, `checklist` 는 빈 dict).""" + return {c.id: c.points for c in self._rubric.criteria if c.points is not None} + + @property + def rubric_document(self) -> dict[str, Any]: + """rubric 문서의 깊은 사본(발행 `rubric.json`·`rubric_hash` 원천).""" + return json.loads(json.dumps(self._rubric.document, ensure_ascii=False)) + + # ── 프롬프트 ── + + def _criterion(self, criterion_id: str) -> RubricCriterion: + try: + return self._by_id[criterion_id] + except KeyError: + raise ValueError(f"rubric_judge: unknown criterion id {criterion_id!r}") from None + + def messages_for(self, sample: EvalSample, criterion_id: str) -> list[dict[str, str]]: + """기준 하나 × 샘플 하나의 메시지 둘(시스템·사용자) — 순수 함수.""" + criterion = self._criterion(criterion_id) + lines = [ + f"- {label}: " + f"{criterion.anchors.get(label) or DEFAULT_LABEL_DESCRIPTIONS.get(label, NO_DESCRIPTION)}" + for label in self.labels + ] + system = _fill( + SYSTEM_TEMPLATE, {"labels_csv": ", ".join(self.labels), "label_lines": "\n".join(lines)} + ) + reference = NO_REFERENCE if sample.reference is None else _render(sample.reference) + user = _fill( + USER_TEMPLATE, + { + "criterion_id": criterion.id, + "polarity": criterion.polarity, + "criterion_text": criterion.text, + "rendered_input": _render_input(sample.input), + "rendered_reference": reference, + "rendered_output": _render(sample.output), + }, + ) + return [{"role": "system", "content": system}, {"role": "user", "content": user}] + + # ── 호출 ── + + def _check_provider(self, provider: Any) -> None: + """알려진 실제 provider 모듈인데 선언과 다르면 `JudgeProviderMismatch` — `judge`·`evaluate` 의 첫 줄(D-L12-18).""" + module = type(provider).__module__ + if ( + module in _PROVIDER_MODULES.values() + and module != _PROVIDER_MODULES[self._judge_provider] + ): + raise JudgeProviderMismatch(self._judge_provider, module) + + def _selected(self, only: Collection[str] | None) -> tuple[RubricCriterion, ...]: + if only is None: + return self._rubric.criteria + if isinstance(only, str): + raise TypeError("rubric_judge: only must be a collection of criterion ids, not a str") + unknown = sorted(set(only) - set(self._by_id)) + if unknown: + raise ValueError(f"rubric_judge: only names unknown criterion id(s) {unknown}") + wanted = set(only) + return tuple(c for c in self._rubric.criteria if c.id in wanted) + + async def judge( + self, + sample: EvalSample, + provider: BaseProvider, + *, + sample_idx: int, + only: Collection[str] | None = None, + ) -> SampleVerdicts: + """기준 순서대로 순차 호출 — `only` 가 있으면 그 기준만(retry 대상). 나머지 기준은 결과에 넣지 않는다.""" + self._check_provider(provider) + return await self._judge(sample, provider, sample_idx, only, self._judge_model) + + async def _judge( + self, + sample: EvalSample, + provider: BaseProvider, + sample_idx: int, + only: Collection[str] | None, + model: str, + ) -> SampleVerdicts: + criteria = self._selected(only) + if sample.output is None: # 출력이 없으면 부르지 않는다(§2.3 마지막 행) + skipped = tuple( + _verdict(sample_idx, c.id, "skipped", reason=MISSING_OUTPUT) for c in criteria + ) + return SampleVerdicts(sample_idx, skipped) + verdicts = [await self._ask(sample, provider, sample_idx, c, model) for c in criteria] + return SampleVerdicts(sample_idx, tuple(verdicts)) + + async def _ask( + self, + sample: EvalSample, + provider: BaseProvider, + sample_idx: int, + criterion: RubricCriterion, + model: str, + ) -> CriterionVerdict: + messages = self.messages_for(sample, criterion.id) + try: + response = await provider.chat_completion( + messages=messages, model=model, temperature=TEMPERATURE, max_tokens=self._max_tokens + ) + except ( + Exception + ) as exc: # 호출 실패는 그 기준만 error — 메시지 없이 타입 이름만(원문 누출 방지) + name = type(exc).__name__ + _LOG.warning( + "rubric_judge: sample %s criterion %s → %s (%s)", + sample_idx, + criterion.id, + GRADER_UNAVAILABLE, + name, + ) + return _verdict( + sample_idx, criterion.id, "error", reason=GRADER_UNAVAILABLE, exception=name + ) + content = response.get("content") if isinstance(response, Mapping) else None + raw = content if isinstance(content, str) else None + prompt_tokens, completion_tokens = _tokens(response) + usage = {"prompt_tokens": prompt_tokens, "completion_tokens": completion_tokens} + try: + label, rationale = parse_verdict(raw, self.labels) + except VerdictParseError as exc: + _LOG.warning( + "rubric_judge: sample %s criterion %s → %s", sample_idx, criterion.id, exc.reason + ) + return _verdict(sample_idx, criterion.id, "error", reason=exc.reason, raw=raw, **usage) + passed = LABEL_PASSED.get(label) + return _verdict( + sample_idx, + criterion.id, + "ok", + label=label, + passed=passed, + rationale=rationale, + raw=raw, + **usage, + ) + + # ── 레거시 어댑터(D-L12-4) ── + + def item_value(self, passed_by_id: Mapping[str, bool]) -> float: + """모든 기준이 판정됐을 때의 item 값 — PB `points_clip@1`·`all_pass@1` 과 같은 뜻의 표준 라이브러리 계산. + + 채점 번들의 `rubric_score` 는 PB `derive` 가 정본이다(`scoring_publish.judge_bundle.item_score`) — 시험이 둘을 대조한다. + """ + missing = sorted(set(self._by_id) - set(passed_by_id)) + if missing: + raise ValueError(f"rubric_judge: verdicts do not cover criteria {missing}") + if self.kind == "checklist": + return 1.0 if all(passed_by_id[c] for c in self._by_id) else 0.0 + points = self.points + earned = math.fsum(points[c] for c in points if passed_by_id[c]) + possible = math.fsum(p for p in points.values() if p > 0) + return min(1.0, max(0.0, earned / possible)) + + def legacy_result(self, verdicts: Sequence[CriterionVerdict]) -> dict[str, Any]: + """판정들 → 레거시 `evaluate` 결과. 모든 기준이 ok·`passed` 있음일 때만, 아니면 `RubricVerdictIncomplete`.""" + code = incomplete_code(verdicts) + if code is not None: + raise RubricVerdictIncomplete(code) + passed = {v.criterion_id: bool(v.passed) for v in verdicts} + score = self.item_value(passed) + categories = {c: 1.0 if passed[c] else 0.0 for c in self.criterion_ids} + return {"score": score, "category_scores": categories, "details": {}} + + async def evaluate( + self, + sample: EvalSample, + provider: BaseProvider | None = None, + judge_model: str | None = None, + ) -> dict[str, Any]: + """레거시 어댑터(D-L12-4) — 모든 기준 ok·`passed` 있음일 때만 점수, 아니면 `RubricVerdictIncomplete`.""" + if provider is None: + raise ValueError(JUDGE_PROVIDER_REQUIRED) + self._check_provider(provider) + model = judge_model or self._judge_model + verdicts = await self._judge(sample, provider, LEGACY_SAMPLE_IDX, None, model) + return self.legacy_result(verdicts.verdicts) + + def aggregate(self, results: list[dict[str, Any]]) -> dict[str, Any]: + """`{"overall_score", "scores", "num_samples"}` — `llm_judge` 와 같은 모양. `scores` = 기준별 met 비율.""" + if not results: + return {"overall_score": 0.0, "scores": {}, "num_samples": 0} + n = len(results) + overall = math.fsum(float(r["score"]) for r in results) / n + scores = { + c: math.fsum(float(r.get("category_scores", {}).get(c, 0.0)) for r in results) / n + for c in self.criterion_ids + } + return {"overall_score": overall, "scores": scores, "num_samples": n} + + +# ── 파서(§2.3) ───────────────────────────────────────────────────────── + + +def _reject_constant(name: str) -> Any: + raise ValueError(f"non-finite constant {name}") + + +def _unique_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + keys = [key for key, _ in pairs] + if len(set(keys)) != len(keys): + raise ValueError("duplicate key") + return dict(pairs) + + +def parse_verdict(content: str | None, labels: Sequence[str]) -> tuple[str, str | None]: + """`rubric-verdict-json@1`(§2.3) — (라벨, 근거). 실패는 `VerdictParseError()`(메시지 = 코드만).""" + text = content.strip() if isinstance(content, str) else "" + if not text: + raise VerdictParseError(GRADER_FAILED_EMPTY) + block = _CODE_BLOCK_RE.match(text) + if block is not None: + text = block.group(1) + try: + data = json.loads(text, parse_constant=_reject_constant, object_pairs_hook=_unique_pairs) + except (ValueError, RecursionError): + raise VerdictParseError(GRADER_FAILED_UNPARSEABLE) from None + if not isinstance(data, dict): + raise VerdictParseError(GRADER_FAILED_SCHEMA) + label, rationale = data.get("label"), data.get("rationale") + if not isinstance(label, str) or not (rationale is None or isinstance(rationale, str)): + raise VerdictParseError(GRADER_FAILED_SCHEMA) + label = label.strip() + if label not in labels: + raise VerdictParseError(GRADER_FAILED_LABEL) + return label, rationale diff --git a/solar_eval/evaluators/rule_based.py b/solar_eval/evaluators/rule_based.py index 04d0cd950c7dcd9f5873d33c386a9c399c28e99a..91d2bbacfc6460dd9cf0fbb90372cff2660ac82b 100644 --- a/solar_eval/evaluators/rule_based.py +++ b/solar_eval/evaluators/rule_based.py @@ -53,7 +53,7 @@ class RuleBasedEvaluator(BaseEvaluator): self, sample: EvalSample, provider: BaseProvider | None = None, - judge_model: str = "gpt-4o", + judge_model: str | None = None, ) -> dict[str, Any]: rule_results = {} for rule_name in self.rules: diff --git a/solar_eval/hub_hook/__init__.py b/solar_eval/hub_hook/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..c5f63aa303d2c831695982ae7059af3b059329e0 --- /dev/null +++ b/solar_eval/hub_hook/__init__.py @@ -0,0 +1,24 @@ +"""엔진 run 종료 훅 — 번들 다리(in-process) → hub 다리(opt-in) (계획 2026-09-27 L6a · P9b). + +`runs start`·`runs resume`(·`runs batch`·L6b `runs eval`)이 run 을 끝낸 뒤 `run_hook` 을 부른다. 훅은 레거시 적재를 부르지 않는다 +(마스터 D-M2-4 (b) — 레거시 화면은 L10 컷오버로 사라졌다). hub 다리 opt-in 은 `HUB_DATABASE_URL` 또는 사용자 설정 파일의 +`hub.database_url`(사람 셸만 — `optin.resolve_hub_optin`, 계획 2026-09-28 L10 §2.6)이다. 실패해도 run 명령의 종료 코드는 기본 0 이고 +`--strict-ingest`/`SOLAR_EVAL_STRICT_INGEST=1` 에서만 3 이다(D-M2-3). + +**import 경계(D-L6-14).** 이 패키지의 모듈 최상위는 표준 라이브러리·click·`solar_eval.cli`·`solar_eval.hub_hook.*` 만 import 한다. +`poc_bundle`·`solar_eval.rescore` 는 함수 안에서. runner 경로(`RUNNER_PATH_MODULES`)는 이 패키지를 import 하지 않는다. +""" + +from solar_eval.hub_hook.hook import exit_code_for, finish_batch, gated_hub_leg, run_hook +from solar_eval.hub_hook.types import HookMode, HookOptions, HookResult, LegResult + +__all__ = [ + "HookMode", + "HookOptions", + "HookResult", + "LegResult", + "exit_code_for", + "finish_batch", + "gated_hub_leg", + "run_hook", +] diff --git a/solar_eval/hub_hook/bundle_leg.py b/solar_eval/hub_hook/bundle_leg.py new file mode 100644 index 0000000000000000000000000000000000000000..dfda35a978058b633a79bfc6cd5b6a1fc30052d4 --- /dev/null +++ b/solar_eval/hub_hook/bundle_leg.py @@ -0,0 +1,329 @@ +"""번들 다리 (계획 2026-09-27 L6 §2.5 · D-L6-2 · D-L6-8 · D-L6-9). + +case 잠금 **안**에서 hook 이 부른다. 순서: + +1. **백필** — `BackfillOptions(case=a/c, root=target.projects_dir, mirror_root=blob_mirror_root(), index_root=hub_index_root(), + repo_root=target.repo_root, run_ids=frozenset({run_id}))` → `backfill_case(opts, target.context)`(wrap → 미러 → 번들 색인 → + eval-wrap). `CaseReport.runs` 는 정확히 하나여야 한다(0 이면 `run_not_in_archive`). 부류 → 코드: + wrap `written·reused·rewrapped_input_fields` → 번들 있음 · `frozen:` → `bundle_frozen:`(hub 는 `no_bundle`) · + `error:` → `bundle_error:` · 번들 색인 `error:` → `bundle_index:`(계속) · eval-wrap `error:*` → + `eval_wrap:`(계속 — 번들은 있다) · eval-wrap `skip:*`·`frozen:*` 는 정보 · `BackfillError` → `mirror_unadoptable`. +2. **재채점**(번들이 있을 때만) — lcs_diff 계열(D-L6-8: run.json 스냅샷 `config.evaluator_config.config.type`, 스냅샷이 없으면 run + 디렉토리 평가 파일의 `poc_bundle.legacy_run.evaluator_family`)이면 `rescore_case(a, c, run_ids={run_id})`(case 모드 채택 규칙, + D-L6-9) → `written|exists` 가 ok · `no_results` skip → `rescore_empty` · 그 밖 skip → `rescore_skip:` · 오류 → + `rescore_error:`. 계열이 아니면 `not_proofread_family`(정보 — "이 평가기는 M2 채점 번들 생산자가 없다", Q-M2-1). +3. **색인** — `indexes.refresh`(lock·템플릿·텍스트 모드·추출기·색인 코드 키가 바뀌었을 때만, D-L6-7). + +다리 결과는 하나의 `LegResult` 다. 실패가 여럿이면 **순서상 첫 실패**가 `code` 이고, 단계마다의 결과는 `detail` 에 모두 남는다 +(상태 파일 §2.7): `{"backfill": run_entry, "rescore": {"status", "code"?, "bundle_id"?}, "indexes": <코드>}`. 번들이 있으면 실패여도 +`detail.backfill.wrap` 이 남아 hook 이 `types.bundle_ready` 로 hub 다리를 돈다(D-L6-2). 색인 CLI 의 정합성 경고가 있으면 +`index_warnings: <건수>`, 치운 옛 색인 디렉토리가 있으면 `index_removed: [<이름>…]` 를 더한다(리뷰 지적 2·4 — 줄 자체는 +`indexes` 가 stderr 로 낸다). + +**단계 예외 봉쇄.** 재채점·색인 단계의 예기치 않은 예외는 그 단계만 `rescore_exception:`·`indexes_exception:` 실패로 +강등한다(타입 이름만) — 백필이 만든 번들을 버리지 않고 hub 다리까지 가게. 백필 자체의 예외는 hook 이 `bundle_exception:` +으로 강등한다(번들 유무를 모른다). 원문·locals 는 싣지 않지만 **발생 위치**는 남긴다(리뷰 지적 5): 가장 안쪽 프레임의 +`<파일 basename>:<줄>:<함수>` 를 `detail.exception_at.<단계>` 에(고객 데이터가 없는 코드 좌표), 트레이스백 전체는 이 모듈 로거에 +(`hook` 과 같은 규칙 — 기본 DEBUG, `SOLAR_EVAL_HOOK_DEBUG=1` 이면 WARNING). + +`poc_bundle`·`solar_eval.rescore` 는 함수 안에서만 import 한다(D-L6-14). 출력·detail 에는 id·부류·코드만(고객 텍스트 없음). +""" + +from __future__ import annotations + +import logging +import traceback +from collections.abc import Mapping +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from solar_eval.hub_hook import indexes, optin, target +from solar_eval.hub_hook.types import ( + BUNDLE_ERROR_PREFIX, + BUNDLE_FROZEN_PREFIX, + BUNDLE_INDEX_FAILED_PREFIX, + EVAL_WRAP_FAILED_PREFIX, + MIRROR_UNADOPTABLE, + NO_BUNDLE, + NOT_PROOFREAD_FAMILY, + PROOFREAD_EVALUATOR_TYPE, + RESCORE_EMPTY, + RESCORE_ERROR_PREFIX, + RESCORE_NO_RESULTS, + RESCORE_OK, + RESCORE_SKIP_PREFIX, + RUN_NOT_IN_ARCHIVE, + STATUS_FAILED, + STATUS_NOT_RUN, + STATUS_SKIPPED, + STEP_BACKFILL, + STEP_INDEXES, + STEP_RESCORE, + WRAP_BUNDLE_READY, + WRAP_ERROR_PREFIX, + WRAP_FROZEN_PREFIX, + LegResult, + Target, + exception_code, +) + +_RUN_JSON = "run.json" +_LOG = logging.getLogger(__name__) +# detail 키 `index_warnings`·`index_removed`·`exception_at`(리뷰 지적 2·4·5)는 코드 모양 문자열이라 이 모듈 최상위 상수로 두지 +# 않는다(`test_types_home` — 코드 문자열은 `types.py` 한 곳). 쓰는 자리에 한 번씩만 적었다 — l6a-verify 가 `types.py` 로 옮긴다 +# (계획 §2.5 구현 시 변경 메모). + + +@dataclass(frozen=True) +class _Backfilled: + """백필 결과 — `entry` 는 보고서 `run_entry`(없으면 run 이 없거나 case 오류), `failures` 는 순서대로의 실패 코드.""" + + entry: Mapping[str, Any] | None + run_id: str | None = None + bundle_id: str | None = None + failures: tuple[str, ...] = () + + @property + def ready(self) -> bool: + return self.entry is not None and self.entry.get("wrap") in WRAP_BUNDLE_READY + + +@dataclass +class _Steps: + """다리 안 단계들의 결과 — 첫 실패와 그 hint 를 모은다.""" + + detail: dict[str, Any] = field(default_factory=dict) + failures: list[str] = field(default_factory=list) + hint: str | None = None + + def fail(self, code: str, hint: str | None = None) -> None: + if not self.failures: + self.hint = hint + self.failures.append(code) + + def result(self) -> LegResult: + if self.failures: + return LegResult.failed(self.failures[0], self.hint, self.detail) + return LegResult.ok(self.detail) + + def contain(self, step: str, exc: Exception) -> str: + """단계 예외 → 코드(타입 이름만). 위치 좌표는 detail 에, 트레이스백은 로거로(모듈 설명 · 리뷰 지적 5).""" + code = exception_code(step, exc) + where = exception_location(exc) + if where is not None: + self.detail.setdefault("exception_at", {})[step] = where + level = logging.WARNING if optin.hook_debug_requested() else logging.DEBUG + _LOG.log(level, "Experiment Hub 훅 번들 다리 단계 예외 봉쇄 — %s", code, exc_info=exc) + return code + + +def exception_location(exc: BaseException) -> str | None: + """가장 안쪽 프레임의 `<파일 basename>:<줄>:<함수>` — 메시지·locals 는 싣지 않는다(고객 데이터가 없는 코드 좌표만).""" + frames = traceback.extract_tb(exc.__traceback__) + if not frames: + return None + last = frames[-1] + return f"{Path(last.filename).name}:{last.lineno}:{last.name}" + + +def run_bundle_leg(target: Target, run_dir: Path, run_id: str) -> LegResult: + """run 하나의 백필 → 재채점 → 색인(§2.5). 백필 예외는 hook 이 `bundle_exception:` 으로 강등한다.""" + backfilled = _backfill(target, run_id) + if backfilled.entry is None: + return LegResult.failed(backfilled.failures[0]) + steps = _Steps({STEP_BACKFILL: dict(backfilled.entry)}) + for code in backfilled.failures: + steps.fail(code) + if backfilled.ready: + _rescore_step(target, Path(run_dir), backfilled, steps) + else: + steps.detail[STEP_RESCORE] = {"status": STATUS_NOT_RUN, "code": NO_BUNDLE} + _indexes_step(target, steps) + return steps.result() + + +def ensure_run_bundle(target: Target, run_id: str) -> tuple[LegResult, Path | None]: + """백필 부분만(L6b `runs eval` 새 경로 ①) — 채택된 미러 run 번들 경로를 함께 돌려준다. 번들이 없으면 None. + + **전제: 호출자가 `lock.case_lock(target.account, target.case)` 를 쥔 채 부른다**(계획 §2.12 B4 "new_path (case 잠금 안)" · + 리뷰 지적 6) — 백필은 미러·번들 색인 사이드카·eval-wrap 을 쓰므로 같은 case 의 훅과 겹치면 안 된다. 여기서 잠그지 않는 + 까닭: L6b 는 ①~⑤(번들 확인·채점·발행·hub·상태 파일)를 한 잠금 안에서 돈다 — `flock` 은 같은 프로세스라도 따로 연 기술자끼리 + 막으므로 안에서 다시 잡으면 자기 자신을 기다리다 한도에 걸린다. + + 판정 집합은 `run_bundle_leg` 와 같다(`WRAP_BUNDLE_READY`) — 훅에서 통과한 run 이 여기서 실패하지 않게(검토 F12). + eval-wrap·번들 색인 실패는 `run_bundle_leg` 처럼 실패로 싣되 경로는 돌려준다(번들은 있다). + """ + backfilled = _backfill(target, run_id) + if backfilled.entry is None: + return LegResult.failed(backfilled.failures[0]), None + detail = {STEP_BACKFILL: dict(backfilled.entry)} + path = _mirror_bundle_path(target, backfilled.bundle_id) if backfilled.ready else None + if backfilled.failures: + return LegResult.failed(backfilled.failures[0], detail=detail), path + return LegResult.ok(detail), path + + +# ── 백필 ───────────────────────────────────────────────────────────────── + + +def _backfill(target: Target, run_id: str) -> _Backfilled: + """`backfill_case` 를 run 하나로. 충돌 검사는 PB 가 case 의 run 디렉토리 전부로 한다.""" + from poc_bundle.backfill.report import run_entry + from poc_bundle.backfill.runner import BackfillError, BackfillOptions, backfill_case + from poc_bundle.home import blob_mirror_root, hub_index_root + + opts = BackfillOptions( + case=target.case_ref, + root=target.projects_dir, + mirror_root=blob_mirror_root(), + index_root=hub_index_root(), + repo_root=target.repo_root, + run_ids=frozenset({run_id}), + ) + try: + report = backfill_case(opts, target.context) + except BackfillError: + return _Backfilled(None, failures=(MIRROR_UNADOPTABLE,)) + if not report.runs: + return _Backfilled(None, failures=(RUN_NOT_IN_ARCHIVE,)) + if len(report.runs) != 1: + raise RuntimeError(f"backfill returned {len(report.runs)} runs for one run directory") + run = report.runs[0] + return _Backfilled(run_entry(run), run.run_id, run.bundle_id, _backfill_failures(run)) + + +def _backfill_failures(run: Any) -> tuple[str, ...]: + """`RunReport` 부류 → 번들 다리 실패 코드(순서: 어댑터·wrap → 번들 색인 → eval-wrap).""" + failures: list[str] = [] + wrap_failure = _wrap_failure(run.adapter, run.wrap) + if wrap_failure is not None: + failures.append(wrap_failure) + if isinstance(run.index, str) and run.index.startswith(WRAP_ERROR_PREFIX): + failures.append(f"{BUNDLE_INDEX_FAILED_PREFIX}{run.index.removeprefix(WRAP_ERROR_PREFIX)}") + if isinstance(run.eval_wrap, str) and run.eval_wrap.startswith(WRAP_ERROR_PREFIX): + detail = run.error_code or run.eval_wrap.removeprefix(WRAP_ERROR_PREFIX) + failures.append(f"{EVAL_WRAP_FAILED_PREFIX}{detail}") + return tuple(failures) + + +def _wrap_failure(adapter: str | None, wrap: str | None) -> str | None: + """wrap 이 번들을 남기지 못했으면 그 코드. 어댑터가 막아 wrap 에 닿지 않았으면 어댑터 부류로.""" + if wrap in WRAP_BUNDLE_READY: + return None + if isinstance(wrap, str) and wrap.startswith(WRAP_FROZEN_PREFIX): + return f"{BUNDLE_FROZEN_PREFIX}{wrap.removeprefix(WRAP_FROZEN_PREFIX)}" + stage = wrap if isinstance(wrap, str) else adapter + if isinstance(stage, str) and stage.startswith(WRAP_ERROR_PREFIX): + return f"{BUNDLE_ERROR_PREFIX}{stage.removeprefix(WRAP_ERROR_PREFIX)}" + return f"{BUNDLE_ERROR_PREFIX}{stage or 'none'}" + + +def _mirror_bundle_path(t: Target, bundle_id: str | None) -> Path | None: + """미러의 `accounts///eval/bundles/`(있을 때만).""" + from solar_eval.rescore.paths import BUNDLES_AREA, mirror_area_dir + + if not bundle_id: + return None + path = mirror_area_dir(None, t.account, t.case, BUNDLES_AREA) / bundle_id + return path if path.is_dir() else None + + +# ── 재채점 ─────────────────────────────────────────────────────────────── + + +def _rescore_step(t: Target, run_dir: Path, backfilled: _Backfilled, steps: _Steps) -> None: + try: + if not is_proofread_family(run_dir): + steps.detail[STEP_RESCORE] = {"status": STATUS_SKIPPED, "code": NOT_PROOFREAD_FAMILY} + return + status, code, bundle_id = _rescore(t, backfilled.run_id or run_dir.name) + except Exception as exc: # 단계 경계 — 백필이 만든 번들로 hub 다리는 계속 간다(모듈 설명) + status, code, bundle_id = STATUS_FAILED, steps.contain(STEP_RESCORE, exc), None + entry: dict[str, Any] = {"status": status} + if code is not None: + entry["code"] = code + if bundle_id is not None: + entry["bundle_id"] = bundle_id + steps.detail[STEP_RESCORE] = entry + if status == STATUS_FAILED and code is not None: + steps.fail(code) + + +def is_proofread_family(run_dir: Path) -> bool: + """재채점 대상인가(D-L6-8). 스냅샷 평가기 type 이 있으면 그것, 없으면 run 디렉토리 평가 파일의 계열(검토 F18). + + run.json·평가 파일을 못 읽으면 계열을 모르므로 대상이 아니다(`not_proofread_family` — wrap 이 그 run 을 이미 가른다). + """ + from poc_bundle.canonical import load_json_strict + from poc_bundle.legacy_run import ( + FAMILY_LCS, + LegacyRunError, + evaluator_family, + read_eval_files, + split_rounds, + ) + + try: + run_json = load_json_strict((Path(run_dir) / _RUN_JSON).read_bytes(), safe_integers=False) + except (OSError, ValueError, RecursionError): + return False + if not isinstance(run_json, dict): + return False + kind = target.snapshot_evaluator_type(run_json) + if kind is not None: + return kind == PROOFREAD_EVALUATOR_TYPE + try: + evaluation, lines = read_eval_files(Path(run_dir)) + except LegacyRunError: + return False + return evaluator_family(run_json, evaluation, split_rounds(evaluation, lines)) == FAMILY_LCS + + +def _rescore(t: Target, run_id: str) -> tuple[str, str | None, str | None]: + """`rescore_case(run_ids={run_id})` → (상태, 코드, 채점 번들 id). 상태는 `written|exists`(ok) 또는 `failed`.""" + from poc_bundle.home import blob_mirror_root + + from solar_eval.rescore.case import rescore_case + + summary = rescore_case( + t.account, + t.case, + mirror_root=blob_mirror_root(), + repo_root=t.repo_root, + run_ids=frozenset({run_id}), + ) + if summary.failures: + return STATUS_FAILED, f"{RESCORE_ERROR_PREFIX}{summary.failures[0].code}", None + if len(summary.outcomes) != 1: + return STATUS_FAILED, RESCORE_EMPTY, None + outcome = summary.outcomes[0] + if outcome.status in RESCORE_OK: + bundle_id = None if outcome.path is None else Path(outcome.path).name + return outcome.status, None, bundle_id + if outcome.code == RESCORE_NO_RESULTS: + return STATUS_FAILED, RESCORE_EMPTY, None + return STATUS_FAILED, f"{RESCORE_SKIP_PREFIX}{outcome.code}", None + + +# ── 색인 ───────────────────────────────────────────────────────────────── + + +def _indexes_step(t: Target, steps: _Steps) -> None: + try: + outcome = indexes.refresh(t) + except Exception as exc: # 단계 경계 — 색인은 case 파생물이라 run 번들·hub 다리를 막지 않는다 + code = steps.contain(STEP_INDEXES, exc) + steps.detail[STEP_INDEXES] = f"{STATUS_FAILED}:{code}" + steps.fail(code) + return + steps.detail[STEP_INDEXES] = outcome.code + if outcome.warnings: + steps.detail["index_warnings"] = len(outcome.warnings) + if outcome.removed: + steps.detail["index_removed"] = list(outcome.removed) + if outcome.failed: + steps.fail(outcome.code.removeprefix(f"{STATUS_FAILED}:"), outcome.reason) + + +__all__ = ["ensure_run_bundle", "is_proofread_family", "run_bundle_leg"] diff --git a/solar_eval/hub_hook/console.py b/solar_eval/hub_hook/console.py new file mode 100644 index 0000000000000000000000000000000000000000..35dc2c0af914dcbaa9b55f816e30bbbed0061faa --- /dev/null +++ b/solar_eval/hub_hook/console.py @@ -0,0 +1,271 @@ +"""훅 콘솔 — 다리별 한 줄과 되살리기 명령 문구 (계획 2026-09-27 L6 §2.6 표 · §2.7 · 검토 F16). + +되살리기 명령의 URL 자리는 늘 `HUB_DATABASE_URL=…` 이다 — 값은 어디에도 싣지 않는다(D-L6-4). 실패 줄은 빨강 + 되살리기. +레거시 화면(`public`)은 L10 컷오버로 코드에서 사라졌다 — 레거시 수동 적재 줄은 없다(계획 2026-09-28 L10 §2.6 · L6b 후속 1). +hub opt-in 이 없으면(`not_opted_in`) 빨간 줄로 "이 run 은 hub(/v1)에 없다" 와 켜는 법·지금 싣는 명령을 안내한다(마스터 D-M2-18). +요약 머리에는 훅 전체 경과 초(상태 파일의 `started_at`→`finished_at`, 잠금 대기 포함)를 붙인다 — 첫 실데이터 run(U-L6a-1)에서 +콘솔만으로 hub 적재 시간을 알 수 없었기 때문이다. 시각이 없거나 형식이 다르면 붙이지 않는다. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from datetime import datetime +from typing import Any + +import click + +from solar_eval.hub_hook import optin +from solar_eval.hub_hook.types import ( + CASE_BLOCKED_PREFIX, + CASE_FAILED_PREFIX, + FILTER_NO_MATCH, + HUB_ERR_CONFIRMATION_REQUIRED, + HUB_ERR_CONNECTION_FAILED, + HUB_FAILED_PREFIX, + LEG_BUNDLE, + LEG_HOOK, + LEG_HUB, + LEG_LOCK, + LEG_PUBLISH, + LOCK_TIMEOUT, + NOT_OPTED_IN, + RUN_NOT_OK_PREFIX, + SCHEMA_MISMATCH, + SKIP_CASE_UNMAPPED, + SKIP_DATA_HOME_INSIDE_REPO, + SKIP_DATA_HOME_MISMATCH, + SKIP_NO_REPO_ROOT, + SKIP_NOT_REPO_MANAGED, + SMOKE_RUN, + STAGE_CASE_LOCK, + STATUS_FAILED, + STATUS_OK, + STATUS_SKIPPED, + HookResult, + LegResult, + Skip, +) + +URL_PLACEHOLDER = "HUB_DATABASE_URL=…" +#: 상태 파일 시각 형식(`hook._now` 와 같다). +HOOK_TIME_FORMAT = "%Y-%m-%dT%H:%M:%SZ" +LEG_LABELS = { + LEG_BUNDLE: "번들", + LEG_HUB: "hub 적재", + LEG_LOCK: "case 잠금", + LEG_HOOK: "훅", + LEG_PUBLISH: "채점 발행", +} +SKIP_REASONS = { + SKIP_NOT_REPO_MANAGED: "레포 관리 프로젝트가 아니다", + SKIP_NO_REPO_ROOT: "레포 config 루트가 없다(--projects-dir 등)", + SKIP_CASE_UNMAPPED: "project 가 case 에 매핑되지 않는다 — make bundle-census 로 확인", + SKIP_DATA_HOME_MISMATCH: "엔진 아카이브가 데이터 홈 밖이다", + SKIP_DATA_HOME_INSIDE_REPO: "데이터 홈이 레포 안이다 — 훅 파생물은 git 밖에 둔다(CBS_DATA_HOME)", +} +#: 요약 줄에 싣는 번들 다리 detail 키(순서대로). +_BUNDLE_SUMMARY_KEYS = ( + (("backfill", "wrap"), "wrap"), + (("backfill", "eval_wrap"), "eval-wrap"), + (("rescore", "status"), "재채점"), + (("indexes",), "색인"), +) + + +# ── 되살리기 명령 ──────────────────────────────────────────────────────── + + +def hub_ingest_command(case_ref: str) -> str: + return f"{URL_PLACEHOLDER} make hub-ingest CASE={case_ref} CONFIRM=local" + + +def hub_backfill_command(case_ref: str, run_id: str) -> str: + return f'make hub-backfill CASE={case_ref} ARGS="--run {run_id}"' + + +def hub_recovery(code: str, case_ref: str, run_id: str) -> str: + """hub 다리 실패 코드 → 되살리기 문구(§2.6 표).""" + ingest = hub_ingest_command(case_ref) + if code == f"{HUB_FAILED_PREFIX}{HUB_ERR_CONFIRMATION_REQUIRED}": + return _confirmation_hint(ingest) + if code == f"{HUB_FAILED_PREFIX}{HUB_ERR_CONNECTION_FAILED}": + return f"make experiment-hub-up 뒤 {ingest}" + if code == f"{CASE_FAILED_PREFIX}{STAGE_CASE_LOCK}": + return f"앞 적재가 끝난 뒤 {ingest}" + if code.startswith((CASE_BLOCKED_PREFIX, CASE_FAILED_PREFIX)): + return f"{ingest} 후 이슈 표(/v1/{case_ref}) 확인" + if code == SCHEMA_MISMATCH: + return ( + f"hub 스키마 판이 다르다 — {URL_PLACEHOLDER} make hub-m1-local CASE={case_ref} " + "CONFIRM=local SKIP_PULL=1 FROM=backfill (주의: 재구성은 모든 case 를 지운다, case 마다 다시)" + ) + if code.startswith(RUN_NOT_OK_PREFIX): + return f"{hub_backfill_command(case_ref, run_id)} 뒤 {ingest}" + if code == FILTER_NO_MATCH: + return f"pnpm 워크스페이스에 @poc/eval-store 가 없다 — pnpm install 뒤 {ingest}" + return ingest + + +#: 사용자 DB 쓰기 확인 안내(사람 셸) — 영속 `hub.confirm` 은 마스터 §4 규칙 9 의 의도된 예외다(L10 §2.6 · 검토 F1-3). +CONFIRMATION_HINT_HUMAN = ( + "사용자 DB 에 쓰려면 명령 앞에 SOLAR_EVAL_HUB_CONFIRM=local 을 붙이거나 설정 파일 hub.confirm: local" + "(사람 셸만 · URL 도 설정에 있을 때만 — 규칙 9 의 의도된 예외)" +) + + +#: `not_opted_in` 빨간 줄의 머리(사람·에이전트 공통). +NOT_OPTED_IN_HEAD = "hub opt-in 없음 — 이 run 은 hub(/v1)에 없다." + + +def not_opted_in_hint(case_ref: str) -> str: + """hub opt-in 이 없다 — 빨간 줄 안내(마스터 D-M2-18 · L10 §2.6 표 "둘 다 없음"). URL 값은 싣지 않는다. + + 에이전트 세션(`optin.AGENT_SESSION_MARKERS`)은 설정 파일 `hub.database_url` 을 무시하므로(D-L10-6) "설정 파일로 켜라" 를 + 말하지 않고 사람이 부를 명령만 준다 — 바로 앞 "설정 파일을 무시한다" 경고와 어긋나지 않게(리뷰 L10 지적 3, + `_confirmation_hint` 와 같은 갈래). + """ + ingest = hub_ingest_command(case_ref) + if optin.is_agent_session(): + return f"{NOT_OPTED_IN_HEAD} 에이전트 세션은 설정 파일 opt-in 을 따르지 않는다 — 사람이 직접: {ingest}" + return ( + f"{NOT_OPTED_IN_HEAD} 켜려면 설정 파일 hub.database_url 또는 명령 앞 " + f"{URL_PLACEHOLDER}. 지금 싣기: {ingest}" + ) + + +def _confirmation_hint(ingest: str) -> str: + """hub 가 사용자 DB 쓰기 확인을 요구했다. 에이전트 세션(`optin.AGENT_SESSION_MARKERS`)은 확인을 무시하므로 사람이 부를 명령을 안내한다.""" + if optin.is_agent_session(): + return f"에이전트 세션은 사용자 DB 쓰기 확인을 넘기지 않는다 — 사람이 직접: {ingest}" + return CONFIRMATION_HINT_HUMAN + + +def default_hint(leg_name: str, leg: LegResult, case_ref: str | None, run_id: str) -> str | None: + """다리가 hint 를 싣지 않았을 때 콘솔이 붙이는 안내.""" + if leg.hint is not None or case_ref is None: + return leg.hint + if leg.status == STATUS_SKIPPED and leg_name == LEG_HUB and leg.code == NOT_OPTED_IN: + return not_opted_in_hint(case_ref) + if leg.status != STATUS_FAILED: + return None + if leg_name == LEG_HUB: + return hub_recovery(leg.code, case_ref, run_id) + if leg_name == LEG_LOCK and leg.code == LOCK_TIMEOUT: + return f"같은 case 의 다른 훅이 끝난 뒤 {hub_backfill_command(case_ref, run_id)} → {hub_ingest_command(case_ref)}" + return f"{hub_backfill_command(case_ref, run_id)} → {hub_ingest_command(case_ref)}" + + +# ── 출력 ───────────────────────────────────────────────────────────────── + + +def echo_no_ingest(project: str, run_id: str, case_ref: str | None) -> None: + """`--no-ingest` — hub 훅 전체를 건너뛴다는 한 줄(Q-M2-4). `project` 는 호출자 시그니처 유지용이다(L10 — 레거시 줄 삭제).""" + del project # 레거시 화면 수동 적재 줄이 L10 에서 사라졌다(계획 2026-09-28 L10 §2.6) + if case_ref is None: + click.secho("\nhub 훅 건너뜀(--no-ingest)", fg="yellow") + return + revive = f"{hub_backfill_command(case_ref, run_id)} → {hub_ingest_command(case_ref)}" + click.secho(f"\nhub 훅 건너뜀(--no-ingest) — 되살리기: {revive}", fg="yellow") + + +def echo_skip(skip: Skip, project: str) -> None: + """hub 대상이 아닌 run — 한 줄(standalone 은 부르지 않는다).""" + reason = SKIP_REASONS.get(skip.code, skip.code) + paths = " · ".join(f"{k}={v}" for k, v in skip.detail.items()) + suffix = f" ({paths})" if paths else "" + click.secho(f"\nhub 훅 건너뜀: {skip.code} — {reason}{suffix}", fg="yellow") + + +def echo_smoke(case_ref: str) -> None: + """`--limit` 스모크 run — 한 줄(잠금·상태 파일 없음, D-L6-10 · 완료 정의 §0.4-2).""" + click.secho( + f"\nhub 훅 건너뜀: {SMOKE_RUN} — --limit 스모크 run 은 번들·hub 에 싣지 않는다 ({case_ref})", + fg="yellow", + ) + + +def elapsed_seconds(result: HookResult) -> int | None: + """훅 전체 경과 초(`started_at`→`finished_at`). 시각이 없거나 형식이 다르거나 음수면 None.""" + if not result.started_at or not result.finished_at: + return None + try: + started = datetime.strptime(result.started_at, HOOK_TIME_FORMAT) + finished = datetime.strptime(result.finished_at, HOOK_TIME_FORMAT) + except ValueError: + return None + seconds = int((finished - started).total_seconds()) + return seconds if seconds >= 0 else None + + +def echo_summary(result: HookResult, project: str) -> None: + """다리마다 한 줄 + 실패면 되살리기(§2.7). 머리에 경과 초. 레거시 화면 줄은 L10 에서 없어졌다.""" + elapsed = elapsed_seconds(result) + suffix = f" · 경과 {elapsed} s" if elapsed is not None else "" + click.secho( + f"\nExperiment Hub 훅 — {result.case or project} · {result.run_id}{suffix}", fg="blue", bold=True + ) + for name, leg in result.legs.items(): + _echo_leg(name, leg, result.case, result.run_id) + + +def _echo_leg(name: str, leg: LegResult, case_ref: str | None, run_id: str) -> None: + label = f" {LEG_LABELS.get(name, name):<10}" + hint = default_hint(name, leg, case_ref, run_id) + if leg.status == STATUS_OK: + details = _ok_details(leg.detail) + click.secho(f"{label} ok{f' ({details})' if details else ''}", fg="green") + elif leg.status == STATUS_FAILED: + click.secho(f"{label} 실패 — {leg.code}", fg="red", bold=True) + if hint: + click.secho(f" {'':<10} 되살리기: {hint}", fg="red") + elif leg.status == STATUS_SKIPPED: + # hub opt-in 없음은 빨간 줄(D-M2-18 — 이 run 은 /v1 에 없다), 그 밖 건너뜀은 노랑 + color = "red" if (name, leg.code) == (LEG_HUB, NOT_OPTED_IN) else "yellow" + click.secho(f"{label} 건너뜀 — {leg.code}{f'. {hint}' if hint else ''}", fg=color) + else: + click.secho(f"{label} 돌지 않음 — {leg.code}", fg="yellow") + + +def _ok_details(detail: Mapping[str, Any]) -> str: + parts = [] + for path, label in _BUNDLE_SUMMARY_KEYS: + value: Any = detail + for key in path: + value = value.get(key) if isinstance(value, Mapping) else None + if isinstance(value, str): + parts.append(f"{label} {value}") + return " · ".join(parts) + + +def echo_batch_hub(leg: LegResult, case_ref: str, run_ids: Iterable[str]) -> None: + """batch 끝 hub 다리 한 줄(D-L6-12).""" + ids = list(run_ids) + _echo_leg(LEG_HUB, leg, case_ref, ids[0] if len(ids) == 1 else "") + + +def echo_batch_summary(results: Iterable[HookResult | None]) -> None: + """batch 끝 요약 "훅: ok N · 실패 M · 건너뜀 K"(건너뜀 = 훅이 돌 다리가 없었던 run).""" + ok = failed = skipped = 0 + for result in results: + if result is not None and not result.ok: + failed += 1 + elif result is not None and any(leg.status == STATUS_OK for leg in result.legs.values()): + ok += 1 + else: + skipped += 1 + color = "red" if failed else "green" + click.secho(f"훅: ok {ok} · 실패 {failed} · 건너뜀 {skipped}", fg=color) + + +def echo_warning(message: str) -> None: + click.secho(f" ⚠ {message}", fg="yellow", err=True) + + +def echo_hook_crashed(code: str) -> None: + """훅 바깥 겹이 예외를 잡았다 — run 결과는 그대로다(D-L6-2).""" + click.secho(f"\nExperiment Hub 훅이 예기치 않게 멈췄다 — {code} (run 결과는 디스크에 있다)", fg="red") + + +def echo_status_write_failed(code: str) -> None: + echo_warning(f"훅 상태 파일을 쓰지 못했다 — {code}") diff --git a/solar_eval/hub_hook/hook.py b/solar_eval/hub_hook/hook.py new file mode 100644 index 0000000000000000000000000000000000000000..58c658fd72e49dea5d201588e59f1194aaaf3758 --- /dev/null +++ b/solar_eval/hub_hook/hook.py @@ -0,0 +1,271 @@ +"""run 종료 훅 오케스트레이터 (계획 2026-09-27 L6 §2.1 · D-L6-2 · D-L6-3 · D-L6-11 · D-L6-12). + +순서 = **번들 → hub**(레거시 적재는 부르지 않는다, D-M2-4 (b)). hub 대상 run 은 case 잠금 **안**에서 두 다리를 돌고 상태 파일을 쓴 +뒤 잠금 밖에서 콘솔 요약을 낸다. + +**예외 봉쇄(D-L6-2 · 검토 F1).** 다리 호출마다 `except Exception` → `failed:_exception:`(타입 이름만). 상태 파일 쓰기 +실패는 콘솔 경고로 강등하고, 훅 전체도 한 겹 더 감싸 run 명령이 훅 때문에 종료 1·트레이스백이 되지 않게 한다. batch 에서 +훅 예외가 프롬프트 실패로 세지지도 않는다. `BaseException`(KeyboardInterrupt·pytest tripwire)은 잡지 않는다. + +**예외 상세는 로그로만(리뷰 지적 7).** 콘솔·상태 파일에는 타입 이름만 싣고(D-L6-2), 트레이스백은 이 모듈 로거에 남긴다 — +기본은 DEBUG(보통 설정에서는 보이지 않는다), `SOLAR_EVAL_HOOK_DEBUG=1` 이면 WARNING(stderr). 트레이스백은 입력 조각을 담을 수 +있으니 현장 진단 때만 켠다. + +**스모크 run 은 잠그지 않는다(D-L6-3 "돌 다리가 없으면 잠그지 않는다" · D-L6-10).** hub 대상이어도 `--limit` 스모크면 잠금·상태 +파일 없이 두 다리 `skipped:smoke_run` 한 줄로 끝난다 — 다른 훅이 잠금을 쥐고 있어도 기다리거나 `lock_timeout` 이 되지 않는다. + +종료 코드는 호출자가 `exit_code_for(result, strict)` 로 정한다 — 기본 0, strict 이고 실패 다리가 있으면 3. + +**opt-in 한 곳(계획 2026-09-28 L10 §2.6 · D-L10-6).** `gated_hub_leg` 가 `optin.resolve_hub_optin(config=t.hub_config)` 로 +환경 변수 > 설정 파일 `hub:` 절을 푼다. 설정 출처면 URL 은 자식 환경(`child_env`)으로만 가고, 설정 값은 진단 가림·상태 파일 +거부 목록(`optin.config_secrets`)에 더한다. 호출자(`runs start`·`resume`·batch 끝·`runs eval` 발행) 시그니처는 그대로다. +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable, Sequence +from contextlib import ExitStack +from datetime import datetime, timezone +from pathlib import Path +from typing import TYPE_CHECKING + +from solar_eval.hub_hook import bundle_leg, console, hub_leg, lock, optin, status, target +from solar_eval.hub_hook.types import ( + BATCH_PENDING, + EXIT_HOOK_FAILED, + HOOK_LOCK_TIMEOUT_SECONDS, + LEG_BUNDLE, + LEG_HOOK, + LEG_HUB, + LEG_LOCK, + LEG_STATUS_FILE, + LOCK_TIMEOUT, + NO_BUNDLE, + NO_WORKSPACE, + NOT_OPTED_IN, + SKIP_STANDALONE, + SMOKE_RUN, + HookMode, + HookOptions, + HookResult, + LegResult, + Skip, + Target, + bundle_ready, + exception_code, + hub_run_id, +) + +if TYPE_CHECKING: + from solar_eval.cli.config import CLIConfig + +_LOG = logging.getLogger(__name__) + + +def _now() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _contained_code(leg: str, exc: Exception) -> str: + """봉쇄한 예외 → 코드(타입 이름만, D-L6-2). 트레이스백은 로거로만 — 콘솔·상태 파일에는 싣지 않는다.""" + code = exception_code(leg, exc) + level = logging.WARNING if optin.hook_debug_requested() else logging.DEBUG + _LOG.log(level, "Experiment Hub 훅 예외 봉쇄 — %s", code, exc_info=exc) + return code + + +def _contained(leg: str, fn: Callable[..., LegResult], *args, **kwargs) -> LegResult: + """다리 하나를 돌리고 예기치 않은 예외를 `_exception:` 실패로 강등한다(D-L6-2).""" + try: + return fn(*args, **kwargs) + except Exception as exc: # 다리 경계 — 어떤 예외도 run 명령을 실패로 만들지 않는다 + return LegResult.failed(_contained_code(leg, exc)) + + +def exit_code_for(result: HookResult | None, strict: bool) -> int: + """훅 결과 → run 명령 종료 코드(D-L6-11). 기본 0, strict 이고 실패 다리가 있으면 3.""" + return EXIT_HOOK_FAILED if strict and result is not None and not result.ok else 0 + + +def run_hook( + cfg: CLIConfig, project: str, run_dir: Path, options: HookOptions +) -> HookResult | None: + """run 하나가 끝난 뒤의 훅. hub 대상이 아니면(standalone·`--no-ingest`) None 또는 건너뜀 결과.""" + run_id = Path(run_dir).name + try: + return _run_hook(cfg, project, Path(run_dir), run_id, options) + except Exception as exc: # 훅 바깥 겹(D-L6-2) — 콘솔에 알리고 결과로 돌려준다 + code = _contained_code(LEG_HOOK, exc) + console.echo_hook_crashed(code) + return HookResult(run_id, None, {LEG_HOOK: LegResult.failed(code)}) + + +def _run_hook( + cfg: CLIConfig, project: str, run_dir: Path, run_id: str, options: HookOptions +) -> HookResult | None: + if options.no_ingest: + if cfg.repo_root is not None: + console.echo_no_ingest(project, run_id, target.case_ref_hint(cfg, project)) + return None + resolved = target.resolve_target(cfg, project) + if isinstance(resolved, Skip): + if resolved.code == SKIP_STANDALONE: + return None + console.echo_skip(resolved, project) + legs = { + LEG_BUNDLE: LegResult.skipped(resolved.code), + LEG_HUB: LegResult.skipped(resolved.code), + } + return HookResult(run_id, None, legs, _now(), _now()) + if options.smoke: + # 돌 다리가 없다 — 잠그지 않고 상태 파일도 쓰지 않는다(D-L6-3 · D-L6-10, 리뷰 지적 1). + console.echo_smoke(resolved.case_ref) + smoke = {LEG_BUNDLE: LegResult.skipped(SMOKE_RUN), LEG_HUB: LegResult.skipped(SMOKE_RUN)} + return HookResult(run_id, resolved.case_ref, smoke, _now(), _now()) + result = _run_target(resolved, run_dir, run_id, options) + console.echo_summary(result, project) + return result + + +def _run_target(t: Target, run_dir: Path, run_id: str, options: HookOptions) -> HookResult: + """case 잠금 안에서 두 다리 → 상태 파일(D-L6-3). 잠금 한도를 넘기면 두 다리 모두 not_run.""" + started = _now() + with ExitStack() as stack: + try: + held = stack.enter_context( + lock.case_lock(t.account, t.case, timeout=HOOK_LOCK_TIMEOUT_SECONDS) + ) + except Exception as exc: # 잠금 파일을 만들 수 없다 — 다리를 돌리지 않는다 + legs = _lock_failed(_contained_code(LEG_LOCK, exc)) + else: + legs = ( + _legs_in_lock(t, run_dir, run_id, options) if held else _lock_failed(LOCK_TIMEOUT) + ) + result = HookResult(run_id, t.case_ref, legs, started, _now()) + _write_status(t, result, options) + return result + + +def _lock_failed(code: str) -> dict[str, LegResult]: + return { + LEG_LOCK: LegResult.failed(code), + LEG_BUNDLE: LegResult.not_run(), + LEG_HUB: LegResult.not_run(), + } + + +def _legs_in_lock( + t: Target, run_dir: Path, run_id: str, options: HookOptions +) -> dict[str, LegResult]: + bundle = _contained(LEG_BUNDLE, bundle_leg.run_bundle_leg, t, run_dir, run_id) + if options.mode is HookMode.BATCH_ITEM: + hub = LegResult.not_run(BATCH_PENDING) + elif not bundle_ready(bundle): + hub = LegResult.skipped(NO_BUNDLE) + else: + # `--expect-run` 은 번들의 run_id(run.json) — 디렉토리 이름과 다를 수 있다(리뷰 지적 3). 상태 파일 이름은 디렉토리 이름. + hub = gated_hub_leg(t, (hub_run_id(bundle, run_id),)) + return {LEG_BUNDLE: bundle, LEG_HUB: hub} + + +def gated_hub_leg(t: Target, run_ids: Sequence[str]) -> LegResult: + """hub 다리 게이트 한 곳 — opt-in(D-L6-4 · L10 D-L10-6)·워크스페이스(C-L6-9)를 본 뒤 `hub_leg.run_hub_leg`(예외 봉쇄). + + 호출자가 case 잠금을 쥐고 번들 유무를 이미 봤다고 가정한다. L6b `runs eval` 새 경로 ④ 도 이 함수를 부른다. + opt-in 해석 경고(설정 모양 오류·에이전트 세션에서 설정 무시)는 건너뛰기 전에 먼저 찍는다 — 값은 싣지 않는다. + """ + resolved = optin.resolve_hub_optin(config=t.hub_config) + for warning in resolved.warnings: + console.echo_warning(warning) + if not resolved.opted_in: + return LegResult.skipped(NOT_OPTED_IN) + if target.workspace_root() is None: + return LegResult.skipped(NO_WORKSPACE) + if resolved.confirm.warning is not None: + console.echo_warning(resolved.confirm.warning) + return _contained( + LEG_HUB, + hub_leg.run_hub_leg, + t, + tuple(run_ids), + confirm_local=resolved.confirm.confirm_local, + child_env=resolved.child_env, + secrets=resolved.secrets, + ) + + +def status_secrets(t: Target) -> tuple[str, ...]: + """상태 파일 거부 목록에 더할 설정 출처 비밀(§2.6 "비밀") — `runs eval` 발행도 같은 함수를 쓴다.""" + return optin.config_secrets(t.hub_config) + + +def _write_status(t: Target, result: HookResult, options: HookOptions) -> None: + try: + status.write_status( + t, result, options.command, strict=options.strict, secrets=status_secrets(t) + ) + except Exception as exc: # 상태 파일은 보조 기록 — 쓰기 실패는 경고로 강등(D-L6-2) + console.echo_status_write_failed(_contained_code(LEG_STATUS_FILE, exc)) + + +def finish_batch( + cfg: CLIConfig, + project: str, + results: Sequence[HookResult | None], + options: HookOptions, +) -> LegResult | None: + """batch 끝 hub 다리 한 번(D-L6-12) — 번들이 생긴 run 들을 `--expect-run` 으로, case 잠금 안에서. + + 끝나면 run 마다 상태 파일의 `hub` 를 `status.update_legs` 로 다시 쓴다 — 번들이 없어 끝 다리에 들지 못한 run 은 + `skipped:no_bundle` 로(상태 파일에 `batch_pending` 이 남지 않게). 끝 다리를 돌지 않았으면 None. + """ + try: + return _finish_batch(cfg, project, results, options) + except Exception as exc: # 훅 바깥 겹(D-L6-2) + code = _contained_code(LEG_HOOK, exc) + console.echo_hook_crashed(code) + return LegResult.failed(code) + + +def _finish_batch( + cfg: CLIConfig, + project: str, + results: Sequence[HookResult | None], + options: HookOptions, +) -> LegResult | None: + targeted = [r for r in results if r is not None and r.case is not None] + ready = [r for r in targeted if bundle_ready(r.legs.get(LEG_BUNDLE))] + # 번들이 없어 끝 다리에 들지 못한 run — 상태 파일의 `batch_pending` 을 `no_bundle` 로 닫는다(l6a-verify). + no_bundle = [r.run_id for r in targeted if r not in ready and _hub_pending(r)] + if options.no_ingest or not (ready or no_bundle): + return None + resolved = target.resolve_target(cfg, project) + if isinstance(resolved, Skip): + return None + # `--expect-run` 은 번들의 run_id(run.json), 상태 파일은 디렉토리 이름(리뷰 지적 3). + expect = [hub_run_id(r.legs.get(LEG_BUNDLE), r.run_id) for r in ready] + leg: LegResult | None = None + with lock.case_lock(resolved.account, resolved.case, timeout=HOOK_LOCK_TIMEOUT_SECONDS) as held: + if ready: + leg = gated_hub_leg(resolved, expect) if held else LegResult.failed(LOCK_TIMEOUT) + for result in ready: + _update_hub_leg(resolved, result.run_id, leg) + for run_id in no_bundle: + _update_hub_leg(resolved, run_id, LegResult.skipped(NO_BUNDLE)) + if leg is not None: + console.echo_batch_hub(leg, resolved.case_ref, expect) + return leg + + +def _hub_pending(result: HookResult) -> bool: + """batch 한 run 의 hub 다리가 끝 다리를 기다리는 중인가(`not_run:batch_pending`).""" + hub = result.legs.get(LEG_HUB) + return hub is not None and hub.code == BATCH_PENDING + + +def _update_hub_leg(t: Target, run_id: str, leg: LegResult) -> None: + try: + status.update_legs(t, run_id, {LEG_HUB: leg}, secrets=status_secrets(t)) + except Exception as exc: # 상태 파일 보조 기록 — 경고로 강등(D-L6-2) + console.echo_status_write_failed(_contained_code(LEG_STATUS_FILE, exc)) diff --git a/solar_eval/hub_hook/hub_leg.py b/solar_eval/hub_hook/hub_leg.py new file mode 100644 index 0000000000000000000000000000000000000000..e46701f57b0972a8fab9d539a79d3ca35e3be0e9 --- /dev/null +++ b/solar_eval/hub_hook/hub_leg.py @@ -0,0 +1,330 @@ +"""hub 다리 (계획 2026-09-27 L6 §2.6 · D-L6-4 · D-L6-5 · C-L6-1 · C-L6-3). + +- argv(`build_hub_argv`) = `["pnpm", "--silent", "--fail-if-no-match", "--filter", "@poc/eval-store", "hub", "ingest", + "--case", a/c, *("--expect-run", r …), "--repo", resolve(target.repo_root), "--mirror", blob_mirror_root()/<버킷>, + "--index", hub_index_root(), *(["--i-understand-local-data"] if confirm_local)]`. URL 은 argv 에 싣지 않는다 — 출처가 + 환경 변수면 부모 환경 그대로(`env=None`), 설정 파일 `hub.database_url` 이면 `child_env`(부모 환경 + `HUB_DATABASE_URL`)로 + **자식 환경으로만** 간다(계획 2026-09-28 L10 §2.6). 적재 하나뿐이다 — 이 모듈은 다른 hub 하위 명령을 짓지 않는다(`proc` 허용 + 목록이 한 번 더 막는다). +- 실행은 **`proc.run_pnpm`(모듈 속성으로)** 만 — `cwd=workspace_root()`(코드 체크아웃, C-L6-9), `timeout=HUB_INGEST_TIMEOUT_SECONDS`. + `FileNotFoundError` → `pnpm_missing`, `TimeoutExpired` → `timeout`. +- 해석(`judge_hub`, 순수 — §2.6 표): 0 → ok(stdout JSON `expect.ok`, `ingest_id`) · 1 → `hub_)">`(필터 불일치면 + `filter_no_match`) · 2 → `case_blocked:`·`case_failed:` · 3 → `schema_mismatch`(재구성하지 않는다, C-M2-3) · + 4 → `run_not_ok:`(요청 순서로 처음 ok 가 아닌 run) · 그 밖 → `hub_exit_` · JSON 이 깨졌거나 모양이 다르면 + `hub_output_unreadable`. +- detail 에는 **코드·id 만** 싣는다(`ingest_id`·run 별 상태). hub 출력 원문(stderr 문구·요약의 다른 키)은 옮기지 않는다 — + URL 이나 입력 조각이 섞여 있을 수 있다(D-L6-4). 외부 출력에서 뽑은 토큰은 `_TOKEN`(실패 stage 는 `_STAGE`) 문법으로 검사한다. +- 다리가 실패하면(시간 초과 포함) hub CLI stderr 의 마지막 `[hub]` 줄 몇 개를 **콘솔(stderr)에만** 옮긴다 — 비밀을 가리고 + 길이를 자른 뒤(`diagnostic_lines`). 코드만으로는 무엇이 잘못됐는지 알 수 없어서다(리뷰 지적 6). 상태 파일에는 싣지 않는다. + 가림은 환경 변수 값에 더해 설정 출처 값(`secrets` — `optin.HubOptIn.secrets`)도 본다. +- opt-in(`HUB_DATABASE_URL`·설정 `hub:` 절)·확인·번들 유무 게이트는 여기가 아니라 `hook.gated_hub_leg` 한 곳이다. + +`poc_bundle` 은 함수 안에서만 import 한다(D-L6-14). +""" + +from __future__ import annotations + +import json +import re +import subprocess +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import Any + +import click + +from solar_eval.hub_hook import console, proc, status, target +from solar_eval.hub_hook.optin import CONFIRM_FLAG +from solar_eval.hub_hook.types import ( + CASE_BLOCKED_PREFIX, + CASE_FAILED_PREFIX, + EXPECT_MISSING, + EXPECT_NO_USABLE_BUNDLE, + EXPECT_OK, + EXPECT_ORPHANED, + FILTER_NO_MATCH, + HUB_CASE_BLOCKED, + HUB_CASE_FAILED, + HUB_EXIT_CASE, + HUB_EXIT_EXPECT, + HUB_EXIT_OK, + HUB_EXIT_PREFIX, + HUB_EXIT_REFUSED, + HUB_EXIT_SCHEMA, + HUB_FAILED_PREFIX, + HUB_INGEST_TIMEOUT_SECONDS, + HUB_OUTPUT_UNREADABLE, + HUB_PACKAGE_FILTER, + NO_WORKSPACE, + PNPM_MISSING, + PNPM_NO_MATCH, + RUN_NOT_OK_PREFIX, + SCHEMA_MISMATCH, + STATUS_FAILED, + TIMEOUT, + LegResult, + Target, +) + +#: pnpm 전역 옵션 — 출력은 스크립트 것만, 필터 불일치는 종료 1(pnpm 10.30.1 사실, §2.6). +PNPM_PREFIX = ("pnpm", "--silent", "--fail-if-no-match", "--filter", HUB_PACKAGE_FILTER) +HUB_INGEST = ("hub", "ingest") +#: hub CLI stderr 오류 줄 `[hub] 오류(): …` 의 코드. +_HUB_ERROR_LINE = re.compile(r"^\[hub\] 오류\(([a-z][a-z0-9_]{0,63})\)", re.MULTILINE) +#: 외부 출력에서 뽑아 코드에 싣는 토큰(case 차단 코드·ingest_id)의 문법. +_TOKEN = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}") +#: 실패 stage — ES `orchestrate.ts` 가 내는 단계 이름(`datasets`·`run-bundles`·`digest`·`case_lock`·`unknown`)과 +#: preflight 실패의 `preflight:<단계>`(리뷰 지적 5). `:` 은 한 번만 받는다. +_STAGE = re.compile(r"[a-z][a-z0-9_-]{0,63}(?::[a-z][a-z0-9_-]{0,63})?") +#: 실패한 hub 다리에서 콘솔(stderr)에 옮기는 hub CLI stderr 꼬리 — 줄 수와 한 줄 길이 상한(리뷰 지적 6). +HUB_DIAG_MAX_LINES = 5 +HUB_DIAG_MAX_CHARS = 300 +_HUB_LOG_PREFIX = "[hub]" +#: `--expect-run` 값 — 옵션으로 오인되지 않게 `-` 로 시작하지 않고 제어 문자가 없다. +_CONTROL = re.compile(r"[\x00-\x1f\x7f-\x9f]") +EXPECT_STATES = frozenset({EXPECT_OK, EXPECT_MISSING, EXPECT_NO_USABLE_BUNDLE, EXPECT_ORPHANED}) + + +class _Unreadable(ValueError): + """hub 출력이 기대한 모양이 아니다 — `hub_output_unreadable` 로 바뀐다(원문은 싣지 않는다).""" + + +# ── argv(순수) ─────────────────────────────────────────────────────────── + + +def build_hub_argv( + target: Target, + run_ids: Sequence[str], + *, + confirm_local: bool, + env: Mapping[str, str] | None = None, +) -> list[str]: + """hub 적재 argv(순수 — 경로는 `poc_bundle.home` 해석값). run_id 가 비었거나 옵션 모양이면 `ValueError`.""" + from poc_bundle.home import blob_mirror_root, hub_index_root + from poc_bundle.mirror import DEFAULT_BUCKET + + expect: list[str] = [] + for run_id in _checked_run_ids(run_ids): + expect += ["--expect-run", run_id] + return [ + *PNPM_PREFIX, + *HUB_INGEST, + "--case", + target.case_ref, + *expect, + "--repo", + # 절대 경로로 — 상대면 자식(pnpm 스크립트 cwd = ES 패키지 디렉토리)이 다른 곳으로 푼다(D-L6-5, 리뷰 지적 2). + str(Path(target.repo_root).expanduser().resolve()), + "--mirror", + str(blob_mirror_root(env) / DEFAULT_BUCKET), + "--index", + str(hub_index_root(env)), + *([CONFIRM_FLAG] if confirm_local else []), + ] + + +def _checked_run_ids(run_ids: Sequence[str]) -> list[str]: + ids = list(run_ids) + if not ids: + raise ValueError("hub leg needs at least one run_id") + for run_id in ids: + if not isinstance(run_id, str) or not run_id or run_id.startswith("-"): + raise ValueError("hub leg run_id must be a non-empty string not starting with '-'") + if _CONTROL.search(run_id): + raise ValueError("hub leg run_id has a control character") + return ids + + +# ── 판정(순수, §2.6 표) ────────────────────────────────────────────────── + + +def judge_hub(result: subprocess.CompletedProcess[str], run_ids: Sequence[str]) -> LegResult: + """hub CLI 종료 코드·출력 → `LegResult`. 출력 원문은 싣지 않는다.""" + code = result.returncode + stdout, stderr = result.stdout or "", result.stderr or "" + if code != HUB_EXIT_OK and PNPM_NO_MATCH in f"{stdout}\n{stderr}": + return LegResult.failed(FILTER_NO_MATCH) + try: + if code == HUB_EXIT_OK: + return _judge_ok(stdout, run_ids) + if code == HUB_EXIT_REFUSED: + return LegResult.failed(_refusal_code(stderr)) + if code == HUB_EXIT_CASE: + return _judge_case(stdout, run_ids) + if code == HUB_EXIT_SCHEMA: + return LegResult.failed(SCHEMA_MISMATCH) + if code == HUB_EXIT_EXPECT: + return _judge_expect_failed(stdout, run_ids) + except _Unreadable: + return LegResult.failed(HUB_OUTPUT_UNREADABLE) + return LegResult.failed(f"{HUB_EXIT_PREFIX}{code}") + + +def _judge_ok(stdout: str, run_ids: Sequence[str]) -> LegResult: + doc = _summary(stdout) + states = _expect_states(doc, run_ids) + if any(state != EXPECT_OK for state in states.values()): + raise _Unreadable("exit 0 with a run that is not ok") + return LegResult.ok({"ingest_id": _token(doc.get("ingest_id")), "expect": states}) + + +def _refusal_code(stderr: str) -> str: + match = _HUB_ERROR_LINE.search(stderr) + return ( + f"{HUB_FAILED_PREFIX}{match.group(1)}" if match else f"{HUB_EXIT_PREFIX}{HUB_EXIT_REFUSED}" + ) + + +def _judge_case(stdout: str, run_ids: Sequence[str]) -> LegResult: + doc = _summary(stdout) + cases = doc.get("cases") + if not isinstance(cases, Mapping) or len(cases) != 1: + raise _Unreadable("exit 2 needs exactly one case summary") + [case] = cases.values() + if not isinstance(case, Mapping): + raise _Unreadable("case summary is not an object") + detail = {"expect": _expect_states(doc, run_ids)} if "expect" in doc else {} + blocked, failed = case.get("blocked"), case.get("failed") + if case.get("status") == HUB_CASE_BLOCKED and isinstance(blocked, Mapping): + code = f"{CASE_BLOCKED_PREFIX}{_token(blocked.get('code'))}" + elif case.get("status") == HUB_CASE_FAILED and isinstance(failed, Mapping): + code = f"{CASE_FAILED_PREFIX}{_token(failed.get('stage'), _STAGE)}" + else: + raise _Unreadable("exit 2 without a blocked or failed case") + return LegResult.failed(code, detail=detail) + + +def _judge_expect_failed(stdout: str, run_ids: Sequence[str]) -> LegResult: + states = _expect_states(_summary(stdout), run_ids) + bad = next((state for state in states.values() if state != EXPECT_OK), None) + if bad is None: + raise _Unreadable("exit 4 with every run ok") + return LegResult.failed(f"{RUN_NOT_OK_PREFIX}{bad}", detail={"expect": states}) + + +def _summary(stdout: str) -> Mapping[str, Any]: + """stdout 의 요약 JSON. 앞에 다른 줄이 섞였으면 `{` 로 시작하는 첫 줄부터 읽는다.""" + lines = stdout.splitlines(keepends=True) + start = next((i for i, line in enumerate(lines) if line.startswith("{")), None) + if start is None: + raise _Unreadable("no JSON object on stdout") + try: + doc = json.loads("".join(lines[start:])) + except json.JSONDecodeError as exc: + raise _Unreadable("summary is not JSON") from exc + if not isinstance(doc, Mapping): + raise _Unreadable("summary is not an object") + return doc + + +def _expect_states(doc: Mapping[str, Any], run_ids: Sequence[str]) -> dict[str, str]: + """요청 순서대로 run → 상태. 요청한 run 이 없거나 모르는 상태면 읽을 수 없다.""" + expect = doc.get("expect") + runs = expect.get("runs") if isinstance(expect, Mapping) else None + if not isinstance(runs, Mapping): + raise _Unreadable("summary has no expect block") + states: dict[str, str] = {} + for run_id in run_ids: + entry = runs.get(run_id) + state = entry.get("state") if isinstance(entry, Mapping) else None + if state not in EXPECT_STATES: + raise _Unreadable("expect block misses a requested run") + states[run_id] = state + return states + + +def _token(value: Any, grammar: re.Pattern[str] = _TOKEN) -> str: + if not isinstance(value, str) or grammar.fullmatch(value) is None: + raise _Unreadable("summary token has an unexpected shape") + return value + + +# ── 실행 ───────────────────────────────────────────────────────────────── + + +def run_hub_leg( + target: Target, + run_ids: Sequence[str], + *, + confirm_local: bool, + child_env: Mapping[str, str] | None = None, + secrets: Sequence[str] = (), +) -> LegResult: + """`hub ingest --case a/c --expect-run …` 한 번. 예외는 hook 이 `hub_exception:` 으로 강등한다. + + `child_env` 는 설정 출처 opt-in 일 때만(부모 환경 + URL), 아니면 None(부모 환경 그대로). `secrets` 는 진단 가림 목록. + """ + ids = _checked_run_ids(run_ids) + argv = build_hub_argv(target, ids, confirm_local=confirm_local, env=child_env) + cwd = _workspace() + if cwd is None: + return LegResult.skipped(NO_WORKSPACE) + try: + result = proc.run_pnpm(argv, cwd=cwd, env=child_env, timeout=HUB_INGEST_TIMEOUT_SECONDS) + except FileNotFoundError: + return LegResult.failed(PNPM_MISSING) + except subprocess.TimeoutExpired as exc: + _echo_diagnostics(exc.stderr, secrets) + return LegResult.failed(TIMEOUT) + leg = _with_run_hint(judge_hub(result, ids), target) + if leg.status == STATUS_FAILED: + _echo_diagnostics(result.stderr, secrets) + return leg + + +def diagnostic_lines(stderr: str | bytes | None, secrets: Sequence[str] = ()) -> list[str]: + """hub CLI stderr → 콘솔에 옮길 꼬리(리뷰 지적 6). `[hub]` 줄이 있으면 그 마지막 몇 줄, 없으면 비지 않은 마지막 몇 줄. + + 줄마다 비밀을 가린 뒤(`status.redact_secrets` — 자르기 전에 가려야 URL 이 중간에 잘려 비밀번호가 새지 않는다) 길이를 자른다. + `secrets` 는 환경 변수 밖의 비밀(설정 출처 URL 값). 상태 파일·detail 에는 싣지 않는다(D-L6-4 — 코드만). + """ + if isinstance(stderr, bytes): + stderr = stderr.decode("utf-8", errors="replace") + lines = [line.rstrip() for line in (stderr or "").splitlines() if line.strip()] + hub_lines = [line for line in lines if line.startswith(_HUB_LOG_PREFIX)] + tail = (hub_lines or lines)[-HUB_DIAG_MAX_LINES:] + return [_clip(status.redact_secrets(line, secrets)) for line in tail] + + +def _clip(line: str) -> str: + return line if len(line) <= HUB_DIAG_MAX_CHARS else f"{line[:HUB_DIAG_MAX_CHARS]}…" + + +def _echo_diagnostics(stderr: str | bytes | None, secrets: Sequence[str] = ()) -> None: + lines = diagnostic_lines(stderr, secrets) + if not lines: + return + click.secho(f" hub CLI stderr 마지막 {len(lines)}줄(비밀은 가림):", fg="red", err=True) + for line in lines: + click.secho(f" {line}", fg="red", err=True) + + +def _workspace(): + # 모듈 속성으로 부른다 — 시험이 `target.workspace_root` 를 바꿀 수 있게. + return target.workspace_root() + + +def _with_run_hint(leg: LegResult, t: Target) -> LegResult: + """`run_not_ok` 면 되살리기에 ok 가 아닌 첫 run 을 적는다(batch 에서 콘솔이 `` 으로 쓰지 않게).""" + if not leg.code.startswith(RUN_NOT_OK_PREFIX): + return leg + states = leg.detail.get("expect") or {} + bad = next((run for run, state in states.items() if state != EXPECT_OK), None) + if bad is None: + return leg + hint = console.hub_recovery(leg.code, t.case_ref, bad) + return LegResult.failed(leg.code, hint, leg.detail) + + +def ingest_runs( + target: Target, + run_ids: Sequence[str], + *, + confirm_local: bool, + child_env: Mapping[str, str] | None = None, + secrets: Sequence[str] = (), +) -> LegResult: + """L6b `runs eval` 새 경로 ④ 가 부르는 적재 — 판정·실행은 `run_hub_leg` 와 같다(게이트는 `hook.gated_hub_leg`).""" + return run_hub_leg( + target, run_ids, confirm_local=confirm_local, child_env=child_env, secrets=secrets + ) diff --git a/solar_eval/hub_hook/indexes.py b/solar_eval/hub_hook/indexes.py new file mode 100644 index 0000000000000000000000000000000000000000..54e1e36502deec23b37cb5f190f27d13f0c07f37 --- /dev/null +++ b/solar_eval/hub_hook/indexes.py @@ -0,0 +1,355 @@ +"""데이터셋·TagSet·정책 색인 갱신 (계획 2026-09-27 L6 §2.5 · D-L6-7 · 검토 F2·F9). + +**키.** `sha256(JCS([lock_sha, template_sha, text_mode, INDEX_EXTRACTOR, index_code_sha]))` — `lock_sha` = lock 파일 바이트 sha256 +(없으면 null), `template_sha` = 정책 템플릿 바이트 sha256(없으면 null), `text_mode` = `optin.text_mode()`, `INDEX_EXTRACTOR` = +`poc_bundle.index.row_tags.EXTRACTOR`, `index_code_sha` = **`poc_bundle` 패키지 전체**의 `*.py` (상대 경로, 길이, 바이트)를 +경로순으로 이은 sha256. 색인 출력은 `index/` 밖 모듈(`keys`·`scoring.policy`·`scoring.ids`·`snapshot`·`lock_match`·`canonical` …) +에도 달려 있어 `index/*.py` 만 보면 그쪽 변경을 놓친다(리뷰 지적 3) — 넓게 잡아 PB 가 바뀌면 한 번 더 만드는 쪽을 택했다 +(`poc_bundle.__version__` 은 고정값이라 코드 변화를 못 잡는다). 키 파일은 `///hook/index-key.json`. + +**판정(순서대로).** + +1. lock 이 없으면(`load_existing_lock(layout) is None`) 세 명령 모두 건너뛴다 → `skipped:no_lock`(실패 아님, D-L6-11). + click 예외 문구는 파싱하지 않는다 — CLI 를 부르기 **전에** 가른다. 레이아웃·lock 을 읽지 못하면(깨진 lock 등) 가르지 않고 + CLI 를 불러 그쪽이 실패를 보고하게 둔다. +2. 정책 템플릿이 없으면 `tags`·`policy` 는 건너뛰고 `datasets` 만 — 결과 `written:datasets_only`. +3. 키가 같고 색인 루트의 그 case 디렉토리(돌 명령의 것 — `datasets/`, 템플릿이 있으면 `tagsets/`·`policies/` 도)가 모두 있으면 + `unchanged`. 아니면 **옛 키를 먼저 지우고** 명령을 차례로 부른 뒤, 건너뛰지 않은 명령이 모두 성공했을 때만 새 키를 쓴다 + (원자적 쓰기). 옛 키를 남겨 두면 명령이 중간에 실패했을 때(datasets 는 새 입력으로 다시 쓰고 옛 파일을 지웠는데 tags 가 + 실패) 입력이 옛 값으로 돌아오는 순간 `unchanged` 로 오판해 섞인 사이드카가 영영 남는다(리뷰 지적 1). 중간의 + KeyboardInterrupt 도 같은 길로 막힌다. + +**남은 옛 출력 치우기(리뷰 지적 2).** 건너뛴 명령이 예전에 쓴 디렉토리는 hub 가 그대로 읽는다(`ES/src/hub/stages/tagsets.ts` +`loadPolicies` — 없는 디렉토리는 빈 목록이다). 그래서 lock 이 없으면 `datasets/`·`tagsets/`·`policies/`(와 키 파일)를, 템플릿만 +없으면 `tagsets/`·`policies/` 를 색인 루트의 그 case 아래에서 지운다 — lock·템플릿에서만 나오는 파생물이다. 번들 색인 +(`bundles/`)은 건드리지 않는다. 지운 이름은 `IndexOutcome.removed` 와 stderr 한 줄로 알린다. 레이아웃·lock 을 못 읽어 세 명령을 +모두 부르는 경우에는 지우지 않는다(없다고 확정하지 못했다). + +**호출.** `poc_bundle.index.cli.index_group.main([...], prog_name="poc-bundle index", standalone_mode=False)` — 아카이브 루트 +(`--projects-dir target.projects_dir`)·색인 루트(`--index-root hub_index_root()`)·텍스트 모드(`--text`)를 **명시**한다. stdout· +stderr 는 버퍼에 받는다(경로·건수뿐이라 훅 콘솔은 다리마다 한 줄이다). 다만 stderr 의 **`warning:` 줄은 버리지 않는다**(리뷰 +지적 4) — `index datasets` 의 "같은 dataset_version 을 나눠 가진 lock 항목"(hub 는 그중 하나만 싣는다)·`index policy` 의 "관측 0 인 +선언 태그" 같은 정합성 경고다. 명령 이름을 붙여 stderr 에 다시 내고 `IndexOutcome.warnings` 에 싣는다(번들 다리는 detail 에 +건수만 둔다 — 상태 파일은 코드·건수만). 실패는 `failed:index_<명령>:<종료 코드>` 이고 사람용 이유는 click 오류 문구(경로·코드만 +— `index/cli.py` 모듈 설명 "행 본문은 싣지 않는다")의 마지막 줄이다. + +돌려주는 값: `unchanged` · `written` · `written:datasets_only` · `skipped:no_lock` · `failed:index_<명령>:`. + +`poc_bundle` 은 함수 안에서만 import 한다(D-L6-14). +""" + +from __future__ import annotations + +import contextlib +import hashlib +import io +import json +import os +from collections.abc import Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import click + +from solar_eval.hub_hook import optin +from solar_eval.hub_hook.types import ( + HOOK_DIRNAME, + INDEX_CMD_DATASETS, + INDEX_CMD_POLICY, + INDEX_CMD_TAGS, + INDEX_FAILED_PREFIX, + INDEX_KEY_FILENAME, + INDEX_UNCHANGED, + INDEX_WRITTEN, + INDEX_WRITTEN_DATASETS_ONLY, + NO_LOCK, + STATUS_FAILED, + STATUS_SKIPPED, + Target, +) + +#: 색인 명령과 그 명령이 쓰는 색인 루트 아래 디렉토리(`/accounts///`). +CMD_DATASETS, CMD_TAGS, CMD_POLICY = INDEX_CMD_DATASETS, INDEX_CMD_TAGS, INDEX_CMD_POLICY +OUTPUT_DIRS = {CMD_DATASETS: "datasets", CMD_TAGS: "tagsets", CMD_POLICY: "policies"} +PROG_NAME = "poc-bundle index" +#: 키 파일 스키마 — 훅 내부 기록(사람·다른 도구가 읽지 않는다). +INDEX_KEY_SCHEMA = "solar-eval-hook-index-key/v1" +#: 색인 CLI stderr 에서 정합성 경고로 보는 줄 머리(`poc_bundle/index/cli.py` 의 `warning: …`). +WARNING_PREFIX = "warning:" + + +@dataclass(frozen=True) +class IndexOutcome: + """`refresh` 결과 — `code` 는 모듈 설명의 반환 어휘, `reason` 은 실패일 때만 사람용 한 줄. + + `warnings` 는 성공한 명령의 stderr `warning:` 줄(`index <명령>: ` 접두), `removed` 는 이번에 치운 옛 출력 디렉토리 이름. + """ + + code: str + reason: str | None = None + warnings: tuple[str, ...] = () + removed: tuple[str, ...] = () + + @property + def failed(self) -> bool: + return self.code.startswith(f"{STATUS_FAILED}:") + + +@dataclass(frozen=True) +class IndexCall: + """색인 명령 한 번의 결과 — 종료 코드, 실패면 click 문구 마지막 줄, stderr 의 `warning:` 줄.""" + + exit_code: int + reason: str | None = None + warnings: tuple[str, ...] = () + + +@dataclass(frozen=True) +class _Plan: + commands: tuple[str, ...] + key: str | None + parts: dict[str, Any] | None + + +def refresh_case_indexes(target: Target) -> str: + """case 하나의 색인을 키가 바뀌었을 때만 다시 만든다(D-L6-7). 반환 어휘는 모듈 설명.""" + return refresh(target).code + + +def refresh(target: Target) -> IndexOutcome: + """`refresh_case_indexes` 와 같고 실패 이유(사람용 한 줄)를 함께 돌려준다.""" + plan = _plan(target) + root = _index_root() + removed = _remove_stale_outputs(target, plan, root) + if not plan.commands: + # lock 이 없다 — 키도 이제 가리킬 색인이 없다(lock 이 돌아오면 디렉토리가 없어 다시 만든다) + key_path(target).unlink(missing_ok=True) + return _announce(target, IndexOutcome(f"{STATUS_SKIPPED}:{NO_LOCK}", removed=removed)) + if plan.key is not None and _unchanged(target, plan, root): + return _announce(target, IndexOutcome(INDEX_UNCHANGED, removed=removed)) + # 옛 키를 먼저 지운다 — 명령이 중간에 실패하면 사이드카가 섞인 채 옛 키가 남아 `unchanged` 로 오판한다(리뷰 지적 1) + key_path(target).unlink(missing_ok=True) + from poc_bundle.index.case import index_lock_derived + + warnings: list[str] = [] + for command, call in index_lock_derived( + target.case_ref, + root, + repo_root=target.repo_root, + commands=plan.commands, + text_mode=optin.text_mode(), + projects_dir=target.projects_dir, + invoke=invoke_index, + ): + warnings += [f"index {command}: {line}" for line in call.warnings] + if call.exit_code != 0: + code = f"{STATUS_FAILED}:{INDEX_FAILED_PREFIX}{command}:{call.exit_code}" + return _announce(target, IndexOutcome(code, call.reason, tuple(warnings), removed)) + if plan.key is not None and plan.parts is not None: + _write_key(target, plan.key, plan.parts) + code = INDEX_WRITTEN if CMD_POLICY in plan.commands else INDEX_WRITTEN_DATASETS_ONLY + return _announce(target, IndexOutcome(code, warnings=tuple(warnings), removed=removed)) + + +def _announce(target: Target, outcome: IndexOutcome) -> IndexOutcome: + """치운 디렉토리·색인 경고를 stderr 에 한 줄씩(경로·태그 이름·건수뿐 — 행 본문은 CLI 가 싣지 않는다).""" + if outcome.removed: + names = "·".join(f"{name}/" for name in outcome.removed) + click.secho( + f" 색인({target.case_ref}): lock·정책 템플릿이 없어 옛 {names} 를 치웠다", + fg="yellow", + err=True, + ) + for line in outcome.warnings: + click.secho(f" 색인 경고({target.case_ref}) — {line}", fg="yellow", err=True) + return outcome + + +def _remove_stale_outputs(target: Target, plan: _Plan, root: Path) -> tuple[str, ...]: + """건너뛴 명령이 예전에 쓴 출력 디렉토리를 지운다(리뷰 지적 2). 지운 디렉토리 이름을 돌려준다. + + lock·템플릿이 없다고 **확정**했을 때만(`plan.key` 가 있거나 명령이 0) — 못 읽어 세 명령을 모두 부르는 경우는 치울 것이 없다. + 심볼릭 링크는 링크만 지운다(가리키는 곳은 건드리지 않는다). + """ + from poc_bundle.index.case import prune_skipped_lock_indexes + + return prune_skipped_lock_indexes(root, target.account, target.case, plan.commands) + + +# ── 선행 판정·키 ───────────────────────────────────────────────────────── + + +def _plan(target: Target) -> _Plan: + """돌 명령과 키. lock 이 없으면 명령 0. 레이아웃·lock 을 못 읽으면 키 없이 세 명령(CLI 가 실패를 보고).""" + from poc_bundle.data.data_root import build_case_layout, load_existing_lock + from poc_bundle.data.errors import DataError + + try: + layout = build_case_layout( + target.repo_root, target.case_ref, projects_dir=target.projects_dir + ) + found = load_existing_lock(layout) + except (DataError, OSError): + return _Plan((CMD_DATASETS, CMD_TAGS, CMD_POLICY), None, None) + if found is None: + return _Plan((), None, None) + template = _template_bytes(target) + commands = (CMD_DATASETS,) if template is None else (CMD_DATASETS, CMD_TAGS, CMD_POLICY) + parts = _key_parts(found[1], template) + return _Plan(commands, _key_of(parts), parts) + + +def _template_bytes(target: Target) -> bytes | None: + """정책 템플릿(`index policy` 기본 위치) 바이트. 없으면 None. 있는데 못 읽으면 `OSError` 를 올린다(번들 다리가 보고).""" + from poc_bundle.index.cli import TEMPLATE_DEFAULT + + path = target.repo_root / "accounts" / target.account / target.case / TEMPLATE_DEFAULT + if not path.exists() and not path.is_symlink(): + return None + return path.read_bytes() + + +def _key_parts(lock_bytes: bytes, template: bytes | None) -> dict[str, Any]: + from poc_bundle.index.row_tags import EXTRACTOR + + return { + "lock_sha": _sha256(lock_bytes), + "template_sha": None if template is None else _sha256(template), + "text_mode": optin.text_mode(), + "extractor": EXTRACTOR, + "index_code_sha": index_code_sha(), + } + + +def _key_of(parts: dict[str, Any]) -> str: + from poc_bundle.canonical import jcs_bytes + + ordered = [ + parts["lock_sha"], + parts["template_sha"], + parts["text_mode"], + parts["extractor"], + parts["index_code_sha"], + ] + return _sha256(jcs_bytes(ordered)) + + +def index_code_sha(package_root: Path | None = None) -> str: + """`poc_bundle` 패키지 전체 `*.py` 의 (상대 경로, 길이, 바이트)를 경로순으로 이은 sha256(검토 F9 · 리뷰 지적 3). + + 색인 출력은 `index/` 밖 PB 모듈(`keys.dataset_version` 등)에도 달려 있다 — 그래서 `index/` 만이 아니라 패키지 전체를 본다. + `package_root` 는 시험용(기본은 설치된 `poc_bundle` 패키지 디렉토리). 경로를 섞어 이름 바꾸기·옮기기도 잡는다. + """ + if package_root is None: + import poc_bundle + + package_root = Path(poc_bundle.__file__).parent + digest = hashlib.sha256() + sources = sorted( + (p.relative_to(package_root).as_posix(), p) for p in package_root.rglob("*.py") + ) + for rel, path in sources: + data = path.read_bytes() + digest.update(f"{rel}\0{len(data)}\0".encode()) + digest.update(data) + return digest.hexdigest() + + +def _sha256(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def key_path(target: Target) -> Path: + """`///hook/index-key.json`.""" + from poc_bundle.home import hub_reports_dir + + return hub_reports_dir() / target.account / target.case / HOOK_DIRNAME / INDEX_KEY_FILENAME + + +def _stored_key(target: Target) -> str | None: + """저장된 키. 없거나 깨졌으면 None — 다시 만든다(키 파일은 캐시일 뿐이다).""" + try: + doc = json.loads(key_path(target).read_text("utf-8")) + except (OSError, ValueError): + return None + key = doc.get("key") if isinstance(doc, dict) else None + return key if isinstance(key, str) else None + + +def _write_key(target: Target, key: str, parts: dict[str, Any]) -> None: + """임시 파일 → `os.replace`(원자적). 실패는 호출자에게 올린다 — 조용히 삼키면 매번 다시 만든다는 사실이 숨는다.""" + path = key_path(target) + path.parent.mkdir(parents=True, exist_ok=True) + doc = {"schema": INDEX_KEY_SCHEMA, "key": key, "parts": parts} + tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp") + tmp.write_text(json.dumps(doc, ensure_ascii=False, sort_keys=True) + "\n", "utf-8") + os.replace(tmp, path) + + +def _index_root() -> Path: + from poc_bundle.home import hub_index_root + + return hub_index_root() + + +def _unchanged(target: Target, plan: _Plan, root: Path) -> bool: + if _stored_key(target) != plan.key: + return False + case_dir = root / "accounts" / target.account / target.case + return all((case_dir / OUTPUT_DIRS[c]).is_dir() for c in plan.commands) + + +# ── click 호출 ─────────────────────────────────────────────────────────── + + +def command_args(target: Target, command: str, root: Path) -> list[str]: + """색인 명령 하나의 인자 — 루트는 모두 명시한다(CLI 기본값을 거치지 않는다).""" + from poc_bundle.index.case import lock_index_args + + return lock_index_args( + command, + target.case_ref, + root, + text_mode=optin.text_mode(), + projects_dir=target.projects_dir, + ) + + +def invoke_index(args: Sequence[str]) -> IndexCall: + """`poc-bundle index ` 를 `standalone_mode=False` 로 — 종료 코드, 실패면 click 문구 마지막 줄, stderr 경고 줄.""" + from poc_bundle.index.cli import index_group + + sink_out, sink_err = io.StringIO(), io.StringIO() + try: + with contextlib.redirect_stdout(sink_out), contextlib.redirect_stderr(sink_err): + rv = index_group.main(list(args), prog_name=PROG_NAME, standalone_mode=False) + except click.ClickException as exc: + return _call(exc.exit_code, sink_err, _last_line(exc.format_message())) + except click.exceptions.Exit as exc: + return _call(exc.exit_code, sink_err) + except click.Abort: + return _call(1, sink_err, "aborted") + except SystemExit as exc: + return _call(exc.code if isinstance(exc.code, int) else 1, sink_err) + return _call(rv if isinstance(rv, int) and not isinstance(rv, bool) else 0, sink_err) + + +def _call(exit_code: int, sink_err: io.StringIO, reason: str | None = None) -> IndexCall: + """버퍼에서 경고 줄을 뽑아 결과를 만든다. 실패인데 이유가 없으면 stderr 마지막 줄을 이유로.""" + text = sink_err.getvalue() + if exit_code != 0 and reason is None: + reason = _last_line(text) + return IndexCall(exit_code, None if exit_code == 0 else reason, warning_lines(text)) + + +def warning_lines(text: str) -> tuple[str, ...]: + """stderr 버퍼에서 `warning:` 로 시작하는 줄(앞뒤 공백 제거)만.""" + return tuple( + line.strip() for line in text.splitlines() if line.strip().startswith(WARNING_PREFIX) + ) + + +def _last_line(text: str) -> str | None: + lines = [line.strip() for line in text.splitlines() if line.strip()] + return lines[-1] if lines else None diff --git a/solar_eval/hub_hook/lock.py b/solar_eval/hub_hook/lock.py new file mode 100644 index 0000000000000000000000000000000000000000..6e6271293827d1071c2a2af2fad10a6f9dc78841 --- /dev/null +++ b/solar_eval/hub_hook/lock.py @@ -0,0 +1,102 @@ +"""case 잠금 파일 (계획 2026-09-27 L6 §2.5 · D-L6-3 · 검토 F8). + +`///hook.lock` 에 `fcntl.flock(LOCK_EX)` — `LOCK_NB` 로 `LOCK_POLL_SECONDS` 마다 다시 시도하고, 한도 +`timeout`(기본 `HOOK_LOCK_TIMEOUT_SECONDS` 1200 s — hub 다리 한도 900 s + 번들 다리 여유)을 넘기면 잡지 못한 채 `False` 를 내준다 +(호출자가 `lock_timeout` 으로 보고한다). 대기 중 `LOCK_NOTICE_SECONDS` 마다 "같은 case 의 다른 훅을 기다리는 중… N 초째" 한 줄을 +stderr 에 낸다. + +- 잠금 파일 부모는 `mkdir(parents=True)` 로 만들고, 파일은 **지우지 않는다** — 지우면 새 파일에 잠금을 거는 쪽과 옛 파일에 거는 + 쪽이 동시에 "잡았다" 고 믿는 경쟁이 생긴다. +- `flock` 은 열린 파일 기술자에 걸린다 — 프로세스가 죽으면 커널이 푼다(잔여 잠금 없음). 같은 프로세스라도 따로 연 기술자끼리는 + 서로 막는다. 데이터 홈은 로컬 디스크 전제다(NFS 에서는 `flock` 이 의미가 없다 — 계획 R-L6-11). +- hub 대상 run 의 두 다리 전부와 batch 끝 hub 다리(`hook.finish_batch`)를 감싼다. L6b `runs eval` 새 경로도 ①~⑤ 전부를 이 + 잠금 안에서 돈다(계획 §2.12 B4) — 그 경로가 부르는 `bundle_leg.ensure_run_bundle` 은 스스로 잠그지 않고 호출자가 쥔 잠금을 + 전제한다(같은 프로세스에서 다시 잡으면 위 규칙대로 자기 자신을 기다린다). 잠금 파일에는 아무것도 쓰지 않는다. + +`poc_bundle` 은 함수 안에서만 import 한다(D-L6-14). +""" + +from __future__ import annotations + +import fcntl +import os +import time +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path + +import click + +from solar_eval.hub_hook.types import ( + HOOK_LOCK_TIMEOUT_SECONDS, + LOCK_FILENAME, + LOCK_NOTICE_SECONDS, + LOCK_POLL_SECONDS, +) + +#: 잠금 파일 권한(내용은 비어 있다 — 권한은 데이터 홈의 다른 파일과 같게). +LOCK_FILE_MODE = 0o644 + + +def lock_path(account: str, case: str) -> Path: + """잠금 파일 위치 `///hook.lock`. 슬러그가 틀리면 `DataConfigError`(경로 조각으로 쓰기 전에 막는다).""" + from poc_bundle.data.case_ref import parse_case_ref + from poc_bundle.home import hub_reports_dir + + account, case = parse_case_ref(f"{account}/{case}") + return hub_reports_dir() / account / case / LOCK_FILENAME + + +@contextmanager +def case_lock( + account: str, case: str, *, timeout: float = HOOK_LOCK_TIMEOUT_SECONDS +) -> Iterator[bool]: + """case 하나의 배타 잠금. 한도 안에 잡으면 `True`, 넘기면 `False`(호출자가 `lock_timeout` 으로 보고). + + Raises: + ValueError: `timeout` 이 음수일 때. + DataConfigError: case 슬러그가 틀렸을 때(`poc_bundle.data.errors`). + OSError: 잠금 파일을 만들거나 열 수 없을 때(hook 이 `lock_exception:` 으로 강등한다). + """ + if timeout < 0: + raise ValueError(f"lock timeout must be >= 0, got {timeout}") + path = lock_path(account, case) + path.parent.mkdir(parents=True, exist_ok=True) + fd = os.open(path, os.O_RDWR | os.O_CREAT, LOCK_FILE_MODE) + try: + held = _acquire(fd, timeout, f"{account}/{case}") + try: + yield held + finally: + if held: + fcntl.flock(fd, fcntl.LOCK_UN) + finally: + os.close(fd) + + +def _acquire(fd: int, timeout: float, case_ref: str) -> bool: + """`LOCK_NB` 폴링으로 잠근다. 한도 안에 잡으면 True — 한도 0 이면 한 번만 시도한다.""" + started = time.monotonic() + next_notice = LOCK_NOTICE_SECONDS + while True: + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + elapsed = time.monotonic() - started + else: + return True + if elapsed >= timeout: + return False + if elapsed >= next_notice: + _echo_waiting(case_ref, elapsed) + next_notice += LOCK_NOTICE_SECONDS + time.sleep(min(LOCK_POLL_SECONDS, max(timeout - elapsed, 0.0))) + + +def _echo_waiting(case_ref: str, elapsed: float) -> None: + """대기 안내 한 줄(stderr) — case 이름과 경과 초만.""" + click.secho( + f" 같은 case({case_ref})의 다른 훅을 기다리는 중… {int(elapsed)} 초째", + fg="yellow", + err=True, + ) diff --git a/solar_eval/hub_hook/optin.py b/solar_eval/hub_hook/optin.py new file mode 100644 index 0000000000000000000000000000000000000000..0d1148c60a002f6735518521e912a486dba129a3 --- /dev/null +++ b/solar_eval/hub_hook/optin.py @@ -0,0 +1,193 @@ +"""훅 opt-in 해석 — 환경 변수 > 설정 파일 `hub:` 절, 한 곳·순수 (계획 2026-09-27 L6 §2.2 · 2026-09-28 L10 §2.6 · D-L10-6). + +| 입력 | 뜻 | +| ------------------------------------- | -------------------------------------------------------------------------------------- | +| `HUB_DATABASE_URL` | 있으면 hub 다리를 켠다(출처 `env`). 값은 읽지 않는다 — 부모 환경 그대로 자식에게 간다 | +| 설정 파일 `hub.database_url` | 환경 변수가 없을 때만 opt-in(출처 `config`) — **사람 셸만**. 값은 자식 환경으로만 간다 | +| `SOLAR_EVAL_HUB_CONFIRM` | `local` 이면 `--i-understand-local-data`. 다른 값은 무시 + 경고 한 줄 | +| 설정 파일 `hub.confirm` | `local` 만 — **URL 출처가 설정일 때만**, 사람 셸만(아래 "규칙 9 의 의도된 예외") | +| `SOLAR_EVAL_STRICT_INGEST` | `1`·`true`·`yes` 면 `--strict-ingest` 와 같다 | +| `HUB_TEXT_MODE` | 색인 키에만 쓴다(기본 `inline`) | +| `AGENT_SESSION_MARKERS`(아래 목록) | 에이전트 세션 표식 — 하나라도 있으면 확인(환경·설정)과 설정 출처 opt-in 을 무시한다 | +| `SOLAR_EVAL_HOOK_DEBUG` | `1`·`true`·`yes` 면 봉쇄한 예외의 트레이스백을 WARNING 로그로(기본 DEBUG) | + +**에이전트 세션 가드(D-L6-16 · D-L10-6).** 표식 목록 `AGENT_SESSION_MARKERS` 는 이 모듈 한 곳이다 — Claude Code 의 +`CLAUDECODE` 와, Codex 가 셸 도구·Seatbelt 샌드박스 자식에 설정한다고 그 레포 `AGENTS.md` 가 적은 `CODEX_SANDBOX`· +`CODEX_SANDBOX_NETWORK_DISABLED`(Q-L10-22 — 문서로 확인한 것만). 표식이 하나라도 비어 있지 않으면 `SOLAR_EVAL_HUB_CONFIRM`· +`hub.confirm` 을 무시하고, 설정 파일 `hub.database_url` 도 무시한다(hub 다리 `skipped:not_opted_in` + 경고 한 줄). 사람 셸에는 +영향이 없다. 표식을 잘못 더해도 사람 셸에서 설정 opt-in 이 꺼질 뿐이라 안전 쪽이다. + +**규칙 9 의 의도된 예외(L10 검토 F1-3).** 마스터 §4 규칙 9 는 "`HUB_DATABASE_URL`·확인은 셸 프로필이 아니라 명령 앞에" 다. +설정 파일 `hub.confirm: local` 은 그 규칙의 **의도된 예외**다 — 사람 셸에서, URL 도 설정 파일에 있을 때만 효력이 있다(환경 URL 과 +설정 confirm 을 섞지 않는다 — 환경에 테스트 URL 을 준 시험 자식이 사용자 확인을 줍지 않게, 검토 F1-1). + +URL 값은 어디에도 싣지 않는다 — 경고·`repr` 에 없고, 설정 출처 값은 `HubOptIn.secrets` 로 가림·거부 목록에만 간다. +""" + +from __future__ import annotations + +import os +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import Any, Literal + +ENV_HUB_DATABASE_URL = "HUB_DATABASE_URL" +ENV_HUB_CONFIRM = "SOLAR_EVAL_HUB_CONFIRM" +ENV_STRICT_INGEST = "SOLAR_EVAL_STRICT_INGEST" +ENV_HUB_TEXT_MODE = "HUB_TEXT_MODE" +ENV_HOOK_DEBUG = "SOLAR_EVAL_HOOK_DEBUG" +#: 에이전트 세션 표식 — 한 곳(D-L10-6 · Q-L10-22). 첫 원소는 Claude Code. +AGENT_SESSION_MARKERS: tuple[str, ...] = ( + "CLAUDECODE", + "CODEX_SANDBOX", + "CODEX_SANDBOX_NETWORK_DISABLED", +) +#: 옛 이름(L6) — 첫 표식의 별칭. +ENV_AGENT_SESSION = AGENT_SESSION_MARKERS[0] + +#: `SOLAR_EVAL_HUB_CONFIRM`·`hub.confirm` 이 받는 유일한 값. +CONFIRM_LOCAL = "local" +#: hub CLI 의 사용자 DB 쓰기 확인 플래그. +CONFIRM_FLAG = "--i-understand-local-data" +TRUTHY = frozenset({"1", "true", "yes"}) +DEFAULT_TEXT_MODE = "inline" +#: 이보다 짧은 값은 우연히 겹칠 수 있어 가림·거부하지 않는다(URL 은 이보다 길다). +MIN_SECRET_LENGTH = 8 + +SOURCE_ENV = "env" +SOURCE_CONFIG = "config" +HubSource = Literal["env", "config"] + +_MARKERS_LABEL = "·".join(AGENT_SESSION_MARKERS) +#: 사람이 직접 부를 적재 한 줄(URL 자리는 `…`). +MANUAL_INGEST = "HUB_DATABASE_URL=… make hub-ingest CASE= CONFIRM=local" + +#: 콘솔 경고 문구(값은 싣지 않는다). +WARN_CONFIRM_IGNORED_AGENT = ( + f"{ENV_HUB_CONFIRM}·설정 파일 hub.confirm 을 무시한다 — 에이전트 세션({_MARKERS_LABEL})에서는 " + f"사용자 DB 쓰기 확인을 넘기지 않는다. 사람이 직접: {MANUAL_INGEST}" +) +WARN_CONFIRM_UNKNOWN_VALUE = ( + f"{ENV_HUB_CONFIRM} 값이 '{CONFIRM_LOCAL}' 이 아니라 무시한다 (받는 값: {CONFIRM_LOCAL})" +) +WARN_CONFIG_CONFIRM_UNKNOWN_VALUE = ( + f"설정 파일 hub.confirm 값이 '{CONFIRM_LOCAL}' 이 아니라 무시한다 (받는 값: {CONFIRM_LOCAL})" +) +WARN_CONFIG_OPTIN_IGNORED_AGENT = ( + f"설정 파일 hub.database_url 을 무시한다 — 에이전트 세션({_MARKERS_LABEL})에서는 설정 파일 opt-in·확인을 " + f"쓰지 않는다. 사람이 직접: {MANUAL_INGEST}" +) +#: 설정 모양 오류 — `{problems}` 는 키 이름만 담은 문제 목록(`HubConfig.problems`). +WARN_CONFIG_HUB_INVALID = "설정 파일 hub: 절을 무시한다 — {problems}" + + +@dataclass(frozen=True) +class ConfirmDecision: + """`--i-understand-local-data` 를 붙일지와, 무시했다면 그 이유(콘솔 경고 한 줄).""" + + confirm_local: bool + warning: str | None = None + + +@dataclass(frozen=True) +class HubOptIn: + """opt-in 해석 결과. `child_env` 는 설정 출처일 때만(부모 환경 + URL), `secrets` 는 설정 출처 URL 값 — 둘 다 repr 밖.""" + + source: HubSource | None + child_env: Mapping[str, str] | None = field(default=None, repr=False) + confirm: ConfirmDecision = ConfirmDecision(False) + warnings: tuple[str, ...] = () + secrets: tuple[str, ...] = field(default=(), repr=False) + + @property + def opted_in(self) -> bool: + return self.source is not None + + +def _env(env: Mapping[str, str] | None) -> Mapping[str, str]: + return os.environ if env is None else env + + +def is_agent_session(env: Mapping[str, str] | None = None) -> bool: + """에이전트 세션인가 — `AGENT_SESSION_MARKERS` 중 하나라도 비어 있지 않다.""" + source = _env(env) + return any(source.get(marker, "").strip() for marker in AGENT_SESSION_MARKERS) + + +def resolve_hub_optin(env: Mapping[str, str] | None = None, config: Any | None = None) -> HubOptIn: + """환경 변수 > 설정 파일 `hub:` 절(§2.6 표). `config` 는 `cli.config.HubConfig`(없으면 None).""" + source_env = _env(env) + agent = is_agent_session(source_env) + url, confirm, problems = _config_fields(config) + warnings: list[str] = [] + if problems: + warnings.append(WARN_CONFIG_HUB_INVALID.format(problems="; ".join(problems))) + secrets = _secrets_of(url) + if source_env.get(ENV_HUB_DATABASE_URL, "").strip(): + decision = _env_confirm(source_env, agent) or ConfirmDecision(False) + return HubOptIn(SOURCE_ENV, None, decision, tuple(warnings), secrets) + if url is None or problems: + return HubOptIn(None, None, ConfirmDecision(False), tuple(warnings), secrets) + if agent: + warnings.append(WARN_CONFIG_OPTIN_IGNORED_AGENT) + return HubOptIn(None, None, ConfirmDecision(False), tuple(warnings), secrets) + child_env = {**source_env, ENV_HUB_DATABASE_URL: url} + decision = _env_confirm(source_env, agent) or _config_confirm(confirm) + return HubOptIn(SOURCE_CONFIG, child_env, decision, tuple(warnings), secrets) + + +def config_secrets(config: Any | None) -> tuple[str, ...]: + """설정 출처 비밀(`hub.database_url` 값) — 상태 파일 거부·콘솔 가림 목록에 더한다(§2.6 "비밀"). 쓰지 않은 설정 값도 넣는다.""" + return _secrets_of(_config_fields(config)[0]) + + +def _secrets_of(url: str | None) -> tuple[str, ...]: + return (url,) if url is not None and len(url) >= MIN_SECRET_LENGTH else () + + +def _config_fields(config: Any | None) -> tuple[str | None, str | None, tuple[str, ...]]: + """`HubConfig` 의 (URL, confirm, 문제) — 값이 문자열이 아니면 없는 것으로 본다.""" + if config is None: + return None, None, () + url = getattr(config, "database_url", None) + confirm = getattr(config, "confirm", None) + problems = tuple(getattr(config, "problems", ()) or ()) + url = url.strip() if isinstance(url, str) and url.strip() else None + confirm = confirm.strip() if isinstance(confirm, str) and confirm.strip() else None + return url, confirm, problems + + +def _env_confirm(env: Mapping[str, str], agent: bool) -> ConfirmDecision | None: + """`SOLAR_EVAL_HUB_CONFIRM` 해석 — 비어 있으면 None(설정 confirm 으로 넘어간다). 에이전트 세션이면 `local` 도 무시.""" + raw = env.get(ENV_HUB_CONFIRM, "").strip() + if not raw: + return None + if raw != CONFIRM_LOCAL: + return ConfirmDecision(False, WARN_CONFIRM_UNKNOWN_VALUE) + if agent: + return ConfirmDecision(False, WARN_CONFIRM_IGNORED_AGENT) + return ConfirmDecision(True) + + +def _config_confirm(confirm: str | None) -> ConfirmDecision: + """설정 `hub.confirm` — URL 출처가 설정이고 사람 셸일 때만 부른다.""" + if confirm is None: + return ConfirmDecision(False) + if confirm != CONFIRM_LOCAL: + return ConfirmDecision(False, WARN_CONFIG_CONFIRM_UNKNOWN_VALUE) + return ConfirmDecision(True) + + +def strict_requested(flag: bool, env: Mapping[str, str] | None = None) -> bool: + """`--strict-ingest` 또는 `SOLAR_EVAL_STRICT_INGEST` 가 참(`1`·`true`·`yes`, 대소문자 무시).""" + return flag or _env(env).get(ENV_STRICT_INGEST, "").strip().lower() in TRUTHY + + +def hook_debug_requested(env: Mapping[str, str] | None = None) -> bool: + """`SOLAR_EVAL_HOOK_DEBUG` 가 참 — 봉쇄한 예외의 트레이스백을 WARNING 로그로 올린다(현장 진단용).""" + return _env(env).get(ENV_HOOK_DEBUG, "").strip().lower() in TRUTHY + + +def text_mode(env: Mapping[str, str] | None = None) -> str: + """`HUB_TEXT_MODE` 해석값(비었으면 `inline`) — 색인 키에만 쓴다(D-L6-7).""" + return _env(env).get(ENV_HUB_TEXT_MODE, "").strip() or DEFAULT_TEXT_MODE diff --git a/solar_eval/hub_hook/proc.py b/solar_eval/hub_hook/proc.py new file mode 100644 index 0000000000000000000000000000000000000000..b80ab70a870ec0398a947af0657fba88a6653fd0 --- /dev/null +++ b/solar_eval/hub_hook/proc.py @@ -0,0 +1,124 @@ +"""pnpm 실행 — 한 곳 (계획 2026-09-27 L6 §2.6 · D-L6-13 · 검토 F8). + +`run_pnpm` 은 훅이 pnpm 을 부르는 **유일한** 진입점이다. 두 가지를 여기서 고정한다: + +- **프로세스 그룹째 죽이기.** `subprocess.run(timeout=)` 은 직계 pnpm 만 죽여 `tsx` 손자가 hub 세션 advisory lock 을 쥔 채 + 남는다. 새 세션(`start_new_session=True`)으로 띄우고 한도를 넘기면 `os.killpg`(SIGTERM → 유예 뒤 SIGKILL) 한 뒤 + `TimeoutExpired` 를 낸다 — 연결이 닫혀 서버가 잠금을 푼다. +- **허용 목록(리뷰 지적 2).** argv 는 `hub_leg.build_hub_argv` 가 만드는 모양 그대로여야 한다 — 고정 머리 + `pnpm --silent --fail-if-no-match --filter @poc/eval-store hub ingest` 뒤에 알려진 적재 옵션(`--case`·`--expect-run`· + `--repo`·`--mirror`·`--index` 값 하나씩, 확인 플래그)만. 다른 패키지·`exec`·다른 hub 하위 명령(파괴 명령 포함)·레거시 적재 + 옵션·두 번째 `hub` 는 모두 `ValueError` 다. 훅은 적재 하나만 부른다(D-M2-4 (b) · 계획 §0.4-6). +- **실행부는 비공개**(`_run_group`, 리뷰 지적 3·6) — 허용 목록 없이 argv 를 돌리므로 이 모듈 밖에서 부르지 않는다 + (`test_scaffold.py` 가 소스로 막는다). 시험은 그룹 kill 확인에만 쓴다. + +테스트 tripwire(D-L6-13): `SE/tests/conftest.py` 가 이 모듈의 `run_pnpm` 을 "호출되면 `pytest.fail`" 로 바꾼다. 그러니 호출자는 +반드시 **모듈 속성으로** 부른다 — `from solar_eval.hub_hook import proc; proc.run_pnpm(...)`. 함수를 이름으로 import 하면 +패치를 비켜 간다(`test_scaffold.py` 가 소스 텍스트로 막는다). +""" + +from __future__ import annotations + +import os +import signal +import subprocess +from collections.abc import Mapping, Sequence +from pathlib import Path + +from solar_eval.hub_hook.optin import CONFIRM_FLAG +from solar_eval.hub_hook.types import HUB_PACKAGE_FILTER + +PNPM = "pnpm" +#: 훅이 부를 수 있는 유일한 명령 머리 — pnpm 전역 옵션 + 적재 하나(§2.6 argv). +ALLOWED_ARGV_HEAD = ( + PNPM, "--silent", "--fail-if-no-match", "--filter", HUB_PACKAGE_FILTER, "hub", "ingest", +) # fmt: skip +#: 머리 뒤에 올 수 있는 값 옵션(값 하나씩 — 값은 `-` 로 시작하지 않는다). +ALLOWED_VALUE_OPTIONS = frozenset({"--case", "--expect-run", "--repo", "--mirror", "--index"}) +#: 머리 뒤에 올 수 있는 플래그. +ALLOWED_FLAGS = frozenset({CONFIRM_FLAG}) +#: SIGTERM 뒤 SIGKILL 까지 기다리는 초. +KILL_GRACE_SECONDS = 5.0 + + +def run_pnpm( + argv: Sequence[str], + *, + cwd: Path, + env: Mapping[str, str] | None = None, + timeout: float, +) -> subprocess.CompletedProcess[str]: + """허용 목록을 통과한 pnpm argv 를 새 세션으로 돌린다. 한도 초과는 그룹째 죽이고 `TimeoutExpired`. + + `FileNotFoundError`(pnpm 없음)는 그대로 낸다 — 호출자(hub 다리)가 `pnpm_missing` 으로 바꾼다. + """ + check_pnpm_argv(argv) + return _run_group(argv, cwd=cwd, env=env, timeout=timeout) + + +def check_pnpm_argv(argv: Sequence[str]) -> None: + """고정 머리 + 알려진 적재 옵션만인 argv 가 아니면 `ValueError`(문구에 값을 싣지 않는다).""" + args = list(argv) + head = len(ALLOWED_ARGV_HEAD) + if tuple(args[:head]) != ALLOWED_ARGV_HEAD: + raise ValueError("run_pnpm: argv must start with the fixed eval-store 'hub ingest' head") + rest = args[head:] + index = 0 + while index < len(rest): + token = rest[index] + if token in ALLOWED_FLAGS: + index += 1 + continue + if token not in ALLOWED_VALUE_OPTIONS: + raise ValueError(f"run_pnpm: argument #{head + index} is not an allowed ingest option") + value = rest[index + 1] if index + 1 < len(rest) else None + if not value or value.startswith("-"): + raise ValueError(f"run_pnpm: option {token} needs a value") + index += 2 + + +def _run_group( + argv: Sequence[str], + *, + cwd: Path, + env: Mapping[str, str] | None = None, + timeout: float, +) -> subprocess.CompletedProcess[str]: + """argv 를 새 프로세스 그룹으로 돌리고 출력을 모은다(허용 목록 없음 — `run_pnpm` 의 실행부, 모듈 밖에서 부르지 않는다).""" + process = subprocess.Popen( + list(argv), + cwd=str(cwd), + env=None if env is None else dict(env), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + start_new_session=True, + ) + try: + stdout, stderr = process.communicate(timeout=timeout) + except subprocess.TimeoutExpired: + stdout, stderr = _kill_group(process) + raise subprocess.TimeoutExpired(list(argv), timeout, stdout, stderr) from None + except BaseException: + # KeyboardInterrupt 등 — 손자가 잠금을 쥔 채 남지 않게 그룹을 정리하고 그대로 올린다. + _kill_group(process) + raise + return subprocess.CompletedProcess(list(argv), process.returncode, stdout, stderr) + + +def _kill_group(process: subprocess.Popen[str]) -> tuple[str, str]: + """SIGTERM → 유예 → SIGKILL. 남은 출력을 돌려준다.""" + _signal_group(process, signal.SIGTERM) + try: + return process.communicate(timeout=KILL_GRACE_SECONDS) + except subprocess.TimeoutExpired: + _signal_group(process, signal.SIGKILL) + return process.communicate() + + +def _signal_group(process: subprocess.Popen[str], sig: signal.Signals) -> None: + try: + os.killpg(process.pid, sig) + except ProcessLookupError: + # 이미 끝난 그룹 — 보낼 곳이 없다. + return diff --git a/solar_eval/hub_hook/status.py b/solar_eval/hub_hook/status.py new file mode 100644 index 0000000000000000000000000000000000000000..6471d526e2c4d9d2a326861ac94b8660d184b43e --- /dev/null +++ b/solar_eval/hub_hook/status.py @@ -0,0 +1,193 @@ +"""상태 파일 (계획 2026-09-27 L6 §2.7 · D-L6-12). + +`///hook/.json`, 스키마 `STATUS_SCHEMA`(`solar-eval-hook/v1`), 원자적 쓰기(같은 디렉토리의 임시 +파일 → fsync → `os.replace`). 키: `schema · run_id · project · case · command · started_at · finished_at · strict · +legs{: {status, code, …detail}} · ok`(`ok` = 실패 다리 0). batch run 은 run 마다 번들 다리 결과로 먼저 쓰고, +`hook.finish_batch` 가 끝 다리 결과로 `update_legs` 를 부른다. hub 대상이 아닌 run 은 상태 파일이 없다. + +값은 코드·건수·id·시각만 — 다리의 `hint`(되살리기 문구)는 콘솔 몫이라 싣지 않고, 고객 텍스트·URL·예외 원문도 없다. +마지막 방어로 직렬화한 문서에 hub·레거시 DB URL 값이 들어 있으면 쓰지 않고 `ValueError`(값은 문구에 싣지 않는다, D-L6-4). +환경 변수 값만으로는 설정 파일 `hub.database_url` 값을 모르므로 호출자가 `secrets=`(`optin.config_secrets`)로 넘긴다 — +그 값도 같은 규칙으로 거부·가린다(계획 2026-09-28 L10 §2.6 "비밀" · 검토 F1-4). + +쓰기 실패는 hook 이 콘솔 경고로 강등한다(D-L6-2) — 이 모듈은 예외를 그대로 낸다. +""" + +from __future__ import annotations + +import json +import os +import re +import tempfile +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import Any + +from solar_eval.hub_hook.optin import ENV_HUB_DATABASE_URL +from solar_eval.hub_hook.types import ( + HOOK_DIRNAME, + STATUS_FAILED, + STATUS_SCHEMA, + Command, + HookResult, + LegResult, + Target, +) + +#: 문서에 값이 새면 안 되는 환경 변수(자격증명을 담은 URL). +# 옛 이름(EVALHUB_DATABASE_URL)은 더 이상 읽지 않지만, 개명 전 셸에 남아 있을 수 있어 값이 새지 않게 계속 가린다. +SECRET_ENV_NAMES = (ENV_HUB_DATABASE_URL, "EXPERIMENT_HUB_DATABASE_URL", "EVALHUB_DATABASE_URL") +#: 이보다 짧은 값은 우연히 겹칠 수 있어 검사하지 않는다(URL 은 이보다 길다). +MIN_SECRET_LENGTH = 8 +#: 파일 이름이 되는 run_id — 경로 구분자·제어 문자·`.`/`..` 금지. +_UNSAFE_RUN_ID = re.compile(r"[/\\\x00-\x1f\x7f]") +#: 임시 파일 이름은 run_id 에 기대지 않는다 — `.json` 이 이름 한도(255바이트) 안이어도 `..json.XXXX.tmp` +#: 는 넘칠 수 있다(리뷰 지적 1 — 237~250바이트 run_id 에서 ENAMETOOLONG). +_TMP_PREFIX = ".hook-" +_TMP_SUFFIX = ".tmp" +#: URL 의 `사용자:비밀번호@` — 비밀번호 자리만 가린다. +_URL_PASSWORD = re.compile(r"(?P[A-Za-z][A-Za-z0-9+.-]*://[^\s:/@]*:)[^\s@]*@") +_REDACTED = "***" +#: 설정 출처 비밀의 이름표 — 거부 문구·가림 자리에 값 대신 싣는다. +CONFIG_SECRET_LABEL = "hub.database_url" + + +def status_path(target: Target, run_id: str) -> Path: + """`///hook/.json`. run_id 가 파일 이름으로 안전하지 않으면 `ValueError`.""" + from poc_bundle.home import hub_reports_dir + + _check_run_id(run_id) + return hub_reports_dir() / target.account / target.case / HOOK_DIRNAME / f"{run_id}.json" + + +def _check_run_id(run_id: str) -> None: + if not isinstance(run_id, str) or run_id in ("", ".", "..") or _UNSAFE_RUN_ID.search(run_id): + raise ValueError("hook status: run_id is not a safe file name") + + +def leg_document(leg: LegResult) -> dict[str, Any]: + """다리 하나 → `{status, code, …detail}`. detail 이 `status`·`code` 를 덮지 못한다. hint 는 싣지 않는다.""" + return {**dict(leg.detail), "status": leg.status, "code": leg.code} + + +def _ok(legs: Mapping[str, Mapping[str, Any]]) -> bool: + return all(leg.get("status") != STATUS_FAILED for leg in legs.values()) + + +def status_document( + target: Target, result: HookResult, command: Command, *, strict: bool +) -> dict[str, Any]: + """상태 파일 문서(순수).""" + legs = {name: leg_document(leg) for name, leg in result.legs.items()} + return { + "schema": STATUS_SCHEMA, + "run_id": result.run_id, + "project": target.project, + "case": target.case_ref, + "command": command, + "started_at": result.started_at, + "finished_at": result.finished_at, + "strict": bool(strict), + "legs": legs, + "ok": _ok(legs), + } + + +def write_status( + target: Target, + result: HookResult, + command: Command, + *, + strict: bool, + secrets: Sequence[str] = (), +) -> Path: + """run 하나의 훅 결과를 원자적으로 쓰고 경로를 돌려준다(같은 run 은 덮어쓴다). `secrets` = 설정 출처 비밀(거부 목록).""" + path = status_path(target, result.run_id) + _atomic_write(path, status_document(target, result, command, strict=strict), secrets) + return path + + +def update_legs( + target: Target, + run_id: str, + legs: Mapping[str, LegResult], + *, + secrets: Sequence[str] = (), +) -> None: + """이미 있는 상태 파일의 다리 몇 개를 원자적으로 바꿔 쓰고 `ok` 를 다시 센다(batch 끝 hub 다리, D-L6-12). + + 파일이 없으면 `FileNotFoundError`, 스키마나 run_id 가 다르면 `ValueError` — 호출자(hook)가 경고로 강등한다. + """ + path = status_path(target, run_id) + doc = json.loads(path.read_text("utf-8")) + if ( + not isinstance(doc, dict) + or doc.get("schema") != STATUS_SCHEMA + or doc.get("run_id") != run_id + ): + raise ValueError(f"hook status: {path.name} is not a {STATUS_SCHEMA} file for this run") + current = doc.get("legs") + merged = dict(current) if isinstance(current, dict) else {} + merged.update({name: leg_document(leg) for name, leg in legs.items()}) + _atomic_write(path, {**doc, "legs": merged, "ok": _ok(merged)}, secrets) + + +def _atomic_write(path: Path, doc: Mapping[str, Any], secrets: Sequence[str] = ()) -> None: + """직렬화·비밀 검사 → 같은 디렉토리 임시 파일 → fsync → `os.replace`. 실패하면 임시 파일을 지우고 다시 낸다.""" + text = json.dumps(doc, ensure_ascii=False, indent=2) + "\n" + _refuse_secrets(text, secrets) + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=_TMP_PREFIX, suffix=_TMP_SUFFIX) + tmp = Path(tmp_name) + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(text) + fh.flush() + os.fsync(fh.fileno()) + os.replace(tmp, path) + except BaseException: + tmp.unlink(missing_ok=True) + raise + + +def _secret_values(extra: Sequence[str] = ()) -> list[tuple[str, str]]: + """(이름표, 값) — 환경 변수 값 + 설정 출처 값(`extra`, 이름표 `hub.database_url`). 검사할 만큼 긴 값만.""" + pairs = [(name, os.environ.get(name, "").strip()) for name in SECRET_ENV_NAMES] + pairs += [(CONFIG_SECRET_LABEL, value.strip()) for value in extra if isinstance(value, str)] + return [(name, value) for name, value in pairs if len(value) >= MIN_SECRET_LENGTH] + + +def _refuse_secrets(text: str, extra: Sequence[str] = ()) -> None: + for name, value in _secret_values(extra): + if value in text: + raise ValueError(f"hook status: refusing to store the value of {name}") + + +def _mask_url_password(text: str) -> str: + return _URL_PASSWORD.sub(lambda m: f"{m.group('head')}{_REDACTED}@", text) + + +def _whole_value_replacement(name: str, value: str) -> str: + """알려진 비밀 값 하나를 무엇으로 바꿀까. 비밀이 비밀번호 자리뿐이면(쿼리·조각 없음) 비밀번호만 가린 URL — 호스트·DB + 이름이 진단에 남는다. 그 밖(쿼리 문자열 비밀번호 등 — 어디가 비밀인지 모른다)은 통째로 `<이름표>`.""" + masked = _mask_url_password(value) + only_password = masked != value and "?" not in value and "#" not in value + return masked if only_password else f"<{name}>" + + +def redact_secrets(text: str, extra: Sequence[str] = ()) -> str: + """콘솔에 옮기는 외부 출력(hub CLI stderr)에서 비밀을 가린다(리뷰 지적 6). + + **알려진 값을 먼저**(리뷰 L10 지적 4): `SECRET_ENV_NAMES` 값이나 설정 출처 값(`extra`)이 통째로 있으면 + `_whole_value_replacement` 로 바꾸고, hub CLI `redactUrl` 이 비밀번호만 가린 모양(`u:***@…?sslpassword=…`)으로 있어도 + 같은 이름표로 바꾼다. 그 **뒤에** 남은 모르는 URL 의 비밀번호 자리를 `***` 로(`redactUrl` 과 같은 모양 — 호스트·DB 이름은 + 진단에 남는다). 순서가 거꾸로면 비밀번호를 가린 순간 값이 통째로 맞지 않아 쿼리 비밀이 샌다. + 상태 파일은 이 함수를 쓰지 않는다 — 거기에는 외부 출력을 아예 싣지 않는다(`_refuse_secrets` 가 마지막 방어). + """ + for name, value in _secret_values(extra): + replacement = _whole_value_replacement(name, value) + text = text.replace(value, replacement) + masked = _mask_url_password(value) + if masked not in (value, replacement): + text = text.replace(masked, replacement) + return _mask_url_password(text) diff --git a/solar_eval/hub_hook/target.py b/solar_eval/hub_hook/target.py new file mode 100644 index 0000000000000000000000000000000000000000..51cf573aa6021a8b445d9542049e88f87109ed96 --- /dev/null +++ b/solar_eval/hub_hook/target.py @@ -0,0 +1,157 @@ +"""hub 대상 판정 — 한 곳 (계획 2026-09-27 L6 §2.3 · D-L6-1 · D-L6-10 · C-L6-8 · C-L6-9). + +`resolve_target(cfg, project)` 은 `Target` 이나 `Skip(code)` 를 돌려준다. 조건(순서대로): + +1. `cfg.repo_root` 없음 → `is_repo_managed` 도 거짓이면 `standalone`(조용히), 참이면 `no_repo_root`. +2. `is_repo_managed(project)` 거짓 → `not_repo_managed`(번역·요약 등). +3. `project_contexts(cfg.projects_dir, cfg.repo_root, only=[project])` 가 case 를 못 준다 → `case_unmapped`. +4. resolve 한 `cfg.projects_dir` ≠ resolve 한 `data_home()/solar-eval-projects` → `data_home_mismatch`(검토 F4 — + `cfg.projects_dir` 는 resolve 되지 않은 경로다. 심볼릭 링크 홈은 양쪽을 풀어 같다). +5. 훅이 **직접** 쓰는 루트(`hub_reports_dir()` — 잠금·상태 파일·색인 키)가 레포(링크된 워크트리면 메인 작업 트리도) 안 → + `data_home_inside_repo`(검토 F10 — PB 미러·사이드카 쓰기는 PB 가 막지만 이 셋은 PB 검사를 거치지 않는다). + +`poc_bundle` 은 함수 안에서만 import 한다(D-L6-14 — `solar-eval --help` 가 백필·미러를 로딩하지 않게). +""" + +from __future__ import annotations + +from collections.abc import Mapping +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from solar_eval.hub_hook.types import ( + PROJECTS_DIRNAME, + SKIP_CASE_UNMAPPED, + SKIP_DATA_HOME_INSIDE_REPO, + SKIP_DATA_HOME_MISMATCH, + SKIP_NO_REPO_ROOT, + SKIP_NOT_REPO_MANAGED, + SKIP_STANDALONE, + Skip, + Target, +) + +if TYPE_CHECKING: + from solar_eval.cli.config import CLIConfig + +#: pnpm 워크스페이스 표식 — `workspace_root` 가 코드 체크아웃 루트를 찾는다(C-L6-9). +WORKSPACE_MARKER = "pnpm-workspace.yaml" +#: 훅 쓰기 위치가 레포 안인지 볼 때 `require_outside_repo` 에 주는 이름표. +HOOK_WRITE_LABEL = "hook state" + + +def resolve_target( + cfg: CLIConfig, project: str, env: Mapping[str, str] | None = None +) -> Target | Skip: + """hub 대상 case 인가(§2.3). 레거시 적재는 어느 결과에서도 부르지 않는다(D-M2-4 (b)).""" + repo_root = cfg.repo_root + if repo_root is None: + return Skip(SKIP_NO_REPO_ROOT if cfg.is_repo_managed(project) else SKIP_STANDALONE) + if not cfg.is_repo_managed(project): + return Skip(SKIP_NOT_REPO_MANAGED) + context = _project_context(cfg.projects_dir, repo_root, project) + if context is None or context.case_ref is None: + return Skip(SKIP_CASE_UNMAPPED) + mismatch = _data_home_mismatch(cfg.projects_dir, env) + if mismatch is not None: + return mismatch + inside = _hook_root_inside_repo(repo_root, env) + if inside is not None: + return inside + return Target( + project=project, + account=context.account, + case=context.case, + context=context, + repo_root=Path(repo_root), + projects_dir=Path(cfg.projects_dir), + # 설정 파일 `hub:` 절 — opt-in 해석은 `hook.gated_hub_leg` → `optin.resolve_hub_optin`(L10 §2.6). + hub_config=getattr(cfg, "hub", None), + ) + + +def _project_context(projects_dir: Path, repo_root: Path, project: str) -> Any | None: + from poc_bundle.census.scan import project_contexts + + contexts = project_contexts(Path(projects_dir), Path(repo_root), only=[project]) + return contexts[0] if contexts else None + + +def _data_home_mismatch(projects_dir: Path, env: Mapping[str, str] | None) -> Skip | None: + from poc_bundle.home import data_home + + actual = Path(projects_dir).expanduser().resolve() + expected = (data_home(env) / PROJECTS_DIRNAME).resolve() + if actual == expected: + return None + return Skip(SKIP_DATA_HOME_MISMATCH, {"projects_dir": str(actual), "expected": str(expected)}) + + +def _hook_root_inside_repo(repo_root: Path, env: Mapping[str, str] | None) -> Skip | None: + from poc_bundle.home import HomeError, hub_reports_dir, require_outside_repo + from poc_bundle.mirror import linked_main_work_tree + + root = hub_reports_dir(env) + main = linked_main_work_tree(Path(repo_root)) + for tree in (Path(repo_root),) if main is None else (Path(repo_root), main): + try: + require_outside_repo(root, tree, HOOK_WRITE_LABEL) + except HomeError: + return Skip(SKIP_DATA_HOME_INSIDE_REPO, {"hub_reports_dir": str(root)}) + return None + + +def case_ref_hint(cfg: CLIConfig, project: str) -> str | None: + """`--no-ingest` 안내용 case 이름 — `poc_bundle` 없이 config 디렉토리 모양(`accounts///03-evaluation`)에서. + + 판정이 아니라 문구용이다. 레포를 모르면 None. + """ + if cfg.repo_root is None: + return None + config_dir = cfg.config_dirs.get(project) + if config_dir is None: + return None + case_dir = Path(config_dir).parent + return f"{case_dir.parent.name}/{case_dir.name}" + + +def workspace_root() -> Path | None: + """pnpm 을 돌릴 **코드 체크아웃** 루트 — `solar_eval` 패키지 파일에서 위로 `pnpm-workspace.yaml`(C-L6-9). + + hub 의 `--repo` 는 config 루트 `cfg.repo_root` 다 — 합성 레포(`SOLAR_EVAL_CONFIG_ROOT`)는 pnpm 워크스페이스가 아니다. + """ + import solar_eval + + for parent in Path(solar_eval.__file__).resolve().parents: + if (parent / WORKSPACE_MARKER).is_file(): + return parent + return None + + +def is_smoke_run(source: Any) -> bool: + """`--limit` 스모크 run 인가(D-L6-10). + + `source` 는 `RunPlan`(`.selection.limit` — `runs start`)이나 run.json 매핑(`dataset.selection.limit` — `runs resume`). + 양의 정수 limit 만 스모크다(불리언·음수·문자열은 아니다). + """ + if isinstance(source, Mapping): + dataset = source.get("dataset") + selection = dataset.get("selection") if isinstance(dataset, Mapping) else None + limit = selection.get("limit") if isinstance(selection, Mapping) else None + else: + selection = getattr(source, "selection", None) + limit = getattr(selection, "limit", None) + return isinstance(limit, int) and not isinstance(limit, bool) and limit > 0 + + +def snapshot_evaluator_type(run_json: Mapping[str, Any]) -> str | None: + """run.json 스냅샷 `config.evaluator_config.config.type` — 모양이 하나라도 다르면 None(D-L6-8). + + `poc_bundle.legacy_run._snapshot_evaluator_type` 과 같은 경로다(비공개 함수라 import 하지 않고 같은 규칙을 둔다). + """ + node: Any = run_json.get("snapshot") + for key in ("config", "evaluator_config", "config", "type"): + if not isinstance(node, Mapping): + return None + node = node.get(key) + return node if isinstance(node, str) and node else None diff --git a/solar_eval/hub_hook/types.py b/solar_eval/hub_hook/types.py new file mode 100644 index 0000000000000000000000000000000000000000..48775849a4ebd53dfbfb2dfdb9dbca6521240767 --- /dev/null +++ b/solar_eval/hub_hook/types.py @@ -0,0 +1,332 @@ +"""run 종료 훅의 타입과 코드 상수 — 한 곳 (계획 2026-09-27 L6 §2.13 · §3.0 · 검토 F11). + +훅이 콘솔·상태 파일·종료 코드에 싣는 **모든** 코드 문자열과 접두어가 여기 있다. 병렬 태스크(L6a.1·L6a.2·L6b)가 새 코드가 +필요하면 제 모듈에 두지 않고 핸드오프에 적고, verify 가 이 파일로 모은다(계획 §6). + +표기: `LegResult.status` 는 `ok · failed · skipped · not_run` 넷이고 `code` 는 상태 접두 없이 싣는다 — 계획의 `failed:bundle_frozen:` +는 `status="failed"`, `code="bundle_frozen:"` 이다. 값에는 코드·건수·id 만 둔다(고객 텍스트·URL·예외 원문 금지, D-L6-2·D-L6-4). + +이 모듈은 표준 라이브러리만 import 한다(D-L6-14). +""" + +from __future__ import annotations + +import enum +from collections.abc import Mapping +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Literal + +# ── 상태·다리 ──────────────────────────────────────────────────────────── + +STATUS_OK = "ok" +STATUS_FAILED = "failed" +STATUS_SKIPPED = "skipped" +STATUS_NOT_RUN = "not_run" +LegStatus = Literal["ok", "failed", "skipped", "not_run"] + +LEG_BUNDLE = "bundle" +LEG_HUB = "hub" +#: 잠금을 못 잡았을 때만 생기는 다리(두 다리는 not_run). +LEG_LOCK = "lock" +#: 훅 전체를 감싼 바깥 겹이 예외를 잡았을 때만 생기는 다리. +LEG_HOOK = "hook" +#: 상태 파일 쓰기 — 다리가 아니라 강등 코드(`status_exception:`)의 이름으로만 쓴다. +LEG_STATUS_FILE = "status" +#: L6b `runs eval` 새 경로의 발행(평가 디렉토리 → eval-wrap) 다리. +LEG_PUBLISH = "publish" + +#: 예외 강등 코드의 가운데 — `bundle_exception:RuntimeError`(타입 이름만, D-L6-2). +EXCEPTION_INFIX = "_exception:" + +# ── 명령·종료 코드 ─────────────────────────────────────────────────────── + +COMMAND_START = "start" +COMMAND_RESUME = "resume" +COMMAND_BATCH = "batch" +COMMAND_EVAL = "eval" +Command = Literal["start", "resume", "batch", "eval"] + +#: `--strict-ingest` 에서 실패 다리가 하나라도 있으면(D-L6-11 · D-M2-3). +EXIT_HOOK_FAILED = 3 + +# ── hub 대상 판정(§2.3 — target.resolve_target) ───────────────────────── + +SKIP_STANDALONE = "standalone" +SKIP_NOT_REPO_MANAGED = "not_repo_managed" +SKIP_NO_REPO_ROOT = "no_repo_root" +SKIP_CASE_UNMAPPED = "case_unmapped" +SKIP_DATA_HOME_MISMATCH = "data_home_mismatch" +SKIP_DATA_HOME_INSIDE_REPO = "data_home_inside_repo" +TARGET_SKIP_CODES = frozenset({ + SKIP_STANDALONE, SKIP_NOT_REPO_MANAGED, SKIP_NO_REPO_ROOT, + SKIP_CASE_UNMAPPED, SKIP_DATA_HOME_MISMATCH, SKIP_DATA_HOME_INSIDE_REPO, +}) # fmt: skip + +#: 데이터 홈 아래 엔진 아카이브 디렉토리 이름(`cli/config.py` `default_projects_dir` 와 같다). +PROJECTS_DIRNAME = "solar-eval-projects" + +# ── 다리 건너뜀 코드 ───────────────────────────────────────────────────── + +NO_INGEST = "no_ingest" +SMOKE_RUN = "smoke_run" +NOT_OPTED_IN = "not_opted_in" +NO_BUNDLE = "no_bundle" +NO_WORKSPACE = "no_workspace" +NOT_PROOFREAD_FAMILY = "not_proofread_family" +NO_LOCK = "no_lock" +NO_POLICY_TEMPLATE = "no_policy_template" +#: batch 한 run 의 hub 다리 — 끝 다리(`finish_batch`)가 채운다(D-L6-12). +BATCH_PENDING = "batch_pending" +#: 스캐폴드 다리 스텁(L6a.2 가 본문을 채운다). +NOT_IMPLEMENTED = "not_implemented" + +# ── 잠금(D-L6-3) ───────────────────────────────────────────────────────── + +LOCK_TIMEOUT = "lock_timeout" +HOOK_LOCK_TIMEOUT_SECONDS = 1200.0 +LOCK_POLL_SECONDS = 0.2 +#: 대기 중 "기다리는 중… N 초째" 안내 주기(검토 F8). +LOCK_NOTICE_SECONDS = 5.0 +HOOK_DIRNAME = "hook" +LOCK_FILENAME = "hook.lock" + +# ── 번들 다리(§2.5) ────────────────────────────────────────────────────── + +#: 백필 wrap 부류 중 "run 번들이 있다"(D-L6-2 — 아니면 hub 는 `no_bundle`). +WRAP_WRITTEN = "written" +WRAP_REUSED = "reused" +WRAP_REWRAPPED = "rewrapped_input_fields" +WRAP_BUNDLE_READY = frozenset({WRAP_WRITTEN, WRAP_REUSED, WRAP_REWRAPPED}) +#: 백필 부류 접두(`PB/src/poc_bundle/backfill/classes.py` 와 같은 값). +WRAP_FROZEN_PREFIX = "frozen:" +WRAP_ERROR_PREFIX = "error:" +EVAL_WRAP_SKIP_PREFIX = "skip:" + +#: 번들 다리 단계 — 상태 파일 `legs.bundle` 의 detail 키이자 단계 예외 코드(`rescore_exception:`)의 이름 +#: (l6a-bundle-leg 가 `bundle_leg.py` 에 두었던 것을 verify 가 옮겼다). +STEP_BACKFILL = "backfill" +STEP_RESCORE = "rescore" +STEP_INDEXES = "indexes" + +RUN_NOT_IN_ARCHIVE = "run_not_in_archive" +BUNDLE_FROZEN_PREFIX = "bundle_frozen:" +BUNDLE_ERROR_PREFIX = "bundle_error:" +EVAL_WRAP_FAILED_PREFIX = "eval_wrap:" +BUNDLE_INDEX_FAILED_PREFIX = "bundle_index:" +MIRROR_UNADOPTABLE = "mirror_unadoptable" + +#: 재채점 대상 평가기 계열(D-L6-8 — 스냅샷 `config.evaluator_config.config.type`). +PROOFREAD_EVALUATOR_TYPE = "lcs_diff" +RESCORE_WRITTEN = "written" +RESCORE_EXISTS = "exists" +RESCORE_OK = frozenset({RESCORE_WRITTEN, RESCORE_EXISTS}) +RESCORE_EMPTY = "rescore_empty" +RESCORE_SKIP_PREFIX = "rescore_skip:" +RESCORE_ERROR_PREFIX = "rescore_error:" +RESCORE_NO_RESULTS = "no_results" + +#: 색인 갱신 결과(D-L6-7). 실패는 `index_<명령>:`. +INDEX_UNCHANGED = "unchanged" +INDEX_WRITTEN = "written" +INDEX_WRITTEN_DATASETS_ONLY = "written:datasets_only" +INDEX_FAILED_PREFIX = "index_" +INDEX_KEY_FILENAME = "index-key.json" +#: 색인 명령(`poc-bundle index <명령>`) — 실패 코드 `index_<명령>:` 의 가운데(l6a-bundle-leg → verify 가 옮겼다). +INDEX_CMD_DATASETS = "datasets" +INDEX_CMD_TAGS = "tags" +INDEX_CMD_POLICY = "policy" + +# ── hub 다리(§2.6) ─────────────────────────────────────────────────────── + +HUB_INGEST_TIMEOUT_SECONDS = 900.0 +#: pnpm 필터 불일치 표식(`--fail-if-no-match` 종료 1 + 이 문구, pnpm 10.30.1). +PNPM_NO_MATCH = "No projects matched" +HUB_PACKAGE_FILTER = "@poc/eval-store" + +HUB_EXIT_OK = 0 +HUB_EXIT_REFUSED = 1 +HUB_EXIT_CASE = 2 +HUB_EXIT_SCHEMA = 3 +HUB_EXIT_EXPECT = 4 + +HUB_FAILED_PREFIX = "hub_" +HUB_EXIT_PREFIX = "hub_exit_" +CASE_BLOCKED_PREFIX = "case_blocked:" +CASE_FAILED_PREFIX = "case_failed:" +SCHEMA_MISMATCH = "schema_mismatch" +RUN_NOT_OK_PREFIX = "run_not_ok:" +PNPM_MISSING = "pnpm_missing" +TIMEOUT = "timeout" +FILTER_NO_MATCH = "filter_no_match" +HUB_OUTPUT_UNREADABLE = "hub_output_unreadable" +#: hub ingest 요약의 `failed.stage` — 잠금 대기 한도 초과(D-L6-6). +STAGE_CASE_LOCK = "case_lock" +#: hub ingest 요약 `cases[].status` 중 종료 2 의 두 값(l6a-hub-ts-leg 가 `hub_leg.py` 에 적었던 것을 verify 가 옮겼다). +HUB_CASE_BLOCKED = "blocked" +HUB_CASE_FAILED = "failed" + +#: hub CLI stderr `[hub] 오류()` 의 코드 중 콘솔이 안내를 바꾸는 것. +HUB_ERR_CONFIRMATION_REQUIRED = "confirmation_required" +HUB_ERR_CONNECTION_FAILED = "connection_failed" +HUB_ERR_UNKNOWN_CASE = "unknown_case" +HUB_ERR_BAD_DATABASE_URL = "bad_database_url" +HUB_ERR_BAD_ARGUMENT = "bad_argument" + +#: `--expect-run` 상태(§2.9). +EXPECT_OK = "ok" +EXPECT_MISSING = "missing" +EXPECT_NO_USABLE_BUNDLE = "no_usable_bundle" +EXPECT_ORPHANED = "orphaned" + +# ── L6b 발행(§2.12 B4) — `runs eval` 새 경로의 `publish` 다리(solar_eval.scoring_publish) ───── +# 스캐폴드 제안값을 l6b-eval-dir 가 쓰며 확정했고 l6b-verify 가 detail 키까지 여기로 모았다. 발행 다리 code 어휘 전부: +# publish_no_bundle:<번들 다리 code> — 번들이 없어 채점하지 않았다(예 `publish_no_bundle:bundle_frozen:input_keys_mismatch`) +# publish_score_failed — 채점·평가 디렉토리 쓰기 실패(detail `exception: `, 끝나지 못한 디렉토리는 치운다) +# publish_eval_wrap: · publish_eval_wrap:skip: — 채점은 평가 디렉토리에 남았고 발행만 못 했다 +# publish_exception: — 예기치 않은 예외(`exception_code(LEG_PUBLISH, exc)`, D-L6-2) + +PUBLISH_NO_BUNDLE_PREFIX = "publish_no_bundle:" +PUBLISH_SCORE_FAILED = "publish_score_failed" +PUBLISH_EVAL_WRAP_PREFIX = "publish_eval_wrap:" +#: 발행 다리 detail 키(상태 파일 `legs.publish`) — 생산자는 `scoring_publish.publish`, 읽는 쪽은 콘솔·시험. +PUBLISH_DETAIL_EVALUATION_ID = "evaluation_id" +PUBLISH_DETAIL_EVALUATION_DIR_ID = "evaluation_dir_id" +PUBLISH_DETAIL_SCORING_BUNDLE_IDS = "scoring_bundle_ids" +PUBLISH_DETAIL_PURPOSE = "purpose" +#: 채점 실패 건수(evaluation.json `eval_failed_count` — run.json `failed_sample_indices` 의 추론 실패와 다르다, §2.12 이름 주의). +PUBLISH_DETAIL_EVAL_FAILED = "eval_failed" +#: `publish_score_failed` 의 예외 타입 이름(원문 없음). +PUBLISH_DETAIL_EXCEPTION = "exception" + +# ── 상태 파일(§2.7) ────────────────────────────────────────────────────── + +STATUS_SCHEMA = "solar-eval-hook/v1" + + +class HookMode(enum.Enum): + """훅이 어떤 다리를 도는가.""" + + #: `runs start`·`runs resume` — 번들 → hub. + FULL = "full" + #: `runs batch` 한 run — 번들 다리만(hub 는 끝에 한 번, D-L6-12). + BATCH_ITEM = "batch_item" + #: L6b `runs eval` 새 경로. + EVAL = "eval" + + +@dataclass(frozen=True) +class LegResult: + """다리 하나의 결과. `detail` 은 상태 파일에 그대로 실린다(코드·건수·id 만).""" + + status: LegStatus + code: str + detail: Mapping[str, Any] = field(default_factory=dict) + hint: str | None = None + + @classmethod + def ok(cls, detail: Mapping[str, Any] | None = None, code: str = STATUS_OK) -> LegResult: + return cls(STATUS_OK, code, dict(detail or {})) + + @classmethod + def failed( + cls, code: str, hint: str | None = None, detail: Mapping[str, Any] | None = None + ) -> LegResult: + return cls(STATUS_FAILED, code, dict(detail or {}), hint) + + @classmethod + def skipped(cls, code: str, hint: str | None = None) -> LegResult: + return cls(STATUS_SKIPPED, code, {}, hint) + + @classmethod + def not_run(cls, code: str = STATUS_NOT_RUN) -> LegResult: + return cls(STATUS_NOT_RUN, code) + + @property + def is_failed(self) -> bool: + return self.status == STATUS_FAILED + + +@dataclass(frozen=True) +class HookResult: + """run 하나의 훅 결과. `case` 는 hub 대상이 아니면 None.""" + + run_id: str + case: str | None + legs: Mapping[str, LegResult] + started_at: str | None = None + finished_at: str | None = None + + @property + def failed_legs(self) -> tuple[str, ...]: + return tuple(name for name, leg in self.legs.items() if leg.is_failed) + + @property + def ok(self) -> bool: + return not self.failed_legs + + +@dataclass(frozen=True) +class HookOptions: + """run 명령이 훅에 넘기는 선택. `strict` 는 플래그와 환경 변수를 합친 값이다(optin.strict_requested).""" + + command: Command + mode: HookMode = HookMode.FULL + no_ingest: bool = False + strict: bool = False + #: `--limit` 스모크 run — 번들·hub 다리 모두 `smoke_run`(D-L6-10). + smoke: bool = False + + +@dataclass(frozen=True) +class Skip: + """hub 대상이 아니다(§2.3). `detail` 에는 경로만(콘솔용).""" + + code: str + detail: Mapping[str, str] = field(default_factory=dict) + + +@dataclass(frozen=True) +class Target: + """hub 대상 case. `context` 는 `poc_bundle.census.scan.ProjectContext`(lock 포함) — 타입은 import 경계 때문에 Any. + + `hub_config` 는 설정 파일 `hub:` 절(`cli.config.HubConfig`, 없으면 None — L10 §2.6). 해석은 `optin.resolve_hub_optin` + 한 곳이고, 값(자격증명 URL)이 `repr` 에 실리지 않게 `repr=False` 다. + """ + + project: str + account: str + case: str + context: Any + repo_root: Path + projects_dir: Path + hub_config: Any = field(default=None, repr=False) + + @property + def case_ref(self) -> str: + return f"{self.account}/{self.case}" + + +def exception_code(leg: str, exc: BaseException) -> str: + """다리 예외를 코드로 — 타입 이름만 싣는다(트레이스백·원문은 입력 조각을 담을 수 있다, D-L6-2).""" + return f"{leg}{EXCEPTION_INFIX}{type(exc).__name__}" + + +def bundle_ready(leg: LegResult | None) -> bool: + """번들 다리가 run 번들을 남겼는가 — `detail.backfill.wrap` 이 written·reused·rewrapped 중 하나.""" + if leg is None: + return False + backfill = leg.detail.get("backfill") + return isinstance(backfill, Mapping) and backfill.get("wrap") in WRAP_BUNDLE_READY + + +def hub_run_id(leg: LegResult | None, fallback: str) -> str: + """hub 에 적재되는 run 의 id — 번들 다리가 wrap 한 번들의 run_id(run.json `run_id`, PB `RunReport.run_id`). + + hub.runs·미러 번들·재채점 필터는 run.json `run_id` 를 쓴다. 훅의 run_id(상태 파일 이름)는 run **디렉토리 이름**이라, 이름을 바꾼 + 디렉토리나 사본 run 에서는 둘이 다르다 — hub `--expect-run` 은 이 값을 써야 적재가 된 run 을 `missing` 으로 보지 않는다(l6a-verify + 리뷰 지적 3). 번들 다리 detail 에 run_id 가 없으면 `fallback`(디렉토리 이름). + """ + backfill = leg.detail.get(STEP_BACKFILL) if leg is not None else None + run_id = backfill.get("run_id") if isinstance(backfill, Mapping) else None + return run_id if isinstance(run_id, str) and run_id else fallback diff --git a/solar_eval/models/__pycache__/__init__.cpython-312.pyc b/solar_eval/models/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2cf0af6a18f571b28794e3294cf39bda70987e14 Binary files /dev/null and b/solar_eval/models/__pycache__/__init__.cpython-312.pyc differ diff --git a/solar_eval/models/__pycache__/base.cpython-312.pyc b/solar_eval/models/__pycache__/base.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..050dd8e24e23e47361b722d954ce3c2664b35008 Binary files /dev/null and b/solar_eval/models/__pycache__/base.cpython-312.pyc differ diff --git a/solar_eval/models/__pycache__/enums.cpython-312.pyc b/solar_eval/models/__pycache__/enums.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6e5d473d9a9a28eccbcd47ceda85b6d2df75aa70 Binary files /dev/null and b/solar_eval/models/__pycache__/enums.cpython-312.pyc differ diff --git a/solar_eval/models/__pycache__/prompt_version.cpython-312.pyc b/solar_eval/models/__pycache__/prompt_version.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c3c561aba070712ce0bb18ad796e29553a861a14 Binary files /dev/null and b/solar_eval/models/__pycache__/prompt_version.cpython-312.pyc differ diff --git a/solar_eval/models/__pycache__/sample.cpython-312.pyc b/solar_eval/models/__pycache__/sample.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e71310cdbfac48d8fb0c0e8660873c2916545125 Binary files /dev/null and b/solar_eval/models/__pycache__/sample.cpython-312.pyc differ diff --git a/solar_eval/models/project.py b/solar_eval/models/project.py index 46d5a067820e1cece3d07e108d305c44e0e85757..2df0a3c43b62b5335b92c6287bbc99577ebe346f 100644 --- a/solar_eval/models/project.py +++ b/solar_eval/models/project.py @@ -39,6 +39,11 @@ class TaskConfig(CustomBaseModel): pipeline: PipelineType pipeline_config: PipelineConfig | None = None evaluator: EvaluatorConfig + # 행 필터 `{tags: [...], category: [...]}` -- 태스크에만 둔다(`defaults.where` 는 run + # 시작 시 거부). 검증 정본은 `core.row_filter.parse_where`. + where: dict[str, list[str]] | None = None + # results 행 `meta` 로 옮길 데이터 행 키 -- 검증 정본은 `core.dataset_prep.validate_meta_fields`. + meta_fields: list[str] | None = None class DatasetConfig(CustomBaseModel): diff --git a/solar_eval/models/prompt_version.py b/solar_eval/models/prompt_version.py index 69cfe167b199880db5941c88e5b99427e04b017d..a630dd83201baa198df9f40f9f6592f0ed95c26e 100644 --- a/solar_eval/models/prompt_version.py +++ b/solar_eval/models/prompt_version.py @@ -141,13 +141,16 @@ def _load_prompt_messages_from_dir(dir_path: Path) -> list[dict[str, str]]: def _detect_prompt_format_dir(dir_path: Path) -> str: - """Detect format from a prompt directory.""" - step_files = list(dir_path.glob("step*.txt")) - if step_files: - return "multi_step" + """Detect format from a prompt directory. + + system.txt 가 있으면 single_step, 그 밖의 .txt(step1.txt 든 solar_list.txt 든 - 파일 이름이 곧 prompt_key)가 + 하나라도 있으면 multi_step. 2026-10-15: 목록 모드 파이프라인은 step*.txt 없이 solar_list·fact_audit 만 쓴다. + """ if (dir_path / "system.txt").exists(): return "single_step" + if any(f.stem not in ("system", "user") for f in dir_path.glob("*.txt")): + return "multi_step" raise ValueError( f"Cannot detect prompt format in {dir_path}: " - "expected step*.txt files (multi_step) or system.txt (single_step)" + "expected step prompt .txt files (multi_step) or system.txt (single_step)" ) diff --git a/solar_eval/models/run.py b/solar_eval/models/run.py index 2c55b78e9ef01755c59ce9958cd79ee489e8248d..d50996b4df294e887ee3e68f71de5c55690fba63 100644 --- a/solar_eval/models/run.py +++ b/solar_eval/models/run.py @@ -31,9 +31,11 @@ class RunResultResponse(CustomBaseModel): input: dict[str, Any] output: str golden: Any - input_tokens: int - output_tokens: int - inference_time_ms: float + # 사용량·지연이 측정되지 않은 행은 null 이다 -- 0 은 "0 을 썼다" 는 측정값이라 구분한다 + # (L6b B1 · D-M2-24, `core/run_records.py` `build_result_row`). + input_tokens: int | None = None + output_tokens: int | None = None + inference_time_ms: float | None = None class EvaluationResponse(CustomBaseModel): @@ -41,7 +43,8 @@ class EvaluationResponse(CustomBaseModel): run_id: str scores: dict[str, float] overall_score: float - eval_model: str + # judge 모델이 없는 평가기(lcs_diff·rule_based)는 null 을 보고한다 (계획 D11). + eval_model: str | None evaluated_at: str diff --git a/solar_eval/pipelines/__pycache__/__init__.cpython-312.pyc b/solar_eval/pipelines/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4c0f81f063842d75bf2789869ab14e657d3d7299 Binary files /dev/null and b/solar_eval/pipelines/__pycache__/__init__.cpython-312.pyc differ diff --git a/solar_eval/pipelines/__pycache__/base.cpython-312.pyc b/solar_eval/pipelines/__pycache__/base.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e5bdfe1ca0c9b2d63a096fdc84c344b6101b7a30 Binary files /dev/null and b/solar_eval/pipelines/__pycache__/base.cpython-312.pyc differ diff --git a/solar_eval/pipelines/__pycache__/corrections.cpython-312.pyc b/solar_eval/pipelines/__pycache__/corrections.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6bdd1b44c85ef7db94fb163deb4c87b0536e00d1 Binary files /dev/null and b/solar_eval/pipelines/__pycache__/corrections.cpython-312.pyc differ diff --git a/solar_eval/pipelines/__pycache__/errors.cpython-312.pyc b/solar_eval/pipelines/__pycache__/errors.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..25485f745b478126cd9a9cfef3d474a2a6242d00 Binary files /dev/null and b/solar_eval/pipelines/__pycache__/errors.cpython-312.pyc differ diff --git a/solar_eval/pipelines/__pycache__/judge.cpython-312.pyc b/solar_eval/pipelines/__pycache__/judge.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c468e20a4ba446e4cf0345a50250efb5062154b2 Binary files /dev/null and b/solar_eval/pipelines/__pycache__/judge.cpython-312.pyc differ diff --git a/solar_eval/pipelines/__pycache__/pipeline.cpython-312.pyc b/solar_eval/pipelines/__pycache__/pipeline.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a71beacb984888ab67cbad14a385321361724923 Binary files /dev/null and b/solar_eval/pipelines/__pycache__/pipeline.cpython-312.pyc differ diff --git a/solar_eval/pipelines/__pycache__/registry.cpython-312.pyc b/solar_eval/pipelines/__pycache__/registry.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..48fddf8ab399ac5e41ab552e322efef59894ff4b Binary files /dev/null and b/solar_eval/pipelines/__pycache__/registry.cpython-312.pyc differ diff --git a/solar_eval/pipelines/__pycache__/rules.cpython-312.pyc b/solar_eval/pipelines/__pycache__/rules.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..26c0255eb923ebd79bfd6467940519a3f405a400 Binary files /dev/null and b/solar_eval/pipelines/__pycache__/rules.cpython-312.pyc differ diff --git a/solar_eval/pipelines/corrections.py b/solar_eval/pipelines/corrections.py new file mode 100644 index 0000000000000000000000000000000000000000..e11a2a4001e4f5fa1fc90c9e91e29bc0e6f99604 --- /dev/null +++ b/solar_eval/pipelines/corrections.py @@ -0,0 +1,247 @@ +"""교정 목록을 본문에 적용하는 결정론 규칙 — `apply_corrections` 스텝의 순수 함수. + +LLM 이 전문을 다시 쓰는 대신 `{"corrections": [{"original": …, "corrected": …}]}` 만 내면(`llm` 스텝 `output_mode: corrections`), +여기서 각 항목을 본문에 치환한다. 규칙: + +- `original` 이 본문에 **정확히 한 번** 있어야 한다(공백 차이는 무시). 0번·2번 이상이면 건너뛴다(`skipped` 로 셈). +- `original == corrected` 거나 둘 중 하나가 비면 건너뛴다. `corrected` 가 비어 있으면 삭제로 보되 `allow_delete` 일 때만. +- 치환은 뒤에서부터 적용해 앞 치환이 뒤 위치를 흔들지 않게 한다. 두 항목의 구간이 겹치면 뒤 항목을 건너뛴다. +- `max_items` 를 넘는 목록은 앞에서부터 그 수만 쓴다(폭주 방지). +- `protect_digits` 면 숫자 글자 열이 달라지는 항목(연도·금액·비율 변경)을, `max_shrink` 면 공백 제외 길이가 그 비율 아래로 줄어드는 + 항목(어절 삭제)을 건너뛴다(`skipped_protected`). 규범 교열 목록 스텝이 숫자를 고치거나 어절을 지우는 사고를 결정론으로 막는 용도다 + (2026-10-15 이투데이 1·22번 재현: pro4 목록이 연도를 바꾸고 회사명을 지웠다). +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class ApplyReport: + applied: int + skipped_not_found: int + skipped_ambiguous: int + skipped_overlap: int + skipped_invalid: int + skipped_protected: int = 0 + + def as_dict(self) -> dict[str, int]: + return dict(self.__dict__) + + +def _normalize(text: str) -> tuple[str, list[int]]: + out: list[str] = [] + idx: list[int] = [] + for i, ch in enumerate(text): + if ch.isspace() or ch == " ": + continue + out.append(ch) + idx.append(i) + return "".join(out), idx + + +def _locate(norm_text: str, raw_index: list[int], needle: str) -> tuple[int, int] | str: + """원본 [s, e) 또는 'not_found' | 'ambiguous'.""" + n, _ = _normalize(needle) + if not n: + return "not_found" + first = norm_text.find(n) + if first < 0: + return "not_found" + if norm_text.find(n, first + 1) >= 0: + return "ambiguous" + return raw_index[first], raw_index[first + len(n) - 1] + 1 + + +@dataclass(frozen=True) +class Hunk: + """원문 대비 바뀐 자리 하나 — 현재 본문의 `[start, end)` 를 `before` 로 되돌리면 원문과 같아진다.""" + + id: int + start: int + end: int + before: str # 원문 쪽 글자 + after: str # 현재 본문 쪽 글자 + left: str # 앞 문맥(현재 본문) + right: str # 뒤 문맥(현재 본문) + + def as_dict(self) -> dict[str, Any]: + return dict(self.__dict__) + + +def diff_hunks( + original: str, + current: str, + *, + context: int = 20, + max_hunks: int = 80, + skip_spacing: bool = False, +) -> list[Hunk]: + """어절(공백 분리) 단위 SequenceMatcher 로 원문→현재 변경 구간을 뽑는다. 현재 본문 기준 문자 오프셋을 함께 준다. + + `skip_spacing` 이면 띄어쓰기만 다른 변경(공백을 빼면 같은 글자열)은 목록에 넣지 않는다. 번호도 매기지 않으므로 되돌릴 수 없고, + 검증 LLM 이 읽을 목록이 짧아진다. + """ + import difflib + + o_tokens = _tokens(original) + c_tokens = _tokens(current) + sm = difflib.SequenceMatcher( + None, [t for t, _ in o_tokens], [t for t, _ in c_tokens], autojunk=False + ) + hunks: list[Hunk] = [] + for tag, i1, i2, j1, j2 in sm.get_opcodes(): + if tag == "equal": + continue + if len(hunks) >= max_hunks: + break + if j1 < j2: + start, end = c_tokens[j1][1], c_tokens[j2 - 1][1] + len(c_tokens[j2 - 1][0]) + else: # 삭제 — 현재 본문에는 자리만 있다 + start = end = c_tokens[j1][1] if j1 < len(c_tokens) else len(current) + before = ( + original[o_tokens[i1][1] : o_tokens[i2 - 1][1] + len(o_tokens[i2 - 1][0])] + if i1 < i2 + else "" + ) + after = current[start:end] + if ( + skip_spacing + and "".join(before.split()) == "".join(after.split()) + and before.count("\n") == after.count("\n") # 문단 합치기·나누기는 띄어쓰기가 아니다 + ): + continue + hunks.append( + Hunk( + id=len(hunks) + 1, + start=start, + end=end, + before=before, + after=after, + left=current[max(0, start - context) : start], + right=current[end : end + context], + ) + ) + return hunks + + +def _tokens(text: str) -> list[tuple[str, int]]: + """(어절, 시작 오프셋). 개행도 어절 하나로 둬 문단 경계 변경이 보이게 한다.""" + out: list[tuple[str, int]] = [] + i = 0 + n = len(text) + while i < n: + ch = text[i] + if ch == "\n": + out.append(("\n", i)) + i += 1 + continue + if ch.isspace(): + i += 1 + continue + j = i + while j < n and not text[j].isspace(): + j += 1 + out.append((text[i:j], i)) + i = j + return out + + +def render_hunks(hunks: Sequence[Hunk]) -> str: + """검증 LLM 에 보여 줄 번호 목록. 개행은 ⏎ 로 보여 한 줄을 지킨다.""" + + def show(s: str) -> str: + return s.replace("\n", "⏎") + + lines = [ + f"#{h.id} …{show(h.left)}「{show(h.before) or '∅'}」→「{show(h.after) or '∅'}」{show(h.right)}…" + for h in hunks + ] + return "\n".join(lines) if lines else "(변경 없음)" + + +def revert_hunks( + current: str, hunks: Sequence[Mapping[str, Any]], ids: Sequence[Any] +) -> tuple[str, dict[str, Any]]: + """번호의 변경을 원문대로 되돌린다(현재 본문의 [start,end) ← before). 모르는 번호·겹침은 무시하고 보고한다.""" + by_id = {int(h["id"]): h for h in hunks if isinstance(h, Mapping) and "id" in h} + wanted: list[int] = [] + unknown: list[Any] = [] + for raw in ids: + try: + i = int(raw) + except (TypeError, ValueError): + unknown.append(raw) + continue + if i in by_id and i not in wanted: + wanted.append(i) + else: + unknown.append(raw) + chosen = sorted((by_id[i] for i in wanted), key=lambda h: int(h["start"]), reverse=True) + out = current + reverted = 0 + for h in chosen: + s, e = int(h["start"]), int(h["end"]) + if out[s:e] != str(h.get("after", "")): + unknown.append(int(h["id"])) # 앞 되돌림이 자리를 흔들었거나 본문이 바뀜 — 건너뛴다 + continue + before = str(h.get("before", "")) + if s == e and before and before != "\n" and e < len(out) and not out[e].isspace(): + before += " " # 지워진 어절을 다시 넣을 때 다음 어절과 붙지 않게 + out = out[:s] + before + out[e:] + reverted += 1 + return out, {"requested": len(ids), "reverted": reverted, "ignored": unknown} + + +def _digits(s: str) -> str: + return "".join(ch for ch in s if ch.isdigit()) + + +def apply_corrections( + text: str, + corrections: Sequence[Mapping[str, Any]], + *, + max_items: int = 50, + allow_delete: bool = False, + protect_digits: bool = False, + max_shrink: float | None = None, +) -> tuple[str, ApplyReport]: + norm_text, raw_index = _normalize(text) + spans: list[tuple[int, int, str]] = [] + not_found = ambiguous = overlap = invalid = protected = 0 + for c in list(corrections)[:max_items]: + original = c.get("original") if isinstance(c, Mapping) else None + corrected = c.get("corrected") if isinstance(c, Mapping) else None + if not isinstance(original, str) or not isinstance(corrected, str) or not original.strip(): + invalid += 1 + continue + if original == corrected or (not corrected.strip() and not allow_delete): + invalid += 1 + continue + if protect_digits and _digits(original) != _digits(corrected): + protected += 1 + continue + if max_shrink is not None and len(_normalize(corrected)[0]) < max_shrink * len( + _normalize(original)[0] + ): + protected += 1 + continue + loc = _locate(norm_text, raw_index, original) + if loc == "not_found": + not_found += 1 + continue + if loc == "ambiguous": + ambiguous += 1 + continue + s, e = loc # type: ignore[misc] + if any(s < e2 and s2 < e for s2, e2, _ in spans): + overlap += 1 + continue + spans.append((s, e, corrected)) + out = text + for s, e, rep in sorted(spans, key=lambda x: x[0], reverse=True): + out = out[:s] + rep + out[e:] + return out, ApplyReport(len(spans), not_found, ambiguous, overlap, invalid, protected) diff --git a/solar_eval/pipelines/pipeline.py b/solar_eval/pipelines/pipeline.py index 395320d1cb660fcf997fecc8f8f71f5d743c1181..81c7c10b6beb40eb75e1f3462c6d7a87cb03d923 100644 --- a/solar_eval/pipelines/pipeline.py +++ b/solar_eval/pipelines/pipeline.py @@ -14,13 +14,15 @@ rule 후처리 블록이 끼어드는 구조가 이 모델의 원형이다. """ import asyncio +import inspect import logging +import math import time from abc import ABC, abstractmethod +from collections.abc import Awaitable, Callable from dataclasses import dataclass, field from pathlib import Path -from collections.abc import Callable -from typing import Any +from typing import Any, ClassVar, Literal from solar_eval.core.dataset_loader import DatasetLoader from solar_eval.models.sample import EvalSample @@ -30,6 +32,53 @@ from solar_eval.providers.base import BaseProvider logger = logging.getLogger(__name__) +#: 자원 라벨의 종류 — `config:`(레포 config 정본 상대) · `data:`(데이터 루트 상대) · `hf:`(원격 선언). +ResourceKind = Literal["config", "data", "hf"] + +#: llm 스텝 분할 방식 — `PipelineEnv.text_split_mode` 가 받는 값. +TEXT_SPLIT_MODES = ("chars", "lines") + + +@dataclass(frozen=True) +class ResourceRef: + """스텝이 실제로 읽을 파일 하나의 기술 — run.json 스냅샷의 `resources` 재료. + + 순수 dataclass 다(파일을 읽지도 해시하지도 않는다). 해시는 `core/snapshot/resources.py` + 가 뜬다 — 이 모듈은 HF Space 벤더 사본도 import 하므로 poc-bundle 패키지에 기대면 안 된다 + (계획 2026-09-24-phase1a D18). + + Attributes: + kind: 라벨 종류. `hf` 는 네트워크를 타지 않으므로 `path` 가 None 이다. + rel: 라벨 본문 — `config` 는 config_dir 상대, `data` 는 데이터 루트 상대(풀 수 없으면 + 절대) POSIX 경로, `hf` 는 `/`. + path: 해시할 로컬 파일. `hf` 면 None. + """ + + kind: ResourceKind + rel: str + path: Path | None + + @property + def label(self) -> str: + return f"{self.kind}:{self.rel}" + + +def relative_posix(path: Path, root: Path | str | None) -> str: + """`root` 기준 POSIX 상대경로 — `ResourceRef.rel` 을 만드는 규칙. + + 둘 다 `resolve()` 한 뒤 비교한다(macOS 의 `/tmp` → `/private/tmp` 같은 심볼릭 링크 차이). + 풀 수 없으면(루트 밖·루트 없음) 절대 POSIX 경로다. `data:` 자원은 어차피 재현 판정에서 + `resource_outside_repo` 로 드러나고, `config:` 자원은 config_dir 밖으로 풀리는 파일을 + `rules._config_candidate` 가 먼저 거부하므로 절대경로 `config:` 라벨은 생기지 않는다. + """ + if root is not None: + try: + return path.resolve().relative_to(Path(root).resolve()).as_posix() + except ValueError: + pass + return path.resolve().as_posix() + + @dataclass class PipelineEnv: """파이프라인 생성 시점에 고정되는 정적 환경. 스텝 생성자에 전달된다.""" @@ -37,14 +86,6 @@ class PipelineEnv: prompts: dict[str, str] = field(default_factory=dict) input_fields: list[str] = field(default_factory=list) text_split_size: int = 500 - # bulk 분할 전략 -- "dp"(운영) | "greedy"(regex 사다리, ablation 전용). steps/chunking.py 참조. - text_split_strategy: str = "dp" - # 문서 단위 chunk (실험, 2026-09-10): 입구에서 한 번 DP 분할해 chunk 마다 **전 스텝**을 돌리고 원장으로 접합. - # 켜면 분할 없이 전문을 받던 스텝(basic_correction 의 user_template)도 chunk 의 원문만 본다. - document_chunking: bool = False - # 문서 단위 chunk 를 동시에 몇 개까지 돌릴지. chunk 마다 LLM 호출이 순차로 이어지므로 이 값 × 러너 워커 수가 - # 동시 API 호출 상한이다 (solar-pro4 는 10 동시부터 429 가 났다 -- README "지뢰"). - document_chunk_concurrency: int = 4 dataset_loader: DatasetLoader | None = None # 데이터 루트의 프로젝트 디렉토리. v24 의 tool_calling_judge 가 pmi_lookup # (1.5MB 파생 통계 — 사람이 편집하지 않는 데이터 자산) 을 풀 때 쓰인다. @@ -52,15 +93,32 @@ class PipelineEnv: # 레포의 config 정본 디렉토리(`accounts///03-evaluation`). 치환 사전처럼 # **사람이 편집하는 지식 자산**은 데이터가 아니라 설정이라 여기서 읽는다. config_dir: Path | None = None - - -# 진행 콜백 -- (event, index, total, step_name, doc_chunk). 데모 UI 가 "지금 어느 단계인가" 를 보여주는 용도, -# 결과에는 아무 영향이 없다. -# event: "start" | "done" (스텝), "chunk" (LLM 스텝의 bulk 진행), "document" (문서 단위 chunk 시작, step_name 은 "") -# doc_chunk: 문서 단위 chunk 모드에서 (chunk 번호, chunk 수), 아니면 None. chunk 들이 **병렬**로 돌므로 -# 이벤트가 섞여 들어온다 -- UI 는 이 값으로 줄을 나눠야 한다. -DocChunk = tuple[int, int] | None -StepCallback = Callable[[str, int, int, str, DocChunk], None] + # 전체 스텝 체인 공유 데드라인(초). None 이면 무제한 — 기존 동작 유지. + # run마다 새로 시작되며 스텝 사이에서 리셋되지 않는다. + timeout_seconds: float | None = None + # llm 스텝의 분할 방식. "chars" = text_split_size 글자마다 자른다(기존 동작 — 문장 중간에서도 끊긴다). + # "lines" = 개행 경계로만 묶는다(한 줄이 한도보다 길면 자르지 않고 그 줄 하나를 bulk 로). + text_split_mode: str = "chars" + + def __post_init__(self) -> None: + if self.text_split_mode not in TEXT_SPLIT_MODES: + raise ValueError( + f"text_split_mode must be one of {TEXT_SPLIT_MODES}, got {self.text_split_mode!r}" + ) + # None = 무제한. 그 외에는 "유한한 양수"만 받는다: bool 은 int 의 + # 서브클래스라 명시적으로 거부하고, inf/nan/음수/0 은 즉시 ValueError. + value = self.timeout_seconds + if value is None: + return + if ( + isinstance(value, bool) + or not isinstance(value, (int, float)) + or not math.isfinite(value) + or value <= 0 + ): + raise ValueError( + f"timeout_seconds must be None or a finite positive number, got {value!r}" + ) @dataclass @@ -83,8 +141,6 @@ class StepContext: usage: dict[str, int] = field( default_factory=lambda: {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0} ) - on_step: StepCallback | None = None - doc_chunk: DocChunk = None # 문서 단위 chunk 모드에서 이 컨텍스트가 담당하는 chunk def add_usage(self, usage: dict[str, Any]) -> None: for k in self.usage: @@ -100,11 +156,23 @@ class BaseStep(ABC): 모르는 이름은 API 호출 한 번 없이 즉시 실패한다. """ + #: 이 스텝이 모델을 호출하는가 — run.json `reproducibility.deterministic` 판정 재료 + #: (모든 스텝이 False 이고 평가기 judge 가 없으면 결정적 run 이다). + uses_model: ClassVar[bool] = False + def __init__(self, config: dict[str, Any], env: PipelineEnv) -> None: self.config = config self.env = env self.name: str = config.get("name", config.get("type", type(self).__name__)) + def resource_refs(self) -> list[ResourceRef]: + """이 스텝이 실행 중 읽을 지식 자산(사전·화이트리스트·PMI 등). + + 스냅샷이 run 시작 **전에** 부른다 — 선언했는데 못 찾는 자원은 여기서 + `KnowledgeAssetError` 로 죽는다(run 도중이 아니라). 자원이 없는 스텝은 기본값 []. + """ + return [] + @abstractmethod async def run(self, sample: EvalSample, ctx: StepContext) -> None: """`ctx.text` 를 변환하고/하거나 `sample.artifacts` 를 채운다.""" @@ -131,125 +199,89 @@ class Pipeline(BasePipeline): max_tokens: int = 8000, reasoning_effort: str | None = None, messages: list[dict[str, str]] | None = None, - on_step: StepCallback | None = None, + on_step: Callable[[str, EvalSample, StepContext], Awaitable[None] | None] | None = None, ) -> EvalSample: input_data = sample.input if isinstance(sample.input, dict) else {} original_text = input_data.get("original", "") + ctx = StepContext( + provider=provider, + model=model, + temperature=temperature, + max_tokens=max_tokens, + reasoning_effort=reasoning_effort, + system_prompt=prompts, + messages=messages, + original_text=original_text, + text=original_text, + shared=self._shared, + ) start = time.monotonic() + # 체인 전체에 걸리는 데드라인 하나 -- 스텝 사이에서 리셋되지 않는다. + # asyncio.timeout 은 취소를 스텝/프로바이더까지 전파해(예: BudgetedProvider, + # BatchRunner 의 실패 샘플 기록) 만료 후 어떤 성공 결과도 커밋되지 않게 한다. + timeout_seconds = self.env.timeout_seconds + deadline = start + timeout_seconds if timeout_seconds is not None else None - def make_ctx(text: str, doc_chunk: DocChunk = None) -> StepContext: - return StepContext( - provider=provider, - model=model, - temperature=temperature, - max_tokens=max_tokens, - reasoning_effort=reasoning_effort, - system_prompt=prompts, - messages=messages, - original_text=text, - text=text, - shared=self._shared, - on_step=on_step, - doc_chunk=doc_chunk, - ) - - if self.env.document_chunking and len(original_text) > self.env.text_split_size: - await self._run_by_document_chunks(sample, input_data, original_text, make_ctx, on_step) - else: - ctx = make_ctx(original_text) - await self._run_chain(sample, ctx, on_step) - sample.output = ctx.text - sample.artifacts["usage"] = ctx.usage - sample.artifacts["step_outputs"] = ctx.step_outputs - - sample.artifacts["inference_time_ms"] = (time.monotonic() - start) * 1000 - # completion 스텝은 자기 finish_reason 을 직접 남긴다 -- 없을 때만 기본값. - sample.artifacts.setdefault("finish_reason", "stop") - return sample - - async def _run_chain( - self, sample: EvalSample, ctx: StepContext, on_step: StepCallback | None - ) -> None: - """스텝 목록을 순서대로 한 번 돈다. `ctx.text` 가 결과, `ctx.step_outputs` 가 스냅숏.""" from datetime import datetime - for step_i, step in enumerate(self.steps): - ts = datetime.now().strftime("%H:%M:%S") - logger.info( - f" [{ts}] step {step_i + 1}/{len(self.steps)} " - f"'{step.name}' ({step.config.get('type', '?')}) start" - ) - if on_step: - on_step("start", step_i + 1, len(self.steps), step.name, ctx.doc_chunk) - try: - await step.run(sample, ctx) - except Exception as e: + step_timings: dict[str, float] = {} + + async def _run_chain() -> None: + for step_i, step in enumerate(self.steps): + if deadline is not None: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError( + f"pipeline timeout_seconds={timeout_seconds} expired " + f"before step {step_i + 1}/{len(self.steps)} '{step.name}'" + ) ts = datetime.now().strftime("%H:%M:%S") - logger.error( - f" [{ts}] step {step_i + 1} '{step.name}' FAILED: {type(e).__name__}: {e}" + logger.info( + f" [{ts}] step {step_i + 1}/{len(self.steps)} " + f"'{step.name}' ({step.config.get('type', '?')}) start" + ) + step_start = time.monotonic() + try: + await step.run(sample, ctx) + except Exception as e: + ts = datetime.now().strftime("%H:%M:%S") + logger.error( + f" [{ts}] step {step_i + 1} '{step.name}' FAILED: {type(e).__name__}: {e}" + ) + raise + ctx.step_outputs[step.name] = ctx.text + step_timings[step.name] = round((time.monotonic() - step_start) * 1000, 1) + # 스텝 완료 훅 - 데모·서빙이 중간 결과(교정 목록 등)를 단계별로 내보내는 용도. 평가 run 은 주지 않는다. + if on_step is not None: + maybe = on_step(step.name, sample, ctx) + if inspect.isawaitable(maybe): + await maybe + ts = datetime.now().strftime("%H:%M:%S") + logger.info( + f" [{ts}] step {step_i + 1} '{step.name}' done, text_len={len(ctx.text)}" + ) + # 동기 꼬리 작업이 데드라인을 넘겼다면 성공으로 조용히 돌려주지 않는다. + if deadline is not None and time.monotonic() > deadline: + raise TimeoutError( + f"pipeline timeout_seconds={timeout_seconds} expired before chain completed" ) - raise - ctx.step_outputs[step.name] = ctx.text - ts = datetime.now().strftime("%H:%M:%S") - logger.info(f" [{ts}] step {step_i + 1} '{step.name}' done, text_len={len(ctx.text)}") - if on_step: - on_step("done", step_i + 1, len(self.steps), step.name, ctx.doc_chunk) - - async def _run_by_document_chunks( - self, - sample: EvalSample, - input_data: dict[str, Any], - original_text: str, - make_ctx: Callable[[str, DocChunk], StepContext], - on_step: StepCallback | None, - ) -> None: - """문서를 입구에서 한 번 나눠 chunk 마다 전 스텝을 돌리고, 출력·스냅숏·사용량을 원장으로 합친다.""" - # steps/__init__ 가 llm.py 를 거쳐 이 모듈을 import 하므로 순환을 피해 지연 import - from solar_eval.pipelines.steps.chunking import join_chunks, plan_chunks - - plan = plan_chunks( - original_text, self.env.text_split_size, strategy=self.env.text_split_strategy - ) - n_chunks = len(plan.chunks) - limit = asyncio.Semaphore(max(1, int(self.env.document_chunk_concurrency))) - - async def run_chunk(ci: int, chunk: str) -> EvalSample: - # chunk 끼리는 독립이라 동시에 돈다 -- 캐시(self._shared)는 읽기 전용으로만 공유 - doc_chunk: DocChunk = (ci + 1, n_chunks) - async with limit: - if on_step: - on_step("document", ci + 1, n_chunks, "", doc_chunk) - sub = EvalSample(input={**input_data, "original": chunk}) - ctx = make_ctx(chunk, doc_chunk) - await self._run_chain(sub, ctx, on_step) - sub.output = ctx.text - sub.artifacts["usage"] = ctx.usage - sub.artifacts["step_outputs"] = ctx.step_outputs - return sub - - subs: list[EvalSample] = list( - await asyncio.gather(*(run_chunk(ci, c) for ci, c in enumerate(plan.chunks))) - ) - sample.output = join_chunks([sub.output for sub in subs], plan) - sample.artifacts["step_outputs"] = { - name: join_chunks([sub.artifacts["step_outputs"][name] for sub in subs], plan) - for name in subs[0].artifacts["step_outputs"] - } - usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0} - for sub in subs: - for k in usage: - usage[k] += sub.artifacts["usage"].get(k, 0) - sample.artifacts["usage"] = usage - if any(sub.artifacts.get("finish_reason", "stop") != "stop" for sub in subs): - sample.artifacts["finish_reason"] = "length" - corrections: dict[str, list[Any]] = {} - for sub in subs: - for step_name, items in sub.artifacts.get("corrections", {}).items(): - corrections.setdefault(step_name, []).extend(items) - if corrections: - sample.artifacts["corrections"] = corrections - sample.artifacts["document_chunks"] = { - "lens": [len(c) for c in plan.chunks], - "kinds": list(plan.kinds), - } + if timeout_seconds is None: + await _run_chain() + else: + # asyncio.timeout 은 만료 시점에 진행 중 await 을 취소하고 TimeoutError + # 로 바꾼다 -- 취소는 provider/BatchRunner 까지 전파된다. + async with asyncio.timeout(timeout_seconds): + await _run_chain() + + elapsed_ms = (time.monotonic() - start) * 1000 + + sample.output = ctx.text + sample.artifacts["usage"] = ctx.usage + sample.artifacts["inference_time_ms"] = elapsed_ms + # 스텝별 벽시계(ms) — 지연 예산(기사당 N초) 배분용. 동시 실행 중이면 API 대기까지 포함된 값이다. + sample.artifacts["step_timings_ms"] = step_timings + # completion 스텝은 자기 finish_reason 을 직접 남긴다 -- 없을 때만 기본값. + sample.artifacts.setdefault("finish_reason", "stop") + sample.artifacts["step_outputs"] = ctx.step_outputs + return sample diff --git a/solar_eval/pipelines/registry.py b/solar_eval/pipelines/registry.py index 3dc4ccb7501503a32abb2033d4306985e9a8407c..e77641fb75e489d4119ec0bd34fc4de758d3e90d 100644 --- a/solar_eval/pipelines/registry.py +++ b/solar_eval/pipelines/registry.py @@ -70,20 +70,28 @@ def _ensure_builtin_steps() -> None: from solar_eval.pipelines import steps # noqa: F401 +def step_key(config: dict[str, Any]) -> str: + """step config 의 레지스트리 조회 이름. + + `{type: X}` 는 X, legacy `{type: rule, rule_type: X}` 는 X(없으면 `name`). 스냅샷의 + `step_impl_hash` 가 같은 키를 쓴다 — 조회와 기록이 한 규칙을 공유한다. + """ + step_type = config.get("type") + if step_type == "rule": + return config.get("rule_type", config.get("name", "")) + return step_type or "" + + def resolve_step(config: dict[str, Any], env: PipelineEnv) -> BaseStep: """step config 하나를 등록된 스텝 인스턴스로 해석한다. 모르는 이름은 즉시 raise.""" _ensure_builtin_steps() - step_type = config.get("type") - if step_type == "rule": - # legacy 표기: {type: rule, rule_type: X}. 레지스트리에서 X 를 찾는다. - key = config.get("rule_type", config.get("name", "")) - cls = STEP_REGISTRY.get(key) - if cls is None: + key = step_key(config) + cls = STEP_REGISTRY.get(key) + if cls is None: + if config.get("type") == "rule": + # legacy 표기: {type: rule, rule_type: X}. 레지스트리에서 X 를 찾는다. raise UnknownRuleTypeError(key, available=available_steps()) - else: - cls = STEP_REGISTRY.get(step_type or "") - if cls is None: - raise UnknownStepTypeError(str(step_type), available=available_steps()) + raise UnknownStepTypeError(str(config.get("type")), available=available_steps()) return cls(config, env) @@ -113,12 +121,12 @@ def create_pipeline( prompts=prompts or {}, input_fields=input_fields, text_split_size=config.get("text_split_size", 500), - text_split_strategy=config.get("text_split_strategy", "dp"), - document_chunking=bool(config.get("document_chunking", False)), - document_chunk_concurrency=int(config.get("document_chunk_concurrency", 4)), + text_split_mode=config.get("text_split_mode", "chars"), dataset_loader=dataset_loader, project_dir=Path(project_dir) if project_dir else None, config_dir=Path(config_dir) if config_dir else None, + # 전체 체인 데드라인 -- 검증은 PipelineEnv.__post_init__ 이 한다. + timeout_seconds=config.get("timeout_seconds"), ) if pipeline_type == "single_step": diff --git a/solar_eval/pipelines/rules.py b/solar_eval/pipelines/rules.py index e8254806840337201b62455c880e45e5f997c7b2..db46fd6e89524ce623299b5c568d74fa2cf60565 100644 --- a/solar_eval/pipelines/rules.py +++ b/solar_eval/pipelines/rules.py @@ -1,6 +1,6 @@ """multi_step 파이프라인의 rule 스텝 구현체. -`multi_step.py` 가 518줄이라 (`.claude/rules/code-style.md` 의 400줄 권장선을 +`multi_step.py` 가 518줄이라 (`.agents/rules/code-style.md` 의 400줄 권장선을 넘어) 룰 구현만 따로 뽑아냈다. vocabulary/post_process/correction_filter 는 동작을 바꾸지 않고 `MultiStepPipeline._apply_*` 메서드에서 그대로 옮겨온 것이다 — 순수 텍스트 변환 로직이라 `self` 상태가 필요 없다(`apply_vocabulary` @@ -14,6 +14,7 @@ self-consistency 의 corrections 목록) `sample` 을 인자로 받는다 — from __future__ import annotations +import difflib import logging import re from pathlib import Path @@ -21,6 +22,7 @@ from typing import TYPE_CHECKING, Any from solar_eval.models.sample import EvalSample from solar_eval.pipelines.errors import KnowledgeAssetError +from solar_eval.pipelines.pipeline import ResourceRef, relative_posix if TYPE_CHECKING: from solar_eval.core.dataset_loader import DatasetLoader @@ -80,7 +82,7 @@ def _load_vocabulary( # 1. config 정본 (레포의 03-evaluation/) — 치환 사전은 설정이다. if config_dir is not None: - candidate = Path(config_dir) / vocab_path + candidate = _config_candidate(config_dir, vocab_path) searched.append(str(candidate)) if candidate.is_file() and dataset_loader is not None: return dataset_loader.load_csv(str(config_dir), vocab_path, source="local") @@ -100,6 +102,75 @@ def _load_vocabulary( raise KnowledgeAssetError(vocab_path, searched) +def _config_candidate(config_dir: Path | str, rel: str) -> Path: + """config 정본 기준 자산 경로 — 로더(`_load_vocabulary`)와 스냅샷 해석이 공유한다. + + config 자산은 config_dir **안**에 있어야 한다: `../` 나 밖을 가리키는 심볼릭 링크로 풀리는 + 파일은 커밋으로 복원되지 않고(dirty 범위 밖), 스냅샷 라벨이 기계마다 다른 절대경로가 된다. + 그런 파일이 실제로 있으면 쓰지 않고 `KnowledgeAssetError` 로 거부한다(로더·스냅샷 공통). + 없으면 경로만 돌려준다 — 호출자가 레거시 선언으로 넘어가거나 같은 오류를 낸다. + + Raises: + KnowledgeAssetError: 후보 파일이 있는데 실제 위치가 config_dir 밖일 때. + """ + candidate = Path(config_dir) / rel + if candidate.is_file(): + resolved = candidate.resolve() + root = Path(config_dir).resolve() + if not resolved.is_relative_to(root): + raise KnowledgeAssetError( + rel, [f"{candidate} → {resolved} (config_dir {root} 밖 — config 자산은 그 안에)"] + ) + return candidate + + +def resolve_vocabulary_path( + config: dict[str, Any], + dataset_loader: DatasetLoader | None, + config_dir: Path | None, +) -> ResourceRef | None: + """치환 사전 선언을 **읽지 않고** 어느 파일인지로 푼다 (run.json 스냅샷의 `resources`). + + `_load_vocabulary` 와 같은 순서를 따른다: config 정본 → 레거시 선언(repo/source). + 레거시 `local` 은 로더의 경로 규칙(`DatasetLoader._resolve_local_path`)을 그대로 쓰고 + 라벨은 데이터 루트 상대(`data:`), 원격(`huggingface` 등)은 네트워크를 타지 않고 + 선언만으로 `hf:/`(path None)를 낸다. + + Returns: + 선언이 없으면 None(정상적인 no-op). + + Raises: + KnowledgeAssetError: 선언했는데 로컬에서 찾을 수 없을 때 — 로더가 run 도중에 낼 + 오류를 run 시작 전으로 앞당긴다. + """ + vocab_path = config.get("vocab_dataset", "") + if not vocab_path: + return None + searched: list[str] = [] + if config_dir is not None: + candidate = _config_candidate(config_dir, vocab_path) + searched.append(str(candidate)) + if candidate.is_file() and dataset_loader is not None: + return ResourceRef("config", relative_posix(candidate, config_dir), candidate) + if dataset_loader is None: + searched.append("(dataset_loader 없음)") + raise KnowledgeAssetError(vocab_path, searched) + repo = config.get("repo", "") + source = config.get("source", "huggingface") + if source != "local": + if not repo: + # repo 없는 원격 선언은 로더가 run 도중 다운로드에 실패한다(prod_251231 의 옛 사고) — + # 스냅샷이 null 로 넘기지 않고 시작 전에 같은 오류로 죽인다. + searched.append(f"{source}:{vocab_path}") + raise KnowledgeAssetError(vocab_path, searched) + return ResourceRef("hf", f"{repo}/{vocab_path}", None) + local = dataset_loader._resolve_local_path(repo, vocab_path) + searched.append(str(local)) + if not local.is_file(): + raise KnowledgeAssetError(vocab_path, searched) + return ResourceRef("data", relative_posix(local, dataset_loader.base_dir), local) + + def apply_pronoun_postprocess(text: str, replacements: dict[str, str]) -> str: """대명사/직함 치환 + 한국어 조사 교정. @@ -312,25 +383,129 @@ def apply_change_rate_guard(text: str, original: str, config: dict[str, Any]) -> return text +_UNIT_SYMBOLS = { + "mm²": "㎟", + "cm²": "㎠", + "km²": "㎢", + "m²": "㎡", + "mm": "㎜", + "cm": "㎝", + "km": "㎞", + "mg": "㎎", + "kg": "㎏", + "ml": "㎖", + "dl": "㎗", + "cc": "㏄", +} +# 숫자(공백 하나까지 허용) 바로 뒤에 오고 뒤에 영문자가 이어지지 않는 단위만 바꾼다. 예전에는 글자열 어디서나 바꿔 +# `Grammy`·`Programme`·`Baccalaureate`·`Nadler`·메일 주소의 mm·cc·dl 까지 ㎜·㏄·㎗ 로 망가뜨렸다(2026-10-20 현업 사례 비교). +_UNIT_RE = re.compile( + r"(?<=\d)(\s?)(" + + "|".join(sorted(map(re.escape, _UNIT_SYMBOLS), key=len, reverse=True)) + + r")(?![A-Za-z])" +) + + def apply_unit_unicode(text: str) -> str: - """단위 텍스트를 유니코드 결합 문자로 변환한다.""" - replacements = { - "mm²": "㎟", - "cm²": "㎠", - "m²": "㎡", - "km²": "㎢", - "mm": "㎜", - "cm": "㎝", - "km": "㎞", - "mg": "㎎", - "kg": "㎏", - "ml": "㎖", - "dl": "㎗", - "cc": "㏄", - } - for k, v in replacements.items(): - text = text.replace(k, v) - return text + """숫자 뒤 단위 텍스트를 유니코드 결합 문자로 변환한다(`10mm`→`10㎜`, `5 kg`→`5 ㎏`).""" + return _UNIT_RE.sub(lambda m: m.group(1) + _UNIT_SYMBOLS[m.group(2)], text) + + +_QUOTE_KINDS = (("'‘’", "‘", "’"), ('"“”', "“", "”")) +_CLOSES_AFTER = ".,!?;%…’”" +# 앞 글자만으로는 여닫이를 정할 수 없는 기호: 같은 줄에 열린 따옴표가 있거나 뒤가 공백·줄 끝이면 닫는 쪽, 아니면 여는 쪽. +# `‘AA-’`·`‘송도는~’`·`‘개(犬)’` 은 닫고, 인터뷰 질문 머리 `-‘꽃’`·`[인터뷰]“딥 리딩”` 은 연다. +_AMBIGUOUS_BEFORE = "-+~)]}>」』" + + +def apply_quote_direction(text: str, original: str = "") -> str: + """둥근 따옴표의 여닫이 방향을 앞 글자와 같은 줄의 짝으로 다시 정한다. + + FT 교열 모델이 곧은 따옴표(')를 조선일보 표기인 둥근 따옴표로 바꾸면서 여는 자리에 닫는 따옴표(’)를 넣거나 한쪽만 바꾸는 + 사고를 낸다(2026-10-21 FT 약점 분석: 정답 밖 오교정의 약 1/4). 둥근 따옴표가 있는 줄에서만 그 줄의 같은 종류 따옴표를 + 모두 둥근 따옴표로 맞추고, 글자·숫자·닫는 문장부호 뒤면 닫는(’ ”), 그 밖(줄 처음·공백·`◇`·`▶` 같은 머리 기호·여는 괄호· + 여는 따옴표 뒤)은 여는(‘ “) 쪽으로 둔다. `-`·`+`·`~` 뒤는 같은 줄에 열린 따옴표가 있거나 뒤가 공백·줄 끝일 때만 닫는다. + 열린 작은따옴표 뒤 숫자에 붙은 프라임(′)은 FT 가 닫는 따옴표를 바꾼 것이라 ’ 로 되돌린다(`‘하야부사2′가`). 단 여는 + 따옴표부터 그 프라임까지가 `original` 에 그대로 있으면 둔다 - 조선일보 원문·게재본에는 `‘춘천사이로248′` 같은 표기가 + 있고 교열자도 고치지 않는다(2026-10-27 기사 셋 확인). + 둥근 따옴표가 없는 줄의 곧은 따옴표는 건드리지 않는다. + """ + lines = [] + for line in text.split("\n"): + chars = list(line) + for kinds, opening, closing in _QUOTE_KINDS: + if opening not in line and closing not in line: + continue + # 아직 닫히지 않은 여는 따옴표 자리(겹따옴표 `‘…(‘…’)’` 의 바깥쪽을 잃지 않도록) + opened: list[int] = [] + for i, ch in enumerate(chars): + prev = chars[i - 1] if i else "" + nxt = chars[i + 1] if i + 1 < len(chars) else "" + if ch == "′" and opening == "‘": + if opened and prev.isdigit() and not (nxt.isdigit() or nxt == "″"): + # 여는 따옴표 글자는 원문과 다를 수 있어(`’1988KY26′`) 안쪽 글자 + 프라임으로 원문을 찾는다. + inner = "".join(chars[opened.pop() + 1 : i + 1]) + chars[i] = ch if inner in original else closing + continue + if ch not in kinds: + continue + if prev and prev in _AMBIGUOUS_BEFORE: + closes = bool(opened) or not nxt or nxt.isspace() + else: + closes = bool(prev) and (prev.isalnum() or prev in _CLOSES_AFTER) + chars[i] = closing if closes else opening + if closes: + if opened: + opened.pop() + else: + opened.append(i) + lines.append("".join(chars)) + return "\n".join(lines) + + +# 따옴표 글자를 종류별 대표 글자로 접어, 따옴표 모양만 다른 자리를 같은 글자로 정렬한다(′ ″ 는 FT 가 닫는 따옴표 대신 넣는다). +_QUOTE_FOLD = str.maketrans({"‘": "'", "’": "'", "′": "'", "“": '"', "”": '"', "″": '"'}) + + +def apply_quote_preserve(text: str, original: str) -> str: + """원문에 있던 따옴표 글자를 원문 모양 그대로 되돌린다. + + 조선일보 교열자는 따옴표 모양(곧은·둥근·여닫이·프라임)을 고치지 않는다 - 기사 셋 508 · 일반 문단 541 의 원문 → 정답에서 + 따옴표 글자만 바뀐 자리는 0 이다. 반면 FT 는 곧은 따옴표를 둥근 따옴표로 바꾸고 방향·프라임 사고를 내서, FT+Opus 출력에는 + 따옴표만 바뀐 자리가 기사 셋 508편 중 78편에서 246곳 있었다(2026-10-27). 따옴표를 종류별로 접은 글자열끼리 정렬해 원문과 맞물리는 + 따옴표 자리만 원문 글자로 돌린다. 옆 글자 교정(`‘하야부사2호′`의 `호` 삭제)은 남고, 원문에 없던 따옴표는 그대로 둔다. + 글자 정렬은 줄 단위로 먼저 짝을 지은 뒤 바뀐 줄 묶음 안에서만 한다(기사 전체를 글자로 정렬하면 긴 기사에서 1초 가까이 걸린다). + """ + if not original or text == original: + return text + orig_lines, text_lines = original.split("\n"), text.split("\n") + out_lines = list(text_lines) + matcher = difflib.SequenceMatcher( + None, + [line.translate(_QUOTE_FOLD) for line in orig_lines], + [line.translate(_QUOTE_FOLD) for line in text_lines], + autojunk=False, + ) + for op, i1, i2, j1, j2 in matcher.get_opcodes(): + if op == "equal": + # 접은 줄이 같으면 따옴표 글자만 다르다 - 원문 줄 그대로. + out_lines[j1:j2] = orig_lines[i1:i2] + elif op == "replace": + block = _restore_quotes("\n".join(text_lines[j1:j2]), "\n".join(orig_lines[i1:i2])) + out_lines[j1:j2] = block.split("\n") + return "\n".join(out_lines) + + +def _restore_quotes(text: str, original: str) -> str: + out = list(text) + matcher = difflib.SequenceMatcher( + None, original.translate(_QUOTE_FOLD), text.translate(_QUOTE_FOLD), autojunk=False + ) + for i, j, size in matcher.get_matching_blocks(): + for k in range(size): + if original[i + k] != text[j + k] and original[i + k] in "'\"‘’“”′″": + out[j + k] = original[i + k] + return "".join(out) def load_compound_whitelist(config: dict[str, Any], config_dir: Path | None) -> set[str]: @@ -341,24 +516,39 @@ def load_compound_whitelist(config: dict[str, Any], config_dir: Path | None) -> 화이트리스트는 사람이 편집하는 지식 자산이라 config 정본(`03-evaluation/`) 기준 상대경로만 지원한다. """ - wl_path = config.get("whitelist_path", "") - if not wl_path: + ref = resolve_whitelist_path(config, config_dir) + if ref is None: return set() + if ref.path is None: # config 자산만 지원하므로 오지 않는다 — 온다면 해석 규칙이 바뀐 것 + raise KnowledgeAssetError(ref.rel, [ref.label]) + entries: set[str] = set() + for line in ref.path.read_text(encoding="utf-8").splitlines(): + form = line.split("\t")[0].strip() + if form: + entries.add(form) + return entries + + +def resolve_whitelist_path(config: dict[str, Any], config_dir: Path | None) -> ResourceRef | None: + """복합어 화이트리스트 선언을 파일로 푼다 — 로더(`load_compound_whitelist`)와 스냅샷이 공유. + Returns: + 선언이 없으면 None. + + Raises: + KnowledgeAssetError: 선언했는데 config 정본에 파일이 없을 때(또는 config_dir 이 없을 때). + """ + wl_path = config.get("whitelist_path", "") + if not wl_path: + return None searched: list[str] = [] if config_dir is not None: - candidate = Path(config_dir) / wl_path + candidate = _config_candidate(config_dir, wl_path) searched.append(str(candidate)) if candidate.is_file(): - entries: set[str] = set() - for line in candidate.read_text(encoding="utf-8").splitlines(): - form = line.split("\t")[0].strip() - if form: - entries.add(form) - return entries + return ResourceRef("config", relative_posix(candidate, config_dir), candidate) else: searched.append("(config_dir 없음)") - raise KnowledgeAssetError(wl_path, searched) diff --git a/solar_eval/pipelines/rules_josa.py b/solar_eval/pipelines/rules_josa.py deleted file mode 100644 index 467fc43284dd28e729595b40a198dd1a51e5f9ca..0000000000000000000000000000000000000000 --- a/solar_eval/pipelines/rules_josa.py +++ /dev/null @@ -1,176 +0,0 @@ -"""기호·숫자·단위 뒤 조사 선택 결정론 교정 — `josa_fix` rule_type 구현체. - -한국어 조사(이/가·을/를·은/는·과/와·로/으로·였/이었)는 앞말의 **발음상** -받침 유무를 따르는데, 앞말이 숫자·라틴 대문자·단위 기호·닫는 괄호/따옴표로 -끝나면 FT/LLM 이 일관되게 놓친다 (2026-09-17 chosun 풀셋 실측: gold 15건 중 -모델 적중 1건 — 예: `WSJ은→는`, `91㎏로→으로`, `차화정)이→가`). - -맞춤법 근거 두 가지가 anchor 결정을 가른다: -- **괄호 안 삽입어는 읽지 않는다** → 조사는 괄호 *앞말*의 발음을 따른다 - (`교섭단체(국민의힘)가`). 그래서 닫는 괄호를 만나면 여는 괄호 앞으로 건너뛴다. -- **따옴표 안은 읽는다** → 따옴표 안 *마지막 글자*의 발음을 따른다 - (`'MB.DRIVE'가`). 닫는 따옴표는 한 글자만 벗긴다. - -보수 가드: 순수 한글 어절(따옴표·괄호를 안 거친 한글 anchor)은 LLM 영역이라 -건드리지 않는다. 소문자 라틴(단어 독음 — iPhone=아이폰)과 R(-er 계 모음 독음)로 -끝나는 anchor 도 독음이 갈려서 제외한다. -""" - -from __future__ import annotations - -import re - -#: 닫는 따옴표류 — 이 문자는 발음되지 않으므로 벗기고 안쪽 글자를 anchor 로 삼는다. -_CLOSING_QUOTES = "'\"’”′″´`" - -#: 교정 대상 조사 표면형 → (받침형, 무받침형). 로/으로는 ㄹ받침 예외를 별도 처리. -_JOSA_PAIRS: dict[str, tuple[str, str]] = { - "이": ("이", "가"), - "가": ("이", "가"), - "을": ("을", "를"), - "를": ("을", "를"), - "은": ("은", "는"), - "는": ("은", "는"), - "과": ("과", "와"), - "와": ("과", "와"), - "으로": ("으로", "로"), - "로": ("으로", "로"), - "이었던": ("이었던", "였던"), - "였던": ("이었던", "였던"), - "이었다": ("이었다", "였다"), - "였다": ("이었다", "였다"), -} - -#: 어절 = stem + 조사 + 꼬리 문장부호. 긴 표면형을 먼저 시도한다 (으로 > 로). -_TOKEN_RE = re.compile( - r"^(?P.+?)(?P이었던|였던|이었다|였다|으로|이|가|을|를|은|는|과|와|로)" - r"(?P[.,!?;:·…]*)$" -) - -#: 숫자 독음의 받침 유무 (영일이삼사오육칠팔구) / ㄹ받침 숫자 (일·칠·팔). -_DIGIT_BATCHIM = { - "0": True, - "1": True, - "2": False, - "3": True, - "4": False, - "5": False, - "6": True, - "7": True, - "8": True, - "9": False, -} -_DIGIT_RIEUL = frozenset("178") - -#: 라틴 대문자 낱자 독음에서 받침이 있는 것 (엘·엠·엔). R(알)은 단어 독음 -#: (-er = 어)과 갈려서 아예 제외한다. -_LATIN_BATCHIM = frozenset("LMN") - -#: 단위 기호 독음의 받침 유무 — 그램 계열(ㅁ)만 받침이 있다. ASCII 'g'/'m' 은 -#: 영어 단어 꼬리(Dream 등)와 구분이 안 돼 넣지 않는다 — post_process 의 -#: unit_unicode 가 kg→㎏ 변환을 이 스텝 앞에서 이미 해 준다. -_UNIT_BATCHIM = { - "㎏": True, - "㎎": True, - "㎍": True, - "㎜": False, - "㎝": False, - "㎞": False, - "㎖": False, - "㎗": False, - "ℓ": False, - "㏄": False, - "%": False, - "℃": False, - "℉": False, - "㎡": False, - "㎢": False, - "㎟": False, - "㎠": False, -} - -_MAX_SKIPS = 8 # 중첩 괄호/따옴표 스킵 한도 — 비정상 입력 무한루프 방지 - - -def _phoneme_of(ch: str, skipped: bool) -> tuple[bool, bool] | None: - """anchor 글자의 (받침 유무, ㄹ받침 여부). 판정 불가/제외 대상이면 None.""" - if "가" <= ch <= "힣": - if not skipped: - return None # 순수 한글 어절은 LLM 영역 — 괄호/따옴표를 거쳤을 때만 - jong = (ord(ch) - 0xAC00) % 28 - return (jong != 0, jong == 8) - if ch.isdigit() and ch in _DIGIT_BATCHIM: - return (_DIGIT_BATCHIM[ch], ch in _DIGIT_RIEUL) - if "A" <= ch <= "Z": - if ch == "R": - return None - return (ch in _LATIN_BATCHIM, False) - if ch in _UNIT_BATCHIM: - return (_UNIT_BATCHIM[ch], False) - return None # 소문자 라틴(단어 독음), 미지 기호 - - -def _resolve_anchor(line: str, stem_end: int) -> tuple[bool, bool] | None: - """stem 끝에서 왼쪽으로 닫는 따옴표/괄호를 건너뛰며 발음 anchor 를 찾는다.""" - i = stem_end - 1 - skipped = False - for _ in range(_MAX_SKIPS): - while i >= 0 and line[i].isspace(): - i -= 1 - if i < 0: - return None - ch = line[i] - if ch in _CLOSING_QUOTES: - i -= 1 # 따옴표 안은 읽는다 — 한 글자만 벗긴다 - skipped = True - continue - if ch in ")]": - opener = "(" if ch == ")" else "[" - depth, j = 1, i - 1 - while j >= 0 and depth: - if line[j] == ch: - depth += 1 - elif line[j] == opener: - depth -= 1 - j -= 1 - if depth: - return None # 짝 없는 괄호 — 판정 포기 - i = j # 괄호 안은 읽지 않는다 — 여는 괄호 앞말로 - skipped = True - continue - return _phoneme_of(ch, skipped) - return None - - -def _fixed_josa(josa: str, batchim: bool, rieul: bool) -> str: - """anchor 발음에 맞는 조사 표면형. 로/으로는 ㄹ받침도 '로'를 취한다.""" - with_b, without_b = _JOSA_PAIRS[josa] - if with_b == "으로": - return without_b if (not batchim or rieul) else with_b - return with_b if batchim else without_b - - -def apply_josa_fix(text: str) -> str: - """`josa_fix` rule_type 구현 — 줄 단위로 어절을 훑어 조사를 교정한다.""" - return "\n".join(_fix_line(line) for line in text.split("\n")) - - -def _fix_line(line: str) -> str: - edits: list[tuple[int, int, str]] = [] - for m in re.finditer(r"\S+", line): - tok = m.group() - tm = _TOKEN_RE.match(tok) - if not tm: - continue - josa = tm.group("josa") - stem = tm.group("stem") - anchor = _resolve_anchor(line, m.start() + len(stem)) - if anchor is None: - continue - fixed = _fixed_josa(josa, *anchor) - if fixed != josa: - start = m.start() + len(stem) - edits.append((start, start + len(josa), fixed)) - for start, end, fixed in reversed(edits): - line = line[:start] + fixed + line[end:] - return line diff --git a/solar_eval/pipelines/rules_revert.py b/solar_eval/pipelines/rules_revert.py deleted file mode 100644 index 1c450d69afba8547ff44ad8590a3457c7a991923..0000000000000000000000000000000000000000 --- a/solar_eval/pipelines/rules_revert.py +++ /dev/null @@ -1,121 +0,0 @@ -"""원문 대비 diff 를 클래스 판정해 되돌리는 정밀도 가드 — `diff_revert` rule_type. - -파이프라인(FT·LLM 스텝)이 만드는 오탐 중 큰 덩어리가 **기계적**이다 -(2026-09-17 chosun C1 풀셋 실측 — paragraph 오탐 410건 중): -- 따옴표 글리프 변환 77건: LLM 이 텍스트를 다시 타이핑하며 `" '` 유니코드 - 따옴표를 ASCII 로 바꾼다. gold 가 이런 수정을 하는 경우는 5건뿐. -- 쉼표 삽입 14건: `90일로` -> `90일로,`. gold 쪽 같은 유형 0건. -- 금지 치환·격식 전환: `대통령실 -> 청와대`(기관 현재명을 과거명으로), - `-다. -> -습니다.`(경어 전환). 교열의 권한 밖. - -원문과 현재 텍스트를 어절 단위로 정렬(difflib)해 바뀐 span 마다 위 클래스에 -해당하면 원문 span 으로 되돌린다. 화이트리스트 복원(v19/v32)과 같은 계보의 -"파이프라인이 만든 변경만 되돌리는" 규칙이라 새 교정을 만들지 않는다. - -config (전부 선택; 기본은 아래): - glyph_restore: true # 글리프-only 치환 되돌림 - comma_insertion_revert: true - forbidden_insertions: [] # 이 문자열을 새로 들여오는 치환 되돌림 (예: [청와대]) - register_guard: false # 평서문 -> 경어(-습니다/-에요) 전환 되돌림 - punct_deletion_revert: false # 원문의 따옴표/불릿 문자를 지운 span 되돌림 -""" - -from __future__ import annotations - -import difflib -import re -from collections import Counter -from typing import Any - -#: 줄바꿈 보존용 sentinel — 어절 분할 전 개행을 이 토큰으로 바꾼다. -#: NUL 은 뉴스 텍스트에 등장하지 않는다. -_NL = chr(0) - -#: 따옴표류 글리프 정규화 — 이것'만' 같아지는 치환은 글리프 변환이다. -#: NFKC 는 쓰지 않는다: 단위 문자(㎏ 등)까지 접어서 post_process 의 의도적 -#: unit_unicode 변환을 되돌려 버린다. -_GLYPH_TRANS = str.maketrans( - { - "“": '"', - "”": '"', - "„": '"', - "″": '"', - "‘": "'", - "’": "'", - "‚": "'", - "′": "'", - "´": "'", - "`": "'", - } -) - -# -습니다/-ㅂ니다(입니다·갑니다 등)·해요체. '아니다'는 평서형이라 제외한다. -_REGISTER_RE = re.compile(r"(? str: - return s.translate(_GLYPH_TRANS) - - -def _tokens(text: str) -> list[str]: - return text.replace("\n", f" {_NL} ").split() - - -def _join(tokens: list[str]) -> str: - return re.sub(f" ?{re.escape(_NL)} ?", "\n", " ".join(tokens)) - - -def _should_revert(orig: str, new: str, config: dict[str, Any]) -> bool: - """바뀐 span (orig -> new, 어절 join 문자열) 이 되돌림 클래스인지 판정.""" - if orig and new: - if config.get("glyph_restore", True) and _norm_glyph(orig) == _norm_glyph(new): - return True - if ( - config.get("comma_insertion_revert", True) - and new.replace(",", "") == orig.replace(",", "") - and new.count(",") > orig.count(",") - ): - return True - if config.get("punct_deletion_revert", False) and orig and new: - removed = Counter(orig) - Counter(new) - added = Counter(new) - Counter(orig) - if removed and not added and set(removed) <= _STRUCTURAL_MARKS: - return True - for forbidden in config.get("forbidden_insertions") or (): - if forbidden in new and forbidden not in orig: - return True - return bool( - config.get("register_guard", False) - and _REGISTER_RE.search(new) - and not _REGISTER_RE.search(orig) - ) - - -def apply_diff_revert(text: str, original: str, config: dict[str, Any]) -> str: - """`diff_revert` rule_type 구현 — 클래스 판정된 diff 를 원문으로 되돌린다.""" - orig_tokens = _tokens(original) - out_tokens = _tokens(text) - matcher = difflib.SequenceMatcher(None, orig_tokens, out_tokens, autojunk=False) - - rebuilt: list[str] = [] - for tag, i1, i2, j1, j2 in matcher.get_opcodes(): - if tag == "equal": - rebuilt.extend(out_tokens[j1:j2]) - continue - o_toks = orig_tokens[i1:i2] - n_toks = out_tokens[j1:j2] - if len(o_toks) == len(n_toks): - # difflib 은 연속 변경 어절을 한 블록으로 합친다 — 글리프 변환과 - # 실제 교정이 섞인 블록을 통째로 판정하면 둘 다 놓치므로 쌍 단위로. - for o, n in zip(o_toks, n_toks, strict=True): - rebuilt.append(o if _should_revert(o, n, config) else n) - elif _should_revert(" ".join(o_toks), " ".join(n_toks), config): - rebuilt.extend(o_toks) - else: - rebuilt.extend(n_toks) - return _join(rebuilt) diff --git a/solar_eval/pipelines/steps/__init__.py b/solar_eval/pipelines/steps/__init__.py index 026320a2fd89dbcda7a7d1ebee45fa830baa72e5..a0f0d9720aab812aa1b0fe5838d6db16a4ae5c34 100644 --- a/solar_eval/pipelines/steps/__init__.py +++ b/solar_eval/pipelines/steps/__init__.py @@ -6,6 +6,7 @@ from solar_eval.pipelines.steps import ( # noqa: F401 completion, + corrections_step, judge_step, llm, rule_steps, diff --git a/solar_eval/pipelines/steps/__pycache__/__init__.cpython-312.pyc b/solar_eval/pipelines/steps/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2971efac1ff2890450c10b071f53b8f2668bcabf Binary files /dev/null and b/solar_eval/pipelines/steps/__pycache__/__init__.cpython-312.pyc differ diff --git a/solar_eval/pipelines/steps/__pycache__/completion.cpython-312.pyc b/solar_eval/pipelines/steps/__pycache__/completion.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..958c07078912be117e528a09deb7aab7c1a4028b Binary files /dev/null and b/solar_eval/pipelines/steps/__pycache__/completion.cpython-312.pyc differ diff --git a/solar_eval/pipelines/steps/__pycache__/corrections_step.cpython-312.pyc b/solar_eval/pipelines/steps/__pycache__/corrections_step.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a0f4fd47b174915e7c541eb2d4bb7539fe8c3360 Binary files /dev/null and b/solar_eval/pipelines/steps/__pycache__/corrections_step.cpython-312.pyc differ diff --git a/solar_eval/pipelines/steps/__pycache__/judge_step.cpython-312.pyc b/solar_eval/pipelines/steps/__pycache__/judge_step.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..57aa47b71cf8a474446b077af6b8528ebe19c64b Binary files /dev/null and b/solar_eval/pipelines/steps/__pycache__/judge_step.cpython-312.pyc differ diff --git a/solar_eval/pipelines/steps/__pycache__/llm.cpython-312.pyc b/solar_eval/pipelines/steps/__pycache__/llm.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..11ad85480e760eb61fe293fe16504abdd0bbe542 Binary files /dev/null and b/solar_eval/pipelines/steps/__pycache__/llm.cpython-312.pyc differ diff --git a/solar_eval/pipelines/steps/__pycache__/rule_steps.cpython-312.pyc b/solar_eval/pipelines/steps/__pycache__/rule_steps.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..52278a6747d67fd3cda798e7cbd3ce00ca269f2e Binary files /dev/null and b/solar_eval/pipelines/steps/__pycache__/rule_steps.cpython-312.pyc differ diff --git a/solar_eval/pipelines/steps/chunking.py b/solar_eval/pipelines/steps/chunking.py deleted file mode 100644 index 65de71a788ee4340b0e72e59ffbbecb62e23b1ec..0000000000000000000000000000000000000000 --- a/solar_eval/pipelines/steps/chunking.py +++ /dev/null @@ -1,294 +0,0 @@ -"""bulk 분할 계획 -- 상한 안에서 문단 > 문장 > 어절 경계만 자르고, 구분자를 원장으로 남겨 접합 때 복원. - -왜 이 모듈인가: 옛 `_split_text` 는 500번째 **문자**에서 무조건 잘랐다. 절단면이 공백이면 -`.rstrip()` + `"".join` 이 공백을 삼키고(`미국 제너럴다이내믹스`→`미국제너럴다이내믹스`), 어절 -중간이면 모델이 뒤 조각을 완성하거나(`들이`→`이들이`) 앞 조각을 버렸고(`이뤄졌다. 지난`→ -`이뤄졌다.난`), `\\n`→`∥PARA∥` 왕복은 마커 한가운데를 잘라 리터럴을 출력에 남겼다. 상세는 -`accounts/chosun/body-proofread/03-evaluation/insights/lab_notes_archive.md#260910_long_article_regression_set`. - -왜 greedy(상한까지 채우고 가장 가까운 경계) 가 아니라 DP 인가: -- greedy 는 꼬리 chunk 를 수십 자로 남긴다 (1022자 → 500/500/22). 조각만 받은 모델은 헛손질한다. -- 따옴표가 열린 채 끝나는 문장 경계는 약하다 -- 뒤 chunk 가 닫는 따옴표로 시작하면 모델이 "고친다". - 후보 경계마다 종류별 비용을 두고 chunk 수·짧은 chunk 벌점까지 합쳐 전체 최소를 고르면 둘 다 풀린다. - -상한(`max_len`)은 파인튜닝 모델이 잘 도는 구간이라 **절대 넘기지 않는다**. 상한 이하 텍스트는 손대지 -않고 그대로 1개 chunk 로 돌려준다 (기존 평가셋 결과가 바이트 단위로 보존되는 조건). -""" - -from __future__ import annotations - -import math -import re -from dataclasses import dataclass - -WS_RUN = re.compile(r"\s+") -SENTENCE_END = ".!?…。!?" -# 문장 끝 부호 뒤에 붙을 수 있는 닫는 기호 -- `했다.”`, `있다)`, `한다.’` 도 문장 경계로 본다 -TRAILING_CLOSERS = "”’\"')]」』》〉" - -# 비용은 "선호 순서와 교환 비율" 이다. v2 (2026-09-10 ablation 반영): 문단 정렬이 호출 1회보다 우선한다 -- -# v1(chunk 1.0 > sent 0.15) 은 호출을 아끼려 문장 경계로 ~450자까지 채웠는데, 그 큰 다문단 chunk 가 -# 문단 단위 chunk 보다 recall 을 5점 깎았다(long-article 매칭 ΔF1 -1.4 vs greedy +0.4). 어절 절단·꼬리· -# 따옴표 안 절단 회피는 그대로다. -KIND_COST: dict[str, float] = { - "para": 0.0, # 문단 경계 (공백 런에 개행 포함) -- 공짜. 문단이 상한에 들어가는 한 문단째로 자른다 - "sent": 0.5, # 문장 끝 부호 + 공백 -- 호출 하나(0.1)보다 비싸다: 문단을 쪼개느니 chunk 를 하나 더 쓴다 - "quoted_sent": 1.0, # 따옴표/괄호가 열린 채 만난 문장 끝 -- 뒤 chunk 가 닫는 기호로 시작한다 - "word": 3.0, # 어절 사이 공백 -- 문장 한가운데 - "char": 5.0, # 공백 없는 런이 상한보다 길 때만 쓰는 강제 절단 -} -CHUNK_COST = 0.1 # chunk 하나 = LLM 호출 하나. 같은 경계 품질이면 호출이 적은 쪽 (tie-breaker 수준) -SHORT_PENALTY = 3.0 # `min_len` 미만 chunk 벌점 계수 (부족 비율의 제곱에 곱한다) -- 꼬리 방지 -DEFAULT_MIN_LEN = 150 - - -@dataclass(frozen=True) -class Boundary: - """chunk 사이 경계. `text[start:end]` 가 구분자(공백 런, 강제 절단이면 빈 문자열).""" - - start: int - end: int - kind: str - - -@dataclass(frozen=True) -class ChunkPlan: - """`join_chunks(plan.chunks, plan)` 이 원문을 그대로 돌려주는 분할 계획.""" - - chunks: list[str] - seps: list[str] # chunks 와 같은 길이. seps[i] 는 chunks[i] 뒤 구분자, 마지막은 trailing 공백 - kinds: list[str] # 내부 경계 종류 (len == len(chunks) - 1) - lead: str # 원문 leading 공백 - - @property - def is_split(self) -> bool: - return len(self.chunks) > 1 - - -STRATEGIES = ("dp", "greedy") - - -def plan_chunks( - text: str, max_len: int, min_len: int = DEFAULT_MIN_LEN, strategy: str = "dp" -) -> ChunkPlan: - """`text` 를 `max_len` 이하 chunk 들로 나누는 계획을 세운다. 상한 이하면 그대로 1개. - - `strategy="greedy"` 는 "상한까지 채우고 그 안에서 가장 뒤의 좋은 경계" 라는 단순 사다리 — - DP 와의 ablation 용이지 운영값이 아니다 (꼬리 chunk · 따옴표 안 절단을 못 막는다). - """ - if max_len <= 0: - raise ValueError(f"max_len 은 양수여야 한다: {max_len}") - if strategy not in STRATEGIES: - raise ValueError(f"strategy 는 {STRATEGIES} 중 하나여야 한다: {strategy!r}") - if len(text) <= max_len: - return ChunkPlan(chunks=[text], seps=[""], kinds=[], lead="") - - lead_len = len(text) - len(text.lstrip()) - body = text.strip() - lead, trail = text[:lead_len], text[lead_len + len(body) :] - if len(body) <= max_len: - return ChunkPlan(chunks=[body], seps=[trail], kinds=[], lead=lead) - - boundaries = _candidate_boundaries(body) - boundaries = _add_forced_cuts(body, boundaries, max_len) - if strategy == "greedy": - cut_ids = _solve_greedy(boundaries, len(body), max_len) - else: - cut_ids = _solve(body, boundaries, max_len, min_len) - - chunks, seps, kinds = [], [], [] - pos = 0 - for b in (boundaries[i] for i in cut_ids): - chunks.append(body[pos : b.start]) - seps.append(body[b.start : b.end]) - kinds.append(b.kind) - pos = b.end - chunks.append(body[pos:]) - seps.append(trail) - return ChunkPlan(chunks=chunks, seps=seps, kinds=kinds, lead=lead) - - -def join_chunks(outputs: list[str], plan: ChunkPlan) -> str: - """LLM 을 거친 chunk 출력들을 원장의 구분자로 다시 잇는다. 출력 가장자리 공백은 모델 잡음이라 버린다.""" - if len(outputs) != len(plan.chunks): - raise ValueError(f"chunk 수 불일치: outputs={len(outputs)} plan={len(plan.chunks)}") - if not plan.is_split: - return outputs[0] - return plan.lead + "".join( - out.strip() + sep for out, sep in zip(outputs, plan.seps, strict=True) - ) - - -# ── 출력 위생 ──────────────────────────────────────────────────────────── - -# 토큰 앞뒤의 공백·개행까지 흡수한다 -- 모델이 토큰과 개행을 같이 내면 개행이 겹친다 (실측: 문단 +1) -SEPARATOR_LITERAL = re.compile(r"\s*\s*") -LITERAL = "" -ANCHOR_LEN = 20 - - -def normalize_separator_literals(text: str, source: str | None = None) -> str: - """모델이 지어낸 `` 리터럴을 그 자리의 원래 구분자로 되돌린다. - - step1~3 프롬프트는 이 토큰으로 문단 경계를 표시하라고 하지만 엔진은 주입도 제거도 하지 않는다 - (계약 불일치 -- insight 260910 참조). 그래서 모델이 가끔 토큰을 그대로 출력에 남기고, 문단 정렬 - chunk(DP v2) 에서 빈도가 올라간다(실기사 6건 run 당 3건). 계약을 (A) 주입/복원으로 살리든 (B) - 프롬프트에서 빼든, 최종 텍스트에 리터럴이 남는 건 어느 쪽에서도 옳지 않다 -- 이 가드는 그 결정과 - 독립이다. - - 무엇으로 바꾸나: `source`(그 chunk 의 입력)가 있으면 리터럴 직전 20자를 source 에서 찾아 그 뒤에 오는 - 원래 공백 런을 쓴다 -- 원문이 개행이면 개행, 문장 사이 공백이면 공백. 모델이 문장 사이에 토큰을 - 지어냈을 때 무조건 개행으로 바꾸면 문단이 늘어난다(실측: 기사 2건에서 +3·+2 문단). 앵커를 못 찾으면 - 개행(토큰의 계약상 의미). - """ - if LITERAL not in text: - return text - if source is None: - return SEPARATOR_LITERAL.sub("\n", text) - parts = SEPARATOR_LITERAL.split(text) - out = parts[0] - for nxt in parts[1:]: - out += _original_separator(source, out) + nxt - return out - - -def _original_separator(source: str, left: str) -> str: - anchor = left[-ANCHOR_LEN:].rstrip() - if anchor: - i = source.rfind(anchor) - if i >= 0: - m = WS_RUN.match(source, i + len(anchor)) - if m: - return "\n" if ("\n" in m.group() or "\r" in m.group()) else " " - return " " # 원문엔 공백조차 없던 자리 -- 붙여 쓰진 않고 최소 구분자 - return "\n" - - -# ── 후보 경계 ──────────────────────────────────────────────────────────── - - -def _candidate_boundaries(body: str) -> list[Boundary]: - open_depth = _open_quote_depth(body) - out: list[Boundary] = [] - for m in WS_RUN.finditer(body): - start, end = m.span() - if start == 0 or end == len(body): - continue # strip() 했으므로 없지만 방어 - if "\n" in m.group() or "\r" in m.group(): - kind = "para" - elif _ends_sentence(body, start, end): - kind = "quoted_sent" if open_depth[start] > 0 else "sent" - else: - kind = "word" - out.append(Boundary(start, end, kind)) - return out - - -def _ends_sentence(body: str, ws_start: int, ws_end: int) -> bool: - i = ws_start - 1 - while i >= 0 and body[i] in TRAILING_CLOSERS: - i -= 1 - if i < 0 or body[i] not in SENTENCE_END: - return False - # `3. 4.` 같은 번호 매김 -- 숫자 사이의 마침표는 문장 끝이 아니다 - numbered = ( - body[i] == "." and i > 0 and body[i - 1].isdigit() and body[ws_end : ws_end + 1].isdigit() - ) - return not numbered - - -def _open_quote_depth(body: str) -> list[int]: - """각 위치 직전까지 열려 있는 따옴표/괄호 수. 곧은따옴표(")는 홀수 개면 열린 것으로 본다.""" - pairs = {"“": 1, "”": -1, "‘": 1, "’": -1, "(": 1, ")": -1, "「": 1, "」": -1} - depth: list[int] = [0] * (len(body) + 1) - curly = 0 - straight = 0 - for i, ch in enumerate(body): - depth[i] = max(curly, 0) + straight - if ch == '"': - straight ^= 1 - else: - curly += pairs.get(ch, 0) - depth[len(body)] = max(curly, 0) + straight - return depth - - -def _add_forced_cuts(body: str, boundaries: list[Boundary], max_len: int) -> list[Boundary]: - """공백 없는 런이 `max_len` 보다 길면 그 안에 강제 절단점을 넣는다 (URL 덩어리 같은 예외).""" - anchors = [0] + [b.end for b in boundaries] - starts = [b.start for b in boundaries] + [len(body)] - forced: list[Boundary] = [] - for prev_end, next_start in zip(anchors, starts, strict=True): - pos = prev_end + max_len - while pos < next_start: - forced.append(Boundary(pos, pos, "char")) - pos += max_len - return sorted(boundaries + forced, key=lambda b: b.start) - - -# ── DP ─────────────────────────────────────────────────────────────────── - - -def _solve(body: str, boundaries: list[Boundary], max_len: int, min_len: int) -> list[int]: - """전체 비용 최소인 경계 부분집합(인덱스, 오름차순)을 고른다. - - 노드 0 = 본문 시작, 노드 k(1..K) = boundaries[k-1], 노드 K+1 = 본문 끝. - chunk(i→j) 비용 = CHUNK_COST + 짧은 chunk 벌점 + (j 가 내부 경계면) 경계 종류 비용. - """ - n_b = len(boundaries) - node_end = [0] + [b.end for b in boundaries] # chunk 시작 위치 (노드별) - node_start = [b.start for b in boundaries] + [len(body)] # chunk 끝 위치 (노드 1..K+1) - best = [math.inf] * (n_b + 2) - back = [-1] * (n_b + 2) - best[0] = 0.0 - for j in range(1, n_b + 2): - end_pos = node_start[j - 1] - edge_cost = KIND_COST[boundaries[j - 1].kind] if j <= n_b else 0.0 - for i in range(j - 1, -1, -1): - length = end_pos - node_end[i] - if length > max_len: - break - if length <= 0 or best[i] == math.inf: - continue - cost = best[i] + CHUNK_COST + _short_penalty(length, min_len) + edge_cost - if cost < best[j]: - best[j], back[j] = cost, i - if best[n_b + 1] == math.inf: - raise RuntimeError("분할 계획 실패 -- 강제 절단점이 부족하다 (버그)") - cut_ids: list[int] = [] - j = n_b + 1 - while back[j] > 0: - j = back[j] - cut_ids.append(j - 1) - return sorted(cut_ids) - - -GREEDY_LADDER = (("para",), ("sent", "quoted_sent"), ("word",), ("char",)) - - -def _solve_greedy(boundaries: list[Boundary], n: int, max_len: int) -> list[int]: - """regex 사다리 재현: 창(pos, pos+max_len] 안에서 문단 > 문장 > 어절 > 강제 순으로, 가장 뒤 경계.""" - cut_ids: list[int] = [] - pos = 0 - while n - pos > max_len: - window = [i for i, b in enumerate(boundaries) if pos < b.start <= pos + max_len] - pick = next( - ( - cands[-1] - for kinds in GREEDY_LADDER - if (cands := [i for i in window if boundaries[i].kind in kinds]) - ), - None, - ) - if pick is None: - raise RuntimeError("greedy 분할 실패 -- 강제 절단점이 부족하다 (버그)") - cut_ids.append(pick) - pos = boundaries[pick].end - return cut_ids - - -def _short_penalty(length: int, min_len: int) -> float: - if min_len <= 0 or length >= min_len: - return 0.0 - deficit = 1.0 - length / min_len - return SHORT_PENALTY * deficit * deficit diff --git a/solar_eval/pipelines/steps/completion.py b/solar_eval/pipelines/steps/completion.py index a1d26e3e218ab6e2e1398fdb159204ecfc5e8f07..a72e9f624cb4a51109a508e70f6872496b40e0a4 100644 --- a/solar_eval/pipelines/steps/completion.py +++ b/solar_eval/pipelines/steps/completion.py @@ -19,6 +19,8 @@ logger = logging.getLogger(__name__) class LLMCompletionStep(BaseStep): """Single LLM call step. Used for translation and summarization.""" + uses_model = True + async def run(self, sample: EvalSample, ctx: StepContext) -> None: input_data = self._input_fields_dict(sample.input) diff --git a/solar_eval/pipelines/steps/corrections_step.py b/solar_eval/pipelines/steps/corrections_step.py new file mode 100644 index 0000000000000000000000000000000000000000..8acdfee6e32968102a6425ccf3b78beb57d55098 --- /dev/null +++ b/solar_eval/pipelines/steps/corrections_step.py @@ -0,0 +1,112 @@ +"""`apply_corrections` 스텝 — `llm` 스텝(`output_mode: corrections`)이 남긴 교정 목록을 본문에 적용한다. + +자기 모듈에 둔 이유: `rule_steps.py` 에 넣으면 그 모듈을 쓰는 모든 규칙 스텝의 impl 지문(`pipelines/corrections.py` 가 +closure 에 들어감)이 바뀐다. 이 스텝을 쓰는 파이프라인만 새 지문을 갖게 한다. +""" + +from __future__ import annotations + +from solar_eval.models.sample import EvalSample +from solar_eval.pipelines.corrections import ( + apply_corrections, + diff_hunks, + render_hunks, + revert_hunks, +) +from solar_eval.pipelines.pipeline import BaseStep, StepContext +from solar_eval.pipelines.registry import register_step + + +@register_step("diff_hunks") +class DiffHunksStep(BaseStep): + """원문(`ctx.original_text`)과 현재 본문의 어절 diff 를 번호 목록으로 만든다 — 검증(verify) LLM 에 보여 줄 "바뀐 자리" 다. + + 목록 텍스트는 `ctx.step_outputs[config.key]`(기본 `diff_list`)에 두어 뒤 스텝의 `user_template` 이 `{steps.diff_list}` 로 읽고, + 구조화 목록은 `sample.artifacts["hunks"][<이 스텝 이름>]` 에 남겨 `revert_hunks` 가 번호로 되돌린다. 본문은 바꾸지 않는다. + config: `key`(기본 diff_list) · `context`(앞뒤 문맥 글자 수, 기본 20) · `max_hunks`(기본 80) · `skip_spacing`(기본 false, 띄어쓰기만 + 다른 변경은 목록에서 뺀다). + """ + + async def run(self, sample: EvalSample, ctx: StepContext) -> None: + config = self.config.get("config", {}) + hunks = diff_hunks( + ctx.original_text, + ctx.text, + context=int(config.get("context", 20)), + max_hunks=int(config.get("max_hunks", 80)), + skip_spacing=bool(config.get("skip_spacing", False)), + ) + sample.artifacts.setdefault("hunks", {})[self.name] = [h.as_dict() for h in hunks] + ctx.step_outputs[str(config.get("key", "diff_list"))] = render_hunks(hunks) + + +@register_step("revert_hunks") +class RevertHunksStep(BaseStep): + """`llm(output_mode: json)` 스텝이 낸 `{"revert": [번호…]}` 를 받아 그 번호의 변경을 원문대로 되돌린다. + + config: `hunks_from`(diff_hunks 스텝 이름, 필수) · `from_step`(판정 llm 스텝 이름, 필수) · `field`(기본 revert). + 되돌린 수·무시한 번호는 `sample.artifacts["reverted"][<이 스텝 이름>]` 에 남는다. + """ + + async def run(self, sample: EvalSample, ctx: StepContext) -> None: + config = self.config.get("config", {}) + hunks_from, from_step = config.get("hunks_from"), config.get("from_step") + if not hunks_from or not from_step: + raise ValueError( + f"revert_hunks '{self.name}': config.hunks_from 와 config.from_step 가 필요하다" + ) + hunks = (sample.artifacts.get("hunks") or {}).get(hunks_from) or [] + decision = (sample.artifacts.get("json") or {}).get(from_step) or {} + ids = ( + decision.get(str(config.get("field", "revert"))) if isinstance(decision, dict) else None + ) + ctx.text, report = revert_hunks(ctx.text, hunks, ids if isinstance(ids, list) else []) + sample.artifacts.setdefault("reverted", {})[self.name] = { + "hunks_from": hunks_from, + "from_step": from_step, + "hunks": len(hunks), + **report, + } + + +@register_step("apply_corrections") +class ApplyCorrectionsStep(BaseStep): + """`sample.artifacts["corrections"][from_step]` 의 `{original, corrected}` 항목을 본문에 치환한다. + + config: `from_step`(필수) · `max_items`(기본 50) · `allow_delete`(기본 false) · `protect_digits`(기본 false, 숫자 열이 바뀌는 + 항목 거부) · `max_shrink`(기본 없음, 공백 제외 길이가 이 비율 아래로 줄면 거부). 적용 보고는 + `sample.artifacts["corrections_applied"][<이 스텝 이름>]` 에 남는다(proposed · applied · skipped_* 수). + """ + + async def run(self, sample: EvalSample, ctx: StepContext) -> None: + config = self.config.get("config", {}) + misplaced = [ + k + for k in ("max_items", "allow_delete", "protect_digits", "max_shrink") + if k in self.config + ] + if misplaced: + # override 경로를 `steps..protect_digits` 처럼 적으면 스텝 최상위에 놓여 조용히 무시된다(2026-10-17 F4g 사고). + raise ValueError( + f"apply_corrections '{self.name}': {misplaced} 는 config 아래에 둔다 - " + f"override 는 steps.{self.name}.config.<키> 로" + ) + from_step = config.get("from_step") + if not from_step: + raise ValueError(f"apply_corrections '{self.name}': config.from_step 가 필요하다") + items = (sample.artifacts.get("corrections") or {}).get(from_step) or [] + ctx.text, report = apply_corrections( + ctx.text, + items, + max_items=int(config.get("max_items", 50)), + allow_delete=bool(config.get("allow_delete", False)), + protect_digits=bool(config.get("protect_digits", False)), + max_shrink=( + float(config["max_shrink"]) if config.get("max_shrink") is not None else None + ), + ) + sample.artifacts.setdefault("corrections_applied", {})[self.name] = { + "from_step": from_step, + "proposed": len(items), + **report.as_dict(), + } diff --git a/solar_eval/pipelines/steps/judge_step.py b/solar_eval/pipelines/steps/judge_step.py index 908484ea7f02327be5030ee4426c35d7e5911205..d0a8a67bb1d2999aba33ae656f0f38879dc0ef86 100644 --- a/solar_eval/pipelines/steps/judge_step.py +++ b/solar_eval/pipelines/steps/judge_step.py @@ -11,10 +11,12 @@ artifacts 배선만 한다: """ import logging +from pathlib import Path from solar_eval.models.sample import EvalSample from solar_eval.pipelines import judge -from solar_eval.pipelines.pipeline import BaseStep, StepContext +from solar_eval.pipelines.errors import KnowledgeAssetError +from solar_eval.pipelines.pipeline import BaseStep, ResourceRef, StepContext, relative_posix from solar_eval.pipelines.registry import register_step logger = logging.getLogger(__name__) @@ -22,6 +24,27 @@ logger = logging.getLogger(__name__) @register_step("tool_calling_judge") class ToolCallingJudgeStep(BaseStep): + uses_model = True + + def resource_refs(self) -> list[ResourceRef]: + """`pmi_lookup`(데이터 루트 기준 파생 통계) — 실행 때와 같은 `judge.resolve_pmi_path` 로 푼다. + + 라벨은 데이터 루트(로더의 `base_dir`, 없으면 프로젝트 디렉토리의 부모) 상대 `data:`. + 못 풀면 run 시작 전에 `KnowledgeAssetError` 로 죽는다. + """ + pmi_lookup = self.config.get("pmi_lookup") + if not pmi_lookup: + return [] + try: + path = judge.resolve_pmi_path(self.env.project_dir, pmi_lookup) + except (ValueError, FileNotFoundError) as exc: + raise KnowledgeAssetError(pmi_lookup, [str(exc)]) from exc + loader = self.env.dataset_loader + root = loader.base_dir if loader is not None and loader.base_dir else None + if root is None and self.env.project_dir is not None: + root = self.env.project_dir.parent + return [ResourceRef("data", relative_posix(path, root), path)] + async def run(self, sample: EvalSample, ctx: StepContext) -> None: step = self.config step_model = step.get("model", ctx.model) diff --git a/solar_eval/pipelines/steps/llm.py b/solar_eval/pipelines/steps/llm.py index a93ccb08032e07b5dbff9d3e3aaab429b509979a..be1e46b952ed0e0bf5bb97893e2f4edbecd841e4 100644 --- a/solar_eval/pipelines/steps/llm.py +++ b/solar_eval/pipelines/steps/llm.py @@ -5,9 +5,11 @@ bulk 분할 · JSON output 추출 · fallback · repeat/intersection(self-consis corrections 캡처를 담당한다. """ +import asyncio import json import logging import re +from dataclasses import dataclass, field from datetime import datetime from typing import Any @@ -15,32 +17,32 @@ from solar_eval.models.sample import EvalSample from solar_eval.pipelines import judge from solar_eval.pipelines.pipeline import BaseStep, StepContext from solar_eval.pipelines.registry import register_step -from solar_eval.pipelines.steps.chunking import ( - ChunkPlan, - join_chunks, - normalize_separator_literals, - plan_chunks, -) logger = logging.getLogger(__name__) -# 출력 길이 가드 -- 교열은 길이를 거의 바꾸지 않는다. 입력 대비 ±25% 또는 40자(짧은 입력용) 를 넘는 변동은 -# 모델이 문장을 지어냈거나(실측 49자→321자) bulk 를 통째로 버린 것(490자→7자)이라 폴백/입력 유지로 처리한다. -# 종전 규칙("5배 초과")은 길이 급감과 5배 미만의 환각을 그대로 통과시켰다 (insight 260910). -LENGTH_TOL_REL = 0.25 -LENGTH_TOL_ABS = 40 +# 문단 경계 보존용 마커 (bulk 분할 시) -- multi_step.py 원본 값 그대로 +PARAGRAPH_SEP = "∥PARA∥" -def plausible_length(source: str, output: str) -> bool: - if not source: - return True - return abs(len(output) - len(source)) <= max(LENGTH_TOL_ABS, LENGTH_TOL_REL * len(source)) +@dataclass +class _BulkResult: + """묶음 하나의 결과. 묶음을 동시에 부를 때 `sample.artifacts` 목록 순서가 흔들리지 않게 모아 두었다가 묶음 순서대로 반영한다.""" + + text: str + usages: list[dict[str, Any]] = field(default_factory=list) + finish: str | None = None + completion_tokens: int | None = None + corrections: list[dict[str, Any]] = field(default_factory=list) + sc_runs: list[dict[str, Any]] | None = None + parsed_json: dict[str, Any] | None = None @register_step("llm") class LLMStep(BaseStep): """체인 텍스트 위에서 도는 LLM 스텝 (prod/v24 파이프라인의 기본 부품).""" + uses_model = True + async def run(self, sample: EvalSample, ctx: StepContext) -> None: step = self.config step_name = self.name @@ -66,168 +68,273 @@ class LLMStep(BaseStep): # Split into bulks if needed # For include_original/user_template steps, skip splitting if include_original or user_template: - plan = ChunkPlan(chunks=[text], seps=[""], kinds=[], lead="") + bulks = [text] else: - plan = plan_chunks( - text, self.env.text_split_size, strategy=self.env.text_split_strategy - ) - bulks = plan.chunks - processed_bulks = [] + bulks = self._split_text(text) logger.debug(f" [{ts}] {len(bulks)} bulk(s), text_len={len(text)}") - if plan.is_split: - # 절단면 진단용 -- 어디를 어떤 경계로 잘랐는지 (insight 260910 의 GLUED/LOST 분석이 읽는다) - chunk_plans = sample.artifacts.setdefault("chunk_plans", {}) - chunk_plans[step_name] = { - "lens": [len(b) for b in bulks], - "kinds": list(plan.kinds), - } - - for bulk_i, bulk in enumerate(bulks): - if ctx.on_step and len(bulks) > 1: - ctx.on_step("chunk", bulk_i + 1, len(bulks), step_name, ctx.doc_chunk) - if step_repeat > 1: - ( - final_text, - run_usage, - runs, - survivors, - ) = await self._run_self_consistency_bulk( - bulk, - step, - step_model=step_model, - step_re=step_re, - step_max_tokens=step_max_tokens, - step_response_format=step_response_format, - step_prompt=step_prompt, - user_template=user_template, - include_original=include_original, - original_text=original_text, - step_outputs=ctx.step_outputs, - ctx=ctx, - ) - processed_bulks.append(final_text) - ctx.add_usage(run_usage) + + # `bulk_concurrency`(기본 1 = 예전처럼 한 묶음씩): 문단 단위 FT 처럼 묶음이 서로 독립인 스텝을 동시에 불러 + # 기사 지연을 묶음 수만큼 늘리지 않는다. 결과 반영은 항상 묶음 순서다. + concurrency = max(1, int(step.get("bulk_concurrency", 1) or 1)) + call: dict[str, Any] = { + "step": step, + "step_name": step_name, + "step_model": step_model, + "step_re": step_re, + "step_max_tokens": step_max_tokens, + "step_temperature": step_temperature, + "step_response_format": step_response_format, + "step_prompt": step_prompt, + "step_repeat": step_repeat, + "include_original": include_original, + "user_template": user_template, + "original_text": original_text, + "many": len(bulks) > 1, + "ctx": ctx, + } + if concurrency == 1: + results = [await self._run_bulk(i, b, **call) for i, b in enumerate(bulks)] + else: + sem = asyncio.Semaphore(concurrency) + + async def _guarded(i: int, b: str) -> _BulkResult: + async with sem: + return await self._run_bulk(i, b, **call) + + results = list(await asyncio.gather(*(_guarded(i, b) for i, b in enumerate(bulks)))) + + processed_bulks = [] + for res in results: + processed_bulks.append(res.text) + for usage in res.usages: + ctx.add_usage(usage) + if res.sc_runs is not None: # 교집합 이전 run 별 제안도 남긴다 -- 진단 문서가 요구한 가시성 # (§5-F 지시 4). 교집합 결과(survivors)는 downstream # tool_calling_judge/apply_judge_decisions 가 "corrections" 로 읽는다. sc_runs = sample.artifacts.setdefault("self_consistency_runs", {}) - sc_runs.setdefault(step_name, []).extend(runs) + sc_runs.setdefault(step_name, []).extend(res.sc_runs) + if res.parsed_json is not None: + sample.artifacts.setdefault("json", {})[step_name] = res.parsed_json + if res.finish is not None: + # 스텝별 finish_reason·completion 토큰(묶음마다 한 값). `length` 면 max_tokens 에 잘린 것 - + # 목록·판정 스텝은 JSON 이 잘려 교정 0건이 되므로 결과 행에서 바로 셀 수 있게 남긴다(2026-10-16). + sample.artifacts.setdefault("step_finish", {}).setdefault(step_name, []).append( + res.finish + ) + sample.artifacts.setdefault("step_usage", {}).setdefault(step_name, []).append( + res.completion_tokens or 0 + ) + if res.corrections or res.sc_runs is not None: + # self-consistency 는 살아남은 교정이 0건이어도 키를 남긴다(downstream judge 가 빈 목록을 읽는다). step_corrs = sample.artifacts.setdefault("corrections", {}) - step_corrs.setdefault(step_name, []).extend(survivors) - continue + step_corrs.setdefault(step_name, []).extend(res.corrections) - if user_template: - user_content = self._format_user_template( - user_template, - original_text, - bulk, - ctx.step_outputs, - ) - elif include_original: - user_content = f"[원문]\n{original_text}\n\n[교정본]\n{bulk}" - else: - user_content = bulk + ctx.text = self._join_bulks(processed_bulks) - ts2 = datetime.now().strftime("%H:%M:%S") - logger.debug( - f" [{ts2}] bulk {bulk_i + 1}/{len(bulks)} calling API (len={len(bulk)})..." + async def _run_bulk( + self, + bulk_i: int, + bulk: str, + *, + step: dict[str, Any], + step_name: str, + step_model: str, + step_re: str | None, + step_max_tokens: int, + step_temperature: float, + step_response_format: dict[str, Any] | None, + step_prompt: str, + step_repeat: int, + include_original: bool, + user_template: str | None, + original_text: str, + many: bool, + ctx: StepContext, + ) -> _BulkResult: + """묶음 하나를 부른다. 공유 상태(`sample.artifacts`)는 건드리지 않고 결과만 돌려준다.""" + # lines 모드: 묶음 앞뒤 개행(빈 줄)은 LLM 에 보내지 않고 출력에 다시 붙인다 — 출력 rstrip 이 먹지 않게. + lead = trail = "" + if self.env.text_split_mode == "lines" and many: + core = bulk.strip("\n") + if not core: + return _BulkResult(text=bulk) + lead = bulk[: len(bulk) - len(bulk.lstrip("\n"))] + trail = bulk[len(bulk.rstrip("\n")) :] + bulk = core + if step_repeat > 1: + ( + final_text, + run_usage, + runs, + survivors, + ) = await self._run_self_consistency_bulk( + bulk, + step, + step_model=step_model, + step_re=step_re, + step_max_tokens=step_max_tokens, + step_response_format=step_response_format, + step_prompt=step_prompt, + user_template=user_template, + include_original=include_original, + original_text=original_text, + step_outputs=ctx.step_outputs, + ctx=ctx, ) - msgs: list[dict[str, str]] = [] - if step_prompt: - msgs.append({"role": "system", "content": step_prompt}) - msgs.append({"role": "user", "content": user_content}) - result = await ctx.provider.chat_completion( - messages=msgs, - model=step_model, - temperature=step_temperature, - max_tokens=step_max_tokens, - reasoning_effort=step_re, - response_format=step_response_format, + return _BulkResult( + text=lead + final_text + trail, + usages=[run_usage], + corrections=list(survivors), + sc_runs=list(runs), ) - ts3 = datetime.now().strftime("%H:%M:%S") - raw_len = len(result.get("content", "")) - output = normalize_separator_literals( - self._extract_output(result["content"]).rstrip(), source=bulk + + if user_template: + user_content = self._format_user_template( + user_template, + original_text, + bulk, + ctx.step_outputs, ) + elif include_original: + user_content = f"[원문]\n{original_text}\n\n[교정본]\n{bulk}" + else: + user_content = bulk - # 길이 가드: JSON 파싱 실패(설명이 통째로 output 이 됨) · 환각 · bulk 삭제를 한 규칙으로 잡는다 - if not plausible_length(bulk, output): - raw_content = result.get("content", "") - logger.warning( - f" [{ts3}] [{step_name}] bulk {bulk_i + 1} output length implausible " - f"(in={len(bulk)}, out={len(output)}), attempting fallback" + ts2 = datetime.now().strftime("%H:%M:%S") + logger.debug(f" [{ts2}] bulk {bulk_i + 1} calling API (len={len(bulk)})...") + msgs: list[dict[str, str]] = [] + if step_prompt: + msgs.append({"role": "system", "content": step_prompt}) + msgs.append({"role": "user", "content": user_content}) + result = await ctx.provider.chat_completion( + messages=msgs, + model=step_model, + temperature=step_temperature, + max_tokens=step_max_tokens, + reasoning_effort=step_re, + response_format=step_response_format, + ) + res = _BulkResult(text="") + ts3 = datetime.now().strftime("%H:%M:%S") + raw_len = len(result.get("content", "")) + output = self._extract_output(result["content"]).rstrip() + output_mode = step.get("output_mode") + if output_mode in ("corrections", "json"): + # 목록·판정만 받는 스텝 — 텍스트는 그대로 두고 부산물만 남긴다. `corrections` 는 아래 캡처가 + # `artifacts["corrections"][step]` 에, `json` 은 파싱한 객체 전체를 `artifacts["json"][step]` 에. + # 적용은 결정론 스텝(`apply_corrections` · `revert_hunks`)이 한다. + output = bulk + if output_mode == "json": + res.parsed_json = judge._parse_json_robust(result.get("content", "")) or {} + + # JSON 파싱 실패 시 fallback: + # output이 입력 대비 5배 이상 길면 파싱 실패로 간주 + if len(output) > len(bulk) * 5 and len(bulk) > 0: + raw_content = result.get("content", "") + logger.warning( + f" [{ts3}] [{step_name}] bulk {bulk_i + 1} output too long " + f"({len(output)} vs input {len(bulk)}), " + f"attempting fallback" + ) + + # Try fallback LLM call if configured + fallback_config = step.get("fallback") + fallback_prompt = ( + self.env.prompts.get(fallback_config["prompt_key"], "") if fallback_config else "" + ) + if fallback_prompt: + fallback_user = ( + f"<원문>\n{original_text}\n\n\n" + f"<교정_결과_JSON>\n{raw_content}\n" ) - guard_log = sample.artifacts.setdefault("length_guard", []) - guard_log.append( - {"step": step_name, "bulk": bulk_i + 1, "in": len(bulk), "out": len(output)} + fallback_msgs: list[dict[str, str]] = [ + {"role": "system", "content": fallback_prompt}, + {"role": "user", "content": fallback_user}, + ] + fallback_rf = fallback_config.get("response_format") + fallback_result = await ctx.provider.chat_completion( + messages=fallback_msgs, + model=step_model, + temperature=step_temperature, + max_tokens=step_max_tokens, + reasoning_effort=step_re, + response_format=fallback_rf, ) + fallback_output = self._extract_output(fallback_result["content"]).rstrip() + ts4 = datetime.now().strftime("%H:%M:%S") - # Try fallback LLM call if configured - fallback_config = step.get("fallback") - fallback_prompt = ( - self.env.prompts.get(fallback_config["prompt_key"], "") - if fallback_config - else "" - ) - if fallback_prompt: - fallback_user = ( - f"<원문>\n{original_text}\n\n\n" - f"<교정_결과_JSON>\n{raw_content}\n" - ) - fallback_msgs: list[dict[str, str]] = [ - {"role": "system", "content": fallback_prompt}, - {"role": "user", "content": fallback_user}, - ] - fallback_rf = fallback_config.get("response_format") - fallback_result = await ctx.provider.chat_completion( - messages=fallback_msgs, - model=step_model, - temperature=step_temperature, - max_tokens=step_max_tokens, - reasoning_effort=step_re, - response_format=fallback_rf, - ) - fallback_output = normalize_separator_literals( - self._extract_output(fallback_result["content"]).rstrip(), source=bulk - ) - ts4 = datetime.now().strftime("%H:%M:%S") - - if plausible_length(bulk, fallback_output): - output = fallback_output - logger.info( - f" [{ts4}] [{step_name}] fallback succeeded, len={len(output)}" - ) - ctx.add_usage(fallback_result.get("usage", {})) - else: - output = bulk - logger.warning( - f" [{ts4}] [{step_name}] fallback length also implausible, using input text" - ) + if len(fallback_output) <= len(bulk) * 5: + output = fallback_output + logger.info(f" [{ts4}] [{step_name}] fallback succeeded, len={len(output)}") + res.usages.append(fallback_result.get("usage", {})) else: output = bulk logger.warning( - f" [{ts3}] [{step_name}] no fallback configured, using input text" + f" [{ts4}] [{step_name}] fallback also too long, using input text" ) + else: + output = bulk + logger.warning(f" [{ts3}] [{step_name}] no fallback configured, using input text") + + logger.debug(f" [{ts3}] bulk {bulk_i + 1} done, raw={raw_len}, extracted={len(output)}") + res.text = lead + output + trail + res.usages.append(result.get("usage", {})) + res.finish = result.get("finish_reason") or "stop" + res.completion_tokens = int((result.get("usage") or {}).get("completion_tokens") or 0) + + # response_format 이 "corrections" 배열을 담고 있으면(예: + # basic_correction) repeat 없이도 뽑아 둔다 -- downstream + # tool_calling_judge/apply_judge_decisions 가 읽을 수 있게. + # output 계산과 무관한 순수 추가이므로 기존 텍스트 동작은 바뀌지 않는다. + res.corrections = judge.extract_corrections(result.get("content", "")) or [] + return res - logger.debug( - f" [{ts3}] bulk {bulk_i + 1} done, raw={raw_len}, extracted={len(output)}" - ) - processed_bulks.append(output) - ctx.add_usage(result.get("usage", {})) - - # response_format 이 "corrections" 배열을 담고 있으면(예: - # basic_correction) repeat 없이도 뽑아 둔다 -- downstream - # tool_calling_judge/apply_judge_decisions 가 읽을 수 있게. - # output 계산과 무관한 순수 추가이므로 기존 텍스트 동작은 바뀌지 않는다. - step_corrections = judge.extract_corrections(result.get("content", "")) - if step_corrections: - step_corrs = sample.artifacts.setdefault("corrections", {}) - step_corrs.setdefault(step_name, []).extend(step_corrections) + # ── helpers (multi_step.py 에서 그대로 이동) ───────────────────────── - # 구분자 원장으로 접합 -- 절단면의 공백/개행을 그대로 복원한다. 분할·접합 규칙은 chunking.py. - ctx.text = join_chunks(processed_bulks, plan) + def _split_text(self, text: str) -> list[str]: + """Split text into bulks of max text_split_size chars, preserving line breaks. - # ── helpers (multi_step.py 에서 그대로 이동) ───────────────────────── + `text_split_mode: lines` 면 개행 경계로만 묶는다 — bulk 는 줄 사이 개행을 빼고 담고 `_join_bulks` 가 + 개행으로 다시 잇는다(LLM 출력의 rstrip 이 끝 개행을 먹어도 줄이 붙지 않게). 한도보다 긴 줄은 자르지 않는다. + """ + if len(text) <= self.env.text_split_size: + return [text] + if self.env.text_split_mode == "lines": + return self._split_lines(text) + + text_with_markers = text.replace("\n", PARAGRAPH_SEP) + bulks = [] + current = "" + + for char in text_with_markers: + current += char + if len(current) >= self.env.text_split_size: + bulks.append(current.replace(PARAGRAPH_SEP, "\n")) + current = "" + + if current: + bulks.append(current.replace(PARAGRAPH_SEP, "\n")) + + return bulks + + def _split_lines(self, text: str) -> list[str]: + bulks: list[str] = [] + current: list[str] = [] + size = 0 + for line in text.split("\n"): + added = len(line) + (1 if current else 0) + if current and size + added > self.env.text_split_size: + bulks.append("\n".join(current)) + current, size = [], 0 + added = len(line) + current.append(line) + size += added + bulks.append("\n".join(current)) + return bulks + + def _join_bulks(self, bulks: list[str]) -> str: + return ("\n" if self.env.text_split_mode == "lines" else "").join(bulks) def _extract_output(self, content: str) -> str: """Extract output from LLM response. Tries JSON parsing first. diff --git a/solar_eval/pipelines/steps/rule_steps.py b/solar_eval/pipelines/steps/rule_steps.py index e7122f5186652864247d4b048e9d36ea38f0ce91..29e896f855b3d2f3e12cec55bf75e05c3fa936c6 100644 --- a/solar_eval/pipelines/steps/rule_steps.py +++ b/solar_eval/pipelines/steps/rule_steps.py @@ -9,7 +9,7 @@ v24 파이프라인이 LLM 스텝 사이사이에 끼워 넣는 "code block 후 import logging from solar_eval.models.sample import EvalSample -from solar_eval.pipelines.pipeline import BaseStep, StepContext +from solar_eval.pipelines.pipeline import BaseStep, ResourceRef, StepContext from solar_eval.pipelines.registry import register_step from solar_eval.pipelines.rules import ( apply_change_rate_guard, @@ -19,13 +19,15 @@ from solar_eval.pipelines.rules import ( apply_currency_compact, apply_judge_decisions, apply_pronoun_postprocess, + apply_quote_direction, + apply_quote_preserve, apply_unit_unicode, apply_vocabulary, apply_yul_ryul_normalize, load_compound_whitelist, + resolve_vocabulary_path, + resolve_whitelist_path, ) -from solar_eval.pipelines.rules_josa import apply_josa_fix -from solar_eval.pipelines.rules_revert import apply_diff_revert logger = logging.getLogger(__name__) @@ -40,6 +42,12 @@ class VocabularyStep(BaseStep): prod/v24 처럼 같은 CSV 를 두 번 쓰는 파이프라인은 여전히 1회 로드. """ + def resource_refs(self) -> list[ResourceRef]: + ref = resolve_vocabulary_path( + self.config.get("config", {}), self.env.dataset_loader, self.env.config_dir + ) + return [ref] if ref is not None else [] + async def run(self, sample: EvalSample, ctx: StepContext) -> None: config = self.config.get("config", {}) cache_key = ( @@ -82,6 +90,10 @@ class CompoundWhitelistRestoreStep(BaseStep): 다른 두 스텝이 캐시를 잘못 공유하지 않도록 경로를 캐시 키에 넣는다. """ + def resource_refs(self) -> list[ResourceRef]: + ref = resolve_whitelist_path(self.config.get("config", {}), self.env.config_dir) + return [ref] if ref is not None else [] + async def run(self, sample: EvalSample, ctx: StepContext) -> None: config = self.config.get("config", {}) cache_key = ("compound_whitelist", config.get("whitelist_path", "")) @@ -106,6 +118,22 @@ class YulRyulNormalizeStep(BaseStep): ctx.text = apply_yul_ryul_normalize(ctx.text, exceptions) +@register_step("quote_direction") +class QuoteDirectionStep(BaseStep): + """둥근 따옴표가 있는 줄에서 따옴표 여닫이 방향을 다시 정한다(`rules.apply_quote_direction`, 원문에 있던 프라임은 둔다).""" + + async def run(self, sample: EvalSample, ctx: StepContext) -> None: + ctx.text = apply_quote_direction(ctx.text, ctx.original_text) + + +@register_step("quote_preserve") +class QuotePreserveStep(BaseStep): + """원문에 있던 따옴표 글자를 원문 모양 그대로 되돌린다(`rules.apply_quote_preserve`, 교열자는 따옴표 모양을 고치지 않는다).""" + + async def run(self, sample: EvalSample, ctx: StepContext) -> None: + ctx.text = apply_quote_preserve(ctx.text, ctx.original_text) + + @register_step("correction_filter") class CorrectionFilterStep(BaseStep): async def run(self, sample: EvalSample, ctx: StepContext) -> None: @@ -122,28 +150,6 @@ class ApplyJudgeDecisionsStep(BaseStep): ctx.text = apply_judge_decisions(ctx.text, config, sample) -@register_step("josa_fix") -class JosaFixStep(BaseStep): - """기호·숫자·단위 뒤 조사 결정론 교정 (2026-09-18, 상세는 `rules_josa` docstring).""" - - async def run(self, sample: EvalSample, ctx: StepContext) -> None: - ctx.text = apply_josa_fix(ctx.text) - - -@register_step("diff_revert") -class DiffRevertStep(BaseStep): - """원문 대비 diff 를 클래스 판정해 되돌리는 정밀도 가드 (2026-09-18). - - 글리프 변환·쉼표 삽입·금지 치환·격식 전환만 되돌린다 — 상세와 실측 근거는 - `rules_revert` docstring 참조. 파이프라인 꼬리(post_process 뒤)에 두는 것을 - 전제로 설계했다. - """ - - async def run(self, sample: EvalSample, ctx: StepContext) -> None: - config = self.config.get("config", {}) - ctx.text = apply_diff_revert(ctx.text, ctx.original_text, config) - - @register_step("change_rate_guard") class ChangeRateGuardStep(BaseStep): """원문 대비 변경률이 임계를 넘으면 원문으로 되돌리는 안전망 (v17 계열).""" diff --git a/solar_eval/providers/__init__.py b/solar_eval/providers/__init__.py index 17eec83a9132311224889ba7d639ec99051a9efb..a73b3d3bcd7d30158e199c1a49b059244340482a 100644 --- a/solar_eval/providers/__init__.py +++ b/solar_eval/providers/__init__.py @@ -1,7 +1,20 @@ """LLM provider abstractions.""" from solar_eval.providers.base import BaseProvider +from solar_eval.providers.openai_compat import OpenAICompatProvider, TimelyProvider from solar_eval.providers.openai_provider import OpenAIProvider +from solar_eval.providers.routed import RoutedProvider from solar_eval.providers.upstage import UpstageProvider -__all__ = ["BaseProvider", "OpenAIProvider", "UpstageProvider"] +#: 추론 제공자 라우트 — 스텝 `model` 의 접두어. 외부(Upstage 밖) 라우트는 egress 정책 `external-allowed` 가 필요하다. +INFERENCE_ROUTES: dict[str, type[BaseProvider]] = {"timely": TimelyProvider} + +__all__ = [ + "INFERENCE_ROUTES", + "BaseProvider", + "OpenAICompatProvider", + "OpenAIProvider", + "RoutedProvider", + "TimelyProvider", + "UpstageProvider", +] diff --git a/solar_eval/providers/__pycache__/__init__.cpython-312.pyc b/solar_eval/providers/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2269794845d6cd1628f822f42d6c39a0096ced8b Binary files /dev/null and b/solar_eval/providers/__pycache__/__init__.cpython-312.pyc differ diff --git a/solar_eval/providers/__pycache__/base.cpython-312.pyc b/solar_eval/providers/__pycache__/base.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ed1110a6c7c7a23fba83f1658645a3608a1cb50a Binary files /dev/null and b/solar_eval/providers/__pycache__/base.cpython-312.pyc differ diff --git a/solar_eval/providers/__pycache__/openai_compat.cpython-312.pyc b/solar_eval/providers/__pycache__/openai_compat.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..27321e766e2cd37db343bc7028f81e9483332223 Binary files /dev/null and b/solar_eval/providers/__pycache__/openai_compat.cpython-312.pyc differ diff --git a/solar_eval/providers/__pycache__/openai_provider.cpython-312.pyc b/solar_eval/providers/__pycache__/openai_provider.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..959e7deabe3c33aa4ebfed5c432bf1587437cb9b Binary files /dev/null and b/solar_eval/providers/__pycache__/openai_provider.cpython-312.pyc differ diff --git a/solar_eval/providers/__pycache__/routed.cpython-312.pyc b/solar_eval/providers/__pycache__/routed.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..48aae8b673b182890644989f2b11efc08f881614 Binary files /dev/null and b/solar_eval/providers/__pycache__/routed.cpython-312.pyc differ diff --git a/solar_eval/providers/__pycache__/upstage.cpython-312.pyc b/solar_eval/providers/__pycache__/upstage.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2542dda5f43d196b4bf21ebf85f503a1f393985e Binary files /dev/null and b/solar_eval/providers/__pycache__/upstage.cpython-312.pyc differ diff --git a/solar_eval/providers/budgeted.py b/solar_eval/providers/budgeted.py new file mode 100644 index 0000000000000000000000000000000000000000..b17d5c808df60213ecdb05d4939b345e547d6236 --- /dev/null +++ b/solar_eval/providers/budgeted.py @@ -0,0 +1,1000 @@ +"""Opt-in, text-only paid inference with a durable, shared spending ceiling. + +Use one ``BudgetLedger`` outside the checkout and close it after all providers +have closed. Its lifetime advisory lock rejects overlapping owners; sequential +processes recover reservations but never replay requests. A request with an +unknown outcome cannot be submitted again with the same payload. Completed +responses may be deliberately repeated, including missing-usage responses whose +full reservation remains committed separately for every call. + +Amounts are conservative estimates, not invoices: UTF-8 bytes plus protocol +allowances bound input, the requested output limit is fully reserved, all input +uses the higher input/cache-write rate, and every amount includes 10% tax safety. +Missing usage and failed calls keep their full reservation. No cache discounts +are assumed. The ledger contains only pricing, token counts and safe metadata, +never prompts, completions or credentials. + +Ledger and provider intentionally share a module: native runtime snapshots hash +provider files directly rather than following their import closures. +""" + +from __future__ import annotations + +import asyncio +import fcntl +import hashlib +import json +import math +import os +import re +import ssl +import stat +import threading +import uuid +from collections import Counter +from collections.abc import Mapping +from dataclasses import dataclass +from decimal import Decimal, InvalidOperation +from pathlib import Path +from typing import Any +from urllib.parse import urlsplit + +import httpx + +from solar_eval.config import get_provider_config +from solar_eval.providers.base import BaseProvider + +_TAX = Decimal("1.10") +_MILLION = Decimal(1_000_000) +_MODEL = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}\Z") +_METADATA = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}\Z") +_PROVIDER_PIN = re.compile(r"[!#$%&'*+\-.^_`|~0-9A-Za-z]{1,200}\Z") +_DIGEST = re.compile(r"[0-9a-f]{64}\Z") +_THINK = re.compile(r"(.*?)", re.DOTALL) +_MODERN_OPENAI = re.compile(r"gpt-?[56](?:\D|$)", re.IGNORECASE) +_HTTP_CODES = frozenset( + { + "invalid_api_key", + "insufficient_quota", + "model_not_found", + "rate_limit_exceeded", + "context_length_exceeded", + "invalid_request_error", + "unsupported_parameter", + "unsupported_value", + "authentication_error", + "permission_denied", + "billing_hard_limit_reached", + "server_error", + "not_found", + "content_policy_violation", + "invalid_model", + "insufficient_balance", + "internal_server_error", + } +) +_STATUSES = frozenset( + { + "success", + "unknown_usage", + "incomplete_output", + "http_error", + "transport_error", + "timeout", + "cancelled", + "invalid_response", + } +) + + +class BudgetError(RuntimeError): + """Fail-closed budget, configuration or input error; contains no request data.""" + + +class BudgetExceededError(BudgetError): + """The next reservation would exceed the cap, or a prior bound was breached.""" + + +class BudgetLedgerError(BudgetError): + """The ledger is locked, unsafe, corrupt, closed or cannot be made durable.""" + + +class DuplicateRequestError(BudgetError): + """An identical request has an unresolved charge/outcome; it will not replay.""" + + +class UnknownModelError(BudgetError): + """The exact model ID has no explicitly configured rate and route.""" + + +class ProviderRequestError(BudgetError): + """Sanitized failure exposing only provider, HTTP status and a safe code.""" + + def __init__(self, provider: str, status_code: int | None, code: str) -> None: + self.provider = provider + self.status_code = status_code + self.code = code + super().__init__(f"{provider}: status={status_code or 'unavailable'} code={code}") + + +def _money(value: Any) -> Decimal: + if isinstance(value, bool): + raise ValueError("invalid_amount") + try: + amount = Decimal(str(value)) + except (InvalidOperation, ValueError): + raise ValueError("invalid_amount") from None + if not amount.is_finite() or amount < 0: + raise ValueError("invalid_amount") + return amount + + +def _token_count(value: Any) -> bool: + return isinstance(value, int) and not isinstance(value, bool) and value >= 0 + + +def _check_rate_authorization( + input_rate: Decimal, output_rate: Decimal, authorization: Any +) -> None: + """A zero tariff is an explicit assumption, never an unpriced-model default.""" + if input_rate == output_rate == 0: + if ( + not isinstance(authorization, str) + or not authorization.strip() + or len(authorization) > 512 + ): + raise ValueError("zero_cost_requires_authorization") + elif authorization is not None: + raise ValueError("zero_cost_authorization_requires_zero_rates") + + +@dataclass(frozen=True) +class ModelRate: + provider: str + input_usd_per_million: float + output_usd_per_million: float + input_reserve_usd_per_million: float | None = None + provider_pin: str | None = None + zero_cost_authorization: str | None = None + + def __post_init__(self) -> None: + if self.provider not in {"upstage", "openai", "timely"}: + raise BudgetError("unapproved_provider") + if self.provider_pin is not None and ( + self.provider != "timely" + or not isinstance(self.provider_pin, str) + or not _PROVIDER_PIN.fullmatch(self.provider_pin) + ): + raise BudgetError("invalid_provider_pin") + try: + input_rate = _money(self.input_usd_per_million) + output_rate = _money(self.output_usd_per_million) + reserve_rate = ( + _money(self.input_reserve_usd_per_million) + if self.input_reserve_usd_per_million is not None + else input_rate + ) + _check_rate_authorization( + max(input_rate, reserve_rate), output_rate, self.zero_cost_authorization + ) + except ValueError: + raise BudgetError("invalid_model_rate") from None + + @property + def effective_input_rate(self) -> Decimal: + """No cache-read discount; include a possible cache-write premium.""" + return max( + _money(self.input_usd_per_million), + _money( + self.input_reserve_usd_per_million + if self.input_reserve_usd_per_million is not None + else self.input_usd_per_million + ), + ) + + +def _charge( + input_tokens: int, output_tokens: int, input_rate: Decimal, output_rate: Decimal +) -> Decimal: + return (input_tokens * input_rate + output_tokens * output_rate) / _MILLION * _TAX + + +def _usage_counts(usage: Any) -> dict[str, int] | None: + if not isinstance(usage, dict): + return None + prompt, completion = usage.get("prompt_tokens"), usage.get("completion_tokens") + if not _token_count(prompt) or not _token_count(completion): + return None + total = usage.get("total_tokens") + if "total_tokens" in usage and (not _token_count(total) or total != prompt + completion): + return None + # Details are not added again: reasoning is already included in completion + # tokens, and cached tokens are already included in prompt tokens. + for name, bound in ( + ("prompt_tokens_details", prompt), + ("completion_tokens_details", completion), + ): + details = usage.get(name) + if details is not None and ( + not isinstance(details, dict) + or any( + not _token_count(value) or value > bound + for key, value in details.items() + if key.endswith("_tokens") and value is not None + ) + ): + return None + counts = {"prompt_tokens": prompt, "completion_tokens": completion} + if total is not None: + counts["total_tokens"] = total + return counts + + +class BudgetLedger: + """Append-only JSONL ledger, mode 0600, protected by a lifetime POSIX lock. + + ``read_summary()`` returns float USD for reporting; all decisions and durable + amounts use decimal arithmetic. ``charged_estimate_usd`` includes only calls + with valid observed token counts. ``committed_upper_usd`` adds every pending + or unknown-cost reservation to those estimates. Resume may lower the ceiling; + increases require ``approve_limit_increase`` with explicit authorization. + A malformed/partial log fails closed, not repaired. + """ + + def __init__(self, path: Path, limit_usd: float = 50.0) -> None: + self._fd: int | None = None + self._pid = os.getpid() + self._lock = threading.Lock() + self._records: dict[str, dict[str, Any]] = {} + self._blocked_keys: set[str] = set() + self._active: set[str] = set() + self._halted = False + self._poisoned = False + self._limit: Decimal | None = None + try: + requested_limit = _money(limit_usd) + if requested_limit == 0: + raise ValueError("invalid_limit") + requested_path = Path(path).expanduser().absolute() + self.path = requested_path.parent.resolve() / requested_path.name + if any((parent / ".git").exists() for parent in self.path.parents): + raise ValueError("ledger_inside_repository") + self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + self._fd = os.open( + self.path, os.O_RDWR | os.O_CREAT | os.O_APPEND | os.O_NOFOLLOW, 0o600 + ) + fcntl.flock(self._fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + info = os.fstat(self._fd) + if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1 or info.st_uid != os.getuid(): + raise ValueError("unsafe_ledger_file") + os.fchmod(self._fd, 0o600) + with os.fdopen(os.dup(self._fd), "r", encoding="utf-8") as stream: + for line in stream: + if not line.endswith("\n"): + raise ValueError("partial_ledger_event") + self._apply(json.loads(line)) + if self._limit is None: + self._append( + { + "event": "open", + "version": 1, + "limit_usd": str(requested_limit), + "tax_multiplier": str(_TAX), + } + ) + elif requested_limit > self._limit: + raise ValueError("cannot_raise_existing_cap") + elif requested_limit < self._limit: + if requested_limit < self._committed(): + raise ValueError("cap_below_committed_spend") + self._append({"event": "limit", "limit_usd": str(requested_limit)}) + # Persist creation of the directory entry as well as the log contents. + directory_fd = os.open(self.path.parent, os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + except Exception: + self._release() + raise BudgetLedgerError("cannot_open_safe_budget_ledger") from None + + def _ensure_open(self) -> None: + if self._fd is None or self._pid != os.getpid() or self._poisoned: + raise BudgetLedgerError("budget_ledger_unavailable") + + def _release(self) -> None: + if self._fd is not None: + os.close(self._fd) + self._fd = None + + def close(self) -> None: + """Release the process lock; close providers and await requests first.""" + if self._active: + raise BudgetLedgerError("requests_still_in_flight") + self._release() + + def approve_limit_increase(self, new_limit_usd: float, *, authorization: str) -> None: + """Audit an explicitly authorized increase without releasing prior charges. + + Open an existing ledger at its current cap, approve while no requests are + in flight, then use the new cap when reopening it. Recovered pending + reservations remain committed; a prior safety halt is never cleared. + Authorization is persisted verbatim: supply no credentials or customer text. + """ + if not self._lock.acquire(blocking=False): + raise BudgetLedgerError("budget_update_in_progress") + try: + self._ensure_open() + if self._active: + raise BudgetLedgerError("requests_still_in_flight") + try: + new_limit = _money(new_limit_usd) + except ValueError: + raise BudgetLedgerError("invalid_limit_increase") from None + if ( + new_limit <= self._limit + or not isinstance(authorization, str) + or not authorization.strip() + ): + raise BudgetLedgerError("invalid_limit_increase") + self._append( + { + "event": "limit_increase", + "old_limit_usd": str(self._limit), + "new_limit_usd": str(new_limit), + "authorization": authorization, + } + ) + finally: + self._lock.release() + + def __enter__(self) -> BudgetLedger: + self._ensure_open() + return self + + def __exit__(self, *_: Any) -> None: + self.close() + + def _committed(self) -> Decimal: + return sum( + ( + record["cost"] if record["cost"] is not None else record["upper"] + for record in self._records.values() + ), + Decimal(0), + ) + + def _apply(self, event: dict[str, Any]) -> None: + kind = event["event"] + if kind == "open": + if ( + self._limit is not None + or event["version"] != 1 + or _money(event["tax_multiplier"]) != _TAX + ): + raise ValueError("invalid_ledger_header") + self._limit = _money(event["limit_usd"]) + if self._limit == 0: + raise ValueError("invalid_ledger_limit") + return + if self._limit is None: + raise ValueError("missing_ledger_header") + if kind == "limit": + limit = _money(event["limit_usd"]) + if limit == 0 or limit > self._limit or limit < self._committed(): + raise ValueError("invalid_ledger_limit") + self._limit = limit + elif kind == "limit_increase": + old_limit = _money(event["old_limit_usd"]) + new_limit = _money(event["new_limit_usd"]) + authorization = event["authorization"] + if ( + old_limit != self._limit + or new_limit <= old_limit + or not isinstance(authorization, str) + or not authorization.strip() + ): + raise ValueError("invalid_limit_increase") + self._limit = new_limit + elif kind == "reserve": + request_id, key = event["id"], event["request_key"] + if ( + not isinstance(request_id, str) + or not re.fullmatch(r"[0-9a-f]{32}", request_id) + or not isinstance(key, str) + or not _DIGEST.fullmatch(key) + or request_id in self._records + or key in self._blocked_keys + or event["provider"] not in {"upstage", "openai", "timely"} + or not isinstance(event["model"], str) + or not _MODEL.fullmatch(event["model"]) + ): + raise ValueError("invalid_reservation") + pin = event.get("provider_pin") + if pin is not None and ( + event["provider"] != "timely" + or not isinstance(pin, str) + or not _PROVIDER_PIN.fullmatch(pin) + ): + raise ValueError("invalid_provider_pin") + input_upper, output_upper = event["input_tokens_upper"], event["output_tokens_upper"] + if not _token_count(input_upper) or not _token_count(output_upper) or output_upper == 0: + raise ValueError("invalid_token_bound") + input_rate = _money(event["input_rate_usd_per_million"]) + output_rate = _money(event["output_rate_usd_per_million"]) + zero_cost_authorization = event.get("zero_cost_authorization") + _check_rate_authorization(input_rate, output_rate, zero_cost_authorization) + upper = _money(event["upper_usd"]) + if ( + upper != _charge(input_upper, output_upper, input_rate, output_rate) + or self._committed() + upper > self._limit + or self._halted + ): + raise ValueError("invalid_reservation_amount") + self._records[request_id] = { + "id": request_id, + "request_key": key, + "provider": event["provider"], + "model": event["model"], + "provider_pin": pin, + "zero_cost_authorization": zero_cost_authorization, + "upper": upper, + "cost": None, + "status": "pending", + "input_rate": input_rate, + "output_rate": output_rate, + "usage": None, + "observed_model": None, + "observed_provider": None, + "provider_request_id": None, + } + self._blocked_keys.add(key) + elif kind == "settle": + record = self._records[event["id"]] + if record["status"] != "pending" or event["status"] not in _STATUSES: + raise ValueError("invalid_settlement") + if event.get("provider_pin") != record["provider_pin"]: + raise ValueError("invalid_settlement_pin") + counts = _usage_counts(event["usage"]) if event["usage"] is not None else None + if event["usage"] is not None and counts is None: + raise ValueError("invalid_usage") + cost = ( + None + if counts is None + else _charge( + counts["prompt_tokens"], + counts["completion_tokens"], + record["input_rate"], + record["output_rate"], + ) + ) + logged_cost = event["charged_estimate_usd"] + if (cost is None) != (logged_cost is None) or ( + cost is not None and cost != _money(logged_cost) + ): + raise ValueError("invalid_settlement_amount") + for name in ("observed_model", "provider_request_id", "observed_provider"): + value = event.get(name) + pattern = _PROVIDER_PIN if name == "observed_provider" else _METADATA + if value is not None and ( + not isinstance(value, str) or not pattern.fullmatch(value) + ): + raise ValueError("invalid_response_metadata") + record[name] = value + record.update(status=event["status"], cost=cost, usage=counts) + # A valid completed response has a known outcome even when its token + # accounting is incomplete. Keep its full charge reservation, but + # permit an explicit new replicate with a fresh reservation. + if cost is not None or event["status"] == "unknown_usage": + self._blocked_keys.remove(record["request_key"]) + if cost is not None: + self._halted |= cost > record["upper"] or self._committed() > self._limit + else: + raise ValueError("unknown_ledger_event") + + def _append(self, event: dict[str, Any]) -> None: + self._ensure_open() + encoded = (json.dumps(event, separators=(",", ":"), allow_nan=False) + "\n").encode() + try: + view = memoryview(encoded) + while view: + written = os.write(self._fd, view) + if written <= 0: + raise OSError("incomplete_write") + view = view[written:] + os.fsync(self._fd) + self._apply(event) + except Exception: + self._poisoned = True + raise BudgetLedgerError("budget_event_not_durable") from None + + async def _reserve( + self, model: str, rate: ModelRate, request_key: str, input_upper: int, output_upper: int + ) -> str: + with self._lock: + self._ensure_open() + if self._halted: + raise BudgetExceededError("prior_reservation_bound_exceeded") + if request_key in self._blocked_keys: + raise DuplicateRequestError("request_has_unknown_prior_outcome") + input_rate, output_rate = rate.effective_input_rate, _money(rate.output_usd_per_million) + upper = _charge(input_upper, output_upper, input_rate, output_rate) + if self._committed() + upper > self._limit: + raise BudgetExceededError("insufficient_uncommitted_budget") + request_id = uuid.uuid4().hex + self._append( + { + "event": "reserve", + "id": request_id, + "request_key": request_key, + "provider": rate.provider, + "model": model, + "provider_pin": rate.provider_pin, + "upper_usd": str(upper), + "input_tokens_upper": input_upper, + "output_tokens_upper": output_upper, + "input_rate_usd_per_million": str(input_rate), + "output_rate_usd_per_million": str(output_rate), + "zero_cost_authorization": rate.zero_cost_authorization, + } + ) + self._active.add(request_id) + return request_id + + async def _settle( + self, + request_id: str, + status: str, + usage: dict[str, int] | None = None, + observed_model: str | None = None, + provider_request_id: str | None = None, + observed_provider: str | None = None, + ) -> Decimal | None: + with self._lock: + self._ensure_open() + record = self._records[request_id] + cost = ( + None + if usage is None + else _charge( + usage["prompt_tokens"], + usage["completion_tokens"], + record["input_rate"], + record["output_rate"], + ) + ) + try: + self._append( + { + "event": "settle", + "id": request_id, + "status": status, + "usage": usage, + "charged_estimate_usd": str(cost) if cost is not None else None, + "observed_model": observed_model, + "provider_request_id": provider_request_id, + "provider_pin": record["provider_pin"], + "observed_provider": observed_provider, + } + ) + finally: + self._active.discard(request_id) + return cost + + def read_summary(self) -> dict[str, Any]: + """Return token-accounted estimates separately from unresolved upper bounds.""" + charged = sum( + (record["cost"] for record in self._records.values() if record["cost"] is not None), + Decimal(0), + ) + committed = self._committed() + records = [ + { + "id": record["id"], + "provider": record["provider"], + "model": record["model"], + "provider_pin": record["provider_pin"], + "status": record["status"], + "reserved_upper_usd": float(record["upper"]), + "charged_estimate_usd": float(record["cost"]) + if record["cost"] is not None + else None, + "usage": dict(record["usage"]) if record["usage"] is not None else None, + "observed_model": record["observed_model"], + "observed_provider": record["observed_provider"], + "provider_request_id": record["provider_request_id"], + "zero_cost_authorization": record["zero_cost_authorization"], + } + for record in self._records.values() + ] + return { + "limit_usd": float(self._limit), + "tax_multiplier": float(_TAX), + "charged_estimate_usd": float(charged), + "committed_upper_usd": float(committed), + "unknown_upper_usd": float(committed - charged), + "remaining_usd": float(max(self._limit - committed, Decimal(0))), + "requests": len(records), + "status_counts": dict(Counter(record["status"] for record in records)), + "halted": self._halted or self._poisoned, + "request_details": records, + } + + +def _upstage_base(base: str) -> str: + try: + parsed = urlsplit(base) + valid = ( + parsed.scheme == "https" + and parsed.hostname == "api.upstage.ai" + and parsed.port in {None, 443} + and parsed.username is None + and parsed.password is None + and not parsed.query + and not parsed.fragment + and not any(character.isspace() for character in base) + ) + except (TypeError, ValueError): + valid = False + if not valid: + raise BudgetError("unapproved_upstage_endpoint") + return "https://api.upstage.ai" + parsed.path.rstrip("/") + + +class BudgetedProvider(BaseProvider): + """Reusable HTTP client with no redirects, proxies, hidden retries or streaming. + + Routing comes solely from the exact ``rates`` map. Upstage uses its configured + HTTPS base only on api.upstage.ai; OpenAI and Timely use fixed HTTPS endpoints. + Timely reads only TIMELY_API_KEY and never falls back from a provider pin. + A transport injection is for offline testing, not a second routing mechanism. + """ + + def __init__( + self, + ledger: BudgetLedger, + rates: Mapping[str, ModelRate], + *, + request_timeout: float = 45.0, + transport: httpx.AsyncBaseTransport | None = None, + ssl_context: ssl.SSLContext | None = None, + ) -> None: + if ( + isinstance(request_timeout, bool) + or not isinstance(request_timeout, (int, float)) + or not math.isfinite(request_timeout) + or request_timeout <= 0 + ): + raise BudgetError("invalid_request_timeout") + self._ssl_context = ssl_context + self._validate_tls_context() + if not rates or any( + not isinstance(model, str) + or not _MODEL.fullmatch(model) + or not isinstance(rate, ModelRate) + for model, rate in rates.items() + ): + raise BudgetError("invalid_model_rates") + config_error = False + try: + config = get_provider_config() + except Exception: + config_error = True + if config_error: + raise BudgetError("invalid_provider_configuration") + self._urls = { + "openai": "https://api.openai.com/v1/chat/completions", + "timely": "https://api.timelyrouter.ai/v1/chat/completions", + } + if any(rate.provider == "upstage" for rate in rates.values()): + self._urls["upstage"] = _upstage_base(config.upstage_api_base) + "/chat/completions" + self._keys = { + "upstage": config.upstage_api_key, + "openai": config.openai_api_key, + "timely": os.environ.get("TIMELY_API_KEY"), + } + if any( + rate.provider_pin is not None + and self._metadata(rate.provider_pin, pattern=_PROVIDER_PIN) is None + for rate in rates.values() + ): + raise BudgetError("invalid_provider_pin") + self.ledger = ledger + self._rates = dict(rates) + self._timeout = float(request_timeout) + self._client = httpx.AsyncClient( + timeout=request_timeout, + transport=transport, + follow_redirects=False, + trust_env=False, + verify=ssl_context if ssl_context is not None else True, + ) + self._closed = False + self._request_ids: list[str] = [] + + def _validate_tls_context(self) -> None: + context = self._ssl_context + if context is not None and ( + not isinstance(context, ssl.SSLContext) + or context.verify_mode != ssl.CERT_REQUIRED + or not context.check_hostname + ): + raise BudgetError("unsafe_tls_context") + + @property + def request_ids(self) -> tuple[str, ...]: + """Reservations owned by this provider instance, including failed calls.""" + return tuple(self._request_ids) + + async def aclose(self) -> None: + """Close the HTTP pool; the caller owns and separately closes the ledger.""" + self._closed = True + await self._client.aclose() + + def _metadata(self, value: Any, *, pattern: re.Pattern[str] = _METADATA) -> str | None: + if ( + not isinstance(value, str) + or not pattern.fullmatch(value) + or any(key and key in value for key in self._keys.values()) + ): + return None + return value + + async def chat_completion( + self, + messages: list[dict[str, str]], + model: str, + temperature: float | None = 0.0, + max_tokens: int = 8000, + reasoning_effort: str | None = None, + response_format: dict[str, Any] | None = None, + tools: list[dict[str, Any]] | None = None, + tool_choice: str | dict[str, Any] | None = None, + ) -> dict[str, Any]: + if model not in self._rates: + raise UnknownModelError("model_has_no_approved_rate") + if self._closed: + raise BudgetError("provider_closed") + self._validate_tls_context() + rate = self._rates[model] + if not self._keys[rate.provider]: + raise ProviderRequestError(rate.provider, None, "missing_api_key") + if not _token_count(max_tokens) or max_tokens == 0: + raise BudgetError("invalid_output_token_limit") + # Byte bounds apply only to text. Media references can incur token costs + # unrelated to their URL/JSON length and therefore must fail closed. + allowed_fields = {"role", "content", "name", "tool_calls", "tool_call_id", "refusal"} + if ( + not isinstance(messages, list) + or not messages + or any( + not isinstance(message, dict) + or set(message) - allowed_fields + or message.get("role") not in {"system", "developer", "user", "assistant", "tool"} + or (message.get("content") is not None and not isinstance(message["content"], str)) + for message in messages + ) + ): + raise BudgetError("only_text_messages_have_supported_token_bounds") + if tools is not None and ( + not isinstance(tools, list) + or any( + not isinstance(tool, dict) + or tool.get("type") != "function" + or not isinstance(tool.get("function"), dict) + for tool in tools + ) + ): + raise BudgetError("only_local_function_tools_have_approved_pricing") + payload: dict[str, Any] = {"model": model, "messages": messages} + if rate.provider in {"openai", "timely"} and _MODERN_OPENAI.match(model): + payload["max_completion_tokens"] = max_tokens + if reasoning_effort is not None: + payload["reasoning_effort"] = reasoning_effort + # Unspecified reasoning may use the model's reasoning-on default. + if reasoning_effort == "none" and temperature is not None: + payload["temperature"] = temperature + elif rate.provider == "timely": + payload["max_completion_tokens"] = max_tokens + if temperature is not None: + payload["temperature"] = temperature + if reasoning_effort is not None: + payload["reasoning_effort"] = reasoning_effort + else: + payload.update(max_tokens=max_tokens, temperature=temperature) + if rate.provider == "upstage" and reasoning_effort: + payload["reasoning_effort"] = reasoning_effort + if rate.provider == "timely": + payload["stream"] = False + if response_format: + payload["response_format"] = response_format + if tools: + payload["tools"] = tools + if tool_choice is not None: + payload["tool_choice"] = tool_choice + invalid_payload = False + try: + body = json.dumps( + payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False + ).encode("utf-8") + except (TypeError, ValueError, UnicodeError): + invalid_payload = True + if invalid_payload: + raise BudgetError("invalid_text_request_payload") + # Includes full schema/tool JSON bytes, not just visible message text. + # One token per UTF-8 byte is conservative for byte-based tokenizers; + # protocol/special-token overhead is reserved independently per message. + input_upper = len(body) + 1024 + 256 * len(messages) + digest_prefix = rate.provider.encode() + b"\0" + if rate.provider == "timely": + digest_prefix += (rate.provider_pin or "").encode() + b"\0" + digest = hashlib.sha256(digest_prefix + body).hexdigest() + reservation = await self.ledger._reserve(model, rate, digest, input_upper, max_tokens) + self._request_ids.append(reservation) + data: Any = None + status_code: int | None = None + request_id: str | None = None + observed_provider: str | None = None + provider_mismatch = False + error_code: str | None = None + failure_status = "transport_error" + headers = { + "Authorization": f"Bearer {self._keys[rate.provider]}", + "Content-Type": "application/json", + } + if rate.provider_pin is not None: + headers["X-Provider-Pin"] = rate.provider_pin + try: + # httpx's timeout alone bounds inactivity, not total elapsed time. + async with asyncio.timeout(self._timeout): + response = await self._client.post( + self._urls[rate.provider], + headers=headers, + content=body, + ) + status_code = response.status_code + request_id = self._metadata( + response.headers.get("x-request-id") + or response.headers.get("request-id") + or response.headers.get("x-upstage-request-id") + ) + if rate.provider == "timely": + observed_provider = self._metadata( + response.headers.get("x-provider"), pattern=_PROVIDER_PIN + ) + provider_mismatch = ( + rate.provider_pin is not None and observed_provider != rate.provider_pin + ) + try: + data = response.json() + except (ValueError, UnicodeError): + data = None + if not 200 <= status_code < 300: + failure_status, error_code = "http_error", "http_error" + if isinstance(data, dict) and isinstance(data.get("error"), dict): + remote_code = data["error"].get("code") + if isinstance(remote_code, str) and remote_code in _HTTP_CODES: + error_code = remote_code + elif not isinstance(data, dict): + failure_status, error_code = "invalid_response", "invalid_response" + except asyncio.CancelledError: + await self.ledger._settle(reservation, "cancelled") + raise + except (TimeoutError, httpx.TimeoutException): + failure_status, error_code = "timeout", "timeout" + except Exception: + failure_status, error_code = "transport_error", "transport_error" + # Raise outside the exception handler: __context__ must not retain an + # httpx exception containing a credential-bearing request or response. + if error_code is not None: + # If routing violated a pin, account any supplied valid usage even + # when the response itself failed. Otherwise retain the historical + # unknown-cost treatment of failed requests. + error_usage = ( + _usage_counts(data.get("usage")) + if provider_mismatch and isinstance(data, dict) + else None + ) + await self.ledger._settle( + reservation, + failure_status, + error_usage, + provider_request_id=request_id, + observed_provider=observed_provider, + ) + raise ProviderRequestError(rate.provider, status_code, error_code) + usage = data.get("usage") + counts = _usage_counts(usage) + observed_model = self._metadata(data.get("model")) + content, think_content, finish_reason, tool_calls = "", "", None, [] + try: + choice = data["choices"][0] + message = choice["message"] + content = message.get("content") or "" + finish_reason = choice.get("finish_reason") + tool_calls = message.get("tool_calls") or [] + if not isinstance(content, str) or not isinstance(tool_calls, list): + error_code = "invalid_response" + elif message.get("refusal"): + error_code = "completion_refused" + elif finish_reason == "length": + error_code = "completion_length" + elif finish_reason == "content_filter": + error_code = "completion_filtered" + elif tool_calls or finish_reason == "tool_calls": + if ( + not tools + or finish_reason != "tool_calls" + or not tool_calls + or any(not isinstance(call, dict) for call in tool_calls) + ): + error_code = "unexpected_tool_calls" + elif finish_reason != "stop": + error_code = "invalid_finish_reason" + if isinstance(content, str) and rate.provider == "upstage": + match = _THINK.search(content) + if match: + think_content = match.group(1).strip() + content = _THINK.sub("", content).strip() + if error_code is None and not tool_calls and not content.strip(): + error_code = "empty_completion" + except (KeyError, IndexError, TypeError, AttributeError): + error_code = "invalid_response" + if provider_mismatch: + error_code = "provider_pin_mismatch" + settlement_status = ( + "invalid_response" + if provider_mismatch + else "incomplete_output" + if error_code + else "success" + if counts is not None + else "unknown_usage" + ) + cost = await self.ledger._settle( + reservation, settlement_status, counts, observed_model, request_id, observed_provider + ) + if self.ledger._halted: + raise BudgetExceededError("observed_charge_exceeded_reservation") + if error_code: + raise ProviderRequestError(rate.provider, status_code, error_code) + result = { + "content": content, + "usage": usage if isinstance(usage, dict) else {}, + "finish_reason": finish_reason, + "tool_calls": tool_calls, + "observed_model": observed_model, + "provider_pin": rate.provider_pin, + "observed_provider": observed_provider, + "provider_request_id": request_id, + "cost_usd": float(cost) if cost is not None else None, + } + if rate.provider == "upstage": + result["think_content"] = think_content + return result + + async def chat_completion_json( + self, + messages: list[dict[str, str]], + model: str, + temperature: float = 0.0, + max_tokens: int = 8000, + ) -> dict[str, Any]: + result = await self.chat_completion(messages, model, temperature, max_tokens) + content = result["content"] + invalid_json = False + try: + try: + parsed = json.loads(content) + except json.JSONDecodeError: + match = re.search(r"```(?:json)?\s*(.*?)```", content, re.DOTALL) + if not match: + raise + parsed = json.loads(match.group(1).strip()) + if not isinstance(parsed, dict): + invalid_json = True + except (ValueError, TypeError): + invalid_json = True + if invalid_json: + raise ProviderRequestError(self._rates[model].provider, 200, "invalid_json_object") + return parsed diff --git a/solar_eval/providers/openai_compat.py b/solar_eval/providers/openai_compat.py new file mode 100644 index 0000000000000000000000000000000000000000..589693e5bda432a2a4cadf4ac13d43741c04754b --- /dev/null +++ b/solar_eval/providers/openai_compat.py @@ -0,0 +1,201 @@ +"""OpenAI 호환 chat/completions 제공자 — Timely Router(https://api.timelyrouter.ai/v1) 처럼 여러 모델을 한 엔드포인트로 묶은 라우터용. + +Upstage 제공자와 같은 반환 모양(`{content, usage, finish_reason, tool_calls, think_content}`)을 지킨다. 라우터 뒤의 모델마다 +받는 인자가 달라서(예: Claude Sonnet 5.5·GPT-5.6 계열은 `temperature` 를 거부한다) 400 응답 본문이 그 인자를 지목하면 +그 인자를 빼고 한 번 다시 보낸다 — 모델별 사전을 두지 않는다. 429 는 `Retry-After` 를 따른다. + +파이프라인 스텝은 `model: timely:claude-opus-5-5` 처럼 접두어로 이 제공자를 고른다(`providers.routed.RoutedProvider`). +키는 env `TIMELY_API_KEY`(또는 solar-eval.yaml `timely.api_key`). 고객 텍스트가 Upstage 밖으로 나가므로, 써도 되는가는 +case 의 데이터 정책(계약)이 정한다 — 레포 기본은 외부 API 허용(SECURITY.md «데이터 처리»), 엔진에 게이트는 없다. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import logging +import re +from typing import Any + +import httpx + +from solar_eval.config import get_provider_config +from solar_eval.providers.base import BaseProvider + +logger = logging.getLogger(__name__) + +MAX_RETRIES = 4 +RETRY_DELAYS = (5, 10, 20, 30) # seconds — Retry-After 가 없을 때 +MAX_RETRY_AFTER = 60.0 +TIMEOUT = 300.0 +#: 400 본문에서 "이 인자를 거부한다" 를 읽어 빼고 다시 보낼 수 있는 인자들 +DROPPABLE = ("temperature", "reasoning_effort", "response_format", "top_p") +_FENCE_RE = re.compile(r"^```(?:json)?\s*|\s*```$", re.S) + + +class OpenAICompatProvider(BaseProvider): + """`base_url` 의 `/chat/completions` 를 Bearer 키로 부르는 제공자.""" + + name = "openai-compat" + + def __init__(self, base_url: str, api_key: str, name: str | None = None) -> None: + self.api_base = base_url.rstrip("/") + self.api_key = api_key + if name: + self.name = name + #: 거부당한 (model, 인자) — 같은 모델에 같은 인자를 다시 보내지 않는다 + self._dropped: set[tuple[str, str]] = set() + + async def chat_completion( + self, + messages: list[dict[str, str]], + model: str, + temperature: float = 0.0, + max_tokens: int = 8000, + reasoning_effort: str | None = None, + response_format: dict[str, Any] | None = None, + tools: list[dict[str, Any]] | None = None, + tool_choice: str | dict[str, Any] | None = None, + ) -> dict[str, Any]: + if not self.api_key: + raise RuntimeError( + f"{self.name}: API 키가 없다 — TIMELY_API_KEY 또는 solar-eval.yaml timely.api_key" + ) + payload: dict[str, Any] = { + "model": model, + "messages": messages, + "temperature": temperature, + "max_tokens": max_tokens, + } + if reasoning_effort: + payload["reasoning_effort"] = reasoning_effort + if response_format: + payload["response_format"] = response_format + if tools: + payload["tools"] = tools + if tool_choice is not None: + payload["tool_choice"] = tool_choice + for key in DROPPABLE: + if (model, key) in self._dropped: + payload.pop(key, None) + + data = await self._post_with_retries(payload) + choice = data["choices"][0] + message = choice.get("message") or {} + return { + "content": message.get("content") or "", + "think_content": "", + "usage": _normalize_usage(data.get("usage") or {}), + "finish_reason": choice.get("finish_reason", "stop"), + "tool_calls": message.get("tool_calls") or [], + } + + async def chat_completion_json( + self, + messages: list[dict[str, str]], + model: str, + temperature: float = 0.0, + max_tokens: int = 8000, + ) -> dict[str, Any]: + result = await self.chat_completion(messages, model, temperature, max_tokens) + content = _FENCE_RE.sub("", (result.get("content") or "").strip()) + try: + return json.loads(content) + except json.JSONDecodeError as exc: + raise RuntimeError(f"{self.name}: JSON 응답을 파싱할 수 없다 ({model})") from exc + + # ── 내부 ──────────────────────────────────────────────────────────── + + async def _post_with_retries(self, payload: dict[str, Any]) -> dict[str, Any]: + model = str(payload.get("model")) + attempt = 0 + while True: + try: + async with httpx.AsyncClient(timeout=TIMEOUT) as client: + response = await client.post( + f"{self.api_base}/chat/completions", + headers={"Authorization": f"Bearer {self.api_key}"}, + json=payload, + ) + except httpx.HTTPError as exc: + attempt = await self._backoff(attempt, model, f"{type(exc).__name__}") + continue + if response.status_code == 200: + return response.json() + if response.status_code == 400: + dropped = _droppable_param(response.text) + if dropped and dropped in payload: + logger.warning(f"{self.name}: {model} 이 {dropped} 를 거부해 빼고 다시 보낸다") + self._dropped.add((model, dropped)) + payload.pop(dropped) + continue + raise RuntimeError(f"{self.name}: 400 ({model}): {_error_message(response.text)}") + if response.status_code in (401, 402, 403, 404): + raise RuntimeError( + f"{self.name}: {response.status_code} ({model}): {_error_message(response.text)}" + ) + if response.status_code == 429: + attempt = await self._backoff( + attempt, model, "429", retry_after=response.headers.get("Retry-After") + ) + continue + attempt = await self._backoff(attempt, model, f"HTTP {response.status_code}") + + async def _backoff( + self, attempt: int, model: str, why: str, *, retry_after: str | None = None + ) -> int: + if attempt >= MAX_RETRIES: + raise RuntimeError(f"{self.name}: {model} 호출이 {MAX_RETRIES}회 실패했다 ({why})") + delay = float(RETRY_DELAYS[min(attempt, len(RETRY_DELAYS) - 1)]) + if retry_after: + with contextlib.suppress(ValueError): + delay = min(float(retry_after), MAX_RETRY_AFTER) + logger.warning( + f"{self.name}: {model} {why} — {delay:.0f}s 뒤 재시도 ({attempt + 1}/{MAX_RETRIES})" + ) + await asyncio.sleep(delay) + return attempt + 1 + + +def _droppable_param(body: str) -> str | None: + """400 본문이 지목한 인자 — `param` 필드가 있으면 그것, 없으면 메시지에 이름이 나오는 인자.""" + try: + err = (json.loads(body) or {}).get("error") or {} + except (json.JSONDecodeError, AttributeError): + err = {} + param = err.get("param") if isinstance(err, dict) else None + if isinstance(param, str) and param in DROPPABLE: + return param + message = str(err.get("message", "") if isinstance(err, dict) else body) + for key in DROPPABLE: + # `temperature` · 'temperature' · "temperature" · field temperature … — 어떤 인용 부호든 단어 경계로 본다 + if re.search(rf"(? str: + try: + err = (json.loads(body) or {}).get("error") or {} + return str(err.get("message") or err.get("code") or body)[:300] + except (json.JSONDecodeError, AttributeError): + return body[:300] + + +def _normalize_usage(usage: dict[str, Any]) -> dict[str, int]: + return { + "prompt_tokens": int(usage.get("prompt_tokens") or 0), + "completion_tokens": int(usage.get("completion_tokens") or 0), + "total_tokens": int(usage.get("total_tokens") or 0), + } + + +class TimelyProvider(OpenAICompatProvider): + """Timely Router — 키·엔드포인트는 `ProviderConfig`(env `TIMELY_API_KEY`·`TIMELY_API_BASE`).""" + + name = "timely" + + def __init__(self) -> None: + cfg = get_provider_config() + super().__init__(cfg.timely_api_base, cfg.timely_api_key, name="timely") diff --git a/solar_eval/providers/routed.py b/solar_eval/providers/routed.py new file mode 100644 index 0000000000000000000000000000000000000000..55871e3f0d279cba27051344b29071986978a173 --- /dev/null +++ b/solar_eval/providers/routed.py @@ -0,0 +1,75 @@ +"""모델 접두어로 제공자를 고르는 래퍼 — 스텝마다 다른 제공자를 쓰기 위한 유일한 통로. + +`model: timely:claude-opus-5-5` → `routes["timely"].chat_completion(model="claude-opus-5-5", …)`. +접두어가 등록돼 있지 않으면(`ft:solar-news-correction` 처럼 콜론이 있어도) 기본 제공자에 모델 문자열 그대로 넘긴다. +라우팅은 추론 제공자에만 쓰고, judge 제공자는 `judge_config` 선언으로 따로 고른다. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from solar_eval.providers.base import BaseProvider + +ROUTE_SEP = ":" + + +def split_route( + model: str, routes: Mapping[str, BaseProvider] | Mapping[str, Any] +) -> tuple[str | None, str]: + """(접두어 | None, 제공자에 넘길 모델 이름). 등록된 접두어만 뗀다.""" + head, sep, rest = model.partition(ROUTE_SEP) + if sep and rest and head in routes: + return head, rest + return None, model + + +def route_prefixes( + models: list[str] | tuple[str, ...], routes: Mapping[str, Any] +) -> tuple[str, ...]: + """모델 목록 가운데 외부 라우트를 타는 것들(`접두어:모델`) — egress 사전 거부에 쓴다.""" + return tuple(m for m in models if split_route(m, routes)[0] is not None) + + +class RoutedProvider(BaseProvider): + def __init__(self, default: BaseProvider, routes: Mapping[str, BaseProvider]) -> None: + self.default = default + self.routes = dict(routes) + + def resolve(self, model: str) -> tuple[BaseProvider, str]: + prefix, name = split_route(model, self.routes) + return (self.routes[prefix] if prefix else self.default), name + + async def chat_completion( + self, + messages: list[dict[str, str]], + model: str, + temperature: float = 0.0, + max_tokens: int = 8000, + reasoning_effort: str | None = None, + response_format: dict[str, Any] | None = None, + tools: list[dict[str, Any]] | None = None, + tool_choice: str | dict[str, Any] | None = None, + ) -> dict[str, Any]: + provider, name = self.resolve(model) + return await provider.chat_completion( + messages=messages, + model=name, + temperature=temperature, + max_tokens=max_tokens, + reasoning_effort=reasoning_effort, + response_format=response_format, + tools=tools, + tool_choice=tool_choice, + ) + + async def chat_completion_json( + self, + messages: list[dict[str, str]], + model: str, + temperature: float = 0.0, + max_tokens: int = 8000, + ) -> dict[str, Any]: + provider, name = self.resolve(model) + return await provider.chat_completion_json(messages, name, temperature, max_tokens) diff --git a/solar_eval/providers/upstage.py b/solar_eval/providers/upstage.py index 9bf2b75193f35fdd7534079d2b5a212bf44b64a7..336fb2f4046b5a3586782510f47fd5d3732a09e9 100644 --- a/solar_eval/providers/upstage.py +++ b/solar_eval/providers/upstage.py @@ -1,9 +1,7 @@ import asyncio import json import logging -import random import re -import time from typing import Any import httpx @@ -17,34 +15,6 @@ THINK_PATTERN = re.compile(r"(.*?)", re.DOTALL) MAX_RETRIES = 3 RETRY_DELAYS = [5, 10, 20] # seconds -# 429 is expected on low-quota models (e.g. solar-mini4: 100 RPM / 50k TPM), -# so rate-limit waits don't consume the generic retry budget -- otherwise the -# sample is dropped from results.jsonl and the score is computed on survivors. -RATE_LIMIT_MAX_WAITS = 40 -RATE_LIMIT_MIN_DELAY = 2.0 -RATE_LIMIT_MAX_DELAY = 65.0 -RATE_LIMIT_RESET_HEADERS = ( - "X-Upstage-Ratelimit-Reset-Tokens", - "X-Upstage-Ratelimit-Reset-Requests", -) -# Transient network failures (DNS/connect) can last minutes; wait them out -# instead of failing the sample. -CONNECT_MAX_WAITS = 40 -CONNECT_RETRY_DELAY = 15.0 - - -def _rate_limit_delay(response: httpx.Response) -> float: - """Seconds to wait before retrying a 429, from Upstage reset headers (epoch secs).""" - now = time.time() - resets = [] - for header in RATE_LIMIT_RESET_HEADERS: - try: - resets.append(float(response.headers[header]) - now) - except (KeyError, ValueError): - continue - base = max(resets) if resets else RETRY_DELAYS[0] - jittered = base + random.uniform(0, 3) - return min(max(jittered, RATE_LIMIT_MIN_DELAY), RATE_LIMIT_MAX_DELAY) class UpstageProvider(BaseProvider): @@ -53,21 +23,6 @@ class UpstageProvider(BaseProvider): self.api_key = cfg.upstage_api_key self.api_base = cfg.upstage_api_base - @staticmethod - async def _backoff_or_raise(error: Exception, attempt: int) -> int: - """Sleep before the next generic retry, or raise once retries are exhausted.""" - delay = RETRY_DELAYS[attempt] if attempt < len(RETRY_DELAYS) else RETRY_DELAYS[-1] - logger.warning( - f"Upstage API attempt {attempt + 1}/{MAX_RETRIES} failed: {error}. " - f"Retrying in {delay}s..." - ) - if attempt == MAX_RETRIES - 1: - raise RuntimeError( - f"Upstage API failed after {MAX_RETRIES} retries: {error}" - ) from error - await asyncio.sleep(delay) - return attempt + 1 - async def chat_completion( self, messages: list[dict[str, str]], @@ -94,10 +49,7 @@ class UpstageProvider(BaseProvider): if tool_choice is not None: payload["tool_choice"] = tool_choice - attempt = 0 - rate_limit_waits = 0 - connect_waits = 0 - while True: + for attempt in range(MAX_RETRIES): try: async with httpx.AsyncClient(timeout=300.0) as client: response = await client.post( @@ -108,31 +60,17 @@ class UpstageProvider(BaseProvider): response.raise_for_status() data = response.json() break - except httpx.HTTPStatusError as e: - if e.response.status_code == 429 and rate_limit_waits < RATE_LIMIT_MAX_WAITS: - rate_limit_waits += 1 - delay = _rate_limit_delay(e.response) - logger.debug( - f"Upstage API rate limited (model={model}, " - f"wait {rate_limit_waits}/{RATE_LIMIT_MAX_WAITS}). " - f"Retrying in {delay:.1f}s..." - ) - await asyncio.sleep(delay) - continue - attempt = await self._backoff_or_raise(e, attempt) - except httpx.TimeoutException as e: - attempt = await self._backoff_or_raise(e, attempt) - except httpx.TransportError as e: - if connect_waits >= CONNECT_MAX_WAITS: - raise RuntimeError( - f"Upstage API unreachable after {CONNECT_MAX_WAITS} waits: {e}" - ) from e - connect_waits += 1 + except (httpx.HTTPStatusError, httpx.TimeoutException) as e: + delay = RETRY_DELAYS[attempt] if attempt < len(RETRY_DELAYS) else RETRY_DELAYS[-1] logger.warning( - f"Upstage API connection error (wait {connect_waits}/" - f"{CONNECT_MAX_WAITS}): {e}. Retrying in {CONNECT_RETRY_DELAY}s..." + f"Upstage API attempt {attempt + 1}/{MAX_RETRIES} failed: {e}. " + f"Retrying in {delay}s..." ) - await asyncio.sleep(CONNECT_RETRY_DELAY) + if attempt == MAX_RETRIES - 1: + raise RuntimeError( + f"Upstage API failed after {MAX_RETRIES} retries: {e}" + ) from e + await asyncio.sleep(delay) choice = data["choices"][0] message = choice["message"] diff --git a/solar_eval/rescore/__init__.py b/solar_eval/rescore/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..55759047209a61bcf87578846fde2482a6189b9b --- /dev/null +++ b/solar_eval/rescore/__init__.py @@ -0,0 +1,26 @@ +"""오프라인 재채점기 `proofread_diff@2` (계획 2026-09-26 L3b §3.2). + +모듈 구성(D1 — 순수 채점과 I/O 를 가른다): + +- `proofread_diff_v2` — 결과 행 하나의 순수 채점(표준 라이브러리 + `evaluators.scoring` 만). 평가기 `impl_hash` 폐포의 시작점. +- `evaluator_doc` — `evaluator/v1` 문서·`implementation_hash`. +- `drafts` — 판정 초안·`producer_aggregates.json`. +- `from_bundle` — run 번들 하나 → 채점 번들(`rescore_run_bundle`). +- `case` — 미러의 case 전체(`rescore_case`). +- `paths` — 미러 영역·기본 출력 위치. + +이 패키지 `__init__` 은 순수 모듈의 이름만 다시 내보낸다 — `poc_bundle` 을 끌어오는 I/O 모듈은 쓰는 쪽이 직접 import 한다 +(CLI 는 명령 함수 안에서 import 한다, `cli/commands/score.py`). +""" + +from solar_eval.rescore.proofread_diff_v2 import ( + NORMALIZERS, + REASONS, + RowError, + RowScore, + label_of, + normalize, + score_row, +) + +__all__ = ["NORMALIZERS", "REASONS", "RowError", "RowScore", "label_of", "normalize", "score_row"] diff --git a/solar_eval/rescore/case.py b/solar_eval/rescore/case.py new file mode 100644 index 0000000000000000000000000000000000000000..d7aa0fd5c5602d4060fc2b5d5cff1a6304946c2c --- /dev/null +++ b/solar_eval/rescore/case.py @@ -0,0 +1,190 @@ +"""case 전체 재채점 — `solar-eval score proofread-diff --case a/c --all` (계획 2026-09-26 L3b §3.2·D6). + +미러의 `accounts///eval/bundles` 에서 run_id 별 최신 커밋 번들을 `poc_bundle.adopt` 로 고르고(최신이 invalid 면 이전으로 +넘어가지 않고 `latest_bundle_invalid` skip), 채택된 번들마다 `rescore_run_bundle` 을 부른다. + +- `bundles` 영역 디렉토리가 없으면 case 오류 `case_area_missing`(해석한 경로를 싣는다) — case 오타나 `--mirror-root` 층 + 어긋남(§2.1 "미러 루트 층")이 "할 일 없음·종료 0" 으로 숨지 않게. +- 영역은 있는데 커밋된 run 번들이 0 이면 case 오류 `case_area_empty`(해석한 경로) — 같은 이유다(계획 2026-09-27 L6 §2.11, + L3b 후속 3). run 필터가 있어도 이 판정이 먼저다. +- 영역에 매니페스트를 못 읽는 번들이 하나라도 있으면(`AdoptError`) case 전체가 오류다 — 어느 run 의 번들인지 모르는 채로 + 채택하지 않는다(fail closed). run 필터가 있어도 영역 전체를 읽는다 — 필터가 fail closed 를 약하게 만들지 않게. +- **run 필터**(`run_ids`, L6 §2.11 — 엔진 종료 훅이 run 하나만 재채점한다): 채택 목록(run_id 별 커밋 번들)을 거른다. 채택 + 규칙은 필터 없는 case 모드와 같다. 요청한 run 이 영역에 없으면 run 마다 오류 `run_not_in_mirror`. 값 규칙 + (`is_valid_run_id` — 공백만이 아님·제어 문자 없음·512자 이하)은 CLI `--run` 과 이 함수 경계가 함께 쓴다. +- run 하나의 오류(`RescoreError`)는 그 run 만 오류로 세고 다음 run 을 계속한다 — 종료 코드는 CLI 가 `errors` 로 정한다. +- 평가기 문서는 case 한 번에 한 번 뜬다(모든 run 이 같은 `evaluator_config_hash` 를 쓴다). +""" + +from __future__ import annotations + +import re +from collections import Counter +from collections.abc import Collection, Mapping, Sequence +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Any + +from poc_bundle.adopt import AdoptError, Committed, adopt_run_bundle, committed_run_bundles + +from solar_eval.rescore.evaluator_doc import current_evaluator_document +from solar_eval.rescore.from_bundle import ( + ERR_ADOPT, + SKIP_LATEST_INVALID, + RescoreError, + RescoreOutcome, + rescore_run_bundle, +) +from solar_eval.rescore.paths import BUNDLES_AREA, mirror_area_dir, resolve_mirror_root + +#: case 오류 — 미러 `bundles` 영역이 없다(case 오타·`--mirror-root` 층 어긋남). 아무것도 안 한 채 성공하지 않게. +ERR_CASE_AREA_MISSING = "case_area_missing" +#: case 오류 — `bundles` 영역은 있는데 커밋된 run 번들이 0 이다(L6 §2.11). +ERR_CASE_AREA_EMPTY = "case_area_empty" +#: run 오류 — run 필터로 요청한 run 의 커밋 번들이 미러 영역에 없다(L6 §2.11). +ERR_RUN_NOT_IN_MIRROR = "run_not_in_mirror" +#: run 필터 값 하나의 글자 수 상한(코드 포인트) — ES `hub ingest --expect-run` 과 같다(L6 §2.9). +MAX_RUN_ID_LENGTH = 512 +# C0·DEL·C1 제어 문자 — ES `--expect-run` 과 같은 범위. stderr `error …` 줄을 위조하지 못하게 한다 +_CONTROL_CHARACTER = re.compile(r"[\x00-\x1f\x7f-\x9f]") + + +def is_valid_run_id(value: object) -> bool: + """run 필터 값 하나 — 공백만이 아닌 문자열, 제어 문자 없음, `MAX_RUN_ID_LENGTH` 자 이하(CLI `--run` 과 같은 규칙).""" + return ( + isinstance(value, str) + and bool(value.strip()) + and len(value) <= MAX_RUN_ID_LENGTH + and _CONTROL_CHARACTER.search(value) is None + ) + + +@dataclass(frozen=True) +class RunFailure: + """run 하나(또는 case 전체, `run_id=None`)의 오류 — id·코드와, 있으면 경로만(메시지 원문은 싣지 않는다).""" + + run_id: str | None + code: str + detail: str | None = None + + +@dataclass +class CaseSummary: + """case 재채점 결과 — run 결과 목록과 오류.""" + + outcomes: list[RescoreOutcome] = field(default_factory=list) + failures: list[RunFailure] = field(default_factory=list) + + def to_json(self) -> dict[str, Any]: + """§2.1 요약 줄 `{written, exists, skipped:{code:n}, errors, n_original_missing}`.""" + statuses = Counter(o.status for o in self.outcomes) + skipped = Counter(o.code for o in self.outcomes if o.status == "skip") + return { + "written": statuses.get("written", 0), + "exists": statuses.get("exists", 0), + "skipped": dict(sorted(skipped.items())), + "errors": len(self.failures), + "n_original_missing": sum(o.n_original_missing for o in self.outcomes), + } + + +def rescore_case( + account: str, + case: str, + *, + mirror_root: Path | None = None, + out_dir: Path | None = None, + now: datetime | None = None, + repo_root: Path | None = None, + run_ids: Collection[str] | None = None, +) -> CaseSummary: + """미러의 한 case 를 재채점한다. `out_dir` 가 None 이면 미러 `evaluations` 에 놓는다(`mirror_bundle`). + + `mirror_root` 는 미러 버킷의 부모(기본 데이터 홈 `blob-mirror`) — 입력 run 번들도 여기서 읽는다. + `run_ids` 를 주면 그 run 들만 재채점한다(없는 run 은 `run_not_in_mirror`). 문자열 하나(`TypeError`)나 빈 모음· + `is_valid_run_id` 를 못 넘는 원소(`ValueError`)는 호출자 버그라 즉시 거부한다 — 글자 단위 필터나 "할 일 없음·성공", + stderr 줄 위조가 되지 않게. + """ + wanted = _checked_run_ids(run_ids) + summary = CaseSummary() + by_run = _committed_by_run(mirror_root, account, case, summary) + if by_run is None: + return summary + selected = sorted(by_run) if wanted is None else sorted(wanted & by_run.keys()) + if wanted is not None: + missing = sorted(wanted - by_run.keys()) + summary.failures.extend(RunFailure(r, ERR_RUN_NOT_IN_MIRROR) for r in missing) + evaluator = current_evaluator_document() + root = resolve_mirror_root(mirror_root) + for run_id in selected: + _rescore_one(run_id, by_run[run_id], summary, out_dir, root, now, evaluator, repo_root) + return summary + + +def _checked_run_ids(run_ids: Collection[str] | None) -> frozenset[str] | None: + """run 필터 경계 검사 — None(필터 없음) 또는 `is_valid_run_id` 를 넘는 값의 비어 있지 않은 모음.""" + if run_ids is None: + return None + if isinstance(run_ids, str | bytes): + raise TypeError("run_ids must be a collection of run ids, not a single string") + wanted = frozenset(run_ids) + if not wanted: + raise ValueError("run_ids must not be empty (pass None for every run)") + if not all(is_valid_run_id(r) for r in wanted): + raise ValueError( + "run_ids must be non-blank strings without control characters," + f" at most {MAX_RUN_ID_LENGTH} characters" + ) + return wanted + + +def _committed_by_run( + mirror_root: Path | None, account: str, case: str, summary: CaseSummary +) -> Mapping[str, Sequence[Committed]] | None: + """영역의 커밋 run 번들(run_id 별). case 오류면 `summary` 에 싣고 None.""" + bundles_dir = mirror_area_dir(mirror_root, account, case, BUNDLES_AREA) + if not bundles_dir.is_dir(): + # `committed_run_bundles` 는 없는 영역을 빈 dict 로 본다 — 그대로 두면 요약이 전부 0 인 채 종료 0 이다 + summary.failures.append(RunFailure(None, ERR_CASE_AREA_MISSING, str(bundles_dir))) + return None + try: + by_run = committed_run_bundles(bundles_dir) + except AdoptError: + summary.failures.append(RunFailure(None, ERR_ADOPT)) + return None + if not by_run: + # 있는데 비었다 — 백필 전이거나 영역 경로가 어긋났다. 할 일 없음·종료 0 으로 숨기지 않는다 + summary.failures.append(RunFailure(None, ERR_CASE_AREA_EMPTY, str(bundles_dir))) + return None + return by_run + + +def _rescore_one( + run_id: str, + cands: Sequence[Committed], + summary: CaseSummary, + out_dir: Path | None, + root: Path, + now: datetime | None, + evaluator: Mapping[str, Any], + repo_root: Path | None, +) -> None: + """run 하나 — 최신 번들 채택(invalid 면 skip) 뒤 재채점. 결과·오류를 `summary` 에 싣는다.""" + adoption = adopt_run_bundle(cands) + if adoption.status != "ok": + summary.outcomes.append(RescoreOutcome("skip", run_id, code=SKIP_LATEST_INVALID)) + return + try: + outcome = rescore_run_bundle( + adoption.committed.path, + out_dir, + mirror_root=None if out_dir is not None else root, + now=now, + evaluator=evaluator, + repo_root=repo_root, + ) + except RescoreError as exc: + summary.failures.append(RunFailure(exc.run_id or run_id, exc.code)) + return + summary.outcomes.append(outcome) diff --git a/solar_eval/rescore/drafts.py b/solar_eval/rescore/drafts.py new file mode 100644 index 0000000000000000000000000000000000000000..b5ba6e7781bfb916a47df402b1b98cf7bc280244 --- /dev/null +++ b/solar_eval/rescore/drafts.py @@ -0,0 +1,108 @@ +"""판정 초안·생산자 집계 조립 (계획 2026-09-26 L3b §2.4·D2·D4). + +- 결과 행마다 판정 3개, 순서 = `exact` → `lcs_f1` → `spans`. 실패 행은 세 지표 모두 `status="error"`·같은 `reason` 이다. +- `producer_aggregates.json`: `exact`·`lcs_f1` 의 `mean` 은 파생 참조 구현 `derive("mean")`, `spans` 의 `micro_prf@1:{f1,precision, + recall}` 은 **레거시 구현** `LCSDiffEvaluator().aggregate()` 에 ok 행 카운트를 넣은 값이다(D4) — hub 적재의 + `producer_aggregate_mismatch` 대조가 "SQL 파생 ↔ 레거시 코드" 대조가 된다. ok 행이 0 이면 `spans` 항목을 싣지 않는다 + (레거시의 0.0 은 "측정 없음" 과 구별되지 않는다). +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from typing import Any + +from poc_bundle.scoring.derivations import derive +from poc_bundle.scoring.writer import JudgmentDraft + +from solar_eval.evaluators.lcs_diff import LCSDiffEvaluator +from solar_eval.rescore.evaluator_doc import ACTOR +from solar_eval.rescore.proofread_diff_v2 import LABEL_CORRECT, RowError, RowScore + +EXACT, LCS_F1, SPANS = "exact", "lcs_f1", "spans" +#: 판정 파일 안 지표 순서. +CRITERIA: tuple[str, ...] = (EXACT, LCS_F1, SPANS) +PRODUCER_AGGREGATES_SCHEMA = "producer-aggregates/v1" +#: 레거시 `aggregate()["scores"]` 키 → `micro_prf@1` 출력 이름. +SPAN_OUTPUTS: tuple[str, ...] = ("f1", "precision", "recall") + + +@dataclass(frozen=True) +class ScoredRow: + """채점한 결과 행 하나 — `sample_idx`, run 번들 `items.jsonl` 의 키 쌍, 채점 결과.""" + + sample_idx: int + input_key: str + golden_key: str + outcome: RowScore | RowError + + +def judgment_drafts(run_id: str, rows: Sequence[ScoredRow], created_at: str) -> list[JudgmentDraft]: + """행마다 `exact`·`lcs_f1`·`spans` 초안 3개(행 순서 그대로).""" + drafts: list[JudgmentDraft] = [] + for row in rows: + common = { + "level": "item", + "run_id": run_id, + "sample_idx": row.sample_idx, + "input_key": row.input_key, + "golden_key": row.golden_key, + "source_kind": "code", + "actor": ACTOR, + "created_at": created_at, + } + for criterion in CRITERIA: + fields = {**common, "criterion_id": criterion, **_result_fields(criterion, row.outcome)} + drafts.append(JudgmentDraft(fields)) + return drafts + + +def _result_fields(criterion: str, outcome: RowScore | RowError) -> dict[str, Any]: + if isinstance(outcome, RowError): + return {"status": "error", "reason": outcome.reason} + if criterion == EXACT: + return { + "score": outcome.score_exact, + "label": outcome.label, + "passed": outcome.label == LABEL_CORRECT, + "status": "ok", + } + if criterion == LCS_F1: + return {"score": outcome.lcs_f1, "status": "ok"} + return {"value": dict(outcome.spans), "status": "ok"} + + +def producer_aggregates( + rows: Sequence[ScoredRow], drafts: Sequence[JudgmentDraft] +) -> dict[str, Any]: + """`producer-aggregates/v1` 문서 — 값은 (metric_key, aggregation) 사전순.""" + values = [_mean_value(EXACT, drafts), _mean_value(LCS_F1, drafts)] + ok = [row.outcome for row in rows if isinstance(row.outcome, RowScore)] + if ok: + scores = legacy_span_scores(ok) + for output in SPAN_OUTPUTS: + values.append(_value(SPANS, f"micro_prf@1:{output}", scores[output])) + values.sort(key=lambda v: (v["metric_key"], v["aggregation"])) + return {"schema": PRODUCER_AGGREGATES_SCHEMA, "values": values} + + +def legacy_span_scores(ok: Sequence[RowScore]) -> Mapping[str, Any]: + """레거시 `LCSDiffEvaluator.aggregate` 가 ok 행 카운트로 내는 run 층 `scores`(퍼센트).""" + aggregated = LCSDiffEvaluator().aggregate([{"details": dict(row.spans)} for row in ok]) + return aggregated["scores"] + + +def _mean_value(criterion: str, drafts: Sequence[JudgmentDraft]) -> dict[str, Any]: + rows = [d.fields for d in drafts if d.fields.get("criterion_id") == criterion] + return _value(criterion, "mean", derive("mean", rows)["value"]) + + +def _value(metric_key: str, aggregation: str, value: Any) -> dict[str, Any]: + return { + "metric_key": metric_key, + "aggregation": aggregation, + "variant": "raw", + "error_policy": "exclude", + "value": value, + } diff --git a/solar_eval/rescore/evaluator_doc.py b/solar_eval/rescore/evaluator_doc.py new file mode 100644 index 0000000000000000000000000000000000000000..c6545dfdc35fa1a95b0efdc00e4b34beb15552c9 --- /dev/null +++ b/solar_eval/rescore/evaluator_doc.py @@ -0,0 +1,110 @@ +"""`proofread_diff@2` 평가기 문서와 구현 해시 (계획 2026-09-26 L3b §2.4·MB1). + +- `impl_hash` = `json_fingerprint(["proofread-diff-impl/v1", closure_hash])` — `closure_hash` 는 `proofread_diff_v2` 에서 시작한 + AST import 폐포(`core/snapshot/impl_hash.py`, 스텝·평가기 해시와 같은 규칙)의 파일 바이트 지문이다. `dependency_lock` 은 + 섞지 않는다: 폐포가 표준 라이브러리·`solar_eval` 밖을 import 하지 않음을 테스트가 고정하고(`test_impl_hash.py`), 서드파티가 + 들어오는 날 섞는다(열린 질문 Q-L3b-1). +- **폐포는 넓히지 않는다**(계획 2026-09-27 L6 C-L6-7·Q-L6-8, L3b 후속 6). ① `drafts.py` 는 `poc_bundle` 을 import 해 넣으면 + 서드파티가 폐포에 들어와 `dependency_lock` 이 필요하다(U-L3b-4 로 넣지 않기로 했다). `evaluators/lcs_diff.py` 는 폐포 밖이지만 + 재채점 경로 **안**이다 — `drafts.legacy_span_scores` 가 `LCSDiffEvaluator.aggregate` 로 `producer_aggregates.json` 의 `spans` + 집계를 낸다. ② 폐포를 바꾸면 `evaluator_config_hash` 가 바뀌어 기존 재채점이 새 평가기로 갈라진다. ③ 두 파일의 변화는 + `from_bundle._same_as` 가 판정 행과 `producer_aggregates.json` 바이트를 함께 비교해 같은 `evaluation_run_id` 안의 + `supersedes` 로 잡는다 — `test_impl_hash.py` 가 셋을 고정한다(판정 행만 바뀐 경우·집계만 바뀐 경우 각각). +- `metrics` 는 벡터 `scoring/bundles/02-proofread-diff-v2-shape` 와 **같은 배열**이다(테스트가 벡터와 대조한다). 문서가 벡터와 + 다른 점은 `impl_hash`·`params.original_field`·`provenance` 셋이다. +""" + +from __future__ import annotations + +import copy +from typing import Any + +from poc_bundle.canonical import json_fingerprint + +from solar_eval._version import __version__ +from solar_eval.core.snapshot.impl_hash import ImplRoots, closure_hash, default_roots +from solar_eval.rescore.proofread_diff_v2 import ( + LABEL_CORRECT, + LABEL_SCORES, + LABELS, + NORMALIZERS, + ORIGINAL_FIELD, +) + +#: 폐포 시작 모듈 — 평가기 문서 `impl.name` 과 같다. +IMPL_START = "solar_eval.rescore.proofread_diff_v2" +#: `impl_hash` 튜플 머리의 도메인 태그. 튜플을 바꾸면 새 태그다. +IMPL_TAG = "proofread-diff-impl/v1" +IMPL_VERSION = "2" +EVALUATOR_KIND = "proofread_diff" +#: 사람이 부르는 평가기 이름 — 판정 `actor` 는 `solar_eval.`. +ALIAS = "proofread_diff@2" +ACTOR = f"solar_eval.{ALIAS}" +#: `params.noanswer` — 정규화한 출력이 빈 문자열이면 `noanswer`(D3). +NOANSWER_RULE = "empty_output" + +#: 지표 선언 — 벡터 02 와 같은 배열(키 순서까지). `exact` 는 categorical, `lcs_f1` 은 continuous, `spans` 는 counts. +METRICS: list[dict[str, Any]] = [ + { + "key": "exact", + "level": "item", + "score_type": "categorical", + "unit": "ratio", + "levels": list(LABELS), + "label_scores": dict(LABEL_SCORES), + "passing_labels": [LABEL_CORRECT], + "aggregations": ["mean"], + "primary": True, + "primary_aggregation": "mean", + }, + { + "key": "lcs_f1", + "level": "item", + "score_type": "continuous", + "unit": "percent", + "min": 0, + "max": 100, + "aggregations": ["mean"], + }, + { + "key": "spans", + "level": "item", + "score_type": "counts", + "unit": "percent", + "count_roles": {"tp": "tp", "fp": "fd", "missing": "fn", "redundant": "fa"}, + "empty_policy": "one", + "scale": 100, + "aggregations": ["micro_prf@1:f1", "micro_prf@1:precision", "micro_prf@1:recall"], + }, +] + + +def implementation_hash(roots: ImplRoots | None = None) -> tuple[str, list[str]]: + """(`impl_hash`, 정렬된 폐포 라벨). `roots` 를 주면 그 해석 루트로 뜬다(테스트가 tmp 사본을 끼운다).""" + closure, labels = closure_hash([IMPL_START], roots or default_roots()) + return json_fingerprint([IMPL_TAG, closure]), labels + + +def evaluator_document(impl_hash: str) -> dict[str, Any]: + """`evaluator/v1` 문서(§2.4). 해시는 `config` 에만 뜨므로 `provenance` 의 버전 표기는 id 를 바꾸지 않는다.""" + return { + "schema": "evaluator/v1", + "config": { + "kind": EVALUATOR_KIND, + "source_kind": "code", + "impl": {"name": IMPL_START, "version": IMPL_VERSION, "impl_hash": impl_hash}, + "params": { + "exact_compare": {"normalizers": list(NORMALIZERS)}, + "noanswer": NOANSWER_RULE, + "original_field": ORIGINAL_FIELD, + }, + "metrics": copy.deepcopy(METRICS), + }, + "provenance": {"alias": ALIAS, "version_label": f"solar-eval {__version__}"}, + } + + +def current_evaluator_document() -> dict[str, Any]: + """지금 디스크의 구현으로 뜬 평가기 문서 — 재채점 한 번(case 전체)에 한 번 부른다.""" + impl_hash, _ = implementation_hash() + return evaluator_document(impl_hash) diff --git a/solar_eval/rescore/from_bundle.py b/solar_eval/rescore/from_bundle.py new file mode 100644 index 0000000000000000000000000000000000000000..6868ede6f60bc4fc856037d3a67a7246aaa2dbae --- /dev/null +++ b/solar_eval/rescore/from_bundle.py @@ -0,0 +1,428 @@ +"""run 번들 하나 → `proofread_diff@2` 채점 번들 (계획 2026-09-26 L3b §2.4·D5). + +순서: subject verify → 생산자 판별(`is_legacy_results_producer`) → `results.jsonl` 레코드 줄 순서대로 읽기 → 행마다 채점(순수 +모듈) → 판정 초안·생산자 집계 → 같은 `evaluation_run_id` 의 기존 최신과 비교 → 새 번들 쓰기(`write_scoring_bundle`, subject +를 넘겨 E1·E2 까지 커밋 전에 본다) → (`mirror_root` 가 있으면) `mirror_bundle(area="evaluations")`. + +- `evaluation_run_id` = `code_rescore_run_id(subject bundle_id, evaluator_config_hash)` — subject 번들이 바뀌면 새 id 다. +- **멱등(D5).** 기존 최신(같은 id)이 subject 와 함께 `verify_scoring_bundle` 을 통과하고(E4 로 `evaluator_config.json` 파일의 + config 해시 = 매니페스트 값까지 본다), 평가기 해시가 같고, `producer_aggregates.json` 바이트가 같고, `judgments.jsonl` 행에서 + `created_at`(재채점 시각)을 뺀 목록이 새 목록과 같으면 새 번들을 쓰지 않고 기존 경로를 `exists` 로 돌려준다. 하나라도 + 다르면(기존 최신이 깨졌어도) `supersedes` = 기존 최신으로 새 ULID 번들을 쓴다 — 새 ULID ≤ 기존이면 `clock_skew`(eval-wrap· + wrap 과 같은 규칙). 비교가 id 순서 검사보다 먼저다. 생산자 집계는 폐포(`impl_hash`) 밖의 레거시 집계 코드가 내므로 판정만 + 보면 그 코드가 바뀐 것을 놓친다. +- **subject 경계.** `manifest.json` 이 없는 디렉토리는 `not_a_run_bundle` 오류(번들이 아니다 — 경로 실수), verify 실패는 + `latest_bundle_invalid` skip(`items.jsonl` 이 깨진 경우 포함 — case 모드의 채택과 같은 분류). +- 출력·오류 메시지에는 id·코드·경로만 싣는다. 입력·출력·정답 원문은 어디에도 쓰지 않는다(census D-04 와 같은 규칙). +""" + +from __future__ import annotations + +import re +import shutil +import tempfile +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, Literal + +from poc_bundle.adopt import AdoptError, Committed, committed_scoring_bundles, latest +from poc_bundle.bundle import verify_bundle +from poc_bundle.canonical import load_json_strict +from poc_bundle.jsonl import iter_record_lines +from poc_bundle.legacy_run import is_legacy_results_producer +from poc_bundle.mirror import MirrorError, mirror_bundle +from poc_bundle.scoring.ids import code_rescore_run_id, evaluator_config_hash +from poc_bundle.scoring.verify import RunBundleRef, load_run_bundle_ref, verify_scoring_bundle +from poc_bundle.scoring.writer import JudgmentDraft, ScoringWriteError, write_scoring_bundle +from ulid import ULID + +from solar_eval._version import __version__ +from solar_eval.rescore.drafts import ScoredRow, judgment_drafts, producer_aggregates +from solar_eval.rescore.evaluator_doc import current_evaluator_document +from solar_eval.rescore.paths import EVALUATIONS_AREA, RescorePathError, mirror_area_dir +from solar_eval.rescore.proofread_diff_v2 import has_original, score_row + +PRODUCER_NAME = "solar-eval score" +PURPOSE = "rescore_after_fix" +RESULTS_FILE = "results.jsonl" +JUDGMENTS_FILE = "judgments.jsonl" +AGGREGATES_FILE = "producer_aggregates.json" +#: 멱등 비교용 임시 번들 id — 가장 큰 ULID 라 어떤 `supersedes` 보다도 뒤에 온다. 출력 디렉토리에는 쓰지 않는다. +STAGING_BUNDLE_ID = "7ZZZZZZZZZZZZZZZZZZZZZZZZZ" + +# run 단위 skip 코드(종료 0 에 포함)와 오류 코드(종료 1) — 계획 §2.2. +SKIP_LATEST_INVALID = "latest_bundle_invalid" +SKIP_UNKNOWN_FORMAT = "unknown_results_format" +SKIP_NO_RESULTS = "no_results" +ERR_ADOPT = "adopt_unreadable_manifest" +ERR_CLOCK_SKEW = "clock_skew" +ERR_MIRROR = "mirror_failed" +ERR_RESULTS = "results_unreadable" +ERR_OUT_INSIDE = "out_inside_bundle" +ERR_NOT_A_BUNDLE = "not_a_run_bundle" +WRITE_FAILED_PREFIX = "write_failed:" +_RULE_ID = re.compile(r"^\[([A-Z]+[0-9]*)\]") + +Status = Literal["written", "exists", "skip"] + + +class RescoreError(RuntimeError): + """이 run 을 재채점할 수 없다(종료 1). `code` 는 §2.2 오류 어휘, `run_id` 는 알면 싣는다.""" + + def __init__(self, code: str, message: str, run_id: str | None = None) -> None: + super().__init__(f"{code}: {message}") + self.code = code + self.run_id = run_id + + +@dataclass(frozen=True) +class RescoreOutcome: + """run 하나의 결과 — `written`·`exists` 는 번들 경로, `skip` 은 코드.""" + + status: Status + run_id: str | None + path: Path | None = None + code: str | None = None + n_original_missing: int = 0 + + +class _Skip(Exception): + def __init__(self, code: str) -> None: + super().__init__(code) + self.code = code + + +@dataclass(frozen=True) +class _Subject: + bundle_dir: Path + run_id: str + bundle_id: str + case: dict[str, Any] + ref: RunBundleRef + + +def rescore_run_bundle( + run_bundle_dir: Path, + out_dir: Path | None = None, + *, + mirror_root: Path | None = None, + now: datetime | None = None, + bundle_id: str | None = None, + evaluator: Mapping[str, Any] | None = None, + repo_root: Path | None = None, +) -> RescoreOutcome: + """run 번들 하나를 재채점한다. `out_dir`(채점 번들들의 부모) 와 `mirror_root` 중 **정확히 하나**를 준다. + + Args: + mirror_root: 미러 버킷의 부모 — 주면 출력 영역은 subject `case` 의 미러 `evaluations` 이고 `mirror_bundle` 로 놓는다. + now · bundle_id: 재현 가능한 테스트 전용. 비우면 지금 시각·새 ULID. + evaluator: 평가기 문서(case 전체에 한 번 뜬 것). 비우면 지금 디스크의 구현으로 뜬다. + repo_root: `mirror_bundle` 의 레포 밖 검사 기준(기본: 레포를 찾아 본다). + + Raises: + RescoreError: §2.2 오류(채택 영역 매니페스트·시계 역행·쓰기·미러·results 읽기). + """ + if (out_dir is None) == (mirror_root is None): + raise ValueError("give exactly one of out_dir / mirror_root") + try: + subject = _load_subject(Path(run_bundle_dir)) + rows, n_missing = _scored_rows(subject) + except _Skip as skip: + run_id = _run_id_or_none(Path(run_bundle_dir)) + return RescoreOutcome("skip", run_id, code=skip.code) + doc = dict(evaluator) if evaluator is not None else current_evaluator_document() + at = _utc_seconds(now) + drafts = judgment_drafts(subject.run_id, rows, at) + target = out_dir if out_dir is not None else _mirror_evaluations(mirror_root, subject) + _check_out_dir(Path(target), subject) + plan = _Plan(subject, doc, drafts, producer_aggregates(rows, drafts), at) + status, path = _commit(plan, Path(target), bundle_id, mirror_root, repo_root) + return RescoreOutcome(status, subject.run_id, path=path, n_original_missing=n_missing) + + +# ── 읽기 ───────────────────────────────────────────────────────────────────── + + +def _load_subject(bundle_dir: Path) -> _Subject: + ref = _subject_ref(bundle_dir) + report = ref.verify_report + if not report.is_bundle: + raise RescoreError(ERR_NOT_A_BUNDLE, f"no manifest.json in {bundle_dir}") + if not report.ok or report.manifest is None: + raise _Skip(SKIP_LATEST_INVALID) + manifest = report.manifest + if not is_legacy_results_producer(manifest.producer.name): + raise _Skip(SKIP_UNKNOWN_FORMAT) + return _Subject( + bundle_dir=bundle_dir, + run_id=manifest.run_id, + bundle_id=manifest.bundle_id, + case=manifest.case.model_dump(mode="json"), + ref=ref, + ) + + +def _subject_ref(bundle_dir: Path) -> RunBundleRef: + """`RunBundleRef` — `items.jsonl` 을 못 읽으면 verify 결과로 가른다: 실패면 skip, 통과인데 못 읽으면 오류. + + `load_run_bundle_ref` 는 verify 결과와 무관하게 items 파싱 오류를 던지므로, 그대로 두면 깨진 번들이 case 모드(채택이 + verify 를 먼저 본다)와 달리 `results_unreadable` 로 분류된다. + """ + try: + return load_run_bundle_ref(bundle_dir) + except ValueError as exc: + if not verify_bundle(bundle_dir).ok: + raise _Skip(SKIP_LATEST_INVALID) from exc + raise RescoreError(ERR_RESULTS, f"items.jsonl unreadable in {bundle_dir.name}") from exc + + +def _scored_rows(subject: _Subject) -> tuple[list[ScoredRow], int]: + """results 레코드 줄 순서대로 채점한 행과 `original` 이 없는 행 수. 레코드가 0이면 `no_results`.""" + rows: list[ScoredRow] = [] + missing = 0 + path = subject.bundle_dir / RESULTS_FILE + for line_no, raw in iter_record_lines(path): + result = _parse_result(raw, line_no, subject) + sample_idx = result["sample_idx"] + input_key, golden_key = subject.ref.items[sample_idx] + rows.append(ScoredRow(sample_idx, input_key, golden_key, score_row(result))) + missing += 0 if has_original(result) else 1 + if not rows: + raise _Skip(SKIP_NO_RESULTS) + return rows, missing + + +def _parse_result(raw: bytes, line_no: int, subject: _Subject) -> Mapping[str, Any]: + """레코드 한 줄 → 객체. 메시지에는 줄 번호만 싣는다(행 내용 없음).""" + where = f"{RESULTS_FILE}:{line_no} of run bundle {subject.bundle_id}" + try: + row = load_json_strict(raw, safe_integers=False) + except (ValueError, RecursionError) as exc: + raise RescoreError(ERR_RESULTS, f"{where} is not strict JSON", subject.run_id) from exc + sample_idx = row.get("sample_idx") if isinstance(row, dict) else None + if isinstance(sample_idx, bool) or not isinstance(sample_idx, int): + raise RescoreError(ERR_RESULTS, f"{where} has no integer sample_idx", subject.run_id) + if sample_idx not in subject.ref.items: + raise RescoreError(ERR_RESULTS, f"{where} sample_idx is not in items.jsonl", subject.run_id) + return row + + +def _run_id_or_none(bundle_dir: Path) -> str | None: + """skip 줄에 실을 run_id — 매니페스트를 읽을 수 있을 때만(검증 실패여도).""" + try: + data = load_json_strict((bundle_dir / "manifest.json").read_bytes()) + except (OSError, ValueError, RecursionError): + return None + run_id = data.get("run_id") if isinstance(data, dict) else None + return run_id if isinstance(run_id, str) else None + + +def _mirror_evaluations(mirror_root: Path | None, subject: _Subject) -> Path: + try: + return mirror_area_dir( + mirror_root, subject.case["account"], subject.case["case"], EVALUATIONS_AREA + ) + except RescorePathError as exc: + raise RescoreError(ERR_MIRROR, str(exc), subject.run_id) from exc + + +def _check_out_dir(out: Path, subject: _Subject) -> None: + target, run_abs = out.resolve(), subject.bundle_dir.resolve() + if target == run_abs or run_abs in target.parents: + raise RescoreError( + ERR_OUT_INSIDE, f"output {target} is inside the run bundle", subject.run_id + ) + + +def _utc_seconds(now: datetime | None) -> str: + moment = (now or datetime.now(UTC)).astimezone(UTC) + return moment.strftime("%Y-%m-%dT%H:%M:%SZ") + + +# ── 멱등·커밋 ───────────────────────────────────────────────────────────────── + + +@dataclass(frozen=True) +class _Plan: + subject: _Subject + evaluator: Mapping[str, Any] + drafts: Sequence[JudgmentDraft] + aggregates: Mapping[str, Any] + created_at: str + + @property + def config_hash(self) -> str: + return evaluator_config_hash(self.evaluator) + + @property + def evaluation_run_id(self) -> str: + return code_rescore_run_id(self.subject.bundle_id, self.config_hash) + + +def _commit( + plan: _Plan, + out: Path, + bundle_id: str | None, + mirror_root: Path | None, + repo_root: Path | None, +) -> tuple[Status, Path]: + previous = _latest_committed(out, plan) + if previous is not None and _same_as(previous, plan): + return "exists", previous.path + new_id = bundle_id or str(ULID()) + if previous is not None and previous.bundle_id >= new_id: + raise RescoreError( + ERR_CLOCK_SKEW, + f"new bundle_id {new_id} is not newer than committed {previous.bundle_id}", + plan.subject.run_id, + ) + supersedes = None if previous is None else previous.bundle_id + fields = _manifest_fields(plan, new_id, supersedes) + if mirror_root is None: + dest = out / new_id + _write(dest, fields, plan) + return "written", dest + return "written", _write_and_mirror(fields, plan, new_id, Path(mirror_root), repo_root) + + +def _latest_committed(out: Path, plan: _Plan) -> Committed | None: + try: + committed = committed_scoring_bundles(out) + except AdoptError as exc: + raise RescoreError(ERR_ADOPT, str(exc), plan.subject.run_id) from exc + cands = committed.get(plan.evaluation_run_id, []) + return latest(cands) if cands else None + + +def _same_as(previous: Committed, plan: _Plan) -> bool: + """D5 — 기존 최신이 온전하고 평가기 해시·생산자 집계·`created_at` 을 뺀 판정 행이 같은가. + + 자리표시 id 로 써 본 번들과 비교한다. 기존 최신이 verify 에 실패하면 같지 않다 — `exists` 로 넘기면 깨진 번들이 영영 + 최신으로 남는다(hub 는 최신이 invalid 면 이전으로 넘어가지 않는다). + """ + evaluator_ref = previous.manifest.get("evaluator") + if ( + not isinstance(evaluator_ref, Mapping) + or evaluator_ref.get("config_hash") != plan.config_hash + or not _previous_verifies(previous, plan) + ): + return False + fields = _manifest_fields(plan, STAGING_BUNDLE_ID, previous.bundle_id) + with tempfile.TemporaryDirectory(prefix="solar-eval-score-") as tmp: + staged = Path(tmp) / STAGING_BUNDLE_ID + _write(staged, fields, plan) + new_rows = _rows_without_time(staged / JUDGMENTS_FILE) + new_aggregates = _bytes_or_none(staged / AGGREGATES_FILE) + old_rows = _rows_without_time(previous.path / JUDGMENTS_FILE) + old_aggregates = _bytes_or_none(previous.path / AGGREGATES_FILE) + return ( + old_rows is not None + and old_rows == new_rows + and old_aggregates is not None + and old_aggregates == new_aggregates + ) + + +def _previous_verifies(previous: Committed, plan: _Plan) -> bool: + """기존 최신을 subject 와 함께 검증한다 — SB1(파일 sha)·E4(`evaluator_config.json` config 해시)·E1·E2 까지.""" + subject = plan.subject + report = verify_scoring_bundle(previous.path, run_bundles={subject.bundle_id: subject.ref}) + return report.ok + + +def _bytes_or_none(path: Path) -> bytes | None: + """파일 바이트. 못 읽으면 None(비교가 거짓 → 새 번들).""" + try: + return path.read_bytes() + except OSError: + return None + + +def _rows_without_time(path: Path) -> list[dict[str, Any]] | None: + """판정 행에서 `created_at` 을 뺀 목록. 못 읽으면 None(비교가 거짓 → 새 번들).""" + try: + rows = [load_json_strict(raw) for _, raw in iter_record_lines(path)] + except (OSError, ValueError, RecursionError): + return None + if not all(isinstance(row, dict) for row in rows): + return None + return [{k: v for k, v in row.items() if k != "created_at"} for row in rows] + + +def _manifest_fields(plan: _Plan, bundle_id: str, supersedes: str | None) -> dict[str, Any]: + subject = plan.subject + return { + "bundle_id": bundle_id, + "evaluation_run_id": plan.evaluation_run_id, + "id_origin": "deterministic", + "supersedes": supersedes, + "created_at": plan.created_at, + "producer": {"name": PRODUCER_NAME, "version": __version__}, + "case": subject.case, + "subject": { + "kind": "run", + "runs": [{"run_id": subject.run_id, "bundle_id": subject.bundle_id, "role": "subject"}], + }, + "purpose": PURPOSE, + "retry_of": None, + "golden_basis": "run", + "status": "completed", + "finished_at": plan.created_at, + "reproducible": {"snapshot_complete": True, "missing": []}, + "known_defects": [], + "observed": None, + "usage": None, + } + + +def _write(dest: Path, fields: Mapping[str, Any], plan: _Plan) -> None: + try: + write_scoring_bundle( + dest, + fields=fields, + evaluator=plan.evaluator, + drafts=plan.drafts, + producer_aggregates=plan.aggregates, + run_bundle=plan.subject.ref, + ) + except ScoringWriteError as exc: + _remove_uncommitted(dest) + raise RescoreError(_write_code(exc.issues), str(exc), plan.subject.run_id) from exc + except OSError as exc: + _remove_uncommitted(dest) + raise RescoreError(f"{WRITE_FAILED_PREFIX}io_error", str(exc), plan.subject.run_id) from exc + + +def _write_code(issues: Sequence[str]) -> str: + """`write_failed:<규칙 id 목록>` — 이슈 문장의 `[]` 머리만 모은다(없으면 `input`).""" + ids = sorted({m.group(1) for issue in issues if (m := _RULE_ID.match(issue))}) + return WRITE_FAILED_PREFIX + (",".join(ids) if ids else "input") + + +def _write_and_mirror( + fields: Mapping[str, Any], plan: _Plan, new_id: str, mirror_root: Path, repo_root: Path | None +) -> Path: + """임시 디렉토리에 쓴 뒤 미러에 놓는다 — 미러의 커밋 규칙(manifest 마지막)·레포 밖 검사를 `mirror_bundle` 이 맡는다.""" + case = plan.subject.case + with tempfile.TemporaryDirectory(prefix="solar-eval-score-") as tmp: + staged = Path(tmp) / new_id + _write(staged, fields, plan) + try: + return mirror_bundle( + staged, + account=case["account"], + case=case["case"], + area=EVALUATIONS_AREA, + root=mirror_root, + repo_root=repo_root, + ) + except (MirrorError, OSError) as exc: + raise RescoreError(ERR_MIRROR, str(exc), plan.subject.run_id) from exc + + +def _remove_uncommitted(staging: Path) -> None: + """writer 가 manifest 없이 남긴 디렉토리를 지운다 — 커밋된 번들(manifest 있음)은 건드리지 않는다.""" + if staging.is_dir() and not (staging / "manifest.json").exists(): + shutil.rmtree(staging) diff --git a/solar_eval/rescore/paths.py b/solar_eval/rescore/paths.py new file mode 100644 index 0000000000000000000000000000000000000000..b5bfdc5965c32c099c114fb5936e60e5d6d7f2bc --- /dev/null +++ b/solar_eval/rescore/paths.py @@ -0,0 +1,63 @@ +"""재채점 번들이 놓일 곳 (계획 2026-09-26 L3b §2.1 "미러 루트 층"). + +- 미러 영역 = `//accounts///eval/` — `mirror_root` 는 **버킷의 부모**(`poc_bundle.mirror.mirror_bundle(root=)` + 와 같은 뜻, 기본 `blob_mirror_root()`). 경로 조각은 `poc_bundle.data.layout` 의 규칙으로만 만든다(슬러그 검사 포함). +- `` 모드 기본 출력: 번들이 `…/eval/bundles/` 에 있으면 `…/eval/evaluations`(eval-wrap 과 같은 규칙), + 아니면 호출자가 `--out` 을 줘야 한다. +""" + +from __future__ import annotations + +from pathlib import Path + +from poc_bundle.data.errors import DataConfigError +from poc_bundle.data.layout import SUBPREFIX, case_prefix +from poc_bundle.home import blob_mirror_root +from poc_bundle.mirror import DEFAULT_BUCKET + +BUNDLES_AREA = "bundles" +EVALUATIONS_AREA = "evaluations" + + +class RescorePathError(ValueError): + """재채점 입력·출력 위치를 정할 수 없다(잘못된 case 슬러그·기본 출력 위치 없음).""" + + +def parse_case_key(case_key: str) -> tuple[str, str]: + """`a/c` → (account, case). 슬러그 규칙은 `case_prefix` 가 본다.""" + parts = case_key.split("/") if isinstance(case_key, str) else [] + if len(parts) != 2: + raise RescorePathError(f"case must be /, got {case_key!r}") + account, case = parts + try: + case_prefix(account, case) + except DataConfigError as exc: + raise RescorePathError(str(exc)) from exc + return account, case + + +def mirror_area_dir( + mirror_root: Path | None, account: str, case: str, area: str, *, bucket: str = DEFAULT_BUCKET +) -> Path: + """미러의 한 case 영역 디렉토리(`bundles` 또는 `evaluations`).""" + if area not in (BUNDLES_AREA, EVALUATIONS_AREA): + raise RescorePathError(f"unknown mirror area {area!r}") + try: + prefix = case_prefix(account, case) + except DataConfigError as exc: + raise RescorePathError(str(exc)) from exc + return resolve_mirror_root(mirror_root) / bucket / prefix / SUBPREFIX[area] + + +def resolve_mirror_root(mirror_root: Path | None) -> Path: + """미러 버킷의 부모 절대 경로 — None 이면 데이터 홈의 `blob-mirror`.""" + return blob_mirror_root() if mirror_root is None else Path(mirror_root).expanduser().resolve() + + +def default_evaluations_dir(run_bundle_dir: Path) -> Path | None: + """`…/eval/bundles/` → `…/eval/evaluations`. 그 모양이 아니면 None(호출자가 `--out` 을 요구한다).""" + resolved = Path(run_bundle_dir).resolve() + parent = resolved.parent + if parent.name == Path(SUBPREFIX[BUNDLES_AREA]).name and parent.parent.name == "eval": + return parent.parent / Path(SUBPREFIX[EVALUATIONS_AREA]).name + return None diff --git a/solar_eval/rescore/proofread_diff_v2.py b/solar_eval/rescore/proofread_diff_v2.py new file mode 100644 index 0000000000000000000000000000000000000000..0ef430edb1ce12e298936d73e8984ecce80359d2 --- /dev/null +++ b/solar_eval/rescore/proofread_diff_v2.py @@ -0,0 +1,153 @@ +"""`proofread_diff@2` — 결과 행 하나의 순수 채점 (계획 2026-09-26 L3b D1·D2·D3). + +**이 모듈은 표준 라이브러리와 `solar_eval.evaluators.scoring` 만 import 한다.** 평가기 `impl_hash` 는 이 모듈에서 시작한 +import 폐포(`core/snapshot/impl_hash.py`)로 뜨므로, 번들 읽기·writer·미러 같은 I/O 코드는 `from_bundle.py`·`case.py` 에 둔다 — +그래야 입출력 코드를 고쳐도 평가기 해시가 바뀌지 않고, 채점 규칙이 바뀌면 반드시 바뀐다(MB1). + +채점 규칙은 레거시 `lcs_diff` 와 **같은 함수**(`compute_proofread_metrics`)다 — 두 벌 두지 않는다. 전제조건도 레거시 +`runs eval`(`cli/commands/run_eval.py` `_score_all` → `validate_required_fields` → `LCSDiffEvaluator.evaluate`)과 같게 맞춘다: + +- `output is None` → `missing_output`, `golden is None` → `missing_golden`(레거시 `validate_required_fields` 의 ValueError). +- `output` 이 문자열이 아님 → `output_not_text`(레거시는 `.split()` 의 AttributeError 로 실패). +- 그 밖의 예외(입력이 객체가 아님, `original` 이 문자열이 아님 등) → `scoring_failed`. +- `input` 에 `original` 이 없으면 `""`, `golden` 이 문자열이 아니면 `str(golden)` — 레거시가 내는 값과 같게. + +`exact` 라벨(D3): 정규화(`strip@1` → `ws_collapse@1`)한 출력이 빈 문자열 → `noanswer`, 정규화한 출력 = 정규화한 정답 → +`correct`, `spans.tp > 0` → `partial`, 나머지 → `incorrect`. 정규화기 정의의 정본은 이 모듈 하나다(문서는 +`PS/SCORING.md` §4.4 가 같은 말을 적고, 테스트가 두 어휘를 대조한다). +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any + +from solar_eval.evaluators.scoring import compute_proofread_metrics + +#: 정규화기 이름 — 평가기 문서 `params.exact_compare.normalizers` 에 이 순서로 실린다(해시 재료). +NORMALIZERS: tuple[str, ...] = ("strip@1", "ws_collapse@1") +#: 원문을 읽는 입력 필드 — 평가기 문서 `params.original_field`. +ORIGINAL_FIELD = "original" + +LABEL_CORRECT = "correct" +LABEL_PARTIAL = "partial" +LABEL_INCORRECT = "incorrect" +LABEL_NOANSWER = "noanswer" +#: `exact` 지표 `levels` 순서(벡터 `scoring/bundles/02` 와 같다). +LABELS: tuple[str, ...] = (LABEL_CORRECT, LABEL_PARTIAL, LABEL_INCORRECT, LABEL_NOANSWER) +#: 라벨 → 점수(`label_scores`). correct 만 1. +LABEL_SCORES: dict[str, int] = { + LABEL_CORRECT: 1, + LABEL_PARTIAL: 0, + LABEL_INCORRECT: 0, + LABEL_NOANSWER: 0, +} + +REASON_MISSING_OUTPUT = "missing_output" +REASON_MISSING_GOLDEN = "missing_golden" +REASON_OUTPUT_NOT_TEXT = "output_not_text" +REASON_SCORING_FAILED = "scoring_failed" +#: 실패 판정 `reason` 어휘(행 단위). +REASONS: tuple[str, ...] = ( + REASON_MISSING_OUTPUT, + REASON_MISSING_GOLDEN, + REASON_OUTPUT_NOT_TEXT, + REASON_SCORING_FAILED, +) +#: `spans` 판정 `value` 의 키 — 레거시 `compute_proofread_metrics` 카운트 이름 그대로. +SPAN_KEYS: tuple[str, ...] = ("tp", "fp", "missing", "redundant") + + +@dataclass(frozen=True) +class RowScore: + """채점 성공 — `exact` 라벨·점수, `lcs_f1`(레거시 `f1`, 0~100), `spans` 카운트.""" + + label: str + score_exact: int + lcs_f1: float + spans: dict[str, int] + + +@dataclass(frozen=True) +class RowError: + """채점 실패 — 세 지표 모두 `status="error"` 이고 같은 `reason` 이다(D2).""" + + reason: str + + +def strip_ws(text: str) -> str: + """`strip@1` — 앞뒤의 `str.isspace()` 문자를 지운다(U+3000 전각 공백 포함).""" + start, end = 0, len(text) + while start < end and text[start].isspace(): + start += 1 + while end > start and text[end - 1].isspace(): + end -= 1 + return text[start:end] + + +def collapse_ws(text: str) -> str: + """`ws_collapse@1` — `str.isspace()` 문자의 연속을 U+0020 하나로 바꾼다.""" + out: list[str] = [] + in_space = False + for ch in text: + if ch.isspace(): + if not in_space: + out.append(" ") + in_space = True + else: + out.append(ch) + in_space = False + return "".join(out) + + +def normalize(text: str) -> str: + """`NORMALIZERS` 순서(strip → collapse)로 정규화한다.""" + return collapse_ws(strip_ws(text)) + + +def label_of(norm_out: str, norm_gold: str, tp: int) -> str: + """D3 라벨 규칙 — 정규화한 값을 받는다. 순서가 규칙이다(빈 출력이 먼저).""" + if norm_out == "": + return LABEL_NOANSWER + if norm_out == norm_gold: + return LABEL_CORRECT + if tp > 0: + return LABEL_PARTIAL + return LABEL_INCORRECT + + +def has_original(result_row: Mapping[str, Any]) -> bool: + """입력 객체에 `original` 키가 있는가 — 없으면 레거시처럼 `""` 로 채점한다(보고용 건수).""" + source = result_row.get("input") + return isinstance(source, Mapping) and ORIGINAL_FIELD in source + + +def score_row(result_row: Mapping[str, Any]) -> RowScore | RowError: + """`results.jsonl` 행 하나를 채점한다. 입력을 바꾸지 않는다. + + 레거시가 실패로 모으는 행은 여기서도 `RowError` 다 — 실패 집합이 같아야 parity 가 뜻을 가진다. + """ + output = result_row.get("output") + golden = result_row.get("golden") + if output is None: + return RowError(REASON_MISSING_OUTPUT) + if golden is None: + return RowError(REASON_MISSING_GOLDEN) + if not isinstance(output, str): + return RowError(REASON_OUTPUT_NOT_TEXT) + reference = str(golden) + try: + # 레거시 `LCSDiffEvaluator.evaluate` 와 같은 식 — `sample.input.get("original", "")`. + original = result_row.get("input").get(ORIGINAL_FIELD, "") # type: ignore[union-attr] + metrics = compute_proofread_metrics(original, reference, output) + except Exception: # noqa: BLE001 — 레거시 `_score_all` 도 모든 예외를 실패 샘플로 모은다. + return RowError(REASON_SCORING_FAILED) + spans = {key: int(metrics[key]) for key in SPAN_KEYS} + label = label_of(normalize(output), normalize(reference), spans["tp"]) + return RowScore( + label=label, + score_exact=LABEL_SCORES[label], + lcs_f1=float(metrics["f1"]), + spans=spans, + ) diff --git a/solar_eval/scoring_publish/__init__.py b/solar_eval/scoring_publish/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..0468477ae56e94a300f82e2bec8b21f9551b7e05 --- /dev/null +++ b/solar_eval/scoring_publish/__init__.py @@ -0,0 +1,38 @@ +"""`runs eval` 새 경로 — 평가 디렉토리 + 채점 번들 발행 (계획 2026-09-27 L6b §2.12 B4 · 마스터 D-M2-6 · C-M2-10). + +hub 대상 lcs_diff 계열 run 을 다시 채점할 때 **run 디렉토리를 건드리지 않는다**. 채점 결과는 데이터 홈의 평가 디렉토리 +(`eval_dir`, D-L6-15)에 남기고, PB eval-wrap 이 그것을 `purpose=rescore_after_fix` 채점 번들로 감싸 미러에 발행한다(`publish`). +그래서 재채점 뒤에도 run 번들이 바뀌지 않는다(다음 백필은 `reused`). 그 밖의 run 은 오늘 동작(in-place) + 안내 한 줄이다. + +**judge 경로(계획 2026-09-28 L12 §2.5 · D-L12-6).** 태스크 평가기가 `rubric_judge` 인 hub 대상 완료 run 은 경로 판정이 +`family=FAMILY_JUDGE` 를 돌려준다. 잠금·번들 확인·hub·상태 파일은 위와 같고, 채점·발행만 `judge` 가 맡는다 — 기준마다 judge 판정을 +`poc-scoring/v1` 채점 번들(기준 행 + item `rubric_score` 행, 근거·원 응답은 `payloads.jsonl` 에만)로 낸다. `runs eval --purpose` +(`primary`·`variance_check`)·`--retry-of ` 는 이 경로에서만 뜻이 있다. + +- `eval_dir` — 평가 디렉토리 자리·레거시 모양 평가 파일·`eval-run.json`. +- `publish` — 경로 판정(`decide_route`)과 새 경로 오케스트레이션(`publish_run_eval`: 잠금 → 번들 확인 → 채점 → eval-wrap → hub → 상태 파일). +- `judge` — judge 경로의 채점·발행(`score_and_publish`)과 발행 코드. + +`hub_hook` 은 읽기만 한다(대상 판정·잠금·번들 확인·hub 게이트·상태 파일). `poc_bundle` 은 함수 안에서만 import 한다 — +이 패키지는 runner 경로(`RUNNER_PATH_MODULES`)에 들어가지 않는다. +""" + +from solar_eval.scoring_publish.publish import ( + FAMILY_JUDGE, + EvalJob, + PublishOutcome, + Route, + Scored, + decide_route, + publish_run_eval, +) + +__all__ = [ + "FAMILY_JUDGE", + "EvalJob", + "PublishOutcome", + "Route", + "Scored", + "decide_route", + "publish_run_eval", +] diff --git a/solar_eval/scoring_publish/eval_dir.py b/solar_eval/scoring_publish/eval_dir.py new file mode 100644 index 0000000000000000000000000000000000000000..63ed9c09b957fea0d727f4b96b6fd41e4e430625 --- /dev/null +++ b/solar_eval/scoring_publish/eval_dir.py @@ -0,0 +1,275 @@ +"""평가 디렉토리 — `runs eval` 새 경로의 채점 결과 자리 (계획 2026-09-27 L6 §2.12 B4 ② · D-L6-15). + +자리는 `/solar-eval-evaluations////` 다. 레거시 아카이브 트리(`solar-eval-projects/`) +**밖**이라 레거시 적재기가 프로젝트 자원으로 오인하지 않고, run 디렉토리 밖이라 run 번들이 그대로다. + +- `evaluation.json`·`eval_details.jsonl` 은 레거시 모양 그대로다 — run 디렉토리와 같은 `JsonlStore` 로 쓴다 + (`build_evaluation_doc`·`build_eval_detail_docs` 재사용). eval-wrap 이 이 디렉토리를 평가 원천으로 읽는다(B3). +- `eval-run.json` 은 이 평가의 정체 — 평가 시각·평가기 설정·judge 모델·원천 run 번들 id. 원자적 쓰기(임시 → `os.replace`). + 다른 두 파일 뒤에 마지막으로 쓴다 — 이 파일이 없는 디렉토리는 끝나지 않은 평가다. +- judge 경로(계획 2026-09-28 L12 §2.7)는 `verdicts.jsonl`(기준 판정 전부 — 근거·원 응답 포함, 데이터 홈 전용)을 더 쓰고 + `eval-run.json` 에 `purpose`·`judge{evaluation_run_id, scoring_bundle_id, retry_of}` 를 싣는다(발행 **뒤**에 쓴다). lcs 경로는 + 선택 인자를 주지 않아 바이트가 그대로다. +- 이름 조각(project·run_id)은 경로 구분자·제어 문자·`.`/`..` 를 거부하고, 루트가 레포(링크된 워크트리면 메인 작업 트리도) + 안이면 쓰기 전에 멈춘다 — hub 대상 판정(`hub_hook.target`, D-L6-1)이 이미 보지만 이 모듈이 직접 쓰므로 한 번 더 본다. + +`poc_bundle`·`ulid` 는 함수 안에서만 import 한다(마스터 §4 규칙 11 · D-L6-14). +""" + +from __future__ import annotations + +import dataclasses +import json +import os +import re +import shutil +import tempfile +from collections.abc import Iterable, Mapping, Sequence +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Any + +from solar_eval.core.run_records import build_eval_detail_docs, build_evaluation_doc +from solar_eval.stores import JsonlStore + +#: 데이터 홈 아래 평가 디렉토리 루트 이름(D-L6-15). +EVALUATIONS_DIRNAME = "solar-eval-evaluations" +EVAL_RUN_FILE = "eval-run.json" +EVAL_RUN_SCHEMA = "solar-eval-eval-run/v1" +#: 이 디렉토리가 싣는 평가의 목적 — eval-wrap 매니페스트 `purpose` 와 같은 값(PB `PURPOSE_RESCORE_AFTER_FIX`). +EVAL_RUN_PURPOSE = "rescore_after_fix" +#: judge 경로(계획 2026-09-28 L12 §2.7) — 기준 판정 전부(근거·원 응답 포함)와 `eval-run.json` 의 `judge` 칸 키. +VERDICTS_FILE = "verdicts.jsonl" +JUDGE_BLOCK_KEYS = ("evaluation_run_id", "scoring_bundle_id", "retry_of") +#: 레포 안 쓰기 검사에 주는 이름표. +WRITE_LABEL = "evaluation directory" +_UNSAFE_SEGMENT = re.compile(r"[/\\\x00-\x1f\x7f]") +_TMP_PREFIX = ".eval-run-" +_TMP_SUFFIX = ".tmp" + +#: `EvalDirError.reason` — 발행 다리 코드 `publish_eval_dir:` 의 꼬리. 모두 설정·입력 오류라 다시 돌려도 같다. +REASON_UNSAFE_SEGMENT = "unsafe_segment" +REASON_INSIDE_REPO = "inside_repo" +REASON_BAD_DOCUMENT = "bad_document" +REASON_OUTSIDE_ROOT = "outside_root" + + +class EvalDirError(ValueError): + """평가 디렉토리를 만들거나 쓸 수 없다(이름 조각·레포 안·잘못된 문서). `reason` 은 위 `REASON_*` 하나.""" + + def __init__(self, message: str, reason: str) -> None: + super().__init__(message) + self.reason = reason + + +#: 콘솔 보고용 집계의 채점 입력 행 수(성공 + 실패) — 채택된 run 번들의 정규화 사본 기준(l6b-verify 리뷰 지적 4). +REPORT_SAMPLES = "input_samples" + + +@dataclass(frozen=True) +class WrittenEvaluation: + """`write_evaluation` 결과 — 새 평가 id 와 콘솔 보고용 집계(`overall_score`·`failed`·`REPORT_SAMPLES` 포함).""" + + evaluation_id: str + aggregated: Mapping[str, Any] + + +def evaluations_root(env: Mapping[str, str] | None = None) -> Path: + """`/solar-eval-evaluations`(절대 경로).""" + from poc_bundle.home import data_home + + return data_home(env) / EVALUATIONS_DIRNAME + + +def _check_segment(label: str, value: str) -> str: + if not isinstance(value, str) or value in ("", ".", "..") or _UNSAFE_SEGMENT.search(value): + raise EvalDirError( + f"evaluation directory: {label} is not a safe path segment", REASON_UNSAFE_SEGMENT + ) + return value + + +def _require_outside_repo(root: Path, repo_root: Path | None) -> None: + """루트가 레포(링크된 워크트리면 메인 작업 트리도) 안이면 `EvalDirError`.""" + from poc_bundle.home import HomeError, require_outside_repo + from poc_bundle.mirror import linked_main_work_tree + + if repo_root is None: + return + main = linked_main_work_tree(Path(repo_root)) + for tree in (Path(repo_root),) if main is None else (Path(repo_root), main): + try: + require_outside_repo(root, tree, WRITE_LABEL) + except HomeError as exc: + raise EvalDirError( + f"evaluation directory is inside the repo: {exc}", REASON_INSIDE_REPO + ) from None + + +def create_evaluation_dir( + project: str, + run_id: str, + *, + repo_root: Path | None, + env: Mapping[str, str] | None = None, +) -> Path: + """새 평가 디렉토리(빈 디렉토리)를 만들어 돌려준다. 이름은 새 ULID — 같은 run 의 재평가는 시각 순으로 쌓인다.""" + from ulid import ULID + + parent = ( + evaluations_root(env) + / _check_segment("project", project) + / _check_segment("run_id", run_id) + ) + _require_outside_repo(evaluations_root(env), repo_root) + path = parent / str(ULID()) + path.mkdir(parents=True, exist_ok=False) + return path + + +async def write_evaluation( + path: Path, + run_id: str, + evaluator: Any, + eval_results: Sequence[Mapping[str, Any]], + eval_failures: Sequence[Mapping[str, Any]], +) -> WrittenEvaluation: + """레거시 모양 `evaluation.json`·`eval_details.jsonl` 을 쓴다(`run_eval._evaluate_run` 과 같은 조립).""" + aggregated = evaluator.aggregate(list(eval_results)) + doc = build_evaluation_doc( + run_id, aggregated, eval_results, eval_failures, evaluator.judge_model + ) + store = JsonlStore(path) + evaluation_id = await store.create_evaluation(doc) + await store.insert_eval_details( + build_eval_detail_docs(evaluation_id, eval_results, eval_failures) + ) + report = { + **aggregated, + "overall_score": doc["overall_score"], + "failed": len(eval_failures), + REPORT_SAMPLES: len(eval_results) + len(eval_failures), + } + return WrittenEvaluation(str(evaluation_id), report) + + +def eval_run_document( + *, + project: str, + run_id: str, + run_bundle_id: str, + evaluation_id: str, + evaluated_at: str, + evaluator_config: Mapping[str, Any] | None, + eval_model: str | None, + purpose: str = EVAL_RUN_PURPOSE, + judge: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + """`eval-run.json` 문서(순수). 시각은 오프셋이 있어야 하고 평가기 설정은 JSON 매핑이어야 한다. + + `purpose`·`judge` 는 judge 경로(계획 2026-09-28 L12 §2.7)만 준다 — lcs 경로 호출은 인자를 주지 않아 바이트가 그대로다. + `judge` 가 있으면 끝에 `{"evaluation_run_id", "scoring_bundle_id", "retry_of"}` 를 더한다(발행 실패면 bundle id 는 null). + """ + _check_offset(evaluated_at) + doc: dict[str, Any] = { + "schema": EVAL_RUN_SCHEMA, + "project": project, + "run_id": run_id, + "run_bundle_id": run_bundle_id, + "evaluation_id": evaluation_id, + "evaluated_at": evaluated_at, + "purpose": purpose, + "evaluator_config": _json_mapping(evaluator_config), + "eval_model": eval_model, + } + if judge is not None: + doc["judge"] = _judge_block(judge) + return doc + + +def _judge_block(judge: Mapping[str, Any]) -> dict[str, Any]: + """`eval-run.json` 의 `judge` 칸 — 닫힌 키 셋, 값은 문자열 id 또는 null.""" + if not isinstance(judge, Mapping) or set(judge) != set(JUDGE_BLOCK_KEYS): + raise EvalDirError( + f"eval-run: judge must have exactly the keys {list(JUDGE_BLOCK_KEYS)}", + REASON_BAD_DOCUMENT, + ) + if not all(judge[k] is None or isinstance(judge[k], str) for k in JUDGE_BLOCK_KEYS): + raise EvalDirError("eval-run: judge ids must be strings or null", REASON_BAD_DOCUMENT) + return {key: judge[key] for key in JUDGE_BLOCK_KEYS} + + +def write_verdicts(path: Path, verdicts: Iterable[Any]) -> Path: + """`verdicts.jsonl` — 기준 판정 전부(근거·원 응답 포함, 데이터 홈 전용 — 계획 L12 §2.7 · D-L12-8). + + 한 줄 = 판정 dataclass 의 JSON(키 순서 = 필드 순서, compact, `ensure_ascii=False`). UTF-8 로 옮길 수 없는 문자열(짝 없는 + 서로게이트)이 든 줄만 `ensure_ascii=True` 로 적는다 — 유료 판정 한 건이 파일 전체 쓰기를 깨뜨리지 않게. 레포 밖 검사는 + 디렉토리를 만들 때 이미 했다(`create_evaluation_dir`). + """ + lines: list[str] = [] + for verdict in verdicts: + record = dataclasses.asdict(verdict) + line = json.dumps(record, ensure_ascii=False, separators=(",", ":"), allow_nan=False) + try: + line.encode("utf-8") + except UnicodeEncodeError: + line = json.dumps(record, ensure_ascii=True, separators=(",", ":"), allow_nan=False) + lines.append(line + "\n") + target = Path(path) / VERDICTS_FILE + target.write_text("".join(lines), encoding="utf-8") + return target + + +def _check_offset(value: str) -> None: + try: + parsed = datetime.fromisoformat(value) + except (TypeError, ValueError): + raise EvalDirError( + "eval-run: evaluated_at is not an ISO 8601 time", REASON_BAD_DOCUMENT + ) from None + if parsed.tzinfo is None: + raise EvalDirError("eval-run: evaluated_at needs a UTC offset", REASON_BAD_DOCUMENT) + + +def _json_mapping(value: Mapping[str, Any] | None) -> dict[str, Any] | None: + """평가기 설정을 JSON 으로 옮길 수 있는 사본으로. 설정이 없으면(기본 평가기) None.""" + if value is None: + return None + if not isinstance(value, Mapping): + raise EvalDirError("eval-run: evaluator_config must be a mapping", REASON_BAD_DOCUMENT) + try: + return json.loads(json.dumps(dict(value), ensure_ascii=False)) + except (TypeError, ValueError): + raise EvalDirError( + "eval-run: evaluator_config is not JSON serializable", REASON_BAD_DOCUMENT + ) from None + + +def write_eval_run(path: Path, doc: Mapping[str, Any]) -> Path: + """`eval-run.json` 원자적 쓰기(직렬화 → 같은 디렉토리 임시 파일 → fsync → `os.replace`).""" + text = json.dumps(doc, ensure_ascii=False, indent=2) + "\n" + target = Path(path) / EVAL_RUN_FILE + fd, tmp_name = tempfile.mkstemp(dir=path, prefix=_TMP_PREFIX, suffix=_TMP_SUFFIX) + tmp = Path(tmp_name) + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(text) + fh.flush() + os.fsync(fh.fileno()) + os.replace(tmp, target) + except BaseException: + tmp.unlink(missing_ok=True) + raise + return target + + +def remove_partial(path: Path, env: Mapping[str, str] | None = None) -> None: + """끝나지 못한 평가 디렉토리를 치운다 — 평가 루트 아래의 디렉토리만(다른 곳은 `EvalDirError`).""" + resolved = Path(path).resolve() + root = evaluations_root(env).resolve() + if root not in resolved.parents: + raise EvalDirError( + "evaluation directory: refusing to remove a path outside the root", REASON_OUTSIDE_ROOT + ) + shutil.rmtree(resolved) diff --git a/solar_eval/scoring_publish/judge.py b/solar_eval/scoring_publish/judge.py new file mode 100644 index 0000000000000000000000000000000000000000..28774d2c7c5892849d9aec95db9daac03444ae29 --- /dev/null +++ b/solar_eval/scoring_publish/judge.py @@ -0,0 +1,522 @@ +"""`runs eval` judge 경로 — rubric judge 채점 → `poc-scoring/v1` 채점 번들 발행 (계획 2026-09-28 L12 §2.5 · D-L12-6~9·17). + +`publish._legs_in_lock` 이 `job.family == FAMILY_JUDGE` 면 L6b 의 `_score_and_publish` 대신 `score_and_publish` 를 부른다. +잠금·번들 확인(①)·hub(④)·상태 파일(⑤)은 L6b 그대로이고 ②③(채점·발행)만 judge 판이다. 순서(§2.5): + +0. 준비 — 채택 run 번들 참조 · PB `parse_rubric_doc`(실패 `publish_judge:rubric_invalid`) · 평가기 문서·설정 해시 · 미러 영역. +1. purpose — `--retry-of` 면 `retry.plan_retry`(거부 `publish_retry:<사유>`, 대상 0 이면 skipped `retry_nothing_to_do`), purpose 를 + 주지 않았으면(기본 primary) 같은 subject·같은 설정의 primary 가 이미 있을 때 `publish_judge:primary_exists`. 여기까지 judge 호출 0. +2. 평가 디렉토리(채점 **전에** — 이름 조각·레포 밖 검사가 채점 비용 없이 멈춘다, `publish_eval_dir:`). +3. 채점 — 채택 run 번들 결과(retry 면 대상 샘플만)를 샘플마다 `evaluator.judge` → `verdicts.jsonl`·레거시 모양 평가 파일. 실패면 + 디렉토리를 치우고 `publish_score_failed`(선언과 다른 실제 provider 는 `publish_judge:judge_provider_mismatch`). +4. ok 기준 행이 0 이면 발행하지 않는다(`publish_judge:skip:all_failed`, 디렉토리는 남긴다 — L6b 규칙). +5. 번들 — 판정 초안·payload → 임시 디렉토리에 writer → 미러(`publish_judge:write_failed:<규칙>`·`publish_judge:mirror_failed`). +6. `eval-run.json`(발행 뒤 — 번들 id 를 안다. 발행 실패면 id 는 null)과 ok 다리. + +**종료 코드·hub 다리(검토 F8).** 사전 거부(`publish_judge:{primary_exists,rubric_invalid,mirror_unadoptable, +judge_provider_mismatch,judge_provider_unknown,judge_endpoint_invalid}`·`publish_retry:*` — judge 호출 0)와 +`retry_nothing_to_do` 는 새 번들이 없으므로 hub 다리가 `skipped(publish_refused)` 다(`is_refusal`). 채점 뒤 실패(`write_failed`· +`mirror_failed`·`skip:all_failed`)는 L6b 규칙 그대로 hub 다리를 돈다(run 번들이 이번에 새로 만들어졌을 수 있다). + +구현 시 변경(계획 §2.5 대비): 미러 채택 영역을 읽을 수 없으면(`AdoptError`) `publish_judge:mirror_unadoptable`(hub 코드 +`MIRROR_UNADOPTABLE` 재사용), 예기치 않은 예외는 `publish_exception:`(D-L6-2) — 둘 다 judge 호출 전이면 호출 0 이다. + +`poc_bundle` 은 함수 안에서만 import 한다(D-L6-14). 로그·코드·힌트에 judge 원문·근거·고객 문장을 싣지 않는다. +""" + +from __future__ import annotations + +import asyncio +import logging +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from solar_eval.core.run_records import build_eval_sample_from_result +from solar_eval.evaluators.judge_config import JUDGE_ENDPOINT_INVALID, JUDGE_PROVIDER_UNKNOWN +from solar_eval.evaluators.rubric_judge import ( + JUDGE_PROVIDER_MISMATCH, + CriterionVerdict, + JudgeProviderMismatch, + RubricJudgeEvaluator, + RubricVerdictIncomplete, + SampleVerdicts, +) +from solar_eval.hub_hook.types import ( + LEG_PUBLISH, + MIRROR_UNADOPTABLE, + PUBLISH_DETAIL_EVAL_FAILED, + PUBLISH_DETAIL_EVALUATION_DIR_ID, + PUBLISH_DETAIL_EVALUATION_ID, + PUBLISH_DETAIL_EXCEPTION, + PUBLISH_DETAIL_PURPOSE, + PUBLISH_DETAIL_SCORING_BUNDLE_IDS, + PUBLISH_SCORE_FAILED, + STATUS_FAILED, + STATUS_SKIPPED, + LegResult, + Target, + exception_code, +) +from solar_eval.scoring_publish import eval_dir, judge_bundle, retry +from solar_eval.scoring_publish.publish import ( + PUBLISH_EVAL_DIR_PREFIX, + EvalJob, + Scored, + load_bundle_results, +) + +_LOG = logging.getLogger(__name__) + +# ── purpose(D-L12-9) ──────────────────────────────────────────────────── + +PURPOSE_PRIMARY = "primary" +PURPOSE_VARIANCE_CHECK = "variance_check" +PURPOSE_RETRY = "retry" +#: `runs eval --purpose` 가 받는 값 — retry 는 `--retry-of` 로만 연다. +CLI_PURPOSES = (PURPOSE_PRIMARY, PURPOSE_VARIANCE_CHECK) + +# ── 발행 다리 코드(§2.5 — 상태 파일 `legs.publish.code`, command=eval) ────────── + +PUBLISH_JUDGE_PREFIX = "publish_judge:" +PUBLISH_RETRY_PREFIX = "publish_retry:" +JUDGE_PRIMARY_EXISTS = "primary_exists" +JUDGE_RUBRIC_INVALID = "rubric_invalid" +JUDGE_WRITE_FAILED = "write_failed" +JUDGE_MIRROR_FAILED = "mirror_failed" +#: ok 기준 행 0 — 발행하지 않는다(`publish_judge:skip:all_failed`). +JUDGE_SKIP_ALL_FAILED = "skip:all_failed" +#: retry 대상이 0 — 발행 다리 skipped, hub 다리 skipped(publish_refused), 종료 0. +RETRY_NOTHING_TO_DO = "retry_nothing_to_do" +#: 사전 거부·retry_nothing_to_do 뒤의 hub 다리 skipped 코드(§2.15). +PUBLISH_REFUSED = "publish_refused" + +#: judge 경로 ok 다리 detail 의 추가 키(§2.5 ⑥). +PUBLISH_DETAIL_JUDGE_CALLS = "judge_calls" +PUBLISH_DETAIL_CRITERION_ERRORS = "criterion_errors" +PUBLISH_DETAIL_EVALUATION_RUN_ID = "evaluation_run_id" +PUBLISH_DETAIL_RETRY_OF = "retry_of" + +HINT_PRIMARY_EXISTS = ( + "같은 run 번들·같은 판정 설정의 primary 채점 번들이 이미 있다 — 판정 분산을 재려면 --purpose variance_check, " + "대표를 바꾸려면 --purpose primary, error 만 다시 부르려면 --retry-of " +) +HINT_RUBRIC_INVALID = ( + "rubric 이 rubric/v1 계약을 어긴다 — poc-bundle 의 rubric 검사(RB0~RB4)로 확인한다" +) +HINT_RETRY = "--retry-of 가 가리키는 평가 실행을 다시 부를 수 없다({code}) — 설정이 바뀌었으면 새 primary 로 채점한다" +HINT_RETRY_NOTHING = "원 평가 실행의 error 판정이 이미 모두 풀렸다" +HINT_EVAL_DIR = ( + "평가 디렉토리를 만들거나 쓸 수 없다({reason}) — 입력·설정 오류라 다시 돌려도 같다. " + "CBS_DATA_HOME·solar-eval-evaluations 위치를 확인한다" +) +HINT_ALL_FAILED = "기준 판정이 하나도 나지 않았다 — 발행하지 않았다(hub 대표 채점본 유지). 판정: {path}/verdicts.jsonl" +HINT_NOT_PUBLISHED = "판정은 평가 디렉토리에 남았다({path}/verdicts.jsonl) — 발행만 못 했다" +HINT_RESCORE = "solar-eval runs eval {project} -r {run} 를 다시" +HINT_MIRROR_UNADOPTABLE = ( + "미러 evaluations 영역에 채택을 믿을 수 없는 번들이 있다 — poc-bundle 로 영역을 확인한다" +) + +#: judge 호출 전에 멈춘 발행 거부 — `publish_judge:<이 값>`. judge 가지만의 사전 거부 전부다: rubric·purpose(§2.5) 에 더해 +#: 미러 채택 불가·judge 설정(provider 이름·endpoint)·선언과 다른 실제 provider(첫 호출 전에 멈춘다, D-L12-18). +#: L6b 와 같은 코드(`publish_eval_dir:*`·`publish_exception:*`)는 lcs 가지처럼 hub 다리를 돈다. +_REFUSED_JUDGE_CODES = frozenset( + f"{PUBLISH_JUDGE_PREFIX}{tail}" + for tail in ( + JUDGE_PRIMARY_EXISTS, + JUDGE_RUBRIC_INVALID, + MIRROR_UNADOPTABLE, + JUDGE_PROVIDER_MISMATCH, + JUDGE_PROVIDER_UNKNOWN, + JUDGE_ENDPOINT_INVALID, + ) +) + + +def is_refusal(leg: LegResult) -> bool: + """발행 다리가 "새 번들 없음" 부류인가 — 사전 거부(실패) 또는 `retry_nothing_to_do`(건너뜀). hub 다리를 돌지 않는다.""" + if leg.status == STATUS_SKIPPED: + return leg.code == RETRY_NOTHING_TO_DO + if leg.status != STATUS_FAILED: + return False + return leg.code in _REFUSED_JUDGE_CODES or leg.code.startswith(PUBLISH_RETRY_PREFIX) + + +# ── 준비·purpose(⓪①) ──────────────────────────────────────────────────── + + +class _Stop(Exception): + """judge 를 부르기 전에 멈춘다 — `leg` 가 발행 다리 결과다.""" + + def __init__(self, leg: LegResult) -> None: + super().__init__(leg.code) + self.leg = leg + + +@dataclass(frozen=True) +class _Prepared: + evaluator: RubricJudgeEvaluator + ref: Any + subject_run_id: str + subject_bundle_id: str + rubric: dict[str, Any] + doc: dict[str, Any] + config_hash: str + area: Path + + +def _judge_code(tail: str) -> str: + return f"{PUBLISH_JUDGE_PREFIX}{tail}" + + +def _prepare(t: Target, job: EvalJob, bundle_dir: Path) -> _Prepared: + from poc_bundle.scoring.evaluator import evaluator_document as parse_evaluator + from poc_bundle.scoring.ids import evaluator_config_hash + from poc_bundle.scoring.manifest import ScoringContractError + from poc_bundle.scoring.rubric import parse_rubric_doc + from poc_bundle.scoring.verify import load_run_bundle_ref + + from solar_eval.evaluators.judge_config import JudgeConfigError, judge_endpoint + from solar_eval.rescore.paths import EVALUATIONS_AREA, mirror_area_dir + + evaluator = job.evaluator + if not isinstance(evaluator, RubricJudgeEvaluator): + raise TypeError( + f"judge path needs a rubric_judge evaluator, got {type(evaluator).__name__}" + ) + ref = load_run_bundle_ref(Path(bundle_dir)) + manifest = ref.verify_report.manifest + if manifest is None: + raise ValueError("the adopted run bundle has no readable manifest") + rubric = evaluator.rubric_document + try: + parse_rubric_doc(rubric) + except ScoringContractError: + raise _Stop( + LegResult.failed(_judge_code(JUDGE_RUBRIC_INVALID), HINT_RUBRIC_INVALID) + ) from None + try: + endpoint = judge_endpoint(evaluator.judge_provider) + except JudgeConfigError as exc: + raise _Stop(LegResult.failed(_judge_code(exc.reason))) from None + impl_hash, _ = judge_bundle.implementation_hash() + doc = judge_bundle.evaluator_document(evaluator, endpoint=endpoint, impl_hash=impl_hash) + area = mirror_area_dir(None, t.account, t.case, EVALUATIONS_AREA) + return _Prepared( + evaluator=evaluator, + ref=ref, + subject_run_id=manifest.run_id, + subject_bundle_id=manifest.bundle_id, + rubric=rubric, + doc=doc, + config_hash=evaluator_config_hash(parse_evaluator(doc)), + area=area, + ) + + +def _primary_exists(prep: _Prepared) -> bool: + """같은 case 미러에 같은 subject run 번들·같은 평가기 설정 해시의 primary 번들이 있는가(D-L12-9 (a)).""" + from poc_bundle.adopt import committed_scoring_bundles + + for cands in committed_scoring_bundles(prep.area).values(): + for bundle in cands: + manifest = bundle.manifest + if ( + manifest.get("purpose") == PURPOSE_PRIMARY + and retry.manifest_config_hash(manifest) == prep.config_hash + and retry.manifest_subject(manifest)[1] == prep.subject_bundle_id + ): + return True + return False + + +def _purpose_plan(prep: _Prepared, job: EvalJob) -> retry.RetryPlan | None: + """① — retry 계획(또는 None). 거부·대상 없음·primary 중복은 `_Stop`(judge 호출 0).""" + from poc_bundle.adopt import AdoptError + + try: + if job.retry_of is not None: + plan = retry.plan_retry( + prep.area, + job.retry_of, + subject=prep.ref, + run_id=prep.subject_run_id, + run_bundle_id=prep.subject_bundle_id, + config_hash=prep.config_hash, + ) + elif job.purpose is None and _primary_exists(prep): + raise _Stop(LegResult.failed(_judge_code(JUDGE_PRIMARY_EXISTS), HINT_PRIMARY_EXISTS)) + else: + return None + except AdoptError: + leg = LegResult.failed(_judge_code(MIRROR_UNADOPTABLE), HINT_MIRROR_UNADOPTABLE) + raise _Stop(leg) from None + if isinstance(plan, retry.RetryRefusal): + code = f"{PUBLISH_RETRY_PREFIX}{plan.code}" + raise _Stop(LegResult.failed(code, HINT_RETRY.format(code=plan.code))) + if not plan.targets: + raise _Stop(LegResult.skipped(RETRY_NOTHING_TO_DO, HINT_RETRY_NOTHING)) + return plan + + +# ── 채점(②③) ─────────────────────────────────────────────────────────── + + +@dataclass(frozen=True) +class _Judged: + """이번에 부른 판정(샘플 순)과 item 값의 바탕(샘플 → 기준 판정 전부 — retry 면 원 ok + 새 판정).""" + + verdicts: tuple[SampleVerdicts, ...] + basis: Mapping[int, tuple[CriterionVerdict, ...]] + + @property + def flat(self) -> list[CriterionVerdict]: + return [v for sample in self.verdicts for v in sample.verdicts] + + +def _kept_verdict(sample_idx: int, criterion_id: str, kept: retry.Kept) -> CriterionVerdict: + label, passed = kept + return CriterionVerdict( + sample_idx, criterion_id, "ok", label, passed, None, None, None, None, None, None + ) + + +def _basis( + evaluator: RubricJudgeEvaluator, sample: SampleVerdicts, plan: retry.RetryPlan | None +) -> tuple[CriterionVerdict, ...]: + if plan is None: + return sample.verdicts + fresh = {v.criterion_id: v for v in sample.verdicts} + kept = plan.kept.get(sample.sample_idx, {}) + merged = [ + fresh[c] if c in fresh else _kept_verdict(sample.sample_idx, c, kept[c]) + for c in evaluator.criterion_ids + if c in fresh or c in kept + ] + return tuple(merged) + + +async def _judge_rows( + evaluator: RubricJudgeEvaluator, + provider: Any, + rows: Sequence[Mapping[str, Any]], + plan: retry.RetryPlan | None, +) -> _Judged: + """샘플마다 순차로 judge(retry 면 대상 기준만). 호출 실패는 판정 안의 error 행이지 예외가 아니다.""" + samples: list[SampleVerdicts] = [] + for row in rows: + idx = row["sample_idx"] + sample = build_eval_sample_from_result(dict(row), reference=row.get("golden")) + only = plan.targets[idx] if plan is not None else None + samples.append(await evaluator.judge(sample, provider, sample_idx=idx, only=only)) + basis = {s.sample_idx: _basis(evaluator, s, plan) for s in samples} + return _Judged(tuple(samples), basis) + + +def _rows_to_judge(bundle_dir: Path, plan: retry.RetryPlan | None) -> list[dict[str, Any]]: + rows = load_bundle_results(bundle_dir) + for row in rows: + idx = row.get("sample_idx") + if isinstance(idx, bool) or not isinstance(idx, int): + raise ValueError("run bundle results.jsonl row has no integer sample_idx") + if plan is not None: + rows = [row for row in rows if row["sample_idx"] in plan.targets] + if {row["sample_idx"] for row in rows} != set(plan.targets): + raise ValueError("retry targets are missing from the run bundle results") + if not rows: + raise ValueError("run bundle results.jsonl has no rows") + return rows + + +async def _judge_and_write( + path: Path, run_id: str, prep: _Prepared, provider: Any, rows: Sequence[Mapping[str, Any]], plan +) -> tuple[_Judged, Scored]: + """③ — 판정 → `verdicts.jsonl` → 레거시 모양 `evaluation.json`·`eval_details.jsonl`(텍스트 없음, 코드만).""" + judged = await _judge_rows(prep.evaluator, provider, rows, plan) + eval_dir.write_verdicts(path, judged.flat) + results: list[dict[str, Any]] = [] + failures: list[dict[str, Any]] = [] + for idx, basis in judged.basis.items(): + try: + results.append({**prep.evaluator.legacy_result(basis), "sample_idx": idx}) + except RubricVerdictIncomplete as exc: + failures.append({"sample_idx": idx, "error": exc.code}) + evaluated_at = datetime.now(timezone.utc).isoformat() + written = await eval_dir.write_evaluation(path, run_id, prep.evaluator, results, failures) + return judged, Scored(path, written.evaluation_id, evaluated_at, written.aggregated) + + +def _remove_partial(path: Path) -> None: + """끝나지 못한 평가 디렉토리를 치운다 — 치우기 실패는 원래 오류를 가리지 않게 경고 한 줄로.""" + try: + eval_dir.remove_partial(path) + except Exception as exc: # 원래 실패가 이어서 보고된다 + _LOG.warning("judge 평가 디렉토리를 치우지 못했다 — %s: %s", type(exc).__name__, path) + + +def _contained(exc: Exception) -> str: + code = exception_code(LEG_PUBLISH, exc) + _LOG.debug("runs eval judge 경로 예외 봉쇄 — %s", code, exc_info=exc) + return code + + +def _score( + path: Path, run_id: str, prep: _Prepared, job: EvalJob, bundle_dir: Path, plan +) -> tuple[_Judged, Scored]: + """③ 을 돌리고, 실패면 디렉토리를 치운 뒤 `_Stop`(채점 못 함).""" + hint = HINT_RESCORE.format(project=job.project, run=job.run_dir_name) + try: + rows = _rows_to_judge(bundle_dir, plan) + return asyncio.run(_judge_and_write(path, run_id, prep, job.judge_provider, rows, plan)) + except JudgeProviderMismatch: + _remove_partial(path) + raise _Stop(LegResult.failed(_judge_code(JUDGE_PROVIDER_MISMATCH))) from None + except Exception as exc: # 채점·쓰기 실패 — run 디렉토리·미러는 그대로다 + _contained(exc) + _remove_partial(path) + detail = {PUBLISH_DETAIL_EXCEPTION: type(exc).__name__} + raise _Stop(LegResult.failed(PUBLISH_SCORE_FAILED, hint, detail)) from None + + +# ── 발행(④⑤⑥) ────────────────────────────────────────────────────────── + + +@dataclass(frozen=True) +class _Ids: + evaluation_run_id: str + bundle_id: str + purpose: str + retry_of: str | None + + +def _new_ids(job: EvalJob, plan: retry.RetryPlan | None) -> _Ids: + from ulid import ULID + + purpose = PURPOSE_RETRY if plan is not None else (job.purpose or PURPOSE_PRIMARY) + retry_of = job.retry_of if plan is not None else None + return _Ids(str(ULID()), str(ULID()), purpose, retry_of) + + +def _utc_seconds() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _publish(t: Target, prep: _Prepared, judged: _Judged, ids: _Ids) -> LegResult | None: + """⑤ — 번들을 쓰고 미러에 놓는다. 성공이면 None, 실패면 발행 다리 결과.""" + at = _utc_seconds() + try: + drafts, payloads = judge_bundle.build( + prep.ref, + prep.subject_run_id, + judged.verdicts, + judged.basis, + evaluator=prep.evaluator, + created_at=at, + ) + fields = judge_bundle.scoring_fields( + prep.ref, + evaluation_run_id=ids.evaluation_run_id, + bundle_id=ids.bundle_id, + purpose=ids.purpose, + retry_of=ids.retry_of, + at=at, + usage=judge_bundle.usage_of(judged.flat), + ) + judge_bundle.write_and_mirror(t, fields, prep.doc, drafts, payloads, prep.rubric, prep.ref) + except judge_bundle.BundleWriteFailed as exc: + return LegResult.failed(_judge_code(f"{JUDGE_WRITE_FAILED}:{exc.rule}")) + except judge_bundle.BundleMirrorFailed: + return LegResult.failed(_judge_code(JUDGE_MIRROR_FAILED)) + except Exception as exc: # 초안 조립 실패 — 판정은 평가 디렉토리에 남는다 + _contained(exc) + rule = judge_bundle.WRITE_RULE_EXCEPTION + return LegResult.failed(_judge_code(f"{JUDGE_WRITE_FAILED}:{rule}")) + return None + + +def _finish( + path: Path, run_id: str, prep: _Prepared, job: EvalJob, scored: Scored, ids: _Ids, bundle_id +) -> None: + """⑥ `eval-run.json` — 발행 뒤에 쓴다(발행 못 했으면 `scoring_bundle_id` 는 null, §2.7).""" + doc = eval_dir.eval_run_document( + project=job.project, + run_id=run_id, + run_bundle_id=prep.subject_bundle_id, + evaluation_id=scored.evaluation_id, + evaluated_at=scored.evaluated_at, + evaluator_config=job.evaluator_config, + eval_model=prep.evaluator.judge_model, + purpose=ids.purpose, + judge={ + "evaluation_run_id": ids.evaluation_run_id, + "scoring_bundle_id": bundle_id, + "retry_of": ids.retry_of, + }, + ) + eval_dir.write_eval_run(path, doc) + + +def _ok_detail(scored: Scored, judged: _Judged, ids: _Ids) -> dict[str, Any]: + flat = judged.flat + return { + PUBLISH_DETAIL_EVALUATION_ID: scored.evaluation_id, + PUBLISH_DETAIL_EVALUATION_DIR_ID: scored.path.name, + PUBLISH_DETAIL_SCORING_BUNDLE_IDS: [ids.bundle_id], + PUBLISH_DETAIL_PURPOSE: ids.purpose, + PUBLISH_DETAIL_EVAL_FAILED: scored.aggregated.get("failed", 0), + PUBLISH_DETAIL_JUDGE_CALLS: judge_bundle.usage_of(flat)["judge_calls"], + PUBLISH_DETAIL_CRITERION_ERRORS: sum(1 for v in flat if v.status == "error"), + PUBLISH_DETAIL_EVALUATION_RUN_ID: ids.evaluation_run_id, + PUBLISH_DETAIL_RETRY_OF: ids.retry_of, + } + + +def _after_scoring( + t: Target, job: EvalJob, prep: _Prepared, judged: _Judged, scored: Scored, run_id: str, plan +) -> LegResult: + """④⑤⑥ — 채점이 끝난 뒤. 어떤 실패든 판정은 평가 디렉토리에 남는다.""" + ids = _new_ids(job, plan) + path = scored.path + if not any(v.status == "ok" for v in judged.flat): + leg = LegResult.failed( + _judge_code(JUDGE_SKIP_ALL_FAILED), HINT_ALL_FAILED.format(path=path) + ) + bundle_id = None + else: + failed = _publish(t, prep, judged, ids) + if failed is not None: + leg = LegResult.failed(failed.code, HINT_NOT_PUBLISHED.format(path=path)) + bundle_id = None + else: + leg = LegResult.ok(_ok_detail(scored, judged, ids)) + bundle_id = ids.bundle_id + try: + _finish(path, run_id, prep, job, scored, ids, bundle_id) + except Exception as exc: # 평가 기록만 못 남겼다 — 다리 경계(D-L6-2) + return LegResult.failed(_contained(exc), HINT_NOT_PUBLISHED.format(path=path)) + return leg + + +def score_and_publish( + t: Target, job: EvalJob, bundle: LegResult, bundle_dir: Path, run_id: str +) -> tuple[Scored | None, LegResult]: + """case 잠금 안, 채택 run 번들이 있을 때 — ⓪ 준비 → ① purpose → ② 평가 디렉토리 → ③ 채점 → ④ 전부 실패 → ⑤ 번들 → ⑥ ok(§2.5). + + 반환 = (평가 디렉토리 채점 | None, 발행 다리). 채점하지 못했으면 None 이다. `bundle` 은 L6b 와 같은 자리(쓰지 않는다). + """ + try: + prep = _prepare(t, job, bundle_dir) + plan = _purpose_plan(prep, job) + try: + path = eval_dir.create_evaluation_dir(job.project, run_id, repo_root=t.repo_root) + except eval_dir.EvalDirError as exc: + hint = HINT_EVAL_DIR.format(reason=exc.reason) + return None, LegResult.failed(f"{PUBLISH_EVAL_DIR_PREFIX}{exc.reason}", hint) + judged, scored = _score(path, run_id, prep, job, bundle_dir, plan) + except _Stop as stop: + return None, stop.leg + except Exception as exc: # 다리 경계(D-L6-2) — judge 를 부르기 전의 예기치 않은 실패 + return None, LegResult.failed(_contained(exc)) + return scored, _after_scoring(t, job, prep, judged, scored, run_id, plan) diff --git a/solar_eval/scoring_publish/judge_bundle.py b/solar_eval/scoring_publish/judge_bundle.py new file mode 100644 index 0000000000000000000000000000000000000000..286b8c3c2c66ce80a79c9de753510be22394faea --- /dev/null +++ b/solar_eval/scoring_publish/judge_bundle.py @@ -0,0 +1,396 @@ +"""rubric judge 채점 번들의 모양 — 평가기 문서·판정 행·payload·매니페스트 필드·쓰기와 미러 (계획 2026-09-28 L12 §2.4 · D-L12-5·7·8). + +- **평가기 문서**(`evaluator_document`) — `kind=label_model`·`source_kind=llm`, `params` 는 호출 방식·judge(provider·model·정규화 + endpoint)·sampling·프롬프트 틀 지문·파서·실패 처리·`rubric_hash`·라벨→passed·item 파생. 지표는 item `rubric_score` 하나. +- **`impl_hash`**(`implementation_hash`) = `json_fingerprint(["rubric-judge-impl/v1", closure_hash(["…rubric_judge"])])` — 폐포는 + `rubric_judge` 한 파일이다(평가기 기반·provider·config 는 제외 목록). `judge_config` 는 폐포 밖이다(검토 F9). +- **판정 행**(`build`) — 샘플마다 기준 행(rubric 순서) → item `rubric_score` 행. 행은 허용 목록 키로만 만든다 — 근거·원 응답·예외 + 이름은 `payloads.jsonl` 레코드 `{"kind":"judge_response","value":{rationale, raw, exception}}` 에만 간다(보고서 R6). + JSON/JCS 로 표현할 수 없는 칸은 null 로 바꾸고 `dropped` 에 이름을 적는다 — 판정 한 건이 유료 번들 전체를 버리지 않게(F14). +- **item 값**(`item_score`) 은 PB `derive` 가 정본이다 — 모든 기준이 ok·`passed` 있음일 때만 값, error 가 있으면 `criterion_error`, + 출력이 없으면 `missing_output`, 라벨 미정이 있으면 `verdict_unknown`(D-L12-5). + +`poc_bundle`·`core.snapshot` 은 함수 안에서만 import 한다(D-L6-14). 로그·오류 문자열에 판정 원문·근거를 싣지 않는다. +""" + +from __future__ import annotations + +import copy +import json +import re +import tempfile +from collections.abc import Iterable, Mapping, Sequence +from pathlib import Path +from typing import Any + +from solar_eval._version import __version__ +from solar_eval.evaluators.rubric_judge import ( + ACTOR, + CRITERION_ERROR, + ITEM_METRIC_KEY, + LABEL_PASSED, + MISSING_OUTPUT, + MODE, + ON_PARSE_FAILURE, + PARSER_ID, + PROMPT_SHA256, + TEMPERATURE, + TEMPLATE_ID, + VERDICT_UNKNOWN, + CriterionVerdict, + RubricJudgeEvaluator, + SampleVerdicts, +) +from solar_eval.hub_hook.types import Target + +#: 폐포 시작 모듈 — 평가기 문서 `impl.name` 과 같다. +IMPL_START = "solar_eval.evaluators.rubric_judge" +#: `impl_hash` 튜플 머리의 도메인 태그. 튜플을 바꾸면 새 태그다. +IMPL_TAG = "rubric-judge-impl/v1" +IMPL_VERSION = "1" +EVALUATOR_KIND = "label_model" +SOURCE_KIND = "llm" +DEFAULT_ALIAS = "rubric-judge" +PRODUCER_NAME = "solar-eval runs eval" +PAYLOAD_KIND = "judge_response" +#: payload 값의 세 칸(늘 있다)과 표현 불가 칸 목록 키(있을 때만). +PAYLOAD_FIELDS = ("rationale", "raw", "exception") +DROPPED_KEY = "dropped" +LEVEL_CRITERION = "criterion" +LEVEL_ITEM = "item" +#: 반복 없음(n=1) — `params.sampling.n`. +SAMPLING_N = 1 +#: `write_failed:<규칙>` 의 꼬리 — ScoringWriteError 에 규칙 id 가 없으면 `input`, 그 밖 예외는 `exception`. +WRITE_RULE_INPUT = "input" +WRITE_RULE_EXCEPTION = "exception" +_RULE_ID = re.compile(r"^\[([A-Z]+[0-9]*)\]") + +#: item 지표 선언(D-L12-5) — `evaluator.py` 의 기본값 선택 키는 쓰지 않는다(SCORING §2). +METRICS: list[dict[str, Any]] = [ + { + "key": ITEM_METRIC_KEY, + "level": LEVEL_ITEM, + "score_type": "continuous", + "unit": "ratio", + "min": 0, + "max": 1, + "aggregations": ["mean"], + "primary": True, + "primary_aggregation": "mean", + } +] + + +class BundleWriteFailed(RuntimeError): + """writer 가 번들을 커밋하지 않았다 — `rule` 은 첫 규칙 id 소문자 · `input` · `exception`.""" + + def __init__(self, rule: str) -> None: + super().__init__(rule) + self.rule = rule + + +class BundleMirrorFailed(RuntimeError): + """임시 번들을 미러에 놓지 못했다.""" + + +# ── 평가기 문서 ───────────────────────────────────────────────────────── + + +def implementation_hash(roots: Any = None) -> tuple[str, list[str]]: + """(`impl_hash`, 정렬된 폐포 라벨). `roots`(`ImplRoots`)를 주면 그 해석 루트로 뜬다(시험이 사본을 끼운다).""" + from poc_bundle.canonical import json_fingerprint + + from solar_eval.core.snapshot.impl_hash import closure_hash, default_roots + + closure, labels = closure_hash([IMPL_START], roots or default_roots()) + return json_fingerprint([IMPL_TAG, closure]), labels + + +def rubric_hash_of(rubric: Mapping[str, Any]) -> str: + """writer 가 매니페스트에 싣는 값과 같은 `rubric_hash` — PB 가 검증한 모양(생략 규칙 적용)의 config 지문.""" + from poc_bundle.scoring.ids import rubric_hash + from poc_bundle.scoring.rubric import parse_rubric_doc + + return rubric_hash(parse_rubric_doc(rubric).to_json_dict()) + + +def _json_number(value: float) -> int | float: + return int(value) if float(value).is_integer() else value + + +def evaluator_document( + evaluator: RubricJudgeEvaluator, *, endpoint: str, impl_hash: str +) -> dict[str, Any]: + """`evaluator/v1` 문서(§2.4, 순수). `endpoint` 는 정규화 값이어야 한다(`judge_config.judge_endpoint`).""" + rubric = evaluator.rubric_document + provenance = rubric.get("provenance") + alias = provenance.get("alias") if isinstance(provenance, Mapping) else None + return { + "schema": "evaluator/v1", + "config": { + "kind": EVALUATOR_KIND, + "source_kind": SOURCE_KIND, + "impl": {"name": IMPL_START, "version": IMPL_VERSION, "impl_hash": impl_hash}, + "params": { + "mode": MODE, + "judge": { + "provider": evaluator.judge_provider, + "model": evaluator.judge_model, + "endpoint": endpoint, + }, + "sampling": { + "temperature": _json_number(TEMPERATURE), + "n": SAMPLING_N, + "max_tokens": evaluator.max_tokens, + }, + "prompt": {"template": TEMPLATE_ID, "sha256": PROMPT_SHA256}, + "parser": PARSER_ID, + "on_parse_failure": ON_PARSE_FAILURE, + "rubric_hash": rubric_hash_of(rubric), + "label_passed": dict(LABEL_PASSED), + "item_score": evaluator.item_aggregation, + }, + "metrics": copy.deepcopy(METRICS), + }, + "provenance": { + "alias": alias if isinstance(alias, str) and alias else DEFAULT_ALIAS, + "version_label": f"solar-eval {__version__}", + }, + } + + +# ── item 값·payload ──────────────────────────────────────────────────── + + +def item_score( + evaluator: RubricJudgeEvaluator, verdicts: Sequence[CriterionVerdict] +) -> tuple[str, int | float | None, str | None]: + """(status, score, reason) — item `rubric_score`(D-L12-5). 값은 PB `derive` 로만 계산한다.""" + if any(v.status == "error" for v in verdicts): + return "error", None, CRITERION_ERROR + if any(v.status == "skipped" for v in verdicts): + return "skipped", None, MISSING_OUTPUT + if any(v.passed is None for v in verdicts): + return "skipped", None, VERDICT_UNKNOWN + ids = [v.criterion_id for v in verdicts] + if sorted(ids) != sorted(evaluator.criterion_ids): + raise ValueError("rubric_score: verdicts must cover every criterion exactly once") + from poc_bundle.scoring.derivations import derive + + rows = [{"status": "ok", "criterion_id": v.criterion_id, "passed": v.passed} for v in verdicts] + fn = evaluator.item_aggregation + params = {"points": evaluator.points} if fn == "points_clip@1" else None + value = derive(fn, rows, params)["value"] + if value is None: + raise ValueError(f"rubric_score: {fn} gave no value") + return "ok", value, None + + +def representable(value: Any) -> bool: + """엄격 JSON·JCS 로 표현되는가 — writer `_require_representable` 와 같은 규칙(시험이 같은 표로 대조).""" + from poc_bundle.canonical import jcs_bytes + + try: + jcs_bytes(value) + json.dumps(value, allow_nan=False) + except (ValueError, TypeError): # CanonicalizationError 는 ValueError 의 하위 클래스다. + return False + return True + + +def payload_record(verdict: CriterionVerdict) -> dict[str, Any]: + """기준 행 하나의 payload — `{kind: judge_response, value: {rationale, raw, exception[, dropped]}}`(D-L12-8).""" + value: dict[str, Any] = {name: getattr(verdict, name) for name in PAYLOAD_FIELDS} + dropped = [ + name + for name in ("rationale", "raw") + if value[name] is not None and not representable(value[name]) + ] + for name in dropped: + value[name] = None + if dropped: + value[DROPPED_KEY] = dropped + return {"kind": PAYLOAD_KIND, "value": value} + + +def usage_of(verdicts: Iterable[CriterionVerdict]) -> dict[str, int | None]: + """매니페스트 `usage` — judge 호출 수(출력 없음 skipped 는 호출 아님)와 응답을 받은 호출의 토큰 합. + + 응답을 받은 호출 중 하나라도 토큰 수를 모르면 그 키는 null 이다(부분 합을 전체처럼 적지 않는다). + """ + called = [v for v in verdicts if v.status != "skipped"] + answered = [v for v in called if v.exception is None] + + def total(name: str) -> int | None: + values = [getattr(v, name) for v in answered] + if not values or any(v is None for v in values): + return None + return sum(values) + + return { + "judge_calls": len(called), + "prompt_tokens": total("prompt_tokens"), + "completion_tokens": total("completion_tokens"), + } + + +# ── 판정 행 ───────────────────────────────────────────────────────────── + + +def _criterion_fields(base: Mapping[str, Any], verdict: CriterionVerdict) -> dict[str, Any]: + """기준 행 — 허용 목록 키만(근거·원 응답·예외는 payload 로만, §2.4 누출 방지).""" + fields = {**base, "level": LEVEL_CRITERION, "criterion_id": verdict.criterion_id} + if verdict.status == "ok": + fields["label"] = verdict.label + fields["passed"] = verdict.passed # 라벨 미정이면 None → writer 가 생략한다 + else: + fields["reason"] = verdict.reason + fields["status"] = verdict.status + return fields + + +def _item_fields( + base: Mapping[str, Any], status: str, score: int | float | None, reason: str | None +) -> dict[str, Any]: + fields = {**base, "level": LEVEL_ITEM, "criterion_id": ITEM_METRIC_KEY} + fields["score"] = score if status == "ok" else None + fields["reason"] = reason + fields["status"] = status + return fields + + +def build( + ref: Any, + run_id: str, + verdicts: Sequence[SampleVerdicts], + item_basis: Mapping[int, Sequence[CriterionVerdict]], + *, + evaluator: RubricJudgeEvaluator, + created_at: str, +) -> tuple[list[Any], list[dict[str, Any]]]: + """(판정 초안, payload 레코드) — 샘플 오름차순, 샘플마다 기준 행 → item 행(§2.4 표). + + `item_basis[sample_idx]` 는 item 값을 계산할 기준 판정 전부다 — 첫 채점이면 그 샘플의 판정, retry 면 원 ok 판정 + 새 판정. + `ref` 는 채택 run 번들의 `RunBundleRef`(`items` 로 `input_key`·`golden_key` 를 찾는다). + """ + from poc_bundle.scoring.writer import JudgmentDraft + + drafts: list[Any] = [] + payloads: list[dict[str, Any]] = [] + for sample in sorted(verdicts, key=lambda s: s.sample_idx): + keys = ref.items.get(sample.sample_idx) + if keys is None: + raise ValueError(f"sample_idx {sample.sample_idx} is not in the run bundle items") + base = { + "run_id": run_id, + "sample_idx": sample.sample_idx, + "input_key": keys[0], + "golden_key": keys[1], + "source_kind": SOURCE_KIND, + "actor": ACTOR, + "created_at": created_at, + } + for verdict in sample.verdicts: + payloads.append(payload_record(verdict)) + drafts.append(JudgmentDraft(_criterion_fields(base, verdict), len(payloads) - 1)) + status, score, reason = item_score(evaluator, item_basis[sample.sample_idx]) + drafts.append(JudgmentDraft(_item_fields(base, status, score, reason))) + return drafts, payloads + + +# ── 매니페스트·쓰기 ────────────────────────────────────────────────────── + + +def scoring_fields( + ref: Any, + *, + evaluation_run_id: str, + bundle_id: str, + purpose: str, + retry_of: str | None, + at: str, + usage: Mapping[str, Any] | None, +) -> dict[str, Any]: + """writer 입력 매니페스트 필드(D-L12-7). case·subject 는 채택 run 번들 매니페스트에서 온다.""" + manifest = ref.verify_report.manifest + if manifest is None: + raise ValueError("the adopted run bundle has no readable manifest") + return { + "bundle_id": bundle_id, + "evaluation_run_id": evaluation_run_id, + "id_origin": "ulid", + "supersedes": None, + "created_at": at, + "producer": {"name": PRODUCER_NAME, "version": __version__}, + "case": manifest.case.model_dump(mode="json"), + "subject": { + "kind": "run", + "runs": [ + {"run_id": manifest.run_id, "bundle_id": manifest.bundle_id, "role": "subject"} + ], + }, + "purpose": purpose, + "retry_of": retry_of, + "golden_basis": "run", + "status": "completed", + "finished_at": at, + "reproducible": {"snapshot_complete": True, "missing": []}, + "known_defects": [], + "observed": None, + "usage": dict(usage) if usage is not None else None, + } + + +def _rule(issues: Sequence[str]) -> str: + for issue in issues: + match = _RULE_ID.match(issue) + if match: + return match.group(1).lower() + return WRITE_RULE_INPUT + + +def write_and_mirror( + t: Target, + fields: Mapping[str, Any], + doc: Mapping[str, Any], + drafts: Sequence[Any], + payloads: Sequence[Mapping[str, Any]], + rubric: Mapping[str, Any], + ref: Any, +) -> Path: + """임시 디렉토리에 쓴 뒤(writer 가 E1~E8 까지 커밋 전에 본다) 미러 `evaluations` 에 놓는다 — 선례 `rescore.from_bundle`. + + Raises: + BundleWriteFailed: writer 가 커밋하지 않았다. BundleMirrorFailed: 미러에 놓지 못했다. + """ + from poc_bundle.mirror import MirrorError, mirror_bundle + from poc_bundle.scoring.writer import ScoringWriteError, write_scoring_bundle + + from solar_eval.rescore.paths import EVALUATIONS_AREA + + with tempfile.TemporaryDirectory(prefix="solar-eval-judge-") as tmp: + staged = Path(tmp) / str(fields["bundle_id"]) + try: + write_scoring_bundle( + staged, + fields=fields, + evaluator=doc, + drafts=drafts, + payloads=payloads, + rubric=rubric, + run_bundle=ref, + ) + except ScoringWriteError as exc: + raise BundleWriteFailed(_rule(exc.issues)) from exc + except OSError as exc: + raise BundleWriteFailed(WRITE_RULE_EXCEPTION) from exc + try: + return mirror_bundle( + staged, + account=t.account, + case=t.case, + area=EVALUATIONS_AREA, + repo_root=t.repo_root, + ) + except (MirrorError, OSError) as exc: + raise BundleMirrorFailed(type(exc).__name__) from exc diff --git a/solar_eval/scoring_publish/publish.py b/solar_eval/scoring_publish/publish.py new file mode 100644 index 0000000000000000000000000000000000000000..6ed774ce6b87c82c6beabfeede3ea6c035b2e0c4 --- /dev/null +++ b/solar_eval/scoring_publish/publish.py @@ -0,0 +1,463 @@ +"""`runs eval` 새 경로 — 번들 확인 → 평가 디렉토리 채점 → eval-wrap → hub (계획 2026-09-27 L6 §2.12 B4 · D-M2-6 · C-M2-10). + +**경로 판정(`decide_route`).** 새 경로 = hub 대상(`hub_hook.target.resolve_target` → `Target`) ∧ 태스크 평가기 `lcs_diff` ∧ run 이 +lcs_diff 계열(`bundle_leg.is_proofread_family` — 스냅샷 평가기 type, 스냅샷이 없는 옛 run 은 평가 파일 계열) ∧ run.json +`status == "completed"`. 아니면 오늘 동작(run 디렉토리 in-place) + 안내 한 줄 — standalone 은 조용히. hub 대상 판정 자체가 +예외를 내면 멈춘다(run 디렉토리를 고치기 전에 — lock·case 설정을 고친 뒤 다시). 옛 동작으로 가던 탈출구 플래그는 L10 컷오버에서 +지웠다(계획 2026-09-28 L10 §2.6 · D-L10-7 — 레거시 화면이 사라져 hub 대상 run 디렉토리를 고칠 이유가 없다). + +**judge 가지(계획 2026-09-28 L12 D-L12-6).** 앞선 검사들 뒤 태스크 평가기가 `rubric_judge` 면 run 계열은 보지 않고 완료 상태만 본다 → +`Route(family=FAMILY_JUDGE)`. 새 경로의 ②③(채점·발행)은 `scoring_publish.judge.score_and_publish` 가 맡고, 발행 거부면 hub 다리를 돌지 않는다. + +**새 경로(`publish_run_eval`) — 전부 case 잠금 안(`hub_hook.lock.case_lock`).** + +1. 번들 확인 — `bundle_leg.ensure_run_bundle(target, run_id)`. wrap 이 written·reused·rewrapped_input_fields 가 아니면(동결·오류) + `publish_no_bundle:<번들 코드>` 실패 — **채점하지 않는다**(hub 도 `no_bundle`). +2. 채점 — 평가 디렉토리(`eval_dir`, D-L6-15)를 **채점 전에** 만든다 — 이름 조각·레포 밖 검사가 설정 오류를 채점 비용 없이 + 잡는다(`publish_eval_dir:`, 다시 돌려도 같으므로 재실행 대신 데이터 홈을 고치라고 안내). 그다음 채택된 run 번들의 + `results.jsonl`(last-wins 어댑터가 정규화한 사본, 검토 F12)을 `score_all` 로 → 그 디렉토리에 `evaluation.json`· + `eval_details.jsonl`·`eval-run.json`. run.json 은 쓰지 않는다. 실패는 `publish_score_failed`(타입 이름만 detail 에) — + 끝나지 못한 평가 디렉토리는 치운다. +3. 발행 — `eval_wrap_run_bundle(<미러 run 번들>, <미러 evaluations>, evaluation_dir=…, purpose="rescore_after_fix", + judged_at=<평가 시각>)`. 건너뜀·오류는 `publish_eval_wrap:<코드>`(점수는 평가 디렉토리에 남는다). **전 샘플 채점 실패** + (`overall_score` None)는 발행하지 않는다(`publish_eval_wrap:skip:all_failed`) — 측정 없는 `rescore_after_fix` 번들이 + hub 대표 채점본(`evaluation_default` — 최신 `finished_at`)을 차지해 유효한 첫 채점을 가리지 않게. 실패 행은 평가 디렉토리에 남는다. +4. hub — `hook.gated_hub_leg(target, [run_id])`(opt-in·워크스페이스 게이트·예외 봉쇄, L6a 와 같은 함수). `--no-ingest` 면 `no_ingest`. + 번들이 있으면 발행이 실패해도 돈다(훅과 같은 규칙, D-L6-2). +5. 상태 파일 `status.write_status(target, result, "eval", strict=…, secrets=…)` — 설정 출처 비밀(`hook.status_secrets`)도 + 거부 목록에 넣는다(L10 §2.6 "비밀"). 쓰기 실패는 경고로 강등. + +다리마다 예기치 않은 예외는 `_exception:` 으로 강등하고(D-L6-2) 트레이스백은 이 모듈 로거로만 남긴다. 코드 문자열은 +`hub_hook.types` 한 곳의 것을 쓴다(검토 F11). `poc_bundle`·`solar_eval.rescore` 는 함수 안에서만 import 한다(D-L6-14). +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from collections.abc import Awaitable, Callable, Mapping +from contextlib import ExitStack +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import click + +from solar_eval.hub_hook import bundle_leg, console, hook, lock, optin, status +from solar_eval.hub_hook import target as hub_target +from solar_eval.hub_hook.types import ( + COMMAND_EVAL, + EVAL_WRAP_SKIP_PREFIX, + HOOK_LOCK_TIMEOUT_SECONDS, + LEG_BUNDLE, + LEG_HUB, + LEG_LOCK, + LEG_PUBLISH, + LEG_STATUS_FILE, + LOCK_TIMEOUT, + NO_BUNDLE, + NO_INGEST, + PROOFREAD_EVALUATOR_TYPE, + PUBLISH_EVAL_WRAP_PREFIX, + PUBLISH_DETAIL_EVAL_FAILED, + PUBLISH_DETAIL_EVALUATION_DIR_ID, + PUBLISH_DETAIL_EVALUATION_ID, + PUBLISH_DETAIL_EXCEPTION, + PUBLISH_DETAIL_PURPOSE, + PUBLISH_DETAIL_SCORING_BUNDLE_IDS, + PUBLISH_NO_BUNDLE_PREFIX, + PUBLISH_SCORE_FAILED, + SKIP_STANDALONE, + STEP_BACKFILL, + HookResult, + LegResult, + Skip, + Target, + bundle_ready, + exception_code, + hub_run_id, +) +from solar_eval.evaluators.judge_config import RUBRIC_JUDGE_TYPE +from solar_eval.scoring_publish import eval_dir + +_LOG = logging.getLogger(__name__) +_RESULTS = "results.jsonl" +#: run.json 의 완료 상태 — 이 값이 아니면 옛 동작이 status 를 되살린다(Q-L6-15). +RUN_COMPLETED = "completed" + +NOTICE_NOT_TARGET = "hub 대상이 아니다({code}) — run 디렉토리에 평가를 쓴다(오늘 동작)" +NOTICE_NOT_PROOFREAD = ( + "이 평가기({kind})는 M2 채점 번들 생산자가 없다 — run 디렉토리에 평가를 쓴다" + "(오늘 동작, 다음 백필이 run 번들을 다시 wrap 한다)" +) +NOTICE_INCOMPLETE = "미완료 run(status={status})은 옛 동작으로 평가해 status 를 되살린다 — 다음 백필이 run 번들을 다시 wrap 한다" +#: hub 대상 판정 실패 — 멈춘다(D-L10-7). 예외 원문은 싣지 않는다(타입 이름만). +ERROR_RESOLVE_FAILED = ( + "hub 대상 판정 실패({kind}) — run 디렉토리는 그대로다. " + "lock·case 설정을 고친 뒤 다시(make check-registry)" +) +_RUN_FAMILY_KIND = "run 이 lcs_diff 계열이 아니다" + +# 아래 둘은 hub_hook/types.py 에 아직 없다(L6b 에서 hub_hook/** 는 읽기만) — l6b-verify 가 types.py 로 옮길 후보. +#: 평가 디렉토리 설정·입력 오류 — `publish_eval_dir:`(채점 전에 멈춘다). +PUBLISH_EVAL_DIR_PREFIX = "publish_eval_dir:" +#: 전 샘플 채점 실패 — `publish_eval_wrap:skip:all_failed`(평가 디렉토리는 남기고 발행하지 않는다). +EVAL_SKIP_ALL_FAILED = "all_failed" +HINT_ALL_FAILED = "전 샘플 채점 실패 — 발행하지 않았다(hub 대표 채점본 유지). 실패 사유: {path}/eval_details.jsonl" +HINT_EVAL_DIR_INSIDE_REPO = ( + "평가 디렉토리 루트({root})가 레포 안이다 — CBS_DATA_HOME 이나 solar-eval-evaluations 링크를 레포 밖으로 고친다" + "(다시 돌려도 같다)" +) +HINT_EVAL_DIR = ( + "평가 디렉토리를 만들거나 쓸 수 없다({reason}) — 입력·설정 오류라 다시 돌려도 같다. " + "CBS_DATA_HOME·solar-eval-evaluations 를 고친다" +) +#: 번들 없음 — 채점하지 않는다. `{backfill}` = `make hub-backfill …`(부류 확인). +HINT_NO_BUNDLE = "{backfill} 로 부류 확인 — 번들이 없으면 채점하지 않는다(run 디렉토리는 그대로다)" + +#: judge 가지의 번들 없음 안내 — 채택된 run 번들이 없으면 judge 채점 번들도 없다. 백필로 부류를 확인하고 다시 돌린다. +HINT_JUDGE_NO_BUNDLE = ( + "채택된 run 번들이 없다 — {command} 로 부류를 확인한 뒤 runs eval 을 다시 돌린다" +) + +ScoreAll = Callable[[Any, Any, list[dict[str, Any]]], Awaitable[tuple[list[dict], list[dict]]]] + +#: 새 경로의 채점 계열(계획 2026-09-28 L12 D-L12-6) — lcs_diff 교열(L6b eval-wrap)과 rubric judge(`scoring_publish.judge`). +FAMILY_PROOFREAD = "proofread" +FAMILY_JUDGE = "judge" + + +# ── 경로 판정 ─────────────────────────────────────────────────────────── + + +@dataclass(frozen=True) +class Route: + """새 경로면 `target`, 아니면 None + 안내 한 줄(standalone 은 None — 조용히, `warning` 이면 경고색). `family` 는 새 경로의 채점 계열.""" + + target: Target | None + notice: str | None = None + warning: bool = False + family: str = FAMILY_PROOFREAD + + +def evaluator_type(config: Any) -> str | None: + """태스크 평가기 type — 키가 없으면 기본 평가기(`run_eval._make_evaluator` 와 같은 규칙).""" + from solar_eval.evaluators.registry import DEFAULT_EVALUATOR_CONFIG + + raw = DEFAULT_EVALUATOR_CONFIG if config is None else config + kind = raw.get("type") if isinstance(raw, Mapping) else None + return kind if isinstance(kind, str) and kind else None + + +def decide_route( + cfg: Any, + project: str, + run_dir: Path, + run_meta: Mapping[str, Any], + task_config: Mapping[str, Any], +) -> Route: + """새 경로인가(모듈 설명). hub 대상 판정이 예외를 내면 멈춘다(run 디렉토리를 고치기 전에 — D-L10-7).""" + try: + resolved = hub_target.resolve_target(cfg, project) + except Exception as exc: # 판정 실패를 "대상 아님" 으로 숨겨 run 디렉토리를 고치지 않는다 + _LOG.debug("hub 대상 판정 실패", exc_info=exc) + raise click.ClickException(ERROR_RESOLVE_FAILED.format(kind=type(exc).__name__)) from None + if isinstance(resolved, Skip): + notice = ( + None + if resolved.code == SKIP_STANDALONE + else NOTICE_NOT_TARGET.format(code=resolved.code) + ) + return Route(None, notice) + kind = evaluator_type(task_config.get("evaluator")) + if kind == RUBRIC_JUDGE_TYPE: + return _judge_route(resolved, run_meta) + if kind != PROOFREAD_EVALUATOR_TYPE: + return Route(None, NOTICE_NOT_PROOFREAD.format(kind=kind)) + if not bundle_leg.is_proofread_family(Path(run_dir)): + return Route(None, NOTICE_NOT_PROOFREAD.format(kind=_RUN_FAMILY_KIND)) + run_status = run_meta.get("status") + if run_status != RUN_COMPLETED: + return Route(None, NOTICE_INCOMPLETE.format(status=run_status)) + return Route(resolved) + + +def _judge_route(resolved: Target, run_meta: Mapping[str, Any]) -> Route: + """rubric judge 가지(D-L12-6) — 앞선 검사 뒤, run 계열은 보지 않고 완료 상태만 본다. 미완료는 오늘 동작 + 안내.""" + run_status = run_meta.get("status") + if run_status != RUN_COMPLETED: + return Route(None, NOTICE_INCOMPLETE.format(status=run_status)) + return Route(resolved, family=FAMILY_JUDGE) + + +# ── 새 경로 ───────────────────────────────────────────────────────────── + + +@dataclass(frozen=True) +class EvalJob: + """새 경로 입력. `score_all` 은 `run_eval._score_all`(샘플별 채점 — 순환 import 를 피해 주입한다). + + `family`·`purpose`·`retry_of` 는 judge 경로(L12 D-L12-9)만 쓴다 — 기본값이라 lcs 경로 호출·시험은 그대로다. + """ + + project: str + run_dir_name: str + evaluator: Any + evaluator_config: Mapping[str, Any] | None + judge_provider: Any + score_all: ScoreAll + no_ingest: bool = False + strict: bool = False + family: str = FAMILY_PROOFREAD + purpose: str | None = None + retry_of: str | None = None + + +@dataclass(frozen=True) +class Scored: + """평가 디렉토리에 남은 채점 — `aggregated` 는 콘솔 보고용(`overall_score`·`failed` 포함).""" + + path: Path + evaluation_id: str + evaluated_at: str + aggregated: Mapping[str, Any] + + +@dataclass(frozen=True) +class PublishOutcome: + """훅 결과(상태 파일과 같은 다리들)와 채점(없으면 None — 채점하지 못했다).""" + + result: HookResult + scored: Scored | None + + +def _now() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _contained_code(leg: str, exc: Exception) -> str: + code = exception_code(leg, exc) + level = logging.WARNING if optin.hook_debug_requested() else logging.DEBUG + _LOG.log(level, "runs eval 발행 예외 봉쇄 — %s", code, exc_info=exc) + return code + + +def publish_run_eval(t: Target, job: EvalJob) -> PublishOutcome: + """case 잠금 안에서 ①~⑤(모듈 설명). 잠금 한도를 넘기면 다리를 돌지 않는다(`lock_timeout`).""" + started = _now() + scored: Scored | None = None + with ExitStack() as stack: + try: + held = stack.enter_context( + lock.case_lock(t.account, t.case, timeout=HOOK_LOCK_TIMEOUT_SECONDS) + ) + except Exception as exc: # 잠금 파일을 만들 수 없다 — 다리를 돌리지 않는다 + legs = _lock_failed(_contained_code(LEG_LOCK, exc)) + else: + if held: + legs, scored = _legs_in_lock(t, job) + else: + legs = _lock_failed(LOCK_TIMEOUT) + result = HookResult(job.run_dir_name, t.case_ref, legs, started, _now()) + _write_status(t, result, job.strict) + console.echo_summary(result, job.project) + return PublishOutcome(result, scored) + + +def _lock_failed(code: str) -> dict[str, LegResult]: + return { + LEG_LOCK: LegResult.failed(code), + LEG_BUNDLE: LegResult.not_run(), + LEG_PUBLISH: LegResult.not_run(), + LEG_HUB: LegResult.not_run(), + } + + +def _legs_in_lock(t: Target, job: EvalJob) -> tuple[dict[str, LegResult], Scored | None]: + bundle, bundle_dir = _ensure_bundle(t, job.run_dir_name) + if bundle_dir is None or not bundle_ready(bundle): + code = bundle.code if bundle.is_failed else NO_BUNDLE + hint = HINT_NO_BUNDLE.format( + backfill=console.hub_backfill_command(t.case_ref, job.run_dir_name) + ) + # judge 가지는 탈출 플래그를 권하지 않는다(L12 §2.5 · M-L12-14). + if job.family == FAMILY_JUDGE: + hint = HINT_JUDGE_NO_BUNDLE.format( + command=console.hub_backfill_command(t.case_ref, job.run_dir_name) + ) + publish = LegResult.failed(f"{PUBLISH_NO_BUNDLE_PREFIX}{code}", hint) + return { + LEG_BUNDLE: bundle, + LEG_PUBLISH: publish, + LEG_HUB: LegResult.skipped(NO_BUNDLE), + }, None + # 번들의 run_id(run.json) — hub `--expect-run`·평가 디렉토리 이름. 디렉토리 이름과 다를 수 있다(l6a 리뷰 지적 3). + run_id = hub_run_id(bundle, job.run_dir_name) + if job.family == FAMILY_JUDGE: + return _judge_legs(t, job, bundle, bundle_dir, run_id) + scored, publish = _score_and_publish(t, job, bundle, bundle_dir, run_id) + hub = LegResult.skipped(NO_INGEST) if job.no_ingest else hook.gated_hub_leg(t, (run_id,)) + return {LEG_BUNDLE: bundle, LEG_PUBLISH: publish, LEG_HUB: hub}, scored + + +def _judge_legs( + t: Target, job: EvalJob, bundle: LegResult, bundle_dir: Path, run_id: str +) -> tuple[dict[str, LegResult], Scored | None]: + """judge 경로의 ②③ + hub(§2.5 · 검토 F8). 발행 거부·`retry_nothing_to_do` 면 새 번들이 없어 hub 다리를 돌지 않는다. + + `judge` 는 함수 안에서 import 한다 — 그 모듈이 이 모듈의 `EvalJob`·`Scored` 를 쓴다(순환 방지). + """ + from solar_eval.scoring_publish import judge + + scored, publish = judge.score_and_publish(t, job, bundle, bundle_dir, run_id) + if judge.is_refusal(publish): + hub = LegResult.skipped(judge.PUBLISH_REFUSED) + elif job.no_ingest: + hub = LegResult.skipped(NO_INGEST) + else: + hub = hook.gated_hub_leg(t, (run_id,)) + return {LEG_BUNDLE: bundle, LEG_PUBLISH: publish, LEG_HUB: hub}, scored + + +def _ensure_bundle(t: Target, run_dir_name: str) -> tuple[LegResult, Path | None]: + try: + return bundle_leg.ensure_run_bundle(t, run_dir_name) + except Exception as exc: # 다리 경계(D-L6-2) — 번들 유무를 모르므로 채점하지 않는다 + return LegResult.failed(_contained_code(LEG_BUNDLE, exc)), None + + +def _score_and_publish( + t: Target, job: EvalJob, bundle: LegResult, bundle_dir: Path, run_id: str +) -> tuple[Scored | None, LegResult]: + """② 채점 → ③ eval-wrap. 채점을 못 하면 (None, `publish_eval_dir:` | `publish_score_failed`).""" + backfill = bundle.detail.get(STEP_BACKFILL) + bundle_id = backfill.get("bundle_id") if isinstance(backfill, Mapping) else None + try: + scored = asyncio.run( + _score_into_dir(t, job, bundle_dir, run_id, str(bundle_id or bundle_dir.name)) + ) + except eval_dir.EvalDirError as exc: # 설정·입력 오류 — 재실행이 아니라 고칠 곳을 안내한다 + _contained_code(LEG_PUBLISH, exc) + return None, LegResult.failed(f"{PUBLISH_EVAL_DIR_PREFIX}{exc.reason}", _eval_dir_hint(exc)) + except Exception as exc: # 채점·쓰기 실패 — run 디렉토리는 그대로다 + _contained_code(LEG_PUBLISH, exc) + hint = f"solar-eval runs eval {job.project} -r {job.run_dir_name} 를 다시" + detail = {PUBLISH_DETAIL_EXCEPTION: type(exc).__name__} + return None, LegResult.failed(PUBLISH_SCORE_FAILED, hint, detail) + return scored, _eval_wrap(t, job, bundle_dir, scored) + + +def _eval_dir_hint(exc: eval_dir.EvalDirError) -> str: + if exc.reason == eval_dir.REASON_INSIDE_REPO: + return HINT_EVAL_DIR_INSIDE_REPO.format(root=eval_dir.evaluations_root()) + return HINT_EVAL_DIR.format(reason=exc.reason) + + +def load_bundle_results(bundle_dir: Path) -> list[dict[str, Any]]: + """run 번들의 `results.jsonl`(정규화 사본) — 빈 줄은 건너뛰고, 객체가 아닌 줄은 `ValueError`(줄 번호만).""" + rows: list[dict[str, Any]] = [] + text = (Path(bundle_dir) / _RESULTS).read_text("utf-8") + for number, line in enumerate(text.splitlines(), start=1): + if not line.strip(): + continue + row = json.loads(line) + if not isinstance(row, dict): + raise ValueError(f"run bundle {_RESULTS}: line {number} is not an object") + rows.append(row) + return rows + + +async def _score_into_dir( + t: Target, job: EvalJob, bundle_dir: Path, run_id: str, bundle_id: str +) -> Scored: + results = load_bundle_results(bundle_dir) + if not results: + raise ValueError(f"run bundle {_RESULTS} has no rows") + # 채점 **전에** 만든다 — 이름 조각·레포 밖 검사(EvalDirError)가 채점 비용 없이 멈춘다(리뷰 지적 2). + path = eval_dir.create_evaluation_dir(job.project, run_id, repo_root=t.repo_root) + try: + eval_results, eval_failures = await job.score_all( + job.evaluator, job.judge_provider, results + ) + evaluated_at = datetime.now(timezone.utc).isoformat() + written = await eval_dir.write_evaluation( + path, run_id, job.evaluator, eval_results, eval_failures + ) + doc = eval_dir.eval_run_document( + project=job.project, + run_id=run_id, + run_bundle_id=bundle_id, + evaluation_id=written.evaluation_id, + evaluated_at=evaluated_at, + evaluator_config=job.evaluator_config, + eval_model=job.evaluator.judge_model, + ) + eval_dir.write_eval_run(path, doc) + except BaseException: + _remove_partial(path) + raise + return Scored(path, written.evaluation_id, evaluated_at, written.aggregated) + + +def _remove_partial(path: Path) -> None: + """끝나지 못한 평가 디렉토리를 치운다 — 치우기 실패는 원래 오류를 가리지 않게 경고 한 줄로.""" + try: + eval_dir.remove_partial(path) + except Exception as exc: # 원래 예외가 이어서 올라간다 + console.echo_warning( + f"끝나지 못한 평가 디렉토리를 치우지 못했다 — {type(exc).__name__}: {path}" + ) + + +def _eval_wrap(t: Target, job: EvalJob, bundle_dir: Path, scored: Scored) -> LegResult: + """③ 평가 디렉토리 → 채점 번들(`purpose=rescore_after_fix`, 판정 시각 = 평가 시각 — 검토 F13). + + 전 샘플 채점 실패면 발행하지 않는다(모듈 설명 3, 리뷰 지적 1). import 도 다리 경계 안이다 — 깨진 설치가 + 상태 파일 없이 트레이스백으로 새지 않게(D-L6-2, 리뷰 지적 3). + """ + if scored.aggregated.get("overall_score") is None: + code = f"{PUBLISH_EVAL_WRAP_PREFIX}{EVAL_WRAP_SKIP_PREFIX}{EVAL_SKIP_ALL_FAILED}" + return LegResult.failed(code, HINT_ALL_FAILED.format(path=scored.path)) + hint = f"solar-eval runs eval {job.project} -r {job.run_dir_name} 를 다시 (평가: {scored.path})" + try: + from poc_bundle.backfill.classes import eval_wrap_error_code + from poc_bundle.evalwrap import legacy + + from solar_eval.rescore.paths import EVALUATIONS_AREA, mirror_area_dir + except Exception as exc: # 다리 경계(D-L6-2) — 점수는 평가 디렉토리에 남는다 + return LegResult.failed(_contained_code(LEG_PUBLISH, exc), hint) + try: + out = mirror_area_dir(None, t.account, t.case, EVALUATIONS_AREA) + paths = legacy.eval_wrap_run_bundle( + bundle_dir, + out, + evaluation_dir=scored.path, + purpose=legacy.PURPOSE_RESCORE_AFTER_FIX, + judged_at=scored.evaluated_at, + ) + except legacy.EvalWrapSkip as exc: + return LegResult.failed( + f"{PUBLISH_EVAL_WRAP_PREFIX}{EVAL_WRAP_SKIP_PREFIX}{exc.reason}", hint + ) + except legacy.EvalWrapError as exc: + return LegResult.failed(f"{PUBLISH_EVAL_WRAP_PREFIX}{eval_wrap_error_code(str(exc))}", hint) + except Exception as exc: # 다리 경계(D-L6-2) — 점수는 평가 디렉토리에 남는다 + return LegResult.failed(_contained_code(LEG_PUBLISH, exc), hint) + return LegResult.ok( + { + PUBLISH_DETAIL_EVALUATION_ID: scored.evaluation_id, + PUBLISH_DETAIL_EVALUATION_DIR_ID: scored.path.name, + PUBLISH_DETAIL_SCORING_BUNDLE_IDS: [Path(p).name for p in paths], + PUBLISH_DETAIL_PURPOSE: legacy.PURPOSE_RESCORE_AFTER_FIX, + PUBLISH_DETAIL_EVAL_FAILED: scored.aggregated.get("failed", 0), + } + ) + + +def _write_status(t: Target, result: HookResult, strict: bool) -> None: + try: + status.write_status(t, result, COMMAND_EVAL, strict=strict, secrets=hook.status_secrets(t)) + except Exception as exc: # 상태 파일은 보조 기록 — 쓰기 실패는 경고로 강등(D-L6-2) + console.echo_status_write_failed(_contained_code(LEG_STATUS_FILE, exc)) diff --git a/solar_eval/scoring_publish/retry.py b/solar_eval/scoring_publish/retry.py new file mode 100644 index 0000000000000000000000000000000000000000..bf225c9bc9041691225f94403a08c3eb885ee2b1 --- /dev/null +++ b/solar_eval/scoring_publish/retry.py @@ -0,0 +1,188 @@ +"""`runs eval --retry-of ` — error 판정만 다시 부르는 계획 (계획 2026-09-28 L12 §2.6 · D-L12-9). + +`plan_retry` 는 judge 를 부르기 전에 원 평가 실행을 찾아 거부하거나(`RetryRefusal(code)`) 대상을 돌려준다(`RetryPlan`): + +1. 같은 case 미러 `evaluations/` 에서 `retry_of` 의 최신 번들 — 없으면 `retry_target_not_found`. +2. 매니페스트: `purpose == "retry"` → `retry_of_retry` · subject(run_id·bundle_id)가 이 run 의 채택 번들과 다르면 `retry_subject_mismatch` + (옛 run 번들을 채점한 원 실행 — hub 가 `stale_subject` 로 뺀다) · `evaluator.config_hash` 가 다르면 `retry_evaluator_mismatch` + (설정이 바뀌었으면 retry 가 아니라 새 primary). +3. 채택 run 번들을 subject 로 `verify_scoring_bundle` — 실패면 `retry_target_invalid`. +4. 대상 = 원 번들의 `level=criterion ∧ status=error` 행 − 같은 `retry_of` 의 **유효한** retry 번들이 같은 `target_sig` 에 ok 행을 이미 + 낸 것. `skipped`(출력 없음)는 대상이 아니다. `kept` = 대상이 있는 샘플의 원 ok 판정 + 이미 풀린 판정 — item 행 재계산용. + +구현 시 변경(계획 §2.6 대비): (a) 매니페스트 검사(2)를 verify(3)보다 먼저 한다 — 옛 subject 를 채점한 원 실행은 verify 가 +E1 로 먼저 실패해 `retry_subject_mismatch` 에 닿지 못한다. (b) `kept` 에 앞선 retry 가 푼 판정도 싣는다 — 한 샘플의 error 가 둘이고 +하나만 먼저 풀렸을 때 item 값을 원 ok 판정만으로 다시 계산하면 기준이 빠진다(hub `judgments_current` 와 같은 병합판). + +`poc_bundle` 은 함수 안에서만 import 한다(D-L6-14). 채택 영역을 읽을 수 없으면(`AdoptError`) 호출자에게 그대로 올린다. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +PURPOSE_RETRY = "retry" +LEVEL_CRITERION = "criterion" +STATUS_OK = "ok" +STATUS_ERROR = "error" +JUDGMENTS_FILE = "judgments.jsonl" + +#: 거부 사유(§2.15 — `publish_retry:<코드>`). +RETRY_TARGET_NOT_FOUND = "retry_target_not_found" +RETRY_TARGET_INVALID = "retry_target_invalid" +RETRY_OF_RETRY = "retry_of_retry" +RETRY_SUBJECT_MISMATCH = "retry_subject_mismatch" +RETRY_EVALUATOR_MISMATCH = "retry_evaluator_mismatch" + +#: 기준 판정 한 건의 (label, passed) — 라벨 미정이면 passed 가 None. +Kept = tuple[str | None, bool | None] + + +@dataclass(frozen=True) +class RetryRefusal: + """retry 를 할 수 없다 — judge 를 부르기 전에 멈춘다.""" + + code: str + + +@dataclass(frozen=True) +class RetryPlan: + """retry 대상(`sample_idx` → 기준 id 들, 원 행 순서)과 item 재계산용 판정(`sample_idx` → 기준 id → (label, passed)).""" + + targets: Mapping[int, tuple[str, ...]] + kept: Mapping[int, Mapping[str, Kept]] = field(default_factory=dict) + + @property + def n_targets(self) -> int: + return sum(len(ids) for ids in self.targets.values()) + + +# ── 매니페스트 읽기(검증 전 dict — 모양이 틀리면 None) ──────────────────────── + + +def manifest_subject(manifest: Mapping[str, Any]) -> tuple[Any, Any]: + """(subject run_id, subject bundle_id) — 모양이 틀리면 (None, None).""" + subject = manifest.get("subject") + runs = subject.get("runs") if isinstance(subject, Mapping) else None + first = runs[0] if isinstance(runs, list) and runs else None + if not isinstance(first, Mapping): + return None, None + return first.get("run_id"), first.get("bundle_id") + + +def manifest_config_hash(manifest: Mapping[str, Any]) -> Any: + evaluator = manifest.get("evaluator") + return evaluator.get("config_hash") if isinstance(evaluator, Mapping) else None + + +def _manifest_refusal( + manifest: Mapping[str, Any], run_id: str, run_bundle_id: str, config_hash: str +) -> RetryRefusal | None: + if manifest.get("purpose") == PURPOSE_RETRY: + return RetryRefusal(RETRY_OF_RETRY) + if manifest_subject(manifest) != (run_id, run_bundle_id): + return RetryRefusal(RETRY_SUBJECT_MISMATCH) + if manifest_config_hash(manifest) != config_hash: + return RetryRefusal(RETRY_EVALUATOR_MISMATCH) + return None + + +# ── 번들 읽기 ─────────────────────────────────────────────────────────── + + +def _verifies(path: Path, subject: Any, run_bundle_id: str) -> bool: + from poc_bundle.scoring.verify import verify_scoring_bundle + + return verify_scoring_bundle(path, run_bundles={run_bundle_id: subject}).ok + + +def _criterion_rows(path: Path) -> list[dict[str, Any]]: + """검증을 통과한 번들의 기준 행(파일 순서).""" + from poc_bundle.canonical import load_json_strict + from poc_bundle.jsonl import iter_record_lines + + rows = [load_json_strict(raw) for _, raw in iter_record_lines(path / JUDGMENTS_FILE)] + return [row for row in rows if isinstance(row, dict) and row.get("level") == LEVEL_CRITERION] + + +def _resolved( + committed: Mapping[str, Any], + retry_of: str, + subject: Any, + identity: tuple[str, str, str], +) -> dict[str, dict[str, Any]]: + """같은 `retry_of` 의 유효한 retry 번들이 낸 ok 기준 행 — `target_sig` → 행. 나중 번들이 이긴다(bundle_id 순).""" + from poc_bundle.adopt import latest + + run_id, run_bundle_id, config_hash = identity + retries = [] + for cands in committed.values(): + newest = latest(cands) + manifest = newest.manifest + same = ( + manifest.get("purpose") == PURPOSE_RETRY + and manifest.get("retry_of") == retry_of + and manifest_subject(manifest) == (run_id, run_bundle_id) + and manifest_config_hash(manifest) == config_hash + ) + if same and _verifies(newest.path, subject, run_bundle_id): + retries.append(newest) + resolved: dict[str, dict[str, Any]] = {} + for bundle in sorted(retries, key=lambda c: c.bundle_id): + for row in _criterion_rows(bundle.path): + if row.get("status") == STATUS_OK: + resolved[row["target_sig"]] = row + return resolved + + +def _plan(rows: list[dict[str, Any]], resolved: Mapping[str, Mapping[str, Any]]) -> RetryPlan: + targets: dict[int, list[str]] = {} + kept: dict[int, dict[str, Kept]] = {} + for row in rows: + idx, criterion_id = row["sample_idx"], row["criterion_id"] + if row.get("status") == STATUS_OK: + kept.setdefault(idx, {})[criterion_id] = (row.get("label"), row.get("passed")) + elif row.get("status") == STATUS_ERROR: + fix = resolved.get(row["target_sig"]) + if fix is None: + targets.setdefault(idx, []).append(criterion_id) + else: + kept.setdefault(idx, {})[criterion_id] = (fix.get("label"), fix.get("passed")) + return RetryPlan( + targets={idx: tuple(ids) for idx, ids in targets.items()}, + kept={idx: dict(kept.get(idx, {})) for idx in targets}, + ) + + +def plan_retry( + area: Path, + retry_of: str, + *, + subject: Any, + run_id: str, + run_bundle_id: str, + config_hash: str, +) -> RetryPlan | RetryRefusal: + """§2.6 — 거부 또는 대상. `subject` 는 채택 run 번들의 `RunBundleRef`, `area` 는 case 미러 `evaluations/`. + + Raises: + poc_bundle.adopt.AdoptError: 영역에 채택을 믿을 수 없게 하는 번들이 있다. + """ + from poc_bundle.adopt import committed_scoring_bundles, latest + + committed = committed_scoring_bundles(Path(area)) + cands = committed.get(retry_of) + if not cands: + return RetryRefusal(RETRY_TARGET_NOT_FOUND) + original = latest(cands) + refusal = _manifest_refusal(original.manifest, run_id, run_bundle_id, config_hash) + if refusal is not None: + return refusal + if not _verifies(original.path, subject, run_bundle_id): + return RetryRefusal(RETRY_TARGET_INVALID) + identity = (run_id, run_bundle_id, config_hash) + resolved = _resolved(committed, retry_of, subject, identity) + return _plan(_criterion_rows(original.path), resolved)