Oviya commited on
Commit
59f2028
·
1 Parent(s): c3ad823
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env +20 -5
  2. apt.txt +0 -6
  3. chroma_db/1ceaf3a3-30e6-42c4-b515-99a05466da04/header.bin → assets/chroma_db/09c5ed20-106f-41c5-94dc-89d203beb195/data_level0.bin +2 -2
  4. {chroma_db/44944ef3-9b61-4c1b-bc5e-6a49750c0c54 → assets/chroma_db/09c5ed20-106f-41c5-94dc-89d203beb195}/header.bin +1 -1
  5. {chroma_db/1ceaf3a3-30e6-42c4-b515-99a05466da04 → assets/chroma_db/09c5ed20-106f-41c5-94dc-89d203beb195}/length.bin +2 -2
  6. {chroma_db/1ceaf3a3-30e6-42c4-b515-99a05466da04 → assets/chroma_db/09c5ed20-106f-41c5-94dc-89d203beb195}/link_lists.bin +0 -0
  7. feedback.mp4 → assets/feedback.mp4 +0 -0
  8. {pdfs → assets/pdfs}/high/high.pdf +0 -0
  9. {pdfs → assets/pdfs}/low/low.pdf +0 -0
  10. {pdfs → assets/pdfs}/mid/mid.pdf +0 -0
  11. chroma_db/44944ef3-9b61-4c1b-bc5e-6a49750c0c54/data_level0.bin → assets/teacher.png +2 -2
  12. teacher_feedback_sentences_category.json → assets/teacher_feedback_sentences_category.json +0 -0
  13. static/references/voice1.wav → assets/teachervoice.wav +0 -0
  14. auth/__init__.py +25 -0
  15. auth/database.py +168 -0
  16. auth/models.py +177 -0
  17. auth/routes.py +346 -0
  18. auth/utils.py +156 -0
  19. build_chroma_db.py +91 -0
  20. chat.py +0 -246
  21. chroma_db/44944ef3-9b61-4c1b-bc5e-6a49750c0c54/length.bin +0 -3
  22. chroma_db/44944ef3-9b61-4c1b-bc5e-6a49750c0c54/link_lists.bin +0 -0
  23. findingword.py +0 -276
  24. generateQuestion.py +0 -535
  25. googlecredentails.json +0 -13
  26. listen.py +0 -436
  27. media/audio/explain_1112505a6701429cb241d131a88bf709.wav +0 -3
  28. media/audio/explain_5c2a7427d1f14a2aa9fa9e59bb1ad603.wav +0 -3
  29. media/audio/explain_975ae1b5996743f6b76b5016f17056de.wav +0 -3
  30. media/audio/explain_ca92720c882d4926973973aa4b9f2316.wav +0 -3
  31. media/audio/explain_cc24a21b0b374e50bc8afbf73a7398c4.wav +0 -3
  32. media/audio/explain_dd70fb52325d44fc84cde7c1c9215232.wav +0 -3
  33. media/audio/synth_22ebf1e3b9404b34a41b2fdc2c691adb.wav +0 -3
  34. media/audio/synth_2757240115da4ba3a9aa1286aee57db9.wav +0 -3
  35. media/audio/synth_4965badeb7da43ffac0c3a7af781ab0f.wav +0 -3
  36. media/audio/synth_7bccf943f0b24880b77aa038b38f8bf1.wav +0 -3
  37. chroma_db/1ceaf3a3-30e6-42c4-b515-99a05466da04/data_level0.bin → media/audio/synth_d38b265fcd6d4f9cbb825007c3f52ac5.wav +2 -2
  38. media/audio/synth_ee1e3e992d6641b9a06d214e0e67ea92.wav +0 -3
  39. pdfs/testing.pdf +0 -3
  40. pron.py +0 -729
  41. pronragg.py +0 -263
  42. pronragupgrade.py → pronunciation.py +180 -371
  43. pronvideo.py +0 -359
  44. ragg/app.py +295 -491
  45. ragg/ingest_all.py +2 -2
  46. ragg/tts.py +1 -1
  47. reading.py +0 -158
  48. start.sh +0 -29
  49. trim/voice1.wav +0 -3
  50. verification.py +164 -504
.env CHANGED
@@ -4,19 +4,34 @@ DB_DATABASE=AuthenticationDB1
4
  DB_DRIVER=ODBC Driver 17 for SQL Server # match the driver installed on your PC
5
 
6
  RUN_INIT_DB=0
 
 
7
  COHERE_API_KEY=iXPfvur9lmAS4Mo91Bdfc6Gujhi3Jdnm6FP2JJqR
8
- OPENAI_API_KEY=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
9
- DID_API_KEY=cmFqYWxhc2htaS5uQHB5a2FyYS5uZXQ:J2uPGx3uD4L7UKgHEiMJI
 
 
10
  DID_SOURCE_IMAGE_URL=https://i.ibb.co/Tpq77ZJ/teacher.png
11
  DID_VOICE_ID=en-US-JennyNeural
 
 
12
  TESSERACT_CMD=C:\Program Files\Tesseract-OCR\tesseract.exe
13
- CHROMA_DIR=C:/Users/DELL/Desktop/Deploymnet/29 oct/py-learn-backend/ragg/chroma
14
- CHROMA_ROOT=C:/Users/DELL/Desktop/Deploymnet/29 oct/py-learn-backend/ragg/chroma
 
 
15
  EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2
 
 
16
  ALLOWED_ORIGINS=http://localhost:4200,http://127.0.0.1:4200
17
  RAG_INGEST_URL=http://localhost:5000/rag/ingest
 
 
18
  AWS_ACCESS_KEY_ID=AKIA3PWGNRHL7RTV3XRJ
19
  AWS_SECRET_ACCESS_KEY=SZBvxZHPw8OVkrFd7nMXe+Nt/3ulrpynXVrGBiKm
20
  AWS_REGION=ap-south-1
21
  S3_BUCKET=pykara-tts-audio
22
- S3_PREFIX=audio/
 
 
 
 
4
  DB_DRIVER=ODBC Driver 17 for SQL Server # match the driver installed on your PC
5
 
6
  RUN_INIT_DB=0
7
+
8
+ # --- API Keys
9
  COHERE_API_KEY=iXPfvur9lmAS4Mo91Bdfc6Gujhi3Jdnm6FP2JJqR
10
+ OPENAI_API_KEY=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
11
+
12
+ # --- D-ID Configuration
13
+ DID_API_KEY=cmFqYWxhc2htaS5uQHB5a2FyYS5uZXQ:9Moos-oxSY8uNUNGx1o-u
14
  DID_SOURCE_IMAGE_URL=https://i.ibb.co/Tpq77ZJ/teacher.png
15
  DID_VOICE_ID=en-US-JennyNeural
16
+
17
+ # --- Tesseract OCR
18
  TESSERACT_CMD=C:\Program Files\Tesseract-OCR\tesseract.exe
19
+
20
+ # --- ChromaDB Configuration
21
+ CHROMA_DIR=C:/Viji-Workingfolder/17-1-26/mj-learn-backend/ragg/chroma
22
+ CHROMA_ROOT=C:/Viji-Workingfolder/17-1-26/mj-learn-backend/ragg/chroma
23
  EMBEDDING_MODEL=sentence-transformers/all-MiniLM-L6-v2
24
+
25
+ # --- CORS and RAG
26
  ALLOWED_ORIGINS=http://localhost:4200,http://127.0.0.1:4200
27
  RAG_INGEST_URL=http://localhost:5000/rag/ingest
28
+
29
+ # --- AWS S3 Configuration
30
  AWS_ACCESS_KEY_ID=AKIA3PWGNRHL7RTV3XRJ
31
  AWS_SECRET_ACCESS_KEY=SZBvxZHPw8OVkrFd7nMXe+Nt/3ulrpynXVrGBiKm
32
  AWS_REGION=ap-south-1
33
  S3_BUCKET=pykara-tts-audio
34
+ S3_PREFIX=audio/
35
+
36
+ # --- Authentication Secret Key (CRITICAL for JWT tokens)
37
+ SECRET_KEY=96c63da06374c1bde332516f3acbd23c84f35f90d8a6321a25d790a0a451af32
apt.txt DELETED
@@ -1,6 +0,0 @@
1
- ffmpeg
2
- poppler-utils
3
- tesseract-ocr
4
- tesseract-ocr-eng
5
- libsndfile1
6
- espeak-ng
 
 
 
 
 
 
 
chroma_db/1ceaf3a3-30e6-42c4-b515-99a05466da04/header.bin → assets/chroma_db/09c5ed20-106f-41c5-94dc-89d203beb195/data_level0.bin RENAMED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:a0e81c3b22454233bc12d0762f06dcca48261a75231cf87c79b75e69a6c00150
3
- size 100
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d3c9fd302f000d7790aa403c2d0d8fec363fe46f30b07d53020b6e33b22435a9
3
+ size 1676000
{chroma_db/44944ef3-9b61-4c1b-bc5e-6a49750c0c54 → assets/chroma_db/09c5ed20-106f-41c5-94dc-89d203beb195}/header.bin RENAMED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:a0e81c3b22454233bc12d0762f06dcca48261a75231cf87c79b75e69a6c00150
3
  size 100
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e87a1dc8bcae6f2c4bea6d5dd5005454d4dace8637dae29bff3c037ea771411e
3
  size 100
{chroma_db/1ceaf3a3-30e6-42c4-b515-99a05466da04 → assets/chroma_db/09c5ed20-106f-41c5-94dc-89d203beb195}/length.bin RENAMED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:7171cf84eb030fe5cb580f57a325f57cceb0aed0e55ea95c81d67d4181e1ed81
3
- size 400
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a9250db95ea158634771707fb36f3fe0d92d810baeb15d1a9b51716f832628c2
3
+ size 4000
{chroma_db/1ceaf3a3-30e6-42c4-b515-99a05466da04 → assets/chroma_db/09c5ed20-106f-41c5-94dc-89d203beb195}/link_lists.bin RENAMED
File without changes
feedback.mp4 → assets/feedback.mp4 RENAMED
File without changes
{pdfs → assets/pdfs}/high/high.pdf RENAMED
File without changes
{pdfs → assets/pdfs}/low/low.pdf RENAMED
File without changes
{pdfs → assets/pdfs}/mid/mid.pdf RENAMED
File without changes
chroma_db/44944ef3-9b61-4c1b-bc5e-6a49750c0c54/data_level0.bin → assets/teacher.png RENAMED
File without changes
teacher_feedback_sentences_category.json → assets/teacher_feedback_sentences_category.json RENAMED
File without changes
static/references/voice1.wav → assets/teachervoice.wav RENAMED
File without changes
auth/__init__.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Authentication module for MJ Learn Backend
3
+
4
+ This module provides:
5
+ - User authentication and authorization
6
+ - JWT token management
7
+ - Database models for user management
8
+ - Security utilities
9
+ """
10
+
11
+ from .models import User, BlacklistedToken, RefreshToken
12
+ from .utils import token_required, anonymize_username
13
+ from .database import get_db_connection, init_db
14
+ from .routes import auth_bp
15
+
16
+ __all__ = [
17
+ 'User',
18
+ 'BlacklistedToken',
19
+ 'RefreshToken',
20
+ 'token_required',
21
+ 'anonymize_username',
22
+ 'get_db_connection',
23
+ 'init_db',
24
+ 'auth_bp'
25
+ ]
auth/database.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Database connection and initialization module
3
+
4
+ Handles:
5
+ - Database connection management
6
+ - Table creation and initialization
7
+ - Connection string configuration
8
+ - Database diagnostics
9
+ """
10
+
11
+ import os
12
+ import pyodbc
13
+ from threading import Lock
14
+ from .models import get_table_definitions
15
+
16
+ # Database configuration
17
+ DB_SERVER = os.getenv("DB_SERVER", r"(localdb)\MSSQLLocalDB")
18
+ DB_DATABASE = os.getenv("DB_DATABASE", "AuthenticationDB1")
19
+ DB_DRIVER = os.getenv("DB_DRIVER", "ODBC Driver 17 for SQL Server")
20
+
21
+ # Build connection string
22
+ is_local = (
23
+ DB_SERVER.lower().startswith("localhost")
24
+ or DB_SERVER.startswith(".")
25
+ or DB_SERVER.lower().startswith("(localdb)")
26
+ or "\\" in DB_SERVER
27
+ )
28
+
29
+ if is_local:
30
+ # Windows local / LocalDB using modern ODBC driver
31
+ CONN_STR = (
32
+ f"DRIVER={{{DB_DRIVER}}};"
33
+ f"SERVER={DB_SERVER};"
34
+ f"DATABASE={DB_DATABASE};"
35
+ "Trusted_Connection=yes;"
36
+ "TrustServerCertificate=yes;"
37
+ )
38
+ else:
39
+ # Remote SQL auth
40
+ CONN_STR = (
41
+ f"DRIVER={{{DB_DRIVER}}};"
42
+ f"SERVER={DB_SERVER};DATABASE={DB_DATABASE};"
43
+ f"UID={os.getenv('DB_USER')};PWD={os.getenv('DB_PASSWORD')};"
44
+ "Encrypt=yes;TrustServerCertificate=yes;"
45
+ )
46
+
47
+ # Database initialization tracking
48
+ _db_init_done = False
49
+ _db_init_lock = Lock()
50
+
51
+
52
+ def get_db_connection():
53
+ """
54
+ Create a database connection with short timeout
55
+
56
+ Raises:
57
+ RuntimeError: If DB credentials are missing for remote connections
58
+ pyodbc.Error: If connection fails
59
+ """
60
+ if "Trusted_Connection=yes" not in CONN_STR:
61
+ if not os.getenv("DB_USER") or not os.getenv("DB_PASSWORD"):
62
+ raise RuntimeError("DB_USER/DB_PASSWORD are not set in the environment.")
63
+ return pyodbc.connect(CONN_STR, timeout=5)
64
+
65
+
66
+ def init_db():
67
+ """
68
+ Create database tables if they do not exist
69
+
70
+ Creates:
71
+ - Users table for authentication
72
+ - BlacklistedTokens table for token management
73
+ - RefreshTokens table for refresh token storage
74
+ """
75
+ conn = get_db_connection()
76
+ cur = conn.cursor()
77
+
78
+ # Get table definitions
79
+ tables = get_table_definitions()
80
+
81
+ # Create each table
82
+ for table_name, sql in tables.items():
83
+ cur.execute(sql)
84
+
85
+ conn.commit()
86
+ conn.close()
87
+
88
+
89
+ def ensure_database_initialized():
90
+ """
91
+ Ensure database is initialized (thread-safe)
92
+
93
+ Call this from Flask app startup to initialize database once.
94
+ Controlled by RUN_INIT_DB environment variable.
95
+ """
96
+ global _db_init_done
97
+ should_init = os.getenv("RUN_INIT_DB", "0") == "1"
98
+
99
+ if should_init and not _db_init_done:
100
+ with _db_init_lock:
101
+ if not _db_init_done:
102
+ try:
103
+ init_db()
104
+ print("? Database initialized successfully")
105
+ return True
106
+ except Exception as e:
107
+ print(f"? Database initialization failed: {e}")
108
+ raise
109
+ finally:
110
+ _db_init_done = True
111
+
112
+ return _db_init_done
113
+
114
+
115
+ def get_database_info():
116
+ """
117
+ Get database diagnostic information (admin only)
118
+
119
+ Returns safe diagnostic information without exposing credentials.
120
+ """
121
+ info = {}
122
+
123
+ # Get available drivers
124
+ try:
125
+ info["drivers_found"] = pyodbc.drivers()
126
+ except Exception as e:
127
+ info["drivers_found_error"] = str(e)
128
+
129
+ # Safe database information
130
+ info["database_name"] = DB_DATABASE
131
+ info["server_type"] = "LocalDB" if is_local else "Remote"
132
+
133
+ # Test connection
134
+ try:
135
+ conn = get_db_connection()
136
+ conn.close()
137
+ info["connection_status"] = "ok"
138
+ except Exception as e:
139
+ info["connection_status"] = "error"
140
+ info["error_type"] = type(e).__name__
141
+
142
+ return info
143
+
144
+
145
+ def test_database_connection():
146
+ """
147
+ Test database connection and return status
148
+
149
+ Returns:
150
+ tuple: (success: bool, message: str)
151
+ """
152
+ try:
153
+ conn = get_db_connection()
154
+
155
+ # Test basic query
156
+ cur = conn.cursor()
157
+ cur.execute("SELECT 1")
158
+ result = cur.fetchone()
159
+
160
+ conn.close()
161
+
162
+ if result and result[0] == 1:
163
+ return True, "Database connection successful"
164
+ else:
165
+ return False, "Database query failed"
166
+
167
+ except Exception as e:
168
+ return False, f"Database connection failed: {str(e)}"
auth/models.py ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Database models and schemas for authentication system
3
+
4
+ Contains:
5
+ - User model with role-based access
6
+ - Token blacklist model
7
+ - Refresh token model
8
+ - Database table definitions
9
+ """
10
+
11
+ import pyodbc
12
+ from typing import Optional, Dict, Any
13
+
14
+
15
+ class User:
16
+ """User model for authentication and authorization"""
17
+
18
+ def __init__(self, username: str, password_hash: str, role: str = 'user', user_id: int = None):
19
+ self.id = user_id
20
+ self.username = username
21
+ self.password_hash = password_hash
22
+ self.role = role
23
+
24
+ @staticmethod
25
+ def find_by_username(conn: pyodbc.Connection, username: str) -> Optional['User']:
26
+ """Find user by username"""
27
+ cur = conn.cursor()
28
+ cur.execute("SELECT id, username, password_hash, role FROM Users WHERE username = ?", (username,))
29
+ row = cur.fetchone()
30
+ if row:
31
+ return User(
32
+ user_id=row[0],
33
+ username=row[1],
34
+ password_hash=row[2],
35
+ role=row[3]
36
+ )
37
+ return None
38
+
39
+ @staticmethod
40
+ def create_user(conn: pyodbc.Connection, username: str, password_hash: str, role: str = 'user') -> bool:
41
+ """Create a new user"""
42
+ try:
43
+ cur = conn.cursor()
44
+ cur.execute(
45
+ "INSERT INTO Users (username, password_hash, role) VALUES (?, ?, ?)",
46
+ (username, password_hash, role)
47
+ )
48
+ conn.commit()
49
+ return True
50
+ except pyodbc.IntegrityError:
51
+ return False
52
+
53
+ @staticmethod
54
+ def get_all_users(conn: pyodbc.Connection) -> list:
55
+ """Get all users (admin only)"""
56
+ cur = conn.cursor()
57
+ cur.execute("SELECT id, username, role FROM Users ORDER BY id")
58
+ users = []
59
+ for row in cur.fetchall():
60
+ users.append({
61
+ "id": row[0],
62
+ "username": row[1],
63
+ "role": row[2]
64
+ })
65
+ return users
66
+
67
+ @staticmethod
68
+ def promote_to_admin(conn: pyodbc.Connection, username: str) -> bool:
69
+ """Promote user to admin role"""
70
+ cur = conn.cursor()
71
+ cur.execute("UPDATE Users SET role = 'admin' WHERE username = ?", (username,))
72
+ conn.commit()
73
+ return cur.rowcount > 0
74
+
75
+ @staticmethod
76
+ def user_count(conn: pyodbc.Connection) -> int:
77
+ """Get total user count"""
78
+ cur = conn.cursor()
79
+ cur.execute("SELECT COUNT(*) FROM Users")
80
+ return cur.fetchone()[0]
81
+
82
+ def to_dict(self) -> Dict[str, Any]:
83
+ """Convert user to dictionary (safe for JSON)"""
84
+ return {
85
+ "id": self.id,
86
+ "username": self.username,
87
+ "role": self.role
88
+ # Note: Never include password_hash in dict
89
+ }
90
+
91
+
92
+ class BlacklistedToken:
93
+ """Model for blacklisted JWT tokens"""
94
+
95
+ @staticmethod
96
+ def is_blacklisted(conn: pyodbc.Connection, token: str) -> bool:
97
+ """Check if token is blacklisted"""
98
+ cur = conn.cursor()
99
+ cur.execute("SELECT token FROM BlacklistedTokens WHERE token = ?", (token,))
100
+ return cur.fetchone() is not None
101
+
102
+ @staticmethod
103
+ def add_to_blacklist(conn: pyodbc.Connection, token: str) -> bool:
104
+ """Add token to blacklist"""
105
+ cur = conn.cursor()
106
+ # Check if already blacklisted
107
+ cur.execute("SELECT token FROM BlacklistedTokens WHERE token = ?", (token,))
108
+ if cur.fetchone():
109
+ return True # Already blacklisted
110
+
111
+ cur.execute("INSERT INTO BlacklistedTokens (token) VALUES (?)", (token,))
112
+ conn.commit()
113
+ return True
114
+
115
+
116
+ class RefreshToken:
117
+ """Model for refresh token management"""
118
+
119
+ @staticmethod
120
+ def find_by_token(conn: pyodbc.Connection, token: str) -> Optional[str]:
121
+ """Find username by refresh token"""
122
+ cur = conn.cursor()
123
+ cur.execute("SELECT username FROM RefreshTokens WHERE token = ?", (token,))
124
+ row = cur.fetchone()
125
+ return row[0] if row else None
126
+
127
+ @staticmethod
128
+ def create_token(conn: pyodbc.Connection, username: str, token: str) -> bool:
129
+ """Store refresh token"""
130
+ cur = conn.cursor()
131
+ cur.execute("INSERT INTO RefreshTokens (username, token) VALUES (?, ?)", (username, token))
132
+ conn.commit()
133
+ return True
134
+
135
+ @staticmethod
136
+ def delete_user_tokens(conn: pyodbc.Connection, username: str) -> bool:
137
+ """Delete all refresh tokens for user"""
138
+ cur = conn.cursor()
139
+ cur.execute("DELETE FROM RefreshTokens WHERE username = ?", (username,))
140
+ conn.commit()
141
+ return True
142
+
143
+
144
+ # Database table creation SQL
145
+ def get_table_definitions():
146
+ """Get SQL statements for creating authentication tables"""
147
+ return {
148
+ 'users': """
149
+ IF OBJECT_ID('Users', 'U') IS NULL
150
+ CREATE TABLE Users (
151
+ id INT IDENTITY(1,1) PRIMARY KEY,
152
+ username NVARCHAR(100) UNIQUE NOT NULL,
153
+ password_hash NVARCHAR(500) NOT NULL,
154
+ role NVARCHAR(50) DEFAULT 'user'
155
+ )
156
+ """,
157
+
158
+ 'blacklisted_tokens': """
159
+ IF OBJECT_ID('BlacklistedTokens', 'U') IS NULL
160
+ CREATE TABLE BlacklistedTokens (
161
+ id INT IDENTITY(1,1) PRIMARY KEY,
162
+ token NVARCHAR(1000) UNIQUE NOT NULL,
163
+ created_at DATETIME DEFAULT GETDATE()
164
+ )
165
+ """,
166
+
167
+ 'refresh_tokens': """
168
+ IF OBJECT_ID('RefreshTokens', 'U') IS NULL
169
+ CREATE TABLE RefreshTokens (
170
+ id INT IDENTITY(1,1) PRIMARY KEY,
171
+ username NVARCHAR(100) NOT NULL,
172
+ token NVARCHAR(1000) UNIQUE NOT NULL,
173
+ created_at DATETIME DEFAULT GETDATE(),
174
+ FOREIGN KEY (username) REFERENCES Users(username) ON DELETE CASCADE
175
+ )
176
+ """
177
+ }
auth/routes.py ADDED
@@ -0,0 +1,346 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Authentication routes and endpoints
3
+
4
+ Contains all authentication-related Flask routes:
5
+ - User registration and login
6
+ - Token refresh and logout
7
+ - Admin user management
8
+ - Database diagnostics
9
+ """
10
+
11
+ import os
12
+ import datetime
13
+ import bcrypt
14
+ import jwt
15
+ import pyodbc
16
+ from flask import Blueprint, request, jsonify, make_response, current_app
17
+
18
+ from .database import get_db_connection
19
+ from .models import User, BlacklistedToken, RefreshToken
20
+ from .utils import (
21
+ token_required,
22
+ anonymize_username,
23
+ add_cookie,
24
+ validate_user_input,
25
+ is_admin_user,
26
+ log_security_event
27
+ )
28
+
29
+ # Create authentication blueprint
30
+ auth_bp = Blueprint('auth', __name__)
31
+
32
+
33
+ @auth_bp.route("/dashboard")
34
+ @token_required
35
+ def dashboard(username):
36
+ """Protected dashboard endpoint"""
37
+ return jsonify({"message": f"Welcome {username} to your dashboard!"})
38
+
39
+
40
+ @auth_bp.route("/login", methods=["POST"])
41
+ def login():
42
+ """User login endpoint"""
43
+ data = request.json or {}
44
+ username = data.get('username', '').strip()
45
+ password = data.get('password', '')
46
+
47
+ # Input validation
48
+ is_valid, error_msg = validate_user_input(username, password)
49
+ if not is_valid:
50
+ return jsonify({"message": error_msg}), 400
51
+
52
+ # Normalize username to prevent case sensitivity issues
53
+ username = username.lower()
54
+
55
+ try:
56
+ conn = get_db_connection()
57
+ user = User.find_by_username(conn, username)
58
+ conn.close()
59
+ except Exception as e:
60
+ current_app.logger.exception("DB access error on login: %s", e)
61
+ return jsonify({"message": "Database is unavailable"}), 503
62
+
63
+ if not user:
64
+ log_security_event("failed_login", username, request.remote_addr, "user_not_found")
65
+ return jsonify({"message": "Invalid credentials"}), 401
66
+
67
+ if not bcrypt.checkpw(password.encode('utf-8'), user.password_hash.encode('utf-8')):
68
+ log_security_event("failed_login", username, request.remote_addr, "wrong_password")
69
+ return jsonify({"message": "Invalid credentials"}), 401
70
+
71
+ # Successful login
72
+ log_security_event("successful_login", username, request.remote_addr)
73
+
74
+ # Generate tokens
75
+ access_token = jwt.encode(
76
+ {'username': username, 'exp': datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(minutes=15)},
77
+ current_app.config['SECRET_KEY'],
78
+ algorithm="HS256"
79
+ )
80
+ refresh_token = jwt.encode(
81
+ {'username': username, 'exp': datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=7)},
82
+ current_app.config['SECRET_KEY'],
83
+ algorithm="HS256"
84
+ )
85
+
86
+ # Store refresh token
87
+ try:
88
+ conn = get_db_connection()
89
+ RefreshToken.create_token(conn, username, refresh_token)
90
+ conn.close()
91
+ except Exception as e:
92
+ current_app.logger.exception("DB write error on login: %s", e)
93
+ return jsonify({"message": "Database is unavailable"}), 503
94
+
95
+ resp = make_response(jsonify({"message": "Login successful"}))
96
+ add_cookie(resp, 'access_token', access_token, 900) # 15 min
97
+ add_cookie(resp, 'refresh_token', refresh_token, 7*24*60*60) # 7 days
98
+ return resp
99
+
100
+
101
+ @auth_bp.route("/refresh", methods=["POST"])
102
+ def refresh():
103
+ """Token refresh endpoint"""
104
+ refresh_token = request.cookies.get("refresh_token")
105
+ if not refresh_token:
106
+ return jsonify({'message': 'Refresh token is missing'}), 400
107
+
108
+ try:
109
+ payload = jwt.decode(refresh_token, current_app.config['SECRET_KEY'], algorithms=["HS256"])
110
+ except jwt.ExpiredSignatureError:
111
+ return jsonify({'message': 'Refresh token has expired'}), 401
112
+ except jwt.InvalidTokenError:
113
+ return jsonify({'message': 'Invalid refresh token'}), 401
114
+
115
+ try:
116
+ conn = get_db_connection()
117
+ username = RefreshToken.find_by_token(conn, refresh_token)
118
+ conn.close()
119
+ except Exception as e:
120
+ current_app.logger.exception("DB access error on refresh: %s", e)
121
+ return jsonify({"message": "Database is unavailable"}), 503
122
+
123
+ if not username:
124
+ return jsonify({'message': 'Invalid refresh token'}), 401
125
+
126
+ # Generate new access token
127
+ new_access = jwt.encode(
128
+ {'username': username, 'exp': datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(minutes=15)},
129
+ current_app.config['SECRET_KEY'],
130
+ algorithm="HS256"
131
+ )
132
+
133
+ resp = make_response(jsonify({'access_token': new_access}))
134
+ add_cookie(resp, 'access_token', new_access, 900)
135
+ return resp
136
+
137
+
138
+ @auth_bp.route("/logout", methods=["POST"])
139
+ @token_required
140
+ def logout(username):
141
+ """User logout endpoint"""
142
+ token = request.cookies.get('access_token')
143
+ if not token:
144
+ return jsonify({"message": "Invalid token format"}), 401
145
+
146
+ try:
147
+ conn = get_db_connection()
148
+
149
+ # Add to blacklist
150
+ BlacklistedToken.add_to_blacklist(conn, token)
151
+
152
+ # Delete refresh tokens
153
+ RefreshToken.delete_user_tokens(conn, username)
154
+
155
+ conn.close()
156
+
157
+ log_security_event("logout", username, request.remote_addr)
158
+
159
+ except Exception as e:
160
+ current_app.logger.exception("DB write error on logout: %s", e)
161
+ return jsonify({"message": "Database is unavailable"}), 503
162
+
163
+ resp = make_response(jsonify({"message": "Logged out successfully!"}))
164
+ resp.delete_cookie('access_token', path='/')
165
+ resp.delete_cookie('refresh_token', path='/')
166
+ return resp
167
+
168
+
169
+ @auth_bp.route("/check-auth", methods=["GET"])
170
+ @token_required
171
+ def check_auth(username):
172
+ """Check authentication status"""
173
+ return jsonify({"message": "Authenticated", "username": username}), 200
174
+
175
+
176
+ @auth_bp.route("/signup", methods=["POST"])
177
+ def signup():
178
+ """User registration endpoint"""
179
+ data = request.json or {}
180
+ username = data.get('username', '').strip()
181
+ password = data.get('password', '')
182
+
183
+ # Input validation
184
+ is_valid, error_msg = validate_user_input(username, password)
185
+ if not is_valid:
186
+ return jsonify({"message": error_msg}), 400
187
+
188
+ # Normalize username (prevent duplicates like "Admin" and "admin")
189
+ username = username.lower()
190
+
191
+ try:
192
+ conn = get_db_connection()
193
+
194
+ # Check if username already exists
195
+ if User.find_by_username(conn, username):
196
+ conn.close()
197
+ return jsonify({"message": "Username already exists"}), 409
198
+
199
+ # Hash password
200
+ password_hash = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt())
201
+
202
+ # Create new user
203
+ if User.create_user(conn, username, password_hash.decode('utf-8')):
204
+ conn.close()
205
+ log_security_event("user_registered", username, request.remote_addr)
206
+ return jsonify({"message": "User registered successfully"}), 201
207
+ else:
208
+ conn.close()
209
+ return jsonify({"message": "Username already exists"}), 409
210
+
211
+ except Exception as e:
212
+ current_app.logger.exception("DB error on signup: %s", e)
213
+ return jsonify({"message": "Database is unavailable"}), 503
214
+
215
+
216
+ @auth_bp.route("/admin/promote-user", methods=["POST"])
217
+ @token_required
218
+ def promote_user(username):
219
+ """Promote a user to admin role - ADMIN ONLY"""
220
+ try:
221
+ conn = get_db_connection()
222
+
223
+ # Check if current user is admin
224
+ if not is_admin_user(conn, username):
225
+ conn.close()
226
+ log_security_event("unauthorized_access", username, request.remote_addr, "promote-user")
227
+ return jsonify({"message": "Unauthorized - Admin access required"}), 403
228
+
229
+ # Get target username from request
230
+ data = request.json or {}
231
+ target_user = data.get('username', '').strip().lower()
232
+
233
+ if not target_user:
234
+ conn.close()
235
+ return jsonify({"message": "Username is required"}), 400
236
+
237
+ # Check if target user exists
238
+ target_user_obj = User.find_by_username(conn, target_user)
239
+ if not target_user_obj:
240
+ conn.close()
241
+ return jsonify({"message": "User not found"}), 404
242
+
243
+ if target_user_obj.role == 'admin':
244
+ conn.close()
245
+ return jsonify({"message": "User is already an admin"}), 400
246
+
247
+ # Promote user to admin
248
+ if User.promote_to_admin(conn, target_user):
249
+ conn.close()
250
+ log_security_event("user_promoted", username, request.remote_addr, f"promoted {target_user}")
251
+ return jsonify({"message": f"User {target_user} promoted to admin successfully"}), 200
252
+ else:
253
+ conn.close()
254
+ return jsonify({"message": "Failed to promote user"}), 500
255
+
256
+ except Exception as e:
257
+ current_app.logger.exception("DB error in promote-user: %s", e)
258
+ return jsonify({"message": "Database is unavailable"}), 503
259
+
260
+
261
+ @auth_bp.route("/admin/users", methods=["GET"])
262
+ @token_required
263
+ def list_users(username):
264
+ """List all users - ADMIN ONLY"""
265
+ try:
266
+ conn = get_db_connection()
267
+
268
+ # Check if current user is admin
269
+ if not is_admin_user(conn, username):
270
+ conn.close()
271
+ log_security_event("unauthorized_access", username, request.remote_addr, "list-users")
272
+ return jsonify({"message": "Unauthorized - Admin access required"}), 403
273
+
274
+ # Get all users
275
+ users = User.get_all_users(conn)
276
+ conn.close()
277
+
278
+ log_security_event("admin_action", username, request.remote_addr, "viewed_user_list")
279
+ return jsonify({"users": users, "total": len(users)}), 200
280
+
281
+ except Exception as e:
282
+ current_app.logger.exception("DB error in list-users: %s", e)
283
+ return jsonify({"message": "Database is unavailable"}), 503
284
+
285
+
286
+ @auth_bp.route("/admin/create-first-admin", methods=["POST"])
287
+ def create_first_admin():
288
+ """Create the first admin user - ONLY if no users exist"""
289
+ try:
290
+ conn = get_db_connection()
291
+
292
+ # Check if any users exist
293
+ if User.user_count(conn) > 0:
294
+ conn.close()
295
+ return jsonify({"message": "Users already exist. Cannot create first admin."}), 409
296
+
297
+ # Create first admin user
298
+ username = "admin"
299
+ password = "admin123" # Should be changed immediately
300
+
301
+ # Hash password
302
+ password_hash = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt())
303
+
304
+ # Create admin user
305
+ if User.create_user(conn, username, password_hash.decode('utf-8'), 'admin'):
306
+ conn.close()
307
+ log_security_event("first_admin_created", "system", request.remote_addr)
308
+ return jsonify({
309
+ "message": "First admin user created successfully",
310
+ "username": "admin",
311
+ "password": "admin123",
312
+ "warning": "CHANGE THE PASSWORD IMMEDIATELY!"
313
+ }), 201
314
+ else:
315
+ conn.close()
316
+ return jsonify({"message": "Failed to create admin user"}), 500
317
+
318
+ except Exception as e:
319
+ current_app.logger.exception("DB error creating first admin: %s", e)
320
+ return jsonify({"message": "Database is unavailable"}), 503
321
+
322
+
323
+ @auth_bp.route("/db/diag", methods=["GET"])
324
+ @token_required
325
+ def db_diag(username):
326
+ """Database diagnostics - ADMIN ONLY"""
327
+ try:
328
+ conn = get_db_connection()
329
+
330
+ # Security: Only allow admin users to access diagnostic information
331
+ if not is_admin_user(conn, username):
332
+ conn.close()
333
+ log_security_event("unauthorized_access", username, request.remote_addr, "db-diag")
334
+ return jsonify({"message": "Unauthorized - Admin access required"}), 403
335
+
336
+ conn.close()
337
+ except Exception as e:
338
+ current_app.logger.exception("DB access error in db_diag: %s", e)
339
+ return jsonify({"message": "Database is unavailable"}), 503
340
+
341
+ # Proceed with diagnostics for admin users only
342
+ from .database import get_database_info
343
+ info = get_database_info()
344
+
345
+ log_security_event("admin_action", username, request.remote_addr, "accessed_db_diagnostics")
346
+ return jsonify(info), 200
auth/utils.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Authentication utilities and security functions
3
+
4
+ Contains:
5
+ - JWT token validation decorator
6
+ - Security helpers
7
+ - Username anonymization for logging
8
+ - Cookie management utilities
9
+ """
10
+
11
+ import os
12
+ import jwt
13
+ import hashlib
14
+ from functools import wraps
15
+ from flask import request, jsonify, current_app, make_response
16
+ from .database import get_db_connection
17
+ from .models import BlacklistedToken
18
+
19
+
20
+ def anonymize_username(username):
21
+ """Create anonymous hash for logging while preserving uniqueness"""
22
+ if not username:
23
+ return "anonymous"
24
+ return hashlib.sha256(f"user_{username}_salt".encode()).hexdigest()[:12]
25
+
26
+
27
+ def token_required(f):
28
+ """
29
+ JWT token validation decorator
30
+
31
+ Validates access token from cookies and checks blacklist.
32
+ Returns username to the decorated function.
33
+ """
34
+ @wraps(f)
35
+ def decorated(*args, **kwargs):
36
+ token = request.cookies.get('access_token')
37
+ if not token:
38
+ return jsonify({"message": "Token is missing"}), 401
39
+
40
+ try:
41
+ # Check blacklist
42
+ conn = get_db_connection()
43
+ if BlacklistedToken.is_blacklisted(conn, token):
44
+ conn.close()
45
+ return jsonify({"message": "Token has been revoked. Please log in again."}), 401
46
+ conn.close()
47
+
48
+ # Decode and validate token
49
+ data = jwt.decode(token, current_app.config['SECRET_KEY'], algorithms=["HS256"])
50
+ return f(data['username'], *args, **kwargs)
51
+
52
+ except jwt.ExpiredSignatureError:
53
+ return jsonify({"message": "Token has expired"}), 401
54
+ except jwt.InvalidTokenError:
55
+ return jsonify({"message": "Invalid token"}), 401
56
+ except Exception as e:
57
+ current_app.logger.exception("Auth error: %s", e)
58
+ return jsonify({"message": "Server error"}), 500
59
+ return decorated
60
+
61
+
62
+ def extract_username_from_request(req) -> str | None:
63
+ """
64
+ Extract username from various sources in request
65
+
66
+ Checks in order:
67
+ 1. X-User header
68
+ 2. Request body JSON
69
+ 3. JWT cookie
70
+ """
71
+ # 1) Header
72
+ hdr = req.headers.get("X-User")
73
+ if hdr:
74
+ return hdr
75
+
76
+ # 2) Body
77
+ data = req.get_json(silent=True) or {}
78
+ if data.get("username"):
79
+ return data.get("username")
80
+
81
+ # 3) JWT cookie
82
+ token = req.cookies.get("access_token")
83
+ if token:
84
+ try:
85
+ payload = jwt.decode(token, current_app.config["SECRET_KEY"], algorithms=["HS256"])
86
+ return payload.get("username")
87
+ except jwt.ExpiredSignatureError:
88
+ return None
89
+ except jwt.InvalidTokenError:
90
+ return None
91
+
92
+ return None
93
+
94
+
95
+ def add_cookie(resp, name: str, value: str, max_age: int):
96
+ """
97
+ Add secure cookie to response
98
+
99
+ In prod: Secure + SameSite=None + Partitioned (works with third-party cookie protections).
100
+ In dev: SameSite=Lax, not Secure.
101
+ """
102
+ IS_PROD = os.getenv("ENV", "dev").lower() == "prod"
103
+
104
+ if IS_PROD:
105
+ resp.headers.add(
106
+ "Set-Cookie",
107
+ f"{name}={value}; Path=/; Max-Age={max_age}; Secure; HttpOnly; SameSite=None; Partitioned"
108
+ )
109
+ else:
110
+ resp.set_cookie(name, value, httponly=True, secure=False, samesite="Lax", max_age=max_age, path="/")
111
+
112
+
113
+ def validate_user_input(username: str, password: str) -> tuple[bool, str]:
114
+ """
115
+ Validate user input for signup/login
116
+
117
+ Returns: (is_valid, error_message)
118
+ """
119
+ if not username or not password:
120
+ return False, "Username and password are required"
121
+
122
+ if len(username) < 3 or len(username) > 50:
123
+ return False, "Username must be 3-50 characters"
124
+
125
+ if len(password) < 8:
126
+ return False, "Password must be at least 8 characters"
127
+
128
+ # Additional validation can be added here
129
+ # - Special character requirements
130
+ # - Username format validation
131
+ # - Password complexity checks
132
+
133
+ return True, ""
134
+
135
+
136
+ def is_admin_user(conn, username: str) -> bool:
137
+ """Check if user has admin role"""
138
+ from .models import User
139
+ user = User.find_by_username(conn, username)
140
+ return user is not None and user.role == 'admin'
141
+
142
+
143
+ def log_security_event(event_type: str, username: str, ip_address: str, details: str = ""):
144
+ """
145
+ Log security events with anonymized usernames
146
+
147
+ Args:
148
+ event_type: Type of security event (login, logout, failed_login, etc.)
149
+ username: Username (will be anonymized)
150
+ ip_address: Request IP address
151
+ details: Additional details about the event
152
+ """
153
+ user_hash = anonymize_username(username)
154
+ current_app.logger.info(
155
+ f"Security Event [{event_type}]: user_hash={user_hash}, ip={ip_address}, details={details}"
156
+ )
build_chroma_db.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import chromadb
4
+
5
+ # ==============================
6
+ # CONFIG
7
+ # ==============================
8
+ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
9
+ JSON_FILE = os.path.join(BASE_DIR, "assets/teacher_feedback_sentences_category.json")
10
+ CHROMA_DIR = os.path.join(BASE_DIR, "assets/chroma_db")
11
+ COLLECTION_NAME = "feedback"
12
+
13
+
14
+ def safe_float(x):
15
+ """Convert '000.000' or 124.944 to float."""
16
+ try:
17
+ return float(x)
18
+ except:
19
+ return 0.0
20
+
21
+
22
+ def load_segments(json_path):
23
+ with open(json_path, "r", encoding="utf-8") as f:
24
+ return json.load(f)
25
+
26
+
27
+ def build_chroma():
28
+ segments = load_segments(JSON_FILE)
29
+
30
+ # Create Chroma client
31
+ client = chromadb.PersistentClient(path=CHROMA_DIR)
32
+ collection = client.get_or_create_collection(COLLECTION_NAME)
33
+
34
+ # OPTIONAL: clear existing db (recommended if you already inserted wrong)
35
+ existing = collection.get()
36
+ existing_ids = existing.get("ids", [])
37
+ if existing_ids:
38
+ collection.delete(ids=existing_ids)
39
+ print(f"✅ Deleted old entries: {len(existing_ids)}")
40
+
41
+ ids = []
42
+ documents = []
43
+ metadatas = []
44
+
45
+ for seg in segments:
46
+ seg_id = seg.get("id")
47
+ text = seg.get("text", "").strip()
48
+ category = seg.get("category", "").strip()
49
+ video_file = seg.get("video_file", "").strip()
50
+
51
+ start = safe_float(seg.get("start"))
52
+ end = safe_float(seg.get("end"))
53
+
54
+ # metadata for chroma
55
+ meta = {
56
+ "category": category,
57
+ "video_file": video_file,
58
+ "start": start,
59
+ "end": end,
60
+ }
61
+
62
+ # store phoneme only if exists
63
+ if "phoneme" in seg and seg["phoneme"]:
64
+ meta["phoneme"] = seg["phoneme"].strip()
65
+
66
+ ids.append(seg_id)
67
+ documents.append(text)
68
+ metadatas.append(meta)
69
+
70
+ # Insert into ChromaDB
71
+ collection.add(
72
+ ids=ids,
73
+ documents=documents,
74
+ metadatas=metadatas
75
+ )
76
+
77
+ print("\n✅ ChromaDB created successfully!")
78
+ print(f"Total inserted: {len(ids)}")
79
+
80
+ # quick stats
81
+ vowels = [m for m in metadatas if m["category"] == "vowel"]
82
+ vowel_specific = [m for m in vowels if m.get("phoneme")]
83
+ consonants = [m for m in metadatas if m["category"] == "consonant"]
84
+ consonant_specific = [m for m in consonants if m.get("phoneme")]
85
+
86
+ print(f"Vowel total: {len(vowels)} | vowel specific: {len(vowel_specific)}")
87
+ print(f"Consonant total: {len(consonants)} | consonant specific: {len(consonant_specific)}")
88
+
89
+
90
+ if __name__ == "__main__":
91
+ build_chroma()
chat.py DELETED
@@ -1,246 +0,0 @@
1
- from flask import Flask, jsonify, send_file, abort, make_response, request, Blueprint, current_app
2
- from flask_cors import CORS
3
- import os
4
- print(f"GOOGLE_APPLICATION_CREDENTIALS: {os.getenv('GOOGLE_APPLICATION_CREDENTIALS')}")
5
- import io
6
- import uuid
7
- import requests
8
- import re
9
- import tempfile # needed by validate-pronounce
10
-
11
- app = Flask(__name__)
12
- CORS(app)
13
-
14
- # 👇 Add the helper right here
15
- def _cohere_headers():
16
- api_key = current_app.config.get("COHERE_API_KEY") or COHERE_API_KEY
17
- return {
18
- "Authorization": f"Bearer {api_key}",
19
- "Content-Type": "application/json",
20
- }
21
-
22
- @app.route('/')
23
- def home():
24
- return "Welcome to the Flask app! The server is running."
25
-
26
- # API configuration for AI-based question generation
27
- COHERE_API_KEY = os.getenv("COHERE_API_KEY", "")
28
- # (1) UPDATED URL: v2 endpoint on api.cohere.com
29
- COHERE_API_URL = 'https://api.cohere.com/v2/chat'
30
-
31
- # Dictionary to store user conversations
32
- user_sessions = {}
33
- # Endpoint to explain grammar topics
34
- movie_bp = Blueprint("movie", __name__)
35
-
36
- def _extract_text_v2(resp_json: dict) -> str:
37
- """
38
- v2 /chat returns:
39
- { "message": { "content": [ { "type": "text", "text": "..." } ] } }
40
- """
41
- msg = resp_json.get("message", {})
42
- content = msg.get("content", [])
43
- if isinstance(content, list) and content:
44
- block = content[0]
45
- if isinstance(block, dict):
46
- return (block.get("text") or "").strip()
47
- return ""
48
-
49
- def _cohere_generate(prompt: str, max_tokens: int = 1000, temperature: float = 0.7):
50
- api_key = current_app.config.get("COHERE_API_KEY") or COHERE_API_KEY
51
- if not api_key:
52
- return None, ("COHERE_API_KEY not set on the server", 500)
53
-
54
- headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
55
- # (2) UPDATED PAYLOAD: use messages instead of prompt
56
- payload = {
57
- "model": "command-r-08-2024",
58
- "messages": [
59
- {"role": "user", "content": prompt}
60
- ],
61
- "max_tokens": max_tokens,
62
- "temperature": temperature
63
- }
64
- try:
65
- r = requests.post(COHERE_API_URL, headers=headers, json=payload, timeout=30)
66
- if r.status_code != 200:
67
- return None, (f"Cohere API error: {r.text}", 502)
68
- # (3) UPDATED PARSING: read message.content[0].text
69
- text = _extract_text_v2(r.json())
70
- return text, None
71
- except Exception as e:
72
- current_app.logger.exception("Cohere request failed: %s", e)
73
- return None, ("Upstream request failed", 502)
74
-
75
- @movie_bp.post("/explain-grammar")
76
- def explain_grammar():
77
- try:
78
- data = request.get_json()
79
- print("Received Data:", data)
80
- topic = data.get('topic', '').strip()
81
- session_id = data.get('session_id', str(uuid.uuid4())) # Use provided session_id or create a new one
82
-
83
- if not topic:
84
- return jsonify({'error': 'Topic is required'}), 400
85
-
86
- # Retrieve previous conversation history
87
- conversation_history = user_sessions.get(session_id, [])
88
-
89
- # Keep the last 10 messages to maintain better context (adjustable)
90
- if len(conversation_history) > 10:
91
- conversation_history = conversation_history[-10:]
92
-
93
- # Generate a more **adaptive** prompt
94
- context = "\n".join(conversation_history) if conversation_history else ""
95
-
96
- prompt = f"""
97
- You are a highly skilled grammar assistant. Your job is to maintain a **dynamic conversation** and respond intelligently based on user input, If the user asks something **unrelated to grammar**, respond with: "Please send a grammar-related question..
98
-
99
- - Your answers must always **relate to the conversation history** and **extend naturally** based on what was previously asked.
100
- - Your answers must be **concise, clear, and to the point**
101
- - If the user asks for **examples**, explanations, or clarifications, **automatically infer** which topic they are referring to.
102
- - If the user's question is **vague**, determine the most **logical continuation** based on prior questions.
103
- - If the user asks something **unrelated to grammar**, respond with: "Please send a grammar-related question."
104
-
105
- **Conversation so far:**
106
- {context}
107
-
108
- **User's new question:** {topic}
109
- Please provide a **coherent and relevant answer** that continues the conversation naturally.
110
- """
111
-
112
- # Make the API call to Cohere
113
- headers = {
114
- 'Authorization': f'Bearer {COHERE_API_KEY}',
115
- 'Content-Type': 'application/json'
116
- }
117
-
118
- # (2) UPDATED PAYLOAD: messages array
119
- payload = {
120
- 'model': 'command-r-08-2024',
121
- 'messages': [
122
- {'role': 'user', 'content': prompt}
123
- ],
124
- 'max_tokens': 1000
125
- }
126
-
127
- response = requests.post(COHERE_API_URL, headers=headers, json=payload)
128
-
129
- if response.status_code == 200:
130
- # (3) UPDATED PARSING
131
- ai_response = _extract_text_v2(response.json())
132
-
133
- # Store conversation history to maintain context
134
- conversation_history.append(f"User: {topic}\nAI: {ai_response}")
135
- user_sessions[session_id] = conversation_history # Update session history
136
-
137
- return jsonify({'response': ai_response, 'session_id': session_id})
138
- else:
139
- return jsonify({'error': 'Failed to fetch data from Cohere API'}), 500
140
-
141
- except Exception as e:
142
- return jsonify({'error': str(e)}), 500
143
-
144
-
145
-
146
- @app.route('/suggest-grammar-questions', methods=['POST'])
147
- def suggest_grammar_questions():
148
- try:
149
- data = request.get_json()
150
- user_input = data.get('input', '').strip() # User's partial input (e.g., "What is v")
151
-
152
- if not user_input:
153
- return jsonify({'error': 'Input is required'}), 400
154
-
155
- prompt = f"""
156
- You are a grammar expert. Given the user's input "{user_input}", generate **3 natural grammar-related questions** that people might ask.
157
-
158
- - The user's input is a **partial or full grammar-related query**.
159
- - AI must **infer the most likely grammar topic** based on the input.
160
- - AI must **ensure all suggestions are strictly related to English grammar**.
161
- - **If the input is incomplete, intelligently complete it** with the most likely grammar concept.
162
- - Ensure all **questions are fully formed and relevant**.
163
-
164
- **User input:** "{user_input}"
165
- Provide exactly 3 well-structured, grammar-related questions:
166
- """
167
-
168
- # Call Cohere API
169
- headers = {
170
- 'Authorization': f'Bearer {COHERE_API_KEY}',
171
- 'Content-Type': 'application/json'
172
- }
173
-
174
- # (2) UPDATED PAYLOAD: messages array
175
- payload = {
176
- 'model': 'command-r-08-2024',
177
- 'messages': [
178
- {'role': 'user', 'content': prompt}
179
- ],
180
- 'max_tokens': 100,
181
- 'temperature': 0.9
182
- }
183
-
184
- response = requests.post(COHERE_API_URL, headers=headers, json=payload)
185
-
186
- if response.status_code == 200:
187
- # (3) UPDATED PARSING
188
- text = _extract_text_v2(response.json())
189
- suggestions = [s for s in (text or "").split("\n") if s.strip()]
190
- return jsonify({'suggestions': suggestions[:3]})
191
- # keep exactly 3 if more lines present
192
- else:
193
- return jsonify({'error': 'Failed to fetch suggestions', 'details': response.text}), 500
194
-
195
- except Exception as e:
196
- return jsonify({'error': str(e)}), 500
197
-
198
-
199
- def validate_topic(topic):
200
- validation_prompt = f"""
201
- You are an AI grammar expert. Your task is to determine if a given topic is related to **English grammar** or not.
202
-
203
- **Input:** "{topic}"
204
-
205
- ### **Rules:**
206
- - If the input is **in the form of a question** (e.g., it asks for an explanation or definition), return `"ask grammar topics"`, even if the topic is related to grammar.
207
- - If the topic is **related to English grammar concepts** such as **parts of speech**, **verb tenses**, **sentence structure**, etc., return `"Grammar"`.
208
- - If the topic is **not related to grammar**, such as general knowledge, science, math, history, or topics from other fields, return `"Not Grammar"`.
209
- - Your response must be based purely on whether the topic relates to grammar, and **not** based on specific words, phrases, or examples.
210
-
211
- **Your response must be exactly either "Grammar", "Not Grammar", or "ask grammar topics". No extra text.**
212
- """
213
-
214
- headers = {
215
- 'Authorization': f'Bearer {COHERE_API_KEY}',
216
- 'Content-Type': 'application/json'
217
- }
218
-
219
- # (2) UPDATED PAYLOAD: messages array
220
- payload = {
221
- 'model': 'command-r-08-2024',
222
- 'messages': [
223
- {'role': 'user', 'content': validation_prompt}
224
- ],
225
- 'max_tokens': 5
226
- }
227
-
228
- try:
229
- response = requests.post(COHERE_API_URL, json=payload, headers=headers)
230
- # (3) UPDATED PARSING
231
- validation_result = _extract_text_v2(response.json())
232
-
233
- # Ensure the response is strictly "Grammar" or "Not Grammar" or "ask grammar topics"
234
- if validation_result not in ["Grammar", "Not Grammar", "ask grammar topics"]:
235
- return "Not Grammar" # Fallback to avoid incorrect responses
236
-
237
- return validation_result
238
-
239
- except Exception as e:
240
- return f"Error: {str(e)}"
241
-
242
-
243
- if __name__ == '__main__':
244
- # app.run(debug=True)
245
- app.register_blueprint(movie_bp, url_prefix='') # expose /explain-grammar locally
246
- app.run(host='0.0.0.0', port=5012, debug=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
chroma_db/44944ef3-9b61-4c1b-bc5e-6a49750c0c54/length.bin DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:f8d329104353429c3a4fab240f87e7cba8ac17269bbfe57d26150d03cb34fa0a
3
- size 400
 
 
 
 
chroma_db/44944ef3-9b61-4c1b-bc5e-6a49750c0c54/link_lists.bin DELETED
File without changes
findingword.py DELETED
@@ -1,276 +0,0 @@
1
- import openai
2
- from flask import Flask, jsonify, request, send_from_directory, send_file, Blueprint, current_app, url_for
3
- import os
4
- from flask_cors import CORS
5
- import io # for streaming S3 bytes in HF/AWS mode
6
-
7
- # Optional (only used in AWS mode)
8
- try:
9
- import boto3
10
- from botocore.exceptions import BotoCoreError, ClientError
11
- except Exception:
12
- # Not required for local; will be imported dynamically in AWS mode
13
- boto3 = None
14
- BotoCoreError = ClientError = Exception
15
-
16
- app = Flask(__name__)
17
- CORS(app)
18
-
19
- # --- Blueprint ---
20
- finding_bp = Blueprint("findingword", __name__)
21
-
22
- # Directories for video, audio, and transcripts
23
- VIDEO_FOLDER = 'static/videos'
24
- AUDIO_FOLDER = 'static/audio' # used only in local mode
25
- TRANSCRIPT_FOLDER = 'static/transcripts'
26
-
27
- # --- OpenAI key handling (same as vocab builder) ---
28
- _OPENAI_API_KEY_FALLBACK = os.getenv("OPENAI_API_KEY", "")
29
-
30
- def _ensure_openai_key():
31
- """Set openai.api_key from Flask config or env before each API call."""
32
- api_key = (current_app.config.get("OPENAI_API_KEY") if current_app else None) or _OPENAI_API_KEY_FALLBACK
33
- if api_key:
34
- openai.api_key = api_key
35
-
36
- # ---------------------- audio-mode helpers ----------------------
37
- def _is_aws_mode() -> bool:
38
- """
39
- Switch to AWS Polly + S3 on Hugging Face / prod.
40
- Local stays on Google TTS + disk.
41
- """
42
- if os.getenv("USE_AWS_AUDIO", "0") == "1":
43
- return True
44
- if os.getenv("SPACE_ID"): # set on Hugging Face Spaces
45
- return True
46
- if os.getenv("ENV", "dev").lower() == "prod":
47
- return True
48
- return False
49
-
50
- def _sanitize_filename(word: str) -> str:
51
- # Keep your current style but ensure safe S3 key/filename
52
- return word.strip().replace(" ", "_").replace(".", "").lower()
53
-
54
- # ---------------------------------------------------------------------
55
-
56
- @finding_bp.route('/generate-vocabulary', methods=['GET'])
57
- def get_vocabulary_word_from_openai():
58
- prompt = (
59
- "Pick a simple vocabulary word suitable for children (ages 6–8) "
60
- "and provide its meaning in very easy English. Do not repeat words from previous responses. "
61
- "Format: 'Word: [word]. Meaning: [meaning].'"
62
- )
63
-
64
- try:
65
- _ensure_openai_key()
66
- response = openai.chat.completions.create(
67
- model="gpt-3.5-turbo",
68
- messages=[
69
- {"role": "system", "content": "You are a helpful assistant."},
70
- {"role": "user", "content": prompt},
71
- ]
72
- )
73
-
74
- result = response.choices[0].message.content.strip()
75
- print(f"Full Response: {result}")
76
-
77
- if "Word:" in result and "Meaning:" in result:
78
- parts = result.split("Meaning:")
79
- word = parts[0].replace("Word:", "").strip()
80
- word = word.rstrip('.') # avoid trailing dot
81
- meaning = parts[1].strip()
82
-
83
- # Generate the sentence
84
- sentence = generate_sentence(word, meaning)
85
-
86
- # Generate audio file for the vocabulary word
87
- audio_file_path_or_name = generate_audio(word) # local path or just filename in AWS mode
88
-
89
- # URL for frontend remains identical
90
- # audio_url = f"/static/audio/{os.path.basename(audio_file_path_or_name)}"
91
- audio_url = url_for("findingword.serve_audio",
92
- filename=os.path.basename(audio_file_path_or_name))
93
-
94
- return jsonify({
95
- "word": word,
96
- "meaning": meaning,
97
- "sentence": sentence,
98
- "audio_file_path": audio_url
99
- })
100
-
101
- else:
102
- return jsonify({"response": result, "message": "Meaning not provided in the expected format"})
103
-
104
- except Exception as e:
105
- return jsonify({"error": str(e)}), 500
106
-
107
-
108
- def generate_sentence(word, meaning):
109
- prompt = f"Create a sentence using the word '{word}' that fully demonstrates its meaning: {meaning}"
110
- _ensure_openai_key()
111
- response = openai.chat.completions.create(
112
- model="gpt-3.5-turbo",
113
- messages=[
114
- {"role": "system", "content": "You are a helpful assistant."},
115
- {"role": "user", "content": prompt},
116
- ]
117
- )
118
- sentence = response.choices[0].message.content.strip()
119
- return sentence
120
-
121
-
122
- def generate_audio(word):
123
- """
124
- Local (default): Google TTS → write MP3 to ./static/audio/<word>.mp3 → return full path.
125
- Hugging Face / AWS mode: Polly → upload to S3 (findingword/<word>.mp3) → return just the filename,
126
- and let /static/audio/<filename> stream from S3 (see route below).
127
- """
128
- sanitized_word = _sanitize_filename(word)
129
- filename = f"{sanitized_word}.mp3"
130
-
131
- if _is_aws_mode():
132
- # ---- AWS Polly + S3 path (no local write) ----
133
- if boto3 is None:
134
- raise RuntimeError("boto3 is required in AWS audio mode but not available")
135
-
136
- region = os.getenv("AWS_DEFAULT_REGION", "eu-north-1")
137
- bucket = os.getenv("S3_BUCKET_NAME")
138
- if not bucket:
139
- raise RuntimeError("S3_BUCKET_NAME is not set")
140
-
141
- polly = boto3.client("polly", region_name=region)
142
- s3 = boto3.client("s3", region_name=region)
143
-
144
- try:
145
- resp = polly.synthesize_speech(
146
- Text=word,
147
- OutputFormat="mp3",
148
- VoiceId=os.getenv("POLLY_VOICE_ID", "Joanna"),
149
- Engine=os.getenv("POLLY_ENGINE", "standard"),
150
- LanguageCode="en-US",
151
- )
152
- stream = resp.get("AudioStream")
153
- if not stream:
154
- raise RuntimeError("Polly returned no AudioStream")
155
- audio_bytes = stream.read()
156
- except (BotoCoreError, ClientError, Exception) as e:
157
- raise RuntimeError(f"Polly TTS failed: {e}")
158
-
159
- key = f"findingword/{filename}"
160
- try:
161
- s3.put_object(Bucket=bucket, Key=key, Body=audio_bytes, ContentType="audio/mpeg")
162
- except (BotoCoreError, ClientError, Exception) as e:
163
- raise RuntimeError(f"S3 upload failed: {e}")
164
-
165
- # Return only the filename; /static/audio/<filename> will proxy from S3
166
- return filename
167
-
168
- # ---- Local Google TTS path (lazy import; create dir here only) ----
169
- audio_dir = AUDIO_FOLDER
170
- try:
171
- os.makedirs(audio_dir, exist_ok=True)
172
- except Exception:
173
- # Fallback if CWD is restricted
174
- audio_dir = "/tmp/audio"
175
- os.makedirs(audio_dir, exist_ok=True)
176
-
177
- audio_file_path = os.path.join(audio_dir, filename)
178
-
179
- if not os.path.exists(audio_file_path):
180
- try:
181
- # Import only in local mode to avoid HF credential errors
182
- from google.cloud import texttospeech
183
- gcp_client = texttospeech.TextToSpeechClient()
184
- except Exception as e:
185
- raise RuntimeError(
186
- "Google TTS is required in local mode but missing. "
187
- "Install google-cloud-texttospeech and set GOOGLE_APPLICATION_CREDENTIALS. "
188
- f"Details: {e}"
189
- )
190
-
191
- synthesis_input = texttospeech.SynthesisInput(text=word)
192
- voice = texttospeech.VoiceSelectionParams(
193
- language_code="en-US", ssml_gender=texttospeech.SsmlVoiceGender.NEUTRAL
194
- )
195
- audio_config = texttospeech.AudioConfig(audio_encoding=texttospeech.AudioEncoding.MP3)
196
-
197
- response = gcp_client.synthesize_speech(
198
- input=synthesis_input, voice=voice, audio_config=audio_config
199
- )
200
-
201
- with open(audio_file_path, "wb") as out:
202
- out.write(response.audio_content)
203
-
204
- print(f"✅ Audio saved: {audio_file_path}")
205
-
206
- return audio_file_path
207
-
208
-
209
- @finding_bp.route('/validate-word', methods=['POST'])
210
- def validate_word():
211
- try:
212
- data = request.get_json()
213
- print("📥 Received data for validation:", data)
214
-
215
- if not data or 'user_input' not in data or 'correct_word' not in data:
216
- return jsonify({"error": "Invalid request, missing fields"}), 400
217
-
218
- user_input = data.get('user_input', '').strip()
219
- correct_word = data.get('correct_word', '').strip()
220
-
221
- if user_input.lower() == correct_word.lower():
222
- return jsonify({"status": "success", "message": "Correct! You typed the word correctly."})
223
- else:
224
- return jsonify({"status": "failure", "message": f"Incorrect. The correct word was '{correct_word}'."})
225
-
226
- except Exception as e:
227
- return jsonify({"error": str(e)}), 500
228
-
229
-
230
- @finding_bp.route('/static/audio/<filename>')
231
- def serve_audio(filename):
232
- """
233
- Local: serve from disk.
234
- AWS mode (HF): fetch the object from S3 and stream it (no local storage).
235
- """
236
- if _is_aws_mode():
237
- if boto3 is None:
238
- return jsonify({"error": "boto3 missing in AWS mode"}), 500
239
-
240
- region = os.getenv("AWS_DEFAULT_REGION", "eu-north-1")
241
- bucket = os.getenv("S3_BUCKET_NAME")
242
- if not bucket:
243
- return jsonify({"error": "S3_BUCKET_NAME not set"}), 500
244
-
245
- s3 = boto3.client("s3", region_name=region)
246
- key = f"findingword/{filename}"
247
-
248
- try:
249
- obj = s3.get_object(Bucket=bucket, Key=key)
250
- data = obj["Body"].read()
251
- return send_file(
252
- io.BytesIO(data),
253
- mimetype="audio/mpeg",
254
- download_name=filename,
255
- as_attachment=False
256
- )
257
- except (BotoCoreError, ClientError, Exception) as e:
258
- return jsonify({"error": f"S3 fetch failed: {str(e)}"}), 404
259
-
260
- # Local: serve file from disk as before (with /tmp fallback)
261
- local_path = os.path.join(AUDIO_FOLDER, filename)
262
- if os.path.exists(local_path):
263
- return send_from_directory(AUDIO_FOLDER, filename)
264
-
265
- alt_dir = "/tmp/audio"
266
- alt_path = os.path.join(alt_dir, filename)
267
- if os.path.exists(alt_path):
268
- return send_from_directory(alt_dir, filename)
269
-
270
- return jsonify({"error": "File not found"}), 404
271
-
272
-
273
- # Run the Flask server (local dev): keep URLs unchanged by registering with empty prefix
274
- if __name__ == '__main__':
275
- app.register_blueprint(finding_bp, url_prefix='') # Local: /generate-vocabulary, /validate-word, /static/audio/...
276
- app.run(host='0.0.0.0', port=5005, debug=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
generateQuestion.py DELETED
@@ -1,535 +0,0 @@
1
- """
2
- Grammar Question Generation and Validation Module
3
-
4
- This module provides endpoints for:
5
- - Generating fill-in-the-blank grammar questions at various difficulty levels
6
- - Batch validating user answers with AI-powered feedback
7
- - Providing hints for incorrect answers
8
-
9
- All AI operations are powered by Cohere's API v2.
10
- """
11
-
12
- import logging
13
- import os
14
- from typing import Optional, Dict, Any, List
15
-
16
- import requests
17
- from flask import Blueprint, jsonify, request, current_app
18
-
19
- # ------------------------------------------------------------------------------
20
- # Configuration Constants
21
- # ------------------------------------------------------------------------------
22
- COHERE_API_URL = 'https://api.cohere.com/v2/chat'
23
- COHERE_MODEL = 'command-r-08-2024'
24
-
25
- # Token limits for different operations
26
- TOKEN_LIMITS = {
27
- 'validation': 5,
28
- 'answer_validation_detailed': 200,
29
- 'hint_generation': 250,
30
- 'question_generation': 1000
31
- }
32
-
33
- # Request timeouts (seconds)
34
- API_TIMEOUT = 30
35
-
36
- # Difficulty levels
37
- VALID_DIFFICULTIES = ['basic', 'intermediate', 'expert']
38
-
39
- # Validation response types
40
- VALIDATION_RESPONSES = ['Grammar', 'Not Grammar', 'ask grammar topics']
41
-
42
- # ------------------------------------------------------------------------------
43
- # Blueprint Setup
44
- # ------------------------------------------------------------------------------
45
- questions_bp = Blueprint('questions', __name__)
46
-
47
- # Configure logging
48
- logger = logging.getLogger(__name__)
49
- logger.setLevel(logging.INFO)
50
-
51
- # ------------------------------------------------------------------------------
52
- # Helper Functions
53
- # ------------------------------------------------------------------------------
54
-
55
- def _get_cohere_headers() -> Optional[Dict[str, str]]:
56
- """
57
- Get Cohere API headers with authentication.
58
-
59
- Prefers API key from Flask app config, falls back to environment variable.
60
-
61
- Returns:
62
- Dict containing Authorization and Content-Type headers, or None if key not found.
63
- """
64
- api_key = current_app.config.get('COHERE_API_KEY') or os.getenv('COHERE_API_KEY', '')
65
-
66
- if not api_key:
67
- logger.error('COHERE_API_KEY is not configured')
68
- return None
69
-
70
- return {
71
- 'Authorization': f'Bearer {api_key}',
72
- 'Content-Type': 'application/json',
73
- }
74
-
75
-
76
- def _extract_text_from_cohere_v2_response(response_json: Dict[str, Any]) -> str:
77
- """
78
- Extract text content from Cohere API v2 response.
79
-
80
- The v2 /chat endpoint returns:
81
- {
82
- "message": {
83
- "content": [
84
- {"type": "text", "text": "..."}
85
- ]
86
- }
87
- }
88
-
89
- Args:
90
- response_json: The JSON response from Cohere API
91
-
92
- Returns:
93
- Extracted text content or empty string if not found
94
- """
95
- message = response_json.get('message', {})
96
- content = message.get('content', [])
97
-
98
- if isinstance(content, list) and content:
99
- first_block = content[0]
100
- if isinstance(first_block, dict):
101
- return (first_block.get('text') or '').strip()
102
-
103
- return ''
104
-
105
-
106
- def _call_cohere_api(prompt: str, max_tokens: int, temperature: float = 0.7) -> Optional[str]:
107
- """
108
- Make a call to Cohere API with standardized error handling.
109
-
110
- Args:
111
- prompt: The prompt to send to the AI
112
- max_tokens: Maximum tokens for the response
113
- temperature: Temperature for response generation (0.0-1.0)
114
-
115
- Returns:
116
- The AI response text, or None if an error occurred
117
- """
118
- headers = _get_cohere_headers()
119
- if not headers:
120
- logger.error('Cannot call Cohere API: headers not available')
121
- return None
122
-
123
- payload = {
124
- 'model': COHERE_MODEL,
125
- 'messages': [
126
- {'role': 'user', 'content': prompt}
127
- ],
128
- 'max_tokens': max_tokens,
129
- 'temperature': temperature
130
- }
131
-
132
- try:
133
- response = requests.post(
134
- COHERE_API_URL,
135
- json=payload,
136
- headers=headers,
137
- timeout=API_TIMEOUT
138
- )
139
-
140
- if response.status_code == 200:
141
- return _extract_text_from_cohere_v2_response(response.json())
142
- else:
143
- logger.error(
144
- f'Cohere API returned status {response.status_code}: {response.text}'
145
- )
146
- return None
147
-
148
- except requests.exceptions.Timeout:
149
- logger.error(f'Cohere API request timed out after {API_TIMEOUT} seconds')
150
- return None
151
- except requests.exceptions.RequestException as e:
152
- logger.error(f'Cohere API request failed: {str(e)}')
153
- return None
154
- except Exception as e:
155
- logger.error(f'Unexpected error calling Cohere API: {str(e)}')
156
- return None
157
-
158
-
159
- def _validate_input_length(text: str, max_length: int = 500) -> bool:
160
- """
161
- Validate that input text doesn't exceed maximum length.
162
-
163
- Args:
164
- text: Input text to validate
165
- max_length: Maximum allowed length
166
-
167
- Returns:
168
- True if valid, False otherwise
169
- """
170
- return len(text.strip()) <= max_length
171
-
172
-
173
- def _get_question_generation_prompt(topic: str, difficulty: str) -> str:
174
- """
175
- Get the appropriate prompt for question generation based on difficulty.
176
-
177
- Args:
178
- topic: The grammar topic
179
- difficulty: The difficulty level (basic, intermediate, expert)
180
-
181
- Returns:
182
- The formatted prompt string
183
- """
184
- if difficulty == 'basic':
185
- return f"""
186
- Generate five **completely new and unique** very basic-level fill-in-the-blank grammar questions **every time** on the topic '{topic}'.
187
-
188
- ### Rules:
189
- - Generate five unique fill-in-the-blank grammar questions based on the topic '{topic}'.
190
- - Each question must have exactly one blank represented by '_______' (not two blanks or underscores inside the sentence).
191
- - Each question must have a different theme for variety.
192
- - Use different sentence structures; avoid predictable patterns.
193
- - Avoid long words or abstract concepts.
194
- - Focus on the topic '{topic}', and ensure the blank is the key part of speech.
195
- - Each question must include the correct answer in parentheses at the end.
196
- - Do not include any explanations or instructions—only the five questions.
197
- """
198
-
199
- elif difficulty == 'intermediate':
200
- return f"""
201
- Generate five **completely new and unique** intermediate-level fill-in-the-blank grammar questions **every time** on the topic '{topic}'.
202
-
203
- ### Rules:
204
- - Generate five unique fill-in-the-blank grammar questions based on the topic '{topic}'.
205
- - Each question must have exactly one blank represented by '_______'.
206
- - Slightly more challenging than basic-level; use a wider range of sentence structures and vocabulary.
207
- - Each question must have a different theme.
208
- - Sentences should be longer and include more detail.
209
- - Focus on the topic '{topic}', and ensure the blank is the key part of speech.
210
- - Each question must include the correct answer in parentheses at the end.
211
- - Do not include any explanations or instructions—only the five questions.
212
- """
213
-
214
- else: # expert
215
- return f"""
216
- Generate five **completely new and unique** advanced-level (C1) fill-in-the-blank grammar questions **every time** on the topic '{topic}'.
217
-
218
- ### Rules:
219
- - Generate five unique fill-in-the-blank grammar questions based on the topic '{topic}'.
220
- - Each question must have exactly one blank represented by '_______'.
221
- - More challenging than intermediate (C1); require expert-level mastery of grammar and context.
222
- - Ensure varied and sophisticated vocabulary; avoid basic words.
223
- - Each question should require nuanced comprehension; test advanced grammar patterns.
224
- - The blank must be the key part of the sentence (not an obvious answer).
225
- - Each question must include the correct answer in parentheses at the end.
226
- - Do not include any explanations or instructions—only the five questions.
227
- """
228
-
229
-
230
- # ------------------------------------------------------------------------------
231
- # Core Functions
232
- # ------------------------------------------------------------------------------
233
-
234
- def validate_topic(topic: str) -> str:
235
- """
236
- Validate whether a given topic is related to English grammar.
237
-
238
- Args:
239
- topic: The topic to validate
240
-
241
- Returns:
242
- One of: 'Grammar', 'Not Grammar', 'ask grammar topics', or an error message
243
- """
244
- if not _validate_input_length(topic, max_length=200):
245
- return 'Not Grammar'
246
-
247
- validation_prompt = f"""
248
- You are a highly knowledgeable AI grammar expert. Your task is to evaluate whether the given topic relates to **English grammar** or not.
249
-
250
- **Input Topic:** "{topic}"
251
-
252
- ### **Instructions:**
253
- - If the input **exactly refers to** grammar concepts (such as **parts of speech**, **verb tenses**, **sentence structure**, **grammar rules**, etc.), respond with `"Grammar"`.
254
- - If the input **seems to be a general question or concept** that is **not directly related to grammar**, such as general knowledge, science, history, or unrelated fields, respond with `"Not Grammar"`.
255
- - If the input is in the form of a **question** (e.g., "What is subject-verb agreement?"), respond with `"ask grammar topics"`.
256
- - If the topic refers to a **specific grammar concept** (e.g., **noun**, **verb**, **preposition**, **past tense**, etc.), always classify it as `"Grammar"`.
257
- - **Do not include any explanations or examples**. Your answer must only be `"Grammar"`, `"Not Grammar"`, or `"ask grammar topics"`, depending on whether the topic is relevant to grammar.
258
- - If the input is **unclear**, err on the side of classifying it as `"Not Grammar"` rather than `"Grammar"`.
259
-
260
- Your response must only be one of these three options:
261
- - `"Grammar"`
262
- - `"Not Grammar"`
263
- - `"ask grammar topics"`
264
- No extra text or explanation.
265
- """
266
-
267
- result = _call_cohere_api(
268
- validation_prompt,
269
- max_tokens=TOKEN_LIMITS['validation'],
270
- temperature=0.3
271
- )
272
-
273
- if result is None:
274
- return 'Error: Unable to validate topic'
275
-
276
- if result not in VALIDATION_RESPONSES:
277
- return 'Not Grammar'
278
-
279
- return result
280
-
281
-
282
- def validate_single_answer(topic: str, question: str, user_answer: str) -> str:
283
- """
284
- Validate a single answer using AI.
285
-
286
- Args:
287
- topic: The grammar topic
288
- question: The question being answered
289
- user_answer: The user's answer
290
-
291
- Returns:
292
- Validation response from the AI
293
- """
294
- prompt = f"""
295
- You are a highly knowledgeable grammar assistant. Validate whether the user's answer to the following question is correct or not based on {topic}. If the answer is incorrect, provide a helpful hint.
296
-
297
- Topic: {topic}
298
- Question: "{question}"
299
- User's Answer: "{user_answer}"
300
-
301
- Is the answer correct? If not, please explain why and give a hint.
302
- """
303
-
304
- result = _call_cohere_api(
305
- prompt,
306
- max_tokens=TOKEN_LIMITS['answer_validation_detailed'],
307
- temperature=0.7
308
- )
309
-
310
- if result is None:
311
- return 'Error: Unable to validate answer'
312
-
313
- return result
314
-
315
-
316
- def generate_hint(topic: str, question: str, user_answer: str) -> str:
317
- """
318
- Generate a helpful hint for an incorrect answer.
319
-
320
- Args:
321
- topic: The grammar topic
322
- question: The question
323
- user_answer: The user's incorrect answer
324
-
325
- Returns:
326
- A helpful hint without revealing the answer
327
- """
328
- prompt = f"""
329
- You are a highly skilled grammar assistant. Your task is to generate a helpful hint for the user to improve their answer based on the following question.
330
-
331
- Topic: {topic}
332
- Question: "{question}"
333
- User's Answer: "{user_answer}"
334
-
335
- If the user's answer is incorrect, provide a specific, actionable hint to help the user correct their answer.
336
- The hint should include:
337
- - Explanation of the error made by the user.
338
- - A hint on the correct grammatical structure or word form.
339
- - A hint on how to structure the sentence correctly **without revealing the exact answer**.
340
-
341
- Please make sure the hint is **clear** and **helpful** for the user, **without revealing the correct answer**.
342
- """
343
-
344
- result = _call_cohere_api(
345
- prompt,
346
- max_tokens=TOKEN_LIMITS['hint_generation'],
347
- temperature=0.7
348
- )
349
-
350
- if result is None:
351
- return 'Error: Unable to generate hint'
352
-
353
- return result
354
-
355
-
356
- # ------------------------------------------------------------------------------
357
- # API Endpoints
358
- # ------------------------------------------------------------------------------
359
-
360
- @questions_bp.post('/generate-questions')
361
- def generate_questions():
362
- """
363
- Generate grammar questions based on topic and difficulty.
364
-
365
- Expected JSON payload:
366
- {
367
- "topic": "string",
368
- "difficulty": "basic" | "intermediate" | "expert"
369
- }
370
-
371
- Returns:
372
- JSON response with generated questions or error message
373
- """
374
- try:
375
- data = request.get_json()
376
-
377
- if not data:
378
- return jsonify({'error': 'Request body must be JSON'}), 400
379
-
380
- # Extract and validate inputs
381
- topic = data.get('topic', '').strip()
382
- difficulty = data.get('difficulty', 'basic').lower()
383
-
384
- if not topic:
385
- return jsonify({'error': 'Topic is required'}), 400
386
-
387
- if not _validate_input_length(topic, max_length=200):
388
- return jsonify({'error': 'Topic exceeds maximum length of 200 characters'}), 400
389
-
390
- if difficulty not in VALID_DIFFICULTIES:
391
- return jsonify({
392
- 'error': f'Invalid difficulty level. Must be one of: {", ".join(VALID_DIFFICULTIES)}'
393
- }), 400
394
-
395
- # Validate topic is grammar-related
396
- validation_result = validate_topic(topic)
397
-
398
- if validation_result.startswith('Error:'):
399
- logger.error(f'Topic validation error: {validation_result}')
400
- return jsonify({'error': 'Unable to validate topic at this time'}), 500
401
-
402
- if validation_result != 'Grammar':
403
- return jsonify({
404
- 'message': 'Please enter a valid **grammar topic**, not a general word or unrelated question.'
405
- }), 400
406
-
407
- logger.info(f'Generating {difficulty} questions for topic: {topic}')
408
-
409
- # Generate questions
410
- prompt = _get_question_generation_prompt(topic, difficulty)
411
- result = _call_cohere_api(
412
- prompt,
413
- max_tokens=TOKEN_LIMITS['question_generation'],
414
- temperature=0.8
415
- )
416
-
417
- if result is None:
418
- return jsonify({
419
- 'error': 'Failed to generate questions',
420
- 'details': 'Unable to reach AI service'
421
- }), 500
422
-
423
- return jsonify({'text': result}), 200
424
-
425
- except Exception as e:
426
- logger.exception(f'Unexpected error in generate_questions: {str(e)}')
427
- return jsonify({'error': 'An unexpected error occurred'}), 500
428
-
429
-
430
- @questions_bp.post('/validate-all-answers')
431
- def validate_all_answers():
432
- """
433
- Validate multiple answers at once (batch validation).
434
-
435
- Expected JSON payload:
436
- {
437
- "questions": [
438
- {
439
- "topic": "string",
440
- "question": "string",
441
- "user_answer": "string"
442
- }
443
- ]
444
- }
445
-
446
- Returns:
447
- JSON response with validation results for all questions
448
- """
449
- try:
450
- data = request.get_json()
451
-
452
- if not data:
453
- return jsonify({'error': 'Request body must be JSON'}), 400
454
-
455
- questions = data.get('questions', [])
456
-
457
- if not questions:
458
- return jsonify({'error': 'No questions provided'}), 400
459
-
460
- if not isinstance(questions, list):
461
- return jsonify({'error': 'Questions must be an array'}), 400
462
-
463
- if len(questions) > 50:
464
- return jsonify({'error': 'Maximum 50 questions allowed per request'}), 400
465
-
466
- validation_results = []
467
-
468
- for item in questions:
469
- if not isinstance(item, dict):
470
- validation_results.append({
471
- 'error': 'Invalid question format'
472
- })
473
- continue
474
-
475
- topic = item.get('topic', '').strip()
476
- question = item.get('question', '').strip()
477
- user_answer = item.get('user_answer', '').strip()
478
-
479
- if not all([topic, question, user_answer]):
480
- validation_results.append({
481
- 'question': question,
482
- 'error': 'Missing required fields (topic, question, or user_answer)'
483
- })
484
- continue
485
-
486
- # Validate input lengths
487
- if not _validate_input_length(topic, 200) or not _validate_input_length(question, 500) or not _validate_input_length(user_answer, 500):
488
- validation_results.append({
489
- 'question': question,
490
- 'error': 'Input exceeds maximum length'
491
- })
492
- continue
493
-
494
- # Validate the answer
495
- validation_response = validate_single_answer(topic, question, user_answer)
496
-
497
- # Generate hint if answer is incorrect
498
- hint = None
499
- if isinstance(validation_response, str) and (
500
- 'incorrect' in validation_response.lower() or
501
- 'not correct' in validation_response.lower()
502
- ):
503
- hint = generate_hint(topic, question, user_answer)
504
-
505
- validation_results.append({
506
- 'question': question,
507
- 'user_answer': user_answer,
508
- 'validation_response': validation_response,
509
- 'hint': hint
510
- })
511
-
512
- return jsonify({'results': validation_results}), 200
513
-
514
- except Exception as e:
515
- logger.exception(f'Unexpected error in validate_all_answers: {str(e)}')
516
- return jsonify({'error': 'An unexpected error occurred'}), 500
517
-
518
-
519
- # ------------------------------------------------------------------------------
520
- # Health Check
521
- # ------------------------------------------------------------------------------
522
-
523
- @questions_bp.get('/health')
524
- def health():
525
- """
526
- Health check endpoint for the questions service.
527
-
528
- Returns:
529
- JSON response indicating service status
530
- """
531
- return jsonify({
532
- 'status': 'healthy',
533
- 'service': 'grammar-questions',
534
- 'cohere_api_configured': bool(_get_cohere_headers())
535
- }), 200
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
googlecredentails.json DELETED
@@ -1,13 +0,0 @@
1
- {
2
- "type": "service_account",
3
- "project_id": "map-pykara-1551704617990",
4
- "private_key_id": "a124b056c0d611f0b5845f343d1210e8c2bad0fc",
5
- "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDHsBOK9V61bKle\niq/2P/fNJ51JcLvi0xcpkQTtpvaFfEX+b6ACE6rOQf1M8+s3oCXENn7MN8XXUyuj\nGMPUPnXSOujuEA2d+0oi7bUifkucPNhbBqHcymp3XM8tt6/75Vfc0avXapHDe2td\nyEQ8WoisbzvdPzy7r1v//8aeEQ10gKZiKDWqVWXdyrNq48zVbMpdwWmLJm92aFpl\nIRStypAerewPZaNF7qACvVwHMXh6bIebr8gPg8gyOTnf5MVu80esW1CC2DSwX5SF\nIRjNcT7Yrb/O0s8awgULLBcyPp/4LpWjJP/l79Bu3+tm2NPU7Qkc55Q3PIA01Fr0\nJeZPXMpLAgMBAAECggEAM/kqpPzHOT4+ePps4RF2LEH2yLIcXOWnPizeFQLjYAvf\n5eDkyxWWW9fGF1zXKGO44LB0YS/VAP3HOkyMT7YwEVc+4BNyj99jROMMxZ0Mje4y\nO8LmpyJSAp432ETae5wOOc2ixc8ZgEEjyUWCKRlBQGw1Lxkx9AGo1uEaL3Ltxxfs\ns/JzY4i2gVVqoCD7dUSFWt7rnjTm0LXz+cQlCMSJVOnpj3rMhSCGsj0dkJusW+BP\nF59yjhNVCs92MS5VidU/Ud8XDjbLzaSdsXATTZ5UGFBnSARNqqa921jBdkgS5/9d\n+KY13w7Se28lkDgR7EvSTCXdWjTvcA+yAW3/4frQyQKBgQD5VNOLwtUOzH5zh8tF\nRye103zqRvag1cR2CeyqaEkBCznVVKDfVvgkTM0XqxwA1p4bQjMvTLmdntI1ubjd\ndcS+t0042xevqEeIfkTA4bB8QsdODDCxWPhmH4HwXMahbfCpm5gAdGalf6qcDt0j\n/lW0S2WowXC2yVzB3hc2tGHpgwKBgQDNB1cXBzRPBcXYcX+JObMh6+IkWHozpw9D\nryaJISAgBF/MW9ZVEexkmhBbcj1b+MWfGa/U4gIXks1RkOiPyn6ywL7tDOLV1+tV\nOlG/dWepWtfuHkdaLDBIhnxykGDqpU/0Y4R3JrmofO1r3lc/uPgUUltVg3bNLerI\nqtb2vnDpmQKBgC4pvHk1+4if6BGv5LzQ7dNGcuxVczhYG+XW9JCgelPNJkoPPzHa\nwlrGNXraXXbyRZe6bAun4v4B992mo0mtkl3VRmDuf7YwK/5jkos7vhdjrc8Phrxv\nQp512vML2mLtHg/pFP2Qj6i4uHfocJ1Ha8rT4uCZ4CqXoarrWdTxFOfNAoGAFwjj\nFPg/PT2Vy8p8nKs53+7DenfiStlTErSj7LYnCNHU/X2359jaqTbR7aQ5FpMtiMF3\nCsDVoVZh8O8J4dXLREP5b2KKPaJDk1C5DHyhR9qn9d27AHuEdTF+29Qyv0oRYJCp\nukVEiJR4jCzvun4KiSXzkvjxKP4mqaLgAdrFjskCgYEArF/mdtBotpOtI22CrWQ2\nG9kKR9USSHik39lj8thANirF/jLdcEea0c/WvLE7tcuJqcJ0hhGZVtoiKVWDyTTJ\nncwRdGHGCau5p5a1gWca/NgXGhUnq3X6AehUcBu4xJnP2Y/PMiAxiBWBRw08ZyNk\nQUyDANxdQVM9B0R8sqPbwDM=\n-----END PRIVATE KEY-----\n",
6
- "client_email": "learnenglishai@map-pykara-1551704617990.iam.gserviceaccount.com",
7
- "client_id": "106031173963438453050",
8
- "auth_uri": "https://accounts.google.com/o/oauth2/auth",
9
- "token_uri": "https://oauth2.googleapis.com/token",
10
- "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
11
- "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/learnenglishai%40map-pykara-1551704617990.iam.gserviceaccount.com",
12
- "universe_domain": "googleapis.com"
13
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
listen.py DELETED
@@ -1,436 +0,0 @@
1
- # listen.py
2
- from flask import Flask, Blueprint, jsonify, send_file, abort, request, send_from_directory
3
- from flask_cors import CORS
4
- from moviepy.editor import VideoFileClip
5
- from google.cloud import speech
6
- import os
7
- print(f"GOOGLE_APPLICATION_CREDENTIALS: {os.getenv('GOOGLE_APPLICATION_CREDENTIALS')}")
8
- import uuid
9
- import requests
10
- from pydub import AudioSegment
11
- import ffmpeg
12
- import re
13
- import io # for streaming S3 bytes in HF/AWS mode
14
- import json # <-- added for JSON creds parsing
15
-
16
- # Optional (only used in AWS mode)
17
- try:
18
- import boto3
19
- from botocore.exceptions import BotoCoreError, ClientError
20
- except Exception:
21
- boto3 = None
22
- BotoCoreError = ClientError = Exception
23
-
24
- # ---------- Blueprint ----------
25
- listen_bp = Blueprint("listen", __name__)
26
-
27
- # ---------------------- storage mode helpers ----------------------
28
- def _is_aws_video_mode() -> bool:
29
- """
30
- Switch to S3 on Hugging Face / prod. Local stays on disk.
31
- """
32
- if os.getenv("USE_AWS_VIDEO", "0") == "1":
33
- return True
34
- if os.getenv("SPACE_ID"): # set on Hugging Face Spaces
35
- return True
36
- if os.getenv("ENV", "dev").lower() == "prod":
37
- return True
38
- return False
39
-
40
- def _s3_clients():
41
- if boto3 is None:
42
- raise RuntimeError("boto3 is required in AWS video mode but not available")
43
- region = os.getenv("AWS_DEFAULT_REGION", "eu-north-1")
44
- s3 = boto3.client("s3", region_name=region)
45
- return s3
46
-
47
- def _video_s3_bucket():
48
- bucket = os.getenv("S3_BUCKET_NAME")
49
- if not bucket:
50
- raise RuntimeError("S3_BUCKET_NAME is not set")
51
- return bucket
52
-
53
- def _video_s3_key(filename: str) -> str:
54
- # Prefix under which listen.py stores videos in the same bucket
55
- prefix = os.getenv("LISTEN_S3_PREFIX", "listen")
56
- prefix = prefix.strip().strip("/")
57
- return f"{prefix}/{filename}"
58
-
59
- # ---------- writable working directories ----------
60
- # Base working dir: /tmp on HF/AWS; local stays under ./static (or override via LISTEN_WORKDIR)
61
- _BASE_WORKDIR = os.getenv(
62
- "LISTEN_WORKDIR",
63
- "/tmp/listen" if _is_aws_video_mode() else os.path.abspath("static")
64
- )
65
-
66
- VIDEO_FOLDER = os.path.join(_BASE_WORKDIR, "videos")
67
- AUDIO_FOLDER = os.path.join(_BASE_WORKDIR, "audio")
68
- TRANSCRIPT_FOLDER = os.path.join(_BASE_WORKDIR, "transcripts")
69
-
70
- # Ensure directories exist (with hard fallback to /tmp if needed)
71
- for _pname in ("videos", "audio", "transcripts"):
72
- _p = os.path.join(_BASE_WORKDIR, _pname)
73
- try:
74
- os.makedirs(_p, exist_ok=True)
75
- except Exception:
76
- _fallback_base = "/tmp/listen"
77
- os.makedirs(os.path.join(_fallback_base, _pname), exist_ok=True)
78
- if _pname == "videos":
79
- VIDEO_FOLDER = os.path.join(_fallback_base, "videos")
80
- elif _pname == "audio":
81
- AUDIO_FOLDER = os.path.join(_fallback_base, "audio")
82
- else:
83
- TRANSCRIPT_FOLDER = os.path.join(_fallback_base, "transcripts")
84
-
85
- # ---------------- Cohere configuration (migrated to v2 Chat) ----------------
86
- COHERE_API_KEY = os.getenv("COHERE_API_KEY", "")
87
- COHERE_API_URL = 'https://api.cohere.com/v2/chat'
88
- # ---------------------------------------------------------------------------
89
-
90
- # --- Google Cloud Speech-to-Text client init (prefers HF secret JSON) ---
91
- def _make_speech_client():
92
- sa_json = os.getenv("GOOGLE_APPLICATION_CREDENTIALS_JSON")
93
- if sa_json:
94
- try:
95
- info = json.loads(sa_json)
96
- return speech.SpeechClient.from_service_account_info(info)
97
- except Exception as e:
98
- print(f"Failed to parse GOOGLE_APPLICATION_CREDENTIALS_JSON: {e}")
99
- # fall through to default ADC
100
- return speech.SpeechClient()
101
-
102
- speech_client = _make_speech_client()
103
- # -------------------------------------------------------------------------
104
-
105
- # ------------- Cohere v2 helper (extract text from chat response) -------------
106
- def _extract_text_v2(resp_json: dict) -> str:
107
- """
108
- Cohere v2 /chat returns:
109
- { "message": { "content": [ { "type": "text", "text": "..." }, ... ] } }
110
- This pulls the first text block.
111
- """
112
- msg = resp_json.get("message", {})
113
- content = msg.get("content", [])
114
- for block in content:
115
- if isinstance(block, dict) and block.get("type") == "text":
116
- text = (block.get("text") or "").strip()
117
- if text:
118
- return text
119
- return ""
120
- # -----------------------------------------------------------------------------
121
-
122
- # Convert video to audio
123
- def convert_video_to_audio(video_path, audio_path):
124
- try:
125
- # Using moviepy to extract audio from video
126
- video = VideoFileClip(video_path)
127
- video.audio.write_audiofile(audio_path, codec='mp3')
128
- return audio_path
129
- except Exception as e:
130
- print(f"Error converting video to audio: {str(e)}")
131
- return None
132
-
133
- # Re-encode MP3 to ensure proper format
134
- def reencode_mp3(input_audio_path, output_audio_path):
135
- try:
136
- # Using pydub to convert and re-encode MP3 (ensuring correct encoding)
137
- audio = AudioSegment.from_mp3(input_audio_path)
138
- audio.export(output_audio_path, format="mp3", codec="libmp3lame", parameters=["-q:a", "0"])
139
- return output_audio_path
140
- except Exception as e:
141
- print(f"Error re-encoding MP3: {str(e)}")
142
- return None
143
-
144
- # Helper function to convert audio to the proper MP3 encoding if necessary
145
- def convert_audio_to_mp3(input_file_path, output_file_path):
146
- """
147
- Converts the audio file to a valid MP3 format with proper encoding.
148
- """
149
- try:
150
- ffmpeg.input(input_file_path).output(output_file_path, acodec='libmp3lame', audio_bitrate='128k').run()
151
- return True
152
- except Exception as e:
153
- print(f"Error during audio conversion: {e}")
154
- return False
155
-
156
- # Function to compress audio dynamically
157
- def compress_audio(input_file_path, output_file_path, target_bitrate="128k"):
158
- audio = AudioSegment.from_file(input_file_path)
159
- audio.export(output_file_path, format="mp3", bitrate=target_bitrate)
160
- return output_file_path
161
-
162
- # ---------------------------- Routes (Blueprint) ----------------------------
163
-
164
- @listen_bp.route('/', methods=['GET'])
165
- def home():
166
- return "Welcome to the Flask app! The server is running."
167
-
168
- @listen_bp.route('/videos', methods=['GET'])
169
- def list_videos():
170
- """
171
- List available videos for users to watch.
172
- """
173
- # If you maintain a VIDEOS list elsewhere, return it here.
174
- # Returning empty list so the endpoint stays valid.
175
- return jsonify([]), 200
176
-
177
- @listen_bp.route('/videos/<filename>')
178
- def serve_video(filename):
179
- """
180
- Local: serve file from disk.
181
- HF/AWS: fetch object from S3 and stream bytes (no redirect).
182
- """
183
- if _is_aws_video_mode():
184
- try:
185
- s3 = _s3_clients()
186
- bucket = _video_s3_bucket()
187
- key = _video_s3_key(filename)
188
- obj = s3.get_object(Bucket=bucket, Key=key)
189
- data = obj["Body"].read()
190
- return send_file(
191
- io.BytesIO(data),
192
- mimetype="video/mp4",
193
- download_name=filename,
194
- as_attachment=False
195
- )
196
- except (BotoCoreError, ClientError, Exception) as e:
197
- print(f"S3 fetch failed for {filename}: {e}")
198
- abort(404)
199
-
200
- # Local
201
- video_path = os.path.join(VIDEO_FOLDER, filename)
202
- if not os.path.exists(video_path):
203
- print(f"Video file not found: {filename}")
204
- abort(404)
205
-
206
- return send_file(video_path, mimetype='video/mp4')
207
-
208
- @listen_bp.route('/upload-video', methods=['POST'])
209
- def upload_video():
210
- """
211
- Local: save to static/videos or /tmp/listen/videos (depending on mode).
212
- HF/AWS: upload to S3 (no local original).
213
- """
214
- print("Received upload request.")
215
-
216
- if 'video' not in request.files:
217
- print("No video file provided in the request.")
218
- return jsonify({'error': 'No video file provided'}), 400
219
-
220
- video = request.files['video']
221
- if video.filename == '':
222
- print("Empty filename detected.")
223
- return jsonify({'error': 'No selected file'}), 400
224
-
225
- try:
226
- filename = f"{uuid.uuid4()}.mp4"
227
-
228
- if _is_aws_video_mode():
229
- try:
230
- s3 = _s3_clients()
231
- bucket = _video_s3_bucket()
232
- key = _video_s3_key(filename)
233
- s3.put_object(
234
- Bucket=bucket,
235
- Key=key,
236
- Body=video.stream.read(),
237
- ContentType="video/mp4"
238
- )
239
- print(f"Uploaded to S3: s3://{bucket}/{key}")
240
- except (BotoCoreError, ClientError, Exception) as e:
241
- print(f"S3 upload error: {e}")
242
- return jsonify({'error': 'Failed to upload to S3'}), 500
243
- else:
244
- # Save locally
245
- video_path = os.path.join(VIDEO_FOLDER, filename)
246
- print(f"Saving video: {filename}")
247
- video.save(video_path)
248
- print(f"Video saved successfully at {video_path}")
249
-
250
- return jsonify({'message': 'Video uploaded successfully!', 'filename': filename}), 200
251
-
252
- except Exception as e:
253
- print(f"Error saving video: {str(e)}")
254
- return jsonify({'error': 'Failed to save video'}), 500
255
-
256
- @listen_bp.route('/generate-questions-dynamicvideo', methods=['POST'])
257
- def generate_questions():
258
- try:
259
- data = request.json
260
- video_filename = data.get('filename')
261
-
262
- if not video_filename:
263
- print("Error: No filename provided in request.")
264
- return jsonify({"error": "Filename is required"}), 400
265
-
266
- # Resolve a local readable path for processing
267
- video_path = os.path.join(VIDEO_FOLDER, video_filename)
268
-
269
- if _is_aws_video_mode():
270
- # Download object bytes to a local working file path
271
- try:
272
- s3 = _s3_clients()
273
- bucket = _video_s3_bucket()
274
- key = _video_s3_key(video_filename)
275
- obj = s3.get_object(Bucket=bucket, Key=key)
276
- data_bytes = obj["Body"].read()
277
- with open(video_path, "wb") as f:
278
- f.write(data_bytes)
279
- except (BotoCoreError, ClientError, Exception) as e:
280
- print(f"S3 download error for {video_filename}: {e}")
281
- return jsonify({"error": "Video file not found"}), 404
282
- else:
283
- if not os.path.exists(video_path):
284
- print(f"Error: Video file {video_filename} not found at {video_path}")
285
- return jsonify({"error": "Video file not found"}), 404
286
-
287
- print(f"Processing video: {video_filename}")
288
-
289
- # Convert video to audio
290
- audio_filename = f"{uuid.uuid4()}.mp3"
291
- audio_path = os.path.join(AUDIO_FOLDER, audio_filename)
292
-
293
- if not convert_video_to_audio(video_path, audio_path):
294
- print("Error: Video to audio conversion failed.")
295
- return jsonify({"error": "Failed to convert video to audio"}), 500
296
-
297
- # Transcribe audio using Google Cloud Speech-to-Text
298
- with open(audio_path, 'rb') as audio_file:
299
- audio_content = audio_file.read()
300
-
301
- audio = speech.RecognitionAudio(content=audio_content)
302
- config = speech.RecognitionConfig(
303
- encoding=speech.RecognitionConfig.AudioEncoding.MP3,
304
- sample_rate_hertz=16000,
305
- language_code="en-US",
306
- )
307
-
308
- response = speech_client.recognize(config=config, audio=audio)
309
- transcripts = [result.alternatives[0].transcript for result in response.results]
310
-
311
- if not transcripts:
312
- print("Error: No transcription results found.")
313
- return jsonify({"error": "No transcription results found"}), 500
314
-
315
- transcription_text = " ".join(transcripts)
316
- print(f"Transcription successful: {transcription_text[:200]}...") # Print first 200 chars
317
-
318
- # ---------------- Cohere v2 Chat call (minimal change) ----------------
319
- headers = {
320
- "Authorization": f"Bearer {COHERE_API_KEY}",
321
- "Content-Type": "application/json"
322
- }
323
-
324
- prompt_text = (
325
- "Generate exactly three multiple-choice questions based on this text:\n"
326
- f"{transcription_text}\n\n"
327
- "Rules:\n"
328
- "- Each question starts with a number and a period (e.g., 1.)\n"
329
- "- Each question has exactly four options labeled A., B., C., and D.\n"
330
- "- After the options, add a line 'Correct answer: <A|B|C|D>'\n"
331
- "- Output plain text only."
332
- )
333
-
334
- cohere_payload = {
335
- "model": "command-r-08-2024",
336
- "messages": [
337
- {"role": "user", "content": prompt_text}
338
- ],
339
- "max_tokens": 300,
340
- "temperature": 0.9
341
- }
342
-
343
- cohere_response = requests.post(
344
- COHERE_API_URL,
345
- json=cohere_payload,
346
- headers=headers,
347
- timeout=60
348
- )
349
-
350
- if cohere_response.status_code != 200:
351
- print(f"Error: Cohere API response failed: {cohere_response.text}")
352
- return jsonify({"error": "Failed to generate questions"}), 500
353
-
354
- raw_text = _extract_text_v2(cohere_response.json())
355
- if not raw_text:
356
- print("Error: No questions text returned by Cohere Chat API.")
357
- return jsonify({"error": "No questions generated"}), 500
358
- # ---------------------------------------------------------------------
359
-
360
- # Extract raw text and parse questions
361
- structured_questions = parse_questions(raw_text)
362
-
363
- return jsonify({"questions": structured_questions}), 200
364
-
365
- except Exception as e:
366
- print(f"Critical Error: {e}")
367
- return jsonify({"error": "An error occurred while generating questions"}), 500
368
-
369
- def parse_questions(response_text):
370
- # Split the text into individual question blocks
371
- question_blocks = response_text.split("\n\n")
372
- questions = []
373
-
374
- # Process each question block
375
- for block in question_blocks:
376
- print("\nProcessing Block:", block) # Debug: Log each question block
377
-
378
- # Split the block into lines
379
- lines = block.strip().split("\n")
380
- print("Split Lines:", lines) # Debug: Log split lines of the block
381
-
382
- # Ensure the block contains a question
383
- if len(lines) < 2:
384
- print("Skipping Invalid Block") # Debug: Log invalid blocks
385
- continue
386
-
387
- # Extract the question text
388
- question_line = lines[0]
389
- question_text = question_line.split(". ", 1)[1] if ". " in question_line else question_line
390
- print("Question Text:", question_text) # Debug: Log extracted question text
391
-
392
- # Extract the options and find the correct answer
393
- options = []
394
- correct_answer_letter = None
395
- for line in lines[1:]:
396
- line = line.strip()
397
- # Handle A., B., C., D. and also a) / A) formats
398
- if line.lower().startswith("correct answer:"):
399
- correct_answer_letter = line.split(":")[-1].strip()
400
- continue
401
- match = re.match(r"^(?:[a-dA-D][\).]?\s)?(.+)$", line)
402
- if match:
403
- option_text = match.group(1).strip()
404
- # We already handled "Correct answer:" above, so only options get appended
405
- if not line.lower().startswith("correct answer:"):
406
- options.append(option_text)
407
-
408
- print("Extracted Options:", options) # Debug: Log extracted options
409
- print("Correct Answer Letter:", correct_answer_letter) # Debug: Log the correct answer letter
410
-
411
- # Map the correct answer text
412
- correct_answer_text = ""
413
- if correct_answer_letter:
414
- option_index = ord(correct_answer_letter.upper()) - ord('A') # Convert 'A'→0, 'B'→1, etc.
415
- if 0 <= option_index < len(options):
416
- correct_answer_text = options[option_index]
417
- print("Mapped Correct Answer Text:", correct_answer_text) # Debug: Log mapped answer
418
-
419
- # Append the parsed question to the list
420
- if question_text and options:
421
- questions.append({
422
- "question": question_text,
423
- "options": options,
424
- "answer": correct_answer_text # Use the full answer text
425
- })
426
-
427
- print("\nFinal Questions:", questions) # Debug: Log final parsed questions
428
- return questions
429
-
430
- # ---------- Standalone (local testing) ----------
431
- if __name__ == '__main__':
432
- app = Flask(__name__)
433
- CORS(app)
434
- app.config["COHERE_API_KEY"] = os.getenv("COHERE_API_KEY", COHERE_API_KEY)
435
- app.register_blueprint(listen_bp, url_prefix='')
436
- app.run(host='0.0.0.0', port=5012, debug=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
media/audio/explain_1112505a6701429cb241d131a88bf709.wav DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:0f7aae706a5bc1c25e9cf61ddc970ab3d0454650c14a936c7da051556c057091
3
- size 1951916
 
 
 
 
media/audio/explain_5c2a7427d1f14a2aa9fa9e59bb1ad603.wav DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:af65a4273dbbcb6aa004215de1f58b1fd964bcdb1df04ce10a0a872a920b29c5
3
- size 634956
 
 
 
 
media/audio/explain_975ae1b5996743f6b76b5016f17056de.wav DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:7c54cd66b876ad6ab7ad9b7420fd38e2aa80e625e5d34be2fb3ef9d96461ddff
3
- size 503372
 
 
 
 
media/audio/explain_ca92720c882d4926973973aa4b9f2316.wav DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:14a428d11bba10b2f51c72826a9339dde62189153473796269b0fd7a09f27c54
3
- size 193612
 
 
 
 
media/audio/explain_cc24a21b0b374e50bc8afbf73a7398c4.wav DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:a1dff985cf893840190f1f1e8229e99c80ed5651035f820c1590e39690fc009f
3
- size 175692
 
 
 
 
media/audio/explain_dd70fb52325d44fc84cde7c1c9215232.wav DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:e37bd2a66186f0e2ca5ee4a02b0a9b63977af13f9bf5bb32f006f8a2066edcf7
3
- size 470092
 
 
 
 
media/audio/synth_22ebf1e3b9404b34a41b2fdc2c691adb.wav DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:7137826339f483af77865b9dfd96c2386311e5cfcc52ca5990a011fabdd12fab
3
- size 1287340
 
 
 
 
media/audio/synth_2757240115da4ba3a9aa1286aee57db9.wav DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:675814fca44682416fa92128edc0b7637c3afdeeea7043f7a167ce36f4ac4a01
3
- size 676972
 
 
 
 
media/audio/synth_4965badeb7da43ffac0c3a7af781ab0f.wav DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:bab5cb8d0e45d587f484120aaad8eefb100a757ab3c91b8909afedbc199ce106
3
- size 157772
 
 
 
 
media/audio/synth_7bccf943f0b24880b77aa038b38f8bf1.wav DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:2914d7c32525eaf3ab30bedbca0a1dc3a9d1145dad34a0c45eb13f28d67f3d7e
3
- size 465484
 
 
 
 
chroma_db/1ceaf3a3-30e6-42c4-b515-99a05466da04/data_level0.bin → media/audio/synth_d38b265fcd6d4f9cbb825007c3f52ac5.wav RENAMED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:90b564d60a2658c07a41e1133109c1574bb40f6ab674750bba8b8eeb28a08f25
3
- size 167600
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fe2266670758acffea3818641f235780f4609418c58e5b6065ae48a22d02a870
3
+ size 483404
media/audio/synth_ee1e3e992d6641b9a06d214e0e67ea92.wav DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:8747b294606c0766d55d6d24adc2c5ace29259c1f1969bae781f92e25dfb456f
3
- size 505932
 
 
 
 
pdfs/testing.pdf DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:b85c06e93333ac99d33ffb8b4f9a4d8402c26ce5b323398bb6691b2f58acee64
3
- size 7352882
 
 
 
 
pron.py DELETED
@@ -1,729 +0,0 @@
1
- """
2
- Pronunciation Trainer – Final Version
3
- Real IPA • Whisper small.en • Phoneme Substitution Detection
4
- Dynamic Feedback System for Children & Adults
5
- """
6
-
7
- import os
8
- import io
9
- import re
10
- import uuid
11
- import tempfile
12
- import numpy as np
13
- import librosa
14
-
15
- from flask import Blueprint, request, jsonify, send_file
16
- from difflib import SequenceMatcher
17
- from werkzeug.utils import secure_filename
18
- from pydub import AudioSegment
19
- from pathlib import Path
20
-
21
- # -------------------------------------------------------------------------
22
- # IMPORTANT: Patch torch.load so XTTS can load on PyTorch 2.6 (HF Space)
23
- # -------------------------------------------------------------------------
24
- import torch
25
-
26
- _original_torch_load = torch.load
27
-
28
-
29
- def _torch_load_allow_weights(*args, **kwargs):
30
- """
31
- Global patch: force weights_only=False for all torch.load calls.
32
- This follows option (1) from the PyTorch warning and is safe here
33
- because we trust the XTTS checkpoint.
34
- """
35
- # Always override to False, regardless of what is passed
36
- kwargs["weights_only"] = False
37
- return _original_torch_load(*args, **kwargs)
38
-
39
-
40
- torch.load = _torch_load_allow_weights
41
- print(">>> [PRON] Patched torch.load to use weights_only=False for XTTS.", flush=True)
42
-
43
- # Use the same XTTS helper that already works in ragg
44
- from ragg.tts import xtts_speak_to_file
45
-
46
- # -------------------------------------------------------------------------
47
- # OPTIONAL MODULES
48
- # -------------------------------------------------------------------------
49
- try:
50
- import whisper
51
- WHISPER_AVAILABLE = True
52
- WHISPER_MODEL = None
53
-
54
- def get_whisper():
55
- global WHISPER_MODEL
56
- if WHISPER_MODEL is None:
57
- # Use small.en as requested
58
- WHISPER_MODEL = whisper.load_model("small.en")
59
- return WHISPER_MODEL
60
- except Exception:
61
- WHISPER_AVAILABLE = False
62
-
63
- try:
64
- from phonemizer import phonemize
65
- PHONEMIZER_AVAILABLE = True
66
- except Exception:
67
- PHONEMIZER_AVAILABLE = False
68
-
69
- # -------------------------------------------------------------------------
70
- # PATHS
71
- # -------------------------------------------------------------------------
72
- BASE = os.path.dirname(os.path.abspath(__file__))
73
- STATIC_DIR = os.path.join(BASE, "static")
74
- AUDIO_DIR = os.path.join(STATIC_DIR, "audio")
75
- REF_DIR = os.path.join(STATIC_DIR, "references")
76
-
77
- os.makedirs(AUDIO_DIR, exist_ok=True)
78
- os.makedirs(REF_DIR, exist_ok=True)
79
-
80
- # Use the same base/trim logic as in ragg/tts.py
81
- BASE_DIR = Path(__file__).resolve().parent.parent
82
- XTTS_REF_DIR = Path(os.getenv("XTTS_REF_DIR", str(BASE_DIR / "trim")))
83
-
84
- # Optional local default reference under this blueprint
85
- DEFAULT_REFERENCE = Path(REF_DIR) / "voice1.wav"
86
-
87
- pron_bp = Blueprint("pron", __name__)
88
-
89
- # -------------------------------------------------------------------------
90
- # HELPERS
91
- # -------------------------------------------------------------------------
92
- def normalize(text):
93
- if not text:
94
- return ""
95
- text = text.lower().strip()
96
- text = re.sub(r"[^a-z ]", "", text)
97
- return text.strip()
98
-
99
-
100
- def read_numpy(file, sr=16000):
101
- file.stream.seek(0)
102
- raw = file.stream.read()
103
- b = io.BytesIO(raw)
104
- ext = os.path.splitext(file.filename)[1].replace(".", "") or "wav"
105
-
106
- try:
107
- audio = AudioSegment.from_file(b, format=ext)
108
- except Exception:
109
- b.seek(0)
110
- audio = AudioSegment.from_file(b)
111
-
112
- audio = audio.set_channels(1).set_frame_rate(sr)
113
- arr = np.array(audio.get_array_of_samples(), dtype=np.float32)
114
- max_val = float(1 << (audio.sample_width * 8 - 1))
115
- return arr / max_val, sr
116
-
117
-
118
- def detect_silence(y, sr):
119
- if y is None or len(y) == 0:
120
- return True, "no_audio"
121
-
122
- duration = len(y) / sr
123
- max_amp = np.max(np.abs(y))
124
-
125
- if duration < 0.3:
126
- return True, "too_short"
127
-
128
- if max_amp < 0.015:
129
- return True, "too_quiet"
130
-
131
- return False, None
132
-
133
-
134
- def _make_suggestion_payload(message):
135
- """
136
- Small helper to create suggestion/feedback arrays so frontend always receives
137
- structured feedback even on error paths.
138
- """
139
- return [{"title": "Notice", "message": message}]
140
-
141
-
142
- def error_response(error_key, message, status=400, extra=None):
143
- payload = {
144
- "error": error_key,
145
- "message": message,
146
- "suggestion": _make_suggestion_payload(message),
147
- "feedback": _make_suggestion_payload(message),
148
- }
149
- if extra:
150
- payload.update(extra)
151
- return jsonify(payload), status
152
-
153
-
154
- def structured_feedback_error(error_key, message, extra=None, status=200):
155
- """
156
- Return a structured JSON payload that frontends can always bind to.
157
- Used for user-facing ASR/validation issues (not server failures).
158
- """
159
- payload = {
160
- "error": error_key,
161
- "message": message,
162
- "silent": False,
163
- "word": None,
164
- "heard_word": None,
165
- "phoneme_teacher": None,
166
- "phoneme_student": None,
167
- "phoneme_similarity": 0.0,
168
- "phonemeSimilarity": 0.0,
169
- "phoneme_score": 0.0,
170
- "phonemeScore": 0.0,
171
- "feedback": _make_suggestion_payload(message),
172
- "suggestion": _make_suggestion_payload(message),
173
- "audio_url": None,
174
- }
175
- if extra:
176
- payload.update(extra)
177
- return jsonify(payload), status
178
-
179
- # -------------------------------------------------------------------------
180
- # REAL IPA PHONEMES
181
- # -------------------------------------------------------------------------
182
- def ipa_phonemes(text):
183
- if not text:
184
- return ""
185
-
186
- if PHONEMIZER_AVAILABLE:
187
- try:
188
- ipa = phonemize(
189
- text,
190
- language="en-us",
191
- backend="espeak",
192
- strip=True,
193
- preserve_punctuation=False,
194
- ipa=True,
195
- with_stress=True,
196
- )
197
- ipa = ipa.replace("ˈ", " ˈ").replace("ˌ", " ˌ")
198
- return " ".join(ipa.split())
199
- except Exception:
200
- return text
201
-
202
- return text
203
-
204
- # -------------------------------------------------------------------------
205
- # ASR OVERRIDE FOR SHORT WORDS
206
- # -------------------------------------------------------------------------
207
- def strong_word_match(word, heard, teacher_ph, student_ph):
208
- ws = SequenceMatcher(None, heard, word).ratio()
209
- ps = SequenceMatcher(None, teacher_ph, student_ph).ratio()
210
-
211
- if ps >= 0.80:
212
- return True
213
-
214
- teacher_split = teacher_ph.split()
215
- student_split = student_ph.split()
216
- if teacher_split and student_split and teacher_split[0] == student_split[0]:
217
- return True
218
-
219
- if len(word) <= 5 and ws >= 0.60:
220
- return True
221
-
222
- return False
223
-
224
- # -------------------------------------------------------------------------
225
- # TTS (Teacher Voice) – using shared xtts_speak_to_file
226
- # -------------------------------------------------------------------------
227
- def clone_voice(text, out_path, reference: Path | str | None = None):
228
- """
229
- Generate teacher audio for 'text' into out_path using XTTS.
230
- Priority:
231
- 1) Uploaded reference file.
232
- 2) DEFAULT_REFERENCE (static/references/voice1.wav).
233
- 3) Finally, XTTS_REF_DIR folder (trim) if nothing else is available.
234
- """
235
- # 1) explicit reference from caller
236
- if reference is not None:
237
- ref_path = Path(str(reference))
238
- if ref_path.is_file():
239
- return xtts_speak_to_file(
240
- text=text,
241
- out_file=out_path,
242
- reference_files=[ref_path],
243
- language="en",
244
- )
245
-
246
- # 2) default local reference
247
- if DEFAULT_REFERENCE.is_file():
248
- return xtts_speak_to_file(
249
- text=text,
250
- out_file=out_path,
251
- reference_files=[DEFAULT_REFERENCE],
252
- language="en",
253
- )
254
-
255
- # 3) fallback to XTTS_REF_DIR / trim as in RAG part
256
- return xtts_speak_to_file(
257
- text=text,
258
- out_file=out_path,
259
- reference_dir=XTTS_REF_DIR,
260
- language="en",
261
- )
262
-
263
-
264
- def clone_voice_bytes(text, reference: Path | str | None = None):
265
- """
266
- Generate teacher audio for 'text' and return raw bytes.
267
- """
268
- tmp_path = Path(tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name)
269
- try:
270
- clone_voice(text, tmp_path, reference=reference)
271
- with open(tmp_path, "rb") as f:
272
- data = f.read()
273
- finally:
274
- try:
275
- tmp_path.unlink()
276
- except Exception:
277
- pass
278
-
279
- return data
280
-
281
- # -------------------------------------------------------------------------
282
- # WAVEFORM / SPECTROGRAM HELPERS
283
- # -------------------------------------------------------------------------
284
- def load_audio_from_bytes(data_bytes: bytes, sr=16000):
285
- tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
286
- try:
287
- tmp.write(data_bytes)
288
- tmp.flush()
289
- tmp.close()
290
- y, sr_loaded = librosa.load(tmp.name, sr=sr, mono=True)
291
- finally:
292
- try:
293
- os.remove(tmp.name)
294
- except Exception:
295
- pass
296
- return y, sr_loaded
297
-
298
-
299
- def compute_waveform_similarity(y_ref, y_stud, sr=16000):
300
- result = {
301
- "similarity": 0.0,
302
- "dtw_dist": None,
303
- "dtw_norm": None,
304
- "dtw_sim": None,
305
- "corr": None,
306
- "corr_sim": None,
307
- }
308
-
309
- try:
310
- y_ref_trim, _ = librosa.effects.trim(y_ref, top_db=20)
311
- except Exception:
312
- y_ref_trim = y_ref
313
- try:
314
- y_stud_trim, _ = librosa.effects.trim(y_stud, top_db=20)
315
- except Exception:
316
- y_stud_trim = y_stud
317
-
318
- if y_ref_trim is None or y_stud_trim is None or len(y_ref_trim) < 10 or len(y_stud_trim) < 10:
319
- return result
320
-
321
- try:
322
- mfcc_ref = librosa.feature.mfcc(y_ref_trim, sr=sr, n_mfcc=13)
323
- mfcc_stud = librosa.feature.mfcc(y_stud_trim, sr=sr, n_mfcc=13)
324
-
325
- D, wp = librosa.sequence.dtw(X=mfcc_ref, Y=mfcc_stud, metric="euclidean")
326
- dtw_dist = float(D[-1, -1])
327
- denom = (mfcc_ref.shape[1] + mfcc_stud.shape[1]) if (mfcc_ref.shape[1] + mfcc_stud.shape[1]) > 0 else 1.0
328
- dtw_norm = dtw_dist / denom
329
-
330
- dtw_sim = max(0.0, 100.0 - dtw_norm * 30.0)
331
-
332
- result["dtw_dist"] = dtw_dist
333
- result["dtw_norm"] = dtw_norm
334
- result["dtw_sim"] = max(0.0, min(100.0, dtw_sim))
335
- except Exception:
336
- result["dtw_dist"] = None
337
- result["dtw_norm"] = None
338
- result["dtw_sim"] = 0.0
339
-
340
- try:
341
- min_len = min(len(y_ref_trim), len(y_stud_trim))
342
- if min_len <= 1:
343
- corr = 0.0
344
- else:
345
- r = y_ref_trim[:min_len]
346
- s = y_stud_trim[:min_len]
347
- r = (r - np.mean(r)) / (np.std(r) + 1e-9)
348
- s = (s - np.mean(s)) / (np.std(s) + 1e-9)
349
- corr = float(np.corrcoef(r, s)[0, 1])
350
- if np.isnan(corr):
351
- corr = 0.0
352
- corr_sim = ((corr + 1.0) / 2.0) * 100.0
353
- result["corr"] = corr
354
- result["corr_sim"] = max(0.0, min(100.0, corr_sim))
355
- except Exception:
356
- result["corr"] = None
357
- result["corr_sim"] = 0.0
358
-
359
- dtw_component = float(result["dtw_sim"] or 0.0)
360
- corr_component = float(result["corr_sim"] or 0.0)
361
- combined = 0.65 * dtw_component + 0.35 * corr_component
362
- result["similarity"] = round(float(max(0.0, min(100.0, combined))), 2)
363
- return result
364
-
365
-
366
- def build_waveform_feedback(word: str, sim_dict: dict, threshold: float):
367
- score = float(sim_dict.get("similarity") or 0.0)
368
- dtw_sim = float(sim_dict.get("dtw_sim") or 0.0)
369
- corr_sim = float(sim_dict.get("corr_sim") or 0.0)
370
-
371
- feedback = []
372
-
373
- if score >= 90:
374
- feedback.append({
375
- "title": "Overall Pronunciation",
376
- "message": f"Excellent. Your waveform for '{word}' is almost the same as the teacher."
377
- })
378
- elif score >= 75:
379
- feedback.append({
380
- "title": "Overall Pronunciation",
381
- "message": f"Very good. Your pronunciation of '{word}' is close to the teacher. Small improvements are possible."
382
- })
383
- elif score >= 60:
384
- feedback.append({
385
- "title": "Overall Pronunciation",
386
- "message": f"Good attempt. You are understandable, but you can still improve clarity and smoothness for '{word}'."
387
- })
388
- else:
389
- feedback.append({
390
- "title": "Overall Pronunciation",
391
- "message": f"You are trying well, but the sound of '{word}' is still far from the teacher. Please practise a few more times."
392
- })
393
-
394
- if dtw_sim >= 75:
395
- feedback.append({
396
- "title": "Rhythm and Timing",
397
- "message": "Your timing and rhythm are close to the teacher. You are stressing the word in a similar way."
398
- })
399
- elif dtw_sim >= 55:
400
- feedback.append({
401
- "title": "Rhythm and Timing",
402
- "message": "Your timing is acceptable, but you can make the word smoother. Try saying the word in one smooth breath."
403
- })
404
- else:
405
- feedback.append({
406
- "title": "Rhythm and Timing",
407
- "message": "Your timing is quite different. Try to copy when the teacher starts and stops the word and keep a steady pace."
408
- })
409
-
410
- if corr_sim >= 75:
411
- feedback.append({
412
- "title": "Clarity of Sound",
413
- "message": "Your sound shape is clear and close to the teacher. Mouth and tongue positions are mostly correct."
414
- })
415
- elif corr_sim >= 55:
416
- feedback.append({
417
- "title": "Clarity of Sound",
418
- "message": "Your sound is partly clear. Try opening your mouth a little more and speak a bit more clearly."
419
- })
420
- else:
421
- feedback.append({
422
- "title": "Clarity of Sound",
423
- "message": "The sound shape is quite different. Try to listen carefully and slowly copy the teacher sound."
424
- })
425
-
426
- feedback.append({
427
- "title": "Practice Tip",
428
- "message": "Listen to the teacher audio 2–3 times and then repeat slowly. Focus on copying the length and loudness of the sound."
429
- })
430
-
431
- passed_text = "You passed the target for this word." if score >= threshold else "You did not yet pass the target. Try again."
432
- feedback.append({
433
- "title": "Score",
434
- "message": f"Waveform score: {score:.1f}/100. Target: {threshold:.1f}. {passed_text}"
435
- })
436
-
437
- return feedback
438
-
439
- # -------------------------------------------------------------------------
440
- # ROUTE: Generate Teacher Audio (download)
441
- # -------------------------------------------------------------------------
442
- @pron_bp.route("/generate_teacher_audio", methods=["POST"])
443
- def generate_teacher_audio():
444
- word = request.form.get("word", "").strip().lower()
445
- if not word:
446
- return error_response("word_required", "Word required", 400)
447
-
448
- ref = None
449
- if "reference" in request.files:
450
- rf = request.files["reference"]
451
- fname = secure_filename(rf.filename)
452
- path = os.path.join(REF_DIR, fname)
453
- rf.save(path)
454
- ref = path
455
-
456
- out = os.path.join(AUDIO_DIR, f"{word}-{uuid.uuid4().hex}.wav")
457
-
458
- try:
459
- clone_voice(word, out, reference=ref)
460
- except FileNotFoundError as e:
461
- return error_response("reference_not_found", f"Reference audio not found: {e}", 500)
462
- except RuntimeError as e:
463
- return error_response("tts_unavailable", f"TTS unavailable: {e}", 503)
464
- except Exception as e:
465
- return error_response("tts_generation_failed", f"TTS generation failed: {e}", 500)
466
-
467
- rel = os.path.relpath(out, STATIC_DIR).replace("\\", "/")
468
- return jsonify({"url": rel})
469
-
470
- # -------------------------------------------------------------------------
471
- # ROUTE: Teacher Audio Stream
472
- # -------------------------------------------------------------------------
473
- @pron_bp.route("/generate_teacher_audio_stream", methods=["POST"])
474
- def generate_teacher_audio_stream():
475
- word = request.form.get("word", "").strip().lower()
476
- if not word:
477
- return error_response("word_required", "Word required", 400)
478
-
479
- ref_path = None
480
- if "reference" in request.files:
481
- try:
482
- rf = request.files["reference"]
483
- fname = secure_filename(rf.filename)
484
- path = os.path.join(REF_DIR, fname)
485
- rf.save(path)
486
- ref_path = path
487
- except Exception as e:
488
- app_msg = f"reference save failed: {e}"
489
- print(app_msg)
490
- return error_response("reference_save_failed", app_msg, 500)
491
-
492
- try:
493
- data = clone_voice_bytes(word, reference=ref_path)
494
- bio = io.BytesIO(data)
495
- bio.seek(0)
496
- return send_file(bio, mimetype="audio/wav", as_attachment=False)
497
-
498
- except FileNotFoundError as e:
499
- msg = f"Reference audio not found: {e}"
500
- print("generate_teacher_audio_stream FileNotFoundError:", e)
501
- return error_response("reference_not_found", msg, 500)
502
-
503
- except RuntimeError as e:
504
- msg = (
505
- "Teacher voice model is not available on this server. "
506
- "You can still practise pronunciation, but teacher audio cannot be generated."
507
- )
508
- print("generate_teacher_audio_stream RuntimeError (XTTS):", e)
509
- return structured_feedback_error("tts_unavailable", msg, status=200)
510
-
511
- except Exception as exc:
512
- print("generate_teacher_audio_stream error:", exc)
513
- return error_response("tts_generation_failed", f"TTS generation failed: {exc}", 500)
514
-
515
- # -------------------------------------------------------------------------
516
- # ROUTE: PRONUNCIATION CHECK
517
- # -------------------------------------------------------------------------
518
- @pron_bp.route("/check_pronunciation", methods=["POST"])
519
- def check_pronunciation():
520
- if "audio" not in request.files:
521
- return error_response("audio_required", "Audio required. Please record and try again.", 400)
522
-
523
- word = request.form.get("word", "").strip().lower()
524
- if not word:
525
- return error_response("word_required", "Word required", 400)
526
-
527
- mode = request.form.get("mode", "phonetics")
528
- file = request.files["audio"]
529
-
530
- y_student, sr = read_numpy(file)
531
- silent, reason = detect_silence(y_student, sr)
532
- if silent:
533
- if reason == "too_short":
534
- msg = "Recording was too short. Please speak clearly for at least 0.3 seconds."
535
- elif reason == "too_quiet":
536
- msg = "Recording too quiet. Increase microphone volume or speak louder."
537
- else:
538
- msg = "No audio detected. Please record again."
539
- return jsonify({
540
- "silent": True,
541
- "reason": reason,
542
- "suggestion": _make_suggestion_payload(msg),
543
- "feedback": _make_suggestion_payload(msg),
544
- "message": msg,
545
- })
546
-
547
- if mode == "waveform":
548
- teacher_bytes = None
549
- if "reference" in request.files:
550
- try:
551
- rf = request.files["reference"]
552
- teacher_bytes = rf.read()
553
- except Exception:
554
- teacher_bytes = None
555
-
556
- if teacher_bytes is None:
557
- try:
558
- teacher_bytes = clone_voice_bytes(word, reference=None)
559
- except Exception:
560
- teacher_bytes = None
561
-
562
- if teacher_bytes is None:
563
- return error_response("teacher_audio_unavailable", "Teacher audio not available", 500)
564
-
565
- try:
566
- y_teacher, sr_teacher = load_audio_from_bytes(teacher_bytes, sr=sr)
567
- except Exception as e:
568
- return error_response("teacher_load_failed", f"Failed to load teacher audio: {e}", 500)
569
-
570
- sim = compute_waveform_similarity(y_teacher, y_student, sr=sr)
571
-
572
- threshold = float(request.form.get("threshold", 65.0))
573
- matched = (sim.get("similarity", 0.0) >= threshold)
574
-
575
- feedback = build_waveform_feedback(word, sim, threshold)
576
-
577
- return jsonify({
578
- "mode": "waveform",
579
- "silent": False,
580
- "word": word,
581
- "waveform_similarity": float(sim.get("similarity") or 0.0),
582
- "waveformScore": float(sim.get("similarity") or 0.0),
583
- "waveform_match": bool(matched),
584
- "feedback": feedback,
585
- "suggestion": feedback,
586
- "details": {
587
- "dtw_dist": sim.get("dtw_dist"),
588
- "dtw_norm": sim.get("dtw_norm"),
589
- "dtw_sim": sim.get("dtw_sim"),
590
- "corr": sim.get("corr"),
591
- "corr_sim": sim.get("corr_sim"),
592
- },
593
- })
594
-
595
- heard = ""
596
- if WHISPER_AVAILABLE:
597
- tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name
598
- file.stream.seek(0)
599
- with open(tmp, "wb") as f:
600
- f.write(file.read())
601
-
602
- result = get_whisper().transcribe(tmp, language="en")
603
- os.remove(tmp)
604
- heard = normalize(result.get("text", ""))
605
-
606
- if not heard:
607
- return structured_feedback_error("no_asr", "Could not understand speech. Please try again.")
608
-
609
- parts = heard.split()
610
- if len(parts) > 1:
611
- msg = f"Detected multiple words: '{heard}'. Please say only '{word}'."
612
- return structured_feedback_error(
613
- "multiple_words",
614
- msg,
615
- extra={"word": word, "heard_word": heard},
616
- )
617
-
618
- heard_word = parts[0]
619
-
620
- teacher_ph = ipa_phonemes(word)
621
- student_ph = ipa_phonemes(heard_word)
622
-
623
- if not strong_word_match(word, heard_word, teacher_ph, student_ph):
624
- msg = f"You said '{heard_word}'. Please say only '{word}'."
625
- return structured_feedback_error(
626
- "incorrect_word",
627
- msg,
628
- extra={"word": word, "heard_word": heard_word},
629
- )
630
-
631
- feedback = []
632
-
633
- t_tokens = teacher_ph.split()
634
- s_tokens = student_ph.split()
635
-
636
- sm = SequenceMatcher(None, t_tokens, s_tokens)
637
-
638
- for tag, i1, i2, j1, j2 in sm.get_opcodes():
639
- if tag == "delete":
640
- missing = t_tokens[i1:i2]
641
- feedback.append({
642
- "title": "Missing Sounds",
643
- "message": f"You missed these sounds: {' '.join(missing)}. Try to say each sound clearly."
644
- })
645
- elif tag == "insert":
646
- extra = s_tokens[j1:j2]
647
- feedback.append({
648
- "title": "Extra Sounds",
649
- "message": f"You added extra sounds: {' '.join(extra)}. Try to keep only the sounds from the teacher word."
650
- })
651
- elif tag == "replace":
652
- exp = t_tokens[i1:i2]
653
- rec = s_tokens[j1:j2]
654
- feedback.append({
655
- "title": "Sound Substitution",
656
- "message": f"Expected {' '.join(exp)} but you said {' '.join(rec)}. Listen again and copy the teacher sound."
657
- })
658
-
659
- vowels = "æɪiːʌəɑɒɔːeɜːuːʊɛ"
660
-
661
- v_t = [p for p in teacher_ph if p in vowels]
662
- v_s = [p for p in student_ph if p in vowels]
663
-
664
- if v_t != v_s:
665
- feedback.append({
666
- "title": "Vowel Accuracy",
667
- "message": "Your vowel sound is different. Open your mouth and copy the long or short sound of the teacher."
668
- })
669
- else:
670
- feedback.append({
671
- "title": "Vowel Accuracy",
672
- "message": "Your vowel pronunciation is accurate and matches the teacher."
673
- })
674
-
675
- cons_t = [p for p in t_tokens if p and p[0] not in vowels]
676
- cons_s = [p for p in s_tokens if p and p[0] not in vowels]
677
-
678
- if cons_t != cons_s:
679
- feedback.append({
680
- "title": "Consonant Accuracy",
681
- "message": "Some consonant sounds are different. Focus on the first and last sound of the word."
682
- })
683
- else:
684
- feedback.append({
685
- "title": "Consonant Accuracy",
686
- "message": "Your consonant sounds match well with the teacher."
687
- })
688
-
689
- ph_sim = SequenceMatcher(None, teacher_ph, student_ph).ratio()
690
- score = round(ph_sim * 100, 2)
691
-
692
- if score >= 90:
693
- overall_msg = f"Excellent. Your pronunciation of '{word}' is almost perfect."
694
- elif score >= 75:
695
- overall_msg = f"Very good. Your pronunciation of '{word}' is clear with small differences."
696
- elif score >= 60:
697
- overall_msg = f"Good attempt. People can understand '{word}', but you can improve some sounds."
698
- else:
699
- overall_msg = f"You are trying well, but you need more practice to say '{word}' like the teacher."
700
-
701
- feedback.insert(0, {
702
- "title": "Overall Score",
703
- "message": f"Phoneme score: {score:.1f}/100. {overall_msg}"
704
- })
705
-
706
- feedback.append({
707
- "title": "How To Say It",
708
- "message": f"Correct IPA for '{word}': {teacher_ph}"
709
- })
710
-
711
- feedback.append({
712
- "title": "Practice Tip",
713
- "message": "Listen to the teacher voice, then repeat slowly 3 times. Focus on the first sound and the vowel in the middle."
714
- })
715
-
716
- return jsonify({
717
- "silent": False,
718
- "word": word,
719
- "heard_word": heard_word,
720
- "phoneme_teacher": teacher_ph,
721
- "phoneme_student": student_ph,
722
- "phoneme_similarity": float(ph_sim),
723
- "phonemeSimilarity": float(ph_sim),
724
- "phoneme_score": float(score),
725
- "phonemeScore": float(score),
726
- "feedback": feedback,
727
- "suggestion": feedback,
728
- "audio_url": None,
729
- })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
pronragg.py DELETED
@@ -1,263 +0,0 @@
1
- import os
2
- import json
3
- import base64
4
- import tempfile
5
- import subprocess
6
- import re
7
- import random
8
-
9
- from flask import Blueprint, request, jsonify
10
- from flask_cors import CORS
11
- from pydub import AudioSegment
12
- from faster_whisper import WhisperModel
13
- from rapidfuzz.distance import Levenshtein
14
- import chromadb
15
-
16
- pronragg_bp = Blueprint("pronragg", __name__)
17
-
18
-
19
- # --------------------------------------------------
20
- # CONFIG
21
- # --------------------------------------------------
22
- BASE_DIR = os.path.dirname(os.path.abspath(__file__))
23
-
24
- VIDEO_PATH = os.path.join(BASE_DIR, "feedback.mp4")
25
- JSON_PATH = os.path.join(BASE_DIR, "teacher_feedback_sentences_category.json")
26
- CHROMA_DIR = os.path.join(BASE_DIR, "chroma_db")
27
-
28
- WHISPER_MODEL = "base"
29
- SAFE_PADDING = 0.05
30
- PAUSE_SECONDS = 0.5
31
- MAX_SEGMENTS_PER_CATEGORY = 3
32
-
33
- # Issue priority (VERY IMPORTANT)
34
- ISSUE_PRIORITY = [
35
- "silence",
36
- "multipleword",
37
- "wrong_word",
38
- "consonant",
39
- "vowel",
40
- "ending",
41
- "syllable",
42
- "stress",
43
- "success"
44
- ]
45
-
46
- # --------------------------------------------------
47
- # INIT MODELS
48
- # --------------------------------------------------
49
- whisper = WhisperModel(
50
- WHISPER_MODEL,
51
- device="cpu",
52
- compute_type="int8"
53
- )
54
-
55
- # --------------------------------------------------
56
- # CHROMA INIT
57
- # --------------------------------------------------
58
- client = chromadb.PersistentClient(path=CHROMA_DIR)
59
- collection = client.get_or_create_collection("feedback")
60
-
61
- def init_segments():
62
- if collection.count() > 0:
63
- return
64
-
65
- with open(JSON_PATH, "r", encoding="utf-8") as f:
66
- data = json.load(f)
67
-
68
- for item in data:
69
- collection.add(
70
- ids=[item["id"]],
71
- documents=[item["text"]],
72
- metadatas=[{
73
- "category": item["category"],
74
- "start": item["start"],
75
- "end": item["end"]
76
- }]
77
- )
78
-
79
- init_segments()
80
-
81
- # --------------------------------------------------
82
- # HELPERS
83
- # --------------------------------------------------
84
- def normalize_text(text: str) -> str:
85
- return re.sub(r"[^a-z]", "", text.lower().strip())
86
-
87
- def transcribe(wav_path: str) -> str:
88
- segments, _ = whisper.transcribe(
89
- wav_path,
90
- language="en",
91
- beam_size=5,
92
- vad_filter=True
93
- )
94
- return "".join(s.text for s in segments).strip().lower()
95
-
96
- # --------------------------------------------------
97
- # PRONUNCIATION LOGIC (FIXED)
98
- # --------------------------------------------------
99
- def analyze(expected: str, heard_raw: str):
100
- expected_n = normalize_text(expected)
101
- heard_n = normalize_text(heard_raw)
102
-
103
- if not heard_n:
104
- return ["silence"], 0
105
-
106
- if len(heard_raw.strip().split()) > 1:
107
- return ["multipleword"], 20
108
-
109
- similarity = Levenshtein.normalized_similarity(expected_n, heard_n)
110
- score = int(similarity * 100)
111
-
112
- if similarity < 0.30:
113
- return ["wrong_word"], score
114
-
115
- detected = []
116
-
117
- vowels = "aeiou"
118
- def is_vowel(ch: str) -> bool:
119
- return ch in vowels
120
-
121
- # First-letter mismatch: classify based on expected character category
122
- if expected_n[0] != heard_n[0]:
123
- if is_vowel(expected_n[0]):
124
- detected.append("vowel")
125
- else:
126
- detected.append("consonant")
127
-
128
- # Vowel sequence mismatch (only add if not already classified as a vowel)
129
- expected_vowels = [c for c in expected_n if c in vowels]
130
- heard_vowels = [c for c in heard_n if c in vowels]
131
- if expected_vowels != heard_vowels and "vowel" not in detected:
132
- detected.append("vowel")
133
-
134
- # Ending error
135
- if expected_n[-1] != heard_n[-1]:
136
- detected.append("ending")
137
-
138
- # Syllable error
139
- if abs(len(expected_n) - len(heard_n)) >= 2:
140
- detected.append("syllable")
141
-
142
- # Stress error
143
- if similarity < 0.85 and not detected:
144
- detected.append("stress")
145
-
146
- if not detected:
147
- return ["success"], score
148
-
149
- # Pick ONLY ONE issue using priority
150
- for p in ISSUE_PRIORITY:
151
- if p in detected:
152
- return [p], score
153
-
154
- return ["success"], score
155
-
156
- # --------------------------------------------------
157
- # FETCH SEGMENTS (STRICT)
158
- # --------------------------------------------------
159
- def fetch_segments(categories):
160
- if not categories:
161
- return []
162
-
163
- category = categories[0]
164
-
165
- result = collection.get(where={"category": category})
166
- metas = result.get("metadatas", [])
167
-
168
- # STRICT FILTER (important)
169
- metas = [m for m in metas if m.get("category") == category]
170
-
171
- if not metas:
172
- return []
173
-
174
- random.shuffle(metas)
175
- return metas[:MAX_SEGMENTS_PER_CATEGORY]
176
-
177
- # --------------------------------------------------
178
- # BUILD VIDEO WITH FREEZE-HOLD PAUSE
179
- # --------------------------------------------------
180
- def build_video(segments):
181
- if not segments:
182
- return ""
183
-
184
- segments = sorted(segments, key=lambda x: x["start"])
185
- clips = []
186
-
187
- for i, seg in enumerate(segments):
188
- clip = tempfile.NamedTemporaryFile(delete=False, suffix=".mp4")
189
-
190
- pause = PAUSE_SECONDS if i < len(segments) - 1 else 0
191
-
192
- subprocess.run(
193
- [
194
- "ffmpeg", "-y",
195
- "-ss", str(max(0, seg["start"] - SAFE_PADDING)),
196
- "-to", str(seg["end"] + SAFE_PADDING),
197
- "-i", VIDEO_PATH,
198
- "-vf", f"tpad=stop_mode=clone:stop_duration={pause}",
199
- "-af", f"apad=pad_dur={pause}",
200
- "-c:v", "libx264",
201
- "-c:a", "aac",
202
- "-movflags", "+faststart",
203
- clip.name
204
- ],
205
- stdout=subprocess.DEVNULL,
206
- stderr=subprocess.DEVNULL
207
- )
208
-
209
- clips.append(clip.name)
210
-
211
- concat_file = tempfile.NamedTemporaryFile(delete=False, suffix=".txt")
212
- with open(concat_file.name, "w") as f:
213
- for c in clips:
214
- f.write(f"file '{c}'\n")
215
-
216
- final_video = tempfile.NamedTemporaryFile(delete=False, suffix=".mp4")
217
- subprocess.run(
218
- [
219
- "ffmpeg", "-y",
220
- "-f", "concat",
221
- "-safe", "0",
222
- "-i", concat_file.name,
223
- "-c:v", "libx264",
224
- "-c:a", "aac",
225
- final_video.name
226
- ],
227
- stdout=subprocess.DEVNULL,
228
- stderr=subprocess.DEVNULL
229
- )
230
-
231
- with open(final_video.name, "rb") as f:
232
- return base64.b64encode(f.read()).decode("utf-8")
233
-
234
- # --------------------------------------------------
235
- # API
236
- # --------------------------------------------------
237
- @pronragg_bp.route("/score", methods=["POST"])
238
- def score_pronunciation():
239
- expected = request.form.get("word", "").strip()
240
- audio = request.files.get("audio")
241
-
242
- if not expected or not audio:
243
- return jsonify({"error": "Missing input"}), 400
244
-
245
- temp = tempfile.NamedTemporaryFile(delete=False, suffix=".webm")
246
- audio.save(temp.name)
247
-
248
- wav = temp.name.replace(".webm", ".wav")
249
- AudioSegment.from_file(temp.name).export(wav, format="wav")
250
-
251
- heard = transcribe(wav)
252
- issues, score = analyze(expected, heard)
253
-
254
- segments = fetch_segments(issues) or fetch_segments(["silence"])
255
- video_blob = build_video(segments)
256
-
257
- return jsonify({
258
- "expected": expected,
259
- "heard": heard,
260
- "issues": issues,
261
- "score": score,
262
- "videoBlobBase64": video_blob
263
- })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
pronragupgrade.py → pronunciation.py RENAMED
@@ -5,24 +5,22 @@ import tempfile
5
  import subprocess
6
  import soundfile as sf
7
  import numpy as np
8
- import json
9
  import base64
10
  import random
11
  import chromadb
12
  import eng_to_ipa as ipa
13
- from flask import Flask, request, jsonify,Blueprint
14
- from flask_cors import CORS
15
  from transformers import Wav2Vec2Processor, Wav2Vec2ForCTC
16
 
17
- pronragupgrade_bp = Blueprint("pronragupgrade", __name__)
18
 
19
  # ==================================================
20
  # 1. SETUP & CONFIG
21
  # ==================================================
22
  BASE_DIR = os.path.dirname(os.path.abspath(__file__))
23
- VIDEO_PATH = os.path.join(BASE_DIR, "feedback.mp4")
24
- JSON_PATH = os.path.join(BASE_DIR, "teacher_feedback_sentences_category.json")
25
- CHROMA_DIR = os.path.join(BASE_DIR, "chroma_db")
26
 
27
  DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
28
  MODEL_ID = "moxeeeem/wav2vec2-finetuned-pronunciation-correction"
@@ -38,97 +36,9 @@ model.eval()
38
  client = chromadb.PersistentClient(path=CHROMA_DIR)
39
  collection = client.get_or_create_collection("feedback")
40
 
41
- def init_segments():
42
- if collection.count() > 0:
43
- return
44
-
45
- if not os.path.exists(JSON_PATH):
46
- print(f"Warning: JSON file not found at {JSON_PATH}")
47
- # Create more comprehensive dummy data with multiple clips per category
48
- dummy_data = [
49
- # Syllable category clips
50
- {"id": 1, "text": "Let's work on syllable count", "category": "syllable", "start": 0, "end": 5},
51
- {"id": 2, "text": "That word has multiple syllables", "category": "syllable", "start": 5, "end": 10},
52
- {"id": 3, "text": "Make sure you pronounce all syllables", "category": "syllable", "start": 10, "end": 15},
53
-
54
- # Ending category clips
55
- {"id": 4, "text": "Focus on the ending sound", "category": "ending", "start": 15, "end": 20},
56
- {"id": 5, "text": "Don't forget the final consonant", "category": "ending", "start": 20, "end": 25},
57
- {"id": 6, "text": "Complete the word properly", "category": "ending", "start": 25, "end": 30},
58
-
59
- # Vowel category clips
60
- {"id": 7, "text": "Let's work on vowel sounds", "category": "vowel", "start": 30, "end": 35},
61
- {"id": 8, "text": "The vowel should be clear", "category": "vowel", "start": 35, "end": 40},
62
- {"id": 9, "text": "Focus on vowel quality", "category": "vowel", "start": 40, "end": 45},
63
-
64
- # Consonant category clips
65
- {"id": 10, "text": "Articulate consonants clearly", "category": "consonant", "start": 45, "end": 50},
66
- {"id": 11, "text": "Consonants should be crisp", "category": "consonant", "start": 50, "end": 55},
67
- {"id": 12, "text": "Work on consonant sounds", "category": "consonant", "start": 55, "end": 60},
68
-
69
- # Stress category clips
70
- {"id": 13, "text": "Focus on word stress", "category": "stress", "start": 60, "end": 65},
71
- {"id": 14, "text": "Emphasize the correct syllable", "category": "stress", "start": 65, "end": 70},
72
- {"id": 15, "text": "Watch your rhythm and stress", "category": "stress", "start": 70, "end": 75},
73
-
74
- # Success category clips
75
- {"id": 16, "text": "Excellent work!", "category": "success", "start": 75, "end": 80},
76
- {"id": 17, "text": "Great pronunciation!", "category": "success", "start": 80, "end": 85},
77
- {"id": 18, "text": "Keep up the good work!", "category": "success", "start": 85, "end": 90},
78
-
79
- # Wrong word category clips
80
- {"id": 19, "text": "That sounds like a different word", "category": "wrong_word", "start": 90, "end": 95},
81
- {"id": 20, "text": "Please say the target word", "category": "wrong_word", "start": 95, "end": 100},
82
-
83
- # Multiple words category clips
84
- {"id": 21, "text": "Say just one word please", "category": "multiple_words", "start": 100, "end": 105},
85
- {"id": 22, "text": "Focus on a single word", "category": "multiple_words", "start": 105, "end": 110},
86
-
87
- # Silence category clips
88
- {"id": 23, "text": "I couldn't hear anything", "category": "silence", "start": 110, "end": 115},
89
- {"id": 24, "text": "Please speak louder", "category": "silence", "start": 115, "end": 120},
90
-
91
- # Specific phoneme clips
92
- {"id": 25, "text": "For the 'æ' sound like in cat", "category": "vowel", "phoneme": "æ", "start": 120, "end": 125},
93
- {"id": 26, "text": "The 'r' should be soft", "category": "consonant", "phoneme": "r", "start": 125, "end": 130},
94
- {"id": 27, "text": "The 'ɪ' sound is short", "category": "vowel", "phoneme": "ɪ", "start": 130, "end": 135},
95
- {"id": 28, "text": "The 't' should be clear", "category": "consonant", "phoneme": "t", "start": 135, "end": 140},
96
- ]
97
- for item in dummy_data:
98
- meta = {"category": item["category"], "start": item["start"], "end": item["end"]}
99
- if "phoneme" in item:
100
- meta["phoneme"] = item["phoneme"]
101
- collection.add(ids=[str(item["id"])], documents=[item["text"]], metadatas=[meta])
102
- print(f"Created {len(dummy_data)} dummy video segments in ChromaDB")
103
- return
104
-
105
- with open(JSON_PATH, "r", encoding="utf-8") as f:
106
- data = json.load(f)
107
-
108
- for item in data:
109
- meta = {
110
- "category": item["category"],
111
- "start": item["start"],
112
- "end": item["end"]
113
- }
114
- if "phoneme" in item:
115
- meta["phoneme"] = item["phoneme"]
116
-
117
- collection.add(
118
- ids=[str(item["id"])],
119
- documents=[item["text"]],
120
- metadatas=[meta]
121
- )
122
-
123
- print(f"Loaded {len(data)} video segments into ChromaDB")
124
-
125
- init_segments()
126
-
127
  # ==================================================
128
  # 3. UK ENGLISH PRONUNCIATION SYSTEM
129
  # ==================================================
130
-
131
- # UK Phoneme Sound Database
132
  UK_PHONEME_DB = {
133
  "ɪ": {"name": "KIT vowel", "example": "sit", "tip": "Short front vowel", "type": "vowel"},
134
  "iː": {"name": "FLEECE vowel", "example": "see", "tip": "Long front vowel", "type": "vowel"},
@@ -142,13 +52,11 @@ UK_PHONEME_DB = {
142
  "ʌ": {"name": "STRUT vowel", "example": "cup", "tip": "Short mid back vowel", "type": "vowel"},
143
  "ɑː": {"name": "BATH vowel", "example": "father", "tip": "Long open back vowel", "type": "vowel"},
144
  "ɒ": {"name": "LOT vowel", "example": "hot", "tip": "Short open back rounded vowel", "type": "vowel"},
145
-
146
  "eɪ": {"name": "FACE diphthong", "example": "day", "tip": "Glide from e to ɪ", "type": "diphthong"},
147
  "aɪ": {"name": "PRICE diphthong", "example": "eye", "tip": "Glide from a to ɪ", "type": "diphthong"},
148
  "ɔɪ": {"name": "CHOICE diphthong", "example": "boy", "tip": "Glide from ɔ to ɪ", "type": "diphthong"},
149
  "aʊ": {"name": "MOUTH diphthong", "example": "now", "tip": "Glide from a to ʊ", "type": "diphthong"},
150
  "əʊ": {"name": "GOAT diphthong", "example": "go", "tip": "Glide from ə to ʊ", "type": "diphthong"},
151
-
152
  "p": {"name": "voiceless bilabial plosive", "example": "pen", "tip": "Explosive 'p' sound", "type": "consonant"},
153
  "b": {"name": "voiced bilabial plosive", "example": "bad", "tip": "Voiced 'b' with vibration", "type": "consonant"},
154
  "t": {"name": "voiceless alveolar plosive", "example": "tea", "tip": "Tongue tip on alveolar ridge", "type": "consonant"},
@@ -175,25 +83,9 @@ UK_PHONEME_DB = {
175
  "w": {"name": "labio-velar approximant", "example": "we", "tip": "Round lips", "type": "consonant"},
176
  }
177
 
178
- # Common words with syllable info
179
- COMMON_UK_WORDS = {
180
- "rabbit": {"phonemes": ["r", "æ", "b", "ɪ", "t"], "syllables": 2, "stress": "first"},
181
- "dog": {"phonemes": ["d", "ɒ", "ɡ"], "syllables": 1, "stress": "only"},
182
- "cat": {"phonemes": ["k", "æ", "t"], "syllables": 1, "stress": "only"},
183
- "water": {"phonemes": ["w", "ɔː", "t", "ə"], "syllables": 2, "stress": "first"},
184
- "hello": {"phonemes": ["h", "ɛ", "l", "əʊ"], "syllables": 2, "stress": "second"},
185
- "banana": {"phonemes": ["b", "ə", "n", "ɑː", "n", "ə"], "syllables": 3, "stress": "second"},
186
- "computer": {"phonemes": ["k", "ə", "m", "p", "j", "uː", "t", "ə"], "syllables": 3, "stress": "second"},
187
- "elephant": {"phonemes": ["ɛ", "l", "ɪ", "f", "ə", "n", "t"], "syllables": 3, "stress": "first"},
188
- }
189
-
190
  def get_uk_pronunciation(word):
191
- """Get UK pronunciation with syllable info."""
192
  word_lower = word.lower().strip()
193
 
194
- if word_lower in COMMON_UK_WORDS:
195
- return COMMON_UK_WORDS[word_lower]["phonemes"]
196
-
197
  try:
198
  ipa_str = ipa.convert(word)
199
  clean_ipa = re.sub(r'[ˈˌː]', '', ipa_str)
@@ -211,10 +103,8 @@ def get_uk_pronunciation(word):
211
  i += 1
212
 
213
  return phonemes
214
- except Exception as e:
215
- print(f"Error getting IPA for {word}: {e}")
216
- if word_lower == "rabbit":
217
- return ["r", "æ", "b", "ɪ", "t"]
218
  phonemes = []
219
  for char in word_lower:
220
  if char in 'aeiou':
@@ -227,15 +117,6 @@ def get_uk_pronunciation(word):
227
  return phonemes
228
 
229
  def get_word_info(word):
230
- """Get syllable and stress info for a word."""
231
- word_lower = word.lower().strip()
232
-
233
- if word_lower in COMMON_UK_WORDS:
234
- return {
235
- "syllables": COMMON_UK_WORDS[word_lower]["syllables"],
236
- "stress": COMMON_UK_WORDS[word_lower]["stress"]
237
- }
238
-
239
  phonemes = get_uk_pronunciation(word)
240
  vowel_count = sum(1 for p in phonemes
241
  if UK_PHONEME_DB.get(p, {}).get('type') in ['vowel', 'diphthong'])
@@ -255,9 +136,7 @@ def get_word_info(word):
255
  # ==================================================
256
  # 4. CORRECTED PHONEME ANALYSIS
257
  # ==================================================
258
-
259
  def is_exact_phoneme_match(ref, stu):
260
- """STRICT matching for accurate scoring."""
261
  if not stu:
262
  return False
263
 
@@ -278,8 +157,7 @@ def is_exact_phoneme_match(ref, stu):
278
 
279
  return False
280
 
281
- def analyze_pronunciation_strict(student_phonemes, reference_phonemes, word):
282
- """STRICT analysis."""
283
  if not student_phonemes:
284
  return {
285
  "score": 0,
@@ -342,22 +220,7 @@ def analyze_pronunciation_strict(student_phonemes, reference_phonemes, word):
342
  # ==================================================
343
  # 5. SCENARIO DETECTION
344
  # ==================================================
345
-
346
  class ScenarioDetector:
347
- """Scenario detection with correct priorities."""
348
-
349
- SCENARIO_PRIORITIES = [
350
- 'silence',
351
- 'multiple_words',
352
- 'wrong_word',
353
- 'syllable',
354
- 'ending',
355
- 'vowel',
356
- 'consonant',
357
- 'stress',
358
- 'success',
359
- ]
360
-
361
  @staticmethod
362
  def detect_silence(student_phonemes, audio_error=None):
363
  if audio_error:
@@ -664,7 +527,6 @@ class ScenarioDetector:
664
  ('stress', lambda: cls.detect_stress_issues(student_phonemes, reference_phonemes, word)),
665
  ('success', lambda: cls.detect_success(analysis_result, score)),
666
  ]
667
-
668
 
669
  for scenario_name, detector_func in detectors:
670
  result = detector_func()
@@ -682,214 +544,204 @@ class ScenarioDetector:
682
  }
683
 
684
  # ==================================================
685
- # 6. IMPROVED VIDEO RAG BUILDER - MERGES MULTIPLE PORTIONS
686
  # ==================================================
687
-
688
- # ==================================================
689
- # 6. IMPROVED VIDEO RAG BUILDER - SMART SELECTION
690
- # ==================================================
691
-
692
- def build_feedback_video(category, feedback_message, target_phoneme=None, student_errors=None):
693
- """
694
- Build feedback video with ordered, dynamic selection:
695
- - success: [praise] -> [move-to-next]
696
- - vowel: [specific phoneme] -> [one general]
697
- - consonant: [specific phoneme] -> [one general]
698
- - other categories: keep balanced/general strategies as before (2–3 clips)
699
-
700
- Returns:
701
- Base64 encoded video string with multiple merged clips
702
- """
703
  print(f"\n=== Building video for: {category} ===")
704
  print(f"Target phoneme: {target_phoneme}")
705
- print(f"Student errors: {student_errors}")
706
-
707
- # Extract target phoneme from errors if not provided
708
- if not target_phoneme and student_errors:
709
- for error in student_errors:
710
- if error.get("type") in ["vowel", "diphthong", "consonant"]:
711
- target_phoneme = error.get("expected")
712
- if target_phoneme:
713
- print(f"Extracted target phoneme from errors: {target_phoneme}")
714
- break
715
-
716
- # Extract target phoneme from feedback if present
717
  if not target_phoneme:
718
  m = re.search(r"'([^']+)'", feedback_message)
719
  target_phoneme = m.group(1) if m else None
720
- if target_phoneme:
721
- print(f"Extracted target phoneme from feedback: {target_phoneme}")
722
 
723
  selected_metadatas = []
724
 
725
  try:
726
- # Pull category clips
727
  gen_results = collection.get(where={"category": category})
728
- if not gen_results or not gen_results.get('metadatas'):
729
  print(f"No clips found for category: {category}")
730
  return ""
731
 
732
- metadatas = gen_results['metadatas']
733
- documents = gen_results.get('documents', [])
734
- # Safe zip in case of mismatch
735
  items = []
736
  for idx, meta in enumerate(metadatas):
737
  text = documents[idx] if idx < len(documents) else ""
738
  items.append({"meta": meta, "text": text})
739
 
740
- # Split generic vs specific (for vowel/consonant)
 
 
741
  generic_clips = []
742
- specific_clips = [] # list of tuples (meta, phoneme)
743
  for it in items:
744
  meta = it["meta"]
 
745
  clip_phoneme = meta.get("phoneme")
746
  if clip_phoneme:
747
- specific_clips.append((meta, clip_phoneme))
748
  else:
749
- # attach text for success/vowel/consonant classification later
750
  meta_copy = dict(meta)
751
- meta_copy["_text"] = it["text"]
752
  generic_clips.append(meta_copy)
753
 
754
  print(f"Found {len(generic_clips)} generic clips, {len(specific_clips)} specific clips")
755
 
756
- # Special ordering rules
757
- if category == "success":
758
- # First: praise message, then: move-next message (both random, dynamic)
759
- praise_keywords = ["good", "great", "perfect", "excellent", "well done", "nice", "clear"]
760
- next_keywords = ["next", "move"]
761
 
762
- # Build pools from generic success clips using text
763
- praise_pool = [m for m in generic_clips if any(k in m.get("_text", "").lower() for k in praise_keywords)]
764
- next_pool = [m for m in generic_clips if any(k in m.get("_text", "").lower() for k in next_keywords)]
 
 
 
765
 
766
- print(f"Success classification: praise={len(praise_pool)} next={len(next_pool)}")
 
 
767
 
768
- # Pick first (praise) randomly
769
- first_clip = random.choice(praise_pool) if praise_pool else (random.choice(generic_clips) if generic_clips else None)
 
 
 
770
 
771
- # Pick second (move-next) randomly and ensure different from first
772
- if next_pool:
773
- next_candidates = [m for m in next_pool if f"{m.get('start')}_{m.get('end')}" != f"{first_clip.get('start')}_{first_clip.get('end')}" ] if first_clip else next_pool
774
- second_clip = random.choice(next_candidates) if next_candidates else None
775
- else:
776
- # Fallback: pick any other success generic clip different from first
777
- alt_candidates = [m for m in generic_clips if f"{m.get('start')}_{m.get('end')}" != f"{first_clip.get('start')}_{first_clip.get('end')}" ] if first_clip else generic_clips
778
- second_clip = random.choice(alt_candidates) if len(alt_candidates) > 0 else None
779
-
780
- selected_metadatas.clear()
781
- if first_clip:
782
- selected_metadatas.append(first_clip)
783
- if second_clip:
784
- selected_metadatas.append(second_clip)
785
-
786
- elif category in ["vowel", "consonant"]:
787
- # Specific first, then exactly one general
788
- specific_found = False
789
-
790
- # 1) exact phoneme
791
- if target_phoneme:
792
- for meta, clip_phoneme in specific_clips:
793
- if clip_phoneme == target_phoneme:
794
- selected_metadatas.append(meta)
795
- specific_found = True
796
- print(f"✓ Selected specific {category} clip for phoneme: {target_phoneme}")
797
- break
798
-
799
- # 2) related fallback (mostly for vowels)
800
- if not specific_found and target_phoneme and category == "vowel":
801
- vowel_groups = {
802
- 'ɪ': ['iː', 'i'], 'iː': ['ɪ', 'i'],
803
- 'æ': ['a', 'ɑː'], 'ɑː': ['æ', 'a'],
804
- 'ʊ': ['uː', 'u'], 'uː': ['ʊ', 'u'],
805
- 'ɒ': ['ɔ', 'ɔː'], 'ɔː': ['ɒ', 'ɔ'],
806
- }
807
- related_phonemes = vowel_groups.get(target_phoneme, [])
808
- for meta, clip_phoneme in specific_clips:
809
- if clip_phoneme in related_phonemes:
810
- selected_metadatas.append(meta)
811
- specific_found = True
812
- print(f"✓ Selected related vowel clip: {clip_phoneme} for target {target_phoneme}")
813
- break
814
-
815
- # 3) If still not found and we have any specific clip with same category, prefer one that exists
816
- if not specific_found and specific_clips:
817
- fallback_meta, fallback_ph = random.choice(specific_clips)
818
- selected_metadatas.append(fallback_meta)
819
- specific_found = True
820
- print(f"✓ Fallback to available specific {category} clip: {fallback_ph}")
821
-
822
- # Then exactly one general
823
- if generic_clips:
824
- general_choice = random.choice(generic_clips)
825
- selected_metadatas.append(general_choice)
826
- print("✓ Added one general clip after specific")
827
-
828
- # Note: If no generic and only specific found, we keep only one clip.
829
- # If no specific and generic exists, we keep one general clip (as requested “only one general”).
830
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
831
  else:
832
- # Keep existing smart strategy for other categories
833
- selection_strategy = "balanced"
834
- if category in ["syllable", "ending", "stress"]:
835
- selection_strategy = "general_focus"
836
-
837
- print(f"Using selection strategy: {selection_strategy}")
838
-
839
- if selection_strategy == "general_focus":
840
- if generic_clips:
841
- selected_generic = random.sample(generic_clips, min(2, len(generic_clips)))
842
- selected_metadatas.extend(selected_generic)
843
- # Add a specific if relevant and space remains
844
- if target_phoneme and len(selected_metadatas) < 3:
845
- for meta, clip_phoneme in specific_clips:
846
- if clip_phoneme == target_phoneme:
847
- selected_metadatas.append(meta)
848
- print(f"✓ Added specific clip for: {target_phoneme}")
849
- break
 
 
 
 
 
 
850
  else:
851
- # balanced
852
- if generic_clips:
853
- selected_metadatas.append(random.choice(generic_clips))
854
- if target_phoneme:
855
- for meta, clip_phoneme in specific_clips:
856
- if clip_phoneme == target_phoneme:
857
- selected_metadatas.append(meta)
858
- print(f"✓ Selected specific clip for: {target_phoneme}")
859
- break
860
- # Fill with additional generic if needed
 
 
 
 
 
861
  if len(selected_metadatas) < 2 and generic_clips:
862
- remaining = [c for c in generic_clips if c not in selected_metadatas]
863
  if remaining:
864
  selected_metadatas.append(random.choice(remaining))
865
 
866
- # Remove duplicates while preserving order
867
  unique_metadatas = []
868
  seen = set()
869
  for meta in selected_metadatas:
870
- key = f"{meta.get('start')}_{meta.get('end')}"
871
  if key not in seen:
872
  seen.add(key)
873
  unique_metadatas.append(meta)
874
-
875
  selected_metadatas = unique_metadatas
876
 
877
- # Ensure minimum clips but DO NOT violate vowel/consonant rule (only one general)
878
- if category not in ["vowel", "consonant"]:
879
- if len(selected_metadatas) < 2 and generic_clips:
880
- needed = 2 - len(selected_metadatas)
881
- remaining = [c for c in generic_clips if c not in selected_metadatas]
882
- if remaining:
883
- selected_metadatas.extend(random.sample(remaining, min(needed, len(remaining))))
884
-
885
  if len(selected_metadatas) == 0:
886
  print("No clips selected after filtering.")
887
  return ""
888
 
889
  print(f"Selected {len(selected_metadatas)} video clips:")
890
- for i, meta in enumerate(selected_metadatas):
891
- phoneme = meta.get('phoneme', 'generic')
892
- print(f" Clip {i+1}: {meta.get('category')} - {phoneme} [{meta.get('start')}->{meta.get('end')}]")
893
 
894
  # --- FFmpeg Processing ---
895
  if not os.path.exists(VIDEO_PATH):
@@ -901,37 +753,43 @@ def build_feedback_video(category, feedback_message, target_phoneme=None, studen
901
  final_video_path = None
902
 
903
  try:
904
- # Extract individual clips
905
  for i, seg in enumerate(selected_metadatas):
906
  tmp_clip = tempfile.NamedTemporaryFile(delete=False, suffix=f"_{i}.mp4")
907
  tmp_clip.close()
908
 
909
- # Extract segment
910
- subprocess.run([
911
- "ffmpeg", "-y", "-ss", str(seg["start"]), "-to", str(seg["end"]),
912
- "-i", VIDEO_PATH, "-c:v", "libx264", "-preset", "ultrafast",
913
- "-crf", "28", "-c:a", "aac", tmp_clip.name
914
- ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
 
 
 
 
 
 
915
 
916
  clips.append(tmp_clip.name)
917
 
918
- # Create concat list
919
  concat_file = tempfile.NamedTemporaryFile(delete=False, suffix=".txt", mode="w")
920
  for clip_path in clips:
921
  concat_file.write(f"file '{os.path.abspath(clip_path)}'\n")
922
  concat_file.close()
923
 
924
- # Create final video
925
  final_video_path = tempfile.NamedTemporaryFile(delete=False, suffix="_final.mp4")
926
  final_video_path.close()
927
 
928
- # Concatenate
929
- subprocess.run([
930
- "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", concat_file.name,
931
- "-c", "copy", final_video_path.name
932
- ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
 
 
 
 
933
 
934
- # Encode to Base64
935
  with open(final_video_path.name, "rb") as f:
936
  v_data = base64.b64encode(f.read()).decode()
937
 
@@ -943,7 +801,6 @@ def build_feedback_video(category, feedback_message, target_phoneme=None, studen
943
  return ""
944
 
945
  finally:
946
- # Cleanup
947
  if concat_file and os.path.exists(concat_file.name):
948
  os.remove(concat_file.name)
949
 
@@ -957,12 +814,11 @@ def build_feedback_video(category, feedback_message, target_phoneme=None, studen
957
  except Exception as e:
958
  print(f"✗ Video generation error: {e}")
959
  return ""
 
960
  # ==================================================
961
  # 7. AUDIO PROCESSING
962
  # ==================================================
963
-
964
  def process_audio_file(audio_path):
965
- """Process audio file."""
966
  try:
967
  wav_path = audio_path.replace('.webm', '.wav')
968
 
@@ -1007,53 +863,13 @@ def process_audio_file(audio_path):
1007
  return None, f"error: {str(e)}"
1008
 
1009
  # ==================================================
1010
- # 8. TEST VIDEO GENERATION
1011
  # ==================================================
1012
-
1013
- def test_video_generation():
1014
- """Test that video generation merges multiple clips."""
1015
- print("\n=== TESTING VIDEO GENERATION ===")
1016
-
1017
- test_cases = [
1018
- {"category": "syllable", "feedback": "Syllable issue", "target_phoneme": None},
1019
- {"category": "vowel", "feedback": "Vowel issue for 'æ'", "target_phoneme": "æ"},
1020
- {"category": "consonant", "feedback": "Consonant issue for 'r'", "target_phoneme": "r"},
1021
- {"category": "ending", "feedback": "Missing final 't'", "target_phoneme": "t"},
1022
- ]
1023
-
1024
- for test in test_cases:
1025
- print(f"\nTesting category: {test['category']}")
1026
- video_blob = build_feedback_video(
1027
- test['category'],
1028
- test['feedback'],
1029
- test['target_phoneme']
1030
- )
1031
-
1032
- if video_blob:
1033
- print(f"✓ Video generated successfully ({len(video_blob)} bytes)")
1034
- print(f" Contains multiple merged clips")
1035
- else:
1036
- print(f"✗ Failed to generate video")
1037
-
1038
- # Also test with just the feedback message
1039
- video_blob2 = build_feedback_video(
1040
- test['category'],
1041
- test['feedback']
1042
- )
1043
-
1044
- if video_blob2:
1045
- print(f"✓ Video also works without explicit target phoneme")
1046
-
1047
- print("\n" + "="*60)
1048
-
1049
- # ==================================================
1050
- # 9. MAIN ENDPOINT
1051
- # ==================================================
1052
-
1053
- @pronragupgrade_bp.route("/score", methods=["POST"])
1054
  def train_pronunciation():
1055
- """Main endpoint with multi-clip video feedback."""
1056
  try:
 
 
1057
  word = request.form.get('word', '').strip().lower()
1058
  if not word:
1059
  return jsonify({
@@ -1078,17 +894,11 @@ def train_pronunciation():
1078
  print(f"\n=== Processing: '{word}' ===")
1079
 
1080
  try:
1081
- # Process audio
1082
  student_phonemes, audio_error = process_audio_file(temp_path)
1083
-
1084
- # Get reference
1085
  reference_phonemes = get_uk_pronunciation(word)
1086
-
1087
- # Analyze
1088
- analysis = analyze_pronunciation_strict(student_phonemes, reference_phonemes, word)
1089
  score = analysis["score"]
1090
 
1091
- # Detect scenario
1092
  scenario_info = ScenarioDetector.detect_scenarios(
1093
  student_phonemes=student_phonemes,
1094
  reference_phonemes=reference_phonemes,
@@ -1103,11 +913,9 @@ def train_pronunciation():
1103
  action = scenario_info.get('action', '')
1104
  target_phoneme = scenario_info.get('target_phoneme')
1105
 
1106
- # Generate video with MULTIPLE clips
1107
  print(f"Generating video for category: {category}")
1108
  video_blob = build_feedback_video(category, feedback, target_phoneme)
1109
 
1110
- # Prepare response
1111
  response = {
1112
  "success": True,
1113
  "scenario": scenario,
@@ -1144,3 +952,4 @@ def train_pronunciation():
1144
  "scenario": "system_error"
1145
  }), 500
1146
 
 
 
5
  import subprocess
6
  import soundfile as sf
7
  import numpy as np
 
8
  import base64
9
  import random
10
  import chromadb
11
  import eng_to_ipa as ipa
12
+ from flask import Blueprint
 
13
  from transformers import Wav2Vec2Processor, Wav2Vec2ForCTC
14
 
15
+ pronunciation_bp = Blueprint("pronunciation", __name__)
16
 
17
  # ==================================================
18
  # 1. SETUP & CONFIG
19
  # ==================================================
20
  BASE_DIR = os.path.dirname(os.path.abspath(__file__))
21
+ print("BASE_DIR:", BASE_DIR)
22
+ VIDEO_PATH = os.path.join(BASE_DIR, "assets/feedback.mp4")
23
+ CHROMA_DIR = os.path.join(BASE_DIR, "assets/chroma_db")
24
 
25
  DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
26
  MODEL_ID = "moxeeeem/wav2vec2-finetuned-pronunciation-correction"
 
36
  client = chromadb.PersistentClient(path=CHROMA_DIR)
37
  collection = client.get_or_create_collection("feedback")
38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  # ==================================================
40
  # 3. UK ENGLISH PRONUNCIATION SYSTEM
41
  # ==================================================
 
 
42
  UK_PHONEME_DB = {
43
  "ɪ": {"name": "KIT vowel", "example": "sit", "tip": "Short front vowel", "type": "vowel"},
44
  "iː": {"name": "FLEECE vowel", "example": "see", "tip": "Long front vowel", "type": "vowel"},
 
52
  "ʌ": {"name": "STRUT vowel", "example": "cup", "tip": "Short mid back vowel", "type": "vowel"},
53
  "ɑː": {"name": "BATH vowel", "example": "father", "tip": "Long open back vowel", "type": "vowel"},
54
  "ɒ": {"name": "LOT vowel", "example": "hot", "tip": "Short open back rounded vowel", "type": "vowel"},
 
55
  "eɪ": {"name": "FACE diphthong", "example": "day", "tip": "Glide from e to ɪ", "type": "diphthong"},
56
  "aɪ": {"name": "PRICE diphthong", "example": "eye", "tip": "Glide from a to ɪ", "type": "diphthong"},
57
  "ɔɪ": {"name": "CHOICE diphthong", "example": "boy", "tip": "Glide from ɔ to ɪ", "type": "diphthong"},
58
  "aʊ": {"name": "MOUTH diphthong", "example": "now", "tip": "Glide from a to ʊ", "type": "diphthong"},
59
  "əʊ": {"name": "GOAT diphthong", "example": "go", "tip": "Glide from ə to ʊ", "type": "diphthong"},
 
60
  "p": {"name": "voiceless bilabial plosive", "example": "pen", "tip": "Explosive 'p' sound", "type": "consonant"},
61
  "b": {"name": "voiced bilabial plosive", "example": "bad", "tip": "Voiced 'b' with vibration", "type": "consonant"},
62
  "t": {"name": "voiceless alveolar plosive", "example": "tea", "tip": "Tongue tip on alveolar ridge", "type": "consonant"},
 
83
  "w": {"name": "labio-velar approximant", "example": "we", "tip": "Round lips", "type": "consonant"},
84
  }
85
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  def get_uk_pronunciation(word):
 
87
  word_lower = word.lower().strip()
88
 
 
 
 
89
  try:
90
  ipa_str = ipa.convert(word)
91
  clean_ipa = re.sub(r'[ˈˌː]', '', ipa_str)
 
103
  i += 1
104
 
105
  return phonemes
106
+ except Exception:
107
+ # Simple fallback for basic words
 
 
108
  phonemes = []
109
  for char in word_lower:
110
  if char in 'aeiou':
 
117
  return phonemes
118
 
119
  def get_word_info(word):
 
 
 
 
 
 
 
 
 
120
  phonemes = get_uk_pronunciation(word)
121
  vowel_count = sum(1 for p in phonemes
122
  if UK_PHONEME_DB.get(p, {}).get('type') in ['vowel', 'diphthong'])
 
136
  # ==================================================
137
  # 4. CORRECTED PHONEME ANALYSIS
138
  # ==================================================
 
139
  def is_exact_phoneme_match(ref, stu):
 
140
  if not stu:
141
  return False
142
 
 
157
 
158
  return False
159
 
160
+ def analyze_pronunciation_strict(student_phonemes, reference_phonemes):
 
161
  if not student_phonemes:
162
  return {
163
  "score": 0,
 
220
  # ==================================================
221
  # 5. SCENARIO DETECTION
222
  # ==================================================
 
223
  class ScenarioDetector:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
  @staticmethod
225
  def detect_silence(student_phonemes, audio_error=None):
226
  if audio_error:
 
527
  ('stress', lambda: cls.detect_stress_issues(student_phonemes, reference_phonemes, word)),
528
  ('success', lambda: cls.detect_success(analysis_result, score)),
529
  ]
 
530
 
531
  for scenario_name, detector_func in detectors:
532
  result = detector_func()
 
544
  }
545
 
546
  # ==================================================
547
+ # 6. VIDEO RAG BUILDER
548
  # ==================================================
549
+ def build_feedback_video(category, feedback_message, target_phoneme=None):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
550
  print(f"\n=== Building video for: {category} ===")
551
  print(f"Target phoneme: {target_phoneme}")
552
+
 
 
 
 
 
 
 
 
 
 
 
553
  if not target_phoneme:
554
  m = re.search(r"'([^']+)'", feedback_message)
555
  target_phoneme = m.group(1) if m else None
 
 
556
 
557
  selected_metadatas = []
558
 
559
  try:
 
560
  gen_results = collection.get(where={"category": category})
561
+ if not gen_results or not gen_results.get("metadatas"):
562
  print(f"No clips found for category: {category}")
563
  return ""
564
 
565
+ metadatas = gen_results["metadatas"]
566
+ documents = gen_results.get("documents", [])
567
+
568
  items = []
569
  for idx, meta in enumerate(metadatas):
570
  text = documents[idx] if idx < len(documents) else ""
571
  items.append({"meta": meta, "text": text})
572
 
573
+ # Split into:
574
+ # - specific clips = has phoneme in metadata
575
+ # - generic clips = no phoneme in metadata
576
  generic_clips = []
577
+ specific_clips = []
578
  for it in items:
579
  meta = it["meta"]
580
+ text = it["text"] or ""
581
  clip_phoneme = meta.get("phoneme")
582
  if clip_phoneme:
583
+ specific_clips.append({"meta": meta, "phoneme": clip_phoneme, "text": text})
584
  else:
 
585
  meta_copy = dict(meta)
586
+ meta_copy["_text"] = text
587
  generic_clips.append(meta_copy)
588
 
589
  print(f"Found {len(generic_clips)} generic clips, {len(specific_clips)} specific clips")
590
 
591
+ def _seg_key(m):
592
+ return f"{m.get('start')}_{m.get('end')}"
 
 
 
593
 
594
+ def pick_generic(exclude_keys=None):
595
+ exclude_keys = exclude_keys or set()
596
+ pool = [m for m in generic_clips if _seg_key(m) not in exclude_keys]
597
+ if pool:
598
+ return random.choice(pool)
599
+ return None
600
 
601
+ def pick_specific_for_phoneme(target, related_map=None, exclude_keys=None):
602
+ exclude_keys = exclude_keys or set()
603
+ related_map = related_map or {}
604
 
605
+ # 1) exact match
606
+ if target:
607
+ for it in specific_clips:
608
+ if it["phoneme"] == target and _seg_key(it["meta"]) not in exclude_keys:
609
+ return it["meta"]
610
 
611
+ # 2) related (mainly for vowels)
612
+ if target and target in related_map:
613
+ for rel in related_map[target]:
614
+ for it in specific_clips:
615
+ if it["phoneme"] == rel and _seg_key(it["meta"]) not in exclude_keys:
616
+ return it["meta"]
617
+
618
+ # 3) fallback any specific
619
+ pool = [it["meta"] for it in specific_clips if _seg_key(it["meta"]) not in exclude_keys]
620
+ if pool:
621
+ return random.choice(pool)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
622
 
623
+ return None
624
+
625
+ # -------------------------
626
+ # REQUIRED CHANGE:
627
+ # For vowel/consonant:
628
+ # Always try to return TWO clips in this order:
629
+ # 1) specific phoneme clip (target phoneme)
630
+ # 2) general clip (generic feedback of vowel/consonant)
631
+ # -------------------------
632
+ if category in ["vowel", "consonant"]:
633
+ exclude = set()
634
+
635
+ vowel_groups = {
636
+ "ɪ": ["iː", "i"], "iː": ["ɪ", "i"],
637
+ "æ": ["a", "ɑː"], "ɑː": ["æ", "a"],
638
+ "ʊ": ["uː", "u"], "uː": ["ʊ", "u"],
639
+ "ɒ": ["ɔ", "ɔː"], "ɔː": ["ɒ", "ɔ"],
640
+ }
641
+
642
+ related_map = vowel_groups if category == "vowel" else {}
643
+
644
+ # 1) Pick SPECIFIC (phoneme)
645
+ specific_meta = pick_specific_for_phoneme(target_phoneme, related_map=related_map, exclude_keys=exclude)
646
+ if specific_meta:
647
+ selected_metadatas.append(specific_meta)
648
+ exclude.add(_seg_key(specific_meta))
649
+ print(f"✓ Selected specific {category} clip for phoneme: {target_phoneme}")
650
+
651
+ # 2) Pick GENERAL (generic)
652
+ generic_meta = pick_generic(exclude_keys=exclude)
653
+ if generic_meta:
654
+ selected_metadatas.append(generic_meta)
655
+ exclude.add(_seg_key(generic_meta))
656
+ print("✓ Selected general (generic) clip")
657
+
658
+ # If still not 2 clips, try to fill with another different clip (best effort)
659
+ if len(selected_metadatas) < 2:
660
+ # try another generic first
661
+ extra_generic = pick_generic(exclude_keys=exclude)
662
+ if extra_generic:
663
+ selected_metadatas.append(extra_generic)
664
+ exclude.add(_seg_key(extra_generic))
665
+ print("✓ Filled missing slot with another generic clip")
666
+
667
+ if len(selected_metadatas) < 2:
668
+ # try another specific as last fallback
669
+ extra_specific = pick_specific_for_phoneme(None, related_map=None, exclude_keys=exclude)
670
+ if extra_specific:
671
+ selected_metadatas.append(extra_specific)
672
+ exclude.add(_seg_key(extra_specific))
673
+ print("✓ Filled missing slot with another specific clip")
674
+
675
+ # If we still cannot make 2 clips, we proceed with whatever we have.
676
+ # (Because the DB may not have enough clips.)
677
+ if not selected_metadatas:
678
+ print("✗ No clips selected for vowel/consonant.")
679
+ return ""
680
+
681
+ # -------------------------
682
+ # Existing logic for other categories (unchanged)
683
+ # -------------------------
684
  else:
685
+ if category == "success":
686
+ praise_keywords = ["good", "great", "perfect", "excellent", "well done", "nice", "clear"]
687
+ next_keywords = ["next", "move"]
688
+
689
+ praise_pool = [m for m in generic_clips if any(k in m.get("_text", "").lower() for k in praise_keywords)]
690
+ next_pool = [m for m in generic_clips if any(k in m.get("_text", "").lower() for k in next_keywords)]
691
+
692
+ print(f"Success classification: praise={len(praise_pool)} next={len(next_pool)}")
693
+
694
+ first_clip = random.choice(praise_pool) if praise_pool else (random.choice(generic_clips) if generic_clips else None)
695
+
696
+ if next_pool:
697
+ next_candidates = [m for m in next_pool if _seg_key(m) != _seg_key(first_clip)] if first_clip else next_pool
698
+ second_clip = random.choice(next_candidates) if next_candidates else None
699
+ else:
700
+ alt_candidates = [m for m in generic_clips if _seg_key(m) != _seg_key(first_clip)] if first_clip else generic_clips
701
+ second_clip = random.choice(alt_candidates) if len(alt_candidates) > 0 else None
702
+
703
+ selected_metadatas.clear()
704
+ if first_clip:
705
+ selected_metadatas.append(first_clip)
706
+ if second_clip:
707
+ selected_metadatas.append(second_clip)
708
+
709
  else:
710
+ selection_strategy = "balanced"
711
+ if category in ["syllable", "ending", "stress"]:
712
+ selection_strategy = "general_focus"
713
+
714
+ print(f"Using selection strategy: {selection_strategy}")
715
+
716
+ if selection_strategy == "general_focus":
717
+ if generic_clips:
718
+ selected_generic = random.sample(generic_clips, min(2, len(generic_clips)))
719
+ selected_metadatas.extend(selected_generic)
720
+ else:
721
+ if generic_clips:
722
+ selected_metadatas.append(random.choice(generic_clips))
723
+
724
+ # ensure at least 2 clips when possible
725
  if len(selected_metadatas) < 2 and generic_clips:
726
+ remaining = [c for c in generic_clips if _seg_key(c) not in {_seg_key(x) for x in selected_metadatas}]
727
  if remaining:
728
  selected_metadatas.append(random.choice(remaining))
729
 
730
+ # Deduplicate (safety)
731
  unique_metadatas = []
732
  seen = set()
733
  for meta in selected_metadatas:
734
+ key = _seg_key(meta)
735
  if key not in seen:
736
  seen.add(key)
737
  unique_metadatas.append(meta)
 
738
  selected_metadatas = unique_metadatas
739
 
 
 
 
 
 
 
 
 
740
  if len(selected_metadatas) == 0:
741
  print("No clips selected after filtering.")
742
  return ""
743
 
744
  print(f"Selected {len(selected_metadatas)} video clips:")
 
 
 
745
 
746
  # --- FFmpeg Processing ---
747
  if not os.path.exists(VIDEO_PATH):
 
753
  final_video_path = None
754
 
755
  try:
 
756
  for i, seg in enumerate(selected_metadatas):
757
  tmp_clip = tempfile.NamedTemporaryFile(delete=False, suffix=f"_{i}.mp4")
758
  tmp_clip.close()
759
 
760
+ subprocess.run(
761
+ [
762
+ "ffmpeg", "-y",
763
+ "-ss", str(seg["start"]), "-to", str(seg["end"]),
764
+ "-i", VIDEO_PATH,
765
+ "-c:v", "libx264", "-preset", "ultrafast",
766
+ "-crf", "28", "-c:a", "aac",
767
+ tmp_clip.name
768
+ ],
769
+ stdout=subprocess.DEVNULL,
770
+ stderr=subprocess.DEVNULL,
771
+ )
772
 
773
  clips.append(tmp_clip.name)
774
 
 
775
  concat_file = tempfile.NamedTemporaryFile(delete=False, suffix=".txt", mode="w")
776
  for clip_path in clips:
777
  concat_file.write(f"file '{os.path.abspath(clip_path)}'\n")
778
  concat_file.close()
779
 
 
780
  final_video_path = tempfile.NamedTemporaryFile(delete=False, suffix="_final.mp4")
781
  final_video_path.close()
782
 
783
+ subprocess.run(
784
+ [
785
+ "ffmpeg", "-y",
786
+ "-f", "concat", "-safe", "0", "-i", concat_file.name,
787
+ "-c", "copy", final_video_path.name
788
+ ],
789
+ stdout=subprocess.DEVNULL,
790
+ stderr=subprocess.DEVNULL,
791
+ )
792
 
 
793
  with open(final_video_path.name, "rb") as f:
794
  v_data = base64.b64encode(f.read()).decode()
795
 
 
801
  return ""
802
 
803
  finally:
 
804
  if concat_file and os.path.exists(concat_file.name):
805
  os.remove(concat_file.name)
806
 
 
814
  except Exception as e:
815
  print(f"✗ Video generation error: {e}")
816
  return ""
817
+
818
  # ==================================================
819
  # 7. AUDIO PROCESSING
820
  # ==================================================
 
821
  def process_audio_file(audio_path):
 
822
  try:
823
  wav_path = audio_path.replace('.webm', '.wav')
824
 
 
863
  return None, f"error: {str(e)}"
864
 
865
  # ==================================================
866
+ # 8. MAIN ENDPOINT
867
  # ==================================================
868
+ @pronunciation_bp.route("/score", methods=["POST"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
869
  def train_pronunciation():
 
870
  try:
871
+ from flask import request, jsonify
872
+
873
  word = request.form.get('word', '').strip().lower()
874
  if not word:
875
  return jsonify({
 
894
  print(f"\n=== Processing: '{word}' ===")
895
 
896
  try:
 
897
  student_phonemes, audio_error = process_audio_file(temp_path)
 
 
898
  reference_phonemes = get_uk_pronunciation(word)
899
+ analysis = analyze_pronunciation_strict(student_phonemes, reference_phonemes)
 
 
900
  score = analysis["score"]
901
 
 
902
  scenario_info = ScenarioDetector.detect_scenarios(
903
  student_phonemes=student_phonemes,
904
  reference_phonemes=reference_phonemes,
 
913
  action = scenario_info.get('action', '')
914
  target_phoneme = scenario_info.get('target_phoneme')
915
 
 
916
  print(f"Generating video for category: {category}")
917
  video_blob = build_feedback_video(category, feedback, target_phoneme)
918
 
 
919
  response = {
920
  "success": True,
921
  "scenario": scenario,
 
952
  "scenario": "system_error"
953
  }), 500
954
 
955
+
pronvideo.py DELETED
@@ -1,359 +0,0 @@
1
- import os
2
- import io
3
- import tempfile
4
- from flask import Flask, Blueprint, request, jsonify
5
- from flask_cors import CORS
6
- from pydub import AudioSegment
7
- from rapidfuzz.distance import Levenshtein
8
-
9
- # ASR - WhisperX (or Faster Whisper for Forced Alignment)
10
- try:
11
- from faster_whisper import WhisperModel
12
- HAS_WHISPER = True
13
- except Exception:
14
- HAS_WHISPER = False
15
-
16
- # Initialize the Flask app and Blueprint
17
-
18
- pronvideo_bp = Blueprint("pronvideo", __name__)
19
-
20
- # -----------------------------
21
- # Load Whisper model (CPU friendly)
22
- # -----------------------------
23
- WHISPER_MODEL_SIZE = os.getenv("WHISPER_MODEL_SIZE", "base")
24
- whisper_model = None
25
- if HAS_WHISPER:
26
- whisper_model = WhisperModel(
27
- WHISPER_MODEL_SIZE,
28
- device="cpu",
29
- compute_type="int8"
30
- )
31
-
32
- # -----------------------------
33
- # Helpers
34
- # -----------------------------
35
- def normalize(text: str) -> str:
36
- return "".join(ch for ch in text.lower().strip() if ch.isalpha() or ch.isspace())
37
-
38
- def phoneme_similarity_score(expected_ph: str, spoken_ph: str) -> int:
39
- if not expected_ph or not spoken_ph:
40
- return 0
41
- dist = Levenshtein.distance(expected_ph, spoken_ph)
42
- max_len = max(len(expected_ph), len(spoken_ph))
43
- similarity = 1 - (dist / max_len)
44
- score = int(round(similarity * 100))
45
- return max(0, min(100, score))
46
-
47
- def convert_to_wav_temp(upload_file) -> str:
48
- upload_file.stream.seek(0)
49
- raw = upload_file.stream.read()
50
- bio = io.BytesIO(raw)
51
- ext = os.path.splitext(upload_file.filename)[1].replace(".", "").lower() or None
52
-
53
- try:
54
- audio = AudioSegment.from_file(bio, format=ext if ext else None)
55
- except Exception:
56
- bio.seek(0)
57
- audio = AudioSegment.from_file(bio)
58
-
59
- audio = audio.set_channels(1).set_frame_rate(16000)
60
- tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".wav")
61
- audio.export(tmp.name, format="wav")
62
- return tmp.name
63
-
64
- def transcribe_audio(audio_path: str) -> str:
65
- if not HAS_WHISPER or whisper_model is None:
66
- raise RuntimeError("Whisper ASR is not installed/available.")
67
- segments, info = whisper_model.transcribe(
68
- audio_path,
69
- language="en",
70
- vad_filter=True
71
- )
72
- text_parts = []
73
- for seg in segments:
74
- if seg.text:
75
- text_parts.append(seg.text.strip())
76
- return " ".join(text_parts).strip()
77
-
78
- # -----------------------------
79
- # Video feedback helpers
80
- # -----------------------------
81
- def static_video_for(kind: str):
82
- mapping = {
83
- "success": {"videoId": "video-success", "videoUrl": "/assets/pronvideo/feedback/success.mp4", "hint": "Great job! Keep going."},
84
- "silence": {"videoId": "video-silence", "videoUrl": "/assets/pronvideo/feedback/silence.mp4", "hint": "Speak clearly into the mic for at least a second."},
85
- "wrong_word": {"videoId": "video-wrong-word", "videoUrl": "/assets/pronvideo/feedback/wrongword.mp4", "hint": "Please say only the target word."},
86
- "vowel": {"videoId": "video-vowel", "videoUrl": "/assets/pronvideo/feedback/vowel.mp4", "hint": "Work on vowel shape and length."},
87
- "consonant": {"videoId": "video-consonant", "videoUrl": "/assets/pronvideo/feedback/consonant.mp4", "hint": "Focus on consonant articulation, especially start/end sounds."},
88
- "stress": {"videoId": "video-stress", "videoUrl": "/assets/pronvideo/feedback/stress.mp4", "hint": "Emphasize the primary stressed syllable."},
89
- "syllable": {"videoId": "video-syllable", "videoUrl": "/assets/pronvideo/feedback/syllable.mp4", "hint": "Match the number of syllables and rhythm."},
90
- "ending": {"videoId": "video-ending", "videoUrl": "/assets/pronvideo/feedback/ending.mp4", "hint": "Work on the final sound—try to finish the word cleanly."},
91
- "multipleword": {"videoId": "video-multipleword", "videoUrl": "/assets/pronvideo/feedback/multipleword.mp4", "hint": "Please say only the target word, not multiple words."},
92
-
93
- }
94
- return mapping.get(kind, {"videoId": None, "videoUrl": None, "hint": None})
95
-
96
- # -----------------------------
97
- # Function to detect feedback based on pronunciation
98
- # -----------------------------
99
- def vowel_consonant_feedback(teacher_ph: str, student_ph: str):
100
- feedback = []
101
-
102
- # Split the IPA tokens into vowels and consonants
103
- t_tokens = split_ipa_tokens(teacher_ph)
104
- s_tokens = split_ipa_tokens(student_ph)
105
-
106
- # Vowel sequence check
107
- v_t = extract_vowel_sequence(teacher_ph)
108
- v_s = extract_vowel_sequence(student_ph)
109
- if v_t != v_s:
110
- feedback.append({
111
- "title": "Vowel Accuracy",
112
- "message": "Your vowel sound is different. Focus on long/short quality and mouth opening."
113
- })
114
-
115
- # Consonant sequence check
116
- cons_t = extract_consonant_tokens(t_tokens)
117
- cons_s = extract_consonant_tokens(s_tokens)
118
- if cons_t != cons_s:
119
- feedback.append({
120
- "title": "Consonant Accuracy",
121
- "message": "Some consonant sounds differ. Pay attention to start and end sounds."
122
- })
123
-
124
- # Ending sound check
125
- end_t = last_ending_token(t_tokens)
126
- end_s = last_ending_token(s_tokens)
127
- if end_t and end_s and end_t != end_s:
128
- feedback.append({
129
- "title": "Ending Sound",
130
- "message": f"The final sound differs. Try to end with '{end_t}'."
131
- })
132
-
133
- return feedback
134
-
135
- # -----------------------------
136
- # Syllable estimation logic
137
- # -----------------------------
138
- def syllable_estimate(ipa: str):
139
- count = 0
140
- in_vowel = False
141
- for ch in ipa:
142
- if ch in VOWELS:
143
- if not in_vowel:
144
- count += 1
145
- in_vowel = True
146
- else:
147
- in_vowel = False
148
- else:
149
- in_vowel = False
150
- return max(1, count) # at least 1 syllable
151
-
152
- def select_video_for_vc(teacher_ph: str, student_ph: str) -> str:
153
- # Early check: if overall similarity is very low, treat as wrong word
154
- score = phoneme_similarity_score(teacher_ph, student_ph)
155
- # threshold chosen empirically; adjust (0-100). <40 => likely a different word.
156
- if score < 40:
157
- return "wrong_word"
158
-
159
- tokens_t = split_ipa_tokens(teacher_ph)
160
- tokens_s = split_ipa_tokens(student_ph)
161
-
162
- v_t = extract_vowel_sequence(teacher_ph)
163
- v_s = extract_vowel_sequence(student_ph)
164
-
165
- cons_t = extract_consonant_tokens(tokens_t)
166
- cons_s = extract_consonant_tokens(tokens_s)
167
-
168
- end_t = last_ending_token(tokens_t)
169
- end_s = last_ending_token(tokens_s)
170
-
171
- stress_t = primary_stress_position(tokens_t)
172
- stress_s = primary_stress_position(tokens_s)
173
-
174
- syl_t = syllable_estimate(teacher_ph)
175
- syl_s = syllable_estimate(student_ph)
176
-
177
- flags = []
178
- if v_t != v_s:
179
- flags.append("vowel")
180
- if cons_t != cons_s:
181
- flags.append("consonant")
182
- if end_t and end_s and end_t != end_s:
183
- flags.append("ending")
184
- if stress_t is not None and stress_s is not None and stress_t != stress_s:
185
- flags.append("stress")
186
- if syl_t != syl_s:
187
- flags.append("syllable")
188
-
189
- if not flags:
190
- return "success" # Correct pronunciation
191
- if len(flags) == 1:
192
- return flags[0] # Return the first mismatch type
193
- return "mixed" # Return mixed if multiple issues are found
194
-
195
- # -----------------------------
196
- # Route: Score pronunciation with targeted feedback
197
- # -----------------------------
198
- @pronvideo_bp.route("/score", methods=["POST"])
199
- def score_pronunciation():
200
- if "audio" not in request.files:
201
- return jsonify({"score": 0, "error": "audio_required"}), 400
202
- expected_word = request.form.get("word", "").strip().lower()
203
- if not expected_word:
204
- return jsonify({"score": 0, "error": "word_required"}), 400
205
-
206
- audio_file = request.files["audio"]
207
-
208
- temp_wav = None
209
- try:
210
- temp_wav = convert_to_wav_temp(audio_file)
211
-
212
- # Transcribe the audio and get spoken text
213
- spoken_text = transcribe_audio(temp_wav)
214
- spoken_text = normalize(spoken_text)
215
-
216
- # If no speech detected
217
- if not spoken_text:
218
- vid = static_video_for("silence")
219
- return jsonify({
220
- "score": 0,
221
- "error": "no_asr_text",
222
- "message": "No speech detected.",
223
- "hint": vid["hint"],
224
- "videoId": vid["videoId"],
225
- "videoUrl": vid["videoUrl"],
226
- "expected": expected_word,
227
- "heard": ""
228
- }), 200
229
-
230
- # If multiple words detected
231
- if len(spoken_text.split()) > 1:
232
- vid = static_video_for("multipleword")
233
- return jsonify({
234
- "score": 0,
235
- "error": "multiple_words",
236
- "message": f"Detected multiple words: '{spoken_text}'. Please say only '{expected_word}'.",
237
- "hint": vid["hint"],
238
- "videoId": vid["videoId"],
239
- "videoUrl": vid["videoUrl"],
240
- "expected": expected_word,
241
- "heard": spoken_text
242
- }), 200
243
-
244
- # Calculate phoneme similarity
245
- expected_ph = expected_word # Assuming expected word phoneme
246
- spoken_ph = spoken_text # Assuming spoken text phoneme
247
- score = phoneme_similarity_score(expected_ph, spoken_ph)
248
-
249
- # Success only when exact match and high score
250
- if spoken_text == expected_word and score >= 90:
251
- vid = static_video_for("success")
252
- return jsonify({
253
- "score": score,
254
- "message": f"Excellent. You pronounced '{expected_word}' correctly.",
255
- "hint": vid["hint"],
256
- "videoId": vid["videoId"],
257
- "videoUrl": vid["videoUrl"],
258
- "expected": expected_word,
259
- "heard": spoken_text
260
- }), 200
261
-
262
- # Phoneme mismatch -> provide targeted feedback for vowel, consonant, stress, or syllable
263
- kind = select_video_for_vc(expected_ph, spoken_ph)
264
- vid = static_video_for(kind)
265
- return jsonify({
266
- "score": score,
267
- "message": "Good try. Some sounds need practice.",
268
- "hint": vid["hint"],
269
- "videoId": vid["videoId"],
270
- "videoUrl": vid["videoUrl"],
271
- "expected": expected_word,
272
- "heard": spoken_text
273
- }), 200
274
-
275
- except Exception as e:
276
- return jsonify({"score": 0, "error": "server_exception", "message": str(e)}), 500
277
- finally:
278
- if temp_wav:
279
- try:
280
- os.remove(temp_wav)
281
- except Exception:
282
- pass
283
-
284
-
285
- # IPA helpers and constants (adds split_ipa_tokens and related helpers)
286
- VOWELS = set("aeiouɪʊɛæɔɑəɜɒeɪoʊaɪɔɪ") # extend with additional IPA symbols as needed
287
- PRIMARY_STRESS = "ˈ"
288
- SECONDARY_STRESS = "ˌ"
289
- IPA_DIGRAPHS = {"tʃ", "dʒ", "t͡ʃ", "d͡ʒ"} # common multi-char IPA consonants
290
-
291
- def split_ipa_tokens(ipa: str):
292
- """
293
- Tokenize an IPA or simple-orthography string into a list of tokens.
294
- - Preserves stress markers as separate tokens.
295
- - Combines common digraphs (e.g. 'tʃ', 'dʒ').
296
- - If input contains spaces, splits on words and tokenizes each chunk.
297
- Works acceptably for plain words (will return characters) and basic IPA.
298
- """
299
- if not ipa:
300
- return []
301
- ipa = ipa.strip()
302
- # If whitespace-separated, preserve word boundaries as contiguous tokens
303
- if " " in ipa:
304
- parts = []
305
- for part in ipa.split():
306
- parts.extend(_tokenize_chunk(part))
307
- return parts
308
- return _tokenize_chunk(ipa)
309
-
310
- def _tokenize_chunk(chunk: str):
311
- tokens = []
312
- i = 0
313
- while i < len(chunk):
314
- ch = chunk[i]
315
- # stress markers
316
- if ch in (PRIMARY_STRESS, SECONDARY_STRESS):
317
- tokens.append(ch)
318
- i += 1
319
- continue
320
- # try two-character digraphs first
321
- if i + 1 < len(chunk):
322
- pair = chunk[i : i + 2]
323
- if pair in IPA_DIGRAPHS:
324
- tokens.append(pair)
325
- i += 2
326
- continue
327
- # fallback single character token
328
- tokens.append(ch)
329
- i += 1
330
- return tokens
331
-
332
- def extract_vowel_sequence(ipa: str):
333
- """Return concatenated vowel tokens in order (string)."""
334
- tokens = split_ipa_tokens(ipa)
335
- return "".join(t for t in tokens if t in VOWELS)
336
-
337
- def extract_consonant_tokens(tokens):
338
- """Filter out vowels and stress markers from a tokens list, return consonant tokens list."""
339
- return [t for t in tokens if t not in VOWELS and t not in (PRIMARY_STRESS, SECONDARY_STRESS) and t.strip()]
340
-
341
- def last_ending_token(tokens):
342
- """Return the last non-stress, non-empty token (approx. final sound)."""
343
- for t in reversed(tokens):
344
- if not t or t in (PRIMARY_STRESS, SECONDARY_STRESS):
345
- continue
346
- return t
347
- return None
348
-
349
- def primary_stress_position(tokens):
350
- """
351
- Return index of primary stress marker if present, otherwise None.
352
- This is a coarse approximation used to compare stress positions between expected and spoken forms.
353
- """
354
- try:
355
- return tokens.index(PRIMARY_STRESS)
356
- except ValueError:
357
- return None
358
-
359
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ragg/app.py CHANGED
@@ -1,26 +1,24 @@
1
- import os
2
  import time
3
- import json
4
- import requests
5
- from dotenv import load_dotenv, find_dotenv
6
- from flask import Flask, Blueprint, request, jsonify, current_app, send_from_directory
7
- # Note: we avoid creating a Flask app at module import time
8
  import uuid
9
  from pathlib import Path
10
  from typing import Iterable, Optional, Sequence, Union
 
 
 
11
  from flask_cors import CORS
 
12
  import requests
13
  from TTS.api import TTS
14
 
15
- # --- S3 (added) ---
16
  try:
17
  import boto3
18
  from botocore.exceptions import NoCredentialsError, ClientError
19
  except Exception:
20
  boto3 = None
21
- NoCredentialsError = ClientError = Exception # fallbacks so type names exist
22
 
23
- # RAG imports
24
  try:
25
  from .rag_backend import IngestBody, ingest_documents, ingest_pdfs_from_folder
26
  from .rag_llm import (
@@ -29,12 +27,11 @@ try:
29
  ExplainBody,
30
  llm_explain,
31
  FollowupBody,
32
- get_vectorstore,
33
- get_vectorstore_for, # ← add this
34
  llm_followups,
 
 
35
  )
36
  except ImportError:
37
- # Fallback when running as: python ragg/app.py
38
  from rag_backend import IngestBody, ingest_documents, ingest_pdfs_from_folder
39
  from rag_llm import (
40
  LLMBody,
@@ -42,28 +39,80 @@ except ImportError:
42
  ExplainBody,
43
  llm_explain,
44
  FollowupBody,
45
- get_vectorstore,
46
- get_vectorstore_for, # ← add this
47
  llm_followups,
 
 
48
  )
49
 
50
- # OpenAI client (no secret logs)
51
- import openai
52
  from openai import OpenAI
53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
 
 
55
  def xtts_speak_to_file(
56
  text: str,
57
  out_file: Optional[Union[str, Path]] = None,
58
- reference_dir: Optional[Union[str, Path]] = "trim",
59
  reference_files: Optional[Sequence[Union[str, Path]]] = None,
60
  language: str = "en",
61
  patterns: Iterable[str] = ("*.wav", "*.mp3", "*.flac"),
62
  ) -> Path:
63
- """
64
- Generate a WAV using XTTS v2 with reference audios; caches the model.
65
- """
66
- speakers: list[str] = []
67
  if reference_files:
68
  speakers.extend(str(Path(p)) for p in reference_files)
69
 
@@ -74,142 +123,55 @@ def xtts_speak_to_file(
74
 
75
  speakers = list(dict.fromkeys(speakers))
76
  if not speakers:
77
- raise FileNotFoundError(
78
- f"No reference audio files found. Checked: "
79
- f"{reference_files or []} and/or {reference_dir}"
80
- )
81
 
82
  if not hasattr(xtts_speak_to_file, "_model") or xtts_speak_to_file._model is None:
83
- import sys, builtins, torch
84
- from torch.serialization import add_safe_globals
85
- # --- XTTS internal classes that must be allow-listed ---
86
- from TTS.tts.configs.xtts_config import XttsConfig
87
- from TTS.tts.models.xtts import XttsAudioConfig, XttsArgs
88
- from TTS.config.shared_configs import BaseDatasetConfig
89
-
90
- # Prevent interactive prompts / stdin crashes on Hugging Face
91
  sys.stdin = open(os.devnull)
92
  builtins.input = lambda *a, **kw: ""
93
  os.environ["COQUI_TOS_AGREED"] = "1"
94
 
95
- # Allowlist all required XTTS classes for PyTorch 2.6+
96
- add_safe_globals([XttsConfig, XttsAudioConfig, BaseDatasetConfig, XttsArgs])
 
 
 
 
 
 
 
 
 
 
97
 
98
- # Initialize the XTTS model safely
99
  xtts_speak_to_file._model = TTS(
100
  model_name="tts_models/multilingual/multi-dataset/xtts_v2",
101
  gpu=False,
102
  progress_bar=False,
103
  )
104
-
105
- tts = xtts_speak_to_file._model
106
 
 
107
  out_path = Path(out_file) if out_file else Path(f"xtts_{uuid.uuid4().hex}.wav")
108
  out_path.parent.mkdir(parents=True, exist_ok=True)
109
 
110
  try:
111
- tts.tts_to_file(
112
- text=text,
113
- speaker_wav=speakers,
114
- language=language,
115
- file_path=str(out_path),
116
- )
117
  except Exception as e:
118
  raise RuntimeError(f"XTTS synthesis failed: {e}") from e
119
 
120
  return out_path
121
 
122
- # ------------------------------------------------------------
123
- # Load environment
124
- # ------------------------------------------------------------
125
- load_dotenv(find_dotenv())
126
- openai_client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
127
-
128
- # Optional: version log (safe), but do NOT print the API key
129
- try:
130
- print(f"openai package version: {openai.__version__}")
131
- except Exception:
132
- pass
133
-
134
- # --- S3 config (added) ---
135
- S3_BUCKET = os.getenv("S3_BUCKET", "").strip()
136
- AWS_REGION = os.getenv("AWS_REGION", "ap-south-1").strip()
137
- S3_PREFIX = os.getenv("S3_PREFIX", "audio/").strip()
138
- AWS_ACCESS_KEY_ID = os.getenv("AWS_ACCESS_KEY_ID", "").strip()
139
- AWS_SECRET_ACCESS_KEY = os.getenv("AWS_SECRET_ACCESS_KEY", "").strip()
140
-
141
- _s3_client = None
142
- if boto3 and S3_BUCKET and AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY:
143
- try:
144
- _s3_client = boto3.client(
145
- "s3",
146
- region_name=AWS_REGION,
147
- aws_access_key_id=AWS_ACCESS_KEY_ID,
148
- aws_secret_access_key=AWS_SECRET_ACCESS_KEY,
149
- )
150
- except Exception as _e:
151
- _s3_client = None
152
-
153
- def _upload_to_s3(file_path: Union[str, Path]) -> Optional[str]:
154
- """
155
- Upload the file to S3 and return a presigned URL (24h).
156
- If S3 is not configured, returns None (caller will fallback).
157
- """
158
- if not _s3_client or not S3_BUCKET:
159
- return None
160
- try:
161
- file_path = str(file_path)
162
- key = f"{S3_PREFIX}{Path(file_path).name}"
163
- _s3_client.upload_file(file_path, S3_BUCKET, key)
164
- url = _s3_client.generate_presigned_url(
165
- "get_object",
166
- Params={"Bucket": S3_BUCKET, "Key": key},
167
- ExpiresIn=24 * 3600,
168
- )
169
- return url
170
- except (NoCredentialsError, ClientError) as e:
171
- try:
172
- current_app.logger.error(f"S3 upload failed: {e}")
173
- except Exception:
174
- print(f"S3 upload failed: {e}")
175
- return None
176
-
177
- # Media and voice references
178
-
179
- # MEDIA_ROOT = Path(os.getenv("MEDIA_ROOT", "./media"))
180
- # AUDIO_DIR = MEDIA_ROOT / "audio"
181
- # AUDIO_DIR.mkdir(parents=True, exist_ok=True)
182
- # XTTS_REF_DIR = os.getenv("XTTS_REF_DIR", "./trim") # folder with your reference audios
183
-
184
- BASE_DIR = Path(__file__).resolve().parent.parent # if app.py is top-level; if it's ragg/app.py use .parent.parent
185
- MEDIA_ROOT = Path(os.getenv("MEDIA_ROOT", str(BASE_DIR / "media")))
186
- AUDIO_DIR = MEDIA_ROOT / "audio"
187
- AUDIO_DIR.mkdir(parents=True, exist_ok=True)
188
- XTTS_REF_DIR = os.getenv("XTTS_REF_DIR", str(BASE_DIR / "trim")) # reference voice files
189
 
190
- # D-ID config (optional)
191
- # ------------------------------------------------------------
192
- # Blueprint (mounted at /rag by the main app)
193
- # ------------------------------------------------------------
194
- rag_bp = Blueprint("rag", __name__)
195
  @rag_bp.route("/audio/<path:filename>", methods=["GET"])
196
  def rag_serve_audio(filename: str):
197
- return send_from_directory(AUDIO_DIR, filename, mimetype="audio/wav", conditional=True)
198
 
199
- # D-ID config (set in .env / HF Secrets)
200
- DID_API_KEY = os.getenv("DID_API_KEY", "")
201
- DID_SOURCE_IMAGE_URL = os.getenv("DID_SOURCE_IMAGE_URL", "")
202
- DID_VOICE_ID = os.getenv("DID_VOICE_ID", "en-US-JennyNeural")
203
 
204
- # Default folder for /ingest-pdfs
205
- PDF_DEFAULT_FOLDER = os.getenv("RAG_PDF_DIR", "./pdfs")
206
-
207
-
208
- # Optional: add CORS headers (the main app should still enable CORS globally)
209
  @rag_bp.after_app_request
210
  def add_cors_headers(resp):
211
  origin = request.headers.get("Origin")
212
- # Allow local Angular during dev; main app may add more origins
213
  if origin in ("http://localhost:4200", "http://127.0.0.1:4200"):
214
  resp.headers["Access-Control-Allow-Origin"] = origin
215
  resp.headers["Vary"] = "Origin"
@@ -218,23 +180,14 @@ def add_cors_headers(resp):
218
  return resp
219
 
220
 
221
- # ------------------------------------------------------------
222
- # Helpers
223
- # ------------------------------------------------------------
224
- def user_to_db_level(username: str | None) -> str | None:
225
  if not username:
226
  return None
227
  u = username.strip().lower()
228
- if u == "lowergrade":
229
- return "low"
230
- if u == "midgrade":
231
- return "mid"
232
- if u == "highergrade":
233
- return "high"
234
- return None
235
 
236
 
237
- def extract_username_from_request(req) -> str | None:
238
  hdr = req.headers.get("X-User")
239
  if hdr:
240
  return hdr
@@ -242,7 +195,7 @@ def extract_username_from_request(req) -> str | None:
242
  return data.get("username")
243
 
244
 
245
- # --- D-ID helpers ---
246
  def _did_create_talk(text: str):
247
  if not DID_API_KEY:
248
  return None, ("DID_API_KEY not set on the server", 500)
@@ -250,11 +203,7 @@ def _did_create_talk(text: str):
250
  return None, ("DID_SOURCE_IMAGE_URL not set on the server", 500)
251
 
252
  payload = {
253
- "script": {
254
- "type": "text",
255
- "input": text,
256
- "provider": {"type": "microsoft", "voice_id": DID_VOICE_ID},
257
- },
258
  "source_url": DID_SOURCE_IMAGE_URL,
259
  "config": {"fluent": True, "pad_audio": 0},
260
  }
@@ -292,16 +241,65 @@ def _did_poll_talk(talk_id: str, timeout_sec: int = 60, interval_sec: float = 2.
292
  return None, ("D-ID poll failed", 502)
293
 
294
 
295
- # ------------------------------------------------------------
296
- # Endpoints (NOTE: no "/rag" prefix here; the blueprint adds it)
297
- # ------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
298
  @rag_bp.route("/ingest", methods=["POST", "OPTIONS"])
299
  def rag_ingest():
300
  if request.method == "OPTIONS":
301
  return ("", 204)
302
  body = IngestBody(**(request.json or {}))
303
- result = ingest_documents(body)
304
- return jsonify(result)
305
 
306
 
307
  @rag_bp.route("/ingest-pdfs", methods=["POST", "OPTIONS"])
@@ -310,11 +308,7 @@ def rag_ingest_pdfs():
310
  return ("", 204)
311
  data = request.json or {}
312
  folder = data.get("folder", PDF_DEFAULT_FOLDER)
313
- subject = data.get("subject")
314
- grade = data.get("grade")
315
- chapter = data.get("chapter")
316
- result = ingest_pdfs_from_folder(folder, subject=subject, grade=grade, chapter=chapter)
317
- return jsonify(result)
318
 
319
 
320
  @rag_bp.route("/generate-questions", methods=["POST", "OPTIONS"])
@@ -327,145 +321,26 @@ def rag_generate_questions():
327
  if not data.get("db_level"):
328
  data["db_level"] = mapped_level
329
  body = LLMBody(**data)
330
- result = llm_generate(body)
331
- return jsonify(result)
332
-
333
-
334
- # @rag_bp.route("/explain-grammar", methods=["POST", "OPTIONS"])
335
- # @rag_bp.route("/explain-grammar", methods=["POST", "OPTIONS"])
336
- # def rag_explain_grammar():
337
- # if request.method == "OPTIONS":
338
- # return ("", 204)
339
-
340
- # data = request.get_json(force=True) or {}
341
-
342
- # # --- Extract username and db_level ---
343
- # username = extract_username_from_request(request)
344
- # db_level = user_to_db_level(username)
345
-
346
- # # --- MAIN BODY (your preferred structure) ---
347
- # body = ExplainBody(
348
- # question=(data.get("question") or "").strip(),
349
- # model=data.get("model", "gpt-4o-mini"),
350
- # db_level=db_level,
351
- # source_ids=data.get("source_ids") or []
352
- # )
353
-
354
- # # --- 1) Run LLM / RAG explanation ---
355
- # result_raw = llm_explain(body)
356
-
357
- # # --- 2) Normalize + extract answer safely ---
358
- # result_dict = None
359
- # answer_text = ""
360
- # try:
361
- # if isinstance(result_raw, dict):
362
- # result_dict = dict(result_raw)
363
- # elif hasattr(result_raw, "model_dump"):
364
- # result_dict = result_raw.model_dump()
365
- # elif hasattr(result_raw, "dict"):
366
- # result_dict = result_raw.dict()
367
- # elif isinstance(result_raw, str):
368
- # result_dict = {"answer": result_raw}
369
- # else:
370
- # result_dict = {"answer": str(result_raw)}
371
-
372
- # answer_text = (
373
- # result_dict.get("answer")
374
- # or result_dict.get("response")
375
- # or result_dict.get("text")
376
- # or ""
377
- # ).strip()
378
- # except Exception as e:
379
- # current_app.logger.exception("Failed to normalize llm_explain result: %s", e)
380
- # return jsonify({"error": "Internal error normalizing LLM response"}), 500
381
-
382
- # # --- 3) Optional: synthesize TTS audio ---
383
- # try:
384
- # if data.get("synthesize_audio"):
385
- # try:
386
- # out_name = f"explain_{uuid.uuid4().hex}.wav"
387
- # wav_path = xtts_speak_to_file(
388
- # text=answer_text or result_dict.get("answer", ""),
389
- # out_file=AUDIO_DIR / out_name,
390
- # reference_dir=XTTS_REF_DIR,
391
- # reference_files=None,
392
- # language=data.get("language", "en"),
393
- # )
394
- # # Local: serve from /rag/audio/*
395
- # if "localhost" in request.host_url or "127.0.0.1" in request.host_url:
396
- # base = request.host_url.rstrip("/")
397
- # result_dict["audio_url"] = f"{base}/rag/audio/{wav_path.name}"
398
- # else:
399
- # # Deployed: try S3 first; fallback to public SPACE_URL if set
400
- # s3_url = _upload_to_s3(str(wav_path))
401
- # if s3_url:
402
- # result_dict["audio_url"] = s3_url
403
- # else:
404
- # base = os.getenv("SPACE_URL", "https://pykara-py-learn-backend.hf.space")
405
- # result_dict["audio_url"] = f"{base}/rag/audio/{wav_path.name}"
406
- # except FileNotFoundError as e:
407
- # current_app.logger.error("XTTS reference audio missing: %s", e)
408
- # except Exception as e:
409
- # current_app.logger.exception("XTTS synthesis during explain-grammar failed: %s", e)
410
- # except Exception:
411
- # current_app.logger.exception("Unexpected error while attempting inline synthesis")
412
-
413
- # # --- 4) Optional: synthesize video (D-ID) ---
414
- # try:
415
- # if data.get("synthesize_video"):
416
- # if not DID_API_KEY or not DID_SOURCE_IMAGE_URL:
417
- # current_app.logger.error("D-ID not configured for inline explain-grammar video synthesis")
418
- # else:
419
- # try:
420
- # talk_id, err = _did_create_talk(answer_text or result_dict.get("answer", ""))
421
- # if err:
422
- # current_app.logger.error(
423
- # "D-ID create error during explain-grammar: %s",
424
- # err[0] if isinstance(err, tuple) else err,
425
- # )
426
- # else:
427
- # video_url, err = _did_poll_talk(talk_id, timeout_sec=120, interval_sec=2.0)
428
- # if err:
429
- # current_app.logger.error(
430
- # "D-ID poll error during explain-grammar: %s",
431
- # err[0] if isinstance(err, tuple) else err,
432
- # )
433
- # else:
434
- # if video_url:
435
- # result_dict["video_url"] = video_url
436
- # except Exception as e:
437
- # current_app.logger.exception("D-ID inline synthesis failed during explain-grammar: %s", e)
438
- # except Exception:
439
- # current_app.logger.exception("Unexpected error while attempting inline video synthesis")
440
-
441
- # # --- Final response ---
442
- # return jsonify(result_dict), 200
443
 
444
  @rag_bp.route("/explain-grammar", methods=["POST", "OPTIONS"])
445
  def rag_explain_grammar():
446
  if request.method == "OPTIONS":
447
  return ("", 204)
448
-
449
  data = request.get_json(force=True) or {}
450
-
451
- # --- Extract username and db_level ---
452
  username = extract_username_from_request(request)
453
- db_level = user_to_db_level(username)
454
 
455
- # --- MAIN BODY (your preferred structure) ---
456
  body = ExplainBody(
457
  question=(data.get("question") or "").strip(),
458
  model=data.get("model", "gpt-4o-mini"),
459
- db_level=db_level,
460
- source_ids=data.get("source_ids") or []
461
  )
462
 
463
- # --- 1) Run LLM / RAG explanation ---
464
  result_raw = llm_explain(body)
465
 
466
- # --- 2) Normalize + extract answer safely ---
467
- result_dict = None
468
- answer_text = ""
469
  try:
470
  if isinstance(result_raw, dict):
471
  result_dict = dict(result_raw)
@@ -477,177 +352,144 @@ def rag_explain_grammar():
477
  result_dict = {"answer": result_raw}
478
  else:
479
  result_dict = {"answer": str(result_raw)}
480
-
481
- answer_text = (
482
- result_dict.get("answer")
483
- or result_dict.get("response")
484
- or result_dict.get("text")
485
- or ""
486
- ).strip()
487
  except Exception as e:
488
  current_app.logger.exception("Failed to normalize llm_explain result: %s", e)
489
  return jsonify({"error": "Internal error normalizing LLM response"}), 500
490
 
491
- # --- 3) Optional: synthesize TTS audio ---
492
- try:
493
- if data.get("synthesize_audio"):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
494
  try:
495
- out_name = f"explain_{uuid.uuid4().hex}.wav"
496
- wav_path = xtts_speak_to_file(
497
- text=answer_text or result_dict.get("answer", ""),
498
- out_file=AUDIO_DIR / out_name,
499
- reference_dir=XTTS_REF_DIR,
500
- reference_files=None,
501
- language=data.get("language", "en"),
502
- )
503
- base = request.host_url.rstrip("/")
504
- result_dict["audio_url"] = f"{base}/rag/audio/{wav_path.name}"
505
- except FileNotFoundError as e:
506
- current_app.logger.error("XTTS reference audio missing: %s", e)
507
  except Exception as e:
508
- current_app.logger.exception("XTTS synthesis during explain-grammar failed: %s", e)
509
- except Exception:
510
- current_app.logger.exception("Unexpected error while attempting inline synthesis")
511
-
512
- # --- 4) Optional: synthesize video (D-ID) ---
513
- try:
514
- if data.get("synthesize_video"):
515
  if not DID_API_KEY or not DID_SOURCE_IMAGE_URL:
516
  current_app.logger.error("D-ID not configured for inline explain-grammar video synthesis")
517
  else:
518
  try:
519
  talk_id, err = _did_create_talk(answer_text or result_dict.get("answer", ""))
520
  if err:
521
- current_app.logger.error(
522
- "D-ID create error during explain-grammar: %s",
523
- err[0] if isinstance(err, tuple) else err,
524
- )
525
  else:
526
  video_url, err = _did_poll_talk(talk_id, timeout_sec=120, interval_sec=2.0)
527
  if err:
528
- current_app.logger.error(
529
- "D-ID poll error during explain-grammar: %s",
530
- err[0] if isinstance(err, tuple) else err,
531
- )
532
- else:
533
- if video_url:
534
- result_dict["video_url"] = video_url
535
  except Exception as e:
536
  current_app.logger.exception("D-ID inline synthesis failed during explain-grammar: %s", e)
537
- except Exception:
538
- current_app.logger.exception("Unexpected error while attempting inline video synthesis")
539
 
540
- # --- Final response ---
541
  return jsonify(result_dict), 200
542
 
543
 
544
-
545
- # @rag_bp.route("/suggest-followups", methods=["POST", "OPTIONS"])
546
  @rag_bp.route("/suggest-followups", methods=["POST", "OPTIONS"])
547
  def rag_suggest_followups():
548
  if request.method == "OPTIONS":
549
  return ("", 204)
550
-
551
  data = request.get_json(force=True) or {}
552
  username = extract_username_from_request(request)
553
- db_level = user_to_db_level(username)
554
-
555
  body = FollowupBody(
556
  last_question=(data.get("last_question") or "").strip(),
557
  last_answer=(data.get("last_answer") or "").strip(),
558
  n=int(data.get("n", 5)),
559
  model=data.get("model", "gpt-4o-mini"),
560
- db_level=db_level,
561
- source_ids=data.get("source_ids") or [] # ← same addition here
562
  )
563
- result = llm_followups(body)
564
- return jsonify(result)
565
 
566
 
567
- # @rag_bp.get("/_diag")
568
  @rag_bp.get("/_diag")
569
  def rag_diag():
 
570
  try:
571
- from .rag_llm import CHROMA_DIR, CHROMA_ROOT, get_vectorstore, get_vectorstore_for
572
  except ImportError:
573
- from rag_llm import CHROMA_DIR, CHROMA_ROOT, get_vectorstore, get_vectorstore_for
574
-
575
- import os
576
- from flask import jsonify
577
 
578
  def _count(vs):
579
- """Handle both LangChain and chromadb client objects."""
580
  if vs is None:
581
  return None
582
- # 1️⃣ chromadb.Collection (your new get_vectorstore_for)
583
  if hasattr(vs, "count") and callable(vs.count):
584
  try:
585
  return vs.count()
586
  except Exception:
587
  return None
588
- # 2️⃣ LangChain vectorstore
589
  if hasattr(vs, "_collection"):
590
  try:
591
- return vs._collection.count() # type: ignore
592
  except Exception:
593
  try:
594
- return vs._client.get_collection(vs._collection.name).count() # type: ignore
595
  except Exception:
596
  return None
597
  return None
598
 
599
- # load each level safely
600
- low_vs = get_vectorstore_for("low")
601
- mid_vs = get_vectorstore_for("mid")
602
- high_vs = get_vectorstore_for("high")
 
 
 
 
 
 
 
 
 
 
 
603
 
604
  info = {
605
- "env_seen": {
606
- "CHROMA_DIR": CHROMA_DIR,
607
- "CHROMA_ROOT": CHROMA_ROOT
608
- },
609
- "low_dir": {
610
- "path": os.path.join(CHROMA_ROOT, "low"),
611
- "exists": os.path.isdir(os.path.join(CHROMA_ROOT, "low")),
612
- },
613
- "counts_default": _count(get_vectorstore()),
614
  "counts_low": _count(low_vs),
615
  "counts_mid": _count(mid_vs),
616
  "counts_high": _count(high_vs),
 
 
 
 
 
 
 
 
 
617
  }
618
  return jsonify(info), 200
619
 
620
- # def rag_diag():
621
- # # minimal imports here to avoid circulars
622
- # try:
623
- # from .rag_llm import CHROMA_DIR, CHROMA_ROOT, get_vectorstore, get_vectorstore_for
624
- # except ImportError:
625
- # from rag_llm import CHROMA_DIR, CHROMA_ROOT, get_vectorstore, get_vectorstore_for
626
- #
627
- # import os
628
- # from flask import jsonify
629
- #
630
- # def _count(vs):
631
- # try:
632
- # return vs._collection.count()
633
- # except Exception:
634
- # try:
635
- # return vs._client.get_collection(vs._collection.name).count()
636
- # except Exception:
637
- # return None
638
- #
639
- # info = {
640
- # "env_seen": {"CHROMA_DIR": CHROMA_DIR, "CHROMA_ROOT": CHROMA_ROOT},
641
- # "low_dir": {
642
- # "path": os.path.join(CHROMA_ROOT, "low"),
643
- # "exists": os.path.isdir(os.path.join(CHROMA_ROOT, "low")),
644
- # },
645
- # "counts_default": _count(get_vectorstore()),
646
- # "counts_low": _count(get_vectorstore_for("low")),
647
- # "counts_mid": _count(get_vectorstore_for("mid")),
648
- # "counts_high": _count(get_vectorstore_for("high")),
649
- # }
650
- # return jsonify(info), 200
651
 
652
  @rag_bp.route("/search", methods=["POST", "OPTIONS"])
653
  def rag_search():
@@ -657,72 +499,42 @@ def rag_search():
657
  q = (data.get("q") or "").strip()
658
  if not q:
659
  return jsonify({"results": []})
660
-
661
- # derive db_level from login, unless explicitly provided
662
  username = extract_username_from_request(request)
663
- mapped_level = user_to_db_level(username)
664
- db_level = data.get("db_level") or mapped_level
665
-
666
  vs = get_vectorstore_for(db_level)
667
  hits = vs.similarity_search_with_score(q, k=5)
668
  out = []
669
  for doc, dist in hits:
670
- out.append({
671
- "distance": float(dist),
672
- "snippet": doc.page_content[:200],
673
- "source_path": os.path.normpath(doc.metadata.get("source_path", "")),
674
- "page": doc.metadata.get("page_1based"),
675
- })
 
 
676
  return jsonify({"results": out})
677
 
678
 
679
- def generate_questions_from_vectorstore():
 
 
 
 
680
  try:
681
  vectorstore = get_vectorstore()
682
  query_text = "important content related to grammar"
683
  results = vectorstore.similarity_search_with_score(query_text, k=5)
684
- print(f"Vectorstore query returned {len(results)} results")
685
  content = "\n".join([doc.page_content for doc, _ in results])
686
- print(f"Retrieved content: {content[:500]}...")
687
  if not content:
688
- return {"error": "No content retrieved from vectorstore. Please ingest PDFs first."}
689
  prompt = f"Generate 5 important questions based on the following content: {content}"
690
  response = openai_client.chat.completions.create(
691
- model="gpt-4o-mini",
692
- messages=[{"role": "user", "content": prompt}],
693
- temperature=0.7,
694
- max_tokens=150,
695
  )
696
- response_text = response.choices[0].message.content.strip()
697
- print(f"Processed OpenAI response: {response_text}")
698
- return response_text
699
  except Exception as e:
700
- print(f"Error during OpenAI API call: {e}")
701
- return {"error": f"Failed to call OpenAI: {str(e)}"}
702
-
703
-
704
- @rag_bp.route("/generate-questions-from-chroma", methods=["POST", "OPTIONS"])
705
- def generate_questions_from_chroma():
706
- def _generate_questions_from_vectorstore():
707
- try:
708
- vectorstore = get_vectorstore()
709
- query_text = "important content related to grammar"
710
- results = vectorstore.similarity_search_with_score(query_text, k=5)
711
- content = "\n".join([doc.page_content for doc, _ in results])
712
- if not content:
713
- return {"error": "No content retrieved from vectorstore. Please ingest PDFs first."}
714
- prompt = f"Generate 5 important questions based on the following content: {content}"
715
- response = openai_client.chat.completions.create(
716
- model="gpt-4o-mini",
717
- messages=[{"role": "user", "content": prompt}],
718
- temperature=0.7,
719
- max_tokens=150,
720
- )
721
- return response.choices[0].message.content.strip()
722
- except Exception as e:
723
- return {"error": f"Failed to call OpenAI: {str(e)}"}
724
-
725
- generated = _generate_questions_from_vectorstore()
726
  return jsonify({"generated_questions": generated})
727
 
728
 
@@ -730,115 +542,111 @@ def generate_questions_from_chroma():
730
  def health():
731
  return {"status": "ok"}, 200
732
 
 
733
  @rag_bp.route("/synthesize-audio", methods=["POST", "OPTIONS"])
734
  def rag_synthesize_audio():
735
- """
736
- Synthesize text to WAV on demand using XTTS and return a public URL.
737
- Body: { "text": "...", "language": "en", "reference_files": ["trim/foo.wav", ...] }
738
- """
739
  if request.method == "OPTIONS":
740
  return ("", 204)
741
-
742
  data = request.get_json(force=True) or {}
743
  text = (data.get("text") or "").strip()
744
  if not text:
745
  return jsonify({"error": "No text provided"}), 400
746
 
747
- language = data.get("language", "en")
748
- reference_files = data.get("reference_files") # optional list of paths
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
749
 
750
  try:
751
  out_name = f"synth_{uuid.uuid4().hex}.wav"
752
  wav_path = xtts_speak_to_file(
753
- text=text,
754
- out_file=AUDIO_DIR / out_name,
755
- reference_dir=XTTS_REF_DIR,
756
- reference_files=reference_files,
757
- language=language,
758
  )
759
- # Local: serve static file
760
  if "localhost" in request.host_url or "127.0.0.1" in request.host_url:
761
  base = request.host_url.rstrip("/")
762
  audio_url = f"{base}/rag/audio/{wav_path.name}"
763
  else:
764
- # Deployed: try S3 first; fallback to SPACE_URL
765
  s3_url = _upload_to_s3(str(wav_path))
766
  if s3_url:
767
  audio_url = s3_url
768
  else:
769
- base = os.getenv("SPACE_URL", "https://pykara-py-learn-backend.hf.space")
770
  audio_url = f"{base}/rag/audio/{wav_path.name}"
771
 
772
  return jsonify({"audio_url": audio_url, "file": wav_path.name}), 200
773
- except Exception as e:
774
- import traceback
775
- print("=== XTTS DEBUG ERROR ===")
776
- print(traceback.format_exc())
777
- print("========================")
778
- return jsonify({"error": "Synthesis failed", "detail": str(e)}), 500
779
- # except FileNotFoundError as e:
780
- # current_app.logger.error("XTTS references missing: %s", e)
781
- # return jsonify({"error": "XTTS reference audio files not found on server"}), 500
782
  except Exception as e:
783
  current_app.logger.exception("XTTS synthesis error: %s", e)
784
- return jsonify({"error": "Synthesis failed"}), 500
785
 
786
 
787
  @rag_bp.route("/synthesize-video", methods=["POST", "OPTIONS"])
788
  def rag_synthesize_video():
789
- """
790
- Synthesize a short video on-demand using the D-ID service and return the public video URL.
791
- Body: { "text": "..." }
792
- """
793
  if request.method == "OPTIONS":
794
  return ("", 204)
795
-
796
  data = request.get_json(force=True) or {}
797
  text = (data.get("text") or "").strip()
798
  if not text:
799
  return jsonify({"error": "No text provided"}), 400
800
-
801
- # Quick config check
802
  if not DID_API_KEY or not DID_SOURCE_IMAGE_URL:
803
  current_app.logger.error("D-ID not configured (DID_API_KEY or DID_SOURCE_IMAGE_URL missing)")
804
  return jsonify({"error": "D-ID not configured on server"}), 500
805
-
806
  try:
807
- # Create talk (calls D-ID /talks)
808
  talk_id, err = _did_create_talk(text)
809
  if err:
810
- # _did_create_talk returns (None, (msg, status))
811
- current_app.logger.error("D-ID create error: %s", err[0])
812
  return jsonify({"error": err[0]}), err[1]
813
-
814
- # Poll for result URL
815
  video_url, err = _did_poll_talk(talk_id, timeout_sec=120, interval_sec=2.0)
816
  if err:
817
- current_app.logger.error("D-ID poll error: %s", err[0])
818
  return jsonify({"error": err[0]}), err[1]
819
-
820
  if not video_url:
821
- current_app.logger.error("D-ID did not return a video URL for talk %s", talk_id)
822
  return jsonify({"error": "D-ID did not return a video URL"}), 502
823
-
824
  return jsonify({"video_url": video_url}), 200
825
-
826
  except Exception as e:
827
  current_app.logger.exception("Unexpected error generating D-ID video: %s", e)
828
  return jsonify({"error": "Internal server error generating video"}), 500
829
 
830
 
831
- # ------------------------------------------------------------
832
- # Local runner (DEV ONLY)
833
- # ------------------------------------------------------------
834
- if __name__ == "__main__":
835
- # Allow this module to run as a standalone server on port 7000 for local dev
836
- from flask import Flask
837
- from flask_cors import CORS
838
 
839
- app = Flask(__name__)
 
 
 
 
 
 
 
 
 
840
 
841
- # CORS for local dev (the production app sets CORS globally in verification.py)
 
 
 
 
842
  CORS(
843
  app,
844
  resources={r"/rag/*": {"origins": ["http://localhost:4200", "http://127.0.0.1:4200"]}},
@@ -846,10 +654,6 @@ if __name__ == "__main__":
846
  allow_headers=["Content-Type", "Authorization", "X-User"],
847
  methods=["GET", "POST", "OPTIONS"],
848
  )
849
-
850
- # Ensure Chroma dir exists (use CHROMA_DIR if set)
851
  os.makedirs(os.getenv("CHROMA_DIR", "./chroma"), exist_ok=True)
852
-
853
- # Mount blueprint at /rag and run
854
  app.register_blueprint(rag_bp, url_prefix="/rag")
855
- app.run(host="0.0.0.0", port=7000, debug=True)
 
1
+ import os
2
  import time
 
 
 
 
 
3
  import uuid
4
  from pathlib import Path
5
  from typing import Iterable, Optional, Sequence, Union
6
+
7
+ from dotenv import load_dotenv, find_dotenv
8
+ from flask import Flask, Blueprint, request, jsonify, current_app, send_from_directory
9
  from flask_cors import CORS
10
+
11
  import requests
12
  from TTS.api import TTS
13
 
 
14
  try:
15
  import boto3
16
  from botocore.exceptions import NoCredentialsError, ClientError
17
  except Exception:
18
  boto3 = None
19
+ NoCredentialsError = ClientError = Exception
20
 
21
+ # local imports (support running as a package or module)
22
  try:
23
  from .rag_backend import IngestBody, ingest_documents, ingest_pdfs_from_folder
24
  from .rag_llm import (
 
27
  ExplainBody,
28
  llm_explain,
29
  FollowupBody,
 
 
30
  llm_followups,
31
+ get_vectorstore,
32
+ get_vectorstore_for,
33
  )
34
  except ImportError:
 
35
  from rag_backend import IngestBody, ingest_documents, ingest_pdfs_from_folder
36
  from rag_llm import (
37
  LLMBody,
 
39
  ExplainBody,
40
  llm_explain,
41
  FollowupBody,
 
 
42
  llm_followups,
43
+ get_vectorstore,
44
+ get_vectorstore_for,
45
  )
46
 
 
 
47
  from openai import OpenAI
48
 
49
+ load_dotenv(find_dotenv())
50
+ openai_client = OpenAI(api_key=os.getenv("OPENAI_API_KEY", ""))
51
+
52
+ # Configuration
53
+ S3_BUCKET = os.getenv("S3_BUCKET", "").strip()
54
+ AWS_REGION = os.getenv("AWS_REGION", "ap-south-1").strip()
55
+ S3_PREFIX = os.getenv("S3_PREFIX", "audio/").strip()
56
+ AWS_ACCESS_KEY_ID = os.getenv("AWS_ACCESS_KEY_ID", "").strip()
57
+ AWS_SECRET_ACCESS_KEY = os.getenv("AWS_SECRET_ACCESS_KEY", "").strip()
58
+
59
+ BASE_DIR = Path(__file__).resolve().parent.parent
60
+ MEDIA_ROOT = Path(os.getenv("MEDIA_ROOT", str(BASE_DIR / "media")))
61
+ AUDIO_DIR = MEDIA_ROOT / "audio"
62
+ AUDIO_DIR.mkdir(parents=True, exist_ok=True)
63
+ XTTS_REF_DIR = Path(os.getenv("XTTS_REF_DIR", str(BASE_DIR / "assets")))
64
+
65
+ DID_API_KEY = os.getenv("DID_API_KEY", "")
66
+ DID_SOURCE_IMAGE_URL = os.getenv("DID_SOURCE_IMAGE_URL", "")
67
+ DID_VOICE_ID = os.getenv("DID_VOICE_ID", "en-US-JennyNeural")
68
+ PDF_DEFAULT_FOLDER = os.getenv("RAG_PDF_DIR", "../assets/pdfs")
69
+
70
+ # init optional s3 client
71
+ _s3_client = None
72
+ if boto3 and S3_BUCKET and AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY:
73
+ try:
74
+ _s3_client = boto3.client(
75
+ "s3",
76
+ region_name=AWS_REGION,
77
+ aws_access_key_id=AWS_ACCESS_KEY_ID,
78
+ aws_secret_access_key=AWS_SECRET_ACCESS_KEY,
79
+ )
80
+ except Exception:
81
+ _s3_client = None
82
+
83
+ rag_bp = Blueprint("rag", __name__)
84
+
85
+ REMOTE_API_URL = "https://kw6j9hcwmljvpa-5000.proxy.runpod.net/generate"
86
+
87
+ def _upload_to_s3(file_path: Union[str, Path]) -> Optional[str]:
88
+ """Upload file to S3 and return presigned URL or None."""
89
+ if not _s3_client or not S3_BUCKET:
90
+ return None
91
+ try:
92
+ file_path = str(file_path)
93
+ key = f"{S3_PREFIX}{Path(file_path).name}"
94
+ _s3_client.upload_file(file_path, S3_BUCKET, key)
95
+ return _s3_client.generate_presigned_url(
96
+ "get_object", Params={"Bucket": S3_BUCKET, "Key": key}, ExpiresIn=24 * 3600
97
+ )
98
+ except (NoCredentialsError, ClientError) as e:
99
+ try:
100
+ current_app.logger.error("S3 upload failed: %s", e)
101
+ except Exception:
102
+ print("S3 upload failed:", e)
103
+ return None
104
+
105
 
106
+ # XTTS helper (lazy-initializes the Coqui model)
107
  def xtts_speak_to_file(
108
  text: str,
109
  out_file: Optional[Union[str, Path]] = None,
110
+ reference_dir: Optional[Union[str, Path]] = "assets",
111
  reference_files: Optional[Sequence[Union[str, Path]]] = None,
112
  language: str = "en",
113
  patterns: Iterable[str] = ("*.wav", "*.mp3", "*.flac"),
114
  ) -> Path:
115
+ speakers = []
 
 
 
116
  if reference_files:
117
  speakers.extend(str(Path(p)) for p in reference_files)
118
 
 
123
 
124
  speakers = list(dict.fromkeys(speakers))
125
  if not speakers:
126
+ raise FileNotFoundError(f"No reference audio files found: {reference_files or reference_dir}")
 
 
 
127
 
128
  if not hasattr(xtts_speak_to_file, "_model") or xtts_speak_to_file._model is None:
129
+ import sys, builtins
 
 
 
 
 
 
 
130
  sys.stdin = open(os.devnull)
131
  builtins.input = lambda *a, **kw: ""
132
  os.environ["COQUI_TOS_AGREED"] = "1"
133
 
134
+ # Best-effort registration for safe globals (if available)
135
+ try:
136
+ from TTS.tts.configs.xtts_config import XttsConfig
137
+ from TTS.tts.models.xtts import XttsAudioConfig, XttsArgs
138
+ from TTS.config.shared_configs import BaseDatasetConfig
139
+ import torch
140
+
141
+ add_safe = getattr(torch.serialization, "add_safe_globals", None)
142
+ if callable(add_safe):
143
+ add_safe([XttsConfig, XttsAudioConfig, BaseDatasetConfig, XttsArgs])
144
+ except Exception:
145
+ pass
146
 
 
147
  xtts_speak_to_file._model = TTS(
148
  model_name="tts_models/multilingual/multi-dataset/xtts_v2",
149
  gpu=False,
150
  progress_bar=False,
151
  )
 
 
152
 
153
+ tts = xtts_speak_to_file._model
154
  out_path = Path(out_file) if out_file else Path(f"xtts_{uuid.uuid4().hex}.wav")
155
  out_path.parent.mkdir(parents=True, exist_ok=True)
156
 
157
  try:
158
+ tts.tts_to_file(text=text, speaker_wav=speakers, language=language, file_path=str(out_path))
 
 
 
 
 
159
  except Exception as e:
160
  raise RuntimeError(f"XTTS synthesis failed: {e}") from e
161
 
162
  return out_path
163
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
 
165
+ # Serve audio files from AUDIO_DIR
 
 
 
 
166
  @rag_bp.route("/audio/<path:filename>", methods=["GET"])
167
  def rag_serve_audio(filename: str):
168
+ return send_from_directory(str(AUDIO_DIR), filename, mimetype="audio/wav", conditional=True)
169
 
 
 
 
 
170
 
171
+ # CORS for dev Angular origins
 
 
 
 
172
  @rag_bp.after_app_request
173
  def add_cors_headers(resp):
174
  origin = request.headers.get("Origin")
 
175
  if origin in ("http://localhost:4200", "http://127.0.0.1:4200"):
176
  resp.headers["Access-Control-Allow-Origin"] = origin
177
  resp.headers["Vary"] = "Origin"
 
180
  return resp
181
 
182
 
183
+ def user_to_db_level(username: Optional[str]) -> Optional[str]:
 
 
 
184
  if not username:
185
  return None
186
  u = username.strip().lower()
187
+ return {"lowergrade": "low", "midgrade": "mid", "highergrade": "high"}.get(u)
 
 
 
 
 
 
188
 
189
 
190
+ def extract_username_from_request(req) -> Optional[str]:
191
  hdr = req.headers.get("X-User")
192
  if hdr:
193
  return hdr
 
195
  return data.get("username")
196
 
197
 
198
+ # D-ID helpers
199
  def _did_create_talk(text: str):
200
  if not DID_API_KEY:
201
  return None, ("DID_API_KEY not set on the server", 500)
 
203
  return None, ("DID_SOURCE_IMAGE_URL not set on the server", 500)
204
 
205
  payload = {
206
+ "script": {"type": "text", "input": text, "provider": {"type": "microsoft", "voice_id": DID_VOICE_ID}},
 
 
 
 
207
  "source_url": DID_SOURCE_IMAGE_URL,
208
  "config": {"fluent": True, "pad_audio": 0},
209
  }
 
241
  return None, ("D-ID poll failed", 502)
242
 
243
 
244
+ # New helper: generate KD Talker video from text (returns (video_url, None) or (None, (msg, status)))
245
+ def _generate_kd_video_from_text(text: str, language: str = "en"):
246
+ image_path = Path(os.getenv("VIDEO_IMAGE_PATH", str(BASE_DIR / 'assets' / 'teacher.png')))
247
+ if not image_path.exists():
248
+ return None, ("Image file not found", 404)
249
+
250
+ # 1) Synthesize audio from text -> save wav under AUDIO_DIR
251
+ try:
252
+ out_name = f"genvid_{uuid.uuid4().hex}.wav"
253
+ wav_path = xtts_speak_to_file(
254
+ text=text,
255
+ out_file=AUDIO_DIR / out_name,
256
+ reference_dir=XTTS_REF_DIR,
257
+ reference_files=None,
258
+ language=language
259
+ )
260
+ except FileNotFoundError as e:
261
+ current_app.logger.error("XTTS references missing: %s", e)
262
+ return None, ("XTTS reference audio files not found on server", 500)
263
+ except Exception as e:
264
+ current_app.logger.exception("XTTS synthesis failed: %s", e)
265
+ return None, ("Audio synthesis failed", 500)
266
+
267
+ # 2) Call GPU server with image + synthesized audio
268
+ try:
269
+ with image_path.open("rb") as img_file, Path(wav_path).open("rb") as audio_file:
270
+ files = {
271
+ "image": ("image", img_file),
272
+ "audio": ("audio", audio_file),
273
+ }
274
+ data_form = {"text": text}
275
+ response = requests.post(REMOTE_API_URL, files=files, data=data_form, timeout=120)
276
+
277
+ if response.status_code != 200:
278
+ return None, (f"GPU server error: {response.text}", 502)
279
+
280
+ # Expect JSON { "video_url": "..." }
281
+ try:
282
+ payload = response.json()
283
+ video_url = payload.get("video_url")
284
+ if not video_url:
285
+ return None, ("Video URL not found in GPU response", 502)
286
+ return video_url, None
287
+ except Exception as e:
288
+ current_app.logger.exception("GPU response parse failed: %s", e)
289
+ return None, ("Error parsing GPU response JSON", 500)
290
+
291
+ except Exception as e:
292
+ current_app.logger.exception("GPU server request failed: %s", e)
293
+ return None, ("GPU server request failed", 500)
294
+
295
+
296
+ # Ingest endpoints
297
  @rag_bp.route("/ingest", methods=["POST", "OPTIONS"])
298
  def rag_ingest():
299
  if request.method == "OPTIONS":
300
  return ("", 204)
301
  body = IngestBody(**(request.json or {}))
302
+ return jsonify(ingest_documents(body))
 
303
 
304
 
305
  @rag_bp.route("/ingest-pdfs", methods=["POST", "OPTIONS"])
 
308
  return ("", 204)
309
  data = request.json or {}
310
  folder = data.get("folder", PDF_DEFAULT_FOLDER)
311
+ return jsonify(ingest_pdfs_from_folder(folder, subject=data.get("subject"), grade=data.get("grade"), chapter=data.get("chapter")))
 
 
 
 
312
 
313
 
314
  @rag_bp.route("/generate-questions", methods=["POST", "OPTIONS"])
 
321
  if not data.get("db_level"):
322
  data["db_level"] = mapped_level
323
  body = LLMBody(**data)
324
+ return jsonify(llm_generate(body))
325
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
326
 
327
  @rag_bp.route("/explain-grammar", methods=["POST", "OPTIONS"])
328
  def rag_explain_grammar():
329
  if request.method == "OPTIONS":
330
  return ("", 204)
 
331
  data = request.get_json(force=True) or {}
 
 
332
  username = extract_username_from_request(request)
 
333
 
 
334
  body = ExplainBody(
335
  question=(data.get("question") or "").strip(),
336
  model=data.get("model", "gpt-4o-mini"),
337
+ db_level=user_to_db_level(username),
338
+ source_ids=data.get("source_ids") or [],
339
  )
340
 
 
341
  result_raw = llm_explain(body)
342
 
343
+ # normalize result
 
 
344
  try:
345
  if isinstance(result_raw, dict):
346
  result_dict = dict(result_raw)
 
352
  result_dict = {"answer": result_raw}
353
  else:
354
  result_dict = {"answer": str(result_raw)}
 
 
 
 
 
 
 
355
  except Exception as e:
356
  current_app.logger.exception("Failed to normalize llm_explain result: %s", e)
357
  return jsonify({"error": "Internal error normalizing LLM response"}), 500
358
 
359
+ answer_text = (result_dict.get("answer") or result_dict.get("response") or result_dict.get("text") or "").strip()
360
+
361
+ # optional audio synthesis
362
+ if data.get("synthesize_audio"):
363
+ try:
364
+ out_name = f"explain_{uuid.uuid4().hex}.wav"
365
+ wav_path = xtts_speak_to_file(
366
+ text=answer_text or result_dict.get("answer", ""),
367
+ out_file=AUDIO_DIR / out_name,
368
+ reference_dir=XTTS_REF_DIR,
369
+ reference_files=None,
370
+ language=data.get("language", "en"),
371
+ )
372
+ base = request.host_url.rstrip("/")
373
+ result_dict["audio_url"] = f"{base}/rag/audio/{wav_path.name}"
374
+ except FileNotFoundError as e:
375
+ current_app.logger.error("XTTS reference audio missing: %s", e)
376
+ except Exception as e:
377
+ current_app.logger.exception("XTTS synthesis during explain-grammar failed: %s", e)
378
+
379
+ # optional video synthesis (D-ID or KD Talker)
380
+ if data.get("synthesize_video"):
381
+ # KD Talker path if frontend requested it (chatId === '2')
382
+ if data.get("kdtalker") or data.get("use_kdtalker"):
383
  try:
384
+ video_url, err = _generate_kd_video_from_text(answer_text or result_dict.get("answer", ""), data.get("language", "en"))
385
+ if err:
386
+ try:
387
+ current_app.logger.error("KD Talker create error during explain-grammar: %s", err[0] if isinstance(err, tuple) else err)
388
+ except Exception:
389
+ print("KD Talker error:", err)
390
+ elif video_url:
391
+ result_dict["video_url"] = video_url
 
 
 
 
392
  except Exception as e:
393
+ current_app.logger.exception("KD Talker inline synthesis failed during explain-grammar: %s", e)
394
+ else:
395
+ # existing D-ID flow
 
 
 
 
396
  if not DID_API_KEY or not DID_SOURCE_IMAGE_URL:
397
  current_app.logger.error("D-ID not configured for inline explain-grammar video synthesis")
398
  else:
399
  try:
400
  talk_id, err = _did_create_talk(answer_text or result_dict.get("answer", ""))
401
  if err:
402
+ current_app.logger.error("D-ID create error during explain-grammar: %s", err[0] if isinstance(err, tuple) else err)
 
 
 
403
  else:
404
  video_url, err = _did_poll_talk(talk_id, timeout_sec=120, interval_sec=2.0)
405
  if err:
406
+ current_app.logger.error("D-ID poll error during explain-grammar: %s", err[0] if isinstance(err, tuple) else err)
407
+ elif video_url:
408
+ result_dict["video_url"] = video_url
 
 
 
 
409
  except Exception as e:
410
  current_app.logger.exception("D-ID inline synthesis failed during explain-grammar: %s", e)
 
 
411
 
 
412
  return jsonify(result_dict), 200
413
 
414
 
 
 
415
  @rag_bp.route("/suggest-followups", methods=["POST", "OPTIONS"])
416
  def rag_suggest_followups():
417
  if request.method == "OPTIONS":
418
  return ("", 204)
 
419
  data = request.get_json(force=True) or {}
420
  username = extract_username_from_request(request)
 
 
421
  body = FollowupBody(
422
  last_question=(data.get("last_question") or "").strip(),
423
  last_answer=(data.get("last_answer") or "").strip(),
424
  n=int(data.get("n", 5)),
425
  model=data.get("model", "gpt-4o-mini"),
426
+ db_level=user_to_db_level(username),
427
+ source_ids=data.get("source_ids") or [],
428
  )
429
+ return jsonify(llm_followups(body))
 
430
 
431
 
 
432
  @rag_bp.get("/_diag")
433
  def rag_diag():
434
+ # Vectorstore diagnostics + media & routing checks
435
  try:
436
+ from .rag_llm import CHROMA_DIR, CHROMA_ROOT, get_vectorstore as gs, get_vectorstore_for as gvf
437
  except ImportError:
438
+ from rag_llm import CHROMA_DIR, CHROMA_ROOT, get_vectorstore as gs, get_vectorstore_for as gvf
 
 
 
439
 
440
  def _count(vs):
 
441
  if vs is None:
442
  return None
 
443
  if hasattr(vs, "count") and callable(vs.count):
444
  try:
445
  return vs.count()
446
  except Exception:
447
  return None
 
448
  if hasattr(vs, "_collection"):
449
  try:
450
+ return vs._collection.count()
451
  except Exception:
452
  try:
453
+ return vs._client.get_collection(vs._collection.name).count()
454
  except Exception:
455
  return None
456
  return None
457
 
458
+ low_vs = gvf("low")
459
+ mid_vs = gvf("mid")
460
+ high_vs = gvf("high")
461
+
462
+ # media checks
463
+ ref_dir_exists = XTTS_REF_DIR.exists() and XTTS_REF_DIR.is_dir()
464
+ ref_files = []
465
+ if ref_dir_exists:
466
+ for ext in ("*.wav", "*.mp3", "*.flac"):
467
+ ref_files.extend([str(p.name) for p in XTTS_REF_DIR.glob(ext)])
468
+ audio_dir_exists = AUDIO_DIR.exists() and AUDIO_DIR.is_dir()
469
+ audio_files = [p.name for p in AUDIO_DIR.glob("*.wav")] if audio_dir_exists else []
470
+
471
+ # list registered routes beginning with /rag
472
+ routes = [r.rule for r in current_app.url_map.iter_rules() if r.rule.startswith("/rag")]
473
 
474
  info = {
475
+ "env_seen": {"CHROMA_DIR": CHROMA_DIR, "CHROMA_ROOT": CHROMA_ROOT},
476
+ "low_dir": {"path": str(Path(CHROMA_ROOT) / "low"), "exists": Path(CHROMA_ROOT, "low").is_dir()},
477
+ "counts_default": _count(gs()),
 
 
 
 
 
 
478
  "counts_low": _count(low_vs),
479
  "counts_mid": _count(mid_vs),
480
  "counts_high": _count(high_vs),
481
+ "media": {
482
+ "xtts_ref_dir": str(XTTS_REF_DIR),
483
+ "xtts_ref_dir_exists": ref_dir_exists,
484
+ "xtts_ref_files_sample": ref_files[:10],
485
+ "audio_dir": str(AUDIO_DIR),
486
+ "audio_dir_exists": audio_dir_exists,
487
+ "audio_files_sample": audio_files[:20],
488
+ },
489
+ "routes": routes,
490
  }
491
  return jsonify(info), 200
492
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
493
 
494
  @rag_bp.route("/search", methods=["POST", "OPTIONS"])
495
  def rag_search():
 
499
  q = (data.get("q") or "").strip()
500
  if not q:
501
  return jsonify({"results": []})
 
 
502
  username = extract_username_from_request(request)
503
+ db_level = data.get("db_level") or user_to_db_level(username)
 
 
504
  vs = get_vectorstore_for(db_level)
505
  hits = vs.similarity_search_with_score(q, k=5)
506
  out = []
507
  for doc, dist in hits:
508
+ out.append(
509
+ {
510
+ "distance": float(dist),
511
+ "snippet": doc.page_content[:200],
512
+ "source_path": os.path.normpath(doc.metadata.get("source_path", "")),
513
+ "page": doc.metadata.get("page_1based"),
514
+ }
515
+ )
516
  return jsonify({"results": out})
517
 
518
 
519
+ @rag_bp.route("/generate-questions-from-chroma", methods=["POST", "OPTIONS"])
520
+ def generate_questions_from_chroma():
521
+ if request.method == "OPTIONS":
522
+ return ("", 204)
523
+
524
  try:
525
  vectorstore = get_vectorstore()
526
  query_text = "important content related to grammar"
527
  results = vectorstore.similarity_search_with_score(query_text, k=5)
 
528
  content = "\n".join([doc.page_content for doc, _ in results])
 
529
  if not content:
530
+ return jsonify({"error": "No content retrieved from vectorstore. Please ingest PDFs first."}), 200
531
  prompt = f"Generate 5 important questions based on the following content: {content}"
532
  response = openai_client.chat.completions.create(
533
+ model="gpt-4o-mini", messages=[{"role": "user", "content": prompt}], temperature=0.7, max_tokens=150
 
 
 
534
  )
535
+ generated = response.choices[0].message.content.strip()
 
 
536
  except Exception as e:
537
+ generated = {"error": f"Failed to call OpenAI: {str(e)}"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
538
  return jsonify({"generated_questions": generated})
539
 
540
 
 
542
  def health():
543
  return {"status": "ok"}, 200
544
 
545
+
546
  @rag_bp.route("/synthesize-audio", methods=["POST", "OPTIONS"])
547
  def rag_synthesize_audio():
 
 
 
 
548
  if request.method == "OPTIONS":
549
  return ("", 204)
 
550
  data = request.get_json(force=True) or {}
551
  text = (data.get("text") or "").strip()
552
  if not text:
553
  return jsonify({"error": "No text provided"}), 400
554
 
555
+ language = (data.get("language") or "en").strip()
556
+ reference_files = data.get("reference_files")
557
+
558
+ # preflight checks
559
+ try:
560
+ if not reference_files:
561
+ if not XTTS_REF_DIR.exists() or not XTTS_REF_DIR.is_dir():
562
+ current_app.logger.error("XTTS_REF_DIR not found: %s", XTTS_REF_DIR)
563
+ return jsonify({"error": "XTTS reference directory not found", "details": str(XTTS_REF_DIR)}), 500
564
+ has_any = any(XTTS_REF_DIR.glob("*.wav")) or any(XTTS_REF_DIR.glob("*.mp3")) or any(XTTS_REF_DIR.glob("*.flac"))
565
+ if not has_any:
566
+ current_app.logger.error("No reference audio files in XTTS_REF_DIR: %s", XTTS_REF_DIR)
567
+ return jsonify({"error": "XTTS reference audio files not found on server", "details": str(XTTS_REF_DIR)}), 500
568
+ else:
569
+ missing = [str(p) for p in reference_files if not Path(p).exists()]
570
+ if missing:
571
+ current_app.logger.error("Provided reference_files missing: %s", missing)
572
+ return jsonify({"error": "One or more reference_files not found", "details": missing}), 400
573
+ except Exception as pre_e:
574
+ current_app.logger.exception("Preflight validation failed: %s", pre_e)
575
+ return jsonify({"error": "Preflight validation failed", "details": str(pre_e)}), 500
576
 
577
  try:
578
  out_name = f"synth_{uuid.uuid4().hex}.wav"
579
  wav_path = xtts_speak_to_file(
580
+ text=text, out_file=AUDIO_DIR / out_name, reference_dir=XTTS_REF_DIR, reference_files=reference_files, language=language
 
 
 
 
581
  )
582
+
583
  if "localhost" in request.host_url or "127.0.0.1" in request.host_url:
584
  base = request.host_url.rstrip("/")
585
  audio_url = f"{base}/rag/audio/{wav_path.name}"
586
  else:
 
587
  s3_url = _upload_to_s3(str(wav_path))
588
  if s3_url:
589
  audio_url = s3_url
590
  else:
591
+ base = os.getenv("SPACE_URL", "https://majemaai-mj-learn-backend.hf.space")
592
  audio_url = f"{base}/rag/audio/{wav_path.name}"
593
 
594
  return jsonify({"audio_url": audio_url, "file": wav_path.name}), 200
595
+ except FileNotFoundError as e:
596
+ current_app.logger.error("XTTS references missing: %s", e)
597
+ return jsonify({"error": "XTTS reference audio files not found on server", "details": str(e)}), 500
 
 
 
 
 
 
598
  except Exception as e:
599
  current_app.logger.exception("XTTS synthesis error: %s", e)
600
+ return jsonify({"error": "Synthesis failed", "details": str(e)}), 500
601
 
602
 
603
  @rag_bp.route("/synthesize-video", methods=["POST", "OPTIONS"])
604
  def rag_synthesize_video():
 
 
 
 
605
  if request.method == "OPTIONS":
606
  return ("", 204)
 
607
  data = request.get_json(force=True) or {}
608
  text = (data.get("text") or "").strip()
609
  if not text:
610
  return jsonify({"error": "No text provided"}), 400
 
 
611
  if not DID_API_KEY or not DID_SOURCE_IMAGE_URL:
612
  current_app.logger.error("D-ID not configured (DID_API_KEY or DID_SOURCE_IMAGE_URL missing)")
613
  return jsonify({"error": "D-ID not configured on server"}), 500
 
614
  try:
 
615
  talk_id, err = _did_create_talk(text)
616
  if err:
 
 
617
  return jsonify({"error": err[0]}), err[1]
 
 
618
  video_url, err = _did_poll_talk(talk_id, timeout_sec=120, interval_sec=2.0)
619
  if err:
 
620
  return jsonify({"error": err[0]}), err[1]
 
621
  if not video_url:
 
622
  return jsonify({"error": "D-ID did not return a video URL"}), 502
 
623
  return jsonify({"video_url": video_url}), 200
 
624
  except Exception as e:
625
  current_app.logger.exception("Unexpected error generating D-ID video: %s", e)
626
  return jsonify({"error": "Internal server error generating video"}), 500
627
 
628
 
629
+ @rag_bp.route("/generate-video-from-text", methods=["POST", "OPTIONS"])
630
+ def generate_video_from_text():
631
+ if request.method == "OPTIONS":
632
+ return ("", 204)
 
 
 
633
 
634
+ data = request.get_json(force=True) or {}
635
+ text = (data.get("text") or "").strip()
636
+ if not text:
637
+ return jsonify({"error": "No text provided"}), 400
638
+
639
+ language = data.get("language", "en")
640
+ video_url, err = _generate_kd_video_from_text(text, language)
641
+ if err:
642
+ return jsonify({"error": err[0]}), err[1]
643
+ return jsonify({"video_url": video_url}), 200
644
 
645
+
646
+ #KD Talker setup (helper already added above)
647
+
648
+ if __name__ == "__main__":
649
+ app = Flask(__name__)
650
  CORS(
651
  app,
652
  resources={r"/rag/*": {"origins": ["http://localhost:4200", "http://127.0.0.1:4200"]}},
 
654
  allow_headers=["Content-Type", "Authorization", "X-User"],
655
  methods=["GET", "POST", "OPTIONS"],
656
  )
 
 
657
  os.makedirs(os.getenv("CHROMA_DIR", "./chroma"), exist_ok=True)
 
 
658
  app.register_blueprint(rag_bp, url_prefix="/rag")
659
+ app.run(host="0.0.0.0", port=7000, debug=True)
ragg/ingest_all.py CHANGED
@@ -18,9 +18,9 @@ IS_HF = bool(os.getenv("HF_HOME") or os.getenv("SPACE_ID"))
18
  HERE = Path(__file__).resolve().parent
19
 
20
  # PDF root auto-detect
21
- PDFS_ROOT = (HERE / "pdfs")
22
  if not PDFS_ROOT.is_dir():
23
- PDFS_ROOT = (HERE.parent / "pdfs") # Works for /app/pdfs/*
24
 
25
  # Chroma root auto-detect
26
  CHROMA_BASE = Path(os.getenv("CHROMA_ROOT") or ("/data/chroma" if IS_HF else "./chroma"))
 
18
  HERE = Path(__file__).resolve().parent
19
 
20
  # PDF root auto-detect
21
+ PDFS_ROOT = (HERE / "assets" / "pdfs")
22
  if not PDFS_ROOT.is_dir():
23
+ PDFS_ROOT = (HERE.parent / "assets" / "pdfs") # Works for /app/pdfs/*
24
 
25
  # Chroma root auto-detect
26
  CHROMA_BASE = Path(os.getenv("CHROMA_ROOT") or ("/data/chroma" if IS_HF else "./chroma"))
ragg/tts.py CHANGED
@@ -7,7 +7,7 @@ from TTS.api import TTS
7
  def xtts_speak_to_file(
8
  text: str,
9
  out_file: Optional[Union[str, Path]] = None,
10
- reference_dir: Optional[Union[str, Path]] = "trim",
11
  reference_files: Optional[Sequence[Union[str, Path]]] = None,
12
  language: str = "en",
13
  patterns: Iterable[str] = ("*.wav", "*.mp3", "*.flac"),
 
7
  def xtts_speak_to_file(
8
  text: str,
9
  out_file: Optional[Union[str, Path]] = None,
10
+ reference_dir: Optional[Union[str, Path]] = "assets",
11
  reference_files: Optional[Sequence[Union[str, Path]]] = None,
12
  language: str = "en",
13
  patterns: Iterable[str] = ("*.wav", "*.mp3", "*.flac"),
reading.py DELETED
@@ -1,158 +0,0 @@
1
- from flask import Flask, Blueprint, request, jsonify, current_app
2
- import openai
3
- import random
4
- import os
5
- from flask_cors import CORS
6
-
7
- # --- Blueprint ---
8
- reading_bp = Blueprint("reading", __name__)
9
-
10
- # app = Flask(__name__)
11
-
12
- app = Flask(__name__)
13
- CORS(app)
14
-
15
- _OPENAI_API_KEY_FALLBACK = os.getenv("OPENAI_API_KEY", "")
16
- # Set up your OpenAI API key (replace this with your own API key)
17
- # openai.api_key = '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' # Replace with your actual OpenAI API key
18
-
19
- def _ensure_openai_key():
20
- """Set openai.api_key from app config or env before each API call."""
21
- api_key = (current_app.config.get("OPENAI_API_KEY")
22
- if current_app else None) or _OPENAI_API_KEY_FALLBACK
23
- if api_key:
24
- openai.api_key = api_key
25
-
26
- # Function to generate content dynamically based on the topic and difficulty level
27
- def generate_content(topic, difficulty):
28
- _ensure_openai_key()
29
- try:
30
- # Define instructions based on difficulty level
31
- if difficulty == "easy":
32
- instruction = f"Write a very simple and basic explanation about {topic} for children aged 6-8. Use very simple words and short sentences."
33
- elif difficulty == "medium":
34
- instruction = f"Write a detailed and engaging explanation about {topic} for children aged 9-12. Use simple words but include more details."
35
- else: # Hard difficulty
36
- instruction = f"Write an in-depth explanation about {topic} for children aged 13-16. Use more complex words and provide deeper insights into the topic."
37
-
38
- # Call OpenAI API to generate the content
39
- response = openai.chat.completions.create(
40
- model="gpt-3.5-turbo",
41
- messages=[
42
- {"role": "system", "content": "You are a friendly teacher explaining concepts to students."},
43
- {"role": "user", "content": instruction}
44
- ],
45
- max_tokens=700,
46
- temperature=0.7
47
- )
48
-
49
- content = response.choices[0].message.content.strip()
50
- return content
51
-
52
- except Exception as e:
53
- return f"Error generating content: {str(e)}"
54
-
55
- # Function to generate multiple-choice questions from content based on difficulty level
56
- def generate_questions(content, difficulty):
57
- _ensure_openai_key()
58
- try:
59
-
60
- # Split the content into sentences or key points and shuffle them
61
- content_sentences = content.split(".") # Assuming content is in sentence form. If not, modify accordingly.
62
- random.shuffle(content_sentences)
63
-
64
- # Adjust question complexity based on difficulty
65
- if difficulty == "easy":
66
- question_instruction = "Generate 3 very simple multiple-choice questions based on the content. The questions should be very easy to understand."
67
- elif difficulty == "medium":
68
- question_instruction = "Generate 3 multiple-choice questions with moderate difficulty based on the content."
69
- else: # Hard difficulty
70
- question_instruction = "Generate 3 challenging multiple-choice questions that require deep understanding of the content."
71
-
72
- # prompt = f"{question_instruction}\nContent:\n{content}\n\nFormat the output like this:\n\n1. Question: What is XYZ?\nOptions: [Option 1, Option 2, Option 3, Option 4]\nCorrect Answer: Option 1\n\n2. Question: Why does XYZ happen?\nOptions: [Option 1, Option 2, Option 3, Option 4]\nCorrect Answer: Option 2"
73
-
74
-
75
- prompt = f"{question_instruction}\nContent:\n{'. '.join(content_sentences[:3])}\n\nFormat the output like this:\n\n1. Question: What is XYZ?\nOptions: [Option 1, Option 2, Option 3, Option 4]\nCorrect Answer: Option 1\n\n2. Question: Why does XYZ happen?\nOptions: [Option 1, Option 2, Option 3, Option 4]\nCorrect Answer: Option 2"
76
-
77
- response = openai.chat.completions.create(
78
- model="gpt-3.5-turbo",
79
- messages=[
80
- {"role": "system", "content": "You are a helpful assistant who generates educational multiple-choice questions."},
81
- {"role": "user", "content": prompt}
82
- ],
83
- max_tokens=700,
84
- temperature=0.7
85
- )
86
-
87
- questions = response.choices[0].message.content.strip()
88
- return questions
89
-
90
- except Exception as e:
91
- return f"Error generating questions: {str(e)}"
92
-
93
- @reading_bp.route('/generate_content', methods=['POST'])
94
- # @app.route('/generate_content', methods=['POST'])
95
- def generate_content_route():
96
- data = request.json
97
- topic = data.get('topic')
98
- difficulty = data.get('difficulty', 'medium') # Default to medium if not provided
99
-
100
- if not topic:
101
- return jsonify({"error": "Topic is required"}), 400
102
-
103
- if difficulty not in ["easy", "medium", "hard"]:
104
- return jsonify({"error": "Invalid difficulty level. Choose 'easy', 'medium', or 'hard'."}), 400
105
-
106
- content = generate_content(topic, difficulty)
107
- return jsonify({"content": content})
108
-
109
- @reading_bp.route('/generate_questions', methods=['POST'])
110
- # @app.route('/generate_questions', methods=['POST'])
111
- def generate_questions_route():
112
- data = request.json
113
- content = data.get('content')
114
- difficulty = data.get('difficulty', 'medium') # Default to medium if not provided
115
-
116
- if not content:
117
- return jsonify({"error": "Content is required"}), 400
118
-
119
- if difficulty not in ["easy", "medium", "hard"]:
120
- return jsonify({"error": "Invalid difficulty level. Choose 'easy', 'medium', or 'hard'."}), 400
121
-
122
- questions = generate_questions(content, difficulty)
123
- return jsonify({"questions": questions})
124
-
125
- @reading_bp.route('/validate_answer', methods=['POST'])
126
- # @app.route('/validate_answer', methods=['POST'])
127
- def validate_answer():
128
- question = request.json.get('question')
129
- selected_answer = request.json.get('selected_answer')
130
-
131
- if not question or not selected_answer:
132
- return jsonify({"error": "Question and answer are required"}), 400
133
-
134
- # Ensure both answers are stripped of leading/trailing spaces before comparison
135
- correct_answer = question["correct_answer"].strip()
136
- selected_answer = selected_answer.strip()
137
-
138
- # Print the correct answer to the backend console for debugging
139
- print(f"Correct Answer: {correct_answer}")
140
-
141
- is_correct = selected_answer == correct_answer
142
-
143
- return jsonify({"is_correct": is_correct, "correct_answer": correct_answer})
144
-
145
- # if __name__ == '__main__':
146
- # app.run(debug=True)
147
-
148
- # if __name__ == '__main__':
149
- # app.run(host='0.0.0.0', port=5001)
150
-
151
- # --- Optional: allow this file to run standalone locally while still using the blueprint ---
152
- if __name__ == '__main__':
153
- app = Flask(__name__)
154
- CORS(app)
155
- # For local runs, pull key from env; no hard-coding
156
- app.config["OPENAI_API_KEY"] = os.getenv("OPENAI_API_KEY", "")
157
- app.register_blueprint(reading_bp, url_prefix='')
158
- app.run(host='0.0.0.0', port=5001, debug=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
start.sh DELETED
@@ -1,29 +0,0 @@
1
- #!/usr/bin/env bash
2
- set -euo pipefail
3
-
4
- echo "== Container start =="
5
- echo "ENV=${ENV:-dev}"
6
- echo "CHROMA_ROOT=${CHROMA_ROOT:-/data/chroma}"
7
-
8
- # Ensure Chroma root exists
9
- mkdir -p "${CHROMA_ROOT}"
10
-
11
- # Decide whether ingestion is needed (if any level folder missing or empty)
12
- _need_ingest=0
13
- for level in low mid high; do
14
- lvl_dir="${CHROMA_ROOT}/${level}"
15
- if [ ! -d "$lvl_dir" ] || [ -z "$(ls -A "$lvl_dir" 2>/dev/null || true)" ]; then
16
- _need_ingest=1
17
- fi
18
- done
19
-
20
- if [ "${_need_ingest}" -eq 1 ]; then
21
- echo "No (or empty) Chroma data found → running ingestion..."
22
- # Ingest PDFs from /app/pdfs/{low,mid,high} into ${CHROMA_ROOT}/{low,mid,high}
23
- python -m ragg.ingest_all || echo "WARNING: ingestion returned non-zero exit"
24
- else
25
- echo "Chroma already present → skipping ingestion."
26
- fi
27
-
28
- # Start the API
29
- exec gunicorn --workers 2 --threads 4 --timeout 120 -b 0.0.0.0:7860 verification:app
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
trim/voice1.wav DELETED
@@ -1,3 +0,0 @@
1
- version https://git-lfs.github.com/spec/v1
2
- oid sha256:09d064bc2bd4880ceb1c6c4a69cb941a1b5e2ea05b151b721aab4cc17c34f56b
3
- size 5364878
 
 
 
 
verification.py CHANGED
@@ -1,530 +1,190 @@
1
- # --- load .env FIRST ---
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  import os
3
  from dotenv import load_dotenv
4
- import requests
5
- from werkzeug.utils import secure_filename
6
- BASEDIR = os.path.abspath(os.path.dirname(__file__))
7
- load_dotenv(os.path.join(BASEDIR, ".env")) # loads DB_USER, DB_PASSWORD, RUN_INIT_DB
8
- import socket
9
  import logging
10
- from threading import Lock
11
- from functools import wraps
12
- import datetime
13
- import bcrypt
14
- import jwt
15
- import pyodbc
16
- from flask import Flask, request, jsonify, make_response, current_app
17
  from flask_cors import CORS
18
 
19
- # ------------------------------------------------------------------------------
20
- # App, ENV, CORS
21
- # ------------------------------------------------------------------------------
22
- app = Flask(__name__)
23
- app.config['SECRET_KEY'] = '96c63da06374c1bde332516f3acbd23c84f35f90d8a6321a25d790a0a451af32'
24
-
25
- IS_PROD = os.getenv("ENV", "dev").lower() == "prod"
26
- _origins = os.getenv("ALLOWED_ORIGINS", "http://localhost:4200")
27
- ALLOWED_ORIGINS = [o.strip() for o in _origins.split(",") if o.strip()]
28
- # CORS(app, supports_credentials=True, origins=ALLOWED_ORIGINS)
29
- # Allow both localhost forms by default if env not set
30
- _default_origins = "http://localhost:4200,http://127.0.0.1:4200"
31
- _origins = os.getenv("ALLOWED_ORIGINS", _default_origins)
32
- ALLOWED_ORIGINS = [o.strip() for o in _origins.split(",") if o.strip()]
33
-
34
- CORS(
35
- app,
36
- resources={r"/*": {"origins": ALLOWED_ORIGINS}},
37
- supports_credentials=True,
38
- allow_headers=["Content-Type", "Authorization", "X-Requested-With", "X-User"],
39
- expose_headers=["Set-Cookie"],
40
- methods=["GET", "POST", "OPTIONS"]
41
- )
42
-
43
 
44
- def extract_username_from_request(req) -> str | None:
45
- # 1) Header
46
- hdr = req.headers.get("X-User")
47
- if hdr:
48
- return hdr
49
 
50
- # 2) Body
51
- data = req.get_json(silent=True) or {}
52
- if data.get("username"):
53
- return data.get("username")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
 
55
- # 3) JWT cookie from verification.py
56
- token = req.cookies.get("access_token")
57
- if token:
58
- try:
59
- payload = jwt.decode(token, current_app.config["SECRET_KEY"], algorithms=["HS256"])
60
- return payload.get("username")
61
- except jwt.ExpiredSignatureError:
62
- return None
63
- except jwt.InvalidTokenError:
64
- return None
65
 
66
- return None
67
 
 
 
 
68
 
69
- @app.after_request
70
- def add_cors_headers(resp):
71
- origin = request.headers.get("Origin")
72
- if origin and origin in ALLOWED_ORIGINS:
73
- # echo the origin, never '*', when using credentials
74
- resp.headers["Access-Control-Allow-Origin"] = origin
75
- resp.headers["Vary"] = "Origin"
76
- resp.headers["Access-Control-Allow-Credentials"] = "true"
77
- resp.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization, X-Requested-With, X-User"
78
- resp.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
79
- return resp
80
-
81
-
82
- @app.before_request
83
- def handle_options_early():
84
- if request.method == "OPTIONS":
85
- resp = app.make_default_options_response()
 
 
 
 
 
 
 
 
 
 
 
86
  origin = request.headers.get("Origin")
87
  if origin and origin in ALLOWED_ORIGINS:
88
  resp.headers["Access-Control-Allow-Origin"] = origin
 
89
  resp.headers["Access-Control-Allow-Credentials"] = "true"
90
- # Mirror browser's requested headers/methods
91
- req_headers = request.headers.get("Access-Control-Request-Headers", "Content-Type, Authorization, X-Requested-With, X-User")
92
- req_method = request.headers.get("Access-Control-Request-Method", "POST")
93
- resp.headers["Access-Control-Allow-Headers"] = req_headers
94
- resp.headers["Access-Control-Allow-Methods"] = req_method
95
  return resp
96
-
97
-
98
- logging.basicConfig(level=logging.INFO)
99
-
100
- # NEW: API keys / shared config for blueprints (read from HF Secrets/ENV)
101
- app.config["COHERE_API_KEY"] = os.getenv("COHERE_API_KEY", "")
102
-
103
- # ------------------------------------------------------------------------------
104
- # SQL Server configuration
105
- # ------------------------------------------------------------------------------
106
- # DB_SERVER = "pykara-sqlserver.cb60o04yk948.ap-south-1.rds.amazonaws.com,1433"
107
- # DB_DATABASE = "AuthenticationDB1"
108
-
109
- DB_SERVER = os.getenv("DB_SERVER", r"(localdb)\MSSQLLocalDB")
110
- DB_DATABASE = os.getenv("DB_DATABASE", "AuthenticationDB1")
111
- DB_DRIVER = os.getenv("DB_DRIVER", "ODBC Driver 17 for SQL Server") # 17 in your image
112
-
113
-
114
- # Build connection string (FIXED)
115
- is_local = (
116
- DB_SERVER.lower().startswith("localhost")
117
- or DB_SERVER.startswith(".")
118
- or DB_SERVER.lower().startswith("(localdb)")
119
- or "\\" in DB_SERVER
120
- )
121
-
122
- if is_local:
123
- # Windows local / LocalDB using modern ODBC driver
124
- CONN_STR = (
125
- f"DRIVER={{{DB_DRIVER}}};"
126
- f"SERVER={DB_SERVER};"
127
- f"DATABASE={DB_DATABASE};"
128
- "Trusted_Connection=yes;"
129
- "TrustServerCertificate=yes;"
130
- )
131
- else:
132
- # Remote SQL auth
133
- CONN_STR = (
134
- f"DRIVER={{{DB_DRIVER}}};"
135
- f"SERVER={DB_SERVER};DATABASE={DB_DATABASE};"
136
- f"UID={os.getenv('DB_USER')};PWD={os.getenv('DB_PASSWORD')};"
137
- "Encrypt=yes;TrustServerCertificate=yes;"
138
- )
139
-
140
-
141
-
142
-
143
- # def get_db_connection():
144
- # """Create a short-timeout connection. Fail clearly if secrets are missing."""
145
- # if "Trusted_Connection=yes" not in CONN_STR:
146
- # if not os.getenv("DB_USER") or not os.getenv("DB_PASSWORD"):
147
- # raise RuntimeError("DB_USER/DB_PASSWORD are not set in the environment.")
148
- # return pyodbc.connect(CONN_STR, timeout=5)
149
-
150
- def get_db_connection():
151
- """Create a short-timeout connection. Fail clearly if secrets are missing."""
152
- if "Trusted_Connection=yes" not in CONN_STR:
153
- if not os.getenv("DB_USER") or not os.getenv("DB_PASSWORD"):
154
- raise RuntimeError("DB_USER/DB_PASSWORD are not set in the environment.")
155
- return pyodbc.connect(CONN_STR, timeout=5)
156
-
157
- @app.get("/db/diag")
158
- def db_diag():
159
- info = {}
160
- try:
161
- info["drivers_found"] = pyodbc.drivers()
162
- except Exception as e:
163
- info["drivers_found_error"] = str(e)
164
-
165
- # Resolve host part (before comma if "host,port")
166
- host = DB_SERVER.split(",")[0].strip()
167
- info["db_server_env"] = DB_SERVER
168
- info["db_database_env"] = DB_DATABASE
169
- info["db_driver_env"] = DB_DRIVER
170
-
171
- try:
172
- ip = socket.gethostbyname(host)
173
- info["dns_lookup"] = {"host": host, "ip": ip}
174
- except Exception as e:
175
- info["dns_lookup"] = {"host": host, "error": str(e)}
176
-
177
- try:
178
- conn = get_db_connection()
179
- conn.close()
180
- info["connect"] = "ok"
181
- except Exception as e:
182
- info["connect"] = f"error: {e}"
183
-
184
- return jsonify(info), 200
185
-
186
- def init_db():
187
- """Create tables if they do not exist."""
188
- conn = get_db_connection()
189
- cur = conn.cursor()
190
-
191
- cur.execute("""
192
- IF OBJECT_ID('Users', 'U') IS NULL
193
- CREATE TABLE Users (
194
- id INT IDENTITY(1,1) PRIMARY KEY,
195
- username NVARCHAR(100) UNIQUE NOT NULL,
196
- password_hash NVARCHAR(500) NOT NULL,
197
- role NVARCHAR(50) DEFAULT 'user'
198
- )
199
- """)
200
-
201
- cur.execute("""
202
- IF OBJECT_ID('BlacklistedTokens', 'U') IS NULL
203
- CREATE TABLE BlacklistedTokens (
204
- id INT IDENTITY(1,1) PRIMARY KEY,
205
- token NVARCHAR(1000) UNIQUE NOT NULL,
206
- created_at DATETIME DEFAULT GETDATE()
207
- )
208
- """)
209
-
210
- cur.execute("""
211
- IF OBJECT_ID('RefreshTokens', 'U') IS NULL
212
- CREATE TABLE RefreshTokens (
213
- id INT IDENTITY(1,1) PRIMARY KEY,
214
- username NVARCHAR(100) NOT NULL,
215
- token NVARCHAR(1000) UNIQUE NOT NULL,
216
- created_at DATETIME DEFAULT GETDATE(),
217
- FOREIGN KEY (username) REFERENCES Users(username) ON DELETE CASCADE
218
- )
219
- """)
220
-
221
- conn.commit()
222
- conn.close()
223
-
224
- # ------------------------------------------------------------------------------
225
- # One-time DB initialisation (Flask 3.x safe)
226
- # ------------------------------------------------------------------------------
227
- _db_init_done = False
228
- _db_init_lock = Lock()
229
- _should_init = os.getenv("RUN_INIT_DB", "0") == "1"
230
-
231
- @app.before_request
232
- def maybe_init_db():
233
- global _db_init_done
234
- if _should_init and not _db_init_done:
235
- with _db_init_lock:
236
- if not _db_init_done:
237
- try:
238
- init_db()
239
- app.logger.info("Database initialised.")
240
- except Exception as e:
241
- app.logger.exception("DB init failed: %s", e)
242
- finally:
243
- _db_init_done = True
244
-
245
- # ------------------------------------------------------------------------------
246
- # Cookie helpers
247
- # ------------------------------------------------------------------------------
248
- def add_cookie(resp, name: str, value: str, max_age: int):
249
- """
250
- In prod: Secure + SameSite=None + Partitioned (works with third-party cookie protections).
251
- In dev: SameSite=Lax, not Secure.
252
- """
253
- if IS_PROD:
254
- resp.headers.add(
255
- "Set-Cookie",
256
- f"{name}={value}; Path=/; Max-Age={max_age}; Secure; HttpOnly; SameSite=None; Partitioned"
257
- )
258
- else:
259
- resp.set_cookie(name, value, httponly=True, secure=False, samesite="Lax", max_age=max_age, path="/")
260
-
261
- # ------------------------------------------------------------------------------
262
- # Health
263
- # ------------------------------------------------------------------------------
264
- @app.get("/")
265
- def health():
266
- return {"status": "ok"}, 200
267
 
268
- # ------------------------------------------------------------------------------
269
- # Auth utilities
270
- # ------------------------------------------------------------------------------
271
- from functools import wraps
272
- def token_required(f):
273
- @wraps(f)
274
- def decorated(*args, **kwargs):
275
- token = request.cookies.get('access_token')
276
- if not token:
277
- return jsonify({"message": "Token is missing"}), 401
278
 
 
 
 
 
 
 
 
 
 
279
  try:
280
- # Check blacklist
281
- conn = get_db_connection()
282
- cur = conn.cursor()
283
- cur.execute("SELECT token FROM BlacklistedTokens WHERE token = ?", (token,))
284
- if cur.fetchone():
285
- conn.close()
286
- return jsonify({"message": "Token has been revoked. Please log in again."}), 401
287
- conn.close()
288
-
289
- data = jwt.decode(token, app.config['SECRET_KEY'], algorithms=["HS256"])
290
- return f(data['username'], *args, **kwargs)
291
-
292
- except jwt.ExpiredSignatureError:
293
- return jsonify({"message": "Token has expired"}), 401
294
- except jwt.InvalidTokenError:
295
- return jsonify({"message": "Invalid token"}), 401
296
- except Exception as e:
297
- app.logger.exception("Auth error: %s", e)
298
- return jsonify({"message": "Server error"}), 500
299
- return decorated
300
 
301
- # ------------------------------------------------------------------------------
302
- # Routes (verification/auth only)
303
- # ------------------------------------------------------------------------------
304
- @app.get("/dashboard")
305
- @token_required
306
- def dashboard(username):
307
- return jsonify({"message": f"Welcome {username} to your dashboard!"})
308
 
309
- @app.post("/login")
310
- def login():
311
- data = request.json or {}
312
- username = data.get('username')
313
- password = data.get('password')
314
-
315
- try:
316
- conn = get_db_connection()
317
- cur = conn.cursor()
318
- cur.execute("SELECT password_hash FROM Users WHERE username = ?", (username,))
319
- row = cur.fetchone()
320
- conn.close()
321
- except Exception as e:
322
- app.logger.exception("DB access error on login: %s", e)
323
- return jsonify({"message": "Database is unavailable"}), 503
324
-
325
- if not row:
326
- return jsonify({"message": "Invalid credentials"}), 401
327
-
328
- stored_hash = row[0]
329
- if not bcrypt.checkpw(password.encode('utf-8'), stored_hash.encode('utf-8')):
330
- return jsonify({"message": "Invalid credentials"}), 401
331
-
332
- access_token = jwt.encode(
333
- {'username': username, 'exp': datetime.datetime.utcnow() + datetime.timedelta(minutes=15)},
334
- app.config['SECRET_KEY'],
335
- algorithm="HS256"
336
- )
337
- refresh_token = jwt.encode(
338
- {'username': username, 'exp': datetime.datetime.utcnow() + datetime.timedelta(days=7)},
339
- app.config['SECRET_KEY'],
340
- algorithm="HS256"
341
- )
342
-
343
- try:
344
- conn = get_db_connection()
345
- cur = conn.cursor()
346
- cur.execute("INSERT INTO RefreshTokens (username, token) VALUES (?, ?)", (username, refresh_token))
347
- conn.commit()
348
- conn.close()
349
- except Exception as e:
350
- app.logger.exception("DB write error on login: %s", e)
351
- return jsonify({"message": "Database is unavailable"}), 503
352
-
353
- resp = make_response(jsonify({"message": "Login successful"}))
354
- add_cookie(resp, 'access_token', access_token, 900) # 15 min
355
- add_cookie(resp, 'refresh_token', refresh_token, 7*24*60*60) # 7 days
356
- return resp
357
-
358
- @app.post("/refresh")
359
- def refresh():
360
- refresh_token = request.cookies.get("refresh_token")
361
- if not refresh_token:
362
- return jsonify({'message': 'Refresh token is missing'}), 400
363
-
364
- try:
365
- payload = jwt.decode(refresh_token, app.config['SECRET_KEY'], algorithms=["HS256"])
366
- except jwt.ExpiredSignatureError:
367
- return jsonify({'message': 'Refresh token has expired'}), 401
368
- except jwt.InvalidTokenError:
369
- return jsonify({'message': 'Invalid refresh token'}), 401
370
-
371
- try:
372
- conn = get_db_connection()
373
- cur = conn.cursor()
374
- cur.execute("SELECT username FROM RefreshTokens WHERE token = ?", (refresh_token,))
375
- row = cur.fetchone()
376
- conn.close()
377
- except Exception as e:
378
- app.logger.exception("DB access error on refresh: %s", e)
379
- return jsonify({"message": "Database is unavailable"}), 503
380
-
381
- if not row:
382
- return jsonify({'message': 'Invalid refresh token'}), 401
383
-
384
- username = row[0]
385
- new_access = jwt.encode(
386
- {'username': username, 'exp': datetime.datetime.utcnow() + datetime.timedelta(minutes=15)},
387
- app.config['SECRET_KEY'],
388
- algorithm="HS256"
389
- )
390
-
391
- resp = make_response(jsonify({'access_token': new_access}))
392
- add_cookie(resp, 'access_token', new_access, 900)
393
- return resp
394
-
395
- @app.post("/logout")
396
- @token_required
397
- def logout(username):
398
- token = request.cookies.get('access_token')
399
- if not token:
400
- return jsonify({"message": "Invalid token format"}), 401
401
-
402
- try:
403
- data = jwt.decode(token, app.config['SECRET_KEY'], algorithms=["HS256"])
404
- username = data['username']
405
- except jwt.ExpiredSignatureError:
406
- return jsonify({"message": "Token has expired"}), 401
407
- except jwt.InvalidTokenError:
408
- return jsonify({"message": "Invalid token"}), 401
409
-
410
- try:
411
- conn = get_db_connection()
412
- cur = conn.cursor()
413
- cur.execute("SELECT token FROM BlacklistedTokens WHERE token = ?", (token,))
414
- if not cur.fetchone():
415
- cur.execute("INSERT INTO BlacklistedTokens (token) VALUES (?)", (token,))
416
- cur.execute("DELETE FROM RefreshTokens WHERE username = ?", (username,))
417
- conn.commit()
418
- conn.close()
419
- except Exception as e:
420
- app.logger.exception("DB write error on logout: %s", e)
421
- return jsonify({"message": "Database is unavailable"}), 503
422
-
423
- resp = make_response(jsonify({"message": "Logged out successfully!"}))
424
- resp.delete_cookie('access_token', path='/')
425
- resp.delete_cookie('refresh_token', path='/')
426
- return resp
427
-
428
- # @app.post("/upload-pdf")
429
- # def upload_pdf():
430
- # file = request.files.get("pdf")
431
- # if not file:
432
- # return jsonify({"error": "No file uploaded"}), 400
433
-
434
- # upload_folder = os.path.join(BASEDIR, "pdfs")
435
- # os.makedirs(upload_folder, exist_ok=True)
436
-
437
- # save_path = os.path.join(upload_folder, file.filename)
438
- # file.save(save_path)
439
-
440
- # # You can optionally trigger RAG indexing here
441
- # print(f"✅ PDF saved successfully at: {save_path}")
442
-
443
- # return jsonify({"message": "PDF uploaded successfully", "path": save_path}), 200
444
-
445
-
446
- @app.post("/upload-pdf")
447
- def upload_pdf():
448
- file = request.files.get("pdf")
449
- if not file or file.filename.strip() == "":
450
- return jsonify({"error": "No file uploaded"}), 400
451
-
452
- # Save to your backend's pdfs folder (BASEDIR/pdfs)
453
- upload_folder = os.path.join(BASEDIR, "pdfs")
454
- os.makedirs(upload_folder, exist_ok=True)
455
-
456
- filename = secure_filename(file.filename)
457
- save_path = os.path.join(upload_folder, filename)
458
- file.save(save_path)
459
- print(f"✅ PDF saved successfully at: {save_path}")
460
-
461
- # 🔔 Trigger RAG ingestion for THIS file (auto-ingest)
462
- RAG_INGEST_URL = os.getenv("RAG_INGEST_URL", "http://localhost:7000/rag/ingest")
463
- rag_result = {"status": "skipped"}
464
-
465
- try:
466
- payload = {
467
- "paths": [save_path], # ingest this single PDF
468
- # optional tags (use if you plan to filter in RAG later)
469
- "subject": "English",
470
- "grade": "5"
471
- }
472
- resp = requests.post(RAG_INGEST_URL, json=payload, timeout=30)
473
- resp.raise_for_status()
474
- rag_result = resp.json()
475
- print("✅ RAG ingest response:", rag_result)
476
- except Exception as e:
477
- # Do not fail the upload flow if ingest fails — just warn
478
- print("⚠️ RAG ingest failed:", e)
479
- rag_result = {"status": "warning", "message": f"RAG ingest failed: {str(e)}"}
480
-
481
- # Frontend already sets localStorage.hasPDF = 'true'; this response is for debugging/visibility
482
- return jsonify({
483
- "message": "PDF uploaded successfully",
484
- "path": save_path,
485
- "rag": rag_result
486
- }), 200
487
 
 
 
488
 
489
- @app.get("/check-auth")
490
- @token_required
491
- def check_auth(username):
492
- return jsonify({"message": "Authenticated", "username": username}), 200
493
 
494
- # ------------------------------------------------------------------------------
495
- # Register Blueprint: grammar (and later media) lives in testmovie.py
496
- # ------------------------------------------------------------------------------
497
- from chat import movie_bp # ensure testmovie.py defines movie_bp = Blueprint(...)
498
- from generateQuestion import questions_bp
499
- from reading import reading_bp
500
- from writting import writting_bp # match the exact file name on Linux
501
- from vocabularyBuilder import vocab_bp
502
- from findingword import finding_bp
503
- from listen import listen_bp
504
- from ragg.app import rag_bp
505
- from pron import pron_bp
506
- from pronvideo import pronvideo_bp
507
- from pronragg import pronragg_bp
508
- from pronragupgrade import pronragupgrade_bp
509
- from ragg.ingest_trigger import ingest_trigger_bp
510
- app.register_blueprint(movie_bp, url_prefix="/media")
511
- app.register_blueprint(questions_bp, url_prefix="/media")
512
- app.register_blueprint(reading_bp, url_prefix="/media")
513
- app.register_blueprint(writting_bp, url_prefix="/media")
514
- app.register_blueprint(vocab_bp, url_prefix="/media")
515
- app.register_blueprint(finding_bp, url_prefix="/media")
516
- app.register_blueprint(listen_bp, url_prefix="/media")
517
- app.register_blueprint(rag_bp, url_prefix="/rag")
518
- app.register_blueprint(ingest_trigger_bp, url_prefix="/rag")
519
- app.register_blueprint(pron_bp, url_prefix="/pron")
520
- app.register_blueprint(pronvideo_bp, url_prefix="/pronvideo")
521
- app.register_blueprint(pronragg_bp, url_prefix="/pronragg")
522
- app.register_blueprint(pronragupgrade_bp, url_prefix="/pronragupgrade")
523
- # app.register_blueprint(questions_bp, url_prefix="/media") # <-- add this
524
- # ------------------------------------------------------------------------------
525
- # Local run (Gunicorn will import `verification:app` on Spaces)
526
- # ------------------------------------------------------------------------------
527
  if __name__ == '__main__':
 
 
 
 
 
528
  port = int(os.getenv("PORT", "5000"))
529
- app.run(host="0.0.0.0", port=port, debug=True)
530
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ MJ Learn Backend - Main Flask Application
3
+
4
+ A clean, professional Flask application with modular authentication.
5
+
6
+ Main Features:
7
+ - JWT-based authentication system
8
+ - Role-based access control (admin/user)
9
+ - Secure token management with blacklisting
10
+ - CORS configuration for cross-origin requests
11
+ - Modular blueprint architecture
12
+ - Environment-based configuration
13
+ """
14
+
15
  import os
16
  from dotenv import load_dotenv
 
 
 
 
 
17
  import logging
18
+ from flask import Flask, request
 
 
 
 
 
 
19
  from flask_cors import CORS
20
 
21
+ # Load environment variables first
22
+ BASEDIR = os.path.abspath(os.path.dirname(__file__))
23
+ load_dotenv(os.path.join(BASEDIR, ".env"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
+ # --- Build local ChromaDB at startup (expects build_chroma_db.py in same folder) ---
26
+ _CHROMA_SCRIPT_PATH = os.path.join(BASEDIR, "build_chroma_db.py")
 
 
 
27
 
28
+ if os.path.exists(_CHROMA_SCRIPT_PATH):
29
+ try:
30
+ import importlib.util
31
+ import traceback
32
+
33
+ spec = importlib.util.spec_from_file_location("build_chroma_db_local", _CHROMA_SCRIPT_PATH)
34
+ build_chroma_mod = importlib.util.module_from_spec(spec)
35
+ spec.loader.exec_module(build_chroma_mod)
36
+
37
+ if hasattr(build_chroma_mod, "build_chroma"):
38
+ # Run the builder to create Chroma DB in the local assets folder
39
+ build_chroma_mod.build_chroma()
40
+ print("✅ build_chroma_db.build_chroma() executed successfully.")
41
+ else:
42
+ print("!! build_chroma_db.py found but no `build_chroma()` function present.")
43
+ except Exception as e:
44
+ print(f"!! Failed to run build_chroma_db.py: {e}")
45
+ traceback.print_exc()
46
+ else:
47
+ print("!! build_chroma_db.py not found in the application folder — skipping Chroma build.")
48
+ # --- End ChromaDB build block ---
49
 
50
+ # Import authentication module
51
+ from auth import auth_bp
52
+ from auth.database import ensure_database_initialized
 
 
 
 
 
 
 
53
 
 
54
 
55
+ def create_app():
56
+ """Application factory pattern for Flask app creation"""
57
+ app = Flask(__name__)
58
 
59
+ # Security configuration
60
+ app.config['SECRET_KEY'] = os.getenv('SECRET_KEY')
61
+ if not app.config['SECRET_KEY']:
62
+ raise RuntimeError("SECRET_KEY must be set in environment variables!")
63
+
64
+ # Environment configuration
65
+ IS_PROD = os.getenv("ENV", "dev").lower() == "prod"
66
+
67
+ # CORS configuration
68
+ _default_origins = "http://localhost:4200,http://127.0.0.1:4200"
69
+ _origins = os.getenv("ALLOWED_ORIGINS", _default_origins)
70
+ ALLOWED_ORIGINS = [o.strip() for o in _origins.split(",") if o.strip()]
71
+
72
+ CORS(
73
+ app,
74
+ resources={r"/*": {"origins": ALLOWED_ORIGINS}},
75
+ supports_credentials=True,
76
+ allow_headers=["Content-Type", "Authorization", "X-Requested-With", "X-User"],
77
+ expose_headers=["Set-Cookie"],
78
+ methods=["GET", "POST", "OPTIONS"]
79
+ )
80
+
81
+ # API configuration for blueprints
82
+ app.config["COHERE_API_KEY"] = os.getenv("COHERE_API_KEY", "")
83
+
84
+ # CORS handlers
85
+ @app.after_request
86
+ def add_cors_headers(resp):
87
  origin = request.headers.get("Origin")
88
  if origin and origin in ALLOWED_ORIGINS:
89
  resp.headers["Access-Control-Allow-Origin"] = origin
90
+ resp.headers["Vary"] = "Origin"
91
  resp.headers["Access-Control-Allow-Credentials"] = "true"
92
+ resp.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization, X-Requested-With, X-User"
93
+ resp.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
 
 
 
94
  return resp
95
+
96
+ @app.before_request
97
+ def handle_options_early():
98
+ if request.method == "OPTIONS":
99
+ resp = app.make_default_options_response()
100
+ origin = request.headers.get("Origin")
101
+ if origin and origin in ALLOWED_ORIGINS:
102
+ resp.headers["Access-Control-Allow-Origin"] = origin
103
+ resp.headers["Access-Control-Allow-Credentials"] = "true"
104
+ # Mirror browser's requested headers/methods
105
+ req_headers = request.headers.get("Access-Control-Request-Headers", "Content-Type, Authorization, X-Requested-With, X-User")
106
+ req_method = request.headers.get("Access-Control-Request-Method", "POST")
107
+ resp.headers["Access-Control-Allow-Headers"] = req_headers
108
+ resp.headers["Access-Control-Allow-Methods"] = req_method
109
+ return resp
110
+
111
+ # Initialize database before first request (Flask 3.x compatible)
112
+ @app.before_request
113
+ def maybe_initialize_database():
114
+ if not hasattr(app, '_db_initialized'):
115
+ try:
116
+ ensure_database_initialized()
117
+ app._db_initialized = True
118
+ except Exception as e:
119
+ app.logger.exception("Database initialization failed: %s", e)
120
+
121
+ # Health check endpoint
122
+ @app.route("/")
123
+ def health():
124
+ return {"status": "ok", "service": "MJ Learn Backend"}, 200
125
+
126
+ # Register authentication blueprint
127
+ app.register_blueprint(auth_bp, url_prefix="/auth")
128
+
129
+ # Register other feature blueprints
130
+ register_feature_blueprints(app)
131
+
132
+ return app
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
 
 
 
 
 
 
 
 
 
 
 
134
 
135
+ def register_feature_blueprints(app):
136
+ """Register feature blueprints with error handling"""
137
+ blueprints = [
138
+ ("ragg.app", "rag_bp", "/rag"),
139
+ ("pronunciation", "pronunciation_bp", "/pronunciation"),
140
+ ("ragg.ingest_trigger", "ingest_trigger_bp", "/rag"),
141
+ ]
142
+
143
+ for module_name, blueprint_name, url_prefix in blueprints:
144
  try:
145
+ module = __import__(module_name, fromlist=[blueprint_name])
146
+ blueprint = getattr(module, blueprint_name)
147
+ app.register_blueprint(blueprint, url_prefix=url_prefix)
148
+ print(f"? Registered {blueprint_name}")
149
+ except ImportError as e:
150
+ print(f"?? Could not import {blueprint_name}: {e}")
151
+ except AttributeError as e:
152
+ print(f"?? Blueprint {blueprint_name} not found in {module_name}: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
153
 
 
 
 
 
 
 
 
154
 
155
+ # Create Flask app instance
156
+ app = create_app()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
 
158
+ # Configure logging
159
+ logging.basicConfig(level=logging.INFO)
160
 
 
 
 
 
161
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  if __name__ == '__main__':
163
+ print("?? Starting MJ Learn Backend...")
164
+ print(f"? SECRET_KEY loaded: {bool(app.config.get('SECRET_KEY'))}")
165
+ print(f"? Environment: {os.getenv('ENV', 'development')}")
166
+ print("=" * 60)
167
+
168
  port = int(os.getenv("PORT", "5000"))
169
+ print(f"?? Server starting on http://localhost:{port}")
170
+ print("?? Available endpoints:")
171
+ print(" GET / - Health check")
172
+ print(" ?? Authentication:")
173
+ print(" POST /auth/signup - User registration")
174
+ print(" POST /auth/login - User login")
175
+ print(" POST /auth/refresh - Token refresh")
176
+ print(" POST /auth/logout - User logout")
177
+ print(" GET /auth/dashboard - Protected endpoint")
178
+ print(" GET /auth/check-auth - Auth status check")
179
+ print(" GET /auth/db/diag - Database diagnostics (ADMIN)")
180
+ print(" ?? Admin Management:")
181
+ print(" GET /auth/admin/users - List all users (ADMIN)")
182
+ print(" POST /auth/admin/promote-user - Promote user to admin (ADMIN)")
183
+ print(" POST /auth/admin/create-first-admin - Create first admin")
184
+ print("=" * 60)
185
+
186
+ try:
187
+ app.run(host="0.0.0.0", port=port, debug=True)
188
+ except Exception as e:
189
+ print(f"? Failed to start server: {e}")
190
+ raise