dev-strender commited on
Commit
66c1f5b
ยท
verified ยท
1 Parent(s): 3dfc93b

feat: progress stepper + highlighted result + correction cards (local compare view)

Browse files
__pycache__/column_view.cpython-312.pyc ADDED
Binary file (19.3 kB). View file
 
__pycache__/runner.cpython-312.pyc CHANGED
Binary files a/__pycache__/runner.cpython-312.pyc and b/__pycache__/runner.cpython-312.pyc differ
 
app.py CHANGED
@@ -2,27 +2,23 @@
2
 
3
  FT ๊ต์—ด + Opus ๊ธฐ์‚ฌ ๊ฒ€ํ†  ๊ตฌ์„ฑ์„ solar-eval ์—”์ง„ ๊ทธ๋Œ€๋กœ ์‹คํ–‰ํ•œ๋‹ค - FT ๊ต์—ด ๋ชจ๋ธ์ด ๋ฌธ๋‹จ ๋ฌถ์Œ๋งˆ๋‹ค 1์ฐจ ๊ต์—ดํ•˜๊ณ ,
4
  Claude Opus ๊ฐ€ ๊ธฐ์‚ฌ ์ „์ฒด๋ฅผ ํ•œ ๋ฒˆ ๋ณด๊ณ  FT ์˜ค๊ต์ •์„ ๋˜๋Œ๋ฆฌ๋ฉฐ ์‚ฌ์‹คยท์ˆซ์žยท๋ฌธ๋งฅ ์˜ค๋ฅ˜๋ฅผ ๊ณ ์นœ ๋’ค ๊ฒฐ์ •๋ก  ๊ทœ์น™์„ ๊ฑด๋‹ค.
5
- ๊ตฌ์„ฑ ์ •์˜์™€ ์‹คํ–‰์€ ์ „๋ถ€ runner.py ์ด๊ณ  ์ด ํŒŒ์ผ์€ UI ๋งŒ ๋‹ด๋‹นํ•œ๋‹ค. ์‚ฌ์šฉ์ž ์ž…๋ ฅ๊ณผ ๊ฒฐ๊ณผ๋Š” logbook.py ๊ฐ€
6
- Supabase ์— ๋‚จ๊ธด๋‹ค(์‹œํฌ๋ฆฟ์ด ์—†์œผ๋ฉด ์กฐ์šฉํžˆ ๊ฑด๋„ˆ๋œ€).
7
  """
8
 
9
  from __future__ import annotations
10
 
11
- import difflib
12
- import html
13
  import os
 
 
14
 
 
15
  import logbook
16
  import runner
17
  import streamlit as st
18
 
19
- PANE_STYLE = (
20
- "border:1px solid rgba(128,128,128,.35);border-radius:8px;"
21
- "padding:14px 16px;line-height:1.9;font-size:1.02rem;min-height:120px"
22
- )
23
-
24
  # ํ•ฉ์„ฑ ์˜ˆ๋ฌธ - ํ‰๊ฐ€์…‹ gold ๋Š” ์“ฐ์ง€ ์•Š๋Š”๋‹ค(์œ ์ถœ ๋ฐฉ์ง€). ๊ฐ ์ธต์˜ ํ‘œ์ ์„ ์ผ๋ถ€๋Ÿฌ ์‹ฌ์–ด ๋‘” ๋ฌธ์žฅ์ด๋‹ค:
25
- # ๋น„๋ฅ /๊ฐฑ์‹ ๋ฅ /์—ฐ์ฒด๋ฅ (์‚ฌ์ „ยท์œจ๋ฅ  ๊ทœ์น™) ยท ์˜๋ฌธํ˜• ์–ด๋ฏธ ยท 160ใŽž์œผ๋กœ(์กฐ์‚ฌ) ยท ๊ณง์€ ๋”ฐ์˜ดํ‘œ(์›๋ฌธ ๋”ฐ์˜ดํ‘œ ๋ณด์กด).
26
  SAMPLE_TEXT = (
27
  "โ–ถ์ •๋ถ€์˜ ๊ฐ•๋ ฅํ•œ ๋ถ€๋™์‚ฐ ๊ทœ์ œ์—๋„ ๋ถˆ๊ตฌํ•˜๊ณ  ์ˆ˜๋„๊ถŒ ์•„ํŒŒํŠธ ๋งค๋งค ๊ฑฐ๋ž˜ ๋น„๋ฅ ์€ "
28
  "์˜คํžˆ๋ ค ์ฆ๊ฐ€์„ธ๋ฅผ ๋ณด์˜€๋‹ค. ์‹œ์ค‘ ์€ํ–‰์˜ ์ฃผํƒ ๋‹ด๋ณด ๋Œ€์ถœ ๊ธˆ๋ฆฌ๊ฐ€ ๋‚ฎ์•„์ง€๋ฉด์„œ "
@@ -32,77 +28,6 @@ SAMPLE_TEXT = (
32
  "์ฃผ๋ชฉํ•˜๊ณ  ์žˆ๋‹ค. ์‹ ๊ทœ ๊ด‘์—ญ์ฒ ๋„๋Š” ์ตœ๊ณ  ์‹œ์† 160ใŽž์œผ๋กœ ์„ค๊ณ„๋๋‹ค."
33
  )
34
 
35
- # ์Šคํ… ์ด๋ฆ„ โ†’ ์‚ฌ์šฉ์ž์—๊ฒŒ ๋ณด์—ฌ์ค„ ๋ผ๋ฒจ (๋ชจ๋ฅด๋Š” ์Šคํ…์€ ์ด๋ฆ„ ๊ทธ๋Œ€๋กœ)
36
- STEP_LABELS = {
37
- "vocabulary_sub_pre": "์šฉ์–ด ์‚ฌ์ „ ์น˜ํ™˜",
38
- "proofread": "1์ฐจ ๊ต์—ด (๊ต์—ด ์ „์šฉ FT ๋ชจ๋ธ, ๋ฌธ๋‹จ๋ณ„)",
39
- "ft_hunks": "FT ์ˆ˜์ • ๋ชฉ๋ก ์ •๋ฆฌ",
40
- "review": "๊ธฐ์‚ฌ ์ „์ฒด ๊ฒ€ํ†  (Claude Opus 1ํšŒ)",
41
- "revert_ft": "FT ์˜ค๊ต์ • ๋˜๋Œ๋ฆผ",
42
- "apply_review": "์‚ฌ์‹คยท์ˆซ์žยท๋ฌธ๋งฅ ๊ต์ • ์ ์šฉ",
43
- "quote_direction": "๋”ฐ์˜ดํ‘œ ๋ฐฉํ–ฅ ์ •๋ฆฌ",
44
- "quote_preserve": "์›๋ฌธ ๋”ฐ์˜ดํ‘œ ๋ณด์กด",
45
- "yul_ryul_normalize": "์œจ/๋ฅ  ๊ทœ์น™ ์ ์šฉ",
46
- "post_process": "ํ›„์ฒ˜๋ฆฌ (๋‹จ์œ„ยทํ†ตํ™”ยท์‰ผํ‘œ)",
47
- }
48
-
49
-
50
- def diff_panes(src: str, dst: str) -> tuple[str, str, int]:
51
- """์–ด์ ˆ ๋‹จ์œ„ diff ๋ฅผ ์ขŒ(์›๋ฌธ)/์šฐ(๊ต์ •) ํŒจ๋„ HTML ๋กœ. ๋ฐ”๋€ ์„ธ๊ทธ๋จผํŠธ ์ˆ˜๋„ ๋ฐ˜ํ™˜."""
52
- a, b = src.split(), dst.split()
53
- sm = difflib.SequenceMatcher(a=a, b=b, autojunk=False)
54
- left: list[str] = []
55
- right: list[str] = []
56
- n_changed = 0
57
- for op, i1, i2, j1, j2 in sm.get_opcodes():
58
- before, after = html.escape(" ".join(a[i1:i2])), html.escape(" ".join(b[j1:j2]))
59
- if op == "equal":
60
- left.append(before)
61
- right.append(after)
62
- continue
63
- n_changed += 1
64
- if before:
65
- left.append(f'<span style="background:rgba(230,80,80,.20)">{before}</span>')
66
- if after:
67
- right.append(f'<span style="background:rgba(60,170,90,.24)">{after}</span>')
68
- return " ".join(left), " ".join(right), n_changed
69
-
70
-
71
- class ProgressView:
72
- """์Šคํ… ์ง„ํ–‰์„ st.status ์•ˆ์— ๊ทธ๋ฆฐ๋‹ค - ๋‹จ๊ณ„๋ณ„ ์ถœ๋ ฅ์€ ๋ณด์—ฌ์ฃผ์ง€ ์•Š๊ณ  '์–ด๋””๊นŒ์ง€ ์™”๋‚˜' ๋งŒ."""
73
-
74
- def __init__(self, names: list[str]) -> None:
75
- self.names = names
76
- self.done = 0
77
- self.status = st.status("๊ต์—ด ์ค‘์ž…๋‹ˆ๋‹คโ€ฆ", expanded=True)
78
- self.bar = self.status.progress(0.0, text=f"0 / {len(names)} ๋‹จ๊ณ„")
79
- self.lines = self.status.empty()
80
- self._render()
81
-
82
- def on_step(self, event: str, index: int, total: int, name: str) -> None:
83
- if event == "done":
84
- self.done = index
85
- self.bar.progress(min(self.done / total, 1.0), text=f"{self.done} / {total} ๋‹จ๊ณ„")
86
- self._render()
87
-
88
- def _render(self) -> None:
89
- rows = []
90
- for i, name in enumerate(self.names, 1):
91
- label = STEP_LABELS.get(name, name)
92
- if i <= self.done:
93
- rows.append(f"โœ… {label}")
94
- elif i == self.done + 1:
95
- rows.append(f"โณ **{label}**")
96
- else:
97
- rows.append(f"โ–ซ๏ธ {label}")
98
- self.lines.markdown(" \n".join(rows))
99
-
100
- def finish(self, elapsed_s: float) -> None:
101
- self.status.update(label=f"๊ต์—ด ์™„๋ฃŒ ยท {elapsed_s:.1f}์ดˆ", state="complete", expanded=False)
102
-
103
- def fail(self) -> None:
104
- self.status.update(label="๊ต์—ด ์‹คํŒจ", state="error", expanded=True)
105
-
106
 
107
  @st.cache_resource(show_spinner="ํŒŒ์ดํ”„๋ผ์ธ ์ค€๋น„ ์ค‘โ€ฆ")
108
  def get_pipeline():
@@ -110,8 +35,9 @@ def get_pipeline():
110
 
111
 
112
  st.set_page_config(page_title="์กฐ์„ ์ผ๋ณด ๋ณธ๋ฌธ ๊ต์—ด ๋ฐ๋ชจ", page_icon="๐Ÿ“ฐ", layout="wide")
 
113
  st.title("๐Ÿ“ฐ ์กฐ์„ ์ผ๋ณด ๋ณธ๋ฌธ ๊ต์—ด ๋ฐ๋ชจ")
114
- st.caption(f"{runner.CONFIG.label} ยท ํ‰๊ฐ€ ํ•˜๋„ค์Šค์™€ ๊ฐ™์€ ์—”์ง„ ์ฝ”๋“œ")
115
 
116
  with st.sidebar:
117
  st.subheader("์„ค์ •")
@@ -125,48 +51,60 @@ with st.sidebar:
125
  cfg = runner.CONFIG
126
  st.markdown(f"**{cfg.label}**")
127
  st.caption(cfg.note)
128
- st.markdown(f"- ํŒŒ์ดํ”„๋ผ์ธ `{cfg.pipeline}`\n- ํ”„๋กฌํ”„ํŠธ `{cfg.prompt}`")
129
 
130
- text = st.text_area("๊ธฐ์‚ฌ ๋ณธ๋ฌธ", value=SAMPLE_TEXT, height=320, max_chars=12000)
 
 
 
 
131
 
132
- if st.button("๊ต์—ด ์‹คํ–‰", type="primary", disabled=not text.strip()):
 
133
  missing = [k for k in runner.REQUIRED_KEYS if not os.environ.get(k)]
 
 
 
134
  if missing:
135
  st.error(
136
  f"{', '.join(missing)} ๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค - ์‚ฌ์ด๋“œ๋ฐ”์— ์ž…๋ ฅํ•˜๊ฑฐ๋‚˜ Space Secrets ์— ์„ค์ •ํ•˜์„ธ์š”."
137
  )
138
  st.stop()
139
-
140
- source = text.strip()
141
- pipeline = get_pipeline()
142
- # ์ž…๋ ฅ์€ ์‹คํ–‰ ์ „์— ๋‚จ๊ธด๋‹ค - ์‹คํ–‰์ด ์‹คํŒจํ•ด๋„ ๋ฌด์—‡์ด ๋“ค์–ด์™”๋Š”์ง€๋Š” ์•Œ ์ˆ˜ ์žˆ๊ฒŒ
143
- article_id = logbook.save_article(source)
144
-
145
- progress = ProgressView(runner.step_names(pipeline))
146
  try:
147
- result = runner.run_proofread(pipeline, source, on_step=progress.on_step)
148
- except Exception as e: # noqa: BLE001 - ๋ฐ๋ชจ ํ‘œ๋ฉด์—์„œ๋Š” ์›์ธ ์š”์•ฝ๋งŒ ๋ณด์—ฌ์ค€๋‹ค
149
- progress.fail()
150
- st.error(f"์‹คํ–‰ ์‹คํŒจ: {type(e).__name__}: {e}")
151
  st.stop()
152
- progress.finish(result["elapsed_s"])
153
-
154
- logbook.save_run(
155
- article_id,
156
- pipeline_key=result["pipeline_key"],
157
- prompt_key=result["prompt_key"],
158
- model=result["model"],
159
- output=result["output"],
160
- processing_time_s=result["elapsed_s"],
161
- )
162
- if logbook.is_configured() and logbook.last_error():
163
- st.caption(f"๊ธฐ๋ก ์‹คํŒจ (๊ฒฐ๊ณผ์—๋Š” ์˜ํ–ฅ ์—†์Œ): {logbook.last_error()}")
164
 
165
- left, right, _n_changed = diff_panes(source, result["output"])
166
- c1, c2 = st.columns(2, gap="medium")
167
- with c1:
168
- st.subheader("์›๋ฌธ")
169
- st.markdown(f'<div style="{PANE_STYLE}">{left}</div>', unsafe_allow_html=True)
170
- with c2:
171
- st.subheader("๊ต์—ด ํ›„")
172
- st.markdown(f'<div style="{PANE_STYLE}">{right}</div>', unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  FT ๊ต์—ด + Opus ๊ธฐ์‚ฌ ๊ฒ€ํ†  ๊ตฌ์„ฑ์„ solar-eval ์—”์ง„ ๊ทธ๋Œ€๋กœ ์‹คํ–‰ํ•œ๋‹ค - FT ๊ต์—ด ๋ชจ๋ธ์ด ๋ฌธ๋‹จ ๋ฌถ์Œ๋งˆ๋‹ค 1์ฐจ ๊ต์—ดํ•˜๊ณ ,
4
  Claude Opus ๊ฐ€ ๊ธฐ์‚ฌ ์ „์ฒด๋ฅผ ํ•œ ๋ฒˆ ๋ณด๊ณ  FT ์˜ค๊ต์ •์„ ๋˜๋Œ๋ฆฌ๋ฉฐ ์‚ฌ์‹คยท์ˆซ์žยท๋ฌธ๋งฅ ์˜ค๋ฅ˜๋ฅผ ๊ณ ์นœ ๋’ค ๊ฒฐ์ •๋ก  ๊ทœ์น™์„ ๊ฑด๋‹ค.
5
+ ๊ตฌ์„ฑ ์ •์˜์™€ ์‹คํ–‰์€ runner.py, ์ง„ํ–‰ยท๊ฒฐ๊ณผ ํ™”๋ฉด์€ column_view.py(๋กœ์ปฌ 3์ข… ๋น„๊ต ์•ฑ๊ณผ ๊ฐ™์€ ๋ชจ์–‘)์ด๊ณ  ์ด ํŒŒ์ผ์€ ํŽ˜์ด์ง€๋งŒ
6
+ ๋‹ด๋‹นํ•œ๋‹ค. ์‚ฌ์šฉ์ž ์ž…๋ ฅ๊ณผ ๊ฒฐ๊ณผ๋Š” logbook.py ๊ฐ€ Supabase ์— ๋‚จ๊ธด๋‹ค(์‹œํฌ๋ฆฟ์ด ์—†์œผ๋ฉด ์กฐ์šฉํžˆ ๊ฑด๋„ˆ๋œ€).
7
  """
8
 
9
  from __future__ import annotations
10
 
 
 
11
  import os
12
+ import queue
13
+ import time
14
 
15
+ import column_view as cv
16
  import logbook
17
  import runner
18
  import streamlit as st
19
 
 
 
 
 
 
20
  # ํ•ฉ์„ฑ ์˜ˆ๋ฌธ - ํ‰๊ฐ€์…‹ gold ๋Š” ์“ฐ์ง€ ์•Š๋Š”๋‹ค(์œ ์ถœ ๋ฐฉ์ง€). ๊ฐ ์ธต์˜ ํ‘œ์ ์„ ์ผ๋ถ€๋Ÿฌ ์‹ฌ์–ด ๋‘” ๋ฌธ์žฅ์ด๋‹ค:
21
+ # ๋น„๋ฅ /๊ฐฑ์‹ ๋ฅ /์—ฐ์ฒด๋ฅ (์‚ฌ์ „ยท์œจ๋ฅ  ๊ทœ์น™) ยท 160ใŽž์œผ๋กœ(์กฐ์‚ฌ) ยท ๊ณง์€ ๋”ฐ์˜ดํ‘œ(์›๋ฌธ ๋”ฐ์˜ดํ‘œ ๋ณด์กด).
22
  SAMPLE_TEXT = (
23
  "โ–ถ์ •๋ถ€์˜ ๊ฐ•๋ ฅํ•œ ๋ถ€๋™์‚ฐ ๊ทœ์ œ์—๋„ ๋ถˆ๊ตฌํ•˜๊ณ  ์ˆ˜๋„๊ถŒ ์•„ํŒŒํŠธ ๋งค๋งค ๊ฑฐ๋ž˜ ๋น„๋ฅ ์€ "
24
  "์˜คํžˆ๋ ค ์ฆ๊ฐ€์„ธ๋ฅผ ๋ณด์˜€๋‹ค. ์‹œ์ค‘ ์€ํ–‰์˜ ์ฃผํƒ ๋‹ด๋ณด ๋Œ€์ถœ ๊ธˆ๋ฆฌ๊ฐ€ ๋‚ฎ์•„์ง€๋ฉด์„œ "
 
28
  "์ฃผ๋ชฉํ•˜๊ณ  ์žˆ๋‹ค. ์‹ ๊ทœ ๊ด‘์—ญ์ฒ ๋„๋Š” ์ตœ๊ณ  ์‹œ์† 160ใŽž์œผ๋กœ ์„ค๊ณ„๋๋‹ค."
29
  )
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
32
  @st.cache_resource(show_spinner="ํŒŒ์ดํ”„๋ผ์ธ ์ค€๋น„ ์ค‘โ€ฆ")
33
  def get_pipeline():
 
35
 
36
 
37
  st.set_page_config(page_title="์กฐ์„ ์ผ๋ณด ๋ณธ๋ฌธ ๊ต์—ด ๋ฐ๋ชจ", page_icon="๐Ÿ“ฐ", layout="wide")
38
+ st.markdown(cv.CSS, unsafe_allow_html=True)
39
  st.title("๐Ÿ“ฐ ์กฐ์„ ์ผ๋ณด ๋ณธ๋ฌธ ๊ต์—ด ๋ฐ๋ชจ")
40
+ st.caption(f"{runner.CONFIG.label} ยท ๋‹จ๊ณ„๊ฐ€ ๋๋‚˜๋Š” ๋Œ€๋กœ ๊ฒฐ๊ณผ๊ฐ€ ์Œ“์ž…๋‹ˆ๋‹ค")
41
 
42
  with st.sidebar:
43
  st.subheader("์„ค์ •")
 
51
  cfg = runner.CONFIG
52
  st.markdown(f"**{cfg.label}**")
53
  st.caption(cfg.note)
 
54
 
55
+ # ํผ ์•ˆ์˜ ์—ฌ๋Ÿฌ ์ค„ ์ž…๋ ฅ์€ โŒ˜+Enter(๋งฅ) ยท Ctrl+Enter ๋กœ ํผ์„ ์ œ์ถœํ•œ๋‹ค - ๋ฒ„ํŠผ์„ ๋ˆ„๋ฅด์ง€ ์•Š์•„๋„ ๋ฐ”๋กœ ๊ต์—ด์„ ์‹œ์ž‘ํ•œ๋‹ค.
56
+ with st.form("article_form", border=False):
57
+ text = st.text_area("๊ธฐ์‚ฌ ๋ณธ๋ฌธ", value=SAMPLE_TEXT, height=220, max_chars=12000)
58
+ go = st.form_submit_button("๊ต์—ด ์‹คํ–‰ (โŒ˜/Ctrl + Enter)", type="primary")
59
+ ph = st.empty()
60
 
61
+ if go:
62
+ source = text.strip()
63
  missing = [k for k in runner.REQUIRED_KEYS if not os.environ.get(k)]
64
+ if not source:
65
+ st.warning("๊ธฐ์‚ฌ ๋ณธ๋ฌธ์„ ๋„ฃ์–ด ์ฃผ์„ธ์š”.")
66
+ st.stop()
67
  if missing:
68
  st.error(
69
  f"{', '.join(missing)} ๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค - ์‚ฌ์ด๋“œ๋ฐ”์— ์ž…๋ ฅํ•˜๊ฑฐ๋‚˜ Space Secrets ์— ์„ค์ •ํ•˜์„ธ์š”."
70
  )
71
  st.stop()
 
 
 
 
 
 
 
72
  try:
73
+ pipeline = get_pipeline()
74
+ except Exception as e: # noqa: BLE001 - ๋ฐ๋ชจ ํ‘œ๋ฉด: ์›์ธ๋งŒ ํ™”๋ฉด์œผ๋กœ
75
+ st.error(f"ํŒŒ์ดํ”„๋ผ์ธ ์ค€๋น„ ์‹คํŒจ: {type(e).__name__}: {e}")
 
76
  st.stop()
77
+ # ์ž…๋ ฅ์€ ์‹คํ–‰ ์ „์— ๋‚จ๊ธด๋‹ค - ์‹คํ–‰์ด ์‹คํŒจํ•ด๋„ ๋ฌด์—‡์ด ๋“ค์–ด์™”๋Š”์ง€๋Š” ์•Œ ์ˆ˜ ์žˆ๊ฒŒ
78
+ article_id = logbook.save_article(source)
 
 
 
 
 
 
 
 
 
 
79
 
80
+ state = cv.new_state(source)
81
+ events = runner.start_proofread(pipeline, source)
82
+ cv.render(ph, state, time.monotonic())
83
+ last_draw = 0.0
84
+ while not (state["done"] or state["error"]):
85
+ moved = False
86
+ try:
87
+ while True:
88
+ cv.absorb(state, events.get_nowait())
89
+ moved = True
90
+ except queue.Empty:
91
+ pass
92
+ now = time.monotonic()
93
+ if moved or now - last_draw > 0.5:
94
+ cv.render(ph, state, now)
95
+ last_draw = now
96
+ if not moved:
97
+ time.sleep(0.15)
98
+ cv.render(ph, state, time.monotonic())
99
+
100
+ if state["done"]:
101
+ logbook.save_run(
102
+ article_id,
103
+ pipeline_key=cfg.pipeline,
104
+ prompt_key=cfg.prompt,
105
+ model=cfg.model,
106
+ output=state["done"]["output"],
107
+ processing_time_s=state["done"]["elapsed_s"],
108
+ )
109
+ if logbook.is_configured() and logbook.last_error():
110
+ st.caption(f"๊ธฐ๋ก ์‹คํŒจ (๊ฒฐ๊ณผ์—๋Š” ์˜ํ–ฅ ์—†์Œ): {logbook.last_error()}")
column_view.py ADDED
@@ -0,0 +1,356 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """๊ต์—ด ์ง„ํ–‰ยท๊ฒฐ๊ณผ ํ•œ ์—ด์„ ๊ทธ๋ฆฌ๋Š” ํ™”๋ฉด ์กฐ๊ฐ (Streamlit) - ๋กœ์ปฌ 3์ข… ๋น„๊ต ์•ฑ(`proofread-demo/compare_app.py`)๊ณผ ๊ฐ™์€ ๋ชจ์–‘.
2
+
3
+ ๊ธฐ๋‹ค๋ฆฌ๋Š” ๋™์•ˆ "๋’ค์—์„œ ๋ฌด์—‡์ด ์ง„ํ–‰๋˜๋Š”์ง€"๊ฐ€ ๋ณด์ด๋„๋ก:
4
+ - ์š”์•ฝ 3์นธ(์†Œ์š” ยท ์ ์šฉ ยท ํ™•์ธ ํ•„์š”)๊ณผ ๋‹จ๊ณ„ ์Šคํ…Œํผ(๋๋‚œ ๋‹จ๊ณ„ โœ“ ์†Œ์š” ์‹œ๊ฐ„ ยท ์ง„ํ–‰ ์ค‘ โ— ๊ฒฝ๊ณผ/๋ณดํ†ต N์ดˆ ยท ๋Œ€๊ธฐ โ—‹)
5
+ - ๋‹จ๊ณ„๊ฐ€ ๋๋‚  ๋•Œ๋งˆ๋‹ค ๋ณธ๋ฌธ ํ•˜์ด๋ผ์ดํŠธยท์ˆ˜์ • ์นด๋“œ๊ฐ€ ๊ทธ ์ž๋ฆฌ์—์„œ ์Œ“์ธ๋‹ค
6
+ - ์นด๋“œยทํ•˜์ด๋ผ์ดํŠธ๋Š” ๊ฒฐ๊ณผ ๋ณธ๋ฌธ์— ๋‚จ์€ ๋ณ€๊ฒฝ๋งŒ ๋ณด์ธ๋‹ค(FT ๊ฐ€ ๊ณ ์น˜๊ณ  ๊ธฐ์‚ฌ ๊ฒ€ํ† ๊ฐ€ ์›๋ฌธ์œผ๋กœ ๋˜๋Œ๋ฆฐ ๊ฒƒ์€ ๋‘˜ ๋‹ค ๋บ€๋‹ค)
7
+ ์ด๋ฒคํŠธ ๋ชจ์–‘์€ `runner.start_proofread` ๋ฅผ ๋”ฐ๋ฅธ๋‹ค.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import difflib
13
+ import html
14
+ import time
15
+ from typing import Any
16
+
17
+ import streamlit as st
18
+
19
+ # ํ™”๋ฉด์— ๋ณด์ผ ๋‹จ๊ณ„. (ํ‘œ์‹œ ์ด๋ฆ„, ๊ทธ ๋‹จ๊ณ„๊ฐ€ ๋๋‚ฌ๋‹ค๊ณ  ๋ณด๋Š” ์Šคํ… ์ด๋ฆ„, ๋ณดํ†ต ๊ฑธ๋ฆฌ๋Š” ์ดˆ)
20
+ STAGES = [
21
+ ("ํ‘œ๊ธฐ ์‚ฌ์ „ ์ ์šฉ", "vocabulary_sub_pre", 0.1),
22
+ ("๊ต์—ด ๋ชจ๋ธ(FT) ๋ฌธ๋‹จ๋ณ„", "proofread", 1),
23
+ ("๊ธฐ์‚ฌ ์ „์ฒด ๊ฒ€ํ†  (Opus 1ํšŒ)", "review", 7),
24
+ ("FT ๊ต์ • ํ™•์ธ", "revert_ft", 0.1),
25
+ ("์‚ฌ์‹คยท์ˆซ์žยท๋ฌธ๋งฅ ๊ต์ • ์ ์šฉ", "apply_review", 0.1),
26
+ ("๋งˆ๋ฌด๋ฆฌ ๊ทœ์น™", "post_process", 0.1),
27
+ ]
28
+ # ๊ต์ • ๋ชฉ๋ก์„ ์ ์šฉํ•˜๋Š” ์Šคํ…: (๋ชฉ๋ก์„ ๋‚ธ ์Šคํ…, ์ˆซ์ž ๊ฐ€๋“œ, ์‚ญ์ œ ๊ฐ€๋“œ ๋น„์œจ) - ์นด๋“œ ์ƒํƒœ๋ฅผ ์ ์šฉ ์Šคํ…๊ณผ ๊ฐ™์€ ๊ฐ€๋“œ๋กœ ๊ณ„์‚ฐํ•œ๋‹ค
29
+ LIST_CARDS = {"apply_review": ("review", False, 0.6)}
30
+ # FT ๋ณ€๊ฒฝ ๋˜๋Œ๋ฆผ: (๋˜๋Œ๋ฆผ ์Šคํ…, ๋ณ€๊ฒฝ ๋ชฉ๋ก ์Šคํ…, ํŒ์ • ์Šคํ…)
31
+ REVERTS = ("revert_ft", "ft_hunks", "review")
32
+
33
+ STAGE_COLORS = [
34
+ "rgba(120,120,120,.25)",
35
+ "rgba(70,130,220,.28)",
36
+ "rgba(60,170,90,.28)",
37
+ "rgba(240,150,40,.32)",
38
+ "rgba(170,90,200,.28)",
39
+ "rgba(120,120,120,.25)",
40
+ ]
41
+ CAT_KO = {
42
+ "spelling": "๋งž์ถค๋ฒ•",
43
+ "spacing": "๋„์–ด์“ฐ๊ธฐยท์‚ฌ์ด์‹œ์˜ท",
44
+ "grammar": "์กฐ์‚ฌยทํ˜ธ์‘",
45
+ "context": "๋ฌธ๋งฅ ๋‚ฑ๋ง",
46
+ "omission": "๋ˆ„๋ฝ",
47
+ "in-article": "๊ธฐ์‚ฌ ์•ˆ ๋ถˆ์ผ์น˜",
48
+ "inconsistency": "๊ธฐ์‚ฌ ์•ˆ ๋ถˆ์ผ์น˜",
49
+ "fact": "์‚ฌ์‹ค ์˜ค๊ธฐ",
50
+ "calc": "์ˆซ์ž ๊ฒ€์‚ฐ",
51
+ "word": "๋ฌธ๋งฅ ๋‚ฑ๋ง",
52
+ "unit": "๋‹จ์œ„ยท์ˆ˜์น˜ ๋ชจ์ˆœ",
53
+ }
54
+ CSS = """
55
+ <style>
56
+ .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}
57
+ .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}
58
+ @keyframes sh{0%{background-position:200% 0}100%{background-position:-200% 0}}
59
+ .stage{font-size:.9rem;margin:2px 0}
60
+ .card{border:1px solid rgba(128,128,128,.3);border-radius:6px;padding:6px 10px;margin-bottom:6px;font-size:.9rem}
61
+ .muted{color:gray;font-size:.82rem}
62
+ .stats{display:flex;gap:8px;margin:4px 0 8px}
63
+ .stat{flex:1;border:1px solid rgba(128,128,128,.3);border-radius:6px;padding:6px 8px;text-align:center}
64
+ .stat .k{font-size:.72rem;color:gray}.stat .v{font-size:1.05rem;font-weight:700}
65
+ .status{min-height:30px;margin-bottom:6px}
66
+ .chip{display:inline-block;font-size:.75rem;padding:1px 8px;border-radius:10px;margin-left:6px;font-weight:600}
67
+ .ok{background:rgba(60,170,90,.22);color:#1e7d3a}
68
+ .chk{background:rgba(240,170,40,.28);color:#8a5a00}
69
+ .rej{background:rgba(128,128,128,.22);color:#555}
70
+ </style>
71
+ """
72
+
73
+ NL = "\n" # ์ค„๋ฐ”๊ฟˆ์„ ์–ด์ ˆ ํ•˜๋‚˜๋กœ ๋‹ค๋ค„ diffยทํ•˜์ด๋ผ์ดํŠธ๊ฐ€ ์ž…๋ ฅ์˜ ์ค„ ๊ตฌ์กฐ๋ฅผ ๋ณด์กดํ•˜๊ฒŒ ํ•œ๋‹ค
74
+
75
+
76
+ def tokenize(text: str) -> list[str]:
77
+ """์–ด์ ˆ + ์ค„๋ฐ”๊ฟˆ ํ† ํฐ. ๋นˆ ์ค„์€ NL ํ† ํฐ์ด ์—ฐ์†์œผ๋กœ ๋‚จ์•„ ๋ฌธ๋‹จ ๊ฐ„๊ฒฉ์ด ์œ ์ง€๋œ๋‹ค."""
78
+ out: list[str] = []
79
+ for line in text.split("\n"):
80
+ out.extend(line.split())
81
+ out.append(NL)
82
+ return out[:-1]
83
+
84
+
85
+ def _norm(s: str) -> str:
86
+ return " ".join(s.split())
87
+
88
+
89
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ ์ƒํƒœ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
90
+ def new_state(original: str) -> dict[str, Any]:
91
+ return {
92
+ "original": original,
93
+ "stage_i": 0,
94
+ "stage_done_at": {},
95
+ "tokens": None,
96
+ "last_text": None,
97
+ "cards": [],
98
+ "log": "๋Œ€๊ธฐ ์ค‘",
99
+ "done": None,
100
+ "error": None,
101
+ "started": time.monotonic(),
102
+ }
103
+
104
+
105
+ def _label_tokens(state: dict, new_text: str, stage_i: int) -> None:
106
+ """์›๋ฌธ ์–ด์ ˆ์— '์–ด๋А ๋‹จ๊ณ„์—์„œ ๋ฐ”๋€Œ์—ˆ๋‚˜' ๋ผ๋ฒจ์„ ๋ˆ„์ ํ•œ๋‹ค. ์›๋ฌธ๊ณผ ๊ฐ™์•„์ง„ ์–ด์ ˆ์€ ๋ผ๋ฒจ์„ ์ง€์šด๋‹ค."""
107
+ if state["tokens"] is None:
108
+ state["tokens"] = [(t, -1) for t in tokenize(state["last_text"])]
109
+ old = [t for t, _ in state["tokens"]]
110
+ new = tokenize(new_text)
111
+ sm = difflib.SequenceMatcher(a=old, b=new, autojunk=False)
112
+ out: list[tuple[str, int]] = []
113
+ for op, i1, i2, j1, j2 in sm.get_opcodes():
114
+ out.extend(state["tokens"][i1:i2] if op == "equal" else ((t, stage_i) for t in new[j1:j2]))
115
+ orig = tokenize(state["original"])
116
+ net = difflib.SequenceMatcher(a=orig, b=[t for t, _ in out], autojunk=False)
117
+ for op, _i1, _i2, j1, j2 in net.get_opcodes():
118
+ if op == "equal":
119
+ out[j1:j2] = [(t, -1) for t, _ in out[j1:j2]]
120
+ state["tokens"] = out
121
+ state["last_text"] = new_text
122
+
123
+
124
+ def _item_status(
125
+ item: dict, text_before: str, protect_digits: bool, max_shrink: float | None
126
+ ) -> str:
127
+ from solar_eval.pipelines.corrections import apply_corrections
128
+
129
+ _, rep = apply_corrections(
130
+ text_before, [item], max_items=1, protect_digits=protect_digits, max_shrink=max_shrink
131
+ )
132
+ if rep.applied:
133
+ return "์ ์šฉ"
134
+ if rep.skipped_protected:
135
+ return "๊ฑฐ๋ถ€ (์ˆซ์žยท์‚ญ์ œ ๊ฐ€๋“œ)"
136
+ if rep.skipped_ambiguous or rep.skipped_not_found:
137
+ return "ํ™•์ธ ํ•„์š”"
138
+ return "๋ฌดํšจ"
139
+
140
+
141
+ def _diff_card(stage: int, before: str, after: str) -> dict:
142
+ spacing = "".join(before.split()) == "".join(after.split())
143
+ return {
144
+ "stage": stage,
145
+ "before": before,
146
+ "after": after,
147
+ "cat": "๋„์–ด์“ฐ๊ธฐ" if spacing else "",
148
+ "status": "์ ์šฉ",
149
+ "evidence": "",
150
+ }
151
+
152
+
153
+ def _add_card(state: dict, card: dict) -> None:
154
+ """์นด๋“œ๋ฅผ ๋”ํ•œ๋‹ค. ์•ž ๋‹จ๊ณ„์˜ ์ ์šฉ ์นด๋“œ๋ฅผ ๊ทธ๋Œ€๋กœ ๋˜๋Œ๋ฆฌ๋Š” ๋ณ€๊ฒฝ์ด๋ฉด ๋‘˜ ๋‹ค ์ง€์šด๋‹ค(๊ฒฐ๊ณผ์ ์œผ๋กœ ๋ฐ”๋€ ๊ฒƒ์ด ์—†๋‹ค)."""
155
+ if card["status"] == "์ ์šฉ":
156
+ for i, c in enumerate(state["cards"]):
157
+ if (
158
+ c["status"] == "์ ์šฉ"
159
+ and _norm(c["before"]) == _norm(card["after"])
160
+ and _norm(c["after"]) == _norm(card["before"])
161
+ ):
162
+ del state["cards"][i]
163
+ return
164
+ state["cards"].append(card)
165
+
166
+
167
+ def _drop_reverted(state: dict, ev: dict) -> None:
168
+ """๊ธฐ์‚ฌ ๊ฒ€ํ† ๊ฐ€ ์›๋ฌธ์œผ๋กœ ๋˜๋Œ๋ฆฐ FT ๋ณ€๊ฒฝ์€ ์นด๋“œ์—์„œ ์ง€์šด๋‹ค."""
169
+ _step, hunks_from, judge_from = REVERTS
170
+ decision = ev["json"].get(judge_from) or {}
171
+ by_id = {int(h["id"]): h for h in ev["hunks"].get(hunks_from) or []}
172
+ for raw in decision.get("revert") or []:
173
+ h = by_id.get(int(raw)) if str(raw).isdigit() else None
174
+ if h is None:
175
+ continue
176
+ pair = (_norm(h["before"]), _norm(h["after"]))
177
+ state["cards"] = [
178
+ c
179
+ for c in state["cards"]
180
+ if not (c["status"] == "์ ์šฉ" and (_norm(c["before"]), _norm(c["after"])) == pair)
181
+ ]
182
+
183
+
184
+ def absorb(state: dict, ev: dict) -> None:
185
+ """์ด๋ฒคํŠธ ํ•˜๋‚˜๋ฅผ ์ƒํƒœ์— ๋ฐ˜์˜ํ•œ๋‹ค. ๋‹จ๊ณ„ ์™„๋ฃŒ ํŒ์ •ยท์นด๋“œยทํ•˜์ด๋ผ์ดํŠธยท๋กœ๊ทธ."""
186
+ if ev["kind"] == "error":
187
+ state["error"] = ev["error"]
188
+ state["log"] = "์‹คํŒจ"
189
+ return
190
+ if ev["kind"] == "done":
191
+ state["done"] = ev
192
+ state["log"] = f"์™„๋ฃŒ - {ev['elapsed_s']:.1f}์ดˆ"
193
+ return
194
+ step, text = ev["step"], ev["text"]
195
+ stage_steps = [s[1] for s in STAGES]
196
+ si = state["stage_i"] # ์ด ์Šคํ…์€ ๋‹ค์Œ ๋ฏธ์™„ ๋‹จ๊ณ„์— ์†ํ•œ๋‹ค
197
+ if state["last_text"] is None:
198
+ state["last_text"] = state["original"]
199
+ pre_text = state["last_text"]
200
+ list_card = LIST_CARDS.get(step)
201
+ reverted = False
202
+ if list_card and list_card[0] in ev["corrections"]:
203
+ corr_key, protect_digits, max_shrink = list_card
204
+ for it in ev["corrections"][corr_key]:
205
+ cat = str(it.get("category", ""))
206
+ _add_card(
207
+ state,
208
+ {
209
+ "stage": si,
210
+ "before": str(it.get("original", "")),
211
+ "after": str(it.get("corrected", "")),
212
+ "cat": CAT_KO.get(cat, cat),
213
+ "status": _item_status(it, pre_text, protect_digits, max_shrink),
214
+ "evidence": str(it.get("evidence", "")),
215
+ },
216
+ )
217
+ elif step == REVERTS[0]:
218
+ _drop_reverted(state, ev)
219
+ reverted = True
220
+ elif ev["changes"] and not list_card:
221
+ for b, a in ev["changes"]:
222
+ _add_card(state, _diff_card(si, b, a))
223
+ if text != pre_text or state["tokens"] is None:
224
+ _label_tokens(state, text, si)
225
+ if step in stage_steps:
226
+ idx = stage_steps.index(step)
227
+ state["stage_done_at"][idx] = ev["elapsed_s"]
228
+ state["stage_i"] = idx + 1
229
+ n = sum(c["stage"] == idx for c in state["cards"])
230
+ n_chk = sum(c["stage"] == idx and c["status"] == "ํ™•์ธ ํ•„์š”" for c in state["cards"])
231
+ extra = (
232
+ (f" - ์ œ์•ˆ {n}๊ฑด" + (f" (ํ™•์ธ ํ•„์š” {n_chk})" if n_chk else ""))
233
+ if n
234
+ else " - ๊ณ ์น  ๊ณณ ์—†์Œ"
235
+ )
236
+ if reverted:
237
+ extra = "" # ํ™•์ธ ๋‹จ๊ณ„๋Š” ์ง€์šด ์นด๋“œ ์ˆ˜๋ฅผ ๋‚ด์„ธ์šฐ์ง€ ์•Š๋Š”๋‹ค
238
+ state["log"] = f"๋ฐฉ๊ธˆ: {STAGES[idx][0]} ์™„๋ฃŒ{extra}"
239
+
240
+
241
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ ๋ Œ๋” โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
242
+ def _render_text(state: dict) -> str:
243
+ toks = state["tokens"] or [(t, -1) for t in tokenize(state["original"])]
244
+ parts: list[str] = []
245
+ run: list[str] = []
246
+ cur = -1
247
+
248
+ def flush() -> None:
249
+ if run:
250
+ seg = html.escape(" ".join(run))
251
+ parts.append(
252
+ seg
253
+ if cur < 0
254
+ else f'<span style="background:{STAGE_COLORS[cur % len(STAGE_COLORS)]};border-radius:3px">{seg}</span>'
255
+ )
256
+ run.clear()
257
+
258
+ for t, s in toks + [("", -2)]:
259
+ if t == NL: # ์ž…๋ ฅ์˜ ์ค„๋ฐ”๊ฟˆ์„ ๊ทธ๋Œ€๋กœ - ๋นˆ ์ค„์€ <br> ๋‘ ๋ฒˆ
260
+ flush()
261
+ parts.append("<br>")
262
+ continue
263
+ if s != cur:
264
+ flush()
265
+ cur = s
266
+ if t:
267
+ run.append(t)
268
+ return (
269
+ " ".join(parts).replace(" <br> ", "<br>").replace("<br> ", "<br>").replace(" <br>", "<br>")
270
+ )
271
+
272
+
273
+ def _cards_html(state: dict) -> str:
274
+ cards = [c for c in state["cards"] if _norm(c["before"]) != _norm(c["after"])]
275
+ if not cards:
276
+ return ""
277
+ parts = []
278
+ for c in cards:
279
+ color = STAGE_COLORS[c["stage"] % len(STAGE_COLORS)]
280
+ if c["status"] == "์ ์šฉ":
281
+ cls, label = "ok", "โœ“ ์ ์šฉ"
282
+ elif c["status"] == "ํ™•์ธ ํ•„์š”":
283
+ cls, label = "chk", "ํ™•์ธ ํ•„์š”"
284
+ else:
285
+ cls, label = "rej", c["status"]
286
+ ev_html = (
287
+ f"<div class='muted'>๊ทผ๊ฑฐ: {html.escape(c['evidence'])}</div>" if c["evidence"] else ""
288
+ )
289
+ parts.append(
290
+ f"<div class='card' style='border-left:5px solid {color}'><span class='muted'>"
291
+ f"{STAGES[c['stage']][0]}{' ยท ' + c['cat'] if c['cat'] else ''}</span>"
292
+ f"<span class='chip {cls}'>{label}</span><br>"
293
+ f"<s>{html.escape(c['before'])}</s> โ†’ <b>{html.escape(c['after'])}</b>{ev_html}</div>"
294
+ )
295
+ return f"<div class='muted' style='margin-top:6px'>์ œ์•ˆ {len(cards)}๊ฑด</div>" + "".join(parts)
296
+
297
+
298
+ def render(ph: Any, state: dict, now: float) -> None:
299
+ """์™ผ์ชฝ: ์š”์•ฝ 3์นธ ยท ๋‹จ๊ณ„ ์Šคํ…Œํผ ยท ์ƒํƒœ ์ค„ / ์˜ค๋ฅธ์ชฝ: ํ•˜์ด๋ผ์ดํŠธ ๋ณธ๋ฌธ ยท ์ˆ˜์ • ์นด๋“œ."""
300
+ elapsed = now - state["started"]
301
+ expected = sum(s[2] for s in STAGES)
302
+ with ph.container():
303
+ left, right = st.columns([2, 3], gap="large")
304
+ with left:
305
+ n_ok = sum(c["status"] == "์ ์šฉ" for c in state["cards"])
306
+ n_chk = sum(c["status"] == "ํ™•์ธ ํ•„์š”" for c in state["cards"])
307
+ if state["error"]:
308
+ took = "์‹คํŒจ"
309
+ elif state["done"]:
310
+ took = f"{state['done']['elapsed_s']:.1f}์ดˆ"
311
+ else:
312
+ took = f"{elapsed:.0f}์ดˆโ€ฆ"
313
+ st.markdown(
314
+ "<div class='stats'>"
315
+ f"<div class='stat'><div class='k'>์†Œ์š”</div><div class='v'>{took}</div></div>"
316
+ f"<div class='stat'><div class='k'>์ ์šฉ</div><div class='v'>{n_ok}</div></div>"
317
+ f"<div class='stat'><div class='k'>ํ™•์ธ ํ•„์š”</div><div class='v'>{n_chk}</div></div>"
318
+ "</div>",
319
+ unsafe_allow_html=True,
320
+ )
321
+ lines = []
322
+ for i, (name, _step, typical) in enumerate(STAGES):
323
+ prev = state["stage_done_at"].get(i - 1, 0.0)
324
+ if i in state["stage_done_at"]:
325
+ lines.append(
326
+ f"<div class='stage'>โœ“ {name} <span class='muted'>({state['stage_done_at'][i] - prev:.1f}์ดˆ)</span></div>"
327
+ )
328
+ elif i == state["stage_i"] and not state["done"] and not state["error"]:
329
+ lines.append(
330
+ f"<div class='stage'>โ— <b>{name}</b> <span class='muted'>์ง„ํ–‰ ์ค‘ {elapsed - prev:.0f}์ดˆ ยท ๋ณดํ†ต {typical:.0f}์ดˆ</span></div>"
331
+ )
332
+ else:
333
+ lines.append(f"<div class='stage muted'>โ—‹ {name}</div>")
334
+ st.markdown("".join(lines), unsafe_allow_html=True)
335
+ if state["error"]:
336
+ st.markdown(
337
+ f"<div class='status' style='color:#b00020'>์‹คํŒจ: {html.escape(state['error'])}</div>",
338
+ unsafe_allow_html=True,
339
+ )
340
+ elif state["done"]:
341
+ st.markdown(
342
+ f"<div class='status' style='color:#1e7d3a'>์™„๋ฃŒ {state['done']['elapsed_s']:.1f}์ดˆ</div>",
343
+ unsafe_allow_html=True,
344
+ )
345
+ else:
346
+ msg = state["log"]
347
+ if elapsed > expected * 1.5 + 5:
348
+ msg += f" ยท ํ‰์†Œ({expected:.0f}์ดˆ)๋ณด๋‹ค ์˜ค๋ž˜ ๊ฑธ๋ฆฝ๋‹ˆ๋‹ค - API ๋Œ€๊ธฐ ์ค‘์ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๊ฒฐ๊ณผ๋Š” ๊ทธ๋Œ€๋กœ ๋„์ฐฉํ•ฉ๋‹ˆ๋‹ค."
349
+ st.markdown(
350
+ f"<div class='status muted'>{elapsed:.0f}์ดˆ ๊ฒฝ๊ณผ ยท {msg}<div class='shimmer'></div></div>",
351
+ unsafe_allow_html=True,
352
+ )
353
+ with right:
354
+ st.markdown(f"<div class='pane'>{_render_text(state)}</div>", unsafe_allow_html=True)
355
+ # ์นด๋“œ๋Š” ํ•œ ๋ฉ์–ด๋ฆฌ HTML ๋กœ ๊ทธ๋ฆฐ๋‹ค - ์นด๋“œ๋งˆ๋‹ค st.markdown ์„ ๋ถ€๋ฅด๋ฉด ์นด๋“œ๊ฐ€ ์ค„์—ˆ์„ ๋•Œ ์ง€๋‚œ ๋ Œ๋”์˜ ์นด๋“œ๊ฐ€ ๋‚จ๋Š”๋‹ค
356
+ st.markdown(_cards_html(state), unsafe_allow_html=True)
runner.py CHANGED
@@ -19,8 +19,10 @@ config ์ž์‚ฐ(ํŒŒ์ดํ”„๋ผ์ธ yamlยทํ”„๋กฌํ”„ํŠธยท์น˜ํ™˜ ์‚ฌ์ „)์€
19
  from __future__ import annotations
20
 
21
  import asyncio
 
 
 
22
  import time
23
- from collections.abc import Callable
24
  from dataclasses import dataclass
25
  from pathlib import Path
26
  from typing import Any
@@ -55,9 +57,6 @@ CONFIG = Config(
55
  #: ํ•„์š”ํ•œ ํ‚ค. ๊ธฐ์‚ฌ ๊ฒ€ํ† (Opus)๊ฐ€ Timely ๋ฅผ ๊ฑฐ์นœ๋‹ค.
56
  REQUIRED_KEYS = ("UPSTAGE_API_KEY", "TIMELY_API_KEY")
57
 
58
- # (event, index, total, step_name) - ์Šคํ…์ด ๋๋‚  ๋•Œ๋งˆ๋‹ค "done" ์œผ๋กœ ๋ถˆ๋ฆฐ๋‹ค
59
- StepCallback = Callable[[str, int, int, str], None]
60
-
61
 
62
  def find_config_dir() -> Path:
63
  """ํŒŒ์ดํ”„๋ผ์ธ yaml ์„ ์‹ค์ œ๋กœ ๊ฐ€์ง„ ์ž์‚ฐ ๋ฃจํŠธ. Space ๋ฒˆ๋“ค์ด ๋ ˆํฌ๋ณด๋‹ค ๋จผ์ €๋‹ค."""
@@ -97,49 +96,76 @@ def build_pipeline() -> Any:
97
  )
98
 
99
 
100
- def step_names(pipeline: Any) -> list[str]:
101
- """UI ๊ฐ€ ์ง„ํ–‰ ํ‘œ์‹œ๋ฅผ ๋ฏธ๋ฆฌ ๊ทธ๋ฆด ์ˆ˜ ์žˆ๊ฒŒ ์Šคํ… ์ด๋ฆ„์„ ์ˆœ์„œ๋Œ€๋กœ."""
102
- return [step.name for step in pipeline.steps]
 
 
 
 
 
 
103
 
104
 
105
- async def _run_async(pipeline: Any, text: str, on_step: StepCallback | None) -> dict[str, Any]:
106
  from solar_eval.models.sample import EvalSample
107
  from solar_eval.providers import INFERENCE_ROUTES, RoutedProvider
108
  from solar_eval.providers.upstage import UpstageProvider
109
 
110
- names = step_names(pipeline)
111
-
112
- def hook(name: str, _sample: Any, _ctx: Any) -> None:
113
- if on_step is not None:
114
- on_step("done", names.index(name) + 1, len(names), name)
115
-
116
- sample = EvalSample(input={"original": text})
117
- provider = RoutedProvider(UpstageProvider(), {k: c() for k, c in INFERENCE_ROUTES.items()})
118
  start = time.monotonic()
119
- # ํ‰๊ฐ€ run ๊ณผ ๊ฐ™์€ ์กฐ๊ฑด: temp 0.0, reasoning off
120
- result = await pipeline.run(
121
- sample,
122
- prompts="",
123
- provider=provider,
124
- model=CONFIG.model,
125
- temperature=0.0,
126
- max_tokens=8000,
127
- reasoning_effort=None,
128
- on_step=hook,
129
- )
130
- return {
131
- "output": result.output,
132
- "usage": result.artifacts.get("usage", {}),
133
- "elapsed_s": time.monotonic() - start,
134
- "pipeline_key": CONFIG.pipeline,
135
- "prompt_key": CONFIG.prompt,
136
- "model": CONFIG.model,
137
- }
138
-
139
-
140
- def run_proofread(pipeline: Any, text: str, on_step: StepCallback | None = None) -> dict[str, Any]:
141
- """ํ…์ŠคํŠธ 1๊ฑด ๊ต์—ด. {output, usage, elapsed_s, pipeline_key, prompt_key, model}.
142
-
143
- `on_step` ์€ ์Šคํ…์ด ๋๋‚  ๋•Œ๋งˆ๋‹ค ("done", ๋ช‡ ๋ฒˆ์งธ, ์ „์ฒด, ์Šคํ… ์ด๋ฆ„) ์œผ๋กœ ๋ถˆ๋ฆฐ๋‹ค(UI ์ง„ํ–‰ ํ‘œ์‹œ).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  """
145
- return asyncio.run(_run_async(pipeline, text, on_step))
 
 
 
 
 
19
  from __future__ import annotations
20
 
21
  import asyncio
22
+ import difflib
23
+ import queue
24
+ import threading
25
  import time
 
26
  from dataclasses import dataclass
27
  from pathlib import Path
28
  from typing import Any
 
57
  #: ํ•„์š”ํ•œ ํ‚ค. ๊ธฐ์‚ฌ ๊ฒ€ํ† (Opus)๊ฐ€ Timely ๋ฅผ ๊ฑฐ์นœ๋‹ค.
58
  REQUIRED_KEYS = ("UPSTAGE_API_KEY", "TIMELY_API_KEY")
59
 
 
 
 
60
 
61
  def find_config_dir() -> Path:
62
  """ํŒŒ์ดํ”„๋ผ์ธ yaml ์„ ์‹ค์ œ๋กœ ๊ฐ€์ง„ ์ž์‚ฐ ๋ฃจํŠธ. Space ๋ฒˆ๋“ค์ด ๋ ˆํฌ๋ณด๋‹ค ๋จผ์ €๋‹ค."""
 
96
  )
97
 
98
 
99
+ def word_changes(before: str, after: str) -> list[tuple[str, str]]:
100
+ """์–ด์ ˆ diff ๋กœ (๋ฐ”๋€Œ๊ธฐ ์ „, ๋ฐ”๋€ ํ›„) ์Œ. ๊ทœ์น™ยท์žฌ์ž‘์„ฑ ์Šคํ…์˜ ๋ณ€๊ฒฝ์„ ์นด๋“œ๋กœ ๋งŒ๋“ค ๋•Œ ์“ด๋‹ค."""
101
+ a, b = before.split(), after.split()
102
+ sm = difflib.SequenceMatcher(a=a, b=b, autojunk=False)
103
+ return [
104
+ (" ".join(a[i1:i2]), " ".join(b[j1:j2]))
105
+ for op, i1, i2, j1, j2 in sm.get_opcodes()
106
+ if op != "equal"
107
+ ]
108
 
109
 
110
+ async def _run_async(pipeline: Any, text: str, events: queue.Queue) -> None:
111
  from solar_eval.models.sample import EvalSample
112
  from solar_eval.providers import INFERENCE_ROUTES, RoutedProvider
113
  from solar_eval.providers.upstage import UpstageProvider
114
 
 
 
 
 
 
 
 
 
115
  start = time.monotonic()
116
+ prev = {"text": text}
117
+
118
+ def hook(name: str, sample: Any, ctx: Any) -> None:
119
+ changes = word_changes(prev["text"], ctx.text)
120
+ prev["text"] = ctx.text
121
+ events.put(
122
+ {
123
+ "kind": "step",
124
+ "step": name,
125
+ "text": ctx.text,
126
+ "changes": changes, # ์ด ์Šคํ…์ด ๋ณธ๋ฌธ์— ์‹ค์ œ๋กœ ๋‚ธ ๋ณ€๊ฒฝ(์–ด์ ˆ ์Œ)
127
+ "corrections": dict(sample.artifacts.get("corrections") or {}),
128
+ "json": dict(
129
+ sample.artifacts.get("json") or {}
130
+ ), # ํŒ์ • ์Šคํ… ๊ฒฐ๊ณผ - ๋˜๋Œ๋ฆผ ๋ฒˆํ˜ธยท์‚ฌ์œ 
131
+ "hunks": dict(sample.artifacts.get("hunks") or {}), # diff_hunks ๋ณ€๊ฒฝ ๋ชฉ๋ก
132
+ "elapsed_s": time.monotonic() - start,
133
+ }
134
+ )
135
+
136
+ try:
137
+ provider = RoutedProvider(UpstageProvider(), {k: c() for k, c in INFERENCE_ROUTES.items()})
138
+ # ํ‰๊ฐ€ run ๊ณผ ๊ฐ™์€ ์กฐ๊ฑด: temp 0.0, reasoning off
139
+ result = await pipeline.run(
140
+ EvalSample(input={"original": text}),
141
+ prompts="",
142
+ provider=provider,
143
+ model=CONFIG.model,
144
+ temperature=0.0,
145
+ max_tokens=8000,
146
+ reasoning_effort=None,
147
+ on_step=hook,
148
+ )
149
+ except Exception as e: # noqa: BLE001 - ๋ฐ๋ชจ ํ‘œ๋ฉด: ์›์ธ๋งŒ ํ™”๋ฉด์œผ๋กœ
150
+ events.put(
151
+ {
152
+ "kind": "error",
153
+ "error": f"{type(e).__name__}: {e}",
154
+ "elapsed_s": time.monotonic() - start,
155
+ }
156
+ )
157
+ return
158
+ events.put({"kind": "done", "output": result.output, "elapsed_s": time.monotonic() - start})
159
+
160
+
161
+ def start_proofread(pipeline: Any, text: str) -> queue.Queue:
162
+ """ํ…์ŠคํŠธ 1๊ฑด ๊ต์—ด์„ ์Šค๋ ˆ๋“œ๋กœ ์‹œ์ž‘ํ•˜๊ณ  ์ด๋ฒคํŠธ ํ๋ฅผ ๋Œ๋ ค์ค€๋‹ค.
163
+
164
+ ์ด๋ฒคํŠธ: {"kind": "step", step, text, changes, corrections, json, hunks, elapsed_s} ๋ฅผ ์Šคํ…๋งˆ๋‹ค,
165
+ ๋๋‚˜๋ฉด {"kind": "done", output, elapsed_s} ๋˜๋Š” {"kind": "error", error, elapsed_s} ํ•˜๋‚˜.
166
  """
167
+ events: queue.Queue = queue.Queue()
168
+ threading.Thread(
169
+ target=lambda: asyncio.run(_run_async(pipeline, text, events)), daemon=True
170
+ ).start()
171
+ return events