Add style report dashboard — Dafna's style portrait

Visual dashboard at #/style-report with 4 sections:
- Hero: 24 decisions, char counts, subject donut, timeline
- Anatomy: average section-length breakdown (intro → ruling → conclusion)
- Signature Phrases Wall: pattern cards with real corpus frequencies, filter
  chips by type, click → modal with examples
- Contribution: per-decision "new vs confirmed" patterns, growth curve SVG

Backend:
- /api/training/style-report endpoint computes all 4 sections in one call
- Headlines in Hebrew are computed server-side from real data
- Backfill script for style_patterns.frequency using _strip_nikud +
  pattern-variant extraction (templates with [placeholders], / alternatives,
  ellipsis all handled)

Real findings from the 24-decision corpus:
- דיון משפטי = 49% of avg decision (the focus)
- 23/24 use "לפנינו ערר" opening formula
- 21/24 use "ניתנה פה אחד" closing
- After 7 decisions we already learned 85% of her style patterns

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 11:34:37 +00:00
parent 32f18de049
commit 858333b386
3 changed files with 1088 additions and 0 deletions

View File

@@ -0,0 +1,163 @@
"""Backfill style_patterns.frequency with real occurrence counts.
The analyzer currently stores frequency=1 for every pattern (it only extracts
unique patterns, doesn't count occurrences). This script scans the full_text
of every decision in style_corpus and updates each pattern's frequency to
the true count of decisions containing the pattern_text as a substring.
Run once after analysis, and again whenever new decisions are added.
"""
from __future__ import annotations
import asyncio
import os
import re
import sys
import unicodedata
from pathlib import Path
# Load env
for line in (Path.home() / ".env").read_text().splitlines():
if "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
sys.path.insert(0, "/home/chaim/legal-ai/mcp-server/src")
from legal_mcp.services import db as db_mod # noqa: E402
def _strip_nikud(text: str) -> str:
"""Remove Hebrew combining marks (nikud) for robust matching."""
return "".join(
c for c in unicodedata.normalize("NFD", text)
if not unicodedata.combining(c)
)
def _extract_searchable_variants(pattern_text: str) -> list[str]:
"""Extract searchable substrings from a pattern template.
The analyzer stores patterns as templates with:
- Placeholders in [brackets]: "בפנינו ערר על החלטת [הגוף] מיום [תאריך]"
- Alternatives separated by / : "נפנה ל... / ראה והשווה / נפנה להחלטה"
- Ellipsis ... for variable parts
This function returns a list of concrete substrings to search for.
We pick the longest fixed segment from each alternative (>= 4 chars)
so that matching is specific enough to be meaningful but still flexible.
"""
# Split on " / " or " או " to get alternatives
alternatives = re.split(r"\s*/\s*|\s+או\s+", pattern_text)
variants: list[str] = []
for alt in alternatives:
alt = alt.strip()
if not alt:
continue
# Remove bracket placeholders [X]
alt = re.sub(r"\[[^\]]*\]", "|", alt)
# Replace ellipsis with separator
alt = re.sub(r"\.{2,}", "|", alt)
# Remove ellipsis unicode
alt = alt.replace("", "|")
# Split on the | separator and take fixed segments
segments = [s.strip(" ,.:;\"'") for s in alt.split("|")]
# Keep segments long enough to be meaningful (>= 4 chars, not just common words)
good = [s for s in segments if len(s) >= 4]
if good:
# Use the longest segment as the key variant for this alternative
variants.append(max(good, key=len))
elif alt.strip():
# Fallback: use the whole cleaned alternative
stripped = alt.replace("|", " ").strip()
if len(stripped) >= 4:
variants.append(stripped)
# Deduplicate while preserving order
seen = set()
unique = []
for v in variants:
if v not in seen:
seen.add(v)
unique.append(v)
return unique
def _count_decisions_containing(variants: list[str], normalized_decisions: list) -> int:
"""Count how many decisions contain ANY of the variants."""
count = 0
for _, _, text in normalized_decisions:
if any(v in text for v in variants):
count += 1
return count
async def main() -> int:
pool = await db_mod.get_pool()
async with pool.acquire() as conn:
decisions = await conn.fetch(
"SELECT id, decision_number, full_text FROM style_corpus "
"WHERE full_text IS NOT NULL AND length(full_text) > 0"
)
patterns = await conn.fetch(
"SELECT id, pattern_text, pattern_type FROM style_patterns"
)
print(f"Scanning {len(patterns)} patterns across {len(decisions)} decisions...")
# Normalize decisions once
normalized_decisions = [
(d["id"], d["decision_number"], _strip_nikud(d["full_text"]))
for d in decisions
]
updates = []
for p in patterns:
pattern_text = p["pattern_text"]
if not pattern_text or len(pattern_text) < 3:
updates.append((0, p["id"]))
continue
variants = _extract_searchable_variants(_strip_nikud(pattern_text))
if not variants:
updates.append((0, p["id"]))
continue
count = _count_decisions_containing(variants, normalized_decisions)
updates.append((count, p["id"]))
await conn.executemany(
"UPDATE style_patterns SET frequency = $1 WHERE id = $2",
updates,
)
# Show distribution
rows = await conn.fetch(
"SELECT pattern_type, pattern_text, frequency "
"FROM style_patterns "
"ORDER BY frequency DESC "
"LIMIT 15"
)
print(f"\nTop 15 patterns by real frequency:")
for r in rows:
print(f" {r['frequency']:>3} [{r['pattern_type']:<22}] {r['pattern_text'][:90]}")
dist = await conn.fetch(
"SELECT frequency, count(*) FROM style_patterns "
"GROUP BY frequency ORDER BY frequency DESC"
)
print(f"\nFrequency distribution:")
for r in dist:
print(f" frequency={r['frequency']:>3}{r['count']} patterns")
return 0
if __name__ == "__main__":
sys.exit(asyncio.run(main()))

View File

@@ -390,6 +390,369 @@ async def training_analyze_style_status():
return state
# ── Style Report — visual dashboard data ─────────────────────────
_SECTION_TYPE_HEBREW = {
"intro": "פתיחה",
"facts": "רקע",
"appellant_claims": "טענות העורר",
"respondent_claims": "טענות המשיב",
"legal_analysis": "דיון משפטי",
"ruling": "הכרעה",
"conclusion": "סוף דבר",
}
_SECTION_DISPLAY_ORDER = [
"intro", "facts", "appellant_claims", "respondent_claims",
"legal_analysis", "ruling", "conclusion",
]
def _strip_nikud(text: str) -> str:
import unicodedata
return "".join(
c for c in unicodedata.normalize("NFD", text)
if not unicodedata.combining(c)
)
def _extract_pattern_variants(pattern_text: str) -> list[str]:
"""Mirror of scripts/backfill_pattern_frequency.py logic for matching."""
alternatives = re.split(r"\s*/\s*|\s+או\s+", pattern_text)
variants: list[str] = []
for alt in alternatives:
alt = alt.strip()
if not alt:
continue
alt = re.sub(r"\[[^\]]*\]", "|", alt)
alt = re.sub(r"\.{2,}", "|", alt)
alt = alt.replace("", "|")
segments = [s.strip(" ,.:;\"'") for s in alt.split("|")]
good = [s for s in segments if len(s) >= 4]
if good:
variants.append(max(good, key=len))
return list(dict.fromkeys(variants))
async def _compute_corpus_stats(conn) -> dict:
"""Hero section: decision count, chars, subject distribution, timeline."""
stats = await conn.fetchrow(
"SELECT count(*) as n, "
" sum(length(full_text)) as total_chars, "
" avg(length(full_text))::int as avg_chars, "
" min(decision_date) as min_date, "
" max(decision_date) as max_date "
"FROM style_corpus"
)
decisions = await conn.fetch(
"SELECT decision_number, decision_date, length(full_text) as chars, "
" subject_categories "
"FROM style_corpus ORDER BY decision_date NULLS LAST"
)
# Subject distribution
from collections import Counter
subject_counter: Counter = Counter()
for d in decisions:
cats = d["subject_categories"]
if isinstance(cats, str):
try:
cats = json.loads(cats)
except Exception:
cats = []
for c in (cats or []):
subject_counter[c] += 1
# Cap at top 6 subjects, collapse rest to "אחר"
top = subject_counter.most_common(6)
other_count = sum(subject_counter.values()) - sum(c for _, c in top)
subject_distribution = [{"label": label, "count": count} for label, count in top]
if other_count > 0:
subject_distribution.append({"label": "אחר", "count": other_count})
n = stats["n"]
top_subject = top[0] if top else None
headline = (
f"קראתי {n} מההחלטות שלך. ממוצע {stats['avg_chars']:,} תווים לכל החלטה"
+ (f", הנושא הנפוץ אצלך: {top_subject[0]} ({top_subject[1]} החלטות)" if top_subject else "")
)
return {
"decision_count": n,
"total_chars": stats["total_chars"],
"avg_chars": stats["avg_chars"],
"date_range": [
str(stats["min_date"]) if stats["min_date"] else None,
str(stats["max_date"]) if stats["max_date"] else None,
],
"decisions": [
{
"number": d["decision_number"] or "",
"date": str(d["decision_date"]) if d["decision_date"] else "",
"chars": d["chars"],
"subjects": (
json.loads(d["subject_categories"])
if isinstance(d["subject_categories"], str)
else (d["subject_categories"] or [])
),
}
for d in decisions
],
"subject_distribution": subject_distribution,
"headline": headline,
}
async def _compute_anatomy(conn) -> dict:
"""Section 2: average section lengths across the training corpus."""
rows = await conn.fetch(
"""
SELECT dc.section_type,
sum(length(dc.content))::int as total_chars,
count(distinct dc.document_id) as docs
FROM document_chunks dc
JOIN documents d ON dc.document_id = d.id
WHERE d.title LIKE '[קורפוס]%'
AND dc.section_type IS NOT NULL
GROUP BY dc.section_type
"""
)
if not rows:
return {
"sections": [],
"total_coverage": 0,
"headline": "אין עדיין נתונים על מבנה ההחלטות",
}
# Map to average per decision (total_chars / docs that have this section)
sections_raw = {r["section_type"]: r for r in rows}
# Compute avg chars per section across decisions that contain it
items = []
total_all_chars = sum(r["total_chars"] for r in rows)
for st_key in _SECTION_DISPLAY_ORDER:
if st_key not in sections_raw:
continue
r = sections_raw[st_key]
avg = round(r["total_chars"] / r["docs"]) if r["docs"] else 0
pct = r["total_chars"] / total_all_chars if total_all_chars else 0
items.append({
"type": st_key,
"label": _SECTION_TYPE_HEBREW.get(st_key, st_key),
"avg_chars": avg,
"pct": round(pct, 4),
"coverage": r["docs"],
})
# Max coverage (decisions that had any chunks)
total_coverage = await conn.fetchval(
"SELECT count(distinct dc.document_id) "
"FROM document_chunks dc JOIN documents d ON dc.document_id=d.id "
"WHERE d.title LIKE '[קורפוס]%'"
)
# Headline: biggest section
biggest = max(items, key=lambda x: x["pct"]) if items else None
if biggest:
pct_int = round(biggest["pct"] * 100)
headline = f"{biggest['label']} הוא {pct_int}% מכל החלטה אצלך — זה המוקד שלך"
else:
headline = ""
return {
"sections": items,
"total_coverage": total_coverage,
"headline": headline,
}
async def _compute_signature_phrases(conn) -> dict:
"""Section 3: all patterns with real frequencies, plus headline about top."""
rows = await conn.fetch(
"SELECT pattern_type, pattern_text, context, frequency, examples "
"FROM style_patterns "
"WHERE frequency > 0 "
"ORDER BY frequency DESC"
)
items = []
for r in rows:
examples = r["examples"]
if isinstance(examples, str):
try:
examples = json.loads(examples)
except Exception:
examples = []
items.append({
"type": r["pattern_type"],
"text": r["pattern_text"],
"context": r["context"] or "",
"frequency": r["frequency"],
"examples": examples or [],
})
# Total decision count for denominator
total_decisions = await conn.fetchval("SELECT count(*) FROM style_corpus")
if items:
top = items[0]
# Clean up for display: strip placeholder brackets and split alternatives
display = re.sub(r"\[[^\]]*\]", "", top["text"]).replace(" ", " ").strip()
display = display.split(" / ")[0].split(" או ")[0].strip(" .,:;\"'")
if len(display) > 60:
display = display[:57] + "..."
headline = f'הפטרן האהוב עלייך: "{display}" — מופיע ב-{top["frequency"]} מתוך {total_decisions} החלטות'
else:
headline = "טרם חולצו דפוסים — הרץ ניתוח קורפוס"
return {"items": items, "total_decisions": total_decisions, "headline": headline}
async def _compute_contribution(conn) -> dict:
"""Section 4: per-decision contribution + growth curve."""
decisions = await conn.fetch(
"SELECT id, decision_number, decision_date, full_text, "
" length(full_text) as chars, subject_categories "
"FROM style_corpus ORDER BY decision_date NULLS LAST, created_at"
)
patterns = await conn.fetch(
"SELECT id, pattern_type, pattern_text, context "
"FROM style_patterns WHERE frequency > 0"
)
if not decisions or not patterns:
return {
"growth_curve": [],
"decision_contributions": [],
"headline": "אין עדיין מספיק נתונים",
}
# Normalize texts once
normalized_decisions = [
(d["id"], d["decision_number"], _strip_nikud(d["full_text"]))
for d in decisions
]
# For each pattern, find first decision (chronologically) that contains it
# and the full set of decisions that contain it
pattern_info: dict = {} # pattern_id → {"first": decision_id, "all": set}
for p in patterns:
variants = _extract_pattern_variants(_strip_nikud(p["pattern_text"]))
if not variants:
continue
first_seen = None
all_matches = set()
for dec_id, _, text in normalized_decisions:
if any(v in text for v in variants):
if first_seen is None:
first_seen = dec_id
all_matches.add(dec_id)
if first_seen is not None:
pattern_info[p["id"]] = {
"first": first_seen,
"all": all_matches,
"type": p["pattern_type"],
"text": p["pattern_text"],
"context": p["context"] or "",
}
# Per-decision: which patterns are new vs confirmed
decision_contributions = []
cumulative_patterns: set = set()
growth_curve = []
for d in decisions:
dec_id = d["id"]
new_patterns = []
confirmed_patterns = []
for pid, info in pattern_info.items():
if info["first"] == dec_id:
new_patterns.append(info)
elif dec_id in info["all"]:
confirmed_patterns.append(info)
# First 3 new patterns as "highlight"
highlight = new_patterns[0] if new_patterns else None
decision_contributions.append({
"decision_number": d["decision_number"] or "",
"decision_date": str(d["decision_date"]) if d["decision_date"] else "",
"chars": d["chars"],
"subjects": (
json.loads(d["subject_categories"])
if isinstance(d["subject_categories"], str)
else (d["subject_categories"] or [])
),
"new_count": len(new_patterns),
"confirmed_count": len(confirmed_patterns),
"new_patterns": [
{"type": p["type"], "text": p["text"], "context": p["context"]}
for p in new_patterns[:10] # cap to keep payload small
],
"highlight": (
{"type": highlight["type"], "text": highlight["text"]}
if highlight else None
),
})
cumulative_patterns.update(pid for pid, info in pattern_info.items() if info["first"] == dec_id)
growth_curve.append({
"decision_number": d["decision_number"] or "",
"date": str(d["decision_date"]) if d["decision_date"] else "",
"cumulative": len(cumulative_patterns),
})
# Headline: when did we hit ~85%?
total_patterns = len(pattern_info)
threshold = int(total_patterns * 0.85)
n_decisions_to_85pct = None
for i, point in enumerate(growth_curve, 1):
if point["cumulative"] >= threshold:
n_decisions_to_85pct = i
break
if n_decisions_to_85pct:
headline = (
f"אחרי {n_decisions_to_85pct} החלטות כבר למדתי 85% "
f"מהסגנון שלך — השאר מיקד וחידד את הידע"
)
else:
headline = f"למדתי {total_patterns} דפוסים מ-{len(decisions)} החלטות"
return {
"growth_curve": growth_curve,
"decision_contributions": decision_contributions,
"total_patterns": total_patterns,
"headline": headline,
}
@app.get("/api/training/style-report")
async def training_style_report():
"""Visual dashboard data for Dafna's Style Portrait page."""
pool = await db.get_pool()
async with pool.acquire() as conn:
corpus = await _compute_corpus_stats(conn)
anatomy = await _compute_anatomy(conn)
phrases = await _compute_signature_phrases(conn)
contribution = await _compute_contribution(conn)
return {
"corpus": corpus,
"anatomy": anatomy,
"signature_phrases": phrases,
"contribution": contribution,
}
@app.get("/api/training/corpus")
async def training_corpus_list():
"""List all decisions currently in the style corpus."""

View File

@@ -397,6 +397,206 @@ header nav a:hover, header nav a.active { color: #fff; background: rgba(255,255,
display: flex; gap: 10px;
}
/* ── Style Report (Dafna's Portrait) ────────────────── */
.style-report-header { text-align: center; margin-bottom: 32px; padding-top: 16px; }
.style-report-header h1 { font-size: 2em; font-weight: 600; color: #1a1a2e; margin-bottom: 6px; }
.style-report-header .subtitle-muted { color: #888; font-size: 0.95em; }
.portrait-card {
background: #fff; border-radius: 12px; padding: 28px 32px;
margin-bottom: 20px; box-shadow: 0 1px 4px rgba(0,0,0,0.06), 0 0 1px rgba(0,0,0,0.08);
}
.portrait-section-title {
font-size: 1.3em; font-weight: 600; color: #1a1a2e;
margin-bottom: 8px; padding-bottom: 10px;
border-bottom: 2px solid #f0f0f0;
}
.portrait-headline {
font-size: 1.05em; color: #555; line-height: 1.6;
margin-bottom: 20px; padding: 12px 16px;
background: #fff9ed; border-right: 3px solid #e9a13f;
border-radius: 4px;
}
/* Hero section */
.portrait-hero .hero-body {
display: grid; grid-template-columns: 1fr auto; gap: 32px; align-items: center;
margin-bottom: 24px;
}
.hero-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
.hero-stat { text-align: center; padding: 14px; background: #fafafa; border-radius: 8px; }
.hero-stat-value { font-size: 1.9em; font-weight: 700; color: #1a1a2e; line-height: 1; }
.hero-stat-label { font-size: 0.8em; color: #888; margin-top: 6px; }
.hero-donut-wrap { display: flex; align-items: center; gap: 20px; }
.donut {
width: 160px; height: 160px; border-radius: 50%;
position: relative; flex-shrink: 0;
}
.donut::after {
content: ''; position: absolute; inset: 24%;
background: #fff; border-radius: 50%;
}
.donut-center {
position: absolute; inset: 0; display: flex;
align-items: center; justify-content: center;
font-size: 0.85em; color: #666; z-index: 1; font-weight: 600;
}
.donut-legend {
display: flex; flex-direction: column; gap: 6px; font-size: 0.82em;
}
.donut-legend-item {
display: flex; align-items: center; gap: 8px;
}
.donut-legend-dot {
width: 11px; height: 11px; border-radius: 50%; flex-shrink: 0;
}
.hero-timeline {
position: relative; height: 44px; margin-top: 8px;
background: linear-gradient(to left, #fafafa, #fff, #fafafa);
border-radius: 6px; padding: 0 16px;
}
.hero-timeline-line {
position: absolute; top: 50%; right: 16px; left: 16px;
height: 2px; background: #e5e5e5; transform: translateY(-50%);
}
.hero-timeline-dot {
position: absolute; top: 50%; width: 10px; height: 10px;
border-radius: 50%; background: #e94560; transform: translate(50%, -50%);
cursor: pointer; transition: transform 0.15s;
box-shadow: 0 0 0 2px #fff;
}
.hero-timeline-dot:hover { transform: translate(50%, -50%) scale(1.4); z-index: 1; }
/* Anatomy section */
.anatomy-bar {
display: flex; width: 100%; height: 56px;
border-radius: 8px; overflow: hidden;
box-shadow: inset 0 1px 3px rgba(0,0,0,0.06);
}
.anatomy-segment {
display: flex; align-items: center; justify-content: center;
font-size: 0.82em; font-weight: 600; color: #fff;
transition: filter 0.15s; position: relative; cursor: help;
text-align: center; padding: 0 4px; overflow: hidden;
}
.anatomy-segment:hover { filter: brightness(1.08); }
.anatomy-segment small {
display: block; font-size: 0.72em; font-weight: 400; opacity: 0.85;
}
.anatomy-legend {
display: flex; flex-wrap: wrap; gap: 14px;
margin-top: 14px; font-size: 0.8em; color: #666;
}
.anatomy-legend-item { display: flex; align-items: center; gap: 6px; }
.anatomy-legend-dot {
width: 10px; height: 10px; border-radius: 2px; flex-shrink: 0;
}
/* Phrase wall */
.phrase-filters {
display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px;
}
.phrase-filter {
padding: 6px 14px; border-radius: 18px;
border: 1px solid #ddd; background: #fff;
font-size: 0.82em; cursor: pointer; transition: all 0.12s;
}
.phrase-filter:hover { background: #f5f5f5; }
.phrase-filter.active {
background: #1a1a2e; color: #fff; border-color: #1a1a2e;
}
.phrase-wall {
display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 12px;
}
.phrase-card {
padding: 14px 16px; border-radius: 8px; background: #fafafa;
border-right: 3px solid; cursor: pointer; transition: all 0.15s;
display: flex; flex-direction: column; gap: 6px;
}
.phrase-card:hover { background: #fff; transform: translateY(-1px); box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.phrase-card-text { font-weight: 500; color: #1a1a2e; line-height: 1.4; }
.phrase-card-meta {
display: flex; justify-content: space-between; font-size: 0.75em; color: #999;
margin-top: auto;
}
.phrase-card-freq {
background: #fff; padding: 2px 8px; border-radius: 10px; font-weight: 600;
}
/* Growth curve + Contribution */
.growth-curve-wrap { margin-bottom: 24px; }
.growth-curve-label { font-size: 0.82em; color: #888; margin-bottom: 8px; }
.growth-curve {
width: 100%; height: 160px;
background: linear-gradient(to bottom, #fafafa, #fff);
border-radius: 6px;
}
.growth-curve-path { fill: none; stroke: #e94560; stroke-width: 2.5; }
.growth-curve-area { fill: #fce4e9; opacity: 0.6; }
.growth-curve-dot { fill: #e94560; cursor: pointer; transition: r 0.15s; }
.growth-curve-dot:hover { r: 6; }
.contribution-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 12px;
}
.contribution-card {
padding: 14px 16px; background: #fafafa; border-radius: 8px;
border: 1px solid #eee;
transition: all 0.15s;
}
.contribution-card:hover { background: #fff; box-shadow: 0 2px 6px rgba(0,0,0,0.05); }
.contribution-card-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 8px; font-size: 0.82em; color: #555;
}
.contribution-card-number { font-weight: 600; color: #1a1a2e; font-size: 1em; }
.contribution-badges { display: flex; gap: 8px; margin: 10px 0; }
.contribution-badge {
padding: 4px 10px; border-radius: 10px; font-size: 0.78em; font-weight: 600;
}
.contribution-badge.new { background: #e8f5e9; color: #2e7d32; }
.contribution-badge.confirmed { background: #e3f2fd; color: #1565c0; }
.contribution-highlight {
font-size: 0.82em; color: #666; margin-top: 8px; padding-top: 8px;
border-top: 1px dashed #e5e5e5; line-height: 1.5;
}
.contribution-highlight strong { color: #1a1a2e; }
/* Modal */
.phrase-modal {
border: none; border-radius: 12px; padding: 24px 28px;
max-width: 640px; width: 90%; direction: rtl;
box-shadow: 0 20px 60px rgba(0,0,0,0.2);
}
.phrase-modal::backdrop { background: rgba(0,0,0,0.4); }
.phrase-modal-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 14px; padding-bottom: 12px; border-bottom: 1px solid #eee;
}
.phrase-modal-type {
font-size: 0.78em; color: #888; text-transform: uppercase;
letter-spacing: 0.05em;
}
.phrase-modal-text {
font-size: 1.1em; font-weight: 600; color: #1a1a2e;
margin-bottom: 12px; line-height: 1.5;
}
.phrase-modal-context {
font-size: 0.88em; color: #666; margin-bottom: 16px; line-height: 1.6;
}
.phrase-modal-examples {
display: flex; flex-direction: column; gap: 10px;
max-height: 300px; overflow-y: auto;
}
.phrase-modal-example {
padding: 10px 14px; background: #fafafa; border-right: 3px solid #e94560;
font-size: 0.86em; line-height: 1.5; color: #333; border-radius: 4px;
}
@media (max-width: 800px) {
.main { padding: 16px; }
header { padding: 14px 16px; }
@@ -417,6 +617,7 @@ header nav a:hover, header nav a.active { color: #fff; background: rgba(255,255,
<a href="#/new" id="navNew">+ תיק חדש</a>
<a href="#/upload" id="navUpload">העלאה</a>
<a href="#/training" id="navTraining">אימון סגנון</a>
<a href="#/style-report" id="navStyleReport">הסגנון שלי</a>
<a href="#/skills" id="navSkills">Skills</a>
</nav>
</header>
@@ -735,9 +936,83 @@ header nav a:hover, header nav a.active { color: #fff; background: rgba(255,255,
<div class="empty">טוען...</div>
</div>
</div>
<div style="margin-top:24px;text-align:center">
<a href="#/style-report" class="btn btn-primary" style="font-size:1em;padding:12px 28px">
← צפי בפורטרט הסגנון המלא
</a>
</div>
</div>
<!-- ══ Page: Style Report (Dafna's Portrait) ══ -->
<div class="page" id="page-style-report">
<div class="style-report-header">
<h1>פורטרט הסגנון שלך</h1>
<p class="subtitle-muted">דוח ויזואלי על סמך הקורפוס שלמדתי ממך</p>
</div>
<div id="styleReportLoading" class="empty" style="padding:60px 20px">
<div class="mini-spinner" style="width:24px;height:24px"></div>
<div style="margin-top:12px">טוען את הפורטרט...</div>
</div>
<div id="styleReportContent" style="display:none">
<!-- Section 1: Hero -->
<section class="portrait-card portrait-hero">
<div class="portrait-headline" id="heroHeadline"></div>
<div class="hero-body">
<div class="hero-stats" id="heroStats"></div>
<div class="hero-donut-wrap">
<div class="donut" id="heroDonut"></div>
<div class="donut-legend" id="heroDonutLegend"></div>
</div>
</div>
<div class="hero-timeline" id="heroTimeline"></div>
</section>
<!-- Section 2: Anatomy -->
<section class="portrait-card portrait-anatomy">
<h2 class="portrait-section-title">איך את בונה החלטה</h2>
<div class="portrait-headline" id="anatomyHeadline"></div>
<div class="anatomy-bar" id="anatomyBar"></div>
<div class="anatomy-legend" id="anatomyLegend"></div>
</section>
<!-- Section 3: Signature Phrases Wall -->
<section class="portrait-card portrait-phrases">
<h2 class="portrait-section-title">הביטויים שחוזרים אצלך</h2>
<div class="portrait-headline" id="phrasesHeadline"></div>
<div class="phrase-filters" id="phraseFilters"></div>
<div class="phrase-wall" id="phraseWall"></div>
</section>
<!-- Section 4: Contribution -->
<section class="portrait-card portrait-contribution">
<h2 class="portrait-section-title">מה תרמה כל החלטה</h2>
<div class="portrait-headline" id="contributionHeadline"></div>
<div class="growth-curve-wrap">
<div class="growth-curve-label">עקומת הלמידה — כמה ידע חדש כל החלטה הביאה</div>
<svg class="growth-curve" id="growthCurve" viewBox="0 0 800 160" preserveAspectRatio="none"></svg>
</div>
<div class="contribution-grid" id="contributionGrid"></div>
</section>
</div>
</div>
</div>
<!-- Modal for pattern examples -->
<dialog id="phraseModal" class="phrase-modal">
<div class="phrase-modal-header">
<span class="phrase-modal-type" id="phraseModalType"></span>
<button class="btn-icon" onclick="document.getElementById('phraseModal').close()"></button>
</div>
<div class="phrase-modal-text" id="phraseModalText"></div>
<div class="phrase-modal-context" id="phraseModalContext"></div>
<div class="phrase-modal-examples" id="phraseModalExamples"></div>
</dialog>
<!-- Status Bar -->
<div class="status-bar">
<div class="stat">תיקים: <span class="stat-value" id="statCases"></span></div>
@@ -803,6 +1078,11 @@ function handleRoute() {
document.getElementById('navTraining').classList.add('active');
subtitle = 'אימון סגנון';
initTrainingPage();
} else if (hash === '#/style-report') {
document.getElementById('page-style-report').classList.add('active');
document.getElementById('navStyleReport').classList.add('active');
subtitle = 'פורטרט הסגנון שלי';
loadStyleReport();
}
document.getElementById('pageSubtitle').textContent = subtitle;
@@ -2086,6 +2366,288 @@ async function pollStyleAnalysisStatus() {
}
}
// ── Style Report Page ────────────────────────────────────
const PATTERN_TYPE_COLORS = {
opening_formula: '#5e9a6e',
closing_formula: '#c87533',
transition: '#4e7cb3',
characteristic_phrase: '#a7547c',
argument_flow: '#7e5c9a',
analysis_structure: '#3e8583',
evidence_handling: '#b8894a',
citation_style: '#5f6b8c',
};
const SECTION_COLORS = {
intro: '#4e7cb3',
facts: '#5e9a6e',
appellant_claims: '#a7547c',
respondent_claims: '#c87533',
legal_analysis: '#7e5c9a',
ruling: '#3e8583',
conclusion: '#b8894a',
};
const DONUT_COLORS = ['#e94560', '#5e9a6e', '#4e7cb3', '#a7547c', '#c87533', '#7e5c9a', '#b8894a'];
let _styleReportData = null;
let _activeFilter = 'all';
async function loadStyleReport() {
document.getElementById('styleReportLoading').style.display = '';
document.getElementById('styleReportContent').style.display = 'none';
try {
const res = await fetch(API + '/training/style-report');
if (!res.ok) throw new Error('Failed to load report');
_styleReportData = await res.json();
renderHero(_styleReportData.corpus);
renderAnatomy(_styleReportData.anatomy);
renderPhrases(_styleReportData.signature_phrases);
renderContribution(_styleReportData.contribution);
document.getElementById('styleReportLoading').style.display = 'none';
document.getElementById('styleReportContent').style.display = '';
} catch (e) {
document.getElementById('styleReportLoading').innerHTML = `<div>שגיאה: ${esc(e.message)}</div>`;
}
}
function renderHero(corpus) {
document.getElementById('heroHeadline').textContent = '★ ' + corpus.headline;
document.getElementById('heroStats').innerHTML = `
<div class="hero-stat">
<div class="hero-stat-value">${corpus.decision_count}</div>
<div class="hero-stat-label">החלטות בקורפוס</div>
</div>
<div class="hero-stat">
<div class="hero-stat-value">${(corpus.total_chars / 1000).toFixed(0)}K</div>
<div class="hero-stat-label">סך תווים</div>
</div>
<div class="hero-stat">
<div class="hero-stat-value">${(corpus.avg_chars / 1000).toFixed(0)}K</div>
<div class="hero-stat-label">ממוצע להחלטה</div>
</div>
`;
// Donut (CSS conic-gradient)
const total = corpus.subject_distribution.reduce((a, b) => a + b.count, 0);
let pct = 0;
const segments = corpus.subject_distribution.map((s, i) => {
const start = (pct / total) * 360;
pct += s.count;
const end = (pct / total) * 360;
const color = DONUT_COLORS[i % DONUT_COLORS.length];
return `${color} ${start}deg ${end}deg`;
}).join(', ');
const donut = document.getElementById('heroDonut');
donut.style.background = `conic-gradient(${segments})`;
donut.innerHTML = `<div class="donut-center">${corpus.decision_count} החלטות</div>`;
document.getElementById('heroDonutLegend').innerHTML = corpus.subject_distribution.map((s, i) => `
<div class="donut-legend-item">
<span class="donut-legend-dot" style="background:${DONUT_COLORS[i % DONUT_COLORS.length]}"></span>
<span>${esc(s.label)} · ${s.count}</span>
</div>
`).join('');
// Timeline
const tl = document.getElementById('heroTimeline');
const dated = corpus.decisions.filter(d => d.date);
if (dated.length < 2) {
tl.innerHTML = '';
return;
}
const dates = dated.map(d => new Date(d.date).getTime());
const minT = Math.min(...dates);
const maxT = Math.max(...dates);
const range = maxT - minT || 1;
let html = '<div class="hero-timeline-line"></div>';
dated.forEach(d => {
const t = new Date(d.date).getTime();
const pct = ((t - minT) / range) * 100;
html += `<div class="hero-timeline-dot" style="right:${pct}%" title="${esc(d.number)} · ${esc(d.date)}"></div>`;
});
tl.innerHTML = html;
}
function renderAnatomy(anatomy) {
document.getElementById('anatomyHeadline').textContent = '★ ' + anatomy.headline;
if (!anatomy.sections || !anatomy.sections.length) {
document.getElementById('anatomyBar').innerHTML = '<div class="empty" style="width:100%">אין עדיין נתונים</div>';
return;
}
const bar = document.getElementById('anatomyBar');
bar.innerHTML = anatomy.sections.map(s => {
const color = SECTION_COLORS[s.type] || '#888';
const pctDisplay = Math.round(s.pct * 100);
return `
<div class="anatomy-segment" style="flex:${s.pct}; background:${color}"
title="${esc(s.label)}: ממוצע ${s.avg_chars.toLocaleString('he-IL')} תווים, ${s.coverage} החלטות">
<div>
<div>${esc(s.label)}</div>
<small>${pctDisplay}%</small>
</div>
</div>
`;
}).join('');
document.getElementById('anatomyLegend').innerHTML = anatomy.sections.map(s => `
<div class="anatomy-legend-item">
<span class="anatomy-legend-dot" style="background:${SECTION_COLORS[s.type] || '#888'}"></span>
<span>${esc(s.label)} · ממוצע ${s.avg_chars.toLocaleString('he-IL')} תווים · ${s.coverage} החלטות</span>
</div>
`).join('');
}
function renderPhrases(phrases) {
document.getElementById('phrasesHeadline').textContent = '★ ' + phrases.headline;
// Build filter chips — one per pattern_type that appears
const types = [...new Set(phrases.items.map(p => p.type))];
const typeLabels = {
opening_formula: 'פתיחה',
closing_formula: 'סיום',
transition: 'מעברים',
characteristic_phrase: 'ביטויים',
argument_flow: 'טיעון',
analysis_structure: 'מבנה',
evidence_handling: 'ראיות',
citation_style: 'ציטוט',
};
const filters = [{ id: 'all', label: 'הכל' }]
.concat(types.map(t => ({ id: t, label: typeLabels[t] || t })));
document.getElementById('phraseFilters').innerHTML = filters.map(f => `
<div class="phrase-filter ${f.id === _activeFilter ? 'active' : ''}"
data-filter="${f.id}" onclick="setPhraseFilter('${f.id}')">${esc(f.label)}</div>
`).join('');
renderPhraseWall(phrases.items);
}
function setPhraseFilter(filterId) {
_activeFilter = filterId;
document.querySelectorAll('.phrase-filter').forEach(el => {
el.classList.toggle('active', el.dataset.filter === filterId);
});
renderPhraseWall(_styleReportData.signature_phrases.items);
}
function renderPhraseWall(items) {
const filtered = _activeFilter === 'all'
? items
: items.filter(p => p.type === _activeFilter);
document.getElementById('phraseWall').innerHTML = filtered.map((p, idx) => {
// Clean display text — first alternative, strip placeholders
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) + '...';
const color = PATTERN_TYPE_COLORS[p.type] || '#888';
const origIdx = items.indexOf(p);
return `
<div class="phrase-card" style="border-right-color:${color}" onclick="showPhraseModal(${origIdx})">
<div class="phrase-card-text">${esc(display)}</div>
<div class="phrase-card-meta">
<span>${esc(p.context.substring(0, 40))}</span>
<span class="phrase-card-freq">${p.frequency}/24</span>
</div>
</div>
`;
}).join('');
}
function showPhraseModal(idx) {
const p = _styleReportData.signature_phrases.items[idx];
if (!p) return;
const typeLabels = {
opening_formula: 'נוסחת פתיחה',
closing_formula: 'נוסחת סיום',
transition: 'ביטוי מעבר',
characteristic_phrase: 'ביטוי אופייני',
argument_flow: 'זרימת טיעון',
analysis_structure: 'מבנה ניתוח',
evidence_handling: 'טיפול בראיות',
citation_style: 'סגנון ציטוט',
};
document.getElementById('phraseModalType').textContent =
(typeLabels[p.type] || p.type) + ` · ${p.frequency}/24 החלטות`;
document.getElementById('phraseModalText').textContent = p.text;
document.getElementById('phraseModalContext').textContent = p.context || '';
const examples = (p.examples || []).filter(e => e && e.length > 0);
document.getElementById('phraseModalExamples').innerHTML = examples.length
? examples.map(e => `<div class="phrase-modal-example">${esc(e)}</div>`).join('')
: '<div class="empty">אין דוגמאות שמורות</div>';
document.getElementById('phraseModal').showModal();
}
function renderContribution(contrib) {
document.getElementById('contributionHeadline').textContent = '★ ' + contrib.headline;
// Growth curve — SVG polyline
const points = contrib.growth_curve;
if (points.length >= 2) {
const w = 800, h = 160, pad = 20;
const maxY = Math.max(...points.map(p => p.cumulative)) || 1;
const step = (w - pad * 2) / (points.length - 1);
// RTL: right = start, so reverse X
const coords = points.map((p, i) => {
const x = w - pad - i * step;
const y = h - pad - (p.cumulative / maxY) * (h - pad * 2);
return { x, y, ...p };
});
const path = coords.map((c, i) => `${i === 0 ? 'M' : 'L'} ${c.x.toFixed(1)} ${c.y.toFixed(1)}`).join(' ');
const areaPath = path + ` L ${coords[coords.length - 1].x} ${h - pad} L ${coords[0].x} ${h - pad} Z`;
const svg = document.getElementById('growthCurve');
svg.innerHTML = `
<path class="growth-curve-area" d="${areaPath}"/>
<path class="growth-curve-path" d="${path}"/>
${coords.map(c => `
<circle class="growth-curve-dot" cx="${c.x}" cy="${c.y}" r="4">
<title>${esc(c.decision_number || 'ללא מספר')}: ${c.cumulative} דפוסים מצטברים</title>
</circle>
`).join('')}
`;
}
// Contribution cards — sort by date
const cards = contrib.decision_contributions;
document.getElementById('contributionGrid').innerHTML = cards.map(d => {
const highlight = d.highlight;
let highlightDisplay = '';
if (highlight) {
let text = highlight.text.replace(/\[[^\]]*\]/g, '').replace(/\s+/g, ' ').trim();
text = text.split(' / ')[0].split(' או ')[0].trim();
if (text.length > 80) text = text.substring(0, 77) + '...';
highlightDisplay = `
<div class="contribution-highlight">
▸ תרומה בולטת: <strong>"${esc(text)}"</strong>
</div>
`;
}
return `
<div class="contribution-card">
<div class="contribution-card-header">
<span class="contribution-card-number">${esc(d.decision_number || 'ללא מספר')}</span>
<span>${esc(d.decision_date || '—')}</span>
</div>
<div class="contribution-badges">
<span class="contribution-badge new">🟢 ${d.new_count} חדשים</span>
<span class="contribution-badge confirmed">🔵 ${d.confirmed_count} חיזקה</span>
</div>
${highlightDisplay}
</div>
`;
}).join('');
}
async function loadCorpusList() {
const container = document.getElementById('corpusList');
const count = document.getElementById('corpusCount');