Skip to content

Commit def154c

Browse files
authored
Merge branch 'main' into fix/contributor-Json
2 parents 7054926 + f8a25d3 commit def154c

75 files changed

Lines changed: 9173 additions & 1677 deletions

Some content is hidden

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

‎.github/workflows/ci.yml‎

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
name: CI – Run Tests
1+
name: CI – Run Tests & Lint
22

33
on:
44
push:
@@ -7,6 +7,26 @@ on:
77
branches: ["main", "master"]
88

99
jobs:
10+
lint:
11+
name: Lint Python
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: actions/checkout@v4
15+
- uses: actions/setup-python@v5
16+
with:
17+
python-version: "3.11"
18+
cache: "pip"
19+
- name: Install linters
20+
run: pip install flake8 black
21+
- name: Check code formatting with Black
22+
run: black --check backend tests || true
23+
- name: Lint with flake8
24+
run: |
25+
# Stop the build if there are Python syntax errors or undefined names
26+
flake8 backend tests --count --select=E9,F63,F7,F82 --show-source --statistics
27+
# exit-zero treats all other errors as warnings
28+
flake8 backend tests --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
29+
1030
test:
1131
name: pytest
1232
runs-on: ubuntu-latest
@@ -22,9 +42,10 @@ jobs:
2242
- name: Install dependencies
2343
run: |
2444
pip install --upgrade pip
45+
pip install torch --index-url https://download.pytorch.org/whl/cpu
2546
pip install -r backend/requirements.txt
2647
pip install pytest pytest-cov
27-
pip install flask-wtf bleach Pillow beautifulsoup4 "pydantic[email]" pytest pytest-cov
48+
pip install flask-wtf bleach Pillow beautifulsoup4 "pydantic[email]"
2849
2950
- name: Run pytest
3051
env:
@@ -42,18 +63,4 @@ jobs:
4263
--ignore=tests/test_category_cache.py \
4364
--ignore=tests/test_env_validation.py \
4465
--ignore=tests/test_purchase_links.py \
45-
-v --tb=short --cov=backend
46-
47-
- name: Install dependencies
48-
run: |
49-
pip install --upgrade pip
50-
pip install torch --index-url https://download.pytorch.org/whl/cpu
51-
pip install -r backend/requirements.txt
52-
pip install flask-wtf bleach Pillow pytest pytest-cov
53-
54-
- name: Install dependencies
55-
run: |
56-
pip install --upgrade pip
57-
pip install torch --index-url https://download.pytorch.org/whl/cpu
58-
pip install -r backend/requirements.txt
59-
pip install flask-wtf bleach Pillow beautifulsoup4 pytest pytest-cov
66+
-v --tb=short --cov=backend

‎.gitignore‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@ mood_analysis/mood_cache.json
66
*.cache
77

88
# Environment variables
9-
/.env
10-
/.env.local
11-
/.env.production
12-
/.env.development
9+
.env
10+
*/.env
11+
*/.env.local
12+
*/.env.production
13+
*/.env.development
1314

1415
# Sensitive local files
1516
*.token
@@ -32,6 +33,8 @@ instance/
3233
*.db
3334
# Virtual Environment
3435
venv/
36+
37+
node_modules/
3538
.venv/
3639

3740
# Local untracked scripts folder

‎Dockerfile‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ FROM python:3.11-slim
44
# Set environment variables
55
ENV PYTHONDONTWRITEBYTECODE=1
66
ENV PYTHONUNBUFFERED=1
7-
ENV FLASK_APP=app.py
7+
ENV FLASK_APP=backend/app.py
88
ENV FLASK_HOST=0.0.0.0
99
ENV PORT=5000
1010

@@ -28,4 +28,4 @@ COPY . .
2828
EXPOSE 5000
2929

3030
# Run the application
31-
CMD ["python", "app.py"]
31+
CMD ["python", "backend/app.py"]

‎Dockerfile.frontend‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
FROM nginx:alpine
2+
COPY ./nginx.conf /etc/nginx/conf.d/default.conf
3+
COPY ./frontend /usr/share/nginx/html
4+
EXPOSE 80
5+
CMD ["nginx", "-g", "daemon off;"]

‎README.md‎

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,9 @@ where stories are explored through <b>mood, atmosphere, and feeling</b> instead
2020
🌧️ Mood-first discovery • 📚 Immersive reading • 🤖 AI-powered storytelling
2121
</p>
2222

23-
24-
[🚀 Live Demo](https://bibliodrift-dm.netlify.app/) (static frontend; AI/auth need a deployed backend — see [docs/contributing.md](docs/contributing.md)) •
25-
[📚 Documentation](docs/) •
26-
[🤝 Contributing](docs/contributing.md)
27-
23+
<a href="https://www.netlify.com">
24+
<img src="https://www.netlify.com/assets/badges/netlify-badge-dark.svg" alt="Deploys by Netlify" />
25+
</a>
2826
</div>
2927

3028
---
@@ -105,7 +103,48 @@ It feels like:
105103
| Storage | LocalStorage |
106104

107105
---
106+
## 🚀 Backend Deployment & BACKEND_URL Setup
107+
108+
> ⚠️ The frontend is deployed on Netlify, but the Flask backend is **not yet deployed**.
109+
> Google Sign-In and all AI features require the backend to be running.
110+
111+
### Why is this needed?
112+
When a user clicks "Sign in with Google", the browser sends a request to `/api/v1/auth/google`.
113+
Netlify needs to know where to forward that request, that's what `BACKEND_URL` is for.
114+
Without it, Netlify has no rule for `/api/v1/*` and shows a 404 error.
115+
116+
### Step 1 — Deploy the Flask backend
117+
118+
You can deploy the backend (located in the `/backend` folder) to either:
119+
120+
| Platform | Free Tier | Docs |
121+
|---|---|---|
122+
| [Render](https://render.com) | ✅ Yes | [Render Python Docs](https://render.com/docs/deploy-flask) |
123+
| [Railway](https://railway.app) | ✅ Yes | [Railway Docs](https://docs.railway.app) |
108124

125+
After deploying, you'll get a public URL like:
126+
https://your-app.onrender.com
127+
128+
### Step 2 — Set BACKEND_URL in Netlify
129+
130+
1. Go to [Netlify Dashboard](https://app.netlify.com)
131+
2. Open your site → **Site Configuration → Environment Variables**
132+
3. Click **Add a variable**
133+
4. Set:
134+
- **Key:** `BACKEND_URL`
135+
- **Value:** `https://your-app.onrender.com` *(no trailing slash)*
136+
5. Click **Save**
137+
138+
### Step 3 — Redeploy the Netlify site
139+
140+
After setting the variable:
141+
1. Go to **Deploys** tab in your Netlify dashboard
142+
2. Click **Trigger deploy → Deploy site**
143+
3. This reruns `build_netlify.py`, which picks up `BACKEND_URL` and adds the proxy rule to `_redirects`
144+
145+
After this, "Sign in with Google" will correctly reach the Flask backend. ✅
146+
147+
---
109148
## 🧠 System Architecture
110149

111150
```mermaid

‎backend/ai_service.py‎

Lines changed: 158 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,113 @@ def _build_flat_prompt(system: str, msgs: list) -> str:
508508
logger.error(f"generate_chat failed: {type(e).__name__}: {e}", exc_info=True)
509509

510510
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."
511618

512619
def generate_text(self, prompt: str, max_tokens: Optional[int] = None, retry_count: int = 0) -> Optional[str]:
513620
"""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]:
653760
llm_service = LLMService()
654761

655762
__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',
657764
'LLMService', 'PromptTemplates']
658765

659766

@@ -963,6 +1070,56 @@ def generate_chat_response(user_message: str, conversation_history: list = []) -
9631070
import random
9641071
return random.choice(variations)
9651072

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+
9661123
# =========================================================================
9671124
# VIBE-CHECK ENDPOINT LOGIC
9681125
# Implements strict validation and caching per PR #903 feedback.

0 commit comments

Comments
 (0)