Rebuild workspace widgets and behavioral graph views

This commit is contained in:
2026-03-13 16:48:24 +00:00
parent f8a6d1d41c
commit 57269770b5
47 changed files with 2951 additions and 1077 deletions

View File

@@ -51,18 +51,15 @@ from core.models import (
Person,
PersonIdentifier,
PlatformChatLink,
WorkspaceConversation,
)
from core.presence import get_settings as get_availability_settings
from core.presence import latest_state_for_people, spans_for_range
from core.realtime.typing_state import get_person_typing_state
from core.translation.engine import process_inbound_translation
from core.transports.capabilities import supports, unsupported_reason
from core.views.workspace import (
INSIGHT_METRICS,
_build_engage_payload,
_parse_draft_options,
)
from core.views.workspace import _build_engage_payload, _parse_draft_options
from core.widget_ids import compose_widget_dom_id, compose_widget_unique
from core.workspace import build_behavioral_metric_groups
COMPOSE_WS_TOKEN_SALT = "compose-ws"
COMPOSE_ENGAGE_TOKEN_SALT = "compose-engage"
@@ -71,6 +68,7 @@ COMPOSE_WORKSPACE_CONTACTS_PAGE_SIZE = 12
COMPOSE_WORKSPACE_HISTORY_PAGE_SIZE = 15
COMPOSE_THREAD_WINDOW_OPTIONS = [20, 40, 60, 100, 200]
COMPOSE_COMMAND_SLUGS = ("bp",)
COMPOSE_ASSET_VERSION = "20260313b"
URL_PATTERN = re.compile(r"https?://[^\s<>'\"\\]+")
SIGNAL_UUID_PATTERN = re.compile(
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
@@ -196,6 +194,14 @@ def _safe_after_ts(raw) -> int:
return max(0, value)
def _safe_before_ts(raw) -> int:
try:
value = int(raw or 0)
except (TypeError, ValueError):
value = 0
return max(0, value)
def _safe_page(raw) -> int:
try:
value = int(raw or 1)
@@ -850,24 +856,7 @@ def _serialize_message(msg: Message) -> dict:
},
}
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 _format_gap_duration(ms_value):
value = max(0, int(ms_value or 0))
seconds = value // 1000
@@ -881,14 +870,8 @@ def _format_gap_duration(ms_value):
if rem_minutes == 0:
return f"{hours}h"
return f"{hours}h {rem_minutes}m"
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 _serialize_messages_with_artifacts(messages):
rows = list(messages or [])
serialized = [_serialize_message(msg) for msg in rows]
@@ -964,6 +947,10 @@ def _plain_text(value):
return cleaned.strip()
def _versioned_static_asset(path: str) -> str:
return f"{static(path)}?v={COMPOSE_ASSET_VERSION}"
def _engage_body_only(value):
lines = [line.strip() for line in str(value or "").splitlines() if line.strip()]
if lines and lines[0].startswith("**"):
@@ -1051,331 +1038,6 @@ def _build_summary_prompt(owner_name, person_name, transcript):
]
def _to_float(value):
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _format_number(value, precision=2):
number = _to_float(value)
if number is None:
return "-"
rounded = round(number, precision)
if float(rounded).is_integer():
return str(int(rounded))
return f"{rounded:.{precision}f}"
def _percent_change(current, previous):
now_val = _to_float(current)
prev_val = _to_float(previous)
if now_val is None or prev_val is None:
return None
if abs(prev_val) < 1e-9:
return None
return ((now_val - prev_val) / abs(prev_val)) * 100.0
def _trend_meta(current, previous, higher_is_better=True):
now_val = _to_float(current)
prev_val = _to_float(previous)
if now_val is None or prev_val is None:
return {
"direction": "unknown",
"icon": "fa-solid fa-minus",
"class_name": "has-text-grey",
"meaning": "No comparison yet",
}
delta = now_val - prev_val
if abs(delta) < 1e-9:
return {
"direction": "flat",
"icon": "fa-solid fa-minus",
"class_name": "has-text-grey",
"meaning": "No meaningful change",
}
is_up = delta > 0
improves = is_up if higher_is_better else not is_up
return {
"direction": "up" if is_up else "down",
"icon": (
"fa-solid fa-arrow-trend-up" if is_up else "fa-solid fa-arrow-trend-down"
),
"class_name": "has-text-success" if improves else "has-text-danger",
"meaning": "Improving signal" if improves else "Risk signal",
}
def _emotion_meta(metric_kind, value, metric_key=None):
score = _to_float(value)
if score is None:
return {
"icon": "fa-regular fa-face-meh-blank",
"class_name": "has-text-grey",
"label": "Unknown",
}
if metric_kind == "confidence":
score = score * 100.0
if metric_kind == "count":
key = str(metric_key or "").strip().lower()
if key == "sample_days":
if score >= 14:
return {
"icon": "fa-solid fa-calendar-check",
"class_name": "has-text-success",
"label": "Broad Coverage",
}
if score >= 5:
return {
"icon": "fa-solid fa-calendar-days",
"class_name": "has-text-warning",
"label": "Adequate Coverage",
}
return {
"icon": "fa-solid fa-calendar-xmark",
"class_name": "has-text-danger",
"label": "Narrow Coverage",
}
if score >= 80:
return {
"icon": "fa-solid fa-chart-column",
"class_name": "has-text-success",
"label": "Rich Data",
}
if score >= 30:
return {
"icon": "fa-solid fa-chart-simple",
"class_name": "has-text-warning",
"label": "Moderate Data",
}
return {
"icon": "fa-solid fa-chart-line",
"class_name": "has-text-danger",
"label": "Sparse Data",
}
if score >= 75:
return {
"icon": "fa-regular fa-face-smile",
"class_name": "has-text-success",
"label": "Positive",
}
if score >= 50:
return {
"icon": "fa-regular fa-face-meh",
"class_name": "has-text-warning",
"label": "Mixed",
}
return {
"icon": "fa-regular fa-face-frown",
"class_name": "has-text-danger",
"label": "Strained",
}
def _quick_insights_rows(conversation):
latest = conversation.metric_snapshots.first()
previous = (
conversation.metric_snapshots.order_by("-computed_at")[1:2].first()
if conversation.metric_snapshots.count() > 1
else None
)
metric_specs = [
{
"key": "stability_score",
"label": "Stability Score",
"doc_slug": "stability_score",
"field": "stability_score",
"source": "conversation",
"kind": "score",
"icon": "fa-solid fa-heart-pulse",
"higher_better": True,
},
{
"key": "stability_confidence",
"label": "Stability Confidence",
"doc_slug": "stability_confidence",
"field": "stability_confidence",
"source": "conversation",
"kind": "confidence",
"icon": "fa-solid fa-shield-check",
"higher_better": True,
},
{
"key": "sample_messages",
"label": "Sample Messages",
"doc_slug": "sample_messages",
"field": "stability_sample_messages",
"source": "conversation",
"kind": "count",
"icon": "fa-solid fa-message",
"higher_better": True,
},
{
"key": "sample_days",
"label": "Sample Days",
"doc_slug": "sample_days",
"field": "stability_sample_days",
"source": "conversation",
"kind": "count",
"icon": "fa-solid fa-calendar-days",
"higher_better": True,
},
{
"key": "commitment_inbound",
"label": "Commit In",
"doc_slug": "commitment_inbound",
"field": "commitment_inbound_score",
"source": "conversation",
"kind": "score",
"icon": "fa-solid fa-inbox",
"higher_better": True,
},
{
"key": "commitment_outbound",
"label": "Commit Out",
"doc_slug": "commitment_outbound",
"field": "commitment_outbound_score",
"source": "conversation",
"kind": "score",
"icon": "fa-solid fa-paper-plane",
"higher_better": True,
},
{
"key": "commitment_confidence",
"label": "Commit Confidence",
"doc_slug": "commitment_confidence",
"field": "commitment_confidence",
"source": "conversation",
"kind": "confidence",
"icon": "fa-solid fa-badge-check",
"higher_better": True,
},
{
"key": "reciprocity",
"label": "Reciprocity",
"doc_slug": "reciprocity_score",
"field": "reciprocity_score",
"source": "snapshot",
"kind": "score",
"icon": "fa-solid fa-right-left",
"higher_better": True,
},
{
"key": "continuity",
"label": "Continuity",
"doc_slug": "continuity_score",
"field": "continuity_score",
"source": "snapshot",
"kind": "score",
"icon": "fa-solid fa-link",
"higher_better": True,
},
{
"key": "response",
"label": "Response",
"doc_slug": "response_score",
"field": "response_score",
"source": "snapshot",
"kind": "score",
"icon": "fa-solid fa-gauge-high",
"higher_better": True,
},
{
"key": "volatility",
"label": "Volatility",
"doc_slug": "volatility_score",
"field": "volatility_score",
"source": "snapshot",
"kind": "score",
"icon": "fa-solid fa-wave-square",
"higher_better": True,
},
{
"key": "inbound_messages",
"label": "Inbound Messages",
"doc_slug": "inbound_messages",
"field": "inbound_messages",
"source": "snapshot",
"kind": "count",
"icon": "fa-solid fa-arrow-down",
"higher_better": True,
},
{
"key": "outbound_messages",
"label": "Outbound Messages",
"doc_slug": "outbound_messages",
"field": "outbound_messages",
"source": "snapshot",
"kind": "count",
"icon": "fa-solid fa-arrow-up",
"higher_better": True,
},
]
rows = []
for spec in metric_specs:
field_name = spec["field"]
metric_copy = _metric_copy(spec.get("doc_slug") or spec["key"], spec["label"])
if spec["source"] == "conversation":
current = getattr(conversation, field_name, None)
previous_value = getattr(previous, field_name, None) if previous else None
else:
current = getattr(latest, field_name, None) if latest else None
previous_value = getattr(previous, field_name, None) if previous else None
trend = _trend_meta(
current,
previous_value,
higher_is_better=spec.get("higher_better", True),
)
delta_pct = _percent_change(current, previous_value)
point_count = conversation.metric_snapshots.exclude(
**{f"{field_name}__isnull": True}
).count()
emotion = _emotion_meta(spec["kind"], current, spec["key"])
rows.append(
{
"key": spec["key"],
"label": spec["label"],
"icon": spec["icon"],
"value": current,
"display_value": _format_number(
current,
3 if spec["kind"] == "confidence" else 2,
),
"delta_pct": delta_pct,
"delta_label": f"{delta_pct:+.2f}%" if delta_pct is not None else "n/a",
"point_count": point_count,
"trend": trend,
"emotion": emotion,
"calculation": metric_copy.get("calculation") or "",
"psychology": metric_copy.get("psychology") or "",
}
)
return {
"rows": rows,
"snapshot_count": conversation.metric_snapshots.count(),
"latest_computed_at": latest.computed_at if latest else None,
}
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 [
{
@@ -2070,6 +1732,7 @@ def _compose_urls(service, identifier, person_id):
return {
"page_url": f"{reverse('compose_page')}?{payload}",
"widget_url": f"{reverse('compose_widget')}?{payload}",
"widget_id": compose_widget_dom_id(service_key, identifier_value, person_id),
"workspace_url": f"{reverse('compose_workspace')}?{payload}",
}
@@ -2238,6 +1901,7 @@ def _manual_contact_rows(user):
existing["identifier"] = identifier_value
existing["compose_url"] = urls["page_url"]
existing["compose_widget_url"] = urls["widget_url"]
existing["compose_widget_id"] = urls["widget_id"]
existing["match_url"] = (
f"{reverse('compose_contact_match')}?"
f"{urlencode({'service': service_key, 'identifier': identifier_value})}"
@@ -2263,6 +1927,7 @@ def _manual_contact_rows(user):
"identifier": identifier_value,
"compose_url": urls["page_url"],
"compose_widget_url": urls["widget_url"],
"compose_widget_id": urls["widget_id"],
"linked_person": bool(person),
"source": source,
"match_url": (
@@ -2892,6 +2557,34 @@ def _load_messages(user, person_identifier, limit):
return {"session": session, "messages": messages}
def _behavioral_metric_launch_groups(person):
if person is None:
return []
return build_behavioral_metric_groups(
lambda spec: {
"slug": spec["slug"],
"title": spec["menu_title"],
"icon": spec["icon"],
"widget_url": reverse(
"ai_workspace_insight_detail",
kwargs={
"type": "widget",
"person_id": person.id,
"metric": spec["slug"],
},
),
"page_url": reverse(
"ai_workspace_insight_detail",
kwargs={
"type": "page",
"person_id": person.id,
"metric": spec["slug"],
},
),
}
)
def _panel_context(
request,
service: str,
@@ -2999,6 +2692,20 @@ def _panel_context(
+ (f": {error_message[:220]}" if error_message else "")
)
behavioral_graphs_page_url = ""
behavioral_graphs_widget_url = ""
behavioral_graph_groups = []
if base["person"] is not None:
behavioral_graphs_page_url = reverse(
"ai_workspace_insight_graphs",
kwargs={"type": "page", "person_id": base["person"].id},
)
behavioral_graphs_widget_url = reverse(
"ai_workspace_insight_graphs",
kwargs={"type": "widget", "person_id": base["person"].id},
)
behavioral_graph_groups = _behavioral_metric_launch_groups(base["person"])
return {
"service": base["service"],
"identifier": base["identifier"],
@@ -3018,6 +2725,10 @@ def _panel_context(
"platform_options": platform_options,
"recent_contacts": recent_contacts,
"signal_ingest_warning": signal_ingest_warning,
"behavioral_graphs_page_url": behavioral_graphs_page_url,
"behavioral_graphs_widget_url": behavioral_graphs_widget_url,
"behavioral_graph_groups": behavioral_graph_groups,
"behavioral_show_widget_actions": render_mode == "widget",
"is_group": base.get("is_group", False),
"group_name": base.get("group_name", ""),
}
@@ -3231,6 +2942,7 @@ class ComposeWorkspaceHistoryWidget(LoginRequiredMixin, View):
"display_ts": _format_ts_datetime_label(int(message.ts or 0)),
"text_preview": _history_preview_text(message.text or ""),
"compose_widget_url": urls["widget_url"],
"compose_widget_id": urls["widget_id"],
"compose_page_url": urls["page_url"],
}
)
@@ -3586,6 +3298,7 @@ class ComposePage(LoginRequiredMixin, View):
person=person,
render_mode="page",
)
context["compose_asset_version"] = COMPOSE_ASSET_VERSION
return render(request, self.template_name, context)
@@ -3607,26 +3320,23 @@ class ComposeWidget(LoginRequiredMixin, View):
if panel_context["person"] is not None
else panel_context["identifier"]
)
widget_key = hashlib.sha1(
(
f"{request.user.pk}:"
f"{panel_context['service']}:"
f"{panel_context['identifier']}:"
f"{getattr(panel_context['person'], 'pk', '')}"
).encode("utf-8")
).hexdigest()[:12]
widget_unique = compose_widget_unique(
panel_context["service"],
panel_context["identifier"],
getattr(panel_context["person"], "pk", None),
)
context = {
"title": f"Manual Chat: {title_name}",
"unique": f"compose-widget-{widget_key}",
"unique": widget_unique,
"window_content": "partials/compose-panel.html",
"widget_control_class": "gia-widget-control-no-scroll",
"widget_options": 'gs-w="6" gs-h="12" gs-x="0" gs-y="0" gs-min-w="4"',
"widget_style_hrefs": [static("css/compose-panel.css")],
"widget_style_hrefs": [_versioned_static_asset("css/compose-panel.css")],
"widget_script_srcs": [
static("js/compose-panel-core.js"),
static("js/compose-panel-thread.js"),
static("js/compose-panel-send.js"),
static("js/compose-panel.js"),
_versioned_static_asset("js/compose-panel-core.js"),
_versioned_static_asset("js/compose-panel-thread.js"),
_versioned_static_asset("js/compose-panel-send.js"),
_versioned_static_asset("js/compose-panel.js"),
],
**panel_context,
}
@@ -3641,10 +3351,12 @@ class ComposeThread(LoginRequiredMixin, View):
limit = _safe_limit(request.GET.get("limit") or 60)
after_ts = _safe_after_ts(request.GET.get("after_ts"))
before_ts = _safe_before_ts(request.GET.get("before_ts"))
base = _context_base(request.user, service, identifier, person)
latest_ts = after_ts
messages = []
seed_previous = None
has_older = False
session_ids = ComposeHistorySync._session_ids_for_scope(
user=request.user,
person=base["person"],
@@ -3665,7 +3377,9 @@ class ComposeThread(LoginRequiredMixin, View):
session_id__in=session_ids,
)
queryset = base_queryset
if after_ts > 0:
if before_ts > 0:
queryset = queryset.filter(ts__lt=before_ts)
elif after_ts > 0:
seed_previous = (
base_queryset.filter(ts__lte=after_ts).order_by("-ts").first()
)
@@ -3683,6 +3397,9 @@ class ComposeThread(LoginRequiredMixin, View):
)
rows_desc.reverse()
messages = rows_desc
if messages:
oldest_ts = int(messages[0].ts or 0)
has_older = base_queryset.filter(ts__lt=oldest_ts).exists()
newest = (
Message.objects.filter(
user=request.user,
@@ -3698,6 +3415,7 @@ class ComposeThread(LoginRequiredMixin, View):
payload = {
"messages": serialized_messages,
"messages_html": _render_compose_message_rows(serialized_messages),
"has_older": has_older,
"last_ts": latest_ts,
"typing": get_person_typing_state(
user_id=request.user.id,
@@ -4355,125 +4073,6 @@ class ComposeSummary(LoginRequiredMixin, View):
return JsonResponse({"ok": True, "cached": False, "summary": summary})
class ComposeQuickInsights(LoginRequiredMixin, View):
def get(self, request):
service, identifier, person = _request_scope(request, "GET")
if not identifier and person is None:
return JsonResponse({"ok": False, "error": "Missing contact identifier."})
base = _context_base(request.user, service, identifier, person)
person = base["person"]
if person is None:
return JsonResponse(
{
"ok": False,
"error": "Quick Insights needs a linked person.",
}
)
conversation = (
WorkspaceConversation.objects.filter(
user=request.user,
participants=person,
)
.order_by("-last_event_ts", "-created_at")
.first()
)
if conversation is None:
return JsonResponse(
{
"ok": True,
"empty": True,
"summary": {
"person_name": person.name,
"platform": "",
"state": "Calibrating",
"thread": "",
"last_event": "",
"last_ai_run": "",
"workspace_created": "",
"snapshot_count": 0,
"platform_docs": _metric_copy("platform", "Platform"),
"state_docs": _metric_copy(
"stability_state", "Participant State"
),
"thread_docs": _metric_copy("thread", "Thread"),
"snapshot_docs": {
"calculation": (
"Count of stored workspace metric snapshots for this person."
),
"psychology": (
"More points improve trend reliability; sparse points are "
"best treated as directional signals."
),
},
},
"rows": [],
"docs": [
"Quick Insights needs at least one workspace conversation snapshot.",
"Run AI operations in AI Workspace to generate the first data points.",
],
}
)
payload = _quick_insights_rows(conversation)
participant_state = _participant_feedback_state_label(conversation, person)
selected_platform_label = _service_label(base["service"])
return JsonResponse(
{
"ok": True,
"empty": False,
"summary": {
"person_name": person.name,
"platform": selected_platform_label,
"platform_scope": "All linked platforms",
"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
else ""
),
"last_ai_run": (
dj_timezone.localtime(conversation.last_ai_run_at).strftime(
"%Y-%m-%d %H:%M"
)
if conversation.last_ai_run_at
else ""
),
"workspace_created": dj_timezone.localtime(
conversation.created_at
).strftime("%Y-%m-%d %H:%M"),
"snapshot_count": payload["snapshot_count"],
"platform_docs": _metric_copy("platform", "Platform"),
"state_docs": _metric_copy("stability_state", "Participant State"),
"thread_docs": _metric_copy("thread", "Thread"),
"snapshot_docs": {
"calculation": (
"Count of stored workspace metric snapshots for this person."
),
"psychology": (
"More points improve trend reliability; sparse points are "
"best treated as directional signals."
),
},
},
"rows": payload["rows"],
"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.",
"Values are computed from all linked platform messages for this person.",
"Data labels are metric-specific (for example, day coverage is rated separately from message volume).",
"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.",
],
}
)
class ComposeEngagePreview(LoginRequiredMixin, View):
def get(self, request):
service, identifier, person = _request_scope(request, "GET")