Enrich contact no-key info pane with first-in-use date

This commit is contained in:
Jack Kingsman
2026-03-11 16:19:10 -07:00
parent 93369f8d64
commit 0c35601af3
8 changed files with 749 additions and 164 deletions
+38
View File
@@ -233,6 +233,44 @@ class NameOnlyContactDetail(BaseModel):
most_active_rooms: list[ContactActiveRoom] = Field(default_factory=list)
class ContactAnalyticsHourlyBucket(BaseModel):
"""A single hourly activity bucket for contact analytics."""
bucket_start: int = Field(description="Unix timestamp for the start of the hour bucket")
last_24h_count: int = 0
last_week_average: float = 0
all_time_average: float = 0
class ContactAnalyticsWeeklyBucket(BaseModel):
"""A single weekly activity bucket for contact analytics."""
bucket_start: int = Field(description="Unix timestamp for the start of the 7-day bucket")
message_count: int = 0
class ContactAnalytics(BaseModel):
"""Unified contact analytics payload for keyed and name-only lookups."""
lookup_type: Literal["contact", "name"]
name: str
contact: Contact | None = None
name_first_seen_at: int | None = None
name_history: list[ContactNameHistory] = Field(default_factory=list)
dm_message_count: int = 0
channel_message_count: int = 0
includes_direct_messages: bool = False
most_active_rooms: list[ContactActiveRoom] = Field(default_factory=list)
advert_paths: list[ContactAdvertPath] = Field(default_factory=list)
advert_frequency: float | None = Field(
default=None,
description="Advert observations per hour (includes multi-path arrivals of same advert)",
)
nearest_repeaters: list[NearestRepeater] = Field(default_factory=list)
hourly_activity: list[ContactAnalyticsHourlyBucket] = Field(default_factory=list)
weekly_activity: list[ContactAnalyticsWeeklyBucket] = Field(default_factory=list)
class Channel(BaseModel):
key: str = Field(description="Channel key (32-char hex)")
name: str
+140 -1
View File
@@ -3,10 +3,28 @@ import time
from typing import Any
from app.database import db
from app.models import Message, MessagePath
from app.models import (
ContactAnalyticsHourlyBucket,
ContactAnalyticsWeeklyBucket,
Message,
MessagePath,
)
class MessageRepository:
@staticmethod
def _contact_activity_filter(public_key: str) -> tuple[str, list[Any]]:
lower_key = public_key.lower()
return (
"((type = 'PRIV' AND LOWER(conversation_key) = ?)"
" OR (type = 'CHAN' AND LOWER(sender_key) = ?))",
[lower_key, lower_key],
)
@staticmethod
def _name_activity_filter(sender_name: str) -> tuple[str, list[Any]]:
return "type = 'CHAN' AND sender_name = ?", [sender_name]
@staticmethod
def _parse_paths(paths_json: str | None) -> list[MessagePath] | None:
"""Parse paths JSON string to list of MessagePath objects."""
@@ -555,6 +573,16 @@ class MessageRepository:
row = await cursor.fetchone()
return row["cnt"] if row else 0
@staticmethod
async def get_first_channel_message_by_sender_name(sender_name: str) -> int | None:
"""Get the earliest stored channel message timestamp for a display name."""
cursor = await db.conn.execute(
"SELECT MIN(received_at) AS first_seen FROM messages WHERE type = 'CHAN' AND sender_name = ?",
(sender_name,),
)
row = await cursor.fetchone()
return row["first_seen"] if row and row["first_seen"] is not None else None
@staticmethod
async def get_channel_stats(conversation_key: str) -> dict:
"""Get channel message statistics: time-windowed counts, first message, unique senders, top senders.
@@ -663,3 +691,114 @@ class MessageRepository:
)
rows = await cursor.fetchall()
return [(row["conversation_key"], row["channel_name"], row["cnt"]) for row in rows]
@staticmethod
async def _get_activity_hour_buckets(where_sql: str, params: list[Any]) -> dict[int, int]:
cursor = await db.conn.execute(
f"""
SELECT received_at / 3600 AS hour_bucket, COUNT(*) AS cnt
FROM messages
WHERE {where_sql}
GROUP BY hour_bucket
""",
params,
)
rows = await cursor.fetchall()
return {int(row["hour_bucket"]): row["cnt"] for row in rows}
@staticmethod
def _build_hourly_activity(
hour_counts: dict[int, int], now: int
) -> list[ContactAnalyticsHourlyBucket]:
current_hour = now // 3600
if hour_counts:
min_hour = min(hour_counts)
else:
min_hour = current_hour
buckets: list[ContactAnalyticsHourlyBucket] = []
for hour_bucket in range(current_hour - 23, current_hour + 1):
last_24h_count = hour_counts.get(hour_bucket, 0)
week_total = 0
week_samples = 0
all_time_total = 0
all_time_samples = 0
compare_hour = hour_bucket
while compare_hour >= min_hour:
count = hour_counts.get(compare_hour, 0)
all_time_total += count
all_time_samples += 1
if week_samples < 7:
week_total += count
week_samples += 1
compare_hour -= 24
buckets.append(
ContactAnalyticsHourlyBucket(
bucket_start=hour_bucket * 3600,
last_24h_count=last_24h_count,
last_week_average=round(week_total / week_samples, 2) if week_samples else 0,
all_time_average=round(all_time_total / all_time_samples, 2)
if all_time_samples
else 0,
)
)
return buckets
@staticmethod
async def _get_weekly_activity(
where_sql: str,
params: list[Any],
now: int,
weeks: int = 26,
) -> list[ContactAnalyticsWeeklyBucket]:
bucket_seconds = 7 * 24 * 3600
current_day_start = (now // 86400) * 86400
start = current_day_start - (weeks - 1) * bucket_seconds
cursor = await db.conn.execute(
f"""
SELECT (received_at - ?) / ? AS bucket_idx, COUNT(*) AS cnt
FROM messages
WHERE {where_sql} AND received_at >= ?
GROUP BY bucket_idx
""",
[start, bucket_seconds, *params, start],
)
rows = await cursor.fetchall()
counts = {int(row["bucket_idx"]): row["cnt"] for row in rows}
return [
ContactAnalyticsWeeklyBucket(
bucket_start=start + bucket_idx * bucket_seconds,
message_count=counts.get(bucket_idx, 0),
)
for bucket_idx in range(weeks)
]
@staticmethod
async def get_contact_activity_series(
public_key: str,
now: int | None = None,
) -> tuple[list[ContactAnalyticsHourlyBucket], list[ContactAnalyticsWeeklyBucket]]:
"""Get combined DM + channel activity series for a keyed contact."""
ts = now if now is not None else int(time.time())
where_sql, params = MessageRepository._contact_activity_filter(public_key)
hour_counts = await MessageRepository._get_activity_hour_buckets(where_sql, params)
hourly = MessageRepository._build_hourly_activity(hour_counts, ts)
weekly = await MessageRepository._get_weekly_activity(where_sql, params, ts)
return hourly, weekly
@staticmethod
async def get_sender_name_activity_series(
sender_name: str,
now: int | None = None,
) -> tuple[list[ContactAnalyticsHourlyBucket], list[ContactAnalyticsWeeklyBucket]]:
"""Get channel-only activity series for a sender name."""
ts = now if now is not None else int(time.time())
where_sql, params = MessageRepository._name_activity_filter(sender_name)
hour_counts = await MessageRepository._get_activity_hour_buckets(where_sql, params)
hourly = MessageRepository._build_hourly_activity(hour_counts, ts)
weekly = await MessageRepository._get_weekly_activity(where_sql, params, ts)
return hourly, weekly
+131 -77
View File
@@ -10,6 +10,7 @@ from app.models import (
ContactActiveRoom,
ContactAdvertPath,
ContactAdvertPathSummary,
ContactAnalytics,
ContactDetail,
ContactRoutingOverrideRequest,
ContactUpsert,
@@ -92,6 +93,102 @@ async def _broadcast_contact_update(contact: Contact) -> None:
broadcast_event("contact", contact.model_dump())
async def _build_keyed_contact_analytics(contact: Contact) -> ContactAnalytics:
name_history = await ContactNameHistoryRepository.get_history(contact.public_key)
dm_count = await MessageRepository.count_dm_messages(contact.public_key)
chan_count = await MessageRepository.count_channel_messages_by_sender(contact.public_key)
active_rooms_raw = await MessageRepository.get_most_active_rooms(contact.public_key)
advert_paths = await ContactAdvertPathRepository.get_recent_for_contact(contact.public_key)
hourly_activity, weekly_activity = await MessageRepository.get_contact_activity_series(
contact.public_key
)
most_active_rooms = [
ContactActiveRoom(channel_key=key, channel_name=name, message_count=count)
for key, name, count in active_rooms_raw
]
advert_frequency: float | None = None
if advert_paths:
total_observations = sum(p.heard_count for p in advert_paths)
earliest = min(p.first_seen for p in advert_paths)
latest = max(p.last_seen for p in advert_paths)
span_hours = (latest - earliest) / 3600.0
if span_hours > 0:
advert_frequency = round(total_observations / span_hours, 2)
first_hop_stats: dict[str, dict] = {}
for p in advert_paths:
prefix = p.next_hop
if prefix:
if prefix not in first_hop_stats:
first_hop_stats[prefix] = {
"heard_count": 0,
"path_len": p.path_len,
"last_seen": p.last_seen,
}
first_hop_stats[prefix]["heard_count"] += p.heard_count
first_hop_stats[prefix]["last_seen"] = max(
first_hop_stats[prefix]["last_seen"], p.last_seen
)
resolved_contacts = await ContactRepository.resolve_prefixes(list(first_hop_stats.keys()))
nearest_repeaters: list[NearestRepeater] = []
for prefix, stats in first_hop_stats.items():
resolved = resolved_contacts.get(prefix)
nearest_repeaters.append(
NearestRepeater(
public_key=resolved.public_key if resolved else prefix,
name=resolved.name if resolved else None,
path_len=stats["path_len"],
last_seen=stats["last_seen"],
heard_count=stats["heard_count"],
)
)
nearest_repeaters.sort(key=lambda r: r.heard_count, reverse=True)
return ContactAnalytics(
lookup_type="contact",
name=contact.name or contact.public_key[:12],
contact=contact,
name_history=name_history,
dm_message_count=dm_count,
channel_message_count=chan_count,
includes_direct_messages=True,
most_active_rooms=most_active_rooms,
advert_paths=advert_paths,
advert_frequency=advert_frequency,
nearest_repeaters=nearest_repeaters,
hourly_activity=hourly_activity,
weekly_activity=weekly_activity,
)
async def _build_name_only_contact_analytics(name: str) -> ContactAnalytics:
chan_count = await MessageRepository.count_channel_messages_by_sender_name(name)
name_first_seen_at = await MessageRepository.get_first_channel_message_by_sender_name(name)
active_rooms_raw = await MessageRepository.get_most_active_rooms_by_sender_name(name)
hourly_activity, weekly_activity = await MessageRepository.get_sender_name_activity_series(name)
most_active_rooms = [
ContactActiveRoom(channel_key=key, channel_name=room_name, message_count=count)
for key, room_name, count in active_rooms_raw
]
return ContactAnalytics(
lookup_type="name",
name=name,
name_first_seen_at=name_first_seen_at,
channel_message_count=chan_count,
includes_direct_messages=False,
most_active_rooms=most_active_rooms,
hourly_activity=hourly_activity,
weekly_activity=weekly_activity,
)
@router.get("", response_model=list[Contact])
async def list_contacts(
limit: int = Query(default=100, ge=1, le=1000),
@@ -115,6 +212,26 @@ async def list_repeater_advert_paths(
)
@router.get("/analytics", response_model=ContactAnalytics)
async def get_contact_analytics(
public_key: str | None = Query(default=None),
name: str | None = Query(default=None, min_length=1, max_length=200),
) -> ContactAnalytics:
"""Get unified contact analytics for either a keyed contact or a sender name."""
if bool(public_key) == bool(name):
raise HTTPException(status_code=400, detail="Specify exactly one of public_key or name")
if public_key:
contact = await _resolve_contact_or_404(public_key)
return await _build_keyed_contact_analytics(contact)
assert name is not None
normalized_name = name.strip()
if not normalized_name:
raise HTTPException(status_code=400, detail="name is required")
return await _build_name_only_contact_analytics(normalized_name)
@router.post("", response_model=Contact)
async def create_contact(
request: CreateContactRequest, background_tasks: BackgroundTasks
@@ -180,73 +297,17 @@ async def get_contact_detail(public_key: str) -> ContactDetail:
advertisement paths, advert frequency, and nearest repeaters.
"""
contact = await _resolve_contact_or_404(public_key)
name_history = await ContactNameHistoryRepository.get_history(contact.public_key)
dm_count = await MessageRepository.count_dm_messages(contact.public_key)
chan_count = await MessageRepository.count_channel_messages_by_sender(contact.public_key)
active_rooms_raw = await MessageRepository.get_most_active_rooms(contact.public_key)
advert_paths = await ContactAdvertPathRepository.get_recent_for_contact(contact.public_key)
most_active_rooms = [
ContactActiveRoom(channel_key=key, channel_name=name, message_count=count)
for key, name, count in active_rooms_raw
]
# Compute advert observation rate (observations/hour) from path data.
# Note: a single advertisement can arrive via multiple paths, so this counts
# RF observations, not unique advertisement broadcasts.
advert_frequency: float | None = None
if advert_paths:
total_observations = sum(p.heard_count for p in advert_paths)
earliest = min(p.first_seen for p in advert_paths)
latest = max(p.last_seen for p in advert_paths)
span_hours = (latest - earliest) / 3600.0
if span_hours > 0:
advert_frequency = round(total_observations / span_hours, 2)
# Compute nearest repeaters from first-hop prefixes in advert paths
first_hop_stats: dict[str, dict] = {} # prefix -> {heard_count, path_len, last_seen}
for p in advert_paths:
prefix = p.next_hop
if prefix:
if prefix not in first_hop_stats:
first_hop_stats[prefix] = {
"heard_count": 0,
"path_len": p.path_len,
"last_seen": p.last_seen,
}
first_hop_stats[prefix]["heard_count"] += p.heard_count
first_hop_stats[prefix]["last_seen"] = max(
first_hop_stats[prefix]["last_seen"], p.last_seen
)
# Resolve all first-hop prefixes to contacts in a single query
resolved_contacts = await ContactRepository.resolve_prefixes(list(first_hop_stats.keys()))
nearest_repeaters: list[NearestRepeater] = []
for prefix, stats in first_hop_stats.items():
resolved = resolved_contacts.get(prefix)
nearest_repeaters.append(
NearestRepeater(
public_key=resolved.public_key if resolved else prefix,
name=resolved.name if resolved else None,
path_len=stats["path_len"],
last_seen=stats["last_seen"],
heard_count=stats["heard_count"],
)
)
nearest_repeaters.sort(key=lambda r: r.heard_count, reverse=True)
analytics = await _build_keyed_contact_analytics(contact)
assert analytics.contact is not None
return ContactDetail(
contact=contact,
name_history=name_history,
dm_message_count=dm_count,
channel_message_count=chan_count,
most_active_rooms=most_active_rooms,
advert_paths=advert_paths,
advert_frequency=advert_frequency,
nearest_repeaters=nearest_repeaters,
contact=analytics.contact,
name_history=analytics.name_history,
dm_message_count=analytics.dm_message_count,
channel_message_count=analytics.channel_message_count,
most_active_rooms=analytics.most_active_rooms,
advert_paths=analytics.advert_paths,
advert_frequency=analytics.advert_frequency,
nearest_repeaters=analytics.nearest_repeaters,
)
@@ -258,18 +319,11 @@ async def get_name_only_contact_detail(
normalized_name = name.strip()
if not normalized_name:
raise HTTPException(status_code=400, detail="name is required")
chan_count = await MessageRepository.count_channel_messages_by_sender_name(normalized_name)
active_rooms_raw = await MessageRepository.get_most_active_rooms_by_sender_name(normalized_name)
most_active_rooms = [
ContactActiveRoom(channel_key=key, channel_name=room_name, message_count=count)
for key, room_name, count in active_rooms_raw
]
analytics = await _build_name_only_contact_analytics(normalized_name)
return NameOnlyContactDetail(
name=normalized_name,
channel_message_count=chan_count,
most_active_rooms=most_active_rooms,
name=analytics.name,
channel_message_count=analytics.channel_message_count,
most_active_rooms=analytics.most_active_rooms,
)