Renovate mitigation panels and messages
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user