forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathfair_use_classifier.py
More file actions
268 lines (217 loc) · 11.1 KB
/
Copy pathfair_use_classifier.py
File metadata and controls
268 lines (217 loc) · 11.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
"""
LLM-based purpose detection for fair-use fair-use.
Classifies whether a user's recent conversations indicate non-personal-use
patterns (audiobook transcription, podcast transcription, pre-recorded content).
"""
import json
import logging
import os
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, cast
import database.conversations as conversations_db
from utils.executors import db_executor, run_blocking
from utils.llm.clients import get_llm
from utils.llm.model_config import get_model, get_provider
from utils.llm.usage_tracker import Features, track_usage
logger = logging.getLogger(__name__)
CLASSIFIER_ROUTE = f"{get_provider('fair_use')}/{get_model('fair_use')}"
CLASSIFIER_LOOKBACK_DAYS = int(os.getenv('FAIR_USE_CLASSIFIER_LOOKBACK_DAYS', '7'))
CLASSIFIER_MAX_CONVERSATIONS = 30
_classifier_llm = None
# ---------------------------------------------------------------------------
# Prompt recipes for different non-personal usage scenarios
# ---------------------------------------------------------------------------
SYSTEM_PROMPT = """You are a fair-use cost-protection analyst for Omi, a personal AI wearable device.
OBJECTIVE: Protect against abuse that causes excessive Deepgram transcription costs. The concern is users who BOTH use the device for the wrong purpose AND consume disproportionate resources. Wrong purpose alone at low volume is NOT a concern.
This classifier is ONLY called when a user has already exceeded speech-hour soft caps. Your job is to determine whether that high usage is legitimate (heavy personal use) or abusive (non-personal bulk transcription).
CRITICAL RULES:
- Be EXTREMELY CONSERVATIVE. False positives restrict real users. When in doubt, score LOW.
- A single suspicious conversation is NOT enough. Require a clear PATTERN across many sessions.
- High usage of personal conversations is 100% LEGITIMATE — never flag this.
- Someone recording 10 hours of work meetings per day is a power user, NOT an abuser.
- Only flag patterns where the user is clearly using Omi as a bulk transcription tool for pre-recorded or non-live content.
LEGITIMATE USE (score 0.0-0.3, do NOT flag regardless of volume):
- Personal conversations (any length, any frequency)
- Work meetings, standups, brainstorms, 1-on-1s
- Live lectures or classes the user physically attends
- Phone calls, video calls, FaceTime
- Conferences, all-day events, workshops
- Group discussions, interviews, therapy sessions
- Any real-time live human interaction
- Mixed usage with some long sessions
ABUSE = HIGH VOLUME + WRONG PURPOSE (score 0.7+ only when BOTH conditions):
- Audiobook transcription: long single-speaker sessions with book-like titles, chapter numbers
- Podcast feed transcription: sessions matching known podcast formats/names at scale
- TV/movie transcription: entertainment content at scale
- Pre-recorded content farm: uniform session lengths, media-like titles, no personal engagement
- Commercial transcription service: massive volume, zero personal context, API-like patterns
NOT ABUSE (even if wrong purpose):
- Someone who transcribed one podcast episode → low volume, not a cost concern
- A few audiobook chapters → not enough volume to matter
- Occasional non-personal use mixed with personal → normal usage
OUTPUT FORMAT (strict JSON):
{
"misuse_score": <float 0.0-1.0>,
"usage_type": "<none|audiobook|podcast|prerecorded|tv_movie|commercial|unknown>",
"confidence": <float 0.0-1.0>,
"evidence": [
{"conversation_id": "...", "title": "...", "reason": "..."}
],
"reasoning": "<brief explanation: what pattern you see and why it's a cost concern>"
}
SCORING GUIDE:
- 0.0-0.2: Clearly legitimate — personal conversations, meetings, live events
- 0.2-0.4: High usage but looks personal — power user with lots of meetings/calls
- 0.4-0.6: Some non-personal patterns but mixed with personal use — lean toward legitimate
- 0.6-0.7: Majority non-personal content at high volume — borderline, gather more evidence
- 0.7-0.85: Strong pattern of bulk non-personal transcription driving high costs
- 0.85-1.0: Unambiguous bulk abuse (e.g., sequential "Chapter 1, 2, 3..." audiobook titles)
"""
RECIPE_AUDIOBOOK = """ADDITIONAL FOCUS: Audiobook Detection
Look specifically for:
- Titles containing book names, chapter numbers, author names
- Very long sessions (>1 hour) with single speaker
- Sequential chapter patterns across sessions
- Literary/narrative content in overviews
"""
RECIPE_PODCAST = """ADDITIONAL FOCUS: Podcast Detection
Look specifically for:
- Titles matching known podcast formats ("Episode XX", "EP.", show names)
- Consistent session durations (~30-90 min, matching episode lengths)
- Interview/show format descriptions in overviews
- Media/entertainment categories
"""
RECIPE_PRERECORDED = """ADDITIONAL FOCUS: Pre-recorded Content Detection
Look specifically for:
- Highly uniform session durations (low variance)
- No real interaction or memory creation
- TV show, movie, or lecture titles
- Media consumption patterns (binge-watching transcription)
"""
RECIPE_COMMERCIAL = """ADDITIONAL FOCUS: Commercial Use Detection
Look specifically for:
- Extremely high conversation count with very few memories
- Conversations that look like customer service calls or business dictation
- No personal engagement patterns
- Usage patterns suggesting a transcription service
"""
def _select_recipes(conversation_summaries: List[Dict[str, Any]]) -> str:
"""Select which additional detection recipes to apply based on conversation patterns."""
recipes: List[str] = []
if not conversation_summaries:
return ""
# Check for signs that suggest specific recipes
durations = [c.get('duration_minutes', 0) for c in conversation_summaries]
categories = [c.get('category', '') for c in conversation_summaries]
# Long sessions suggest audiobook/podcast
long_sessions = sum(1 for d in durations if d > 60)
if long_sessions >= 3:
recipes.append(RECIPE_AUDIOBOOK)
# Consistent durations suggest pre-recorded
if len(durations) >= 5:
avg_dur = sum(durations) / len(durations)
if avg_dur > 0:
variance = sum((d - avg_dur) ** 2 for d in durations) / len(durations)
cv = (variance**0.5) / avg_dur if avg_dur > 0 else 0
if cv < 0.3: # Low coefficient of variation = uniform durations
recipes.append(RECIPE_PRERECORDED)
# Very high count with few unique categories
if len(conversation_summaries) >= 20:
unique_cats = len(set(categories))
if unique_cats <= 3:
recipes.append(RECIPE_COMMERCIAL)
# Medium-duration sessions suggest podcast
medium_sessions = sum(1 for d in durations if 25 <= d <= 90)
if medium_sessions >= 5:
recipes.append(RECIPE_PODCAST)
return '\n'.join(recipes)
def _prepare_conversation_summaries(uid: str) -> List[Dict[str, Any]]:
"""Fetch recent conversations and extract metadata for classification."""
start_date = datetime.now(timezone.utc) - timedelta(days=CLASSIFIER_LOOKBACK_DAYS)
conversations = conversations_db.get_conversations(
uid,
limit=CLASSIFIER_MAX_CONVERSATIONS,
start_date=start_date,
)
summaries: List[Dict[str, Any]] = []
for conv in conversations:
structured = cast(Dict[str, Any], conv.get('structured') or {})
started = conv.get('started_at')
ended = conv.get('finished_at') or conv.get('ended_at')
duration_minutes = 0
if started and ended:
try:
if isinstance(started, datetime) and isinstance(ended, datetime):
duration_minutes = (ended - started).total_seconds() / 60
except Exception:
pass
summaries.append(
{
'conversation_id': conv.get('id', ''),
'title': structured.get('title', '') or '',
'overview': (structured.get('overview', '') or '')[:200], # Truncate for token efficiency
'category': structured.get('category', '') or '',
'duration_minutes': round(duration_minutes, 1),
'source': conv.get('source', ''),
'created_at': str(conv.get('created_at', '')),
}
)
return summaries
async def classify_user_purpose(uid: str) -> Dict[str, Any]:
"""Run LLM classification on a user's recent conversations.
Returns a dict matching the ClassifierResult model:
{misuse_score, usage_type, confidence, evidence, model, prompt_version}
"""
default_result: Dict[str, Any] = {
'misuse_score': 0.0,
'usage_type': 'none',
'confidence': 0.0,
'evidence': [],
'model': CLASSIFIER_ROUTE,
'prompt_version': 'v2',
}
try:
summaries = await run_blocking(db_executor, _prepare_conversation_summaries, uid)
if not summaries:
logger.info(f'fair_use: no conversations to classify for {uid}')
return default_result
additional_recipes = _select_recipes(summaries)
user_message = f"""Analyze the following {len(summaries)} recent conversations from user and determine if their usage is legitimate personal use or potential misuse.
{additional_recipes}
CONVERSATIONS:
{json.dumps(summaries, indent=2, default=str)}
Respond with ONLY the JSON output, no other text."""
with track_usage(uid, Features.OTHER):
classifier_llm = _classifier_llm or get_llm('fair_use')
response = await classifier_llm.ainvoke(
[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_message},
]
)
content = cast(str, cast(Any, response).content) if hasattr(response, 'content') else str(response)
# Parse JSON from response
# Handle potential markdown code blocks
if '```json' in content:
content = content.split('```json')[1].split('```')[0]
elif '```' in content:
content = content.split('```')[1].split('```')[0]
result = json.loads(content.strip())
# Validate and clamp
# get(k, default) only defaults an ABSENT key, so a present-but-null field (the LLM emitting
# misuse_score / confidence / evidence: null) would slip through and raise (float(None),
# None[:10]); the broad except below then swallows it and returns default_result
# (misuse_score 0.0 = "not abuse"), silently disabling abuse detection for that run.
result['misuse_score'] = max(0.0, min(1.0, float(result.get('misuse_score') or 0.0)))
result['confidence'] = max(0.0, min(1.0, float(result.get('confidence') or 0.0)))
result['usage_type'] = result.get('usage_type') or 'none'
result['evidence'] = (result.get('evidence') or [])[:10] # Cap evidence entries
result['model'] = CLASSIFIER_ROUTE
result['prompt_version'] = 'v2'
return result
except json.JSONDecodeError as e:
logger.error(f'fair_use: classifier JSON parse error for {uid}: {e}')
return default_result
except Exception as e:
logger.error(f'fair_use: classifier error for {uid}: {e}')
return default_result