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.
- activity_frames/__init__.py +129 -0
- activity_frames/_time.py +86 -0
- activity_frames/capture.py +251 -0
- activity_frames/cli.py +155 -0
- activity_frames/db.py +86 -0
- activity_frames/emit.py +118 -0
- activity_frames/enrich.py +342 -0
- activity_frames/entities.py +416 -0
- activity_frames/frames.py +358 -0
- activity_frames/mcp_server.py +262 -0
- activity_frames/patterns.py +253 -0
- activity_frames/sessionize.py +356 -0
- activity_frames-0.1.0.dist-info/METADATA +157 -0
- activity_frames-0.1.0.dist-info/RECORD +17 -0
- activity_frames-0.1.0.dist-info/WHEEL +4 -0
- activity_frames-0.1.0.dist-info/entry_points.txt +2 -0
- activity_frames-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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
|