Skip to content

Commit 41aaebf

Browse files
Merge pull request #371 from Tushar15769/feature/Ambient-Sound
[NSoC'26] Added ambient sound feature
2 parents 1a6f63a + 2422b95 commit 41aaebf

2,058 files changed

Lines changed: 1373 additions & 516603 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ mood_analysis/mood_cache.json
99
.env
1010
.env.local
1111
.env.production
12+
.env.development
1213

1314
# Bruno API Collection - Sensitive files
1415
bruno/environments/local.bru

‎API_EXAMPLES.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@ This document provides sample request and response payloads for BiblioDrift APIs
1212

1313
```json
1414
{
15-
"message": "I want something cozy for a rainy evening"
15+
"message": "I want something cozy for a rainy evening",
16+
"history": [
17+
{"role": "user", "content": "Hello!"},
18+
{"role": "assistant", "content": "Welcome to my shop. How can I help you find a story today?"}
19+
]
1620
}
1721
```
1822

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ BiblioDrift is a cozy, visual-first book discovery platform designed to make fin
2222
No manual curation, static lists, or hardcoded recommendations are permitted.
2323
- **Dynamic Popups**: Click a book to see an expanded view with AI-generated blurbs.
2424
- **Curated Tables**: Horizontal scrolling lists based on moods like "Monsoon Reads".
25+
- **Elara, the Wise Bookseller**: An interactive AI persona who chats with you, remembers your mood, and hand-picks books like a real librarian.
26+
- **Ambient Sanctuary**: Immersive background sounds (e.g., Rainy Evening, Cozy Fireplace) with volume control to enhance your reading atmosphere.
2527

2628
## 🛠️ Tech Stack
2729

‎backend/ai_service.py‎

Lines changed: 197 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,6 @@ def get_book_note_prompt(title: str, author: str, description: str, mood_context
111111
Style: 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\nCustomer: {user_message}\nElara:"
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)

‎backend/app.py‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,13 @@
1717
from datetime import datetime, timedelta, timezone
1818
from sanitizer import sanitize_payload
1919

20-
# Load environment variables from .env file BEFORE importing config
21-
load_dotenv()
20+
# Load environment variables from config directory based on APP_ENV
21+
env = os.getenv('APP_ENV', 'development')
22+
env_path = os.path.join(os.path.dirname(__file__), '..', 'config', f'.env.{env}')
23+
if os.path.exists(env_path):
24+
load_dotenv(env_path)
25+
else:
26+
load_dotenv()
2227

2328
from config import app_config, setup_logging
2429
from ai_service import generate_book_note, get_ai_recommendations, get_category_books, get_book_mood_tags_safe, generate_chat_response, llm_service
@@ -111,8 +116,9 @@
111116
# The fix below restricts CORS to specific trusted origins. We can
112117
# optionally load allowed origins from environment variables.
113118
# =====================================================================
114-
allowed_origins = os.environ.get("ALLOWED_ORIGINS", "https://bibliodrift.com").split(",")
115-
CORS(app, supports_credentials=True, origins=allowed_origins)
119+
# ALLOWED_ORIGINS=http://127.0.0.1:5500,http://localhost:5500,http://127.0.0.1:5000,http://localhost:5000
120+
# For development, we'll allow all to be safe, then restrict in prod
121+
CORS(app, supports_credentials=True, origins=["http://127.0.0.1:5500", "http://localhost:5500", "http://127.0.0.1:5000", "http://localhost:5000"])
116122

117123
# Initialize cache service
118124
cache_service.init_app(app)

0 commit comments

Comments
 (0)