Further improve detail display and work on inline latency display

This commit is contained in:
2026-02-15 20:08:02 +00:00
parent 1ebd565f44
commit 63af5d234e
17 changed files with 1223 additions and 239 deletions

View File

@@ -30,7 +30,7 @@ from core.models import (
WorkspaceConversation,
)
from core.realtime.typing_state import get_person_typing_state
from core.views.workspace import _build_engage_payload, _parse_draft_options
from core.views.workspace import INSIGHT_METRICS, _build_engage_payload, _parse_draft_options
COMPOSE_WS_TOKEN_SALT = "compose-ws"
COMPOSE_ENGAGE_TOKEN_SALT = "compose-engage"
@@ -169,6 +169,320 @@ def _serialize_message(msg: Message) -> dict:
}
THREAD_METRIC_FRAGMENT_SPECS = (
{
"slug": "stability_score",
"title": "Stability Score",
"source": "conversation",
"field": "stability_score",
"precision": 2,
},
{
"slug": "stability_confidence",
"title": "Stability Confidence",
"source": "conversation",
"field": "stability_confidence",
"precision": 3,
},
{
"slug": "sample_messages",
"title": "Sample Messages",
"source": "conversation",
"field": "stability_sample_messages",
"precision": 0,
},
{
"slug": "sample_days",
"title": "Sample Days",
"source": "conversation",
"field": "stability_sample_days",
"precision": 0,
},
{
"slug": "commitment_inbound",
"title": "Commit In",
"source": "conversation",
"field": "commitment_inbound_score",
"precision": 2,
},
{
"slug": "commitment_outbound",
"title": "Commit Out",
"source": "conversation",
"field": "commitment_outbound_score",
"precision": 2,
},
{
"slug": "commitment_confidence",
"title": "Commit Confidence",
"source": "conversation",
"field": "commitment_confidence",
"precision": 3,
},
{
"slug": "inbound_messages",
"title": "Inbound Messages",
"source": "snapshot",
"field": "inbound_messages",
"precision": 0,
},
{
"slug": "outbound_messages",
"title": "Outbound Messages",
"source": "snapshot",
"field": "outbound_messages",
"precision": 0,
},
{
"slug": "reciprocity_score",
"title": "Reciprocity",
"source": "snapshot",
"field": "reciprocity_score",
"precision": 2,
},
{
"slug": "continuity_score",
"title": "Continuity",
"source": "snapshot",
"field": "continuity_score",
"precision": 2,
},
{
"slug": "response_score",
"title": "Response",
"source": "snapshot",
"field": "response_score",
"precision": 2,
},
{
"slug": "volatility_score",
"title": "Volatility",
"source": "snapshot",
"field": "volatility_score",
"precision": 2,
},
{
"slug": "inbound_response_score",
"title": "Inbound Response",
"source": "snapshot",
"field": "inbound_response_score",
"precision": 2,
},
{
"slug": "outbound_response_score",
"title": "Outbound Response",
"source": "snapshot",
"field": "outbound_response_score",
"precision": 2,
},
{
"slug": "balance_inbound_score",
"title": "Inbound Balance",
"source": "snapshot",
"field": "balance_inbound_score",
"precision": 2,
},
{
"slug": "balance_outbound_score",
"title": "Outbound Balance",
"source": "snapshot",
"field": "balance_outbound_score",
"precision": 2,
},
)
THREAD_METRIC_COPY_OVERRIDES = {
"inbound_messages": {
"calculation": (
"Count of counterpart-to-user messages in the sampled analysis window."
),
"psychology": (
"Lower counts can indicate reduced reach-back or temporary withdrawal."
),
},
"outbound_messages": {
"calculation": (
"Count of user-to-counterpart messages in the sampled analysis window."
),
"psychology": (
"Large imbalances can reflect chasing or over-functioning dynamics."
),
},
}
def _workspace_conversation_for_person(user, person):
if person is None:
return None
return (
WorkspaceConversation.objects.filter(
user=user,
participants=person,
)
.order_by("-last_event_ts", "-created_at")
.first()
)
def _counterpart_identifiers_for_person(user, person):
if person is None:
return set()
values = (
PersonIdentifier.objects.filter(user=user, person=person)
.values_list("identifier", flat=True)
)
return {str(value or "").strip() for value in values if str(value or "").strip()}
def _message_is_outgoing_for_analysis(msg, counterpart_identifiers):
sender = str(getattr(msg, "sender_uuid", "") or "").strip()
if sender and sender in counterpart_identifiers:
return False
return _is_outgoing(msg)
def _format_gap_duration(ms_value):
value = max(0, int(ms_value or 0))
seconds = value // 1000
if seconds < 60:
return f"{seconds}s"
minutes = seconds // 60
if minutes < 60:
return f"{minutes}m"
hours = minutes // 60
rem_minutes = minutes % 60
if rem_minutes == 0:
return f"{hours}h"
return f"{hours}h {rem_minutes}m"
def _score_from_lag_for_thread(lag_ms, target_hours=4):
if lag_ms is None:
return 50.0
target_ms = max(1, target_hours) * 60 * 60 * 1000
return max(0.0, min(100.0, 100.0 / (1.0 + (lag_ms / target_ms))))
def _metric_copy(slug, fallback_title):
spec = INSIGHT_METRICS.get(slug) or {}
override = THREAD_METRIC_COPY_OVERRIDES.get(slug) or {}
return {
"title": spec.get("title") or fallback_title,
"calculation": override.get("calculation") or spec.get("calculation") or "",
"psychology": override.get("psychology") or spec.get("psychology") or "",
}
def _format_metric_fragment_value(value, precision):
if value is None:
return "-"
try:
number = float(value)
except (TypeError, ValueError):
return str(value)
if int(precision or 0) <= 0:
return str(int(round(number)))
rounded = round(number, int(precision))
if float(rounded).is_integer():
return str(int(rounded))
return f"{rounded:.{int(precision)}f}"
def _build_thread_metric_fragments(conversation):
if conversation is None:
return []
snapshot = conversation.metric_snapshots.first()
fragments = []
for spec in THREAD_METRIC_FRAGMENT_SPECS:
if spec["source"] == "snapshot":
source_obj = snapshot
else:
source_obj = conversation
if source_obj is None:
continue
value = getattr(source_obj, spec["field"], None)
copy = _metric_copy(spec["slug"], spec["title"])
fragments.append(
{
"slug": spec["slug"],
"title": copy["title"],
"value": _format_metric_fragment_value(value, spec.get("precision", 2)),
"calculation": copy["calculation"],
"psychology": copy["psychology"],
}
)
return fragments
def _build_gap_fragment(is_outgoing_reply, lag_ms, snapshot):
metric_slug = "outbound_response_score" if is_outgoing_reply else "inbound_response_score"
copy = _metric_copy(metric_slug, "Response Score")
score_value = None
if snapshot is not None:
score_value = getattr(
snapshot,
"outbound_response_score" if is_outgoing_reply else "inbound_response_score",
None,
)
if score_value is None:
score_value = _score_from_lag_for_thread(lag_ms)
return {
"title": "Unseen Gap",
"focus": "Your reply delay" if is_outgoing_reply else "Counterpart reply delay",
"lag": _format_gap_duration(lag_ms),
"score": _format_metric_fragment_value(score_value, 2),
"calculation": copy["calculation"],
"psychology": copy["psychology"],
}
def _serialize_messages_with_artifacts(
messages,
counterpart_identifiers=None,
conversation=None,
seed_previous=None,
):
rows = list(messages or [])
serialized = [_serialize_message(msg) for msg in rows]
for item in serialized:
item["gap_fragments"] = []
item["metric_fragments"] = []
counterpart_identifiers = set(counterpart_identifiers or [])
snapshot = conversation.metric_snapshots.first() if conversation is not None else None
prev_msg = seed_previous
prev_ts = int(prev_msg.ts or 0) if prev_msg is not None else None
prev_outgoing = (
_message_is_outgoing_for_analysis(prev_msg, counterpart_identifiers)
if prev_msg is not None
else None
)
for idx, msg in enumerate(rows):
current_ts = int(msg.ts or 0)
current_outgoing = _message_is_outgoing_for_analysis(msg, counterpart_identifiers)
if (
prev_msg is not None
and prev_ts is not None
and prev_outgoing is not None
and current_outgoing != prev_outgoing
and current_ts >= prev_ts
):
lag_ms = current_ts - prev_ts
serialized[idx]["gap_fragments"].append(
_build_gap_fragment(current_outgoing, lag_ms, snapshot)
)
prev_msg = msg
prev_ts = current_ts
prev_outgoing = current_outgoing
if serialized:
serialized[-1]["metric_fragments"] = _build_thread_metric_fragments(conversation)
return serialized
def _owner_name(user) -> str:
return (
user.first_name
@@ -571,6 +885,21 @@ def _quick_insights_rows(conversation):
}
def _participant_feedback_state_label(conversation, person):
payload = conversation.participant_feedback or {}
if not isinstance(payload, dict) or person is None:
return ""
raw = payload.get(str(person.id)) or {}
if not isinstance(raw, dict):
return ""
state_key = str(raw.get("state") or "").strip().lower()
return {
"withdrawing": "Withdrawing",
"overextending": "Overextending",
"balanced": "Balanced",
}.get(state_key, "")
def _build_engage_prompt(owner_name, person_name, transcript):
return [
{
@@ -743,6 +1072,10 @@ def _panel_context(
base = _context_base(request.user, service, identifier, person)
limit = _safe_limit(request.GET.get("limit") or request.POST.get("limit"))
session_bundle = _load_messages(request.user, base["person_identifier"], limit)
conversation = _workspace_conversation_for_person(request.user, base["person"])
counterpart_identifiers = _counterpart_identifiers_for_person(
request.user, base["person"]
)
last_ts = 0
if session_bundle["messages"]:
last_ts = int(session_bundle["messages"][-1].ts or 0)
@@ -773,9 +1106,11 @@ def _panel_context(
"person_identifier": base["person_identifier"],
"session": session_bundle["session"],
"messages": session_bundle["messages"],
"serialized_messages": [
_serialize_message(msg) for msg in session_bundle["messages"]
],
"serialized_messages": _serialize_messages_with_artifacts(
session_bundle["messages"],
counterpart_identifiers=counterpart_identifiers,
conversation=conversation,
),
"last_ts": last_ts,
"limit": limit,
"notice_message": notice,
@@ -900,13 +1235,18 @@ class ComposeThread(LoginRequiredMixin, View):
base = _context_base(request.user, service, identifier, person)
latest_ts = after_ts
messages = []
seed_previous = None
if base["person_identifier"] is not None:
session, _ = ChatSession.objects.get_or_create(
user=request.user,
identifier=base["person_identifier"],
)
queryset = Message.objects.filter(user=request.user, session=session)
base_queryset = Message.objects.filter(user=request.user, session=session)
queryset = base_queryset
if after_ts > 0:
seed_previous = (
base_queryset.filter(ts__lte=after_ts).order_by("-ts").first()
)
queryset = queryset.filter(ts__gt=after_ts)
messages = list(
queryset.select_related(
@@ -924,8 +1264,17 @@ class ComposeThread(LoginRequiredMixin, View):
)
if newest:
latest_ts = max(latest_ts, int(newest))
conversation = _workspace_conversation_for_person(request.user, base["person"])
counterpart_identifiers = _counterpart_identifiers_for_person(
request.user, base["person"]
)
payload = {
"messages": [_serialize_message(msg) for msg in messages],
"messages": _serialize_messages_with_artifacts(
messages,
counterpart_identifiers=counterpart_identifiers,
conversation=conversation,
seed_previous=seed_previous,
),
"last_ts": latest_ts,
"typing": get_person_typing_state(
user_id=request.user.id,
@@ -1122,6 +1471,7 @@ class ComposeQuickInsights(LoginRequiredMixin, View):
)
payload = _quick_insights_rows(conversation)
participant_state = _participant_feedback_state_label(conversation, person)
return JsonResponse(
{
"ok": True,
@@ -1129,7 +1479,9 @@ class ComposeQuickInsights(LoginRequiredMixin, View):
"summary": {
"person_name": person.name,
"platform": conversation.get_platform_type_display(),
"state": conversation.get_stability_state_display(),
"state": participant_state
or conversation.get_stability_state_display(),
"stability_state": conversation.get_stability_state_display(),
"thread": conversation.platform_thread_id or "",
"last_event": _format_ts_label(conversation.last_event_ts or 0)
if conversation.last_event_ts
@@ -1150,6 +1502,7 @@ class ComposeQuickInsights(LoginRequiredMixin, View):
"docs": [
"Each row shows current value, percent change vs previous point, and data-point count.",
"Arrow color indicates improving or risk direction for that metric.",
"State uses participant feedback (Withdrawing/Overextending/Balanced) when available.",
"Face indicator maps value range to positive, mixed, or strained climate.",
"Use this card for fast triage; open AI Workspace for full graphs and details.",
],