diff --git a/web/app.py b/web/app.py index 80936fc..9d2b885 100644 --- a/web/app.py +++ b/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.""" diff --git a/web/static/design-system.css b/web/static/design-system.css index 47f5e76..13b665d 100644 --- a/web/static/design-system.css +++ b/web/static/design-system.css @@ -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; } diff --git a/web/static/index.html b/web/static/index.html index bf22d56..b894e70 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -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 { העלאה אימון סגנון הסגנון שלי + השוואה + כתיבה Skills מצב מערכת + @@ -1827,6 +2123,60 @@ header nav a.active::after { + +
+ +
+
+
+
+ + +
+
+
+ + +
+ +
+
+
+
+
+ + +
+ +
+
+
+ + + +
+ +
+ 0 תווים · + 0 מילים · + +
+
+ +
+
+