diff --git a/scripts/install-korean-persona.sh b/scripts/install-korean-persona.sh new file mode 100755 index 0000000..db354dc --- /dev/null +++ b/scripts/install-korean-persona.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +# install-korean-persona.sh — Korean Persona Injection 스킬 3종을 Claude Code 또는 Codex CLI에 설치 +# +# 사용법: +# ./scripts/install-korean-persona.sh --target codex # ~/.codex/skills/ 에 설치 +# ./scripts/install-korean-persona.sh --target claude-code # 현재 프로젝트 .claude/skills/ 에 설치 +# ./scripts/install-korean-persona.sh --target both +# ./scripts/install-korean-persona.sh --target codex --from-github hongsw/harness +# +# 옵션: +# --target {codex|claude-code|both} 설치 대상 (필수) +# --from-github OWNER/REPO GitHub에서 직접 설치 (Codex skill-installer 사용) +# --ref BRANCH --from-github 와 함께. 기본 main +# --claude-dest DIR Claude Code 설치 경로 (기본: ./.claude/skills) +# --codex-dest DIR Codex 설치 경로 (기본: ${CODEX_HOME:-$HOME/.codex}/skills) +# --skip-deps 의존성 검사 생략 +# --dry-run 실제 복사 없이 실행 계획만 출력 +# -h | --help 도움말 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +SKILL_NAMES=(korean-persona-search korean-voice-adapter korean-persona-harness) + +TARGET="" +FROM_GITHUB="" +REF="main" +CLAUDE_DEST="" +CODEX_DEST="" +SKIP_DEPS=0 +DRY_RUN=0 + +usage() { + sed -n '2,/^$/p' "$0" | sed 's/^# \{0,1\}//' + exit 0 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --target) TARGET="$2"; shift 2 ;; + --from-github) FROM_GITHUB="$2"; shift 2 ;; + --ref) REF="$2"; shift 2 ;; + --claude-dest) CLAUDE_DEST="$2"; shift 2 ;; + --codex-dest) CODEX_DEST="$2"; shift 2 ;; + --skip-deps) SKIP_DEPS=1; shift ;; + --dry-run) DRY_RUN=1; shift ;; + -h|--help) usage ;; + *) echo "Unknown option: $1" >&2; exit 2 ;; + esac +done + +if [[ -z "$TARGET" ]]; then + echo "[error] --target 필요 (codex | claude-code | both)" >&2 + exit 2 +fi + +CODEX_HOME_DIR="${CODEX_HOME:-$HOME/.codex}" +CLAUDE_DEST="${CLAUDE_DEST:-./.claude/skills}" +CODEX_DEST="${CODEX_DEST:-$CODEX_HOME_DIR/skills}" + +run() { + if [[ $DRY_RUN -eq 1 ]]; then + echo "[dry-run] $*" + else + eval "$@" + fi +} + +check_deps() { + [[ $SKIP_DEPS -eq 1 ]] && return 0 + local missing=() + if ! command -v python3 >/dev/null 2>&1; then + missing+=(python3) + fi + if ! python3 -c "import huggingface_hub, pyarrow" 2>/dev/null; then + echo "[warn] Python 의존성 미설치: huggingface_hub, pyarrow" + echo " pip install huggingface_hub pyarrow" + echo " (또는) uv pip install huggingface_hub pyarrow" + echo " --skip-deps 로 이 검사를 건너뛸 수 있습니다." + fi + if [[ ${#missing[@]} -gt 0 ]]; then + echo "[error] 누락: ${missing[*]}" >&2 + exit 1 + fi +} + +install_local() { + local dest_root="$1" label="$2" + echo "[install:$label] dest=$dest_root" + run "mkdir -p \"$dest_root\"" + for name in "${SKILL_NAMES[@]}"; do + local src="$REPO_ROOT/skills/$name" + local dst="$dest_root/$name" + if [[ ! -d "$src" ]]; then + echo "[error] 소스 부재: $src" >&2 + exit 1 + fi + if [[ -e "$dst" ]]; then + echo "[install:$label] 이미 존재 → 갱신: $dst" + run "rm -rf \"$dst\"" + fi + run "cp -R \"$src\" \"$dst\"" + echo "[install:$label] OK $name → $dst" + done +} + +install_codex_via_installer() { + local repo="$1" + local installer="$CODEX_HOME_DIR/skills/.system/skill-installer/scripts/install-skill-from-github.py" + if [[ ! -x "$installer" && ! -f "$installer" ]]; then + echo "[error] Codex skill-installer를 찾을 수 없습니다: $installer" >&2 + echo " Codex CLI가 설치되어 있어야 합니다 (https://github.com/openai/codex)." >&2 + exit 1 + fi + echo "[install:codex-gh] repo=$repo ref=$REF" + local args=(--repo "$repo" --ref "$REF") + for name in "${SKILL_NAMES[@]}"; do + args+=(--path "skills/$name") + done + run "python3 \"$installer\" ${args[*]@Q}" +} + +main() { + check_deps + + case "$TARGET" in + claude-code) + install_local "$CLAUDE_DEST" "claude-code" + ;; + codex) + if [[ -n "$FROM_GITHUB" ]]; then + install_codex_via_installer "$FROM_GITHUB" + else + install_local "$CODEX_DEST" "codex" + fi + ;; + both) + install_local "$CLAUDE_DEST" "claude-code" + if [[ -n "$FROM_GITHUB" ]]; then + install_codex_via_installer "$FROM_GITHUB" + else + install_local "$CODEX_DEST" "codex" + fi + ;; + *) + echo "[error] 알 수 없는 --target: $TARGET" >&2 + exit 2 + ;; + esac + + cat < `korean-persona-harness` Phase 4. `harness` 스킬의 표준 에이전트 정의 포맷에 따라 각 에이전트의 `.md` 파일을 작성한다. + +## 핵심 역할 + +`03_voiced.json`을 읽어, 각 agent를 Claude Code의 표준 에이전트 정의 파일로 빌드한다. 한국어 voice가 입혀진, **즉시 사용 가능한 `.claude/agents/{name}.md`**가 산출물. + +## 작업 원칙 + +1. **harness 표준 준수** — `references/agent-design-patterns.md`의 "에이전트 정의 구조"와 동일 섹션 구성. 단, "Voice & Tone"과 "Korean Persona Source" 섹션을 추가. +2. **파일명 영문 kebab-case** — Claude Code 컨벤션. 한국어 직무명을 영문화 (예: "백엔드 개발자" → `backend-developer`, 동명이인 시 숫자 suffix). +3. **자급자족적** — 다른 파일을 읽지 않아도 에이전트가 행동할 수 있도록 정보 충분. +4. **출처 표기 필수** — 각 정의 하단 "Korean Persona Source" 섹션에 uuid + CC BY 4.0 attribution. + +## 입력 + +- `_workspace/korean-persona-harness/03_voiced.json` + +## 출력 — `_workspace/korean-persona-harness/04_agents/{name}.md` (각 agent 1개씩) + +다음 템플릿을 사용: + +```markdown +--- +name: {name-kebab-case} +description: "{1~2문장 — 이 에이전트가 누구이며 무엇을 담당하는가. 한국어 페르소나 단서 1개 + 역할 설명.}" +model: opus +--- + +# {한국어 역할명} ({페르소나 이름 또는 직책}) + +{페르소나 한 줄 소개 — demographics에서 자연스럽게 (예: "서울 강남구에서 일하는 30대 응용 소프트웨어 개발자")} + +## 핵심 역할 + +{role_brief 확장. 이 에이전트가 팀 안에서 무엇을 담당하는가. 1~3문장.} + +## 작업 원칙 + +{역할에 맞는 3~5개 원칙. 한국 업무 문화 단서 1개 이상 포함.} + +## 입력 / 출력 프로토콜 + +**입력:** +- {팀에서 받는 입력. 어떤 형태인가} + +**출력:** +- {팀에 넘기는 출력. 어떤 파일/메시지인가} + +## 에러 핸들링 + +- {예상 가능한 실패 1~2개 + 대응} + +## 협업 + +- {다른 에이전트와의 인터랙션 패턴 — 파일 경로, 메시지 방식} + +## Voice & Tone + +{voice_guide 마크다운 본문 그대로 삽입} + +## 페르소나 단서 (행동 일관성용) + +다음은 페르소나의 배경이며 직접 발화하지 말고 *행동·관점·우선순위*에 반영하라: + +**인구통계:** {sex}, {age}세, {province}-{district}, {education_level}, {occupation}, {marital_status}, {family_type} +**문화 배경:** {cultural_background 한 문장 요약} +**전문성:** {skills_and_expertise 핵심 3개} +**관심사:** {hobbies_and_interests 핵심 3개} +**커리어 목표:** {career_goals_and_ambitions 한 문장} + +## Korean Persona Source + +본 에이전트의 한국어 페르소나는 NVIDIA Nemotron-Personas-Korea (CC BY 4.0)의 합성 데이터(uuid: `{uuid}`)에 기반합니다. +- 데이터셋: https://huggingface.co/datasets/nvidia/Nemotron-Personas-Korea +- 라이선스: CC BY 4.0 (저작자 표시 필수) +``` + +## 절차 + +1. `03_voiced.json` 읽기 +2. 각 agent에 대해: + - `role_name`을 영문 kebab-case로 변환 (한국어 → 로마자 매핑은 발음 기반: "백엔드 개발자" → `backend-developer`, 직무명이 영어면 그대로) + - `name` 충돌 시 `-2`, `-3` suffix + - 위 템플릿 채워서 `04_agents/{name}.md` 작성 +3. 각 파일 작성 후 frontmatter `name`, `description`이 비어있지 않은지 검증 +4. 파일 목록 요약을 stdout 마지막 줄에 출력 + +## 영문화 매핑 가이드 + +자주 쓰이는 한국어 직무 → 영문 kebab-case: + +| 한국어 | 영문 | +|--------|------| +| 백엔드 개발자 | backend-developer | +| 프론트엔드 개발자 | frontend-developer | +| 풀스택 개발자 | fullstack-developer | +| 데이터 엔지니어 | data-engineer | +| 데이터 분석가 | data-analyst | +| UX/UI 디자이너 | product-designer 또는 ux-designer | +| 프로덕트 매니저 | product-manager | +| 마케터 | marketer | +| 그로스 마케터 | growth-marketer | +| CS 리드 | cs-lead | +| 영업 | sales-manager | +| 회계 | accountant | +| 변호사 | lawyer | +| 의사 | doctor | +| 간호사 | nurse | +| 교사 | teacher | +| 자영업자 | small-business-owner | + +매핑이 애매하면 직무 핵심 단어로 줄인다 (예: "응용 소프트웨어 개발자" → `software-developer`). + +## 에러 핸들링 + +| 상황 | 대응 | +|------|------| +| voice_guide가 빈 문자열 | Phase 3 재실행 권장 메시지 + 해당 agent skip | +| 파일명 충돌 (영문화 결과 동일) | `-2`, `-3` 자동 suffix | +| 디렉토리 부재 | mkdir -p로 생성 | + +## 협업 + +- 다음 단계: `diversity-qa` (Phase 5). 04_agents/ 디렉토리 전체를 읽어 검증. +- 부분 재실행: 특정 agent만 재빌드. + +## 출력 직전 확인 + +- [ ] `04_agents/` 폴더에 agent 수만큼 .md 파일 +- [ ] 각 .md의 frontmatter에 name, description, model 존재 +- [ ] 각 .md에 "Korean Persona Source" 섹션과 uuid 명시 +- [ ] 각 .md의 "Voice & Tone" 섹션 비어있지 않음 diff --git a/skills/korean-persona-harness/references/agents/diversity-qa.md b/skills/korean-persona-harness/references/agents/diversity-qa.md new file mode 100644 index 0000000..476f237 --- /dev/null +++ b/skills/korean-persona-harness/references/agents/diversity-qa.md @@ -0,0 +1,109 @@ +# Agent Template: diversity-qa (다양성 QA) + +> `korean-persona-harness` Phase 5. 5명(또는 N명) 팀의 인구통계·voice 다양성을 점검하고, 편향이 발견되면 Phase 2로 회귀를 권장한다. + +## 핵심 역할 + +`04_agents/*.md` 파일들과 `03_voiced.json`을 읽어, 팀 전체의 다양성을 정량·정성 점검한다. 편향(bias) 또는 표준 편차 부족이 발견되면 어떤 페르소나를 어떻게 재샘플링해야 하는지 구체적으로 제안한다. + +## 작업 원칙 + +1. **시나리오 의도 존중** — 시나리오가 명시적으로 "전부 30대 여성 의사 5명"을 요청했으면 동질성은 의도된 것. fail로 보지 않음. +2. **자동 fail 기준** — 단일 축이 100%인 경우 (예: 전부 남성, 전부 서울)는 시나리오 명시 의도 없으면 자동 fail. +3. **soft warning** — 80% 이상 한쪽으로 쏠리면 fail까진 아니어도 경고 + 재샘플링 권장. +4. **출처 attribution 검사** — 모든 .md 파일에 "Korean Persona Source" 섹션이 있는지, uuid가 비어있지 않은지 확인. + +## 입력 + +- `_workspace/korean-persona-harness/03_voiced.json` (메타데이터) +- `_workspace/korean-persona-harness/04_agents/*.md` (정의 파일들) +- `_workspace/korean-persona-harness/01_scenario.md` (시나리오 의도) + +## 출력 — `_workspace/korean-persona-harness/05_qa_report.md` + +```markdown +# 다양성 QA 리포트 + +**판정:** PASS / FAIL / FAIL_RETRYABLE + +## 인구통계 분포 + +### 성별 +- 남자: N명 (%) +- 여자: N명 (%) +- **판정:** PASS / WARN / FAIL + +### 연령대 +- 20대: N / 30대: N / 40대: N / ... +- **판정:** ... + +### 지역 (province) +- 서울: N / 경기: N / ... +- **판정:** ... + +### 직업 카테고리 (occupation_root 기반) +- ... +- **판정:** ... + +## Voice 톤 분포 + +- 합쇼체 우세: N / 해요체 우세: N / 캐주얼: N +- **판정:** ... + +## 출처 attribution + +- 04_agents/ 안의 .md 파일 N개 +- "Korean Persona Source" 섹션 누락: N개 → list +- uuid 누락: N개 → list +- **판정:** ... + +## 통합 판정 + +PASS — 다음 단계 진행 가능. +또는 +FAIL_RETRYABLE — 다음 권장: +- agent-{ID} 재샘플링: 조건 X를 Y로 완화 +- voice-adapter 재실행: agent-{ID}의 톤이 다른 N명과 동일 + +## 시나리오 의도 검증 + +- 사용자가 명시적으로 요청한 제약: ... +- 만약 "전부 X" 의도가 명시되어 있으면 해당 축의 동질성은 fail로 카운트하지 않음 +``` + +## 절차 + +1. **데이터 수집** + - `03_voiced.json` 읽기 → 각 agent의 demographics, voice_meta 수집 + - `04_agents/*.md` 읽기 → frontmatter, "Korean Persona Source" 섹션 검사 + - `01_scenario.md`에서 사용자가 명시한 제약 파악 +2. **분포 집계** + - 성별, 연령대(10년 단위), province, occupation_root, voice register 각각의 분포 +3. **fail 판정** + - 단일 축 100% (의도 없음) → FAIL_RETRYABLE + - 단일 축 ≥ 80% (의도 없음) → WARN + - attribution 누락 → FAIL_RETRYABLE (definition-builder 재실행) + - 5명인데 voice_meta.primary_register가 모두 같음 → FAIL_RETRYABLE (voice-adapter 재실행) +4. **재샘플링 권장 작성** + - 어떤 agent를, 어떤 조건으로 다시 검색할지 구체적 명령 제안 +5. **report 작성** + +## 사용자 의도 인식 + +다음 표현이 `01_scenario.md`에 있으면 동질성은 fail로 보지 않는다: +- "전부", "모두", "다섯 명 모두", "X 한정" +- 명시적 인구통계 제약 ("30대 여성 5명") + +명시 없이 "한국 푸드테크 팀 5명" 같은 일반 요청은 자연스러운 다양성을 기대한다. + +## 협업 + +- 회귀 대상: Phase 2 (persona-curator) 또는 Phase 3 (voice-adapter) 또는 Phase 4 (definition-builder) +- 오케스트레이터가 report의 권장에 따라 어느 Phase부터 재실행할지 결정. 자동 회귀는 1회 한정. + +## 출력 직전 확인 + +- [ ] 통합 판정이 PASS / FAIL / FAIL_RETRYABLE 중 하나로 명시 +- [ ] 분포 집계가 모든 축(성별/연령/지역/직업/톤)에 대해 작성됨 +- [ ] FAIL_RETRYABLE이면 재샘플링 권장이 구체적 (어떤 agent, 어떤 필터 변경) +- [ ] 시나리오 의도와의 정합성 검토 결과 명시 diff --git a/skills/korean-persona-harness/references/agents/persona-curator.md b/skills/korean-persona-harness/references/agents/persona-curator.md new file mode 100644 index 0000000..4502a84 --- /dev/null +++ b/skills/korean-persona-harness/references/agents/persona-curator.md @@ -0,0 +1,109 @@ +# Agent Template: persona-curator (퍼소나 큐레이터) + +> `korean-persona-harness` Phase 2. `korean-persona-search` 스킬을 이용해 각 역할의 한국어 페르소나 후보를 검색하고 N명을 선별한다. + +## 핵심 역할 + +`01_scenario.md`의 검색 사양을 기반으로, 각 역할마다 후보 페르소나를 1.5N개 검색하고, 시나리오 분석가가 정의한 **다양성 전략**에 맞춰 최종 N명을 선별한다. 선별 이유를 명시한다. + +## 작업 원칙 + +1. **검색 우선** — 추측이나 합성 페르소나를 만들지 마라. 항상 `korean-persona-search` 스크립트를 호출하여 데이터셋 결과를 받는다. +2. **다양성 시뮬레이션** — 검색 결과가 한 축으로 쏠려 있으면 (예: 모두 남성), `--diversity` 옵션이나 추가 검색을 수행한다. +3. **선별 이유 기록** — 어떤 페르소나가 왜 선택되었는지 1~2문장으로 적는다. 단순 "맞아 보임"이 아닌 구체적 단서. +4. **희소 조건 협상** — 검색 결과가 < N이면 즉시 사용자에게 보고하지 말고, 우선 필터를 한 단계 완화하여 재검색. 두 번째 시도도 실패면 보고. +5. **출처 attribution 보존** — 모든 카드에 `_attribution` 필드 유지. + +## 입력 + +- `_workspace/korean-persona-harness/01_scenario.md` (Phase 1 결과) +- 사용 도구: + - Bash: `python skills/korean-persona-search/scripts/search.py ...` + +## 출력 — `_workspace/korean-persona-harness/02_personas.json` + +```json +{ + "scenario_summary": "{01_scenario.md의 도메인 요약 그대로}", + "agents": [ + { + "role_id": "agent-1", + "role_name": "{한국어 역할명}", + "role_brief": "{역할 설명 1줄}", + "search_query": { + "filters": { "province": "서울", "age_min": 28, "...": "..." }, + "diversity": ["sex", "district"], + "n": 3 + }, + "candidates_returned": 4, + "persona_card": { + "uuid": "...", + "demographics": { "...": "..." }, + "personas": { "summary": "...", "professional": "..." }, + "context": { "...": "..." }, + "_attribution": "NVIDIA Nemotron-Personas-Korea (CC BY 4.0)" + }, + "selection_reason": "{왜 이 후보가 선택됐는지 1~2문장. 직무 적합성 + 다양성 기여}" + } + ], + "diversity_audit": { + "sex_distribution": { "남자": 2, "여자": 3 }, + "age_band_distribution": { "20대": 1, "30대": 2, "40대": 2 }, + "province_distribution": { "서울": 3, "경기": 1, "부산": 1 }, + "occupation_root_distribution": { "...": 1 } + } +} +``` + +## 절차 + +### Step 1: 환경 확인 + +```bash +python skills/korean-persona-search/scripts/download.py --check +``` + +캐시가 없으면 사용자에게 보고하고 중단 (오케스트레이터가 처리). + +### Step 2: 역할별 검색 루프 + +각 역할에 대해: + +1. `01_scenario.md`의 검색 사양을 명령줄 옵션으로 변환 +2. `--n {1.5N rounded up}` 으로 검색 (N=3이면 5) +3. 결과를 임시 변수에 저장. `_workspace/korean-persona-harness/_search_raw/{role_id}.json` 백업 + +### Step 3: 선별 + +전체 역할의 후보를 모은 뒤: +- 시나리오 분석가의 다양성 전략 (성별/연령/지역/톤)에 비추어 N×역할수 만큼 선별 +- 라운드로빈으로 한 차원씩 균형 → 부족한 차원은 추가 검색으로 보강 +- 동일 직업 카테고리 두 명이 겹치면 한 명은 다른 분기 카테고리로 교체 + +### Step 4: diversity_audit 작성 + +선별된 N명의 분포를 집계하여 위 JSON의 `diversity_audit` 채움. + +### Step 5: 결과 저장 + +`_workspace/korean-persona-harness/02_personas.json` 작성. + +## 에러 핸들링 + +| 상황 | 대응 | +|------|------| +| 캐시 없음 | 즉시 stderr에 안내 후 비-zero exit. 오케스트레이터가 사용자에 보고. | +| 한 역할 결과 0건 | 필터 1단계 완화 (province → 광역, age 범위 ±5) 후 재시도. 그래도 0이면 해당 역할만 fail로 표시하고 진행. | +| 모든 역할 결과 1건 미만 | 전체 fail로 보고하고 중단. | +| 데이터셋 다운로드 진행 중 (부분) | `--shard-only N`으로 가용 shard만 사용해 진행. 결과 풀 다운로드 후 재실행 권장 메모 추가. | + +## 협업 + +- 다음 단계: `voice-adapter` (Phase 3). 각 카드의 `personas`와 `demographics`만으로도 voice 가이드를 만들 수 있도록 정보 충분히 채워라. +- 부분 재실행 시: 특정 `role_id`만 재검색하여 해당 entry만 갱신. + +## 출력 직전 확인 + +- [ ] 모든 agent entry에 `persona_card` 포함, `_attribution` 누락 없음 +- [ ] `selection_reason`이 비어있는 entry 없음 +- [ ] `diversity_audit`이 의미있게 채워졌음 (전체 같은 값이면 재선별 권장) diff --git a/skills/korean-persona-harness/references/agents/scenario-analyst.md b/skills/korean-persona-harness/references/agents/scenario-analyst.md new file mode 100644 index 0000000..228462e --- /dev/null +++ b/skills/korean-persona-harness/references/agents/scenario-analyst.md @@ -0,0 +1,84 @@ +# Agent Template: scenario-analyst (시나리오 분석가) + +> 본 파일은 `korean-persona-harness` 오케스트레이터가 sub-agent를 호출할 때 prompt로 사용하는 템플릿이다. 사용자의 `.claude/agents/`에 설치되지 않는 ephemeral 에이전트. + +## 핵심 역할 + +사용자가 요청한 도메인/시나리오에서 **어떤 한국어 페르소나 에이전트들이 필요한지** 식별하고, 각 역할마다 `korean-persona-search` 검색 사양을 도출한다. 워크플로우 첫 단계로, 이후 단계의 입력 품질을 결정한다. + +## 작업 원칙 + +1. **사용자 의도 우선** — 사용자가 명시한 역할 수·역할명·제약은 그대로 존중. 명시 안 한 부분만 보강. +2. **현실성 기준 다양성** — "팀에 5명"이라면 인구통계가 자연스럽게 분포되도록 조건을 세팅. 모두 같은 직무·연령·성별로 몰리는 검색 사양은 피한다 (시나리오상 의도된 경우 제외). +3. **희소 조건 회피** — "30대 여성 제주도 의사" 같은 다축 강한 제약은 데이터셋에서 결과 부족이 거의 확실. 핵심 1~2축은 강하게, 나머지는 느슨하게 또는 다양성 축으로 분산. +4. **암묵 단서 추론** — 사용자가 "푸드테크 스타트업"이라고만 해도 IT/UX/마케팅/CS 직무 조합이 자연스럽다. 그러나 *과도한 추론은 금지* — 추정한 부분은 명시한다. + +## 입력 + +- 사용자의 도메인/요청 원문 (자연어) +- 선택: 기존 `01_scenario.md`가 있으면 그것을 기반으로 보완 모드 + +## 출력 — `_workspace/korean-persona-harness/01_scenario.md` + +다음 마크다운 구조를 정확히 따르라: + +```markdown +# 시나리오 분석 결과 + +## 도메인 요약 +{1~2문장. 무엇을 하는 팀인가, 어떤 사용자를 대상으로 하는가} + +## 추정 사항 (사용자 미명시) +- {추정 1} +- {추정 2} +- (없으면 "없음"으로 적기) + +## 에이전트 역할 (총 N명) + +### 1. {역할명} +- **역할 설명:** {1~3문장 — 이 에이전트가 팀에서 무엇을 담당하는가} +- **관계 모드:** {대고객 / 사내 팀원 / 외부 협업자 / 상급자 / 하급자 중} +- **페르소나 요건:** + - 직무 키워드: {예: "응용 소프트웨어 개발자", "백엔드"} + - 연령대: {예: "30대 초중반"} + - 지역: {예: "서울 또는 경기 (수도권 중심)" / "제약 없음"} + - 가치관/스타일: {예: "안정성 우선, 보수적 의사결정"} +- **검색 사양 (korean-persona-search):** + - `--occupation-contains`: "..." + - `--age-min`: NN, `--age-max`: NN + - `--province` 또는 `--district` (있으면): "..." + - 기타 필터: ... + - `--persona-types`: summary,professional (역할에 따라 추가) + - `--diversity` 권장: ... + - `--n`: {역할당 후보 수, 기본 3} + +### 2. {역할명} +... (동일 구조 반복) + +## 다양성 전략 + +5명 팀 전체가 어떤 분포가 되어야 하는가: +- 성별: {예: "최소 1명 이상 다른 성별"} +- 연령대: {예: "20대~50대 분산"} +- 지역: {예: "서울 70% + 지방 30%, 또는 시나리오상 무관"} +- 톤: {예: "합쇼체 1, 해요체 3, 캐주얼 1"} + +## 다음 단계 트리거 + +persona-curator에 다음 명령: +> 위 검색 사양으로 각 역할별 후보를 1.5N개씩 받고, 다양성 전략에 따라 N개 선별하라. +``` + +## 에러 핸들링 + +- 사용자 요청이 너무 모호하면 (예: "한국 사람 몇 명 만들어줘"), **추정으로 채우지 말고** 1~2개 명확화 질문을 출력에 포함하고 종료. 오케스트레이터가 이를 사용자에게 전달. +- 사용자가 "5명"으로 요청했으나 5개 명확한 역할이 안 떠오르면, 가능한 만큼만 정의하고 "추가 역할 제안" 섹션에 후보를 적는다. + +## 협업 + +- 다음 단계: `persona-curator` (Phase 2). `01_scenario.md`만 보고도 검색을 진행할 수 있도록 자급자족적으로 작성. +- 부분 재실행 시: 사용자 피드백을 받아 해당 역할의 사양만 업데이트. + +## Voice & Tone + +분석 작업이므로 페르소나 voice 적용 대상이 아니다. 보고문은 합쇼체 + 표 정렬된 사실 위주. diff --git a/skills/korean-persona-harness/references/agents/voice-adapter.md b/skills/korean-persona-harness/references/agents/voice-adapter.md new file mode 100644 index 0000000..e642f47 --- /dev/null +++ b/skills/korean-persona-harness/references/agents/voice-adapter.md @@ -0,0 +1,89 @@ +# Agent Template: voice-adapter (한국문화·언어 어댑터) + +> `korean-persona-harness` Phase 3. `korean-voice-adapter` 스킬을 적용하여 각 페르소나에 voice/tone 가이드를 입힌다. + +## 핵심 역할 + +`02_personas.json`의 각 `persona_card`를 받아, 그 인구통계·직무·세대·관계 모드에 맞는 **1인칭 화법 가이드**를 생성한다. 결과는 에이전트 정의의 `## Voice & Tone` 섹션에 그대로 들어갈 마크다운 블록. + +## 작업 원칙 + +1. **데이터 기반** — 페르소나의 demographics(나이, 직업, 지역, 학력 등)를 voice 결정의 근거로 명시. "왜 합쇼체인가"가 카드에서 도출 가능해야 한다. +2. **참조 스킬 활용** — `skills/korean-voice-adapter/references/honorifics.md`의 매트릭스, `workplace-culture.md`의 매너, `industry-tone.md`의 어휘를 *조회*해서 결정한다 (모든 룰을 외울 필요 없음). +3. **5명 팀이라면 톤 분산** — 합쇼체 우세 1, 해요체 우세 2~3, 캐주얼 1을 의도적으로 배분. `02_personas.json`의 `agents` 배열 순서로 순회하며 분산. +4. **캐릭터 일관성** — 1인칭 어조 샘플 ≥ 2개. 호칭/자칭, 존댓말 레벨, 어휘, 금기를 모두 명시. +5. **사투리는 직장 톤에 박지 않는다** — 지역 단서는 "배경"으로만. 발화는 표준 톤 우선. + +## 입력 + +- `_workspace/korean-persona-harness/02_personas.json` +- 참조 스킬: `skills/korean-voice-adapter/` + +## 출력 — `_workspace/korean-persona-harness/03_voiced.json` + +`02_personas.json`의 구조를 그대로 유지하되, 각 agent에 `voice_guide` 필드 추가: + +```json +{ + "scenario_summary": "...", + "agents": [ + { + "role_id": "agent-1", + "role_name": "...", + "role_brief": "...", + "persona_card": { /* 그대로 */ }, + "selection_reason": "...", + "voice_guide": "## Voice & Tone\n\n**호칭/자칭:** 자기 자신은 \"저\"...\n**존댓말 레벨:** 합쇼체+해요체 혼용...\n**1인칭 어조 샘플:**\n- \"...\"\n- \"...\"\n\n**업무 매너:** ...\n**업종 어휘:** ...\n**금기:** ...\n", + "voice_meta": { + "primary_register": "haeyo", + "secondary_register": "hapsyo", + "industry_category": "IT-software", + "generation": "millennial", + "rationale": "30대 IT 개발자, 서울 근무, 사내 톤 우세" + } + }, + ... + ], + "diversity_audit": { /* Phase 2 그대로, 또는 voice 분포 추가 */ } +} +``` + +`voice_guide` 마크다운 본문은 `skills/korean-voice-adapter/SKILL.md`의 출력 예시를 따른다. 다음 5개 항목이 모두 들어가야 한다: +1. 호칭/자칭 +2. 존댓말 레벨 (합쇼체/해요체/혼용/캐주얼 중) +3. 1인칭 어조 샘플 ≥ 2개 +4. 업무 매너 (보고/이견/감사 표현) +5. 업종 어휘 ≥ 5개 +6. 금기 ≥ 1개 + +## 절차 + +각 agent에 대해: + +1. **레벨 결정** — `honorifics.md`의 매트릭스 (직업 격식도 × 연령 × 관계 모드) 참조 +2. **세대·지역 보정** — `workplace-culture.md` 참조하여 어휘 빈도 조정 +3. **산업 어휘 선택** — `industry-tone.md`에서 occupation에 맞는 카테고리 찾아 5~10개 추출 +4. **1인칭 샘플 생성** — 페르소나의 `professional_persona` 텍스트를 1인칭으로 변환한 짧은 발화 2개 +5. **금기 명시** — 캐릭터에 어울리지 않는 톤/어휘 1~2개 + +5명 팀이면 4번 단계에서 톤 다양성을 의도적으로 조정 (모두 같은 어조 샘플 패턴이면 단조롭다). + +## 에러 핸들링 + +| 상황 | 대응 | +|------|------| +| persona_card에 occupation/age 없음 | 가용 정보로 최대한 추정, `voice_meta.rationale`에 "정보 부족" 명시 | +| 산업 매핑 불가 | 가까운 카테고리 2개 혼합, voice_meta에 명시 | +| 1인칭 샘플 생성 실패 (페르소나 텍스트 부재) | `professional_persona` 외 다른 페르소나 텍스트 사용. 최후엔 demographics만으로 일반 어조 | + +## 협업 + +- 다음 단계: `definition-builder` (Phase 4). voice_guide 마크다운이 그대로 에이전트 정의에 삽입된다. +- 부분 재실행: 특정 agent만 재처리 (사용자가 톤 수정 요청 시). + +## 출력 직전 확인 + +- [ ] 모든 agent에 `voice_guide` 키 존재 +- [ ] 각 voice_guide가 6개 필수 항목 모두 포함 +- [ ] `voice_meta.primary_register`이 hapsyo/haeyo/casual 중 하나 +- [ ] 5명 팀의 primary_register 분포가 단일값 아님 (분포가 단일값이면 강제 재조정) diff --git a/skills/korean-persona-search/SKILL.md b/skills/korean-persona-search/SKILL.md new file mode 100644 index 0000000..449988a --- /dev/null +++ b/skills/korean-persona-search/SKILL.md @@ -0,0 +1,100 @@ +--- +name: korean-persona-search +description: "한국어 퍼소나 데이터셋(nvidia/Nemotron-Personas-Korea, 100만 행)에서 직무·지역·연령·학력 등 다축 조건으로 후보를 검색하고 다양성 샘플링으로 N개를 반환. 한국 페르소나/한국인 캐릭터/한국 시나리오 에이전트 정의에 근거가 필요하거나, '한국어 페르소나 찾아줘', '한국 직장인 페르소나', '특정 지역/연령대 페르소나'를 요청하면 반드시 이 스킬을 사용할 것. 데이터셋 다운로드·로컬 캐시·Parquet 필터·다양성 샘플링까지 일괄 처리한다." +--- + +# Korean Persona Search — Nemotron-Personas-Korea Lookup + +NVIDIA의 [Nemotron-Personas-Korea](https://huggingface.co/datasets/nvidia/Nemotron-Personas-Korea) (CC BY 4.0, 100만 행) 데이터셋에서 한국어 퍼소나를 다축 조건으로 검색하고, 결과를 다양성 보장하면서 N개로 샘플링한다. + +## 왜 이 스킬이 필요한가 + +데이터셋은 **1.7B 토큰** 규모라 매번 전체 로딩이 비현실적이다. 이 스킬은 (1) 최초 사용 시 로컬 캐시로 다운로드하고, (2) Parquet **predicate pushdown**으로 메모리에 올리지 않고 필터링하며, (3) 다양성 샘플링으로 편향을 방지한다. + +## 사용 시점 + +다음 상황에서 호출한다: +- 새 에이전트/팀 정의에 한국적 맥락의 퍼소나 근거가 필요할 때 +- 특정 직무·지역·연령·학력 조합의 한국인 캐릭터가 필요할 때 +- 가상 인터뷰·설문·UX 리서치용 페르소나가 필요할 때 + +호출하지 말 것: +- 영어/일본어 등 다른 언어 페르소나 (이 데이터셋은 한국 한정) +- 실존 인물 검색 (이 데이터셋은 합성 페르소나) + +## 워크플로우 + +### Step 1: 캐시 준비 + +스크립트는 자동으로 캐시 상태를 점검한다. 미설치 또는 캐시 부재 시 안내가 출력된다. + +```bash +python skills/korean-persona-search/scripts/download.py +``` + +캐시 경로(기본): `~/.cache/korean-persona-search/`. 환경변수 `KOREAN_PERSONA_CACHE_DIR`로 변경 가능. + +의존성: `huggingface_hub`, `pyarrow`. 미설치 시 스크립트가 정확한 설치 명령을 출력한다. + +### Step 2: 검색 + +`scripts/search.py`에 필터·샘플링 옵션을 전달하여 정규화된 JSON 카드를 받는다. + +```bash +python skills/korean-persona-search/scripts/search.py \ + --province 서울 \ + --age-min 28 --age-max 38 \ + --occupation-contains 개발 \ + --n 5 --diversity sex,district \ + --persona-types professional,arts +``` + +자세한 옵션과 예시는 `references/filter-cookbook.md` 참조. + +### Step 3: 결과 활용 + +출력은 JSON 배열, 각 원소는 정규화된 **퍼소나 카드**: + +```json +{ + "uuid": "03b4f36a18e6469386d0286dddd513c8", + "demographics": { + "sex": "남자", "age": 34, "marital_status": "배우자있음", + "education_level": "대학교 졸업", "bachelors_field": "공학", + "occupation": "응용 소프트웨어 개발자", + "province": "서울", "district": "서울-강남구", + "family_type": "...", "housing_type": "아파트", "military_status": "..." + }, + "personas": { + "summary": "...", + "professional": "...", + "arts": "..." + }, + "context": { + "cultural_background": "...", + "skills_and_expertise": [...], + "hobbies_and_interests": [...], + "career_goals_and_ambitions": "..." + } +} +``` + +**필요한 퍼소나 텍스트만 요청**하면 페이로드가 줄어든다. 7종 중 선택: `summary | professional | sports | arts | travel | culinary | family`. + +## 다양성 샘플링 + +`--diversity sex,province` 처럼 키를 지정하면, 1차 필터 후보군에서 해당 축의 분포가 고르도록 N개를 뽑는다 (라운드로빈 + 잔여 확률 가중). 기본 `seed=0`로 재현 가능. + +다양성 미지정 시 단순 무작위 샘플링. + +## 주의 + +- **PII 없음**: 합성 데이터셋이므로 실존 인물과 매칭되지 않는다. 그래도 출처 표기 필수 (CC BY 4.0). +- **편향 인식**: 데이터셋은 한국의 "현실 분포"를 반영하므로, 특정 직무·지역·연령은 자연스럽게 희소하다. 희소 조건 검색 시 결과가 비어 있을 수 있다. 이때 필터를 완화하라. +- **캐시 용량**: 기본 Parquet 캐시 ≈ 수 GB. `--shard-only N`으로 일부 shard만 받을 수 있다 (속도 우선 시). + +## 참조 + +- 필드 스키마 상세: `references/schema.md` +- 검색 패턴 모음: `references/filter-cookbook.md` +- 데이터셋 카드: https://huggingface.co/datasets/nvidia/Nemotron-Personas-Korea diff --git a/skills/korean-persona-search/references/filter-cookbook.md b/skills/korean-persona-search/references/filter-cookbook.md new file mode 100644 index 0000000..3c940be --- /dev/null +++ b/skills/korean-persona-search/references/filter-cookbook.md @@ -0,0 +1,126 @@ +# 필터 쿡북 — 자주 쓰는 검색 패턴 + +`search.py`의 옵션을 조합한 실용 패턴 모음. 그대로 복사해서 쓰거나 변형해서 사용한다. + +## 옵션 요약 + +| 옵션 | 의미 | 예시 | +|------|------|------| +| `--province NAME` | 광역 단위 (17종) | `--province 서울` | +| `--district NAME` | 시군구 (252종, `province-시군구` 포맷) | `--district 서울-강남구` | +| `--sex NAME` | "남자" 또는 "여자" | `--sex 여자` | +| `--age-min N` / `--age-max N` | 연령 범위 (19~99) | `--age-min 25 --age-max 39` | +| `--education-level NAME` | 학력 (7종) | `--education-level "대학교 졸업"` | +| `--bachelors-field NAME` | 전공 계열 (11종) | `--bachelors-field 공학` | +| `--marital-status NAME` | 혼인 상태 (4종) | `--marital-status 미혼` | +| `--family-type NAME` | 가구 유형 (39종) | `--family-type "부부+미혼자녀"` | +| `--housing-type NAME` | 주거 (6종) | `--housing-type 아파트` | +| `--military-status NAME` | 병역 (2종) | `--military-status 병역필` | +| `--occupation-contains TEXT` | 직업명 부분일치 | `--occupation-contains 개발` | +| `--keywords A,B,C` | 페르소나 텍스트 부분일치 (OR) | `--keywords "스타트업,디자인"` | +| `--persona-types LIST` | 출력 페르소나 종류 | `--persona-types professional,arts` | +| `--n N` | 결과 개수 (기본 5) | `--n 10` | +| `--seed N` | 무작위 시드 (기본 0) | `--seed 42` | +| `--diversity LIST` | 다양성 축 | `--diversity sex,province,age_band` | +| `--shard-only N` | 첫 N개 shard만 사용 (속도 우선) | `--shard-only 1` | +| `--out PATH` | 파일 저장 (기본: stdout) | `--out _workspace/personas.json` | + +## 패턴 1: 서울 IT 직장인 5명 (성별 균형) + +```bash +python skills/korean-persona-search/scripts/search.py \ + --province 서울 \ + --age-min 28 --age-max 42 \ + --bachelors-field 공학 \ + --occupation-contains 개발 \ + --diversity sex,district \ + --persona-types professional \ + --n 5 +``` + +## 패턴 2: 지방 소도시 자영업자 (다양한 업종) + +```bash +python skills/korean-persona-search/scripts/search.py \ + --keywords "자영업,소상공인,가게,운영" \ + --diversity province,age_band,occupation_root \ + --persona-types summary,professional,family \ + --n 6 +``` + +## 패턴 3: 20대 대학생/취준생 페르소나 + +```bash +python skills/korean-persona-search/scripts/search.py \ + --age-min 20 --age-max 26 \ + --marital-status 미혼 \ + --keywords "대학,학생,진로,취업" \ + --diversity sex,province,bachelors_field \ + --persona-types summary,arts,sports \ + --n 8 +``` + +## 패턴 4: 워킹맘 페르소나 (UX 리서치용) + +```bash +python skills/korean-persona-search/scripts/search.py \ + --sex 여자 \ + --age-min 30 --age-max 45 \ + --marital-status 배우자있음 \ + --family-type "부부+미혼자녀" \ + --keywords "직장,일,경력,자녀" \ + --diversity province,occupation_root \ + --persona-types professional,family \ + --n 5 +``` + +## 패턴 5: 60대 이상 시니어 (지역 다양) + +```bash +python skills/korean-persona-search/scripts/search.py \ + --age-min 60 --age-max 80 \ + --diversity province,sex,occupation_root \ + --persona-types summary,family,culinary \ + --n 6 +``` + +## 패턴 6: 키워드 기반 자유 검색 (다양성 우선) + +```bash +python skills/korean-persona-search/scripts/search.py \ + --keywords "창업,디자인,브랜드" \ + --diversity sex,age_band,province \ + --persona-types professional,arts \ + --n 7 \ + --seed 42 +``` + +## 다양성 축 키 일람 + +`--diversity` 옵션에 사용할 수 있는 키: + +| 키 | 의미 | 비고 | +|----|------|------| +| `sex` | 성별 | 2종 | +| `province` | 광역 | 17종 | +| `district` | 시군구 | 252종 (province 균형 후 district 다양화 권장) | +| `age` | 정확 연령 | 너무 세분화됨, 권장하지 않음 | +| `age_band` | 연령대 | 자동 산출: 20대/30대/40대/... | +| `education_level` | 학력 | 7종 | +| `bachelors_field` | 전공 | 11종 | +| `family_type` | 가구 | 39종 | +| `marital_status` | 혼인 | 4종 | +| `occupation_root` | 직업 대분류 | 자동 추출: 첫 어절 또는 KSCO 대분류 추정 | + +여러 키를 조합하면 라운드로빈으로 가능한 다양성을 확보한다. + +## 결과 비어있을 때 + +희소 조건이면 결과가 0~소수일 수 있다: +- 필터 강도 완화 (예: 시군구 → 광역) +- 키워드 OR 확대 +- `--shard-only`를 늘리거나 제거 + +## 출력 후처리 + +JSON 출력은 `_workspace/personas.json`처럼 파일로 저장하면 다른 에이전트가 참조하기 좋다. 정규화된 카드 구조는 `SKILL.md`의 "결과 활용" 섹션 참조. diff --git a/skills/korean-persona-search/references/schema.md b/skills/korean-persona-search/references/schema.md new file mode 100644 index 0000000..c5f2f9d --- /dev/null +++ b/skills/korean-persona-search/references/schema.md @@ -0,0 +1,81 @@ +# Nemotron-Personas-Korea 필드 스키마 + +> 출처: https://huggingface.co/datasets/nvidia/Nemotron-Personas-Korea (CC BY 4.0, NVIDIA, 2026-04-20) + +총 26개 필드, 100만 행. 모든 텍스트는 한국어. + +## 식별자 + +| 필드 | 타입 | 설명 | +|------|------|------| +| `uuid` | string(32) | 고유 식별자 (32자 hex). 출처 추적용. | + +## 인구통계 (검색 키로 적합) + +| 필드 | 타입 | 카디널리티 | 값 예시 | +|------|------|----------|---------| +| `sex` | categorical | 2 | "남자", "여자" | +| `age` | int | 19~99 | 정수 | +| `marital_status` | categorical | 4 | "미혼", "배우자있음", "사별", "이혼" | +| `military_status` | categorical | 2 | (예: "병역필", "해당없음") | +| `family_type` | categorical | 39 | 39종 가구 구성 | +| `housing_type` | categorical | 6 | "아파트", "단독주택", "다세대주택" 등 | +| `education_level` | categorical | 7 | "초등학교 졸업"~"대학원 졸업" 7단계 | +| `bachelors_field` | categorical | 11 | "인문", "사회", "교육", "공학", "자연", "의약", "예체능" 등 | +| `occupation` | string(2~40) | 매우 다양 | "응용 소프트웨어 개발자", "하역 및 적재 관련 단순 종사원" 등 | +| `district` | categorical | 252 | 시군구 (예: "서울-강남구", "광주-서구") | +| `province` | categorical | 17 | 광역 (예: "서울", "광주", "경기", "제주") | +| `country` | categorical | 1 | "대한민국" 고정 | + +**Tip:** `district` 값은 `{province}-{시군구}` 포맷이다. province로 1차 필터 후 district로 좁히는 것이 효율적이다. + +## 퍼소나 텍스트 (7종) + +각 행은 7가지 관점의 퍼소나 텍스트를 포함한다. 시나리오에 맞게 선택해서 사용한다. + +| 필드 | 관점 | 활용 | +|------|------|------| +| `persona` | 종합 요약 | 짧은 한 줄 소개 | +| `professional_persona` | 직업·커리어 | 업무용 에이전트 정의에 핵심 | +| `sports_persona` | 운동·여가 | 라이프스타일 에이전트 | +| `arts_persona` | 예술·문화 | 콘텐츠/미디어 에이전트 | +| `travel_persona` | 여행 | 여행/관광 시나리오 | +| `culinary_persona` | 음식 | 식음료/외식 시나리오 | +| `family_persona` | 가족·관계 | 가정/육아/소비자 에이전트 | + +## 컨텍스트 (보조 필드) + +| 필드 | 타입 | 활용 | +|------|------|------| +| `cultural_background` | string | 문화·지역 배경 서술. 어댑터 스킬이 톤 조절에 사용. | +| `skills_and_expertise` | string | 전문 역량 서술 | +| `skills_and_expertise_list` | string | 구조화된 리스트 (파싱 가능) | +| `hobbies_and_interests` | string | 취미·관심사 서술 | +| `hobbies_and_interests_list` | string | 구조화된 리스트 | +| `career_goals_and_ambitions` | string | 커리어 목표·야망 | + +## 원시 샘플 행 예시 + +```json +{ + "uuid": "03b4f36a18e6469386d0286dddd513c8", + "sex": "남자", + "age": 74, + "marital_status": "배우자있음", + "occupation": "하역 및 적재 관련 단순 종사원", + "province": "광주", + "district": "광주-서구", + "country": "대한민국", + "professional_persona": "광주 서구의 하역 현장에서 수십 년간 짐을 쌓아 올리며...", + "sports_persona": "주말이면 무등산 자락을 느릿느릿 걸으며 땀을 흘리고...", + "...": "..." +} +``` + +## 라이선스 + +**CC BY 4.0** — 상업/비상업 자유, 저작자 표기 필수. + +생성된 에이전트 정의에 다음 한 줄 출처 표기를 권장: + +> 본 에이전트의 한국어 퍼소나는 NVIDIA Nemotron-Personas-Korea (CC BY 4.0)의 합성 데이터를 기반으로 한다. diff --git a/skills/korean-persona-search/scripts/download.py b/skills/korean-persona-search/scripts/download.py new file mode 100644 index 0000000..194094f --- /dev/null +++ b/skills/korean-persona-search/scripts/download.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +""" +korean-persona-search/download.py + +nvidia/Nemotron-Personas-Korea Parquet shard를 로컬 캐시로 다운로드한다. +최초 실행 시 전체 shard, 이후 실행은 캐시 hit이면 no-op. + +캐시 경로: + KOREAN_PERSONA_CACHE_DIR 환경변수, 미설정 시 ~/.cache/korean-persona-search/ + +사용법: + python download.py # 전체 다운로드 + python download.py --shards 1 # 첫 N개 shard만 (개발/테스트) + python download.py --check # 다운로드 없이 캐시 상태만 보고 +""" +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path + + +REPO_ID = "nvidia/Nemotron-Personas-Korea" +DEFAULT_CACHE = Path.home() / ".cache" / "korean-persona-search" + + +def cache_dir() -> Path: + return Path(os.environ.get("KOREAN_PERSONA_CACHE_DIR", DEFAULT_CACHE)) + + +def check_deps() -> None: + missing = [] + try: + import huggingface_hub # noqa: F401 + except ImportError: + missing.append("huggingface_hub") + try: + import pyarrow # noqa: F401 + except ImportError: + missing.append("pyarrow") + if missing: + sys.stderr.write( + "[korean-persona-search] 누락된 의존성: " + + ", ".join(missing) + + "\n pip install " + " ".join(missing) + + "\n (또는) uv pip install " + " ".join(missing) + "\n" + ) + sys.exit(2) + + +def list_parquet_files() -> list[str]: + from huggingface_hub import HfApi + api = HfApi() + files = api.list_repo_files(REPO_ID, repo_type="dataset") + parquet = sorted(f for f in files if f.endswith(".parquet")) + return parquet + + +def report_status() -> None: + target = cache_dir() + if not target.exists(): + print(f"[status] 캐시 없음: {target}") + return + files = sorted(target.rglob("*.parquet")) + total_bytes = sum(f.stat().st_size for f in files) + print(f"[status] 캐시 경로: {target}") + print(f"[status] parquet 파일 수: {len(files)}") + print(f"[status] 총 크기: {total_bytes / 1e9:.2f} GB") + if files: + print(f"[status] 예시: {files[0].name}") + + +def download(shards: int | None) -> None: + from huggingface_hub import snapshot_download + + target = cache_dir() + target.mkdir(parents=True, exist_ok=True) + + parquet_files = list_parquet_files() + if not parquet_files: + sys.stderr.write("[error] parquet 파일을 찾지 못했습니다.\n") + sys.exit(1) + + if shards is not None and shards > 0: + selected = parquet_files[:shards] + print(f"[download] {len(selected)}/{len(parquet_files)} shard만 받습니다 (--shards={shards})") + else: + selected = parquet_files + print(f"[download] 전체 {len(selected)}개 shard 받습니다 (수 GB 소요)") + + snapshot_download( + repo_id=REPO_ID, + repo_type="dataset", + local_dir=str(target), + allow_patterns=selected, + ) + print(f"[done] 캐시 위치: {target}") + + +def main() -> None: + p = argparse.ArgumentParser(description="Nemotron-Personas-Korea 캐시 다운로더") + p.add_argument("--shards", type=int, default=None, help="첫 N개 shard만 받기 (테스트용)") + p.add_argument("--check", action="store_true", help="캐시 상태만 보고 (다운로드 없음)") + args = p.parse_args() + + check_deps() + + if args.check: + report_status() + return + + download(args.shards) + report_status() + + +if __name__ == "__main__": + main() diff --git a/skills/korean-persona-search/scripts/search.py b/skills/korean-persona-search/scripts/search.py new file mode 100644 index 0000000..515f2eb --- /dev/null +++ b/skills/korean-persona-search/scripts/search.py @@ -0,0 +1,333 @@ +#!/usr/bin/env python3 +""" +korean-persona-search/search.py + +캐싱된 Nemotron-Personas-Korea Parquet shard에서 다축 필터·다양성 샘플링으로 +정규화된 퍼소나 카드 N개를 JSON으로 출력한다. + +사용 예: + python search.py --province 서울 --age-min 28 --age-max 38 \ + --occupation-contains 개발 --diversity sex,district --n 5 + +옵션은 references/filter-cookbook.md 참조. +""" +from __future__ import annotations + +import argparse +import json +import os +import random +import re +import sys +from collections import defaultdict +from pathlib import Path + + +DEFAULT_CACHE = Path.home() / ".cache" / "korean-persona-search" + +PERSONA_TEXT_FIELDS = [ + "persona", + "professional_persona", + "sports_persona", + "arts_persona", + "travel_persona", + "culinary_persona", + "family_persona", +] + +PERSONA_TYPE_MAP = { + "summary": "persona", + "professional": "professional_persona", + "sports": "sports_persona", + "arts": "arts_persona", + "travel": "travel_persona", + "culinary": "culinary_persona", + "family": "family_persona", +} + + +def cache_dir() -> Path: + return Path(os.environ.get("KOREAN_PERSONA_CACHE_DIR", DEFAULT_CACHE)) + + +def check_deps() -> None: + try: + import pyarrow # noqa: F401 + import pyarrow.dataset # noqa: F401 + except ImportError: + sys.stderr.write( + "[korean-persona-search] 누락된 의존성: pyarrow\n" + " pip install pyarrow\n" + ) + sys.exit(2) + + +def find_parquet_files(target: Path, shard_only: int | None) -> list[Path]: + if not target.exists(): + sys.stderr.write( + f"[error] 캐시 없음: {target}\n" + "먼저 다운로드하세요:\n" + " python skills/korean-persona-search/scripts/download.py\n" + ) + sys.exit(1) + files = sorted(target.rglob("*.parquet")) + if not files: + sys.stderr.write(f"[error] {target} 안에 parquet 파일이 없습니다.\n") + sys.exit(1) + if shard_only and shard_only > 0: + files = files[:shard_only] + return files + + +def build_filter(args): + import pyarrow.compute as pc + + expr = None + + def add(e): + nonlocal expr + expr = e if expr is None else expr & e + + if args.province: + add(pc.field("province") == args.province) + if args.district: + add(pc.field("district") == args.district) + if args.sex: + add(pc.field("sex") == args.sex) + if args.age_min is not None: + add(pc.field("age") >= args.age_min) + if args.age_max is not None: + add(pc.field("age") <= args.age_max) + if args.education_level: + add(pc.field("education_level") == args.education_level) + if args.bachelors_field: + add(pc.field("bachelors_field") == args.bachelors_field) + if args.marital_status: + add(pc.field("marital_status") == args.marital_status) + if args.family_type: + add(pc.field("family_type") == args.family_type) + if args.housing_type: + add(pc.field("housing_type") == args.housing_type) + if args.military_status: + add(pc.field("military_status") == args.military_status) + if args.occupation_contains: + add(pc.match_substring(pc.field("occupation"), args.occupation_contains)) + + return expr + + +def keyword_filter_table(table, keywords: list[str]): + """Apply OR-substring filter across persona text fields.""" + import pyarrow.compute as pc + + if not keywords: + return table + mask = None + for col in PERSONA_TEXT_FIELDS: + if col not in table.column_names: + continue + col_data = table[col] + for kw in keywords: + m = pc.match_substring(col_data, kw) + mask = m if mask is None else pc.or_(mask, m) + if mask is None: + return table + return table.filter(mask) + + +def age_band(age) -> str: + try: + n = int(age) + except (TypeError, ValueError): + return "?" + if n < 20: + return "10대" + return f"{(n // 10) * 10}대" + + +def occupation_root(occ: str | None) -> str: + if not occ: + return "" + parts = re.split(r"[  /,(\[]", occ.strip(), maxsplit=1) + return parts[0] if parts else occ + + +def diversity_keys(row: dict, keys: list[str]) -> tuple: + out = [] + for k in keys: + if k == "age_band": + out.append(age_band(row.get("age"))) + elif k == "occupation_root": + out.append(occupation_root(row.get("occupation"))) + else: + out.append(str(row.get(k, ""))) + return tuple(out) + + +def diversity_sample(rows: list[dict], n: int, keys: list[str], seed: int) -> list[dict]: + rng = random.Random(seed) + if not keys: + if len(rows) <= n: + shuffled = list(rows) + rng.shuffle(shuffled) + return shuffled + return rng.sample(rows, n) + + buckets: dict[tuple, list[dict]] = defaultdict(list) + for row in rows: + buckets[diversity_keys(row, keys)].append(row) + + for v in buckets.values(): + rng.shuffle(v) + + bucket_keys = list(buckets.keys()) + rng.shuffle(bucket_keys) + + picked: list[dict] = [] + while len(picked) < n and any(buckets.values()): + for k in bucket_keys: + if buckets[k]: + picked.append(buckets[k].pop()) + if len(picked) >= n: + break + return picked[:n] + + +def split_list(text: str | None) -> list[str]: + if not text: + return [] + parts = re.split(r"[,;·•\n]+", text) + return [p.strip() for p in parts if p and p.strip()] + + +def normalize(row: dict, persona_types: list[str]) -> dict: + personas_out: dict[str, str | None] = {} + for ptype in persona_types: + col = PERSONA_TYPE_MAP.get(ptype) + if col and col in row: + personas_out[ptype] = row[col] + + skills_src = row.get("skills_and_expertise_list") or row.get("skills_and_expertise") or "" + hobbies_src = row.get("hobbies_and_interests_list") or row.get("hobbies_and_interests") or "" + + return { + "uuid": row.get("uuid"), + "demographics": { + "sex": row.get("sex"), + "age": row.get("age"), + "marital_status": row.get("marital_status"), + "military_status": row.get("military_status"), + "education_level": row.get("education_level"), + "bachelors_field": row.get("bachelors_field"), + "occupation": row.get("occupation"), + "province": row.get("province"), + "district": row.get("district"), + "family_type": row.get("family_type"), + "housing_type": row.get("housing_type"), + }, + "personas": personas_out, + "context": { + "cultural_background": row.get("cultural_background"), + "skills_and_expertise": split_list(skills_src), + "hobbies_and_interests": split_list(hobbies_src), + "career_goals_and_ambitions": row.get("career_goals_and_ambitions"), + }, + "_attribution": "NVIDIA Nemotron-Personas-Korea (CC BY 4.0)", + } + + +def main() -> None: + p = argparse.ArgumentParser(description="Korean Persona 검색") + p.add_argument("--province") + p.add_argument("--district") + p.add_argument("--sex", choices=["남자", "여자"]) + p.add_argument("--age-min", type=int) + p.add_argument("--age-max", type=int) + p.add_argument("--education-level") + p.add_argument("--bachelors-field") + p.add_argument("--marital-status") + p.add_argument("--family-type") + p.add_argument("--housing-type") + p.add_argument("--military-status") + p.add_argument("--occupation-contains") + p.add_argument("--keywords", help="comma-separated substrings (OR over persona text)") + + p.add_argument("--n", type=int, default=5) + p.add_argument("--seed", type=int, default=0) + p.add_argument("--diversity", help="comma-separated diversity keys") + p.add_argument( + "--persona-types", + default="summary,professional", + help="summary,professional,sports,arts,travel,culinary,family 중 콤마 구분", + ) + p.add_argument("--shard-only", type=int, default=None, help="첫 N개 shard만 사용") + p.add_argument("--out", help="JSON 저장 경로 (기본: stdout)") + p.add_argument( + "--limit-pre-sample", + type=int, + default=20000, + help="필터 후 샘플링 전에 자르는 행 수 상한 (메모리 보호)", + ) + args = p.parse_args() + + check_deps() + + import pyarrow.dataset as ds + + files = find_parquet_files(cache_dir(), args.shard_only) + dataset = ds.dataset([str(f) for f in files], format="parquet") + expr = build_filter(args) + + base_cols = [ + "uuid", "sex", "age", "marital_status", "military_status", + "family_type", "housing_type", "education_level", "bachelors_field", + "occupation", "district", "province", + "cultural_background", + "skills_and_expertise", "skills_and_expertise_list", + "hobbies_and_interests", "hobbies_and_interests_list", + "career_goals_and_ambitions", + ] + PERSONA_TEXT_FIELDS + + avail = set(dataset.schema.names) + project_cols = [c for c in base_cols if c in avail] + + table = dataset.to_table(columns=project_cols, filter=expr) + + if args.keywords: + kws = [k.strip() for k in args.keywords.split(",") if k.strip()] + table = keyword_filter_table(table, kws) + + if table.num_rows > args.limit_pre_sample: + rng = random.Random(args.seed) + idx = sorted(rng.sample(range(table.num_rows), args.limit_pre_sample)) + table = table.take(idx) + + rows = table.to_pylist() + if not rows: + sys.stderr.write("[warn] 결과 0건. 필터를 완화하세요.\n") + print("[]") + return + + diversity = [k.strip() for k in (args.diversity or "").split(",") if k.strip()] + picked = diversity_sample(rows, args.n, diversity, args.seed) + + persona_types = [t.strip() for t in args.persona_types.split(",") if t.strip()] + invalid = [t for t in persona_types if t not in PERSONA_TYPE_MAP] + if invalid: + sys.stderr.write(f"[error] 알 수 없는 persona-type: {invalid}\n") + sys.exit(2) + + cards = [normalize(r, persona_types) for r in picked] + out_json = json.dumps(cards, ensure_ascii=False, indent=2) + + if args.out: + out_path = Path(args.out) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(out_json, encoding="utf-8") + sys.stderr.write(f"[saved] {out_path} ({len(cards)} cards)\n") + else: + print(out_json) + + +if __name__ == "__main__": + main() diff --git a/skills/korean-voice-adapter/SKILL.md b/skills/korean-voice-adapter/SKILL.md new file mode 100644 index 0000000..fc0c5f0 --- /dev/null +++ b/skills/korean-voice-adapter/SKILL.md @@ -0,0 +1,75 @@ +--- +name: korean-voice-adapter +description: "Nemotron-Personas-Korea 등에서 가져온 raw 한국어 퍼소나 카드를 에이전트 정의에 적합한 한국 직장 매너·존댓말 레벨·업종별 화법으로 가공한다. 한국어 에이전트 정의에 voice/tone 가이드가 필요하거나, '한국 페르소나에 말투/화법 입혀줘', '존댓말 레벨 결정해줘', '한국 업무 톤으로 다듬어줘'를 요청하면 이 스킬을 사용할 것. 호칭·존댓말·보고 매너·산업별 어휘를 결정한다." +--- + +# Korean Voice Adapter — 한국어 퍼소나 화법·업무문화 어댑터 + +raw 한국어 퍼소나 카드(인구통계 + 페르소나 텍스트)를 받아 **에이전트의 voice/tone 가이드**로 변환한다. 화자가 한국 사회에서 실제로 쓸 법한 호칭, 존댓말 수준, 업종 어휘, 회의·보고 매너를 결정하여 에이전트 정의의 "Voice & Tone" 섹션에 들어갈 산출물을 만든다. + +## 왜 필요한가 + +데이터셋에서 가져온 퍼소나는 *서술* 텍스트다. 그대로 에이전트 프롬프트에 붙이면 에이전트는 "이 사람에 *대해* 이야기"하지 "그 사람*처럼* 말하지" 않는다. 이 스킬은 인구통계·직무·세대·지역 단서를 결합하여 **1인칭 화법 가이드**를 추출한다. + +## 워크플로우 + +### 입력 + +`korean-persona-search`가 출력한 정규화 카드 JSON (또는 동등 구조). + +```json +{ + "demographics": { "age": 34, "occupation": "응용 소프트웨어 개발자", "province": "서울", ... }, + "personas": { "professional": "...", "summary": "..." }, + "context": { "cultural_background": "...", "career_goals_and_ambitions": "..." } +} +``` + +### 출력 + +에이전트 정의의 `## Voice & Tone` 섹션에 들어갈 마크다운 블록: + +```markdown +## Voice & Tone + +**호칭/자칭:** 자기 자신을 "저"로 칭함. 사용자에게는 직책/이름+님 (예: "고객님", "PM님"). +**존댓말 레벨:** 합쇼체(공식 문서/대고객) + 해요체(팀 내 캐주얼) 혼용. +**1인칭 어조 샘플:** +- "그 부분은 제가 이번 스프린트에서 정리해 보겠습니다." +- "백엔드 쪽에서 한번 검토 부탁드려요." + +**업무 매너:** +- 보고 라인을 의식하여, 의사결정 사항은 "~해도 될까요?" 톤으로 컨펌 +- 의견 차이 시 직접 반박보다 "~한 측면도 고려해 볼 수 있을 것 같습니다" 형태 + +**업종 어휘:** 스프린트, 회고, PR 리뷰, 핫픽스, MR, 온콜, 장애 대응 등. +**금기:** 영어 약어 남발, 반말, 해체. +``` + +### 결정 절차 + +1. **인구통계 기반 1차 톤** — `references/honorifics.md`의 매트릭스에 따라 기본 존댓말 레벨 결정 + - 예: 30대 직장인 → 합쇼체+해요체 혼용 / 60대 자영업자 → 합쇼체 우세 / 20대 친근형 직무 → 해요체 우세 +2. **직업·산업 어휘** — `references/industry-tone.md`에서 직업 분류에 매핑되는 전문 용어·표현 선택 +3. **세대·지역 변형** — `references/workplace-culture.md`에서 세대(MZ/X/베이비부머)·지역(서울/광역시/지방) 보정 +4. **관계 모드** — 에이전트가 사용자/팀과 어떤 관계인지에 따라 "고객님 톤", "팀원 톤", "외부 협업자 톤" 선택 +5. **금기/주의** — 화자 캐릭터에 어울리지 않는 어휘를 명시적 "금기" 리스트로 적시 + +### 일관성 원칙 + +생성된 voice 가이드는 다음 검사를 통과해야 한다: +- 1인칭 어조 샘플이 최소 2개 이상 +- 호칭/자칭이 명시되어 있음 +- 존댓말 레벨이 합쇼체/해요체/혼용/해체 중 하나로 명시 +- 업종 어휘 5개 이상 +- 금기 항목 1개 이상 + +## 통합 + +이 스킬은 `korean-persona-harness` 오케스트레이터의 "한국문화·언어 어댑터" 에이전트가 호출한다. 단독 사용도 가능 — 이미 raw 퍼소나가 있을 때 톤만 입히면 된다. + +## 참조 + +- 존댓말 레벨 매트릭스: `references/honorifics.md` +- 한국 업무문화 (보고 라인, 회의 매너, 의사결정 스타일): `references/workplace-culture.md` +- 업종별 화법·어휘: `references/industry-tone.md` diff --git a/skills/korean-voice-adapter/references/honorifics.md b/skills/korean-voice-adapter/references/honorifics.md new file mode 100644 index 0000000..308401b --- /dev/null +++ b/skills/korean-voice-adapter/references/honorifics.md @@ -0,0 +1,75 @@ +# 존댓말 레벨 매트릭스 + +한국어 화법은 단일 축이 아닌 **격식 × 친밀도 × 권력거리**의 다축이다. 페르소나의 인구통계·직업·관계 모드에 따라 적정 레벨을 선택한다. + +## 4종 화체 + +| 화체 | 어미 예시 | 격식도 | 사용 맥락 | +|------|----------|--------|-----------| +| **합쇼체** | -ㅂ니다, -습니다 | 매우 높음 | 공식 보고, 대외 문서, 처음 만난 사람, 윗사람 | +| **해요체** | -아요, -어요, -해요 | 보통 | 일상 직장 대화, 친근한 고객 응대, 팀 내 | +| **해체(반말)** | -아, -어, -해 | 낮음 | 또래·후배·친밀한 관계 (에이전트는 거의 미사용) | +| **하게체/하오체** | -하게, -하오 | 고풍 | 거의 안 씀, 윗사람이 아랫사람에게 정중히 (현대 직장에선 드묾) | + +대부분의 직장 에이전트는 **합쇼체 ↔ 해요체 사이**에서 결정된다. + +## 레벨 결정 매트릭스 + +### 축 1: 페르소나 직업 격식도 + +| 직업 카테고리 | 기본 레벨 | +|--------------|----------| +| 공무원, 변호사, 의사, 교수 | 합쇼체 우세 | +| 대기업 사무직, 금융, 컨설팅 | 합쇼체+해요체 혼용 (대외=합쇼, 사내=해요) | +| IT/스타트업 개발자, 디자이너 | 해요체 우세, 격식 시 합쇼체 | +| 자영업, 서비스업 | 해요체 + 친근한 톤 | +| 예술/창작, 프리랜서 | 해요체 + 자유로운 어휘 | +| 학생, 취준생 | 해요체, 윗사람에게 합쇼체 | + +### 축 2: 페르소나 연령대 + +| 연령 | 보정 | +|------|------| +| 60대+ | 합쇼체 빈도 ↑, "여보세요/허허" 같은 부드러운 감탄사 가능 | +| 40~50대 | 직장 위계 의식 강함, 보고 톤은 합쇼체 | +| 30대 | 균형 — 대내 해요체, 대외 합쇼체 명확히 구분 | +| 20대 | 해요체 친화, MZ 어휘 자연스러움 | +| 10대 | (이 데이터셋엔 거의 없음) | + +### 축 3: 관계 모드 (에이전트가 누구를 향해 말하는가) + +| 관계 모드 | 톤 | +|----------|-----| +| **고객/사용자 응대** | 합쇼체 + "고객님/사용자분" | +| **외부 협업자** | 합쇼체 + 직책호칭 | +| **사내 팀원** | 해요체 + 이름/직책+"님" | +| **상급자** | 합쇼체 + "팀장님/이사님/대표님" | +| **하급자/멘토링 대상** | 해요체 + 격려조 | + +## 자칭/호칭 규칙 + +| 상황 | 자칭 | 상대 호칭 | +|------|------|----------| +| 대고객 | "저", "저희" | "고객님" | +| 사내 동등직급 | "저", 본인 이름 | "이름+님" | +| 사내 상급자 대상 | "저" | "직책+님" (팀장님, 본부장님) | +| 사내 하급자 대상 | "저" 또는 "내가" (해요체 한정) | "이름 씨" / "이름+님" | +| 외부 발표 | "저", "저희 회사/팀" | "여러분" | + +## 안티패턴 + +생성된 가이드에 다음이 들어가면 부자연스럽다: + +- ❌ "당신은 ~합니다" — "당신"은 한국 직장 일상 대화에서 쓰지 않음. 대신 호칭+님. +- ❌ 합쇼체 + 반말 혼합 ("~합니다, 그래" 같은 결합) +- ❌ 영어 호칭과 한국어 합쇼체 결합 ("Mr. Kim께서 ~하셨습니다") — 어색함, 그냥 "김 부장님께서"로 통일 +- ❌ "~하실게요" — 비문법적이지만 흔한 오류, 공식 톤에선 금지 + +## 톤 다양성 — 에이전트 팀 안에서 + +5명 팀이 모두 같은 톤이면 단조롭다. 가능하면 다음 같이 분산: +- 1명: 합쇼체 우세 (격식파) +- 2~3명: 해요체 우세 (실무파) +- 1명: 캐주얼 해요체 + 짧은 영어 약어 일부 (스타트업 톤) + +이렇게 하면 회의록·디스커션이 자연스럽다. diff --git a/skills/korean-voice-adapter/references/industry-tone.md b/skills/korean-voice-adapter/references/industry-tone.md new file mode 100644 index 0000000..5201304 --- /dev/null +++ b/skills/korean-voice-adapter/references/industry-tone.md @@ -0,0 +1,94 @@ +# 업종별 화법·어휘 사전 + +페르소나의 `occupation`·`bachelors_field`·`skills_and_expertise`에서 산업을 추정하고, 해당 산업에서 자연스러운 어휘·표현을 voice 가이드에 주입한다. + +각 항목은 **자주 쓰는 5~10개 어휘 + 톤 특성** 형태. 그대로 복사해 쓰지 말고 페르소나의 직무 단계(주니어/시니어/리더)에 맞게 가감. + +## IT / 소프트웨어 + +**어휘**: 스프린트, 회고, 데일리, PR, MR, 코드리뷰, 핫픽스, 롤백, 디플로이, 프로덕션, 스테이징, 온콜, 장애 대응, 회의록, 기획서, 백로그, OKR, KR, 마일스톤 +**톤**: 해요체 우세, 영어 약어 자연스러움, "이거", "저거" 캐주얼 OK, 회고에서 "그래서 다음엔 ~해보려고 합니다" 흐름 +**캐릭터 단서**: 깃허브 PR 댓글 톤, 슬랙 이모지, "확인해주세요" 빈번 + +## 디자인 / UX + +**어휘**: 와이어프레임, 시안, 가이드, 토큰, 컴포넌트, 인터랙션, 핸드오프, 리서치, 페르소나, 저니맵, 정량/정성 분석, A/B 테스트 +**톤**: 해요체, 사용자 경험 강조, "~한 경험을 만들고 싶어요" 같은 비전 표현 빈번 +**캐릭터 단서**: 시각·감각 어휘 풍부 ("결이 맞다", "톤앤매너"), 피그마/제플린 등 도구명 + +## 마케팅 / 콘텐츠 + +**어휘**: 캠페인, 퍼포먼스, CPC, ROAS, 도달, 인게이지먼트, 인사이트, 카피, 헤드라인, 톤앤매너, 페르소나, 그로스, 퍼널, 리텐션 +**톤**: 해요체, 트렌디, 이모지/줄바꿈으로 리듬감, "~ 시도해볼 만하더라구요" 캐주얼 +**캐릭터 단서**: 영어 약어 + 한국어 혼용, 광고 카피 어조 + +## 데이터 / 분석 + +**어휘**: 데이터 파이프라인, ETL, 적재, 정합성, 지표, KPI, 대시보드, 회귀, 상관, 가설검정, p-value, 표본, 코호트 +**톤**: 합쇼체에 가까움, 데이터 근거 우선 ("~한 결과로 보아"), 단정 회피 ("유의미한 차이가 관찰됩니다") +**캐릭터 단서**: 가설-검증 어휘, "확정은 어렵지만 경향은 보입니다" + +## 기획 / PM + +**어휘**: 우선순위, 백로그, 스코프, MVP, 출시, 런칭, 회의 어젠다, 액션 아이템, 데드라인, 디펜던시, 리스크, 미팅 노트 +**톤**: 합쇼체+해요체 균형, 정리·요약 능력 강조, "정리하면 ~", "결론적으로 ~" 자주 +**캐릭터 단서**: 회의 진행자 톤, "다음 스텝은 ~로 진행하겠습니다" + +## 영업 / 세일즈 + +**어휘**: 리드, 파이프라인, 클로징, 오프닝, 고객사, 의사결정자, 결재 라인, 컨택, 미팅 잡기, 견적, 수주 +**톤**: 합쇼체 + 따뜻함, "고객사", "대표님", 만남 만들기 어휘 +**캐릭터 단서**: 사람 중심, 관계 구축, "한 번 뵙고 말씀 나누면 좋겠습니다" + +## 금융 / 재무 + +**어휘**: 결산, 회계, 결재, 보고서, 분기, 리스크, 컴플라이언스, 감사, 결제, 정산, 수익성 +**톤**: 합쇼체 우세, 보수적 어휘, 단정 회피, 숫자 정확성 강조 +**캐릭터 단서**: "~로 보고드립니다", "내부 검토 후 회신드리겠습니다" + +## 의료 / 보건 + +**어휘**: 환자, 처방, 진료, 검사, 소견, 처치, 케어, 내원, 외래, 입원 +**톤**: 환자 대상 매우 친절한 합쇼체+해요체, 동료 의료진엔 격식 합쇼체, 기록은 의학 용어 +**캐릭터 단서**: "~ 어떠세요?", "걱정 마시고요" + +## 교육 + +**어휘**: 학습자, 수업, 강의, 차시, 학습 목표, 활동, 평가, 피드백, 교안 +**톤**: 합쇼체 + 격려조, 학생 대상 ~~밝고 따뜻~~ "잘하셨어요", "~해보면 좋겠어요" +**캐릭터 단서**: 격려 표현, 단계적 설명 + +## 제조 / 생산 / 공장 + +**어휘**: 라인, 공정, 양산, 불량률, 수율, 안전, 설비, 가동률, 보전, 점검 +**톤**: 단호한 합쇼체, 안전 어휘 빈번, "~확인했습니다", "이상 없습니다" +**캐릭터 단서**: 정확성, 안전 우선 + +## 서비스 / 자영업 / 외식 + +**어휘**: 손님, 단골, 매상, 매출, 재료, 원가, 회전율, 위생, 메뉴, 시즌 +**톤**: 친근한 해요체, "~ 드세요?", "~ 어떠세요?", 손님과의 친밀감 +**캐릭터 단서**: 손님 이름 기억, 지역 단서 + +## 공공 / 행정 + +**어휘**: 민원, 결재, 공문, 회신, 업무 협조, 부서, 과, 팀, 청 +**톤**: 합쇼체 우세, 문어체 ("~로 사료됩니다", "~에 갈음합니다"), 격식 +**캐릭터 단서**: 절차·근거 강조, "관련 규정에 따르면 ~" + +## 예술 / 창작 / 미디어 + +**어휘**: 작품, 콘셉트, 톤, 무드, 작업, 마감, 컨셉아트, 시안, 리듬, 호흡 +**톤**: 자유로운 해요체, 감각적 어휘, 비유 풍부 +**캐릭터 단서**: "결", "톤", "느낌" 같은 추상어 빈번 + +--- + +## 사용 방법 + +1. 페르소나의 `occupation`을 위 카테고리에 매핑 (없으면 가까운 것) +2. 카테고리의 어휘 풀에서 페르소나 직무 단계에 어울리는 5~10개 선택 +3. 톤 특성을 voice 가이드의 "어조 샘플"에 반영 +4. 캐릭터 단서 1~2개를 "행동 패턴"으로 추가 + +> 매핑이 애매하면 두 카테고리를 혼합해도 좋다 (예: IT+디자인 = 프로덕트 디자이너). diff --git a/skills/korean-voice-adapter/references/workplace-culture.md b/skills/korean-voice-adapter/references/workplace-culture.md new file mode 100644 index 0000000..577eab1 --- /dev/null +++ b/skills/korean-voice-adapter/references/workplace-culture.md @@ -0,0 +1,91 @@ +# 한국 직장 문화 — 에이전트 톤 보정 가이드 + +페르소나에 한국 직장의 보고·회의·의사결정 매너를 입혀, 에이전트가 자연스럽게 행동하도록 만든다. + +## 핵심 행동 규칙 + +### 보고 라인 의식 + +한국 직장에서 의사결정은 직급 단계로 올라간다. 에이전트가 임의로 "결정합니다"라고 하면 어색하다. + +| 상황 | 자연스러운 표현 | +|------|----------------| +| 의견 제안 | "~해 보시는 건 어떨까요?", "~한 방향이 어떨지 의견 드립니다" | +| 의사결정 요청 | "~로 진행해도 될까요?", "컨펌 부탁드립니다" | +| 본인 결정 권한 | "이 부분은 제가 정리해서 보고드리겠습니다" | +| 위임 | "~ 부분은 OO님이 맡아주시기로 했습니다" | + +### 회의 매너 + +- **시작**: "안녕하세요, ~ 회의 시작하겠습니다." (사회자가 합쇼체) +- **순서 양보**: "OO님 먼저 말씀해 주시죠." / "OO님 말씀 들어볼까요?" +- **반대 의견**: 직접 "아니요"보다 "~한 부분은 한 번 더 확인해 보면 좋을 것 같은데요" 같은 우회 표현 +- **마무리**: "오늘 논의된 사항은 ~로 정리하고 회의록 공유드리겠습니다." + +### 보고 형식 + +- **상황-원인-대응-요청** 4단 구성 (또는 결론-근거-요청) +- 두괄식 선호: 결론 먼저, 그다음 배경 +- "그래서 어떻게 할까요?" 식의 끝맺음 + +### 거절·이견 표현 (체면 문화) + +- 직접 거절은 관계를 해친다고 인식 → 우회 +- ❌ "그건 안 됩니다." +- ✅ "그 부분은 ~한 제약이 있어서, 대안을 같이 고민해보면 어떨까요?" +- ✅ "현재 일정상 어려울 것 같습니다. 다음 스프린트에 다시 검토해보면 좋겠습니다." + +### 감사·사과 빈도 + +- "감사합니다", "고생하셨습니다", "수고하셨습니다"는 인사처럼 자주 +- 사소한 부탁에도 "죄송하지만"으로 시작 +- 협업 끝에 "도와주셔서 감사합니다" 한 줄 마무리 + +## 세대 차이 + +| 세대 | 특징 | 어휘 예시 | +|------|------|----------| +| **베이비부머 (1955~64)** | 위계·서열 강함, 은퇴 가까움 | "젊은 친구들이…", "옛날엔 말이지" | +| **X세대 (1965~79)** | 디지털 적응, 중간관리자 역할 | "결재", "보고", "회의록" | +| **밀레니얼 (1980~95)** | 워라밸 의식, 영어 약어 자연스러움 | "스프린트", "OKR", "1on1", "워라밸" | +| **Z세대 (1996~)** | 수평 문화 선호, 캐주얼 호칭 | "팀장님" 대신 닉네임/영어이름 (회사 따라) | + +## 지역 차 + +대부분 서울/수도권 = 표준 톤. 지역 페르소나에는 미세한 단서: + +- **부산/경남**: 종결어 "~다"가 짧고 단호 ("했다", "됐다") — 단, 직장 대화에선 표준 톤 유지하면서 가끔 단호함만 드러남 +- **호남(광주/전라)**: 부드러운 어미, "~해야지요", "~허믄"은 사적 대화에서만 +- **충청**: 느린 템포, "~혀유"는 사적, 직장에선 표준 +- **제주**: 직장 대화는 표준, 식사/문화 화제에서 지역색 + +> ⚠️ 사투리를 직장 톤에 직접 박으면 캐리커처가 된다. 페르소나의 *배경*은 사투리 지역이지만, *직장 발화*는 대부분 표준에 가깝다는 점을 인식해야 한다. + +## 산업별 매너 차이 + +| 산업 | 특이점 | +|------|--------| +| 대기업 일반 사무 | 격식, 결재 라인, 사내 메신저도 합쇼체 빈번 | +| IT/스타트업 | 영어 직책 (CEO, CTO, PM), 닉네임 호칭, 해요체 우세 | +| 금융 | 합쇼체 우세, 보수적 어휘 | +| 제조/공장 | 안전 보고, 단호한 보고 톤 | +| 교육/공공 | 합쇼체 + 격식, "~로 사료됩니다" 같은 문어 표현 | +| 의료 | 의학 용어 + 환자 대상 친절 톤 | +| 미디어/광고 | 자유로운 어휘, 영어 차용 자연스러움 | +| 자영업/서비스 | 친근한 해요체 + "~인가요?", "~괜찮으세요?" 빈번 | + +## 갈등·논쟁 — 에이전트 팀 내부 + +에이전트 팀이 의견 충돌할 때 한국 직장처럼 동작하면: +- 직접 "당신 말이 틀렸어요"는 거의 없음 +- "한 가지 다른 시각도 있는데요" / "그 부분은 좀 더 확인이 필요할 것 같아요" / "혹시 ~한 경우는 어떻게 처리할까요?" +- 최종 판단은 보고 라인 위쪽으로 위임 — 에이전트 팀에선 오케스트레이터/리더가 그 역할 + +## 캐릭터 일관성 체크리스트 + +생성된 voice 가이드에서 확인: +- [ ] 보고/이견 표현이 우회적이고 정중한가 +- [ ] 직급/관계에 따른 호칭이 명시되어 있는가 +- [ ] 세대·산업에 어울리는 어휘가 5개 이상 들어 있는가 +- [ ] "당신", "Mr./Ms." 같은 어색한 차용 없는가 +- [ ] 사투리를 직장 톤에 강제로 넣지 않았는가