Further improve detail display and work on inline latency display
This commit is contained in:
@@ -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.",
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user