biradar-vilohith-patil commited on
Commit
c46e9ff
·
0 Parent(s):

full size

Browse files
Files changed (44) hide show
  1. .gitignore +24 -0
  2. Dockerfile +31 -0
  3. README.md +88 -0
  4. backend/README.md +55 -0
  5. backend/app/__init__.py +1 -0
  6. backend/app/api/__init__.py +0 -0
  7. backend/app/api/routes.py +108 -0
  8. backend/app/config.py +60 -0
  9. backend/app/main.py +85 -0
  10. backend/app/models/__init__.py +0 -0
  11. backend/app/models/model_loader.py +61 -0
  12. backend/app/pipeline/__init__.py +0 -0
  13. backend/app/pipeline/grammar_corrector.py +110 -0
  14. backend/app/pipeline/homophone_resolver.py +41 -0
  15. backend/app/pipeline/processor.py +121 -0
  16. backend/app/pipeline/scorer.py +97 -0
  17. backend/app/pipeline/spell_checker.py +61 -0
  18. backend/app/resources/dictionary.txt +895 -0
  19. backend/app/resources/homophones.json +142 -0
  20. backend/app/utils/__init__.py +0 -0
  21. backend/app/utils/text_utils.py +104 -0
  22. backend/requirements.txt +8 -0
  23. backend/tests/__init__.py +0 -0
  24. backend/tests/test_pipeline.py +227 -0
  25. frontend/index.html +15 -0
  26. frontend/package-lock.json +0 -0
  27. frontend/package.json +26 -0
  28. frontend/postcss.config.js +1 -0
  29. frontend/src/App.jsx +52 -0
  30. frontend/src/components/CorrectionPanel.jsx +269 -0
  31. frontend/src/components/Navbar.jsx +49 -0
  32. frontend/src/components/TextEditor.jsx +186 -0
  33. frontend/src/components/ThemeToggle.jsx +63 -0
  34. frontend/src/hooks/useTextCorrection.js +38 -0
  35. frontend/src/hooks/useTextRefinement.js +26 -0
  36. frontend/src/main.jsx +23 -0
  37. frontend/src/pages/Home.jsx +275 -0
  38. frontend/src/services/api.js +47 -0
  39. frontend/src/store/UIContext.jsx +50 -0
  40. frontend/src/styles/globals.css +308 -0
  41. frontend/tailwind.config.js +45 -0
  42. frontend/vite.config.js +12 -0
  43. package-lock.json +61 -0
  44. package.json +6 -0
.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ .venv/
3
+ __pycache__/
4
+ *.pyc
5
+ *.pyo
6
+ *.pyd
7
+ .env
8
+ *.egg-info/
9
+ dist/
10
+ .pytest_cache/
11
+
12
+ # HuggingFace cache (too large for GitHub)
13
+ ~/.cache/huggingface/
14
+
15
+ # Node
16
+ node_modules/
17
+ .env
18
+ .env.local
19
+ frontend/dist/
20
+
21
+ # Editors
22
+ .DS_Store
23
+ .vscode/
24
+ .idea/
Dockerfile ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dockerfile
2
+ FROM python:3.10-slim
3
+
4
+ # Set environment variables
5
+ ENV PYTHONUNBUFFERED=1 \
6
+ PYTHONDONTWRITEBYTECODE=1 \
7
+ PIP_NO_CACHE_DIR=1 \
8
+ PIP_DISABLE_PIP_VERSION_CHECK=1
9
+
10
+ # Create a non-root user (Mandatory for Hugging Face Spaces)
11
+ RUN useradd -m -u 1000 user
12
+ USER user
13
+ ENV PATH="/home/user/.local/bin:$PATH"
14
+
15
+ WORKDIR /app
16
+
17
+ # Install dependencies first (leverage Docker layer caching)
18
+ COPY --chown=user requirements.txt .
19
+ RUN pip install --user -r requirements.txt
20
+
21
+ # Download the spaCy English model
22
+ RUN python -m spacy download en_core_web_sm
23
+
24
+ # Copy the rest of the application code
25
+ COPY --chown=user . .
26
+
27
+ # Expose the standard Hugging Face port
28
+ EXPOSE 7860
29
+
30
+ # Run the FastAPI application
31
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: WriteRight NLP Backend
3
+ sdk: docker
4
+ app_port: 7860
5
+ ---
6
+
7
+
8
+ # WriteRight NLP API 🚀
9
+
10
+ WriteRight is a production-grade NLP auto-correction and text refinement backend. It utilizes a multi-stage pipeline combining algorithmic rules with state-of-the-art Transformer models to provide instant spelling, grammar, and stylistic corrections.
11
+
12
+ ## 🧠 Architecture & Models
13
+
14
+ The backend utilizes a decoupled architecture to ensure high precision:
15
+
16
+ 1. **Tokenization & NER:** `spaCy (en_core_web_sm)`
17
+ 2. **Spell Correction:** `SymSpell` (O(1) lookup, edit distance 1-2)
18
+ 3. **Homophone Resolution:** Context-aware POS heuristics
19
+ 4. **Grammar Correction:** `vennify/t5-base-grammar-correction` (T5 Encoder-Decoder)
20
+ 5. **Stylistic Refinement:** `eugenesiow/bart-paraphrase` (BART Sequence-to-Sequence)
21
+
22
+ By separating **Grammar Correction** (T5) from **Stylistic Refinement** (BART), the system prevents prompt-leakage and maintains semantic integrity during paraphrasing.
23
+
24
+ ## 🔌 API Endpoints
25
+
26
+ The API is built with **FastAPI** and is fully documented. Once running, visit `/docs` for the interactive Swagger UI.
27
+
28
+ ### 1. Correct Text (`POST /api/correct`)
29
+ Executes the primary multi-stage correction pipeline.
30
+
31
+ **Request:**
32
+ \`\`\`json
33
+ {
34
+ "text": "Thier going to the store to much.",
35
+ "run_spell": true,
36
+ "run_grammar": true,
37
+ "run_homophones": true
38
+ }
39
+ \`\`\`
40
+
41
+ **Response:**
42
+ \`\`\`json
43
+ {
44
+ "original": "Thier going to the store to much.",
45
+ "corrected": "They're going to the store too much.",
46
+ "spell_fixed": 0,
47
+ "grammar_fixed": 1,
48
+ "homophone_fixed": 2,
49
+ "confidence": 0.89,
50
+ "diffs": [...],
51
+ "processing_ms": 345.2
52
+ }
53
+ \`\`\`
54
+
55
+ ### 2. Refine Text (`POST /api/refine`)
56
+ Paraphrases and refines text for flow and style using the BART model.
57
+
58
+ **Request:**
59
+ \`\`\`json
60
+ {
61
+ "text": "They're going to the store too much.",
62
+ "style": "professional"
63
+ }
64
+ \`\`\`
65
+
66
+ ## 🛠️ Local Setup & Development
67
+
68
+ **Prerequisites:** Python 3.9+
69
+
70
+ 1. **Install dependencies:**
71
+ \`\`\`bash
72
+ pip install -r requirements.txt
73
+ python -m spacy download en_core_web_sm
74
+ \`\`\`
75
+
76
+ 2. **Run the server:**
77
+ \`\`\`bash
78
+ uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
79
+ \`\`\`
80
+
81
+ ## ☁️ Hugging Face Deployment
82
+
83
+ This repository is configured for automatic deployment on Hugging Face Spaces via Docker.
84
+
85
+ * **Port Mapping:** The Dockerfile routes Uvicorn to `7860` as required by HF Spaces.
86
+ * **Cold Starts:** Upon the very first container initialization, the system will download the T5 and BART model weights (~1.5GB) from the Hugging Face Hub. Initial boot may take 2-3 minutes. Subsequent requests will be processed in-memory.
87
+
88
+ ---
backend/README.md ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 2. Backend `README.md`
2
+ Place this in your `backend/` directory (or the root of your Hugging Face Space).
3
+
4
+ ```markdown
5
+ # WriteRight NLP API 🧠⚙️
6
+
7
+ The core backend for **WriteRight**. This API processes text through a multi-stage Natural Language Processing pipeline to provide algorithmic spell checking, context-aware homophone resolution, structural grammar correction, and stylistic paraphrasing.
8
+
9
+ ## 🏗️ Architecture & Models
10
+
11
+ The backend utilizes a decoupled architecture to ensure high precision and prevent prompt-leakage:
12
+
13
+ 1. **Pre-processing & Tokenization:** `spaCy` (`en_core_web_sm`)
14
+ 2. **Algorithmic Spell Correction:** `SymSpell` (O(1) dictionary lookup, edit distance 1-2)
15
+ 3. **Homophone Resolution:** Context-aware regex heuristics.
16
+ 4. **Grammar Correction:** `vennify/t5-base-grammar-correction` (Encoder-Decoder architecture fine-tuned specifically for grammar).
17
+ 5. **Stylistic Refinement:** `eugenesiow/bart-paraphrase` (BART Sequence-to-Sequence model for native text rewrites).
18
+
19
+ ## 🔌 Core API Endpoints
20
+
21
+ The API is built with **FastAPI**. Once the server is running, visit `/docs` for the interactive OpenAPI/Swagger UI.
22
+
23
+ * **`GET /api/health`**: System liveness and model loading status.
24
+ * **`GET /api/stats`**: Aggregated usage metrics (words processed, average fixes).
25
+ * **`POST /api/correct`**: Executes the primary multi-stage correction pipeline. Returns diffs, confidence scores, and fixed text.
26
+ * **`POST /api/refine`**: Paraphrases and refines text for flow and style using the BART model.
27
+
28
+ ## 🛠️ Local Setup
29
+
30
+ ### Prerequisites
31
+ * Python 3.9+
32
+
33
+ ### Setup Instructions
34
+
35
+ 1. **Install Python dependencies:**
36
+ ```bash
37
+ pip install -r requirements.txt
38
+ Download the spaCy Language Model:
39
+
40
+ Bash
41
+ python -m spacy download en_core_web_sm
42
+ Run the FastAPI server:
43
+
44
+ Bash
45
+ uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
46
+ The API will be available at http://localhost:8000.
47
+
48
+ ☁️ Hugging Face Spaces Deployment
49
+ This repository is optimized for deployment via Docker on Hugging Face Spaces.
50
+
51
+ Port Mapping: The Dockerfile automatically routes Uvicorn to port 7860, which is required by Hugging Face Spaces.
52
+
53
+ Cold Starts: Upon the very first container initialization, the system will download the T5 and BART model weights (~1.5GB total) from the Hugging Face Hub. This initial boot may take 2-3 minutes. All subsequent requests are processed in-memory.
54
+
55
+ Security: Runs as a non-root user (UID 1000) in compliance with HF Spaces security policies.
backend/app/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # app package
backend/app/api/__init__.py ADDED
File without changes
backend/app/api/routes.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app/api/routes.py
3
+ ─────────────────
4
+ All REST endpoints for the WriteRight API.
5
+ """
6
+
7
+ from __future__ import annotations
8
+ from fastapi import APIRouter, Request, HTTPException
9
+ from pydantic import BaseModel, Field, field_validator
10
+ from loguru import logger
11
+ import time
12
+
13
+ from app.config import get_settings
14
+
15
+ router = APIRouter()
16
+ settings = get_settings()
17
+
18
+ # ── Request / Response schemas ────────────────────────────────────────────────
19
+
20
+ class CorrectionRequest(BaseModel):
21
+ text: str = Field(..., min_length=1, max_length=4096)
22
+ run_spell: bool = Field(True)
23
+ run_grammar: bool = Field(True)
24
+ run_homophones: bool = Field(True)
25
+
26
+ @field_validator("text")
27
+ @classmethod
28
+ def strip_and_validate(cls, v: str) -> str:
29
+ v = v.strip()
30
+ if not v: raise ValueError("text must not be blank")
31
+ return v
32
+
33
+ class DiffEntry(BaseModel):
34
+ original: str
35
+ corrected: str
36
+ type: str
37
+ position: int
38
+
39
+ class CorrectionResponse(BaseModel):
40
+ original: str
41
+ corrected: str
42
+ spell_fixed: int
43
+ grammar_fixed: int
44
+ homophone_fixed: int
45
+ confidence: float
46
+ diffs: list[DiffEntry]
47
+ processing_ms: float
48
+
49
+ class RefineRequest(BaseModel):
50
+ text: str = Field(..., min_length=1, max_length=4096)
51
+ style: str = Field("professional")
52
+
53
+ class RefineResponse(BaseModel):
54
+ original: str
55
+ refined: str
56
+ improvements: list[str]
57
+ processing_ms: float
58
+
59
+ # ── Endpoints ─────────────────────────────────────────────────────────────────
60
+
61
+ @router.post("/correct", response_model=CorrectionResponse, tags=["NLP"])
62
+ async def correct_text(body: CorrectionRequest, request: Request):
63
+ proc = request.app.state.processor
64
+ t0 = time.perf_counter()
65
+
66
+ try:
67
+ result = await proc.correct(
68
+ text=body.text,
69
+ run_spell=body.run_spell,
70
+ run_grammar=body.run_grammar,
71
+ run_homophones=body.run_homophones,
72
+ )
73
+ except Exception as e:
74
+ logger.error(f"/correct error: {e}")
75
+ raise HTTPException(status_code=500, detail=str(e))
76
+
77
+ elapsed_ms = (time.perf_counter() - t0) * 1000
78
+
79
+ return CorrectionResponse(
80
+ original=body.text,
81
+ corrected=result["corrected"],
82
+ spell_fixed=result["spell_fixed"],
83
+ grammar_fixed=result["grammar_fixed"],
84
+ homophone_fixed=result["homophone_fixed"],
85
+ confidence=result["confidence"],
86
+ diffs=[DiffEntry(**d) for d in result["diffs"]],
87
+ processing_ms=round(elapsed_ms, 1),
88
+ )
89
+
90
+ @router.post("/refine", response_model=RefineResponse, tags=["NLP"])
91
+ async def refine_text(body: RefineRequest, request: Request):
92
+ proc = request.app.state.processor
93
+ t0 = time.perf_counter()
94
+
95
+ try:
96
+ result = await proc.refine(text=body.text, style=body.style)
97
+ except Exception as e:
98
+ logger.error(f"/refine error: {e}")
99
+ raise HTTPException(status_code=500, detail=str(e))
100
+
101
+ elapsed_ms = (time.perf_counter() - t0) * 1000
102
+
103
+ return RefineResponse(
104
+ original=body.text,
105
+ refined=result["refined"],
106
+ improvements=result["improvements"],
107
+ processing_ms=round(elapsed_ms, 1),
108
+ )
backend/app/config.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app/config.py
3
+ ─────────────
4
+ Centralised settings loaded from environment / .env file.
5
+ """
6
+
7
+ from pydantic_settings import BaseSettings, SettingsConfigDict
8
+ from functools import lru_cache
9
+ from pathlib import Path
10
+
11
+ BASE_DIR = Path(__file__).resolve().parent
12
+
13
+ class Settings(BaseSettings):
14
+ # ── App ──────────────────────────────────────────────
15
+ app_name: str = "WriteRight NLP API"
16
+ app_version: str = "1.0.0"
17
+ debug: bool = False
18
+
19
+ # ── CORS ─────────────────────────────────────────────
20
+ allowed_origins: list[str] = [
21
+ "http://localhost:5173",
22
+ "https://writeright.vercel.app",
23
+ "https://shikanja-malik-writeright-api.hf.space",
24
+ ]
25
+
26
+ # ── Models ────────────────────────────────────────────
27
+ # Grammar Model (T5 fine-tuned)
28
+ model_name: str = "prithivida/grammar_error_correcter_v1"
29
+ model_prefix: str = "grammar: "
30
+
31
+ # Refinement Model (BERT-based BART architecture for paraphrasing/rewriting)
32
+ refine_model_name: str = "eugenesiow/bart-paraphrase"
33
+
34
+ max_input_length: int = 512
35
+ max_output_length: int = 512
36
+ model_device: str = "cpu" # "cuda" if GPU available
37
+
38
+ # ── Spell checker ─────────────────────────────────────
39
+ spell_dict_path: str = str(BASE_DIR / "resources" / "dictionary.txt")
40
+ spell_max_edit_distance: int = 2
41
+ spell_prefix_length: int = 7
42
+
43
+ # ── Homophones ────────────────────────────────────────
44
+ homophone_path: str = str(BASE_DIR / "resources" / "homophones.json")
45
+
46
+ # ── Pipeline ─────────────────────────────────────────
47
+ spacy_model: str = "en_core_web_sm"
48
+ min_word_length_for_spell: int = 2
49
+ skip_spell_for_proper_nouns: bool = True
50
+
51
+ model_config = SettingsConfigDict(
52
+ env_file=".env",
53
+ env_file_encoding="utf-8",
54
+ case_sensitive=False,
55
+ extra="ignore",
56
+ )
57
+
58
+ @lru_cache(maxsize=1)
59
+ def get_settings() -> Settings:
60
+ return Settings()
backend/app/main.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app/main.py
3
+ ───────────
4
+ FastAPI application entry-point.
5
+ Bootstraps the NLP pipeline on startup and exposes the REST API.
6
+ """
7
+
8
+ from contextlib import asynccontextmanager
9
+ from loguru import logger
10
+ import sys
11
+
12
+ from fastapi import FastAPI
13
+ from fastapi.middleware.cors import CORSMiddleware
14
+ from fastapi.responses import JSONResponse
15
+
16
+ from app.config import get_settings
17
+ from app.api.routes import router
18
+ from app.pipeline.processor import NLPProcessor
19
+
20
+ # ── Logging ──────────────────────────────────────────────────────────────────
21
+ logger.remove()
22
+ logger.add(
23
+ sys.stderr,
24
+ format="<green>{time:HH:mm:ss}</green> | <level>{level: <8}</level> | {message}",
25
+ level="DEBUG",
26
+ colorize=True,
27
+ )
28
+
29
+ # ── Lifespan: startup / shutdown ─────────────────────────────────────────────
30
+ @asynccontextmanager
31
+ async def lifespan(app: FastAPI):
32
+ settings = get_settings()
33
+ logger.info(f"Starting {settings.app_name} v{settings.app_version}")
34
+
35
+ # Initialise the pipeline (loads spaCy + SymSpell + T5 + BART)
36
+ processor = NLPProcessor(settings)
37
+ await processor.initialise()
38
+ app.state.processor = processor
39
+
40
+ logger.info("Pipeline ready — API is live.")
41
+ yield
42
+
43
+ # Cleanup
44
+ logger.info("Shutting down — releasing model resources.")
45
+ del app.state.processor
46
+
47
+ # ── App ───────────────────────────────────────────────────────────────────────
48
+ settings = get_settings()
49
+
50
+ app = FastAPI(
51
+ title=settings.app_name,
52
+ version=settings.app_version,
53
+ description=(
54
+ "NLP-based auto-corrector & paraphraser: spell correction via SymSpell, "
55
+ "grammar correction via T5, and style refinement via BART."
56
+ ),
57
+ lifespan=lifespan,
58
+ docs_url="/docs",
59
+ redoc_url="/redoc",
60
+ )
61
+
62
+ # ── CORS ──────────────────────────────────────────────────────────────────────
63
+ app.add_middleware(
64
+ CORSMiddleware,
65
+ allow_origins=settings.allowed_origins,
66
+ allow_credentials=True,
67
+ allow_methods=["*"],
68
+ allow_headers=["*"],
69
+ )
70
+
71
+ # ── Routes ────────────────────────────────────────────────────────────────────
72
+ app.include_router(router, prefix="/api")
73
+
74
+ # ── Global exception handler ─────────────────────────────────────────────────
75
+ @app.exception_handler(Exception)
76
+ async def global_exception_handler(request, exc):
77
+ logger.error(f"Unhandled exception: {exc}")
78
+ return JSONResponse(
79
+ status_code=500,
80
+ content={"detail": "Internal server error. Check logs for details."},
81
+ )
82
+
83
+ if __name__ == "__main__":
84
+ import uvicorn
85
+ uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True)
backend/app/models/__init__.py ADDED
File without changes
backend/app/models/model_loader.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app/models/model_loader.py
3
+ ───────────────────────────
4
+ Loads and caches:
5
+ • spaCy en_core_web_sm — for tokenisation, POS tagging, NER
6
+ • HuggingFace T5 — for grammar correction
7
+ • HuggingFace BART — for stylistic text refinement (paraphrasing)
8
+ """
9
+
10
+ from __future__ import annotations
11
+ from loguru import logger
12
+ from app.config import Settings
13
+
14
+ class ModelLoader:
15
+ def __init__(self, settings: Settings):
16
+ self._settings = settings
17
+
18
+ def load(self) -> tuple:
19
+ """
20
+ Returns (nlp, tokenizer_t5, model_t5, tokenizer_bart, model_bart).
21
+ """
22
+ nlp = self._load_spacy()
23
+ t5_tok, t5_mod = self._load_hf_model(self._settings.model_name, "Grammar (T5)")
24
+ bart_tok, bart_mod = self._load_hf_model(self._settings.refine_model_name, "Refine (BART)")
25
+ return nlp, t5_tok, t5_mod, bart_tok, bart_mod
26
+
27
+ def _load_spacy(self):
28
+ try:
29
+ import spacy
30
+ logger.info(f"Loading spaCy model: {self._settings.spacy_model}")
31
+ nlp = spacy.load(
32
+ self._settings.spacy_model,
33
+ disable=["ner"] if self._settings.skip_spell_for_proper_nouns else [],
34
+ )
35
+ return nlp
36
+ except Exception as e:
37
+ logger.error(f"spaCy load error: {e}")
38
+ return None
39
+
40
+ def _load_hf_model(self, model_name: str, label: str) -> tuple:
41
+ try:
42
+ from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
43
+ device = self._settings.model_device
44
+
45
+ logger.info(f"Loading {label} model: {model_name} on {device}")
46
+ tokenizer = AutoTokenizer.from_pretrained(model_name)
47
+ model = AutoModelForSeq2SeqLM.from_pretrained(model_name)
48
+
49
+ if device == "cuda":
50
+ try:
51
+ model = model.to("cuda")
52
+ except Exception:
53
+ logger.warning("CUDA unavailable, using CPU.")
54
+
55
+ model.eval()
56
+ logger.info(f"{label} model loaded successfully.")
57
+ return tokenizer, model
58
+
59
+ except Exception as e:
60
+ logger.error(f"{label} load error: {e}")
61
+ return None, None
backend/app/pipeline/__init__.py ADDED
File without changes
backend/app/pipeline/grammar_corrector.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app/pipeline/grammar_corrector.py
3
+ ───────────────────────────────────
4
+ Handles two independent tasks:
5
+ 1. Grammar Correction (T5)
6
+ 2. Text Refinement & Style Paraphrasing (BART)
7
+ """
8
+
9
+ from __future__ import annotations
10
+ import re
11
+ from loguru import logger
12
+ from app.config import Settings
13
+ from app.utils.text_utils import count_word_diffs
14
+
15
+ class GrammarCorrector:
16
+ def __init__(self, settings: Settings, tokenizer_t5, model_t5, tokenizer_bart, model_bart):
17
+ self._settings = settings
18
+
19
+ # Grammar dependencies
20
+ self._tokenizer = tokenizer_t5
21
+ self._model = model_t5
22
+
23
+ # Refinement dependencies
24
+ self._refine_tokenizer = tokenizer_bart
25
+ self._refine_model = model_bart
26
+
27
+ # ── Grammar correction ────────────────────────────────────────────────────
28
+
29
+ def correct(self, sentence: str) -> dict[str, object]:
30
+ if not self._tokenizer or not self._model:
31
+ return {"corrected": sentence, "fixes": 0}
32
+
33
+ sentence = sentence.strip()
34
+ if not sentence:
35
+ return {"corrected": sentence, "fixes": 0}
36
+
37
+ try:
38
+ prompt = self._settings.model_prefix + sentence
39
+ inputs = self._tokenizer.encode(
40
+ prompt, return_tensors="pt", max_length=self._settings.max_input_length, truncation=True
41
+ )
42
+ outputs = self._model.generate(
43
+ inputs, max_length=self._settings.max_output_length, num_beams=4, early_stopping=True
44
+ )
45
+ corrected = self._tokenizer.decode(outputs[0], skip_special_tokens=True)
46
+ fixes = count_word_diffs(sentence, corrected)
47
+ return {"corrected": corrected, "fixes": fixes}
48
+ except Exception as e:
49
+ logger.error(f"T5 grammar correction error: {e}")
50
+ return {"corrected": sentence, "fixes": 0}
51
+
52
+ # ── Refinement (CRITICAL BUG FIX) ─────────────────────────────────────────
53
+
54
+ def refine(self, text: str, style: str = "professional") -> dict[str, object]:
55
+ """
56
+ Uses the BART seq2seq model to paraphrase.
57
+ By removing conversational prompts (which leaked into output) and using a
58
+ dedicated paraphrasing model, we return only clean, refined text.
59
+ """
60
+ if not self._refine_tokenizer or not self._refine_model:
61
+ return {"refined": text, "improvements": ["Refinement model not loaded."]}
62
+
63
+ try:
64
+ # We no longer prepend instructional prompts that confuse the model.
65
+ # BART paraphrase models expect raw text input.
66
+ inputs = self._refine_tokenizer.encode(
67
+ text, return_tensors="pt", max_length=self._settings.max_input_length, truncation=True
68
+ )
69
+
70
+ # Adjusted generation params for distinct phrasing
71
+ outputs = self._refine_model.generate(
72
+ inputs,
73
+ max_length=self._settings.max_output_length,
74
+ num_beams=5,
75
+ early_stopping=True,
76
+ no_repeat_ngram_size=3,
77
+ temperature=0.7 # Slight variance for better natural phrasing
78
+ )
79
+ refined = self._refine_tokenizer.decode(outputs[0], skip_special_tokens=True)
80
+
81
+ improvements = self._detect_improvements(text, refined)
82
+ return {"refined": refined, "improvements": improvements}
83
+ except Exception as e:
84
+ logger.error(f"BART refinement error: {e}")
85
+ return {"refined": text, "improvements": [f"Refinement error: {e}"]}
86
+
87
+ @staticmethod
88
+ def _detect_improvements(original: str, refined: str) -> list[str]:
89
+ improvements: list[str] = []
90
+ orig_words = original.split()
91
+ ref_words = refined.split()
92
+
93
+ if len(ref_words) < len(orig_words) * 0.9:
94
+ improvements.append("Condensed verbose phrasing for clarity.")
95
+ if len(ref_words) > len(orig_words) * 1.1:
96
+ improvements.append("Expanded phrases for improved readability.")
97
+
98
+ orig_sents = re.split(r"[.!?]+", original)
99
+ ref_sents = re.split(r"[.!?]+", refined)
100
+ if len(ref_sents) != len(orig_sents):
101
+ improvements.append("Restructured sentences for better flow.")
102
+
103
+ passive_re = re.compile(r"\bwas\s+\w+ed\b|\bwere\s+\w+ed\b", re.I)
104
+ if passive_re.search(original) and not passive_re.search(refined):
105
+ improvements.append("Converted passive constructions to active voice.")
106
+
107
+ if not improvements:
108
+ improvements.append("Improved vocabulary and sentence cohesion.")
109
+
110
+ return improvements[:3]
backend/app/pipeline/homophone_resolver.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ import re
3
+ from loguru import logger
4
+ from app.config import Settings
5
+
6
+ _RULES = [
7
+ (re.compile(r"\bthier\b", re.I), "thier", "their"),
8
+ (re.compile(r"\btheir\s+is\b", re.I), "their is", "there is"),
9
+ (re.compile(r"\byour\s+(?:going|coming|doing|being)\b", re.I), "your", "you're"),
10
+ (re.compile(r"\byoure\b", re.I), "youre", "you're"),
11
+ (re.compile(r"\bits\s+(?:a|an|the|not|been|going)\b", re.I), "its", "it's"),
12
+ (re.compile(r"\b(?:more|less|better|worse)\s+then\b", re.I), "then", "than"),
13
+ (re.compile(r"\bto\s+(?:much|many|late|soon)\b", re.I), "to much", "too much"),
14
+ (re.compile(r"\bweather\s+or\s+not\b", re.I), "weather", "whether"),
15
+ ]
16
+
17
+ class HomophoneResolver:
18
+ def __init__(self, settings: Settings):
19
+ self._settings = settings
20
+
21
+ def load(self) -> bool:
22
+ return True # Relying on heuristic rules for speed
23
+
24
+ def resolve(self, text: str) -> dict[str, object]:
25
+ fixes = 0
26
+ result = text
27
+ for pattern, wrong, correct in _RULES:
28
+ if pattern.search(result):
29
+ old = result
30
+ result = self._replace_preserving_case(result, wrong, correct)
31
+ if result != old: fixes += 1
32
+ return {"corrected": result, "fixes": fixes}
33
+
34
+ @staticmethod
35
+ def _replace_preserving_case(text: str, wrong: str, correct: str) -> str:
36
+ def replacer(m: re.Match) -> str:
37
+ original = m.group(0)
38
+ if original.isupper(): return correct.upper()
39
+ if original[0].isupper(): return correct.capitalize()
40
+ return correct
41
+ return re.sub(re.escape(wrong), replacer, text, flags=re.IGNORECASE)
backend/app/pipeline/processor.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app/pipeline/processor.py
3
+ ──────────────────────────
4
+ Orchestrates the full NLP correction pipeline:
5
+ Pre-process → Spell → Homophones → Grammar → Score → Diff
6
+ """
7
+
8
+ from __future__ import annotations
9
+ import asyncio
10
+ from loguru import logger
11
+
12
+ from app.config import Settings
13
+ from app.pipeline.spell_checker import SpellChecker
14
+ from app.pipeline.homophone_resolver import HomophoneResolver
15
+ from app.pipeline.grammar_corrector import GrammarCorrector
16
+ from app.pipeline.scorer import Scorer
17
+ from app.models.model_loader import ModelLoader
18
+ from app.utils.text_utils import split_into_sentences, normalise_whitespace, build_word_diffs
19
+
20
+ class NLPProcessor:
21
+ def __init__(self, settings: Settings):
22
+ self._settings = settings
23
+ self.spacy_ready: bool = False
24
+ self.symspell_ready: bool = False
25
+ self.t5_ready: bool = False
26
+
27
+ self._spell_checker: SpellChecker | None = None
28
+ self._homophone_resolver: HomophoneResolver | None = None
29
+ self._grammar_corrector: GrammarCorrector | None = None
30
+ self._scorer: Scorer | None = None
31
+ self._model_loader: ModelLoader | None = None
32
+
33
+ async def initialise(self) -> None:
34
+ logger.info("Initialising NLP pipeline…")
35
+ loop = asyncio.get_event_loop()
36
+ await loop.run_in_executor(None, self._load_all)
37
+ logger.info(f"Pipeline ready | spaCy={self.spacy_ready} SymSpell={self.symspell_ready} T5={self.t5_ready}")
38
+
39
+ def _load_all(self) -> None:
40
+ s = self._settings
41
+ self._model_loader = ModelLoader(s)
42
+
43
+ # Unpack both models
44
+ nlp, t5_tok, t5_mod, bart_tok, bart_mod = self._model_loader.load()
45
+
46
+ if nlp: self.spacy_ready = True
47
+
48
+ self._spell_checker = SpellChecker(s)
49
+ if self._spell_checker.load(): self.symspell_ready = True
50
+
51
+ self._homophone_resolver = HomophoneResolver(s)
52
+ self._homophone_resolver.load()
53
+
54
+ # Inject both models into the corrector
55
+ self._grammar_corrector = GrammarCorrector(s, t5_tok, t5_mod, bart_tok, bart_mod)
56
+ if t5_tok and t5_mod: self.t5_ready = True
57
+
58
+ self._scorer = Scorer(nlp)
59
+
60
+ async def correct(self, text: str, run_spell: bool = True, run_grammar: bool = True, run_homophones: bool = True) -> dict:
61
+ loop = asyncio.get_event_loop()
62
+ return await loop.run_in_executor(None, self._correct_sync, text, run_spell, run_grammar, run_homophones)
63
+
64
+ def _correct_sync(self, text: str, run_spell: bool, run_grammar: bool, run_homophones: bool) -> dict:
65
+ original = text
66
+ spell_fixed = grammar_fixed = homophone_fixed = 0
67
+ all_diffs: list[dict] = []
68
+
69
+ current = normalise_whitespace(text)
70
+
71
+ if run_spell and self._spell_checker and self.symspell_ready:
72
+ spell_result = self._spell_checker.correct(current)
73
+ if spell_result["corrected"] != current:
74
+ diffs = build_word_diffs(current, spell_result["corrected"], "spell")
75
+ all_diffs.extend(diffs)
76
+ spell_fixed = spell_result["fixes"]
77
+ current = spell_result["corrected"]
78
+
79
+ if run_homophones and self._homophone_resolver:
80
+ hom_result = self._homophone_resolver.resolve(current)
81
+ if hom_result["corrected"] != current:
82
+ diffs = build_word_diffs(current, hom_result["corrected"], "homophone")
83
+ all_diffs.extend(diffs)
84
+ homophone_fixed = hom_result["fixes"]
85
+ current = hom_result["corrected"]
86
+
87
+ if run_grammar and self._grammar_corrector:
88
+ sentences = split_into_sentences(current)
89
+ corrected_sentences = []
90
+ for sent in sentences:
91
+ gram_result = self._grammar_corrector.correct(sent)
92
+ corrected_sentences.append(gram_result["corrected"])
93
+ grammar_fixed += gram_result["fixes"]
94
+
95
+ grammar_text = " ".join(corrected_sentences)
96
+ if grammar_text != current:
97
+ diffs = build_word_diffs(current, grammar_text, "grammar")
98
+ all_diffs.extend(diffs)
99
+ current = grammar_text
100
+
101
+ confidence = 1.0
102
+ if self._scorer:
103
+ confidence = self._scorer.score(original, current)
104
+
105
+ return {
106
+ "corrected": current,
107
+ "spell_fixed": spell_fixed,
108
+ "grammar_fixed": grammar_fixed,
109
+ "homophone_fixed": homophone_fixed,
110
+ "confidence": round(confidence, 3),
111
+ "diffs": all_diffs,
112
+ }
113
+
114
+ async def refine(self, text: str, style: str = "professional") -> dict:
115
+ loop = asyncio.get_event_loop()
116
+ return await loop.run_in_executor(None, self._refine_sync, text, style)
117
+
118
+ def _refine_sync(self, text: str, style: str) -> dict:
119
+ if self._grammar_corrector:
120
+ return self._grammar_corrector.refine(text, style)
121
+ return {"refined": text, "improvements": ["Model not loaded; no refinement applied."]}
backend/app/pipeline/scorer.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app/pipeline/scorer.py
3
+ ───────────────────────
4
+ Confidence scoring for the correction pipeline.
5
+
6
+ Produces a value in [0, 1] reflecting how much the text changed and
7
+ whether the changes seem linguistically reasonable.
8
+
9
+ Heuristics used:
10
+ • Normalised edit distance between original and corrected
11
+ • Token overlap ratio (Jaccard)
12
+ • spaCy entity preservation check (named entities should survive)
13
+ • Penalty if corrected text is much longer / shorter than original
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import math
19
+ import re
20
+
21
+ from loguru import logger
22
+
23
+
24
+ class Scorer:
25
+ def __init__(self, nlp=None):
26
+ """
27
+ :param nlp: spaCy Language model (optional but improves scoring).
28
+ """
29
+ self._nlp = nlp
30
+
31
+ def score(self, original: str, corrected: str) -> float:
32
+ """Return a confidence score in [0.0, 1.0]."""
33
+ if not original or not corrected:
34
+ return 1.0
35
+
36
+ if original == corrected:
37
+ return 1.0
38
+
39
+ try:
40
+ sim = self._token_similarity(original, corrected)
41
+ ratio = self._length_ratio(original, corrected)
42
+ ent = self._entity_preservation(original, corrected)
43
+
44
+ # Weighted average
45
+ score = 0.5 * sim + 0.25 * ratio + 0.25 * ent
46
+ return max(0.0, min(1.0, score))
47
+ except Exception as e:
48
+ logger.warning(f"Scorer error: {e}")
49
+ return 0.85 # safe fallback
50
+
51
+ # ── Sub-metrics ────────────────────────────────────────────────────────────
52
+
53
+ @staticmethod
54
+ def _token_similarity(a: str, b: str) -> float:
55
+ """Jaccard similarity on word-token sets."""
56
+ ta = set(re.findall(r"\b\w+\b", a.lower()))
57
+ tb = set(re.findall(r"\b\w+\b", b.lower()))
58
+ if not ta and not tb:
59
+ return 1.0
60
+ intersection = len(ta & tb)
61
+ union = len(ta | tb)
62
+ return intersection / union if union else 1.0
63
+
64
+ @staticmethod
65
+ def _length_ratio(a: str, b: str) -> float:
66
+ """
67
+ Penalises large length differences.
68
+ Returns 1.0 when lengths match, decays toward 0 as ratio diverges.
69
+ """
70
+ la, lb = len(a.split()), len(b.split())
71
+ if la == 0:
72
+ return 1.0
73
+ ratio = min(la, lb) / max(la, lb)
74
+ # Apply mild sigmoid to soften extreme cases
75
+ return 1 / (1 + math.exp(-10 * (ratio - 0.5)))
76
+
77
+ def _entity_preservation(self, a: str, b: str) -> float:
78
+ """
79
+ Check that named entities in the original survive in the corrected text.
80
+ Falls back to 1.0 if spaCy is unavailable.
81
+ """
82
+ if not self._nlp:
83
+ return 1.0
84
+
85
+ try:
86
+ doc_a = self._nlp(a)
87
+ doc_b = self._nlp(b)
88
+ ents_a = {ent.text.lower() for ent in doc_a.ents}
89
+ ents_b = {ent.text.lower() for ent in doc_b.ents}
90
+
91
+ if not ents_a:
92
+ return 1.0
93
+
94
+ preserved = len(ents_a & ents_b)
95
+ return preserved / len(ents_a)
96
+ except Exception:
97
+ return 1.0
backend/app/pipeline/spell_checker.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ import re
3
+ from pathlib import Path
4
+ from loguru import logger
5
+ from app.config import Settings
6
+
7
+ try:
8
+ from symspellpy import SymSpell, Verbosity
9
+ SYMSPELL_AVAILABLE = True
10
+ except ImportError:
11
+ SYMSPELL_AVAILABLE = False
12
+ logger.warning("symspellpy not installed.")
13
+
14
+ _SKIP_PATTERNS = re.compile(r"^https?://|^www\.\d|^[A-Z]{2,}$|^[A-Z][a-z]+[A-Z]", re.VERBOSE)
15
+
16
+ class SpellChecker:
17
+ def __init__(self, settings: Settings):
18
+ self._settings = settings
19
+ self._sym: SymSpell | None = None
20
+
21
+ def load(self) -> bool:
22
+ if not SYMSPELL_AVAILABLE: return False
23
+ self._sym = SymSpell(
24
+ max_dictionary_edit_distance=self._settings.spell_max_edit_distance,
25
+ prefix_length=self._settings.spell_prefix_length,
26
+ )
27
+ try:
28
+ import pkg_resources
29
+ freq_dict = pkg_resources.resource_filename("symspellpy", "frequency_dictionary_en_82_765.txt")
30
+ self._sym.load_dictionary(freq_dict, term_index=0, count_index=1)
31
+ return True
32
+ except Exception as e:
33
+ logger.error(f"Could not load dictionary: {e}")
34
+ return False
35
+
36
+ def correct(self, text: str) -> dict[str, object]:
37
+ if not self._sym: return {"corrected": text, "fixes": 0}
38
+ tokens = re.split(r"(\s+)", text)
39
+ fixes = 0
40
+ out_tokens = []
41
+ for token in tokens:
42
+ if re.match(r"^\s+$", token):
43
+ out_tokens.append(token)
44
+ continue
45
+ corrected_token = self._correct_token(token)
46
+ if corrected_token.lower() != token.lower(): fixes += 1
47
+ out_tokens.append(corrected_token)
48
+ return {"corrected": "".join(out_tokens), "fixes": fixes}
49
+
50
+ def _correct_token(self, token: str) -> str:
51
+ m = re.match(r"^([^a-zA-Z']*)([a-zA-Z']+)([^a-zA-Z']*)$", token)
52
+ if not m: return token
53
+ pre, word, post = m.group(1), m.group(2), m.group(3)
54
+ if len(word) <= self._settings.min_word_length_for_spell or _SKIP_PATTERNS.match(token):
55
+ return token
56
+ suggestions = self._sym.lookup(word.lower(), Verbosity.CLOSEST, max_edit_distance=self._settings.spell_max_edit_distance, include_unknown=True)
57
+ if not suggestions: return token
58
+ best = suggestions[0].term
59
+ if word.isupper(): best = best.upper()
60
+ elif word[0].isupper(): best = best.capitalize()
61
+ return pre + best + post
backend/app/resources/dictionary.txt ADDED
@@ -0,0 +1,895 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ the 23135851162
2
+ be 13047913334
3
+ to 12136980858
4
+ of 11820836747
5
+ and 10989497138
6
+ a 10427512430
7
+ in 8507281891
8
+ that 7517730288
9
+ have 6332185948
10
+ it 5430541382
11
+ for 4997722092
12
+ not 4801285117
13
+ on 4393142827
14
+ with 4029515580
15
+ he 3823871886
16
+ as 3427985255
17
+ you 3196816182
18
+ do 3131510655
19
+ at 2869637029
20
+ this 2845892349
21
+ but 2680153038
22
+ his 2667609292
23
+ by 2543163226
24
+ from 2374401740
25
+ they 2232267448
26
+ we 2109019013
27
+ say 2034311816
28
+ her 1987261827
29
+ she 1887671418
30
+ or 1883558688
31
+ an 1877617239
32
+ will 1871894427
33
+ my 1844518463
34
+ one 1784649037
35
+ all 1764372325
36
+ would 1753396418
37
+ there 1697748860
38
+ their 1692028469
39
+ what 1672397224
40
+ so 1614701173
41
+ up 1606261547
42
+ out 1585019526
43
+ if 1539994854
44
+ about 1486618582
45
+ who 1456791147
46
+ get 1432069524
47
+ which 1406168949
48
+ go 1399748023
49
+ me 1389376087
50
+ when 1378090840
51
+ make 1370462898
52
+ can 1358527726
53
+ like 1337416424
54
+ time 1307368028
55
+ no 1306069636
56
+ just 1285200450
57
+ him 1258736792
58
+ know 1255891088
59
+ take 1217432073
60
+ people 1200748066
61
+ into 1195302738
62
+ year 1155716074
63
+ your 1148824302
64
+ good 1100481453
65
+ some 1087131892
66
+ could 1072940878
67
+ them 1069048218
68
+ see 1062551219
69
+ other 1059528497
70
+ than 1050780447
71
+ then 1044044680
72
+ now 1036888474
73
+ look 1035090920
74
+ only 1024898714
75
+ come 1021688283
76
+ its 1021009688
77
+ over 1005887756
78
+ think 993978026
79
+ also 979960350
80
+ back 973524093
81
+ after 963920424
82
+ use 957289006
83
+ two 954393614
84
+ how 949977754
85
+ our 949463625
86
+ work 928975027
87
+ first 925920870
88
+ well 920406394
89
+ way 914476578
90
+ even 911090614
91
+ new 907988449
92
+ want 907234302
93
+ because 896476892
94
+ any 887671451
95
+ these 883173820
96
+ give 869832637
97
+ day 860024628
98
+ most 844543003
99
+ us 840124234
100
+ great 823721764
101
+ between 786614100
102
+ need 783609951
103
+ large 780695513
104
+ often 778690396
105
+ hand 776503568
106
+ high 770406359
107
+ place 764700677
108
+ hold 762248718
109
+ world 761118640
110
+ found 751019296
111
+ still 742064648
112
+ learn 737700100
113
+ plant 736609716
114
+ cover 729513282
115
+ food 726600130
116
+ sun 724421042
117
+ four 722019754
118
+ thought 719717224
119
+ let 718813948
120
+ keep 718064716
121
+ children 711264132
122
+ feet 710006048
123
+ side 706012284
124
+ without 702624438
125
+ boy 700607012
126
+ once 699700064
127
+ animal 698219120
128
+ life 697721780
129
+ enough 696026928
130
+ took 694399756
131
+ sometimes 693234860
132
+ four 692567868
133
+ always 691455888
134
+ those 690866928
135
+ both 688813748
136
+ paper 685820464
137
+ together 682924156
138
+ got 681217700
139
+ group 677802320
140
+ often 674997004
141
+ run 674344416
142
+ important 673613156
143
+ until 673163588
144
+ children 671694308
145
+ side 669965152
146
+ feet 667614564
147
+ car 665900220
148
+ mile 662823440
149
+ night 661444056
150
+ walk 659888340
151
+ white 658869392
152
+ sea 656992164
153
+ began 654853664
154
+ grow 651904028
155
+ took 649677476
156
+ river 648718488
157
+ four 646714148
158
+ carry 645818732
159
+ state 644703476
160
+ once 641844164
161
+ book 640803592
162
+ hear 639854912
163
+ stop 637718860
164
+ without 636892820
165
+ second 635671556
166
+ later 633894900
167
+ miss 633058484
168
+ idea 629874540
169
+ enough 628729848
170
+ eat 628047748
171
+ face 625890232
172
+ watch 623762768
173
+ far 622873316
174
+ Indian 620952404
175
+ really 619726348
176
+ almost 617891252
177
+ let 617185284
178
+ above 616553032
179
+ girl 616166684
180
+ sometimes 615780032
181
+ mountain 614668660
182
+ cut 613836796
183
+ young 613021752
184
+ talk 611744940
185
+ soon 610804936
186
+ list 609928508
187
+ song 609066436
188
+ leave 608153748
189
+ family 607362272
190
+ body 606613452
191
+ music 605788456
192
+ color 604998232
193
+ stand 604159636
194
+ sun 603334504
195
+ questions 602582212
196
+ fish 601791140
197
+ area 600994260
198
+ mark 600258948
199
+ dog 599554380
200
+ horse 599083596
201
+ birds 598548028
202
+ problem 598131716
203
+ complete 597605484
204
+ room 597091200
205
+ knew 596633136
206
+ since 596222892
207
+ ever 595769780
208
+ piece 595346760
209
+ told 594982420
210
+ usually 594566356
211
+ didn't 594106232
212
+ friends 593691736
213
+ easy 593246828
214
+ heard 592828048
215
+ order 592464296
216
+ red 592064960
217
+ door 591672108
218
+ sure 591296616
219
+ become 590927236
220
+ top 590604420
221
+ ship 590244476
222
+ across 589899980
223
+ today 589511692
224
+ during 589159356
225
+ short 588804860
226
+ better 588444984
227
+ best 588101696
228
+ however 587748032
229
+ low 587401632
230
+ hours 587055892
231
+ black 586706432
232
+ products 586363764
233
+ happened 586019740
234
+ whole 585671868
235
+ measure 585325120
236
+ remember 584978560
237
+ early 584632540
238
+ waves 584289648
239
+ reached 583945716
240
+ listen 583602752
241
+ wind 583258072
242
+ rock 582907720
243
+ space 582555248
244
+ covered 582212892
245
+ fast 581866844
246
+ several 581517780
247
+ hold 581175800
248
+ himself 580826272
249
+ toward 580475856
250
+ five 580130312
251
+ step 579774028
252
+ morning 579425152
253
+ passed 579071064
254
+ vowel 578717452
255
+ true 578366580
256
+ hundred 578014996
257
+ against 577662480
258
+ pattern 577310128
259
+ numeral 576956796
260
+ table 576603896
261
+ north 576252116
262
+ slowly 575899252
263
+ money 575547488
264
+ map 575194152
265
+ farm 574842136
266
+ pulled 574489040
267
+ draw 574136388
268
+ voice 573785576
269
+ seen 573429900
270
+ cold 573073872
271
+ cried 572717064
272
+ plan 572360400
273
+ notice 572005016
274
+ south 571651600
275
+ sing 571291780
276
+ war 570935772
277
+ ground 570579996
278
+ fall 570224008
279
+ king 569869408
280
+ town 569512648
281
+ I'll 569157764
282
+ unit 568799064
283
+ figure 568441940
284
+ certain 568082564
285
+ field 567723120
286
+ travel 567361284
287
+ wood 566999220
288
+ fire 566637916
289
+ upon 566279744
290
+ done 565920200
291
+ english 565562392
292
+ road 565201428
293
+ half 564842644
294
+ ten 564484760
295
+ fly 564126892
296
+ gave 563768060
297
+ box 563404256
298
+ finally 563039940
299
+ wait 562678600
300
+ correct 562318528
301
+ oh 561959312
302
+ quickly 561600892
303
+ person 561244988
304
+ became 560885480
305
+ shown 560527592
306
+ minutes 560167728
307
+ strong 559807348
308
+ verb 559448128
309
+ stars 559087948
310
+ front 558729664
311
+ feel 558373696
312
+ fact 558014396
313
+ inches 557657052
314
+ street 557300988
315
+ decided 556943060
316
+ contain 556584664
317
+ course 556225468
318
+ surface 555867668
319
+ produce 555509372
320
+ building 555150732
321
+ ocean 554791732
322
+ class 554431916
323
+ note 554073196
324
+ nothing 553716480
325
+ rest 553358852
326
+ carefully 552999216
327
+ scientists 552641584
328
+ inside 552283916
329
+ wheels 551926252
330
+ stay 551566984
331
+ green 551207460
332
+ known 550850896
333
+ island 550491624
334
+ week 550132164
335
+ less 549772408
336
+ machine 549414804
337
+ base 549055504
338
+ ago 548696844
339
+ stood 548337256
340
+ plane 547977044
341
+ system 547617852
342
+ behind 547258744
343
+ ran 546899200
344
+ round 546541936
345
+ boat 546179756
346
+ game 545818712
347
+ force 545459288
348
+ brought 545098352
349
+ understand 544739948
350
+ warm 544380200
351
+ common 544022632
352
+ bring 543661584
353
+ explain 543302420
354
+ dry 542942852
355
+ though 542581844
356
+ language 542219920
357
+ shape 541857988
358
+ deep 541496644
359
+ thousands 541136236
360
+ yes 540775528
361
+ clear 540413760
362
+ equation 540052996
363
+ yet 539692432
364
+ government 539330772
365
+ filled 538970244
366
+ heat 538607908
367
+ full 538246484
368
+ hot 537886088
369
+ check 537525936
370
+ object 537163292
371
+ am 536803048
372
+ rule 536440272
373
+ among 536077940
374
+ noun 535717120
375
+ power 535356968
376
+ cannot 534994008
377
+ able 534633980
378
+ six 534271564
379
+ size 533910308
380
+ dark 533547024
381
+ ball 533183972
382
+ material 532820692
383
+ special 532459644
384
+ heavy 532095060
385
+ fine 531733068
386
+ pair 531372900
387
+ circle 531009652
388
+ include 530648488
389
+ built 530287024
390
+ can't 529925548
391
+ matter 529562048
392
+ square 529200388
393
+ syllables 528838724
394
+ perhaps 528477836
395
+ bill 528116548
396
+ felt 527753172
397
+ suddenly 527391448
398
+ test 527028532
399
+ direction 526665760
400
+ center 526301848
401
+ farmers 525939996
402
+ ready 525578148
403
+ anything 525218304
404
+ divided 524853376
405
+ general 524489048
406
+ energy 524125216
407
+ subject 523762404
408
+ europe 523400652
409
+ moon 523037604
410
+ region 522676888
411
+ return 522315040
412
+ believe 521952832
413
+ dance 521589588
414
+ members 521228292
415
+ picked 520866016
416
+ simple 520503728
417
+ cells 520140996
418
+ paint 519777624
419
+ mind 519415140
420
+ love 519051456
421
+ cause 518688676
422
+ rain 518325012
423
+ exercise 517962492
424
+ eggs 517600240
425
+ train 517237636
426
+ blue 516876888
427
+ wish 516513900
428
+ drop 516149264
429
+ developed 515784516
430
+ window 515419228
431
+ difference 515055696
432
+ distance 514690948
433
+ heart 514327984
434
+ sit 513961712
435
+ sum 513597336
436
+ summer 513234612
437
+ wall 512868516
438
+ forest 512503916
439
+ probably 512138360
440
+ legs 511773056
441
+ sat 511407804
442
+ main 511042460
443
+ winter 510677616
444
+ wide 510311248
445
+ written 509945776
446
+ length 509579840
447
+ reason 509213680
448
+ kept 508847220
449
+ interest 508480492
450
+ arms 508113400
451
+ brother 507744596
452
+ race 507377948
453
+ present 507011180
454
+ beautiful 506643336
455
+ store 506276936
456
+ job 505908980
457
+ edge 505541908
458
+ past 505175076
459
+ sign 504807444
460
+ record 504440428
461
+ finished 504073400
462
+ discovered 503706184
463
+ wild 503339256
464
+ happy 502972712
465
+ beside 502605196
466
+ gone 502237296
467
+ sky 501869640
468
+ glass 501500148
469
+ million 501131780
470
+ west 500764716
471
+ lay 500397076
472
+ weather 500028908
473
+ root 499660800
474
+ instruments 499293208
475
+ meet 498925992
476
+ third 498557980
477
+ months 498190164
478
+ paragraph 497822648
479
+ raised 497455636
480
+ represent 497088108
481
+ soft 496720608
482
+ whether 496352788
483
+ clothes 495984316
484
+ flowers 495615852
485
+ shall 495247236
486
+ teacher 494879996
487
+ held 494512652
488
+ describe 494143624
489
+ drive 493775664
490
+ cross 493407920
491
+ speak 493041076
492
+ solve 492672464
493
+ appear 492303868
494
+ metal 491935772
495
+ son 491568360
496
+ either 491200224
497
+ ice 490831436
498
+ sleep 490463480
499
+ village 490095524
500
+ factors 489726228
501
+ result 489358376
502
+ jumped 488990528
503
+ snow 488621496
504
+ ride 488252248
505
+ care 487883220
506
+ floor 487515472
507
+ hill 487146824
508
+ pushed 486778252
509
+ baby 486409208
510
+ buy 486040756
511
+ century 485672492
512
+ outside 485303832
513
+ everything 484935188
514
+ tall 484566892
515
+ already 484198776
516
+ instead 483829804
517
+ phrase 483461176
518
+ soil 483092848
519
+ bed 482724572
520
+ copy 482354744
521
+ free 481985832
522
+ hope 481617916
523
+ spring 481248104
524
+ case 480878852
525
+ laughed 480510684
526
+ nation 480142828
527
+ quite 479772372
528
+ type 479403892
529
+ themselves 479034680
530
+ temperature 478665640
531
+ bright 478296560
532
+ lead 477927832
533
+ everyone 477558236
534
+ method 477189444
535
+ section 476821032
536
+ lake 476451848
537
+ iron 476082196
538
+ within 475713360
539
+ dictionary 475344392
540
+ hair 474975104
541
+ age 474605764
542
+ amount 474235432
543
+ scale 473866116
544
+ pounds 473497096
545
+ although 473127800
546
+ per 472758432
547
+ broken 472389300
548
+ moment 472019308
549
+ tiny 471649924
550
+ possible 471280244
551
+ gold 470910612
552
+ milk 470541360
553
+ quiet 470171948
554
+ natural 469801408
555
+ lot 469432608
556
+ stone 469063096
557
+ act 468693396
558
+ build 468323128
559
+ middle 467954420
560
+ speed 467584564
561
+ count 467215072
562
+ consonant 466845616
563
+ someone 466475952
564
+ sail 466107088
565
+ rolled 465737464
566
+ bear 465367228
567
+ wonder 464997516
568
+ smiled 464628244
569
+ angle 464258324
570
+ fraction 463888872
571
+ Africa 463519868
572
+ killed 463149948
573
+ melody 462779168
574
+ bottom 462409964
575
+ trip 462040292
576
+ hole 461671208
577
+ poor 461300716
578
+ let's 460930092
579
+ fight 460560364
580
+ surprise 460191492
581
+ French 459820880
582
+ died 459451392
583
+ beat 459081604
584
+ exactly 458712296
585
+ remain 458342252
586
+ dress 457972960
587
+ iron 457601668
588
+ couldn't 457231848
589
+ fingers 456862172
590
+ row 456492656
591
+ least 456122380
592
+ catch 455753132
593
+ climbed 455383592
594
+ wrote 455013208
595
+ shouted 454643748
596
+ continued 454273616
597
+ itself 453903680
598
+ else 453534260
599
+ plains 453163872
600
+ gas 452793528
601
+ England 452424080
602
+ burning 452053568
603
+ design 451683624
604
+ joined 451314640
605
+ foot 450944824
606
+ law 450575116
607
+ ears 450204680
608
+ grass 449833888
609
+ you've 449463864
610
+ grown 449094448
611
+ valley 448723892
612
+ cents 448354388
613
+ key 447983408
614
+ president 447612796
615
+ brown 447241560
616
+ trouble 446870992
617
+ cool 446501360
618
+ cloud 446130768
619
+ lost 445760224
620
+ sent 445390672
621
+ symbols 445019456
622
+ wear 444648864
623
+ bad 444278960
624
+ save 443908880
625
+ experiment 443539180
626
+ engine 443168968
627
+ alone 442798532
628
+ drawing 442428400
629
+ east 442058972
630
+ pay 441689728
631
+ single 441319108
632
+ touch 440948836
633
+ information 440578736
634
+ express 440209572
635
+ mouth 439839408
636
+ yard 439470116
637
+ equal 439099752
638
+ decimal 438728664
639
+ yourself 438358360
640
+ control 437988168
641
+ practice 437617932
642
+ report 437248120
643
+ straight 436877656
644
+ rise 436507184
645
+ statement 436137448
646
+ stick 435767216
647
+ party 435396484
648
+ seeds 435026988
649
+ suppose 434656328
650
+ woman 434286064
651
+ coast 433915556
652
+ bank 433544100
653
+ period 433174420
654
+ wire 432803184
655
+ choose 432432680
656
+ clean 432062528
657
+ visit 431691284
658
+ bit 431320996
659
+ whose 430951008
660
+ received 430579948
661
+ garden 430208796
662
+ please 429838672
663
+ strange 429467512
664
+ caught 429097724
665
+ fell 428726576
666
+ team 428356424
667
+ God 427985192
668
+ captain 427614060
669
+ direct 427244240
670
+ ring 426872872
671
+ serve 426501616
672
+ child 426131516
673
+ desert 425760532
674
+ increase 425389496
675
+ history 425018456
676
+ cost 424647628
677
+ maybe 424277064
678
+ business 423906212
679
+ separate 423534992
680
+ break 423164840
681
+ uncle 422793864
682
+ hunting 422423624
683
+ flow 422053016
684
+ lady 421681936
685
+ students 421311076
686
+ human 420940276
687
+ art 420568952
688
+ feeling 420198032
689
+ supply 419828616
690
+ corner 419457300
691
+ electric 419085348
692
+ insects 418714608
693
+ crops 418343876
694
+ tone 417972872
695
+ hit 417601588
696
+ sand 417231284
697
+ doctor 416860296
698
+ provide 416489124
699
+ thus 416118936
700
+ won't 415747944
701
+ cook 415376548
702
+ bones 415006040
703
+ tail 414634484
704
+ board 414263892
705
+ modern 413893892
706
+ compound 413523432
707
+ mine 413151964
708
+ wasn't 412780536
709
+ fit 412410156
710
+ addition 412038444
711
+ belong 411667816
712
+ safe 411295872
713
+ soldiers 410924888
714
+ guess 410553288
715
+ silent 410182580
716
+ trade 409811500
717
+ rather 409440700
718
+ compare 409069888
719
+ crowd 408698048
720
+ poem 408327368
721
+ enjoy 407955688
722
+ elements 407584560
723
+ indicate 407213504
724
+ except 406841800
725
+ expect 406470912
726
+ flat 406100000
727
+ seven 405728488
728
+ interesting 405357304
729
+ sense 404985512
730
+ string 404614260
731
+ blow 404242680
732
+ famous 403871996
733
+ value 403501188
734
+ wings 403130436
735
+ movement 402759128
736
+ pole 402387968
737
+ exciting 402016852
738
+ branches 401645984
739
+ thick 401274296
740
+ blood 400903824
741
+ lie 400532840
742
+ spot 400160352
743
+ bell 399789892
744
+ fun 399418224
745
+ loud 399047888
746
+ consider 398676416
747
+ suggested 398305432
748
+ thin 397934780
749
+ position 397563940
750
+ entered 397192676
751
+ fruit 396821220
752
+ tied 396450044
753
+ rich 396078636
754
+ dollars 395707812
755
+ send 395336596
756
+ sight 394965488
757
+ chief 394594144
758
+ Japanese 394223504
759
+ stream 393852160
760
+ planets 393480788
761
+ rhythm 393110308
762
+ eight 392738876
763
+ science 392367880
764
+ major 391996932
765
+ observe 391626476
766
+ tube 391254728
767
+ necessary 390883884
768
+ weight 390512976
769
+ meat 390141884
770
+ lifted 389770756
771
+ process 389399780
772
+ army 389029072
773
+ hat 388658228
774
+ property 388287052
775
+ particular 387915720
776
+ swim 387544248
777
+ terms 387172936
778
+ current 386801936
779
+ park 386430704
780
+ sell 386059576
781
+ shoulder 385688448
782
+ industry 385317176
783
+ wash 384945612
784
+ block 384574464
785
+ spread 384203016
786
+ cattle 383832208
787
+ wife 383460632
788
+ sharp 383089344
789
+ company 382717680
790
+ radio 382346380
791
+ we'll 381975796
792
+ action 381603976
793
+ capital 381232508
794
+ factories 380861432
795
+ settled 380490368
796
+ yellow 380119624
797
+ isn't 379748000
798
+ southern 379375720
799
+ truck 379004428
800
+ fair 378633048
801
+ printed 378262296
802
+ wouldn't 377889848
803
+ ahead 377518720
804
+ chance 377147408
805
+ born 376776056
806
+ level 376404856
807
+ triangle 376034012
808
+ molecules 375663200
809
+ France 375292312
810
+ repeated 374920612
811
+ column 374549440
812
+ western 374178948
813
+ church 373808132
814
+ sister 373437012
815
+ oxygen 373065548
816
+ plural 372693852
817
+ various 372323512
818
+ agreed 371952880
819
+ opposite 371580688
820
+ wrong 371210492
821
+ chart 370839456
822
+ prepared 370469348
823
+ pretty 370097916
824
+ solution 369726104
825
+ fresh 369355832
826
+ shop 368983776
827
+ suffix 368612256
828
+ especially 368242104
829
+ shoes 367871052
830
+ actually 367499800
831
+ nose 367128332
832
+ afraid 366756584
833
+ dead 366386024
834
+ sugar 366015500
835
+ adjective 365644020
836
+ fig 365273136
837
+ office 364901824
838
+ huge 364530552
839
+ gun 364160236
840
+ similar 363789520
841
+ death 363418140
842
+ score 363047164
843
+ forward 362676124
844
+ stretched 362305504
845
+ experience 361933864
846
+ rose 361562348
847
+ allow 361191660
848
+ fear 360820492
849
+ workers 360449476
850
+ Washington 360079584
851
+ Greek 359708280
852
+ women 359336900
853
+ bought 358965544
854
+ led 358594908
855
+ march 358223364
856
+ northern 357852648
857
+ create 357481068
858
+ British 357110100
859
+ difficult 356738832
860
+ match 356367268
861
+ win 355996800
862
+ doesn't 355625856
863
+ steel 355254496
864
+ total 354883304
865
+ deal 354512468
866
+ determine 354140864
867
+ evening 353769640
868
+ nor 353399080
869
+ rope 353028004
870
+ cotton 352656832
871
+ apple 352285916
872
+ details 351914264
873
+ entire 351543272
874
+ corn 351172404
875
+ substances 350801548
876
+ smell 350430560
877
+ tools 350059424
878
+ conditions 349688528
879
+ cows 349317484
880
+ track 348946472
881
+ arrived 348575176
882
+ located 348204144
883
+ sir 347833164
884
+ seat 347462200
885
+ division 347090976
886
+ effect 346720048
887
+ underline 346349048
888
+ view 345978584
889
+ recent 345607096
890
+ urban 345236356
891
+ easily 344865840
892
+ difficult 344493608
893
+ object 344122600
894
+ measure 343751376
895
+ science 343380480
backend/app/resources/homophones.json ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_comment": "Each key is a word; value is its homophone group. Used by HomophoneResolver.",
3
+
4
+ "their": ["their", "there", "they're"],
5
+ "there": ["their", "there", "they're"],
6
+ "they're": ["their", "there", "they're"],
7
+
8
+ "your": ["your", "you're"],
9
+ "you're": ["your", "you're"],
10
+
11
+ "its": ["its", "it's"],
12
+ "it's": ["its", "it's"],
13
+
14
+ "to": ["to", "too", "two"],
15
+ "too": ["to", "too", "two"],
16
+ "two": ["to", "too", "two"],
17
+
18
+ "then": ["then", "than"],
19
+ "than": ["then", "than"],
20
+
21
+ "affect": ["affect", "effect"],
22
+ "effect": ["affect", "effect"],
23
+
24
+ "lose": ["lose", "loose"],
25
+ "loose": ["lose", "loose"],
26
+
27
+ "accept": ["accept", "except"],
28
+ "except": ["accept", "except"],
29
+
30
+ "weather": ["weather", "whether"],
31
+ "whether": ["weather", "whether"],
32
+
33
+ "principal": ["principal", "principle"],
34
+ "principle": ["principal", "principle"],
35
+
36
+ "complement": ["complement", "compliment"],
37
+ "compliment": ["complement", "compliment"],
38
+
39
+ "stationary": ["stationary", "stationery"],
40
+ "stationery": ["stationary", "stationery"],
41
+
42
+ "bare": ["bare", "bear"],
43
+ "bear": ["bare", "bear"],
44
+
45
+ "pore": ["pore", "pour", "poor"],
46
+ "pour": ["pore", "pour", "poor"],
47
+ "poor": ["pore", "pour", "poor"],
48
+
49
+ "brake": ["brake", "break"],
50
+ "break": ["brake", "break"],
51
+
52
+ "capital": ["capital", "capitol"],
53
+ "capitol": ["capital", "capitol"],
54
+
55
+ "cite": ["cite", "sight", "site"],
56
+ "sight": ["cite", "sight", "site"],
57
+ "site": ["cite", "sight", "site"],
58
+
59
+ "coarse": ["coarse", "course"],
60
+ "course": ["coarse", "course"],
61
+
62
+ "council": ["council", "counsel"],
63
+ "counsel": ["council", "counsel"],
64
+
65
+ "desert": ["desert", "dessert"],
66
+ "dessert": ["desert", "dessert"],
67
+
68
+ "discreet": ["discreet", "discrete"],
69
+ "discrete": ["discreet", "discrete"],
70
+
71
+ "elicit": ["elicit", "illicit"],
72
+ "illicit": ["elicit", "illicit"],
73
+
74
+ "eminent": ["eminent", "imminent"],
75
+ "imminent": ["eminent", "imminent"],
76
+
77
+ "flair": ["flair", "flare"],
78
+ "flare": ["flair", "flare"],
79
+
80
+ "forward": ["forward", "foreword"],
81
+ "foreword": ["forward", "foreword"],
82
+
83
+ "formally": ["formally", "formerly"],
84
+ "formerly": ["formally", "formerly"],
85
+
86
+ "gorilla": ["gorilla", "guerrilla"],
87
+ "guerrilla": ["gorilla", "guerrilla"],
88
+
89
+ "led": ["led", "lead"],
90
+ "lead": ["led", "lead"],
91
+
92
+ "lightening": ["lightening", "lightning"],
93
+ "lightning": ["lightening", "lightning"],
94
+
95
+ "moral": ["moral", "morale"],
96
+ "morale": ["moral", "morale"],
97
+
98
+ "passed": ["passed", "past"],
99
+ "past": ["passed", "past"],
100
+
101
+ "peace": ["peace", "piece"],
102
+ "piece": ["peace", "piece"],
103
+
104
+ "peak": ["peak", "peek", "pique"],
105
+ "peek": ["peak", "peek", "pique"],
106
+ "pique": ["peak", "peek", "pique"],
107
+
108
+ "plain": ["plain", "plane"],
109
+ "plane": ["plain", "plane"],
110
+
111
+ "pray": ["pray", "prey"],
112
+ "prey": ["pray", "prey"],
113
+
114
+ "right": ["right", "write", "rite"],
115
+ "write": ["right", "write", "rite"],
116
+ "rite": ["right", "write", "rite"],
117
+
118
+ "role": ["role", "roll"],
119
+ "roll": ["role", "roll"],
120
+
121
+ "scene": ["scene", "seen"],
122
+ "seen": ["scene", "seen"],
123
+
124
+ "steak": ["steak", "stake"],
125
+ "stake": ["steak", "stake"],
126
+
127
+ "suite": ["suite", "sweet"],
128
+ "sweet": ["suite", "sweet"],
129
+
130
+ "vain": ["vain", "vane", "vein"],
131
+ "vane": ["vain", "vane", "vein"],
132
+ "vein": ["vain", "vane", "vein"],
133
+
134
+ "waste": ["waste", "waist"],
135
+ "waist": ["waste", "waist"],
136
+
137
+ "who's": ["who's", "whose"],
138
+ "whose": ["who's", "whose"],
139
+
140
+ "which": ["which", "witch"],
141
+ "witch": ["which", "witch"]
142
+ }
backend/app/utils/__init__.py ADDED
File without changes
backend/app/utils/text_utils.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app/utils/text_utils.py
3
+ ────────────────────────
4
+ Shared text processing helpers used across the pipeline.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ import unicodedata
11
+
12
+
13
+ # ── Whitespace normalisation ──────────────────────────────────────────────────
14
+
15
+ def normalise_whitespace(text: str) -> str:
16
+ """Collapse multiple spaces / tabs into one; strip leading/trailing."""
17
+ text = unicodedata.normalize("NFKC", text) # normalise unicode
18
+ text = re.sub(r"[ \t]+", " ", text) # collapse horizontal space
19
+ text = re.sub(r"\n{3,}", "\n\n", text) # max two consecutive newlines
20
+ return text.strip()
21
+
22
+
23
+ # ── Sentence splitting ────────────────────────────────────────────────────────
24
+
25
+ _SENT_BOUNDARY = re.compile(
26
+ r"(?<=[.!?])\s+(?=[A-Z])" # punctuation followed by capital
27
+ r"|(?<=[.!?])\s*$" # punctuation at end of string
28
+ )
29
+
30
+
31
+ def split_into_sentences(text: str) -> list[str]:
32
+ """
33
+ Naive but fast sentence splitter.
34
+ Falls back gracefully for texts without clear boundaries.
35
+ """
36
+ # Split on sentence-ending punctuation followed by a space and capital
37
+ parts = re.split(r"(?<=[.!?])\s+(?=[A-Z\"\'])", text)
38
+ # Filter empty strings
39
+ return [p.strip() for p in parts if p.strip()]
40
+
41
+
42
+ # ── Word-level diff ───────────────────────────────────────────────────────────
43
+
44
+ def build_word_diffs(
45
+ original: str,
46
+ corrected: str,
47
+ diff_type: str,
48
+ ) -> list[dict]:
49
+ """
50
+ Simple LCS-based word-level diff.
51
+ Returns a list of {original, corrected, type, position} dicts.
52
+ """
53
+ ow = original.split()
54
+ cw = corrected.split()
55
+
56
+ # LCS DP table
57
+ m, n = len(ow), len(cw)
58
+ dp = [[0] * (n + 1) for _ in range(m + 1)]
59
+ for i in range(m - 1, -1, -1):
60
+ for j in range(n - 1, -1, -1):
61
+ if ow[i].lower() == cw[j].lower():
62
+ dp[i][j] = dp[i + 1][j + 1] + 1
63
+ else:
64
+ dp[i][j] = max(dp[i + 1][j], dp[i][j + 1])
65
+
66
+ diffs: list[dict] = []
67
+ i, j, pos = 0, 0, 0
68
+
69
+ while i < m or j < n:
70
+ if i < m and j < n and ow[i].lower() == cw[j].lower():
71
+ i += 1; j += 1; pos += 1
72
+ elif j < n and (i >= m or dp[i + 1][j] <= dp[i][j + 1]):
73
+ # Insertion in corrected
74
+ # Pair with previous deletion if it exists and merge into a substitution
75
+ if diffs and diffs[-1]["position"] == pos - 1 and diffs[-1]["corrected"] == "":
76
+ diffs[-1]["corrected"] = cw[j]
77
+ else:
78
+ diffs.append({
79
+ "original": "",
80
+ "corrected": cw[j],
81
+ "type": diff_type,
82
+ "position": pos,
83
+ })
84
+ j += 1
85
+ else:
86
+ diffs.append({
87
+ "original": ow[i],
88
+ "corrected": "",
89
+ "type": diff_type,
90
+ "position": pos,
91
+ })
92
+ i += 1; pos += 1
93
+
94
+ return diffs
95
+
96
+
97
+ def count_word_diffs(a: str, b: str) -> int:
98
+ """Count the number of differing word positions between two strings."""
99
+ wa, wb = a.split(), b.split()
100
+ count = abs(len(wa) - len(wb))
101
+ for x, y in zip(wa, wb):
102
+ if x.lower() != y.lower():
103
+ count += 1
104
+ return count
backend/requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.104.1
2
+ uvicorn==0.24.0.post1
3
+ pydantic==2.5.2
4
+ pydantic-settings==2.1.0
5
+ loguru==0.7.2
6
+ spacy==3.7.2
7
+ symspellpy==6.7.7
8
+ transformers==4.35.2
backend/tests/__init__.py ADDED
File without changes
backend/tests/test_pipeline.py ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ tests/test_pipeline.py
3
+ ───────────────────────
4
+ Unit + integration tests for the WriteRight NLP pipeline.
5
+ Run with: pytest tests/ -v
6
+ """
7
+
8
+ import pytest
9
+ import sys
10
+ import os
11
+
12
+ # Make sure app is importable
13
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
14
+
15
+ from app.utils.text_utils import (
16
+ normalise_whitespace,
17
+ split_into_sentences,
18
+ build_word_diffs,
19
+ count_word_diffs,
20
+ )
21
+ from app.pipeline.homophone_resolver import HomophoneResolver
22
+ from app.config import Settings
23
+
24
+
25
+ # ── Fixtures ──────────────────────────────────────────────────────────────────
26
+
27
+ @pytest.fixture
28
+ def settings():
29
+ return Settings()
30
+
31
+
32
+ @pytest.fixture
33
+ def homophone_resolver(settings):
34
+ resolver = HomophoneResolver(settings)
35
+ resolver.load()
36
+ return resolver
37
+
38
+
39
+ # ── text_utils tests ──────────────────────────────────────────────────────────
40
+
41
+ class TestNormaliseWhitespace:
42
+ def test_collapses_spaces(self):
43
+ assert normalise_whitespace("hello world") == "hello world"
44
+
45
+ def test_strips_leading_trailing(self):
46
+ assert normalise_whitespace(" hello ") == "hello"
47
+
48
+ def test_collapses_tabs(self):
49
+ assert normalise_whitespace("hello\t\tworld") == "hello world"
50
+
51
+ def test_preserves_single_newlines(self):
52
+ result = normalise_whitespace("line one\nline two")
53
+ assert "line one" in result and "line two" in result
54
+
55
+ def test_collapses_triple_newlines(self):
56
+ result = normalise_whitespace("a\n\n\n\nb")
57
+ assert "\n\n\n" not in result
58
+
59
+ def test_empty_string(self):
60
+ assert normalise_whitespace("") == ""
61
+
62
+ def test_only_whitespace(self):
63
+ assert normalise_whitespace(" \t ") == ""
64
+
65
+
66
+ class TestSplitIntoSentences:
67
+ def test_single_sentence(self):
68
+ sentences = split_into_sentences("Hello world.")
69
+ assert len(sentences) == 1
70
+
71
+ def test_two_sentences(self):
72
+ sentences = split_into_sentences("Hello world. My name is Alice.")
73
+ assert len(sentences) == 2
74
+
75
+ def test_question_marks(self):
76
+ sentences = split_into_sentences("How are you? I am fine.")
77
+ assert len(sentences) == 2
78
+
79
+ def test_exclamation(self):
80
+ sentences = split_into_sentences("Watch out! There is a bug.")
81
+ assert len(sentences) == 2
82
+
83
+ def test_no_punctuation(self):
84
+ sentences = split_into_sentences("a simple sentence without end")
85
+ assert len(sentences) >= 1
86
+
87
+ def test_empty_string(self):
88
+ sentences = split_into_sentences("")
89
+ assert sentences == []
90
+
91
+
92
+ class TestBuildWordDiffs:
93
+ def test_no_changes(self):
94
+ diffs = build_word_diffs("hello world", "hello world", "spell")
95
+ assert len(diffs) == 0
96
+
97
+ def test_single_substitution(self):
98
+ diffs = build_word_diffs("helo world", "hello world", "spell")
99
+ # Should detect the change
100
+ assert len(diffs) >= 1
101
+
102
+ def test_diff_type_preserved(self):
103
+ diffs = build_word_diffs("I goes home", "I go home", "grammar")
104
+ for d in diffs:
105
+ assert d["type"] == "grammar"
106
+
107
+ def test_insertion(self):
108
+ diffs = build_word_diffs("I very happy", "I am very happy", "grammar")
109
+ assert len(diffs) >= 1
110
+
111
+
112
+ class TestCountWordDiffs:
113
+ def test_identical(self):
114
+ assert count_word_diffs("hello world", "hello world") == 0
115
+
116
+ def test_one_change(self):
117
+ assert count_word_diffs("helo world", "hello world") == 1
118
+
119
+ def test_length_difference(self):
120
+ result = count_word_diffs("one two three", "one two three four")
121
+ assert result >= 1
122
+
123
+ def test_empty(self):
124
+ assert count_word_diffs("", "") == 0
125
+
126
+
127
+ # ── Homophone resolver tests ─────────────────────────────────────────────────
128
+
129
+ class TestHomophoneResolver:
130
+ def test_your_going(self, homophone_resolver):
131
+ result = homophone_resolver.resolve("your going to love this")
132
+ assert "you're" in result["corrected"]
133
+ assert result["fixes"] >= 1
134
+
135
+ def test_their_is(self, homophone_resolver):
136
+ result = homophone_resolver.resolve("their is a problem")
137
+ assert "there" in result["corrected"].lower()
138
+
139
+ def test_more_then(self, homophone_resolver):
140
+ result = homophone_resolver.resolve("This is more then enough")
141
+ assert "than" in result["corrected"]
142
+
143
+ def test_no_false_positive(self, homophone_resolver):
144
+ clean = "There is a cat in their house."
145
+ result = homophone_resolver.resolve(clean)
146
+ # Should not mangle correctly used their/there
147
+ assert result["corrected"] == clean or result["fixes"] == 0
148
+
149
+ def test_will_loose(self, homophone_resolver):
150
+ result = homophone_resolver.resolve("Don't loose your keys")
151
+ assert "lose" in result["corrected"]
152
+
153
+
154
+ # ── Config tests ──────────────────────────────────────────────────────────────
155
+
156
+ class TestSettings:
157
+ def test_defaults_exist(self, settings):
158
+ assert settings.app_name
159
+ assert settings.app_version
160
+ assert settings.max_input_length > 0
161
+
162
+ def test_model_name_set(self, settings):
163
+ assert "t5" in settings.model_name.lower()
164
+
165
+ def test_spell_edit_distance(self, settings):
166
+ assert 1 <= settings.spell_max_edit_distance <= 3
167
+
168
+
169
+ # ── API integration tests (requires running server) ──────────────────────────
170
+
171
+ @pytest.mark.asyncio
172
+ class TestAPIIntegration:
173
+ """
174
+ These tests hit the actual API. Skip them if the server isn't running.
175
+ Run with: pytest tests/ -v -m integration
176
+ """
177
+
178
+ @pytest.mark.integration
179
+ async def test_health_endpoint(self):
180
+ import httpx
181
+ async with httpx.AsyncClient(base_url="http://localhost:8000") as client:
182
+ resp = await client.get("/api/health")
183
+ assert resp.status_code == 200
184
+ data = resp.json()
185
+ assert data["status"] == "ok"
186
+
187
+ @pytest.mark.integration
188
+ async def test_correct_endpoint(self):
189
+ import httpx
190
+ async with httpx.AsyncClient(base_url="http://localhost:8000") as client:
191
+ resp = await client.post(
192
+ "/api/correct",
193
+ json={"text": "She dont know wher she goed yesterday."},
194
+ )
195
+ assert resp.status_code == 200
196
+ data = resp.json()
197
+ assert "corrected" in data
198
+ assert data["corrected"] != data["original"]
199
+
200
+ @pytest.mark.integration
201
+ async def test_refine_endpoint(self):
202
+ import httpx
203
+ async with httpx.AsyncClient(base_url="http://localhost:8000") as client:
204
+ resp = await client.post(
205
+ "/api/refine",
206
+ json={"text": "The meeting was attended by the team.", "style": "professional"},
207
+ )
208
+ assert resp.status_code == 200
209
+ data = resp.json()
210
+ assert "refined" in data
211
+ assert "improvements" in data
212
+
213
+ @pytest.mark.integration
214
+ async def test_empty_text_rejected(self):
215
+ import httpx
216
+ async with httpx.AsyncClient(base_url="http://localhost:8000") as client:
217
+ resp = await client.post("/api/correct", json={"text": " "})
218
+ assert resp.status_code == 422
219
+
220
+ @pytest.mark.integration
221
+ async def test_stats_endpoint(self):
222
+ import httpx
223
+ async with httpx.AsyncClient(base_url="http://localhost:8000") as client:
224
+ resp = await client.get("/api/stats")
225
+ assert resp.status_code == 200
226
+ data = resp.json()
227
+ assert "total_requests" in data
frontend/index.html ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>WriteRight · AI Text Correction</title>
7
+ <meta name="description" content="WriteRight — intelligent spelling, grammar, and style correction powered by NLP." />
8
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
9
+ <link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,600;0,700;1,400;1,600&family=DM+Sans:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
10
+ </head>
11
+ <body>
12
+ <div id="root"></div>
13
+ <script type="module" src="/src/main.jsx"></script>
14
+ </body>
15
+ </html>
frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend/package.json ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "writeright-frontend",
3
+ "version": "2.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "dependencies": {
12
+ "@tanstack/react-query": "^5.45.0",
13
+ "axios": "^1.7.2",
14
+ "framer-motion": "^11.3.2",
15
+ "react": "^18.3.1",
16
+ "react-dom": "^18.3.1",
17
+ "react-hot-toast": "^2.4.1"
18
+ },
19
+ "devDependencies": {
20
+ "@vitejs/plugin-react": "^4.3.0",
21
+ "autoprefixer": "^10.4.19",
22
+ "postcss": "^8.4.38",
23
+ "tailwindcss": "^3.4.4",
24
+ "vite": "^5.3.1"
25
+ }
26
+ }
frontend/postcss.config.js ADDED
@@ -0,0 +1 @@
 
 
1
+ export default { plugins: { tailwindcss: {}, autoprefixer: {} } }
frontend/src/App.jsx ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * App.jsx
3
+ * ────────
4
+ * Root shell: ambient background, navbar, page content, toasts.
5
+ */
6
+
7
+ import { Toaster } from 'react-hot-toast'
8
+ import { motion } from 'framer-motion'
9
+ import Navbar from './components/Navbar'
10
+ import Home from './pages/Home'
11
+ import { useUI } from './store/UIContext'
12
+
13
+ export default function App() {
14
+ const { theme } = useUI()
15
+
16
+ return (
17
+ <div className="app-shell">
18
+
19
+ {/* ── Animated ambient background ── */}
20
+ <div className="ambient" aria-hidden="true">
21
+ <div className="ambient-orb ambient-orb-1" />
22
+ <div className="ambient-orb ambient-orb-2" />
23
+ <div className="ambient-orb ambient-orb-3" />
24
+ </div>
25
+ <div className="noise-overlay" aria-hidden="true" />
26
+
27
+ {/* ── Navbar ── */}
28
+ <Navbar />
29
+
30
+ {/* ── Page ── */}
31
+ <main className="page-content" style={{ position: 'relative', zIndex: 1 }}>
32
+ <Home />
33
+ </main>
34
+
35
+ {/* ── Toast notifications ── */}
36
+ <Toaster
37
+ position="bottom-right"
38
+ toastOptions={{
39
+ duration: 2600,
40
+ style: {
41
+ fontFamily: "'DM Sans', sans-serif",
42
+ fontSize: '13px',
43
+ background: 'var(--surface)',
44
+ color: 'var(--text)',
45
+ border: '1px solid var(--border)',
46
+ boxShadow: 'var(--shadow-lg)',
47
+ },
48
+ }}
49
+ />
50
+ </div>
51
+ )
52
+ }
frontend/src/components/CorrectionPanel.jsx ADDED
@@ -0,0 +1,269 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * components/CorrectionPanel.jsx
3
+ * ───────────────────────────────
4
+ * Right panel. Features:
5
+ * - Skeleton loader during processing
6
+ * - Word-level diff highlighting
7
+ * - 3D flip card: front = correction, back = refinement
8
+ * - Metric chips, confidence badge, copy, "Use as Input"
9
+ */
10
+
11
+ import { useMemo } from 'react'
12
+ import { motion, AnimatePresence } from 'framer-motion'
13
+ import toast from 'react-hot-toast'
14
+
15
+ // ── Word diff renderer ──
16
+ function renderDiff(original, diffs) {
17
+ if (!diffs || diffs.length === 0) return <span>{original}</span>
18
+
19
+ const changeMap = {}
20
+ for (const d of diffs) {
21
+ if (d.original || d.corrected) changeMap[d.position] = d
22
+ }
23
+
24
+ const tokens = original.split(/(\s+)/)
25
+ let idx = 0
26
+ const nodes = []
27
+
28
+ tokens.forEach((tok, i) => {
29
+ if (/^\s+$/.test(tok)) { nodes.push(tok); return }
30
+ const ch = changeMap[idx]
31
+ if (ch) {
32
+ const cls = ch.type === 'homophone' ? 'diff-hom' : 'diff-del'
33
+ nodes.push(<span key={`d${i}`} className={cls}>{ch.original || tok}</span>)
34
+ if (ch.corrected) {
35
+ nodes.push(' ')
36
+ nodes.push(<span key={`i${i}`} className="diff-ins">{ch.corrected}</span>)
37
+ }
38
+ } else {
39
+ nodes.push(tok)
40
+ }
41
+ idx++
42
+ })
43
+
44
+ return <>{nodes}</>
45
+ }
46
+
47
+ // ── Confidence badge ──
48
+ function ConfBadge({ value }) {
49
+ const pct = Math.round(value * 100)
50
+ const color = pct >= 85 ? 'var(--green)' : pct >= 65 ? 'var(--amber)' : 'var(--red)'
51
+ const bg = pct >= 85 ? 'var(--green-soft)' : pct >= 65 ? 'var(--amber-soft)' : 'var(--red-soft)'
52
+ return (
53
+ <motion.span
54
+ whileHover={{ scale: 1.05 }}
55
+ style={{ fontSize: 11.5, fontWeight: 600, color, background: bg,
56
+ padding: '2px 9px', borderRadius: 100, border: `1px solid ${color}`, cursor: 'default' }}>
57
+ {pct}% confidence
58
+ </motion.span>
59
+ )
60
+ }
61
+
62
+ // ── Skeleton ──
63
+ function Skeleton({ lines = [88, 72, 80, 55, 68] }) {
64
+ return (
65
+ <motion.div style={{ padding: '18px' }} initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
66
+ {lines.map((w, i) => (
67
+ <motion.div
68
+ key={i}
69
+ className="skeleton"
70
+ style={{ width: `${w}%`, height: 13, marginBottom: 11, borderRadius: 6 }}
71
+ initial={{ opacity: 0 }}
72
+ animate={{ opacity: 1 }}
73
+ transition={{ delay: i * 0.05 }}
74
+ />
75
+ ))}
76
+ </motion.div>
77
+ )
78
+ }
79
+
80
+ // ── Copy util ──
81
+ function copyText(text) {
82
+ navigator.clipboard.writeText(text).then(() => {
83
+ toast.success('Copied to clipboard', {
84
+ style: {
85
+ background: 'var(--green-soft)', color: 'var(--green)',
86
+ border: '1px solid var(--green)',
87
+ fontFamily: "'DM Sans',sans-serif", fontSize: '13px',
88
+ },
89
+ })
90
+ })
91
+ }
92
+
93
+ // ── Card header ──
94
+ function PanelHeader({ title, tagLabel, tagClass, copyValue, extra }) {
95
+ return (
96
+ <div className="card-header" style={{ gap: 8 }}>
97
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
98
+ <span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text)' }}>{title}</span>
99
+ <span className={`tag ${tagClass}`}>{tagLabel}</span>
100
+ </div>
101
+ <div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
102
+ {extra}
103
+ {copyValue && (
104
+ <motion.button
105
+ className="btn-ghost"
106
+ style={{ padding: '4px 10px', fontSize: 12 }}
107
+ onClick={() => copyText(copyValue)}
108
+ whileHover={{ scale: 1.05 }}
109
+ whileTap={{ scale: 0.95 }}
110
+ >
111
+ ⎘ Copy
112
+ </motion.button>
113
+ )}
114
+ </div>
115
+ </div>
116
+ )
117
+ }
118
+
119
+ // ── Empty state ──
120
+ function EmptyState() {
121
+ return (
122
+ <motion.div
123
+ className="empty-state"
124
+ initial={{ opacity: 0, scale: 0.95 }}
125
+ animate={{ opacity: 1, scale: 1 }}
126
+ transition={{ duration: 0.5, ease: "easeOut" }}
127
+ >
128
+ <div className="empty-icon">✦</div>
129
+ <p style={{ fontSize: 14, fontWeight: 500, color: 'var(--muted)' }}>
130
+ Awaiting Text Pipeline
131
+ </p>
132
+ <p style={{ fontSize: 12.5, color: 'var(--muted2)', maxWidth: 220, lineHeight: 1.6 }}>
133
+ Insert text on the left and run diagnostics to see engine evaluations.
134
+ </p>
135
+ </motion.div>
136
+ )
137
+ }
138
+
139
+ // ── Correction face (front) ──
140
+ function CorrectionFace({ result, loading, onFlipHint }) {
141
+ const diffNode = useMemo(() => result ? renderDiff(result.original, result.diffs) : null, [result])
142
+
143
+ if (loading) return <><PanelHeader title="Neural Diagnostics" tagLabel="Processing…" tagClass="tag-proc" /><Skeleton /></>
144
+ if (!result) return <EmptyState />
145
+
146
+ return (
147
+ <>
148
+ <PanelHeader title="Pipeline Results" tagLabel="✓ Resolved" tagClass="tag-spell" copyValue={result.corrected} />
149
+
150
+ <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', padding: '12px 18px 0' }}>
151
+ {result.spell_fixed > 0 && (
152
+ <motion.span whileHover={{ scale: 1.05 }} className="chip chip-spell"><span className="chip-dot" />{result.spell_fixed} spelling</motion.span>
153
+ )}
154
+ {result.grammar_fixed > 0 && (
155
+ <motion.span whileHover={{ scale: 1.05 }} className="chip chip-gram"><span className="chip-dot" />{result.grammar_fixed} grammar</motion.span>
156
+ )}
157
+ {result.homophone_fixed > 0 && (
158
+ <motion.span whileHover={{ scale: 1.05 }} className="chip chip-hom"><span className="chip-dot" />{result.homophone_fixed} homophone</motion.span>
159
+ )}
160
+ <ConfBadge value={result.confidence} />
161
+ </div>
162
+
163
+ <motion.div
164
+ style={{ padding: '14px 18px 18px', fontSize: 14.5, lineHeight: 1.9, borderTop: '1px solid var(--divider)', marginTop: 12, whiteSpace: 'pre-wrap', wordBreak: 'break-word', color: 'var(--text)' }}
165
+ initial={{ opacity: 0, y: 5 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.4 }}
166
+ >
167
+ {diffNode}
168
+ </motion.div>
169
+
170
+ <div style={{ margin: '0 18px 12px', padding: '10px 14px', borderRadius: 8, background: 'var(--surface-alt)', border: '1px solid var(--border)', fontSize: 13, color: 'var(--text-2)', lineHeight: 1.7 }}>
171
+ <span style={{ color: 'var(--green)', marginRight: 6, fontWeight: 600 }}>✓</span>
172
+ {result.corrected}
173
+ </div>
174
+
175
+ {onFlipHint && (
176
+ <div style={{ padding: '10px 18px 14px', fontSize: 11.5, color: 'var(--muted2)', borderTop: '1px solid var(--divider)', display: 'flex', alignItems: 'center', gap: 6 }}>
177
+ <span style={{ fontSize: 13 }}>✦</span> Click <strong style={{ color: 'var(--accent)' }}>Refine Text</strong> to synthesize a stylistic rewrite →
178
+ </div>
179
+ )}
180
+ </>
181
+ )
182
+ }
183
+
184
+ // ── Refinement face (back) ──
185
+ function RefinementFace({ result, loading }) {
186
+ if (loading) return <><PanelHeader title="Stylistic Refinement" tagLabel="Synthesizing…" tagClass="tag-proc" /><Skeleton lines={[90, 78, 85, 72, 60, 82, 50]} /></>
187
+ if (!result) return null
188
+
189
+ return (
190
+ <>
191
+ <PanelHeader
192
+ title="Stylistic Refinement" tagLabel="✦ Synthesized" tagClass="tag-refine" copyValue={result.refined}
193
+ extra={
194
+ <motion.button
195
+ className="btn-ghost" style={{ padding: '4px 10px', fontSize: 12 }}
196
+ onClick={() => {
197
+ window.dispatchEvent(new CustomEvent('wr:loadText', { detail: result.refined }))
198
+ toast.success('Loaded into editor', { style: { background: 'var(--purple-soft)', color: 'var(--purple)', border: '1px solid var(--purple)', fontFamily: "'DM Sans',sans-serif", fontSize: '13px' } })
199
+ }}
200
+ whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}
201
+ >
202
+ ↑ Use as Input
203
+ </motion.button>
204
+ }
205
+ />
206
+
207
+ {result.improvements?.length > 0 && (
208
+ <div style={{ padding: '12px 18px 0', display: 'flex', flexDirection: 'column', gap: 6 }}>
209
+ {result.improvements.map((imp, i) => (
210
+ <motion.div key={i} className="improvement-item" initial={{ opacity: 0, x: 10 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: i * 0.07 }}>
211
+ <span className="improvement-icon">✦</span>{imp}
212
+ </motion.div>
213
+ ))}
214
+ </div>
215
+ )}
216
+
217
+ <motion.div
218
+ className="refined-text" style={{ padding: '16px 18px 20px', borderTop: '1px solid var(--divider)', marginTop: 12 }}
219
+ initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.2, duration: 0.5 }}
220
+ >
221
+ {result.refined}
222
+ </motion.div>
223
+
224
+ <div style={{ padding: '10px 18px 14px', borderTop: '1px solid var(--divider)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', fontSize: 11.5, color: 'var(--muted2)' }}>
225
+ <span>T5 Transformer Output</span>
226
+ <span style={{ color: 'var(--purple)' }}>✦ AI-enhanced</span>
227
+ </div>
228
+ </>
229
+ )
230
+ }
231
+
232
+ // ── Main export ──
233
+ export default function CorrectionPanel({ correctionResult, refineResult, correctionLoading, refineLoading }) {
234
+ const isFlipped = Boolean(refineResult || refineLoading)
235
+
236
+ return (
237
+ <motion.div initial={{ opacity: 0, x: 28 }} animate={{ opacity: 1, x: 0 }} transition={{ duration: 0.55, ease: [0.22, 1, 0.36, 1], delay: 0.2 }}>
238
+
239
+ <AnimatePresence>
240
+ {isFlipped && (
241
+ <motion.div initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -8 }} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10, fontSize: 12, color: 'var(--purple)', fontWeight: 500 }}>
242
+ <span style={{ fontSize: 14 }}>✦</span> Viewing refined output <span style={{ color: 'var(--muted2)', fontWeight: 400 }}>· card flipped 180°</span>
243
+ </motion.div>
244
+ )}
245
+ </AnimatePresence>
246
+
247
+ <div className="flip-scene" style={{ minHeight: 400 }}>
248
+ <div className={`flip-card ${isFlipped ? 'flipped' : ''}`}>
249
+ <div className="flip-face flip-face-front"><CorrectionFace result={correctionResult} loading={correctionLoading} onFlipHint={Boolean(correctionResult)} /></div>
250
+ <div className="flip-face flip-face-back"><RefinementFace result={refineResult} loading={refineLoading} /></div>
251
+ </div>
252
+ </div>
253
+
254
+ <AnimatePresence>
255
+ {isFlipped && correctionResult && (
256
+ <motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 8 }} style={{ marginTop: 10, display: 'flex', justifyContent: 'flex-end' }}>
257
+ <motion.button
258
+ className="btn-ghost" style={{ fontSize: 12, padding: '5px 12px' }}
259
+ onClick={() => window.dispatchEvent(new CustomEvent('wr:backToCorrection'))}
260
+ whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.95 }}
261
+ >
262
+ ← Back to diagnostics
263
+ </motion.button>
264
+ </motion.div>
265
+ )}
266
+ </AnimatePresence>
267
+ </motion.div>
268
+ )
269
+ }
frontend/src/components/Navbar.jsx ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * components/Navbar.jsx
3
+ * ─────────────────────
4
+ * Sticky top bar — Cleaned up to remove unnecessary API health polling.
5
+ */
6
+
7
+ import { motion } from 'framer-motion'
8
+ import ThemeToggle from './ThemeToggle'
9
+
10
+ export default function Navbar() {
11
+ return (
12
+ <motion.header
13
+ className="navbar"
14
+ initial={{ y: -56, opacity: 0 }}
15
+ animate={{ y: 0, opacity: 1 }}
16
+ transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
17
+ style={{
18
+ display: 'flex',
19
+ justifyContent: 'space-between',
20
+ alignItems: 'center',
21
+ padding: '16px 32px',
22
+ borderBottom: '1px solid var(--border)',
23
+ background: 'var(--overlay)',
24
+ backdropFilter: 'blur(12px)'
25
+ }}
26
+ >
27
+ <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
28
+ <span style={{ fontWeight: 700, fontSize: 18, color: 'var(--text)', letterSpacing: '-0.02em' }}>
29
+ WriteRight
30
+ </span>
31
+ <span style={{
32
+ fontSize: 11,
33
+ fontWeight: 600,
34
+ color: 'var(--muted)',
35
+ background: 'var(--surface-alt)',
36
+ padding: '2px 8px',
37
+ borderRadius: 100,
38
+ border: '1px solid var(--border)'
39
+ }}>
40
+ v2.0 Engine
41
+ </span>
42
+ </div>
43
+
44
+ <div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
45
+ <ThemeToggle />
46
+ </div>
47
+ </motion.header>
48
+ )
49
+ }
frontend/src/components/TextEditor.jsx ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * components/TextEditor.jsx
3
+ * ─────────────────────────
4
+ * Left panel: textarea, char/word counters, action buttons.
5
+ * Toggles removed for a cleaner, opinionated UX.
6
+ */
7
+
8
+ import { useRef, useState, useEffect } from 'react'
9
+ import { motion, AnimatePresence } from 'framer-motion'
10
+
11
+ const MAX_CHARS = 4096
12
+
13
+ const STYLES = ['professional', 'casual', 'academic', 'concise']
14
+
15
+ export default function TextEditor({ onCorrect, onRefine, onClear, loading, hasResult }) {
16
+ const [text, setText] = useState('')
17
+ const [focused, setFocused] = useState(false)
18
+ const [style, setStyle] = useState('professional')
19
+ const ref = useRef(null)
20
+
21
+ // Listen for "use-as-input" event from Home
22
+ useEffect(() => {
23
+ function handler(e) {
24
+ setText(e.detail || '')
25
+ setTimeout(() => ref.current?.focus(), 100)
26
+ }
27
+ window.addEventListener('wr:loadText', handler)
28
+ return () => window.removeEventListener('wr:loadText', handler)
29
+ }, [])
30
+
31
+ const words = text.trim() ? text.trim().split(/\s+/).length : 0
32
+ const chars = text.length
33
+ const pct = Math.min((chars / MAX_CHARS) * 100, 100)
34
+
35
+ function handleChange(e) {
36
+ if (e.target.value.length <= MAX_CHARS) setText(e.target.value)
37
+ }
38
+
39
+ function handleClear() {
40
+ setText(''); onClear(); ref.current?.focus()
41
+ }
42
+
43
+ const barColor = pct > 90 ? 'var(--red)' : pct > 70 ? 'var(--amber)'
44
+ : 'linear-gradient(90deg, var(--accent), var(--accent-2))'
45
+
46
+ return (
47
+ <motion.div
48
+ initial={{ opacity: 0, x: -28 }}
49
+ animate={{ opacity: 1, x: 0 }}
50
+ transition={{ duration: 0.55, ease: [0.22, 1, 0.36, 1], delay: 0.1 }}
51
+ >
52
+ {/* ── Main input card ── */}
53
+ <div className={`card ${focused ? 'focused' : ''} ${loading ? 'processing' : ''}`}
54
+ style={{ '--ring-color': 'rgba(79,70,229,.2)' }}>
55
+
56
+ {/* Card header - simplified */}
57
+ <div className="card-header" style={{ justifyContent: 'flex-start' }}>
58
+ <div className="label-row">
59
+ <span className="label-dot" />
60
+ Input Text
61
+ </div>
62
+ </div>
63
+
64
+ {/* Textarea */}
65
+ <textarea
66
+ ref={ref}
67
+ className="editor-textarea"
68
+ value={text}
69
+ onChange={handleChange}
70
+ onFocus={() => setFocused(true)}
71
+ onBlur={() => setFocused(false)}
72
+ rows={9}
73
+ disabled={loading}
74
+ placeholder={"Paste or type your text here…\n\nTry: 'She dont know wher she goed yesterday, it were a compliceted situaton.'"}
75
+ />
76
+
77
+ {/* Char progress bar */}
78
+ <div className="char-bar-track">
79
+ <motion.div
80
+ className="char-bar-fill"
81
+ animate={{ width: `${pct}%` }}
82
+ transition={{ duration: 0.15 }}
83
+ style={{ background: barColor }}
84
+ />
85
+ </div>
86
+
87
+ {/* Footer */}
88
+ <div style={{
89
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
90
+ padding: '10px 18px',
91
+ }}>
92
+ <div style={{ display: 'flex', gap: 14 }}>
93
+ <span style={{ fontSize: 12, color: 'var(--muted)' }}>
94
+ Words <strong style={{ color: 'var(--text-2)' }}>{words}</strong>
95
+ </span>
96
+ <span style={{ fontSize: 12, color: pct > 90 ? 'var(--red)' : 'var(--muted)' }}>
97
+ Chars <strong style={{ color: pct > 90 ? 'var(--red)' : 'var(--text-2)' }}>{chars}</strong>
98
+ <span style={{ color: 'var(--muted2)', marginLeft: 2 }}>/ {MAX_CHARS}</span>
99
+ </span>
100
+ </div>
101
+ <AnimatePresence>
102
+ {text && (
103
+ <motion.button
104
+ className="btn-danger"
105
+ onClick={handleClear}
106
+ disabled={loading}
107
+ initial={{ opacity: 0, scale: 0.8 }}
108
+ animate={{ opacity: 1, scale: 1 }}
109
+ exit={{ opacity: 0, scale: 0.8 }}
110
+ transition={{ duration: 0.15 }}
111
+ >
112
+ ✕ Clear
113
+ </motion.button>
114
+ )}
115
+ </AnimatePresence>
116
+ </div>
117
+ </div>
118
+
119
+ {/* ── Action row ── */}
120
+ <motion.div
121
+ style={{ display: 'flex', gap: 10, marginTop: 14, flexWrap: 'wrap', alignItems: 'center' }}
122
+ initial={{ opacity: 0, y: 10 }}
123
+ animate={{ opacity: 1, y: 0 }}
124
+ transition={{ delay: 0.25, duration: 0.4 }}
125
+ >
126
+ {/* Correct button */}
127
+ <motion.button
128
+ className="btn-primary"
129
+ onClick={() => !loading && text.trim() && onCorrect(text)}
130
+ disabled={!text.trim() || loading}
131
+ whileHover={!loading && text.trim() ? { scale: 1.02, boxShadow: '0 4px 20px rgba(79,70,229,.45)' } : {}}
132
+ whileTap={{ scale: 0.97 }}
133
+ style={{ minWidth: 170 }}
134
+ >
135
+ {loading
136
+ ? <><span className="spinner" /> Analysing…</>
137
+ : <><span style={{ fontSize: 15 }}>⚡</span> Fix Spelling &amp; Grammar</>
138
+ }
139
+ </motion.button>
140
+
141
+ {/* Refine button + style picker */}
142
+ <div style={{ display: 'flex', alignItems: 'stretch' }}>
143
+ <motion.button
144
+ className="btn-cyan"
145
+ onClick={() => hasResult && !loading && onRefine(style)}
146
+ disabled={!hasResult || loading}
147
+ whileHover={hasResult && !loading ? { scale: 1.02, boxShadow: '0 4px 20px rgba(8,145,178,.4)' } : {}}
148
+ whileTap={{ scale: 0.97 }}
149
+ style={{ borderRadius: '10px 0 0 10px', paddingRight: 14 }}
150
+ >
151
+ {loading
152
+ ? <><span className="spinner" /> Refining…</>
153
+ : <><span style={{ fontSize: 13 }}>✦</span> Refine Text</>
154
+ }
155
+ </motion.button>
156
+ <select
157
+ value={style}
158
+ onChange={e => setStyle(e.target.value)}
159
+ disabled={!hasResult || loading}
160
+ style={{
161
+ padding: '0 10px',
162
+ borderRadius: '0 10px 10px 0',
163
+ border: '1px solid var(--accent-2)',
164
+ borderLeft: 'none',
165
+ background: 'var(--accent-2-soft)',
166
+ color: hasResult ? 'var(--accent-2)' : 'var(--muted)',
167
+ fontSize: 12.5,
168
+ fontFamily: "'DM Sans', sans-serif",
169
+ cursor: hasResult ? 'pointer' : 'not-allowed',
170
+ outline: 'none',
171
+ opacity: hasResult ? 1 : 0.5,
172
+ transition: 'all 0.25s ease',
173
+ fontWeight: 500,
174
+ }}
175
+ >
176
+ {STYLES.map(s => (
177
+ <option key={s} value={s}>
178
+ {s.charAt(0).toUpperCase() + s.slice(1)}
179
+ </option>
180
+ ))}
181
+ </select>
182
+ </div>
183
+ </motion.div>
184
+ </motion.div>
185
+ )
186
+ }
frontend/src/components/ThemeToggle.jsx ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * components/ThemeToggle.jsx
3
+ * ────────────────────────────
4
+ * Animated sun/moon toggle that switches dark ↔ light mode.
5
+ */
6
+
7
+ import { motion, AnimatePresence } from 'framer-motion'
8
+ import { useUI } from '../store/UIContext'
9
+
10
+ const SunIcon = () => (
11
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
12
+ <circle cx="12" cy="12" r="5"/>
13
+ <line x1="12" y1="2" x2="12" y2="4"/>
14
+ <line x1="12" y1="20" x2="12" y2="22"/>
15
+ <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
16
+ <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
17
+ <line x1="2" y1="12" x2="4" y2="12"/>
18
+ <line x1="20" y1="12" x2="22" y2="12"/>
19
+ <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
20
+ <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
21
+ </svg>
22
+ )
23
+
24
+ const MoonIcon = () => (
25
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
26
+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
27
+ </svg>
28
+ )
29
+
30
+ export default function ThemeToggle() {
31
+ const { theme, toggleTheme } = useUI()
32
+ const isDark = theme === 'dark'
33
+
34
+ return (
35
+ <motion.button
36
+ onClick={toggleTheme}
37
+ className="btn-icon"
38
+ title={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
39
+ whileTap={{ scale: 0.9 }}
40
+ style={{ position: 'relative', width: 34, height: 34 }}
41
+ aria-label="Toggle theme"
42
+ >
43
+ <AnimatePresence mode="wait" initial={false}>
44
+ <motion.span
45
+ key={isDark ? 'moon' : 'sun'}
46
+ initial={{ rotate: -90, opacity: 0, scale: 0.6 }}
47
+ animate={{ rotate: 0, opacity: 1, scale: 1 }}
48
+ exit={{ rotate: 90, opacity: 0, scale: 0.6 }}
49
+ transition={{ duration: 0.22, ease: 'easeOut' }}
50
+ style={{
51
+ position: 'absolute',
52
+ display: 'flex',
53
+ alignItems: 'center',
54
+ justifyContent: 'center',
55
+ color: isDark ? 'var(--accent)' : 'var(--amber)',
56
+ }}
57
+ >
58
+ {isDark ? <MoonIcon /> : <SunIcon />}
59
+ </motion.span>
60
+ </AnimatePresence>
61
+ </motion.button>
62
+ )
63
+ }
frontend/src/hooks/useTextCorrection.js ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * hooks/useTextCorrection.js
3
+ * ────────────────────────────
4
+ * React Query mutation for the /api/correct endpoint.
5
+ */
6
+
7
+ import { useMutation } from '@tanstack/react-query'
8
+ import { correctText } from '../services/api'
9
+ import toast from 'react-hot-toast'
10
+
11
+ export function useTextCorrection() {
12
+ return useMutation({
13
+ // Simplified payload: just the text
14
+ mutationFn: ({ text }) => correctText(text),
15
+ onSuccess: (data) => {
16
+ const total = (data.spell_fixed || 0) + (data.grammar_fixed || 0) + (data.homophone_fixed || 0)
17
+ if (total === 0) {
18
+ toast('No errors found — your text looks great!', {
19
+ icon: '✨',
20
+ style: getToastStyle('green'),
21
+ })
22
+ }
23
+ },
24
+ onError: (err) => {
25
+ toast.error(err.message || 'Correction failed.', {
26
+ style: getToastStyle('red'),
27
+ })
28
+ },
29
+ })
30
+ }
31
+
32
+ function getToastStyle(color) {
33
+ const map = {
34
+ green: { background: 'var(--green-soft)', color: 'var(--green)', border: '1px solid var(--green)' },
35
+ red: { background: 'var(--red-soft)', color: 'var(--red)', border: '1px solid var(--red)' },
36
+ }
37
+ return { ...map[color], fontFamily: "'DM Sans', sans-serif", fontSize: '13px' }
38
+ }
frontend/src/hooks/useTextRefinement.js ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * hooks/useTextRefinement.js
3
+ * ───────────────────────────
4
+ * React Query mutation for the /api/refine endpoint.
5
+ */
6
+
7
+ import { useMutation } from '@tanstack/react-query'
8
+ import { refineText } from '../services/api'
9
+ import toast from 'react-hot-toast'
10
+
11
+ export function useTextRefinement() {
12
+ return useMutation({
13
+ mutationFn: ({ text, style = 'professional' }) => refineText(text, style),
14
+ onError: (err) => {
15
+ toast.error(err.message || 'Refinement failed.', {
16
+ style: {
17
+ background: 'var(--red-soft)',
18
+ color: 'var(--red)',
19
+ border: '1px solid var(--red)',
20
+ fontFamily: "'DM Sans', sans-serif",
21
+ fontSize: '13px',
22
+ },
23
+ })
24
+ },
25
+ })
26
+ }
frontend/src/main.jsx ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+ import ReactDOM from 'react-dom/client'
3
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
4
+ import { UIProvider } from './store/UIContext'
5
+ import App from './App'
6
+ import './styles/globals.css'
7
+
8
+ const queryClient = new QueryClient({
9
+ defaultOptions: {
10
+ mutations: { retry: 0 },
11
+ queries: { retry: 1, staleTime: 30_000 },
12
+ },
13
+ })
14
+
15
+ ReactDOM.createRoot(document.getElementById('root')).render(
16
+ <React.StrictMode>
17
+ <QueryClientProvider client={queryClient}>
18
+ <UIProvider>
19
+ <App />
20
+ </UIProvider>
21
+ </QueryClientProvider>
22
+ </React.StrictMode>
23
+ )
frontend/src/pages/Home.jsx ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * pages/Home.jsx
3
+ * ──────────────
4
+ * Split-panel layout. Left = TextEditor, Right = CorrectionPanel.
5
+ * Hero title with scroll-based parallax inspired by Chrome's download UX.
6
+ */
7
+
8
+ import { useState, useCallback, useEffect, useRef } from 'react'
9
+ import toast from 'react-hot-toast'
10
+ import { motion, useScroll, useTransform, useSpring, AnimatePresence } from 'framer-motion'
11
+ import TextEditor from '../components/TextEditor'
12
+ import CorrectionPanel from '../components/CorrectionPanel'
13
+ import { useTextCorrection } from '../hooks/useTextCorrection'
14
+ import { useTextRefinement } from '../hooks/useTextRefinement'
15
+
16
+ const HERO_TAGS = ['spaCy', 'SymSpell', 'T5 Transformer', 'FastAPI', 'React 18']
17
+
18
+ export default function Home() {
19
+ const heroRef = useRef(null)
20
+ const { scrollY } = useScroll()
21
+
22
+ // Scroll parallax: title drifts up slightly and fades as user scrolls
23
+ const rawY = useTransform(scrollY, [0, 200], [0, -40])
24
+ const rawOp = useTransform(scrollY, [0, 160], [1, 0])
25
+ const heroY = useSpring(rawY, { stiffness: 120, damping: 22 })
26
+ const heroOp = useSpring(rawOp, { stiffness: 120, damping: 22 })
27
+
28
+ // RQ mutations
29
+ const {
30
+ mutate: correctMutate,
31
+ data: correctionResult,
32
+ isPending: correctionLoading,
33
+ reset: resetCorrection,
34
+ } = useTextCorrection()
35
+
36
+ const {
37
+ mutate: refineMutate,
38
+ data: refineResult,
39
+ isPending: refineLoading,
40
+ reset: resetRefinement,
41
+ } = useTextRefinement()
42
+
43
+ // Listen for "back to correction" event from CorrectionPanel
44
+ useEffect(() => {
45
+ function handler() { resetRefinement() }
46
+ window.addEventListener('wr:backToCorrection', handler)
47
+ return () => window.removeEventListener('wr:backToCorrection', handler)
48
+ }, [resetRefinement])
49
+
50
+ const handleCorrect = useCallback((text, pipeline) => {
51
+ resetRefinement()
52
+ correctMutate({ text, pipeline })
53
+ }, [correctMutate, resetRefinement])
54
+
55
+ const handleRefine = useCallback((style) => {
56
+ if (!correctionResult?.corrected) return
57
+ refineMutate({ text: correctionResult.corrected, style })
58
+ }, [correctionResult, refineMutate])
59
+
60
+ const handleClear = useCallback(() => {
61
+ resetCorrection()
62
+ resetRefinement()
63
+ }, [resetCorrection, resetRefinement])
64
+
65
+ const isLoading = correctionLoading || refineLoading
66
+
67
+ return (
68
+ <div style={{ position: 'relative', zIndex: 1 }}>
69
+
70
+ {/* ══════════ HERO ══════════ */}
71
+ <motion.div
72
+ ref={heroRef}
73
+ style={{ y: heroY, opacity: heroOp }}
74
+ initial={{ opacity: 0, y: 24 }}
75
+ animate={{ opacity: 1, y: 0 }}
76
+ transition={{ duration: 0.7, ease: [0.22, 1, 0.36, 1] }}
77
+ >
78
+ <div style={{ textAlign: 'center', padding: '48px 0 36px' }}>
79
+ {/* Eyebrow */}
80
+ <motion.div
81
+ initial={{ opacity: 0, y: 10 }}
82
+ animate={{ opacity: 1, y: 0 }}
83
+ transition={{ delay: 0.1 }}
84
+ style={{
85
+ display: 'inline-flex', alignItems: 'center', gap: 8,
86
+ padding: '5px 14px', borderRadius: 100,
87
+ border: '1px solid var(--border2)',
88
+ background: 'var(--surface-alt)',
89
+ fontSize: 11, fontWeight: 600, letterSpacing: '0.07em',
90
+ color: 'var(--muted)', textTransform: 'uppercase',
91
+ marginBottom: 20,
92
+ }}
93
+ >
94
+ <span style={{ color: 'var(--accent)', fontSize: 13 }}>✦</span>
95
+ AI Writing Assistant
96
+ </motion.div>
97
+
98
+ {/* Main title */}
99
+ <motion.h1
100
+ className="hero-display"
101
+ initial={{ opacity: 0, y: 20 }}
102
+ animate={{ opacity: 1, y: 0 }}
103
+ transition={{ delay: 0.18, duration: 0.65, ease: [0.22, 1, 0.36, 1] }}
104
+ >
105
+ Write<em>Right</em>
106
+ </motion.h1>
107
+
108
+ {/* Subtitle */}
109
+ <motion.p
110
+ className="hero-sub"
111
+ initial={{ opacity: 0, y: 14 }}
112
+ animate={{ opacity: 1, y: 0 }}
113
+ transition={{ delay: 0.28, duration: 0.55 }}
114
+ >
115
+ Paste any text. Fix spelling, resolve grammar, and refine style —
116
+ powered by spaCy, SymSpell, and a T5 transformer.
117
+ </motion.p>
118
+
119
+ {/* Tech stack tags */}
120
+ <motion.div
121
+ style={{ display: 'flex', gap: 6, justifyContent: 'center', flexWrap: 'wrap', marginTop: 20 }}
122
+ initial={{ opacity: 0 }}
123
+ animate={{ opacity: 1 }}
124
+ transition={{ delay: 0.4 }}
125
+ >
126
+ {HERO_TAGS.map((tag, i) => (
127
+ <motion.span
128
+ key={tag}
129
+ style={{
130
+ padding: '3px 11px', borderRadius: 100,
131
+ border: '1px solid var(--border)',
132
+ background: 'var(--surface-alt)',
133
+ fontSize: 11, fontWeight: 500, color: 'var(--muted2)',
134
+ letterSpacing: '0.04em',
135
+ }}
136
+ initial={{ opacity: 0, y: 8 }}
137
+ animate={{ opacity: 1, y: 0 }}
138
+ transition={{ delay: 0.42 + i * 0.06 }}
139
+ >
140
+ {tag}
141
+ </motion.span>
142
+ ))}
143
+ </motion.div>
144
+ </div>
145
+ </motion.div>
146
+
147
+ {/* ══════════ DIVIDER ══════════ */}
148
+ <motion.div
149
+ className="divider-text"
150
+ style={{ marginBottom: 0, color: 'var(--muted2)', fontSize: 10.5 }}
151
+ initial={{ opacity: 0 }}
152
+ animate={{ opacity: 1 }}
153
+ transition={{ delay: 0.55 }}
154
+ >
155
+ Start editing
156
+ </motion.div>
157
+
158
+ {/* ══════════ SPLIT GRID ══════════ */}
159
+ <div className="split-grid" style={{ marginTop: 20 }}>
160
+
161
+ {/* LEFT — Editor */}
162
+ <TextEditor
163
+ onCorrect={handleCorrect}
164
+ onRefine={handleRefine}
165
+ onClear={handleClear}
166
+ loading={isLoading}
167
+ hasResult={Boolean(correctionResult)}
168
+ />
169
+
170
+ {/* RIGHT — Results */}
171
+ <CorrectionPanel
172
+ correctionResult={correctionResult}
173
+ refineResult={refineResult}
174
+ correctionLoading={correctionLoading}
175
+ refineLoading={refineLoading}
176
+ onUseRefined={() => {}}
177
+ />
178
+
179
+ </div>
180
+ {/* ══════════ ACADEMIC EVALUATION METRICS DASHBOARD ══════════ */}
181
+ <AnimatePresence>
182
+ {correctionResult && (
183
+ <motion.div
184
+ initial={{ opacity: 0, y: 30, scale: 0.98 }}
185
+ animate={{ opacity: 1, y: 0, scale: 1 }}
186
+ exit={{ opacity: 0, y: 20, scale: 0.98 }}
187
+ transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
188
+ style={{ marginTop: '32px' }}
189
+ >
190
+ <div className="card" style={{ padding: '32px', background: 'var(--surface)' }}>
191
+ <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: '24px', borderBottom: '1px solid var(--border)', paddingBottom: '16px' }}>
192
+ <span className="label-dot" style={{ background: 'var(--purple)', width: 8, height: 8 }} />
193
+ <h2 style={{ fontSize: '18px', fontWeight: 700, letterSpacing: '-0.01em', color: 'var(--text)' }}>
194
+ Text Evaluation Metrics
195
+ </h2>
196
+ </div>
197
+
198
+ {/* Data Calculation (Handled inline for the UI) */}
199
+ {(() => {
200
+ const errors = (correctionResult.spell_fixed || 0) + (correctionResult.grammar_fixed || 0) + (correctionResult.homophone_fixed || 0);
201
+ const words = correctionResult.original ? correctionResult.original.trim().split(/\s+/).length : 1;
202
+
203
+ // NEW FORMULA: Accuracy Ratio (Words / (Words + Errors))
204
+ const qualityScore = Math.round((words / (words + errors)) * 100);
205
+ const burdenScore = Math.round((words -errors) / words)*100;
206
+ return (
207
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: '24px' }}>
208
+
209
+ {/* Panel 1: User Text Quality Score */}
210
+ <div style={{ padding: '24px', background: 'var(--surface-alt)', borderRadius: '12px', border: '1px solid var(--border)' }}>
211
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
212
+ <div>
213
+ <span style={{ fontSize: '14px', fontWeight: 600, color: 'var(--text-2)' }}>Accuracy Ratio Score</span>
214
+ <p style={{ fontSize: '12px', color: 'var(--muted)', marginTop: '4px' }}>Proportional evaluation of input validity.</p>
215
+ </div>
216
+ <div style={{ display: 'flex', alignItems: 'baseline', gap: '2px' }}>
217
+ <span style={{ fontSize: '42px', fontWeight: 700, color: qualityScore > 80 ? 'var(--green)' : qualityScore > 50 ? 'var(--amber)' : 'var(--red)' }}>
218
+ {qualityScore}
219
+ </span>
220
+ <span style={{ fontSize: '16px', fontWeight: 600, color: 'var(--muted)' }}>/100</span>
221
+ </div>
222
+ </div>
223
+
224
+ {/* Formula Display */}
225
+ <div style={{ marginTop: '20px', padding: '12px', background: 'var(--bg)', borderRadius: '8px', border: '1px dashed var(--border2)', fontFamily: "'JetBrains Mono', monospace", fontSize: '12px', color: 'var(--muted)' }}>
226
+ <div style={{ marginBottom: '6px', color: 'var(--text-2)', fontWeight: 500 }}>Applied Formula:</div>
227
+ <div>Score = (Words / (Words + Errors)) * 100</div>
228
+ <div style={{ marginTop: '6px', color: 'var(--accent)' }}>Calculation: ({words} / ({words} + {errors})) * 100</div>
229
+ </div>
230
+ </div>
231
+
232
+ {/* Panel 2: Error Density (Rho) */}
233
+ {/* Panel 2: Correction Burden */}
234
+ <div style={{ padding: '24px', background: 'var(--surface-alt)', borderRadius: '12px', border: '1px solid var(--border)' }}>
235
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
236
+ <div>
237
+ <span style={{ fontSize: '14px', fontWeight: 600, color: 'var(--text-2)' }}>Correction Burden</span>
238
+ <p style={{ fontSize: '12px', color: 'var(--muted)', marginTop: '4px' }}>Projected number of errors per 100 words.</p>
239
+ </div>
240
+ <div style={{ display: 'flex', alignItems: 'baseline', gap: '2px' }}>
241
+ <span style={{ fontSize: '42px', fontWeight: 700, color: 'var(--accent)' }}>
242
+ {burdenScore}
243
+ </span>
244
+ <span style={{ fontSize: '14px', fontWeight: 600, color: 'var(--muted)', marginLeft: '4px' }}>errors</span>
245
+ </div>
246
+ </div>
247
+
248
+ {/* Formula Display */}
249
+ <div style={{ marginTop: '20px', padding: '12px', background: 'var(--bg)', borderRadius: '8px', border: '1px dashed var(--border2)', fontFamily: "'JetBrains Mono', monospace", fontSize: '12px', color: 'var(--muted)' }}>
250
+ <div style={{ marginBottom: '6px', color: 'var(--text-2)', fontWeight: 500 }}>Applied Formula:</div>
251
+ <div>Burden = (Total Errors / Total Words) * 100</div>
252
+ <div style={{ marginTop: '6px', color: 'var(--accent)' }}>Calculation: ({errors} / {words}) * 100</div>
253
+ </div>
254
+ </div>
255
+
256
+ </div>
257
+ );
258
+ })()}
259
+ </div>
260
+ </motion.div>
261
+ )}
262
+ </AnimatePresence>
263
+
264
+ {/* Footer */}
265
+ <motion.p
266
+ style={{ textAlign: 'center', marginTop: 64, fontSize: 11.5, color: 'var(--muted2)' }}
267
+ initial={{ opacity: 0 }}
268
+ animate={{ opacity: 1 }}
269
+ transition={{ delay: 1.2 }}
270
+ >
271
+ CVR College of Engineering · CSE(DS) · NLP Auto-Corrector
272
+ </motion.p>
273
+ </div>
274
+ )
275
+ }
frontend/src/services/api.js ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * services/api.js
3
+ * ─────────────────
4
+ * Axios wrapper for all WriteRight endpoints.
5
+ */
6
+
7
+ import axios from 'axios'
8
+
9
+ const BASE_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8000'
10
+
11
+ const http = axios.create({
12
+ baseURL: BASE_URL,
13
+ timeout: 60_000,
14
+ headers: { 'Content-Type': 'application/json' },
15
+ })
16
+
17
+ http.interceptors.response.use(
18
+ res => res,
19
+ err => {
20
+ const message =
21
+ err?.response?.data?.detail ||
22
+ err?.response?.data?.message ||
23
+ err?.message || 'Unknown error'
24
+ return Promise.reject(new Error(message))
25
+ }
26
+ )
27
+
28
+ export async function correctText(text) {
29
+ // Removed all pipeline toggles for a cleaner, unified NLP pass
30
+ const { data } = await http.post('/api/correct', { text })
31
+ return data
32
+ }
33
+
34
+ export async function refineText(text, style = 'professional') {
35
+ const { data } = await http.post('/api/refine', { text, style })
36
+ return data
37
+ }
38
+
39
+ export async function getHealth() {
40
+ const { data } = await http.get('/api/health')
41
+ return data
42
+ }
43
+
44
+ export async function getStats() {
45
+ const { data } = await http.get('/api/stats')
46
+ return data
47
+ }
frontend/src/store/UIContext.jsx ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * store/UIContext.jsx
3
+ * ─────────────────────
4
+ * Provides:
5
+ * - theme: 'light' | 'dark' (persisted to localStorage)
6
+ * - toggleTheme()
7
+ * - mode: 'idle' | 'correction' | 'refine'
8
+ * - setMode()
9
+ */
10
+
11
+ import { createContext, useContext, useEffect, useState, useCallback } from 'react'
12
+
13
+ const UIContext = createContext(null)
14
+
15
+ export function UIProvider({ children }) {
16
+ const [theme, setTheme] = useState(() => {
17
+ const saved = localStorage.getItem('wr-theme')
18
+ if (saved) return saved
19
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
20
+ })
21
+
22
+ const [mode, setMode] = useState('idle')
23
+
24
+ // Apply theme class to <html>
25
+ useEffect(() => {
26
+ const root = document.documentElement
27
+ if (theme === 'dark') {
28
+ root.classList.add('dark')
29
+ } else {
30
+ root.classList.remove('dark')
31
+ }
32
+ localStorage.setItem('wr-theme', theme)
33
+ }, [theme])
34
+
35
+ const toggleTheme = useCallback(() => {
36
+ setTheme(t => t === 'light' ? 'dark' : 'light')
37
+ }, [])
38
+
39
+ return (
40
+ <UIContext.Provider value={{ theme, toggleTheme, mode, setMode }}>
41
+ {children}
42
+ </UIContext.Provider>
43
+ )
44
+ }
45
+
46
+ export function useUI() {
47
+ const ctx = useContext(UIContext)
48
+ if (!ctx) throw new Error('useUI must be inside UIProvider')
49
+ return ctx
50
+ }
frontend/src/styles/globals.css ADDED
@@ -0,0 +1,308 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ /* ══════════════════════════════════════════════════
6
+ DESIGN TOKENS — LIGHT MODE (default)
7
+ ══════════════════════════════════════════════════ */
8
+ @layer base {
9
+ :root {
10
+ /* Surfaces */
11
+ --bg: #f7f6f3;
12
+ --bg-alt: #efede8;
13
+ --surface: #ffffff;
14
+ --surface-alt: #f4f3f0;
15
+ --overlay: rgba(247,246,243,0.85);
16
+
17
+ /* Borders */
18
+ --border: #e2dfd8;
19
+ --border2: #cdc9c0;
20
+ --divider: #e8e5df;
21
+
22
+ /* Text */
23
+ --text: #1c1a17;
24
+ --text-2: #3d3a34;
25
+ --muted: #7c7870;
26
+ --muted2: #a09b93;
27
+ --placeholder: #b8b4ad;
28
+
29
+ /* Accent palette — indigo-to-teal */
30
+ --accent: #4f46e5;
31
+ --accent-soft: #eef2ff;
32
+ --accent-2: #0891b2;
33
+ --accent-2-soft: #ecfeff;
34
+
35
+ /* Semantic */
36
+ --green: #059669;
37
+ --green-soft: #d1fae5;
38
+ --red: #dc2626;
39
+ --red-soft: #fee2e2;
40
+ --amber: #d97706;
41
+ --amber-soft: #fef3c7;
42
+ --purple: #7c3aed;
43
+ --purple-soft: #ede9fe;
44
+
45
+ /* Shadows */
46
+ --shadow-sm: 0 1px 3px rgba(0,0,0,.06), 0 1px 2px rgba(0,0,0,.04);
47
+ --shadow-md: 0 4px 16px rgba(0,0,0,.08), 0 2px 6px rgba(0,0,0,.04);
48
+ --shadow-lg: 0 12px 40px rgba(0,0,0,.10), 0 4px 12px rgba(0,0,0,.06);
49
+ --shadow-card: 0 0 0 1px var(--border), var(--shadow-md);
50
+
51
+ /* Ring */
52
+ --ring-color: rgba(79,70,229,.25);
53
+
54
+ /* Flip card BG */
55
+ --flip-front: #ffffff;
56
+ --flip-back: #f0eeff;
57
+ }
58
+
59
+ /* ══════════════════════════════════════
60
+ DARK MODE
61
+ ══════════════════════════════════════ */
62
+ .dark {
63
+ --bg: #0f0f14;
64
+ --bg-alt: #13131a;
65
+ --surface: #18181f;
66
+ --surface-alt: #1e1e28;
67
+ --overlay: rgba(15,15,20,0.88);
68
+
69
+ --border: #252530;
70
+ --border2: #32323e;
71
+ --divider: #1f1f28;
72
+
73
+ --text: #f0eee8;
74
+ --text-2: #c8c4bc;
75
+ --muted: #6b6870;
76
+ --muted2: #4e4c55;
77
+ --placeholder: #3a3840;
78
+
79
+ --accent: #818cf8;
80
+ --accent-soft: #1e1e38;
81
+ --accent-2: #22d3ee;
82
+ --accent-2-soft: #0e2028;
83
+
84
+ --green: #34d399;
85
+ --green-soft: #0a2018;
86
+ --red: #f87171;
87
+ --red-soft: #2a0f0f;
88
+ --amber: #fbbf24;
89
+ --amber-soft: #2a1a08;
90
+ --purple: #c084fc;
91
+ --purple-soft: #1a0f2a;
92
+
93
+ --shadow-sm: 0 1px 3px rgba(0,0,0,.3);
94
+ --shadow-md: 0 4px 20px rgba(0,0,0,.4);
95
+ --shadow-lg: 0 12px 48px rgba(0,0,0,.5);
96
+ --shadow-card: 0 0 0 1px var(--border), var(--shadow-md);
97
+
98
+ --ring-color: rgba(129,140,248,.3);
99
+
100
+ --flip-front: #18181f;
101
+ --flip-back: #14102a;
102
+ }
103
+
104
+ /* ── Base resets ── */
105
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
106
+ html { scroll-behavior: smooth; font-size: 16px; }
107
+
108
+ body {
109
+ background-color: var(--bg);
110
+ color: var(--text);
111
+ font-family: 'DM Sans', sans-serif;
112
+ min-height: 100vh;
113
+ overflow-x: hidden;
114
+ -webkit-font-smoothing: antialiased;
115
+ -moz-osx-font-smoothing: grayscale;
116
+ transition: background-color 0.35s ease, color 0.35s ease;
117
+ }
118
+
119
+ ::selection { background: var(--accent-soft); color: var(--accent); }
120
+
121
+ ::-webkit-scrollbar { width: 5px; }
122
+ ::-webkit-scrollbar-track { background: transparent; }
123
+ ::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 10px; }
124
+ }
125
+
126
+ /* ══════════════════════════════════════════════════
127
+ COMPONENT CLASSES
128
+ ══════════════════════════════════════════════════ */
129
+ @layer components {
130
+
131
+ /* ── Layout ── */
132
+ .app-shell {
133
+ position: relative;
134
+ min-height: 100vh;
135
+ display: flex;
136
+ flex-direction: column;
137
+ }
138
+
139
+ .page-content {
140
+ flex: 1;
141
+ max-width: 1280px;
142
+ margin: 0 auto;
143
+ padding: 0 24px 80px;
144
+ width: 100%;
145
+ }
146
+
147
+ /* ── Split grid ── */
148
+ .split-grid {
149
+ display: grid;
150
+ grid-template-columns: 1fr 1fr;
151
+ gap: 24px;
152
+ align-items: start;
153
+ margin-top: 32px;
154
+ }
155
+
156
+ @media (max-width: 900px) {
157
+ .split-grid { grid-template-columns: 1fr; }
158
+ }
159
+
160
+ /* ── Ambient background ── */
161
+ .ambient {
162
+ position: fixed; inset: 0;
163
+ pointer-events: none; z-index: 0;
164
+ overflow: hidden;
165
+ }
166
+
167
+ .ambient-orb {
168
+ position: absolute;
169
+ border-radius: 50%;
170
+ filter: blur(80px);
171
+ opacity: 0.5;
172
+ animation: float 8s ease-in-out infinite;
173
+ }
174
+ .dark .ambient-orb { opacity: 0.18; }
175
+
176
+ .ambient-orb-1 { width: 500px; height: 500px; background: radial-gradient(circle, #c7d2fe, #a5b4fc, transparent 70%); top: -100px; left: -150px; animation-delay: 0s; }
177
+ .ambient-orb-2 { width: 400px; height: 400px; background: radial-gradient(circle, #a5f3fc, #67e8f9, transparent 70%); bottom: -80px; right: -120px; animation-delay: -3s; }
178
+ .ambient-orb-3 { width: 300px; height: 300px; background: radial-gradient(circle, #ddd6fe, #c4b5fd, transparent 70%); top: 40%; left: 60%; animation-delay: -5s; }
179
+
180
+ .noise-overlay {
181
+ position: fixed; inset: 0; z-index: 0; pointer-events: none; opacity: 0.025;
182
+ background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
183
+ background-repeat: repeat; background-size: 128px;
184
+ }
185
+
186
+ /* ── Navbar ── */
187
+ .navbar {
188
+ position: sticky; top: 0; z-index: 100;
189
+ background: var(--overlay); border-bottom: 1px solid var(--border);
190
+ backdrop-filter: blur(16px) saturate(160%); -webkit-backdrop-filter: blur(16px) saturate(160%);
191
+ transition: background 0.35s ease, border-color 0.35s ease;
192
+ }
193
+
194
+ /* ── Hero typography ── */
195
+ .hero-display {
196
+ font-family: 'Cormorant Garamond', serif;
197
+ font-size: clamp(3rem, 7vw, 5.5rem);
198
+ font-weight: 700; line-height: 1.05; letter-spacing: -0.02em; color: var(--text);
199
+ transition: color 0.35s ease;
200
+ }
201
+ .hero-display em {
202
+ font-style: italic; font-weight: 400; background: linear-gradient(135deg, var(--accent) 0%, var(--accent-2) 100%);
203
+ background-size: 200% 200%; -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
204
+ animation: gradientX 5s ease infinite;
205
+ }
206
+ .hero-sub { font-size: 15px; color: var(--muted); font-weight: 400; max-width: 460px; line-height: 1.7; margin: 12px auto 0; }
207
+
208
+ /* ── Cards ── */
209
+ .card {
210
+ background: var(--surface); border: 1px solid var(--border); border-radius: 16px;
211
+ box-shadow: var(--shadow-card); transition: background 0.35s ease, border-color 0.35s ease, box-shadow 0.25s ease; overflow: hidden;
212
+ }
213
+ .card:hover { box-shadow: var(--shadow-lg); }
214
+ .card.focused { border-color: var(--accent); box-shadow: 0 0 0 3px var(--ring-color), var(--shadow-md); }
215
+ .card.processing { --ring-color: rgba(79,70,229,.3); animation: pulseRing 2s ease-in-out infinite; border-color: var(--accent); }
216
+ .dark .card.processing { --ring-color: rgba(129,140,248,.25); }
217
+
218
+ .card-header {
219
+ display: flex; align-items: center; justify-content: space-between; padding: 14px 18px;
220
+ border-bottom: 1px solid var(--divider); background: var(--surface-alt);
221
+ }
222
+
223
+ /* ── Flip card ── */
224
+ .flip-scene { perspective: 1200px; }
225
+ .flip-card { position: relative; transform-style: preserve-3d; transition: transform 0.7s cubic-bezier(.34,.14,.4,1); }
226
+ .flip-card.flipped { transform: rotateY(180deg); }
227
+ .flip-face { backface-visibility: hidden; -webkit-backface-visibility: hidden; border-radius: 16px; overflow: hidden; }
228
+ .flip-face-front { background: var(--flip-front); border: 1px solid var(--border); box-shadow: var(--shadow-card); transition: background 0.35s ease, border-color 0.35s ease; }
229
+ .flip-face-back { position: absolute; inset: 0; background: var(--flip-back); border: 1px solid var(--accent); box-shadow: 0 0 0 1px var(--accent), var(--shadow-md); transform: rotateY(180deg); transition: background 0.35s ease; }
230
+
231
+ /* ── Buttons ── */
232
+ .btn {
233
+ display: inline-flex; align-items: center; gap: 7px; font-family: 'DM Sans', sans-serif; font-size: 13.5px;
234
+ font-weight: 500; border-radius: 10px; cursor: pointer; border: none; transition: all 0.18s cubic-bezier(.22,1,.36,1);
235
+ position: relative; overflow: hidden; white-space: nowrap; user-select: none;
236
+ }
237
+ .btn::before { content: ''; position: absolute; inset: 0; background: linear-gradient(180deg, rgba(255,255,255,.1) 0%, transparent 100%); opacity: 0; transition: opacity 0.15s; }
238
+ .btn:hover::before { opacity: 1; }
239
+ .btn:active { transform: scale(0.97); }
240
+ .btn:disabled { opacity: 0.45; cursor: not-allowed; pointer-events: none; }
241
+
242
+ .btn-primary { padding: 10px 20px; background: var(--accent); color: white; box-shadow: 0 2px 12px rgba(79,70,229,.35); }
243
+ .btn-primary:hover { background: #4338ca; box-shadow: 0 4px 20px rgba(79,70,229,.45); transform: translateY(-1px); }
244
+
245
+ .btn-cyan { padding: 10px 20px; background: var(--accent-2); color: white; box-shadow: 0 2px 12px rgba(8,145,178,.3); }
246
+ .btn-cyan:hover { background: #0e7490; box-shadow: 0 4px 20px rgba(8,145,178,.4); transform: translateY(-1px); }
247
+
248
+ .btn-ghost { padding: 7px 14px; background: transparent; border: 1px solid var(--border2); color: var(--muted); }
249
+ .btn-ghost:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
250
+
251
+ .btn-danger { padding: 7px 12px; font-size: 12.5px; background: transparent; border: 1px solid transparent; color: var(--muted); }
252
+ .btn-danger:hover { border-color: var(--red); color: var(--red); background: var(--red-soft); }
253
+
254
+ .btn-icon { padding: 7px; background: transparent; border: 1px solid var(--border); color: var(--muted); border-radius: 8px; }
255
+ .btn-icon:hover { background: var(--surface-alt); color: var(--text); border-color: var(--border2); }
256
+
257
+ /* ── Textarea ── */
258
+ .editor-textarea {
259
+ width: 100%; background: transparent; border: none; outline: none; resize: none; color: var(--text);
260
+ font-family: 'DM Sans', sans-serif; font-size: 15px; line-height: 1.85; padding: 16px 18px; transition: color 0.35s ease;
261
+ }
262
+ .editor-textarea::placeholder { color: var(--placeholder); }
263
+
264
+ /* ── Progress bar ── */
265
+ .char-bar-track { height: 2px; background: var(--divider); margin: 0 18px; border-radius: 2px; overflow: hidden; }
266
+ .char-bar-fill { height: 100%; border-radius: 2px; transition: width 0.15s ease, background 0.3s ease; }
267
+
268
+ /* ── Skeleton ── */
269
+ .skeleton {
270
+ border-radius: 6px; background-size: 200% 100%; animation: shimmer 1.7s infinite;
271
+ background: linear-gradient(90deg, var(--surface-alt) 25%, var(--border) 50%, var(--surface-alt) 75%);
272
+ }
273
+
274
+ /* ── Diff highlights ── */
275
+ .diff-del { background: var(--red-soft); color: var(--red); text-decoration: line-through; text-decoration-color: var(--red); border-radius: 3px; padding: 0 3px; font-size: 0.95em; }
276
+ .diff-ins { background: var(--green-soft); color: var(--green); border-radius: 3px; padding: 0 3px; font-weight: 500; }
277
+ .diff-hom { background: var(--amber-soft); color: var(--amber); border-radius: 3px; padding: 0 3px; border-bottom: 1.5px dashed var(--amber); }
278
+
279
+ /* ── Chips & Tags ── */
280
+ .chip { display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; border-radius: 100px; font-size: 11.5px; font-weight: 500; cursor: default; }
281
+ .chip-dot { width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
282
+ .chip-spell { background: var(--green-soft); color: var(--green); }
283
+ .chip-gram { background: var(--accent-soft); color: var(--accent); }
284
+ .chip-hom { background: var(--amber-soft); color: var(--amber); }
285
+
286
+ .tag { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 100px; font-size: 10px; font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase; border: 1px solid currentColor; }
287
+ .tag-spell { color: var(--green); background: var(--green-soft); border-color: var(--green-soft); }
288
+ .tag-refine { color: var(--purple); background: var(--purple-soft); border-color: var(--purple-soft); }
289
+ .tag-proc { color: var(--muted); background: var(--surface-alt); border-color: var(--border); }
290
+
291
+ /* ── Utilities ── */
292
+ .label-row { display: flex; align-items: center; gap: 6px; font-size: 10.5px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted2); }
293
+ .label-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--accent); flex-shrink: 0; }
294
+
295
+ .spinner { width: 15px; height: 15px; border-radius: 50%; border: 2px solid rgba(255,255,255,0.25); border-top-color: rgba(255,255,255,0.9); animation: spin 0.65s linear infinite; flex-shrink: 0; }
296
+ @keyframes spin { to { transform: rotate(360deg); } }
297
+
298
+ .divider-text { display: flex; align-items: center; gap: 12px; font-size: 11px; letter-spacing: 0.06em; color: var(--muted2); text-transform: uppercase; }
299
+ .divider-text::before, .divider-text::after { content: ''; flex: 1; height: 1px; background: var(--divider); }
300
+
301
+ .improvement-item { display: flex; align-items: flex-start; gap: 8px; padding: 8px 10px; border-radius: 8px; font-size: 13px; color: var(--text-2); background: var(--surface-alt); border: 1px solid var(--border); line-height: 1.5; }
302
+ .improvement-icon { color: var(--purple); flex-shrink: 0; margin-top: 1px; font-size: 14px; }
303
+
304
+ .empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 60px 24px; text-align: center; gap: 12px; color: var(--muted2); }
305
+ .empty-icon { font-size: 40px; opacity: 0.3; animation: float 4s ease-in-out infinite; }
306
+
307
+ .refined-text { font-family: 'Cormorant Garamond', serif; font-size: 17px; line-height: 1.9; color: var(--text); white-space: pre-wrap; word-break: break-word; transition: color 0.35s ease; }
308
+ }
frontend/tailwind.config.js ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('tailwindcss').Config} */
2
+ export default {
3
+ darkMode: 'class',
4
+ content: ['./index.html', './src/**/*.{js,jsx,ts,tsx}'],
5
+ theme: {
6
+ extend: {
7
+ fontFamily: {
8
+ display: ["'Cormorant Garamond'", 'Georgia', 'serif'],
9
+ body: ["'DM Sans'", 'sans-serif'],
10
+ mono: ["'JetBrains Mono'", 'monospace'],
11
+ },
12
+ colors: {
13
+ ink: { DEFAULT: '#1a1a2e', 50: '#f0f0f8', 100: '#e0e0f0' },
14
+ slate2: { DEFAULT: '#334155', light: '#f8fafc' },
15
+ },
16
+ animation: {
17
+ 'fade-up': 'fadeUp .55s cubic-bezier(.22,1,.36,1) both',
18
+ 'fade-down': 'fadeDown .55s cubic-bezier(.22,1,.36,1) both',
19
+ 'fade-in': 'fadeIn .4s ease both',
20
+ 'slide-left': 'slideLeft .55s cubic-bezier(.22,1,.36,1) both',
21
+ 'slide-right': 'slideRight .55s cubic-bezier(.22,1,.36,1) both',
22
+ 'shimmer': 'shimmer 1.7s infinite',
23
+ 'pulse-ring': 'pulseRing 2s ease-in-out infinite',
24
+ 'float': 'float 6s ease-in-out infinite',
25
+ 'gradient-x': 'gradientX 5s ease infinite',
26
+ },
27
+ keyframes: {
28
+ fadeUp: { from: { opacity: 0, transform: 'translateY(20px)' }, to: { opacity: 1, transform: 'none' } },
29
+ fadeDown: { from: { opacity: 0, transform: 'translateY(-16px)' }, to: { opacity: 1, transform: 'none' } },
30
+ fadeIn: { from: { opacity: 0 }, to: { opacity: 1 } },
31
+ slideLeft: { from: { opacity: 0, transform: 'translateX(-28px)' }, to: { opacity: 1, transform: 'none' } },
32
+ slideRight: { from: { opacity: 0, transform: 'translateX(28px)' }, to: { opacity: 1, transform: 'none' } },
33
+ shimmer: { '0%': { backgroundPosition: '200% 0' }, '100%': { backgroundPosition: '-200% 0' } },
34
+ pulseRing: { '0%,100%': { boxShadow: '0 0 0 0 var(--ring-color)' }, '50%': { boxShadow: '0 0 0 8px transparent' } },
35
+ float: { '0%,100%': { transform: 'translateY(0)' }, '50%': { transform: 'translateY(-12px)' } },
36
+ gradientX: { '0%,100%': { backgroundPosition: '0% 50%' }, '50%': { backgroundPosition: '100% 50%' } },
37
+ },
38
+ transitionTimingFunction: {
39
+ spring: 'cubic-bezier(.34,1.56,.64,1)',
40
+ smooth: 'cubic-bezier(.22,1,.36,1)',
41
+ },
42
+ },
43
+ },
44
+ plugins: [],
45
+ }
frontend/vite.config.js ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ server: {
7
+ port: 5173,
8
+ proxy: {
9
+ '/api': { target: 'http://localhost:8000', changeOrigin: true },
10
+ },
11
+ },
12
+ })
package-lock.json ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "writeright",
3
+ "lockfileVersion": 3,
4
+ "requires": true,
5
+ "packages": {
6
+ "": {
7
+ "dependencies": {
8
+ "@tanstack/react-query": "^5.95.2",
9
+ "use-debounce": "^10.1.1"
10
+ }
11
+ },
12
+ "node_modules/@tanstack/query-core": {
13
+ "version": "5.95.2",
14
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.95.2.tgz",
15
+ "integrity": "sha512-o4T8vZHZET4Bib3jZ/tCW9/7080urD4c+0/AUaYVpIqOsr7y0reBc1oX3ttNaSW5mYyvZHctiQ/UOP2PfdmFEQ==",
16
+ "license": "MIT",
17
+ "funding": {
18
+ "type": "github",
19
+ "url": "https://github.com/sponsors/tannerlinsley"
20
+ }
21
+ },
22
+ "node_modules/@tanstack/react-query": {
23
+ "version": "5.95.2",
24
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.95.2.tgz",
25
+ "integrity": "sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA==",
26
+ "license": "MIT",
27
+ "dependencies": {
28
+ "@tanstack/query-core": "5.95.2"
29
+ },
30
+ "funding": {
31
+ "type": "github",
32
+ "url": "https://github.com/sponsors/tannerlinsley"
33
+ },
34
+ "peerDependencies": {
35
+ "react": "^18 || ^19"
36
+ }
37
+ },
38
+ "node_modules/react": {
39
+ "version": "19.2.4",
40
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
41
+ "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
42
+ "license": "MIT",
43
+ "peer": true,
44
+ "engines": {
45
+ "node": ">=0.10.0"
46
+ }
47
+ },
48
+ "node_modules/use-debounce": {
49
+ "version": "10.1.1",
50
+ "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.1.1.tgz",
51
+ "integrity": "sha512-kvds8BHR2k28cFsxW8k3nc/tRga2rs1RHYCqmmGqb90MEeE++oALwzh2COiuBLO1/QXiOuShXoSN2ZpWnMmvuQ==",
52
+ "license": "MIT",
53
+ "engines": {
54
+ "node": ">= 16.0.0"
55
+ },
56
+ "peerDependencies": {
57
+ "react": "*"
58
+ }
59
+ }
60
+ }
61
+ }
package.json ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "dependencies": {
3
+ "@tanstack/react-query": "^5.95.2",
4
+ "use-debounce": "^10.1.1"
5
+ }
6
+ }