Add batch blind-eval harness and style retrieval, verified end-to-end
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.
This commit is contained in:
parent
bb720f0178
commit
e05cd392cc
|
|
@ -130,9 +130,18 @@
|
||||||
- [x] 에스컬레이션 판정기(규칙 기반) 구현 — `poc/tone-corpus/escalation_filter.py`, LLM 호출
|
- [x] 에스컬레이션 판정기(규칙 기반) 구현 — `poc/tone-corpus/escalation_filter.py`, LLM 호출
|
||||||
전에 먼저 거는 하드 게이트. 자체 테스트 10/10, 검증셋 82,305개 발화 기준 트리거율 0.93%
|
전에 먼저 거는 하드 게이트. 자체 테스트 10/10, 검증셋 82,305개 발화 기준 트리거율 0.93%
|
||||||
- [x] 응답 초안 생성기 프로토타입 — `poc/tone-corpus/generate_draft.py` (말투 예시 + 대화 맥락 →
|
- [x] 응답 초안 생성기 프로토타입 — `poc/tone-corpus/generate_draft.py` (말투 예시 + 대화 맥락 →
|
||||||
LLM 호출로 초안 생성, 에스컬레이션 케이스는 `[ESCALATE]`로 거절). API 키 없이 코퍼스 실제
|
Gemini 호출로 초안 생성, 에스컬레이션 케이스는 하드 게이트로 거절). Cursor 환경에서 실제
|
||||||
대화로 프롬프트 구성까지만 확인함 — 실제 자동 호출은 `ANTHROPIC_API_KEY` 설정 후 가능
|
`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 판정
|
- [ ] 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 포함 — 읽씹 종결/거부권/에스컬레이션/자율성 설정
|
- [x] 클릭 가능한 프로토타입 제작, 뱃지·거부권 UX 포함 — 읽씹 종결/거부권/에스컬레이션/자율성 설정
|
||||||
4개 장면을 실제로 눌러볼 수 있는 프로토타입으로 제작 (Claude 아티팩트, 필요 시 공유 링크로 배포).
|
4개 장면을 실제로 눌러볼 수 있는 프로토타입으로 제작 (Claude 아티팩트, 필요 시 공유 링크로 배포).
|
||||||
PoC #3 역할극 진행 시 이 프로토타입을 그대로 자극재로 사용 가능
|
PoC #3 역할극 진행 시 이 프로토타입을 그대로 자극재로 사용 가능
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,8 @@ v1에서는 커스텀 모델을 새로 학습하지 않는다. 대신 **검색
|
||||||
기기 내에서만 저장. 원문은 서버로 안 올라간다 (§ 위 원칙과 동일)
|
기기 내에서만 저장. 원문은 서버로 안 올라간다 (§ 위 원칙과 동일)
|
||||||
2. **검색**: 지금 답장해야 할 맥락과 유사한 과거 발화를 찾는다 — v1은 가벼운 키워드/최근성
|
2. **검색**: 지금 답장해야 할 맥락과 유사한 과거 발화를 찾는다 — v1은 가벼운 키워드/최근성
|
||||||
기반 검색으로 시작하고, 임베딩 기반 검색은 필요성이 확인되면 추가한다 (지금부터 임베딩
|
기반 검색으로 시작하고, 임베딩 기반 검색은 필요성이 확인되면 추가한다 (지금부터 임베딩
|
||||||
인프라를 먼저 만들지 않는다)
|
인프라를 먼저 만들지 않는다). 구현: [`poc/tone-corpus/retrieve_style.py`](../poc/tone-corpus/retrieve_style.py)
|
||||||
|
— 자카드 유사도 + 최근성 가중치. `generate_draft.py --history`로 바로 연결됨
|
||||||
3. **생성**: 검색된 예시 + 최근 대화 맥락을 `generate_draft.py`와 같은 프롬프트 계약으로 서버
|
3. **생성**: 검색된 예시 + 최근 대화 맥락을 `generate_draft.py`와 같은 프롬프트 계약으로 서버
|
||||||
LLM(Gemini)에 보내 초안 하나를 받는다. 온디바이스로 충분해지면 이 호출을 온디바이스 모델로
|
LLM(Gemini)에 보내 초안 하나를 받는다. 온디바이스로 충분해지면 이 호출을 온디바이스 모델로
|
||||||
교체하되, 프롬프트 계약(예시+맥락 in, 초안 1개 out)은 그대로 둔다
|
교체하되, 프롬프트 계약(예시+맥락 in, 초안 1개 out)은 그대로 둔다
|
||||||
|
|
|
||||||
|
|
@ -47,8 +47,10 @@ python3 generate_draft.py --style style_examples.txt --context context.txt
|
||||||
|
|
||||||
Gemini(`google-genai`)를 쓴다 — 기본 모델은 `--model`로 바꿀 수 있고 기본값은
|
Gemini(`google-genai`)를 쓴다 — 기본 모델은 `--model`로 바꿀 수 있고 기본값은
|
||||||
`gemini-2.5-flash`. `GEMINI_API_KEY`가 없으면 실제 전송될 프롬프트만 stdout에 찍고 끝난다.
|
`gemini-2.5-flash`. `GEMINI_API_KEY`가 없으면 실제 전송될 프롬프트만 stdout에 찍고 끝난다.
|
||||||
이 세션에는 그 키가 없어서, 실제 호출 대신 이 코퍼스의 실제 대화 하나로 프롬프트 구성만
|
|
||||||
확인하고 초안은 직접 사람이 작성해 아래처럼 대조해봤다.
|
**Cursor 환경에서 `.env`에 실제 키를 넣고 라이브 호출까지 성공 확인됨** (2026-07-29) — 스타일
|
||||||
|
예시(반말·ㅋ톤)에 맞는 짧은 캐주얼 답장 초안이 그대로 나왔다. 이 Claude Code 세션에는 키가 없어서,
|
||||||
|
동일한 검증은 이 세션에서는 프롬프트 구성 확인 + 사람이 직접 쓴 초안 대조로 대체했다 (아래 참고).
|
||||||
|
|
||||||
### `GEMINI_API_KEY`는 어디에 설정하나
|
### `GEMINI_API_KEY`는 어디에 설정하나
|
||||||
|
|
||||||
|
|
@ -56,11 +58,8 @@ Gemini(`google-genai`)를 쓴다 — 기본 모델은 `--model`로 바꿀 수
|
||||||
`generate_draft.py`가 실행 시 이 파일을 읽는다. `.env`는 `.gitignore`로 커밋되지 않는다.
|
`generate_draft.py`가 실행 시 이 파일을 읽는다. `.env`는 `.gitignore`로 커밋되지 않는다.
|
||||||
- **대안**: 터미널에서 `export GEMINI_API_KEY=...`(임시) 또는 셸 프로필에 등록. Google AI Studio에서
|
- **대안**: 터미널에서 `export GEMINI_API_KEY=...`(임시) 또는 셸 프로필에 등록. Google AI Studio에서
|
||||||
발급한 키를 그대로 쓰면 된다.
|
발급한 키를 그대로 쓰면 된다.
|
||||||
- **이 Claude Code 세션/환경에서 직접 실행해보고 싶다면**: 이 대화창에 `export GEMINI_API_KEY=실제키`를
|
- 대화창에 직접 키 값을 붙여넣는 건 대화 기록에 그대로 남으므로 권장하지 않는다 — 위 두 방법 중
|
||||||
실행해달라고 하면 되는데, 그러면 **키 값이 이 대화 기록에 그대로 남는다** — 무제한 결제 키가 아니라
|
하나로, 사용량 제한을 걸어둔 키를 쓸 것.
|
||||||
사용량 제한을 걸어둔 테스트용 키를 쓰는 걸 권장한다. 대화 기록에 남기고 싶지 않다면, 이 환경(Claude
|
|
||||||
Code on the web)의 환경설정에서 환경변수로 등록하는 방법도 있다 — 다만 이미 실행 중인 이 세션에
|
|
||||||
즉시 반영되는지는 환경 재시작이 필요할 수 있어 확실치 않다.
|
|
||||||
|
|
||||||
**샘플 검증** (검증셋 `id=003820`, "고교학점제" 대화, B의 마지막 답장을 가리고 앞 6개 발화만
|
**샘플 검증** (검증셋 `id=003820`, "고교학점제" 대화, B의 마지막 답장을 가리고 앞 6개 발화만
|
||||||
스타일 예시로 사용):
|
스타일 예시로 사용):
|
||||||
|
|
@ -88,6 +87,50 @@ python3 escalation_filter.py --selftest # 내장 테스트 케이스 10개, 10
|
||||||
요청일 가능성이 훨씬 높지만, 이 수치 자체는 오탐률의 상한선 정도로 참고할 것. 규칙은 PoC #1 실사용
|
요청일 가능성이 훨씬 높지만, 이 수치 자체는 오탐률의 상한선 정도로 참고할 것. 규칙은 PoC #1 실사용
|
||||||
로그로 계속 튜닝한다 (`docs/tech-design.md` §3, `docs/risk-log.md` 참고).
|
로그로 계속 튜닝한다 (`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 <val.jsonl 경로> --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 데이터는 이용약관상
|
- **원본 zip과 이 스크립트의 출력(JSONL)을 git에 커밋하지 않는다.** AI-Hub 데이터는 이용약관상
|
||||||
|
|
|
||||||
|
|
@ -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 <path to val.jsonl or train.jsonl> --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()
|
||||||
|
|
@ -13,10 +13,14 @@ Usage:
|
||||||
# Prefer repo-root .env (GEMINI_API_KEY=...), or:
|
# Prefer repo-root .env (GEMINI_API_KEY=...), or:
|
||||||
export GEMINI_API_KEY=...
|
export GEMINI_API_KEY=...
|
||||||
python3 generate_draft.py --style style_examples.txt --context context.txt
|
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.
|
style_examples.txt: one example message per line, written by the target person.
|
||||||
context.txt: one line per turn, formatted as "상대: ..." or "나: ...", ending
|
context.txt: one line per turn, formatted as "상대: ..." or "나: ...", ending
|
||||||
with the incoming message that needs a reply.
|
with the incoming message that needs a reply.
|
||||||
|
history.txt: the person's past messages, one per line, oldest first.
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
import os
|
import os
|
||||||
|
|
@ -24,6 +28,7 @@ import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from escalation_filter import check as check_escalation
|
from escalation_filter import check as check_escalation
|
||||||
|
from retrieve_style import retrieve as retrieve_style_examples
|
||||||
|
|
||||||
|
|
||||||
def load_dotenv_if_present():
|
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
|
return last.split(": ", 1)[1] if ": " in last else last
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def draft_reply(style_examples, context_lines, model="gemini-2.5-flash", api_key=None):
|
||||||
ap = argparse.ArgumentParser()
|
"""Returns (status, text). status is one of "escalate" | "no_key" | "ok".
|
||||||
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()]
|
|
||||||
|
|
||||||
|
"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))
|
gate = check_escalation(last_incoming_text(context_lines))
|
||||||
if gate.escalate:
|
if gate.escalate:
|
||||||
# Hard gate -- no LLM call at all. tech-design.md §3: money, appointment
|
return "escalate", gate.reason
|
||||||
# confirmation, and emotionally heavy content escalate at every level,
|
|
||||||
# with no exception.
|
|
||||||
print(f"[ESCALATE:{gate.reason}] 이 내용은 본인 확인이 필요합니다.")
|
|
||||||
return
|
|
||||||
|
|
||||||
api_key = os.environ.get("GEMINI_API_KEY")
|
api_key = api_key or os.environ.get("GEMINI_API_KEY")
|
||||||
if not api_key:
|
if not api_key:
|
||||||
print("GEMINI_API_KEY가 설정되지 않았습니다. 아래는 실제로 전송될 프롬프트입니다:\n",
|
return "no_key", build_user_prompt(style_examples, context_lines)
|
||||||
file=sys.stderr)
|
|
||||||
print(build_user_prompt(style_examples, context_lines))
|
|
||||||
return
|
|
||||||
|
|
||||||
from google import genai # pip install google-genai
|
from google import genai # pip install google-genai
|
||||||
from google.genai import types
|
from google.genai import types
|
||||||
|
|
||||||
client = genai.Client(api_key=api_key)
|
client = genai.Client(api_key=api_key)
|
||||||
resp = client.models.generate_content(
|
resp = client.models.generate_content(
|
||||||
model=args.model,
|
model=model,
|
||||||
contents=build_user_prompt(style_examples, context_lines),
|
contents=build_user_prompt(style_examples, context_lines),
|
||||||
config=types.GenerateContentConfig(system_instruction=SYSTEM_PROMPT, max_output_tokens=300),
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
Loading…
Reference in New Issue