Ali Hashhash commited on
Commit
f1332f7
·
1 Parent(s): eaf5c68

feat: implement chat with note backend endpoint and register chat router

Browse files
src/api/chat_routes.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Chat routes — Document-specific Q&A powered by Groq.
3
+ """
4
+
5
+ from typing import List, Optional
6
+
7
+ from fastapi import APIRouter, Depends, HTTPException
8
+ from pydantic import BaseModel, Field
9
+
10
+ from src.auth.dependencies import get_current_user
11
+ from src.db.models import User
12
+ from src.summarization.note_generator import NoteGenerator
13
+ from src.utils.logger import setup_logger
14
+
15
+ logger = setup_logger(__name__)
16
+ router = APIRouter(tags=["Chat"])
17
+
18
+
19
+ # ─── Schemas ──────────────────────────────────────────────────────────
20
+
21
+ class ChatMessage(BaseModel):
22
+ role: str = Field(..., description="Either 'user' or 'assistant'")
23
+ content: str = Field(..., description="Message content")
24
+
25
+
26
+ class ChatRequest(BaseModel):
27
+ note_content: str = Field(
28
+ ...,
29
+ description="The full text of the note to use as context",
30
+ )
31
+ question: str = Field(
32
+ ...,
33
+ min_length=1,
34
+ description="The user's question about the note",
35
+ )
36
+ history: Optional[List[ChatMessage]] = Field(
37
+ default=None,
38
+ description="Previous conversation turns for multi-turn context",
39
+ )
40
+
41
+
42
+ class ChatResponse(BaseModel):
43
+ answer: str = Field(..., description="AI-generated answer")
44
+
45
+
46
+ # ─── Endpoint ─────────────────────────────────────────────────────────
47
+
48
+ @router.post("/chat/note", response_model=ChatResponse)
49
+ async def chat_with_note(
50
+ request: ChatRequest,
51
+ current_user: User = Depends(get_current_user),
52
+ ):
53
+ """Ask a question about a specific note. Answers are grounded in the note content."""
54
+ logger.info(
55
+ "Chat request from user %s — question length: %d, context length: %d",
56
+ current_user.id,
57
+ len(request.question),
58
+ len(request.note_content),
59
+ )
60
+
61
+ if not request.note_content.strip():
62
+ raise HTTPException(status_code=400, detail="Note content cannot be empty.")
63
+
64
+ note_gen = NoteGenerator()
65
+
66
+ history_dicts = None
67
+ if request.history:
68
+ history_dicts = [msg.model_dump() for msg in request.history]
69
+
70
+ answer = note_gen.chat_with_note(
71
+ note_content=request.note_content,
72
+ question=request.question,
73
+ history=history_dicts,
74
+ )
75
+
76
+ return ChatResponse(answer=answer)
src/api/main.py CHANGED
@@ -8,6 +8,7 @@ from fastapi.middleware.cors import CORSMiddleware
8
  from src.api.auth_routes import router as auth_router
9
  from src.api.notes_routes import router as notes_router
10
  from src.api.recommendation_routes import router as recommendation_router
 
11
  from src.utils.logger import setup_logger
12
 
13
  logger = setup_logger(__name__)
@@ -36,6 +37,7 @@ app.add_middleware(
36
  app.include_router(notes_router)
37
  app.include_router(recommendation_router)
38
  app.include_router(auth_router)
 
39
 
40
  @app.get("/")
41
  def read_root():
 
8
  from src.api.auth_routes import router as auth_router
9
  from src.api.notes_routes import router as notes_router
10
  from src.api.recommendation_routes import router as recommendation_router
11
+ from src.api.chat_routes import router as chat_router
12
  from src.utils.logger import setup_logger
13
 
14
  logger = setup_logger(__name__)
 
37
  app.include_router(notes_router)
38
  app.include_router(recommendation_router)
39
  app.include_router(auth_router)
40
+ app.include_router(chat_router)
41
 
42
  @app.get("/")
43
  def read_root():
src/summarization/note_generator.py CHANGED
@@ -1,340 +1,403 @@
1
- import json
2
- import os
3
- import re
4
- from typing import Dict, List, Optional
5
-
6
- from pydantic import ValidationError
7
- from groq import Groq
8
-
9
- from ..utils.logger import setup_logger
10
- from .schemas import SummarySchema
11
- from .segmenter import TranscriptSegmenter
12
-
13
-
14
- logger = setup_logger(__name__)
15
-
16
-
17
- # ─────────────────────────────────────────────────────────────────────────────
18
- # PROMPT TEMPLATES
19
- # ─────────────────────────────────────────────────────────────────────────────
20
-
21
- _SUMMARY_SYSTEM = """
22
- You are an expert educational content analyst and structured note-taking specialist.
23
- Transform raw video transcripts into clean, structured chronological JSON summaries.
24
-
25
- LANGUAGE RULE — CRITICAL, NEVER VIOLATE:
26
- - Detect the primary language of the transcript.
27
- - Every content field (title, summary, segments, conclusion) MUST be written entirely in that SAME detected language.
28
- - Do NOT mix languages. Arabic transcript -> everything in Arabic.
29
- - Only the "detected_language" field itself is stated in English (e.g. "Arabic").
30
-
31
- TIMELINE RULES — STRICTLY ENFORCED:
32
- - Divide the transcript into chronological segments that follow its natural progression.
33
- - Produce a MINIMUM of 3 and a MAXIMUM of 7 segments.
34
- - Each segment MUST cover a distinct phase or theme; do NOT repeat the same topic.
35
- - Segments must be ordered chronologically as they appear in the transcript.
36
- - Each segment must include:
37
- * title: a short descriptive title
38
- * summary: concise summary of that section (2-3 sentences)
39
- * key_insight: the single most important takeaway from that section
40
- * why_it_matters: brief explanation of value/importance (1-2 sentences)
41
-
42
- TOPICS RULE:
43
- - Extract the actual topics discussed in the video dynamically.
44
- - Topics should be specific and descriptive (e.g. "Python", "Machine Learning", "Neural Networks").
45
- - Do NOT use generic fixed categories.
46
-
47
- CRITICAL: RETURN A JSON OBJECT EXACTLY MATCHING THIS STRUCTURE.
48
- DO NOT CHANGE, OMIT, OR RENAME ANY KEYS.
49
- {
50
- "title": "Inferred video title in transcript language",
51
- "detected_language": "English (or Arabic, etc.)",
52
- "summary": "Concise overall summary (3-5 sentences)",
53
- "segments": [
54
- {
55
- "title": "Segment title",
56
- "summary": "What this section covers (2-3 sentences)",
57
- "key_insight": "Most important point from this section",
58
- "why_it_matters": "Why this is valuable (1-2 sentences)"
59
- }
60
- ],
61
- "conclusion": "Final overall takeaway / closing conclusion",
62
- "topics": ["Topic1", "Topic2", "Topic3"]
63
- }
64
-
65
- OUTPUT: Return ONLY a valid JSON object. No markdown fences, no extra text.
66
- """.strip()
67
-
68
- _SUMMARY_USER = """
69
- Video Title: {video_title}
70
-
71
- TRANSCRIPT:
72
- {transcript}
73
-
74
- Analyze thoroughly. Detect the language.
75
- Divide the content into 3-7 chronological segments.
76
- For each segment provide: title, summary, key_insight, why_it_matters.
77
- Return ONLY the exact JSON structure requested.
78
- """.strip()
79
-
80
-
81
- # ─────────────────────────────────────────────────────────────────────────────
82
- # LANGUAGE LABELS (simplified)
83
- # ─────────────────────────────────────────────────────────────────────────────
84
-
85
- _LABELS = {
86
- "Arabic": {
87
- "source": "المصدر",
88
- "duration": "المدة",
89
- "summary": "الملخص العام",
90
- "timeline": "التسلسل الزمني",
91
- "insight": "أهم نقطة",
92
- "why": "لماذا يهم؟",
93
- "conclusion": "الخلاصة",
94
- },
95
- "English": {
96
- "source": "Source",
97
- "duration": "Duration",
98
- "summary": "Overall Summary",
99
- "timeline": "Timeline",
100
- "insight": "Key Insight",
101
- "why": "Why It Matters",
102
- "conclusion": "Conclusion",
103
- },
104
- }
105
-
106
- def _labels(language: str) -> dict:
107
- return _LABELS.get(language, _LABELS["English"])
108
-
109
-
110
- # ─────────────────────────────────────────────────────────────────────────────
111
- # TOKEN UTILITIES
112
- # ─────────────────────────────────────────────────────────────────────────────
113
-
114
- _CHUNK_TARGET_TOKENS = 2500
115
-
116
- def _estimate_tokens(text: str) -> int:
117
- """
118
- Lightweight token estimation using a word-count heuristic.
119
-
120
- Production logs show that Groq's tokenizer produces ~2.5 tokens per
121
- whitespace-delimited word for Arabic / mixed-script transcripts.
122
- Using 2.5× as a conservative multiplier to avoid underestimation.
123
- """
124
- word_count = len(text.split())
125
- return int(word_count * 2.5)
126
-
127
-
128
- def _split_into_chunks(text: str, target_tokens: int = _CHUNK_TARGET_TOKENS) -> List[str]:
129
- """
130
- Split text into chunks of approximately `target_tokens` tokens each.
131
-
132
- Splits on sentence boundaries (period + space, newline) to avoid
133
- cutting mid-sentence. Falls back to word-level splitting if no
134
- sentence boundaries are found within a chunk.
135
- """
136
- # Split into sentences (on ". " or newline)
137
- sentences = re.split(r'(?<=[.!?])\s+|\n+', text)
138
- sentences = [s.strip() for s in sentences if s.strip()]
139
-
140
- chunks: List[str] = []
141
- current_chunk: List[str] = []
142
- current_tokens = 0
143
-
144
- for sentence in sentences:
145
- sentence_tokens = _estimate_tokens(sentence)
146
-
147
- # If a single sentence exceeds the target, split by words
148
- if sentence_tokens > target_tokens:
149
- # Flush current chunk first
150
- if current_chunk:
151
- chunks.append(" ".join(current_chunk))
152
- current_chunk = []
153
- current_tokens = 0
154
-
155
- words = sentence.split()
156
- word_buffer: List[str] = []
157
- buffer_tokens = 0
158
- for word in words:
159
- wt = _estimate_tokens(word)
160
- if buffer_tokens + wt > target_tokens and word_buffer:
161
- chunks.append(" ".join(word_buffer))
162
- word_buffer = [word]
163
- buffer_tokens = wt
164
- else:
165
- word_buffer.append(word)
166
- buffer_tokens += wt
167
- if word_buffer:
168
- chunks.append(" ".join(word_buffer))
169
- continue
170
-
171
- if current_tokens + sentence_tokens > target_tokens and current_chunk:
172
- chunks.append(" ".join(current_chunk))
173
- current_chunk = [sentence]
174
- current_tokens = sentence_tokens
175
- else:
176
- current_chunk.append(sentence)
177
- current_tokens += sentence_tokens
178
-
179
- # Don't forget the last chunk
180
- if current_chunk:
181
- chunks.append(" ".join(current_chunk))
182
-
183
- return chunks
184
-
185
-
186
- # ─────────────────────────────────────────────────────────────────────────────
187
- # NOTE GENERATOR
188
- # ─────────────────────────────────────────────────────────────────────────────
189
-
190
- class NoteGenerator:
191
- """Generates structured study notes using Groq (Llama-3.3-70b-versatile)."""
192
-
193
- def __init__(self):
194
- self.api_key = os.environ.get("GROQ_API_KEY", "").strip()
195
- self.client = Groq(api_key=self.api_key) if self.api_key else None
196
- self.model_id = "llama-3.3-70b-versatile"
197
- logger.info(f"🚀 NoteGenerator v4.0 initialized — model: {self.model_id}")
198
-
199
- def _chat(self, system: str, user: str, max_tokens: int = 4096) -> Optional[str]:
200
- if not self.client:
201
- return None
202
- try:
203
- response = self.client.chat.completions.create(
204
- model=self.model_id,
205
- max_tokens=max_tokens,
206
- temperature=0.3,
207
- response_format={"type": "json_object"},
208
- messages=[
209
- {"role": "system", "content": system},
210
- {"role": "user", "content": user},
211
- ],
212
- )
213
- return response.choices[0].message.content
214
- except Exception as e:
215
- logger.error(f"❌ Groq API call failed: {e}")
216
- return None
217
-
218
- def _get_error_json(self, error_msg: str) -> Dict:
219
- return {
220
- "title": "Error in Generation",
221
- "detected_language": "English",
222
- "summary": f"Could not generate notes: {error_msg}",
223
- "segments": [],
224
- "conclusion": "",
225
- "topics": [],
226
- }
227
-
228
- def generateSummary(self, transcript_text: str, video_title: str) -> Dict:
229
- """Generate structured JSON summary from transcript."""
230
- if not self.client:
231
- return self._get_error_json("Groq API Key missing.")
232
-
233
- logger.info(f"📝 Summary generation started via {self.model_id}")
234
- user_prompt = _SUMMARY_USER.format(
235
- video_title=video_title,
236
- transcript=transcript_text[:30000],
237
- )
238
-
239
- raw = self._chat(_SUMMARY_SYSTEM, user_prompt, max_tokens=4096)
240
- if raw is None:
241
- return self._get_error_json("Groq API call failed.")
242
-
243
- try:
244
- data = json.loads(raw)
245
- validated = SummarySchema(**data)
246
- return validated.model_dump()
247
- except (json.JSONDecodeError, ValidationError) as e:
248
- logger.error(f"❌ Schema validation failed: {e}")
249
- return self._get_error_json(f"Validation Error: {str(e)}")
250
-
251
- def format_notes_to_markdown(self, json_notes: Dict) -> str:
252
- """Convert JSON notes to clean Markdown — Summary → Timeline → Conclusion."""
253
- lang = json_notes.get("detected_language", "English")
254
- L = _labels(lang)
255
- lines: list[str] = []
256
-
257
- def add(text: str = ""):
258
- lines.append(text)
259
-
260
- def blank():
261
- lines.append("")
262
-
263
- def divider():
264
- lines.append("")
265
- lines.append("---")
266
- lines.append("")
267
-
268
- # ── OVERALL SUMMARY ──
269
- summary = json_notes.get("summary", "")
270
- if summary:
271
- add(f"## 📋 {L['summary']}")
272
- blank()
273
- add(summary)
274
- divider()
275
-
276
- # ── TIMELINE ──
277
- segments = json_notes.get("segments", [])
278
- if segments:
279
- add(f"## 🕐 {L['timeline']}")
280
- blank()
281
- for i, seg in enumerate(segments, start=1):
282
- s_title = seg.get("title", "") if isinstance(seg, dict) else seg.title
283
- s_summary = seg.get("summary", "") if isinstance(seg, dict) else seg.summary
284
- s_insight = seg.get("key_insight", "") if isinstance(seg, dict) else seg.key_insight
285
- s_why = seg.get("why_it_matters", "") if isinstance(seg, dict) else seg.why_it_matters
286
-
287
- add(f"### {i}. {s_title}")
288
- blank()
289
- add(s_summary)
290
- blank()
291
- if s_insight:
292
- add(f"> **💎 {L['insight']}:** {s_insight}")
293
- blank()
294
- if s_why:
295
- add(f"> **{L['why']}** {s_why}")
296
- blank()
297
- divider()
298
-
299
- # ── CONCLUSION ──
300
- conclusion = json_notes.get("conclusion", "")
301
- if conclusion:
302
- add(f"## 🔖 {L['conclusion']}")
303
- blank()
304
- add(f"> {conclusion}")
305
- blank()
306
-
307
- return "\n".join(lines)
308
-
309
- def format_final_notes(
310
- self,
311
- notes: str,
312
- video_title: str,
313
- video_url: str,
314
- duration: int,
315
- detected_language: str = "English",
316
- ) -> str:
317
- """
318
- Wrap the formatted Markdown body with Source + Duration header.
319
- """
320
- L = _labels(detected_language)
321
-
322
- if duration and duration > 0:
323
- hours = duration // 3600
324
- minutes = (duration % 3600) // 60
325
- secs = duration % 60
326
- if hours > 0:
327
- duration_str = f"{hours}:{minutes:02d}:{secs:02d}"
328
- else:
329
- duration_str = f"{minutes:02d}:{secs:02d}"
330
- else:
331
- duration_str = "N/A (Auto-generated)"
332
-
333
- header = (
334
- f"# {video_title}\n\n"
335
- f"---\n\n"
336
- f"> **{L['source']}:** {video_url} \n"
337
- f"> **{L['duration']}:** {duration_str}\n\n"
338
- f"---\n\n"
339
- )
340
- return header + notes
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import re
4
+ from typing import Dict, List, Optional
5
+
6
+ from pydantic import ValidationError
7
+ from groq import Groq
8
+
9
+ from ..utils.logger import setup_logger
10
+ from .schemas import SummarySchema
11
+ from .segmenter import TranscriptSegmenter
12
+
13
+
14
+ logger = setup_logger(__name__)
15
+
16
+
17
+ # ─────────────────────────────────────────────────────────────────────────────
18
+ # PROMPT TEMPLATES
19
+ # ─────────────────────────────────────────────────────────────────────────────
20
+
21
+ _SUMMARY_SYSTEM = """
22
+ You are an expert educational content analyst and structured note-taking specialist.
23
+ Transform raw video transcripts into clean, structured chronological JSON summaries.
24
+
25
+ LANGUAGE RULE — CRITICAL, NEVER VIOLATE:
26
+ - Detect the primary language of the transcript.
27
+ - Every content field (title, summary, segments, conclusion) MUST be written entirely in that SAME detected language.
28
+ - Do NOT mix languages. Arabic transcript -> everything in Arabic.
29
+ - Only the "detected_language" field itself is stated in English (e.g. "Arabic").
30
+
31
+ TIMELINE RULES — STRICTLY ENFORCED:
32
+ - Divide the transcript into chronological segments that follow its natural progression.
33
+ - Produce a MINIMUM of 3 and a MAXIMUM of 7 segments.
34
+ - Each segment MUST cover a distinct phase or theme; do NOT repeat the same topic.
35
+ - Segments must be ordered chronologically as they appear in the transcript.
36
+ - Each segment must include:
37
+ * title: a short descriptive title
38
+ * summary: concise summary of that section (2-3 sentences)
39
+ * key_insight: the single most important takeaway from that section
40
+ * why_it_matters: brief explanation of value/importance (1-2 sentences)
41
+
42
+ TOPICS RULE:
43
+ - Extract the actual topics discussed in the video dynamically.
44
+ - Topics should be specific and descriptive (e.g. "Python", "Machine Learning", "Neural Networks").
45
+ - Do NOT use generic fixed categories.
46
+
47
+ CRITICAL: RETURN A JSON OBJECT EXACTLY MATCHING THIS STRUCTURE.
48
+ DO NOT CHANGE, OMIT, OR RENAME ANY KEYS.
49
+ {
50
+ "title": "Inferred video title in transcript language",
51
+ "detected_language": "English (or Arabic, etc.)",
52
+ "summary": "Concise overall summary (3-5 sentences)",
53
+ "segments": [
54
+ {
55
+ "title": "Segment title",
56
+ "summary": "What this section covers (2-3 sentences)",
57
+ "key_insight": "Most important point from this section",
58
+ "why_it_matters": "Why this is valuable (1-2 sentences)"
59
+ }
60
+ ],
61
+ "conclusion": "Final overall takeaway / closing conclusion",
62
+ "topics": ["Topic1", "Topic2", "Topic3"]
63
+ }
64
+
65
+ OUTPUT: Return ONLY a valid JSON object. No markdown fences, no extra text.
66
+ """.strip()
67
+
68
+ _SUMMARY_USER = """
69
+ Video Title: {video_title}
70
+
71
+ TRANSCRIPT:
72
+ {transcript}
73
+
74
+ Analyze thoroughly. Detect the language.
75
+ Divide the content into 3-7 chronological segments.
76
+ For each segment provide: title, summary, key_insight, why_it_matters.
77
+ Return ONLY the exact JSON structure requested.
78
+ """.strip()
79
+
80
+
81
+ # ─────────────────────────────────────────────────────────────────────────────
82
+ # LANGUAGE LABELS (simplified)
83
+ # ─────────────────────────────────────────────────────────────────────────────
84
+
85
+ _LABELS = {
86
+ "Arabic": {
87
+ "source": "المصدر",
88
+ "duration": "المدة",
89
+ "summary": "الملخص العام",
90
+ "timeline": "التسلسل الزمني",
91
+ "insight": "أهم نقطة",
92
+ "why": "لماذا يهم؟",
93
+ "conclusion": "الخلاصة",
94
+ },
95
+ "English": {
96
+ "source": "Source",
97
+ "duration": "Duration",
98
+ "summary": "Overall Summary",
99
+ "timeline": "Timeline",
100
+ "insight": "Key Insight",
101
+ "why": "Why It Matters",
102
+ "conclusion": "Conclusion",
103
+ },
104
+ }
105
+
106
+ def _labels(language: str) -> dict:
107
+ return _LABELS.get(language, _LABELS["English"])
108
+
109
+
110
+ # ─────────────────────────────────────────────────────────────────────────────
111
+ # TOKEN UTILITIES
112
+ # ─────────────────────────────────────────────────────────────────────────────
113
+
114
+ _CHUNK_TARGET_TOKENS = 2500
115
+
116
+ def _estimate_tokens(text: str) -> int:
117
+ """
118
+ Lightweight token estimation using a word-count heuristic.
119
+
120
+ Production logs show that Groq's tokenizer produces ~2.5 tokens per
121
+ whitespace-delimited word for Arabic / mixed-script transcripts.
122
+ Using 2.5× as a conservative multiplier to avoid underestimation.
123
+ """
124
+ word_count = len(text.split())
125
+ return int(word_count * 2.5)
126
+
127
+
128
+ def _split_into_chunks(text: str, target_tokens: int = _CHUNK_TARGET_TOKENS) -> List[str]:
129
+ """
130
+ Split text into chunks of approximately `target_tokens` tokens each.
131
+
132
+ Splits on sentence boundaries (period + space, newline) to avoid
133
+ cutting mid-sentence. Falls back to word-level splitting if no
134
+ sentence boundaries are found within a chunk.
135
+ """
136
+ # Split into sentences (on ". " or newline)
137
+ sentences = re.split(r'(?<=[.!?])\s+|\n+', text)
138
+ sentences = [s.strip() for s in sentences if s.strip()]
139
+
140
+ chunks: List[str] = []
141
+ current_chunk: List[str] = []
142
+ current_tokens = 0
143
+
144
+ for sentence in sentences:
145
+ sentence_tokens = _estimate_tokens(sentence)
146
+
147
+ # If a single sentence exceeds the target, split by words
148
+ if sentence_tokens > target_tokens:
149
+ # Flush current chunk first
150
+ if current_chunk:
151
+ chunks.append(" ".join(current_chunk))
152
+ current_chunk = []
153
+ current_tokens = 0
154
+
155
+ words = sentence.split()
156
+ word_buffer: List[str] = []
157
+ buffer_tokens = 0
158
+ for word in words:
159
+ wt = _estimate_tokens(word)
160
+ if buffer_tokens + wt > target_tokens and word_buffer:
161
+ chunks.append(" ".join(word_buffer))
162
+ word_buffer = [word]
163
+ buffer_tokens = wt
164
+ else:
165
+ word_buffer.append(word)
166
+ buffer_tokens += wt
167
+ if word_buffer:
168
+ chunks.append(" ".join(word_buffer))
169
+ continue
170
+
171
+ if current_tokens + sentence_tokens > target_tokens and current_chunk:
172
+ chunks.append(" ".join(current_chunk))
173
+ current_chunk = [sentence]
174
+ current_tokens = sentence_tokens
175
+ else:
176
+ current_chunk.append(sentence)
177
+ current_tokens += sentence_tokens
178
+
179
+ # Don't forget the last chunk
180
+ if current_chunk:
181
+ chunks.append(" ".join(current_chunk))
182
+
183
+ return chunks
184
+
185
+
186
+ # ─────────────────────────────────────────────────────────────────────────────
187
+ # NOTE GENERATOR
188
+ # ─────────────────────────────────────────────────────────────────────────────
189
+
190
+ class NoteGenerator:
191
+ """Generates structured study notes using Groq (Llama-3.3-70b-versatile)."""
192
+
193
+ def __init__(self):
194
+ self.api_key = os.environ.get("GROQ_API_KEY", "").strip()
195
+ self.client = Groq(api_key=self.api_key) if self.api_key else None
196
+ self.model_id = "llama-3.3-70b-versatile"
197
+ logger.info(f"🚀 NoteGenerator v4.0 initialized — model: {self.model_id}")
198
+
199
+ def _chat(self, system: str, user: str, max_tokens: int = 4096) -> Optional[str]:
200
+ if not self.client:
201
+ return None
202
+ try:
203
+ response = self.client.chat.completions.create(
204
+ model=self.model_id,
205
+ max_tokens=max_tokens,
206
+ temperature=0.3,
207
+ response_format={"type": "json_object"},
208
+ messages=[
209
+ {"role": "system", "content": system},
210
+ {"role": "user", "content": user},
211
+ ],
212
+ )
213
+ return response.choices[0].message.content
214
+ except Exception as e:
215
+ logger.error(f"❌ Groq API call failed: {e}")
216
+ return None
217
+
218
+ def _get_error_json(self, error_msg: str) -> Dict:
219
+ return {
220
+ "title": "Error in Generation",
221
+ "detected_language": "English",
222
+ "summary": f"Could not generate notes: {error_msg}",
223
+ "segments": [],
224
+ "conclusion": "",
225
+ "topics": [],
226
+ }
227
+
228
+ def generateSummary(self, transcript_text: str, video_title: str) -> Dict:
229
+ """Generate structured JSON summary from transcript."""
230
+ if not self.client:
231
+ return self._get_error_json("Groq API Key missing.")
232
+
233
+ logger.info(f"📝 Summary generation started via {self.model_id}")
234
+ user_prompt = _SUMMARY_USER.format(
235
+ video_title=video_title,
236
+ transcript=transcript_text[:30000],
237
+ )
238
+
239
+ raw = self._chat(_SUMMARY_SYSTEM, user_prompt, max_tokens=4096)
240
+ if raw is None:
241
+ return self._get_error_json("Groq API call failed.")
242
+
243
+ try:
244
+ data = json.loads(raw)
245
+ validated = SummarySchema(**data)
246
+ return validated.model_dump()
247
+ except (json.JSONDecodeError, ValidationError) as e:
248
+ logger.error(f"❌ Schema validation failed: {e}")
249
+ return self._get_error_json(f"Validation Error: {str(e)}")
250
+
251
+ def format_notes_to_markdown(self, json_notes: Dict) -> str:
252
+ """Convert JSON notes to clean Markdown — Summary → Timeline → Conclusion."""
253
+ lang = json_notes.get("detected_language", "English")
254
+ L = _labels(lang)
255
+ lines: list[str] = []
256
+
257
+ def add(text: str = ""):
258
+ lines.append(text)
259
+
260
+ def blank():
261
+ lines.append("")
262
+
263
+ def divider():
264
+ lines.append("")
265
+ lines.append("---")
266
+ lines.append("")
267
+
268
+ # ── OVERALL SUMMARY ──
269
+ summary = json_notes.get("summary", "")
270
+ if summary:
271
+ add(f"## 📋 {L['summary']}")
272
+ blank()
273
+ add(summary)
274
+ divider()
275
+
276
+ # ── TIMELINE ──
277
+ segments = json_notes.get("segments", [])
278
+ if segments:
279
+ add(f"## 🕐 {L['timeline']}")
280
+ blank()
281
+ for i, seg in enumerate(segments, start=1):
282
+ s_title = seg.get("title", "") if isinstance(seg, dict) else seg.title
283
+ s_summary = seg.get("summary", "") if isinstance(seg, dict) else seg.summary
284
+ s_insight = seg.get("key_insight", "") if isinstance(seg, dict) else seg.key_insight
285
+ s_why = seg.get("why_it_matters", "") if isinstance(seg, dict) else seg.why_it_matters
286
+
287
+ add(f"### {i}. {s_title}")
288
+ blank()
289
+ add(s_summary)
290
+ blank()
291
+ if s_insight:
292
+ add(f"> **💎 {L['insight']}:** {s_insight}")
293
+ blank()
294
+ if s_why:
295
+ add(f"> **{L['why']}** {s_why}")
296
+ blank()
297
+ divider()
298
+
299
+ # ── CONCLUSION ──
300
+ conclusion = json_notes.get("conclusion", "")
301
+ if conclusion:
302
+ add(f"## 🔖 {L['conclusion']}")
303
+ blank()
304
+ add(f"> {conclusion}")
305
+ blank()
306
+
307
+ return "\n".join(lines)
308
+
309
+ def format_final_notes(
310
+ self,
311
+ notes: str,
312
+ video_title: str,
313
+ video_url: str,
314
+ duration: int,
315
+ detected_language: str = "English",
316
+ ) -> str:
317
+ """
318
+ Wrap the formatted Markdown body with Source + Duration header.
319
+ """
320
+ L = _labels(detected_language)
321
+
322
+ if duration and duration > 0:
323
+ hours = duration // 3600
324
+ minutes = (duration % 3600) // 60
325
+ secs = duration % 60
326
+ if hours > 0:
327
+ duration_str = f"{hours}:{minutes:02d}:{secs:02d}"
328
+ else:
329
+ duration_str = f"{minutes:02d}:{secs:02d}"
330
+ else:
331
+ duration_str = "N/A (Auto-generated)"
332
+
333
+ header = (
334
+ f"# {video_title}\n\n"
335
+ f"---\n\n"
336
+ f"> **{L['source']}:** {video_url} \n"
337
+ f"> **{L['duration']}:** {duration_str}\n\n"
338
+ f"---\n\n"
339
+ )
340
+ return header + notes
341
+
342
+ # ─────────────────────────────────────────────────────────────────────
343
+ # CHAT WITH NOTE (Document-Specific Q&A)
344
+ # ─────────────────────────────────────────────────────────────────────
345
+
346
+ _CHAT_SYSTEM = (
347
+ "You are an AI assistant helping the user understand a video note. "
348
+ "Answer the user's question strictly using the provided context below. "
349
+ "If the answer is not in the context, politely inform the user that "
350
+ "the video does not mention it. Do not hallucinate or invent information. "
351
+ "Keep your answers clear, concise, and helpful. "
352
+ "If the context is in Arabic, reply in Arabic. Match the language of the context."
353
+ )
354
+
355
+ _CHAT_CONTEXT_LIMIT = 25_000 # characters — safe for Groq free-tier TPM
356
+
357
+ def chat_with_note(
358
+ self,
359
+ note_content: str,
360
+ question: str,
361
+ history: list[dict] | None = None,
362
+ ) -> str:
363
+ """Answer a user question grounded in the provided note content."""
364
+ if not self.client:
365
+ return "AI service is not configured. Please set the GROQ_API_KEY."
366
+
367
+ truncated_context = note_content[: self._CHAT_CONTEXT_LIMIT]
368
+
369
+ messages: list[dict] = [
370
+ {"role": "system", "content": self._CHAT_SYSTEM},
371
+ {
372
+ "role": "user",
373
+ "content": (
374
+ f"--- VIDEO NOTE CONTEXT ---\n"
375
+ f"{truncated_context}\n"
376
+ f"--- END OF CONTEXT ---"
377
+ ),
378
+ },
379
+ ]
380
+
381
+ # Append conversation history (if any)
382
+ if history:
383
+ for msg in history:
384
+ role = msg.get("role", "user")
385
+ content = msg.get("content", "")
386
+ if role in ("user", "assistant") and content:
387
+ messages.append({"role": role, "content": content})
388
+
389
+ # Append the current question
390
+ messages.append({"role": "user", "content": question})
391
+
392
+ try:
393
+ response = self.client.chat.completions.create(
394
+ model=self.model_id,
395
+ max_tokens=1024,
396
+ temperature=0.3,
397
+ messages=messages,
398
+ )
399
+ answer = response.choices[0].message.content
400
+ return answer.strip() if answer else "I couldn't generate a response."
401
+ except Exception as e:
402
+ logger.error(f"❌ Chat API call failed: {e}")
403
+ return f"Sorry, something went wrong: {str(e)}"