dev-strender Claude Opus 4.7 (1M context) commited on
Commit
c318017
·
1 Parent(s): 7cd2a55

feat(title-proofread): address customer feedback round 1

Browse files

- Add Chosun style-book thousands-comma rule (PRIORITY 0, MANDATORY).
Examples: 7870 -> 7,870; 1만2000 -> 1만2,000.
Year/code exceptions documented (2030, KF-21).
- Strip stray </think> tokens from model output.
Handles both paired <think>...</think> and orphan </think> patterns
occasionally emitted by solar-pro2.
- Lock model to solar-pro2 (production extension target) and drop the
pro3 comparison column. Single-column result UI only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

title_proofread/prompts/prompt_dev_v1/system.txt CHANGED
@@ -21,6 +21,28 @@
21
 
22
  # CRITICAL 보존 규칙 (PRIORITY 순서)
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  ## PRIORITY 1: 의미·구조 절대 보존 (MANDATORY)
25
 
26
  NEVER 다음을 수행한다:
@@ -42,8 +64,9 @@ NEVER 다음을 수행한다:
42
  → 풀어쓰지 않는다. NEVER `高수익` → `고수익`
43
  - **말줄임표 `…`**: 팩트와 시사점·결론을 연결하는 의도된 구두점
44
  → 위치·개수 그대로 유지
45
- - **수치 표기 양식**: `40조원`, `1만9585건`, `22만8000명`, `30%`, `2배`
46
  → 만·억·조 한글 병기 양식 변경 금지, 단위·기호 (`%`·`배`·`명`·`원`) 변경 금지
 
47
  - **따옴표 종류 변환 절대 금지**: 큰따옴표 ↔ 작은따옴표 변환은 의미를 완전히 바꾼다
48
 
49
  ## PRIORITY 3: 정보 추가/삭제 금지 (MANDATORY)
 
21
 
22
  # CRITICAL 보존 규칙 (PRIORITY 순서)
23
 
24
+ ## PRIORITY 0: 조선 스타일북 의무 교정 (MANDATORY — 보존이 아니라 *적극 교정*)
25
+
26
+ 다음 패턴은 조선일보 스타일북 위반이므로 **반드시 교정**한다. 보존 원칙보다 우선.
27
+
28
+ ### 천단위 콤마 (4자리 이상 아라비아 숫자)
29
+
30
+ 원문에 콤마가 빠진 4자리 이상 숫자는 *반드시* 천단위 콤마를 보충한다:
31
+ - `7870` → `7,870`
32
+ - `12345` → `12,345`
33
+ - `1234567` → `1,234,567`
34
+
35
+ 한글 단위(만/억/조) 뒤에 붙는 숫자에도 동일 적용:
36
+ - `1만2000` → `1만2,000`
37
+ - `5억3000만` → `5억3,000만`
38
+ - `2만8000명` → `2만8,000명`
39
+
40
+ **예외 — 콤마 추가하지 않음**:
41
+ - 연도: `2030`, `1980`, `2024학번`
42
+ - 코드·번호·식별자 성격: `KF-21`, `B-1`, `5G`
43
+
44
+ `% / %p / 배 / 명 / 원 / 건 / km / kg` 등 단위 *앞* 숫자 모두에 적용.
45
+
46
  ## PRIORITY 1: 의미·구조 절대 보존 (MANDATORY)
47
 
48
  NEVER 다음을 수행한다:
 
64
  → 풀어쓰지 않는다. NEVER `高수익` → `고수익`
65
  - **말줄임표 `…`**: 팩트와 시사점·결론을 연결하는 의도된 구두점
66
  → 위치·개수 그대로 유지
67
+ - **수치 표기 양식**: `40조원`, `1만9,585건`, `22만8,000명`, `30%`, `2배`
68
  → 만·억·조 한글 병기 양식 변경 금지, 단위·기호 (`%`·`배`·`명`·`원`) 변경 금지
69
+ → 천단위 콤마 규칙은 아래 *PRIORITY 0* 의 의무 교정 대상이므로, *원문에 콤마가 빠져 있으면 반드시 보충* 한다
70
  - **따옴표 종류 변환 절대 금지**: 큰따옴표 ↔ 작은따옴표 변환은 의미를 완전히 바꾼다
71
 
72
  ## PRIORITY 3: 정보 추가/삭제 금지 (MANDATORY)
title_proofread/runner.py CHANGED
@@ -1,18 +1,40 @@
1
- """제목 교열 단일 호출 러너 — solar-pro2 / solar-pro3 비교용.
2
 
3
- UI 시스템·유저 프롬프트직접 편집할 수 있 prompt 인자를 전부 외부에서 받음.
4
- 모델은 호출 시 인자로 지정 (UI 에서 두 모델을 병렬로 호출).
5
  """
6
 
7
  from __future__ import annotations
8
 
 
9
  import time
10
  from pathlib import Path
11
  from typing import Any
12
 
13
- MODELS = ["solar-pro2", "solar-pro3"]
14
  DEFAULT_PROMPT_DIR = Path(__file__).resolve().parent / "prompts" / "prompt_dev_v1"
15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
  def load_default_prompts() -> tuple[str, str]:
18
  """`prompt_dev_v1` 의 system.txt + user.txt 를 그대로 반환."""
@@ -33,28 +55,27 @@ def run_title_proofread(
33
  category: str,
34
  system_prompt: str,
35
  user_template: str,
36
- model: str,
37
  temperature: float = 0.0,
38
  reasoning_effort: str = "low",
39
  max_tokens: int = 2000,
40
  ) -> dict[str, Any]:
41
- """단일 LLM 호출. UI 에서 모델 번씩 (병렬) 호출.
42
 
43
  Returns:
44
  {
45
- "output": str, # 모델 응답 (strip 처리)
46
- "user_message": str, # placeholder 치환된 실 user content (디버깅용)
47
  "model": str,
48
  "latency_ms": int,
49
  "usage": dict, # {prompt_tokens, completion_tokens, total_tokens}
50
- "error": str | None, # 에러 시 메시지
51
  }
52
  """
53
  user_msg = render_user_message(user_template, original, category)
54
  start = time.time()
55
  try:
56
  kwargs: dict[str, Any] = {
57
- "model": model,
58
  "messages": [
59
  {"role": "system", "content": system_prompt},
60
  {"role": "user", "content": user_msg},
@@ -69,14 +90,15 @@ def run_title_proofread(
69
  return {
70
  "output": "",
71
  "user_message": user_msg,
72
- "model": model,
73
  "latency_ms": int((time.time() - start) * 1000),
74
  "usage": {},
75
  "error": f"{type(exc).__name__}: {exc}",
76
  }
77
 
78
  elapsed_ms = int((time.time() - start) * 1000)
79
- raw = (resp.choices[0].message.content or "").strip()
 
80
  usage = getattr(resp, "usage", None)
81
  usage_dict: dict[str, int] = {}
82
  if usage:
@@ -86,9 +108,9 @@ def run_title_proofread(
86
  usage_dict[k] = v
87
 
88
  return {
89
- "output": raw,
90
  "user_message": user_msg,
91
- "model": model,
92
  "latency_ms": elapsed_ms,
93
  "usage": usage_dict,
94
  "error": None,
 
1
+ """제목 교열 단일 호출 러너 — solar-pro2 고정.
2
 
3
+ production 환경(extension) solar-pro2 사용하므로 데모동일 모델로 고정.
 
4
  """
5
 
6
  from __future__ import annotations
7
 
8
+ import re
9
  import time
10
  from pathlib import Path
11
  from typing import Any
12
 
13
+ MODEL = "solar-pro2"
14
  DEFAULT_PROMPT_DIR = Path(__file__).resolve().parent / "prompts" / "prompt_dev_v1"
15
 
16
+ # solar-pro2 가 가끔 응답 본문 앞에 reasoning trace 를 emit 하고 `</think>` 로 닫는
17
+ # 경우가 있음 (보통은 paired `<think>...</think>` 인데 unpaired 가 발생). upstage
18
+ # provider 의 paired-tag strip 도 우회되므로, 데모 단에서 결정적으로 제거한다.
19
+ _PAIRED_THINK = re.compile(r"<think>.*?</think>", re.DOTALL)
20
+ _ORPHAN_THINK_PREFIX = re.compile(r"^.*?</think>\s*", re.DOTALL)
21
+
22
+
23
+ def _strip_think(raw: str) -> str:
24
+ """`<think>...</think>` 및 unpaired `</think>` 앞부분 모두 제거.
25
+
26
+ 가드 순서:
27
+ 1. paired `<think>...</think>` 블록 제거
28
+ 2. 그래도 `</think>` 가 남아 있으면 → 첫 등장 위치 이전을 전부 reasoning
29
+ trace 로 간주하고 잘라냄 (가장 흔한 누출 패턴)
30
+ 3. 남은 `<think>` / `</think>` 토큰 잔존도 제거
31
+ """
32
+ s = _PAIRED_THINK.sub("", raw)
33
+ if "</think>" in s:
34
+ s = _ORPHAN_THINK_PREFIX.sub("", s, count=1)
35
+ s = s.replace("</think>", "").replace("<think>", "")
36
+ return s.strip()
37
+
38
 
39
  def load_default_prompts() -> tuple[str, str]:
40
  """`prompt_dev_v1` 의 system.txt + user.txt 를 그대로 반환."""
 
55
  category: str,
56
  system_prompt: str,
57
  user_template: str,
 
58
  temperature: float = 0.0,
59
  reasoning_effort: str = "low",
60
  max_tokens: int = 2000,
61
  ) -> dict[str, Any]:
62
+ """단일 LLM 호출. 모델 항상 `solar-pro2`.
63
 
64
  Returns:
65
  {
66
+ "output": str, # 모델 응답 (strip + think-token 제거 후)
67
+ "user_message": str, # placeholder 치환된 실 user content
68
  "model": str,
69
  "latency_ms": int,
70
  "usage": dict, # {prompt_tokens, completion_tokens, total_tokens}
71
+ "error": str | None,
72
  }
73
  """
74
  user_msg = render_user_message(user_template, original, category)
75
  start = time.time()
76
  try:
77
  kwargs: dict[str, Any] = {
78
+ "model": MODEL,
79
  "messages": [
80
  {"role": "system", "content": system_prompt},
81
  {"role": "user", "content": user_msg},
 
90
  return {
91
  "output": "",
92
  "user_message": user_msg,
93
+ "model": MODEL,
94
  "latency_ms": int((time.time() - start) * 1000),
95
  "usage": {},
96
  "error": f"{type(exc).__name__}: {exc}",
97
  }
98
 
99
  elapsed_ms = int((time.time() - start) * 1000)
100
+ raw = resp.choices[0].message.content or ""
101
+ cleaned = _strip_think(raw)
102
  usage = getattr(resp, "usage", None)
103
  usage_dict: dict[str, int] = {}
104
  if usage:
 
108
  usage_dict[k] = v
109
 
110
  return {
111
+ "output": cleaned,
112
  "user_message": user_msg,
113
+ "model": MODEL,
114
  "latency_ms": elapsed_ms,
115
  "usage": usage_dict,
116
  "error": None,
title_proofread/ui.py CHANGED
@@ -1,22 +1,19 @@
1
- """Gradio UI — 제목 교열 sandbox 탭.
2
 
3
- 사용자에게 system/user 프롬프트와 호출 메타데이터를 노출하지 ,
4
- 입력 제목·카테고리·추론 옵션만 받아 solar-pro2 / solar-pro3 결과를
5
- 좌우 컬럼에서 동시에 비교한다.
6
  """
7
 
8
  from __future__ import annotations
9
 
10
- from concurrent.futures import ThreadPoolExecutor
11
  from typing import Any
12
 
13
  import gradio as gr
14
  from diff_utils import highlight_diff
15
 
16
- from .runner import MODELS, load_default_prompts, run_title_proofread
17
 
18
  # UI 단순화를 위해 category 는 입력 받지 않고 내부 고정.
19
- # 모델 거동상 카테고리별로 의미 있는 차이가 관찰되지 않아 선택 UI 제거.
20
  DEFAULT_CATEGORY = "일반기사"
21
 
22
 
@@ -26,13 +23,9 @@ def build_title_proofread_tab(client: Any) -> None:
26
  Args:
27
  client: openai.OpenAI 호환 클라이언트 (Upstage base_url 설정).
28
  """
29
- # Default prompts loaded once at module init — never exposed to users.
30
  default_system, default_user = load_default_prompts()
31
 
32
- gr.Markdown(
33
- "## 제목 교열\n"
34
- f"한국 신문 제목 교열 결과를 `{MODELS[0]}` 와 `{MODELS[1]}` 두 모델로 비교."
35
- )
36
 
37
  original = gr.Textbox(
38
  label="제목 입력",
@@ -40,7 +33,7 @@ def build_title_proofread_tab(client: Any) -> None:
40
  lines=1,
41
  )
42
 
43
- with gr.Accordion("추론 옵션 (양쪽 모델 공통 적용)", open=False):
44
  with gr.Row():
45
  temperature = gr.Slider(
46
  minimum=0.0,
@@ -61,24 +54,12 @@ def build_title_proofread_tab(client: Any) -> None:
61
  elem_id="title-proofread-run-btn",
62
  )
63
 
64
- # ─── 결과 — 모델을 좌우 컬럼으로 비교 ───
65
- with gr.Row(equal_height=False):
66
- with gr.Column():
67
- gr.Markdown(f"### {MODELS[0]}")
68
- output_pro2 = gr.Textbox(label="교정 결과", lines=2, interactive=False)
69
- diff_pro2 = gr.HTML(label="원본 대비 diff")
70
- with gr.Column():
71
- gr.Markdown(f"### {MODELS[1]}")
72
- output_pro3 = gr.Textbox(label="교정 결과", lines=2, interactive=False)
73
- diff_pro3 = gr.HTML(label="원본 대비 diff")
74
 
75
  def _empty():
76
- return (
77
- gr.update(value=""),
78
- gr.update(value=""),
79
- gr.update(value=""),
80
- gr.update(value=""),
81
- )
82
 
83
  def _on_run(
84
  original_text: str,
@@ -94,43 +75,24 @@ def build_title_proofread_tab(client: Any) -> None:
94
 
95
  orig_clean = original_text.strip()
96
 
97
- # 두 모델 병렬 호출 (총 latency = max(pro2, pro3)).
98
- # 시스템·유저 프롬프트는 내부 default 고정 — UI 에 노출하지 않음.
99
- with ThreadPoolExecutor(max_workers=2) as ex:
100
- futures = {
101
- m: ex.submit(
102
- run_title_proofread,
103
- client=client,
104
- original=orig_clean,
105
- category=DEFAULT_CATEGORY,
106
- system_prompt=default_system,
107
- user_template=default_user,
108
- model=m,
109
- temperature=temperature_val,
110
- reasoning_effort=reasoning_effort_val,
111
- )
112
- for m in MODELS
113
- }
114
- results = {m: f.result() for m, f in futures.items()}
115
-
116
- r2 = results[MODELS[0]]
117
- r3 = results[MODELS[1]]
118
-
119
- # 모델 호출 자체가 실패하면 toast 로 알리고 결과 비움.
120
- for m, r in ((MODELS[0], r2), (MODELS[1], r3)):
121
- if r.get("error"):
122
- gr.Warning(f"{m} 호출 실패: {r['error']}")
123
-
124
- def _diff(out: str) -> str:
125
- return highlight_diff(orig_clean, out) if out else ""
126
-
127
- return (
128
- gr.update(value=r2["output"]),
129
- gr.update(value=_diff(r2["output"])),
130
- gr.update(value=r3["output"]),
131
- gr.update(value=_diff(r3["output"])),
132
  )
133
 
 
 
 
 
 
 
 
 
134
  run_btn.click(
135
  _on_run,
136
  inputs=[
@@ -139,9 +101,7 @@ def build_title_proofread_tab(client: Any) -> None:
139
  reasoning_effort,
140
  ],
141
  outputs=[
142
- output_pro2,
143
- diff_pro2,
144
- output_pro3,
145
- diff_pro3,
146
  ],
147
  )
 
1
+ """Gradio UI — 제목 교열 sandbox 탭 (solar-pro2 단일 모델).
2
 
3
+ production extension 동일 모델(solar-pro2) 정. 시스템/유저 프롬프트는
4
+ 내부 default 사용 UI 노출하지 않음. 호출 메타데이터도 표시하지 않음.
 
5
  """
6
 
7
  from __future__ import annotations
8
 
 
9
  from typing import Any
10
 
11
  import gradio as gr
12
  from diff_utils import highlight_diff
13
 
14
+ from .runner import MODEL, load_default_prompts, run_title_proofread
15
 
16
  # UI 단순화를 위해 category 는 입력 받지 않고 내부 고정.
 
17
  DEFAULT_CATEGORY = "일반기사"
18
 
19
 
 
23
  Args:
24
  client: openai.OpenAI 호환 클라이언트 (Upstage base_url 설정).
25
  """
 
26
  default_system, default_user = load_default_prompts()
27
 
28
+ gr.Markdown(f"## 제목 교열 ({MODEL})")
 
 
 
29
 
30
  original = gr.Textbox(
31
  label="제목 입력",
 
33
  lines=1,
34
  )
35
 
36
+ with gr.Accordion("추론 옵션", open=False):
37
  with gr.Row():
38
  temperature = gr.Slider(
39
  minimum=0.0,
 
54
  elem_id="title-proofread-run-btn",
55
  )
56
 
57
+ # ─── 결과 — 단일 컬럼 ───
58
+ output = gr.Textbox(label="교정 결과", lines=2, interactive=False)
59
+ diff_html = gr.HTML(label="원본 대비 diff")
 
 
 
 
 
 
 
60
 
61
  def _empty():
62
+ return gr.update(value=""), gr.update(value="")
 
 
 
 
 
63
 
64
  def _on_run(
65
  original_text: str,
 
75
 
76
  orig_clean = original_text.strip()
77
 
78
+ result = run_title_proofread(
79
+ client=client,
80
+ original=orig_clean,
81
+ category=DEFAULT_CATEGORY,
82
+ system_prompt=default_system,
83
+ user_template=default_user,
84
+ temperature=temperature_val,
85
+ reasoning_effort=reasoning_effort_val,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  )
87
 
88
+ if result.get("error"):
89
+ gr.Warning(f"{MODEL} 호출 실패: {result['error']}")
90
+ return _empty()
91
+
92
+ out_text = result["output"]
93
+ diff = highlight_diff(orig_clean, out_text) if out_text else ""
94
+ return gr.update(value=out_text), gr.update(value=diff)
95
+
96
  run_btn.click(
97
  _on_run,
98
  inputs=[
 
101
  reasoning_effort,
102
  ],
103
  outputs=[
104
+ output,
105
+ diff_html,
 
 
106
  ],
107
  )