Deploy contextual paraphrase refinements
Browse files- .env.example +8 -0
- models/schemas.py +24 -1
- routers/paraphrase.py +44 -3
- services/paraphrase_service.py +105 -12
- tests/test_api.py +25 -2
.env.example
CHANGED
|
@@ -13,6 +13,14 @@ GROQ_API_KEY=
|
|
| 13 |
HF_API_TOKEN=
|
| 14 |
# HF_MODEL=mistralai/Mistral-7B-Instruct-v0.3
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
# ── Optional overrides ────────────────────────────────────────────────────────
|
| 17 |
# Grammar: defaults to public languagetool.org API (free, no key needed)
|
| 18 |
# Override to point at a self-hosted LanguageTool container
|
|
|
|
| 13 |
HF_API_TOKEN=
|
| 14 |
# HF_MODEL=mistralai/Mistral-7B-Instruct-v0.3
|
| 15 |
|
| 16 |
+
# ── Premium tier ──────────────────────────────────────────────────────────────
|
| 17 |
+
# GitHub Models (requires GitHub PAT with Models scope — free with Student Pack)
|
| 18 |
+
# GITHUB_PAT=ghp_your_github_pat_here
|
| 19 |
+
# GITHUB_MODEL=Meta-Llama-3.1-405B-Instruct
|
| 20 |
+
|
| 21 |
+
# Promo codes that unlock premium (comma-separated)
|
| 22 |
+
# PREMIUM_PROMO_CODES=LAUNCH2024,BETAUSER
|
| 23 |
+
|
| 24 |
# ── Optional overrides ────────────────────────────────────────────────────────
|
| 25 |
# Grammar: defaults to public languagetool.org API (free, no key needed)
|
| 26 |
# Override to point at a self-hosted LanguageTool container
|
models/schemas.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
from pydantic import BaseModel, Field
|
| 2 |
-
from typing import Optional
|
| 3 |
|
| 4 |
|
| 5 |
# ── Paraphrase ──────────────────────────────────────────────────────────────
|
|
@@ -8,6 +8,10 @@ class ParaphraseRequest(BaseModel):
|
|
| 8 |
text: str = Field(..., min_length=1, max_length=10000, description="Text to paraphrase")
|
| 9 |
intensity: int = Field(3, ge=1, le=5, description="Paraphrase intensity (1=minimal, 5=aggressive)")
|
| 10 |
model: str = Field("standard", description="Model to use: 'standard' or a GitHub Models model name")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
|
| 13 |
class ParaphraseResponse(BaseModel):
|
|
@@ -15,6 +19,25 @@ class ParaphraseResponse(BaseModel):
|
|
| 15 |
paraphrased: str
|
| 16 |
intensity: int
|
| 17 |
model_used: str = "standard"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
|
| 20 |
# ── Grammar ─────────────────────────────────────────────────────────────────
|
|
|
|
| 1 |
from pydantic import BaseModel, Field
|
| 2 |
+
from typing import Literal, Optional
|
| 3 |
|
| 4 |
|
| 5 |
# ── Paraphrase ──────────────────────────────────────────────────────────────
|
|
|
|
| 8 |
text: str = Field(..., min_length=1, max_length=10000, description="Text to paraphrase")
|
| 9 |
intensity: int = Field(3, ge=1, le=5, description="Paraphrase intensity (1=minimal, 5=aggressive)")
|
| 10 |
model: str = Field("standard", description="Model to use: 'standard' or a GitHub Models model name")
|
| 11 |
+
writing_mode: Literal[
|
| 12 |
+
"standard", "fluency", "formal", "simple", "creative",
|
| 13 |
+
"academic", "expand", "shorten", "humanize",
|
| 14 |
+
] = Field("standard", description="Writing style for the paraphrase")
|
| 15 |
|
| 16 |
|
| 17 |
class ParaphraseResponse(BaseModel):
|
|
|
|
| 19 |
paraphrased: str
|
| 20 |
intensity: int
|
| 21 |
model_used: str = "standard"
|
| 22 |
+
writing_mode: str = "standard"
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class ParaphraseRefineRequest(BaseModel):
|
| 26 |
+
text: str = Field(..., min_length=1, max_length=10000, description="Full paraphrased text for context")
|
| 27 |
+
selected_text: str = Field(..., min_length=1, max_length=2000, description="Selected sentence or word")
|
| 28 |
+
kind: Literal["sentence", "word"]
|
| 29 |
+
writing_mode: Literal[
|
| 30 |
+
"standard", "fluency", "formal", "simple", "creative",
|
| 31 |
+
"academic", "expand", "shorten", "humanize",
|
| 32 |
+
] = "standard"
|
| 33 |
+
intensity: int = Field(3, ge=1, le=5)
|
| 34 |
+
count: int = Field(5, ge=2, le=8)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class ParaphraseRefineResponse(BaseModel):
|
| 38 |
+
selected_text: str
|
| 39 |
+
kind: str
|
| 40 |
+
suggestions: list[str]
|
| 41 |
|
| 42 |
|
| 43 |
# ── Grammar ─────────────────────────────────────────────────────────────────
|
routers/paraphrase.py
CHANGED
|
@@ -2,11 +2,17 @@ from fastapi import APIRouter, Depends, HTTPException
|
|
| 2 |
from sqlalchemy.orm import Session
|
| 3 |
|
| 4 |
from database import get_db
|
| 5 |
-
from models.schemas import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
from routers.auth import _optional_user_id
|
| 7 |
from services.auth_service import get_user_by_id
|
| 8 |
from services.paraphrase_service import paraphrase as _paraphrase
|
| 9 |
from services.paraphrase_service import paraphrase_premium as _paraphrase_premium
|
|
|
|
| 10 |
|
| 11 |
router = APIRouter(prefix="/api", tags=["paraphrase"])
|
| 12 |
|
|
@@ -34,9 +40,18 @@ def paraphrase_endpoint(
|
|
| 34 |
|
| 35 |
try:
|
| 36 |
if use_premium:
|
| 37 |
-
result_text, model_used = _paraphrase_premium(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
else:
|
| 39 |
-
result_text, model_used = _paraphrase(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
except Exception as exc:
|
| 41 |
raise HTTPException(status_code=500, detail=str(exc))
|
| 42 |
|
|
@@ -45,4 +60,30 @@ def paraphrase_endpoint(
|
|
| 45 |
paraphrased=result_text,
|
| 46 |
intensity=request.intensity,
|
| 47 |
model_used=model_used,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
)
|
|
|
|
| 2 |
from sqlalchemy.orm import Session
|
| 3 |
|
| 4 |
from database import get_db
|
| 5 |
+
from models.schemas import (
|
| 6 |
+
ParaphraseRefineRequest,
|
| 7 |
+
ParaphraseRefineResponse,
|
| 8 |
+
ParaphraseRequest,
|
| 9 |
+
ParaphraseResponse,
|
| 10 |
+
)
|
| 11 |
from routers.auth import _optional_user_id
|
| 12 |
from services.auth_service import get_user_by_id
|
| 13 |
from services.paraphrase_service import paraphrase as _paraphrase
|
| 14 |
from services.paraphrase_service import paraphrase_premium as _paraphrase_premium
|
| 15 |
+
from services.paraphrase_service import refine_selection as _refine_selection
|
| 16 |
|
| 17 |
router = APIRouter(prefix="/api", tags=["paraphrase"])
|
| 18 |
|
|
|
|
| 40 |
|
| 41 |
try:
|
| 42 |
if use_premium:
|
| 43 |
+
result_text, model_used = _paraphrase_premium(
|
| 44 |
+
request.text,
|
| 45 |
+
request.intensity,
|
| 46 |
+
model=request.model,
|
| 47 |
+
writing_mode=request.writing_mode,
|
| 48 |
+
)
|
| 49 |
else:
|
| 50 |
+
result_text, model_used = _paraphrase(
|
| 51 |
+
request.text,
|
| 52 |
+
request.intensity,
|
| 53 |
+
writing_mode=request.writing_mode,
|
| 54 |
+
)
|
| 55 |
except Exception as exc:
|
| 56 |
raise HTTPException(status_code=500, detail=str(exc))
|
| 57 |
|
|
|
|
| 60 |
paraphrased=result_text,
|
| 61 |
intensity=request.intensity,
|
| 62 |
model_used=model_used,
|
| 63 |
+
writing_mode=request.writing_mode,
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
@router.post(
|
| 68 |
+
"/paraphrase/refine",
|
| 69 |
+
response_model=ParaphraseRefineResponse,
|
| 70 |
+
summary="Suggest contextual sentence or word alternatives",
|
| 71 |
+
)
|
| 72 |
+
def refine_paraphrase(request: ParaphraseRefineRequest) -> ParaphraseRefineResponse:
|
| 73 |
+
try:
|
| 74 |
+
suggestions = _refine_selection(
|
| 75 |
+
text=request.text,
|
| 76 |
+
selected_text=request.selected_text,
|
| 77 |
+
kind=request.kind,
|
| 78 |
+
writing_mode=request.writing_mode,
|
| 79 |
+
intensity=request.intensity,
|
| 80 |
+
count=request.count,
|
| 81 |
+
)
|
| 82 |
+
except Exception as exc:
|
| 83 |
+
raise HTTPException(status_code=500, detail=str(exc))
|
| 84 |
+
|
| 85 |
+
return ParaphraseRefineResponse(
|
| 86 |
+
selected_text=request.selected_text,
|
| 87 |
+
kind=request.kind,
|
| 88 |
+
suggestions=suggestions,
|
| 89 |
)
|
services/paraphrase_service.py
CHANGED
|
@@ -15,6 +15,18 @@ logger = logging.getLogger(__name__)
|
|
| 15 |
|
| 16 |
_CHUNK_CHAR_LIMIT = 3500
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
INTENSITY_PROMPTS: dict[int, str] = {
|
| 19 |
1: (
|
| 20 |
"Paraphrase the following text with minimal changes — only replace a few words "
|
|
@@ -41,25 +53,33 @@ INTENSITY_PROMPTS: dict[int, str] = {
|
|
| 41 |
|
| 42 |
_SYSTEM_PROMPT = (
|
| 43 |
"You are a professional writing assistant specialised in paraphrasing. "
|
|
|
|
|
|
|
|
|
|
| 44 |
"Return ONLY the paraphrased text — no explanations, no labels, "
|
| 45 |
"no quotation marks, no preamble."
|
| 46 |
)
|
| 47 |
|
| 48 |
|
| 49 |
-
def paraphrase(text: str, intensity: int = 3) -> tuple[str, str]:
|
| 50 |
"""Return (paraphrased_text, model_used) at the given intensity (1-5)."""
|
| 51 |
try:
|
| 52 |
-
return _paraphrase_llm(text, intensity), "standard"
|
| 53 |
except RuntimeError:
|
| 54 |
return _paraphrase_t5(text, intensity), "standard"
|
| 55 |
|
| 56 |
|
| 57 |
-
def paraphrase_premium(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
"""Paraphrase using a premium GitHub Models model. Returns (text, model_used)."""
|
| 59 |
try:
|
| 60 |
-
return _paraphrase_llm_premium(text, intensity, model)
|
| 61 |
except RuntimeError:
|
| 62 |
-
return _paraphrase_llm(text, intensity), "standard"
|
| 63 |
|
| 64 |
|
| 65 |
def _split_into_chunks(text: str) -> list[str]:
|
|
@@ -84,9 +104,10 @@ def _split_into_chunks(text: str) -> list[str]:
|
|
| 84 |
return chunks if chunks else [text]
|
| 85 |
|
| 86 |
|
| 87 |
-
def _paraphrase_with_fn(text: str, intensity: int, chat_fn) -> str:
|
| 88 |
"""Shared chunking logic for both free and premium paraphrasing."""
|
| 89 |
instruction = INTENSITY_PROMPTS.get(intensity, INTENSITY_PROMPTS[3])
|
|
|
|
| 90 |
chunks = _split_into_chunks(text)
|
| 91 |
|
| 92 |
parts: list[str] = []
|
|
@@ -103,22 +124,32 @@ def _paraphrase_with_fn(text: str, intensity: int, chat_fn) -> str:
|
|
| 103 |
)
|
| 104 |
part = chat_fn(
|
| 105 |
system_prompt=_SYSTEM_PROMPT,
|
| 106 |
-
user_prompt=
|
|
|
|
|
|
|
|
|
|
| 107 |
temperature=0.4 + (intensity - 1) * 0.15,
|
| 108 |
-
|
|
|
|
|
|
|
| 109 |
)
|
| 110 |
parts.append(part)
|
| 111 |
|
| 112 |
return "\n\n".join(parts)
|
| 113 |
|
| 114 |
|
| 115 |
-
def _paraphrase_llm(text: str, intensity: int) -> str:
|
| 116 |
from services.llm_client import llm_chat
|
| 117 |
|
| 118 |
-
return _paraphrase_with_fn(text, intensity, llm_chat)
|
| 119 |
|
| 120 |
|
| 121 |
-
def _paraphrase_llm_premium(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
from services.llm_client import llm_chat_premium
|
| 123 |
|
| 124 |
model_used = "standard"
|
|
@@ -132,10 +163,72 @@ def _paraphrase_llm_premium(text: str, intensity: int, model: str) -> tuple[str,
|
|
| 132 |
model_used = mu
|
| 133 |
return content
|
| 134 |
|
| 135 |
-
result = _paraphrase_with_fn(text, intensity, _chat_fn)
|
| 136 |
return result, model_used
|
| 137 |
|
| 138 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
# ── T5 fallback ──────────────────────────────────────────────────────────────
|
| 140 |
|
| 141 |
INTENSITY_PARAMS: dict[int, dict] = {
|
|
|
|
| 15 |
|
| 16 |
_CHUNK_CHAR_LIMIT = 3500
|
| 17 |
|
| 18 |
+
MODE_PROMPTS: dict[str, str] = {
|
| 19 |
+
"standard": "Use natural vocabulary and varied sentence structure.",
|
| 20 |
+
"fluency": "Prioritize clarity, readability, grammar, and a smooth natural flow.",
|
| 21 |
+
"formal": "Use polished, professional language without sounding inflated.",
|
| 22 |
+
"simple": "Use plain language and shorter, easier-to-understand phrasing.",
|
| 23 |
+
"creative": "Use fresh, expressive phrasing while preserving every fact.",
|
| 24 |
+
"academic": "Use precise, objective, scholarly language and retain technical terms.",
|
| 25 |
+
"expand": "Add useful clarity and transitions, but do not invent facts or arguments.",
|
| 26 |
+
"shorten": "Make the text concise while preserving every essential fact and qualification.",
|
| 27 |
+
"humanize": "Use natural cadence, varied syntax, and idiomatic wording that sounds genuinely human.",
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
INTENSITY_PROMPTS: dict[int, str] = {
|
| 31 |
1: (
|
| 32 |
"Paraphrase the following text with minimal changes — only replace a few words "
|
|
|
|
| 53 |
|
| 54 |
_SYSTEM_PROMPT = (
|
| 55 |
"You are a professional writing assistant specialised in paraphrasing. "
|
| 56 |
+
"Preserve every fact, name, number, date, citation, technical term, and negation. "
|
| 57 |
+
"Never generalise a precise claim or add information that is not in the source. "
|
| 58 |
+
"Keep paragraph boundaries and, unless asked to expand or shorten, keep roughly the same length. "
|
| 59 |
"Return ONLY the paraphrased text — no explanations, no labels, "
|
| 60 |
"no quotation marks, no preamble."
|
| 61 |
)
|
| 62 |
|
| 63 |
|
| 64 |
+
def paraphrase(text: str, intensity: int = 3, writing_mode: str = "standard") -> tuple[str, str]:
|
| 65 |
"""Return (paraphrased_text, model_used) at the given intensity (1-5)."""
|
| 66 |
try:
|
| 67 |
+
return _paraphrase_llm(text, intensity, writing_mode), "standard"
|
| 68 |
except RuntimeError:
|
| 69 |
return _paraphrase_t5(text, intensity), "standard"
|
| 70 |
|
| 71 |
|
| 72 |
+
def paraphrase_premium(
|
| 73 |
+
text: str,
|
| 74 |
+
intensity: int = 3,
|
| 75 |
+
model: str = "Meta-Llama-3.1-405B-Instruct",
|
| 76 |
+
writing_mode: str = "standard",
|
| 77 |
+
) -> tuple[str, str]:
|
| 78 |
"""Paraphrase using a premium GitHub Models model. Returns (text, model_used)."""
|
| 79 |
try:
|
| 80 |
+
return _paraphrase_llm_premium(text, intensity, model, writing_mode)
|
| 81 |
except RuntimeError:
|
| 82 |
+
return _paraphrase_llm(text, intensity, writing_mode), "standard"
|
| 83 |
|
| 84 |
|
| 85 |
def _split_into_chunks(text: str) -> list[str]:
|
|
|
|
| 104 |
return chunks if chunks else [text]
|
| 105 |
|
| 106 |
|
| 107 |
+
def _paraphrase_with_fn(text: str, intensity: int, chat_fn, writing_mode: str = "standard") -> str:
|
| 108 |
"""Shared chunking logic for both free and premium paraphrasing."""
|
| 109 |
instruction = INTENSITY_PROMPTS.get(intensity, INTENSITY_PROMPTS[3])
|
| 110 |
+
mode_instruction = MODE_PROMPTS.get(writing_mode, MODE_PROMPTS["standard"])
|
| 111 |
chunks = _split_into_chunks(text)
|
| 112 |
|
| 113 |
parts: list[str] = []
|
|
|
|
| 124 |
)
|
| 125 |
part = chat_fn(
|
| 126 |
system_prompt=_SYSTEM_PROMPT,
|
| 127 |
+
user_prompt=(
|
| 128 |
+
f"{instruction} {mode_instruction}{context}\n\n"
|
| 129 |
+
f"Text to paraphrase:\n{chunk}\n\nParaphrased version:"
|
| 130 |
+
),
|
| 131 |
temperature=0.4 + (intensity - 1) * 0.15,
|
| 132 |
+
# A chunk is capped at 3,500 characters, so 2,048 tokens leaves
|
| 133 |
+
# ample room while avoiding an unnecessarily large generation cap.
|
| 134 |
+
max_tokens=2048,
|
| 135 |
)
|
| 136 |
parts.append(part)
|
| 137 |
|
| 138 |
return "\n\n".join(parts)
|
| 139 |
|
| 140 |
|
| 141 |
+
def _paraphrase_llm(text: str, intensity: int, writing_mode: str = "standard") -> str:
|
| 142 |
from services.llm_client import llm_chat
|
| 143 |
|
| 144 |
+
return _paraphrase_with_fn(text, intensity, llm_chat, writing_mode)
|
| 145 |
|
| 146 |
|
| 147 |
+
def _paraphrase_llm_premium(
|
| 148 |
+
text: str,
|
| 149 |
+
intensity: int,
|
| 150 |
+
model: str,
|
| 151 |
+
writing_mode: str = "standard",
|
| 152 |
+
) -> tuple[str, str]:
|
| 153 |
from services.llm_client import llm_chat_premium
|
| 154 |
|
| 155 |
model_used = "standard"
|
|
|
|
| 163 |
model_used = mu
|
| 164 |
return content
|
| 165 |
|
| 166 |
+
result = _paraphrase_with_fn(text, intensity, _chat_fn, writing_mode)
|
| 167 |
return result, model_used
|
| 168 |
|
| 169 |
|
| 170 |
+
def refine_selection(
|
| 171 |
+
text: str,
|
| 172 |
+
selected_text: str,
|
| 173 |
+
kind: str,
|
| 174 |
+
writing_mode: str = "standard",
|
| 175 |
+
intensity: int = 3,
|
| 176 |
+
count: int = 5,
|
| 177 |
+
) -> list[str]:
|
| 178 |
+
"""Generate contextual alternatives for one sentence or one word."""
|
| 179 |
+
from services.llm_client import llm_chat
|
| 180 |
+
|
| 181 |
+
mode_instruction = MODE_PROMPTS.get(writing_mode, MODE_PROMPTS["standard"])
|
| 182 |
+
if kind == "word":
|
| 183 |
+
system_prompt = (
|
| 184 |
+
"You are a context-aware thesaurus. Suggest replacement words or short phrases "
|
| 185 |
+
"that fit the exact grammar, tense, number, meaning, and tone of the selected word. "
|
| 186 |
+
"Do not repeat the selected word. Return only a numbered list, one option per line."
|
| 187 |
+
)
|
| 188 |
+
user_prompt = (
|
| 189 |
+
f"Full text:\n{text}\n\nSelected word: {selected_text}\n"
|
| 190 |
+
f"Style: {mode_instruction}\nProvide {count} precise replacements."
|
| 191 |
+
)
|
| 192 |
+
else:
|
| 193 |
+
system_prompt = (
|
| 194 |
+
"You rewrite a selected sentence without changing its meaning. Preserve every fact, "
|
| 195 |
+
"name, number, qualification, citation, and negation. Each suggestion must stand in "
|
| 196 |
+
"the same surrounding text. Return only a numbered list, one complete sentence per line."
|
| 197 |
+
)
|
| 198 |
+
intensity_instruction = INTENSITY_PROMPTS.get(intensity, INTENSITY_PROMPTS[3])
|
| 199 |
+
user_prompt = (
|
| 200 |
+
f"Full text for context:\n{text}\n\nSelected sentence:\n{selected_text}\n\n"
|
| 201 |
+
f"Style: {mode_instruction}\nChange level: {intensity_instruction}\n"
|
| 202 |
+
f"Provide {count} distinct, precise alternatives."
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
raw = llm_chat(
|
| 206 |
+
system_prompt=system_prompt,
|
| 207 |
+
user_prompt=user_prompt,
|
| 208 |
+
temperature=0.35 if kind == "word" else 0.65,
|
| 209 |
+
# Alternatives are short. Smaller caps reduce provider latency and
|
| 210 |
+
# prevent verbose models from continuing past the requested list.
|
| 211 |
+
max_tokens=180 if kind == "word" else 600,
|
| 212 |
+
)
|
| 213 |
+
return _parse_suggestions(raw, selected_text, count)
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def _parse_suggestions(raw: str, selected_text: str, count: int) -> list[str]:
|
| 217 |
+
"""Normalize numbered/bulleted LLM output into a stable deduplicated list."""
|
| 218 |
+
suggestions: list[str] = []
|
| 219 |
+
selected_key = selected_text.strip().casefold().rstrip(".!?")
|
| 220 |
+
for line in raw.splitlines():
|
| 221 |
+
value = re.sub(r"^\s*(?:[-*•]|\d+[.)])\s*", "", line).strip()
|
| 222 |
+
value = value.strip('"“”')
|
| 223 |
+
if not value or value.casefold().rstrip(".!?") == selected_key:
|
| 224 |
+
continue
|
| 225 |
+
if value.casefold() not in {item.casefold() for item in suggestions}:
|
| 226 |
+
suggestions.append(value)
|
| 227 |
+
if len(suggestions) == count:
|
| 228 |
+
break
|
| 229 |
+
return suggestions
|
| 230 |
+
|
| 231 |
+
|
| 232 |
# ── T5 fallback ──────────────────────────────────────────────────────────────
|
| 233 |
|
| 234 |
INTENSITY_PARAMS: dict[int, dict] = {
|
tests/test_api.py
CHANGED
|
@@ -31,16 +31,20 @@ class TestHealth:
|
|
| 31 |
|
| 32 |
class TestParaphrase:
|
| 33 |
def test_paraphrase_success(self):
|
| 34 |
-
with patch(
|
|
|
|
|
|
|
|
|
|
| 35 |
response = client.post("/api/paraphrase", json={"text": "The quick brown fox jumps over the lazy dog.", "intensity": 3}) # noqa: E501
|
| 36 |
assert response.status_code == 200
|
| 37 |
data = response.json()
|
| 38 |
assert data["original"] == "The quick brown fox jumps over the lazy dog."
|
| 39 |
assert data["paraphrased"] == "A quick fox leapt over a lazy dog."
|
| 40 |
assert data["intensity"] == 3
|
|
|
|
| 41 |
|
| 42 |
def test_paraphrase_intensity_bounds(self):
|
| 43 |
-
with patch("routers.paraphrase._paraphrase", return_value="result"):
|
| 44 |
r1 = client.post("/api/paraphrase", json={"text": "Hello world.", "intensity": 1})
|
| 45 |
r5 = client.post("/api/paraphrase", json={"text": "Hello world.", "intensity": 5})
|
| 46 |
assert r1.status_code == 200
|
|
@@ -59,6 +63,25 @@ class TestParaphrase:
|
|
| 59 |
response = client.post("/api/paraphrase", json={"text": "Hello.", "intensity": 3})
|
| 60 |
assert response.status_code == 500
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
# ── Grammar ───────────────────────────────────────────────────────────────────
|
| 64 |
|
|
|
|
| 31 |
|
| 32 |
class TestParaphrase:
|
| 33 |
def test_paraphrase_success(self):
|
| 34 |
+
with patch(
|
| 35 |
+
"routers.paraphrase._paraphrase",
|
| 36 |
+
return_value=("A quick fox leapt over a lazy dog.", "standard"),
|
| 37 |
+
):
|
| 38 |
response = client.post("/api/paraphrase", json={"text": "The quick brown fox jumps over the lazy dog.", "intensity": 3}) # noqa: E501
|
| 39 |
assert response.status_code == 200
|
| 40 |
data = response.json()
|
| 41 |
assert data["original"] == "The quick brown fox jumps over the lazy dog."
|
| 42 |
assert data["paraphrased"] == "A quick fox leapt over a lazy dog."
|
| 43 |
assert data["intensity"] == 3
|
| 44 |
+
assert data["writing_mode"] == "standard"
|
| 45 |
|
| 46 |
def test_paraphrase_intensity_bounds(self):
|
| 47 |
+
with patch("routers.paraphrase._paraphrase", return_value=("result", "standard")):
|
| 48 |
r1 = client.post("/api/paraphrase", json={"text": "Hello world.", "intensity": 1})
|
| 49 |
r5 = client.post("/api/paraphrase", json={"text": "Hello world.", "intensity": 5})
|
| 50 |
assert r1.status_code == 200
|
|
|
|
| 63 |
response = client.post("/api/paraphrase", json={"text": "Hello.", "intensity": 3})
|
| 64 |
assert response.status_code == 500
|
| 65 |
|
| 66 |
+
def test_contextual_refine_options(self):
|
| 67 |
+
with patch(
|
| 68 |
+
"routers.paraphrase._refine_selection",
|
| 69 |
+
return_value=["Clear writing makes ideas easier to share.", "Good prose communicates ideas clearly."],
|
| 70 |
+
):
|
| 71 |
+
response = client.post(
|
| 72 |
+
"/api/paraphrase/refine",
|
| 73 |
+
json={
|
| 74 |
+
"text": "Good writing helps people communicate ideas clearly.",
|
| 75 |
+
"selected_text": "Good writing helps people communicate ideas clearly.",
|
| 76 |
+
"kind": "sentence",
|
| 77 |
+
"writing_mode": "fluency",
|
| 78 |
+
"intensity": 3,
|
| 79 |
+
"count": 2,
|
| 80 |
+
},
|
| 81 |
+
)
|
| 82 |
+
assert response.status_code == 200
|
| 83 |
+
assert len(response.json()["suggestions"]) == 2
|
| 84 |
+
|
| 85 |
|
| 86 |
# ── Grammar ───────────────────────────────────────────────────────────────────
|
| 87 |
|