Spaces:
Sleeping
Sleeping
biradar-vilohith-patil commited on
Commit ·
c46e9ff
0
Parent(s):
full size
Browse files- .gitignore +24 -0
- Dockerfile +31 -0
- README.md +88 -0
- backend/README.md +55 -0
- backend/app/__init__.py +1 -0
- backend/app/api/__init__.py +0 -0
- backend/app/api/routes.py +108 -0
- backend/app/config.py +60 -0
- backend/app/main.py +85 -0
- backend/app/models/__init__.py +0 -0
- backend/app/models/model_loader.py +61 -0
- backend/app/pipeline/__init__.py +0 -0
- backend/app/pipeline/grammar_corrector.py +110 -0
- backend/app/pipeline/homophone_resolver.py +41 -0
- backend/app/pipeline/processor.py +121 -0
- backend/app/pipeline/scorer.py +97 -0
- backend/app/pipeline/spell_checker.py +61 -0
- backend/app/resources/dictionary.txt +895 -0
- backend/app/resources/homophones.json +142 -0
- backend/app/utils/__init__.py +0 -0
- backend/app/utils/text_utils.py +104 -0
- backend/requirements.txt +8 -0
- backend/tests/__init__.py +0 -0
- backend/tests/test_pipeline.py +227 -0
- frontend/index.html +15 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +26 -0
- frontend/postcss.config.js +1 -0
- frontend/src/App.jsx +52 -0
- frontend/src/components/CorrectionPanel.jsx +269 -0
- frontend/src/components/Navbar.jsx +49 -0
- frontend/src/components/TextEditor.jsx +186 -0
- frontend/src/components/ThemeToggle.jsx +63 -0
- frontend/src/hooks/useTextCorrection.js +38 -0
- frontend/src/hooks/useTextRefinement.js +26 -0
- frontend/src/main.jsx +23 -0
- frontend/src/pages/Home.jsx +275 -0
- frontend/src/services/api.js +47 -0
- frontend/src/store/UIContext.jsx +50 -0
- frontend/src/styles/globals.css +308 -0
- frontend/tailwind.config.js +45 -0
- frontend/vite.config.js +12 -0
- package-lock.json +61 -0
- 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 & 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 |
+
}
|