Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
41613c4
fix(auth): block login/refresh for unverified email accounts
InfinityZero3000 Jun 23, 2026
1d2aede
fix(auth): backfill legacy verification, allow Google to vouch for lo…
InfinityZero3000 Jun 24, 2026
e2360c1
feat(flutter): map AUTH_FORBIDDEN backend error code to AuthFailure
InfinityZero3000 Jun 24, 2026
6352045
refactor(flutter): separate chat domain entities from data-layer models
InfinityZero3000 Jun 24, 2026
36e8c53
refactor(ai-service): extract lexi_chat pipeline orchestration into a…
InfinityZero3000 Jun 24, 2026
d122cdd
fix(backend): wire up the already-split admin routers, delete dead ad…
InfinityZero3000 Jun 24, 2026
22373ca
refactor(ai-service): split retrieve_node/generate_node out of nodes_…
InfinityZero3000 Jun 24, 2026
a718f7a
refactor(flutter): split profile_page.dart into section widgets
InfinityZero3000 Jun 24, 2026
48fea7d
refactor(flutter): split home_page.dart into section widgets
InfinityZero3000 Jun 24, 2026
b72c2ca
fix(ranking): correct benchmark mode string for adaptive mode
InfinityZero3000 Jun 25, 2026
9917ba3
feat(ai-service): native-language tutoring, STT dedup, Groq round-rob…
InfinityZero3000 Jun 26, 2026
fa806e4
feat(backend): game power-up shop, idempotent challenge claims, servi…
InfinityZero3000 Jun 26, 2026
91566ed
feat(admin): shop CRUD via JSON body with effects/icon_url and power-…
InfinityZero3000 Jun 26, 2026
45b5fef
feat(flutter): in-game power-up tray, native-language chat, league ce…
InfinityZero3000 Jun 26, 2026
b59d1c9
fix(backend): harden power-up migration snapshot
InfinityZero3000 Jun 26, 2026
2cdfab1
fix(admin): regenerate package-lock.json in sync with package.json
InfinityZero3000 Jun 26, 2026
0c13301
fix(backend): pin kombu<5.6 to satisfy celery 5.5 dependency resolution
InfinityZero3000 Jun 26, 2026
9efac21
chore(backend): gitignore CI test artifacts (coverage.xml, pytest/pro…
InfinityZero3000 Jun 29, 2026
55111fa
feat: add quiz functionality and refactor daily review card
InfinityZero3000 Jun 29, 2026
51ab7a5
feat: add product expansion suite
InfinityZero3000 Jul 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
.DEFAULT_GOAL := help

APP_DIR := lexilingo_app
APP_DIR := flutter-app
FLUTTER ?= flutter
DART ?= dart

Expand Down
230 changes: 114 additions & 116 deletions admin-service/package-lock.json

Large diffs are not rendered by default.

9 changes: 8 additions & 1 deletion admin-service/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import {
LayoutDashboard, Users, BookOpen, Layers, FileText,
PenTool, BarChart3, Languages, Trophy, ShoppingBag,
Megaphone, ScrollText, Activity, Settings,
Shield, Database, Bot, ArrowRight, ArrowLeft, MessageSquare, Swords, Bell
Shield, Database, Bot, ArrowRight, ArrowLeft, MessageSquare, Swords, Bell,
ClipboardCheck, Gauge
} from "lucide-react";

// Lazy-loaded components
Expand All @@ -31,9 +32,11 @@ const AdsPage = lazy(() => import("./pages/AdsPage").then(m => ({ default: m.Ads
const LogsPage = lazy(() => import("./pages/LogsPage").then(m => ({ default: m.LogsPage })));
const MonitoringPage = lazy(() => import("./pages/MonitoringPage").then(m => ({ default: m.MonitoringPage })));
const ContentLabPage = lazy(() => import("./pages/ContentLabPage").then(m => ({ default: m.ContentLabPage })));
const ContentQaQueuePage = lazy(() => import("./pages/ContentQaQueuePage").then(m => ({ default: m.ContentQaQueuePage })));
const DatabasePage = lazy(() => import("./pages/DatabasePage").then(m => ({ default: m.DatabasePage })));
const AiModelsPage = lazy(() => import("./pages/AiModelsPage").then(m => ({ default: m.AiModelsPage })));
const ContentAnalyticsPage = lazy(() => import("./pages/ContentAnalyticsPage").then(m => ({ default: m.ContentAnalyticsPage })));
const AiQualityDashboardPage = lazy(() => import("./pages/AiQualityDashboardPage").then(m => ({ default: m.AiQualityDashboardPage })));
const SystemSettingsPage = lazy(() => import("./pages/SystemSettingsPage").then(m => ({ default: m.SystemSettingsPage })));
const AdminManagementPage = lazy(() => import("./pages/AdminManagementPage").then(m => ({ default: m.AdminManagementPage })));
const AiChatSettingsPage = lazy(() => import("./pages/AiChatSettingsPage").then(m => ({ default: m.AiChatSettingsPage })));
Expand All @@ -60,7 +63,9 @@ const AppRoutes = () => {
{ to: "/admin/lessons", label: t.nav.lessons, icon: <FileText size={18} /> },
{ to: "/admin/topics", label: t.nav.topics, icon: <MessageSquare size={18} /> },
{ to: "/admin/content-lab", label: t.nav.grammarTest, icon: <PenTool size={18} /> },
{ to: "/admin/content-qa", label: t.nav.contentQaQueue, icon: <ClipboardCheck size={18} /> },
{ to: "/admin/content-analytics", label: t.nav.contentAnalytics, icon: <BarChart3 size={18} /> },
{ to: "/admin/ai-quality", label: t.nav.aiQuality, icon: <Gauge size={18} /> },
{ to: "/admin/vocabulary", label: t.nav.vocabulary, icon: <Languages size={18} /> },
{ to: "/admin/achievements", label: t.nav.achievements, icon: <Trophy size={18} /> },
{ to: "/admin/shop", label: t.nav.shop, icon: <ShoppingBag size={18} /> },
Expand Down Expand Up @@ -104,7 +109,9 @@ const AppRoutes = () => {
<Route path="/admin/lessons" element={<LessonsPage />} />
<Route path="/admin/topics" element={<TopicsPage />} />
<Route path="/admin/content-lab" element={<ContentLabPage />} />
<Route path="/admin/content-qa" element={<ContentQaQueuePage />} />
<Route path="/admin/content-analytics" element={<ContentAnalyticsPage />} />
<Route path="/admin/ai-quality" element={<AiQualityDashboardPage />} />
<Route path="/admin/vocabulary" element={<VocabularyPage />} />
<Route path="/admin/achievements" element={<AchievementsPage />} />
<Route path="/admin/shop" element={<ShopPage />} />
Expand Down
25 changes: 15 additions & 10 deletions admin-service/src/lib/adminApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,8 @@ export const uploadBadgeImage = async (file: File): Promise<AdminResponse<{ url:
// Shop Management
// ============================================================================

export type ShopItemEffects = Record<string, unknown>;

export type ShopItemType = {
id: string;
name: string;
Expand All @@ -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) =>
Expand All @@ -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<AdminResponse<ShopItemType>>(url.toString(), { method: "POST" });
};
icon_url?: string;
effects?: ShopItemEffects;
}) =>
apiFetch<AdminResponse<ShopItemType>>(`${ENV.backendUrl}/admin/shop`, {
method: "POST",
body: JSON.stringify(params),
});

export const updateShopItem = async (id: string, params: Partial<ShopItemType>) => {
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<AdminResponse<ShopItemType>>(url.toString(), { method: "PUT" });
};
export const updateShopItem = async (id: string, params: Partial<ShopItemType>) =>
apiFetch<AdminResponse<ShopItemType>>(`${ENV.backendUrl}/admin/shop/${id}`, {
method: "PUT",
body: JSON.stringify(params),
});

export const deleteShopItem = async (id: string) =>
apiFetch<AdminResponse<{ deleted: boolean }>>(`${ENV.backendUrl}/admin/shop/${id}`, {
Expand Down
37 changes: 37 additions & 0 deletions admin-service/src/lib/aiQualityApi.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

const { apiFetchMock } = vi.hoisted(() => ({
apiFetchMock: vi.fn(),
}));

vi.mock("./api", () => ({
apiFetch: apiFetchMock,
}));

vi.mock("./env", () => ({
ENV: {
backendUrl: "https://backend.example/api/v1",
},
}));

import { getAiQualitySummary } from "./aiQualityApi";

describe("aiQualityApi", () => {
beforeEach(() => {
apiFetchMock.mockReset();
apiFetchMock.mockResolvedValue({
events: [],
summary: { total_events: 0 },
total: 0,
source: "ai:audit:all",
});
});

it("loads the backend AI audit quality summary with a bounded limit", async () => {
await getAiQualitySummary(250);

expect(apiFetchMock).toHaveBeenCalledWith(
"https://backend.example/api/v1/ai-audit/quality-summary?limit=250",
);
});
});
58 changes: 58 additions & 0 deletions admin-service/src/lib/aiQualityApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { apiFetch } from "./api";
import { ENV } from "./env";

export type AiQualityEndpointBreakdown = {
endpoint: string;
total: number;
failures: number;
average_latency_ms: number;
};

export type AiQualityFailure = {
request_id?: string | null;
user_id?: string | null;
endpoint?: string | null;
status?: string | null;
latency_ms?: number | null;
received_at?: string | null;
error?: string | null;
};

export type AiQualitySummary = {
total_events: number;
success_count: number;
failure_count: number;
success_rate: number;
average_latency_ms: number;
p95_latency_ms: number;
lexi: {
events: number;
failures: number;
average_latency_ms: number;
};
stt: {
failures: number;
};
tts: {
failures: number;
};
correction: {
events: number;
failures: number;
average_score: number;
};
endpoint_breakdown: AiQualityEndpointBreakdown[];
latest_failures: AiQualityFailure[];
};

export type AiQualityResponse = {
events: unknown[];
summary: AiQualitySummary;
total: number;
source: string;
};

export const getAiQualitySummary = (limit = 500) =>
apiFetch<AiQualityResponse>(
`${ENV.backendUrl}/ai-audit/quality-summary?limit=${encodeURIComponent(limit)}`,
);
34 changes: 34 additions & 0 deletions admin-service/src/lib/contentAgentApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@ vi.mock("./env", () => ({

import {
applyContentAgentJob,
buildContentQaQueue,
cancelContentAgentJob,
createContentAgentJob,
getContentAgentJob,
getContentAgentPreview,
listContentAgentJobs,
listContentQaQueue,
retryContentAgentJob,
uploadContentAgentFile,
} from "./contentAgentApi";
Expand Down Expand Up @@ -121,4 +123,36 @@ describe("contentAgentApi", () => {
total: 4,
});
});

it("builds a content QA queue from agent job statuses", () => {
const queue = buildContentQaQueue([
{ id: "ready", status: "preview_ready" },
{ id: "failed", status: "failed" },
{ id: "done", status: "completed" },
{ id: "running", status: "generating" },
] as any);

expect(queue.total).toBe(4);
expect(queue.reviewable.map((job) => job.id)).toEqual(["ready"]);
expect(queue.failed.map((job) => job.id)).toEqual(["failed"]);
expect(queue.applied.map((job) => job.id)).toEqual(["done"]);
});

it("loads content QA queue through the jobs endpoint", async () => {
apiFetchMock.mockResolvedValueOnce({
data: {
jobs: [
{ id: "job-1", status: "preview_ready" },
{ id: "job-2", status: "failed" },
],
total: 2,
},
});

await expect(listContentQaQueue()).resolves.toMatchObject({
reviewable: [{ id: "job-1" }],
failed: [{ id: "job-2" }],
total: 2,
});
});
});
27 changes: 27 additions & 0 deletions admin-service/src/lib/contentAgentApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,13 @@ export type ContentAgentJobList = {
total: number;
};

export type ContentQaQueue = {
reviewable: ContentAgentJob[];
failed: ContentAgentJob[];
applied: ContentAgentJob[];
total: number;
};

type ApiEnvelope<T> = {
success?: boolean;
data?: T;
Expand Down Expand Up @@ -297,6 +304,26 @@ export const listContentAgentJobs = async (): Promise<ContentAgentJobList> => {
};
};

export const buildContentQaQueue = (
jobs: readonly ContentAgentJob[],
): ContentQaQueue => {
const reviewable = jobs.filter((job) => job.status === "preview_ready");
const failed = jobs.filter((job) => job.status === "failed");
const applied = jobs.filter((job) => job.status === "completed");

return {
reviewable,
failed,
applied,
total: jobs.length,
};
};

export const listContentQaQueue = async (): Promise<ContentQaQueue> => {
const { jobs } = await listContentAgentJobs();
return buildContentQaQueue(jobs);
};

export const getContentAgentJob = async (
jobId: string,
): Promise<ContentAgentJob> => {
Expand Down
9 changes: 9 additions & 0 deletions admin-service/src/lib/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ const en: Translations = {
lessons: "Lessons",
topics: "Topics",
grammarTest: "Grammar & Tests",
contentQaQueue: "Content QA",
contentAnalytics: "Content Analytics",
aiQuality: "AI Quality",
vocabulary: "Vocabulary",
achievements: "Achievements",
shop: "Shop",
Expand Down Expand Up @@ -479,6 +481,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 ==============
Expand Down
9 changes: 9 additions & 0 deletions admin-service/src/lib/i18n/vi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ const vi = {
lessons: "Bài học",
topics: "Topic chat",
grammarTest: "Ngữ pháp & Kiểm tra",
contentQaQueue: "QA nội dung",
contentAnalytics: "Phân tích nội dung",
aiQuality: "Chất lượng AI",
vocabulary: "Từ vựng",
achievements: "Thành tựu",
shop: "Cửa hàng",
Expand Down Expand Up @@ -477,6 +479,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 ==============
Expand Down
Loading
Loading