Skip to content

Commit 37f12b9

Browse files
committed
chg: [chat + user-account] add tempolocus, TempoLocus analyzes AIL message activity to estimate likely timezones, countries derived from timezone offsets, and holiday-region
1 parent 23cfc0c commit 37f12b9

6 files changed

Lines changed: 207 additions & 2 deletions

File tree

bin/lib/chats_viewer.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
import time
1212
import uuid
1313

14+
import tempolocus
15+
from tempolocus.core import DetectionError
16+
1417
from datetime import datetime, timezone
1518

1619
sys.path.append(os.environ['AIL_BIN'])
@@ -1062,6 +1065,65 @@ def enrich_chat_relationships_labels(relationships):
10621065
meta[row['target']] = row['target']
10631066
return meta
10641067

1068+
def _get_tempolocus_predictions_from_weekly(weekly_activity, top=5):
1069+
if not weekly_activity:
1070+
return {}
1071+
try:
1072+
return tempolocus.detect(weekly_activity, kind='weekly', top=top)
1073+
except DetectionError:
1074+
return {}
1075+
1076+
def _get_tempolocus_holiday_predictions_from_yearly_activity(yearly_activity, top=5, holiday_profile='standard', activity_signal='lack'):
1077+
yearly_results = []
1078+
for year, daily_activity in sorted(yearly_activity.items()):
1079+
if not daily_activity:
1080+
continue
1081+
try:
1082+
result = tempolocus.detect(
1083+
{'nb': [[day, count] for day, count in sorted(daily_activity.items())]},
1084+
kind='yearly',
1085+
top=top,
1086+
holiday_profile=holiday_profile,
1087+
activity_signal=activity_signal,
1088+
)
1089+
result['year'] = year
1090+
yearly_results.append(result)
1091+
except DetectionError:
1092+
continue
1093+
if not yearly_results:
1094+
return {}
1095+
return {
1096+
'input_type': 'yearly_daily_activity_by_year',
1097+
'holiday_profile': holiday_profile,
1098+
'activity_signal': activity_signal,
1099+
'years': yearly_results,
1100+
}
1101+
1102+
def get_chat_tempolocus_predictions(chat_type, chat_instance_uuid, chat_id, top=5):
1103+
chat = get_obj_chat(chat_type, chat_instance_uuid, chat_id)
1104+
return _get_tempolocus_predictions_from_weekly(chat.get_nb_week_messages(), top=top)
1105+
1106+
def get_chat_tempolocus_holiday_predictions(chat_type, chat_instance_uuid, chat_id, top=5, holiday_profile='standard', activity_signal='lack'):
1107+
chat = get_obj_chat(chat_type, chat_instance_uuid, chat_id)
1108+
yearly_activity = {}
1109+
for year in chat.get_message_years():
1110+
_, daily_activity = chat.get_nb_year_messages(year)
1111+
yearly_activity[str(year)] = daily_activity
1112+
return _get_tempolocus_holiday_predictions_from_yearly_activity(yearly_activity, top=top, holiday_profile=holiday_profile, activity_signal=activity_signal)
1113+
1114+
def get_user_account_tempolocus_predictions(user_id, instance_uuid, top=5):
1115+
user_account = UsersAccount.UserAccount(user_id, instance_uuid)
1116+
weekly_activity = get_user_account_nb_all_week_messages(user_account.id, user_account.get_chats(), user_account.get_chat_subchannels())
1117+
return _get_tempolocus_predictions_from_weekly(weekly_activity, top=top)
1118+
1119+
def get_user_account_tempolocus_holiday_predictions(user_id, instance_uuid, top=5, holiday_profile='standard', activity_signal='lack'):
1120+
user_account = UsersAccount.UserAccount(user_id, instance_uuid)
1121+
yearly_activity = {}
1122+
for year in user_account.get_years():
1123+
_, daily_activity = get_user_account_nb_year_messages(user_account.id, user_account.get_chats(), year)
1124+
yearly_activity[str(year)] = daily_activity
1125+
return _get_tempolocus_holiday_predictions_from_yearly_activity(yearly_activity, top=top, holiday_profile=holiday_profile, activity_signal=activity_signal)
1126+
10651127
def api_get_chat_service_instance(chat_instance_uuid):
10661128
chat_instance = ChatServiceInstance(chat_instance_uuid)
10671129
if not chat_instance.exists():

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ coverage>=5.5
101101
# # # #
102102
PySocks>=1.7.1
103103
pycountry>=20.7.3
104+
tempolocus>=1.0.0
104105

105106
idna
106107
publicsuffixlist

var/www/blueprints/chats_explorer.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,16 @@
3838
def create_json_response(data, status_code):
3939
return Response(json.dumps(data, indent=2, sort_keys=True), mimetype='application/json'), status_code
4040

41+
def get_tempolocus_request_options():
42+
requested = request.args.get('tempolocus') == '1'
43+
holiday_profile = request.args.get('tempolocus_holiday_profile')
44+
if holiday_profile not in {'standard', 'public-worker'}:
45+
holiday_profile = 'standard'
46+
activity_signal = request.args.get('tempolocus_activity_signal')
47+
if activity_signal not in {'lack', 'peak'}:
48+
activity_signal = 'lack'
49+
return requested, holiday_profile, activity_signal
50+
4151
# ============ FUNCTIONS ============
4252

4353
# ============= ROUTES ==============
@@ -149,12 +159,20 @@ def chats_explorer_chat():
149159
languages = Language.get_all_languages()
150160
translation_languages = Language.get_translation_languages()
151161
languages_stats = chats_viewer.api_get_languages_stats('chat', instance_uuid, chat_id)
162+
tempolocus_requested, tempolocus_holiday_profile, tempolocus_activity_signal = get_tempolocus_request_options()
163+
tempolocus_predictions = {}
164+
tempolocus_holiday_predictions = {}
165+
if tempolocus_requested:
166+
tempolocus_predictions = chats_viewer.get_chat_tempolocus_predictions('chat', instance_uuid, chat_id)
167+
tempolocus_holiday_predictions = chats_viewer.get_chat_tempolocus_holiday_predictions('chat', instance_uuid, chat_id, holiday_profile=tempolocus_holiday_profile, activity_signal=tempolocus_activity_signal)
152168
lang_endpoint = url_for('chats_explorer.chats_explorer_chat_lang') + f'?type=chat&subtype={instance_uuid}&id={chat_id}&lang='
153169
return render_template('chat_viewer.html', chat=chat, bootstrap_label=bootstrap_label,
154170
ollama_enabled=images_engine.is_ollama_enabled(),
155171
ail_tags=Tag.get_modal_add_tags(chat['id'], chat['type'], chat['subtype']),
156172
message_id=message_id, languages_stats=languages_stats, lang_endpoint=lang_endpoint,
157-
all_languages=languages, translation_languages=translation_languages, translation_target=target)
173+
tempolocus_predictions=tempolocus_predictions, tempolocus_holiday_predictions=tempolocus_holiday_predictions,
174+
tempolocus_requested=tempolocus_requested, tempolocus_holiday_profile=tempolocus_holiday_profile,
175+
tempolocus_activity_signal=tempolocus_activity_signal, all_languages=languages, translation_languages=translation_languages, translation_target=target)
158176

159177
@chats_explorer.route("chats/explorer/chat/lang", methods=['GET'])
160178
@login_required
@@ -510,12 +528,20 @@ def objects_user_account():
510528
languages = Language.get_all_languages()
511529
translation_languages = Language.get_translation_languages()
512530
languages_stats = chats_viewer.api_get_languages_stats('user-account', instance_uuid, user_id)
531+
tempolocus_requested, tempolocus_holiday_profile, tempolocus_activity_signal = get_tempolocus_request_options()
532+
tempolocus_predictions = {}
533+
tempolocus_holiday_predictions = {}
534+
if tempolocus_requested and not is_forum_account:
535+
tempolocus_predictions = chats_viewer.get_user_account_tempolocus_predictions(user_id, instance_uuid)
536+
tempolocus_holiday_predictions = chats_viewer.get_user_account_tempolocus_holiday_predictions(user_id, instance_uuid, holiday_profile=tempolocus_holiday_profile, activity_signal=tempolocus_activity_signal)
513537
lang_endpoint = url_for('chats_explorer.objects_user_account_lang') + f'?subtype={instance_uuid}&id={user_id}&lang='
514538
account_context = 'forum' if is_forum_account else 'chat'
515539
return render_template('user_account.html', meta=user_account, bootstrap_label=bootstrap_label,
516540
ail_tags=Tag.get_modal_add_tags(user_account['id'], user_account['type'], user_account['subtype']),
517541
languages_stats=languages_stats, lang_endpoint=lang_endpoint, all_languages=languages,
518-
translation_languages=translation_languages, translation_target=target,
542+
tempolocus_predictions=tempolocus_predictions, tempolocus_holiday_predictions=tempolocus_holiday_predictions,
543+
tempolocus_requested=tempolocus_requested, tempolocus_holiday_profile=tempolocus_holiday_profile,
544+
tempolocus_activity_signal=tempolocus_activity_signal, translation_languages=translation_languages, translation_target=target,
519545
account_context=account_context)
520546

521547

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
<div class="card my-3">
2+
<div class="card-header bg-dark text-white">
3+
<i class="fas fa-clock"></i> TempoLocus activity inference
4+
</div>
5+
<div class="card-body">
6+
<form method="get" class="form-inline mb-3">
7+
{% for key, value in request.args.items() %}
8+
{% if key not in ['tempolocus', 'tempolocus_holiday_profile', 'tempolocus_activity_signal'] %}
9+
<input type="hidden" name="{{ key }}" value="{{ value }}">
10+
{% endif %}
11+
{% endfor %}
12+
<input type="hidden" name="tempolocus" value="1">
13+
<label class="mr-2" for="tempolocus_holiday_profile">Holiday profile</label>
14+
<select class="form-control form-control-sm mr-3" id="tempolocus_holiday_profile" name="tempolocus_holiday_profile">
15+
<option value="standard" {% if tempolocus_holiday_profile == 'standard' %}selected{% endif %}>standard</option>
16+
<option value="public-worker" {% if tempolocus_holiday_profile == 'public-worker' %}selected{% endif %}>public-worker</option>
17+
</select>
18+
<label class="mr-2" for="tempolocus_activity_signal">Yearly activity signal</label>
19+
<select class="form-control form-control-sm mr-3" id="tempolocus_activity_signal" name="tempolocus_activity_signal">
20+
<option value="lack" {% if tempolocus_activity_signal == 'lack' %}selected{% endif %}>lack</option>
21+
<option value="peak" {% if tempolocus_activity_signal == 'peak' %}selected{% endif %}>peak</option>
22+
</select>
23+
<button class="btn btn-sm btn-primary" type="submit">
24+
{% if tempolocus_requested %}Update TempoLocus inference{% else %}Run TempoLocus inference{% endif %}
25+
</button>
26+
</form>
27+
28+
{% if not tempolocus_requested %}
29+
<p class="text-muted mb-0">TempoLocus analyzes AIL message activity to estimate likely timezones, countries derived from timezone offsets, and holiday-region patterns for this chat or user account.</p>
30+
{% else %}
31+
{% if tempolocus_predictions.get('signals') %}
32+
<p class="text-muted mb-3">
33+
Based on {{ tempolocus_predictions['signals'].get('total_activity', 0) }} messages in AIL weekly hour statistics. Weekly mode infers timezone patterns; holidays are only checked in the holiday-region section below.
34+
Confidence: {{ "%.2f"|format((tempolocus_predictions.get('confidence') or 0) * 100) }}%.
35+
</p>
36+
{% endif %}
37+
<div class="row">
38+
<div class="col-12 col-lg-6">
39+
<h5>Most probable timezones</h5>
40+
<table class="table table-sm table-hover">
41+
<thead>
42+
<tr><th>Timezone</th><th>Probability</th><th>Candidate zones</th></tr>
43+
</thead>
44+
<tbody>
45+
{% for timezone in tempolocus_predictions.get('results', [])[:5] %}
46+
<tr>
47+
<td><strong>{{ timezone.get('id') }}</strong><br><small>{{ timezone.get('label') }}</small></td>
48+
<td>{{ "%.2f"|format((timezone.get('probability') or 0) * 100) }}%</td>
49+
<td><small>{{ timezone.get('timezone_candidates', [])[:3] | join(', ') }}</small></td>
50+
</tr>
51+
{% endfor %}
52+
</tbody>
53+
</table>
54+
</div>
55+
<div class="col-12 col-lg-6">
56+
<h5>Most probable countries from timezone offsets</h5>
57+
<table class="table table-sm table-hover">
58+
<thead>
59+
<tr><th>Country</th><th>Probability</th><th>Matched offsets</th></tr>
60+
</thead>
61+
<tbody>
62+
{% for country in tempolocus_predictions.get('probable_countries', [])[:5] %}
63+
<tr>
64+
<td><strong>{{ country.get('label') }}</strong> <small>({{ country.get('id') }})</small></td>
65+
<td>{{ "%.2f"|format((country.get('probability') or 0) * 100) }}%</td>
66+
<td><small>{{ country.get('matched_timezone_offsets', []) | join(', ') }}</small></td>
67+
</tr>
68+
{% endfor %}
69+
</tbody>
70+
</table>
71+
</div>
72+
</div>
73+
74+
{% if tempolocus_holiday_predictions %}
75+
<hr>
76+
<h5>Most probable holiday regions</h5>
77+
<p class="text-muted">
78+
Holiday profile: {{ tempolocus_holiday_predictions.get('holiday_profile') }};
79+
activity signal: {{ tempolocus_holiday_predictions.get('activity_signal') }}.
80+
Holiday matching is evaluated independently per calendar year.
81+
</p>
82+
{% for yearly_prediction in tempolocus_holiday_predictions.get('years', []) %}
83+
<h6 class="mt-3">{{ yearly_prediction.get('year') }}</h6>
84+
{% if yearly_prediction.get('signals') %}
85+
<p class="text-muted mb-2">
86+
Date range: {{ yearly_prediction['signals'].get('date_range', {}).get('start') }} - {{ yearly_prediction['signals'].get('date_range', {}).get('end') }};
87+
confidence: {{ "%.2f"|format((yearly_prediction.get('confidence') or 0) * 100) }}%.
88+
</p>
89+
{% endif %}
90+
<table class="table table-sm table-hover">
91+
<thead>
92+
<tr><th>Region</th><th>Probability</th><th>Matched holidays</th></tr>
93+
</thead>
94+
<tbody>
95+
{% for region in yearly_prediction.get('results', [])[:5] %}
96+
<tr>
97+
<td><strong>{{ region.get('label') }}</strong> <small>({{ region.get('id') }})</small></td>
98+
<td>{{ "%.2f"|format((region.get('probability') or 0) * 100) }}%</td>
99+
<td>
100+
{% for holiday in region.get('evidence', {}).get('matched_holidays', [])[:3] %}
101+
<small>{{ holiday.get('date') }} {{ holiday.get('name') }}</small>{% if not loop.last %}<br>{% endif %}
102+
{% endfor %}
103+
</td>
104+
</tr>
105+
{% endfor %}
106+
</tbody>
107+
</table>
108+
{% endfor %}
109+
{% else %}
110+
<p class="text-muted mb-0">No TempoLocus holiday-region prediction is available for this activity.</p>
111+
{% endif %}
112+
{% endif %}
113+
</div>
114+
</div>

var/www/templates/chats_explorer/chat_viewer.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ <h5>Messages by year:</h5>
134134
<h5>Languages:</h5>
135135
<div id="langpie" style="width: 100%;height: 300px;"></div>
136136
{% include 'chats_explorer/block_language_stats.html' %}
137+
{% include 'chats_explorer/block_tempolocus.html' %}
137138

138139
{% with translate_url=url_for('chats_explorer.chats_explorer_chat', subtype=chat['subtype']), obj_id=chat['id'], pagination=chat['pagination'] %}
139140
{% include 'chats_explorer/block_translation.html' %}

var/www/templates/chats_explorer/user_account.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ <h5>Messages by year:</h5>
7070
<h5>Languages:</h5>
7171
<div id="langpie" style="width: 100%;height: 300px;"></div>
7272
{% include 'chats_explorer/block_language_stats.html' %}
73+
{% include 'chats_explorer/block_tempolocus.html' %}
7374

7475
<h4>Usernames:</h4>
7576
<div id="timeline_user_usernames" style="max-width: 900px"></div>

0 commit comments

Comments
 (0)