Spaces:
Sleeping
Sleeping
Ali Hashhash commited on
Commit ·
4db2bb6
1
Parent(s): c29c4d4
feat: implement summarization engine with Pydantic schemas and map-reduce processing pipeline
Browse files
src/categorization/topic_classifier.py
CHANGED
|
@@ -9,9 +9,9 @@ Usage:
|
|
| 9 |
# => "Technology & AI"
|
| 10 |
|
| 11 |
Categories:
|
| 12 |
-
Technology & AI | Business & Finance | Education
|
| 13 |
-
Productivity & Self-Growth |
|
| 14 |
-
Entertainment
|
| 15 |
"""
|
| 16 |
|
| 17 |
from typing import List, Set
|
|
@@ -28,11 +28,15 @@ logger = setup_logger(__name__)
|
|
| 28 |
CATEGORIES = [
|
| 29 |
"Technology & AI",
|
| 30 |
"Business & Finance",
|
| 31 |
-
"Education
|
|
|
|
| 32 |
"Productivity & Self-Growth",
|
| 33 |
-
"
|
| 34 |
-
"
|
| 35 |
-
"
|
|
|
|
|
|
|
|
|
|
| 36 |
]
|
| 37 |
|
| 38 |
|
|
@@ -98,12 +102,22 @@ _register("Business & Finance", [
|
|
| 98 |
"ربح", "دخل", "ميزانية",
|
| 99 |
])
|
| 100 |
|
| 101 |
-
# ── Education
|
| 102 |
-
_register("Education
|
| 103 |
# English
|
| 104 |
"education", "learning", "teaching", "school", "university", "college",
|
| 105 |
-
"academic", "
|
| 106 |
"tutorial", "lecture", "scholarship", "degree", "phd", "thesis",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
"science", "physics", "chemistry", "biology", "math", "mathematics",
|
| 108 |
"statistics", "calculus", "algebra", "geometry", "astronomy", "space",
|
| 109 |
"nasa", "quantum", "quantum physics", "quantum computing",
|
|
@@ -111,12 +125,10 @@ _register("Education & Science", [
|
|
| 111 |
"climate", "climate change", "environment", "engineering",
|
| 112 |
"mechanical engineering", "electrical engineering", "civil engineering",
|
| 113 |
"experiment", "laboratory", "lab", "hypothesis", "theory",
|
| 114 |
-
"
|
| 115 |
-
"anthropology", "archaeology", "literature", "language", "grammar",
|
| 116 |
# Arabic
|
| 117 |
-
"
|
| 118 |
-
"
|
| 119 |
-
"هندسة", "تاريخ", "فلسفة", "علم نفس", "فلك", "بيئة",
|
| 120 |
])
|
| 121 |
|
| 122 |
# ── Productivity & Self-Growth ──
|
|
@@ -128,74 +140,92 @@ _register("Productivity & Self-Growth", [
|
|
| 128 |
"mindset", "focus", "concentration", "efficiency", "organization",
|
| 129 |
"planning", "journaling", "morning routine", "routine", "success",
|
| 130 |
"self help", "self-help", "life coaching", "coaching", "mentoring",
|
| 131 |
-
"mentor", "
|
| 132 |
"emotional intelligence", "communication skills", "public speaking",
|
| 133 |
"negotiation", "critical thinking", "problem solving", "creativity",
|
| 134 |
"decision making", "confidence", "resilience", "work-life balance",
|
| 135 |
"burnout", "career", "career development", "skill building",
|
| 136 |
# Arabic
|
| 137 |
"إنتاجية", "تطوير ذات", "تحفيز", "عادات", "إدارة الوقت",
|
| 138 |
-
"أهداف", "تركيز", "نجاح", "تخطيط", "
|
| 139 |
"مهارات", "تفكير", "إبداع",
|
| 140 |
])
|
| 141 |
|
| 142 |
-
# ──
|
| 143 |
-
_register("
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
# English
|
| 145 |
-
"
|
| 146 |
-
"
|
| 147 |
-
"
|
| 148 |
-
"
|
| 149 |
-
"
|
| 150 |
-
"president", "prime minister", "foreign policy", "domestic policy",
|
| 151 |
-
"protest", "activism", "corruption", "media", "journalism",
|
| 152 |
-
"press", "freedom of speech", "censorship", "propaganda",
|
| 153 |
-
"international relations", "treaty", "nuclear",
|
| 154 |
# Arabic
|
| 155 |
-
"
|
| 156 |
-
"قانون", "حقوق إنسان", "دبلوماسية", "برلمان", "رئيس",
|
| 157 |
-
"إعلام", "صحافة",
|
| 158 |
])
|
| 159 |
|
| 160 |
-
# ── Entertainment
|
| 161 |
-
_register("Entertainment
|
| 162 |
# English
|
| 163 |
"entertainment", "movie", "movies", "film", "films", "cinema",
|
| 164 |
"tv", "television", "series", "netflix", "streaming", "anime",
|
| 165 |
"manga", "gaming", "video games", "esports", "twitch", "youtube",
|
| 166 |
-
"podcast", "music", "song", "album", "concert",
|
| 167 |
-
"celebrity", "
|
| 168 |
-
"travel", "tourism", "food", "cooking", "recipe", "restaurant",
|
| 169 |
-
"cuisine", "vlog", "vlogging", "photography", "art", "design",
|
| 170 |
-
"graphic design", "illustration", "architecture", "interior design",
|
| 171 |
-
"diy", "crafts", "comedy", "humor", "drama", "reality tv",
|
| 172 |
"social media", "tiktok", "instagram", "influencer", "content creator",
|
| 173 |
-
"
|
| 174 |
# Arabic
|
| 175 |
"ترفيه", "أفلام", "سينما", "مسلسلات", "ألعاب", "موسيقى",
|
| 176 |
-
"
|
| 177 |
-
"ثقافة", "كوميديا", "يوتيوب",
|
| 178 |
])
|
| 179 |
|
| 180 |
-
# ──
|
| 181 |
-
_register("
|
| 182 |
# English
|
| 183 |
-
"
|
| 184 |
-
"
|
| 185 |
-
"
|
| 186 |
-
|
| 187 |
-
"
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
"
|
| 194 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
# Arabic
|
| 196 |
-
"
|
| 197 |
-
"
|
| 198 |
-
"يوغا", "نوم", "فيتامينات",
|
| 199 |
])
|
| 200 |
|
| 201 |
|
|
@@ -239,14 +269,14 @@ def classify_topic(topics: List[str]) -> str:
|
|
| 239 |
break
|
| 240 |
|
| 241 |
if not matched:
|
| 242 |
-
return "Education
|
| 243 |
|
| 244 |
# Return the first match in CATEGORIES order for consistency
|
| 245 |
for cat in CATEGORIES:
|
| 246 |
if cat in matched:
|
| 247 |
return cat
|
| 248 |
|
| 249 |
-
return "Education
|
| 250 |
|
| 251 |
|
| 252 |
def classify_topics(topics: List[str]) -> List[str]:
|
|
@@ -269,7 +299,7 @@ def classify_topic_groq(title: str, summary: str) -> str:
|
|
| 269 |
Bypasses the local Zero-Shot classification model entirely.
|
| 270 |
"""
|
| 271 |
if not title and not summary:
|
| 272 |
-
return "Education
|
| 273 |
|
| 274 |
try:
|
| 275 |
from src.utils.model_loader import get_groq_client
|
|
@@ -319,11 +349,11 @@ def classify_topic_groq(title: str, summary: str) -> str:
|
|
| 319 |
return cat
|
| 320 |
|
| 321 |
logger.warning("⚠️ Groq returned invalid category: %s — falling back", reply)
|
| 322 |
-
return "Education
|
| 323 |
|
| 324 |
except Exception as e:
|
| 325 |
logger.error("❌ Groq category classification failed: %s", e, exc_info=True)
|
| 326 |
-
return "Education
|
| 327 |
|
| 328 |
|
| 329 |
# ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -341,7 +371,7 @@ def classify_topic_zeroshot(text: str) -> str:
|
|
| 341 |
The best-matching category string from CATEGORIES.
|
| 342 |
"""
|
| 343 |
if not text or not text.strip():
|
| 344 |
-
return "Education
|
| 345 |
|
| 346 |
try:
|
| 347 |
from src.utils.model_loader import get_classifier_pipeline
|
|
@@ -364,14 +394,14 @@ def classify_topic_zeroshot(text: str) -> str:
|
|
| 364 |
|
| 365 |
except Exception as e:
|
| 366 |
logger.warning("⚠️ Zero-shot classification failed: %s — falling back", e)
|
| 367 |
-
return "Education
|
| 368 |
|
| 369 |
|
| 370 |
def classify_topic_hybrid(topics: List[str], text: str = "") -> str:
|
| 371 |
"""Best-of-both-worlds classifier.
|
| 372 |
|
| 373 |
1. First tries fast keyword matching via ``classify_topic(topics)``.
|
| 374 |
-
2. If the result is the generic fallback ("Education
|
| 375 |
``text`` is provided, runs the mDeBERTa zero-shot classifier on
|
| 376 |
the text for a more nuanced result.
|
| 377 |
|
|
@@ -385,7 +415,7 @@ def classify_topic_hybrid(topics: List[str], text: str = "") -> str:
|
|
| 385 |
keyword_result = classify_topic(topics)
|
| 386 |
|
| 387 |
# If keyword matching gave a confident answer, use it
|
| 388 |
-
if keyword_result != "Education
|
| 389 |
return keyword_result
|
| 390 |
|
| 391 |
# If we have text, try zero-shot as a fallback
|
|
|
|
| 9 |
# => "Technology & AI"
|
| 10 |
|
| 11 |
Categories:
|
| 12 |
+
Technology & AI | Business & Finance | Education | Science
|
| 13 |
+
Productivity & Self-Growth | Health & Wellness | Sports & Fitness
|
| 14 |
+
Entertainment | History | Philosophy | Arts & Culture
|
| 15 |
"""
|
| 16 |
|
| 17 |
from typing import List, Set
|
|
|
|
| 28 |
CATEGORIES = [
|
| 29 |
"Technology & AI",
|
| 30 |
"Business & Finance",
|
| 31 |
+
"Education",
|
| 32 |
+
"Science",
|
| 33 |
"Productivity & Self-Growth",
|
| 34 |
+
"Health & Wellness",
|
| 35 |
+
"Sports & Fitness",
|
| 36 |
+
"Entertainment",
|
| 37 |
+
"History",
|
| 38 |
+
"Philosophy",
|
| 39 |
+
"Arts & Culture",
|
| 40 |
]
|
| 41 |
|
| 42 |
|
|
|
|
| 102 |
"ربح", "دخل", "ميزانية",
|
| 103 |
])
|
| 104 |
|
| 105 |
+
# ── Education ──
|
| 106 |
+
_register("Education", [
|
| 107 |
# English
|
| 108 |
"education", "learning", "teaching", "school", "university", "college",
|
| 109 |
+
"academic", "study", "studying", "exam", "exams", "course",
|
| 110 |
"tutorial", "lecture", "scholarship", "degree", "phd", "thesis",
|
| 111 |
+
"curriculum", "pedagogy", "classroom", "student", "teacher",
|
| 112 |
+
"grammar", "language", "linguistics",
|
| 113 |
+
# Arabic
|
| 114 |
+
"تعليم", "تعلم", "مدرسة", "جامعة", "دراسة", "امتحان", "منهج",
|
| 115 |
+
"محاضرة", "طالب", "معلم",
|
| 116 |
+
])
|
| 117 |
+
|
| 118 |
+
# ── Science ──
|
| 119 |
+
_register("Science", [
|
| 120 |
+
# English
|
| 121 |
"science", "physics", "chemistry", "biology", "math", "mathematics",
|
| 122 |
"statistics", "calculus", "algebra", "geometry", "astronomy", "space",
|
| 123 |
"nasa", "quantum", "quantum physics", "quantum computing",
|
|
|
|
| 125 |
"climate", "climate change", "environment", "engineering",
|
| 126 |
"mechanical engineering", "electrical engineering", "civil engineering",
|
| 127 |
"experiment", "laboratory", "lab", "hypothesis", "theory",
|
| 128 |
+
"research", "psychology", "sociology", "anthropology",
|
|
|
|
| 129 |
# Arabic
|
| 130 |
+
"علوم", "فيزياء", "كيمياء", "أحياء", "رياضيات", "بحث",
|
| 131 |
+
"هندسة", "فلك", "بيئة", "علم نفس",
|
|
|
|
| 132 |
])
|
| 133 |
|
| 134 |
# ── Productivity & Self-Growth ──
|
|
|
|
| 140 |
"mindset", "focus", "concentration", "efficiency", "organization",
|
| 141 |
"planning", "journaling", "morning routine", "routine", "success",
|
| 142 |
"self help", "self-help", "life coaching", "coaching", "mentoring",
|
| 143 |
+
"mentor", "minimalism", "mindfulness",
|
| 144 |
"emotional intelligence", "communication skills", "public speaking",
|
| 145 |
"negotiation", "critical thinking", "problem solving", "creativity",
|
| 146 |
"decision making", "confidence", "resilience", "work-life balance",
|
| 147 |
"burnout", "career", "career development", "skill building",
|
| 148 |
# Arabic
|
| 149 |
"إنتاجية", "تطوير ذات", "تحفيز", "عادات", "إدارة الوقت",
|
| 150 |
+
"أهداف", "تركيز", "نجاح", "تخطيط", "ثقة بالنفس",
|
| 151 |
"مهارات", "تفكير", "إبداع",
|
| 152 |
])
|
| 153 |
|
| 154 |
+
# ── Health & Wellness ──
|
| 155 |
+
_register("Health & Wellness", [
|
| 156 |
+
# English
|
| 157 |
+
"health", "wellness", "mental health", "therapy", "depression",
|
| 158 |
+
"anxiety", "stress", "sleep", "yoga", "pilates", "meditation",
|
| 159 |
+
"diet", "nutrition", "calories", "protein", "vitamins",
|
| 160 |
+
"supplements", "weight loss", "fitness",
|
| 161 |
+
"medicine", "medical", "doctor", "hospital", "surgery", "disease",
|
| 162 |
+
"virus", "vaccine", "pandemic", "covid", "cancer", "diabetes",
|
| 163 |
+
"heart", "cardio", "physical therapy", "rehabilitation",
|
| 164 |
+
"first aid", "pharmacy", "drug", "prescription",
|
| 165 |
+
# Arabic
|
| 166 |
+
"صحة", "تغذية", "حمية", "صحة نفسية", "علاج", "طب",
|
| 167 |
+
"مستشفى", "نوم", "فيتامينات", "يوغا",
|
| 168 |
+
])
|
| 169 |
+
|
| 170 |
+
# ── Sports & Fitness ──
|
| 171 |
+
_register("Sports & Fitness", [
|
| 172 |
# English
|
| 173 |
+
"sports", "football", "soccer", "basketball", "tennis", "baseball",
|
| 174 |
+
"cricket", "rugby", "boxing", "mma", "ufc", "wrestling",
|
| 175 |
+
"olympics", "world cup", "premier league", "nba", "nfl",
|
| 176 |
+
"exercise", "workout", "gym", "bodybuilding", "crossfit",
|
| 177 |
+
"running", "marathon", "swimming", "cycling", "hiking",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
# Arabic
|
| 179 |
+
"رياضة", "تمارين", "لياقة", "كرة قدم", "سباحة",
|
|
|
|
|
|
|
| 180 |
])
|
| 181 |
|
| 182 |
+
# ── Entertainment ──
|
| 183 |
+
_register("Entertainment", [
|
| 184 |
# English
|
| 185 |
"entertainment", "movie", "movies", "film", "films", "cinema",
|
| 186 |
"tv", "television", "series", "netflix", "streaming", "anime",
|
| 187 |
"manga", "gaming", "video games", "esports", "twitch", "youtube",
|
| 188 |
+
"podcast", "music", "song", "album", "concert",
|
| 189 |
+
"celebrity", "comedy", "humor", "drama", "reality tv",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
"social media", "tiktok", "instagram", "influencer", "content creator",
|
| 191 |
+
"vlog", "vlogging", "pop culture",
|
| 192 |
# Arabic
|
| 193 |
"ترفيه", "أفلام", "سينما", "مسلسلات", "ألعاب", "موسيقى",
|
| 194 |
+
"كوميديا", "يوتيوب",
|
|
|
|
| 195 |
])
|
| 196 |
|
| 197 |
+
# ── History ──
|
| 198 |
+
_register("History", [
|
| 199 |
# English
|
| 200 |
+
"history", "ancient", "medieval", "civilization", "empire",
|
| 201 |
+
"world war", "revolution", "archaeology", "historical",
|
| 202 |
+
"dynasty", "colonialism", "independence", "heritage",
|
| 203 |
+
# Arabic
|
| 204 |
+
"تاريخ", "حضارة", "إمبراطورية", "ثورة", "آثار", "تراث",
|
| 205 |
+
])
|
| 206 |
+
|
| 207 |
+
# ── Philosophy ──
|
| 208 |
+
_register("Philosophy", [
|
| 209 |
+
# English
|
| 210 |
+
"philosophy", "ethics", "morality", "existentialism", "stoicism",
|
| 211 |
+
"metaphysics", "epistemology", "logic", "consciousness",
|
| 212 |
+
"free will", "determinism", "nihilism", "virtue",
|
| 213 |
+
# Arabic
|
| 214 |
+
"فلسفة", "أخلاق", "وجودية", "منطق", "وعي",
|
| 215 |
+
])
|
| 216 |
+
|
| 217 |
+
# ── Arts & Culture ──
|
| 218 |
+
_register("Arts & Culture", [
|
| 219 |
+
# English
|
| 220 |
+
"art", "artist", "painting", "sculpture", "gallery", "museum",
|
| 221 |
+
"photography", "design", "graphic design", "illustration",
|
| 222 |
+
"architecture", "interior design", "fashion", "style", "beauty",
|
| 223 |
+
"makeup", "skincare", "travel", "tourism", "food", "cooking",
|
| 224 |
+
"recipe", "restaurant", "cuisine", "diy", "crafts",
|
| 225 |
+
"culture", "literature", "lifestyle", "luxury",
|
| 226 |
# Arabic
|
| 227 |
+
"فن", "تصميم", "تصوير", "سفر", "طبخ", "أزياء",
|
| 228 |
+
"جمال", "ثقافة",
|
|
|
|
| 229 |
])
|
| 230 |
|
| 231 |
|
|
|
|
| 269 |
break
|
| 270 |
|
| 271 |
if not matched:
|
| 272 |
+
return "Education"
|
| 273 |
|
| 274 |
# Return the first match in CATEGORIES order for consistency
|
| 275 |
for cat in CATEGORIES:
|
| 276 |
if cat in matched:
|
| 277 |
return cat
|
| 278 |
|
| 279 |
+
return "Education"
|
| 280 |
|
| 281 |
|
| 282 |
def classify_topics(topics: List[str]) -> List[str]:
|
|
|
|
| 299 |
Bypasses the local Zero-Shot classification model entirely.
|
| 300 |
"""
|
| 301 |
if not title and not summary:
|
| 302 |
+
return "Education"
|
| 303 |
|
| 304 |
try:
|
| 305 |
from src.utils.model_loader import get_groq_client
|
|
|
|
| 349 |
return cat
|
| 350 |
|
| 351 |
logger.warning("⚠️ Groq returned invalid category: %s — falling back", reply)
|
| 352 |
+
return "Education"
|
| 353 |
|
| 354 |
except Exception as e:
|
| 355 |
logger.error("❌ Groq category classification failed: %s", e, exc_info=True)
|
| 356 |
+
return "Education"
|
| 357 |
|
| 358 |
|
| 359 |
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
| 371 |
The best-matching category string from CATEGORIES.
|
| 372 |
"""
|
| 373 |
if not text or not text.strip():
|
| 374 |
+
return "Education"
|
| 375 |
|
| 376 |
try:
|
| 377 |
from src.utils.model_loader import get_classifier_pipeline
|
|
|
|
| 394 |
|
| 395 |
except Exception as e:
|
| 396 |
logger.warning("⚠️ Zero-shot classification failed: %s — falling back", e)
|
| 397 |
+
return "Education"
|
| 398 |
|
| 399 |
|
| 400 |
def classify_topic_hybrid(topics: List[str], text: str = "") -> str:
|
| 401 |
"""Best-of-both-worlds classifier.
|
| 402 |
|
| 403 |
1. First tries fast keyword matching via ``classify_topic(topics)``.
|
| 404 |
+
2. If the result is the generic fallback ("Education") AND
|
| 405 |
``text`` is provided, runs the mDeBERTa zero-shot classifier on
|
| 406 |
the text for a more nuanced result.
|
| 407 |
|
|
|
|
| 415 |
keyword_result = classify_topic(topics)
|
| 416 |
|
| 417 |
# If keyword matching gave a confident answer, use it
|
| 418 |
+
if keyword_result != "Education":
|
| 419 |
return keyword_result
|
| 420 |
|
| 421 |
# If we have text, try zero-shot as a fallback
|
src/summarization/note_generator.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
import os
|
| 2 |
import re
|
| 3 |
from typing import Dict, List
|
|
@@ -34,51 +35,6 @@ _LANG_MATCH_INSTRUCTION = (
|
|
| 34 |
"Identify the primary language of the input text. You MUST generate your entire response strictly in that exact same language. DO NOT mix languages under any circumstances."
|
| 35 |
)
|
| 36 |
|
| 37 |
-
def parse_key_points_from_markdown(markdown_text: str) -> List[str]:
|
| 38 |
-
"""Extract bullet points under the Key Points header in Markdown."""
|
| 39 |
-
if not markdown_text:
|
| 40 |
-
return []
|
| 41 |
-
|
| 42 |
-
lines = markdown_text.splitlines()
|
| 43 |
-
key_points = []
|
| 44 |
-
in_key_points_section = False
|
| 45 |
-
|
| 46 |
-
for line in lines:
|
| 47 |
-
line_strip = line.strip()
|
| 48 |
-
if not line_strip:
|
| 49 |
-
continue
|
| 50 |
-
# Check for headers like ## 💡 Key Points or ## 💡 أهم النقاط or ## Key Points
|
| 51 |
-
if line_strip.startswith("##") and ("Key Points" in line_strip or "النقاط" in line_strip):
|
| 52 |
-
in_key_points_section = True
|
| 53 |
-
continue
|
| 54 |
-
elif line_strip.startswith("##") and in_key_points_section:
|
| 55 |
-
# Reached a new section header, stop parsing
|
| 56 |
-
break
|
| 57 |
-
|
| 58 |
-
if in_key_points_section:
|
| 59 |
-
# Match list items starting with -, *, or numbering
|
| 60 |
-
if line_strip.startswith("-") or line_strip.startswith("*"):
|
| 61 |
-
cleaned_point = line_strip.lstrip("-* ").strip()
|
| 62 |
-
if cleaned_point:
|
| 63 |
-
key_points.append(cleaned_point)
|
| 64 |
-
elif line_strip[0].isdigit() and (line_strip[1] == "." or (len(line_strip) > 2 and line_strip[2] == ".")):
|
| 65 |
-
parts = line_strip.split(".", 1)
|
| 66 |
-
if len(parts) > 1:
|
| 67 |
-
cleaned_point = parts[1].strip()
|
| 68 |
-
if cleaned_point:
|
| 69 |
-
key_points.append(cleaned_point)
|
| 70 |
-
|
| 71 |
-
# Fallback if parsing failed or section wasn't found
|
| 72 |
-
if not key_points:
|
| 73 |
-
for line in lines:
|
| 74 |
-
line_strip = line.strip()
|
| 75 |
-
if line_strip.startswith("- ") or line_strip.startswith("* "):
|
| 76 |
-
cleaned = line_strip[2:].strip()
|
| 77 |
-
if cleaned and not any(h in cleaned for h in ["General Summary", "الملخص العام", "Key Questions", "الأسئلة", "Key Points", "النقاط"]):
|
| 78 |
-
key_points.append(cleaned)
|
| 79 |
-
|
| 80 |
-
return key_points[:5]
|
| 81 |
-
|
| 82 |
|
| 83 |
|
| 84 |
|
|
@@ -248,51 +204,153 @@ class NoteGenerator:
|
|
| 248 |
)
|
| 249 |
return segments_list
|
| 250 |
|
| 251 |
-
|
| 252 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
combined_text = " ".join(seg["summary"] for seg in segments_list)
|
| 254 |
clean_combined = WHITESPACE_RE.sub(" ", combined_text.strip())[:3000]
|
| 255 |
|
| 256 |
if not clean_combined:
|
| 257 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
|
| 259 |
if INFERENCE_MODE == "groq":
|
| 260 |
messages = [
|
| 261 |
{
|
| 262 |
"role": "system",
|
| 263 |
"content": (
|
| 264 |
-
"You are a professional summarizer compiling a final overview of a video.\n"
|
| 265 |
-
|
| 266 |
-
"
|
| 267 |
-
"
|
| 268 |
-
"
|
| 269 |
-
"
|
| 270 |
-
"
|
| 271 |
-
|
| 272 |
-
"
|
| 273 |
-
"
|
| 274 |
-
"
|
| 275 |
-
"
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
),
|
| 283 |
},
|
| 284 |
{"role": "user", "content": clean_combined},
|
| 285 |
]
|
| 286 |
-
logger.info("🟢 Reducing summaries via Groq API (
|
| 287 |
groq_client = get_groq_client()
|
| 288 |
chat_completion = groq_client.chat.completions.create(
|
| 289 |
model="llama-3.3-70b-versatile",
|
| 290 |
messages=messages,
|
| 291 |
-
max_tokens=
|
| 292 |
temperature=0.0,
|
|
|
|
| 293 |
)
|
| 294 |
-
|
|
|
|
|
|
|
| 295 |
else:
|
|
|
|
| 296 |
messages = [
|
| 297 |
{
|
| 298 |
"role": "system",
|
|
@@ -309,10 +367,15 @@ class NoteGenerator:
|
|
| 309 |
logger.info("🤖 Reducing summaries via local Qwen pipeline...")
|
| 310 |
overall = _generate_text_local(messages, max_new_tokens=250)
|
| 311 |
|
| 312 |
-
|
| 313 |
-
|
| 314 |
|
| 315 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 316 |
|
| 317 |
def generateSummary(self, transcript_text: str, video_title: str) -> Dict:
|
| 318 |
"""Generates a structured AI summary, validated against SummarySchema."""
|
|
|
|
| 1 |
+
import json
|
| 2 |
import os
|
| 3 |
import re
|
| 4 |
from typing import Dict, List
|
|
|
|
| 35 |
"Identify the primary language of the input text. You MUST generate your entire response strictly in that exact same language. DO NOT mix languages under any circumstances."
|
| 36 |
)
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
|
| 40 |
|
|
|
|
| 204 |
)
|
| 205 |
return segments_list
|
| 206 |
|
| 207 |
+
# ── Valid categories for the reduce prompt ────────────────────────────
|
| 208 |
+
VALID_CATEGORIES = [
|
| 209 |
+
"Technology & AI",
|
| 210 |
+
"Business & Finance",
|
| 211 |
+
"Education",
|
| 212 |
+
"Science",
|
| 213 |
+
"Productivity & Self-Growth",
|
| 214 |
+
"Health & Wellness",
|
| 215 |
+
"Sports & Fitness",
|
| 216 |
+
"Entertainment",
|
| 217 |
+
"History",
|
| 218 |
+
"Philosophy",
|
| 219 |
+
"Arts & Culture",
|
| 220 |
+
]
|
| 221 |
+
|
| 222 |
+
def _parse_reduce_json(self, raw_text: str, video_title: str) -> Dict:
|
| 223 |
+
"""Safely parse the strict JSON returned by the reduce LLM call.
|
| 224 |
+
|
| 225 |
+
Returns a dict with keys: markdown_summary, key_points, category, language.
|
| 226 |
+
Falls back gracefully if parsing fails.
|
| 227 |
+
"""
|
| 228 |
+
# Try to extract JSON from the response (handle markdown code fences)
|
| 229 |
+
text = raw_text.strip()
|
| 230 |
+
if text.startswith("```"):
|
| 231 |
+
# Remove ```json ... ``` wrappers
|
| 232 |
+
text = re.sub(r"^```(?:json)?\s*", "", text)
|
| 233 |
+
text = re.sub(r"\s*```$", "", text)
|
| 234 |
+
|
| 235 |
+
try:
|
| 236 |
+
data = json.loads(text)
|
| 237 |
+
except json.JSONDecodeError:
|
| 238 |
+
logger.warning("⚠️ Failed to parse reduce JSON. Falling back to raw text.")
|
| 239 |
+
return {
|
| 240 |
+
"markdown_summary": raw_text,
|
| 241 |
+
"key_points": [],
|
| 242 |
+
"category": "Education",
|
| 243 |
+
"language": "ar",
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
# Validate and sanitize each field
|
| 247 |
+
markdown_summary = data.get("markdown_summary", "").strip()
|
| 248 |
+
if not markdown_summary:
|
| 249 |
+
markdown_summary = raw_text
|
| 250 |
+
|
| 251 |
+
key_points = data.get("key_points", [])
|
| 252 |
+
if not isinstance(key_points, list):
|
| 253 |
+
key_points = []
|
| 254 |
+
key_points = [str(p).strip() for p in key_points if str(p).strip()][:5]
|
| 255 |
+
|
| 256 |
+
category = data.get("category", "").strip()
|
| 257 |
+
if category not in self.VALID_CATEGORIES:
|
| 258 |
+
# Attempt fuzzy match
|
| 259 |
+
category_lower = category.lower()
|
| 260 |
+
matched = False
|
| 261 |
+
for valid_cat in self.VALID_CATEGORIES:
|
| 262 |
+
if valid_cat.lower() in category_lower or category_lower in valid_cat.lower():
|
| 263 |
+
category = valid_cat
|
| 264 |
+
matched = True
|
| 265 |
+
break
|
| 266 |
+
if not matched:
|
| 267 |
+
category = "Education"
|
| 268 |
+
|
| 269 |
+
language = data.get("language", "ar").strip().lower()
|
| 270 |
+
if language not in ("ar", "en"):
|
| 271 |
+
language = "ar"
|
| 272 |
+
|
| 273 |
+
return {
|
| 274 |
+
"markdown_summary": markdown_summary,
|
| 275 |
+
"key_points": key_points,
|
| 276 |
+
"category": category,
|
| 277 |
+
"language": language,
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
def _reduce_summary(self, segments_list: List[Dict], video_title: str) -> Dict:
|
| 281 |
+
"""REDUCE step: combine chunk-summaries into a strict JSON with
|
| 282 |
+
markdown_summary, key_points, category, and language.
|
| 283 |
+
|
| 284 |
+
Returns a dict (parsed JSON), NOT a raw string.
|
| 285 |
+
"""
|
| 286 |
combined_text = " ".join(seg["summary"] for seg in segments_list)
|
| 287 |
clean_combined = WHITESPACE_RE.sub(" ", combined_text.strip())[:3000]
|
| 288 |
|
| 289 |
if not clean_combined:
|
| 290 |
+
return {
|
| 291 |
+
"markdown_summary": f"Summary of: {video_title}.",
|
| 292 |
+
"key_points": [],
|
| 293 |
+
"category": "Education",
|
| 294 |
+
"language": "ar",
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
categories_str = ", ".join(f'"{c}"' for c in self.VALID_CATEGORIES)
|
| 298 |
|
| 299 |
if INFERENCE_MODE == "groq":
|
| 300 |
messages = [
|
| 301 |
{
|
| 302 |
"role": "system",
|
| 303 |
"content": (
|
| 304 |
+
"You are a professional summarizer compiling a final overview of a video.\n\n"
|
| 305 |
+
"STRICT RULES — follow every one exactly:\n\n"
|
| 306 |
+
"1. LANGUAGE: Detect the primary language of the text below. "
|
| 307 |
+
"If Arabic, write EVERYTHING (headers, body, questions, answers, key points) in Arabic. "
|
| 308 |
+
"If English, write EVERYTHING in English. "
|
| 309 |
+
"NEVER mix languages.\n\n"
|
| 310 |
+
"2. OUTPUT FORMAT: You MUST return a single valid JSON object with this exact schema — no extra text, no markdown fences, ONLY the JSON:\n"
|
| 311 |
+
'{\n'
|
| 312 |
+
' "language": "ar" or "en",\n'
|
| 313 |
+
' "category": "one of the categories listed below",\n'
|
| 314 |
+
' "markdown_summary": "the formatted markdown string",\n'
|
| 315 |
+
' "key_points": ["point 1", "point 2", "point 3", "point 4", "point 5"]\n'
|
| 316 |
+
'}\n\n'
|
| 317 |
+
'3. MARKDOWN_SUMMARY FORMATTING — this is critical for readability:\n'
|
| 318 |
+
' - Start with: ## 📋 الملخص العام (or ## 📋 General Summary for English)\n'
|
| 319 |
+
' - Then TWO newlines (\\n\\n)\n'
|
| 320 |
+
' - Write a clear, concise 1-paragraph overview of the entire video.\n'
|
| 321 |
+
' - Then: \\n\\n---\\n\\n\n'
|
| 322 |
+
' - Then: ## ❓ أبرز الأسئلة والأجوبة (or ## ❓ Key Questions & Answers for English)\n'
|
| 323 |
+
' - Then TWO newlines (\\n\\n)\n'
|
| 324 |
+
' - Write exactly 5 Q&A pairs. Format each pair as:\n'
|
| 325 |
+
' **س: [Question]?**\\nج: [Answer]\\n\\n (for Arabic)\n'
|
| 326 |
+
' **Q: [Question]?**\\nA: [Answer]\\n\\n (for English)\n'
|
| 327 |
+
' - IMPORTANT: Put \\n\\n (double newline) between EVERY Q&A pair for spacious layout.\n'
|
| 328 |
+
' - Use relevant modern emojis sparingly in questions to make it engaging.\n'
|
| 329 |
+
' - DO NOT include key points, bullet lists, or any other sections in markdown_summary.\n\n'
|
| 330 |
+
'4. KEY_POINTS: Exactly 5 concise, factual strings in a JSON array. "
|
| 331 |
+
"These must be concrete insights from the content, NOT generic text. "
|
| 332 |
+
"Do NOT repeat these inside markdown_summary.\n\n"
|
| 333 |
+
f"5. CATEGORY: Must be exactly one of: [{categories_str}]. "
|
| 334 |
+
"Choose the most accurate one based on the actual content.\n\n"
|
| 335 |
+
"6. Do NOT wrap the output in markdown code fences. Return raw JSON only."
|
| 336 |
),
|
| 337 |
},
|
| 338 |
{"role": "user", "content": clean_combined},
|
| 339 |
]
|
| 340 |
+
logger.info("🟢 Reducing summaries via Groq API (strict JSON mode)...")
|
| 341 |
groq_client = get_groq_client()
|
| 342 |
chat_completion = groq_client.chat.completions.create(
|
| 343 |
model="llama-3.3-70b-versatile",
|
| 344 |
messages=messages,
|
| 345 |
+
max_tokens=1500,
|
| 346 |
temperature=0.0,
|
| 347 |
+
response_format={"type": "json_object"},
|
| 348 |
)
|
| 349 |
+
raw_response = chat_completion.choices[0].message.content or ""
|
| 350 |
+
logger.info(f"🔎 Reduce raw response (len={len(raw_response)}): {raw_response[:300]!r}")
|
| 351 |
+
return self._parse_reduce_json(raw_response, video_title)
|
| 352 |
else:
|
| 353 |
+
# Local Qwen mode — keep simple text-based reduce (no JSON)
|
| 354 |
messages = [
|
| 355 |
{
|
| 356 |
"role": "system",
|
|
|
|
| 367 |
logger.info("🤖 Reducing summaries via local Qwen pipeline...")
|
| 368 |
overall = _generate_text_local(messages, max_new_tokens=250)
|
| 369 |
|
| 370 |
+
if not overall or len(overall.strip()) < 5:
|
| 371 |
+
overall = f"استعراض شامل ومناقشة تفصيلية لموضوع: {video_title}."
|
| 372 |
|
| 373 |
+
return {
|
| 374 |
+
"markdown_summary": overall,
|
| 375 |
+
"key_points": [],
|
| 376 |
+
"category": "Education",
|
| 377 |
+
"language": "ar",
|
| 378 |
+
}
|
| 379 |
|
| 380 |
def generateSummary(self, transcript_text: str, video_title: str) -> Dict:
|
| 381 |
"""Generates a structured AI summary, validated against SummarySchema."""
|
src/summarization/schemas.py
CHANGED
|
@@ -73,3 +73,10 @@ class SummarySchema(BaseModel):
|
|
| 73 |
" Examples: ['Python', 'Machine Learning', 'Neural Networks']."
|
| 74 |
),
|
| 75 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
" Examples: ['Python', 'Machine Learning', 'Neural Networks']."
|
| 74 |
),
|
| 75 |
)
|
| 76 |
+
key_points: List[str] = Field(
|
| 77 |
+
default_factory=list,
|
| 78 |
+
description=(
|
| 79 |
+
"Exactly 5 concise key points extracted from the video content."
|
| 80 |
+
" Used by the UI to render a separate key-points list."
|
| 81 |
+
),
|
| 82 |
+
)
|