Compare commits
3 Commits
ui/dashboa
...
api/graph-
| Author | SHA1 | Date | |
|---|---|---|---|
| fa2ba11b66 | |||
| 7dfd9c4228 | |||
| 6d99c520e6 |
@@ -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
|
||||||
|
|||||||
141
fastapi_app.py
141
fastapi_app.py
@@ -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 ?
|
||||||
""",
|
""",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
graphState.search = state.search || '';
|
||||||
|
|
||||||
|
_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 i = 0; i < sim.length; i++) {
|
||||||
for (let j = i + 1; j < sim.length; j++) {
|
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 f = (dist - target) * k;
|
|
||||||
const fx = (dx/dist) * f, fy = (dy/dist) * f;
|
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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user