Israelbliz/User-Modeling-Agent
0
1"""Task A agent — the Impersonator.2 3Given a UserPersona and an item (title, description, categories, domain),4produce a predicted rating and a generated review that match the user's5behavioral voice.6 7The workflow is a deterministic 4-step pipeline:8 9 1. select_similar_history(persona, item)10 → pick the 3 most similar past reviews from the persona's history11 → "similar" means same domain when possible, else any12 → these ground the generation in the user's actual writing samples13 2. build_prompt(persona, item, similar_history)14 → render the persona + similar reviews + item into a structured prompt15 → the prompt is what the LLM sees16 3. llm.structured(prompt, ReviewOutput)17 → call GPT-4o (reasoning tier) and parse into a Pydantic schema18 → schema enforces (rating: float, review: str, reasoning: str)19 4. postprocess(output, persona)20 → clamp rating to 1-521 → if naija_mode is on, run the review through the style layer22 23The reasoning field is mandatory and exposed in the API response. This is24how the system demonstrates "intelligence per feature" — every generated25review comes with a sentence explaining why this rating, grounded in the26persona's signals.27"""28from __future__ import annotations29 30import logging31from dataclasses import dataclass, field32from typing import Optional33 34from pydantic import BaseModel, Field35 36from core.llm import LLMClient37from core.persona import UserPersona38from core.nigerian import naija_style_review39from core.reflection import reflect_on_review, ReflectionTrace40 41log = logging.getLogger(__name__)42 43 44# ──────────────────────────────────────────────────────────────────────────────45# Schemas46# ──────────────────────────────────────────────────────────────────────────────47 48class ItemInput(BaseModel):49 """Item details given to the Impersonator."""50 parent_asin: str = Field(description="Item ID")51 title: str = Field(description="Item title")52 description: str = Field(default="", description="Item description / synopsis")53 categories: str = Field(default="", description="Category breadcrumbs")54 domain: str = Field(description="Books / Movies_and_TV / Kindle_Store")55 average_rating: Optional[float] = Field(default=None, description="Crowd average rating, if known")56 57 58class GeneratedReview(BaseModel):59 """Structured output from the LLM."""60 rating: float = Field(description="Star rating, 1.0 to 5.0, half-stars allowed")61 review: str = Field(description="The full review text in this user's voice")62 reasoning: str = Field(description="One-sentence justification grounded in the user's persona signals")63 64 65@dataclass66class ImpersonationResult:67 """Final output returned by the agent."""68 rating: float69 review: str70 reasoning: str71 used_history_count: int # how many past reviews informed the generation72 naija_mode: bool73 # Self-reflection metadata (Stage 3b)74 reflection_iterations: int = 0 # how many critique cycles ran75 reflection_refined: bool = False # whether the review was revised76 reflection_notes: list[str] = field(default_factory=list) # critique findings77 78 79# ──────────────────────────────────────────────────────────────────────────────80# Workflow steps81# ──────────────────────────────────────────────────────────────────────────────82 83def select_similar_history(persona: UserPersona, item: ItemInput,84 k: int = 3) -> list[dict]:85 """Pick up to k past reviews to ground the generation.86 87 Preference order:88 1. same domain as the item89 2. any domain (fallback)90 Within each group we just take the most recent (history_samples is91 already sorted by recency-desc from the persona builder).92 """93 if not persona.history_samples:94 return []95 96 same_domain = [s for s in persona.history_samples if s["domain"] == item.domain]97 other_domain = [s for s in persona.history_samples if s["domain"] != item.domain]98 99 chosen = same_domain[:k]100 if len(chosen) < k:101 chosen.extend(other_domain[:(k - len(chosen))])102 return chosen103 104 105def build_prompt(persona: UserPersona, item: ItemInput,106 similar_history: list[dict]) -> str:107 """Render the impersonation prompt.108 109 Three sections:110 - PERSONA: who the user is, quantitative + qualitative111 - WRITING SAMPLES: actual reviews this user wrote112 - TARGET ITEM: the new thing they need to review113 114 The prompt is deliberately structured so the LLM has a clear template115 to follow and grounds outputs in real data.116 """117 parts = ["You are simulating a real Amazon reviewer. Generate a review that authentically reflects their voice, rating tendencies, and behavioral patterns.\n"]118 119 parts.append("=" * 60)120 parts.append("THE USER YOU ARE SIMULATING")121 parts.append("=" * 60)122 parts.append(persona.to_prompt_block())123 124 if similar_history:125 parts.append("=" * 60)126 parts.append(f"ACTUAL REVIEWS THIS USER WROTE (study the voice carefully)")127 parts.append("=" * 60)128 for i, h in enumerate(similar_history, 1):129 parts.append(f"\n[Sample {i}] {h['rating']}★ in {h['domain']}:")130 parts.append(h["text"][:600])131 132 parts.append("\n" + "=" * 60)133 parts.append("NEW ITEM TO REVIEW")134 parts.append("=" * 60)135 parts.append(f"Domain: {item.domain}")136 parts.append(f"Title: {item.title}")137 if item.categories:138 parts.append(f"Categories: {item.categories}")139 if item.description:140 parts.append(f"Description: {item.description[:800]}")141 if item.average_rating:142 parts.append(f"Crowd average: {item.average_rating:.1f}★")143 144 parts.append("\n" + "=" * 60)145 parts.append("YOUR TASK")146 parts.append("=" * 60)147 parts.append(148 "Produce three things.\n\n"149 "1. A RATING from 1.0 to 5.0. Predict it in TWO explicit steps:\n"150 " Step A — The PRIOR: what does this user usually give? Look at their\n"151 " rating distribution and average. This is your starting point.\n"152 " Step B — The ITEM EVIDENCE: now read the NEW ITEM carefully. The\n"153 " title, description, and any crowd average carry signal about\n"154 " whether THIS specific item is a hit or a miss FOR THIS USER.\n"155 " - A title or description with negative/lukewarm language\n"156 " (e.g. 'capable of better', 'lost than found', 'disappointing')\n"157 " pulls the rating DOWN — even for a generous user.\n"158 " - Rich, substantive material that fits the user's stated tastes\n"159 " pulls the rating UP — even for a critical user. A critical\n"160 " reviewer still gives 4-5★ to things that genuinely engage them.\n"161 " - Do not assume 'critical tone' means the user dislikes things;\n"162 " critical users rate highly when the material rewards their\n"163 " attention. Do not assume a generous user gives 5★ to\n"164 " everything; they still give 4★ to mild disappointments.\n"165 " Final rating = the PRIOR adjusted by the ITEM EVIDENCE. If the\n"166 " item evidence is neutral or absent, stay near the prior. If the\n"167 " item evidence clearly points somewhere, MOVE toward it.\n\n"168 "2. A REVIEW in this user's voice — match their length, tone,\n"169 " vocabulary, and quirks visible in their writing samples\n"170 " (capitalization, sentence structure, how they signal approval or\n"171 " disapproval). The review's sentiment MUST be consistent with the\n"172 " rating you chose.\n\n"173 "3. A one-sentence REASONING explaining the rating. It MUST cite BOTH\n"174 " (a) the persona prior AND (b) the specific item evidence that\n"175 " adjusted it — e.g. 'This user averages 4.8★, but the title signals\n"176 " \"capable of better\", a mild letdown, so 4★ not 5★.'"177 )178 179 return "\n".join(parts)180 181 182def postprocess(output: GeneratedReview, persona: UserPersona,183 naija_mode: bool, llm: LLMClient) -> GeneratedReview:184 """Clamp rating, optionally apply Naija style transfer."""185 # Clamp to [1.0, 5.0] and snap to nearest half-star186 rating = max(1.0, min(5.0, output.rating))187 rating = round(rating * 2) / 2188 189 review = output.review.strip()190 if naija_mode and review:191 try:192 review = naija_style_review(review, llm=llm)193 except Exception as e:194 log.warning(f"Naija style transfer failed; returning original. ({e})")195 196 return GeneratedReview(rating=rating, review=review, reasoning=output.reasoning)197 198 199# ──────────────────────────────────────────────────────────────────────────────200# Agent201# ──────────────────────────────────────────────────────────────────────────────202 203class ImpersonationAgent:204 """The Task A agent.205 206 Usage:207 agent = ImpersonationAgent()208 result = agent.run(persona, item, naija_mode=False)209 # result.rating, result.review, result.reasoning210 """211 212 def __init__(self, llm: LLMClient | None = None,213 history_samples_k: int = 3,214 use_reflection: bool = True,215 reflection_max_iterations: int = 2):216 self.llm = llm or LLMClient()217 self.history_samples_k = history_samples_k218 self.use_reflection = use_reflection219 self.reflection_max_iterations = reflection_max_iterations220 221 def run(self, persona: UserPersona, item: ItemInput,222 naija_mode: bool = False) -> ImpersonationResult:223 # Step 1: select grounding history224 similar = select_similar_history(persona, item, k=self.history_samples_k)225 log.info(f"Selected {len(similar)} similar history items for grounding")226 227 # Step 2: build prompt228 prompt = build_prompt(persona, item, similar)229 230 # Step 3: LLM call with structured output231 log.info(f"Calling LLM for impersonation of user {persona.user_id} on item {item.parent_asin}")232 raw_output = self.llm.structured(233 prompt,234 schema=GeneratedReview,235 model="reasoning",236 system="You are an expert behavioral simulator. You write reviews exactly as the specified user would write them, matching their tone, length, rating patterns, and quirks.",237 )238 239 # Step 4: self-reflection — critique + refine (Stage 3b)240 reflection_iterations = 0241 reflection_refined = False242 reflection_notes: list[str] = []243 rating, review = raw_output.rating, raw_output.review244 if self.use_reflection:245 log.info("Running self-reflection on generated review")246 rating, review, trace = reflect_on_review(247 self.llm, persona,248 item_title=item.title, item_domain=item.domain,249 rating=rating, review=review,250 max_iterations=self.reflection_max_iterations,251 )252 reflection_iterations = trace.iterations_run253 reflection_refined = trace.refined254 reflection_notes = list(trace.critiques)255 256 refined_output = GeneratedReview(257 rating=rating, review=review, reasoning=raw_output.reasoning,258 )259 260 # Step 5: postprocess (clamp rating, optional naija style)261 final = postprocess(refined_output, persona, naija_mode=naija_mode, llm=self.llm)262 263 return ImpersonationResult(264 rating=final.rating,265 review=final.review,266 reasoning=final.reasoning,267 used_history_count=len(similar),268 naija_mode=naija_mode,269 reflection_iterations=reflection_iterations,270 reflection_refined=reflection_refined,271 reflection_notes=reflection_notes,272 )273 