diff --git a/README.md b/README.md index 17fd332..3bacc7e 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Lingput - AI-Powered Comprehensible Input for Language Learning -[![Tests](https://github.com/mark-mdev/lingput/actions/workflows/pr-tests.yml/badge.svg)](https://github.com/mark-mdev/lingput/actions/workflows/pr-tests.yml) -[![Deploy](https://github.com/mark-mdev/lingput/actions/workflows/deploy.yml/badge.svg)](https://github.com/mark-mdev/lingput/actions/workflows/deploy.yml) +[![Tests](https://github.com/markmdev/lingput/actions/workflows/pr-tests.yml/badge.svg)](https://github.com/markmdev/lingput/actions/workflows/pr-tests.yml) +[![Deploy](https://github.com/markmdev/lingput/actions/workflows/deploy.yml/badge.svg)](https://github.com/markmdev/lingput/actions/workflows/deploy.yml) **Demo:** https://lingput.dev/ @@ -18,15 +18,15 @@ Unlike generic flashcard apps, Lingput adapts to your vocabulary and provides ** ## Architectural & Technical Highlights -This project was built to production-grade standards, demonstrating expertise in full-stack development, system design, and scalability. Here are the key technical features: - - **Scalable Background Processing:** Utilizes a robust **Job Queue System (BullMQ & Redis)** to handle complex, long-running AI tasks (story and audio generation) asynchronously. This ensures the API remains fast and responsive, providing a seamless user experience with real-time progress updates on the frontend. - **Clean Backend Architecture:** The Express.js backend is built on a **testable, multi-layered architecture** (Controller, Service, Repository) with **Dependency Injection** for loose coupling and maintainability. -- **Robust Caching Strategy:** Leverages **Redis** for caching frequently accessed data (like stories and word lists), significantly reducing database load and improving API response times. +- **Robust Caching Strategy:** Leverages **Redis** for caching frequently accessed data (like stories and word lists), significantly **reducing database load** and improving API response times. - **Secure Authentication:** Implements a secure, modern authentication system using **HTTP-only cookies** with access and refresh tokens to protect against XSS attacks. - **Advanced Frontend State Management:** The Next.js frontend features a **custom React hook (`handleJob`)** to intelligently manage the lifecycle of background jobs, abstracting away the complexity of polling and providing optimistic UI updates. - **Containerized for Production:** The entire application is containerized using **Docker and Docker Compose**, ensuring consistent, reproducible deployments for all services (backend, frontend, workers, NGINX). +Full tech stack: [Tech Stack](#tech-stack) + --- ## CI/CD @@ -42,7 +42,7 @@ This repo ships with a simple, reliable pipeline built around **Docker**, **GitH On every **Pull Request** and on **pushes to `main`**, GitHub Actions runs: -- **ESLint** for the codebase. +- **ESLint**. - **Unit/Integration tests**. - Dependency caching to keep CI fast. @@ -71,7 +71,6 @@ On every **Pull Request** and on **pushes to `main`**, GitHub Actions runs: - [Features](#features) - [Tech Stack](#tech-stack) - [Quickstart](#quickstart) -- [Production Deploy](#production-deploy) - [Roadmap](#roadmap) - [Contributing](#contributing) - [License](#license) @@ -88,14 +87,14 @@ On every **Pull Request** and on **pushes to `main`**, GitHub Actions runs: ## Features -- 🔐 **Auth with secure cookies** - register/login with HTTP-only tokens, refresh flow included. -- 📊 **Vocabulary assessment** - quick test estimates your vocab size using a frequency list. -- 📚 **Personalized story generation** - AI generates stories with your known words (plus a few new). -- 🌍 **Chunked translation** - story is split into chunks with translations for easier comprehension. -- 🎧 **Audio generation** - full audio track (story + translations with pauses), stored in Supabase. -- 📝 **Smart word tracking** - The app doesn't just show translations, it saves words with examples and helps you track your progress. -- ⚡ **Background jobs** - BullMQ workers handle long-running tasks with progress updates. -- 🚀 **Caching** - Redis caches stories and word lists for fast responses. +- **Auth with secure cookies** - register/login with HTTP-only tokens, refresh flow included. +- **Vocabulary assessment** - quick test estimates your vocab size using a frequency list. +- **Personalized story generation** - AI generates stories with your known words (plus a few new). +- **Chunked translation** - story is split into chunks with translations for easier comprehension. +- **Audio generation** - full audio track (story + translations with pauses), stored in Supabase. +- **Smart word tracking** - The app doesn't just show translations, it saves words with examples and helps you track your progress. +- **Background jobs** - BullMQ workers handle long-running tasks with progress updates. +- **Caching** - Redis caches stories and word lists for fast responses. --- @@ -151,7 +150,7 @@ On every **Pull Request** and on **pushes to `main`**, GitHub Actions runs: ```bash # Clone the repository -git clone https://github.com/mark-mdev/lingput +git clone https://github.com/markmdev/lingput ``` Create `.env` files for backend and frontend: @@ -185,25 +184,6 @@ App: [http://localhost:3050](http://localhost:3050) --- -## Production Deploy - -Use `docker-compose.yml` with prebuilt images: - -- `markmdev/lingput-backend` -- `markmdev/lingput-worker` -- `markmdev/lingput-frontend` -- `markmdev/lingput-lemma` -- `markmdev/lingput-nginx` - -Steps: - -1. Build & push images to your registry. -2. Update image names in `docker-compose.yml`. -3. Provide production env vars (`OPENAI_API_KEY`, `SUPABASE_URL`, `SUPABASE_SERVICE_API_KEY`, `DATABASE_URL`, `REDIS_HOST`, `REDIS_PORT`, `JWT_SECRET`, etc). -4. Expose NGINX (`80` by default). - ---- - ## Contributing Contributions welcome! diff --git a/apps/backend/.env.defaults b/apps/backend/.env.defaults index 9dd52a5..20e922e 100644 --- a/apps/backend/.env.defaults +++ b/apps/backend/.env.defaults @@ -2,4 +2,4 @@ REDIS_HOST=redis REDIS_PORT=6379 DATABASE_URL=postgresql://lingput:cmpinputpswd@postgres:5432/lingput?schema=public APP_PORT=4000 -LEMMA_SERVICE_URL=http://lemma:8000 \ No newline at end of file +LEMMA_SERVICE_URL=http://lemmas:8000 \ No newline at end of file diff --git a/apps/backend/src/modules/story/services/lemmaAssembler/lemmatizationService.ts b/apps/backend/src/modules/story/services/lemmaAssembler/lemmatizationService.ts index 5b7707c..4a82fb7 100644 --- a/apps/backend/src/modules/story/services/lemmaAssembler/lemmatizationService.ts +++ b/apps/backend/src/modules/story/services/lemmaAssembler/lemmatizationService.ts @@ -38,8 +38,8 @@ export class LemmatizationService { let response: OpenAIResponse; try { response = await this.openai.responses.create({ - model: "gpt-5-mini", - reasoning: { effort: "low" }, + model: "gpt-5-nano", + reasoning: { effort: "minimal" }, input: [ { role: "system", diff --git a/apps/backend/src/modules/story/services/storyAssembler/storyGeneratorService.ts b/apps/backend/src/modules/story/services/storyAssembler/storyGeneratorService.ts index e358fe3..9141d1b 100644 --- a/apps/backend/src/modules/story/services/storyAssembler/storyGeneratorService.ts +++ b/apps/backend/src/modules/story/services/storyAssembler/storyGeneratorService.ts @@ -13,8 +13,8 @@ export class StoryGeneratorService { let response: OpenAIResponse; try { response = await this.openai.responses.create({ - model: "gpt-5-mini", - reasoning: { effort: "low" }, + model: "gpt-5", + reasoning: { effort: "minimal" }, input: [ { role: "system", diff --git a/apps/backend/src/modules/story/services/storyAssembler/translationService.ts b/apps/backend/src/modules/story/services/storyAssembler/translationService.ts index dcf70be..c59d53e 100644 --- a/apps/backend/src/modules/story/services/storyAssembler/translationService.ts +++ b/apps/backend/src/modules/story/services/storyAssembler/translationService.ts @@ -21,8 +21,8 @@ export class TranslationService { let response: OpenAIResponse; try { response = await this.openai.responses.create({ - model: "gpt-5-mini", - reasoning: { effort: "low" }, + model: "gpt-5", + reasoning: { effort: "minimal" }, input: [ { role: "system", diff --git a/apps/backend/src/modules/vocabAssessment/vocabAssessmentController.ts b/apps/backend/src/modules/vocabAssessment/vocabAssessmentController.ts index 8fe4e51..fa0cfac 100644 --- a/apps/backend/src/modules/vocabAssessment/vocabAssessmentController.ts +++ b/apps/backend/src/modules/vocabAssessment/vocabAssessmentController.ts @@ -13,6 +13,13 @@ const answerSchema = z.object({ export class VocabAssessmentController { constructor(private vocabAssessmentService: VocabAssessmentService) {} + skip = async (req: AuthedRequest, res: Response) => { + const user = req.user; + + await this.vocabAssessmentService.skipAssessment(user.userId, "en", "de"); + res.status(200).json(formatResponse({ success: true })); + }; + start = async (req: AuthedRequest, res: Response) => { const user = req.user; diff --git a/apps/backend/src/modules/vocabAssessment/vocabAssessmentRoutes.ts b/apps/backend/src/modules/vocabAssessment/vocabAssessmentRoutes.ts index 35ee5db..0915f2a 100644 --- a/apps/backend/src/modules/vocabAssessment/vocabAssessmentRoutes.ts +++ b/apps/backend/src/modules/vocabAssessment/vocabAssessmentRoutes.ts @@ -9,6 +9,7 @@ export function buildVocabAssessmentRouter( const router = Router(); router.get("/start", authMiddleware, asyncHandler(controller.start)); + router.post("/skip", authMiddleware, asyncHandler(controller.skip)); router.post("/answer", authMiddleware, asyncHandler(controller.answer)); return router; diff --git a/apps/backend/src/modules/vocabAssessment/vocabAssessmentService.ts b/apps/backend/src/modules/vocabAssessment/vocabAssessmentService.ts index 33f9b2a..453f235 100644 --- a/apps/backend/src/modules/vocabAssessment/vocabAssessmentService.ts +++ b/apps/backend/src/modules/vocabAssessment/vocabAssessmentService.ts @@ -31,6 +31,21 @@ export class VocabAssessmentService { private redisWordsCache: RedisWordsCache, ) {} + async skipAssessment( + userId: number, + sourceLanguage: string, + targetLanguage: string, + ) { + const words = await this.getWordRanking(sourceLanguage, targetLanguage); + const knownVocabulary = words.slice(0, 50); + const vocabularyDTO: UserVocabularyDTO[] = knownVocabulary.map((word) => ({ + word: word.word, + translation: word.translation, + article: null, + })); + await this.vocabularyService.saveManyWords(vocabularyDTO, userId); + } + async startAssessment( userId: number, sourceLanguage: string, @@ -113,23 +128,7 @@ export class VocabAssessmentService { }; } - let words: WordRanking[] | null; - words = await this.redisWordsCache.getWords(sourceLanguage, targetLanguage); - if (!words) { - words = await this.vocabAssessmentRepository.getWords( - sourceLanguage, - targetLanguage, - ); - try { - await this.redisWordsCache.saveWords( - sourceLanguage, - targetLanguage, - words, - ); - } catch (error) { - logger.error("[cache] Failed to save words in Redis", error); - } - } + const words = await this.getWordRanking(sourceLanguage, targetLanguage); const wordsToReview = state.wordsToReview; const result = this.checkAnswer(answer, wordsToReview); @@ -191,6 +190,31 @@ export class VocabAssessmentService { }; } + private async getWordRanking( + sourceLanguage: string, + targetLanguage: string, + ): Promise { + let words: WordRanking[] | null; + words = await this.redisWordsCache.getWords(sourceLanguage, targetLanguage); + if (!words) { + words = await this.vocabAssessmentRepository.getWords( + sourceLanguage, + targetLanguage, + ); + try { + await this.redisWordsCache.saveWords( + sourceLanguage, + targetLanguage, + words, + ); + } catch (error) { + logger.error("[cache] Failed to save words in Redis", error); + } + } + + return words; + } + private checkAnswer( answer: Record, wordsToReview: WordRanking[], diff --git a/apps/frontend/src/app/(protected)/dashboard/page.tsx b/apps/frontend/src/app/(protected)/dashboard/page.tsx index 150387e..cc8684e 100644 --- a/apps/frontend/src/app/(protected)/dashboard/page.tsx +++ b/apps/frontend/src/app/(protected)/dashboard/page.tsx @@ -15,7 +15,7 @@ import useAuthRedirect from "@/features/auth/hooks/useAuthRedirect"; export default function DashboardPage() { const { viewMode, setViewMode, chosenStoryId } = useViewMode(); const { stories, error, isLoading, mutateStories } = useStories(); - const { wordsCount } = useWordsCount(); + const { wordsCount, isWordsCountLoading } = useWordsCount(); const chosenStory = stories?.find((s) => String(s.id) === chosenStoryId) ?? null; const { setWordStatus } = useWordStatus(chosenStory, mutateStories); @@ -38,6 +38,7 @@ export default function DashboardPage() { setViewMode("newStory")} handleChangeToAllStoriesViewMode={() => setViewMode("allStories")} diff --git a/apps/frontend/src/app/(protected)/vocab-assessment/page.tsx b/apps/frontend/src/app/(protected)/vocab-assessment/page.tsx index 1cfb9ac..c1c663a 100644 --- a/apps/frontend/src/app/(protected)/vocab-assessment/page.tsx +++ b/apps/frontend/src/app/(protected)/vocab-assessment/page.tsx @@ -19,7 +19,7 @@ export default function VocabAssessmentPage() { sessionUUID ? "loading" : "ready" ); - const { wordsCount, isLoading } = useWordsCount(); + const { wordsCount, isWordsCountLoading } = useWordsCount(); const [apiResponse, setApiResponse] = useState(null); const [answer, setAnswer] = useState>({}); @@ -32,14 +32,18 @@ export default function VocabAssessmentPage() { useEffect(() => { const fetch = async () => { if (sessionUUID && apiResponse === null) { - const clientApi = new ClientApi(); - const vocabAssessmentApi = new VocabAssessmentApi(clientApi); - const result = await vocabAssessmentApi.continue(sessionUUID); - setApiResponse(result); - if (result.status === "active") { - setStatus("started"); - } else if (result.status === "completed") { - setStatus("completed"); + try { + const clientApi = new ClientApi(); + const vocabAssessmentApi = new VocabAssessmentApi(clientApi); + const result = await vocabAssessmentApi.continue(sessionUUID); + setApiResponse(result); + if (result.status === "active") { + setStatus("started"); + } else if (result.status === "completed") { + setStatus("completed"); + } + } catch { + throw new Error("Unknown server error"); } } }; @@ -98,7 +102,7 @@ export default function VocabAssessmentPage() { return ( { + if (called.current) return; + called.current = true; + const skip = async () => { + await vocabAssessmentApi.skip(); + wordsCountUpdate(); + router.replace("/dashboard"); + }; + + skip(); + }, [router]); + + return

Loading...

; +} diff --git a/apps/frontend/src/app/error.tsx b/apps/frontend/src/app/error.tsx index e468317..8e18cd9 100644 --- a/apps/frontend/src/app/error.tsx +++ b/apps/frontend/src/app/error.tsx @@ -11,7 +11,6 @@ export default function Error({ reset: () => void; }) { useEffect(() => { - console.error(error); toast.error(error.message); }, [error]); diff --git a/apps/frontend/src/components/AssessmentRequiredOverlay.tsx b/apps/frontend/src/components/AssessmentRequiredOverlay.tsx index 4b056b5..4a96650 100644 --- a/apps/frontend/src/components/AssessmentRequiredOverlay.tsx +++ b/apps/frontend/src/components/AssessmentRequiredOverlay.tsx @@ -1,6 +1,10 @@ import Link from "next/link"; -export default function AssessmentRequiredOverlay({ wordsCount }: { wordsCount: number }) { +export default function AssessmentRequiredOverlay({ + wordsCount, +}: { + wordsCount: number | undefined; +}) { if (wordsCount !== 0) return null; return ( @@ -11,12 +15,21 @@ export default function AssessmentRequiredOverlay({ wordsCount }: { wordsCount: Please complete the vocab-assessment first so we can prepare personalized stories for you.

- - Take assessment - +

You can skip it for the demo purposes.

+
+ + Skip and Continue + + + Take assessment + +
); diff --git a/apps/frontend/src/components/Dashboard.tsx b/apps/frontend/src/components/Dashboard.tsx index 4ce6a57..d937340 100644 --- a/apps/frontend/src/components/Dashboard.tsx +++ b/apps/frontend/src/components/Dashboard.tsx @@ -20,6 +20,7 @@ import { toast } from "react-toastify"; export default function Dashboard({ wordsCount, + isWordsCountLoading, viewMode, handleChangeToNewStoryViewMode, handleChangeToAllStoriesViewMode, @@ -31,6 +32,7 @@ export default function Dashboard({ mutate, }: { wordsCount: number | undefined; + isWordsCountLoading: boolean; viewMode: "chosenStory" | "newStory" | "allStories"; handleChangeToNewStoryViewMode: () => void; handleChangeToAllStoriesViewMode: () => void; @@ -70,7 +72,9 @@ export default function Dashboard({ return (
- + {showOnboarding && !hasIntroShown && } {showOnboarding && hasIntroShown && coachmarkIndex !== null && ( - +
+ + +
)} {status === "started" && (
+

+ + Skip assessment + +

Step {apiResponse?.step}{" "} {apiResponse?.lastStep ? (Last) : ""} diff --git a/apps/frontend/src/features/dashboard/components/RightPanel.tsx b/apps/frontend/src/features/dashboard/components/RightPanel.tsx index d22ceec..788ff3a 100644 --- a/apps/frontend/src/features/dashboard/components/RightPanel.tsx +++ b/apps/frontend/src/features/dashboard/components/RightPanel.tsx @@ -7,7 +7,7 @@ export default function RightPanel({ }) { return (

{children}
diff --git a/apps/frontend/src/features/vocab/hooks/useWordsCount.ts b/apps/frontend/src/features/vocab/hooks/useWordsCount.ts index 2f8d307..da4392e 100644 --- a/apps/frontend/src/features/vocab/hooks/useWordsCount.ts +++ b/apps/frontend/src/features/vocab/hooks/useWordsCount.ts @@ -5,6 +5,8 @@ import useSWR from "swr"; export default function useWordsCount() { const clientApi = new ClientApi(); const vocabApi = new VocabApi(clientApi); - const { data, isLoading } = useSWR("/api/vocab/words-count", () => vocabApi.getWordsCount()); - return { wordsCount: data, isLoading }; + const { data, isLoading, mutate } = useSWR("/api/vocab/words-count", () => + vocabApi.getWordsCount() + ); + return { wordsCount: data, isWordsCountLoading: isLoading, wordsCountUpdate: mutate }; } diff --git a/apps/frontend/src/features/vocabAssessment/api.ts b/apps/frontend/src/features/vocabAssessment/api.ts index 0bae166..13feeb5 100644 --- a/apps/frontend/src/features/vocabAssessment/api.ts +++ b/apps/frontend/src/features/vocabAssessment/api.ts @@ -2,6 +2,10 @@ import { AssessmentResponse } from "./types"; import { BackendApi } from "@/lib/backendApi"; export class VocabAssessmentApi extends BackendApi { + skip() { + return this.post("/api/vocab-assessment/skip"); + } + start(): Promise { return this.fetch("/api/vocab-assessment/start"); } diff --git a/apps/frontend/src/features/vocabAssessment/components/SkipButton.tsx b/apps/frontend/src/features/vocabAssessment/components/SkipButton.tsx new file mode 100644 index 0000000..e9176f4 --- /dev/null +++ b/apps/frontend/src/features/vocabAssessment/components/SkipButton.tsx @@ -0,0 +1,12 @@ +import Link from "next/link"; + +export default function SkipButton() { + return ( + + Skip + + ); +} diff --git a/apps/landing/components/landing/Footer.tsx b/apps/landing/components/landing/Footer.tsx index e1d78a3..25ec024 100644 --- a/apps/landing/components/landing/Footer.tsx +++ b/apps/landing/components/landing/Footer.tsx @@ -16,7 +16,7 @@ export default function Footer() {