User Preference & Recommendation MCP
Purpose
Transforms raw reading history and explicit ratings into personalised manga recommendations. Combines collaborative filtering (Amazon Personalize) with semantic RAG over the catalog, so recommendations are both statistically grounded and contextually explained.
Exposed Tools
| Tool | Input | Output | Use Case |
|---|---|---|---|
get_recommendations |
user_id, context_query, limit |
RecommendedList |
Personalised feed |
get_user_preferences |
user_id |
PreferenceProfile |
Current taste snapshot |
update_reading_history |
user_id, manga_id, event_type |
ack |
Record read/rated/wishlisted |
get_preference_explanation |
user_id, manga_id |
ExplanationText |
"Why was this recommended?" |
Architecture: Personalise + RAG Fusion
flowchart TD
TC([Tool Call: get_recommendations\nuser_id · context_query]) --> PF[Fetch Personalize Candidates\nAmazon Personalize · HRNN-Coldstart]
TC --> QE[Embed context_query\nTitan Embed v2]
PF --> FC[50 Candidate manga_ids]
QE --> SS[Semantic Search\nOpenSearch with manga_ids filter]
SS --> SC[Semantic Candidates]
FC --> MX[Score Mixer\nPersonalize score × semantic score]
SC --> MX
MX --> RR[Cross-Encoder Rerank\non merged list]
RR --> EX[Explanation Generator\nWhy recommended?]
EX --> TR([Tool Result → Claude])
style TC fill:#4A90D9,color:#fff
style TR fill:#27AE60,color:#fff
style MX fill:#8E44AD,color:#fff
User Preference Profile: Data Model
@dataclass
class PreferenceProfile:
user_id: str
preferred_genres: list[str] # ["dark_fantasy", "action", "seinen"]
disliked_genres: list[str] # ["isekai", "harem"]
preferred_demographics: list[str] # ["seinen", "josei"]
content_rating_max: str # "mature" | "all_ages" | "explicit"
reading_pace: str # "volume_collector" | "chapter_reader"
language_preference: str # "japanese_raw" | "english" | "both"
taste_embedding: list[float] # 1024-dim average of read manga embeddings
last_updated: datetime
The taste_embedding is the centroid of all read manga embeddings, updated incrementally on each reading event:
def update_taste_embedding(old_emb: list[float], new_manga_emb: list[float],
alpha: float = 0.1) -> list[float]:
# Exponential moving average — recent reads weight more
return [(1 - alpha) * o + alpha * n for o, n in zip(old_emb, new_manga_emb)]
Cold-Start Handling
flowchart TD
A{User has\nreading history?} -->|≥ 5 reads| B[Full Personalise\n+ RAG Fusion]
A -->|1-4 reads| C[Content-Based Only\nSeed embedding from reads]
A -->|0 reads| D[Onboarding Flow\nPick 3 favourite genres]
D --> E[Genre-Seeded Vector\nAverage genre exemplar embeddings]
E --> F[Catalog MCP\nFilter by selected genres]
C --> G[Taste Embedding\nFrom sparse history]
G --> F
B --> H([Personalised Result])
F --> H
style A fill:#8E44AD,color:#fff
style H fill:#27AE60,color:#fff
Recommendation Explanation (RAG)
When Claude calls get_preference_explanation, the MCP:
- Fetches the user's top-3 recently read manga
- Fetches the target manga's metadata
- Runs a semantic similarity comparison between the two sets
- Constructs a human-readable explanation
def generate_explanation(user_history: list[Manga], target: Manga) -> str:
similarities = [
(h, cosine_sim(h.embedding, target.embedding))
for h in user_history
]
top_match = max(similarities, key=lambda x: x[1])
shared_genres = set(top_match[0].genres) & set(target.genres)
shared_themes = get_shared_themes(top_match[0], target) # NLP extraction
return (
f"Recommended because you enjoyed '{top_match[0].title_en}'. "
f"Both feature {', '.join(shared_genres)} themes "
f"and share the tone of {', '.join(shared_themes)}."
)
Data Flow: Reading Event → Preference Update
sequenceDiagram
participant App
participant PrefMCP
participant DynamoDB
participant Kinesis
participant EmbedSvc
participant Personalize
App->>PrefMCP: update_reading_history(user_id, manga_id, event="completed")
PrefMCP->>DynamoDB: Write event to UserEvents table
PrefMCP->>Kinesis: Publish event to user-events stream
Kinesis->>EmbedSvc: Trigger embedding update Lambda
EmbedSvc->>DynamoDB: Update taste_embedding (EMA update)
Kinesis->>Personalize: Feed real-time event tracker
PrefMCP-->>App: ack {updated: true}
Diversity Injection
Pure collaborative filtering creates filter bubbles — users only see what they already liked. The MCP injects diversity in the final ranking step:
def diversify(candidates: list[MangaScore], diversity_factor: float = 0.3) -> list[MangaScore]:
selected = [candidates[0]] # Always keep #1
for candidate in candidates[1:]:
max_sim = max(cosine_sim(candidate.embedding, s.embedding) for s in selected)
diversity_score = candidate.relevance * (1 - diversity_factor * max_sim)
candidate.final_score = diversity_score
return sorted(candidates, key=lambda x: x.final_score, reverse=True)
Latency Budget
gantt
title Preference MCP P99 Latency (1200ms total)
dateFormat X
axisFormat %Lms
section Pipeline
Fetch Personalize candidates :0, 150
Embed context query :0, 70
OpenSearch semantic search :150, 350
Score mixing :350, 400
Cross-encoder rerank :400, 520
Explanation generation :520, 650
DynamoDB history fetch :0, 80
Format + serialize :650, 700
Note: Personalize fetch and embedding run concurrently (asyncio.gather).
Interview Grill
Q: Why use Amazon Personalize alongside RAG rather than just RAG? A: Personalize captures collaborative signals — "users like you read X". RAG captures semantic similarity — "X is similar to what you described". They address different axes of relevance. Personalize alone can't handle a new user query like "find me something with samurai politics", and RAG alone has no knowledge of community behaviour patterns.
Q: How do you avoid recommending manga the user has already read?
A: DynamoDB UserReadHistory table is queried before scoring. Any manga_id in the user's history is excluded from the candidate set via a bloom filter (fast membership check) before reranking.
Q: What if the Personalize model hasn't been retrained with recent releases? A: Personalize has a real-time event tracker that updates scores within minutes of events. But for manga published in the last 7 days, the Catalog MCP's semantic search (no Personalize dependency) is exclusively used so new titles get immediate exposure.
Q: How is the taste embedding protected from preference poisoning (a user mass-rating to skew their vector)?
A: Rate limiting on update_reading_history (max 20 events/minute). Outlier detection: if a single event shifts the taste embedding by more than 0.15 cosine distance, the update is quarantined and reviewed asynchronously.