Protect structural spans during style conversion

Keep arbitrary text links, email addresses, and code spans usable while preserving the deterministic middle-korean-style-v1 prose transform.\n\nConstraint: PR #281 round 3 requested URL/email/code-like span protection after broad global replacement probes rewrote structural tokens.\nRejected: Narrowing all lexicon and particle rules with word-boundary heuristics | would change established v1 creative broad-replacement behavior beyond the reviewed issue.\nConfidence: high\nScope-risk: narrow\nDirective: Protect new structural span classes before broad replacements and add regression tests before extending the protected surface.\nTested: node --test scripts/test_korean_middle_korean.js; node --check korean-middle-korean/scripts/korean_middle_korean.js; node --check scripts/korean_middle_korean.js; CLI URL/email/code/Markdown probes; installed-skill smoke via ~/.agents/skills/korean-middle-korean/scripts/korean_middle_korean.js; npm run lint; npm run typecheck; root/workspace test chain without pip bootstrap; npm run pack:dry-run; post-deslop npm run lint && npm run typecheck && node --test scripts/test_korean_middle_korean.js && npm run pack:dry-run\nNot-tested: direct npm run test remains blocked before repo tests by local Homebrew Python 3.14 pyexpat/libexpat import error.
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-05-23 11:10:54 +09:00
commit 76995658e8
4 changed files with 59 additions and 2 deletions

View file

@ -7,6 +7,7 @@
- `했다`, `하는`, `말하는` 같은 일부 어미를 `ᄒᆞ엿다〮`, `ᄒᆞᄂᆞᆫ`, `ᄆᆞᆯᄒᆞᄂᆞᆫ`처럼 변환
- 날짜 단위를 `年`, `月`, `日`로 변환
- 일부 한자어를 `熱愛說`, `俳優`, `學校`처럼 Hanja 힌트로 변환
- URL, 이메일, Markdown 링크, inline/fenced code span은 구조 토큰으로 보고 변환하지 않음
- 인명·숫자·고유명사는 완전 보존이 아니라, 규칙이 맞지 않을 때 원문을 남기는 best-effort 방식으로 처리
## 왜 별도 스킬이 필요한가
@ -25,6 +26,7 @@ LLM에게 "중세 국어처럼"이라고만 요청하면 변환 강도와 표기
- 그다음 결정론적 lexicon 치환을 적용한다.
- 일부 현대 조사를 중세국어풍 조사로 바꾼다.
- 일부 현대 어미를 `ᄒᆞ-` 계열 중세국어풍 어미로 바꾼다.
- URL, 이메일, Markdown 링크, inline/fenced code span은 먼저 보호한 뒤 마지막에 원문 그대로 복원한다.
- 한자어 힌트는 넓은 전역 치환으로 적용되므로 합성어·고유명사처럼 보이는 문자열 안에서도 바뀔 수 있다.
- 변환하지 못한 내용은 원문 의미 보존을 위해 그대로 둔다.

View file

@ -20,6 +20,7 @@ metadata:
- `했다`, `하는`, `말하는` 같은 현대 어미를 `ᄒᆞ엿다〮`, `ᄒᆞᄂᆞᆫ`, `ᄆᆞᆯᄒᆞᄂᆞᆫ`처럼 바꾼다.
- 날짜 단위는 `年`, `月`, `日`로 바꾼다.
- 일부 한자어/밈 예시는 `熱愛說`, `俳優`, `學校`처럼 Hanja 힌트를 섞는다.
- URL, 이메일, Markdown 링크, inline/fenced code span은 구조 토큰으로 보고 변환하지 않는다.
- 인명·숫자·고유명사는 완전 보존이 아니라, 규칙이 맞지 않을 때 원문을 남기는 best-effort 방식으로 처리한다.
- 한자어 힌트는 넓은 전역 치환으로 적용되므로 합성어·고유명사처럼 보이는 문자열 안에서도 바뀔 수 있다.
@ -46,6 +47,7 @@ metadata:
1. 변환할 한국어 텍스트를 받는다.
2. 설치된 `korean-middle-korean` skill 디렉터리를 기준으로 `node scripts/korean_middle_korean.js` 를 실행한다.
- URL, 이메일, Markdown 링크, inline/fenced code span은 보호되어 원문 그대로 복원된다.
3. 기본 JSON 출력에서 `output`을 사용자에게 반환한다.
4. 사용자가 근거를 원하면 `replacements` 배열의 규칙 적용 내역을 요약한다.
5. 학술적 정확성이 필요하다고 보이면 이 스킬은 창작용 스타일 변환임을 먼저 밝힌다.

View file

@ -5,7 +5,7 @@ const fs = require("node:fs");
const PROFILE = "middle-korean-style-v1";
const CONTRACT =
"Deterministic Korean Middle Korean-style rewrite: public-domain orthographic flavor rules, fixed broad lexicon replacements, archaic particles/endings, Sino-Korean Hanja hints, and best-effort preservation for names/numbers when no rule matches.";
"Deterministic Korean Middle Korean-style rewrite: public-domain orthographic flavor rules, fixed broad lexicon replacements, archaic particles/endings, Sino-Korean Hanja hints, protected URL/email/Markdown-code spans, and best-effort preservation for names/numbers when no rule matches.";
const LEXICON = [
["야 이", "이"],
@ -53,6 +53,35 @@ function replaceRegex(text, pattern, to, replacements, kind, label) {
return next;
}
function protectSpans(input) {
const protectedSpans = [];
let text = input;
function protect(pattern) {
text = text.replace(pattern, (match) => {
const token = `\uE000${protectedSpans.length}\uE001`;
protectedSpans.push(match);
return token;
});
}
protect(/```[\s\S]*?```/g);
protect(/`[^`\n]*`/g);
protect(/\[[^\]\n]*\]\([^\s)]+(?:\s+"[^"]*")?\)/g);
protect(/\bhttps?:\/\/[A-Za-z0-9._~:/?#[\]@!$&'()*+,;=%-]+/g);
protect(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g);
return { text, protectedSpans };
}
function restoreSpans(text, protectedSpans) {
let output = text;
protectedSpans.forEach((span, index) => {
output = output.replaceAll(`\uE000${index}\uE001`, span);
});
return output;
}
function convertToMiddleKoreanStyle(input) {
return createReport(input).output;
}
@ -63,7 +92,8 @@ function createReport(input) {
}
const replacements = [];
let output = input;
const protectedInput = protectSpans(input);
let output = protectedInput.text;
output = replaceRegex(output, /(\d+)년/g, "$1年", replacements, "date", "년→年");
output = replaceRegex(output, /(\d+)월/g, "$1月", replacements, "date", "월→月");
@ -86,6 +116,7 @@ function createReport(input) {
output = replaceRegex(output, /([가-힣ᄀ-ᇿA-Za-z0-9一-龥]+)와(?=\s|[",.?!]|$)/g, "$1와", replacements, "particle", "와 보존");
output = output.replace(/\s+([,.;:?!])/g, "$1");
output = restoreSpans(output, protectedInput.protectedSpans);
return {
profile: PROFILE,

View file

@ -44,12 +44,34 @@ test("documentation and skill describe proper-noun preservation as best effort",
assert.match(docs, /인명·숫자·고유명사는 완전 보존이 아니라/i);
assert.match(docs, /넓은 전역 치환/i);
assert.match(docs, /URL, 이메일, Markdown 링크, inline\/fenced code span은 구조 토큰/i);
assert.match(skill, /인명·숫자·고유명사는 완전 보존이 아니라/i);
assert.match(skill, /넓은 전역 치환/i);
assert.match(skill, /URL, 이메일, Markdown 링크, inline\/fenced code span은 구조 토큰/i);
assert.match(convertToMiddleKoreanStyle("배우자는 학교에서 일했다."), /俳優자/);
});
test("converter preserves URLs, emails, Markdown links, and code spans unchanged", () => {
const input = [
"https://example.com에서 확인했다.",
"contact@example.com은 말했다.",
"[학교에서 보기](https://example.com/학교에서)은 유지했다.",
"`학교에서` 테스트했다.",
"```\n학교에서 공부했다.\n```\n밖에서 공부했다.",
].join("\n");
const output = convertToMiddleKoreanStyle(input);
assert.match(output, /https:\/\/example[.]com에서 확인ᄒᆞ엿다〮[.]/);
assert.match(output, /contact@example[.]com은 말ᄒᆞ엿다〮[.]/);
assert.match(output, /\[학교에서 보기\]\(https:\/\/example[.]com\/학교에서\)은 유지ᄒᆞ엿다〮[.]/);
assert.match(output, /`학교에서` 테스트ᄒᆞ엿다〮[.]/);
assert.match(output, /```\n학교에서 공부했다[.]\n```/);
assert.match(output, /밖애 공부ᄒᆞ엿다〮[.]/);
});
test("createReport exposes deterministic metadata and replacement evidence", () => {
const report = createReport("열애설을 인정했다.");