139 lines
5.6 KiB
Python
139 lines
5.6 KiB
Python
from __future__ import annotations
|
|||
|
|
|
||
|
|
import itertools
|
||
|
|
import json
|
||
|
|
import math
|
||
|
|
import re
|
||
|
|
import threading
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
from .council import HUMAN, agent
|
||
|
|
from .events import event
|
||
|
|
from .records import read_json, write_atomic
|
||
|
|
from .transcripts import STREAM_AGE
|
||
|
|
|
||
|
|
PSEUDO = "name:"
|
||
|
|
TITLE_SCAN = 10.0
|
||
|
|
SESSION_ID = re.compile(r"[A-Za-z0-9_-]{1,128}")
|
||
|
|
|
||
|
|
|
||
|
|
class Directory:
|
||
|
|
def __init__(self, entries: list[dict]) -> None:
|
||
|
|
self.by_session: dict[str, list[dict]] = {}
|
||
|
|
self.by_name: dict[str, list[str]] = {}
|
||
|
|
for entry in entries:
|
||
|
|
self.by_session.setdefault(entry["session"], []).append(entry)
|
||
|
|
holders = self.by_name.setdefault(entry["name"], [])
|
||
|
|
if entry["session"] in holders:
|
||
|
|
holders.remove(entry["session"])
|
||
|
|
holders.append(entry["session"])
|
||
|
|
|
||
|
|
def at(self, session: str, ts: float) -> str:
|
||
|
|
own = self.by_session.get(session)
|
||
|
|
if not own:
|
||
|
|
return ""
|
||
|
|
valid = [entry for entry in own if entry["first_seen"] <= ts]
|
||
|
|
return (valid[-1] if valid else own[0])["name"]
|
||
|
|
|
||
|
|
def now(self, session: str) -> str:
|
||
|
|
own = self.by_session.get(session)
|
||
|
|
return own[-1]["name"] if own else ""
|
||
|
|
|
||
|
|
def find(self, name: str, ts: float) -> str:
|
||
|
|
holders = self.by_name.get(name, [])
|
||
|
|
real = [session for session in holders if not session.startswith(PSEUDO)]
|
||
|
|
valid = [session for session in real if self.at(session, ts) == name]
|
||
|
|
chosen = valid or real or holders
|
||
|
|
return chosen[-1] if chosen else ""
|
||
|
|
|
||
|
|
def key(self, name: str, ts: float) -> str:
|
||
|
|
if not name or name == HUMAN:
|
||
|
|
return name
|
||
|
|
return self.find(name, ts) or f"{PSEUDO}{name}"
|
||
|
|
|
||
|
|
def label(self, key: str, ts: float) -> str:
|
||
|
|
return self.at(key, ts) or key.removeprefix(PSEUDO)
|
||
|
|
|
||
|
|
def names(self, key: str) -> list[str]:
|
||
|
|
return list(dict.fromkeys(entry["name"] for entry in self.by_session.get(key, [])))
|
||
|
|
|
||
|
|
def timeline(self, key: str) -> list[dict]:
|
||
|
|
return [{"name": entry["name"], "since": entry["first_seen"]} for entry in self.by_session.get(key, [])]
|
||
|
|
|
||
|
|
def aliases(self, name: str) -> set[str]:
|
||
|
|
return set(self.by_name.get(name, []))
|
||
|
|
|
||
|
|
def renames(self, begin: float, end: float) -> list[dict]:
|
||
|
|
found = []
|
||
|
|
for session, own in self.by_session.items():
|
||
|
|
for before, after in itertools.pairwise(own):
|
||
|
|
if before["name"] != after["name"] and begin <= after["first_seen"] <= end:
|
||
|
|
found.append(event({
|
||
|
|
"id": f"rename:{session}:{after['first_seen']}", "ts": after["first_seen"], "kind": "rename", "agent": after["name"],
|
||
|
|
"now": self.now(session), "session": session, "title": f"{before['name']} heißt jetzt {after['name']}",
|
||
|
|
"text": f"Quelle: {after['source']}", "ref": {"before": before["name"], "after": after["name"], "source": after["source"]},
|
||
|
|
}))
|
||
|
|
return found
|
||
|
|
|
||
|
|
|
||
|
|
class Names:
|
||
|
|
def __init__(self, file: Path) -> None:
|
||
|
|
self.file = file
|
||
|
|
self.lock = threading.Lock()
|
||
|
|
self.titled = -math.inf
|
||
|
|
data = read_json(file)
|
||
|
|
data = data if isinstance(data, dict) else {}
|
||
|
|
entries, observed = data.get("entries"), data.get("observed")
|
||
|
|
keys = {"session", "name", "first_seen", "source"}
|
||
|
|
self.entries = [entry for entry in entries if isinstance(entry, dict) and set(entry) == keys] if isinstance(entries, list) else []
|
||
|
|
self.observed: dict[str, str] = observed if isinstance(observed, dict) else {}
|
||
|
|
|
||
|
|
def observe(self, sightings: list[tuple[str, str, float | None, str]], now: float) -> None:
|
||
|
|
with self.lock:
|
||
|
|
changed = False
|
||
|
|
for session, name, seen, source in sightings:
|
||
|
|
key = f"{session}\x00{source}"
|
||
|
|
if not session or not name or self.observed.get(key) == name:
|
||
|
|
continue
|
||
|
|
self.observed[key] = name
|
||
|
|
changed = True
|
||
|
|
history = [entry for entry in self.entries if entry["session"] == session]
|
||
|
|
if history and history[-1]["name"] == name:
|
||
|
|
continue
|
||
|
|
floor = history[-1]["first_seen"] if history else -math.inf
|
||
|
|
first = seen if seen is not None and seen > floor else max(now, floor)
|
||
|
|
self.entries.append({"session": session, "name": name, "first_seen": first, "source": source})
|
||
|
|
if changed:
|
||
|
|
write_atomic(self.file, json.dumps({"entries": self.entries, "observed": self.observed}, sort_keys=True).encode("utf-8"))
|
||
|
|
|
||
|
|
def listed(self) -> list[dict]:
|
||
|
|
with self.lock:
|
||
|
|
return [dict(entry) for entry in self.entries]
|
||
|
|
|
||
|
|
def due(self, now: float) -> bool:
|
||
|
|
with self.lock:
|
||
|
|
if now - self.titled < TITLE_SCAN:
|
||
|
|
return False
|
||
|
|
self.titled = now
|
||
|
|
return True
|
||
|
|
|
||
|
|
|
||
|
|
def title_sightings(root: Path, claimed: set[str], now: float) -> list[tuple[str, str, float | None, str]]:
|
||
|
|
try:
|
||
|
|
paths = sorted(root.glob("*/*/custom-title.json"))
|
||
|
|
except OSError:
|
||
|
|
return []
|
||
|
|
found = []
|
||
|
|
for path in paths:
|
||
|
|
session = path.parent.name
|
||
|
|
try:
|
||
|
|
changed = path.stat().st_mtime
|
||
|
|
except OSError:
|
||
|
|
continue
|
||
|
|
if not SESSION_ID.fullmatch(session) or (session not in claimed and changed < now - STREAM_AGE):
|
||
|
|
continue
|
||
|
|
data = read_json(path)
|
||
|
|
name = agent(data.get("customTitle") if isinstance(data, dict) else None)
|
||
|
|
if name:
|
||
|
|
found.append((session, name, changed, "title"))
|
||
|
|
return found
|