peekabook-api / app /simulation /peeka_reader_agent.py
lael
feat: initial deploy
3960e2b
Raw
History Blame Contribute Delete
11 kB
"""
PeekaReader: ํŽ˜๋ฅด์†Œ๋‚˜ DNA ๊ธฐ๋ฐ˜ ์‚ฌ์šฉ์ž ์‹œ๋ฎฌ๋ ˆ์ด์…˜ ์—์ด์ „ํŠธ
CRS์˜ ์Šฌ๋กฏ ์งˆ๋ฌธ์— ์ž๋™ ์‘๋‹ตํ•˜๊ณ  ์ถ”์ฒœ ๊ฒฐ๊ณผ๋ฅผ ๋„์„œ๋ณ„๋กœ self-evaluation ํ•จ.
ReAct ํŒจํ„ด(Thought + Action)์œผ๋กœ ๋ฐœํ™”๋ฅผ ์ƒ์„ฑํ•จ.
๋ณ€๊ฒฝ ์ด๋ ฅ:
- v2 (ํ˜„์žฌ): EVAL_SYSTEM_BOTH๋งŒ ์ฑ„ํƒ (์‹คํ—˜์—์„œ ๊ฐ€์žฅ ์ผ๊ด€๋œ ํ‰๊ฐ€)
FALLBACK ๋ชจ๋“œ ์ œ๊ฑฐ (book_intro ์—†์œผ๋ฉด ํ‰๊ฐ€ ์ž์ฒด๋ฅผ ์Šคํ‚ตํ•˜๋Š” ๊ฒŒ ๋” ์ •์งํ•จ)
evaluate() mode ํŒŒ๋ผ๋ฏธํ„ฐ ์ œ๊ฑฐ (๋‹จ์ผ ๊ฒฝ๋กœ๋กœ ๋‹จ์ˆœํ™”)
"""
from __future__ import annotations
import json
from typing import Optional
from openai import OpenAI
from app.config import LLM_MODEL
# OpenAI ํด๋ผ์ด์–ธํŠธ๋Š” ๋ชจ๋“ˆ ์ž„ํฌํŠธ ์‹œ์ ์— ๋งŒ๋“ค์ง€ ์•Š๊ณ  ์ง€์—ฐ ์ดˆ๊ธฐํ™”ํ•จ
# (ํ…Œ์ŠคํŠธ ์‹œ ํ™˜๊ฒฝ๋ณ€์ˆ˜ ์ฃผ์ž… ์ˆœ์„œ ๋ฌธ์ œ ํšŒํ”ผ)
_client: Optional[OpenAI] = None
def _get_client() -> OpenAI:
"""OpenAI ํด๋ผ์ด์–ธํŠธ๋ฅผ ์ง€์—ฐ ์ดˆ๊ธฐํ™”ํ•˜์—ฌ ๋ฐ˜ํ™˜ํ•จ"""
global _client
if _client is None:
_client = OpenAI()
return _client
def extract_session_dna(full_persona: dict, session_id: int) -> dict:
"""
ํŽ˜๋ฅด์†Œ๋‚˜ ํ’€์—์„œ ํŠน์ • ์„ธ์…˜์˜ DNA๋ฅผ ์ถ”์ถœํ•จ.
๊ณ ์ • ์†์„ฑ + ์„ธ์…˜๋ณ„ DNA + ๋ˆ„์  ๋ฉ”๋ชจ๋ฆฌ(derived_preferences)๋ฅผ ํ•ฉ์‚ฐํ•จ.
๋ฐ˜ํ™˜๊ฐ’์€ PeekaReader / PeekaJudge๊ฐ€ ๋ฐ›๋Š” persona ๋”•์…”๋„ˆ๋ฆฌ ํ˜•ํƒœ์™€ ๋™์ผํ•จ.
Args:
full_persona: ํŽ˜๋ฅด์†Œ๋‚˜ ์ „์ฒด dict (demographics, sessions, long_term_memory ํฌํ•จ)
session_id: ์ถ”์ถœํ•  ์„ธ์…˜ ๋ฒˆํ˜ธ (1๋ถ€ํ„ฐ ์‹œ์ž‘)
Returns:
Judge ๋ฃจ๋ธŒ๋ฆญ 5๊ฐœ ์ถ• + ๊ณ ์ • ์†์„ฑ์„ ํ•ฉ์นœ DNA dict
"""
session = full_persona["sessions"][session_id - 1]
memory = full_persona["long_term_memory"]
# Judge ๋ฃจ๋ธŒ๋ฆญ 5๊ฐœ ์ถ• + ๊ณ ์ • ์†์„ฑ
dna = {
"reading_goal": session["reading_goal"],
"preferred_genre": session["preferred_genre"],
"reading_style": session["reading_style"],
"difficulty_level": session["difficulty_level"],
"current_context": session["current_context"],
"demographics": full_persona["demographics"],
"speaking_style": full_persona["speaking_style"],
"disliked": full_persona["disliked"],
"pain_points": full_persona["pain_points"],
}
# ๋ˆ„์  ์ทจํ–ฅ์ด ์žˆ์œผ๋ฉด current_context์— ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ๋ง๋ถ™์ž„
# โ†’ PeekaReader๊ฐ€ "์ง€๋‚œ๋ฒˆ์— ์–ด๋ ค์šด ์ฑ…์„ ๋ฐ›์•˜์–ด์„œ์š”..." ๋ฐœํ™”๋ฅผ ๋งŒ๋“ค์–ด๋ƒ„
if memory["derived_preferences"]:
prefs_str = " / ".join(memory["derived_preferences"])
dna["current_context"] += f" (์ด์ „ ๊ฒฝํ—˜: {prefs_str})"
return dna
class PeekaReaderAgent:
"""
CRS ์Šฌ๋กฏ ์งˆ๋ฌธ์— ํŽ˜๋ฅด์†Œ๋‚˜ DNA ๊ธฐ๋ฐ˜์œผ๋กœ ์ž๋™ ์‘๋‹ตํ•˜๊ณ ,
์ถ”์ฒœ ๊ฒฐ๊ณผ๋ฅผ ๋„์„œ๋ณ„๋กœ self-evaluation ํ•˜๋Š” ReAct ์—์ด์ „ํŠธ.
"""
ANSWER_SYSTEM = """\
๋‹น์‹ ์€ ๋„์„œ ์ถ”์ฒœ ์ฑ—๋ด‡๊ณผ ๋Œ€ํ™”ํ•˜๋Š” ์‹ค์ œ ์‚ฌ์šฉ์ž๋ฅผ ์—ฐ๊ธฐํ•˜๋Š” ์—์ด์ „ํŠธ์ž…๋‹ˆ๋‹ค.
## ๋‹น์‹ ์˜ ํŽ˜๋ฅด์†Œ๋‚˜ (DNA)
{persona_str}
## DNA ์šฐ์„ ์ˆœ์œ„ (์ค‘์š”)
๋‹ต๋ณ€ ์‹œ ๋‹ค์Œ ์ˆœ์„œ๋กœ ์ •๋ณด๋ฅผ ์‚ฌ์šฉํ•˜์„ธ์š”.
1์ˆœ์œ„: reading_goal โ€” ์ง€๊ธˆ ์ด ์„ธ์…˜์—์„œ ์ •ํ™•ํžˆ ์ฐพ๊ณ  ์žˆ๋Š” ๊ฒƒ
2์ˆœ์œ„: current_context โ€” ์ง€๊ธˆ ์ฒ˜ํ•œ ๊ตฌ์ฒด์  ์ƒํ™ฉ
3์ˆœ์œ„: preferred_genre, reading_style, difficulty_level โ€” ์žฅ๋ฅดยท์Šคํƒ€์ผ ์กฐ๊ฑด
preferred_genre๋Š” "์–ด๋–ค ์ฑ…์žฅ์— ๊ฝ‚ํžŒ ์ฑ…์ธ์ง€"์ผ ๋ฟ,
"๋ฌด์—‡์„ ์ฐพ๋Š”์ง€"๋Š” reading_goal๊ณผ current_context๊ฐ€ ๊ฒฐ์ •ํ•ฉ๋‹ˆ๋‹ค.
## ํ–‰๋™ ๊ทœ์น™ (ReAct)
๋จผ์ € ์†์œผ๋กœ ํ•œ ๋ฌธ์žฅ ์ƒ๊ฐ(Thought)ํ•œ ๋’ค, ๊ทธ ์ƒ๊ฐ์— ๊ธฐ๋ฐ˜ํ•ด ๋‹ต(Action)ํ•˜์„ธ์š”.
- Thought: "๋‚˜๋Š” [DNA ํŠน์„ฑ]์ด๊ณ  [current_context]๋ผ์„œ [reading_goal]์„ ์œ„ํ•œ ์ฑ…์ด ํ•„์š”ํ•˜๋‹ค"
- Action: ์ฑ—๋ด‡ ์งˆ๋ฌธ์— ๋Œ€ํ•œ ์ž์—ฐ์Šค๋Ÿฌ์šด ๋‹ต๋ณ€
## ๋‹ต๋ณ€ ๊ทœ์น™
1. ํŽ˜๋ฅด์†Œ๋‚˜์— ์ถฉ์‹คํ•˜๋˜, DNA ๋‹จ์–ด๋ฅผ ๊ทธ๋Œ€๋กœ ๋ณต๋ถ™ํ•˜์ง€ ๋ง๊ณ  ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ํ’€์–ด ์“ฐ์„ธ์š”.
์˜ˆ) "๊ฒฝ์ œ ๊ต์–‘์„œ" -> "์‹ค์ƒํ™œ์— ๋„์›€ ๋˜๋Š” ์ฑ…"
2. speaking_style์„ ์ง€์ผœ ์‹ค์ œ ์‚ฌ๋žŒ์ฒ˜๋Ÿผ ๊ตฌ์–ด์ฒด๋กœ, 1~3๋ฌธ์žฅ ์ด๋‚ด๋กœ.
3. ํŽ˜๋ฅด์†Œ๋‚˜์— ์—†๋Š” ์ •๋ณด๋Š” DNA์™€ ์ผ๊ด€๋œ ๋ฐฉํ–ฅ์œผ๋กœ ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ์ง€์–ด๋‚ด์„ธ์š”.
4. ์ฑ—๋ด‡์˜ ์งˆ๋ฌธ์—๋งŒ ๋‹ตํ•˜์„ธ์š”. ๋จผ์ € ์ฑ… ์ถ”์ฒœ์„ ์š”์ฒญํ•˜์ง€ ๋งˆ์„ธ์š”.
## ๋งฅ๋ฝ ์ •์ • ๊ทœ์น™ (ํ•„์ˆ˜)
์ฑ—๋ด‡์ด reading_goalยทcurrent_context์™€ ๋‹ค๋ฅธ ๋งฅ๋ฝ์„ ์–ธ๊ธ‰ํ•˜๋ฉด(์˜ˆ: ๊ณผ๊ฑฐ์— ์ฝ์€ ์ฑ… ์ถ”์ •, ๋‹ค๋ฅธ ์ฃผ์ œ๋กœ ์œ ๋„)
"๋น„์Šทํ•œ ์ฑ…"์ด๋ผ๊ณ  ๋™์กฐํ•˜์ง€ ๋ง๊ณ , ์ง€๊ธˆ ๋‹น์‹ ์ด ์ฐพ๋Š” ๊ฒƒ์„ ๋ถ„๋ช…ํžˆ ๋งํ•˜์„ธ์š”.
์˜ˆ) "๊ทธ๊ฒƒ๋ณด๋‹ค๋Š” [reading_goal ํ•ต์‹ฌ]์„ ๋‹ค๋ฃฌ ์ฑ…์„ ์ฐพ๊ณ  ์žˆ์–ด์š”."
## Utterance ๊ทœ์น™
Utterance์—๋Š” reading_goal์˜ ํ•ต์‹ฌ ํ‘œํ˜„(์ฃผ์ œ์–ด/์ƒํ™ฉ์–ด)์ด ์ตœ์†Œ ํ•œ ๋ฒˆ์€ ๋“ฑ์žฅํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.
์˜ˆ) reading_goal="์†Œ๋น„์ž ์‹ฌ๋ฆฌ๋ฅผ ๋งˆ์ผ€ํ„ฐ ์‹œ๊ฐ์œผ๋กœ ์ดํ•ด"
-> "๋งˆ์ผ€ํ„ฐ ์ž…์žฅ์—์„œ ์†Œ๋น„์ž ์‹ฌ๋ฆฌ๋ฅผ ๋‹ค๋ฃฌ ์ฑ…์ด ์žˆ์„๊นŒ์š”?"
## ์ถœ๋ ฅ ํ˜•์‹ (JSON)
{{"thought": "์†๋งˆ์Œ ํ•œ ๋ฌธ์žฅ", "utterance": "์‹ค์ œ ๋ฐœํ™”"}}
"""
# Self-evaluation ์‹œ์Šคํ…œ ํ”„๋กฌํ”„ํŠธ (๊ตฌ EVAL_SYSTEM_BOTH)
# ๋„์„œ ์†Œ๊ฐœ๊ธ€(์ฃผ) + CRS ์ถ”์ฒœ ์ด์œ (๋ณด์กฐ) ๋‘˜ ๋‹ค ์ฐธ๊ณ 
EVAL_SYSTEM = """\
๋‹น์‹ ์€ ์•„๋ž˜ DNA๋ฅผ ๊ฐ€์ง„ ๋„์„œ๊ด€ ์ด์šฉ์ž์ž…๋‹ˆ๋‹ค.
## ๋‹น์‹ ์˜ ํŽ˜๋ฅด์†Œ๋‚˜ (DNA)
{persona_str}
## ๊ณผ์ œ
์ถ”์ฒœ๋œ ๋„์„œ ๊ฐ๊ฐ์ด ๋‹น์‹ ์˜ DNA์™€ ๋งž๋Š”์ง€ ํŒ๋‹จํ•˜์„ธ์š”.
## ํ‰๊ฐ€ ๋ฐฉ๋ฒ•
์•„๋ž˜ ๋‘ ๊ฐ€์ง€ ์ •๋ณด๋ฅผ ํ•จ๊ป˜ ์ฐธ๊ณ ํ•˜์„ธ์š”.
1. ๋„์„œ ์†Œ๊ฐœ๊ธ€ (์ถœํŒ์‚ฌ ์ž‘์„ฑ ๊ณ ์ • ํ…์ŠคํŠธ)
2. ์ถ”์ฒœ ์ด์œ  (ํ๋ ˆ์ดํ„ฐ AI ์ž‘์„ฑ)
๋‹จ, ํŒ๋‹จ์˜ ์ฃผ๋œ ๊ทผ๊ฑฐ๋Š” DNA์™€ ๋„์„œ ์†Œ๊ฐœ๊ธ€์ด๋ฉฐ,
์ถ”์ฒœ ์ด์œ ๋Š” ๋ณด์กฐ ์ฐธ๊ณ  ์ž๋ฃŒ๋กœ๋งŒ ํ™œ์šฉํ•˜์„ธ์š”.
## ๋„์„œ ์†Œ๊ฐœ๊ธ€
{book_intros_str}
## ํŒ๋‹จ ๊ธฐ์ค€ (๊ถŒ๋‹น)
- ๊ด€์‹ฌ ์žฅ๋ฅด/์ฃผ์ œ์™€ ๋งž๋Š”๊ฐ€
- ๋‚œ์ด๋„๊ฐ€ ๋‚ด ์ˆ˜์ค€์— ๋งž๋Š”๊ฐ€
- ํ˜„์žฌ ์ƒํ™ฉ(๋ชฉ์ )์— ์ ํ•ฉํ•œ๊ฐ€
## ์ถœ๋ ฅ ํ˜•์‹ (JSON๋งŒ ์ถœ๋ ฅ)
{{
"books_evaluated": [
{{"title": "์ฑ… ์ œ๋ชฉ", "match": true, "reason": "DNA ๊ธฐ์ค€์œผ๋กœ ํ•œ ๋ฌธ์žฅ"}}
],
"overall_reason": "์ „์ฒด ์†Œ๊ฐ ํ•œ ๋ฌธ์žฅ"
}}
"""
def __init__(self, persona_id: str, persona: dict, verbose: bool = True):
self.persona_id = persona_id
self.persona = persona
self.verbose = verbose
self.history: list = []
self.turn_count = 0
self.persona_str = "\n".join(f"- {k}: {v}" for k, v in persona.items())
def answer(self, question: str) -> dict:
"""CRS ์Šฌ๋กฏ ์งˆ๋ฌธ์— DNA ๊ธฐ๋ฐ˜ ์ž๋™ ์‘๋‹ต (์ตœ๋Œ€ 3ํšŒ ์žฌ์‹œ๋„)"""
self.turn_count += 1
self.history.append({"role": "user", "content": question})
thought = ""
utterance = ""
for attempt in range(3):
resp = _get_client().chat.completions.create(
model=LLM_MODEL,
messages=[
{"role": "system",
"content": self.ANSWER_SYSTEM.format(
persona_str=self.persona_str)},
*self.history
],
temperature=0.3, # ์›๋ž˜ 0.7
# seed=42,
response_format={"type": "json_object"},
)
content = resp.choices[0].message.content # ์‘๋‹ต ๋ฏธ์ƒ์„ฑ ์‹œ fallback
raw = content.strip() if content else "{}"
try:
parsed = json.loads(raw)
thought = parsed.get("thought", "")
utterance = parsed.get("utterance", "")
except json.JSONDecodeError:
thought, utterance = "", ""
if utterance:
break
if self.verbose and attempt < 2:
print(f" [์žฌ์‹œ๋„ {attempt + 1}] utterance ๋น„์–ด์žˆ์Œ โ€” ์žฌ์ƒ์„ฑ ์ค‘...")
if not utterance:
utterance = "์ž˜ ๋ชจ๋ฅด๊ฒ ์–ด์š”."
thought = ""
if self.verbose:
print(f" [๊ฒฝ๊ณ ] utterance ์ƒ์„ฑ ์‹คํŒจ โ€” ๊ธฐ๋ณธ๊ฐ’ ์‚ฌ์šฉ")
# history์—๋Š” ๋ฐœํ™”๋งŒ ๋ˆ„์  (CRS๊ฐ€ ๋ฐ›๋Š” ๊ฑด utterance๋ฟ)
self.history.append({"role": "assistant", "content": utterance})
if self.verbose:
print(f"\n[Turn {self.turn_count}]")
print(f" CSR : {question}")
print(f" THOUGHT : {thought}")
print(f" USER : {utterance}")
return {"thought": thought, "utterance": utterance}
def evaluate(self, recommendation_text: str,
book_intros: Optional[dict] = None) -> dict:
"""
์ถ”์ฒœ๋œ ๋„์„œ๋ฅผ self-evaluation ํ•จ.
book_intros๊ฐ€ ๋น„์–ด ์žˆ์œผ๋ฉด ํ‰๊ฐ€ ์ž์ฒด๋ฅผ ์Šคํ‚ตํ•จ (FALLBACK ํ‰๊ฐ€๋Š”
์‹คํ—˜์ ์œผ๋กœ ์‹ ๋ขฐ์„ฑ์ด ๋‚ฎ์•„ ์ œ๊ฑฐ๋จ). skipped ํ”Œ๋ž˜๊ทธ๋กœ ๋ช…์‹œํ•จ.
Args:
recommendation_text: CRS์˜ ์ตœ์ข… ์ถ”์ฒœ ๋ฉ”์‹œ์ง€ (๋ณด์กฐ ์ฐธ๊ณ ์šฉ)
book_intros: {"์ œ๋ชฉ": "์†Œ๊ฐœ๊ธ€"} dict. ํ•„์ˆ˜.
Returns:
์ •์ƒ ํ‰๊ฐ€ ์‹œ: {"books_evaluated": [...], "overall_reason": "..."}
์Šคํ‚ต ์‹œ: {"books_evaluated": [], "skipped": True}
"""
if not book_intros:
if self.verbose:
print(" [self-evaluation ์Šคํ‚ต] book_intro ์—†์Œ โ€” ํ‰๊ฐ€ ๋ถˆ๊ฐ€")
return {"books_evaluated": [], "skipped": True}
book_intros_str = "\n\n".join([
f"๐Ÿ“š {title}\n์†Œ๊ฐœ: {intro}"
for title, intro in book_intros.items()
])
system_content = self.EVAL_SYSTEM.format(
persona_str=self.persona_str,
book_intros_str=book_intros_str,
)
user_content = (
f"[์ถ”์ฒœ ์ด์œ ]\n{recommendation_text}\n\n"
f"[๋„์„œ ์†Œ๊ฐœ๊ธ€]\n{book_intros_str}"
)
resp = _get_client().chat.completions.create(
model=LLM_MODEL,
messages=[
{"role": "system", "content": system_content},
{"role": "user", "content": user_content},
],
temperature=0.0,
seed=42,
response_format={"type": "json_object"},
)
content = resp.choices[0].message.content
raw = content.strip() if content else \
'{"books_evaluated": [], "overall_reason": "์‘๋‹ต ์—†์Œ"}'
try:
result = json.loads(raw)
except json.JSONDecodeError:
result = {"books_evaluated": [], "overall_reason": raw}
books = result.get("books_evaluated", [])
matched = sum(1 for b in books if b.get("match"))
if self.verbose:
print(f"\n[Self-Evaluation]")
for b in books:
mark = "O" if b.get("match") else "X"
print(f" [{mark}] {b.get('title', '?')} โ€” {b.get('reason', '')}")
print(f" ์ดํ‰: {result.get('overall_reason', '')}")
if books:
print(f" match_rate: {matched}/{len(books)}๊ถŒ "
f"({matched / len(books):.0%})")
return result
def reset(self):
self.history = []
self.turn_count = 0