File size: 11,754 Bytes
921ab6c
1b4d8db
921ab6c
 
 
 
 
 
 
 
 
 
1b4d8db
 
 
 
 
 
 
 
 
921ab6c
 
1b4d8db
 
 
 
 
921ab6c
1b4d8db
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
921ab6c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
"""FastAPI service exposing TANDEM as four endpoints (BCT hackathon submission).

Task A      POST /simulator/predict            persona + product   -> review + rating
Task B      POST /recommender/recommend        persona + cands     -> deterministic top-k
Agentic     POST /recommender/recommend-agentic persona + cands    -> plan/score/reflect top-k
Multi-turn  POST /recommender/converse          session+message    -> conversational top-k

The /recommender/recommend endpoint is the deterministic argsort that is
load-bearing for the H7 architectural ablation. /recommender/recommend-agentic
is the second mode that satisfies the brief's "agentic workflows that
reason before recommending" requirement without disturbing the ablation
baseline.

Run locally:
    uvicorn src.api.main:app --reload --host 0.0.0.0 --port 8000

Run in Docker:
    docker compose up                 # uses .env for GROQ_API_KEY
"""
from __future__ import annotations

import threading
import uuid
from typing import Literal

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field

from src.agents.recommender_agentic import recommend_agentic
from src.agents.simulator import predict
from src.llm.client import GroqClient

app = FastAPI(
    title="TANDEM",
    description="Two-agent LLM recommendation with cultural overlay (BCT 2026)",
    version="0.1.0",
)

# Lazy-initialised client; reads GROQ_API_KEY from env on first request.
_client: GroqClient | None = None


def _get_client() -> GroqClient:
    global _client
    if _client is None:
        _client = GroqClient(cache_path="cache/llm_responses.jsonl")
    return _client


# ---- Schemas ---------------------------------------------------------------

class HistoryItem(BaseModel):
    item_id: str
    rating: float = 0.0
    summary: str = ""
    review_text: str = ""
    timestamp: int = 0


class Persona(BaseModel):
    persona_id: str = "p_request"
    history_window: list[HistoryItem] = Field(default_factory=list)
    preference_summary: str = ""
    default_name: str = "User"
    naija_name: str = "Nigerian User"
    ethnic_hint: str = "Nigerian"
    religious_hint: str = ""


class Item(BaseModel):
    item_id: str
    title: str
    brand: str = ""
    description: str = ""
    category: str = ""


class SimulatorRequest(BaseModel):
    persona: Persona
    item: Item
    condition: Literal["overlay-off", "noise-on", "cultural-on"] = "cultural-on"
    architecture: Literal["decomposed", "monolithic"] = "decomposed"


class SimulatorResponse(BaseModel):
    rating: float
    review: str
    model: str
    cached: bool


class RecommenderRequest(BaseModel):
    persona: Persona
    candidates: list[Item]
    top_k: int = 10
    condition: Literal["overlay-off", "noise-on", "cultural-on"] = "cultural-on"


class RankedItem(BaseModel):
    item_id: str
    rating: float
    review: str


class RecommenderResponse(BaseModel):
    ranked: list[RankedItem]
    cached_hits: int
    api_calls: int


# ---- Endpoints --------------------------------------------------------------

@app.get("/health")
def health() -> dict:
    return {"status": "ok", "service": "TANDEM", "version": "0.1.0"}


@app.post("/simulator/predict", response_model=SimulatorResponse)
def simulator_predict(req: SimulatorRequest) -> SimulatorResponse:
    """Task A — predict the user's review and rating for the candidate item."""
    persona_dict = req.persona.model_dump()
    item_dict = req.item.model_dump()
    rec = predict(
        client=_get_client(),
        persona=persona_dict,
        item=item_dict,
        condition=req.condition,
        architecture=req.architecture,
    )
    return SimulatorResponse(
        rating=rec["predicted_rating"],
        review=rec["predicted_review"],
        model=rec["model"],
        cached=rec["cached"],
    )


@app.post("/recommender/recommend", response_model=RecommenderResponse)
def recommender_recommend(req: RecommenderRequest) -> RecommenderResponse:
    """Task B — rank the candidate items for the persona by predicted rating."""
    if not req.candidates:
        raise HTTPException(400, "candidates list is empty")

    client = _get_client()
    persona_dict = req.persona.model_dump()
    predictions: list[dict] = []
    cached_hits = 0
    for item in req.candidates:
        rec = predict(
            client=client,
            persona=persona_dict,
            item=item.model_dump(),
            condition=req.condition,
            architecture="decomposed",
        )
        if rec.get("cached"):
            cached_hits += 1
        predictions.append(rec)

    predictions.sort(
        key=lambda r: (-r["predicted_rating"], -len(r.get("predicted_review", ""))),
    )
    top = predictions[: req.top_k]
    return RecommenderResponse(
        ranked=[
            RankedItem(
                item_id=r["item_id"],
                rating=r["predicted_rating"],
                review=r["predicted_review"],
            )
            for r in top
        ],
        cached_hits=cached_hits,
        api_calls=len(predictions) - cached_hits,
    )


# ---- Agentic recommender (plan -> score -> reflect) -------------------------

class AgenticRequest(BaseModel):
    persona: Persona
    candidates: list[Item]
    top_k: int = 5
    reflect_window: int = 5
    condition: Literal["overlay-off", "noise-on", "cultural-on"] = "cultural-on"


class AgenticRankedItem(BaseModel):
    item_id: str
    rank: int
    title: str
    predicted_rating: float
    predicted_review: str
    reason: str


class AgenticResponse(BaseModel):
    priorities: list[str]
    ranked: list[AgenticRankedItem]
    trace: dict


@app.post("/recommender/recommend-agentic", response_model=AgenticResponse)
def recommender_recommend_agentic(req: AgenticRequest) -> AgenticResponse:
    """Agentic Task B mode. Plans the user's priorities, scores each candidate
    via the simulator, then reflects + re-ranks the top-N with one-sentence
    justifications. Total LLM calls: C scoring + 2 reasoning (plan, reflect).

    The deterministic /recommender/recommend endpoint remains canonical for
    H7-ablation purposes; this is the additional reasoning mode the BCT
    brief asks for.
    """
    if not req.candidates:
        raise HTTPException(400, "candidates list is empty")
    if req.top_k < 1 or req.top_k > 20:
        raise HTTPException(400, "top_k must be between 1 and 20")
    result = recommend_agentic(
        client=_get_client(),
        persona=req.persona.model_dump(),
        candidates=[c.model_dump() for c in req.candidates],
        top_k=req.top_k,
        reflect_window=req.reflect_window,
        condition=req.condition,
    )
    return AgenticResponse(
        priorities=result["priorities"],
        ranked=[AgenticRankedItem(**r) for r in result["ranked"]],
        trace=result["trace"],
    )


# ---- Multi-turn conversational recommender ----------------------------------

# Sessions live in process memory. The free-tier HF Space restarts on sleep, so
# session state is best-effort and ephemeral by design --- judges hitting the
# live demo see this behaviour explicitly documented in /docs.
_sessions: dict[str, dict] = {}
_sessions_lock = threading.Lock()


class ConverseRequest(BaseModel):
    session_id: str | None = Field(
        default=None,
        description="Omit on the first turn; the server returns one. Pass it back on subsequent turns.",
    )
    persona: Persona | None = None
    candidates: list[Item] | None = None
    message: str | None = Field(
        default=None,
        description="User feedback for turns 2+, e.g. 'I want something with shea butter, not synthetic'.",
    )
    top_k: int = 5
    condition: Literal["overlay-off", "noise-on", "cultural-on"] = "cultural-on"


class ConverseResponse(BaseModel):
    session_id: str
    turn: int
    refined_preferences: list[str]
    ranked: list[RankedItem]
    assistant_reply: str


def _converse_rank(client: GroqClient, persona: dict, candidates: list[dict], top_k: int,
                   condition: str) -> list[dict]:
    out: list[dict] = []
    for it in candidates:
        rec = predict(client=client, persona=persona, item=it, condition=condition,
                      architecture="decomposed")
        out.append(rec)
    out.sort(key=lambda r: (-r["predicted_rating"], -len(r.get("predicted_review", ""))))
    return out[:top_k]


@app.post("/recommender/converse", response_model=ConverseResponse)
def recommender_converse(req: ConverseRequest) -> ConverseResponse:
    """Conversational recommendation with server-side session state.

    First turn: send `persona` and `candidates`. Server returns a `session_id`
    plus top-k.

    Subsequent turns: send the same `session_id` and a free-text `message`
    (e.g. "I want something with shea butter, not synthetic"). The server
    appends the feedback to the persona's preference summary and re-ranks
    the original candidate pool through the simulator, returning the new
    top-k. The candidate pool is fixed at session creation.

    Sessions are in-process and ephemeral; expect them to vanish on HF
    Space sleep.
    """
    client = _get_client()

    if req.session_id is None:
        if req.persona is None or not req.candidates:
            raise HTTPException(400, "first turn requires `persona` and `candidates`")
        sid = uuid.uuid4().hex[:12]
        persona_dict = req.persona.model_dump()
        candidates_dict = [c.model_dump() for c in req.candidates]
        top = _converse_rank(client, persona_dict, candidates_dict, req.top_k, req.condition)
        state = {
            "persona": persona_dict,
            "candidates": candidates_dict,
            "refined": [],
            "turn": 1,
            "top_k": req.top_k,
            "condition": req.condition,
        }
        with _sessions_lock:
            _sessions[sid] = state
        reply = (
            f"Started session {sid}. Top {len(top)} recommendations ranked. "
            f"Send a follow-up message to refine — e.g. 'I want something with shea butter, "
            f"not synthetic' or 'show me halal-friendly options only'."
        )
        return ConverseResponse(
            session_id=sid, turn=1, refined_preferences=[],
            ranked=[RankedItem(item_id=r["item_id"], rating=r["predicted_rating"],
                               review=r["predicted_review"]) for r in top],
            assistant_reply=reply,
        )

    with _sessions_lock:
        state = _sessions.get(req.session_id)
    if state is None:
        raise HTTPException(404, f"session {req.session_id} not found (sessions are ephemeral)")
    if not req.message:
        raise HTTPException(400, "subsequent turns require `message`")

    msg = req.message.strip()[:300]
    state["refined"].append(msg)
    state["turn"] += 1
    persona = dict(state["persona"])
    persona["preference_summary"] = (
        (persona.get("preference_summary") or "")
        + " | conversational refinement: " + " ; ".join(state["refined"])
    ).strip()
    persona["persona_id"] = f"{state['persona'].get('persona_id', 'session')}-t{state['turn']}"
    top = _converse_rank(client, persona, state["candidates"], state["top_k"], state["condition"])

    reply = (
        f"Turn {state['turn']}: applied feedback '{msg[:80]}'. "
        f"Re-ranked top {len(top)}. Send another message to keep refining."
    )
    return ConverseResponse(
        session_id=req.session_id, turn=state["turn"],
        refined_preferences=list(state["refined"]),
        ranked=[RankedItem(item_id=r["item_id"], rating=r["predicted_rating"],
                           review=r["predicted_review"]) for r in top],
        assistant_reply=reply,
    )