dev-strender's picture
deploy: v35 document-level chunking (DP split + parallel chunks), progress view, Supabase logbook
8debb9c verified
Raw
History Blame Contribute Delete
7.98 kB
"""조선일보 본문 교열 데모 (Streamlit / HF Space).
v35 파이프라인(prod 7-스텝 + 화이트리스트 복원 + 율/률 규칙 + 문서 단위 chunk) × combo 프롬프트를
solar-eval 엔진 그대로 실행한다. 로직은 전부 runner.py — 이 파일은 UI 만 담당.
사용자 입력과 결과는 logbook.py 가 Supabase 에 남긴다 (시크릿 없으면 조용히 건너뜀).
"""
from __future__ import annotations
import difflib
import html
import os
import logbook
import runner
import streamlit as st
PANE_STYLE = (
"border:1px solid rgba(128,128,128,.35);border-radius:8px;"
"padding:14px 16px;line-height:1.9;font-size:1.02rem;min-height:120px"
)
SAMPLE_TEXT = (
"정부의 강력한 부동산 규제에도 불구하고 수도권 아파트 매매 거래 비률은 "
"오히려 증가세를 보였다. 시중 은행의 주택 담보 대출 금리가 낮아지면서 "
"실수요자의 구매 심리가 되살아났기 때문이라는 분석이 나온다."
)
# 스텝 이름 → 사용자에게 보여줄 라벨 (pipeline_dev_v34 기준. 모르는 스텝은 이름 그대로)
STEP_LABELS = {
"vocabulary_sub_pre": "용어 사전 치환",
"proofread": "1차 교열 (교열 전용 모델)",
"basic_correction": "기본 교정 (자기일관성 검증)",
"context_correction": "문맥 교정",
"style_correction": "문체 교정",
"vocabulary_reapply": "용어 사전 재적용",
"compound_whitelist_restore": "복합어 화이트리스트 복원",
"yul_ryul_normalize": "율/률 규칙 적용",
"post_process": "후처리",
}
def diff_panes(src: str, dst: str) -> tuple[str, str, int]:
"""어절 단위 diff 를 좌(원문)/우(교정) 패널 HTML 로. 바뀐 세그먼트 수도 반환."""
a, b = src.split(), dst.split()
sm = difflib.SequenceMatcher(a=a, b=b, autojunk=False)
left: list[str] = []
right: list[str] = []
n_changed = 0
for op, i1, i2, j1, j2 in sm.get_opcodes():
before, after = html.escape(" ".join(a[i1:i2])), html.escape(" ".join(b[j1:j2]))
if op == "equal":
left.append(before)
right.append(after)
continue
n_changed += 1
if before:
left.append(f'<span style="background:rgba(230,80,80,.20)">{before}</span>')
if after:
right.append(f'<span style="background:rgba(60,170,90,.24)">{after}</span>')
return " ".join(left), " ".join(right), n_changed
class ProgressView:
"""스텝 진행을 st.status 안에 그린다 — 단계별 출력은 보여주지 않고 '어디까지 왔나' 만.
문서 단위 chunk 모드에서는 chunk 들이 **병렬**로 돌아 이벤트가 섞여 들어오므로, chunk 마다 줄을 하나씩
두고(몇 단계까지 왔나) 전체 진행 바는 (끝난 단계 수) / (chunk 수 × 단계 수) 로 잡는다.
"""
def __init__(self, names: list[str]) -> None:
self.names = names
self.total_steps = len(names)
self.n_chunks = 1
self.done: dict[int, int] = {1: 0} # chunk 번호 → 끝난 단계 수
self.current: dict[int, str] = {} # chunk 번호 → 진행 중 단계 라벨
self.status = st.status("교열 중입니다…", expanded=True)
self.bar = self.status.progress(0.0, text=f"0 / {self.total_steps} 단계")
self.lines = self.status.empty()
self._render()
def on_step(self, event: str, index: int, total: int, name: str, doc_chunk=None) -> None:
ci = doc_chunk[0] if doc_chunk else 1
if doc_chunk:
self.n_chunks = doc_chunk[1]
self.done.setdefault(ci, 0)
label = STEP_LABELS.get(name, name)
if event == "start":
self.current[ci] = label
elif event == "chunk":
self.current[ci] = f"{label} · 조각 {index}/{total}"
elif event == "done":
self.done[ci] = index
self.current.pop(ci, None)
finished = sum(self.done.values())
grand = self.n_chunks * self.total_steps
unit = "단계" if self.n_chunks == 1 else f"단계 (문서 조각 {self.n_chunks}개 병렬)"
self.bar.progress(min(finished / grand, 1.0), text=f"{finished} / {grand} {unit}")
self._render()
def _render(self) -> None:
if self.n_chunks == 1:
done = self.done.get(1, 0)
rows = []
for i, name in enumerate(self.names, 1):
label = STEP_LABELS.get(name, name)
if i <= done:
rows.append(f"✅ {label}")
elif i == done + 1:
rows.append(f"⏳ **{self.current.get(1, label)}**")
else:
rows.append(f"▫️ {label}")
else:
rows = []
for ci in range(1, self.n_chunks + 1):
done = self.done.get(ci, 0)
if done >= self.total_steps:
rows.append(f"✅ 조각 {ci}: 완료")
elif ci in self.current:
rows.append(f"⏳ 조각 {ci}: **{self.current[ci]}** ({done}/{self.total_steps})")
else:
rows.append(f"▫️ 조각 {ci}: 대기")
self.lines.markdown(" \n".join(rows))
def finish(self, elapsed_s: float) -> None:
self.status.update(label=f"교열 완료 · {elapsed_s:.1f}초", state="complete", expanded=False)
def fail(self) -> None:
self.status.update(label="교열 실패", state="error", expanded=True)
@st.cache_resource
def get_pipeline():
return runner.build_pipeline()
st.set_page_config(page_title="조선일보 본문 교열 데모", page_icon="📰", layout="wide")
st.title("📰 조선일보 본문 교열 데모")
with st.sidebar:
st.subheader("설정")
if not os.environ.get("UPSTAGE_API_KEY"):
key = st.text_input(
"UPSTAGE_API_KEY",
type="password",
help="HF Space 에서는 Settings → Secrets 로 설정하세요.",
)
if key:
os.environ["UPSTAGE_API_KEY"] = key
model = st.selectbox("모델 (step1–3 주입)", ["solar-pro4", "solar-pro2"], index=0)
text = st.text_area("기사 본문", value=SAMPLE_TEXT, height=320, max_chars=4000)
if st.button("교열 실행", type="primary", disabled=not text.strip()):
if not os.environ.get("UPSTAGE_API_KEY"):
st.error(
"UPSTAGE_API_KEY 가 필요합니다 — 사이드바에 입력하거나 Space Secrets 에 설정하세요."
)
st.stop()
source = text.strip()
pipeline = get_pipeline()
# 입력은 실행 전에 남긴다 — 실행이 실패해도 무엇이 들어왔는지는 알 수 있게
article_id = logbook.save_article(source)
progress = ProgressView(runner.step_names(pipeline))
try:
result = runner.run_proofread(pipeline, source, model=model, on_step=progress.on_step)
except Exception as e: # noqa: BLE001 — 데모 표면에서는 원인 요약만 보여준다
progress.fail()
st.error(f"실행 실패: {type(e).__name__}: {e}")
st.stop()
progress.finish(result["elapsed_s"])
logbook.save_run(
article_id,
pipeline_key=result["pipeline_key"],
prompt_key=result["prompt_key"],
model=result["model"],
output=result["output"],
processing_time_s=result["elapsed_s"],
)
if logbook.is_configured() and logbook.last_error():
st.caption(f"기록 실패 (결과에는 영향 없음): {logbook.last_error()}")
left, right, _n_changed = diff_panes(source, result["output"])
c1, c2 = st.columns(2, gap="medium")
with c1:
st.subheader("원문")
st.markdown(f'<div style="{PANE_STYLE}">{left}</div>', unsafe_allow_html=True)
with c2:
st.subheader("교열 후")
st.markdown(f'<div style="{PANE_STYLE}">{right}</div>', unsafe_allow_html=True)