5 new features: dark mode, shortcuts, SSE tasks, compare, compose

Dark mode:
- body.dark overrides CSS variables (navy→cream reverse)
- Persisted in localStorage, applied before paint to avoid flash
- Toggle button in nav (moon/sun icon), Shift+D shortcut

Keyboard shortcuts:
- g h/n/u/t/s/c/w/d/k for page navigation
- n for new case, ? for help (Shift+/)
- Esc closes any open dialog, blurs focused input
- Help modal via showShortcutsHelp() with styled kbd elements

SSE tasks stream:
- /api/system/tasks/stream pushes snapshots whenever _progress changes
- Client uses EventSource instead of 3s polling
- Auto-reconnect after 5s on error
- 15s heartbeat keeps proxies alive

Compare decisions (new #/compare page):
- /api/training/compare?a=id&b=id serializes both decisions' metadata,
  section breakdown from document_chunks, and three buckets of patterns
  (only in A, only in B, shared) using variant matching
- Two-column header with section-length breakdown + patterns count
- Three-column diff row (only_a / shared / only_b)

Compose with suggestions (new #/compose page):
- Large RTL justified textarea with Hebrew display font title input
- Sidebar lists all 47 style_patterns grouped by type with filter chips
- Click a pattern → inserts at cursor, replacing [placeholders] with ___
- Live section guess (פתיחה / רקע / טענות / דיון / סוף דבר) based on
  most-recent 400 chars
- Auto-save draft to localStorage every second; restore on page load
- "העתק טקסט" copies title+body to clipboard

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 12:24:45 +00:00
parent ea3ef5963e
commit 5cb0be473c
3 changed files with 961 additions and 18 deletions

View File

@@ -770,6 +770,125 @@ async def training_style_report():
}
@app.get("/api/training/compare")
async def training_compare(a: str, b: str):
"""Compare two decisions from style_corpus by ID.
Returns side-by-side data: basic metadata, length, section breakdown,
which patterns appear in each, shared/unique patterns.
"""
try:
ida, idb = UUID(a), UUID(b)
except ValueError:
raise HTTPException(400, "invalid id(s)")
pool = await db.get_pool()
async with pool.acquire() as conn:
rows = await conn.fetch(
"SELECT id, decision_number, decision_date, subject_categories, "
" full_text, length(full_text) as chars "
"FROM style_corpus WHERE id = ANY($1::uuid[])",
[ida, idb],
)
if len(rows) != 2:
raise HTTPException(404, "אחת ההחלטות לא נמצאה")
by_id = {r["id"]: r for r in rows}
row_a = by_id[ida]
row_b = by_id[idb]
patterns = await conn.fetch(
"SELECT id, pattern_type, pattern_text, context "
"FROM style_patterns WHERE frequency > 0"
)
# Section breakdown via document_chunks
async def section_stats(corpus_row):
nm = corpus_row["decision_number"]
if not nm:
return []
rows2 = await conn.fetch(
"SELECT dc.section_type, sum(length(dc.content))::int as chars "
"FROM document_chunks dc JOIN documents d ON dc.document_id=d.id "
"WHERE d.title LIKE $1 AND dc.section_type IS NOT NULL "
"GROUP BY dc.section_type ORDER BY chars DESC",
f"%{nm}%",
)
return [{"type": r["section_type"], "chars": r["chars"]} for r in rows2]
sections_a = await section_stats(row_a)
sections_b = await section_stats(row_b)
# Pattern matching via variant extraction
def _strip_nikud_local(t: str) -> str:
import unicodedata
return "".join(c for c in unicodedata.normalize("NFD", t) if not unicodedata.combining(c))
def _variants(pt: str) -> list[str]:
alts = re.split(r"\s*/\s*|\s+או\s+", pt)
out = []
for a in alts:
a = re.sub(r"\[[^\]]*\]", "|", a)
a = re.sub(r"\.{2,}", "|", a).replace("", "|")
segs = [s.strip(" ,.:;\"'") for s in a.split("|")]
good = [s for s in segs if len(s) >= 4]
if good:
out.append(max(good, key=len))
return list(dict.fromkeys(out))
text_a = _strip_nikud_local(row_a["full_text"])
text_b = _strip_nikud_local(row_b["full_text"])
in_a, in_b = [], []
for p in patterns:
vs = _variants(_strip_nikud_local(p["pattern_text"]))
if not vs:
continue
in_a_flag = any(v in text_a for v in vs)
in_b_flag = any(v in text_b for v in vs)
entry = {
"id": str(p["id"]),
"type": p["pattern_type"],
"text": p["pattern_text"],
"context": p["context"] or "",
}
if in_a_flag:
in_a.append(entry)
if in_b_flag:
in_b.append(entry)
set_a = {p["id"] for p in in_a}
set_b = {p["id"] for p in in_b}
shared_ids = set_a & set_b
only_a_ids = set_a - set_b
only_b_ids = set_b - set_a
def serialize(row, sections, patterns_list):
cats = row["subject_categories"]
if isinstance(cats, str):
try:
cats = json.loads(cats)
except Exception:
cats = []
return {
"id": str(row["id"]),
"decision_number": row["decision_number"] or "",
"decision_date": str(row["decision_date"]) if row["decision_date"] else "",
"chars": row["chars"],
"subjects": cats or [],
"sections": sections,
"patterns_count": len(patterns_list),
}
return {
"a": serialize(row_a, sections_a, in_a),
"b": serialize(row_b, sections_b, in_b),
"shared": [p for p in in_a if p["id"] in shared_ids],
"only_a": [p for p in in_a if p["id"] in only_a_ids],
"only_b": [p for p in in_b if p["id"] in only_b_ids],
}
@app.delete("/api/training/corpus/{corpus_id}")
async def training_corpus_delete(corpus_id: str):
"""Remove a decision from the style corpus."""
@@ -811,13 +930,11 @@ async def training_corpus_list():
]
@app.get("/api/system/tasks")
async def system_tasks():
"""List all active background tasks (from in-memory _progress dict)."""
def _get_active_tasks() -> list[dict]:
"""Extract active (non-terminal) tasks from _progress dict."""
items = []
for task_id, data in list(_progress.items()):
status = data.get("status", "unknown")
# Skip terminal states older than this request
if status in ("completed", "failed"):
continue
items.append({
@@ -827,9 +944,46 @@ async def system_tasks():
"filename": data.get("filename", ""),
"error": data.get("error", ""),
})
return items
@app.get("/api/system/tasks")
async def system_tasks():
"""List all active background tasks (one-shot)."""
items = _get_active_tasks()
return {"active": items, "count": len(items)}
@app.get("/api/system/tasks/stream")
async def system_tasks_stream():
"""SSE stream — pushes active-task snapshots when anything changes.
Replaces client-side polling. Clients connect once and receive
events whenever the task set changes. Also sends a heartbeat every
15s to keep proxies from timing out.
"""
async def event_gen():
last_snapshot: str | None = None
last_heartbeat = time.time()
# Emit initial state immediately
while True:
snapshot = json.dumps(
{"active": _get_active_tasks(), "count": len(_get_active_tasks())},
ensure_ascii=False,
)
now = time.time()
if snapshot != last_snapshot:
yield f"event: tasks\ndata: {snapshot}\n\n"
last_snapshot = snapshot
last_heartbeat = now
elif now - last_heartbeat > 15:
yield ": heartbeat\n\n"
last_heartbeat = now
await asyncio.sleep(1)
return StreamingResponse(event_gen(), media_type="text/event-stream")
@app.get("/api/progress/{task_id}")
async def progress_stream(task_id: str):
"""SSE stream of processing progress."""