proofread-demo / column_view.py
dev-strender's picture
feat: 3 example articles; neutral review-step wording
3634984 verified
Raw History Blame Contribute Delete
15.2 kB
"""ꡐ열 μ§„ν–‰Β·κ²°κ³Ό ν•œ 열을 κ·Έλ¦¬λŠ” ν™”λ©΄ 쑰각 (Streamlit) - 둜컬 3μ’… 비ꡐ μ•±(`proofread-demo/compare_app.py`)κ³Ό 같은 λͺ¨μ–‘.
κΈ°λ‹€λ¦¬λŠ” λ™μ•ˆ "λ’€μ—μ„œ 무엇이 μ§„ν–‰λ˜λŠ”μ§€"κ°€ 보이도둝:
- μš”μ•½ 3μΉΈ(μ†Œμš” Β· 적용 Β· 확인 ν•„μš”)κ³Ό 단계 μŠ€ν…ŒνΌ(λλ‚œ 단계 βœ“ μ†Œμš” μ‹œκ°„ Β· μ§„ν–‰ 쀑 ● κ²½κ³Ό/보톡 N초 Β· λŒ€κΈ° β—‹)
- 단계가 끝날 λ•Œλ§ˆλ‹€ λ³Έλ¬Έ ν•˜μ΄λΌμ΄νŠΈΒ·μˆ˜μ • μΉ΄λ“œκ°€ κ·Έ μžλ¦¬μ—μ„œ μŒ“μΈλ‹€
- μΉ΄λ“œΒ·ν•˜μ΄λΌμ΄νŠΈλŠ” κ²°κ³Ό 본문에 남은 λ³€κ²½λ§Œ 보인닀(FT κ°€ 고치고 기사 κ²€ν† κ°€ μ›λ¬ΈμœΌλ‘œ 되돌린 것은 λ‘˜ λ‹€ λΊ€λ‹€)
이벀트 λͺ¨μ–‘은 `runner.start_proofread` λ₯Ό λ”°λ₯Έλ‹€.
"""
from __future__ import annotations
import difflib
import html
import time
from typing import Any
import streamlit as st
# 화면에 보일 단계. (ν‘œμ‹œ 이름, κ·Έ 단계가 끝났닀고 λ³΄λŠ” μŠ€ν… 이름, 보톡 κ±Έλ¦¬λŠ” 초)
STAGES = [
("ν‘œκΈ° 사전 적용", "vocabulary_sub_pre", 0.1),
("ꡐ열 λͺ¨λΈ(FT) 문단별", "proofread", 1),
("기사 전체 κ²€ν†  (1회)", "review", 7),
("FT ꡐ정 확인", "revert_ft", 0.1),
("μ‚¬μ‹€Β·μˆ«μžΒ·λ¬Έλ§₯ ꡐ정 적용", "apply_review", 0.1),
("마무리 κ·œμΉ™", "post_process", 0.1),
]
# ꡐ정 λͺ©λ‘μ„ μ μš©ν•˜λŠ” μŠ€ν…: (λͺ©λ‘μ„ λ‚Έ μŠ€ν…, 숫자 κ°€λ“œ, μ‚­μ œ κ°€λ“œ λΉ„μœ¨) - μΉ΄λ“œ μƒνƒœλ₯Ό 적용 μŠ€ν…κ³Ό 같은 κ°€λ“œλ‘œ κ³„μ‚°ν•œλ‹€
LIST_CARDS = {"apply_review": ("review", False, 0.6)}
# FT λ³€κ²½ 되돌림: (되돌림 μŠ€ν…, λ³€κ²½ λͺ©λ‘ μŠ€ν…, νŒμ • μŠ€ν…)
REVERTS = ("revert_ft", "ft_hunks", "review")
STAGE_COLORS = [
"rgba(120,120,120,.25)",
"rgba(70,130,220,.28)",
"rgba(60,170,90,.28)",
"rgba(240,150,40,.32)",
"rgba(170,90,200,.28)",
"rgba(120,120,120,.25)",
]
CAT_KO = {
"spelling": "λ§žμΆ€λ²•",
"spacing": "λ„μ–΄μ“°κΈ°Β·μ‚¬μ΄μ‹œμ˜·",
"grammar": "μ‘°μ‚¬Β·ν˜Έμ‘",
"context": "λ¬Έλ§₯ 낱말",
"omission": "λˆ„λ½",
"in-article": "기사 μ•ˆ 뢈일치",
"inconsistency": "기사 μ•ˆ 뢈일치",
"fact": "사싀 였기",
"calc": "숫자 κ²€μ‚°",
"word": "λ¬Έλ§₯ 낱말",
"unit": "λ‹¨μœ„Β·μˆ˜μΉ˜ λͺ¨μˆœ",
}
CSS = """
<style>
.pane{border:1px solid rgba(128,128,128,.35);border-radius:8px;padding:12px 14px;line-height:1.95;font-size:.98rem;min-height:120px}
.shimmer{height:4px;border-radius:2px;margin:6px 0 10px;background:linear-gradient(90deg,rgba(128,128,128,.15) 25%,rgba(128,128,128,.45) 50%,rgba(128,128,128,.15) 75%);background-size:200% 100%;animation:sh 1.2s linear infinite}
@keyframes sh{0%{background-position:200% 0}100%{background-position:-200% 0}}
.stage{font-size:.9rem;margin:2px 0}
.card{border:1px solid rgba(128,128,128,.3);border-radius:6px;padding:6px 10px;margin-bottom:6px;font-size:.9rem}
.muted{color:gray;font-size:.82rem}
.stats{display:flex;gap:8px;margin:4px 0 8px}
.stat{flex:1;border:1px solid rgba(128,128,128,.3);border-radius:6px;padding:6px 8px;text-align:center}
.stat .k{font-size:.72rem;color:gray}.stat .v{font-size:1.05rem;font-weight:700}
.status{min-height:30px;margin-bottom:6px}
.chip{display:inline-block;font-size:.75rem;padding:1px 8px;border-radius:10px;margin-left:6px;font-weight:600}
.ok{background:rgba(60,170,90,.22);color:#1e7d3a}
.chk{background:rgba(240,170,40,.28);color:#8a5a00}
.rej{background:rgba(128,128,128,.22);color:#555}
</style>
"""
NL = "\n" # μ€„λ°”κΏˆμ„ μ–΄μ ˆ ν•˜λ‚˜λ‘œ 닀뀄 diffΒ·ν•˜μ΄λΌμ΄νŠΈκ°€ μž…λ ₯의 쀄 ꡬ쑰λ₯Ό λ³΄μ‘΄ν•˜κ²Œ ν•œλ‹€
def tokenize(text: str) -> list[str]:
"""μ–΄μ ˆ + μ€„λ°”κΏˆ 토큰. 빈 쀄은 NL 토큰이 μ—°μ†μœΌλ‘œ 남아 문단 간격이 μœ μ§€λœλ‹€."""
out: list[str] = []
for line in text.split("\n"):
out.extend(line.split())
out.append(NL)
return out[:-1]
def _norm(s: str) -> str:
return " ".join(s.split())
# ───────────────────────── μƒνƒœ ─────────────────────────
def new_state(original: str) -> dict[str, Any]:
return {
"original": original,
"stage_i": 0,
"stage_done_at": {},
"tokens": None,
"last_text": None,
"cards": [],
"log": "λŒ€κΈ° 쀑",
"done": None,
"error": None,
"started": time.monotonic(),
}
def _label_tokens(state: dict, new_text: str, stage_i: int) -> None:
"""원문 μ–΄μ ˆμ— 'μ–΄λŠ λ‹¨κ³„μ—μ„œ λ°”λ€Œμ—ˆλ‚˜' 라벨을 λˆ„μ ν•œλ‹€. 원문과 κ°™μ•„μ§„ μ–΄μ ˆμ€ 라벨을 μ§€μš΄λ‹€."""
if state["tokens"] is None:
state["tokens"] = [(t, -1) for t in tokenize(state["last_text"])]
old = [t for t, _ in state["tokens"]]
new = tokenize(new_text)
sm = difflib.SequenceMatcher(a=old, b=new, autojunk=False)
out: list[tuple[str, int]] = []
for op, i1, i2, j1, j2 in sm.get_opcodes():
out.extend(state["tokens"][i1:i2] if op == "equal" else ((t, stage_i) for t in new[j1:j2]))
orig = tokenize(state["original"])
net = difflib.SequenceMatcher(a=orig, b=[t for t, _ in out], autojunk=False)
for op, _i1, _i2, j1, j2 in net.get_opcodes():
if op == "equal":
out[j1:j2] = [(t, -1) for t, _ in out[j1:j2]]
state["tokens"] = out
state["last_text"] = new_text
def _item_status(
item: dict, text_before: str, protect_digits: bool, max_shrink: float | None
) -> str:
from solar_eval.pipelines.corrections import apply_corrections
_, rep = apply_corrections(
text_before, [item], max_items=1, protect_digits=protect_digits, max_shrink=max_shrink
)
if rep.applied:
return "적용"
if rep.skipped_protected:
return "κ±°λΆ€ (μˆ«μžΒ·μ‚­μ œ κ°€λ“œ)"
if rep.skipped_ambiguous or rep.skipped_not_found:
return "확인 ν•„μš”"
return "무효"
def _diff_card(stage: int, before: str, after: str) -> dict:
spacing = "".join(before.split()) == "".join(after.split())
return {
"stage": stage,
"before": before,
"after": after,
"cat": "띄어쓰기" if spacing else "",
"status": "적용",
"evidence": "",
}
def _add_card(state: dict, card: dict) -> None:
"""μΉ΄λ“œλ₯Ό λ”ν•œλ‹€. μ•ž λ‹¨κ³„μ˜ 적용 μΉ΄λ“œλ₯Ό κ·ΈλŒ€λ‘œ λ˜λŒλ¦¬λŠ” 변경이면 λ‘˜ λ‹€ μ§€μš΄λ‹€(결과적으둜 바뀐 것이 μ—†λ‹€)."""
if card["status"] == "적용":
for i, c in enumerate(state["cards"]):
if (
c["status"] == "적용"
and _norm(c["before"]) == _norm(card["after"])
and _norm(c["after"]) == _norm(card["before"])
):
del state["cards"][i]
return
state["cards"].append(card)
def _drop_reverted(state: dict, ev: dict) -> None:
"""기사 κ²€ν† κ°€ μ›λ¬ΈμœΌλ‘œ 되돌린 FT 변경은 μΉ΄λ“œμ—μ„œ μ§€μš΄λ‹€."""
_step, hunks_from, judge_from = REVERTS
decision = ev["json"].get(judge_from) or {}
by_id = {int(h["id"]): h for h in ev["hunks"].get(hunks_from) or []}
for raw in decision.get("revert") or []:
h = by_id.get(int(raw)) if str(raw).isdigit() else None
if h is None:
continue
pair = (_norm(h["before"]), _norm(h["after"]))
state["cards"] = [
c
for c in state["cards"]
if not (c["status"] == "적용" and (_norm(c["before"]), _norm(c["after"])) == pair)
]
def absorb(state: dict, ev: dict) -> None:
"""이벀트 ν•˜λ‚˜λ₯Ό μƒνƒœμ— λ°˜μ˜ν•œλ‹€. 단계 μ™„λ£Œ νŒμ •Β·μΉ΄λ“œΒ·ν•˜μ΄λΌμ΄νŠΈΒ·λ‘œκ·Έ."""
if ev["kind"] == "error":
state["error"] = ev["error"]
state["log"] = "μ‹€νŒ¨"
return
if ev["kind"] == "done":
state["done"] = ev
state["log"] = f"μ™„λ£Œ - {ev['elapsed_s']:.1f}초"
return
step, text = ev["step"], ev["text"]
stage_steps = [s[1] for s in STAGES]
si = state["stage_i"] # 이 μŠ€ν…μ€ λ‹€μŒ λ―Έμ™„ 단계에 μ†ν•œλ‹€
if state["last_text"] is None:
state["last_text"] = state["original"]
pre_text = state["last_text"]
list_card = LIST_CARDS.get(step)
reverted = False
if list_card and list_card[0] in ev["corrections"]:
corr_key, protect_digits, max_shrink = list_card
for it in ev["corrections"][corr_key]:
cat = str(it.get("category", ""))
_add_card(
state,
{
"stage": si,
"before": str(it.get("original", "")),
"after": str(it.get("corrected", "")),
"cat": CAT_KO.get(cat, cat),
"status": _item_status(it, pre_text, protect_digits, max_shrink),
"evidence": str(it.get("evidence", "")),
},
)
elif step == REVERTS[0]:
_drop_reverted(state, ev)
reverted = True
elif ev["changes"] and not list_card:
for b, a in ev["changes"]:
_add_card(state, _diff_card(si, b, a))
if text != pre_text or state["tokens"] is None:
_label_tokens(state, text, si)
if step in stage_steps:
idx = stage_steps.index(step)
state["stage_done_at"][idx] = ev["elapsed_s"]
state["stage_i"] = idx + 1
n = sum(c["stage"] == idx for c in state["cards"])
n_chk = sum(c["stage"] == idx and c["status"] == "확인 ν•„μš”" for c in state["cards"])
extra = (
(f" - μ œμ•ˆ {n}건" + (f" (확인 ν•„μš” {n_chk})" if n_chk else ""))
if n
else " - κ³ μΉ  κ³³ μ—†μŒ"
)
if reverted:
extra = "" # 확인 λ‹¨κ³„λŠ” μ§€μš΄ μΉ΄λ“œ 수λ₯Ό λ‚΄μ„Έμš°μ§€ μ•ŠλŠ”λ‹€
state["log"] = f"방금: {STAGES[idx][0]} μ™„λ£Œ{extra}"
# ───────────────────────── λ Œλ” ─────────────────────────
def _render_text(state: dict) -> str:
toks = state["tokens"] or [(t, -1) for t in tokenize(state["original"])]
parts: list[str] = []
run: list[str] = []
cur = -1
def flush() -> None:
if run:
seg = html.escape(" ".join(run))
parts.append(
seg
if cur < 0
else f'<span style="background:{STAGE_COLORS[cur % len(STAGE_COLORS)]};border-radius:3px">{seg}</span>'
)
run.clear()
for t, s in toks + [("", -2)]:
if t == NL: # μž…λ ₯의 μ€„λ°”κΏˆμ„ κ·ΈλŒ€λ‘œ - 빈 쀄은 <br> 두 번
flush()
parts.append("<br>")
continue
if s != cur:
flush()
cur = s
if t:
run.append(t)
return (
" ".join(parts).replace(" <br> ", "<br>").replace("<br> ", "<br>").replace(" <br>", "<br>")
)
def _cards_html(state: dict) -> str:
cards = [c for c in state["cards"] if _norm(c["before"]) != _norm(c["after"])]
if not cards:
return ""
parts = []
for c in cards:
color = STAGE_COLORS[c["stage"] % len(STAGE_COLORS)]
if c["status"] == "적용":
cls, label = "ok", "βœ“ 적용"
elif c["status"] == "확인 ν•„μš”":
cls, label = "chk", "확인 ν•„μš”"
else:
cls, label = "rej", c["status"]
ev_html = (
f"<div class='muted'>κ·Όκ±°: {html.escape(c['evidence'])}</div>" if c["evidence"] else ""
)
parts.append(
f"<div class='card' style='border-left:5px solid {color}'><span class='muted'>"
f"{STAGES[c['stage']][0]}{' Β· ' + c['cat'] if c['cat'] else ''}</span>"
f"<span class='chip {cls}'>{label}</span><br>"
f"<s>{html.escape(c['before'])}</s> β†’ <b>{html.escape(c['after'])}</b>{ev_html}</div>"
)
return f"<div class='muted' style='margin-top:6px'>μ œμ•ˆ {len(cards)}건</div>" + "".join(parts)
def render(ph: Any, state: dict, now: float) -> None:
"""μ™Όμͺ½: μš”μ•½ 3μΉΈ Β· 단계 μŠ€ν…ŒνΌ Β· μƒνƒœ 쀄 / 였λ₯Έμͺ½: ν•˜μ΄λΌμ΄νŠΈ λ³Έλ¬Έ Β· μˆ˜μ • μΉ΄λ“œ."""
elapsed = now - state["started"]
expected = sum(s[2] for s in STAGES)
with ph.container():
left, right = st.columns([2, 3], gap="large")
with left:
n_ok = sum(c["status"] == "적용" for c in state["cards"])
n_chk = sum(c["status"] == "확인 ν•„μš”" for c in state["cards"])
if state["error"]:
took = "μ‹€νŒ¨"
elif state["done"]:
took = f"{state['done']['elapsed_s']:.1f}초"
else:
took = f"{elapsed:.0f}μ΄ˆβ€¦"
st.markdown(
"<div class='stats'>"
f"<div class='stat'><div class='k'>μ†Œμš”</div><div class='v'>{took}</div></div>"
f"<div class='stat'><div class='k'>적용</div><div class='v'>{n_ok}</div></div>"
f"<div class='stat'><div class='k'>확인 ν•„μš”</div><div class='v'>{n_chk}</div></div>"
"</div>",
unsafe_allow_html=True,
)
lines = []
for i, (name, _step, typical) in enumerate(STAGES):
prev = state["stage_done_at"].get(i - 1, 0.0)
if i in state["stage_done_at"]:
lines.append(
f"<div class='stage'>βœ“ {name} <span class='muted'>({state['stage_done_at'][i] - prev:.1f}초)</span></div>"
)
elif i == state["stage_i"] and not state["done"] and not state["error"]:
lines.append(
f"<div class='stage'>● <b>{name}</b> <span class='muted'>μ§„ν–‰ 쀑 {elapsed - prev:.0f}초 Β· 보톡 {typical:.0f}초</span></div>"
)
else:
lines.append(f"<div class='stage muted'>β—‹ {name}</div>")
st.markdown("".join(lines), unsafe_allow_html=True)
if state["error"]:
st.markdown(
f"<div class='status' style='color:#b00020'>μ‹€νŒ¨: {html.escape(state['error'])}</div>",
unsafe_allow_html=True,
)
elif state["done"]:
st.markdown(
f"<div class='status' style='color:#1e7d3a'>μ™„λ£Œ {state['done']['elapsed_s']:.1f}초</div>",
unsafe_allow_html=True,
)
else:
msg = state["log"]
if elapsed > expected * 1.5 + 5:
msg += f" Β· ν‰μ†Œ({expected:.0f}초)보닀 였래 κ±Έλ¦½λ‹ˆλ‹€ - API λŒ€κΈ° 쀑일 수 μžˆμŠ΅λ‹ˆλ‹€. κ²°κ³ΌλŠ” κ·ΈλŒ€λ‘œ λ„μ°©ν•©λ‹ˆλ‹€."
st.markdown(
f"<div class='status muted'>{elapsed:.0f}초 경과 · {msg}<div class='shimmer'></div></div>",
unsafe_allow_html=True,
)
with right:
st.markdown(f"<div class='pane'>{_render_text(state)}</div>", unsafe_allow_html=True)
# μΉ΄λ“œλŠ” ν•œ 덩어리 HTML 둜 κ·Έλ¦°λ‹€ - μΉ΄λ“œλ§ˆλ‹€ st.markdown 을 λΆ€λ₯΄λ©΄ μΉ΄λ“œκ°€ μ€„μ—ˆμ„ λ•Œ μ§€λ‚œ λ Œλ”μ˜ μΉ΄λ“œκ°€ λ‚¨λŠ”λ‹€
st.markdown(_cards_html(state), unsafe_allow_html=True)