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:
162
web/app.py
162
web/app.py
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user