Spaces:
Runtime error
Runtime error
Oviya commited on
Commit ·
59f2028
1
Parent(s): c3ad823
fix
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env +20 -5
- apt.txt +0 -6
- chroma_db/1ceaf3a3-30e6-42c4-b515-99a05466da04/header.bin → assets/chroma_db/09c5ed20-106f-41c5-94dc-89d203beb195/data_level0.bin +2 -2
- {chroma_db/44944ef3-9b61-4c1b-bc5e-6a49750c0c54 → assets/chroma_db/09c5ed20-106f-41c5-94dc-89d203beb195}/header.bin +1 -1
- {chroma_db/1ceaf3a3-30e6-42c4-b515-99a05466da04 → assets/chroma_db/09c5ed20-106f-41c5-94dc-89d203beb195}/length.bin +2 -2
- {chroma_db/1ceaf3a3-30e6-42c4-b515-99a05466da04 → assets/chroma_db/09c5ed20-106f-41c5-94dc-89d203beb195}/link_lists.bin +0 -0
- feedback.mp4 → assets/feedback.mp4 +0 -0
- {pdfs → assets/pdfs}/high/high.pdf +0 -0
- {pdfs → assets/pdfs}/low/low.pdf +0 -0
- {pdfs → assets/pdfs}/mid/mid.pdf +0 -0
- chroma_db/44944ef3-9b61-4c1b-bc5e-6a49750c0c54/data_level0.bin → assets/teacher.png +2 -2
- teacher_feedback_sentences_category.json → assets/teacher_feedback_sentences_category.json +0 -0
- static/references/voice1.wav → assets/teachervoice.wav +0 -0
- auth/__init__.py +25 -0
- auth/database.py +168 -0
- auth/models.py +177 -0
- auth/routes.py +346 -0
- auth/utils.py +156 -0
- build_chroma_db.py +91 -0
- chat.py +0 -246
- chroma_db/44944ef3-9b61-4c1b-bc5e-6a49750c0c54/length.bin +0 -3
- chroma_db/44944ef3-9b61-4c1b-bc5e-6a49750c0c54/link_lists.bin +0 -0
- findingword.py +0 -276
- generateQuestion.py +0 -535
- googlecredentails.json +0 -13
- listen.py +0 -436
- media/audio/explain_1112505a6701429cb241d131a88bf709.wav +0 -3
- media/audio/explain_5c2a7427d1f14a2aa9fa9e59bb1ad603.wav +0 -3
- media/audio/explain_975ae1b5996743f6b76b5016f17056de.wav +0 -3
- media/audio/explain_ca92720c882d4926973973aa4b9f2316.wav +0 -3
- media/audio/explain_cc24a21b0b374e50bc8afbf73a7398c4.wav +0 -3
- media/audio/explain_dd70fb52325d44fc84cde7c1c9215232.wav +0 -3
- media/audio/synth_22ebf1e3b9404b34a41b2fdc2c691adb.wav +0 -3
- media/audio/synth_2757240115da4ba3a9aa1286aee57db9.wav +0 -3
- media/audio/synth_4965badeb7da43ffac0c3a7af781ab0f.wav +0 -3
- media/audio/synth_7bccf943f0b24880b77aa038b38f8bf1.wav +0 -3
- chroma_db/1ceaf3a3-30e6-42c4-b515-99a05466da04/data_level0.bin → media/audio/synth_d38b265fcd6d4f9cbb825007c3f52ac5.wav +2 -2
- media/audio/synth_ee1e3e992d6641b9a06d214e0e67ea92.wav +0 -3
- pdfs/testing.pdf +0 -3
- pron.py +0 -729
- pronragg.py +0 -263
- pronragupgrade.py → pronunciation.py +180 -371
- pronvideo.py +0 -359
- ragg/app.py +295 -491
- ragg/ingest_all.py +2 -2
- ragg/tts.py +1 -1
- reading.py +0 -158
- start.sh +0 -29
- trim/voice1.wav +0 -3
- 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=sk-proj-
|
| 9 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 14 |
-
|
|
|
|
|
|
|
| 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:
|
| 3 |
-
size
|
|
|
|
| 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:
|
| 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:
|
| 3 |
-
size
|
|
|
|
| 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:
|
| 3 |
-
size
|
|
|
|
| 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
|
| 14 |
-
from flask_cors import CORS
|
| 15 |
from transformers import Wav2Vec2Processor, Wav2Vec2ForCTC
|
| 16 |
|
| 17 |
-
|
| 18 |
|
| 19 |
# ==================================================
|
| 20 |
# 1. SETUP & CONFIG
|
| 21 |
# ==================================================
|
| 22 |
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 23 |
-
|
| 24 |
-
|
| 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
|
| 215 |
-
|
| 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
|
| 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.
|
| 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 |
-
|
| 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(
|
| 729 |
print(f"No clips found for category: {category}")
|
| 730 |
return ""
|
| 731 |
|
| 732 |
-
metadatas = gen_results[
|
| 733 |
-
documents = gen_results.get(
|
| 734 |
-
|
| 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
|
|
|
|
|
|
|
| 741 |
generic_clips = []
|
| 742 |
-
specific_clips = []
|
| 743 |
for it in items:
|
| 744 |
meta = it["meta"]
|
|
|
|
| 745 |
clip_phoneme = meta.get("phoneme")
|
| 746 |
if clip_phoneme:
|
| 747 |
-
specific_clips.append(
|
| 748 |
else:
|
| 749 |
-
# attach text for success/vowel/consonant classification later
|
| 750 |
meta_copy = dict(meta)
|
| 751 |
-
meta_copy["_text"] =
|
| 752 |
generic_clips.append(meta_copy)
|
| 753 |
|
| 754 |
print(f"Found {len(generic_clips)} generic clips, {len(specific_clips)} specific clips")
|
| 755 |
|
| 756 |
-
|
| 757 |
-
|
| 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 |
-
|
| 763 |
-
|
| 764 |
-
|
|
|
|
|
|
|
|
|
|
| 765 |
|
| 766 |
-
|
|
|
|
|
|
|
| 767 |
|
| 768 |
-
#
|
| 769 |
-
|
|
|
|
|
|
|
|
|
|
| 770 |
|
| 771 |
-
#
|
| 772 |
-
if
|
| 773 |
-
|
| 774 |
-
|
| 775 |
-
|
| 776 |
-
|
| 777 |
-
|
| 778 |
-
|
| 779 |
-
|
| 780 |
-
|
| 781 |
-
|
| 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 |
-
|
| 833 |
-
|
| 834 |
-
|
| 835 |
-
|
| 836 |
-
|
| 837 |
-
|
| 838 |
-
|
| 839 |
-
|
| 840 |
-
|
| 841 |
-
|
| 842 |
-
|
| 843 |
-
|
| 844 |
-
|
| 845 |
-
|
| 846 |
-
|
| 847 |
-
|
| 848 |
-
|
| 849 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 850 |
else:
|
| 851 |
-
|
| 852 |
-
if
|
| 853 |
-
|
| 854 |
-
|
| 855 |
-
|
| 856 |
-
|
| 857 |
-
|
| 858 |
-
|
| 859 |
-
|
| 860 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 867 |
unique_metadatas = []
|
| 868 |
seen = set()
|
| 869 |
for meta in selected_metadatas:
|
| 870 |
-
key =
|
| 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 |
-
|
| 910 |
-
|
| 911 |
-
|
| 912 |
-
|
| 913 |
-
|
| 914 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 929 |
-
|
| 930 |
-
|
| 931 |
-
|
| 932 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 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
|
| 22 |
|
| 23 |
-
#
|
| 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]] = "
|
| 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
|
| 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 |
-
#
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 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 |
-
#
|
| 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 |
-
|
| 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
|
| 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 |
-
#
|
| 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 |
-
|
| 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 |
-
|
| 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("
|
| 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 |
-
|
| 331 |
-
|
| 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=
|
| 460 |
-
source_ids=data.get("source_ids") or []
|
| 461 |
)
|
| 462 |
|
| 463 |
-
# --- 1) Run LLM / RAG explanation ---
|
| 464 |
result_raw = llm_explain(body)
|
| 465 |
|
| 466 |
-
#
|
| 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 |
-
|
| 492 |
-
|
| 493 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 494 |
try:
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 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("
|
| 509 |
-
|
| 510 |
-
|
| 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 |
-
|
| 530 |
-
|
| 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=
|
| 561 |
-
source_ids=data.get("source_ids") or []
|
| 562 |
)
|
| 563 |
-
|
| 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()
|
| 592 |
except Exception:
|
| 593 |
try:
|
| 594 |
-
return vs._client.get_collection(vs._collection.name).count()
|
| 595 |
except Exception:
|
| 596 |
return None
|
| 597 |
return None
|
| 598 |
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 603 |
|
| 604 |
info = {
|
| 605 |
-
"env_seen": {
|
| 606 |
-
|
| 607 |
-
|
| 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 |
-
|
| 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 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
|
|
|
|
|
|
| 676 |
return jsonify({"results": out})
|
| 677 |
|
| 678 |
|
| 679 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 697 |
-
print(f"Processed OpenAI response: {response_text}")
|
| 698 |
-
return response_text
|
| 699 |
except Exception as e:
|
| 700 |
-
|
| 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"
|
| 748 |
-
reference_files = data.get("reference_files")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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://
|
| 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
|
| 774 |
-
|
| 775 |
-
|
| 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 |
-
|
| 833 |
-
|
| 834 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 840 |
|
| 841 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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]] = "
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 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 |
-
|
| 45 |
-
|
| 46 |
-
hdr = req.headers.get("X-User")
|
| 47 |
-
if hdr:
|
| 48 |
-
return hdr
|
| 49 |
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 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 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 91 |
-
|
| 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 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
#
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
)
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 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 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 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 |
-
|
| 310 |
-
|
| 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 |
-
|
| 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
|