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