Renovate mitigation panels and messages

This commit is contained in:
2026-02-15 21:02:40 +00:00
parent 63af5d234e
commit cc3fff0757
12 changed files with 1518 additions and 236 deletions

View File

@@ -13,7 +13,7 @@ from django.core import signing
from django.core.cache import cache
from django.http import HttpResponseBadRequest, JsonResponse
from django.shortcuts import get_object_or_404, render
from django.urls import reverse
from django.urls import NoReverseMatch, reverse
from django.utils import timezone as dj_timezone
from django.views import View
@@ -426,10 +426,14 @@ def _build_gap_fragment(is_outgoing_reply, lag_ms, snapshot):
)
if score_value is None:
score_value = _score_from_lag_for_thread(lag_ms)
score_value = max(0.0, min(100.0, float(score_value)))
return {
"slug": metric_slug,
"title": "Unseen Gap",
"focus": "Your reply delay" if is_outgoing_reply else "Counterpart reply delay",
"lag": _format_gap_duration(lag_ms),
"lag_ms": int(lag_ms or 0),
"score_value": round(score_value, 2),
"score": _format_metric_fragment_value(score_value, 2),
"calculation": copy["calculation"],
"psychology": copy["psychology"],
@@ -483,6 +487,90 @@ def _serialize_messages_with_artifacts(
return serialized
def _insight_detail_url(person_id, metric_slug):
if not person_id or not metric_slug:
return ""
try:
return reverse(
"ai_workspace_insight_detail",
kwargs={
"type": "page",
"person_id": person_id,
"metric": str(metric_slug),
},
)
except NoReverseMatch:
return ""
def _glance_items_from_state(gap_fragment=None, metric_fragments=None, person_id=None):
items = []
if gap_fragment:
tooltip_parts = [
f"{gap_fragment.get('focus') or 'Response delay'}",
f"Delay {gap_fragment.get('lag') or '-'}",
f"Score {gap_fragment.get('score') or '-'}",
]
if gap_fragment.get("calculation"):
tooltip_parts.append(
f"How it is calculated: {gap_fragment.get('calculation')}"
)
if gap_fragment.get("psychology"):
tooltip_parts.append(
f"Psychological interpretation: {gap_fragment.get('psychology')}"
)
items.append(
{
"label": "Response Delay",
"value": f"{gap_fragment.get('lag') or '-'} · {gap_fragment.get('score') or '-'}",
"tooltip": " | ".join(tooltip_parts),
"url": _insight_detail_url(
person_id,
gap_fragment.get("slug") or "inbound_response_score",
),
}
)
metric_fragments = list(metric_fragments or [])
for metric in metric_fragments[:2]:
tooltip_parts = []
if metric.get("calculation"):
tooltip_parts.append(f"How it is calculated: {metric.get('calculation')}")
if metric.get("psychology"):
tooltip_parts.append(
f"Psychological interpretation: {metric.get('psychology')}"
)
items.append(
{
"label": str(metric.get("title") or "Metric"),
"value": str(metric.get("value") or "-"),
"tooltip": " | ".join(tooltip_parts),
"url": _insight_detail_url(person_id, metric.get("slug")),
}
)
return items[:3]
def _build_glance_items(serialized_messages, person_id=None):
rows = list(serialized_messages or [])
latest_metrics = []
latest_gap = None
for row in reversed(rows):
row_metrics = list(row.get("metric_fragments") or [])
if row_metrics and not latest_metrics:
latest_metrics = row_metrics
row_gaps = list(row.get("gap_fragments") or [])
if row_gaps and latest_gap is None:
latest_gap = row_gaps[0]
if latest_metrics and latest_gap:
break
return _glance_items_from_state(
latest_gap,
latest_metrics,
person_id=person_id,
)
def _owner_name(user) -> str:
return (
user.first_name
@@ -1040,6 +1128,7 @@ def _compose_urls(service, identifier, person_id):
return {
"page_url": f"{reverse('compose_page')}?{payload}",
"widget_url": f"{reverse('compose_widget')}?{payload}",
"workspace_url": f"{reverse('compose_workspace')}?{payload}",
}
@@ -1076,6 +1165,15 @@ def _panel_context(
counterpart_identifiers = _counterpart_identifiers_for_person(
request.user, base["person"]
)
serialized_messages = _serialize_messages_with_artifacts(
session_bundle["messages"],
counterpart_identifiers=counterpart_identifiers,
conversation=conversation,
)
glance_items = _build_glance_items(
serialized_messages,
person_id=(base["person"].id if base["person"] else None),
)
last_ts = 0
if session_bundle["messages"]:
last_ts = int(session_bundle["messages"][-1].ts or 0)
@@ -1106,11 +1204,9 @@ def _panel_context(
"person_identifier": base["person_identifier"],
"session": session_bundle["session"],
"messages": session_bundle["messages"],
"serialized_messages": _serialize_messages_with_artifacts(
session_bundle["messages"],
counterpart_identifiers=counterpart_identifiers,
conversation=conversation,
),
"serialized_messages": serialized_messages,
"glance_items": glance_items,
"glance_items_json": json.dumps(glance_items),
"last_ts": last_ts,
"limit": limit,
"notice_message": notice,
@@ -1118,6 +1214,9 @@ def _panel_context(
"render_mode": render_mode,
"compose_page_url": urls["page_url"],
"compose_widget_url": urls["widget_url"],
"compose_workspace_url": (
f"{urls['workspace_url']}&{urlencode({'limit': limit})}"
),
"compose_drafts_url": reverse("compose_drafts"),
"compose_summary_url": reverse("compose_summary"),
"compose_engage_preview_url": reverse("compose_engage_preview"),
@@ -1137,11 +1236,15 @@ def _panel_context(
class ComposeContactsDropdown(LoginRequiredMixin, View):
def get(self, request):
rows = list(
all_value = str(request.GET.get("all") or "").strip().lower()
fetch_all = all_value in {"1", "true", "yes", "y", "all"}
preview_limit = 5
queryset = (
PersonIdentifier.objects.filter(user=request.user)
.select_related("person")
.order_by("person__name", "service", "identifier")
)
rows = list(queryset) if fetch_all else list(queryset[:preview_limit])
items = []
for row in rows:
urls = _compose_urls(row.service, row.identifier, row.person_id)
@@ -1159,10 +1262,83 @@ class ComposeContactsDropdown(LoginRequiredMixin, View):
{
"items": items,
"manual_icon_class": "fa-solid fa-paper-plane",
"is_preview": not fetch_all,
"fetch_contacts_url": f"{reverse('compose_contacts_dropdown')}?all=1",
},
)
class ComposeWorkspace(LoginRequiredMixin, View):
template_name = "pages/compose-workspace.html"
def get(self, request):
service = _default_service(request.GET.get("service"))
identifier = str(request.GET.get("identifier") or "").strip()
person = None
person_id = request.GET.get("person")
if person_id:
person = Person.objects.filter(id=person_id, user=request.user).first()
limit = _safe_limit(request.GET.get("limit") or 40)
initial_widget_url = ""
if identifier or person is not None:
base = _context_base(request.user, service, identifier, person)
if base["identifier"]:
urls = _compose_urls(
base["service"],
base["identifier"],
base["person"].id if base["person"] else None,
)
initial_widget_url = (
f"{urls['widget_url']}&{urlencode({'limit': limit})}"
)
contacts_widget_url = (
f"{reverse('compose_workspace_contacts_widget')}"
f"?{urlencode({'limit': limit})}"
)
context = {
"contacts_widget_url": contacts_widget_url,
"initial_widget_url": initial_widget_url,
}
return render(request, self.template_name, context)
class ComposeWorkspaceContactsWidget(LoginRequiredMixin, View):
def _contact_rows(self, user):
rows = []
queryset = (
PersonIdentifier.objects.filter(user=user)
.select_related("person")
.order_by("person__name", "service", "identifier")
)
for row in queryset:
urls = _compose_urls(row.service, row.identifier, row.person_id)
rows.append(
{
"person_name": row.person.name,
"service": row.service,
"identifier": row.identifier,
"compose_widget_url": urls["widget_url"],
}
)
return rows
def get(self, request):
limit = _safe_limit(request.GET.get("limit") or 40)
context = {
"title": "Manual Workspace",
"unique": "compose-workspace-contacts",
"window_content": "partials/compose-workspace-contacts-widget.html",
"widget_options": 'gs-w="4" gs-h="14" gs-x="0" gs-y="0" gs-min-w="3"',
"contact_rows": self._contact_rows(request.user),
"limit": limit,
"limit_options": [20, 40, 60, 100, 200],
"manual_icon_class": "fa-solid fa-paper-plane",
}
return render(request, "mixins/wm/widget.html", context)
class ComposePage(LoginRequiredMixin, View):
template_name = "pages/compose.html"
@@ -1704,7 +1880,7 @@ class ComposeEngageSend(LoginRequiredMixin, View):
class ComposeSend(LoginRequiredMixin, View):
@staticmethod
def _response(request, *, ok, message="", level="info"):
def _response(request, *, ok, message="", level="info", panel_id=""):
response = render(
request,
"partials/compose-send-status.html",
@@ -1718,10 +1894,11 @@ class ComposeSend(LoginRequiredMixin, View):
"ok": bool(ok),
"message": str(message or ""),
"level": str(level or "info"),
"panel_id": str(panel_id or ""),
}
}
if ok:
trigger_payload["composeMessageSent"] = True
trigger_payload["composeMessageSent"] = {"panel_id": str(panel_id or "")}
response["HX-Trigger"] = json.dumps(trigger_payload)
return response
@@ -1735,6 +1912,7 @@ class ComposeSend(LoginRequiredMixin, View):
render_mode = str(request.POST.get("render_mode") or "page").strip().lower()
if render_mode not in {"page", "widget"}:
render_mode = "page"
panel_id = str(request.POST.get("panel_id") or "").strip()
if not identifier and person is None:
return HttpResponseBadRequest("Missing contact identifier.")
@@ -1747,6 +1925,7 @@ class ComposeSend(LoginRequiredMixin, View):
ok=False,
message="Enable send confirmation before sending.",
level="warning",
panel_id=panel_id,
)
text = str(request.POST.get("text") or "").strip()
@@ -1756,6 +1935,7 @@ class ComposeSend(LoginRequiredMixin, View):
ok=False,
message="Message is empty.",
level="danger",
panel_id=panel_id,
)
base = _context_base(request.user, service, identifier, person)
@@ -1771,6 +1951,7 @@ class ComposeSend(LoginRequiredMixin, View):
ok=False,
message="Send failed. Check service account state.",
level="danger",
panel_id=panel_id,
)
if base["person_identifier"] is not None:
@@ -1788,4 +1969,10 @@ class ComposeSend(LoginRequiredMixin, View):
custom_author="USER",
)
return self._response(request, ok=True, message="", level="success")
return self._response(
request,
ok=True,
message="",
level="success",
panel_id=panel_id,
)

View File

@@ -495,6 +495,17 @@ INSIGHT_GRAPH_SPECS = [
},
]
INFORMATION_OVERVIEW_SLUGS = (
"platform",
"thread",
"workspace_created",
"stability_state",
"stability_computed",
"commitment_computed",
"last_event",
"last_ai_run",
)
def _format_unix_ms(ts):
if not ts:
@@ -892,6 +903,27 @@ def _all_graph_payload(conversation):
return graphs
def _information_overview_rows(conversation):
latest_snapshot = conversation.metric_snapshots.first()
rows = []
for slug in INFORMATION_OVERVIEW_SLUGS:
spec = INSIGHT_METRICS.get(slug)
if not spec:
continue
rows.append(
{
"slug": slug,
"title": spec.get("title") or slug.replace("_", " ").title(),
"value": _format_metric_value(conversation, slug, latest_snapshot),
"group": spec.get("group"),
"group_title": INSIGHT_GROUPS.get(spec.get("group"), {}).get(
"title", "Information"
),
}
)
return rows
def _commitment_directionality_payload(conversation):
latest_snapshot = conversation.metric_snapshots.first()
inbound = conversation.commitment_inbound_score
@@ -1574,6 +1606,26 @@ def _parse_result_sections(result_text):
def _build_interaction_signals(operation, result_text, message_event_ids):
def _normalize_signal_key(value):
key = re.sub(r"[^a-z0-9]+", "_", str(value or "").strip().lower()).strip("_")
if key == "open_loops":
return "open_loop"
return key
def _signal_display_label(label, key):
if str(label or "").strip():
return str(label).strip().title()
return str(key or "Signal").replace("_", " ").strip().title()
meaning_by_key = {
"repair": "Repair markers suggest active attempts to restore connection.",
"de_escalation": "De-escalation markers suggest pressure reduction and safer tone.",
"open_loop": "Open loops are unresolved topics likely to reappear later.",
"risk": "Risk markers indicate escalating friction or potential rupture points.",
"conflict": "Conflict markers indicate direct tension or adversarial framing.",
"draft_generated": "Draft generation indicates actionable next-step options are available.",
}
text = (result_text or "").lower()
signals = []
heuristics = [
@@ -1585,17 +1637,25 @@ def _build_interaction_signals(operation, result_text, message_event_ids):
]
for label, token, valence in heuristics:
if token in text:
signal_key = _normalize_signal_key(label)
signals.append(
{
"label": label,
"display_label": _signal_display_label(label, signal_key),
"signal_key": signal_key,
"meaning": meaning_by_key.get(signal_key, ""),
"valence": valence,
"message_event_ids": message_event_ids[:6],
}
)
if not signals and operation == "draft_reply":
signal_key = _normalize_signal_key("draft_generated")
signals.append(
{
"label": "draft_generated",
"display_label": _signal_display_label("draft_generated", signal_key),
"signal_key": signal_key,
"meaning": meaning_by_key.get(signal_key, ""),
"valence": "positive",
"message_event_ids": message_event_ids[:3],
}
@@ -1662,12 +1722,26 @@ def _build_memory_proposals(operation, result_text):
def _group_memory_proposals(memory_proposals):
signal_keys_by_kind = {
"open_loops": ["open_loop"],
"emotional_state": ["de_escalation", "repair", "conflict"],
"patterns": ["repair", "conflict"],
"friction_loops": ["conflict", "risk"],
"summary": ["open_loop", "repair", "de_escalation", "conflict", "risk"],
"rules": ["repair", "conflict", "risk"],
"insights": ["open_loop", "repair", "de_escalation", "conflict", "risk"],
}
grouped = {}
for item in memory_proposals or []:
label = str(item.get("kind_label") or item.get("kind") or "Insights").strip()
key = label.lower()
key = str(item.get("kind") or label).strip().lower()
if key not in grouped:
grouped[key] = {"title": label, "items": []}
grouped[key] = {
"title": label,
"key": key,
"signal_keys": list(signal_keys_by_kind.get(key, [])),
"items": [],
}
grouped[key]["items"].append(item)
return list(grouped.values())
@@ -3502,6 +3576,7 @@ class AIWorkspaceInformation(LoginRequiredMixin, View):
"person": person,
"workspace_conversation": conversation,
"directionality": directionality,
"overview_rows": _information_overview_rows(conversation),
"commitment_graph_cards": commitment_graph_cards,
"graphs_url": reverse(
"ai_workspace_insight_graphs",