Files
NordicFlow/LLM_CONTEXTS/stunning-system.txt
Kazimierz Ciołek 7ecefb5621 Initialize repo
2026-02-02 13:56:14 +01:00

10859 lines
403 KiB
Plaintext
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
Directory structure:
└── alphatra-stunning-system/
├── README.md
├── check_module.js
├── components.json
├── eslint.config.mjs
├── next.config.mjs
├── next.config.ts
├── package.json
├── postcss.config.mjs
├── tailwind.config.js
├── tsconfig.json
├── docs/
│ ├── agents.md
│ ├── architecture.md
│ ├── plan.md
│ ├── rules.md
│ └── spec.md
├── src/
│ ├── app/
│ │ ├── globals.css
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ ├── learn/
│ │ │ ├── [topic]/
│ │ │ │ └── page.tsx
│ │ │ └── analytics/
│ │ │ └── page.tsx
│ │ ├── profile/
│ │ │ └── history/
│ │ │ └── page.tsx
│ │ ├── settings/
│ │ │ └── page.tsx
│ │ ├── test-gen/
│ │ │ └── page.tsx
│ │ └── write/
│ │ └── page.tsx
│ ├── components/
│ │ ├── challenge/
│ │ │ └── DailyChallengeCard.tsx
│ │ ├── content/
│ │ │ └── syllabus.md
│ │ ├── learning/
│ │ │ └── LearningDashboard.tsx
│ │ ├── tutor/
│ │ │ ├── ExamRunner.tsx
│ │ │ ├── ExamSidebar.tsx
│ │ │ ├── FeedbackDisplay.tsx
│ │ │ ├── QuestionCard.tsx
│ │ │ ├── QuizOptionsDialog.tsx
│ │ │ ├── QuizReview.tsx
│ │ │ ├── QuizReviewDialog.tsx
│ │ │ ├── QuizRunner.tsx
│ │ │ ├── SpeakingDrill.tsx
│ │ │ ├── TheoryUploader.tsx
│ │ │ ├── TheoryViewer.tsx
│ │ │ ├── WritingCanvas.tsx
│ │ │ └── question/
│ │ │ ├── QuestionRenderer.tsx
│ │ │ ├── types.ts
│ │ │ ├── hooks/
│ │ │ │ └── useQuestionState.ts
│ │ │ └── renderers/
│ │ │ ├── Dictation.tsx
│ │ │ ├── ErrorCorrection.tsx
│ │ │ ├── GapFill.tsx
│ │ │ ├── MultipleChoice.tsx
│ │ │ └── SimpleInput.tsx
│ │ └── ui/
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ ├── dialog.tsx
│ │ ├── input.tsx
│ │ ├── progress.tsx
│ │ ├── scroll-area.tsx
│ │ ├── tabs.tsx
│ │ └── textarea.tsx
│ ├── content/
│ │ └── courses/
│ │ └── futuro-semplice.md
│ └── lib/
│ ├── utils.ts
│ ├── ai/
│ │ ├── actions.ts
│ │ ├── config.ts
│ │ ├── examples.ts
│ │ ├── explain.ts
│ │ ├── memory.ts
│ │ ├── prompts.ts
│ │ └── schemas.ts
│ ├── challenge/
│ │ └── daily.ts
│ ├── config/
│ │ └── topics.ts
│ ├── content/
│ │ ├── loader.ts
│ │ └── slicer.ts
│ ├── didactic/
│ │ └── utils.ts
│ ├── learning/
│ │ ├── curriculum.ts
│ │ ├── curriculumBuilder.ts
│ │ ├── curriculumIntegration.ts
│ │ ├── diagnostic.ts
│ │ ├── metrics.ts
│ │ ├── moduleProgress.ts
│ │ ├── planner.ts
│ │ ├── sessionPlan.ts
│ │ ├── sessionTemplates.ts
│ │ ├── skillState.ts
│ │ └── skillTree.ts
│ └── store/
│ ├── config.ts
│ ├── user.ts
│ └── useUserStore.ts
└── .snapshots/
├── readme.md
├── config.json
└── sponsors.md
================================================
FILE: README.md
================================================
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
================================================
FILE: check_module.js
================================================
try {
const path = require.resolve('@ai-sdk/openai');
console.log('Resolution Success:', path);
} catch (e) {
console.log('Resolution Failed:', e.message);
// debug sibling
try {
console.log('Sibling ai:', require.resolve('ai'));
} catch (e2) {
console.log('Sibling ai missing');
}
}
================================================
FILE: components.json
================================================
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}
================================================
FILE: eslint.config.mjs
================================================
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
================================================
FILE: next.config.mjs
================================================
/** @type {import('next').NextConfig} */
const nextConfig = {
// config options here
};
export default nextConfig;
================================================
FILE: next.config.ts
================================================
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
================================================
FILE: package.json
================================================
{
"name": "test_master",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.344.0",
"next": "14.2.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^10.1.0",
"remark-directive": "^4.0.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^2.2.0",
"tailwindcss-animate": "^1.0.7",
"unist-util-visit": "^5.0.0",
"zod": "^3.22.4",
"zustand": "^5.0.9"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"autoprefixer": "^10.4.0",
"eslint": "^8.0.0",
"eslint-config-next": "14.2.3",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.0.0"
}
}
================================================
FILE: postcss.config.mjs
================================================
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;
================================================
FILE: tailwind.config.js
================================================
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
content: [
'./pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}',
],
prefix: "",
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "#ea2a33", // New primary color
foreground: "#ffffff",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
// New Design System Colors
"background-light": "#f8f6f6",
"background-dark": "#211111",
"surface-light": "#ffffff",
"surface-dark": "#2d1b1b",
"text-main": "#1b0e0e",
"text-sub": "#994d51",
},
fontFamily: {
"display": ["var(--font-jakarta)", "sans-serif"], // Using CSS variable for Next.js font
},
borderRadius: {
lg: "1.5rem",
md: "1rem", // Default radius in new design is 1rem
sm: "0.5rem",
"xl": "2rem",
"2xl": "3rem",
},
boxShadow: {
'soft': '0 4px 20px -2px rgba(27, 14, 14, 0.05)',
'glow': '0 0 15px rgba(234, 42, 51, 0.3)',
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],
}
================================================
FILE: tsconfig.json
================================================
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": [
"node_modules"
]
}
================================================
FILE: docs/agents.md
================================================
# AGENTS CONFIGURATION
Jesteś zespołem trzech ekspertów inżynierii oprogramowania. Twoim celem jest przeprowadzenie Stakeholdera (Użytkownika) przez proces zbierania wymagań, a następnie wygenerowanie perfekcyjnej specyfikacji.
## 🎭 TWOJE PERSONY (Działasz jako jedna z nich w danej turze)
### 1. 👔 Product Manager (PM)
* **Cel:** Zrozumieć "dlaczego" i "co". Właściciel `spec.md`.
* **Styl:** Empatyczny, zorganizowany, nietechniczny.
* **Zadania:**
* Zbiera User Stories w formacie EARS.
* Definiuje Kryteria Akceptacji (Gherkin).
* Dba o to, by użytkownik nie czuł się przytłoczony.
### 2. 🎨 UX Researcher (UX)
* **Cel:** Zrozumieć "dla kogo" i "jak". Współtwórca `spec.md`.
* **Styl:** Wizualny, skupiony na przepływie.
* **Zadania:**
* Tworzy Mapy Podróży Użytkownika (Journey Maps).
* Opisuje ekrany i intencje (bez niskopoziomowych szczegółów UI).
### 3. 🏗️ Technical Architect (Tech)
* **Cel:** Zrozumieć "czym" i "gdzie". Właściciel `architecture.md`.
* **Styl:** Precyzyjny, analityczny, skupiony na SDD (Spec-Driven Development).
* **Zadania:**
* Projektuje schemat OpenAPI (Contract First).
* Definiuje stos technologiczny i granice systemu.
# AI Agent Roles & Responsibilities
## 1. Role: Product Owner & Pedagogical Architect
* **Focus:** User Value, Learning Science, Curriculum Structure.
* **Directives:**
* Ensure all generated exercises have a clear learning objective.
* Reject "lazy" multiple-choice questions unless strictly necessary.
* Validate that AI feedback explains *why* an answer is wrong (metalinguistic feedback), not just *what* is wrong.
* **Motto:** "We are building a teacher, not a quiz maker."
## 2. Role: Senior Frontend Developer (UI/UX)
* **Focus:** React (Next.js), Tailwind, Accessibility, State Management.
* **Directives:**
* Use `shadcn/ui` for a clean, distraction-free interface.
* Implement optimistic UI updates for instant feedback feel.
* Ensure the text input area for Writing tasks supports rich text highlighting (for error marking).
## 3. Role: AI Integration Engineer (Backend/Edge)
* **Focus:** Vercel AI SDK, Prompt Engineering, Structured Data (JSON).
* **Directives:**
* ALL LLM outputs must be forced into strict JSON Schema (using Zod).
* Implement caching strategies to save tokens on repeated theory requests.
* Design the "Context Injection" mechanism: How to feed 10 pages of PDF theory into the prompt context efficiently.
## 4. Role: QA & Compliance
* **Focus:** Edge cases, Hallucination checks, Type Safety.
* **Directives:**
* Test: What happens if the LLM generates invalid JSON? (Fallback mechanisms).
* Test: What happens if the user inputs Italian profanity in the Writing Lab?
---
## 🔄 PROTOKÓŁ ROZMOWY (Ściśle przestrzegaj)
### FAZA 1: Discovery (Wywiad)
1. **Start:** Przedstaw się jako PM i zadaj pytanie otwierające: *"Jestem Twoim Product Managerem. Opowiedz mi historię problemu, który chcesz rozwiązać?"*
2. **Pętla:**
* Zadawaj **TYLKO JEDNO** pytanie na raz.
* Unikaj żargonu technicznego w pytaniach do Stakeholdera.
* Persony wymieniają się pytaniami (np. runda 1: PM, runda 2: UX, runda 3: Tech), aby pokryć wszystkie aspekty.
* Kontynuuj przez minimum 3 rundy na eksperta (łącznie ~9 pytań), aż zbierzesz komplet wymagań.
### FAZA 2: Creation (Generowanie plików)
1. Po zebraniu danych, poinformuj użytkownika, że przystępujesz do tworzenia dokumentacji.
2. Zaktualizuj plik `spec.md` (wymagania).
3. Zaktualizuj plik `architecture.md` (rozwiązanie techniczne).
4. Zaktualizuj plik `plan.md` (status prac).
### FAZA 3: Feedback
1. Zaprezentuj podsumowanie zmian.
2. Zapytaj o akceptację.
================================================
FILE: docs/architecture.md
================================================
# SYSTEM ARCHITECTURE (UPDATED)
## 1. 🏗️ High-Level Design
System "Local-First AI". Aplikacja automatycznie dostosowuje się do środowiska użytkownika, wykrywając dostępne modele LLM.
```mermaid
graph TD
User[Użytkownik] -->|Przeglądarka| FE[Next.js App]
FE -->|API Request| BE[Next.js API Route]
BE -- 1. Check Models --> OllamaAPI[Ollama API /api/tags]
OllamaAPI -- 2. Return 'llama3' --> BE
BE -- 3. Generate(prompt) --> OllamaLLM[Local LLM (e.g. Llama3/Mistral)]
OllamaLLM -- 4. Structured JSON --> BE
BE -->|Response| FE
```
# Technical Architecture & Stack Definition
## 1. Core Stack
* **Framework:** Next.js 14+ (App Router).
* **Language:** TypeScript (Strict Mode).
* **Styling:** Tailwind CSS + Shadcn/UI (Radix Primitives).
* **State Management:** React Context or Zustand (for session state).
## 2. AI & Data Layer
* **LLM Provider:** OpenAI (gpt-4o-mini for speed/cost) or Anthropic (Claude 3.5 Sonnet for reasoning).
* **Orchestration:** Vercel AI SDK (`generateObject` for JSON, `streamText` for explanations).
* **Data Storage (MVP):**
* `src/data/theory`: Markdown/JSON files containing the parsed content from PDFs.
* `localStorage`: User progress and saved tests.
* **Data Storage (Future):** Supabase (PostgreSQL) for user accounts and vector embeddings (pgvector).
## 3. System Components
### A. The "Prompt Factory"
A dedicated module (`src/lib/ai/prompts`) that constructs dynamic prompts based on:
1. **Base Instructions:** (You are an Italian teacher...)
2. **Context:** (Chunks from `spec.md` theory files)
3. **User Constraints:** (Level A2, Focus: Verbs)
### B. The Validator (Zod)
No data from the LLM reaches the UI without passing through a Zod Schema validation.
* `TestSchema`: Array of Questions.
* `FeedbackSchema`: Array of Errors with indices and explanations.
## 4. Directory Structure
```text
/src
/app # Next.js App Router
/components
/ui # Shadcn components
/tutor # Domain specific (WritingArea, QuizCard)
/lib
/ai # Prompt templates, Server Actions
/content # Parsed PDF content (JSON/MD)
/types # TypeScript interfaces
================================================
FILE: docs/plan.md
================================================
# Implementation Plan
## Phase 1: Foundation & "Hello World"
- [ ] Initialize Next.js project with TypeScript & Tailwind.
- [ ] Install `shadcn/ui` core components (Button, Card, Textarea, Input).
- [ ] Set up OpenAI API / Vercel AI SDK env variables.
- [ ] Create simple `theory.json` mock data (manually extracted from PDF - e.g., "Futuro Semplice" table).
## Phase 2: The Generator Engine (Backend Logic)
- [ ] Define Zod Schemas for `QuizQuestion` and `QuizResult`.
- [ ] Implement Server Action: `generateQuiz(topic, difficulty)`.
- [ ] **Prompt Engineering:** Create the system prompt that accepts JSON theory and outputs JSON questions.
- [ ] Test generation of 3 types: Multiple Choice, Fill-in-the-gap, Scrambled Sentence.
## Phase 3: The Learning UI (Frontend)
- [ ] Build `TheoryViewer` component (renders Markdown text).
- [ ] Build `QuizRunner` component (handles user answers, shows progress).
- [ ] Implement immediate feedback UI (Green/Red highlights).
## Phase 4: The Writing Lab (Advanced AI)
- [ ] Build `WritingCanvas` (large text area).
- [ ] Implement `analyzeWriting` Server Action.
- [ ] Create `FeedbackOverlay`: A component that highlights text ranges based on AI response coordinates.
## Phase 5: Polish & Persistence
- [ ] Add loading skeletons (AI takes time).
- [ ] Save past quiz results to LocalStorage.
- [ ] Final Review against `rules.md`.
================================================
FILE: docs/rules.md
================================================
# PROJECT RULES & GUIDELINES
## 🛑 NON-NEGOTIABLE RULES
1. **Spec-First:** Żaden kod (poza prototypami) nie może powstać bez wcześniejszego zdefiniowania w `spec.md` i `architecture.md`.
2. **Contract-First:** Zmiany w API muszą zaczynać się od modyfikacji definicji OpenAPI/Swagger w `architecture.md` lub dedykowanym pliku `.yaml`.
3. **One Question Limit:** Podczas interakcji z użytkownikiem, nigdy nie zadawaj serii pytań. Zawsze czekaj na odpowiedź.
## 📝 DOCUMENTATION STANDARDS
* **User Stories:** Muszą używać składni EARS (Easy Approach to Requirements Syntax).
* *Wzór:* "When [trigger], the [system] shall [response]."
* **Diagrams:** Wszystkie diagramy muszą być renderowane w **Mermaid.js**.
* **Language:** Cała dokumentacja w języku polskim (chyba że kod wymaga angielskiego).
## 🛠 TECH STACK CONSTRAINTS
* **Frontend:** Next.js (App Router), Tailwind CSS, shadcn/ui.
* **Backend:** Python (FastAPI) LUB Node.js (Next.js API Routes).
* **Validation:** Zod (dla schematów JSON).
* **AI Integration:** Vercel AI SDK + `ollama-ai-provider`.
* **Endpoint Ollama:** `http://127.0.0.1:11434`.
## 💾 FILE MANAGEMENT
* Nie usuwaj treści bez wyraźnego polecenia.
* Zawsze aktualizuj `plan.md` po zakończeniu zadania.
# PROJECT RULES & GUIDELINES
## 🛑 NON-NEGOTIABLE RULES
1. **Local-First:** Domyślny tryb pracy to lokalna Ollama. Nie używaj kluczy API zewnętrznych dostawców (OpenAI/Anthropic) w kodzie produkcyjnym.
2. **Auto-Discovery:** Nie hardcoduj nazw modeli (np. "llama2"). Kod musi dynamicznie pobierać nazwę modelu z endpointu `/api/tags`.
# Operational Rules & Constraints
## 1. Coding Standards
* **TypeScript:** No `any`. Use strict interfaces for all AI responses.
* **Components:** Logic must be separated from UI. Use Custom Hooks for complex logic (`useQuizEngine`, `useAIWriting`).
* **Naming:** Variables related to AI data should be prefixed clearly (e.g., `aiGeneratedQuestions`, `userInputText`).
## 2. AI & Prompting Guidelines (The "Safety Rail")
* **JSON Only:** Always request JSON mode from the LLM to prevent parsing errors.
* **Language Enforcement:** System prompts must explicitly state: "All explanations in Polish, all exercises in Italian."
* **Theory Grounding:** The AI instructions must say: "Only use vocabulary and grammar rules present in the provided Context. Do not introduce C1 level words to an A1 student."
## 3. Pedagogical Design Rules
* **No "Yes/No" Questions:** Avoid binary questions; they have low educational value.
* **Feedback Mandatory:** Every error must be accompanied by an explanation field.
* *Bad:* "Wrong."
* *Good:* "Incorrect. 'Andrò' is Future Tense. We need Present Tense here ('Vado')."
* **Positive Reinforcement:** UI should celebrate streaks and correct answers visually.
## 4. Git & Workflow
* Update `plan.md` status after every commit.
* Never commit API keys.
================================================
FILE: docs/spec.md
================================================
# Project Specification: AI Language Tutor (MVP: Italian)
## 1. High-Level Concept
A web-based language learning platform that utilizes Generative AI to create dynamic, context-aware exercises based on uploaded theoretical material (PDFs/Notes). The system moves beyond simple quizzes, implementing **Second Language Acquisition (SLA)** principles like *Input Hypothesis (i+1)* and *Active Retrieval*.
## 2. Target Audience
* **Primary:** Italian language learners (Levels A1-B2).
* **Secondary:** Teachers creating automated assessments from their own notes.
## 3. Core Features
### 3.1 The "Context-Aware" Test Generator
* **Mechanism:** User selects a topic (e.g., "Futuro Semplice") and difficulty. The system retrieves relevant theory from `knowledge-base` (e.g., conjugation tables) and generates unique questions.
* **Supported Exercise Types:**
1. **Gap Fill (Cloze Tests):** Smart removal of auxiliary verbs (essere/avere) or prepositions.
2. **Syntactic Reordering:** Reassembling scrambled sentences to teach sentence structure (SVO).
3. **Error Detection:** The AI generates a plausible error (e.g., "Domani *vado* a fare la spesa" vs "andrò") and asks the user to fix it.
4. **Transformation Drills:** "Change this sentence from Presente to Passato Prossimo".
### 3.2 The Writing Lab ("Il Laboratorio di Scrittura")
* **Input:** Open-ended text field with a specific prompt (e.g., "Write a formal email to a professor canceling a meeting").
* **AI Analysis Pipeline:**
* **Strictness Level:** Configurable (Forgiving vs. Academic).
* **Feedback Categories:**
* 🔴 **Grammar:** Critical errors (conjugation, agreement).
* 🟠 **Vocabulary:** Repetitive words or false friends.
* 🔵 **Pragmatics:** Tone appropriateness (Tu vs. Lei).
* **Output:** Highlighted text with tooltip explanations, NOT just a rewritten version.
### 3.3 Theory Hub
* A clean, read-only view of the source material (Markdown/PDF content) linked directly to exercises.
* **Feature:** "Peek at Theory" during a test, user can reveal the specific rule (costing "mastery points").
## 4. User Flows
1. **Onboarding:** Select Language -> Upload/Select Theory Module.
2. **Practice Loop:** Choose Topic -> Generate Test -> Solve -> Receive Granular Feedback -> Retry failed concepts.
## 5. Non-Functional Requirements
* **Latency:** Test generation must take < 5 seconds.
* **Determinism:** The same seed should generate the same test (for review purposes).
* **Accuracy:** Hallucinations regarding grammar rules are strictly forbidden.
================================================
FILE: src/app/globals.css
================================================
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
================================================
FILE: src/app/layout.tsx
================================================
import type { Metadata } from "next";
import { Plus_Jakarta_Sans } from "next/font/google";
import "./globals.css";
import { Toaster } from 'sonner';
const jakarta = Plus_Jakarta_Sans({
subsets: ["latin"],
variable: '--font-jakarta',
});
export const metadata: Metadata = {
title: "Test Master: Learn Italian",
description: "AI-powered Italian language tutor",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body
className={`${jakarta.variable} font-display antialiased bg-background-light dark:bg-background-dark text-text-main`}
suppressHydrationWarning
>
{children}
<Toaster richColors position="top-center" />
</body>
</html>
);
}
================================================
FILE: src/app/page.tsx
================================================
'use client';
import React, { useState } from 'react';
import Link from 'next/link';
import { useUserStore } from '@/lib/store/useUserStore';
import { TOPICS, Topic } from '@/lib/config/topics';
import * as LucideIcons from 'lucide-react';
import { useRouter } from 'next/navigation';
import { generateQuiz } from '@/lib/ai/actions';
import { toast } from 'sonner';
// Helper component for dynamic icon
const DynamicIcon = ({ name, className }: { name: string; className?: string }) => {
const Icon = (LucideIcons as any)[name] || LucideIcons.BookOpen;
return <Icon className={className} />;
};
export default function Dashboard() {
const user = useUserStore();
const router = useRouter();
// Quiz Configuration State
const [level, setLevel] = useState('A2');
const [source, setSource] = useState<'syllabus' | 'ai'>('syllabus');
const [focus, setFocus] = useState('Gramatyka & Słownictwo (Mixed)');
const [tone, setTone] = useState('Standardowy (Neutral)');
const [loading, setLoading] = useState(false);
const startQuickQuiz = async () => {
setLoading(true);
try {
// In a real scenario, we'd use these config values
// For now, redirecting to a generic topic or using the first one
const topicId = TOPICS[0].id;
router.push(`/learn/${topicId}`);
} catch (error) {
toast.error("Nie udało się rozpocząć testu");
} finally {
setLoading(false);
}
};
const activeTopics = TOPICS; // Show all topics for now
return (
<div className="min-h-screen flex flex-col transition-colors duration-300">
{/* Top Navigation */}
<header className="sticky top-0 z-50 bg-background-light/80 dark:bg-background-dark/80 backdrop-blur-md border-b border-[#f3e7e8] dark:border-[#3a2a2a] px-4 md:px-10 py-3">
<div className="max-w-7xl mx-auto flex items-center justify-between whitespace-nowrap">
<div className="flex items-center gap-4 text-text-main dark:text-white">
<div className="size-8 text-primary flex items-center justify-center">
<span className="material-symbols-outlined filled text-3xl font-bold font-serif">TM</span>
</div>
<h2 className="text-text-main dark:text-white text-xl font-bold leading-tight tracking-[-0.015em]">Test Master</h2>
</div>
{/* Desktop Nav */}
<div className="hidden md:flex flex-1 justify-end gap-8 items-center">
<nav className="flex items-center gap-9">
<Link href="/" className="text-primary text-sm font-bold">Home</Link>
<Link href="/learn/analytics" className="text-text-main dark:text-white/80 hover:text-primary dark:hover:text-primary transition-colors text-sm font-medium">Progress</Link>
<Link href="/profile/history" className="text-text-main dark:text-white/80 hover:text-primary dark:hover:text-primary transition-colors text-sm font-medium">History</Link>
<Link href="/settings" className="text-text-main dark:text-white/80 hover:text-primary dark:hover:text-primary transition-colors text-sm font-medium">Settings</Link>
</nav>
<div className={`bg-center bg-no-repeat bg-cover rounded-full size-10 border-2 border-white dark:border-surface-dark shadow-sm bg-primary/20 text-primary flex items-center justify-center font-bold`}>
{user?.name?.[0] || 'U'}
</div>
</div>
{/* Mobile Menu Icon */}
<div className="md:hidden text-text-main dark:text-white">
<LucideIcons.Menu />
</div>
</div>
</header>
{/* Main Content Area */}
<div className="flex-grow w-full max-w-7xl mx-auto px-4 md:px-10 py-8">
<div className="flex flex-col lg:flex-row gap-8 lg:gap-12 items-start">
{/* Left Column: Content Stream */}
<div className="flex-1 w-full flex flex-col gap-6">
{/* Breadcrumbs */}
<nav className="flex flex-wrap gap-2 text-sm">
<Link href="/" className="text-text-sub dark:text-text-sub/80 font-medium hover:text-primary transition-colors">Home</Link>
<span className="text-text-sub dark:text-text-sub/80 font-medium">/</span>
<span className="text-text-main dark:text-white font-semibold">Italian</span>
</nav>
{/* Page Heading */}
<div className="flex flex-col gap-2">
<h1 className="text-text-main dark:text-white text-3xl md:text-4xl font-bold tracking-tight">Wybierz temat</h1>
<p className="text-text-sub dark:text-gray-400 text-lg">Kontynuuj swoją przygodę z językiem włoskim.</p>
</div>
{/* Featured Topic (Hero) */}
<div className="w-full relative overflow-hidden rounded-2xl bg-gradient-to-r from-primary to-[#ff6b6b] p-6 text-white shadow-soft group cursor-pointer transition-all hover:shadow-lg hover:-translate-y-0.5">
<div className="relative z-10 flex flex-col gap-4">
<div className="flex justify-between items-start">
<span className="inline-flex items-center gap-1 rounded-full bg-white/20 backdrop-blur-sm px-3 py-1 text-xs font-semibold text-white">
<LucideIcons.Zap className="w-3 h-3" /> Polecane
</span>
<LucideIcons.ArrowRight className="w-5 h-5 opacity-50 group-hover:translate-x-1 transition-transform" />
</div>
<div>
<h3 className="text-2xl font-bold mb-1">Codzienne rozmowy</h3>
<p className="text-white/90 text-sm max-w-md">Naucz się swobodnie rozmawiać o pogodzie, samopoczuciu i planach na weekend.</p>
</div>
<div className="mt-2 w-full bg-black/20 rounded-full h-1.5 max-w-[200px]">
<div className="bg-white h-1.5 rounded-full" style={{ width: '15%' }}></div>
</div>
</div>
{/* Abstract pattern background */}
<div className="absolute right-[-20px] bottom-[-40px] opacity-20">
<LucideIcons.MessageSquareQuote size={180} />
</div>
</div>
{/* Topics Grid Section */}
<div className="flex flex-col gap-4 pt-4">
<div className="flex items-center justify-between">
<h3 className="text-text-main dark:text-white text-xl font-bold">Dostępne Tematy (Topics)</h3>
<button className="text-sm font-semibold text-primary hover:text-primary/80 transition-colors">Zobacz wszystkie</button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{activeTopics.map((topic) => (
<Link href={`/learn/${topic.id}`} key={topic.id}>
<div className="group relative h-full flex flex-col gap-4 rounded-2xl bg-surface-light dark:bg-surface-dark p-5 border border-[#f0e4e5] dark:border-[#3a2a2a] hover:border-primary/30 dark:hover:border-primary/30 transition-all hover:shadow-soft hover:-translate-y-1 cursor-pointer">
<div className="flex justify-between items-start">
<div className="size-12 rounded-xl bg-[#fff5f5] dark:bg-primary/10 flex items-center justify-center text-primary">
<DynamicIcon name={topic.icon || 'BookOpen'} className="w-6 h-6" />
</div>
{user.topicStats?.[topic.id]?.mastery && (
<div className="flex items-center gap-1 text-xs font-medium text-emerald-600 bg-emerald-50 dark:bg-emerald-900/20 dark:text-emerald-400 px-2 py-1 rounded-full">
<LucideIcons.CheckCircle className="w-3 h-3" />
{user.topicStats[topic.id].mastery >= 100 ? 'Ukończone' : `${Math.round(user.topicStats[topic.id].mastery)}%`}
</div>
)}
</div>
<div>
<h4 className="text-text-main dark:text-white font-bold text-lg">{topic.title}</h4>
<p className="text-text-sub dark:text-gray-400 text-sm line-clamp-2">{topic.description}</p>
</div>
<div className="mt-auto pt-2">
<div className="flex justify-between text-xs font-semibold mb-1.5 text-text-sub dark:text-gray-500">
<span>Postęp</span>
<span>{Math.round(user.topicStats?.[topic.id]?.mastery || 0)}%</span>
</div>
<div className="w-full bg-[#f3e7e8] dark:bg-gray-700 rounded-full h-2">
<div
className="bg-primary h-2 rounded-full transition-all duration-500"
style={{ width: `${Math.min(100, user.topicStats?.[topic.id]?.mastery || 0)}%` }}
></div>
</div>
</div>
</div>
</Link>
))}
</div>
</div>
</div>
{/* Right Column: Quiz Configuration Panel */}
<div className="w-full lg:w-[380px] flex-shrink-0 sticky top-24">
<div className="bg-surface-light dark:bg-surface-dark rounded-2xl p-6 shadow-soft border border-[#f0e4e5] dark:border-[#3a2a2a] flex flex-col gap-6">
<div className="flex items-center gap-3 pb-2 border-b border-[#f3e7e8] dark:border-[#3a2a2a]">
<div className="p-2 bg-primary/10 rounded-lg text-primary">
<LucideIcons.SlidersHorizontal className="w-5 h-5" />
</div>
<h3 className="text-lg font-bold text-text-main dark:text-white">Konfiguracja Testu</h3>
</div>
{/* Level Selector */}
<div className="flex flex-col gap-3">
<label className="text-sm font-semibold text-text-sub dark:text-gray-400 uppercase tracking-wider">Poziom (Level)</label>
<div className="flex bg-[#f8f6f6] dark:bg-black/20 p-1 rounded-full border border-[#e7d0d1] dark:border-[#3a2a2a]">
{['A1', 'A2', 'B1', 'B2'].map((lvl) => (
<button
key={lvl}
onClick={() => setLevel(lvl)}
className={`flex-1 py-2 rounded-full text-sm font-bold transition-all ${level === lvl
? 'bg-primary text-white shadow-sm'
: 'text-text-sub dark:text-gray-400 hover:text-text-main hover:bg-white/50 dark:hover:bg-white/10'
}`}
>
{lvl}
</button>
))}
</div>
</div>
{/* Source Selector */}
<div className="flex flex-col gap-3">
<label className="text-sm font-semibold text-text-sub dark:text-gray-400 uppercase tracking-wider">Źródło (Source)</label>
<div className="grid grid-cols-2 gap-3">
<label className="cursor-pointer relative">
<input
type="radio"
className="peer sr-only"
name="source"
checked={source === 'syllabus'}
onChange={() => setSource('syllabus')}
/>
<div className="flex flex-col items-center justify-center gap-2 p-3 rounded-xl border border-[#e7d0d1] dark:border-[#3a2a2a] bg-white dark:bg-black/20 text-text-sub dark:text-gray-400 peer-checked:border-primary peer-checked:bg-primary/5 peer-checked:text-primary transition-all h-full">
<LucideIcons.Book className="w-5 h-5" />
<span className="text-xs font-bold">Sylabus</span>
</div>
</label>
<label className="cursor-pointer relative">
<input
type="radio"
className="peer sr-only"
name="source"
checked={source === 'ai'}
onChange={() => setSource('ai')}
/>
<div className="flex flex-col items-center justify-center gap-2 p-3 rounded-xl border border-[#e7d0d1] dark:border-[#3a2a2a] bg-white dark:bg-black/20 text-text-sub dark:text-gray-400 peer-checked:border-primary peer-checked:bg-primary/5 peer-checked:text-primary transition-all h-full">
<LucideIcons.Sparkles className="w-5 h-5" />
<span className="text-xs font-bold">AI Expanded</span>
</div>
</label>
</div>
</div>
{/* Dropdowns */}
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<label className="text-sm font-semibold text-text-sub dark:text-gray-400 uppercase tracking-wider">Obszar (Focus)</label>
<div className="relative">
<select
value={focus}
onChange={(e) => setFocus(e.target.value)}
className="w-full appearance-none bg-white dark:bg-black/20 border border-[#e7d0d1] dark:border-[#3a2a2a] rounded-xl px-4 py-3 text-text-main dark:text-white text-sm font-medium focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary cursor-pointer"
>
<option>Gramatyka & Słownictwo (Mixed)</option>
<option>Tylko Słownictwo</option>
<option>Tylko Gramatyka</option>
<option>Słuchanie</option>
</select>
<div className="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none text-text-sub">
<LucideIcons.ChevronDown className="w-4 h-4" />
</div>
</div>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-semibold text-text-sub dark:text-gray-400 uppercase tracking-wider">Styl (Tone)</label>
<div className="relative">
<select
value={tone}
onChange={(e) => setTone(e.target.value)}
className="w-full appearance-none bg-white dark:bg-black/20 border border-[#e7d0d1] dark:border-[#3a2a2a] rounded-xl px-4 py-3 text-text-main dark:text-white text-sm font-medium focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary cursor-pointer"
>
<option>Standardowy (Neutral)</option>
<option>Formalny</option>
<option>Casual / Slang</option>
</select>
<div className="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none text-text-sub">
<LucideIcons.ChevronDown className="w-4 h-4" />
</div>
</div>
</div>
</div>
{/* Divider */}
<div className="h-px w-full bg-[#f3e7e8] dark:bg-[#3a2a2a]"></div>
{/* CTA Button */}
<button
onClick={startQuickQuiz}
disabled={loading}
className="w-full bg-primary hover:bg-primary/90 text-white font-bold py-4 rounded-full shadow-lg shadow-primary/25 active:scale-[0.98] transition-all flex items-center justify-center gap-2 group disabled:opacity-70 disabled:cursor-not-allowed"
>
{loading ? (
<span>Przygotowywanie...</span>
) : (
<>
<span>Rozpocznij Test</span>
<LucideIcons.ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
</>
)}
</button>
<p className="text-center text-xs text-text-sub dark:text-gray-500">
Szacowany czas: ~15 minut
</p>
</div>
</div>
</div>
</div>
</div>
);
}
================================================
FILE: src/app/learn/[topic]/page.tsx
================================================
'use client';
import React, { useState, useEffect } from 'react';
import { useParams, useSearchParams } from 'next/navigation';
import Link from 'next/link';
import { generateQuiz } from '@/lib/ai/actions';
import { getTopicContent } from '@/lib/content/loader';
import { Quiz } from '@/lib/ai/schemas';
import { useUserStore } from '@/lib/store/useUserStore';
import { ConfigStore } from '@/lib/store/config';
import { QuizRunner } from '@/components/tutor/QuizRunner';
import { TheoryViewer } from '@/components/tutor/TheoryViewer';
import { QuizOptionsDialog } from '@/components/tutor/QuizOptionsDialog';
import { SpeakingDrill } from '@/components/tutor/SpeakingDrill';
import { TOPICS } from '@/lib/config/topics';
import { toast } from 'sonner';
import {
GraduationCap, Clock, Play, Bookmark, Calendar, Brain,
ChevronRight, ChevronLeft, Sparkles, Edit3, Mic, HelpCircle,
MessageCircle, Lightbulb, BarChart, FileText
} from 'lucide-react';
export default function LearnPage() {
const params = useParams();
const topicId = decodeURIComponent(params.topic as string);
const topicConfig = TOPICS.find(t => t.id === topicId);
// Use title from config if available, otherwise fallback to topicId formatted
const title = topicConfig ? topicConfig.title : topicId.replace(/-/g, ' ');
const [activeTab, setActiveTab] = useState('theory'); // 'theory' | 'practice' | 'examples' | 'exercises'
const [quiz, setQuiz] = useState<Quiz | null>(null);
const [loading, setLoading] = useState(false);
const [customTheory, setCustomTheory] = useState<string>('');
const [optionsOpen, setOptionsOpen] = useState(false);
const user = useUserStore();
const searchParams = useSearchParams();
const isDailyChallenge = searchParams.get('daily') === 'true';
const [quizMode, setQuizMode] = useState<'practice' | 'exam'>('practice');
// Load Content
useEffect(() => {
const loadContent = async () => {
if (topicId === 'smart-review') {
const items = user.getMistakes();
setCustomTheory(items.length > 0 ? items.join('\n') : "Brak błędów do przeglądu! Świetna robota.");
return;
}
const content = await getTopicContent(topicId, topicConfig?.sourceFile);
if (content) {
setCustomTheory(content);
} else {
setCustomTheory("# Brak Treści\n\nNiestety, treść tej lekcji nie została jeszcze dodana do bazy plików.");
}
};
loadContent();
}, [topicId, user, topicConfig]);
// Check for Retry Quiz
useEffect(() => {
if (user.retryQuiz && user.retryQuiz.topic === title) {
setQuiz({
topic: user.retryQuiz.topic,
difficulty: 'A2',
questions: user.retryQuiz.questions
});
user.setRetryQuiz(null);
setOptionsOpen(false);
setActiveTab('practice');
toast.info('Wczytano quiz z historii.');
}
}, [title, user.retryQuiz, user]);
const startQuiz = async (options: {
difficulty: string;
deepCheck: boolean;
amount: number;
mode: 'practice' | 'exam';
config?: any;
}) => {
setLoading(true);
setQuizMode(options.mode);
try {
const promptTopic = topicId === 'smart-review' ? 'Smart Review' : topicId;
// Get global config at runtime
const globalConfig = ConfigStore.get();
const result = await generateQuiz(
promptTopic,
options.difficulty,
customTheory || undefined,
{
model: globalConfig.aiModel,
deepCheck: options.deepCheck,
amount: options.amount,
config: options.config,
aiConfig: {
provider: globalConfig.aiProvider,
url: globalConfig.aiUrl
}
}
);
if (result.success && result.data) {
setQuiz(result.data);
if (options.deepCheck) toast.info('Supervisor zweryfikował quiz.');
setActiveTab('practice');
}
} catch (error) {
toast.error('Błąd generowania quizu. Spróbuj ponownie.');
console.error(error);
} finally {
setLoading(false);
}
};
// Loading State Overlay
if (loading) {
return (
<div className="flex flex-col items-center justify-center min-h-[80vh] space-y-8">
<div className="relative w-32 h-32">
<div className="absolute inset-0 rounded-full border-4 border-slate-100"></div>
<div className="absolute inset-0 rounded-full border-4 border-indigo-500 border-t-transparent animate-spin"></div>
<Sparkles className="absolute inset-0 m-auto h-12 w-12 text-indigo-500 animate-pulse" />
</div>
<div className="text-center space-y-4 max-w-md">
<h2 className="text-2xl font-bold text-slate-800 animate-bounce">
Tworzenie Twojego Quizu... 🎨
</h2>
<div className="w-full bg-slate-100 rounded-full h-4 overflow-hidden shadow-inner border border-slate-200">
<div className="h-full bg-gradient-to-r from-blue-400 via-indigo-500 to-purple-500 animate-[loading_2s_ease-in-out_infinite] w-[40%] rounded-full relative">
<div className="absolute inset-0 bg-white/30 animate-[shimmer_1s_infinite]"></div>
</div>
</div>
<div className="space-y-1">
<p className="text-slate-600 font-medium animate-pulse">Analizuję materiał dydaktyczny...</p>
<p className="text-xs text-slate-400">(Może to potrwać do 60 sekund)</p>
</div>
</div>
<style jsx>{`
@keyframes loading { 0% { transform: translateX(-100%); width: 20%; } 50% { width: 80%; } 100% { transform: translateX(200%); width: 20%; } }
@keyframes shimmer { 0% { transform: translateX(-100%); } 100% { transform: translateX(100%); } }
`}</style>
</div>
);
}
return (
<div className="min-h-screen bg-background-light dark:bg-background-dark text-text-main font-display antialiased flex flex-col transition-colors duration-300">
{/* Header / Breadcrumbs */}
<div className="w-full max-w-7xl mx-auto px-4 md:px-10 py-6">
<nav className="flex flex-wrap gap-2 text-sm items-center mb-6">
<Link href="/" className="text-text-sub hover:text-primary transition-colors flex items-center gap-1">
<ChevronLeft className="w-4 h-4" /> Wróć
</Link>
<span className="text-text-sub">/</span>
<Link href="/learn" className="text-text-sub hover:text-primary transition-colors">Włoski</Link>
<span className="text-text-sub">/</span>
<span className="text-text-main dark:text-white font-semibold">{title}</span>
</nav>
<div className="flex flex-col lg:flex-row gap-8 lg:gap-12 items-start">
{/* Main Content Column */}
<div className="flex-1 w-full flex flex-col gap-8">
{/* Hero Card */}
<div className="relative overflow-hidden rounded-3xl bg-white dark:bg-zinc-800 border border-gray-100 dark:border-gray-700 shadow-soft p-8 lg:p-10">
<div className="absolute top-0 right-0 w-64 h-64 bg-indigo-500/5 rounded-bl-[100px] pointer-events-none"></div>
<div className="relative z-10 flex flex-col gap-6">
<div className="flex items-center gap-3">
<div className="px-3 py-1 rounded-full bg-indigo-500/10 text-indigo-600 dark:text-indigo-400 text-xs font-bold uppercase tracking-wider">
Gramatyka
</div>
<div className="flex items-center gap-1 text-text-sub text-sm">
<Clock className="w-4 h-4" />
<span>15 min</span>
</div>
<div className="flex items-center gap-1 text-text-sub text-sm">
<BarChart className="w-4 h-4" />
<span>{topicConfig?.level || 'A2'}</span>
</div>
</div>
<div className="max-w-2xl">
<h1 className="text-3xl md:text-5xl font-bold text-text-main dark:text-white tracking-tight mb-4">
{title}
</h1>
<p className="text-lg text-text-sub dark:text-gray-400 leading-relaxed">
{topicConfig?.description || 'Opanuj ten temat dzięki lekcjom i ćwiczeniom interaktywnym.'}
</p>
</div>
<div className="flex flex-wrap gap-4 mt-2">
<button
onClick={() => setOptionsOpen(true)}
className="bg-indigo-600 hover:bg-indigo-700 text-white px-8 py-3 rounded-xl font-semibold shadow-lg shadow-indigo-500/20 transition-all flex items-center gap-2 hover:scale-105"
>
<Play className="w-5 h-5 fill-current" />
Rozpocznij naukę
</button>
{activeTab === 'practice' && (
<button onClick={() => setActiveTab('theory')} className="bg-white dark:bg-white/5 border border-gray-200 dark:border-gray-700 text-text-main dark:text-white px-6 py-3 rounded-xl font-medium hover:bg-gray-50 dark:hover:bg-white/10 transition-all flex items-center gap-2">
<FileText className="w-5 h-5" />
Wróć do Teorii
</button>
)}
</div>
</div>
</div>
{/* Navigation Tabs */}
<div className="flex overflow-x-auto pb-2 gap-2 border-b border-gray-200 dark:border-gray-800 scrollbar-hide">
{[
{ id: 'theory', label: 'Przegląd' },
{ id: 'rules', label: 'Zasady' }, // Alias for theory for now
{ id: 'examples', label: 'Przykłady' },
{ id: 'practice', label: 'Ćwiczenia' },
{ id: 'speaking', label: 'Mowa' }
].map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id === 'rules' ? 'theory' : tab.id)}
className={`px-5 py-2 rounded-full text-sm font-medium whitespace-nowrap transition-colors ${activeTab === tab.id || (tab.id === 'rules' && activeTab === 'theory')
? 'bg-text-main text-white'
: 'text-text-sub hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
>
{tab.label}
</button>
))}
</div>
{/* CONTENT AREA */}
<div className="flex flex-col gap-6">
{/* THEORY TAB */}
{(activeTab === 'theory' || activeTab === 'rules') && (
<>
<section className="flex flex-col gap-6 animate-in fade-in duration-500">
<h2 className="text-2xl font-bold text-text-main dark:text-white">Materiał Lekcyjny</h2>
{/* Dynamic content rendered nicely */}
<div className="prose prose-slate dark:prose-invert max-w-none bg-white dark:bg-zinc-800 p-8 rounded-3xl border border-gray-100 dark:border-gray-700 shadow-sm">
<TheoryViewer
content={customTheory}
topic={title}
/>
</div>
</section>
</>
)}
{/* PRACTICE / QUIZ TAB */}
{activeTab === 'practice' && (
<section className="animate-in slide-in-from-bottom-4 duration-500">
{!quiz ? (
<div className="flex flex-col items-center justify-center p-12 text-center space-y-4 border-2 border-dashed border-indigo-200 dark:border-indigo-900 rounded-3xl bg-indigo-50/50 dark:bg-indigo-900/10">
<div className="bg-white dark:bg-indigo-500/20 p-4 rounded-full shadow-sm mb-2">
<Sparkles className="w-12 h-12 text-indigo-500" />
</div>
<h2 className="text-xl font-bold text-slate-800 dark:text-white">
Gotowy na wyzwanie?
</h2>
<p className="text-slate-500 dark:text-slate-400 max-w-sm">
Wygeneruj spersonalizowany quiz AI, aby przetestować swoją wiedzę w praktyce.
</p>
<button
onClick={() => setOptionsOpen(true)}
className="mt-4 px-6 py-2 bg-indigo-600 text-white rounded-full font-medium hover:bg-indigo-700 transition"
>
Generuj Quiz
</button>
</div>
) : (
<QuizRunner
quiz={quiz}
onRestart={() => setQuiz(null)}
mode={quizMode}
topicId={topicId}
isDailyChallenge={isDailyChallenge}
/>
)}
</section>
)}
{/* EXAMPLES / EXTRAS TAB (Placeholder) */}
{activeTab === 'examples' && (
<section className="grid grid-cols-1 md:grid-cols-2 gap-4 animate-in fade-in duration-500">
<div className="bg-white dark:bg-zinc-800 p-6 rounded-2xl border border-gray-100 dark:border-gray-700 shadow-sm flex gap-4">
<div className="size-10 rounded-full bg-blue-50 dark:bg-blue-900/20 text-blue-500 flex items-center justify-center flex-shrink-0">
<Calendar className="w-5 h-5" />
</div>
<div>
<h3 className="font-bold text-text-main dark:text-white mb-2">Plany na przyszłość</h3>
<p className="text-sm text-text-sub dark:text-gray-400 leading-relaxed">
Opisywanie czynności, które na pewno lub prawdopodobnie się wydarzą.
<br /><em className="text-text-main dark:text-gray-300 not-italic mt-1 block">"Domani andrò al mare."</em>
</p>
</div>
</div>
<div className="bg-white dark:bg-zinc-800 p-6 rounded-2xl border border-gray-100 dark:border-gray-700 shadow-sm flex gap-4">
<div className="size-10 rounded-full bg-purple-50 dark:bg-purple-900/20 text-purple-500 flex items-center justify-center flex-shrink-0">
<Brain className="w-5 h-5" />
</div>
<div>
<h3 className="font-bold text-text-main dark:text-white mb-2">Przypuszczenia</h3>
<p className="text-sm text-text-sub dark:text-gray-400 leading-relaxed">
Wyrażanie wątpliwości lub przypuszczeń.
<br /><em className="text-text-main dark:text-gray-300 not-italic mt-1 block">"Sarà vero?"</em>
</p>
</div>
</div>
</section>
)}
{/* SPEAKING TAB */}
{activeTab === 'speaking' && (
<section className="animate-in slide-in-from-bottom-4 duration-500">
<div className="max-w-xl mx-auto space-y-8 mt-10">
<div className="text-center space-y-2">
<h2 className="text-2xl font-bold text-slate-800 dark:text-white">Trening Wymowy (Beta)</h2>
<p className="text-slate-500 dark:text-slate-400">Powiedz zdanie związane z tematem <strong>{title}</strong>.</p>
</div>
<SpeakingDrill onTranscript={(text) => {
toast.success(`Rozpoznano: "${text}"`);
// In real app, we would analyze grammar here
setCustomTheory(prev => prev + `\n\n[Mowa Użytkownika]: ${text}`);
}} />
<div className="bg-yellow-50 p-4 rounded-lg text-sm text-yellow-800 border border-yellow-200 dark:bg-yellow-900/20 dark:text-yellow-300 dark:border-yellow-700">
<strong>Wskazówka:</strong> Użyj przeglądarki Chrome lub Edge dla najlepszej jakości rozpoznawania mowy.
</div>
</div>
</section>
)}
</div>
</div>
{/* SIDEBAR */}
<div className="w-full lg:w-[360px] flex-shrink-0 flex flex-col gap-6 lg:sticky lg:top-24">
{/* Progress Card */}
<div className="bg-white dark:bg-zinc-800 rounded-2xl p-6 shadow-soft border border-gray-100 dark:border-gray-700">
<div className="flex justify-between items-center mb-4">
<h3 className="font-bold text-text-main dark:text-white">Twój postęp</h3>
<span className="text-xs font-semibold px-2 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded-md">W toku</span>
</div>
<div className="flex items-center gap-4 mb-6">
<div className="relative size-16">
<svg className="size-full -rotate-90" viewBox="0 0 36 36">
<path className="text-gray-100 dark:text-gray-700" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" fill="none" stroke="currentColor" strokeWidth="3"></path>
<path className="text-indigo-500" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" fill="none" stroke="currentColor" strokeDasharray="35, 100" strokeWidth="3"></path>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-sm font-bold text-text-main dark:text-white">35%</span>
</div>
</div>
<div className="flex flex-col">
<span className="text-sm font-medium text-text-main dark:text-white">Teoria opanowana</span>
<span className="text-xs text-text-sub">Zostały jeszcze ćwiczenia</span>
</div>
</div>
<button
onClick={() => setActiveTab('practice')}
className="w-full py-3 rounded-xl bg-text-main dark:bg-white dark:text-black text-white font-medium hover:bg-black dark:hover:bg-gray-200 transition-colors shadow-sm"
>
Kontynuuj
</button>
</div>
{/* Exercise Quick Links */}
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between px-1">
<h3 className="font-bold text-text-main dark:text-white">Szybkie Ćwiczenia</h3>
<button onClick={() => setActiveTab('practice')} className="text-xs font-semibold text-indigo-500 hover:text-indigo-600 transition-colors">
Zobacz wszystkie
</button>
</div>
{[
{ title: 'Uzupełnij luki', desc: '10 pytań • A1/A2', icon: Edit3, color: 'text-indigo-600', bg: 'bg-indigo-50 dark:bg-indigo-900/20' },
{ title: 'Wymowa zdań', desc: '5 nagrań • A2', icon: Mic, color: 'text-pink-500', bg: 'bg-pink-50 dark:bg-pink-900/20' },
{ title: 'Quiz Szybki', desc: '15 pytań • Mieszany', icon: HelpCircle, color: 'text-teal-600', bg: 'bg-teal-50 dark:bg-teal-900/20' }
].map((item, i) => (
<div key={i} onClick={() => { setActiveTab('practice'); setOptionsOpen(true); }} className="group block bg-white dark:bg-zinc-800 p-4 rounded-xl border border-gray-100 dark:border-gray-700 hover:border-indigo-500/30 hover:shadow-md transition-all cursor-pointer">
<div className="flex items-center gap-4">
<div className={`size-10 rounded-lg ${item.bg} ${item.color} flex items-center justify-center group-hover:scale-110 transition-transform`}>
<item.icon className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-text-main dark:text-white text-sm truncate">{item.title}</h4>
<p className="text-xs text-text-sub truncate">{item.desc}</p>
</div>
<div className="text-gray-300 group-hover:text-indigo-500 transition-colors">
<ChevronRight className="w-5 h-5" />
</div>
</div>
</div>
))}
</div>
{/* AI Tutor Promo */}
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-gray-900 to-gray-800 p-6 text-white shadow-lg">
<div className="absolute top-0 right-0 w-32 h-32 bg-white/10 rounded-full -translate-y-1/2 translate-x-1/2 blur-2xl"></div>
<div className="relative z-10 flex flex-col gap-3">
<div className="flex items-center gap-2 mb-1">
<Sparkles className="w-4 h-4 text-yellow-400" />
<span className="text-xs font-bold uppercase tracking-wider text-gray-300">AI Tutor</span>
</div>
<h3 className="font-bold text-lg leading-tight">Masz wątpliwości?</h3>
<p className="text-sm text-gray-300">Zapytaj asystenta AI o wyjaśnienie dowolnego przykładu.</p>
<button className="mt-2 w-full py-2 bg-white/10 hover:bg-white/20 border border-white/20 rounded-lg text-sm font-semibold transition-colors">
Otwórz Czat
</button>
</div>
</div>
</div>
</div>
</div>
<QuizOptionsDialog
open={optionsOpen}
onOpenChange={setOptionsOpen}
onConfirm={startQuiz}
topicName={title}
topicId={topicId}
/>
</div>
);
}
================================================
FILE: src/app/learn/analytics/page.tsx
================================================
'use client';
import { LearningDashboard } from '@/components/learning/LearningDashboard';
import { Button } from '@/components/ui/button';
import Link from 'next/link';
import { ArrowLeft } from 'lucide-react';
export default function LearningPage() {
return (
<div className="min-h-screen bg-slate-50 p-8">
<div className="max-w-5xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-slate-900">Analiza Postępów</h1>
<p className="text-slate-500 mt-1">
Sprawdź, jak efektywnie się uczysz i co poprawić
</p>
</div>
<Button variant="outline" asChild>
<Link href="/" className="flex items-center gap-2">
<ArrowLeft className="h-4 w-4" />
Powrót
</Link>
</Button>
</div>
{/* Dashboard */}
<LearningDashboard />
{/* Info Footer */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm text-blue-900">
<h3 className="font-semibold mb-2">💡 Jak interpretować metryki?</h3>
<ul className="space-y-1 list-disc list-inside">
<li><strong>Transfer Wiedzy</strong> - czy rozumiesz reguły, czy tylko zapamiętałeś odpowiedzi</li>
<li><strong>Uczenie się na Błędach</strong> - czy poprawiasz się po feedbacku</li>
<li><strong>Automatyzacja</strong> - czy odpowiadasz szybciej (znak opanowania materiału)</li>
</ul>
</div>
</div>
</div>
);
}
================================================
FILE: src/app/profile/history/page.tsx
================================================
'use client';
import React, { useState } from 'react';
import { useUserStore } from '@/lib/store/useUserStore';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Calendar, ChevronLeft, Search, RotateCcw } from 'lucide-react';
import Link from 'next/link';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { QuizReview } from '@/components/tutor/QuizReview';
import { QuizResult } from '@/lib/store/useUserStore';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
export default function HistoryPage() {
const user = useUserStore();
const router = useRouter();
const [selectedQuiz, setSelectedQuiz] = useState<QuizResult | null>(null);
// Group by date or just list? Let's list for now.
const history = user.history;
if (history.length === 0) {
return (
<div className="container mx-auto p-4 max-w-4xl py-12 text-center space-y-6">
<div className="w-24 h-24 bg-slate-100 rounded-full flex items-center justify-center mx-auto">
<Calendar className="w-12 h-12 text-slate-400" />
</div>
<h1 className="text-2xl font-bold text-slate-800">Historia jest pusta</h1>
<p className="text-slate-500">
Nie wykonałeś jeszcze żadnych quizów. Rozpocznij naukę, aby śledzić postępy!
</p>
<Button asChild>
<Link href="/">Wróć do Panelu</Link>
</Button>
</div>
);
}
return (
<div className="container mx-auto p-4 max-w-4xl space-y-8">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" asChild>
<Link href="/"><ChevronLeft className="h-6 w-6" /></Link>
</Button>
<div>
<h1 className="text-3xl font-black text-slate-900 tracking-tight">Twoja Historia</h1>
<p className="text-slate-500">{user.completedQuizzes} ukończonych testów</p>
</div>
</div>
<div className="grid gap-4">
{history.map((quiz) => (
<Card
key={quiz.id}
className="cursor-pointer hover:shadow-md transition-all hover:border-blue-300 group"
onClick={() => setSelectedQuiz(quiz)}
>
<CardContent className="p-6 flex justify-between items-center">
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-slate-500">
<Calendar className="h-4 w-4" />
{quiz.date}
</div>
<h3 className="text-lg font-bold text-slate-800 group-hover:text-blue-600 transition-colors">
{quiz.topic}
</h3>
<p className="text-sm text-slate-600">
{quiz.questions.length} pytań
</p>
</div>
<div className="text-right">
<div className={`text-2xl font-black ${(quiz.score / quiz.total) >= 0.8 ? 'text-green-600' :
(quiz.score / quiz.total) >= 0.5 ? 'text-amber-500' : 'text-red-500'
}`}>
{Math.round((quiz.score / quiz.total) * 100)}%
</div>
<div className="text-xs text-slate-400 uppercase font-bold">Wynik</div>
</div>
</CardContent>
</Card>
))}
</div>
<Dialog open={!!selectedQuiz} onOpenChange={(open) => !open && setSelectedQuiz(null)}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto p-0 border-none bg-transparent shadow-none">
{selectedQuiz && (
<QuizReview
quiz={{
topic: selectedQuiz.topic,
difficulty: 'A2',
questions: selectedQuiz.questions
}}
userAnswers={selectedQuiz.userAnswers}
score={selectedQuiz.score}
onRestart={() => { /* No-op in review */ }}
onRetry={() => {
user.setRetryQuiz(selectedQuiz);
router.push(`/learn/${selectedQuiz.topic}`);
}}
/>
)}
</DialogContent>
</Dialog>
</div>
);
}
================================================
FILE: src/app/settings/page.tsx
================================================
'use client';
import React, { useEffect, useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { checkConnection } from '@/lib/ai/actions';
import {
Settings, Cpu, ShieldCheck, Save, ArrowLeft, User,
BookOpen, Bell, Moon, Sun, Trash2, Smartphone, Monitor
} from 'lucide-react';
import { ConfigStore, AppConfig } from '@/lib/store/config';
import { useUserStore } from '@/lib/store/useUserStore';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
export default function SettingsPage() {
const router = useRouter();
const user = useUserStore();
// Config State
const [config, setConfig] = useState<AppConfig | null>(null);
const [availableModels, setAvailableModels] = useState<string[]>([]);
const [loadingModels, setLoadingModels] = useState(false);
// UI State
const [name, setName] = useState('');
const [theme, setTheme] = useState<'light' | 'dark'>('light');
useEffect(() => {
// Load initial data
const initialConfig = ConfigStore.get();
setConfig(initialConfig);
setName(user.name);
// Detect theme
if (document.documentElement.classList.contains('dark')) {
setTheme('dark');
}
// Fetch models using the loaded config
const fetchModels = async () => {
setLoadingModels(true);
try {
// Use the loaded config to check connection
const result = await checkConnection({
provider: initialConfig.aiProvider,
url: initialConfig.aiUrl
});
if (result.success && result.models) {
setAvailableModels(result.models);
} else {
// Fallback only if check fails entirely
setAvailableModels(['llama3:latest', 'mistral:latest', 'gemma:latest']);
}
} catch (e) {
// Fallback list
setAvailableModels(['llama3:latest', 'mistral:latest', 'gemma:latest']);
} finally {
setLoadingModels(false);
}
};
fetchModels();
}, [user.name]);
const handleSave = () => {
if (!config) return;
// Save Config
ConfigStore.update(config);
// Save User Profile
if (name !== user.name) {
user.setName(name);
}
toast.success("Ustawienia zostały zapisane!");
};
const toggleTheme = (newTheme: 'light' | 'dark') => {
setTheme(newTheme);
if (newTheme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
};
const handleResetProgress = () => {
if (confirm("Czy na pewno chcesz zresetować cały postęp? Tej operacji nie można cofnąć.")) {
localStorage.removeItem('test_master_progress');
window.location.reload();
}
};
if (!config) return null;
return (
<div className="min-h-screen bg-background-light dark:bg-background-dark p-4 md:p-8 transition-colors duration-300">
<div className="max-w-4xl mx-auto space-y-8">
{/* Header */}
<header className="flex items-center gap-4 sticky top-0 bg-background-light/95 dark:bg-background-dark/95 backdrop-blur z-10 py-4 border-b border-border/10">
<Button variant="ghost" size="icon" onClick={() => router.push('/')} className="hover:bg-primary/10 hover:text-primary">
<ArrowLeft className="h-6 w-6" />
</Button>
<div>
<h1 className="text-3xl font-bold text-text-main dark:text-white flex items-center gap-2">
<Settings className="h-8 w-8 text-primary" /> Ustawienia
</h1>
<p className="text-text-sub dark:text-gray-400">Dostosuj aplikację do swoich potrzeb</p>
</div>
</header>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{/* Navigation / TOC (Hidden on mobile for now, or just a sticky list) */}
<div className="hidden md:block col-span-1 space-y-2 sticky top-28 self-start">
<h3 className="text-sm font-bold text-text-sub uppercase tracking-wider mb-4 px-2">Kategorie</h3>
{['Profil', 'Wygląd', 'AI i Generator', 'Niebezpieczne'].map((item) => (
<div key={item} className="px-4 py-2 rounded-lg hover:bg-white/50 dark:hover:bg-white/5 cursor-pointer text-text-main dark:text-gray-300 font-medium transition-colors">
{item}
</div>
))}
</div>
{/* Main Content Form */}
<div className="md:col-span-2 space-y-8">
{/* PROFILE SECTION */}
<section className="space-y-4">
<h2 className="text-xl font-bold text-text-main dark:text-white flex items-center gap-2">
<User className="h-5 w-5 text-primary" /> Profil Użytkownika
</h2>
<Card className="border-border/50 shadow-sm">
<CardContent className="p-6 space-y-6">
<div className="flex items-center gap-4">
<div className="h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center text-primary text-2xl font-bold">
{name ? name[0] : 'U'}
</div>
<div className="flex-1">
<label className="block text-sm font-medium text-text-sub mb-1">Twoje Imię</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full p-2 rounded-lg border border-border bg-white dark:bg-black/20 text-text-main dark:text-white focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all"
placeholder="Wpisz swoje imię..."
/>
</div>
</div>
<div className="flex justify-between items-center p-4 bg-slate-50 dark:bg-white/5 rounded-xl border border-border/50">
<div>
<p className="font-bold text-text-main dark:text-white">Poziom Mistrzostwa</p>
<p className="text-xs text-text-sub">{user.masteryPoints} Punktów XP</p>
</div>
<span className="text-2xl font-bold text-primary">A2</span>
</div>
</CardContent>
</Card>
</section>
{/* APPEARANCE SECTION */}
<section className="space-y-4">
<h2 className="text-xl font-bold text-text-main dark:text-white flex items-center gap-2">
<Monitor className="h-5 w-5 text-blue-500" /> Wygląd
</h2>
<Card className="border-border/50 shadow-sm">
<CardContent className="p-6">
<div className="grid grid-cols-2 gap-4">
<button
onClick={() => toggleTheme('light')}
className={`p-4 rounded-xl border-2 flex flex-col items-center gap-3 transition-all ${theme === 'light' ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50'}`}
>
<Sun className={`h-8 w-8 ${theme === 'light' ? 'text-primary' : 'text-gray-400'}`} />
<span className="font-semibold text-text-main dark:text-gray-300">Jasny</span>
</button>
<button
onClick={() => toggleTheme('dark')}
className={`p-4 rounded-xl border-2 flex flex-col items-center gap-3 transition-all ${theme === 'dark' ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50'}`}
>
<Moon className={`h-8 w-8 ${theme === 'dark' ? 'text-primary' : 'text-gray-400'}`} />
<span className="font-semibold text-text-main dark:text-gray-300">Ciemny</span>
</button>
</div>
</CardContent>
</Card>
</section>
{/* AI SECTION */}
<section className="space-y-4">
<h2 className="text-xl font-bold text-text-main dark:text-white flex items-center gap-2">
<Cpu className="h-5 w-5 text-purple-600" /> AI i Generator
</h2>
<Card className="border-border/50 shadow-sm">
<CardContent className="p-6 space-y-6">
{/* Provider Selection */}
<div>
<label className="block text-sm font-medium text-text-sub mb-3">Dostawca Modelu</label>
<div className="flex p-1 bg-slate-100 dark:bg-black/20 rounded-xl">
{(['ollama', 'lmstudio'] as const).map((p) => (
<button
key={p}
onClick={() => {
const defaultUrl = p === 'ollama' ? 'http://localhost:11434' : 'http://localhost:1234';
setConfig({ ...config, aiProvider: p, aiUrl: defaultUrl });
}}
className={`flex-1 py-2 text-sm font-bold capitalize rounded-lg transition-all flex items-center justify-center gap-2 ${config.aiProvider === p
? 'bg-white dark:bg-white/10 text-primary shadow-sm'
: 'text-text-sub hover:text-text-main'
}`}
>
{p === 'ollama' ? '🦙 Ollama' : '🧪 LM Studio'}
</button>
))}
</div>
</div>
{/* Connection Config & Check */}
<div className="space-y-3">
<label className="block text-sm font-medium text-text-sub">Adres Serwera API</label>
<div className="flex gap-2">
<input
type="text"
value={config.aiUrl}
onChange={(e) => {
// Allow editing, but sanitize on blur or use raw in store
setConfig({ ...config, aiUrl: e.target.value });
}}
onBlur={() => {
// Basic cleanup on blur: remove trailing slash, remove /v1... if clearly duplicated
let clean = config.aiUrl.trim().replace(/\/+$/, '');
if (config.aiProvider === 'lmstudio' && clean.endsWith('/v1')) {
// Keep it if user explicitly wants base/v1, but standard is base
// Actually LM Studio default is port 1234. Let's leave it unless it's obviously wrong.
}
setConfig({ ...config, aiUrl: clean });
}}
className="flex-1 p-3 rounded-xl border border-border bg-white dark:bg-black/20 text-text-main dark:text-white focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all font-mono text-sm"
/>
<Button
onClick={async () => {
setLoadingModels(true);
try {
// Call Server Action to bypass CORS
const result = await checkConnection({
provider: config.aiProvider,
url: config.aiUrl
});
if (result.success && result.models) {
setAvailableModels(result.models);
// If current model not in list, pick first
if (!result.models.includes(config.aiModel)) {
setConfig({ ...config, aiModel: result.models[0] || config.aiModel });
}
toast.success(`Połączono! Znaleziono ${result.models.length} modeli.`);
} else {
throw new Error(result.error || 'Nieznany błąd');
}
} catch (e: any) {
console.error(e);
toast.error("Błąd: " + (e.message || "Brak połączenia"));
setAvailableModels([]); // Reset on error
} finally {
setLoadingModels(false);
}
}}
variant="outline"
className="whitespace-nowrap"
disabled={loadingModels}
>
{loadingModels ? 'Sprawdzanie...' : 'Sprawdź Połączenie 🔄'}
</Button>
</div>
<div className="flex flex-col gap-2">
<p className="text-xs text-text-sub">
{config.aiProvider === 'ollama'
? 'Domyślny port: 11434. Endpoint: /api/tags'
: 'Domyślny port: 1234. Endpoint: /v1/models (Kompatybilny z OpenAI)'}
</p>
{/* Context Length Hint for LM Studio */}
{config.aiProvider === 'lmstudio' && (
<div className="text-xs p-3 bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 rounded-lg border border-blue-100 dark:border-blue-900/50 flex flex-col gap-1">
<p className="font-semibold flex items-center gap-1">
Wskazówka: Ustawienia LM Studio
</p>
<p className="opacity-90">
Zalecamy ustawienie <strong>Context Length</strong> na min. 4096 (zakładka Server), aby uniknąć błędów przy długich konwersacjach.
<br />
Jeśli widzisz błąd "RotatingKVCache", wyłącz <strong>Context Cache Quantization</strong>.
</p>
</div>
)}
</div>
</div>
{/* Model Selection */}
<div>
<label className="block text-sm font-medium text-text-sub mb-2">Wybierz Model</label>
{availableModels.length === 0 ? (
<div className="p-3 bg-slate-50 dark:bg-white/5 rounded-xl border border-dashed border-border text-center text-sm text-text-sub">
Kliknij "Sprawdź Połączenie", aby załadować modele.
</div>
) : (
<div className="relative">
<select
value={config.aiModel}
onChange={(e) => setConfig({ ...config, aiModel: e.target.value })}
className="w-full p-3 rounded-xl border border-border bg-white dark:bg-black/20 text-text-main dark:text-white appearance-none focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all"
>
{availableModels.map(model => (
<option key={model} value={model}>{model}</option>
))}
</select>
<div className="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none text-text-sub">
<Settings className="h-4 w-4" />
</div>
</div>
)}
</div>
<div className="flex items-center justify-between p-4 bg-slate-50 dark:bg-white/5 rounded-xl border border-border/50">
<div className="max-w-[70%]">
<p className="font-bold text-text-main dark:text-white flex items-center gap-2">
<ShieldCheck className="h-4 w-4 text-green-600" /> Głęboka Weryfikacja
</p>
<p className="text-xs text-text-sub mt-1">
Agent "Supervisor" sprawdzi poprawność każdego pytania. Zwiększa jakość, ale wydłuża czas o 5-10s.
</p>
</div>
<div className="relative inline-block w-12 mr-2 align-middle select-none transition duration-200 ease-in">
<input
type="checkbox"
name="toggle"
id="toggle"
checked={config.generationSettings.deepCheck}
onChange={(e) => setConfig({
...config,
generationSettings: { ...config.generationSettings, deepCheck: e.target.checked }
})}
className="toggle-checkbox absolute block w-6 h-6 rounded-full bg-white border-4 appearance-none cursor-pointer peer checked:right-0 checked:border-green-400"
/>
<label htmlFor="toggle" className={`toggle-label block overflow-hidden h-6 rounded-full bg-gray-300 cursor-pointer ${config.generationSettings.deepCheck ? 'bg-green-400' : ''}`}></label>
</div>
</div>
<div>
<label className="block text-sm font-medium text-text-sub mb-3">Surowość Oceny Pisania</label>
<div className="flex rounded-xl bg-slate-100 dark:bg-black/20 p-1">
{['low', 'medium', 'high'].map((level) => (
<button
key={level}
onClick={() => setConfig({
...config,
generationSettings: { ...config.generationSettings, strictness: level as any }
})}
className={`flex-1 py-2 text-sm font-bold capitalize rounded-lg transition-all ${config.generationSettings.strictness === level
? 'bg-white dark:bg-white/10 text-primary shadow-sm'
: 'text-text-sub hover:text-text-main'
}`}
>
{level}
</button>
))}
</div>
</div>
</CardContent>
</Card>
</section>
{/* DATA SECTION */}
<section className="space-y-4">
<h2 className="text-xl font-bold text-text-main dark:text-white flex items-center gap-2">
<Trash2 className="h-5 w-5 text-red-600" /> Strefa Niebezpieczna
</h2>
<Card className="border-red-200 bg-red-50 dark:bg-red-900/10">
<CardContent className="p-6 flex items-center justify-between">
<div>
<p className="font-bold text-red-900 dark:text-red-200">Zresetuj Cały Postęp</p>
<p className="text-xs text-red-700 dark:text-red-400">Usuwa historię quizów, punkty i statystyki.</p>
</div>
<Button onClick={handleResetProgress} variant="destructive" className="bg-red-600 hover:bg-red-700">
Reset
</Button>
</CardContent>
</Card>
</section>
{/* SAVE BUTTON */}
<div className="sticky bottom-4 flex justify-end pt-4">
<Button
onClick={handleSave}
size="lg"
className="bg-primary hover:bg-primary/90 text-white font-bold py-6 px-8 rounded-full shadow-lg shadow-primary/30 flex items-center gap-2 hover:scale-105 transition-transform"
>
<Save className="h-5 w-5" />
Zapisz Zmiany
</Button>
</div>
</div>
</div>
</div>
</div>
);
}
================================================
FILE: src/app/test-gen/page.tsx
================================================
'use client';
import { useState } from 'react';
import { generateQuiz } from '@/lib/ai/actions';
export default function TestGenPage() {
const [result, setResult] = useState<any>(null);
const [loading, setLoading] = useState(false);
const handleGenerate = async () => {
setLoading(true);
const res = await generateQuiz('Futuro Semplice', 'A2');
setResult(res);
setLoading(false);
};
return (
<div className="p-8">
<h1 className="text-2xl font-bold mb-4">Generator Test</h1>
<button
onClick={handleGenerate}
disabled={loading}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
>
{loading ? 'Generating...' : 'Generate Quiz'}
</button>
{result && (
<pre className="mt-8 p-4 bg-gray-100 rounded overflow-auto max-h-[500px]">
{JSON.stringify(result, null, 2)}
</pre>
)}
</div>
);
}
================================================
FILE: src/app/write/page.tsx
================================================
'use client';
import React, { useState, useEffect } from 'react';
import { WritingCanvas } from '@/components/tutor/WritingCanvas';
import { FeedbackDisplay } from '@/components/tutor/FeedbackDisplay';
import { WritingAnalysis } from '@/lib/ai/schemas';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
const PROMPTS = [
{ id: 'free', label: 'Free Write', text: 'Write about anything you want.' },
{ id: 'email', label: 'Email a Friend', text: 'Write an email to a friend inviting them to dinner on Saturday.' },
{ id: 'holiday', label: 'Last Holiday', text: 'Describe where you went for your last summer holiday.' },
{ id: 'opinion', label: 'Opinion', text: 'Do you prefer living in the city or the countryside? Explain why.' },
];
import { useUserStore, WritingResult } from '@/lib/store/useUserStore';
export default function WritingPage() {
const user = useUserStore();
const [selectedPrompt, setSelectedPrompt] = useState(PROMPTS[0]);
const [analysis, setAnalysis] = useState<WritingAnalysis | null>(null);
const history = user.writingHistory;
// No effect needed!
const handlePromptChange = (val: string) => {
const p = PROMPTS.find(x => x.id === val) || PROMPTS[0];
setSelectedPrompt(p);
setAnalysis(null); // Reset analysis on prompt change
};
return (
<div className="container mx-auto py-8 px-4 max-w-6xl">
<header className="mb-8">
<h1 className="text-3xl font-bold tracking-tight text-slate-900 mb-2">
Laboratorio di Scrittura
</h1>
<p className="text-slate-500">Practice your written Italian with instant AI feedback.</p>
</header>
<div className="grid gap-8 lg:grid-cols-[1.2fr_0.8fr]">
<div className="space-y-6">
<Card>
<CardHeader className="pb-3">
<CardTitle>Choose a Topic</CardTitle>
<CardDescription>Select a prompt to guide your writing.</CardDescription>
</CardHeader>
<CardContent>
<Tabs value={selectedPrompt.id} onValueChange={handlePromptChange}>
<TabsList className="grid grid-cols-4 w-full">
{PROMPTS.map(p => (
<TabsTrigger key={p.id} value={p.id}>{p.label}</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className="mt-4 p-4 bg-blue-50 text-blue-800 rounded-md font-medium">
Topic: {selectedPrompt.text}
</div>
</CardContent>
</Card>
<WritingCanvas
prompt={selectedPrompt.text}
onAnalysisComplete={(res, text) => {
setAnalysis(res);
user.saveWritingResult({
id: crypto.randomUUID(),
date: new Date().toLocaleDateString('pl-PL'),
topic: selectedPrompt.label,
originalText: text,
analysis: res
});
}}
/>
{/* Writing History Section */}
<div className="mt-8">
<h3 className="text-xl font-bold mb-4 flex items-center gap-2">
📜 Twoje Portfolio (Historia)
</h3>
{history.length === 0 ? (
<p className="text-slate-500 italic">Brak zapisanych prac.</p>
) : (
<div className="space-y-3">
{history.map(item => (
<div key={item.id} className="p-4 bg-white border rounded-lg shadow-sm hover:shadow-md transition">
<div className="flex justify-between items-start mb-2">
<div>
<h4 className="font-bold text-slate-800">{item.topic}</h4>
<p className="text-xs text-slate-500">{item.date}</p>
</div>
<span className="bg-purple-100 text-purple-700 px-2 py-1 rounded text-xs font-bold">
Ocena: {item.analysis.score}/10
</span>
</div>
<p className="text-sm text-slate-600 line-clamp-2 italic border-l-2 border-purple-200 pl-2 mb-2">
"{item.originalText}"
</p>
<div className="text-xs text-slate-500">
Znaleziono błędów: {item.analysis.corrections.length}
</div>
{/* Ideally clicking restores/views it, but for MVP just listing is fine */}
</div>
))}
</div>
)}
</div>
</div>
<div className="min-h-[500px]">
{analysis ? (
<FeedbackDisplay analysis={analysis} />
) : (
<div className="h-full flex flex-col items-center justify-center text-slate-400 border-2 border-dashed rounded-xl p-8 bg-slate-50/50">
<div className="text-6xl mb-4">✍️</div>
<h3 className="text-xl font-semibold text-slate-600">Waiting for text...</h3>
<p className="text-center mt-2 max-w-xs">
Write something on the left and click "Check with AI Tutor" to see detailed feedback here.
</p>
</div>
)}
</div>
</div>
</div>
);
}
================================================
FILE: src/components/challenge/DailyChallengeCard.tsx
================================================
'use client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { getTodaysDailyChallenge, isDailyChallengeCompleted } from '@/lib/challenge/daily';
import { useUserStore } from '@/lib/store/useUserStore';
import { Flame, Calendar, Target, Star } from 'lucide-react';
import Link from 'next/link';
import { TOPICS } from '@/lib/config/topics';
export function DailyChallengeCard() {
const user = useUserStore();
const challenge = getTodaysDailyChallenge();
const isCompleted = isDailyChallengeCompleted();
const streak = user.dailyProgress.currentStreak;
const longestStreak = user.dailyProgress.longestStreak;
const topicConfig = TOPICS.find(t => t.id === challenge.topicId);
const topicTitle = topicConfig?.title || challenge.topicId;
return (
<Card className={`${isCompleted ? 'bg-green-50 border-green-200' : 'bg-gradient-to-br from-blue-50 to-indigo-50 border-indigo-200'} shadow-lg`}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-xl">
<Calendar className="h-5 w-5 text-indigo-600" />
Codzienny Challenge
</CardTitle>
<div className="flex items-center gap-4">
{/* Streak Counter */}
<div className="flex items-center gap-1 bg-white px-3 py-1.5 rounded-full shadow-sm">
<Flame className={`h-4 w-4 ${streak > 0 ? 'text-orange-500' : 'text-slate-300'}`} />
<span className="font-bold text-sm">{streak}</span>
<span className="text-xs text-slate-500">dni</span>
</div>
{isCompleted && (
<div className="bg-green-600 text-white px-3 py-1.5 rounded-full text-xs font-semibold">
✅ Ukończono!
</div>
)}
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{!isCompleted ? (
<>
{/* Challenge Info */}
<div className="bg-white rounded-lg p-4 space-y-3">
<div className="flex items-start gap-3">
<Target className="h-5 w-5 text-indigo-600 mt-0.5" />
<div className="flex-1">
<h3 className="font-semibold text-lg text-slate-900 capitalize">
{topicTitle}
</h3>
<p className="text-sm text-slate-600 mt-1">
{challenge.reason}
</p>
</div>
<div className="bg-indigo-100 text-indigo-700 px-2 py-1 rounded text-xs font-bold">
{challenge.difficulty}
</div>
</div>
{/* Rewards */}
<div className="flex items-center gap-2 text-sm text-amber-700 bg-amber-50 border border-amber-200 rounded px-3 py-2">
<Star className="h-4 w-4" />
<span className="font-semibold">+50 punktów bonusowych</span>
{streak === 6 && <span className="text-xs">(jutro +100 za 7-dniowy streak!)</span>}
</div>
</div>
{/* Action Button */}
<Button asChild size="lg" className="w-full bg-indigo-600 hover:bg-indigo-700 text-white shadow-md">
<Link href={`/learn/${challenge.topicId}?daily=true`}>
Zacznij Challenge 🚀
</Link>
</Button>
</>
) : (
<>
{/* Completed State */}
<div className="bg-white rounded-lg p-4 text-center space-y-2">
<div className="text-4xl">🎉</div>
<h3 className="font-bold text-lg text-green-700">
Świetna robota!
</h3>
<p className="text-sm text-slate-600">
Ukończyłeś dzisiejszy challenge. Wróć jutro po kolejne wyzwanie!
</p>
{/* Streak Info */}
<div className="mt-4 pt-4 border-t flex justify-center gap-8">
<div className="text-center">
<div className="text-2xl font-bold text-orange-500">{streak}</div>
<div className="text-xs text-slate-500">Aktualny streak</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-indigo-600">{longestStreak}</div>
<div className="text-xs text-slate-500">Najdłuższy streak</div>
</div>
</div>
</div>
</>
)}
</CardContent>
</Card>
);
}
================================================
FILE: src/components/content/syllabus.md
================================================
# Elenco Argomenti (Syllabus)
## Parte 1: Fonetica, Morfologia e Sintassi
### Fonetica e ortografia
- Lalfabeto italiano: vocali e consonanti
- Digrammi e trigrammi (gli, gn, sc, ch, gh), consonanti doppie, grafemi particolari
### Morfologia e sintassi
- **Nomi**:
- Genere (maschile, femminile) e particolarità
- Numero (singolare, plurale), plurali irregolari
- **Articoli**: Determinativi (il, lo, la, i, gli, le) e indeterminativi (un, uno, una)
- **Aggettivi**: Flessione e accordo con il nome
- **Gradi dell'aggettivo**: Comparativo (maggioranza, minoranza, uguaglianza) e Superlativo (relativo e assoluto)
- **Dimostrativi**: Questo, quello (flessione e uso)
- **Possessivi**: Mio, tuo, suo, ecc.
- **Pronomi**: Pronomi diretti (lo, la, li, le)
- **Verbi**:
- Persona e numero
- Transitivi e intransitivi, modali (dovere, potere, volere), riflessivi
- **Tempi e Modi**:
- Presente Indicativo
- Stare + Gerundio
- Stare per + Infinito
- Passato Prossimo
- Imperfetto
- Verbi chiave: Essere, Avere, 3 coniugazioni (-are, -ere, -ire), irregolari
- **Preposizioni**:
- Semplici: di, a, da, in, con, su, per, tra, fra
- Locuzioni: sopra, sotto, vicino, di fronte...
- **Avverbi e Congiunzioni**:
- Tempo, frequenza (sempre, di solito)
- Congiunzioni base (e, ma, o, perché)
- **Altro**:
- Cè / Ci sono (Esserci)
- Ordine delle parole nella frase
## Parte 2: Lessico e Pragmatica
### Lessico
- Tempo atmosferico (meteo)
- Tempo cronologico: momenti della giornata, ore, giorni, mesi, stagioni
- La città (luoghi, direzioni)
- Cibo e alimentazione
- Salute / Malattia
- Colori, numeri, operazioni matematiche
- Nomi geografici e aggettivi di nazionalità
### Competenze Comunicative
- Presentarsi e salutare (formale/informale)
- Chiedere informazioni o interpellare qualcuno
- Esprimere bisogni, intenzioni, desideri
- Esprimere accordo / disaccordo
- Scusarsi, fare gli auguri
- Telefonare / Rispondere al telefono
- Richiedere attenzione / Parola
- Chiedere chiarimenti
- Descrivere persone e luoghi
## Parte 3: Cultura e Società
### Competenze Socio-culturali
- **Torino**: Luoghi dinteresse storico
- **Geografia**: Italia e Piemonte
- **Personaggi**: Italiani famosi nel mondo
- **Lifestyle**:
- Le case degli italiani
- Alimentazione e gastronomia
- Orari, abitudini, passatempi
- **Viaggi**: Mete e mezzi di trasporto
- **Stereotipi**: Luoghi comuni sugli italiani
================================================
FILE: src/components/learning/LearningDashboard.tsx
================================================
'use client';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Button } from '@/components/ui/button';
import { TrendingUp, TrendingDown, Minus, Target, Lightbulb, Zap } from 'lucide-react';
import { useLearningMetrics, decideNextLesson } from '@/lib/learning/metrics';
import Link from 'next/link';
export function LearningDashboard() {
const metrics = useLearningMetrics(); // Now a hook - memoized
const nextLesson = decideNextLesson(metrics);
// Convert 0-1 to percentage
const transferPct = Math.round(metrics.transferAccuracy * 100);
const recoveryPct = Math.round(metrics.errorRecovery * 100);
// Color coding
const getColor = (value: number) => {
if (value >= 75) return 'text-green-600 bg-green-50 border-green-200';
if (value >= 50) return 'text-yellow-600 bg-yellow-50 border-yellow-200';
return 'text-red-600 bg-red-50 border-red-200';
};
const getProgressColor = (value: number) => {
if (value >= 75) return '[&>div]:bg-green-500';
if (value >= 50) return '[&>div]:bg-yellow-500';
return '[&>div]:bg-red-500';
};
const TrendIcon = metrics.timeTrend === 'improving' ? TrendingUp
: metrics.timeTrend === 'worsening' ? TrendingDown
: Minus;
return (
<div className="space-y-6">
{/* Section 1: Learning Progress */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Target className="h-5 w-5 text-indigo-600" />
Postęp w Nauce
</CardTitle>
<CardDescription>
Czy faktycznie się uczysz? Sprawdź kluczowe wskaźniki.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Transfer Accuracy */}
<div className="space-y-2">
<div className="flex justify-between items-center">
<div>
<h4 className="font-medium text-sm">Transfer Wiedzy</h4>
<p className="text-xs text-slate-500">Rozumienie reguł, nie zapamiętywanie</p>
</div>
<span className={`text-2xl font-bold ${transferPct >= 75 ? 'text-green-600' : transferPct >= 50 ? 'text-yellow-600' : 'text-red-600'}`}>
{transferPct}%
</span>
</div>
<Progress value={transferPct} className={getProgressColor(transferPct)} />
</div>
{/* Error Recovery */}
<div className="space-y-2">
<div className="flex justify-between items-center">
<div>
<h4 className="font-medium text-sm">Uczenie się na Błędach</h4>
<p className="text-xs text-slate-500">Poprawa po feedbacku</p>
</div>
<span className={`text-2xl font-bold ${recoveryPct >= 75 ? 'text-green-600' : recoveryPct >= 50 ? 'text-yellow-600' : 'text-red-600'}`}>
{recoveryPct}%
</span>
</div>
<Progress value={recoveryPct} className={getProgressColor(recoveryPct)} />
</div>
{/* Time Trend */}
<div className="space-y-2">
<div className="flex justify-between items-center">
<div>
<h4 className="font-medium text-sm">Automatyzacja</h4>
<p className="text-xs text-slate-500">Szybkość odpowiedzi</p>
</div>
<div className="flex items-center gap-2">
<TrendIcon className={`h-5 w-5 ${metrics.timeTrend === 'improving' ? 'text-green-600' :
metrics.timeTrend === 'worsening' ? 'text-red-600' :
'text-slate-400'
}`} />
<span className="text-sm font-medium capitalize">{
metrics.timeTrend === 'improving' ? 'Lepiej' :
metrics.timeTrend === 'worsening' ? 'Gorzej' :
'Stabilnie'
}</span>
</div>
</div>
</div>
{/* Summary */}
<div className={`p-4 rounded-lg border-2 ${(transferPct >= 75 && recoveryPct >= 75) ? 'bg-green-50 border-green-200' :
(transferPct >= 50 && recoveryPct >= 50) ? 'bg-yellow-50 border-yellow-200' :
'bg-red-50 border-red-200'
}`}>
<p className="text-sm font-medium">
{(transferPct >= 75 && recoveryPct >= 75)
? '✅ Świetnie! Realnie rozumiesz materiał i uczysz się na błędach.'
: (transferPct >= 50 && recoveryPct >= 50)
? '⚠️ Robisz postępy, ale jest jeszcze przestrzeń na poprawę.'
: '❌ Skupmy się na utrwaleniu podstaw przed pójściem dalej.'}
</p>
</div>
</CardContent>
</Card>
{/* Section 2: Weak Areas & Fluency */}
<div className="grid md:grid-cols-2 gap-6">
{/* Weak Areas */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Lightbulb className="h-5 w-5 text-amber-600" />
Obszary do Poprawy
</CardTitle>
</CardHeader>
<CardContent>
{metrics.weakAreas.length === 0 ? (
<p className="text-sm text-slate-500 text-center py-4">
Brak zidentyfikowanych słabych punktów. Brawo! 🎉
</p>
) : (
<div className="space-y-3">
{metrics.weakAreas.map((area, idx) => (
<div
key={idx}
className="flex justify-between items-center p-3 bg-slate-50 rounded-lg hover:bg-slate-100 transition cursor-pointer"
>
<div className="flex-1">
<h4 className="font-medium text-sm">{area.label}</h4>
<p className="text-xs text-slate-500">{area.count} błędów</p>
</div>
<Link href={area.topicId ? `/learn/${area.topicId}` : '/learn/smart-review'}>
<Button size="sm" variant="outline">
Ćwicz
</Button>
</Link>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Fluency Stats */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Zap className="h-5 w-5 text-blue-600" />
Płynność
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-center">
<div className="text-4xl font-bold text-blue-600">
{Math.round(metrics.avgTimePerQuestion / 1000)}s
</div>
<p className="text-sm text-slate-500 mt-1">Średni czas odpowiedzi</p>
</div>
<div className={`p-4 rounded-lg ${metrics.timeTrend === 'improving' ? 'bg-green-50 border border-green-200' :
metrics.timeTrend === 'worsening' ? 'bg-red-50 border border-red-200' :
'bg-slate-50 border border-slate-200'
}`}>
<p className="text-sm">
{metrics.timeTrend === 'improving'
? '📈 Świetnie! Odpowiadasz coraz szybciej - znak automatyzacji.'
: metrics.timeTrend === 'worsening'
? '📉 Zwalniasz - może materiał jest za trudny?'
: '➡️ Stabilny czas - kontynuuj praktykę.'}
</p>
</div>
<div className="grid grid-cols-2 gap-2 text-center text-sm">
<div className="p-2 bg-slate-50 rounded">
<div className="font-bold text-lg">{metrics.lastScoreRatio > 0 ? Math.round(metrics.lastScoreRatio * 100) : 0}%</div>
<div className="text-xs text-slate-500">Ostatni wynik</div>
</div>
<div className="p-2 bg-slate-50 rounded">
<div className="font-bold text-lg">{transferPct}%</div>
<div className="text-xs text-slate-500">Transfer</div>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Section 3: Next Best Lesson */}
<Card className="border-2 border-indigo-200 bg-gradient-to-br from-indigo-50 to-blue-50">
<CardHeader>
<CardTitle className="flex items-center gap-2">
🎯 Sugerowana Kolejna Lekcja
</CardTitle>
<CardDescription className="text-indigo-900">
{nextLesson.reason}
</CardDescription>
</CardHeader>
<CardContent>
<Button
size="lg"
className="w-full bg-indigo-600 hover:bg-indigo-700 text-white text-lg"
asChild
>
<Link href={
nextLesson.type === 'fix-weakness' && nextLesson.topicId
? `/learn/${nextLesson.topicId}`
: nextLesson.type === 'next-topic'
? '/learn/passato-prossimo' // Default next topic
: nextLesson.type === 'repeat-easier'
? '/learn/futuro-semplice?difficulty=A1'
: '/learn/futuro-semplice'
}>
{nextLesson.actionText}
</Link>
</Button>
</CardContent>
</Card>
</div>
);
}
================================================
FILE: src/components/tutor/ExamRunner.tsx
================================================
import React, { useState, useEffect, useRef } from 'react';
import { Quiz, Question } from '@/lib/ai/schemas';
import { QuestionCard } from './QuestionCard';
import { ExamSidebar } from './ExamSidebar';
import { useUserStore } from '@/lib/store/useUserStore';
import { toast } from 'sonner';
import { getTodaysDailyChallenge } from '@/lib/challenge/daily';
interface ExamRunnerProps {
quiz: Quiz;
onRestart: () => void;
topicId?: string;
isDailyChallenge?: boolean;
}
export function ExamRunner({ quiz, onRestart, topicId, isDailyChallenge }: ExamRunnerProps) {
const user = useUserStore();
const [userAnswers, setUserAnswers] = useState<Record<string, string>>({});
const [activeQuestionIndex, setActiveQuestionIndex] = useState(0);
const questionRefs = useRef<(HTMLDivElement | null)[]>([]);
// Initialize refs array
useEffect(() => {
questionRefs.current = questionRefs.current.slice(0, quiz.questions.length);
}, [quiz.questions.length]);
const handleAnswer = (questionId: string, answer: string) => {
setUserAnswers(prev => ({
...prev,
[questionId]: answer
}));
};
const scrollToQuestion = (index: number) => {
const el = questionRefs.current[index];
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
setActiveQuestionIndex(index);
}
};
// Intersection Observer to update active index on scroll
useEffect(() => {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const index = Number(entry.target.getAttribute('data-index'));
if (!isNaN(index)) {
setActiveQuestionIndex(index);
}
}
});
}, { threshold: 0.5, rootMargin: '-20% 0px -20% 0px' });
questionRefs.current.forEach(el => {
if (el) observer.observe(el);
});
return () => observer.disconnect();
}, [quiz.questions]);
const finishExam = () => {
// Validation: Check if all questions answered? Or just warn?
const answeredCount = Object.keys(userAnswers).length;
if (answeredCount < quiz.questions.length) {
if (!confirm(`Odpowiedziałeś na ${answeredCount} z ${quiz.questions.length} pytań. Czy na pewno chcesz zakończyć?`)) {
return;
}
}
let calculatedScore = 0;
quiz.questions.forEach((q, idx) => {
const uAns = userAnswers[q.id || `q-${idx}`];
if (uAns && uAns.trim().toLowerCase() === q.correctAnswer.trim().toLowerCase()) {
calculatedScore++;
}
});
// Save results
user.saveQuizResult({
id: crypto.randomUUID(),
date: new Date().toLocaleDateString('pl-PL'),
topic: quiz.topic,
total: quiz.questions.length,
score: calculatedScore,
questions: quiz.questions,
userAnswers: userAnswers
});
if (topicId || quiz.topic) {
user.updateTopicStats(topicId || quiz.topic, calculatedScore, quiz.questions.length);
}
if (isDailyChallenge) {
const challenge = getTodaysDailyChallenge();
user.completeDailyChallenge(challenge, calculatedScore, quiz.questions.length);
toast.success('Daily Challenge ukończony!');
}
// Show Score Dialog or Redirect?
// For now, simpler to reuse pure state or callback props?
// Wait, QuizRunner handles "isFinished" state internally to show review.
// We should probably do the same here OR unify.
// Let's implement a simple "Exam Summary" view here or reuse render?
// Since we are decoupling, let's just use a simple summary state here.
setIsFinished(true);
setFinalScore(calculatedScore);
};
const [isFinished, setIsFinished] = useState(false);
const [finalScore, setFinalScore] = useState(0);
if (isFinished) {
return (
<div className="max-w-2xl mx-auto py-12 text-center space-y-6">
<div className="text-4xl">🎉</div>
<h2 className="text-3xl font-bold">Egzamin Zakończony!</h2>
<div className="text-xl">
Twój wynik: <span className="font-bold text-indigo-600">{finalScore}</span> / {quiz.questions.length}
</div>
<button
onClick={onRestart}
className="px-8 py-3 bg-indigo-600 text-white rounded-xl font-semibold hover:bg-indigo-700 transition"
>
Wróć do nauki
</button>
</div>
);
}
return (
<div className="flex flex-col lg:flex-row gap-8 items-start relative pb-20">
{/* Main Question List */}
<div className="flex-1 w-full space-y-12">
{/* Header Info */}
<div className="bg-white dark:bg-zinc-800 p-8 rounded-3xl border border-gray-100 dark:border-gray-700 shadow-sm relative overflow-hidden">
<div className="absolute top-0 right-0 p-6 text-right opacity-50">
<div className="text-xs font-bold uppercase tracking-widest text-slate-500">Data</div>
<div className="text-sm font-medium">{new Date().toLocaleDateString('pl-PL')}</div>
</div>
<span className="inline-block px-3 py-1 bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 text-xs font-bold uppercase tracking-wider rounded-md mb-4">
Egzamin Gramatyczny
</span>
<h1 className="text-4xl font-bold text-slate-900 dark:text-white mb-2">{quiz.topic}</h1>
<p className="text-slate-500 max-w-lg">Proszę przeczytać uważnie polecenia i odpowiedzieć na wszystkie pytania. Powodzenia!</p>
</div>
{/* Questions */}
<div className="space-y-12">
{quiz.questions.map((q, idx) => (
<div
key={q.id || idx}
ref={el => { questionRefs.current[idx] = el }}
data-index={idx}
id={`question-${idx}`}
className={`transition-opacity duration-500 ${Math.abs(activeQuestionIndex - idx) > 1 ? 'opacity-50 hover:opacity-100' : 'opacity-100'}`}
>
<div className="flex items-baseline gap-4 mb-4">
<span className={`text-2xl font-bold font-mono ${activeQuestionIndex === idx ? 'text-indigo-600' : 'text-slate-300'}`}>
{(idx + 1).toString().padStart(2, '0')}.
</span>
<div className="flex-1">
<QuestionCard
question={q}
onAnswer={(isCorrect, ans) => handleAnswer(q.id || `q-${idx}`, ans)}
isLast={false} // Hide "Next" button logic
onNext={() => { }}
mode="exam"
initialValue={userAnswers[q.id || `q-${idx}`]}
variant="minimal"
/>
</div>
</div>
</div>
))}
</div>
</div>
{/* Sidebar */}
<ExamSidebar
totalQuestions={quiz.questions.length}
completedCount={Object.keys(userAnswers).length}
onFinish={finishExam}
currentQuestionIndex={activeQuestionIndex}
setCurrentQuestionIndex={scrollToQuestion}
answersState={userAnswers}
questionIds={quiz.questions.map((q, i) => q.id || `q-${i}`)}
tymeLeftSeconds={1500}
/>
</div>
);
}
================================================
FILE: src/components/tutor/ExamSidebar.tsx
================================================
import React, { useState, useEffect } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Clock, CheckCircle2, AlertCircle } from 'lucide-react';
interface ExamSidebarProps {
totalQuestions: number;
completedCount: number;
tymeLeftSeconds: number; // passed from parent to sync or managed here?
// Managing here is easier for UI tick, but parent needs to know when time is up.
// Let's accept seconds and onTick? Or just initialSeconds.
// Simpler: Parent handles generic timer logic, but visual countdown here?
// Let's pass `timeLeft` string or seconds from parent for robustness, or manage here.
// For now, let's manage visual tick here based on a prop.
onFinish: () => void;
currentQuestionIndex: number;
setCurrentQuestionIndex: (index: number) => void;
answersState: Record<string, string>; // questionId -> answer
questionIds: string[];
}
export function ExamSidebar({
totalQuestions,
completedCount,
onFinish,
currentQuestionIndex,
setCurrentQuestionIndex,
answersState,
questionIds
}: ExamSidebarProps) {
// Simple timer mock for now - in real app parent should probably drive this
// to auto-submit when time is up.
// We'll just display a static mock or simple countdown.
const [timeLeft, setTimeLeft] = useState(1500); // 25 mins
useEffect(() => {
const timer = setInterval(() => {
setTimeLeft(p => (p > 0 ? p - 1 : 0));
}, 1000);
return () => clearInterval(timer);
}, []);
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
};
return (
<div className="w-full lg:w-[320px] flex-shrink-0 flex flex-col gap-6 lg:sticky lg:top-24 h-fit">
<Card className="border-none shadow-soft bg-white dark:bg-zinc-800">
<CardContent className="p-6">
<div className="flex justify-between items-start mb-6">
<div>
<span className="text-xs font-bold text-slate-400 uppercase tracking-widest">Czas pozostały</span>
<div className="text-4xl font-bold text-slate-900 dark:text-white mt-1 tabular-nums tracking-tight">
{formatTime(timeLeft)}
</div>
</div>
<div className="size-2 rounded-full bg-indigo-500 animate-pulse mt-2"></div>
</div>
<div className="w-full bg-slate-100 dark:bg-slate-700 h-2 rounded-full overflow-hidden mb-8">
<div
className="bg-indigo-500 h-full transition-all duration-1000"
style={{ width: `${(timeLeft / 1500) * 100}%` }}
></div>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between text-sm">
<span className="font-bold text-slate-900 dark:text-white">Pytania</span>
<span className="text-slate-500">{completedCount} z {totalQuestions} ukończone</span>
</div>
<div className="grid grid-cols-5 gap-2">
{Array.from({ length: totalQuestions }).map((_, idx) => {
const qId = questionIds[idx];
const isAnswered = !!answersState[qId];
const isCurrent = currentQuestionIndex === idx;
return (
<button
key={idx}
onClick={() => setCurrentQuestionIndex(idx)} // Scroll to question
className={`
aspect-square rounded-full flex items-center justify-center text-sm font-medium transition-all
${isCurrent
? 'bg-indigo-600 text-white ring-4 ring-indigo-100 dark:ring-indigo-900/30'
: isAnswered
? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/50 dark:text-indigo-300'
: 'bg-slate-100 text-slate-400 dark:bg-slate-800 dark:text-slate-600 hover:bg-slate-200 dark:hover:bg-slate-700'
}
`}
>
{idx + 1}
</button>
);
})}
</div>
</div>
<div className="mt-8 flex items-center justify-between text-[10px] text-slate-400 uppercase font-semibold tracking-wider">
<div className="flex items-center gap-1">
<div className="size-2 rounded-full bg-indigo-600"></div> Obecne
</div>
<div className="flex items-center gap-1">
<div className="size-2 rounded-full bg-indigo-100 dark:bg-indigo-900/50"></div> Ukończone
</div>
<div className="flex items-center gap-1">
<div className="size-2 rounded-full bg-slate-100 dark:bg-slate-800"></div> Do zrobienia
</div>
</div>
<Button
onClick={onFinish}
className="w-full mt-8 bg-slate-900 hover:bg-slate-800 text-white dark:bg-white dark:text-black dark:hover:bg-gray-200 rounded-xl py-6 text-base font-semibold shadow-xl shadow-slate-200 dark:shadow-none transition-transform hover:scale-[1.02]"
>
<CheckCircle2 className="w-5 h-5 mr-2" />
Zakończ egzamin
</Button>
<Button variant="ghost" className="w-full mt-2 text-slate-400 hover:text-red-500 text-xs">
<AlertCircle className="w-3 h-3 mr-1.5" />
Zgłoś błąd w pytaniu
</Button>
</CardContent>
</Card>
</div>
);
}
================================================
FILE: src/components/tutor/FeedbackDisplay.tsx
================================================
import React from 'react';
import { WritingAnalysis } from '@/lib/ai/schemas';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
import { AlertCircle, CheckCircle, Info } from 'lucide-react';
interface FeedbackDisplayProps {
analysis: WritingAnalysis;
}
export function FeedbackDisplay({ analysis }: FeedbackDisplayProps) {
return (
<div className="space-y-6">
<Card className="border-t-4 border-t-purple-500 shadow-md">
<CardHeader>
<CardTitle className="flex justify-between items-center">
<span>Overall Feedback</span>
<span className={`text-2xl font-bold ${analysis.score >= 8 ? 'text-green-600' :
analysis.score >= 5 ? 'text-yellow-600' : 'text-red-600'
}`}>
{analysis.score}/10
</span>
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-lg leading-relaxed">{analysis.overallFeedback}</p>
</CardContent>
</Card>
<div className="space-y-4">
<h3 className="text-xl font-bold flex items-center gap-2">
<AlertCircle className="w-5 h-5 text-red-500" /> Corrections
</h3>
<ScrollArea className="h-[400px] pr-4">
<div className="space-y-4">
{analysis.corrections.length === 0 ? (
<div className="text-center p-8 bg-green-50 rounded-lg">
<CheckCircle className="w-12 h-12 text-green-500 mx-auto mb-2" />
<p className="text-green-700 font-medium">No errors found! Ottimo lavoro!</p>
</div>
) : (
analysis.corrections.map((item, idx) => (
<Card key={idx} className="border-l-4 border-l-red-400">
<CardContent className="p-4">
<div className="flex gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="line-through text-red-500 font-medium">{item.original}</span>
<span>→</span>
<span className="text-green-600 font-bold bg-green-50 px-2 rounded-sm">{item.correction}</span>
</div>
<p className="text-sm text-slate-600 mt-2 flex gap-2 items-start">
<Info className="w-4 h-4 shrink-0 mt-0.5 text-blue-500" />
{item.explanation}
</p>
</div>
<div className="text-xs font-mono uppercase text-slate-400 border px-2 py-1 rounded h-fit">
{item.type}
</div>
</div>
</CardContent>
</Card>
))
)}
</div>
</ScrollArea>
</div>
</div>
);
}
================================================
FILE: src/components/tutor/QuestionCard.tsx
================================================
import React from 'react';
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Question } from '@/lib/ai/schemas';
import { useQuestionState } from './question/hooks/useQuestionState';
import { QuestionRenderer } from './question/QuestionRenderer';
import { Lightbulb, Volume2 } from 'lucide-react';
import { speak } from '@/lib/utils';
interface QuestionCardProps {
question: Question;
onAnswer: (isCorrect: boolean, answer: string) => void;
isLast: boolean;
onNext: () => void;
readOnlyResult?: { answer: string; isCorrect: boolean };
mode?: 'practice' | 'exam';
initialValue?: string;
variant?: 'default' | 'minimal';
}
export function QuestionCard({
question,
onAnswer,
isLast,
onNext,
readOnlyResult,
mode = 'practice',
initialValue,
variant = 'default'
}: QuestionCardProps) {
const q = useQuestionState(readOnlyResult?.answer ?? initialValue);
const [showHint, setShowHint] = React.useState(false);
React.useEffect(() => {
if (readOnlyResult) {
q.setValue(readOnlyResult.answer);
}
}, [readOnlyResult, question.id]);
const correct = q.value?.trim().toLowerCase() === question.correctAnswer.trim().toLowerCase();
const handleSubmit = () => {
q.submit();
onAnswer(correct, q.value!);
};
const handleExamNext = () => {
onAnswer(false, q.value || '');
onNext();
};
if (variant === 'minimal') {
return (
<div className="space-y-4">
<div className="space-y-2">
<div className="text-lg font-medium leading-none mb-2 text-indigo-600/80">{question.instruction}</div>
{question.type !== 'dictation' && (
<h2 className="text-xl font-bold text-slate-900 dark:text-white flex items-start gap-2 leading-relaxed">
{question.question}
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-full text-slate-400 hover:text-indigo-600 -mt-1"
onClick={(e) => { e.stopPropagation(); speak(question.question); }}
>
<Volume2 className="h-4 w-4" />
</Button>
</h2>
)}
</div>
<div className="pl-0">
<QuestionRenderer
question={question}
value={q.value}
onChange={(val) => {
q.setValue(val);
// Auto-save answer in exam mode (minimal variant usually implies exam list)
if (mode === 'exam') {
onAnswer(false, val);
}
}}
disabled={!!readOnlyResult}
/>
</div>
</div>
);
}
return (
<Card>
<CardHeader className="flex flex-row justify-between items-start space-y-0 pb-2">
<div className="space-y-2">
<div className="text-lg font-medium leading-none mb-2">{question.instruction}</div>
{question.type !== 'dictation' && (
<h2 className="text-2xl font-bold text-slate-800 flex items-center gap-2">
{question.question}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full text-slate-500 hover:text-blue-600"
onClick={(e) => { e.stopPropagation(); speak(question.question); }}
>
<Volume2 className="h-4 w-4" />
</Button>
</h2>
)}
</div>
{mode === 'practice' && (
<Button variant="ghost" size="sm" onClick={() => setShowHint(!showHint)} className="text-amber-500">
<Lightbulb className="w-4 h-4 mr-1" />
{showHint ? 'Ukryj' : 'Podpowiedź'}
</Button>
)}
</CardHeader>
<CardContent className="space-y-4">
{showHint && mode === 'practice' && (
<div className="bg-amber-50 border border-amber-200 p-3 rounded text-sm text-amber-800 animate-in fade-in">
💡 {question.explanation.split('.')[0]}...
</div>
)}
<QuestionRenderer
question={question}
value={q.value}
onChange={q.setValue}
disabled={q.submitted || !!readOnlyResult}
/>
{(q.submitted || readOnlyResult) && mode === 'practice' && (
<div className={`mt-4 p-4 rounded-md border-l-4 ${(readOnlyResult ? readOnlyResult.isCorrect : correct)
? 'bg-green-50 border-green-500 text-green-900'
: 'bg-red-50 border-red-500 text-red-900'
}`}>
<div className="font-bold mb-1">
{(readOnlyResult ? readOnlyResult.isCorrect : correct) ? 'Świetnie!' : 'Niestety, to nie jest poprawna odpowiedź.'}
</div>
<div className="text-sm opacity-90">
{question.explanation}
</div>
</div>
)}
</CardContent>
<CardFooter className="justify-end pt-4">
{mode === 'exam' ? (
<Button onClick={handleExamNext}>
{isLast ? 'Zakończ' : 'Dalej'}
</Button>
) : (
!q.submitted && !readOnlyResult ? (
<Button disabled={!q.value} onClick={handleSubmit}>
Sprawdź
</Button>
) : (
<Button onClick={onNext}>
{isLast ? 'Zakończ' : 'Dalej'}
</Button>
)
)}
</CardFooter>
</Card>
);
}
================================================
FILE: src/components/tutor/QuizOptionsDialog.tsx
================================================
'use client';
import React, { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { ShieldCheck, Layers, Gauge, BookOpen, MessageCircle, Scale } from 'lucide-react';
import { ConfigStore } from '@/lib/store/config';
import { TestConfiguration } from '@/lib/ai/schemas';
import { useUserStore, suggestDifficulty } from '@/lib/store/useUserStore';
interface QuizOptionsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: (options: {
difficulty: string;
deepCheck: boolean;
amount: number;
mode: 'practice' | 'exam';
config?: TestConfiguration
}) => void;
topicName: string;
topicId: string; // Needed for Config
}
export function QuizOptionsDialog({ open, onOpenChange, onConfirm, topicName, topicId }: QuizOptionsDialogProps) {
const [difficulty, setDifficulty] = useState('A2');
const [deepCheck, setDeepCheck] = useState(false);
const [amount, setAmount] = useState(5);
const [mode, setMode] = useState<'practice' | 'exam'>('practice');
// New Didactic Params
const [tone, setTone] = useState<'formal' | 'informal'>('formal');
const [focus, setFocus] = useState<'grammar' | 'vocabulary' | 'pragmatics' | 'general'>('general');
const [sourceMode, setSourceMode] = useState<'syllabus-only' | 'ai-expanded'>('ai-expanded');
// Load defaults from config when opening
// Load defaults from config when opening
React.useEffect(() => {
if (open) {
const config = ConfigStore.get();
setDeepCheck(config.generationSettings.deepCheck);
// Adaptive suggestion
const user = useUserStore.getState();
const stats = user.topicStats[topicId];
if (stats) {
const suggested = suggestDifficulty(stats.mastery);
setDifficulty(suggested);
}
}
}, [open, topicId]);
const handleConfirm = () => {
const config: TestConfiguration = {
level: difficulty as any,
topicId: topicId,
focus: focus,
tone: tone,
exerciseTypes: ['multiple-choice', 'gap-fill'], // Default mix
sourceMode: sourceMode
};
onConfirm({ difficulty, deepCheck, amount, mode, config });
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Konfiguracja Quizu</DialogTitle>
<DialogDescription>
Dostosuj parametry dydaktyczne dla: <span className="font-semibold text-slate-800">{topicName}</span>
</DialogDescription>
</DialogHeader>
<div className="grid gap-6 py-4">
{/* Difficulty */}
<div className="space-y-2">
<label className="text-sm font-medium flex items-center gap-2">
<Gauge className="h-4 w-4" /> Poziom Trudności
</label>
<div className="grid grid-cols-4 gap-2">
{['A1', 'A2', 'B1', 'B2'].map((level) => (
<div
key={level}
onClick={() => setDifficulty(level)}
className={`cursor-pointer rounded-md border p-2 text-center text-sm font-medium transition-all ${difficulty === level
? 'bg-blue-600 text-white border-blue-600 shadow-sm'
: 'bg-white hover:bg-slate-50 border-slate-200 text-slate-700'
}`}
>
{level}
</div>
))}
</div>
</div>
{/* Focus & Tone (New Grid) */}
<div className="grid grid-cols-2 gap-4">
{/* Tone */}
<div className="space-y-2">
<label className="text-sm font-medium flex items-center gap-2">
<MessageCircle className="h-4 w-4" /> Ton Języka
</label>
<div className="flex rounded-md shadow-sm">
<button
onClick={() => setTone('formal')}
className={`flex-1 px-3 py-1.5 text-xs font-medium border rounded-l-md ${tone === 'formal' ? 'bg-slate-800 text-white' : 'bg-white text-slate-700'}`}
>
Formalny
</button>
<button
onClick={() => setTone('informal')}
className={`flex-1 px-3 py-1.5 text-xs font-medium border-t border-b border-r rounded-r-md ${tone === 'informal' ? 'bg-slate-800 text-white' : 'bg-white text-slate-700'}`}
>
Luźny
</button>
</div>
</div>
{/* Source Mode */}
<div className="space-y-2">
<label className="text-sm font-medium flex items-center gap-2">
<BookOpen className="h-4 w-4" /> Źródło Wiedzy
</label>
<div className="flex rounded-md shadow-sm">
<button
onClick={() => setSourceMode('syllabus-only')}
className={`flex-1 px-3 py-1.5 text-xs font-medium border rounded-l-md ${sourceMode === 'syllabus-only' ? 'bg-indigo-600 text-white' : 'bg-white text-slate-700'}`}
>
Sylabus
</button>
<button
onClick={() => setSourceMode('ai-expanded')}
className={`flex-1 px-3 py-1.5 text-xs font-medium border-t border-b border-r rounded-r-md ${sourceMode === 'ai-expanded' ? 'bg-indigo-600 text-white' : 'bg-white text-slate-700'}`}
>
+ AI
</button>
</div>
</div>
</div>
{/* Focus Selection */}
<div className="space-y-2">
<label className="text-sm font-medium flex items-center gap-2">
<Scale className="h-4 w-4" /> Fokus Testu
</label>
<div className="grid grid-cols-3 gap-2">
{[
{ id: 'grammar', label: 'Gramatyka' },
{ id: 'vocabulary', label: 'Słownictwo' },
{ id: 'pragmatics', label: 'Komunikacja' }
].map((f) => (
<div
key={f.id}
onClick={() => setFocus(f.id as any)}
className={`cursor-pointer rounded-md border p-2 text-center text-xs font-medium transition-all ${focus === f.id
? 'bg-emerald-600 text-white border-emerald-600'
: 'bg-white hover:bg-slate-50 border-slate-200 text-slate-700'
}`}
>
{f.label}
</div>
))}
</div>
</div>
{/* Mode Selection */}
<div className="space-y-2">
<label className="text-sm font-medium">Tryb Sprawdzania</label>
<div className="grid grid-cols-2 gap-2">
<div
onClick={() => setMode('practice')}
className={`cursor-pointer rounded-md border p-2 text-center transition-all ${mode === 'practice'
? 'bg-blue-50 border-blue-500 text-blue-700 ring-1 ring-blue-500'
: 'bg-white hover:bg-slate-50 border-slate-200 text-slate-600'
}`}
>
<div className="text-sm font-bold">Trening 🏋️</div>
</div>
<div
onClick={() => setMode('exam')}
className={`cursor-pointer rounded-md border p-2 text-center transition-all ${mode === 'exam'
? 'bg-amber-50 border-amber-500 text-amber-700 ring-1 ring-amber-500'
: 'bg-white hover:bg-slate-50 border-slate-200 text-slate-600'
}`}
>
<div className="text-sm font-bold">Egzamin 🎓</div>
</div>
</div>
</div>
{/* Agentic Check */}
<div className="flex items-start space-x-3 rounded-md border p-3 shadow-sm bg-slate-50">
<ShieldCheck className={`h-5 w-5 mt-0.5 ${deepCheck ? 'text-green-600' : 'text-slate-400'}`} />
<div className="flex-1 space-y-1">
<div className="flex items-center justify-between">
<label className="text-sm font-medium leading-none">
Agent Weryfikator
</label>
<input
type="checkbox"
checked={deepCheck}
onChange={(e) => setDeepCheck(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-600"
/>
</div>
<p className="text-xs text-slate-500">
Wymusza podwójną weryfikację poprawności merytorycznej przez AI.
</p>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>Anuluj</Button>
<Button onClick={handleConfirm} className="bg-blue-600 hover:bg-blue-700 text-white">
Generuj Quiz 🚀
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
================================================
FILE: src/components/tutor/QuizReview.tsx
================================================
import React, { useState } from 'react';
import { Quiz, Question } from '@/lib/ai/schemas';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { CheckCircle2, Bot, AlertCircle, RotateCcw } from 'lucide-react';
import { explainAnswerAction } from '@/lib/ai/explain';
export interface QuizReviewProps {
quiz: Quiz;
userAnswers: Record<string, string>;
score: number;
onRestart: () => void;
onRetry?: () => void; // Restarts same quiz
}
export function QuizReview({ quiz, userAnswers, score, onRestart, onRetry }: QuizReviewProps) {
const [explanations, setExplanations] = useState<Record<string, string>>({});
const [loadingExplanation, setLoadingExplanation] = useState<string | null>(null);
const requestExplanation = async (q: Question, uAns: string) => {
setLoadingExplanation(q.id!);
const explanation = await explainAnswerAction(q, uAns || "Brak odpowiedzi", quiz.topic);
setExplanations(prev => ({ ...prev, [q.id!]: explanation }));
setLoadingExplanation(null);
};
return (
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4">
<Card className="max-w-3xl mx-auto text-center py-8 shadow-xl border-t-8 border-t-blue-500">
<CardHeader>
<CardTitle className="text-3xl">Raport Końcowy 📊</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex justify-center items-center gap-6">
<div className="text-center">
<div className="text-6xl font-black text-blue-600">
{Math.round((score / quiz.questions.length) * 100)}%
</div>
<div className="text-sm text-slate-400 uppercase tracking-wider font-bold mt-1">Wynik</div>
</div>
</div>
<p className="text-lg text-muted-foreground">
Zdobyłeś {score} z {quiz.questions.length} punktów.
</p>
<div className="text-left space-y-4 mt-8 px-4">
<h3 className="font-bold text-lg text-slate-800 border-b pb-2">Szczegółowa Analiza</h3>
{quiz.questions.map((q, idx) => {
const uAns = userAnswers[q.id || `q-${idx}`];
const isCorrect = uAns?.trim().toLowerCase() === q.correctAnswer.trim().toLowerCase();
const qId = q.id || `q-${idx}`;
return (
<div key={qId} className={`p-4 rounded-lg border ${isCorrect ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200'}`}>
<div className="flex justify-between items-start">
<div className="font-medium text-slate-800 mb-1">
{idx + 1}. {q.question}
</div>
{isCorrect ? <CheckCircle2 className="text-green-600 h-5 w-5" /> : <AlertCircle className="text-red-500 h-5 w-5" />}
</div>
<div className="grid grid-cols-2 gap-4 text-sm mt-2">
<div>
<span className="text-slate-500 block text-xs">Twoja odpowiedź:</span>
<span className={isCorrect ? 'text-green-700 font-medium' : 'text-red-700 font-medium line-through'}>
{uAns || "(Brak)"}
</span>
</div>
<div>
<span className="text-slate-500 block text-xs">Poprawna odpowiedź:</span>
<span className="text-slate-800 font-medium">{q.correctAnswer}</span>
</div>
</div>
{/* Explanation Area */}
<div className="mt-3 text-sm text-slate-600 bg-white/50 p-2 rounded">
💡 {q.explanation}
</div>
{/* AI Explain Button */}
{!isCorrect && (
<div className="mt-3">
{explanations[qId] ? (
<div className="bg-blue-100 text-blue-800 p-3 rounded text-sm flex gap-2">
<Bot className="h-5 w-5 shrink-0" />
{explanations[qId]}
</div>
) : (
<Button
variant="ghost"
size="sm"
className="text-blue-600 hover:bg-blue-100"
onClick={() => requestExplanation(q, uAns)}
disabled={loadingExplanation === qId}
>
<Bot className="h-4 w-4 mr-2" />
{loadingExplanation === qId ? 'Generuję...' : 'Dopytaj AI dlaczego?'}
</Button>
)}
</div>
)}
</div>
);
})}
</div>
<div className="flex gap-4 justify-center mt-8">
<Button onClick={onRestart} size="lg" variant="outline">
<RotateCcw className="mr-2 h-4 w-4" /> Nowy Quiz
</Button>
{onRetry && (
<Button onClick={onRetry} size="lg">
<RotateCcw className="mr-2 h-4 w-4" /> Spróbuj Ponownie
</Button>
)}
</div>
</CardContent>
</Card>
</div>
);
}
================================================
FILE: src/components/tutor/QuizReviewDialog.tsx
================================================
import React from 'react';
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { QuizResult } from '@/lib/store/useUserStore';
import { QuizReview } from './QuizReview';
interface QuizReviewDialogProps {
result: QuizResult | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onRetry?: () => void;
}
export function QuizReviewDialog({ result, open, onOpenChange, onRetry }: QuizReviewDialogProps) {
if (!result) return null;
// Reconstruct quiz from result
// TODO: Store difficulty in QuizResult to avoid hardcoding
const quiz = {
topic: result.topic,
difficulty: (result as any).difficulty || 'A2' as const, // Use actual difficulty if stored, or default to A2
questions: result.questions
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto p-0 border-none bg-transparent shadow-none">
<QuizReview
quiz={quiz}
userAnswers={result.userAnswers}
score={result.score}
onRestart={() => onOpenChange(false)}
onRetry={onRetry}
/>
</DialogContent>
</Dialog>
);
}
================================================
FILE: src/components/tutor/QuizRunner.tsx
================================================
import React, { useState, useMemo } from 'react';
import { Quiz, Question } from '@/lib/ai/schemas';
import { QuestionCard } from './QuestionCard';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { RotateCcw, ChevronLeft, ChevronRight, CheckCircle2, Bot, AlertCircle, Lightbulb } from 'lucide-react';
import { useUserStore } from '@/lib/store/useUserStore';
import { explainAnswerAction } from '@/lib/ai/explain';
import { toast } from 'sonner';
import { QuizReview } from './QuizReview';
import { getTodaysDailyChallenge } from '@/lib/challenge/daily';
import { orderQuestionsByDifficulty, calculatePartialPoints } from '@/lib/didactic/utils';
import { ExamRunner } from './ExamRunner';
interface QuizRunnerProps {
quiz: Quiz;
onRestart: () => void;
mode?: 'practice' | 'exam';
topicId?: string;
isDailyChallenge?: boolean;
}
export function QuizRunner({ quiz, onRestart, mode = 'practice', topicId, isDailyChallenge }: QuizRunnerProps) {
const user = useUserStore();
if (mode === 'exam') {
return (
<ExamRunner
quiz={quiz}
onRestart={onRestart}
topicId={topicId}
isDailyChallenge={isDailyChallenge}
/>
);
}
// Order quiz questions from easy to hard (scaffolding)
const orderedQuestions = useMemo(() => orderQuestionsByDifficulty(quiz.questions), [quiz.questions]);
const [currentIndex, setCurrentIndex] = useState(0);
const [score, setScore] = useState(0);
const [isFinished, setIsFinished] = useState(false);
// historySaved removed - dead code (never actually used)
const [userAnswers, setUserAnswers] = useState<Record<string, string>>({});
// AI Explanations cache: { questionId: explanation }
const [explanations, setExplanations] = useState<Record<string, string>>({});
const [loadingExplanation, setLoadingExplanation] = useState<string | null>(null);
const question = orderedQuestions[currentIndex];
const progress = ((currentIndex + 1) / orderedQuestions.length) * 100;
// Handler for Practice and Exam
const handleAnswer = (isCorrect: boolean, answer: string) => {
// Update local state first
const newAnswers = { ...userAnswers, [question.id || `q-${currentIndex}`]: answer };
setUserAnswers(newAnswers);
if (mode === 'practice') {
if (isCorrect) {
setScore(s => s + 1);
}
// Award points with partial credit for close answers
const points = calculatePartialPoints(isCorrect, answer, question.correctAnswer);
user.addPoints(points);
if (points === 5) {
toast.info('⚠️ Byłeś blisko! +5 punktów');
}
// Navigation handled by "Next" button in QuestionCard in practice mode
}
};
const finishExam = () => {
// Calculate score
let calculatedScore = 0;
quiz.questions.forEach((q, idx) => {
const uAns = userAnswers[q.id || `q-${idx}`];
if (uAns && uAns.trim().toLowerCase() === q.correctAnswer.trim().toLowerCase()) {
calculatedScore++;
}
});
setScore(calculatedScore);
// Bonus for perfect score
if (calculatedScore === quiz.questions.length) user.addPoints(50);
user.addPoints(calculatedScore * 10); // Base points
// Directly Save history HERE to avoid stale state race condition
user.saveQuizResult({
id: crypto.randomUUID(), // Use UUID to avoid collisions and SSR mismatch
date: new Date().toLocaleDateString('pl-PL'),
topic: quiz.topic,
total: quiz.questions.length,
score: calculatedScore, // Use local variable!
questions: quiz.questions,
userAnswers: userAnswers
});
// Update topic stats if topicId is available
if (topicId || quiz.topic) {
user.updateTopicStats(topicId || quiz.topic, calculatedScore, quiz.questions.length);
}
// Complete daily challenge if applicable
if (isDailyChallenge) {
const challenge = getTodaysDailyChallenge();
user.completeDailyChallenge(challenge, calculatedScore, quiz.questions.length);
// Show streak milestone celebration
const newStreak = user.dailyProgress.currentStreak + 1;
if (newStreak === 7) {
toast.success('🔥 7-dniowy streak! +100 bonus punktów!', { duration: 5000 });
} else if (newStreak === 30) {
toast.success('🌟 30-dniowy streak! +500 bonus punktów!', { duration: 5000 });
} else if (newStreak === 100) {
toast.success('💎 100-dniowy streak! +2000 bonus punktów!', { duration: 8000 });
} else {
toast.success(`Daily Challenge ukończony! +50 bonus punktów`, { duration: 3000 });
}
}
// History already saved above - setHistorySaved removed (dead code)
setIsFinished(true);
};
const handleNext = () => {
if (currentIndex < quiz.questions.length - 1) {
setCurrentIndex(currentIndex + 1);
} else {
setIsFinished(true);
}
};
const handlePrev = () => {
if (currentIndex > 0) {
setCurrentIndex(currentIndex - 1);
}
};
const requestExplanation = async (q: Question, uAns: string) => {
setLoadingExplanation(q.id!);
const explanation = await explainAnswerAction(q, uAns || "Brak odpowiedzi", quiz.topic);
setExplanations(prev => ({ ...prev, [q.id!]: explanation }));
setLoadingExplanation(null);
};
if (isFinished) {
return (
<QuizReview
quiz={quiz}
userAnswers={userAnswers}
score={score}
onRestart={onRestart}
onRetry={() => {
setIsFinished(false);
setCurrentIndex(0);
setScore(0);
setUserAnswers({});
}}
/>
);
}
return (
<div className="w-full max-w-3xl mx-auto space-y-6">
<div className="flex items-center justify-between px-1">
<div className="text-sm text-muted-foreground space-x-4">
<span>Pytanie {currentIndex + 1} z {quiz.questions.length}</span>
<span>Wynik: {score}</span>
</div>
<Button variant="ghost" size="sm" onClick={() => {
if (confirm("Czy na pewno chcesz przerwać quiz? Postęp zostanie utracony.")) {
onRestart();
}
}} className="text-slate-400 hover:text-red-500">
<RotateCcw className="h-4 w-4 mr-1" /> Wyjdź
</Button>
</div>
<Progress value={progress} className="h-2" />
<QuestionCard
key={question.id || currentIndex}
question={question}
onAnswer={handleAnswer}
isLast={currentIndex === quiz.questions.length - 1}
onNext={handleNext}
mode="practice"
initialValue={userAnswers[question.id || `q-${currentIndex}`]}
/>
</div>
);
}
================================================
FILE: src/components/tutor/SpeakingDrill.tsx
================================================
'use client';
import React, { useState, useEffect, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Mic, MicOff, AlertCircle } from 'lucide-react';
interface SpeakingDrillProps {
onTranscript: (text: string) => void;
}
export function SpeakingDrill({ onTranscript }: SpeakingDrillProps) {
const [isListening, setIsListening] = useState(false);
const [transcript, setTranscript] = useState('');
const [error, setError] = useState<string | null>(null);
const recognitionRef = useRef<any>(null);
useEffect(() => {
if (typeof window !== 'undefined' && 'webkitSpeechRecognition' in window) {
// @ts-ignore
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
const recognition = new SpeechRecognition();
recognition.lang = 'it-IT'; // Italian by default
recognition.continuous = false;
recognition.interimResults = true;
recognition.onstart = () => setIsListening(true);
recognition.onend = () => setIsListening(false);
recognition.onresult = (event: any) => {
const current = event.resultIndex;
const transcriptText = event.results[current][0].transcript;
setTranscript(transcriptText);
if (event.results[current].isFinal) {
onTranscript(transcriptText);
}
};
recognition.onerror = (event: any) => {
setError('Błąd rozpoznawania: ' + event.error);
setIsListening(false);
};
recognitionRef.current = recognition;
} else {
setError('Twoja przeglądarka nie obsługuje rozpoznawania mowy.');
}
}, [onTranscript]);
const toggleMic = () => {
if (!recognitionRef.current) return;
if (isListening) {
recognitionRef.current.stop();
} else {
setError(null);
setTranscript('');
recognitionRef.current.start();
}
};
if (error && error.includes('nie obsługuje')) {
return (
<div className="text-red-500 text-sm flex items-center gap-2">
<AlertCircle className="h-4 w-4" /> {error}
</div>
);
}
return (
<Card className={`transition-all duration-300 ${isListening ? 'border-red-400 shadow-red-100 bg-red-50' : 'border-slate-200'}`}>
<CardContent className="p-4 flex flex-col items-center justify-center gap-4 text-center">
<div className={`relative rounded-full p-4 transition-all duration-500 ${isListening ? 'bg-red-100 scale-110' : 'bg-slate-100'}`}>
{isListening && <span className="absolute inset-0 rounded-full animate-ping bg-red-200 opacity-75"></span>}
<Mic className={`h-8 w-8 relative z-10 ${isListening ? 'text-red-600' : 'text-slate-400'}`} />
</div>
<div>
<h3 className="font-bold text-slate-700">Mówienie (Beta)</h3>
<p className="text-sm text-slate-500">
{isListening ? 'Słucham... powiedz coś po włosku!' : 'Kliknij mikrofon i mów.'}
</p>
</div>
{transcript && (
<div className="bg-white p-3 rounded border border-slate-200 w-full text-lg font-medium italic text-slate-800">
"{transcript}"
</div>
)}
{error && <p className="text-xs text-red-500">{error}</p>}
<Button
onClick={toggleMic}
variant={isListening ? "destructive" : "default"}
className={isListening ? 'animate-pulse' : ''}
>
{isListening ? <><MicOff className="mr-2 h-4 w-4" /> Zatrzymaj</> : <><Mic className="mr-2 h-4 w-4" /> Rozpocznij</>}
</Button>
</CardContent>
</Card>
);
}
================================================
FILE: src/components/tutor/TheoryUploader.tsx
================================================
import React, { useState, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Upload, FileText, CheckCircle } from 'lucide-react';
interface TheoryUploaderProps {
onUpload: (content: string) => void;
}
export function TheoryUploader({ onUpload }: TheoryUploaderProps) {
const [text, setText] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setText(e.target.value);
onUpload(e.target.value);
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const content = event.target?.result as string;
setText(content);
onUpload(content);
};
reader.readAsText(file);
};
return (
<Card className="border-dashed border-2 bg-slate-50/50">
<CardHeader className="pb-2">
<CardTitle className="text-lg flex items-center gap-2">
<Upload className="w-5 h-5 text-blue-600" />
Własne Materiały
</CardTitle>
<CardDescription>
Wgraj notatki, aby stworzyć spersonalizowany quiz.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div
className="border-2 border-dashed border-slate-300 rounded-xl p-8 flex flex-col items-center justify-center text-center cursor-pointer hover:bg-white hover:border-blue-400 transition-all group"
onClick={() => fileInputRef.current?.click()}
>
<div className="bg-blue-100 p-4 rounded-full mb-3 group-hover:scale-110 transition-transform">
<Upload className="w-8 h-8 text-blue-600" />
</div>
<h3 className="font-semibold text-slate-700">Kliknij, aby wgrać plik</h3>
<p className="text-xs text-slate-500 mt-1">Obsługiwane formaty: .txt, .md</p>
<input
type="file"
accept=".txt,.md"
className="hidden"
ref={fileInputRef}
onChange={handleFileUpload}
/>
</div>
<div className="relative">
<div className="absolute inset-x-0 top-0 h-px bg-slate-200 flex items-center justify-center">
<span className="bg-slate-50 px-2 text-xs text-slate-400 uppercase">lub wklej tekst</span>
</div>
<Textarea
value={text}
onChange={handleTextChange}
placeholder="Wklej tutaj treść artykułu, notatki lub lekcję..."
className="mt-4 min-h-[150px] font-mono text-sm resize-none focus-visible:ring-blue-500 bg-white"
/>
{text.length > 50 && (
<div className="absolute bottom-2 right-2 text-green-600 flex items-center gap-1 text-xs font-bold bg-white px-2 py-1 rounded shadow-sm border">
<CheckCircle className="w-3 h-3" /> Gotowe do analizy
</div>
)}
</div>
</CardContent>
</Card>
);
}
================================================
FILE: src/components/tutor/TheoryViewer.tsx
================================================
import React from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
// @ts-ignore
import remarkDirective from 'remark-directive';
import { visit } from 'unist-util-visit';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
BookOpen, Star, Volume2, Table as TableIcon,
Calendar, Brain, Lightbulb, Info, AlertTriangle, CheckCircle
} from 'lucide-react';
import { speak } from '@/lib/utils';
import { Button } from '@/components/ui/button';
interface TheoryViewerProps {
content: string;
topic: string;
}
// Custom Plugin to handle :::directive syntax
function remarkDirectivePlugin() {
return (tree: any) => {
visit(tree, (node) => {
if (
node.type === 'textDirective' ||
node.type === 'leafDirective' ||
node.type === 'containerDirective'
) {
const data = node.data || (node.data = {});
const attributes = node.attributes || {};
const name = node.name;
if (name === 'card' || name === 'grid' || name === 'callout') {
data.hName = name;
data.hProperties = attributes;
}
}
});
};
}
const ICONS: Record<string, React.ElementType> = {
calendar: Calendar,
brain: Brain,
lightbulb: Lightbulb,
info: Info,
alert: AlertTriangle,
check: CheckCircle
};
export function TheoryViewer({ content, topic }: TheoryViewerProps) {
return (
<Card className="h-full border-none shadow-none bg-transparent">
<CardContent className="p-0">
<ScrollArea className="h-full w-full pr-4">
<article className="max-w-none pb-20 prose prose-slate dark:prose-invert prose-headings:font-bold prose-h1:text-3xl prose-h2:text-2xl prose-h2:mt-8 prose-p:text-slate-600 dark:prose-p:text-slate-300 prose-p:leading-7 prose-strong:text-indigo-600 dark:prose-strong:text-indigo-400">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkDirective, remarkDirectivePlugin]}
components={{
// @ts-ignore
grid: ({ children, className }) => (
<div className={`grid grid-cols-1 md:grid-cols-2 gap-6 my-8 ${className || ''}`}>
{children}
</div>
),
// @ts-ignore
card: ({ children, title, icon, color = 'blue' }) => {
const IconComponent = ICONS[icon] || Info;
const bgClass = `bg-${color}-50 dark:bg-${color}-900/20`;
const iconBgClass = `bg-${color}-100 dark:bg-${color}-900/40`;
const textClass = `text-${color}-600 dark:text-${color}-400`;
// Fallback if dynamic classes don't work due to Tailwind JIT
// We can map specific colors or just use style for now or ensuring safelist
// For safety, let's map common generic styles or rely on exact strings in MD if helpful.
// Better approach: explicit map
const styles: any = {
blue: { bg: 'bg-blue-50 dark:bg-blue-900/10', iconBg: 'bg-blue-100 dark:bg-blue-900/30', text: 'text-blue-600 dark:text-blue-400', border: 'border-blue-100 dark:border-blue-900/30' },
purple: { bg: 'bg-purple-50 dark:bg-purple-900/10', iconBg: 'bg-purple-100 dark:bg-purple-900/30', text: 'text-purple-600 dark:text-purple-400', border: 'border-purple-100 dark:border-purple-900/30' },
indigo: { bg: 'bg-indigo-50 dark:bg-indigo-900/10', iconBg: 'bg-indigo-100 dark:bg-indigo-900/30', text: 'text-indigo-600 dark:text-indigo-400', border: 'border-indigo-100 dark:border-indigo-900/30' },
yellow: { bg: 'bg-yellow-50 dark:bg-yellow-900/10', iconBg: 'bg-yellow-100 dark:bg-yellow-900/30', text: 'text-yellow-700 dark:text-yellow-400', border: 'border-yellow-100 dark:border-yellow-900/30' },
};
const style = styles[color] || styles.blue;
return (
<div className={`${style.bg} ${style.border} h-full p-6 rounded-2xl border shadow-sm flex gap-4 not-prose hover:shadow-md transition-all`}>
<div className={`size-12 rounded-2xl ${style.iconBg} ${style.text} flex items-center justify-center flex-shrink-0 shadow-inner`}>
<IconComponent className="w-6 h-6" />
</div>
<div className="flex flex-col">
{title && <h3 className={`font-bold text-lg text-slate-800 dark:text-white mb-2`}>{title}</h3>}
<div className="text-sm text-slate-600 dark:text-slate-300 leading-relaxed flex-1">
{children}
</div>
</div>
</div>
);
},
h1: ({ children }) => <h1 className="text-3xl font-extrabold text-slate-900 dark:text-white mt-4 mb-6">{children}</h1>,
h2: ({ children }) => (
<h2 className="text-2xl font-bold text-slate-800 dark:text-white mt-10 mb-6 flex items-center gap-3">
{children}
</h2>
),
table: ({ children }) => (
<div className="my-8 overflow-hidden rounded-2xl border border-gray-200 dark:border-gray-700 shadow-sm bg-white dark:bg-zinc-800/50">
<div className="overflow-x-auto">
<table className="w-full text-sm text-left border-collapse">{children}</table>
</div>
</div>
),
thead: ({ children }) => <thead className="bg-gray-50 dark:bg-white/5 border-b border-gray-200 dark:border-gray-700">{children}</thead>,
th: ({ children }) => <th className="px-6 py-4 font-semibold text-slate-700 dark:text-gray-300">{children}</th>,
td: ({ children }) => <td className="px-6 py-4 text-slate-600 dark:text-slate-400 border-b border-gray-100 dark:border-gray-800 last:border-0">{children}</td>,
p: ({ children }) => {
const textContent = React.Children.toArray(children)
.map(child => typeof child === 'string' ? child : '')
.join(' ');
return (
<div className="group flex items-start gap-2 mb-4 relative">
<p className="flex-1">{children}</p>
{textContent.length > 5 && (
<Button
variant="ghost"
size="icon"
onClick={() => speak(textContent)}
className="opacity-0 group-hover:opacity-100 transition-opacity absolute -right-8 top-0 text-slate-400 hover:text-indigo-600 h-6 w-6"
>
<Volume2 className="h-4 w-4" />
</Button>
)}
</div>
);
}
}}
>
{content}
</ReactMarkdown>
</article>
</ScrollArea>
</CardContent>
</Card>
);
}
================================================
FILE: src/components/tutor/WritingCanvas.tsx
================================================
import React, { useState } from 'react';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
import { analyzeWriting } from '@/lib/ai/actions';
import { WritingAnalysis } from '@/lib/ai/schemas';
import { Loader2, PenTool } from 'lucide-react';
import { toast } from 'sonner';
interface WritingCanvasProps {
onAnalysisComplete: (result: WritingAnalysis, originalText: string) => void;
prompt?: string;
}
export function WritingCanvas({ onAnalysisComplete, prompt }: WritingCanvasProps) {
const [text, setText] = useState('');
const [loading, setLoading] = useState(false);
const [strictness, setStrictness] = useState<'forgiving' | 'academic'>('academic');
// ...
const handleAnalyze = async () => {
if (!text.trim()) {
toast.warning('Najpierw napisz coś!');
return;
}
setLoading(true);
try {
const result = await analyzeWriting(text, prompt || 'General correction', strictness);
if (result.success && result.data) {
onAnalysisComplete(result.data, text);
toast.success('Analiza zakończona!');
} else {
toast.error(result.error || 'Nie udało się przeanalizować tekstu.');
}
} catch (e) {
toast.error('Błąd połączenia.');
} finally {
setLoading(false);
}
};
return (
<div className="space-y-4">
<div className="relative">
<div className="absolute top-[-10px] left-4 bg-white px-2 text-sm text-blue-600 font-bold z-10">
Twój Tekst
</div>
<Textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Pisz tutaj po włosku..."
className="min-h-[300px] p-6 text-lg leading-relaxed shadow-inner bg-slate-50 border-2 resize-none focus-visible:ring-blue-500"
/>
</div>
<div className="flex justify-between items-center bg-slate-100 p-3 rounded-lg">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-600">Tryb Sprawdzania:</span>
<select
value={strictness}
onChange={(e) => setStrictness(e.target.value as 'forgiving' | 'academic')}
className="p-1 rounded border-slate-300 text-sm"
>
<option value="academic">Akademicki (Surowy)</option>
<option value="forgiving">Przyjacielski (Łagodny)</option>
</select>
</div>
<Button
onClick={handleAnalyze}
disabled={loading || !text.trim()}
size="lg"
className="bg-purple-600 hover:bg-purple-700 text-white shadow-lg shadow-purple-200"
>
{loading ? (
<><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Analizowanie...</>
) : (
<><PenTool className="mr-2 h-4 w-4" /> Sprawdź z Asystentem AI</>
)}
</Button>
</div>
</div>
);
}
================================================
FILE: src/components/tutor/question/QuestionRenderer.tsx
================================================
import { QuestionRendererProps } from './types';
import { MultipleChoice } from './renderers/MultipleChoice';
import { GapFill } from './renderers/GapFill';
import { ErrorCorrection } from './renderers/ErrorCorrection';
import { Dictation } from './renderers/Dictation';
import { SimpleInput } from './renderers/SimpleInput';
export function QuestionRenderer(props: QuestionRendererProps) {
switch (props.question.type) {
case 'multiple-choice':
return <MultipleChoice {...props} />;
case 'gap-fill':
return <GapFill {...props} />;
case 'error-correction':
return <ErrorCorrection {...props} />;
case 'dictation':
return <Dictation {...props} />;
case 'scramble':
case 'transformation':
return <SimpleInput {...props} />;
default:
return <p>Nieobsługiwany typ pytania</p>;
}
}
================================================
FILE: src/components/tutor/question/types.ts
================================================
import { Question } from '@/lib/ai/schemas';
export interface QuestionSession {
questionId: string;
answer: string | null;
isSubmitted: boolean;
}
export interface QuestionRendererProps {
question: Question;
value: string | null;
onChange: (value: string) => void;
disabled: boolean;
}
================================================
FILE: src/components/tutor/question/hooks/useQuestionState.ts
================================================
import { useState } from 'react';
export function useQuestionState(initialValue?: string) {
const [value, setValue] = useState<string | null>(initialValue ?? null);
const [submitted, setSubmitted] = useState(false);
return {
value,
setValue,
submitted,
submit: () => setSubmitted(true),
reset: () => {
setValue(null);
setSubmitted(false);
}
};
}
================================================
FILE: src/components/tutor/question/renderers/Dictation.tsx
================================================
import { Button } from '@/components/ui/button';
import { QuestionRendererProps } from '../types';
import { Volume2, Clock } from 'lucide-react';
import { speak } from '@/lib/utils';
export function Dictation({
question,
value,
onChange,
disabled
}: QuestionRendererProps) {
return (
<div className="space-y-6 text-center py-8">
<div className="mb-6">
<div className="w-24 h-24 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-4 animate-pulse">
<Volume2 className="w-12 h-12 text-blue-600" />
</div>
<p className="text-slate-500 mb-4">Kliknij, aby posłuchać</p>
<div className="flex justify-center gap-4">
<Button
size="lg"
className="gap-2 text-lg px-8 py-6"
onClick={() => speak(question.question, 'it-IT', 1)}
>
<Volume2 className="w-6 h-6" /> Odsłuchaj
</Button>
<Button
variant="outline"
size="lg"
className="gap-2 px-6 py-6"
onClick={() => speak(question.question, 'it-IT', 0.5)}
title="Tryb żółwia"
>
<Clock className="w-6 h-6" /> Wolniej
</Button>
</div>
</div>
<div className="max-w-md mx-auto">
<input
type="text"
className="w-full p-4 text-center border-2 border-slate-200 rounded-xl font-mono text-2xl focus:border-blue-500 outline-none transition-all placeholder:text-slate-300"
placeholder="Wpisz to co słyszysz..."
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
value={value || ''}
autoComplete="off"
autoCapitalize="off"
/>
</div>
</div>
);
}
================================================
FILE: src/components/tutor/question/renderers/ErrorCorrection.tsx
================================================
import React from 'react';
import { Button } from '@/components/ui/button';
import { QuestionRendererProps } from '../types';
import { Question } from '@/lib/ai/schemas';
export function ErrorCorrection({
question: q,
value,
onChange,
disabled
}: QuestionRendererProps) {
// We know this is an error-correction question based on usage in QuestionRenderer
const question = q as Extract<Question, { type: 'error-correction' }>;
return (
<div className="space-y-6">
<div className="text-xl text-center font-serif leading-relaxed p-6 bg-amber-50 rounded-lg border-2 border-amber-100 shadow-sm">
{(() => {
// Highlight the error segment
if (!question.errorSegment) return question.question;
const parts = question.question.split(question.errorSegment);
return (
<>
{parts.map((part, i) => (
<React.Fragment key={i}>
{part}
{i < parts.length - 1 && (
<span className="text-red-600 font-bold decoration-wavy underline decoration-red-400 bg-red-100 px-1 rounded mx-0.5">
{question.errorSegment}
</span>
)}
</React.Fragment>
))}
</>
);
})()}
</div>
<p className="text-sm text-center text-slate-500 font-medium uppercase tracking-widest">
Wybierz poprawną formę:
</p>
<div className="grid grid-cols-1 gap-3">
{question.options?.map((option, idx) => (
<Button
key={idx}
variant={value === option ? 'secondary' : 'outline'}
disabled={disabled}
onClick={() => onChange(option)}
className="justify-start h-auto py-4 text-lg"
>
<div className="flex items-center gap-3 w-full">
<div className={`w-6 h-6 rounded-full border-2 flex items-center justify-center text-xs font-bold
${value === option ? 'border-primary text-primary' : 'border-slate-300 text-slate-400'}
`}>
{String.fromCharCode(65 + idx)}
</div>
{option}
</div>
</Button>
))}
</div>
</div>
);
}
================================================
FILE: src/components/tutor/question/renderers/GapFill.tsx
================================================
import { Button } from '@/components/ui/button';
import { QuestionRendererProps } from '../types';
export function GapFill({
question,
value,
onChange,
disabled
}: QuestionRendererProps) {
return (
<>
<div className="p-4 bg-slate-50 rounded text-lg leading-relaxed">
{question.question.split('___').map((part, i, arr) => (
<span key={i}>
{part}
{i < arr.length - 1 && (
<span className="inline-block border-b-2 border-slate-400 px-2 min-w-[60px] font-bold text-center text-blue-600">
{value || '___'}
</span>
)}
</span>
))}
</div>
<div className="grid grid-cols-2 gap-2 mt-4">
{question.options?.map((opt) => (
<Button
key={opt}
variant={value === opt ? 'secondary' : 'outline'}
disabled={disabled}
onClick={() => onChange(opt)}
>
{opt}
</Button>
))}
</div>
</>
);
}
================================================
FILE: src/components/tutor/question/renderers/MultipleChoice.tsx
================================================
import { Button } from '@/components/ui/button';
import { QuestionRendererProps } from '../types';
export function MultipleChoice({
question,
value,
onChange,
disabled
}: QuestionRendererProps) {
return (
<div className="grid gap-3">
{question.options?.map((opt) => (
<Button
key={opt}
variant={value === opt ? 'secondary' : 'outline'}
disabled={disabled}
onClick={() => onChange(opt)}
className="justify-start text-left h-auto py-3 whitespace-normal"
>
{opt}
</Button>
))}
</div>
);
}
================================================
FILE: src/components/tutor/question/renderers/SimpleInput.tsx
================================================
import { QuestionRendererProps } from '../types';
export function SimpleInput({
question,
value,
onChange,
disabled
}: QuestionRendererProps) {
return (
<div className="space-y-4">
<p className="text-sm text-slate-500">Wpisz poprawną odpowiedź:</p>
<input
type="text"
className="w-full p-3 border-2 rounded-md font-mono text-lg focus:border-blue-500 outline-none"
placeholder="Wpisz odpowiedź tutaj..."
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
value={value || ''}
/>
</div>
);
}
================================================
FILE: src/components/ui/button.tsx
================================================
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
// asChild is ignored in this simplified version
const Comp = "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
================================================
FILE: src/components/ui/card.tsx
================================================
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
================================================
FILE: src/components/ui/dialog.tsx
================================================
import React from 'react';
import { cn } from '@/lib/utils';
import { Button } from './button';
import { X } from 'lucide-react';
interface DialogProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
children: React.ReactNode;
}
export function Dialog({ open, onOpenChange, children }: DialogProps) {
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/50 backdrop-blur-sm transition-opacity"
onClick={() => onOpenChange?.(false)}
/>
{/* Context Provider for children to access close handler if needed */}
<div className="relative z-50 w-full">
{children}
</div>
</div>
);
}
export function DialogContent({ className, children }: { className?: string, children: React.ReactNode }) {
return (
<div className={cn("relative w-full max-w-lg mx-auto bg-white p-6 shadow-lg rounded-xl border border-slate-200 animate-in fade-in zoom-in-95 duration-200", className)}>
{/* We can rely on DialogHeader/Footer for structure */}
{children}
</div>
);
}
export function DialogHeader({ className, children }: { className?: string, children: React.ReactNode }) {
return (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left mb-4", className)}>
{children}
</div>
);
}
export function DialogTitle({ className, children }: { className?: string, children: React.ReactNode }) {
return (
<h3 className={cn("text-lg font-semibold leading-none tracking-tight", className)}>
{children}
</h3>
);
}
export function DialogDescription({ className, children }: { className?: string, children: React.ReactNode }) {
return (
<p className={cn("text-sm text-muted-foreground", className)}>
{children}
</p>
);
}
// Helper to close dialog inside content if needed, though strictly we pass open state down.
// For now, simpler is better.
export function DialogFooter({ className, children }: { className?: string, children: React.ReactNode }) {
return (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}>
{children}
</div>
);
}
================================================
FILE: src/components/ui/input.tsx
================================================
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }
================================================
FILE: src/components/ui/progress.tsx
================================================
import * as React from "react"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & { value?: number | null }
>(({ className, value, ...props }, ref) => (
<div
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-slate-100",
className
)}
{...props}
>
<div
className="h-full w-full flex-1 bg-blue-600 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</div>
))
Progress.displayName = "Progress"
export { Progress }
================================================
FILE: src/components/ui/scroll-area.tsx
================================================
import * as React from "react"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, children, ...props }, ref) => (
<div
ref={ref}
className={cn("relative overflow-auto", className)}
{...props}
>
{children}
</div>
))
ScrollArea.displayName = "ScrollArea"
export { ScrollArea }
================================================
FILE: src/components/ui/tabs.tsx
================================================
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
const TabsContext = React.createContext<{
value: string;
onValueChange: (value: string) => void;
} | null>(null);
const Tabs = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & { value: string; onValueChange: (value: string) => void }
>(({ className, value, onValueChange, children, ...props }, ref) => (
<TabsContext.Provider value={{ value, onValueChange }}>
<div ref={ref} className={cn("", className)} {...props}>
{children}
</div>
</TabsContext.Provider>
))
Tabs.displayName = "Tabs"
const TabsList = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-slate-100 p-1 text-slate-500",
className
)}
{...props}
/>
))
TabsList.displayName = "TabsList"
const TabsTrigger = React.forwardRef<
HTMLButtonElement,
React.ButtonHTMLAttributes<HTMLButtonElement> & { value: string }
>(({ className, value, ...props }, ref) => {
const context = React.useContext(TabsContext);
const isActive = context?.value === value;
return (
<button
ref={ref}
onClick={() => context?.onValueChange(value)}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-white transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
isActive ? "bg-white text-slate-950 shadow-sm" : "hover:bg-slate-200/50 hover:text-slate-900",
className
)}
{...props}
/>
)
})
TabsTrigger.displayName = "TabsTrigger"
const TabsContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & { value: string }
>(({ className, value, children, ...props }, ref) => {
const context = React.useContext(TabsContext);
if (context?.value !== value) return null;
return (
<div
ref={ref}
className={cn(
"mt-2 ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-950 focus-visible:ring-offset-2",
className
)}
{...props}
>
{children}
</div>
)
})
TabsContent.displayName = "TabsContent"
export { Tabs, TabsList, TabsTrigger, TabsContent }
================================================
FILE: src/components/ui/textarea.tsx
================================================
import * as React from "react"
import { cn } from "@/lib/utils"
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> { }
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
autoComplete="off"
data-gramm="false" // Disable Grammarly
data-lpignore="true" // Disable LastPass
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }
================================================
FILE: src/content/courses/futuro-semplice.md
================================================
# Futuro Semplice
The **Futuro Semplice** (Simple Future) is used to talk about future actions, predictions, and suppositions.
:::grid
:::card{icon="calendar" color="blue" title="Plany na przyszłość"}
Opisywanie czynności, które na pewno lub prawdopodobnie się wydarzą.
*"Domani andrò al mare."*
:::
:::card{icon="brain" color="purple" title="Przypuszczenia"}
Wyrażanie wątpliwości lub przypuszczeń dotyczących teraźniejszości.
*"Sarà vero?" (Czy to może być prawda?)*
:::
:::
## Conjugation (Regular)
| Subject | -ARE (Parlare) | -ERE (Prendere) | -IRE (Dormire) |
| :--- | :--- | :--- | :--- |
| Io | Parler**ò** | Prender**ò** | Dormir**ò** |
| Tu | Parler**ai** | Prender**ai** | Dormir**ai** |
| Lui/Lei | Parler**à** | Prender**à** | Dormir**à** |
| Noi | Parler**emo** | Prender**emo** | Dormir**emo** |
| Voi | Parler**ete** | Prender**ete** | Dormir**ete** |
| Loro | Parler**anno** | Prender**anno** | Dormir**anno** |
:::card{icon="lightbulb" color="yellow" title="Wskazówka"}
Zwróć uwagę, że dla czasowników zakończonych na **-are**, litera 'a' zmienia się na 'e' (parl**a**re -> parl**e**rò).
:::
## Irregular Verbs
:::grid
:::card{icon="check" color="indigo" title="Essere"}
* Io **sarò**
* Tu **sarai**
* Lui **sarà**
:::
:::card{icon="check" color="indigo" title="Avere"}
* Io **avrò**
* Tu **avrai**
* Lui **avrà**
:::
:::
================================================
FILE: src/lib/utils.ts
================================================
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function speak(text: string, lang = 'it-IT', rate = 0.9) {
if (typeof window === 'undefined') return;
// Cancel current speech to avoid queue buildup
window.speechSynthesis.cancel();
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = lang;
utterance.rate = rate;
// Try to find a good voice
const voices = window.speechSynthesis.getVoices();
const italianVoice = voices.find(v => v.lang.includes('it'));
if (italianVoice) utterance.voice = italianVoice;
window.speechSynthesis.speak(utterance);
}
================================================
FILE: src/lib/ai/actions.ts
================================================
'use server';
import { QuizSchema, WritingAnalysisSchema, TestConfiguration } from './schemas';
import { quizValidationPrompt, createRichQuizPrompt } from './prompts';
import { getTopicContext } from '../content/slicer';
import { orderQuestionsByDifficulty } from '../didactic/utils';
// NOTE: buildTutorMemory removed - uses client-only Zustand store (breaks server actions)
// Validation Supervisor Agent
import fs from 'fs/promises';
import path from 'path';
export async function getSyllabus() {
try {
const filePath = path.join(process.cwd(), 'src/components/content/syllabus.md');
const content = await fs.readFile(filePath, 'utf-8');
return content;
} catch (error) {
console.error('Failed to read syllabus:', error);
return null;
}
}
// Helper to get formatted API endpoint for Ollama vs LMStudio
function getAiEndpoint(provider: 'ollama' | 'lmstudio' = 'ollama', baseUrl: string = 'http://127.0.0.1:11434'): string {
// 1. Remove trailing slashes
let cleanUrl = baseUrl.replace(/\/+$/, '');
// 2. Aggressively strip known API suffixes users might paste
// e.g. http://localhost:1234/v1/chat/completions -> http://localhost:1234
const suffixesToRemove = [
'/v1/chat/completions',
'/api/chat',
'/v1/models',
'/api/tags',
'/v1',
'/api'
];
for (const suffix of suffixesToRemove) {
if (cleanUrl.endsWith(suffix)) {
cleanUrl = cleanUrl.slice(0, -suffix.length);
// Remove trailing slash again if left
cleanUrl = cleanUrl.replace(/\/+$/, '');
}
}
// LM Studio often runs on port 1234 and uses OpenAI-like /v1/chat/completions
if (provider === 'lmstudio') {
return `${cleanUrl}/v1/chat/completions`;
}
// Ollama uses /api/chat
return `${cleanUrl}/api/chat`;
}
interface AIConfig {
provider: 'ollama' | 'lmstudio';
url: string;
}
// Helper to clean JSON from Markdown
function cleanJson(text: string): string {
// Remove markdown code blocks ```json ... ``` or just ``` ... ```
let clean = text.replace(/```json\s*([\s\S]*?)\s*```/g, '$1');
clean = clean.replace(/```\s*([\s\S]*?)\s*```/g, '$1');
return clean.trim();
}
// Unified AI Fetcher
async function fetchAI(messages: any[], model: string, aiConfig?: AIConfig) {
const provider = aiConfig?.provider || 'ollama';
let baseUrl = aiConfig?.url || 'http://127.0.0.1:11434';
// Normalize URL
const endpoint = getAiEndpoint(provider, baseUrl);
// Construct Body
const body: any = {
model: model,
messages: messages,
stream: false
};
if (provider === 'ollama') {
body.format = 'json';
} else if (provider === 'lmstudio') {
// LM Studio / OpenAI compatible JSON mode
// body.response_format = { type: "json_object" }; // Some older versions struggle with this, better to rely on prompt
// For max compatibility, we skip strict response_format and rely on our cleanJson helper + prompt
// But we explicitly DO NOT send 'format: "json"' as that is Ollama specific
body.temperature = 0.7;
}
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
cache: 'no-store',
signal: AbortSignal.timeout(300000) // 5 minutes timeout for local models
});
if (!response.ok) {
throw new Error(`AI Provider (${provider}) Error ${response.status}: ${response.statusText}`);
}
const data = await response.json();
// Extract Content
let content = '';
if (provider === 'lmstudio') {
content = data.choices?.[0]?.message?.content || '';
} else {
content = data.message?.content || '';
}
if (!content) throw new Error('Empty response from AI');
return cleanJson(content);
}
async function validateQuizContent(quiz: any, model: string, aiConfig?: AIConfig): Promise<{ valid: boolean; feedback?: string }> {
try {
const content = await fetchAI(
[{ role: 'user', content: quizValidationPrompt(JSON.stringify(quiz)) }],
model,
aiConfig
);
const parsed = JSON.parse(content || '{}');
return { valid: parsed.valid === true, feedback: parsed.feedback };
} catch (e) {
console.error("Validation agent failed", e);
return { valid: true }; // Fail open
}
}
interface GenerateOptions {
model?: string;
deepCheck?: boolean;
amount?: number;
config?: TestConfiguration;
aiConfig?: AIConfig; // New: Pass AI connection settings
}
export async function generateQuiz(topic: string, difficulty: string, context?: string, options: GenerateOptions = {}) {
const model = options.model || 'llama3.2:latest';
const deepCheck = options.deepCheck || false;
const aiConfig = options.aiConfig;
// Default config if not provided (backward compatibility)
const config: TestConfiguration = options.config || {
level: difficulty as any,
topicId: topic,
focus: 'general',
tone: 'formal',
exerciseTypes: ['multiple-choice', 'gap-fill'],
sourceMode: 'ai-expanded'
};
// RAG: Get Context from Slicer
const ragContext = getTopicContext(config.topicId);
let attempt = 0;
// deepCheck disabled by default to speed up, can be enabled via options
const maxAttempts = deepCheck ? 2 : 1;
let lastFeedback = "";
// Ensure config has amount
if (!config.amount) config.amount = options.amount || 5;
// TODO: Memory personalization should be built client-side and passed as data
const memory = undefined;
try {
while (attempt < maxAttempts) {
attempt++;
console.log(`[Generator] Attempt ${attempt} for ${topic} (Provider: ${aiConfig?.provider || 'ollama'})`);
// Truncate context to prevent context length errors (typical local window is 2048/4096)
// Keeping it reasonable (approx 1000-1500 chars) ensures we leave room for system prompt + generic knowledge
const safeRagContext = ragContext.length > 1200
? ragContext.slice(0, 1200) + "... [truncated]"
: ragContext;
// Use New Rich Prompt (with optional previous feedback)
const promptContext = lastFeedback ? `PREVIOUS FEEDBACK (FIX THIS): ${lastFeedback}\n\n` + safeRagContext : safeRagContext;
const prompt = createRichQuizPrompt(config, promptContext, memory);
// Call AI
let content;
try {
content = await fetchAI(
[
{ role: 'system', content: 'You are a helpful AI that outputs STRICT JSON only. Do not output markdown.' },
{ role: 'user', content: prompt }
],
model,
aiConfig
);
} catch (err: any) {
return { success: false, error: err.message };
}
// Parse & Sanitize
let parsedJson;
try {
parsedJson = JSON.parse(content);
} catch (e) {
console.error("JSON Parse Error", e);
console.log("Raw Content:", content);
lastFeedback = "Invalid JSON structure. Ensure you output only raw JSON.";
continue;
}
// Fallbacks & Validation
if (!parsedJson.topic) parsedJson.topic = topic;
// Normalize Questions
if (Array.isArray(parsedJson.questions)) {
parsedJson.questions.forEach((q: any, index: number) => {
// Unique ID generation using crypto.randomUUID() (avoids SSR mismatch + collisions)
if (!q.id || q.id.startsWith('q-')) {
q.id = crypto.randomUUID();
}
if (!q.explanation) q.explanation = "Brak dostępnego wyjaśnienia.";
// Fix Scramble types often missing instruction
if (q.type === 'scramble' && !q.instruction) q.instruction = "Ułóż zdanie";
// Sanitize correctAnswer (sometimes AI returns ["Answer"] instead of "Answer")
if (Array.isArray(q.correctAnswer)) {
q.correctAnswer = q.correctAnswer[0] || "";
}
// Sanitize Options
if (q.options && !Array.isArray(q.options)) {
// If options is a string (rare but possible), try to split or wrap
q.options = [String(q.options)];
}
});
}
// Basic Validation with Zod
try {
const validatedQuiz = QuizSchema.parse(parsedJson);
// Deep Check via AI Supervisor (if enabled)
if (deepCheck) {
console.log(`[DeepCheck] Validating content for attempt ${attempt}...`);
const validation = await validateQuizContent(validatedQuiz, model, aiConfig);
if (!validation.valid) {
console.warn(`[DeepCheck] Failed: ${validation.feedback}`);
lastFeedback = validation.feedback || "AI rejected content.";
continue; // Retry loop
}
}
// Also Check Duplicates
if (hasDuplicates(validatedQuiz.questions)) {
console.warn("Duplicate questions detected");
lastFeedback = "Duplicate questions";
continue;
}
// Order questions by difficulty (scaffolding: easy → hard)
validatedQuiz.questions = orderQuestionsByDifficulty(validatedQuiz.questions);
return { success: true, data: validatedQuiz };
} catch (e) {
console.warn("Schema Validation Failed", e);
lastFeedback = "Schema mismatch: " + (e as any).message;
continue;
}
}
return { success: false, error: `Failed to generate quiz after ${maxAttempts} attempts. Last error: ${lastFeedback}` };
} catch (error) {
console.error('Error generating quiz:', error);
return { success: false, error: 'Internal Generator Error' };
}
}
// Helper to check for content duplication
function hasDuplicates(questions: any[]): boolean {
const questionsSet = new Set();
for (const q of questions) {
if (!q.question) continue;
const qText = q.question.toLowerCase().trim();
if (questionsSet.has(qText)) return true;
questionsSet.add(qText);
}
return false;
}
export async function analyzeWriting(text: string, promptInstruction: string, strictness: 'forgiving' | 'academic' = 'academic', aiConfig?: AIConfig) {
try {
const persona = strictness === 'academic'
? "You are a specific strict Italian professor. You demand high accuracy and formal tone."
: "You are a supportive Italian friend. You verify understanding but ignore minor mistakes. Be encouraging.";
const prompt = `
${persona}
Analyze the following text written by a student.
STUDENT TEXT:
"${text}"
INSTRUCTION:
${promptInstruction}
OUTPUT CHECKLIST:
1. Identify grammar, vocabulary, and spelling errors.
2. Provide a correction for each error.
3. Explain the error STRICTLY in POLISH. (Do NOT use English).
4. Give an overall score (1-10) and general feedback STRICTLY in POLISH.
5. Output STRICT JSON matching the schema.
`;
const content = await fetchAI(
[
{ role: 'system', content: 'You are a helpful AI that outputs STRICT JSON only.' },
{ role: 'user', content: prompt }
],
'llama3.2', // TODO: Make configurable
aiConfig
);
const parsedJson = JSON.parse(content);
const validatedAnalysis = WritingAnalysisSchema.parse(parsedJson);
return { success: true, data: validatedAnalysis };
} catch (error) {
console.error('Error analyzing writing:', error);
return { success: false, error: 'Failed to analyze writing. Ensure AI is running.' };
}
}
export async function checkConnection(aiConfig: AIConfig) {
try {
const provider = aiConfig.provider;
let baseUrl = aiConfig.url || 'http://127.0.0.1:11434';
// Use the same robust URL cleaning
let cleanUrl = baseUrl.replace(/\/+$/, '');
const suffixesToRemove = [
'/v1/chat/completions',
'/api/chat',
'/v1/models',
'/api/tags',
'/v1',
'/api'
];
for (const suffix of suffixesToRemove) {
if (cleanUrl.endsWith(suffix)) {
cleanUrl = cleanUrl.slice(0, -suffix.length);
cleanUrl = cleanUrl.replace(/\/+$/, '');
}
}
const endpoint = provider === 'ollama'
? `${cleanUrl}/api/tags`
: `${cleanUrl}/v1/models`;
const response = await fetch(endpoint, {
method: 'GET',
cache: 'no-store'
});
if (!response.ok) {
throw new Error(`Provider returned ${response.status}`);
}
const data = await response.json();
// Normalize models list
const models = provider === 'ollama'
? data.models.map((m: any) => m.name)
: data.data.map((m: any) => m.id);
return { success: true, models };
} catch (error: any) {
console.error('Check Connection Error:', error);
return { success: false, error: error.message };
}
}
================================================
FILE: src/lib/ai/config.ts
================================================
import { createOllama } from 'ollama-ai-provider';
export const ollama = createOllama({
baseURL: process.env.OLLAMA_BASE_URL || 'http://127.0.0.1:11434/api',
});
// Helper to get dynamic model if needed
export const defaultModel = 'llama3.2';
================================================
FILE: src/lib/ai/examples.ts
================================================
export const FEW_SHOT_EXAMPLES = `
### CATEGORY: VERBI PRESENTE (ESSERE / AVERE)
EXAMPLE 1:
INPUT: Topic: "Verbi Essere/Avere", Level: "A1"
OUTPUT:
{
"topic": "verbi-essere-avere",
"difficulty": "A1",
"questions": [
{
"id": "q_ea_1",
"type": "gap-fill",
"instruction": "Completa con il verbo essere o avere",
"question": "Oggi il tempo ___ brutto.",
"options": ["è", "ha", "sono", "hanno"],
"correctAnswer": "è",
"explanation": "Używamy 'essere' do opisu pogody (czasownik bezosobowy)."
},
{
"id": "q_ea_2",
"type": "gap-fill",
"instruction": "Completa con il verbo essere o avere",
"question": "Tu e Francesco ___ molto da studiare.",
"options": ["avete", "siete", "hanno", "sono"],
"correctAnswer": "avete",
"explanation": "Wyrażenie 'avere da fare/studiare' (mieć coś do zrobienia)."
},
{
"id": "q_ea_3",
"type": "gap-fill",
"instruction": "Completa la frase",
"question": "I miei amici ___ contenti di andare al mare.",
"options": ["sono", "hanno", "siete", "avete"],
"correctAnswer": "sono",
"explanation": "'Essere contento di...' (być zadowolonym z...)."
},
{
"id": "q_ea_4",
"type": "gap-fill",
"instruction": "Inserisci il verbo corretto",
"question": "Il vostro gatto ___ paura del mio cane.",
"options": ["ha", "è", "hanno", "sono"],
"correctAnswer": "ha",
"explanation": "Wyrażenie 'avere paura' (bać się)."
}
]
}
### CATEGORY: PARTICIPIO PASSATO & PASSATO PROSSIMO
EXAMPLE 2:
INPUT: Topic: "Passato Prossimo", Level: "A2"
OUTPUT:
{
"topic": "passato-prossimo",
"difficulty": "A2",
"questions": [
{
"id": "q_pp_1",
"type": "gap-fill",
"instruction": "Completa il participio",
"question": "Ciao Sandra, sei già tornat___ dalle Dolomiti?",
"options": ["-a", "-o", "-i", "-e"],
"correctAnswer": "tornata",
"explanation": "Sandra jest kobietą, a czasownik 'tornare' łączy się z 'essere', więc końcówka to -a."
},
{
"id": "q_pp_2",
"type": "multiple-choice",
"instruction": "Scegli l'ausiliare corretto",
"question": "Ieri ___ andati al cinema.",
"options": ["siamo", "abbiamo", "sono", "hanno"],
"correctAnswer": "siamo",
"explanation": "Czasownik ruchu 'andare' wymaga posiłkowego 'essere'. 'Siamo andati' (my poszliśmy)."
},
{
"id": "q_pp_3",
"type": "gap-fill",
"instruction": "Completa la frase",
"question": "Giulia ___ (arrivare) a Riomaggiore alle 11:30.",
"options": ["è arrivata", "ha arrivato", "arrivò", "arriva"],
"correctAnswer": "è arrivata",
"explanation": "'Arrivare' (przybyć) to czasownik ruchu/zmiany stanu -> posiłkowy 'essere'."
}
]
}
### CATEGORY: IMPERFETTO VS PASSATO PROSSIMO
EXAMPLE 3:
INPUT: Topic: "Imperfetto", Level: "A2"
OUTPUT:
{
"topic": "imperfetto",
"difficulty": "A2",
"questions": [
{
"id": "q_imp_1",
"type": "gap-fill",
"instruction": "Coniuga all'imperfetto",
"question": "Quando ero piccolo, ___ (giocare) sempre in giardino.",
"correctAnswer": "giocavo",
"explanation": "Czynność powtarzalna w przeszłości ('zawsze') wymaga Imperfetto."
},
{
"id": "q_imp_2",
"type": "multiple-choice",
"instruction": "Scegli il tempo corretto",
"question": "Mentre ___ (mangiare), ha squillato il telefono.",
"options": ["mangiavo", "ho mangiato", "mangiai"],
"correctAnswer": "mangiavo",
"explanation": "Czynność w tle (tło wydarzeń) wyrażamy przez Imperfetto."
},
{
"id": "q_imp_3",
"type": "gap-fill",
"instruction": "Completa la frase",
"question": "Da giovane, mia madre ___ (leggere) molti romanzi.",
"correctAnswer": "leggeva",
"explanation": "Opis zwyczajów w przeszłości -> Imperfetto."
}
]
}
### CATEGORY: ARTICOLI & PLURALI
EXAMPLE 4:
INPUT: Topic: "Articoli e Plurali", Level: "A1"
OUTPUT:
{
"topic": "articoli-plurali",
"difficulty": "A1",
"questions": [
{
"id": "q_art_1",
"type": "transformation",
"instruction": "Trasforma al plurale",
"question": "giornata faticosa",
"correctAnswer": "giornate faticose",
"explanation": "Rzeczowniki i przymiotniki na -a (żeńskie) w liczbie mnogiej mają -e."
},
{
"id": "q_art_2",
"type": "transformation",
"instruction": "Trasforma al plurale",
"question": "esercizio difficile",
"correctAnswer": "esercizi difficili",
"explanation": "'Esercizio' kończy się na -io (akcent na i), w liczbie mnogiej jedno 'i'. 'Difficile' (e) -> 'difficili' (i)."
},
{
"id": "q_art_3",
"type": "transformation",
"instruction": "Aggiungi l'articolo e trasforma al plurale",
"question": "stella luminosa",
"correctAnswer": "le stelle luminose",
"explanation": "La stella -> Le stelle."
},
{
"id": "q_art_4",
"type": "multiple-choice",
"instruction": "Scegli l'articolo corretto",
"question": "___ stivali sono nuovi.",
"options": ["Gli", "I", "Le", "Lo"],
"correctAnswer": "Gli",
"explanation": "'Stivali' zaczyna się na s+spółgłoskę, więc w l.mn. używamy 'Gli'."
}
]
}
### CATEGORY: PREPOSIZIONI
EXAMPLE 5:
INPUT: Topic: "Preposizioni", Level: "A1"
OUTPUT:
{
"topic": "preposizioni",
"difficulty": "A1",
"questions": [
{
"id": "q_prep_1",
"type": "gap-fill",
"instruction": "Inserisci la preposizione",
"question": "Vado ___ scuola tutti i giorni.",
"options": ["a", "in", "da", "per"],
"correctAnswer": "a",
"explanation": "'Andare a scuola' to stałe wyrażenie."
},
{
"id": "q_prep_2",
"type": "gap-fill",
"instruction": "Inserisci la preposizione",
"question": "Il libro è ___ tavolo.",
"options": ["sul", "in", "per", "tra"],
"correctAnswer": "sul",
"explanation": "Na stole = 'su' + 'il' = 'sul'."
},
{
"id": "q_prep_3",
"type": "multiple-choice",
"instruction": "Scegli l'opzione corretta",
"question": "Vengo ___ Stati Uniti.",
"options": ["dagli", "da", "in", "negli"],
"correctAnswer": "dagli",
"explanation": "Pochodzenie: 'venire da'. Stati Uniti to l.mn, więc 'da' + 'gli' = 'dagli'."
},
{
"id": "q_prep_4",
"type": "gap-fill",
"instruction": "Completa",
"question": "La stazione è vicino ___ ufficio postale.",
"correctAnswer": "all'",
"explanation": "'Vicino a' + 'l'ufficio' = 'all'ufficio'."
}
]
}
### CATEGORY: AGGETTIVI POSSESSIVI
EXAMPLE 6:
INPUT: Topic: "Possessivi", Level: "A1"
OUTPUT:
{
"topic": "possessivi",
"difficulty": "A1",
"questions": [
{
"id": "q_poss_1",
"type": "transformation",
"instruction": "Trasforma inserendo il possessivo",
"question": "Noi / le domande",
"correctAnswer": "le nostre domande",
"explanation": "Possessivo dla 'noi' (nasze) + rodzaj żeński liczba mnoga."
},
{
"id": "q_poss_2",
"type": "transformation",
"instruction": "Trasforma al singolare",
"question": "i miei amici",
"correctAnswer": "il mio amico",
"explanation": "I -> il, miei -> mio, amici -> amico."
},
{
"id": "q_poss_3",
"type": "gap-fill",
"instruction": "Completa con il possessivo",
"question": "Ciao Elisa, vuoi venire a cena a casa ___ (di me)?",
"correctAnswer": "mia",
"explanation": "'Casa mia' (mój dom)."
}
]
}
### CATEGORY: COMPARATIVI E SUPERLATIVI
EXAMPLE 7:
INPUT: Topic: "Gradi dell'Aggettivo", Level: "A2"
OUTPUT:
{
"topic": "gradi-aggettivo",
"difficulty": "A2",
"questions": [
{
"id": "q_comp_1",
"type": "multiple-choice",
"instruction": "Scegli l'opzione corretta",
"question": "Questo libro è ___ di quello.",
"options": ["più interessante", "interessante più", "il più interessante"],
"correctAnswer": "più interessante",
"explanation": "Stopień wyższy: più + przymiotnik."
},
{
"id": "q_comp_2",
"type": "multiple-choice",
"instruction": "Scegli il superlativo assoluto",
"question": "Questo esercizio è ___ (molto difficile).",
"options": ["difficilissimo", "più difficile", "il difficile"],
"correctAnswer": "difficilissimo",
"explanation": "Końcówka -issimo tworzy superlativo assoluto."
},
{
"id": "q_comp_3",
"type": "multiple-choice",
"instruction": "Completa la frase",
"question": "Marco è ___ (alto) di Paolo.",
"options": ["più alto", "l'alto", "altissimo"],
"correctAnswer": "più alto",
"explanation": "Porównanie dwóch osób: 'più alto di...'."
}
]
}
### CATEGORY: DIALOGHI E PRAGMATICA (BAR/RISTORANTE)
EXAMPLE 8:
INPUT: Topic: "Al Bar", Level: "A1"
OUTPUT:
{
"topic": "al-bar",
"difficulty": "A1",
"questions": [
{
"id": "q_bar_1",
"type": "gap-fill",
"instruction": "Cosa dice il cliente?",
"question": "Buongiorno, ___ (chcieć - forma grzeczna) un caffè, per favore.",
"options": ["vorrei", "voglio", "vuole", "vorresti"],
"correctAnswer": "vorrei",
"explanation": "'Vorrei' (chciałbym) to forma grzecznościowa od 'volere'."
},
{
"id": "q_bar_2",
"type": "gap-fill",
"instruction": "Completa la domanda",
"question": "___ costa un cornetto?",
"options": ["Quanto", "Come", "Dove", "Chi"],
"correctAnswer": "Quanto",
"explanation": "'Quanto costa?' (ile kosztuje?)."
},
{
"id": "q_bar_3",
"type": "multiple-choice",
"instruction": "Scegli la risposta corretta",
"question": "Cameriere: 'Cosa prende?' Cliente: ___",
"options": ["Per me un panino", "Io sono un panino", "A me piace panino"],
"correctAnswer": "Per me un panino",
"explanation": "Standardowa formuła zamawiania: 'Per me...'"
}
]
}
### CATEGORY: ERRORI COMUNI (CORREZIONE)
EXAMPLE 9:
INPUT: Topic: "Error Correction", Level: "A2"
OUTPUT:
{
"topic": "errori",
"difficulty": "A2",
"questions": [
{
"id": "q_err_1",
"type": "error-correction",
"instruction": "Trova l'errore",
"question": "Ieri vado al mare.",
"errorSegment": "vado",
"correctAnswer": "sono andato",
"options": ["vado", "sono andato", "andrò"],
"explanation": "'Ieri' (wczoraj) wymaga czasu przeszłego."
},
{
"id": "q_err_2",
"type": "error-correction",
"instruction": "Trova l'errore",
"question": "La gente dicono che è bello.",
"errorSegment": "dicono",
"correctAnswer": "dice",
"explanation": "'La gente' (ludzie) w języku włoskim to liczba POJEDYNCZA (ona), więc 'dice', a nie 'dicono'."
},
{
"id": "q_err_3",
"type": "error-correction",
"instruction": "Correggi",
"question": "Mi piace gli spaghetti.",
"errorSegment": "piace",
"correctAnswer": "piacciono",
"explanation": "Spaghetti to l.mn., więc 'Mi piacciono' (podobają mi się)."
}
]
}
### CATEGORY: SPELLING E DOPPIE
EXAMPLE 10:
INPUT: Topic: "Ortografia", Level: "A1"
OUTPUT:
{
"topic": "ortografia",
"difficulty": "A1",
"questions": [
{
"id": "q_spell_1",
"type": "multiple-choice",
"instruction": "Scegli la parola scritta correttamente",
"question": "Vado a casa di mia ___.",
"options": ["sorella", "sorela", "sorrella"],
"correctAnswer": "sorella",
"explanation": "'Sorella' ma podwójne 'll'."
},
{
"id": "q_spell_2",
"type": "multiple-choice",
"instruction": "Scegli la parola corretta",
"question": "Ho dimenticato le ___ in cucina.",
"options": ["forchette", "forchete", "forcette"],
"correctAnswer": "forchette",
"explanation": "Dźwięk 'k' przed 'e' zapisujemy 'ch' -> forchette."
}
]
}
`;
================================================
FILE: src/lib/ai/explain.ts
================================================
'use server';
import { Question } from './schemas';
/**
* Generates a specific explanation for why a user's answer was wrong/correct compared to the question.
*/
export async function explainAnswerAction(question: Question, userAnswer: string, topic: string) {
try {
const prompt = `
You are an Italian Tutor.
Question: "${question.question}"
Correct Answer: "${question.correctAnswer}"
User Answer: "${userAnswer}"
Topic: "${topic}"
Original Explanation: "${question.explanation}"
Task: Explain specifically to the user why their answer is wrong (or if it's a valid alternative not captured).
If logical, explain the grammar rule.
Keep it short (max 2 sentences).
Language: Polish.
`;
const response = await fetch('http://127.0.0.1:11434/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'llama3.2:latest',
messages: [{ role: 'user', content: prompt }],
stream: false
})
});
const data = await response.json();
return data.message?.content || "Nie udało się wygenerować wyjaśnienia.";
} catch (e) {
console.error("Explain error", e);
return "Błąd połączenia z AI.";
}
}
================================================
FILE: src/lib/ai/memory.ts
================================================
import { useUserStore } from '@/lib/store/useUserStore';
export interface TutorMemory {
weaknesses: {
grammar: string[];
vocabulary: string[];
topics: string[];
};
strengths: string[];
recentMistakes: string[];
avgAccuracy: number;
preferredDifficulty: 'A1' | 'A2' | 'B1' | 'B2';
}
/**
* Builds a memory profile of the user's learning history
* to personalize AI quiz generation
*/
export function buildTutorMemory(topicId?: string): TutorMemory {
const store = useUserStore.getState();
// Get recent mistakes
const mistakes = store.getMistakes();
// Extract grammar weaknesses from mistakes
const grammarWeaknesses: string[] = [];
const vocabWeaknesses: string[] = [];
const topicWeaknesses: string[] = [];
mistakes.forEach(mistake => {
// Parse mistake format: "[Topic] Question: "..." Correct: "...""
const topicMatch = mistake.match(/\[(.*?)\]/);
if (topicMatch) {
const topic = topicMatch[1];
if (!topicWeaknesses.includes(topic)) {
topicWeaknesses.push(topic);
}
}
// Simple heuristic: if mistake contains verb conjugation patterns
if (mistake.match(/\b(sarò|sarai|andrò|avrò|essere|avere)\b/i)) {
if (!grammarWeaknesses.includes('verb conjugation')) {
grammarWeaknesses.push('verb conjugation');
}
}
// If it's about articles/prepositions
if (mistake.match(/\b(il|la|di|a|da|in|su|per)\b/i)) {
if (!grammarWeaknesses.includes('articles and prepositions')) {
grammarWeaknesses.push('articles and prepositions');
}
}
});
// Find strong topics (mastery > 85)
const strengths: string[] = [];
Object.values(store.topicStats).forEach(stat => {
if (stat.mastery >= 85) {
strengths.push(stat.topicId);
}
});
// Calculate overall accuracy
const totalQuizzes = store.history.length;
let totalCorrect = 0;
let totalQuestions = 0;
store.history.slice(0, 10).forEach(quiz => {
totalCorrect += quiz.score;
totalQuestions += quiz.total;
});
const avgAccuracy = totalQuestions > 0
? Math.round((totalCorrect / totalQuestions) * 100)
: 60;
// Determine preferred difficulty based on current topic or overall performance
let preferredDifficulty: 'A1' | 'A2' | 'B1' | 'B2' = 'A2';
if (topicId && store.topicStats[topicId]) {
const mastery = store.topicStats[topicId].mastery;
if (mastery < 50) preferredDifficulty = 'A1';
else if (mastery < 70) preferredDifficulty = 'A2';
else if (mastery < 85) preferredDifficulty = 'B1';
else preferredDifficulty = 'B2';
} else if (avgAccuracy < 50) {
preferredDifficulty = 'A1';
} else if (avgAccuracy < 70) {
preferredDifficulty = 'A2';
} else if (avgAccuracy < 85) {
preferredDifficulty = 'B1';
} else {
preferredDifficulty = 'B2';
}
return {
weaknesses: {
grammar: grammarWeaknesses.slice(0, 3),
vocabulary: vocabWeaknesses.slice(0, 3),
topics: topicWeaknesses.slice(0, 3)
},
strengths: strengths.slice(0, 3),
recentMistakes: mistakes.slice(0, 5),
avgAccuracy,
preferredDifficulty
};
}
/**
* Generates a personalized system prompt segment based on tutor memory
*/
export function generateMemoryPrompt(memory: TutorMemory): string {
const parts: string[] = [];
if (memory.weaknesses.grammar.length > 0) {
parts.push(`WEAK AREAS (focus here): ${memory.weaknesses.grammar.join(', ')}`);
}
if (memory.weaknesses.topics.length > 0) {
parts.push(`Topics needing practice: ${memory.weaknesses.topics.join(', ')}`);
}
if (memory.strengths.length > 0) {
parts.push(`Strong topics (use sparingly): ${memory.strengths.join(', ')}`);
}
parts.push(`Student average accuracy: ${memory.avgAccuracy}%`);
if (memory.recentMistakes.length > 0) {
parts.push(`\nRecent mistakes to avoid repeating:\n${memory.recentMistakes.slice(0, 3).join('\n')}`);
}
return parts.join('\n');
}
================================================
FILE: src/lib/ai/prompts.ts
================================================
import { Quiz, TestConfiguration } from './schemas';
import { FEW_SHOT_EXAMPLES } from './examples';
import { TutorMemory, generateMemoryPrompt } from './memory';
export const createRichQuizPrompt = (
config: TestConfiguration,
context?: string,
memory?: TutorMemory,
isLiteMode: boolean = false
) => {
const memoryContext = memory ? `\n\n📊 STUDENT PROFILE:\n${generateMemoryPrompt(memory)}\n` : '';
// In Lite Mode (for local models), use a tiny example to save tokens
const examplesContent = isLiteMode
? `EXAMPLE: { "topic": "test", "difficulty": "A1", "questions": [{ "id": "1", "type": "gap-fill", "instruction": "...", "question": "...", "options": ["..."], "correctAnswer": "...", "explanation": "..." }] }`
: FEW_SHOT_EXAMPLES;
return `
ROLE:
You are a PROFESSIONAL ITALIAN LANGUAGE TEACHER for POLISH STUDENTS.
Your primary goal is TEACHING, not testing.
STUDENT PROFILE (IMPORTANT):
- Native language: Polish
- Typical difficulties: articles (il/la/i/le), prepositions, verb auxiliaries (essere/avere), agreement
- Learning goal: active and correct usage of Italian, not just recognition
${memoryContext}
TARGET LEVEL:
${config.level} (CEFR standard)
FOCUS OF THIS QUIZ:
${config.focus.toUpperCase()}
TONE AND LANGUAGE RULES:
- Instructions: ITALIAN
- Explanations: POLISH ONLY (CRITICAL - NO English or Italian in explanations)
- Address the student using: ${config.tone === 'formal' ? 'Lei' : 'Tu'}
REFERENCE MATERIAL (DO NOT COPY VERBATIM):
"""
${context || 'Use standard Italian appropriate for the given CEFR level.'}
"""
FEW-SHOT EXAMPLES (PATTERNS TO FOLLOW):
"""
${examplesContent}
"""
DIDACTIC PRINCIPLES (FOLLOW STRICTLY):
1. Exercises MUST progress from EASIER to HARDER.
2. First questions should test RECOGNITION (multiple-choice if available).
3. Later questions must require ACTIVE PRODUCTION (gap-fill, error-correction).
4. Each question must focus on ONLY ONE grammar or usage problem.
5. Avoid trick questions and ambiguity.
6. Distractors must reflect REAL mistakes made by Polish learners (e.g., wrong auxiliary, wrong agreement).
7. Explanations must explain the GRAMMAR RULE, not just why the answer is correct.
8. Do NOT assume the student already knows the rule.
9. Explanations should be understandable without prior theory.
TASK:
Generate exactly ${config.amount || 5} exercises.
ALLOWED EXERCISE TYPES:
${config.exerciseTypes.join(', ')}
SOURCE MODE:
${config.sourceMode.toUpperCase()} (If SYLLABUS-ONLY: strictly use only vocabulary/grammar from the provided material)
${memory ? `⚠️ PERSONALIZATION: Focus on student's weak areas (${memory.weaknesses.grammar.join(', ')}) while avoiding over-repetition of mastered topics.` : ''}
OUTPUT FORMAT (STRICT JSON):
{
"topic": "${config.topicId}",
"difficulty": "${config.level}",
"questions": [
{
"id": "q1",
"type": "multiple-choice | gap-fill | error-correction | scramble | transformation",
"instruction": "Instruction in Italian (e.g., 'Scegli la risposta corretta')",
"question": "Sentence or phrase in Italian",
"options": ["option1", "option2", "option3", "option4"],
"correctAnswer": "string",
"explanation": "Explanation in POLISH explaining the RULE",
"errorSegment": "wrong_word" // Only for error-correction
}
]
}
FINAL SELF-CHECK (DO NOT OUTPUT THIS, JUST VERIFY):
- Would this help a real student understand Italian better?
- Is the explanation understandable without prior theory?
- Is the difficulty appropriate for ${config.level}?
- Do questions progress from easier (recognition) to harder (production)?
- Do distractors reflect real Polish learner mistakes?
OUTPUT ONLY JSON. NO COMMENTS. NO MARKDOWN BLOCKS.
`;
};
export const quizValidationPrompt = (quizJson: string) => {
return `
You are an EDUCATIONAL QUALITY ASSURANCE EXPERT for a language learning application.
INPUT QUIZ:
${quizJson}
EVALUATE STRICTLY AGAINST THE FOLLOWING CRITERIA:
1. LINGUISTIC CORRECTNESS:
- Are all correct answers grammatically and idiomatically correct in Italian?
2. EXPLANATION LANGUAGE:
- Are ALL explanations written STRICTLY in POLISH? (Reject if English or Italian)
3. PEDAGOGICAL QUALITY:
- Do explanations explain the GRAMMAR RULE (not only the answer)?
- Are explanations understandable without prior knowledge?
- Is each question focused on only ONE grammar or usage issue?
4. DIFFICULTY CONSISTENCY:
- Is the difficulty consistent with the declared CEFR level (A1-C2)?
- Do questions progress from easier to harder?
5. DISTRACTOR QUALITY:
- Are distractors realistic mistakes for Polish learners?
- Do they reflect common errors (wrong auxiliary, agreement, preposition)?
6. LEARNING EFFECTIVENESS:
- Is this quiz useful for LEARNING, not only testing?
- Would it help a student understand and internalize the rule?
7. TECHNICAL:
- Are options distinctive (no duplicates)?
- Is the JSON structure valid?
- Are there any DUPLICATE questions?
OUTPUT STRICT JSON ONLY:
{
"valid": boolean,
"feedback": "POLISH explanation of what to improve, or 'OK' if valid"
}
Be strict. This is educational content, not entertainment.
`;
};
================================================
FILE: src/lib/ai/schemas.ts
================================================
import { z } from 'zod';
// Base schema for a question
// Base schema definitions moved below to support discriminated union
// Specific schemas for different types
const BaseQuestion = z.object({
id: z.string().describe('Unique identifier for the question'),
instruction: z.string().describe('Instruction in Italian (e.g., "Scegli la risposta corretta")'),
question: z.string().describe('The content of the question (sentence or phrase)'),
correctAnswer: z.string().describe('The correct answer text'),
explanation: z.string().describe('Explanation in Polish why this answer is correct'),
});
export const MultipleChoiceSchema = BaseQuestion.extend({
type: z.literal('multiple-choice'),
options: z.array(z.string()).min(2).describe('Distractors + Correct Answer'),
});
export const GapFillSchema = BaseQuestion.extend({
type: z.literal('gap-fill'),
options: z.array(z.string()).min(2).describe('Options to fill the gap'),
});
export const ErrorCorrectionSchema = BaseQuestion.extend({
type: z.literal('error-correction'),
errorSegment: z.string().describe('The specific word/phrase that is wrong'),
options: z.array(z.string()).min(2).describe('Options including correct correction'),
});
export const OtherQuestionSchema = BaseQuestion.extend({
type: z.enum(['scramble', 'transformation', 'dictation']),
options: z.array(z.string()).optional().describe('Optional helpful words'),
errorSegment: z.string().optional(),
});
export const QuestionSchema = z.discriminatedUnion('type', [
MultipleChoiceSchema,
GapFillSchema,
ErrorCorrectionSchema,
OtherQuestionSchema
]);
export const QuizSchema = z.object({
topic: z.string().describe('Topic of the generated quiz'),
difficulty: z.enum(['A1', 'A2', 'B1', 'B2']).describe('CEFR Difficulty level'),
questions: z.array(QuestionSchema).min(1).max(10).describe('List of generated questions'),
});
export const TestConfigurationSchema = z.object({
level: z.enum(['A1', 'A2', 'B1', 'B2']),
topicId: z.string(),
focus: z.enum(['grammar', 'vocabulary', 'pragmatics', 'general']),
tone: z.enum(['formal', 'informal']),
exerciseTypes: z.array(z.enum(['multiple-choice', 'gap-fill', 'error-correction', 'scramble', 'transformation', 'dictation'])),
sourceMode: z.enum(['syllabus-only', 'ai-expanded']),
amount: z.number().optional().describe('Number of questions to generate'),
});
export type Question = z.infer<typeof QuestionSchema>;
export type Quiz = z.infer<typeof QuizSchema>;
export type TestConfiguration = z.infer<typeof TestConfigurationSchema>;
export const WritingAnalysisSchema = z.object({
corrections: z.array(z.object({
original: z.string(),
correction: z.string(),
explanation: z.string(),
type: z.enum(['grammar', 'vocabulary', 'tone', 'spelling'])
})).describe('List of errors found in the text'),
overallFeedback: z.string().describe('General feedback in Polish about the writing'),
score: z.number().min(1).max(10).describe('Score from 1 to 10')
});
export type WritingAnalysis = z.infer<typeof WritingAnalysisSchema>;
================================================
FILE: src/lib/challenge/daily.ts
================================================
import { useUserStore, TopicStats, suggestDifficulty } from '@/lib/store/useUserStore';
import { TOPICS } from '@/lib/config/topics';
export interface DailyProgress {
lastCompletedDate: string; // ISO format: "2025-12-12"
currentStreak: number; // Consecutive days
longestStreak: number; // Best ever streak
completedChallenges: DailyChallenge[];
}
export interface DailyChallenge {
id: string;
date: string; // ISO format
topicId: string;
difficulty: 'A1' | 'A2' | 'B1' | 'B2';
completed: boolean;
score?: number;
total?: number;
reason?: string;
}
/**
* Selects today's daily challenge based on user's weak points
*/
export function selectDailyChallenge(): {
topicId: string;
difficulty: 'A1' | 'A2' | 'B1' | 'B2';
reason: string;
} {
const user = useUserStore.getState();
const stats = Object.values(user.topicStats);
// 1. Find weakest topic (mastery < 60%)
const weakTopics = stats
.filter(s => s.mastery < 60 && s.attempts > 0)
.sort((a, b) => a.mastery - b.mastery);
if (weakTopics.length > 0) {
const topic = weakTopics[0];
return {
topicId: topic.topicId,
difficulty: suggestDifficulty(topic.mastery),
reason: `Twój najsłabszy obszar (${topic.mastery}% opanowania)`
};
}
// 2. Find topics not practiced recently (> 3 days)
const now = new Date();
const staleTopics = stats
.filter(s => {
if (!s.lastAttempt) return false;
const lastAttempt = new Date(s.lastAttempt);
const daysSince = (now.getTime() - lastAttempt.getTime()) / (1000 * 60 * 60 * 24);
return daysSince > 3;
})
.sort((a, b) => {
const aDate = new Date(a.lastAttempt).getTime();
const bDate = new Date(b.lastAttempt).getTime();
return aDate - bDate;
});
if (staleTopics.length > 0) {
const topic = staleTopics[0];
const lastDate = new Date(topic.lastAttempt);
const daysAgo = Math.floor((now.getTime() - lastDate.getTime()) / (1000 * 60 * 60 * 24));
return {
topicId: topic.topicId,
difficulty: suggestDifficulty(topic.mastery),
reason: `Niepraktykowany od ${daysAgo} dni - odśwież wiedzę!`
};
}
// 3. Fallback: Random from available topics
const allTopics = TOPICS;
if (allTopics.length === 0) {
// Ultimate fallback
return {
topicId: 'futuro-semplice',
difficulty: 'A2',
reason: 'Codzienny trening - utrzymaj formę!'
};
}
const random = allTopics[Math.floor(Math.random() * allTopics.length)];
return {
topicId: random.id,
difficulty: 'A2',
reason: 'Codzienny trening - utrzymaj formę!'
};
}
/**
* Calculate streak based on last completed date
*/
export function calculateStreak(
lastCompletedDate: string,
currentStreak: number
): { newStreak: number; broke: boolean } {
const today = new Date().toISOString().slice(0, 10);
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10);
// First time
if (!lastCompletedDate) {
return { newStreak: 1, broke: false };
}
// Already completed today
if (lastCompletedDate === today) {
return { newStreak: currentStreak, broke: false };
}
// Completed yesterday - streak continues
if (lastCompletedDate === yesterday) {
return { newStreak: currentStreak + 1, broke: false };
}
// Gap detected - streak broke
return { newStreak: 1, broke: true };
}
/**
* Get today's date in ISO format
*/
export function getTodayDate(): string {
return new Date().toISOString().slice(0, 10);
}
/**
* Check if daily challenge was completed today
*/
export function isDailyChallengeCompleted(): boolean {
const user = useUserStore.getState();
if (!user.dailyProgress) return false;
const today = getTodayDate();
return user.dailyProgress.completedChallenges.some(
(c: DailyChallenge) => c.date === today && c.completed
);
}
/**
* Generate today's daily challenge if not exists
*/
export function getTodaysDailyChallenge(): DailyChallenge {
const user = useUserStore.getState();
const today = getTodayDate();
// Check if already exists
const existing = user.dailyProgress?.completedChallenges.find(
(c: DailyChallenge) => c.date === today
);
if (existing) return existing;
// Generate new challenge
const selected = selectDailyChallenge();
return {
id: `daily-${today}`,
date: today,
topicId: selected.topicId,
difficulty: selected.difficulty,
completed: false,
reason: selected.reason
};
}
================================================
FILE: src/lib/config/topics.ts
================================================
export interface Topic {
id: string;
title: string;
titleIT?: string; // Italian title
description: string;
level: string; // 'A1' | 'A2' | 'B1' | 'B2'
icon?: string; // Lucide icon name
status?: 'beginning' | 'intermediate' | 'advanced' | 'comprehensive';
moduleId?: number; // Which module (1-6)
sourceFile?: string; // If loading from MD file
}
export const TOPICS: Topic[] = [
// MODULE 1: Language Fundamentals
{
id: 'alfabet-wymowa',
title: 'Alfabet i Wymowa',
titleIT: 'Alfabeto e Pronuncia',
description: 'Fonetica: vocali, consonanti, różnice vs polski',
level: 'A1',
icon: 'Volume2',
status: 'beginning',
moduleId: 1
},
{
id: 'digrammi-trigrammi',
title: 'Digrammi, Trigrammi, Doppie',
titleIT: 'Digrammi, Trigrammi e Doppie',
description: 'gli, gn, sc, ch, doppie - typowe błędy Polaków',
level: 'A1',
icon: 'Mic',
status: 'beginning',
moduleId: 1
},
{
id: 'rodzaj-rzeczownikow',
title: 'Rodzaj Rzeczowników',
titleIT: 'Genere dei Sostantivi',
description: 'Maschile / Femminile, wyjątki, końcówki',
level: 'A1',
icon: 'Type',
status: 'beginning',
moduleId: 1
},
{
id: 'liczba-mnoga',
title: 'Liczba Mnoga',
titleIT: 'Plurale',
description: 'Plurali regolari e irregolari, parole invariabili',
level: 'A1',
icon: 'Copy',
status: 'beginning',
moduleId: 1
},
{
id: 'artikuly',
title: 'Artykuły',
titleIT: 'Articoli',
description: 'Determinativi (il/la) e Indeterminativi (un/una)',
level: 'A1',
icon: 'FileText',
status: 'beginning',
moduleId: 1
},
// MODULE 2: Sentence Structure
{
id: 'przymiotniki-zgoda',
title: 'Przymiotniki - Zgoda',
titleIT: 'Aggettivi - Accordo',
description: 'Accordo con il nome, pozycja przymiotników',
level: 'A1',
icon: 'CheckCircle',
status: 'intermediate',
moduleId: 2
},
{
id: 'questo-quello',
title: 'Questo / Quello',
titleIT: 'Dimostrativi',
description: 'Aggettivi dimostrativi - forma i uso',
level: 'A1',
icon: 'MousePointer',
status: 'intermediate',
moduleId: 2
},
{
id: 'dzierzawcze',
title: 'Zaimki Dzierżawcze',
titleIT: 'Possessivi',
description: 'Mio, tuo, suo - z artykułem',
level: 'A1',
icon: 'User',
status: 'intermediate',
moduleId: 2
},
{
id: 'zaimki-bezposrednie',
title: 'Zaimki Dopełnienia Bliższego',
titleIT: 'Pronomi Diretti',
description: 'Mi, ti, lo, la - pozycja w zdaniu',
level: 'A2',
icon: 'ArrowRight',
status: 'intermediate',
moduleId: 2
},
{
id: 'szyk-zdania',
title: 'Szyk Zdania',
titleIT: 'Ordine delle Parole',
description: 'SVO, akcent informacyjny',
level: 'A1',
icon: 'Shuffle',
status: 'intermediate',
moduleId: 2
},
// MODULE 3: Verbs and Tenses
{
id: 'osoba-liczba',
title: 'Czasownik - Osoba i Liczba',
titleIT: 'Verbo - Persona e Numero',
description: '3 koniugacje, essere / avere',
level: 'A1',
icon: 'Users',
status: 'intermediate',
moduleId: 3
},
{
id: 'presente',
title: 'Presente',
titleIT: 'Presente Indicativo',
description: 'Czasowniki regularne i nieregularne',
level: 'A1',
icon: 'Clock',
status: 'intermediate',
moduleId: 3
},
{
id: 'stare-gerundio',
title: 'Stare + Gerundio',
titleIT: 'Stare + Gerundio / Stare per',
description: 'Czynność w toku i zamiar',
level: 'A2',
icon: 'Play',
status: 'intermediate',
moduleId: 3
},
{
id: 'passato-prossimo',
title: 'Passato Prossimo',
titleIT: 'Passato Prossimo',
description: 'Essere vs Avere, participio',
level: 'A2',
icon: 'Calendar',
status: 'intermediate',
moduleId: 3
},
{
id: 'imperfetto',
title: 'Imperfetto',
titleIT: 'Imperfetto',
description: 'Opis vs fakt, kontrast z Passato Prossimo',
level: 'A2',
icon: 'History',
status: 'intermediate',
moduleId: 3
},
// MODULE 4: Language Functions
{
id: 'przyimki-proste',
title: 'Przyimki Proste',
titleIT: 'Preposizioni Semplici',
description: 'Di, a, da, in, con, su, per, tra/fra',
level: 'A1',
icon: 'Link',
status: 'intermediate',
moduleId: 4
},
{
id: 'locuzioni-avverbi',
title: 'Locuzioni i Przysłówki',
titleIT: 'Locuzioni e Avverbi',
description: 'Sopra, sotto, perché, quando',
level: 'A2',
icon: 'MapPin',
status: 'intermediate',
moduleId: 4
},
{
id: 'ce-ci-sono',
title: 'C\'è / Ci sono',
titleIT: 'C\'è / Ci sono',
description: 'Wyrażanie istnienia',
level: 'A1',
icon: 'Eye',
status: 'intermediate',
moduleId: 4
},
{
id: 'porownaniacomparatives',
title: 'Porównania',
titleIT: 'Comparativi e Superlativi',
description: 'Più, meno, come - stopniowanie',
level: 'A2',
icon: 'BarChart',
status: 'intermediate',
moduleId: 4
},
{
id: 'opisywanie',
title: 'Opisywanie Osób i Miejsc',
titleIT: 'Descrivere Persone e Luoghi',
description: 'Wygląd, cechy, charakterystyka',
level: 'A2',
icon: 'Image',
status: 'intermediate',
moduleId: 4
},
// MODULE 5: Thematic Vocabulary
{
id: 'czas-pogoda',
title: 'Czas i Pogoda',
titleIT: 'Tempo e Meteo',
description: 'Dni, miesiące, pory roku, pogoda',
level: 'A1',
icon: 'Cloud',
status: 'advanced',
moduleId: 5
},
{
id: 'miasto-geografia',
title: 'Miasto i Geografia',
titleIT: 'Città e Geografia',
description: 'Luoghi, orientamento, transport',
level: 'A2',
icon: 'Map',
status: 'advanced',
moduleId: 5
},
{
id: 'jedzenie',
title: 'Jedzenie i Gastronomia',
titleIT: 'Cibo e Gastronomia',
description: 'Menu, zamawianie, produkty',
level: 'A2',
icon: 'Coffee',
status: 'advanced',
moduleId: 5
},
{
id: 'zdrowie',
title: 'Zdrowie i Samopoczucie',
titleIT: 'Salute e Benessere',
description: 'Dolegliwości, wizyta u lekarza',
level: 'A2',
icon: 'Heart',
status: 'advanced',
moduleId: 5
},
{
id: 'kolory-liczby',
title: 'Kolory, Liczby, Narodowości',
titleIT: 'Colori, Numeri, Nazionalità',
description: 'Podstawowe słownictwo',
level: 'A1',
icon: 'Palette',
status: 'advanced',
moduleId: 5
},
// MODULE 6: Pragmatics and Culture
{
id: 'powitania',
title: 'Przedstawianie się i Powitania',
titleIT: 'Presentarsi e Salutare',
description: 'Formal / Informal, kontekst',
level: 'A1',
icon: 'Hand',
status: 'advanced',
moduleId: 6
},
{
id: 'prosby-intencje',
title: 'Prośby i Intencje',
titleIT: 'Richieste e Intenzioni',
description: 'Vorrei, posso, potrebbe',
level: 'A2',
icon: 'MessageCircle',
status: 'advanced',
moduleId: 6
},
{
id: 'telefon',
title: 'Rozmowa Telefoniczna',
titleIT: 'Conversazione Telefonica',
description: 'Zaczynanie i kończenie rozmowy',
level: 'A2',
icon: 'Phone',
status: 'advanced',
moduleId: 6
},
{
id: 'kultura-wloska',
title: 'Kultura i Styl Życia',
titleIT: 'Cultura e Stile di Vita',
description: 'Dom, jedzenie, stereotypy włoskie',
level: 'A2',
icon: 'Globe',
status: 'advanced',
moduleId: 6
},
{
id: 'podroz-wlochy',
title: 'Podróż po Włoszech',
titleIT: 'Viaggiare in Italia',
description: 'Torino, Piemonte, transport',
level: 'A2',
icon: 'Plane',
status: 'advanced',
moduleId: 6
},
// Keep original special topics
{
id: 'futuro-semplice',
title: 'Futuro Semplice',
titleIT: 'Futuro Semplice',
description: 'Rozmawiaj o jutrze, obietnicach i przewidywaniach.',
level: 'A2',
icon: 'CalendarDays',
status: 'intermediate',
moduleId: 3
},
{
id: 'test-calosion',
title: 'Test Całościowy (Syllabus)',
titleIT: 'Test Completo',
description: 'Materiały A1/A2: Fonetyka, Morfologia, Składnia, Leksyka.',
level: 'A2',
icon: 'GraduationCap',
status: 'comprehensive',
moduleId: 0,
sourceFile: 'syllabus.md'
},
{
id: 'architetto-frase',
title: 'L\'Architetto della Frase',
titleIT: 'L\'Architetto della Frase',
description: 'Morfologia i Składnia: Znajdź i popraw błędy w zdaniach.',
level: 'B1',
icon: 'Wrench',
status: 'advanced',
moduleId: 2,
sourceFile: 'syllabus.md'
},
{
id: 'mistrz-wymowy',
title: 'Mistrz Wymowy (Fonetika)',
titleIT: 'Maestro di Pronuncia',
description: 'Dyktando AI: Digrammi, trigrammi e doppie.',
level: 'A1',
icon: 'Headphones',
status: 'beginning',
moduleId: 1,
sourceFile: 'syllabus.md'
}
];
================================================
FILE: src/lib/content/loader.ts
================================================
'use server';
import fs from 'fs/promises';
import path from 'path';
export async function getTopicContent(topicId: string, sourceFile?: string): Promise<string | null> {
try {
// If a specific source file is defined in config (e.g. syllabus.md), use it
// Otherwise try to find a file matching the topicId in content/courses
const filename = sourceFile || `${topicId}.md`;
// Look in src/content/courses first
let filePath = path.join(process.cwd(), 'src/content/courses', filename);
// Check if file exists
try {
await fs.access(filePath);
} catch {
// Fallback: Check src/components/content for legacy/syllabus files if explicit sourceFile was requested
// Or if we just can't find the course file
if (sourceFile) {
filePath = path.join(process.cwd(), 'src/components/content', filename);
} else {
return null;
}
}
const content = await fs.readFile(filePath, 'utf-8');
return content;
} catch (error) {
console.warn(`[ContentLoader] Failed to load content for ${topicId}:`, error);
return null;
}
}
================================================
FILE: src/lib/content/slicer.ts
================================================
import fs from 'fs';
import path from 'path';
export interface SyllabusChunk {
id: string;
part: string;
section: string;
content: string[]; // List of bullet points or text
}
// Map recognizable topic IDs (from TOPICS config) to Syllabus Section Headers
const TOPIC_MAP: Record<string, string[]> = {
'verbi-passato': ['Verbi', 'Tempi e Modi', 'Passato Prossimo'],
'verbi-presente': ['Verbi', 'Tempi e Modi', 'Presente Indicativo'],
'cibo': ['Cibo e alimentazione'],
'saluti': ['Competenze Comunicative', 'Presentarsi e salutare'],
'la-citta': ['La città'],
'pronomi': ['Pronomi'],
'articoli': ['Articoli'],
'preposizioni': ['Preposizioni'],
// Add default fallback or expanded mapping
};
// Singleton cache
let _cachedChunks: SyllabusChunk[] | null = null;
export function getSyllabusChunks(): SyllabusChunk[] {
if (_cachedChunks) return _cachedChunks;
const filePath = path.join(process.cwd(), 'src/components/content/syllabus.md');
try {
const fileContent = fs.readFileSync(filePath, 'utf-8');
_cachedChunks = parseSyllabus(fileContent);
return _cachedChunks;
} catch (error) {
console.error("Error reading syllabus:", error);
return [];
}
}
function parseSyllabus(markdown: string): SyllabusChunk[] {
const lines = markdown.split('\n');
const chunks: SyllabusChunk[] = [];
let currentPart = '';
let currentSection = '';
let currentContent: string[] = [];
const flushChunk = () => {
if (currentSection && currentContent.length > 0) {
chunks.push({
id: `${currentPart}-${currentSection}`.toLowerCase().replace(/[^a-z0-9]+/g, '-'),
part: currentPart,
section: currentSection,
content: [...currentContent]
});
currentContent = [];
}
};
lines.forEach(line => {
const trimmed = line.trim();
if (!trimmed) return;
if (trimmed.startsWith('## ')) {
flushChunk();
currentPart = trimmed.replace('## ', '').trim();
// Reset section when part changes? Usually yes.
currentSection = '';
} else if (trimmed.startsWith('### ')) {
flushChunk();
currentSection = trimmed.replace('### ', '').trim();
} else if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) {
// Content item
currentContent.push(trimmed);
} else if (currentSection && !trimmed.startsWith('#')) {
// Text block inside a section
currentContent.push(trimmed);
}
});
flushChunk(); // Final flush
return chunks;
}
export function getTopicContext(topicId: string): string {
const chunks = getSyllabusChunks();
// 1. Direct keywords from Topic ID
const keywords = topicId.split('-'); // e.g., ['verbi', 'passato']
// 2. Lookup in Map
const specificSections = TOPIC_MAP[topicId] || [];
// 3. Filter chunks
const relevantChunks = chunks.filter(chunk => {
// High priority: Exact section match from MAP
if (specificSections.some(s => chunk.section.includes(s) || chunk.content.some(c => c.includes(s)))) {
return true;
}
// Medium priority: Keyword match in section title
if (keywords.some(k => chunk.section.toLowerCase().includes(k))) {
return true;
}
return false;
});
if (relevantChunks.length === 0) {
// Fallback: Return General Grammar or just empty (let AI handle it)
// But for RAG we prefer giving *something* if possible, or being explicit "No specific context found".
return "No specific syllabus context found for this topic. Use general knowledge.";
}
// formatting for Prompt
return relevantChunks.map(chunk => `
SECTION: ${chunk.section} (Part: ${chunk.part})
CONTENT:
${chunk.content.join('\n')}
`).join('\n---\n');
}
================================================
FILE: src/lib/didactic/utils.ts
================================================
import { Question } from '../ai/schemas';
/**
* Generates context-aware feedback based on the type of mistake
*/
export function getSmartFeedback(
question: Question,
userAnswer: string,
correctAnswer: string
): string {
if (userAnswer === correctAnswer) {
return '✅ Dobrze! Tak właśnie się tego używa.';
}
// Close but wrong ending (grammar form - same root, different ending)
if (
userAnswer.length >= 4 &&
correctAnswer.length >= 4 &&
userAnswer.slice(0, 4).toLowerCase() === correctAnswer.slice(0, 4).toLowerCase()
) {
return '⚠️ Byłeś blisko — forma gramatyczna jest inna. Sprawdź końcówkę i regułę.';
}
// Completely different construction
return '❌ To inna konstrukcja — zwróć uwagę na regułę w wyjaśnieniu poniżej.';
}
/**
* Builds a teaching hint that guides without revealing the answer
*/
export function buildTeachingHint(question: Question): string {
if (question.type === 'gap-fill') {
return '💡 Zastanów się, czy to jest czas ruchu czy stanu. Pomyśl o kontekście zdania.';
}
if (question.type === 'error-correction') {
return '💡 Sprawdź zgodność osoby i liczby. Zwróć uwagę na podmiot zdania.';
}
if (question.type === 'multiple-choice') {
return '💡 Pomyśl o regule, nie o tłumaczeniu dosłownym. Jaka jest różnica między opcjami?';
}
if (question.type === 'transformation') {
return '💡 Jaka reguła transformacji dotyczy tego typu zdań? Pomyśl o formie.';
}
if (question.type === 'scramble') {
return '💡 Jaki jest naturalny szyk wyrazów w języku włoskim? Zacznij od podmiotu.';
}
// Fallback: first sentence of explanation (but not the whole thing)
const firstSentence = question.explanation.split('.')[0];
if (firstSentence && firstSentence.length < 100) {
return `💡 Wskazówka: ${firstSentence}.`;
}
return '💡 Przeczytaj jeszcze raz pytanie i pomyśl o regule gramatycznej.';
}
/**
* Calculate partial points for close answers
*/
export function calculatePartialPoints(
isCorrect: boolean,
userAnswer: string,
correctAnswer: string
): number {
if (isCorrect) return 10;
// Check if answer is similar (same root, wrong ending)
if (isSimilarAnswer(userAnswer, correctAnswer)) {
return 5; // Half points for being close
}
return 0;
}
/**
* Checks if two answers are similar (same root, different ending)
*/
function isSimilarAnswer(userAnswer: string, correctAnswer: string): boolean {
if (!userAnswer || !correctAnswer) return false;
if (userAnswer.length < 4 || correctAnswer.length < 4) return false;
const userRoot = userAnswer.slice(0, 4).toLowerCase();
const correctRoot = correctAnswer.slice(0, 4).toLowerCase();
return userRoot === correctRoot;
}
/**
* Determines appropriate exercise types based on difficulty level
*/
export function selectExerciseTypesByDifficulty(difficulty: string): string[] {
if (difficulty === 'A1') {
// Beginners: recognition only
return ['multiple-choice'];
}
if (difficulty === 'A2') {
// Elementary: recognition + controlled production
return ['multiple-choice', 'gap-fill'];
}
if (difficulty === 'B1') {
// Intermediate: production + error detection
return ['gap-fill', 'error-correction', 'transformation'];
}
// B2+: advanced production
return ['gap-fill', 'error-correction', 'transformation', 'scramble'];
}
/**
* Orders questions from easy to hard (scaffolding)
*/
export function orderQuestionsByDifficulty(questions: Question[]): Question[] {
const ordered: Question[] = [];
// Phase 1: Recognition (easiest)
const multipleChoice = questions.filter(q => q.type === 'multiple-choice');
ordered.push(...multipleChoice);
// Phase 2: Controlled production (medium)
const gapFill = questions.filter(q => q.type === 'gap-fill');
ordered.push(...gapFill);
// Phase 3: Error detection (harder)
const errorCorrection = questions.filter(q => q.type === 'error-correction');
ordered.push(...errorCorrection);
// Phase 4: Advanced production (hardest)
const advanced = questions.filter(q =>
q.type === 'transformation' ||
q.type === 'scramble' ||
q.type === 'dictation'
);
ordered.push(...advanced);
return ordered;
}
================================================
FILE: src/lib/learning/curriculum.ts
================================================
/**
* 30-Lesson Curriculum Structure
* 6 modules × 5 lessons each
* Complete A1-A2 Italian course
*/
import { Skill } from './skillTree';
export interface LessonMaterial {
id: number;
moduleId: number;
title: string;
titlePL: string;
primarySkills: string[]; // Main skills taught
secondarySkills: string[]; // Supporting skills
cefr: 'A1' | 'A2';
estimatedMinutes: number;
}
export interface Module {
id: number;
title: string;
titlePL: string;
goal: string;
checkpoint: string;
lessons: LessonMaterial[];
}
// MODULE 1: Fundamenty Języka (1-5)
export const MODULE_1: Module = {
id: 1,
title: 'Language Fundamentals',
titlePL: 'Fundamenty Języka',
goal: 'Dekodowanie języka + pierwsza kontrola form',
checkpoint: 'A1-FORM',
lessons: [
{
id: 1,
moduleId: 1,
title: 'Italian Alphabet and Pronunciation',
titlePL: 'Alfabet włoski i wymowa',
primarySkills: ['phonetics-vowels-consonants'],
secondarySkills: ['phonetics-basic-pronunciation'],
cefr: 'A1',
estimatedMinutes: 30
},
{
id: 2,
moduleId: 1,
title: 'Digraphs, Trigraphs, Double Consonants',
titlePL: 'Digrammi, trigrammi, doppie',
primarySkills: ['phonetics-digrams-trigrams', 'phonetics-double-consonants'],
secondarySkills: [],
cefr: 'A1',
estimatedMinutes: 35
},
{
id: 3,
moduleId: 1,
title: 'Noun Gender',
titlePL: 'Rodzaj rzeczowników',
primarySkills: ['nouns-gender-regular'],
secondarySkills: ['nouns-gender-irregular'],
cefr: 'A1',
estimatedMinutes: 40
},
{
id: 4,
moduleId: 1,
title: 'Plural Forms and Irregular Plurals',
titlePL: 'Liczba mnoga i plurali irregolari',
primarySkills: ['nouns-plural-regular'],
secondarySkills: ['nouns-plural-irregular'],
cefr: 'A1',
estimatedMinutes: 40
},
{
id: 5,
moduleId: 1,
title: 'Articles (Definite and Indefinite)',
titlePL: 'Artykuły (determinativi i indeterminativi)',
primarySkills: ['articles-definite', 'articles-indefinite'],
secondarySkills: [],
cefr: 'A1',
estimatedMinutes: 45
}
]
};
// MODULE 2: Struktura Zdania (6-10)
export const MODULE_2: Module = {
id: 2,
title: 'Sentence Structure',
titlePL: 'Struktura Zdania',
goal: 'Budowa poprawnej frazy',
checkpoint: 'A1-SENTENCE',
lessons: [
{
id: 6,
moduleId: 2,
title: 'Adjectives - Form and Agreement',
titlePL: 'Przymiotniki forma i zgoda',
primarySkills: ['adjectives-agreement'],
secondarySkills: [],
cefr: 'A1',
estimatedMinutes: 40
},
{
id: 7,
moduleId: 2,
title: 'Demonstratives (questo/quello)',
titlePL: 'Aggettivi dimostrativi (questo/quello)',
primarySkills: ['demonstratives-forms'],
secondarySkills: ['demonstratives-usage'],
cefr: 'A1',
estimatedMinutes: 35
},
{
id: 8,
moduleId: 2,
title: 'Possessive Adjectives and Pronouns',
titlePL: 'Aggettivi i pronomi dzierżawcze',
primarySkills: ['possessives-with-article'],
secondarySkills: [],
cefr: 'A1',
estimatedMinutes: 40
},
{
id: 9,
moduleId: 2,
title: 'Direct Object Pronouns',
titlePL: 'Zaimki dopełnienia bliższego',
primarySkills: ['pronouns-direct-forms', 'pronouns-direct-position'],
secondarySkills: [],
cefr: 'A2',
estimatedMinutes: 45
},
{
id: 10,
moduleId: 2,
title: 'Word Order in Sentences',
titlePL: 'Kolejność wyrazów w zdaniu',
primarySkills: ['syntax-basic-word-order'],
secondarySkills: [],
cefr: 'A1',
estimatedMinutes: 35
}
]
};
// MODULE 3: Czasowniki i Czas (11-15)
export const MODULE_3: Module = {
id: 3,
title: 'Verbs and Tenses',
titlePL: 'Czasowniki i Czas',
goal: 'Opisywanie teraźniejszości i przeszłości',
checkpoint: 'A2-TENSES',
lessons: [
{
id: 11,
moduleId: 3,
title: 'Verb Person and Number',
titlePL: 'Czasownik osoba i liczba',
primarySkills: ['verbs-person-number', 'present-essere-avere'],
secondarySkills: [],
cefr: 'A1',
estimatedMinutes: 40
},
{
id: 12,
moduleId: 3,
title: 'Present Tense',
titlePL: 'Presente',
primarySkills: ['present-regular-verbs'],
secondarySkills: ['present-irregular-verbs'],
cefr: 'A1',
estimatedMinutes: 50
},
{
id: 13,
moduleId: 3,
title: 'Stare + gerundio / stare per',
titlePL: 'Stare + gerundio / stare per',
primarySkills: ['progressive-stare-gerundio', 'immediate-future-stare-per'],
secondarySkills: [],
cefr: 'A2',
estimatedMinutes: 40
},
{
id: 14,
moduleId: 3,
title: 'Passato Prossimo',
titlePL: 'Passato prossimo',
primarySkills: ['passato-prossimo-auxiliary'],
secondarySkills: ['past-participle-agreement'],
cefr: 'A2',
estimatedMinutes: 60
},
{
id: 15,
moduleId: 3,
title: 'Imperfetto',
titlePL: 'Imperfetto',
primarySkills: ['imperfetto-basics', 'imperfetto-passato-distinction'],
secondarySkills: [],
cefr: 'A2',
estimatedMinutes: 55
}
]
};
// MODULE 4: Funkcje Językowe (16-20)
export const MODULE_4: Module = {
id: 4,
title: 'Language Functions',
titlePL: 'Funkcje Językowe',
goal: 'Komunikacja w realnych sytuacjach',
checkpoint: 'A2-FUNCTION',
lessons: [
{
id: 16,
moduleId: 4,
title: 'Simple Prepositions',
titlePL: 'Przyimki proste',
primarySkills: ['basic-prepositions'],
secondarySkills: [],
cefr: 'A1',
estimatedMinutes: 40
},
{
id: 17,
moduleId: 4,
title: 'Locutions, Adverbs, Conjunctions',
titlePL: 'Locuzioni + avverbi + congiunzioni',
primarySkills: ['prepositions-locutions', 'adverbs-frequency', 'conjunctions-basic'],
secondarySkills: [],
cefr: 'A2',
estimatedMinutes: 45
},
{
id: 18,
moduleId: 4,
title: 'C\'è / ci sono',
titlePL: 'C\'è / ci sono',
primarySkills: ['ci-sono-usage'],
secondarySkills: [],
cefr: 'A1',
estimatedMinutes: 30
},
{
id: 19,
moduleId: 4,
title: 'Comparisons and Degrees',
titlePL: 'Porównania i stopniowanie',
primarySkills: ['comparatives', 'superlatives'],
secondarySkills: [],
cefr: 'A2',
estimatedMinutes: 45
},
{
id: 20,
moduleId: 4,
title: 'Describing People and Places',
titlePL: 'Opisywanie osoby i miejsca',
primarySkills: ['describing-people-places'],
secondarySkills: [],
cefr: 'A2',
estimatedMinutes: 50
}
]
};
// MODULE 5: Leksyka Tematyczna (21-25)
export const MODULE_5: Module = {
id: 5,
title: 'Thematic Vocabulary',
titlePL: 'Leksyka Tematyczna',
goal: 'Zasób słów + transfer',
checkpoint: 'LEXICAL',
lessons: [
{
id: 21,
moduleId: 5,
title: 'Time, Days, Months, Weather',
titlePL: 'Czas, dni, miesiące, pogoda',
primarySkills: ['lex-time-weather'],
secondarySkills: [],
cefr: 'A1',
estimatedMinutes: 40
},
{
id: 22,
moduleId: 5,
title: 'City and Geography',
titlePL: 'Miasto i geografia',
primarySkills: ['lex-city', 'lex-geography'],
secondarySkills: [],
cefr: 'A2',
estimatedMinutes: 40
},
{
id: 23,
moduleId: 5,
title: 'Food and Gastronomy',
titlePL: 'Jedzenie i gastronomia',
primarySkills: ['lex-food', 'pragmatics-ordering-food'],
secondarySkills: [],
cefr: 'A2',
estimatedMinutes: 45
},
{
id: 24,
moduleId: 5,
title: 'Health and Well-being',
titlePL: 'Zdrowie i samopoczucie',
primarySkills: ['lex-health', 'pragmatics-expressing-problems'],
secondarySkills: [],
cefr: 'A2',
estimatedMinutes: 40
},
{
id: 25,
moduleId: 5,
title: 'Colors, Numbers, Nationalities',
titlePL: 'Kolory, liczby, narodowości',
primarySkills: ['lex-colors-numbers', 'lex-nationalities'],
secondarySkills: [],
cefr: 'A1',
estimatedMinutes: 35
}
]
};
// MODULE 6: Pragmatyka i Kultura (26-30)
export const MODULE_6: Module = {
id: 6,
title: 'Pragmatics and Culture',
titlePL: 'Pragmatyka i Kultura',
goal: 'Naturalność + kontekst kulturowy',
checkpoint: 'FINAL A2 INTEGRATION',
lessons: [
{
id: 26,
moduleId: 6,
title: 'Introductions and Greetings',
titlePL: 'Przedstawianie się i powitania',
primarySkills: ['pragmatics-greetings-formal-informal'],
secondarySkills: [],
cefr: 'A1',
estimatedMinutes: 35
},
{
id: 27,
moduleId: 6,
title: 'Requests, Needs, Intentions',
titlePL: 'Prośby, potrzeby, intencje',
primarySkills: ['pragmatics-requests', 'pragmatics-expressing-desire'],
secondarySkills: [],
cefr: 'A2',
estimatedMinutes: 40
},
{
id: 28,
moduleId: 6,
title: 'Phone Conversations',
titlePL: 'Telefon i rozmowa',
primarySkills: ['pragmatics-telephone-conversation'],
secondarySkills: [],
cefr: 'A2',
estimatedMinutes: 40
},
{
id: 29,
moduleId: 6,
title: 'Italy: Culture and Lifestyle',
titlePL: 'Włochy: kultura i styl życia',
primarySkills: ['culture-italian-lifestyle'],
secondarySkills: [],
cefr: 'A2',
estimatedMinutes: 45
},
{
id: 30,
moduleId: 6,
title: 'Traveling in Italy',
titlePL: 'Podróż po Włoszech',
primarySkills: ['culture-travel-italy', 'pragmatics-travel'],
secondarySkills: [],
cefr: 'A2',
estimatedMinutes: 50
}
]
};
// Complete curriculum
export const CURRICULUM_MODULES: Module[] = [
MODULE_1,
MODULE_2,
MODULE_3,
MODULE_4,
MODULE_5,
MODULE_6
];
// All 30 lessons flat
export const ALL_LESSONS: LessonMaterial[] = CURRICULUM_MODULES.flatMap(m => m.lessons);
// Helper functions
export function getLessonById(id: number): LessonMaterial | undefined {
return ALL_LESSONS.find(l => l.id === id);
}
export function getModuleById(id: number): Module | undefined {
return CURRICULUM_MODULES.find(m => m.id === id);
}
export function getLessonsByModule(moduleId: number): LessonMaterial[] {
return ALL_LESSONS.filter(l => l.moduleId === moduleId);
}
export function getNextLesson(currentId: number): LessonMaterial | undefined {
return ALL_LESSONS.find(l => l.id === currentId + 1);
}
export function getPreviousLesson(currentId: number): LessonMaterial | undefined {
return ALL_LESSONS.find(l => l.id === currentId - 1);
}
export function getTotalEstimatedTime(): number {
return ALL_LESSONS.reduce((sum, l) => sum + l.estimatedMinutes, 0);
}
export function getModuleProgress(
moduleId: number,
completedLessons: number[]
): number {
const moduleLessons = getLessonsByModule(moduleId);
const completed = moduleLessons.filter(l => completedLessons.includes(l.id)).length;
return moduleLessons.length > 0 ? (completed / moduleLessons.length) * 100 : 0;
}
================================================
FILE: src/lib/learning/curriculumBuilder.ts
================================================
/**
* Curriculum Builder - Adaptive Learning Engine
* Decides what to teach next based on skill states, mastery, and learning goals
*/
import { Skill, SKILL_TREE, getSkill, getPrerequisites } from './skillTree';
import {
SkillState,
needsRemediation,
isInPlateau,
isExamReady,
daysSinceLastPractice
} from './skillState';
import { SessionType } from './sessionPlan';
export type CurriculumGoalType =
| 'remediation' // Fix critical gaps
| 'transfer' // Improve transfer (plateau breaking)
| 'progression' // Learn new skills
| 'assessment' // Formal exam
| 'consolidation' // Review after break
| 'diagnostic'; // Initial assessment
export type Intensity = 'low' | 'medium' | 'high';
export interface CurriculumGoal {
goalType: CurriculumGoalType;
targetSkills: string[]; // Skill IDs
recommendedMode: SessionType;
intensity: Intensity;
strategy: string; // Human-readable explanation
minSessionsRequired: number;
}
export interface CurriculumState {
userLevel: 'A1' | 'A2' | 'B1' | 'B2';
skillStates: Record<string, SkillState>;
currentGoal?: CurriculumGoal;
completedGoals: string[]; // Goal IDs
lastSessionDate?: string;
}
/**
* Main Curriculum Builder Algorithm
* Analyzes user state and decides next learning goal
*/
export function buildCurriculum(state: CurriculumState): CurriculumGoal {
const skills = Object.values(state.skillStates);
// 1. CRITICAL: Remediation (blocking progress)
const blockers = skills.filter(s =>
s.prerequisitesMet &&
needsRemediation(s)
);
if (blockers.length > 0) {
// Focus on worst blocker
const worst = blockers.sort((a, b) => a.mastery - b.mastery)[0];
return {
goalType: 'remediation',
targetSkills: [worst.skillId],
recommendedMode: 'practice',
intensity: 'high',
strategy: `Intensive drill on ${worst.skillId} to fix critical gap`,
minSessionsRequired: 3
};
}
// 2. PLATEAU: High mastery, low transfer
const plateauSkills = skills.filter(isInPlateau);
if (plateauSkills.length > 0) {
// Pick top 2 plateau skills
const targets = plateauSkills
.sort((a, b) => b.mastery - a.mastery)
.slice(0, 2)
.map(s => s.skillId);
return {
goalType: 'transfer',
targetSkills: targets,
recommendedMode: 'practice',
intensity: 'medium',
strategy: 'Practice same rule in new contexts to improve transfer',
minSessionsRequired: 2
};
}
// 3. ASSESSMENT: Skills ready for exam
const examReadySkills = skills.filter(isExamReady);
if (examReadySkills.length >= 3) {
const targets = examReadySkills
.slice(0, 5)
.map(s => s.skillId);
return {
goalType: 'assessment',
targetSkills: targets,
recommendedMode: 'exam',
intensity: 'low',
strategy: 'Formal assessment of mastered skills',
minSessionsRequired: 1
};
}
// 4. CONSOLIDATION: Long break detected
if (state.lastSessionDate) {
const daysSinceSession = Math.floor(
(Date.now() - new Date(state.lastSessionDate).getTime()) / (1000 * 60 * 60 * 24)
);
if (daysSinceSession >= 7) {
// Review previously learned skills
const reviewSkills = skills
.filter(s => s.mastery >= 50 && s.mastery < 85)
.sort((a, b) => daysSinceLastPractice(b) - daysSinceLastPractice(a))
.slice(0, 3)
.map(s => s.skillId);
if (reviewSkills.length > 0) {
return {
goalType: 'consolidation',
targetSkills: reviewSkills,
recommendedMode: 'practice',
intensity: 'medium',
strategy: 'Review after break - mixed practice',
minSessionsRequired: 1
};
}
}
}
// 5. PROGRESSION: Learn new skills
const newSkills = selectNextSkills(skills, SKILL_TREE, state.userLevel);
if (newSkills.length > 0) {
return {
goalType: 'progression',
targetSkills: newSkills.map(s => s.id),
recommendedMode: 'practice',
intensity: 'medium',
strategy: 'Introduction to new skill with scaffolding',
minSessionsRequired: 2
};
}
// 6. FALLBACK: Diagnostic test (shouldn't happen often)
return {
goalType: 'diagnostic',
targetSkills: [],
recommendedMode: 'diagnostic',
intensity: 'low',
strategy: 'Comprehensive diagnostic to reassess skill map',
minSessionsRequired: 1
};
}
/**
* Select next skills for progression
* Based on prerequisites and current level
*/
function selectNextSkills(
currentStates: SkillState[],
skillTree: Skill[],
userLevel: 'A1' | 'A2' | 'B1' | 'B2'
): Skill[] {
const stateMap = new Map(currentStates.map(s => [s.skillId, s]));
// Filter skills at or below user level
const availableSkills = skillTree.filter(skill => {
// Level check
const levelOrder = { 'A1': 0, 'A2': 1, 'B1': 2, 'B2': 3 };
if (levelOrder[skill.cefr] > levelOrder[userLevel]) return false;
// Prerequisites check
const prereqsMet = skill.prerequisites.every(prereqId => {
const prereqState = stateMap.get(prereqId);
return prereqState && prereqState.mastery >= 70;
});
if (!prereqsMet) return false;
// Not already mastered
const currentState = stateMap.get(skill.id);
if (currentState && currentState.mastery >= 85) return false;
// Not currently being learned
if (currentState && currentState.practiceCount > 0) return false;
return true;
});
// Sort by priority (grammar > vocabulary > pragmatics for now)
availableSkills.sort((a, b) => {
const priority = { grammar: 0, vocabulary: 1, pragmatics: 2 };
return priority[a.category] - priority[b.category];
});
// Return top 1-2 skills
return availableSkills.slice(0, 2);
}
/**
* Recommend session count for a goal
*/
export function recommendSessionCount(goal: CurriculumGoal): number {
const base = goal.minSessionsRequired;
switch (goal.intensity) {
case 'high': return base + 2;
case 'medium': return base + 1;
case 'low': return base;
}
}
/**
* Check if goal is complete
*/
export function isGoalComplete(
goal: CurriculumGoal,
skillStates: Record<string, SkillState>,
sessionsCompleted: number
): boolean {
// Minimum sessions required
if (sessionsCompleted < goal.minSessionsRequired) return false;
const targetStates = goal.targetSkills.map(id => skillStates[id]).filter(Boolean);
switch (goal.goalType) {
case 'remediation':
// Mastery must reach 60+
return targetStates.every(s => s.mastery >= 60);
case 'transfer':
// Transfer score must improve
return targetStates.every(s => s.transferScore >= 0.5);
case 'progression':
// Basic mastery (50+)
return targetStates.every(s => s.mastery >= 50);
case 'assessment':
case 'diagnostic':
// Just needs to be completed once
return sessionsCompleted >= 1;
case 'consolidation':
// All reviewed
return sessionsCompleted >= 1;
default:
return false;
}
}
/**
* Get human-readable goal description
*/
export function getGoalDescription(goal: CurriculumGoal): string {
const skillNames = goal.targetSkills.join(', ');
switch (goal.goalType) {
case 'remediation':
return `🔧 Napraw krytyczne luki: ${skillNames}`;
case 'transfer':
return `🔄 Popraw transfer: ${skillNames}`;
case 'progression':
return `🚀 Naucz się: ${skillNames}`;
case 'assessment':
return `✅ Test formalny: ${skillNames}`;
case 'consolidation':
return `📚 Przegląd po przerwie: ${skillNames}`;
case 'diagnostic':
return `🔍 Test diagnostyczny`;
}
}
/**
* Initialize curriculum state for new user
*/
export function initCurriculumState(level: 'A1' | 'A2' | 'B1' | 'B2' = 'A2'): CurriculumState {
return {
userLevel: level,
skillStates: {},
completedGoals: [],
lastSessionDate: new Date().toISOString()
};
}
================================================
FILE: src/lib/learning/curriculumIntegration.ts
================================================
/**
* Integration layer for Curriculum Builder
* Connects user store → curriculum → learning planner
*/
import { buildCurriculum, CurriculumGoal, CurriculumState } from './curriculumBuilder';
import { SkillState, initSkillState, updateSkillState } from './skillState';
import { SKILL_TREE } from './skillTree';
import { UserProfile, SessionContext } from './sessionPlan';
import { planLearningSession } from './planner';
/**
* Build curriculum state from user data
* (Called from client with Zustand store data)
*/
export function buildCurriculumStateFromUser(userData: {
history: any[];
topicStats: Record<string, any>;
level?: string;
}): CurriculumState {
const state: CurriculumState = {
userLevel: (userData.level as any) || 'A2',
skillStates: {},
completedGoals: [],
lastSessionDate: userData.history[0]?.date || new Date().toISOString()
};
// Initialize skill states from topic stats
Object.entries(userData.topicStats).forEach(([topicId, stats]: [string, any]) => {
const skill = SKILL_TREE.find(s => s.id === topicId);
if (!skill) return;
// Check if prerequisites are met
const prerequisitesMet = skill.prerequisites.every(prereqId => {
const prereqStats = userData.topicStats[prereqId];
return prereqStats && prereqStats.mastery >= 70;
});
state.skillStates[topicId] = {
skillId: topicId,
mastery: stats.mastery || 0,
confidence: 50, // TODO: Calculate from variance
errorPersistence: 0, // TODO: Calculate from mistakes
transferScore: 0.5, // TODO: Calculate from history
lastPracticed: stats.lastAttempt || new Date().toISOString(),
practiceCount: stats.attempts || 0,
prerequisitesMet,
status: 'learning'
};
});
// Initialize missing skills
SKILL_TREE.forEach(skill => {
if (!state.skillStates[skill.id]) {
const prerequisitesMet = skill.prerequisites.every(prereqId => {
const prereqState = state.skillStates[prereqId];
return prereqState && prereqState.mastery >= 70;
});
state.skillStates[skill.id] = initSkillState(skill.id, prerequisitesMet);
}
});
return state;
}
/**
* Get current curriculum goal and translate to session plan
*/
export async function getCurriculumGuidedSession(
curriculumState: CurriculumState,
userProfile: UserProfile
): Promise<{ goal: CurriculumGoal; sessionPlan?: any; error?: string }> {
try {
// 1. Build curriculum goal
const goal = buildCurriculum(curriculumState);
// 2. Create session context from goal
const sessionContext: SessionContext = {
mode: goal.recommendedMode,
topicId: goal.targetSkills[0], // Primary skill
allowedExerciseTypes: getExerciseTypesForGoal(goal),
requestedAmount: getQuestionCountForGoal(goal),
focusOnWeaknesses: goal.goalType === 'remediation' || goal.goalType === 'transfer'
};
// 3. Call Learning Planner
const result = await planLearningSession(userProfile, sessionContext);
if (!result.success) {
return { goal, error: result.error };
}
return {
goal,
sessionPlan: result.plan
};
} catch (error) {
return {
goal: buildCurriculum(curriculumState),
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}
/**
* Select exercise types based on goal
*/
function getExerciseTypesForGoal(goal: CurriculumGoal): string[] {
switch (goal.goalType) {
case 'remediation':
// High repetition, controlled production
return ['multiple-choice', 'gap-fill', 'error-correction'];
case 'transfer':
// New contexts
return ['gap-fill', 'transformation', 'error-correction'];
case 'progression':
// Scaffolding
return ['multiple-choice', 'gap-fill'];
case 'assessment':
// Mixed
return ['multiple-choice', 'gap-fill', 'error-correction', 'transformation'];
case 'consolidation':
// Review
return ['multiple-choice', 'gap-fill', 'error-correction'];
case 'diagnostic':
// Broad coverage
return ['multiple-choice', 'gap-fill', 'error-correction'];
}
}
/**
* Determine question count based on goal intensity
*/
function getQuestionCountForGoal(goal: CurriculumGoal): number {
const base = {
'remediation': 7, // More practice
'transfer': 5,
'progression': 5,
'assessment': 8, // Comprehensive
'consolidation': 6,
'diagnostic': 12 // Wide coverage
};
let count = base[goal.goalType] || 5;
// Adjust by intensity
if (goal.intensity === 'high') count += 2;
if (goal.intensity === 'low') count -= 1;
return Math.max(3, count);
}
/**
* Update curriculum state after completing a session
*/
export function updateCurriculumAfterSession(
state: CurriculumState,
sessionResult: {
skillId: string;
correct: number;
total: number;
wasTransfer?: boolean;
},
recentScores: number[]
): CurriculumState {
const currentSkillState = state.skillStates[sessionResult.skillId];
if (!currentSkillState) {
console.warn(`Skill ${sessionResult.skillId} not found in curriculum state`);
return state;
}
const updatedSkillState = updateSkillState(
currentSkillState,
sessionResult,
recentScores
);
return {
...state,
skillStates: {
...state.skillStates,
[sessionResult.skillId]: updatedSkillState
},
lastSessionDate: new Date().toISOString()
};
}
/**
* Get next recommended action for user
*/
export function getNextRecommendedAction(state: CurriculumState): {
action: string;
description: string;
buttonText: string;
route: string;
} {
const goal = buildCurriculum(state);
switch (goal.goalType) {
case 'remediation':
return {
action: 'remediation',
description: `Napraw luki w: ${goal.targetSkills[0]}`,
buttonText: '🔧 Napraw teraz',
route: `/learn/${goal.targetSkills[0]}`
};
case 'transfer':
return {
action: 'transfer',
description: 'Popraw transfer - nowe konteksty',
buttonText: '🔄 Ćwicz transfer',
route: `/learn/${goal.targetSkills[0]}?mode=transfer`
};
case 'progression':
return {
action: 'progression',
description: `Naucz się: ${goal.targetSkills[0]}`,
buttonText: '🚀 Zacznij naukę',
route: `/learn/${goal.targetSkills[0]}`
};
case 'assessment':
return {
action: 'assessment',
description: 'Gotowy na test formalny',
buttonText: '✅ Zdaj egzamin',
route: `/exam`
};
case 'consolidation':
return {
action: 'consolidation',
description: 'Przegląd po przerwie',
buttonText: '📚 Przejrzyj materiał',
route: `/review`
};
case 'diagnostic':
return {
action: 'diagnostic',
description: 'Sprawdź swój poziom',
buttonText: '🔍 Test poziomujący',
route: `/diagnostic`
};
}
}
================================================
FILE: src/lib/learning/diagnostic.ts
================================================
/**
* Diagnostic Entry Test
* 12-question test to assess user level and recommend starting module
*/
import { SessionPlan, QuestionIntent } from './sessionPlan';
export interface DiagnosticQuestion {
skillId: string;
type: string;
difficulty: 'easy' | 'medium' | 'hard';
}
export interface DiagnosticResult {
totalQuestions: number;
correctAnswers: number;
accuracy: number;
skillScores: Record<string, number>; // skillId → % correct
recommendedModule: number;
recommendedLesson: number;
reasoning: string;
}
/**
* Diagnostic test structure (12 questions)
* Covers key skills from all 6 modules
*/
export const DIAGNOSTIC_TEST_QUESTIONS: DiagnosticQuestion[] = [
// Module 1 - Fundamentals
{
skillId: 'phonetics-digrams-trigrams',
type: 'multiple-choice',
difficulty: 'easy'
},
{
skillId: 'nouns-gender-regular',
type: 'multiple-choice',
difficulty: 'easy'
},
{
skillId: 'articles-definite',
type: 'gap-fill',
difficulty: 'medium'
},
// Module 2 - Structure
{
skillId: 'adjectives-agreement',
type: 'error-correction',
difficulty: 'medium'
},
// Module 3 - Verbs
{
skillId: 'present-regular-verbs',
type: 'gap-fill',
difficulty: 'medium'
},
{
skillId: 'passato-prossimo-auxiliary',
type: 'multiple-choice',
difficulty: 'hard'
},
{
skillId: 'imperfetto-basics',
type: 'gap-fill',
difficulty: 'hard'
},
// Module 4 - Functions
{
skillId: 'basic-prepositions',
type: 'gap-fill',
difficulty: 'medium'
},
{
skillId: 'pronouns-direct-forms',
type: 'multiple-choice',
difficulty: 'hard'
},
// Module 5 - Vocabulary
{
skillId: 'lex-food',
type: 'multiple-choice',
difficulty: 'easy'
},
{
skillId: 'comparatives',
type: 'transformation',
difficulty: 'hard'
},
// Module 6 - Pragmatics
{
skillId: 'pragmatics-greetings-formal-informal',
type: 'multiple-choice',
difficulty: 'medium'
}
];
/**
* Generate diagnostic test session plan
*/
export function createDiagnosticSessionPlan(): SessionPlan {
const structure: QuestionIntent[] = DIAGNOSTIC_TEST_QUESTIONS.map((q, idx) => ({
order: idx + 1,
intent: q.type === 'multiple-choice' ? 'recognition' : 'controlled-production',
skill: q.skillId,
exerciseType: q.type,
difficulty: q.difficulty,
reason: 'Diagnostic assessment'
}));
return {
sessionGoal: 'Comprehensive diagnostic test to assess Italian level',
sessionType: 'diagnostic',
targetLevel: 'A2',
skillFocus: {
primary: DIAGNOSTIC_TEST_QUESTIONS.map(q => q.skillId),
secondary: []
},
structure,
globalRules: {
progression: 'mixed',
allowHints: false,
allowPartialCredit: false,
showImmediateFeedback: false
}
};
}
/**
* Analyze diagnostic test results and recommend starting module
*/
export function analyzeDiagnosticResults(
answers: Record<string, string>,
quiz: any // Quiz with questions
): DiagnosticResult {
const totalQuestions = quiz.questions.length;
let correctAnswers = 0;
const skillScores: Record<string, { correct: number; total: number }> = {};
// Calculate scores per skill
quiz.questions.forEach((q: any, idx: number) => {
const userAnswer = answers[q.id] || answers[`q-${idx}`];
const isCorrect = userAnswer?.trim().toLowerCase() === q.correctAnswer.trim().toLowerCase();
if (isCorrect) correctAnswers++;
// Track by skill (assuming question has skillId metadata)
const skillId = DIAGNOSTIC_TEST_QUESTIONS[idx]?.skillId || 'unknown';
if (!skillScores[skillId]) {
skillScores[skillId] = { correct: 0, total: 0 };
}
skillScores[skillId].total++;
if (isCorrect) skillScores[skillId].correct++;
});
const accuracy = totalQuestions > 0 ? correctAnswers / totalQuestions : 0;
// Calculate skill percentages
const skillPercentages: Record<string, number> = {};
Object.entries(skillScores).forEach(([skillId, stats]) => {
skillPercentages[skillId] = stats.total > 0 ? (stats.correct / stats.total) * 100 : 0;
});
// Decision logic for starting module
const { module, lesson, reasoning } = determineStartingPoint(accuracy, skillPercentages);
return {
totalQuestions,
correctAnswers,
accuracy: accuracy * 100,
skillScores: skillPercentages,
recommendedModule: module,
recommendedLesson: lesson,
reasoning
};
}
/**
* Determine starting module and lesson based on diagnostic results
*/
function determineStartingPoint(
overallAccuracy: number,
skillScores: Record<string, number>
): { module: number; lesson: number; reasoning: string } {
// Helper to calculate average for skill category
const avgOf = (skillIds: string[]): number => {
const scores = skillIds
.map(id => skillScores[id])
.filter(s => s !== undefined);
return scores.length > 0
? scores.reduce((sum, s) => sum + s, 0) / scores.length
: 0;
};
// Module 1 skills (fundamentals)
const module1Avg = avgOf([
'phonetics-digrams-trigrams',
'nouns-gender-regular',
'articles-definite'
]);
// Module 3 skills (verbs)
const module3Avg = avgOf([
'present-regular-verbs',
'passato-prossimo-auxiliary',
'imperfetto-basics'
]);
// Module 4+ skills (functions + pragmatics)
const module4PlusAvg = avgOf([
'basic-prepositions',
'pronouns-direct-forms',
'comparatives',
'pragmatics-greetings-formal-informal'
]);
// Decision tree
if (module1Avg < 60) {
return {
module: 1,
lesson: 1,
reasoning: 'Fundamenty (alfabet, rodzaje, artykuły) wymagają utrwalenia. Zaczynamy od początku.'
};
}
if (module1Avg >= 75 && module3Avg < 50) {
return {
module: 3,
lesson: 11,
reasoning: 'Podstawy OK, ale czasowniki wymagają pracy. Zaczynamy od Module 3.'
};
}
if (module3Avg >= 60 && module4PlusAvg < 60) {
return {
module: 4,
lesson: 16,
reasoning: 'Gramatyka solidna, pracujmy nad funkcjami językowymi i komunikacją.'
};
}
if (overallAccuracy >= 0.75) {
return {
module: 5,
lesson: 21,
reasoning: 'Świetnie! Przejdźmy do leksyki tematycznej i pragmatyki.'
};
}
// Default: mixed performance → start at Module 2
return {
module: 2,
lesson: 6,
reasoning: 'Mieszane wyniki. Zaczynamy od struktury zdania (Module 2).'
};
}
/**
* Generate user-friendly diagnostic report
*/
export function generateDiagnosticReport(result: DiagnosticResult): {
level: string;
strengths: string[];
weaknesses: string[];
nextSteps: string;
} {
const { accuracy, skillScores } = result;
// Determine CEFR level
let level = 'A1';
if (accuracy >= 85) level = 'B1';
else if (accuracy >= 70) level = 'A2+';
else if (accuracy >= 50) level = 'A2';
// Identify strengths and weaknesses
const strengths: string[] = [];
const weaknesses: string[] = [];
Object.entries(skillScores).forEach(([skillId, score]) => {
if (score >= 75) {
strengths.push(skillId);
} else if (score < 50) {
weaknesses.push(skillId);
}
});
// Recommendations
const nextSteps = result.reasoning +
(weaknesses.length > 0
? ` Skupimy się na: ${weaknesses.slice(0, 3).join(', ')}.`
: ' Kontynuujmy progresję!');
return {
level,
strengths,
weaknesses,
nextSteps
};
}
================================================
FILE: src/lib/learning/metrics.ts
================================================
import { useMemo } from 'react';
import { useUserStore, QuizResult, TopicStats } from '../store/useUserStore';
/**
* Core learning metrics interface
*/
export interface LearningMetrics {
transferAccuracy: number; // 0-1: Understanding of rules (last 2 questions)
errorRecovery: number; // 0-1: Learning from mistakes
timeTrend: 'improving' | 'stable' | 'worsening'; // Speed trend
avgTimePerQuestion: number; // Milliseconds
weakAreas: WeakArea[];
lastScoreRatio: number;
}
export interface WeakArea {
label: string;
count: number;
topicId?: string;
}
export interface NextLessonSuggestion {
type: 'repeat-easier' | 'fix-weakness' | 'consolidation' | 'next-topic';
topicId?: string;
difficulty?: string;
reason: string;
actionText: string;
}
/**
* Calculate transfer accuracy (understanding vs memorization)
* Based on accuracy of last 2 questions in recent quizzes
*/
export function calculateTransferAccuracy(history: QuizResult[]): number {
if (history.length === 0) return 0.6; // Default neutral
const recentQuizzes = history.slice(0, 5);
let totalLast2 = 0;
let correctLast2 = 0;
recentQuizzes.forEach(quiz => {
const questions = quiz.questions;
if (questions.length < 2) return;
// Last 2 questions (hardest)
const last2 = questions.slice(-2);
last2.forEach((q, localIdx) => {
const globalIdx = questions.length - 2 + localIdx;
const userAnswer = quiz.userAnswers[q.id] || quiz.userAnswers[`q-${globalIdx}`];
if (userAnswer) {
totalLast2++;
if (userAnswer.trim().toLowerCase() === q.correctAnswer.trim().toLowerCase()) {
correctLast2++;
}
}
});
});
return totalLast2 > 0 ? correctLast2 / totalLast2 : 0.6;
}
/**
* Calculate error recovery rate
* How often user corrects mistakes on retry
*/
export function calculateErrorRecovery(history: QuizResult[]): number {
if (history.length < 2) return 0.5; // Need at least 2 quizzes
// Look for patterns where same question was answered wrong then right
const mistakes: Record<string, boolean> = {};
const corrections: Record<string, boolean> = {};
history.forEach(quiz => {
quiz.questions.forEach((q, idx) => {
const userAnswer = quiz.userAnswers[q.id] || quiz.userAnswers[`q-${idx}`];
const isCorrect = userAnswer?.trim().toLowerCase() === q.correctAnswer.trim().toLowerCase();
const questionKey = q.question.toLowerCase();
if (!isCorrect && userAnswer) {
mistakes[questionKey] = true;
} else if (isCorrect && mistakes[questionKey]) {
corrections[questionKey] = true;
}
});
});
const totalMistakes = Object.keys(mistakes).length;
const totalCorrected = Object.keys(corrections).length;
return totalMistakes > 0 ? totalCorrected / totalMistakes : 0.5;
}
/**
* Calculate time trend (improving = faster, worsening = slower)
*/
export function calculateTimeTrend(history: QuizResult[]): {
trend: 'improving' | 'stable' | 'worsening';
avgTime: number;
} {
if (history.length < 3) {
return { trend: 'stable', avgTime: 15000 };
}
// Assume each quiz took similar time per question
// In real app, track actual time
const recent = history.slice(0, 3);
const older = history.slice(3, 6);
const recentAvg = recent.reduce((sum, q) => sum + q.total, 0) / recent.length;
const olderAvg = older.length > 0
? older.reduce((sum, q) => sum + q.total, 0) / older.length
: recentAvg;
// For now, use question count as proxy for time
// TODO: Track actual time in QuizResult
const avgTime = 15000; // Default 15s per question
if (recentAvg < olderAvg * 0.9) return { trend: 'improving', avgTime };
if (recentAvg > olderAvg * 1.1) return { trend: 'worsening', avgTime };
return { trend: 'stable', avgTime };
}
/**
* Extract weak areas from mistakes
*/
export function extractWeakAreas(): WeakArea[] {
const user = useUserStore.getState();
const mistakes = user.getMistakes();
const areaCount: Record<string, { count: number; topicId?: string }> = {};
mistakes.forEach(mistake => {
// Parse: "[Topic] Question: "..." Correct: "...""
const topicMatch = mistake.match(/\[(.*?)\]/);
if (topicMatch) {
const topic = topicMatch[1];
if (!areaCount[topic]) {
areaCount[topic] = { count: 0, topicId: topic };
}
areaCount[topic].count++;
}
});
return Object.entries(areaCount)
.map(([label, data]) => ({
label,
count: data.count,
topicId: data.topicId
}))
.sort((a, b) => b.count - a.count)
.slice(0, 3);
}
/**
* React hook for getting comprehensive learning metrics
* Memoized to avoid recalculating on every render
*/
export function useLearningMetrics(): LearningMetrics {
const history = useUserStore(state => state.history);
return useMemo(() => {
const transferAccuracy = calculateTransferAccuracy(history);
const errorRecovery = calculateErrorRecovery(history);
const { trend: timeTrend, avgTime: avgTimePerQuestion } = calculateTimeTrend(history);
const weakAreas = extractWeakAreas();
const lastScoreRatio = history.length > 0
? history[0].score / history[0].total
: 0.5;
return {
transferAccuracy,
errorRecovery,
timeTrend,
avgTimePerQuestion,
weakAreas,
lastScoreRatio
};
}, [history]); // Recalculate only when history changes
}
// Keep old function for backward compatibility but mark as deprecated
/** @deprecated Use useLearningMetrics() hook instead */
export function getLearningMetrics(): LearningMetrics {
const user = useUserStore.getState();
const history = user.history;
const transferAccuracy = calculateTransferAccuracy(history);
const errorRecovery = calculateErrorRecovery(history);
const { trend: timeTrend, avgTime: avgTimePerQuestion } = calculateTimeTrend(history);
const weakAreas = extractWeakAreas();
const lastScoreRatio = history.length > 0
? history[0].score / history[0].total
: 0.5;
return {
transferAccuracy,
errorRecovery,
timeTrend,
avgTimePerQuestion,
weakAreas,
lastScoreRatio
};
}
/**
* Next Best Lesson Algorithm
* Based on didactic rules + user metrics
*/
export function decideNextLesson(metrics: LearningMetrics): NextLessonSuggestion {
const { lastScoreRatio, errorRecovery, transferAccuracy, weakAreas } = metrics;
// 1. Student is struggling → simplify
if (lastScoreRatio < 0.6) {
return {
type: 'repeat-easier',
difficulty: 'A1',
reason: 'Za dużo błędów wróćmy do podstaw i utrwalmy fundament',
actionText: 'Powtórz temat prościej 📚'
};
}
// 2. Understanding rules but making same mistakes
if (errorRecovery < 0.75 && weakAreas.length > 0) {
return {
type: 'fix-weakness',
topicId: weakAreas[0].topicId,
reason: `Naprawmy konkretny problem: ${weakAreas[0].label}`,
actionText: `Napraw: ${weakAreas[0].label} 🛠️`
};
}
// 3. Mastered rules → move forward
if (transferAccuracy > 0.8 && lastScoreRatio > 0.85) {
return {
type: 'next-topic',
reason: 'Świetnie! Gotowy na kolejny temat',
actionText: 'Przejdź dalej 🚀'
};
}
// 4. Default → consolidation
return {
type: 'consolidation',
reason: 'Utrwalmy wiedzę zanim pójdziemy dalej',
actionText: 'Utrwal temat 💪'
};
}
================================================
FILE: src/lib/learning/moduleProgress.ts
================================================
/**
* Module Progress Tracking
* Track completion and mastery per module
*/
import { Module, CURRICULUM_MODULES, getLessonsByModule } from './curriculum';
import { SkillState } from './skillState';
export interface ModuleProgress {
moduleId: number;
status: 'locked' | 'in-progress' | 'completed';
completionPercent: number;
lessonsCompleted: number;
totalLessons: number;
avgMastery: number; // Average mastery of module skills
weakSkills: string[]; // Skills < 60% mastery
strongSkills: string[]; // Skills >= 80% mastery
transferIndex: number; // % of transfer sessions successful
recommendation: string;
}
export interface ModuleDashboardData {
modules: ModuleProgress[];
overallProgress: number;
currentModule: number;
estimatedTimeRemaining: number; // Minutes
}
/**
* Calculate progress for a single module
*/
export function calculateModuleProgress(
module: Module,
completedLessons: number[],
skillStates: Record<string, SkillState>
): ModuleProgress {
const moduleLessons = getLessonsByModule(module.id);
const completed = moduleLessons.filter(l => completedLessons.includes(l.id));
const completionPercent = (completed.length / moduleLessons.length) * 100;
// Collect all skills from module lessons
const allSkillIds = new Set<string>();
moduleLessons.forEach(lesson => {
lesson.primarySkills.forEach(s => allSkillIds.add(s));
lesson.secondarySkills.forEach(s => allSkillIds.add(s));
});
// Calculate average mastery
const skillMasteries = Array.from(allSkillIds)
.map(skillId => skillStates[skillId]?.mastery || 0)
.filter(m => m > 0);
const avgMastery = skillMasteries.length > 0
? skillMasteries.reduce((sum, m) => sum + m, 0) / skillMasteries.length
: 0;
// Identify weak and strong skills
const weakSkills = Array.from(allSkillIds)
.filter(skillId => {
const state = skillStates[skillId];
return state && state.mastery > 0 && state.mastery < 60;
});
const strongSkills = Array.from(allSkillIds)
.filter(skillId => {
const state = skillStates[skillId];
return state && state.mastery >= 80;
});
// Calculate transfer index
const skillsWithTransfer = Array.from(allSkillIds)
.map(skillId => skillStates[skillId])
.filter(s => s && s.practiceCount > 0);
const transferIndex = skillsWithTransfer.length > 0
? skillsWithTransfer.reduce((sum, s) => sum + s.transferScore, 0) / skillsWithTransfer.length
: 0;
// Determine status
let status: 'locked' | 'in-progress' | 'completed' = 'locked';
if (module.id === 1) {
// First module always unlocked
status = completed.length === moduleLessons.length ? 'completed' : 'in-progress';
} else {
// Check if previous module is complete
const prevModule = CURRICULUM_MODULES.find(m => m.id === module.id - 1);
if (prevModule) {
const prevLessons = getLessonsByModule(prevModule.id);
const prevCompleted = prevLessons.filter(l => completedLessons.includes(l.id));
if (prevCompleted.length === prevLessons.length) {
// Previous module complete - this one unlocked
status = completed.length === moduleLessons.length ? 'completed' : 'in-progress';
}
}
}
// Generate recommendation
const recommendation = generateModuleRecommendation(
module,
completionPercent,
avgMastery,
weakSkills,
status
);
return {
moduleId: module.id,
status,
completionPercent: Math.round(completionPercent),
lessonsCompleted: completed.length,
totalLessons: moduleLessons.length,
avgMastery: Math.round(avgMastery),
weakSkills,
strongSkills,
transferIndex: Math.round(transferIndex * 100),
recommendation
};
}
/**
* Generate recommendation text for module
*/
function generateModuleRecommendation(
module: Module,
completion: number,
avgMastery: number,
weakSkills: string[],
status: 'locked' | 'in-progress' | 'completed'
): string {
if (status === 'locked') {
return `Moduł zablokowany. Ukończ najpierw Module ${module.id - 1}.`;
}
if (status === 'completed' && avgMastery >= 80) {
return `✅ Moduł ukończony z świetnym wynikiem!`;
}
if (status === 'completed' && avgMastery < 80) {
return `⚠️ Moduł ukończony, ale zalecane powtórzenie: ${weakSkills.slice(0, 2).join(', ')}`;
}
// In progress
if (weakSkills.length > 0) {
return `📚 Kontynuuj naukę. Skup się na: ${weakSkills.slice(0, 2).join(', ')}`;
}
return `🚀 Świetnie! Kontynuuj kolejne lekcje.`;
}
/**
* Get complete dashboard data
*/
export function getModuleDashboard(
completedLessons: number[],
skillStates: Record<string, SkillState>
): ModuleDashboardData {
const moduleProgresses = CURRICULUM_MODULES.map(module =>
calculateModuleProgress(module, completedLessons, skillStates)
);
// Overall progress
const totalLessons = moduleProgresses.reduce((sum, m) => sum + m.totalLessons, 0);
const totalCompleted = moduleProgresses.reduce((sum, m) => sum + m.lessonsCompleted, 0);
const overallProgress = totalLessons > 0 ? (totalCompleted / totalLessons) * 100 : 0;
// Current module (first in-progress or incomplete)
const currentModule = moduleProgresses.find(
m => m.status === 'in-progress' && m.completionPercent < 100
)?.moduleId || 1;
// Estimate remaining time
const remainingLessons = totalLessons - totalCompleted;
const avgMinutesPerLesson = 40; // From lesson data
const estimatedTimeRemaining = remainingLessons * avgMinutesPerLesson;
return {
modules: moduleProgresses,
overallProgress: Math.round(overallProgress),
currentModule,
estimatedTimeRemaining
};
}
/**
* Get color coding for UI
*/
export function getProgressColor(mastery: number): string {
if (mastery >= 80) return 'green';
if (mastery >= 60) return 'yellow';
return 'red';
}
/**
* Get status icon for UI
*/
export function getStatusIcon(status: 'locked' | 'in-progress' | 'completed'): string {
switch (status) {
case 'locked': return '🔒';
case 'in-progress': return '📚';
case 'completed': return '✅';
}
}
/**
* Check if module checkpoint is passed
*/
export function isCheckpointPassed(
module: Module,
skillStates: Record<string, SkillState>
): boolean {
const moduleLessons = getLessonsByModule(module.id);
const allSkillIds = new Set<string>();
moduleLessons.forEach(lesson => {
lesson.primarySkills.forEach(s => allSkillIds.add(s));
});
// All primary skills must have >= 70% mastery
return Array.from(allSkillIds).every(skillId => {
const state = skillStates[skillId];
return state && state.mastery >= 70;
});
}
================================================
FILE: src/lib/learning/planner.ts
================================================
'use server';
import { SessionPlan, UserProfile, SessionContext } from './sessionPlan';
import fs from 'fs/promises';
import path from 'path';
// Schema for validating session plan output
import { z } from 'zod';
const QuestionIntentSchema = z.object({
order: z.number(),
intent: z.enum(['recognition', 'controlled-production', 'free-production', 'error-detection', 'transfer']),
skill: z.string(),
exerciseType: z.string(),
difficulty: z.enum(['easy', 'medium', 'hard']),
reason: z.string()
});
const SessionPlanSchema = z.object({
sessionGoal: z.string(),
sessionType: z.enum(['practice', 'exam', 'diagnostic']),
targetLevel: z.enum(['A1', 'A2', 'B1', 'B2']),
skillFocus: z.object({
primary: z.array(z.string()),
secondary: z.array(z.string())
}),
structure: z.array(QuestionIntentSchema),
globalRules: z.object({
progression: z.enum(['easy-to-hard', 'mixed', 'random']),
allowHints: z.boolean(),
allowPartialCredit: z.boolean(),
showImmediateFeedback: z.boolean()
})
});
/**
* Load Learning Planner system prompt
*/
async function getLearningPlannerPrompt(): Promise<string> {
// In production, this would be in a database or CMS
// For now, we'll construct it inline
return `
ROLE:
You are an EXPERT DIDACTIC PLANNER for an AI-powered Italian language tutor serving Polish students.
You do NOT generate questions. You DESIGN learning or assessment sessions.
OUTPUT FORMAT (STRICT JSON):
{
"sessionGoal": "string",
"sessionType": "practice | exam | diagnostic",
"targetLevel": "A1 | A2 | B1 | B2",
"skillFocus": {
"primary": ["skill_id"],
"secondary": ["skill_id"]
},
"structure": [
{
"order": 1,
"intent": "recognition | controlled-production | free-production | error-detection | transfer",
"skill": "skill_id",
"exerciseType": "multiple-choice | gap-fill | error-correction | transformation",
"difficulty": "easy | medium | hard",
"reason": "SHORT justification"
}
],
"globalRules": {
"progression": "easy-to-hard | mixed | random",
"allowHints": boolean,
"allowPartialCredit": boolean,
"showImmediateFeedback": boolean
}
}
PLANNING RULES:
1. Every question MUST have clear didactic intent
2. Practice mode: easy → hard, focus on weaknesses
3. Exam mode: mixed difficulty, no scaffolding
4. One skill per question
5. Respect cognitive load progression
OUTPUT ONLY JSON. NO MARKDOWN.
`;
}
/**
* Create session plan using Learning Planner Agent
*/
export async function planLearningSession(
userProfile: UserProfile,
sessionContext: SessionContext
): Promise<{ success: boolean; plan?: SessionPlan; error?: string }> {
try {
const systemPrompt = await getLearningPlannerPrompt();
const userMessage = JSON.stringify({
userProfile,
sessionContext
}, null, 2);
// Call Ollama API
const response = await fetch('http://localhost:11434/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'gemma2:2b',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userMessage }
],
stream: false,
format: 'json'
})
});
if (!response.ok) {
return {
success: false,
error: `Ollama API error: ${response.status}`
};
}
const data = await response.json();
const content = data.message?.content;
if (!content) {
return {
success: false,
error: 'No content in response'
};
}
// Parse and validate JSON
const parsed = JSON.parse(content);
const validated = SessionPlanSchema.parse(parsed);
console.log('[Learning Planner] Created session plan:', validated.sessionGoal);
return {
success: true,
plan: validated
};
} catch (error) {
console.error('[Learning Planner] Error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
};
}
}
/**
* Build user profile from current user state
* (to be called from client-side with user data)
*/
export function buildUserProfile(userData: {
history: any[];
topicStats: Record<string, any>;
metrics?: {
transferAccuracy: number;
errorRecovery: number;
};
}): UserProfile {
// Default profile
const profile: UserProfile = {
cefr: 'A2', // Default
avgAccuracy: 0.5,
errorRecovery: 0.5,
transferAccuracy: 0.5,
recentMistakes: [],
strengths: [],
weaknesses: []
};
// Calculate from history
if (userData.history.length > 0) {
const totalScore = userData.history.reduce((sum, h) => sum + h.score, 0);
const totalQuestions = userData.history.reduce((sum, h) => sum + h.total, 0);
profile.avgAccuracy = totalQuestions > 0 ? totalScore / totalQuestions : 0.5;
}
// Use metrics if provided
if (userData.metrics) {
profile.transferAccuracy = userData.metrics.transferAccuracy;
profile.errorRecovery = userData.metrics.errorRecovery;
}
// Extract weaknesses from topic stats
Object.entries(userData.topicStats).forEach(([topicId, stats]: [string, any]) => {
if (stats.mastery < 60) {
profile.weaknesses.push(topicId);
} else if (stats.mastery > 80) {
profile.strengths.push(topicId);
}
});
return profile;
}
================================================
FILE: src/lib/learning/sessionPlan.ts
================================================
import { Intent } from './skillTree';
export type SessionType = 'practice' | 'exam' | 'diagnostic';
export type Progression = 'easy-to-hard' | 'mixed' | 'random';
export type Difficulty = 'easy' | 'medium' | 'hard';
export interface QuestionIntent {
order: number;
intent: Intent;
skill: string; // Skill ID from skill tree
exerciseType: string; // QuestionType from schemas
difficulty: Difficulty;
reason: string; // Why this question exists (didactic justification)
}
export interface SessionPlan {
sessionGoal: string;
sessionType: SessionType;
targetLevel: 'A1' | 'A2' | 'B1' | 'B2';
skillFocus: {
primary: string[]; // Primary skill IDs to practice
secondary: string[]; // Secondary/supporting skills
};
structure: QuestionIntent[];
globalRules: {
progression: Progression;
allowHints: boolean;
allowPartialCredit: boolean;
showImmediateFeedback: boolean;
};
}
export interface UserProfile {
cefr: 'A1' | 'A2' | 'B1' | 'B2';
avgAccuracy: number; // 0-1
errorRecovery: number; // 0-1
transferAccuracy: number; // 0-1
recentMistakes: string[]; // Skill IDs
strengths: string[]; // Skill IDs
weaknesses: string[]; // Skill IDs
}
export interface SessionContext {
mode: SessionType;
topicId?: string; // If topic-focused
allowedExerciseTypes: string[];
requestedAmount: number;
focusOnWeaknesses?: boolean;
}
/**
* Default session plan for backward compatibility
* When Learning Planner is not used
*/
export function createDefaultSessionPlan(
topicId: string,
level: string,
amount: number,
mode: SessionType = 'practice'
): SessionPlan {
return {
sessionGoal: `Practice ${topicId} at ${level} level`,
sessionType: mode,
targetLevel: level as any,
skillFocus: {
primary: [topicId],
secondary: []
},
structure: Array.from({ length: amount }, (_, i) => ({
order: i + 1,
intent: i < amount / 2 ? 'recognition' : 'controlled-production',
skill: topicId,
exerciseType: i < amount / 2 ? 'multiple-choice' : 'gap-fill',
difficulty: i === 0 ? 'easy' : i < amount - 1 ? 'medium' : 'hard',
reason: `Question ${i + 1} of ${amount}`
})),
globalRules: {
progression: 'easy-to-hard',
allowHints: mode === 'practice',
allowPartialCredit: mode === 'practice',
showImmediateFeedback: mode === 'practice'
}
};
}
================================================
FILE: src/lib/learning/sessionTemplates.ts
================================================
/**
* Session Plan Templates
* Auto-generate session plans based on curriculum goals
*/
import { SessionPlan, QuestionIntent } from './sessionPlan';
import { LessonMaterial } from './curriculum';
import { CurriculumGoalType } from './curriculumBuilder';
export type SessionTemplate = 'standard-practice' | 'intensive-drill' | 'transfer-focus' | 'exam' | 'consolidation';
/**
* Generate session plan for a lesson
*/
export function generateLessonSessionPlan(
lesson: LessonMaterial,
template: SessionTemplate = 'standard-practice'
): SessionPlan {
const primarySkill = lesson.primarySkills[0];
switch (template) {
case 'standard-practice':
return createStandardPracticeTemplate(lesson, primarySkill);
case 'intensive-drill':
return createIntensiveDrillTemplate(lesson, primarySkill);
case 'transfer-focus':
return createTransferTemplate(lesson, primarySkill);
case 'exam':
return createExamTemplate(lesson, primarySkill);
case 'consolidation':
return createConsolidationTemplate(lesson, primarySkill);
default:
return createStandardPracticeTemplate(lesson, primarySkill);
}
}
/**
* TEMPLATE 1: Standard Practice (default)
* Progressive: recognition → production → error-detection
*/
function createStandardPracticeTemplate(
lesson: LessonMaterial,
primarySkill: string
): SessionPlan {
const structure: QuestionIntent[] = [
{
order: 1,
intent: 'recognition',
skill: primarySkill,
exerciseType: 'multiple-choice',
difficulty: 'easy',
reason: 'Build confidence with recognition'
},
{
order: 2,
intent: 'recognition',
skill: primarySkill,
exerciseType: 'multiple-choice',
difficulty: 'medium',
reason: 'Reinforce recognition in varied context'
},
{
order: 3,
intent: 'controlled-production',
skill: primarySkill,
exerciseType: 'gap-fill',
difficulty: 'medium',
reason: 'Practice active recall with support'
},
{
order: 4,
intent: 'controlled-production',
skill: primarySkill,
exerciseType: 'gap-fill',
difficulty: 'medium',
reason: 'Strengthen production ability'
},
{
order: 5,
intent: 'error-detection',
skill: primarySkill,
exerciseType: 'error-correction',
difficulty: 'hard',
reason: 'Develop self-correction skill'
}
];
return {
sessionGoal: `Learn ${lesson.titlePL}`,
sessionType: 'practice',
targetLevel: lesson.cefr,
skillFocus: {
primary: lesson.primarySkills,
secondary: lesson.secondarySkills
},
structure,
globalRules: {
progression: 'easy-to-hard',
allowHints: true,
allowPartialCredit: true,
showImmediateFeedback: true
}
};
}
/**
* TEMPLATE 2: Intensive Drill (remediation)
* More questions, focused repetition
*/
function createIntensiveDrillTemplate(
lesson: LessonMaterial,
primarySkill: string
): SessionPlan {
const structure: QuestionIntent[] = [
{
order: 1,
intent: 'recognition',
skill: primarySkill,
exerciseType: 'multiple-choice',
difficulty: 'easy',
reason: 'Warm-up recognition'
},
{
order: 2,
intent: 'controlled-production',
skill: primarySkill,
exerciseType: 'gap-fill',
difficulty: 'easy',
reason: 'Controlled production - low pressure'
},
{
order: 3,
intent: 'controlled-production',
skill: primarySkill,
exerciseType: 'gap-fill',
difficulty: 'medium',
reason: 'Increased difficulty production'
},
{
order: 4,
intent: 'error-detection',
skill: primarySkill,
exerciseType: 'error-correction',
difficulty: 'medium',
reason: 'Identify common errors'
},
{
order: 5,
intent: 'controlled-production',
skill: primarySkill,
exerciseType: 'transformation',
difficulty: 'medium',
reason: 'Apply rule transformation'
},
{
order: 6,
intent: 'error-detection',
skill: primarySkill,
exerciseType: 'error-correction',
difficulty: 'hard',
reason: 'Complex error detection'
},
{
order: 7,
intent: 'controlled-production',
skill: primarySkill,
exerciseType: 'gap-fill',
difficulty: 'hard',
reason: 'Final mastery check'
}
];
return {
sessionGoal: `Intensive practice: ${lesson.titlePL}`,
sessionType: 'practice',
targetLevel: lesson.cefr,
skillFocus: {
primary: lesson.primarySkills,
secondary: []
},
structure,
globalRules: {
progression: 'easy-to-hard',
allowHints: true,
allowPartialCredit: true,
showImmediateFeedback: true
}
};
}
/**
* TEMPLATE 3: Transfer Focus
* Apply rule in new contexts
*/
function createTransferTemplate(
lesson: LessonMaterial,
primarySkill: string
): SessionPlan {
const structure: QuestionIntent[] = [
{
order: 1,
intent: 'recognition',
skill: primarySkill,
exerciseType: 'multiple-choice',
difficulty: 'medium',
reason: 'Review base skill'
},
{
order: 2,
intent: 'transfer',
skill: primarySkill,
exerciseType: 'gap-fill',
difficulty: 'medium',
reason: 'Apply in new context'
},
{
order: 3,
intent: 'transfer',
skill: primarySkill,
exerciseType: 'transformation',
difficulty: 'hard',
reason: 'Complex transfer task'
},
{
order: 4,
intent: 'transfer',
skill: primarySkill,
exerciseType: 'error-correction',
difficulty: 'hard',
reason: 'Error detection in new context'
},
{
order: 5,
intent: 'free-production',
skill: primarySkill,
exerciseType: 'scramble',
difficulty: 'hard',
reason: 'Independent production'
}
];
return {
sessionGoal: `Transfer ${lesson.titlePL} to new contexts`,
sessionType: 'practice',
targetLevel: lesson.cefr,
skillFocus: {
primary: lesson.primarySkills,
secondary: lesson.secondarySkills
},
structure,
globalRules: {
progression: 'mixed',
allowHints: false,
allowPartialCredit: true,
showImmediateFeedback: true
}
};
}
/**
* TEMPLATE 4: Exam (assessment)
* Mixed difficulty, no hints, no immediate feedback
*/
function createExamTemplate(
lesson: LessonMaterial,
primarySkill: string
): SessionPlan {
const structure: QuestionIntent[] = [
{
order: 1,
intent: 'recognition',
skill: primarySkill,
exerciseType: 'multiple-choice',
difficulty: 'medium',
reason: 'Assess recognition'
},
{
order: 2,
intent: 'controlled-production',
skill: primarySkill,
exerciseType: 'gap-fill',
difficulty: 'hard',
reason: 'Assess production'
},
{
order: 3,
intent: 'error-detection',
skill: primarySkill,
exerciseType: 'error-correction',
difficulty: 'medium',
reason: 'Assess error detection'
},
{
order: 4,
intent: 'controlled-production',
skill: primarySkill,
exerciseType: 'transformation',
difficulty: 'hard',
reason: 'Assess transformation'
},
{
order: 5,
intent: 'transfer',
skill: primarySkill,
exerciseType: 'gap-fill',
difficulty: 'hard',
reason: 'Assess transfer ability'
}
];
return {
sessionGoal: `Formal assessment: ${lesson.titlePL}`,
sessionType: 'exam',
targetLevel: lesson.cefr,
skillFocus: {
primary: lesson.primarySkills,
secondary: lesson.secondarySkills
},
structure,
globalRules: {
progression: 'mixed',
allowHints: false,
allowPartialCredit: false,
showImmediateFeedback: false
}
};
}
/**
* TEMPLATE 5: Consolidation (review after break)
* Mixed skills, moderate difficulty
*/
function createConsolidationTemplate(
lesson: LessonMaterial,
primarySkill: string
): SessionPlan {
const allSkills = [...lesson.primarySkills, ...lesson.secondarySkills];
const structure: QuestionIntent[] = allSkills.slice(0, 6).map((skill, idx) => ({
order: idx + 1,
intent: idx % 2 === 0 ? 'recognition' : 'controlled-production',
skill,
exerciseType: idx % 2 === 0 ? 'multiple-choice' : 'gap-fill',
difficulty: 'medium',
reason: `Review ${skill}`
}));
return {
sessionGoal: `Review: ${lesson.titlePL}`,
sessionType: 'practice',
targetLevel: lesson.cefr,
skillFocus: {
primary: lesson.primarySkills,
secondary: lesson.secondarySkills
},
structure,
globalRules: {
progression: 'mixed',
allowHints: true,
allowPartialCredit: true,
showImmediateFeedback: true
}
};
}
/**
* Select appropriate template based on curriculum goal
*/
export function selectTemplateForGoal(goalType: CurriculumGoalType): SessionTemplate {
switch (goalType) {
case 'remediation':
return 'intensive-drill';
case 'transfer':
return 'transfer-focus';
case 'assessment':
return 'exam';
case 'consolidation':
return 'consolidation';
case 'progression':
default:
return 'standard-practice';
}
}
================================================
FILE: src/lib/learning/skillState.ts
================================================
/**
* Extended Skill State for Curriculum Building
* Tracks detailed metrics per skill for adaptive learning
*/
export type SkillStatus = 'blocked' | 'learning' | 'unstable' | 'mastered';
export interface SkillState {
skillId: string;
mastery: number; // 0-100: overall correctness
confidence: number; // 0-100: consistency (low variance)
errorPersistence: number; // 0-1: how often same error repeats
transferScore: number; // 0-1: success in transfer contexts
lastPracticed: string; // ISO date
practiceCount: number; // Total practice sessions
prerequisitesMet: boolean;
status: SkillStatus;
}
export interface SkillHistory {
skillId: string;
date: string;
correct: number;
total: number;
wasTransfer: boolean;
}
/**
* Calculate skill status based on metrics
*/
export function calculateSkillStatus(state: SkillState): SkillStatus {
if (!state.prerequisitesMet) return 'blocked';
if (state.mastery >= 85 && state.confidence >= 75) return 'mastered';
if (state.mastery >= 60 && state.confidence < 50) return 'unstable';
return 'learning';
}
/**
* Calculate confidence from variance in recent attempts
*/
export function calculateConfidence(recentScores: number[]): number {
if (recentScores.length < 3) return 50; // Not enough data
const mean = recentScores.reduce((a, b) => a + b, 0) / recentScores.length;
const variance = recentScores.reduce((sum, score) => sum + Math.pow(score - mean, 2), 0) / recentScores.length;
const stdDev = Math.sqrt(variance);
// Lower variance = higher confidence
// Map stdDev (0-50) to confidence (100-0)
const confidence = Math.max(0, Math.min(100, 100 - (stdDev * 2)));
return confidence;
}
/**
* Calculate error persistence (how often same mistakes repeat)
*/
export function calculateErrorPersistence(
mistakes: string[], // Array of mistake descriptions
windowSize: number = 5
): number {
if (mistakes.length < 2) return 0;
const recent = mistakes.slice(-windowSize);
const uniqueErrors = new Set(recent);
// High repetition = high persistence
const persistence = 1 - (uniqueErrors.size / recent.length);
return persistence;
}
/**
* Calculate transfer score (success in new contexts)
*/
export function calculateTransferScore(history: SkillHistory[]): number {
const transferAttempts = history.filter(h => h.wasTransfer);
if (transferAttempts.length === 0) return 0.5; // Unknown
const totalTransfer = transferAttempts.reduce((sum, h) => sum + h.total, 0);
const correctTransfer = transferAttempts.reduce((sum, h) => sum + h.correct, 0);
return totalTransfer > 0 ? correctTransfer / totalTransfer : 0.5;
}
/**
* Update skill state after a practice session
*/
export function updateSkillState(
current: SkillState,
newAttempt: {
correct: number;
total: number;
wasTransfer?: boolean;
},
recentScores: number[]
): SkillState {
const newMastery = ((current.mastery * current.practiceCount) + (newAttempt.correct / newAttempt.total * 100)) / (current.practiceCount + 1);
const updated: SkillState = {
...current,
mastery: Math.round(newMastery),
confidence: calculateConfidence([...recentScores, newAttempt.correct / newAttempt.total * 100]),
lastPracticed: new Date().toISOString(),
practiceCount: current.practiceCount + 1
};
updated.status = calculateSkillStatus(updated);
return updated;
}
/**
* Initialize skill state for a new skill
*/
export function initSkillState(skillId: string, prerequisitesMet: boolean = true): SkillState {
return {
skillId,
mastery: 0,
confidence: 50,
errorPersistence: 0,
transferScore: 0.5,
lastPracticed: new Date().toISOString(),
practiceCount: 0,
prerequisitesMet,
status: prerequisitesMet ? 'learning' : 'blocked'
};
}
/**
* Check if skill needs remediation (intensive practice)
*/
export function needsRemediation(state: SkillState): boolean {
return (
state.mastery < 50 &&
state.errorPersistence > 0.6 &&
state.practiceCount >= 3
);
}
/**
* Check if skill is in plateau (high mastery, low transfer)
*/
export function isInPlateau(state: SkillState): boolean {
return (
state.mastery >= 70 &&
state.transferScore < 0.4 &&
state.practiceCount >= 2
);
}
/**
* Check if skill is ready for exam
*/
export function isExamReady(state: SkillState): boolean {
return (
state.mastery >= 80 &&
state.confidence >= 75 &&
state.transferScore >= 0.6
);
}
/**
* Calculate days since last practice
*/
export function daysSinceLastPractice(state: SkillState): number {
const lastDate = new Date(state.lastPracticed);
const now = new Date();
const diffMs = now.getTime() - lastDate.getTime();
return Math.floor(diffMs / (1000 * 60 * 60 * 24));
}
================================================
FILE: src/lib/learning/skillTree.ts
================================================
/**
* Skill Tree for Italian Language Learning (A1-A2)
* Based on CEFR standards for Polish learners
*/
export type Intent =
| 'recognition' // Can identify correct form
| 'controlled-production' // Can produce with support
| 'free-production' // Can produce independently
| 'error-detection' // Can find mistakes
| 'transfer'; // Can apply rule in new context
export interface Skill {
id: string;
category: 'grammar' | 'vocabulary' | 'pragmatics';
cefr: 'A1' | 'A2' | 'B1' | 'B2';
name: string;
description: string;
prerequisites: string[];
transferVariants: string[];
commonErrors: string[];
}
export interface IntentDefinition {
id: Intent;
description: string;
cognitiveLoad: 'low' | 'medium' | 'high';
allowedExerciseTypes: string[];
requiresHints: boolean;
allowsPartialCredit: boolean;
}
// Intent Definitions
export const INTENT_DEFINITIONS: Record<Intent, IntentDefinition> = {
'recognition': {
id: 'recognition',
description: 'Student can identify correct form among options',
cognitiveLoad: 'low',
allowedExerciseTypes: ['multiple-choice'],
requiresHints: false,
allowsPartialCredit: false
},
'controlled-production': {
id: 'controlled-production',
description: 'Student can produce correct form with support/context',
cognitiveLoad: 'medium',
allowedExerciseTypes: ['gap-fill', 'transformation'],
requiresHints: true,
allowsPartialCredit: true
},
'free-production': {
id: 'free-production',
description: 'Student can produce correct form independently',
cognitiveLoad: 'high',
allowedExerciseTypes: ['dictation', 'scramble'],
requiresHints: false,
allowsPartialCredit: true
},
'error-detection': {
id: 'error-detection',
description: 'Student can identify and correct errors',
cognitiveLoad: 'high',
allowedExerciseTypes: ['error-correction'],
requiresHints: true,
allowsPartialCredit: false
},
'transfer': {
id: 'transfer',
description: 'Student can apply rule in new/unfamiliar context',
cognitiveLoad: 'high',
allowedExerciseTypes: ['gap-fill', 'transformation', 'error-correction'],
requiresHints: false,
allowsPartialCredit: true
}
};
// A1 Skills - Fundamentals
export const A1_SKILLS: Skill[] = [
{
id: 'present-essere-avere',
category: 'grammar',
cefr: 'A1',
name: 'Present tense: essere & avere',
description: 'Basic conjugation of to be and to have',
prerequisites: [],
transferVariants: ['passato-prossimo-auxiliary'],
commonErrors: ['Confusion between è and ha', 'Wrong person agreement']
},
{
id: 'gender-number-basics',
category: 'grammar',
cefr: 'A1',
name: 'Gender and number agreement',
description: 'Understanding masculine/feminine, singular/plural',
prerequisites: [],
transferVariants: ['adjective-agreement', 'article-agreement'],
commonErrors: ['Polish learners ignore gender', 'Irregular plurals']
},
{
id: 'definite-articles',
category: 'grammar',
cefr: 'A1',
name: 'Definite articles (il, la, i, le)',
description: 'Using correct definite article with gender/number',
prerequisites: ['gender-number-basics'],
transferVariants: ['indefinite-articles', 'partitive-articles'],
commonErrors: ['Omitting articles (Polish influence)', 'Wrong gender article']
},
{
id: 'indefinite-articles',
category: 'grammar',
cefr: 'A1',
name: 'Indefinite articles (un, una, uno)',
description: 'Using correct indefinite article',
prerequisites: ['definite-articles'],
transferVariants: [],
commonErrors: ['Confusion with definite', 'uno vs un']
},
{
id: 'present-regular-verbs',
category: 'grammar',
cefr: 'A1',
name: 'Present tense regular verbs (-are, -ere, -ire)',
description: 'Conjugation of regular verbs',
prerequisites: ['present-essere-avere'],
transferVariants: ['present-irregular-verbs'],
commonErrors: ['Wrong endings', 'Stem changes ignored']
},
{
id: 'basic-prepositions',
category: 'grammar',
cefr: 'A1',
name: 'Basic prepositions (a, di, in, da, con)',
description: 'Using simple prepositions',
prerequisites: [],
transferVariants: ['combined-prepositions'],
commonErrors: ['Literal translation from Polish', 'Confusion a/in']
}
];
// A2 Skills - Functional Structures
export const A2_SKILLS: Skill[] = [
{
id: 'passato-prossimo-auxiliary',
category: 'grammar',
cefr: 'A2',
name: 'Passato Prossimo: auxiliary selection',
description: 'Choosing between essere and avere for past tense',
prerequisites: ['present-essere-avere'],
transferVariants: ['reflexive-verbs-passato', 'trapassato-prossimo'],
commonErrors: [
'Using avere with movement verbs',
'Using essere with transitive verbs',
'Ignoring past participle agreement with essere'
]
},
{
id: 'past-participle-agreement',
category: 'grammar',
cefr: 'A2',
name: 'Past participle agreement',
description: 'Agreement with essere and direct object pronouns',
prerequisites: ['passato-prossimo-auxiliary', 'gender-number-basics'],
transferVariants: [],
commonErrors: ['No agreement with essere', 'Wrong endings']
},
{
id: 'imperfetto-basics',
category: 'grammar',
cefr: 'A2',
name: 'Imperfetto (basic usage)',
description: 'Formation and basic uses of imperfect tense',
prerequisites: ['present-regular-verbs'],
transferVariants: ['imperfetto-passato-distinction'],
commonErrors: ['Confusion with passato prossimo', 'Wrong stem']
},
{
id: 'imperfetto-passato-distinction',
category: 'grammar',
cefr: 'A2',
name: 'Imperfetto vs Passato Prossimo',
description: 'Choosing correct past tense based on aspect',
prerequisites: ['passato-prossimo-auxiliary', 'imperfetto-basics'],
transferVariants: [],
commonErrors: [
'Using PP for descriptions/habits',
'Using Imp for completed actions',
'Not recognizing time signals'
]
},
{
id: 'reflexive-verbs-basic',
category: 'grammar',
cefr: 'A2',
name: 'Reflexive verbs (present)',
description: 'Basic reflexive verb conjugation',
prerequisites: ['present-regular-verbs'],
transferVariants: ['reflexive-verbs-passato'],
commonErrors: ['Forgetting pronoun', 'Wrong pronoun position']
},
{
id: 'combined-prepositions',
category: 'grammar',
cefr: 'A2',
name: 'Combined prepositions (del, alla, etc.)',
description: 'Preposition + article combinations',
prerequisites: ['basic-prepositions', 'definite-articles'],
transferVariants: [],
commonErrors: ['Not combining', 'Wrong form (dello vs del)']
},
{
id: 'direct-object-pronouns',
category: 'grammar',
cefr: 'A2',
name: 'Direct object pronouns (lo, la, li, le)',
description: 'Using and placing direct object pronouns',
prerequisites: ['gender-number-basics'],
transferVariants: ['indirect-object-pronouns', 'combined-pronouns'],
commonErrors: ['Wrong pronoun', 'Wrong position', 'No agreement in PP']
},
{
id: 'polite-requests',
category: 'pragmatics',
cefr: 'A2',
name: 'Polite requests (vorrei, potrebbe)',
description: 'Making polite requests in Italian',
prerequisites: ['present-regular-verbs'],
transferVariants: ['conditional-politeness'],
commonErrors: ['Too direct (Polish influence)', 'Wrong form']
}
];
// Combined skill tree
export const SKILL_TREE: Skill[] = [...A1_SKILLS, ...A2_SKILLS];
// Helper functions
export function getSkill(skillId: string): Skill | undefined {
return SKILL_TREE.find(s => s.id === skillId);
}
export function getSkillsByLevel(cefr: 'A1' | 'A2' | 'B1' | 'B2'): Skill[] {
return SKILL_TREE.filter(s => s.cefr === cefr);
}
export function getPrerequisites(skillId: string): Skill[] {
const skill = getSkill(skillId);
if (!skill) return [];
return skill.prerequisites.map(id => getSkill(id)).filter(Boolean) as Skill[];
}
export function getTransferVariants(skillId: string): Skill[] {
const skill = getSkill(skillId);
if (!skill) return [];
return skill.transferVariants.map(id => getSkill(id)).filter(Boolean) as Skill[];
}
================================================
FILE: src/lib/store/config.ts
================================================
export interface AppConfig {
aiModel: string;
aiProvider: 'ollama' | 'lmstudio'; // New
aiUrl: string; // New
generationSettings: {
checkCount: number; // How many times to self-correct
strictness: 'low' | 'medium' | 'high'; // For writing
deepCheck: boolean; // Enable agentic supervisor
};
}
const DEFAULT_CONFIG: AppConfig = {
aiModel: 'llama3.2:latest',
aiProvider: 'ollama',
aiUrl: 'http://localhost:11434',
generationSettings: {
checkCount: 1,
strictness: 'medium',
deepCheck: false
}
};
const STORAGE_KEY = 'test_master_config';
export const ConfigStore = {
get: (): AppConfig => {
if (typeof window === 'undefined') return DEFAULT_CONFIG;
const stored = localStorage.getItem(STORAGE_KEY);
try {
return stored ? { ...DEFAULT_CONFIG, ...JSON.parse(stored) } : DEFAULT_CONFIG;
} catch (e) {
return DEFAULT_CONFIG;
}
},
update: (newConfig: Partial<AppConfig>) => {
const current = ConfigStore.get();
const updated = { ...current, ...newConfig };
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
window.dispatchEvent(new Event('config-updated'));
return updated;
},
// Helper to update nested settings
updateSettings: (settings: Partial<AppConfig['generationSettings']>) => {
const current = ConfigStore.get();
const updated = {
...current,
generationSettings: { ...current.generationSettings, ...settings }
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
window.dispatchEvent(new Event('config-updated'));
return updated;
}
};
================================================
FILE: src/lib/store/user.ts
================================================
import { Question } from '../ai/schemas';
export interface QuizResult {
id: string;
date: string;
topic: string;
score: number;
total: number;
questions: Question[];
userAnswers: Record<string, string>; // questionId -> answer
}
export interface UserProgress {
masteryPoints: number;
completedQuizzes: number;
unlockedModules: string[];
history: QuizResult[];
}
const DEFAULT_PROGRESS: UserProgress = {
masteryPoints: 0,
completedQuizzes: 0,
unlockedModules: ['futuro-semplice'],
history: [],
};
const STORAGE_KEY = 'test_master_progress';
export const UserStore = {
get: (): UserProgress => {
if (typeof window === 'undefined') return DEFAULT_PROGRESS;
const stored = localStorage.getItem(STORAGE_KEY);
try {
const parsed = stored ? JSON.parse(stored) : DEFAULT_PROGRESS;
// Migration: Ensure history exists
if (!parsed.history) parsed.history = [];
return parsed;
} catch (e) {
return DEFAULT_PROGRESS;
}
},
update: (userData: Partial<UserProgress>) => {
const current = UserStore.get();
const updated = { ...current, ...userData };
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
// Dispatch event for UI updates
window.dispatchEvent(new Event('progress-updated'));
return updated;
},
addPoints: (amount: number) => {
const current = UserStore.get();
UserStore.update({ masteryPoints: current.masteryPoints + amount });
},
completeQuiz: () => {
const current = UserStore.get();
UserStore.update({ completedQuizzes: current.completedQuizzes + 1 });
},
saveQuizResult: (result: QuizResult) => {
const current = UserStore.get();
const updatedHistory = [result, ...current.history]; // Add to beginning
UserStore.update({
history: updatedHistory,
completedQuizzes: current.completedQuizzes + 1
});
}
};
================================================
FILE: src/lib/store/useUserStore.ts
================================================
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { Question, WritingAnalysis } from '../ai/schemas';
import { DailyProgress, DailyChallenge } from '../challenge/daily';
export interface TopicStats {
topicId: string;
attempts: number;
correct: number;
incorrect: number;
lastAttempt: string;
mastery: number; // 0100
}
export function suggestDifficulty(mastery: number): 'A1' | 'A2' | 'B1' | 'B2' {
if (mastery < 50) return 'A1';
if (mastery < 70) return 'A2';
if (mastery < 85) return 'B1';
return 'B2';
}
export interface QuizResult {
id: string;
date: string;
topic: string;
score: number;
total: number;
questions: Question[];
userAnswers: Record<string, string>; // questionId -> answer
}
export interface WritingResult {
id: string;
date: string;
topic: string; // or "Free Write"
originalText: string;
analysis: WritingAnalysis;
}
interface UserState {
name: string; // User's name
masteryPoints: number;
completedQuizzes: number;
unlockedModules: string[];
history: QuizResult[];
writingHistory: WritingResult[];
// Actions
setName: (name: string) => void;
addPoints: (amount: number) => void;
completeQuiz: () => void;
saveQuizResult: (result: QuizResult) => void;
saveWritingResult: (result: WritingResult) => void;
getMistakes: () => string[];
// Retry logic
retryQuiz: QuizResult | null;
setRetryQuiz: (quiz: QuizResult | null) => void;
// Adaptive Learning
topicStats: Record<string, TopicStats>;
updateTopicStats: (topicId: string, correct: number, total: number) => void;
// Daily Challenge
dailyProgress: DailyProgress;
completeDailyChallenge: (challenge: DailyChallenge, score: number, total: number) => void;
}
export const useUserStore = create<UserState>()(
persist(
(set, get) => ({
name: 'Student',
masteryPoints: 0,
completedQuizzes: 0,
unlockedModules: ['futuro-semplice'],
history: [],
writingHistory: [],
retryQuiz: null,
topicStats: {},
dailyProgress: {
lastCompletedDate: '',
currentStreak: 0,
longestStreak: 0,
completedChallenges: []
},
setRetryQuiz: (quiz) => set({ retryQuiz: quiz }),
setName: (name) => set({ name }),
addPoints: (amount) => set((state) => ({ masteryPoints: state.masteryPoints + amount })),
completeQuiz: () => set((state) => ({ completedQuizzes: state.completedQuizzes + 1 })),
saveQuizResult: (result) => set((state) => ({
history: [result, ...state.history],
completedQuizzes: state.completedQuizzes + 1
})),
saveWritingResult: (result) => set((state) => ({
writingHistory: [result, ...state.writingHistory],
masteryPoints: state.masteryPoints + 20
})),
updateTopicStats: (topicId, correct, total) =>
set((state) => {
const prev = state.topicStats[topicId] ?? {
topicId,
attempts: 0,
correct: 0,
incorrect: 0,
lastAttempt: '',
mastery: 0
};
const newCorrect = prev.correct + correct;
const newIncorrect = prev.incorrect + (total - correct);
const newTotal = newCorrect + newIncorrect;
return {
topicStats: {
...state.topicStats,
[topicId]: {
topicId,
attempts: prev.attempts + 1,
correct: newCorrect,
incorrect: newIncorrect,
lastAttempt: new Date().toISOString(),
mastery: Math.round((newCorrect / (newTotal || 1)) * 100)
}
}
};
}),
completeDailyChallenge: (challenge, score, total) =>
set((state) => {
const today = new Date().toISOString().slice(0, 10);
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 10);
// Calculate new streak
let newStreak = 1;
let broke = false;
if (state.dailyProgress.lastCompletedDate === yesterday) {
newStreak = state.dailyProgress.currentStreak + 1;
} else if (state.dailyProgress.lastCompletedDate === today) {
// Already completed today
newStreak = state.dailyProgress.currentStreak;
} else if (state.dailyProgress.lastCompletedDate && state.dailyProgress.lastCompletedDate < yesterday) {
// Streak broke
broke = true;
newStreak = 1;
}
const newLongestStreak = Math.max(newStreak, state.dailyProgress.longestStreak);
// Update challenge
const updatedChallenge: DailyChallenge = {
...challenge,
completed: true,
score,
total
};
// Remove old challenge for today if exists, add new one
const otherChallenges = state.dailyProgress.completedChallenges.filter(
c => c.date !== today
);
// Award bonus points
const bonusPoints = 50;
const streakBonus = newStreak === 7 ? 100 : newStreak === 30 ? 500 : newStreak === 100 ? 2000 : 0;
return {
dailyProgress: {
lastCompletedDate: today,
currentStreak: newStreak,
longestStreak: newLongestStreak,
completedChallenges: [...otherChallenges, updatedChallenge].slice(-30) // Keep last 30
},
masteryPoints: state.masteryPoints + bonusPoints + streakBonus
};
}),
getMistakes: () => {
const history = get().history;
const mistakes: string[] = [];
// Limit to last 5 quizzes
const recentHistory = history.slice(0, 5);
recentHistory.forEach(quiz => {
quiz.questions.forEach((q, idx) => {
// Try exact ID first, then fallback to index-based ID used in older versions
let userAnswer = quiz.userAnswers[q.id];
if (userAnswer === undefined) {
userAnswer = quiz.userAnswers[`q-${idx}`];
}
if (userAnswer && userAnswer.trim().toLowerCase() !== q.correctAnswer.trim().toLowerCase()) {
const mistakeEntry = `[${quiz.topic}] Question: "${q.question}" Correct: "${q.correctAnswer}"`;
if (!mistakes.includes(mistakeEntry)) {
mistakes.push(mistakeEntry);
}
}
});
});
return mistakes.slice(0, 10);
}
}),
{
name: 'test_master_progress', // matches existing localStorage key to keep data!
}
)
);
================================================
FILE: .snapshots/readme.md
================================================
# Snapshots Directory
This directory contains snapshots of your code for AI interactions. Each snapshot is a markdown file that includes relevant code context and project structure information.
## What's included in snapshots?
- Selected code files and their contents
- Project structure (if enabled)
- Your prompt/question for the AI
## Configuration
You can customize snapshot behavior in `config.json`.
================================================
FILE: .snapshots/config.json
================================================
{
"excluded_patterns": [
".git",
".gitignore",
"gradle",
"gradlew",
"gradlew.*",
"node_modules",
".snapshots",
".idea",
".vscode",
"*.log",
"*.tmp",
"target",
"dist",
"build",
".DS_Store",
"*.bak",
"*.swp",
"*.swo",
"*.lock",
"*.iml",
"coverage",
"*.min.js",
"*.min.css",
"__pycache__",
".marketing",
".env",
".env.*",
"*.jpg",
"*.jpeg",
"*.png",
"*.gif",
"*.bmp",
"*.tiff",
"*.ico",
"*.svg",
"*.webp",
"*.psd",
"*.ai",
"*.eps",
"*.indd",
"*.raw",
"*.cr2",
"*.nef",
"*.mp4",
"*.mov",
"*.avi",
"*.wmv",
"*.flv",
"*.mkv",
"*.webm",
"*.m4v",
"*.wfp",
"*.prproj",
"*.aep",
"*.psb",
"*.xcf",
"*.sketch",
"*.fig",
"*.xd",
"*.db",
"*.sqlite",
"*.sqlite3",
"*.mdb",
"*.accdb",
"*.frm",
"*.myd",
"*.myi",
"*.ibd",
"*.dbf",
"*.rdb",
"*.aof",
"*.pdb",
"*.sdb",
"*.s3db",
"*.ddb",
"*.db-shm",
"*.db-wal",
"*.sqlitedb",
"*.sql.gz",
"*.bak.sql",
"dump.sql",
"dump.rdb",
"*.vsix",
"*.jar",
"*.war",
"*.ear",
"*.zip",
"*.tar",
"*.tar.gz",
"*.tgz",
"*.rar",
"*.7z",
"*.exe",
"*.dll",
"*.so",
"*.dylib",
"*.app",
"*.dmg",
"*.iso",
"*.msi",
"*.deb",
"*.rpm",
"*.apk",
"*.aab",
"*.ipa",
"*.pkg",
"*.nupkg",
"*.snap",
"*.whl",
"*.gem",
"*.pyc",
"*.pyo",
"*.pyd",
"*.class",
"*.o",
"*.obj",
"*.lib",
"*.a",
"*.map",
".npmrc"
],
"default": {
"default_prompt": "Enter your prompt here",
"default_include_all_files": false,
"default_include_entire_project_structure": true
},
"included_patterns": [
"build.gradle",
"settings.gradle",
"gradle.properties",
"pom.xml",
"Makefile",
"CMakeLists.txt",
"package.json",
"requirements.txt",
"Pipfile",
"Gemfile",
"composer.json",
".editorconfig",
".eslintrc.json",
".eslintrc.js",
".prettierrc",
".babelrc",
".dockerignore",
".gitattributes",
".stylelintrc",
".npmrc"
]
}
================================================
FILE: .snapshots/sponsors.md
================================================
# Thank you for using Snapshots for AI
Thanks for using Snapshots for AI. We hope this tool has helped you solve a problem or two.
If you would like to support our work, please help us by considering the following offers and requests:
## Ways to Support
### Join the GBTI Network!!! 🙏🙏🙏
The GBTI Network is a community of developers who are passionate about open source and community-driven development. Members enjoy access to exclussive tools, resources, a private MineCraft server, a listing in our members directory, co-op opportunities and more.
- Support our work by becoming a [GBTI Network member](https://gbti.network/membership/).
### Try out BugHerd 🐛
BugHerd is a visual feedback and bug-tracking tool designed to streamline website development by enabling users to pin feedback directly onto web pages. This approach facilitates clear communication among clients, designers, developers, and project managers.
- Start your free trial with [BugHerd](https://partners.bugherd.com/55z6c8az8rvr) today.
### Hire Developers from Codeable 👥
Codeable connects you with top-tier professionals skilled in frameworks and technologies such as Laravel, React, Django, Node, Vue.js, Angular, Ruby on Rails, and Node.js. Don't let the WordPress focus discourage you. Codeable experts do it all.
- Visit [Codeable](https://www.codeable.io/developers/?ref=z8h3e) to hire your next team member.
### Lead positive reviews on our marketplace listing ⭐⭐⭐⭐⭐
- Rate us on [VSCode marketplace](https://marketplace.visualstudio.com/items?itemName=GBTI.snapshots-for-ai)
- Review us on [Cursor marketplace](https://open-vsx.org/extension/GBTI/snapshots-for-ai)
### Star Our GitHub Repository ⭐
- Star and watch our [repository](https://github.com/gbti-network/vscode-snapshots-for-ai)
### 📡 Stay Connected
Follow us on your favorite platforms for updates, news, and community discussions:
- **[Twitter/X](https://twitter.com/gbti_network)**
- **[GitHub](https://github.com/gbti-network)**
- **[YouTube](https://www.youtube.com/channel/UCh4FjB6r4oWQW-QFiwqv-UA)**
- **[Dev.to](https://dev.to/gbti)**
- **[Daily.dev](https://dly.to/zfCriM6JfRF)**
- **[Hashnode](https://gbti.hashnode.dev/)**
- **[Discord Community](https://gbti.network)**
- **[Reddit Community](https://www.reddit.com/r/GBTI_network)**
---
Thank you for supporting open source software! 🙏