From e05cd392ccfbb7bd0d425ccb4961e44f4c705c56 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 09:20:41 +0000 Subject: [PATCH] Add batch blind-eval harness and style retrieval, verified end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit blind_eval.py runs PoC #1's blind-eval methodology over N held-out corpus dialogues automatically (generate_draft.py refactored to expose draft_reply() so both share the same drafting logic). retrieve_style.py implements the keyword/recency search from tech-design.md §2-1 and wires into generate_draft.py as --history, replacing hand-curated --style files. Bash access was intermittently restricted for part of this session (auto-mode safety classifier), so these were initially written and committed-pending without live execution. Now verified for real: generate_draft.py's existing behavior still holds after the draft_reply() refactor, blind_eval.py runs cleanly against val.jsonl, and retrieve_style.py's original weighted-sum scoring had a real bug -- recency drowned out keyword overlap for short Korean messages (particle attachment means "핀란드" and "핀란드는" don't share a token), so it was effectively returning the most recent messages regardless of topic. Fixed by ranking on (overlap, recency) instead of a weighted sum, confirmed the Finland-related exemplar now ranks first for a matching query. --- docs/PLANNING.md | 13 ++- docs/tech-design.md | 3 +- poc/tone-corpus/README.md | 57 +++++++++++-- poc/tone-corpus/blind_eval.py | 130 ++++++++++++++++++++++++++++++ poc/tone-corpus/generate_draft.py | 75 +++++++++++------ poc/tone-corpus/retrieve_style.py | 64 +++++++++++++++ 6 files changed, 307 insertions(+), 35 deletions(-) create mode 100644 poc/tone-corpus/blind_eval.py create mode 100644 poc/tone-corpus/retrieve_style.py diff --git a/docs/PLANNING.md b/docs/PLANNING.md index 746da2c..099b887 100644 --- a/docs/PLANNING.md +++ b/docs/PLANNING.md @@ -130,9 +130,18 @@ - [x] 에스컬레이션 판정기(규칙 기반) 구현 — `poc/tone-corpus/escalation_filter.py`, LLM 호출 전에 먼저 거는 하드 게이트. 자체 테스트 10/10, 검증셋 82,305개 발화 기준 트리거율 0.93% - [x] 응답 초안 생성기 프로토타입 — `poc/tone-corpus/generate_draft.py` (말투 예시 + 대화 맥락 → - LLM 호출로 초안 생성, 에스컬레이션 케이스는 `[ESCALATE]`로 거절). API 키 없이 코퍼스 실제 - 대화로 프롬프트 구성까지만 확인함 — 실제 자동 호출은 `ANTHROPIC_API_KEY` 설정 후 가능 + Gemini 호출로 초안 생성, 에스컬레이션 케이스는 하드 게이트로 거절). Cursor 환경에서 실제 + `GEMINI_API_KEY`로 라이브 호출 성공 확인함 (스타일 예시 반말·ㅋ톤에 맞는 짧은 답장 초안 생성) +- [x] 블라인드 평가 자동화 스크립트 — `poc/tone-corpus/blind_eval.py`, 검증셋 대화를 자동으로 + held-out 처리해 초안 생성 + 실제 답장을 나란히 리포트. 이 세션의 실행 도구 제한으로 직접 + 돌려보진 못함 — 실행 전 가벼운 샘플(`--n 5`)로 먼저 확인할 것 +- [x] 말투 검색기(retrieval) 구현 — `poc/tone-corpus/retrieve_style.py` (키워드 자카드 유사도 + + 최근성 가중치, 임베딩 없이 v1 설계 그대로). `generate_draft.py --history`로 연결해 스타일 + 예시를 손으로 안 골라도 자동 검색되게 함 - [ ] PoC #1·#3 실제 실행 (참가자 모집, 대화 샘플 수집, 역할극 인터뷰) 및 Go/No-Go 판정 +- [x] `blind_eval.py`, `retrieve_style.py`, `generate_draft.py --history` 연동 실제 실행 검증 — + 검색기의 recency 가중치가 키워드 겹침을 압도하는 버그 발견·수정함 (`poc/tone-corpus/README.md` + "말투 검색기" 참고) - [x] 클릭 가능한 프로토타입 제작, 뱃지·거부권 UX 포함 — 읽씹 종결/거부권/에스컬레이션/자율성 설정 4개 장면을 실제로 눌러볼 수 있는 프로토타입으로 제작 (Claude 아티팩트, 필요 시 공유 링크로 배포). PoC #3 역할극 진행 시 이 프로토타입을 그대로 자극재로 사용 가능 diff --git a/docs/tech-design.md b/docs/tech-design.md index 11f0143..265404b 100644 --- a/docs/tech-design.md +++ b/docs/tech-design.md @@ -41,7 +41,8 @@ v1에서는 커스텀 모델을 새로 학습하지 않는다. 대신 **검색 기기 내에서만 저장. 원문은 서버로 안 올라간다 (§ 위 원칙과 동일) 2. **검색**: 지금 답장해야 할 맥락과 유사한 과거 발화를 찾는다 — v1은 가벼운 키워드/최근성 기반 검색으로 시작하고, 임베딩 기반 검색은 필요성이 확인되면 추가한다 (지금부터 임베딩 - 인프라를 먼저 만들지 않는다) + 인프라를 먼저 만들지 않는다). 구현: [`poc/tone-corpus/retrieve_style.py`](../poc/tone-corpus/retrieve_style.py) + — 자카드 유사도 + 최근성 가중치. `generate_draft.py --history`로 바로 연결됨 3. **생성**: 검색된 예시 + 최근 대화 맥락을 `generate_draft.py`와 같은 프롬프트 계약으로 서버 LLM(Gemini)에 보내 초안 하나를 받는다. 온디바이스로 충분해지면 이 호출을 온디바이스 모델로 교체하되, 프롬프트 계약(예시+맥락 in, 초안 1개 out)은 그대로 둔다 diff --git a/poc/tone-corpus/README.md b/poc/tone-corpus/README.md index 1a28045..4b1b214 100644 --- a/poc/tone-corpus/README.md +++ b/poc/tone-corpus/README.md @@ -47,8 +47,10 @@ python3 generate_draft.py --style style_examples.txt --context context.txt Gemini(`google-genai`)를 쓴다 — 기본 모델은 `--model`로 바꿀 수 있고 기본값은 `gemini-2.5-flash`. `GEMINI_API_KEY`가 없으면 실제 전송될 프롬프트만 stdout에 찍고 끝난다. -이 세션에는 그 키가 없어서, 실제 호출 대신 이 코퍼스의 실제 대화 하나로 프롬프트 구성만 -확인하고 초안은 직접 사람이 작성해 아래처럼 대조해봤다. + +**Cursor 환경에서 `.env`에 실제 키를 넣고 라이브 호출까지 성공 확인됨** (2026-07-29) — 스타일 +예시(반말·ㅋ톤)에 맞는 짧은 캐주얼 답장 초안이 그대로 나왔다. 이 Claude Code 세션에는 키가 없어서, +동일한 검증은 이 세션에서는 프롬프트 구성 확인 + 사람이 직접 쓴 초안 대조로 대체했다 (아래 참고). ### `GEMINI_API_KEY`는 어디에 설정하나 @@ -56,11 +58,8 @@ Gemini(`google-genai`)를 쓴다 — 기본 모델은 `--model`로 바꿀 수 `generate_draft.py`가 실행 시 이 파일을 읽는다. `.env`는 `.gitignore`로 커밋되지 않는다. - **대안**: 터미널에서 `export GEMINI_API_KEY=...`(임시) 또는 셸 프로필에 등록. Google AI Studio에서 발급한 키를 그대로 쓰면 된다. -- **이 Claude Code 세션/환경에서 직접 실행해보고 싶다면**: 이 대화창에 `export GEMINI_API_KEY=실제키`를 - 실행해달라고 하면 되는데, 그러면 **키 값이 이 대화 기록에 그대로 남는다** — 무제한 결제 키가 아니라 - 사용량 제한을 걸어둔 테스트용 키를 쓰는 걸 권장한다. 대화 기록에 남기고 싶지 않다면, 이 환경(Claude - Code on the web)의 환경설정에서 환경변수로 등록하는 방법도 있다 — 다만 이미 실행 중인 이 세션에 - 즉시 반영되는지는 환경 재시작이 필요할 수 있어 확실치 않다. +- 대화창에 직접 키 값을 붙여넣는 건 대화 기록에 그대로 남으므로 권장하지 않는다 — 위 두 방법 중 + 하나로, 사용량 제한을 걸어둔 키를 쓸 것. **샘플 검증** (검증셋 `id=003820`, "고교학점제" 대화, B의 마지막 답장을 가리고 앞 6개 발화만 스타일 예시로 사용): @@ -88,6 +87,50 @@ python3 escalation_filter.py --selftest # 내장 테스트 케이스 10개, 10 요청일 가능성이 훨씬 높지만, 이 수치 자체는 오탐률의 상한선 정도로 참고할 것. 규칙은 PoC #1 실사용 로그로 계속 튜닝한다 (`docs/tech-design.md` §3, `docs/risk-log.md` 참고). +## 블라인드 평가 자동화 (`blind_eval.py`) + +`docs/poc-plan.md`의 블라인드 평가를 검증셋 전체에 대해 자동으로 돌려보는 배치 스크립트. 각 +대화의 마지막 발화를 가리고, 그 화자의 앞선 발화를 말투 예시로 써서 `generate_draft.draft_reply`로 +초안을 생성한 뒤 실제 발화와 나란히 JSONL로 남긴다. + +```bash +python3 blind_eval.py --corpus --n 30 --output eval_report.jsonl +``` + +**주의 — 이건 진짜 PoC #1이 아니다.** 익명 화자쌍의 일반 대화라 "내 말투 같다"를 판단할 수 없다. +자동 계산하는 `length_ratio`(길이 비율), `formal_match`(존댓말/반말 일치 여부)도 사람이 5점 척도로 +평가하는 진짜 지표의 대체재가 아니라, **대량으로 돌려서 파이프라인 자체가 말이 되는 응답을 +내는지**(요청한 형식으로 나오는지, 에스컬레이션이 과하게/적게 걸리는지) 보는 사전 체크용이다. +진짜 개인화 평가는 `poc-materials.md` §1로 실제 참가자 데이터를 모은 뒤에만 가능하다. + +`--n 5`로 실제 검증셋에 돌려 확인함 — 샘플링, 컨텍스트/스타일 예시 구성, JSONL 출력 전부 정상 +동작 (API 키 없이 `no_key` 상태로 5/5 처리됨, 리포트 레코드 형식도 의도대로 나옴). + +## 말투 검색기 (`retrieve_style.py`) + +`docs/tech-design.md` §2-1에서 설계한 "가벼운 키워드/최근성 기반 검색"의 실제 구현. 그 사람의 +과거 발화 전체(오래된 것부터 한 줄에 하나)와 지금 답장해야 할 메시지를 주면, 키워드 겹침(자카드 +유사도) + 최근성 가중치로 점수를 매겨 가장 비슷한 발화 상위 k개를 돌려준다. 임베딩은 쓰지 않는다 — +"지금부터 임베딩 인프라를 먼저 만들지 않는다"는 설계 원칙을 그대로 따름. + +```bash +python3 retrieve_style.py --history history.txt --query "그 얘기 진짜야?" --k 6 +``` + +`generate_draft.py`에 그대로 연결되어 있다 — `--style` 대신 `--history`를 주면 매번 스타일 예시를 +손으로 고르지 않고 자동으로 검색해서 쓴다: + +```bash +python3 generate_draft.py --history history.txt --context context.txt +``` + +**버그를 하나 잡음**: 처음엔 `overlap + RECENCY_WEIGHT * recency`로 점수를 더했는데, 실제로 +돌려보니 짧은 한국어 문장은 조사가 붙어서("핀란드" vs "핀란드는") 토큰이 잘 안 겹쳐 자카드 +유사도가 거의 항상 최근성 항보다 작았다 — 결과적으로 검색이 사실상 "그냥 최신 메시지 K개"가 +되는 문제였다. `(overlap, recency)` 튜플로 정렬해서 **키워드가 실제로 겹치는 게 항상 먼저 오고, +동점일 때만 최근성으로 갈리게** 고쳤다. 검증: `--query "핀란드는 교육이 좋대"`로 돌리니 겹치는 +토큰이 있는 과거 발화가 1위로 올라오고, 나머지는 최근 순으로 정렬됨을 확인. + ## 반드시 지킬 것 - **원본 zip과 이 스크립트의 출력(JSONL)을 git에 커밋하지 않는다.** AI-Hub 데이터는 이용약관상 diff --git a/poc/tone-corpus/blind_eval.py b/poc/tone-corpus/blind_eval.py new file mode 100644 index 0000000..ab99b99 --- /dev/null +++ b/poc/tone-corpus/blind_eval.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Batch version of PoC #1's blind evaluation (`docs/poc-plan.md`): hold out +each dialogue's last utterance, draft a reply from the rest via +`generate_draft.draft_reply`, and report it next to the real one. + +This is still the general AI-Hub corpus, not a real person's messages -- +so it can only sanity-check "does the pipeline produce plausible Korean +SNS replies," not "does this feel like me." The real PoC #1 blind eval +(human raters, 5-point scale) still needs actual participant data per +`docs/poc-materials.md` §1. Treat this as a pre-check, not a substitute. + +Usage: + python3 blind_eval.py --corpus --n 30 \ + --output eval_report.jsonl + +Without GEMINI_API_KEY set, each sample's status will be "no_key" and the +report just records what would have been sent -- useful for validating the +sampling logic itself before spending API calls. +""" +import argparse +import json +import random +import re +import sys + +from generate_draft import draft_reply, load_dotenv_if_present + +MIN_TURNS = 6 +MIN_STYLE_EXAMPLES = 3 +FORMAL_ENDING = re.compile(r"(요|니다)[.!?~ㅋㅎㅠㅜ]*$") + + +def build_sample(dialogue): + utts = dialogue.get("utterances", []) + if len(utts) < MIN_TURNS: + return None + + held_speaker = utts[-1]["speaker"] + real_reply = utts[-1]["text"] + context_lines = [] + style_examples = [] + for u in utts[:-1]: + label = "나" if u["speaker"] == held_speaker else "상대" + context_lines.append(f"{label}: {u['text']}") + if u["speaker"] == held_speaker: + style_examples.append(u["text"]) + + if len(style_examples) < MIN_STYLE_EXAMPLES: + return None + + return { + "id": dialogue.get("id"), + "topic": dialogue.get("topic"), + "style_examples": style_examples, + "context_lines": context_lines, + "real_reply": real_reply, + } + + +def is_formal(text): + return bool(FORMAL_ENDING.search(text.strip())) + + +def evaluate(sample, model): + status, text = draft_reply(sample["style_examples"], sample["context_lines"], model=model) + record = { + "id": sample["id"], + "topic": sample["topic"], + "real_reply": sample["real_reply"], + "status": status, + } + if status == "ok": + record["generated_draft"] = text + record["length_ratio"] = round(len(text) / max(len(sample["real_reply"]), 1), 2) + record["formal_match"] = is_formal(text) == is_formal(sample["real_reply"]) + elif status == "escalate": + record["escalation_reason"] = text + else: + record["prompt_preview"] = text + return record + + +def summarize(records): + total = len(records) + counts = {} + for r in records: + counts[r["status"]] = counts.get(r["status"], 0) + 1 + ok = [r for r in records if r["status"] == "ok"] + summary = {"total": total, "status_counts": counts} + if ok: + summary["avg_length_ratio"] = round(sum(r["length_ratio"] for r in ok) / len(ok), 2) + summary["formal_match_rate"] = round(sum(r["formal_match"] for r in ok) / len(ok), 2) + return summary + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--corpus", required=True, help="val.jsonl 또는 train.jsonl 경로") + ap.add_argument("--n", type=int, default=30, help="평가할 대화 수") + ap.add_argument("--seed", type=int, default=0) + ap.add_argument("--model", default="gemini-2.5-flash") + ap.add_argument("--output", required=True, help="결과를 쓸 JSONL 경로") + args = ap.parse_args() + + load_dotenv_if_present() + + with open(args.corpus, encoding="utf-8") as f: + dialogues = [json.loads(line) for line in f if line.strip()] + + samples = [s for s in (build_sample(d) for d in dialogues) if s] + if not samples: + raise SystemExit(f"{MIN_TURNS}턴 이상, 화자 발화 {MIN_STYLE_EXAMPLES}개 이상인 대화가 없습니다.") + + rng = random.Random(args.seed) + chosen = rng.sample(samples, min(args.n, len(samples))) + + records = [] + with open(args.output, "w", encoding="utf-8") as out: + for i, sample in enumerate(chosen, 1): + record = evaluate(sample, args.model) + records.append(record) + out.write(json.dumps(record, ensure_ascii=False) + "\n") + print(f"[{i}/{len(chosen)}] {record['id']} status={record['status']}", file=sys.stderr) + + summary = summarize(records) + print(json.dumps(summary, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/poc/tone-corpus/generate_draft.py b/poc/tone-corpus/generate_draft.py index f6ac001..767154e 100644 --- a/poc/tone-corpus/generate_draft.py +++ b/poc/tone-corpus/generate_draft.py @@ -13,10 +13,14 @@ Usage: # Prefer repo-root .env (GEMINI_API_KEY=...), or: export GEMINI_API_KEY=... python3 generate_draft.py --style style_examples.txt --context context.txt + # or, to auto-pick exemplars from a person's message history instead of + # a hand-curated style file (retrieve_style.py, tech-design.md §2-1): + python3 generate_draft.py --history history.txt --context context.txt style_examples.txt: one example message per line, written by the target person. context.txt: one line per turn, formatted as "상대: ..." or "나: ...", ending with the incoming message that needs a reply. +history.txt: the person's past messages, one per line, oldest first. """ import argparse import os @@ -24,6 +28,7 @@ import sys from pathlib import Path from escalation_filter import check as check_escalation +from retrieve_style import retrieve as retrieve_style_examples def load_dotenv_if_present(): @@ -73,45 +78,65 @@ def last_incoming_text(context_lines): return last.split(": ", 1)[1] if ": " in last else last -def main(): - ap = argparse.ArgumentParser() - ap.add_argument("--style", required=True, help="말투 예시 파일 (한 줄에 한 문장)") - ap.add_argument("--context", required=True, help="대화 맥락 파일 (한 줄에 한 발화)") - ap.add_argument("--model", default="gemini-2.5-flash") - args = ap.parse_args() - - load_dotenv_if_present() - - with open(args.style, encoding="utf-8") as f: - style_examples = [l.strip() for l in f if l.strip()] - with open(args.context, encoding="utf-8") as f: - context_lines = [l.strip() for l in f if l.strip()] +def draft_reply(style_examples, context_lines, model="gemini-2.5-flash", api_key=None): + """Returns (status, text). status is one of "escalate" | "no_key" | "ok". + "escalate": text is the escalation reason (금전/약속 확정/감정적으로 무거운 주제). + "no_key": text is the prompt that would have been sent (GEMINI_API_KEY missing). + "ok": text is the generated draft. + """ gate = check_escalation(last_incoming_text(context_lines)) if gate.escalate: - # Hard gate -- no LLM call at all. tech-design.md §3: money, appointment - # confirmation, and emotionally heavy content escalate at every level, - # with no exception. - print(f"[ESCALATE:{gate.reason}] 이 내용은 본인 확인이 필요합니다.") - return + return "escalate", gate.reason - api_key = os.environ.get("GEMINI_API_KEY") + api_key = api_key or os.environ.get("GEMINI_API_KEY") if not api_key: - print("GEMINI_API_KEY가 설정되지 않았습니다. 아래는 실제로 전송될 프롬프트입니다:\n", - file=sys.stderr) - print(build_user_prompt(style_examples, context_lines)) - return + return "no_key", build_user_prompt(style_examples, context_lines) from google import genai # pip install google-genai from google.genai import types client = genai.Client(api_key=api_key) resp = client.models.generate_content( - model=args.model, + model=model, contents=build_user_prompt(style_examples, context_lines), config=types.GenerateContentConfig(system_instruction=SYSTEM_PROMPT, max_output_tokens=300), ) - print(resp.text.strip()) + return "ok", resp.text.strip() + + +def main(): + ap = argparse.ArgumentParser() + style_group = ap.add_mutually_exclusive_group(required=True) + style_group.add_argument("--style", help="말투 예시 파일 (한 줄에 한 문장, 직접 큐레이션)") + style_group.add_argument("--history", help="과거 발화 전체 파일 (한 줄에 하나) -- 맥락과 비슷한 것을 자동 검색") + ap.add_argument("--k", type=int, default=6, help="--history 사용 시 검색해올 예시 개수") + ap.add_argument("--context", required=True, help="대화 맥락 파일 (한 줄에 한 발화)") + ap.add_argument("--model", default="gemini-2.5-flash") + args = ap.parse_args() + + load_dotenv_if_present() + + with open(args.context, encoding="utf-8") as f: + context_lines = [l.strip() for l in f if l.strip()] + + if args.style: + with open(args.style, encoding="utf-8") as f: + style_examples = [l.strip() for l in f if l.strip()] + else: + with open(args.history, encoding="utf-8") as f: + history = [l.strip() for l in f if l.strip()] + style_examples = retrieve_style_examples(history, last_incoming_text(context_lines), k=args.k) + + status, text = draft_reply(style_examples, context_lines, model=args.model) + if status == "escalate": + print(f"[ESCALATE:{text}] 이 내용은 본인 확인이 필요합니다.") + elif status == "no_key": + print("GEMINI_API_KEY가 설정되지 않았습니다. 아래는 실제로 전송될 프롬프트입니다:\n", + file=sys.stderr) + print(text) + else: + print(text) if __name__ == "__main__": diff --git a/poc/tone-corpus/retrieve_style.py b/poc/tone-corpus/retrieve_style.py new file mode 100644 index 0000000..0af89da --- /dev/null +++ b/poc/tone-corpus/retrieve_style.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Keyword/recency-based retrieval -- the search step of the personalization +layer in `docs/tech-design.md` §2-1: "지금부터 임베딩 인프라를 먼저 만들지 +않는다" -- so this is plain token overlap + recency, not embeddings. + +Given a person's message history (one message per line, oldest first) and +the incoming message they need to reply to, returns the top-k past messages +most likely to show the relevant tone -- meant to feed `generate_draft.py`'s +--style input instead of a hand-curated file. + +Usage: + python3 retrieve_style.py --history history.txt --query "그 얘기 진짜야?" --k 6 +""" +import argparse +import re + +TOKEN_RE = re.compile(r"[가-힣A-Za-z0-9]+") + + +def tokenize(text): + return set(TOKEN_RE.findall(text)) + + +def _jaccard(a, b): + if not a or not b: + return 0.0 + return len(a & b) / len(a | b) + + +def retrieve(history_messages, query_text, k=6): + """history_messages: oldest-first list of past messages by the person. + Ranked by keyword overlap with query_text first; recency only breaks + ties. A weighted-sum of the two was tried and dropped -- Korean short + messages rarely share more than one or two tokens (no particle + stripping here, so "핀란드" and "핀란드는" don't match each other), + so any nonzero recency weight ended up drowning out real overlap and + just returning the most recent messages regardless of topic.""" + query_tokens = tokenize(query_text) + n = len(history_messages) + scored = [] + for i, msg in enumerate(history_messages): + overlap = _jaccard(tokenize(msg), query_tokens) + recency = i / max(n - 1, 1) + scored.append(((overlap, recency), msg)) + scored.sort(key=lambda pair: pair[0], reverse=True) + return [msg for _, msg in scored[:k]] + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--history", required=True, help="과거 발화 파일 (한 줄에 하나, 오래된 것부터)") + ap.add_argument("--query", required=True, help="지금 답장해야 할 상대 메시지") + ap.add_argument("--k", type=int, default=6) + args = ap.parse_args() + + with open(args.history, encoding="utf-8") as f: + history = [l.strip() for l in f if l.strip()] + + for msg in retrieve(history, args.query, k=args.k): + print(msg) + + +if __name__ == "__main__": + main()