|
19 | 19 |
|
20 | 20 | #to generate book spine images dynamically based on title and author |
21 | 21 |
|
22 | | -from backend.spine_generator import create_spine |
| 22 | +from spine_generator import create_spine |
23 | 23 |
|
24 | 24 | def process_new_book(book_data): |
25 | 25 | # 1. Save book to your database first |
@@ -508,6 +508,113 @@ def _build_flat_prompt(system: str, msgs: list) -> str: |
508 | 508 | logger.error(f"generate_chat failed: {type(e).__name__}: {e}", exc_info=True) |
509 | 509 |
|
510 | 510 | return None |
| 511 | + |
| 512 | + def generate_chat_stream(self, system_prompt: str, messages: list, max_tokens: Optional[int] = None): |
| 513 | + """Yield chunks of text from the LLM for streaming.""" |
| 514 | + if not self.is_available(): |
| 515 | + yield "The candles are flickering and my connection to the ether seems troubled today. " |
| 516 | + return |
| 517 | + |
| 518 | + if max_tokens is None: |
| 519 | + max_tokens = self.config.get('gemini_max_tokens', 600) |
| 520 | + |
| 521 | + # Per-model context window limits (conservative estimates) |
| 522 | + _MODEL_CONTEXT_LIMITS = { |
| 523 | + 'llama-3.1-8b-instant': 8192, |
| 524 | + 'llama-3.3-70b-versatile': 32768, |
| 525 | + 'gpt-3.5-turbo': 4096, |
| 526 | + 'gpt-4': 8192, |
| 527 | + 'gpt-4o': 16384, |
| 528 | + 'models/gemini-2.0-flash-lite': 32768, |
| 529 | + 'models/gemini-1.5-flash': 32768, |
| 530 | + } |
| 531 | + |
| 532 | + def _get_context_limit(model_name: str) -> int: |
| 533 | + return _MODEL_CONTEXT_LIMITS.get(model_name, 4096) |
| 534 | + |
| 535 | + try: |
| 536 | + # --- Gemini --- |
| 537 | + if self.gemini_client and (self.preferred_llm == 'gemini' or not self.groq_client): |
| 538 | + try: |
| 539 | + from google.genai import types |
| 540 | + context_limit = _get_context_limit(self.config['gemini_model']) |
| 541 | + trimmed = self.trim_history_to_token_budget( |
| 542 | + system_prompt, messages, max_tokens, context_limit |
| 543 | + ) |
| 544 | + |
| 545 | + flat_prompt = system_prompt + "\n\n" |
| 546 | + for m in trimmed: |
| 547 | + role = "Elara" if m.get("role") == "assistant" else "Customer" |
| 548 | + flat_prompt += f"{role}: {m.get('content', '')}\n" |
| 549 | + flat_prompt += "Elara:" |
| 550 | + |
| 551 | + response = self.gemini_client.models.generate_content_stream( |
| 552 | + model=self.config['gemini_model'], |
| 553 | + contents=flat_prompt, |
| 554 | + ) |
| 555 | + for chunk in response: |
| 556 | + if chunk.text: |
| 557 | + yield chunk.text |
| 558 | + return |
| 559 | + except Exception as e: |
| 560 | + logger.warning(f"Gemini streaming chat failed, falling back: {e}") |
| 561 | + |
| 562 | + # --- Groq --- |
| 563 | + if self.groq_client: |
| 564 | + try: |
| 565 | + context_limit = _get_context_limit(self.config['groq_model']) |
| 566 | + trimmed = self.trim_history_to_token_budget( |
| 567 | + system_prompt, messages, max_tokens, context_limit |
| 568 | + ) |
| 569 | + |
| 570 | + groq_messages = [{"role": "system", "content": system_prompt}] + [ |
| 571 | + {"role": m.get("role", "user"), "content": m.get("content", "")} |
| 572 | + for m in trimmed |
| 573 | + ] |
| 574 | + response = self.groq_client.chat.completions.create( |
| 575 | + model=self.config['groq_model'], |
| 576 | + messages=groq_messages, |
| 577 | + max_tokens=min(max_tokens, self.config['groq_max_tokens']), |
| 578 | + temperature=self.config['groq_temperature'], |
| 579 | + stream=True |
| 580 | + ) |
| 581 | + for chunk in response: |
| 582 | + content = chunk.choices[0].delta.content |
| 583 | + if content: |
| 584 | + yield content |
| 585 | + return |
| 586 | + except Exception as e: |
| 587 | + logger.warning(f"Groq streaming chat failed, falling back: {e}") |
| 588 | + |
| 589 | + # --- OpenAI --- |
| 590 | + if self.openai_client: |
| 591 | + from openai import OpenAI |
| 592 | + client = OpenAI(api_key=os.getenv('OPENAI_API_KEY')) |
| 593 | + context_limit = _get_context_limit(self.config['openai_model']) |
| 594 | + trimmed = self.trim_history_to_token_budget( |
| 595 | + system_prompt, messages, max_tokens, context_limit |
| 596 | + ) |
| 597 | + |
| 598 | + oai_messages = [{"role": "system", "content": system_prompt}] + [ |
| 599 | + {"role": m.get("role", "user"), "content": m.get("content", "")} |
| 600 | + for m in trimmed |
| 601 | + ] |
| 602 | + response = client.chat.completions.create( |
| 603 | + model=self.config['openai_model'], |
| 604 | + messages=oai_messages, |
| 605 | + max_tokens=min(max_tokens, self.config['openai_max_tokens']), |
| 606 | + temperature=self.config['openai_temperature'], |
| 607 | + stream=True |
| 608 | + ) |
| 609 | + for chunk in response: |
| 610 | + content = chunk.choices[0].delta.content |
| 611 | + if content: |
| 612 | + yield content |
| 613 | + return |
| 614 | + |
| 615 | + except Exception as e: |
| 616 | + logger.error(f"generate_chat_stream failed: {type(e).__name__}: {e}", exc_info=True) |
| 617 | + yield "An error occurred while peering into the books. Please ask again." |
511 | 618 |
|
512 | 619 | def generate_text(self, prompt: str, max_tokens: Optional[int] = None, retry_count: int = 0) -> Optional[str]: |
513 | 620 | """Generate text using available LLM service with retry logic.""" |
@@ -653,7 +760,7 @@ def _generate_with_gemini(self, prompt: str, max_tokens: int) -> Optional[str]: |
653 | 760 | llm_service = LLMService() |
654 | 761 |
|
655 | 762 | __all__ = ['generate_book_note', 'get_ai_recommendations', 'get_category_books', |
656 | | - 'get_book_mood_tags_safe', 'generate_chat_response', 'llm_service', |
| 763 | + 'get_book_mood_tags_safe', 'generate_chat_response', 'generate_chat_response_stream', 'llm_service', |
657 | 764 | 'LLMService', 'PromptTemplates'] |
658 | 765 |
|
659 | 766 |
|
@@ -963,6 +1070,56 @@ def generate_chat_response(user_message: str, conversation_history: list = []) - |
963 | 1070 | import random |
964 | 1071 | return random.choice(variations) |
965 | 1072 |
|
| 1073 | +def generate_chat_response_stream(user_message: str, conversation_history: list = []): |
| 1074 | + """ |
| 1075 | + Yield an emotionally rich, persona-driven chat response from Elara. |
| 1076 | + """ |
| 1077 | + if not llm_service.is_available(): |
| 1078 | + logger.warning("generate_chat_response_stream: No LLM available") |
| 1079 | + yield "The candles are flickering and my connection to the ether seems troubled today. " |
| 1080 | + yield "Come back in a moment — the books are waiting, and so am I." |
| 1081 | + return |
| 1082 | + |
| 1083 | + messages = [] |
| 1084 | + for msg in (conversation_history or []): |
| 1085 | + role = msg.get("type", msg.get("role", "user")) |
| 1086 | + if role in ("bookseller", "assistant"): |
| 1087 | + role = "assistant" |
| 1088 | + else: |
| 1089 | + role = "user" |
| 1090 | + content = msg.get("content", "") |
| 1091 | + if content: |
| 1092 | + messages.append({"role": role, "content": content}) |
| 1093 | + |
| 1094 | + messages.append({"role": "user", "content": user_message}) |
| 1095 | + |
| 1096 | + yield from llm_service.generate_chat_stream( |
| 1097 | + system_prompt=_WISE_BOOKSELLER_SYSTEM_PROMPT, |
| 1098 | + messages=messages, |
| 1099 | + ) |
| 1100 | + |
| 1101 | + # SMART FALLBACK: A variety of poetic responses that adapt to keywords |
| 1102 | + msg_lower = user_message.lower() |
| 1103 | + |
| 1104 | + if any(k in msg_lower for k in ['rain', 'melancholy', 'sad', 'quiet']): |
| 1105 | + yield "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." |
| 1106 | + elif any(k in msg_lower for k in ['adventure', 'journey', 'travel', 'exciting']): |
| 1107 | + yield "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." |
| 1108 | + elif any(k in msg_lower for k in ['cozy', 'warm', 'happy', 'gentle']): |
| 1109 | + yield "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." |
| 1110 | + elif any(k in msg_lower for k in ['dark', 'mystery', 'thriller', 'shadow']): |
| 1111 | + yield "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." |
| 1112 | + else: |
| 1113 | + # Generic but varied fallbacks |
| 1114 | + variations = [ |
| 1115 | + "The books have been whispering your name today. I've pulled a few that seem particularly eager to meet you.", |
| 1116 | + "Every reader is a traveler, and every book a destination. Which of these paths shall we walk today?", |
| 1117 | + "I've spent a lifetime listening to the scent of old paper... and it tells me these stories belong in your hands.", |
| 1118 | + "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." |
| 1119 | + ] |
| 1120 | + import random |
| 1121 | + yield random.choice(variations) |
| 1122 | + |
966 | 1123 | # ========================================================================= |
967 | 1124 | # VIBE-CHECK ENDPOINT LOGIC |
968 | 1125 | # Implements strict validation and caching per PR #903 feedback. |
|
0 commit comments