Improve chat experience and begin search implementation

This commit is contained in:
2026-02-15 17:32:26 +00:00
parent 6612274ab9
commit a94bbff655
21 changed files with 3081 additions and 179 deletions

View File

@@ -1,12 +1,15 @@
from __future__ import annotations
import hashlib
import re
import time
from datetime import datetime, timezone as dt_timezone
from urllib.parse import urlencode
from asgiref.sync import async_to_sync
from django.contrib.auth.mixins import LoginRequiredMixin
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
@@ -14,7 +17,21 @@ from django.utils import timezone as dj_timezone
from django.views import View
from core.clients import transport
from core.models import ChatSession, Message, Person, PersonIdentifier
from core.messaging import ai as ai_runner
from core.messaging.utils import messages_to_string
from core.models import (
AI,
ChatSession,
Message,
PatternMitigationPlan,
Person,
PersonIdentifier,
)
from core.views.workspace import _build_engage_payload, _parse_draft_options
COMPOSE_WS_TOKEN_SALT = "compose-ws"
COMPOSE_ENGAGE_TOKEN_SALT = "compose-engage"
COMPOSE_AI_CACHE_TTL = 60 * 30
def _default_service(service: str | None) -> str:
@@ -64,6 +81,185 @@ def _serialize_message(msg: Message) -> dict:
}
def _owner_name(user) -> str:
return (
user.first_name
or user.get_full_name().strip()
or user.username
or "Me"
)
def _compose_ws_token(user_id, service, identifier, person_id):
payload = {
"u": int(user_id),
"s": str(service or ""),
"i": str(identifier or ""),
"p": str(person_id) if person_id else "",
"exp": int(time.time()) + (60 * 60 * 12),
}
return signing.dumps(payload, salt=COMPOSE_WS_TOKEN_SALT)
def _compose_ai_cache_key(kind, user_id, service, identifier, person_id, last_ts, limit):
raw = "|".join(
[
str(kind or ""),
str(user_id),
str(service or ""),
str(identifier or ""),
str(person_id or ""),
str(last_ts or 0),
str(limit or 0),
]
)
digest = hashlib.sha1(raw.encode("utf-8")).hexdigest()
return f"compose:{kind}:{digest}"
def _plain_text(value):
cleaned = re.sub(r"\s+", " ", str(value or "").strip())
cleaned = re.sub(r"^\s*#{1,6}\s*", "", cleaned)
cleaned = re.sub(r"\*\*(.*?)\*\*", r"\1", cleaned)
cleaned = re.sub(r"`(.*?)`", r"\1", cleaned)
return cleaned.strip()
def _engage_body_only(value):
lines = [line.strip() for line in str(value or "").splitlines() if line.strip()]
if lines and lines[0].startswith("**"):
lines = lines[1:]
if lines and lines[0].lower() == "guidance:":
lines = lines[1:]
return _plain_text(" ".join(lines))
def _messages_for_ai(user, person_identifier, limit):
if person_identifier is None:
return []
session, _ = ChatSession.objects.get_or_create(user=user, identifier=person_identifier)
rows = list(
Message.objects.filter(user=user, session=session)
.select_related("session", "session__identifier", "session__identifier__person")
.order_by("-ts")[:limit]
)
rows.reverse()
return rows
def _fallback_drafts():
return [
{
"label": "Soft",
"text": "I want us to stay connected. I am listening and I want to understand your perspective clearly.",
},
{
"label": "Neutral",
"text": "I hear your point. Let us clarify what each of us means so we can move forward constructively.",
},
{
"label": "Firm",
"text": "I want to resolve this respectfully. I will continue when we can keep the conversation constructive.",
},
]
def _build_draft_prompt(owner_name, person_name, transcript):
return [
{
"role": "system",
"content": (
"Generate exactly three short reply drafts for a chat. "
"Return labels Soft, Neutral, Firm. "
"Format:\nSoft: ...\nNeutral: ...\nFirm: ...\n"
"Each draft must be one to two sentences, plain text, no markdown."
),
},
{
"role": "user",
"content": (
f"Me: {owner_name}\n"
f"Other: {person_name}\n"
f"Conversation:\n{transcript}"
),
},
]
def _build_summary_prompt(owner_name, person_name, transcript):
return [
{
"role": "system",
"content": (
"Create a concise conversation summary with three sections. "
"Use this exact structure:\n"
"Headlines:\n- ...\n"
"Patterns:\n- ...\n"
"Suggested Next Message:\n- ...\n"
"Keep each bullet practical and specific."
),
},
{
"role": "user",
"content": (
f"Me: {owner_name}\n"
f"Other: {person_name}\n"
f"Conversation:\n{transcript}"
),
},
]
def _build_engage_prompt(owner_name, person_name, transcript):
return [
{
"role": "system",
"content": (
"Write one short de-escalating outreach in shared framing. "
"Use 'we/us/our' only. No names. One or two sentences."
),
},
{
"role": "user",
"content": (
f"Me: {owner_name}\n"
f"Other: {person_name}\n"
f"Conversation:\n{transcript}"
),
},
]
def _latest_plan_for_person(user, person):
if person is None:
return None
conversation = (
PatternMitigationPlan.objects.filter(
user=user,
conversation__participants=person,
)
.select_related("conversation")
.order_by("-updated_at")
.first()
)
return conversation
def _best_engage_source(plan):
if plan is None:
return (None, "")
correction = plan.corrections.order_by("-created_at").first()
if correction:
return (correction, "correction")
rule = plan.rules.order_by("-created_at").first()
if rule:
return (rule, "rule")
game = plan.games.order_by("-created_at").first()
if game:
return (game, "game")
return (None, "")
def _context_base(user, service, identifier, person):
person_identifier = None
if person is not None:
@@ -143,6 +339,13 @@ def _panel_context(
base["identifier"],
base["person"].id if base["person"] else None,
)
ws_token = _compose_ws_token(
user_id=request.user.id,
service=base["service"],
identifier=base["identifier"],
person_id=base["person"].id if base["person"] else None,
)
ws_url = f"/ws/compose/thread/?{urlencode({'token': ws_token})}"
unique_raw = f"{base['service']}|{base['identifier']}|{request.user.id}"
unique = hashlib.sha1(unique_raw.encode("utf-8")).hexdigest()[:12]
@@ -164,6 +367,11 @@ def _panel_context(
"render_mode": render_mode,
"compose_page_url": urls["page_url"],
"compose_widget_url": urls["widget_url"],
"compose_drafts_url": reverse("compose_drafts"),
"compose_summary_url": reverse("compose_summary"),
"compose_engage_preview_url": reverse("compose_engage_preview"),
"compose_engage_send_url": reverse("compose_engage_send"),
"compose_ws_url": ws_url,
"ai_workspace_url": (
f"{reverse('ai_workspace')}?person={base['person'].id}"
if base["person"]
@@ -305,6 +513,285 @@ class ComposeThread(LoginRequiredMixin, View):
return JsonResponse(payload)
class ComposeDrafts(LoginRequiredMixin, View):
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 = get_object_or_404(Person, id=person_id, user=request.user)
if not identifier and person is None:
return JsonResponse({"ok": False, "error": "Missing contact identifier."})
base = _context_base(request.user, service, identifier, person)
limit = _safe_limit(request.GET.get("limit") or 60)
messages = _messages_for_ai(request.user, base["person_identifier"], limit)
if not messages:
return JsonResponse(
{
"ok": True,
"cached": False,
"drafts": _fallback_drafts(),
}
)
last_ts = int(messages[-1].ts or 0)
cache_key = _compose_ai_cache_key(
"drafts",
request.user.id,
base["service"],
base["identifier"],
base["person"].id if base["person"] else "",
last_ts,
limit,
)
cached = cache.get(cache_key)
if cached:
return JsonResponse({"ok": True, "cached": True, "drafts": cached})
ai_obj = AI.objects.filter(user=request.user).first()
transcript = messages_to_string(
messages,
author_rewrites={
"USER": _owner_name(request.user),
"BOT": "Assistant",
},
)
drafts = _fallback_drafts()
if ai_obj is not None:
try:
result = async_to_sync(ai_runner.run_prompt)(
_build_draft_prompt(
owner_name=_owner_name(request.user),
person_name=base["person"].name if base["person"] else "Other",
transcript=transcript,
),
ai_obj,
)
parsed = _parse_draft_options(result)
if parsed:
drafts = parsed
except Exception:
pass
cache.set(cache_key, drafts, timeout=COMPOSE_AI_CACHE_TTL)
return JsonResponse({"ok": True, "cached": False, "drafts": drafts})
class ComposeSummary(LoginRequiredMixin, View):
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 = get_object_or_404(Person, id=person_id, user=request.user)
if not identifier and person is None:
return JsonResponse({"ok": False, "error": "Missing contact identifier."})
base = _context_base(request.user, service, identifier, person)
limit = _safe_limit(request.GET.get("limit") or 60)
messages = _messages_for_ai(request.user, base["person_identifier"], limit)
if not messages:
return JsonResponse({"ok": True, "cached": False, "summary": ""})
last_ts = int(messages[-1].ts or 0)
cache_key = _compose_ai_cache_key(
"summary",
request.user.id,
base["service"],
base["identifier"],
base["person"].id if base["person"] else "",
last_ts,
limit,
)
cached = cache.get(cache_key)
if cached:
return JsonResponse({"ok": True, "cached": True, "summary": cached})
ai_obj = AI.objects.filter(user=request.user).first()
transcript = messages_to_string(
messages,
author_rewrites={
"USER": _owner_name(request.user),
"BOT": "Assistant",
},
)
if ai_obj is None:
fallback = (
"Headlines:\n"
"- Conversation loaded.\n"
"Patterns:\n"
"- Not enough AI context configured yet.\n"
"Suggested Next Message:\n"
"- I want us to keep this clear and constructive."
)
cache.set(cache_key, fallback, timeout=COMPOSE_AI_CACHE_TTL)
return JsonResponse({"ok": True, "cached": False, "summary": fallback})
try:
summary = async_to_sync(ai_runner.run_prompt)(
_build_summary_prompt(
owner_name=_owner_name(request.user),
person_name=base["person"].name if base["person"] else "Other",
transcript=transcript,
),
ai_obj,
)
except Exception as exc:
return JsonResponse({"ok": False, "error": str(exc)})
summary = str(summary or "").strip()
cache.set(cache_key, summary, timeout=COMPOSE_AI_CACHE_TTL)
return JsonResponse({"ok": True, "cached": False, "summary": summary})
class ComposeEngagePreview(LoginRequiredMixin, View):
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 = get_object_or_404(Person, id=person_id, user=request.user)
if not identifier and person is None:
return JsonResponse({"ok": False, "error": "Missing contact identifier."})
base = _context_base(request.user, service, identifier, person)
limit = _safe_limit(request.GET.get("limit") or 60)
messages = _messages_for_ai(request.user, base["person_identifier"], limit)
transcript = messages_to_string(
messages,
author_rewrites={
"USER": _owner_name(request.user),
"BOT": "Assistant",
},
)
owner_name = _owner_name(request.user)
recipient_name = base["person"].name if base["person"] else "Other"
plan = _latest_plan_for_person(request.user, base["person"])
source_obj, source_kind = _best_engage_source(plan)
preview = ""
outbound = ""
artifact_label = "AI-generated"
if source_obj is not None:
payload = _build_engage_payload(
source_obj=source_obj,
source_kind=source_kind,
share_target="other",
framing="shared",
context_note="",
owner_name=owner_name,
recipient_name=recipient_name,
)
preview = str(payload.get("preview") or "").strip()
outbound = _engage_body_only(payload.get("outbound") or "")
artifact_label = f"{source_kind.title()}: {getattr(source_obj, 'title', '')}"
else:
ai_obj = AI.objects.filter(user=request.user).first()
if ai_obj is not None:
try:
generated = async_to_sync(ai_runner.run_prompt)(
_build_engage_prompt(owner_name, recipient_name, transcript),
ai_obj,
)
outbound = _plain_text(generated)
except Exception:
outbound = ""
if not outbound:
outbound = (
"We should slow down, clarify what we mean, and respond with care."
)
preview = f"**Shared Engage** (Correction)\n\nGuidance:\n{outbound}"
token = signing.dumps(
{
"u": request.user.id,
"s": base["service"],
"i": base["identifier"],
"p": str(base["person"].id) if base["person"] else "",
"outbound": outbound,
"exp": int(time.time()) + (60 * 10),
},
salt=COMPOSE_ENGAGE_TOKEN_SALT,
)
return JsonResponse(
{
"ok": True,
"preview": preview,
"outbound": outbound,
"token": token,
"artifact": artifact_label,
}
)
class ComposeEngageSend(LoginRequiredMixin, View):
def post(self, request):
service = _default_service(request.POST.get("service"))
identifier = str(request.POST.get("identifier") or "").strip()
person = None
person_id = request.POST.get("person")
if person_id:
person = get_object_or_404(Person, id=person_id, user=request.user)
if not identifier and person is None:
return JsonResponse({"ok": False, "error": "Missing contact identifier."})
failsafe_arm = str(request.POST.get("failsafe_arm") or "").strip()
failsafe_confirm = str(request.POST.get("failsafe_confirm") or "").strip()
if failsafe_arm != "1" or failsafe_confirm != "1":
return JsonResponse(
{"ok": False, "error": "Enable both send safety switches first."}
)
token = str(request.POST.get("engage_token") or "").strip()
if not token:
return JsonResponse({"ok": False, "error": "Missing engage token."})
try:
payload = signing.loads(token, salt=COMPOSE_ENGAGE_TOKEN_SALT)
except Exception:
return JsonResponse({"ok": False, "error": "Invalid engage token."})
if int(payload.get("u") or 0) != int(request.user.id):
return JsonResponse({"ok": False, "error": "Token does not match user."})
if int(payload.get("exp") or 0) < int(time.time()):
return JsonResponse({"ok": False, "error": "Engage token expired."})
outbound = str(payload.get("outbound") or "").strip()
if not outbound:
return JsonResponse({"ok": False, "error": "Empty engage payload."})
base = _context_base(request.user, service, identifier, person)
ts = async_to_sync(transport.send_message_raw)(
base["service"],
base["identifier"],
text=outbound,
attachments=[],
)
if not ts:
return JsonResponse({"ok": False, "error": "Send failed."})
if base["person_identifier"] is not None:
session, _ = ChatSession.objects.get_or_create(
user=request.user,
identifier=base["person_identifier"],
)
ts_value = int(ts) if str(ts).isdigit() else int(time.time() * 1000)
Message.objects.create(
user=request.user,
session=session,
sender_uuid="",
text=outbound,
ts=ts_value,
delivered_ts=ts_value if str(ts).isdigit() else None,
custom_author="USER",
)
return JsonResponse({"ok": True, "message": "Shared engage sent."})
class ComposeSend(LoginRequiredMixin, View):
def post(self, request):
service = _default_service(request.POST.get("service"))
@@ -320,6 +807,18 @@ class ComposeSend(LoginRequiredMixin, View):
if not identifier and person is None:
return HttpResponseBadRequest("Missing contact identifier.")
failsafe_arm = str(request.POST.get("failsafe_arm") or "").strip()
failsafe_confirm = str(request.POST.get("failsafe_confirm") or "").strip()
if failsafe_arm != "1" or failsafe_confirm != "1":
return render(
request,
"partials/compose-send-status.html",
{
"notice_message": "Enable both send safety switches before sending.",
"notice_level": "warning",
},
)
text = str(request.POST.get("text") or "").strip()
if not text:
return render(

View File

@@ -1,15 +1,16 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from mixins.views import ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate
from mixins.views import ObjectCreate, ObjectDelete, ObjectUpdate
from core.forms import GroupForm
from core.models import Group
from core.views.osint import OSINTListBase
from core.util import logs
log = logs.get_logger(__name__)
class GroupList(LoginRequiredMixin, ObjectList):
list_template = "partials/group-list.html"
class GroupList(LoginRequiredMixin, OSINTListBase):
osint_scope = "groups"
model = Group
page_title = "Groups"

View File

@@ -1,15 +1,16 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from mixins.views import ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate
from mixins.views import ObjectCreate, ObjectDelete, ObjectUpdate
from core.forms import ManipulationForm
from core.models import Manipulation
from core.views.osint import OSINTListBase
from core.util import logs
log = logs.get_logger(__name__)
class ManipulationList(LoginRequiredMixin, ObjectList):
list_template = "partials/manipulation-list.html"
class ManipulationList(LoginRequiredMixin, OSINTListBase):
osint_scope = "manipulations"
model = Manipulation
page_title = "Manipulations"

1013
core/views/osint.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,18 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from mixins.views import ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate
from mixins.views import ObjectCreate, ObjectDelete, ObjectUpdate
from core.forms import PersonForm
from core.models import Person
from core.views.osint import OSINTListBase
from core.util import logs
log = logs.get_logger(__name__)
class PersonList(LoginRequiredMixin, ObjectList):
list_template = "partials/person-list.html"
class PersonList(LoginRequiredMixin, OSINTListBase):
osint_scope = "people"
model = Person
page_title = "People"
# page_subtitle = "Add times here in order to permit trading."
list_url_name = "people"
list_url_args = ["type"]

View File

@@ -1,15 +1,16 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from mixins.views import ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate
from mixins.views import ObjectCreate, ObjectDelete, ObjectUpdate
from core.forms import PersonaForm
from core.models import Persona
from core.views.osint import OSINTListBase
from core.util import logs
log = logs.get_logger(__name__)
class PersonaList(LoginRequiredMixin, ObjectList):
list_template = "partials/persona-list.html"
class PersonaList(LoginRequiredMixin, OSINTListBase):
osint_scope = "personas"
model = Persona
page_title = "Personas"

View File

@@ -1,29 +1,94 @@
from django.conf import settings
from django.shortcuts import render
from django.views import View
from mixins.views import ObjectList, ObjectRead
from core.clients import transport
from core.views.signal import Signal, SignalAccountAdd, SignalAccounts
from core.views.manage.permissions import SuperUserRequiredMixin
class WhatsApp(Signal):
class WhatsApp(SuperUserRequiredMixin, View):
template_name = "pages/signal.html"
service = "whatsapp"
page_title = "WhatsApp"
accounts_url_name = "whatsapp_accounts"
def get(self, request):
return render(
request,
self.template_name,
{
"service": self.service,
"service_label": self.page_title,
"accounts_url_name": self.accounts_url_name,
},
)
class WhatsAppAccounts(SignalAccounts):
class WhatsAppAccounts(SuperUserRequiredMixin, ObjectList):
list_template = "partials/signal-accounts.html"
service = "whatsapp"
context_object_name_singular = "WhatsApp Account"
context_object_name = "WhatsApp Accounts"
list_url_name = "whatsapp_accounts"
list_url_args = ["type"]
def _normalize_accounts(self, rows):
out = []
for item in rows or []:
if isinstance(item, dict):
value = (
item.get("number")
or item.get("id")
or item.get("jid")
or item.get("account")
)
if value:
out.append(str(value))
elif item:
out.append(str(item))
return out
def get_queryset(self, **kwargs):
self.extra_context = self._service_context(
service="whatsapp",
label="WhatsApp",
add_url_name="whatsapp_account_add",
show_contact_actions=False,
)
self.extra_context = {
"service": "whatsapp",
"service_label": "WhatsApp",
"account_add_url_name": "whatsapp_account_add",
"show_contact_actions": False,
"endpoint_base": str(
getattr(settings, "WHATSAPP_HTTP_URL", "http://whatsapp:8080")
).rstrip("/"),
"service_warning": transport.get_service_warning("whatsapp"),
}
return self._normalize_accounts(transport.list_accounts("whatsapp"))
class WhatsAppAccountAdd(SignalAccountAdd):
class WhatsAppAccountAdd(SuperUserRequiredMixin, ObjectRead):
detail_template = "partials/whatsapp-account-add.html"
service = "whatsapp"
context_object_name_singular = "Add Account"
context_object_name = "Add Account"
detail_url_name = "whatsapp_account_add"
detail_url_args = ["type", "device"]
def post(self, request, *args, **kwargs):
self.request = request
return super().get(request, *args, **kwargs)
def get_object(self, **kwargs):
form_args = self.request.POST.dict()
device_name = form_args.get("device", "GIA Device")
try:
image_bytes = transport.get_link_qr(self.service, device_name)
return {
"ok": True,
"image_b64": transport.image_bytes_to_base64(image_bytes),
"warning": transport.get_service_warning(self.service),
}
except Exception as exc:
return {
"ok": False,
"error": str(exc),
"warning": transport.get_service_warning(self.service),
}

View File

@@ -155,6 +155,55 @@ INSIGHT_METRICS = {
"values often precede misunderstandings or withdrawal cycles."
),
},
"reciprocity_score": {
"title": "Reciprocity Component",
"group": "stability",
"history_field": "reciprocity_score",
"calculation": (
"100 * (1 - |inbound - outbound| / total_messages). Higher means "
"more balanced participation."
),
"psychology": (
"Lower reciprocity can reflect perceived asymmetry and rising pursuit/"
"withdraw cycles."
),
},
"continuity_score": {
"title": "Continuity Component",
"group": "stability",
"history_field": "continuity_score",
"calculation": (
"100 * min(1, distinct_sample_days / span_days). Higher means steadier "
"day-to-day continuity."
),
"psychology": (
"Drops can signal communication becoming episodic or reactive."
),
},
"response_score": {
"title": "Response Component",
"group": "stability",
"history_field": "response_score",
"calculation": (
"Average of inbound and outbound response-lag scores, each mapped from "
"median lag to a 0-100 curve."
),
"psychology": (
"Lower response score can indicate delayed repair loops during tension."
),
},
"volatility_score": {
"title": "Volatility Component",
"group": "stability",
"history_field": "volatility_score",
"calculation": (
"Derived from coefficient of variation of daily message counts and "
"inverted to a 0-100 stability signal."
),
"psychology": (
"High volatility can suggest inconsistent rhythm and reduced predictability."
),
},
"stability_confidence": {
"title": "Stability Confidence",
"group": "confidence",
@@ -219,6 +268,52 @@ INSIGHT_METRICS = {
"Estimates user follow-through and consistency toward the counterpart."
),
},
"inbound_response_score": {
"title": "Inbound Response Score",
"group": "commitment",
"history_field": "inbound_response_score",
"calculation": (
"Response-speed score built from median lag between user outbound and "
"counterpart inbound replies."
),
"psychology": (
"Lower values suggest delayed reciprocity from counterpart direction."
),
},
"outbound_response_score": {
"title": "Outbound Response Score",
"group": "commitment",
"history_field": "outbound_response_score",
"calculation": (
"Response-speed score built from median lag between counterpart inbound "
"and user outbound replies."
),
"psychology": "Lower values suggest slower follow-through from user direction.",
},
"balance_inbound_score": {
"title": "Inbound Balance Score",
"group": "commitment",
"history_field": "balance_inbound_score",
"calculation": (
"100 * min(1, inbound_messages / outbound_messages). Captures inbound "
"participation parity."
),
"psychology": (
"Lower values can indicate one-sided conversational load from user side."
),
},
"balance_outbound_score": {
"title": "Outbound Balance Score",
"group": "commitment",
"history_field": "balance_outbound_score",
"calculation": (
"100 * min(1, outbound_messages / inbound_messages). Captures outbound "
"participation parity."
),
"psychology": (
"Lower values can indicate one-sided conversational load from counterpart side."
),
},
"commitment_confidence": {
"title": "Commit Confidence",
"group": "confidence",
@@ -334,6 +429,78 @@ INSIGHT_GRAPH_SPECS = [
"y_min": 0,
"y_max": None,
},
{
"slug": "reciprocity_score",
"title": "Reciprocity Component",
"field": "reciprocity_score",
"group": "stability",
"y_min": 0,
"y_max": 100,
},
{
"slug": "continuity_score",
"title": "Continuity Component",
"field": "continuity_score",
"group": "stability",
"y_min": 0,
"y_max": 100,
},
{
"slug": "response_score",
"title": "Response Component",
"field": "response_score",
"group": "stability",
"y_min": 0,
"y_max": 100,
},
{
"slug": "volatility_score",
"title": "Volatility Component",
"field": "volatility_score",
"group": "stability",
"y_min": 0,
"y_max": 100,
},
{
"slug": "inbound_response_score",
"title": "Inbound Response Score",
"field": "inbound_response_score",
"group": "commitment",
"y_min": 0,
"y_max": 100,
},
{
"slug": "outbound_response_score",
"title": "Outbound Response Score",
"field": "outbound_response_score",
"group": "commitment",
"y_min": 0,
"y_max": 100,
},
{
"slug": "balance_inbound_score",
"title": "Inbound Balance Score",
"field": "balance_inbound_score",
"group": "commitment",
"y_min": 0,
"y_max": 100,
},
{
"slug": "balance_outbound_score",
"title": "Outbound Balance Score",
"field": "balance_outbound_score",
"group": "commitment",
"y_min": 0,
"y_max": 100,
},
{
"slug": "last_event",
"title": "Last Event Timestamp",
"field": "source_event_ts",
"group": "timeline",
"y_min": None,
"y_max": None,
},
]
@@ -487,7 +654,10 @@ def _to_float(value):
return float(value)
def _format_metric_value(conversation, metric_slug):
def _format_metric_value(conversation, metric_slug, latest_snapshot=None):
snapshot = latest_snapshot
if snapshot is None:
snapshot = conversation.metric_snapshots.first()
if metric_slug == "platform":
return conversation.get_platform_type_display() or "-"
if metric_slug == "thread":
@@ -498,6 +668,14 @@ def _format_metric_value(conversation, metric_slug):
return conversation.get_stability_state_display()
if metric_slug == "stability_score":
return conversation.stability_score
if metric_slug == "reciprocity_score":
return snapshot.reciprocity_score if snapshot else None
if metric_slug == "continuity_score":
return snapshot.continuity_score if snapshot else None
if metric_slug == "response_score":
return snapshot.response_score if snapshot else None
if metric_slug == "volatility_score":
return snapshot.volatility_score if snapshot else None
if metric_slug == "stability_confidence":
return conversation.stability_confidence
if metric_slug == "sample_messages":
@@ -510,6 +688,14 @@ def _format_metric_value(conversation, metric_slug):
return conversation.commitment_inbound_score
if metric_slug == "commitment_outbound":
return conversation.commitment_outbound_score
if metric_slug == "inbound_response_score":
return snapshot.inbound_response_score if snapshot else None
if metric_slug == "outbound_response_score":
return snapshot.outbound_response_score if snapshot else None
if metric_slug == "balance_inbound_score":
return snapshot.balance_inbound_score if snapshot else None
if metric_slug == "balance_outbound_score":
return snapshot.balance_outbound_score if snapshot else None
if metric_slug == "commitment_confidence":
return conversation.commitment_confidence
if metric_slug == "commitment_computed":
@@ -2713,7 +2899,8 @@ class AIWorkspaceInsightDetail(LoginRequiredMixin, View):
person = get_object_or_404(Person, pk=person_id, user=request.user)
conversation = _conversation_for_person(request.user, person)
value = _format_metric_value(conversation, metric)
latest_snapshot = conversation.metric_snapshots.first()
value = _format_metric_value(conversation, metric, latest_snapshot)
group = INSIGHT_GROUPS[spec["group"]]
points = []
if spec["history_field"]:
@@ -2773,6 +2960,7 @@ class AIWorkspaceInsightHelp(LoginRequiredMixin, View):
person = get_object_or_404(Person, pk=person_id, user=request.user)
conversation = _conversation_for_person(request.user, person)
latest_snapshot = conversation.metric_snapshots.first()
metrics = []
for slug, spec in INSIGHT_METRICS.items():
metrics.append(
@@ -2783,7 +2971,11 @@ class AIWorkspaceInsightHelp(LoginRequiredMixin, View):
"group_title": INSIGHT_GROUPS[spec["group"]]["title"],
"calculation": spec["calculation"],
"psychology": spec["psychology"],
"value": _format_metric_value(conversation, slug),
"value": _format_metric_value(
conversation,
slug,
latest_snapshot,
),
}
)