"""Behavioral memory: long-term + short-term + contextual state per user. Long-term: stable preferences derived from full history. Short-term: mood + explicit recent_experiences tags from the LAST K reviews. Contextual: situational flags supplied per-request (rainy, salary_week, etc.). The PRD asks for a memory graph; we model it as a layered dict with explicit tagged experiences ([late_delivery, bad_packaging, ...]) so downstream agents can reason over recent friction without re-reading full review text. """ from __future__ import annotations import json import re from pathlib import Path import pandas as pd from app.config import MEMORY_DIR from app.persona.features import ( DELIVERY_TERMS, NEG_TERMS, PACKAGING_TERMS, POS_TERMS, PRICE_TERMS, QUALITY_TERMS, SERVICE_TERMS, ) from app.persona.store import user_reviews # Tag rules: (tag name, regex/keyword set, rating-condition predicate) _TAG_RULES: list[tuple[str, set[str], int]] = [ ("late_delivery", {"late", "slow", "delay", "took forever", "took too long"}, 3), ("bad_packaging", {"damaged", "leaked", "crushed", "broken", "open", "torn", "smashed"}, 3), ("bad_quality", {"stale", "rotten", "moldy", "spoiled", "rancid", "off"}, 3), ("bad_service", {"rude", "unhelpful", "no response", "refused refund", "no refund"}, 3), ("overpriced", {"overpriced", "not worth", "too expensive", "rip off"}, 3), ("great_value", {"great deal", "worth it", "bargain", "great price"}, 4), ("loved_quality", {"delicious", "perfect", "amazing", "fantastic", "love this"}, 4), ] def _path(user_id: str) -> Path: return MEMORY_DIR / f"{user_id.replace('/', '_')}.json" def _recent_signal(reviews: pd.DataFrame, tokens: set[str]) -> float: if reviews.empty: return 0.0 return float(reviews["text"].str.lower().apply(lambda t: any(tok in t for tok in tokens)).mean()) def _tag_review(text: str, rating: int) -> list[str]: """Return tags that fire on a single review.""" t = text.lower() tags = [] for tag, tokens, threshold in _TAG_RULES: hit = any(tok in t for tok in tokens) if not hit: continue if threshold <= 3 and rating <= threshold: tags.append(tag) elif threshold >= 4 and rating >= threshold: tags.append(tag) return tags def build(user_id: str, last_k: int = 5) -> dict: from app.persona.coldstart import neutral_memory from app.persona.store import _reviews df = _reviews() sub = df[df["user_id"] == user_id] if sub.empty: out = neutral_memory(user_id) _path(user_id).write_text(json.dumps(out, indent=2), encoding="utf-8") return out reviews = sub.sort_values("timestamp") recent = reviews.tail(last_k) recent_avg = float(recent["rating"].mean()) long_avg = float(reviews["rating"].mean()) drift = recent_avg - long_avg # negative = trending grumpier recent_frustration = _recent_signal(recent, NEG_TERMS) recent_joy = _recent_signal(recent, POS_TERMS) recent_delivery_complaints = _recent_signal(recent[recent["rating"] <= 2], DELIVERY_TERMS) recent_price_complaints = _recent_signal(recent[recent["rating"] <= 2], PRICE_TERMS) recent_packaging_complaints = _recent_signal(recent[recent["rating"] <= 2], PACKAGING_TERMS) recent_service_complaints = _recent_signal(recent[recent["rating"] <= 2], SERVICE_TERMS) # Explicit per-review experience tags from the recent window experiences: list[dict] = [] tag_counts: dict[str, int] = {} for _, row in recent.iterrows(): tags = _tag_review(str(row["text"]), int(row["rating"])) if not tags: continue experiences.append( { "timestamp": str(row["timestamp"]), "rating": int(row["rating"]), "tags": tags, "summary": str(row.get("summary") or "")[:80], } ) for t in tags: tag_counts[t] = tag_counts.get(t, 0) + 1 # Open friction = any negative tag fired in recent window negative_tags = {"late_delivery", "bad_packaging", "bad_quality", "bad_service", "overpriced"} open_friction = sorted({t for t in tag_counts if t in negative_tags}) mood = "neutral" if drift <= -0.7 or recent_frustration > 0.4 or open_friction: mood = "frustrated" elif drift >= 0.7 or recent_joy > 0.5: mood = "upbeat" memory = { "user_id": user_id, "long_term": { "avg_rating": round(long_avg, 3), "n_reviews_total": int(len(reviews)), }, "short_term": { "recent_avg_rating": round(recent_avg, 3), "rating_drift": round(drift, 3), "mood": mood, "recent_frustration": round(recent_frustration, 3), "recent_joy": round(recent_joy, 3), "recent_delivery_complaints": round(recent_delivery_complaints, 3), "recent_price_complaints": round(recent_price_complaints, 3), "recent_packaging_complaints": round(recent_packaging_complaints, 3), "recent_service_complaints": round(recent_service_complaints, 3), "open_friction": open_friction, "tag_counts": tag_counts, "recent_experiences": experiences, "last_k_ratings": recent["rating"].tolist(), "last_k_summaries": recent["summary"].fillna("").tolist(), }, } _path(user_id).write_text(json.dumps(memory, indent=2), encoding="utf-8") return memory def get_or_build(user_id: str) -> dict: p = _path(user_id) if p.exists(): return json.loads(p.read_text(encoding="utf-8")) return build(user_id)