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."""

View File

@@ -108,6 +108,54 @@
--t-slow: 280ms var(--ease-out);
}
/* ── Dark theme overrides ────────────────────────────── */
body.dark {
--color-navy: #f5f1e8;
--color-navy-soft: #e8e0c8;
--color-navy-dim: #c7bc9a;
--color-cream: #0a0f1c;
--color-cream-deep: #121a2e;
--color-parchment: #161f36;
--color-gold: #d4a55a;
--color-gold-deep: #e8bc6f;
--color-gold-soft: #c89a56;
--color-gold-wash: rgba(212, 165, 90, 0.08);
--color-ink: #f5f1e8;
--color-ink-soft: #d8d2c0;
--color-ink-muted: #9a9380;
--color-ink-light: #6a6458;
--color-rule: #2a3352;
--color-rule-soft: #1e2a45;
--color-surface: #141b2f;
--color-surface-raised: #1a2238;
--color-bg: #0a0f1c;
--color-success: #5a9a6a;
--color-success-bg: rgba(90, 154, 106, 0.12);
--color-warn: #c79956;
--color-warn-bg: rgba(199, 153, 86, 0.12);
--color-danger: #c16565;
--color-danger-bg: rgba(193, 101, 101, 0.12);
--color-info: #6d8bab;
--color-info-bg: rgba(109, 139, 171, 0.12);
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.35), 0 1px 2px rgba(0, 0, 0, 0.25);
--shadow: 0 2px 6px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.25);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.45), 0 2px 4px rgba(0, 0, 0, 0.25);
--shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.5), 0 2px 6px rgba(0, 0, 0, 0.3);
}
body.dark header {
background: #060a18;
border-bottom-color: var(--color-gold);
}
/* ── Base overrides ──────────────────────────────────── */
html { font-size: 16px; }

View File

@@ -83,6 +83,21 @@ header nav a.active::after {
background: var(--color-gold);
border-radius: 2px 2px 0 0;
}
.theme-toggle {
background: transparent;
border: 1px solid rgba(169, 125, 58, 0.4);
color: var(--color-gold-soft);
cursor: pointer;
font-size: 1.05em;
padding: 6px 12px;
border-radius: var(--radius);
margin-right: var(--space-3);
transition: all var(--t);
}
.theme-toggle:hover {
background: rgba(169, 125, 58, 0.15);
border-color: var(--color-gold);
}
/* Main */
.main { flex: 1; max-width: 1200px; width: 100%; margin: 0 auto; padding: var(--space-7) var(--space-6); }
@@ -876,6 +891,247 @@ header nav a.active::after {
.diag-row-title { font-weight: 600; color: var(--color-navy); margin-bottom: 2px; }
.diag-row-meta { font-size: 0.88em; color: var(--color-ink-muted); }
/* ── Compare Decisions ───────────────────────────────── */
.compare-pickers {
display: grid;
grid-template-columns: 1fr auto 1fr auto;
gap: var(--space-4);
align-items: end;
}
.compare-vs {
font-size: 1.5em;
color: var(--color-gold);
padding-bottom: 10px;
font-family: var(--font-display);
}
.compare-result-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-5);
margin-bottom: var(--space-5);
}
.compare-column {
background: var(--color-surface);
border: 1px solid var(--color-rule-soft);
border-radius: var(--radius-lg);
padding: var(--space-6);
border-top: 4px solid var(--color-gold);
}
.compare-col-title {
font-family: var(--font-display);
font-size: 1.2em;
font-weight: 800;
color: var(--color-navy);
margin-bottom: var(--space-2);
}
.compare-col-meta {
font-size: 0.82em;
color: var(--color-ink-muted);
display: flex;
gap: 12px;
flex-wrap: wrap;
margin-bottom: var(--space-4);
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--color-rule-soft);
}
.compare-col-stat {
display: flex;
justify-content: space-between;
padding: 6px 0;
font-size: 0.88em;
}
.compare-col-stat strong { color: var(--color-navy); }
.compare-diff-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: var(--space-4);
}
.compare-diff-col {
background: var(--color-surface);
border: 1px solid var(--color-rule-soft);
border-radius: var(--radius-lg);
padding: var(--space-5);
}
.compare-diff-title {
font-family: var(--font-display);
font-weight: 700;
color: var(--color-navy);
margin-bottom: var(--space-3);
padding-bottom: var(--space-2);
border-bottom: 2px solid var(--color-gold);
display: flex;
justify-content: space-between;
align-items: baseline;
}
.compare-diff-count {
background: var(--color-gold-wash);
color: var(--color-gold-deep);
padding: 2px 10px;
border-radius: var(--radius-pill);
font-size: 0.78em;
font-weight: 700;
font-family: var(--font-body);
}
.compare-diff-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 400px;
overflow-y: auto;
}
.compare-diff-item {
padding: 8px 12px;
background: var(--color-cream);
border-radius: var(--radius);
font-size: 0.82em;
color: var(--color-ink);
border-right: 2px solid var(--color-gold);
line-height: 1.4;
}
/* ── Compose (writing assistant) ─────────────────────── */
.compose-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: var(--space-5);
min-height: 70vh;
}
.compose-main {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.compose-toolbar {
display: flex;
gap: var(--space-3);
align-items: center;
}
.compose-title-input {
flex: 1;
padding: 12px 16px;
font-family: var(--font-display);
font-size: 1.15em;
font-weight: 600;
color: var(--color-navy);
background: var(--color-surface);
border: 1px solid var(--color-rule);
border-radius: var(--radius-md);
}
.compose-title-input:focus {
outline: none;
border-color: var(--color-gold);
box-shadow: 0 0 0 3px var(--color-gold-wash);
}
.compose-textarea {
flex: 1;
min-height: 60vh;
padding: var(--space-6) var(--space-7);
font-family: var(--font-body);
font-size: 1.02em;
line-height: 1.75;
color: var(--color-ink);
background: var(--color-surface);
border: 1px solid var(--color-rule);
border-radius: var(--radius-lg);
resize: vertical;
text-align: justify;
text-justify: inter-word;
direction: rtl;
}
.compose-textarea:focus {
outline: none;
border-color: var(--color-gold);
box-shadow: 0 0 0 3px var(--color-gold-wash);
}
.compose-stats {
font-size: 0.82em;
color: var(--color-ink-muted);
padding: 6px 14px;
background: var(--color-parchment);
border-radius: var(--radius);
border: 1px solid var(--color-rule-soft);
}
.compose-stats span { color: var(--color-navy); font-weight: 600; }
.compose-sidebar {
background: var(--color-parchment);
border: 1px solid var(--color-rule);
border-radius: var(--radius-lg);
padding: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-3);
max-height: 80vh;
overflow: hidden;
}
.compose-sidebar-title {
font-family: var(--font-display);
font-size: 1.05em;
font-weight: 700;
color: var(--color-navy);
padding-bottom: var(--space-3);
border-bottom: 2px solid var(--color-gold);
}
.compose-filters {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.compose-filter {
padding: 4px 10px;
border-radius: var(--radius-pill);
border: 1px solid var(--color-rule);
background: var(--color-surface);
font-size: 0.76em;
cursor: pointer;
transition: all var(--t-fast);
}
.compose-filter:hover { background: var(--color-gold-wash); }
.compose-filter.active {
background: var(--color-navy);
color: var(--color-parchment);
border-color: var(--color-navy);
}
.compose-patterns {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
padding-right: 4px;
}
.compose-pattern {
padding: 10px 12px;
background: var(--color-surface);
border: 1px solid var(--color-rule-soft);
border-radius: var(--radius);
font-size: 0.84em;
cursor: pointer;
transition: all var(--t-fast);
border-right: 3px solid var(--color-gold);
line-height: 1.4;
}
.compose-pattern:hover {
background: var(--color-gold-wash);
transform: translateX(-2px);
box-shadow: var(--shadow-sm);
}
.compose-pattern-text {
color: var(--color-navy);
font-weight: 500;
margin-bottom: 2px;
}
.compose-pattern-meta {
font-size: 0.78em;
color: var(--color-ink-muted);
}
@media (max-width: 1000px) {
.compose-layout { grid-template-columns: 1fr; }
.compare-result-grid, .compare-diff-row { grid-template-columns: 1fr; }
}
/* ── Local files (research, drafts, proofread) ───────── */
.local-file-group { margin-bottom: 12px; }
.local-file-group-header { font-size: 0.82em; font-weight: 600; color: #666; margin-bottom: 6px; padding-bottom: 4px; border-bottom: 1px solid #eee; }
@@ -1366,8 +1622,43 @@ header nav a.active::after {
max-height: 300px; overflow-y: auto;
}
.phrase-modal-example {
padding: 10px 14px; background: #fafafa; border-right: 3px solid var(--color-gold);
font-size: 0.86em; line-height: 1.5; color: #333; border-radius: 4px;
padding: 10px 14px; background: var(--color-cream); border-right: 3px solid var(--color-gold);
font-size: 0.86em; line-height: 1.5; color: var(--color-ink); border-radius: var(--radius);
}
/* Shortcuts help dialog */
.shortcuts-dialog { max-width: 460px; }
.shortcuts-table {
width: 100%;
border-collapse: collapse;
font-size: 0.88em;
}
.shortcuts-table tr { border-bottom: 1px solid var(--color-rule-soft); }
.shortcuts-table tr:last-child { border-bottom: none; }
.shortcuts-table td {
padding: 10px 8px;
color: var(--color-ink);
}
.shortcut-keys {
text-align: left;
direction: ltr;
unicode-bidi: embed;
white-space: nowrap;
width: 120px;
}
kbd {
display: inline-block;
padding: 2px 8px;
background: var(--color-parchment);
border: 1px solid var(--color-rule);
border-bottom-width: 2px;
border-radius: var(--radius-sm);
font-family: var(--font-mono);
font-size: 0.85em;
color: var(--color-navy);
margin: 0 2px;
min-width: 22px;
text-align: center;
}
@media (max-width: 800px) {
@@ -1391,8 +1682,13 @@ header nav a.active::after {
<a href="#/upload" id="navUpload">העלאה</a>
<a href="#/training" id="navTraining">אימון סגנון</a>
<a href="#/style-report" id="navStyleReport">הסגנון שלי</a>
<a href="#/compare" id="navCompare">השוואה</a>
<a href="#/compose" id="navCompose">כתיבה</a>
<a href="#/skills" id="navSkills">Skills</a>
<a href="#/diagnostics" id="navDiagnostics">מצב מערכת</a>
<button class="theme-toggle" id="themeToggle" onclick="toggleTheme()" title="החלף ערכת צבעים (Shift+D)">
<span id="themeIcon">🌙</span>
</button>
</nav>
</header>
@@ -1827,6 +2123,60 @@ header nav a.active::after {
</div>
</div>
<!-- ══ Page: Compare Decisions ══ -->
<div class="page" id="page-compare">
<div class="page-header">
<h2>השוואת שתי החלטות</h2>
</div>
<div class="card">
<div class="card-body">
<div class="compare-pickers">
<div class="form-group">
<label>החלטה א'</label>
<select id="compareSelectA"><option value="">בחרי החלטה...</option></select>
</div>
<div class="compare-vs"></div>
<div class="form-group">
<label>החלטה ב'</label>
<select id="compareSelectB"><option value="">בחרי החלטה...</option></select>
</div>
<button class="btn btn-primary" onclick="runCompare()">השווה</button>
</div>
</div>
</div>
<div id="compareResult"></div>
</div>
<!-- ══ Page: Compose (writing with live suggestions) ══ -->
<div class="page" id="page-compose">
<div class="page-header">
<h2>כתיבת החלטה — עם הצעות סגנון</h2>
</div>
<div class="compose-layout">
<div class="compose-main">
<div class="compose-toolbar">
<input type="text" id="composeTitle" placeholder="כותרת ההחלטה (למשל: ערר 1045/25 — יעקב כהן)" class="compose-title-input">
<button class="btn btn-ghost btn-sm" onclick="copyComposeText()">העתק טקסט</button>
<button class="btn btn-ghost btn-sm" onclick="saveComposeDraft()">שמור טיוטה</button>
</div>
<textarea id="composeTextarea" class="compose-textarea"
placeholder="התחילי לכתוב כאן. הצעות סגנון יופיעו בצד בהתאם לתוכן..."></textarea>
<div class="compose-stats">
<span id="composeCharCount">0</span> תווים ·
<span id="composeWordCount">0</span> מילים ·
<span id="composeSectionGuess"></span>
</div>
</div>
<aside class="compose-sidebar">
<h3 class="compose-sidebar-title">ביטויים של דפנה</h3>
<div class="compose-filters" id="composeFilters"></div>
<div class="compose-patterns" id="composePatterns">
<div class="empty" style="padding:20px">טוען...</div>
</div>
</aside>
</div>
</div>
<!-- ══ Page: Diagnostics ══ -->
<div class="page" id="page-diagnostics">
<div class="page-header">
@@ -1936,16 +2286,133 @@ function handleRoute() {
document.getElementById('navDiagnostics').classList.add('active');
subtitle = 'מצב מערכת';
loadDiagnostics();
} else if (hash === '#/compare') {
document.getElementById('page-compare').classList.add('active');
document.getElementById('navCompare').classList.add('active');
subtitle = 'השוואת החלטות';
initComparePage();
} else if (hash === '#/compose') {
document.getElementById('page-compose').classList.add('active');
document.getElementById('navCompose').classList.add('active');
subtitle = 'כתיבת החלטה';
initComposePage();
}
document.getElementById('pageSubtitle').textContent = subtitle;
}
window.addEventListener('hashchange', handleRoute);
// ── Keyboard shortcuts ──────────────────────────────
const SHORTCUTS = [
{ keys: ['g', 'h'], label: 'דף הבית', action: () => location.hash = '#/' },
{ keys: ['g', 'n'], label: 'תיק חדש', action: () => location.hash = '#/new' },
{ keys: ['g', 'u'], label: 'העלאה', action: () => location.hash = '#/upload' },
{ keys: ['g', 't'], label: 'אימון סגנון', action: () => location.hash = '#/training' },
{ keys: ['g', 's'], label: 'הסגנון שלי', action: () => location.hash = '#/style-report' },
{ keys: ['g', 'c'], label: 'השוואה', action: () => location.hash = '#/compare' },
{ keys: ['g', 'w'], label: 'כתיבת החלטה', action: () => location.hash = '#/compose' },
{ keys: ['g', 'd'], label: 'מצב מערכת', action: () => location.hash = '#/diagnostics' },
{ keys: ['g', 'k'], label: 'Skills', action: () => location.hash = '#/skills' },
{ keys: ['n'], label: 'תיק חדש', action: () => location.hash = '#/new' },
{ keys: ['D'], label: 'Dark mode', shiftKey: true, action: toggleTheme },
{ keys: ['?'], label: 'עזרה', shiftKey: true, action: () => showShortcutsHelp() },
{ keys: ['Escape'], label: 'סגור', action: () => {
document.querySelectorAll('dialog[open]').forEach(d => d.close());
}},
];
let _pendingKey = null;
let _pendingTimer = null;
function setupKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
// Ignore when typing in inputs
const tag = (e.target.tagName || '').toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select' || e.target.isContentEditable) {
if (e.key === 'Escape') e.target.blur();
return;
}
// Handle immediate-single shortcuts
for (const s of SHORTCUTS) {
if (s.keys.length === 1 && s.keys[0] === e.key) {
if (s.shiftKey !== undefined && e.shiftKey !== s.shiftKey) continue;
e.preventDefault();
s.action();
return;
}
}
// Handle 2-key chords starting with 'g'
if (e.key === 'g' && !_pendingKey) {
_pendingKey = 'g';
_pendingTimer = setTimeout(() => { _pendingKey = null; }, 1500);
return;
}
if (_pendingKey === 'g') {
clearTimeout(_pendingTimer);
_pendingKey = null;
for (const s of SHORTCUTS) {
if (s.keys.length === 2 && s.keys[0] === 'g' && s.keys[1] === e.key) {
e.preventDefault();
s.action();
return;
}
}
}
});
}
function showShortcutsHelp() {
let dialog = document.getElementById('shortcutsDialog');
if (!dialog) {
dialog = document.createElement('dialog');
dialog.id = 'shortcutsDialog';
dialog.className = 'phrase-modal shortcuts-dialog';
document.body.appendChild(dialog);
}
const rows = SHORTCUTS.map(s => {
const keysDisplay = s.keys.map(k => `<kbd>${k}</kbd>`).join(' ');
return `<tr><td class="shortcut-keys">${keysDisplay}</td><td>${esc(s.label)}</td></tr>`;
}).join('');
dialog.innerHTML = `
<div class="phrase-modal-header">
<span class="phrase-modal-type">קיצורי מקלדת</span>
<button class="btn-icon" onclick="document.getElementById('shortcutsDialog').close()">✕</button>
</div>
<table class="shortcuts-table">${rows}</table>
`;
dialog.showModal();
}
// ── Theme (dark/light) ──────────────────────────────
function applyTheme(dark) {
document.body.classList.toggle('dark', dark);
const icon = document.getElementById('themeIcon');
if (icon) icon.textContent = dark ? '☀️' : '🌙';
}
function toggleTheme() {
const dark = !document.body.classList.contains('dark');
applyTheme(dark);
try { localStorage.setItem('theme', dark ? 'dark' : 'light'); } catch (e) {}
}
// Apply saved theme immediately (before onload to prevent flash)
(function initTheme() {
try {
const saved = localStorage.getItem('theme');
if (saved === 'dark') applyTheme(true);
} catch (e) {}
})();
window.addEventListener('load', () => {
handleRoute();
loadStatus();
startProcessPanelPolling();
setupKeyboardShortcuts();
});
// ── Case List ────────────────────────────────────────────
@@ -2702,6 +3169,271 @@ async function loadStatus() {
} catch (e) {}
}
// ── Compare Decisions ────────────────────────────────
async function initComparePage() {
const selA = document.getElementById('compareSelectA');
const selB = document.getElementById('compareSelectB');
if (selA.options.length > 1) return; // already loaded
try {
const res = await fetch(API + '/training/corpus');
const rows = await res.json();
const options = rows.map(r => {
const label = `${r.decision_number || '(ללא מספר)'} · ${r.decision_date || '—'}`;
return `<option value="${esc(r.id)}">${esc(label)}</option>`;
}).join('');
selA.innerHTML = '<option value="">בחרי החלטה...</option>' + options;
selB.innerHTML = '<option value="">בחרי החלטה...</option>' + options;
} catch (e) {
toast('שגיאה בטעינת רשימת החלטות', 'error');
}
}
async function runCompare() {
const a = document.getElementById('compareSelectA').value;
const b = document.getElementById('compareSelectB').value;
if (!a || !b) { toast('בחרי שתי החלטות', 'error'); return; }
if (a === b) { toast('יש לבחור שתי החלטות שונות', 'error'); return; }
const result = document.getElementById('compareResult');
result.innerHTML = '<div class="card"><div class="card-body"><div class="empty">משווה...</div></div></div>';
try {
const res = await fetch(API + '/training/compare?a=' + encodeURIComponent(a) + '&b=' + encodeURIComponent(b));
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
renderCompareResult(data);
} catch (e) {
result.innerHTML = `<div class="card"><div class="card-body"><div class="empty">שגיאה: ${esc(e.message)}</div></div></div>`;
}
}
function renderCompareResult(data) {
const container = document.getElementById('compareResult');
const a = data.a, b = data.b;
const headerCard = (row, label) => {
const secTotal = row.sections.reduce((sum, s) => sum + s.chars, 0) || 1;
const sectionsHtml = row.sections.map(s => {
const pct = Math.round((s.chars / secTotal) * 100);
const labelHeb = ({
intro: 'פתיחה', facts: 'רקע', appellant_claims: 'טענות עורר',
respondent_claims: 'טענות משיב', legal_analysis: 'דיון משפטי',
ruling: 'הכרעה', conclusion: 'סוף דבר',
})[s.type] || s.type;
return `<div class="compare-col-stat"><span>${esc(labelHeb)}</span><strong>${s.chars.toLocaleString('he-IL')} (${pct}%)</strong></div>`;
}).join('');
return `
<div class="compare-column">
<div class="compare-col-title">${esc(row.decision_number || '(ללא מספר)')}</div>
<div class="compare-col-meta">
<span>${esc(row.decision_date || '—')}</span>
<span>${row.chars.toLocaleString('he-IL')} תווים</span>
</div>
<div class="compare-col-stat"><span>דפוסים שזוהו</span><strong>${row.patterns_count}</strong></div>
${sectionsHtml}
</div>
`;
};
const diffColumn = (title, items, colorClass) => {
if (!items.length) {
return `
<div class="compare-diff-col">
<div class="compare-diff-title">${esc(title)} <span class="compare-diff-count">0</span></div>
<div class="empty" style="padding:16px">—</div>
</div>
`;
}
const itemsHtml = items.map(p => {
let display = p.text.replace(/\[[^\]]*\]/g, '').replace(/\s+/g, ' ').trim();
display = display.split(' / ')[0].split(' או ')[0].trim();
if (display.length > 80) display = display.substring(0, 77) + '...';
return `<div class="compare-diff-item">${esc(display)}</div>`;
}).join('');
return `
<div class="compare-diff-col">
<div class="compare-diff-title">${esc(title)} <span class="compare-diff-count">${items.length}</span></div>
<div class="compare-diff-list">${itemsHtml}</div>
</div>
`;
};
container.innerHTML = `
<div class="compare-result-grid">
${headerCard(a)}
${headerCard(b)}
</div>
<div class="compare-diff-row">
${diffColumn('רק ב-' + (a.decision_number || 'א'), data.only_a)}
${diffColumn('משותפים', data.shared)}
${diffColumn('רק ב-' + (b.decision_number || 'ב'), data.only_b)}
</div>
`;
}
// ── Compose (writing with suggestions) ───────────────
let _composePatterns = [];
let _composeFilter = 'all';
async function initComposePage() {
// Restore draft if any
try {
const saved = localStorage.getItem('composeDraft');
const title = localStorage.getItem('composeTitle');
if (saved) document.getElementById('composeTextarea').value = saved;
if (title) document.getElementById('composeTitle').value = title;
} catch (e) {}
updateComposeStats();
document.getElementById('composeTextarea').addEventListener('input', onComposeInput);
if (_composePatterns.length) {
renderComposePatterns();
return;
}
try {
const res = await fetch(API + '/training/patterns');
const data = await res.json();
_composePatterns = [];
Object.entries(data.by_type || {}).forEach(([type, items]) => {
items.forEach(p => _composePatterns.push({ type, ...p }));
});
// Sort by frequency desc
_composePatterns.sort((a, b) => (b.frequency || 0) - (a.frequency || 0));
renderComposeFilters();
renderComposePatterns();
} catch (e) {
document.getElementById('composePatterns').innerHTML = '<div class="empty">שגיאה בטעינה</div>';
}
}
function renderComposeFilters() {
const types = [...new Set(_composePatterns.map(p => p.type))];
const typeLabels = {
opening_formula: 'פתיחה',
closing_formula: 'סיום',
transition: 'מעברים',
characteristic_phrase: 'ביטויים',
argument_flow: 'טיעון',
analysis_structure: 'מבנה',
evidence_handling: 'ראיות',
citation_style: 'ציטוט',
};
const all = [{ id: 'all', label: 'הכל' }]
.concat(types.map(t => ({ id: t, label: typeLabels[t] || t })));
document.getElementById('composeFilters').innerHTML = all.map(f => `
<div class="compose-filter ${f.id === _composeFilter ? 'active' : ''}"
onclick="setComposeFilter('${f.id}')">${esc(f.label)}</div>
`).join('');
}
function setComposeFilter(f) {
_composeFilter = f;
renderComposeFilters();
renderComposePatterns();
}
function renderComposePatterns() {
const filtered = _composeFilter === 'all'
? _composePatterns
: _composePatterns.filter(p => p.type === _composeFilter);
const typeLabels = {
opening_formula: 'פתיחה', closing_formula: 'סיום', transition: 'מעבר',
characteristic_phrase: 'ביטוי', argument_flow: 'טיעון',
analysis_structure: 'מבנה', evidence_handling: 'ראיות', citation_style: 'ציטוט',
};
document.getElementById('composePatterns').innerHTML = filtered.map((p, idx) => {
let display = p.pattern_text || p.text || '';
let clean = display.replace(/\[[^\]]*\]/g, '').replace(/\s+/g, ' ').trim();
clean = clean.split(' / ')[0].split(' או ')[0].trim();
if (clean.length > 90) clean = clean.substring(0, 87) + '...';
const origIdx = _composePatterns.indexOf(p);
return `
<div class="compose-pattern" onclick="insertComposePattern(${origIdx})">
<div class="compose-pattern-text">${esc(clean)}</div>
<div class="compose-pattern-meta">${esc(typeLabels[p.type] || p.type)} · ${p.frequency || 0}/24</div>
</div>
`;
}).join('') || '<div class="empty" style="padding:20px">אין דפוסים בקטגוריה זו</div>';
}
function insertComposePattern(idx) {
const p = _composePatterns[idx];
if (!p) return;
let text = (p.pattern_text || p.text || '').replace(/\[[^\]]*\]/g, '___').replace(/\s+/g, ' ').trim();
text = text.split(' / ')[0].split(' או ')[0].trim();
const ta = document.getElementById('composeTextarea');
const start = ta.selectionStart;
const end = ta.selectionEnd;
const before = ta.value.substring(0, start);
const after = ta.value.substring(end);
// Add space before if needed
const prefix = (before && !/\s$/.test(before)) ? ' ' : '';
const suffix = (after && !/^\s/.test(after)) ? ' ' : '';
const insert = prefix + text + suffix;
ta.value = before + insert + after;
const newPos = start + insert.length;
ta.selectionStart = ta.selectionEnd = newPos;
ta.focus();
onComposeInput();
}
function onComposeInput() {
updateComposeStats();
// Auto-save draft every second (debounced)
clearTimeout(onComposeInput._t);
onComposeInput._t = setTimeout(() => {
try {
localStorage.setItem('composeDraft', document.getElementById('composeTextarea').value);
localStorage.setItem('composeTitle', document.getElementById('composeTitle').value);
} catch (e) {}
}, 1000);
}
function updateComposeStats() {
const ta = document.getElementById('composeTextarea');
if (!ta) return;
const text = ta.value || '';
const chars = text.length;
const words = (text.match(/\S+/g) || []).length;
document.getElementById('composeCharCount').textContent = chars.toLocaleString('he-IL');
document.getElementById('composeWordCount').textContent = words.toLocaleString('he-IL');
// Guess section from recent text
const recent = text.slice(-400);
let section = '—';
if (/בפנינו|לפנינו|עניינו|עסקינן/.test(recent)) section = 'פתיחה';
else if (/רקע|העובדות|תכנית/.test(recent)) section = 'רקע';
else if (/טענות העורר|טוען העורר|המערער/.test(recent)) section = 'טענות עורר';
else if (/טענות המשיב|טוענת המשיבה|טוענים המשיבים/.test(recent)) section = 'טענות משיב';
else if (/דיון|הכרעה|לאחר שבחנו|נחדד|נזכיר/.test(recent)) section = 'דיון והכרעה';
else if (/סוף דבר|לאור כל האמור|הערר נדחה|הערר מתקבל|ניתנה פה/.test(recent)) section = 'סוף דבר';
document.getElementById('composeSectionGuess').textContent = section;
}
function copyComposeText() {
const ta = document.getElementById('composeTextarea');
const title = document.getElementById('composeTitle').value;
const text = (title ? title + '\n\n' : '') + ta.value;
navigator.clipboard.writeText(text).then(() => toast('הועתק ללוח', 'success'));
}
function saveComposeDraft() {
try {
localStorage.setItem('composeDraft', document.getElementById('composeTextarea').value);
localStorage.setItem('composeTitle', document.getElementById('composeTitle').value);
toast('הטיוטה נשמרה', 'success');
} catch (e) {
toast('שגיאה בשמירה', 'error');
}
}
// ── Diagnostics Page ─────────────────────────────────
async function loadDiagnostics() {
@@ -2783,22 +3515,31 @@ async function loadDiagnostics() {
}
}
// ── Processing Visibility Panel ──────────────────────
// ── Processing Visibility Panel (SSE push) ───────────
let _processPollTimer = null;
let _tasksEventSource = null;
function startProcessPanelPolling() {
if (_processPollTimer) return;
pollProcessPanel();
_processPollTimer = setInterval(pollProcessPanel, 3000);
}
async function pollProcessPanel() {
if (_tasksEventSource) return;
try {
const res = await fetch(API + '/system/tasks');
const data = await res.json();
renderProcessPanel(data.active || []);
} catch (e) {}
const es = new EventSource(API + '/system/tasks/stream');
es.addEventListener('tasks', (e) => {
try {
const data = JSON.parse(e.data);
renderProcessPanel(data.active || []);
} catch (err) {}
});
es.onerror = () => {
// Reconnect after a delay
es.close();
_tasksEventSource = null;
setTimeout(startProcessPanelPolling, 5000);
};
_tasksEventSource = es;
} catch (e) {
// Browsers without EventSource fall back to one-shot fetch
fetch(API + '/system/tasks').then(r => r.json()).then(d => renderProcessPanel(d.active || [])).catch(() => {});
}
}
const STEP_LABELS = {