Spaces:
Running
Running
deploy: v35 document-level chunking (DP split + parallel chunks), progress view, Supabase logbook
Browse files- README.md +1 -1
- app.py +115 -13
- assets/pipelines/pipeline_dev_v35.yaml +5 -0
- logbook.py +96 -0
- runner.py +32 -7
- solar_eval/pipelines/pipeline.py +117 -20
- solar_eval/pipelines/registry.py +3 -0
- solar_eval/pipelines/steps/chunking.py +294 -0
- solar_eval/pipelines/steps/llm.py +49 -38
README.md
CHANGED
|
@@ -10,7 +10,7 @@ pinned: false
|
|
| 10 |
|
| 11 |
# 조선일보 본문 교열 데모
|
| 12 |
|
| 13 |
-
Upstage Solar 기반 7+2-스텝 교열 파이프라인(`
|
| 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 |
-
|
| 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 |
-
|
| 23 |
-
|
| 24 |
-
"
|
| 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 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"""
|
| 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 =
|
|
|
|
|
|
|
| 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 |
-
"""
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 94 |
-
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
|
| 152 |
-
sample.output =
|
| 153 |
-
sample.artifacts["
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 55 |
else:
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
| 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 =
|
|
|
|
|
|
|
| 123 |
|
| 124 |
-
# JSON 파싱 실패
|
| 125 |
-
|
| 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
|
| 130 |
-
f"({len(
|
| 131 |
-
|
|
|
|
|
|
|
|
|
|
| 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 =
|
|
|
|
|
|
|
| 160 |
ts4 = datetime.now().strftime("%H:%M:%S")
|
| 161 |
|
| 162 |
-
if
|
| 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
|
| 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 |
-
|
|
|
|
| 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 |
|