dev-strender commited on
Commit
8debb9c
·
verified ·
1 Parent(s): 3ded0dd

deploy: v35 document-level chunking (DP split + parallel chunks), progress view, Supabase logbook

Browse files
README.md CHANGED
@@ -10,7 +10,7 @@ pinned: false
10
 
11
  # 조선일보 본문 교열 데모
12
 
13
- Upstage Solar 기반 7+2-스텝 교열 파이프라인(`pipeline_dev_v34`) 데모입니다.
14
  FT 교열 모델 → Solar 3-스텝 교정 → 결정론 규칙(어휘 치환 · 복합어 화이트리스트
15
  복원 · 율/률 받침 규칙) 순서로 흐르며, 평가 하네스와 같은 엔진 코드를 실행합니다.
16
 
 
10
 
11
  # 조선일보 본문 교열 데모
12
 
13
+ Upstage Solar 기반 7+2-스텝 교열 파이프라인(`pipeline_dev_v35` — 문서 단위 chunk) 데모입니다.
14
  FT 교열 모델 → Solar 3-스텝 교정 → 결정론 규칙(어휘 치환 · 복합어 화이트리스트
15
  복원 · 율/률 받침 규칙) 순서로 흐르며, 평가 하네스와 같은 엔진 코드를 실행합니다.
16
 
app.py CHANGED
@@ -1,7 +1,8 @@
1
  """조선일보 본문 교열 데모 (Streamlit / HF Space).
2
 
3
- v34 파이프라인(prod 7-스텝 + 화이트리스트 복원 + 율/률 규칙) × combo 프롬프트를
4
  solar-eval 엔진 그대로 실행한다. 로직은 전부 runner.py — 이 파일은 UI 만 담당.
 
5
  """
6
 
7
  from __future__ import annotations
@@ -10,19 +11,33 @@ import difflib
10
  import html
11
  import os
12
 
 
13
  import runner
14
  import streamlit as st
15
 
 
 
 
 
 
16
  SAMPLE_TEXT = (
17
  "정부의 강력한 부동산 규제에도 불구하고 수도권 아파트 매매 거래 비률은 "
18
  "오히려 증가세를 보였다. 시중 은행의 주택 담보 대출 금리가 낮아지면서 "
19
  "실수요자의 구매 심리가 되살아났기 때문이라는 분석이 나온다."
20
  )
21
 
22
- PANE_STYLE = (
23
- "border:1px solid rgba(128,128,128,.35);border-radius:8px;"
24
- "padding:14px 16px;line-height:1.9;font-size:1.02rem;min-height:120px"
25
- )
 
 
 
 
 
 
 
 
26
 
27
 
28
  def diff_panes(src: str, dst: str) -> tuple[str, str, int]:
@@ -46,6 +61,74 @@ def diff_panes(src: str, dst: str) -> tuple[str, str, int]:
46
  return " ".join(left), " ".join(right), n_changed
47
 
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  @st.cache_resource
50
  def get_pipeline():
51
  return runner.build_pipeline()
@@ -74,14 +157,33 @@ if st.button("교열 실행", type="primary", disabled=not text.strip()):
74
  "UPSTAGE_API_KEY 가 필요합니다 — 사이드바에 입력하거나 Space Secrets 에 설정하세요."
75
  )
76
  st.stop()
77
- with st.spinner("교열 중입니다…"):
78
- try:
79
- result = runner.run_proofread(get_pipeline(), text.strip(), model=model)
80
- except Exception as e: # noqa: BLE001 — 데모 표면에서는 원인 요약만 보여준다
81
- st.error(f"실행 실패: {type(e).__name__}: {e}")
82
- st.stop()
83
-
84
- left, right, _n_changed = diff_panes(text.strip(), result["output"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
 
86
  c1, c2 = st.columns(2, gap="medium")
87
  with c1:
 
1
  """조선일보 본문 교열 데모 (Streamlit / HF Space).
2
 
3
+ v35 파이프라인(prod 7-스텝 + 화이트리스트 복원 + 율/률 규칙 + 문서 단위 chunk) × combo 프롬프트를
4
  solar-eval 엔진 그대로 실행한다. 로직은 전부 runner.py — 이 파일은 UI 만 담당.
5
+ 사용자 입력과 결과는 logbook.py 가 Supabase 에 남긴다 (시크릿 없으면 조용히 건너뜀).
6
  """
7
 
8
  from __future__ import annotations
 
11
  import html
12
  import os
13
 
14
+ import logbook
15
  import runner
16
  import streamlit as st
17
 
18
+ PANE_STYLE = (
19
+ "border:1px solid rgba(128,128,128,.35);border-radius:8px;"
20
+ "padding:14px 16px;line-height:1.9;font-size:1.02rem;min-height:120px"
21
+ )
22
+
23
  SAMPLE_TEXT = (
24
  "정부의 강력한 부동산 규제에도 불구하고 수도권 아파트 매매 거래 비률은 "
25
  "오히려 증가세를 보였다. 시중 은행의 주택 담보 대출 금리가 낮아지면서 "
26
  "실수요자의 구매 심리가 되살아났기 때문이라는 분석이 나온다."
27
  )
28
 
29
+ # 스텝 이름 → 사용자에게 보여줄 라벨 (pipeline_dev_v34 기준. 모르는 스텝은 이름 그대로)
30
+ STEP_LABELS = {
31
+ "vocabulary_sub_pre": "용어 사전 치환",
32
+ "proofread": "1차 교열 (교열 전용 모델)",
33
+ "basic_correction": "기본 교정 (자기일관성 검증)",
34
+ "context_correction": "문맥 교정",
35
+ "style_correction": "문체 교정",
36
+ "vocabulary_reapply": "용어 사전 재적용",
37
+ "compound_whitelist_restore": "복합어 화이트리스트 복원",
38
+ "yul_ryul_normalize": "율/률 규칙 적용",
39
+ "post_process": "후처리",
40
+ }
41
 
42
 
43
  def diff_panes(src: str, dst: str) -> tuple[str, str, int]:
 
61
  return " ".join(left), " ".join(right), n_changed
62
 
63
 
64
+ class ProgressView:
65
+ """스텝 진행을 st.status 안에 그린다 — 단계별 출력은 보여주지 않고 '어디까지 왔나' 만.
66
+
67
+ 문서 단위 chunk 모드에서는 chunk 들이 **병렬**로 돌아 이벤트가 섞여 들어오므로, chunk 마다 줄을 하나씩
68
+ 두고(몇 단계까지 왔나) 전체 진행 바는 (끝난 단계 수) / (chunk 수 × 단계 수) 로 잡는다.
69
+ """
70
+
71
+ def __init__(self, names: list[str]) -> None:
72
+ self.names = names
73
+ self.total_steps = len(names)
74
+ self.n_chunks = 1
75
+ self.done: dict[int, int] = {1: 0} # chunk 번호 → 끝난 단계 수
76
+ self.current: dict[int, str] = {} # chunk 번호 → 진행 중 단계 라벨
77
+ self.status = st.status("교열 중입니다…", expanded=True)
78
+ self.bar = self.status.progress(0.0, text=f"0 / {self.total_steps} 단계")
79
+ self.lines = self.status.empty()
80
+ self._render()
81
+
82
+ def on_step(self, event: str, index: int, total: int, name: str, doc_chunk=None) -> None:
83
+ ci = doc_chunk[0] if doc_chunk else 1
84
+ if doc_chunk:
85
+ self.n_chunks = doc_chunk[1]
86
+ self.done.setdefault(ci, 0)
87
+ label = STEP_LABELS.get(name, name)
88
+ if event == "start":
89
+ self.current[ci] = label
90
+ elif event == "chunk":
91
+ self.current[ci] = f"{label} · 조각 {index}/{total}"
92
+ elif event == "done":
93
+ self.done[ci] = index
94
+ self.current.pop(ci, None)
95
+ finished = sum(self.done.values())
96
+ grand = self.n_chunks * self.total_steps
97
+ unit = "단계" if self.n_chunks == 1 else f"단계 (문서 조각 {self.n_chunks}개 병렬)"
98
+ self.bar.progress(min(finished / grand, 1.0), text=f"{finished} / {grand} {unit}")
99
+ self._render()
100
+
101
+ def _render(self) -> None:
102
+ if self.n_chunks == 1:
103
+ done = self.done.get(1, 0)
104
+ rows = []
105
+ for i, name in enumerate(self.names, 1):
106
+ label = STEP_LABELS.get(name, name)
107
+ if i <= done:
108
+ rows.append(f"✅ {label}")
109
+ elif i == done + 1:
110
+ rows.append(f"⏳ **{self.current.get(1, label)}**")
111
+ else:
112
+ rows.append(f"▫️ {label}")
113
+ else:
114
+ rows = []
115
+ for ci in range(1, self.n_chunks + 1):
116
+ done = self.done.get(ci, 0)
117
+ if done >= self.total_steps:
118
+ rows.append(f"✅ 조각 {ci}: 완료")
119
+ elif ci in self.current:
120
+ rows.append(f"⏳ 조각 {ci}: **{self.current[ci]}** ({done}/{self.total_steps})")
121
+ else:
122
+ rows.append(f"▫️ 조각 {ci}: 대기")
123
+ self.lines.markdown(" \n".join(rows))
124
+
125
+ def finish(self, elapsed_s: float) -> None:
126
+ self.status.update(label=f"교열 완��� · {elapsed_s:.1f}초", state="complete", expanded=False)
127
+
128
+ def fail(self) -> None:
129
+ self.status.update(label="교열 실패", state="error", expanded=True)
130
+
131
+
132
  @st.cache_resource
133
  def get_pipeline():
134
  return runner.build_pipeline()
 
157
  "UPSTAGE_API_KEY 가 필요합니다 — 사이드바에 입력하거나 Space Secrets 에 설정하세요."
158
  )
159
  st.stop()
160
+
161
+ source = text.strip()
162
+ pipeline = get_pipeline()
163
+ # 입력은 실행 전에 남긴다 — 실행이 실패해도 무엇이 들어왔는지는 알 수 있게
164
+ article_id = logbook.save_article(source)
165
+
166
+ progress = ProgressView(runner.step_names(pipeline))
167
+ try:
168
+ result = runner.run_proofread(pipeline, source, model=model, on_step=progress.on_step)
169
+ except Exception as e: # noqa: BLE001 — 데모 표면에서는 원인 요약만 보여준다
170
+ progress.fail()
171
+ st.error(f"실행 실패: {type(e).__name__}: {e}")
172
+ st.stop()
173
+ progress.finish(result["elapsed_s"])
174
+
175
+ logbook.save_run(
176
+ article_id,
177
+ pipeline_key=result["pipeline_key"],
178
+ prompt_key=result["prompt_key"],
179
+ model=result["model"],
180
+ output=result["output"],
181
+ processing_time_s=result["elapsed_s"],
182
+ )
183
+ if logbook.is_configured() and logbook.last_error():
184
+ st.caption(f"기록 실패 (결과에는 영향 없음): {logbook.last_error()}")
185
+
186
+ left, right, _n_changed = diff_panes(source, result["output"])
187
 
188
  c1, c2 = st.columns(2, gap="medium")
189
  with c1:
assets/pipelines/pipeline_dev_v35.yaml ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ name: pipeline_dev_v35
2
+ description: "v34 + 문서 단위 chunk — 입구에서 한 번 DP 분할해 chunk 마다 전 스텝(basic_correction 포함)을 돌리고 원장으로 접합. long-article 매칭 ΔF1 −0.9 → +3.5 (2026-09-10, insight 260910)."
3
+ extends: pipeline_dev_v34
4
+ overrides:
5
+ document_chunking: true
logbook.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """사용자 입력·교열 결과 기록 — Supabase(PostgREST) 에 직접 REST 호출.
2
+
3
+ 옛 gradio 데모(`services/prj-chosun-gradio/blindtest/db.py`)의 `articles` · `pipeline_runs` 두 테이블을
4
+ 그대로 쓴다(스키마는 `schema.sql`). SDK 대신 httpx 로 REST 를 부르는 것도 그때와 같다 — `supabase`
5
+ 패키지가 끌고 오는 `realtime`/`websockets` 핀이 다른 의존성과 충돌했던 전력 때문.
6
+
7
+ 환경 변수:
8
+ SUPABASE_URL — 프로젝트 URL (https://xxx.supabase.co)
9
+ SUPABASE_KEY (또는 SUPABASE_ANON_KEY) — anon 키 (RLS 정책은 schema.sql)
10
+
11
+ 둘 중 하나라도 없으면 모든 쓰기는 no-op 이고 `is_configured()` 가 False 다. 기록 실패는 절대 데모를
12
+ 멈추지 않는다 — `last_error()` 에 한 줄 남기고 None 을 돌려준다.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+
19
+ import httpx
20
+
21
+ TIMEOUT_S = 10.0
22
+ _last_error: str | None = None
23
+
24
+
25
+ def _url() -> str:
26
+ return os.environ.get("SUPABASE_URL", "").strip().rstrip("/")
27
+
28
+
29
+ def _key() -> str:
30
+ # 옛 gradio 데모는 SUPABASE_KEY, 최근 .env 는 SUPABASE_ANON_KEY 를 쓴다 -- 둘 다 받는다
31
+ return (os.environ.get("SUPABASE_KEY") or os.environ.get("SUPABASE_ANON_KEY") or "").strip()
32
+
33
+
34
+ def is_configured() -> bool:
35
+ return bool(_url() and _key())
36
+
37
+
38
+ def last_error() -> str | None:
39
+ return _last_error
40
+
41
+
42
+ def _post(table: str, row: dict) -> list[dict]:
43
+ headers = {
44
+ "apikey": _key(),
45
+ "Authorization": f"Bearer {_key()}",
46
+ "Content-Type": "application/json",
47
+ "Prefer": "return=representation",
48
+ }
49
+ with httpx.Client(timeout=TIMEOUT_S) as client:
50
+ resp = client.post(f"{_url()}/rest/v1/{table}", headers=headers, json=row)
51
+ if resp.status_code >= 300:
52
+ raise RuntimeError(f"{resp.status_code} {resp.text[:200]}")
53
+ return resp.json() if resp.content else []
54
+
55
+
56
+ def _insert(table: str, row: dict) -> int | None:
57
+ global _last_error
58
+ if not is_configured():
59
+ return None
60
+ try:
61
+ data = _post(table, row)
62
+ _last_error = None
63
+ return data[0]["id"] if data else None
64
+ except Exception as exc: # noqa: BLE001 — 기록은 데모의 부수 기능, 어떤 실패도 삼켜서 한 줄만 남긴다
65
+ _last_error = f"{table}: {type(exc).__name__}: {exc}"
66
+ return None
67
+
68
+
69
+ def save_article(source_text: str) -> int | None:
70
+ """사용자 입력 원문 1건. 교열 실행 **전**에 부른다 — 실행이 실패해도 입력은 남게."""
71
+ return _insert("articles", {"source_text": source_text})
72
+
73
+
74
+ def save_run(
75
+ article_id: int | None,
76
+ *,
77
+ pipeline_key: str,
78
+ prompt_key: str,
79
+ model: str,
80
+ output: str,
81
+ processing_time_s: float,
82
+ ) -> int | None:
83
+ """교열 결과 1건. `article_id` 가 없으면(입력 기록 실패) 결과도 기록하지 않는다."""
84
+ if article_id is None:
85
+ return None
86
+ return _insert(
87
+ "pipeline_runs",
88
+ {
89
+ "article_id": article_id,
90
+ "pipeline_key": pipeline_key,
91
+ "prompt_key": prompt_key,
92
+ "model": model,
93
+ "output": output,
94
+ "processing_time_s": round(processing_time_s, 2),
95
+ },
96
+ )
runner.py CHANGED
@@ -1,4 +1,4 @@
1
- """v34 파이프라인을 텍스트 1건에 실행하는 프로그래매틱 러너.
2
 
3
  교열 로직을 재구현하지 않고 solar-eval 엔진을 그대로 import 한다 — 데모가
4
  보여주는 동작이 평가 run 과 바이트 단위로 같아야 하기 때문이다 (사본 로직은
@@ -16,15 +16,21 @@ from __future__ import annotations
16
 
17
  import asyncio
18
  import time
 
19
  from pathlib import Path
20
  from typing import Any
21
 
22
  import yaml
23
 
24
- PIPELINE_NAME = "pipeline_dev_v34"
 
 
25
  PROMPT_NAME = "prompt_dev_260825_combo"
26
  DEFAULT_MODEL = "solar-pro4"
27
 
 
 
 
28
 
29
  def find_config_dir() -> Path:
30
  here = Path(__file__).resolve().parent
@@ -38,7 +44,7 @@ def find_config_dir() -> Path:
38
 
39
 
40
  def build_pipeline() -> Any:
41
- """v34 파이프라인 인스턴스를 만든다. 앱에서 1회 만들어 재사용한다."""
42
  import solar_eval.pipelines.steps # noqa: F401 — STEP_REGISTRY 등록
43
  from solar_eval.core.dataset_loader import DatasetLoader
44
  from solar_eval.core.pipeline_compose import compose_pipeline
@@ -65,7 +71,14 @@ def build_pipeline() -> Any:
65
  )
66
 
67
 
68
- async def _run_async(pipeline: Any, text: str, model: str) -> dict[str, Any]:
 
 
 
 
 
 
 
69
  from solar_eval.models.sample import EvalSample
70
  from solar_eval.providers.upstage import UpstageProvider
71
 
@@ -80,6 +93,7 @@ async def _run_async(pipeline: Any, text: str, model: str) -> dict[str, Any]:
80
  temperature=0.0,
81
  max_tokens=8000,
82
  reasoning_effort=None,
 
83
  )
84
  elapsed = time.monotonic() - start
85
  return {
@@ -87,9 +101,20 @@ async def _run_async(pipeline: Any, text: str, model: str) -> dict[str, Any]:
87
  "step_outputs": result.artifacts.get("step_outputs", {}),
88
  "usage": result.artifacts.get("usage", {}),
89
  "elapsed_s": elapsed,
 
 
 
90
  }
91
 
92
 
93
- def run_proofread(pipeline: Any, text: str, model: str = DEFAULT_MODEL) -> dict[str, Any]:
94
- """텍스트 1건 교열. {output, step_outputs, usage, elapsed_s} 를 돌려준다."""
95
- return asyncio.run(_run_async(pipeline, text, model))
 
 
 
 
 
 
 
 
 
1
+ """v35 파이프라인(v34 + 문서 단위 chunk)을 텍스트 1건에 실행하는 프로그래매틱 러너.
2
 
3
  교열 로직을 재구현하지 않고 solar-eval 엔진을 그대로 import 한다 — 데모가
4
  보여주는 동작이 평가 run 과 바이트 단위로 같아야 하기 때문이다 (사본 로직은
 
16
 
17
  import asyncio
18
  import time
19
+ from collections.abc import Callable
20
  from pathlib import Path
21
  from typing import Any
22
 
23
  import yaml
24
 
25
+ PIPELINE_NAME = (
26
+ "pipeline_dev_v35" # = v34 + document_chunking (extends). 두 yaml 이 모두 있어야 한다
27
+ )
28
  PROMPT_NAME = "prompt_dev_260825_combo"
29
  DEFAULT_MODEL = "solar-pro4"
30
 
31
+ # 엔진의 StepCallback 과 같은 모양: (event, index, total, step_name, doc_chunk)
32
+ StepCallback = Callable[[str, int, int, str, tuple[int, int] | None], None]
33
+
34
 
35
  def find_config_dir() -> Path:
36
  here = Path(__file__).resolve().parent
 
44
 
45
 
46
  def build_pipeline() -> Any:
47
+ """v35 파이프라인 인스턴스를 만든다. 앱에서 1회 만들어 재사용한다."""
48
  import solar_eval.pipelines.steps # noqa: F401 — STEP_REGISTRY 등록
49
  from solar_eval.core.dataset_loader import DatasetLoader
50
  from solar_eval.core.pipeline_compose import compose_pipeline
 
71
  )
72
 
73
 
74
+ def step_names(pipeline: Any) -> list[str]:
75
+ """UI 가 진행 표시를 미리 그릴 수 있게 스텝 이름을 순서대로."""
76
+ return [step.name for step in pipeline.steps]
77
+
78
+
79
+ async def _run_async(
80
+ pipeline: Any, text: str, model: str, on_step: StepCallback | None
81
+ ) -> dict[str, Any]:
82
  from solar_eval.models.sample import EvalSample
83
  from solar_eval.providers.upstage import UpstageProvider
84
 
 
93
  temperature=0.0,
94
  max_tokens=8000,
95
  reasoning_effort=None,
96
+ on_step=on_step,
97
  )
98
  elapsed = time.monotonic() - start
99
  return {
 
101
  "step_outputs": result.artifacts.get("step_outputs", {}),
102
  "usage": result.artifacts.get("usage", {}),
103
  "elapsed_s": elapsed,
104
+ "pipeline_key": PIPELINE_NAME,
105
+ "prompt_key": PROMPT_NAME,
106
+ "model": model,
107
  }
108
 
109
 
110
+ def run_proofread(
111
+ pipeline: Any,
112
+ text: str,
113
+ model: str = DEFAULT_MODEL,
114
+ on_step: StepCallback | None = None,
115
+ ) -> dict[str, Any]:
116
+ """텍스트 1건 교열. {output, step_outputs, usage, elapsed_s, pipeline_key, prompt_key, model}.
117
+
118
+ `on_step` 은 스텝 시작/종료와 LLM 스텝의 bulk 진행마다 불린다 — UI 진행 표시용.
119
+ """
120
+ return asyncio.run(_run_async(pipeline, text, model, on_step))
solar_eval/pipelines/pipeline.py CHANGED
@@ -13,11 +13,13 @@ rule 후처리 블록이 끼어드는 구조가 이 모델의 원형이다.
13
  - 실행기가 스텝마다 `step_outputs[스텝이름] = ctx.text` 스냅숏을 찍는다.
14
  """
15
 
 
16
  import logging
17
  import time
18
  from abc import ABC, abstractmethod
19
  from dataclasses import dataclass, field
20
  from pathlib import Path
 
21
  from typing import Any
22
 
23
  from solar_eval.core.dataset_loader import DatasetLoader
@@ -35,6 +37,14 @@ class PipelineEnv:
35
  prompts: dict[str, str] = field(default_factory=dict)
36
  input_fields: list[str] = field(default_factory=list)
37
  text_split_size: int = 500
 
 
 
 
 
 
 
 
38
  dataset_loader: DatasetLoader | None = None
39
  # 데이터 루트의 프로젝트 디렉토리. v24 의 tool_calling_judge 가 pmi_lookup
40
  # (1.5MB 파생 통계 — 사람이 편집하지 않는 데이터 자산) 을 풀 때 쓰인다.
@@ -44,6 +54,15 @@ class PipelineEnv:
44
  config_dir: Path | None = None
45
 
46
 
 
 
 
 
 
 
 
 
 
47
  @dataclass
48
  class StepContext:
49
  """샘플 1건이 체인을 흐르는 동안의 가변 상태."""
@@ -64,6 +83,8 @@ class StepContext:
64
  usage: dict[str, int] = field(
65
  default_factory=lambda: {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
66
  )
 
 
67
 
68
  def add_usage(self, usage: dict[str, Any]) -> None:
69
  for k in self.usage:
@@ -110,23 +131,46 @@ class Pipeline(BasePipeline):
110
  max_tokens: int = 8000,
111
  reasoning_effort: str | None = None,
112
  messages: list[dict[str, str]] | None = None,
 
113
  ) -> EvalSample:
114
  input_data = sample.input if isinstance(sample.input, dict) else {}
115
  original_text = input_data.get("original", "")
116
- ctx = StepContext(
117
- provider=provider,
118
- model=model,
119
- temperature=temperature,
120
- max_tokens=max_tokens,
121
- reasoning_effort=reasoning_effort,
122
- system_prompt=prompts,
123
- messages=messages,
124
- original_text=original_text,
125
- text=original_text,
126
- shared=self._shared,
127
- )
128
  start = time.monotonic()
129
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  from datetime import datetime
131
 
132
  for step_i, step in enumerate(self.steps):
@@ -135,6 +179,8 @@ class Pipeline(BasePipeline):
135
  f" [{ts}] step {step_i + 1}/{len(self.steps)} "
136
  f"'{step.name}' ({step.config.get('type', '?')}) start"
137
  )
 
 
138
  try:
139
  await step.run(sample, ctx)
140
  except Exception as e:
@@ -146,13 +192,64 @@ class Pipeline(BasePipeline):
146
  ctx.step_outputs[step.name] = ctx.text
147
  ts = datetime.now().strftime("%H:%M:%S")
148
  logger.info(f" [{ts}] step {step_i + 1} '{step.name}' done, text_len={len(ctx.text)}")
 
 
149
 
150
- elapsed_ms = (time.monotonic() - start) * 1000
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
 
152
- sample.output = ctx.text
153
- sample.artifacts["usage"] = ctx.usage
154
- sample.artifacts["inference_time_ms"] = elapsed_ms
155
- # completion 스텝은 자기 finish_reason 을 직접 남긴다 -- 없을 때만 기본값.
156
- sample.artifacts.setdefault("finish_reason", "stop")
157
- sample.artifacts["step_outputs"] = ctx.step_outputs
158
- return sample
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  - 실행기가 스텝마다 `step_outputs[스텝이름] = ctx.text` 스냅숏을 찍는다.
14
  """
15
 
16
+ import asyncio
17
  import logging
18
  import time
19
  from abc import ABC, abstractmethod
20
  from dataclasses import dataclass, field
21
  from pathlib import Path
22
+ from collections.abc import Callable
23
  from typing import Any
24
 
25
  from solar_eval.core.dataset_loader import DatasetLoader
 
37
  prompts: dict[str, str] = field(default_factory=dict)
38
  input_fields: list[str] = field(default_factory=list)
39
  text_split_size: int = 500
40
+ # bulk 분할 전략 -- "dp"(운영) | "greedy"(regex 사다리, ablation 전용). steps/chunking.py 참조.
41
+ text_split_strategy: str = "dp"
42
+ # 문서 단위 chunk (실험, 2026-09-10): 입구에서 한 번 DP 분할해 chunk 마다 **전 스텝**을 돌리고 원장으로 접합.
43
+ # 켜면 분할 없이 전문을 받던 스텝(basic_correction 의 user_template)도 chunk 의 원문만 본다.
44
+ document_chunking: bool = False
45
+ # 문서 단위 chunk 를 동시에 몇 개까지 돌릴지. chunk 마다 LLM 호출이 순차로 이어지므로 이 값 × 러너 워커 수가
46
+ # 동시 API 호출 상한이다 (solar-pro4 는 10 동시부터 429 가 났다 -- README "지뢰").
47
+ document_chunk_concurrency: int = 4
48
  dataset_loader: DatasetLoader | None = None
49
  # 데이터 루트의 프로젝트 디렉토리. v24 의 tool_calling_judge 가 pmi_lookup
50
  # (1.5MB 파생 통계 — 사람이 편집하지 않는 데이터 자산) 을 풀 때 쓰인다.
 
54
  config_dir: Path | None = None
55
 
56
 
57
+ # 진행 콜백 -- (event, index, total, step_name, doc_chunk). 데모 UI 가 "지금 어느 단계인가" 를 보여주는 용도,
58
+ # 결과에는 아무 영향이 없다.
59
+ # event: "start" | "done" (스텝), "chunk" (LLM 스텝의 bulk 진행), "document" (문서 단위 chunk 시작, step_name 은 "")
60
+ # doc_chunk: 문서 단위 chunk 모드에서 (chunk 번호, chunk 수), 아니면 None. chunk 들이 **병렬**로 돌므로
61
+ # 이벤트가 섞여 들어온다 -- UI 는 이 값으로 줄을 나눠야 한다.
62
+ DocChunk = tuple[int, int] | None
63
+ StepCallback = Callable[[str, int, int, str, DocChunk], None]
64
+
65
+
66
  @dataclass
67
  class StepContext:
68
  """샘플 1건이 체인을 흐르는 동안의 가변 상태."""
 
83
  usage: dict[str, int] = field(
84
  default_factory=lambda: {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
85
  )
86
+ on_step: StepCallback | None = None
87
+ doc_chunk: DocChunk = None # 문서 단위 chunk 모드에서 이 컨텍스트가 담당하는 chunk
88
 
89
  def add_usage(self, usage: dict[str, Any]) -> None:
90
  for k in self.usage:
 
131
  max_tokens: int = 8000,
132
  reasoning_effort: str | None = None,
133
  messages: list[dict[str, str]] | None = None,
134
+ on_step: StepCallback | None = None,
135
  ) -> EvalSample:
136
  input_data = sample.input if isinstance(sample.input, dict) else {}
137
  original_text = input_data.get("original", "")
 
 
 
 
 
 
 
 
 
 
 
 
138
  start = time.monotonic()
139
 
140
+ def make_ctx(text: str, doc_chunk: DocChunk = None) -> StepContext:
141
+ return StepContext(
142
+ provider=provider,
143
+ model=model,
144
+ temperature=temperature,
145
+ max_tokens=max_tokens,
146
+ reasoning_effort=reasoning_effort,
147
+ system_prompt=prompts,
148
+ messages=messages,
149
+ original_text=text,
150
+ text=text,
151
+ shared=self._shared,
152
+ on_step=on_step,
153
+ doc_chunk=doc_chunk,
154
+ )
155
+
156
+ if self.env.document_chunking and len(original_text) > self.env.text_split_size:
157
+ await self._run_by_document_chunks(sample, input_data, original_text, make_ctx, on_step)
158
+ else:
159
+ ctx = make_ctx(original_text)
160
+ await self._run_chain(sample, ctx, on_step)
161
+ sample.output = ctx.text
162
+ sample.artifacts["usage"] = ctx.usage
163
+ sample.artifacts["step_outputs"] = ctx.step_outputs
164
+
165
+ sample.artifacts["inference_time_ms"] = (time.monotonic() - start) * 1000
166
+ # completion 스텝은 자기 finish_reason 을 직접 남긴다 -- 없을 때만 기본값.
167
+ sample.artifacts.setdefault("finish_reason", "stop")
168
+ return sample
169
+
170
+ async def _run_chain(
171
+ self, sample: EvalSample, ctx: StepContext, on_step: StepCallback | None
172
+ ) -> None:
173
+ """스텝 목록을 순서대로 한 번 돈다. `ctx.text` 가 결과, `ctx.step_outputs` 가 스냅숏."""
174
  from datetime import datetime
175
 
176
  for step_i, step in enumerate(self.steps):
 
179
  f" [{ts}] step {step_i + 1}/{len(self.steps)} "
180
  f"'{step.name}' ({step.config.get('type', '?')}) start"
181
  )
182
+ if on_step:
183
+ on_step("start", step_i + 1, len(self.steps), step.name, ctx.doc_chunk)
184
  try:
185
  await step.run(sample, ctx)
186
  except Exception as e:
 
192
  ctx.step_outputs[step.name] = ctx.text
193
  ts = datetime.now().strftime("%H:%M:%S")
194
  logger.info(f" [{ts}] step {step_i + 1} '{step.name}' done, text_len={len(ctx.text)}")
195
+ if on_step:
196
+ on_step("done", step_i + 1, len(self.steps), step.name, ctx.doc_chunk)
197
 
198
+ async def _run_by_document_chunks(
199
+ self,
200
+ sample: EvalSample,
201
+ input_data: dict[str, Any],
202
+ original_text: str,
203
+ make_ctx: Callable[[str, DocChunk], StepContext],
204
+ on_step: StepCallback | None,
205
+ ) -> None:
206
+ """문서를 입구에서 한 번 나눠 chunk 마다 전 스텝을 돌리고, 출력·스냅숏·사용량을 원장으로 합친다."""
207
+ # steps/__init__ 가 llm.py 를 거쳐 이 모듈을 import 하므로 순환을 피해 지연 import
208
+ from solar_eval.pipelines.steps.chunking import join_chunks, plan_chunks
209
+
210
+ plan = plan_chunks(
211
+ original_text, self.env.text_split_size, strategy=self.env.text_split_strategy
212
+ )
213
+ n_chunks = len(plan.chunks)
214
+ limit = asyncio.Semaphore(max(1, int(self.env.document_chunk_concurrency)))
215
+
216
+ async def run_chunk(ci: int, chunk: str) -> EvalSample:
217
+ # chunk 끼리는 독립이라 동시에 돈다 -- 캐시(self._shared)는 읽기 전용으로만 공유
218
+ doc_chunk: DocChunk = (ci + 1, n_chunks)
219
+ async with limit:
220
+ if on_step:
221
+ on_step("document", ci + 1, n_chunks, "", doc_chunk)
222
+ sub = EvalSample(input={**input_data, "original": chunk})
223
+ ctx = make_ctx(chunk, doc_chunk)
224
+ await self._run_chain(sub, ctx, on_step)
225
+ sub.output = ctx.text
226
+ sub.artifacts["usage"] = ctx.usage
227
+ sub.artifacts["step_outputs"] = ctx.step_outputs
228
+ return sub
229
+
230
+ subs: list[EvalSample] = list(
231
+ await asyncio.gather(*(run_chunk(ci, c) for ci, c in enumerate(plan.chunks)))
232
+ )
233
 
234
+ sample.output = join_chunks([sub.output for sub in subs], plan)
235
+ sample.artifacts["step_outputs"] = {
236
+ name: join_chunks([sub.artifacts["step_outputs"][name] for sub in subs], plan)
237
+ for name in subs[0].artifacts["step_outputs"]
238
+ }
239
+ usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
240
+ for sub in subs:
241
+ for k in usage:
242
+ usage[k] += sub.artifacts["usage"].get(k, 0)
243
+ sample.artifacts["usage"] = usage
244
+ if any(sub.artifacts.get("finish_reason", "stop") != "stop" for sub in subs):
245
+ sample.artifacts["finish_reason"] = "length"
246
+ corrections: dict[str, list[Any]] = {}
247
+ for sub in subs:
248
+ for step_name, items in sub.artifacts.get("corrections", {}).items():
249
+ corrections.setdefault(step_name, []).extend(items)
250
+ if corrections:
251
+ sample.artifacts["corrections"] = corrections
252
+ sample.artifacts["document_chunks"] = {
253
+ "lens": [len(c) for c in plan.chunks],
254
+ "kinds": list(plan.kinds),
255
+ }
solar_eval/pipelines/registry.py CHANGED
@@ -113,6 +113,9 @@ def create_pipeline(
113
  prompts=prompts or {},
114
  input_fields=input_fields,
115
  text_split_size=config.get("text_split_size", 500),
 
 
 
116
  dataset_loader=dataset_loader,
117
  project_dir=Path(project_dir) if project_dir else None,
118
  config_dir=Path(config_dir) if config_dir else None,
 
113
  prompts=prompts or {},
114
  input_fields=input_fields,
115
  text_split_size=config.get("text_split_size", 500),
116
+ text_split_strategy=config.get("text_split_strategy", "dp"),
117
+ document_chunking=bool(config.get("document_chunking", False)),
118
+ document_chunk_concurrency=int(config.get("document_chunk_concurrency", 4)),
119
  dataset_loader=dataset_loader,
120
  project_dir=Path(project_dir) if project_dir else None,
121
  config_dir=Path(config_dir) if config_dir else None,
solar_eval/pipelines/steps/chunking.py ADDED
@@ -0,0 +1,294 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """bulk 분할 계획 -- 상한 안에서 문단 > 문장 > 어절 경계만 자르고, 구분자를 원장으로 남겨 접합 때 복원.
2
+
3
+ 왜 이 모듈인가: 옛 `_split_text` 는 500번째 **문자**에서 무조건 잘랐다. 절단면이 공백이면
4
+ `.rstrip()` + `"".join` 이 공백을 삼키고(`미국 제너럴다이내믹스`→`미국제너럴다이내믹스`), 어절
5
+ 중간이면 모델이 뒤 조각을 완성하거나(`들이`→`이들이`) 앞 조각을 버렸고(`이뤄졌다. 지난`→
6
+ `이뤄졌다.난`), `\\n`→`∥PARA∥` 왕복은 마커 한가운데를 잘라 리터럴을 출력에 남겼다. 상세는
7
+ `accounts/chosun/body-proofread/03-evaluation/insights/260910_long_article_regression_set.md`.
8
+
9
+ 왜 greedy(상한까지 채우고 가장 가까운 경계) 가 아니라 DP 인가:
10
+ - greedy 는 꼬리 chunk 를 수십 자로 남긴다 (1022자 → 500/500/22). 조각만 받은 모델은 헛손질한다.
11
+ - 따옴표가 열린 채 끝나는 문장 경계는 약하다 -- 뒤 chunk 가 닫는 따옴표로 시작하면 모델이 "고친다".
12
+ 후보 경계마다 종류별 비용을 두고 chunk 수·짧은 chunk 벌점까지 합쳐 전체 최소를 고르면 둘 다 풀린다.
13
+
14
+ 상한(`max_len`)은 파인튜닝 모델이 잘 도는 구간이라 **절대 넘기지 않는다**. 상한 이하 텍스트는 손대지
15
+ 않고 그대로 1개 chunk 로 돌려준다 (기존 평가셋 결과가 바이트 단위로 보존되는 조건).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import math
21
+ import re
22
+ from dataclasses import dataclass
23
+
24
+ WS_RUN = re.compile(r"\s+")
25
+ SENTENCE_END = ".!?…。!?"
26
+ # 문장 끝 부호 뒤에 붙을 수 있는 닫는 기호 -- `했다.”`, `있다)`, `한다.’` 도 문장 경계로 본다
27
+ TRAILING_CLOSERS = "”’\"')]」』》〉"
28
+
29
+ # 비용은 "선호 순서와 교환 비율" 이다. v2 (2026-09-10 ablation 반영): 문단 정렬이 호출 1회보다 우선한다 --
30
+ # v1(chunk 1.0 > sent 0.15) 은 호출을 아끼려 문장 경계로 ~450자까지 채웠는데, 그 큰 다문단 chunk 가
31
+ # 문단 단위 chunk 보다 recall 을 5점 깎았다(long-article 매칭 ΔF1 -1.4 vs greedy +0.4). 어절 절단·꼬리·
32
+ # 따옴표 안 절단 회피는 그대로다.
33
+ KIND_COST: dict[str, float] = {
34
+ "para": 0.0, # 문단 경계 (공백 런에 개행 포함) -- 공짜. 문단이 상한에 들어가는 한 문단째로 자른다
35
+ "sent": 0.5, # 문장 끝 부호 + 공백 -- 호출 하나(0.1)보다 비싸다: 문단을 쪼개느니 chunk 를 하나 더 쓴다
36
+ "quoted_sent": 1.0, # 따옴표/괄호가 열린 채 만난 문장 끝 -- 뒤 chunk 가 닫는 기호로 시작한다
37
+ "word": 3.0, # 어절 사이 공백 -- 문장 한가운데
38
+ "char": 5.0, # 공백 없는 런이 상한보다 길 때만 쓰는 강제 절단
39
+ }
40
+ CHUNK_COST = 0.1 # chunk 하나 = LLM 호출 하나. 같은 경계 품질이면 호출이 적은 쪽 (tie-breaker 수준)
41
+ SHORT_PENALTY = 3.0 # `min_len` 미만 chunk 벌점 계수 (부족 비율의 제곱에 곱한다) -- 꼬리 방지
42
+ DEFAULT_MIN_LEN = 150
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class Boundary:
47
+ """chunk 사이 경계. `text[start:end]` 가 구분자(공백 런, 강제 절단이면 빈 문자열)."""
48
+
49
+ start: int
50
+ end: int
51
+ kind: str
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class ChunkPlan:
56
+ """`join_chunks(plan.chunks, plan)` 이 원문을 그대로 돌려주는 분할 계획."""
57
+
58
+ chunks: list[str]
59
+ seps: list[str] # chunks 와 같은 길이. seps[i] 는 chunks[i] 뒤 구분자, 마지막은 trailing 공백
60
+ kinds: list[str] # 내부 경계 종류 (len == len(chunks) - 1)
61
+ lead: str # 원문 leading 공백
62
+
63
+ @property
64
+ def is_split(self) -> bool:
65
+ return len(self.chunks) > 1
66
+
67
+
68
+ STRATEGIES = ("dp", "greedy")
69
+
70
+
71
+ def plan_chunks(
72
+ text: str, max_len: int, min_len: int = DEFAULT_MIN_LEN, strategy: str = "dp"
73
+ ) -> ChunkPlan:
74
+ """`text` 를 `max_len` 이하 chunk 들로 나누는 계획을 세운다. 상한 이하면 그대로 1개.
75
+
76
+ `strategy="greedy"` 는 "상한까지 채우고 그 안에서 가장 뒤의 좋은 경계" 라는 단순 사다리 —
77
+ DP 와의 ablation 용이지 운영값이 아니다 (꼬리 chunk · 따옴표 안 절단을 못 막는다).
78
+ """
79
+ if max_len <= 0:
80
+ raise ValueError(f"max_len 은 양수여야 한다: {max_len}")
81
+ if strategy not in STRATEGIES:
82
+ raise ValueError(f"strategy 는 {STRATEGIES} 중 하나여야 한다: {strategy!r}")
83
+ if len(text) <= max_len:
84
+ return ChunkPlan(chunks=[text], seps=[""], kinds=[], lead="")
85
+
86
+ lead_len = len(text) - len(text.lstrip())
87
+ body = text.strip()
88
+ lead, trail = text[:lead_len], text[lead_len + len(body) :]
89
+ if len(body) <= max_len:
90
+ return ChunkPlan(chunks=[body], seps=[trail], kinds=[], lead=lead)
91
+
92
+ boundaries = _candidate_boundaries(body)
93
+ boundaries = _add_forced_cuts(body, boundaries, max_len)
94
+ if strategy == "greedy":
95
+ cut_ids = _solve_greedy(boundaries, len(body), max_len)
96
+ else:
97
+ cut_ids = _solve(body, boundaries, max_len, min_len)
98
+
99
+ chunks, seps, kinds = [], [], []
100
+ pos = 0
101
+ for b in (boundaries[i] for i in cut_ids):
102
+ chunks.append(body[pos : b.start])
103
+ seps.append(body[b.start : b.end])
104
+ kinds.append(b.kind)
105
+ pos = b.end
106
+ chunks.append(body[pos:])
107
+ seps.append(trail)
108
+ return ChunkPlan(chunks=chunks, seps=seps, kinds=kinds, lead=lead)
109
+
110
+
111
+ def join_chunks(outputs: list[str], plan: ChunkPlan) -> str:
112
+ """LLM 을 거친 chunk 출력들을 원장의 구분자로 다시 잇는다. 출력 가장자리 공백은 모델 잡음이라 버린다."""
113
+ if len(outputs) != len(plan.chunks):
114
+ raise ValueError(f"chunk 수 불일치: outputs={len(outputs)} plan={len(plan.chunks)}")
115
+ if not plan.is_split:
116
+ return outputs[0]
117
+ return plan.lead + "".join(
118
+ out.strip() + sep for out, sep in zip(outputs, plan.seps, strict=True)
119
+ )
120
+
121
+
122
+ # ── 출력 위생 ────────────────────────────────────────────────────────────
123
+
124
+ # 토큰 앞뒤의 공백·개행까지 흡수한다 -- 모델이 토큰과 개행을 같이 내면 개행이 겹친다 (실측: 문단 +1)
125
+ SEPARATOR_LITERAL = re.compile(r"\s*<paragraph_separator>\s*")
126
+ LITERAL = "<paragraph_separator>"
127
+ ANCHOR_LEN = 20
128
+
129
+
130
+ def normalize_separator_literals(text: str, source: str | None = None) -> str:
131
+ """모델이 지어낸 `<paragraph_separator>` 리터럴을 그 자리의 원래 구분자로 되돌린다.
132
+
133
+ step1~3 프롬프트는 이 토큰으로 문단 경계를 표시하라고 하지만 엔진은 주입도 제거도 하지 않는다
134
+ (계약 불일치 -- insight 260910 참조). 그래서 모델이 가끔 토큰을 그대로 출력에 남기고, 문단 정렬
135
+ chunk(DP v2) 에서 빈도가 올라간다(실기사 6건 run 당 3건). 계약을 (A) 주입/복원으로 살리든 (B)
136
+ 프롬프트에서 빼든, 최종 텍스트에 리터럴이 남는 건 어느 쪽에서도 옳지 않다 -- 이 가드는 그 결정과
137
+ 독립이다.
138
+
139
+ 무엇으로 바꾸나: `source`(그 chunk 의 입력)가 있으면 리터럴 직전 20자를 source 에서 찾아 그 뒤에 오는
140
+ 원래 공백 런을 쓴다 -- 원문이 개행이면 개행, 문장 사이 공백이면 공백. 모델이 문장 사이에 토큰을
141
+ 지어냈을 때 무조건 개행으로 바꾸면 문단이 늘어난다(실측: 기사 2건에서 +3·+2 문단). 앵커를 못 찾으면
142
+ 개행(토큰의 계약상 의미).
143
+ """
144
+ if LITERAL not in text:
145
+ return text
146
+ if source is None:
147
+ return SEPARATOR_LITERAL.sub("\n", text)
148
+ parts = SEPARATOR_LITERAL.split(text)
149
+ out = parts[0]
150
+ for nxt in parts[1:]:
151
+ out += _original_separator(source, out) + nxt
152
+ return out
153
+
154
+
155
+ def _original_separator(source: str, left: str) -> str:
156
+ anchor = left[-ANCHOR_LEN:].rstrip()
157
+ if anchor:
158
+ i = source.rfind(anchor)
159
+ if i >= 0:
160
+ m = WS_RUN.match(source, i + len(anchor))
161
+ if m:
162
+ return "\n" if ("\n" in m.group() or "\r" in m.group()) else " "
163
+ return " " # 원문엔 공백조차 없던 자리 -- 붙여 쓰진 않고 최소 구분자
164
+ return "\n"
165
+
166
+
167
+ # ── 후보 경계 ────────────────────────────────────────────────────────────
168
+
169
+
170
+ def _candidate_boundaries(body: str) -> list[Boundary]:
171
+ open_depth = _open_quote_depth(body)
172
+ out: list[Boundary] = []
173
+ for m in WS_RUN.finditer(body):
174
+ start, end = m.span()
175
+ if start == 0 or end == len(body):
176
+ continue # strip() 했으므로 없지만 방어
177
+ if "\n" in m.group() or "\r" in m.group():
178
+ kind = "para"
179
+ elif _ends_sentence(body, start, end):
180
+ kind = "quoted_sent" if open_depth[start] > 0 else "sent"
181
+ else:
182
+ kind = "word"
183
+ out.append(Boundary(start, end, kind))
184
+ return out
185
+
186
+
187
+ def _ends_sentence(body: str, ws_start: int, ws_end: int) -> bool:
188
+ i = ws_start - 1
189
+ while i >= 0 and body[i] in TRAILING_CLOSERS:
190
+ i -= 1
191
+ if i < 0 or body[i] not in SENTENCE_END:
192
+ return False
193
+ # `3. 4.` 같은 번호 매김 -- 숫자 사이의 마침표는 문장 끝이 아니다
194
+ numbered = (
195
+ body[i] == "." and i > 0 and body[i - 1].isdigit() and body[ws_end : ws_end + 1].isdigit()
196
+ )
197
+ return not numbered
198
+
199
+
200
+ def _open_quote_depth(body: str) -> list[int]:
201
+ """각 위치 직전까지 열려 있는 따옴표/괄호 수. 곧은따옴표(")는 홀수 개면 열린 것으로 본다."""
202
+ pairs = {"“": 1, "”": -1, "‘": 1, "’": -1, "(": 1, ")": -1, "「": 1, "」": -1}
203
+ depth: list[int] = [0] * (len(body) + 1)
204
+ curly = 0
205
+ straight = 0
206
+ for i, ch in enumerate(body):
207
+ depth[i] = max(curly, 0) + straight
208
+ if ch == '"':
209
+ straight ^= 1
210
+ else:
211
+ curly += pairs.get(ch, 0)
212
+ depth[len(body)] = max(curly, 0) + straight
213
+ return depth
214
+
215
+
216
+ def _add_forced_cuts(body: str, boundaries: list[Boundary], max_len: int) -> list[Boundary]:
217
+ """공백 없는 런이 `max_len` 보다 길면 그 안에 강제 절단점을 넣는다 (URL 덩어리 같은 예외)."""
218
+ anchors = [0] + [b.end for b in boundaries]
219
+ starts = [b.start for b in boundaries] + [len(body)]
220
+ forced: list[Boundary] = []
221
+ for prev_end, next_start in zip(anchors, starts, strict=True):
222
+ pos = prev_end + max_len
223
+ while pos < next_start:
224
+ forced.append(Boundary(pos, pos, "char"))
225
+ pos += max_len
226
+ return sorted(boundaries + forced, key=lambda b: b.start)
227
+
228
+
229
+ # ── DP ───────────────────────────────────────────────────────────────────
230
+
231
+
232
+ def _solve(body: str, boundaries: list[Boundary], max_len: int, min_len: int) -> list[int]:
233
+ """전체 비용 최소인 경계 부분집합(인덱스, 오름차순)을 고른다.
234
+
235
+ 노드 0 = 본문 시작, 노드 k(1..K) = boundaries[k-1], 노드 K+1 = 본문 끝.
236
+ chunk(i→j) 비용 = CHUNK_COST + 짧은 chunk 벌점 + (j 가 내부 경계면) 경계 종류 비용.
237
+ """
238
+ n_b = len(boundaries)
239
+ node_end = [0] + [b.end for b in boundaries] # chunk 시작 위치 (노드별)
240
+ node_start = [b.start for b in boundaries] + [len(body)] # chunk 끝 위치 (노드 1..K+1)
241
+ best = [math.inf] * (n_b + 2)
242
+ back = [-1] * (n_b + 2)
243
+ best[0] = 0.0
244
+ for j in range(1, n_b + 2):
245
+ end_pos = node_start[j - 1]
246
+ edge_cost = KIND_COST[boundaries[j - 1].kind] if j <= n_b else 0.0
247
+ for i in range(j - 1, -1, -1):
248
+ length = end_pos - node_end[i]
249
+ if length > max_len:
250
+ break
251
+ if length <= 0 or best[i] == math.inf:
252
+ continue
253
+ cost = best[i] + CHUNK_COST + _short_penalty(length, min_len) + edge_cost
254
+ if cost < best[j]:
255
+ best[j], back[j] = cost, i
256
+ if best[n_b + 1] == math.inf:
257
+ raise RuntimeError("분할 계획 실패 -- 강제 절단점이 부족하다 (버그)")
258
+ cut_ids: list[int] = []
259
+ j = n_b + 1
260
+ while back[j] > 0:
261
+ j = back[j]
262
+ cut_ids.append(j - 1)
263
+ return sorted(cut_ids)
264
+
265
+
266
+ GREEDY_LADDER = (("para",), ("sent", "quoted_sent"), ("word",), ("char",))
267
+
268
+
269
+ def _solve_greedy(boundaries: list[Boundary], n: int, max_len: int) -> list[int]:
270
+ """regex 사다리 재현: 창(pos, pos+max_len] 안에서 문단 > 문장 > 어절 > 강제 순으로, 가장 뒤 경계."""
271
+ cut_ids: list[int] = []
272
+ pos = 0
273
+ while n - pos > max_len:
274
+ window = [i for i, b in enumerate(boundaries) if pos < b.start <= pos + max_len]
275
+ pick = next(
276
+ (
277
+ cands[-1]
278
+ for kinds in GREEDY_LADDER
279
+ if (cands := [i for i in window if boundaries[i].kind in kinds])
280
+ ),
281
+ None,
282
+ )
283
+ if pick is None:
284
+ raise RuntimeError("greedy 분할 실패 -- 강제 절단점이 부족하다 (버그)")
285
+ cut_ids.append(pick)
286
+ pos = boundaries[pick].end
287
+ return cut_ids
288
+
289
+
290
+ def _short_penalty(length: int, min_len: int) -> float:
291
+ if min_len <= 0 or length >= min_len:
292
+ return 0.0
293
+ deficit = 1.0 - length / min_len
294
+ return SHORT_PENALTY * deficit * deficit
solar_eval/pipelines/steps/llm.py CHANGED
@@ -15,11 +15,26 @@ from solar_eval.models.sample import EvalSample
15
  from solar_eval.pipelines import judge
16
  from solar_eval.pipelines.pipeline import BaseStep, StepContext
17
  from solar_eval.pipelines.registry import register_step
 
 
 
 
 
 
18
 
19
  logger = logging.getLogger(__name__)
20
 
21
- # 문단 경계 보존용 마커 (bulk 분할 시) -- multi_step.py 원본 값 그대로
22
- PARAGRAPH_SEP = "∥PARA∥"
 
 
 
 
 
 
 
 
 
23
 
24
 
25
  @register_step("llm")
@@ -51,13 +66,25 @@ class LLMStep(BaseStep):
51
  # Split into bulks if needed
52
  # For include_original/user_template steps, skip splitting
53
  if include_original or user_template:
54
- bulks = [text]
55
  else:
56
- bulks = self._split_text(text)
 
 
 
57
  processed_bulks = []
58
  logger.debug(f" [{ts}] {len(bulks)} bulk(s), text_len={len(text)}")
 
 
 
 
 
 
 
59
 
60
  for bulk_i, bulk in enumerate(bulks):
 
 
61
  if step_repeat > 1:
62
  (
63
  final_text,
@@ -119,16 +146,20 @@ class LLMStep(BaseStep):
119
  )
120
  ts3 = datetime.now().strftime("%H:%M:%S")
121
  raw_len = len(result.get("content", ""))
122
- output = self._extract_output(result["content"]).rstrip()
 
 
123
 
124
- # JSON 파싱 실패 시 fallback:
125
- # output이 입력 대비 5배 이상 길면 파싱 실패로 간주
126
- if len(output) > len(bulk) * 5 and len(bulk) > 0:
127
  raw_content = result.get("content", "")
128
  logger.warning(
129
- f" [{ts3}] [{step_name}] bulk {bulk_i + 1} output too long "
130
- f"({len(output)} vs input {len(bulk)}), "
131
- f"attempting fallback"
 
 
 
132
  )
133
 
134
  # Try fallback LLM call if configured
@@ -156,10 +187,12 @@ class LLMStep(BaseStep):
156
  reasoning_effort=step_re,
157
  response_format=fallback_rf,
158
  )
159
- fallback_output = self._extract_output(fallback_result["content"]).rstrip()
 
 
160
  ts4 = datetime.now().strftime("%H:%M:%S")
161
 
162
- if len(fallback_output) <= len(bulk) * 5:
163
  output = fallback_output
164
  logger.info(
165
  f" [{ts4}] [{step_name}] fallback succeeded, len={len(output)}"
@@ -168,7 +201,7 @@ class LLMStep(BaseStep):
168
  else:
169
  output = bulk
170
  logger.warning(
171
- f" [{ts4}] [{step_name}] fallback also too long, using input text"
172
  )
173
  else:
174
  output = bulk
@@ -191,33 +224,11 @@ class LLMStep(BaseStep):
191
  step_corrs = sample.artifacts.setdefault("corrections", {})
192
  step_corrs.setdefault(step_name, []).extend(step_corrections)
193
 
194
- ctx.text = self._join_bulks(processed_bulks)
 
195
 
196
  # ── helpers (multi_step.py 에서 그대로 이동) ─────────────────────────
197
 
198
- def _split_text(self, text: str) -> list[str]:
199
- """Split text into bulks of max text_split_size chars, preserving line breaks."""
200
- if len(text) <= self.env.text_split_size:
201
- return [text]
202
-
203
- text_with_markers = text.replace("\n", PARAGRAPH_SEP)
204
- bulks = []
205
- current = ""
206
-
207
- for char in text_with_markers:
208
- current += char
209
- if len(current) >= self.env.text_split_size:
210
- bulks.append(current.replace(PARAGRAPH_SEP, "\n"))
211
- current = ""
212
-
213
- if current:
214
- bulks.append(current.replace(PARAGRAPH_SEP, "\n"))
215
-
216
- return bulks
217
-
218
- def _join_bulks(self, bulks: list[str]) -> str:
219
- return "".join(bulks)
220
-
221
  def _extract_output(self, content: str) -> str:
222
  """Extract output from LLM response. Tries JSON parsing first.
223
 
 
15
  from solar_eval.pipelines import judge
16
  from solar_eval.pipelines.pipeline import BaseStep, StepContext
17
  from solar_eval.pipelines.registry import register_step
18
+ from solar_eval.pipelines.steps.chunking import (
19
+ ChunkPlan,
20
+ join_chunks,
21
+ normalize_separator_literals,
22
+ plan_chunks,
23
+ )
24
 
25
  logger = logging.getLogger(__name__)
26
 
27
+ # 출력 길이 가드 -- 교열은 길이를 거의 바꾸지 않는다. 입력 대비 ±25% 또는 40자(짧은 입력용) 를 넘는 변동은
28
+ # 모델이 문장을 지어냈거나(실측 49자→321자) bulk 를 통째로 버린 것(490자→7자)이라 폴백/입력 유지로 처리한다.
29
+ # 종전 규칙("5배 초과")은 길이 급감과 5배 미만의 환각을 그대로 통과시켰다 (insight 260910).
30
+ LENGTH_TOL_REL = 0.25
31
+ LENGTH_TOL_ABS = 40
32
+
33
+
34
+ def plausible_length(source: str, output: str) -> bool:
35
+ if not source:
36
+ return True
37
+ return abs(len(output) - len(source)) <= max(LENGTH_TOL_ABS, LENGTH_TOL_REL * len(source))
38
 
39
 
40
  @register_step("llm")
 
66
  # Split into bulks if needed
67
  # For include_original/user_template steps, skip splitting
68
  if include_original or user_template:
69
+ plan = ChunkPlan(chunks=[text], seps=[""], kinds=[], lead="")
70
  else:
71
+ plan = plan_chunks(
72
+ text, self.env.text_split_size, strategy=self.env.text_split_strategy
73
+ )
74
+ bulks = plan.chunks
75
  processed_bulks = []
76
  logger.debug(f" [{ts}] {len(bulks)} bulk(s), text_len={len(text)}")
77
+ if plan.is_split:
78
+ # 절단면 진단용 -- 어디를 어떤 경계로 잘랐는지 (insight 260910 의 GLUED/LOST 분석이 읽는다)
79
+ chunk_plans = sample.artifacts.setdefault("chunk_plans", {})
80
+ chunk_plans[step_name] = {
81
+ "lens": [len(b) for b in bulks],
82
+ "kinds": list(plan.kinds),
83
+ }
84
 
85
  for bulk_i, bulk in enumerate(bulks):
86
+ if ctx.on_step and len(bulks) > 1:
87
+ ctx.on_step("chunk", bulk_i + 1, len(bulks), step_name, ctx.doc_chunk)
88
  if step_repeat > 1:
89
  (
90
  final_text,
 
146
  )
147
  ts3 = datetime.now().strftime("%H:%M:%S")
148
  raw_len = len(result.get("content", ""))
149
+ output = normalize_separator_literals(
150
+ self._extract_output(result["content"]).rstrip(), source=bulk
151
+ )
152
 
153
+ # 길이 가드: JSON 파싱 실패(설명이 통째로 output 이 됨) · 환각 · bulk 삭제를 한 규칙으로 잡는다
154
+ if not plausible_length(bulk, output):
 
155
  raw_content = result.get("content", "")
156
  logger.warning(
157
+ f" [{ts3}] [{step_name}] bulk {bulk_i + 1} output length implausible "
158
+ f"(in={len(bulk)}, out={len(output)}), attempting fallback"
159
+ )
160
+ guard_log = sample.artifacts.setdefault("length_guard", [])
161
+ guard_log.append(
162
+ {"step": step_name, "bulk": bulk_i + 1, "in": len(bulk), "out": len(output)}
163
  )
164
 
165
  # Try fallback LLM call if configured
 
187
  reasoning_effort=step_re,
188
  response_format=fallback_rf,
189
  )
190
+ fallback_output = normalize_separator_literals(
191
+ self._extract_output(fallback_result["content"]).rstrip(), source=bulk
192
+ )
193
  ts4 = datetime.now().strftime("%H:%M:%S")
194
 
195
+ if plausible_length(bulk, fallback_output):
196
  output = fallback_output
197
  logger.info(
198
  f" [{ts4}] [{step_name}] fallback succeeded, len={len(output)}"
 
201
  else:
202
  output = bulk
203
  logger.warning(
204
+ f" [{ts4}] [{step_name}] fallback length also implausible, using input text"
205
  )
206
  else:
207
  output = bulk
 
224
  step_corrs = sample.artifacts.setdefault("corrections", {})
225
  step_corrs.setdefault(step_name, []).extend(step_corrections)
226
 
227
+ # 구분자 원장으로 접합 -- 절단면의 공백/개행을 그대로 복원한다. 분할·접합 규칙은 chunking.py.
228
+ ctx.text = join_chunks(processed_bulks, plan)
229
 
230
  # ── helpers (multi_step.py 에서 그대로 이동) ─────────────────────────
231
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
  def _extract_output(self, content: str) -> str:
233
  """Extract output from LLM response. Tries JSON parsing first.
234