diff --git a/scripts/backfill_pattern_frequency.py b/scripts/backfill_pattern_frequency.py new file mode 100644 index 0000000..6ba3fe6 --- /dev/null +++ b/scripts/backfill_pattern_frequency.py @@ -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())) diff --git a/web/app.py b/web/app.py index 46c9820..5b30731 100644 --- a/web/app.py +++ b/web/app.py @@ -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.""" diff --git a/web/static/index.html b/web/static/index.html index 84a5681..2b1207a 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -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, + תיק חדש העלאה אימון סגנון + הסגנון שלי Skills @@ -735,9 +936,83 @@ header nav a:hover, header nav a.active { color: #fff; background: rgba(255,255,
דוח ויזואלי על סמך הקורפוס שלמדתי ממך
+