Spaces:
Sleeping
Sleeping
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 +24 -1
- title_proofread/runner.py +36 -14
- title_proofread/ui.py +28 -68
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만
|
| 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
|
| 2 |
|
| 3 |
-
|
| 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 |
-
|
| 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 호출.
|
| 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":
|
| 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":
|
| 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 =
|
|
|
|
| 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":
|
| 90 |
"user_message": user_msg,
|
| 91 |
-
"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 |
-
|
| 4 |
-
|
| 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
|
| 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("추론 옵션
|
| 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 |
-
|
| 66 |
-
|
| 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 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 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 |
-
|
| 143 |
-
|
| 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 |
)
|