kimal3 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,393 @@
1
+ # Zeitleiste, Einzelbild und Sprachmodell hinter `kimal3 zeigen auswerten | bild` und `einrichten` (Ruling R-6: eigene
2
+ # Datei, werkzeug.py ruft nur auf). Laeuft mit dem Python des Aufnehmers, Arbeitsordner = Aufnahmeordner.
3
+ #
4
+ # Die Regel steckt in `zeitleiste_aus` (rein, ohne Modell geprueft von scripts/zeigen/test_zeitleiste.py); `auswerten`
5
+ # liest nur die Aufnahme, wandelt Ton in Woerter und schreibt die Datei. Spec 2026-10-18-k3zeigen-design.md, US-7/US-8.
6
+ #
7
+ # 🔴 Was hier zurueckkommt, sind Zahlen und feste Codes -- nie Gesagtes, Getipptes, Fenstertitel, keine Meldung einer
8
+ # fremden Bibliothek. Die Zeitleiste selbst liest Claude erst nach Sabines Ja.
9
+ import io
10
+ import json
11
+ import logging
12
+ import math
13
+ import os
14
+ import re
15
+ import sqlite3
16
+ import time
17
+ import warnings
18
+
19
+ # Vor jedem Import von huggingface_hub (auch dem in werkzeug._hf_cache, das nach `import zeitleiste` laeuft): sonst
20
+ # schreibt der Download Fortschrittsbalken nach stderr.
21
+ os.environ["HF_HUB_DISABLE_PROGRESS_BARS"] = "1"
22
+
23
+ MODELL = "large-v3-turbo"
24
+ SATZ_PAUSE_SEK = 0.8
25
+ TIPP_PAUSE_SEK = 2.0
26
+ BILD_BREITE = 1600 # Lesekosten bei 3440 x 2880 (Spec US-8)
27
+ DATEI = "zeitleiste.md"
28
+ ROH = "roh" # Zwilling von gemeinsam.ROH: die Aufnahme selbst liegt dort, Zeitleiste und Bilder eine Ebene hoeher
29
+
30
+ TEXTE = {
31
+ "de": {"titel": "# Zeitleiste", "aufgabe": "Aufgabe", "datum": "Datum", "dauer": "Dauer", "ohne_aufgabe": "(keine)",
32
+ "sagt": "sagt", "tippt": "tippt", "klickt": "klickt", "auf": "„", "zu": "\"",
33
+ "passwort": "[Passwortfeld, ausgeblendet]", "ausgelassen": "[getippt, ausgelassen]",
34
+ "unbekannt": "[unbekanntes Element]", "Strg": "Strg", "Umschalt": "Umschalt"},
35
+ "en": {"titel": "# Timeline", "aufgabe": "Task", "datum": "Date", "dauer": "Duration", "ohne_aufgabe": "(none)",
36
+ "sagt": "says", "tippt": "types", "klickt": "clicks", "auf": "“", "zu": "”",
37
+ "passwort": "[password field, hidden]", "ausgelassen": "[typed, left out]",
38
+ "unbekannt": "[unknown element]", "Strg": "Ctrl", "Umschalt": "Shift"},
39
+ }
40
+
41
+ # Tasten des Aufnehmers (pynput-Namen). AltGr fehlt in KOMBI absichtlich: AltGr+Q ist „@", getippt, keine Kombination.
42
+ UMSCHALTER = {"ctrl": "Strg", "ctrl_l": "Strg", "ctrl_r": "Strg", "alt": "Alt", "alt_l": "Alt", "alt_r": "Alt",
43
+ "cmd": "Win", "cmd_l": "Win", "cmd_r": "Win", "shift": "Umschalt", "shift_l": "Umschalt",
44
+ "shift_r": "Umschalt", "alt_gr": "AltGr"}
45
+ KOMBI = ("Strg", "Alt", "Win")
46
+ ZEICHEN = {"space": " ", "enter": "⏎", "tab": "⇥", "up": "↑", "down": "↓", "left": "←", "right": "→"}
47
+ # Passwortfelder: Windows meldet `IsPassword`, gespeichert wird das Merkmal aber nicht (StructuralElement kennt kein
48
+ # Feld dafuer, structural.py). Deshalb wie der Aufnehmer selbst (shadow/events.py native_field_is_secure) zusaetzlich
49
+ # an Rolle, Klasse und Automation-ID -- und am Namen eines Eingabefelds, dort auch auf Deutsch.
50
+ # `\bpass\d*\b`/`\bpw\d*\b` fuer Web-Kennungen wie user_pass, pass2, login-pw (nach _woerter_teilen: "user pass").
51
+ GEHEIM = re.compile(r"passw|kennw|passcode|\bpass\d*\b|\bpw\d*\b|\bpin\b|\bpwd\b|\botp\b|\btan\b|secret|geheim",
52
+ re.IGNORECASE)
53
+
54
+
55
+ def _uhr(sek: float) -> str:
56
+ sek = max(0, int(sek))
57
+ return f"{sek // 60}:{sek % 60:02d}"
58
+
59
+
60
+ def _eine_zeile(wert) -> str:
61
+ """Jedes Ereignis bleibt eine Zeile: Zeilenumbrueche und Tabulatoren in Namen und Gesagtem werden Leerzeichen."""
62
+ return " ".join(str(wert or "").split())
63
+
64
+
65
+ def _woerter_teilen(text: str) -> str:
66
+ return re.sub(r"(?<=[a-z0-9])(?=[A-Z])", " ", re.sub(r"[-_.#]+", " ", text))
67
+
68
+
69
+ def passwortfeld(beobachtung: dict | None) -> bool:
70
+ if not isinstance(beobachtung, dict):
71
+ return False
72
+ e = beobachtung.get("element") or {}
73
+ if e.get("is_password") is True or e.get("IsPassword") is True:
74
+ return True
75
+ rollen = " ".join(str(e.get(k) or "") for k in ("role", "control_type", "class_name")).casefold()
76
+ if "password" in rollen or "secure" in rollen:
77
+ return True
78
+ namen = [e.get("automation_id"), e.get("class_name")]
79
+ if str(e.get("control_type") or e.get("role") or "").casefold() == "edit":
80
+ namen.append(e.get("name"))
81
+ return any(isinstance(n, str) and GEHEIM.search(_woerter_teilen(n)) for n in namen)
82
+
83
+
84
+ def _programm(beobachtung: dict) -> str | None:
85
+ name = ((beobachtung.get("process") or {}).get("process_name") or "").strip() # nie window.title (Spec US-7)
86
+ return re.sub(r"\.exe$", "", name, flags=re.IGNORECASE).casefold() or None
87
+
88
+
89
+ def _klick_text(beobachtung: dict | None, t: dict) -> str:
90
+ if not isinstance(beobachtung, dict) or not beobachtung.get("element"):
91
+ return t["unbekannt"]
92
+ e = beobachtung["element"]
93
+ teile = [_eine_zeile(e.get("role") or e.get("control_type"))]
94
+ if _eine_zeile(e.get("name")):
95
+ teile.append(f"{t['auf']}{_eine_zeile(e['name'])}{t['zu']}")
96
+ programm = _programm(beobachtung)
97
+ if programm:
98
+ teile.append(f"({programm})")
99
+ return " ".join(x for x in teile if x) or t["unbekannt"]
100
+
101
+
102
+ def _saetze(woerter: list) -> list:
103
+ # Pause = Stille zwischen Ende des einen und Anfang des naechsten Worts. Nur Anfaenge zu vergleichen trennte
104
+ # gedehnte Woerter mitten im Satz (gemessen an wetter-3: „aktuellen" 1,6 s lang, 0 s Stille vor „Tag.").
105
+ saetze, satz, ende = [], [], None
106
+ for zeit, bis, wort in sorted(woerter):
107
+ wort = _eine_zeile(wort)
108
+ if not wort:
109
+ continue
110
+ if satz and zeit - ende > SATZ_PAUSE_SEK:
111
+ saetze.append(satz)
112
+ satz = []
113
+ satz.append((zeit, wort))
114
+ ende = bis
115
+ if wort[-1] in ".!?…":
116
+ saetze.append(satz)
117
+ satz = []
118
+ return saetze + ([satz] if satz else [])
119
+
120
+
121
+ def _kombi_name(e: dict) -> str:
122
+ zeichen = e.get("zeichen") or ""
123
+ if len(zeichen) == 1 and 1 <= ord(zeichen) <= 26: # Strg+Buchstabe kommt als Steuerzeichen: \x03 = Strg+C
124
+ return chr(64 + ord(zeichen))
125
+ if zeichen.isprintable() and zeichen:
126
+ return zeichen.upper()
127
+ return (e.get("taste") or "?").replace("_", " ").title()
128
+
129
+
130
+ def zeitleiste_aus(kopf: dict, woerter: list, ereignisse: list, sprache: str, ohne_getipptes: bool) -> tuple:
131
+ """Text der Zeitleiste und ihre Zahlen. kopf: aufgabe, datum (fertiger Text), dauer_sek. woerter: (Anfang, Ende, Wort)
132
+ in Sekunden ab Aufnahmestart. ereignisse: dicts mit t, art (click|press|release|…), beobachtung, bei Tasten
133
+ taste und zeichen."""
134
+ t = TEXTE[sprache]
135
+ zahlen = {"saetze": 0, "klicks": 0, "tippstellen": 0, "passwortfelder": 0}
136
+ zeilen = [] # (Sekunde, Verb, Text)
137
+
138
+ for satz in _saetze(woerter):
139
+ zeilen.append((satz[0][0], t["sagt"], f"{t['auf']}{' '.join(w for _, w in satz)}{t['zu']}"))
140
+ zahlen["saetze"] += 1
141
+
142
+ folge = None # laufende Tippfolge: t, text, geheim, zuletzt
143
+ gehalten = set()
144
+ klick_geheim = False
145
+
146
+ def folge_ende():
147
+ nonlocal folge
148
+ if folge and folge["text"]:
149
+ if folge["geheim"]:
150
+ text, zaehler = t["passwort"], "passwortfelder"
151
+ elif ohne_getipptes:
152
+ text, zaehler = t["ausgelassen"], "tippstellen"
153
+ else:
154
+ text, zaehler = f"{t['auf']}{folge['text']}{t['zu']}", "tippstellen"
155
+ zeilen.append((folge["t"], t["tippt"], text))
156
+ zahlen[zaehler] += 1
157
+ folge = None
158
+
159
+ for e in sorted(ereignisse, key=lambda e: e["t"]):
160
+ art, taste = e["art"], e.get("taste")
161
+ if art == "click":
162
+ folge_ende()
163
+ gehalten.clear()
164
+ klick_geheim = passwortfeld(e.get("beobachtung"))
165
+ zeilen.append((e["t"], t["klickt"], _klick_text(e.get("beobachtung"), t)))
166
+ zahlen["klicks"] += 1
167
+ continue
168
+ if art == "release":
169
+ gehalten.discard(UMSCHALTER.get(taste))
170
+ continue
171
+ if art != "press":
172
+ continue # Mausbewegung, Scrollen
173
+ if taste in UMSCHALTER:
174
+ gehalten.add(UMSCHALTER[taste])
175
+ continue
176
+ zeichen = e.get("zeichen") or ""
177
+ steuer = taste is None and len(zeichen) == 1 and 1 <= ord(zeichen) <= 26 # Tab/Enter haben einen Namen
178
+ # Windows meldet AltGr als ctrl_l + alt_r, also Strg+Alt: ein druckbares Zeichen dabei ist Text („@", „€").
179
+ altgr = bool(zeichen) and zeichen.isprintable() and ("AltGr" in gehalten or {"Strg", "Alt"} <= gehalten)
180
+ # Im Passwortfeld wird keine Kombination eine eigene Zeile: sie verriete Tasten und teilte die Ausblendung.
181
+ geheim = klick_geheim or (folge is not None and folge["geheim"]) or passwortfeld(e.get("beobachtung"))
182
+ kombi = [m for m in KOMBI if m in gehalten] or (["Strg"] if steuer else [])
183
+ if kombi and not altgr and not geheim:
184
+ folge_ende()
185
+ mods = kombi + (["Umschalt"] if "Umschalt" in gehalten else [])
186
+ zeilen.append((e["t"], t["tippt"], "[" + "+".join(t.get(m, m) for m in mods) + f"+{_kombi_name(e)}]"))
187
+ gehalten.clear() # eine Kombination verbraucht die Umschalter; die naechste Taste kommt wieder mit eigenen
188
+ continue
189
+ gehalten.clear()
190
+ if folge and e["t"] - folge["zuletzt"] > TIPP_PAUSE_SEK:
191
+ folge_ende()
192
+ if folge is None:
193
+ folge = {"t": e["t"], "text": "", "geheim": klick_geheim, "zuletzt": e["t"]}
194
+ folge["zuletzt"] = e["t"]
195
+ folge["geheim"] = folge["geheim"] or passwortfeld(e.get("beobachtung"))
196
+ if taste == "backspace":
197
+ folge["text"] = folge["text"][:-1]
198
+ elif zeichen and zeichen.isprintable():
199
+ folge["text"] += zeichen
200
+ else:
201
+ folge["text"] += ZEICHEN.get(taste) or f"[{(taste or '?').replace('_', ' ').title()}]"
202
+ folge_ende()
203
+
204
+ kopfzeilen = [t["titel"], "", f"{t['aufgabe']}: {kopf.get('aufgabe') or t['ohne_aufgabe']}",
205
+ f"{t['datum']}: {kopf['datum']}", f"{t['dauer']}: {_uhr(kopf['dauer_sek'])}", ""]
206
+ rumpf = [f"{_uhr(s)} {verb:<8}{text}" for s, verb, text in sorted(zeilen, key=lambda z: z[0])]
207
+ return "\n".join(kopfzeilen + rumpf) + "\n", zahlen
208
+
209
+
210
+ # --- Aufnahme lesen, Ton in Woerter ------------------------------------------------------------------------------
211
+
212
+ def _lies_aufnahme(ordner: str):
213
+ db = sqlite3.connect(f"file:{os.path.join(ordner, ROH, 'recording.db')}?mode=ro", uri=True)
214
+ try:
215
+ start, beschreibung = db.execute("select timestamp, task_description from recording").fetchone()
216
+ start = float(start)
217
+ ereignisse = []
218
+ for zeit, art, taste, zeichen, gedrueckt, roh in db.execute(
219
+ "select timestamp, name, key_name, key_char, mouse_pressed, structural_observation from action_event "
220
+ "where name in ('click', 'press', 'release') order by timestamp"):
221
+ if art == "click" and not gedrueckt:
222
+ continue # Loslassen der Maustaste: doppelt zum Klick davor
223
+ try:
224
+ beobachtung = json.loads(roh) if roh else None
225
+ except ValueError:
226
+ beobachtung = None
227
+ ereignisse.append({"t": float(zeit) - start, "art": art, "taste": taste, "zeichen": zeichen,
228
+ "beobachtung": beobachtung})
229
+ letzte = db.execute("select max(timestamp) from action_event").fetchone()[0]
230
+ ton = [(float(ts) - start, flac) for ts, flac in
231
+ db.execute("select timestamp, flac_data from audio_info where length(flac_data) > 0 order by timestamp")]
232
+ finally:
233
+ db.close()
234
+ return start, beschreibung, ereignisse, (float(letzte) - start if letzte is not None else 0.0), ton
235
+
236
+
237
+ def _still() -> None:
238
+ """Fremde Bibliotheken schreiben sonst nach stderr (loguru im Aufnehmer, Warnungen, logging) -- womoeglich mit
239
+ Fenstertiteln oder Erkanntem. Vor ihrem Import aufrufen."""
240
+ warnings.simplefilter("ignore")
241
+ logging.disable(logging.CRITICAL)
242
+ try:
243
+ from loguru import logger
244
+
245
+ logger.remove()
246
+ except ImportError:
247
+ pass
248
+
249
+
250
+ def _dekodiere(flac: bytes):
251
+ """16 kHz mono float32 direkt ueber PyAV (faster_whisper.decode_audio scheitert an PyAV 19, Befund B3)."""
252
+ import av
253
+ import numpy
254
+
255
+ teile = []
256
+ with av.open(io.BytesIO(flac)) as quelle:
257
+ wandler = av.AudioResampler(format="flt", layout="mono", rate=16000)
258
+ for rahmen in quelle.decode(audio=0):
259
+ teile += [r.to_ndarray().reshape(-1) for r in wandler.resample(rahmen)]
260
+ teile += [r.to_ndarray().reshape(-1) for r in wandler.resample(None)]
261
+ return numpy.concatenate(teile) if teile else numpy.zeros(0, dtype=numpy.float32)
262
+
263
+
264
+ def _erkenne(ton: list, sprache: str) -> tuple:
265
+ """Woerter (Anfang, Ende ab Aufnahmestart, Wort) und Ende des Tons. Ob das Modell da ist, prueft auswerten vorher."""
266
+ os.environ["HF_HUB_OFFLINE"] = "1" # das Modell hat `einrichten` geladen; hier geht nichts ins Netz
267
+ _still()
268
+ from faster_whisper import WhisperModel
269
+
270
+ modell = WhisperModel(MODELL, device="cpu", compute_type="int8")
271
+ woerter, ende = [], 0.0
272
+ for abstand, flac in ton:
273
+ samples = _dekodiere(flac)
274
+ ende = max(ende, abstand + len(samples) / 16000)
275
+ if not len(samples):
276
+ continue
277
+ segmente, _ = modell.transcribe(samples, language=sprache, word_timestamps=True, vad_filter=True)
278
+ woerter += [(abstand + w.start, abstand + w.end, w.word) for s in segmente for w in (s.words or [])]
279
+ return woerter, ende
280
+
281
+
282
+ def _aufgabe(ordner: str, beschreibung: str | None) -> str | None:
283
+ try:
284
+ with open(os.path.join(ordner, "aufgabe.txt"), encoding="utf-8-sig") as f:
285
+ return f.read().strip() or beschreibung
286
+ except OSError:
287
+ return beschreibung
288
+
289
+
290
+ def _weg(pfad: str) -> None:
291
+ try:
292
+ os.remove(pfad)
293
+ except FileNotFoundError:
294
+ pass
295
+
296
+
297
+ def auswerten(ordner: str, sprache: str, ohne_getipptes: bool, modell_da) -> dict:
298
+ """`modell_da()` = werkzeug.modell_da gegen den Zwischenspeicher: nur ein fehlendes Modell heisst modell-fehlt."""
299
+ daten = {"ergebnis": "fehler", "dauer_sek": 0.0, "saetze": 0, "klicks": 0, "tippstellen": 0, "passwortfelder": 0,
300
+ "grund": None}
301
+ if not os.path.isdir(ordner):
302
+ return dict(daten, grund="ordner-fehlt")
303
+ ziel = os.path.join(ordner, DATEI)
304
+ _weg(ziel) # eine alte Zeitleiste darf nie als Ergebnis dieses Laufs dastehen
305
+ _weg(ziel + ".tmp")
306
+ if not os.path.isfile(os.path.join(ordner, ROH, "recording.db")):
307
+ return dict(daten, grund="keine-aufnahme")
308
+ try:
309
+ start, beschreibung, ereignisse, letzte, ton = _lies_aufnahme(ordner)
310
+ except (sqlite3.Error, TypeError, ValueError):
311
+ return dict(daten, grund="aufnahme-unlesbar")
312
+ daten["dauer_sek"] = round(max(letzte, 0.0), 2)
313
+ if not ton:
314
+ return dict(daten, ergebnis="kein-ton")
315
+ if not modell_da():
316
+ return dict(daten, grund="modell-fehlt")
317
+ try:
318
+ woerter, ton_ende = _erkenne(ton, sprache)
319
+ except Exception:
320
+ return dict(daten, grund="erkennung") # kein Text der Ausnahme: sie koennte Erkanntes tragen
321
+ dauer = max(letzte, ton_ende)
322
+ kopf = {"aufgabe": _aufgabe(ordner, beschreibung), "datum": time.strftime("%Y-%m-%d %H:%M", time.localtime(start)),
323
+ "dauer_sek": dauer}
324
+ text, zahlen = zeitleiste_aus(kopf, woerter, ereignisse, sprache, ohne_getipptes)
325
+ daten.update(zahlen, dauer_sek=round(dauer, 2))
326
+ if zahlen["saetze"] == 0:
327
+ return dict(daten, ergebnis="keine-sprache")
328
+ try:
329
+ with open(ziel + ".tmp", "w", encoding="utf-8", newline="\n") as f:
330
+ f.write(text)
331
+ os.replace(ziel + ".tmp", ziel)
332
+ except OSError:
333
+ _weg(ziel + ".tmp")
334
+ return dict(daten, grund="schreiben-gescheitert")
335
+ return dict(daten, ergebnis="fertig")
336
+
337
+
338
+ # --- Einzelbild ----------------------------------------------------------------------------------------------------
339
+
340
+ def bild_name(sekunden: float) -> str:
341
+ return _uhr(sekunden).replace(":", "-") + ".png"
342
+
343
+
344
+ def _einzelbild(ordner: str, sekunden: float):
345
+ _still()
346
+ from openadapt_capture.capture import CaptureSession # erst nach chdir: der Aufnehmer liest beim Import eine .env
347
+
348
+ sitzung = CaptureSession.load(os.path.join(ordner, ROH))
349
+ if sitzung.video_path is None:
350
+ return None
351
+ return sitzung.get_frame_at(sitzung._recording.timestamp + sekunden, tolerance=1.0)
352
+
353
+
354
+ def bild(ordner: str, sekunden: float) -> dict:
355
+ if not os.path.isdir(ordner):
356
+ return {"ergebnis": "fehler", "datei": None, "grund": "ordner-fehlt"}
357
+ if not math.isfinite(sekunden) or sekunden < 0:
358
+ return {"ergebnis": "fehler", "datei": None, "grund": "zeit-ungueltig"}
359
+ roh_ordner = os.path.join(ordner, ROH)
360
+ if not (os.path.isdir(roh_ordner) and any(n.endswith(".mp4") for n in os.listdir(roh_ordner))):
361
+ return {"ergebnis": "fehler", "datei": None, "grund": "kein-video"}
362
+ try:
363
+ roh = _einzelbild(ordner, sekunden)
364
+ except Exception:
365
+ roh = None
366
+ if roh is None:
367
+ return {"ergebnis": "fehler", "datei": None, "grund": "kein-bild"}
368
+ if roh.width > BILD_BREITE:
369
+ roh = roh.resize((BILD_BREITE, round(roh.height * BILD_BREITE / roh.width)))
370
+ os.makedirs(os.path.join(ordner, "bilder"), exist_ok=True)
371
+ datei = os.path.join(ordner, "bilder", bild_name(sekunden))
372
+ roh.convert("RGB").save(datei, "PNG")
373
+ return {"ergebnis": "fertig", "datei": datei, "grund": None}
374
+
375
+
376
+ # --- Sprachmodell ----------------------------------------------------------------------------------------------------
377
+
378
+ def _lade_modell() -> None:
379
+ _still()
380
+ from faster_whisper.utils import download_model
381
+
382
+ download_model(MODELL)
383
+
384
+
385
+ def modell_laden(vollstaendig) -> dict:
386
+ """`vollstaendig()` = werkzeug.modell_da gegen den Zwischenspeicher; erst danach zaehlt ein Laden als geladen."""
387
+ if vollstaendig():
388
+ return {"ergebnis": "da", "grund": None}
389
+ try:
390
+ _lade_modell()
391
+ except Exception:
392
+ return {"ergebnis": "fehler", "grund": "laden-gescheitert"}
393
+ return {"ergebnis": "geladen", "grund": None} if vollstaendig() else {"ergebnis": "fehler", "grund": "unvollstaendig"}