4 Commits

6 changed files with 820 additions and 112 deletions

View File

@@ -78,7 +78,7 @@ def main() -> int:
pending = [ pending = [
eg eg
for eg in all_egs for eg in all_egs
if (not eg.correctness.confirmed and eg.correctness.rejections == 0) if (not eg.correctness.is_final())
] ]
confirmed = 0 confirmed = 0
@@ -94,16 +94,7 @@ def main() -> int:
if src == "session" and ( if src == "session" and (
content.startswith("Session Summary (sess_") or content.startswith("Please remember ") content.startswith("Session Summary (sess_") or content.startswith("Please remember ")
): ):
eg.correctness.rejections += 1 eg.correctness.reject("verify-pending", "Auto-reject: session placeholder")
eg.correctness.last_reviewed = _now()
eg.correctness.review_history.append(
ReviewEntry(
by="verify-pending",
action="reject",
at=_now(),
note="Auto-reject: session placeholder",
)
)
store.save(eg) store.save(eg)
rejected += 1 rejected += 1
continue continue
@@ -118,30 +109,11 @@ def main() -> int:
still_pending += 1 still_pending += 1
continue continue
if 200 <= status < 300: if 200 <= status < 300:
eg.correctness.confirmed = True eg.correctness.confirm("verify-pending", f"Auto-confirm: web url ok ({status}) {url}")
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}",
)
)
store.save(eg) store.save(eg)
confirmed += 1 confirmed += 1
else: else:
eg.correctness.rejections += 1 eg.correctness.reject("verify-pending", f"Auto-reject: web url status={status} {url}")
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}",
)
)
store.save(eg) store.save(eg)
rejected += 1 rejected += 1
continue continue

View File

@@ -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` 1314px (secondary)
- `text-md` 1516px (Body)
- `text-lg` 1820px (Titles)
- **Ziffern:** optional `font-variant-numeric: tabular-nums;` für Stats.
### Spacing/Rhythm
- 4px Grid; Standard-Gaps: 8/12/16.
- Container-Padding: 12px (mobile), 1620px (>=768px).
- Border-radius: 1216px (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) 510 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. 560720px) → 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
@@
- <div class="graph" id="graph" style="display:none;">
- <canvas id="graphCanvas" width="440" height="520"></canvas>
- <div class="muted small" id="graphHint">Lade Graph…</div>
- </div>
+ <div class="graph" id="graph" style="display:none;">
+ <div class="graph-controls">
+ <button class="btn primary" id="btnGraphPhysics" onclick="toggleGraphPhysics()">Physics</button>
+ <button class="btn" onclick="resetGraphView()">Reset</button>
+ <button class="btn" onclick="fitGraphView()">Fit</button>
+ <button class="btn" onclick="reloadGraph()">Reload</button>
+ </div>
+ <canvas id="graphCanvas" width="440" height="520"></canvas>
+ <div class="graph-hint muted small" id="graphHint">Lade Graph…</div>
+ <div class="graph-legend">
+ <div><strong>Graph</strong>: Zoom (Wheel/Pinch), Pan (Drag). Klick auf Engram öffnet Details, Klick auf Tag setzt Suche.</div>
+ <div class="legend-row"><span class="legend-dot engram"></span> Engram</div>
+ <div class="legend-row"><span class="legend-dot tag"></span> Tag</div>
+ <div class="legend-row"><span class="legend-dot match"></span> Match</div>
+ </div>
+ </div>
```

View File

@@ -54,11 +54,22 @@ def get_db():
def parse_engram(row: sqlite3.Row) -> dict: def parse_engram(row: sqlite3.Row) -> dict:
meta = json.loads(row["metadata_json"] or "{}") meta = json.loads(row["metadata_json"] or "{}")
correctness = json.loads(row["correctness_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 { return {
"id": row["id"], "id": row["id"],
"content": row["content"], "content": row["content"],
"confidence": meta.get("confidence", 0.0), "confidence": meta.get("confidence", 0.0),
"confirmed": correctness.get("confirmed", False), "confirmed": correctness.get("confirmed", False),
"verdict": verdict,
"evidence": correctness.get("evidence", []),
"confirmations": correctness.get("confirmations", 0), "confirmations": correctness.get("confirmations", 0),
"rejections": correctness.get("rejections", 0), "rejections": correctness.get("rejections", 0),
"tags": meta.get("tags", []), "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}") raise FileNotFoundError(f"Engram not found: {engram_id}")
corr = json.loads(row["correctness_json"] or "{}") corr = json.loads(row["correctness_json"] or "{}")
corr.setdefault("verdict", None)
corr.setdefault("evidence", [])
corr.setdefault("confirmed", False) corr.setdefault("confirmed", False)
corr.setdefault("confirmations", 0) corr.setdefault("confirmations", 0)
corr.setdefault("rejections", 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] corr["review_history"] = [entry]
if action == "confirm": if action == "confirm":
corr["verdict"] = "confirmed_true"
corr["confirmed"] = True corr["confirmed"] = True
corr["confirmations"] = int(corr.get("confirmations", 0) or 0) + 1 corr["confirmations"] = int(corr.get("confirmations", 0) or 0) + 1
elif action == "reject": elif action == "reject":
corr["verdict"] = "confirmed_false"
corr["rejections"] = int(corr.get("rejections", 0) or 0) + 1 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( c.execute(
"UPDATE engrams SET correctness_json = ?, modified_at = ? WHERE id = ?", "UPDATE engrams SET correctness_json = ?, modified_at = ? WHERE id = ?",
@@ -232,8 +265,25 @@ def api_storage_stats():
conn = get_db() conn = get_db()
c = conn.cursor() c = conn.cursor()
total = c.execute("SELECT COUNT(*) FROM engrams").fetchone()[0] total = c.execute("SELECT COUNT(*) FROM engrams").fetchone()[0]
confirmed = c.execute( confirmed_true = c.execute(
"SELECT COUNT(*) FROM engrams WHERE json_extract(correctness_json, '$.confirmed') = 1" """
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] ).fetchone()[0]
sources = { sources = {
r[0]: r[1] r[0]: r[1]
@@ -268,8 +318,9 @@ def api_storage_stats():
return { return {
"sql": { "sql": {
"total_engrams": total, "total_engrams": total,
"confirmed": confirmed, "confirmed": confirmed_true,
"pending": total - confirmed, "rejected": confirmed_false,
"pending": total - confirmed_true - confirmed_false,
"by_source": sources, "by_source": sources,
}, },
"vector": { "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" "SELECT id, metadata_json, correctness_json, created_at, modified_at FROM engrams ORDER BY created_at DESC LIMIT 2000"
).fetchall() ).fetchall()
total = c.execute("SELECT COUNT(*) FROM engrams").fetchone()[0] total = c.execute("SELECT COUNT(*) FROM engrams").fetchone()[0]
confirmed = c.execute( confirmed_true = c.execute(
"SELECT COUNT(*) FROM engrams WHERE json_extract(correctness_json, '$.confirmed') = 1" """
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] ).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] = {} tag_counts: dict[str, int] = {}
source_counts: dict[str, int] = {} source_counts: dict[str, int] = {}
@@ -488,10 +556,27 @@ def api_stats():
conn = get_db() conn = get_db()
c = conn.cursor() c = conn.cursor()
total = c.execute("SELECT COUNT(*) FROM engrams").fetchone()[0] total = c.execute("SELECT COUNT(*) FROM engrams").fetchone()[0]
confirmed = c.execute( confirmed_true = c.execute(
"SELECT COUNT(*) FROM engrams WHERE json_extract(correctness_json, '$.confirmed') = 1" """
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] ).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( errors = c.execute(
"SELECT COUNT(*) FROM engrams WHERE json_extract(metadata_json, '$.tags') LIKE '%error%'" "SELECT COUNT(*) FROM engrams WHERE json_extract(metadata_json, '$.tags') LIKE '%error%'"
).fetchone()[0] ).fetchone()[0]
@@ -501,7 +586,8 @@ def api_stats():
conn.close() conn.close()
return { return {
"total": total, "total": total,
"confirmed": confirmed, "confirmed": confirmed_true,
"rejected": confirmed_false,
"pending": pending, "pending": pending,
"errors": errors, "errors": errors,
"avg_confidence": round(avg_conf, 2), "avg_confidence": round(avg_conf, 2),
@@ -514,6 +600,7 @@ def api_engrams(
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
tag: str = Query(None), tag: str = Query(None),
confirmed: bool = Query(None), confirmed: bool = Query(None),
verdict: str = Query(None),
search: str = Query(None), search: str = Query(None),
min_confidence: float = Query(0.0), min_confidence: float = Query(0.0),
): ):
@@ -527,9 +614,30 @@ def api_engrams(
params.append(f'%"{tag}"%') params.append(f'%"{tag}"%')
if confirmed is not None: if confirmed is not None:
if confirmed:
# confirmed == statement is true (verdict confirmed_true)
where_clauses.append( where_clauses.append(
f"json_extract(correctness_json, '$.confirmed') = {int(confirmed)}" "("
"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: if search:
# Use FTS # Use FTS
@@ -740,6 +848,8 @@ def api_create_engram(content: str = Form(...), tags: str = Form(""), source: st
"hash": "", "hash": "",
} }
correctness = { correctness = {
"verdict": "unknown",
"evidence": [],
"confirmed": False, "confirmed": False,
"confirmations": 0, "confirmations": 0,
"rejections": 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( rows = c.execute(
""" """
SELECT * FROM engrams 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 ORDER BY created_at DESC
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
""", """,

View File

@@ -40,26 +40,60 @@ class ReviewEntry:
@dataclass @dataclass
class Correctness: class Correctness:
"""Verfolgt die Korrektheit eines Engramms über Zeit.""" """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 confirmed: bool = False
confirmations: int = 0 confirmations: int = 0
rejections: int = 0 rejections: int = 0
last_reviewed: Optional[str] = None last_reviewed: Optional[str] = None
review_history: List[ReviewEntry] = field(default_factory=list) 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: def confirm(self, by: str, note: str = "") -> None:
self.confirmations += 1 self.confirmations += 1
self.confirmed = True self.set_verdict(by, "confirmed_true", note)
self.last_reviewed = _now() # Preserve historic action tag too
self.review_history.append(ReviewEntry(by, "confirm", self.last_reviewed, note)) self.review_history.append(ReviewEntry(by, "confirm", self.last_reviewed, note))
def reject(self, by: str, note: str = "") -> None: def reject(self, by: str, note: str = "") -> None:
self.rejections += 1 self.rejections += 1
self.confirmed = False self.set_verdict(by, "confirmed_false", note)
self.last_reviewed = _now()
self.review_history.append(ReviewEntry(by, "reject", self.last_reviewed, note)) self.review_history.append(ReviewEntry(by, "reject", self.last_reviewed, note))
def score(self) -> float: def score(self) -> float:
"""Confidence-Score aus Korrekturhistorie.""" """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 total = self.confirmations + self.rejections
if total == 0: if total == 0:
return 0.5 # Unbestimmt return 0.5 # Unbestimmt
@@ -74,6 +108,8 @@ class Correctness:
else: else:
review_history.append(entry.to_dict()) review_history.append(entry.to_dict())
return { return {
"verdict": self.verdict,
"evidence": self.evidence,
"confirmed": self.confirmed, "confirmed": self.confirmed,
"confirmations": self.confirmations, "confirmations": self.confirmations,
"rejections": self.rejections, "rejections": self.rejections,
@@ -84,11 +120,30 @@ class Correctness:
@classmethod @classmethod
def from_dict(cls, d: dict) -> "Correctness": def from_dict(cls, d: dict) -> "Correctness":
c = cls() c = cls()
verdict = d.get("verdict")
if isinstance(verdict, str) and verdict.strip():
c.verdict = verdict.strip()
c.confirmed = d.get("confirmed", False) c.confirmed = d.get("confirmed", False)
c.confirmations = d.get("confirmations", 0) c.confirmations = d.get("confirmations", 0)
c.rejections = d.get("rejections", 0) c.rejections = d.get("rejections", 0)
c.last_reviewed = d.get("last_reviewed") 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", [])] 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 return c

View File

@@ -124,6 +124,24 @@ body {
color: #8a9aff; color: #8a9aff;
font-size: 0.72rem; 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 { .muted {
color: #888899; color: #888899;
font-size: 0.8rem; font-size: 0.8rem;
@@ -138,7 +156,45 @@ body {
background:#12121a; background:#12121a;
border:1px solid #252533; border:1px solid #252533;
border-radius: 14px; border-radius: 14px;
touch-action: none;
} }
.graph-controls{
display:flex;
gap:8px;
padding: 10px 12px 0;
align-items:center;
flex-wrap: wrap;
}
.graph-controls .btn{
background:#1e1e28;
border:1px solid #2a2a3a;
border-radius: 10px;
padding: 8px 10px;
color:#cfd3ff;
font-weight:700;
font-size:0.82rem;
}
.graph-controls .btn.primary{
border-color:#6c8af5;
box-shadow:0 0 0 1px rgba(108,138,245,0.18) inset;
}
.graph-legend{
margin: 8px 12px 0;
padding: 10px 12px;
background:#1a1a24;
border:1px solid #252533;
border-radius: 14px;
color:#b9b9c9;
font-size:0.8rem;
line-height:1.4;
}
.legend-row{ display:flex; align-items:center; gap:8px; margin-top:6px; }
.legend-dot{ width:10px; height:10px; border-radius:50%; display:inline-block; }
.legend-dot.engram{ background:#6c8af5; }
.legend-dot.tag{ background:#8a9aff; }
.legend-dot.match{ background:#f7d154; }
.graph-hint{ padding: 4px 12px 10px; }
#searchInput { #searchInput {
flex: 1; flex: 1;
background: #1e1e28; background: #1e1e28;

View File

@@ -29,6 +29,7 @@
<option value="all">Alle</option> <option value="all">Alle</option>
<option value="pending">Pending</option> <option value="pending">Pending</option>
<option value="confirmed">Confirmed</option> <option value="confirmed">Confirmed</option>
<option value="rejected">Rejected</option>
<option value="errors">Errors</option> <option value="errors">Errors</option>
</select> </select>
</div> </div>
@@ -45,8 +46,20 @@
<!-- Graph --> <!-- Graph -->
<div class="graph" id="graph" style="display:none;"> <div class="graph" id="graph" style="display:none;">
<div class="graph-controls">
<button class="btn primary" id="btnGraphPhysics" onclick="toggleGraphPhysics()">Physics: off</button>
<button class="btn" onclick="resetGraphView()">Reset view</button>
<button class="btn" onclick="fitGraphView()">Fit</button>
<button class="btn" onclick="reloadGraph()">Reload</button>
</div>
<canvas id="graphCanvas" width="440" height="520"></canvas> <canvas id="graphCanvas" width="440" height="520"></canvas>
<div class="muted small" id="graphHint">Lade Graph…</div> <div class="graph-hint muted small" id="graphHint">Lade Graph…</div>
<div class="graph-legend">
<div><strong>Graph</strong>: Zoom per Pinch (2 Finger), Pan per Drag (1 Finger). Tap auf Engram öffnet Details, Tap auf Tag setzt Suche.</div>
<div class="legend-row"><span class="legend-dot engram"></span> Engram</div>
<div class="legend-row"><span class="legend-dot tag"></span> Tag</div>
<div class="legend-row"><span class="legend-dot match"></span> Match (Suche)</div>
</div>
</div> </div>
<!-- Status --> <!-- Status -->
@@ -130,6 +143,7 @@ async function loadCards() {
if (state.search) url += `&search=${encodeURIComponent(state.search)}`; if (state.search) url += `&search=${encodeURIComponent(state.search)}`;
if (state.filter === 'confirmed') url += '&confirmed=1'; if (state.filter === 'confirmed') url += '&confirmed=1';
if (state.filter === 'pending') url += '&confirmed=0'; if (state.filter === 'pending') url += '&confirmed=0';
if (state.filter === 'rejected') url += '&verdict=confirmed_false';
if (state.filter === 'errors') url += '&tag=error'; if (state.filter === 'errors') url += '&tag=error';
const data = await api(url); const data = await api(url);
@@ -216,79 +230,282 @@ async function loadGraph() {
renderGraph(g.nodes || [], g.edges || []); renderGraph(g.nodes || [], g.edges || []);
} }
function renderGraph(nodes, edges) { function reloadGraph() { loadGraph(); }
const canvas = document.getElementById('graphCanvas');
const hint = document.getElementById('graphHint'); // ─── Graph Renderer (Canvas) ────────────────────────────────────────────────
const ctx = canvas.getContext('2d'); let graphState = {
nodes: [],
edges: [],
sim: [],
links: [],
nodeById: new Map(),
simById: new Map(),
degree: new Map(),
physicsOn: false,
draggingId: null,
panning: false,
lastX: 0,
lastY: 0,
panX: 0,
panY: 0,
zoom: 1,
raf: null,
search: '',
pointers: new Map(),
pinchStartDist: null,
pinchStartZoom: null,
pinchStartPan: null,
};
function _graphCanvas() { return document.getElementById('graphCanvas'); }
function _graphCtx() { return _graphCanvas().getContext('2d'); }
function _graphNodeRadius(n) {
const d = graphState.degree.get(n.id) || 0;
const base = n.kind === 'tag' ? 4 : 6;
return Math.max(3, Math.min(14, base + Math.sqrt(d)));
}
function _graphNodeFill(n) {
if (n.kind === 'tag') return '#8a9aff';
return '#6c8af5';
}
function _graphMatches(n, term) {
const t = (term || '').trim().toLowerCase();
if (!t) return false;
const id = (n.id || '').toLowerCase();
const label = (n.label || '').toLowerCase();
return id.includes(t) || label.includes(t);
}
function _graphWorldFromScreen(cx, cy) {
return {
x: (cx - graphState.panX) / graphState.zoom,
y: (cy - graphState.panY) / graphState.zoom,
};
}
function _graphHitTest(wx, wy) {
for (let i = graphState.sim.length - 1; i >= 0; i--) {
const n = graphState.sim[i];
const r = _graphNodeRadius(n) + 2;
const dx = wx - n.x;
const dy = wy - n.y;
if ((dx*dx + dy*dy) <= r*r) return n;
}
return null;
}
function _graphInitInteractions() {
const canvas = _graphCanvas();
if (canvas._graphBound) return;
canvas._graphBound = true;
const toCanvasXY = (ev) => {
const rect = canvas.getBoundingClientRect();
return { cx: ev.clientX - rect.left, cy: ev.clientY - rect.top };
};
const pinchMidpoint = () => {
const pts = Array.from(graphState.pointers.values());
if (pts.length < 2) return null;
return { cx: (pts[0].cx + pts[1].cx) / 2, cy: (pts[0].cy + pts[1].cy) / 2 };
};
const pinchDistance = () => {
const pts = Array.from(graphState.pointers.values());
if (pts.length < 2) return null;
const dx = pts[0].cx - pts[1].cx;
const dy = pts[0].cy - pts[1].cy;
return Math.sqrt(dx*dx + dy*dy);
};
canvas.addEventListener('pointerdown', (ev) => {
canvas.setPointerCapture(ev.pointerId);
const pos = toCanvasXY(ev);
graphState.pointers.set(ev.pointerId, pos);
// When 2 pointers: start pinch
if (graphState.pointers.size === 2) {
graphState.panning = false;
graphState.draggingId = null;
graphState.pinchStartDist = pinchDistance();
graphState.pinchStartZoom = graphState.zoom;
graphState.pinchStartPan = { panX: graphState.panX, panY: graphState.panY };
_graphDraw();
return;
}
// Single pointer: pan or drag node
const { cx, cy } = pos;
const w = _graphWorldFromScreen(cx, cy);
const hit = _graphHitTest(w.x, w.y);
graphState.lastX = cx; graphState.lastY = cy;
if (hit) graphState.draggingId = hit.id;
else graphState.panning = true;
});
canvas.addEventListener('pointermove', (ev) => {
if (!graphState.pointers.has(ev.pointerId)) return;
const pos = toCanvasXY(ev);
graphState.pointers.set(ev.pointerId, pos);
// Pinch zoom (2 pointers)
if (graphState.pointers.size >= 2 && graphState.pinchStartDist) {
const dist = pinchDistance();
const mid = pinchMidpoint();
if (!dist || !mid) return;
const zoomOld = graphState.zoom;
const wx = (mid.cx - graphState.panX) / zoomOld;
const wy = (mid.cy - graphState.panY) / zoomOld;
const factor = dist / graphState.pinchStartDist;
const next = Math.max(0.35, Math.min(3.0, graphState.pinchStartZoom * factor));
graphState.zoom = next;
graphState.panX = mid.cx - wx * graphState.zoom;
graphState.panY = mid.cy - wy * graphState.zoom;
_graphDraw();
return;
}
// Single pointer
const { cx, cy } = pos;
const dx = cx - graphState.lastX;
const dy = cy - graphState.lastY;
graphState.lastX = cx; graphState.lastY = cy;
if (graphState.draggingId) {
const w = _graphWorldFromScreen(cx, cy);
const n = graphState.simById.get(graphState.draggingId);
if (n) { n.x = w.x; n.y = w.y; n.vx = 0; n.vy = 0; }
_graphDraw();
return;
}
if (graphState.panning) {
graphState.panX += dx;
graphState.panY += dy;
_graphDraw();
}
});
const endPointer = (ev) => {
if (graphState.pointers.has(ev.pointerId)) graphState.pointers.delete(ev.pointerId);
try { canvas.releasePointerCapture(ev.pointerId); } catch {}
if (graphState.pointers.size < 2) {
graphState.pinchStartDist = null;
graphState.pinchStartZoom = null;
graphState.pinchStartPan = null;
}
graphState.draggingId = null;
graphState.panning = false;
};
canvas.addEventListener('pointerup', endPointer);
canvas.addEventListener('pointercancel', endPointer);
}
function renderGraph(nodes, edges) {
const canvas = _graphCanvas();
const hint = document.getElementById('graphHint');
const ctx = _graphCtx();
// Fit canvas to container width (mobile)
const w = canvas.parentElement.clientWidth - 24; const w = canvas.parentElement.clientWidth - 24;
canvas.width = Math.max(320, Math.min(520, w)); canvas.width = Math.max(320, Math.min(520, w));
canvas.height = 520; canvas.height = 520;
if (!nodes.length || !edges.length) { graphState.nodes = nodes || [];
hint.textContent = 'Graph: keine Kanten (noch keine Links/Tags/Hosts im Sample).'; graphState.edges = edges || [];
graphState.nodeById = new Map(graphState.nodes.map(n => [n.id, n]));
graphState.degree = new Map();
for (const e of graphState.edges) {
graphState.degree.set(e.from, (graphState.degree.get(e.from) || 0) + 1);
graphState.degree.set(e.to, (graphState.degree.get(e.to) || 0) + 1);
}
if (!graphState.nodes.length || !graphState.edges.length) {
hint.textContent = 'Graph: keine Kanten (noch keine Links/Tags im Sample).';
ctx.clearRect(0,0,canvas.width,canvas.height); ctx.clearRect(0,0,canvas.width,canvas.height);
return; return;
} }
hint.textContent = `nodes=${nodes.length} edges=${edges.length}`; hint.textContent = `nodes=${graphState.nodes.length} edges=${graphState.edges.length}`;
graphState.sim = graphState.nodes.map(n => ({
const nodeById = new Map(nodes.map(n => [n.id, n]));
const sim = nodes.map(n => ({
id: n.id, id: n.id,
kind: n.kind, kind: n.kind,
label: n.label || n.id, label: n.label || n.id,
x: Math.random()*canvas.width, x: (Math.random() - 0.5) * canvas.width,
y: Math.random()*canvas.height, y: (Math.random() - 0.5) * canvas.height,
vx: 0, vy: 0, vx: 0, vy: 0,
})); }));
const simById = new Map(sim.map(n => [n.id, n])); graphState.simById = new Map(graphState.sim.map(n => [n.id, n]));
graphState.links = graphState.edges
const links = edges .map(e => ({a: graphState.simById.get(e.from), b: graphState.simById.get(e.to), kind: e.kind}))
.map(e => ({a: simById.get(e.from), b: simById.get(e.to), kind: e.kind}))
.filter(l => l.a && l.b); .filter(l => l.a && l.b);
// Simple force layout (few iterations) graphState.panX = canvas.width / 2;
for (let iter=0; iter<180; iter++) { graphState.panY = canvas.height / 2;
// repulsion graphState.zoom = 1;
for (let i=0; i<sim.length; i++) { graphState.search = state.search || '';
for (let j=i+1; j<sim.length; j++) {
_graphInitInteractions();
for (let i = 0; i < 120; i++) _graphStepPhysics(0.9);
_graphDraw();
}
function _graphStepPhysics(alpha = 1.0) {
const canvas = _graphCanvas();
const repulsion = 180;
const damping = 0.86;
const target = 80;
const springK = 0.018;
const sim = graphState.sim;
for (let i = 0; i < sim.length; i++) {
for (let j = i + 1; j < sim.length; j++) {
const a = sim[i], b = sim[j]; const a = sim[i], b = sim[j];
const dx = a.x - b.x, dy = a.y - b.y; const dx = a.x - b.x, dy = a.y - b.y;
const d2 = dx*dx + dy*dy + 0.01; const d2 = dx*dx + dy*dy + 0.02;
const f = 120 / d2; const f = (repulsion / d2) * alpha;
a.vx += dx*f; a.vy += dy*f; a.vx += dx*f; a.vy += dy*f;
b.vx -= dx*f; b.vy -= dy*f; b.vx -= dx*f; b.vy -= dy*f;
} }
} }
// springs for (const l of graphState.links) {
for (const l of links) {
const a = l.a, b = l.b; const a = l.a, b = l.b;
const dx = b.x - a.x, dy = b.y - a.y; const dx = b.x - a.x, dy = b.y - a.y;
const dist = Math.sqrt(dx*dx + dy*dy) || 1; const dist = Math.sqrt(dx*dx + dy*dy) || 1;
const target = 60; const f = (dist - target) * springK * alpha;
const k = 0.02; const fx = (dx/dist) * f, fy = (dy/dist) * f;
const f = (dist - target) * k;
const fx = (dx/dist)*f, fy = (dy/dist)*f;
a.vx += fx; a.vy += fy; a.vx += fx; a.vy += fy;
b.vx -= fx; b.vy -= fy; b.vx -= fx; b.vy -= fy;
} }
// integrate + bounds
for (const n of sim) { for (const n of sim) {
n.vx *= 0.85; n.vy *= 0.85; n.vx *= damping; n.vy *= damping;
n.x += n.vx; n.y += n.vy; n.x += n.vx; n.y += n.vy;
n.x = Math.max(10, Math.min(canvas.width-10, n.x)); const pad = 10;
n.y = Math.max(10, Math.min(canvas.height-10, n.y)); const bx = canvas.width / graphState.zoom;
} const by = canvas.height / graphState.zoom;
n.x = Math.max(-bx/2 + pad, Math.min(bx/2 - pad, n.x));
n.y = Math.max(-by/2 + pad, Math.min(by/2 - pad, n.y));
} }
}
function _graphDraw() {
const canvas = _graphCanvas();
const ctx = _graphCtx();
const hint = document.getElementById('graphHint');
ctx.clearRect(0,0,canvas.width,canvas.height); ctx.clearRect(0,0,canvas.width,canvas.height);
// edges ctx.save();
ctx.globalAlpha = 0.5; ctx.translate(graphState.panX, graphState.panY);
ctx.scale(graphState.zoom, graphState.zoom);
ctx.globalAlpha = 0.45;
ctx.strokeStyle = '#3a3a55'; ctx.strokeStyle = '#3a3a55';
ctx.lineWidth = 1; ctx.lineWidth = 1 / graphState.zoom;
for (const l of links) { for (const l of graphState.links) {
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(l.a.x, l.a.y); ctx.moveTo(l.a.x, l.a.y);
ctx.lineTo(l.b.x, l.b.y); ctx.lineTo(l.b.x, l.b.y);
@@ -296,19 +513,82 @@ function renderGraph(nodes, edges) {
} }
ctx.globalAlpha = 1.0; ctx.globalAlpha = 1.0;
// nodes const term = (graphState.search || '').trim();
for (const n of sim) { let matches = 0;
let r = 5; for (const n of graphState.sim) {
let fill = '#6c8af5'; const r = _graphNodeRadius(n);
if (n.kind === 'tag') { fill = '#8a9aff'; r = 4; } const isMatch = _graphMatches(n, term);
if (n.kind === 'host') { fill = '#f5b46c'; r = 4; } if (isMatch) matches++;
if (n.kind === 'engram') { fill = '#6c8af5'; r = 5; }
if (isMatch) {
ctx.beginPath();
ctx.strokeStyle = '#f7d154';
ctx.lineWidth = 3 / graphState.zoom;
ctx.arc(n.x, n.y, r + 3, 0, Math.PI*2);
ctx.stroke();
}
ctx.beginPath(); ctx.beginPath();
ctx.fillStyle = fill; ctx.fillStyle = _graphNodeFill(n);
ctx.arc(n.x, n.y, r, 0, Math.PI*2); ctx.arc(n.x, n.y, r, 0, Math.PI*2);
ctx.fill(); ctx.fill();
} }
ctx.restore();
hint.textContent = `nodes=${graphState.nodes.length} edges=${graphState.edges.length}` + (term ? ` | match=${matches}` : '');
}
function _graphLoop() {
if (!graphState.physicsOn) return;
_graphStepPhysics(1.0);
_graphDraw();
graphState.raf = requestAnimationFrame(_graphLoop);
}
function toggleGraphPhysics() {
graphState.physicsOn = !graphState.physicsOn;
const b = document.getElementById('btnGraphPhysics');
b.textContent = `Physics: ${graphState.physicsOn ? 'on' : 'off'}`;
b.classList.toggle('primary', graphState.physicsOn);
if (graphState.physicsOn) {
if (graphState.raf) cancelAnimationFrame(graphState.raf);
graphState.raf = requestAnimationFrame(_graphLoop);
} else {
if (graphState.raf) cancelAnimationFrame(graphState.raf);
graphState.raf = null;
_graphDraw();
}
}
function resetGraphView() {
const canvas = _graphCanvas();
graphState.panX = canvas.width / 2;
graphState.panY = canvas.height / 2;
graphState.zoom = 1;
_graphDraw();
}
function fitGraphView() {
const canvas = _graphCanvas();
if (!graphState.sim.length) return;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const n of graphState.sim) {
minX = Math.min(minX, n.x); minY = Math.min(minY, n.y);
maxX = Math.max(maxX, n.x); maxY = Math.max(maxY, n.y);
}
const w = Math.max(1, maxX - minX);
const h = Math.max(1, maxY - minY);
const zx = (canvas.width - 40) / w;
const zy = (canvas.height - 40) / h;
graphState.zoom = Math.max(0.35, Math.min(2.5, Math.min(zx, zy)));
graphState.panX = canvas.width / 2;
graphState.panY = canvas.height / 2;
_graphDraw();
}
function graphApplySearch(term) {
graphState.search = term || '';
_graphDraw();
} }
// Real-time updates via SSE // Real-time updates via SSE
@@ -347,6 +627,7 @@ function renderCards() {
<div class="card ${item.confirmed ? 'confirmed' : ''} ${item.rejections > 0 ? 'rejected' : ''}" data-id="${item.id}"> <div class="card ${item.confirmed ? 'confirmed' : ''} ${item.rejections > 0 ? 'rejected' : ''}" data-id="${item.id}">
<div class="card-header"> <div class="card-header">
<span class="conf-badge" style="background:hsl(${item.confidence*120},70%,40%)">${Math.round(item.confidence*100)}%</span> <span class="conf-badge" style="background:hsl(${item.confidence*120},70%,40%)">${Math.round(item.confidence*100)}%</span>
${renderVerdictPill(item)}
<span class="tags">${item.tags.map(t => '<span class="tag">'+t+'</span>').join('')}</span> <span class="tags">${item.tags.map(t => '<span class="tag">'+t+'</span>').join('')}</span>
<span class="date">${fmtDate(item.created)}</span> <span class="date">${fmtDate(item.created)}</span>
</div> </div>
@@ -365,6 +646,19 @@ function renderCards() {
`).join(''); `).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 `<span class="verdict-pill ${cls}">${label}</span>`;
}
function fmtDate(iso) { function fmtDate(iso) {
const d = new Date(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')}`; 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')}`;
@@ -462,6 +756,7 @@ document.getElementById('searchInput').addEventListener('input', (e) => {
state.search = e.target.value; state.search = e.target.value;
state.offset = 0; state.offset = 0;
loadCards(); loadCards();
if (state.view === 'graph') graphApplySearch(state.search);
}); });
document.getElementById('filterSelect').addEventListener('change', (e) => { document.getElementById('filterSelect').addEventListener('change', (e) => {