activity-frames 0.1.0__py3-none-any.whl

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,253 @@
1
+ """Repetitive-workflow detection (port of Nocta's PatternDetector.swift).
2
+
3
+ Six deterministic detectors over the recorder DB. Each returns
4
+ WorkPattern rows: a machine-readable kind, a human-readable label, and
5
+ the observed count. No scoring, no inference; a pattern is reported
6
+ only when it actually repeated.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from urllib.parse import urlsplit
12
+
13
+ from .db import Database
14
+
15
+ MIN_FREQUENCY = 3
16
+ _GENERIC_ELEMENTS = {"scroll area", "group", "cell", "text", "text field"}
17
+
18
+
19
+ @dataclass
20
+ class WorkPattern:
21
+ kind: str # repeated_click | url_pattern | action_sequence | app_switch | repeated_text | daily_habit
22
+ label: str
23
+ count: int
24
+
25
+
26
+ def detect(db: Database, start_utc: str, end_utc: str,
27
+ *, include_text: bool = False) -> list[WorkPattern]:
28
+ """Detect repetitive workflows.
29
+
30
+ include_text gates the repeated-text detector: its labels quote raw
31
+ typed content, so it is OFF by default (same contract as the rest of
32
+ the package: input volume is standard, input content is opt-in).
33
+ """
34
+ out: list[WorkPattern] = []
35
+ if db.table_exists("ui_events"):
36
+ out += repeated_clicks(db, start_utc, end_utc)
37
+ out += action_sequences(db, start_utc, end_utc)
38
+ if include_text:
39
+ out += repeated_text(db, start_utc, end_utc)
40
+ out += daily_habits(db, start_utc, end_utc)
41
+ out += url_patterns(db, start_utc, end_utc)
42
+ out += app_switching(db, start_utc, end_utc)
43
+ return out
44
+
45
+
46
+ def repeated_clicks(db: Database, start: str, end: str) -> list[WorkPattern]:
47
+ rows = db.rows(
48
+ """
49
+ SELECT element_name, element_role, COUNT(*) as cnt FROM ui_events
50
+ WHERE timestamp BETWEEN ? AND ?
51
+ AND event_type = 'click'
52
+ AND element_name IS NOT NULL AND element_name != ''
53
+ AND element_name NOT IN ('scroll area','group','cell','text','text field')
54
+ GROUP BY element_name, element_role
55
+ HAVING cnt >= ?
56
+ ORDER BY cnt DESC LIMIT 20
57
+ """,
58
+ (start, end, MIN_FREQUENCY),
59
+ )
60
+ return [
61
+ WorkPattern(
62
+ kind="repeated_click",
63
+ label=f"Clicked '{(name or '').strip()}' ({role or ''}) {cnt}x",
64
+ count=int(cnt),
65
+ )
66
+ for name, role, cnt in rows
67
+ if name
68
+ ]
69
+
70
+
71
+ def url_patterns(db: Database, start: str, end: str) -> list[WorkPattern]:
72
+ rows = db.rows(
73
+ """
74
+ SELECT browser_url, COUNT(*) as cnt FROM frames
75
+ WHERE timestamp BETWEEN ? AND ?
76
+ AND focused = 1
77
+ AND browser_url IS NOT NULL AND browser_url != ''
78
+ GROUP BY browser_url ORDER BY cnt DESC LIMIT 2000
79
+ """,
80
+ (start, end),
81
+ )
82
+ groups: dict[str, dict] = {}
83
+ for url, cnt in rows:
84
+ try:
85
+ parts_all = urlsplit(url)
86
+ except ValueError:
87
+ continue
88
+ host = parts_all.hostname
89
+ if not host:
90
+ continue
91
+ parts = [p for p in parts_all.path.split("/") if p]
92
+ if len(parts) >= 2:
93
+ key = f"{host}/{parts[0]}/{parts[1]}/*"
94
+ elif len(parts) == 1:
95
+ key = f"{host}/{parts[0]}/*"
96
+ else:
97
+ key = host
98
+ g = groups.setdefault(key, {"urls": set(), "visits": 0})
99
+ g["urls"].add(url)
100
+ g["visits"] += int(cnt)
101
+
102
+ keep = [
103
+ (k, g) for k, g in groups.items()
104
+ if g["visits"] >= MIN_FREQUENCY and (len(g["urls"]) >= 3 or g["visits"] >= 5)
105
+ ]
106
+ keep.sort(key=lambda kg: -kg[1]["visits"])
107
+ return [
108
+ WorkPattern(
109
+ kind="url_pattern",
110
+ label=f"{k} - {g['visits']} visits, {len(g['urls'])} unique pages",
111
+ count=g["visits"],
112
+ )
113
+ for k, g in keep[:15]
114
+ ]
115
+
116
+
117
+ def action_sequences(db: Database, start: str, end: str) -> list[WorkPattern]:
118
+ # Most recent 20k clicks, re-sorted chronologically for sequence mining
119
+ # (a plain ORDER BY ... LIMIT would analyze the OLDEST part of the window).
120
+ rows = db.rows(
121
+ """
122
+ SELECT element_name, element_role FROM (
123
+ SELECT timestamp, element_name, element_role FROM ui_events
124
+ WHERE timestamp BETWEEN ? AND ?
125
+ AND event_type = 'click'
126
+ AND element_name IS NOT NULL AND element_name != ''
127
+ ORDER BY timestamp DESC LIMIT 20000
128
+ ) ORDER BY timestamp ASC
129
+ """,
130
+ (start, end),
131
+ )
132
+ if len(rows) < 4:
133
+ return []
134
+ actions = []
135
+ for name, role in rows:
136
+ name = (name or "").strip()
137
+ actions.append(f"[{role or 'element'}]" if name in _GENERIC_ELEMENTS else name)
138
+
139
+ bigrams: dict[str, int] = {}
140
+ trigrams: dict[str, int] = {}
141
+ for i in range(len(actions) - 1):
142
+ a, b = actions[i], actions[i + 1]
143
+ if a == b and a.startswith("["):
144
+ continue
145
+ bigrams[f"{a} -> {b}"] = bigrams.get(f"{a} -> {b}", 0) + 1
146
+ for i in range(len(actions) - 2):
147
+ seq = f"{actions[i]} -> {actions[i + 1]} -> {actions[i + 2]}"
148
+ trigrams[seq] = trigrams.get(seq, 0) + 1
149
+
150
+ out: list[WorkPattern] = []
151
+ seen_parts: set[str] = set()
152
+ for seq, cnt in sorted(trigrams.items(), key=lambda kv: -kv[1])[:10]:
153
+ if cnt < MIN_FREQUENCY:
154
+ break
155
+ out.append(WorkPattern(kind="action_sequence", label=f"{seq} ({cnt}x)", count=cnt))
156
+ seen_parts.update(seq.split(" -> "))
157
+ for seq, cnt in sorted(bigrams.items(), key=lambda kv: -kv[1])[:10]:
158
+ if cnt < MIN_FREQUENCY:
159
+ break
160
+ if all(p in seen_parts for p in seq.split(" -> ")):
161
+ continue
162
+ out.append(WorkPattern(kind="action_sequence", label=f"{seq} ({cnt}x)", count=cnt))
163
+ return out[:10]
164
+
165
+
166
+ def app_switching(db: Database, start: str, end: str) -> list[WorkPattern]:
167
+ rows = db.rows(
168
+ """
169
+ SELECT app_name FROM (
170
+ SELECT timestamp, app_name FROM frames
171
+ WHERE timestamp BETWEEN ? AND ?
172
+ AND focused = 1
173
+ AND app_name IS NOT NULL AND app_name != ''
174
+ ORDER BY timestamp DESC LIMIT 50000
175
+ ) ORDER BY timestamp ASC
176
+ """,
177
+ (start, end),
178
+ )
179
+ transitions: dict[str, int] = {}
180
+ prev = None
181
+ for (app,) in rows:
182
+ if prev and prev != app:
183
+ key = f"{prev} -> {app}"
184
+ transitions[key] = transitions.get(key, 0) + 1
185
+ prev = app
186
+ keep = sorted(
187
+ ((k, v) for k, v in transitions.items() if v >= MIN_FREQUENCY),
188
+ key=lambda kv: -kv[1],
189
+ )
190
+ return [
191
+ WorkPattern(kind="app_switch", label=f"{k} - {v} transitions", count=v)
192
+ for k, v in keep[:10]
193
+ ]
194
+
195
+
196
+ def repeated_text(db: Database, start: str, end: str) -> list[WorkPattern]:
197
+ rows = db.rows(
198
+ """
199
+ SELECT text_content, COUNT(*) as cnt FROM ui_events
200
+ WHERE timestamp BETWEEN ? AND ?
201
+ AND event_type = 'text'
202
+ AND text_content IS NOT NULL AND LENGTH(text_content) > 10
203
+ GROUP BY text_content
204
+ HAVING cnt >= ?
205
+ ORDER BY cnt DESC LIMIT 15
206
+ """,
207
+ (start, end, MIN_FREQUENCY),
208
+ )
209
+ out = []
210
+ for text, cnt in rows:
211
+ text = (text or "").strip()
212
+ if len(text) < 10:
213
+ continue
214
+ preview = text[:120] + "..." if len(text) > 120 else text
215
+ out.append(
216
+ WorkPattern(kind="repeated_text", label=f'Typed {cnt}x: "{preview}"', count=int(cnt))
217
+ )
218
+ return out[:10]
219
+
220
+
221
+ def daily_habits(db: Database, start: str, end: str) -> list[WorkPattern]:
222
+ rows = db.rows(
223
+ """
224
+ SELECT date(timestamp) as day, element_name, COUNT(*) as cnt FROM ui_events
225
+ WHERE timestamp BETWEEN ? AND ?
226
+ AND event_type = 'click'
227
+ AND element_name IS NOT NULL AND element_name != ''
228
+ AND element_name NOT IN ('scroll area','group')
229
+ GROUP BY day, element_name
230
+ HAVING cnt >= 3
231
+ ORDER BY element_name, day
232
+ """,
233
+ (start, end),
234
+ )
235
+ habits: dict[str, dict] = {}
236
+ for _day, name, cnt in rows:
237
+ if not name:
238
+ continue
239
+ h = habits.setdefault(name, {"days": 0, "total": 0})
240
+ h["days"] += 1
241
+ h["total"] += int(cnt)
242
+ keep = sorted(
243
+ ((n, h) for n, h in habits.items() if h["days"] >= 2),
244
+ key=lambda nh: -nh[1]["total"],
245
+ )
246
+ return [
247
+ WorkPattern(
248
+ kind="daily_habit",
249
+ label=f"'{n.strip()}' on {h['days']} days, {h['total']}x total",
250
+ count=h["total"],
251
+ )
252
+ for n, h in keep[:10]
253
+ ]
@@ -0,0 +1,356 @@
1
+ """Turn frame snapshots into bounded activity segments.
2
+
3
+ The recorder stores instants: one row per screen change. This module
4
+ compiles those instants into what an agent actually needs: contiguous
5
+ segments of "the user was in app X (on site Y) from T1 to T2".
6
+
7
+ All math is deterministic and documented:
8
+
9
+ - dwell: a frame contributes min(gap_to_next_frame, DWELL_CAP) seconds
10
+ of active time. Capture is event-driven (median gap ~9s); a long gap
11
+ means the screen was static or the user was away, so dwell is capped.
12
+ - segment boundary: the (app, site) context key changes, or a gap
13
+ larger than SESSION_GAP occurs.
14
+ - flicker merge: an interruption shorter than merge_flicker seconds
15
+ that returns to the same context key is folded into the surrounding
16
+ segment and recorded in `interruptions` (nothing is hidden).
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass, field
21
+ from urllib.parse import urlsplit
22
+
23
+ from ._time import parse_epoch
24
+ from .db import Database
25
+
26
+ DWELL_CAP = 90.0 # seconds; max credit for one frame
27
+ SESSION_GAP = 300.0 # seconds; larger gap = user away / new session
28
+ MERGE_FLICKER = 20.0 # seconds; brief context switches fold into host segment
29
+
30
+
31
+ def _domain(url: str | None) -> str | None:
32
+ if not url:
33
+ return None
34
+ try:
35
+ host = urlsplit(url).hostname
36
+ except ValueError:
37
+ return None
38
+ if not host:
39
+ return None
40
+ return host[4:] if host.startswith("www.") else host
41
+
42
+
43
+ # Invisible direction/format marks that pollute captured app names
44
+ # (e.g. WhatsApp ships with a leading U+200E).
45
+ _FORMAT_CHARS = dict.fromkeys(map(ord, "‎‏​⁠"))
46
+
47
+
48
+ def clean_name(s: str) -> str:
49
+ return s.translate(_FORMAT_CHARS).strip()
50
+
51
+
52
+ @dataclass
53
+ class RawFrame:
54
+ id: int
55
+ epoch: float
56
+ app: str
57
+ window: str | None
58
+ url: str | None
59
+ domain: str | None
60
+ device: str = ""
61
+
62
+
63
+ @dataclass
64
+ class Interruption:
65
+ app: str
66
+ domain: str | None
67
+ seconds: float
68
+
69
+
70
+ @dataclass
71
+ class Segment:
72
+ app: str
73
+ domain: str | None # None for non-browser apps
74
+ start_epoch: float
75
+ end_epoch: float
76
+ active_seconds: float = 0.0
77
+ frames: list[RawFrame] = field(default_factory=list)
78
+ interruptions: list[Interruption] = field(default_factory=list)
79
+
80
+ @property
81
+ def key(self) -> tuple[str, str | None]:
82
+ return (self.app, self.domain)
83
+
84
+ @property
85
+ def frame_ids(self) -> list[int]:
86
+ return [f.id for f in self.frames]
87
+
88
+ def wall_seconds(self) -> float:
89
+ return max(0.0, self.end_epoch - self.start_epoch)
90
+
91
+
92
+ def _has_column(db: Database, table: str, column: str) -> bool:
93
+ try:
94
+ return any(r[1] == column for r in db.rows(f"PRAGMA table_info({table})"))
95
+ except Exception:
96
+ return False
97
+
98
+
99
+ def load_frames(db: Database, start_utc: str, end_utc: str) -> list[RawFrame]:
100
+ # device_name is optional: older or non-default capture schemas lack it.
101
+ dev_col = "device_name" if _has_column(db, "frames", "device_name") else "''"
102
+ rows = db.rows(
103
+ f"""
104
+ SELECT id, timestamp, app_name, window_name, browser_url, {dev_col}
105
+ FROM frames
106
+ WHERE timestamp >= ? AND timestamp < ?
107
+ AND app_name IS NOT NULL AND app_name != ''
108
+ ORDER BY timestamp ASC
109
+ """,
110
+ (start_utc, end_utc),
111
+ )
112
+ out = []
113
+ for fid, ts, app, window, url, device in rows:
114
+ epoch = parse_epoch(ts or "")
115
+ if epoch <= 0:
116
+ continue
117
+ out.append(
118
+ RawFrame(
119
+ id=int(fid), epoch=epoch, app=clean_name(app or ""),
120
+ window=clean_name(window) if window else window,
121
+ url=url, domain=_domain(url),
122
+ device=device or "",
123
+ )
124
+ )
125
+ return out
126
+
127
+
128
+ def segments(
129
+ db: Database,
130
+ start_utc: str,
131
+ end_utc: str,
132
+ *,
133
+ dwell_cap: float = DWELL_CAP,
134
+ session_gap: float = SESSION_GAP,
135
+ merge_flicker: float = MERGE_FLICKER,
136
+ ) -> list[Segment]:
137
+ """Chronological (app, site) segments for a UTC window.
138
+
139
+ Frames are partitioned by capture device (each monitor records its
140
+ own stream); segmentation runs per device so two simultaneous
141
+ monitors do not shred each other's sessions. The merged result is
142
+ sorted by start time.
143
+ """
144
+ all_frames = load_frames(db, start_utc, end_utc)
145
+ if not all_frames:
146
+ return []
147
+
148
+ by_device: dict[str, list[RawFrame]] = {}
149
+ for f in all_frames:
150
+ by_device.setdefault(f.device, []).append(f)
151
+
152
+ merged: list[Segment] = []
153
+ for stream in by_device.values():
154
+ merged.extend(
155
+ _segment_stream(stream, dwell_cap=dwell_cap,
156
+ session_gap=session_gap,
157
+ merge_flicker=merge_flicker)
158
+ )
159
+ merged.sort(key=lambda s: s.start_epoch)
160
+ return merged
161
+
162
+
163
+ def _segment_stream(
164
+ frames: list[RawFrame],
165
+ *,
166
+ dwell_cap: float,
167
+ session_gap: float,
168
+ merge_flicker: float,
169
+ ) -> list[Segment]:
170
+ if not frames:
171
+ return []
172
+
173
+ # Pass 1: raw segmentation on context-key change or session gap.
174
+ raw: list[Segment] = []
175
+ cur: Segment | None = None
176
+ for i, f in enumerate(frames):
177
+ gap_to_next = (
178
+ frames[i + 1].epoch - f.epoch if i + 1 < len(frames) else None
179
+ )
180
+ dwell = min(gap_to_next, dwell_cap) if gap_to_next is not None else 0.0
181
+
182
+ key = (f.app, f.domain)
183
+ if cur is None or key != cur.key:
184
+ cur = Segment(
185
+ app=f.app, domain=f.domain,
186
+ start_epoch=f.epoch, end_epoch=f.epoch,
187
+ )
188
+ raw.append(cur)
189
+ cur.frames.append(f)
190
+ cur.end_epoch = f.epoch
191
+ if gap_to_next is not None and gap_to_next <= session_gap:
192
+ cur.active_seconds += dwell
193
+ if gap_to_next is not None and gap_to_next > session_gap:
194
+ cur = None # session break: next frame starts a new segment
195
+
196
+ # Pass 2: flicker merge. A -> B -> A where B is brief becomes one A
197
+ # segment with B recorded as an interruption.
198
+ if merge_flicker <= 0:
199
+ return raw
200
+ merged: list[Segment] = []
201
+ i = 0
202
+ while i < len(raw):
203
+ seg = raw[i]
204
+ while (
205
+ i + 2 < len(raw)
206
+ and raw[i + 1].wall_seconds() <= merge_flicker
207
+ and raw[i + 2].key == seg.key
208
+ # never merge across a session break, on either side of B
209
+ and raw[i + 1].start_epoch - seg.end_epoch <= session_gap
210
+ and raw[i + 2].start_epoch - raw[i + 1].end_epoch <= session_gap
211
+ ):
212
+ flicker, cont = raw[i + 1], raw[i + 2]
213
+ # The flicker's time is recorded on the interruption, NOT
214
+ # added to the host segment's active time: active_seconds
215
+ # stays honest about time spent in THIS context.
216
+ seg.interruptions.append(
217
+ Interruption(
218
+ app=flicker.app, domain=flicker.domain,
219
+ seconds=round(flicker.active_seconds or flicker.wall_seconds() or 1.0, 1),
220
+ )
221
+ )
222
+ seg.frames.extend(cont.frames)
223
+ seg.active_seconds += cont.active_seconds
224
+ seg.end_epoch = cont.end_epoch
225
+ seg.interruptions.extend(cont.interruptions)
226
+ i += 2
227
+ merged.append(seg)
228
+ i += 1
229
+ return merged
230
+
231
+
232
+ # ---- Coverage (port of ActivitySkeletonBuilder's one-pass measures) ----
233
+
234
+ @dataclass
235
+ class Gap:
236
+ start_epoch: float
237
+ end_epoch: float
238
+
239
+ @property
240
+ def minutes(self) -> int:
241
+ return int((self.end_epoch - self.start_epoch) / 60)
242
+
243
+
244
+ @dataclass
245
+ class Coverage:
246
+ first_epoch: float
247
+ last_epoch: float
248
+ active_minutes: int
249
+ span_minutes: int
250
+ coverage_pct: int
251
+ frame_count: int
252
+ distinct_apps: int
253
+ gaps: list[Gap]
254
+ hour_histogram: dict[int, int] # local hour -> active minutes
255
+
256
+
257
+ def coverage(db: Database, start_utc: str, end_utc: str,
258
+ *, session_gap: float = SESSION_GAP) -> Coverage:
259
+ frames = load_frames(db, start_utc, end_utc)
260
+ if not frames:
261
+ return Coverage(0, 0, 0, 0, 0, 0, 0, [], {})
262
+
263
+ from datetime import datetime
264
+
265
+ active_minutes: set[int] = set()
266
+ hour_minutes: dict[int, set[int]] = {}
267
+ gaps: list[Gap] = []
268
+ apps: set[str] = set()
269
+ prev: float | None = None
270
+
271
+ for f in frames:
272
+ apps.add(f.app)
273
+ local = datetime.fromtimestamp(f.epoch).astimezone()
274
+ minute_id = int(f.epoch / 60)
275
+ active_minutes.add(minute_id)
276
+ hour_minutes.setdefault(local.hour, set()).add(minute_id)
277
+ if prev is not None and f.epoch - prev > session_gap:
278
+ gaps.append(Gap(prev, f.epoch))
279
+ prev = f.epoch
280
+
281
+ first, last = frames[0].epoch, frames[-1].epoch
282
+ span_min = int((last - first) / 60)
283
+ active_min = len(active_minutes)
284
+ pct = min(100, int(active_min / span_min * 100)) if span_min > 0 else 0
285
+ return Coverage(
286
+ first_epoch=first,
287
+ last_epoch=last,
288
+ active_minutes=active_min,
289
+ span_minutes=span_min,
290
+ coverage_pct=pct,
291
+ frame_count=len(frames),
292
+ distinct_apps=len(apps),
293
+ gaps=[g for g in gaps if g.minutes >= 5],
294
+ hour_histogram={h: len(m) for h, m in sorted(hour_minutes.items())},
295
+ )
296
+
297
+
298
+ # ---- App ledger (per-app aggregates over a window) ---------------------
299
+
300
+ @dataclass
301
+ class AppUsage:
302
+ app: str
303
+ minutes: float
304
+ sessions: int
305
+ longest_session_min: int
306
+ top_windows: list[str]
307
+
308
+
309
+ def app_ledger(db: Database, start_utc: str, end_utc: str,
310
+ *, dwell_cap: float = DWELL_CAP,
311
+ session_gap: float = SESSION_GAP) -> list[AppUsage]:
312
+ all_frames = load_frames(db, start_utc, end_utc)
313
+ dwell: dict[str, float] = {}
314
+ windows: dict[str, dict[str, float]] = {}
315
+ sessions: dict[str, int] = {}
316
+ longest: dict[str, float] = {}
317
+
318
+ # Per-device streams: dwell is the gap to the next frame on the SAME
319
+ # monitor, so simultaneous monitors do not corrupt each other's math.
320
+ by_device: dict[str, list[RawFrame]] = {}
321
+ for f in all_frames:
322
+ by_device.setdefault(f.device, []).append(f)
323
+
324
+ for frames in by_device.values():
325
+ cur_session: dict[str, float] = {}
326
+ for i, f in enumerate(frames[:-1]):
327
+ gap = frames[i + 1].epoch - f.epoch
328
+ if gap > session_gap:
329
+ cur_session.clear()
330
+ continue
331
+ d = min(gap, dwell_cap)
332
+ dwell[f.app] = dwell.get(f.app, 0.0) + d
333
+ if f.window:
334
+ windows.setdefault(f.app, {})
335
+ windows[f.app][f.window] = windows[f.app].get(f.window, 0.0) + d
336
+ if d > 0:
337
+ if cur_session.get(f.app, 0.0) == 0.0:
338
+ sessions[f.app] = sessions.get(f.app, 0) + 1
339
+ cur_session[f.app] = cur_session.get(f.app, 0.0) + d
340
+ longest[f.app] = max(longest.get(f.app, 0.0), cur_session[f.app])
341
+
342
+ out = []
343
+ for app, secs in sorted(dwell.items(), key=lambda kv: -kv[1]):
344
+ if secs < 20:
345
+ continue
346
+ tops = sorted(windows.get(app, {}).items(), key=lambda kv: -kv[1])[:4]
347
+ out.append(
348
+ AppUsage(
349
+ app=app,
350
+ minutes=round(secs / 60, 1),
351
+ sessions=sessions.get(app, 1),
352
+ longest_session_min=int(longest.get(app, 0.0) / 60),
353
+ top_windows=[w for w, _ in tops],
354
+ )
355
+ )
356
+ return out