File size: 23,252 Bytes
d0e3307
 
 
 
 
 
77c4795
 
d0e3307
 
 
 
 
 
 
514a1ba
d0e3307
 
 
 
 
 
77c4795
d0e3307
 
 
 
77c4795
d0e3307
 
 
 
 
 
514a1ba
d0e3307
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77c4795
d0e3307
77c4795
514a1ba
 
 
 
d0e3307
 
 
 
 
 
77c4795
d0e3307
 
 
 
 
 
 
 
 
 
 
 
 
 
77c4795
 
 
d0e3307
 
 
 
 
 
 
 
 
 
 
77c4795
 
 
d0e3307
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b89978f
d0e3307
 
 
 
 
 
 
 
 
 
 
b89978f
 
 
 
 
d0e3307
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77c4795
 
 
 
 
 
 
e6f81c2
 
77c4795
e6f81c2
77c4795
e6f81c2
 
 
77c4795
 
 
 
 
 
 
e6f81c2
 
 
77c4795
 
e6f81c2
 
77c4795
e6f81c2
 
77c4795
 
e6f81c2
77c4795
 
e6f81c2
 
 
77c4795
e6f81c2
 
77c4795
e6f81c2
 
 
 
 
 
 
 
 
 
 
 
 
77c4795
e6f81c2
 
77c4795
e6f81c2
 
77c4795
e6f81c2
77c4795
 
 
 
e6f81c2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77c4795
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
"""
This class is a LLM based recommender that can choose the perfect content for the user given user profile and our goal

"""
import json
import os
import random

import pandas as pd
import openai
from openai import OpenAI
from dotenv import load_dotenv
import time
import streamlit as st
from tqdm import tqdm
from Messaging_system.Homepage_Recommender import DefaultRec
load_dotenv()


# -----------------------------------------------------------------------
class LLMR:

    def __init__(self, CoreConfig, random=False):

        self.Core = CoreConfig
        self.user = None
        self.selected_content_ids = []  # will be populated for each user
        self.random=random

    def get_recommendations(self, progress_callback):
        """
        selecting the recommended content for each user
        :return:
        """
        default = DefaultRec(self.Core)

        self.Core.users_df["recommendation"] = None
        self.Core.users_df["recommendation_info"] = None
        total_users = len(self.Core.users_df)

        st.write("Choosing the best content to recommend ... ")

        self.Core.start_time = time.time()
        for progress, (idx, row) in enumerate(
                tqdm(self.Core.users_df.iterrows(), desc="Selecting the best content to recommend ...")):
            # if we have a prompt to generate a personalized message
            # Update progress if callback is provided
            if progress_callback is not None:
                progress_callback(progress, total_users)

            self.user = row
            recommendation_dict, content_info, recsys_json, token = self._get_recommendation()

            if recommendation_dict["content_id"] is None:  # error in selecting a content to recommend
                self.Core.users_df.at[idx, "recommendation"] = default.recommendation
                self.Core.users_df.at[idx, "recommendation_info"] = default.recommendation_info
                self.Core.users_df.at[idx, "recsys_result"] = default.for_you_url


            else:
                # updating tokens
                self.Core.total_tokens['prompt_tokens'] += int(token['prompt_tokens'])
                self.Core.total_tokens['completion_tokens'] += int(token['completion_tokens'])
                self.Core.temp_token_counter = int(token['prompt_tokens']) + int(token['completion_tokens'])
                self.Core.users_df.at[idx, "recommendation"] = recommendation_dict
                self.Core.users_df.at[idx, "recommendation_info"] = content_info
                self.Core.users_df.at[idx, "recsys_result"] = recsys_json
                self.Core.respect_request_ratio()

        return self.Core

    # --------------------------------------------------------------
    # --------------------------------------------------------------
    def _get_recommendation(self):
        """
        select and return the recommendation from the available list of contents
        :return: content_id
        """

        if self.random:  # select recommendations randomly from top options
            return self._get_recommendation_random()

        prompt, recsys_json = self._generate_prompt()
        if prompt is None:
            return None, None, None, None

        else:
            content_id, tokens = self.get_llm_response(prompt)
            if content_id == 0:
                # was not able to receive a recommendation
                return None, None, None, None
            else:
                content_info = self._get_content_info(content_id)
                recsys_data = json.loads(recsys_json)
                recommendation_dict = self._get_recommendation_info(content_id, recsys_data)
                return recommendation_dict, content_info, recsys_json, tokens

    # --------------------------------------------------------------
    # --------------------------------------------------------------

    def _generate_prompt(self):
        """
        Generates the prompts for given user in order to choose the recommendation from the available list
        :param user:
        :return:
        """
        available_contents, recsys_json = self._get_available_contents()
        if available_contents.strip() == "": # no item to recommend
            return None

        # Getting different part of the prompts
        input_context = self._input_context()
        user_info = self._get_user_profile()
        task = self._task_instructions()
        output_instruction = self._output_instruction()

        prompt = f"""
### Context:
{input_context}

### User Information:
{user_info}

### Available Contents:
{available_contents}

### Main Task:
{task}

### Output Instructions:
{output_instruction}
"""

        return prompt, recsys_json

    # --------------------------------------------------------------
    # --------------------------------------------------------------
    def _input_context(self):
        """
        :return: input instructions as a string
        """

        context = f""" 
You are a helpful educational music content recommender. Your goal is to choose a perfect content to recommend to the user given the information that we have from the user and available contents to recommend. 
"""

        return context

    # --------------------------------------------------------------
    # --------------------------------------------------------------
    def _system_instructions(self):
        """
        (Optional) A helper function that defines high-level system context for certain LLMs.
        For example, if your LLM endpoint supports messages in the form of role='system'.
        """
        context = f""" 
        You are a helpful educational music content recommender 
        """

        return context

    # --------------------------------------------------------------
    # --------------------------------------------------------------
    def _task_instructions(self):
        """
        creating the instructions about the task
        :return: task
        """

        task = """
- You must select exactly ONE content from the 'Available Contents' to recommend.
- Base your decision on the User information and focus on providing the most relevant recommendation.
- Do not recommended content where the topic is focused on a specific Gear (e.g. YAMAHA)
- Provide the content_id of the recommended content in the output based on Output instructions.
"""

        return task

    # --------------------------------------------------------------
    # --------------------------------------------------------------
    def _get_user_profile(self):
        """
        getting user's goal and user's last completed content to use for choosing the recommended content
        :return:
        """

        last_completed_content = self._get_user_data(attribute="last_completed_content")
        user_info = self._get_user_data(attribute="user_info")

        recommendation_info = f"""
**User information and preferences:** 

{user_info}

**Previous completed content:** 
{last_completed_content}
"""

        return recommendation_info

    # --------------------------------------------------------------
    # --------------------------------------------------------------
    def _get_user_data(self, attribute):
        """
        get user's information for the requested attribute
        :param user:
        :return: user_info
        """

        # Previous interaction
        if pd.notna(self.user[attribute]) and self.user[attribute] not in [
            None, [], {}] and (not isinstance(self.user[attribute], str) or self.user[attribute].strip()):
            user_info = self.user[attribute]
        else:
            user_info = "Not Available"

        return user_info

    # --------------------------------------------------------------
    # --------------------------------------------------------------

    def _get_user_recommendation(self):

        recsys_json = self.user["recsys_result"]

        try:
            recsys_data = json.loads(recsys_json)
            # Sections to process
            sections = self.Core.recsys_contents

            # Check if none of the sections are present in recsys_data --> cold start scenario
            if not any(section in recsys_data for section in sections):
                popular_content = self.Core.popular_contents_df.iloc[0][f"popular_content"]
                return popular_content
            else:
                return recsys_json
        except:
            popular_content = self.Core.popular_contents_df.iloc[0][f"popular_content"]
            return popular_content


    # --------------------------------------------------------------
    # --------------------------------------------------------------
    def _get_available_contents(self):

        # Get the user ID
        recsys_json = self._get_user_recommendation()
        recsys_data = json.loads(recsys_json)

        # Sections to process
        sections = self.Core.recsys_contents

        # Collect selected content_ids
        selected_content_ids = []

        for section in sections:
            if section in recsys_data:
                # Get the list of recommendations in this section
                recs = recsys_data[section]
                # Sort by recommendation_rank (ascending order)
                recs_sorted = sorted(recs, key=lambda x: x['recommendation_rank'])
                # Select top 3 recommendations
                top_recs = recs_sorted[:3]
                # Get the content_ids
                content_ids = [rec['content_id'] for rec in top_recs]
                # Append to the list
                selected_content_ids.extend(content_ids)
        # Fetch content info for the selected content_ids
        content_info_rows = self.Core.content_info[self.Core.content_info['content_id'].isin(selected_content_ids)]

        # Create a mapping from CONTENT_ID to CONTENT_INFO
        content_info_map = dict(zip(content_info_rows['content_id'], content_info_rows['content_info']))

        # Assemble the text in a structured way using a list
        lines = []
        for content_id in selected_content_ids:
            # Retrieve the content_info (which may include multi-line text)
            content_info = content_info_map.get(content_id, "No content info found")

            # Append the structured lines without extra spaces
            lines.append(f"**content_id**: {content_id}")
            lines.append("**content_info**:")
            lines.append(content_info)  # this line may already contain internal newlines
            lines.append("")  # blank line for separation

        # Join all lines into a single text string with newline characters
        text = "\n".join(lines)

        self.selected_content_ids = selected_content_ids

        return text, recsys_json

    # --------------------------------------------------------------
    # --------------------------------------------------------------

    def _get_content_info(self, content_id):
        """
        getting content_info for the recommended content
        :param content_id:
        :return:
        """

        content_info_row = self.Core.content_info[self.Core.content_info['content_id'] == content_id]
        content_info = content_info_row['content_info'].iloc[0]

        return content_info

    # --------------------------------------------------------------
    # --------------------------------------------------------------
    def is_valid_content_id(self, content_id):
        """
        check if the llm respond is a valid content_id
        :param content_id:
        :return:
        """

        if content_id in self.selected_content_ids:
            return True
        else:
            return False

    # --------------------------------------------------------------
    # --------------------------------------------------------------
    def _output_instruction(self):
        """
        :return: output instructions as a string
        """

        instructions = f"""
    Return the content_id of the final recommendation in **JSON** format with the following structure:

    {{
      "content_id": "content_id of the recommended content from Available Contents, as an integer",
    }}
    
    Do not include any additional keys or text outside the JSON.
    """

        return instructions

    def get_llm_response(self, prompt, max_retries=4):
        """
        sending the prompt to the LLM and get back the response
        """

        openai.api_key = self.Core.api_key
        instructions = self._system_instructions()
        client = OpenAI(api_key=self.Core.api_key)

        for attempt in range(max_retries):
            try:
                response = client.chat.completions.create(
                    model=self.Core.model,
                    response_format={"type": "json_object"},
                    messages=[
                        {"role": "system", "content": instructions},
                        {"role": "user", "content": prompt}
                    ],
                    max_tokens=20,
                    n=1,
                    temperature=0.7
                )

                tokens = {
                    'prompt_tokens': response.usage.prompt_tokens,
                    'completion_tokens': response.usage.completion_tokens,
                    'total_tokens': response.usage.total_tokens
                }

                try:
                    content = response.choices[0].message.content

                    # Extract JSON code block

                    output = json.loads(content)

                    if 'content_id' in output and self.is_valid_content_id(int(output['content_id'])):
                        return int(output['content_id']), tokens

                    else:
                        print(f"'content_id' missing or invalid in response on attempt {attempt + 1}. Retrying...")
                        continue  # Continue to next attempt

                except json.JSONDecodeError:
                    print(f"Invalid JSON from LLM on attempt {attempt + 1}. Retrying...")

            except openai.APIConnectionError as e:
                print("The server could not be reached")
                print(e.__cause__)  # an underlying Exception, likely raised within httpx.
            except openai.RateLimitError as e:
                print("A 429 status code was received; we should back off a bit.")
            except openai.APIStatusError as e:
                print("Another non-200-range status code was received")
                print(e.status_code)
                print(e.response)

        print("Max retries exceeded. Returning empty response.")
        return 0, 0


    # ==========================================================================
    # Randomly select recommendations from top options
    # ==========================================================================
    def _get_recommendation_random(self):
        """
        Randomly pick ONE valid item from the top-5 of each requested section.
        If the first random pick is missing/invalid, keep trying other candidates.
        Also remove the picked item from every section in recsys_json.
        Returns: (recommendation_dict, content_info, updated_recsys_json, zero_tokens_dict)
        """
        import json, random

        # 1) Get user's recsys_result or fall back to {}
        recsys_json = self._get_user_recommendation()
        try:
            recsys_data = json.loads(recsys_json) if recsys_json else {}
        except Exception:
            recsys_data = {}

        sections = self.Core.recsys_contents

        # 2) Primary candidate set
        unique_candidates = self.build_unique_candidates(recsys_data, sections)

        # 3) Cold start or empty? -> use popular contents
        used_popular_fallback = False
        if not unique_candidates:
            recsys_data = self._get_popular_fallback_json(k=5)
            unique_candidates = self.build_unique_candidates(recsys_data, sections)
            used_popular_fallback = True

        # Still nothing? bail out
        if not unique_candidates:
            return None, None, None, None

        # 4) Try candidates in random order until a valid one is found
        idxs = list(range(len(unique_candidates)))
        random.shuffle(idxs)

        picked_id, recommendation_dict, content_info = self.try_pick_from_candidates(idxs, unique_candidates,
                                                                                     recsys_data)

        # 5) If nothing valid from primary set, and we haven't tried popular fallback yet, try it now
        if picked_id is None and not used_popular_fallback:
            recsys_data = self._get_popular_fallback_json(k=5)
            unique_candidates = self.build_unique_candidates(recsys_data, sections)
            if unique_candidates:
                idxs = list(range(len(unique_candidates)))
                random.shuffle(idxs)
                picked_id, recommendation_dict, content_info = self.try_pick_from_candidates(idxs, unique_candidates,
                                                                                             recsys_data)

        # 6) If still nothing, bail out
        if picked_id is None:
            return None, None, None, None

        # 7) Remove picked_id from ALL sections and store back
        recsys_data = self._remove_selected_from_all(recsys_data, picked_id)

        # 8) Track available ids if you still need it elsewhere
        self.selected_content_ids = [r["content_id"] for r in unique_candidates if r.get("content_id")]

        # 9) Prepare return values
        updated_json = json.dumps(recsys_data)
        zero_tokens = {"prompt_tokens": 0, "completion_tokens": 0}

        return recommendation_dict, content_info, updated_json, zero_tokens

    # ====================================================================
    def build_unique_candidates(self, src_data, sections):
        # Build candidate pool (top 5 per section) and dedupe by content_id
        cands = self._collect_top_k(src_data, sections, k=5)
        seen, uniq = set(), []
        for rec in cands or []:
            cid = rec.get("content_id")
            if cid and cid not in seen:
                seen.add(cid)
                uniq.append(rec)
        return uniq

    # ======================================================================
    def try_pick_from_candidates(self, idxs, candidates, source_data):
        """
        Iterate candidates in random order, returning the first valid pick:
        (picked_id, recommendation_dict, content_info) or (None, None, None)
        """
        banned_contents = set(self.Core.config_file.get("banned_contents", []))  # use set for faster lookup

        for i in idxs:
            rec = candidates[i]
            picked_id = rec.get("content_id")
            if not picked_id:
                continue
            # Skip if content is banned
            if picked_id in banned_contents:
                continue
            try:
                # Validate we can fetch both info payloads
                content_info = self._get_content_info(picked_id)
                if not content_info:
                    # Treat falsy/empty as invalid and keep searching
                    continue

                recommendation_dict = self._get_recommendation_info(picked_id, source_data)
                # If both succeed, we have a winner
                return picked_id, recommendation_dict, content_info

            except IndexError:
                # Your reported failure mode; skip this candidate
                continue
            except KeyError:
                continue
            except Exception:
                # Any unexpected data issue: skip and try the next
                continue
        return None, None, None
    #======================================================================
    # helpers used by the random path
    #======================================================================
    def _get_recommendation_info(self, content_id, recsys_data):
        # Search through all categories in the recsys data
        found_item=None
        for category, items in recsys_data.items():
            for item in items:
                if item.get("content_id") == content_id:
                    found_item = item
                    break  # Exit inner loop if item is found
            if found_item:
                break  # Exit outer loop if item is found

        if found_item is None:
            print(f"content_id {content_id} not found in recsys_data")
            return None

        # Extract required fields from found_item
        web_url_path = found_item.get("web_url_path")
        title = found_item.get("title")
        thumbnail_url = found_item.get("thumbnail_url")

        # Add these to the recommendation dict
        recommendation_dict = {
            "content_id": content_id,
            "web_url_path": web_url_path,
            "title": title,
            "thumbnail_url": thumbnail_url
        }

        return recommendation_dict
    # =====================================================================
    def _collect_top_k(self, recsys_data: dict, sections, k: int = 5):
        """
        From each section, grab top-k items by recommendation_rank (ascending).
        Returns a flat list of rec dicts.
        """
        out = []
        for sec in sections:
            recs = recsys_data.get(sec, [])
            if not isinstance(recs, list):
                continue
            recs_sorted = sorted(
                recs,
                key=lambda x: x.get("recommendation_rank", float("inf"))
            )
            out.extend(recs_sorted[:k])
        return out
    #======================================================================
    def _get_popular_fallback_json(self, k: int = 5):
        """
        Build a recsys-like dict from popular contents when user has no recsys_result.
        Assumes self.Core.popular_contents_df.iloc[0]['popular_content'] holds a JSON string
        with the same structure: {section: [{content_id, recommendation_rank, ...}, ...], ...}
        """
        try:
            popular_json = self.Core.popular_contents_df.iloc[0]["popular_content"]
            data = json.loads(popular_json)
        except Exception:
            return {}

        sections = self.Core.recsys_contents
        out = {}
        for sec in sections:
            recs = data.get(sec, [])
            recs_sorted = sorted(
                recs,
                key=lambda x: x.get("recommendation_rank", float("inf"))
            )
            out[sec] = recs_sorted[:k]
        return out
    #======================================================================
    def _remove_selected_from_all(self, recsys_data: dict, content_id):
        """
        Remove the chosen content_id from every section so it won't be recommended again.
        """
        for sec, recs in list(recsys_data.items()):
            if isinstance(recs, list):
                recsys_data[sec] = [r for r in recs if r.get("content_id") != content_id]
        return recsys_data