hamouchi zineb commited on
Commit
009f914
·
0 Parent(s):

Deploy clean version to HF Space (no binary files)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.example +0 -0
  2. .gitignore +3 -0
  3. Dockerfile +11 -0
  4. README.md +5 -0
  5. backend/__init__.py +0 -0
  6. backend/app/__init__.py +0 -0
  7. backend/app/__pycache__/__init__.cpython-314.pyc +0 -0
  8. backend/app/__pycache__/main.cpython-314.pyc +0 -0
  9. backend/app/api/__init__.py +0 -0
  10. backend/app/api/__pycache__/__init__.cpython-314.pyc +0 -0
  11. backend/app/api/__pycache__/routes_auth.cpython-314.pyc +0 -0
  12. backend/app/api/__pycache__/routes_cefr.cpython-314.pyc +0 -0
  13. backend/app/api/__pycache__/routes_chat.cpython-314.pyc +0 -0
  14. backend/app/api/__pycache__/routes_chat_stream.cpython-314.pyc +0 -0
  15. backend/app/api/__pycache__/routes_stats.cpython-314.pyc +0 -0
  16. backend/app/api/routes_auth.py +40 -0
  17. backend/app/api/routes_cefr.py +13 -0
  18. backend/app/api/routes_chat.py +31 -0
  19. backend/app/api/routes_chat_stream.py +76 -0
  20. backend/app/api/routes_stats.py +74 -0
  21. backend/app/core/__pycache__/deps.cpython-314.pyc +0 -0
  22. backend/app/core/__pycache__/security.cpython-314.pyc +0 -0
  23. backend/app/core/deps.py +33 -0
  24. backend/app/core/security.py +24 -0
  25. backend/app/db/__pycache__/database.cpython-314.pyc +0 -0
  26. backend/app/db/__pycache__/models.cpython-314.pyc +0 -0
  27. backend/app/db/__pycache__/repo.cpython-314.pyc +0 -0
  28. backend/app/db/__pycache__/session.cpython-314.pyc +0 -0
  29. backend/app/db/database.py +26 -0
  30. backend/app/db/models.py +41 -0
  31. backend/app/db/repo.py +38 -0
  32. backend/app/db/session.py +19 -0
  33. backend/app/main.py +16 -0
  34. backend/app/schemas/__pycache__/auth.cpython-314.pyc +0 -0
  35. backend/app/schemas/auth.py +16 -0
  36. backend/app/services/__init__.py +16 -0
  37. backend/app/services/__pycache__/__init__.cpython-314.pyc +0 -0
  38. backend/app/services/__pycache__/cefr_predictor.cpython-314.pyc +0 -0
  39. backend/app/services/__pycache__/feedback.cpython-314.pyc +0 -0
  40. backend/app/services/__pycache__/llm_tutor.cpython-314.pyc +0 -0
  41. backend/app/services/__pycache__/tutor_engine.cpython-314.pyc +0 -0
  42. backend/app/services/cefr_predictor.py +18 -0
  43. backend/app/services/feedback.py +36 -0
  44. backend/app/services/llm_tutor.py +96 -0
  45. backend/app/services/tutor_engine.py +188 -0
  46. backend/render.yaml +1 -0
  47. backend/requirements.txt +11 -0
  48. backend/run.py +4 -0
  49. frontend/app.py +203 -0
  50. frontend/pages/2_📊_Dashboard.py +182 -0
.env.example ADDED
File without changes
.gitignore ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ ml/models/*.pkl
2
+ ml\models\*.pkl
3
+ *.pkl
Dockerfile ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+ ENV PYTHONUNBUFFERED=1
5
+ ENV PYTHONPATH=/app
6
+
7
+ COPY . /app
8
+ RUN pip install --no-cache-dir -r requirements.txt
9
+
10
+ EXPOSE 7860
11
+ CMD ["bash", "start.sh"]
README.md ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ ---
2
+ title: LearnLanguage
3
+ sdk: docker
4
+ app_port: 7860
5
+ ---
backend/__init__.py ADDED
File without changes
backend/app/__init__.py ADDED
File without changes
backend/app/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (147 Bytes). View file
 
backend/app/__pycache__/main.cpython-314.pyc ADDED
Binary file (918 Bytes). View file
 
backend/app/api/__init__.py ADDED
File without changes
backend/app/api/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (151 Bytes). View file
 
backend/app/api/__pycache__/routes_auth.cpython-314.pyc ADDED
Binary file (3.84 kB). View file
 
backend/app/api/__pycache__/routes_cefr.cpython-314.pyc ADDED
Binary file (1.32 kB). View file
 
backend/app/api/__pycache__/routes_chat.cpython-314.pyc ADDED
Binary file (1.71 kB). View file
 
backend/app/api/__pycache__/routes_chat_stream.cpython-314.pyc ADDED
Binary file (4.21 kB). View file
 
backend/app/api/__pycache__/routes_stats.cpython-314.pyc ADDED
Binary file (4.18 kB). View file
 
backend/app/api/routes_auth.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException
2
+ from sqlalchemy.orm import Session
3
+ from ..schemas.auth import RegisterIn, LoginIn, AuthOut
4
+ from ..db.models import User
5
+ from ..core.security import hash_password, verify_password, create_access_token
6
+ from ..core.deps import get_db, get_current_user
7
+
8
+ router = APIRouter(prefix="/auth", tags=["auth"])
9
+
10
+ @router.post("/register", response_model=AuthOut)
11
+ def register(payload: RegisterIn, db: Session = Depends(get_db)):
12
+ if db.query(User).filter(User.email == payload.email).first():
13
+ raise HTTPException(status_code=400, detail="Email already used")
14
+ if db.query(User).filter(User.username == payload.username).first():
15
+ raise HTTPException(status_code=400, detail="Username already used")
16
+
17
+ user = User(
18
+ email=payload.email,
19
+ username=payload.username,
20
+ password_hash=hash_password(payload.password),
21
+ )
22
+ db.add(user)
23
+ db.commit()
24
+ db.refresh(user)
25
+
26
+ token = create_access_token(user.id, user.email)
27
+ return AuthOut(token=token, user_id=user.id, email=user.email, username=user.username)
28
+
29
+ @router.post("/login", response_model=AuthOut)
30
+ def login(payload: LoginIn, db: Session = Depends(get_db)):
31
+ user = db.query(User).filter(User.email == payload.email).first()
32
+ if not user or not verify_password(payload.password, user.password_hash):
33
+ raise HTTPException(status_code=401, detail="Invalid credentials")
34
+
35
+ token = create_access_token(user.id, user.email)
36
+ return AuthOut(token=token, user_id=user.id, email=user.email, username=user.username)
37
+
38
+ @router.get("/me")
39
+ def me(user: User = Depends(get_current_user)):
40
+ return {"user_id": user.id, "email": user.email, "username": user.username}
backend/app/api/routes_cefr.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter
2
+ from pydantic import BaseModel
3
+ from ..services.cefr_predictor import CEFRPredictor
4
+
5
+ router = APIRouter()
6
+ predictor = CEFRPredictor()
7
+
8
+ class PredictRequest(BaseModel):
9
+ text: str
10
+
11
+ @router.post("/predict-level")
12
+ def predict_level(payload: PredictRequest):
13
+ return {"level": predictor.predict(payload.text)}
backend/app/api/routes_chat.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends
2
+ from sqlalchemy.orm import Session
3
+ from pydantic import BaseModel
4
+
5
+ from ..core.deps import get_db, get_current_user
6
+ from ..db.models import User
7
+ from ..services import tutor_engine
8
+
9
+ router = APIRouter()
10
+
11
+ # -------- REQUEST MODEL --------
12
+ class ChatRequest(BaseModel):
13
+ message: str
14
+ mode: str = "conversation"
15
+
16
+
17
+ # -------- CHAT ENDPOINT --------
18
+ @router.post("/chat")
19
+ def chat_endpoint(
20
+ payload: ChatRequest,
21
+ user: User = Depends(get_current_user),
22
+ db: Session = Depends(get_db)
23
+ ):
24
+ result = tutor_engine.chat(
25
+ user_text=payload.message,
26
+ user=user,
27
+ history=[],
28
+ mode=payload.mode,
29
+ db=db
30
+ )
31
+ return result
backend/app/api/routes_chat_stream.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import requests
3
+ from fastapi import APIRouter
4
+ from fastapi.responses import StreamingResponse
5
+ from pydantic import BaseModel
6
+ from typing import List, Dict, Any
7
+
8
+ from ..services.tutor_engine import build_prompt_context, safe_json_parse, make_exercise_from_top_errors
9
+ from ..db.database import SessionLocal
10
+ from ..db.repo import save_message, save_corrections, get_last_messages, top_errors
11
+
12
+ router = APIRouter()
13
+
14
+ OLLAMA_URL = "http://127.0.0.1:11434/api/chat"
15
+ OLLAMA_MODEL = "llama3.2:3b"
16
+
17
+ class StreamChatRequest(BaseModel):
18
+ session_id: str
19
+ message: str
20
+
21
+ @router.post("/chat_stream")
22
+ def chat_stream(payload: StreamChatRequest):
23
+ db = SessionLocal()
24
+
25
+ # load memory (last messages) from DB
26
+ history = get_last_messages(db, payload.session_id, limit=12)
27
+ # build prompt context (level/topic/profile + JSON-only instructions)
28
+ ctx = build_prompt_context(payload.message, history, db, payload.session_id)
29
+
30
+ # save user message first
31
+ save_message(db, payload.session_id, "You", payload.message, level=ctx["level"], topic=ctx["topic"])
32
+
33
+ messages = ctx["ollama_messages"]
34
+
35
+ body = {
36
+ "model": OLLAMA_MODEL,
37
+ "messages": messages,
38
+ "stream": True,
39
+ "options": {"temperature": 0.4, "num_predict": 260}
40
+ }
41
+
42
+ def gen():
43
+ full = ""
44
+ try:
45
+ with requests.post(OLLAMA_URL, json=body, stream=True, timeout=180) as r:
46
+ r.raise_for_status()
47
+ for line in r.iter_lines(decode_unicode=True):
48
+ if not line:
49
+ continue
50
+ data = json.loads(line)
51
+ if "message" in data and "content" in data["message"]:
52
+ chunk = data["message"]["content"]
53
+ full += chunk
54
+ # send chunk to streamlit
55
+ yield chunk
56
+
57
+ # after stream ends: parse JSON and persist bot output + corrections
58
+ parsed = safe_json_parse(full)
59
+ bot_reply = parsed.get("reply","")
60
+ corrections = parsed.get("corrections", [])
61
+
62
+ # generate extra exercise from top errors
63
+ errs = top_errors(db, payload.session_id, limit=3)
64
+ parsed["exercise_from_progress"] = make_exercise_from_top_errors(ctx["level"], errs)
65
+
66
+ # persist bot message + corrections
67
+ save_message(db, payload.session_id, "Bot", bot_reply, level=ctx["level"], topic=ctx["topic"])
68
+ save_corrections(db, payload.session_id, corrections)
69
+
70
+ # send final JSON marker
71
+ yield "\n\n[[FINAL_JSON]]\n" + json.dumps(parsed, ensure_ascii=False)
72
+
73
+ finally:
74
+ db.close()
75
+
76
+ return StreamingResponse(gen(), media_type="text/plain")
backend/app/api/routes_stats.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends
2
+ from sqlalchemy import func
3
+ from sqlalchemy.orm import Session
4
+
5
+ from ..core.deps import get_db, get_current_user # ✅ هنا الصحيح
6
+ from ..db.models import Message, Correction, User # حسب الموديلات ديالك
7
+
8
+ router = APIRouter(tags=["stats"])
9
+
10
+ LEVEL_ORDER = ["A1","A2","B1","B2","C1","C2"]
11
+
12
+
13
+ @router.get("/stats/me")
14
+ def stats_me(user: User = Depends(get_current_user), db: Session = Depends(get_db)):
15
+ # ✅ Top 5 errors (ديال هاد user فقط)
16
+ errors = (
17
+ db.query(
18
+ Correction.error,
19
+ Correction.suggestion,
20
+ func.count(Correction.id).label("count")
21
+ )
22
+ .filter(Correction.user_id == user.id) # ✅ بدل session_id
23
+ .group_by(Correction.error, Correction.suggestion)
24
+ .order_by(func.count(Correction.id).desc())
25
+ .limit(5)
26
+ .all()
27
+ )
28
+
29
+ top_errors = [
30
+ {"error": e.error, "suggestion": e.suggestion, "count": e.count}
31
+ for e in errors
32
+ ]
33
+
34
+ # ✅ Messages per day (ديال user فقط)
35
+ messages_per_day = (
36
+ db.query(
37
+ func.date(Message.created_at).label("day"),
38
+ func.count(Message.id).label("count")
39
+ )
40
+ .filter(Message.user_id == user.id) # ✅ بدل session_id
41
+ .group_by("day")
42
+ .order_by("day")
43
+ .all()
44
+ )
45
+ msgs = [{"day": str(m.day), "count": m.count} for m in messages_per_day]
46
+
47
+ # ✅ CEFR progression (ديال user فقط)
48
+ levels = (
49
+ db.query(Message.created_at, Message.level)
50
+ .filter(Message.user_id == user.id) # ✅ بدل session_id
51
+ .filter(Message.level.isnot(None))
52
+ .filter(Message.level != "")
53
+ .order_by(Message.created_at)
54
+ .all()
55
+ )
56
+
57
+ level_map = {lvl: i for i, lvl in enumerate(LEVEL_ORDER)}
58
+ progression = [
59
+ {"time": str(l.created_at), "level": l.level, "value": level_map.get(l.level, 0)}
60
+ for l in levels
61
+ ]
62
+
63
+ total_messages = (
64
+ db.query(func.count(Message.id))
65
+ .filter(Message.user_id == user.id) # ✅ بدل session_id
66
+ .scalar()
67
+ )
68
+
69
+ return {
70
+ "top_errors": top_errors,
71
+ "messages_per_day": msgs,
72
+ "progression": progression,
73
+ "total_messages": total_messages
74
+ }
backend/app/core/__pycache__/deps.cpython-314.pyc ADDED
Binary file (1.89 kB). View file
 
backend/app/core/__pycache__/security.cpython-314.pyc ADDED
Binary file (2.4 kB). View file
 
backend/app/core/deps.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import Depends, HTTPException
2
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
3
+ from sqlalchemy.orm import Session
4
+ from ..db.session import SessionLocal
5
+ from ..db.models import User
6
+ from ..core.security import decode_token
7
+
8
+ bearer = HTTPBearer(auto_error=False)
9
+
10
+ def get_db():
11
+ db = SessionLocal()
12
+ try:
13
+ yield db
14
+ finally:
15
+ db.close()
16
+
17
+ def get_current_user(
18
+ creds: HTTPAuthorizationCredentials = Depends(bearer),
19
+ db: Session = Depends(get_db),
20
+ ):
21
+ if not creds:
22
+ raise HTTPException(status_code=401, detail="Missing token")
23
+ token = creds.credentials
24
+ try:
25
+ payload = decode_token(token)
26
+ user_id = int(payload["sub"])
27
+ except Exception:
28
+ raise HTTPException(status_code=401, detail="Invalid token")
29
+
30
+ user = db.query(User).filter(User.id == user_id).first()
31
+ if not user:
32
+ raise HTTPException(status_code=401, detail="User not found")
33
+ return user
backend/app/core/security.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from datetime import datetime, timedelta
3
+ from passlib.context import CryptContext
4
+ import jwt
5
+
6
+ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
7
+
8
+ JWT_SECRET = os.getenv("JWT_SECRET", "CHANGE_ME_SUPER_SECRET")
9
+ JWT_ALG = "HS256"
10
+ JWT_EXPIRE_HOURS = int(os.getenv("JWT_EXPIRE_HOURS", "72"))
11
+
12
+ def hash_password(password: str) -> str:
13
+ return pwd_context.hash(password)
14
+
15
+ def verify_password(password: str, password_hash: str) -> bool:
16
+ return pwd_context.verify(password, password_hash)
17
+
18
+ def create_access_token(user_id: int, email: str):
19
+ exp = datetime.utcnow() + timedelta(hours=JWT_EXPIRE_HOURS)
20
+ payload = {"sub": str(user_id), "email": email, "exp": exp}
21
+ return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALG)
22
+
23
+ def decode_token(token: str):
24
+ return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALG])
backend/app/db/__pycache__/database.cpython-314.pyc ADDED
Binary file (821 Bytes). View file
 
backend/app/db/__pycache__/models.cpython-314.pyc ADDED
Binary file (2.57 kB). View file
 
backend/app/db/__pycache__/repo.cpython-314.pyc ADDED
Binary file (3.96 kB). View file
 
backend/app/db/__pycache__/session.cpython-314.pyc ADDED
Binary file (1.15 kB). View file
 
backend/app/db/database.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import create_engine
2
+ from sqlalchemy.orm import sessionmaker, declarative_base
3
+
4
+ DATABASE_URL = "sqlite:///./learnlanguage.db"
5
+
6
+ engine = create_engine(
7
+ DATABASE_URL,
8
+ connect_args={"check_same_thread": False}
9
+ )
10
+
11
+ SessionLocal = sessionmaker(
12
+ autocommit=False,
13
+ autoflush=False,
14
+ bind=engine
15
+ )
16
+
17
+ Base = declarative_base()
18
+
19
+
20
+ # ✅ IMPORTANT: THIS WAS MISSING
21
+ def get_db():
22
+ db = SessionLocal()
23
+ try:
24
+ yield db
25
+ finally:
26
+ db.close()
backend/app/db/models.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, func
2
+ from sqlalchemy.orm import relationship
3
+ from datetime import datetime
4
+ from .session import Base
5
+
6
+ class User(Base):
7
+ __tablename__ = "users"
8
+ id = Column(Integer, primary_key=True, index=True)
9
+ email = Column(String(255), unique=True, index=True, nullable=False)
10
+ username = Column(String(80), unique=True, index=True, nullable=False)
11
+ password_hash = Column(String(255), nullable=False)
12
+ created_at = Column(DateTime, default=datetime.utcnow)
13
+
14
+ messages = relationship("Message", back_populates="user", cascade="all, delete-orphan")
15
+
16
+
17
+ class Message(Base):
18
+ __tablename__ = "messages"
19
+ id = Column(Integer, primary_key=True, index=True)
20
+ user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
21
+ role = Column(String(16), nullable=False) # "user" | "bot"
22
+ text = Column(Text, nullable=False)
23
+ level = Column(String(8), nullable=True)
24
+ topic = Column(String(64), nullable=True)
25
+ created_at = Column(DateTime, default=datetime.utcnow, index=True)
26
+
27
+ user = relationship("User", back_populates="messages")
28
+
29
+ class Correction(Base):
30
+ __tablename__ = "corrections"
31
+ id = Column(Integer, primary_key=True, index=True)
32
+
33
+ user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) # ✅ مهم
34
+
35
+ error = Column(String, nullable=False)
36
+ suggestion = Column(String, default="")
37
+ explanation = Column(Text, default="")
38
+
39
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
40
+
41
+ user = relationship("User")
backend/app/db/repo.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy.orm import Session
2
+ from .models import Message, Correction
3
+
4
+ def save_message(db: Session, session_id: str, role: str, content: str, level: str = "", topic: str = ""):
5
+ m = Message(session_id=session_id, role=role, content=content, level=level, topic=topic)
6
+ db.add(m)
7
+ db.commit()
8
+
9
+ def save_corrections(db: Session, session_id: str, corrections: list):
10
+ for c in corrections or []:
11
+ db.add(Correction(
12
+ session_id=session_id,
13
+ error=c.get("error",""),
14
+ suggestion=c.get("suggestion",""),
15
+ explanation=c.get("explanation","")
16
+ ))
17
+ db.commit()
18
+
19
+ def get_last_messages(db: Session, session_id: str, limit: int = 12):
20
+ rows = (db.query(Message)
21
+ .filter(Message.session_id == session_id)
22
+ .order_by(Message.id.desc())
23
+ .limit(limit)
24
+ .all())
25
+ rows.reverse()
26
+ return [{"role": r.role, "content": r.content, "level": r.level, "topic": r.topic} for r in rows]
27
+
28
+ def top_errors(db: Session, session_id: str, limit: int = 5):
29
+ # simple frequency count in python (fast enough)
30
+ rows = (db.query(Correction)
31
+ .filter(Correction.session_id == session_id)
32
+ .all())
33
+ freq = {}
34
+ for r in rows:
35
+ key = (r.error.strip().lower(), r.suggestion.strip())
36
+ freq[key] = freq.get(key, 0) + 1
37
+ ranked = sorted(freq.items(), key=lambda x: x[1], reverse=True)[:limit]
38
+ return [{"error": k[0], "suggestion": k[1], "count": v} for k, v in ranked]
backend/app/db/session.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from sqlalchemy import create_engine
3
+ from sqlalchemy.orm import sessionmaker, declarative_base
4
+
5
+ # مهم: نخلي db داخل storage باش ما يطيحش
6
+ DB_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "..", "storage")
7
+ DB_DIR = os.path.abspath(DB_DIR)
8
+ os.makedirs(DB_DIR, exist_ok=True)
9
+
10
+ DB_PATH = os.path.join(DB_DIR, "learnlanguage.db")
11
+ DATABASE_URL = f"sqlite:///{DB_PATH}"
12
+
13
+ engine = create_engine(
14
+ DATABASE_URL,
15
+ connect_args={"check_same_thread": False},
16
+ future=True,
17
+ )
18
+ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine, future=True)
19
+ Base = declarative_base()
backend/app/main.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI
2
+ from .db.session import Base, engine
3
+ from .api.routes_auth import router as auth_router
4
+ from .api.routes_chat import router as chat_router
5
+ from .api.routes_stats import router as stats_router
6
+
7
+ app = FastAPI(title="LearnLanguage API")
8
+
9
+ Base.metadata.create_all(bind=engine)
10
+
11
+ @app.get("/")
12
+ def health():
13
+ return {"status": "ok"}
14
+ app.include_router(auth_router)
15
+ app.include_router(chat_router)
16
+ app.include_router(stats_router)
backend/app/schemas/__pycache__/auth.cpython-314.pyc ADDED
Binary file (1.58 kB). View file
 
backend/app/schemas/auth.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, EmailStr
2
+
3
+ class RegisterIn(BaseModel):
4
+ email: EmailStr
5
+ username: str
6
+ password: str
7
+
8
+ class LoginIn(BaseModel):
9
+ email: EmailStr
10
+ password: str
11
+
12
+ class AuthOut(BaseModel):
13
+ token: str
14
+ user_id: int
15
+ email: EmailStr
16
+ username: str
backend/app/services/__init__.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ import joblib
3
+
4
+ MODEL_PATH = Path("ml/models/cefr_model.pkl")
5
+
6
+ class CEFRPredictor:
7
+ def __init__(self):
8
+ if not MODEL_PATH.exists():
9
+ raise FileNotFoundError(f"Model not found: {MODEL_PATH}")
10
+ self.model = joblib.load(MODEL_PATH)
11
+
12
+ def predict(self, text: str) -> str:
13
+ text = (text or "").strip()
14
+ if not text:
15
+ return "A2" # default safe
16
+ return self.model.predict([text])[0]
backend/app/services/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (1.51 kB). View file
 
backend/app/services/__pycache__/cefr_predictor.cpython-314.pyc ADDED
Binary file (1.7 kB). View file
 
backend/app/services/__pycache__/feedback.cpython-314.pyc ADDED
Binary file (1.8 kB). View file
 
backend/app/services/__pycache__/llm_tutor.cpython-314.pyc ADDED
Binary file (3.92 kB). View file
 
backend/app/services/__pycache__/tutor_engine.cpython-314.pyc ADDED
Binary file (10.3 kB). View file
 
backend/app/services/cefr_predictor.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ import joblib
3
+
4
+ # رجّع root ديال المشروع (learnlanguage)
5
+ ROOT = Path(__file__).resolve().parents[3] # .../learnlanguage
6
+ MODEL_PATH = ROOT / "ml" / "models" / "cefr_model.pkl"
7
+
8
+ class CEFRPredictor:
9
+ def __init__(self):
10
+ if not MODEL_PATH.exists():
11
+ raise FileNotFoundError(f"Model not found: {MODEL_PATH}")
12
+ self.model = joblib.load(MODEL_PATH)
13
+
14
+ def predict(self, text: str) -> str:
15
+ text = (text or "").strip()
16
+ if not text:
17
+ return "A2"
18
+ return self.model.predict([text])[0]
backend/app/services/feedback.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import language_tool_python
2
+
3
+ _tool = None
4
+
5
+ def _get_tool():
6
+ global _tool
7
+ if _tool is None:
8
+ _tool = language_tool_python.LanguageTool("en-US")
9
+ return _tool
10
+
11
+ def grammar_feedback(text: str, max_items: int = 6):
12
+ text = (text or "").strip()
13
+ if not text:
14
+ return text, []
15
+
16
+ try:
17
+ tool = _get_tool()
18
+ matches = tool.check(text)
19
+ corrected = language_tool_python.utils.correct(text, matches)
20
+
21
+ corrections = []
22
+ for m in matches[:max_items]:
23
+ suggestion = m.replacements[0] if m.replacements else ""
24
+ # ✅ هنا الفرق
25
+ error_fragment = text[m.offset:m.offset + m.error_length]
26
+
27
+ corrections.append({
28
+ "offset": m.offset,
29
+ "error": error_fragment,
30
+ "suggestion": suggestion,
31
+ "rule": getattr(m, "ruleId", ""),
32
+ "message": m.message
33
+ })
34
+ return corrected, corrections
35
+ except Exception:
36
+ return text, []
backend/app/services/llm_tutor.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import requests
3
+ from typing import List, Dict, Any
4
+
5
+ OLLAMA_URL = "http://127.0.0.1:11434/api/chat"
6
+ OLLAMA_MODEL = "llama3.2:3b" # بدلها لـ llama3.1:8b إذا بغيتي
7
+
8
+ SYSTEM_TUTOR = """
9
+ You are a professional English tutor.
10
+
11
+ You MUST:
12
+ 1. Correct the user's sentence.
13
+ 2. Explain the corrections briefly.
14
+ 3. Answer the user's question directly.
15
+ 4. Ask ONE follow-up question related to the user's message.
16
+ 5. Provide ONE short exercise related to the same topic.
17
+
18
+ VERY IMPORTANT:
19
+ - Output MUST be ONLY valid JSON.
20
+ - No markdown.
21
+ - No code fences.
22
+ - No extra text.
23
+ - Always fill all fields.
24
+
25
+ Return exactly this structure:
26
+
27
+ {
28
+ "reply": "...",
29
+ "corrected_text": "...",
30
+ "corrections": [
31
+ {"error": "...", "suggestion": "...", "explanation": "..."}
32
+ ],
33
+ "followup_question": "...",
34
+ "exercise": {"type": "...", "prompt": "...", "answer": "..."}
35
+ }
36
+ """
37
+
38
+ def _history_to_messages(history: List[Dict[str, Any]]) -> List[Dict[str, str]]:
39
+ msgs = [{"role": "system", "content": SYSTEM_TUTOR.strip()}]
40
+
41
+ # keep last 10 turns
42
+ for h in history[-10:]:
43
+ role = (h.get("role") or "").lower()
44
+ content = (h.get("content") or "").strip()
45
+ if not content:
46
+ continue
47
+
48
+ if role == "you":
49
+ msgs.append({"role": "user", "content": content})
50
+ elif role == "bot":
51
+ msgs.append({"role": "assistant", "content": content})
52
+
53
+ return msgs
54
+
55
+ def call_llm(message: str, cefr_level: str, topic: str, profile: dict, history: List[Dict[str, Any]]):
56
+ msgs = _history_to_messages(history)
57
+
58
+ user_prompt = f"""
59
+ CEFR level: {cefr_level}
60
+ Detected topic: {topic}
61
+ Known profile facts: {profile}
62
+
63
+ User message:
64
+ {message}
65
+
66
+ Return JSON only.
67
+ """.strip()
68
+
69
+ msgs.append({"role": "user", "content": user_prompt})
70
+
71
+ payload = {
72
+ "model": OLLAMA_MODEL,
73
+ "messages": msgs,
74
+ "stream": False,
75
+ "options": {
76
+ "temperature": 0.4,
77
+ "num_predict": 300
78
+ }
79
+ }
80
+
81
+ try:
82
+ r = requests.post(OLLAMA_URL, json=payload, timeout=180)
83
+
84
+ r.raise_for_status()
85
+ data = r.json()
86
+ # Ollama returns: {"message": {"role":"assistant","content":"..."} , ...}
87
+ return data["message"]["content"]
88
+ except Exception:
89
+ # fallback JSON if Ollama is not running
90
+ return """{
91
+ "reply":"⚠️ Local LLM (Ollama) not reachable. Make sure Ollama is running and the model is pulled.",
92
+ "corrected_text":"",
93
+ "corrections":[],
94
+ "followup_question":"What do you want to practice today (home, study, food, travel)?",
95
+ "exercise":{"type":"fill_blank","prompt":"I ___ English every day. (study/studies)","answer":"study"}
96
+ }"""
backend/app/services/tutor_engine.py ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json, re
2
+ from collections import Counter
3
+ from .cefr_predictor import CEFRPredictor
4
+ from ..db.repo import top_errors
5
+
6
+ predictor = CEFRPredictor()
7
+ LEVEL_ORDER = ["A1","A2","B1","B2","C1","C2"]
8
+
9
+ SYSTEM_JSON = """
10
+ You are a professional English tutor.
11
+
12
+ You MUST:
13
+ - Answer the user's question directly.
14
+ - Correct the user's sentence (capitalization, punctuation, grammar).
15
+ - Provide corrections with short explanations.
16
+ - Ask ONE follow-up question related to the same topic.
17
+ - Provide ONE short micro-exercise related to the same topic.
18
+ - Keep reply under 2 sentences.
19
+
20
+ Return ONLY valid JSON (no markdown, no code fences), exactly keys:
21
+ reply, corrected_text, corrections, followup_question, exercise
22
+
23
+ corrections: array of {error, suggestion, explanation} max 5
24
+ exercise: {type, prompt, answer}
25
+ """
26
+
27
+ def smooth_level(levels, current):
28
+ levels = [x for x in (levels or []) if x in LEVEL_ORDER]
29
+ if current in LEVEL_ORDER: levels.append(current)
30
+ if not levels: return current or "A2"
31
+ return Counter(levels).most_common(1)[0][0]
32
+
33
+ def detect_topic(text: str) -> str:
34
+ t = (text or "").lower()
35
+ if "irregular" in t: return "irregular_verbs"
36
+ if any(k in t for k in ["tense","past","present","future"]): return "tenses"
37
+ if any(k in t for k in ["food","eat"]): return "food"
38
+ if any(k in t for k in ["study","school","exam"]): return "study"
39
+ if any(k in t for k in ["live","city","country","from","morocco"]): return "home"
40
+ return "general"
41
+
42
+ def extract_profile(history):
43
+ profile = {}
44
+ for h in history[-12:]:
45
+ if (h.get("role") or "") == "You":
46
+ msg = (h.get("content") or "").lower()
47
+ m = re.search(r"\bmy name is\s+([a-z]+)", msg)
48
+ if m: profile["name"] = m.group(1).title()
49
+ m = re.search(r"\bi live in\s+([a-z\s]+)", msg)
50
+ if m: profile["lives_in"] = m.group(1).strip().title()
51
+ return profile
52
+
53
+ def safe_json_parse(text: str):
54
+ cleaned = (text or "").strip()
55
+ cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned, flags=re.IGNORECASE)
56
+ cleaned = re.sub(r"\s*```$", "", cleaned)
57
+
58
+ m = re.search(r"\{.*\}", cleaned, flags=re.DOTALL)
59
+ if m: cleaned = m.group(0)
60
+
61
+ try:
62
+ data = json.loads(cleaned)
63
+ data.setdefault("reply","")
64
+ data.setdefault("corrected_text","")
65
+ data.setdefault("corrections",[])
66
+ data.setdefault("followup_question","")
67
+ data.setdefault("exercise", {"type":"","prompt":"","answer":""})
68
+ return data
69
+ except Exception:
70
+ return {
71
+ "reply": cleaned[:700],
72
+ "corrected_text": "",
73
+ "corrections": [],
74
+ "followup_question": "Can you tell me more?",
75
+ "exercise": {"type":"rewrite","prompt":"Rewrite your sentence correctly.","answer":""}
76
+ }
77
+
78
+ def make_exercise_from_top_errors(level: str, errors: list):
79
+ # errors = [{"error":"at morocco","suggestion":"in Morocco","count":3},...]
80
+ if not errors:
81
+ return {"type":"", "prompt":"", "answer":""}
82
+
83
+ e0 = errors[0]
84
+ if level in ["A1","A2"]:
85
+ return {
86
+ "type":"fix_mistake",
87
+ "prompt": f"Fix this: 'I live {e0['error']}'.",
88
+ "answer": f"I live {e0['suggestion']}."
89
+ }
90
+ return {
91
+ "type":"rewrite",
92
+ "prompt": f"Rewrite correctly and add a reason: 'I live {e0['error']}'.",
93
+ "answer": f"I live {e0['suggestion']} because ..."
94
+ }
95
+
96
+ def build_prompt_context(user_text: str, history: list, db, session_id: str):
97
+ recent_levels = [h.get("level") for h in history if h.get("level")]
98
+ pred = predictor.predict(user_text)
99
+ level = smooth_level(recent_levels, pred)
100
+ topic = detect_topic(user_text)
101
+ profile = extract_profile(history)
102
+
103
+ # progress: top errors from db
104
+ errs = top_errors(db, session_id, limit=3)
105
+
106
+ # build messages for ollama
107
+ # We include system + short conversation + user instruction
108
+ msgs = [{"role":"system","content":SYSTEM_JSON.strip()}]
109
+
110
+ for h in history[-10:]:
111
+ role = (h.get("role") or "")
112
+ content = (h.get("content") or "")
113
+ if not content: continue
114
+ if role == "You":
115
+ msgs.append({"role":"user","content":content})
116
+ elif role == "Bot":
117
+ msgs.append({"role":"assistant","content":content})
118
+
119
+ user_instruction = f"""
120
+ CEFR: {level}
121
+ Topic: {topic}
122
+ Profile: {profile}
123
+ Common mistakes to focus on: {errs}
124
+
125
+ User message:
126
+ {user_text}
127
+
128
+ Return JSON only.
129
+ """.strip()
130
+
131
+ msgs.append({"role":"user","content":user_instruction})
132
+
133
+ return {"level": level, "topic": topic, "profile": profile, "ollama_messages": msgs}
134
+ from ..db.models import Message, Correction
135
+ from .llm_tutor import call_llm
136
+ def chat(user_text: str, user, history=None, mode="conversation", db=None):
137
+ history = history or []
138
+
139
+ pred = predictor.predict(user_text)
140
+ recent_levels = [h.get("level") for h in history if h.get("level")]
141
+ level = smooth_level(recent_levels, pred)
142
+ topic = detect_topic(user_text)
143
+ profile = extract_profile(history)
144
+
145
+ # Call LLM
146
+ raw = call_llm(user_text, level, topic, profile, history)
147
+ parsed = safe_json_parse(raw)
148
+
149
+ # ---------------- SAVE USER MESSAGE ----------------
150
+ user_msg = Message(
151
+ user_id=user.id,
152
+ role="user",
153
+ text=user_text,
154
+ level=level,
155
+ topic=topic
156
+ )
157
+ db.add(user_msg)
158
+
159
+ # ---------------- SAVE BOT MESSAGE ----------------
160
+ bot_msg = Message(
161
+ user_id=user.id,
162
+ role="bot",
163
+ text=parsed.get("reply",""),
164
+ level=level,
165
+ topic=topic
166
+ )
167
+ db.add(bot_msg)
168
+
169
+ db.flush() # باش ناخدو id
170
+
171
+ # ---------------- SAVE CORRECTIONS ----------------
172
+ corrections = parsed.get("corrections", [])
173
+ for c in corrections:
174
+ corr = Correction(
175
+ user_id=user.id,
176
+ error=c.get("error",""),
177
+ suggestion=c.get("suggestion",""),
178
+ explanation=c.get("explanation","")
179
+ )
180
+ db.add(corr)
181
+
182
+ db.commit()
183
+
184
+ return {
185
+ "level": level,
186
+ "topic": topic,
187
+ **parsed
188
+ }
backend/render.yaml ADDED
@@ -0,0 +1 @@
 
 
1
+
backend/requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.111.0
2
+ uvicorn[standard]==0.30.1
3
+
4
+ sqlalchemy==2.0.30
5
+ pydantic==2.7.4
6
+
7
+ python-jose[cryptography]==3.3.0
8
+ passlib==1.7.4
9
+
10
+ python-multipart==0.0.9
11
+ requests==2.32.3
backend/run.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ import uvicorn
2
+
3
+ if __name__ == "__main__":
4
+ uvicorn.run("backend.app.main:app", host="127.0.0.1", port=8000)
frontend/app.py ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import requests
3
+ import json
4
+ import pandas as pd
5
+ from datetime import datetime
6
+
7
+ API_URL = "http://127.0.0.1:8001"
8
+
9
+ st.set_page_config(
10
+ page_title="LearnLanguage 2026 • Tutor",
11
+ page_icon="🧠",
12
+ layout="wide",
13
+ )
14
+
15
+ # ---------------- SESSION ----------------
16
+ if "messages" not in st.session_state:
17
+ st.session_state.messages = []
18
+
19
+ if "last" not in st.session_state:
20
+ st.session_state.last = None
21
+
22
+ if "mode" not in st.session_state:
23
+ st.session_state.mode = "conversation"
24
+
25
+ if "token" not in st.session_state:
26
+ st.session_state.token = None
27
+
28
+
29
+ # ---------------- STREAM FUNCTION ----------------
30
+ def stream_chat(message: str):
31
+
32
+ payload = {
33
+ "message": message,
34
+ "mode": st.session_state.mode
35
+ }
36
+
37
+ headers = {
38
+ "Authorization": f"Bearer {st.session_state.token}"
39
+ }
40
+
41
+ r = requests.post(
42
+ f"{API_URL}/chat",
43
+ json=payload,
44
+ headers=headers,
45
+ timeout=180
46
+ )
47
+
48
+ r.raise_for_status()
49
+
50
+ data = r.json()
51
+
52
+ yield ("final", data)
53
+ # ---------------- SIDEBAR AUTH ----------------
54
+ with st.sidebar:
55
+ st.markdown("## 🔐 Account")
56
+
57
+ tabL, tabR = st.tabs(["Login", "Register"])
58
+
59
+ with tabL:
60
+ email = st.text_input("Email", key="login_email")
61
+ pwd = st.text_input("Password", type="password", key="login_pwd")
62
+
63
+ if st.button("Login", use_container_width=True):
64
+ r = requests.post(
65
+ f"{API_URL}/auth/login",
66
+ json={"email": email, "password": pwd}
67
+ )
68
+
69
+ if r.status_code == 200:
70
+ st.session_state.token = r.json()["token"]
71
+ st.success("Logged in ✅")
72
+ st.rerun()
73
+ else:
74
+ st.error(r.text)
75
+
76
+ with tabR:
77
+ email2 = st.text_input("Email", key="reg_email")
78
+ username2 = st.text_input("Username", key="reg_username")
79
+ pwd2 = st.text_input("Password", type="password", key="reg_pwd")
80
+
81
+ if st.button("Create account", use_container_width=True):
82
+ r = requests.post(
83
+ f"{API_URL}/auth/register",
84
+ json={"email": email2, "username": username2, "password": pwd2}
85
+ )
86
+
87
+ if r.status_code == 200:
88
+ st.session_state.token = r.json()["token"]
89
+ st.success("Account created ✅")
90
+ st.rerun()
91
+ else:
92
+ st.error(r.text)
93
+
94
+ if st.session_state.token:
95
+ me = requests.get(
96
+ f"{API_URL}/auth/me",
97
+ headers={"Authorization": f"Bearer {st.session_state.token}"}
98
+ )
99
+
100
+ if me.status_code == 200:
101
+ st.success(f"Connected as: {me.json()['username']}")
102
+
103
+ if st.button("Logout", use_container_width=True):
104
+ st.session_state.token = None
105
+ st.session_state.messages = []
106
+ st.rerun()
107
+
108
+
109
+ # ---------------- MAIN HEADER ----------------
110
+ st.title("🧠 LearnLanguage • Streaming Tutor")
111
+ st.caption("Streaming replies • Corrections • Exercises • Progress-ready")
112
+
113
+
114
+ # ---------------- INPUT ----------------
115
+ if not st.session_state.token:
116
+ st.warning("Please login first to start chatting.")
117
+ st.stop()
118
+
119
+ colA, colB = st.columns([5, 1])
120
+
121
+ with colA:
122
+ user_msg = st.text_input(
123
+ "Type your message (English)",
124
+ placeholder="e.g., I like my city because it is calm..."
125
+ )
126
+
127
+ with colB:
128
+ send = st.button("Send 🚀", use_container_width=True)
129
+
130
+
131
+ # ---------------- SEND LOGIC ----------------
132
+ if send and user_msg.strip():
133
+
134
+ ts = datetime.now().strftime("%H:%M")
135
+
136
+ st.session_state.messages.append({
137
+ "role": "user",
138
+ "text": user_msg,
139
+ "ts": ts
140
+ })
141
+
142
+ streamed_text = ""
143
+ placeholder = st.empty()
144
+
145
+ try:
146
+ for kind, data in stream_chat(user_msg):
147
+
148
+ if kind == "text":
149
+ streamed_text += data
150
+ placeholder.markdown(f"**Tutor (streaming…)**\n\n{streamed_text}")
151
+
152
+ else:
153
+ res = data
154
+ st.session_state.last = res
155
+ st.session_state.messages.append({
156
+ "role": "bot",
157
+ "text": res.get("reply", ""),
158
+ "ts": datetime.now().strftime("%H:%M")
159
+ })
160
+ break
161
+
162
+ st.rerun()
163
+
164
+ except Exception as e:
165
+ st.error(f"Streaming error: {e}")
166
+
167
+
168
+ # ---------------- CHAT HISTORY ----------------
169
+ st.markdown("## 💬 Conversation")
170
+
171
+ for m in st.session_state.messages[-30:]:
172
+ who = "You" if m["role"] == "user" else "Tutor"
173
+ st.markdown(f"**{who} • {m['ts']}**")
174
+ st.write(m["text"])
175
+
176
+
177
+ # ---------------- TUTOR PANEL ----------------
178
+ st.markdown("## 🧾 Tutor Panel")
179
+
180
+ res = st.session_state.last or {}
181
+
182
+ tabs = st.tabs(["Feedback", "Exercises", "Raw JSON"])
183
+
184
+ with tabs[0]:
185
+ st.markdown("**Corrected text**")
186
+ st.write(res.get("corrected_text", "—"))
187
+
188
+ st.markdown("**Corrections**")
189
+ corrections = res.get("corrections", [])
190
+ if corrections:
191
+ st.dataframe(pd.DataFrame(corrections))
192
+ else:
193
+ st.success("No corrections 🎉")
194
+
195
+ st.markdown("**Follow-up question**")
196
+ st.write(res.get("followup_question", "—"))
197
+
198
+ with tabs[1]:
199
+ st.json(res.get("exercise", {}))
200
+ st.json(res.get("exercise_from_progress", {}))
201
+
202
+ with tabs[2]:
203
+ st.json(res)
frontend/pages/2_📊_Dashboard.py ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import requests
3
+ import pandas as pd
4
+
5
+ API_URL = "http://127.0.0.1:8001"
6
+
7
+ st.set_page_config(
8
+ page_title="LearnLanguage • Dashboard",
9
+ page_icon="📊",
10
+ layout="wide"
11
+ )
12
+
13
+ # ---------------- SESSION ----------------
14
+ if "token" not in st.session_state:
15
+ st.session_state.token = None
16
+
17
+ # ---------------- CSS ----------------
18
+ st.markdown("""
19
+ <style>
20
+ .stApp{ background:#f6f7fb; }
21
+ .card{
22
+ background:#ffffff;
23
+ border:1px solid #e9ecf3;
24
+ border-radius:16px;
25
+ padding:16px 18px;
26
+ box-shadow:0 10px 24px rgba(17,24,39,0.06);
27
+ }
28
+ .kpi-title{ color:#6b7280; font-size:12px; font-weight:700; }
29
+ .kpi-value{ color:#111827; font-size:26px; font-weight:900; }
30
+ .empty-state{
31
+ padding:14px;
32
+ border:1px dashed #d8dde8;
33
+ border-radius:14px;
34
+ background:#fbfcff;
35
+ color:#6b7280;
36
+ }
37
+ </style>
38
+ """, unsafe_allow_html=True)
39
+
40
+ # ---------------- HELPERS ----------------
41
+ def api_get(path: str, token: str, timeout=10):
42
+ headers = {"Authorization": f"Bearer {token}"}
43
+ r = requests.get(f"{API_URL}{path}", headers=headers, timeout=timeout)
44
+ r.raise_for_status()
45
+ return r.json()
46
+
47
+ def api_post(path: str, payload: dict, timeout=10):
48
+ r = requests.post(f"{API_URL}{path}", json=payload, timeout=timeout)
49
+ return r
50
+
51
+ # ---------------- SIDEBAR: LOGIN ----------------
52
+ with st.sidebar:
53
+ st.markdown("<div class='card'>", unsafe_allow_html=True)
54
+ st.markdown("### 🔐 Account")
55
+
56
+ if not st.session_state.token:
57
+ email = st.text_input("Email", key="dash_email")
58
+ pwd = st.text_input("Password", type="password", key="dash_pwd")
59
+
60
+ c1, c2 = st.columns(2)
61
+ with c1:
62
+ if st.button("Login", use_container_width=True):
63
+ resp = api_post("/auth/login", {"email": email, "password": pwd})
64
+ if resp.status_code == 200:
65
+ st.session_state.token = resp.json()["token"]
66
+ st.success("Logged in ✅")
67
+ st.rerun()
68
+ else:
69
+ st.error(resp.text)
70
+
71
+ with c2:
72
+ if st.button("Register", use_container_width=True):
73
+ st.info("Register from main app (or add register form here).")
74
+
75
+ else:
76
+ # show user
77
+ try:
78
+ me = api_get("/auth/me", st.session_state.token, timeout=6)
79
+ st.caption(f"Connected as: **{me.get('username','')}**")
80
+ except Exception:
81
+ st.caption("Connected (token)")
82
+
83
+ if st.button("Logout", use_container_width=True):
84
+ st.session_state.token = None
85
+ st.rerun()
86
+
87
+ st.markdown("</div>", unsafe_allow_html=True)
88
+
89
+ # ---------------- LOGIN CHECK ----------------
90
+ if not st.session_state.token:
91
+ st.warning("Please login to view your dashboard.")
92
+ st.stop()
93
+
94
+ # ---------------- LOAD DATA ----------------
95
+ try:
96
+ data = api_get("/stats/me", st.session_state.token, timeout=10)
97
+ except Exception as e:
98
+ st.error(f"Cannot load stats: {e}")
99
+ st.stop()
100
+
101
+ # ---------------- KPIs ----------------
102
+ total_messages = data.get("total_messages", 0)
103
+ progression = data.get("progression", [])
104
+ messages_per_day = data.get("messages_per_day", [])
105
+ top_errors = data.get("top_errors", [])
106
+
107
+ current_level = progression[-1]["level"] if progression else "—"
108
+ active_days = len(messages_per_day) if messages_per_day else 0
109
+ top_error_text = top_errors[0]["error"] if top_errors else "—"
110
+
111
+ st.title("📊 Progress Dashboard")
112
+
113
+ c1, c2, c3, c4 = st.columns(4)
114
+
115
+ with c1:
116
+ st.markdown("<div class='card'>", unsafe_allow_html=True)
117
+ st.markdown("<div class='kpi-title'>Total Messages</div>", unsafe_allow_html=True)
118
+ st.markdown(f"<div class='kpi-value'>{total_messages}</div>", unsafe_allow_html=True)
119
+ st.markdown("</div>", unsafe_allow_html=True)
120
+
121
+ with c2:
122
+ st.markdown("<div class='card'>", unsafe_allow_html=True)
123
+ st.markdown("<div class='kpi-title'>Current Level</div>", unsafe_allow_html=True)
124
+ st.markdown(f"<div class='kpi-value'>{current_level}</div>", unsafe_allow_html=True)
125
+ st.markdown("</div>", unsafe_allow_html=True)
126
+
127
+ with c3:
128
+ st.markdown("<div class='card'>", unsafe_allow_html=True)
129
+ st.markdown("<div class='kpi-title'>Active Days</div>", unsafe_allow_html=True)
130
+ st.markdown(f"<div class='kpi-value'>{active_days}</div>", unsafe_allow_html=True)
131
+ st.markdown("</div>", unsafe_allow_html=True)
132
+
133
+ with c4:
134
+ st.markdown("<div class='card'>", unsafe_allow_html=True)
135
+ st.markdown("<div class='kpi-title'>Top Mistake</div>", unsafe_allow_html=True)
136
+ st.markdown(f"<div class='kpi-value'>{top_error_text}</div>", unsafe_allow_html=True)
137
+ st.markdown("</div>", unsafe_allow_html=True)
138
+
139
+ st.divider()
140
+
141
+ # ---------------- CHARTS ----------------
142
+ left, right = st.columns(2)
143
+
144
+ with left:
145
+ st.markdown("### 📈 CEFR Progression")
146
+
147
+ if progression:
148
+ dfp = pd.DataFrame(progression)
149
+
150
+ mapping = {"A1":0,"A2":1,"B1":2,"B2":3,"C1":4,"C2":5}
151
+ if "value" not in dfp.columns:
152
+ dfp["value"] = dfp["level"].map(mapping).fillna(0)
153
+
154
+ dfp["time"] = pd.to_datetime(dfp["time"], errors="coerce")
155
+ dfp = dfp.dropna(subset=["time"]).sort_values("time").set_index("time")
156
+
157
+ st.line_chart(dfp["value"])
158
+ st.caption("0=A1, 1=A2, 2=B1, 3=B2, 4=C1, 5=C2")
159
+ else:
160
+ st.markdown("<div class='empty-state'>No progression yet.</div>", unsafe_allow_html=True)
161
+
162
+ with right:
163
+ st.markdown("### 💬 Messages Per Day")
164
+
165
+ if messages_per_day:
166
+ dfm = pd.DataFrame(messages_per_day)
167
+ dfm["day"] = pd.to_datetime(dfm["day"], errors="coerce")
168
+ dfm = dfm.dropna(subset=["day"]).sort_values("day").set_index("day")
169
+
170
+ st.bar_chart(dfm["count"])
171
+ else:
172
+ st.markdown("<div class='empty-state'>No activity yet.</div>", unsafe_allow_html=True)
173
+
174
+ st.divider()
175
+
176
+ # ---------------- TOP ERRORS ----------------
177
+ st.markdown("### ❌ Top Frequent Mistakes")
178
+
179
+ if top_errors:
180
+ st.dataframe(pd.DataFrame(top_errors), use_container_width=True, hide_index=True)
181
+ else:
182
+ st.markdown("<div class='empty-state'>🎉 No repeated errors yet.</div>", unsafe_allow_html=True)