diff --git a/cron_tasks/verify_pending_external.py b/cron_tasks/verify_pending_external.py index b1ee909..24c11a1 100755 --- a/cron_tasks/verify_pending_external.py +++ b/cron_tasks/verify_pending_external.py @@ -78,7 +78,7 @@ def main() -> int: pending = [ eg for eg in all_egs - if (not eg.correctness.confirmed and eg.correctness.rejections == 0) + if (not eg.correctness.is_final()) ] confirmed = 0 @@ -94,16 +94,7 @@ def main() -> int: if src == "session" and ( content.startswith("Session Summary (sess_") or content.startswith("Please remember ") ): - eg.correctness.rejections += 1 - eg.correctness.last_reviewed = _now() - eg.correctness.review_history.append( - ReviewEntry( - by="verify-pending", - action="reject", - at=_now(), - note="Auto-reject: session placeholder", - ) - ) + eg.correctness.reject("verify-pending", "Auto-reject: session placeholder") store.save(eg) rejected += 1 continue @@ -118,30 +109,11 @@ def main() -> int: still_pending += 1 continue if 200 <= status < 300: - eg.correctness.confirmed = True - eg.correctness.confirmations += 1 - eg.correctness.last_reviewed = _now() - eg.correctness.review_history.append( - ReviewEntry( - by="verify-pending", - action="confirm", - at=_now(), - note=f"Auto-confirm: web url ok ({status}) {url}", - ) - ) + eg.correctness.confirm("verify-pending", f"Auto-confirm: web url ok ({status}) {url}") store.save(eg) confirmed += 1 else: - eg.correctness.rejections += 1 - eg.correctness.last_reviewed = _now() - eg.correctness.review_history.append( - ReviewEntry( - by="verify-pending", - action="reject", - at=_now(), - note=f"Auto-reject: web url status={status} {url}", - ) - ) + eg.correctness.reject("verify-pending", f"Auto-reject: web url status={status} {url}") store.save(eg) rejected += 1 continue diff --git a/docs/dashboard-ui-ux-design.md b/docs/dashboard-ui-ux-design.md new file mode 100644 index 0000000..475567c --- /dev/null +++ b/docs/dashboard-ui-ux-design.md @@ -0,0 +1,215 @@ +# Second-Brain Dashboard UI/UX Design Plan (mobile-first) + +Basis: `second-brain/templates/dashboard.html`, `second-brain/static/style.css` (aktueller Stand) + neue Graph-Controls aus `/tmp/second-brain-staging/`. + +## 1) Konkreter Design-Plan + +### Ziele (UX) +- **Schnelles Triaging unterwegs:** Pending/Errors sofort sichtbar, 1-Hand-Bedienung. +- **Konsistentes Design-System:** einheitliche Buttons/Inputs/Panels statt Einzellösungen. +- **Graph als Diagnose-Tool:** klare Controls, Legende, nachvollziehbares Feedback (Loading/Empty/Errors). + +### Farbschema (Dark, high-contrast, "indigo + emerald") +- **Background:** sehr dunkel (nahe #0f1117) für weniger Blendung. +- **Surface (Cards/Panels):** abgestufte Flächen (Surface-1, Surface-2) für Hierarchie. +- **Primary:** Indigo/Blue für interaktive Elemente und Highlights (bisher #6c8af5 bleibt als Basis). +- **Success:** Emerald für Confirm/OK (bisher #3a7d3a → etwas heller/satter). +- **Danger:** Red für Errors/Reject. +- **Warning:** Amber für Pending/Match/Attention. +- **Text:** fast-weiß, sekundär gedimmt. + +### Typografie +- Systemfont-Stack (schnell, gut lesbar): `ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial`. +- Skala (mobile-first): + - `text-xs` 12px (Labels, Meta) + - `text-sm` 13–14px (secondary) + - `text-md` 15–16px (Body) + - `text-lg` 18–20px (Titles) +- **Ziffern:** optional `font-variant-numeric: tabular-nums;` für Stats. + +### Spacing/Rhythm +- 4px Grid; Standard-Gaps: 8/12/16. +- Container-Padding: 12px (mobile), 16–20px (>=768px). +- Border-radius: 12–16px (Cards/Modals), 10px (Inputs/Buttons), 999px (Pills). + +### Komponenten (konkret) + +#### A) Tabs +- Tabs bleiben als 3 Buttons, aber: + - **Active State** deutlicher (background + border + subtle glow). + - **Touch target** min. 44px Höhe. + - Sticky bleibt, aber mit leichter **Backdrop-Blurring** (wenn möglich) oder solid surface. + +#### B) Search +- Search-Row wird als **kompakte Toolbar** gestaltet: + - Input + Filter in einer Zeile. + - Optional Quick-Chips darunter: `All / Pending / Confirmed / Errors` (klickbar) als Alternative zum Select. +- Clear-Button (×) im Input (per CSS `::-webkit-search-cancel-button` oder eigener Button) für Mobile. + +#### C) Cards +- Karte als 3 Zonen: Header (badges/tags/date), Body (content), Footer (actions). +- Status wird stärker codiert: + - left-border + kleine **Status-Pill** (OK/Pending/Error) mit eindeutiger Farbe. +- Body: bessere Lesbarkeit via `line-height: 1.55` und max-height/clamp optional. + +#### D) Modal +- Modal als **Bottom Sheet** auf Mobile (>=50vh) + klassisches Center-Modal auf Desktop. +- Close-Button größer + Tap-Area. +- Inhalt in Tabs/Sections (History/Meta/Content) optional später. + +#### E) Graph + Controls (aus Staging) +- Controls als **Control Bar** oberhalb Canvas: + - Primary: Physics toggle. + - Secondary: Fit, Reset, Reload. + - Text labels kurz (z.B. `Physics` statt `Physics: off`, state als Badge). +- Canvas passt sich an Viewport an: + - `width: min(100%, 560px)`; Height: `min(65vh, 560px)` (CSS statt fester HTML Attribute, wenn möglich). +- Legende als einklappbares Panel (`Details`/`summary`) oder leichtes Panel unter Canvas. + +#### F) Status Panels +- Status-View nutzt vorhandene `.panel`/`.kv-*`: + - Gruppen: System, Storage, Jobs, Insights, Pending Queue. + - Jede Gruppe als Panel mit klarer Title Row. +- Kritische Werte (Errors/Pending/Queue) farblich markieren. + +## 2) CSS-Variablen (Theme-Tokens) + Mapping + +### Token-Vorschlag (`:root`) +```css +:root { + /* typography */ + --font-sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; + + /* colors */ + --bg: #0f1117; + --surface-1: #14151d; + --surface-2: #1a1b26; + --border: #2a2d3a; + + --text: #e8e8ee; + --text-muted: #8b90a3; + --text-dim: #5c6276; + + --primary: #6c8af5; /* existing */ + --primary-2: #8aa1ff; + --success: #2fbf71; + --danger: #ef4444; + --warning: #f7d154; /* existing-ish */ + + /* shadows */ + --shadow-1: 0 1px 0 rgba(0,0,0,.25), 0 8px 24px rgba(0,0,0,.35); + + /* radius */ + --r-sm: 10px; + --r-md: 14px; + --r-lg: 16px; + + /* spacing */ + --s-1: 4px; + --s-2: 8px; + --s-3: 12px; + --s-4: 16px; + --s-5: 20px; + + /* control sizes */ + --tap: 44px; +} +``` + +### Mapping auf existierende Selektoren +- `body`/`.app` + - `background: var(--bg)` statt `#141419` + - `font-family: var(--font-sans)` + - `color: var(--text)` +- `.stats-bar`, `.tabs-bar`, `.search-box` + - `background: var(--surface-1)` (Stats ggf. Gradient bleibt, aber auf Tokens) + - `border-bottom: 1px solid var(--border)` +- `.panel`, `.card`, `.modal-content`, `.graph-legend` + - `background: var(--surface-2)` + - `border: 1px solid var(--border)` + - `border-radius: var(--r-md)` +- `.stat-num`, `#pageNum`, `.tab-btn.active`, `#searchInput:focus` + - `color/border-color: var(--primary)` +- `.muted`, `.stat-label`, `.kv-key`, `.date` + - `color: var(--text-muted)` bzw. `var(--text-dim)` +- Buttons + - vereinheitlichen über `.btn` + Modifier: `.btn.primary`, `.btn.danger`, `.btn.success`, `.btn.ghost` + - `min-height: var(--tap)` für Touch + +## 3) 5–10 priorisierte UI-Änderungen (mit Begründung) + +1) **Design Tokens (CSS vars) einführen** → reduziert Farbmix, erleichtert spätere Themes/Anpassungen. +2) **Einheitliche Button-Komponente (`.btn`)** (inkl. `:active`, `:disabled`, `min-height`) → bessere Touch-UX, konsistente Interaktion. +3) **Graph-Controls + Legende aus Staging in den Main-Template-Stand ziehen** → Graph wird tatsächlich bedienbar/selbsterklärend. +4) **Responsive Graph-Canvas (CSS gesteuert)** statt fixer `width/height` → bessere Nutzung auf Phones, weniger Scroll. +5) **Search als Toolbar + Clear-Action** → schnelleres Filtern unterwegs, weniger Friktion. +6) **Modal als Bottom-Sheet auf Mobile** → angenehmer für längeren Content + History, weniger „winziges Fenster“. +7) **Status/Health Werte farblich akzentuieren** (pending/errors/warn) → schnelleres Erkennen von Problemen. +8) **Cards: Status-Pill + typografische Lesbarkeit** (line-height, spacing) → weniger „Textblock“, bessere Scanbarkeit. +9) **Accessibility-Basics**: Focus-Rings, Kontrast, `prefers-reduced-motion` → weniger „invisible focus“ und bessere Bedienbarkeit. +10) **Top-level Layout Max-Width für Desktop** (z.B. 560–720px) → verhindert „zu breite“ Zeilen. + +## 4) Optionale Patch-Vorschläge (Diff-Snippets, NICHT anwenden) + +> Hinweis: Snippets sind bewusst klein gehalten. Gesamt < 120 Zeilen. + +### Snippet A — Tokens + Button-System (style.css) +```diff +--- a/second-brain/static/style.css ++++ b/second-brain/static/style.css +@@ ++:root { ++ --font-sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; ++ --bg:#0f1117; --surface-1:#14151d; --surface-2:#1a1b26; --border:#2a2d3a; ++ --text:#e8e8ee; --text-muted:#8b90a3; --text-dim:#5c6276; ++ --primary:#6c8af5; --success:#2fbf71; --danger:#ef4444; --warning:#f7d154; ++ --r-sm:10px; --r-md:14px; --r-lg:16px; ++ --s-2:8px; --s-3:12px; --s-4:16px; ++ --tap:44px; ++} ++ ++body { font-family: var(--font-sans); background: var(--bg); color: var(--text); } ++ ++.btn{ ++ min-height: var(--tap); ++ background: #1e1e28; ++ border: 1px solid var(--border); ++ border-radius: var(--r-sm); ++ padding: 8px 12px; ++ color: #cfd3ff; ++ font-weight: 700; ++} ++.btn.primary{ border-color: var(--primary); box-shadow: 0 0 0 1px rgba(108,138,245,0.18) inset; } ++.btn.success{ background: rgba(47,191,113,.18); border-color: rgba(47,191,113,.35); } ++.btn.danger{ background: rgba(239,68,68,.16); border-color: rgba(239,68,68,.35); } ++.btn:active{ transform: scale(.98); } ++.btn:disabled{ opacity: .45; } +``` + +### Snippet B — Graph Controls + Legend übernehmen (dashboard.html) +```diff +--- a/second-brain/templates/dashboard.html ++++ b/second-brain/templates/dashboard.html +@@ +- ++ +``` + diff --git a/fastapi_app.py b/fastapi_app.py index 098714e..b5feed6 100644 --- a/fastapi_app.py +++ b/fastapi_app.py @@ -54,11 +54,22 @@ def get_db(): def parse_engram(row: sqlite3.Row) -> dict: meta = json.loads(row["metadata_json"] or "{}") correctness = json.loads(row["correctness_json"] or "{}") + verdict = correctness.get("verdict") + if not isinstance(verdict, str) or not verdict: + # Back-compat inference for older rows + if correctness.get("confirmed", False): + verdict = "confirmed_true" + elif int(correctness.get("rejections", 0) or 0) > 0: + verdict = "confirmed_false" + else: + verdict = "unknown" return { "id": row["id"], "content": row["content"], "confidence": meta.get("confidence", 0.0), "confirmed": correctness.get("confirmed", False), + "verdict": verdict, + "evidence": correctness.get("evidence", []), "confirmations": correctness.get("confirmations", 0), "rejections": correctness.get("rejections", 0), "tags": meta.get("tags", []), @@ -88,6 +99,8 @@ def _update_correctness(engram_id: str, *, action: str, reason: str | None = Non raise FileNotFoundError(f"Engram not found: {engram_id}") corr = json.loads(row["correctness_json"] or "{}") + corr.setdefault("verdict", None) + corr.setdefault("evidence", []) corr.setdefault("confirmed", False) corr.setdefault("confirmations", 0) corr.setdefault("rejections", 0) @@ -106,10 +119,30 @@ def _update_correctness(engram_id: str, *, action: str, reason: str | None = Non corr["review_history"] = [entry] if action == "confirm": + corr["verdict"] = "confirmed_true" corr["confirmed"] = True corr["confirmations"] = int(corr.get("confirmations", 0) or 0) + 1 elif action == "reject": + corr["verdict"] = "confirmed_false" corr["rejections"] = int(corr.get("rejections", 0) or 0) + 1 + corr["confirmed"] = False + + # Store minimal evidence for dashboard-driven actions. + try: + ev = corr.get("evidence") + if not isinstance(ev, list): + ev = [] + ev.append( + { + "kind": "human", + "by": "dashboard", + "at": corr["last_reviewed"], + "action": action, + } + ) + corr["evidence"] = ev[-50:] # cap growth + except Exception: + pass c.execute( "UPDATE engrams SET correctness_json = ?, modified_at = ? WHERE id = ?", @@ -232,8 +265,25 @@ def api_storage_stats(): conn = get_db() c = conn.cursor() total = c.execute("SELECT COUNT(*) FROM engrams").fetchone()[0] - confirmed = c.execute( - "SELECT COUNT(*) FROM engrams WHERE json_extract(correctness_json, '$.confirmed') = 1" + confirmed_true = c.execute( + """ + SELECT COUNT(*) FROM engrams + WHERE ( + json_extract(correctness_json, '$.verdict') = 'confirmed_true' + OR (json_extract(correctness_json, '$.verdict') IS NULL AND json_extract(correctness_json, '$.confirmed') = 1) + ) + """ + ).fetchone()[0] + confirmed_false = c.execute( + """ + SELECT COUNT(*) FROM engrams + WHERE ( + json_extract(correctness_json, '$.verdict') = 'confirmed_false' + OR (json_extract(correctness_json, '$.verdict') IS NULL + AND json_extract(correctness_json, '$.confirmed') = 0 + AND COALESCE(json_extract(correctness_json, '$.rejections'), 0) > 0) + ) + """ ).fetchone()[0] sources = { r[0]: r[1] @@ -268,8 +318,9 @@ def api_storage_stats(): return { "sql": { "total_engrams": total, - "confirmed": confirmed, - "pending": total - confirmed, + "confirmed": confirmed_true, + "rejected": confirmed_false, + "pending": total - confirmed_true - confirmed_false, "by_source": sources, }, "vector": { @@ -310,10 +361,27 @@ def api_insights(limit: int = Query(8, ge=1, le=50)): "SELECT id, metadata_json, correctness_json, created_at, modified_at FROM engrams ORDER BY created_at DESC LIMIT 2000" ).fetchall() total = c.execute("SELECT COUNT(*) FROM engrams").fetchone()[0] - confirmed = c.execute( - "SELECT COUNT(*) FROM engrams WHERE json_extract(correctness_json, '$.confirmed') = 1" + confirmed_true = c.execute( + """ + SELECT COUNT(*) FROM engrams + WHERE ( + json_extract(correctness_json, '$.verdict') = 'confirmed_true' + OR (json_extract(correctness_json, '$.verdict') IS NULL AND json_extract(correctness_json, '$.confirmed') = 1) + ) + """ ).fetchone()[0] - pending = total - confirmed + confirmed_false = c.execute( + """ + SELECT COUNT(*) FROM engrams + WHERE ( + json_extract(correctness_json, '$.verdict') = 'confirmed_false' + OR (json_extract(correctness_json, '$.verdict') IS NULL + AND json_extract(correctness_json, '$.confirmed') = 0 + AND COALESCE(json_extract(correctness_json, '$.rejections'), 0) > 0) + ) + """ + ).fetchone()[0] + pending = total - confirmed_true - confirmed_false tag_counts: dict[str, int] = {} source_counts: dict[str, int] = {} @@ -488,10 +556,27 @@ def api_stats(): conn = get_db() c = conn.cursor() total = c.execute("SELECT COUNT(*) FROM engrams").fetchone()[0] - confirmed = c.execute( - "SELECT COUNT(*) FROM engrams WHERE json_extract(correctness_json, '$.confirmed') = 1" + confirmed_true = c.execute( + """ + SELECT COUNT(*) FROM engrams + WHERE ( + json_extract(correctness_json, '$.verdict') = 'confirmed_true' + OR (json_extract(correctness_json, '$.verdict') IS NULL AND json_extract(correctness_json, '$.confirmed') = 1) + ) + """ ).fetchone()[0] - pending = total - confirmed + confirmed_false = c.execute( + """ + SELECT COUNT(*) FROM engrams + WHERE ( + json_extract(correctness_json, '$.verdict') = 'confirmed_false' + OR (json_extract(correctness_json, '$.verdict') IS NULL + AND json_extract(correctness_json, '$.confirmed') = 0 + AND COALESCE(json_extract(correctness_json, '$.rejections'), 0) > 0) + ) + """ + ).fetchone()[0] + pending = total - confirmed_true - confirmed_false errors = c.execute( "SELECT COUNT(*) FROM engrams WHERE json_extract(metadata_json, '$.tags') LIKE '%error%'" ).fetchone()[0] @@ -501,7 +586,8 @@ def api_stats(): conn.close() return { "total": total, - "confirmed": confirmed, + "confirmed": confirmed_true, + "rejected": confirmed_false, "pending": pending, "errors": errors, "avg_confidence": round(avg_conf, 2), @@ -514,6 +600,7 @@ def api_engrams( offset: int = Query(0, ge=0), tag: str = Query(None), confirmed: bool = Query(None), + verdict: str = Query(None), search: str = Query(None), min_confidence: float = Query(0.0), ): @@ -527,9 +614,30 @@ def api_engrams( params.append(f'%"{tag}"%') if confirmed is not None: - where_clauses.append( - f"json_extract(correctness_json, '$.confirmed') = {int(confirmed)}" - ) + if confirmed: + # confirmed == statement is true (verdict confirmed_true) + where_clauses.append( + "(" + "json_extract(correctness_json, '$.verdict') = 'confirmed_true' " + "OR (json_extract(correctness_json, '$.verdict') IS NULL AND json_extract(correctness_json, '$.confirmed') = 1)" + ")" + ) + else: + # pending/unresolved (unknown/probable) but exclude confirmed_false. + where_clauses.append( + "(" + "json_extract(correctness_json, '$.verdict') IN ('unknown','probable_true','probable_false') " + "OR (json_extract(correctness_json, '$.verdict') IS NULL " + " AND json_extract(correctness_json, '$.confirmed') = 0 " + " AND COALESCE(json_extract(correctness_json, '$.rejections'), 0) = 0)" + ")" + ) + + if verdict: + v = verdict.strip() + if v in ("unknown", "probable_true", "probable_false", "confirmed_true", "confirmed_false"): + where_clauses.append("json_extract(correctness_json, '$.verdict') = ?") + params.append(v) if search: # Use FTS @@ -740,6 +848,8 @@ def api_create_engram(content: str = Form(...), tags: str = Form(""), source: st "hash": "", } correctness = { + "verdict": "unknown", + "evidence": [], "confirmed": False, "confirmations": 0, "rejections": 0, @@ -767,7 +877,12 @@ def api_pending(limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge= rows = c.execute( """ SELECT * FROM engrams - WHERE json_extract(correctness_json, '$.confirmed') = 0 + WHERE ( + json_extract(correctness_json, '$.verdict') IN ('unknown','probable_true','probable_false') + OR (json_extract(correctness_json, '$.verdict') IS NULL + AND json_extract(correctness_json, '$.confirmed') = 0 + AND COALESCE(json_extract(correctness_json, '$.rejections'), 0) = 0) + ) ORDER BY created_at DESC LIMIT ? OFFSET ? """, diff --git a/src/engram.py b/src/engram.py index 8eabbb8..8bd59e6 100644 --- a/src/engram.py +++ b/src/engram.py @@ -40,26 +40,60 @@ class ReviewEntry: @dataclass class Correctness: """Verfolgt die Korrektheit eines Engramms über Zeit.""" + # verdict model (not only binary confirm/reject) + # Values: + # - unknown + # - probable_true / probable_false + # - confirmed_true / confirmed_false + verdict: str = "unknown" + evidence: List[Dict[str, Any]] = field(default_factory=list) confirmed: bool = False confirmations: int = 0 rejections: int = 0 last_reviewed: Optional[str] = None review_history: List[ReviewEntry] = field(default_factory=list) + def is_final(self) -> bool: + return self.verdict in ("confirmed_true", "confirmed_false") + + def set_verdict(self, by: str, verdict: str, note: str = "", evidence: Optional[List[Dict[str, Any]]] = None) -> None: + verdict = (verdict or "").strip() + if verdict not in ("unknown", "probable_true", "probable_false", "confirmed_true", "confirmed_false"): + verdict = "unknown" + self.verdict = verdict + # Keep backward-compatible boolean in sync: + # historically, confirmed=True meant "this statement is correct". + self.confirmed = verdict == "confirmed_true" + self.last_reviewed = _now() + if evidence: + try: + self.evidence.extend([e for e in evidence if isinstance(e, dict)]) + except Exception: + pass + self.review_history.append(ReviewEntry(by, "set_verdict", self.last_reviewed, f"{verdict}: {note}".strip())) + def confirm(self, by: str, note: str = "") -> None: self.confirmations += 1 - self.confirmed = True - self.last_reviewed = _now() + self.set_verdict(by, "confirmed_true", note) + # Preserve historic action tag too self.review_history.append(ReviewEntry(by, "confirm", self.last_reviewed, note)) def reject(self, by: str, note: str = "") -> None: self.rejections += 1 - self.confirmed = False - self.last_reviewed = _now() + self.set_verdict(by, "confirmed_false", note) self.review_history.append(ReviewEntry(by, "reject", self.last_reviewed, note)) def score(self) -> float: """Confidence-Score aus Korrekturhistorie.""" + # verdict-first scoring (explicit, non-binary) + if self.verdict == "confirmed_true": + return 1.0 + if self.verdict == "confirmed_false": + return 0.0 + if self.verdict == "probable_true": + return 0.75 + if self.verdict == "probable_false": + return 0.25 total = self.confirmations + self.rejections if total == 0: return 0.5 # Unbestimmt @@ -74,6 +108,8 @@ class Correctness: else: review_history.append(entry.to_dict()) return { + "verdict": self.verdict, + "evidence": self.evidence, "confirmed": self.confirmed, "confirmations": self.confirmations, "rejections": self.rejections, @@ -84,11 +120,30 @@ class Correctness: @classmethod def from_dict(cls, d: dict) -> "Correctness": c = cls() + verdict = d.get("verdict") + if isinstance(verdict, str) and verdict.strip(): + c.verdict = verdict.strip() c.confirmed = d.get("confirmed", False) c.confirmations = d.get("confirmations", 0) c.rejections = d.get("rejections", 0) c.last_reviewed = d.get("last_reviewed") + ev = d.get("evidence", []) + if isinstance(ev, list): + c.evidence = [e for e in ev if isinstance(e, dict)] c.review_history = [ReviewEntry.from_dict(r) for r in d.get("review_history", [])] + # Backfill verdict if missing/invalid. + if c.verdict not in ("unknown", "probable_true", "probable_false", "confirmed_true", "confirmed_false"): + if c.confirmed: + c.verdict = "confirmed_true" + elif c.rejections > 0: + c.verdict = "confirmed_false" + else: + c.verdict = "unknown" + # Ensure boolean stays consistent for older mixed data. + if c.verdict == "confirmed_true": + c.confirmed = True + elif c.verdict == "confirmed_false": + c.confirmed = False return c diff --git a/static/style.css b/static/style.css index 6a3e8f3..f2efe55 100644 --- a/static/style.css +++ b/static/style.css @@ -124,6 +124,24 @@ body { color: #8a9aff; font-size: 0.72rem; } + +.verdict-pill{ + display:inline-block; + margin: 2px 6px 2px 0; + padding: 2px 8px; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.4px; + border: 1px solid #2a2a3a; + background: #1e1e28; + color: #cfd3ff; +} +.verdict-pill.v-true{ border-color:#2f6b3f; color:#aaf0b6; } +.verdict-pill.v-false{ border-color:#7a2c2c; color:#ffb3b3; } +.verdict-pill.v-prob-true{ border-color:#6c8af5; color:#cfd9ff; } +.verdict-pill.v-prob-false{ border-color:#b08a2a; color:#ffe2a3; } +.verdict-pill.v-unknown{ border-color:#3a3a55; color:#b9b9c9; } .muted { color: #888899; font-size: 0.8rem; diff --git a/templates/dashboard.html b/templates/dashboard.html index 8b1f378..4c3bd0e 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -29,6 +29,7 @@ + @@ -130,6 +131,7 @@ async function loadCards() { if (state.search) url += `&search=${encodeURIComponent(state.search)}`; if (state.filter === 'confirmed') url += '&confirmed=1'; if (state.filter === 'pending') url += '&confirmed=0'; + if (state.filter === 'rejected') url += '&verdict=confirmed_false'; if (state.filter === 'errors') url += '&tag=error'; const data = await api(url); @@ -347,6 +349,7 @@ function renderCards() {
${Math.round(item.confidence*100)}% + ${renderVerdictPill(item)} ${item.tags.map(t => ''+t+'').join('')} ${fmtDate(item.created)}
@@ -365,6 +368,19 @@ function renderCards() { `).join(''); } +function renderVerdictPill(item) { + const v = (item.verdict || '').toString(); + if (!v) return ''; + let cls = 'v-unknown'; + let label = v; + if (v === 'confirmed_true') { cls = 'v-true'; label = 'TRUE'; } + else if (v === 'confirmed_false') { cls = 'v-false'; label = 'FALSE'; } + else if (v === 'probable_true') { cls = 'v-prob-true'; label = 'LIKELY'; } + else if (v === 'probable_false') { cls = 'v-prob-false'; label = 'UNLIKELY'; } + else if (v === 'unknown') { cls = 'v-unknown'; label = 'UNKNOWN'; } + return `${label}`; +} + function fmtDate(iso) { const d = new Date(iso); return `${d.getDate().toString().padStart(2,'0')}.${(d.getMonth()+1).toString().padStart(2,'0')} ${d.getHours().toString().padStart(2,'0')}:${d.getMinutes().toString().padStart(2,'0')}`;