rushabh13 commited on
Commit
5e17442
·
1 Parent(s): 53f85e0

Deploy contextual paraphrase refinements

Browse files
.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 ParaphraseRequest, ParaphraseResponse
 
 
 
 
 
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(request.text, request.intensity, model=request.model)
 
 
 
 
 
38
  else:
39
- result_text, model_used = _paraphrase(request.text, request.intensity)
 
 
 
 
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(text: str, intensity: int = 3, model: str = "Meta-Llama-3.1-405B-Instruct") -> tuple[str, str]:
 
 
 
 
 
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=f"{instruction}{context}\n\nText to paraphrase:\n{chunk}\n\nParaphrased version:",
 
 
 
107
  temperature=0.4 + (intensity - 1) * 0.15,
108
- max_tokens=4096,
 
 
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(text: str, intensity: int, model: str) -> tuple[str, str]:
 
 
 
 
 
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("routers.paraphrase._paraphrase", return_value="A quick fox leapt over a lazy dog."):
 
 
 
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