Feature/#270 (#281)

* Enable deterministic Middle Korean-style rewriting

Constraint: Issue #270 requested a new skill that converts incoming Korean text into 한국 중세 국어 style under non-interactive TDD automation.
Rejected: LLM-only prompt guidance | It would not provide deterministic CLI behavior or regression-testable output.
Confidence: high
Scope-risk: narrow
Directive: Keep this as creative style conversion, not an academically exact Middle Korean translator.
Tested: node --test scripts/test_korean_middle_korean.js; npm run lint; npm run typecheck; root node/python/workspace tests without pip bootstrap; npm run pack:dry-run; installed-skill smoke.
Not-tested: npm run test bootstrap step because python3 -m pip fails in this local Homebrew Python 3.14 environment due pyexpat/libexpat symbol mismatch before tests start.

* Align Middle Korean profile contract with implementation

Preserve the existing date-before-lexicon transform order and document it as the v1 contract instead of reordering an already-reviewed helper.

Constraint: PR #281 review requested the docs/contract mismatch be resolved with TDD evidence.

Rejected: Reordering the converter | would alter current output behavior beyond the approved follow-up.

Confidence: high

Scope-risk: narrow

Directive: Treat middle-korean-style-v1 output-changing rule order edits as contract changes that need regression tests and docs updates.

Tested: node --test scripts/test_korean_middle_korean.js; npm run lint; npm run typecheck; npm run pack:dry-run; npm run test without pip bootstrap commands; installed-skill smoke.

Not-tested: npm run test direct bootstrap remains blocked locally by Homebrew Python 3.14 pyexpat/libexpat symbol mismatch.

* Clarify middle Korean profile stability

Align the documented v1 contract with the intentionally broad deterministic replacer so future readers do not infer exact proper-noun preservation.

Constraint: PR #281 round 2 architect WATCH asked to weaken preservation guarantees or add stronger rule boundaries.

Rejected: Changing replacement behavior | The PR already verified the creative v1 output and only the contract wording was mismatched.

Confidence: high

Scope-risk: narrow

Directive: Treat output-changing edits to middle-korean-style-v1 as compatibility-affecting and update docs plus regression tests together.

Tested: 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; npm run lint; npm run typecheck; root/workspace post-bootstrap test chain; npm run pack:dry-run; installed skill smoke.

Not-tested: Direct npm run test still blocked before tests by local Homebrew Python 3.14 pyexpat/libexpat symbol mismatch.

* 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 17:54:56 +09:00 committed by GitHub
commit 45084293f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 563 additions and 2 deletions

View file

@ -107,6 +107,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 네이버 뉴스 검색 | `naver-news-search` | 네이버 검색 Open API 뉴스 검색으로 기사 제목·요약·발행시각·원문/네이버 링크를 정리 | 불필요 | [네이버 뉴스 검색 가이드](docs/features/naver-news-search.md) |
| 한국어 글자 수 세기 | `korean-character-count` | 한국어 텍스트의 글자 수·줄 수·UTF-8/NEIS byte 수를 결정론적으로 계산 | 불필요 | [한국어 글자 수 세기 가이드](docs/features/korean-character-count.md) |
| 한국어 유행어 글쓰기 | `korean-slang-writing` | 나무위키 유행어 기반 큐레이션 시드로 한국 유행어 후보 조회, 무드/문맥/safety 필터 및 나무위키 best-effort 요약으로 한국어 글을 유행어 느낌으로 작성 | 불필요 | [한국어 유행어 글쓰기 가이드](docs/features/korean-slang-writing.md) |
| 한국 중세 국어풍 변환 | `korean-middle-korean` | 한국어 입력문을 중세국어풍 조사·어미·Hanja 힌트·성조점이 섞인 창작용 문체로 결정론적 변환 | 불필요 | [한국 중세 국어풍 변환 가이드](docs/features/korean-middle-korean.md) |
| K-스킬 클리너 | `k-skill-cleaner` | 인터뷰와 코딩 에이전트별 트리거 횟수 통계를 합쳐 불필요한 K-스킬 삭제 후보를 추천 | 불필요 | [K-스킬 클리너 가이드](docs/features/k-skill-cleaner.md) |
> ## ⚠️ 근처 블루리본 맛집 스킬 — 지원 중단
@ -224,6 +225,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [네이버 뉴스 검색 가이드](docs/features/naver-news-search.md)
- [한국어 글자 수 세기 가이드](docs/features/korean-character-count.md)
- [한국어 유행어 글쓰기 가이드](docs/features/korean-slang-writing.md)
- [한국 중세 국어풍 변환 가이드](docs/features/korean-middle-korean.md)
- [K-스킬 클리너 가이드](docs/features/k-skill-cleaner.md)
- [릴리스/배포 가이드](docs/releasing.md)

View file

@ -0,0 +1,86 @@
# 한국 중세 국어풍 변환 가이드
## 이 기능으로 할 수 있는 일
- 한국어 입력문을 창작용 **중세국어풍 문체**로 변환
- `은/는`, `을/를`, `에서` 같은 일부 조사를 `ᄋᆞᆫ`, `ᄋᆞᆯ`, `애`처럼 변환
- `했다`, `하는`, `말하는` 같은 일부 어미를 `ᄒᆞ엿다〮`, `ᄒᆞᄂᆞᆫ`, `ᄆᆞᆯᄒᆞᄂᆞᆫ`처럼 변환
- 날짜 단위를 `年`, `月`, `日`로 변환
- 일부 한자어를 `熱愛說`, `俳優`, `學校`처럼 Hanja 힌트로 변환
- URL, 이메일, Markdown 링크, inline/fenced code span은 구조 토큰으로 보고 변환하지 않음
- 인명·숫자·고유명사는 완전 보존이 아니라, 규칙이 맞지 않을 때 원문을 남기는 best-effort 방식으로 처리
## 왜 별도 스킬이 필요한가
LLM에게 "중세 국어처럼"이라고만 요청하면 변환 강도와 표기가 매번 달라진다. 이 스킬은 밈/창작용 변환에서 필요한 최소 계약을 고정한다.
- 동일 입력은 동일 출력으로 변환한다.
- 어떤 규칙이 적용됐는지 `replacements` 배열로 확인할 수 있다.
- 학술적 복원이 아니라 스타일 변환임을 문서화한다.
## 기본 계약
프로필은 `middle-korean-style-v1`이다.
- 날짜 단위 정규화를 먼저 적용한다. `2015년 7월 21일``2015年 7月 21日`처럼 바뀐다.
- 그다음 결정론적 lexicon 치환을 적용한다.
- 일부 현대 조사를 중세국어풍 조사로 바꾼다.
- 일부 현대 어미를 `ᄒᆞ-` 계열 중세국어풍 어미로 바꾼다.
- URL, 이메일, Markdown 링크, inline/fenced code span은 먼저 보호한 뒤 마지막에 원문 그대로 복원한다.
- 한자어 힌트는 넓은 전역 치환으로 적용되므로 합성어·고유명사처럼 보이는 문자열 안에서도 바뀔 수 있다.
- 변환하지 못한 내용은 원문 의미 보존을 위해 그대로 둔다.
`middle-korean-style-v1`의 출력 변경은 호환성에 영향을 주는 계약 변경으로 본다. 새 규칙을 추가하거나 순서를 바꿀 때는 회귀 테스트와 문서 예시를 함께 갱신한다.
## CLI 사용 예시
### 기본 JSON 출력
```bash
node scripts/korean_middle_korean.js --text "민수는 3월 5일 학교에서 공부했다."
```
예상 출력 일부:
```json
{
"profile": "middle-korean-style-v1",
"input": "민수는 3월 5일 학교에서 공부했다.",
"output": "민수ᄋᆞᆫ 3月 5日 學校애 공부ᄒᆞ엿다〮.",
"replacements": [
{ "kind": "date", "from": "월→月", "to": "$1月", "count": 1 }
]
}
```
### 변환문만 출력
```bash
node scripts/korean_middle_korean.js --text "열애설을 인정했다." --format text
```
예상 출력:
```text
熱愛說ᄋᆞᆯ 인졍ᄒᆞ엿다〮.
```
### 파일/stdin 입력
```bash
node scripts/korean_middle_korean.js --file ./input.txt --format text
cat input.txt | node scripts/korean_middle_korean.js --stdin --format json
```
## 응답 원칙
- 결과는 `output` 필드를 중심으로 전달한다.
- "정확한 중세국어 번역"이 아니라 "중세국어풍/창작용 변환"이라고 설명한다.
- 사용자가 학술적 정확성을 요구하면 이 스킬의 한계를 먼저 알리고, 전문 고문헌 검토가 필요하다고 안내한다.
## 검증
```bash
node --test scripts/test_korean_middle_korean.js
node scripts/korean_middle_korean.js --text "민수는 3월 5일 학교에서 공부했다." --format text
```

View file

@ -0,0 +1,89 @@
---
name: korean-middle-korean
description: Convert incoming Korean text into a deterministic Korean Middle Korean-style rewrite with archaic particles, endings, Hanja hints, and tone-mark flavor.
license: MIT
metadata:
category: writing
locale: ko-KR
phase: v1
---
# Korean Middle Korean Style Converter
## What this skill does
사용자가 한국어 문장을 "한국 중세 국어처럼", "훈민정음/중세국어 느낌으로", "옛 국어 밈체로" 바꾸어 달라고 할 때, 입력문을 **결정론적 Middle Korean-style 문체**로 바꾼다.
이 스킬은 학술적 복원이 아니라 창작용 스타일 변환이다.
- 현대 한국어 조사 일부를 `ᄋᆞᆫ`, `ᄋᆞᆯ`, `애` 같은 중세국어풍 표기로 바꾼다.
- `했다`, `하는`, `말하는` 같은 현대 어미를 `ᄒᆞ엿다〮`, `ᄒᆞᄂᆞᆫ`, `ᄆᆞᆯᄒᆞᄂᆞᆫ`처럼 바꾼다.
- 날짜 단위는 `年`, `月`, `日`로 바꾼다.
- 일부 한자어/밈 예시는 `熱愛說`, `俳優`, `學校`처럼 Hanja 힌트를 섞는다.
- URL, 이메일, Markdown 링크, inline/fenced code span은 구조 토큰으로 보고 변환하지 않는다.
- 인명·숫자·고유명사는 완전 보존이 아니라, 규칙이 맞지 않을 때 원문을 남기는 best-effort 방식으로 처리한다.
- 한자어 힌트는 넓은 전역 치환으로 적용되므로 합성어·고유명사처럼 보이는 문자열 안에서도 바뀔 수 있다.
## When to use
- "이 문장을 한국 중세 국어로 바꿔줘"
- "훈민정음 느낌 나는 밈 문장으로 변환해줘"
- "중세국어풍으로 농담을 써줘"
- "아래 글을 옛 국어 말투로 바꿔줘"
## When NOT to use
- 학술 논문, 고문헌 번역, 훈민정음 해례본식 엄밀 표기가 필요한 작업
- 법률·의학·계약 문서처럼 의미 오해가 위험한 문서
- 혐오·괴롭힘·명예훼손 목적의 조롱성 변환
## Prerequisites
- `node` 18+
- 설치된 `korean-middle-korean` skill 디렉터리 안에 `scripts/korean_middle_korean.js` helper 포함
- 별도 API 키 없음
## Workflow
1. 변환할 한국어 텍스트를 받는다.
2. 설치된 `korean-middle-korean` skill 디렉터리를 기준으로 `node scripts/korean_middle_korean.js` 를 실행한다.
- URL, 이메일, Markdown 링크, inline/fenced code span은 보호되어 원문 그대로 복원된다.
3. 기본 JSON 출력에서 `output`을 사용자에게 반환한다.
4. 사용자가 근거를 원하면 `replacements` 배열의 규칙 적용 내역을 요약한다.
5. 학술적 정확성이 필요하다고 보이면 이 스킬은 창작용 스타일 변환임을 먼저 밝힌다.
## CLI examples
```bash
node scripts/korean_middle_korean.js --text "민수는 3월 5일 학교에서 공부했다."
node scripts/korean_middle_korean.js --text "열애설을 인정했다." --format text
cat input.txt | node scripts/korean_middle_korean.js --stdin --format json
node scripts/korean_middle_korean.js --file ./input.txt --format text
```
## Response policy
- `output`을 중심으로 답한다.
- "정확한 중세국어 번역"이라고 단정하지 말고, "중세국어풍/창작용 변환"이라고 설명한다.
- 사용자가 원문 의미 보존을 중요하게 말하면, 변환문 뒤에 "의미 보존 확인"을 짧게 덧붙인다.
## Output schema
```json
{
"profile": "middle-korean-style-v1",
"input": "열애설을 인정했다.",
"output": "熱愛說ᄋᆞᆯ 인졍ᄒᆞ엿다〮.",
"replacements": [
{ "kind": "lexicon", "from": "열애설", "to": "熱愛說", "count": 1 }
],
"contract": "Deterministic Korean Middle Korean-style rewrite..."
}
```
## Done when
- `node scripts/korean_middle_korean.js --help` 가 동작한다.
- `--text`, `--file`, `--stdin` 입력이 모두 동작한다.
- JSON과 text 출력이 모두 동작한다.
- 이슈 #270의 예시처럼 날짜/Hanja/중세국어풍 조사·어미·성조점이 나타난다.

View file

@ -0,0 +1,214 @@
#!/usr/bin/env node
"use strict";
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, protected URL/email/Markdown-code spans, and best-effort preservation for names/numbers when no rule matches.";
const LEXICON = [
["야 이", "이"],
["맛국노야", "맛國노〮야"],
["설마", "쇼ᄆᆞ"],
["새벽", "샛ᄇᆡ긔〮"],
["배우", "俳優"],
["구자욱이랑", "구자욱과"],
["거리", "街里"],
["손잡고", "손ᄋᆞᆯ 자ᇙ고"],
["걸어다니는", "거러다니ᄂᆞᆫ"],
["모습", "모ᄉᆡᆸ〮"],
["찍혀", "찍히야"],
["열애설", "熱愛說"],
["터지고", "터ᄂᆞᆺ고"],
["인정했지만", "인졍ᄒᆞ엿거ᄂᆞᆫ"],
["인정했다", "인졍ᄒᆞ엿다〮"],
["인정", "인졍"],
["맛보기한", "맛보기〮ᄒᆞᆫ"],
["느낌이랄까", "닏믁이ᄅᆞᆯ가〯"],
["기분이구나", "기븐〮이로다"],
["말해", "ᄆᆞᆯᄒᆞ야"],
["사건", "일"],
["학교", "學校"],
];
function record(replacements, kind, from, to, count) {
if (count > 0) {
replacements.push({ kind, from, to, count });
}
}
function replaceLiteral(text, from, to, replacements) {
const count = text.split(from).length - 1;
if (count === 0) return text;
record(replacements, "lexicon", from, to, count);
return text.split(from).join(to);
}
function replaceRegex(text, pattern, to, replacements, kind, label) {
const matches = text.match(pattern);
const count = matches ? matches.length : 0;
const next = text.replace(pattern, to);
record(replacements, kind, label ?? String(pattern), typeof to === "string" ? to : "<rule>", count);
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;
}
function createReport(input) {
if (typeof input !== "string") {
throw new TypeError("input must be a string");
}
const replacements = [];
const protectedInput = protectSpans(input);
let output = protectedInput.text;
output = replaceRegex(output, /(\d+)년/g, "$1年", replacements, "date", "년→年");
output = replaceRegex(output, /(\d+)월/g, "$1月", replacements, "date", "월→月");
output = replaceRegex(output, /(\d+)일/g, "$1日", replacements, "date", "일→日");
for (const [from, to] of LEXICON) {
output = replaceLiteral(output, from, to, replacements);
}
output = replaceRegex(output, /말하는/g, "ᄆᆞᆯᄒᆞᄂᆞᆫ", replacements, "ending", "말하는→ᄆᆞᆯᄒᆞᄂᆞᆫ");
output = replaceRegex(output, /공부했다〮?/g, "공부ᄒᆞ엿다〮", replacements, "ending", "공부했다→공부ᄒᆞ엿다〮");
output = replaceRegex(output, /했다〮?/g, "ᄒᆞ엿다〮", replacements, "ending", "했다→ᄒᆞ엿다〮");
output = replaceRegex(output, /하는(?=\s|[",.?!]|$)/g, "ᄒᆞᄂᆞᆫ", replacements, "ending", "하는→ᄒᆞᄂᆞᆫ");
output = replaceRegex(output, /된(?=\s|[",.?!]|$)/g, "ᄃᆞᆫ", replacements, "ending", "된→ᄃᆞᆫ");
output = replaceRegex(output, /것이냐(?=[.?!。]|$)/g, "것이냐〮", replacements, "ending", "것이냐→것이냐〮");
output = replaceRegex(output, /([가-힣ᄀ-ᇿA-Za-z0-9一-龥]+)(은|는)(?=\s|[",.?!]|$)/g, "$1ᄋᆞᆫ", replacements, "particle", "은/는→ᄋᆞᆫ");
output = replaceRegex(output, /([가-힣ᄀ-ᇿA-Za-z0-9一-龥]+)(을|를)(?=\s|[",.?!]|$)/g, "$1ᄋᆞᆯ", replacements, "particle", "을/를→ᄋᆞᆯ");
output = replaceRegex(output, /([가-힣ᄀ-ᇿA-Za-z0-9一-龥]+)에서(?=\s|[",.?!]|$)/g, "$1애", replacements, "particle", "에서→애");
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,
input,
output,
replacements,
contract: CONTRACT,
};
}
function parseArgs(argv) {
const parsed = {
format: "json",
inputMode: null,
text: undefined,
};
let sourceCount = 0;
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--text") {
sourceCount += 1;
parsed.inputMode = "text";
parsed.text = argv[++index];
if (parsed.text === undefined) throw new Error("--text requires a value");
} else if (arg === "--file") {
sourceCount += 1;
parsed.inputMode = "file";
parsed.file = argv[++index];
if (!parsed.file) throw new Error("--file requires a path");
} else if (arg === "--stdin") {
sourceCount += 1;
parsed.inputMode = "stdin";
} else if (arg === "--format") {
parsed.format = argv[++index];
if (!parsed.format) throw new Error("--format requires a value");
} else if (arg === "--help" || arg === "-h") {
parsed.help = true;
} else {
throw new Error(`unknown argument: ${arg}`);
}
}
if (!parsed.help && sourceCount !== 1) {
throw new Error("provide exactly one input source: --text, --file, or --stdin");
}
if (!["json", "text"].includes(parsed.format)) {
throw new Error(`unknown format: ${parsed.format}`);
}
return parsed;
}
function readInput(options) {
if (options.inputMode === "text") return options.text;
if (options.inputMode === "file") return fs.readFileSync(options.file, "utf8");
if (options.inputMode === "stdin") return fs.readFileSync(0, "utf8");
throw new Error("missing input source");
}
function helpText() {
return `Usage: node scripts/korean_middle_korean.js (--text TEXT | --file PATH | --stdin) [--format json|text]\n\nConverts Korean input into a deterministic Korean Middle Korean-style rewrite.\n`;
}
function main(argv = process.argv.slice(2)) {
const options = parseArgs(argv);
if (options.help) {
process.stdout.write(helpText());
return;
}
const report = createReport(readInput(options));
if (options.format === "text") {
process.stdout.write(`${report.output}\n`);
} else {
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
}
}
if (require.main === module) {
try {
main();
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
module.exports = {
CONTRACT,
PROFILE,
convertToMiddleKoreanStyle,
createReport,
parseArgs,
main,
};

View file

@ -10,9 +10,9 @@
"scripts": {
"build": "npm run build --workspaces --if-present",
"build:manus-bundle": "node scripts/build-manus-bundle.js",
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js scripts/build-manus-bundle.js scripts/test_build_manus_bundle.js scripts/workflow-actions.test.js && python3 -m py_compile scripts/k_skill_cleaner.py scripts/test_k_skill_cleaner.py corporate-registration-consulting/scripts/fill_official_hwp.py k-skill-cleaner/scripts/k_skill_cleaner.py scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/nts_business_registration.py scripts/test_nts_business_registration.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py scripts/geeknews_search.py scripts/test_geeknews_search.py nts-business-registration/scripts/nts_business_registration.py scripts/test_naver_blog_search.py scripts/test_korean_slang_writing.py scripts/kakaotalk_mac.py scripts/test_kakaotalk_mac.py scripts/test_coupang_partners_mcp_wrapper.py scripts/test_ohou_today_deal.py scripts/ticket_availability.py scripts/test_ticket_availability.py scripts/test_danawa_price_search.py ticket-availability/scripts/ticket_availability.py coupang-product-search/scripts/coupang_partners_mcp.py ohou-today-deal/scripts/ohou_today_deal.py kakaotalk-mac/scripts/kakaotalk_mac.py naver-blog-research/scripts/_naver_http.py naver-blog-research/scripts/naver_search.py naver-blog-research/scripts/naver_read.py naver-blog-research/scripts/naver_download_images.py korean-slang-writing/scripts/_slang_http.py korean-slang-writing/scripts/slang_search.py korean-slang-writing/scripts/slang_lookup.py korean-scholarship-search/scripts/scholarship_filter.py korean-scholarship-search/scripts/test_scholarship_filter.py korean-scholarship-search/scripts/university_search_plan.py seoul-bike/scripts/seoul_bike.py scripts/test_seoul_bike.py danawa-price-search/scripts/danawa_search.py kosis-stats/scripts/run_kosis_stats.py kosis-stats/tests/test_run_kosis_stats.py kstartup-search/scripts/run_kstartup.py kstartup-search/tests/test_run_kstartup.py intercity-bus-booking/scripts/intercity_bus_search.py daangn-used-goods-search/scripts/daangn_used_goods.py daangn-realty-search/scripts/daangn_realty.py daangn-jobs-search/scripts/daangn_jobs.py daangn-cars-search/scripts/daangn_cars.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js scripts/korean_middle_korean.js scripts/test_korean_middle_korean.js scripts/build-manus-bundle.js scripts/test_build_manus_bundle.js scripts/workflow-actions.test.js && python3 -m py_compile scripts/k_skill_cleaner.py scripts/test_k_skill_cleaner.py corporate-registration-consulting/scripts/fill_official_hwp.py k-skill-cleaner/scripts/k_skill_cleaner.py scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/nts_business_registration.py scripts/test_nts_business_registration.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py scripts/geeknews_search.py scripts/test_geeknews_search.py nts-business-registration/scripts/nts_business_registration.py scripts/test_naver_blog_search.py scripts/test_korean_slang_writing.py scripts/kakaotalk_mac.py scripts/test_kakaotalk_mac.py scripts/test_coupang_partners_mcp_wrapper.py scripts/test_ohou_today_deal.py scripts/ticket_availability.py scripts/test_ticket_availability.py scripts/test_danawa_price_search.py ticket-availability/scripts/ticket_availability.py coupang-product-search/scripts/coupang_partners_mcp.py ohou-today-deal/scripts/ohou_today_deal.py kakaotalk-mac/scripts/kakaotalk_mac.py naver-blog-research/scripts/_naver_http.py naver-blog-research/scripts/naver_search.py naver-blog-research/scripts/naver_read.py naver-blog-research/scripts/naver_download_images.py korean-slang-writing/scripts/_slang_http.py korean-slang-writing/scripts/slang_search.py korean-slang-writing/scripts/slang_lookup.py korean-scholarship-search/scripts/scholarship_filter.py korean-scholarship-search/scripts/test_scholarship_filter.py korean-scholarship-search/scripts/university_search_plan.py seoul-bike/scripts/seoul_bike.py scripts/test_seoul_bike.py danawa-price-search/scripts/danawa_search.py kosis-stats/scripts/run_kosis_stats.py kosis-stats/tests/test_run_kosis_stats.py kstartup-search/scripts/run_kstartup.py kstartup-search/tests/test_run_kstartup.py intercity-bus-booking/scripts/intercity_bus_search.py daangn-used-goods-search/scripts/daangn_used_goods.py daangn-realty-search/scripts/daangn_realty.py daangn-jobs-search/scripts/daangn_jobs.py daangn-cars-search/scripts/daangn_cars.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
"typecheck": "tsc --noEmit",
"test": "python3 -m pip install --user --quiet beautifulsoup4 && node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js scripts/test_build_manus_bundle.js scripts/workflow-actions.test.js && PYTHONPATH=.:scripts python3 -m unittest scripts.test_k_skill_cleaner scripts.test_fine_dust scripts.test_ktx_booking scripts.test_sillok_search scripts.test_korean_spell_check scripts.test_patent_search scripts.test_mfds_drug_safety scripts.test_nts_business_registration scripts.test_mfds_food_safety scripts.test_zipcode_search scripts.test_subway_lost_property scripts.test_geeknews_search scripts.test_naver_blog_search scripts.test_korean_slang_writing scripts.test_kakaotalk_mac scripts.test_coupang_partners_mcp_wrapper scripts.test_ohou_today_deal scripts.test_ticket_availability scripts.test_seoul_bike scripts.test_danawa_price_search && PYTHONPATH=.:scripts:korean-scholarship-search/scripts python3 -m unittest discover -s korean-scholarship-search/scripts -p 'test_scholarship_filter.py' && PYTHONPATH=.:scripts:kosis-stats/scripts python3 -m unittest discover -s kosis-stats/tests -p 'test_run_kosis_stats.py' && PYTHONPATH=.:scripts:kstartup-search/scripts python3 -m unittest discover -s kstartup-search/tests -p 'test_run_kstartup.py' && npm run test --workspaces --if-present && ./scripts/validate-skills.sh",
"test": "python3 -m pip install --user --quiet beautifulsoup4 && node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js scripts/test_korean_middle_korean.js scripts/test_build_manus_bundle.js scripts/workflow-actions.test.js && PYTHONPATH=.:scripts python3 -m unittest scripts.test_k_skill_cleaner scripts.test_fine_dust scripts.test_ktx_booking scripts.test_sillok_search scripts.test_korean_spell_check scripts.test_patent_search scripts.test_mfds_drug_safety scripts.test_nts_business_registration scripts.test_mfds_food_safety scripts.test_zipcode_search scripts.test_subway_lost_property scripts.test_geeknews_search scripts.test_naver_blog_search scripts.test_korean_slang_writing scripts.test_kakaotalk_mac scripts.test_coupang_partners_mcp_wrapper scripts.test_ohou_today_deal scripts.test_ticket_availability scripts.test_seoul_bike scripts.test_danawa_price_search && PYTHONPATH=.:scripts:korean-scholarship-search/scripts python3 -m unittest discover -s korean-scholarship-search/scripts -p 'test_scholarship_filter.py' && PYTHONPATH=.:scripts:kosis-stats/scripts python3 -m unittest discover -s kosis-stats/tests -p 'test_run_kosis_stats.py' && PYTHONPATH=.:scripts:kstartup-search/scripts python3 -m unittest discover -s kstartup-search/tests -p 'test_run_kstartup.py' && npm run test --workspaces --if-present && ./scripts/validate-skills.sh",
"pack:dry-run": "npm pack --workspace k-lotto --dry-run && npm pack --workspace daiso-product-search --dry-run && npm pack --workspace market-kurly-search --dry-run && npm pack --workspace blue-ribbon-nearby --dry-run && npm pack --workspace kakao-bar-nearby --dry-run && npm pack --workspace cheap-gas-nearby --dry-run && npm pack --workspace public-restroom-nearby --dry-run && npm pack --workspace parking-lot-search --dry-run && npm pack --workspace court-auction-notice-search --dry-run && npm pack --workspace donation-place-search --dry-run && npm pack --workspace gongsijiga-search --dry-run && npm pack --workspace kbl-results --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace lck-analytics --dry-run && npm pack --workspace toss-securities --dry-run && npm pack --workspace hipass-receipt --dry-run && npm pack --workspace used-car-price-search --dry-run && npm pack --workspace k-skill-rhwp --dry-run && npm pack --workspace korean-marathon-schedule --dry-run && npm pack --workspace gangnamunni-clinic-search --dry-run && npm pack --workspace daishin-report-search --dry-run && npm pack --workspace sh-notice-search --dry-run && npm pack --workspace emergency-room-beds --dry-run && npm pack --workspace local-election-candidate-search --dry-run",
"ci": "npm run lint && npm run typecheck && npm run test && npm run pack:dry-run",
"version-packages": "changeset version",

View file

@ -0,0 +1,14 @@
"use strict";
const bundled = require("../korean-middle-korean/scripts/korean_middle_korean.js");
if (require.main === module) {
try {
bundled.main(process.argv.slice(2));
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
module.exports = bundled;

View file

@ -0,0 +1,156 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const childProcess = require("node:child_process");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const {
convertToMiddleKoreanStyle,
createReport,
parseArgs,
} = require("./korean_middle_korean.js");
const ISSUE_SAMPLE =
'야 이 맛국노야 설마 2015년 7월 21일 새벽에 배우 채수빈이 구자욱이랑 거리에서 손잡고 걸어다니는 모습이 찍혀 열애설이 터지고 구자욱은 열애설을 인정했지만 채수빈은 "맛보기한 느낌이랄까? 열애설이 이런 기분이구나"라고 말해 구자욱이 맛자욱이 된 그 사건을 말하는 것이냐.';
test("convertToMiddleKoreanStyle applies the issue #270 medieval Korean style markers", () => {
const output = convertToMiddleKoreanStyle(ISSUE_SAMPLE);
assert.match(output, /이 맛國노〮야/);
assert.match(output, /2015年 7月 21日/);
assert.match(output, /俳優/);
assert.match(output, /街里/);
assert.match(output, /熱愛說/);
assert.match(output, /인졍ᄒᆞ/);
assert.match(output, /기븐〮이로다/);
assert.match(output, /ᄆᆞᆯᄒᆞᄂᆞᆫ 것이냐〮[.]?$/);
});
test("converter leaves unrecognized names and numbers unchanged while archaising particles and endings", () => {
const output = convertToMiddleKoreanStyle("민수는 3월 5일 학교에서 공부했다.");
assert.match(output, /민수ᄋᆞᆫ/);
assert.match(output, /3月 5日/);
assert.match(output, /學校/);
assert.match(output, /공부ᄒᆞ/);
assert.match(output, /ᄒᆞ엿다〮[.]?$/);
assert.match(convertToMiddleKoreanStyle("전설이 된 이야기."), /ᄃᆞᆫ 이야기/);
});
test("documentation and skill describe proper-noun preservation as best effort", () => {
const docs = fs.readFileSync(path.join(__dirname, "..", "docs", "features", "korean-middle-korean.md"), "utf8");
const skill = fs.readFileSync(path.join(__dirname, "..", "korean-middle-korean", "SKILL.md"), "utf8");
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("열애설을 인정했다.");
assert.equal(report.profile, "middle-korean-style-v1");
assert.equal(report.input, "열애설을 인정했다.");
assert.match(report.output, /熱愛說ᄋᆞᆯ/);
assert.match(report.output, /인졍ᄒᆞ엿다/);
assert.ok(report.replacements.some((replacement) => replacement.kind === "lexicon"));
assert.match(report.contract, /deterministic/i);
});
test("documentation records the v1 rule order and compatibility policy", () => {
const docs = fs.readFileSync(path.join(__dirname, "..", "docs", "features", "korean-middle-korean.md"), "utf8");
assert.match(docs, /날짜 단위 정규화를 먼저 적용한다/);
assert.match(docs, /그다음 결정론적 lexicon 치환을 적용한다/);
assert.match(docs, /`middle-korean-style-v1`의 출력 변경/);
const report = createReport("2015년 7월 21일 배우가 말했다.");
const firstLexiconIndex = report.replacements.findIndex((replacement) => replacement.kind === "lexicon");
const lastDateIndex = report.replacements.findLastIndex((replacement) => replacement.kind === "date");
assert.ok(lastDateIndex >= 0);
assert.ok(firstLexiconIndex > lastDateIndex);
});
test("parseArgs enforces a single input source", () => {
assert.deepEqual(parseArgs(["--text", "가나다"]), {
format: "json",
inputMode: "text",
text: "가나다",
});
assert.throws(() => parseArgs(["--text", "가", "--stdin"]), /exactly one input source/i);
assert.throws(() => parseArgs(["--format", "xml", "--text", "가"]), /unknown format/i);
});
test("CLI accepts text, file, and stdin input", () => {
const repoRoot = path.join(__dirname, "..");
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "korean-middle-korean-cli-"));
const samplePath = path.join(tempDir, "sample.txt");
try {
fs.writeFileSync(samplePath, "열애설을 인정했다.", "utf8");
const textOutput = JSON.parse(
childProcess.execFileSync("node", ["scripts/korean_middle_korean.js", "--text", "학교에서 공부했다.", "--format", "json"], {
cwd: repoRoot,
encoding: "utf8",
}),
);
assert.match(textOutput.output, /學校/);
assert.match(textOutput.output, /공부ᄒᆞ엿다/);
const fileOutput = JSON.parse(
childProcess.execFileSync("node", ["scripts/korean_middle_korean.js", "--file", samplePath], {
cwd: repoRoot,
encoding: "utf8",
}),
);
assert.match(fileOutput.output, /熱愛說ᄋᆞᆯ/);
const stdinOutput = childProcess.execFileSync("node", ["scripts/korean_middle_korean.js", "--stdin", "--format", "text"], {
cwd: repoRoot,
encoding: "utf8",
input: "기분이구나.",
});
assert.match(stdinOutput, /기븐〮이로다/);
const installedSkillOutput = childProcess.execFileSync(
"node",
["scripts/korean_middle_korean.js", "--text", "학교에서 공부했다.", "--format", "text"],
{
cwd: path.join(repoRoot, "korean-middle-korean"),
encoding: "utf8",
},
);
assert.match(installedSkillOutput, /學校애 공부ᄒᆞ엿다/);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});