idnameraj commited on
Commit
27d974a
·
verified ·
1 Parent(s): 471eb17

Upload 80 files

Browse files
.gitattributes CHANGED
@@ -1,3 +1,9 @@
1
  # Keep shell scripts Unix-safe on Windows checkouts
2
  *.sh text eol=lf
3
  scripts/* text eol=lf
 
 
 
 
 
 
 
1
  # Keep shell scripts Unix-safe on Windows checkouts
2
  *.sh text eol=lf
3
  scripts/* text eol=lf
4
+ frontend/dist/apple-touch-icon.png filter=lfs diff=lfs merge=lfs -text
5
+ frontend/dist/zuzu-icon-512.png filter=lfs diff=lfs merge=lfs -text
6
+ frontend/dist/zuzu-logo.png filter=lfs diff=lfs merge=lfs -text
7
+ frontend/public/apple-touch-icon.png filter=lfs diff=lfs merge=lfs -text
8
+ frontend/public/zuzu-icon-512.png filter=lfs diff=lfs merge=lfs -text
9
+ frontend/public/zuzu-logo.png filter=lfs diff=lfs merge=lfs -text
Dockerfile CHANGED
@@ -22,7 +22,10 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
22
  LANGUAGE_TOOL_ENABLED=true \
23
  LANGUAGE_TOOL_EMBEDDED=true \
24
  LANGUAGE_TOOL_LANGUAGE=en-US \
25
- LANGUAGETOOL_JAVA_OPTS="-Xms256m -Xmx768m"
 
 
 
26
 
27
  WORKDIR /app
28
 
 
22
  LANGUAGE_TOOL_ENABLED=true \
23
  LANGUAGE_TOOL_EMBEDDED=true \
24
  LANGUAGE_TOOL_LANGUAGE=en-US \
25
+ LANGUAGE_TOOL_TIMEOUT=90 \
26
+ LANGUAGE_TOOL_CHUNK_CHARS=1800 \
27
+ GRAMMAR_MAX_CHARS=12000 \
28
+ LANGUAGETOOL_JAVA_OPTS="-Xms256m -Xmx1024m"
29
 
30
  WORKDIR /app
31
 
app/__pycache__/config.cpython-311.pyc CHANGED
Binary files a/app/__pycache__/config.cpython-311.pyc and b/app/__pycache__/config.cpython-311.pyc differ
 
app/__pycache__/main.cpython-311.pyc CHANGED
Binary files a/app/__pycache__/main.cpython-311.pyc and b/app/__pycache__/main.cpython-311.pyc differ
 
app/config.py CHANGED
@@ -45,6 +45,15 @@ SESSION_IDLE_MINUTES = int(os.environ.get("SESSION_IDLE_MINUTES", "30") or "30")
45
  # Example compose service: http://languagetool:8010 | local: http://127.0.0.1:8010
46
  LANGUAGE_TOOL_URL = (os.environ.get("LANGUAGE_TOOL_URL") or "").rstrip("/")
47
  LANGUAGE_TOOL_LANGUAGE = os.environ.get("LANGUAGE_TOOL_LANGUAGE") or "en-US"
48
- LANGUAGE_TOOL_TIMEOUT = float(os.environ.get("LANGUAGE_TOOL_TIMEOUT", "30") or "30")
 
 
 
 
 
 
 
 
 
49
  _lt_flag = (os.environ.get("LANGUAGE_TOOL_ENABLED") or "true").strip().lower()
50
  LANGUAGE_TOOL_ENABLED = _lt_flag not in {"0", "false", "no", "off"} and bool(LANGUAGE_TOOL_URL)
 
45
  # Example compose service: http://languagetool:8010 | local: http://127.0.0.1:8010
46
  LANGUAGE_TOOL_URL = (os.environ.get("LANGUAGE_TOOL_URL") or "").rstrip("/")
47
  LANGUAGE_TOOL_LANGUAGE = os.environ.get("LANGUAGE_TOOL_LANGUAGE") or "en-US"
48
+ LANGUAGE_TOOL_TIMEOUT = float(os.environ.get("LANGUAGE_TOOL_TIMEOUT", "90") or "90")
49
+ LANGUAGE_TOOL_CHUNK_CHARS = max(
50
+ 500,
51
+ min(int(os.environ.get("LANGUAGE_TOOL_CHUNK_CHARS", "1800") or "1800"), 8000),
52
+ )
53
+ # Hard cap for grammar checks (prevents Space OOMs / proxy 500s on huge pastes)
54
+ GRAMMAR_MAX_CHARS = max(
55
+ 1000,
56
+ min(int(os.environ.get("GRAMMAR_MAX_CHARS", "12000") or "12000"), MAX_CHARS),
57
+ )
58
  _lt_flag = (os.environ.get("LANGUAGE_TOOL_ENABLED") or "true").strip().lower()
59
  LANGUAGE_TOOL_ENABLED = _lt_flag not in {"0", "false", "no", "off"} and bool(LANGUAGE_TOOL_URL)
app/main.py CHANGED
@@ -26,8 +26,15 @@ from app.billing.quota import (
26
  record_rewrite,
27
  )
28
  from app.bootstrap import ensure_resources
29
- from app.config import APP_TITLE, AUTH_ENABLED, LANGUAGE_TOOL_LANGUAGE, LANGUAGE_TOOL_URL, MAX_CHARS
30
- from app.pipeline.grammar import check_grammar, languagetool_reachable, normalize_language
 
 
 
 
 
 
 
31
  from app.pipeline.nlp import spacy_available
32
  from app.pipeline.orchestrator import rewrite_text, similarity_check
33
  from app.pipeline.tones import normalize_tone
@@ -191,11 +198,14 @@ def api_grammar(body: GrammarRequest):
191
  if not text:
192
  logger.info("grammar rejected: empty text")
193
  raise HTTPException(status_code=400, detail="Paste some text to check.")
194
- if len(text) > MAX_CHARS:
195
- logger.info("grammar rejected: too long chars=%s", len(text))
196
  raise HTTPException(
197
  status_code=413,
198
- detail=f"Text is too long ({len(text):,} chars). Max is {MAX_CHARS:,}.",
 
 
 
199
  )
200
  try:
201
  result = check_grammar(text, language=language)
@@ -207,12 +217,18 @@ def api_grammar(body: GrammarRequest):
207
  result.get("engine"),
208
  )
209
  return result
 
 
210
  except Exception:
211
- logger.exception("grammar check crashed")
212
- raise HTTPException(
213
- status_code=500,
214
- detail="Grammar check failed on the server. Please try again.",
215
- ) from None
 
 
 
 
216
 
217
 
218
  def _register_frontend() -> None:
 
26
  record_rewrite,
27
  )
28
  from app.bootstrap import ensure_resources
29
+ from app.config import (
30
+ APP_TITLE,
31
+ AUTH_ENABLED,
32
+ GRAMMAR_MAX_CHARS,
33
+ LANGUAGE_TOOL_LANGUAGE,
34
+ LANGUAGE_TOOL_URL,
35
+ MAX_CHARS,
36
+ )
37
+ from app.pipeline.grammar import check_grammar, languagetool_reachable, normalize_language, rules_fallback_result
38
  from app.pipeline.nlp import spacy_available
39
  from app.pipeline.orchestrator import rewrite_text, similarity_check
40
  from app.pipeline.tones import normalize_tone
 
198
  if not text:
199
  logger.info("grammar rejected: empty text")
200
  raise HTTPException(status_code=400, detail="Paste some text to check.")
201
+ if len(text) > GRAMMAR_MAX_CHARS:
202
+ logger.info("grammar rejected: too long chars=%s max=%s", len(text), GRAMMAR_MAX_CHARS)
203
  raise HTTPException(
204
  status_code=413,
205
+ detail=(
206
+ f"Text is too long for grammar check ({len(text):,} chars). "
207
+ f"Max is {GRAMMAR_MAX_CHARS:,} characters — shorten the draft or split into sections."
208
+ ),
209
  )
210
  try:
211
  result = check_grammar(text, language=language)
 
217
  result.get("engine"),
218
  )
219
  return result
220
+ except HTTPException:
221
+ raise
222
  except Exception:
223
+ logger.exception("grammar check crashed — returning safe fallback")
224
+ try:
225
+ return rules_fallback_result(text, language)
226
+ except Exception:
227
+ logger.exception("grammar fallback also failed")
228
+ raise HTTPException(
229
+ status_code=500,
230
+ detail="Grammar check failed on the server. Try a shorter text and retry.",
231
+ ) from None
232
 
233
 
234
  def _register_frontend() -> None:
app/pipeline/__pycache__/grammar.cpython-311.pyc CHANGED
Binary files a/app/pipeline/__pycache__/grammar.cpython-311.pyc and b/app/pipeline/__pycache__/grammar.cpython-311.pyc differ
 
app/pipeline/grammar.py CHANGED
@@ -9,6 +9,7 @@ from dataclasses import asdict, dataclass
9
  import httpx
10
 
11
  from app.config import (
 
12
  LANGUAGE_TOOL_ENABLED,
13
  LANGUAGE_TOOL_LANGUAGE,
14
  LANGUAGE_TOOL_TIMEOUT,
@@ -119,37 +120,54 @@ def _check_languagetool_chunk(
119
  return issues
120
 
121
 
122
- def _iter_chunks(text: str, max_chars: int = 12000) -> list[tuple[int, str]]:
123
- """Split long text into overlapping-safe paragraph chunks with absolute offsets."""
124
- if len(text) <= max_chars:
 
 
 
 
 
125
  return [(0, text)]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  chunks: list[tuple[int, str]] = []
127
- parts = re.split(r"(?<=\n\n)", text)
128
  buf = ""
129
  buf_start = 0
130
- pos = 0
131
- for part in parts:
132
- if not part:
 
 
 
 
 
133
  continue
134
- if buf and len(buf) + len(part) > max_chars:
135
  chunks.append((buf_start, buf))
136
  buf = part
137
- buf_start = pos
138
  else:
139
  if not buf:
140
- buf_start = pos
141
  buf += part
142
- pos += len(part)
143
- if len(buf) >= max_chars:
144
- chunks.append((buf_start, buf))
145
- buf = ""
146
  if buf:
147
  chunks.append((buf_start, buf))
148
- if not chunks:
149
- # hard split
150
- for i in range(0, len(text), max_chars):
151
- chunks.append((i, text[i : i + max_chars]))
152
- return chunks
153
 
154
 
155
  def normalize_language(language: str | None) -> str:
@@ -164,20 +182,41 @@ def normalize_language(language: str | None) -> str:
164
  return f"{parts[0].lower()}-{parts[1].upper()}"
165
 
166
 
167
- def check_languagetool(text: str, language: str | None = None) -> list[GrammarIssue] | None:
168
- """Call self-hosted LanguageTool. Returns None if unavailable."""
 
 
 
169
  if not LANGUAGE_TOOL_ENABLED or not LANGUAGE_TOOL_URL:
170
- return None
171
  lang = normalize_language(language)
 
 
 
 
172
  try:
173
- issues: list[GrammarIssue] = []
174
- with httpx.Client(timeout=LANGUAGE_TOOL_TIMEOUT) as client:
175
- for base, chunk in _iter_chunks(text):
176
- issues.extend(_check_languagetool_chunk(client, chunk, base, lang))
177
- return issues
 
 
 
 
 
 
 
178
  except Exception as exc: # noqa: BLE001
179
- logger.warning("LanguageTool request failed: %s", exc)
180
- return None
 
 
 
 
 
 
 
181
 
182
 
183
  def check_rules(text: str) -> list[GrammarIssue]:
@@ -325,15 +364,18 @@ def check_grammar(text: str, language: str | None = None) -> dict:
325
  "note": "Paste some text to check.",
326
  }
327
 
328
- lt_issues = check_languagetool(text, lang)
329
  if lt_issues is not None:
330
  filtered = _dedupe(lt_issues)
 
 
 
331
  return {
332
  "issues": [asdict(i) for i in filtered],
333
  "input_words": _word_count(text),
334
  "engine": "languagetool",
335
  "language": lang,
336
- "note": f"Self-hosted LanguageTool ({lang}) — full sentence grammar & spelling.",
337
  }
338
 
339
  # Fallback when LT is down / not configured
@@ -345,6 +387,8 @@ def check_grammar(text: str, language: str | None = None) -> dict:
345
  else "LanguageTool is not configured — using basic local rules only. "
346
  "Set LANGUAGE_TOOL_URL and start the self-hosted server."
347
  )
 
 
348
  return {
349
  "issues": [asdict(i) for i in filtered],
350
  "input_words": _word_count(text),
@@ -352,3 +396,22 @@ def check_grammar(text: str, language: str | None = None) -> dict:
352
  "language": lang,
353
  "note": note,
354
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  import httpx
10
 
11
  from app.config import (
12
+ LANGUAGE_TOOL_CHUNK_CHARS,
13
  LANGUAGE_TOOL_ENABLED,
14
  LANGUAGE_TOOL_LANGUAGE,
15
  LANGUAGE_TOOL_TIMEOUT,
 
120
  return issues
121
 
122
 
123
+ def _iter_chunks(text: str, max_chars: int | None = None) -> list[tuple[int, str]]:
124
+ """Split text into small chunks by sentence, then hard-split if needed.
125
+
126
+ Long single paragraphs (no blank lines) must still be chunked — otherwise
127
+ LanguageTool OOMs / times out on Spaces.
128
+ """
129
+ limit = max_chars if max_chars is not None else LANGUAGE_TOOL_CHUNK_CHARS
130
+ if len(text) <= limit:
131
  return [(0, text)]
132
+
133
+ # Prefer sentence boundaries, then paragraph breaks
134
+ pieces: list[tuple[int, str]] = []
135
+ pattern = re.compile(r".+?(?:[.!?][)\"']*\s+|\n+|$)", re.DOTALL)
136
+ pos = 0
137
+ for m in pattern.finditer(text):
138
+ chunk = m.group(0)
139
+ if not chunk:
140
+ continue
141
+ pieces.append((m.start(), chunk))
142
+ pos = m.end()
143
+ if pos < len(text):
144
+ pieces.append((pos, text[pos:]))
145
+ if not pieces:
146
+ pieces = [(0, text)]
147
+
148
  chunks: list[tuple[int, str]] = []
 
149
  buf = ""
150
  buf_start = 0
151
+ for start, part in pieces:
152
+ # Oversized single sentence → hard split
153
+ if len(part) > limit:
154
+ if buf:
155
+ chunks.append((buf_start, buf))
156
+ buf = ""
157
+ for i in range(0, len(part), limit):
158
+ chunks.append((start + i, part[i : i + limit]))
159
  continue
160
+ if buf and len(buf) + len(part) > limit:
161
  chunks.append((buf_start, buf))
162
  buf = part
163
+ buf_start = start
164
  else:
165
  if not buf:
166
+ buf_start = start
167
  buf += part
 
 
 
 
168
  if buf:
169
  chunks.append((buf_start, buf))
170
+ return chunks or [(0, text)]
 
 
 
 
171
 
172
 
173
  def normalize_language(language: str | None) -> str:
 
182
  return f"{parts[0].lower()}-{parts[1].upper()}"
183
 
184
 
185
+ def check_languagetool(text: str, language: str | None = None) -> tuple[list[GrammarIssue] | None, str | None]:
186
+ """Call self-hosted LanguageTool.
187
+
188
+ Returns (issues, warning). issues is None if LT is unavailable entirely.
189
+ """
190
  if not LANGUAGE_TOOL_ENABLED or not LANGUAGE_TOOL_URL:
191
+ return None, None
192
  lang = normalize_language(language)
193
+ chunks = _iter_chunks(text)
194
+ issues: list[GrammarIssue] = []
195
+ failures = 0
196
+ timeout = httpx.Timeout(connect=5.0, read=LANGUAGE_TOOL_TIMEOUT, write=10.0, pool=5.0)
197
  try:
198
+ with httpx.Client(timeout=timeout) as client:
199
+ for base, chunk in chunks:
200
+ try:
201
+ issues.extend(_check_languagetool_chunk(client, chunk, base, lang))
202
+ except Exception as exc: # noqa: BLE001
203
+ failures += 1
204
+ logger.warning(
205
+ "LanguageTool chunk failed (offset=%s len=%s): %s",
206
+ base,
207
+ len(chunk),
208
+ exc,
209
+ )
210
  except Exception as exc: # noqa: BLE001
211
+ logger.warning("LanguageTool client failed: %s", exc)
212
+ return None, str(exc)
213
+
214
+ if not issues and failures == len(chunks):
215
+ return None, f"All {failures} LanguageTool chunk(s) failed"
216
+ warning = None
217
+ if failures:
218
+ warning = f"{failures} of {len(chunks)} chunk(s) failed; showing partial results."
219
+ return issues, warning
220
 
221
 
222
  def check_rules(text: str) -> list[GrammarIssue]:
 
364
  "note": "Paste some text to check.",
365
  }
366
 
367
+ lt_issues, lt_warning = check_languagetool(text, lang)
368
  if lt_issues is not None:
369
  filtered = _dedupe(lt_issues)
370
+ note = f"Self-hosted LanguageTool ({lang}) — full sentence grammar & spelling."
371
+ if lt_warning:
372
+ note = f"{note} {lt_warning}"
373
  return {
374
  "issues": [asdict(i) for i in filtered],
375
  "input_words": _word_count(text),
376
  "engine": "languagetool",
377
  "language": lang,
378
+ "note": note,
379
  }
380
 
381
  # Fallback when LT is down / not configured
 
387
  else "LanguageTool is not configured — using basic local rules only. "
388
  "Set LANGUAGE_TOOL_URL and start the self-hosted server."
389
  )
390
+ if lt_warning:
391
+ note = f"{note} ({lt_warning})"
392
  return {
393
  "issues": [asdict(i) for i in filtered],
394
  "input_words": _word_count(text),
 
396
  "language": lang,
397
  "note": note,
398
  }
399
+
400
+
401
+ def rules_fallback_result(text: str, language: str | None = None, extra_note: str = "") -> dict:
402
+ """Safe local-rules payload when LT or the main checker fails hard."""
403
+ lang = normalize_language(language)
404
+ filtered = _dedupe(check_rules(text or ""))
405
+ note = (
406
+ "Grammar engine hit an error on this text — showing basic local rules only. "
407
+ "Try a shorter section, or wait and retry."
408
+ )
409
+ if extra_note:
410
+ note = f"{note} {extra_note}"
411
+ return {
412
+ "issues": [asdict(i) for i in filtered],
413
+ "input_words": _word_count(text or ""),
414
+ "engine": "rules",
415
+ "language": lang,
416
+ "note": note,
417
+ }
frontend/dist/assets/index.css CHANGED
@@ -318,6 +318,33 @@ textarea {
318
  line-height: 1.45;
319
  }
320
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
321
  .grammar-lang {
322
  flex-wrap: wrap;
323
  }
 
318
  line-height: 1.45;
319
  }
320
 
321
+ .engine-badge {
322
+ display: inline-flex;
323
+ align-self: flex-start;
324
+ margin-top: 0.35rem;
325
+ font-size: 0.72rem;
326
+ font-weight: 700;
327
+ letter-spacing: 0.04em;
328
+ text-transform: uppercase;
329
+ padding: 0.25rem 0.55rem;
330
+ border-radius: 999px;
331
+ border: 1px solid var(--panel-edge);
332
+ background: rgba(255, 255, 255, 0.75);
333
+ color: var(--ink-soft);
334
+ }
335
+
336
+ .engine-badge.engine-languagetool {
337
+ border-color: rgba(15, 122, 95, 0.35);
338
+ background: rgba(15, 122, 95, 0.1);
339
+ color: var(--accent);
340
+ }
341
+
342
+ .engine-badge.engine-rules {
343
+ border-color: rgba(226, 90, 60, 0.35);
344
+ background: rgba(226, 90, 60, 0.1);
345
+ color: var(--coral);
346
+ }
347
+
348
  .grammar-lang {
349
  flex-wrap: wrap;
350
  }
frontend/dist/assets/index.js CHANGED
@@ -15,6 +15,7 @@ const TONES = ["Neutral", "Casual", "Formal", "Academic"];
15
  const STRENGTHS = ["Light", "Normal", "Heavy"];
16
  const STRENGTH_MAP = { Light: 0, Normal: 1, Heavy: 2 };
17
  const MAX_CHARS = 50000;
 
18
  const LOGO_SRC = "/zuzu-logo.png";
19
 
20
  function BrandLogo({ size = 44, className = "" }) {
@@ -623,6 +624,7 @@ function App() {
623
  const [grammarLanguage, setGrammarLanguage] = useState("en-US");
624
  const [grammarIssues, setGrammarIssues] = useState([]);
625
  const [grammarNote, setGrammarNote] = useState("");
 
626
  const [grammarLoading, setGrammarLoading] = useState(false);
627
  const [grammarError, setGrammarError] = useState("");
628
  const [grammarMeta, setGrammarMeta] = useState("");
@@ -671,10 +673,14 @@ function App() {
671
  async function onGrammarCheck() {
672
  const text = grammarText.trim();
673
  if (!text) { setGrammarError("Paste some text first — or try the sample."); return; }
674
- if (text.length > MAX_CHARS) { setGrammarError(`Text is too long (${text.length.toLocaleString()} chars).`); return; }
 
 
 
675
  setGrammarLoading(true);
676
  setGrammarError("");
677
  setGrammarMeta("Checking…");
 
678
  try {
679
  const result = await checkGrammar(text, {
680
  language: grammarLanguage,
@@ -682,12 +688,14 @@ function App() {
682
  });
683
  setGrammarIssues(result.issues || []);
684
  setGrammarNote(result.note || "");
 
685
  const n = (result.issues || []).length;
686
  const lang = result.language || grammarLanguage;
687
  setGrammarMeta(n ? `${n} issue${n === 1 ? "" : "s"} · ${result.input_words} words · ${lang}` : `No issues found · ${result.input_words} words · ${lang}`);
688
  } catch (err) {
689
  setGrammarError(err instanceof Error ? err.message : "Grammar check failed.");
690
  setGrammarMeta("");
 
691
  setGrammarIssues([]);
692
  } finally {
693
  setGrammarLoading(false);
@@ -863,11 +871,17 @@ function App() {
863
  h("p", { className: "grammar-lead" },
864
  "Self-hosted LanguageTool checks full sentence grammar for ",
865
  h("strong", null, (GRAMMAR_LANGUAGES.find((l) => l.id === grammarLanguage) || {}).label || grammarLanguage),
866
- ". If LanguageTool is offline, basic local rules are used instead.",
867
  ),
 
 
 
 
 
 
868
  ),
869
  h("div", { className: "toolbar-actions" },
870
- h("button", { type: "button", className: "btn btn-quiet", disabled: grammarLoading, onClick: () => { setGrammarText(""); setGrammarIssues([]); setGrammarMeta(""); setGrammarError(""); setGrammarNote(""); } }, "Clear"),
871
  h("button", { type: "button", className: "btn btn-quiet", disabled: grammarLoading, onClick: () => { setGrammarText(GRAMMAR_SAMPLE); setGrammarIssues([]); setGrammarError(""); setGrammarMeta("Sample loaded — hit Check grammar."); setGrammarNote(""); } }, "Try sample"),
872
  grammarIssues.length ? h("button", { type: "button", className: "btn btn-quiet", disabled: grammarLoading, onClick: onApplyAllGrammar }, "Apply all") : null,
873
  h("button", { type: "button", className: "btn btn-primary btn-rewrite", disabled: grammarLoading, onClick: () => onGrammarCheck() }, grammarLoading ? "Checking…" : "Check grammar"),
 
15
  const STRENGTHS = ["Light", "Normal", "Heavy"];
16
  const STRENGTH_MAP = { Light: 0, Normal: 1, Heavy: 2 };
17
  const MAX_CHARS = 50000;
18
+ const GRAMMAR_MAX_CHARS = 12000;
19
  const LOGO_SRC = "/zuzu-logo.png";
20
 
21
  function BrandLogo({ size = 44, className = "" }) {
 
624
  const [grammarLanguage, setGrammarLanguage] = useState("en-US");
625
  const [grammarIssues, setGrammarIssues] = useState([]);
626
  const [grammarNote, setGrammarNote] = useState("");
627
+ const [grammarEngine, setGrammarEngine] = useState("");
628
  const [grammarLoading, setGrammarLoading] = useState(false);
629
  const [grammarError, setGrammarError] = useState("");
630
  const [grammarMeta, setGrammarMeta] = useState("");
 
673
  async function onGrammarCheck() {
674
  const text = grammarText.trim();
675
  if (!text) { setGrammarError("Paste some text first — or try the sample."); return; }
676
+ if (text.length > GRAMMAR_MAX_CHARS) {
677
+ setGrammarError(`Text is too long for grammar check (${text.length.toLocaleString()} chars). Max is ${GRAMMAR_MAX_CHARS.toLocaleString()} — shorten or split into sections.`);
678
+ return;
679
+ }
680
  setGrammarLoading(true);
681
  setGrammarError("");
682
  setGrammarMeta("Checking…");
683
+ setGrammarEngine("");
684
  try {
685
  const result = await checkGrammar(text, {
686
  language: grammarLanguage,
 
688
  });
689
  setGrammarIssues(result.issues || []);
690
  setGrammarNote(result.note || "");
691
+ setGrammarEngine(result.engine || "");
692
  const n = (result.issues || []).length;
693
  const lang = result.language || grammarLanguage;
694
  setGrammarMeta(n ? `${n} issue${n === 1 ? "" : "s"} · ${result.input_words} words · ${lang}` : `No issues found · ${result.input_words} words · ${lang}`);
695
  } catch (err) {
696
  setGrammarError(err instanceof Error ? err.message : "Grammar check failed.");
697
  setGrammarMeta("");
698
+ setGrammarEngine("");
699
  setGrammarIssues([]);
700
  } finally {
701
  setGrammarLoading(false);
 
871
  h("p", { className: "grammar-lead" },
872
  "Self-hosted LanguageTool checks full sentence grammar for ",
873
  h("strong", null, (GRAMMAR_LANGUAGES.find((l) => l.id === grammarLanguage) || {}).label || grammarLanguage),
874
+ ". Long text is checked in small chunks. If LanguageTool is offline, basic local rules are used instead.",
875
  ),
876
+ grammarEngine
877
+ ? h("span", {
878
+ className: `engine-badge engine-${grammarEngine}`,
879
+ title: grammarNote || undefined,
880
+ }, `Engine: ${grammarEngine === "languagetool" ? "LanguageTool" : grammarEngine === "rules" ? "Local rules" : grammarEngine}`)
881
+ : null,
882
  ),
883
  h("div", { className: "toolbar-actions" },
884
+ h("button", { type: "button", className: "btn btn-quiet", disabled: grammarLoading, onClick: () => { setGrammarText(""); setGrammarIssues([]); setGrammarMeta(""); setGrammarError(""); setGrammarNote(""); setGrammarEngine(""); } }, "Clear"),
885
  h("button", { type: "button", className: "btn btn-quiet", disabled: grammarLoading, onClick: () => { setGrammarText(GRAMMAR_SAMPLE); setGrammarIssues([]); setGrammarError(""); setGrammarMeta("Sample loaded — hit Check grammar."); setGrammarNote(""); } }, "Try sample"),
886
  grammarIssues.length ? h("button", { type: "button", className: "btn btn-quiet", disabled: grammarLoading, onClick: onApplyAllGrammar }, "Apply all") : null,
887
  h("button", { type: "button", className: "btn btn-primary btn-rewrite", disabled: grammarLoading, onClick: () => onGrammarCheck() }, grammarLoading ? "Checking…" : "Check grammar"),
frontend/src/App.tsx CHANGED
@@ -18,6 +18,7 @@ import type { PlanCard } from "./supabase";
18
  const LOGO_SRC = "/zuzu-logo.png";
19
  const LOGO_FALLBACK = "/favicon.svg";
20
  const MAX_CHARS = 50000;
 
21
 
22
  type ProductId = "writer" | "grammar";
23
 
@@ -621,6 +622,7 @@ export default function App() {
621
  const [grammarLanguage, setGrammarLanguage] = useState("en-US");
622
  const [grammarIssues, setGrammarIssues] = useState<GrammarIssue[]>([]);
623
  const [grammarNote, setGrammarNote] = useState("");
 
624
  const [grammarLoading, setGrammarLoading] = useState(false);
625
  const [grammarError, setGrammarError] = useState("");
626
  const [grammarMeta, setGrammarMeta] = useState("");
@@ -694,13 +696,16 @@ export default function App() {
694
  setGrammarError("Paste some text first — or try the sample.");
695
  return;
696
  }
697
- if (text.length > MAX_CHARS) {
698
- setGrammarError(`Text is too long (${text.length.toLocaleString()} chars).`);
 
 
699
  return;
700
  }
701
  setGrammarLoading(true);
702
  setGrammarError("");
703
  setGrammarMeta("Checking…");
 
704
  try {
705
  const result = await checkGrammar(text, {
706
  language: grammarLanguage,
@@ -708,6 +713,7 @@ export default function App() {
708
  });
709
  setGrammarIssues(result.issues);
710
  setGrammarNote(result.note ?? "");
 
711
  setGrammarMeta(
712
  result.issues.length
713
  ? `${result.issues.length} issue${result.issues.length === 1 ? "" : "s"} · ${result.input_words} words · ${result.language ?? grammarLanguage}`
@@ -717,6 +723,7 @@ export default function App() {
717
  const apiErr = err as ApiError;
718
  setGrammarError(apiErr.message || "Grammar check failed.");
719
  setGrammarMeta("");
 
720
  setGrammarIssues([]);
721
  } finally {
722
  setGrammarLoading(false);
@@ -967,8 +974,16 @@ export default function App() {
967
  <p className="grammar-lead">
968
  Self-hosted LanguageTool checks full sentence grammar for{" "}
969
  <strong>{GRAMMAR_LANGUAGES.find((l) => l.id === grammarLanguage)?.label ?? grammarLanguage}</strong>
970
- . If LanguageTool is offline, basic local rules are used instead.
971
  </p>
 
 
 
 
 
 
 
 
972
  </div>
973
  <div className="toolbar-actions">
974
  <button
@@ -980,6 +995,7 @@ export default function App() {
980
  setGrammarMeta("");
981
  setGrammarError("");
982
  setGrammarNote("");
 
983
  }}
984
  disabled={grammarLoading}
985
  >
 
18
  const LOGO_SRC = "/zuzu-logo.png";
19
  const LOGO_FALLBACK = "/favicon.svg";
20
  const MAX_CHARS = 50000;
21
+ const GRAMMAR_MAX_CHARS = 12000;
22
 
23
  type ProductId = "writer" | "grammar";
24
 
 
622
  const [grammarLanguage, setGrammarLanguage] = useState("en-US");
623
  const [grammarIssues, setGrammarIssues] = useState<GrammarIssue[]>([]);
624
  const [grammarNote, setGrammarNote] = useState("");
625
+ const [grammarEngine, setGrammarEngine] = useState("");
626
  const [grammarLoading, setGrammarLoading] = useState(false);
627
  const [grammarError, setGrammarError] = useState("");
628
  const [grammarMeta, setGrammarMeta] = useState("");
 
696
  setGrammarError("Paste some text first — or try the sample.");
697
  return;
698
  }
699
+ if (text.length > GRAMMAR_MAX_CHARS) {
700
+ setGrammarError(
701
+ `Text is too long for grammar check (${text.length.toLocaleString()} chars). Max is ${GRAMMAR_MAX_CHARS.toLocaleString()} — shorten or split into sections.`,
702
+ );
703
  return;
704
  }
705
  setGrammarLoading(true);
706
  setGrammarError("");
707
  setGrammarMeta("Checking…");
708
+ setGrammarEngine("");
709
  try {
710
  const result = await checkGrammar(text, {
711
  language: grammarLanguage,
 
713
  });
714
  setGrammarIssues(result.issues);
715
  setGrammarNote(result.note ?? "");
716
+ setGrammarEngine(result.engine || "");
717
  setGrammarMeta(
718
  result.issues.length
719
  ? `${result.issues.length} issue${result.issues.length === 1 ? "" : "s"} · ${result.input_words} words · ${result.language ?? grammarLanguage}`
 
723
  const apiErr = err as ApiError;
724
  setGrammarError(apiErr.message || "Grammar check failed.");
725
  setGrammarMeta("");
726
+ setGrammarEngine("");
727
  setGrammarIssues([]);
728
  } finally {
729
  setGrammarLoading(false);
 
974
  <p className="grammar-lead">
975
  Self-hosted LanguageTool checks full sentence grammar for{" "}
976
  <strong>{GRAMMAR_LANGUAGES.find((l) => l.id === grammarLanguage)?.label ?? grammarLanguage}</strong>
977
+ . Long text is checked in small chunks. If LanguageTool is offline, basic local rules are used instead.
978
  </p>
979
+ {grammarEngine ? (
980
+ <span
981
+ className={`engine-badge engine-${grammarEngine}`}
982
+ title={grammarNote || undefined}
983
+ >
984
+ Engine: {grammarEngine === "languagetool" ? "LanguageTool" : grammarEngine === "rules" ? "Local rules" : grammarEngine}
985
+ </span>
986
+ ) : null}
987
  </div>
988
  <div className="toolbar-actions">
989
  <button
 
995
  setGrammarMeta("");
996
  setGrammarError("");
997
  setGrammarNote("");
998
+ setGrammarEngine("");
999
  }}
1000
  disabled={grammarLoading}
1001
  >
frontend/src/index.css CHANGED
@@ -318,6 +318,33 @@ textarea {
318
  line-height: 1.45;
319
  }
320
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
321
  .grammar-lang {
322
  flex-wrap: wrap;
323
  }
 
318
  line-height: 1.45;
319
  }
320
 
321
+ .engine-badge {
322
+ display: inline-flex;
323
+ align-self: flex-start;
324
+ margin-top: 0.35rem;
325
+ font-size: 0.72rem;
326
+ font-weight: 700;
327
+ letter-spacing: 0.04em;
328
+ text-transform: uppercase;
329
+ padding: 0.25rem 0.55rem;
330
+ border-radius: 999px;
331
+ border: 1px solid var(--panel-edge);
332
+ background: rgba(255, 255, 255, 0.75);
333
+ color: var(--ink-soft);
334
+ }
335
+
336
+ .engine-badge.engine-languagetool {
337
+ border-color: rgba(15, 122, 95, 0.35);
338
+ background: rgba(15, 122, 95, 0.1);
339
+ color: var(--accent);
340
+ }
341
+
342
+ .engine-badge.engine-rules {
343
+ border-color: rgba(226, 90, 60, 0.35);
344
+ background: rgba(226, 90, 60, 0.1);
345
+ color: var(--coral);
346
+ }
347
+
348
  .grammar-lang {
349
  flex-wrap: wrap;
350
  }
scripts/start.sh CHANGED
@@ -1,47 +1,52 @@
1
- #!/usr/bin/env bash
2
- # Start self-hosted LanguageTool (optional) then the ZuZu API.
3
- set -euo pipefail
4
-
5
- LT_HOME="${LANGUAGETOOL_HOME:-/opt/languagetool}"
6
- LT_PORT="${LANGUAGETOOL_PORT:-8010}"
7
- LT_JAVA_OPTS="${LANGUAGETOOL_JAVA_OPTS:--Xms256m -Xmx768m}"
8
-
9
- start_languagetool() {
10
- if [[ "${LANGUAGE_TOOL_EMBEDDED:-true}" != "true" ]]; then
11
- echo "[zuzu] Embedded LanguageTool disabled (LANGUAGE_TOOL_EMBEDDED=${LANGUAGE_TOOL_EMBEDDED:-})"
12
- return 0
13
- fi
14
- if [[ ! -f "${LT_HOME}/languagetool-server.jar" ]]; then
15
- echo "[zuzu] LanguageTool jar not found at ${LT_HOME}; grammar will use local rules only"
16
- return 0
17
- fi
18
-
19
- echo "[zuzu] Starting LanguageTool on :${LT_PORT} "
20
- # shellcheck disable=SC2086
21
- java ${LT_JAVA_OPTS} -cp "${LT_HOME}/languagetool-server.jar" \
22
- org.languagetool.server.HTTPServer \
23
- --port "${LT_PORT}" \
24
- --public \
25
- --allow-origin "*" \
26
- >/tmp/languagetool.log 2>&1 &
27
- echo $! >/tmp/languagetool.pid
28
-
29
- export LANGUAGE_TOOL_URL="${LANGUAGE_TOOL_URL:-http://127.0.0.1:${LT_PORT}}"
30
- export LANGUAGE_TOOL_ENABLED="${LANGUAGE_TOOL_ENABLED:-true}"
31
-
32
- for i in $(seq 1 90); do
33
- if curl -fsS "http://127.0.0.1:${LT_PORT}/v2/languages" >/dev/null 2>&1; then
34
- echo "[zuzu] LanguageTool ready (attempt ${i})"
35
- return 0
36
- fi
37
- sleep 2
38
- done
39
- echo "[zuzu] WARNING: LanguageTool did not become ready; see /tmp/languagetool.log"
40
- echo "[zuzu] Grammar will fall back to local rules until LT is up"
41
- }
42
-
43
- start_languagetool
44
-
45
- HOST="${HOST:-0.0.0.0}"
46
- PORT="${PORT:-7860}"
47
- exec uvicorn app.main:app --host "${HOST}" --port "${PORT}"
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Start self-hosted LanguageTool (optional) then the ZuZu API.
3
+ set -euo pipefail
4
+
5
+ LT_HOME="${LANGUAGETOOL_HOME:-/opt/languagetool}"
6
+ LT_PORT="${LANGUAGETOOL_PORT:-8010}"
7
+ LT_JAVA_OPTS="${LANGUAGETOOL_JAVA_OPTS:--Xms256m -Xmx1024m}"
8
+
9
+ start_languagetool() {
10
+ if [[ "${LANGUAGE_TOOL_EMBEDDED:-true}" != "true" ]]; then
11
+ echo "[zuzu] Embedded LanguageTool disabled (LANGUAGE_TOOL_EMBEDDED=${LANGUAGE_TOOL_EMBEDDED:-})"
12
+ return 0
13
+ fi
14
+ if [[ ! -f "${LT_HOME}/languagetool-server.jar" ]]; then
15
+ echo "[zuzu] LanguageTool jar not found at ${LT_HOME}; grammar will use local rules only"
16
+ return 0
17
+ fi
18
+
19
+ echo "[zuzu] Starting LanguageTool on :${LT_PORT} ..."
20
+ # shellcheck disable=SC2086
21
+ java ${LT_JAVA_OPTS} -cp "${LT_HOME}/languagetool-server.jar" \
22
+ org.languagetool.server.HTTPServer \
23
+ --port "${LT_PORT}" \
24
+ --public \
25
+ --allow-origin "*" \
26
+ >/tmp/languagetool.log 2>&1 &
27
+ echo $! >/tmp/languagetool.pid
28
+
29
+ export LANGUAGE_TOOL_URL="${LANGUAGE_TOOL_URL:-http://127.0.0.1:${LT_PORT}}"
30
+ export LANGUAGE_TOOL_ENABLED="${LANGUAGE_TOOL_ENABLED:-true}"
31
+
32
+ for i in $(seq 1 90); do
33
+ if curl -fsS "http://127.0.0.1:${LT_PORT}/v2/languages" >/dev/null 2>&1; then
34
+ echo "[zuzu] LanguageTool HTTP up (attempt ${i}) — prewarming check..."
35
+ curl -fsS -X POST "http://127.0.0.1:${LT_PORT}/v2/check" \
36
+ --data-urlencode "language=en-US" \
37
+ --data-urlencode "text=She go to the store yesterday." \
38
+ >/dev/null 2>&1 || true
39
+ echo "[zuzu] LanguageTool ready"
40
+ return 0
41
+ fi
42
+ sleep 2
43
+ done
44
+ echo "[zuzu] WARNING: LanguageTool did not become ready; see /tmp/languagetool.log"
45
+ echo "[zuzu] Grammar will fall back to local rules until LT is up"
46
+ }
47
+
48
+ start_languagetool
49
+
50
+ HOST="${HOST:-0.0.0.0}"
51
+ PORT="${PORT:-7860}"
52
+ exec uvicorn app.main:app --host "${HOST}" --port "${PORT}"