From 41613c454d800dd0988ebda0c5ec52a97df84fd0 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Huu <2001230909@hufi.edu.vn> Date: Tue, 23 Jun 2026 11:48:46 +0700 Subject: [PATCH 01/20] fix(auth): block login/refresh for unverified email accounts Critical authorization bug: /login and /refresh never checked user.is_verified, so accounts with unconfirmed emails (including ones using addresses the registrant doesn't control) could log in and stay logged in indefinitely via refresh-token rotation. Google/ Facebook OAuth is unaffected since it already sets is_verified from the provider's own claim. Also fixes /resend-verification, which checked a nonexistent `email_verified` attribute and never actually fired. Flutter login page now detects the new 403 and routes the user to the existing email-verification-pending screen to resend the link instead of just showing a raw error. Co-Authored-By: Claude Sonnet 4.6 --- backend-service/app/routes/auth.py | 22 ++++++- backend-service/tests/test_auth_routes.py | 58 +++++++++++++++++++ .../auth/presentation/pages/login_page.dart | 23 ++++++++ 3 files changed, 101 insertions(+), 2 deletions(-) diff --git a/backend-service/app/routes/auth.py b/backend-service/app/routes/auth.py index 3d83d971..c96a977a 100644 --- a/backend-service/app/routes/auth.py +++ b/backend-service/app/routes/auth.py @@ -129,7 +129,7 @@ async def resend_verification_email( result = await db.execute(select(User).where(User.email == body.email)) user = result.scalar_one_or_none() # Always return 200 to avoid email enumeration - if user and not getattr(user, "email_verified", False): + if user and not user.is_verified: try: from app.core.security import create_verification_token token = create_verification_token( @@ -160,6 +160,15 @@ async def login( ) if not user.is_active: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User account is inactive") + # Local (email/password) accounts must verify their email before a session + # is issued. Google/Facebook logins set is_verified from the provider's + # own email_verified claim at account-creation time, so this never blocks + # OAuth users. + if not user.is_verified: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Email not verified. Please check your inbox for a verification link before logging in.", + ) user_id = str(user.id) username = user.username @@ -256,7 +265,16 @@ async def refresh_token( status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or inactive" ) - + + # A refresh token issued before email verification (or for an account + # that was since unverified) must not keep renewing access forever — + # otherwise the 7-day rotation window silently bypasses /login's check. + if not user.is_verified: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Email not verified. Please check your inbox for a verification link before logging in.", + ) + # FIX: Implement token rotation db_token.is_used = True diff --git a/backend-service/tests/test_auth_routes.py b/backend-service/tests/test_auth_routes.py index c1f940fc..27ec5f6c 100644 --- a/backend-service/tests/test_auth_routes.py +++ b/backend-service/tests/test_auth_routes.py @@ -409,6 +409,35 @@ async def test_login_missing_password_returns_422(self, client): ) assert response.status_code == 422 + async def test_login_unverified_user_returns_403(self): + """Correct credentials but unverified email returns 403, not tokens.""" + from app.main import app + from app.core.database import get_db + + unverified_user = _make_mock_user(is_verified=False) + session = _make_mock_session(scalar_one_or_none_value=unverified_user) + + async def mock_get_db(): + yield session + + app.dependency_overrides[get_db] = mock_get_db + transport = ASGITransport(app=app) + + with patch("app.services.auth_service.verify_password_async", new=AsyncMock(return_value=True)): + async with AsyncClient(transport=transport, base_url="http://test") as c: + response = await c.post( + f"{BASE}/login", + json={"email": "test@example.com", "password": "testpass"}, + ) + app.dependency_overrides.clear() + + assert response.status_code == 403 + message = response.json()["error"]["message"].lower() + assert "not verified" in message + data = response.json() + assert "access_token" not in data + assert "refresh_token" not in data + # =========================================================================== # POST /auth/refresh @@ -481,6 +510,35 @@ async def test_refresh_user_not_found_returns_401(self, client): ) assert response.status_code == 401 + async def test_refresh_unverified_user_returns_403(self): + """A still-unverified user cannot use a refresh token to renew access either.""" + from app.main import app + from app.core.database import get_db + + unverified_user = _make_mock_user(is_verified=False) + session = _make_mock_session(scalar_one_or_none_value=unverified_user) + + async def mock_get_db(): + yield session + + app.dependency_overrides[get_db] = mock_get_db + transport = ASGITransport(app=app) + + with patch( + "app.core.security.decode_token", + return_value={"type": "refresh", "sub": "00000000-0000-0000-0000-000000000001"}, + ): + async with AsyncClient(transport=transport, base_url="http://test") as c: + response = await c.post( + f"{BASE}/refresh", + json={"refresh_token": "valid.refresh.token"}, + ) + app.dependency_overrides.clear() + + assert response.status_code == 403 + message = response.json()["error"]["message"].lower() + assert "not verified" in message + # =========================================================================== # GET /auth/me diff --git a/flutter-app/lib/features/auth/presentation/pages/login_page.dart b/flutter-app/lib/features/auth/presentation/pages/login_page.dart index 837f0e58..a8bb8cea 100644 --- a/flutter-app/lib/features/auth/presentation/pages/login_page.dart +++ b/flutter-app/lib/features/auth/presentation/pages/login_page.dart @@ -6,6 +6,7 @@ import 'package:lexilingo_app/core/widgets/lottie_loading_widget.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../providers/auth_provider.dart'; +import 'email_verification_pending_page.dart'; import 'forgot_password_page.dart'; import 'register_page.dart'; import 'package:lexilingo_app/core/theme/app_theme.dart'; @@ -57,6 +58,14 @@ class _LoginPageState extends State { normalized.contains('auth_invalid'); } + bool _isEmailNotVerifiedMessage(String? message) { + if (message == null) return false; + final normalized = message.toLowerCase(); + return normalized.contains('not verified') || + normalized.contains('chưa được xác thực') || + normalized.contains('chưa xác thực'); + } + Future _loadSavedCredentials() async { final prefs = await SharedPreferences.getInstance(); final remember = prefs.getBool(_rememberPasswordKey) ?? false; @@ -373,6 +382,20 @@ class _LoginPageState extends State { if (authProvider.isAuthenticated) { await _persistCredentialPreference(); setState(() => _failedAttempts = 0); + } else if (_isEmailNotVerifiedMessage( + authProvider.errorMessage, + )) { + if (!mounted) return; + final email = _emailController.text + .trim(); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + EmailVerificationPendingPage( + email: email, + ), + ), + ); } else if (_isInvalidCredentialMessage( authProvider.errorMessage, )) { From 1d2aedef98b633eb0434fc543581918c24630928 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Huu <2001230909@hufi.edu.vn> Date: Wed, 24 Jun 2026 11:23:04 +0700 Subject: [PATCH 02/20] fix(auth): backfill legacy verification, allow Google to vouch for local accounts Backfills is_verified=true for users created before commit 41613c45's login/refresh enforcement so existing accounts aren't locked out. Adds is_verified checks to admin login/OTP routes for consistency. Lets Google OAuth link to (and verify) an existing unverified local account when the email matches and Google confirms email_verified=true, instead of always rejecting with "Cannot change provider". Expands auth route test coverage to 56 tests covering Google/Facebook login, admin login/OTP, password change, and resend-verification. Co-Authored-By: Claude Sonnet 4.6 --- .../backfill_is_verified_legacy_users.py | 46 ++ backend-service/app/routes/auth.py | 28 +- backend-service/tests/test_auth_routes.py | 594 +++++++++++++++++- 3 files changed, 640 insertions(+), 28 deletions(-) create mode 100644 backend-service/alembic/versions/backfill_is_verified_legacy_users.py diff --git a/backend-service/alembic/versions/backfill_is_verified_legacy_users.py b/backend-service/alembic/versions/backfill_is_verified_legacy_users.py new file mode 100644 index 00000000..0f1866c3 --- /dev/null +++ b/backend-service/alembic/versions/backfill_is_verified_legacy_users.py @@ -0,0 +1,46 @@ +"""Backfill is_verified for users created before email-verification enforcement + +Revision ID: backfill_is_verified_legacy_users +Revises: 0d7ae4e272b7 +Create Date: 2026-06-23 + +login/refresh now reject accounts with is_verified=False (see +app/routes/auth.py, commit 41613c45). Accounts registered before that +enforcement existed were never required to verify their email, so without +this backfill every pre-existing unverified local account would be locked +out on its next login attempt. +""" + +from typing import Sequence, Union + +from alembic import op + + +# revision identifiers, used by Alembic. +revision: str = "backfill_is_verified_legacy_users" +down_revision: Union[str, None] = "0d7ae4e272b7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +# Cutoff = author timestamp of commit 41613c45 ("fix(auth): block +# login/refresh for unverified email accounts"). Users registered at or +# after this point were always subject to the enforcement and must verify +# normally. +ENFORCEMENT_CUTOFF = "2026-06-23T11:48:46+07:00" + + +def upgrade() -> None: + op.execute( + f""" + UPDATE users + SET is_verified = TRUE + WHERE is_verified = FALSE + AND created_at < TIMESTAMP WITH TIME ZONE '{ENFORCEMENT_CUTOFF}' + """ + ) + + +def downgrade() -> None: + # Backfill is one-directional by design — there is no reliable way to + # tell which legacy users were "really" unverified vs. grandfathered in. + pass diff --git a/backend-service/app/routes/auth.py b/backend-service/app/routes/auth.py index c96a977a..81f7518b 100644 --- a/backend-service/app/routes/auth.py +++ b/backend-service/app/routes/auth.py @@ -361,8 +361,6 @@ async def google_login( audience = settings.GOOGLE_CLIENT_ID # None is also accepted below # Verify Google token with the correct audience - import logging - logger = logging.getLogger(__name__) logger.info(f"Google login attempt: source={request.source}, audience={audience}") google_info = await verify_google_token(request.id_token, audience=audience) @@ -441,19 +439,27 @@ async def google_login( await db.refresh(user) elif not user.has_google_auth: # "google" is NOT yet in this user's providers list - if request.source != "admin" or not allowlisted_admin_role: + is_admin_link = request.source == "admin" and allowlisted_admin_role + # Google has verified this email, and it matches an existing local account — + # let Google vouch for the email instead of leaving the user locked out + # behind an unclicked verification link. + can_verify_local_account = user.has_local_auth and email_verified + + if not is_admin_link and not can_verify_local_account: if not user.has_local_auth: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered with a different login method." ) - # Non-admin Google login for a local-only account: block provider switch + # Local account exists but Google hasn't verified this email either — + # an unverified claim can't be trusted to unlock it. raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered. Please login with your existing method." ) - # Admin source + allowlisted email → link google to this account + # Admin-allowlisted link, or Google vouching for a local account's email → + # link google to this account. user.add_provider("google") user.is_verified = email_verified or user.is_verified user.avatar_url = google_info.get("picture") or user.avatar_url @@ -519,9 +525,7 @@ async def facebook_login( """ from app.core.firebase_auth import verify_firebase_token, get_or_create_user_from_claims from app.core.config import settings - import logging - logger = logging.getLogger(__name__) logger.info(f"Facebook (Firebase) login attempt: source={request.source}") # Verify Firebase ID token @@ -803,6 +807,12 @@ async def admin_login( if not user.is_active: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User account is inactive") + if not user.is_verified: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Email not verified. Please check your inbox for a verification link before logging in.", + ) + if getattr(user, "role_level", 0) < 1: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin privileges required") @@ -863,7 +873,7 @@ async def admin_request_otp( user = result.scalar_one_or_none() # Respond the same way whether the user exists or not (anti-enumeration) - if user and user.is_active and getattr(user, "role_level", 0) >= 1: + if user and user.is_active and user.is_verified and getattr(user, "role_level", 0) >= 1: otp = str(random.randint(100000, 999999)) _admin_otp_store[email] = (otp, time.time() + _OTP_TTL_SECONDS) @@ -911,7 +921,7 @@ async def admin_verify_otp( ) user = result.scalar_one_or_none() - if not user or not user.is_active or getattr(user, "role_level", 0) < 1: + if not user or not user.is_active or not user.is_verified or getattr(user, "role_level", 0) < 1: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions") user_id = str(user.id) diff --git a/backend-service/tests/test_auth_routes.py b/backend-service/tests/test_auth_routes.py index 27ec5f6c..7b8b9a47 100644 --- a/backend-service/tests/test_auth_routes.py +++ b/backend-service/tests/test_auth_routes.py @@ -10,6 +10,12 @@ - POST /auth/forgot-password — always-success anti-enumeration response - POST /auth/reset-password — valid token, invalid token, weak password - POST /auth/verify-email — valid token, invalid token, already verified +- POST /auth/google — invalid token, new user, link to unverified local account, blocked link +- POST /auth/facebook — invalid token, success, inactive +- POST /auth/admin/login — success, wrong role, unverified, inactive +- POST /auth/admin/request-otp, /auth/admin/verify-otp — anti-enumeration, unverified blocked, success +- POST /auth/change-password — success, wrong current password, OAuth-only blocked +- POST /auth/resend-verification — always-success anti-enumeration response """ import uuid @@ -29,7 +35,7 @@ def _make_mock_user( *, is_active: bool = True, is_verified: bool = True, - provider: list[str] = ["local"], + provider: tuple[str, ...] = ("local",), hashed_password: str = "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5GyYzS6NzE3Fu", ) -> MagicMock: """Build a minimal mock User ORM object.""" @@ -41,7 +47,22 @@ def _make_mock_user( u.hashed_password = hashed_password u.is_active = is_active u.is_verified = is_verified - u.provider = provider + u.provider = list(provider) + # MagicMock auto-generates truthy attributes for undefined properties, so + # has_local_auth/has_google_auth must be set explicitly to mirror the real + # User.has_local_auth/has_google_auth @property behavior based on provider. + u.has_local_auth = "local" in u.provider + u.has_google_auth = "google" in u.provider + + def _add_provider(p: str) -> None: + if p not in u.provider: + u.provider.append(p) + if p == "google": + u.has_google_auth = True + elif p == "local": + u.has_local_auth = True + + u.add_provider = MagicMock(side_effect=_add_provider) u.native_language = "vi" u.target_language = "en" u.level = "beginner" @@ -187,26 +208,28 @@ class TestRegister: async def test_register_success_returns_201(self, client): """Valid registration creates a user and returns 201.""" - response = await client.post( - f"{BASE}/register", - json={ - "email": "newuser@example.com", - "username": "newuser", - "password": "SecurePass123!", - }, - ) + with patch("app.routes.auth.EmailService.send_verification_email", new=AsyncMock()): + response = await client.post( + f"{BASE}/register", + json={ + "email": "newuser@example.com", + "username": "newuser", + "password": "SecurePass123!", + }, + ) assert response.status_code == 201 async def test_register_success_response_has_email(self, client): """Successful register response includes submitted email.""" - response = await client.post( - f"{BASE}/register", - json={ - "email": "another@example.com", - "username": "anotheruser", - "password": "SecurePass123!", - }, - ) + with patch("app.routes.auth.EmailService.send_verification_email", new=AsyncMock()): + response = await client.post( + f"{BASE}/register", + json={ + "email": "another@example.com", + "username": "anotheruser", + "password": "SecurePass123!", + }, + ) assert response.status_code == 201 data = response.json() assert data["email"] == "another@example.com" @@ -615,7 +638,8 @@ async def mock_get_db(): app.dependency_overrides[get_db] = mock_get_db transport = ASGITransport(app=app) - with patch("app.core.security.create_verification_token", return_value="fake-token"): + with patch("app.core.security.create_verification_token", return_value="fake-token"), \ + patch("app.routes.auth.EmailService.send_password_reset_email", new=AsyncMock()): async with AsyncClient(transport=transport, base_url="http://test") as c: response = await c.post( f"{BASE}/forgot-password", @@ -756,3 +780,535 @@ async def mock_get_db(): data = response.json() assert data["verified"] is True assert "already verified" in data["message"].lower() + + +# =========================================================================== +# POST /auth/google +# =========================================================================== + +class TestGoogleLogin: + + async def test_google_login_invalid_token_returns_401(self, client): + """An unverifiable Google ID token returns 401.""" + with patch("app.core.security.verify_google_token", new=AsyncMock(return_value=None)), \ + patch("app.core.firebase_auth.verify_firebase_token", return_value=None): + response = await client.post( + f"{BASE}/google", + json={"id_token": "bad-token", "source": "app"}, + ) + assert response.status_code == 401 + + async def test_google_login_new_user_created_returns_200(self): + """A first-time Google sign-in (email_verified=True) creates a new user.""" + from app.main import app + from app.core.database import get_db + + session = _make_mock_session(scalar_one_or_none_value=None) + + async def mock_get_db(): + yield session + + app.dependency_overrides[get_db] = mock_get_db + transport = ASGITransport(app=app) + + google_info = {"email": "newgoogle@example.com", "email_verified": True, "name": "New Google User"} + with patch("app.core.security.verify_google_token", new=AsyncMock(return_value=google_info)), \ + patch("app.routes.auth._ensure_unique_username", new=AsyncMock(return_value="newgoogleuser")), \ + patch("app.routes.auth._get_role_id", new=AsyncMock(return_value=None)), \ + patch( + "app.services.starter_reward_service.StarterRewardService.grant_new_user_reward", + new=AsyncMock(), + ): + async with AsyncClient(transport=transport, base_url="http://test") as c: + response = await c.post( + f"{BASE}/google", + json={"id_token": "good-token", "source": "app"}, + ) + app.dependency_overrides.clear() + + assert response.status_code == 200 + data = response.json() + assert "access_token" in data + + async def test_google_login_links_unverified_local_account_when_email_verified(self): + """Google vouching for the email (email_verified=True) verifies and links + an existing unverified local-only account instead of blocking it (Phase 2.2).""" + from app.main import app + from app.core.database import get_db + + local_user = _make_mock_user(is_verified=False, provider=["local"]) + session = _make_mock_session(scalar_one_or_none_value=local_user) + + async def mock_get_db(): + yield session + + app.dependency_overrides[get_db] = mock_get_db + transport = ASGITransport(app=app) + + google_info = {"email": "test@example.com", "email_verified": True, "name": "Test User"} + with patch("app.core.security.verify_google_token", new=AsyncMock(return_value=google_info)): + async with AsyncClient(transport=transport, base_url="http://test") as c: + response = await c.post( + f"{BASE}/google", + json={"id_token": "good-token", "source": "app"}, + ) + app.dependency_overrides.clear() + + assert response.status_code == 200 + data = response.json() + assert "access_token" in data + assert local_user.is_verified is True + assert "google" in local_user.provider + + async def test_google_login_local_account_blocked_when_email_not_verified(self): + """If Google has NOT verified the email, an unverified local account stays blocked.""" + from app.main import app + from app.core.database import get_db + + local_user = _make_mock_user(is_verified=False, provider=["local"]) + session = _make_mock_session(scalar_one_or_none_value=local_user) + + async def mock_get_db(): + yield session + + app.dependency_overrides[get_db] = mock_get_db + transport = ASGITransport(app=app) + + google_info = {"email": "test@example.com", "email_verified": False, "name": "Test User"} + with patch("app.core.security.verify_google_token", new=AsyncMock(return_value=google_info)): + async with AsyncClient(transport=transport, base_url="http://test") as c: + response = await c.post( + f"{BASE}/google", + json={"id_token": "good-token", "source": "app"}, + ) + app.dependency_overrides.clear() + + assert response.status_code == 400 + + async def test_google_login_admin_source_not_allowlisted_new_user_returns_403(self): + """Admin-source Google login for a brand-new, non-allowlisted email is rejected.""" + from app.main import app + from app.core.database import get_db + + session = _make_mock_session(scalar_one_or_none_value=None) + + async def mock_get_db(): + yield session + + app.dependency_overrides[get_db] = mock_get_db + transport = ASGITransport(app=app) + + google_info = {"email": "stranger@example.com", "email_verified": True, "name": "Stranger"} + with patch("app.core.security.verify_google_token", new=AsyncMock(return_value=google_info)), \ + patch("app.core.config.settings.GOOGLE_ADMIN_CLIENT_ID", "admin-client-id"), \ + patch("app.core.config.Settings.get_admin_role_for_email", return_value=None): + async with AsyncClient(transport=transport, base_url="http://test") as c: + response = await c.post( + f"{BASE}/google", + json={"id_token": "good-token", "source": "admin"}, + ) + app.dependency_overrides.clear() + + assert response.status_code == 403 + + +# =========================================================================== +# POST /auth/facebook +# =========================================================================== + +class TestFacebookLogin: + + async def test_facebook_login_invalid_token_returns_401(self, client): + """An unverifiable Firebase ID token returns 401.""" + with patch("app.core.firebase_auth.verify_firebase_token", return_value=None): + response = await client.post( + f"{BASE}/facebook", + json={"id_token": "bad-token", "source": "app"}, + ) + assert response.status_code == 401 + + async def test_facebook_login_success_returns_200(self, client): + """A valid Facebook (Firebase) token returns tokens for an active user.""" + fb_user = _make_mock_user(is_active=True) + claims = { + "email": fb_user.email, + "email_verified": True, + "firebase": {"sign_in_provider": "facebook.com"}, + } + with patch("app.core.firebase_auth.verify_firebase_token", return_value=claims), \ + patch("app.core.firebase_auth.get_or_create_user_from_claims", new=AsyncMock(return_value=fb_user)): + response = await client.post( + f"{BASE}/facebook", + json={"id_token": "good-token", "source": "app"}, + ) + assert response.status_code == 200 + data = response.json() + assert "access_token" in data + + async def test_facebook_login_inactive_user_returns_403(self, client): + """An inactive account cannot complete Facebook login.""" + fb_user = _make_mock_user(is_active=False) + claims = { + "email": fb_user.email, + "email_verified": True, + "firebase": {"sign_in_provider": "facebook.com"}, + } + with patch("app.core.firebase_auth.verify_firebase_token", return_value=claims), \ + patch("app.core.firebase_auth.get_or_create_user_from_claims", new=AsyncMock(return_value=fb_user)): + response = await client.post( + f"{BASE}/facebook", + json={"id_token": "good-token", "source": "app"}, + ) + assert response.status_code == 403 + + +# =========================================================================== +# POST /auth/admin/login +# =========================================================================== + +class TestAdminLogin: + + def _admin_user(self, *, is_active=True, is_verified=True, role_level=1): + user = _make_mock_user(is_active=is_active, is_verified=is_verified) + user.role_level = role_level + return user + + async def test_admin_login_success_returns_200_with_tokens(self): + from app.main import app + from app.core.database import get_db + + admin_user = self._admin_user() + session = _make_mock_session(scalar_one_or_none_value=admin_user) + + async def mock_get_db(): + yield session + + app.dependency_overrides[get_db] = mock_get_db + transport = ASGITransport(app=app) + + with patch("app.routes.auth.verify_password_async", new=AsyncMock(return_value=True)): + async with AsyncClient(transport=transport, base_url="http://test") as c: + response = await c.post( + f"{BASE}/admin/login", + json={"email": "admin@example.com", "password": "testpass"}, + ) + app.dependency_overrides.clear() + + assert response.status_code == 200 + assert "access_token" in response.json()["data"] + + async def test_admin_login_insufficient_role_returns_403(self): + from app.main import app + from app.core.database import get_db + + regular_user = self._admin_user(role_level=0) + session = _make_mock_session(scalar_one_or_none_value=regular_user) + + async def mock_get_db(): + yield session + + app.dependency_overrides[get_db] = mock_get_db + transport = ASGITransport(app=app) + + with patch("app.routes.auth.verify_password_async", new=AsyncMock(return_value=True)): + async with AsyncClient(transport=transport, base_url="http://test") as c: + response = await c.post( + f"{BASE}/admin/login", + json={"email": "user@example.com", "password": "testpass"}, + ) + app.dependency_overrides.clear() + + assert response.status_code == 403 + assert "Admin privileges" in response.json()["error"]["message"] + + async def test_admin_login_unverified_returns_403(self): + """An admin account that hasn't verified its email cannot log in (Phase 2.1).""" + from app.main import app + from app.core.database import get_db + + admin_user = self._admin_user(is_verified=False) + session = _make_mock_session(scalar_one_or_none_value=admin_user) + + async def mock_get_db(): + yield session + + app.dependency_overrides[get_db] = mock_get_db + transport = ASGITransport(app=app) + + with patch("app.routes.auth.verify_password_async", new=AsyncMock(return_value=True)): + async with AsyncClient(transport=transport, base_url="http://test") as c: + response = await c.post( + f"{BASE}/admin/login", + json={"email": "admin@example.com", "password": "testpass"}, + ) + app.dependency_overrides.clear() + + assert response.status_code == 403 + assert "not verified" in response.json()["error"]["message"].lower() + + async def test_admin_login_inactive_returns_403(self): + from app.main import app + from app.core.database import get_db + + admin_user = self._admin_user(is_active=False) + session = _make_mock_session(scalar_one_or_none_value=admin_user) + + async def mock_get_db(): + yield session + + app.dependency_overrides[get_db] = mock_get_db + transport = ASGITransport(app=app) + + with patch("app.routes.auth.verify_password_async", new=AsyncMock(return_value=True)): + async with AsyncClient(transport=transport, base_url="http://test") as c: + response = await c.post( + f"{BASE}/admin/login", + json={"email": "admin@example.com", "password": "testpass"}, + ) + app.dependency_overrides.clear() + + assert response.status_code == 403 + assert "inactive" in response.json()["error"]["message"].lower() + + async def test_admin_login_wrong_password_returns_401(self): + from app.main import app + from app.core.database import get_db + + admin_user = self._admin_user() + session = _make_mock_session(scalar_one_or_none_value=admin_user) + + async def mock_get_db(): + yield session + + app.dependency_overrides[get_db] = mock_get_db + transport = ASGITransport(app=app) + + with patch("app.routes.auth.verify_password_async", new=AsyncMock(return_value=False)): + async with AsyncClient(transport=transport, base_url="http://test") as c: + response = await c.post( + f"{BASE}/admin/login", + json={"email": "admin@example.com", "password": "wrongpass"}, + ) + app.dependency_overrides.clear() + + assert response.status_code == 401 + + +# =========================================================================== +# POST /auth/admin/request-otp, /auth/admin/verify-otp +# =========================================================================== + +class TestAdminOtp: + + async def test_request_otp_unknown_email_still_200(self, client): + """Anti-enumeration: unknown email returns 200 same as a real admin.""" + response = await client.post( + f"{BASE}/admin/request-otp", + json={"email": "ghost@example.com"}, + ) + assert response.status_code == 200 + + async def test_request_otp_unverified_admin_still_200_but_no_otp_sent(self): + """An unverified admin gets the same generic 200 response (Phase 2.1), but no + OTP is actually generated for them — verified implicitly via verify-otp failing.""" + from app.main import app + from app.core.database import get_db + from app.routes import auth as auth_routes + + unverified_admin = _make_mock_user(is_verified=False) + unverified_admin.role_level = 1 + session = _make_mock_session(scalar_one_or_none_value=unverified_admin) + + async def mock_get_db(): + yield session + + app.dependency_overrides[get_db] = mock_get_db + auth_routes._admin_otp_store.clear() + transport = ASGITransport(app=app) + + async with AsyncClient(transport=transport, base_url="http://test") as c: + response = await c.post( + f"{BASE}/admin/request-otp", + json={"email": unverified_admin.email}, + ) + app.dependency_overrides.clear() + + assert response.status_code == 200 + assert unverified_admin.email not in auth_routes._admin_otp_store + + async def test_verify_otp_invalid_code_returns_400(self, client): + response = await client.post( + f"{BASE}/admin/verify-otp", + json={"email": "admin@example.com", "otp": "000000"}, + ) + assert response.status_code == 400 + + async def test_verify_otp_unverified_admin_returns_403(self): + """A valid OTP cannot bypass the is_verified check on verify-otp (Phase 2.1).""" + from app.main import app + from app.core.database import get_db + from app.routes import auth as auth_routes + import time + + unverified_admin = _make_mock_user(is_verified=False) + unverified_admin.role_level = 1 + session = _make_mock_session(scalar_one_or_none_value=unverified_admin) + + async def mock_get_db(): + yield session + + app.dependency_overrides[get_db] = mock_get_db + auth_routes._admin_otp_store[unverified_admin.email] = ("123456", time.time() + 300) + transport = ASGITransport(app=app) + + async with AsyncClient(transport=transport, base_url="http://test") as c: + response = await c.post( + f"{BASE}/admin/verify-otp", + json={"email": unverified_admin.email, "otp": "123456"}, + ) + app.dependency_overrides.clear() + + assert response.status_code == 403 + + async def test_verify_otp_success_returns_tokens(self): + from app.main import app + from app.core.database import get_db + from app.routes import auth as auth_routes + import time + + admin_user = _make_mock_user(is_verified=True) + admin_user.role_level = 1 + session = _make_mock_session(scalar_one_or_none_value=admin_user) + + async def mock_get_db(): + yield session + + app.dependency_overrides[get_db] = mock_get_db + auth_routes._admin_otp_store[admin_user.email] = ("654321", time.time() + 300) + transport = ASGITransport(app=app) + + async with AsyncClient(transport=transport, base_url="http://test") as c: + response = await c.post( + f"{BASE}/admin/verify-otp", + json={"email": admin_user.email, "otp": "654321"}, + ) + app.dependency_overrides.clear() + + assert response.status_code == 200 + assert "access_token" in response.json()["data"] + + +# =========================================================================== +# POST /auth/change-password +# =========================================================================== + +class TestChangePassword: + + async def test_change_password_success_returns_200(self, auth_client): + with patch("app.routes.auth.verify_password_async", new=AsyncMock(return_value=True)): + response = await auth_client.post( + f"{BASE}/change-password", + json={"current_password": "oldpass123", "new_password": "newpass456"}, + ) + assert response.status_code == 200 + + async def test_change_password_wrong_current_password_returns_400(self, auth_client): + with patch("app.routes.auth.verify_password_async", new=AsyncMock(return_value=False)): + response = await auth_client.post( + f"{BASE}/change-password", + json={"current_password": "wrongpass", "new_password": "newpass456"}, + ) + assert response.status_code == 400 + assert "incorrect" in response.json()["error"]["message"].lower() + + async def test_change_password_oauth_only_account_returns_400(self): + """An OAuth-only account (no 'local' provider) cannot change a password it doesn't have.""" + from app.main import app + from app.core.database import get_db + from app.core.dependencies import get_current_user + + oauth_user = _make_mock_user(provider=["google"]) + session = _make_mock_session() + + async def mock_get_db(): + yield session + + async def mock_get_current_user(): + return oauth_user + + app.dependency_overrides[get_db] = mock_get_db + app.dependency_overrides[get_current_user] = mock_get_current_user + transport = ASGITransport(app=app) + + with patch("app.routes.auth.verify_password_async", new=AsyncMock(return_value=True)): + async with AsyncClient(transport=transport, base_url="http://test") as c: + response = await c.post( + f"{BASE}/change-password", + json={"current_password": "whatever", "new_password": "newpass456"}, + ) + app.dependency_overrides.clear() + + assert response.status_code == 400 + assert "OAuth" in response.json()["error"]["message"] + + +# =========================================================================== +# POST /auth/resend-verification +# =========================================================================== + +class TestResendVerification: + + async def test_resend_verification_unknown_email_still_200(self, client): + """Anti-enumeration: unknown email returns the same generic 200.""" + response = await client.post( + f"{BASE}/resend-verification", + json={"email": "ghost@example.com"}, + ) + assert response.status_code == 200 + + async def test_resend_verification_unverified_user_returns_200(self): + from app.main import app + from app.core.database import get_db + + unverified_user = _make_mock_user(is_verified=False) + session = _make_mock_session(scalar_one_or_none_value=unverified_user) + + async def mock_get_db(): + yield session + + app.dependency_overrides[get_db] = mock_get_db + transport = ASGITransport(app=app) + + with patch("app.core.security.create_verification_token", return_value="fake-token"), \ + patch("app.services.email_service.EmailService.send_verification_email", new=AsyncMock()): + async with AsyncClient(transport=transport, base_url="http://test") as c: + response = await c.post( + f"{BASE}/resend-verification", + json={"email": unverified_user.email}, + ) + app.dependency_overrides.clear() + + assert response.status_code == 200 + + async def test_resend_verification_already_verified_user_returns_200(self): + """Already-verified users get the same generic response (no email sent).""" + from app.main import app + from app.core.database import get_db + + verified_user = _make_mock_user(is_verified=True) + session = _make_mock_session(scalar_one_or_none_value=verified_user) + + async def mock_get_db(): + yield session + + app.dependency_overrides[get_db] = mock_get_db + transport = ASGITransport(app=app) + + async with AsyncClient(transport=transport, base_url="http://test") as c: + response = await c.post( + f"{BASE}/resend-verification", + json={"email": verified_user.email}, + ) + app.dependency_overrides.clear() + + assert response.status_code == 200 From e2360c1a02d7994c696f19530e0ee95c3d45dcab Mon Sep 17 00:00:00 2001 From: Thang Nguyen Huu <2001230909@hufi.edu.vn> Date: Wed, 24 Jun 2026 11:25:14 +0700 Subject: [PATCH 03/20] feat(flutter): map AUTH_FORBIDDEN backend error code to AuthFailure Backend's admin/verification routes return AUTH_FORBIDDEN for inactive/unverified accounts, but the client had no mapping for it and fell through to a generic server-error message. Maps it alongside the other AUTH_* codes so the UI surfaces the real reason. Co-Authored-By: Claude Sonnet 4.6 --- flutter-app/lib/core/network/response_models.dart | 1 + .../features/auth/data/repositories/auth_repository_impl.dart | 1 + 2 files changed, 2 insertions(+) diff --git a/flutter-app/lib/core/network/response_models.dart b/flutter-app/lib/core/network/response_models.dart index 11d406f1..6a712424 100644 --- a/flutter-app/lib/core/network/response_models.dart +++ b/flutter-app/lib/core/network/response_models.dart @@ -212,6 +212,7 @@ class ErrorCodes { static const String authInvalid = 'AUTH_INVALID'; static const String authExpired = 'AUTH_EXPIRED'; static const String authMissing = 'AUTH_MISSING'; + static const String authForbidden = 'AUTH_FORBIDDEN'; static const String permissionDenied = 'PERMISSION_DENIED'; // Resource Errors diff --git a/flutter-app/lib/features/auth/data/repositories/auth_repository_impl.dart b/flutter-app/lib/features/auth/data/repositories/auth_repository_impl.dart index f43bb9ac..c080e93d 100644 --- a/flutter-app/lib/features/auth/data/repositories/auth_repository_impl.dart +++ b/flutter-app/lib/features/auth/data/repositories/auth_repository_impl.dart @@ -278,6 +278,7 @@ class AuthRepositoryImpl implements AuthRepository { case ErrorCodes.authInvalid: case ErrorCodes.authExpired: case ErrorCodes.authMissing: + case ErrorCodes.authForbidden: return AuthFailure(e.message); case ErrorCodes.validationError: From 63520451eb848088fc1085ac7cec4c2497f4ce53 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Huu <2001230909@hufi.edu.vn> Date: Wed, 24 Jun 2026 11:25:30 +0700 Subject: [PATCH 04/20] refactor(flutter): separate chat domain entities from data-layer models story_repository.dart (the domain interface) and presentation widgets imported data/models/* directly, violating Clean Architecture's domain-never-imports-data rule. Adds Story/StoryListItem/TopicSession/ EducationalHints entities under domain/entities/, gives each data model a toEntity() mapper, and updates the repository implementation and all presentation call sites to depend on entities instead of models. Co-Authored-By: Claude Sonnet 4.6 --- .../data/models/educational_hints_model.dart | 44 ++++ .../chat/data/models/story_model.dart | 123 ++++++++-- .../chat/data/models/topic_session_model.dart | 48 ++++ .../repositories/story_repository_impl.dart | 33 ++- .../domain/entities/educational_hints.dart | 67 ++++++ .../features/chat/domain/entities/story.dart | 215 ++++++++++++++++++ .../chat/domain/entities/topic_session.dart | 131 +++++++++++ .../domain/repositories/story_repository.dart | 4 +- .../pages/story_selection_page.dart | 2 +- .../presentation/pages/topic_chat_page.dart | 4 +- .../providers/story_provider.dart | 18 +- .../utils/topic_icon_resolver.dart | 2 +- .../widgets/educational_hints_widgets.dart | 2 +- .../chat/presentation/widgets/topic_card.dart | 2 +- .../chat/quick_save_chat_widgets_test.dart | 2 +- 15 files changed, 648 insertions(+), 49 deletions(-) create mode 100644 flutter-app/lib/features/chat/domain/entities/educational_hints.dart create mode 100644 flutter-app/lib/features/chat/domain/entities/story.dart create mode 100644 flutter-app/lib/features/chat/domain/entities/topic_session.dart diff --git a/flutter-app/lib/features/chat/data/models/educational_hints_model.dart b/flutter-app/lib/features/chat/data/models/educational_hints_model.dart index e3afc36a..6e99d8d3 100644 --- a/flutter-app/lib/features/chat/data/models/educational_hints_model.dart +++ b/flutter-app/lib/features/chat/data/models/educational_hints_model.dart @@ -2,6 +2,8 @@ /// Provides grammar corrections and vocabulary hints from AI responses library; +import '../../domain/entities/educational_hints.dart' as entities; + /// Grammar correction from AI class GrammarCorrection { final String original; @@ -162,3 +164,45 @@ class LlmMetadata { 'fallback_used': fallbackUsed, }; } + +// --------------------------------------------------------------------------- +// Domain entity mappers — see story_model.dart for rationale. +// --------------------------------------------------------------------------- + +extension GrammarCorrectionToEntity on GrammarCorrection { + entities.GrammarCorrection toEntity() => entities.GrammarCorrection( + original: original, + corrected: corrected, + explanation: explanation, + errorType: errorType, + rule: rule, + ); +} + +extension VocabularyHintToEntity on VocabularyHint { + entities.VocabularyHint toEntity() => entities.VocabularyHint( + term: term, + definition: definition, + example: example, + partOfSpeech: partOfSpeech, + pronunciation: pronunciation, + ); +} + +extension EducationalHintsToEntity on EducationalHints { + entities.EducationalHints toEntity() => entities.EducationalHints( + grammarCorrections: grammarCorrections.map((g) => g.toEntity()).toList(), + vocabularyHints: vocabularyHints.map((v) => v.toEntity()).toList(), + encouragement: encouragement, + nextSuggestion: nextSuggestion, + ); +} + +extension LlmMetadataToEntity on LlmMetadata { + entities.LlmMetadata toEntity() => entities.LlmMetadata( + provider: provider, + model: model, + latencyMs: latencyMs, + fallbackUsed: fallbackUsed, + ); +} diff --git a/flutter-app/lib/features/chat/data/models/story_model.dart b/flutter-app/lib/features/chat/data/models/story_model.dart index 13aafe11..8a45bc57 100644 --- a/flutter-app/lib/features/chat/data/models/story_model.dart +++ b/flutter-app/lib/features/chat/data/models/story_model.dart @@ -3,31 +3,12 @@ /// Defines story/topic data structures for language learning scenarios library; -/// Difficulty levels matching CEFR standard -enum DifficultyLevel { - A1('A1', 'Beginner'), - A2('A2', 'Elementary'), - B1('B1', 'Intermediate'), - B2('B2', 'Upper Intermediate'), - C1('C1', 'Advanced'), - C2('C2', 'Proficiency'); - - final String code; - final String label; - - const DifficultyLevel(this.code, this.label); - - String get displayName => '$code - $label'; - String get shortName => code; - - static DifficultyLevel fromString(String? value) { - if (value == null) return DifficultyLevel.A1; - return DifficultyLevel.values.firstWhere( - (e) => e.code.toUpperCase() == value.toUpperCase(), - orElse: () => DifficultyLevel.A1, - ); - } -} +// DifficultyLevel is a domain concept owned by domain/entities/story.dart. +// Re-exported here so existing callers of this model file keep working +// without each needing to import the entity file directly. +import '../../domain/entities/story.dart' show DifficultyLevel; +export '../../domain/entities/story.dart' show DifficultyLevel; +import '../../domain/entities/story.dart' as entities; /// Localized title with Vietnamese and English class LocalizedTitle { @@ -458,3 +439,95 @@ class StoryListItem { 'tags': tags, }; } + +// --------------------------------------------------------------------------- +// Domain entity mappers — convert wire-format models into the pure domain +// entities defined in domain/entities/story.dart, used at the repository +// boundary so domain/presentation never depend on these JSON-aware models. +// --------------------------------------------------------------------------- + +extension LocalizedTitleToEntity on LocalizedTitle { + entities.LocalizedTitle toEntity() => + entities.LocalizedTitle(vi: vi, en: en); +} + +extension VocabularyItemToEntity on VocabularyItem { + entities.VocabularyItem toEntity() => entities.VocabularyItem( + term: term, + definition: definition, + exampleInStory: exampleInStory, + partOfSpeech: partOfSpeech, + phonetic: phonetic, + ); +} + +extension GrammarPointToEntity on GrammarPoint { + entities.GrammarPoint toEntity() => entities.GrammarPoint( + grammarStructure: grammarStructure, + explanation: explanation, + usageInStory: usageInStory, + examples: examples, + ); +} + +extension RolePersonaToEntity on RolePersona { + entities.RolePersona toEntity() => entities.RolePersona( + name: name, + role: role, + personality: personality, + speakingStyle: speakingStyle, + background: background, + ); +} + +extension ContextDescriptionToEntity on ContextDescription { + entities.ContextDescription toEntity() => entities.ContextDescription( + setting: setting, + scenario: scenario, + objectives: objectives, + ); +} + +extension ConversationFlowToEntity on ConversationFlow { + entities.ConversationFlow toEntity() => entities.ConversationFlow( + openingPrompt: openingPrompt, + keyMilestones: keyMilestones, + closingScenarios: closingScenarios, + ); +} + +extension StoryToEntity on Story { + entities.Story toEntity() => entities.Story( + storyId: storyId, + title: title.toEntity(), + difficultyLevel: difficultyLevel, + category: category, + estimatedMinutes: estimatedMinutes, + iconKey: iconKey, + coverImageUrl: coverImageUrl, + contextDescription: contextDescription.toEntity(), + rolePersona: rolePersona.toEntity(), + vocabularyList: vocabularyList.map((v) => v.toEntity()).toList(), + grammarPoints: grammarPoints.map((g) => g.toEntity()).toList(), + conversationFlow: conversationFlow.toEntity(), + isPublished: isPublished, + suggestedPrompts: suggestedPrompts, + tags: tags, + createdAt: createdAt, + updatedAt: updatedAt, + ); +} + +extension StoryListItemToEntity on StoryListItem { + entities.StoryListItem toEntity() => entities.StoryListItem( + storyId: storyId, + title: title.toEntity(), + difficultyLevel: difficultyLevel, + category: category, + estimatedMinutes: estimatedMinutes, + iconKey: iconKey, + coverImageUrl: coverImageUrl, + suggestedPrompts: suggestedPrompts, + tags: tags, + ); +} diff --git a/flutter-app/lib/features/chat/data/models/topic_session_model.dart b/flutter-app/lib/features/chat/data/models/topic_session_model.dart index fcc8040b..7994b755 100644 --- a/flutter-app/lib/features/chat/data/models/topic_session_model.dart +++ b/flutter-app/lib/features/chat/data/models/topic_session_model.dart @@ -4,6 +4,7 @@ library; import 'story_model.dart'; import 'educational_hints_model.dart'; +import '../../domain/entities/topic_session.dart' as entities; /// Request to start a topic session class StartTopicSessionRequest { @@ -288,3 +289,50 @@ class StoriesListResponse { 'limit': limit, }; } + +// --------------------------------------------------------------------------- +// Domain entity mappers — see story_model.dart for rationale. +// --------------------------------------------------------------------------- + +extension TopicSessionToEntity on TopicSession { + entities.TopicSession toEntity() => entities.TopicSession( + sessionId: sessionId, + story: story.toEntity(), + rolePersona: rolePersona.toEntity(), + openingMessage: openingMessage, + vocabularyPreview: vocabularyPreview.map((v) => v.toEntity()).toList(), + createdAt: createdAt, + ); +} + +extension TopicChatResponseToEntity on TopicChatResponse { + entities.TopicChatResponse toEntity() => entities.TopicChatResponse( + response: response, + messageIdOverride: messageId, + educationalHints: educationalHints?.toEntity(), + processingTimeMs: processingTimeMs, + llmMetadata: llmMetadata?.toEntity(), + ); +} + +extension TopicChatMessageToEntity on TopicChatMessage { + entities.TopicChatMessage toEntity() => entities.TopicChatMessage( + id: id, + sessionId: sessionId, + content: content, + isUser: isUser, + timestamp: timestamp, + hints: hints?.toEntity(), + llmMetadata: llmMetadata?.toEntity(), + ); +} + +extension TopicMessagesPageResultToEntity on TopicMessagesPageResult { + entities.TopicMessagesPageResult toEntity() => + entities.TopicMessagesPageResult( + messages: messages.map((m) => m.toEntity()).toList(), + hasMore: hasMore, + nextCursor: nextCursor, + returned: returned, + ); +} diff --git a/flutter-app/lib/features/chat/data/repositories/story_repository_impl.dart b/flutter-app/lib/features/chat/data/repositories/story_repository_impl.dart index a17647d8..91c1df81 100644 --- a/flutter-app/lib/features/chat/data/repositories/story_repository_impl.dart +++ b/flutter-app/lib/features/chat/data/repositories/story_repository_impl.dart @@ -3,10 +3,25 @@ import 'package:dartz/dartz.dart'; import '../../../../core/error/exceptions.dart'; import '../../../../core/error/failures.dart'; import '../../../../core/utils/app_logger.dart'; +import '../../domain/entities/story.dart'; +import '../../domain/entities/topic_session.dart'; import '../../domain/repositories/story_repository.dart'; import '../datasources/story_api_data_source.dart'; -import '../models/story_model.dart'; -import '../models/topic_session_model.dart'; +// Only the toEntity() extensions are needed here — the model classes +// themselves would collide with the same-named domain entities above. +import '../models/story_model.dart' + hide + Story, + StoryListItem, + DifficultyLevel, + LocalizedTitle, + VocabularyItem, + GrammarPoint, + RolePersona, + ContextDescription, + ConversationFlow; +import '../models/topic_session_model.dart' + hide TopicSession, TopicChatResponse, TopicChatMessage, TopicMessagesPageResult; const _tag = 'StoryRepositoryImpl'; @@ -28,7 +43,7 @@ class StoryRepositoryImpl implements StoryRepository { difficultyLevel: difficultyLevel, limit: limit, ); - return Right(stories); + return Right(stories.map((s) => s.toEntity()).toList()); } on ServerException catch (e) { logError(_tag, 'getStories server error: $e'); return Left(ServerFailure(e.message)); @@ -42,7 +57,7 @@ class StoryRepositoryImpl implements StoryRepository { Future> getStoryDetails(String storyId) async { try { final story = await apiDataSource.getStoryDetails(storyId); - return Right(story); + return Right(story.toEntity()); } on ServerException catch (e) { logError(_tag, 'getStoryDetails server error: $e'); return Left(ServerFailure(e.message)); @@ -100,7 +115,7 @@ class StoryRepositoryImpl implements StoryRepository { sessionTitle: sessionTitle, preferredLlm: preferredLlm, ); - return Right(session); + return Right(session.toEntity()); } on ServerException catch (e) { logError(_tag, 'startTopicSession server error: $e'); return Left(ServerFailure(e.message)); @@ -122,7 +137,7 @@ class StoryRepositoryImpl implements StoryRepository { userId: userId, message: message, ); - return Right(response); + return Right(response.toEntity()); } on ServerException catch (e) { logError(_tag, 'sendTopicMessage server error: $e'); return Left(ServerFailure(e.message)); @@ -138,7 +153,7 @@ class StoryRepositoryImpl implements StoryRepository { ) async { try { final session = await apiDataSource.getTopicSession(sessionId); - return Right(session); + return Right(session.toEntity()); } on ServerException catch (e) { logError(_tag, 'getTopicSession server error: $e'); return Left(ServerFailure(e.message)); @@ -154,7 +169,7 @@ class StoryRepositoryImpl implements StoryRepository { ) async { try { final messages = await apiDataSource.getTopicMessages(sessionId); - return Right(messages); + return Right(messages.map((m) => m.toEntity()).toList()); } on ServerException catch (e) { logError(_tag, 'getTopicMessages server error: $e'); return Left(ServerFailure(e.message)); @@ -200,7 +215,7 @@ class StoryRepositoryImpl implements StoryRepository { limit: limit, cursor: cursor, ); - return Right(page); + return Right(page.toEntity()); } on ServerException catch (e) { logError(_tag, 'getTopicMessagesPaged server error: $e'); return Left(ServerFailure(e.message)); diff --git a/flutter-app/lib/features/chat/domain/entities/educational_hints.dart b/flutter-app/lib/features/chat/domain/entities/educational_hints.dart new file mode 100644 index 00000000..59c99297 --- /dev/null +++ b/flutter-app/lib/features/chat/domain/entities/educational_hints.dart @@ -0,0 +1,67 @@ +/// Domain entities for AI educational hints (grammar/vocabulary feedback). +/// Pure Dart value objects — no JSON/serialization dependency. +library; + +class GrammarCorrection { + final String original; + final String corrected; + final String explanation; + final String? errorType; + final String? rule; + + const GrammarCorrection({ + required this.original, + required this.corrected, + required this.explanation, + this.errorType, + this.rule, + }); +} + +class VocabularyHint { + final String term; + final String definition; + final String? example; + final String? partOfSpeech; + final String? pronunciation; + + const VocabularyHint({ + required this.term, + required this.definition, + this.example, + this.partOfSpeech, + this.pronunciation, + }); +} + +class EducationalHints { + final List grammarCorrections; + final List vocabularyHints; + final String? encouragement; + final String? nextSuggestion; + + const EducationalHints({ + this.grammarCorrections = const [], + this.vocabularyHints = const [], + this.encouragement, + this.nextSuggestion, + }); + + bool get hasGrammarHints => grammarCorrections.isNotEmpty; + bool get hasVocabularyHints => vocabularyHints.isNotEmpty; + bool get hasAnyHints => hasGrammarHints || hasVocabularyHints; +} + +class LlmMetadata { + final String provider; + final String model; + final int? latencyMs; + final bool fallbackUsed; + + const LlmMetadata({ + required this.provider, + required this.model, + this.latencyMs, + this.fallbackUsed = false, + }); +} diff --git a/flutter-app/lib/features/chat/domain/entities/story.dart b/flutter-app/lib/features/chat/domain/entities/story.dart new file mode 100644 index 00000000..374002d9 --- /dev/null +++ b/flutter-app/lib/features/chat/domain/entities/story.dart @@ -0,0 +1,215 @@ +// ignore_for_file: constant_identifier_names +/// Domain entities for Story/Topic-based conversation. +/// Pure Dart value objects — no JSON/serialization dependency, no data-layer imports. +library; + +/// Difficulty levels matching CEFR standard +enum DifficultyLevel { + A1('A1', 'Beginner'), + A2('A2', 'Elementary'), + B1('B1', 'Intermediate'), + B2('B2', 'Upper Intermediate'), + C1('C1', 'Advanced'), + C2('C2', 'Proficiency'); + + final String code; + final String label; + + const DifficultyLevel(this.code, this.label); + + String get displayName => '$code - $label'; + String get shortName => code; + + static DifficultyLevel fromString(String? value) { + if (value == null) return DifficultyLevel.A1; + return DifficultyLevel.values.firstWhere( + (e) => e.code.toUpperCase() == value.toUpperCase(), + orElse: () => DifficultyLevel.A1, + ); + } +} + +class LocalizedTitle { + final String vi; + final String en; + + const LocalizedTitle({required this.vi, required this.en}); +} + +class VocabularyItem { + final String term; + final String definition; + final String exampleInStory; + final String partOfSpeech; + final String? phonetic; + + const VocabularyItem({ + required this.term, + required this.definition, + this.exampleInStory = '', + this.partOfSpeech = '', + this.phonetic, + }); +} + +class GrammarPoint { + final String grammarStructure; + final String explanation; + final String usageInStory; + final List examples; + + const GrammarPoint({ + required this.grammarStructure, + required this.explanation, + this.usageInStory = '', + this.examples = const [], + }); +} + +class RolePersona { + final String name; + final String role; + final String personality; + final String speakingStyle; + final String background; + + const RolePersona({ + required this.name, + required this.role, + required this.personality, + required this.speakingStyle, + required this.background, + }); +} + +class ContextDescription { + final String setting; + final String scenario; + final List objectives; + + const ContextDescription({ + required this.setting, + required this.scenario, + this.objectives = const [], + }); +} + +class ConversationFlow { + final String openingPrompt; + final List keyMilestones; + final List closingScenarios; + + const ConversationFlow({ + required this.openingPrompt, + this.keyMilestones = const [], + this.closingScenarios = const [], + }); +} + +/// Full story detail +class Story { + final String storyId; + final LocalizedTitle title; + final DifficultyLevel difficultyLevel; + final String category; + final int estimatedMinutes; + final String? iconKey; + final String? coverImageUrl; + final ContextDescription contextDescription; + final RolePersona rolePersona; + final List vocabularyList; + final List grammarPoints; + final ConversationFlow conversationFlow; + final bool isPublished; + final List suggestedPrompts; + final List tags; + final DateTime? createdAt; + final DateTime? updatedAt; + + const Story({ + required this.storyId, + required this.title, + required this.difficultyLevel, + required this.category, + this.estimatedMinutes = 15, + this.iconKey, + this.coverImageUrl, + required this.contextDescription, + required this.rolePersona, + this.vocabularyList = const [], + this.grammarPoints = const [], + required this.conversationFlow, + this.isPublished = true, + this.suggestedPrompts = const [], + this.tags = const [], + this.createdAt, + this.updatedAt, + }); +} + +/// Story list item for display in story selection. +/// +/// Carries its own [toCacheJson]/[fromCacheJson] pair purely for the +/// presentation layer's local "recently used" persistence — this is the +/// entity's own concern (how it survives a restart), not an API contract, +/// so it doesn't pull in the data-layer model. +class StoryListItem { + final String storyId; + final LocalizedTitle title; + final DifficultyLevel difficultyLevel; + final String category; + final int estimatedMinutes; + final String? iconKey; + final String? coverImageUrl; + final List suggestedPrompts; + final List tags; + + const StoryListItem({ + required this.storyId, + required this.title, + required this.difficultyLevel, + required this.category, + this.estimatedMinutes = 15, + this.iconKey, + this.coverImageUrl, + this.suggestedPrompts = const [], + this.tags = const [], + }); + + Map toCacheJson() => { + 'story_id': storyId, + 'title': {'vi': title.vi, 'en': title.en}, + 'category': category, + 'difficulty_level': difficultyLevel.code, + 'estimated_minutes': estimatedMinutes, + 'icon_key': iconKey, + 'cover_image_url': coverImageUrl, + 'suggested_prompts': suggestedPrompts, + 'tags': tags, + }; + + factory StoryListItem.fromCacheJson(Map json) { + return StoryListItem( + storyId: json['story_id'] as String? ?? '', + title: LocalizedTitle( + vi: (json['title'] as Map?)?['vi'] as String? ?? '', + en: (json['title'] as Map?)?['en'] as String? ?? '', + ), + category: json['category'] as String? ?? '', + difficultyLevel: DifficultyLevel.fromString( + json['difficulty_level'] as String?, + ), + estimatedMinutes: json['estimated_minutes'] as int? ?? 15, + iconKey: json['icon_key'] as String?, + coverImageUrl: json['cover_image_url'] as String?, + suggestedPrompts: + (json['suggested_prompts'] as List?) + ?.map((e) => e.toString()) + .toList() ?? + [], + tags: + (json['tags'] as List?)?.map((e) => e.toString()).toList() ?? + [], + ); + } +} diff --git a/flutter-app/lib/features/chat/domain/entities/topic_session.dart b/flutter-app/lib/features/chat/domain/entities/topic_session.dart new file mode 100644 index 00000000..c96ec995 --- /dev/null +++ b/flutter-app/lib/features/chat/domain/entities/topic_session.dart @@ -0,0 +1,131 @@ +/// Domain entities for Topic-Based Conversation sessions. +/// Pure Dart value objects — no JSON/serialization dependency, no data-layer imports. +library; + +import 'story.dart'; +import 'educational_hints.dart'; + +/// Response after starting a topic session +class TopicSession { + final String sessionId; + final StoryListItem story; + final RolePersona rolePersona; + final String openingMessage; + final List vocabularyPreview; + final DateTime createdAt; + + const TopicSession({ + required this.sessionId, + required this.story, + required this.rolePersona, + required this.openingMessage, + this.vocabularyPreview = const [], + required this.createdAt, + }); + + /// Local persistence only (the provider's "resume an active session" + /// cache) — not an API contract, so it lives on the entity itself rather + /// than pulling in the data-layer model. + Map toCacheJson() => { + 'session_id': sessionId, + 'story': story.toCacheJson(), + 'role_persona': { + 'name': rolePersona.name, + 'role': rolePersona.role, + 'personality': rolePersona.personality, + 'speaking_style': rolePersona.speakingStyle, + 'background': rolePersona.background, + }, + 'opening_message': openingMessage, + 'created_at': createdAt.toIso8601String(), + }; + + factory TopicSession.fromCacheJson(Map json) { + final personaJson = json['role_persona'] as Map? ?? {}; + return TopicSession( + sessionId: json['session_id'] as String? ?? '', + story: StoryListItem.fromCacheJson( + json['story'] as Map, + ), + rolePersona: RolePersona( + name: personaJson['name'] as String? ?? '', + role: personaJson['role'] as String? ?? '', + personality: personaJson['personality'] as String? ?? '', + speakingStyle: personaJson['speaking_style'] as String? ?? '', + background: personaJson['background'] as String? ?? '', + ), + openingMessage: json['opening_message'] as String? ?? '', + createdAt: + DateTime.tryParse(json['created_at']?.toString() ?? '') ?? + DateTime.now(), + ); + } +} + +/// Response from AI in a topic session +class TopicChatResponse { + final String response; + final String? messageIdOverride; + final EducationalHints? educationalHints; + final int? processingTimeMs; + final LlmMetadata? llmMetadata; + + const TopicChatResponse({ + required this.response, + this.messageIdOverride, + this.educationalHints, + this.processingTimeMs, + this.llmMetadata, + }); + + String get messageId => + messageIdOverride ?? DateTime.now().millisecondsSinceEpoch.toString(); + bool get hasHints => educationalHints?.hasAnyHints ?? false; +} + +/// A message in the topic chat +class TopicChatMessage { + final String id; + final String sessionId; + final String content; + final bool isUser; + final DateTime timestamp; + final EducationalHints? hints; + final LlmMetadata? llmMetadata; + + const TopicChatMessage({ + required this.id, + required this.sessionId, + required this.content, + required this.isUser, + required this.timestamp, + this.hints, + this.llmMetadata, + }); + + String get displayContent { + return content + .replaceAll( + RegExp(r'[\s\S]*?', caseSensitive: false), + '', + ) + .trim(); + } + + bool get hasHints => hints?.hasAnyHints ?? false; +} + +/// Cursor-based page result for topic chat messages. +class TopicMessagesPageResult { + final List messages; + final bool hasMore; + final String? nextCursor; + final int returned; + + const TopicMessagesPageResult({ + required this.messages, + required this.hasMore, + required this.nextCursor, + required this.returned, + }); +} diff --git a/flutter-app/lib/features/chat/domain/repositories/story_repository.dart b/flutter-app/lib/features/chat/domain/repositories/story_repository.dart index 450a75d9..bdb2111a 100644 --- a/flutter-app/lib/features/chat/domain/repositories/story_repository.dart +++ b/flutter-app/lib/features/chat/domain/repositories/story_repository.dart @@ -1,8 +1,8 @@ import 'package:dartz/dartz.dart'; import '../../../../core/error/failures.dart'; -import '../../data/models/story_model.dart'; -import '../../data/models/topic_session_model.dart'; +import '../entities/story.dart'; +import '../entities/topic_session.dart'; /// Repository interface for Story/Topic-based conversation abstract class StoryRepository { diff --git a/flutter-app/lib/features/chat/presentation/pages/story_selection_page.dart b/flutter-app/lib/features/chat/presentation/pages/story_selection_page.dart index 6b2e5b7a..7145a664 100644 --- a/flutter-app/lib/features/chat/presentation/pages/story_selection_page.dart +++ b/flutter-app/lib/features/chat/presentation/pages/story_selection_page.dart @@ -4,7 +4,7 @@ import 'package:lexilingo_app/core/widgets/cefr_badge.dart'; import 'package:lexilingo_app/core/widgets/lottie_loading_widget.dart'; import 'package:provider/provider.dart'; import 'package:lexilingo_app/core/theme/app_theme.dart'; -import '../../data/models/story_model.dart'; +import '../../domain/entities/story.dart'; import '../providers/story_provider.dart'; import '../utils/topic_icon_resolver.dart'; import 'topic_chat_page.dart'; diff --git a/flutter-app/lib/features/chat/presentation/pages/topic_chat_page.dart b/flutter-app/lib/features/chat/presentation/pages/topic_chat_page.dart index fd51f9bb..d70965ac 100644 --- a/flutter-app/lib/features/chat/presentation/pages/topic_chat_page.dart +++ b/flutter-app/lib/features/chat/presentation/pages/topic_chat_page.dart @@ -8,8 +8,8 @@ import 'package:lexilingo_app/core/theme/app_theme.dart'; import '../../../auth/presentation/providers/auth_provider.dart'; import '../../../lexi_chat/presentation/widgets/lexi_typing_indicator.dart'; -import '../../data/models/story_model.dart'; -import '../../data/models/topic_session_model.dart'; +import '../../domain/entities/story.dart'; +import '../../domain/entities/topic_session.dart'; import '../providers/story_provider.dart'; import '../widgets/educational_hints_widgets.dart'; diff --git a/flutter-app/lib/features/chat/presentation/providers/story_provider.dart b/flutter-app/lib/features/chat/presentation/providers/story_provider.dart index dd9e11f2..c1abda03 100644 --- a/flutter-app/lib/features/chat/presentation/providers/story_provider.dart +++ b/flutter-app/lib/features/chat/presentation/providers/story_provider.dart @@ -2,8 +2,8 @@ import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'dart:convert'; -import '../../data/models/story_model.dart'; -import '../../data/models/topic_session_model.dart'; +import '../../domain/entities/story.dart'; +import '../../domain/entities/topic_session.dart'; import '../../domain/repositories/story_repository.dart'; /// State management for Story/Topic-based conversation @@ -485,7 +485,7 @@ class StoryProvider extends ChangeNotifier { final prefs = await SharedPreferences.getInstance(); await prefs.setString( '$_topicSessionPrefix$storyId', - jsonEncode(session.toJson()), + jsonEncode(session.toCacheJson()), ); } catch (e) { debugPrint('Error saving topic session: $e'); @@ -497,7 +497,9 @@ class StoryProvider extends ChangeNotifier { final prefs = await SharedPreferences.getInstance(); final raw = prefs.getString('$_topicSessionPrefix$storyId'); if (raw == null) return null; - return TopicSession.fromJson(jsonDecode(raw) as Map); + return TopicSession.fromCacheJson( + jsonDecode(raw) as Map, + ); } catch (e) { debugPrint('Error loading saved topic session: $e'); return null; @@ -521,7 +523,7 @@ class StoryProvider extends ChangeNotifier { // Also cache full JSON for quick boot final jsonList = _recentlyUsed - .map((s) => jsonEncode(s.toJson())) + .map((s) => jsonEncode(s.toCacheJson())) .toList(); await prefs.setStringList('${_recentTopicsKey}_data', jsonList); } catch (e) { @@ -536,7 +538,11 @@ class StoryProvider extends ChangeNotifier { if (jsonList != null) { _recentlyUsed = jsonList - .map((s) => StoryListItem.fromJson(jsonDecode(s))) + .map( + (s) => StoryListItem.fromCacheJson( + jsonDecode(s) as Map, + ), + ) .toList(); notifyListeners(); } diff --git a/flutter-app/lib/features/chat/presentation/utils/topic_icon_resolver.dart b/flutter-app/lib/features/chat/presentation/utils/topic_icon_resolver.dart index 42b47e55..846d4082 100644 --- a/flutter-app/lib/features/chat/presentation/utils/topic_icon_resolver.dart +++ b/flutter-app/lib/features/chat/presentation/utils/topic_icon_resolver.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import '../../data/models/story_model.dart'; +import '../../domain/entities/story.dart'; class TopicIconResolver { const TopicIconResolver._(); diff --git a/flutter-app/lib/features/chat/presentation/widgets/educational_hints_widgets.dart b/flutter-app/lib/features/chat/presentation/widgets/educational_hints_widgets.dart index 8d121e0b..86324b23 100644 --- a/flutter-app/lib/features/chat/presentation/widgets/educational_hints_widgets.dart +++ b/flutter-app/lib/features/chat/presentation/widgets/educational_hints_widgets.dart @@ -1,7 +1,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import '../../data/models/educational_hints_model.dart'; +import '../../domain/entities/educational_hints.dart'; import 'package:lexilingo_app/core/theme/app_theme.dart'; /// Educational Hints Card diff --git a/flutter-app/lib/features/chat/presentation/widgets/topic_card.dart b/flutter-app/lib/features/chat/presentation/widgets/topic_card.dart index b88a4b90..73444a65 100644 --- a/flutter-app/lib/features/chat/presentation/widgets/topic_card.dart +++ b/flutter-app/lib/features/chat/presentation/widgets/topic_card.dart @@ -2,7 +2,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:lexilingo_app/core/widgets/cefr_badge.dart'; import 'package:lexilingo_app/core/widgets/lottie_loading_widget.dart'; -import '../../data/models/story_model.dart'; +import '../../domain/entities/story.dart'; import 'package:lexilingo_app/core/theme/app_theme.dart'; import '../utils/topic_icon_resolver.dart'; diff --git a/flutter-app/test/features/chat/quick_save_chat_widgets_test.dart b/flutter-app/test/features/chat/quick_save_chat_widgets_test.dart index 0ceb3dee..8bd54d83 100644 --- a/flutter-app/test/features/chat/quick_save_chat_widgets_test.dart +++ b/flutter-app/test/features/chat/quick_save_chat_widgets_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:lexilingo_app/core/widgets/quick_save_selection_area.dart'; -import 'package:lexilingo_app/features/chat/data/models/story_model.dart'; +import 'package:lexilingo_app/features/chat/domain/entities/story.dart'; import 'package:lexilingo_app/features/chat/presentation/pages/topic_chat_page.dart'; import 'package:lexilingo_app/features/lexi_chat/domain/entities/lexi_message.dart'; import 'package:lexilingo_app/features/lexi_chat/presentation/widgets/lexi_dialogue_bubble.dart'; From 36e8c5308de8941e4d99ffe8b265c50ca8cf0f34 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Huu <2001230909@hufi.edu.vn> Date: Wed, 24 Jun 2026 11:25:40 +0700 Subject: [PATCH 05/20] refactor(ai-service): extract lexi_chat pipeline orchestration into a service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lexi_chat.py mixed route handling with pipeline orchestration and SSE streaming, growing past 1400 lines. Moves the pipeline run, session store/idempotency helpers, and the SSE generator into a new lexi_chat_service.py; the route module now just parses requests, calls the service, and serializes the response. No behavior change — pipeline and streaming tests pass unmodified aside from updated monkeypatch targets. Co-Authored-By: Claude Sonnet 4.6 --- ai-service/api/routes/lexi_chat.py | 946 ++---------------- ai-service/api/services/lexi_chat_service.py | 865 ++++++++++++++++ .../tests/test_history_restore_endpoints.py | 3 +- ai-service/tests/test_lexi_chat_routes.py | 31 +- .../tests/test_lexi_session_management.py | 9 +- .../tests/test_tracecag_chat_integration.py | 11 +- 6 files changed, 950 insertions(+), 915 deletions(-) create mode 100644 ai-service/api/services/lexi_chat_service.py diff --git a/ai-service/api/routes/lexi_chat.py b/ai-service/api/routes/lexi_chat.py index 7ea72536..3416d791 100644 --- a/ai-service/api/routes/lexi_chat.py +++ b/ai-service/api/routes/lexi_chat.py @@ -10,27 +10,23 @@ Integrates with the existing TraceCAG system for document retrieval and knowledge graph expansion to make conversations contextually rich. + +The actual pipeline orchestration (session bootstrap, STT/TraceCAG/TTS, +persistence) lives in api.services.lexi_chat_service — this module only +parses requests, enforces auth/quota, and shapes responses. """ import asyncio import logging -import os -import json -import uuid import time -import base64 -import hashlib -from contextlib import suppress -from dataclasses import dataclass, field +import uuid from datetime import datetime, timezone -from typing import AsyncGenerator, Optional, List, Dict, Any +from typing import Dict, Any from fastapi import APIRouter, HTTPException, Depends, Query, Header, Request from fastapi.responses import StreamingResponse from motor.motor_asyncio import AsyncIOMotorDatabase -from bson import ObjectId from pymongo.errors import OperationFailure -from pydantic import BaseModel, Field from api.core.auth import AuthenticatedUser, enforce_user_scope, get_current_user from api.core.audit_emitter import emit_ai_audit_event from api.core.quota_guard import default_token_cost_for_endpoint, enforce_user_quota @@ -42,55 +38,21 @@ load_full_cursor, to_iso_timestamp, ) -from api.services.lexi_session_store import LexiSessionStore, get_lexi_store -from api.services.lexi_idempotency_store import LexiIdempotencyStore, get_lexi_idempotency_store -from api.services.lexi_pipeline_helpers import ( - sanitize_lexi_response as _sanitize_lexi_response, - synthesize_tts as _synthesize_tts, - transcribe_audio as _transcribe_audio, +from api.services import lexi_chat_service as svc +from api.services.lexi_chat_service import ( + LexiChatRequest, + LexiChatResponse, + LexiSessionListResponse, + LexiSessionRenameRequest, + LexiSessionRequest, + LexiSessionResponse, + LexiSessionSummary, ) logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/v1/lexi", tags=["lexi-chat"]) -_store: LexiSessionStore = get_lexi_store() -_idempotency_store: LexiIdempotencyStore = get_lexi_idempotency_store() - -SAFE_FIXED_RESPONSE = ( - "Squawk! I'm temporarily unavailable right now. " - "Please try again in a moment." -) - -# SSE stream settings -_STREAM_PIPELINE_TIMEOUT_S = 50 # cancel and error if pipeline exceeds this -_HEARTBEAT_INTERVAL_S = 2.5 # how often to send ": ping" keep-alive comments - - -def _env_float(name: str, default: float, minimum: float = 0.0) -> float: - raw = os.getenv(name) - if raw is None: - return default - try: - return max(minimum, float(raw)) - except ValueError: - return default - - -def _idempotency_request_hash(request: "LexiChatRequest") -> str: - payload = { - "user_id": request.user_id, - "session_id": request.session_id, - "message": request.message, - "input_type": request.input_type, - "audio_base64": request.audio_base64, - "enable_tts": request.enable_tts, - "learner_level": request.learner_level, - "story_context": request.story_context, - } - normalized = json.dumps(payload, sort_keys=True, separators=(",", ":")) - return hashlib.sha256(normalized.encode("utf-8")).hexdigest() - def _serialize_lexi_message(doc: Dict[str, Any]) -> Dict[str, Any]: return { @@ -102,502 +64,6 @@ def _serialize_lexi_message(doc: Dict[str, Any]) -> Dict[str, Any]: } -async def _ensure_session_owner( - session_id: str, - current_user: AuthenticatedUser, - db: AsyncIOMotorDatabase, -) -> Dict[str, Any]: - session_doc = await db["lexi_sessions"].find_one({"session_id": session_id}) - if not session_doc: - raise HTTPException(status_code=404, detail="Session not found") - - owner_user_id = str(session_doc.get("user_id") or "") - if owner_user_id and owner_user_id != current_user.user_id: - raise HTTPException(status_code=403, detail="Forbidden: session ownership mismatch") - return session_doc - - -def _assert_cached_session_owner( - cached_session: Dict[str, Any] | None, - current_user: AuthenticatedUser, -) -> None: - if not cached_session: - return - owner_user_id = str(cached_session.get("user_id") or "") - if owner_user_id and owner_user_id != current_user.user_id: - raise HTTPException(status_code=403, detail="Forbidden: session ownership mismatch") - - -async def _get_cached_session_with_messages( - session_id: str, -) -> tuple[Dict[str, Any] | None, List[Dict[str, Any]]]: - if callable(getattr(type(_store), "get_session_with_messages", None)): - return await _store.get_session_with_messages(session_id) - - cached_session, cached_messages = await asyncio.gather( - _store.get_session(session_id), - _store.get_messages(session_id), - ) - return cached_session, cached_messages - - -async def _prepare_existing_lexi_session( - session_id: str, - current_user: AuthenticatedUser, - db: AsyncIOMotorDatabase, -) -> List[Dict[str, Any]]: - cached_session, cached_messages = await _get_cached_session_with_messages(session_id) - cached_owner = str((cached_session or {}).get("user_id") or "") - if cached_session and cached_owner: - _assert_cached_session_owner(cached_session, current_user) - return cached_messages - - try: - await _ensure_session_owner(session_id, current_user, db) - except HTTPException as exc: - if exc.status_code != 404: - raise - _assert_cached_session_owner(cached_session, current_user) - if not cached_session: - raise - return cached_messages - - _assert_cached_session_owner(cached_session, current_user) - if cached_session: - return cached_messages - - docs = await ( - db["lexi_messages"] - .find({"session_id": session_id}) - .sort("timestamp", -1) - .limit(10) - .to_list(length=10) - ) - docs.reverse() - return [ - { - "id": doc.get("id") or doc.get("message_id") or str(doc.get("_id", "")), - "role": doc.get("role", "user"), - "content": doc.get("content", ""), - "timestamp": to_iso_timestamp(doc.get("timestamp")), - } - for doc in docs - ] - - -async def _create_lexi_session_for_user( - session_id: str, - user_id: str, - story_context: Optional[str], - db: AsyncIOMotorDatabase, -) -> None: - now_iso = datetime.now(timezone.utc).isoformat() - await asyncio.gather( - _store.set_session(session_id, { - "session_id": session_id, - "user_id": user_id, - "created_at": now_iso, - "updated_at": now_iso, - "title": "Lexi Chat", - "message_count": 0, - "persona": "lexi", - "story_context": story_context, - }), - _store.init_messages(session_id), - db["lexi_sessions"].update_one( - {"session_id": session_id}, - { - "$set": { - "session_id": session_id, - "user_id": user_id, - "title": "Lexi Chat", - "created_at": now_iso, - "updated_at": now_iso, - "message_count": 0, - "persona": "lexi", - } - }, - upsert=True, - ), - ) - - -async def _append_lexi_messages( - session_id: str, - messages: List[Dict[str, Any]], -) -> None: - if callable(getattr(type(_store), "append_messages", None)): - await _store.append_messages(session_id, messages) - return - - await asyncio.gather( - *(_store.append_message(session_id, message) for message in messages) - ) - - -# ─── Lexi Persona System Prompt ───────────────────────────────────────────── -LEXI_PERSONA = """You are Lexi, a cheerful, witty parrot who is an expert English tutor. -You speak in a warm, encouraging tone — like a fun game character guiding an adventure. - -Personality traits: -- Playful and humorous, but always educational -- Uses short, clear sentences appropriate to the learner's level -- Celebrates small victories with enthusiasm ("Squawk! Great job! ") -- Gently corrects mistakes with encouraging context -- Occasionally drops parrot-themed phrases ("Polly wants proper grammar!") -- Adapts difficulty based on learner's CEFR level -- Keeps conversations flowing like a story / adventure - -Story context rules: -- Each conversation is an "adventure" with Lexi -- Reference previous topics to build continuity -- Use the knowledge graph context to teach related concepts -- When the learner makes errors, weave corrections into the story naturally - -Response format: -- Keep responses concise (2-4 sentences for dialogue) -- Use markdown for emphasis when helpful -- Include a gentle correction if errors are found -- Always end with something that invites the learner to continue -""" - - -# ─── Request / Response Models ─────────────────────────────────────────────── -class LexiChatRequest(BaseModel): - """Request to chat with Lexi.""" - user_id: str = Field(default="demo_user", description="User identifier") - session_id: Optional[str] = Field(default=None, description="Existing session ID") - message: str = Field(..., min_length=1, description="User message text") - input_type: str = Field(default="text", description="'text' or 'voice'") - audio_base64: Optional[str] = Field(default=None, description="Base64 audio for STT") - enable_tts: bool = Field(default=True, description="Generate TTS audio response") - learner_level: str = Field(default="B1", description="CEFR level: A1-C2") - story_context: Optional[str] = Field(default=None, description="Story/adventure context") - - -class LexiCorrection(BaseModel): - """A grammar/vocabulary correction.""" - error_span: str = "" - correction: str = "" - error_type: str = "" - explanation: str = "" - - -class LexiChatResponse(BaseModel): - """Structured response from Lexi.""" - success: bool = True - session_id: str - message_id: str - lexi_response: str - audio_base64: Optional[str] = None - corrections: List[LexiCorrection] = [] - linked_concepts: List[str] = [] - vietnamese_hint: Optional[str] = None - scores: Optional[Dict[str, Any]] = None - story_context: Optional[str] = None - metadata: Dict[str, Any] = {} - - -class LexiSessionResponse(BaseModel): - """Response for session creation.""" - success: bool = True - session_id: str - created_at: str - persona: str = "lexi" - - -class LexiSessionRequest(BaseModel): - """Request to create a Lexi session.""" - user_id: str = Field(default="demo_user", description="User identifier") - - -class LexiSessionRenameRequest(BaseModel): - title: str = Field(..., min_length=1, max_length=120) - - -class LexiSessionSummary(BaseModel): - session_id: str - user_id: str - title: str - created_at: str - updated_at: str - message_count: int = 0 - - -class LexiSessionListResponse(BaseModel): - success: bool = True - sessions: List[LexiSessionSummary] = [] - - -# ─── Pipeline result ───────────────────────────────────────────────────────── - -@dataclass -class _PipelineResult: - lexi_response: str - user_text: str - message_id: str - session_id: str - corrections: List["LexiCorrection"] = field(default_factory=list) - linked_concepts: List[str] = field(default_factory=list) - vietnamese_hint: Optional[str] = None - scores: Optional[Dict[str, Any]] = None - model_used: str = "trace-cag" - story_ctx: Optional[str] = None - audio_b64: Optional[str] = None - metadata: Dict[str, Any] = field(default_factory=dict) - - -async def _run_lexi_pipeline( - request: "LexiChatRequest", - session_id: str, - history: List[Dict[str, Any]], - db: AsyncIOMotorDatabase, - quota: Any, - start_time: float, - skip_tts: bool = False, -) -> _PipelineResult: - """Execute the full Lexi pipeline (STT → TraceCAG → TTS → persist) and return results. - - Extracted so both the regular /chat and the streaming /stream endpoints - can share the same logic without duplication. - """ - metadata: Dict[str, Any] = {"pipeline_steps": ["session_ready"]} - - # ── STT ── - user_text = request.message - if request.input_type == "voice" and request.audio_base64: - transcript = await _transcribe_audio(request.audio_base64) - if transcript: - user_text = transcript - metadata["pipeline_steps"].append("stt_complete") - metadata["stt_transcript"] = transcript - else: - metadata["pipeline_steps"].append("stt_failed") - - # ── TraceCAG pipeline ── - lexi_response = "" - corrections: List[LexiCorrection] = [] - linked_concepts: List[str] = [] - vietnamese_hint: Optional[str] = None - scores: Optional[Dict[str, Any]] = None - model_used = "trace-cag" - - try: - from api.services.orchestrator import get_orchestrator - - orchestrator = await get_orchestrator() - graph_result = await orchestrator.process( - user_input=user_text, - session_id=session_id, - user_id=request.user_id, - learner_profile={"level": request.learner_level}, - conversation_history=history, - ) - lexi_response = graph_result.get("tutor_response", "") - for c in graph_result.get("corrections", []): - corrections.append(LexiCorrection( - error_span=c.get("error", ""), - correction=c.get("correction", ""), - error_type=c.get("type", ""), - explanation=c.get("explanation", ""), - )) - linked_concepts = graph_result.get("linked_concepts", []) - vietnamese_hint = graph_result.get("vietnamese_hint") - scores = graph_result.get("scores") - metadata["pipeline_steps"].append("trace-cag_complete") - metadata["trace-cag_metadata"] = graph_result.get("metadata", {}) - model_used = ", ".join(graph_result.get("metadata", {}).get("models_used", ["trace-cag"])) - - except Exception as e: - logger.error("TraceCAG hard failure in Lexi pipeline (primary): %s", e) - metadata["pipeline_steps"].append("trace-cag_failed_primary") - try: - from api.services.orchestrator import get_orchestrator - - orchestrator = await get_orchestrator() - retry_result = await orchestrator.process( - user_input=user_text, - session_id=session_id, - user_id=request.user_id, - learner_profile={"level": request.learner_level}, - conversation_history=[], - cache_policy="off", - retrieval_policy="rapid", - diagnosis_policy="rules", - generation_policy="auto", - ) - lexi_response = str(retry_result.get("tutor_response") or "").strip() - if not lexi_response: - raise RuntimeError("TraceCAG degraded retry returned empty tutor_response") - retry_meta = retry_result.get("metadata", {}) or {} - metadata["pipeline_steps"].append("trace-cag_retry_complete") - metadata["trace-cag_metadata"] = { - **retry_meta, - "fallback_used": True, - "retry_mode": "trace-cag_degraded", - "primary_error": str(e), - } - model_used = ", ".join(retry_meta.get("models_used", ["trace-cag_retry"])) - except Exception as retry_err: - logger.error("TraceCAG hard failure in Lexi pipeline (degraded retry): %s", retry_err) - metadata["pipeline_steps"].append("trace-cag_failed_hard") - metadata["trace-cag_metadata"] = { - "fallback_used": True, - "primary_error": str(e), - "retry_error": str(retry_err), - } - lexi_response = SAFE_FIXED_RESPONSE - model_used = "trace-cag_safe_response" - - # ── Guards ── - story_ctx = request.story_context - if not story_ctx: - _pre_sess = await _store.get_session(session_id) - if _pre_sess: - story_ctx = _pre_sess.get("story_context") - - if not lexi_response: - lexi_response = SAFE_FIXED_RESPONSE - model_used = "trace-cag_safe_response" - metadata["pipeline_steps"].append("trace-cag_empty_response_guard") - - lexi_response = _sanitize_lexi_response(lexi_response) - metadata["model_used"] = model_used - - # ── TTS ── - audio_b64: Optional[str] = None - if request.enable_tts and not skip_tts: - tts_timeout_s = _env_float("LEXI_TTS_TIMEOUT_SECONDS", 8.0, minimum=0.5) - try: - audio_b64 = await asyncio.wait_for( - _synthesize_tts(lexi_response), - timeout=tts_timeout_s, - ) - metadata["pipeline_steps"].append("tts_complete" if audio_b64 else "tts_skipped") - except asyncio.TimeoutError: - logger.warning("Lexi TTS timed out after %.1fs", tts_timeout_s) - metadata["pipeline_steps"].append("tts_timeout") - - # ── Persist messages ── - message_id = str(uuid.uuid4()) - timestamp = datetime.now(timezone.utc).isoformat() - user_message = { - "id": str(uuid.uuid4()), - "role": "user", - "content": user_text, - "timestamp": timestamp, - } - assistant_message = { - "id": message_id, - "role": "assistant", - "content": lexi_response, - "timestamp": timestamp, - } - - # Fire all independent writes concurrently: cache append + MongoDB writes. - await asyncio.gather( - _append_lexi_messages(session_id, [user_message, assistant_message]), - db["lexi_messages"].insert_many([ - { - **user_message, - "session_id": session_id, - "user_id": request.user_id, - }, - { - **assistant_message, - "session_id": session_id, - "user_id": request.user_id, - }, - ]), - db["lexi_sessions"].update_one( - {"session_id": session_id}, - { - "$set": { - "updated_at": timestamp, - "user_id": request.user_id, - }, - "$inc": {"message_count": 2}, - "$setOnInsert": { - "created_at": timestamp, - "title": "Lexi Chat", - "persona": "lexi", - }, - }, - upsert=True, - ), - ) - - # Read session (needed for message_count), then fire session update + conv cache concurrently - cached_session = await _store.get_session(session_id) or {} - if not story_ctx: - story_ctx = cached_session.get("story_context") - cached_count = int(cached_session.get("message_count") or 0) - - async def _write_conv_cache(): - try: - from api.core.redis_client import ConversationCache, RedisClient - _redis_inst = await RedisClient.get_instance() - _conv_cache = ConversationCache(_redis_inst) - await _conv_cache.add_turn( - session_id=session_id, - user_message=user_text, - ai_response=lexi_response, - metadata={ - "model": model_used, - "scores": scores, - "latency_ms": int((time.time() - start_time) * 1000), - }, - ) - except Exception as _cc_err: - logger.debug(f"ConversationCache write skipped: {_cc_err}") - - await asyncio.gather( - _store.set_session(session_id, { - "session_id": session_id, - "user_id": request.user_id, - "created_at": cached_session.get("created_at", timestamp), - "updated_at": timestamp, - "title": cached_session.get("title", "Lexi Chat"), - "message_count": cached_count + 2, - "persona": cached_session.get("persona", "lexi"), - "story_context": story_ctx, - }), - _write_conv_cache(), - ) - - total_ms = int((time.time() - start_time) * 1000) - metadata["latency_ms"] = total_ms - metadata["quota"] = { - "rpm_used": quota.rpm_used, - "rpm_limit": quota.rpm_limit, - "rpd_used": quota.rpd_used, - "rpd_limit": quota.rpd_limit, - "tpm_used": quota.tpm_used, - "tpm_limit": quota.tpm_limit, - "tpd_used": quota.tpd_used, - "tpd_limit": quota.tpd_limit, - } - logger.info( - f"Lexi pipeline complete — {total_ms}ms, model: {model_used}, " - f"steps: {metadata['pipeline_steps']}" - ) - - return _PipelineResult( - lexi_response=lexi_response, - user_text=user_text, - message_id=message_id, - session_id=session_id, - corrections=corrections, - linked_concepts=linked_concepts, - vietnamese_hint=vietnamese_hint, - scores=scores, - model_used=model_used, - story_ctx=story_ctx, - audio_b64=audio_b64, - metadata=metadata, - ) - - # ─── Routes ────────────────────────────────────────────────────────────────── @router.post("/sessions", response_model=LexiSessionResponse) async def create_lexi_session( @@ -611,8 +77,8 @@ async def create_lexi_session( session_id = str(uuid.uuid4()) now = datetime.now(timezone.utc).isoformat() - - await _store.set_session(session_id, { + + await svc.lexi_store.set_session(session_id, { "session_id": session_id, "user_id": request.user_id, "created_at": now, @@ -622,7 +88,7 @@ async def create_lexi_session( "persona": "lexi", "story_context": None, }) - await _store.init_messages(session_id) + await svc.lexi_store.init_messages(session_id) await db["lexi_sessions"].update_one( {"session_id": session_id}, @@ -639,7 +105,7 @@ async def create_lexi_session( }, upsert=True, ) - + logger.info(f" Lexi session created: {session_id[:8]}... for user: {request.user_id}") return LexiSessionResponse(session_id=session_id, created_at=now) @@ -648,7 +114,7 @@ async def create_lexi_session( async def lexi_chat( request_context: Request, request: LexiChatRequest, - x_idempotency_key: Optional[str] = Header( + x_idempotency_key: str | None = Header( default=None, alias="X-Idempotency-Key", ), @@ -681,20 +147,20 @@ async def lexi_chat( # ── 1. Session management ── if request.session_id: session_id = request.session_id - history = await _prepare_existing_lexi_session(session_id, current_user, db) + history = await svc.prepare_existing_lexi_session(session_id, current_user, db) else: session_id = str(uuid.uuid4()) history = [] - await _create_lexi_session_for_user( + await svc.create_lexi_session_for_user( session_id=session_id, user_id=request.user_id, story_context=request.story_context, db=db, ) - request_hash = _idempotency_request_hash(request) + request_hash = svc.idempotency_request_hash(request) if x_idempotency_key: - cached_response = await _idempotency_store.get( + cached_response = await svc.lexi_idempotency_store.get( user_id=request.user_id, session_id=session_id, idempotency_key=x_idempotency_key, @@ -704,7 +170,7 @@ async def lexi_chat( return LexiChatResponse(**cached_response) # ── 2–6. Execute shared pipeline (STT → TraceCAG → TTS → persist) ── - result = await _run_lexi_pipeline( + result = await svc.run_lexi_pipeline( request=request, session_id=session_id, history=history, @@ -746,7 +212,7 @@ async def lexi_chat( ) if x_idempotency_key: - await _idempotency_store.set( + await svc.lexi_idempotency_store.set( user_id=request.user_id, session_id=session_id, idempotency_key=x_idempotency_key, @@ -783,7 +249,7 @@ async def lexi_stream_chat( auth_user_id = enforce_user_scope(current_user, request.user_id) request = request.model_copy(update={"user_id": auth_user_id}) - quota_timeout_s = _env_float("LEXI_STREAM_QUOTA_TIMEOUT_SECONDS", 5.0, minimum=0.5) + quota_timeout_s = svc.env_float("LEXI_STREAM_QUOTA_TIMEOUT_SECONDS", 5.0, minimum=0.5) try: quota = await asyncio.wait_for( enforce_user_quota( @@ -808,16 +274,16 @@ async def lexi_stream_chat( ) from exc session_id = request.session_id or str(uuid.uuid4()) - prechecked_history: Optional[List[Dict[str, Any]]] = None + prechecked_history: list[Dict[str, Any]] | None = None if request.session_id: - session_timeout_s = _env_float( + session_timeout_s = svc.env_float( "LEXI_STREAM_SESSION_TIMEOUT_SECONDS", 15.0, - minimum=_HEARTBEAT_INTERVAL_S, + minimum=svc.HEARTBEAT_INTERVAL_S, ) try: prechecked_history = await asyncio.wait_for( - _prepare_existing_lexi_session(session_id, current_user, db), + svc.prepare_existing_lexi_session(session_id, current_user, db), timeout=session_timeout_s, ) except asyncio.TimeoutError as exc: @@ -830,317 +296,17 @@ async def lexi_stream_chat( detail="Chat session service is temporarily unavailable", ) from exc - async def _sse_generator() -> AsyncGenerator[str, None]: - from api.services.orchestrator import get_orchestrator - from api.services.trace_cag.nodes_v2 import build_generation_prompt, stream_llm_tokens - from api.services.trace_cag.evaluation_agent import EvaluationAgent - - user_text = request.message - - # 1. Open the SSE response before touching Redis/Mongo. This prevents a - # degraded session store from holding the HTTP response until the edge - # proxy returns a 504. - yield "event: thinking\ndata: {}\n\n" - - # 2. Session management. Keep sending data events while Redis/Mongo is - # slow so Cloudflare and nginx do not treat the stream as idle. - if prechecked_history is not None: - history = prechecked_history - else: - async def _prepare_session() -> List[Dict[str, Any]]: - await _create_lexi_session_for_user( - session_id=session_id, - user_id=request.user_id, - story_context=request.story_context, - db=db, - ) - return [] - - session_task = asyncio.create_task(_prepare_session()) - loop = asyncio.get_running_loop() - session_timeout_s = _env_float( - "LEXI_STREAM_SESSION_TIMEOUT_SECONDS", - 15.0, - minimum=_HEARTBEAT_INTERVAL_S, - ) - session_deadline = loop.time() + session_timeout_s - while not session_task.done(): - if loop.time() >= session_deadline: - session_task.cancel() - with suppress(asyncio.CancelledError): - await session_task - logger.error( - "Lexi /stream session preparation timed out after %.1fs", - session_timeout_s, - ) - yield ( - f"event: error\ndata: " - f"{json.dumps({'error': 'Chat session service is temporarily unavailable.'})}\n\n" - ) - return - try: - await asyncio.wait_for( - asyncio.shield(session_task), - timeout=_HEARTBEAT_INTERVAL_S, - ) - except asyncio.TimeoutError: - yield "event: heartbeat\ndata: {}\n\n" - except Exception: - break - - try: - history = await session_task - except Exception as exc: - logger.error("Lexi /stream session preparation error: %s", exc) - yield ( - f"event: error\ndata: " - f"{json.dumps({'error': 'Chat session service is temporarily unavailable.'})}\n\n" - ) - return - - # 3. Context preparation — KG + diagnose + retrieve, NO LLM generation. - # Heartbeat pings keep the SSE connection alive while this runs. - # get_orchestrator() may block on cold start (model loading); run it as - # a task so we can keep pinging while it initialises. - try: - orch_task = asyncio.create_task(get_orchestrator()) - loop = asyncio.get_running_loop() - orch_deadline = loop.time() + 45.0 # Reduced from 60s: total must fit within Cloudflare's 100s proxy timeout - while not orch_task.done(): - if loop.time() >= orch_deadline: - orch_task.cancel() - yield ( - f"event: error\ndata: " - f"{json.dumps({'error': 'Service is starting up. Please try again.'})}\n\n" - ) - return - try: - await asyncio.wait_for(asyncio.shield(orch_task), timeout=_HEARTBEAT_INTERVAL_S) - except asyncio.TimeoutError: - # Use proper SSE data event (not comment) — Cloudflare flushes data events - # immediately but may buffer SSE comment lines (`: ping`). - yield "event: heartbeat\ndata: {}\n\n" - except Exception: - break - orchestrator = await orch_task - ctx_task = asyncio.create_task( - orchestrator.pipeline.analyze_for_streaming( - user_input=user_text, - session_id=session_id, - user_id=request.user_id, - learner_profile={"level": request.learner_level}, - conversation_history=history, - ) - ) - - loop = asyncio.get_running_loop() - ctx_deadline = loop.time() + 15.0 # Reduced from 25s: orch(45) + ctx(15) + LLM < 90s - while not ctx_task.done(): - if loop.time() >= ctx_deadline: - ctx_task.cancel() - logger.error("Lexi /stream context prep timed out") - yield ( - f"event: error\ndata: " - f"{json.dumps({'error': 'Response timed out. Please try again.'})}\n\n" - ) - return - try: - await asyncio.wait_for( - asyncio.shield(ctx_task), timeout=_HEARTBEAT_INTERVAL_S - ) - except asyncio.TimeoutError: - # Use proper SSE data event — Cloudflare flushes data events immediately - yield "event: heartbeat\ndata: {}\n\n" - except Exception: - break - - raw_state = await ctx_task - - except Exception as exc: - logger.error("Lexi /stream context prep error: %s", exc) - yield ( - f"event: error\ndata: " - f"{json.dumps({'error': 'Pipeline failed. Please try again.'})}\n\n" - ) - return - - # Extract corrections / concepts / scores from pipeline state - diag_errors = list(raw_state.get("diagnosis_errors") or []) - corrections = [ - LexiCorrection( - error_span=str(e.get("span") or ""), - correction=str(e.get("correction") or ""), - error_type=str(e.get("type") or ""), - explanation=str(e.get("explanation") or ""), - ) - for e in diag_errors - ] - linked_concepts = list(raw_state.get("kg_seed_concepts") or []) - vietnamese_hint = raw_state.get("vietnamese_hint") - grammar_score = float(raw_state.get("grammar_score") or 0.8) - fluency_score = float(raw_state.get("fluency_score") or 0.8) - vocab_level = str(raw_state.get("vocabulary_level") or "B1") - - lexi_response = "" - model_used = "stream_llm" - cache_hit = bool(raw_state.get("cache_hit")) and bool(raw_state.get("tutor_response")) - - # 3. Token streaming - if cache_hit: - # Cache hit: deliver words quickly — no LLM round-trip needed. - lexi_response = _sanitize_lexi_response(str(raw_state["tutor_response"])) - model_used = f"cached_{raw_state.get('cache_layer', 'L0')}" - for word in lexi_response.split(" "): - yield f"event: chunk\ndata: {json.dumps({'text': word + ' '})}\n\n" - await asyncio.sleep(0.012) - else: - # Cache miss: stream real LLM tokens as they arrive. - try: - system_prompt, llm_messages = build_generation_prompt(raw_state) - tokens: list[str] = [] - async for token in stream_llm_tokens( - system_prompt=system_prompt, - messages=llm_messages, - user_input=user_text, - ): - tokens.append(token) - yield f"event: chunk\ndata: {json.dumps({'text': token})}\n\n" - lexi_response = _sanitize_lexi_response("".join(tokens)) - except Exception as gen_err: - logger.error("Lexi /stream LLM generation error: %s", gen_err) - - if not lexi_response: - lexi_response = SAFE_FIXED_RESPONSE - model_used = "trace-cag_safe_response" - else: - model_used = f"groq/{os.getenv('GROQ_MODEL', 'qwen/qwen3-32b')}" \ - if os.getenv("GROQ_API_KEY") else "gemini-2.0-flash" - - # 4. TTS synthesis — after all chunks so TTFB is unaffected - audio_b64: Optional[str] = None - if request.enable_tts: - try: - audio_b64 = await asyncio.wait_for( - _synthesize_tts(lexi_response), timeout=8.0 - ) - except Exception as tts_err: - logger.warning("Lexi stream TTS failed: %s", tts_err) - - # 5. Persist messages (parallelised) - message_id = str(uuid.uuid4()) - timestamp = datetime.now(timezone.utc).isoformat() - user_message = { - "id": str(uuid.uuid4()), - "role": "user", - "content": user_text, - "timestamp": timestamp, - } - assistant_message = { - "id": message_id, - "role": "assistant", - "content": lexi_response, - "timestamp": timestamp, - } - - async def _stream_persist(): - await asyncio.gather( - _append_lexi_messages(session_id, [user_message, assistant_message]), - db["lexi_messages"].insert_many([ - {**user_message, "session_id": session_id, "user_id": request.user_id}, - {**assistant_message, "session_id": session_id, "user_id": request.user_id}, - ]), - db["lexi_sessions"].update_one( - {"session_id": session_id}, - { - "$set": {"updated_at": timestamp, "user_id": request.user_id}, - "$inc": {"message_count": 2}, - "$setOnInsert": { - "created_at": timestamp, - "title": "Lexi Chat", - "persona": "lexi", - }, - }, - upsert=True, - ), - ) - cached_sess = await _store.get_session(session_id) or {} - cached_count = int(cached_sess.get("message_count") or 0) - await _store.set_session(session_id, { - "session_id": session_id, - "user_id": request.user_id, - "created_at": cached_sess.get("created_at", timestamp), - "updated_at": timestamp, - "title": cached_sess.get("title", "Lexi Chat"), - "message_count": cached_count + 2, - "persona": cached_sess.get("persona", "lexi"), - "story_context": request.story_context, - }) - - try: - await asyncio.wait_for(_stream_persist(), timeout=5.0) - except Exception as persist_err: - logger.warning("Lexi stream persist error: %s", persist_err) - - # 6. Done event - overall_score = EvaluationAgent.compute_overall_score( - grammar_score, fluency_score, vocab_level - ) - scores = { - "fluency": fluency_score, - "grammar": grammar_score, - "overall": overall_score, - "vocabulary_level": vocab_level, - } - total_ms = int((time.time() - start_time) * 1000) - done_payload = json.dumps({ - "message_id": message_id, - "session_id": session_id, - "lexi_response": lexi_response, - "corrections": [ - { - "error_span": c.error_span, - "correction": c.correction, - "error_type": c.error_type, - "explanation": c.explanation, - } - for c in corrections - ], - "linked_concepts": linked_concepts, - "vietnamese_hint": vietnamese_hint, - "scores": scores, - "story_context": request.story_context, - "audio_base64": audio_b64, - "metadata": { - "latency_ms": total_ms, - "model_used": model_used, - "cache_hit": cache_hit, - "quota": { - "rpm_used": quota.rpm_used, - "rpm_limit": quota.rpm_limit, - "rpd_used": quota.rpd_used, - "rpd_limit": quota.rpd_limit, - }, - }, - }) - yield f"event: done\ndata: {done_payload}\n\n" - - logger.info( - "Lexi /stream complete — %dms, model: %s, cache_hit: %s", - total_ms, model_used, cache_hit, - ) - await emit_ai_audit_event({ - "request_id": request_id, - "user_id": current_user.user_id, - "endpoint": "lexi.stream", - "status": "success", - "session_id": session_id, - "model_used": model_used, - "latency_ms": total_ms, - "quota": {"rpm_used": quota.rpm_used, "rpm_limit": quota.rpm_limit}, - }) - return StreamingResponse( - _sse_generator(), + svc.stream_lexi_chat( + request=request, + session_id=session_id, + prechecked_history=prechecked_history, + quota=quota, + start_time=start_time, + request_id=request_id, + current_user=current_user, + db=db, + ), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", @@ -1176,17 +342,17 @@ async def get_lexi_messages( session_doc = None try: - session_doc = await _ensure_session_owner(session_id, current_user, db) - cached_session = await _store.get_session(session_id) + session_doc = await svc.ensure_session_owner(session_id, current_user, db) + cached_session = await svc.lexi_store.get_session(session_id) except HTTPException as e: - cached_session = await _store.get_session(session_id) + cached_session = await svc.lexi_store.get_session(session_id) if e.status_code == 404 and cached_session: owner_user_id = str(cached_session.get("user_id") or "") if owner_user_id and hasattr(current_user, "user_id") and owner_user_id != current_user.user_id: raise HTTPException(status_code=403, detail="Forbidden: session ownership mismatch") else: raise - cached_messages = await _store.get_messages(session_id) if cached_session else [] + cached_messages = await svc.lexi_store.get_messages(session_id) if cached_session else [] expected_count = int((session_doc or {}).get("message_count") or 0) cache_is_complete = cached_session is not None and ( @@ -1222,7 +388,7 @@ async def get_lexi_messages( ] # Rehydrate cache for faster next reads. - await _store.set_session(session_id, { + await svc.lexi_store.set_session(session_id, { "session_id": session_doc.get("session_id"), "user_id": session_doc.get("user_id", "demo_user"), "created_at": session_doc.get("created_at", datetime.now(timezone.utc).isoformat()), @@ -1232,9 +398,9 @@ async def get_lexi_messages( "persona": session_doc.get("persona", "lexi"), "story_context": session_doc.get("story_context"), }) - await _store.delete_messages(session_id) + await svc.lexi_store.delete_messages(session_id) for item in payload: - await _store.append_message(session_id, item) + await svc.lexi_store.append_message(session_id, item) return { "success": True, @@ -1251,7 +417,7 @@ async def get_lexi_messages_paged( db: AsyncIOMotorDatabase = Depends(get_database), current_user: AuthenticatedUser = Depends(get_current_user), ) -> Dict[str, Any]: - await _ensure_session_owner(session_id, current_user, db) + await svc.ensure_session_owner(session_id, current_user, db) if limit < 1: raise HTTPException(status_code=400, detail="limit must be >= 1") @@ -1311,7 +477,7 @@ async def get_lexi_messages_metadata( db: AsyncIOMotorDatabase = Depends(get_database), current_user: AuthenticatedUser = Depends(get_current_user), ) -> Dict[str, Any]: - await _ensure_session_owner(session_id, current_user, db) + await svc.ensure_session_owner(session_id, current_user, db) base_query: Dict[str, Any] = {"session_id": session_id} total_count = await db["lexi_messages"].count_documents(base_query) @@ -1414,7 +580,7 @@ async def rename_lexi_session( db: AsyncIOMotorDatabase = Depends(get_database), current_user: AuthenticatedUser = Depends(get_current_user), ) -> Dict[str, Any]: - await _ensure_session_owner(session_id, current_user, db) + await svc.ensure_session_owner(session_id, current_user, db) title = request.title.strip() if not title: @@ -1428,11 +594,11 @@ async def rename_lexi_session( if result.matched_count == 0: raise HTTPException(status_code=404, detail="Session not found") - sess = await _store.get_session(session_id) + sess = await svc.lexi_store.get_session(session_id) if sess: sess["title"] = title sess["updated_at"] = now - await _store.set_session(session_id, sess) + await svc.lexi_store.set_session(session_id, sess) return {"success": True, "session_id": session_id, "title": title} @@ -1443,12 +609,12 @@ async def delete_lexi_session( db: AsyncIOMotorDatabase = Depends(get_database), current_user: AuthenticatedUser = Depends(get_current_user), ) -> Dict[str, Any]: - await _ensure_session_owner(session_id, current_user, db) + await svc.ensure_session_owner(session_id, current_user, db) await db["lexi_sessions"].delete_one({"session_id": session_id}) await db["lexi_messages"].delete_many({"session_id": session_id}) - await _store.delete_session(session_id) - await _store.delete_messages(session_id) + await svc.lexi_store.delete_session(session_id) + await svc.lexi_store.delete_messages(session_id) return {"success": True, "session_id": session_id} diff --git a/ai-service/api/services/lexi_chat_service.py b/ai-service/api/services/lexi_chat_service.py new file mode 100644 index 00000000..200b9a6e --- /dev/null +++ b/ai-service/api/services/lexi_chat_service.py @@ -0,0 +1,865 @@ +""" +Lexi chat pipeline service — owns the actual conversation turn orchestration +(session bootstrapping, STT, TraceCAG, TTS, persistence) so the route module +only has to parse requests and shape responses. + +Extracted from api/routes/lexi_chat.py so /chat and /stream share one +implementation without the orchestration living in a route handler. +""" + +import asyncio +import json +import logging +import os +import time +import uuid +from contextlib import suppress +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any, AsyncGenerator, Dict, List, Optional + +from fastapi import HTTPException +from motor.motor_asyncio import AsyncIOMotorDatabase +from pydantic import BaseModel, Field + +from api.core.auth import AuthenticatedUser +from api.utils.cursor import to_iso_timestamp +from api.services.lexi_session_store import LexiSessionStore, get_lexi_store +from api.services.lexi_idempotency_store import LexiIdempotencyStore, get_lexi_idempotency_store +from api.services.lexi_pipeline_helpers import ( + sanitize_lexi_response as _sanitize_lexi_response, + synthesize_tts as _synthesize_tts, + transcribe_audio as _transcribe_audio, +) + +logger = logging.getLogger(__name__) + +lexi_store: LexiSessionStore = get_lexi_store() +lexi_idempotency_store: LexiIdempotencyStore = get_lexi_idempotency_store() + +SAFE_FIXED_RESPONSE = ( + "Squawk! I'm temporarily unavailable right now. " + "Please try again in a moment." +) + +# SSE stream settings +HEARTBEAT_INTERVAL_S = 2.5 # how often to send a heartbeat to keep proxies alive + + +def env_float(name: str, default: float, minimum: float = 0.0) -> float: + raw = os.getenv(name) + if raw is None: + return default + try: + return max(minimum, float(raw)) + except ValueError: + return default + + +# ─── Request / Response Models ─────────────────────────────────────────────── +class LexiChatRequest(BaseModel): + """Request to chat with Lexi.""" + user_id: str = Field(default="demo_user", description="User identifier") + session_id: Optional[str] = Field(default=None, description="Existing session ID") + message: str = Field(..., min_length=1, description="User message text") + input_type: str = Field(default="text", description="'text' or 'voice'") + audio_base64: Optional[str] = Field(default=None, description="Base64 audio for STT") + enable_tts: bool = Field(default=True, description="Generate TTS audio response") + learner_level: str = Field(default="B1", description="CEFR level: A1-C2") + story_context: Optional[str] = Field(default=None, description="Story/adventure context") + + +class LexiCorrection(BaseModel): + """A grammar/vocabulary correction.""" + error_span: str = "" + correction: str = "" + error_type: str = "" + explanation: str = "" + + +class LexiChatResponse(BaseModel): + """Structured response from Lexi.""" + success: bool = True + session_id: str + message_id: str + lexi_response: str + audio_base64: Optional[str] = None + corrections: List[LexiCorrection] = [] + linked_concepts: List[str] = [] + vietnamese_hint: Optional[str] = None + scores: Optional[Dict[str, Any]] = None + story_context: Optional[str] = None + metadata: Dict[str, Any] = {} + + +class LexiSessionResponse(BaseModel): + """Response for session creation.""" + success: bool = True + session_id: str + created_at: str + persona: str = "lexi" + + +class LexiSessionRequest(BaseModel): + """Request to create a Lexi session.""" + user_id: str = Field(default="demo_user", description="User identifier") + + +class LexiSessionRenameRequest(BaseModel): + title: str = Field(..., min_length=1, max_length=120) + + +class LexiSessionSummary(BaseModel): + session_id: str + user_id: str + title: str + created_at: str + updated_at: str + message_count: int = 0 + + +class LexiSessionListResponse(BaseModel): + success: bool = True + sessions: List[LexiSessionSummary] = [] + + +def idempotency_request_hash(request: "LexiChatRequest") -> str: + import hashlib + + payload = { + "user_id": request.user_id, + "session_id": request.session_id, + "message": request.message, + "input_type": request.input_type, + "audio_base64": request.audio_base64, + "enable_tts": request.enable_tts, + "learner_level": request.learner_level, + "story_context": request.story_context, + } + normalized = json.dumps(payload, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(normalized.encode("utf-8")).hexdigest() + + +# ─── Session bootstrapping ─────────────────────────────────────────────────── +async def ensure_session_owner( + session_id: str, + current_user: AuthenticatedUser, + db: AsyncIOMotorDatabase, +) -> Dict[str, Any]: + session_doc = await db["lexi_sessions"].find_one({"session_id": session_id}) + if not session_doc: + raise HTTPException(status_code=404, detail="Session not found") + + owner_user_id = str(session_doc.get("user_id") or "") + if owner_user_id and owner_user_id != current_user.user_id: + raise HTTPException(status_code=403, detail="Forbidden: session ownership mismatch") + return session_doc + + +def assert_cached_session_owner( + cached_session: Dict[str, Any] | None, + current_user: AuthenticatedUser, +) -> None: + if not cached_session: + return + owner_user_id = str(cached_session.get("user_id") or "") + if owner_user_id and owner_user_id != current_user.user_id: + raise HTTPException(status_code=403, detail="Forbidden: session ownership mismatch") + + +async def get_cached_session_with_messages( + session_id: str, +) -> tuple[Dict[str, Any] | None, List[Dict[str, Any]]]: + if callable(getattr(type(lexi_store), "get_session_with_messages", None)): + return await lexi_store.get_session_with_messages(session_id) + + cached_session, cached_messages = await asyncio.gather( + lexi_store.get_session(session_id), + lexi_store.get_messages(session_id), + ) + return cached_session, cached_messages + + +async def prepare_existing_lexi_session( + session_id: str, + current_user: AuthenticatedUser, + db: AsyncIOMotorDatabase, +) -> List[Dict[str, Any]]: + cached_session, cached_messages = await get_cached_session_with_messages(session_id) + cached_owner = str((cached_session or {}).get("user_id") or "") + if cached_session and cached_owner: + assert_cached_session_owner(cached_session, current_user) + return cached_messages + + try: + await ensure_session_owner(session_id, current_user, db) + except HTTPException as exc: + if exc.status_code != 404: + raise + assert_cached_session_owner(cached_session, current_user) + if not cached_session: + raise + return cached_messages + + assert_cached_session_owner(cached_session, current_user) + if cached_session: + return cached_messages + + docs = await ( + db["lexi_messages"] + .find({"session_id": session_id}) + .sort("timestamp", -1) + .limit(10) + .to_list(length=10) + ) + docs.reverse() + return [ + { + "id": doc.get("id") or doc.get("message_id") or str(doc.get("_id", "")), + "role": doc.get("role", "user"), + "content": doc.get("content", ""), + "timestamp": to_iso_timestamp(doc.get("timestamp")), + } + for doc in docs + ] + + +async def create_lexi_session_for_user( + session_id: str, + user_id: str, + story_context: Optional[str], + db: AsyncIOMotorDatabase, +) -> None: + now_iso = datetime.now(timezone.utc).isoformat() + await asyncio.gather( + lexi_store.set_session(session_id, { + "session_id": session_id, + "user_id": user_id, + "created_at": now_iso, + "updated_at": now_iso, + "title": "Lexi Chat", + "message_count": 0, + "persona": "lexi", + "story_context": story_context, + }), + lexi_store.init_messages(session_id), + db["lexi_sessions"].update_one( + {"session_id": session_id}, + { + "$set": { + "session_id": session_id, + "user_id": user_id, + "title": "Lexi Chat", + "created_at": now_iso, + "updated_at": now_iso, + "message_count": 0, + "persona": "lexi", + } + }, + upsert=True, + ), + ) + + +async def append_lexi_messages( + session_id: str, + messages: List[Dict[str, Any]], +) -> None: + if callable(getattr(type(lexi_store), "append_messages", None)): + await lexi_store.append_messages(session_id, messages) + return + + await asyncio.gather( + *(lexi_store.append_message(session_id, message) for message in messages) + ) + + +# ─── Pipeline result ───────────────────────────────────────────────────────── +@dataclass +class PipelineResult: + lexi_response: str + user_text: str + message_id: str + session_id: str + corrections: List["LexiCorrection"] = field(default_factory=list) + linked_concepts: List[str] = field(default_factory=list) + vietnamese_hint: Optional[str] = None + scores: Optional[Dict[str, Any]] = None + model_used: str = "trace-cag" + story_ctx: Optional[str] = None + audio_b64: Optional[str] = None + metadata: Dict[str, Any] = field(default_factory=dict) + + +async def run_lexi_pipeline( + request: "LexiChatRequest", + session_id: str, + history: List[Dict[str, Any]], + db: AsyncIOMotorDatabase, + quota: Any, + start_time: float, + skip_tts: bool = False, +) -> PipelineResult: + """Execute the full Lexi pipeline (STT → TraceCAG → TTS → persist) and return results. + + Shared by both the regular /chat and the streaming /stream endpoints so + they don't duplicate this logic. + """ + metadata: Dict[str, Any] = {"pipeline_steps": ["session_ready"]} + + # ── STT ── + user_text = request.message + if request.input_type == "voice" and request.audio_base64: + transcript = await _transcribe_audio(request.audio_base64) + if transcript: + user_text = transcript + metadata["pipeline_steps"].append("stt_complete") + metadata["stt_transcript"] = transcript + else: + metadata["pipeline_steps"].append("stt_failed") + + # ── TraceCAG pipeline ── + lexi_response = "" + corrections: List[LexiCorrection] = [] + linked_concepts: List[str] = [] + vietnamese_hint: Optional[str] = None + scores: Optional[Dict[str, Any]] = None + model_used = "trace-cag" + + try: + from api.services.orchestrator import get_orchestrator + + orchestrator = await get_orchestrator() + graph_result = await orchestrator.process( + user_input=user_text, + session_id=session_id, + user_id=request.user_id, + learner_profile={"level": request.learner_level}, + conversation_history=history, + ) + lexi_response = graph_result.get("tutor_response", "") + for c in graph_result.get("corrections", []): + corrections.append(LexiCorrection( + error_span=c.get("error", ""), + correction=c.get("correction", ""), + error_type=c.get("type", ""), + explanation=c.get("explanation", ""), + )) + linked_concepts = graph_result.get("linked_concepts", []) + vietnamese_hint = graph_result.get("vietnamese_hint") + scores = graph_result.get("scores") + metadata["pipeline_steps"].append("trace-cag_complete") + metadata["trace-cag_metadata"] = graph_result.get("metadata", {}) + model_used = ", ".join(graph_result.get("metadata", {}).get("models_used", ["trace-cag"])) + + except Exception as e: + logger.error("TraceCAG hard failure in Lexi pipeline (primary): %s", e) + metadata["pipeline_steps"].append("trace-cag_failed_primary") + try: + from api.services.orchestrator import get_orchestrator + + orchestrator = await get_orchestrator() + retry_result = await orchestrator.process( + user_input=user_text, + session_id=session_id, + user_id=request.user_id, + learner_profile={"level": request.learner_level}, + conversation_history=[], + cache_policy="off", + retrieval_policy="rapid", + diagnosis_policy="rules", + generation_policy="auto", + ) + lexi_response = str(retry_result.get("tutor_response") or "").strip() + if not lexi_response: + raise RuntimeError("TraceCAG degraded retry returned empty tutor_response") + retry_meta = retry_result.get("metadata", {}) or {} + metadata["pipeline_steps"].append("trace-cag_retry_complete") + metadata["trace-cag_metadata"] = { + **retry_meta, + "fallback_used": True, + "retry_mode": "trace-cag_degraded", + "primary_error": str(e), + } + model_used = ", ".join(retry_meta.get("models_used", ["trace-cag_retry"])) + except Exception as retry_err: + logger.error("TraceCAG hard failure in Lexi pipeline (degraded retry): %s", retry_err) + metadata["pipeline_steps"].append("trace-cag_failed_hard") + metadata["trace-cag_metadata"] = { + "fallback_used": True, + "primary_error": str(e), + "retry_error": str(retry_err), + } + lexi_response = SAFE_FIXED_RESPONSE + model_used = "trace-cag_safe_response" + + # ── Guards ── + story_ctx = request.story_context + if not story_ctx: + _pre_sess = await lexi_store.get_session(session_id) + if _pre_sess: + story_ctx = _pre_sess.get("story_context") + + if not lexi_response: + lexi_response = SAFE_FIXED_RESPONSE + model_used = "trace-cag_safe_response" + metadata["pipeline_steps"].append("trace-cag_empty_response_guard") + + lexi_response = _sanitize_lexi_response(lexi_response) + metadata["model_used"] = model_used + + # ── TTS ── + audio_b64: Optional[str] = None + if request.enable_tts and not skip_tts: + tts_timeout_s = env_float("LEXI_TTS_TIMEOUT_SECONDS", 8.0, minimum=0.5) + try: + audio_b64 = await asyncio.wait_for( + _synthesize_tts(lexi_response), + timeout=tts_timeout_s, + ) + metadata["pipeline_steps"].append("tts_complete" if audio_b64 else "tts_skipped") + except asyncio.TimeoutError: + logger.warning("Lexi TTS timed out after %.1fs", tts_timeout_s) + metadata["pipeline_steps"].append("tts_timeout") + + # ── Persist messages ── + message_id = str(uuid.uuid4()) + timestamp = datetime.now(timezone.utc).isoformat() + user_message = { + "id": str(uuid.uuid4()), + "role": "user", + "content": user_text, + "timestamp": timestamp, + } + assistant_message = { + "id": message_id, + "role": "assistant", + "content": lexi_response, + "timestamp": timestamp, + } + + # Fire all independent writes concurrently: cache append + MongoDB writes. + await asyncio.gather( + append_lexi_messages(session_id, [user_message, assistant_message]), + db["lexi_messages"].insert_many([ + { + **user_message, + "session_id": session_id, + "user_id": request.user_id, + }, + { + **assistant_message, + "session_id": session_id, + "user_id": request.user_id, + }, + ]), + db["lexi_sessions"].update_one( + {"session_id": session_id}, + { + "$set": { + "updated_at": timestamp, + "user_id": request.user_id, + }, + "$inc": {"message_count": 2}, + "$setOnInsert": { + "created_at": timestamp, + "title": "Lexi Chat", + "persona": "lexi", + }, + }, + upsert=True, + ), + ) + + # Read session (needed for message_count), then fire session update + conv cache concurrently + cached_session = await lexi_store.get_session(session_id) or {} + if not story_ctx: + story_ctx = cached_session.get("story_context") + cached_count = int(cached_session.get("message_count") or 0) + + async def _write_conv_cache(): + try: + from api.core.redis_client import ConversationCache, RedisClient + _redis_inst = await RedisClient.get_instance() + _conv_cache = ConversationCache(_redis_inst) + await _conv_cache.add_turn( + session_id=session_id, + user_message=user_text, + ai_response=lexi_response, + metadata={ + "model": model_used, + "scores": scores, + "latency_ms": int((time.time() - start_time) * 1000), + }, + ) + except Exception as _cc_err: + logger.debug(f"ConversationCache write skipped: {_cc_err}") + + await asyncio.gather( + lexi_store.set_session(session_id, { + "session_id": session_id, + "user_id": request.user_id, + "created_at": cached_session.get("created_at", timestamp), + "updated_at": timestamp, + "title": cached_session.get("title", "Lexi Chat"), + "message_count": cached_count + 2, + "persona": cached_session.get("persona", "lexi"), + "story_context": story_ctx, + }), + _write_conv_cache(), + ) + + total_ms = int((time.time() - start_time) * 1000) + metadata["latency_ms"] = total_ms + metadata["quota"] = { + "rpm_used": quota.rpm_used, + "rpm_limit": quota.rpm_limit, + "rpd_used": quota.rpd_used, + "rpd_limit": quota.rpd_limit, + "tpm_used": quota.tpm_used, + "tpm_limit": quota.tpm_limit, + "tpd_used": quota.tpd_used, + "tpd_limit": quota.tpd_limit, + } + logger.info( + f"Lexi pipeline complete — {total_ms}ms, model: {model_used}, " + f"steps: {metadata['pipeline_steps']}" + ) + + return PipelineResult( + lexi_response=lexi_response, + user_text=user_text, + message_id=message_id, + session_id=session_id, + corrections=corrections, + linked_concepts=linked_concepts, + vietnamese_hint=vietnamese_hint, + scores=scores, + model_used=model_used, + story_ctx=story_ctx, + audio_b64=audio_b64, + metadata=metadata, + ) + + +async def stream_lexi_chat( + request: "LexiChatRequest", + session_id: str, + prechecked_history: Optional[List[Dict[str, Any]]], + quota: Any, + start_time: float, + request_id: str, + current_user: AuthenticatedUser, + db: AsyncIOMotorDatabase, +) -> AsyncGenerator[str, None]: + """SSE generator for the streaming Lexi chat pipeline. See lexi_stream_chat + route docstring for the event sequence (thinking/heartbeat/chunk/done/error). + """ + from api.core.audit_emitter import emit_ai_audit_event + from api.services.orchestrator import get_orchestrator + from api.services.trace_cag.nodes_v2 import build_generation_prompt, stream_llm_tokens + from api.services.trace_cag.evaluation_agent import EvaluationAgent + + user_text = request.message + + # 1. Open the SSE response before touching Redis/Mongo. This prevents a + # degraded session store from holding the HTTP response until the edge + # proxy returns a 504. + yield "event: thinking\ndata: {}\n\n" + + # 2. Session management. Keep sending data events while Redis/Mongo is + # slow so Cloudflare and nginx do not treat the stream as idle. + if prechecked_history is not None: + history = prechecked_history + else: + async def _prepare_session() -> List[Dict[str, Any]]: + await create_lexi_session_for_user( + session_id=session_id, + user_id=request.user_id, + story_context=request.story_context, + db=db, + ) + return [] + + session_task = asyncio.create_task(_prepare_session()) + loop = asyncio.get_running_loop() + session_timeout_s = env_float( + "LEXI_STREAM_SESSION_TIMEOUT_SECONDS", + 15.0, + minimum=HEARTBEAT_INTERVAL_S, + ) + session_deadline = loop.time() + session_timeout_s + while not session_task.done(): + if loop.time() >= session_deadline: + session_task.cancel() + with suppress(asyncio.CancelledError): + await session_task + logger.error( + "Lexi /stream session preparation timed out after %.1fs", + session_timeout_s, + ) + yield ( + f"event: error\ndata: " + f"{json.dumps({'error': 'Chat session service is temporarily unavailable.'})}\n\n" + ) + return + try: + await asyncio.wait_for( + asyncio.shield(session_task), + timeout=HEARTBEAT_INTERVAL_S, + ) + except asyncio.TimeoutError: + yield "event: heartbeat\ndata: {}\n\n" + except Exception: + break + + try: + history = await session_task + except Exception as exc: + logger.error("Lexi /stream session preparation error: %s", exc) + yield ( + f"event: error\ndata: " + f"{json.dumps({'error': 'Chat session service is temporarily unavailable.'})}\n\n" + ) + return + + # 3. Context preparation — KG + diagnose + retrieve, NO LLM generation. + # Heartbeat pings keep the SSE connection alive while this runs. + # get_orchestrator() may block on cold start (model loading); run it as + # a task so we can keep pinging while it initialises. + try: + orch_task = asyncio.create_task(get_orchestrator()) + loop = asyncio.get_running_loop() + orch_deadline = loop.time() + 45.0 # Reduced from 60s: total must fit within Cloudflare's 100s proxy timeout + while not orch_task.done(): + if loop.time() >= orch_deadline: + orch_task.cancel() + yield ( + f"event: error\ndata: " + f"{json.dumps({'error': 'Service is starting up. Please try again.'})}\n\n" + ) + return + try: + await asyncio.wait_for(asyncio.shield(orch_task), timeout=HEARTBEAT_INTERVAL_S) + except asyncio.TimeoutError: + # Use proper SSE data event (not comment) — Cloudflare flushes data events + # immediately but may buffer SSE comment lines (`: ping`). + yield "event: heartbeat\ndata: {}\n\n" + except Exception: + break + orchestrator = await orch_task + ctx_task = asyncio.create_task( + orchestrator.pipeline.analyze_for_streaming( + user_input=user_text, + session_id=session_id, + user_id=request.user_id, + learner_profile={"level": request.learner_level}, + conversation_history=history, + ) + ) + + loop = asyncio.get_running_loop() + ctx_deadline = loop.time() + 15.0 # Reduced from 25s: orch(45) + ctx(15) + LLM < 90s + while not ctx_task.done(): + if loop.time() >= ctx_deadline: + ctx_task.cancel() + logger.error("Lexi /stream context prep timed out") + yield ( + f"event: error\ndata: " + f"{json.dumps({'error': 'Response timed out. Please try again.'})}\n\n" + ) + return + try: + await asyncio.wait_for( + asyncio.shield(ctx_task), timeout=HEARTBEAT_INTERVAL_S + ) + except asyncio.TimeoutError: + # Use proper SSE data event — Cloudflare flushes data events immediately + yield "event: heartbeat\ndata: {}\n\n" + except Exception: + break + + raw_state = await ctx_task + + except Exception as exc: + logger.error("Lexi /stream context prep error: %s", exc) + yield ( + f"event: error\ndata: " + f"{json.dumps({'error': 'Pipeline failed. Please try again.'})}\n\n" + ) + return + + # Extract corrections / concepts / scores from pipeline state + diag_errors = list(raw_state.get("diagnosis_errors") or []) + corrections = [ + LexiCorrection( + error_span=str(e.get("span") or ""), + correction=str(e.get("correction") or ""), + error_type=str(e.get("type") or ""), + explanation=str(e.get("explanation") or ""), + ) + for e in diag_errors + ] + linked_concepts = list(raw_state.get("kg_seed_concepts") or []) + vietnamese_hint = raw_state.get("vietnamese_hint") + grammar_score = float(raw_state.get("grammar_score") or 0.8) + fluency_score = float(raw_state.get("fluency_score") or 0.8) + vocab_level = str(raw_state.get("vocabulary_level") or "B1") + + lexi_response = "" + model_used = "stream_llm" + cache_hit = bool(raw_state.get("cache_hit")) and bool(raw_state.get("tutor_response")) + + # 3. Token streaming + if cache_hit: + # Cache hit: deliver words quickly — no LLM round-trip needed. + lexi_response = _sanitize_lexi_response(str(raw_state["tutor_response"])) + model_used = f"cached_{raw_state.get('cache_layer', 'L0')}" + for word in lexi_response.split(" "): + yield f"event: chunk\ndata: {json.dumps({'text': word + ' '})}\n\n" + await asyncio.sleep(0.012) + else: + # Cache miss: stream real LLM tokens as they arrive. + try: + system_prompt, llm_messages = build_generation_prompt(raw_state) + tokens: list[str] = [] + async for token in stream_llm_tokens( + system_prompt=system_prompt, + messages=llm_messages, + user_input=user_text, + ): + tokens.append(token) + yield f"event: chunk\ndata: {json.dumps({'text': token})}\n\n" + lexi_response = _sanitize_lexi_response("".join(tokens)) + except Exception as gen_err: + logger.error("Lexi /stream LLM generation error: %s", gen_err) + + if not lexi_response: + lexi_response = SAFE_FIXED_RESPONSE + model_used = "trace-cag_safe_response" + else: + model_used = f"groq/{os.getenv('GROQ_MODEL', 'qwen/qwen3-32b')}" \ + if os.getenv("GROQ_API_KEY") else "gemini-2.0-flash" + + # 4. TTS synthesis — after all chunks so TTFB is unaffected + audio_b64: Optional[str] = None + if request.enable_tts: + try: + audio_b64 = await asyncio.wait_for( + _synthesize_tts(lexi_response), timeout=8.0 + ) + except Exception as tts_err: + logger.warning("Lexi stream TTS failed: %s", tts_err) + + # 5. Persist messages (parallelised) + message_id = str(uuid.uuid4()) + timestamp = datetime.now(timezone.utc).isoformat() + user_message = { + "id": str(uuid.uuid4()), + "role": "user", + "content": user_text, + "timestamp": timestamp, + } + assistant_message = { + "id": message_id, + "role": "assistant", + "content": lexi_response, + "timestamp": timestamp, + } + + async def _stream_persist(): + await asyncio.gather( + append_lexi_messages(session_id, [user_message, assistant_message]), + db["lexi_messages"].insert_many([ + {**user_message, "session_id": session_id, "user_id": request.user_id}, + {**assistant_message, "session_id": session_id, "user_id": request.user_id}, + ]), + db["lexi_sessions"].update_one( + {"session_id": session_id}, + { + "$set": {"updated_at": timestamp, "user_id": request.user_id}, + "$inc": {"message_count": 2}, + "$setOnInsert": { + "created_at": timestamp, + "title": "Lexi Chat", + "persona": "lexi", + }, + }, + upsert=True, + ), + ) + cached_sess = await lexi_store.get_session(session_id) or {} + cached_count = int(cached_sess.get("message_count") or 0) + await lexi_store.set_session(session_id, { + "session_id": session_id, + "user_id": request.user_id, + "created_at": cached_sess.get("created_at", timestamp), + "updated_at": timestamp, + "title": cached_sess.get("title", "Lexi Chat"), + "message_count": cached_count + 2, + "persona": cached_sess.get("persona", "lexi"), + "story_context": request.story_context, + }) + + try: + await asyncio.wait_for(_stream_persist(), timeout=5.0) + except Exception as persist_err: + logger.warning("Lexi stream persist error: %s", persist_err) + + # 6. Done event + overall_score = EvaluationAgent.compute_overall_score( + grammar_score, fluency_score, vocab_level + ) + scores = { + "fluency": fluency_score, + "grammar": grammar_score, + "overall": overall_score, + "vocabulary_level": vocab_level, + } + total_ms = int((time.time() - start_time) * 1000) + done_payload = json.dumps({ + "message_id": message_id, + "session_id": session_id, + "lexi_response": lexi_response, + "corrections": [ + { + "error_span": c.error_span, + "correction": c.correction, + "error_type": c.error_type, + "explanation": c.explanation, + } + for c in corrections + ], + "linked_concepts": linked_concepts, + "vietnamese_hint": vietnamese_hint, + "scores": scores, + "story_context": request.story_context, + "audio_base64": audio_b64, + "metadata": { + "latency_ms": total_ms, + "model_used": model_used, + "cache_hit": cache_hit, + "quota": { + "rpm_used": quota.rpm_used, + "rpm_limit": quota.rpm_limit, + "rpd_used": quota.rpd_used, + "rpd_limit": quota.rpd_limit, + }, + }, + }) + yield f"event: done\ndata: {done_payload}\n\n" + + logger.info( + "Lexi /stream complete — %dms, model: %s, cache_hit: %s", + total_ms, model_used, cache_hit, + ) + await emit_ai_audit_event({ + "request_id": request_id, + "user_id": current_user.user_id, + "endpoint": "lexi.stream", + "status": "success", + "session_id": session_id, + "model_used": model_used, + "latency_ms": total_ms, + "quota": {"rpm_used": quota.rpm_used, "rpm_limit": quota.rpm_limit}, + }) diff --git a/ai-service/tests/test_history_restore_endpoints.py b/ai-service/tests/test_history_restore_endpoints.py index 661ce6d6..e6b256d1 100644 --- a/ai-service/tests/test_history_restore_endpoints.py +++ b/ai-service/tests/test_history_restore_endpoints.py @@ -7,6 +7,7 @@ from api.routes import chat as chat_route from api.routes import lexi_chat as lexi_route from api.routes import topic_chat as topic_route +from api.services import lexi_chat_service as lexi_svc from api.core.auth import AuthenticatedUser @@ -87,7 +88,7 @@ def mock_lexi_store(monkeypatch): store.set_session = AsyncMock() store.delete_messages = AsyncMock() store.append_message = AsyncMock() - monkeypatch.setattr(lexi_route, "_store", store) + monkeypatch.setattr(lexi_svc, "lexi_store", store) return store diff --git a/ai-service/tests/test_lexi_chat_routes.py b/ai-service/tests/test_lexi_chat_routes.py index 24791c76..58090750 100644 --- a/ai-service/tests/test_lexi_chat_routes.py +++ b/ai-service/tests/test_lexi_chat_routes.py @@ -9,6 +9,7 @@ from bson import ObjectId from api.routes import lexi_chat as lexi_route +from api.services import lexi_chat_service as svc from api.core.auth import AuthenticatedUser @@ -45,7 +46,7 @@ def mock_store(monkeypatch): store.get_session = AsyncMock(return_value=None) store.delete_session = AsyncMock() store.delete_messages = AsyncMock() - monkeypatch.setattr(lexi_route, "_store", store) + monkeypatch.setattr(svc, "lexi_store", store) return store @@ -54,7 +55,7 @@ def mock_idempotency(monkeypatch): idem = MagicMock() idem.get = AsyncMock(return_value=None) idem.set = AsyncMock() - monkeypatch.setattr(lexi_route, "_idempotency_store", idem) + monkeypatch.setattr(svc, "lexi_idempotency_store", idem) return idem @@ -135,7 +136,7 @@ async def test_lexi_chat_returns_response(mock_store, mock_idempotency, monkeypa monkeypatch.setattr(lexi_route, "enforce_user_quota", AsyncMock(return_value=_quota())) monkeypatch.setattr(lexi_route, "emit_ai_audit_event", AsyncMock()) - pipeline_result = lexi_route._PipelineResult( + pipeline_result = svc.PipelineResult( lexi_response="Squawk! Great question!", user_text="Hello Lexi", message_id="msg-1", @@ -143,7 +144,7 @@ async def test_lexi_chat_returns_response(mock_store, mock_idempotency, monkeypa model_used="trace-cag", metadata={"pipeline_steps": [], "latency_ms": 100, "quota": {}}, ) - monkeypatch.setattr(lexi_route, "_run_lexi_pipeline", AsyncMock(return_value=pipeline_result)) + monkeypatch.setattr(svc, "run_lexi_pipeline", AsyncMock(return_value=pipeline_result)) db = _make_db() request_ctx = MagicMock() @@ -174,7 +175,7 @@ async def test_lexi_chat_returns_cached_idempotency_response(mock_store, mock_id mock_idempotency.get.return_value = cached run_pipeline = AsyncMock() - monkeypatch.setattr(lexi_route, "_run_lexi_pipeline", run_pipeline) + monkeypatch.setattr(svc, "run_lexi_pipeline", run_pipeline) db = _make_db() request_ctx = MagicMock() @@ -207,7 +208,7 @@ async def test_lexi_chat_uses_hot_cached_session_without_mongo_lookup( mock_store.get_session.return_value = {"session_id": "s1", "user_id": "u1"} mock_store.get_messages.return_value = cached_history - pipeline_result = lexi_route._PipelineResult( + pipeline_result = svc.PipelineResult( lexi_response="Fast cached path", user_text="Hello again", message_id="msg-1", @@ -216,7 +217,7 @@ async def test_lexi_chat_uses_hot_cached_session_without_mongo_lookup( metadata={"pipeline_steps": [], "latency_ms": 50, "quota": {}}, ) run_pipeline = AsyncMock(return_value=pipeline_result) - monkeypatch.setattr(lexi_route, "_run_lexi_pipeline", run_pipeline) + monkeypatch.setattr(svc, "run_lexi_pipeline", run_pipeline) db = _make_db(session_doc={"session_id": "s1", "user_id": "u1"}) request_ctx = MagicMock() @@ -251,7 +252,7 @@ async def test_lexi_chat_quota_exceeded_raises_429(mock_store, monkeypatch): AsyncMock(side_effect=HTTPException(status_code=429, detail="quota exceeded")), ) run_pipeline = AsyncMock() - monkeypatch.setattr(lexi_route, "_run_lexi_pipeline", run_pipeline) + monkeypatch.setattr(svc, "run_lexi_pipeline", run_pipeline) db = _make_db() request_ctx = MagicMock() @@ -287,7 +288,7 @@ async def test_lexi_chat_rejects_session_owned_by_another_user( db = _make_db(session_doc={"session_id": "sess-other", "user_id": "owner-1"}) run_pipeline = AsyncMock() - monkeypatch.setattr(lexi_route, "_run_lexi_pipeline", run_pipeline) + monkeypatch.setattr(svc, "run_lexi_pipeline", run_pipeline) request_ctx = MagicMock() request_ctx.headers = {} @@ -328,7 +329,7 @@ async def test_lexi_chat_rejects_unknown_supplied_session_id( db = _make_db(session_doc=None) run_pipeline = AsyncMock() - monkeypatch.setattr(lexi_route, "_run_lexi_pipeline", run_pipeline) + monkeypatch.setattr(svc, "run_lexi_pipeline", run_pipeline) request_ctx = MagicMock() request_ctx.headers = {} @@ -419,7 +420,7 @@ async def test_get_lexi_messages_paged_returns_empty_page(monkeypatch): """No messages → pagination with empty list and zero count.""" session_doc = {"session_id": "s1", "user_id": "u1"} db = _make_db(session_doc=session_doc, msg_docs=[], count=0) - monkeypatch.setattr(lexi_route, "_ensure_session_owner", AsyncMock(return_value=session_doc)) + monkeypatch.setattr(svc, "ensure_session_owner", AsyncMock(return_value=session_doc)) result = await lexi_route.get_lexi_messages_paged( session_id="s1", @@ -443,7 +444,7 @@ async def test_get_lexi_messages_paged_returns_messages(monkeypatch): doc2 = {"id": "m2", "session_id": "s1", "role": "assistant", "content": "Hello!", "timestamp": "2024-01-01T00:00:01", "_id": OID()} session_doc = {"session_id": "s1", "user_id": "u1"} db = _make_db(session_doc=session_doc, msg_docs=[doc2, doc1], count=2) - monkeypatch.setattr(lexi_route, "_ensure_session_owner", AsyncMock(return_value=session_doc)) + monkeypatch.setattr(svc, "ensure_session_owner", AsyncMock(return_value=session_doc)) result = await lexi_route.get_lexi_messages_paged( session_id="s1", @@ -464,7 +465,7 @@ async def test_get_lexi_messages_paged_invalid_limit_raises_400(monkeypatch): session_doc = {"session_id": "s1", "user_id": "u1"} db = _make_db(session_doc=session_doc) - monkeypatch.setattr(lexi_route, "_ensure_session_owner", AsyncMock(return_value=session_doc)) + monkeypatch.setattr(svc, "ensure_session_owner", AsyncMock(return_value=session_doc)) with pytest.raises(HTTPException) as exc_info: await lexi_route.get_lexi_messages_paged( @@ -485,7 +486,7 @@ async def test_get_lexi_messages_metadata_empty_session(monkeypatch): """No messages → all nulls in metadata.""" session_doc = {"session_id": "s1", "user_id": "u1"} db = _make_db(session_doc=session_doc, count=0) - monkeypatch.setattr(lexi_route, "_ensure_session_owner", AsyncMock(return_value=session_doc)) + monkeypatch.setattr(svc, "ensure_session_owner", AsyncMock(return_value=session_doc)) result = await lexi_route.get_lexi_messages_metadata( session_id="s1", @@ -508,7 +509,7 @@ async def test_get_lexi_messages_metadata_with_messages(monkeypatch): session_doc = {"session_id": "s1", "user_id": "u1"} db = _make_db(session_doc=session_doc, count=2) - monkeypatch.setattr(lexi_route, "_ensure_session_owner", AsyncMock(return_value=session_doc)) + monkeypatch.setattr(svc, "ensure_session_owner", AsyncMock(return_value=session_doc)) # Override find to return latest/oldest in the right order per call call_count = [0] diff --git a/ai-service/tests/test_lexi_session_management.py b/ai-service/tests/test_lexi_session_management.py index c279a8c9..015db322 100644 --- a/ai-service/tests/test_lexi_session_management.py +++ b/ai-service/tests/test_lexi_session_management.py @@ -5,6 +5,7 @@ from starlette.requests import Request from api.routes import lexi_chat as lexi_route +from api.services import lexi_chat_service as svc from api.core.auth import AuthenticatedUser @@ -19,7 +20,7 @@ def mock_store(monkeypatch): store.get_session = AsyncMock(return_value=None) store.delete_session = AsyncMock() store.init_messages = AsyncMock() - monkeypatch.setattr(lexi_route, "_store", store) + monkeypatch.setattr(svc, "lexi_store", store) return store @@ -204,7 +205,7 @@ async def test_delete_lexi_session_cleans_db_and_cache(mock_db, mock_store): def test_sanitize_lexi_response_preserves_valid_bold_markdown(): text = "Ah, a **wonderful** question!" - sanitized = lexi_route._sanitize_lexi_response(text) + sanitized = svc._sanitize_lexi_response(text) assert sanitized == text @@ -216,7 +217,7 @@ def test_sanitize_lexi_response_fixes_misaligned_bold_markers(): "3. Keep **good** habits" ) - sanitized = lexi_route._sanitize_lexi_response(raw) + sanitized = svc._sanitize_lexi_response(raw) assert "Daily practice**" not in sanitized assert "(15 mins), **" not in sanitized @@ -324,7 +325,7 @@ def test_sanitize_lexi_response_fixes_escaped_misaligned_bold_markers(): "3. Keep \\*\\*good\\*\\* habits" ) - sanitized = lexi_route._sanitize_lexi_response(raw) + sanitized = svc._sanitize_lexi_response(raw) assert "Daily practice**" not in sanitized assert "(15 mins), **" not in sanitized diff --git a/ai-service/tests/test_tracecag_chat_integration.py b/ai-service/tests/test_tracecag_chat_integration.py index f2acd576..c52f6194 100644 --- a/ai-service/tests/test_tracecag_chat_integration.py +++ b/ai-service/tests/test_tracecag_chat_integration.py @@ -4,6 +4,7 @@ from api.routes import chat as chat_route from api.routes import lexi_chat as lexi_route +from api.services import lexi_chat_service as svc @pytest.fixture @@ -71,7 +72,7 @@ def mock_lexi_store(monkeypatch): store.get_session = AsyncMock(return_value=None) store.delete_session = AsyncMock() store.init_messages = AsyncMock() - monkeypatch.setattr(lexi_route, "_store", store) + monkeypatch.setattr(svc, "lexi_store", store) quota_mock = MagicMock() quota_mock.rpm_used = 1 quota_mock.rpm_limit = 100 @@ -214,8 +215,8 @@ async def test_lexi_chat_voice_uses_stt_trace_cag_and_tts( mock_lexi_db, mock_lexi_store, ): - monkeypatch.setattr(lexi_route, "_transcribe_audio", AsyncMock(return_value="Transcribed from voice")) - monkeypatch.setattr(lexi_route, "_synthesize_tts", AsyncMock(return_value="FAKE_AUDIO_BASE64")) + monkeypatch.setattr(svc, "_transcribe_audio", AsyncMock(return_value="Transcribed from voice")) + monkeypatch.setattr(svc, "_synthesize_tts", AsyncMock(return_value="FAKE_AUDIO_BASE64")) orchestrator = MagicMock() orchestrator.process = AsyncMock( @@ -264,7 +265,7 @@ async def test_lexi_chat_trace_cag_primary_fail_then_degraded_retry_with_tts( mock_lexi_db, mock_lexi_store, ): - monkeypatch.setattr(lexi_route, "_synthesize_tts", AsyncMock(return_value="FAKE_AUDIO_BASE64")) + monkeypatch.setattr(svc, "_synthesize_tts", AsyncMock(return_value="FAKE_AUDIO_BASE64")) orchestrator = MagicMock() orchestrator.process = AsyncMock( @@ -316,7 +317,7 @@ async def _stalled_tts(_text): await asyncio.Event().wait() monkeypatch.setenv("LEXI_TTS_TIMEOUT_SECONDS", "0.01") - monkeypatch.setattr(lexi_route, "_synthesize_tts", _stalled_tts) + monkeypatch.setattr(svc, "_synthesize_tts", _stalled_tts) orchestrator = MagicMock() orchestrator.process = AsyncMock( From d122cdd8240d5298ac7043756114644ba59d8c57 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Huu <2001230909@hufi.edu.vn> Date: Wed, 24 Jun 2026 11:25:58 +0700 Subject: [PATCH 06/20] fix(backend): wire up the already-split admin routers, delete dead admin.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main.py imported a 2152-line app/routes/admin.py whose own docstring called it deprecated, while the actual split files (admin_courses.py, admin_gamification.py, admin_system.py) sat unwired and unused — an abandoned refactor that left the "old" file as the only live one. Verified the split files are an exact route-for-route, AST-identical match (45 routes either way) before switching main.py to them and deleting admin.py. Also finishes the original refactor goal for the /admin/seed endpoint: extracts its ~430 lines of literal seed data into app/core/sample_data_catalog.py and the seeding loop into app/services/admin_seed_service.py, deep-copying the course catalog before the seeding loop mutates it in place so repeated seed runs stay idempotent. Co-Authored-By: Claude Sonnet 4.6 --- .../app/core/sample_data_catalog.py | 468 ++++ backend-service/app/main.py | 8 +- backend-service/app/routes/admin.py | 2152 ----------------- backend-service/app/routes/admin_system.py | 588 +---- .../app/services/admin_seed_service.py | 121 + 5 files changed, 614 insertions(+), 2723 deletions(-) create mode 100644 backend-service/app/core/sample_data_catalog.py delete mode 100644 backend-service/app/routes/admin.py create mode 100644 backend-service/app/services/admin_seed_service.py diff --git a/backend-service/app/core/sample_data_catalog.py b/backend-service/app/core/sample_data_catalog.py new file mode 100644 index 00000000..9223cd55 --- /dev/null +++ b/backend-service/app/core/sample_data_catalog.py @@ -0,0 +1,468 @@ +"""Canonical sample data used by the dev-only /admin/seed endpoint.""" + +from typing import Any + +SAMPLE_ACHIEVEMENTS: list[dict[str, Any]] = [ + {"name": "First Steps", "description": "Complete your first lesson", + "condition_type": "lessons_completed", "condition_value": 1, + "category": "lessons", "rarity": "common", "xp_reward": 10, "gems_reward": 5}, + {"name": "Week Warrior", "description": "Maintain a 7-day streak", + "condition_type": "streak_days", "condition_value": 7, + "category": "streak", "rarity": "rare", "xp_reward": 50, "gems_reward": 20}, + {"name": "Word Collector", "description": "Add 100 words to vocabulary", + "condition_type": "vocab_count", "condition_value": 100, + "category": "vocabulary", "rarity": "epic", "xp_reward": 100, "gems_reward": 50}, + {"name": "Social Butterfly", "description": "Follow 10 friends", + "condition_type": "following_count", "condition_value": 10, + "category": "social", "rarity": "rare", "xp_reward": 30, "gems_reward": 15}, +] + +SAMPLE_COURSE_CATEGORIES: list[dict[str, Any]] = [ + { + "name": "Grammar", + "slug": "grammar", + "description": "Master English grammar rules and structures", + "icon": "📚", + "color": "#4CAF50", + "order_index": 1, + }, + { + "name": "Vocabulary", + "slug": "vocabulary", + "description": "Build practical English vocabulary for daily use", + "icon": "🧠", + "color": "#2196F3", + "order_index": 2, + }, +] + +SAMPLE_COURSES: list[dict[str, Any]] = [ + { + "title": "English Grammar Foundations", + "description": "Start with core grammar patterns for everyday communication.", + "language": "en", + "level": "A1", + "category_slug": "grammar", + "tags": ["grammar", "beginner", "fundamentals"], + "total_xp": 120, + "estimated_duration": 90, + "thumbnail_url": "https://images.unsplash.com/photo-1456513080510-7bf3a84b82f8", + "units": [ + { + "title": "Present Simple Basics", + "description": "Build confidence with daily routine sentences.", + "order_index": 1, + "background_color": "#1E3A8A", + "lessons": [ + { + "title": "I/You/We/They + Verb", + "description": "Form positive present simple sentences.", + "order_index": 1, + "estimated_minutes": 12, + "xp_reward": 20, + "total_exercises": 10, + "exercises": [ + { + "id": "ex_g1_1", + "type": "multiple_choice", + "ui_type": "multiple_choice", + "question": "Choose the correct form: 'I ___ coffee every morning.'", + "options": [{"id": "0", "text": "drink", "is_correct": True}, {"id": "1", "text": "drinks", "is_correct": False}, {"id": "2", "text": "drinking", "is_correct": False}, {"id": "3", "text": "drunk", "is_correct": False}], + "correct_answer": "drink", + "explanation": "With I/You/We/They, use the base verb form." + }, + { + "id": "ex_g1_2", + "type": "true_false", + "ui_type": "true_or_false", + "question": "True or False: 'They plays football' is grammatically correct.", + "options": [{"id": "0", "text": "True", "is_correct": False}, {"id": "1", "text": "False", "is_correct": True}], + "correct_answer": "False", + "explanation": "'They' is plural and takes the base verb 'play', not 'plays'." + }, + { + "id": "ex_g1_3", + "type": "fill_blank", + "ui_type": "fill_in_the_blank", + "question": "Complete the sentence: 'We {blank} in a big city.'", + "correct_answer": "live", + "explanation": "'We' takes the base verb 'live'." + }, + { + "id": "ex_g1_4", + "type": "reorder", + "ui_type": "arrange_the_sentence", + "question": "Arrange: 'day / every / study / they'", + "options": [{"id": "0", "text": "they"}, {"id": "1", "text": "study"}, {"id": "2", "text": "every"}, {"id": "3", "text": "day"}], + "correct_answer": "they study every day", + "explanation": "Subject + Verb + Time expression is the standard structure." + }, + { + "id": "ex_g1_5", + "type": "translate", + "ui_type": "translation_choice", + "question": "Choose the translation for: 'Chúng tôi muốn học.'", + "options": [{"id": "0", "text": "We want to learn.", "is_correct": True}, {"id": "1", "text": "We wants to learn.", "is_correct": False}, {"id": "2", "text": "We learning.", "is_correct": False}], + "correct_answer": "We want to learn.", + "explanation": "'Chúng tôi' translates to 'We', which takes the base verb 'want'." + }, + { + "id": "ex_g1_6", + "type": "fill_blank", + "ui_type": "dialogue_completion", + "question": "A: Do you speak English? B: Yes, I {blank}.", + "correct_answer": "do", + "explanation": "Short answer to 'Do you...' is 'I do'." + }, + { + "id": "ex_g1_7", + "type": "multiple_choice", + "ui_type": "collocation_choice", + "question": "Complete the phrase: 'They ___ homework together.'", + "options": [{"id": "0", "text": "do", "is_correct": True}, {"id": "1", "text": "make", "is_correct": False}], + "correct_answer": "do", + "explanation": "The collocation is 'do homework'." + }, + { + "id": "ex_g1_8", + "type": "fill_blank", + "ui_type": "dictation", + "question": "Listen and write what you hear: '{blank}'", + "correct_answer": "We go home", + "explanation": "Type the exact words heard in the audio." + }, + { + "id": "ex_g1_9", + "type": "translate", + "ui_type": "speaking_repeat", + "question": "Repeat: 'I listen to music.'", + "correct_answer": "I listen to music", + "explanation": "Record yourself repeating this sentence." + }, + { + "id": "ex_g1_10", + "type": "multiple_choice", + "ui_type": "vocabulary_flashcard", + "question": "Learn the card: 'Routine'", + "options": [{"id": "0", "text": "Got it!", "is_correct": True}], + "correct_answer": "Got it!", + "explanation": "Routine is a sequence of actions regularly followed." + } + ] + }, + { + "title": "He/She/It + Verb-s", + "description": "Use third-person singular correctly.", + "order_index": 2, + "estimated_minutes": 12, + "xp_reward": 20, + "total_exercises": 10, + "exercises": [ + { + "id": "ex_g2_1", + "type": "multiple_choice", + "ui_type": "multiple_choice", + "question": "Choose correct: 'He ___ to school by bus.'", + "options": [{"id": "0", "text": "go", "is_correct": False}, {"id": "1", "text": "goes", "is_correct": True}, {"id": "2", "text": "going", "is_correct": False}], + "correct_answer": "goes", + "explanation": "He/she/it takes verb+s/es." + }, + { + "id": "ex_g2_2", + "type": "true_false", + "ui_type": "true_or_false", + "question": "True or False: 'It rain a lot' is correct.", + "options": [{"id": "0", "text": "True", "is_correct": False}, {"id": "1", "text": "False", "is_correct": True}], + "correct_answer": "False", + "explanation": "It should be 'It rains a lot'." + }, + { + "id": "ex_g2_3", + "type": "fill_blank", + "ui_type": "fill_in_the_blank", + "question": "Complete: 'She {blank} the piano well.'", + "correct_answer": "plays", + "explanation": "She requires plays." + }, + { + "id": "ex_g2_4", + "type": "fill_blank", + "ui_type": "grammar_correction", + "question": "Correct the error: 'He play tennis.' -> '{blank}'", + "correct_answer": "He plays tennis", + "explanation": "Add 's' to play." + }, + { + "id": "ex_g2_5", + "type": "multiple_choice", + "ui_type": "image_based_choice", + "question": "Choose the image that represents: 'She runs.'", + "options": [{"id": "0", "text": "Running", "is_correct": True}, {"id": "1", "text": "Sleeping", "is_correct": False}], + "correct_answer": "Running", + "explanation": "Select the correct action card." + }, + { + "id": "ex_g2_6", + "type": "multiple_choice", + "ui_type": "listen_and_choose", + "question": "Listen and select what he does.", + "options": [{"id": "0", "text": "He eats breakfast", "is_correct": True}, {"id": "1", "text": "He plays games", "is_correct": False}], + "correct_answer": "He eats breakfast", + "explanation": "Audio matches breakfast." + }, + { + "id": "ex_g2_7", + "type": "translate", + "ui_type": "pronunciation_practice", + "question": "Practice speaking: 'He plays tennis.'", + "correct_answer": "He plays tennis", + "explanation": "Evaluate pronunciation of He plays tennis." + }, + { + "id": "ex_g2_8", + "type": "multiple_choice", + "ui_type": "reading_comprehension", + "question": "Read and answer: 'Tom is a chef. He works in a restaurant.' Where does Tom work?", + "options": [{"id": "0", "text": "restaurant", "is_correct": True}, {"id": "1", "text": "office", "is_correct": False}], + "correct_answer": "restaurant", + "explanation": "The text states he works in a restaurant." + }, + { + "id": "ex_g2_9", + "type": "fill_blank", + "ui_type": "short_writing_answer", + "question": "Write in English: 'Cô ấy nói tiếng Anh.'", + "correct_answer": "She speaks English", + "explanation": "Translate exactly." + }, + { + "id": "ex_g2_10", + "type": "matching", + "ui_type": "categorization", + "question": "Categorize the pronouns: Singular vs Plural", + "options": [{"id": "he", "text": "Singular"}, {"id": "they", "text": "Plural"}], + "correct_answer": "he:Singular, they:Plural", + "explanation": "He is singular, they is plural." + } + ] + }, + ], + }, + ], + }, + { + "title": "Daily Vocabulary Starter", + "description": "Learn essential words and phrases for daily life.", + "language": "en", + "level": "A1", + "category_slug": "vocabulary", + "tags": ["vocabulary", "daily-life", "beginner"], + "total_xp": 120, + "estimated_duration": 80, + "thumbnail_url": "https://images.unsplash.com/photo-1434030216411-0b793f4b4173", + "units": [ + { + "title": "Home and Family", + "description": "Words you use every day at home.", + "order_index": 1, + "background_color": "#0F766E", + "lessons": [ + { + "title": "Family Members", + "description": "Describe people in your family.", + "order_index": 1, + "estimated_minutes": 10, + "xp_reward": 20, + "total_exercises": 10, + "exercises": [ + { + "id": "ex_v1_1", + "type": "multiple_choice", + "ui_type": "multiple_choice", + "question": "Your father's sister is your ___.", + "options": [{"id": "0", "text": "aunt", "is_correct": True}, {"id": "1", "text": "uncle", "is_correct": False}], + "correct_answer": "aunt", + "explanation": "Father's sister is aunt." + }, + { + "id": "ex_v1_2", + "type": "matching", + "ui_type": "match_word_to_meaning", + "question": "Match the family words with meanings.", + "options": [{"id": "father", "text": "Male parent"}, {"id": "mother", "text": "Female parent"}], + "correct_answer": "father:Male parent, mother:Female parent", + "explanation": "Father is male parent, mother is female." + }, + { + "id": "ex_v1_3", + "type": "fill_blank", + "ui_type": "fill_in_the_blank", + "question": "Complete: 'My mother's husband is my {blank}.'", + "correct_answer": "father", + "explanation": "Mother's husband is father." + }, + { + "id": "ex_v1_4", + "type": "matching", + "ui_type": "cognitive_fluidity", + "question": "Match: mother, sister", + "options": [{"id": "mother", "text": "female parent"}, {"id": "sister", "text": "female sibling"}], + "correct_answer": "mother:female parent, sister:female sibling", + "explanation": "Fast match." + }, + { + "id": "ex_v1_5", + "type": "multiple_choice", + "ui_type": "vocabulary_flashcard", + "question": "Learn: 'Sibling' (anh chị em ruột)", + "options": [{"id": "0", "text": "Got it!", "is_correct": True}], + "correct_answer": "Got it!", + "explanation": "A sibling is a brother or sister." + }, + { + "id": "ex_v1_6", + "type": "true_false", + "ui_type": "true_or_false", + "question": "True or False: 'Cousin' is the child of your aunt or uncle.", + "options": [{"id": "0", "text": "True", "is_correct": True}, {"id": "1", "text": "False", "is_correct": False}], + "correct_answer": "True", + "explanation": "Cousin is child of aunt/uncle." + }, + { + "id": "ex_v1_7", + "type": "reorder", + "ui_type": "arrange_the_sentence", + "question": "Arrange: 'my / this / is / brother'", + "options": [{"id": "0", "text": "this"}, {"id": "1", "text": "is"}, {"id": "2", "text": "my"}, {"id": "3", "text": "brother"}], + "correct_answer": "this is my brother", + "explanation": "Sentence structure." + }, + { + "id": "ex_v1_8", + "type": "translate", + "ui_type": "translation_choice", + "question": "Translation of: 'Anh trai'", + "options": [{"id": "0", "text": "brother", "is_correct": True}, {"id": "1", "text": "sister", "is_correct": False}], + "correct_answer": "brother", + "explanation": "Anh trai is brother." + }, + { + "id": "ex_v1_9", + "type": "translate", + "ui_type": "speaking_repeat", + "question": "Repeat: 'My grandmother is nice.'", + "correct_answer": "My grandmother is nice", + "explanation": "Practice speaking." + }, + { + "id": "ex_v1_10", + "type": "fill_blank", + "ui_type": "dictation", + "question": "Dictation: Listen and write '{blank}'", + "correct_answer": "He is my uncle", + "explanation": "Type what you hear." + } + ] + }, + { + "title": "Rooms and Objects", + "description": "Talk about places and things at home.", + "order_index": 2, + "estimated_minutes": 10, + "xp_reward": 20, + "total_exercises": 10, + "exercises": [ + { + "id": "ex_v2_1", + "type": "multiple_choice", + "ui_type": "multiple_choice", + "question": "You cook meals in the ___.", + "options": [{"id": "0", "text": "kitchen", "is_correct": True}, {"id": "1", "text": "bedroom", "is_correct": False}], + "correct_answer": "kitchen", + "explanation": "Kitchen is for cooking." + }, + { + "id": "ex_v2_2", + "type": "matching", + "ui_type": "match_word_to_meaning", + "question": "Match rooms with objects.", + "options": [{"id": "bedroom", "text": "bed"}, {"id": "kitchen", "text": "fridge"}], + "correct_answer": "bedroom:bed, kitchen:fridge", + "explanation": "Bed in bedroom, fridge in kitchen." + }, + { + "id": "ex_v2_3", + "type": "fill_blank", + "ui_type": "fill_in_the_blank", + "question": "Complete: 'I sleep in my {blank}.'", + "correct_answer": "bedroom", + "explanation": "Bedroom is for sleeping." + }, + { + "id": "ex_v2_4", + "type": "true_false", + "ui_type": "true_or_false", + "question": "True or False: A sofa is typically placed in the living room.", + "options": [{"id": "0", "text": "True", "is_correct": True}, {"id": "1", "text": "False", "is_correct": False}], + "correct_answer": "True", + "explanation": "Sofa in living room." + }, + { + "id": "ex_v2_5", + "type": "reorder", + "ui_type": "arrange_the_sentence", + "question": "Arrange: 'in / the / bed / is / bedroom / the'", + "options": [{"id": "0", "text": "the"}, {"id": "1", "text": "bed"}, {"id": "2", "text": "is"}, {"id": "3", "text": "in"}, {"id": "4", "text": "the"}, {"id": "5", "text": "bedroom"}], + "correct_answer": "the bed is in the bedroom", + "explanation": "Correct sentence structure." + }, + { + "id": "ex_v2_6", + "type": "translate", + "ui_type": "translation_choice", + "question": "Translation of: 'Phòng tắm'", + "options": [{"id": "0", "text": "bathroom", "is_correct": True}, {"id": "1", "text": "garden", "is_correct": False}], + "correct_answer": "bathroom", + "explanation": "Phòng tắm is bathroom." + }, + { + "id": "ex_v2_7", + "type": "translate", + "ui_type": "speaking_repeat", + "question": "Repeat: 'The desk is clean.'", + "correct_answer": "The desk is clean", + "explanation": "Evaluate pronunciation." + }, + { + "id": "ex_v2_8", + "type": "fill_blank", + "ui_type": "dictation", + "question": "Dictation: Listen and write '{blank}'", + "correct_answer": "Open the window", + "explanation": "Write what you hear." + }, + { + "id": "ex_v2_9", + "type": "multiple_choice", + "ui_type": "image_based_choice", + "question": "Select the image that shows a table.", + "options": [{"id": "0", "text": "Table", "is_correct": True}, {"id": "1", "text": "Chair", "is_correct": False}], + "correct_answer": "Table", + "explanation": "Card with table." + }, + { + "id": "ex_v2_10", + "type": "multiple_choice", + "ui_type": "vocabulary_flashcard", + "question": "Learn: 'Furnishings'", + "options": [{"id": "0", "text": "Got it!", "is_correct": True}], + "correct_answer": "Got it!", + "explanation": "Furniture and appliances in a room." + } + ] + }, + ], + }, + ], + }, +] diff --git a/backend-service/app/main.py b/backend-service/app/main.py index 8baef223..ab854d48 100644 --- a/backend-service/app/main.py +++ b/backend-service/app/main.py @@ -48,7 +48,9 @@ gamification_router, ) from app.routes.learning import router as learning_router -from app.routes.admin import router as admin_router +from app.routes.admin_courses import router as admin_courses_router +from app.routes.admin_gamification import router as admin_gamification_router +from app.routes.admin_system import router as admin_system_router from app.routes.content_agent import router as content_agent_router from app.routes.notification_campaign import router as notification_campaign_router from app.routes.ranking_agent import router as ranking_agent_router @@ -282,7 +284,9 @@ async def limit_request_body(request: Request, call_next): app.include_router(vocabulary_router, prefix=f"{settings.API_V1_PREFIX}/vocabulary", tags=["Vocabulary"]) app.include_router(gamification_router, prefix=f"{settings.API_V1_PREFIX}", tags=["Gamification"]) app.include_router(challenges_router, prefix=f"{settings.API_V1_PREFIX}", tags=["Challenges"]) -app.include_router(admin_router, prefix=f"{settings.API_V1_PREFIX}", tags=["Admin"]) +app.include_router(admin_courses_router, prefix=f"{settings.API_V1_PREFIX}", tags=["Admin"]) +app.include_router(admin_gamification_router, prefix=f"{settings.API_V1_PREFIX}", tags=["Admin"]) +app.include_router(admin_system_router, prefix=f"{settings.API_V1_PREFIX}", tags=["Admin"]) app.include_router(content_agent_router, prefix=f"{settings.API_V1_PREFIX}") app.include_router(notification_campaign_router, prefix=f"{settings.API_V1_PREFIX}") app.include_router(ranking_agent_router, prefix=f"{settings.API_V1_PREFIX}") diff --git a/backend-service/app/routes/admin.py b/backend-service/app/routes/admin.py deleted file mode 100644 index 4186e43d..00000000 --- a/backend-service/app/routes/admin.py +++ /dev/null @@ -1,2152 +0,0 @@ -""" -Admin API Routes — DEPRECATED - -This module has been split into: - app/routes/admin_courses.py — courses, units, lessons, vocab, grammar, questions, test-exams - app/routes/admin_gamification.py — achievements, shop - app/routes/admin_system.py — seed endpoint, system-info, quota - -main.py now registers admin_courses_router, admin_gamification_router, admin_system_router directly. -This file is kept only as a reference and is no longer imported. -""" - -from typing import Optional, List -from uuid import UUID -from fastapi import APIRouter, Depends, HTTPException, Query, status, UploadFile, File -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, or_, func, desc - -from app.core.database import get_db -from app.core.dependencies import get_current_admin, get_current_super_admin -from app.models.user import User -from app.models.rbac import Role -from app.models.course import Course, Unit, Lesson -from app.models.course_category import CourseCategory -from app.models.vocabulary import VocabularyItem -from app.models.gamification import Achievement, ShopItem -from app.models.content import GrammarItem, QuestionItem, TestExam -from app.crud.course import CourseCRUD, UnitCRUD, LessonCRUD -from app.schemas.course import ( - CourseCreate, CourseUpdate, CourseResponse, - UnitCreate, UnitUpdate, UnitResponse, - LessonCreate, LessonUpdate, LessonResponse, LessonDetailResponse -) -from app.schemas.response import ApiResponse -from app.schemas.content import ( - GrammarCreate, GrammarUpdate, GrammarResponse, - QuestionCreate, QuestionUpdate, QuestionResponse, - TestExamCreate, TestExamUpdate, TestExamResponse -) -from app.schemas.user import AdminUserUpdate, AdminUserListItem - -router = APIRouter(prefix="/admin", tags=["Admin"]) - - -# ============================================================================ -# RBAC: Admin guard — requires role.level >= 1 (admin or super_admin) -# Imported from app.core.dependencies.get_current_admin -# ============================================================================ - -# Alias for backward compatibility in this file -require_admin = get_current_admin - - -# ============================================================================ -# Course Admin CRUD -# ============================================================================ - -@router.get("/courses", response_model=ApiResponse[dict]) -async def list_courses_admin( - page: int = Query(1, ge=1), - page_size: int = Query(20, ge=1, le=100), - search: Optional[str] = Query(None), - level: Optional[str] = Query(None), - is_published: Optional[bool] = Query(None), - db: AsyncSession = Depends(get_db), - admin_user: User = Depends(require_admin) -): - """List all courses (including unpublished) for admin management.""" - query = select(Course) - filters = [] - if search: - pattern = f"%{search}%" - filters.append(or_(Course.title.ilike(pattern), Course.description.ilike(pattern))) - if level: - filters.append(Course.level == level) - if is_published is not None: - filters.append(Course.is_published == is_published) - if filters: - from sqlalchemy import and_ - query = query.where(and_(*filters)) - - count_q = select(func.count()).select_from(query.subquery()) - total = await db.scalar(count_q) or 0 - - query = query.order_by(desc(Course.updated_at)) - offset = (page - 1) * page_size - result = await db.execute(query.offset(offset).limit(page_size)) - courses = result.scalars().all() - - return ApiResponse( - success=True, - message=f"Retrieved {len(courses)} courses", - data={ - "courses": [CourseResponse.model_validate(c).model_dump() for c in courses], - "total": total, - "page": page, - "page_size": page_size, - "total_pages": (total + page_size - 1) // page_size, - } - ) - - -@router.get("/units", response_model=ApiResponse[List[dict]]) -async def list_units_admin( - course_id: Optional[UUID] = Query(None, description="Filter units by course"), - search: Optional[str] = Query(None, description="Search units by title or description"), - db: AsyncSession = Depends(get_db), - admin_user: User = Depends(require_admin) -): - """List all units, optionally filtered by course or searched by title/description.""" - query = select(Unit) - if course_id: - query = query.where(Unit.course_id == course_id) - if search: - query = query.where( - or_( - Unit.title.ilike(f"%{search}%"), - Unit.description.ilike(f"%{search}%") - ) - ) - query = query.order_by(Unit.order_index) - result = await db.execute(query) - units = result.scalars().all() - return ApiResponse( - success=True, - message=f"Retrieved {len(units)} units", - data=[UnitResponse.model_validate(u).model_dump() for u in units] - ) - - -@router.get("/lessons", response_model=ApiResponse[List[dict]]) -async def list_lessons_admin( - unit_id: Optional[UUID] = Query(None, description="Filter by unit"), - course_id: Optional[UUID] = Query(None, description="Filter by course"), - db: AsyncSession = Depends(get_db), - admin_user: User = Depends(require_admin) -): - """List lessons filtered by unit or course.""" - query = select(Lesson) - if unit_id: - query = query.where(Lesson.unit_id == unit_id) - elif course_id: - query = query.where(Lesson.course_id == course_id) - query = query.order_by(Lesson.order_index) - result = await db.execute(query) - lessons = result.scalars().all() - return ApiResponse( - success=True, - message=f"Retrieved {len(lessons)} lessons", - data=[LessonResponse.model_validate(l).model_dump() for l in lessons] - ) - - -@router.post("/courses", response_model=ApiResponse[CourseResponse]) -async def create_course( - course: CourseCreate, - db: AsyncSession = Depends(get_db), - admin_user: User = Depends(require_admin) -): - """ - Create a new course. - - Admin only endpoint. - """ - new_course = await CourseCRUD.create_course(db, course) - - return ApiResponse( - success=True, - message="Course created successfully", - data=CourseResponse.model_validate(new_course) - ) - - -@router.put("/courses/{course_id}", response_model=ApiResponse[CourseResponse]) -async def update_course( - course_id: UUID, - course_update: CourseUpdate, - db: AsyncSession = Depends(get_db), - admin_user: User = Depends(require_admin) -): - """ - Update an existing course. - - Admin only endpoint. - """ - updated_course = await CourseCRUD.update_course(db, course_id, course_update) - - if not updated_course: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Course not found" - ) - - return ApiResponse( - success=True, - message="Course updated successfully", - data=CourseResponse.model_validate(updated_course) - ) - - -@router.delete("/courses/{course_id}", response_model=ApiResponse[dict]) -async def delete_course( - course_id: UUID, - db: AsyncSession = Depends(get_db), - admin_user: User = Depends(require_admin) -): - """ - Delete a course (soft delete - unpublish). - - Admin only endpoint. - """ - success = await CourseCRUD.delete_course(db, course_id) - - if not success: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Course not found" - ) - - return ApiResponse( - success=True, - message="Course deleted successfully", - data={"deleted": True, "course_id": str(course_id)} - ) - - -# ============================================================================ -# Unit Admin CRUD -# ============================================================================ - -@router.post("/units", response_model=ApiResponse[UnitResponse]) -async def create_unit( - unit: UnitCreate, - db: AsyncSession = Depends(get_db), - admin_user: User = Depends(require_admin) -): - """ - Create a new unit within a course. - - Admin only endpoint. - """ - # Verify course exists - course = await CourseCRUD.get_course(db, unit.course_id) - if not course: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Course not found" - ) - - new_unit = await UnitCRUD.create_unit(db, unit) - - return ApiResponse( - success=True, - message="Unit created successfully", - data=UnitResponse.model_validate(new_unit) - ) - - -@router.put("/units/{unit_id}", response_model=ApiResponse[UnitResponse]) -async def update_unit( - unit_id: UUID, - unit_update: UnitUpdate, - db: AsyncSession = Depends(get_db), - admin_user: User = Depends(require_admin) -): - """ - Update an existing unit. - - Admin only endpoint. - """ - updated_unit = await UnitCRUD.update_unit(db, unit_id, unit_update) - - if not updated_unit: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Unit not found" - ) - - return ApiResponse( - success=True, - message="Unit updated successfully", - data=UnitResponse.model_validate(updated_unit) - ) - - -@router.delete("/units/{unit_id}", response_model=ApiResponse[dict]) -async def delete_unit( - unit_id: UUID, - db: AsyncSession = Depends(get_db), - admin_user: User = Depends(require_admin) -): - """ - Delete a unit (cascade deletes lessons). - - Admin only endpoint. - """ - success = await UnitCRUD.delete_unit(db, unit_id) - - if not success: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Unit not found" - ) - - return ApiResponse( - success=True, - message="Unit deleted successfully", - data={"deleted": True, "unit_id": str(unit_id)} - ) - - -# ============================================================================ -# Lesson Admin CRUD -# ============================================================================ - -@router.post("/lessons", response_model=ApiResponse[LessonResponse]) -async def create_lesson( - lesson: LessonCreate, - db: AsyncSession = Depends(get_db), - admin_user: User = Depends(require_admin) -): - """ - Create a new lesson within a unit. - - Admin only endpoint. - """ - # Verify unit exists - unit = await UnitCRUD.get_unit(db, lesson.unit_id) - if not unit: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Unit not found" - ) - - new_lesson = await LessonCRUD.create_lesson(db, lesson) - - return ApiResponse( - success=True, - message="Lesson created successfully", - data=LessonResponse.model_validate(new_lesson) - ) - - -@router.put("/lessons/{lesson_id}", response_model=ApiResponse[LessonResponse]) -async def update_lesson( - lesson_id: UUID, - lesson_update: LessonUpdate, - db: AsyncSession = Depends(get_db), - admin_user: User = Depends(require_admin) -): - """ - Update an existing lesson. - - Admin only endpoint. - """ - updated_lesson = await LessonCRUD.update_lesson(db, lesson_id, lesson_update) - - if not updated_lesson: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Lesson not found" - ) - - return ApiResponse( - success=True, - message="Lesson updated successfully", - data=LessonResponse.model_validate(updated_lesson) - ) - - -@router.delete("/lessons/{lesson_id}", response_model=ApiResponse[dict]) -async def delete_lesson( - lesson_id: UUID, - db: AsyncSession = Depends(get_db), - admin_user: User = Depends(require_admin) -): - """ - Delete a lesson. - - Admin only endpoint. - """ - success = await LessonCRUD.delete_lesson(db, lesson_id) - - if not success: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Lesson not found" - ) - - return ApiResponse( - success=True, - message="Lesson deleted successfully", - data={"deleted": True, "lesson_id": str(lesson_id)} - ) - - -@router.get("/lessons/{lesson_id}", response_model=ApiResponse[dict]) -async def get_lesson_detail( - lesson_id: UUID, - db: AsyncSession = Depends(get_db), - admin_user: User = Depends(require_admin) -): - """Get a single lesson with full content/exercises (admin only).""" - lesson = await LessonCRUD.get_lesson(db, lesson_id) - if not lesson: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Lesson not found") - - data = LessonDetailResponse.model_validate(lesson).model_dump() - return ApiResponse(success=True, message="Lesson retrieved", data=data) - - -class LessonContentUpdate(LessonUpdate): - """Dedicated schema for updating lesson exercises content.""" - pass - - -@router.put("/lessons/{lesson_id}/content", response_model=ApiResponse[dict]) -async def update_lesson_content( - lesson_id: UUID, - payload: LessonContentUpdate, - db: AsyncSession = Depends(get_db), - admin_user: User = Depends(require_admin) -): - """Update lesson exercises and estimated_minutes (admin only).""" - lesson = await LessonCRUD.get_lesson(db, lesson_id) - if not lesson: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Lesson not found") - - update_data = payload.model_dump(exclude_unset=True) - - # Derive total_exercises from exercises array inside content - if "content" in update_data and update_data["content"]: - exercises = update_data["content"].get("exercises", []) - update_data["total_exercises"] = len(exercises) - - for field, value in update_data.items(): - setattr(lesson, field, value) - - await db.commit() - await db.refresh(lesson) - - data = LessonDetailResponse.model_validate(lesson).model_dump() - return ApiResponse(success=True, message="Lesson content updated", data=data) - - -# ============================================================================ -# Vocabulary Admin CRUD -# ============================================================================ - -@router.get("/vocabulary", response_model=ApiResponse[List[dict]]) -async def list_vocabulary( - limit: int = Query(50, ge=1, le=100), - offset: int = Query(0, ge=0), - db: AsyncSession = Depends(get_db), - admin_user: User = Depends(require_admin) -): - """ - List all vocabulary items. - - Admin only endpoint. - """ - result = await db.execute( - select(VocabularyItem) - .order_by(VocabularyItem.word) - .limit(limit) - .offset(offset) - ) - items = result.scalars().all() - - return ApiResponse( - success=True, - message=f"Retrieved {len(items)} vocabulary items", - data=[{ - "id": str(item.id), - "word": item.word, - "definition": getattr(item, "definition", None), - "translation": item.translation, - "part_of_speech": item.part_of_speech, - "pronunciation": getattr(item, "pronunciation", None), - "difficulty_level": item.difficulty_level, - } for item in items] - ) - - -@router.post("/vocabulary", response_model=ApiResponse[dict]) -async def create_vocabulary( - word: str, - definition: str, - translation: str, - part_of_speech: str = "noun", - pronunciation: Optional[str] = None, - difficulty_level: str = "A1", - db: AsyncSession = Depends(get_db), - admin_user: User = Depends(require_admin) -): - """ - Create a new vocabulary item. - - Args: - word: The vocabulary word - definition: English definition - translation: Vietnamese translation - part_of_speech: Part of speech (noun, verb, adjective, etc.) - pronunciation: IPA pronunciation - difficulty_level: CEFR level (A1, A2, B1, B2, C1, C2) - - Admin only endpoint. - """ - vocab = VocabularyItem( - word=word, - definition=definition, - translation={"vi": translation}, # JSON format as per model - part_of_speech=part_of_speech, - pronunciation=pronunciation, - difficulty_level=difficulty_level - ) - db.add(vocab) - await db.commit() - await db.refresh(vocab) - - return ApiResponse( - success=True, - message="Vocabulary created successfully", - data={ - "id": str(vocab.id), - "word": vocab.word, - "definition": vocab.definition, - "translation": vocab.translation - } - ) - - -@router.delete("/vocabulary/{vocab_id}", response_model=ApiResponse[dict]) -async def delete_vocabulary( - vocab_id: UUID, - db: AsyncSession = Depends(get_db), - admin_user: User = Depends(require_admin) -): - """ - Delete a vocabulary item. - - Admin only endpoint. - """ - result = await db.execute( - select(VocabularyItem).where(VocabularyItem.id == vocab_id) - ) - vocab = result.scalar_one_or_none() - - if not vocab: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Vocabulary not found" - ) - - await db.delete(vocab) - await db.commit() - - return ApiResponse( - success=True, - message="Vocabulary deleted successfully", - data={"deleted": True, "vocab_id": str(vocab_id)} - ) - - -@router.put("/vocabulary/{vocab_id}", response_model=ApiResponse[dict]) -async def update_vocabulary( - vocab_id: UUID, - word: Optional[str] = None, - definition: Optional[str] = None, - translation: Optional[str] = None, - part_of_speech: Optional[str] = None, - pronunciation: Optional[str] = None, - difficulty_level: Optional[str] = None, - db: AsyncSession = Depends(get_db), - admin_user: User = Depends(require_admin) -): - """Update a vocabulary item.""" - result = await db.execute( - select(VocabularyItem).where(VocabularyItem.id == vocab_id) - ) - vocab = result.scalar_one_or_none() - if not vocab: - raise HTTPException(status_code=404, detail="Vocabulary not found") - - if word is not None: vocab.word = word - if definition is not None: vocab.definition = definition - if translation is not None: vocab.translation = {"vi": translation} - if part_of_speech is not None: vocab.part_of_speech = part_of_speech - if pronunciation is not None: vocab.pronunciation = pronunciation - if difficulty_level is not None: vocab.difficulty_level = difficulty_level - - await db.commit() - await db.refresh(vocab) - - return ApiResponse( - success=True, - message="Vocabulary updated successfully", - data={ - "id": str(vocab.id), - "word": vocab.word, - "definition": vocab.definition, - "translation": vocab.translation, - "part_of_speech": vocab.part_of_speech, - "difficulty_level": vocab.difficulty_level, - } - ) - - -@router.post("/vocabulary/bulk-import", response_model=ApiResponse[dict]) -async def bulk_import_vocabulary( - file: UploadFile = File(...), - db: AsyncSession = Depends(get_db), - admin_user: User = Depends(require_admin) -): - """ - Bulk import vocabulary from CSV file. - - CSV format: word,definition,translation,part_of_speech,pronunciation,difficulty_level - First row must be headers. - """ - import csv - import io - - if not file.filename or not file.filename.endswith(".csv"): - raise HTTPException(status_code=400, detail="Only CSV files are supported") - - # Limit CSV file size to 10MB to prevent memory exhaustion - content = await file.read() - if len(content) > 10 * 1024 * 1024: - raise HTTPException(status_code=413, detail="File too large. Maximum 10MB allowed.") - text = content.decode("utf-8-sig") # Handle BOM - reader = csv.DictReader(io.StringIO(text)) - - created = 0 - skipped = 0 - errors = [] - - for row_num, row in enumerate(reader, start=2): - word = row.get("word", "").strip() - definition = row.get("definition", "").strip() - translation = row.get("translation", "").strip() - - if not word: - skipped += 1 - continue - - # Check duplicate - existing = await db.scalar( - select(func.count()).where(VocabularyItem.word == word) - ) - if existing: - skipped += 1 - continue - - try: - vocab = VocabularyItem( - word=word, - definition=definition or word, - translation={"vi": translation} if translation else {}, - part_of_speech=row.get("part_of_speech", "noun").strip() or "noun", - pronunciation=row.get("pronunciation", "").strip() or None, - difficulty_level=row.get("difficulty_level", "A1").strip() or "A1", - ) - db.add(vocab) - created += 1 - except Exception as e: - errors.append(f"Row {row_num}: {str(e)}") - - await db.commit() - - return ApiResponse( - success=True, - message=f"Imported {created} words, skipped {skipped} duplicates", - data={ - "created": created, - "skipped": skipped, - "errors": errors[:10], # Limit error list - } - ) - - -# ============================================================================ -# Badge Image Upload -# ============================================================================ - -@router.post("/upload/badge", response_model=ApiResponse[dict]) -async def upload_badge_image( - file: UploadFile = File(...), - admin_user: User = Depends(require_admin) -): - """ - Upload a badge image. Returns the URL path to the uploaded file. - Accepts PNG, JPG, WEBP images up to 2MB. - """ - import os - from pathlib import Path - - # Validate file type — SVG excluded to prevent XSS - allowed_types = ["image/png", "image/jpeg", "image/webp"] - if file.content_type not in allowed_types: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid file type: {file.content_type}. Allowed: {', '.join(allowed_types)}" - ) - - # Read file and check size (2MB limit) - contents = await file.read() - if len(contents) > 2 * 1024 * 1024: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="File too large. Maximum 2MB allowed." - ) - - # Save to static/badges/ - static_dir = Path(__file__).resolve().parent.parent.parent / "static" / "badges" - static_dir.mkdir(parents=True, exist_ok=True) - - # Generate UUID-based filename to prevent path traversal and name collisions - import uuid - allowed_extensions = {".png", ".jpg", ".jpeg", ".webp"} - original_ext = Path(file.filename or "badge.png").suffix.lower() - if original_ext not in allowed_extensions: - original_ext = ".png" - safe_name = f"{uuid.uuid4().hex}{original_ext}" - filepath = static_dir / safe_name - - with open(filepath, "wb") as f: - f.write(contents) - - badge_url = f"/static/badges/{filepath.name}" - - return ApiResponse( - success=True, - message="Badge image uploaded successfully", - data={ - "url": badge_url, - "filename": filepath.name, - } - ) - - -# ============================================================================ -# Achievement Admin CRUD -# ============================================================================ - -@router.get("/achievements", response_model=ApiResponse[List[dict]]) -async def list_achievements_admin( - db: AsyncSession = Depends(get_db), - admin_user: User = Depends(require_admin) -): - """ - List all achievements (admin view). - - Admin only endpoint. - """ - result = await db.execute( - select(Achievement).order_by(Achievement.category, Achievement.name) - ) - achievements = result.scalars().all() - - return ApiResponse( - success=True, - message=f"Retrieved {len(achievements)} achievements", - data=[{ - "id": str(a.id), - "slug": a.slug, - "name": a.name, - "description": a.description, - "badge_icon": a.badge_icon, - "badge_color": a.badge_color, - "condition_type": a.condition_type, - "condition_value": a.condition_value, - "category": a.category, - "rarity": a.rarity, - "xp_reward": a.xp_reward, - "gems_reward": a.gems_reward, - "is_hidden": a.is_hidden - } for a in achievements] - ) - - -@router.post("/achievements", response_model=ApiResponse[dict]) -async def create_achievement( - name: str, - description: str, - condition_type: str, - condition_value: int = 1, - category: str = "special", - rarity: str = "common", - xp_reward: int = 0, - gems_reward: int = 0, - is_hidden: bool = False, - badge_icon: Optional[str] = None, - badge_color: Optional[str] = None, - slug: Optional[str] = None, - db: AsyncSession = Depends(get_db), - admin_user: User = Depends(require_admin) -): - """ - Create a new achievement. - - Admin only endpoint. - """ - achievement = Achievement( - name=name, - slug=slug, - description=description, - condition_type=condition_type, - condition_value=condition_value, - category=category, - rarity=rarity, - xp_reward=xp_reward, - gems_reward=gems_reward, - is_hidden=is_hidden, - badge_icon=badge_icon, - badge_color=badge_color - ) - db.add(achievement) - await db.commit() - await db.refresh(achievement) - - return ApiResponse( - success=True, - message="Achievement created successfully", - data={ - "id": str(achievement.id), - "name": achievement.name, - "category": achievement.category - } - ) - - -@router.put("/achievements/{achievement_id}", response_model=ApiResponse[dict]) -async def update_achievement( - achievement_id: UUID, - name: Optional[str] = None, - description: Optional[str] = None, - condition_type: Optional[str] = None, - condition_value: Optional[int] = None, - category: Optional[str] = None, - rarity: Optional[str] = None, - xp_reward: Optional[int] = None, - gems_reward: Optional[int] = None, - is_hidden: Optional[bool] = None, - badge_icon: Optional[str] = None, - badge_color: Optional[str] = None, - slug: Optional[str] = None, - db: AsyncSession = Depends(get_db), - admin_user: User = Depends(require_admin) -): - """Update an achievement. Admin only.""" - result = await db.execute( - select(Achievement).where(Achievement.id == achievement_id) - ) - achievement = result.scalar_one_or_none() - if not achievement: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Achievement not found") - - for field, value in { - "name": name, "description": description, "condition_type": condition_type, - "condition_value": condition_value, "category": category, "rarity": rarity, - "xp_reward": xp_reward, "gems_reward": gems_reward, "is_hidden": is_hidden, - "badge_icon": badge_icon, "badge_color": badge_color, "slug": slug, - }.items(): - if value is not None: - setattr(achievement, field, value) - - await db.commit() - await db.refresh(achievement) - - return ApiResponse( - success=True, - message="Achievement updated successfully", - data={ - "id": str(achievement.id), - "name": achievement.name, - "category": achievement.category, - "rarity": achievement.rarity, - } - ) - - -@router.delete("/achievements/{achievement_id}", response_model=ApiResponse[dict]) -async def delete_achievement( - achievement_id: UUID, - db: AsyncSession = Depends(get_db), - admin_user: User = Depends(require_admin) -): - """ - Delete an achievement. - - Admin only endpoint. - """ - result = await db.execute( - select(Achievement).where(Achievement.id == achievement_id) - ) - achievement = result.scalar_one_or_none() - - if not achievement: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Achievement not found" - ) - - await db.delete(achievement) - await db.commit() - - return ApiResponse( - success=True, - message="Achievement deleted successfully", - data={"deleted": True, "achievement_id": str(achievement_id)} - ) - - -# ============================================================================ -# Shop Admin CRUD -# ============================================================================ - -@router.get("/shop", response_model=ApiResponse[List[dict]]) -async def list_shop_items_admin( - include_unavailable: bool = Query(False), - db: AsyncSession = Depends(get_db), - admin_user: User = Depends(require_admin) -): - """ - List all shop items (admin view). - - Admin only endpoint. - """ - query = select(ShopItem).order_by(ShopItem.item_type, ShopItem.price_gems) - if not include_unavailable: - query = query.where(ShopItem.is_available == True) - - result = await db.execute(query) - items = result.scalars().all() - - return ApiResponse( - success=True, - message=f"Retrieved {len(items)} shop items", - data=[{ - "id": str(item.id), - "name": item.name, - "description": item.description, - "item_type": item.item_type, - "price_gems": item.price_gems, - "is_available": item.is_available, - "stock_quantity": item.stock_quantity - } for item in items] - ) - - -@router.post("/shop", response_model=ApiResponse[dict]) -async def create_shop_item( - name: str, - description: str, - item_type: str, - price_gems: int, - is_available: bool = True, - stock_quantity: Optional[int] = None, - db: AsyncSession = Depends(get_db), - admin_user: User = Depends(require_admin) -): - """ - Create a new shop item. - - Admin only endpoint. - """ - item = ShopItem( - name=name, - description=description, - item_type=item_type, - price_gems=price_gems, - is_available=is_available, - stock_quantity=stock_quantity - ) - db.add(item) - await db.commit() - await db.refresh(item) - - return ApiResponse( - success=True, - message="Shop item created successfully", - data={ - "id": str(item.id), - "name": item.name, - "price_gems": item.price_gems - } - ) - - -@router.put("/shop/{item_id}", response_model=ApiResponse[dict]) -async def update_shop_item( - item_id: UUID, - name: Optional[str] = None, - description: Optional[str] = None, - price_gems: Optional[int] = None, - is_available: Optional[bool] = None, - stock_quantity: Optional[int] = None, - db: AsyncSession = Depends(get_db), - admin_user: User = Depends(require_admin) -): - """ - Update a shop item. - - Admin only endpoint. - """ - result = await db.execute( - select(ShopItem).where(ShopItem.id == item_id) - ) - item = result.scalar_one_or_none() - - if not item: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Shop item not found" - ) - - if name is not None: - item.name = name - if description is not None: - item.description = description - if price_gems is not None: - item.price_gems = price_gems - if is_available is not None: - item.is_available = is_available - if stock_quantity is not None: - item.stock_quantity = stock_quantity - - await db.commit() - await db.refresh(item) - - return ApiResponse( - success=True, - message="Shop item updated successfully", - data={ - "id": str(item.id), - "name": item.name, - "price_gems": item.price_gems, - "is_available": item.is_available - } - ) - - -@router.delete("/shop/{item_id}", response_model=ApiResponse[dict]) -async def delete_shop_item( - item_id: UUID, - db: AsyncSession = Depends(get_db), - admin_user: User = Depends(require_admin) -): - """ - Delete a shop item. - - Admin only endpoint. - """ - result = await db.execute( - select(ShopItem).where(ShopItem.id == item_id) - ) - item = result.scalar_one_or_none() - - if not item: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="Shop item not found" - ) - - await db.delete(item) - await db.commit() - - return ApiResponse( - success=True, - message="Shop item deleted successfully", - data={"deleted": True, "item_id": str(item_id)} - ) - - -# ============================================================================ -# Grammar Admin CRUD -# ============================================================================ - -@router.get("/grammar", response_model=ApiResponse[List[GrammarResponse]]) -async def list_grammar_admin( - limit: int = Query(50, ge=1, le=200), - offset: int = Query(0, ge=0), - db: AsyncSession = Depends(get_db), - admin_user: User = Depends(require_admin) -): - result = await db.execute( - select(GrammarItem).order_by(GrammarItem.created_at.desc()).limit(limit).offset(offset) - ) - items = result.scalars().all() - return ApiResponse( - success=True, - message=f"Retrieved {len(items)} grammar items", - data=[GrammarResponse.model_validate(item) for item in items] - ) - - -@router.post("/grammar", response_model=ApiResponse[GrammarResponse]) -async def create_grammar_admin( - payload: GrammarCreate, - db: AsyncSession = Depends(get_db), - admin_user: User = Depends(require_admin) -): - item = GrammarItem( - title=payload.title, - level=payload.level, - topic=payload.topic, - summary=payload.summary, - content=payload.content, - examples=payload.examples, - tags=payload.tags, - is_active=payload.is_active, - ) - db.add(item) - await db.commit() - await db.refresh(item) - return ApiResponse(success=True, message="Grammar created", data=GrammarResponse.model_validate(item)) - - -@router.put("/grammar/{grammar_id}", response_model=ApiResponse[GrammarResponse]) -async def update_grammar_admin( - grammar_id: UUID, - payload: GrammarUpdate, - db: AsyncSession = Depends(get_db), - admin_user: User = Depends(require_admin) -): - result = await db.execute(select(GrammarItem).where(GrammarItem.id == grammar_id)) - item = result.scalar_one_or_none() - if not item: - raise HTTPException(status_code=404, detail="Grammar item not found") - - for field, value in payload.model_dump(exclude_unset=True).items(): - setattr(item, field, value) - - await db.commit() - await db.refresh(item) - return ApiResponse(success=True, message="Grammar updated", data=GrammarResponse.model_validate(item)) - - -@router.delete("/grammar/{grammar_id}", response_model=ApiResponse[dict]) -async def delete_grammar_admin( - grammar_id: UUID, - db: AsyncSession = Depends(get_db), - admin_user: User = Depends(require_admin) -): - result = await db.execute(select(GrammarItem).where(GrammarItem.id == grammar_id)) - item = result.scalar_one_or_none() - if not item: - raise HTTPException(status_code=404, detail="Grammar item not found") - await db.delete(item) - await db.commit() - return ApiResponse(success=True, message="Grammar deleted", data={"deleted": True, "grammar_id": str(grammar_id)}) - - -# ============================================================================ -# Question Bank Admin CRUD -# ============================================================================ - -@router.get("/questions", response_model=ApiResponse[List[QuestionResponse]]) -async def list_questions_admin( - limit: int = Query(50, ge=1, le=200), - offset: int = Query(0, ge=0), - db: AsyncSession = Depends(get_db), - admin_user: User = Depends(require_admin) -): - result = await db.execute( - select(QuestionItem).order_by(QuestionItem.created_at.desc()).limit(limit).offset(offset) - ) - items = result.scalars().all() - return ApiResponse( - success=True, - message=f"Retrieved {len(items)} questions", - data=[QuestionResponse.model_validate(item) for item in items] - ) - - -@router.post("/questions", response_model=ApiResponse[QuestionResponse]) -async def create_question_admin( - payload: QuestionCreate, - db: AsyncSession = Depends(get_db), - admin_user: User = Depends(require_admin) -): - item = QuestionItem( - prompt=payload.prompt, - question_type=payload.question_type, - options=payload.options, - answer=payload.answer, - explanation=payload.explanation, - difficulty_level=payload.difficulty_level, - tags=payload.tags, - grammar_id=payload.grammar_id, - is_active=payload.is_active, - ) - db.add(item) - await db.commit() - await db.refresh(item) - return ApiResponse(success=True, message="Question created", data=QuestionResponse.model_validate(item)) - - -@router.put("/questions/{question_id}", response_model=ApiResponse[QuestionResponse]) -async def update_question_admin( - question_id: UUID, - payload: QuestionUpdate, - db: AsyncSession = Depends(get_db), - admin_user: User = Depends(require_admin) -): - result = await db.execute(select(QuestionItem).where(QuestionItem.id == question_id)) - item = result.scalar_one_or_none() - if not item: - raise HTTPException(status_code=404, detail="Question not found") - - for field, value in payload.model_dump(exclude_unset=True).items(): - setattr(item, field, value) - - await db.commit() - await db.refresh(item) - return ApiResponse(success=True, message="Question updated", data=QuestionResponse.model_validate(item)) - - -@router.delete("/questions/{question_id}", response_model=ApiResponse[dict]) -async def delete_question_admin( - question_id: UUID, - db: AsyncSession = Depends(get_db), - admin_user: User = Depends(require_admin) -): - result = await db.execute(select(QuestionItem).where(QuestionItem.id == question_id)) - item = result.scalar_one_or_none() - if not item: - raise HTTPException(status_code=404, detail="Question not found") - await db.delete(item) - await db.commit() - return ApiResponse(success=True, message="Question deleted", data={"deleted": True, "question_id": str(question_id)}) - - -# ============================================================================ -# Test Exam Admin CRUD -# ============================================================================ - -@router.get("/test-exams", response_model=ApiResponse[List[TestExamResponse]]) -async def list_test_exams_admin( - limit: int = Query(50, ge=1, le=200), - offset: int = Query(0, ge=0), - db: AsyncSession = Depends(get_db), - admin_user: User = Depends(require_admin) -): - result = await db.execute( - select(TestExam).order_by(TestExam.created_at.desc()).limit(limit).offset(offset) - ) - items = result.scalars().all() - return ApiResponse( - success=True, - message=f"Retrieved {len(items)} test exams", - data=[TestExamResponse.model_validate(item) for item in items] - ) - - -@router.post("/test-exams", response_model=ApiResponse[TestExamResponse]) -async def create_test_exam_admin( - payload: TestExamCreate, - db: AsyncSession = Depends(get_db), - admin_user: User = Depends(require_admin) -): - item = TestExam( - title=payload.title, - description=payload.description, - level=payload.level, - duration_minutes=payload.duration_minutes, - passing_score=payload.passing_score, - question_ids=[str(q) for q in payload.question_ids] if payload.question_ids else None, - is_published=payload.is_published, - ) - db.add(item) - await db.commit() - await db.refresh(item) - return ApiResponse(success=True, message="Test exam created", data=TestExamResponse.model_validate(item)) - - -@router.put("/test-exams/{test_exam_id}", response_model=ApiResponse[TestExamResponse]) -async def update_test_exam_admin( - test_exam_id: UUID, - payload: TestExamUpdate, - db: AsyncSession = Depends(get_db), - admin_user: User = Depends(require_admin) -): - result = await db.execute(select(TestExam).where(TestExam.id == test_exam_id)) - item = result.scalar_one_or_none() - if not item: - raise HTTPException(status_code=404, detail="Test exam not found") - - update_data = payload.model_dump(exclude_unset=True) - if "question_ids" in update_data and update_data["question_ids"] is not None: - update_data["question_ids"] = [str(q) for q in update_data["question_ids"]] - for field, value in update_data.items(): - setattr(item, field, value) - - await db.commit() - await db.refresh(item) - return ApiResponse(success=True, message="Test exam updated", data=TestExamResponse.model_validate(item)) - - -@router.delete("/test-exams/{test_exam_id}", response_model=ApiResponse[dict]) -async def delete_test_exam_admin( - test_exam_id: UUID, - db: AsyncSession = Depends(get_db), - admin_user: User = Depends(require_admin) -): - result = await db.execute(select(TestExam).where(TestExam.id == test_exam_id)) - item = result.scalar_one_or_none() - if not item: - raise HTTPException(status_code=404, detail="Test exam not found") - await db.delete(item) - await db.commit() - return ApiResponse(success=True, message="Test exam deleted", data={"deleted": True, "test_exam_id": str(test_exam_id)}) - - -# ============================================================================ -# Seed Data Endpoint (Development Only) -# ============================================================================ - -@router.post("/seed", response_model=ApiResponse[dict]) -async def seed_sample_data( - db: AsyncSession = Depends(get_db), - admin_user: User = Depends(require_admin) -): - """ - Seed sample data for development/testing. - - Creates sample achievements, shop items, course categories, and courses. - Admin only endpoint. - """ - created = { - "achievements": 0, - "shop_items": 0, - "course_categories": 0, - "courses": 0, - "units": 0, - "lessons": 0, - } - - # Sample Achievements - sample_achievements = [ - {"name": "First Steps", "description": "Complete your first lesson", - "condition_type": "lessons_completed", "condition_value": 1, - "category": "lessons", "rarity": "common", "xp_reward": 10, "gems_reward": 5}, - {"name": "Week Warrior", "description": "Maintain a 7-day streak", - "condition_type": "streak_days", "condition_value": 7, - "category": "streak", "rarity": "rare", "xp_reward": 50, "gems_reward": 20}, - {"name": "Word Collector", "description": "Add 100 words to vocabulary", - "condition_type": "vocab_count", "condition_value": 100, - "category": "vocabulary", "rarity": "epic", "xp_reward": 100, "gems_reward": 50}, - {"name": "Social Butterfly", "description": "Follow 10 friends", - "condition_type": "following_count", "condition_value": 10, - "category": "social", "rarity": "rare", "xp_reward": 30, "gems_reward": 15}, - ] - - for ach_data in sample_achievements: - # Check if exists - result = await db.execute( - select(Achievement).where(Achievement.name == ach_data["name"]) - ) - if not result.scalar_one_or_none(): - achievement = Achievement(**ach_data) - db.add(achievement) - created["achievements"] += 1 - - # Sample Shop Items - from app.core.shop_catalog import SHOP_CATALOG - sample_shop_items = SHOP_CATALOG - - for item_data in sample_shop_items: - # Check if exists - result = await db.execute( - select(ShopItem).where(ShopItem.name == item_data["name"]) - ) - existing_item = result.scalar_one_or_none() - if not existing_item: - item = ShopItem(**item_data) - db.add(item) - created["shop_items"] += 1 - else: - for field, value in item_data.items(): - setattr(existing_item, field, value) - - # Sample Course Categories - sample_categories = [ - { - "name": "Grammar", - "slug": "grammar", - "description": "Master English grammar rules and structures", - "icon": "📚", - "color": "#4CAF50", - "order_index": 1, - }, - { - "name": "Vocabulary", - "slug": "vocabulary", - "description": "Build practical English vocabulary for daily use", - "icon": "🧠", - "color": "#2196F3", - "order_index": 2, - }, - ] - - category_ids: dict[str, UUID] = {} - for category_data in sample_categories: - result = await db.execute( - select(CourseCategory).where(CourseCategory.slug == category_data["slug"]) - ) - category = result.scalar_one_or_none() - if not category: - category = CourseCategory(**category_data) - db.add(category) - await db.flush() - created["course_categories"] += 1 - category_ids[category_data["slug"]] = category.id - - # Sample published courses with basic roadmap content - sample_courses = [ - { - "title": "English Grammar Foundations", - "description": "Start with core grammar patterns for everyday communication.", - "language": "en", - "level": "A1", - "category_slug": "grammar", - "tags": ["grammar", "beginner", "fundamentals"], - "total_xp": 120, - "estimated_duration": 90, - "thumbnail_url": "https://images.unsplash.com/photo-1456513080510-7bf3a84b82f8", - "units": [ - { - "title": "Present Simple Basics", - "description": "Build confidence with daily routine sentences.", - "order_index": 1, - "background_color": "#1E3A8A", - "lessons": [ - { - "title": "I/You/We/They + Verb", - "description": "Form positive present simple sentences.", - "order_index": 1, - "estimated_minutes": 12, - "xp_reward": 20, - "total_exercises": 10, - "exercises": [ - { - "id": "ex_g1_1", - "type": "multiple_choice", - "ui_type": "multiple_choice", - "question": "Choose the correct form: 'I ___ coffee every morning.'", - "options": [{"id": "0", "text": "drink", "is_correct": True}, {"id": "1", "text": "drinks", "is_correct": False}, {"id": "2", "text": "drinking", "is_correct": False}, {"id": "3", "text": "drunk", "is_correct": False}], - "correct_answer": "drink", - "explanation": "With I/You/We/They, use the base verb form." - }, - { - "id": "ex_g1_2", - "type": "true_false", - "ui_type": "true_or_false", - "question": "True or False: 'They plays football' is grammatically correct.", - "options": [{"id": "0", "text": "True", "is_correct": False}, {"id": "1", "text": "False", "is_correct": True}], - "correct_answer": "False", - "explanation": "'They' is plural and takes the base verb 'play', not 'plays'." - }, - { - "id": "ex_g1_3", - "type": "fill_blank", - "ui_type": "fill_in_the_blank", - "question": "Complete the sentence: 'We {blank} in a big city.'", - "correct_answer": "live", - "explanation": "'We' takes the base verb 'live'." - }, - { - "id": "ex_g1_4", - "type": "reorder", - "ui_type": "arrange_the_sentence", - "question": "Arrange: 'day / every / study / they'", - "options": [{"id": "0", "text": "they"}, {"id": "1", "text": "study"}, {"id": "2", "text": "every"}, {"id": "3", "text": "day"}], - "correct_answer": "they study every day", - "explanation": "Subject + Verb + Time expression is the standard structure." - }, - { - "id": "ex_g1_5", - "type": "translate", - "ui_type": "translation_choice", - "question": "Choose the translation for: 'Chúng tôi muốn học.'", - "options": [{"id": "0", "text": "We want to learn.", "is_correct": True}, {"id": "1", "text": "We wants to learn.", "is_correct": False}, {"id": "2", "text": "We learning.", "is_correct": False}], - "correct_answer": "We want to learn.", - "explanation": "'Chúng tôi' translates to 'We', which takes the base verb 'want'." - }, - { - "id": "ex_g1_6", - "type": "fill_blank", - "ui_type": "dialogue_completion", - "question": "A: Do you speak English? B: Yes, I {blank}.", - "correct_answer": "do", - "explanation": "Short answer to 'Do you...' is 'I do'." - }, - { - "id": "ex_g1_7", - "type": "multiple_choice", - "ui_type": "collocation_choice", - "question": "Complete the phrase: 'They ___ homework together.'", - "options": [{"id": "0", "text": "do", "is_correct": True}, {"id": "1", "text": "make", "is_correct": False}], - "correct_answer": "do", - "explanation": "The collocation is 'do homework'." - }, - { - "id": "ex_g1_8", - "type": "fill_blank", - "ui_type": "dictation", - "question": "Listen and write what you hear: '{blank}'", - "correct_answer": "We go home", - "explanation": "Type the exact words heard in the audio." - }, - { - "id": "ex_g1_9", - "type": "translate", - "ui_type": "speaking_repeat", - "question": "Repeat: 'I listen to music.'", - "correct_answer": "I listen to music", - "explanation": "Record yourself repeating this sentence." - }, - { - "id": "ex_g1_10", - "type": "multiple_choice", - "ui_type": "vocabulary_flashcard", - "question": "Learn the card: 'Routine'", - "options": [{"id": "0", "text": "Got it!", "is_correct": True}], - "correct_answer": "Got it!", - "explanation": "Routine is a sequence of actions regularly followed." - } - ] - }, - { - "title": "He/She/It + Verb-s", - "description": "Use third-person singular correctly.", - "order_index": 2, - "estimated_minutes": 12, - "xp_reward": 20, - "total_exercises": 10, - "exercises": [ - { - "id": "ex_g2_1", - "type": "multiple_choice", - "ui_type": "multiple_choice", - "question": "Choose correct: 'He ___ to school by bus.'", - "options": [{"id": "0", "text": "go", "is_correct": False}, {"id": "1", "text": "goes", "is_correct": True}, {"id": "2", "text": "going", "is_correct": False}], - "correct_answer": "goes", - "explanation": "He/she/it takes verb+s/es." - }, - { - "id": "ex_g2_2", - "type": "true_false", - "ui_type": "true_or_false", - "question": "True or False: 'It rain a lot' is correct.", - "options": [{"id": "0", "text": "True", "is_correct": False}, {"id": "1", "text": "False", "is_correct": True}], - "correct_answer": "False", - "explanation": "It should be 'It rains a lot'." - }, - { - "id": "ex_g2_3", - "type": "fill_blank", - "ui_type": "fill_in_the_blank", - "question": "Complete: 'She {blank} the piano well.'", - "correct_answer": "plays", - "explanation": "She requires plays." - }, - { - "id": "ex_g2_4", - "type": "fill_blank", - "ui_type": "grammar_correction", - "question": "Correct the error: 'He play tennis.' -> '{blank}'", - "correct_answer": "He plays tennis", - "explanation": "Add 's' to play." - }, - { - "id": "ex_g2_5", - "type": "multiple_choice", - "ui_type": "image_based_choice", - "question": "Choose the image that represents: 'She runs.'", - "options": [{"id": "0", "text": "Running", "is_correct": True}, {"id": "1", "text": "Sleeping", "is_correct": False}], - "correct_answer": "Running", - "explanation": "Select the correct action card." - }, - { - "id": "ex_g2_6", - "type": "multiple_choice", - "ui_type": "listen_and_choose", - "question": "Listen and select what he does.", - "options": [{"id": "0", "text": "He eats breakfast", "is_correct": True}, {"id": "1", "text": "He plays games", "is_correct": False}], - "correct_answer": "He eats breakfast", - "explanation": "Audio matches breakfast." - }, - { - "id": "ex_g2_7", - "type": "translate", - "ui_type": "pronunciation_practice", - "question": "Practice speaking: 'He plays tennis.'", - "correct_answer": "He plays tennis", - "explanation": "Evaluate pronunciation of He plays tennis." - }, - { - "id": "ex_g2_8", - "type": "multiple_choice", - "ui_type": "reading_comprehension", - "question": "Read and answer: 'Tom is a chef. He works in a restaurant.' Where does Tom work?", - "options": [{"id": "0", "text": "restaurant", "is_correct": True}, {"id": "1", "text": "office", "is_correct": False}], - "correct_answer": "restaurant", - "explanation": "The text states he works in a restaurant." - }, - { - "id": "ex_g2_9", - "type": "fill_blank", - "ui_type": "short_writing_answer", - "question": "Write in English: 'Cô ấy nói tiếng Anh.'", - "correct_answer": "She speaks English", - "explanation": "Translate exactly." - }, - { - "id": "ex_g2_10", - "type": "matching", - "ui_type": "categorization", - "question": "Categorize the pronouns: Singular vs Plural", - "options": [{"id": "he", "text": "Singular"}, {"id": "they", "text": "Plural"}], - "correct_answer": "he:Singular, they:Plural", - "explanation": "He is singular, they is plural." - } - ] - }, - ], - }, - ], - }, - { - "title": "Daily Vocabulary Starter", - "description": "Learn essential words and phrases for daily life.", - "language": "en", - "level": "A1", - "category_slug": "vocabulary", - "tags": ["vocabulary", "daily-life", "beginner"], - "total_xp": 120, - "estimated_duration": 80, - "thumbnail_url": "https://images.unsplash.com/photo-1434030216411-0b793f4b4173", - "units": [ - { - "title": "Home and Family", - "description": "Words you use every day at home.", - "order_index": 1, - "background_color": "#0F766E", - "lessons": [ - { - "title": "Family Members", - "description": "Describe people in your family.", - "order_index": 1, - "estimated_minutes": 10, - "xp_reward": 20, - "total_exercises": 10, - "exercises": [ - { - "id": "ex_v1_1", - "type": "multiple_choice", - "ui_type": "multiple_choice", - "question": "Your father's sister is your ___.", - "options": [{"id": "0", "text": "aunt", "is_correct": True}, {"id": "1", "text": "uncle", "is_correct": False}], - "correct_answer": "aunt", - "explanation": "Father's sister is aunt." - }, - { - "id": "ex_v1_2", - "type": "matching", - "ui_type": "match_word_to_meaning", - "question": "Match the family words with meanings.", - "options": [{"id": "father", "text": "Male parent"}, {"id": "mother", "text": "Female parent"}], - "correct_answer": "father:Male parent, mother:Female parent", - "explanation": "Father is male parent, mother is female." - }, - { - "id": "ex_v1_3", - "type": "fill_blank", - "ui_type": "fill_in_the_blank", - "question": "Complete: 'My mother's husband is my {blank}.'", - "correct_answer": "father", - "explanation": "Mother's husband is father." - }, - { - "id": "ex_v1_4", - "type": "matching", - "ui_type": "cognitive_fluidity", - "question": "Match: mother, sister", - "options": [{"id": "mother", "text": "female parent"}, {"id": "sister", "text": "female sibling"}], - "correct_answer": "mother:female parent, sister:female sibling", - "explanation": "Fast match." - }, - { - "id": "ex_v1_5", - "type": "multiple_choice", - "ui_type": "vocabulary_flashcard", - "question": "Learn: 'Sibling' (anh chị em ruột)", - "options": [{"id": "0", "text": "Got it!", "is_correct": True}], - "correct_answer": "Got it!", - "explanation": "A sibling is a brother or sister." - }, - { - "id": "ex_v1_6", - "type": "true_false", - "ui_type": "true_or_false", - "question": "True or False: 'Cousin' is the child of your aunt or uncle.", - "options": [{"id": "0", "text": "True", "is_correct": True}, {"id": "1", "text": "False", "is_correct": False}], - "correct_answer": "True", - "explanation": "Cousin is child of aunt/uncle." - }, - { - "id": "ex_v1_7", - "type": "reorder", - "ui_type": "arrange_the_sentence", - "question": "Arrange: 'my / this / is / brother'", - "options": [{"id": "0", "text": "this"}, {"id": "1", "text": "is"}, {"id": "2", "text": "my"}, {"id": "3", "text": "brother"}], - "correct_answer": "this is my brother", - "explanation": "Sentence structure." - }, - { - "id": "ex_v1_8", - "type": "translate", - "ui_type": "translation_choice", - "question": "Translation of: 'Anh trai'", - "options": [{"id": "0", "text": "brother", "is_correct": True}, {"id": "1", "text": "sister", "is_correct": False}], - "correct_answer": "brother", - "explanation": "Anh trai is brother." - }, - { - "id": "ex_v1_9", - "type": "translate", - "ui_type": "speaking_repeat", - "question": "Repeat: 'My grandmother is nice.'", - "correct_answer": "My grandmother is nice", - "explanation": "Practice speaking." - }, - { - "id": "ex_v1_10", - "type": "fill_blank", - "ui_type": "dictation", - "question": "Dictation: Listen and write '{blank}'", - "correct_answer": "He is my uncle", - "explanation": "Type what you hear." - } - ] - }, - { - "title": "Rooms and Objects", - "description": "Talk about places and things at home.", - "order_index": 2, - "estimated_minutes": 10, - "xp_reward": 20, - "total_exercises": 10, - "exercises": [ - { - "id": "ex_v2_1", - "type": "multiple_choice", - "ui_type": "multiple_choice", - "question": "You cook meals in the ___.", - "options": [{"id": "0", "text": "kitchen", "is_correct": True}, {"id": "1", "text": "bedroom", "is_correct": False}], - "correct_answer": "kitchen", - "explanation": "Kitchen is for cooking." - }, - { - "id": "ex_v2_2", - "type": "matching", - "ui_type": "match_word_to_meaning", - "question": "Match rooms with objects.", - "options": [{"id": "bedroom", "text": "bed"}, {"id": "kitchen", "text": "fridge"}], - "correct_answer": "bedroom:bed, kitchen:fridge", - "explanation": "Bed in bedroom, fridge in kitchen." - }, - { - "id": "ex_v2_3", - "type": "fill_blank", - "ui_type": "fill_in_the_blank", - "question": "Complete: 'I sleep in my {blank}.'", - "correct_answer": "bedroom", - "explanation": "Bedroom is for sleeping." - }, - { - "id": "ex_v2_4", - "type": "true_false", - "ui_type": "true_or_false", - "question": "True or False: A sofa is typically placed in the living room.", - "options": [{"id": "0", "text": "True", "is_correct": True}, {"id": "1", "text": "False", "is_correct": False}], - "correct_answer": "True", - "explanation": "Sofa in living room." - }, - { - "id": "ex_v2_5", - "type": "reorder", - "ui_type": "arrange_the_sentence", - "question": "Arrange: 'in / the / bed / is / bedroom / the'", - "options": [{"id": "0", "text": "the"}, {"id": "1", "text": "bed"}, {"id": "2", "text": "is"}, {"id": "3", "text": "in"}, {"id": "4", "text": "the"}, {"id": "5", "text": "bedroom"}], - "correct_answer": "the bed is in the bedroom", - "explanation": "Correct sentence structure." - }, - { - "id": "ex_v2_6", - "type": "translate", - "ui_type": "translation_choice", - "question": "Translation of: 'Phòng tắm'", - "options": [{"id": "0", "text": "bathroom", "is_correct": True}, {"id": "1", "text": "garden", "is_correct": False}], - "correct_answer": "bathroom", - "explanation": "Phòng tắm is bathroom." - }, - { - "id": "ex_v2_7", - "type": "translate", - "ui_type": "speaking_repeat", - "question": "Repeat: 'The desk is clean.'", - "correct_answer": "The desk is clean", - "explanation": "Evaluate pronunciation." - }, - { - "id": "ex_v2_8", - "type": "fill_blank", - "ui_type": "dictation", - "question": "Dictation: Listen and write '{blank}'", - "correct_answer": "Open the window", - "explanation": "Write what you hear." - }, - { - "id": "ex_v2_9", - "type": "multiple_choice", - "ui_type": "image_based_choice", - "question": "Select the image that shows a table.", - "options": [{"id": "0", "text": "Table", "is_correct": True}, {"id": "1", "text": "Chair", "is_correct": False}], - "correct_answer": "Table", - "explanation": "Card with table." - }, - { - "id": "ex_v2_10", - "type": "multiple_choice", - "ui_type": "vocabulary_flashcard", - "question": "Learn: 'Furnishings'", - "options": [{"id": "0", "text": "Got it!", "is_correct": True}], - "correct_answer": "Got it!", - "explanation": "Furniture and appliances in a room." - } - ] - }, - ], - }, - ], - }, - ] - - for course_data in sample_courses: - result = await db.execute( - select(Course).where(Course.title == course_data["title"]) - ) - existing_course = result.scalar_one_or_none() - if existing_course: - await db.delete(existing_course) - await db.flush() - - unit_seed = course_data.pop("units") - category_slug = course_data.pop("category_slug") - - course = Course( - **course_data, - category_id=category_ids.get(category_slug), - total_lessons=sum(len(unit["lessons"]) for unit in unit_seed), - is_published=True, - ) - db.add(course) - await db.flush() - created["courses"] += 1 - - for unit_data in unit_seed: - lesson_seed = unit_data.pop("lessons") - unit = Unit( - **unit_data, - course_id=course.id, - total_lessons=len(lesson_seed), - ) - db.add(unit) - await db.flush() - created["units"] += 1 - - for lesson_data in lesson_seed: - exercises = lesson_data.pop("exercises", []) - lesson = Lesson( - **lesson_data, - course_id=course.id, - unit_id=unit.id, - content={"exercises": exercises, "version": 1}, - pass_threshold=70, - lesson_type="lesson", - ) - db.add(lesson) - created["lessons"] += 1 - - await db.commit() - - return ApiResponse( - success=True, - message=( - "Seed data created: " - f"{created['achievements']} achievements, " - f"{created['shop_items']} shop items, " - f"{created['course_categories']} categories, " - f"{created['courses']} courses, " - f"{created['units']} units, " - f"{created['lessons']} lessons" - ), - data=created - ) - - -# ============================================================================ -# System Settings / Info -# ============================================================================ - -@router.get("/system-info", response_model=ApiResponse[dict]) -async def get_system_info( - db: AsyncSession = Depends(get_db), - admin_user: User = Depends(require_admin) -): - """Get system configuration and stats. Admin only.""" - from app.core.config import settings as app_settings - from app.models.user import User as UserModel - - # Count totals - user_count = (await db.execute(select(func.count(UserModel.id)))).scalar() or 0 - course_count = (await db.execute(select(func.count(Course.id)))).scalar() or 0 - vocab_count = (await db.execute(select(func.count(VocabularyItem.id)))).scalar() or 0 - achievement_count = (await db.execute(select(func.count(Achievement.id)))).scalar() or 0 - - return ApiResponse( - success=True, - message="System info", - data={ - "app_name": app_settings.APP_NAME, - "app_env": app_settings.APP_ENV, - "debug": app_settings.DEBUG, - "api_prefix": app_settings.API_V1_PREFIX, - "log_level": app_settings.LOG_LEVEL, - "token_expire_minutes": app_settings.ACCESS_TOKEN_EXPIRE_MINUTES, - "refresh_token_days": app_settings.REFRESH_TOKEN_EXPIRE_DAYS, - "cors_origins": app_settings.cors_origins, - "ai_service_url": app_settings.AI_SERVICE_URL, - "google_oauth": bool(app_settings.GOOGLE_CLIENT_ID), - "firebase": bool(app_settings.FIREBASE_PROJECT_ID), - "totals": { - "users": user_count, - "courses": course_count, - "vocabulary": vocab_count, - "achievements": achievement_count, - } - } - ) - - -from pydantic import BaseModel - -class SystemInfoUpdate(BaseModel): - app_name: Optional[str] = None - debug: Optional[bool] = None - log_level: Optional[str] = None - token_expire_minutes: Optional[int] = None - refresh_token_days: Optional[int] = None - cors_origins: Optional[str] = None - ai_service_url: Optional[str] = None - -@router.put("/system-info", response_model=ApiResponse[dict]) -async def update_system_info( - payload: SystemInfoUpdate, - admin_user: User = Depends(require_admin) -): - """Update system configuration. Admin only.""" - from app.core.config import settings as app_settings - from pathlib import Path - - updates = {} - - if payload.app_name is not None: - app_settings.APP_NAME = payload.app_name - updates["APP_NAME"] = payload.app_name - - if payload.debug is not None: - app_settings.DEBUG = payload.debug - updates["DEBUG"] = payload.debug - - if payload.log_level is not None: - level = payload.log_level.upper() - if level in ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]: - app_settings.LOG_LEVEL = level - updates["LOG_LEVEL"] = level - - if payload.token_expire_minutes is not None: - app_settings.ACCESS_TOKEN_EXPIRE_MINUTES = payload.token_expire_minutes - updates["ACCESS_TOKEN_EXPIRE_MINUTES"] = payload.token_expire_minutes - - if payload.refresh_token_days is not None: - app_settings.REFRESH_TOKEN_EXPIRE_DAYS = payload.refresh_token_days - updates["REFRESH_TOKEN_EXPIRE_DAYS"] = payload.refresh_token_days - - if payload.cors_origins is not None: - app_settings.ALLOWED_ORIGINS = payload.cors_origins - updates["ALLOWED_ORIGINS"] = payload.cors_origins - - if payload.ai_service_url is not None: - app_settings.AI_SERVICE_URL = payload.ai_service_url - updates["AI_SERVICE_URL"] = payload.ai_service_url - - if updates: - project_root = Path(__file__).parent.parent.parent - env_file = project_root / ".env" - if app_settings.APP_ENV == "production": - prod_env = project_root / ".env.production" - if prod_env.exists(): - env_file = prod_env - - try: - content = env_file.read_text() if env_file.exists() else "" - lines = content.splitlines() - new_lines = [] - updated_keys = set() - - for line in lines: - stripped = line.strip() - if not stripped or stripped.startswith("#"): - new_lines.append(line) - continue - if "=" in stripped: - key, val = stripped.split("=", 1) - key = key.strip() - if key in updates: - new_val = updates[key] - if isinstance(new_val, bool): - new_val_str = "true" if new_val else "false" - else: - new_val_str = str(new_val) - new_lines.append(f"{key}={new_val_str}") - updated_keys.add(key) - else: - new_lines.append(line) - else: - new_lines.append(line) - - for key, val in updates.items(): - if key not in updated_keys: - if isinstance(val, bool): - val_str = "true" if val else "false" - else: - val_str = str(val) - new_lines.append(f"{key}={val_str}") - - env_file.write_text("\n".join(new_lines) + "\n") - except Exception as e: - # Silently fallback if unable to write file in docker environment - pass - - return ApiResponse( - success=True, - message="System configuration updated successfully", - data={ - "app_name": app_settings.APP_NAME, - "app_env": app_settings.APP_ENV, - "debug": app_settings.DEBUG, - "api_prefix": app_settings.API_V1_PREFIX, - "log_level": app_settings.LOG_LEVEL, - "token_expire_minutes": app_settings.ACCESS_TOKEN_EXPIRE_MINUTES, - "refresh_token_days": app_settings.REFRESH_TOKEN_EXPIRE_DAYS, - "cors_origins": app_settings.cors_origins, - "ai_service_url": app_settings.AI_SERVICE_URL, - } - ) - - -# ============================================================================ -# User Admin (RBAC) - MOVED TO app/routes/user_management.py -# ============================================================================ -# Legacy routes removed - use /api/v1/admin/users/* endpoints from user_management.py - - -# ============================================================================ -# API Quota Monitoring (Phase 0 Infrastructure) -# ============================================================================ - -@router.get("/quota-usage", response_model=ApiResponse[dict]) -async def get_quota_usage( - api_name: Optional[str] = Query(None, description="Specific API to check"), - admin_user: User = Depends(require_admin), -): - """ - Get current API quota usage for all APIs or a specific one. - - Returns usage stats including threshold status, remaining budget, - and time until daily reset. - """ - from app.services.quota_manager import QuotaManager - - if api_name: - if api_name not in QuotaManager.LIMITS: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Unknown API: {api_name}. " - f"Available: {list(QuotaManager.LIMITS.keys())}", - ) - usage = await QuotaManager.get_usage(api_name) - return ApiResponse( - success=True, - message=f"Quota usage for {api_name}", - data=usage, - ) - - all_usage = await QuotaManager.get_all_usage() - return ApiResponse( - success=True, - message=f"Quota usage for {len(all_usage)} APIs", - data={ - "apis": all_usage, - "reset_in": QuotaManager.get_reset_time(), - }, - ) - - -@router.post("/quota-reset/{api_name}", response_model=ApiResponse[dict]) -async def reset_quota( - api_name: str, - admin_user: User = Depends(require_admin), -): - """ - Manually reset quota counter for a specific API (emergency use). - - Use when: quota incorrectly tracked, or need to allow more requests - after investigating an issue. - """ - from app.services.quota_manager import QuotaManager - - if api_name not in QuotaManager.LIMITS: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Unknown API: {api_name}. " - f"Available: {list(QuotaManager.LIMITS.keys())}", - ) - - success = await QuotaManager.reset_quota(api_name) - if not success: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail="Redis unavailable, cannot reset quota.", - ) - - return ApiResponse( - success=True, - message=f"Quota reset for {api_name}", - data=await QuotaManager.get_usage(api_name), - ) diff --git a/backend-service/app/routes/admin_system.py b/backend-service/app/routes/admin_system.py index 3ae4ef5c..ab07c36b 100644 --- a/backend-service/app/routes/admin_system.py +++ b/backend-service/app/routes/admin_system.py @@ -1,6 +1,5 @@ """Admin routes — seed data, system info, and quota monitoring.""" from typing import Optional, List -from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import select, func @@ -9,12 +8,11 @@ from app.core.database import get_db from app.core.dependencies import get_current_admin, get_current_super_admin from app.models.user import User -from app.models.course import Course, Unit, Lesson -from app.models.course_category import CourseCategory +from app.models.course import Course from app.models.vocabulary import VocabularyItem -from app.models.gamification import Achievement, ShopItem -from app.models.content import GrammarItem, QuestionItem, TestExam +from app.models.gamification import Achievement from app.schemas.response import ApiResponse +from app.services import admin_seed_service router = APIRouter(prefix="/admin", tags=["Admin"]) @@ -32,576 +30,28 @@ async def seed_sample_data( ): """ Seed sample data for development/testing. - + Creates sample achievements, shop items, course categories, and courses. Admin only endpoint. """ + achievements_created = await admin_seed_service.seed_achievements(db) + shop_items_created = await admin_seed_service.seed_shop_items(db) + categories_created, category_ids = await admin_seed_service.seed_course_categories(db) + courses_created, units_created, lessons_created = await admin_seed_service.seed_courses( + db, category_ids + ) + + await db.commit() + created = { - "achievements": 0, - "shop_items": 0, - "course_categories": 0, - "courses": 0, - "units": 0, - "lessons": 0, + "achievements": achievements_created, + "shop_items": shop_items_created, + "course_categories": categories_created, + "courses": courses_created, + "units": units_created, + "lessons": lessons_created, } - - # Sample Achievements - sample_achievements = [ - {"name": "First Steps", "description": "Complete your first lesson", - "condition_type": "lessons_completed", "condition_value": 1, - "category": "lessons", "rarity": "common", "xp_reward": 10, "gems_reward": 5}, - {"name": "Week Warrior", "description": "Maintain a 7-day streak", - "condition_type": "streak_days", "condition_value": 7, - "category": "streak", "rarity": "rare", "xp_reward": 50, "gems_reward": 20}, - {"name": "Word Collector", "description": "Add 100 words to vocabulary", - "condition_type": "vocab_count", "condition_value": 100, - "category": "vocabulary", "rarity": "epic", "xp_reward": 100, "gems_reward": 50}, - {"name": "Social Butterfly", "description": "Follow 10 friends", - "condition_type": "following_count", "condition_value": 10, - "category": "social", "rarity": "rare", "xp_reward": 30, "gems_reward": 15}, - ] - - for ach_data in sample_achievements: - # Check if exists - result = await db.execute( - select(Achievement).where(Achievement.name == ach_data["name"]) - ) - if not result.scalar_one_or_none(): - achievement = Achievement(**ach_data) - db.add(achievement) - created["achievements"] += 1 - - # Sample Shop Items - from app.core.shop_catalog import SHOP_CATALOG - sample_shop_items = SHOP_CATALOG - - for item_data in sample_shop_items: - # Check if exists - result = await db.execute( - select(ShopItem).where(ShopItem.name == item_data["name"]) - ) - existing_item = result.scalar_one_or_none() - if not existing_item: - item = ShopItem(**item_data) - db.add(item) - created["shop_items"] += 1 - else: - for field, value in item_data.items(): - setattr(existing_item, field, value) - - # Sample Course Categories - sample_categories = [ - { - "name": "Grammar", - "slug": "grammar", - "description": "Master English grammar rules and structures", - "icon": "📚", - "color": "#4CAF50", - "order_index": 1, - }, - { - "name": "Vocabulary", - "slug": "vocabulary", - "description": "Build practical English vocabulary for daily use", - "icon": "🧠", - "color": "#2196F3", - "order_index": 2, - }, - ] - - category_ids: dict[str, UUID] = {} - for category_data in sample_categories: - result = await db.execute( - select(CourseCategory).where(CourseCategory.slug == category_data["slug"]) - ) - category = result.scalar_one_or_none() - if not category: - category = CourseCategory(**category_data) - db.add(category) - await db.flush() - created["course_categories"] += 1 - category_ids[category_data["slug"]] = category.id - - # Sample published courses with basic roadmap content - sample_courses = [ - { - "title": "English Grammar Foundations", - "description": "Start with core grammar patterns for everyday communication.", - "language": "en", - "level": "A1", - "category_slug": "grammar", - "tags": ["grammar", "beginner", "fundamentals"], - "total_xp": 120, - "estimated_duration": 90, - "thumbnail_url": "https://images.unsplash.com/photo-1456513080510-7bf3a84b82f8", - "units": [ - { - "title": "Present Simple Basics", - "description": "Build confidence with daily routine sentences.", - "order_index": 1, - "background_color": "#1E3A8A", - "lessons": [ - { - "title": "I/You/We/They + Verb", - "description": "Form positive present simple sentences.", - "order_index": 1, - "estimated_minutes": 12, - "xp_reward": 20, - "total_exercises": 10, - "exercises": [ - { - "id": "ex_g1_1", - "type": "multiple_choice", - "ui_type": "multiple_choice", - "question": "Choose the correct form: 'I ___ coffee every morning.'", - "options": [{"id": "0", "text": "drink", "is_correct": True}, {"id": "1", "text": "drinks", "is_correct": False}, {"id": "2", "text": "drinking", "is_correct": False}, {"id": "3", "text": "drunk", "is_correct": False}], - "correct_answer": "drink", - "explanation": "With I/You/We/They, use the base verb form." - }, - { - "id": "ex_g1_2", - "type": "true_false", - "ui_type": "true_or_false", - "question": "True or False: 'They plays football' is grammatically correct.", - "options": [{"id": "0", "text": "True", "is_correct": False}, {"id": "1", "text": "False", "is_correct": True}], - "correct_answer": "False", - "explanation": "'They' is plural and takes the base verb 'play', not 'plays'." - }, - { - "id": "ex_g1_3", - "type": "fill_blank", - "ui_type": "fill_in_the_blank", - "question": "Complete the sentence: 'We {blank} in a big city.'", - "correct_answer": "live", - "explanation": "'We' takes the base verb 'live'." - }, - { - "id": "ex_g1_4", - "type": "reorder", - "ui_type": "arrange_the_sentence", - "question": "Arrange: 'day / every / study / they'", - "options": [{"id": "0", "text": "they"}, {"id": "1", "text": "study"}, {"id": "2", "text": "every"}, {"id": "3", "text": "day"}], - "correct_answer": "they study every day", - "explanation": "Subject + Verb + Time expression is the standard structure." - }, - { - "id": "ex_g1_5", - "type": "translate", - "ui_type": "translation_choice", - "question": "Choose the translation for: 'Chúng tôi muốn học.'", - "options": [{"id": "0", "text": "We want to learn.", "is_correct": True}, {"id": "1", "text": "We wants to learn.", "is_correct": False}, {"id": "2", "text": "We learning.", "is_correct": False}], - "correct_answer": "We want to learn.", - "explanation": "'Chúng tôi' translates to 'We', which takes the base verb 'want'." - }, - { - "id": "ex_g1_6", - "type": "fill_blank", - "ui_type": "dialogue_completion", - "question": "A: Do you speak English? B: Yes, I {blank}.", - "correct_answer": "do", - "explanation": "Short answer to 'Do you...' is 'I do'." - }, - { - "id": "ex_g1_7", - "type": "multiple_choice", - "ui_type": "collocation_choice", - "question": "Complete the phrase: 'They ___ homework together.'", - "options": [{"id": "0", "text": "do", "is_correct": True}, {"id": "1", "text": "make", "is_correct": False}], - "correct_answer": "do", - "explanation": "The collocation is 'do homework'." - }, - { - "id": "ex_g1_8", - "type": "fill_blank", - "ui_type": "dictation", - "question": "Listen and write what you hear: '{blank}'", - "correct_answer": "We go home", - "explanation": "Type the exact words heard in the audio." - }, - { - "id": "ex_g1_9", - "type": "translate", - "ui_type": "speaking_repeat", - "question": "Repeat: 'I listen to music.'", - "correct_answer": "I listen to music", - "explanation": "Record yourself repeating this sentence." - }, - { - "id": "ex_g1_10", - "type": "multiple_choice", - "ui_type": "vocabulary_flashcard", - "question": "Learn the card: 'Routine'", - "options": [{"id": "0", "text": "Got it!", "is_correct": True}], - "correct_answer": "Got it!", - "explanation": "Routine is a sequence of actions regularly followed." - } - ] - }, - { - "title": "He/She/It + Verb-s", - "description": "Use third-person singular correctly.", - "order_index": 2, - "estimated_minutes": 12, - "xp_reward": 20, - "total_exercises": 10, - "exercises": [ - { - "id": "ex_g2_1", - "type": "multiple_choice", - "ui_type": "multiple_choice", - "question": "Choose correct: 'He ___ to school by bus.'", - "options": [{"id": "0", "text": "go", "is_correct": False}, {"id": "1", "text": "goes", "is_correct": True}, {"id": "2", "text": "going", "is_correct": False}], - "correct_answer": "goes", - "explanation": "He/she/it takes verb+s/es." - }, - { - "id": "ex_g2_2", - "type": "true_false", - "ui_type": "true_or_false", - "question": "True or False: 'It rain a lot' is correct.", - "options": [{"id": "0", "text": "True", "is_correct": False}, {"id": "1", "text": "False", "is_correct": True}], - "correct_answer": "False", - "explanation": "It should be 'It rains a lot'." - }, - { - "id": "ex_g2_3", - "type": "fill_blank", - "ui_type": "fill_in_the_blank", - "question": "Complete: 'She {blank} the piano well.'", - "correct_answer": "plays", - "explanation": "She requires plays." - }, - { - "id": "ex_g2_4", - "type": "fill_blank", - "ui_type": "grammar_correction", - "question": "Correct the error: 'He play tennis.' -> '{blank}'", - "correct_answer": "He plays tennis", - "explanation": "Add 's' to play." - }, - { - "id": "ex_g2_5", - "type": "multiple_choice", - "ui_type": "image_based_choice", - "question": "Choose the image that represents: 'She runs.'", - "options": [{"id": "0", "text": "Running", "is_correct": True}, {"id": "1", "text": "Sleeping", "is_correct": False}], - "correct_answer": "Running", - "explanation": "Select the correct action card." - }, - { - "id": "ex_g2_6", - "type": "multiple_choice", - "ui_type": "listen_and_choose", - "question": "Listen and select what he does.", - "options": [{"id": "0", "text": "He eats breakfast", "is_correct": True}, {"id": "1", "text": "He plays games", "is_correct": False}], - "correct_answer": "He eats breakfast", - "explanation": "Audio matches breakfast." - }, - { - "id": "ex_g2_7", - "type": "translate", - "ui_type": "pronunciation_practice", - "question": "Practice speaking: 'He plays tennis.'", - "correct_answer": "He plays tennis", - "explanation": "Evaluate pronunciation of He plays tennis." - }, - { - "id": "ex_g2_8", - "type": "multiple_choice", - "ui_type": "reading_comprehension", - "question": "Read and answer: 'Tom is a chef. He works in a restaurant.' Where does Tom work?", - "options": [{"id": "0", "text": "restaurant", "is_correct": True}, {"id": "1", "text": "office", "is_correct": False}], - "correct_answer": "restaurant", - "explanation": "The text states he works in a restaurant." - }, - { - "id": "ex_g2_9", - "type": "fill_blank", - "ui_type": "short_writing_answer", - "question": "Write in English: 'Cô ấy nói tiếng Anh.'", - "correct_answer": "She speaks English", - "explanation": "Translate exactly." - }, - { - "id": "ex_g2_10", - "type": "matching", - "ui_type": "categorization", - "question": "Categorize the pronouns: Singular vs Plural", - "options": [{"id": "he", "text": "Singular"}, {"id": "they", "text": "Plural"}], - "correct_answer": "he:Singular, they:Plural", - "explanation": "He is singular, they is plural." - } - ] - }, - ], - }, - ], - }, - { - "title": "Daily Vocabulary Starter", - "description": "Learn essential words and phrases for daily life.", - "language": "en", - "level": "A1", - "category_slug": "vocabulary", - "tags": ["vocabulary", "daily-life", "beginner"], - "total_xp": 120, - "estimated_duration": 80, - "thumbnail_url": "https://images.unsplash.com/photo-1434030216411-0b793f4b4173", - "units": [ - { - "title": "Home and Family", - "description": "Words you use every day at home.", - "order_index": 1, - "background_color": "#0F766E", - "lessons": [ - { - "title": "Family Members", - "description": "Describe people in your family.", - "order_index": 1, - "estimated_minutes": 10, - "xp_reward": 20, - "total_exercises": 10, - "exercises": [ - { - "id": "ex_v1_1", - "type": "multiple_choice", - "ui_type": "multiple_choice", - "question": "Your father's sister is your ___.", - "options": [{"id": "0", "text": "aunt", "is_correct": True}, {"id": "1", "text": "uncle", "is_correct": False}], - "correct_answer": "aunt", - "explanation": "Father's sister is aunt." - }, - { - "id": "ex_v1_2", - "type": "matching", - "ui_type": "match_word_to_meaning", - "question": "Match the family words with meanings.", - "options": [{"id": "father", "text": "Male parent"}, {"id": "mother", "text": "Female parent"}], - "correct_answer": "father:Male parent, mother:Female parent", - "explanation": "Father is male parent, mother is female." - }, - { - "id": "ex_v1_3", - "type": "fill_blank", - "ui_type": "fill_in_the_blank", - "question": "Complete: 'My mother's husband is my {blank}.'", - "correct_answer": "father", - "explanation": "Mother's husband is father." - }, - { - "id": "ex_v1_4", - "type": "matching", - "ui_type": "cognitive_fluidity", - "question": "Match: mother, sister", - "options": [{"id": "mother", "text": "female parent"}, {"id": "sister", "text": "female sibling"}], - "correct_answer": "mother:female parent, sister:female sibling", - "explanation": "Fast match." - }, - { - "id": "ex_v1_5", - "type": "multiple_choice", - "ui_type": "vocabulary_flashcard", - "question": "Learn: 'Sibling' (anh chị em ruột)", - "options": [{"id": "0", "text": "Got it!", "is_correct": True}], - "correct_answer": "Got it!", - "explanation": "A sibling is a brother or sister." - }, - { - "id": "ex_v1_6", - "type": "true_false", - "ui_type": "true_or_false", - "question": "True or False: 'Cousin' is the child of your aunt or uncle.", - "options": [{"id": "0", "text": "True", "is_correct": True}, {"id": "1", "text": "False", "is_correct": False}], - "correct_answer": "True", - "explanation": "Cousin is child of aunt/uncle." - }, - { - "id": "ex_v1_7", - "type": "reorder", - "ui_type": "arrange_the_sentence", - "question": "Arrange: 'my / this / is / brother'", - "options": [{"id": "0", "text": "this"}, {"id": "1", "text": "is"}, {"id": "2", "text": "my"}, {"id": "3", "text": "brother"}], - "correct_answer": "this is my brother", - "explanation": "Sentence structure." - }, - { - "id": "ex_v1_8", - "type": "translate", - "ui_type": "translation_choice", - "question": "Translation of: 'Anh trai'", - "options": [{"id": "0", "text": "brother", "is_correct": True}, {"id": "1", "text": "sister", "is_correct": False}], - "correct_answer": "brother", - "explanation": "Anh trai is brother." - }, - { - "id": "ex_v1_9", - "type": "translate", - "ui_type": "speaking_repeat", - "question": "Repeat: 'My grandmother is nice.'", - "correct_answer": "My grandmother is nice", - "explanation": "Practice speaking." - }, - { - "id": "ex_v1_10", - "type": "fill_blank", - "ui_type": "dictation", - "question": "Dictation: Listen and write '{blank}'", - "correct_answer": "He is my uncle", - "explanation": "Type what you hear." - } - ] - }, - { - "title": "Rooms and Objects", - "description": "Talk about places and things at home.", - "order_index": 2, - "estimated_minutes": 10, - "xp_reward": 20, - "total_exercises": 10, - "exercises": [ - { - "id": "ex_v2_1", - "type": "multiple_choice", - "ui_type": "multiple_choice", - "question": "You cook meals in the ___.", - "options": [{"id": "0", "text": "kitchen", "is_correct": True}, {"id": "1", "text": "bedroom", "is_correct": False}], - "correct_answer": "kitchen", - "explanation": "Kitchen is for cooking." - }, - { - "id": "ex_v2_2", - "type": "matching", - "ui_type": "match_word_to_meaning", - "question": "Match rooms with objects.", - "options": [{"id": "bedroom", "text": "bed"}, {"id": "kitchen", "text": "fridge"}], - "correct_answer": "bedroom:bed, kitchen:fridge", - "explanation": "Bed in bedroom, fridge in kitchen." - }, - { - "id": "ex_v2_3", - "type": "fill_blank", - "ui_type": "fill_in_the_blank", - "question": "Complete: 'I sleep in my {blank}.'", - "correct_answer": "bedroom", - "explanation": "Bedroom is for sleeping." - }, - { - "id": "ex_v2_4", - "type": "true_false", - "ui_type": "true_or_false", - "question": "True or False: A sofa is typically placed in the living room.", - "options": [{"id": "0", "text": "True", "is_correct": True}, {"id": "1", "text": "False", "is_correct": False}], - "correct_answer": "True", - "explanation": "Sofa in living room." - }, - { - "id": "ex_v2_5", - "type": "reorder", - "ui_type": "arrange_the_sentence", - "question": "Arrange: 'in / the / bed / is / bedroom / the'", - "options": [{"id": "0", "text": "the"}, {"id": "1", "text": "bed"}, {"id": "2", "text": "is"}, {"id": "3", "text": "in"}, {"id": "4", "text": "the"}, {"id": "5", "text": "bedroom"}], - "correct_answer": "the bed is in the bedroom", - "explanation": "Correct sentence structure." - }, - { - "id": "ex_v2_6", - "type": "translate", - "ui_type": "translation_choice", - "question": "Translation of: 'Phòng tắm'", - "options": [{"id": "0", "text": "bathroom", "is_correct": True}, {"id": "1", "text": "garden", "is_correct": False}], - "correct_answer": "bathroom", - "explanation": "Phòng tắm is bathroom." - }, - { - "id": "ex_v2_7", - "type": "translate", - "ui_type": "speaking_repeat", - "question": "Repeat: 'The desk is clean.'", - "correct_answer": "The desk is clean", - "explanation": "Evaluate pronunciation." - }, - { - "id": "ex_v2_8", - "type": "fill_blank", - "ui_type": "dictation", - "question": "Dictation: Listen and write '{blank}'", - "correct_answer": "Open the window", - "explanation": "Write what you hear." - }, - { - "id": "ex_v2_9", - "type": "multiple_choice", - "ui_type": "image_based_choice", - "question": "Select the image that shows a table.", - "options": [{"id": "0", "text": "Table", "is_correct": True}, {"id": "1", "text": "Chair", "is_correct": False}], - "correct_answer": "Table", - "explanation": "Card with table." - }, - { - "id": "ex_v2_10", - "type": "multiple_choice", - "ui_type": "vocabulary_flashcard", - "question": "Learn: 'Furnishings'", - "options": [{"id": "0", "text": "Got it!", "is_correct": True}], - "correct_answer": "Got it!", - "explanation": "Furniture and appliances in a room." - } - ] - }, - ], - }, - ], - }, - ] - for course_data in sample_courses: - result = await db.execute( - select(Course).where(Course.title == course_data["title"]) - ) - existing_course = result.scalar_one_or_none() - if existing_course: - await db.delete(existing_course) - await db.flush() - - unit_seed = course_data.pop("units") - category_slug = course_data.pop("category_slug") - - course = Course( - **course_data, - category_id=category_ids.get(category_slug), - total_lessons=sum(len(unit["lessons"]) for unit in unit_seed), - is_published=True, - ) - db.add(course) - await db.flush() - created["courses"] += 1 - - for unit_data in unit_seed: - lesson_seed = unit_data.pop("lessons") - unit = Unit( - **unit_data, - course_id=course.id, - total_lessons=len(lesson_seed), - ) - db.add(unit) - await db.flush() - created["units"] += 1 - - for lesson_data in lesson_seed: - exercises = lesson_data.pop("exercises", []) - lesson = Lesson( - **lesson_data, - course_id=course.id, - unit_id=unit.id, - content={"exercises": exercises, "version": 1}, - pass_threshold=70, - lesson_type="lesson", - ) - db.add(lesson) - created["lessons"] += 1 - - await db.commit() - return ApiResponse( success=True, message=( diff --git a/backend-service/app/services/admin_seed_service.py b/backend-service/app/services/admin_seed_service.py new file mode 100644 index 00000000..c5490a3c --- /dev/null +++ b/backend-service/app/services/admin_seed_service.py @@ -0,0 +1,121 @@ +"""Seeding logic for the dev-only /admin/seed endpoint, split by data type.""" + +import copy +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.sample_data_catalog import ( + SAMPLE_ACHIEVEMENTS, + SAMPLE_COURSE_CATEGORIES, + SAMPLE_COURSES, +) +from app.core.shop_catalog import SHOP_CATALOG +from app.models.course import Course, Lesson, Unit +from app.models.course_category import CourseCategory +from app.models.gamification import Achievement, ShopItem + + +async def seed_achievements(db: AsyncSession) -> int: + created = 0 + for ach_data in SAMPLE_ACHIEVEMENTS: + result = await db.execute( + select(Achievement).where(Achievement.name == ach_data["name"]) + ) + if not result.scalar_one_or_none(): + db.add(Achievement(**ach_data)) + created += 1 + return created + + +async def seed_shop_items(db: AsyncSession) -> int: + created = 0 + for item_data in SHOP_CATALOG: + result = await db.execute( + select(ShopItem).where(ShopItem.name == item_data["name"]) + ) + existing_item = result.scalar_one_or_none() + if not existing_item: + db.add(ShopItem(**item_data)) + created += 1 + else: + for field, value in item_data.items(): + setattr(existing_item, field, value) + return created + + +async def seed_course_categories(db: AsyncSession) -> tuple[int, dict[str, UUID]]: + created = 0 + category_ids: dict[str, UUID] = {} + for category_data in SAMPLE_COURSE_CATEGORIES: + result = await db.execute( + select(CourseCategory).where(CourseCategory.slug == category_data["slug"]) + ) + category = result.scalar_one_or_none() + if not category: + category = CourseCategory(**category_data) + db.add(category) + await db.flush() + created += 1 + category_ids[category_data["slug"]] = category.id + return created, category_ids + + +async def seed_courses( + db: AsyncSession, category_ids: dict[str, UUID] +) -> tuple[int, int, int]: + """Returns (courses_created, units_created, lessons_created).""" + courses_created = 0 + units_created = 0 + lessons_created = 0 + + # SAMPLE_COURSES is a module-level constant mutated in place below + # (.pop on units/exercises) — deep-copy so repeated calls stay idempotent. + for course_data in copy.deepcopy(SAMPLE_COURSES): + result = await db.execute( + select(Course).where(Course.title == course_data["title"]) + ) + existing_course = result.scalar_one_or_none() + if existing_course: + await db.delete(existing_course) + await db.flush() + + unit_seed = course_data.pop("units") + category_slug = course_data.pop("category_slug") + + course = Course( + **course_data, + category_id=category_ids.get(category_slug), + total_lessons=sum(len(unit["lessons"]) for unit in unit_seed), + is_published=True, + ) + db.add(course) + await db.flush() + courses_created += 1 + + for unit_data in unit_seed: + lesson_seed = unit_data.pop("lessons") + unit = Unit( + **unit_data, + course_id=course.id, + total_lessons=len(lesson_seed), + ) + db.add(unit) + await db.flush() + units_created += 1 + + for lesson_data in lesson_seed: + exercises = lesson_data.pop("exercises", []) + lesson = Lesson( + **lesson_data, + course_id=course.id, + unit_id=unit.id, + content={"exercises": exercises, "version": 1}, + pass_threshold=70, + lesson_type="lesson", + ) + db.add(lesson) + lessons_created += 1 + + return courses_created, units_created, lessons_created From 22373cadc83906f9a492c3672ca160987bef8d9a Mon Sep 17 00:00:00 2001 From: Thang Nguyen Huu <2001230909@hufi.edu.vn> Date: Wed, 24 Jun 2026 11:26:32 +0700 Subject: [PATCH 07/20] refactor(ai-service): split retrieve_node/generate_node out of nodes_v2.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nodes_v2.py had grown to 2015 lines. Moves retrieve_node (budgeted hybrid retrieval + fusion scoring) into trace_cag/retrieve.py, and generate_node plus its build_generation_prompt/stream_llm_tokens helpers into trace_cag/generate.py, leaving nodes_v2.py at 885 lines. Both are re-exported from nodes_v2.py so existing imports (graph.py, lexi_chat_service.py, tests) keep working unchanged. Pure move — no logic changes; 114/114 trace_cag tests pass. Co-Authored-By: Claude Sonnet 4.6 --- ai-service/api/services/trace_cag/generate.py | 660 ++++++++++ ai-service/api/services/trace_cag/nodes_v2.py | 1152 +---------------- ai-service/api/services/trace_cag/retrieve.py | 501 +++++++ 3 files changed, 1172 insertions(+), 1141 deletions(-) create mode 100644 ai-service/api/services/trace_cag/generate.py create mode 100644 ai-service/api/services/trace_cag/retrieve.py diff --git a/ai-service/api/services/trace_cag/generate.py b/ai-service/api/services/trace_cag/generate.py new file mode 100644 index 00000000..be80f4e7 --- /dev/null +++ b/ai-service/api/services/trace_cag/generate.py @@ -0,0 +1,660 @@ +""" +TraceCAG generate_node — grounded generation (LLM call with KG/retrieval context). + +Split out of nodes_v2.py (Phase 4 refactor) along with its prompt-building and +token-streaming helpers, which the streaming chat endpoint also calls directly. +""" + +import asyncio +import json +import logging +import os +import time +from typing import Any, AsyncGenerator, Dict, List + +from api.services.trace_cag.state import TraceCAGState +from api.services.trace_cag.evaluation_agent import EvaluationAgent +from api.services.document_intelligence import get_doc_intel_service +from api.services.llama_kv_service import get_local_llama_kv_service + +from api.services.trace_cag.env_helpers import _env_flag +from api.services.trace_cag.provider_state import _provider_is_disabled +from api.services.trace_cag.llm_client import _get_httpx_client, _throttled_post_json +from api.services.trace_cag.cache_utils import _write_cache_entry + +from api.services.trace_cag.benchmark.ranking import _update_ranker_from_generation +from api.services.trace_cag.benchmark.qa_generation import ( + _generate_extractive_fallback_response, + _generate_benchmark_qa_response, +) + +logger = logging.getLogger(__name__) + +_LOCAL_LLAMA_CORE_SYSTEM_PROMPT = ( + "You are Lexi, an expert English tutor. " + "Provide grounded, concise and actionable feedback. " + "If evidence is weak, state uncertainty explicitly." +) + + +def _compute_difficulty_ramp(session_turn: int, overall_score: float) -> str: + """ + PCC difficulty signal based on session progress + learner performance. + + Returns: 'gentle' | 'standard' | 'challenging' + - gentle: first 3 turns or score < 0.50 + - standard: turns 3-8 or score 0.50-0.70 + - challenging: turns 8+ with score >= 0.70 + """ + if session_turn < 3 or overall_score < 0.50: + return "gentle" + if session_turn < 8 or overall_score < 0.70: + return "standard" + return "challenging" + + +def build_generation_prompt( + state: Dict[str, Any], +) -> tuple[str, List[Dict[str, Any]]]: + """Extract (system_prompt, messages) from raw pipeline state. + + Used by the streaming endpoint so it can call the LLM with streaming + enabled after the rest of the pipeline (KG, diagnosis, retrieval) has + prepared the context. + """ + errors = state.get("diagnosis_errors", []) + intent = state.get("diagnosis_intent", "correct") + level = (state.get("learner_profile") or {}).get("level", "B1") + user_input = str(state.get("user_input") or "") + context = str(state.get("retrieved_context") or "") + vietnamese_hint = state.get("vietnamese_hint") + session_turn = len(state.get("conversation_history") or []) + prev_overall = state.get("overall_score", 0.5) + fluency_score = state.get("fluency_score", 0.8) + error_count = len(errors) + + difficulty = _compute_difficulty_ramp(session_turn, prev_overall) + + if intent == "explain" and fluency_score > 0.7: + strategy = "socratic" + elif error_count == 0: + strategy = "praise" + elif error_count <= 2: + strategy = "feedback" + else: + strategy = "scaffold" + + system_prompt = ( + "You are Lexi 🦜, a cheerful, witty parrot who is an expert English tutor.\n" + "You speak in a warm, encouraging tone — like a fun game character guiding an adventure.\n" + "Keep responses concise (2-4 sentences). Use the knowledge context provided.\n" + "Gently correct mistakes with encouraging context.\n" + f"The learner's current CEFR level is: {level}\n" + f"Difficulty setting for this turn: {difficulty}\n" + ) + if context: + system_prompt += f"\n--- Knowledge Graph Context ---\n{context}\n" + + if strategy == "socratic": + system_prompt += ( + "\nStrategy: SOCRATIC. Guide through short questions, don't reveal answer.\n" + ) + if errors: + hints = "\n".join( + f"- '{e.get('span','')}' → '{e.get('correction','')}'" + for e in errors[:3] + ) + system_prompt += f"\n--- Errors (hints only) ---\n{hints}\n" + elif errors: + errs_text = "\n".join( + f"- '{e.get('span','')}' → '{e.get('correction','')}' ({e.get('explanation','')})" + for e in errors[:3] + ) + system_prompt += f"\n--- Errors Found ---\n{errs_text}\n" + system_prompt += f"Strategy: {strategy}. Weave corrections naturally.\n" + else: + system_prompt += "\nNo errors found — praise the learner's effort!\n" + + if vietnamese_hint: + system_prompt += f"\n--- Vietnamese Hint ---\n{vietnamese_hint}\n" + + messages: List[Dict[str, Any]] = [{"role": "system", "content": system_prompt}] + for msg in (state.get("conversation_history") or [])[-12:]: + role = msg.get("role") + content = msg.get("content", "") + if role and content: + messages.append({"role": role, "content": content}) + elif msg.get("user"): + messages.append({"role": "user", "content": msg["user"]}) + if msg.get("ai"): + messages.append({"role": "assistant", "content": msg["ai"]}) + messages.append({"role": "user", "content": user_input}) + + return system_prompt, messages + + +async def stream_llm_tokens( + *, + system_prompt: str, + messages: List[Dict[str, Any]], + user_input: str, +) -> "AsyncGenerator[str, None]": + """Stream tokens from Groq (preferred) then Gemini as fallback. + + Yields raw text deltas as they arrive from the provider. + Falls back silently to Gemini if Groq is unavailable or rate-limited. + """ + + async def _try_groq() -> "AsyncGenerator[str, None]": + from api.core.groq_key_pool import get_available_groq_key, record_groq_key_usage + + groq_key = await get_available_groq_key(estimated_tokens=512) + groq_model = os.getenv("GROQ_MODEL", "qwen/qwen3-32b") + if not groq_key or _provider_is_disabled("groq"): + return + + # Disable Qwen3 thinking mode to prevent thinking tokens consuming max_tokens budget. + groq_messages = messages + if "qwen" in groq_model.lower(): + groq_messages = list(messages) + for i, msg in enumerate(groq_messages): + if msg.get("role") == "user": + groq_messages[i] = {**msg, "content": f"/no_think\n{msg['content']}"} + break + + client = _get_httpx_client("groq") + tokens_yielded = 0 + try: + async with client.stream( + "POST", + "https://api.groq.com/openai/v1/chat/completions", + headers={ + "Authorization": f"Bearer {groq_key}", + "Content-Type": "application/json", + }, + json={ + "model": groq_model, + "messages": groq_messages, + "max_tokens": 512, + "temperature": 0.7, + "stream": True, + }, + timeout=25.0, + ) as resp: + if resp.status_code != 200: + logger.warning("[stream_llm_tokens] Groq status %d", resp.status_code) + return + async for line in resp.aiter_lines(): + if not line.startswith("data: "): + continue + data = line[6:] + if data.strip() == "[DONE]": + break + try: + obj = json.loads(data) + delta = obj["choices"][0]["delta"].get("content") or "" + if delta: + tokens_yielded += len(delta.split()) + yield delta + except Exception as _exc: + logger.debug("[generate] ignored: %s", _exc) + pass + await record_groq_key_usage(groq_key, max(50, tokens_yielded + 50)) + except Exception as exc: + logger.warning("[stream_llm_tokens] Groq stream error: %s", exc) + + async def _try_gemini() -> "AsyncGenerator[str, None]": + gemini_key = os.getenv("GEMINI_API_KEY", "") + if not gemini_key or _provider_is_disabled("gemini"): + return + + url = ( + "https://generativelanguage.googleapis.com/v1beta/models/" + f"gemini-2.0-flash:streamGenerateContent?key={gemini_key}&alt=sse" + ) + request_body = { + "contents": [{"role": "user", "parts": [{"text": user_input}]}], + "systemInstruction": {"parts": [{"text": system_prompt}]}, + } + client = _get_httpx_client("gemini") + try: + async with client.stream( + "POST", url, json=request_body, timeout=25.0 + ) as resp: + if resp.status_code != 200: + logger.warning("[stream_llm_tokens] Gemini status %d", resp.status_code) + return + async for line in resp.aiter_lines(): + if not line.startswith("data: "): + continue + data = line[6:] + try: + obj = json.loads(data) + text = ( + obj.get("candidates", [{}])[0] + .get("content", {}) + .get("parts", [{}])[0] + .get("text", "") + ) + if text: + yield text + except Exception as _exc: + logger.debug("[generate] ignored: %s", _exc) + pass + except Exception as exc: + logger.warning("[stream_llm_tokens] Gemini stream error: %s", exc) + + # Try Groq first; if it yields nothing, fall back to Gemini + got_tokens = False + async for token in _try_groq(): + got_tokens = True + yield token + + if not got_tokens: + async for token in _try_gemini(): + yield token + + +async def generate_node(state: TraceCAGState) -> Dict[str, Any]: + """ + Generate the tutor response using LLM grounded in KG evidence. + + This node calls the LLM fallback chain (Groq → Gemini → Ollama) + with the Lexi persona, KG context, and diagnosis data injected + into the system prompt. This is the SINGLE place where LLM + generation happens — callers should NOT make a separate LLM call. + """ + # When the streaming endpoint handles generation externally, skip this node. + if state.get("generation_policy") == "skip": + return {} + + logger.info("[generate_node] Generating grounded tutor response...") + start_time = time.time() + + errors = state.get("diagnosis_errors", []) + intent = state.get("diagnosis_intent", "correct") + level = state.get("learner_profile", {}).get("level", "B1") + user_input = state.get("user_input", "") + context = state.get("retrieved_context", "") + jit_soft_graph = str(state.get("jit_soft_graph") or "").strip() + vietnamese_hint = state.get("vietnamese_hint") + benchmark_task = state.get("benchmark_task") + + if benchmark_task in {"multihop_qa", "retrieval_qa"}: + return await _generate_benchmark_qa_response(state, start_time) + + # Determine strategy (paper Eq. strategy) + error_count = len(errors) + fluency_score = state.get("fluency_score", 0.8) + + if intent == "explain" and fluency_score > 0.7: + strategy = "socratic" + elif error_count == 0: + strategy = "praise" + elif error_count <= 2: + strategy = "feedback" + else: + strategy = "scaffold" + + generation_policy = state.get("generation_policy", "auto") + if generation_policy == "template": + logger.warning("[generate_node] generation_policy='template' is deprecated; using extractive policy") + generation_policy = "extractive" + + if generation_policy == "extractive": + response = _generate_extractive_fallback_response(errors, strategy, user_input, context) + model_used = "extractive_policy" + + if strategy == "socratic": + next_action = "ask" + elif error_count == 0: + next_action = "continue" + elif error_count <= 2: + next_action = "hint" + else: + next_action = "correct" + + grammar_score = state.get("grammar_score", 0.8) + fluency_score = state.get("fluency_score", 0.8) + vocab_level = state.get("vocabulary_level", "B1") + overall_score = EvaluationAgent.compute_overall_score(grammar_score, fluency_score, vocab_level) + + _update_ranker_from_generation( + question=user_input, + response=response, + retrieval_trace=list(state.get("retrieval_trace") or []), + ) + + if state.get("cache_policy", "on") == "on": + try: + await _write_cache_entry(state, response, strategy, errors, overall_score, context, model_used=model_used) + except Exception as e: + logger.debug(f"[generate_node] Cache write failed: {e}") + + latency_ms = int((time.time() - start_time) * 1000) + + return { + "tutor_response": response, + "strategy": strategy, + "next_action": next_action, + "overall_score": overall_score, + "ttft_ms": latency_ms, + "models_used": [model_used], + } + + # Build system prompt with Lexi persona + grounded context + session_turn = len(state.get("conversation_history", [])) + prev_overall = state.get("overall_score", 0.5) + difficulty = _compute_difficulty_ramp(session_turn, prev_overall) + + system_prompt = ( + "You are Lexi 🦜, a cheerful, witty parrot who is an expert English tutor.\n" + "You speak in a warm, encouraging tone — like a fun game character guiding an adventure.\n" + "Keep responses concise (2-4 sentences). Use the knowledge context provided.\n" + "Gently correct mistakes with encouraging context.\n" + f"The learner's current CEFR level is: {level}\n" + f"Difficulty setting for this turn: {difficulty}\n" + ) + + if context: + system_prompt += f"\n--- Knowledge Graph Context ---\n{context}\n" + + if strategy == "socratic": + system_prompt += ( + "\nStrategy: SOCRATIC. The learner asked for an explanation and is fairly fluent.\n" + "Guide them through a chain of short questions so they discover the answer themselves.\n" + "Do NOT give the answer directly — instead ask 1-2 leading questions.\n" + ) + if errors: + errors_text = "\n".join([ + f"- '{e.get('span','')}' → '{e.get('correction','')}' ({e.get('explanation','')})" + for e in errors[:3] + ]) + system_prompt += f"\n--- Errors Found (use as hints, don't reveal directly) ---\n{errors_text}\n" + elif errors: + errors_text = "\n".join([ + f"- '{e.get('span','')}' → '{e.get('correction','')}' ({e.get('explanation','')})" + for e in errors[:3] + ]) + system_prompt += f"\n--- Errors Found ---\n{errors_text}\n" + system_prompt += f"Strategy: {strategy}. Weave corrections naturally into your response.\n" + else: + system_prompt += "\nNo errors found — praise the learner's effort!\n" + + if vietnamese_hint: + system_prompt += f"\n--- Vietnamese Hint (for reference) ---\n{vietnamese_hint}\n" + + response = "" + model_used = "llm_unavailable" + + local_llama_enabled = _env_flag("TRACECAG_ENABLE_LOCAL_LLAMA_KV", False) + if local_llama_enabled and not response: + try: + local_llama = get_local_llama_kv_service() + local_result = await local_llama.generate( + session_id=str(state.get("session_id") or "default"), + core_system_prompt=_LOCAL_LLAMA_CORE_SYSTEM_PROMPT, + dynamic_system_prompt=system_prompt, + user_query=user_input, + soft_graph="" if "[JIT_SOFT_GRAPH]" in context else jit_soft_graph, + ) + if local_result and str(local_result.get("text") or "").strip(): + response = str(local_result.get("text") or "").strip() + model_used = str(local_result.get("model") or "llama_cpp_kv") + except Exception as e: + logger.warning(f"[generate_node] Local llama KV path failed, fallback to provider chain: {e}") + + # Call LLM via fallback chain (Groq → Gemini → Ollama) + if not response: + response = "" + model_used = "llm_unavailable" + + try: + import httpx + + messages = [{"role": "system", "content": system_prompt}] + + # Inject conversation history (last 6 turns = up to 12 messages) + history = state.get("conversation_history", []) + for msg in history[-12:]: + role = msg.get("role") + content = msg.get("content", "") + if role and content: + # Standard {"role": "user"/"assistant", "content": "..."} format + messages.append({"role": role, "content": content}) + elif msg.get("user"): + # ConversationCache {"user": "...", "ai": "..."} format + messages.append({"role": "user", "content": msg["user"]}) + if msg.get("ai"): + messages.append({"role": "assistant", "content": msg["ai"]}) + + messages.append({"role": "user", "content": user_input}) + + if not response: + # Race Groq and Gemini concurrently; first successful response wins. + # Ollama is kept as last-resort with a tighter 15s timeout. + import asyncio as _asyncio + from api.core.groq_key_pool import get_available_groq_key, record_groq_key_usage + + groq_key = await get_available_groq_key(estimated_tokens=512) + groq_model = os.getenv("GROQ_MODEL", "qwen/qwen3-32b") + gemini_key = os.getenv("GEMINI_API_KEY", "") + + # Disable Qwen3 thinking to keep token budget for actual response. + _groq_messages = list(messages) + if "qwen" in groq_model.lower(): + for i, msg in enumerate(_groq_messages): + if msg.get("role") == "user": + _groq_messages[i] = {**msg, "content": f"/no_think\n{msg['content']}"} + break + + async def _try_groq(): + if not groq_key: + return None, None + try: + resp = await _throttled_post_json( + provider="groq", + url="https://api.groq.com/openai/v1/chat/completions", + headers={"Authorization": f"Bearer {groq_key}", "Content-Type": "application/json"}, + payload={"model": groq_model, "messages": _groq_messages, "max_tokens": 512, "temperature": 0.7}, + httpx_module=httpx, + timeout=20.0, + ) + if resp is not None and resp.status_code == 200: + data = resp.json() + tokens = data.get("usage", {}).get("total_tokens", 500) + await record_groq_key_usage(groq_key, tokens) + return data["choices"][0]["message"]["content"], f"groq/{groq_model}" + except Exception as e: + logger.warning(f"[generate_node] Groq failed: {e}") + return None, None + + async def _try_gemini(): + if not gemini_key: + return None, None + try: + gemini_contents = [{"role": "user", "parts": [{"text": user_input}]}] + url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={gemini_key}" + request_body = { + "contents": gemini_contents, + "systemInstruction": {"parts": [{"text": system_prompt}]}, + } + resp = await _throttled_post_json( + provider="gemini", + url=url, + payload=request_body, + httpx_module=httpx, + timeout=20.0, + ) + if resp is not None and resp.status_code == 200: + candidates = resp.json().get("candidates", []) + if candidates: + return candidates[0]["content"]["parts"][0]["text"], "gemini-2.0-flash" + except Exception as e: + logger.warning(f"[generate_node] Gemini failed: {e}") + return None, None + + # Launch both concurrently; pick first non-None result. + tasks = [_asyncio.ensure_future(_try_groq()), _asyncio.ensure_future(_try_gemini())] + done, pending = await _asyncio.wait(tasks, return_when=_asyncio.FIRST_COMPLETED) + for task in done: + try: + _text, _model = task.result() + if _text: + response = _text + model_used = _model + break + except Exception as _exc: + logger.debug("[generate] ignored: %s", _exc) + pass + # If winner found, cancel the loser immediately. + if response: + for p in pending: + p.cancel() + else: + # Wait for the second one too before falling back to Ollama. + if pending: + done2, _ = await _asyncio.wait(pending, timeout=15.0) + for task in done2: + try: + _text, _model = task.result() + if _text: + response = _text + model_used = _model + break + except Exception as _exc: + logger.debug("[generate] ignored: %s", _exc) + pass + + # Ollama — last resort, tight 15s timeout. + if not response: + from api.core.config import settings + ollama_url = settings.OLLAMA_BASE_URL + ollama_model = os.getenv("OLLAMA_MODEL", "lexilingo-qwen3-1.7b") + try: + resp = await _throttled_post_json( + provider="ollama", + url=f"{ollama_url}/api/chat", + payload={ + "model": ollama_model, + "messages": messages, + "stream": False, + "options": {"num_predict": 256, "temperature": 0.7}, + }, + httpx_module=httpx, + timeout=15.0, + max_retries=1, + ) + if resp is not None and resp.status_code == 200: + response = resp.json().get("message", {}).get("content", "") + model_used = f"ollama/{ollama_model}" + except Exception as e: + logger.warning(f"[generate_node] Ollama failed: {e}") + + except Exception as e: + logger.error(f"[generate_node] LLM chain error: {e}") + + # 4. Deterministic extractive fallback + if not response: + response = _generate_extractive_fallback_response(errors, strategy, user_input, context) + model_used = "extractive_fallback" + + # Determine next action + if strategy == "socratic": + next_action = "ask" + elif error_count == 0: + next_action = "continue" + elif error_count <= 2: + next_action = "hint" + else: + next_action = "correct" + + # Calculate overall score + grammar_score = state.get("grammar_score", 0.8) + fluency_score = state.get("fluency_score", 0.8) + vocab_level = state.get("vocabulary_level", "B1") + overall_score = EvaluationAgent.compute_overall_score(grammar_score, fluency_score, vocab_level) + + _update_ranker_from_generation( + question=user_input, + response=response, + retrieval_trace=list(state.get("retrieval_trace") or []), + ) + + # Generate personalized practice exercises via ContentAutoGenerator (cag_service) + action_plan = [] + if errors or intent == "practice": + try: + from api.services.cag_service import ContentAutoGenerator + cag_gen = ContentAutoGenerator() + err_types = [err.get("type", "grammar") for err in errors if isinstance(err, dict)] + if not err_types: + err_types = ["grammar"] + + first_err_type = err_types[0] if errors else "vocabulary" + if "vocab" in first_err_type.lower() or intent == "practice": + vocab_ex = cag_gen.generate_vocabulary_exercise( + level=level, + error_patterns=err_types if errors else None, + count=3 + ) + action_plan.append({ + "action": "practice", + "type": "vocabulary", + "concept": vocab_ex.get("topic", ""), + "count": len(vocab_ex.get("words", [])), + "exercise": vocab_ex + }) + else: + grammar_drill = cag_gen.generate_grammar_drill( + level=level, + error_patterns=err_types, + count=3 + ) + action_plan.append({ + "action": "practice", + "type": "grammar", + "concept": grammar_drill.get("grammar_point", ""), + "count": len(grammar_drill.get("exercises", [])), + "exercise": grammar_drill + }) + except Exception as cag_err: + logger.warning(f"[generate_node] Failed to generate CAG practice: {cag_err}") + + state["action_plan"] = action_plan + + # Store response in Redis cache for future hits + if state.get("cache_policy", "on") == "on": + try: + # ── Tiered Cache Management (L0/L1 Promotion) ───────────── + # Nếu thông tin từ L2 được sử dụng, kiểm tra thăng hạng + doc_service = get_doc_intel_service() + trace = state.get("retrieval_trace", []) + is_l2_used = any(t.get("item_id", "").startswith("ext_") for t in trace[:3]) + + if is_l2_used: + for t in trace[:3]: + if t.get("item_id", "").startswith("ext_"): + chunk_id = str(t.get("item_id") or "").replace("ext_", "") + if doc_service.should_promote_to_cache(chunk_id): + logger.info(f"[cache_promotion] Chunk {chunk_id[:8]} promoted to L1 cache") + await _write_cache_entry(state, response, strategy, errors, overall_score, context) + break + else: + # Mặc định cache cho các luồng KG/Rules để tối ưu tốc độ + await _write_cache_entry(state, response, strategy, errors, overall_score, context) + except Exception as e: + logger.debug(f"[generate_node] Cache write failed: {e}") + + latency_ms = int((time.time() - start_time) * 1000) + logger.info(f"[generate_node] Generated response via {model_used} in {latency_ms}ms") + + return { + "tutor_response": response, + "strategy": strategy, + "next_action": next_action, + "overall_score": overall_score, + "action_plan": action_plan, + "ttft_ms": latency_ms, + "models_used": [model_used], + } diff --git a/ai-service/api/services/trace_cag/nodes_v2.py b/ai-service/api/services/trace_cag/nodes_v2.py index 2cb93b7e..b8595e7d 100644 --- a/ai-service/api/services/trace_cag/nodes_v2.py +++ b/ai-service/api/services/trace_cag/nodes_v2.py @@ -17,30 +17,20 @@ import re import json import time -import math -from typing import AsyncGenerator, Dict, Any, List, Optional +from typing import Dict, Any, List, Optional from api.services.trace_cag.state import TraceCAGState, DiagnosisError from api.services.trace_cag.evaluation_agent import EvaluationAgent -from api.services.trace_cag.retrieval_ranker import get_retrieval_ranker -from api.services.document_intelligence import get_doc_intel_service from api.services.jit_graph_service import get_jit_graph_service -from api.services.llama_kv_service import get_local_llama_kv_service -from api.services.trace_cag.env_helpers import _env_flag, _env_float, _env_int, _clip01 +from api.services.trace_cag.env_helpers import _env_flag from api.services.trace_cag.provider_state import _provider_is_disabled # LLM client — httpx pooling and rate-limit throttling -from api.services.trace_cag.llm_client import _get_httpx_client, _throttled_post_json - -# KG query cache utilities -from api.services.trace_cag.kg_utils import ( - _KG_QUERY_CACHE, _kg_cache_key, _kg_cache_get, _kg_cache_set, - _pack_kg_nodes_for_context, -) +from api.services.trace_cag.llm_client import _throttled_post_json # PCC cache R/W helpers used by the generation path -from api.services.trace_cag.cache_utils import _detect_native_request, _write_cache_entry +from api.services.trace_cag.cache_utils import _detect_native_request # Façade re-exports: graph.py imports cache_gate_node from this module, and the # cache-gate tests reach these helpers through the nodes_v2 namespace. @@ -52,35 +42,23 @@ _profile_epoch, ) -from api.services.trace_cag.benchmark.adaptive import ( - _adaptive_mode_enabled, _choose_adaptive_profile, -) -from api.services.trace_cag.benchmark.ranking import ( - _select_diverse_multihop_evidence, _compute_evidence_budget, - _rank_benchmark_candidates, _ranker_enabled, _rank_with_online_ranker, - _build_benchmark_candidates, _update_ranker_from_generation, -) -from api.services.trace_cag.benchmark.qa_generation import ( - _generate_extractive_fallback_response, - _generate_benchmark_qa_response, +# retrieve_node / generate_node live in their own modules (Phase 4 split) — +# re-exported here so existing `from nodes_v2 import ...` call sites keep working. +from api.services.trace_cag.retrieve import retrieve_node # noqa: F401 +from api.services.trace_cag.generate import ( # noqa: F401 + build_generation_prompt, + stream_llm_tokens, + generate_node, ) logger = logging.getLogger(__name__) -_LOCAL_LLAMA_CORE_SYSTEM_PROMPT = ( - "You are Lexi, an expert English tutor. " - "Provide grounded, concise and actionable feedback. " - "If evidence is weak, state uncertainty explicitly." -) - - # ============================================================ # MODEL GATEWAY INTEGRATION # ============================================================ _gateway_instance = None -_retrieval_v3_instance = None async def get_gateway(): @@ -94,16 +72,6 @@ async def get_gateway(): return _gateway_instance -async def _get_retrieval_v3(): - """Lazy singleton for RetrievalServiceV3 (centrality + community ranking).""" - global _retrieval_v3_instance - if _retrieval_v3_instance is None: - from api.services.kg_service_v3 import get_kg_service - from api.services.retrieval_service_v3 import RetrievalServiceV3 - _retrieval_v3_instance = RetrievalServiceV3(get_kg_service()) - return _retrieval_v3_instance - - # ============================================================ # NODE 1: INPUT NODE # ============================================================ @@ -661,1104 +629,6 @@ async def _jit_graph_extract_node(state: TraceCAGState) -> Dict[str, Any]: "jit_graph_meta": {"enabled": False, "error": str(exc)}, } -_FUSION_ALPHA = 0.5 # KG structural relevance weight -_FUSION_BETA = 0.3 # Vector similarity weight -_FUSION_GAMMA = 0.2 # Recency bonus weight -_RECENCY_LAMBDA = 0.01 # Decay rate for recency bonus - - -# ============================================================ -# PCC: PROGRESSIVE COMPLEXITY CONTROL -# ============================================================ - -def _compute_difficulty_ramp(session_turn: int, overall_score: float) -> str: - """ - PCC difficulty signal based on session progress + learner performance. - - Returns: 'gentle' | 'standard' | 'challenging' - - gentle: first 3 turns or score < 0.50 - - standard: turns 3-8 or score 0.50-0.70 - - challenging: turns 8+ with score >= 0.70 - """ - if session_turn < 3 or overall_score < 0.50: - return "gentle" - if session_turn < 8 or overall_score < 0.70: - return "standard" - return "challenging" - - -def _fusion_score( - kg_depth: int, - vec_sim: float, - last_used_turns_ago: int, -) -> float: - """ - Compute fusion score for a retrieved evidence item (paper Eq. 8). - - s_kg = 1 / (1 + depth) — inverse hop distance - s_vec = cosine similarity — from MiniLM - s_rec = exp(-λ · Δt) — recency bonus - """ - s_kg = 1.0 / (1.0 + kg_depth) - s_vec = vec_sim - s_rec = math.exp(-_RECENCY_LAMBDA * last_used_turns_ago) - return _FUSION_ALPHA * s_kg + _FUSION_BETA * s_vec + _FUSION_GAMMA * s_rec - - - -# ============================================================ -# NODE 4: BUDGETED HYBRID RETRIEVAL (paper Alg. 5) -# ============================================================ - -async def retrieve_node(state: TraceCAGState) -> Dict[str, Any]: - """ - Budgeted hybrid retrieval with fusion scoring (paper Alg. 5). - - Stage 1: Graph-local evidence (cheap) — KG concepts + diagnosis - Stage 2: Optional vector evidence (budgeted) — MiniLM similarity - Fusion: score(e) = α·s_kg + β·s_vec + γ·s_rec (Eq. 8) - """ - logger.info("[retrieve_node] Budgeted hybrid retrieval...") - start_time = time.time() - retrieve_start = time.monotonic() - - kg_budget_ms = max(0.0, _env_float("TRACECAG_RETRIEVE_BUDGET_KG_MS", 120.0)) - vector_budget_ms = max(0.0, _env_float("TRACECAG_RETRIEVE_BUDGET_VECTOR_MS", 80.0)) - fusion_budget_ms = max(0.0, _env_float("TRACECAG_RETRIEVE_BUDGET_FUSION_MS", 40.0)) - total_budget_ms = kg_budget_ms + vector_budget_ms + fusion_budget_ms - - def _elapsed_ms() -> float: - return (time.monotonic() - retrieve_start) * 1000.0 - - budget_exhausted = False - - retrieval_policy = state.get("retrieval_policy", "full") - benchmark_context = (state.get("benchmark_context") or "").strip() - benchmark_task = state.get("benchmark_task") or "" - benchmark_metadata = state.get("benchmark_metadata") or {} - benchmark_ranker = str(benchmark_metadata.get("_benchmark_ranker") or "graph").strip().lower() - benchmark_mode = str(benchmark_metadata.get("_benchmark_mode") or "").strip().lower() - user_input = state.get("user_input", "") - adaptive_profile = str(state.get("adaptive_profile") or "").strip().lower() - adaptive_features = dict(state.get("adaptive_features") or {}) - adaptive_controller = dict(state.get("adaptive_controller") or {}) - - if _adaptive_mode_enabled(state, benchmark_mode) and not adaptive_profile: - adaptive_choice = _choose_adaptive_profile( - state=state, - user_input=user_input, - benchmark_task=benchmark_task, - benchmark_mode=benchmark_mode, - benchmark_metadata=benchmark_metadata, - ) - adaptive_profile = str(adaptive_choice.get("profile") or "balanced") - adaptive_features = dict(adaptive_choice.get("features") or {}) - adaptive_controller = { - **dict(adaptive_choice.get("controller") or {}), - "explore": bool(adaptive_choice.get("explore", False)), - "objective_map": adaptive_choice.get("objective_map", {}), - "tau_reuse": adaptive_choice.get("tau_reuse"), - "tau_patch": adaptive_choice.get("tau_patch"), - "support_floor": adaptive_choice.get("support_floor"), - "evidence_budget_delta": adaptive_choice.get("evidence_budget_delta"), - } - - kg_concepts = state.get("kg_seed_concepts", []) - kg_expanded = state.get("kg_expanded_nodes", []) - session_turn = len(state.get("conversation_history", [])) - benchmark_candidates, relevant_ids = _build_benchmark_candidates(state) - - # ── Stage 1: Graph-local evidence (cheap) ──────────────────────── - # Each evidence item: {"text": ..., "kg_depth": ..., "vec_sim": ..., "turns_ago": ...} - evidence_items: List[Dict[str, Any]] = [] - - for concept_id in state.get("diagnosis_root_causes", []): - evidence_items.append({ - "text": f"Grammar concept: {concept_id}", - "kg_depth": 0, - "vec_sim": 0.0, - "turns_ago": 0, - }) - - for node in kg_expanded: - depth = node.get("depth", 1) if isinstance(node, dict) else 1 - evidence_items.append({ - "text": f"Related: {node.get('id', '')} ({node.get('relation', '')})", - "kg_depth": depth, - "vec_sim": 0.0, - "turns_ago": session_turn, - }) - - # Query KG with top-K node retrieval and bounded context packing to control prompt size. - if not benchmark_candidates and not benchmark_context: - try: - from api.services.kg_service_v3 import get_kg_service - - learner_level = state.get("learner_profile", {}).get("level", "B1") - top_k = max(1, _env_int("TRACECAG_KG_TOPK", 8)) - token_budget = max(32, _env_int("TRACECAG_KG_CONTEXT_TOKEN_BUDGET", 160)) - - cache_key = _kg_cache_key(user_input, learner_level, top_k) - queried_nodes = _kg_cache_get(cache_key) - if queried_nodes is None: - kg = get_kg_service() - queried_nodes = kg.query_concepts(user_input, learner_level=learner_level, top_k=top_k) - _kg_cache_set(cache_key, queried_nodes) - - packed_nodes = _pack_kg_nodes_for_context(queried_nodes, token_budget) - for node in packed_nodes: - title = str(node.get("title") or node.get("id") or "") - keywords = str(node.get("keywords") or "") - score = float(node.get("score") or 0.0) - evidence_items.append({ - "item_id": str(node.get("id") or title), - "title": title, - "text": f"Concept: {title}. Keywords: {keywords}", - "kg_depth": 1, - "vec_sim": max(0.0, min(1.0, score)), - "turns_ago": session_turn, - }) - except Exception as kg_exc: - logger.warning(f"[retrieve_node] KG top-K query skipped: {kg_exc}") - - for error in state.get("diagnosis_errors", [])[:3]: - evidence_items.append({ - "text": f"Error: '{error.get('span', '')}' → '{error.get('correction', '')}' — {error.get('explanation', '')}", - "kg_depth": 0, - "vec_sim": 0.0, - "turns_ago": 0, - }) - - if benchmark_candidates: - evidence_items = [] - ranked_candidates = _rank_benchmark_candidates( - user_input, - benchmark_candidates, - benchmark_ranker, - benchmark_mode, - adaptive_profile, - ) - for candidate in ranked_candidates: - final_score = float(candidate.get("fusion_score", candidate.get("vec_sim", 0.0))) - evidence_items.append({ - "item_id": candidate["item_id"], - "title": candidate["title"], - "text": candidate["text"], - "kg_depth": candidate["kg_depth"], - "vec_sim": final_score, - "turns_ago": candidate["turns_ago"], - "graph_score": float(candidate.get("graph_score") or 0.0), - "memory_score": float(candidate.get("memory_score") or 0.0), - "precomputed_score": final_score, - "is_relevant": candidate["item_id"] in relevant_ids, - }) - elif benchmark_context: - evidence_items.insert(0, { - "item_id": "benchmark_context", - "title": "benchmark_context", - "text": benchmark_context, - "kg_depth": 0, - "vec_sim": 1.0, - "turns_ago": 0, - "is_relevant": True, - }) - - # ── Stage 2: RetrievalServiceV3 (centrality + community ranking) ───── - vector_hits = [] - if not benchmark_candidates and _elapsed_ms() <= (kg_budget_ms + vector_budget_ms): - errors = state.get("diagnosis_errors", []) - confidence = float(state.get("diagnosis_confidence", 0.0) or 0.0) - is_multihop_task = benchmark_task == "multihop_qa" - - do_vector_search = True - max_hits = 5 - - if retrieval_policy == "rapid": - if len(errors) == 0 and confidence >= 0.85 and not is_multihop_task: - do_vector_search = False - elif len(errors) <= 2 and confidence >= 0.72: - max_hits = 3 - - if do_vector_search and _elapsed_ms() <= (kg_budget_ms + vector_budget_ms): - # ── Primary: RetrievalServiceV3 (centrality + community diversity) ── - try: - from api.models.v3_schemas import V3PipelineContext - - retrieval_v3 = await _get_retrieval_v3() - ctx = V3PipelineContext( - user_input=user_input, - session_id=state.get("session_id", ""), - user_id=state.get("user_id"), - ) - seed_nodes = [ - c if isinstance(c, str) else c.get("id", "") - for c in kg_concepts[:5] - ] - bundle = await retrieval_v3.retrieve(user_input, seed_nodes, ctx) - - for hit in bundle.vector_hits[:max_hits]: - snippet = getattr(hit, "snippet", hit.id) - vector_hits.append({"text": snippet, "score": hit.score}) - evidence_items.append({ - "item_id": hit.id, - "title": snippet, - "text": f"Concept ({hit.id}): {snippet}", - "kg_depth": 2, - "vec_sim": hit.score, - "turns_ago": session_turn, - }) - - logger.info( - f"[retrieve_node] RetrievalServiceV3: {len(vector_hits)} hits " - f"(centrality+community ranked)" - ) - - except Exception as e: - logger.warning( - f"[retrieve_node] RetrievalServiceV3 unavailable, " - f"falling back to MiniLM gateway: {e}" - ) - # ── Fallback: MiniLM gateway ────────────────────────────────── - try: - from api.services.model_gateway import get_gateway - - gateway = await get_gateway() - max_expanded = 10 - threshold = 0.3 - - if retrieval_policy == "rapid" and len(errors) <= 2 and confidence >= 0.7: - max_expanded = 5 - threshold = 0.35 - - candidate_labels = [] - for c in kg_concepts: - if isinstance(c, dict): - candidate_labels.append(c.get("id", "") + " " + c.get("label", "")) - else: - candidate_labels.append(str(c)) - for node in kg_expanded[:max_expanded]: - label = node.get("id", "") + " " + node.get("label", node.get("relation", "")) - if label.strip() and label not in candidate_labels: - candidate_labels.append(label) - - if candidate_labels: - result = await gateway.invoke( - "minilm", "invoke", - {"task": "similarity", "query": user_input, "candidates": candidate_labels}, - ) - if result.get("success"): - sim_results = result.get("data", {}).get("results", []) - for r in sim_results: - if r["score"] >= threshold: - vector_hits.append({"text": r["text"], "score": r["score"]}) - evidence_items.append({ - "text": f"Semantic match: {r['text']}", - "kg_depth": 2, - "vec_sim": r["score"], - "turns_ago": session_turn, - }) - - vector_hits = vector_hits[:max_hits] - logger.info(f"[retrieve_node] MiniLM fallback: {len(vector_hits)} hits") - except Exception as e2: - logger.warning(f"[retrieve_node] Vector search fully skipped: {e2}") - elif not benchmark_candidates: - budget_exhausted = True - - # ── Stage 3: L2 External Knowledge (Selective Retrieval) ───────── - # Phân tích xem có nên ép buộc tìm kiếm bên ngoài không (Proactive Dynamic Retrieval) - force_external = False - dynamic_patterns = [ - r"\bhôm (qua|nay|kia)\b", r"\bmới (đây|nhất)\b", r"\bvừa mới\b", - r"\brecently\b", r"\byesterday\b", r"\btoday\b", r"\blatest\b", r"\bcurrent\b", - r"\bdo you know\b", r"\bnghe nói\b", r"\bbạn có biết\b", r"\bnews\b", r"\btin tức\b" - ] - if any(re.search(p, user_input, re.IGNORECASE) for p in dynamic_patterns): - force_external = True - logger.info("[retrieve_node] Dynamic intent detected. Forcing L2 Search.") - - # Kích hoạt L2 nếu (thiếu dữ liệu) HOẶC (phát hiện intent cần tin tức thực tế) - if (len(evidence_items) < 3 or force_external) and not benchmark_candidates: - try: - doc_service = get_doc_intel_service() - # Nếu force_external, ta có thể điều chỉnh query để search hiệu quả hơn - search_query = user_input - if force_external and len(user_input) < 100: - # Bổ sung ngữ cảnh để search Tavily tốt hơn - search_query = f"latest information about {user_input}" - - external_hits = await asyncio.wait_for( - doc_service.query_l2(search_query), timeout=5.0 - ) - for hit in external_hits: - # Tránh trùng lặp nếu đã có trong evidence_items - if any(e.get("chunk_id") == hit["id"] for e in evidence_items): - continue - - evidence_items.append({ - "item_id": f"ext_{hit['id']}", - "title": "External Knowledge", - "text": f"Context: {hit['content']}", - "kg_depth": 3, # Tầng sâu hơn KG - "vec_sim": hit["score"], - "turns_ago": session_turn, - "is_external": True, - "chunk_id": hit["id"] - }) - if external_hits: - logger.info(f"[retrieve_node] L2 Context injected: {len(external_hits)} chunks") - except Exception as e3: - logger.warning(f"[retrieve_node] L2 retrieval failed: {e3}") - - # ── Fusion scoring and ranking ─────────────────────────────────── - if _elapsed_ms() <= total_budget_ms: - for item in evidence_items: - if benchmark_candidates and "precomputed_score" in item: - item["fusion_score"] = float(item.get("precomputed_score") or 0.0) - else: - item["fusion_score"] = _fusion_score( - kg_depth=item["kg_depth"], - vec_sim=item["vec_sim"], - last_used_turns_ago=item["turns_ago"], - ) - else: - budget_exhausted = True - for item in evidence_items: - if "fusion_score" not in item: - item["fusion_score"] = float(item.get("vec_sim") or 0.0) - - evidence_items = _rank_with_online_ranker( - question=user_input, - evidence_items=evidence_items, - allow_exploration=_adaptive_mode_enabled(state, benchmark_mode), - benchmark_mode=benchmark_mode, - ) - evidence_budget = _compute_evidence_budget( - question=user_input, - retrieval_policy=retrieval_policy, - benchmark_mode=benchmark_mode, - benchmark_candidates=bool(benchmark_candidates), - adaptive_profile=adaptive_profile, - ) - if benchmark_candidates and benchmark_task in {"multihop_qa", "retrieval_qa"}: - top_evidence = _select_diverse_multihop_evidence( - items=evidence_items, - question=user_input, - budget=evidence_budget, - ) - else: - top_evidence = evidence_items[:evidence_budget] - retrieval_trace = [ - { - "item_id": str(item.get("item_id") or item.get("title") or f"item_{idx}"), - "title": str(item.get("title") or item.get("item_id") or ""), - "text": str(item.get("text") or ""), - "rank": idx + 1, - "score": float(item.get("fusion_score") or 0.0), - "is_relevant": bool(item.get("is_relevant") or False), - } - for idx, item in enumerate(top_evidence) - ] - - if benchmark_candidates: - context_parts = [] - for item in top_evidence: - title = str(item.get("title") or "").strip() - text = str(item.get("text") or "").strip() - context_parts.append(f"[{title}] {text}" if title else text) - retrieved_context = "\n".join(part for part in context_parts if part).strip() - elif benchmark_context and benchmark_task in {"multihop_qa", "retrieval_qa"}: - retrieved_context = benchmark_context - else: - context_parts = [item["text"] for item in top_evidence] - retrieved_context = "\n".join(context_parts) if context_parts else "" - - jit_soft_graph = str(state.get("jit_soft_graph") or "").strip() - jit_graph_meta = dict(state.get("jit_graph_meta") or {}) - if jit_soft_graph: - retrieved_context = ( - f"[JIT_SOFT_GRAPH]\n{jit_soft_graph}\n\n" - f"{retrieved_context}".strip() - ) - - latency_ms = int((time.time() - start_time) * 1000) - logger.info( - f"[retrieve_node] {len(evidence_items)} candidates → top {len(top_evidence)} via fusion scoring" - f" (mode={benchmark_mode or 'default'}, ranker={benchmark_ranker}, latency={latency_ms}ms)" - ) - - ranker_snapshot = get_retrieval_ranker().snapshot() if _ranker_enabled() else {} - graph_update = dict(state.get("graph_update") or {}) - - return { - "vector_hits": vector_hits, - "retrieved_context": retrieved_context, - "jit_soft_graph": jit_soft_graph or None, - "jit_graph_meta": jit_graph_meta, - "retrieval_trace": retrieval_trace, - "adaptive_profile": adaptive_profile or None, - "adaptive_features": adaptive_features, - "adaptive_controller": adaptive_controller, - "retrieval_meta": { - "budget": { - "kg_ms": kg_budget_ms, - "vector_ms": vector_budget_ms, - "fusion_ms": fusion_budget_ms, - "total_ms": total_budget_ms, - "elapsed_ms": _elapsed_ms(), - "exhausted": budget_exhausted, - }, - "fusion": { - "alpha": _FUSION_ALPHA, - "beta": _FUSION_BETA, - "gamma": _FUSION_GAMMA, - "recency_lambda": _RECENCY_LAMBDA, - }, - "kg_topk": { - "top_k": max(1, _env_int("TRACECAG_KG_TOPK", 8)), - "context_token_budget": max(32, _env_int("TRACECAG_KG_CONTEXT_TOKEN_BUDGET", 160)), - "query_cache_size": len(_KG_QUERY_CACHE), - }, - "jit_graph": jit_graph_meta, - "graph_update": { - "latency_ms": int(graph_update.get("latency_ms") or 0), - "nodes_added": int(graph_update.get("nodes_added") or 0), - "edges_added": int(graph_update.get("edges_added") or 0), - }, - "mode": benchmark_mode or "default", - "ranker": benchmark_ranker, - "learned_ranker": { - "enabled": _ranker_enabled(), - "blend": _clip01(_env_float("TRACECAG_RANKER_BLEND", 0.42)), - "snapshot": ranker_snapshot, - }, - "adaptive": { - "profile": adaptive_profile or None, - "features": adaptive_features, - "controller": adaptive_controller, - }, - }, - "models_used": ["retrieval_fusion"] + (["minilm"] if vector_hits else []), - } - - -# ============================================================ -# STREAMING HELPERS: Build prompt + stream tokens from LLM -# ============================================================ - -def build_generation_prompt( - state: Dict[str, Any], -) -> tuple[str, List[Dict[str, Any]]]: - """Extract (system_prompt, messages) from raw pipeline state. - - Used by the streaming endpoint so it can call the LLM with streaming - enabled after the rest of the pipeline (KG, diagnosis, retrieval) has - prepared the context. - """ - errors = state.get("diagnosis_errors", []) - intent = state.get("diagnosis_intent", "correct") - level = (state.get("learner_profile") or {}).get("level", "B1") - user_input = str(state.get("user_input") or "") - context = str(state.get("retrieved_context") or "") - vietnamese_hint = state.get("vietnamese_hint") - session_turn = len(state.get("conversation_history") or []) - prev_overall = state.get("overall_score", 0.5) - fluency_score = state.get("fluency_score", 0.8) - error_count = len(errors) - - difficulty = _compute_difficulty_ramp(session_turn, prev_overall) - - if intent == "explain" and fluency_score > 0.7: - strategy = "socratic" - elif error_count == 0: - strategy = "praise" - elif error_count <= 2: - strategy = "feedback" - else: - strategy = "scaffold" - - system_prompt = ( - "You are Lexi 🦜, a cheerful, witty parrot who is an expert English tutor.\n" - "You speak in a warm, encouraging tone — like a fun game character guiding an adventure.\n" - "Keep responses concise (2-4 sentences). Use the knowledge context provided.\n" - "Gently correct mistakes with encouraging context.\n" - f"The learner's current CEFR level is: {level}\n" - f"Difficulty setting for this turn: {difficulty}\n" - ) - if context: - system_prompt += f"\n--- Knowledge Graph Context ---\n{context}\n" - - if strategy == "socratic": - system_prompt += ( - "\nStrategy: SOCRATIC. Guide through short questions, don't reveal answer.\n" - ) - if errors: - hints = "\n".join( - f"- '{e.get('span','')}' → '{e.get('correction','')}'" - for e in errors[:3] - ) - system_prompt += f"\n--- Errors (hints only) ---\n{hints}\n" - elif errors: - errs_text = "\n".join( - f"- '{e.get('span','')}' → '{e.get('correction','')}' ({e.get('explanation','')})" - for e in errors[:3] - ) - system_prompt += f"\n--- Errors Found ---\n{errs_text}\n" - system_prompt += f"Strategy: {strategy}. Weave corrections naturally.\n" - else: - system_prompt += "\nNo errors found — praise the learner's effort!\n" - - if vietnamese_hint: - system_prompt += f"\n--- Vietnamese Hint ---\n{vietnamese_hint}\n" - - messages: List[Dict[str, Any]] = [{"role": "system", "content": system_prompt}] - for msg in (state.get("conversation_history") or [])[-12:]: - role = msg.get("role") - content = msg.get("content", "") - if role and content: - messages.append({"role": role, "content": content}) - elif msg.get("user"): - messages.append({"role": "user", "content": msg["user"]}) - if msg.get("ai"): - messages.append({"role": "assistant", "content": msg["ai"]}) - messages.append({"role": "user", "content": user_input}) - - return system_prompt, messages - - -async def stream_llm_tokens( - *, - system_prompt: str, - messages: List[Dict[str, Any]], - user_input: str, -) -> "AsyncGenerator[str, None]": - """Stream tokens from Groq (preferred) then Gemini as fallback. - - Yields raw text deltas as they arrive from the provider. - Falls back silently to Gemini if Groq is unavailable or rate-limited. - """ - - async def _try_groq() -> "AsyncGenerator[str, None]": - from api.core.groq_key_pool import get_available_groq_key, record_groq_key_usage - - groq_key = await get_available_groq_key(estimated_tokens=512) - groq_model = os.getenv("GROQ_MODEL", "qwen/qwen3-32b") - if not groq_key or _provider_is_disabled("groq"): - return - - # Disable Qwen3 thinking mode to prevent thinking tokens consuming max_tokens budget. - groq_messages = messages - if "qwen" in groq_model.lower(): - groq_messages = list(messages) - for i, msg in enumerate(groq_messages): - if msg.get("role") == "user": - groq_messages[i] = {**msg, "content": f"/no_think\n{msg['content']}"} - break - - client = _get_httpx_client("groq") - tokens_yielded = 0 - try: - async with client.stream( - "POST", - "https://api.groq.com/openai/v1/chat/completions", - headers={ - "Authorization": f"Bearer {groq_key}", - "Content-Type": "application/json", - }, - json={ - "model": groq_model, - "messages": groq_messages, - "max_tokens": 512, - "temperature": 0.7, - "stream": True, - }, - timeout=25.0, - ) as resp: - if resp.status_code != 200: - logger.warning("[stream_llm_tokens] Groq status %d", resp.status_code) - return - async for line in resp.aiter_lines(): - if not line.startswith("data: "): - continue - data = line[6:] - if data.strip() == "[DONE]": - break - try: - obj = json.loads(data) - delta = obj["choices"][0]["delta"].get("content") or "" - if delta: - tokens_yielded += len(delta.split()) - yield delta - except Exception as _exc: - logger.debug("[nodes_v2] ignored: %s", _exc) - pass - await record_groq_key_usage(groq_key, max(50, tokens_yielded + 50)) - except Exception as exc: - logger.warning("[stream_llm_tokens] Groq stream error: %s", exc) - - async def _try_gemini() -> "AsyncGenerator[str, None]": - gemini_key = os.getenv("GEMINI_API_KEY", "") - if not gemini_key or _provider_is_disabled("gemini"): - return - - url = ( - "https://generativelanguage.googleapis.com/v1beta/models/" - f"gemini-2.0-flash:streamGenerateContent?key={gemini_key}&alt=sse" - ) - request_body = { - "contents": [{"role": "user", "parts": [{"text": user_input}]}], - "systemInstruction": {"parts": [{"text": system_prompt}]}, - } - client = _get_httpx_client("gemini") - try: - async with client.stream( - "POST", url, json=request_body, timeout=25.0 - ) as resp: - if resp.status_code != 200: - logger.warning("[stream_llm_tokens] Gemini status %d", resp.status_code) - return - async for line in resp.aiter_lines(): - if not line.startswith("data: "): - continue - data = line[6:] - try: - obj = json.loads(data) - text = ( - obj.get("candidates", [{}])[0] - .get("content", {}) - .get("parts", [{}])[0] - .get("text", "") - ) - if text: - yield text - except Exception as _exc: - logger.debug("[nodes_v2] ignored: %s", _exc) - pass - except Exception as exc: - logger.warning("[stream_llm_tokens] Gemini stream error: %s", exc) - - # Try Groq first; if it yields nothing, fall back to Gemini - got_tokens = False - async for token in _try_groq(): - got_tokens = True - yield token - - if not got_tokens: - async for token in _try_gemini(): - yield token - - -# ============================================================ -# NODE 5: GROUNDED GENERATION (LLM call with context) -# ============================================================ - -async def generate_node(state: TraceCAGState) -> Dict[str, Any]: - """ - Generate the tutor response using LLM grounded in KG evidence. - - This node calls the LLM fallback chain (Groq → Gemini → Ollama) - with the Lexi persona, KG context, and diagnosis data injected - into the system prompt. This is the SINGLE place where LLM - generation happens — callers should NOT make a separate LLM call. - """ - # When the streaming endpoint handles generation externally, skip this node. - if state.get("generation_policy") == "skip": - return {} - - logger.info("[generate_node] Generating grounded tutor response...") - start_time = time.time() - - errors = state.get("diagnosis_errors", []) - intent = state.get("diagnosis_intent", "correct") - level = state.get("learner_profile", {}).get("level", "B1") - user_input = state.get("user_input", "") - context = state.get("retrieved_context", "") - jit_soft_graph = str(state.get("jit_soft_graph") or "").strip() - vietnamese_hint = state.get("vietnamese_hint") - benchmark_task = state.get("benchmark_task") - - if benchmark_task in {"multihop_qa", "retrieval_qa"}: - return await _generate_benchmark_qa_response(state, start_time) - - # Determine strategy (paper Eq. strategy) - error_count = len(errors) - fluency_score = state.get("fluency_score", 0.8) - - if intent == "explain" and fluency_score > 0.7: - strategy = "socratic" - elif error_count == 0: - strategy = "praise" - elif error_count <= 2: - strategy = "feedback" - else: - strategy = "scaffold" - - generation_policy = state.get("generation_policy", "auto") - if generation_policy == "template": - logger.warning("[generate_node] generation_policy='template' is deprecated; using extractive policy") - generation_policy = "extractive" - - if generation_policy == "extractive": - response = _generate_extractive_fallback_response(errors, strategy, user_input, context) - model_used = "extractive_policy" - - if strategy == "socratic": - next_action = "ask" - elif error_count == 0: - next_action = "continue" - elif error_count <= 2: - next_action = "hint" - else: - next_action = "correct" - - grammar_score = state.get("grammar_score", 0.8) - fluency_score = state.get("fluency_score", 0.8) - vocab_level = state.get("vocabulary_level", "B1") - overall_score = EvaluationAgent.compute_overall_score(grammar_score, fluency_score, vocab_level) - - _update_ranker_from_generation( - question=user_input, - response=response, - retrieval_trace=list(state.get("retrieval_trace") or []), - ) - - if state.get("cache_policy", "on") == "on": - try: - await _write_cache_entry(state, response, strategy, errors, overall_score, context, model_used=model_used) - except Exception as e: - logger.debug(f"[generate_node] Cache write failed: {e}") - - latency_ms = int((time.time() - start_time) * 1000) - - return { - "tutor_response": response, - "strategy": strategy, - "next_action": next_action, - "overall_score": overall_score, - "ttft_ms": latency_ms, - "models_used": [model_used], - } - - # Build system prompt with Lexi persona + grounded context - session_turn = len(state.get("conversation_history", [])) - prev_overall = state.get("overall_score", 0.5) - difficulty = _compute_difficulty_ramp(session_turn, prev_overall) - - system_prompt = ( - "You are Lexi 🦜, a cheerful, witty parrot who is an expert English tutor.\n" - "You speak in a warm, encouraging tone — like a fun game character guiding an adventure.\n" - "Keep responses concise (2-4 sentences). Use the knowledge context provided.\n" - "Gently correct mistakes with encouraging context.\n" - f"The learner's current CEFR level is: {level}\n" - f"Difficulty setting for this turn: {difficulty}\n" - ) - - if context: - system_prompt += f"\n--- Knowledge Graph Context ---\n{context}\n" - - if strategy == "socratic": - system_prompt += ( - "\nStrategy: SOCRATIC. The learner asked for an explanation and is fairly fluent.\n" - "Guide them through a chain of short questions so they discover the answer themselves.\n" - "Do NOT give the answer directly — instead ask 1-2 leading questions.\n" - ) - if errors: - errors_text = "\n".join([ - f"- '{e.get('span','')}' → '{e.get('correction','')}' ({e.get('explanation','')})" - for e in errors[:3] - ]) - system_prompt += f"\n--- Errors Found (use as hints, don't reveal directly) ---\n{errors_text}\n" - elif errors: - errors_text = "\n".join([ - f"- '{e.get('span','')}' → '{e.get('correction','')}' ({e.get('explanation','')})" - for e in errors[:3] - ]) - system_prompt += f"\n--- Errors Found ---\n{errors_text}\n" - system_prompt += f"Strategy: {strategy}. Weave corrections naturally into your response.\n" - else: - system_prompt += "\nNo errors found — praise the learner's effort!\n" - - if vietnamese_hint: - system_prompt += f"\n--- Vietnamese Hint (for reference) ---\n{vietnamese_hint}\n" - - response = "" - model_used = "llm_unavailable" - - local_llama_enabled = _env_flag("TRACECAG_ENABLE_LOCAL_LLAMA_KV", False) - if local_llama_enabled and not response: - try: - local_llama = get_local_llama_kv_service() - local_result = await local_llama.generate( - session_id=str(state.get("session_id") or "default"), - core_system_prompt=_LOCAL_LLAMA_CORE_SYSTEM_PROMPT, - dynamic_system_prompt=system_prompt, - user_query=user_input, - soft_graph="" if "[JIT_SOFT_GRAPH]" in context else jit_soft_graph, - ) - if local_result and str(local_result.get("text") or "").strip(): - response = str(local_result.get("text") or "").strip() - model_used = str(local_result.get("model") or "llama_cpp_kv") - except Exception as e: - logger.warning(f"[generate_node] Local llama KV path failed, fallback to provider chain: {e}") - - # Call LLM via fallback chain (Groq → Gemini → Ollama) - if not response: - response = "" - model_used = "llm_unavailable" - - try: - import httpx - - messages = [{"role": "system", "content": system_prompt}] - - # Inject conversation history (last 6 turns = up to 12 messages) - history = state.get("conversation_history", []) - for msg in history[-12:]: - role = msg.get("role") - content = msg.get("content", "") - if role and content: - # Standard {"role": "user"/"assistant", "content": "..."} format - messages.append({"role": role, "content": content}) - elif msg.get("user"): - # ConversationCache {"user": "...", "ai": "..."} format - messages.append({"role": "user", "content": msg["user"]}) - if msg.get("ai"): - messages.append({"role": "assistant", "content": msg["ai"]}) - - messages.append({"role": "user", "content": user_input}) - - if not response: - # Race Groq and Gemini concurrently; first successful response wins. - # Ollama is kept as last-resort with a tighter 15s timeout. - import asyncio as _asyncio - from api.core.groq_key_pool import get_available_groq_key, record_groq_key_usage - - groq_key = await get_available_groq_key(estimated_tokens=512) - groq_model = os.getenv("GROQ_MODEL", "qwen/qwen3-32b") - gemini_key = os.getenv("GEMINI_API_KEY", "") - - # Disable Qwen3 thinking to keep token budget for actual response. - _groq_messages = list(messages) - if "qwen" in groq_model.lower(): - for i, msg in enumerate(_groq_messages): - if msg.get("role") == "user": - _groq_messages[i] = {**msg, "content": f"/no_think\n{msg['content']}"} - break - - async def _try_groq(): - if not groq_key: - return None, None - try: - resp = await _throttled_post_json( - provider="groq", - url="https://api.groq.com/openai/v1/chat/completions", - headers={"Authorization": f"Bearer {groq_key}", "Content-Type": "application/json"}, - payload={"model": groq_model, "messages": _groq_messages, "max_tokens": 512, "temperature": 0.7}, - httpx_module=httpx, - timeout=20.0, - ) - if resp is not None and resp.status_code == 200: - data = resp.json() - tokens = data.get("usage", {}).get("total_tokens", 500) - await record_groq_key_usage(groq_key, tokens) - return data["choices"][0]["message"]["content"], f"groq/{groq_model}" - except Exception as e: - logger.warning(f"[generate_node] Groq failed: {e}") - return None, None - - async def _try_gemini(): - if not gemini_key: - return None, None - try: - gemini_contents = [{"role": "user", "parts": [{"text": user_input}]}] - url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={gemini_key}" - request_body = { - "contents": gemini_contents, - "systemInstruction": {"parts": [{"text": system_prompt}]}, - } - resp = await _throttled_post_json( - provider="gemini", - url=url, - payload=request_body, - httpx_module=httpx, - timeout=20.0, - ) - if resp is not None and resp.status_code == 200: - candidates = resp.json().get("candidates", []) - if candidates: - return candidates[0]["content"]["parts"][0]["text"], "gemini-2.0-flash" - except Exception as e: - logger.warning(f"[generate_node] Gemini failed: {e}") - return None, None - - # Launch both concurrently; pick first non-None result. - tasks = [_asyncio.ensure_future(_try_groq()), _asyncio.ensure_future(_try_gemini())] - done, pending = await _asyncio.wait(tasks, return_when=_asyncio.FIRST_COMPLETED) - for task in done: - try: - _text, _model = task.result() - if _text: - response = _text - model_used = _model - break - except Exception as _exc: - logger.debug("[nodes_v2] ignored: %s", _exc) - pass - # If winner found, cancel the loser immediately. - if response: - for p in pending: - p.cancel() - else: - # Wait for the second one too before falling back to Ollama. - if pending: - done2, _ = await _asyncio.wait(pending, timeout=15.0) - for task in done2: - try: - _text, _model = task.result() - if _text: - response = _text - model_used = _model - break - except Exception as _exc: - logger.debug("[nodes_v2] ignored: %s", _exc) - pass - - # Ollama — last resort, tight 15s timeout. - if not response: - from api.core.config import settings - ollama_url = settings.OLLAMA_BASE_URL - ollama_model = os.getenv("OLLAMA_MODEL", "lexilingo-qwen3-1.7b") - try: - resp = await _throttled_post_json( - provider="ollama", - url=f"{ollama_url}/api/chat", - payload={ - "model": ollama_model, - "messages": messages, - "stream": False, - "options": {"num_predict": 256, "temperature": 0.7}, - }, - httpx_module=httpx, - timeout=15.0, - max_retries=1, - ) - if resp is not None and resp.status_code == 200: - response = resp.json().get("message", {}).get("content", "") - model_used = f"ollama/{ollama_model}" - except Exception as e: - logger.warning(f"[generate_node] Ollama failed: {e}") - - except Exception as e: - logger.error(f"[generate_node] LLM chain error: {e}") - - # 4. Deterministic extractive fallback - if not response: - response = _generate_extractive_fallback_response(errors, strategy, user_input, context) - model_used = "extractive_fallback" - - # Determine next action - if strategy == "socratic": - next_action = "ask" - elif error_count == 0: - next_action = "continue" - elif error_count <= 2: - next_action = "hint" - else: - next_action = "correct" - - # Calculate overall score - grammar_score = state.get("grammar_score", 0.8) - fluency_score = state.get("fluency_score", 0.8) - vocab_level = state.get("vocabulary_level", "B1") - overall_score = EvaluationAgent.compute_overall_score(grammar_score, fluency_score, vocab_level) - - _update_ranker_from_generation( - question=user_input, - response=response, - retrieval_trace=list(state.get("retrieval_trace") or []), - ) - - # Generate personalized practice exercises via ContentAutoGenerator (cag_service) - action_plan = [] - if errors or intent == "practice": - try: - from api.services.cag_service import ContentAutoGenerator - cag_gen = ContentAutoGenerator() - err_types = [err.get("type", "grammar") for err in errors if isinstance(err, dict)] - if not err_types: - err_types = ["grammar"] - - first_err_type = err_types[0] if errors else "vocabulary" - if "vocab" in first_err_type.lower() or intent == "practice": - vocab_ex = cag_gen.generate_vocabulary_exercise( - level=level, - error_patterns=err_types if errors else None, - count=3 - ) - action_plan.append({ - "action": "practice", - "type": "vocabulary", - "concept": vocab_ex.get("topic", ""), - "count": len(vocab_ex.get("words", [])), - "exercise": vocab_ex - }) - else: - grammar_drill = cag_gen.generate_grammar_drill( - level=level, - error_patterns=err_types, - count=3 - ) - action_plan.append({ - "action": "practice", - "type": "grammar", - "concept": grammar_drill.get("grammar_point", ""), - "count": len(grammar_drill.get("exercises", [])), - "exercise": grammar_drill - }) - except Exception as cag_err: - logger.warning(f"[generate_node] Failed to generate CAG practice: {cag_err}") - - state["action_plan"] = action_plan - - # Store response in Redis cache for future hits - if state.get("cache_policy", "on") == "on": - try: - # ── Tiered Cache Management (L0/L1 Promotion) ───────────── - # Nếu thông tin từ L2 được sử dụng, kiểm tra thăng hạng - doc_service = get_doc_intel_service() - trace = state.get("retrieval_trace", []) - is_l2_used = any(t.get("item_id", "").startswith("ext_") for t in trace[:3]) - - if is_l2_used: - for t in trace[:3]: - if t.get("item_id", "").startswith("ext_"): - chunk_id = str(t.get("item_id") or "").replace("ext_", "") - if doc_service.should_promote_to_cache(chunk_id): - logger.info(f"[cache_promotion] Chunk {chunk_id[:8]} promoted to L1 cache") - await _write_cache_entry(state, response, strategy, errors, overall_score, context) - break - else: - # Mặc định cache cho các luồng KG/Rules để tối ưu tốc độ - await _write_cache_entry(state, response, strategy, errors, overall_score, context) - except Exception as e: - logger.debug(f"[generate_node] Cache write failed: {e}") - - latency_ms = int((time.time() - start_time) * 1000) - logger.info(f"[generate_node] Generated response via {model_used} in {latency_ms}ms") - - return { - "tutor_response": response, - "strategy": strategy, - "next_action": next_action, - "overall_score": overall_score, - "action_plan": action_plan, - "ttft_ms": latency_ms, - "models_used": [model_used], - } - - - - # ============================================================ # NODE 6: NATIVE LANGUAGE HINT (AI-POWERED, Lazy Load) # ============================================================ diff --git a/ai-service/api/services/trace_cag/retrieve.py b/ai-service/api/services/trace_cag/retrieve.py new file mode 100644 index 00000000..7330a73e --- /dev/null +++ b/ai-service/api/services/trace_cag/retrieve.py @@ -0,0 +1,501 @@ +""" +TraceCAG retrieve_node — budgeted hybrid retrieval with fusion scoring (paper Alg. 5). + +Split out of nodes_v2.py (Phase 4 refactor) since this node alone carries the +KG/vector/L2-external retrieval stages and fusion-score ranking logic. +""" + +import asyncio +import logging +import math +import re +import time +from typing import Any, Dict, List + +from api.services.trace_cag.state import TraceCAGState +from api.services.document_intelligence import get_doc_intel_service +from api.services.trace_cag.env_helpers import _env_float, _env_int, _clip01 +from api.services.trace_cag.retrieval_ranker import get_retrieval_ranker + +from api.services.trace_cag.kg_utils import ( + _KG_QUERY_CACHE, _kg_cache_key, _kg_cache_get, _kg_cache_set, + _pack_kg_nodes_for_context, +) + +from api.services.trace_cag.benchmark.adaptive import ( + _adaptive_mode_enabled, _choose_adaptive_profile, +) +from api.services.trace_cag.benchmark.ranking import ( + _select_diverse_multihop_evidence, _compute_evidence_budget, + _rank_benchmark_candidates, _ranker_enabled, _rank_with_online_ranker, + _build_benchmark_candidates, +) + +logger = logging.getLogger(__name__) + +_FUSION_ALPHA = 0.5 # KG structural relevance weight +_FUSION_BETA = 0.3 # Vector similarity weight +_FUSION_GAMMA = 0.2 # Recency bonus weight +_RECENCY_LAMBDA = 0.01 # Decay rate for recency bonus + +_retrieval_v3_instance = None + + +async def _get_retrieval_v3(): + """Lazy singleton for RetrievalServiceV3 (centrality + community ranking).""" + global _retrieval_v3_instance + if _retrieval_v3_instance is None: + from api.services.kg_service_v3 import get_kg_service + from api.services.retrieval_service_v3 import RetrievalServiceV3 + _retrieval_v3_instance = RetrievalServiceV3(get_kg_service()) + return _retrieval_v3_instance + + +def _fusion_score( + kg_depth: int, + vec_sim: float, + last_used_turns_ago: int, +) -> float: + """ + Compute fusion score for a retrieved evidence item (paper Eq. 8). + + s_kg = 1 / (1 + depth) — inverse hop distance + s_vec = cosine similarity — from MiniLM + s_rec = exp(-λ · Δt) — recency bonus + """ + s_kg = 1.0 / (1.0 + kg_depth) + s_vec = vec_sim + s_rec = math.exp(-_RECENCY_LAMBDA * last_used_turns_ago) + return _FUSION_ALPHA * s_kg + _FUSION_BETA * s_vec + _FUSION_GAMMA * s_rec + + +async def retrieve_node(state: TraceCAGState) -> Dict[str, Any]: + """ + Budgeted hybrid retrieval with fusion scoring (paper Alg. 5). + + Stage 1: Graph-local evidence (cheap) — KG concepts + diagnosis + Stage 2: Optional vector evidence (budgeted) — MiniLM similarity + Fusion: score(e) = α·s_kg + β·s_vec + γ·s_rec (Eq. 8) + """ + logger.info("[retrieve_node] Budgeted hybrid retrieval...") + start_time = time.time() + retrieve_start = time.monotonic() + + kg_budget_ms = max(0.0, _env_float("TRACECAG_RETRIEVE_BUDGET_KG_MS", 120.0)) + vector_budget_ms = max(0.0, _env_float("TRACECAG_RETRIEVE_BUDGET_VECTOR_MS", 80.0)) + fusion_budget_ms = max(0.0, _env_float("TRACECAG_RETRIEVE_BUDGET_FUSION_MS", 40.0)) + total_budget_ms = kg_budget_ms + vector_budget_ms + fusion_budget_ms + + def _elapsed_ms() -> float: + return (time.monotonic() - retrieve_start) * 1000.0 + + budget_exhausted = False + + retrieval_policy = state.get("retrieval_policy", "full") + benchmark_context = (state.get("benchmark_context") or "").strip() + benchmark_task = state.get("benchmark_task") or "" + benchmark_metadata = state.get("benchmark_metadata") or {} + benchmark_ranker = str(benchmark_metadata.get("_benchmark_ranker") or "graph").strip().lower() + benchmark_mode = str(benchmark_metadata.get("_benchmark_mode") or "").strip().lower() + user_input = state.get("user_input", "") + adaptive_profile = str(state.get("adaptive_profile") or "").strip().lower() + adaptive_features = dict(state.get("adaptive_features") or {}) + adaptive_controller = dict(state.get("adaptive_controller") or {}) + + if _adaptive_mode_enabled(state, benchmark_mode) and not adaptive_profile: + adaptive_choice = _choose_adaptive_profile( + state=state, + user_input=user_input, + benchmark_task=benchmark_task, + benchmark_mode=benchmark_mode, + benchmark_metadata=benchmark_metadata, + ) + adaptive_profile = str(adaptive_choice.get("profile") or "balanced") + adaptive_features = dict(adaptive_choice.get("features") or {}) + adaptive_controller = { + **dict(adaptive_choice.get("controller") or {}), + "explore": bool(adaptive_choice.get("explore", False)), + "objective_map": adaptive_choice.get("objective_map", {}), + "tau_reuse": adaptive_choice.get("tau_reuse"), + "tau_patch": adaptive_choice.get("tau_patch"), + "support_floor": adaptive_choice.get("support_floor"), + "evidence_budget_delta": adaptive_choice.get("evidence_budget_delta"), + } + + kg_concepts = state.get("kg_seed_concepts", []) + kg_expanded = state.get("kg_expanded_nodes", []) + session_turn = len(state.get("conversation_history", [])) + benchmark_candidates, relevant_ids = _build_benchmark_candidates(state) + + # ── Stage 1: Graph-local evidence (cheap) ──────────────────────── + # Each evidence item: {"text": ..., "kg_depth": ..., "vec_sim": ..., "turns_ago": ...} + evidence_items: List[Dict[str, Any]] = [] + + for concept_id in state.get("diagnosis_root_causes", []): + evidence_items.append({ + "text": f"Grammar concept: {concept_id}", + "kg_depth": 0, + "vec_sim": 0.0, + "turns_ago": 0, + }) + + for node in kg_expanded: + depth = node.get("depth", 1) if isinstance(node, dict) else 1 + evidence_items.append({ + "text": f"Related: {node.get('id', '')} ({node.get('relation', '')})", + "kg_depth": depth, + "vec_sim": 0.0, + "turns_ago": session_turn, + }) + + # Query KG with top-K node retrieval and bounded context packing to control prompt size. + if not benchmark_candidates and not benchmark_context: + try: + from api.services.kg_service_v3 import get_kg_service + + learner_level = state.get("learner_profile", {}).get("level", "B1") + top_k = max(1, _env_int("TRACECAG_KG_TOPK", 8)) + token_budget = max(32, _env_int("TRACECAG_KG_CONTEXT_TOKEN_BUDGET", 160)) + + cache_key = _kg_cache_key(user_input, learner_level, top_k) + queried_nodes = _kg_cache_get(cache_key) + if queried_nodes is None: + kg = get_kg_service() + queried_nodes = kg.query_concepts(user_input, learner_level=learner_level, top_k=top_k) + _kg_cache_set(cache_key, queried_nodes) + + packed_nodes = _pack_kg_nodes_for_context(queried_nodes, token_budget) + for node in packed_nodes: + title = str(node.get("title") or node.get("id") or "") + keywords = str(node.get("keywords") or "") + score = float(node.get("score") or 0.0) + evidence_items.append({ + "item_id": str(node.get("id") or title), + "title": title, + "text": f"Concept: {title}. Keywords: {keywords}", + "kg_depth": 1, + "vec_sim": max(0.0, min(1.0, score)), + "turns_ago": session_turn, + }) + except Exception as kg_exc: + logger.warning(f"[retrieve_node] KG top-K query skipped: {kg_exc}") + + for error in state.get("diagnosis_errors", [])[:3]: + evidence_items.append({ + "text": f"Error: '{error.get('span', '')}' → '{error.get('correction', '')}' — {error.get('explanation', '')}", + "kg_depth": 0, + "vec_sim": 0.0, + "turns_ago": 0, + }) + + if benchmark_candidates: + evidence_items = [] + ranked_candidates = _rank_benchmark_candidates( + user_input, + benchmark_candidates, + benchmark_ranker, + benchmark_mode, + adaptive_profile, + ) + for candidate in ranked_candidates: + final_score = float(candidate.get("fusion_score", candidate.get("vec_sim", 0.0))) + evidence_items.append({ + "item_id": candidate["item_id"], + "title": candidate["title"], + "text": candidate["text"], + "kg_depth": candidate["kg_depth"], + "vec_sim": final_score, + "turns_ago": candidate["turns_ago"], + "graph_score": float(candidate.get("graph_score") or 0.0), + "memory_score": float(candidate.get("memory_score") or 0.0), + "precomputed_score": final_score, + "is_relevant": candidate["item_id"] in relevant_ids, + }) + elif benchmark_context: + evidence_items.insert(0, { + "item_id": "benchmark_context", + "title": "benchmark_context", + "text": benchmark_context, + "kg_depth": 0, + "vec_sim": 1.0, + "turns_ago": 0, + "is_relevant": True, + }) + + # ── Stage 2: RetrievalServiceV3 (centrality + community ranking) ───── + vector_hits = [] + if not benchmark_candidates and _elapsed_ms() <= (kg_budget_ms + vector_budget_ms): + errors = state.get("diagnosis_errors", []) + confidence = float(state.get("diagnosis_confidence", 0.0) or 0.0) + is_multihop_task = benchmark_task == "multihop_qa" + + do_vector_search = True + max_hits = 5 + + if retrieval_policy == "rapid": + if len(errors) == 0 and confidence >= 0.85 and not is_multihop_task: + do_vector_search = False + elif len(errors) <= 2 and confidence >= 0.72: + max_hits = 3 + + if do_vector_search and _elapsed_ms() <= (kg_budget_ms + vector_budget_ms): + # ── Primary: RetrievalServiceV3 (centrality + community diversity) ── + try: + from api.models.v3_schemas import V3PipelineContext + + retrieval_v3 = await _get_retrieval_v3() + ctx = V3PipelineContext( + user_input=user_input, + session_id=state.get("session_id", ""), + user_id=state.get("user_id"), + ) + seed_nodes = [ + c if isinstance(c, str) else c.get("id", "") + for c in kg_concepts[:5] + ] + bundle = await retrieval_v3.retrieve(user_input, seed_nodes, ctx) + + for hit in bundle.vector_hits[:max_hits]: + snippet = getattr(hit, "snippet", hit.id) + vector_hits.append({"text": snippet, "score": hit.score}) + evidence_items.append({ + "item_id": hit.id, + "title": snippet, + "text": f"Concept ({hit.id}): {snippet}", + "kg_depth": 2, + "vec_sim": hit.score, + "turns_ago": session_turn, + }) + + logger.info( + f"[retrieve_node] RetrievalServiceV3: {len(vector_hits)} hits " + f"(centrality+community ranked)" + ) + + except Exception as e: + logger.warning( + f"[retrieve_node] RetrievalServiceV3 unavailable, " + f"falling back to MiniLM gateway: {e}" + ) + # ── Fallback: MiniLM gateway ────────────────────────────────── + try: + from api.services.model_gateway import get_gateway + + gateway = await get_gateway() + max_expanded = 10 + threshold = 0.3 + + if retrieval_policy == "rapid" and len(errors) <= 2 and confidence >= 0.7: + max_expanded = 5 + threshold = 0.35 + + candidate_labels = [] + for c in kg_concepts: + if isinstance(c, dict): + candidate_labels.append(c.get("id", "") + " " + c.get("label", "")) + else: + candidate_labels.append(str(c)) + for node in kg_expanded[:max_expanded]: + label = node.get("id", "") + " " + node.get("label", node.get("relation", "")) + if label.strip() and label not in candidate_labels: + candidate_labels.append(label) + + if candidate_labels: + result = await gateway.invoke( + "minilm", "invoke", + {"task": "similarity", "query": user_input, "candidates": candidate_labels}, + ) + if result.get("success"): + sim_results = result.get("data", {}).get("results", []) + for r in sim_results: + if r["score"] >= threshold: + vector_hits.append({"text": r["text"], "score": r["score"]}) + evidence_items.append({ + "text": f"Semantic match: {r['text']}", + "kg_depth": 2, + "vec_sim": r["score"], + "turns_ago": session_turn, + }) + + vector_hits = vector_hits[:max_hits] + logger.info(f"[retrieve_node] MiniLM fallback: {len(vector_hits)} hits") + except Exception as e2: + logger.warning(f"[retrieve_node] Vector search fully skipped: {e2}") + elif not benchmark_candidates: + budget_exhausted = True + + # ── Stage 3: L2 External Knowledge (Selective Retrieval) ───────── + # Phân tích xem có nên ép buộc tìm kiếm bên ngoài không (Proactive Dynamic Retrieval) + force_external = False + dynamic_patterns = [ + r"\bhôm (qua|nay|kia)\b", r"\bmới (đây|nhất)\b", r"\bvừa mới\b", + r"\brecently\b", r"\byesterday\b", r"\btoday\b", r"\blatest\b", r"\bcurrent\b", + r"\bdo you know\b", r"\bnghe nói\b", r"\bbạn có biết\b", r"\bnews\b", r"\btin tức\b" + ] + if any(re.search(p, user_input, re.IGNORECASE) for p in dynamic_patterns): + force_external = True + logger.info("[retrieve_node] Dynamic intent detected. Forcing L2 Search.") + + # Kích hoạt L2 nếu (thiếu dữ liệu) HOẶC (phát hiện intent cần tin tức thực tế) + if (len(evidence_items) < 3 or force_external) and not benchmark_candidates: + try: + doc_service = get_doc_intel_service() + # Nếu force_external, ta có thể điều chỉnh query để search hiệu quả hơn + search_query = user_input + if force_external and len(user_input) < 100: + # Bổ sung ngữ cảnh để search Tavily tốt hơn + search_query = f"latest information about {user_input}" + + external_hits = await asyncio.wait_for( + doc_service.query_l2(search_query), timeout=5.0 + ) + for hit in external_hits: + # Tránh trùng lặp nếu đã có trong evidence_items + if any(e.get("chunk_id") == hit["id"] for e in evidence_items): + continue + + evidence_items.append({ + "item_id": f"ext_{hit['id']}", + "title": "External Knowledge", + "text": f"Context: {hit['content']}", + "kg_depth": 3, # Tầng sâu hơn KG + "vec_sim": hit["score"], + "turns_ago": session_turn, + "is_external": True, + "chunk_id": hit["id"] + }) + if external_hits: + logger.info(f"[retrieve_node] L2 Context injected: {len(external_hits)} chunks") + except Exception as e3: + logger.warning(f"[retrieve_node] L2 retrieval failed: {e3}") + + # ── Fusion scoring and ranking ─────────────────────────────────── + if _elapsed_ms() <= total_budget_ms: + for item in evidence_items: + if benchmark_candidates and "precomputed_score" in item: + item["fusion_score"] = float(item.get("precomputed_score") or 0.0) + else: + item["fusion_score"] = _fusion_score( + kg_depth=item["kg_depth"], + vec_sim=item["vec_sim"], + last_used_turns_ago=item["turns_ago"], + ) + else: + budget_exhausted = True + for item in evidence_items: + if "fusion_score" not in item: + item["fusion_score"] = float(item.get("vec_sim") or 0.0) + + evidence_items = _rank_with_online_ranker( + question=user_input, + evidence_items=evidence_items, + allow_exploration=_adaptive_mode_enabled(state, benchmark_mode), + benchmark_mode=benchmark_mode, + ) + evidence_budget = _compute_evidence_budget( + question=user_input, + retrieval_policy=retrieval_policy, + benchmark_mode=benchmark_mode, + benchmark_candidates=bool(benchmark_candidates), + adaptive_profile=adaptive_profile, + ) + if benchmark_candidates and benchmark_task in {"multihop_qa", "retrieval_qa"}: + top_evidence = _select_diverse_multihop_evidence( + items=evidence_items, + question=user_input, + budget=evidence_budget, + ) + else: + top_evidence = evidence_items[:evidence_budget] + retrieval_trace = [ + { + "item_id": str(item.get("item_id") or item.get("title") or f"item_{idx}"), + "title": str(item.get("title") or item.get("item_id") or ""), + "text": str(item.get("text") or ""), + "rank": idx + 1, + "score": float(item.get("fusion_score") or 0.0), + "is_relevant": bool(item.get("is_relevant") or False), + } + for idx, item in enumerate(top_evidence) + ] + + if benchmark_candidates: + context_parts = [] + for item in top_evidence: + title = str(item.get("title") or "").strip() + text = str(item.get("text") or "").strip() + context_parts.append(f"[{title}] {text}" if title else text) + retrieved_context = "\n".join(part for part in context_parts if part).strip() + elif benchmark_context and benchmark_task in {"multihop_qa", "retrieval_qa"}: + retrieved_context = benchmark_context + else: + context_parts = [item["text"] for item in top_evidence] + retrieved_context = "\n".join(context_parts) if context_parts else "" + + jit_soft_graph = str(state.get("jit_soft_graph") or "").strip() + jit_graph_meta = dict(state.get("jit_graph_meta") or {}) + if jit_soft_graph: + retrieved_context = ( + f"[JIT_SOFT_GRAPH]\n{jit_soft_graph}\n\n" + f"{retrieved_context}".strip() + ) + + latency_ms = int((time.time() - start_time) * 1000) + logger.info( + f"[retrieve_node] {len(evidence_items)} candidates → top {len(top_evidence)} via fusion scoring" + f" (mode={benchmark_mode or 'default'}, ranker={benchmark_ranker}, latency={latency_ms}ms)" + ) + + ranker_snapshot = get_retrieval_ranker().snapshot() if _ranker_enabled() else {} + graph_update = dict(state.get("graph_update") or {}) + + return { + "vector_hits": vector_hits, + "retrieved_context": retrieved_context, + "jit_soft_graph": jit_soft_graph or None, + "jit_graph_meta": jit_graph_meta, + "retrieval_trace": retrieval_trace, + "adaptive_profile": adaptive_profile or None, + "adaptive_features": adaptive_features, + "adaptive_controller": adaptive_controller, + "retrieval_meta": { + "budget": { + "kg_ms": kg_budget_ms, + "vector_ms": vector_budget_ms, + "fusion_ms": fusion_budget_ms, + "total_ms": total_budget_ms, + "elapsed_ms": _elapsed_ms(), + "exhausted": budget_exhausted, + }, + "fusion": { + "alpha": _FUSION_ALPHA, + "beta": _FUSION_BETA, + "gamma": _FUSION_GAMMA, + "recency_lambda": _RECENCY_LAMBDA, + }, + "kg_topk": { + "top_k": max(1, _env_int("TRACECAG_KG_TOPK", 8)), + "context_token_budget": max(32, _env_int("TRACECAG_KG_CONTEXT_TOKEN_BUDGET", 160)), + "query_cache_size": len(_KG_QUERY_CACHE), + }, + "jit_graph": jit_graph_meta, + "graph_update": { + "latency_ms": int(graph_update.get("latency_ms") or 0), + "nodes_added": int(graph_update.get("nodes_added") or 0), + "edges_added": int(graph_update.get("edges_added") or 0), + }, + "mode": benchmark_mode or "default", + "ranker": benchmark_ranker, + "learned_ranker": { + "enabled": _ranker_enabled(), + "blend": _clip01(_env_float("TRACECAG_RANKER_BLEND", 0.42)), + "snapshot": ranker_snapshot, + }, + "adaptive": { + "profile": adaptive_profile or None, + "features": adaptive_features, + "controller": adaptive_controller, + }, + }, + "models_used": ["retrieval_fusion"] + (["minilm"] if vector_hits else []), + } From a718f7a566f7c3d68ad1839a094fcb597e91166e Mon Sep 17 00:00:00 2001 From: Thang Nguyen Huu <2001230909@hufi.edu.vn> Date: Wed, 24 Jun 2026 11:27:09 +0700 Subject: [PATCH 08/20] refactor(flutter): split profile_page.dart into section widgets _ProfilePageState had grown to 1772 lines covering header, quick actions, level progress, learning stats, weekly activity, badges, and the admin shortcut all in one file. Extracts each section into its own widget under presentation/widgets/profile_page/, dropping profile_page.dart from 2223 to 232 lines. flutter analyze clean, existing profile_page_test.dart (24 tests) passes unmodified. Co-Authored-By: Claude Sonnet 4.6 --- .../presentation/pages/profile_page.dart | 2014 +---------------- .../profile_page/admin_panel_tile.dart | 87 + .../profile_page/learning_stats_section.dart | 225 ++ .../profile_page/level_progress_card.dart | 94 + .../widgets/profile_page/profile_header.dart | 543 +++++ .../profile_page/quick_actions_row.dart | 131 ++ .../profile_page/recent_badges_section.dart | 275 +++ .../profile_page/social_stats_row.dart | 56 + .../profile_page/weekly_activity_section.dart | 710 ++++++ 9 files changed, 2137 insertions(+), 1998 deletions(-) create mode 100644 flutter-app/lib/features/profile/presentation/widgets/profile_page/admin_panel_tile.dart create mode 100644 flutter-app/lib/features/profile/presentation/widgets/profile_page/learning_stats_section.dart create mode 100644 flutter-app/lib/features/profile/presentation/widgets/profile_page/level_progress_card.dart create mode 100644 flutter-app/lib/features/profile/presentation/widgets/profile_page/profile_header.dart create mode 100644 flutter-app/lib/features/profile/presentation/widgets/profile_page/quick_actions_row.dart create mode 100644 flutter-app/lib/features/profile/presentation/widgets/profile_page/recent_badges_section.dart create mode 100644 flutter-app/lib/features/profile/presentation/widgets/profile_page/social_stats_row.dart create mode 100644 flutter-app/lib/features/profile/presentation/widgets/profile_page/weekly_activity_section.dart diff --git a/flutter-app/lib/features/profile/presentation/pages/profile_page.dart b/flutter-app/lib/features/profile/presentation/pages/profile_page.dart index a540b1bf..7f2475ff 100644 --- a/flutter-app/lib/features/profile/presentation/pages/profile_page.dart +++ b/flutter-app/lib/features/profile/presentation/pages/profile_page.dart @@ -1,31 +1,23 @@ import 'package:flutter/material.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'dart:ui'; import 'package:lexilingo_app/features/admin/admin_app.dart'; -import 'package:lexilingo_app/features/achievements/data/badge_asset_mapper.dart'; -import 'package:lexilingo_app/features/achievements/domain/entities/achievement_entity.dart'; -import 'package:lexilingo_app/features/achievements/presentation/screens/achievements_screen.dart'; -import 'package:lexilingo_app/features/achievements/presentation/widgets/achievement_widgets.dart'; import 'package:lexilingo_app/features/auth/domain/entities/user_entity.dart'; import 'package:lexilingo_app/features/auth/presentation/providers/auth_provider.dart'; import 'package:lexilingo_app/features/gamification/gamification.dart'; import 'package:lexilingo_app/features/level/level.dart'; -import 'package:lexilingo_app/features/profile/presentation/pages/edit_profile_screen.dart'; import 'package:lexilingo_app/features/profile/presentation/providers/profile_provider.dart'; -import 'package:lexilingo_app/features/profile/presentation/widgets/profile_ui_components.dart'; +import 'package:lexilingo_app/features/profile/presentation/widgets/profile_page/admin_panel_tile.dart'; +import 'package:lexilingo_app/features/profile/presentation/widgets/profile_page/learning_stats_section.dart'; +import 'package:lexilingo_app/features/profile/presentation/widgets/profile_page/level_progress_card.dart'; +import 'package:lexilingo_app/features/profile/presentation/widgets/profile_page/profile_header.dart'; +import 'package:lexilingo_app/features/profile/presentation/widgets/profile_page/quick_actions_row.dart'; +import 'package:lexilingo_app/features/profile/presentation/widgets/profile_page/recent_badges_section.dart'; +import 'package:lexilingo_app/features/profile/presentation/widgets/profile_page/weekly_activity_section.dart'; import 'package:lexilingo_app/features/progress/presentation/providers/progress_provider.dart'; -import 'package:lexilingo_app/features/progress/presentation/screens/my_progress_screen.dart'; -import 'package:lexilingo_app/features/social/social.dart'; import 'package:lexilingo_app/features/user/presentation/pages/settings_page.dart'; import 'package:lexilingo_app/features/voice/presentation/screens/voice_practice_screen.dart'; -import 'package:lexilingo_app/features/profile/presentation/pages/learning_stats_pages.dart'; -import 'package:lexilingo_app/features/user/domain/entities/weekly_activity_entity.dart'; -import 'package:lexilingo_app/core/widgets/glassmorphic_components.dart' - as glass; -import 'package:lexilingo_app/core/widgets/network_avatar_image.dart'; import 'package:provider/provider.dart'; import 'package:lexilingo_app/core/theme/app_theme.dart'; -import 'package:lexilingo_app/core/widgets/skeleton_loading.dart'; class ProfilePage extends StatefulWidget { const ProfilePage({super.key}); @@ -34,29 +26,15 @@ class ProfilePage extends StatefulWidget { State createState() => _ProfilePageState(); } -class _ProfilePageState extends State - with SingleTickerProviderStateMixin { - late final AnimationController _badgeShineController; - +class _ProfilePageState extends State { @override void initState() { super.initState(); - _badgeShineController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 3000), - )..repeat(); - WidgetsBinding.instance.addPostFrameCallback((_) { _loadData(); }); } - @override - void dispose() { - _badgeShineController.dispose(); - super.dispose(); - } - Future _loadData() async { final authProvider = context.read(); final levelProvider = context.read(); @@ -97,13 +75,6 @@ class _ProfilePageState extends State ); } - String _formatMemberSince(BuildContext context, DateTime? createdAt) { - if (createdAt == null) return 'profile.member'.tr(); - return 'profile.memberSince'.tr( - namedArgs: {'date': DateFormat('MMM yyyy').format(createdAt)}, - ); - } - @override Widget build(BuildContext context) { final authProvider = Provider.of(context); @@ -144,7 +115,7 @@ class _ProfilePageState extends State ), ), Text( - _formatMemberSince(context, user?.createdAt), + formatMemberSince(context, user?.createdAt), maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( @@ -237,28 +208,28 @@ class _ProfilePageState extends State child: Column( children: [ // Profile Header - _buildProfileHeader(context, user), + ProfileHeader(user: user, onProfileEdited: _loadData), // Quick Actions (Shop, Leaderboard, Social, Wallet) - _buildQuickActions(context), + const QuickActionsRow(), // Level Progress Card - _buildLevelProgressCard(context), + const ProfileLevelProgressCard(), // AI Proficiency Assessment (radar chart) const ProficiencyCard(), // Learning Stats - _buildLearningStats(context, user), + const LearningStatsSection(), // Weekly Activity - _buildWeeklyActivity(context), + const WeeklyActivitySection(), // Recent Badges - _buildRecentBadges(context), + const RecentBadgesSection(), // Admin Panel shortcut (only for authorised accounts) - if (_isAdminUser(user)) _buildAdminPanelTile(context), + if (_isAdminUser(user)) AdminPanelTile(onTap: _openAdminPanel), const SizedBox(height: 80), ], @@ -267,1957 +238,4 @@ class _ProfilePageState extends State ), ); } - - /// Quick Actions Grid - Navigate to new gamification/social features - Widget _buildQuickActions(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - children: [ - _buildQuickActionButton( - context, - icon: Icons.store, - label: 'profile.shop'.tr(), - color: AppColors.orange, - gradient: AppColors.warmGradient, - onTap: () => Navigator.push( - context, - MaterialPageRoute(builder: (_) => const ShopScreen()), - ), - ), - const SizedBox(width: 12), - _buildQuickActionButton( - context, - icon: Icons.leaderboard, - label: 'profile.ranks'.tr(), - color: AppColors.greenSuccess, - gradient: AppColors.successGradient, - onTap: () => Navigator.push( - context, - MaterialPageRoute(builder: (_) => const LeaderboardScreen()), - ), - ), - const SizedBox(width: 12), - _buildQuickActionButton( - context, - icon: Icons.people, - label: 'profile.friends'.tr(), - color: AppColorRoles.primary( - Theme.of(context).brightness == Brightness.dark, - ), - gradient: Theme.of(context).brightness == Brightness.dark - ? AppColors.primaryGradientDark - : AppColors.indigoGradient, - onTap: () => Navigator.push( - context, - MaterialPageRoute(builder: (_) => const SocialScreen()), - ), - ), - const SizedBox(width: 12), - _buildQuickActionButton( - context, - icon: Icons.insights_rounded, - label: 'profile.progress'.tr(), - color: AppColors.purple, - gradient: AppColors.purpleGradient, - onTap: () => Navigator.push( - context, - MaterialPageRoute(builder: (_) => const MyProgressScreen()), - ), - ), - ], - ), - ); - } - - Widget _buildQuickActionButton( - BuildContext context, { - required IconData icon, - required String label, - required Color color, - required List gradient, - required VoidCallback onTap, - }) { - return Expanded( - child: GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(vertical: 12), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: gradient, - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: color.withValues(alpha: 0.3), - blurRadius: 8, - offset: Offset(0, 4), - ), - ], - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - icon, - color: Theme.of(context).colorScheme.surface, - size: 24, - ), - const SizedBox(height: 4), - Text( - label, - style: TextStyle( - color: Theme.of(context).colorScheme.surface, - fontSize: 11, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ), - ); - } - - Widget _buildProfileHeader(BuildContext context, dynamic user) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final accent = AppColorRoles.primary(isDark); - return Consumer( - builder: (context, levelProvider, child) { - // Use numeric level progress, not CEFR-based - final progress = levelProvider.displayLevelProgress; - - return Container( - margin: const EdgeInsets.all(16), - child: Stack( - children: [ - // Glassmorphic Background Card - ClipRRect( - borderRadius: BorderRadius.circular(24), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), - child: Container( - width: double.infinity, - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - (isDark ? accent : AppColors.primary).withValues( - alpha: 0.15, - ), - (isDark ? accent : AppColors.primary).withValues( - alpha: 0.1, - ), - AppColors.surfaceLight.withValues(alpha: 0.05), - ], - ), - borderRadius: BorderRadius.circular(24), - border: Border.all( - color: Theme.of( - context, - ).colorScheme.surface.withValues(alpha: 0.2), - width: 1.5, - ), - boxShadow: [ - BoxShadow( - color: (isDark ? accent : AppColors.primary) - .withValues(alpha: 0.12), - blurRadius: 20, - offset: const Offset(0, 10), - ), - ], - ), - child: Column( - children: [ - // Avatar with animated progress ring - Stack( - alignment: Alignment.center, - children: [ - // Progress Ring - glass.AnimatedProgressRing( - progress: progress, - size: 140, - strokeWidth: 6, - gradientColors: [ - isDark ? accent : AppColors.primary, - isDark ? accent : AppColors.primary, - AppColors.purple, - ], - child: Container( - width: 120, - height: 120, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: Theme.of(context).colorScheme.surface - .withValues(alpha: 0.5), - width: 3, - ), - boxShadow: [ - BoxShadow( - color: - (isDark ? accent : AppColors.primary) - .withValues(alpha: 0.3), - blurRadius: 20, - spreadRadius: 5, - ), - ], - ), - child: ClipOval( - child: NetworkAvatarImage( - imageUrl: user?.avatarUrl, - fit: BoxFit.cover, - width: 120, - height: 120, - fallback: Container( - color: - (isDark ? accent : AppColors.primary) - .withValues(alpha: 0.2), - child: Icon( - Icons.person, - size: 60, - color: isDark - ? accent - : AppColors.primary, - ), - ), - ), - ), - ), - ), - // Verified Badge - if (user?.isVerified == true) - Positioned( - bottom: 10, - right: 10, - child: Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - isDark ? accent : AppColors.primary, - isDark ? accent : AppColors.primary, - ], - ), - shape: BoxShape.circle, - border: Border.all( - color: AppColors.surfaceLight, - width: 2, - ), - boxShadow: [ - BoxShadow( - color: - (isDark - ? accent - : AppColors.primary) - .withValues(alpha: 0.5), - blurRadius: 8, - ), - ], - ), - child: Icon( - Icons.verified, - color: AppColors.surfaceLight, - size: 16, - ), - ), - ), - ], - ), - const SizedBox(height: 20), - // User Name - Text( - user?.displayName ?? 'profile.guestUser'.tr(), - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.headlineSmall - ?.copyWith( - fontWeight: FontWeight.bold, - letterSpacing: -0.5, - ), - ), - const SizedBox(height: 6), - // Email - if (user?.email != null) - Text( - user.email, - style: const TextStyle( - color: AppColors.grey600, - fontSize: 13, - ), - ), - const SizedBox(height: 12), - // Level Badge + CEFR Proficiency Badge + Rank Badge - Consumer( - builder: (_, lp, __) { - final rankData = rankVisualDataFor(lp.rank); - final isMasterRank = lp.rank == 'master'; - return Wrap( - spacing: 8, - runSpacing: 8, - alignment: WrapAlignment.center, - children: [ - // Numeric Level badge - Container( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: AppColorRoles.primaryGradient( - isDark, - ), - ), - borderRadius: BorderRadius.circular(20), - boxShadow: [ - BoxShadow( - color: (isDark - ? accent - : AppColors.primary) - .withValues(alpha: 0.4), - blurRadius: 8, - offset: const Offset(0, 4), - ), - ], - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.workspace_premium_rounded, - color: AppColors.surfaceLight, - size: 16, - ), - const SizedBox(width: 6), - Text( - 'profile.level'.tr( - namedArgs: { - 'level': '${lp.displayLevel}', - }, - ), - style: TextStyle( - color: AppColors.surfaceLight, - fontWeight: FontWeight.bold, - fontSize: 13, - ), - ), - ], - ), - ), - // CEFR Proficiency Badge - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - decoration: BoxDecoration( - color: _getTierColor( - lp.proficiencyLevel, - ).withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: _getTierColor( - lp.proficiencyLevel, - ).withValues(alpha: 0.5), - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - _getTierIcon(lp.proficiencyLevel), - size: 14, - color: _getTierColor( - lp.proficiencyLevel, - ), - ), - const SizedBox(width: 4), - Text( - lp.proficiencyLevel, - style: TextStyle( - color: _getTierColor( - lp.proficiencyLevel, - ), - fontWeight: FontWeight.bold, - fontSize: 13, - ), - ), - const SizedBox(width: 4), - Text( - '·', - style: TextStyle( - color: _getTierColor( - lp.proficiencyLevel, - ).withValues(alpha: 0.6), - fontSize: 13, - ), - ), - const SizedBox(width: 4), - Text( - lp.proficiencyName, - style: TextStyle( - color: _getTierColor( - lp.proficiencyLevel, - ), - fontWeight: FontWeight.w500, - fontSize: 12, - ), - ), - ], - ), - ), - // League Rank Badge - GestureDetector( - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => - const LeaderboardScreen(), - ), - ), - child: isMasterRank - ? ShaderMask( - shaderCallback: (bounds) => - const LinearGradient( - colors: [ - Color(0xFF5AB6FF), - Color(0xFFFFD64F), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ).createShader(bounds), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 6, - ), - decoration: BoxDecoration( - color: Colors.white.withValues( - alpha: 0.12, - ), - borderRadius: - BorderRadius.circular(20), - border: Border.all( - color: Colors.white.withValues( - alpha: 0.4, - ), - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - RankAssetIcon( - rank: lp.rank, - size: 16, - decorated: false, - ), - const SizedBox(width: 5), - Text( - lp.rankName, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 12, - ), - ), - ], - ), - ), - ) - : Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 6, - ), - decoration: BoxDecoration( - color: rankData.color.withValues( - alpha: 0.1, - ), - borderRadius: - BorderRadius.circular(20), - border: Border.all( - color: rankData.color.withValues( - alpha: 0.4, - ), - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - RankAssetIcon( - rank: lp.rank, - size: 16, - decorated: false, - ), - const SizedBox(width: 5), - Text( - lp.rankName, - style: TextStyle( - color: rankData.color, - fontWeight: FontWeight.bold, - fontSize: 12, - ), - ), - ], - ), - ), - ), - ], - ); - }, - ), - const SizedBox(height: 8), - // Member Since - Text( - _formatMemberSince(context, user?.createdAt), - style: const TextStyle( - color: AppColors.grey600, - fontSize: 12, - ), - ), - const SizedBox(height: 12), - // Edit Profile Button - GestureDetector( - onTap: () async { - final result = await Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const EditProfileScreen(), - ), - ); - if (result == true) { - _loadData(); // Refresh after edit - } - }, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 8, - ), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: AppColorRoles.primaryGradient(isDark), - ), - borderRadius: BorderRadius.circular(20), - boxShadow: [ - BoxShadow( - color: (isDark ? accent : AppColors.primary) - .withValues(alpha: 0.3), - blurRadius: 8, - offset: Offset(0, 4), - ), - ], - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.edit, - color: AppColors.surfaceLight, - size: 14, - ), - SizedBox(width: 6), - Text( - 'profile.editProfile'.tr(), - style: TextStyle( - color: AppColors.surfaceLight, - fontWeight: FontWeight.bold, - fontSize: 13, - ), - ), - ], - ), - ), - ), - const SizedBox(height: 20), - // Social Stats Row - _buildSocialStatsRow(context), - ], - ), - ), - ), - ), - ], - ), - ); - }, - ); - } - - /// Social stats row showing XP, followers, following - Widget _buildSocialStatsRow(BuildContext context) { - return Consumer( - builder: (context, profileProvider, _) { - final stats = profileProvider.stats; - - return Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - AnimatedSocialStat( - value: '${stats?.totalVocabularyMastered ?? 0}', - label: 'profile.words'.tr(), - icon: Icons.spellcheck_rounded, - color: AppColors.greenSuccessBright, - ), - Container( - height: 40, - width: 1, - color: AppColors.grey400.withValues(alpha: 0.5), - ), - AnimatedSocialStat( - value: '${stats?.totalLessonsCompleted ?? 0}', - label: 'profile.lessons'.tr(), - icon: Icons.menu_book, - color: AppColorRoles.primary( - Theme.of(context).brightness == Brightness.dark, - ), - ), - Container( - height: 40, - width: 1, - color: AppColors.grey400.withValues(alpha: 0.5), - ), - AnimatedSocialStat( - value: '${stats?.currentStreak ?? 0}', - label: 'profile.dayStreak'.tr(), - icon: Icons.local_fire_department, - color: AppColors.dangerGradient[0], - ), - ], - ); - }, - ); - } - - IconData _getTierIcon(String tierCode) { - switch (tierCode) { - case 'A1': - return Icons.eco; - case 'A2': - return Icons.spa; - case 'B1': - return Icons.bolt; - case 'B2': - return Icons.rocket_launch; - case 'C1': - return Icons.workspace_premium; - case 'C2': - return Icons.diamond; - default: - return Icons.star; - } - } - - Widget _buildLevelProgressCard(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - return Consumer( - builder: (context, levelProvider, child) { - final level = levelProvider.displayLevel; - final xpIn = levelProvider.displayXpInLevel; - final xpFor = levelProvider.displayXpForNextLevel; - final progress = levelProvider.displayLevelProgress; - - return GlassmorphicContainer( - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'profile.levelRange'.tr( - namedArgs: { - 'level': '$level', - 'nextLevel': '${level + 1}', - }, - ), - style: const TextStyle(fontWeight: FontWeight.w500), - ), - Text( - 'profile.xpProgress'.tr( - namedArgs: {'current': '$xpIn', 'total': '$xpFor'}, - ), - style: const TextStyle(fontSize: 14), - ), - ], - ), - const SizedBox(height: 12), - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: LinearProgressIndicator( - value: progress, - backgroundColor: AppColorRoles.primary( - isDark, - ).withValues(alpha: 0.18), - valueColor: AlwaysStoppedAnimation( - AppColorRoles.primary(isDark), - ), - minHeight: 12, - ), - ), - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'profile.xpToNextLevel'.tr( - namedArgs: { - 'xp': '${xpFor - xpIn}', - 'level': '${level + 1}', - }, - ), - style: TextStyle( - color: Theme.of(context).colorScheme.onSurfaceVariant, - fontSize: 12, - ), - ), - Text( - 'profile.percentComplete'.tr( - namedArgs: { - 'percent': (progress * 100).toStringAsFixed(0), - }, - ), - style: TextStyle( - color: AppColorRoles.primary(isDark), - fontSize: 12, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ], - ), - ); - }, - ); - } - - Color _getTierColor(String tierCode) { - final isDark = Theme.of(context).brightness == Brightness.dark; - switch (tierCode) { - case 'A1': - return AppColors.greenSuccessBright; - case 'A2': - return AppColors.teal; - case 'B1': - return AppColorRoles.primary(isDark); - case 'B2': - return AppColorRoles.primaryDeep(isDark); - case 'C1': - return AppColors.purple; - case 'C2': - return AppColors.warning; - default: - return AppColorRoles.primary(isDark); - } - } - - Widget _buildLearningStats(BuildContext context, dynamic user) { - return Consumer( - builder: (context, profileProvider, child) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final primaryColor = AppColorRoles.primary(isDark); - // Use stats from ProfileProvider (backend API) - final stats = profileProvider.stats; - final lessonsCompleted = stats?.totalLessonsCompleted ?? 0; - final coursesCompleted = stats?.totalCoursesCompleted ?? 0; - final vocabularyMastered = stats?.totalVocabularyMastered ?? 0; - final testsPassed = stats?.totalTestsPassed ?? 0; - final avgScore = stats?.averageTestScore ?? 0.0; - - // Show loading state - if (profileProvider.isLoadingStats && stats == null) { - return _buildLoadingStats(context); - } - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.all(16.0), - child: Row( - children: [ - Icon(Icons.insights_rounded, color: primaryColor, size: 20), - const SizedBox(width: 8), - Text( - 'profile.learningStats'.tr(), - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Column( - children: [ - Row( - children: [ - Expanded( - child: GlassmorphicStatCard( - icon: Icons.abc, - color: primaryColor, - title: 'profile.lessons'.tr(), - value: '$lessonsCompleted', - subtitle: 'profile.completed'.tr(), - valueInRightCircle: true, - valueCircleSize: 40, - valueCircleFontSize: 18, - isAction: true, - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const LearningLessonsStatsPage(), - ), - ), - iconBoxSize: 28, - iconSize: 14, - titleFontSize: 13, - subtitleFontSize: 11, - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - ), - ), - const SizedBox(width: 10), - Expanded( - child: GlassmorphicStatCard( - icon: Icons.school, - color: primaryColor, - title: 'profile.courses'.tr(), - value: '$coursesCompleted', - subtitle: 'profile.finished'.tr(), - valueInRightCircle: true, - valueCircleSize: 40, - valueCircleFontSize: 18, - isAction: true, - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const LearningCoursesStatsPage(), - ), - ), - iconBoxSize: 28, - iconSize: 14, - titleFontSize: 13, - subtitleFontSize: 11, - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - ), - ), - ], - ), - const SizedBox(height: 10), - Row( - children: [ - Expanded( - child: GlassmorphicStatCard( - icon: Icons.auto_stories, - color: primaryColor, - title: 'profile.vocabulary'.tr(), - value: '$vocabularyMastered', - subtitle: 'profile.mastered'.tr(), - valueInRightCircle: true, - valueCircleSize: 40, - valueCircleFontSize: 18, - isAction: true, - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => - const LearningVocabularyStatsPage(), - ), - ), - iconBoxSize: 28, - iconSize: 14, - titleFontSize: 13, - subtitleFontSize: 11, - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - ), - ), - const SizedBox(width: 10), - Expanded( - child: GlassmorphicStatCard( - icon: Icons.quiz, - color: primaryColor, - title: 'profile.tests'.tr(), - value: '$testsPassed', - subtitle: avgScore > 0 - ? 'profile.averagePercent'.tr( - namedArgs: { - 'percent': avgScore.toStringAsFixed(0), - }, - ) - : 'profile.passed'.tr(), - valueInRightCircle: true, - valueCircleSize: 40, - valueCircleFontSize: 18, - isAction: true, - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const LearningTestsStatsPage(), - ), - ), - iconBoxSize: 28, - iconSize: 14, - titleFontSize: 13, - subtitleFontSize: 11, - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - ), - ), - ], - ), - ], - ), - ), - ], - ); - }, - ); - } - - Widget _buildLoadingStats(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.all(16.0), - child: Text( - 'profile.learningStats'.tr(), - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), - ), - ), - GridView.count( - crossAxisCount: 2, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - padding: const EdgeInsets.symmetric(horizontal: 16), - crossAxisSpacing: 12, - mainAxisSpacing: 12, - childAspectRatio: 1.15, - children: List.generate( - 4, - (index) => ShimmerContainer( - child: SkeletonBox(height: double.infinity, borderRadius: 12), - ), - ), - ), - ], - ); - } - - void _showWeeklyActivityDetail( - BuildContext context, - List activities, - ) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (_) => _WeeklyActivityDetailSheet(activities: activities), - ); - } - - Widget _buildWeeklyActivity(BuildContext context) { - return Consumer( - builder: (context, profileProvider, child) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final activities = profileProvider.weeklyActivity; - final isLoading = profileProvider.isLoadingActivity; - final activityError = profileProvider.activityError; - - return Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 16, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'profile.weeklyActivity'.tr(), - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - GestureDetector( - onTap: activities.isNotEmpty - ? () => _showWeeklyActivityDetail(context, activities) - : null, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'profile.last7Days'.tr(), - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - fontSize: 12, - fontWeight: FontWeight.w500, - ), - ), - if (activities.isNotEmpty) ...[ - const SizedBox(width: 4), - Icon( - Icons.chevron_right, - size: 16, - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - ), - ], - ], - ), - ), - ], - ), - ), - GlassmorphicContainer( - child: isLoading && activities.isEmpty - ? SizedBox( - width: double.infinity, - child: Padding( - padding: const EdgeInsets.all(24.0), - child: ShimmerContainer( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - height: 120, - child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.end, - children: List.generate( - 7, - (index) => Expanded( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 4, - ), - child: SkeletonBox( - height: 20.0 + (index % 3) * 30.0, - borderRadius: 4, - ), - ), - ), - ), - ), - ), - const SizedBox(height: 6), - Row( - children: List.generate( - 7, - (index) => const Expanded( - child: Center( - child: SkeletonBox( - width: 20, - height: 12, - borderRadius: 2, - ), - ), - ), - ), - ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: - MainAxisAlignment.spaceAround, - children: List.generate( - 3, - (index) => const Column( - children: [ - SkeletonCircle(size: 20), - SizedBox(height: 4), - SkeletonBox( - width: 40, - height: 16, - borderRadius: 4, - ), - SizedBox(height: 4), - SkeletonBox( - width: 60, - height: 12, - borderRadius: 4, - ), - ], - ), - ), - ), - ], - ), - ), - ), - ) - : activities.isEmpty - ? Center( - child: Padding( - padding: const EdgeInsets.all(24.0), - child: Text( - activityError == null || activityError.isEmpty - ? 'profile.noActivityDataYet'.tr() - : activityError, - style: const TextStyle(color: AppColors.grey600), - textAlign: TextAlign.center, - ), - ), - ) - : Column( - children: [ - // XP Chart with animated bars - SizedBox( - height: 120, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.end, - children: activities.asMap().entries.map((entry) { - final index = entry.key; - final activity = entry.value; - final maxXP = activities - .map((a) => a.xpEarned) - .reduce((a, b) => a > b ? a : b); - final normalizedValue = maxXP > 0 - ? activity.xpEarned / maxXP - : 0.0; - - return Expanded( - child: AnimatedActivityBar( - label: '', - value: normalizedValue, - xpValue: activity.xpEarned, - color: AppColorRoles.primary(isDark), - delay: Duration(milliseconds: index * 100), - ), - ); - }).toList(), - ), - ), - // Day labels row — separate from bars to ensure alignment - const SizedBox(height: 6), - Row( - children: activities.map((activity) { - String dayLabel; - final parsedDate = DateTime.tryParse(activity.date); - if (parsedDate != null) { - dayLabel = DateFormat( - 'E', - ).format(parsedDate).substring(0, 1); - } else { - final raw = activity.date.trim(); - dayLabel = raw.isEmpty - ? '-' - : raw.substring(0, 1).toUpperCase(); - } - return Expanded( - child: Center( - child: Text( - dayLabel, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: isDark - ? AppColors.grey500 - : AppColors.grey600, - ), - ), - ), - ); - }).toList(), - ), - const SizedBox(height: 16), - // Summary stats - Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - _buildActivityStat( - 'profile.totalXp'.tr(), - '${activities.fold(0, (sum, a) => sum + a.xpEarned)}', - Icons.star, - AppColors.warning, - ), - _buildActivityStat( - 'profile.lessons'.tr(), - '${activities.fold(0, (sum, a) => sum + a.lessonsCompleted)}', - Icons.menu_book, - AppColorRoles.primary(isDark), - ), - _buildActivityStat( - 'profile.words'.tr(), - '${activities.fold(0, (sum, a) => sum + a.vocabularyLearned)}', - Icons.abc, - AppColors.greenSuccessBright, - ), - ], - ), - ], - ), - ), - ], - ); - }, - ); - } - - Widget _buildActivityStat( - String label, - String value, - IconData icon, - Color color, - ) { - return Column( - children: [ - Icon(icon, size: 20, color: color), - const SizedBox(height: 4), - Text( - value, - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), - ), - Text( - label, - style: const TextStyle(fontSize: 11, color: AppColors.grey600), - ), - ], - ); - } - - Widget _buildRecentBadges(BuildContext context) { - return Consumer( - builder: (context, provider, child) { - final badges = provider.recentBadges; - final isLoading = provider.isLoadingBadges; - final visibleBadges = badges.where(_hasRenderableBadgeImage).toList(); - - return Column( - children: [ - Padding( - padding: const EdgeInsets.all(16.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'profile.recentBadges'.tr(), - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - TextButton( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const AchievementsScreen(), - ), - ); - }, - child: Text('achievements.viewAll'.tr()), - ), - ], - ), - ), - SizedBox( - height: 100, - child: isLoading - ? ShimmerContainer( - child: ListView.separated( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 16), - itemCount: 4, - separatorBuilder: (_, __) => const SizedBox(width: 16), - itemBuilder: (context, index) { - return const SkeletonCircle(size: 80); - }, - ), - ) - : visibleBadges.isEmpty - ? _buildEmptyBadges() - : ListView.separated( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 16), - itemCount: visibleBadges.length, - separatorBuilder: (_, __) => const SizedBox(width: 16), - itemBuilder: (context, index) { - final badge = visibleBadges[index]; - return _buildBadgeItemFromEntity(badge); - }, - ), - ), - ], - ); - }, - ); - } - - bool _hasRenderableBadgeImage(UserAchievementEntity badge) { - final achievement = badge.achievement; - - if (achievement.category.toLowerCase() == 'xp') { - return false; - } - - final networkBadge = achievement.badgeIcon?.trim(); - final networkBadgeUri = networkBadge != null && networkBadge.isNotEmpty - ? Uri.tryParse(networkBadge) - : null; - final hasValidNetworkBadge = - networkBadgeUri != null && - (networkBadgeUri.scheme == 'http' || - networkBadgeUri.scheme == 'https') && - networkBadgeUri.host.isNotEmpty; - if (hasValidNetworkBadge) { - return true; - } - - final lookupKey = (achievement.slug ?? achievement.id).trim(); - if (lookupKey.isEmpty) { - return false; - } - - return BadgeAssetMapper.hasRenderableBadge(lookupKey); - } - - /// Build empty badges placeholder - Widget _buildEmptyBadges() { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.emoji_events_outlined, - size: 40, - color: AppColors.grey500, - ), - const SizedBox(height: 8), - Text( - 'profile.completeLessonsToEarnBadges'.tr(), - style: const TextStyle(color: AppColors.grey600, fontSize: 12), - ), - ], - ), - ); - } - - /// Build badge item from UserAchievementEntity - Widget _buildBadgeItemFromEntity(UserAchievementEntity badge) { - final achievement = badge.achievement; - const badgeSize = 64.0; - final badgeMask = _buildBadgeShineMask(badge, badgeSize); - - return Tooltip( - message: '${achievement.name}\n${badge.unlockedTimeAgo}', - child: Column( - children: [ - SizedBox( - width: badgeSize, - height: badgeSize, - child: Stack( - alignment: Alignment.center, - children: [ - AchievementBadge( - achievement: achievement, - isUnlocked: true, - size: badgeSize, - ), - Positioned.fill( - child: IgnorePointer( - child: AnimatedBuilder( - animation: _badgeShineController, - builder: (context, child) { - // Smaller sweep portion => longer idle pause per cycle. - const sweepPortion = 0.62; - final progress = _badgeShineController.value; - final isSweeping = progress < sweepPortion; - final sweepT = isSweeping - ? Curves.easeInOut.transform( - progress / sweepPortion, - ) - : 1.0; - final position = isSweeping - ? lerpDouble(-1.6, 1.6, sweepT)! - : 2.4; - - return ShaderMask( - // Apply shine only where the badge image has alpha. - blendMode: BlendMode.srcIn, - shaderCallback: (bounds) { - return LinearGradient( - begin: Alignment(position - 0.45, -1.0), - end: Alignment(position + 0.45, 1.0), - colors: [ - AppColors.surfaceLight.withValues(alpha: 0), - AppColors.surfaceLight.withValues(alpha: 0.06), - AppColors.surfaceLight.withValues(alpha: 0.56), - AppColors.surfaceLight.withValues(alpha: 0.06), - AppColors.surfaceLight.withValues(alpha: 0), - ], - stops: const [0.0, 0.42, 0.5, 0.58, 1.0], - ).createShader(bounds); - }, - child: child, - ); - }, - child: badgeMask, - ), - ), - ), - ], - ), - ), - const SizedBox(height: 8), - Text( - achievement.name, - style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w500), - overflow: TextOverflow.ellipsis, - ), - ], - ), - ); - } - - Widget _buildBadgeShineMask(UserAchievementEntity badge, double badgeSize) { - final achievement = badge.achievement; - - final networkBadge = achievement.badgeIcon?.trim(); - final networkBadgeUri = networkBadge != null && networkBadge.isNotEmpty - ? Uri.tryParse(networkBadge) - : null; - final hasValidNetworkBadge = - networkBadgeUri != null && - (networkBadgeUri.scheme == 'http' || - networkBadgeUri.scheme == 'https') && - networkBadgeUri.host.isNotEmpty; - - if (hasValidNetworkBadge) { - return ClipOval( - child: Image.network( - networkBadge!, - width: badgeSize, - height: badgeSize, - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => const SizedBox.shrink(), - ), - ); - } - - final lookupKey = (achievement.slug ?? achievement.id).trim(); - final assetPath = BadgeAssetMapper.getBadgeAsset(lookupKey); - if (assetPath != null) { - return ClipOval( - child: Image.asset( - assetPath, - width: badgeSize, - height: badgeSize, - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => const SizedBox.shrink(), - ), - ); - } - - return const SizedBox.shrink(); - } - - Widget _buildAdminPanelTile(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: _openAdminPanel, - borderRadius: BorderRadius.circular(16), - child: Ink( - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [Color(0xFFFF6B35), Color(0xFFFF3B00)], - begin: Alignment.centerLeft, - end: Alignment.centerRight, - ), - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.deepOrange.withValues(alpha: isDark ? 0.4 : 0.25), - blurRadius: 12, - offset: const Offset(0, 4), - ), - ], - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(10), - ), - child: const Icon( - Icons.admin_panel_settings_rounded, - color: Colors.white, - size: 22, - ), - ), - const SizedBox(width: 14), - const Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Switch to Admin Panel', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 15, - ), - ), - SizedBox(height: 2), - Text( - 'LexiLingo Admin mobile', - style: TextStyle( - color: Colors.white70, - fontSize: 12, - ), - ), - ], - ), - ), - const Icon( - Icons.open_in_new_rounded, - color: Colors.white70, - size: 18, - ), - ], - ), - ), - ), - ), - ), - ); - } -} - -class _WeeklyActivityDetailSheet extends StatelessWidget { - final List activities; - - const _WeeklyActivityDetailSheet({required this.activities}); - - @override - Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final primary = AppColorRoles.primary(isDark); - - final totalXP = activities.fold(0, (s, a) => s + a.xpEarned); - final totalLessons = activities.fold( - 0, - (s, a) => s + a.lessonsCompleted, - ); - final totalWords = activities.fold( - 0, - (s, a) => s + a.vocabularyLearned, - ); - final maxXP = activities - .map((a) => a.xpEarned) - .reduce((a, b) => a > b ? a : b); - return DraggableScrollableSheet( - initialChildSize: 0.75, - minChildSize: 0.5, - maxChildSize: 0.95, - expand: false, - builder: (_, scrollController) { - return Container( - decoration: BoxDecoration( - color: isDark ? const Color(0xFF1A1A2E) : Colors.white, - borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.2), - blurRadius: 20, - offset: const Offset(0, -4), - ), - ], - ), - child: Column( - children: [ - // Drag handle - Container( - margin: const EdgeInsets.only(top: 12), - width: 40, - height: 4, - decoration: BoxDecoration( - color: AppColors.grey400, - borderRadius: BorderRadius.circular(2), - ), - ), - // Header - Padding( - padding: const EdgeInsets.fromLTRB(20, 20, 20, 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'profile.weeklyActivity'.tr(), - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - Text( - 'profile.last7DaysDetailedBreakdown'.tr(), - style: TextStyle( - fontSize: 12, - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - ), - ), - ], - ), - GestureDetector( - onTap: () => Navigator.pop(context), - child: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: isDark - ? Colors.white.withValues(alpha: 0.08) - : Colors.black.withValues(alpha: 0.06), - shape: BoxShape.circle, - ), - child: const Icon(Icons.close, size: 16), - ), - ), - ], - ), - ), - // Summary row - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 8, - ), - child: Row( - children: [ - _SummaryChip( - icon: Icons.star, - color: AppColors.warning, - label: 'profile.xpValue'.tr( - namedArgs: {'xp': '$totalXP'}, - ), - sublabel: 'profile.total'.tr(), - ), - const SizedBox(width: 8), - _SummaryChip( - icon: Icons.menu_book, - color: primary, - label: '$totalLessons', - sublabel: 'profile.lessons'.tr(), - ), - const SizedBox(width: 8), - _SummaryChip( - icon: Icons.abc, - color: AppColors.greenSuccessBright, - label: '$totalWords', - sublabel: 'profile.words'.tr(), - ), - ], - ), - ), - const Divider(height: 1), - // Day list - Expanded( - child: ListView.builder( - controller: scrollController, - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: activities.length, - itemBuilder: (context, index) { - final a = activities[index]; - final isBest = a.xpEarned == maxXP && maxXP > 0; - final barFraction = maxXP > 0 ? a.xpEarned / maxXP : 0.0; - - DateTime? parsedDate = DateTime.tryParse(a.date); - final String dayName = parsedDate != null - ? DateFormat('EEEE').format(parsedDate) - : a.date; - final String shortDate = parsedDate != null - ? DateFormat('MMM d').format(parsedDate) - : ''; - - return Container( - margin: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 4, - ), - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: isBest - ? primary.withValues(alpha: isDark ? 0.15 : 0.08) - : (isDark - ? Colors.white.withValues(alpha: 0.04) - : Colors.black.withValues(alpha: 0.03)), - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: isBest - ? primary.withValues(alpha: 0.4) - : Colors.transparent, - width: 1.5, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Row( - children: [ - Text( - dayName, - style: const TextStyle( - fontWeight: FontWeight.w600, - fontSize: 14, - ), - ), - if (shortDate.isNotEmpty) ...[ - const SizedBox(width: 6), - Text( - shortDate, - style: TextStyle( - fontSize: 12, - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - ), - ), - ], - ], - ), - ), - if (isBest) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 2, - ), - decoration: BoxDecoration( - color: AppColors.warning.withValues( - alpha: 0.15, - ), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: AppColors.warning.withValues( - alpha: 0.5, - ), - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.emoji_events, - size: 12, - color: AppColors.warning, - ), - SizedBox(width: 3), - Text( - 'profile.bestDay'.tr(), - style: TextStyle( - fontSize: 11, - color: AppColors.warning, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 10), - // XP bar - if (a.xpEarned > 0) ...[ - Row( - children: [ - Expanded( - child: ClipRRect( - borderRadius: BorderRadius.circular(4), - child: LinearProgressIndicator( - value: barFraction, - backgroundColor: primary.withValues( - alpha: 0.12, - ), - valueColor: AlwaysStoppedAnimation( - primary, - ), - minHeight: 6, - ), - ), - ), - const SizedBox(width: 10), - Text( - 'profile.xpValue'.tr( - namedArgs: {'xp': '${a.xpEarned}'}, - ), - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.bold, - color: primary, - ), - ), - ], - ), - const SizedBox(height: 10), - ], - // Stats chips - Row( - children: [ - if (a.xpEarned == 0) - Text( - 'profile.noActivity'.tr(), - style: TextStyle( - fontSize: 12, - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - ), - ) - else ...[ - _MiniStat( - icon: Icons.menu_book, - color: primary, - value: 'profile.lessonsCount'.tr( - namedArgs: { - 'count': '${a.lessonsCompleted}', - }, - ), - ), - const SizedBox(width: 12), - _MiniStat( - icon: Icons.abc, - color: AppColors.greenSuccessBright, - value: 'profile.wordsCount'.tr( - namedArgs: { - 'count': '${a.vocabularyLearned}', - }, - ), - ), - ], - ], - ), - ], - ), - ); - }, - ), - ), - // Bottom safe area - SizedBox(height: MediaQuery.of(context).padding.bottom + 16), - ], - ), - ); - }, - ); - } -} - -class _SummaryChip extends StatelessWidget { - final IconData icon; - final Color color; - final String label; - final String sublabel; - - const _SummaryChip({ - required this.icon, - required this.color, - required this.label, - required this.sublabel, - }); - - @override - Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - return Expanded( - child: Container( - padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12), - decoration: BoxDecoration( - color: color.withValues(alpha: isDark ? 0.12 : 0.08), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: color.withValues(alpha: 0.25)), - ), - child: Row( - children: [ - Icon(icon, size: 16, color: color), - const SizedBox(width: 6), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.bold, - color: color, - ), - ), - Text( - sublabel, - style: TextStyle( - fontSize: 10, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ], - ), - ), - ); - } -} - -class _MiniStat extends StatelessWidget { - final IconData icon; - final Color color; - final String value; - - const _MiniStat({ - required this.icon, - required this.color, - required this.value, - }); - - @override - Widget build(BuildContext context) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 14, color: color), - const SizedBox(width: 4), - Text( - value, - style: TextStyle( - fontSize: 12, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ], - ); - } } diff --git a/flutter-app/lib/features/profile/presentation/widgets/profile_page/admin_panel_tile.dart b/flutter-app/lib/features/profile/presentation/widgets/profile_page/admin_panel_tile.dart new file mode 100644 index 00000000..1aa4be09 --- /dev/null +++ b/flutter-app/lib/features/profile/presentation/widgets/profile_page/admin_panel_tile.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; + +class AdminPanelTile extends StatelessWidget { + final VoidCallback onTap; + + const AdminPanelTile({super.key, required this.onTap}); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Ink( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFFFF6B35), Color(0xFFFF3B00)], + begin: Alignment.centerLeft, + end: Alignment.centerRight, + ), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.deepOrange.withValues(alpha: isDark ? 0.4 : 0.25), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + Icons.admin_panel_settings_rounded, + color: Colors.white, + size: 22, + ), + ), + const SizedBox(width: 14), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Switch to Admin Panel', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 15, + ), + ), + SizedBox(height: 2), + Text( + 'LexiLingo Admin mobile', + style: TextStyle( + color: Colors.white70, + fontSize: 12, + ), + ), + ], + ), + ), + const Icon( + Icons.open_in_new_rounded, + color: Colors.white70, + size: 18, + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/flutter-app/lib/features/profile/presentation/widgets/profile_page/learning_stats_section.dart b/flutter-app/lib/features/profile/presentation/widgets/profile_page/learning_stats_section.dart new file mode 100644 index 00000000..a74b873c --- /dev/null +++ b/flutter-app/lib/features/profile/presentation/widgets/profile_page/learning_stats_section.dart @@ -0,0 +1,225 @@ +import 'package:flutter/material.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:lexilingo_app/features/profile/presentation/pages/learning_stats_pages.dart'; +import 'package:lexilingo_app/features/profile/presentation/providers/profile_provider.dart'; +import 'package:lexilingo_app/features/profile/presentation/widgets/profile_ui_components.dart'; +import 'package:provider/provider.dart'; +import 'package:lexilingo_app/core/theme/app_theme.dart'; +import 'package:lexilingo_app/core/widgets/skeleton_loading.dart'; + +class LearningStatsSection extends StatelessWidget { + const LearningStatsSection({super.key}); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, profileProvider, child) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final primaryColor = AppColorRoles.primary(isDark); + // Use stats from ProfileProvider (backend API) + final stats = profileProvider.stats; + final lessonsCompleted = stats?.totalLessonsCompleted ?? 0; + final coursesCompleted = stats?.totalCoursesCompleted ?? 0; + final vocabularyMastered = stats?.totalVocabularyMastered ?? 0; + final testsPassed = stats?.totalTestsPassed ?? 0; + final avgScore = stats?.averageTestScore ?? 0.0; + + // Show loading state + if (profileProvider.isLoadingStats && stats == null) { + return const _LoadingStats(); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + Icon(Icons.insights_rounded, color: primaryColor, size: 20), + const SizedBox(width: 8), + Text( + 'profile.learningStats'.tr(), + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + Row( + children: [ + Expanded( + child: GlassmorphicStatCard( + icon: Icons.abc, + color: primaryColor, + title: 'profile.lessons'.tr(), + value: '$lessonsCompleted', + subtitle: 'profile.completed'.tr(), + valueInRightCircle: true, + valueCircleSize: 40, + valueCircleFontSize: 18, + isAction: true, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const LearningLessonsStatsPage(), + ), + ), + iconBoxSize: 28, + iconSize: 14, + titleFontSize: 13, + subtitleFontSize: 11, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: GlassmorphicStatCard( + icon: Icons.school, + color: primaryColor, + title: 'profile.courses'.tr(), + value: '$coursesCompleted', + subtitle: 'profile.finished'.tr(), + valueInRightCircle: true, + valueCircleSize: 40, + valueCircleFontSize: 18, + isAction: true, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const LearningCoursesStatsPage(), + ), + ), + iconBoxSize: 28, + iconSize: 14, + titleFontSize: 13, + subtitleFontSize: 11, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + ), + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: GlassmorphicStatCard( + icon: Icons.auto_stories, + color: primaryColor, + title: 'profile.vocabulary'.tr(), + value: '$vocabularyMastered', + subtitle: 'profile.mastered'.tr(), + valueInRightCircle: true, + valueCircleSize: 40, + valueCircleFontSize: 18, + isAction: true, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => + const LearningVocabularyStatsPage(), + ), + ), + iconBoxSize: 28, + iconSize: 14, + titleFontSize: 13, + subtitleFontSize: 11, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: GlassmorphicStatCard( + icon: Icons.quiz, + color: primaryColor, + title: 'profile.tests'.tr(), + value: '$testsPassed', + subtitle: avgScore > 0 + ? 'profile.averagePercent'.tr( + namedArgs: { + 'percent': avgScore.toStringAsFixed(0), + }, + ) + : 'profile.passed'.tr(), + valueInRightCircle: true, + valueCircleSize: 40, + valueCircleFontSize: 18, + isAction: true, + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const LearningTestsStatsPage(), + ), + ), + iconBoxSize: 28, + iconSize: 14, + titleFontSize: 13, + subtitleFontSize: 11, + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + ), + ), + ], + ), + ], + ), + ), + ], + ); + }, + ); + } +} + +class _LoadingStats extends StatelessWidget { + const _LoadingStats(); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + 'profile.learningStats'.tr(), + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + ), + GridView.count( + crossAxisCount: 2, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 16), + crossAxisSpacing: 12, + mainAxisSpacing: 12, + childAspectRatio: 1.15, + children: List.generate( + 4, + (index) => ShimmerContainer( + child: SkeletonBox(height: double.infinity, borderRadius: 12), + ), + ), + ), + ], + ); + } +} diff --git a/flutter-app/lib/features/profile/presentation/widgets/profile_page/level_progress_card.dart b/flutter-app/lib/features/profile/presentation/widgets/profile_page/level_progress_card.dart new file mode 100644 index 00000000..9ed8b637 --- /dev/null +++ b/flutter-app/lib/features/profile/presentation/widgets/profile_page/level_progress_card.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:lexilingo_app/features/level/level.dart'; +import 'package:lexilingo_app/features/profile/presentation/widgets/profile_ui_components.dart'; +import 'package:provider/provider.dart'; +import 'package:lexilingo_app/core/theme/app_theme.dart'; + +class ProfileLevelProgressCard extends StatelessWidget { + const ProfileLevelProgressCard({super.key}); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return Consumer( + builder: (context, levelProvider, child) { + final level = levelProvider.displayLevel; + final xpIn = levelProvider.displayXpInLevel; + final xpFor = levelProvider.displayXpForNextLevel; + final progress = levelProvider.displayLevelProgress; + + return GlassmorphicContainer( + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'profile.levelRange'.tr( + namedArgs: { + 'level': '$level', + 'nextLevel': '${level + 1}', + }, + ), + style: const TextStyle(fontWeight: FontWeight.w500), + ), + Text( + 'profile.xpProgress'.tr( + namedArgs: {'current': '$xpIn', 'total': '$xpFor'}, + ), + style: const TextStyle(fontSize: 14), + ), + ], + ), + const SizedBox(height: 12), + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: LinearProgressIndicator( + value: progress, + backgroundColor: AppColorRoles.primary( + isDark, + ).withValues(alpha: 0.18), + valueColor: AlwaysStoppedAnimation( + AppColorRoles.primary(isDark), + ), + minHeight: 12, + ), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'profile.xpToNextLevel'.tr( + namedArgs: { + 'xp': '${xpFor - xpIn}', + 'level': '${level + 1}', + }, + ), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 12, + ), + ), + Text( + 'profile.percentComplete'.tr( + namedArgs: { + 'percent': (progress * 100).toStringAsFixed(0), + }, + ), + style: TextStyle( + color: AppColorRoles.primary(isDark), + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ], + ), + ); + }, + ); + } +} diff --git a/flutter-app/lib/features/profile/presentation/widgets/profile_page/profile_header.dart b/flutter-app/lib/features/profile/presentation/widgets/profile_page/profile_header.dart new file mode 100644 index 00000000..6cc8822a --- /dev/null +++ b/flutter-app/lib/features/profile/presentation/widgets/profile_page/profile_header.dart @@ -0,0 +1,543 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:lexilingo_app/features/gamification/gamification.dart'; +import 'package:lexilingo_app/features/level/level.dart'; +import 'package:lexilingo_app/features/profile/presentation/pages/edit_profile_screen.dart'; +import 'package:lexilingo_app/features/profile/presentation/widgets/profile_page/social_stats_row.dart'; +import 'package:lexilingo_app/core/widgets/glassmorphic_components.dart' as glass; +import 'package:lexilingo_app/core/widgets/network_avatar_image.dart'; +import 'package:provider/provider.dart'; +import 'package:lexilingo_app/core/theme/app_theme.dart'; + +String formatMemberSince(BuildContext context, DateTime? createdAt) { + if (createdAt == null) return 'profile.member'.tr(); + return 'profile.memberSince'.tr( + namedArgs: {'date': DateFormat('MMM yyyy').format(createdAt)}, + ); +} + +IconData tierIcon(String tierCode) { + switch (tierCode) { + case 'A1': + return Icons.eco; + case 'A2': + return Icons.spa; + case 'B1': + return Icons.bolt; + case 'B2': + return Icons.rocket_launch; + case 'C1': + return Icons.workspace_premium; + case 'C2': + return Icons.diamond; + default: + return Icons.star; + } +} + +Color tierColor(BuildContext context, String tierCode) { + final isDark = Theme.of(context).brightness == Brightness.dark; + switch (tierCode) { + case 'A1': + return AppColors.greenSuccessBright; + case 'A2': + return AppColors.teal; + case 'B1': + return AppColorRoles.primary(isDark); + case 'B2': + return AppColorRoles.primaryDeep(isDark); + case 'C1': + return AppColors.purple; + case 'C2': + return AppColors.warning; + default: + return AppColorRoles.primary(isDark); + } +} + +class ProfileHeader extends StatelessWidget { + final dynamic user; + final VoidCallback onProfileEdited; + + const ProfileHeader({ + super.key, + required this.user, + required this.onProfileEdited, + }); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final accent = AppColorRoles.primary(isDark); + return Consumer( + builder: (context, levelProvider, child) { + // Use numeric level progress, not CEFR-based + final progress = levelProvider.displayLevelProgress; + + return Container( + margin: const EdgeInsets.all(16), + child: Stack( + children: [ + // Glassmorphic Background Card + ClipRRect( + borderRadius: BorderRadius.circular(24), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + (isDark ? accent : AppColors.primary).withValues( + alpha: 0.15, + ), + (isDark ? accent : AppColors.primary).withValues( + alpha: 0.1, + ), + AppColors.surfaceLight.withValues(alpha: 0.05), + ], + ), + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: Theme.of( + context, + ).colorScheme.surface.withValues(alpha: 0.2), + width: 1.5, + ), + boxShadow: [ + BoxShadow( + color: (isDark ? accent : AppColors.primary) + .withValues(alpha: 0.12), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: Column( + children: [ + // Avatar with animated progress ring + Stack( + alignment: Alignment.center, + children: [ + // Progress Ring + glass.AnimatedProgressRing( + progress: progress, + size: 140, + strokeWidth: 6, + gradientColors: [ + isDark ? accent : AppColors.primary, + isDark ? accent : AppColors.primary, + AppColors.purple, + ], + child: Container( + width: 120, + height: 120, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: Theme.of(context).colorScheme.surface + .withValues(alpha: 0.5), + width: 3, + ), + boxShadow: [ + BoxShadow( + color: + (isDark ? accent : AppColors.primary) + .withValues(alpha: 0.3), + blurRadius: 20, + spreadRadius: 5, + ), + ], + ), + child: ClipOval( + child: NetworkAvatarImage( + imageUrl: user?.avatarUrl, + fit: BoxFit.cover, + width: 120, + height: 120, + fallback: Container( + color: + (isDark ? accent : AppColors.primary) + .withValues(alpha: 0.2), + child: Icon( + Icons.person, + size: 60, + color: isDark + ? accent + : AppColors.primary, + ), + ), + ), + ), + ), + ), + // Verified Badge + if (user?.isVerified == true) + Positioned( + bottom: 10, + right: 10, + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + isDark ? accent : AppColors.primary, + isDark ? accent : AppColors.primary, + ], + ), + shape: BoxShape.circle, + border: Border.all( + color: AppColors.surfaceLight, + width: 2, + ), + boxShadow: [ + BoxShadow( + color: + (isDark + ? accent + : AppColors.primary) + .withValues(alpha: 0.5), + blurRadius: 8, + ), + ], + ), + child: Icon( + Icons.verified, + color: AppColors.surfaceLight, + size: 16, + ), + ), + ), + ], + ), + const SizedBox(height: 20), + // User Name + Text( + user?.displayName ?? 'profile.guestUser'.tr(), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineSmall + ?.copyWith( + fontWeight: FontWeight.bold, + letterSpacing: -0.5, + ), + ), + const SizedBox(height: 6), + // Email + if (user?.email != null) + Text( + user.email, + style: const TextStyle( + color: AppColors.grey600, + fontSize: 13, + ), + ), + const SizedBox(height: 12), + // Level Badge + CEFR Proficiency Badge + Rank Badge + Consumer( + builder: (_, lp, __) { + final rankData = rankVisualDataFor(lp.rank); + final isMasterRank = lp.rank == 'master'; + return Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.center, + children: [ + // Numeric Level badge + Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: AppColorRoles.primaryGradient( + isDark, + ), + ), + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: (isDark + ? accent + : AppColors.primary) + .withValues(alpha: 0.4), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.workspace_premium_rounded, + color: AppColors.surfaceLight, + size: 16, + ), + const SizedBox(width: 6), + Text( + 'profile.level'.tr( + namedArgs: { + 'level': '${lp.displayLevel}', + }, + ), + style: TextStyle( + color: AppColors.surfaceLight, + fontWeight: FontWeight.bold, + fontSize: 13, + ), + ), + ], + ), + ), + // CEFR Proficiency Badge + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: tierColor( + context, + lp.proficiencyLevel, + ).withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: tierColor( + context, + lp.proficiencyLevel, + ).withValues(alpha: 0.5), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + tierIcon(lp.proficiencyLevel), + size: 14, + color: tierColor( + context, + lp.proficiencyLevel, + ), + ), + const SizedBox(width: 4), + Text( + lp.proficiencyLevel, + style: TextStyle( + color: tierColor( + context, + lp.proficiencyLevel, + ), + fontWeight: FontWeight.bold, + fontSize: 13, + ), + ), + const SizedBox(width: 4), + Text( + '·', + style: TextStyle( + color: tierColor( + context, + lp.proficiencyLevel, + ).withValues(alpha: 0.6), + fontSize: 13, + ), + ), + const SizedBox(width: 4), + Text( + lp.proficiencyName, + style: TextStyle( + color: tierColor( + context, + lp.proficiencyLevel, + ), + fontWeight: FontWeight.w500, + fontSize: 12, + ), + ), + ], + ), + ), + // League Rank Badge + GestureDetector( + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => + const LeaderboardScreen(), + ), + ), + child: isMasterRank + ? ShaderMask( + shaderCallback: (bounds) => + const LinearGradient( + colors: [ + Color(0xFF5AB6FF), + Color(0xFFFFD64F), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ).createShader(bounds), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.white.withValues( + alpha: 0.12, + ), + borderRadius: + BorderRadius.circular(20), + border: Border.all( + color: Colors.white.withValues( + alpha: 0.4, + ), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + RankAssetIcon( + rank: lp.rank, + size: 16, + decorated: false, + ), + const SizedBox(width: 5), + Text( + lp.rankName, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ], + ), + ), + ) + : Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: rankData.color.withValues( + alpha: 0.1, + ), + borderRadius: + BorderRadius.circular(20), + border: Border.all( + color: rankData.color.withValues( + alpha: 0.4, + ), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + RankAssetIcon( + rank: lp.rank, + size: 16, + decorated: false, + ), + const SizedBox(width: 5), + Text( + lp.rankName, + style: TextStyle( + color: rankData.color, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ], + ), + ), + ), + ], + ); + }, + ), + const SizedBox(height: 8), + // Member Since + Text( + formatMemberSince(context, user?.createdAt), + style: const TextStyle( + color: AppColors.grey600, + fontSize: 12, + ), + ), + const SizedBox(height: 12), + // Edit Profile Button + GestureDetector( + onTap: () async { + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const EditProfileScreen(), + ), + ); + if (result == true) { + onProfileEdited(); // Refresh after edit + } + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 8, + ), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: AppColorRoles.primaryGradient(isDark), + ), + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: (isDark ? accent : AppColors.primary) + .withValues(alpha: 0.3), + blurRadius: 8, + offset: Offset(0, 4), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.edit, + color: AppColors.surfaceLight, + size: 14, + ), + SizedBox(width: 6), + Text( + 'profile.editProfile'.tr(), + style: TextStyle( + color: AppColors.surfaceLight, + fontWeight: FontWeight.bold, + fontSize: 13, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 20), + // Social Stats Row + const SocialStatsRow(), + ], + ), + ), + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/flutter-app/lib/features/profile/presentation/widgets/profile_page/quick_actions_row.dart b/flutter-app/lib/features/profile/presentation/widgets/profile_page/quick_actions_row.dart new file mode 100644 index 00000000..8bd1f71c --- /dev/null +++ b/flutter-app/lib/features/profile/presentation/widgets/profile_page/quick_actions_row.dart @@ -0,0 +1,131 @@ +import 'package:flutter/material.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:lexilingo_app/features/gamification/gamification.dart'; +import 'package:lexilingo_app/features/progress/presentation/screens/my_progress_screen.dart'; +import 'package:lexilingo_app/features/social/social.dart'; +import 'package:lexilingo_app/core/theme/app_theme.dart'; + +/// Quick Actions Grid - Navigate to new gamification/social features +class QuickActionsRow extends StatelessWidget { + const QuickActionsRow({super.key}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + _QuickActionButton( + icon: Icons.store, + label: 'profile.shop'.tr(), + color: AppColors.orange, + gradient: AppColors.warmGradient, + onTap: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const ShopScreen()), + ), + ), + const SizedBox(width: 12), + _QuickActionButton( + icon: Icons.leaderboard, + label: 'profile.ranks'.tr(), + color: AppColors.greenSuccess, + gradient: AppColors.successGradient, + onTap: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const LeaderboardScreen()), + ), + ), + const SizedBox(width: 12), + _QuickActionButton( + icon: Icons.people, + label: 'profile.friends'.tr(), + color: AppColorRoles.primary( + Theme.of(context).brightness == Brightness.dark, + ), + gradient: Theme.of(context).brightness == Brightness.dark + ? AppColors.primaryGradientDark + : AppColors.indigoGradient, + onTap: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const SocialScreen()), + ), + ), + const SizedBox(width: 12), + _QuickActionButton( + icon: Icons.insights_rounded, + label: 'profile.progress'.tr(), + color: AppColors.purple, + gradient: AppColors.purpleGradient, + onTap: () => Navigator.push( + context, + MaterialPageRoute(builder: (_) => const MyProgressScreen()), + ), + ), + ], + ), + ); + } +} + +class _QuickActionButton extends StatelessWidget { + final IconData icon; + final String label; + final Color color; + final List gradient; + final VoidCallback onTap; + + const _QuickActionButton({ + required this.icon, + required this.label, + required this.color, + required this.gradient, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Expanded( + child: GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: gradient, + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: color.withValues(alpha: 0.3), + blurRadius: 8, + offset: Offset(0, 4), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + color: Theme.of(context).colorScheme.surface, + size: 24, + ), + const SizedBox(height: 4), + Text( + label, + style: TextStyle( + color: Theme.of(context).colorScheme.surface, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/flutter-app/lib/features/profile/presentation/widgets/profile_page/recent_badges_section.dart b/flutter-app/lib/features/profile/presentation/widgets/profile_page/recent_badges_section.dart new file mode 100644 index 00000000..db077da3 --- /dev/null +++ b/flutter-app/lib/features/profile/presentation/widgets/profile_page/recent_badges_section.dart @@ -0,0 +1,275 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:lexilingo_app/features/achievements/data/badge_asset_mapper.dart'; +import 'package:lexilingo_app/features/achievements/domain/entities/achievement_entity.dart'; +import 'package:lexilingo_app/features/achievements/presentation/screens/achievements_screen.dart'; +import 'package:lexilingo_app/features/achievements/presentation/widgets/achievement_widgets.dart'; +import 'package:lexilingo_app/features/profile/presentation/providers/profile_provider.dart'; +import 'package:provider/provider.dart'; +import 'package:lexilingo_app/core/theme/app_theme.dart'; +import 'package:lexilingo_app/core/widgets/skeleton_loading.dart'; + +class RecentBadgesSection extends StatefulWidget { + const RecentBadgesSection({super.key}); + + @override + State createState() => _RecentBadgesSectionState(); +} + +class _RecentBadgesSectionState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _badgeShineController; + + @override + void initState() { + super.initState(); + _badgeShineController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 3000), + )..repeat(); + } + + @override + void dispose() { + _badgeShineController.dispose(); + super.dispose(); + } + + bool _hasRenderableBadgeImage(UserAchievementEntity badge) { + final achievement = badge.achievement; + + if (achievement.category.toLowerCase() == 'xp') { + return false; + } + + final networkBadge = achievement.badgeIcon?.trim(); + final networkBadgeUri = networkBadge != null && networkBadge.isNotEmpty + ? Uri.tryParse(networkBadge) + : null; + final hasValidNetworkBadge = + networkBadgeUri != null && + (networkBadgeUri.scheme == 'http' || + networkBadgeUri.scheme == 'https') && + networkBadgeUri.host.isNotEmpty; + if (hasValidNetworkBadge) { + return true; + } + + final lookupKey = (achievement.slug ?? achievement.id).trim(); + if (lookupKey.isEmpty) { + return false; + } + + return BadgeAssetMapper.hasRenderableBadge(lookupKey); + } + + /// Build empty badges placeholder + Widget _buildEmptyBadges() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.emoji_events_outlined, + size: 40, + color: AppColors.grey500, + ), + const SizedBox(height: 8), + Text( + 'profile.completeLessonsToEarnBadges'.tr(), + style: const TextStyle(color: AppColors.grey600, fontSize: 12), + ), + ], + ), + ); + } + + /// Build badge item from UserAchievementEntity + Widget _buildBadgeItemFromEntity(UserAchievementEntity badge) { + final achievement = badge.achievement; + const badgeSize = 64.0; + final badgeMask = _buildBadgeShineMask(badge, badgeSize); + + return Tooltip( + message: '${achievement.name}\n${badge.unlockedTimeAgo}', + child: Column( + children: [ + SizedBox( + width: badgeSize, + height: badgeSize, + child: Stack( + alignment: Alignment.center, + children: [ + AchievementBadge( + achievement: achievement, + isUnlocked: true, + size: badgeSize, + ), + Positioned.fill( + child: IgnorePointer( + child: AnimatedBuilder( + animation: _badgeShineController, + builder: (context, child) { + // Smaller sweep portion => longer idle pause per cycle. + const sweepPortion = 0.62; + final progress = _badgeShineController.value; + final isSweeping = progress < sweepPortion; + final sweepT = isSweeping + ? Curves.easeInOut.transform( + progress / sweepPortion, + ) + : 1.0; + final position = isSweeping + ? lerpDouble(-1.6, 1.6, sweepT)! + : 2.4; + + return ShaderMask( + // Apply shine only where the badge image has alpha. + blendMode: BlendMode.srcIn, + shaderCallback: (bounds) { + return LinearGradient( + begin: Alignment(position - 0.45, -1.0), + end: Alignment(position + 0.45, 1.0), + colors: [ + AppColors.surfaceLight.withValues(alpha: 0), + AppColors.surfaceLight.withValues(alpha: 0.06), + AppColors.surfaceLight.withValues(alpha: 0.56), + AppColors.surfaceLight.withValues(alpha: 0.06), + AppColors.surfaceLight.withValues(alpha: 0), + ], + stops: const [0.0, 0.42, 0.5, 0.58, 1.0], + ).createShader(bounds); + }, + child: child, + ); + }, + child: badgeMask, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 8), + Text( + achievement.name, + style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w500), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ); + } + + Widget _buildBadgeShineMask(UserAchievementEntity badge, double badgeSize) { + final achievement = badge.achievement; + + final networkBadge = achievement.badgeIcon?.trim(); + final networkBadgeUri = networkBadge != null && networkBadge.isNotEmpty + ? Uri.tryParse(networkBadge) + : null; + final hasValidNetworkBadge = + networkBadgeUri != null && + (networkBadgeUri.scheme == 'http' || + networkBadgeUri.scheme == 'https') && + networkBadgeUri.host.isNotEmpty; + + if (hasValidNetworkBadge) { + return ClipOval( + child: Image.network( + networkBadge!, + width: badgeSize, + height: badgeSize, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const SizedBox.shrink(), + ), + ); + } + + final lookupKey = (achievement.slug ?? achievement.id).trim(); + final assetPath = BadgeAssetMapper.getBadgeAsset(lookupKey); + if (assetPath != null) { + return ClipOval( + child: Image.asset( + assetPath, + width: badgeSize, + height: badgeSize, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const SizedBox.shrink(), + ), + ); + } + + return const SizedBox.shrink(); + } + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, provider, child) { + final badges = provider.recentBadges; + final isLoading = provider.isLoadingBadges; + final visibleBadges = badges.where(_hasRenderableBadgeImage).toList(); + + return Column( + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'profile.recentBadges'.tr(), + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + TextButton( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const AchievementsScreen(), + ), + ); + }, + child: Text('achievements.viewAll'.tr()), + ), + ], + ), + ), + SizedBox( + height: 100, + child: isLoading + ? ShimmerContainer( + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: 4, + separatorBuilder: (_, __) => const SizedBox(width: 16), + itemBuilder: (context, index) { + return const SkeletonCircle(size: 80); + }, + ), + ) + : visibleBadges.isEmpty + ? _buildEmptyBadges() + : ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: visibleBadges.length, + separatorBuilder: (_, __) => const SizedBox(width: 16), + itemBuilder: (context, index) { + final badge = visibleBadges[index]; + return _buildBadgeItemFromEntity(badge); + }, + ), + ), + ], + ); + }, + ); + } +} diff --git a/flutter-app/lib/features/profile/presentation/widgets/profile_page/social_stats_row.dart b/flutter-app/lib/features/profile/presentation/widgets/profile_page/social_stats_row.dart new file mode 100644 index 00000000..81160d2d --- /dev/null +++ b/flutter-app/lib/features/profile/presentation/widgets/profile_page/social_stats_row.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:lexilingo_app/features/profile/presentation/providers/profile_provider.dart'; +import 'package:lexilingo_app/features/profile/presentation/widgets/profile_ui_components.dart'; +import 'package:provider/provider.dart'; +import 'package:lexilingo_app/core/theme/app_theme.dart'; + +/// Social stats row showing XP, followers, following +class SocialStatsRow extends StatelessWidget { + const SocialStatsRow({super.key}); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, profileProvider, _) { + final stats = profileProvider.stats; + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + AnimatedSocialStat( + value: '${stats?.totalVocabularyMastered ?? 0}', + label: 'profile.words'.tr(), + icon: Icons.spellcheck_rounded, + color: AppColors.greenSuccessBright, + ), + Container( + height: 40, + width: 1, + color: AppColors.grey400.withValues(alpha: 0.5), + ), + AnimatedSocialStat( + value: '${stats?.totalLessonsCompleted ?? 0}', + label: 'profile.lessons'.tr(), + icon: Icons.menu_book, + color: AppColorRoles.primary( + Theme.of(context).brightness == Brightness.dark, + ), + ), + Container( + height: 40, + width: 1, + color: AppColors.grey400.withValues(alpha: 0.5), + ), + AnimatedSocialStat( + value: '${stats?.currentStreak ?? 0}', + label: 'profile.dayStreak'.tr(), + icon: Icons.local_fire_department, + color: AppColors.dangerGradient[0], + ), + ], + ); + }, + ); + } +} diff --git a/flutter-app/lib/features/profile/presentation/widgets/profile_page/weekly_activity_section.dart b/flutter-app/lib/features/profile/presentation/widgets/profile_page/weekly_activity_section.dart new file mode 100644 index 00000000..a8f56c2f --- /dev/null +++ b/flutter-app/lib/features/profile/presentation/widgets/profile_page/weekly_activity_section.dart @@ -0,0 +1,710 @@ +import 'package:flutter/material.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:lexilingo_app/features/profile/presentation/providers/profile_provider.dart'; +import 'package:lexilingo_app/features/profile/presentation/widgets/profile_ui_components.dart'; +import 'package:lexilingo_app/features/user/domain/entities/weekly_activity_entity.dart'; +import 'package:provider/provider.dart'; +import 'package:lexilingo_app/core/theme/app_theme.dart'; +import 'package:lexilingo_app/core/widgets/skeleton_loading.dart'; + +void _showWeeklyActivityDetail( + BuildContext context, + List activities, +) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => _WeeklyActivityDetailSheet(activities: activities), + ); +} + +class WeeklyActivitySection extends StatelessWidget { + const WeeklyActivitySection({super.key}); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, profileProvider, child) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final activities = profileProvider.weeklyActivity; + final isLoading = profileProvider.isLoadingActivity; + final activityError = profileProvider.activityError; + + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 16, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'profile.weeklyActivity'.tr(), + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + GestureDetector( + onTap: activities.isNotEmpty + ? () => _showWeeklyActivityDetail(context, activities) + : null, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'profile.last7Days'.tr(), + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + if (activities.isNotEmpty) ...[ + const SizedBox(width: 4), + Icon( + Icons.chevron_right, + size: 16, + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + ], + ], + ), + ), + ], + ), + ), + GlassmorphicContainer( + child: isLoading && activities.isEmpty + ? SizedBox( + width: double.infinity, + child: Padding( + padding: const EdgeInsets.all(24.0), + child: ShimmerContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: 120, + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.end, + children: List.generate( + 7, + (index) => Expanded( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4, + ), + child: SkeletonBox( + height: 20.0 + (index % 3) * 30.0, + borderRadius: 4, + ), + ), + ), + ), + ), + ), + const SizedBox(height: 6), + Row( + children: List.generate( + 7, + (index) => const Expanded( + child: Center( + child: SkeletonBox( + width: 20, + height: 12, + borderRadius: 2, + ), + ), + ), + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: + MainAxisAlignment.spaceAround, + children: List.generate( + 3, + (index) => const Column( + children: [ + SkeletonCircle(size: 20), + SizedBox(height: 4), + SkeletonBox( + width: 40, + height: 16, + borderRadius: 4, + ), + SizedBox(height: 4), + SkeletonBox( + width: 60, + height: 12, + borderRadius: 4, + ), + ], + ), + ), + ), + ], + ), + ), + ), + ) + : activities.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Text( + activityError == null || activityError.isEmpty + ? 'profile.noActivityDataYet'.tr() + : activityError, + style: const TextStyle(color: AppColors.grey600), + textAlign: TextAlign.center, + ), + ), + ) + : Column( + children: [ + // XP Chart with animated bars + SizedBox( + height: 120, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.end, + children: activities.asMap().entries.map((entry) { + final index = entry.key; + final activity = entry.value; + final maxXP = activities + .map((a) => a.xpEarned) + .reduce((a, b) => a > b ? a : b); + final normalizedValue = maxXP > 0 + ? activity.xpEarned / maxXP + : 0.0; + + return Expanded( + child: AnimatedActivityBar( + label: '', + value: normalizedValue, + xpValue: activity.xpEarned, + color: AppColorRoles.primary(isDark), + delay: Duration(milliseconds: index * 100), + ), + ); + }).toList(), + ), + ), + // Day labels row — separate from bars to ensure alignment + const SizedBox(height: 6), + Row( + children: activities.map((activity) { + String dayLabel; + final parsedDate = DateTime.tryParse(activity.date); + if (parsedDate != null) { + dayLabel = DateFormat( + 'E', + ).format(parsedDate).substring(0, 1); + } else { + final raw = activity.date.trim(); + dayLabel = raw.isEmpty + ? '-' + : raw.substring(0, 1).toUpperCase(); + } + return Expanded( + child: Center( + child: Text( + dayLabel, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: isDark + ? AppColors.grey500 + : AppColors.grey600, + ), + ), + ), + ); + }).toList(), + ), + const SizedBox(height: 16), + // Summary stats + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _ActivityStat( + label: 'profile.totalXp'.tr(), + value: + '${activities.fold(0, (sum, a) => sum + a.xpEarned)}', + icon: Icons.star, + color: AppColors.warning, + ), + _ActivityStat( + label: 'profile.lessons'.tr(), + value: + '${activities.fold(0, (sum, a) => sum + a.lessonsCompleted)}', + icon: Icons.menu_book, + color: AppColorRoles.primary(isDark), + ), + _ActivityStat( + label: 'profile.words'.tr(), + value: + '${activities.fold(0, (sum, a) => sum + a.vocabularyLearned)}', + icon: Icons.abc, + color: AppColors.greenSuccessBright, + ), + ], + ), + ], + ), + ), + ], + ); + }, + ); + } +} + +class _ActivityStat extends StatelessWidget { + final String label; + final String value; + final IconData icon; + final Color color; + + const _ActivityStat({ + required this.label, + required this.value, + required this.icon, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Icon(icon, size: 20, color: color), + const SizedBox(height: 4), + Text( + value, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + Text( + label, + style: const TextStyle(fontSize: 11, color: AppColors.grey600), + ), + ], + ); + } +} + +class _WeeklyActivityDetailSheet extends StatelessWidget { + final List activities; + + const _WeeklyActivityDetailSheet({required this.activities}); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final primary = AppColorRoles.primary(isDark); + + final totalXP = activities.fold(0, (s, a) => s + a.xpEarned); + final totalLessons = activities.fold( + 0, + (s, a) => s + a.lessonsCompleted, + ); + final totalWords = activities.fold( + 0, + (s, a) => s + a.vocabularyLearned, + ); + final maxXP = activities + .map((a) => a.xpEarned) + .reduce((a, b) => a > b ? a : b); + return DraggableScrollableSheet( + initialChildSize: 0.75, + minChildSize: 0.5, + maxChildSize: 0.95, + expand: false, + builder: (_, scrollController) { + return Container( + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1A1A2E) : Colors.white, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 20, + offset: const Offset(0, -4), + ), + ], + ), + child: Column( + children: [ + // Drag handle + Container( + margin: const EdgeInsets.only(top: 12), + width: 40, + height: 4, + decoration: BoxDecoration( + color: AppColors.grey400, + borderRadius: BorderRadius.circular(2), + ), + ), + // Header + Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'profile.weeklyActivity'.tr(), + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + Text( + 'profile.last7DaysDetailedBreakdown'.tr(), + style: TextStyle( + fontSize: 12, + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + GestureDetector( + onTap: () => Navigator.pop(context), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: isDark + ? Colors.white.withValues(alpha: 0.08) + : Colors.black.withValues(alpha: 0.06), + shape: BoxShape.circle, + ), + child: const Icon(Icons.close, size: 16), + ), + ), + ], + ), + ), + // Summary row + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 8, + ), + child: Row( + children: [ + _SummaryChip( + icon: Icons.star, + color: AppColors.warning, + label: 'profile.xpValue'.tr( + namedArgs: {'xp': '$totalXP'}, + ), + sublabel: 'profile.total'.tr(), + ), + const SizedBox(width: 8), + _SummaryChip( + icon: Icons.menu_book, + color: primary, + label: '$totalLessons', + sublabel: 'profile.lessons'.tr(), + ), + const SizedBox(width: 8), + _SummaryChip( + icon: Icons.abc, + color: AppColors.greenSuccessBright, + label: '$totalWords', + sublabel: 'profile.words'.tr(), + ), + ], + ), + ), + const Divider(height: 1), + // Day list + Expanded( + child: ListView.builder( + controller: scrollController, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: activities.length, + itemBuilder: (context, index) { + final a = activities[index]; + final isBest = a.xpEarned == maxXP && maxXP > 0; + final barFraction = maxXP > 0 ? a.xpEarned / maxXP : 0.0; + + DateTime? parsedDate = DateTime.tryParse(a.date); + final String dayName = parsedDate != null + ? DateFormat('EEEE').format(parsedDate) + : a.date; + final String shortDate = parsedDate != null + ? DateFormat('MMM d').format(parsedDate) + : ''; + + return Container( + margin: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: isBest + ? primary.withValues(alpha: isDark ? 0.15 : 0.08) + : (isDark + ? Colors.white.withValues(alpha: 0.04) + : Colors.black.withValues(alpha: 0.03)), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: isBest + ? primary.withValues(alpha: 0.4) + : Colors.transparent, + width: 1.5, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Row( + children: [ + Text( + dayName, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + if (shortDate.isNotEmpty) ...[ + const SizedBox(width: 6), + Text( + shortDate, + style: TextStyle( + fontSize: 12, + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + if (isBest) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppColors.warning.withValues( + alpha: 0.15, + ), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppColors.warning.withValues( + alpha: 0.5, + ), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.emoji_events, + size: 12, + color: AppColors.warning, + ), + SizedBox(width: 3), + Text( + 'profile.bestDay'.tr(), + style: TextStyle( + fontSize: 11, + color: AppColors.warning, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 10), + // XP bar + if (a.xpEarned > 0) ...[ + Row( + children: [ + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: barFraction, + backgroundColor: primary.withValues( + alpha: 0.12, + ), + valueColor: AlwaysStoppedAnimation( + primary, + ), + minHeight: 6, + ), + ), + ), + const SizedBox(width: 10), + Text( + 'profile.xpValue'.tr( + namedArgs: {'xp': '${a.xpEarned}'}, + ), + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: primary, + ), + ), + ], + ), + const SizedBox(height: 10), + ], + // Stats chips + Row( + children: [ + if (a.xpEarned == 0) + Text( + 'profile.noActivity'.tr(), + style: TextStyle( + fontSize: 12, + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + ) + else ...[ + _MiniStat( + icon: Icons.menu_book, + color: primary, + value: 'profile.lessonsCount'.tr( + namedArgs: { + 'count': '${a.lessonsCompleted}', + }, + ), + ), + const SizedBox(width: 12), + _MiniStat( + icon: Icons.abc, + color: AppColors.greenSuccessBright, + value: 'profile.wordsCount'.tr( + namedArgs: { + 'count': '${a.vocabularyLearned}', + }, + ), + ), + ], + ], + ), + ], + ), + ); + }, + ), + ), + // Bottom safe area + SizedBox(height: MediaQuery.of(context).padding.bottom + 16), + ], + ), + ); + }, + ); + } +} + +class _SummaryChip extends StatelessWidget { + final IconData icon; + final Color color; + final String label; + final String sublabel; + + const _SummaryChip({ + required this.icon, + required this.color, + required this.label, + required this.sublabel, + }); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return Expanded( + child: Container( + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12), + decoration: BoxDecoration( + color: color.withValues(alpha: isDark ? 0.12 : 0.08), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color.withValues(alpha: 0.25)), + ), + child: Row( + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 6), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: color, + ), + ), + Text( + sublabel, + style: TextStyle( + fontSize: 10, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _MiniStat extends StatelessWidget { + final IconData icon; + final Color color; + final String value; + + const _MiniStat({ + required this.icon, + required this.color, + required this.value, + }); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: color), + const SizedBox(width: 4), + Text( + value, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ); + } +} From 48fea7d435d5aaffde1459c8f0afa9cf9db058f9 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Huu <2001230909@hufi.edu.vn> Date: Wed, 24 Jun 2026 11:27:45 +0700 Subject: [PATCH 09/20] refactor(flutter): split home_page.dart into section widgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _HomePageNewState had grown to 2031 lines. Extracts header, streak card, level/daily-goal row, quick actions, enrolled/featured courses, and the skeleton loading state into widgets under presentation/widgets/home_page/, dropping home_page.dart from 2159 to 222 lines. Also deletes _buildQuickStats and _buildContinueSection — both already carried `// ignore: unused_element` and had zero callers anywhere in the codebase. flutter analyze clean. Co-Authored-By: Claude Sonnet 4.6 --- .../home/presentation/pages/home_page.dart | 1982 +---------------- .../home_page/course_level_helpers.dart | 37 + .../home_page/enrolled_courses_section.dart | 297 +++ .../home_page/featured_courses_section.dart | 493 ++++ .../widgets/home_page/home_header.dart | 43 + .../home_page/home_skeleton_loading.dart | 72 + .../home_page/level_and_daily_goal_row.dart | 604 +++++ .../widgets/home_page/quick_actions_grid.dart | 214 ++ .../widgets/home_page/section_title.dart | 24 + .../home_page/streak_card_section.dart | 40 + 10 files changed, 1851 insertions(+), 1955 deletions(-) create mode 100644 flutter-app/lib/features/home/presentation/widgets/home_page/course_level_helpers.dart create mode 100644 flutter-app/lib/features/home/presentation/widgets/home_page/enrolled_courses_section.dart create mode 100644 flutter-app/lib/features/home/presentation/widgets/home_page/featured_courses_section.dart create mode 100644 flutter-app/lib/features/home/presentation/widgets/home_page/home_header.dart create mode 100644 flutter-app/lib/features/home/presentation/widgets/home_page/home_skeleton_loading.dart create mode 100644 flutter-app/lib/features/home/presentation/widgets/home_page/level_and_daily_goal_row.dart create mode 100644 flutter-app/lib/features/home/presentation/widgets/home_page/quick_actions_grid.dart create mode 100644 flutter-app/lib/features/home/presentation/widgets/home_page/section_title.dart create mode 100644 flutter-app/lib/features/home/presentation/widgets/home_page/streak_card_section.dart diff --git a/flutter-app/lib/features/home/presentation/pages/home_page.dart b/flutter-app/lib/features/home/presentation/pages/home_page.dart index dbb8a0dd..df09a958 100644 --- a/flutter-app/lib/features/home/presentation/pages/home_page.dart +++ b/flutter-app/lib/features/home/presentation/pages/home_page.dart @@ -1,31 +1,25 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:lexilingo_app/core/theme/app_theme.dart'; import 'package:lexilingo_app/core/widgets/widgets.dart'; -import 'package:lexilingo_app/core/widgets/glassmorphic_components.dart' - as glass; import 'package:lexilingo_app/features/home/presentation/providers/home_provider.dart'; -import 'package:lexilingo_app/features/home/presentation/widgets/home_ui_components.dart'; +import 'package:lexilingo_app/features/home/presentation/widgets/home_page/enrolled_courses_section.dart'; +import 'package:lexilingo_app/features/home/presentation/widgets/home_page/featured_courses_section.dart'; +import 'package:lexilingo_app/features/home/presentation/widgets/home_page/home_header.dart'; +import 'package:lexilingo_app/features/home/presentation/widgets/home_page/home_skeleton_loading.dart'; +import 'package:lexilingo_app/features/home/presentation/widgets/home_page/level_and_daily_goal_row.dart'; +import 'package:lexilingo_app/features/home/presentation/widgets/home_page/quick_actions_grid.dart'; +import 'package:lexilingo_app/features/home/presentation/widgets/home_page/section_title.dart'; +import 'package:lexilingo_app/features/home/presentation/widgets/home_page/streak_card_section.dart'; import 'package:lexilingo_app/features/user/presentation/providers/user_provider.dart'; import 'package:lexilingo_app/features/auth/presentation/providers/auth_provider.dart'; -import 'package:lexilingo_app/features/course/domain/entities/course_entity.dart'; -import 'package:lexilingo_app/features/course/presentation/screens/course_detail_screen.dart'; -import 'package:lexilingo_app/features/course/presentation/utils/course_thumbnail_resolver.dart'; -import 'package:lexilingo_app/features/vocabulary/presentation/pages/vocab_library_page.dart'; import 'package:lexilingo_app/features/vocabulary/presentation/widgets/daily_review_card.dart'; import 'package:lexilingo_app/features/progress/presentation/providers/streak_provider.dart'; -import 'package:lexilingo_app/features/progress/presentation/widgets/points_calendar_dialog.dart'; import 'package:lexilingo_app/features/progress/presentation/widgets/daily_challenges_widget.dart'; import 'package:lexilingo_app/features/progress/presentation/widgets/daily_reward_dialog.dart'; import 'package:lexilingo_app/features/level/level.dart'; import 'package:lexilingo_app/features/games/presentation/widgets/level_up_dialog.dart'; import 'package:lexilingo_app/features/gamification/presentation/widgets/rank_up_dialog.dart'; -import 'package:lexilingo_app/features/notifications/presentation/providers/notification_provider.dart'; -import 'package:lexilingo_app/features/notifications/presentation/pages/notifications_page.dart'; -import 'package:lexilingo_app/features/books/presentation/providers/book_provider.dart'; -import 'package:lexilingo_app/features/user/presentation/providers/settings_provider.dart'; import 'package:lexilingo_app/features/vocabulary/presentation/widgets/word_of_day_card.dart'; import 'package:lexilingo_app/features/gamification/presentation/widgets/active_boosts_bar.dart'; @@ -58,11 +52,11 @@ class _HomePageNewState extends State { }); // Listen for level-up events triggered by fetchLevelFull _levelProvider?.addListener(_onLevelProviderChange); - + // Setup streak provider listener _streakProvider = context.read(); _streakProvider?.addListener(_onStreakProviderChange); - + // Load streak data here (after auth token is ready) instead of relying // on the race-prone call in main.dart that fires before authentication. _streakProvider?.loadStreak(); @@ -138,7 +132,7 @@ class _HomePageNewState extends State { builder: (context, homeProvider, userProvider, authProvider, child) { if (homeProvider.isLoading && homeProvider.featuredCourses.isEmpty) { - return _buildSkeletonLoading(); + return const HomeSkeletonLoading(); } if (homeProvider.errorMessage != null) { @@ -160,22 +154,22 @@ class _HomePageNewState extends State { itemDuration: const Duration(milliseconds: 460), slideOffset: 22, children: [ - Align( + const Align( alignment: Alignment.centerLeft, - child: _buildHeader(context, homeProvider, authProvider), + child: HomeHeader(), ), - Padding( - padding: const EdgeInsets.only(top: 12), - child: _buildStreakCard(context, homeProvider), + const Padding( + padding: EdgeInsets.only(top: 12), + child: StreakCardSection(), ), Padding( padding: const EdgeInsets.only(top: 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildSectionTitle(context, 'home.quickActions'.tr()), + SectionTitle(title: 'home.quickActions'.tr()), const SizedBox(height: 8), - _buildQuickActionsHorizontal(context), + const QuickActionsGrid(), ], ), ), @@ -187,9 +181,9 @@ class _HomePageNewState extends State { padding: EdgeInsets.only(top: 12), child: WordOfDayCard(), ), - Padding( - padding: const EdgeInsets.only(top: 12), - child: _buildLevelAndDailyGoalRow(context, homeProvider), + const Padding( + padding: EdgeInsets.only(top: 12), + child: LevelAndDailyGoalRow(), ), const Padding( padding: EdgeInsets.fromLTRB(16, 12, 16, 0), @@ -204,12 +198,11 @@ class _HomePageNewState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildSectionTitle( - context, - 'home.continueLearningSection'.tr(), + SectionTitle( + title: 'home.continueLearningSection'.tr(), ), const SizedBox(height: 8), - _buildEnrolledCoursesSection(context, homeProvider), + const EnrolledCoursesSection(), ], ), ), @@ -218,1942 +211,21 @@ class _HomePageNewState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildSectionTitle( - context, - 'home.featuredCourses'.tr(), + SectionTitle( + title: 'home.featuredCourses'.tr(), ), const SizedBox(height: 8), - _buildFeaturedCourses(context, homeProvider), - ], - ), - ), - ], - ), - ), - ); - }, - ), - ), - ); - } - - Widget _buildHeader( - BuildContext context, - HomeProvider homeProvider, - AuthProvider authProvider, - ) { - // Get user display name from AuthProvider - final user = authProvider.currentUser; - final displayName = user?.displayName.isNotEmpty == true - ? user!.displayName - : user?.username ?? 'profile.guestUser'.tr(); - - return Consumer2( - builder: (context, notificationProvider, levelProvider, child) { - final totalXP = levelProvider.levelStatus.totalXP; - return PersonalizedGreetingHeader( - userName: displayName, - totalXP: totalXP, - avatarUrl: user?.avatarUrl, - notificationCount: notificationProvider.unreadCount, - onNotificationTap: () { - Navigator.push( - context, - MaterialPageRoute(builder: (_) => const NotificationsPage()), - ); - }, - onAvatarTap: () { - // Navigate to profile or settings - }, - ); - }, - ); - } - - Widget _buildStreakCard(BuildContext context, HomeProvider provider) { - return Consumer( - builder: (context, streakProvider, child) { - final streak = streakProvider.streak; - final currentStreak = streak?.currentStreak ?? provider.streakDays; - final longestStreak = streak?.longestStreak ?? 0; - final isActiveToday = streak?.isActiveToday ?? false; - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: AnimatedStreakCard( - streakDays: currentStreak, - longestStreak: longestStreak, - isActiveToday: isActiveToday, - weeklyActivity: streak?.weeklyActivity, - weeklyProgressPercentages: provider.weeklyProgress.weekProgress - .map((day) => day.progressPercentage) - .toList(growable: false), - onTap: () { - if (streak != null) { - showPointsCalendarDialog(context, streak); - } - }, - ), - ); - }, - ); - } - - Widget _buildLevelAndDailyGoalRow( - BuildContext context, - HomeProvider provider, - ) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: LayoutBuilder( - builder: (context, constraints) { - final isNarrow = constraints.maxWidth < 360; - - if (isNarrow) { - return Column( - children: [ - SizedBox(height: 148, child: LevelProgressCard(compact: true)), - const SizedBox(height: 12), - SizedBox( - height: 148, - child: _buildDailyGoalCard( - context, - provider, - compact: true, - margin: EdgeInsets.zero, - ), - ), - ], - ); - } - - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - flex: 6, - child: SizedBox( - height: 148, - child: LevelProgressCard(compact: true), - ), - ), - const SizedBox(width: 12), - Expanded( - flex: 4, - child: SizedBox( - height: 148, - child: _buildDailyGoalCard( - context, - provider, - compact: true, - margin: EdgeInsets.zero, - ), - ), - ), - ], - ); - }, - ), - ); - } - - Widget _buildDailyGoalCard( - BuildContext context, - HomeProvider provider, { - bool compact = false, - EdgeInsetsGeometry? margin, - }) { - final isDark = Theme.of(context).brightness == Brightness.dark; - // Use user-configured daily goal from settings; fall back to HomeProvider. - final goalXP = context.read().dailyGoalXP; - final earnedXP = provider.dailyXP; - final percentage = goalXP > 0 ? (earnedXP / goalXP).clamp(0.0, 1.0) : 0.0; - final isCompleted = percentage >= 1.0; - final colorScheme = Theme.of(context).colorScheme; - final accent = AppColorRoles.primary(isDark); - final accentDeep = AppColorRoles.primaryDeep(isDark); - final surfaceBg = colorScheme.surfaceContainerHighest; - final compactBg = isCompleted - ? AppColors.greenSuccessBright.withValues(alpha: 0.10) - : accent.withValues(alpha: 0.07); - final cardPadding = compact ? 14.0 : 20.0; - final ringSize = compact ? 58.0 : 70.0; - final ringStroke = compact ? 5.0 : 6.0; - final titleFontSize = compact ? 16.0 : null; - final valueFontSize = compact ? 18.0 : null; - final chipFontSize = compact ? 11.0 : 12.0; - final badgeIconSize = compact ? 16.0 : 18.0; - final ringValueFontSize = compact ? 12.0 : 14.0; - - final borderRadius = BorderRadius.circular(compact ? 16 : 20); - - return Material( - color: Colors.transparent, - child: Padding( - padding: margin ?? const EdgeInsets.symmetric(horizontal: 16), - child: InkWell( - onTap: () => _showDailyGoalSheet(context, provider), - borderRadius: borderRadius, - child: Ink( - padding: EdgeInsets.all(cardPadding), - decoration: BoxDecoration( - color: compact ? compactBg : surfaceBg, - borderRadius: borderRadius, - border: Border.all( - color: isCompleted - ? AppColors.greenSuccessBright.withValues(alpha: 0.3) - : accent.withValues(alpha: 0.35), - ), - ), - child: compact - ? Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - padding: const EdgeInsets.all(5), - decoration: BoxDecoration( - color: colorScheme.surface, - borderRadius: BorderRadius.circular(8), - ), - child: isCompleted - ? AppGameIcon( - GameIcon.trophy, - size: badgeIconSize, - fallbackColor: AppColors.greenSuccessBright, - ) - : AppGameIcon( - GameIcon.bolt, - size: badgeIconSize, - fallbackColor: accent, - ), - ), - const SizedBox(width: 8), - Flexible( - child: Text( - 'home.dailyGoal'.tr(), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall - ?.copyWith( - fontWeight: FontWeight.bold, - fontSize: 15, - color: isCompleted - ? AppColors.greenSuccess - : accentDeep, - ), - ), - ), - ], - ), - const SizedBox(height: 10), - Expanded( - child: Center( - child: Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - color: colorScheme.surface, - ), - child: glass.AnimatedProgressRing( - progress: percentage.clamp(0.0, 1.0), - size: ringSize + 10, - strokeWidth: ringStroke, - gradientColors: isCompleted - ? const [ - AppColors.greenSuccessBright, - AppColors.greenSuccess, - ] - : AppColorRoles.primaryGradient(isDark), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (isCompleted) - const AppGameIcon( - GameIcon.checkmark, - size: 20, - fallbackColor: - AppColors.greenSuccessBright, - ) - else - Text( - '${(percentage * 100).toInt()}%', - style: TextStyle( - fontSize: ringValueFontSize, - fontWeight: FontWeight.bold, - color: accent, - ), - ), - ], - ), - ), - ), - ), - ), - const SizedBox(height: 8), - Text( - '$earnedXP/$goalXP XP', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleMedium - ?.copyWith( - fontWeight: FontWeight.bold, - fontSize: 16, - color: isCompleted - ? AppColors.greenSuccessBright - : accent, - ), - ), - ], - ) - : Row( - children: [ - // Animated Progress Ring - Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - shape: BoxShape.circle, - color: colorScheme.surface, - ), - child: glass.AnimatedProgressRing( - progress: percentage.clamp(0.0, 1.0), - size: ringSize, - strokeWidth: ringStroke, - gradientColors: isCompleted - ? const [ - AppColors.greenSuccessBright, - AppColors.greenSuccess, - ] - : const [AppColors.primary, AppColors.primary], - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (isCompleted) - const AppGameIcon( - GameIcon.checkmark, - size: 22, - fallbackColor: AppColors.greenSuccessBright, - ) - else - Text( - '${(percentage * 100).toInt()}%', - style: TextStyle( - fontSize: ringValueFontSize, - fontWeight: FontWeight.bold, - color: isCompleted - ? AppColors.greenSuccessBright - : AppColors.primary, - ), - ), - ], - ), - ), - ), - SizedBox(width: compact ? 12 : 20), - // Info - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - padding: EdgeInsets.all(compact ? 5 : 6), - decoration: BoxDecoration( - color: colorScheme.surface, - borderRadius: BorderRadius.circular(8), - ), - child: isCompleted - ? AppGameIcon( - GameIcon.trophy, - size: badgeIconSize, - fallbackColor: - AppColors.greenSuccessBright, - ) - : AppGameIcon( - GameIcon.bolt, - size: badgeIconSize, - fallbackColor: accent, - ), - ), - const SizedBox(width: 8), - Expanded( - child: Text( - 'home.dailyXpGoal'.tr(), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context) - .textTheme - .titleMedium - ?.copyWith( - fontWeight: FontWeight.bold, - fontSize: titleFontSize, - color: isCompleted - ? AppColors.greenSuccess - : accentDeep, - ), - ), - ), - ], - ), - SizedBox(height: compact ? 6 : 8), - Text( - '$earnedXP/$goalXP XP', - style: Theme.of(context).textTheme.headlineSmall - ?.copyWith( - fontWeight: FontWeight.bold, - fontSize: valueFontSize, - color: isCompleted - ? AppColors.greenSuccessBright - : accent, - ), - ), - SizedBox(height: compact ? 2 : 4), - Container( - padding: EdgeInsets.symmetric( - horizontal: compact ? 8 : 10, - vertical: compact ? 3 : 4, - ), - decoration: BoxDecoration( - color: colorScheme.surface, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - isCompleted - ? AppGameIcon( - GameIcon.giftBox, - size: 14, - fallbackColor: - AppColors.greenSuccessBright, - ) - : Icon( - Icons.trending_up, - size: 14, - color: accent, - ), - const SizedBox(width: 4), - Text( - isCompleted - ? 'home.goalCompleted'.tr() - : 'home.keepGoing'.tr(), - style: TextStyle( - fontSize: chipFontSize, - fontWeight: FontWeight.w600, - color: isCompleted - ? AppColors.greenSuccessBright - : accent, - ), - ), - ], - ), - ), - ], - ), - ), - ], - ), - ), - ), - ), - ); - } - - Future _showDailyGoalSheet( - BuildContext context, - HomeProvider homeProvider, - ) async { - final settings = context.read(); - final settingsState = settings.settings; - - if (settingsState == null) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('home.dailyGoalSettingsPending'.tr())), - ); - return; - } - - await showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (sheetContext) { - final isDark = Theme.of(sheetContext).brightness == Brightness.dark; - final colorScheme = Theme.of(sheetContext).colorScheme; - final primaryColor = AppColorRoles.primary(isDark); - - return Consumer( - builder: (sheetContext, settingsProvider, _) { - return Container( - padding: const EdgeInsets.fromLTRB(24, 12, 24, 32), - decoration: BoxDecoration( - color: Theme.of(sheetContext).scaffoldBackgroundColor, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(24), - ), - ), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: Container( - width: 40, - height: 4, - margin: const EdgeInsets.only(bottom: 20), - decoration: BoxDecoration( - color: colorScheme.outlineVariant, - borderRadius: BorderRadius.circular(999), - ), - ), - ), - Text( - 'home.dailyGoal'.tr(), - style: Theme.of(sheetContext).textTheme.headlineSmall - ?.copyWith(fontWeight: FontWeight.w800), - ), - const SizedBox(height: 6), - Text( - 'home.dailyGoalDescription'.tr(), - style: Theme.of(sheetContext).textTheme.bodyMedium - ?.copyWith(color: AppColorRoles.textMuted(isDark)), - ), - const SizedBox(height: 16), - Container( - width: double.infinity, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - primaryColor, - primaryColor.withValues(alpha: 0.72), - ], - ), - borderRadius: BorderRadius.circular(18), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '${homeProvider.dailyXP}/${settingsProvider.dailyGoalXP} XP', - style: Theme.of(sheetContext) - .textTheme - .headlineSmall - ?.copyWith( - color: colorScheme.onPrimary, - fontWeight: FontWeight.w800, - ), - ), - const SizedBox(height: 4), - Text( - 'settings.goal_progress'.tr( - namedArgs: { - 'percent': - '${(homeProvider.dailyProgressPercentage * 100).toInt()}', - }, - ), - style: Theme.of(sheetContext).textTheme.bodyMedium - ?.copyWith( - color: colorScheme.onPrimary.withValues( - alpha: 0.88, - ), - ), - ), + const FeaturedCoursesSection(), ], ), ), - const SizedBox(height: 16), - ...SettingsProvider.dailyGoalPresets.map((goal) { - final xp = goal['xp'] as int; - final isSelected = settingsProvider.dailyGoalXP == xp; - - return Padding( - padding: const EdgeInsets.only(bottom: 10), - child: InkWell( - borderRadius: BorderRadius.circular(16), - onTap: () async { - await settingsProvider.updateDailyGoal(xp); - if (!sheetContext.mounted) return; - - if (settingsProvider.error == null) { - homeProvider.updateDailyGoalTarget(xp); - Navigator.of(sheetContext).pop(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'settings.goal_updated'.tr( - namedArgs: {'xp': '$xp'}, - ), - ), - ), - ); - } else { - ScaffoldMessenger.of(sheetContext).showSnackBar( - SnackBar( - content: Text(settingsProvider.error!), - ), - ); - } - }, - child: AnimatedContainer( - duration: const Duration(milliseconds: 180), - padding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 14, - ), - decoration: BoxDecoration( - color: isSelected - ? primaryColor.withValues(alpha: 0.12) - : colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: isSelected - ? primaryColor - : colorScheme.outlineVariant, - width: isSelected ? 2 : 1, - ), - ), - child: Row( - children: [ - Container( - width: 42, - height: 42, - decoration: BoxDecoration( - color: isSelected - ? primaryColor - : primaryColor.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - goal['icon'] as IconData, - color: isSelected - ? colorScheme.onPrimary - : primaryColor, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - '${(goal['label'] as String).tr()} • $xp XP', - style: Theme.of(sheetContext) - .textTheme - .titleMedium - ?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 2), - Text( - (goal['description'] as String).tr(), - style: Theme.of(sheetContext) - .textTheme - .bodySmall - ?.copyWith( - color: AppColorRoles.textMuted( - isDark, - ), - ), - ), - ], - ), - ), - if (isSelected) - AppGameIcon( - GameIcon.checkmark, - size: 24, - fallbackColor: primaryColor, - ), - ], - ), - ), - ), - ); - }), ], ), ), ); }, - ); - }, - ); - } - - Widget _buildEnrolledCoursesSection( - BuildContext context, - HomeProvider provider, - ) { - // Show loading state if courses are being loaded - if (provider.isLoading && provider.enrolledCourses.isEmpty) { - return SizedBox( - height: 126, // Slightly increased to match card content height - child: ListView.builder( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 16), - itemCount: 2, - itemBuilder: (context, index) { - return Container( - margin: const EdgeInsets.only(right: 16), - width: 240, // Reduced from 296 roughly - child: const CardSkeleton(isHorizontal: true), - ); - }, - ), - ); - } - - // Show empty state if no enrolled courses - if (provider.enrolledCourses.isEmpty) { - final isDark = Theme.of(context).brightness == Brightness.dark; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: isDark ? AppColors.surfaceDarkMuted : AppColors.surfaceLight, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: isDark - ? AppColors.surfaceDarkElevated - : AppColors.slate200, - ), - ), - child: Column( - children: [ - AppGameIcon( - GameIcon.lessonBoard, - size: 48, - fallbackColor: isDark - ? AppColors.textMuted - : AppColors.textGrey, - ), - const SizedBox(height: 12), - Text( - 'home.noEnrolledCoursesYet'.tr(), - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - color: isDark ? AppColors.textInverted : AppColors.textDark, - ), - ), - const SizedBox(height: 8), - Text( - 'home.enrollCoursePrompt'.tr(), - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: isDark ? AppColors.textMuted : AppColors.textGrey, - ), - textAlign: TextAlign.center, - ), - ], - ), - ), - ); - } - - // Show enrolled courses - return _buildEnrolledCourses(context, provider); - } - - Widget _buildEnrolledCourses(BuildContext context, HomeProvider provider) { - return SizedBox( - height: 136, - child: ListView.builder( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 16), - itemCount: provider.enrolledCourses.length, - itemBuilder: (context, index) { - final course = provider.enrolledCourses[index]; - // Staggered animation for enrolled courses - return AnimatedListItem( - index: index, - duration: const Duration(milliseconds: 300), - delayPerItem: const Duration(milliseconds: 80), - child: _buildEnrolledCourseCard(context, course), - ); - }, - ), - ); - } - - Widget _buildEnrolledCourseCard(BuildContext context, CourseEntity course) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final progress = course.userProgress ?? 0; - final progressColor = progress >= 80 - ? AppColors.greenSuccessBright - : progress >= 50 - ? AppColors.orange - : AppColorRoles.primary(isDark); - final colorScheme = Theme.of(context).colorScheme; - final surfaceBg = colorScheme.surfaceContainerHighest; - - return GestureDetector( - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => CourseDetailScreen(courseId: course.id), - ), - ); - }, - child: Container( - width: 240, - margin: const EdgeInsets.only(right: 16), - decoration: BoxDecoration( - color: surfaceBg, - borderRadius: BorderRadius.circular(20), - border: Border.all(color: progressColor.withValues(alpha: 0.3)), - ), - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - // Course thumbnail - Container( - width: 80, - height: 80, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: colorScheme.surface, - image: course.thumbnailUrl != null - ? DecorationImage( - image: NetworkImage(course.thumbnailUrl!), - fit: BoxFit.cover, - ) - : null, - border: Border.all(color: colorScheme.outlineVariant), - ), - child: course.thumbnailUrl == null - ? AppGameIcon( - GameIcon.lessonBoard, - size: 32, - fallbackColor: progressColor, - ) - : null, - ), - const SizedBox(width: 16), - // Info section - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - course.title, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - fontSize: 14, - height: 1.2, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), - Row( - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 2, - ), - decoration: BoxDecoration( - color: progressColor.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - _localizedCourseLevel(course.level), - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: progressColor, - ), - ), - ), - const SizedBox(width: 8), - Text( - 'profile.lessonsCount'.tr( - namedArgs: {'count': '${course.totalLessons}'}, - ), - style: Theme.of(context).textTheme.bodySmall - ?.copyWith( - color: AppColorRoles.textSecondary(isDark), - ), - ), - ], - ), - const SizedBox(height: 8), - // Progress bar - Stack( - children: [ - Container( - height: 6, - decoration: BoxDecoration( - color: progressColor.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(3), - ), - ), - FractionallySizedBox( - widthFactor: progress / 100, - child: Container( - height: 6, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - progressColor, - progressColor.withValues(alpha: 0.7), - ], - ), - borderRadius: BorderRadius.circular(3), - ), - ), - ), - ], - ), - const SizedBox(height: 4), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - '${progress.toInt()}%', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: progressColor, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - Container( - padding: const EdgeInsets.all(5), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - progressColor, - progressColor.withValues(alpha: 0.8), - ], - ), - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: progressColor.withValues(alpha: 0.4), - blurRadius: 0, - offset: const Offset(0, 2), - ), - ], - ), - child: AppGameIcon( - GameIcon.playArrow, - size: 16, - fallbackColor: AppColors.surfaceLight, - ), - ), - ], - ), - ], - ), - ), - ], - ), - ), - ), - ); - } - - Widget _buildFeaturedCourses(BuildContext context, HomeProvider provider) { - // Show skeleton loading while courses are loading - if (provider.isLoading && provider.featuredCourses.isEmpty) { - return SizedBox( - height: 220, - child: ListView.builder( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 16), - itemCount: 3, - itemBuilder: (context, index) { - return Container( - width: 240, - margin: const EdgeInsets.only(right: 16), - child: const CardSkeleton(isHorizontal: false), - ); - }, - ), - ); - } - - if (provider.featuredCourses.isEmpty) { - return _buildFeaturedCoursesEmptyState(context, provider); - } - - return SizedBox( - height: 220, - child: ListView.builder( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 16), - itemCount: provider.featuredCourses.length, - itemBuilder: (context, index) { - final course = provider.featuredCourses[index]; - // Staggered animation for featured courses - return AnimatedListItem( - index: index, - duration: const Duration(milliseconds: 400), - delayPerItem: const Duration(milliseconds: 100), - beginOffset: const Offset(0, 60), - child: _buildCourseCard(context, course, provider), - ); - }, - ), - ); - } - - Widget _buildFeaturedCoursesEmptyState( - BuildContext context, - HomeProvider provider, - ) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final primary = AppColorRoles.primary(isDark); - - return SizedBox( - height: 220, - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 16), - padding: const EdgeInsets.all(18), - decoration: BoxDecoration( - color: isDark ? AppColors.surfaceDarkMuted : Colors.white, - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: primary.withValues(alpha: isDark ? 0.22 : 0.16), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: primary.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(12), - ), - child: AppGameIcon( - GameIcon.book, - size: 24, - fallbackColor: primary, - ), - ), - const SizedBox(height: 12), - Text( - 'home.noFeaturedCourses'.tr(), - style: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), - ), - const SizedBox(height: 6), - Text( - 'home.noFeaturedCoursesDescription'.tr(), - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: AppColorRoles.textMuted(isDark), - height: 1.35, - ), - ), - const Spacer(), - Row( - children: [ - FilledButton.icon( - onPressed: () => provider.loadFeaturedCourses(), - icon: const Icon(Icons.refresh_rounded, size: 18), - label: Text('home.reload'.tr()), - ), - const SizedBox(width: 10), - TextButton( - onPressed: () => provider.refreshData(), - child: Text('home.refreshData'.tr()), - ), - ], - ), - ], - ), - ), - ); - } - - Widget _buildCourseCard( - BuildContext context, - CourseEntity course, - HomeProvider provider, - ) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final levelColor = _getLevelColor(course.level, isDark: isDark); - - return GestureDetector( - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => CourseDetailScreen( - courseId: course.id, - heroTag: 'featured-course-image-${course.id}', - initialThumbnailUrl: course.thumbnailUrl, - fallbackThumbnailUrl: courseFallbackThumbnailUrl(course), - ), - ), - ); - }, - child: Container( - width: 240, - margin: const EdgeInsets.only(right: 16), - decoration: BoxDecoration( - color: isDark ? AppColors.surfaceDarkMuted : Colors.white, - borderRadius: BorderRadius.circular(24), - border: Border.all( - color: isDark - ? Colors.white.withValues(alpha: 0.1) - : levelColor.withValues(alpha: 0.25), - width: 1, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Hero animation for course thumbnail - Hero( - tag: 'featured-course-image-${course.id}', - child: ClipRRect( - borderRadius: const BorderRadius.vertical( - top: Radius.circular(24), - ), - child: Stack( - children: [ - Positioned.fill( - child: _buildFeaturedCourseThumbnail( - context, - course, - levelColor, - ), - ), - // Gradient overlay - Container( - height: 110, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - Colors.black.withValues(alpha: 0.6), - ], - ), - ), - ), - // Level badge - top left - Positioned( - top: 12, - left: 12, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - levelColor, - levelColor.withValues(alpha: 0.85), - ], - ), - borderRadius: BorderRadius.circular(20), - boxShadow: [ - BoxShadow( - color: levelColor.withValues(alpha: 0.5), - blurRadius: 8, - offset: const Offset(0, 3), - ), - ], - ), - child: Text( - _localizedCourseLevel(course.level), - style: TextStyle( - color: AppColors.surfaceLight, - fontSize: 12, - fontWeight: FontWeight.bold, - letterSpacing: 0.5, - ), - ), - ), - ), - // XP badge - top right - Positioned( - top: 12, - right: 12, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 5, - ), - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [AppColors.orange, AppColors.orange], - ), - borderRadius: BorderRadius.circular(20), - boxShadow: [ - BoxShadow( - color: const Color( - 0xFFF59E0B, - ).withValues(alpha: 0.4), - blurRadius: 6, - offset: const Offset(0, 2), - ), - ], - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - AppGameIcon( - GameIcon.star, - size: 14, - fallbackColor: AppColors.surfaceLight, - ), - const SizedBox(width: 4), - Text( - 'profile.xpValue'.tr( - namedArgs: {'xp': '${course.totalXp}'}, - ), - style: TextStyle( - color: AppColors.surfaceLight, - fontSize: 11, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ), - // Course title overlay at bottom - Positioned( - bottom: 12, - left: 12, - right: 12, - child: Text( - course.title, - style: TextStyle( - color: AppColors.surfaceLight, - fontSize: 16, - fontWeight: FontWeight.bold, - shadows: [ - Shadow( - color: AppColors.backgroundDark.withValues( - alpha: 0.75, - ), - blurRadius: 4, - ), - ], - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), - ), - // Bottom section with info and action - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 12, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - // Info chips row - Row( - children: [ - _buildInfoChip( - icon: GameIcon.lessonBoard, - label: 'profile.lessonsCount'.tr( - namedArgs: {'count': '${course.totalLessons}'}, - ), - color: AppColorRoles.primary(isDark), - ), - const SizedBox(width: 8), - _buildInfoChip( - icon: GameIcon.translate, - label: course.language, - color: AppColors.purple, - ), - ], - ), - const SizedBox(height: 8), - // Action button - Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(vertical: 10), - decoration: BoxDecoration( - color: levelColor, - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: levelColor.withValues(alpha: 0.6), - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - AppGameIcon( - GameIcon.playArrow, - size: 18, - fallbackColor: AppColors.surfaceLight, - ), - SizedBox(width: 8), - Text( - 'home.startLearning'.tr(), - style: TextStyle( - color: AppColors.surfaceLight, - fontSize: 14, - fontWeight: FontWeight.bold, - letterSpacing: 0.3, - ), - ), - ], - ), - ), - ], - ), - ), - ), - ], - ), - ), - ); - } - - Widget _buildFeaturedCourseThumbnail( - BuildContext context, - CourseEntity course, - Color levelColor, [ - int index = 0, - ]) { - final candidates = buildCourseCardThumbnailCandidates(course); - if (index >= candidates.length) { - return _buildFeaturedCourseThumbnailPlaceholder(context, levelColor); - } - - return CachedNetworkImage( - imageUrl: candidates[index], - fit: BoxFit.cover, - placeholder: (_, __) => - Container(color: levelColor.withValues(alpha: 0.1)), - errorWidget: (_, __, ___) => - _buildFeaturedCourseThumbnail(context, course, levelColor, index + 1), - ); - } - - Widget _buildFeaturedCourseThumbnailPlaceholder( - BuildContext context, - Color levelColor, - ) { - return Container( - color: levelColor.withValues(alpha: 0.1), - alignment: Alignment.center, - child: Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.15), - shape: BoxShape.circle, - border: Border.all( - color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.3), - width: 2, - ), - ), - child: const AppGameIcon( - GameIcon.lessonBoard, - size: 40, - fallbackColor: AppColors.surfaceLight, - ), - ), - ); - } - - Widget _buildInfoChip({ - required GameIcon icon, - required String label, - required Color color, - }) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: color.withValues(alpha: 0.2), width: 1.5), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - AppGameIcon(icon, size: 14, fallbackColor: color), - const SizedBox(width: 5), - Text( - label, - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: color, - ), - ), - ], - ), - ); - } - - Color _getLevelColor(String level, {bool isDark = false}) { - switch (level.toLowerCase()) { - case 'beginner': - return AppColors.greenSuccessBright; - case 'elementary': - return AppColors.greenSuccessSoft; - case 'intermediate': - return AppColors.orange; - case 'upper-intermediate': - return AppColors.orange; - case 'advanced': - return AppColors.dangerGradient[0]; - default: - return AppColorRoles.primary(isDark); - } - } - - String _localizedCourseLevel(String level) { - switch (level.toLowerCase()) { - case 'beginner': - return 'course.difficulty.beginner'.tr(); - case 'elementary': - return 'course.difficulty.elementary'.tr(); - case 'intermediate': - return 'course.difficulty.intermediate'.tr(); - case 'upper-intermediate': - return 'course.difficulty.upperIntermediate'.tr(); - case 'advanced': - return 'course.difficulty.advanced'.tr(); - default: - return level; - } - } - - /// Quick Actions - Horizontal scrollable section with circular buttons - Widget _buildQuickActionsHorizontal(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final accent = AppColorRoles.primary(isDark); - // Keep all dark-mode quick actions in a consistent neon range. - const neonDarkActionColors = [ - Color(0xFFFF3131), // YouTube - Color(0xFFFC1AD3), // News - Color(0xFFFFA319), // Games - Color(0xFF35FF0D), // Podcast - Color(0xFF8B5CFF), // Books (neon purple) - Color(0xFFFF369E), // Vocabulary - ]; - - final quickActions = [ - { - 'icon': Icons.smart_display, - 'label': 'home.youtube', - 'color': isDark ? neonDarkActionColors[0] : AppColors.dangerGradient[0], - 'bgColor': - (isDark ? neonDarkActionColors[0] : AppColors.dangerGradient[0]) - .withValues(alpha: isDark ? 0.16 : 0.1), - 'route': '/youtube', - }, - { - 'icon': GameIcon.newspaper, - 'label': 'home.news', - 'color': isDark ? neonDarkActionColors[1] : AppColors.teal, - 'bgColor': (isDark ? neonDarkActionColors[1] : AppColors.teal) - .withValues(alpha: isDark ? 0.16 : 0.1), - 'route': '/news', - }, - { - 'icon': GameIcon.gameController, - 'label': 'home.games', - 'color': isDark ? neonDarkActionColors[2] : AppColors.purple, - 'bgColor': (isDark ? neonDarkActionColors[2] : AppColors.purple) - .withValues(alpha: isDark ? 0.16 : 0.1), - 'route': '/games', - }, - { - 'icon': GameIcon.headphones, - 'label': 'home.podcast', - 'color': isDark ? neonDarkActionColors[3] : accent, - 'bgColor': (isDark ? neonDarkActionColors[3] : accent).withValues( - alpha: isDark ? 0.16 : 0.12, - ), - 'route': '/podcast', - }, - { - 'icon': GameIcon.book, - 'label': 'home.books', - 'color': isDark ? neonDarkActionColors[4] : AppColors.purpleLight, - 'bgColor': (isDark ? neonDarkActionColors[4] : AppColors.purple) - .withValues(alpha: isDark ? 0.16 : 0.1), - 'route': '/books', - }, - { - 'icon': GameIcon.vocabulary, - 'label': 'home.vocabulary', - 'color': isDark ? neonDarkActionColors[5] : AppColors.orange, - 'bgColor': (isDark ? neonDarkActionColors[5] : AppColors.warning) - .withValues(alpha: isDark ? 0.16 : 0.1), - 'route': '/vocab', - }, - ]; - - const crossAxisCount = 3; - const spacing = 12.0; - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: LayoutBuilder( - builder: (context, constraints) { - final tileWidth = - (constraints.maxWidth - spacing * (crossAxisCount - 1)) / - crossAxisCount; - // Scale the icon (and tile height) with tile width instead of a - // fixed aspect ratio, so narrow screens don't overflow vertically. - final iconSize = (tileWidth * 0.42).clamp(34.0, 44.0); - final tileHeight = iconSize + 56; - - return GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: crossAxisCount, - mainAxisSpacing: spacing, - crossAxisSpacing: spacing, - mainAxisExtent: tileHeight, - ), - itemCount: quickActions.length, - itemBuilder: (context, index) { - final action = quickActions[index]; - return _buildQuickActionChip( - context, - icon: action['icon']!, - label: (action['label'] as String).tr(), - color: action['color'] as Color, - bgColor: action['bgColor'] as Color, - iconSize: iconSize, - onTap: () { - final route = action['route'] as String; - if (route == '/vocab') { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const VocabLibraryPage(), - ), - ); - } else { - Navigator.pushNamed(context, route); - } - }, - ); - }, - ); - }, - ), - ); - } - - Widget _buildQuickActionChip( - BuildContext context, { - required Object icon, - required String label, - required Color color, - required Color bgColor, - required VoidCallback onTap, - double iconSize = 44, - }) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final fontSize = iconSize <= 38 ? 9.0 : 10.0; - final surfaceColor = Theme.of(context).colorScheme.surface; - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), - decoration: BoxDecoration( - color: bgColor, - borderRadius: BorderRadius.circular(18), - border: Border.all( - color: color.withValues(alpha: isDark ? 0.5 : 0.35), - width: 2, - ), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: iconSize, - height: iconSize, - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular(14), - boxShadow: [ - BoxShadow( - color: color.withValues(alpha: isDark ? 0.5 : 0.35), - blurRadius: 0, - offset: const Offset(0, 4), - ), - ], - ), - child: icon is GameIcon - ? AppGameIcon( - icon, - size: iconSize * 0.5, - fallbackColor: surfaceColor, - ) - : Icon( - icon as IconData, - color: surfaceColor, - size: iconSize * 0.5, - ), - ), - const SizedBox(height: 10), - Text( - label, - style: TextStyle( - fontSize: fontSize, - height: 1.1, - fontWeight: FontWeight.w700, - color: color, - ), - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], ), ), ); } - - // ignore: unused_element - Widget _buildQuickStats(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - final stats = [ - _QuickStat( - icon: Icons.article_rounded, - label: 'home.articles'.tr(), - value: '—', - color: AppColorRoles.primary(isDark), - ), - _QuickStat( - icon: Icons.sports_esports_rounded, - label: 'home.games'.tr(), - value: '—', - color: AppColors.purple, - ), - _QuickStat( - icon: Icons.headphones_rounded, - label: 'home.listened'.tr(), - value: '—', - color: AppColorRoles.primary(isDark), - ), - _QuickStat( - icon: Icons.menu_book_rounded, - label: 'home.reading'.tr(), - value: '—', - color: AppColors.greenSuccessBright, - ), - ]; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - children: stats - .expand( - (s) => [ - Expanded( - child: Container( - padding: const EdgeInsets.symmetric(vertical: 12), - decoration: BoxDecoration( - color: isDark - ? AppColors.surfaceDarkElevated - : Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: s.color.withValues(alpha: 0.2)), - ), - child: Column( - children: [ - Icon(s.icon, color: s.color, size: 22), - const SizedBox(height: 4), - Text( - s.value, - style: TextStyle( - fontWeight: FontWeight.w700, - fontSize: 16, - color: isDark ? Colors.white : AppColors.textDark, - ), - ), - Text( - s.label, - style: TextStyle( - fontSize: 10, - color: isDark ? Colors.white54 : AppColors.textGrey, - ), - ), - ], - ), - ), - ), - const SizedBox(width: 8), - ], - ) - .take(stats.length * 2 - 1) - .toList(), - ), - ); - } - - // ignore: unused_element - Widget _buildContinueSection(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - return Consumer( - builder: (context, bookProvider, _) { - final currentBook = bookProvider.currentBook; - final currentProgress = bookProvider.currentProgress; - - final items = <_ContinueItem>[ - _ContinueItem( - icon: Icons.smart_display_rounded, - title: 'home.youtube'.tr(), - subtitle: 'home.continueWatching'.tr(), - color: AppColors.dangerGradient[0], - route: '/youtube', - ), - _ContinueItem( - icon: Icons.article_rounded, - title: 'home.news'.tr(), - subtitle: 'home.continueReading'.tr(), - color: AppColorRoles.primary(isDark), - route: '/news', - ), - _ContinueItem( - icon: Icons.sports_esports_rounded, - title: 'home.games'.tr(), - subtitle: 'home.earnMoreXp'.tr(), - color: AppColors.purple, - route: '/games', - ), - _ContinueItem( - icon: Icons.podcasts_rounded, - title: 'home.podcast'.tr(), - subtitle: 'home.continueListening'.tr(), - color: AppColorRoles.primary(isDark), - route: '/podcast', - ), - if (currentBook != null) - _ContinueItem( - icon: Icons.menu_book_rounded, - title: currentBook.title.length > 18 - ? '${currentBook.title.substring(0, 18)}…' - : currentBook.title, - subtitle: currentProgress != null - ? 'home.percentRead'.tr( - namedArgs: { - 'percent': - '${(currentProgress.readingProgress * 100).toInt()}', - }, - ) - : 'home.continueReading'.tr(), - color: AppColors.greenSuccessBright, - route: '/books', - ) - else - _ContinueItem( - icon: Icons.menu_book_rounded, - title: 'home.books'.tr(), - subtitle: 'home.startReading'.tr(), - color: AppColors.greenSuccessBright, - route: '/books', - ), - ]; - - return SizedBox( - height: 116, - child: ListView.separated( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 16), - itemCount: items.length, - separatorBuilder: (_, __) => const SizedBox(width: 12), - itemBuilder: (context, i) { - final item = items[i]; - return GestureDetector( - onTap: () => Navigator.pushNamed(context, item.route), - child: Container( - width: 130, - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: isDark - ? AppColors.surfaceDarkElevated - : Colors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: item.color.withValues(alpha: 0.25), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: item.color.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(8), - ), - child: Icon(item.icon, color: item.color, size: 20), - ), - const SizedBox(height: 8), - Text( - item.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontWeight: FontWeight.w700, - fontSize: 13, - color: isDark ? Colors.white : AppColors.textDark, - ), - ), - Text( - item.subtitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 11, - color: isDark ? Colors.white54 : AppColors.textGrey, - ), - ), - ], - ), - ), - ); - }, - ), - ); - }, - ); - } - - Widget _buildSectionTitle(BuildContext context, String title) { - final isDark = Theme.of(context).brightness == Brightness.dark; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Text( - title, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - fontSize: 18, - color: AppColorRoles.textPrimary(isDark), - ), - ), - ); - } - - /// Build skeleton loading state for home page - Widget _buildSkeletonLoading() { - return SingleChildScrollView( - padding: const EdgeInsets.only(bottom: 24.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header skeleton - ShimmerContainer( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Row( - children: [ - const SkeletonCircle(size: 48), - const SizedBox(width: 12), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SkeletonText(width: 150, height: 14), - SizedBox(height: 6), - SkeletonText(width: 100, height: 12), - SizedBox(height: 6), - SkeletonText(width: 120, height: 18), - ], - ), - const Spacer(), - const SkeletonCircle(size: 40), - ], - ), - ), - ), - const SizedBox(height: 16), - // Streak card skeleton - const SkeletonProgressStats(), - const SizedBox(height: 24), - // Daily goal skeleton - ShimmerContainer( - child: Container( - margin: EdgeInsets.symmetric(horizontal: 16), - height: 120, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(16), - ), - ), - ), - const SizedBox(height: 24), - // Section title skeleton - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: ShimmerContainer( - child: SkeletonText(width: 150, height: 20), - ), - ), - const SizedBox(height: 12), - // Courses skeleton - const SkeletonHomeSection(), - const SizedBox(height: 24), - // Another section - const SkeletonHomeSection(), - ], - ), - ); - } -} - -class _QuickStat { - final IconData icon; - final String label; - final String value; - final Color color; - - const _QuickStat({ - required this.icon, - required this.label, - required this.value, - required this.color, - }); -} - -class _ContinueItem { - final IconData icon; - final String title; - final String subtitle; - final Color color; - final String route; - - const _ContinueItem({ - required this.icon, - required this.title, - required this.subtitle, - required this.color, - required this.route, - }); } diff --git a/flutter-app/lib/features/home/presentation/widgets/home_page/course_level_helpers.dart b/flutter-app/lib/features/home/presentation/widgets/home_page/course_level_helpers.dart new file mode 100644 index 00000000..3c6ca435 --- /dev/null +++ b/flutter-app/lib/features/home/presentation/widgets/home_page/course_level_helpers.dart @@ -0,0 +1,37 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:lexilingo_app/core/theme/app_theme.dart'; + +Color getLevelColor(String level, {bool isDark = false}) { + switch (level.toLowerCase()) { + case 'beginner': + return AppColors.greenSuccessBright; + case 'elementary': + return AppColors.greenSuccessSoft; + case 'intermediate': + return AppColors.orange; + case 'upper-intermediate': + return AppColors.orange; + case 'advanced': + return AppColors.dangerGradient[0]; + default: + return AppColorRoles.primary(isDark); + } +} + +String localizedCourseLevel(String level) { + switch (level.toLowerCase()) { + case 'beginner': + return 'course.difficulty.beginner'.tr(); + case 'elementary': + return 'course.difficulty.elementary'.tr(); + case 'intermediate': + return 'course.difficulty.intermediate'.tr(); + case 'upper-intermediate': + return 'course.difficulty.upperIntermediate'.tr(); + case 'advanced': + return 'course.difficulty.advanced'.tr(); + default: + return level; + } +} diff --git a/flutter-app/lib/features/home/presentation/widgets/home_page/enrolled_courses_section.dart b/flutter-app/lib/features/home/presentation/widgets/home_page/enrolled_courses_section.dart new file mode 100644 index 00000000..503cd4a9 --- /dev/null +++ b/flutter-app/lib/features/home/presentation/widgets/home_page/enrolled_courses_section.dart @@ -0,0 +1,297 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:lexilingo_app/core/theme/app_theme.dart'; +import 'package:lexilingo_app/core/widgets/widgets.dart'; +import 'package:lexilingo_app/features/course/domain/entities/course_entity.dart'; +import 'package:lexilingo_app/features/course/presentation/screens/course_detail_screen.dart'; +import 'package:lexilingo_app/features/home/presentation/providers/home_provider.dart'; +import 'package:lexilingo_app/features/home/presentation/widgets/home_page/course_level_helpers.dart'; +import 'package:lexilingo_app/features/home/presentation/widgets/home_ui_components.dart'; + +class EnrolledCoursesSection extends StatelessWidget { + const EnrolledCoursesSection({super.key}); + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + + // Show loading state if courses are being loaded + if (provider.isLoading && provider.enrolledCourses.isEmpty) { + return SizedBox( + height: 126, // Slightly increased to match card content height + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: 2, + itemBuilder: (context, index) { + return Container( + margin: const EdgeInsets.only(right: 16), + width: 240, // Reduced from 296 roughly + child: const CardSkeleton(isHorizontal: true), + ); + }, + ), + ); + } + + // Show empty state if no enrolled courses + if (provider.enrolledCourses.isEmpty) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: isDark ? AppColors.surfaceDarkMuted : AppColors.surfaceLight, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isDark + ? AppColors.surfaceDarkElevated + : AppColors.slate200, + ), + ), + child: Column( + children: [ + AppGameIcon( + GameIcon.lessonBoard, + size: 48, + fallbackColor: isDark + ? AppColors.textMuted + : AppColors.textGrey, + ), + const SizedBox(height: 12), + Text( + 'home.noEnrolledCoursesYet'.tr(), + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: isDark ? AppColors.textInverted : AppColors.textDark, + ), + ), + const SizedBox(height: 8), + Text( + 'home.enrollCoursePrompt'.tr(), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: isDark ? AppColors.textMuted : AppColors.textGrey, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + // Show enrolled courses + return SizedBox( + height: 136, + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: provider.enrolledCourses.length, + itemBuilder: (context, index) { + final course = provider.enrolledCourses[index]; + // Staggered animation for enrolled courses + return AnimatedListItem( + index: index, + duration: const Duration(milliseconds: 300), + delayPerItem: const Duration(milliseconds: 80), + child: _EnrolledCourseCard(course: course), + ); + }, + ), + ); + } +} + +class _EnrolledCourseCard extends StatelessWidget { + final CourseEntity course; + + const _EnrolledCourseCard({required this.course}); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final progress = course.userProgress ?? 0; + final progressColor = progress >= 80 + ? AppColors.greenSuccessBright + : progress >= 50 + ? AppColors.orange + : AppColorRoles.primary(isDark); + final colorScheme = Theme.of(context).colorScheme; + final surfaceBg = colorScheme.surfaceContainerHighest; + + return GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => CourseDetailScreen(courseId: course.id), + ), + ); + }, + child: Container( + width: 240, + margin: const EdgeInsets.only(right: 16), + decoration: BoxDecoration( + color: surfaceBg, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: progressColor.withValues(alpha: 0.3)), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + // Course thumbnail + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: colorScheme.surface, + image: course.thumbnailUrl != null + ? DecorationImage( + image: NetworkImage(course.thumbnailUrl!), + fit: BoxFit.cover, + ) + : null, + border: Border.all(color: colorScheme.outlineVariant), + ), + child: course.thumbnailUrl == null + ? AppGameIcon( + GameIcon.lessonBoard, + size: 32, + fallbackColor: progressColor, + ) + : null, + ), + const SizedBox(width: 16), + // Info section + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + course.title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + fontSize: 14, + height: 1.2, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: progressColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + localizedCourseLevel(course.level), + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: progressColor, + ), + ), + ), + const SizedBox(width: 8), + Text( + 'profile.lessonsCount'.tr( + namedArgs: {'count': '${course.totalLessons}'}, + ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: AppColorRoles.textSecondary(isDark), + ), + ), + ], + ), + const SizedBox(height: 8), + // Progress bar + Stack( + children: [ + Container( + height: 6, + decoration: BoxDecoration( + color: progressColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(3), + ), + ), + FractionallySizedBox( + widthFactor: progress / 100, + child: Container( + height: 6, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + progressColor, + progressColor.withValues(alpha: 0.7), + ], + ), + borderRadius: BorderRadius.circular(3), + ), + ), + ), + ], + ), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + '${progress.toInt()}%', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: progressColor, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Container( + padding: const EdgeInsets.all(5), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + progressColor, + progressColor.withValues(alpha: 0.8), + ], + ), + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: progressColor.withValues(alpha: 0.4), + blurRadius: 0, + offset: const Offset(0, 2), + ), + ], + ), + child: AppGameIcon( + GameIcon.playArrow, + size: 16, + fallbackColor: AppColors.surfaceLight, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/flutter-app/lib/features/home/presentation/widgets/home_page/featured_courses_section.dart b/flutter-app/lib/features/home/presentation/widgets/home_page/featured_courses_section.dart new file mode 100644 index 00000000..f871261c --- /dev/null +++ b/flutter-app/lib/features/home/presentation/widgets/home_page/featured_courses_section.dart @@ -0,0 +1,493 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:lexilingo_app/core/theme/app_theme.dart'; +import 'package:lexilingo_app/core/widgets/widgets.dart'; +import 'package:lexilingo_app/features/course/domain/entities/course_entity.dart'; +import 'package:lexilingo_app/features/course/presentation/screens/course_detail_screen.dart'; +import 'package:lexilingo_app/features/course/presentation/utils/course_thumbnail_resolver.dart'; +import 'package:lexilingo_app/features/home/presentation/providers/home_provider.dart'; +import 'package:lexilingo_app/features/home/presentation/widgets/home_page/course_level_helpers.dart'; +import 'package:lexilingo_app/features/home/presentation/widgets/home_ui_components.dart'; + +class FeaturedCoursesSection extends StatelessWidget { + const FeaturedCoursesSection({super.key}); + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + + // Show skeleton loading while courses are loading + if (provider.isLoading && provider.featuredCourses.isEmpty) { + return SizedBox( + height: 220, + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: 3, + itemBuilder: (context, index) { + return Container( + width: 240, + margin: const EdgeInsets.only(right: 16), + child: const CardSkeleton(isHorizontal: false), + ); + }, + ), + ); + } + + if (provider.featuredCourses.isEmpty) { + return _FeaturedCoursesEmptyState(provider: provider); + } + + return SizedBox( + height: 220, + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: provider.featuredCourses.length, + itemBuilder: (context, index) { + final course = provider.featuredCourses[index]; + // Staggered animation for featured courses + return AnimatedListItem( + index: index, + duration: const Duration(milliseconds: 400), + delayPerItem: const Duration(milliseconds: 100), + beginOffset: const Offset(0, 60), + child: _CourseCard(course: course), + ); + }, + ), + ); + } +} + +class _FeaturedCoursesEmptyState extends StatelessWidget { + final HomeProvider provider; + + const _FeaturedCoursesEmptyState({required this.provider}); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final primary = AppColorRoles.primary(isDark); + + return SizedBox( + height: 220, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: isDark ? AppColors.surfaceDarkMuted : Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: primary.withValues(alpha: isDark ? 0.22 : 0.16), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), + child: AppGameIcon( + GameIcon.book, + size: 24, + fallbackColor: primary, + ), + ), + const SizedBox(height: 12), + Text( + 'home.noFeaturedCourses'.tr(), + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 6), + Text( + 'home.noFeaturedCoursesDescription'.tr(), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: AppColorRoles.textMuted(isDark), + height: 1.35, + ), + ), + const Spacer(), + Row( + children: [ + FilledButton.icon( + onPressed: () => provider.loadFeaturedCourses(), + icon: const Icon(Icons.refresh_rounded, size: 18), + label: Text('home.reload'.tr()), + ), + const SizedBox(width: 10), + TextButton( + onPressed: () => provider.refreshData(), + child: Text('home.refreshData'.tr()), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _CourseCard extends StatelessWidget { + final CourseEntity course; + + const _CourseCard({required this.course}); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final levelColor = getLevelColor(course.level, isDark: isDark); + + return GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => CourseDetailScreen( + courseId: course.id, + heroTag: 'featured-course-image-${course.id}', + initialThumbnailUrl: course.thumbnailUrl, + fallbackThumbnailUrl: courseFallbackThumbnailUrl(course), + ), + ), + ); + }, + child: Container( + width: 240, + margin: const EdgeInsets.only(right: 16), + decoration: BoxDecoration( + color: isDark ? AppColors.surfaceDarkMuted : Colors.white, + borderRadius: BorderRadius.circular(24), + border: Border.all( + color: isDark + ? Colors.white.withValues(alpha: 0.1) + : levelColor.withValues(alpha: 0.25), + width: 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Hero animation for course thumbnail + Hero( + tag: 'featured-course-image-${course.id}', + child: ClipRRect( + borderRadius: const BorderRadius.vertical( + top: Radius.circular(24), + ), + child: Stack( + children: [ + Positioned.fill( + child: _FeaturedCourseThumbnail( + course: course, + levelColor: levelColor, + ), + ), + // Gradient overlay + Container( + height: 110, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withValues(alpha: 0.6), + ], + ), + ), + ), + // Level badge - top left + Positioned( + top: 12, + left: 12, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + levelColor, + levelColor.withValues(alpha: 0.85), + ], + ), + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: levelColor.withValues(alpha: 0.5), + blurRadius: 8, + offset: const Offset(0, 3), + ), + ], + ), + child: Text( + localizedCourseLevel(course.level), + style: TextStyle( + color: AppColors.surfaceLight, + fontSize: 12, + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + ), + ), + ), + ), + // XP badge - top right + Positioned( + top: 12, + right: 12, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [AppColors.orange, AppColors.orange], + ), + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: const Color( + 0xFFF59E0B, + ).withValues(alpha: 0.4), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + AppGameIcon( + GameIcon.star, + size: 14, + fallbackColor: AppColors.surfaceLight, + ), + const SizedBox(width: 4), + Text( + 'profile.xpValue'.tr( + namedArgs: {'xp': '${course.totalXp}'}, + ), + style: TextStyle( + color: AppColors.surfaceLight, + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ), + // Course title overlay at bottom + Positioned( + bottom: 12, + left: 12, + right: 12, + child: Text( + course.title, + style: TextStyle( + color: AppColors.surfaceLight, + fontSize: 16, + fontWeight: FontWeight.bold, + shadows: [ + Shadow( + color: AppColors.backgroundDark.withValues( + alpha: 0.75, + ), + blurRadius: 4, + ), + ], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + // Bottom section with info and action + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 12, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Info chips row + Row( + children: [ + _InfoChip( + icon: GameIcon.lessonBoard, + label: 'profile.lessonsCount'.tr( + namedArgs: {'count': '${course.totalLessons}'}, + ), + color: AppColorRoles.primary(isDark), + ), + const SizedBox(width: 8), + _InfoChip( + icon: GameIcon.translate, + label: course.language, + color: AppColors.purple, + ), + ], + ), + const SizedBox(height: 8), + // Action button + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: levelColor, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: levelColor.withValues(alpha: 0.6), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AppGameIcon( + GameIcon.playArrow, + size: 18, + fallbackColor: AppColors.surfaceLight, + ), + SizedBox(width: 8), + Text( + 'home.startLearning'.tr(), + style: TextStyle( + color: AppColors.surfaceLight, + fontSize: 14, + fontWeight: FontWeight.bold, + letterSpacing: 0.3, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} + +class _FeaturedCourseThumbnail extends StatelessWidget { + final CourseEntity course; + final Color levelColor; + final int index; + + const _FeaturedCourseThumbnail({ + required this.course, + required this.levelColor, + this.index = 0, + }); + + @override + Widget build(BuildContext context) { + final candidates = buildCourseCardThumbnailCandidates(course); + if (index >= candidates.length) { + return _FeaturedCourseThumbnailPlaceholder(levelColor: levelColor); + } + + return CachedNetworkImage( + imageUrl: candidates[index], + fit: BoxFit.cover, + placeholder: (_, __) => + Container(color: levelColor.withValues(alpha: 0.1)), + errorWidget: (_, __, ___) => _FeaturedCourseThumbnail( + course: course, + levelColor: levelColor, + index: index + 1, + ), + ); + } +} + +class _FeaturedCourseThumbnailPlaceholder extends StatelessWidget { + final Color levelColor; + + const _FeaturedCourseThumbnailPlaceholder({required this.levelColor}); + + @override + Widget build(BuildContext context) { + return Container( + color: levelColor.withValues(alpha: 0.1), + alignment: Alignment.center, + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.15), + shape: BoxShape.circle, + border: Border.all( + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.3), + width: 2, + ), + ), + child: const AppGameIcon( + GameIcon.lessonBoard, + size: 40, + fallbackColor: AppColors.surfaceLight, + ), + ), + ); + } +} + +class _InfoChip extends StatelessWidget { + final GameIcon icon; + final String label; + final Color color; + + const _InfoChip({ + required this.icon, + required this.label, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: color.withValues(alpha: 0.2), width: 1.5), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + AppGameIcon(icon, size: 14, fallbackColor: color), + const SizedBox(width: 5), + Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ], + ), + ); + } +} diff --git a/flutter-app/lib/features/home/presentation/widgets/home_page/home_header.dart b/flutter-app/lib/features/home/presentation/widgets/home_page/home_header.dart new file mode 100644 index 00000000..0a5a8da3 --- /dev/null +++ b/flutter-app/lib/features/home/presentation/widgets/home_page/home_header.dart @@ -0,0 +1,43 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:lexilingo_app/features/auth/presentation/providers/auth_provider.dart'; +import 'package:lexilingo_app/features/home/presentation/widgets/home_ui_components.dart'; +import 'package:lexilingo_app/features/level/level.dart'; +import 'package:lexilingo_app/features/notifications/presentation/pages/notifications_page.dart'; +import 'package:lexilingo_app/features/notifications/presentation/providers/notification_provider.dart'; + +class HomeHeader extends StatelessWidget { + const HomeHeader({super.key}); + + @override + Widget build(BuildContext context) { + final authProvider = Provider.of(context); + // Get user display name from AuthProvider + final user = authProvider.currentUser; + final displayName = user?.displayName.isNotEmpty == true + ? user!.displayName + : user?.username ?? 'profile.guestUser'.tr(); + + return Consumer2( + builder: (context, notificationProvider, levelProvider, child) { + final totalXP = levelProvider.levelStatus.totalXP; + return PersonalizedGreetingHeader( + userName: displayName, + totalXP: totalXP, + avatarUrl: user?.avatarUrl, + notificationCount: notificationProvider.unreadCount, + onNotificationTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const NotificationsPage()), + ); + }, + onAvatarTap: () { + // Navigate to profile or settings + }, + ); + }, + ); + } +} diff --git a/flutter-app/lib/features/home/presentation/widgets/home_page/home_skeleton_loading.dart b/flutter-app/lib/features/home/presentation/widgets/home_page/home_skeleton_loading.dart new file mode 100644 index 00000000..a62bdec8 --- /dev/null +++ b/flutter-app/lib/features/home/presentation/widgets/home_page/home_skeleton_loading.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:lexilingo_app/core/widgets/widgets.dart'; + +/// Skeleton loading state for home page +class HomeSkeletonLoading extends StatelessWidget { + const HomeSkeletonLoading({super.key}); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.only(bottom: 24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header skeleton + ShimmerContainer( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + const SkeletonCircle(size: 48), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SkeletonText(width: 150, height: 14), + SizedBox(height: 6), + SkeletonText(width: 100, height: 12), + SizedBox(height: 6), + SkeletonText(width: 120, height: 18), + ], + ), + const Spacer(), + const SkeletonCircle(size: 40), + ], + ), + ), + ), + const SizedBox(height: 16), + // Streak card skeleton + const SkeletonProgressStats(), + const SizedBox(height: 24), + // Daily goal skeleton + ShimmerContainer( + child: Container( + margin: EdgeInsets.symmetric(horizontal: 16), + height: 120, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(16), + ), + ), + ), + const SizedBox(height: 24), + // Section title skeleton + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: ShimmerContainer( + child: SkeletonText(width: 150, height: 20), + ), + ), + const SizedBox(height: 12), + // Courses skeleton + const SkeletonHomeSection(), + const SizedBox(height: 24), + // Another section + const SkeletonHomeSection(), + ], + ), + ); + } +} diff --git a/flutter-app/lib/features/home/presentation/widgets/home_page/level_and_daily_goal_row.dart b/flutter-app/lib/features/home/presentation/widgets/home_page/level_and_daily_goal_row.dart new file mode 100644 index 00000000..8b9379ba --- /dev/null +++ b/flutter-app/lib/features/home/presentation/widgets/home_page/level_and_daily_goal_row.dart @@ -0,0 +1,604 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:lexilingo_app/core/theme/app_theme.dart'; +import 'package:lexilingo_app/core/widgets/widgets.dart'; +import 'package:lexilingo_app/core/widgets/glassmorphic_components.dart' + as glass; +import 'package:lexilingo_app/features/home/presentation/providers/home_provider.dart'; +import 'package:lexilingo_app/features/level/level.dart'; +import 'package:lexilingo_app/features/user/presentation/providers/settings_provider.dart'; + +void _showDailyGoalSheet(BuildContext context, HomeProvider homeProvider) async { + final settings = context.read(); + final settingsState = settings.settings; + + if (settingsState == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('home.dailyGoalSettingsPending'.tr())), + ); + return; + } + + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (sheetContext) { + final isDark = Theme.of(sheetContext).brightness == Brightness.dark; + final colorScheme = Theme.of(sheetContext).colorScheme; + final primaryColor = AppColorRoles.primary(isDark); + + return Consumer( + builder: (sheetContext, settingsProvider, _) { + return Container( + padding: const EdgeInsets.fromLTRB(24, 12, 24, 32), + decoration: BoxDecoration( + color: Theme.of(sheetContext).scaffoldBackgroundColor, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(24), + ), + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + color: colorScheme.outlineVariant, + borderRadius: BorderRadius.circular(999), + ), + ), + ), + Text( + 'home.dailyGoal'.tr(), + style: Theme.of(sheetContext).textTheme.headlineSmall + ?.copyWith(fontWeight: FontWeight.w800), + ), + const SizedBox(height: 6), + Text( + 'home.dailyGoalDescription'.tr(), + style: Theme.of(sheetContext).textTheme.bodyMedium + ?.copyWith(color: AppColorRoles.textMuted(isDark)), + ), + const SizedBox(height: 16), + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + primaryColor, + primaryColor.withValues(alpha: 0.72), + ], + ), + borderRadius: BorderRadius.circular(18), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${homeProvider.dailyXP}/${settingsProvider.dailyGoalXP} XP', + style: Theme.of(sheetContext) + .textTheme + .headlineSmall + ?.copyWith( + color: colorScheme.onPrimary, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 4), + Text( + 'settings.goal_progress'.tr( + namedArgs: { + 'percent': + '${(homeProvider.dailyProgressPercentage * 100).toInt()}', + }, + ), + style: Theme.of(sheetContext).textTheme.bodyMedium + ?.copyWith( + color: colorScheme.onPrimary.withValues( + alpha: 0.88, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + ...SettingsProvider.dailyGoalPresets.map((goal) { + final xp = goal['xp'] as int; + final isSelected = settingsProvider.dailyGoalXP == xp; + + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: () async { + await settingsProvider.updateDailyGoal(xp); + if (!sheetContext.mounted) return; + + if (settingsProvider.error == null) { + homeProvider.updateDailyGoalTarget(xp); + Navigator.of(sheetContext).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'settings.goal_updated'.tr( + namedArgs: {'xp': '$xp'}, + ), + ), + ), + ); + } else { + ScaffoldMessenger.of(sheetContext).showSnackBar( + SnackBar( + content: Text(settingsProvider.error!), + ), + ); + } + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 14, + ), + decoration: BoxDecoration( + color: isSelected + ? primaryColor.withValues(alpha: 0.12) + : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isSelected + ? primaryColor + : colorScheme.outlineVariant, + width: isSelected ? 2 : 1, + ), + ), + child: Row( + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: isSelected + ? primaryColor + : primaryColor.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + goal['icon'] as IconData, + color: isSelected + ? colorScheme.onPrimary + : primaryColor, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + '${(goal['label'] as String).tr()} • $xp XP', + style: Theme.of(sheetContext) + .textTheme + .titleMedium + ?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + (goal['description'] as String).tr(), + style: Theme.of(sheetContext) + .textTheme + .bodySmall + ?.copyWith( + color: AppColorRoles.textMuted( + isDark, + ), + ), + ), + ], + ), + ), + if (isSelected) + AppGameIcon( + GameIcon.checkmark, + size: 24, + fallbackColor: primaryColor, + ), + ], + ), + ), + ), + ); + }), + ], + ), + ), + ); + }, + ); + }, + ); +} + +class LevelAndDailyGoalRow extends StatelessWidget { + const LevelAndDailyGoalRow({super.key}); + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: LayoutBuilder( + builder: (context, constraints) { + final isNarrow = constraints.maxWidth < 360; + + if (isNarrow) { + return Column( + children: [ + SizedBox(height: 148, child: LevelProgressCard(compact: true)), + const SizedBox(height: 12), + SizedBox( + height: 148, + child: _DailyGoalCard( + provider: provider, + compact: true, + margin: EdgeInsets.zero, + ), + ), + ], + ); + } + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 6, + child: SizedBox( + height: 148, + child: LevelProgressCard(compact: true), + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 4, + child: SizedBox( + height: 148, + child: _DailyGoalCard( + provider: provider, + compact: true, + margin: EdgeInsets.zero, + ), + ), + ), + ], + ); + }, + ), + ); + } +} + +class _DailyGoalCard extends StatelessWidget { + final HomeProvider provider; + final bool compact; + final EdgeInsetsGeometry? margin; + + const _DailyGoalCard({ + required this.provider, + this.compact = false, + this.margin, + }); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + // Use user-configured daily goal from settings; fall back to HomeProvider. + final goalXP = context.read().dailyGoalXP; + final earnedXP = provider.dailyXP; + final percentage = goalXP > 0 ? (earnedXP / goalXP).clamp(0.0, 1.0) : 0.0; + final isCompleted = percentage >= 1.0; + final colorScheme = Theme.of(context).colorScheme; + final accent = AppColorRoles.primary(isDark); + final accentDeep = AppColorRoles.primaryDeep(isDark); + final surfaceBg = colorScheme.surfaceContainerHighest; + final compactBg = isCompleted + ? AppColors.greenSuccessBright.withValues(alpha: 0.10) + : accent.withValues(alpha: 0.07); + final cardPadding = compact ? 14.0 : 20.0; + final ringSize = compact ? 58.0 : 70.0; + final ringStroke = compact ? 5.0 : 6.0; + final titleFontSize = compact ? 16.0 : null; + final valueFontSize = compact ? 18.0 : null; + final chipFontSize = compact ? 11.0 : 12.0; + final badgeIconSize = compact ? 16.0 : 18.0; + final ringValueFontSize = compact ? 12.0 : 14.0; + + final borderRadius = BorderRadius.circular(compact ? 16 : 20); + + return Material( + color: Colors.transparent, + child: Padding( + padding: margin ?? const EdgeInsets.symmetric(horizontal: 16), + child: InkWell( + onTap: () => _showDailyGoalSheet(context, provider), + borderRadius: borderRadius, + child: Ink( + padding: EdgeInsets.all(cardPadding), + decoration: BoxDecoration( + color: compact ? compactBg : surfaceBg, + borderRadius: borderRadius, + border: Border.all( + color: isCompleted + ? AppColors.greenSuccessBright.withValues(alpha: 0.3) + : accent.withValues(alpha: 0.35), + ), + ), + child: compact + ? Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(5), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(8), + ), + child: isCompleted + ? AppGameIcon( + GameIcon.trophy, + size: badgeIconSize, + fallbackColor: AppColors.greenSuccessBright, + ) + : AppGameIcon( + GameIcon.bolt, + size: badgeIconSize, + fallbackColor: accent, + ), + ), + const SizedBox(width: 8), + Flexible( + child: Text( + 'home.dailyGoal'.tr(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall + ?.copyWith( + fontWeight: FontWeight.bold, + fontSize: 15, + color: isCompleted + ? AppColors.greenSuccess + : accentDeep, + ), + ), + ), + ], + ), + const SizedBox(height: 10), + Expanded( + child: Center( + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colorScheme.surface, + ), + child: glass.AnimatedProgressRing( + progress: percentage.clamp(0.0, 1.0), + size: ringSize + 10, + strokeWidth: ringStroke, + gradientColors: isCompleted + ? const [ + AppColors.greenSuccessBright, + AppColors.greenSuccess, + ] + : AppColorRoles.primaryGradient(isDark), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (isCompleted) + const AppGameIcon( + GameIcon.checkmark, + size: 20, + fallbackColor: + AppColors.greenSuccessBright, + ) + else + Text( + '${(percentage * 100).toInt()}%', + style: TextStyle( + fontSize: ringValueFontSize, + fontWeight: FontWeight.bold, + color: accent, + ), + ), + ], + ), + ), + ), + ), + ), + const SizedBox(height: 8), + Text( + '$earnedXP/$goalXP XP', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith( + fontWeight: FontWeight.bold, + fontSize: 16, + color: isCompleted + ? AppColors.greenSuccessBright + : accent, + ), + ), + ], + ) + : Row( + children: [ + // Animated Progress Ring + Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colorScheme.surface, + ), + child: glass.AnimatedProgressRing( + progress: percentage.clamp(0.0, 1.0), + size: ringSize, + strokeWidth: ringStroke, + gradientColors: isCompleted + ? const [ + AppColors.greenSuccessBright, + AppColors.greenSuccess, + ] + : const [AppColors.primary, AppColors.primary], + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (isCompleted) + const AppGameIcon( + GameIcon.checkmark, + size: 22, + fallbackColor: AppColors.greenSuccessBright, + ) + else + Text( + '${(percentage * 100).toInt()}%', + style: TextStyle( + fontSize: ringValueFontSize, + fontWeight: FontWeight.bold, + color: isCompleted + ? AppColors.greenSuccessBright + : AppColors.primary, + ), + ), + ], + ), + ), + ), + SizedBox(width: compact ? 12 : 20), + // Info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: EdgeInsets.all(compact ? 5 : 6), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(8), + ), + child: isCompleted + ? AppGameIcon( + GameIcon.trophy, + size: badgeIconSize, + fallbackColor: + AppColors.greenSuccessBright, + ) + : AppGameIcon( + GameIcon.bolt, + size: badgeIconSize, + fallbackColor: accent, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'home.dailyXpGoal'.tr(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith( + fontWeight: FontWeight.bold, + fontSize: titleFontSize, + color: isCompleted + ? AppColors.greenSuccess + : accentDeep, + ), + ), + ), + ], + ), + SizedBox(height: compact ? 6 : 8), + Text( + '$earnedXP/$goalXP XP', + style: Theme.of(context).textTheme.headlineSmall + ?.copyWith( + fontWeight: FontWeight.bold, + fontSize: valueFontSize, + color: isCompleted + ? AppColors.greenSuccessBright + : accent, + ), + ), + SizedBox(height: compact ? 2 : 4), + Container( + padding: EdgeInsets.symmetric( + horizontal: compact ? 8 : 10, + vertical: compact ? 3 : 4, + ), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + isCompleted + ? AppGameIcon( + GameIcon.giftBox, + size: 14, + fallbackColor: + AppColors.greenSuccessBright, + ) + : Icon( + Icons.trending_up, + size: 14, + color: accent, + ), + const SizedBox(width: 4), + Text( + isCompleted + ? 'home.goalCompleted'.tr() + : 'home.keepGoing'.tr(), + style: TextStyle( + fontSize: chipFontSize, + fontWeight: FontWeight.w600, + color: isCompleted + ? AppColors.greenSuccessBright + : accent, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/flutter-app/lib/features/home/presentation/widgets/home_page/quick_actions_grid.dart b/flutter-app/lib/features/home/presentation/widgets/home_page/quick_actions_grid.dart new file mode 100644 index 00000000..03ed4331 --- /dev/null +++ b/flutter-app/lib/features/home/presentation/widgets/home_page/quick_actions_grid.dart @@ -0,0 +1,214 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:lexilingo_app/core/theme/app_theme.dart'; +import 'package:lexilingo_app/core/widgets/widgets.dart'; +import 'package:lexilingo_app/features/vocabulary/presentation/pages/vocab_library_page.dart'; + +/// Quick Actions - Horizontal scrollable section with circular buttons +class QuickActionsGrid extends StatelessWidget { + const QuickActionsGrid({super.key}); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final accent = AppColorRoles.primary(isDark); + // Keep all dark-mode quick actions in a consistent neon range. + const neonDarkActionColors = [ + Color(0xFFFF3131), // YouTube + Color(0xFFFC1AD3), // News + Color(0xFFFFA319), // Games + Color(0xFF35FF0D), // Podcast + Color(0xFF8B5CFF), // Books (neon purple) + Color(0xFFFF369E), // Vocabulary + ]; + + final quickActions = [ + { + 'icon': Icons.smart_display, + 'label': 'home.youtube', + 'color': isDark ? neonDarkActionColors[0] : AppColors.dangerGradient[0], + 'bgColor': + (isDark ? neonDarkActionColors[0] : AppColors.dangerGradient[0]) + .withValues(alpha: isDark ? 0.16 : 0.1), + 'route': '/youtube', + }, + { + 'icon': GameIcon.newspaper, + 'label': 'home.news', + 'color': isDark ? neonDarkActionColors[1] : AppColors.teal, + 'bgColor': (isDark ? neonDarkActionColors[1] : AppColors.teal) + .withValues(alpha: isDark ? 0.16 : 0.1), + 'route': '/news', + }, + { + 'icon': GameIcon.gameController, + 'label': 'home.games', + 'color': isDark ? neonDarkActionColors[2] : AppColors.purple, + 'bgColor': (isDark ? neonDarkActionColors[2] : AppColors.purple) + .withValues(alpha: isDark ? 0.16 : 0.1), + 'route': '/games', + }, + { + 'icon': GameIcon.headphones, + 'label': 'home.podcast', + 'color': isDark ? neonDarkActionColors[3] : accent, + 'bgColor': (isDark ? neonDarkActionColors[3] : accent).withValues( + alpha: isDark ? 0.16 : 0.12, + ), + 'route': '/podcast', + }, + { + 'icon': GameIcon.book, + 'label': 'home.books', + 'color': isDark ? neonDarkActionColors[4] : AppColors.purpleLight, + 'bgColor': (isDark ? neonDarkActionColors[4] : AppColors.purple) + .withValues(alpha: isDark ? 0.16 : 0.1), + 'route': '/books', + }, + { + 'icon': GameIcon.vocabulary, + 'label': 'home.vocabulary', + 'color': isDark ? neonDarkActionColors[5] : AppColors.orange, + 'bgColor': (isDark ? neonDarkActionColors[5] : AppColors.warning) + .withValues(alpha: isDark ? 0.16 : 0.1), + 'route': '/vocab', + }, + ]; + + const crossAxisCount = 3; + const spacing = 12.0; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: LayoutBuilder( + builder: (context, constraints) { + final tileWidth = + (constraints.maxWidth - spacing * (crossAxisCount - 1)) / + crossAxisCount; + // Scale the icon (and tile height) with tile width instead of a + // fixed aspect ratio, so narrow screens don't overflow vertically. + final iconSize = (tileWidth * 0.42).clamp(34.0, 44.0); + final tileHeight = iconSize + 56; + + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + mainAxisSpacing: spacing, + crossAxisSpacing: spacing, + mainAxisExtent: tileHeight, + ), + itemCount: quickActions.length, + itemBuilder: (context, index) { + final action = quickActions[index]; + return _QuickActionChip( + icon: action['icon']!, + label: (action['label'] as String).tr(), + color: action['color'] as Color, + bgColor: action['bgColor'] as Color, + iconSize: iconSize, + onTap: () { + final route = action['route'] as String; + if (route == '/vocab') { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const VocabLibraryPage(), + ), + ); + } else { + Navigator.pushNamed(context, route); + } + }, + ); + }, + ); + }, + ), + ); + } +} + +class _QuickActionChip extends StatelessWidget { + final Object icon; + final String label; + final Color color; + final Color bgColor; + final VoidCallback onTap; + final double iconSize; + + const _QuickActionChip({ + required this.icon, + required this.label, + required this.color, + required this.bgColor, + required this.onTap, + this.iconSize = 44, + }); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final fontSize = iconSize <= 38 ? 9.0 : 10.0; + final surfaceColor = Theme.of(context).colorScheme.surface; + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: color.withValues(alpha: isDark ? 0.5 : 0.35), + width: 2, + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: iconSize, + height: iconSize, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(14), + boxShadow: [ + BoxShadow( + color: color.withValues(alpha: isDark ? 0.5 : 0.35), + blurRadius: 0, + offset: const Offset(0, 4), + ), + ], + ), + child: icon is GameIcon + ? AppGameIcon( + icon as GameIcon, + size: iconSize * 0.5, + fallbackColor: surfaceColor, + ) + : Icon( + icon as IconData, + color: surfaceColor, + size: iconSize * 0.5, + ), + ), + const SizedBox(height: 10), + Text( + label, + style: TextStyle( + fontSize: fontSize, + height: 1.1, + fontWeight: FontWeight.w700, + color: color, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ); + } +} diff --git a/flutter-app/lib/features/home/presentation/widgets/home_page/section_title.dart b/flutter-app/lib/features/home/presentation/widgets/home_page/section_title.dart new file mode 100644 index 00000000..bd557a52 --- /dev/null +++ b/flutter-app/lib/features/home/presentation/widgets/home_page/section_title.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:lexilingo_app/core/theme/app_theme.dart'; + +class SectionTitle extends StatelessWidget { + final String title; + + const SectionTitle({super.key, required this.title}); + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + title, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + fontSize: 18, + color: AppColorRoles.textPrimary(isDark), + ), + ), + ); + } +} diff --git a/flutter-app/lib/features/home/presentation/widgets/home_page/streak_card_section.dart b/flutter-app/lib/features/home/presentation/widgets/home_page/streak_card_section.dart new file mode 100644 index 00000000..98b7e88e --- /dev/null +++ b/flutter-app/lib/features/home/presentation/widgets/home_page/streak_card_section.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:lexilingo_app/features/home/presentation/providers/home_provider.dart'; +import 'package:lexilingo_app/features/home/presentation/widgets/home_ui_components.dart'; +import 'package:lexilingo_app/features/progress/presentation/providers/streak_provider.dart'; +import 'package:lexilingo_app/features/progress/presentation/widgets/points_calendar_dialog.dart'; + +class StreakCardSection extends StatelessWidget { + const StreakCardSection({super.key}); + + @override + Widget build(BuildContext context) { + return Consumer2( + builder: (context, provider, streakProvider, child) { + final streak = streakProvider.streak; + final currentStreak = streak?.currentStreak ?? provider.streakDays; + final longestStreak = streak?.longestStreak ?? 0; + final isActiveToday = streak?.isActiveToday ?? false; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: AnimatedStreakCard( + streakDays: currentStreak, + longestStreak: longestStreak, + isActiveToday: isActiveToday, + weeklyActivity: streak?.weeklyActivity, + weeklyProgressPercentages: provider.weeklyProgress.weekProgress + .map((day) => day.progressPercentage) + .toList(growable: false), + onTap: () { + if (streak != null) { + showPointsCalendarDialog(context, streak); + } + }, + ), + ); + }, + ); + } +} From b72c2ca9099bc518955db80b2db0316e51f2cf77 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Huu <2001230909@hufi.edu.vn> Date: Thu, 25 Jun 2026 12:53:05 +0700 Subject: [PATCH 10/20] fix(ranking): correct benchmark mode string for adaptive mode --- .../services/trace_cag/benchmark/adaptive.py | 2 +- .../services/trace_cag/benchmark/ranking.py | 68 +++++++++++++------ 2 files changed, 48 insertions(+), 22 deletions(-) diff --git a/ai-service/api/services/trace_cag/benchmark/adaptive.py b/ai-service/api/services/trace_cag/benchmark/adaptive.py index cf76ab3d..e8bfea14 100644 --- a/ai-service/api/services/trace_cag/benchmark/adaptive.py +++ b/ai-service/api/services/trace_cag/benchmark/adaptive.py @@ -100,7 +100,7 @@ def _adaptive_mode_enabled(state: TraceCAGState, benchmark_mode: str = "") -> bo retrieval_policy = str(state.get("retrieval_policy") or "").strip().lower() if retrieval_policy == "adaptive": return True - if str(benchmark_mode or "").strip().lower() == "trace-cag_adaptive": + if str(benchmark_mode or "").strip().lower() == "tracecag_adaptive": return True return False diff --git a/ai-service/api/services/trace_cag/benchmark/ranking.py b/ai-service/api/services/trace_cag/benchmark/ranking.py index 49a6ce7f..d209ab47 100644 --- a/ai-service/api/services/trace_cag/benchmark/ranking.py +++ b/ai-service/api/services/trace_cag/benchmark/ranking.py @@ -217,27 +217,48 @@ def _select_diverse_multihop_evidence( # ── Graph helpers ───────────────────────────────────────────────────────────── -def _build_candidate_graph(candidates: list[Dict[str, Any]]) -> dict[str, set[str]]: +def _build_candidate_graph(candidates: list[Dict[str, Any]]) -> dict[str, dict[str, float]]: + """ + Bridge-entity adjacency for multi-hop reasoning: edge A->B means A's body + text names the bridge entity that B's title covers (the standard HotpotQA + bridge pattern, where doc A mentions the entity whose own page B holds the + next-hop answer). Edge weight 1.0 = B's full title appears verbatim in A's + text (high-precision bridge mention); 0.4 = weaker token-overlap fallback + for titles that get paraphrased instead of quoted exactly. + """ + title_norm_map = { + str(candidate["item_id"]): _normalize_benchmark_surface( + str(candidate.get("title") or candidate.get("item_id") or "") + ) + for candidate in candidates + } title_token_map = { str(candidate["item_id"]): _title_tokens(str(candidate.get("title") or candidate.get("item_id") or "")) for candidate in candidates } - adjacency: dict[str, set[str]] = {str(candidate["item_id"]): set() for candidate in candidates} + adjacency: dict[str, dict[str, float]] = {str(candidate["item_id"]): {} for candidate in candidates} for candidate in candidates: source_id = str(candidate["item_id"]) + source_text_norm = _normalize_benchmark_surface(str(candidate.get("text") or "")) source_text_tokens = set(_benchmark_tokens(str(candidate.get("text") or ""))) for other in candidates: target_id = str(other["item_id"]) if source_id == target_id: continue + + target_title_norm = title_norm_map.get(target_id, "") + if target_title_norm and len(target_title_norm) >= 4 and f" {target_title_norm} " in f" {source_text_norm} ": + adjacency[source_id][target_id] = 1.0 + continue + title_tokens = title_token_map.get(target_id, set()) if not title_tokens: continue overlap = len(source_text_tokens & title_tokens) threshold = 1 if len(title_tokens) <= 2 else 2 if overlap >= threshold: - adjacency[source_id].add(target_id) + adjacency[source_id][target_id] = 0.4 return adjacency @@ -291,7 +312,7 @@ def _compute_evidence_budget( if config is not None: budget += int(config.evidence_budget_delta) - if benchmark_candidates and benchmark_mode == "trace-cag_adaptive": + if benchmark_candidates and benchmark_mode == "tracecag_adaptive": config = _ADAPTIVE_PROFILES.get(adaptive_profile or "balanced") if config is not None: budget = max(4, min(max_budget, budget + int(config.evidence_budget_delta))) @@ -353,7 +374,7 @@ def _rank_benchmark_candidates( adjacency = _build_candidate_graph(candidates) degree_scores = { - item_id: (len(neighbors) / max(len(candidates) - 1, 1)) + item_id: (sum(neighbors.values()) / max(len(candidates) - 1, 1)) for item_id, neighbors in adjacency.items() } seed_ids = [ @@ -368,8 +389,9 @@ def _rank_benchmark_candidates( if seed_id == item_id: seed_bridge = max(seed_bridge, base_scores.get(seed_id, 0.0)) continue - if item_id in adjacency.get(seed_id, set()) or seed_id in adjacency.get(item_id, set()): - seed_bridge = max(seed_bridge, base_scores.get(seed_id, 0.0)) + edge_weight = adjacency.get(seed_id, {}).get(item_id) or adjacency.get(item_id, {}).get(seed_id) + if edge_weight: + seed_bridge = max(seed_bridge, base_scores.get(seed_id, 0.0) * edge_weight) graph_scores[item_id] = (0.7 * seed_bridge) + (0.3 * degree_scores.get(item_id, 0.0)) memory_state = _normalize_score_map(base_scores) @@ -378,9 +400,10 @@ def _rank_benchmark_candidates( for source_id, neighbors in adjacency.items(): if not neighbors: continue - shared = 0.85 * memory_state.get(source_id, 0.0) / len(neighbors) - for target_id in neighbors: - propagated[target_id] = propagated.get(target_id, 0.0) + shared + weight_total = sum(neighbors.values()) or 1.0 + shared = 0.85 * memory_state.get(source_id, 0.0) + for target_id, edge_weight in neighbors.items(): + propagated[target_id] = propagated.get(target_id, 0.0) + shared * (edge_weight / weight_total) memory_state = _normalize_score_map(propagated) scored_candidates: list[Dict[str, Any]] = [] @@ -406,16 +429,19 @@ def _rank_benchmark_candidates( + (0.08 * query_coverage) + (0.08 * anchor_coverage) ) - elif benchmark_mode == "trace-cag_rapid": - # Rebalance toward broader top-k coverage (R@1..R@5) over top-1 peaking. + elif benchmark_mode == "tracecag_rapid": + # Keep the full lexical base score (matches flat ranker's strength on + # single-hop docs) and add the bridge/graph signal on top instead of + # diluting base_score — diluting it previously made multi-hop bridge + # targets win at the cost of demoting strong direct matches. final_score = ( - (0.34 * base_score) - + (0.16 * graph_score) - + (0.24 * memory_score) - + (0.10 * query_coverage) - + (0.08 * anchor_coverage) - + (0.04 * anchor_title_exact) - + (0.04 * title_token_recall) + base_score + + (0.18 * graph_score) + + (0.06 * memory_score) + + (0.06 * query_coverage) + + (0.05 * anchor_coverage) + + (0.03 * anchor_title_exact) + + (0.03 * title_token_recall) ) if title_phrase >= 0.9: final_score += 0.04 @@ -423,7 +449,7 @@ def _rank_benchmark_candidates( final_score += 0.03 else: final_score += 0.01 * title_phrase - elif benchmark_mode == "trace-cag_adaptive": + elif benchmark_mode == "tracecag_adaptive": profile = _ADAPTIVE_PROFILES.get(adaptive_profile or "balanced") if profile is None: profile = _ADAPTIVE_PROFILES["balanced"] @@ -587,7 +613,7 @@ def _rank_with_online_ranker( # Keep graph/title heuristic dominant until online ranker has enough updates. mode = str(benchmark_mode or "").strip().lower() - if mode == "trace-cag_rapid": + if mode == "tracecag_rapid": snapshot = ranker.snapshot() updates = int(snapshot.get("updates", 0) or 0) warmup_updates = max(1, _env_int("TRACECAG_RANKER_WARMUP_UPDATES", 40)) From 9917ba32f7118b9be4a4f238b889bc4d64a941cc Mon Sep 17 00:00:00 2001 From: Thang Nguyen Huu <2001230909@hufi.edu.vn> Date: Fri, 26 Jun 2026 21:09:26 +0700 Subject: [PATCH 11/20] feat(ai-service): native-language tutoring, STT dedup, Groq round-robin, tracecag packaging - lexi_chat: thread native_language (ISO 639-1) through request, idempotency hash, and all orchestrator calls (primary/retry/stream) via iso_to_language_name - stt/transcript_normalizer: drop filler-only utterances and dedup leading repeated n-grams (Moonshine hallucination artifact) - groq_key_pool: round-robin fallback over all GROQ_API_KEYS when the Redis-backed pool isn't initialized (standalone CLIs no longer burn one key) - voice_session/metrics/sentence_finalizer/model_registry hardening - trace_cag: cache_utils/l1_state_cache/nodes_v2/retrieve/state refinements; benchmark qa_generation + ranking fixes - tests: native-language hint, benchmark model-strength, normalizer, lexi chat routes, tracecag chat integration - fix(test): sync dependency-constraint expected pins to merged Dependabot bumps (torch 2.12.1, moonshine-voice 0.0.62, sherpa-onnx 1.13.3) Co-Authored-By: Claude Opus 4.8 --- ai-service/api/core/groq_key_pool.py | 32 +- ai-service/api/services/lexi_chat_service.py | 20 +- ai-service/api/services/stt/metrics.py | 32 +- ai-service/api/services/stt/model_registry.py | 2 + .../api/services/stt/sentence_finalizer.py | 14 +- .../api/services/stt/transcript_normalizer.py | 74 +++- ai-service/api/services/stt/voice_session.py | 68 ++- .../trace_cag/benchmark/qa_generation.py | 167 +++++-- .../services/trace_cag/benchmark/ranking.py | 15 +- .../api/services/trace_cag/cache_utils.py | 31 +- .../api/services/trace_cag/l1_state_cache.py | 9 +- ai-service/api/services/trace_cag/nodes_v2.py | 67 ++- ai-service/api/services/trace_cag/retrieve.py | 1 + ai-service/api/services/trace_cag/state.py | 3 +- ai-service/api/utils/languages.py | 19 + .../2026-06-25-tracecag-service-packaging.md | 45 ++ ...06-25-tracecag-service-packaging-design.md | 32 ++ ai-service/docs/tracecag_service_guide.md | 408 ++++++++++++++++++ ai-service/service/__init__.py | 2 + ai-service/service/tracecag_service/README.md | 207 +++++++++ .../service/tracecag_service/__init__.py | 18 + .../tracecag_service/adapters/__init__.py | 14 + .../tracecag_service/adapters/lexilingo.py | 61 +++ .../tracecag_service/adapters/memory.py | 187 ++++++++ ai-service/service/tracecag_service/config.py | 70 +++ .../service/tracecag_service/core/__init__.py | 26 ++ .../tracecag_service/core/fingerprint.py | 216 ++++++++++ .../service/tracecag_service/core/scar_l1.py | 134 ++++++ ai-service/service/tracecag_service/ports.py | 26 ++ .../service/tracecag_service/runtime.py | 138 ++++++ .../service/tracecag_service/schemas.py | 167 +++++++ .../test_tracecag_lexilingo_adapter.py | 64 +++ .../service/test_tracecag_memory_adapter.py | 89 ++++ .../service/test_tracecag_service_runtime.py | 74 ++++ ai-service/tests/stt/test_normalizer.py | 125 ++++++ ai-service/tests/stt/test_pipeline.py | 83 ++++ ai-service/tests/stt/test_session_manager.py | 19 + ai-service/tests/stt/test_voice_session.py | 92 ++++ .../tests/test_dependency_constraints.py | 6 +- ai-service/tests/test_lexi_chat_routes.py | 55 +++ .../tests/test_tracecag_chat_integration.py | 40 ++ .../test_benchmark_model_strength.py | 38 ++ .../tests/trace_cag/test_cache_gate_l1.py | 112 ++++- .../test_l1_drift_probe_decisions.py | 2 +- .../tests/trace_cag/test_l1_state_cache.py | 4 +- .../trace_cag/test_native_language_hint.py | 30 ++ 46 files changed, 3059 insertions(+), 79 deletions(-) create mode 100644 ai-service/api/utils/languages.py create mode 100644 ai-service/docs/superpowers/plans/2026-06-25-tracecag-service-packaging.md create mode 100644 ai-service/docs/superpowers/specs/2026-06-25-tracecag-service-packaging-design.md create mode 100644 ai-service/docs/tracecag_service_guide.md create mode 100644 ai-service/service/__init__.py create mode 100644 ai-service/service/tracecag_service/README.md create mode 100644 ai-service/service/tracecag_service/__init__.py create mode 100644 ai-service/service/tracecag_service/adapters/__init__.py create mode 100644 ai-service/service/tracecag_service/adapters/lexilingo.py create mode 100644 ai-service/service/tracecag_service/adapters/memory.py create mode 100644 ai-service/service/tracecag_service/config.py create mode 100644 ai-service/service/tracecag_service/core/__init__.py create mode 100644 ai-service/service/tracecag_service/core/fingerprint.py create mode 100644 ai-service/service/tracecag_service/core/scar_l1.py create mode 100644 ai-service/service/tracecag_service/ports.py create mode 100644 ai-service/service/tracecag_service/runtime.py create mode 100644 ai-service/service/tracecag_service/schemas.py create mode 100644 ai-service/tests/service/test_tracecag_lexilingo_adapter.py create mode 100644 ai-service/tests/service/test_tracecag_memory_adapter.py create mode 100644 ai-service/tests/service/test_tracecag_service_runtime.py create mode 100644 ai-service/tests/stt/test_normalizer.py create mode 100644 ai-service/tests/trace_cag/test_benchmark_model_strength.py create mode 100644 ai-service/tests/trace_cag/test_native_language_hint.py diff --git a/ai-service/api/core/groq_key_pool.py b/ai-service/api/core/groq_key_pool.py index 0e9f3754..1f88e7dd 100644 --- a/ai-service/api/core/groq_key_pool.py +++ b/ai-service/api/core/groq_key_pool.py @@ -69,6 +69,8 @@ async def record_usage(self, api_key: str, tokens_used: int) -> None: _pool_instance: Optional[GroqKeyPool] = None +_fallback_keys: Optional[List[str]] = None +_fallback_cursor = 0 def get_groq_key_pool() -> Optional[GroqKeyPool]: @@ -76,18 +78,44 @@ def get_groq_key_pool() -> Optional[GroqKeyPool]: return _pool_instance +def _get_fallback_keys() -> List[str]: + """All configured Groq keys, used when the Redis-backed pool isn't built + (e.g. standalone scripts that never call build_groq_key_pool).""" + global _fallback_keys + if _fallback_keys is None: + raw = os.getenv("GROQ_API_KEYS", "").strip() or os.getenv("GROQ_API_KEY", "").strip() + _fallback_keys = [k.strip() for k in raw.split(",") if k.strip()] + return _fallback_keys + + +def get_configured_key_count() -> int: + """Number of usable Groq keys, whether or not the Redis pool is initialized.""" + pool = get_groq_key_pool() + return pool.count if pool else len(_get_fallback_keys()) + + async def get_available_groq_key(estimated_tokens: int = 600) -> Optional[str]: """ Get the next available Groq API key from the pool. - Falls back to the single GROQ_API_KEY environment variable if pool is empty/disabled. + Falls back to round-robin over all configured GROQ_API_KEYS when the + Redis-backed pool isn't initialized — without this, callers like the + standalone benchmark CLI always got the same single key back and burned + through one account's daily quota while the rest of the pool sat idle. """ + global _fallback_cursor pool = get_groq_key_pool() if pool: slot = await pool.get_available(estimated_tokens) if slot: api_key, _ = slot return api_key - return os.getenv("GROQ_API_KEY", "").strip() or None + + keys = _get_fallback_keys() + if not keys: + return None + key = keys[_fallback_cursor % len(keys)] + _fallback_cursor += 1 + return key async def record_groq_key_usage(api_key: str, tokens_used: int) -> None: diff --git a/ai-service/api/services/lexi_chat_service.py b/ai-service/api/services/lexi_chat_service.py index 200b9a6e..0742499e 100644 --- a/ai-service/api/services/lexi_chat_service.py +++ b/ai-service/api/services/lexi_chat_service.py @@ -66,6 +66,7 @@ class LexiChatRequest(BaseModel): audio_base64: Optional[str] = Field(default=None, description="Base64 audio for STT") enable_tts: bool = Field(default=True, description="Generate TTS audio response") learner_level: str = Field(default="B1", description="CEFR level: A1-C2") + native_language: str = Field(default="vi", description="ISO 639-1 code, e.g. vi/en/ja") story_context: Optional[str] = Field(default=None, description="Story/adventure context") @@ -134,6 +135,7 @@ def idempotency_request_hash(request: "LexiChatRequest") -> str: "audio_base64": request.audio_base64, "enable_tts": request.enable_tts, "learner_level": request.learner_level, + "native_language": request.native_language, "story_context": request.story_context, } normalized = json.dumps(payload, sort_keys=True, separators=(",", ":")) @@ -328,13 +330,17 @@ async def run_lexi_pipeline( try: from api.services.orchestrator import get_orchestrator + from api.utils.languages import iso_to_language_name orchestrator = await get_orchestrator() graph_result = await orchestrator.process( user_input=user_text, session_id=session_id, user_id=request.user_id, - learner_profile={"level": request.learner_level}, + learner_profile={ + "level": request.learner_level, + "native_language": iso_to_language_name(request.native_language), + }, conversation_history=history, ) lexi_response = graph_result.get("tutor_response", "") @@ -357,13 +363,17 @@ async def run_lexi_pipeline( metadata["pipeline_steps"].append("trace-cag_failed_primary") try: from api.services.orchestrator import get_orchestrator + from api.utils.languages import iso_to_language_name orchestrator = await get_orchestrator() retry_result = await orchestrator.process( user_input=user_text, session_id=session_id, user_id=request.user_id, - learner_profile={"level": request.learner_level}, + learner_profile={ + "level": request.learner_level, + "native_language": iso_to_language_name(request.native_language), + }, conversation_history=[], cache_policy="off", retrieval_policy="rapid", @@ -559,6 +569,7 @@ async def stream_lexi_chat( from api.services.orchestrator import get_orchestrator from api.services.trace_cag.nodes_v2 import build_generation_prompt, stream_llm_tokens from api.services.trace_cag.evaluation_agent import EvaluationAgent + from api.utils.languages import iso_to_language_name user_text = request.message @@ -653,7 +664,10 @@ async def _prepare_session() -> List[Dict[str, Any]]: user_input=user_text, session_id=session_id, user_id=request.user_id, - learner_profile={"level": request.learner_level}, + learner_profile={ + "level": request.learner_level, + "native_language": iso_to_language_name(request.native_language), + }, conversation_history=history, ) ) diff --git a/ai-service/api/services/stt/metrics.py b/ai-service/api/services/stt/metrics.py index 18c19fd3..8afa4198 100644 --- a/ai-service/api/services/stt/metrics.py +++ b/ai-service/api/services/stt/metrics.py @@ -1,18 +1,48 @@ """Small in-process STT metrics adapter.""" -from collections import Counter +from collections import Counter, defaultdict, deque from threading import Lock +_LATENCY_WINDOW = 200 + class STTMetrics: def __init__(self): self._counter = Counter() + self._latencies: dict[str, deque[float]] = defaultdict( + lambda: deque(maxlen=_LATENCY_WINDOW) + ) self._lock = Lock() def increment(self, name: str, value: int = 1) -> None: with self._lock: self._counter[name] += value + def record_latency(self, name: str, value: float) -> None: + with self._lock: + self._latencies[name].append(value) + + def latency_stats(self, name: str) -> dict[str, float]: + with self._lock: + samples = list(self._latencies.get(name, [])) + if not samples: + return {"count": 0, "min": 0.0, "max": 0.0, "avg": 0.0, "p95": 0.0} + samples.sort() + count = len(samples) + p95_idx = max(0, int(count * 0.95) - 1) + return { + "count": count, + "min": samples[0], + "max": samples[-1], + "avg": sum(samples) / count, + "p95": samples[p95_idx], + } + def snapshot(self) -> dict[str, int]: with self._lock: return dict(self._counter) + + def all_latency_stats(self) -> dict[str, dict[str, float]]: + with self._lock: + names = list(self._latencies.keys()) + return {name: self.latency_stats(name) for name in names} diff --git a/ai-service/api/services/stt/model_registry.py b/ai-service/api/services/stt/model_registry.py index 8b398a44..bfe56c2a 100644 --- a/ai-service/api/services/stt/model_registry.py +++ b/ai-service/api/services/stt/model_registry.py @@ -33,6 +33,7 @@ def __init__(self, config: STTConfig, primary=None, verifier=None): "disabled" if not config.verify_enabled else "unavailable" ) self.vad_status = "fallback" + self.vad_fallback_count = 0 self._vad_factory = SileroVADFactory(config.silero_model_path) self._verify_sem = asyncio.Semaphore(config.max_verify_concurrency) @@ -82,6 +83,7 @@ async def start(self) -> None: def create_vad(self): if self.vad_status == "silero": return self._vad_factory.create(self.config) + self.vad_fallback_count += 1 return EnergyEndpointDetector(self.config) async def create_primary_session(self, language: str): diff --git a/ai-service/api/services/stt/sentence_finalizer.py b/ai-service/api/services/stt/sentence_finalizer.py index 6db5b98e..fbc30a65 100644 --- a/ai-service/api/services/stt/sentence_finalizer.py +++ b/ai-service/api/services/stt/sentence_finalizer.py @@ -2,6 +2,7 @@ from __future__ import annotations +import time from uuid import uuid4 from api.services.stt.config import STTConfig @@ -16,10 +17,11 @@ class SentenceFinalizer: - def __init__(self, config: STTConfig, registry): + def __init__(self, config: STTConfig, registry, metrics=None): self.config = config self.registry = registry self.router = VerifierRouter(config) + self.metrics = metrics async def finalize( self, @@ -31,7 +33,7 @@ async def finalize( primary: PrimaryResult, utterance_id: str | None = None, user_id: str | None = None, - ) -> FinalTranscriptEvent: + ) -> FinalTranscriptEvent | None: decision = self.router.decide(primary, audio.duration_ms, use_case) selected = primary source = primary.source @@ -39,7 +41,13 @@ async def finalize( uncertain = primary.confidence < 0.70 if decision.verify and self.config.verify_enabled: try: + t0 = time.monotonic() verifier = await self.registry.verify(audio, language) + if self.metrics: + self.metrics.record_latency( + "stt_verify_latency_ms", + (time.monotonic() - t0) * 1000, + ) verified = True if verifier.text and ( verifier.confidence >= primary.confidence @@ -53,6 +61,8 @@ async def finalize( uncertain = primary.confidence < self.config.confidence_verify preserve = use_case == UseCase.PRONUNCIATION_SCORING text, rules = normalize_transcript(selected.text, preserve_exact=preserve) + if not text: + return None return FinalTranscriptEvent( session_id=session_id, user_id=user_id, diff --git a/ai-service/api/services/stt/transcript_normalizer.py b/ai-service/api/services/stt/transcript_normalizer.py index 38309e69..c6439954 100644 --- a/ai-service/api/services/stt/transcript_normalizer.py +++ b/ai-service/api/services/stt/transcript_normalizer.py @@ -1,15 +1,77 @@ """Conservative transcript normalization.""" +from __future__ import annotations + import re +_WHITESPACE = re.compile(r"\s+") +_FILLER_ONLY = re.compile( + r"^(um+|uh+|hmm+|mm+|hm+|ah+|er+|eh+|mhm+)\.?$", + re.IGNORECASE, +) + def normalize_transcript( text: str, preserve_exact: bool = False ) -> tuple[str, list[str]]: - original = text - text = re.sub(r"\s+", " ", text).strip() - rules = ["trim"] if text != original else [] - if text and not preserve_exact and text[-1] not in ".?!": - text += "." - rules.append("punctuation") + """Return (normalized_text, rules_applied). + + Returns ("", ["filler_removed"]) or ("", ["hallucination_removed"]) when the + input is pure noise so callers can discard it without additional checks. + preserve_exact=True skips noise removal and punctuation (pronunciation scoring). + """ + rules: list[str] = [] + + trimmed = _WHITESPACE.sub(" ", text).strip() + if trimmed != text: + rules.append("trim") + text = trimmed + + if not preserve_exact: + if _FILLER_ONLY.match(text): + return "", ["filler_removed"] + + deduped = _deduplicate_repetitions(text) + if deduped != text: + if not deduped: + return "", ["hallucination_removed"] + text = deduped + rules.append("deduplication") + + if text and text[-1] not in ".?!": + text += "." + rules.append("punctuation") + return text, rules + + +def _deduplicate_repetitions(text: str) -> str: + """Remove leading repeated n-grams (Moonshine hallucination artifact). + + "hello world hello world hello world" → "hello world" + "I want to I want to I want to" → "I want to" + "can can can you help me" → "can you help me" + """ + words = text.split() + if len(words) < 3: + return text + for n in (3, 2, 1): + result = _strip_leading_ngram_repeat(words, n) + if result is not None: + return " ".join(result) + return text + + +def _strip_leading_ngram_repeat(words: list[str], n: int) -> list[str] | None: + """Return deduplicated word list if the leading n-gram repeats ≥3 times, else None.""" + if len(words) < n * 3: + return None + seed = words[:n] + pos = 0 + reps = 0 + while pos + n <= len(words) and words[pos : pos + n] == seed: + reps += 1 + pos += n + if reps < 3: + return None + return seed + words[pos:] diff --git a/ai-service/api/services/stt/voice_session.py b/ai-service/api/services/stt/voice_session.py index 834c9df1..612baf4f 100644 --- a/ai-service/api/services/stt/voice_session.py +++ b/ai-service/api/services/stt/voice_session.py @@ -53,8 +53,18 @@ def __init__( self._segment = bytearray() self._segment_start_ms = 0 self._clock_ms = 0 - self._finalizer = SentenceFinalizer(config, registry) + self._finalizer = SentenceFinalizer(config, registry, self.metrics) self._downstream_futures: dict[asyncio.Future, str] = {} + # timing + self._session_start_monotonic = time.monotonic() + self._first_audio_at: float | None = None + self._first_partial_emitted = False + self._vad_speech_end_at: float | None = None + # per-session aggregates (flushed to metrics on stop) + self._finals_count = 0 + self._verified_count = 0 + self._uncertain_count = 0 + self._total_confidence = 0.0 async def start_worker(self) -> None: self._primary = await self.registry.create_primary_session(self.start.language) @@ -103,6 +113,8 @@ async def _run(self) -> None: await self._primary.close() async def _process(self, frame: AudioFrame) -> None: + if self._first_audio_at is None: + self._first_audio_at = time.monotonic() start_ms = self._clock_ms self._clock_ms += frame.duration_ms self.ring.append(frame.pcm16) @@ -119,6 +131,12 @@ async def _process(self, frame: AudioFrame) -> None: ) self.transcripts.set_partial(event) await self.emit(event.model_dump()) + if not self._first_partial_emitted: + self._first_partial_emitted = True + self.metrics.record_latency( + "stt_time_to_first_partial_ms", + (time.monotonic() - self._first_audio_at) * 1000, + ) vad_event = self.vad.process(frame.pcm16, frame.duration_ms) if vad_event.speech_started: pre_roll_bytes = self.config.pre_roll_ms * 16000 * 2 // 1000 @@ -127,6 +145,7 @@ async def _process(self, frame: AudioFrame) -> None: elif self.vad.in_speech: self._segment.extend(frame.pcm16) if vad_event.speech_ended: + self._vad_speech_end_at = time.monotonic() await self._finalize_segment() async def _finalize_segment(self) -> None: @@ -162,14 +181,30 @@ async def _finalize_segment(self) -> None: utterance_id, self.start.user_id, ) - self.transcripts.add_final(final) - await self.emit(final.model_dump(), preserve=True) - if self.final_sink: - completion = await self.final_sink.submit( - final, self.emit_downstream_response - ) - self._downstream_futures[completion] = final.utterance_id - completion.add_done_callback(self._downstream_futures.pop) + if final is not None: + self.transcripts.add_final(final) + await self.emit(final.model_dump(), preserve=True) + if self._vad_speech_end_at is not None: + self.metrics.record_latency( + "stt_final_latency_ms", + (time.monotonic() - self._vad_speech_end_at) * 1000, + ) + self._vad_speech_end_at = None + self._finals_count += 1 + self._total_confidence += final.confidence + if final.verified: + self._verified_count += 1 + if final.uncertain: + self._uncertain_count += 1 + if self.final_sink: + completion = await self.final_sink.submit( + final, self.emit_downstream_response + ) + self._downstream_futures[completion] = final.utterance_id + completion.add_done_callback(self._downstream_futures.pop) + else: + self.transcripts.candidates.pop(utterance_id, None) + self.metrics.increment("stt_noise_utterances_dropped") self.metrics.increment("stt_number_of_segments") self._segment.clear() @@ -227,6 +262,21 @@ async def stop(self, reason: str = "client_stop") -> None: } ) await asyncio.gather(group, return_exceptions=True) + session_duration_s = time.monotonic() - self._session_start_monotonic + self.metrics.record_latency("stt_session_duration_s", session_duration_s) + if self._finals_count > 0: + self.metrics.record_latency( + "stt_session_avg_confidence", + self._total_confidence / self._finals_count, + ) + self.metrics.record_latency( + "stt_session_verify_rate", + self._verified_count / self._finals_count, + ) + self.metrics.record_latency( + "stt_session_uncertain_rate", + self._uncertain_count / self._finals_count, + ) self.writer.close(delete=not self.config.save_audio_for_debug) await self.emit( { diff --git a/ai-service/api/services/trace_cag/benchmark/qa_generation.py b/ai-service/api/services/trace_cag/benchmark/qa_generation.py index 9c17350e..0f15abbb 100644 --- a/ai-service/api/services/trace_cag/benchmark/qa_generation.py +++ b/ai-service/api/services/trace_cag/benchmark/qa_generation.py @@ -9,13 +9,14 @@ from __future__ import annotations +import asyncio import logging import os import re import time from typing import Any, Dict -from api.services.trace_cag.env_helpers import _env_float +from api.services.trace_cag.env_helpers import _env_float, _env_flag, _env_int from api.services.trace_cag.benchmark.ranking import ( _answer_support_score, _content_tokens, @@ -191,6 +192,22 @@ def _generate_extractive_qa_response(question: str, context: str) -> str: return answer or best_sentence +def _is_strong_benchmark_model(model_used: str) -> bool: + """Whether the generating model is capable enough to trust over extractive spans. + + Classify by actual parameter size, not substring tags: the old tag list + ("70b","7b",…) mis-read "groq/qwen/qwen3-32b" as WEAK (no tag matched) and + "qwen3-1.7b" as STRONG ("7b" is a substring of "1.7b") — exactly backwards, + so the main 32B benchmark model got the aggressive extractive override. + """ + model_name = str(model_used or "").strip().lower() + if model_name in ("extractive_fallback", "extractive_policy"): + return False + param_sizes = [float(m) for m in re.findall(r"(\d+(?:\.\d+)?)\s*b\b", model_name)] + max_params_b = max(param_sizes) if param_sizes else 0.0 + return max_params_b >= 14.0 or any(tag in model_name for tag in ("gemini", "gpt", "claude")) + + def _postprocess_benchmark_qa_answer( question: str, raw_answer: str, @@ -216,10 +233,10 @@ def _postprocess_benchmark_qa_answer( if low in {"yes", "no", "unknown"}: return low - # Strong LLM models (70b+) produce high-quality answers that should be trusted - # more than extractive span matching. Only override for clear hallucinations. - model_name = str(model_used or "").strip().lower() - is_strong_llm = any(tag in model_name for tag in ("70b", "7b", "gemini", "gpt", "claude")) and model_name not in ("extractive_fallback", "extractive_policy") + # Capable LLMs produce high-quality answers that should be trusted more than + # extractive span matching; only override for clear hallucinations. + low_model = str(model_used or "").strip().lower() + is_strong_llm = _is_strong_benchmark_model(low_model) extractive_answer = _generate_extractive_qa_response(question, context) llm_support = _answer_support_score(candidate, context) @@ -254,15 +271,43 @@ def _postprocess_benchmark_qa_answer( return candidate -def _truncate_benchmark_context(context: str, question: str, max_chars: int = _BENCHMARK_CONTEXT_MAX_CHARS) -> str: +def _truncate_benchmark_context( + context: str, + question: str, + max_chars: int = _BENCHMARK_CONTEXT_MAX_CHARS, + items: "list[dict] | None" = None, +) -> str: """Truncate context to keep token usage within TPM budget. - Keeps the most relevant passages by scoring each paragraph's lexical overlap - with the question, then greedily fills up to max_chars. + When `items` (the upstream rank-ordered retrieval trace) is supplied, drop + whole lowest-ranked tail items to fit the budget — this preserves the + bridge-aware multihop ordering computed upstream in ranking.py. The single + joined `context` string has no recoverable per-document boundaries (items + are joined with a single "\\n" and start with "[", so the lexical + paragraph-split regex below never fires — it degenerates to a blind + front-truncate that can sever a document mid-sentence). Falls back to that + paragraph-overlap heuristic only when no structured items are available. """ if not context or len(context) <= max_chars: return context + if items: + selected_parts: list[str] = [] + total = 0 + for item in items: + title = str(item.get("title") or "").strip() + text = str(item.get("text") or "").strip() + if not text: + continue + part = f"[{title}] {text}" if title else text + if total + len(part) + 1 > max_chars: + break + selected_parts.append(part) + total += len(part) + 1 + if selected_parts: + return "\n".join(selected_parts) + return context[:max_chars] + paragraphs = [p.strip() for p in re.split(r"\n{2,}|\n(?=[A-Z])|(?<=\.)\s{2,}", context) if p.strip()] if not paragraphs: return context[:max_chars] @@ -313,19 +358,61 @@ async def _generate_benchmark_qa_response(state: TraceCAGState, start_time: floa else: response = "" model_used = "llm_unavailable" - truncated_context = _truncate_benchmark_context(clean_context, question) + truncated_context = _truncate_benchmark_context( + clean_context, question, items=list(state.get("retrieval_trace") or []) + ) _bench_groq_model = os.getenv("GROQ_MODEL", "qwen/qwen3-32b") _no_think_prefix = "/no_think\n" if "qwen" in _bench_groq_model.lower() else "" - system_prompt = ( - _no_think_prefix + - "You are a precise QA system for multi-hop reasoning benchmarks. " - "The context spans multiple passages — read ALL of them, identify key entities and facts, " - "then chain the evidence to reach the answer. " - "Output ONLY the minimal final answer: a short entity/phrase (1–6 words), 'yes', or 'no'. " - "Never explain, never add punctuation or preamble. " - "If genuinely unanswerable from the context, output exactly: unknown" - ) - user_prompt = f"Context:\n{truncated_context}\n\nQuestion: {question}\n\nAnswer (entity or yes/no, max 6 words):" + # E2G ("Evidence → minimal Answer"): one bounded reasoning step before the + # answer, grounded in the passages, with an exemplar that pins HotpotQA + # answer granularity. Targets the dominant failure buckets (2-hop synthesis + # + near-miss span/format), per arXiv:2401.05787 / 2212.10509. Kept bounded + # (still /no_think, no explosion) to stay inside the TPM budget. + # Default OFF: Run 23 (n=64, tracecag_rapid) showed bounded E2G with /no_think + # kept REGRESSES EM 46.9%→39.1% (net −5 EM per-sample: 1 gain, 6 losses) — + # the model writes a post-hoc Evidence line without real reasoning and diverges + # to wrong entities on questions direct answering got right (Phil Spector→"the + # Teddy Bears", YG Entertainment→WINNER). The research-backed gain needs REAL + # Qwen3 thinking (remove /no_think, ~500+ reasoning tokens/call), which blows the + # 500K/day TPD across n=64×3-modes. Knob kept for a future quota-permitting test. + _use_e2g = _env_flag("TRACECAG_BENCHMARK_E2G", False) + _bench_max_tokens = _env_int("TRACECAG_BENCHMARK_MAX_TOKENS", 220 if _use_e2g else 96) + if _use_e2g: + system_prompt = ( + _no_think_prefix + + "You are a precise multi-hop QA system. Read ALL passages, find the " + "supporting fact(s), and chain them across passages to reach the answer.\n" + "Then give the MINIMAL final answer copied in the gold style: a short " + "entity/phrase (1–6 words), 'yes', or 'no'. Use the exact surface form as " + "it appears in the passage; do NOT add titles, honorifics, given names, " + "dates, or parentheticals beyond what the question asks. If the context " + "does not contain the answer, output 'unknown'.\n" + "Respond in EXACTLY this format, nothing else:\n" + "Evidence: \n" + "Answer: \n\n" + "Example:\n" + "Context:\n" + "[Doctor Strange (2016 film)] Doctor Strange is a 2016 Marvel film starring " + "Benedict Cumberbatch, directed by Scott Derrickson.\n" + "[Scott Derrickson] Scott Derrickson is an American director.\n" + "Question: What nationality is the director of the 2016 film starring " + "Benedict Cumberbatch as the title role?\n" + "Evidence: Doctor Strange (2016) stars Cumberbatch and was directed by Scott " + "Derrickson, who is American.\n" + "Answer: American" + ) + user_prompt = f"Context:\n{truncated_context}\n\nQuestion: {question}" + else: + system_prompt = ( + _no_think_prefix + + "You are a precise QA system for multi-hop reasoning benchmarks. " + "The context spans multiple passages — read ALL of them, identify key entities and facts, " + "then chain the evidence to reach the answer. " + "Output ONLY the minimal final answer: a short entity/phrase (1–6 words), 'yes', or 'no'. " + "Never explain, never add punctuation or preamble. " + "If genuinely unanswerable from the context, output exactly: unknown" + ) + user_prompt = f"Context:\n{truncated_context}\n\nQuestion: {question}\n\nAnswer (entity or yes/no, max 6 words):" try: import httpx @@ -340,15 +427,18 @@ async def _generate_benchmark_qa_response(state: TraceCAGState, start_time: floa break if provider == "groq": - from api.core.groq_key_pool import get_available_groq_key, record_groq_key_usage, get_groq_key_pool + from api.core.groq_key_pool import get_available_groq_key, record_groq_key_usage, get_configured_key_count groq_model = os.getenv("GROQ_MODEL", "qwen/qwen3-32b") # Retry across all available keys — skip keys returning 401 (expired/invalid) - _pool = get_groq_key_pool() - _max_key_tries = _pool.count if _pool else 4 + # 5 retries (2s..32s, ~62s worst case): Run 19 showed Groq's qwen3-32b + # over-capacity periods can outlast 3 retries (14s) for a sample. + _max_503_retries = 5 + _max_key_tries = (get_configured_key_count() or 1) + _max_503_retries _tried_groq_keys: set = set() + _503_retry_count = 0 for _key_attempt in range(_max_key_tries): groq_key = await get_available_groq_key(estimated_tokens=96) - if not groq_key or groq_key in _tried_groq_keys: + if not groq_key or (groq_key in _tried_groq_keys and _503_retry_count >= _max_503_retries): break _tried_groq_keys.add(groq_key) try: @@ -356,17 +446,29 @@ async def _generate_benchmark_qa_response(state: TraceCAGState, start_time: floa provider="groq", url="https://api.groq.com/openai/v1/chat/completions", headers={"Authorization": f"Bearer {groq_key}", "Content-Type": "application/json"}, - payload={"model": groq_model, "messages": messages, "max_tokens": 96, "temperature": 0.0}, + payload={"model": groq_model, "messages": messages, "max_tokens": _bench_max_tokens, "temperature": 0.0}, httpx_module=httpx, timeout=30.0, ) if resp is not None and resp.status_code == 200: data = resp.json() - tokens = data.get("usage", {}).get("total_tokens", 96) + tokens = data.get("usage", {}).get("total_tokens", _bench_max_tokens) await record_groq_key_usage(groq_key, tokens) _raw = data["choices"][0]["message"]["content"].strip() # Strip blocks (Qwen3 thinking mode) - response = re.sub(r".*?\s*", "", _raw, flags=re.DOTALL).strip() + _raw = re.sub(r".*?\s*", "", _raw, flags=re.DOTALL).strip() + # E2G: keep only the final "Answer:" line (drop the Evidence + # reasoning so it never reaches the EM/F1 scorer). Fall back to + # the last non-empty line if the model skipped the format. + if _use_e2g: + _ans_lines = re.findall(r"(?im)^\s*(?:final\s+)?answer\s*:\s*(.+?)\s*$", _raw) + if _ans_lines: + response = _ans_lines[-1].strip() + else: + _nonempty = [ln.strip() for ln in _raw.splitlines() if ln.strip()] + response = (_nonempty[-1] if _nonempty else _raw).strip() + else: + response = _raw model_used = f"groq/{groq_model}" break elif resp is not None and resp.status_code == 401: @@ -374,6 +476,19 @@ async def _generate_benchmark_qa_response(state: TraceCAGState, start_time: floa "[_generate_benchmark_qa_response] Groq key invalid (401), trying next key" ) continue + elif resp is not None and resp.status_code == 503 and _503_retry_count < _max_503_retries: + # Transient "over capacity" — Groq's own error message asks for + # exponential backoff; the old code gave up to extractive_fallback + # on the very first occurrence instead of honoring that. + _503_retry_count += 1 + _backoff = 2 ** _503_retry_count + logger.warning( + "[_generate_benchmark_qa_response] Groq 503 over capacity, " + "backing off %ss (retry %d/%d)", + _backoff, _503_retry_count, _max_503_retries, + ) + await asyncio.sleep(_backoff) + continue else: logger.warning( "[_generate_benchmark_qa_response] Groq returned %s: %s", diff --git a/ai-service/api/services/trace_cag/benchmark/ranking.py b/ai-service/api/services/trace_cag/benchmark/ranking.py index d209ab47..3429aa33 100644 --- a/ai-service/api/services/trace_cag/benchmark/ranking.py +++ b/ai-service/api/services/trace_cag/benchmark/ranking.py @@ -16,6 +16,16 @@ # Local copy of the decay constant (mirrors nodes_v2._RECENCY_LAMBDA). _RECENCY_LAMBDA = 0.01 +# tracecag_rapid bridge/graph weight. NOTE (2026-06-26, Run 22): raising this +# from 0.18→0.80 was offline-validated to lift recall@5 79%→84% and +# both-supporting@7 69%→78% with flat rank-1 precision — yet live it REGRESSED +# end-to-end EM 46.9%→43.8% (recovered 0 of the 25% "gold dropped by ranking" +# questions, flipped 2 clean wins via added distractors). Lesson: retrieval +# recall is NOT the binding constraint here — the 32B reader's multi-hop +# synthesis is — so more/higher-ranked evidence the reader can't use is +# net-negative. Default stays 0.18 (the EM-best config); knob kept for research. +_RAPID_GRAPH_WEIGHT = _env_float("TRACECAG_RAPID_GRAPH_WEIGHT", 0.18) + # ── Text helpers ────────────────────────────────────────────────────────────── @@ -292,6 +302,7 @@ def _compute_evidence_budget( benchmark_mode: str, benchmark_candidates: bool, adaptive_profile: str = "", + benchmark_task: str = "", ) -> int: # Lazy import to avoid circular dependency at module load time. from api.services.trace_cag.benchmark.adaptive import _ADAPTIVE_PROFILES @@ -301,8 +312,6 @@ def _compute_evidence_budget( complexity = _question_complexity_score(question) budget = base + max(0, complexity - 2) - benchmark_task = "multihop_qa" if "multihop" in (question or "").lower() else "" - if retrieval_policy == "rapid": # Keep full evidence budget for multihop-style questions in rapid mode. if benchmark_task != "multihop_qa": @@ -436,7 +445,7 @@ def _rank_benchmark_candidates( # targets win at the cost of demoting strong direct matches. final_score = ( base_score - + (0.18 * graph_score) + + (_RAPID_GRAPH_WEIGHT * graph_score) + (0.06 * memory_score) + (0.06 * query_coverage) + (0.05 * anchor_coverage) diff --git a/ai-service/api/services/trace_cag/cache_utils.py b/ai-service/api/services/trace_cag/cache_utils.py index a65202ac..588a4459 100644 --- a/ai-service/api/services/trace_cag/cache_utils.py +++ b/ai-service/api/services/trace_cag/cache_utils.py @@ -60,6 +60,11 @@ def _is_exact_reuse_match(fingerprint: CacheFingerprint, entry: CacheEntry) -> b if fingerprint.get("level") != cached_fingerprint.get("level"): return False + cur_lang = fingerprint.get("native_language") + cached_lang = cached_fingerprint.get("native_language") + if cur_lang and cached_lang and cur_lang != cached_lang: + return False + current_intent = fingerprint.get("intent", "unknown") cached_intent = cached_fingerprint.get("intent", "unknown") if current_intent != "unknown" and cached_intent != "unknown" and current_intent != cached_intent: @@ -83,10 +88,18 @@ def _compute_reuse_risk( ρ = clip[0,1]( w1·ΔI + w2·ΔC + w3·Δℓ + w4·Δprog + w5·s ) """ + # A cached response generated for a different native language can't be + # reused or patched — the hint/explanation text is in the wrong language + # regardless of how close the grammar/intent/level match is. + cached_fp = entry.get("fingerprint") or {} + cur_lang = fingerprint.get("native_language") + cached_lang = cached_fp.get("native_language") + if cur_lang and cached_lang and cur_lang != cached_lang: + return 1.0 + # Compare intents fingerprint-to-fingerprint: both sides are built with the # same pre-diagnosis extractor. execution_plan.intent is diagnosis-stage # vocabulary and would read as drift even for identical queries. - cached_fp = entry.get("fingerprint") or {} cur_intent = fingerprint.get("intent", "unknown") cached_intent = cached_fp.get("intent") or (entry.get("execution_plan") or {}).get("intent", "unknown") if cur_intent == "unknown" or cached_intent == "unknown": @@ -145,6 +158,7 @@ def _build_fingerprint(state: TraceCAGState) -> CacheFingerprint: query_norm=state.get("user_input", "").strip().lower(), intent=_infer_intent_pre_diagnosis(state.get("user_input", "")), level=state.get("learner_profile", {}).get("level", "B1"), + native_language=state.get("learner_profile", {}).get("native_language", "Vietnamese"), root_concepts=_extract_lightweight_graph_concepts(state.get("user_input", "")), session_turn=len(state.get("conversation_history", [])), ) @@ -201,6 +215,7 @@ def _build_l1_request_signature( answer_target=_answer_target_hint(user_input), relation_hints=_relation_hints(user_input), evidence_hash="", + native_language=str(fingerprint.get("native_language") or ""), ) hints = state_hints or {} return replace( @@ -255,7 +270,8 @@ def _build_l1_candidate_signature( answer_target=_answer_target_hint(candidate_query), relation_hints=_relation_hints(candidate_query), evidence_hash=_evidence_dependency_hash(entry), - created_at=float(entry.get("created_at") or time.monotonic()), + native_language=str(candidate_fp.get("native_language") or ""), + created_at=float(entry.get("created_at") or time.time()), ttl=int(entry.get("ttl") or 3600), ) return replace( @@ -782,7 +798,10 @@ async def _write_cache_entry( ) ttl = 3600 if errors else 1800 - now = time.monotonic() + # Wall-clock, not monotonic: created_at is serialized into Redis and read + # back by other processes/hosts, where monotonic()'s reference point is + # undefined (Python docs) and only happens to align on a single host. + now = time.time() raw_bundle = [ {"type": "kg", "content": c} @@ -908,10 +927,12 @@ async def cache_gate_node(state: TraceCAGState) -> Dict[str, Any]: bucket = _build_graph_bucket(user_input, level, intent_hint, profile_epoch, conversation_history) l1_buckets = _build_l1_bucket_aliases(user_input, level, intent_hint, profile_epoch, conversation_history) + native_language = learner_profile.get("native_language", "Vietnamese") fingerprint = CacheFingerprint( query_norm=user_input.strip().lower(), intent=intent_hint, level=level, + native_language=native_language, root_concepts=_extract_lightweight_graph_concepts(user_input), session_turn=len(conversation_history), ) @@ -985,7 +1006,9 @@ def _with_gate_meta( best_rejection: tuple[float, tuple[str, ...]] | None = None # --- Try in-process cache --- - now = time.monotonic() + # Wall-clock (see _write_cache_entry): must compare against created_at + # values that may have been written by a different process/host via Redis. + now = time.time() entry = await _get_cache_entry(cache_key, level, now) if entry: if not _cache_entry_quality_ok_for_benchmark(entry, benchmark_task): diff --git a/ai-service/api/services/trace_cag/l1_state_cache.py b/ai-service/api/services/trace_cag/l1_state_cache.py index 55bcf769..a6170fae 100644 --- a/ai-service/api/services/trace_cag/l1_state_cache.py +++ b/ai-service/api/services/trace_cag/l1_state_cache.py @@ -25,6 +25,7 @@ class L1Request: answer_target: str = "" relation_hints: set[str] = field(default_factory=set) evidence_hash: str = "" + native_language: str = "" @dataclass(frozen=True) @@ -42,6 +43,7 @@ class L1Candidate: answer_target: str = "" relation_hints: set[str] = field(default_factory=set) evidence_hash: str = "" + native_language: str = "" created_at: float = 0.0 ttl: int = 3600 @@ -88,11 +90,16 @@ def decide_l1_reuse( Risk scoring then separates exact safe reuse from near-hit patching. """ - current_time = time.monotonic() if now is None else now + # Wall-clock: candidate.created_at is read back from Redis and may have + # been written by a different process/host (monotonic()'s reference point + # is undefined across processes per the stdlib docs). + current_time = time.time() if now is None else now reasons: list[str] = [] if request.level != candidate.level: reasons.append("level_mismatch") + if not _empty_or_equal(request.native_language, candidate.native_language): + reasons.append("native_language_mismatch") if request.profile_epoch != candidate.profile_epoch: reasons.append("profile_epoch_mismatch") if not _empty_or_equal(request.intent, candidate.intent): diff --git a/ai-service/api/services/trace_cag/nodes_v2.py b/ai-service/api/services/trace_cag/nodes_v2.py index b8595e7d..8bdf9f7b 100644 --- a/ai-service/api/services/trace_cag/nodes_v2.py +++ b/ai-service/api/services/trace_cag/nodes_v2.py @@ -719,7 +719,7 @@ async def vietnamese_node(state: TraceCAGState) -> Dict[str, Any]: # --- Attempt 3: Hardcoded strings --- if not native_hint: - native_hint = _get_predefined_vietnamese(errors) + native_hint = _get_predefined_native_hint(errors, native_language) models_used.append("native_fallback") latency_ms = int((time.time() - start_time) * 1000) @@ -732,26 +732,69 @@ async def vietnamese_node(state: TraceCAGState) -> Dict[str, Any]: except Exception as e: logger.error(f"[vietnamese_node] Error: {e}") return { - "vietnamese_hint": _get_predefined_vietnamese(errors), + "vietnamese_hint": _get_predefined_native_hint(errors, native_language), "models_used": ["native_fallback"], } -def _get_predefined_vietnamese(errors: list) -> str: - """Fallback predefined Vietnamese explanations""" - if not errors: - return "Câu của bạn rất tốt! Tiếp tục cố gắng nhé! " - - error_type = errors[0].get("type", "").lower() - explanations = { +# Last-resort hardcoded explanations, keyed by language name (matches the +# `native_language` values produced by api.utils.languages.iso_to_language_name). +# Only the most common onboarding languages get a translated dict — anything +# else falls back to a short English line rather than translating every string. +_PREDEFINED_NATIVE_EXPLANATIONS: Dict[str, Dict[str, str]] = { + "Vietnamese": { + "_no_errors": "Câu của bạn rất tốt! Tiếp tục cố gắng nhé! ", + "_default": "Hãy chú ý quy tắc ngữ pháp này nhé!", "subject_verb_agreement": "Trong tiếng Anh, động từ phải hòa hợp với chủ ngữ. Với 'I/you/we/they' dùng động từ nguyên mẫu, với 'he/she/it' thêm -s hoặc -es.", "third_person_s": "Với chủ ngữ ngôi thứ 3 số ít (he, she, it), động từ cần thêm -s hoặc -es. Ví dụ: He goes, She works.", "past_tense": "Khi nói về quá khứ (yesterday, last week...), cần dùng thì quá khứ đơn. Động từ bất quy tắc cần học thuộc!", "present_perfect": "Thì hiện tại hoàn thành dùng: have/has + past participle. Ví dụ: have gone, has eaten.", "article": "Dùng 'a' trước phụ âm, 'an' trước nguyên âm (a, e, i, o, u). Ví dụ: a book, an apple.", - } - - return explanations.get(error_type, "Hãy chú ý quy tắc ngữ pháp này nhé!") + }, + "Japanese": { + "_no_errors": "あなたの文章はとても良いです!頑張り続けてください!", + "_default": "この文法のルールに気をつけてください!", + "subject_verb_agreement": "英語では動詞は主語と一致させる必要があります。'I/you/we/they'には原形、'he/she/it'には-sか-esを付けます。", + "third_person_s": "三人称単数の主語(he, she, it)には、動詞に-sか-esを付けます。例:He goes, She works.", + "past_tense": "過去のことを話すとき(yesterday, last weekなど)は過去形を使います。不規則動詞は覚える必要があります!", + "present_perfect": "現在完了形は have/has + 過去分詞 を使います。例:have gone, has eaten.", + "article": "子音の前には'a'、母音(a, e, i, o, u)の前には'an'を使います。例:a book, an apple.", + }, + "Korean": { + "_no_errors": "문장이 정말 좋아요! 계속 노력하세요!", + "_default": "이 문법 규칙에 주의하세요!", + "subject_verb_agreement": "영어에서는 동사가 주어와 일치해야 합니다. 'I/you/we/they'는 원형을, 'he/she/it'는 -s나 -es를 붙입니다.", + "third_person_s": "3인칭 단수 주어(he, she, it)는 동사에 -s나 -es를 붙입니다. 예: He goes, She works.", + "past_tense": "과거(yesterday, last week 등)를 말할 때는 과거 시제를 사용합니다. 불규칙 동사는 외워야 해요!", + "present_perfect": "현재완료는 have/has + 과거분사를 사용합니다. 예: have gone, has eaten.", + "article": "자음 앞에는 'a', 모음(a, e, i, o, u) 앞에는 'an'을 사용합니다. 예: a book, an apple.", + }, + "Chinese": { + "_no_errors": "你的句子很好!继续努力!", + "_default": "请注意这个语法规则!", + "subject_verb_agreement": "在英语中,动词必须与主语一致。'I/you/we/they'用原形动词,'he/she/it'要加-s或-es。", + "third_person_s": "第三人称单数主语(he, she, it)的动词要加-s或-es。例如:He goes, She works.", + "past_tense": "说过去的事情(yesterday, last week等)要用过去时。不规则动词需要记住!", + "present_perfect": "现在完成时用:have/has + 过去分词。例如:have gone, has eaten.", + "article": "辅音前用'a',元音(a, e, i, o, u)前用'an'。例如:a book, an apple.", + }, +} + +_FALLBACK_NATIVE_NO_ERRORS = "Nice work, your sentence is correct! Keep practicing!" +_FALLBACK_NATIVE_DEFAULT = "Pay attention to this grammar rule!" + + +def _get_predefined_native_hint(errors: list, native_language: str) -> str: + """Last-resort fallback explanation in the learner's native language.""" + explanations = _PREDEFINED_NATIVE_EXPLANATIONS.get(native_language) + if not explanations: + return _FALLBACK_NATIVE_NO_ERRORS if not errors else _FALLBACK_NATIVE_DEFAULT + + if not errors: + return explanations["_no_errors"] + + error_type = errors[0].get("type", "").lower() + return explanations.get(error_type, explanations["_default"]) # ============================================================ diff --git a/ai-service/api/services/trace_cag/retrieve.py b/ai-service/api/services/trace_cag/retrieve.py index 7330a73e..837917dc 100644 --- a/ai-service/api/services/trace_cag/retrieve.py +++ b/ai-service/api/services/trace_cag/retrieve.py @@ -398,6 +398,7 @@ def _elapsed_ms() -> float: benchmark_mode=benchmark_mode, benchmark_candidates=bool(benchmark_candidates), adaptive_profile=adaptive_profile, + benchmark_task=benchmark_task, ) if benchmark_candidates and benchmark_task in {"multihop_qa", "retrieval_qa"}: top_evidence = _select_diverse_multihop_evidence( diff --git a/ai-service/api/services/trace_cag/state.py b/ai-service/api/services/trace_cag/state.py index ce9d0302..29f543d2 100644 --- a/ai-service/api/services/trace_cag/state.py +++ b/ai-service/api/services/trace_cag/state.py @@ -56,6 +56,7 @@ class CacheFingerprint(TypedDict, total=False): query_norm: str # normalized user input intent: str # diagnosed intent level: str # CEFR level + native_language: str # learner's native language (e.g. Vietnamese) root_concepts: List[str] # root-cause concept IDs session_turn: int # turn number within session @@ -75,7 +76,7 @@ class CacheEntry(TypedDict, total=False): fluency_score: float vocabulary_level: str action_plan: List[Dict[str, Any]] - created_at: float # t_c: timestamp (monotonic) + created_at: float # t_c: timestamp (wall-clock epoch — Redis-persisted, read across processes/hosts) ttl: int # seconds diff --git a/ai-service/api/utils/languages.py b/ai-service/api/utils/languages.py new file mode 100644 index 00000000..2b3cc944 --- /dev/null +++ b/ai-service/api/utils/languages.py @@ -0,0 +1,19 @@ +"""ISO 639-1 code -> English language name, scoped to the onboarding language +list in flutter-app/lib/core/l10n/app_localizations.dart (AppLocales.metadata). +""" + +from __future__ import annotations + +_ISO_TO_LANGUAGE_NAME: dict[str, str] = { + "vi": "Vietnamese", + "en": "English", + "ja": "Japanese", + "ko": "Korean", + "zh": "Chinese", + "fr": "French", + "es": "Spanish", +} + + +def iso_to_language_name(code: str) -> str: + return _ISO_TO_LANGUAGE_NAME.get((code or "").strip().lower(), "Vietnamese") diff --git a/ai-service/docs/superpowers/plans/2026-06-25-tracecag-service-packaging.md b/ai-service/docs/superpowers/plans/2026-06-25-tracecag-service-packaging.md new file mode 100644 index 00000000..a1d15b3d --- /dev/null +++ b/ai-service/docs/superpowers/plans/2026-06-25-tracecag-service-packaging.md @@ -0,0 +1,45 @@ +# TRACE-CAG Service Packaging Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a portable TRACE-CAG service package that can be imported by other projects without breaking the existing LexiLingo app. + +**Architecture:** The new package defines stable request/response schemas and a runtime wrapper around a pluggable analyzer protocol. The existing app is integrated through a lazy LexiLingo adapter, while a dependency-free in-memory adapter proves portability. + +**Tech Stack:** Python dataclasses, Protocol typing, asyncio, pytest. + +--- + +## Chunk 1: Additive Package + +**Files:** +- Create: `service/__init__.py` +- Create: `service/tracecag_service/__init__.py` +- Create: `service/tracecag_service/config.py` +- Create: `service/tracecag_service/schemas.py` +- Create: `service/tracecag_service/ports.py` +- Create: `service/tracecag_service/runtime.py` +- Create: `service/tracecag_service/core/fingerprint.py` +- Create: `service/tracecag_service/core/scar_l1.py` +- Create: `service/tracecag_service/adapters/lexilingo.py` +- Create: `service/tracecag_service/adapters/memory.py` +- Create: `service/tracecag_service/README.md` + +- [ ] Add portable dataclass schemas. +- [ ] Add analyzer protocol and service runtime. +- [ ] Add pure fingerprint and SCAR-L1 helpers. +- [ ] Add lazy adapter to the existing LexiLingo pipeline. +- [ ] Add in-memory adapter for dependency-free use. +- [ ] Document usage. + +## Chunk 2: Tests + +**Files:** +- Create: `tests/service/test_tracecag_service_runtime.py` +- Create: `tests/service/test_tracecag_memory_adapter.py` +- Create: `tests/service/test_tracecag_lexilingo_adapter.py` + +- [ ] Test runtime accepts a pluggable analyzer and normalizes dict responses. +- [ ] Test in-memory adapter cache miss, exact reuse, and L1 patch behavior. +- [ ] Test LexiLingo adapter lazily calls the existing pipeline with mapped kwargs. +- [ ] Run targeted pytest for `tests/service`. diff --git a/ai-service/docs/superpowers/specs/2026-06-25-tracecag-service-packaging-design.md b/ai-service/docs/superpowers/specs/2026-06-25-tracecag-service-packaging-design.md new file mode 100644 index 00000000..b410c9c1 --- /dev/null +++ b/ai-service/docs/superpowers/specs/2026-06-25-tracecag-service-packaging-design.md @@ -0,0 +1,32 @@ +# TRACE-CAG Service Packaging Design + +## Goal + +Package the reusable TRACE-CAG runtime behind a stable service contract without changing the existing FastAPI routes, pipeline graph, Redis, KG, or model gateway behavior. + +## Boundary + +The new code lives under `service/tracecag_service/`. It is importable as a Python package and has no import-time dependency on `api.*`. The current LexiLingo pipeline remains the production implementation and is reached only through a lazy adapter. + +## Design + +The package exposes: + +- `TraceCAGRequest` and `TraceCAGResponse` dataclasses for a stable cross-project contract. +- `TraceCAGService`, a small lifecycle wrapper with validation, timeouts, batch concurrency, and response normalization. +- `TraceCAGAnalyzer`, a protocol that any project can implement. +- `LexiLingoTraceCAGAnalyzer`, a lazy adapter to the existing `api.services.trace_cag.graph.get_trace_cag()` pipeline. +- `InMemoryTraceCAGAnalyzer`, a dependency-free adapter for local tests, demos, and new projects before they wire real KG/LLM backends. +- Portable core helpers for fingerprinting and SCAR-L1 reuse decisions. + +## Non-Breaking Rules + +- Do not move or rename existing `api/services/trace_cag` modules in this pass. +- Do not edit FastAPI routes. +- Do not change existing singleton lifecycle. +- Do not import `api.*` from package top-level modules. +- Keep the current app path intact; the new service is additive. + +## Follow-Up + +After this additive package is stable, pure logic can be moved gradually from `api/services/trace_cag` into `service/tracecag_service/core` with compatibility re-exports. diff --git a/ai-service/docs/tracecag_service_guide.md b/ai-service/docs/tracecag_service_guide.md new file mode 100644 index 00000000..e374af6c --- /dev/null +++ b/ai-service/docs/tracecag_service_guide.md @@ -0,0 +1,408 @@ +# TRACE-CAG Portable Service Guide + +This document explains how to use the packaged TRACE-CAG service and the theory +behind its reusable cache-aware generation mechanism. + +## 1. Purpose + +The goal of `service/tracecag_service` is to turn TRACE-CAG from an app-local +pipeline into a reusable service contract. A new project should be able to: + +1. import `TraceCAGRequest`, `TraceCAGResponse`, and `TraceCAGService`; +2. provide an analyzer adapter for its own KG/cache/LLM stack; +3. keep the core TRACE-CAG ideas: state fingerprinting, cache safety, graph-aware + near-hit reuse, and conservative fallback; +4. avoid importing LexiLingo FastAPI modules unless it explicitly chooses the + LexiLingo adapter. + +This package is intentionally additive. It does not move or rename the current +`api/services/trace_cag` implementation. + +## 2. High-Level Architecture + +```text +Caller + | + v +TraceCAGRequest + | + v +TraceCAGService + |-- validation + |-- timeout + |-- batch concurrency + |-- response normalization + | + v +TraceCAGAnalyzer adapter + |-- LexiLingo adapter: current production pipeline + |-- Memory adapter: dependency-free L0/L1 demo + |-- Custom adapter: any project-specific implementation + | + v +TraceCAGResponse +``` + +The important design decision is the `TraceCAGAnalyzer` port: + +```python +async def analyze(request: TraceCAGRequest) -> TraceCAGResponse | dict: + ... +``` + +Everything else is replaceable. One project can use LangGraph; another can use +a simple function; another can call an external HTTP service. The caller still +sees the same request and response contract. + +## 3. Package Boundaries + +| Layer | Files | Responsibility | +| --- | --- | --- | +| Contract | `schemas.py` | Stable request and response DTOs. | +| Runtime | `runtime.py`, `config.py` | Service facade, validation, timeout, batch execution. | +| Port | `ports.py` | Protocol that adapters implement. | +| Core mechanism | `core/fingerprint.py`, `core/scar_l1.py` | Pure cache-state logic. | +| Adapters | `adapters/lexilingo.py`, `adapters/memory.py` | Bind the contract to a concrete implementation. | + +Top-level package imports should stay lightweight. The only `api.*` import is +inside `LexiLingoTraceCAGAnalyzer._get_pipeline()`, so importing the portable +package in another project does not boot the LexiLingo app. + +## 4. Usage In LexiLingo + +Use this when you want the new service contract but still want the existing +pipeline implementation: + +```python +from service.tracecag_service import TraceCAGRequest +from service.tracecag_service.adapters.lexilingo import create_lexilingo_service + +service = create_lexilingo_service() + +response = await service.analyze( + TraceCAGRequest( + user_input="I go to school yesterday.", + session_id="lexi-session-1", + user_id="user-1", + input_type="text", + learner_profile={"level": "B1"}, + ) +) +``` + +What happens: + +1. `TraceCAGService` validates the request. +2. `LexiLingoTraceCAGAnalyzer` lazily loads `get_trace_cag()`. +3. `TraceCAGRequest.to_pipeline_kwargs()` maps the portable DTO to the current + `TraceCAGPipeline.analyze(...)` signature. +4. The existing production pipeline runs. +5. The result is normalized into `TraceCAGResponse`. + +## 5. Usage In A New Project + +Start with a custom analyzer: + +```python +from service.tracecag_service import TraceCAGRequest, TraceCAGResponse, TraceCAGService + + +class ProjectAnalyzer: + async def analyze(self, request: TraceCAGRequest) -> TraceCAGResponse: + profile = request.learner_profile or {"level": "B1"} + return TraceCAGResponse( + tutor_response=f"Project answer for {profile['level']}: {request.user_input}", + metadata={"path": "slow", "cache_hit": False}, + ) + + +service = TraceCAGService(ProjectAnalyzer()) +response = await service.analyze_text("Explain present perfect", session_id="demo") +``` + +Then incrementally replace the placeholder with: + +1. a real profile/history store; +2. a KG or document retriever; +3. a cache store; +4. an LLM generator; +5. the pure fingerprint and SCAR-L1 logic from `core/`. + +## 6. TRACE-CAG Mechanism + +TRACE-CAG is a cache-aware generation strategy. Its central question is: + +> Can this request reuse an existing response artifact without violating user, +> profile, graph, evidence, or task state? + +The answer is ternary: + +| Decision | Meaning | Cost profile | +| --- | --- | --- | +| `reuse` | Cached response is state-compatible and can be returned directly. | Lowest latency/cost. | +| `patch` | Cached artifact is near enough, but the caller should apply a small adaptation. | Low latency/cost. | +| `full` | Cache is unsafe or unavailable; run full retrieval/generation. | Highest latency/cost, safest fallback. | + +The portable package currently exposes two core mechanisms: + +- fingerprinting: convert request/profile/history into stable state features; +- SCAR-L1: decide whether a graph-bucket near-hit is safe to reuse or patch. + +## 7. State Fingerprinting + +`core/fingerprint.py` builds `TraceCAGFingerprint`: + +```text +TraceCAGFingerprint + query_norm normalized text + intent correct | explain | practice | ask + level A1..C2, default B1 + profile_epoch hash of coarse learner profile state + session_turn conversation history length + concepts grammar/topic/entity hints + entities stable entity-like tokens + answer_target feedback | person | place | time | number | entity + relation_hints founder | author | location | comparison | ... +``` + +The fingerprint serves two jobs: + +1. Build an L0 exact cache key with `build_cache_key(fingerprint)`. +2. Build an L1 graph-state bucket with `build_graph_bucket(fingerprint)`. + +Why not cache only by normalized text? Because two identical-looking questions +can be unsafe to reuse across learner level, profile progress, task intent, or +graph/evidence state. + +## 8. L0 Exact Reuse + +L0 is the fastest path. The memory adapter computes: + +```python +fingerprint = build_fingerprint(...) +cache_key = build_cache_key(fingerprint) +``` + +If the exact key exists and the entry is not stale, the adapter returns: + +```text +metadata.cache_hit = true +metadata.cache_layer = "L0" +metadata.cache_decision = "reuse" +metadata.path = "fast" +``` + +L0 is strict by construction because its key includes normalized query, intent, +CEFR level, profile epoch, and session turn. + +## 9. L1 Graph-Aware Near-Hit Reuse + +L1 exists because many safe requests are not exact string matches: + +- "Who founded AlphaSoft?" +- "Who created AlphaSoft?" + +These may be equivalent enough to avoid full generation if state is compatible. +The service builds a graph bucket using stable state features: + +```python +bucket = build_graph_bucket(fingerprint) +``` + +Then it evaluates candidates with SCAR-L1. + +## 10. SCAR-L1 Decision Logic + +`core/scar_l1.py` compares: + +- `L1Request`: current request state; +- `L1Candidate`: cached artifact state. + +Hard gates reject unsafe candidates before risk scoring: + +| Gate | Why it matters | +| --- | --- | +| `level_mismatch` | A C1 answer may be wrong pedagogically for A2. | +| `profile_epoch_mismatch` | Learner progress/profile changed enough to invalidate artifact assumptions. | +| `intent_mismatch` | Explaining, correcting, and asking are different tasks. | +| `answer_target_mismatch` | Person/place/time/number answers are not interchangeable. | +| `evidence_mismatch` | Underlying evidence changed. | +| `relation_mismatch` | Graph relation path changed. | +| `concept_overlap_below_floor` | Near-hit is too semantically far. | +| `stale_candidate` | TTL expired. | + +If hard gates pass, risk is computed: + +```text +risk = + 0.30 * intent_drift ++ 0.25 * (1 - concept_overlap) ++ 0.20 * (1 - entity_overlap) ++ 0.15 * (1 - relation_overlap) ++ 0.10 * age_ratio +``` + +Then: + +```text +if exact normalized query and risk <= tau_reuse: + decision = reuse +elif risk <= tau_patch: + decision = patch +else: + decision = full +``` + +Defaults: + +```text +tau_reuse = 0.25 +tau_patch = 0.55 +concept_floor = 0.50 +``` + +This keeps L1 conservative: it accepts only state-compatible near-hits and falls +back to full reconstruction when uncertainty is too high. + +## 11. PCC And SCAR-L1 Relationship + +PCC means "profile/context/certificate" style safety checking around reuse. In +the current app, PCC logic also lives in `api/services/trace_cag/cache_utils.py` +and includes Redis-backed L0/L1 orchestration, bucket versions, cache writes, +and benchmark-aware policy hooks. + +The portable package extracts the safest core idea: + +1. represent current state as a fingerprint; +2. represent candidate state as a certificate; +3. apply hard compatibility gates; +4. calculate reuse risk; +5. return `reuse`, `patch`, or `full`. + +This lets another project adopt the same reuse safety model without importing +the LexiLingo app. + +## 12. Production Adapter Vs Memory Adapter + +| Adapter | Use case | Dependencies | +| --- | --- | --- | +| `LexiLingoTraceCAGAnalyzer` | Use current production TRACE-CAG from this repo. | Existing `api.services.trace_cag` stack. | +| `InMemoryTraceCAGAnalyzer` | Tests, demos, new-project scaffold, local examples. | No external services. | +| Custom analyzer | Any other project. | Whatever the project chooses. | + +The memory adapter is not a full tutor. It intentionally does not call an LLM. +Its job is to prove and demonstrate the portable cache-state mechanism. + +## 13. Implementing A Real Adapter + +A real adapter should usually perform these steps: + +1. Build request fingerprint. +2. Try L0 exact cache. +3. Try L1 candidate pool plus SCAR-L1. +4. If `reuse`, return cached artifact. +5. If `patch`, adapt the cached artifact and return it with metadata. +6. If `full`, run retrieval, diagnosis, generation, then write a new cache entry. +7. Normalize the result into `TraceCAGResponse`. + +Skeleton: + +```python +from service.tracecag_service import TraceCAGRequest, TraceCAGResponse +from service.tracecag_service.core import build_cache_key, build_fingerprint, build_graph_bucket + + +class RealAnalyzer: + async def analyze(self, request: TraceCAGRequest) -> TraceCAGResponse: + fingerprint = build_fingerprint( + user_input=request.user_input, + learner_profile=request.learner_profile, + conversation_history=request.conversation_history, + ) + cache_key = build_cache_key(fingerprint) + bucket = build_graph_bucket(fingerprint) + + # 1. check exact cache by cache_key + # 2. check L1 candidates by bucket and decide_l1_reuse + # 3. run full KG/retrieval/LLM if needed + # 4. write cache entry + # 5. return TraceCAGResponse +``` + +## 14. Voice/STT Boundary + +The service contract supports voice through: + +```python +TraceCAGRequest( + user_input=final_transcript_text, + input_type="voice", + stt_final=final_event_dict, +) +``` + +Only final transcripts should enter TRACE-CAG. Partial STT events are UI state +and should not be cached or analyzed as final learner intent. + +## 15. Benchmark Hooks + +The request preserves current benchmark controls: + +- `benchmark_task` +- `benchmark_context` +- `benchmark_metadata` +- `kg_seed_concepts` +- `return_raw_state` + +These fields allow benchmark runners to evaluate the production pipeline through +the portable contract without duplicating benchmark-only pipeline code. + +## 16. Operational Guarantees + +The service wrapper provides: + +- strict input validation by default; +- timeout containment; +- non-throwing response errors for boundary failures; +- bounded batch concurrency through `analyze_many`; +- response metadata stamping with service name, version, adapter, session, and + input type. + +Example error handling: + +```python +response = await service.analyze_text("") +if response.error: + print(response.metadata["error_type"], response.error) +``` + +## 17. Migration Checklist For Another Project + +1. Copy `service/tracecag_service` into the target repo. +2. Keep imports stable: `from service.tracecag_service import ...`. +3. Start with `InMemoryTraceCAGAnalyzer` to verify the package imports and tests. +4. Implement a project adapter using `TraceCAGAnalyzer`. +5. Wire cache storage: in-memory, Redis, SQLite, Postgres, or project-specific. +6. Wire KG/retrieval. +7. Wire LLM generation. +8. Preserve metadata keys: `cache_hit`, `cache_decision`, `cache_layer`, `path`, + `reuse_risk`, and `tokens_saved`. +9. Add tests for L0 reuse, L1 patch, L1 rejection, timeout, and invalid input. + +## 18. Verification + +Run package tests: + +```bash +pytest tests/service -q +``` + +Expected result: + +```text +10 passed +``` + +These tests intentionally avoid FastAPI startup, Redis, Kuzu, Groq, Gemini, and +network calls. + diff --git a/ai-service/service/__init__.py b/ai-service/service/__init__.py new file mode 100644 index 00000000..894621b7 --- /dev/null +++ b/ai-service/service/__init__.py @@ -0,0 +1,2 @@ +"""Portable service packages for LexiLingo components.""" + diff --git a/ai-service/service/tracecag_service/README.md b/ai-service/service/tracecag_service/README.md new file mode 100644 index 00000000..4264c02f --- /dev/null +++ b/ai-service/service/tracecag_service/README.md @@ -0,0 +1,207 @@ +# TRACE-CAG Service Package + +`service/tracecag_service` is the portable packaging layer for TRACE-CAG. It +lets another Python project call the TRACE-CAG contract without importing the +FastAPI app at module import time. + +The existing LexiLingo app remains unchanged. The production adapter imports +`api.services.trace_cag.graph.get_trace_cag()` lazily only when the first +analysis request runs. + +## What This Package Contains + +| File | Purpose | +| --- | --- | +| `schemas.py` | Stable `TraceCAGRequest` and `TraceCAGResponse` DTOs. | +| `runtime.py` | `TraceCAGService`: validation, timeout, batch concurrency, response normalization. | +| `ports.py` | `TraceCAGAnalyzer` protocol for pluggable implementations. | +| `config.py` | Portable config and env loading via `TRACECAG_SERVICE_*`. | +| `core/fingerprint.py` | Lightweight state fingerprinting for cache keys and L1 buckets. | +| `core/scar_l1.py` | Pure SCAR-L1 reuse/patch/full decision logic. | +| `adapters/lexilingo.py` | Lazy bridge to the current LexiLingo TRACE-CAG pipeline. | +| `adapters/memory.py` | Dependency-free adapter for tests, demos, and new projects. | + +## Quick Start Inside LexiLingo + +```python +from service.tracecag_service import TraceCAGRequest +from service.tracecag_service.adapters.lexilingo import create_lexilingo_service + +service = create_lexilingo_service() + +response = await service.analyze( + TraceCAGRequest( + user_input="I go to school yesterday.", + session_id="session-1", + user_id="user-1", + learner_profile={"level": "B1"}, + ) +) + +print(response.tutor_response) +print(response.metadata["cache_decision"]) +``` + +Use this when you want the portable contract but still want the existing +LexiLingo KG, Redis, model gateway, and LangGraph runtime. + +## Quick Start In Another Project + +Copy or vendor the `service/tracecag_service` package, then implement the +`TraceCAGAnalyzer` port: + +```python +from service.tracecag_service import TraceCAGRequest, TraceCAGResponse, TraceCAGService + + +class MyAnalyzer: + async def analyze(self, request: TraceCAGRequest) -> TraceCAGResponse: + # Connect your KG, cache, retriever, LLM, or workflow here. + return TraceCAGResponse( + tutor_response=f"Handled: {request.user_input}", + metadata={"path": "slow", "cache_hit": False}, + ) + + +service = TraceCAGService(MyAnalyzer()) +response = await service.analyze_text("Explain past simple", session_id="demo") +``` + +The service wrapper does not care whether the implementation is LangGraph, +FastAPI, a CLI tool, a notebook, or a background worker. The only required +method is: + +```python +async def analyze(request: TraceCAGRequest) -> TraceCAGResponse | dict: + ... +``` + +## Dependency-Free Demo Adapter + +The memory adapter proves the package works without Redis, Kuzu, FastAPI, or +LLM keys. It implements lightweight L0 exact reuse and L1 SCAR-style near-hit +reuse. + +```python +from service.tracecag_service import TraceCAGService +from service.tracecag_service.adapters.memory import InMemoryTraceCAGAnalyzer + +service = TraceCAGService(InMemoryTraceCAGAnalyzer()) + +first = await service.analyze_text("Who founded AlphaSoft?", session_id="s1") +second = await service.analyze_text("Who founded AlphaSoft?", session_id="s1") +near = await service.analyze_text("Who created AlphaSoft?", session_id="s1") + +assert first.metadata["cache_decision"] == "full" +assert second.metadata["cache_layer"] == "L0" +assert near.metadata["cache_layer"] == "L1" +``` + +Use it for package-level tests or as a placeholder while wiring a real project +adapter. + +## Request Fields + +`TraceCAGRequest` is intentionally close to the current pipeline signature: + +| Field | Meaning | +| --- | --- | +| `user_input` | Required user text or final transcript. | +| `session_id` | Conversation/session boundary for history and cache state. | +| `user_id` | Optional user identity for personalization. | +| `input_type` | `"text"` or `"voice"`. | +| `learner_profile` | Level, errors, progress, vocabulary count, or project-specific profile fields. | +| `conversation_history` | Already-loaded turns. Supplying it lets callers skip a store lookup. | +| `stt_final` | Optional final-only STT event payload. | +| `cache_policy` | `"on"` or `"off"`. | +| `retrieval_policy` | Existing pipeline retrieval knob, usually `"full"` or `"rapid"`. | +| `diagnosis_policy` | Existing pipeline diagnosis knob, usually `"auto"` or `"rules"`. | +| `generation_policy` | Existing generation knob, including `"auto"` or `"skip"` for streaming prep. | +| `benchmark_*` | Optional benchmark task/context/metadata hooks. | +| `kg_seed_concepts` | Preloaded graph concepts from another cache or caller. | +| `return_raw_state` | Ask adapters to attach raw pipeline state when supported. | + +## Response Fields + +`TraceCAGResponse` normalizes either a dataclass response or a raw dict: + +| Field | Meaning | +| --- | --- | +| `tutor_response` | Final user-facing text. | +| `corrections` | Grammar or language feedback objects. | +| `linked_concepts` | KG or lightweight concept IDs used by the response. | +| `action_plan` | Suggested follow-up actions. | +| `scores` | Fluency, grammar, overall, vocabulary, or project-specific metrics. | +| `action` | Strategy and next action. | +| `metadata` | Latency, cache path, cache decision, service adapter, and request metadata. | +| `raw_state` | Optional raw implementation state. | +| `error` | Non-throwing error string for validation, timeout, or runtime failure. | + +## Runtime Configuration + +```python +from service.tracecag_service import TraceCAGServiceConfig + +config = TraceCAGServiceConfig( + timeout_seconds=20, + max_concurrency=2, + include_raw_state_by_default=False, +) +``` + +Environment variables use the `TRACECAG_SERVICE_` prefix: + +| Env var | Default | +| --- | --- | +| `TRACECAG_SERVICE_NAME` | `tracecag-service` | +| `TRACECAG_SERVICE_VERSION` | `0.1.0` | +| `TRACECAG_SERVICE_TIMEOUT_SECONDS` | `30.0` | +| `TRACECAG_SERVICE_MAX_CONCURRENCY` | `4` | +| `TRACECAG_SERVICE_DEFAULT_SESSION_ID` | `tracecag-session` | +| `TRACECAG_SERVICE_INCLUDE_RAW_STATE` | `false` | +| `TRACECAG_SERVICE_STRICT_VALIDATION` | `true` | + +## Batch Calls + +```python +requests = [ + TraceCAGRequest(user_input="Explain articles", session_id="s1"), + TraceCAGRequest(user_input="Practice past tense", session_id="s2"), +] + +responses = await service.analyze_many(requests) +``` + +`TraceCAGService` bounds parallelism with `max_concurrency`. + +## Error Handling + +The wrapper returns errors as `TraceCAGResponse` instead of raising for common +service boundary failures: + +- validation: empty input, missing session, unsupported input type, invalid cache policy +- timeout: analyzer exceeded `timeout_seconds` +- runtime: analyzer raised an exception + +Check: + +```python +if response.error: + print(response.metadata["error_type"], response.error) +``` + +## Theory And Mechanism + +For the full explanation of the cache theory, PCC/SCAR-L1 decision flow, and +how to implement this package in another project, read: + +`docs/tracecag_service_guide.md` + +## Tests + +```bash +pytest tests/service -q +``` + +These tests do not start FastAPI, Redis, KG, or any model provider. + diff --git a/ai-service/service/tracecag_service/__init__.py b/ai-service/service/tracecag_service/__init__.py new file mode 100644 index 00000000..3e646090 --- /dev/null +++ b/ai-service/service/tracecag_service/__init__.py @@ -0,0 +1,18 @@ +"""Portable TRACE-CAG service package. + +The package is intentionally additive. Importing it does not import the +existing FastAPI app or `api.services.trace_cag`; use the LexiLingo adapter +when you want to bind this contract to the current application pipeline. +""" + +from service.tracecag_service.config import TraceCAGServiceConfig +from service.tracecag_service.runtime import TraceCAGService +from service.tracecag_service.schemas import TraceCAGRequest, TraceCAGResponse + +__all__ = [ + "TraceCAGRequest", + "TraceCAGResponse", + "TraceCAGService", + "TraceCAGServiceConfig", +] + diff --git a/ai-service/service/tracecag_service/adapters/__init__.py b/ai-service/service/tracecag_service/adapters/__init__.py new file mode 100644 index 00000000..b7d6ee69 --- /dev/null +++ b/ai-service/service/tracecag_service/adapters/__init__.py @@ -0,0 +1,14 @@ +"""Adapters that bind the portable service to concrete runtimes.""" + +from service.tracecag_service.adapters.lexilingo import ( + LexiLingoTraceCAGAnalyzer, + create_lexilingo_service, +) +from service.tracecag_service.adapters.memory import InMemoryTraceCAGAnalyzer + +__all__ = [ + "InMemoryTraceCAGAnalyzer", + "LexiLingoTraceCAGAnalyzer", + "create_lexilingo_service", +] + diff --git a/ai-service/service/tracecag_service/adapters/lexilingo.py b/ai-service/service/tracecag_service/adapters/lexilingo.py new file mode 100644 index 00000000..12e16340 --- /dev/null +++ b/ai-service/service/tracecag_service/adapters/lexilingo.py @@ -0,0 +1,61 @@ +"""Lazy adapter from the portable service contract to this app's pipeline.""" + +from __future__ import annotations + +import inspect +from typing import Any, Awaitable, Callable + +from service.tracecag_service.config import TraceCAGServiceConfig +from service.tracecag_service.runtime import TraceCAGService +from service.tracecag_service.schemas import TraceCAGRequest, TraceCAGResponse + + +PipelineFactory = Callable[[], Any | Awaitable[Any]] + + +class LexiLingoTraceCAGAnalyzer: + """Adapter for the existing `api.services.trace_cag` implementation. + + The import is intentionally lazy so the portable package can be imported in + another project without importing FastAPI, Redis, KG, or model code. + """ + + def __init__(self, pipeline_factory: PipelineFactory | None = None) -> None: + self._pipeline_factory = pipeline_factory + self._pipeline: Any | None = None + + async def analyze(self, request: TraceCAGRequest) -> TraceCAGResponse: + pipeline = await self._get_pipeline() + result = await pipeline.analyze(**request.to_pipeline_kwargs()) + return TraceCAGResponse.from_mapping( + result, + raw_state=result if request.return_raw_state and isinstance(result, dict) else None, + ) + + async def _get_pipeline(self) -> Any: + if self._pipeline is not None: + return self._pipeline + + if self._pipeline_factory is not None: + candidate = self._pipeline_factory() + self._pipeline = await candidate if inspect.isawaitable(candidate) else candidate + return self._pipeline + + from api.services.trace_cag.graph import get_trace_cag + + self._pipeline = await get_trace_cag() + return self._pipeline + + +def create_lexilingo_service( + *, + config: TraceCAGServiceConfig | None = None, + pipeline_factory: PipelineFactory | None = None, +) -> TraceCAGService: + """Create a portable service bound to the current LexiLingo pipeline.""" + + return TraceCAGService( + analyzer=LexiLingoTraceCAGAnalyzer(pipeline_factory=pipeline_factory), + config=config, + ) + diff --git a/ai-service/service/tracecag_service/adapters/memory.py b/ai-service/service/tracecag_service/adapters/memory.py new file mode 100644 index 00000000..b3877164 --- /dev/null +++ b/ai-service/service/tracecag_service/adapters/memory.py @@ -0,0 +1,187 @@ +"""Dependency-free TRACE-CAG adapter for demos, tests, and new projects.""" + +from __future__ import annotations + +from dataclasses import dataclass +import time + +from service.tracecag_service.core.fingerprint import ( + TraceCAGFingerprint, + build_cache_key, + build_fingerprint, + build_graph_bucket, +) +from service.tracecag_service.core.scar_l1 import ( + L1Candidate, + L1Decision, + L1Request, + decide_l1_reuse, +) +from service.tracecag_service.schemas import TraceCAGRequest, TraceCAGResponse + + +@dataclass(slots=True) +class _MemoryEntry: + cache_key: str + bucket: str + fingerprint: TraceCAGFingerprint + response: TraceCAGResponse + created_at: float + ttl: int + + +class InMemoryTraceCAGAnalyzer: + """Tiny portable analyzer that exercises TRACE-CAG cache semantics. + + It does not call an LLM. Use it to prove the service package can run + without LexiLingo infrastructure, or as a starting point for another + project to replace with its own KG/LLM implementation. + """ + + def __init__(self, *, ttl_seconds: int = 3600) -> None: + self.ttl_seconds = ttl_seconds + self._entries: dict[str, _MemoryEntry] = {} + self._buckets: dict[str, list[str]] = {} + + async def analyze(self, request: TraceCAGRequest) -> TraceCAGResponse: + fingerprint = build_fingerprint( + user_input=request.user_input, + learner_profile=request.learner_profile, + conversation_history=request.conversation_history, + ) + cache_key = build_cache_key(fingerprint) + bucket = build_graph_bucket(fingerprint) + now = time.monotonic() + + if request.cache_policy != "off": + exact = self._entries.get(cache_key) + if exact and not self._is_stale(exact, now): + return self._from_cache(exact, "reuse", "L0", 0.0) + + candidate = self._best_l1_candidate(fingerprint, now) + if candidate is not None: + entry, decision = candidate + return self._from_cache(entry, decision.decision, "L1", decision.risk) + + response = self._generate_response(request, fingerprint) + if request.cache_policy != "off": + self._entries[cache_key] = _MemoryEntry( + cache_key=cache_key, + bucket=bucket, + fingerprint=fingerprint, + response=response.copy(), + created_at=now, + ttl=self.ttl_seconds, + ) + self._buckets.setdefault(bucket, []).append(cache_key) + return response + + async def close(self) -> None: + self._entries.clear() + self._buckets.clear() + + def _best_l1_candidate( + self, + fingerprint: TraceCAGFingerprint, + now: float, + ) -> tuple[_MemoryEntry, L1Decision] | None: + request_sig = L1Request( + query_norm=fingerprint.query_norm, + intent=fingerprint.intent, + level=fingerprint.level, + profile_epoch=fingerprint.profile_epoch, + session_turn=fingerprint.session_turn, + concepts=set(fingerprint.concepts), + entities=set(fingerprint.entities), + answer_target=fingerprint.answer_target, + relation_hints=set(fingerprint.relation_hints), + ) + + best: tuple[float, _MemoryEntry, L1Decision] | None = None + for entry in self._entries.values(): + if self._is_stale(entry, now): + continue + candidate_sig = L1Candidate( + cache_key=entry.cache_key, + query_norm=entry.fingerprint.query_norm, + intent=entry.fingerprint.intent, + level=entry.fingerprint.level, + profile_epoch=entry.fingerprint.profile_epoch, + session_turn=entry.fingerprint.session_turn, + concepts=set(entry.fingerprint.concepts), + entities=set(entry.fingerprint.entities), + answer_target=entry.fingerprint.answer_target, + relation_hints=set(entry.fingerprint.relation_hints), + created_at=entry.created_at, + ttl=entry.ttl, + ) + decision = decide_l1_reuse(request_sig, candidate_sig, now=now) + if not decision.safe_to_reuse: + continue + score = decision.rank_score + if best is None or score > best[0]: + best = (score, entry, decision) + if best is None: + return None + return best[1], best[2] + + def _from_cache( + self, + entry: _MemoryEntry, + decision: str, + layer: str, + risk: float, + ) -> TraceCAGResponse: + response = entry.response.copy() + if decision == "patch": + response.tutor_response = ( + f"{response.tutor_response}\n\n" + "State-compatible cache patch applied for this request." + ) + response.metadata = { + **response.metadata, + "path": "fast", + "cache_hit": True, + "cache_decision": decision, + "cache_layer": layer, + "cache_bucket": entry.bucket, + "reuse_risk": risk, + "tokens_saved": max(response.metadata.get("tokens_saved", 0), 128), + } + return response + + def _generate_response( + self, + request: TraceCAGRequest, + fingerprint: TraceCAGFingerprint, + ) -> TraceCAGResponse: + return TraceCAGResponse( + tutor_response=( + "TRACE-CAG portable service analyzed the request. " + f"Intent: {fingerprint.intent}. Level: {fingerprint.level}." + ), + linked_concepts=sorted(fingerprint.concepts), + scores={ + "fluency": 0.0, + "grammar": 0.0, + "overall": 0.0, + "vocabulary_level": fingerprint.level, + }, + action={ + "strategy": "portable", + "next": "wire_real_kg_llm_adapter", + }, + metadata={ + "path": "slow", + "cache_hit": False, + "cache_decision": "full", + "cache_layer": "none", + "cache_bucket": build_graph_bucket(fingerprint), + "reuse_risk": 1.0, + "tokens_saved": 0, + "input_type": request.input_type, + }, + ) + + def _is_stale(self, entry: _MemoryEntry, now: float) -> bool: + return (now - entry.created_at) >= entry.ttl diff --git a/ai-service/service/tracecag_service/config.py b/ai-service/service/tracecag_service/config.py new file mode 100644 index 00000000..bb2eabc1 --- /dev/null +++ b/ai-service/service/tracecag_service/config.py @@ -0,0 +1,70 @@ +"""Configuration for the portable TRACE-CAG service wrapper.""" + +from __future__ import annotations + +from dataclasses import dataclass +import os + + +def _env_bool(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +def _env_float(name: str, default: float) -> float: + raw = os.getenv(name) + if raw is None: + return default + try: + return float(raw) + except ValueError: + return default + + +def _env_int(name: str, default: int) -> int: + raw = os.getenv(name) + if raw is None: + return default + try: + return int(raw) + except ValueError: + return default + + +@dataclass(frozen=True) +class TraceCAGServiceConfig: + """Runtime knobs that are safe to carry across projects.""" + + service_name: str = "tracecag-service" + version: str = "0.1.0" + timeout_seconds: float = 30.0 + max_concurrency: int = 4 + default_session_id: str = "tracecag-session" + include_raw_state_by_default: bool = False + strict_validation: bool = True + + @classmethod + def from_env(cls, prefix: str = "TRACECAG_SERVICE_") -> "TraceCAGServiceConfig": + """Build config from environment variables without requiring settings.py.""" + + return cls( + service_name=os.getenv(f"{prefix}NAME", cls.service_name), + version=os.getenv(f"{prefix}VERSION", cls.version), + timeout_seconds=_env_float(f"{prefix}TIMEOUT_SECONDS", cls.timeout_seconds), + max_concurrency=max(1, _env_int(f"{prefix}MAX_CONCURRENCY", cls.max_concurrency)), + default_session_id=os.getenv( + f"{prefix}DEFAULT_SESSION_ID", + cls.default_session_id, + ), + include_raw_state_by_default=_env_bool( + f"{prefix}INCLUDE_RAW_STATE", + cls.include_raw_state_by_default, + ), + strict_validation=_env_bool( + f"{prefix}STRICT_VALIDATION", + cls.strict_validation, + ), + ) + diff --git a/ai-service/service/tracecag_service/core/__init__.py b/ai-service/service/tracecag_service/core/__init__.py new file mode 100644 index 00000000..15c80a85 --- /dev/null +++ b/ai-service/service/tracecag_service/core/__init__.py @@ -0,0 +1,26 @@ +"""Portable TRACE-CAG core algorithms.""" + +from service.tracecag_service.core.fingerprint import ( + TraceCAGFingerprint, + build_cache_key, + build_fingerprint, + build_graph_bucket, +) +from service.tracecag_service.core.scar_l1 import ( + L1Candidate, + L1Decision, + L1Request, + decide_l1_reuse, +) + +__all__ = [ + "TraceCAGFingerprint", + "build_cache_key", + "build_fingerprint", + "build_graph_bucket", + "L1Candidate", + "L1Decision", + "L1Request", + "decide_l1_reuse", +] + diff --git a/ai-service/service/tracecag_service/core/fingerprint.py b/ai-service/service/tracecag_service/core/fingerprint.py new file mode 100644 index 00000000..26d58a0d --- /dev/null +++ b/ai-service/service/tracecag_service/core/fingerprint.py @@ -0,0 +1,216 @@ +"""Portable state fingerprinting used by TRACE-CAG service adapters.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import hashlib +import re +from typing import Any, Mapping, Sequence + + +_CEFR_ORDER = ("A1", "A2", "B1", "B2", "C1", "C2") + +_ENTITY_STOPWORDS = { + "what", "which", "who", "whom", "whose", "where", "when", "why", "how", + "many", "much", "the", "and", "that", "this", "those", "these", "with", + "from", "into", "onto", "about", "person", "company", "film", "movie", + "creator", "created", "founder", "founded", "director", "directed", + "writer", "author", "mother", "father", "wife", "brother", "born", + "birth", "year", "date", "country", "city", "language", "award", +} + + +@dataclass(frozen=True, slots=True) +class TraceCAGFingerprint: + """Portable request-state signature for cache and reuse decisions.""" + + query_norm: str + intent: str + level: str + profile_epoch: int + session_turn: int + concepts: set[str] = field(default_factory=set) + entities: set[str] = field(default_factory=set) + answer_target: str = "feedback" + relation_hints: set[str] = field(default_factory=set) + + +def normalize_query(text: str) -> str: + """Normalize user text for stable cache keys.""" + + return " ".join((text or "").strip().lower().split()) + + +def infer_intent(text: str) -> str: + """Cheap pre-diagnosis intent classifier.""" + + query = normalize_query(text) + if any(token in query for token in ("why", "explain", "what does", "how does")): + return "explain" + if any(token in query for token in ("practice", "exercise", "quiz", "train")): + return "practice" + if query.endswith("?") or any( + token in query + for token in ("who", "where", "when", "which", "what", "how many") + ): + return "ask" + return "correct" + + +def extract_lightweight_concepts(text: str) -> set[str]: + """Approximate root concepts before a full KG or LLM diagnosis.""" + + query = normalize_query(text) + concepts: set[str] = set() + pattern_map = { + r"\b(i|you|we|they)\s+(is|was)\b": "concept:grammar.subject_verb_agreement", + r"\b(he|she|it)\s+(go|want|need|have|do)\b": "concept:grammar.third_person_s", + r"\byesterday\b.*\b(go|come|eat|buy|need|want)\b": "concept:grammar.past_time_markers", + r"\b(have|has)\s+went\b": "concept:grammar.present_perfect", + r"\bmore\s+better\b|\bmore\s+worse\b": "concept:grammar.comparatives", + r"\bexplain\b|\bwhy\b|\bwhat does\b": "intent:explain", + r"\bpractice\b|\bexercise\b|\bquiz\b": "intent:practice", + } + for pattern, concept in pattern_map.items(): + if re.search(pattern, query, re.IGNORECASE): + concepts.add(concept) + + for token in re.findall(r"[a-z0-9][a-z0-9'-]{3,}", query)[:8]: + concepts.add(f"token:{token.strip('-')}") + return {concept for concept in concepts if concept} + + +def extract_entities(text: str) -> set[str]: + """Extract stable entity-ish tokens for state compatibility checks.""" + + query = normalize_query(text) + entities = { + token.strip("-'") + for token in re.findall(r"[a-z0-9][a-z0-9'-]{2,}", query) + if token not in _ENTITY_STOPWORDS and len(token.strip("-'")) >= 3 + } + return {entity for entity in entities if entity} + + +def answer_target_hint(text: str) -> str: + """Classify the coarse answer target as a hard reuse signal.""" + + query = normalize_query(text) + if any(token in query for token in ("when", "what year", "which year", "date")): + return "time" + if any(token in query for token in ("where", "country", "city", "place", "located")): + return "place" + if any(token in query for token in ("how many", "how much", "times", "number")): + return "number" + if any( + token in query + for token in ("who", "mother", "father", "wife", "brother", "founder", "director") + ): + return "person" + if any(token in query for token in ("which", "what")): + return "entity" + return "feedback" + + +def relation_hints(text: str) -> set[str]: + """Map lexical cues into coarse relation classes for SCAR-L1.""" + + query = normalize_query(text) + relation_map = { + "founder": ("founder", "founded", "co-founded", "created", "creator"), + "maker": ("makes", "made by", "manufacturer", "behind"), + "birth": ("born", "birth"), + "director": ("director", "directed"), + "author": ("writer", "author", "wrote"), + "family": ("mother", "father", "wife", "brother"), + "time_order": ("came out first", "came first", "first", "earlier", "before", "released"), + "comparison": ("taller", "higher", "larger", "older", "younger"), + "location": ("where", "country", "city", "located", "place"), + "award": ("award", "won"), + } + hints: set[str] = set() + for relation, cues in relation_map.items(): + if any(cue in query for cue in cues): + hints.add(relation) + return hints + + +def profile_epoch(profile: Mapping[str, Any] | None) -> int: + """Compress learner profile state into a stable epoch integer.""" + + profile = profile or {} + level = normalize_level(str(profile.get("level") or "B1")) + sessions_completed = int(profile.get("sessions_completed") or 0) + vocabulary_count = int(profile.get("vocabulary_count") or 0) + common_errors = profile.get("common_errors") or [] + error_bucket = len(common_errors) // 2 if isinstance(common_errors, Sequence) else 0 + material = f"{level}|{sessions_completed // 3}|{vocabulary_count // 200}|{error_bucket}" + return int(hashlib.md5(material.encode()).hexdigest()[:8], 16) + + +def normalize_level(level: str) -> str: + """Return a known CEFR level, defaulting to B1 for unknown input.""" + + upper = (level or "B1").upper() + return upper if upper in _CEFR_ORDER else "B1" + + +def build_fingerprint( + *, + user_input: str, + learner_profile: Mapping[str, Any] | None = None, + conversation_history: Sequence[Mapping[str, Any]] | None = None, +) -> TraceCAGFingerprint: + """Build the portable request fingerprint.""" + + profile = learner_profile or {} + history = conversation_history or [] + entities = extract_entities(user_input) + concepts = set(extract_lightweight_concepts(user_input)) + concepts.update(f"entity:{entity}" for entity in entities) + return TraceCAGFingerprint( + query_norm=normalize_query(user_input), + intent=infer_intent(user_input), + level=normalize_level(str(profile.get("level") or "B1")), + profile_epoch=profile_epoch(profile), + session_turn=len(history), + concepts=concepts, + entities=entities, + answer_target=answer_target_hint(user_input), + relation_hints=relation_hints(user_input), + ) + + +def build_cache_key(fingerprint: TraceCAGFingerprint) -> str: + """Build an exact L0 cache key.""" + + material = "|".join( + [ + fingerprint.query_norm, + fingerprint.intent, + fingerprint.level, + str(fingerprint.profile_epoch), + str(fingerprint.session_turn), + ] + ) + return hashlib.md5(material.encode()).hexdigest() + + +def build_graph_bucket(fingerprint: TraceCAGFingerprint) -> str: + """Build a graph-state bucket for L1 candidate pooling.""" + + material = "|".join( + [ + "tracecag_l1", + fingerprint.level, + fingerprint.intent, + str(fingerprint.profile_epoch), + str(fingerprint.session_turn // 2), + fingerprint.answer_target, + *sorted(fingerprint.relation_hints), + *sorted(fingerprint.entities)[:8], + *sorted(fingerprint.concepts)[:8], + ] + ) + return hashlib.md5(material.encode()).hexdigest() + diff --git a/ai-service/service/tracecag_service/core/scar_l1.py b/ai-service/service/tracecag_service/core/scar_l1.py new file mode 100644 index 00000000..48edd45e --- /dev/null +++ b/ai-service/service/tracecag_service/core/scar_l1.py @@ -0,0 +1,134 @@ +"""SCAR-L1 state-certified reuse decisions. + +This module is pure Python so it can be copied into another project without +Redis, FastAPI, LangGraph, or model-provider dependencies. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import time + + +@dataclass(frozen=True, slots=True) +class L1Request: + """State signature for the current request.""" + + query_norm: str + intent: str + level: str + profile_epoch: int + session_turn: int + concepts: set[str] = field(default_factory=set) + entities: set[str] = field(default_factory=set) + answer_target: str = "" + relation_hints: set[str] = field(default_factory=set) + evidence_hash: str = "" + + +@dataclass(frozen=True, slots=True) +class L1Candidate: + """State signature for an L1 cache candidate.""" + + cache_key: str + query_norm: str + intent: str + level: str + profile_epoch: int + session_turn: int + concepts: set[str] = field(default_factory=set) + entities: set[str] = field(default_factory=set) + answer_target: str = "" + relation_hints: set[str] = field(default_factory=set) + evidence_hash: str = "" + created_at: float = 0.0 + ttl: int = 3600 + + +@dataclass(frozen=True, slots=True) +class L1Decision: + """SCAR-L1 ternary decision.""" + + decision: str + risk: float + rank_score: float + reasons: tuple[str, ...] + safe_to_reuse: bool + + +def _jaccard(left: set[str], right: set[str]) -> float: + if not left and not right: + return 1.0 + return len(left & right) / max(len(left | right), 1) + + +def _empty_or_equal(left: str, right: str) -> bool: + return not left or not right or left == right + + +def _compatible_relations(left: set[str], right: set[str]) -> bool: + if not left or not right: + return True + return left == right + + +def decide_l1_reuse( + request: L1Request, + candidate: L1Candidate, + *, + now: float | None = None, + tau_reuse: float = 0.25, + tau_patch: float = 0.55, + concept_floor: float = 0.50, +) -> L1Decision: + """Decide whether an L1 candidate can be reused, patched, or rejected.""" + + current_time = time.monotonic() if now is None else now + reasons: list[str] = [] + + if request.level != candidate.level: + reasons.append("level_mismatch") + if request.profile_epoch != candidate.profile_epoch: + reasons.append("profile_epoch_mismatch") + if not _empty_or_equal(request.intent, candidate.intent): + reasons.append("intent_mismatch") + if not _empty_or_equal(request.answer_target, candidate.answer_target): + reasons.append("answer_target_mismatch") + if not _empty_or_equal(request.evidence_hash, candidate.evidence_hash): + reasons.append("evidence_mismatch") + if not _compatible_relations(request.relation_hints, candidate.relation_hints): + reasons.append("relation_mismatch") + + concept_overlap = _jaccard(request.concepts, candidate.concepts) + if concept_overlap < concept_floor: + reasons.append("concept_overlap_below_floor") + + age_ratio = min( + max((current_time - candidate.created_at) / max(candidate.ttl, 1), 0.0), + 1.0, + ) + if age_ratio >= 1.0: + reasons.append("stale_candidate") + + entity_overlap = _jaccard(request.entities, candidate.entities) + relation_overlap = _jaccard(request.relation_hints, candidate.relation_hints) + intent_drift = 0.0 if _empty_or_equal(request.intent, candidate.intent) else 1.0 + + risk = ( + 0.30 * intent_drift + + 0.25 * (1.0 - concept_overlap) + + 0.20 * (1.0 - entity_overlap) + + 0.15 * (1.0 - relation_overlap) + + 0.10 * age_ratio + ) + risk = round(max(0.0, min(1.0, risk)), 6) + rank_score = (1.0 - risk) + (0.30 * concept_overlap) + (0.20 * entity_overlap) + + if reasons: + return L1Decision("full", risk, rank_score, tuple(reasons), False) + if request.query_norm == candidate.query_norm and risk <= tau_reuse: + return L1Decision("reuse", risk, rank_score, ("exact_or_normalized_match",), True) + if risk <= tau_patch: + return L1Decision("patch", risk, rank_score, ("state_compatible_near_hit",), True) + return L1Decision("full", risk, rank_score, ("risk_above_patch_threshold",), False) + diff --git a/ai-service/service/tracecag_service/ports.py b/ai-service/service/tracecag_service/ports.py new file mode 100644 index 00000000..6471a0cf --- /dev/null +++ b/ai-service/service/tracecag_service/ports.py @@ -0,0 +1,26 @@ +"""Ports that decouple the service package from any one application.""" + +from __future__ import annotations + +from typing import Any, Mapping, Protocol, runtime_checkable + +from service.tracecag_service.schemas import TraceCAGRequest, TraceCAGResponse + + +@runtime_checkable +class TraceCAGAnalyzer(Protocol): + """Analyzer contract implemented by real pipelines or test adapters.""" + + async def analyze( + self, + request: TraceCAGRequest, + ) -> TraceCAGResponse | Mapping[str, Any]: + """Analyze a request and return either a normalized response or mapping.""" + + +class SupportsClose(Protocol): + """Optional async close hook used by adapters with external resources.""" + + async def close(self) -> None: + """Release adapter resources.""" + diff --git a/ai-service/service/tracecag_service/runtime.py b/ai-service/service/tracecag_service/runtime.py new file mode 100644 index 00000000..36d3f284 --- /dev/null +++ b/ai-service/service/tracecag_service/runtime.py @@ -0,0 +1,138 @@ +"""Public runtime wrapper for portable TRACE-CAG analyzers.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Iterable +from typing import Any + +from service.tracecag_service.config import TraceCAGServiceConfig +from service.tracecag_service.ports import TraceCAGAnalyzer +from service.tracecag_service.schemas import TraceCAGRequest, TraceCAGResponse + + +class TraceCAGService: + """Small stable facade around a pluggable TRACE-CAG analyzer.""" + + def __init__( + self, + analyzer: TraceCAGAnalyzer, + config: TraceCAGServiceConfig | None = None, + ) -> None: + self.analyzer = analyzer + self.config = config or TraceCAGServiceConfig() + + async def analyze( + self, + request: TraceCAGRequest, + ) -> TraceCAGResponse: + """Run one analysis call with validation, timeout, and normalization.""" + + validation_error = self._validate(request) + if validation_error: + return self._error_response(request, validation_error, error_type="validation") + + try: + result = await asyncio.wait_for( + self.analyzer.analyze(request), + timeout=self.config.timeout_seconds, + ) + response = TraceCAGResponse.from_mapping(result) + self._stamp_metadata(response, request) + if self.config.include_raw_state_by_default and response.raw_state is None: + response.raw_state = response.extra.pop("raw_state", None) + return response + except asyncio.TimeoutError: + return self._error_response(request, "TRACE-CAG service timed out", error_type="timeout") + except Exception as exc: + return self._error_response(request, str(exc), error_type="runtime") + + async def analyze_text( + self, + text: str, + *, + session_id: str | None = None, + **kwargs: Any, + ) -> TraceCAGResponse: + """Convenience entry point for simple text-only integrations.""" + + request = TraceCAGRequest.from_text( + text, + session_id=session_id or self.config.default_session_id, + **kwargs, + ) + return await self.analyze(request) + + async def analyze_many( + self, + requests: Iterable[TraceCAGRequest], + ) -> list[TraceCAGResponse]: + """Analyze multiple requests with bounded concurrency.""" + + semaphore = asyncio.Semaphore(max(1, self.config.max_concurrency)) + + async def _run(item: TraceCAGRequest) -> TraceCAGResponse: + async with semaphore: + return await self.analyze(item) + + return await asyncio.gather(*[_run(request) for request in requests]) + + async def close(self) -> None: + """Close the adapter when it provides an async close hook.""" + + close = getattr(self.analyzer, "close", None) + if close is not None: + await close() + + def _validate(self, request: TraceCAGRequest) -> str | None: + if not self.config.strict_validation: + return None + if not request.user_input or not request.user_input.strip(): + return "user_input is required" + if not request.session_id or not request.session_id.strip(): + return "session_id is required" + if request.input_type not in {"text", "voice"}: + return "input_type must be 'text' or 'voice'" + if request.cache_policy not in {"on", "off"}: + return "cache_policy must be 'on' or 'off'" + return None + + def _stamp_metadata( + self, + response: TraceCAGResponse, + request: TraceCAGRequest, + ) -> None: + response.metadata = { + **response.metadata, + "service": { + "name": self.config.service_name, + "version": self.config.version, + "adapter": type(self.analyzer).__name__, + }, + "request": { + "session_id": request.session_id, + "input_type": request.input_type, + }, + } + + def _error_response( + self, + request: TraceCAGRequest, + message: str, + *, + error_type: str, + ) -> TraceCAGResponse: + response = TraceCAGResponse( + tutor_response="", + error=message, + metadata={ + "path": "error", + "cache_hit": False, + "cache_decision": "full", + "cache_layer": "none", + "error_type": error_type, + }, + ) + self._stamp_metadata(response, request) + return response + diff --git a/ai-service/service/tracecag_service/schemas.py b/ai-service/service/tracecag_service/schemas.py new file mode 100644 index 00000000..9e0702ea --- /dev/null +++ b/ai-service/service/tracecag_service/schemas.py @@ -0,0 +1,167 @@ +"""Stable request and response contracts for TRACE-CAG.""" + +from __future__ import annotations + +from copy import deepcopy +from dataclasses import dataclass, field +from typing import Any, Mapping + + +@dataclass(slots=True) +class TraceCAGRequest: + """Portable input contract for any TRACE-CAG implementation.""" + + user_input: str + session_id: str = "tracecag-session" + user_id: str | None = None + input_type: str = "text" + learner_profile: dict[str, Any] | None = None + conversation_history: list[dict[str, Any]] | None = None + stt_final: dict[str, Any] | None = None + cache_policy: str = "on" + retrieval_policy: str = "full" + diagnosis_policy: str = "auto" + generation_policy: str = "auto" + benchmark_task: str | None = None + benchmark_context: str | None = None + benchmark_metadata: dict[str, Any] | None = None + kg_seed_concepts: list[str] | None = None + return_raw_state: bool = False + metadata: dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_text( + cls, + text: str, + *, + session_id: str = "tracecag-session", + **kwargs: Any, + ) -> "TraceCAGRequest": + """Convenience constructor for simple text calls.""" + + return cls(user_input=text, session_id=session_id, **kwargs) + + def to_pipeline_kwargs(self) -> dict[str, Any]: + """Map the portable request to the current LexiLingo pipeline signature.""" + + kwargs: dict[str, Any] = { + "user_input": self.user_input, + "session_id": self.session_id, + "user_id": self.user_id, + "input_type": self.input_type, + "learner_profile": deepcopy(self.learner_profile), + "conversation_history": deepcopy(self.conversation_history), + "stt_final": deepcopy(self.stt_final), + "cache_policy": self.cache_policy, + "retrieval_policy": self.retrieval_policy, + "diagnosis_policy": self.diagnosis_policy, + "generation_policy": self.generation_policy, + "benchmark_task": self.benchmark_task, + "benchmark_context": self.benchmark_context, + "benchmark_metadata": deepcopy(self.benchmark_metadata), + "kg_seed_concepts": deepcopy(self.kg_seed_concepts), + "return_raw_state": self.return_raw_state, + } + return {key: value for key, value in kwargs.items() if value is not None} + + +@dataclass(slots=True) +class TraceCAGResponse: + """Portable output contract normalized from any TRACE-CAG implementation.""" + + tutor_response: str = "" + corrections: list[dict[str, Any]] = field(default_factory=list) + linked_concepts: list[Any] = field(default_factory=list) + action_plan: list[dict[str, Any]] = field(default_factory=list) + vietnamese_hint: str | None = None + pronunciation_tip: str | None = None + scores: dict[str, Any] = field(default_factory=dict) + action: dict[str, Any] = field(default_factory=dict) + metadata: dict[str, Any] = field(default_factory=dict) + audio: dict[str, Any] | None = None + raw_state: dict[str, Any] | None = None + error: str | None = None + extra: dict[str, Any] = field(default_factory=dict) + + @classmethod + def from_mapping( + cls, + payload: Mapping[str, Any] | "TraceCAGResponse", + *, + raw_state: Mapping[str, Any] | None = None, + ) -> "TraceCAGResponse": + """Normalize a dict response or raw pipeline state into this contract.""" + + if isinstance(payload, TraceCAGResponse): + response = payload.copy() + if raw_state is not None: + response.raw_state = deepcopy(dict(raw_state)) + return response + + data = dict(payload) + known = { + "tutor_response", + "corrections", + "linked_concepts", + "action_plan", + "vietnamese_hint", + "pronunciation_tip", + "scores", + "action", + "metadata", + "audio", + "raw_state", + "error", + } + metadata = deepcopy(data.get("metadata") or {}) + if not metadata: + metadata = { + "latency_ms": data.get("latency_ms", 0), + "path": data.get("path", "slow"), + "cache_hit": data.get("cache_hit", False), + "cache_decision": data.get("cache_decision", "full"), + "cache_layer": data.get("cache_layer", "none"), + } + + return cls( + tutor_response=str(data.get("tutor_response") or ""), + corrections=deepcopy(data.get("corrections") or []), + linked_concepts=deepcopy(data.get("linked_concepts") or data.get("kg_seed_concepts") or []), + action_plan=deepcopy(data.get("action_plan") or []), + vietnamese_hint=data.get("vietnamese_hint"), + pronunciation_tip=data.get("pronunciation_tip"), + scores=deepcopy(data.get("scores") or {}), + action=deepcopy(data.get("action") or {}), + metadata=metadata, + audio=deepcopy(data.get("audio")), + raw_state=deepcopy(dict(raw_state or data.get("raw_state") or {})) or None, + error=data.get("error"), + extra={key: deepcopy(value) for key, value in data.items() if key not in known}, + ) + + def copy(self) -> "TraceCAGResponse": + """Return a detached copy that callers can mutate safely.""" + + return TraceCAGResponse.from_mapping(self.to_dict(include_raw_state=True)) + + def to_dict(self, *, include_raw_state: bool = False) -> dict[str, Any]: + """Serialize to a plain dict suitable for API responses.""" + + payload: dict[str, Any] = { + "tutor_response": self.tutor_response, + "corrections": deepcopy(self.corrections), + "linked_concepts": deepcopy(self.linked_concepts), + "action_plan": deepcopy(self.action_plan), + "vietnamese_hint": self.vietnamese_hint, + "pronunciation_tip": self.pronunciation_tip, + "scores": deepcopy(self.scores), + "action": deepcopy(self.action), + "metadata": deepcopy(self.metadata), + "audio": deepcopy(self.audio), + "error": self.error, + **deepcopy(self.extra), + } + if include_raw_state and self.raw_state is not None: + payload["raw_state"] = deepcopy(self.raw_state) + return payload + diff --git a/ai-service/tests/service/test_tracecag_lexilingo_adapter.py b/ai-service/tests/service/test_tracecag_lexilingo_adapter.py new file mode 100644 index 00000000..2fcd1723 --- /dev/null +++ b/ai-service/tests/service/test_tracecag_lexilingo_adapter.py @@ -0,0 +1,64 @@ +import pytest + +from service.tracecag_service import TraceCAGRequest +from service.tracecag_service.adapters.lexilingo import LexiLingoTraceCAGAnalyzer + + +class FakePipeline: + def __init__(self): + self.calls = [] + + async def analyze(self, **kwargs): + self.calls.append(kwargs) + return { + "tutor_response": "from existing pipeline", + "metadata": {"path": "slow", "cache_hit": False}, + } + + +@pytest.mark.asyncio +async def test_lexilingo_adapter_maps_request_to_existing_pipeline_kwargs(): + pipeline = FakePipeline() + adapter = LexiLingoTraceCAGAnalyzer(pipeline_factory=lambda: pipeline) + request = TraceCAGRequest( + user_input="I go yesterday", + session_id="s1", + user_id="u1", + input_type="text", + learner_profile={"level": "A2"}, + conversation_history=[{"role": "user", "content": "hello"}], + kg_seed_concepts=["concept:grammar.past_time_markers"], + return_raw_state=True, + ) + + response = await adapter.analyze(request) + + assert response.tutor_response == "from existing pipeline" + assert len(pipeline.calls) == 1 + call = pipeline.calls[0] + assert call["user_input"] == "I go yesterday" + assert call["session_id"] == "s1" + assert call["user_id"] == "u1" + assert call["learner_profile"] == {"level": "A2"} + assert call["conversation_history"] == [{"role": "user", "content": "hello"}] + assert call["kg_seed_concepts"] == ["concept:grammar.past_time_markers"] + assert call["return_raw_state"] is True + assert response.raw_state["tutor_response"] == "from existing pipeline" + + +@pytest.mark.asyncio +async def test_lexilingo_adapter_accepts_async_pipeline_factory(): + pipeline = FakePipeline() + + async def factory(): + return pipeline + + adapter = LexiLingoTraceCAGAnalyzer(pipeline_factory=factory) + + response = await adapter.analyze( + TraceCAGRequest(user_input="hello", session_id="s1") + ) + + assert response.tutor_response == "from existing pipeline" + assert len(pipeline.calls) == 1 + diff --git a/ai-service/tests/service/test_tracecag_memory_adapter.py b/ai-service/tests/service/test_tracecag_memory_adapter.py new file mode 100644 index 00000000..2b098738 --- /dev/null +++ b/ai-service/tests/service/test_tracecag_memory_adapter.py @@ -0,0 +1,89 @@ +import pytest + +from service.tracecag_service import TraceCAGRequest, TraceCAGService +from service.tracecag_service.adapters.memory import InMemoryTraceCAGAnalyzer + + +@pytest.mark.asyncio +async def test_memory_adapter_cache_miss_then_l0_reuse(): + service = TraceCAGService(InMemoryTraceCAGAnalyzer()) + request = TraceCAGRequest( + user_input="Who founded AlphaSoft?", + session_id="s1", + learner_profile={"level": "B1"}, + ) + + first = await service.analyze(request) + second = await service.analyze(request) + + assert first.metadata["cache_hit"] is False + assert first.metadata["cache_decision"] == "full" + assert second.metadata["cache_hit"] is True + assert second.metadata["cache_layer"] == "L0" + assert second.metadata["cache_decision"] == "reuse" + assert second.tutor_response == first.tutor_response + + +@pytest.mark.asyncio +async def test_memory_adapter_uses_l1_patch_for_state_compatible_near_hit(): + service = TraceCAGService(InMemoryTraceCAGAnalyzer()) + + await service.analyze( + TraceCAGRequest( + user_input="Who founded AlphaSoft?", + session_id="s1", + learner_profile={"level": "B1"}, + ) + ) + response = await service.analyze( + TraceCAGRequest( + user_input="Who created AlphaSoft?", + session_id="s1", + learner_profile={"level": "B1"}, + ) + ) + + assert response.metadata["cache_hit"] is True + assert response.metadata["cache_layer"] == "L1" + assert response.metadata["cache_decision"] == "patch" + assert "State-compatible cache patch" in response.tutor_response + + +@pytest.mark.asyncio +async def test_memory_adapter_rejects_l1_when_profile_level_changes(): + service = TraceCAGService(InMemoryTraceCAGAnalyzer()) + + await service.analyze( + TraceCAGRequest( + user_input="Who founded AlphaSoft?", + session_id="s1", + learner_profile={"level": "B1"}, + ) + ) + response = await service.analyze( + TraceCAGRequest( + user_input="Who created AlphaSoft?", + session_id="s1", + learner_profile={"level": "C1"}, + ) + ) + + assert response.metadata["cache_hit"] is False + assert response.metadata["cache_decision"] == "full" + + +@pytest.mark.asyncio +async def test_memory_adapter_respects_cache_policy_off(): + service = TraceCAGService(InMemoryTraceCAGAnalyzer()) + request = TraceCAGRequest( + user_input="Who founded AlphaSoft?", + session_id="s1", + cache_policy="off", + ) + + first = await service.analyze(request) + second = await service.analyze(request) + + assert first.metadata["cache_hit"] is False + assert second.metadata["cache_hit"] is False + diff --git a/ai-service/tests/service/test_tracecag_service_runtime.py b/ai-service/tests/service/test_tracecag_service_runtime.py new file mode 100644 index 00000000..6211d772 --- /dev/null +++ b/ai-service/tests/service/test_tracecag_service_runtime.py @@ -0,0 +1,74 @@ +import asyncio + +import pytest + +from service.tracecag_service import ( + TraceCAGRequest, + TraceCAGResponse, + TraceCAGService, + TraceCAGServiceConfig, +) + + +class DictAnalyzer: + async def analyze(self, request: TraceCAGRequest): + return { + "tutor_response": f"handled:{request.user_input}", + "metadata": {"path": "slow", "cache_hit": False}, + } + + +class SlowAnalyzer: + async def analyze(self, request: TraceCAGRequest): + await asyncio.sleep(0.05) + return TraceCAGResponse(tutor_response="too late") + + +@pytest.mark.asyncio +async def test_runtime_normalizes_mapping_response(): + service = TraceCAGService(DictAnalyzer()) + + response = await service.analyze_text("hello", session_id="s1") + + assert response.tutor_response == "handled:hello" + assert response.metadata["path"] == "slow" + assert response.metadata["service"]["adapter"] == "DictAnalyzer" + assert response.metadata["request"]["session_id"] == "s1" + + +@pytest.mark.asyncio +async def test_runtime_returns_validation_error_without_calling_adapter(): + service = TraceCAGService(DictAnalyzer()) + + response = await service.analyze(TraceCAGRequest(user_input="", session_id="s1")) + + assert response.error == "user_input is required" + assert response.metadata["path"] == "error" + assert response.metadata["error_type"] == "validation" + + +@pytest.mark.asyncio +async def test_runtime_timeout_is_returned_as_response(): + service = TraceCAGService( + SlowAnalyzer(), + TraceCAGServiceConfig(timeout_seconds=0.001), + ) + + response = await service.analyze_text("hello", session_id="s1") + + assert response.error == "TRACE-CAG service timed out" + assert response.metadata["error_type"] == "timeout" + + +@pytest.mark.asyncio +async def test_analyze_many_uses_service_contract(): + service = TraceCAGService(DictAnalyzer(), TraceCAGServiceConfig(max_concurrency=2)) + requests = [ + TraceCAGRequest(user_input="one", session_id="s1"), + TraceCAGRequest(user_input="two", session_id="s2"), + ] + + responses = await service.analyze_many(requests) + + assert [item.tutor_response for item in responses] == ["handled:one", "handled:two"] + diff --git a/ai-service/tests/stt/test_normalizer.py b/ai-service/tests/stt/test_normalizer.py new file mode 100644 index 00000000..6944dbd9 --- /dev/null +++ b/ai-service/tests/stt/test_normalizer.py @@ -0,0 +1,125 @@ +import pytest + +from api.services.stt.transcript_normalizer import normalize_transcript + + +def test_trim_removes_extra_whitespace(): + text, rules = normalize_transcript(" hello world ") + assert text == "hello world." + assert "trim" in rules + + +def test_adds_period_when_missing(): + text, rules = normalize_transcript("hello world") + assert text == "hello world." + assert "punctuation" in rules + + +def test_no_change_when_punctuated(): + text, rules = normalize_transcript("Hello world.") + assert text == "Hello world." + assert "punctuation" not in rules + + +def test_question_mark_preserved(): + text, rules = normalize_transcript("What time is it?") + assert text == "What time is it?" + assert "punctuation" not in rules + + +def test_filler_um_removed(): + text, rules = normalize_transcript("um") + assert text == "" + assert rules == ["filler_removed"] + + +def test_filler_uh_removed(): + text, rules = normalize_transcript("uh") + assert text == "" + assert rules == ["filler_removed"] + + +def test_filler_hmm_removed(): + text, rules = normalize_transcript("hmm") + assert text == "" + assert rules == ["filler_removed"] + + +def test_filler_with_trailing_period_removed(): + text, rules = normalize_transcript("um.") + assert text == "" + assert rules == ["filler_removed"] + + +def test_filler_mixed_case_removed(): + text, rules = normalize_transcript("Uh") + assert text == "" + assert rules == ["filler_removed"] + + +def test_bigram_repeat_deduplicated(): + text, rules = normalize_transcript("hello world hello world hello world") + assert text == "hello world." + assert "deduplication" in rules + + +def test_trigram_repeat_deduplicated(): + text, rules = normalize_transcript("I want to I want to I want to") + assert text == "I want to." + assert "deduplication" in rules + + +def test_unigram_repeat_with_tail_deduplicated(): + text, rules = normalize_transcript("can can can you help me") + assert text == "can you help me." + assert "deduplication" in rules + + +def test_unigram_repeat_only_collapsed(): + text, rules = normalize_transcript("the the the") + assert text == "the." + assert "deduplication" in rules + + +def test_two_repetitions_not_deduplicated(): + text, rules = normalize_transcript("hello world hello world") + assert text == "hello world hello world." + assert "deduplication" not in rules + + +def test_normal_sentence_unchanged(): + text, rules = normalize_transcript("I want to improve my English.") + assert text == "I want to improve my English." + assert "deduplication" not in rules + + +def test_preserve_exact_skips_filler_removal(): + text, rules = normalize_transcript("um", preserve_exact=True) + assert text == "um" + assert "filler_removed" not in rules + + +def test_preserve_exact_skips_punctuation(): + text, rules = normalize_transcript("hello world", preserve_exact=True) + assert text == "hello world" + assert "punctuation" not in rules + + +def test_preserve_exact_skips_deduplication(): + text, rules = normalize_transcript( + "hello world hello world hello world", preserve_exact=True + ) + assert text == "hello world hello world hello world" + assert "deduplication" not in rules + + +def test_empty_input_returns_empty(): + text, rules = normalize_transcript("") + assert text == "" + assert rules == [] + + +def test_preserve_exact_still_trims_whitespace(): + text, rules = normalize_transcript(" hello ", preserve_exact=True) + assert text == "hello" + assert "trim" in rules diff --git a/ai-service/tests/stt/test_pipeline.py b/ai-service/tests/stt/test_pipeline.py index b9c194a9..4e965697 100644 --- a/ai-service/tests/stt/test_pipeline.py +++ b/ai-service/tests/stt/test_pipeline.py @@ -1,9 +1,11 @@ import pytest from api.services.stt.config import STTConfig +from api.services.stt.metrics import STTMetrics from api.services.stt.model_registry import STTModelRegistry from api.services.stt.schemas import AudioSegment, PrimaryResult, UseCase from api.services.stt.sentence_finalizer import SentenceFinalizer +from api.services.stt.vad_endpointing import EnergyEndpointDetector from api.services.stt.verifier_router import VerifierRouter from tests.stt.fakes import FakePrimary, FakeVerifier @@ -45,3 +47,84 @@ async def test_finalizer_selects_better_verifier(tmp_path): assert event.text == "verified text." assert event.verified is True assert event.source == "faster-whisper" + + +@pytest.mark.asyncio +async def test_verifier_failure_marks_uncertain_and_source_only(tmp_path): + config = STTConfig(temp_dir=str(tmp_path)) + registry = STTModelRegistry( + config, primary=FakePrimary(), verifier=FakeVerifier(fail=True) + ) + finalizer = SentenceFinalizer(config, registry) + audio = AudioSegment(pcm16=b"\x00\x00" * 8000, start_ms=0, end_ms=1000) + primary = PrimaryResult( + text="hello", confidence=0.5, confidence_source="fake", source="fake-primary" + ) + event = await finalizer.finalize("s1", "t1", UseCase.CONVERSATION, "en", audio, primary) + assert event is not None + assert event.uncertain is True + assert event.source == "fake-primary_only" + + +@pytest.mark.asyncio +async def test_finalizer_drops_noise_utterance(tmp_path): + config = STTConfig(temp_dir=str(tmp_path), verify_enabled=False) + registry = STTModelRegistry(config, primary=FakePrimary(), verifier=FakeVerifier()) + finalizer = SentenceFinalizer(config, registry) + result = await finalizer.finalize( + "s1", + "t1", + UseCase.CONVERSATION, + "en", + AudioSegment(pcm16=b"\x00\x00" * 8000, start_ms=0, end_ms=1000), + PrimaryResult(text="um", confidence=0.9, confidence_source="fake", source="fake-primary"), + ) + assert result is None + + +def test_router_always_verifies_non_conversation_use_cases(tmp_path): + router = VerifierRouter(STTConfig(temp_dir=str(tmp_path))) + audio_duration_ms = 2000 + primary = PrimaryResult( + text="hello world", confidence=0.9, confidence_source="fake", source="fake-primary" + ) + for use_case in (UseCase.SPEAKING_PRACTICE, UseCase.PRONUNCIATION_SCORING): + decision = router.decide(primary, audio_duration_ms, use_case) + assert decision.verify, f"{use_case} should always trigger verification" + assert decision.reason == "accuracy_sensitive_use_case" + + +@pytest.mark.asyncio +async def test_vad_fallback_to_energy_detector(tmp_path): + config = STTConfig( + temp_dir=str(tmp_path), + vad_engine="silero", + silero_model_path="/nonexistent/model.onnx", + ) + registry = STTModelRegistry(config, primary=FakePrimary(), verifier=FakeVerifier()) + await registry.start() + assert registry.vad_status == "fallback" + vad = registry.create_vad() + assert isinstance(vad, EnergyEndpointDetector) + assert registry.vad_fallback_count == 1 + + +@pytest.mark.asyncio +async def test_metrics_records_verify_latency(tmp_path): + config = STTConfig(temp_dir=str(tmp_path)) + metrics = STTMetrics() + registry = STTModelRegistry(config, primary=FakePrimary(), verifier=FakeVerifier()) + finalizer = SentenceFinalizer(config, registry, metrics) + await finalizer.finalize( + "s1", + "t1", + UseCase.CONVERSATION, + "en", + AudioSegment(pcm16=b"\x00\x00" * 8000, start_ms=0, end_ms=1000), + PrimaryResult( + text="hello", confidence=0.5, confidence_source="fake", source="fake-primary" + ), + ) + stats = metrics.latency_stats("stt_verify_latency_ms") + assert stats["count"] == 1 + assert stats["avg"] >= 0 diff --git a/ai-service/tests/stt/test_session_manager.py b/ai-service/tests/stt/test_session_manager.py index add282de..72100215 100644 --- a/ai-service/tests/stt/test_session_manager.py +++ b/ai-service/tests/stt/test_session_manager.py @@ -159,3 +159,22 @@ async def test_cancelled_session_start_releases_reservation(tmp_path): assert not manager.sessions assert not manager._pending_sessions + + +@pytest.mark.asyncio +async def test_idle_session_is_closed_by_manager(tmp_path): + config = STTConfig(temp_dir=str(tmp_path), session_idle_timeout_seconds=0) + registry = STTModelRegistry(config, primary=FakePrimary(), verifier=FakeVerifier()) + registry.status = "ready" + manager = SessionManager(config, registry) + + session = await manager.create(StartMessage(session_id="idle")) + session.last_activity = time.monotonic() - 1.0 + + now = time.monotonic() + for sid, s in list(manager.sessions.items()): + if now - s.last_activity > manager.config.session_idle_timeout_seconds: + await manager.close(sid, "idle_timeout") + + assert "idle" not in manager.sessions + await manager.shutdown() diff --git a/ai-service/tests/stt/test_voice_session.py b/ai-service/tests/stt/test_voice_session.py index 40be17de..ad84eff6 100644 --- a/ai-service/tests/stt/test_voice_session.py +++ b/ai-service/tests/stt/test_voice_session.py @@ -206,6 +206,98 @@ async def test_stop_emits_downstream_timeout_before_closing(tmp_path): assert events[-2]["error"] == "TRACE-CAG response timed out" +@pytest.mark.asyncio +async def test_silence_frames_produce_no_final(tmp_path): + config = STTConfig( + temp_dir=str(tmp_path), + min_speech_ms=20, + min_silence_ms=20, + verify_enabled=False, + ) + registry = STTModelRegistry(config, primary=FakePrimary(), verifier=FakeVerifier()) + registry.status = "ready" + session = VoiceSession( + StartMessage(session_id="silent"), config, registry, STTMetrics() + ) + await session.start_worker() + for i in range(5): + silence = parse_audio_frame(HEADER.pack(1, 0, i, i * 20) + b"\x00\x00" * 320) + session.enqueue(silence) + await asyncio.sleep(0.05) + await session.stop() + events = list(session.event_queue._queue) + final_events = [e for e in events if e.get("type") == "stt.final"] + assert not final_events + + +@pytest.mark.asyncio +async def test_metrics_records_time_to_first_partial(tmp_path): + config = STTConfig( + temp_dir=str(tmp_path), + min_speech_ms=20, + min_silence_ms=20, + verify_enabled=False, + ) + primary_session = FakePrimarySession() + registry = STTModelRegistry( + config, primary=FakePrimary(primary_session), verifier=FakeVerifier() + ) + registry.status = "ready" + metrics = STTMetrics() + session = VoiceSession( + StartMessage(session_id="partial-metrics"), config, registry, metrics + ) + await session.start_worker() + session.enqueue(_speech_frame(0)) + await asyncio.sleep(0.05) + await session.stop() + stats = metrics.latency_stats("stt_time_to_first_partial_ms") + assert stats["count"] == 1 + assert stats["avg"] >= 0 + + +@pytest.mark.asyncio +async def test_metrics_records_final_latency(tmp_path): + config = STTConfig( + temp_dir=str(tmp_path), + min_speech_ms=20, + min_silence_ms=20, + verify_enabled=False, + ) + registry = STTModelRegistry(config, primary=FakePrimary(), verifier=FakeVerifier()) + registry.status = "ready" + metrics = STTMetrics() + session = VoiceSession( + StartMessage(session_id="final-metrics"), config, registry, metrics + ) + await session.start_worker() + session.enqueue(_speech_frame(0)) + silence = parse_audio_frame(HEADER.pack(1, 0, 1, 20) + b"\x00\x00" * 320) + session.enqueue(silence) + await asyncio.sleep(0.05) + await session.stop() + stats = metrics.latency_stats("stt_final_latency_ms") + assert stats["count"] >= 1 + assert stats["avg"] >= 0 + + +@pytest.mark.asyncio +async def test_metrics_records_session_duration(tmp_path): + config = STTConfig(temp_dir=str(tmp_path)) + registry = STTModelRegistry(config, primary=FakePrimary(), verifier=FakeVerifier()) + registry.status = "ready" + metrics = STTMetrics() + session = VoiceSession( + StartMessage(session_id="duration-metrics"), config, registry, metrics + ) + await session.start_worker() + await asyncio.sleep(0.01) + await session.stop() + stats = metrics.latency_stats("stt_session_duration_s") + assert stats["count"] == 1 + assert stats["avg"] >= 0.01 + + @pytest.mark.asyncio async def test_stop_timeout_does_not_overwrite_completed_response(tmp_path): config = STTConfig( diff --git a/ai-service/tests/test_dependency_constraints.py b/ai-service/tests/test_dependency_constraints.py index c33685fe..476c29c3 100644 --- a/ai-service/tests/test_dependency_constraints.py +++ b/ai-service/tests/test_dependency_constraints.py @@ -24,12 +24,12 @@ def test_ai_constraints_match_supported_torch_stack(): expected = { "numpy": "1.26.4", - "torch": "2.2.2", + "torch": "2.12.1", "transformers": "4.57.6", "sentence-transformers": "4.1.0", "faster-whisper": "1.2.1", - "moonshine-voice": "0.0.59", - "sherpa-onnx": "1.13.2", + "moonshine-voice": "0.0.62", + "sherpa-onnx": "1.13.3", } for package, version in expected.items(): pinned = constraints[package] diff --git a/ai-service/tests/test_lexi_chat_routes.py b/ai-service/tests/test_lexi_chat_routes.py index 58090750..48d8b5ee 100644 --- a/ai-service/tests/test_lexi_chat_routes.py +++ b/ai-service/tests/test_lexi_chat_routes.py @@ -193,6 +193,61 @@ async def test_lexi_chat_returns_cached_idempotency_response(mock_store, mock_id run_pipeline.assert_not_called() +# ─── native_language ────────────────────────────────────────────────────────── + +def test_idempotency_request_hash_differs_by_native_language(): + """Two otherwise-identical requests in different native languages must not + collide on the same idempotency key — a Japanese learner's reply shouldn't + surface a cached Vietnamese-hint response (or vice versa).""" + base = dict(user_id="u1", session_id="s1", message="Hello Lexi") + req_vi = svc.LexiChatRequest(**base, native_language="vi") + req_ja = svc.LexiChatRequest(**base, native_language="ja") + + assert svc.idempotency_request_hash(req_vi) != svc.idempotency_request_hash(req_ja) + + +def test_lexi_chat_request_defaults_native_language_to_vi(): + request = lexi_route.LexiChatRequest(user_id="u1", message="Hello") + assert request.native_language == "vi" + + +@pytest.mark.asyncio +async def test_lexi_chat_passes_native_language_into_learner_profile( + mock_store, mock_idempotency, monkeypatch +): + """native_language="ja" must reach orchestrator.process as a mapped + learner_profile["native_language"] = "Japanese", not the Vietnamese default.""" + monkeypatch.setattr(lexi_route, "enforce_user_scope", lambda cu, uid: uid) + monkeypatch.setattr(lexi_route, "enforce_user_quota", AsyncMock(return_value=_quota())) + monkeypatch.setattr(lexi_route, "emit_ai_audit_event", AsyncMock()) + + orchestrator = MagicMock() + orchestrator.process = AsyncMock( + return_value={"tutor_response": "Good job!", "metadata": {}} + ) + monkeypatch.setattr( + "api.services.orchestrator.get_orchestrator", AsyncMock(return_value=orchestrator) + ) + + db = _make_db() + request_ctx = MagicMock() + request_ctx.headers = {"X-Request-Id": "req-ja"} + + await lexi_route.lexi_chat( + request_context=request_ctx, + request=lexi_route.LexiChatRequest( + user_id="u1", message="Hello Lexi", native_language="ja" + ), + x_idempotency_key=None, + db=db, + current_user=_user(), + ) + + orchestrator.process.assert_awaited_once() + learner_profile = orchestrator.process.call_args.kwargs["learner_profile"] + assert learner_profile["native_language"] == "Japanese" + + @pytest.mark.asyncio async def test_lexi_chat_uses_hot_cached_session_without_mongo_lookup( mock_store, diff --git a/ai-service/tests/test_tracecag_chat_integration.py b/ai-service/tests/test_tracecag_chat_integration.py index c52f6194..6385910d 100644 --- a/ai-service/tests/test_tracecag_chat_integration.py +++ b/ai-service/tests/test_tracecag_chat_integration.py @@ -259,6 +259,41 @@ async def _fake_get_orchestrator(): assert first_user_msg["content"] == "Transcribed from voice" +@pytest.mark.asyncio +async def test_lexi_chat_native_language_ja_maps_to_japanese_in_learner_profile( + monkeypatch, + mock_lexi_db, + mock_lexi_store, +): + """A Japanese learner's request must reach orchestrator.process with + learner_profile["native_language"] == "Japanese", not the Vietnamese + default — and the cache must not be shared across languages.""" + orchestrator = MagicMock() + orchestrator.process = AsyncMock( + return_value={"tutor_response": "Good job!", "metadata": {}} + ) + + async def _fake_get_orchestrator(): + return orchestrator + + monkeypatch.setattr("api.services.orchestrator.get_orchestrator", _fake_get_orchestrator) + + await lexi_route.lexi_chat( + request_context=MagicMock(), + request=lexi_route.LexiChatRequest( + user_id="u1", + message="text input", + learner_level="B1", + native_language="ja", + ), + db=mock_lexi_db, + current_user=MagicMock(user_id="u1"), + ) + + learner_profile = orchestrator.process.call_args.kwargs["learner_profile"] + assert learner_profile["native_language"] == "Japanese" + + @pytest.mark.asyncio async def test_lexi_chat_trace_cag_primary_fail_then_degraded_retry_with_tts( monkeypatch, @@ -291,6 +326,7 @@ async def _fake_get_orchestrator(): input_type="text", enable_tts=True, learner_level="B1", + native_language="ja", ), db=mock_lexi_db, current_user=MagicMock(user_id="u1"), @@ -304,6 +340,10 @@ async def _fake_get_orchestrator(): assert response.metadata["trace-cag_metadata"]["fallback_used"] is True assert response.metadata["trace-cag_metadata"]["retry_mode"] == "trace-cag_degraded" + # native_language must survive into the degraded retry call too. + retry_learner_profile = orchestrator.process.await_args_list[1].kwargs["learner_profile"] + assert retry_learner_profile["native_language"] == "Japanese" + @pytest.mark.asyncio async def test_lexi_chat_tts_timeout_returns_text_without_audio( diff --git a/ai-service/tests/trace_cag/test_benchmark_model_strength.py b/ai-service/tests/trace_cag/test_benchmark_model_strength.py new file mode 100644 index 00000000..7ea9a7ba --- /dev/null +++ b/ai-service/tests/trace_cag/test_benchmark_model_strength.py @@ -0,0 +1,38 @@ +"""Regression test for benchmark model-strength classification (Bug 9). + +The old substring-tag logic mis-read "groq/qwen/qwen3-32b" as WEAK and +"qwen3-1.7b" as STRONG ("7b" ⊂ "1.7b") — exactly backwards — so the main +32B benchmark model got the aggressive extractive override of its answers. +""" + +from api.services.trace_cag.benchmark.qa_generation import _is_strong_benchmark_model + + +def test_qwen3_32b_is_strong(): + assert _is_strong_benchmark_model("groq/qwen/qwen3-32b") is True + + +def test_qwen3_1_7b_is_weak(): + # "7b" is a substring of "1.7b" — must NOT classify the 1.7B model as strong. + assert _is_strong_benchmark_model("ollama/lexilingo-qwen3-1.7b") is False + + +def test_cloud_models_are_strong(): + assert _is_strong_benchmark_model("gemini-2.0-flash") is True + + +def test_small_instant_model_is_weak(): + assert _is_strong_benchmark_model("groq/llama-3.1-8b-instant") is False + + +def test_large_llama_is_strong(): + assert _is_strong_benchmark_model("groq/llama-3.1-70b-versatile") is True + + +def test_extractive_paths_never_strong(): + assert _is_strong_benchmark_model("extractive_fallback") is False + assert _is_strong_benchmark_model("extractive_policy") is False + + +def test_empty_model_is_weak(): + assert _is_strong_benchmark_model("") is False diff --git a/ai-service/tests/trace_cag/test_cache_gate_l1.py b/ai-service/tests/trace_cag/test_cache_gate_l1.py index ba21ad73..3af73deb 100644 --- a/ai-service/tests/trace_cag/test_cache_gate_l1.py +++ b/ai-service/tests/trace_cag/test_cache_gate_l1.py @@ -16,7 +16,7 @@ async def test_cache_gate_l1_near_hit_accepts_cache_entry_dict(monkeypatch): candidate_input = "I went to school yesterday." level = "B1" profile = {"level": level} - now = time.monotonic() + now = time.time() current_key = hashlib.md5(f"{user_input.lower()}||{level}".encode()).hexdigest() candidate_key = hashlib.md5(f"{candidate_input.lower()}||{level}".encode()).hexdigest() @@ -87,7 +87,7 @@ async def test_cache_gate_l1_patches_safe_qa_paraphrase(monkeypatch): candidate_input = "Who was the founder of the corporation behind the iPhone?" level = "B1" profile = {"level": level} - now = time.monotonic() + now = time.time() current_key = hashlib.md5(f"{user_input.lower()}||{level}".encode()).hexdigest() candidate_key = hashlib.md5(f"{candidate_input.lower()}||{level}".encode()).hexdigest() @@ -150,7 +150,7 @@ async def test_cache_gate_l1_rejects_answer_target_shift(monkeypatch): candidate_input = "Who was the founder of the corporation behind the iPhone?" level = "B1" profile = {"level": level} - now = time.monotonic() + now = time.time() current_key = hashlib.md5(f"{user_input.lower()}||{level}".encode()).hexdigest() candidate_key = hashlib.md5(f"{candidate_input.lower()}||{level}".encode()).hexdigest() @@ -204,3 +204,109 @@ async def fake_get_bucket_candidate_keys(_bucket): assert result["cache_hit"] is False assert result["cache_layer"] == "none" assert result["cache_decision"] == "full" + + +@pytest.mark.asyncio +async def test_cache_gate_l0_rejects_reuse_across_native_languages(monkeypatch): + """A Vietnamese-hint cache entry must not be reused for a Japanese learner + asking the identical question at the identical level — same L0 cache_key, + different native_language must still force a full pipeline run.""" + user_input = "She buy a car yesterday and is happy about it now." + level = "B1" + + await cache_mod._write_cache_entry( + state={ + "user_input": user_input, + "learner_profile": {"level": level, "native_language": "Vietnamese"}, + "conversation_history": [], + }, + response="Cached Vietnamese-hint response.", + strategy="feedback", + errors=[], + overall_score=0.9, + ) + + result = await nodes.cache_gate_node( + { + "user_input": user_input, + "session_id": "test-session", + "learner_profile": {"level": level, "native_language": "Japanese"}, + "conversation_history": [], + "cache_policy": "on", + } + ) + + assert result["cache_hit"] is False + assert result["cache_decision"] == "full" + + +@pytest.mark.asyncio +async def test_cache_gate_l1_rejects_near_hit_across_native_languages(monkeypatch): + """An L1 near-hit candidate in a different native language must be + rejected even when level/intent/concepts all match.""" + user_input = "I go to school yesterday." + candidate_input = "I went to school yesterday." + level = "B1" + profile = {"level": level, "native_language": "Japanese"} + now = time.time() + + current_key = hashlib.md5(f"{user_input.lower()}||{level}".encode()).hexdigest() + candidate_key = hashlib.md5(f"{candidate_input.lower()}||{level}".encode()).hexdigest() + profile_epoch = nodes._profile_epoch(profile) + bucket = nodes._build_graph_bucket( + user_input, + level, + nodes._infer_intent_pre_diagnosis(user_input), + profile_epoch, + [], + ) + entry = { + "fingerprint": { + "query_norm": candidate_input.lower(), + "intent": "correct", + "level": level, + "native_language": "Vietnamese", + "root_concepts": nodes._extract_lightweight_graph_concepts(user_input), + "session_turn": 0, + }, + "graph_bucket": bucket, + "profile_snapshot": {"level": level, "native_language": "Vietnamese"}, + "response": "Cached Vietnamese-hint feedback.", + "evidence_bundle": [], + "retrieval_trace": [], + "execution_plan": {"strategy": "feedback", "intent": "correct"}, + "diagnosis_errors": [], + "grammar_score": 0.9, + "fluency_score": 0.9, + "vocabulary_level": level, + "action_plan": [], + "overall_score": 0.9, + "created_at": now, + "ttl": 3600, + } + + async def fake_get_cache_entry(cache_key, _level, _now): + if cache_key == current_key: + return None + if cache_key == candidate_key: + return entry + raise AssertionError(f"unexpected cache key: {cache_key}") + + async def fake_get_bucket_candidate_keys(_bucket): + return [candidate_key] if _bucket == bucket else [] + + monkeypatch.setattr(cache_mod, "_get_cache_entry", fake_get_cache_entry) + monkeypatch.setattr(cache_mod, "_get_bucket_candidate_keys", fake_get_bucket_candidate_keys) + + result = await nodes.cache_gate_node( + { + "user_input": user_input, + "session_id": "test-session", + "learner_profile": profile, + "conversation_history": [], + "cache_policy": "on", + } + ) + + assert result["cache_hit"] is False + assert result["cache_decision"] == "full" diff --git a/ai-service/tests/trace_cag/test_l1_drift_probe_decisions.py b/ai-service/tests/trace_cag/test_l1_drift_probe_decisions.py index 2e15d74e..71659b21 100644 --- a/ai-service/tests/trace_cag/test_l1_drift_probe_decisions.py +++ b/ai-service/tests/trace_cag/test_l1_drift_probe_decisions.py @@ -55,7 +55,7 @@ def _candidate( answer_target=answer_target, relation_hints=relations, evidence_hash="", - created_at=time.monotonic(), + created_at=time.time(), ttl=3600, ) diff --git a/ai-service/tests/trace_cag/test_l1_state_cache.py b/ai-service/tests/trace_cag/test_l1_state_cache.py index cd9a7ebc..41f08d7f 100644 --- a/ai-service/tests/trace_cag/test_l1_state_cache.py +++ b/ai-service/tests/trace_cag/test_l1_state_cache.py @@ -39,7 +39,7 @@ def _candidate(**overrides): "answer_target": "person", "relation_hints": {"founder", "maker"}, "evidence_hash": "ev:iphone:apple", - "created_at": time.monotonic(), + "created_at": time.time(), "ttl": 3600, } base.update(overrides) @@ -117,7 +117,7 @@ def test_l1_full_for_low_concept_overlap(): def test_l1_full_for_stale_candidate(): - candidate = _candidate(created_at=time.monotonic() - 7200, ttl=3600) + candidate = _candidate(created_at=time.time() - 7200, ttl=3600) decision = decide_l1_reuse(_request(), candidate) diff --git a/ai-service/tests/trace_cag/test_native_language_hint.py b/ai-service/tests/trace_cag/test_native_language_hint.py new file mode 100644 index 00000000..14fc196c --- /dev/null +++ b/ai-service/tests/trace_cag/test_native_language_hint.py @@ -0,0 +1,30 @@ +"""Tests for the last-resort native-language fallback hint (nodes_v2._get_predefined_native_hint).""" + +import api.services.trace_cag.nodes_v2 as nodes + +_ERROR = {"type": "past_tense", "span": "go", "correction": "went", "explanation": "past tense"} + + +def test_predefined_native_hint_vietnamese_no_errors(): + assert "tốt" in nodes._get_predefined_native_hint([], "Vietnamese") + + +def test_predefined_native_hint_vietnamese_known_error_type(): + hint = nodes._get_predefined_native_hint([_ERROR], "Vietnamese") + assert "quá khứ" in hint + + +def test_predefined_native_hint_japanese_does_not_fall_back_to_vietnamese(): + hint = nodes._get_predefined_native_hint([_ERROR], "Japanese") + assert "quá khứ" not in hint + assert hint == nodes._PREDEFINED_NATIVE_EXPLANATIONS["Japanese"]["past_tense"] + + +def test_predefined_native_hint_unsupported_language_falls_back_to_short_english(): + hint = nodes._get_predefined_native_hint([_ERROR], "Thai") + assert hint == nodes._FALLBACK_NATIVE_DEFAULT + + +def test_predefined_native_hint_unsupported_language_no_errors(): + hint = nodes._get_predefined_native_hint([], "Thai") + assert hint == nodes._FALLBACK_NATIVE_NO_ERRORS From fa806e41c0f92145c4e1aa5d201753257029e375 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Huu <2001230909@hufi.edu.vn> Date: Fri, 26 Jun 2026 21:09:39 +0700 Subject: [PATCH 12/20] feat(backend): game power-up shop, idempotent challenge claims, service hardening - shop_catalog: 10 in-game power-up consumables (time freeze, extra time, skip token, reveal/translate hint, shield, extra heart, lucky clover, score multiplier, pair swap) + GAME_POWERUP_ITEM_TYPES; alembic upsert migration - item_effects_service: route power-ups to _handle_instant_game_powerup (echo effects payload, no server state); reject cosmetics from the use endpoint; null-safe hint accounting - DailyChallengeService: idempotent claims via ChallengeRewardClaim unique index + single transaction (add_gems commit=False); guards double-claim - admin shop CRUD now accepts JSON body (ShopItemAdminCreate/Update) with effects/icon_url; list endpoint returns them too - services package: split achievement checker into its own module, lazy __getattr__ keeps imports light and backward compatible - schemas: migrate to Pydantic v2 (ConfigDict/field_validator) + guard test - quota_manager: UTC-keyed quotas, input validation (negative/zero cost, unknown API); content_agent_validation + artifact index; misc cleanup - remove dead check_missing.py / test_vocab_definitions.py - fix(test): admin RBAC create-shop test uses json body to match new contract Co-Authored-By: Claude Opus 4.8 --- backend-service/.gitignore | 1 + .../versions/add_game_powerup_shop_items.py | 71 ++ backend-service/app/check_missing.py | 54 -- backend-service/app/core/exceptions.py | 2 +- backend-service/app/core/shop_catalog.py | 93 ++ backend-service/app/crud/course.py | 14 +- backend-service/app/crud/vocabulary.py | 30 +- .../app/routes/admin_gamification.py | 70 +- backend-service/app/routes/game_scoring.py | 3 + backend-service/app/routes/games.py | 7 + backend-service/app/routes/vocabulary.py | 19 +- backend-service/app/routes/youtube.py | 8 + backend-service/app/schemas/content.py | 11 +- backend-service/app/schemas/course.py | 52 +- .../app/schemas/course_category.py | 8 +- backend-service/app/schemas/devices.py | 5 +- backend-service/app/schemas/gamification.py | 89 +- backend-service/app/schemas/proficiency.py | 5 +- backend-service/app/schemas/progress.py | 42 +- backend-service/app/schemas/response.py | 9 +- backend-service/app/schemas/user.py | 8 +- backend-service/app/schemas/vocabulary.py | 17 +- backend-service/app/services/__init__.py | 474 +--------- .../services/achievement_checker_service.py | 456 ++++++++++ .../app/services/api_cache_service.py | 89 +- .../services/content_agent_artifact_index.py | 250 ++++++ .../app/services/content_agent_validation.py | 829 ++++++++---------- .../app/services/item_effects_service.py | 82 +- .../notification_campaign/segmenter.py | 8 +- .../app/services/proficiency_service.py | 10 +- backend-service/app/services/quota_manager.py | 61 +- .../app/services/reminder_service.py | 2 +- .../app/services/streak_service.py | 8 +- .../app/services/user_stats_service.py | 4 +- backend-service/app/services/xp_service.py | 5 +- backend-service/app/tasks/content_prefetch.py | 1 + backend-service/app/test_vocab_definitions.py | 18 - backend-service/tests/conftest.py | 4 +- .../tests/crud/test_course_crud_queries.py | 19 + .../tests/test_achievement_system.py | 4 +- .../tests/test_admin_rbac_security.py | 2 +- backend-service/tests/test_admin_routes.py | 73 +- .../tests/test_api_cache_service.py | 57 ++ backend-service/tests/test_auth_routes.py | 6 +- .../tests/test_content_agent_validation.py | 57 ++ .../tests/test_course_categories_routes.py | 6 +- backend-service/tests/test_courses_routes.py | 4 +- backend-service/tests/test_devices_routes.py | 8 +- backend-service/tests/test_games_routes.py | 6 + .../tests/test_item_effects_game_powerups.py | 79 ++ .../tests/test_item_effects_service.py | 125 ++- backend-service/tests/test_learning_routes.py | 6 +- .../tests/test_proficiency_functional.py | 50 +- backend-service/tests/test_quota_manager.py | 89 ++ .../tests/test_schema_pydantic_v2_debt.py | 28 + backend-service/tests/test_shop_catalog.py | 25 +- backend-service/tests/test_streak_service.py | 42 + backend-service/tests/test_users_routes.py | 6 +- .../tests/test_vocabulary_routes.py | 23 +- backend-service/tests/test_xp_service.py | 74 ++ backend-service/tests/test_youtube_routes.py | 12 +- 61 files changed, 2435 insertions(+), 1285 deletions(-) create mode 100644 backend-service/alembic/versions/add_game_powerup_shop_items.py delete mode 100644 backend-service/app/check_missing.py create mode 100644 backend-service/app/services/achievement_checker_service.py create mode 100644 backend-service/app/services/content_agent_artifact_index.py delete mode 100644 backend-service/app/test_vocab_definitions.py create mode 100644 backend-service/tests/crud/test_course_crud_queries.py create mode 100644 backend-service/tests/test_api_cache_service.py create mode 100644 backend-service/tests/test_item_effects_game_powerups.py create mode 100644 backend-service/tests/test_quota_manager.py create mode 100644 backend-service/tests/test_schema_pydantic_v2_debt.py create mode 100644 backend-service/tests/test_streak_service.py diff --git a/backend-service/.gitignore b/backend-service/.gitignore index 3341093c..50d3713f 100644 --- a/backend-service/.gitignore +++ b/backend-service/.gitignore @@ -66,3 +66,4 @@ data/ # Logs *.log logs/ +import_* \ No newline at end of file diff --git a/backend-service/alembic/versions/add_game_powerup_shop_items.py b/backend-service/alembic/versions/add_game_powerup_shop_items.py new file mode 100644 index 00000000..9939edb5 --- /dev/null +++ b/backend-service/alembic/versions/add_game_powerup_shop_items.py @@ -0,0 +1,71 @@ +"""Add in-game power-up shop items (time freeze, skip token, shield, etc). + +Revision ID: add_game_powerup_items +Revises: backfill_is_verified_legacy_users +""" + +from typing import Sequence, Union +import uuid + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID as PG_UUID, JSON as PG_JSON + +from app.core.shop_catalog import SHOP_CATALOG + + +revision: str = "add_game_powerup_items" +down_revision: Union[str, None] = "backfill_is_verified_legacy_users" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + connection = op.get_bind() + + _insert = sa.text( + "INSERT INTO shop_items (id, name, description, item_type, price_gems, " + "effects, icon_url, is_available, created_at) VALUES " + "(:id, :name, :description, :item_type, :price_gems, :effects, :icon_url, :is_available, NOW())" + ).bindparams( + sa.bindparam("id", type_=PG_UUID()), + sa.bindparam("effects", type_=PG_JSON()), + ) + + _update = sa.text( + "UPDATE shop_items SET description=:description, item_type=:item_type, " + "price_gems=:price_gems, effects=:effects, icon_url=:icon_url, " + "is_available=:is_available WHERE name=:name" + ).bindparams( + sa.bindparam("effects", type_=PG_JSON()), + ) + + for item in SHOP_CATALOG: + existing = connection.execute( + sa.text("SELECT name FROM shop_items WHERE name = :name"), + {"name": item["name"]}, + ).first() + params = { + "name": item["name"], + "description": item["description"], + "item_type": item["item_type"], + "price_gems": item["price_gems"], + "effects": item.get("effects"), + "icon_url": item.get("icon_url"), + "is_available": item.get("is_available", True), + } + if existing: + connection.execute(_update, params) + else: + connection.execute(_insert, {"id": uuid.uuid4(), **params}) + + +def downgrade() -> None: + from app.core.shop_catalog import GAME_POWERUP_ITEM_TYPES + + powerup_names = [ + item["name"] for item in SHOP_CATALOG if item["item_type"] in GAME_POWERUP_ITEM_TYPES + ] + connection = op.get_bind() + shop_items = sa.table("shop_items", sa.column("name", sa.String())) + connection.execute(shop_items.delete().where(shop_items.c.name.in_(powerup_names))) diff --git a/backend-service/app/check_missing.py b/backend-service/app/check_missing.py deleted file mode 100644 index 92d5d469..00000000 --- a/backend-service/app/check_missing.py +++ /dev/null @@ -1,54 +0,0 @@ -import json -import asyncio -import sys -from sqlalchemy import select, func -from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession -from sqlalchemy.orm import sessionmaker - -sys.path.append("/app") -from app.models.vocabulary import VocabularyItem, PartOfSpeech -from app.core.config import settings - -INPUT_FILE = "/app/data/vocabulary_import.json" - -def guess_pos(word, defn): - # Remove Vietnamese "v.v." / "v. v." to prevent false verb matching on " v." - clean_defn = defn.replace("v.v.", "").replace("v. v.", "") - if " v." in clean_defn or " verb" in clean_defn or word.startswith("to "): - return PartOfSpeech.VERB - if " adj." in clean_defn or " adj " in clean_defn: - return PartOfSpeech.ADJECTIVE - if " adv." in clean_defn or " adv " in clean_defn: - return PartOfSpeech.ADVERB - if " phrase" in clean_defn or " idiom" in clean_defn or " " in word: - return PartOfSpeech.PHRASE - return PartOfSpeech.NOUN - -async def main(): - with open(INPUT_FILE, "r", encoding="utf-8") as f: - data = json.load(f) - - engine = create_async_engine(settings.async_database_url, echo=False) - AsyncSessionLocal = sessionmaker( - engine, class_=AsyncSession, expire_on_commit=False - ) - - async with AsyncSessionLocal() as session: - # Get all existing word + part_of_speech pairs - result = await session.execute(select(VocabularyItem.word, VocabularyItem.part_of_speech)) - existing = {(r[0], r[1]) for r in result.all()} - - missing_count = 0 - for item in data: - word = item['word'][:255] - defn = item.get('definition', '') - pos = guess_pos(word, defn) - if (word, pos) not in existing: - missing_count += 1 - - print(f"TOTAL_IN_JSON: {len(data)}") - print(f"EXISTING_IN_DB: {len(existing)}") - print(f"MISSING_FROM_DB: {missing_count}") - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/backend-service/app/core/exceptions.py b/backend-service/app/core/exceptions.py index 96af838a..bc23915e 100644 --- a/backend-service/app/core/exceptions.py +++ b/backend-service/app/core/exceptions.py @@ -63,7 +63,7 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE ) return JSONResponse( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, content=error_response.model_dump(mode="json") ) diff --git a/backend-service/app/core/shop_catalog.py b/backend-service/app/core/shop_catalog.py index bf4f4240..8a36c106 100644 --- a/backend-service/app/core/shop_catalog.py +++ b/backend-service/app/core/shop_catalog.py @@ -89,6 +89,99 @@ def _avatar_item(seed: str, price: int) -> dict[str, Any]: "effects": {"quantity": 3}, "is_available": True, }, + { + "name": "Time Freeze", + "description": "Pause the countdown timer for 10 seconds in any timed game", + "item_type": "time_freeze", + "price_gems": 10, + "effects": {"seconds": 10}, + "is_available": True, + }, + { + "name": "Extra Time", + "description": "Add 20 seconds straight to the clock in any timed game", + "item_type": "extra_time", + "price_gems": 15, + "effects": {"seconds": 20}, + "is_available": True, + }, + { + "name": "Skip Token", + "description": "Skip the current word or question with no penalty", + "item_type": "skip_token", + "price_gems": 12, + "effects": {}, + "is_available": True, + }, + { + "name": "Magnifying Glass", + "description": "Free reveal: the next letter, or eliminate 2 wrong options", + "item_type": "reveal_hint", + "price_gems": 8, + "effects": {"mode": "letter"}, + "is_available": True, + }, + { + "name": "Quick Translate", + "description": "Reveal the Vietnamese translation of the current word", + "item_type": "translate_hint", + "price_gems": 8, + "effects": {"mode": "translation"}, + "is_available": True, + }, + { + "name": "Shield", + "description": "Negate the next wrong answer or life loss", + "item_type": "mistake_shield", + "price_gems": 18, + "effects": {}, + "is_available": True, + }, + { + "name": "Extra Heart", + "description": "Start Hangman with one extra life", + "item_type": "extra_heart", + "price_gems": 15, + "effects": {"lives": 1}, + "is_available": True, + }, + { + "name": "Lucky Clover", + "description": "30% chance to auto-correct your next wrong answer", + "item_type": "lucky_clover", + "price_gems": 20, + "effects": {"chance": 0.3}, + "is_available": True, + }, + { + "name": "Score Multiplier", + "description": "Double your in-game score for the rest of this session", + "item_type": "score_multiplier", + "price_gems": 22, + "effects": {"multiplier": 2}, + "is_available": True, + }, + { + "name": "Pair Swap", + "description": "Undo one wrong match in Matching Game for a free retry", + "item_type": "pair_swap", + "price_gems": 10, + "effects": {}, + "is_available": True, + }, +] + +GAME_POWERUP_ITEM_TYPES: list[str] = [ + "time_freeze", + "extra_time", + "skip_token", + "reveal_hint", + "translate_hint", + "mistake_shield", + "extra_heart", + "lucky_clover", + "score_multiplier", + "pair_swap", ] _AVATAR_SEEDS_BY_TIER = { diff --git a/backend-service/app/crud/course.py b/backend-service/app/crud/course.py index d7cf2e4a..43f64d48 100644 --- a/backend-service/app/crud/course.py +++ b/backend-service/app/crud/course.py @@ -6,8 +6,8 @@ """ from typing import List, Optional -from sqlalchemy import select, func, and_, or_, text -from sqlalchemy.orm import Session, selectinload +from sqlalchemy import String, case, cast, select, func, and_ +from sqlalchemy.orm import selectinload from sqlalchemy.ext.asyncio import AsyncSession import uuid @@ -16,6 +16,14 @@ from app.schemas.course import CourseCreate, CourseUpdate, UnitCreate, UnitUpdate, LessonCreate, LessonUpdate +def _seed_first_ordering(): + """Return a portable expression that sorts seeded courses first.""" + return case( + (cast(Course.tags, String).like('%"seed"%'), 0), + else_=1, + ) + + # ===================== # Course CRUD # ===================== @@ -79,7 +87,7 @@ async def get_courses( # Get paginated results — seed/curated courses first, then crawled query = query.order_by( - text("CASE WHEN tags @> '[\"seed\"]'::jsonb THEN 0 ELSE 1 END"), + _seed_first_ordering(), Course.created_at.asc() ).offset(skip).limit(limit) result = await db.execute(query) diff --git a/backend-service/app/crud/vocabulary.py b/backend-service/app/crud/vocabulary.py index aa275e2c..5aa576ee 100644 --- a/backend-service/app/crud/vocabulary.py +++ b/backend-service/app/crud/vocabulary.py @@ -135,7 +135,10 @@ async def get_vocabulary_items( func.lower(VocabularyItem.word), VocabularyItem.id, ) - + + if not tag: + query = query.offset(offset).limit(limit) + result = await db.execute(query) items = [ item @@ -150,7 +153,7 @@ async def get_vocabulary_items( if expected_tag in normalize_all_tags(item.tags) ] return items[offset : offset + limit] - return items[offset : offset + limit] + return items async def search_vocabulary( self, @@ -270,6 +273,29 @@ async def get_user_vocabulary_list( result = await db.execute(query) return list(result.scalars().all()) + + async def count_user_vocabulary( + self, + db: AsyncSession, + user_id: uuid.UUID, + status: Optional[VocabularyStatus] = None, + ) -> int: + """Count visible vocabulary entries in a user's collection.""" + query = ( + select(func.count(UserVocabulary.id)) + .join( + VocabularyItem, + VocabularyItem.id == UserVocabulary.vocabulary_id, + ) + .where(UserVocabulary.user_id == user_id) + .where(*_valid_definition_conditions()) + ) + + if status: + query = query.where(UserVocabulary.status == status) + + result = await db.execute(query) + return result.scalar() or 0 async def add_to_collection( self, diff --git a/backend-service/app/routes/admin_gamification.py b/backend-service/app/routes/admin_gamification.py index 1757123c..3c707028 100644 --- a/backend-service/app/routes/admin_gamification.py +++ b/backend-service/app/routes/admin_gamification.py @@ -11,6 +11,7 @@ from app.models.user import User from app.models.gamification import Achievement, ShopItem from app.schemas.response import ApiResponse +from app.schemas.gamification import ShopItemAdminCreate, ShopItemAdminUpdate router = APIRouter(prefix="/admin", tags=["Admin"]) @@ -220,6 +221,8 @@ async def list_shop_items_admin( "description": item.description, "item_type": item.item_type, "price_gems": item.price_gems, + "icon_url": item.icon_url, + "effects": item.effects, "is_available": item.is_available, "stock_quantity": item.stock_quantity } for item in items] @@ -228,32 +231,29 @@ async def list_shop_items_admin( @router.post("/shop", response_model=ApiResponse[dict]) async def create_shop_item( - name: str, - description: str, - item_type: str, - price_gems: int, - is_available: bool = True, - stock_quantity: Optional[int] = None, + payload: ShopItemAdminCreate, db: AsyncSession = Depends(get_db), admin_user: User = Depends(require_admin) ): """ Create a new shop item. - + Admin only endpoint. """ item = ShopItem( - name=name, - description=description, - item_type=item_type, - price_gems=price_gems, - is_available=is_available, - stock_quantity=stock_quantity + name=payload.name, + description=payload.description, + item_type=payload.item_type, + price_gems=payload.price_gems, + icon_url=payload.icon_url, + effects=payload.effects, + is_available=payload.is_available, + stock_quantity=payload.stock_quantity ) db.add(item) await db.commit() await db.refresh(item) - + return ApiResponse( success=True, message="Shop item created successfully", @@ -268,44 +268,46 @@ async def create_shop_item( @router.put("/shop/{item_id}", response_model=ApiResponse[dict]) async def update_shop_item( item_id: UUID, - name: Optional[str] = None, - description: Optional[str] = None, - price_gems: Optional[int] = None, - is_available: Optional[bool] = None, - stock_quantity: Optional[int] = None, + payload: ShopItemAdminUpdate, db: AsyncSession = Depends(get_db), admin_user: User = Depends(require_admin) ): """ Update a shop item. - + Admin only endpoint. """ result = await db.execute( select(ShopItem).where(ShopItem.id == item_id) ) item = result.scalar_one_or_none() - + if not item: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Shop item not found" ) - - if name is not None: - item.name = name - if description is not None: - item.description = description - if price_gems is not None: - item.price_gems = price_gems - if is_available is not None: - item.is_available = is_available - if stock_quantity is not None: - item.stock_quantity = stock_quantity - + + if payload.name is not None: + item.name = payload.name + if payload.description is not None: + item.description = payload.description + if payload.item_type is not None: + item.item_type = payload.item_type + if payload.price_gems is not None: + item.price_gems = payload.price_gems + if payload.icon_url is not None: + item.icon_url = payload.icon_url + if payload.effects is not None: + item.effects = payload.effects + if payload.is_available is not None: + item.is_available = payload.is_available + if payload.stock_quantity is not None: + item.stock_quantity = payload.stock_quantity + await db.commit() await db.refresh(item) - + return ApiResponse( success=True, message="Shop item updated successfully", diff --git a/backend-service/app/routes/game_scoring.py b/backend-service/app/routes/game_scoring.py index f5363762..6d0274fa 100644 --- a/backend-service/app/routes/game_scoring.py +++ b/backend-service/app/routes/game_scoring.py @@ -14,6 +14,7 @@ from app.schemas.games import GameSessionCompleteRequest, GameSessionCompleteResponse from app.services import check_achievements_for_user from app.services.game_scoring_service import GameScoringError, score_game +from app.services.item_effects_service import ItemEffectsService from app.services.streak_service import update_user_streak from app.services.xp_service import award_xp_transaction, get_existing_xp_award @@ -88,6 +89,7 @@ async def complete_game_session( except GameScoringError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + item_multiplier = await ItemEffectsService(db).get_xp_multiplier(current_user.id) xp_result = await award_xp_transaction( db=db, user=current_user, @@ -96,6 +98,7 @@ async def complete_game_session( source_id=str(session.id), source_detail=session.game_type, commit=False, + item_multiplier=item_multiplier, ) session.score = score.correct_count diff --git a/backend-service/app/routes/games.py b/backend-service/app/routes/games.py index 81650cd5..5db892dd 100644 --- a/backend-service/app/routes/games.py +++ b/backend-service/app/routes/games.py @@ -31,6 +31,7 @@ GameSessionCompleteResponse, ) from app.services.game_scoring_service import GameScoringError, score_game +from app.services.item_effects_service import ItemEffectsService from app.services.xp_service import award_xp_transaction, get_existing_xp_award from app.core.cache import build_cache_key, delete_cached, invalidate_cache from app.services import check_achievements_for_user @@ -984,6 +985,7 @@ async def get_word_scramble( "definition": w.definition, "xp_value": w.xp_value, "cefr_level": w.cefr_level, + "vietnamese_translation": w.vietnamese_translation, }) session = await create_game_session( @@ -1169,6 +1171,7 @@ async def get_spelling_bee( "example_sentence": w.example_sentence, "xp_value": w.xp_value, "audio_url": None, + "vietnamese_translation": w.vietnamese_translation, }) session = await create_game_session( @@ -1275,6 +1278,7 @@ async def get_hangman_word( "xp_value": word_obj.xp_value, "base_xp": word_obj.xp_value, "max_lives": 6, + "vietnamese_translation": word_obj.vietnamese_translation, "hints": { "hint1_free": word_obj.hint or "", "hint2_xp_cost": hint2_cost, @@ -1323,6 +1327,7 @@ async def get_hangman_word( "xp_value": word_obj["xp_value"], "base_xp": word_obj["xp_value"], "max_lives": 6, + "vietnamese_translation": word_obj.get("vietnamese_translation"), "hints": { "hint1_free": word_obj["hint"], "hint2_xp_cost": hint2_cost, @@ -1579,6 +1584,7 @@ async def complete_game_session( detail=str(exc), ) from exc + item_multiplier = await ItemEffectsService(db).get_xp_multiplier(current_user.id) xp_result = await award_xp_transaction( db=db, user=current_user, @@ -1587,6 +1593,7 @@ async def complete_game_session( source_id=str(session.id), source_detail=session.game_type, commit=False, + item_multiplier=item_multiplier, ) session.score = score.correct_count diff --git a/backend-service/app/routes/vocabulary.py b/backend-service/app/routes/vocabulary.py index 77a83dd7..b64b567b 100644 --- a/backend-service/app/routes/vocabulary.py +++ b/backend-service/app/routes/vocabulary.py @@ -241,9 +241,11 @@ async def get_user_collection( "accuracy": uv.accuracy }) - # Get total count (without filters for simplicity) - # In production, add proper count query - total = len(user_vocab_list) + total = await vocabulary_crud.count_user_vocabulary( + db, + user_id=current_user.id, + status=status_filter, + ) return UserVocabularyListResponse( items=items_with_vocab, @@ -490,10 +492,9 @@ async def get_due_vocabulary( limit=limit ) - # Load vocabulary items items_with_vocab = [] for uv in due_vocab: - vocab_item = await vocabulary_crud.get_vocabulary_item(db, uv.vocabulary_id) + vocab_item = uv.vocabulary if vocab_item: items_with_vocab.append({ **uv.__dict__, @@ -540,14 +541,6 @@ async def submit_review( - XP awarded - Next review date """ - # Verify ownership - user_vocab = await vocabulary_crud.get_user_vocabulary( - db, - user_id=current_user.id, - vocabulary_id=uuid.UUID('00000000-0000-0000-0000-000000000000') # Dummy, we'll fetch by ID - ) - - # Actually fetch by user_vocabulary_id and verify user from sqlalchemy import select from app.models.vocabulary import UserVocabulary diff --git a/backend-service/app/routes/youtube.py b/backend-service/app/routes/youtube.py index 50aaf27d..b62bdb68 100644 --- a/backend-service/app/routes/youtube.py +++ b/backend-service/app/routes/youtube.py @@ -38,6 +38,9 @@ } YOUTUBE_API_BASE = "https://www.googleapis.com/youtube/v3" +YOUTUBE_CHANNEL_THUMBNAIL_COST = 1 +YOUTUBE_SEARCH_COST = 100 +YOUTUBE_NO_DATA_API_COST = 0 def _raise_youtube_api_error(response: httpx.Response) -> None: @@ -181,6 +184,7 @@ async def get_curated_channels( api_name="youtube", fetch_fn=lambda: _fetch_channel_thumbnails(channel_ids), priority=Priority.LOW, + cost=YOUTUBE_CHANNEL_THUMBNAIL_COST, redis_ttl=604800, # 7 days db_ttl=2592000, # 30 days ) @@ -267,6 +271,7 @@ async def search_videos( page_token=page_token, ), priority=Priority.HIGH, + cost=YOUTUBE_SEARCH_COST, redis_ttl=21600, # 6 hours db_ttl=43200, # 12 hours ) @@ -338,6 +343,7 @@ async def get_captions( api_name="youtube", fetch_fn=lambda: _fetch_captions_transcript_api(video_id, lang), priority=Priority.HIGH, + cost=YOUTUBE_NO_DATA_API_COST, redis_ttl=604800, # 7 days db_ttl=31536000, # 1 year ) @@ -456,6 +462,7 @@ async def get_channel_videos( page_token=page_token, ), priority=Priority.MEDIUM, + cost=YOUTUBE_SEARCH_COST, redis_ttl=43200, # 12 hours db_ttl=86400, # 24 hours ) @@ -526,6 +533,7 @@ async def translate_word( api_name="youtube", fetch_fn=lambda: _fetch_word_data(clean_word, lang, context), priority=Priority.MEDIUM, + cost=YOUTUBE_NO_DATA_API_COST, redis_ttl=2592000, # 30 days db_ttl=7776000, # 90 days ) diff --git a/backend-service/app/schemas/content.py b/backend-service/app/schemas/content.py index 2d4adbd7..3579fe25 100644 --- a/backend-service/app/schemas/content.py +++ b/backend-service/app/schemas/content.py @@ -5,7 +5,7 @@ import json from datetime import datetime from typing import Any -from pydantic import BaseModel, Field, UUID4, field_validator +from pydantic import BaseModel, ConfigDict, Field, UUID4, field_validator def _coerce_json_list(value: Any) -> list[Any] | None: @@ -99,8 +99,7 @@ class GrammarResponse(GrammarBase): created_at: datetime updated_at: datetime - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # ------------------- Question Bank ------------------- @@ -148,8 +147,7 @@ class QuestionResponse(QuestionBase): created_at: datetime updated_at: datetime - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # ------------------- Test Exams ------------------- @@ -188,5 +186,4 @@ class TestExamResponse(TestExamBase): created_at: datetime updated_at: datetime - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) diff --git a/backend-service/app/schemas/course.py b/backend-service/app/schemas/course.py index bc24ff58..ec30355c 100644 --- a/backend-service/app/schemas/course.py +++ b/backend-service/app/schemas/course.py @@ -7,7 +7,7 @@ from typing import Optional, List, Any from datetime import datetime -from pydantic import BaseModel, Field, validator +from pydantic import BaseModel, ConfigDict, Field, field_validator import uuid @@ -24,7 +24,8 @@ class CourseBase(BaseModel): tags: Optional[List[str]] = Field(default_factory=list) thumbnail_url: Optional[str] = Field(None, max_length=500) - @validator('level') + @field_validator("level") + @classmethod def validate_level(cls, v): allowed_levels = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2'] if v not in allowed_levels: @@ -47,7 +48,8 @@ class CourseUpdate(BaseModel): thumbnail_url: Optional[str] = Field(None, max_length=500) is_published: Optional[bool] = None - @validator('level') + @field_validator("level") + @classmethod def validate_level(cls, v): if v is not None: allowed_levels = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2'] @@ -70,7 +72,8 @@ class CourseResponse(CourseBase): is_enrolled: Optional[bool] = None user_progress: Optional[float] = None # 0-100% - @validator('tags', pre=True, always=True) + @field_validator("tags", mode="before") + @classmethod def parse_tags(cls, v): """Handle tags as dict with 'categories' key or as list.""" if v is None: @@ -87,8 +90,7 @@ def parse_tags(cls, v): return v return [] - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class CourseListItem(BaseModel): @@ -105,7 +107,8 @@ class CourseListItem(BaseModel): estimated_duration: int = 0 is_enrolled: Optional[bool] = None - @validator('tags', pre=True, always=True) + @field_validator("tags", mode="before") + @classmethod def parse_tags(cls, v): """Handle tags as dict with 'categories' key or as list.""" if v is None: @@ -124,8 +127,7 @@ def parse_tags(cls, v): return v return [] - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # ===================== @@ -163,8 +165,7 @@ class UnitResponse(UnitBase): created_at: datetime updated_at: datetime - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # ===================== @@ -180,7 +181,8 @@ class LessonBase(BaseModel): xp_reward: int = Field(default=10, ge=0) pass_threshold: int = Field(default=80, ge=0, le=100) - @validator('lesson_type') + @field_validator("lesson_type") + @classmethod def validate_lesson_type(cls, v): allowed_types = ['lesson', 'practice', 'review', 'test', 'vocabulary', 'grammar'] if v not in allowed_types: @@ -206,7 +208,8 @@ class LessonUpdate(BaseModel): estimated_minutes: Optional[int] = Field(None, ge=0) content: Optional[Any] = None - @validator('lesson_type') + @field_validator("lesson_type") + @classmethod def validate_lesson_type(cls, v): if v is not None: allowed_types = ['lesson', 'practice', 'review', 'test', 'vocabulary', 'grammar'] @@ -227,8 +230,7 @@ class LessonResponse(LessonBase): created_at: datetime updated_at: datetime - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class LessonDetailResponse(LessonResponse): @@ -236,8 +238,7 @@ class LessonDetailResponse(LessonResponse): estimated_minutes: int = 10 content: Optional[Any] = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # ===================== @@ -254,8 +255,7 @@ class LessonInUnit(BaseModel): is_locked: Optional[bool] = None is_completed: Optional[bool] = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class UnitWithLessons(BaseModel): @@ -268,8 +268,7 @@ class UnitWithLessons(BaseModel): icon_url: Optional[str] = None lessons: List[LessonInUnit] = Field(default_factory=list) - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class CourseDetailResponse(CourseResponse): @@ -288,13 +287,12 @@ class EnrollmentResponse(BaseModel): enrolled_at: datetime message: str = "Successfully enrolled in course" - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class CourseWithLessons(CourseResponse): """Schema for course with lessons.""" - lessons: List[LessonResponse] = [] + lessons: List[LessonResponse] = Field(default_factory=list) # ===================== @@ -323,7 +321,8 @@ class Exercise(BaseModel): difficulty: int = Field(default=1, ge=1, le=5) points: int = Field(default=10, ge=0) - @validator('type') + @field_validator("type") + @classmethod def validate_type(cls, v): allowed = ['multiple_choice', 'true_false', 'fill_blank', 'translate', 'matching', 'reorder'] if v not in allowed: @@ -344,5 +343,4 @@ class LessonContentResponse(BaseModel): total_exercises: int exercises: List[Exercise] = Field(default_factory=list) - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) diff --git a/backend-service/app/schemas/course_category.py b/backend-service/app/schemas/course_category.py index 2da620b0..e21ad993 100644 --- a/backend-service/app/schemas/course_category.py +++ b/backend-service/app/schemas/course_category.py @@ -6,7 +6,7 @@ from typing import Optional from datetime import datetime -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field import uuid @@ -48,8 +48,7 @@ class CourseCategoryResponse(CourseCategoryBase): created_at: datetime updated_at: datetime - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class CourseCategoryListItem(BaseModel): @@ -62,5 +61,4 @@ class CourseCategoryListItem(BaseModel): color: Optional[str] = None course_count: int - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) diff --git a/backend-service/app/schemas/devices.py b/backend-service/app/schemas/devices.py index 71e2efd0..588cc817 100644 --- a/backend-service/app/schemas/devices.py +++ b/backend-service/app/schemas/devices.py @@ -5,7 +5,7 @@ """ from typing import Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from datetime import datetime @@ -33,5 +33,4 @@ class DeviceResponse(BaseModel): last_active: datetime created_at: datetime - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) diff --git a/backend-service/app/schemas/gamification.py b/backend-service/app/schemas/gamification.py index e9976f38..cc413e53 100644 --- a/backend-service/app/schemas/gamification.py +++ b/backend-service/app/schemas/gamification.py @@ -6,7 +6,7 @@ from datetime import datetime from typing import Optional, List, Dict, Any from uuid import UUID -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from enum import Enum @@ -77,8 +77,7 @@ class AchievementResponse(AchievementBase): is_hidden: bool = False created_at: datetime - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class UserAchievementResponse(BaseModel): @@ -89,8 +88,7 @@ class UserAchievementResponse(BaseModel): progress: int = 0 is_showcased: bool = False - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class AchievementProgressResponse(BaseModel): @@ -107,8 +105,7 @@ class AchievementProgressResponse(BaseModel): progress_percentage: float = 0.0 unlocked_at: Optional[datetime] = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # ============================================================================ @@ -124,8 +121,7 @@ class WalletResponse(BaseModel): total_gems_spent: int updated_at: datetime - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class WalletTransactionResponse(BaseModel): @@ -138,8 +134,7 @@ class WalletTransactionResponse(BaseModel): description: Optional[str] = None created_at: datetime - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class StarterRewardResponse(BaseModel): @@ -155,8 +150,7 @@ class WalletHistoryResponse(BaseModel): wallet: WalletResponse transactions: List[WalletTransactionResponse] - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # ============================================================================ @@ -175,8 +169,7 @@ class LeaderboardUserEntry(BaseModel): lessons_completed: int is_current_user: bool = False - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class LeaderboardResponse(BaseModel): @@ -190,8 +183,7 @@ class LeaderboardResponse(BaseModel): promotion_zone: int = 3 # Top N get promoted demotion_zone: int = 3 # Bottom N get demoted - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class UserLeagueStatusResponse(BaseModel): @@ -212,8 +204,7 @@ class UserLeagueStatusResponse(BaseModel): week_ends_in_hours: int rank_icon_url: str = "" - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # ============================================================================ @@ -232,8 +223,31 @@ class ShopItemResponse(BaseModel): is_available: bool = True stock_quantity: Optional[int] = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) + + +class ShopItemAdminCreate(BaseModel): + """Admin payload to create a shop item, including effects/icon_url.""" + name: str + description: str + item_type: str + price_gems: int + icon_url: Optional[str] = None + effects: Optional[Dict[str, Any]] = None + is_available: bool = True + stock_quantity: Optional[int] = None + + +class ShopItemAdminUpdate(BaseModel): + """Admin payload to update a shop item. All fields optional (partial update).""" + name: Optional[str] = None + description: Optional[str] = None + item_type: Optional[str] = None + price_gems: Optional[int] = None + icon_url: Optional[str] = None + effects: Optional[Dict[str, Any]] = None + is_available: Optional[bool] = None + stock_quantity: Optional[int] = None class PurchaseRequest(BaseModel): @@ -251,8 +265,7 @@ class PurchaseResponse(BaseModel): new_balance: int message: str - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class EquipAvatarRequest(BaseModel): @@ -276,8 +289,7 @@ class UserInventoryItemResponse(BaseModel): expires_at: Optional[datetime] = None purchased_at: datetime - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class InventoryResponse(BaseModel): @@ -285,8 +297,7 @@ class InventoryResponse(BaseModel): items: List[UserInventoryItemResponse] total_items: int - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class UseItemRequest(BaseModel): @@ -302,8 +313,7 @@ class UseItemResponse(BaseModel): effects_applied: Optional[Dict[str, Any]] = None expires_at: Optional[datetime] = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # ============================================================================ @@ -338,8 +348,7 @@ class UserSocialProfile(BaseModel): similarity_score: Optional[float] = None distance_km: Optional[float] = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class FollowersListResponse(BaseModel): @@ -347,8 +356,7 @@ class FollowersListResponse(BaseModel): users: List[UserSocialProfile] total: int - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class FriendSuggestionsResponse(BaseModel): @@ -356,8 +364,7 @@ class FriendSuggestionsResponse(BaseModel): users: List[UserSocialProfile] total: int - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class LocationUpdateRequest(BaseModel): @@ -375,8 +382,7 @@ class LocationUpdateResponse(BaseModel): stored_longitude: Optional[float] = None expires_in_seconds: int = 0 - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class NearbyUsersResponse(BaseModel): @@ -386,8 +392,7 @@ class NearbyUsersResponse(BaseModel): radius_km: float location_enabled: bool - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class ActivityFeedItem(BaseModel): @@ -402,8 +407,7 @@ class ActivityFeedItem(BaseModel): activity_data: Optional[Dict[str, Any]] = None created_at: datetime - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class ActivityFeedResponse(BaseModel): @@ -412,5 +416,4 @@ class ActivityFeedResponse(BaseModel): total: int has_more: bool = False - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) diff --git a/backend-service/app/schemas/proficiency.py b/backend-service/app/schemas/proficiency.py index f3b5f9b0..1ac35cdd 100644 --- a/backend-service/app/schemas/proficiency.py +++ b/backend-service/app/schemas/proficiency.py @@ -14,7 +14,7 @@ from typing import Optional, List, Dict from datetime import datetime -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from enum import Enum @@ -86,8 +86,7 @@ class ProficiencyProfile(BaseModel): # Level history level_history: List[Dict] = Field(default_factory=list) - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # ===================== diff --git a/backend-service/app/schemas/progress.py b/backend-service/app/schemas/progress.py index 07103b30..3551b8e2 100644 --- a/backend-service/app/schemas/progress.py +++ b/backend-service/app/schemas/progress.py @@ -6,7 +6,7 @@ from datetime import datetime from typing import Optional, List, Dict, Any from uuid import UUID -from pydantic import BaseModel, Field, validator +from pydantic import BaseModel, ConfigDict, Field, field_validator from enum import Enum @@ -34,7 +34,8 @@ class LessonCompletionBase(BaseModel): lesson_id: str = Field(..., description="UUID of the lesson") score: float = Field(..., ge=0, le=100, description="Score achieved (0-100)") - @validator('score') + @field_validator("score") + @classmethod def validate_score(cls, v): if v < 0 or v > 100: raise ValueError('Score must be between 0 and 100') @@ -57,8 +58,7 @@ class LessonCompletionResponse(BaseModel): course_progress: float message: str - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class UserProgressSummary(BaseModel): @@ -71,8 +71,7 @@ class UserProgressSummary(BaseModel): longest_streak: int achievements_unlocked: int - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class CourseProgressDetail(BaseModel): @@ -87,8 +86,7 @@ class CourseProgressDetail(BaseModel): last_activity_at: datetime estimated_completion_days: Optional[int] = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class CourseProgressResponse(BaseModel): @@ -96,8 +94,7 @@ class CourseProgressResponse(BaseModel): course: CourseProgressDetail units_progress: list[dict] # List of unit progress details - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class UserActivityLog(BaseModel): @@ -107,8 +104,7 @@ class UserActivityLog(BaseModel): xp_earned: int time_spent_minutes: int - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class ProgressStatsResponse(BaseModel): @@ -117,8 +113,7 @@ class ProgressStatsResponse(BaseModel): recent_activity: list[UserActivityLog] course_progress: list[CourseProgressDetail] - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # ============================================================================ @@ -139,8 +134,7 @@ class LessonStartResponse(BaseModel): lives_remaining: int = 3 hints_available: int = 3 - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class AnswerSubmitRequest(BaseModel): @@ -164,8 +158,7 @@ class AnswerSubmitResponse(BaseModel): hints_remaining: int current_score: float - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class LessonCompleteRequest(BaseModel): @@ -191,8 +184,7 @@ class LessonCompleteResponse(BaseModel): wrong_answers: int hints_used: int - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # ============================================================================ @@ -221,8 +213,7 @@ class LessonProgressItem(BaseModel): icon_url: Optional[str] = None background_color: Optional[str] = "#4CAF50" - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class UnitProgressRoadmap(BaseModel): @@ -245,8 +236,7 @@ class UnitProgressRoadmap(BaseModel): icon_url: Optional[str] = None background_color: Optional[str] = "#2196F3" - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class CourseRoadmapResponse(BaseModel): @@ -269,6 +259,4 @@ class CourseRoadmapResponse(BaseModel): # Roadmap data units: List[UnitProgressRoadmap] - class Config: - from_attributes = True - + model_config = ConfigDict(from_attributes=True) diff --git a/backend-service/app/schemas/response.py b/backend-service/app/schemas/response.py index 1593db99..aad1924e 100644 --- a/backend-service/app/schemas/response.py +++ b/backend-service/app/schemas/response.py @@ -3,7 +3,7 @@ """ from typing import Generic, TypeVar, Any -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict T = TypeVar('T') @@ -14,12 +14,13 @@ class ApiResponse(BaseModel, Generic[T]): data: T | None = None error: str | None = None - class Config: - json_schema_extra = { + model_config = ConfigDict( + json_schema_extra={ "example": { "success": True, "message": "Operation completed successfully", "data": {}, - "error": None + "error": None, } } + ) diff --git a/backend-service/app/schemas/user.py b/backend-service/app/schemas/user.py index 6215db15..b4d988b4 100644 --- a/backend-service/app/schemas/user.py +++ b/backend-service/app/schemas/user.py @@ -5,7 +5,7 @@ from typing import Optional from uuid import UUID from datetime import datetime -from pydantic import BaseModel, EmailStr, Field, UUID4 +from pydantic import BaseModel, ConfigDict, EmailStr, Field, UUID4 class UserBase(BaseModel): @@ -63,8 +63,7 @@ class UserResponse(UserBase): role_id: Optional[UUID] = None role_slug: Optional[str] = None # Populated from role relationship - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class UserInDB(UserResponse): @@ -93,5 +92,4 @@ class AdminUserListItem(BaseModel): created_at: datetime last_login: Optional[datetime] = None - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) diff --git a/backend-service/app/schemas/vocabulary.py b/backend-service/app/schemas/vocabulary.py index f02912b8..b542f516 100644 --- a/backend-service/app/schemas/vocabulary.py +++ b/backend-service/app/schemas/vocabulary.py @@ -56,8 +56,7 @@ class VocabularyItemResponse(VocabularyItemBase): created_at: datetime updated_at: datetime - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # ===== UserVocabulary Schemas ===== @@ -114,8 +113,7 @@ class UserVocabularyResponse(BaseModel): is_due: bool = Field(default=False, description="Whether review is due") accuracy: float = Field(default=0.0, description="Accuracy percentage") - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class UserVocabularyWithItem(UserVocabularyResponse): @@ -147,10 +145,12 @@ class QuickSaveVocabularyRequest(BaseModel): difficulty_level: Optional[str] = Field("A1", max_length=2) @field_validator("difficulty_level") + @classmethod def normalize_difficulty_level(cls, v: Optional[str]) -> Optional[str]: return v.upper() if isinstance(v, str) else v @field_validator("part_of_speech") + @classmethod def normalize_part_of_speech(cls, v: Optional[str]) -> Optional[str]: return v.lower() if isinstance(v, str) else v @@ -172,7 +172,8 @@ class ReviewSubmission(BaseModel): quality: int = Field(..., ge=0, le=5, description="Quality rating (0-5)") time_spent_ms: int = Field(default=0, ge=0, description="Time spent in milliseconds") - @field_validator('quality') + @field_validator("quality") + @classmethod def validate_quality(cls, v): """Ensure quality is in valid range""" if not 0 <= v <= 5: @@ -212,8 +213,7 @@ class VocabularyReviewHistoryItem(BaseModel): interval_after: Optional[int] reviewed_at: datetime - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) # ===== Due Vocabulary Schemas ===== @@ -262,8 +262,7 @@ class VocabularyDeckResponse(VocabularyDeckBase): updated_at: datetime item_count: int = Field(default=0, description="Number of items in deck") - class Config: - from_attributes = True + model_config = ConfigDict(from_attributes=True) class AddToDeckRequest(BaseModel): diff --git a/backend-service/app/services/__init__.py b/backend-service/app/services/__init__.py index ee209389..6bda698f 100644 --- a/backend-service/app/services/__init__.py +++ b/backend-service/app/services/__init__.py @@ -1,465 +1,23 @@ -""" -Achievement Checker Service -Stateless service to evaluate and unlock achievements based on user actions. +"""Service package exports. -This service checks achievement conditions and automatically unlocks badges -when conditions are met. It's designed to be called after specific user actions. +Heavy service implementations live in dedicated modules. Keep package import +lightweight so unrelated routes do not load achievement-checking dependencies +unless they explicitly need them. """ -from typing import List, Optional, Dict, Any -from uuid import UUID -from datetime import datetime, timezone -import logging -from sqlalchemy import select, func, and_ -from sqlalchemy.exc import IntegrityError -from sqlalchemy.ext.asyncio import AsyncSession - -from app.models.gamification import Achievement, UserAchievement, ChallengeRewardClaim -from app.models.progress import UserCourseProgress, LessonCompletion, Streak, DailyActivity -from app.models.vocabulary import UserVocabulary, VocabularyStatus -from app.models.user import User -from app.models.games import GameSession - -logger = logging.getLogger(__name__) - - -# Mapping of triggers to achievement condition types -TRIGGER_CONDITIONS = { - "lesson_complete": [ - "lesson_complete", "xp_earned", "course_complete", - "numeric_level", "speed_lesson", - ], - "streak_update": ["reach_streak", "comeback"], - "vocab_review": ["vocab_mastered", "vocab_reviewed"], - "quiz_complete": ["perfect_score", "quiz_complete", "first_perfect"], - "voice_practice": ["voice_practice"], - "xp_earned": ["xp_earned", "numeric_level"], - "study_session": ["study_time_night", "study_time_morning"], - "grammar_complete": ["grammar_mastered"], - "culture_complete": ["culture_lesson"], - "writing_complete": ["writing_complete"], - "listening_complete": ["listening_complete"], - "social_action": ["social_interaction", "help_others"], - "chat_complete": ["chat_complete"], - "daily_challenge": ["daily_challenge_complete"], - "game_complete": [ - "game_complete", - "xp_earned", - "numeric_level", - "reach_streak", - ], -} - - -class AchievementCheckerService: - """ - Service to check and unlock achievements. - - Usage: - checker = AchievementCheckerService(db) - newly_unlocked = await checker.check_by_trigger(user_id, "lesson_complete") - """ - - def __init__(self, db: AsyncSession): - self.db = db - - async def check_all(self, user_id: UUID) -> List[Achievement]: - """ - Check all achievements for a user. - Returns list of newly unlocked achievements. - - Use sparingly as this checks ALL conditions. - Prefer check_by_trigger() for better performance. - """ - # Get all achievements not yet unlocked by user - unlocked_ids = await self._get_unlocked_achievement_ids(user_id) - - result = await self.db.execute( - select(Achievement).where(~Achievement.id.in_(unlocked_ids)) - ) - pending_achievements = list(result.scalars().all()) - - # Get user stats once for all evaluations - user_stats = await self._get_user_stats(user_id) - - newly_unlocked = [] - for achievement in pending_achievements: - if await self._evaluate_condition(user_id, achievement, user_stats): - unlocked = await self._unlock_achievement(user_id, achievement) - if unlocked: - newly_unlocked.append(achievement) - - return newly_unlocked - - async def check_by_trigger(self, user_id: UUID, trigger: str) -> List[Achievement]: - """ - Check only achievements related to a specific trigger. - More efficient than check_all(). - - Args: - user_id: User UUID - trigger: One of "lesson_complete", "streak_update", "vocab_review", - "quiz_complete", "voice_practice", "xp_earned" - - Returns: - List of newly unlocked achievements - """ - condition_types = TRIGGER_CONDITIONS.get(trigger, []) - if not condition_types: - return [] - - # Get pending achievements of relevant types - unlocked_ids = await self._get_unlocked_achievement_ids(user_id) - - result = await self.db.execute( - select(Achievement).where( - and_( - ~Achievement.id.in_(unlocked_ids) if unlocked_ids else True, - Achievement.condition_type.in_(condition_types) - ) - ) - ) - pending_achievements = list(result.scalars().all()) - - if not pending_achievements: - return [] - - # Get only needed stats - user_stats = await self._get_user_stats(user_id, condition_types) - - newly_unlocked = [] - for achievement in pending_achievements: - if await self._evaluate_condition(user_id, achievement, user_stats): - unlocked = await self._unlock_achievement(user_id, achievement) - if unlocked: - newly_unlocked.append(achievement) - - return newly_unlocked - - async def _get_unlocked_achievement_ids(self, user_id: UUID) -> List[UUID]: - """Get IDs of achievements already unlocked by user""" - result = await self.db.execute( - select(UserAchievement.achievement_id).where( - UserAchievement.user_id == user_id - ) - ) - return [row[0] for row in result.all()] - - async def _get_user_stats( - self, - user_id: UUID, - condition_types: Optional[List[str]] = None - ) -> Dict[str, Any]: - """ - Fetch user statistics needed for achievement evaluation. - Only fetches stats relevant to the condition_types if provided. - """ - stats = {} - - # Determine which stats to fetch - fetch_all = condition_types is None - need_lessons = fetch_all or "lesson_complete" in condition_types or "course_complete" in condition_types - need_streak = fetch_all or "reach_streak" in condition_types or "comeback" in condition_types - need_vocab = fetch_all or "vocab_mastered" in condition_types or "vocab_reviewed" in condition_types - need_xp = fetch_all or "xp_earned" in condition_types or "numeric_level" in condition_types - need_quiz = fetch_all or "perfect_score" in condition_types or "quiz_complete" in condition_types or "first_perfect" in condition_types - need_voice = fetch_all or "voice_practice" in condition_types - need_level = fetch_all or "numeric_level" in condition_types - need_time = fetch_all or "study_time_night" in condition_types or "study_time_morning" in condition_types or "speed_lesson" in condition_types - need_skills = fetch_all or any(ct in (condition_types or []) for ct in ["grammar_mastered", "culture_lesson", "writing_complete", "listening_complete"]) - need_social = fetch_all or any(ct in (condition_types or []) for ct in ["social_interaction", "chat_complete", "help_others"]) - need_challenges = fetch_all or "daily_challenge_complete" in condition_types - need_games = fetch_all or "game_complete" in condition_types - - # Fetch lesson completion count - if need_lessons: - result = await self.db.execute( - select(func.count(LessonCompletion.id)).where( - and_( - LessonCompletion.user_id == user_id, - LessonCompletion.is_passed == True - ) - ) - ) - stats["lessons_completed"] = result.scalar() or 0 - - # Count completed courses (progress_percentage >= 100) - result = await self.db.execute( - select(func.count(UserCourseProgress.id)).where( - and_( - UserCourseProgress.user_id == user_id, - UserCourseProgress.progress_percentage >= 100 - ) - ) - ) - stats["courses_completed"] = result.scalar() or 0 - - # Fetch streak from Streak model - if need_streak: - result = await self.db.execute( - select(Streak.current_streak, Streak.longest_streak).where(Streak.user_id == user_id) - ) - row = result.first() - if row: - stats["current_streak"] = row[0] or 0 - stats["longest_streak"] = row[1] or 0 - else: - stats["current_streak"] = 0 - stats["longest_streak"] = 0 - - # Fetch vocabulary mastered - if need_vocab: - # Count words with status MASTERED - result = await self.db.execute( - select(func.count(UserVocabulary.id)).where( - and_( - UserVocabulary.user_id == user_id, - UserVocabulary.status == VocabularyStatus.MASTERED - ) - ) - ) - stats["vocab_mastered"] = result.scalar() or 0 - - # Count total reviewed - result = await self.db.execute( - select(func.count(UserVocabulary.id)).where( - and_( - UserVocabulary.user_id == user_id, - UserVocabulary.total_reviews > 0 - ) - ) - ) - stats["vocab_reviewed"] = result.scalar() or 0 - - # Fetch XP (sum from all user course progress) - if need_xp: - result = await self.db.execute( - select(User.total_xp).where(User.id == user_id) - ) - stats["total_xp"] = result.scalar() or 0 +from importlib import import_module +from typing import Any - if need_games: - result = await self.db.execute( - select(func.count(GameSession.id)).where( - and_( - GameSession.user_id == user_id, - GameSession.completed_at.is_not(None), - GameSession.xp_awarded.is_(True), - ) - ) - ) - stats["games_completed"] = result.scalar() or 0 - - # Fetch perfect scores and quiz completions - if need_quiz: - # Count perfect score lessons (best_score == 100) - result = await self.db.execute( - select(func.count(LessonCompletion.id)).where( - and_( - LessonCompletion.user_id == user_id, - LessonCompletion.best_score == 100 - ) - ) - ) - stats["perfect_scores"] = result.scalar() or 0 - - # Total quiz/lesson completions - result = await self.db.execute( - select(func.count(LessonCompletion.id)).where( - LessonCompletion.user_id == user_id - ) - ) - stats["quiz_completed"] = result.scalar() or 0 - - # Fetch voice practice count - # Note: Voice practices not tracked separately yet, return 0 for now - if need_voice: - stats["voice_practices"] = 0 - - # Fetch numeric level from User model - if need_level: - result = await self.db.execute( - select(User.numeric_level).where(User.id == user_id) - ) - stats["numeric_level"] = result.scalar() or 0 - - # Fetch time-based study stats from DailyActivity - if need_time: - # Count night study sessions (activities with night hours) - # We approximate by counting daily activities — actual hour tracking - # would need additional logging. For now count total daily activities. - result = await self.db.execute( - select(func.count(DailyActivity.id)).where( - DailyActivity.user_id == user_id - ) - ) - total_activities = result.scalar() or 0 - stats["night_study_sessions"] = 0 # Placeholder: needs hour tracking - stats["morning_study_sessions"] = 0 # Placeholder: needs hour tracking - stats["speed_lessons"] = 0 # Placeholder: needs lesson duration tracking - - # Fetch skill-based stats (grammar, culture, writing, listening) - if need_skills: - # These are placeholders — actual implementation needs - # tagged lesson categories in the database - stats["grammar_mastered"] = 0 - stats["culture_lessons"] = 0 - stats["writing_completed"] = 0 - stats["listening_completed"] = 0 - - # Fetch social stats - if need_social: - stats["social_interactions"] = 0 # Placeholder - stats["chats_completed"] = 0 # Placeholder - stats["help_others_count"] = 0 # Placeholder - - # Fetch daily challenge completions - if need_challenges: - result = await self.db.execute( - select(func.count(ChallengeRewardClaim.id)).where( - and_( - ChallengeRewardClaim.user_id == user_id, - ChallengeRewardClaim.challenge_id != "daily_bonus" - ) - ) - ) - stats["daily_challenges_completed"] = result.scalar() or 0 - - return stats - - async def _evaluate_condition( - self, - user_id: UUID, - achievement: Achievement, - user_stats: Dict[str, Any] - ) -> bool: - """ - Evaluate if user meets the achievement condition. - - Returns True if condition is met, False otherwise. - """ - condition_type = achievement.condition_type - condition_value = achievement.condition_value or 0 - condition_data = achievement.condition_data or {} - - # Map condition types to stat checks - condition_evaluators = { - "lesson_complete": lambda: user_stats.get("lessons_completed", 0) >= condition_value, - "reach_streak": lambda: max( - user_stats.get("current_streak", 0), - user_stats.get("longest_streak", 0) - ) >= condition_value, - "vocab_mastered": lambda: user_stats.get("vocab_mastered", 0) >= condition_value, - "vocab_reviewed": lambda: user_stats.get("vocab_reviewed", 0) >= condition_value, - "xp_earned": lambda: user_stats.get("total_xp", 0) >= condition_value, - "perfect_score": lambda: user_stats.get("perfect_scores", 0) >= condition_value, - "quiz_complete": lambda: user_stats.get("quiz_completed", 0) >= condition_value, - "voice_practice": lambda: user_stats.get("voice_practices", 0) >= condition_value, - "course_complete": lambda: user_stats.get("courses_completed", 0) >= condition_value, - # ---- New condition evaluators ---- - "numeric_level": lambda: user_stats.get("numeric_level", 0) >= condition_value, - "study_time_night": lambda: user_stats.get("night_study_sessions", 0) >= condition_value, - "study_time_morning": lambda: user_stats.get("morning_study_sessions", 0) >= condition_value, - "speed_lesson": lambda: user_stats.get("speed_lessons", 0) >= condition_value, - "first_perfect": lambda: user_stats.get("perfect_scores", 0) >= 1 and condition_value <= 1, - "grammar_mastered": lambda: user_stats.get("grammar_mastered", 0) >= condition_value, - "culture_lesson": lambda: user_stats.get("culture_lessons", 0) >= condition_value, - "writing_complete": lambda: user_stats.get("writing_completed", 0) >= condition_value, - "listening_complete": lambda: user_stats.get("listening_completed", 0) >= condition_value, - "social_interaction": lambda: user_stats.get("social_interactions", 0) >= condition_value, - "chat_complete": lambda: user_stats.get("chats_completed", 0) >= condition_value, - "help_others": lambda: user_stats.get("help_others_count", 0) >= condition_value, - "daily_challenge_complete": lambda: user_stats.get("daily_challenges_completed", 0) >= condition_value, - "game_complete": lambda: user_stats.get("games_completed", 0) >= condition_value, - "comeback": lambda: False, # Special: checked contextually at login - } - - evaluator = condition_evaluators.get(condition_type) - if evaluator: - return evaluator() - - # Unknown condition type - log warning and return False - logger.warning("Unknown achievement condition type: %s", condition_type) - return False - - async def _unlock_achievement( - self, - user_id: UUID, - achievement: Achievement - ) -> Optional[UserAchievement]: - """ - Unlock achievement for user (idempotent). - Also awards gems if configured. - - Uses flush + IntegrityError to safely handle race conditions - where two concurrent requests try to unlock the same achievement. - - Returns UserAchievement if newly unlocked, None if already had. - """ - user_achievement: Optional[UserAchievement] = None - try: - # Isolate insert conflicts without rolling back outer request work. - async with self.db.begin_nested(): - user_achievement = UserAchievement( - user_id=user_id, - achievement_id=achievement.id, - unlocked_at=datetime.now(timezone.utc) - ) - self.db.add(user_achievement) - await self.db.flush() - except IntegrityError: - # Another request already unlocked this — safe to ignore. - return None - - # Award gems (if configured) - if achievement.gems_reward > 0: - from app.crud.gamification import WalletCRUD - await WalletCRUD.add_gems( - self.db, - user_id, - achievement.gems_reward, - source="achievement", - description=f"Unlocked: {achievement.name}", - commit=False, - ) - - return user_achievement +__all__ = [ + "AchievementCheckerService", + "TRIGGER_CONDITIONS", + "check_achievements_for_user", +] -# ============================================================================ -# Helper function for easy use in routes -# ============================================================================ +def __getattr__(name: str) -> Any: + if name not in __all__: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") -async def check_achievements_for_user( - db: AsyncSession, - user_id: UUID, - trigger: str -) -> List[Dict[str, Any]]: - """ - Convenience function to check achievements and return serializable results. - - Args: - db: Database session - user_id: User UUID - trigger: Trigger type (lesson_complete, streak_update, etc.) - - Returns: - List of dicts with unlocked achievement info - """ - checker = AchievementCheckerService(db) - unlocked = await checker.check_by_trigger(user_id, trigger) - - return [ - { - "id": str(a.id), - "name": a.name, - "description": a.description, - "badge_icon": a.badge_icon, - "badge_color": a.badge_color, - "category": a.category, - "rarity": a.rarity, - "xp_reward": a.xp_reward, - "gems_reward": a.gems_reward, - } - for a in unlocked - ] + module = import_module("app.services.achievement_checker_service") + return getattr(module, name) diff --git a/backend-service/app/services/achievement_checker_service.py b/backend-service/app/services/achievement_checker_service.py new file mode 100644 index 00000000..103a801c --- /dev/null +++ b/backend-service/app/services/achievement_checker_service.py @@ -0,0 +1,456 @@ +""" +Achievement Checker Service +Stateless service to evaluate and unlock achievements based on user actions. + +This service checks achievement conditions and automatically unlocks badges +when conditions are met. It's designed to be called after specific user actions. +""" + +from typing import List, Optional, Dict, Any +from uuid import UUID +from datetime import datetime, timezone +import logging +from sqlalchemy import select, func, and_ +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.gamification import Achievement, UserAchievement, ChallengeRewardClaim +from app.models.progress import UserCourseProgress, LessonCompletion, Streak, DailyActivity +from app.models.vocabulary import UserVocabulary, VocabularyStatus +from app.models.user import User +from app.models.games import GameSession + +logger = logging.getLogger(__name__) + + +# Mapping of triggers to achievement condition types +TRIGGER_CONDITIONS = { + "lesson_complete": [ + "lesson_complete", "xp_earned", "course_complete", + "numeric_level", "speed_lesson", + ], + "streak_update": ["reach_streak", "comeback"], + "vocab_review": ["vocab_mastered", "vocab_reviewed"], + "quiz_complete": ["perfect_score", "quiz_complete", "first_perfect"], + "voice_practice": ["voice_practice"], + "xp_earned": ["xp_earned", "numeric_level"], + "study_session": ["study_time_night", "study_time_morning"], + "grammar_complete": ["grammar_mastered"], + "culture_complete": ["culture_lesson"], + "writing_complete": ["writing_complete"], + "listening_complete": ["listening_complete"], + "social_action": ["social_interaction", "help_others"], + "chat_complete": ["chat_complete"], + "daily_challenge": ["daily_challenge_complete"], + "game_complete": [ + "game_complete", + "xp_earned", + "numeric_level", + "reach_streak", + ], +} + + +class AchievementCheckerService: + """ + Service to check and unlock achievements. + + Usage: + checker = AchievementCheckerService(db) + newly_unlocked = await checker.check_by_trigger(user_id, "lesson_complete") + """ + + def __init__(self, db: AsyncSession): + self.db = db + + async def check_all(self, user_id: UUID) -> List[Achievement]: + """ + Check all achievements for a user. + Returns list of newly unlocked achievements. + + Use sparingly as this checks ALL conditions. + Prefer check_by_trigger() for better performance. + """ + # Get all achievements not yet unlocked by user + unlocked_ids = await self._get_unlocked_achievement_ids(user_id) + + result = await self.db.execute( + select(Achievement).where(~Achievement.id.in_(unlocked_ids)) + ) + pending_achievements = list(result.scalars().all()) + + # Get user stats once for all evaluations + user_stats = await self._get_user_stats(user_id) + + newly_unlocked = [] + for achievement in pending_achievements: + if await self._evaluate_condition(user_id, achievement, user_stats): + unlocked = await self._unlock_achievement(user_id, achievement) + if unlocked: + newly_unlocked.append(achievement) + + return newly_unlocked + + async def check_by_trigger(self, user_id: UUID, trigger: str) -> List[Achievement]: + """ + Check only achievements related to a specific trigger. + More efficient than check_all(). + + Args: + user_id: User UUID + trigger: One of "lesson_complete", "streak_update", "vocab_review", + "quiz_complete", "voice_practice", "xp_earned" + + Returns: + List of newly unlocked achievements + """ + condition_types = TRIGGER_CONDITIONS.get(trigger, []) + if not condition_types: + return [] + + # Get pending achievements of relevant types + unlocked_ids = await self._get_unlocked_achievement_ids(user_id) + + result = await self.db.execute( + select(Achievement).where( + and_( + ~Achievement.id.in_(unlocked_ids) if unlocked_ids else True, + Achievement.condition_type.in_(condition_types) + ) + ) + ) + pending_achievements = list(result.scalars().all()) + + if not pending_achievements: + return [] + + # Get only needed stats + user_stats = await self._get_user_stats(user_id, condition_types) + + newly_unlocked = [] + for achievement in pending_achievements: + if await self._evaluate_condition(user_id, achievement, user_stats): + unlocked = await self._unlock_achievement(user_id, achievement) + if unlocked: + newly_unlocked.append(achievement) + + return newly_unlocked + + async def _get_unlocked_achievement_ids(self, user_id: UUID) -> List[UUID]: + """Get IDs of achievements already unlocked by user""" + result = await self.db.execute( + select(UserAchievement.achievement_id).where( + UserAchievement.user_id == user_id + ) + ) + return [row[0] for row in result.all()] + + async def _get_user_stats( + self, + user_id: UUID, + condition_types: Optional[List[str]] = None + ) -> Dict[str, Any]: + """ + Fetch user statistics needed for achievement evaluation. + Only fetches stats relevant to the condition_types if provided. + """ + stats = {} + + # Determine which stats to fetch + fetch_all = condition_types is None + need_lessons = fetch_all or "lesson_complete" in condition_types or "course_complete" in condition_types + need_streak = fetch_all or "reach_streak" in condition_types or "comeback" in condition_types + need_vocab = fetch_all or "vocab_mastered" in condition_types or "vocab_reviewed" in condition_types + need_xp = fetch_all or "xp_earned" in condition_types or "numeric_level" in condition_types + need_quiz = fetch_all or "perfect_score" in condition_types or "quiz_complete" in condition_types or "first_perfect" in condition_types + need_voice = fetch_all or "voice_practice" in condition_types + need_level = fetch_all or "numeric_level" in condition_types + need_time = fetch_all or "study_time_night" in condition_types or "study_time_morning" in condition_types or "speed_lesson" in condition_types + need_skills = fetch_all or any(ct in (condition_types or []) for ct in ["grammar_mastered", "culture_lesson", "writing_complete", "listening_complete"]) + need_social = fetch_all or any(ct in (condition_types or []) for ct in ["social_interaction", "chat_complete", "help_others"]) + need_challenges = fetch_all or "daily_challenge_complete" in condition_types + need_games = fetch_all or "game_complete" in condition_types + + # Fetch lesson completion count + if need_lessons: + result = await self.db.execute( + select(func.count(LessonCompletion.id)).where( + and_( + LessonCompletion.user_id == user_id, + LessonCompletion.is_passed.is_(True) + ) + ) + ) + stats["lessons_completed"] = result.scalar() or 0 + + # Count completed courses (progress_percentage >= 100) + result = await self.db.execute( + select(func.count(UserCourseProgress.id)).where( + and_( + UserCourseProgress.user_id == user_id, + UserCourseProgress.progress_percentage >= 100 + ) + ) + ) + stats["courses_completed"] = result.scalar() or 0 + + # Fetch streak from Streak model + if need_streak: + result = await self.db.execute( + select(Streak.current_streak, Streak.longest_streak).where(Streak.user_id == user_id) + ) + row = result.first() + if row: + stats["current_streak"] = row[0] or 0 + stats["longest_streak"] = row[1] or 0 + else: + stats["current_streak"] = 0 + stats["longest_streak"] = 0 + + # Fetch vocabulary mastered + if need_vocab: + # Count words with status MASTERED + result = await self.db.execute( + select(func.count(UserVocabulary.id)).where( + and_( + UserVocabulary.user_id == user_id, + UserVocabulary.status == VocabularyStatus.MASTERED + ) + ) + ) + stats["vocab_mastered"] = result.scalar() or 0 + + # Count total reviewed + result = await self.db.execute( + select(func.count(UserVocabulary.id)).where( + and_( + UserVocabulary.user_id == user_id, + UserVocabulary.total_reviews > 0 + ) + ) + ) + stats["vocab_reviewed"] = result.scalar() or 0 + + # Fetch XP (sum from all user course progress) + if need_xp: + result = await self.db.execute( + select(User.total_xp).where(User.id == user_id) + ) + stats["total_xp"] = result.scalar() or 0 + + if need_games: + result = await self.db.execute( + select(func.count(GameSession.id)).where( + and_( + GameSession.user_id == user_id, + GameSession.completed_at.is_not(None), + GameSession.xp_awarded.is_(True), + ) + ) + ) + stats["games_completed"] = result.scalar() or 0 + + # Fetch perfect scores and quiz completions + if need_quiz: + # Count perfect score lessons (best_score == 100) + result = await self.db.execute( + select(func.count(LessonCompletion.id)).where( + and_( + LessonCompletion.user_id == user_id, + LessonCompletion.best_score == 100 + ) + ) + ) + stats["perfect_scores"] = result.scalar() or 0 + + # Total quiz/lesson completions + result = await self.db.execute( + select(func.count(LessonCompletion.id)).where( + LessonCompletion.user_id == user_id + ) + ) + stats["quiz_completed"] = result.scalar() or 0 + + # Fetch voice practice count + # Note: Voice practices not tracked separately yet, return 0 for now + if need_voice: + stats["voice_practices"] = 0 + + # Fetch numeric level from User model + if need_level: + result = await self.db.execute( + select(User.numeric_level).where(User.id == user_id) + ) + stats["numeric_level"] = result.scalar() or 0 + + # Fetch time-based study stats from DailyActivity + if need_time: + stats["night_study_sessions"] = 0 # Placeholder: needs hour tracking + stats["morning_study_sessions"] = 0 # Placeholder: needs hour tracking + stats["speed_lessons"] = 0 # Placeholder: needs lesson duration tracking + + # Fetch skill-based stats (grammar, culture, writing, listening) + if need_skills: + # These are placeholders — actual implementation needs + # tagged lesson categories in the database + stats["grammar_mastered"] = 0 + stats["culture_lessons"] = 0 + stats["writing_completed"] = 0 + stats["listening_completed"] = 0 + + # Fetch social stats + if need_social: + stats["social_interactions"] = 0 # Placeholder + stats["chats_completed"] = 0 # Placeholder + stats["help_others_count"] = 0 # Placeholder + + # Fetch daily challenge completions + if need_challenges: + result = await self.db.execute( + select(func.count(ChallengeRewardClaim.id)).where( + and_( + ChallengeRewardClaim.user_id == user_id, + ChallengeRewardClaim.challenge_id != "daily_bonus" + ) + ) + ) + stats["daily_challenges_completed"] = result.scalar() or 0 + + return stats + + async def _evaluate_condition( + self, + user_id: UUID, + achievement: Achievement, + user_stats: Dict[str, Any] + ) -> bool: + """ + Evaluate if user meets the achievement condition. + + Returns True if condition is met, False otherwise. + """ + condition_type = achievement.condition_type + condition_value = achievement.condition_value or 0 + condition_data = achievement.condition_data or {} + + # Map condition types to stat checks + condition_evaluators = { + "lesson_complete": lambda: user_stats.get("lessons_completed", 0) >= condition_value, + "reach_streak": lambda: max( + user_stats.get("current_streak", 0), + user_stats.get("longest_streak", 0) + ) >= condition_value, + "vocab_mastered": lambda: user_stats.get("vocab_mastered", 0) >= condition_value, + "vocab_reviewed": lambda: user_stats.get("vocab_reviewed", 0) >= condition_value, + "xp_earned": lambda: user_stats.get("total_xp", 0) >= condition_value, + "perfect_score": lambda: user_stats.get("perfect_scores", 0) >= condition_value, + "quiz_complete": lambda: user_stats.get("quiz_completed", 0) >= condition_value, + "voice_practice": lambda: user_stats.get("voice_practices", 0) >= condition_value, + "course_complete": lambda: user_stats.get("courses_completed", 0) >= condition_value, + # ---- New condition evaluators ---- + "numeric_level": lambda: user_stats.get("numeric_level", 0) >= condition_value, + "study_time_night": lambda: user_stats.get("night_study_sessions", 0) >= condition_value, + "study_time_morning": lambda: user_stats.get("morning_study_sessions", 0) >= condition_value, + "speed_lesson": lambda: user_stats.get("speed_lessons", 0) >= condition_value, + "first_perfect": lambda: user_stats.get("perfect_scores", 0) >= 1 and condition_value <= 1, + "grammar_mastered": lambda: user_stats.get("grammar_mastered", 0) >= condition_value, + "culture_lesson": lambda: user_stats.get("culture_lessons", 0) >= condition_value, + "writing_complete": lambda: user_stats.get("writing_completed", 0) >= condition_value, + "listening_complete": lambda: user_stats.get("listening_completed", 0) >= condition_value, + "social_interaction": lambda: user_stats.get("social_interactions", 0) >= condition_value, + "chat_complete": lambda: user_stats.get("chats_completed", 0) >= condition_value, + "help_others": lambda: user_stats.get("help_others_count", 0) >= condition_value, + "daily_challenge_complete": lambda: user_stats.get("daily_challenges_completed", 0) >= condition_value, + "game_complete": lambda: user_stats.get("games_completed", 0) >= condition_value, + "comeback": lambda: False, # Special: checked contextually at login + } + + evaluator = condition_evaluators.get(condition_type) + if evaluator: + return evaluator() + + # Unknown condition type - log warning and return False + logger.warning("Unknown achievement condition type: %s", condition_type) + return False + + async def _unlock_achievement( + self, + user_id: UUID, + achievement: Achievement + ) -> Optional[UserAchievement]: + """ + Unlock achievement for user (idempotent). + Also awards gems if configured. + + Uses flush + IntegrityError to safely handle race conditions + where two concurrent requests try to unlock the same achievement. + + Returns UserAchievement if newly unlocked, None if already had. + """ + user_achievement: Optional[UserAchievement] = None + try: + # Isolate insert conflicts without rolling back outer request work. + async with self.db.begin_nested(): + user_achievement = UserAchievement( + user_id=user_id, + achievement_id=achievement.id, + unlocked_at=datetime.now(timezone.utc) + ) + self.db.add(user_achievement) + await self.db.flush() + except IntegrityError: + # Another request already unlocked this — safe to ignore. + return None + + # Award gems (if configured) + if achievement.gems_reward > 0: + from app.crud.gamification import WalletCRUD + await WalletCRUD.add_gems( + self.db, + user_id, + achievement.gems_reward, + source="achievement", + description=f"Unlocked: {achievement.name}", + commit=False, + ) + + return user_achievement + + +# ============================================================================ +# Helper function for easy use in routes +# ============================================================================ + +async def check_achievements_for_user( + db: AsyncSession, + user_id: UUID, + trigger: str +) -> List[Dict[str, Any]]: + """ + Convenience function to check achievements and return serializable results. + + Args: + db: Database session + user_id: User UUID + trigger: Trigger type (lesson_complete, streak_update, etc.) + + Returns: + List of dicts with unlocked achievement info + """ + checker = AchievementCheckerService(db) + unlocked = await checker.check_by_trigger(user_id, trigger) + + return [ + { + "id": str(a.id), + "name": a.name, + "description": a.description, + "badge_icon": a.badge_icon, + "badge_color": a.badge_color, + "category": a.category, + "rarity": a.rarity, + "xp_reward": a.xp_reward, + "gems_reward": a.gems_reward, + } + for a in unlocked + ] diff --git a/backend-service/app/services/api_cache_service.py b/backend-service/app/services/api_cache_service.py index a4024a0c..c26d7dae 100644 --- a/backend-service/app/services/api_cache_service.py +++ b/backend-service/app/services/api_cache_service.py @@ -84,6 +84,7 @@ async def get_or_fetch( api_name: str, fetch_fn: Callable, priority: Priority = Priority.MEDIUM, + cost: int = 1, redis_ttl: int = 3600, db_ttl: int = 86400, ) -> CacheResult: @@ -95,6 +96,7 @@ async def get_or_fetch( api_name: API identifier for quota tracking (e.g. "newsapi") fetch_fn: Async callable that fetches from external API priority: Request priority level (affects quota threshold behavior) + cost: Quota units consumed by a cache miss; 0 bypasses quota checks redis_ttl: Redis cache time-to-live in seconds db_ttl: PostgreSQL "freshness" TTL in seconds @@ -136,48 +138,50 @@ async def get_or_fetch( is_stale=False, ) - # ──── Layer 3: Check quota threshold BEFORE calling external API ──── - quota_status = await QuotaManager.check_status(api_name) - - # BLOCKED (100%+): NEVER call API - if quota_status == QuotaStatus.BLOCKED: - logger.warning(f"Quota BLOCKED for {api_name}, serving stale cache") - if db_entry is not None: - return CacheResult( - data=json.loads(db_entry.data), - source="db_stale", - is_stale=True, - ) - raise QuotaExhaustedError(api_name, QuotaManager.get_reset_time()) - - # CRITICAL (90-99%): only HIGH priority gets through - if quota_status == QuotaStatus.CRITICAL and priority != Priority.HIGH: - logger.info( - f"Quota CRITICAL for {api_name}, blocking {priority.value} priority" - ) - if db_entry is not None: - return CacheResult( - data=json.loads(db_entry.data), - source="db_stale", - is_stale=True, + if cost > 0: + # ──── Layer 3: Check quota threshold BEFORE calling external API ──── + quota_status = await QuotaManager.check_status(api_name, cost=cost) + + # BLOCKED (100%+): NEVER call API + if quota_status == QuotaStatus.BLOCKED: + logger.warning("Quota BLOCKED for %s, serving stale cache", api_name) + if db_entry is not None: + return CacheResult( + data=json.loads(db_entry.data), + source="db_stale", + is_stale=True, + ) + raise QuotaExhaustedError(api_name, QuotaManager.get_reset_time()) + + # CRITICAL (90-99%): only HIGH priority gets through + if quota_status == QuotaStatus.CRITICAL and priority != Priority.HIGH: + logger.info( + "Quota CRITICAL for %s, blocking %s priority", + api_name, + priority.value, ) - raise QuotaNearLimitError( - api_name, "CRITICAL", - "Only user-initiated requests allowed at this quota level." - ) - - # WARNING (70-89%): block LOW priority (background/prefetch) - if quota_status == QuotaStatus.WARNING and priority == Priority.LOW: - logger.info( - f"Quota WARNING for {api_name}, blocking LOW priority" - ) - if db_entry is not None: - return CacheResult( - data=json.loads(db_entry.data), - source="db_stale", - is_stale=True, + if db_entry is not None: + return CacheResult( + data=json.loads(db_entry.data), + source="db_stale", + is_stale=True, + ) + raise QuotaNearLimitError( + api_name, + "CRITICAL", + "Only user-initiated requests allowed at this quota level.", ) - # No cache at all → allow even LOW (user should see something) + + # WARNING (70-89%): block LOW priority (background/prefetch) + if quota_status == QuotaStatus.WARNING and priority == Priority.LOW: + logger.info("Quota WARNING for %s, blocking LOW priority", api_name) + if db_entry is not None: + return CacheResult( + data=json.loads(db_entry.data), + source="db_stale", + is_stale=True, + ) + # No cache at all → allow even LOW (user should see something) # ──── Layer 3: Fetch from external API ──── try: @@ -195,8 +199,9 @@ async def get_or_fetch( ) raise - # Record quota usage - await QuotaManager.record_request(api_name) + # Record quota usage only for calls that consume upstream quota. + if cost > 0: + await QuotaManager.record_request(api_name, cost=cost) # ──── Store in both cache layers ──── data_json = json.dumps(result, ensure_ascii=False, default=str) diff --git a/backend-service/app/services/content_agent_artifact_index.py b/backend-service/app/services/content_agent_artifact_index.py new file mode 100644 index 00000000..d5580a7a --- /dev/null +++ b/backend-service/app/services/content_agent_artifact_index.py @@ -0,0 +1,250 @@ +"""Typed traversal index for raw content-agent artifacts. + +The validation service receives raw dictionaries from jobs, uploads, and AI +responses. This module centralizes the defensive walk over the nested +course/unit/lesson tree so validation rules can work with dict-only nodes. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass(frozen=True) +class StructuralIssue: + code: str + path: str + message: str + + +@dataclass(frozen=True) +class ObjectNode: + path: str + data: dict[str, Any] + + +@dataclass(frozen=True) +class LessonNode: + path: str + data: dict[str, Any] + vocabulary: tuple[ObjectNode, ...] + exercises: tuple[ObjectNode, ...] + + +@dataclass(frozen=True) +class UnitNode: + path: str + data: dict[str, Any] + lessons: tuple[LessonNode, ...] + + +@dataclass(frozen=True) +class CourseNode: + path: str + data: dict[str, Any] + units: tuple[UnitNode, ...] + + +@dataclass(frozen=True) +class ArtifactIndex: + courses: tuple[CourseNode, ...] + units: tuple[UnitNode, ...] + lessons: tuple[LessonNode, ...] + vocabulary: tuple[ObjectNode, ...] + exercises: tuple[ObjectNode, ...] + structural_errors: tuple[StructuralIssue, ...] = field(default_factory=tuple) + + +def _add_issue( + issues: list[StructuralIssue], code: str, path: str, message: str +) -> None: + issues.append(StructuralIssue(code=code, path=path, message=message)) + + +def _list_field( + owner: dict[str, Any], + field_name: str, + path: str, + issues: list[StructuralIssue], + type_code: str, +) -> list[Any]: + value = owner.get(field_name) + if value is None: + return [] + if not isinstance(value, list): + _add_issue(issues, type_code, path, f"{field_name} must be a list") + return [] + return value + + +def _object_node( + value: Any, + path: str, + issues: list[StructuralIssue], + code: str, + label: str, +) -> dict[str, Any] | None: + if isinstance(value, dict): + return value + _add_issue(issues, code, path, f"{label} must be an object") + return None + + +def build_artifact_index(artifact: dict[str, Any]) -> ArtifactIndex: + issues: list[StructuralIssue] = [] + courses_raw = artifact.get("courses") + if courses_raw is None: + courses_raw = [] + elif not isinstance(courses_raw, list): + _add_issue(issues, "COURSES_TYPE", "courses", "courses must be a list") + courses_raw = [] + + courses: list[CourseNode] = [] + all_units: list[UnitNode] = [] + all_lessons: list[LessonNode] = [] + all_vocabulary: list[ObjectNode] = [] + all_exercises: list[ObjectNode] = [] + + for ci, course_value in enumerate(courses_raw): + course_path = f"courses[{ci}]" + course = _object_node( + course_value, + course_path, + issues, + "COURSE_ENTRY_TYPE", + "course entry", + ) + if course is None: + continue + + units_raw = _list_field( + course, + "units", + f"{course_path}.units", + issues, + "UNITS_TYPE", + ) + if not units_raw: + _add_issue( + issues, + "NO_UNITS", + f"{course_path}.units", + "course must contain at least one unit", + ) + + unit_nodes: list[UnitNode] = [] + for ui, unit_value in enumerate(units_raw): + unit_path = f"{course_path}.units[{ui}]" + unit = _object_node( + unit_value, + unit_path, + issues, + "UNIT_ENTRY_TYPE", + "unit entry", + ) + if unit is None: + continue + + lessons_raw = _list_field( + unit, + "lessons", + f"{unit_path}.lessons", + issues, + "LESSONS_TYPE", + ) + if not lessons_raw: + _add_issue( + issues, + "NO_LESSONS", + f"{unit_path}.lessons", + "unit must contain at least one lesson", + ) + + lesson_nodes: list[LessonNode] = [] + for li, lesson_value in enumerate(lessons_raw): + lesson_path = f"{unit_path}.lessons[{li}]" + lesson = _object_node( + lesson_value, + lesson_path, + issues, + "LESSON_ENTRY_TYPE", + "lesson entry", + ) + if lesson is None: + continue + + vocabulary_raw = _list_field( + lesson, + "vocabulary", + f"{lesson_path}.vocabulary", + issues, + "VOCABULARY_TYPE", + ) + vocabulary_nodes: list[ObjectNode] = [] + for vi, vocab_value in enumerate(vocabulary_raw): + vocab_path = f"{lesson_path}.vocabulary[{vi}]" + vocab = _object_node( + vocab_value, + vocab_path, + issues, + "VOCABULARY_ENTRY_TYPE", + "vocabulary entry", + ) + if vocab is not None: + vocabulary_nodes.append(ObjectNode(vocab_path, vocab)) + + exercises_raw = _list_field( + lesson, + "exercises", + f"{lesson_path}.exercises", + issues, + "EXERCISES_TYPE", + ) + exercise_nodes: list[ObjectNode] = [] + for ei, exercise_value in enumerate(exercises_raw): + exercise_path = f"{lesson_path}.exercises[{ei}]" + exercise = _object_node( + exercise_value, + exercise_path, + issues, + "EXERCISE_ENTRY_TYPE", + "exercise entry", + ) + if exercise is not None: + exercise_nodes.append(ObjectNode(exercise_path, exercise)) + + lesson_node = LessonNode( + path=lesson_path, + data=lesson, + vocabulary=tuple(vocabulary_nodes), + exercises=tuple(exercise_nodes), + ) + lesson_nodes.append(lesson_node) + all_vocabulary.extend(vocabulary_nodes) + all_exercises.extend(exercise_nodes) + + unit_node = UnitNode( + path=unit_path, + data=unit, + lessons=tuple(lesson_nodes), + ) + unit_nodes.append(unit_node) + all_lessons.extend(lesson_nodes) + + course_node = CourseNode( + path=course_path, + data=course, + units=tuple(unit_nodes), + ) + courses.append(course_node) + all_units.extend(unit_nodes) + + return ArtifactIndex( + courses=tuple(courses), + units=tuple(all_units), + lessons=tuple(all_lessons), + vocabulary=tuple(all_vocabulary), + exercises=tuple(all_exercises), + structural_errors=tuple(issues), + ) diff --git a/backend-service/app/services/content_agent_validation.py b/backend-service/app/services/content_agent_validation.py index 7d5c3fa1..582ae69e 100644 --- a/backend-service/app/services/content_agent_validation.py +++ b/backend-service/app/services/content_agent_validation.py @@ -14,6 +14,11 @@ from pathlib import Path from typing import Any +from app.services.content_agent_artifact_index import ( + ArtifactIndex, + build_artifact_index, +) + # --------------------------------------------------------------------------- # Valid enum sets (mirrors the Pydantic schema without importing it to keep # this module importable before the ORM is initialised) @@ -328,8 +333,8 @@ def _check_manifest_coverage( def _check_courses_present(artifact: dict[str, Any], report: ValidationReport) -> None: - courses = artifact.get("courses") or [] - if not courses: + courses = artifact.get("courses") + if courses is None or courses == []: _blocking( report, "NO_COURSES", @@ -340,6 +345,7 @@ def _check_courses_present(artifact: dict[str, Any], report: ValidationReport) - def _check_provenance( artifact: dict[str, Any], + index: ArtifactIndex, report: ValidationReport, pinned_snapshots: list[dict[str, Any]], ) -> None: @@ -353,470 +359,404 @@ def _check_provenance( str(pin.get("source_name") or pin.get("source_id")): pin for pin in pinned_snapshots } - courses = artifact.get("courses") or [] - for ci, course in enumerate(courses): - for ui, unit in enumerate(course.get("units") or []): - for li, lesson in enumerate(unit.get("lessons") or []): - for vi, vocab in enumerate(lesson.get("vocabulary") or []): - base = ( - f"courses[{ci}].units[{ui}].lessons[{li}]" - f".vocabulary[{vi}]" + for vocab_node in index.vocabulary: + vocab = vocab_node.data + base = vocab_node.path + mode = vocab.get("license_mode", "") + if mode not in _LICENSE_MODES: + _blocking( + report, + "INVALID_LICENSE_MODE", + f"{base}.license_mode", + f"license_mode '{mode}' is not storable", + ) + source_name = str(vocab.get("source_name") or "") + if source_name == "generated": + if mode != "generated": + _blocking( + report, + "GENERATED_LICENSE_MODE_INVALID", + f"{base}.license_mode", + "generated vocabulary must use license_mode 'generated'", + ) + for field_name in ( + "source_url", + "source_checksum", + "source_version", + "source_record_id", + "license_id", + "license_url", + "attribution_text", + "raw_checksum", + "record_checksum", + "lineage", + "content_usage", + ): + if vocab.get(field_name) not in (None, "", {}): + _blocking( + report, + "GENERATED_PROVENANCE_PRESENT", + f"{base}.{field_name}", + "generated vocabulary must not carry imported " + "source provenance", ) - mode = vocab.get("license_mode", "") - if mode not in _LICENSE_MODES: - _blocking( - report, - "INVALID_LICENSE_MODE", - f"{base}.license_mode", - f"license_mode '{mode}' is not storable", - ) - source_name = str(vocab.get("source_name") or "") - if source_name == "generated": - if mode != "generated": - _blocking( - report, - "GENERATED_LICENSE_MODE_INVALID", - f"{base}.license_mode", - "generated vocabulary must use license_mode 'generated'", - ) - for field_name in ( - "source_url", - "source_checksum", - "source_version", - "source_record_id", - "license_id", - "license_url", - "attribution_text", - "raw_checksum", - "record_checksum", - "lineage", - "content_usage", - ): - if vocab.get(field_name) not in (None, "", {}): - _blocking( - report, - "GENERATED_PROVENANCE_PRESENT", - f"{base}.{field_name}", - "generated vocabulary must not carry imported " - "source provenance", - ) - continue - manifest = manifest_by_source.get(source_name) - if manifest is None: - _blocking( - report, - "VOCAB_SOURCE_NOT_IN_MANIFEST", - f"{base}.source_name", - f"source '{source_name}' is absent from source_manifest", - ) - continue - pin = pinned_by_source.get(source_name) - if source_name == "admin_upload": - required_fields = ( - "source_record_id", - "record_checksum", - "lineage", - "content_usage", - "source_version", - "license_id", - "license_url", - "attribution_text", - "raw_checksum", - ) - for field_name in required_fields: - if vocab.get(field_name) in (None, "", {}): - _blocking( - report, - "VOCAB_PROVENANCE_MISSING", - f"{base}.{field_name}", - f"{field_name} is required for admin upload content", - ) - if mode != "admin_owned": - _blocking( - report, - "ADMIN_UPLOAD_LICENSE_MODE_INVALID", - f"{base}.license_mode", - "admin_upload vocabulary must use admin_owned mode", - ) - for field_name in ( - "source_version", - "license_id", - "license_url", - "attribution_text", - "raw_checksum", - ): - if str(vocab.get(field_name)) != str( - manifest.get(field_name) - ): - _blocking( - report, - "VOCAB_PROVENANCE_MISMATCH", - f"{base}.{field_name}", - f"{field_name} does not match the admin upload manifest", - ) - if not _SHA256_PATTERN.fullmatch( - str(vocab.get("record_checksum") or "") - ): - _blocking( - report, - "VOCAB_RECORD_CHECKSUM_INVALID", - f"{base}.record_checksum", - "record_checksum must be a lowercase SHA-256 digest", - ) - lineage = vocab.get("lineage") - if not isinstance(lineage, dict) or not { - "adapter", - "adapter_version", - "raw_path", - }.issubset(lineage): - _blocking( - report, - "VOCAB_LINEAGE_INVALID", - f"{base}.lineage", - "lineage must identify adapter, adapter_version, and raw_path", - ) - continue - if pin is None: - _blocking( - report, - "VOCAB_SOURCE_NOT_PINNED", - f"{base}.source_name", - f"dataset source '{source_name}' was not pinned to the job", - ) - continue - required_fields = ( - "source_url", - "source_record_id", - "record_checksum", - "lineage", - "content_usage", - *_PINNED_PROVENANCE_FIELDS, + continue + manifest = manifest_by_source.get(source_name) + if manifest is None: + _blocking( + report, + "VOCAB_SOURCE_NOT_IN_MANIFEST", + f"{base}.source_name", + f"source '{source_name}' is absent from source_manifest", + ) + continue + pin = pinned_by_source.get(source_name) + if source_name == "admin_upload": + required_fields = ( + "source_record_id", + "record_checksum", + "lineage", + "content_usage", + "source_version", + "license_id", + "license_url", + "attribution_text", + "raw_checksum", + ) + for field_name in required_fields: + if vocab.get(field_name) in (None, "", {}): + _blocking( + report, + "VOCAB_PROVENANCE_MISSING", + f"{base}.{field_name}", + f"{field_name} is required for admin upload content", + ) + if mode != "admin_owned": + _blocking( + report, + "ADMIN_UPLOAD_LICENSE_MODE_INVALID", + f"{base}.license_mode", + "admin_upload vocabulary must use admin_owned mode", + ) + for field_name in ( + "source_version", + "license_id", + "license_url", + "attribution_text", + "raw_checksum", + ): + if str(vocab.get(field_name)) != str(manifest.get(field_name)): + _blocking( + report, + "VOCAB_PROVENANCE_MISMATCH", + f"{base}.{field_name}", + f"{field_name} does not match the admin upload manifest", ) - for field_name in required_fields: - if vocab.get(field_name) in (None, "", {}): - _blocking( - report, - "VOCAB_PROVENANCE_MISSING", - f"{base}.{field_name}", - f"{field_name} is required for pinned dataset content", - ) - if not _is_valid_url(str(vocab.get("source_url") or "")): - _blocking( - report, - "INVALID_SOURCE_URL", - f"{base}.source_url", - "source_url must be a valid http/https URL", - ) - if not _SHA256_PATTERN.fullmatch( - str(vocab.get("record_checksum") or "") - ): - _blocking( - report, - "VOCAB_RECORD_CHECKSUM_INVALID", - f"{base}.record_checksum", - "record_checksum must be a lowercase SHA-256 digest", - ) - lineage = vocab.get("lineage") - if not isinstance(lineage, dict) or not { - "adapter", - "adapter_version", - "raw_path", - }.issubset(lineage): - _blocking( - report, - "VOCAB_LINEAGE_INVALID", - f"{base}.lineage", - "lineage must identify adapter, adapter_version, and raw_path", - ) - for field_name in _PINNED_PROVENANCE_FIELDS: - if str(vocab.get(field_name)) != str(pin.get(field_name)): - _blocking( - report, - "VOCAB_PROVENANCE_MISMATCH", - f"{base}.{field_name}", - f"{field_name} does not match the pinned snapshot", - ) - - -def _check_course_levels(artifact: dict[str, Any], report: ValidationReport) -> None: - courses = artifact.get("courses") or [] - for ci, course in enumerate(courses): - level = course.get("level", "") + if not _SHA256_PATTERN.fullmatch( + str(vocab.get("record_checksum") or "") + ): + _blocking( + report, + "VOCAB_RECORD_CHECKSUM_INVALID", + f"{base}.record_checksum", + "record_checksum must be a lowercase SHA-256 digest", + ) + lineage = vocab.get("lineage") + if not isinstance(lineage, dict) or not { + "adapter", + "adapter_version", + "raw_path", + }.issubset(lineage): + _blocking( + report, + "VOCAB_LINEAGE_INVALID", + f"{base}.lineage", + "lineage must identify adapter, adapter_version, and raw_path", + ) + continue + if pin is None: + _blocking( + report, + "VOCAB_SOURCE_NOT_PINNED", + f"{base}.source_name", + f"dataset source '{source_name}' was not pinned to the job", + ) + continue + required_fields = ( + "source_url", + "source_record_id", + "record_checksum", + "lineage", + "content_usage", + *_PINNED_PROVENANCE_FIELDS, + ) + for field_name in required_fields: + if vocab.get(field_name) in (None, "", {}): + _blocking( + report, + "VOCAB_PROVENANCE_MISSING", + f"{base}.{field_name}", + f"{field_name} is required for pinned dataset content", + ) + if not _is_valid_url(str(vocab.get("source_url") or "")): + _blocking( + report, + "INVALID_SOURCE_URL", + f"{base}.source_url", + "source_url must be a valid http/https URL", + ) + if not _SHA256_PATTERN.fullmatch(str(vocab.get("record_checksum") or "")): + _blocking( + report, + "VOCAB_RECORD_CHECKSUM_INVALID", + f"{base}.record_checksum", + "record_checksum must be a lowercase SHA-256 digest", + ) + lineage = vocab.get("lineage") + if not isinstance(lineage, dict) or not { + "adapter", + "adapter_version", + "raw_path", + }.issubset(lineage): + _blocking( + report, + "VOCAB_LINEAGE_INVALID", + f"{base}.lineage", + "lineage must identify adapter, adapter_version, and raw_path", + ) + for field_name in _PINNED_PROVENANCE_FIELDS: + if str(vocab.get(field_name)) != str(pin.get(field_name)): + _blocking( + report, + "VOCAB_PROVENANCE_MISMATCH", + f"{base}.{field_name}", + f"{field_name} does not match the pinned snapshot", + ) + + +def _check_course_levels(index: ArtifactIndex, report: ValidationReport) -> None: + for course_node in index.courses: + level = course_node.data.get("level", "") if level not in _CEFR_LEVELS: _blocking( report, "INVALID_COURSE_LEVEL", - f"courses[{ci}].level", + f"{course_node.path}.level", f"course level '{level}' is not a valid CEFR level", ) def _check_unique_lesson_orders( - artifact: dict[str, Any], report: ValidationReport + index: ArtifactIndex, report: ValidationReport ) -> None: - courses = artifact.get("courses") or [] - for ci, course in enumerate(courses): - for ui, unit in enumerate(course.get("units") or []): - orders = [ - lesson.get("order_index") - for lesson in (unit.get("lessons") or []) - ] - if len(orders) != len(set(orders)): - _blocking( - report, - "DUPLICATE_LESSON_ORDER", - f"courses[{ci}].units[{ui}].lessons[*].order_index", - "lesson order_index values must be unique within a unit", - ) + for unit_node in index.units: + orders = [lesson.data.get("order_index") for lesson in unit_node.lessons] + if len(orders) != len(set(orders)): + _blocking( + report, + "DUPLICATE_LESSON_ORDER", + f"{unit_node.path}.lessons[*].order_index", + "lesson order_index values must be unique within a unit", + ) def _check_definition_length( - artifact: dict[str, Any], report: ValidationReport + index: ArtifactIndex, report: ValidationReport ) -> None: - courses = artifact.get("courses") or [] - for ci, course in enumerate(courses): - for ui, unit in enumerate(course.get("units") or []): - for li, lesson in enumerate(unit.get("lessons") or []): - for vi, vocab in enumerate(lesson.get("vocabulary") or []): - definition = str(vocab.get("definition") or "") - path = f"courses[{ci}].units[{ui}].lessons[{li}].vocabulary[{vi}].definition" - if len(definition) < _DEF_MIN_CHARS: - _blocking( - report, - "DEFINITION_TOO_SHORT", - path, - f"definition must be at least {_DEF_MIN_CHARS} characters", - ) - elif len(definition) > _DEF_MAX_CHARS: - _blocking( - report, - "DEFINITION_TOO_LONG", - path, - f"definition must not exceed {_DEF_MAX_CHARS} characters", - ) - - -def _check_pos_enum(artifact: dict[str, Any], report: ValidationReport) -> None: - courses = artifact.get("courses") or [] - for ci, course in enumerate(courses): - for ui, unit in enumerate(course.get("units") or []): - for li, lesson in enumerate(unit.get("lessons") or []): - for vi, vocab in enumerate(lesson.get("vocabulary") or []): - pos = vocab.get("part_of_speech", "") - if pos not in _POS_VALUES: - _blocking( - report, - "INVALID_POS", - f"courses[{ci}].units[{ui}].lessons[{li}].vocabulary[{vi}].part_of_speech", - f"part_of_speech '{pos}' is not a recognised value", - ) - - -def _check_cefr_enum(artifact: dict[str, Any], report: ValidationReport) -> None: - courses = artifact.get("courses") or [] - for ci, course in enumerate(courses): - for ui, unit in enumerate(course.get("units") or []): - for li, lesson in enumerate(unit.get("lessons") or []): - for vi, vocab in enumerate(lesson.get("vocabulary") or []): - level = vocab.get("difficulty_level", "") - if level not in _CEFR_LEVELS: - _blocking( - report, - "INVALID_VOCAB_CEFR", - f"courses[{ci}].units[{ui}].lessons[{li}].vocabulary[{vi}].difficulty_level", - f"difficulty_level '{level}' is not a valid CEFR level", - ) + for vocab_node in index.vocabulary: + definition = str(vocab_node.data.get("definition") or "") + path = f"{vocab_node.path}.definition" + if len(definition) < _DEF_MIN_CHARS: + _blocking( + report, + "DEFINITION_TOO_SHORT", + path, + f"definition must be at least {_DEF_MIN_CHARS} characters", + ) + elif len(definition) > _DEF_MAX_CHARS: + _blocking( + report, + "DEFINITION_TOO_LONG", + path, + f"definition must not exceed {_DEF_MAX_CHARS} characters", + ) + + +def _check_pos_enum(index: ArtifactIndex, report: ValidationReport) -> None: + for vocab_node in index.vocabulary: + pos = vocab_node.data.get("part_of_speech", "") + if pos not in _POS_VALUES: + _blocking( + report, + "INVALID_POS", + f"{vocab_node.path}.part_of_speech", + f"part_of_speech '{pos}' is not a recognised value", + ) + + +def _check_cefr_enum(index: ArtifactIndex, report: ValidationReport) -> None: + for vocab_node in index.vocabulary: + level = vocab_node.data.get("difficulty_level", "") + if level not in _CEFR_LEVELS: + _blocking( + report, + "INVALID_VOCAB_CEFR", + f"{vocab_node.path}.difficulty_level", + f"difficulty_level '{level}' is not a valid CEFR level", + ) def _check_translation_shape( - artifact: dict[str, Any], report: ValidationReport + index: ArtifactIndex, report: ValidationReport ) -> None: """Warn when translation_vi is non-null but suspiciously short.""" - courses = artifact.get("courses") or [] - for ci, course in enumerate(courses): - for ui, unit in enumerate(course.get("units") or []): - for li, lesson in enumerate(unit.get("lessons") or []): - for vi, vocab in enumerate(lesson.get("vocabulary") or []): - tv = vocab.get("translation_vi") - if tv is not None and len(str(tv).strip()) < 1: - _warn( - report, - "EMPTY_TRANSLATION_VI", - f"courses[{ci}].units[{ui}].lessons[{li}].vocabulary[{vi}].translation_vi", - "translation_vi is present but empty", - ) - - -def _check_urls(artifact: dict[str, Any], report: ValidationReport) -> None: + for vocab_node in index.vocabulary: + tv = vocab_node.data.get("translation_vi") + if tv is not None and len(str(tv).strip()) < 1: + _warn( + report, + "EMPTY_TRANSLATION_VI", + f"{vocab_node.path}.translation_vi", + "translation_vi is present but empty", + ) + + +def _check_urls(index: ArtifactIndex, report: ValidationReport) -> None: """Blocking: any non-null source_url must be a valid HTTP/S URL.""" - courses = artifact.get("courses") or [] - for ci, course in enumerate(courses): - for ui, unit in enumerate(course.get("units") or []): - for li, lesson in enumerate(unit.get("lessons") or []): - for vi, vocab in enumerate(lesson.get("vocabulary") or []): - url = vocab.get("source_url") - if url is not None and not _is_valid_url(url): - _blocking( - report, - "INVALID_SOURCE_URL", - f"courses[{ci}].units[{ui}].lessons[{li}].vocabulary[{vi}].source_url", - "source_url must be a valid http/https URL when provided", - ) - for ei, exercise in enumerate(lesson.get("exercises") or []): - for url_field in ("audio_url", "image_url"): - url = exercise.get(url_field) - if url is not None and not _is_valid_url(url): - _blocking( - report, - "INVALID_EXERCISE_URL", - f"courses[{ci}].units[{ui}].lessons[{li}].exercises[{ei}].{url_field}", - f"{url_field} must be a valid http/https URL when provided", - ) - - -def _check_exercise_ids(artifact: dict[str, Any], report: ValidationReport) -> None: + for vocab_node in index.vocabulary: + url = vocab_node.data.get("source_url") + if url is not None and not _is_valid_url(url): + _blocking( + report, + "INVALID_SOURCE_URL", + f"{vocab_node.path}.source_url", + "source_url must be a valid http/https URL when provided", + ) + for exercise_node in index.exercises: + for url_field in ("audio_url", "image_url"): + url = exercise_node.data.get(url_field) + if url is not None and not _is_valid_url(url): + _blocking( + report, + "INVALID_EXERCISE_URL", + f"{exercise_node.path}.{url_field}", + f"{url_field} must be a valid http/https URL when provided", + ) + + +def _check_exercise_ids(index: ArtifactIndex, report: ValidationReport) -> None: """Exercise IDs must be unique within the whole artifact.""" seen: set[str] = set() - courses = artifact.get("courses") or [] - for ci, course in enumerate(courses): - for ui, unit in enumerate(course.get("units") or []): - for li, lesson in enumerate(unit.get("lessons") or []): - for ei, exercise in enumerate(lesson.get("exercises") or []): - eid = exercise.get("id") or "" - if not eid: - _blocking( - report, - "EXERCISE_MISSING_ID", - f"courses[{ci}].units[{ui}].lessons[{li}].exercises[{ei}].id", - "exercise id must not be empty", - ) - elif eid in seen: - _blocking( - report, - "DUPLICATE_EXERCISE_ID", - f"courses[{ci}].units[{ui}].lessons[{li}].exercises[{ei}].id", - f"exercise id '{eid}' is duplicated across the artifact", - ) - else: - seen.add(eid) + for exercise_node in index.exercises: + eid = exercise_node.data.get("id") or "" + if not eid: + _blocking( + report, + "EXERCISE_MISSING_ID", + f"{exercise_node.path}.id", + "exercise id must not be empty", + ) + elif eid in seen: + _blocking( + report, + "DUPLICATE_EXERCISE_ID", + f"{exercise_node.path}.id", + f"exercise id '{eid}' is duplicated across the artifact", + ) + else: + seen.add(eid) def _check_exercise_type_ui_type( - artifact: dict[str, Any], report: ValidationReport + index: ArtifactIndex, report: ValidationReport ) -> None: - courses = artifact.get("courses") or [] - for ci, course in enumerate(courses): - for ui, unit in enumerate(course.get("units") or []): - for li, lesson in enumerate(unit.get("lessons") or []): - for ei, exercise in enumerate(lesson.get("exercises") or []): - etype = exercise.get("type", "") - ui_type = exercise.get("ui_type", "") - base = f"courses[{ci}].units[{ui}].lessons[{li}].exercises[{ei}]" - if etype not in _EXERCISE_TYPES: - _blocking( - report, - "INVALID_EXERCISE_TYPE", - f"{base}.type", - f"exercise type '{etype}' is not recognised", - ) - if not ui_type: - _blocking( - report, - "MISSING_UI_TYPE", - f"{base}.ui_type", - "ui_type must not be empty", - ) - elif _exercise_type_mapping().get(ui_type) != etype: - _blocking( - report, - "EXERCISE_UI_TYPE_MISMATCH", - f"{base}.ui_type", - f"ui_type '{ui_type}' is not valid for exercise type '{etype}'", - ) - - -def _check_options(artifact: dict[str, Any], report: ValidationReport) -> None: + for exercise_node in index.exercises: + exercise = exercise_node.data + etype = exercise.get("type", "") + ui_type = exercise.get("ui_type", "") + base = exercise_node.path + if etype not in _EXERCISE_TYPES: + _blocking( + report, + "INVALID_EXERCISE_TYPE", + f"{base}.type", + f"exercise type '{etype}' is not recognised", + ) + if not ui_type: + _blocking( + report, + "MISSING_UI_TYPE", + f"{base}.ui_type", + "ui_type must not be empty", + ) + elif _exercise_type_mapping().get(ui_type) != etype: + _blocking( + report, + "EXERCISE_UI_TYPE_MISMATCH", + f"{base}.ui_type", + f"ui_type '{ui_type}' is not valid for exercise type '{etype}'", + ) + + +def _check_options(index: ArtifactIndex, report: ValidationReport) -> None: """multiple_choice exercises must have at least 2 options.""" - courses = artifact.get("courses") or [] - for ci, course in enumerate(courses): - for ui, unit in enumerate(course.get("units") or []): - for li, lesson in enumerate(unit.get("lessons") or []): - for ei, exercise in enumerate(lesson.get("exercises") or []): - if exercise.get("type") == "multiple_choice": - options = exercise.get("options") or [] - if len(options) < 2: - _blocking( - report, - "MC_INSUFFICIENT_OPTIONS", - f"courses[{ci}].units[{ui}].lessons[{li}].exercises[{ei}].options", - "multiple_choice exercise must have at least 2 options", - ) + for exercise_node in index.exercises: + exercise = exercise_node.data + if exercise.get("type") == "multiple_choice": + options = exercise.get("options") or [] + if len(options) < 2: + _blocking( + report, + "MC_INSUFFICIENT_OPTIONS", + f"{exercise_node.path}.options", + "multiple_choice exercise must have at least 2 options", + ) def _check_speaking_listening_text( - artifact: dict[str, Any], report: ValidationReport + index: ArtifactIndex, report: ValidationReport ) -> None: """Exercises with audio_url must have a non-empty question for listening.""" - courses = artifact.get("courses") or [] - for ci, course in enumerate(courses): - for ui, unit in enumerate(course.get("units") or []): - for li, lesson in enumerate(unit.get("lessons") or []): - for ei, exercise in enumerate(lesson.get("exercises") or []): - audio_url = exercise.get("audio_url") - question = str(exercise.get("question") or "").strip() - if audio_url and not question: - _blocking( - report, - "MISSING_AUDIO_QUESTION", - f"courses[{ci}].units[{ui}].lessons[{li}].exercises[{ei}].question", - "exercises with audio_url must have a non-empty question", - ) - - -def _check_counts(artifact: dict[str, Any], report: ValidationReport) -> None: + for exercise_node in index.exercises: + audio_url = exercise_node.data.get("audio_url") + question = str(exercise_node.data.get("question") or "").strip() + if audio_url and not question: + _blocking( + report, + "MISSING_AUDIO_QUESTION", + f"{exercise_node.path}.question", + "exercises with audio_url must have a non-empty question", + ) + + +def _check_counts(index: ArtifactIndex, report: ValidationReport) -> None: """Warn when lesson vocabulary or exercise counts look off.""" - courses = artifact.get("courses") or [] - for ci, course in enumerate(courses): - for ui, unit in enumerate(course.get("units") or []): - for li, lesson in enumerate(unit.get("lessons") or []): - vocab_count = len(lesson.get("vocabulary") or []) - ex_count = len(lesson.get("exercises") or []) - path_base = f"courses[{ci}].units[{ui}].lessons[{li}]" - if vocab_count == 0: - _blocking( - report, - "NO_VOCABULARY", - f"{path_base}.vocabulary", - "lesson must have at least one vocabulary item", - ) - if ex_count == 0: - _blocking( - report, - "NO_EXERCISES", - f"{path_base}.exercises", - "lesson must have at least one exercise", - ) + for lesson_node in index.lessons: + vocab_count = len(lesson_node.vocabulary) + ex_count = len(lesson_node.exercises) + if vocab_count == 0: + _blocking( + report, + "NO_VOCABULARY", + f"{lesson_node.path}.vocabulary", + "lesson must have at least one vocabulary item", + ) + if ex_count == 0: + _blocking( + report, + "NO_EXERCISES", + f"{lesson_node.path}.exercises", + "lesson must have at least one exercise", + ) -def _collect_metrics(artifact: dict[str, Any]) -> dict[str, Any]: - courses = artifact.get("courses") or [] - total_units = 0 - total_lessons = 0 - total_vocab = 0 - total_exercises = 0 - for course in courses: - for unit in course.get("units") or []: - total_units += 1 - for lesson in unit.get("lessons") or []: - total_lessons += 1 - total_vocab += len(lesson.get("vocabulary") or []) - total_exercises += len(lesson.get("exercises") or []) +def _collect_metrics(index: ArtifactIndex) -> dict[str, Any]: return { - "course_count": len(courses), - "unit_count": total_units, - "lesson_count": total_lessons, - "vocabulary_count": total_vocab, - "exercise_count": total_exercises, + "course_count": len(index.courses), + "unit_count": len(index.units), + "lesson_count": len(index.lessons), + "vocabulary_count": len(index.vocabulary), + "exercise_count": len(index.exercises), } @@ -843,19 +783,22 @@ def validate_artifact( _check_schema_version(artifact, report) _check_manifest_coverage(artifact, report, pins, admin_upload) _check_courses_present(artifact, report) - _check_course_levels(artifact, report) - _check_provenance(artifact, report, pins) - _check_unique_lesson_orders(artifact, report) - _check_definition_length(artifact, report) - _check_pos_enum(artifact, report) - _check_cefr_enum(artifact, report) - _check_translation_shape(artifact, report) - _check_urls(artifact, report) - _check_exercise_ids(artifact, report) - _check_exercise_type_ui_type(artifact, report) - _check_options(artifact, report) - _check_speaking_listening_text(artifact, report) - _check_counts(artifact, report) - - report.metrics = _collect_metrics(artifact) + artifact_index = build_artifact_index(artifact) + for issue in artifact_index.structural_errors: + _blocking(report, issue.code, issue.path, issue.message) + _check_course_levels(artifact_index, report) + _check_provenance(artifact, artifact_index, report, pins) + _check_unique_lesson_orders(artifact_index, report) + _check_definition_length(artifact_index, report) + _check_pos_enum(artifact_index, report) + _check_cefr_enum(artifact_index, report) + _check_translation_shape(artifact_index, report) + _check_urls(artifact_index, report) + _check_exercise_ids(artifact_index, report) + _check_exercise_type_ui_type(artifact_index, report) + _check_options(artifact_index, report) + _check_speaking_listening_text(artifact_index, report) + _check_counts(artifact_index, report) + + report.metrics = _collect_metrics(artifact_index) return report diff --git a/backend-service/app/services/item_effects_service.py b/backend-service/app/services/item_effects_service.py index 038aa691..508ca73a 100644 --- a/backend-service/app/services/item_effects_service.py +++ b/backend-service/app/services/item_effects_service.py @@ -13,22 +13,25 @@ from datetime import datetime, timedelta, timezone from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, and_, update +from sqlalchemy.exc import IntegrityError -from app.models.gamification import UserInventory, ShopItem +from app.core.shop_catalog import GAME_POWERUP_ITEM_TYPES +from app.models.gamification import ChallengeRewardClaim, UserInventory, ShopItem from app.models.progress import LessonAttempt, Streak class ItemEffectsService: """Service for managing item usage and effects.""" - + + COSMETIC_ITEM_TYPES = frozenset({"avatar", "theme"}) + # Item type handlers ITEM_HANDLERS = { 'streak_freeze': '_handle_streak_freeze', 'double_xp': '_handle_double_xp', 'hint_pack': '_handle_hint_pack', 'heart_refill': '_handle_heart_refill', - 'avatar': '_handle_cosmetic', - 'theme': '_handle_cosmetic', + **{item_type: '_handle_instant_game_powerup' for item_type in GAME_POWERUP_ITEM_TYPES}, } def __init__(self, db: AsyncSession): @@ -74,6 +77,12 @@ async def use_item( if not shop_item: return False, "Shop item not found", None + + if shop_item.item_type in self.COSMETIC_ITEM_TYPES: + return False, ( + f"{shop_item.item_type} items are permanent cosmetics. " + "Equip them from the dedicated inventory endpoint." + ), None # Get handler for this item type handler_name = self.ITEM_HANDLERS.get(shop_item.item_type) @@ -165,12 +174,13 @@ async def _handle_hint_pack( if not attempt: return False, "Start a lesson before using a hint pack", None - attempt.bonus_hints += hint_count + attempt.bonus_hints = (attempt.bonus_hints or 0) + hint_count + hints_used = attempt.hints_used or 0 return True, f"Added {hint_count} hints to your active lesson!", { "hints_added": hint_count, "hints_remaining": max( 0, - 3 + attempt.bonus_hints - attempt.hints_used, + 3 + attempt.bonus_hints - hints_used, ), "effect": "hints" } @@ -209,16 +219,21 @@ async def _get_active_lesson_attempt( ) return result.scalars().first() - async def _handle_cosmetic( + async def _handle_instant_game_powerup( self, user_id: UUID, inventory: UserInventory, shop_item: ShopItem, ) -> Tuple[bool, str, Optional[Dict]]: - """Handle cosmetic items (avatars, themes) - just mark as owned/active.""" - return True, f"{shop_item.name} equipped!", { + """Handle one-shot in-game power-ups (time freeze, skip, reveal, shield, etc.). + + These have no server-tracked state — the mini-games apply the effect + client-side. The handler just validates and echoes the item's effects + payload back so the client knows what to do. + """ + return True, f"{shop_item.name} ready!", { "item_type": shop_item.item_type, - "effect": "cosmetic" + **(shop_item.effects or {}), } async def get_active_boosts(self, user_id: UUID) -> List[Dict[str, Any]]: @@ -235,7 +250,7 @@ async def get_active_boosts(self, user_id: UUID) -> List[Dict[str, Any]]: .where( and_( UserInventory.user_id == user_id, - UserInventory.is_active == True, + UserInventory.is_active.is_(True), UserInventory.expires_at > now ) ) @@ -288,7 +303,7 @@ async def cleanup_expired_boosts(self, user_id: UUID) -> int: .where( and_( UserInventory.user_id == user_id, - UserInventory.is_active == True, + UserInventory.is_active.is_(True), UserInventory.expires_at <= now ) ) @@ -319,9 +334,28 @@ async def claim_challenge_reward( """ from app.models.progress import DailyActivity from app.crud.gamification import WalletCRUD - from datetime import date - today = date.today() + today = datetime.now(timezone.utc).date() + claim_date = datetime.combine( + today, + datetime.min.time(), + tzinfo=timezone.utc, + ) + + existing_claim = await self.db.execute( + select(ChallengeRewardClaim).where( + and_( + ChallengeRewardClaim.user_id == user_id, + ChallengeRewardClaim.challenge_id == challenge_id, + ChallengeRewardClaim.claim_date == claim_date, + ) + ) + ) + if existing_claim.scalar_one_or_none(): + return False, "Reward already claimed today", { + "challenge_id": challenge_id, + "already_claimed": True, + } # Award XP to User.total_xp and DailyActivity if xp_reward > 0: @@ -360,8 +394,26 @@ async def claim_challenge_reward( user_id, gems_reward, source="daily_challenge", - description=f"Challenge reward: {challenge_id}" + description=f"Challenge reward: {challenge_id}", + commit=False, ) + + self.db.add(ChallengeRewardClaim( + user_id=user_id, + challenge_id=challenge_id, + claim_date=claim_date, + xp_reward=xp_reward, + gems_reward=gems_reward, + )) + + try: + await self.db.commit() + except IntegrityError: + await self.db.rollback() + return False, "Reward already claimed today", { + "challenge_id": challenge_id, + "already_claimed": True, + } return True, f"Claimed! +{xp_reward} XP" + (f" +{gems_reward} gems" if gems_reward else ""), { "xp_earned": xp_reward, diff --git a/backend-service/app/services/notification_campaign/segmenter.py b/backend-service/app/services/notification_campaign/segmenter.py index 7fdfdc49..9ae5ad89 100644 --- a/backend-service/app/services/notification_campaign/segmenter.py +++ b/backend-service/app/services/notification_campaign/segmenter.py @@ -38,7 +38,7 @@ async def segment_users( min_streak = filters.get("min_streak") inactive_days = filters.get("inactive_days") - conditions = [User.is_active == True] + conditions = [User.is_active.is_(True)] if cefr_levels: normalized = [lvl.upper() for lvl in cefr_levels] @@ -47,7 +47,7 @@ async def segment_users( if inactive_days is not None: cutoff = datetime.now(UTC) - timedelta(days=inactive_days) conditions.append( - (User.last_login < cutoff) | (User.last_login == None) + (User.last_login < cutoff) | User.last_login.is_(None) ) if has_fcm_required: @@ -55,7 +55,7 @@ async def segment_users( exists( select(UserDevice.id).where( UserDevice.user_id == User.id, - UserDevice.fcm_token != None, + UserDevice.fcm_token.is_not(None), UserDevice.fcm_token != "", ) ) @@ -98,7 +98,7 @@ async def segment_users( await db.execute( select(UserDevice.user_id, UserDevice.fcm_token).where( UserDevice.user_id.in_([row.id for row in rows]), - UserDevice.fcm_token != None, + UserDevice.fcm_token.is_not(None), UserDevice.fcm_token != "", ) ) diff --git a/backend-service/app/services/proficiency_service.py b/backend-service/app/services/proficiency_service.py index 1c2790cb..7b5704f5 100644 --- a/backend-service/app/services/proficiency_service.py +++ b/backend-service/app/services/proficiency_service.py @@ -13,7 +13,7 @@ """ from typing import Optional, List, Dict, Tuple -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func, and_ @@ -600,7 +600,13 @@ def should_suggest_assessment(profile: ProficiencyProfile) -> bool: # Check if last assessment was over a week ago if profile.last_full_assessment: - days_since = (datetime.now() - profile.last_full_assessment).days + last_assessment = profile.last_full_assessment + if ( + last_assessment.tzinfo is None + or last_assessment.utcoffset() is None + ): + last_assessment = last_assessment.replace(tzinfo=timezone.utc) + days_since = (datetime.now(timezone.utc) - last_assessment).days if days_since < 7: return False diff --git a/backend-service/app/services/quota_manager.py b/backend-service/app/services/quota_manager.py index af333b98..a8f9b4a2 100644 --- a/backend-service/app/services/quota_manager.py +++ b/backend-service/app/services/quota_manager.py @@ -9,7 +9,7 @@ """ import logging -from datetime import date, datetime, timedelta +from datetime import date, datetime, timedelta, timezone from enum import Enum from typing import Optional @@ -18,6 +18,10 @@ logger = logging.getLogger(__name__) +def _utc_today() -> date: + return datetime.now(timezone.utc).date() + + class QuotaStatus(Enum): """Graduated quota threshold levels.""" NORMAL = "normal" # 0-69% → all requests allowed @@ -67,7 +71,7 @@ class QuotaManager: @classmethod def _redis_key(cls, api_name: str) -> str: """Generate date-keyed Redis key (auto-resets daily).""" - return f"quota:{api_name}:{date.today().isoformat()}" + return f"quota:{api_name}:{_utc_today().isoformat()}" @classmethod async def check_status(cls, api_name: str, cost: int = 1) -> QuotaStatus: @@ -81,15 +85,15 @@ async def check_status(cls, api_name: str, cost: int = 1) -> QuotaStatus: Returns: QuotaStatus enum value """ + budget = cls.LIMITS.get(api_name) + if budget is None: + logger.error("Unknown API: %s. No quota limit configured.", api_name) + return QuotaStatus.NORMAL + redis = await RedisClient.get_instance() if redis is None: # Redis down → be conservative, allow but log - logger.warning(f"Redis unavailable, assuming NORMAL for {api_name}") - return QuotaStatus.NORMAL - - budget = cls.LIMITS.get(api_name) - if budget is None: - logger.error(f"Unknown API: {api_name}. No quota limit configured.") + logger.warning("Redis unavailable, assuming NORMAL for %s", api_name) return QuotaStatus.NORMAL key = cls._redis_key(api_name) @@ -140,33 +144,52 @@ async def record_request(cls, api_name: str, cost: int = 1) -> QuotaStatus: Returns: Updated QuotaStatus after recording """ + if cost < 0: + raise ValueError("quota cost must be non-negative") + + budget = cls.LIMITS.get(api_name) + if budget is None: + logger.error("Unknown API: %s. Quota usage was not recorded.", api_name) + return QuotaStatus.NORMAL + + if cost == 0: + return await cls.check_status(api_name, cost=0) + redis = await RedisClient.get_instance() if redis is None: - logger.warning(f"Redis unavailable, cannot track quota for {api_name}") + logger.warning("Redis unavailable, cannot track quota for %s", api_name) return QuotaStatus.NORMAL key = cls._redis_key(api_name) new_count = await redis.incrby(key, cost) await redis.expire(key, 86400) # Auto-expire after 24h (cleanup) - budget = cls.LIMITS.get(api_name, 1) ratio = new_count / budget # Log threshold crossings if ratio >= 1.0: logger.warning( - f" BLOCKED: {api_name} at {ratio*100:.0f}% " - f"({new_count}/{budget}). No more requests until reset." + "Quota BLOCKED: %s at %.0f%% (%s/%s). No more requests until reset.", + api_name, + ratio * 100, + new_count, + budget, ) elif ratio >= cls.CRITICAL_THRESHOLD: logger.warning( - f" CRITICAL: {api_name} at {ratio*100:.0f}% " - f"({new_count}/{budget}). Only HIGH priority allowed." + "Quota CRITICAL: %s at %.0f%% (%s/%s). Only HIGH priority allowed.", + api_name, + ratio * 100, + new_count, + budget, ) elif ratio >= cls.WARNING_THRESHOLD: logger.info( - f"️ WARNING: {api_name} at {ratio*100:.0f}% " - f"({new_count}/{budget}). LOW priority blocked." + "Quota WARNING: %s at %.0f%% (%s/%s). LOW priority blocked.", + api_name, + ratio * 100, + new_count, + budget, ) return await cls.check_status(api_name, cost=0) @@ -215,11 +238,15 @@ def get_reset_time(cls) -> str: @classmethod async def reset_quota(cls, api_name: str) -> bool: """Manual quota reset (emergency use / admin endpoint).""" + if api_name not in cls.LIMITS: + logger.error("Unknown API: %s. Quota reset was skipped.", api_name) + return False + redis = await RedisClient.get_instance() if redis is None: return False key = cls._redis_key(api_name) await redis.delete(key) - logger.info(f" Manual quota reset for {api_name}") + logger.info("Manual quota reset for %s", api_name) return True diff --git a/backend-service/app/services/reminder_service.py b/backend-service/app/services/reminder_service.py index cae0dbdd..91f8df91 100644 --- a/backend-service/app/services/reminder_service.py +++ b/backend-service/app/services/reminder_service.py @@ -110,7 +110,7 @@ async def scan_due_preferences( result = await db.execute( select(UserReminderPreference) .where( - UserReminderPreference.enabled == True, + UserReminderPreference.enabled.is_(True), UserReminderPreference.next_check_at <= now_utc, ) .order_by(UserReminderPreference.next_check_at) diff --git a/backend-service/app/services/streak_service.py b/backend-service/app/services/streak_service.py index 53a28bb7..79d4b0f5 100644 --- a/backend-service/app/services/streak_service.py +++ b/backend-service/app/services/streak_service.py @@ -1,5 +1,5 @@ import logging -from datetime import date, timedelta +from datetime import date, datetime, timedelta, timezone from uuid import UUID from sqlalchemy import select, and_ from sqlalchemy.ext.asyncio import AsyncSession @@ -10,6 +10,10 @@ logger = logging.getLogger(__name__) + +def _utc_today() -> date: + return datetime.now(timezone.utc).date() + async def update_user_streak(db: AsyncSession, user_id: UUID) -> tuple[Streak, bool, bool, list]: """ Unified function to update user's streak. @@ -30,7 +34,7 @@ async def update_user_streak(db: AsyncSession, user_id: UUID) -> tuple[Streak, b ) streak = result.scalar_one_or_none() - today = date.today() + today = _utc_today() streak_increased = False streak_saved = False diff --git a/backend-service/app/services/user_stats_service.py b/backend-service/app/services/user_stats_service.py index 67eaf797..5dda8ca4 100644 --- a/backend-service/app/services/user_stats_service.py +++ b/backend-service/app/services/user_stats_service.py @@ -46,7 +46,7 @@ async def get_user_stats(db: AsyncSession, user: User) -> UserStatsResponse: lessons_completed = (await db.execute( select(func.count(LessonCompletion.id)).where( LessonCompletion.user_id == user.id, - LessonCompletion.is_passed == True, + LessonCompletion.is_passed.is_(True), ) )).scalar() or 0 @@ -119,7 +119,7 @@ async def get_weekly_activity(db: AsyncSession, user: User) -> WeeklyActivityRes LessonAttempt.user_id == user.id, LessonAttempt.finished_at >= day_start, LessonAttempt.finished_at <= day_end, - LessonAttempt.passed == True, + LessonAttempt.passed.is_(True), ) )).first() diff --git a/backend-service/app/services/xp_service.py b/backend-service/app/services/xp_service.py index fb84eb2e..56b6cf2e 100644 --- a/backend-service/app/services/xp_service.py +++ b/backend-service/app/services/xp_service.py @@ -105,6 +105,7 @@ async def award_xp_transaction( source_id: str | None = None, source_detail: str | None = None, commit: bool = True, + item_multiplier: float = 1.0, daily_xp_loader: Callable[ [uuid.UUID, AsyncSession], Awaitable[int] ] = get_daily_xp, @@ -119,7 +120,7 @@ async def award_xp_transaction( ) if source in REPEAT_SENSITIVE_SOURCES and not source_id: raise HTTPException( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=f"source_id is required for source '{source}' to prevent duplicate awards.", ) if base_xp < 0 or base_xp > MAX_SINGLE_AWARD: @@ -149,7 +150,7 @@ async def award_xp_transaction( capped_base_xp = min(base_xp, XP_SOURCE_CAPS[source]) streak_days = await streak_loader(user.id, db) - multiplier = calculate_streak_multiplier(streak_days) + multiplier = calculate_streak_multiplier(streak_days) * max(1.0, item_multiplier) raw_awarded = int(capped_base_xp * multiplier) daily_xp_today = await daily_xp_loader(user.id, db) cap_remaining = max(0, DAILY_XP_CAP - daily_xp_today) diff --git a/backend-service/app/tasks/content_prefetch.py b/backend-service/app/tasks/content_prefetch.py index 1e8c187b..f637b3a8 100644 --- a/backend-service/app/tasks/content_prefetch.py +++ b/backend-service/app/tasks/content_prefetch.py @@ -114,6 +114,7 @@ async def prefetch_youtube(db: AsyncSession) -> dict: api_name="youtube", fetch_fn=lambda cid=channel_id: _fetch_youtube_channel(cid), priority=Priority.LOW, + cost=100, redis_ttl=43200, # 12 hours db_ttl=86400, # 24 hours ) diff --git a/backend-service/app/test_vocab_definitions.py b/backend-service/app/test_vocab_definitions.py deleted file mode 100644 index 0ceec92e..00000000 --- a/backend-service/app/test_vocab_definitions.py +++ /dev/null @@ -1,18 +0,0 @@ -import asyncio -import sys -sys.path.insert(0, "/app") -from sqlalchemy import select -from app.core.database import engine -from app.models.vocabulary import VocabularyItem - -async def main(): - async with engine.connect() as conn: - for w in ['idiom', 'lexicon']: - res = await conn.execute(select(VocabularyItem.word, VocabularyItem.definition, VocabularyItem.part_of_speech).where(VocabularyItem.word == w)) - for row in res.all(): - print(f"Word: {row[0]}, POS: {row[2]}") - print(f"Def: {row[1]}") - print("-" * 30) - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/backend-service/tests/conftest.py b/backend-service/tests/conftest.py index 344fa5b9..eb9faef3 100644 --- a/backend-service/tests/conftest.py +++ b/backend-service/tests/conftest.py @@ -330,12 +330,12 @@ async def test_lesson_attempt( test_lesson: Lesson ) -> LessonAttempt: """Create a test lesson attempt""" - from datetime import datetime + from datetime import UTC, datetime attempt = LessonAttempt( user_id=test_user.id, lesson_id=test_lesson.id, - started_at=datetime.utcnow(), + started_at=datetime.now(UTC), total_questions=10, lives_remaining=3, hints_used=0, diff --git a/backend-service/tests/crud/test_course_crud_queries.py b/backend-service/tests/crud/test_course_crud_queries.py new file mode 100644 index 00000000..ab3bf5dc --- /dev/null +++ b/backend-service/tests/crud/test_course_crud_queries.py @@ -0,0 +1,19 @@ +from sqlalchemy import select +from sqlalchemy.dialects import postgresql, sqlite + +from app.crud.course import _seed_first_ordering +from app.models.course import Course + + +def test_seed_first_ordering_uses_portable_sql(): + stmt = select(Course.id).order_by(_seed_first_ordering(), Course.created_at) + + sqlite_sql = str(stmt.compile(dialect=sqlite.dialect())) + postgres_sql = str(stmt.compile(dialect=postgresql.dialect())) + + assert "@>" not in sqlite_sql + assert "::jsonb" not in sqlite_sql + assert "@>" not in postgres_sql + assert "::jsonb" not in postgres_sql + assert "LIKE" in sqlite_sql + assert "LIKE" in postgres_sql diff --git a/backend-service/tests/test_achievement_system.py b/backend-service/tests/test_achievement_system.py index 13ee58c5..9f9e1a1a 100644 --- a/backend-service/tests/test_achievement_system.py +++ b/backend-service/tests/test_achievement_system.py @@ -6,7 +6,7 @@ import pytest from uuid import uuid4 -from datetime import datetime, date, timedelta +from datetime import UTC, datetime, date, timedelta from unittest.mock import AsyncMock, patch, MagicMock from sqlalchemy.ext.asyncio import AsyncSession @@ -408,7 +408,7 @@ def test_response_serialization(self): rarity="common", xp_reward=10, gems_reward=5, - created_at=datetime.utcnow(), + created_at=datetime.now(UTC), ) data = resp.model_dump() assert data["slug"] == "test_slug" diff --git a/backend-service/tests/test_admin_rbac_security.py b/backend-service/tests/test_admin_rbac_security.py index 81eb7acc..2eb58aa5 100644 --- a/backend-service/tests/test_admin_rbac_security.py +++ b/backend-service/tests/test_admin_rbac_security.py @@ -522,7 +522,7 @@ async def test_create_achievement(self, async_client: AsyncClient, admin_headers @pytest.mark.asyncio async def test_create_shop_item(self, async_client: AsyncClient, admin_headers): - response = await async_client.post("/api/v1/admin/shop", headers=admin_headers, params={ + response = await async_client.post("/api/v1/admin/shop", headers=admin_headers, json={ "name": "Test Item", "description": "A test item", "item_type": "streak_freeze", diff --git a/backend-service/tests/test_admin_routes.py b/backend-service/tests/test_admin_routes.py index 3f31e475..737ff1f7 100644 --- a/backend-service/tests/test_admin_routes.py +++ b/backend-service/tests/test_admin_routes.py @@ -399,18 +399,87 @@ async def test_create_shop_item( response = await async_client.post( "/api/v1/admin/shop", headers=admin_headers, - params={ + json={ "name": "Test Shop Item", "description": "Test item", "item_type": "test", "price_gems": 100 } ) - + assert response.status_code == 200 data = response.json() assert data["success"] is True + @pytest.mark.asyncio + async def test_create_shop_item_persists_effects_and_icon_url( + self, + async_client: AsyncClient, + admin_headers: dict + ): + """effects/icon_url must round-trip — admin-created power-ups need a real effects payload to work in-game""" + response = await async_client.post( + "/api/v1/admin/shop", + headers=admin_headers, + json={ + "name": "Test Time Freeze", + "description": "Pauses the timer", + "item_type": "time_freeze", + "price_gems": 10, + "icon_url": "assets/icon-library/clock_freeze.png", + "effects": {"seconds": 15}, + } + ) + + assert response.status_code == 200 + item_id = response.json()["data"]["id"] + + list_response = await async_client.get( + "/api/v1/admin/shop?include_unavailable=true", + headers=admin_headers + ) + created = next( + item for item in list_response.json()["data"] if item["id"] == item_id + ) + assert created["effects"] == {"seconds": 15} + assert created["icon_url"] == "assets/icon-library/clock_freeze.png" + + @pytest.mark.asyncio + async def test_update_shop_item_can_change_effects( + self, + async_client: AsyncClient, + admin_headers: dict + ): + """Admin must be able to retune an existing power-up's effects payload""" + create_response = await async_client.post( + "/api/v1/admin/shop", + headers=admin_headers, + json={ + "name": "Test Lucky Clover", + "description": "30% auto-correct chance", + "item_type": "lucky_clover", + "price_gems": 20, + "effects": {"chance": 0.3}, + } + ) + item_id = create_response.json()["data"]["id"] + + update_response = await async_client.put( + f"/api/v1/admin/shop/{item_id}", + headers=admin_headers, + json={"effects": {"chance": 0.5}} + ) + assert update_response.status_code == 200 + + list_response = await async_client.get( + "/api/v1/admin/shop?include_unavailable=true", + headers=admin_headers + ) + updated = next( + item for item in list_response.json()["data"] if item["id"] == item_id + ) + assert updated["effects"] == {"chance": 0.5} + class TestAdminSeed: """Tests for seed data endpoint""" diff --git a/backend-service/tests/test_api_cache_service.py b/backend-service/tests/test_api_cache_service.py new file mode 100644 index 00000000..7ab1264a --- /dev/null +++ b/backend-service/tests/test_api_cache_service.py @@ -0,0 +1,57 @@ +from unittest.mock import AsyncMock + +import pytest + +from app.services import api_cache_service +from app.services.api_cache_service import APICacheService +from app.services.quota_manager import QuotaStatus + + +@pytest.mark.asyncio +async def test_zero_cost_fetch_bypasses_quota_manager(monkeypatch): + service = APICacheService(db=object()) + monkeypatch.setattr(api_cache_service.RedisClient, "get_instance", AsyncMock(return_value=None)) + monkeypatch.setattr(service, "_get_db_entry", AsyncMock(return_value=None)) + monkeypatch.setattr(service, "_warm_redis", AsyncMock()) + monkeypatch.setattr(service, "_upsert_db_entry", AsyncMock()) + check_status = AsyncMock(side_effect=AssertionError("quota should be skipped")) + record_request = AsyncMock(side_effect=AssertionError("quota should be skipped")) + monkeypatch.setattr(api_cache_service.QuotaManager, "check_status", check_status) + monkeypatch.setattr(api_cache_service.QuotaManager, "record_request", record_request) + + result = await service.get_or_fetch( + cache_key="youtube:captions:abc:en", + api_name="youtube", + fetch_fn=AsyncMock(return_value={"segments": []}), + cost=0, + ) + + assert result.source == "api" + assert result.data == {"segments": []} + check_status.assert_not_called() + record_request.assert_not_called() + + +@pytest.mark.asyncio +async def test_fetch_forwards_quota_cost(monkeypatch): + service = APICacheService(db=object()) + monkeypatch.setattr(api_cache_service.RedisClient, "get_instance", AsyncMock(return_value=None)) + monkeypatch.setattr(service, "_get_db_entry", AsyncMock(return_value=None)) + monkeypatch.setattr(service, "_warm_redis", AsyncMock()) + monkeypatch.setattr(service, "_upsert_db_entry", AsyncMock()) + check_status = AsyncMock(return_value=QuotaStatus.NORMAL) + record_request = AsyncMock(return_value=QuotaStatus.NORMAL) + monkeypatch.setattr(api_cache_service.QuotaManager, "check_status", check_status) + monkeypatch.setattr(api_cache_service.QuotaManager, "record_request", record_request) + + result = await service.get_or_fetch( + cache_key="youtube:search:q:english", + api_name="youtube", + fetch_fn=AsyncMock(return_value={"videos": []}), + cost=100, + ) + + assert result.source == "api" + assert result.data == {"videos": []} + check_status.assert_awaited_once_with("youtube", cost=100) + record_request.assert_awaited_once_with("youtube", cost=100) diff --git a/backend-service/tests/test_auth_routes.py b/backend-service/tests/test_auth_routes.py index 7b8b9a47..24be7552 100644 --- a/backend-service/tests/test_auth_routes.py +++ b/backend-service/tests/test_auth_routes.py @@ -20,7 +20,7 @@ import uuid import pytest -from datetime import datetime +from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock, patch from httpx import AsyncClient, ASGITransport @@ -74,7 +74,7 @@ def _add_provider(p: str) -> None: u.is_admin = False u.is_super_admin = False u.role_id = None - u.created_at = datetime.utcnow() + u.created_at = datetime.now(UTC) u.last_login = None u.avatar_url = None return u @@ -105,7 +105,7 @@ async def fake_refresh(obj): if not getattr(obj, "id", None): obj.id = uuid.UUID("550e8400-e29b-41d4-a716-446655440002") if not getattr(obj, "created_at", None): - obj.created_at = datetime.utcnow() + obj.created_at = datetime.now(UTC) if getattr(obj, "is_active", None) is None: obj.is_active = True if getattr(obj, "is_verified", None) is None: diff --git a/backend-service/tests/test_content_agent_validation.py b/backend-service/tests/test_content_agent_validation.py index 3bb45b64..a5ee63f9 100644 --- a/backend-service/tests/test_content_agent_validation.py +++ b/backend-service/tests/test_content_agent_validation.py @@ -160,6 +160,63 @@ def test_invalid_course_level_is_blocking() -> None: assert "INVALID_COURSE_LEVEL" in codes +def test_courses_must_be_a_list_without_crashing() -> None: + art = _base_artifact() + art["courses"] = "not-a-list" + report = validate_artifact(art) + codes = {e.code for e in report.blocking_errors} + assert "COURSES_TYPE" in codes + assert report.metrics["course_count"] == 0 + + +def test_course_and_child_entries_must_be_objects_without_crashing() -> None: + art = _base_artifact() + art["courses"] = [ + "not-a-course", + { + "title": "Broken course", + "level": "A1", + "units": [ + "not-a-unit", + { + "title": "Broken unit", + "order_index": 0, + "lessons": [ + "not-a-lesson", + { + "title": "Broken lesson", + "order_index": 0, + "vocabulary": ["not-vocabulary"], + "exercises": ["not-exercise"], + }, + ], + }, + ], + }, + ] + report = validate_artifact(art) + codes = {e.code for e in report.blocking_errors} + assert { + "COURSE_ENTRY_TYPE", + "UNIT_ENTRY_TYPE", + "LESSON_ENTRY_TYPE", + "VOCABULARY_ENTRY_TYPE", + "EXERCISE_ENTRY_TYPE", + }.issubset(codes) + + +def test_empty_units_and_lessons_are_blocking() -> None: + art = _base_artifact() + art["courses"][0]["units"] = [] + report = validate_artifact(art) + assert "NO_UNITS" in {error.code for error in report.blocking_errors} + + art = _base_artifact() + art["courses"][0]["units"][0]["lessons"] = [] + report = validate_artifact(art) + assert "NO_LESSONS" in {error.code for error in report.blocking_errors} + + # --- license gate --- diff --git a/backend-service/tests/test_course_categories_routes.py b/backend-service/tests/test_course_categories_routes.py index 495ae05d..abc9b977 100644 --- a/backend-service/tests/test_course_categories_routes.py +++ b/backend-service/tests/test_course_categories_routes.py @@ -17,7 +17,7 @@ import pytest import uuid -from datetime import datetime +from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock, patch from httpx import AsyncClient, ASGITransport @@ -125,8 +125,8 @@ def _make_mock_category( cat.order_index = 0 cat.is_active = True cat.course_count = 5 - cat.created_at = datetime.utcnow() - cat.updated_at = datetime.utcnow() + cat.created_at = datetime.now(UTC) + cat.updated_at = datetime.now(UTC) return cat diff --git a/backend-service/tests/test_courses_routes.py b/backend-service/tests/test_courses_routes.py index aa15d1c5..4560a42b 100644 --- a/backend-service/tests/test_courses_routes.py +++ b/backend-service/tests/test_courses_routes.py @@ -11,6 +11,7 @@ import pytest import uuid +from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock, patch from httpx import AsyncClient, ASGITransport @@ -314,11 +315,10 @@ async def test_already_enrolled_returns_200_with_message(self, auth_client: Asyn @pytest.mark.asyncio async def test_new_enrollment_returns_200_with_success_message(self, auth_client: AsyncClient): - from datetime import datetime mock_course = MagicMock() mock_course.is_published = True mock_progress = MagicMock() - mock_progress.started_at = datetime.utcnow() + mock_progress.started_at = datetime.now(UTC) with patch("app.crud.course.CourseCRUD.get_course", new=AsyncMock(return_value=mock_course)), \ patch("app.crud.course.CourseCRUD.is_user_enrolled", new=AsyncMock(return_value=False)), \ diff --git a/backend-service/tests/test_devices_routes.py b/backend-service/tests/test_devices_routes.py index 06430c9d..bf06a6d2 100644 --- a/backend-service/tests/test_devices_routes.py +++ b/backend-service/tests/test_devices_routes.py @@ -12,7 +12,7 @@ """ import pytest -from datetime import datetime +from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock from httpx import AsyncClient, ASGITransport @@ -109,8 +109,8 @@ def _make_mock_device(): device.device_type = "android" device.device_name = "Pixel 8" device.fcm_token = "mock-fcm-token-xyz" - device.last_active = datetime.utcnow() - device.created_at = datetime.utcnow() + device.last_active = datetime.now(UTC) + device.created_at = datetime.now(UTC) return device @@ -145,7 +145,7 @@ async def refresh_side_effect(obj): import uuid as _uuid obj.id = _uuid.uuid4() if not getattr(obj, "created_at", None) or str(type(obj.created_at)) == "": - obj.created_at = datetime.utcnow() + obj.created_at = datetime.now(UTC) mock_session.refresh = AsyncMock(side_effect=refresh_side_effect) diff --git a/backend-service/tests/test_games_routes.py b/backend-service/tests/test_games_routes.py index d6fbb550..efcd1974 100644 --- a/backend-service/tests/test_games_routes.py +++ b/backend-service/tests/test_games_routes.py @@ -493,6 +493,10 @@ async def test_completes_session_with_server_verified_score(self): new_callable=AsyncMock, return_value=xp_result, ) as award_mock, patch( + "app.routes.games.ItemEffectsService.get_xp_multiplier", + new_callable=AsyncMock, + return_value=1.0, + ) as multiplier_mock, patch( "app.routes.games.check_achievements_for_user", new_callable=AsyncMock, return_value=[], @@ -519,9 +523,11 @@ async def test_completes_session_with_server_verified_score(self): assert session.completed_at is not None assert session.xp_awarded is True db.commit.assert_awaited_once() + multiplier_mock.assert_awaited_once_with(user_id) award_mock.assert_awaited_once() assert award_mock.await_args.kwargs["source_id"] == str(session_id) assert award_mock.await_args.kwargs["commit"] is False + assert award_mock.await_args.kwargs["item_multiplier"] == 1.0 achievement_mock.assert_awaited_once_with( db, user_id, diff --git a/backend-service/tests/test_item_effects_game_powerups.py b/backend-service/tests/test_item_effects_game_powerups.py new file mode 100644 index 00000000..5140fad6 --- /dev/null +++ b/backend-service/tests/test_item_effects_game_powerups.py @@ -0,0 +1,79 @@ +"""Tests for the generic instant-consumable handler covering game power-up items.""" + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.shop_catalog import GAME_POWERUP_ITEM_TYPES +from app.models.gamification import ShopItem, UserInventory +from app.models.user import User +from app.services.item_effects_service import ItemEffectsService + + +async def _give_item( + db_session: AsyncSession, + user: User, + item_type: str, + effects: dict, + quantity: int = 1, +) -> UserInventory: + shop_item = ShopItem( + name=f"Test {item_type}", + description="test item", + item_type=item_type, + price_gems=10, + effects=effects, + is_available=True, + ) + db_session.add(shop_item) + await db_session.flush() + + inventory = UserInventory( + user_id=user.id, + shop_item_id=shop_item.id, + quantity=quantity, + ) + db_session.add(inventory) + await db_session.commit() + return inventory + + +@pytest.mark.asyncio +@pytest.mark.parametrize("item_type", GAME_POWERUP_ITEM_TYPES) +async def test_instant_game_powerup_consumes_one_and_echoes_effects( + db_session: AsyncSession, + test_user: User, + item_type: str, +): + effects = {"seconds": 10} if "time" in item_type else {"chance": 0.3} + inventory = await _give_item(db_session, test_user, item_type, effects, quantity=2) + + service = ItemEffectsService(db_session) + success, message, applied_effects = await service.use_item(test_user.id, inventory.id) + + assert success is True + assert message + assert applied_effects["item_type"] == item_type + for key, value in effects.items(): + assert applied_effects[key] == value + + await db_session.refresh(inventory) + assert inventory.quantity == 1 + # Instant power-ups have no duration_hours, so they never become a + # timed "active boost" like double_xp does. + assert inventory.is_active is False + assert inventory.expires_at is None + + +@pytest.mark.asyncio +async def test_instant_game_powerup_rejects_when_out_of_stock( + db_session: AsyncSession, + test_user: User, +): + inventory = await _give_item(db_session, test_user, "skip_token", {}, quantity=0) + + service = ItemEffectsService(db_session) + success, message, applied_effects = await service.use_item(test_user.id, inventory.id) + + assert success is False + assert applied_effects is None + assert "remaining" in message.lower() diff --git a/backend-service/tests/test_item_effects_service.py b/backend-service/tests/test_item_effects_service.py index b99e8ace..f14ae62f 100644 --- a/backend-service/tests/test_item_effects_service.py +++ b/backend-service/tests/test_item_effects_service.py @@ -1,10 +1,16 @@ from types import SimpleNamespace -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, MagicMock, patch from uuid import uuid4 import pytest -from app.services.item_effects_service import ItemEffectsService +from app.services.item_effects_service import DailyChallengeService, ItemEffectsService + + +def _result(value=None): + result = MagicMock() + result.scalar_one_or_none.return_value = value + return result @pytest.mark.asyncio @@ -25,6 +31,23 @@ async def test_hint_pack_adds_bonus_hints_to_active_attempt(): assert effects["hints_remaining"] == 7 +@pytest.mark.asyncio +async def test_hint_pack_handles_null_bonus_hints(): + service = ItemEffectsService(AsyncMock()) + attempt = SimpleNamespace(bonus_hints=None, hints_used=None) + service._get_active_lesson_attempt = AsyncMock(return_value=attempt) + + success, _, effects = await service._handle_hint_pack( + uuid4(), + SimpleNamespace(), + SimpleNamespace(effects={"quantity": 2}), + ) + + assert success is True + assert attempt.bonus_hints == 2 + assert effects["hints_remaining"] == 5 + + @pytest.mark.asyncio async def test_hint_pack_requires_active_attempt(): service = ItemEffectsService(AsyncMock()) @@ -56,3 +79,101 @@ async def test_heart_refill_restores_configured_maximum(): assert success is True assert attempt.lives_remaining == 3 assert effects["hearts_restored"] == 3 + + +@pytest.mark.asyncio +async def test_generic_use_rejects_cosmetic_without_consuming_inventory(): + inventory = SimpleNamespace( + id=uuid4(), + user_id=uuid4(), + shop_item_id=uuid4(), + quantity=1, + ) + shop_item = SimpleNamespace( + id=inventory.shop_item_id, + item_type="avatar", + name="Sunny Avatar", + effects={"avatar_url": "https://example.com/avatar.svg"}, + ) + db = MagicMock() + db.execute = AsyncMock(side_effect=[_result(inventory), _result(shop_item)]) + db.commit = AsyncMock() + + service = ItemEffectsService(db) + success, message, effects = await service.use_item( + inventory.user_id, + inventory.id, + ) + + assert success is False + assert "dedicated inventory endpoint" in message + assert effects is None + assert inventory.quantity == 1 + db.commit.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_daily_challenge_service_rejects_duplicate_claim(): + db = MagicMock() + db.execute = AsyncMock(return_value=_result(SimpleNamespace())) + db.add = MagicMock() + db.commit = AsyncMock() + + service = DailyChallengeService(db) + success, message, payload = await service.claim_challenge_reward( + user_id=uuid4(), + challenge_id="complete_lessons", + xp_reward=20, + ) + + assert success is False + assert "already claimed" in message.lower() + assert payload["already_claimed"] is True + db.add.assert_not_called() + db.commit.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_daily_challenge_service_records_claim_and_commits_once(): + user_id = uuid4() + user = SimpleNamespace(id=user_id, total_xp=5) + db = MagicMock() + db.execute = AsyncMock( + side_effect=[ + _result(None), + _result(user), + _result(None), + ] + ) + db.add = MagicMock() + db.commit = AsyncMock() + db.rollback = AsyncMock() + + service = DailyChallengeService(db) + with patch( + "app.crud.gamification.LeaderboardCRUD.add_xp", + new=AsyncMock(), + ) as add_xp, patch( + "app.crud.gamification.WalletCRUD.add_gems", + new=AsyncMock(), + ) as add_gems: + success, message, payload = await service.claim_challenge_reward( + user_id=user_id, + challenge_id="complete_lessons", + xp_reward=20, + gems_reward=3, + ) + + added_model_names = [call.args[0].__class__.__name__ for call in db.add.call_args_list] + + assert success is True + assert "Claimed" in message + assert payload["xp_earned"] == 20 + assert payload["gems_earned"] == 3 + assert user.total_xp == 25 + assert "DailyActivity" in added_model_names + assert "ChallengeRewardClaim" in added_model_names + add_xp.assert_awaited_once_with(db, user_id, 20) + add_gems.assert_awaited_once() + assert add_gems.await_args.kwargs["commit"] is False + db.commit.assert_awaited_once() diff --git a/backend-service/tests/test_learning_routes.py b/backend-service/tests/test_learning_routes.py index 14191b50..6805a86b 100644 --- a/backend-service/tests/test_learning_routes.py +++ b/backend-service/tests/test_learning_routes.py @@ -7,7 +7,7 @@ from httpx import AsyncClient from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from datetime import datetime +from datetime import UTC, datetime from app.models.course import Course, Unit, Lesson from app.models.user import User @@ -85,7 +85,7 @@ async def test_start_lesson_resume_existing( existing_attempt = LessonAttempt( user_id=test_user.id, lesson_id=test_lesson.id, - started_at=datetime.utcnow(), + started_at=datetime.now(UTC), total_questions=10, lives_remaining=2, hints_used=1, @@ -320,7 +320,7 @@ async def test_complete_lesson_already_completed( test_lesson_attempt: LessonAttempt ): """Test cannot complete already completed lesson""" - test_lesson_attempt.finished_at = datetime.utcnow() + test_lesson_attempt.finished_at = datetime.now(UTC) await db_session.commit() response = await async_client.post( diff --git a/backend-service/tests/test_proficiency_functional.py b/backend-service/tests/test_proficiency_functional.py index df59ea52..13e24a7b 100644 --- a/backend-service/tests/test_proficiency_functional.py +++ b/backend-service/tests/test_proficiency_functional.py @@ -23,6 +23,7 @@ import pytest import sys +from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Dict @@ -488,7 +489,54 @@ def test_level_index(self): # ═══════════════════════════════════════════════════════════════════════ -# 6. Level Thresholds Configuration +# 6. Assessment Suggestion Logic +# ═══════════════════════════════════════════════════════════════════════ + +class TestAssessmentSuggestion: + """Test formal assessment suggestion rules.""" + + def _profile( + self, + *, + last_full_assessment: datetime | None, + assessment_count: int = 50, + score: float = 70, + ) -> ProficiencyProfile: + return ProficiencyProfile( + user_id="user-1", + overall_level=ProficiencyLevel.A1, + skills={ + skill: SkillAssessment( + skill=skill, + score=score, + level=ProficiencyLevel.A1, + confidence=1.0, + total_exercises=assessment_count, + correct_exercises=int(assessment_count * 0.8), + ) + for skill in SkillType + }, + assessment_count=assessment_count, + last_full_assessment=last_full_assessment, + ) + + def test_accepts_timezone_aware_last_assessment(self): + profile = self._profile( + last_full_assessment=datetime.now(UTC) - timedelta(days=8), + ) + + assert ProficiencyService.should_suggest_assessment(profile) is True + + def test_recent_timezone_aware_assessment_suppresses_suggestion(self): + profile = self._profile( + last_full_assessment=datetime.now(UTC) - timedelta(days=2), + ) + + assert ProficiencyService.should_suggest_assessment(profile) is False + + +# ═══════════════════════════════════════════════════════════════════════ +# 7. Level Thresholds Configuration # ═══════════════════════════════════════════════════════════════════════ class TestLevelThresholds: diff --git a/backend-service/tests/test_quota_manager.py b/backend-service/tests/test_quota_manager.py new file mode 100644 index 00000000..2599351b --- /dev/null +++ b/backend-service/tests/test_quota_manager.py @@ -0,0 +1,89 @@ +from datetime import date + +import pytest + +from app.services import quota_manager +from app.services.quota_manager import QuotaManager, QuotaStatus + + +def test_quota_key_uses_utc_date(monkeypatch): + monkeypatch.setattr( + quota_manager, + "_utc_today", + lambda: date(2026, 6, 26), + ) + + assert QuotaManager._redis_key("newsapi") == "quota:newsapi:2026-06-26" + + +def test_reset_time_returns_human_readable_duration(): + reset_time = QuotaManager.get_reset_time() + + assert "h" in reset_time + assert "m" in reset_time + + +@pytest.mark.asyncio +async def test_unknown_api_usage_is_not_recorded(monkeypatch): + class FakeRedis: + def __init__(self): + self.incrby_calls = [] + + async def incrby(self, key, cost): + self.incrby_calls.append((key, cost)) + return cost + + async def expire(self, key, ttl): + return True + + redis = FakeRedis() + + async def fake_get_instance(): + return redis + + monkeypatch.setattr( + quota_manager.RedisClient, + "get_instance", + fake_get_instance, + ) + + status = await QuotaManager.record_request("unknown-api") + + assert status == QuotaStatus.NORMAL + assert redis.incrby_calls == [] + + +@pytest.mark.asyncio +async def test_zero_cost_usage_is_not_recorded(monkeypatch): + class FakeRedis: + def __init__(self): + self.incrby_calls = [] + + async def get(self, key): + return 0 + + async def incrby(self, key, cost): + self.incrby_calls.append((key, cost)) + return cost + + redis = FakeRedis() + + async def fake_get_instance(): + return redis + + monkeypatch.setattr( + quota_manager.RedisClient, + "get_instance", + fake_get_instance, + ) + + status = await QuotaManager.record_request("youtube", cost=0) + + assert status == QuotaStatus.NORMAL + assert redis.incrby_calls == [] + + +@pytest.mark.asyncio +async def test_negative_quota_cost_is_rejected(): + with pytest.raises(ValueError, match="non-negative"): + await QuotaManager.record_request("youtube", cost=-1) diff --git a/backend-service/tests/test_schema_pydantic_v2_debt.py b/backend-service/tests/test_schema_pydantic_v2_debt.py new file mode 100644 index 00000000..0bb094ee --- /dev/null +++ b/backend-service/tests/test_schema_pydantic_v2_debt.py @@ -0,0 +1,28 @@ +import re +from pathlib import Path + + +SCHEMA_DIR = Path(__file__).resolve().parents[1] / "app" / "schemas" + + +def test_schemas_do_not_use_pydantic_v1_config_or_validators(): + offenders: dict[str, list[str]] = {} + + for path in sorted(SCHEMA_DIR.glob("*.py")): + source = path.read_text(encoding="utf-8") + issues: list[str] = [] + + if "class Config:" in source: + issues.append("class Config") + if re.search(r"from pydantic import .*\bvalidator\b", source): + issues.append("validator import") + if re.search(r"@validator\(", source): + issues.append("@validator") + + if issues: + offenders[path.name] = issues + + assert not offenders, ( + "Use Pydantic v2 APIs in schemas: ConfigDict/model_config and " + f"field_validator/model_validator. Offenders: {offenders}" + ) diff --git a/backend-service/tests/test_shop_catalog.py b/backend-service/tests/test_shop_catalog.py index bf41b9b9..853ef036 100644 --- a/backend-service/tests/test_shop_catalog.py +++ b/backend-service/tests/test_shop_catalog.py @@ -1,10 +1,15 @@ -from app.core.shop_catalog import AVATAR_SHOP_ITEMS, CONSUMABLE_SHOP_ITEMS, SHOP_CATALOG +from app.core.shop_catalog import ( + AVATAR_SHOP_ITEMS, + CONSUMABLE_SHOP_ITEMS, + GAME_POWERUP_ITEM_TYPES, + SHOP_CATALOG, +) def test_catalog_contains_affordable_consumables_and_many_avatars(): - assert len(CONSUMABLE_SHOP_ITEMS) == 9 + assert len(CONSUMABLE_SHOP_ITEMS) == 19 assert len(AVATAR_SHOP_ITEMS) == 36 - assert len(SHOP_CATALOG) == 45 + assert len(SHOP_CATALOG) == 55 prices = {item["name"]: item["price_gems"] for item in CONSUMABLE_SHOP_ITEMS} assert prices["Hint Pack (5)"] == 12 @@ -13,6 +18,20 @@ def test_catalog_contains_affordable_consumables_and_many_avatars(): assert prices["Streak Freeze"] == 25 +def test_catalog_contains_all_game_powerup_items(): + powerup_items = { + item["item_type"]: item + for item in CONSUMABLE_SHOP_ITEMS + if item["item_type"] in GAME_POWERUP_ITEM_TYPES + } + assert set(powerup_items) == set(GAME_POWERUP_ITEM_TYPES) + assert powerup_items["time_freeze"]["effects"] == {"seconds": 10} + assert powerup_items["extra_time"]["effects"] == {"seconds": 20} + assert powerup_items["lucky_clover"]["effects"] == {"chance": 0.3} + assert powerup_items["score_multiplier"]["effects"] == {"multiplier": 2} + assert all(item["price_gems"] > 0 for item in powerup_items.values()) + + def test_avatar_catalog_uses_adventurer_urls_and_low_price_tiers(): assert {item["price_gems"] for item in AVATAR_SHOP_ITEMS} == {10, 15, 20} assert all(item["item_type"] == "avatar" for item in AVATAR_SHOP_ITEMS) diff --git a/backend-service/tests/test_streak_service.py b/backend-service/tests/test_streak_service.py new file mode 100644 index 00000000..d64a0f86 --- /dev/null +++ b/backend-service/tests/test_streak_service.py @@ -0,0 +1,42 @@ +from datetime import date +from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 + +import pytest + +from app.services import streak_service +from app.services.streak_service import update_user_streak + + +def _result(value=None): + result = MagicMock() + result.scalar_one_or_none.return_value = value + return result + + +@pytest.mark.asyncio +async def test_update_user_streak_uses_utc_service_date(monkeypatch): + fixed_today = date(2026, 6, 26) + monkeypatch.setattr(streak_service, "_utc_today", lambda: fixed_today) + monkeypatch.setattr( + streak_service, + "check_achievements_for_user", + AsyncMock(return_value=[]), + ) + monkeypatch.setattr(streak_service, "delete_cached", AsyncMock()) + + db = MagicMock() + db.execute = AsyncMock(side_effect=[_result(None), _result(None)]) + db.add = MagicMock() + db.flush = AsyncMock() + + streak, increased, saved, achievements = await update_user_streak( + db, + uuid4(), + ) + + assert streak.last_activity_date == fixed_today + assert streak.current_streak == 1 + assert increased is True + assert saved is False + assert achievements == [] diff --git a/backend-service/tests/test_users_routes.py b/backend-service/tests/test_users_routes.py index f7f809c4..1b1b29d3 100644 --- a/backend-service/tests/test_users_routes.py +++ b/backend-service/tests/test_users_routes.py @@ -16,7 +16,7 @@ import pytest import uuid -from datetime import datetime +from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock, patch from httpx import AsyncClient, ASGITransport @@ -46,8 +46,8 @@ def _make_mock_user(user_id: str = "550e8400-e29b-41d4-a716-446655440001"): user.rank = "bronze" user.avatar_url = None user.bio = None - user.created_at = datetime.utcnow() - user.updated_at = datetime.utcnow() + user.created_at = datetime.now(UTC) + user.updated_at = datetime.now(UTC) user.last_login = None user.role_slug = "user" user.role_id = None diff --git a/backend-service/tests/test_vocabulary_routes.py b/backend-service/tests/test_vocabulary_routes.py index 48322d7a..2f1626ba 100644 --- a/backend-service/tests/test_vocabulary_routes.py +++ b/backend-service/tests/test_vocabulary_routes.py @@ -18,7 +18,7 @@ import pytest import uuid -from datetime import datetime, date, timedelta +from datetime import UTC, datetime, date, timedelta from typing import List from unittest.mock import AsyncMock, MagicMock, patch from httpx import AsyncClient, ASGITransport @@ -124,8 +124,8 @@ def _make_mock_vocab_item(vocab_id: str = VOCAB_ID): item.course_id = None item.lesson_id = None item.usage_frequency = 0 - item.created_at = datetime.utcnow() - item.updated_at = datetime.utcnow() + item.created_at = datetime.now(UTC) + item.updated_at = datetime.now(UTC) return item @@ -140,7 +140,7 @@ def _make_mock_user_vocab(): uv.interval = 1 uv.streak = 0 uv.repetitions = 0 - uv.next_review_date = datetime.utcnow() + uv.next_review_date = datetime.now(UTC) uv.last_reviewed_at = None uv.fsrs_stability = 0.0 uv.fsrs_difficulty = 0.0 @@ -157,7 +157,7 @@ def _make_mock_user_vocab(): uv.longest_streak = 0 uv.total_xp_earned = 0 uv.notes = None - uv.added_at = datetime.utcnow() + uv.added_at = datetime.now(UTC) return uv @@ -270,6 +270,15 @@ async def test_response_has_items_and_total(self, auth_client): assert "items" in data assert "total" in data + @pytest.mark.asyncio + async def test_total_comes_from_count_query(self, auth_client): + client, _, _, _ = auth_client + with patch("app.crud.vocabulary.vocabulary_crud.get_user_vocabulary_list", new=AsyncMock(return_value=[])), \ + patch("app.crud.vocabulary.vocabulary_crud.count_user_vocabulary", new=AsyncMock(return_value=42)): + response = await client.get(f"{BASE}/collection") + assert response.status_code == 200 + assert response.json()["total"] == 42 + @pytest.mark.asyncio async def test_invalid_status_returns_400(self, auth_client): client, _, _, _ = auth_client @@ -727,7 +736,7 @@ async def test_creates_deck_successfully(self, auth_client): mock_deck.name = "Travel Phrases" mock_deck.description = "Words for travel" mock_deck.color = "#2196F3" - mock_deck.created_at = datetime.utcnow() + mock_deck.created_at = datetime.now(UTC) mock_deck.user_id = "00000000-0000-0000-0000-000000000001" mock_deck.word_count = 0 with patch("app.crud.vocabulary.vocabulary_crud.create_deck", new=AsyncMock(return_value=mock_deck)): @@ -774,7 +783,7 @@ async def test_returns_user_decks(self, auth_client): mock_deck.name = "Travel Phrases" mock_deck.description = "Words for travel" mock_deck.color = "#2196F3" - mock_deck.created_at = datetime.utcnow() + mock_deck.created_at = datetime.now(UTC) mock_deck.user_id = "00000000-0000-0000-0000-000000000001" mock_deck.word_count = 5 with patch("app.crud.vocabulary.vocabulary_crud.get_user_decks", new=AsyncMock(return_value=[mock_deck])): diff --git a/backend-service/tests/test_xp_service.py b/backend-service/tests/test_xp_service.py index a5dca613..e05cad1b 100644 --- a/backend-service/tests/test_xp_service.py +++ b/backend-service/tests/test_xp_service.py @@ -135,6 +135,80 @@ async def test_award_creates_current_week_leaderboard_entry(): assert leaderboard_entries[0].league == user.rank +# ── Game XP must respect a purchased double_xp/triple_xp shop boost ────────── + +@pytest.mark.asyncio +async def test_game_xp_award_applies_item_multiplier_on_top_of_streak(): + """A purchased Double XP boost (item_multiplier=2.0) must scale game XP, + not just lesson/challenge XP — this was previously a dead purchase for + players who only play the mini-games.""" + user_id = uuid.uuid4() + user = _make_user(user_id) + daily = SimpleNamespace(xp_earned=0) + leaderboard = SimpleNamespace(xp_earned=0, league="bronze") + db = MagicMock() + db.execute = AsyncMock( + side_effect=[ + _result(scalar_one_or_none=user), + _result(scalar_one_or_none=None), + MagicMock(), + _result(scalar_one_or_none=daily), + _result(scalar_one_or_none=leaderboard), + ] + ) + db.add = MagicMock() + db.commit = AsyncMock() + + result = await award_xp_transaction( + db=db, + user=user, + source="game", + base_xp=10, + source_id="game-session-001", + item_multiplier=2.0, + daily_xp_loader=AsyncMock(return_value=0), + streak_loader=AsyncMock(return_value=0), + ) + + assert result.multiplier == 2.0 + assert result.xp_awarded == 20 + + +@pytest.mark.asyncio +async def test_item_multiplier_below_one_does_not_reduce_xp(): + """A missing/invalid boost (item_multiplier < 1.0) must never penalize XP.""" + user_id = uuid.uuid4() + user = _make_user(user_id) + daily = SimpleNamespace(xp_earned=0) + leaderboard = SimpleNamespace(xp_earned=0, league="bronze") + db = MagicMock() + db.execute = AsyncMock( + side_effect=[ + _result(scalar_one_or_none=user), + _result(scalar_one_or_none=None), + MagicMock(), + _result(scalar_one_or_none=daily), + _result(scalar_one_or_none=leaderboard), + ] + ) + db.add = MagicMock() + db.commit = AsyncMock() + + result = await award_xp_transaction( + db=db, + user=user, + source="game", + base_xp=10, + source_id="game-session-002", + item_multiplier=0.0, + daily_xp_loader=AsyncMock(return_value=0), + streak_loader=AsyncMock(return_value=0), + ) + + assert result.multiplier == 1.0 + assert result.xp_awarded == 10 + + # ── Task 5: source_id requirement for repeat-sensitive sources ──────────────── @pytest.mark.parametrize("source", sorted(REPEAT_SENSITIVE_SOURCES)) diff --git a/backend-service/tests/test_youtube_routes.py b/backend-service/tests/test_youtube_routes.py index 2b8f5d76..a53c61b3 100644 --- a/backend-service/tests/test_youtube_routes.py +++ b/backend-service/tests/test_youtube_routes.py @@ -28,8 +28,12 @@ async def no_db_client(): from app.main import app from app.core.database import get_db + class NoDbSession: + async def execute(self, *args, **kwargs): + raise RuntimeError("Database is disabled for this route test") + async def mock_get_db(): - yield AsyncMock() + yield NoDbSession() app.dependency_overrides[get_db] = mock_get_db transport = ASGITransport(app=app) @@ -310,6 +314,7 @@ async def test_search_with_channel_id_filter(self, no_db_client: AsyncClient): call_args = mock_cache_instance.get_or_fetch.call_args cache_key = call_args.kwargs.get("cache_key") or call_args.args[0] assert "UCHaHD477h-FeBbVh9Sh7syA" in cache_key + assert call_args.kwargs["cost"] == 100 # ============================================================================ @@ -402,6 +407,7 @@ async def test_permanent_cache_key_includes_video_and_lang(self, no_db_client: A call_kwargs = mock_cache_instance.get_or_fetch.call_args.kwargs assert call_kwargs["cache_key"] == "youtube:captions:myVideoId123:en" + assert call_kwargs["cost"] == 0 # ============================================================================ @@ -459,6 +465,7 @@ async def test_returns_videos_for_channel(self, no_db_client: AsyncClient): assert data["channel_id"] == "UCHaHD477h-FeBbVh9Sh7syA" assert "data" in data assert "source" in data + assert mock_cache_instance.get_or_fetch.call_args.kwargs["cost"] == 100 @pytest.mark.asyncio async def test_max_results_validation(self, no_db_client: AsyncClient): @@ -745,6 +752,7 @@ async def test_translate_returns_word_data(self, no_db_client): async def test_translate_passes_context_to_fetch(self, no_db_client): """context query param is forwarded to the cache fetch_fn.""" captured_context: list[str] = [] + captured_cost: list[int] = [] mock_result = MagicMock() mock_result.data = { @@ -756,6 +764,7 @@ async def capture_fetch(cache_key, api_name, fetch_fn, **kwargs): # Execute fetch_fn to capture the context via _fetch_word_data signature # We just verify cache_key is stable (word+lang, not context) captured_context.append(cache_key) + captured_cost.append(kwargs["cost"]) return mock_result with patch("app.routes.youtube.APICacheService") as MockCache: @@ -770,6 +779,7 @@ async def capture_fetch(cache_key, api_name, fetch_fn, **kwargs): assert resp.status_code == 200 # Cache key must NOT include context (stable hit rate) assert captured_context[0] == "youtube:translate:run:vi" + assert captured_cost[0] == 0 @pytest.mark.asyncio async def test_translate_returns_empty_on_exception(self, no_db_client): From 91566ed24881fa3f3a2170ec121f2baca0c6f429 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Huu <2001230909@hufi.edu.vn> Date: Fri, 26 Jun 2026 21:09:51 +0700 Subject: [PATCH 13/20] feat(admin): shop CRUD via JSON body with effects/icon_url and power-up types - adminApi: create/update shop item send JSON body (was query params); add ShopItemEffects type, effects/icon_url fields - ShopPage: item-type list mirrors backend shop_catalog; quick-create templates prefill name/description/price/effects per type - i18n (en/vi) strings for the new shop fields Co-Authored-By: Claude Opus 4.8 --- admin-service/src/lib/adminApi.ts | 25 ++-- admin-service/src/lib/i18n/en.ts | 7 + admin-service/src/lib/i18n/vi.ts | 7 + admin-service/src/pages/ShopPage.tsx | 193 +++++++++++++++++++++++---- 4 files changed, 194 insertions(+), 38 deletions(-) diff --git a/admin-service/src/lib/adminApi.ts b/admin-service/src/lib/adminApi.ts index d30dbfc7..ab963405 100644 --- a/admin-service/src/lib/adminApi.ts +++ b/admin-service/src/lib/adminApi.ts @@ -280,6 +280,8 @@ export const uploadBadgeImage = async (file: File): Promise; + export type ShopItemType = { id: string; name: string; @@ -289,6 +291,7 @@ export type ShopItemType = { is_available: boolean; stock_quantity?: number | null; icon_url?: string | null; + effects?: ShopItemEffects | null; }; export const listShopItems = async (includeUnavailable = true) => @@ -303,17 +306,19 @@ export const createShopItem = async (params: { price_gems: number; is_available?: boolean; stock_quantity?: number; -}) => { - const url = new URL(`${ENV.backendUrl}/admin/shop`); - Object.entries(params).forEach(([k, v]) => { if (v !== undefined) url.searchParams.set(k, String(v)); }); - return apiFetch>(url.toString(), { method: "POST" }); -}; + icon_url?: string; + effects?: ShopItemEffects; +}) => + apiFetch>(`${ENV.backendUrl}/admin/shop`, { + method: "POST", + body: JSON.stringify(params), + }); -export const updateShopItem = async (id: string, params: Partial) => { - const url = new URL(`${ENV.backendUrl}/admin/shop/${id}`); - Object.entries(params).forEach(([k, v]) => { if (v !== undefined) url.searchParams.set(k, String(v)); }); - return apiFetch>(url.toString(), { method: "PUT" }); -}; +export const updateShopItem = async (id: string, params: Partial) => + apiFetch>(`${ENV.backendUrl}/admin/shop/${id}`, { + method: "PUT", + body: JSON.stringify(params), + }); export const deleteShopItem = async (id: string) => apiFetch>(`${ENV.backendUrl}/admin/shop/${id}`, { diff --git a/admin-service/src/lib/i18n/en.ts b/admin-service/src/lib/i18n/en.ts index 7e7781a6..964046a3 100644 --- a/admin-service/src/lib/i18n/en.ts +++ b/admin-service/src/lib/i18n/en.ts @@ -479,6 +479,13 @@ const en: Translations = { stockQuantity: "Stock Quantity", unlimitedPlaceholder: "Leave blank = unlimited", updateFailed: "Update failed", + useTemplate: "Use Template", + templateApplied: "Template applied for this item type — adjust as needed before saving.", + iconUrl: "Icon URL", + iconUrlPlaceholder: "e.g. assets/icon-library/clock_freeze.png", + effectsLabel: "Effects (JSON)", + effectsHint: "e.g. {\"seconds\": 10} — determines how this item behaves in-game.", + effectsInvalidJson: "Effects must be valid JSON, e.g. {\"seconds\": 10}", }, // ============== Ads/Banner ============== diff --git a/admin-service/src/lib/i18n/vi.ts b/admin-service/src/lib/i18n/vi.ts index 9f7edc4d..8cce7801 100644 --- a/admin-service/src/lib/i18n/vi.ts +++ b/admin-service/src/lib/i18n/vi.ts @@ -477,6 +477,13 @@ const vi = { stockQuantity: "Số lượng kho", unlimitedPlaceholder: "Để trống = vô hạn", updateFailed: "Cập nhật thất bại", + useTemplate: "Dùng mẫu", + templateApplied: "Đã điền mẫu cho loại item này — có thể chỉnh lại trước khi lưu.", + iconUrl: "URL icon", + iconUrlPlaceholder: "VD: assets/icon-library/clock_freeze.png", + effectsLabel: "Hiệu ứng (JSON)", + effectsHint: "VD: {\"seconds\": 10} — quyết định cách item này hoạt động trong game.", + effectsInvalidJson: "Hiệu ứng phải là JSON hợp lệ, ví dụ {\"seconds\": 10}", }, // ============== Ads/Banner ============== diff --git a/admin-service/src/pages/ShopPage.tsx b/admin-service/src/pages/ShopPage.tsx index a63b4bb2..c3a58262 100644 --- a/admin-service/src/pages/ShopPage.tsx +++ b/admin-service/src/pages/ShopPage.tsx @@ -12,7 +12,75 @@ import { } from "../lib/adminApi"; import { useI18n } from "../lib/i18n"; -const ITEM_TYPES = ["streak_freeze", "double_xp", "hint_pack", "cosmetic", "power_up", "time_boost"]; +// Mirrors the item_type values actually seeded in backend-service/app/core/shop_catalog.py. +const ITEM_TYPES = [ + // Boosts + "streak_freeze", + "double_xp", + "hint_pack", + "heart_refill", + // Cosmetics + "avatar", + "theme", + // In-game power-ups + "time_freeze", + "extra_time", + "skip_token", + "reveal_hint", + "translate_hint", + "mistake_shield", + "extra_heart", + "lucky_clover", + "score_multiplier", + "pair_swap", +]; + +// Quick-create templates mirroring backend-service/app/core/shop_catalog.py, so +// picking an item_type fills in a sensible, already-working default instead of +// the admin having to know each item's expected effects payload shape by heart. +const ITEM_TYPE_TEMPLATES: Record< + string, + { name: string; description: string; price_gems: number; effects: Record } +> = { + streak_freeze: { name: "Streak Freeze", description: "Add one streak freeze", price_gems: 25, effects: { quantity: 1 } }, + double_xp: { name: "Double XP (1 hour)", description: "Earn double XP for 1 hour", price_gems: 25, effects: { duration_hours: 1, multiplier: 2 } }, + hint_pack: { name: "Hint Pack (5)", description: "Add 5 hints to your active lesson", price_gems: 12, effects: { quantity: 5 } }, + heart_refill: { name: "Heart Refill", description: "Restore your active lesson to 3 hearts", price_gems: 15, effects: { hearts: 3 } }, + avatar: { name: "New Avatar", description: "Exclusive profile avatar", price_gems: 50, effects: { avatar_url: "" } }, + theme: { name: "New Theme", description: "Unlockable app theme", price_gems: 50, effects: {} }, + time_freeze: { name: "Time Freeze", description: "Pause the countdown timer for 10 seconds in any timed game", price_gems: 10, effects: { seconds: 10 } }, + extra_time: { name: "Extra Time", description: "Add 20 seconds straight to the clock in any timed game", price_gems: 15, effects: { seconds: 20 } }, + skip_token: { name: "Skip Token", description: "Skip the current word or question with no penalty", price_gems: 12, effects: {} }, + reveal_hint: { name: "Magnifying Glass", description: "Free reveal: the next letter, or eliminate 2 wrong options", price_gems: 8, effects: { mode: "letter" } }, + translate_hint: { name: "Quick Translate", description: "Reveal the Vietnamese translation of the current word", price_gems: 8, effects: { mode: "translation" } }, + mistake_shield: { name: "Shield", description: "Negate the next wrong answer or life loss", price_gems: 18, effects: {} }, + extra_heart: { name: "Extra Heart", description: "Start Hangman with one extra life", price_gems: 15, effects: { lives: 1 } }, + lucky_clover: { name: "Lucky Clover", description: "30% chance to auto-correct your next wrong answer", price_gems: 20, effects: { chance: 0.3 } }, + score_multiplier: { name: "Score Multiplier", description: "Double your in-game score for the rest of this session", price_gems: 22, effects: { multiplier: 2 } }, + pair_swap: { name: "Pair Swap", description: "Undo one wrong match in Matching Game for a free retry", price_gems: 10, effects: {} }, +}; + +type ShopItemForm = { + name: string; + description: string; + item_type: string; + price_gems: number; + is_available: boolean; + stock_quantity: number | undefined; + icon_url: string; + effectsText: string; +}; + +const EMPTY_FORM: ShopItemForm = { + name: "", + description: "", + item_type: "streak_freeze", + price_gems: 50, + is_available: true, + stock_quantity: undefined, + icon_url: "", + effectsText: "{}", +}; export const ShopPage = () => { const { t } = useI18n(); @@ -24,20 +92,29 @@ export const ShopPage = () => { const [showForm, setShowForm] = useState(false); const [editingId, setEditingId] = useState(null); const [saving, setSaving] = useState(false); - const [form, setForm] = useState({ - name: "", - description: "", - item_type: "streak_freeze", - price_gems: 50, - is_available: true, - stock_quantity: undefined as number | undefined, - }); + const [effectsError, setEffectsError] = useState(null); + const [form, setForm] = useState(EMPTY_FORM); const resetForm = () => { - setForm({ name: "", description: "", item_type: "streak_freeze", price_gems: 50, is_available: true, stock_quantity: undefined }); + setForm(EMPTY_FORM); + setEffectsError(null); setEditingId(null); }; + const applyTemplate = (itemType: string) => { + const template = ITEM_TYPE_TEMPLATES[itemType]; + if (!template) return; + setForm((prev) => ({ + ...prev, + item_type: itemType, + name: template.name, + description: template.description, + price_gems: template.price_gems, + effectsText: JSON.stringify(template.effects, null, 2), + })); + setEffectsError(null); + }; + const loadItems = async () => { setLoading(true); setError(null); @@ -62,12 +139,25 @@ export const ShopPage = () => { price_gems: item.price_gems, is_available: item.is_available, stock_quantity: item.stock_quantity ?? undefined, + icon_url: item.icon_url ?? "", + effectsText: JSON.stringify(item.effects ?? {}, null, 2), }); + setEffectsError(null); setShowForm(true); }; const handleSave = async (e: React.FormEvent) => { e.preventDefault(); + + let effects: Record | undefined; + try { + effects = form.effectsText.trim() ? JSON.parse(form.effectsText) : {}; + } catch { + setEffectsError(t.shop.effectsInvalidJson); + return; + } + setEffectsError(null); + setSaving(true); setError(null); try { @@ -78,9 +168,20 @@ export const ShopPage = () => { price_gems: form.price_gems, is_available: form.is_available, stock_quantity: form.stock_quantity ?? undefined, + icon_url: form.icon_url || undefined, + effects, }); } else { - await createShopItem(form); + await createShopItem({ + name: form.name, + description: form.description, + item_type: form.item_type, + price_gems: form.price_gems, + is_available: form.is_available, + stock_quantity: form.stock_quantity, + icon_url: form.icon_url || undefined, + effects, + }); } resetForm(); setShowForm(false); @@ -197,9 +298,31 @@ export const ShopPage = () => { {/* Create/Edit Modal */} {showForm && (
setShowForm(false)}> -
e.stopPropagation()} style={{ maxWidth: 520 }}> +
e.stopPropagation()} style={{ maxWidth: 560 }}>

{editingId ? t.shop.editItem : t.shop.createNew}

+
+ + {!editingId && ( + + )} +