dev-strender's picture
Replace v24-era demo with v34 pipeline demo (engine-vendored bundle)
9c84f9d verified
Raw History Blame Contribute Delete
19.5 kB
"""multi_step ํŒŒ์ดํ”„๋ผ์ธ์˜ rule ์Šคํ… ๊ตฌํ˜„์ฒด.
`multi_step.py` ๊ฐ€ 518์ค„์ด๋ผ (`.claude/rules/code-style.md` ์˜ 400์ค„ ๊ถŒ์žฅ์„ ์„
๋„˜์–ด) ๋ฃฐ ๊ตฌํ˜„๋งŒ ๋”ฐ๋กœ ๋ฝ‘์•„๋ƒˆ๋‹ค. vocabulary/post_process/correction_filter ๋Š”
๋™์ž‘์„ ๋ฐ”๊พธ์ง€ ์•Š๊ณ  `MultiStepPipeline._apply_*` ๋ฉ”์„œ๋“œ์—์„œ ๊ทธ๋Œ€๋กœ ์˜ฎ๊ฒจ์˜จ
๊ฒƒ์ด๋‹ค โ€” ์ˆœ์ˆ˜ ํ…์ŠคํŠธ ๋ณ€ํ™˜ ๋กœ์ง์ด๋ผ `self` ์ƒํƒœ๊ฐ€ ํ•„์š” ์—†๋‹ค(`apply_vocabulary`
๋งŒ ์˜ˆ์™ธ โ€” ๋ฐ์ดํ„ฐ์…‹์—์„œ ๋กœ๋“œํ•œ ์–ดํœ˜ ์บ์‹œ๋ฅผ ์ธ์ž๋กœ ๋ฐ›๊ณ  ๊ฐฑ์‹ ๋œ ์บ์‹œ๋ฅผ ๋˜๋Œ๋ ค์ค€๋‹ค).
`apply_judge_decisions`/`apply_change_rate_guard` ๋Š” v24 ๊ณ„์—ด ํฌํŒ… ๋‹จ๊ณ„(ยง5-F)์—์„œ
์ถ”๊ฐ€๋๋‹ค. ์ „์ž๋Š” `EvalSample.artifacts` ๋ฅผ ์ฝ์–ด์•ผ ํ•ด์„œ(judge ์Šคํ…์˜ ๊ตฌ์กฐํ™” ๊ฒฐ์ •,
self-consistency ์˜ corrections ๋ชฉ๋ก) `sample` ์„ ์ธ์ž๋กœ ๋ฐ›๋Š”๋‹ค โ€” ๋‹ค๋ฅธ rule
ํ•จ์ˆ˜๋“ค๊ณผ ์‹œ๊ทธ๋‹ˆ์ฒ˜๊ฐ€ ๋‹ค๋ฅธ ์œ ์ผํ•œ ์ด์œ ๋‹ค.
"""
from __future__ import annotations
import logging
import re
from pathlib import Path
from typing import TYPE_CHECKING, Any
from solar_eval.models.sample import EvalSample
from solar_eval.pipelines.errors import KnowledgeAssetError
if TYPE_CHECKING:
from solar_eval.core.dataset_loader import DatasetLoader
logger = logging.getLogger(__name__)
def apply_vocabulary(
text: str,
config: dict[str, Any],
vocabulary: list[dict[str, str]] | None,
dataset_loader: DatasetLoader | None,
config_dir: Path | None = None,
) -> tuple[str, list[dict[str, str]] | None]:
"""`vocabulary_sub`/`vocabulary_reapply` rule_type ๊ตฌํ˜„.
ํ˜ธ์ถœ์ž(`VocabularyStep`)๊ฐ€ run ์ˆ˜๋ช… ์บ์‹œ๋ฅผ ๋“ค๊ณ  ์žˆ์œผ๋ฏ€๋กœ, ์ตœ์ดˆ ํ˜ธ์ถœ ์‹œ ๋กœ๋“œํ•œ
์–ดํœ˜ ๋ชฉ๋ก์„ ๋ฐ˜ํ™˜๊ฐ’์œผ๋กœ ๋˜๋Œ๋ ค์ค˜ ์บ์‹œ๋กœ ์ €์žฅํ•˜๊ฒŒ ํ•œ๋‹ค.
Args:
config_dir: ๋ ˆํฌ์˜ config ์ •๋ณธ ๋””๋ ‰ํ† ๋ฆฌ(`03-evaluation`). ์น˜ํ™˜ ์‚ฌ์ „์€
๋ฐ์ดํ„ฐ๊ฐ€ ์•„๋‹ˆ๋ผ **์„ค์ •**์ด๋ผ(๋ฐ”๋€Œ๋ฉด ๊ฐ™์€ ๋ฌธ์ œ๋ฅผ ๋‹ค๋ฅด๊ฒŒ ํ‘ผ๋‹ค) ์—ฌ๊ธฐ๋ฅผ
๋จผ์ € ์ฐพ๋Š”๋‹ค. ๋ฐ์ดํ„ฐ ๋ฃจํŠธ ๊ธฐ์ค€ ๋ ˆ๊ฑฐ์‹œ ์„ ์–ธ๋„ ๊ณ„์† ์ง€์›ํ•œ๋‹ค.
Returns:
(์น˜ํ™˜๋œ text, ๊ฐฑ์‹ ๋œ vocabulary ์บ์‹œ) ํŠœํ”Œ.
Raises:
KnowledgeAssetError: `vocab_dataset` ์„ ์„ ์–ธํ–ˆ๋Š”๋ฐ ๋ชป ์ฝ์„ ๋•Œ.
"""
if vocabulary is None:
vocabulary = _load_vocabulary(config, dataset_loader, config_dir)
for row in vocabulary:
wrong = row.get("wrong", row.get("before", ""))
correct = row.get("correct", row.get("after", ""))
if wrong and correct:
text = text.replace(wrong, correct)
return text, vocabulary
def _load_vocabulary(
config: dict[str, Any],
dataset_loader: DatasetLoader | None,
config_dir: Path | None,
) -> list[dict[str, str]]:
"""์น˜ํ™˜ ์‚ฌ์ „์„ ๋กœ๋“œํ•œ๋‹ค โ€” config ์ •๋ณธ ์šฐ์„ , ๋ฐ์ดํ„ฐ ๋ฃจํŠธ ํด๋ฐฑ.
์„ ์–ธ์ด ์—†์œผ๋ฉด ๋นˆ ๋ชฉ๋ก(์ •์ƒ์ ์ธ no-op)์ด๊ณ , ์„ ์–ธํ–ˆ๋Š”๋ฐ ๋ชป ์ฝ์œผ๋ฉด ์˜ˆ์™ธ๋‹ค.
"""
vocab_path = config.get("vocab_dataset", "")
if not vocab_path:
return []
searched: list[str] = []
# 1. config ์ •๋ณธ (๋ ˆํฌ์˜ 03-evaluation/) โ€” ์น˜ํ™˜ ์‚ฌ์ „์€ ์„ค์ •์ด๋‹ค.
if config_dir is not None:
candidate = Path(config_dir) / vocab_path
searched.append(str(candidate))
if candidate.is_file() and dataset_loader is not None:
return dataset_loader.load_csv(str(config_dir), vocab_path, source="local")
# 2. ๋ ˆ๊ฑฐ์‹œ ์„ ์–ธ (repo/source ๋กœ ๋ฐ์ดํ„ฐ ๋ฃจํŠธ๋‚˜ HF ๋ฅผ ๊ฐ€๋ฆฌํ‚ค๋Š” ๊ฒฝ์šฐ)
repo = config.get("repo", "")
source = config.get("source", "huggingface")
if dataset_loader is not None:
searched.append(f"{source}:{repo}/{vocab_path}" if repo else f"{source}:{vocab_path}")
try:
return dataset_loader.load_csv(repo, vocab_path, source=source)
except Exception as e: # noqa: BLE001 โ€” ์›์ธ์„ KnowledgeAssetError ์— ์‹ค์–ด ๋‹ค์‹œ ๋˜์ง„๋‹ค
logger.debug("vocabulary load failed (%s): %s", vocab_path, e)
else:
searched.append("(dataset_loader ์—†์Œ)")
raise KnowledgeAssetError(vocab_path, searched)
def apply_pronoun_postprocess(text: str, replacements: dict[str, str]) -> str:
"""๋Œ€๋ช…์‚ฌ/์งํ•จ ์น˜ํ™˜ + ํ•œ๊ตญ์–ด ์กฐ์‚ฌ ๊ต์ •.
๋‹จ์–ด๋ฅผ ์น˜ํ™˜ํ•  ๋•Œ(์˜ˆ: "์ด์žฌ๋ช… ๋Œ€ํ‘œ" โ†’ "์ด์žฌ๋ช… ๋Œ€ํ†ต๋ น") ๋’ค๋”ฐ๋ฅด๋Š” ์กฐ์‚ฌ๋„
์ƒˆ ๋‹จ์–ด์˜ ๋ฐ›์นจ์— ๋งž์ถฐ ๋ฐ”๊ฟ”์•ผ ํ•œ๋‹ค.
"""
# ๋ชจ์Œ์œผ๋กœ ๋๋‚˜๋Š” ์กฐ์‚ฌ โ†’ ์ž์Œ์œผ๋กœ ๋๋‚˜๋Š” ๋‹จ์–ด ๋’ค์— ๋ถ™๋Š” ๋Œ€์‘ ์กฐ์‚ฌ
correction_map = {
"๋Š”": "์€",
"๊ฐ€": "์ด",
"๋ฅผ": "์„",
"์™€": "๊ณผ",
"๋กœ": "์œผ๋กœ",
"์—ฌ": "์ด์—ฌ",
"๋ผ": "์ด๋ผ",
"๋ž‘": "์ด๋ž‘",
"๋‹ค": "์ด๋‹ค",
"์˜€๋‹ค": "์ด์—ˆ๋‹ค",
"๋ผ๋ฉด": "์ด๋ผ๋ฉด",
"๋ผ์„œ": "์ด๋ผ์„œ",
"๋กœ๋ถ€ํ„ฐ": "์œผ๋กœ๋ถ€ํ„ฐ",
}
all_particles = (
list(correction_map.keys()) + list(correction_map.values()) + ["๋งŒ", "๋„", "๊ป˜์„œ"]
)
particle_pattern = "|".join(re.escape(p) for p in sorted(all_particles, key=len, reverse=True))
for old, new in replacements.items():
regex = re.compile(f"({re.escape(old)})({particle_pattern})?")
def replacer(m: re.Match, new_word: str = new) -> str:
particle = m.group(2)
if particle:
return new_word + correction_map.get(particle, particle)
return new_word
text = regex.sub(replacer, text)
return text
def apply_currency_compact(text: str) -> str:
"""ํ†ตํ™” ํ‘œํ˜„์—์„œ ์ˆซ์ž์™€ ๋‹จ์œ„ ์‚ฌ์ด ๊ณต๋ฐฑ์„ ์ œ๊ฑฐํ•œ๋‹ค."""
_unit = r"(์›|๋ช…|๊ฐœ|๋Œ€|์œ„์•ˆ|๋‹ฌ๋Ÿฌ|์œ ๋กœ|์—”|ํ†ค|๊ถŒ|๊ฑด|ํšŒ|์žฅ|๋งˆ๋ฆฌ|๊ทธ๋ฃจ|์†ก์ด|ํ•„)"
_tail = r"(?=[\s.,!?()\"\'๊ฐ€-ํžฃ]|$)"
# "3์–ต 5000๋งŒ ์›" โ†’ "3์–ต5000๋งŒ์›"
text = re.sub(
r"(\d+(?:\.\d+)?)(๋งŒ|์–ต|์กฐ)\s*(\d+)\s*" + _unit + _tail,
r"\1\2\3\4",
text,
)
# "3์–ต ์›" โ†’ "3์–ต์›"
text = re.sub(
r"(\d+(?:\.\d+)?)(๋งŒ|์–ต|์กฐ)\s*" + _unit + _tail,
r"\1\2\3",
text,
)
return text
def apply_comma_removal(text: str) -> str:
"""์ˆซ์ž ์‚ฌ์ด์˜ ์ฒœ๋‹จ์œ„ ์ฝค๋งˆ๋ฅผ ์ œ๊ฑฐํ•œ๋‹ค (1,000 โ†’ 1000)."""
return re.sub(r"(?<=\d),(?=\d)", "", text)
def apply_correction_filter(text: str, config: dict[str, Any], original: str = "") -> str:
"""Filter corrections by comparing with original text.
Keeps only 'safe' corrections (spacing changes, small character diffs).
Reverts larger changes that are likely unnecessary expression substitutions.
Config options:
max_char_diff: Maximum character differences to allow (default: 2)
allow_spacing: Keep spacing-only changes (default: true)
"""
from solar_eval.evaluators.scoring import compute_lcs
if not original:
return text
max_cd = config.get("max_char_diff", 2)
allow_spacing = config.get("allow_spacing", True)
orig_tokens = original.split()
pred_tokens = text.split()
if orig_tokens == pred_tokens:
return text
lcs_pairs = compute_lcs(orig_tokens, pred_tokens)
def is_safe(o_text: str, c_text: str) -> bool:
if allow_spacing and o_text.replace(" ", "") == c_text.replace(" ", ""):
return True
if abs(len(o_text) - len(c_text)) <= 1:
d = sum(1 for a, b in zip(o_text, c_text) if a != b)
d += abs(len(o_text) - len(c_text))
if d <= max_cd:
return True
return False
result_tokens: list[str] = []
prev_orig = -1
prev_pred = -1
for oi, pi in lcs_pairs:
orig_gap = orig_tokens[prev_orig + 1 : oi]
pred_gap = pred_tokens[prev_pred + 1 : pi]
if orig_gap or pred_gap:
o_text = " ".join(orig_gap)
p_text = " ".join(pred_gap)
if o_text == p_text or is_safe(o_text, p_text):
result_tokens.extend(pred_gap)
else:
result_tokens.extend(orig_gap)
result_tokens.append(orig_tokens[oi])
prev_orig = oi
prev_pred = pi
orig_tail = orig_tokens[prev_orig + 1 :]
pred_tail = pred_tokens[prev_pred + 1 :]
if orig_tail or pred_tail:
o_text = " ".join(orig_tail)
p_text = " ".join(pred_tail)
if o_text == p_text or is_safe(o_text, p_text):
result_tokens.extend(pred_tail)
else:
result_tokens.extend(orig_tail)
return " ".join(result_tokens)
def apply_judge_decisions(text: str, config: dict[str, Any], sample: EvalSample) -> str:
"""`tool_calling_judge` ์˜ verdict="revert" ํŒ์ •์„ ํ…์ŠคํŠธ์— ๋˜๋Œ๋ฆฐ๋‹ค.
`judge_step`(๊ธฐ๋ณธ "tool_calling_judge")์˜ ํŒ์ • ๋ชฉ๋ก๊ณผ `corrections_step`
(๊ธฐ๋ณธ "basic_correction")์˜ corrections ๋ชฉ๋ก์„ `sample.artifacts` ์—์„œ ์ฝ๋Š”๋‹ค
โ€” ์ด ๋‘ artifact ๋Š” ๊ฐ๊ฐ `pipelines/judge.py::run_tool_calling_judge` ์™€
`MultiStepPipeline` ์˜ self-consistency ๊ฒฝ๋กœ๊ฐ€ ์ฑ„์šด๋‹ค. ์–ด๋А ํ•œ์ชฝ์ด๋ผ๋„ ๋น„์–ด
์žˆ์œผ๋ฉด(judge ์Šคํ…์ด ์—†์—ˆ๊ฑฐ๋‚˜ corrections ๊ฐ€ ํ•˜๋‚˜๋„ ์—†์—ˆ๋˜ ๊ฒฝ์šฐ) ์กฐ์šฉํžˆ
์›๋ฌธ(text)์„ ๊ทธ๋Œ€๋กœ ๋Œ๋ ค์ค€๋‹ค โ€” revert ํ•  ๋Œ€์ƒ ์ž์ฒด๊ฐ€ ์—†๋Š” ์ •์ƒ ์ƒํƒœ๋‹ค.
HF ๊ตฌํ˜„(`huggingface/pipelines_v24.py::apply_judge_decisions_for_bulk`)์„
ํฌํŒ…. bulk ๋‹จ์œ„์˜€๋˜ ์›๋ณธ๊ณผ ๋‹ฌ๋ฆฌ ์—ฌ๊ธฐ์„œ๋Š” ๋ฌธ์„œ ์ „์ฒด text ์— ์ ์šฉํ•œ๋‹ค
(ยง5-F ์„ค๊ณ„ ๊ฒฐ์ • โ€” ์ด ์—”์ง„์€ tool_calling_judge/basic_correction ์„ ์ด๋ฏธ
๋ฌธ์„œ ๋‹จ์œ„๋กœ ์ฒ˜๋ฆฌํ•˜๋ฏ€๋กœ bulk ์ •๋ ฌ ๋ฌธ์ œ ์ž์ฒด๊ฐ€ ์—†๋‹ค).
"""
judge_step = config.get("judge_step", "tool_calling_judge")
corrections_step = config.get("corrections_step", "basic_correction")
decisions = sample.artifacts.get("judge_decisions", {}).get(judge_step, [])
corrections = sample.artifacts.get("corrections", {}).get(corrections_step, [])
if not decisions or not corrections:
return text
revert_pairs: list[tuple[str, str]] = []
for d in decisions:
if not isinstance(d, dict) or d.get("verdict") != "revert":
continue
idx = d.get("correction_index")
if not isinstance(idx, int) or idx < 0 or idx >= len(corrections):
continue
entry = corrections[idx]
if not isinstance(entry, dict):
continue
corrected = str(entry.get("corrected", ""))
original = str(entry.get("original", ""))
if corrected and original and corrected != original:
revert_pairs.append((corrected, original))
# ๊ธด ๊ฒƒ๋ถ€ํ„ฐ ๋˜๋Œ๋ ค์•ผ ์งง์€ ๊ต์ •์ด ๊ธด ๊ต์ •์˜ ๋ถ€๋ถ„ ๋ฌธ์ž์—ด์ผ ๋•Œ ์ž˜๋ชป ๋˜๋Œ๋ฆฌ์ง€ ์•Š๋Š”๋‹ค.
revert_pairs.sort(key=lambda p: -len(p[0]))
for corrected, original in revert_pairs:
if corrected in text:
text = text.replace(corrected, original, 1)
return text
def apply_change_rate_guard(text: str, original: str, config: dict[str, Any]) -> str:
"""์›๋ฌธ ๋Œ€๋น„ ํ† ํฐ ๋ณ€๊ฒฝ ๋น„์œจ์ด `max_change_rate` ๋ฅผ ๋„˜์œผ๋ฉด ์›๋ฌธ์œผ๋กœ ๋˜๋Œ๋ฆฌ๋Š” ์•ˆ์ „๋ง.
v17 ์ธ์‚ฌ์ดํŠธ ๋ฌธ์„œ(`insights/260504_solar-pro2_essentials.md`)๊ฐ€ "change_rate_guard
(rule, bulk-level safety net, max_change_rate: 0.45)" ๋ผ๊ณ ๋งŒ ์„ค๋ช…ํ•˜๊ณ , ์‹ค์ œ
๊ตฌํ˜„์€ HF ์•„์นด์ด๋ธŒ(`huggingface/pipelines.py`/`pipelines_v24.py`) ์–ด๋””์—๋„ ์—†๋‹ค
(๋ ˆํฌ ์ „์ฒด ๊ฒ€์ƒ‰์œผ๋กœ ํ™•์ธ). ๊ทธ๋ž˜์„œ ์ด ํ•จ์ˆ˜๋Š” ๊ทธ ์„ค๋ช…๋งŒ ๋ณด๊ณ  ์ƒˆ๋กœ ์ž‘์„ฑํ•œ ๊ฒƒ์ด๋‹ค
โ€” v17 ์ˆ˜์น˜ ์žฌํ˜„์„ ์•ฝ์†ํ•˜์ง€ ์•Š๋Š”๋‹ค๋Š” ์ „์ œ๋กœ ํฌํŒ… ๋Œ€์ƒ์— ํฌํ•จ๋๋‹ค
(`.agents/01-plans/harness/2026-08-09-chosun-proofread-evalsample-migration.md` ยง9-3).
๋ณ€๊ฒฝ ๋น„์œจ์€ `evaluators/scoring.compute_lcs` ๋กœ ์›๋ฌธยทํ˜„์žฌ ํ…์ŠคํŠธ์˜ ํ† ํฐ ๋‹จ์œ„
LCS ๋ฅผ ๊ตฌํ•ด `1 - |LCS| / |์›๋ฌธ ํ† ํฐ|` ์œผ๋กœ ์ •์˜ํ•œ๋‹ค โ€” ์ด ํ”„๋กœ์ ํŠธ์˜ ๋‹ค๋ฅธ ๋ชจ๋“ 
๊ต์—ด diff ๋กœ์ง(`compute_proofread_metrics`, `apply_correction_filter`)๊ณผ ๊ฐ™์€
์ฒ™๋„๋ฅผ ์“ด๋‹ค.
"""
from solar_eval.evaluators.scoring import compute_lcs
max_rate = config.get("max_change_rate", 0.45)
orig_tokens = original.split()
if not orig_tokens:
return text
text_tokens = text.split()
lcs_pairs = compute_lcs(orig_tokens, text_tokens)
change_rate = 1 - (len(lcs_pairs) / len(orig_tokens))
if change_rate > max_rate:
logger.warning(
"[change_rate_guard] change_rate=%.3f > max=%.3f, reverting to original",
change_rate,
max_rate,
)
return original
return text
def apply_unit_unicode(text: str) -> str:
"""๋‹จ์œ„ ํ…์ŠคํŠธ๋ฅผ ์œ ๋‹ˆ์ฝ”๋“œ ๊ฒฐํ•ฉ ๋ฌธ์ž๋กœ ๋ณ€ํ™˜ํ•œ๋‹ค."""
replacements = {
"mmยฒ": "ใŽŸ",
"cmยฒ": "ใŽ ",
"mยฒ": "ใŽก",
"kmยฒ": "ใŽข",
"mm": "ใŽœ",
"cm": "ใŽ",
"km": "ใŽž",
"mg": "ใŽŽ",
"kg": "ใŽ",
"ml": "ใŽ–",
"dl": "ใŽ—",
"cc": "ใ„",
}
for k, v in replacements.items():
text = text.replace(k, v)
return text
def load_compound_whitelist(config: dict[str, Any], config_dir: Path | None) -> set[str]:
"""๋ณตํ•ฉ์–ด ํ™”์ดํŠธ๋ฆฌ์ŠคํŠธ(ํƒญ ๊ตฌ๋ถ„ ``ํ‘œ์ œ์–ด\\t๋นˆ๋„``)๋ฅผ ๋กœ๋“œํ•œ๋‹ค.
์„ ์–ธ์ด ์—†์œผ๋ฉด ๋นˆ set(์ •์ƒ์ ์ธ no-op)์ด๊ณ , ์„ ์–ธํ–ˆ๋Š”๋ฐ ๋ชป ์ฝ์œผ๋ฉด ์˜ˆ์™ธ๋‹ค โ€”
`KnowledgeAssetError` ๊ณ„์•ฝ(`errors.py`)์„ ์น˜ํ™˜ ์‚ฌ์ „๊ณผ ๋™์ผํ•˜๊ฒŒ ๋”ฐ๋ฅธ๋‹ค.
ํ™”์ดํŠธ๋ฆฌ์ŠคํŠธ๋Š” ์‚ฌ๋žŒ์ด ํŽธ์ง‘ํ•˜๋Š” ์ง€์‹ ์ž์‚ฐ์ด๋ผ config ์ •๋ณธ(`03-evaluation/`)
๊ธฐ์ค€ ์ƒ๋Œ€๊ฒฝ๋กœ๋งŒ ์ง€์›ํ•œ๋‹ค.
"""
wl_path = config.get("whitelist_path", "")
if not wl_path:
return set()
searched: list[str] = []
if config_dir is not None:
candidate = Path(config_dir) / wl_path
searched.append(str(candidate))
if candidate.is_file():
entries: set[str] = set()
for line in candidate.read_text(encoding="utf-8").splitlines():
form = line.split("\t")[0].strip()
if form:
entries.add(form)
return entries
else:
searched.append("(config_dir ์—†์Œ)")
raise KnowledgeAssetError(wl_path, searched)
def _has_whitelist_prefix(joined: str, split_at: int, whitelist: set[str]) -> bool:
"""๊ฒฐํ•ฉํ˜•์˜ ์ ‘๋‘(๋ถ„๋ฆฌ์  ๋„ˆ๋จธ ~ ์ „์ฒด)๊ฐ€ ํ™”์ดํŠธ๋ฆฌ์ŠคํŠธ ํ‘œ์ œ์–ด์ธ์ง€.
์กฐ์‚ฌยท์–ด๋ฏธ๊ฐ€ ๋ถ™์€ ์–ด์ ˆ("์‚ผ์„ฑ"+"์ „์ž๋Š”")๋„ ํ‘œ์ œ์–ด("์‚ผ์„ฑ์ „์ž")๋กœ ์žก๊ธฐ ์œ„ํ•ด
์ •ํ™• ์ผ์น˜๊ฐ€ ์•„๋‹ˆ๋ผ ์ ‘๋‘ ์ผ์น˜๋ฅผ ๋ณธ๋‹ค. ๋ถ„๋ฆฌ์ (`split_at`) ์•ˆ์ชฝ์—์„œ ๋๋‚˜๋Š”
์ ‘๋‘๋Š” ์ฒซ ์–ด์ ˆ๋งŒ์œผ๋กœ ์„ฑ๋ฆฝํ•˜๋ฏ€๋กœ ํ›„๋ณด์—์„œ ๋บ€๋‹ค.
"""
return any(joined[:k] in whitelist for k in range(split_at + 1, len(joined) + 1))
def apply_compound_whitelist_restore(text: str, original: str, whitelist: set[str]) -> str:
"""`compound_whitelist_restore` rule_type ๊ตฌํ˜„ (v19/v32 ๊ณ„๋ณด์˜ ํ˜„ ์—”์ง„ ํฌํŒ…).
ํŒŒ์ดํ”„๋ผ์ธ(FTยทstep1/2/3)์ด ๊ฐˆ๋ผ๋†“์€ ํ™”์ดํŠธ๋ฆฌ์ŠคํŠธ ๋ณตํ•ฉ์–ด๋ฅผ ๋‹ค์‹œ ๋ถ™์ธ๋‹ค.
์ธ์ ‘ ์–ด์ ˆ ์Œ์˜ ๊ฒฐํ•ฉ์ด (1) ํ™”์ดํŠธ๋ฆฌ์ŠคํŠธ ํ‘œ์ œ์–ด๋ฅผ ์ ‘๋‘๋กœ ๊ฐ–๊ณ  โ€” ๋ถ„๋ฆฌ์ ์ด
ํ‘œ์ œ์–ด ์•ˆ์ชฝ โ€” (2) ์›๋ฌธ์— ๊ทธ๋Œ€๋กœ ์กด์žฌํ•  ๋•Œ๋งŒ ๋ถ™์ธ๋‹ค. ์กฐ๊ฑด (2) ๋•Œ๋ฌธ์— ์ด
๋ฃฐ์€ ํŒŒ์ดํ”„๋ผ์ธ์ด ๋งŒ๋“  ๋ถ„๋ฆฌ๋ฅผ ๋˜๋Œ๋ฆด ๋ฟ, ์ƒˆ ๊ต์ •์„ ๋งŒ๋“ค์ง€ ์•Š๋Š”๋‹ค.
2026-09-02 ์žฌ์ƒ ์‹ค์ธก(combo ์ฒด์ธ ์ถœ๋ ฅ ์œ„): paragraph +2.91 f1
(redundant 408โ†’342), ci-v0 +0.43, ci-v2/ci-v1/yul-ryul ๋ฌด๋ณ€ํ™”.
"""
if not whitelist:
return text
restored_lines: list[str] = []
for line in text.split("\n"):
tokens = line.split()
out: list[str] = []
i = 0
while i < len(tokens):
if i + 1 < len(tokens):
joined = tokens[i] + tokens[i + 1]
if joined in original and _has_whitelist_prefix(joined, len(tokens[i]), whitelist):
out.append(joined)
i += 2
continue
out.append(tokens[i])
i += 1
restored_lines.append(" ".join(out))
return "\n".join(restored_lines)
#: ์œจ/๋ฅ  ์ •๊ทœํ™”๊ฐ€ "๋ช…์‚ฌ + ์กฐ์‚ฌ" ๋ฌธ๋งฅ์œผ๋กœ ์ธ์ •ํ•˜๋Š” ์–ด์ ˆ ๊ผฌ๋ฆฌ(ํ•œ๊ธ€ ์ž”์—ฌ) ๋ชฉ๋ก.
#: ์ด ๋ชฉ๋ก ๋ฐ–์˜ ํ•œ๊ธ€์ด ์ด์–ด์ง€๋ฉด(์˜ˆ: ์ธ๋ช… '๊น€์œจํฌ๊ฐ€'์˜ 'ํฌ๊ฐ€') ๊ฑด๋“œ๋ฆฌ์ง€ ์•Š๋Š”๋‹ค.
_YUL_RYUL_JOSA = frozenset(
{
"์€",
"๋Š”",
"์ด",
"๊ฐ€",
"์„",
"๋ฅผ",
"์˜",
"์—",
"๋„",
"๋งŒ",
"๊ณผ",
"์™€",
"๋กœ",
"์œผ๋กœ",
"๋ณด๋‹ค",
"๋ถ€ํ„ฐ",
"๊นŒ์ง€",
"์กฐ์ฐจ",
"๋งˆ์ €",
"๋ฐ–์—",
"์ฒ˜๋Ÿผ",
"๋Œ€๋กœ",
"๋งŒํผ",
"๋ฟ",
"๋‚˜",
"์ด๋‚˜",
"๋ž€",
"์ด๋ž€",
"๋ผ",
"์ด๋ผ",
"๋ผ๊ณ ",
"์ด๋ผ๊ณ ",
"๋ผ๋Š”",
"์ด๋ผ๋Š”",
"๋ผ๋ฉฐ",
"์ด๋ผ๋ฉฐ",
"๋ฉฐ",
"์ด๋ฉฐ",
"๊ณ ",
"์ด๊ณ ",
"๋‹ค",
"์ด๋‹ค",
"์ž…๋‹ˆ๋‹ค",
"์—์„œ",
"์—๊ฒŒ",
"์—๋Š”",
"์—๋„",
"์—์„œ๋Š”",
"์—์„œ๋„",
"๋กœ๋Š”",
"์œผ๋กœ๋Š”",
"๋กœ๋„",
"์œผ๋กœ๋„",
"๋กœ์„œ",
"์œผ๋กœ์„œ",
"๋กœ์จ",
"์œผ๋กœ์จ",
"์™€์˜",
"๊ณผ์˜",
"๊ณผ๋Š”",
"์™€๋Š”",
"์€์š”",
"์š”",
"๋Œ€๋น„",
}
)
def apply_yul_ryul_normalize(text: str, exceptions: tuple[str, ...] = ()) -> str:
"""`yul_ryul_normalize` rule_type ๊ตฌํ˜„ โ€” ์œจ/๋ฅ  ๋ฐ›์นจ ๊ทœ์น™์˜ ๊ฒฐ์ •๋ก ์  ์ ์šฉ.
ํ•œ๊ธ€ ๋งž์ถค๋ฒ• ์ œ11ํ•ญ: ์•ž ์Œ์ ˆ์— ๋ฐ›์นจ์ด ์—†๊ฑฐ๋‚˜ ใ„ด ๋ฐ›์นจ์ด๋ฉด '์œจ'(๋น„์œจยทํ™˜์œจยท
์ „ํ™˜์œจ), ๊ทธ ์™ธ ๋ฐ›์นจ์ด๋ฉด '๋ฅ '(๋ฒ•๋ฅ ยทํ™•๋ฅ ยท์ถœ์„๋ฅ ). ๋ฐ›์นจ์€ ์œ ๋‹ˆ์ฝ”๋“œ ์—ฐ์‚ฐ์œผ๋กœ
ํŒ์ •๋˜๋ฏ€๋กœ LLM ์—†์ด ์ตœ์ข… ํ…์ŠคํŠธ์—์„œ ๊ฐ•์ œํ•  ์ˆ˜ ์žˆ๋‹ค.
์ธ๋ช… ์˜คํƒ ๋ฐฉ์ง€ 2์ค‘ ๊ฐ€๋“œ:
1. ์–ด์ ˆ ๊ผฌ๋ฆฌ ๊ฒ€์‚ฌ โ€” ์œจ/๋ฅ  ๋’ค์˜ ํ•œ๊ธ€ ์ž”์—ฌ๊ฐ€ ์กฐ์‚ฌ ๋ชฉ๋ก(`_YUL_RYUL_JOSA`)์—
์—†์œผ๋ฉด ์ ‘๋ฏธ์‚ฌ ์šฉ๋ฒ•์ด ์•„๋‹Œ ๊ฒƒ์œผ๋กœ ๋ณด๊ณ  ๊ฑด๋„ˆ๋›ด๋‹ค (์žฌ์ƒ ์‹ค์ธก์˜ ์œ ์ผํ•œ ์˜คํƒ
'๊น€์œจํฌ๊ฐ€'โ†’'๊น€๋ฅ ํฌ๊ฐ€' ๊ฐ€ ์—ฌ๊ธฐ์„œ ๊ฑธ๋Ÿฌ์ง„๋‹ค).
2. `exceptions` โ€” ์–ด์ ˆ์— ํฌํ•จ๋˜๋ฉด ๋ฌด์กฐ๊ฑด ๊ฑด๋„ˆ๋›ฐ๋Š” ๋ฌธ์ž์—ด ๋ชฉ๋ก (์œจ/๋ฅ ๋กœ
๋๋‚˜๋Š” ์ธ๋ช… '๊น€์„์œจ' ๋ฅ˜๋ฅผ ์œ„ํ•œ ํ›…. ๊ธฐ๋ณธ ๋น„์–ด ์žˆ์Œ).
2026-09-02 ์žฌ์ƒ ์‹ค์ธก(v33/combo ์ถœ๋ ฅ ์œ„, ์กฐ์‚ฌ ๊ฐ€๋“œ ํฌํ•จ): yul-ryul ๋†“์นจ 2๊ฑด
์ „๋Ÿ‰ ํšŒ์ˆ˜(+3.07), ci-v2 +0.59, ci-v1 +0.53, paragraphยทci-v0 ๋ณ€๊ฒฝ 0๊ฑด.
"""
out = list(text)
n = len(text)
for i, ch in enumerate(text):
if ch not in ("์œจ", "๋ฅ "):
continue
prev = text[i - 1] if i > 0 else ""
if not ("๊ฐ€" <= prev <= "ํžฃ"):
continue
jong = (ord(prev) - 0xAC00) % 28
want = "์œจ" if jong in (0, 4) else "๋ฅ " # ๋ฐ›์นจ ์—†์Œ(0)/ใ„ด(4) โ†’ ์œจ
if want == ch:
continue
j = i + 1
while j < n and not text[j].isspace():
j += 1
residue = "".join(c for c in text[i + 1 : j] if "๊ฐ€" <= c <= "ํžฃ")
if residue and residue not in _YUL_RYUL_JOSA:
continue
if exceptions:
k = i
while k > 0 and not text[k - 1].isspace():
k -= 1
token = text[k:j]
if any(e in token for e in exceptions):
continue
out[i] = want
return "".join(out)