File size: 15,236 Bytes
66c1f5b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3634984
66c1f5b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
"""ꡐ열 μ§„ν–‰Β·κ²°κ³Ό ν•œ 열을 κ·Έλ¦¬λŠ” ν™”λ©΄ 쑰각 (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)