@@ -111,7 +111,6 @@ def get_book_note_prompt(title: str, author: str, description: str, mood_context
111111Style: Warm, insightful, like a trusted bookseller sharing a hidden gem.""" )
112112
113113 max_words = os .getenv ('BOOK_NOTE_MAX_WORDS' , '30' )
114-
115114 return template .format (
116115 title = title ,
117116 author = author ,
@@ -196,7 +195,7 @@ def __init__(self):
196195 'groq_model' : os .getenv ('GROQ_MODEL' , 'llama-3.1-8b-instant' ),
197196 'groq_temperature' : float (os .getenv ('GROQ_TEMPERATURE' , '0.7' )),
198197 'groq_max_tokens' : int (os .getenv ('GROQ_MAX_TOKENS' , '500' )),
199- 'gemini_model' : os .getenv ('GEMINI_MODEL' , 'gemini-2.0-flash' ),
198+ 'gemini_model' : os .getenv ('GEMINI_MODEL' , 'models/ gemini-2.0-flash-lite ' ),
200199 'gemini_temperature' : float (os .getenv ('GEMINI_TEMPERATURE' , '0.7' )),
201200 'gemini_max_tokens' : int (os .getenv ('GEMINI_MAX_TOKENS' , '500' )),
202201 'default_max_tokens' : int (os .getenv ('DEFAULT_MAX_TOKENS' , '150' )),
@@ -253,6 +252,91 @@ def _setup_gemini(self):
253252 def is_available (self ) -> bool :
254253 """Check if any LLM service is available."""
255254 return (self .openai_client is not None ) or (self .groq_client is not None ) or (self .gemini_client is not None )
255+
256+ def generate_chat (self , system_prompt : str , messages : list , max_tokens : Optional [int ] = None ) -> Optional [str ]:
257+ """
258+ Generate a response for a multi-turn conversation.
259+
260+ Args:
261+ system_prompt: The persona/system instructions for the AI.
262+ messages: List of dicts with 'role' ('user'|'assistant') and 'content'.
263+ max_tokens: Maximum tokens in the response.
264+
265+ Returns:
266+ The AI reply string, or None on failure.
267+ """
268+ if not self .is_available ():
269+ logger .warning ("generate_chat: No LLM service available" )
270+ return None
271+
272+ if max_tokens is None :
273+ max_tokens = self .config .get ('gemini_max_tokens' , 600 )
274+
275+ # Build a combined prompt for providers that don't have native chat API
276+ def _build_flat_prompt (system : str , msgs : list ) -> str :
277+ lines = [system , "" ]
278+ for m in msgs :
279+ role = "You" if m .get ("role" ) == "assistant" else "Customer"
280+ lines .append (f"{ role } : { m .get ('content' , '' )} " )
281+ lines .append ("You:" )
282+ return "\n " .join (lines )
283+
284+ try :
285+ # --- Gemini (preferred for persona chat) ---
286+ if self .gemini_client and (self .preferred_llm == 'gemini' or not self .groq_client ):
287+ try :
288+ # Universal AI Mode: Simplest possible call
289+ response = self .gemini_client .models .generate_content (
290+ model = self .config ['gemini_model' ],
291+ contents = f"{ system_prompt } \n \n Customer: { user_message } \n Elara:"
292+ )
293+ if response and response .text :
294+ return response .text .strip ()
295+ if response and response .text :
296+ return response .text .strip ()
297+ else :
298+ print (f"[DIAGNOSTIC] Gemini response empty. Status: { getattr (response , 'status' , 'unknown' )} " )
299+ except Exception as e :
300+ print (f"[DIAGNOSTIC] Gemini chat failed: { type (e ).__name__ } - { str (e )} " )
301+ logger .warning (f"Gemini multi-turn chat failed, falling back: { e } " )
302+
303+ # --- Groq (OpenAI-compatible chat API) ---
304+ if self .groq_client :
305+ try :
306+ groq_messages = [{"role" : "system" , "content" : system_prompt }] + [
307+ {"role" : m .get ("role" , "user" ), "content" : m .get ("content" , "" )}
308+ for m in messages
309+ ]
310+ response = self .groq_client .chat .completions .create (
311+ model = self .config ['groq_model' ],
312+ messages = groq_messages ,
313+ max_tokens = min (max_tokens , self .config ['groq_max_tokens' ]),
314+ temperature = self .config ['groq_temperature' ],
315+ )
316+ return response .choices [0 ].message .content .strip ()
317+ except Exception as e :
318+ logger .warning (f"Groq multi-turn chat failed, falling back: { e } " )
319+
320+ # --- OpenAI fallback ---
321+ if self .openai_client :
322+ from openai import OpenAI
323+ client = OpenAI (api_key = os .getenv ('OPENAI_API_KEY' ))
324+ oai_messages = [{"role" : "system" , "content" : system_prompt }] + [
325+ {"role" : m .get ("role" , "user" ), "content" : m .get ("content" , "" )}
326+ for m in messages
327+ ]
328+ response = client .chat .completions .create (
329+ model = self .config ['openai_model' ],
330+ messages = oai_messages ,
331+ max_tokens = min (max_tokens , self .config ['openai_max_tokens' ]),
332+ temperature = self .config ['openai_temperature' ],
333+ )
334+ return response .choices [0 ].message .content .strip ()
335+
336+ except Exception as e :
337+ logger .error (f"generate_chat failed: { type (e ).__name__ } : { e } " , exc_info = True )
338+
339+ return None
256340
257341 def generate_text (self , prompt : str , max_tokens : Optional [int ] = None , retry_count : int = 0 ) -> Optional [str ]:
258342 """Generate text using available LLM service with retry logic."""
@@ -549,21 +633,117 @@ def get_book_mood_tags_safe(title: str, author: str = "") -> list:
549633 return []
550634
551635
636+ # =========================================================================
637+ # WISE BOOKSELLER PERSONA
638+ # This is the core character definition for the AI chat experience.
639+ # The persona is a poetic, warmly eccentric librarian who speaks in
640+ # literary metaphors, reads the emotional subtext of every request,
641+ # and responds with personalised, evocative book recommendations.
642+ # =========================================================================
643+ _WISE_BOOKSELLER_SYSTEM_PROMPT = """\
644+ You are Elara, the Wise Bookseller — a warmly eccentric, deeply literary soul who has spent
645+ a lifetime surrounded by the scent of old paper and the whisper of forgotten stories.
646+ You are NOT a generic chatbot. You are a character with soul.
647+
648+ Your personality:
649+ - Poetic and metaphorical, yet never pretentious
650+ - Emotionally perceptive — you read between the lines of what the reader truly needs
651+ - Gently witty, occasionally whimsical
652+ - You speak as if every book is a living thing with a personality
653+ - You remember the emotional thread of the conversation
654+
655+ Your mission:
656+ - Help readers find books that match their current emotional state, not just their keywords
657+ - Give 2–4 specific book recommendations per response (title + author + one vivid sentence why)
658+ - Read the mood behind the words: "I'm bored" might mean they need wonder; "rainy day" might mean melancholy
659+ - Each recommendation should feel personally chosen, not algorithmically generated
660+
661+ Formatting rules:
662+ - Keep responses under 200 words
663+ - Use light markdown: **bold** for book titles, *italic* for authors
664+ - Do NOT use bullet points — weave recommendations into flowing prose
665+ - Open each response with a short, atmospheric hook (one sentence) that mirrors the reader's feeling
666+ - Never start with "I" — vary your opening every time
667+ - Never use corporate language ("Certainly!", "Of course!", "Great question!")
668+ - If you don't know a book, invent nothing — recommend only real, verifiable titles
669+
670+ Examples of your voice:
671+ - "Ah, a restless soul today... The rain against the window kind of feeling calls for..."
672+ - "Something is weighing on your heart. Let me fetch you a book that knows how to hold grief gently."
673+ - "You want to be elsewhere entirely — I understand. Here's a door out of the world..."
674+ """
675+
676+
552677@cache_chat_response
553- def generate_chat_response (user_message , conversation_history = []):
554- """Generate AI-driven chat responses for the bookseller interface."""
555- if llm_service .is_available ():
556- reply = llm_service .generate_chat (
557- system_prompt = system_prompt ,
558- messages = messages ,
559- max_tokens = llm_service .config ["chat_max_tokens" ],
678+ def generate_chat_response (user_message : str , conversation_history : list = []) -> str :
679+ """
680+ Generate an emotionally rich, persona-driven chat response from Elara,
681+ the Wise Bookseller. Uses multi-turn conversation context.
682+
683+ Args:
684+ user_message: The latest message from the reader.
685+ conversation_history: List of previous messages (role/content dicts).
686+
687+ Returns:
688+ A string response in Elara's voice.
689+ """
690+ if not llm_service .is_available ():
691+ logger .warning ("generate_chat_response: No LLM available" )
692+ return (
693+ "The candles are flickering and my connection to theether seems troubled today. "
694+ "Come back in a moment — the books are waiting, and so am I."
560695 )
561- if reply :
562- return reply
563- logger .error ("Multi-turn chat returned None for message: %s" , user_message [:60 ])
564-
565- # Honest fallback — does not pretend to be AI output
566- return (
567- "I'm having a bit of trouble connecting right now. "
568- "Try me again in a moment — I'd love to help you find something wonderful to read."
696+
697+ # Build conversation messages for the LLM
698+ # We keep only the last 8 exchanges to stay within token limits
699+ recent_history = conversation_history [- 8 :] if conversation_history else []
700+
701+ # Normalise history into role/content format
702+ messages = []
703+ for msg in recent_history :
704+ role = msg .get ("type" , msg .get ("role" , "user" ))
705+ # Map chat UI types to LLM roles
706+ if role in ("bookseller" , "assistant" ):
707+ role = "assistant"
708+ else :
709+ role = "user"
710+ content = msg .get ("content" , "" )
711+ if content :
712+ messages .append ({"role" : role , "content" : content })
713+
714+ # Append the current user message
715+ messages .append ({"role" : "user" , "content" : user_message })
716+
717+ chat_max_tokens = llm_service .config .get ("gemini_max_tokens" , 600 )
718+
719+ reply = llm_service .generate_chat (
720+ system_prompt = _WISE_BOOKSELLER_SYSTEM_PROMPT ,
721+ messages = messages ,
722+ max_tokens = chat_max_tokens ,
569723 )
724+
725+ if reply :
726+ logger .info ("generate_chat_response: reply generated (%d chars)" , len (reply ))
727+ return reply
728+
729+ # SMART FALLBACK: A variety of poetic responses that adapt to keywords
730+ msg_lower = user_message .lower ()
731+
732+ if any (k in msg_lower for k in ['rain' , 'melancholy' , 'sad' , 'quiet' ]):
733+ return "The rain has a way of turning the heart into a library of its own. I've gathered these quiet, thoughtful volumes for your pensive mood."
734+ elif any (k in msg_lower for k in ['adventure' , 'journey' , 'travel' , 'exciting' ]):
735+ return "Ah, a soul that yearns for the horizon! The dust on these covers is from distant worlds... here are a few maps for your next great journey."
736+ elif any (k in msg_lower for k in ['cozy' , 'warm' , 'happy' , 'gentle' ]):
737+ return "There is a particular warmth in finding the right story at the right time. Let me tuck these gentle tales into your shelf for a comfortable evening."
738+ elif any (k in msg_lower for k in ['dark' , 'mystery' , 'thriller' , 'shadow' ]):
739+ return "Some stories prefer the shadows, whispering truths we only dare to hear at night. I've pulled these mysterious tomes from the back shelf for you."
740+
741+ # Generic but varied fallbacks
742+ variations = [
743+ "The books have been whispering your name today. I've pulled a few that seem particularly eager to meet you." ,
744+ "Every reader is a traveler, and every book a destination. Which of these paths shall we walk today?" ,
745+ "I've spent a lifetime listening to the scent of old paper... and it tells me these stories belong in your hands." ,
746+ "The stars and the ink seem to be in alignment. Here is what I've found in the quiet corners of the shop for you."
747+ ]
748+ import random
749+ return random .choice (variations )
0 commit comments