Israelbliz/User-Modeling-Agent
0
1"""Persona engine — turn a user's review history into a behavioral fingerprint.2 3The persona is the spine of the whole system. Both tasks ask it different4questions:5 6 Task A: "Given this persona and this item, how would the user rate and review it?"7 Task B: "Given this persona, what items would the user want next?"8 9A persona has two layers:10 11 1. Quantitative signals (computed deterministically from history)12 - rating cadence: mean, std, distribution shape13 - review length: mean, std14 - vocabulary fingerprint: top distinctive terms15 - domain mix: which categories the user engages with16 - verified-purchase rate, helpful-vote signal17 18 2. Qualitative summary (LLM-generated, cached)19 - tone descriptor (snarky / earnest / analytical / casual / ...)20 - common preferences (themes, styles)21 - common complaints (deal-breakers)22 - recommended audience for THIS user (one-liner persona pitch)23 24The qualitative layer is what makes generated reviews feel like the actual25user wrote them. Without it, you get generic LLM prose. With it, you get26behavioral fidelity — which is one of Task A's three scored axes.27"""28from __future__ import annotations29 30import logging31from collections import Counter32from dataclasses import dataclass, field, asdict33from typing import Any34 35import pandas as pd36from pydantic import BaseModel, Field37 38from core.llm import LLMClient39 40log = logging.getLogger(__name__)41 42 43# ──────────────────────────────────────────────────────────────────────────────44# Schemas45# ──────────────────────────────────────────────────────────────────────────────46 47class QualitativeSummary(BaseModel):48 """LLM-generated qualitative layer of a persona."""49 tone: str = Field(description="One-word tone descriptor: snarky, earnest, analytical, casual, enthusiastic, terse, verbose, etc.")50 preferred_themes: list[str] = Field(description="3-5 themes/styles/qualities this user gravitates toward")51 common_complaints: list[str] = Field(description="2-4 recurring deal-breakers or critique patterns")52 voice_one_liner: str = Field(description="A single sentence describing this user's reviewing voice as if pitching them to a casting director")53 54 55@dataclass56class UserPersona:57 """Complete persona — quantitative signals + qualitative summary + history."""58 user_id: str59 60 # Quantitative61 n_reviews: int62 avg_rating: float63 std_rating: float64 avg_review_length: float65 std_review_length: float66 verified_rate: float67 domains: list[str]68 n_domains: int69 rating_distribution: dict[int, float] # {1: 0.05, 2: 0.1, ..., 5: 0.4}70 top_terms: list[str] # vocabulary fingerprint71 72 # Qualitative (lazily filled by PersonaEngine.enrich)73 tone: str = ""74 preferred_themes: list[str] = field(default_factory=list)75 common_complaints: list[str] = field(default_factory=list)76 voice_one_liner: str = ""77 78 # Sample history for retrieval/grounding (subset of training reviews)79 history_samples: list[dict[str, Any]] = field(default_factory=list)80 81 def to_prompt_block(self) -> str:82 """Render the persona as a structured prompt section.83 84 This text is what the LLM sees when generating reviews / recommendations.85 Keeping it formatted consistently is what makes generation behaviorally86 faithful.87 """88 dist = " ".join(f"{r}★:{p:.0%}" for r, p in sorted(self.rating_distribution.items()))89 return (90 f"USER PERSONA\n"91 f" Reviews written: {self.n_reviews}\n"92 f" Avg rating: {self.avg_rating:.2f} (±{self.std_rating:.2f})\n"93 f" Rating distribution: {dist}\n"94 f" Avg review length: {self.avg_review_length:.0f} words (±{self.std_review_length:.0f})\n"95 f" Verified-purchase rate: {self.verified_rate:.0%}\n"96 f" Active domains: {', '.join(self.domains)}\n"97 f" Vocabulary fingerprint: {', '.join(self.top_terms[:15])}\n"98 f" Tone: {self.tone or 'unspecified'}\n"99 f" Preferred themes: {', '.join(self.preferred_themes) or 'unspecified'}\n"100 f" Common complaints: {', '.join(self.common_complaints) or 'unspecified'}\n"101 f" Voice: {self.voice_one_liner or 'unspecified'}\n"102 )103 104 def as_dict(self) -> dict:105 return asdict(self)106 107 108# ──────────────────────────────────────────────────────────────────────────────109# Engine110# ──────────────────────────────────────────────────────────────────────────────111 112# A small set of generic English stopwords + Amazon-review noise. Keeping113# this in-module avoids pulling in nltk's download flow.114_STOPWORDS = set("""115a an the and or but if then else when while of in on at by to for with from116into onto over under is are was were be been being have has had do does did117i you he she it we they me him her us them my your his its our their this118that these those there here what which who whom whose how why so as too very119just also more most some any all each every other another such no not nor only120own same can will would could should might may must one two three really get121got gets just like dont didnt isnt arent wasnt werent havent hadnt hasnt cant122couldnt wouldnt shouldnt wont thats whats theres heres ive ill ive youve im123""".split())124 125 126class PersonaEngine:127 """Build personas from review history.128 129 Two entry points:130 from_dataframe(user_id, training_reviews_df) -> UserPersona131 enrich(persona) -> UserPersona # adds qualitative summary via LLM132 """133 134 def __init__(self, llm: LLMClient | None = None,135 top_terms_k: int = 20,136 history_samples_k: int = 8):137 self.llm = llm or LLMClient()138 self.top_terms_k = top_terms_k139 self.history_samples_k = history_samples_k140 # Enrichment cache — keyed by user_id. enrich() makes an LLM call per141 # user; for a user already seen this session, the cached qualitative142 # summary is reused. The result is identical (same user, same summary)143 # — this only removes a redundant call, it never changes output.144 self._enrichment_cache: dict[str, dict] = {}145 146 # ─────────────────────────── Quantitative ────────────────────────────147 def from_dataframe(self, user_id: str,148 reviews: pd.DataFrame) -> UserPersona:149 """Build a UserPersona from a DataFrame of one user's training reviews.150 151 Expected columns: user_id, parent_asin, rating, text, verified_purchase,152 domain, timestamp.153 """154 user_reviews = reviews[reviews["user_id"] == user_id]155 if user_reviews.empty:156 raise ValueError(f"No reviews found for user_id={user_id!r}")157 158 ratings = user_reviews["rating"].astype(float)159 lengths = user_reviews["text"].fillna("").str.split().str.len()160 161 # Rating distribution as proportions162 dist = ratings.round().astype(int).value_counts(normalize=True).to_dict()163 rating_dist = {int(k): float(v) for k, v in dist.items()}164 165 # Vocabulary fingerprint: most common non-stopword tokens166 top_terms = self._top_terms(user_reviews["text"].tolist())167 168 # Sample history items for retrieval grounding — keep the most recent169 history = user_reviews.sort_values("timestamp", ascending=False) \170 .head(self.history_samples_k)171 history_samples = [172 {173 "parent_asin": row["parent_asin"],174 "rating": float(row["rating"]),175 "text": row["text"][:500],176 "domain": row["domain"],177 }178 for _, row in history.iterrows()179 ]180 181 return UserPersona(182 user_id=user_id,183 n_reviews=len(user_reviews),184 avg_rating=float(ratings.mean()),185 std_rating=float(ratings.std()) if len(ratings) > 1 else 0.0,186 avg_review_length=float(lengths.mean()),187 std_review_length=float(lengths.std()) if len(lengths) > 1 else 0.0,188 verified_rate=float(user_reviews["verified_purchase"].mean()),189 domains=sorted(user_reviews["domain"].unique().tolist()),190 n_domains=int(user_reviews["domain"].nunique()),191 rating_distribution=rating_dist,192 top_terms=top_terms,193 history_samples=history_samples,194 )195 196 def _top_terms(self, texts: list[str]) -> list[str]:197 """Most frequent content tokens, stopwords removed."""198 counter: Counter = Counter()199 for txt in texts:200 if not isinstance(txt, str):201 continue202 tokens = [t.lower().strip(".,!?\"'()[]{}:;") for t in txt.split()]203 tokens = [t for t in tokens204 if t and len(t) > 2 and t not in _STOPWORDS and t.isalpha()]205 counter.update(tokens)206 return [w for w, _ in counter.most_common(self.top_terms_k)]207 208 # ─────────────────────────── Qualitative ─────────────────────────────209 def enrich(self, persona: UserPersona) -> UserPersona:210 """Add LLM-generated qualitative summary to an existing persona.211 212 Uses the reasoning model (gpt-4o) — more reliable structured output213 than the bulk model. If the LLM call still fails, falls back to a214 deterministic summary derived from the writing samples so we never215 end up with an empty Voice/Tone.216 """217 if not persona.history_samples:218 log.warning(f"User {persona.user_id} has no history samples; skipping enrichment")219 return self._apply_deterministic_fallback(persona)220 221 # Cache hit — reuse the qualitative summary computed earlier this222 # session for this user. Identical result, one fewer LLM call.223 cached = self._enrichment_cache.get(persona.user_id)224 if cached is not None:225 log.info(f"Persona enrichment cache hit for {persona.user_id}")226 persona.tone = cached["tone"] or persona.tone227 persona.preferred_themes = cached["preferred_themes"] or persona.preferred_themes228 persona.common_complaints = cached["common_complaints"] or persona.common_complaints229 persona.voice_one_liner = cached["voice_one_liner"] or persona.voice_one_liner230 return persona231 232 sample_block = "\n\n".join(233 f"[{i+1}] Rating: {s['rating']}★ Domain: {s['domain']}\n{s['text'][:400]}"234 for i, s in enumerate(persona.history_samples)235 )236 237 prompt = (238 f"Below are review samples from a single user. Read them carefully "239 f"and infer their reviewing voice.\n\n"240 f"{sample_block}\n\n"241 f"Quantitative signals about this user:\n"242 f"- Average rating: {persona.avg_rating:.2f} of 5\n"243 f"- Average review length: {persona.avg_review_length:.0f} words\n"244 f"- Vocabulary they use often: {', '.join(persona.top_terms[:15])}\n\n"245 f"Produce a qualitative summary of their reviewer voice. "246 f"Be concise and concrete. If the samples are too sparse or generic, "247 f"infer the most plausible voice rather than refusing."248 )249 250 try:251 summary = self.llm.structured(252 prompt, QualitativeSummary, model="reasoning",253 system="You are a behavioral analyst specializing in online review patterns. Always produce valid output.",254 )255 persona.tone = summary.tone or persona.tone256 persona.preferred_themes = summary.preferred_themes or persona.preferred_themes257 persona.common_complaints = summary.common_complaints or persona.common_complaints258 persona.voice_one_liner = summary.voice_one_liner or persona.voice_one_liner259 # cache the successful summary for reuse this session260 self._enrichment_cache[persona.user_id] = {261 "tone": persona.tone,262 "preferred_themes": persona.preferred_themes,263 "common_complaints": persona.common_complaints,264 "voice_one_liner": persona.voice_one_liner,265 }266 except Exception as e:267 log.warning(f"LLM enrichment failed for {persona.user_id} ({type(e).__name__}); using deterministic fallback")268 persona = self._apply_deterministic_fallback(persona)269 270 return persona271 272 @staticmethod273 def _apply_deterministic_fallback(persona: UserPersona) -> UserPersona:274 """Fill in tone/themes/voice from quantitative signals when LLM fails.275 276 This isn't as rich as an LLM summary, but it guarantees downstream277 query construction has SOMETHING to work with — much better than278 an empty string.279 """280 # Tone bucket from avg rating281 if persona.avg_rating >= 4.5:282 tone = "enthusiastic"283 elif persona.avg_rating >= 3.8:284 tone = "earnest"285 elif persona.avg_rating >= 3.0:286 tone = "measured"287 else:288 tone = "critical"289 290 # Use top distinctive terms as proxy themes (filter out true generics)291 generic_terms = {"book", "read", "story", "movie", "film", "great", "good",292 "really", "much", "first", "next", "through", "about"}293 candidate_themes = [t for t in persona.top_terms if t not in generic_terms][:5]294 themes = candidate_themes or persona.top_terms[:3]295 296 # Domain-grounded voice297 domain_str = "/".join(persona.domains) if persona.domains else "general"298 length_descriptor = (299 "writes brief reviews" if persona.avg_review_length < 30300 else "writes detailed reviews" if persona.avg_review_length > 150301 else "writes moderate-length reviews"302 )303 voice = (304 f"A {tone} {domain_str} reviewer who {length_descriptor} "305 f"(avg {persona.avg_rating:.1f}★ over {persona.n_reviews} reviews)."306 )307 308 if not persona.tone:309 persona.tone = tone310 if not persona.preferred_themes:311 persona.preferred_themes = themes312 if not persona.voice_one_liner:313 persona.voice_one_liner = voice314 return persona315 