mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
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:
parent
876077c7c9
commit
45084293f0
7 changed files with 563 additions and 2 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
86
docs/features/korean-middle-korean.md
Normal file
86
docs/features/korean-middle-korean.md
Normal 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
|
||||
```
|
||||
89
korean-middle-korean/SKILL.md
Normal file
89
korean-middle-korean/SKILL.md
Normal 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/중세국어풍 조사·어미·성조점이 나타난다.
|
||||
214
korean-middle-korean/scripts/korean_middle_korean.js
Executable file
214
korean-middle-korean/scripts/korean_middle_korean.js
Executable 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,
|
||||
};
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
14
scripts/korean_middle_korean.js
Normal file
14
scripts/korean_middle_korean.js
Normal 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;
|
||||
156
scripts/test_korean_middle_korean.js
Normal file
156
scripts/test_korean_middle_korean.js
Normal 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 });
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue