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

2
.gitignore vendored
View File

@@ -170,5 +170,7 @@ node_modules/
.codex-cli/
.ship-safe/
.uwsgi-reload
mixins/static/
vendor/django-crud-mixins/mixins/static/
.container-home/

View File

@@ -342,11 +342,6 @@ urlpatterns = [
compose.ComposeSummary.as_view(),
name="compose_summary",
),
path(
"compose/quick-insights/",
compose.ComposeQuickInsights.as_view(),
name="compose_quick_insights",
),
path(
"compose/engage/preview/",
compose.ComposeEngagePreview.as_view(),

View File

@@ -96,12 +96,12 @@
.compose-shell .compose-thread {
display: flex;
flex-direction: column;
gap: 0.75rem;
gap: 0.35rem;
flex: 1 1 auto;
min-height: 0;
max-height: none;
overflow-y: auto;
padding: 0.75rem;
padding: 0.5rem 0.625rem;
border: 1px solid var(--bulma-border, #dbdbdb);
background: var(--bulma-scheme-main-bis, #f7f8fa);
}
@@ -112,10 +112,19 @@
font-size: 0.875rem;
}
.compose-shell .compose-history-loader {
margin: 0;
text-align: center;
}
.compose-shell .compose-history-loader.is-hidden {
display: none;
}
.compose-shell .compose-row {
display: flex;
flex-direction: column;
gap: 0.35rem;
gap: 0.15rem;
}
.compose-shell .compose-row.is-in {
@@ -137,10 +146,10 @@
.compose-shell .compose-bubble {
width: fit-content;
max-width: min(42rem, 100%);
padding: 0.75rem 0.875rem;
max-width: min(38rem, 100%);
padding: 0.4rem 0.55rem;
border: 1px solid var(--bulma-border, #dbdbdb);
border-radius: 1rem;
border-radius: 0.8rem;
background: var(--bulma-scheme-main, #fff);
}
@@ -150,8 +159,8 @@
}
.compose-shell .compose-reply-ref {
margin-bottom: 0.5rem;
padding-left: 0.75rem;
margin-bottom: 0.3rem;
padding-left: 0.45rem;
border-left: 3px solid var(--bulma-border, #dbdbdb);
}
@@ -160,7 +169,7 @@
border: 0;
background: transparent;
color: var(--bulma-link, #3273dc);
font-size: 0.75rem;
font-size: 0.6875rem;
text-align: left;
}
@@ -168,35 +177,26 @@
text-decoration: underline;
}
.compose-shell .compose-source-badge-wrap {
margin-bottom: 0.5rem;
}
.compose-shell .compose-source-badge {
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.02em;
}
.compose-shell .compose-media {
margin: 0 0 0.5rem;
margin: 0 0 0.3rem;
}
.compose-shell .compose-media:last-of-type {
margin-bottom: 0.625rem;
margin-bottom: 0.35rem;
}
.compose-shell .compose-image {
display: block;
max-width: min(26rem, 100%);
max-height: 24rem;
border-radius: 0.75rem;
max-height: 22rem;
border-radius: 0.6rem;
}
.compose-shell .compose-body {
margin: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
line-height: 1.28;
}
.compose-shell .compose-image-fallback.is-hidden {
@@ -209,11 +209,11 @@
.compose-shell .compose-reactions + .compose-msg-meta,
.compose-shell .compose-edit-history + .compose-reactions,
.compose-shell .compose-edit-history + .compose-msg-meta {
margin-top: 0.5rem;
margin-top: 0.3rem;
}
.compose-shell .compose-edit-history {
font-size: 0.75rem;
font-size: 0.6875rem;
}
.compose-shell .compose-edit-history summary {
@@ -221,15 +221,15 @@
}
.compose-shell .compose-edit-history ul {
margin: 0.5rem 0 0;
margin: 0.35rem 0 0;
padding-left: 1rem;
}
.compose-shell .compose-edit-diff {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.25rem;
gap: 0.25rem;
margin-top: 0.15rem;
}
.compose-shell .compose-edit-old {
@@ -244,21 +244,21 @@
.compose-shell .compose-reactions {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
gap: 0.2rem;
}
.compose-shell .compose-reaction-chip {
min-height: 1.7rem;
font-size: 0.875rem;
min-height: 1.45rem;
font-size: 0.75rem;
}
.compose-shell .compose-msg-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
margin-top: 0.5rem;
font-size: 0.75rem;
gap: 0.25rem;
margin-top: 0.3rem;
font-size: 0.6875rem;
}
.compose-shell .compose-msg-flag {
@@ -275,7 +275,30 @@
}
.compose-shell .compose-reply-btn {
margin-top: 0.5rem;
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-top: 0.25rem;
min-height: 1.75rem;
padding-inline: 0.45rem;
}
@media (hover: hover) {
.compose-shell .compose-row .compose-reply-btn {
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 0.12s ease-in-out;
}
.compose-shell .compose-row:hover .compose-reply-btn,
.compose-shell .compose-row.compose-reply-selected .compose-reply-btn,
.compose-shell .compose-row:focus-within .compose-reply-btn,
.compose-shell .compose-reply-btn:focus-visible {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
}
.compose-shell .compose-form {

View File

@@ -130,6 +130,70 @@ body .has-text-grey-light {
color: var(--gia-text);
}
.gia-split-dropdown .dropdown-menu {
min-width: 18rem;
}
.gia-dropdown-nest {
border-top: 1px solid var(--gia-border);
}
.gia-dropdown-nest summary {
list-style: none;
cursor: pointer;
}
.gia-dropdown-nest summary::-webkit-details-marker {
display: none;
}
.gia-dropdown-nest-body {
padding: 0.25rem 0 0.35rem;
}
.gia-dropdown-nest-body .dropdown-item {
padding-left: 1.5rem;
}
.gia-inline-tabs ul {
border-bottom: 0;
}
.gia-behavior-shell {
min-width: 0;
}
.gia-behavior-summary-card,
.gia-behavior-graph-card {
height: 100%;
}
.gia-behavior-chart-shell {
min-height: 11rem;
}
.gia-behavior-chart {
display: block;
width: 100%;
height: 11rem;
}
.gia-behavior-chart-area {
fill: color-mix(in srgb, var(--bulma-link) 16%, transparent);
}
.gia-behavior-chart-line {
fill: none;
stroke: var(--bulma-link);
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
}
.gia-behavior-chart-point {
fill: var(--bulma-link);
}
.input,
.textarea,
.select select {
@@ -554,6 +618,16 @@ html.gia-has-workspace-root {
box-shadow: 0 0 0 2px rgba(50, 115, 220, 0.16);
}
.grid-stack-item.is-gia-anchor .gia-widget-panel {
border-color: rgba(255, 159, 28, 0.55);
box-shadow: 0 0 0 2px rgba(255, 159, 28, 0.12);
}
.grid-stack-item.is-gia-spawned .gia-widget-panel {
border-color: rgba(72, 199, 142, 0.6);
box-shadow: 0 0 0 3px rgba(72, 199, 142, 0.18);
}
.floating-window {
max-height: 300px;
z-index: 9000;
@@ -740,10 +814,11 @@ html.gia-has-workspace-root {
.gia-send-composer {
margin: 0;
padding: 0.75rem;
border: 1px solid var(--bulma-border, #dbdbdb);
border-radius: 0.875rem;
background: var(--bulma-scheme-main-bis, #f7f8fa);
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.gia-send-composer-row {
@@ -756,11 +831,12 @@ html.gia-has-workspace-root {
}
.gia-send-composer-input {
min-height: 2.75rem;
max-height: 8rem;
min-height: 2.5rem;
max-height: 7rem;
resize: none;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
box-shadow: none;
}
.gia-send-composer-action {
@@ -769,7 +845,7 @@ html.gia-has-workspace-root {
.gia-send-composer-button {
height: 100%;
min-height: 2.75rem;
min-height: 2.5rem;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}

View File

@@ -18,6 +18,7 @@
const replyBanner = config.replyBanner;
const replyBannerText = config.replyBannerText;
const replyClearBtn = config.replyClearBtn;
const historyLoader = config.historyLoader;
const platformSelect = config.platformSelect;
const contactSelect = config.contactSelect;
const hiddenService = config.hiddenService;
@@ -29,6 +30,9 @@
let lastTs = core.toInt(thread.dataset.lastTs);
let beforeContextReset = null;
state.loadingOlder = false;
state.olderExhausted = false;
const nearBottom = function () {
return thread.scrollHeight - thread.scrollTop - thread.clientHeight < 120;
};
@@ -39,6 +43,21 @@
}
};
const setHistoryLoader = function (message, hidden) {
if (!historyLoader) {
return;
}
historyLoader.textContent = String(
message || "Scroll up to load older messages."
);
historyLoader.classList.toggle("is-hidden", !!hidden);
};
const getOldestTs = function () {
const firstRow = thread.querySelector(".compose-row");
return core.toInt(firstRow && firstRow.dataset ? firstRow.dataset.ts : 0);
};
const queryParams = function (extraParams) {
const params = new URLSearchParams();
params.set("service", thread.dataset.service || "");
@@ -204,8 +223,28 @@
});
if (rows.length) {
scrollToBottom(shouldStick);
setHistoryLoader("", false);
}
ensureEmptyState();
if (!thread.querySelector(".compose-row")) {
setHistoryLoader("", true);
}
};
const prependMessageHtml = function (html) {
const rows = parseMessageRows(html);
if (!rows.length) {
return 0;
}
const previousHeight = thread.scrollHeight;
const previousTop = thread.scrollTop;
rows.forEach(function (msg) {
upsertMessageRow(msg);
});
thread.scrollTop = previousTop + (thread.scrollHeight - previousHeight);
setHistoryLoader("", false);
ensureEmptyState();
return rows.length;
};
const applyTyping = function (payload) {
@@ -267,6 +306,47 @@
}
};
const loadOlder = async function () {
if (state.loadingOlder || state.olderExhausted) {
return;
}
const oldestTs = getOldestTs();
if (!oldestTs) {
state.olderExhausted = true;
setHistoryLoader("Start of conversation.", false);
return;
}
state.loadingOlder = true;
setHistoryLoader("Loading older messages...", false);
try {
const response = await fetch(
thread.dataset.pollUrl + "?" + queryParams({ before_ts: String(oldestTs) }),
{
method: "GET",
credentials: "same-origin",
headers: { Accept: "application/json" },
}
);
if (!response.ok) {
setHistoryLoader("Could not load older messages.", false);
return;
}
const payload = await response.json();
const inserted = prependMessageHtml(payload.messages_html || "");
state.olderExhausted = !payload.has_older || inserted === 0;
setHistoryLoader(
state.olderExhausted
? "Start of conversation."
: "Scroll up to load older messages.",
false
);
} catch (_err) {
setHistoryLoader("Could not load older messages.", false);
} finally {
state.loadingOlder = false;
}
};
const setupWebSocket = function () {
const wsPath = String(thread.dataset.wsUrl || "").trim();
if (!wsPath || !window.WebSocket) {
@@ -359,8 +439,14 @@
clearReplyTarget();
closeSocket();
lastTs = 0;
state.loadingOlder = false;
state.olderExhausted = false;
thread.dataset.lastTs = "0";
thread.innerHTML = "";
if (historyLoader) {
thread.appendChild(historyLoader);
}
setHistoryLoader("Loading recent messages...", false);
ensureEmptyState("Loading messages...");
applyTyping({ typing: false });
poll(true);
@@ -466,6 +552,12 @@
setReplyTarget(row.dataset.messageId || "", row.dataset.replySnippet || "");
textarea.focus();
});
thread.addEventListener("scroll", function () {
if (thread.scrollTop <= 48) {
loadOlder();
}
});
};
const init = function () {
@@ -473,6 +565,7 @@
bindContextSelectors();
applyTyping(core.parseJsonSafe(panel.dataset.initialTyping || "{}", {}));
ensureEmptyState();
setHistoryLoader("", !thread.querySelector(".compose-row"));
scrollToBottom(true);
setupWebSocket();
@@ -492,6 +585,9 @@
init: init,
poll: poll,
queryParams: queryParams,
scrollToLatest: function () {
scrollToBottom(true);
},
setBeforeContextReset: function (callback) {
beforeContextReset = callback;
},

View File

@@ -119,6 +119,7 @@
const threadController = threadModule.createController({
contactSelect: document.getElementById(panelId + "-contact-select"),
hiddenIdentifier: document.getElementById(panelId + "-input-identifier"),
historyLoader: document.getElementById(panelId + "-history-loader"),
hiddenPerson: document.getElementById(panelId + "-input-person"),
hiddenReplyTo: form.querySelector('input[name="reply_to_message_id"]'),
hiddenService: document.getElementById(panelId + "-input-service"),
@@ -135,6 +136,7 @@
thread: thread,
typingNode: document.getElementById(panelId + "-typing"),
});
state.threadController = threadController;
const sendController = sendModule.createController({
armInput: form.querySelector('input[name="failsafe_arm"]'),
@@ -177,6 +179,25 @@
},
initAll: initAll,
initPanel: initPanel,
scrollWidgetToLatest: function (widgetId) {
const widgetNode = widgetId ? document.getElementById(String(widgetId)) : null;
if (!widgetNode) {
return;
}
const panel = widgetNode.querySelector("[data-compose-panel]");
const panelId = String(panel && panel.id ? panel.id : "").trim();
if (!panelId) {
return;
}
const state = window.giaComposePanels[panelId];
if (
state
&& state.threadController
&& typeof state.threadController.scrollToLatest === "function"
) {
state.threadController.scrollToLatest();
}
},
};
document.addEventListener("DOMContentLoaded", function () {

File diff suppressed because it is too large Load Diff

View File

@@ -43,7 +43,7 @@
<link rel="preload" href="{% static 'vendor/fontawesome/webfonts/fa-regular-400.woff2' %}" as="font" type="font/woff2" integrity="sha512-qioT43fXB5q4Bbpn8sPQE9OIZLjKD0c0lVmpm6KmT8k34LM6gkRcOOMi1BOl2lohFG/7p9tzKfTP5G563BQq1g==" crossorigin="anonymous">
<link rel="stylesheet" href="{% static 'css/bulma.min.css' %}" integrity="sha512-yh2RE0wZCVZeysGiqTwDTO/dKelCbS9bP2L94UvOFtl/FKXcNAje3Y2oBg/ZMZ3LS1sicYk4dYVGtDex75fvvA==" crossorigin="anonymous">
<link rel="stylesheet" href="{% static 'vendor/fontawesome/css/all.css' %}" integrity="sha512-UKBBxJ5N3/MYiSsYTlEsARsp4vELKVRIklED4Mb6wpuVFOgy5Blt+sXUdz1TDReqWsm64xxBA2QoBJRCxI0x5Q==" crossorigin="anonymous">
<link rel="stylesheet" href="{% static 'css/gia-theme.css' %}" integrity="sha512-17wNDv0gA1saxAIzoySMcOef4/8dKEo2eZcWGhVHUjKolxhbfYVia9i/wExDqEw8MhfP4Kk8BrMajcOHngqJJg==" crossorigin="anonymous">
<link rel="stylesheet" href="{% static 'css/gia-theme.css' %}" integrity="sha512-Lnmy74rBeyPt8DwWBqsRj9hFqTXuXZqkIgIeIm+inhoXpOfGjsvlealaDwRNVv/ou0Bta6h/RmOkjOJYOkALCw==" crossorigin="anonymous">
{% block extra_head_assets %}{% endblock %}
<script src="{% static 'js/htmx.min.js' %}" integrity="sha512-CGXFnDNv5q48ciFeIyWFcfZhqYW0sSBiPO+HZDO3XLM+p8xjhezz5CCxtkXVDKfCbvF+iUhel7xoeSp19o7x7g==" crossorigin="anonymous"></script>
<script defer src="{% static 'js/remove-me.js' %}" integrity="sha512-uhE4kDw2+tXdJPDKSttOEYhVnwYq310+d5DMQnTjafJ58QLlYPyx0RTCNbjcrTiGfCjAhaQob4AumEUa2m3TaQ==" crossorigin="anonymous"></script>
@@ -163,6 +163,49 @@
});
});
var closeGiaDropdowns = function (exceptNode) {
document.querySelectorAll(".dropdown[data-gia-dropdown].is-active").forEach(function (node) {
if (exceptNode && node === exceptNode) {
return;
}
node.classList.remove("is-active");
var trigger = node.querySelector(".js-gia-dropdown-toggle");
if (trigger) {
trigger.setAttribute("aria-expanded", "false");
}
});
};
document.addEventListener("click", function (event) {
var toggle = event.target.closest(".js-gia-dropdown-toggle");
if (toggle) {
event.preventDefault();
event.stopPropagation();
var dropdown = toggle.closest(".dropdown[data-gia-dropdown]");
if (!dropdown) {
return;
}
var nextState = !dropdown.classList.contains("is-active");
closeGiaDropdowns(nextState ? dropdown : null);
dropdown.classList.toggle("is-active", nextState);
toggle.setAttribute("aria-expanded", nextState ? "true" : "false");
return;
}
if (!event.target.closest(".dropdown[data-gia-dropdown]")) {
closeGiaDropdowns(null);
return;
}
if (event.target.closest(".dropdown[data-gia-dropdown] .dropdown-item") && !event.target.closest("summary")) {
closeGiaDropdowns(null);
}
});
document.addEventListener("keydown", function (event) {
if (event.key === "Escape") {
closeGiaDropdowns(null);
}
});
document.body.addEventListener("htmx:afterRequest", function (event) {
const detail = (event && event.detail) || null;
const source = detail && detail.elt ? detail.elt : null;

View File

@@ -24,7 +24,7 @@
aria-label="Snap assistant">
<p class="panel-heading gia-snap-assistant-heading">
<span class="icon is-small"><i class="fa-solid fa-table-columns"></i></span>
<span>Snap Right</span>
<span class="gia-snap-assistant-title">Snap Right</span>
<button
type="button"
class="delete is-small js-gia-snap-assistant-close"
@@ -32,7 +32,7 @@
</p>
<div class="panel-block">
<div class="content is-small">
<p>Choose a second window for the right side.</p>
<p class="gia-snap-assistant-message">Choose a second window for the right side.</p>
</div>
</div>
<div class="panel-block is-active gia-snap-assistant-body">

View File

@@ -3,7 +3,7 @@
data-gia-widget-shell="1"
{% if widget_style_hrefs %}data-gia-style-hrefs="{{ widget_style_hrefs|join:'|' }}"{% endif %}
{% if widget_script_srcs %}data-gia-script-srcs="{{ widget_script_srcs|join:'|' }}"{% endif %}>
<div id="widget-{{ unique }}" class="grid-stack-item" {% block widget_options %}{% if widget_options is None %}gs-w="6" gs-h="1" gs-y="20" gs-x="0"{% else %}{% autoescape off %}{{ widget_options }}{% endautoescape %}{% endif %}{% endblock %}>
<div id="widget-{{ unique }}" class="grid-stack-item" gs-id="widget-{{ unique }}" {% block widget_options %}{% if widget_options is None %}gs-w="6" gs-h="1" gs-y="20" gs-x="0"{% else %}{% autoescape off %}{{ widget_options }}{% endautoescape %}{% endif %}{% endblock %}>
<div class="grid-stack-item-content">
<section class="gia-widget-panel">
<header class="gia-widget-heading">
@@ -34,6 +34,14 @@
aria-label="Snap window left">
<span class="icon is-small"><i class="fa-solid fa-arrow-left"></i></span>
</button>
<button
type="button"
class="button is-light js-gia-widget-action"
data-gia-action="snap-top"
data-gia-widget-id="widget-{{ unique }}"
aria-label="Snap window top">
<span class="icon is-small"><i class="fa-solid fa-arrow-up"></i></span>
</button>
<button
type="button"
class="button is-light js-gia-widget-action"
@@ -42,6 +50,14 @@
aria-label="Snap window right">
<span class="icon is-small"><i class="fa-solid fa-arrow-right"></i></span>
</button>
<button
type="button"
class="button is-light js-gia-widget-action"
data-gia-action="snap-bottom"
data-gia-widget-id="widget-{{ unique }}"
aria-label="Snap window bottom">
<span class="icon is-small"><i class="fa-solid fa-arrow-down"></i></span>
</button>
<button
type="button"
class="button is-light js-gia-widget-action"

View File

@@ -1,98 +1,22 @@
{% extends "base.html" %}
{% block content %}
<div class="columns is-multiline">
<div class="column is-12">
<section class="section">
<div class="container">
<nav class="breadcrumb is-small" aria-label="breadcrumbs">
<ul>
<li><a href="{{ workspace_url }}">AI Workspace</a></li>
<li class="is-active"><a aria-current="page">Information</a></li>
<li class="is-active"><a aria-current="page">MS / PS Information</a></li>
</ul>
</nav>
</div>
<div class="column is-12">
<h1 class="title is-4" style="margin-bottom: 0.35rem;">Information: {{ person.name }}</h1>
<p class="is-size-7 has-text-grey">Commitment directionality and underlying metric factors from deterministic message-history snapshots.</p>
<div class="mb-4">
<h1 class="title is-4 mb-1">MS / PS Information: {{ person.name }}</h1>
<p class="is-size-7 has-text-grey">
Current message-state and presence-state signals derived from timing data.
</p>
{% include "partials/ai-insight-nav.html" with active_tab="information" %}
</div>
<div class="column is-5">
<div class="box">
<p class="heading">Conversation Overview</p>
{% if overview_rows %}
{% for row in overview_rows %}
<article class="message is-light" style="margin-bottom: 0.45rem;">
<div class="message-body" style="padding: 0.45rem 0.55rem;">
<p class="is-size-7 has-text-grey" style="margin-bottom: 0.12rem;">
{{ row.group_title }}
</p>
<p style="margin-bottom: 0.3rem;">
<strong>{{ row.title }}:</strong> {{ row.value|default:"-" }}
</p>
<a
class="tag is-light"
href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric=row.slug %}">
View Detail
</a>
</div>
</article>
{% endfor %}
{% else %}
<p class="is-size-7 has-text-grey">No conversation metadata available yet.</p>
{% endif %}
</div>
<div class="box">
<p class="heading">Commitment Directionality</p>
<p class="title is-5" style="margin-bottom: 0.35rem;">{{ directionality.direction_label }}</p>
<p><strong>Commit In:</strong> {{ directionality.commit_in|default:"-" }}</p>
<p><strong>Commit Out:</strong> {{ directionality.commit_out|default:"-" }}</p>
<p><strong>Delta:</strong> {{ directionality.delta|default:"-" }}</p>
<p><strong>Magnitude:</strong> {{ directionality.magnitude|default:"-" }}</p>
<p><strong>Confidence:</strong> {{ directionality.confidence|default:"-" }}</p>
<article class="message is-light" style="margin-top: 0.65rem;">
<div class="message-body">
{{ directionality.conclusion }}
</div>
</article>
</div>
</div>
<div class="column is-7">
<div class="box">
<p class="heading">Factor Inputs</p>
<div class="columns is-multiline" style="margin: 0 -0.2rem;">
{% for factor in directionality.factors %}
<div class="column is-12-mobile is-6-tablet" style="padding: 0.2rem;">
<article class="box" style="margin-bottom: 0; border: 1px solid rgba(0, 0, 0, 0.14); box-shadow: none;">
<p class="is-size-7 has-text-weight-semibold" style="margin-bottom: 0.2rem;">
<span class="icon is-small"><i class="{{ factor.icon }}"></i></span>
<span>{{ factor.title }}</span>
</p>
<p class="is-size-7"><strong>Weight:</strong> {{ factor.weight }}</p>
<p class="is-size-7"><strong>Value:</strong> {{ factor.value|default:"-" }}</p>
<p style="margin-top: 0.35rem;">
<a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric=factor.slug %}">
View Metric
</a>
</p>
</article>
</div>
{% endfor %}
</div>
</div>
<div class="box">
<p class="heading">Linked Graphs</p>
<div class="tags">
{% for ref in directionality.graph_refs %}
<a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric=ref.slug %}">
{{ ref.title }}: {{ ref.value|default:"-" }}
</a>
{% endfor %}
</div>
</div>
</div>
{% include "partials/ai-workspace-behavioral-information.html" %}
</div>
</section>
{% endblock %}

View File

@@ -1,113 +1,23 @@
{% extends "base.html" %}
{% load static %}
{% block content %}
<div class="columns is-multiline">
<div class="column is-12">
<section class="section">
<div class="container">
<nav class="breadcrumb is-small" aria-label="breadcrumbs">
<ul>
<li><a href="{{ workspace_url }}">AI Workspace</a></li>
<li><a href="{{ graphs_url }}">Insight Graphs</a></li>
<li><a href="{{ graphs_url }}">Behavioral Graphs</a></li>
<li class="is-active"><a aria-current="page">{{ metric.title }}</a></li>
</ul>
</nav>
</div>
<div class="column is-12">
<h1 class="title is-4" style="margin-bottom: 0.35rem;">{{ metric.title }}: {{ person.name }}</h1>
<p class="is-size-7 has-text-grey">Conversation {{ workspace_conversation.id }}</p>
<div class="mb-4">
<h1 class="title is-4 mb-1">{{ metric.title }}: {{ person.name }}</h1>
<p class="is-size-7 has-text-grey">
Detailed MS/PS graph for this contact.
</p>
{% include "partials/ai-insight-nav.html" %}
</div>
<div class="column is-5">
<div class="box">
<p class="heading">{{ metric_group.title }}</p>
<p class="title is-5" style="margin-bottom: 0.5rem;">{{ metric.title }}</p>
<p><strong>Current Value:</strong> {{ metric_value|default:"-" }}</p>
<p style="margin-top: 0.65rem;"><strong>How It Is Calculated</strong></p>
<p>{{ metric.calculation }}</p>
<p style="margin-top: 0.65rem;"><strong>Psychological Interpretation</strong></p>
<p>{{ metric.psychology }}</p>
{% if metric_psychology_hint %}
<article class="message is-info is-light" style="margin-top: 0.75rem;">
<div class="message-body">
{{ metric_psychology_hint }}
{% include "partials/ai-workspace-behavioral-graph-detail.html" %}
</div>
</article>
{% endif %}
</div>
</div>
<div class="column is-7">
<div class="box">
<p class="heading">History</p>
{% if graph_applicable %}
<div style="height: 360px;">
<canvas id="metric-detail-chart"></canvas>
</div>
{% if not graph_points %}
<p class="is-size-7 has-text-grey" style="margin-top: 0.65rem;">
No historical points yet for this metric.
</p>
{% endif %}
{% else %}
<article class="message is-light">
<div class="message-body">
This is a point-in-time metric and is not charted.
</div>
</article>
{% endif %}
</div>
</div>
</div>
{{ graph_points|json_script:"metric-detail-points" }}
<script src="{% static 'js/chart.js' %}"></script>
<script>
(function() {
var node = document.getElementById("metric-detail-points");
if (!node) {
return;
}
var points = JSON.parse(node.textContent || "[]");
var shouldRender = {{ graph_applicable|yesno:"true,false" }};
if (!shouldRender) {
return;
}
var labels = points.map(function(row) {
var dt = new Date(row.x);
return dt.toLocaleString();
});
var values = points.map(function(row) {
return row.y;
});
var ctx = document.getElementById("metric-detail-chart");
if (!ctx) {
return;
}
new Chart(ctx.getContext("2d"), {
type: "line",
data: {
labels: labels,
datasets: [
{
label: "{{ metric.title|escapejs }}",
data: values,
borderColor: "#3273dc",
backgroundColor: "rgba(50,115,220,0.12)",
borderWidth: 2,
pointRadius: 2,
tension: 0.28,
spanGaps: true
}
]
},
options: {
maintainAspectRatio: false,
plugins: {
legend: {
display: true
}
}
}
});
})();
</script>
</section>
{% endblock %}

View File

@@ -1,129 +1,22 @@
{% extends "base.html" %}
{% load static %}
{% block content %}
<div class="columns is-multiline">
<div class="column is-12">
<section class="section">
<div class="container">
<nav class="breadcrumb is-small" aria-label="breadcrumbs">
<ul>
<li><a href="{{ workspace_url }}">AI Workspace</a></li>
<li class="is-active"><a aria-current="page">Insight Graphs</a></li>
<li class="is-active"><a aria-current="page">Behavioral Graphs</a></li>
</ul>
</nav>
</div>
<div class="column is-12">
<h1 class="title is-4" style="margin-bottom: 0.35rem;">Insight Graphs: {{ person.name }}</h1>
<div class="mb-4">
<h1 class="title is-4 mb-1">Behavioral Graphs: {{ person.name }}</h1>
<p class="is-size-7 has-text-grey">
Historical metrics for workspace {{ workspace_conversation.id }}. Points are range-downsampled server-side with high-resolution recent data and progressively sparser older ranges.
Rebuilt from message and event timing data using the MS/PS model rather than workspace snapshots.
</p>
<div class="buttons are-small" style="margin: 0.5rem 0 0.25rem;">
<a
class="button {% if graph_density == 'low' %}is-dark{% else %}is-light{% endif %}"
href="?density=low">
Density: Low (max {{ graph_density_caps.low }})
</a>
<a
class="button {% if graph_density == 'medium' %}is-dark{% else %}is-light{% endif %}"
href="?density=medium">
Density: Medium (max {{ graph_density_caps.medium }})
</a>
<a
class="button {% if graph_density == 'high' %}is-dark{% else %}is-light{% endif %}"
href="?density=high">
Density: High (max {{ graph_density_caps.high }})
</a>
</div>
{% include "partials/ai-insight-nav.html" with active_tab="graphs" %}
</div>
{% for graph in graph_cards %}
<div class="column is-6">
<div class="box">
<div class="is-flex is-justify-content-space-between is-align-items-center" style="margin-bottom: 0.45rem;">
<div>
<p class="heading">{{ graph.group_title }}</p>
<p class="title is-6" style="margin-bottom: 0.2rem;">{{ graph.title }}</p>
<p class="is-size-7 has-text-grey">
{{ graph.count }} displayed of {{ graph.raw_count }} source point{{ graph.raw_count|pluralize }}
</p>
{% include "partials/ai-workspace-behavioral-graphs.html" %}
</div>
<div class="buttons are-small" style="margin: 0;">
<a class="button is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric=graph.slug %}">
Detail
</a>
<a class="button is-light" href="{{ help_url }}#group-{{ graph.group }}">
How It Works
</a>
</div>
</div>
<div style="height: 250px;">
<canvas id="graph-{{ graph.slug }}"></canvas>
</div>
</div>
</div>
{% endfor %}
</div>
{{ graph_cards|json_script:"insight-graph-cards" }}
<script src="{% static 'js/chart.js' %}"></script>
<script>
(function() {
var node = document.getElementById("insight-graph-cards");
if (!node) {
return;
}
var graphs = JSON.parse(node.textContent || "[]");
var palette = ["#3273dc", "#23d160", "#ffdd57", "#ff3860", "#7957d5", "#00d1b2"];
graphs.forEach(function(graph, idx) {
var canvas = document.getElementById("graph-" + graph.slug);
if (!canvas) {
return;
}
var labels = (graph.points || []).map(function(row) {
return new Date(row.x).toLocaleString();
});
var values = (graph.points || []).map(function(row) {
return row.y;
});
var color = palette[idx % palette.length];
var yScale = {};
if (graph.y_min !== null && graph.y_min !== undefined) {
yScale.min = graph.y_min;
}
if (graph.y_max !== null && graph.y_max !== undefined) {
yScale.max = graph.y_max;
}
new Chart(canvas.getContext("2d"), {
type: "line",
data: {
labels: labels,
datasets: [
{
label: graph.title,
data: values,
borderColor: color,
backgroundColor: color + "33",
borderWidth: 2,
pointRadius: 2,
tension: 0.24,
spanGaps: true
}
]
},
options: {
maintainAspectRatio: false,
scales: {
y: yScale
},
plugins: {
legend: {
display: false
}
}
}
});
});
})();
</script>
</section>
{% endblock %}

View File

@@ -1,58 +1,23 @@
{% extends "base.html" %}
{% block content %}
<div class="columns is-multiline">
<div class="column is-12">
<section class="section">
<div class="container">
<nav class="breadcrumb is-small" aria-label="breadcrumbs">
<ul>
<li><a href="{{ workspace_url }}">AI Workspace</a></li>
<li><a href="{{ graphs_url }}">Insight Graphs</a></li>
<li class="is-active"><a aria-current="page">Scoring Help</a></li>
<li><a href="{{ graphs_url }}">Behavioral Graphs</a></li>
<li class="is-active"><a aria-current="page">MS / PS Help</a></li>
</ul>
</nav>
</div>
<div class="column is-12">
<h1 class="title is-4" style="margin-bottom: 0.35rem;">Scoring Help: {{ person.name }}</h1>
<div class="mb-4">
<h1 class="title is-4 mb-1">MS / PS Help: {{ person.name }}</h1>
<p class="is-size-7 has-text-grey">
Combined explanation for each metric collection group and what it can
imply in relationship dynamics. Scoring is deterministic from message
history and can be backfilled via metric history reconciliation.
Definitions and psychological interpretation for the timing signal system.
</p>
{% include "partials/ai-insight-nav.html" with active_tab="help" %}
</div>
{% for group_key, group in groups.items %}
<div class="column is-12" id="group-{{ group_key }}">
<div class="box">
<p class="heading">{{ group.title }}</p>
<p style="margin-bottom: 0.75rem;">{{ group.summary }}</p>
{% for metric in metrics %}
{% if metric.group == group_key %}
<article class="message is-light" style="margin-bottom: 0.6rem;">
<div class="message-body">
<h3 class="is-size-6 has-text-weight-semibold" style="margin-bottom: 0.45rem;">{{ metric.title }}</h3>
<p class="is-size-7 has-text-grey has-text-weight-semibold" style="margin-bottom: 0.15rem;">
Current Value
</p>
<p style="margin-bottom: 0.55rem;">{{ metric.value|default:"-" }}</p>
<p class="is-size-7 has-text-grey has-text-weight-semibold" style="margin-bottom: 0.15rem;">
How It Is Calculated
</p>
<p style="margin-bottom: 0.55rem;">{{ metric.calculation }}</p>
<p class="is-size-7 has-text-grey has-text-weight-semibold" style="margin-bottom: 0.15rem;">
Psychological Interpretation
</p>
<p>{{ metric.psychology }}</p>
</div>
</article>
{% endif %}
{% endfor %}
</div>
</div>
{% endfor %}
{% include "partials/ai-workspace-behavioral-help.html" %}
</div>
</section>
{% endblock %}

View File

@@ -3,18 +3,18 @@
class="tag {% if active_tab == 'graphs' %}is-dark{% else %}is-link is-light{% endif %}"
href="{{ graphs_url }}">
<span class="icon is-small"><i class="fa-solid fa-chart-line"></i></span>
<span>Insight Graphs</span>
<span>Behavioral Graphs</span>
</a>
<a
class="tag {% if active_tab == 'information' %}is-dark{% else %}is-link is-light{% endif %}"
href="{{ information_url }}">
<span class="icon is-small"><i class="fa-solid fa-circle-info"></i></span>
<span>Information View</span>
<span>MS / PS Information</span>
</a>
<a
class="tag {% if active_tab == 'help' %}is-dark{% else %}is-link is-light{% endif %}"
href="{{ help_url }}">
<span class="icon is-small"><i class="fa-solid fa-circle-question"></i></span>
<span>Scoring Guide</span>
<span>MS / PS Help</span>
</a>
</div>

View File

@@ -0,0 +1,36 @@
<div class="gia-behavior-shell">
<div class="is-flex is-justify-content-space-between is-align-items-center is-flex-wrap-wrap mb-3">
<div>
<p class="is-size-7 has-text-weight-semibold has-text-grey mb-1">{{ metric.state_label }} · {{ metric.group|upper }}</p>
<h3 class="title is-5 mb-1">{{ metric.title }}</h3>
<p class="is-size-7">{{ metric.calculation }}</p>
</div>
{% include "partials/behavioral-range-tabs.html" %}
</div>
<div class="columns is-multiline">
<div class="column is-12">
{% include "partials/behavioral-graph-card.html" with graph=metric person=person graphs_widget_url=graphs_widget_url %}
</div>
<div class="column is-12-tablet is-6-desktop">
<article class="message is-light">
<div class="message-body">
<p class="is-size-7 has-text-weight-semibold has-text-grey mb-1">Psychological Reading</p>
<p>{{ metric.psychology }}</p>
</div>
</article>
</div>
<div class="column is-12-tablet is-6-desktop">
<article class="message is-light">
<div class="message-body">
<p class="is-size-7 has-text-weight-semibold has-text-grey mb-1">Coverage</p>
<p class="mb-2">{{ coverage.message_count }} messages · {{ coverage.event_count }} events</p>
<p class="is-size-7">Latest value: {{ metric.current_value_label }}</p>
{% if metric.delta_label %}
<p class="is-size-7">Latest shift: {{ metric.delta_label }}</p>
{% endif %}
</div>
</article>
</div>
</div>
</div>

View File

@@ -0,0 +1,36 @@
<div class="gia-behavior-shell">
<div class="is-flex is-justify-content-space-between is-align-items-center is-flex-wrap-wrap mb-3">
<div>
<p class="is-size-7 has-text-weight-semibold has-text-grey mb-1">Behavioral Graphs</p>
<p class="mb-0">{{ coverage.message_count }} messages · {{ coverage.event_count }} events · {{ coverage.session_count }} sessions</p>
</div>
{% include "partials/behavioral-range-tabs.html" %}
</div>
<div class="columns is-multiline">
{% for card in summary_cards %}
<div class="column is-12-mobile is-6-tablet is-4-desktop">
{% include "partials/behavioral-summary-card.html" with card=card %}
</div>
{% endfor %}
</div>
{% for group_key, group in behavioral_groups.items %}
<section class="mb-4">
<div class="mb-3">
<p class="is-size-7 has-text-weight-semibold has-text-grey mb-1">{{ group.eyebrow }}</p>
<h3 class="title is-5 mb-1">{{ group.title }}</h3>
<p class="is-size-7">{{ group.summary }}</p>
</div>
<div class="columns is-multiline">
{% for graph in graph_cards %}
{% if graph.group == group_key %}
<div class="column is-12-mobile is-6-desktop">
{% include "partials/behavioral-graph-card.html" with graph=graph person=person graphs_widget_url=graphs_widget_url %}
</div>
{% endif %}
{% endfor %}
</div>
</section>
{% endfor %}
</div>

View File

@@ -0,0 +1,33 @@
<div class="gia-behavior-shell">
<div class="is-flex is-justify-content-space-between is-align-items-center is-flex-wrap-wrap mb-3">
<div>
<p class="is-size-7 has-text-weight-semibold has-text-grey mb-1">MS / PS Help</p>
<p class="mb-0">Signal definitions and psychological interpretation for the timing system.</p>
</div>
{% include "partials/behavioral-range-tabs.html" %}
</div>
{% for group_key, group in groups.items %}
<section class="box mb-4">
<p class="is-size-7 has-text-weight-semibold has-text-grey mb-1">{{ group.eyebrow }}</p>
<h3 class="title is-5 mb-2">{{ group.title }}</h3>
<p class="mb-3">{{ group.summary }}</p>
{% for metric in metrics %}
{% if metric.group == group_key %}
<article class="message is-light mb-3">
<div class="message-body">
<p class="is-size-7 has-text-weight-semibold has-text-grey mb-1">{{ metric.state_label }}</p>
<p class="title is-6 mb-2">
<span class="icon is-small mr-1"><i class="{{ metric.icon }}"></i></span>
<span>{{ metric.title }}</span>
</p>
<p class="mb-2"><strong>Current:</strong> {{ metric.current_value_label }}</p>
<p class="mb-2"><strong>How it is calculated:</strong> {{ metric.calculation }}</p>
<p><strong>What it can mean:</strong> {{ metric.psychology }}</p>
</div>
</article>
{% endif %}
{% endfor %}
</section>
{% endfor %}
</div>

View File

@@ -0,0 +1,51 @@
<div class="gia-behavior-shell">
<div class="is-flex is-justify-content-space-between is-align-items-center is-flex-wrap-wrap mb-3">
<div>
<p class="is-size-7 has-text-weight-semibold has-text-grey mb-1">MS / PS Information</p>
<p class="mb-0">Current timing signals for {{ person.name }} across message state and presence state.</p>
</div>
{% include "partials/behavioral-range-tabs.html" %}
</div>
<div class="columns is-multiline">
{% for card in summary_cards %}
<div class="column is-12-mobile is-6-tablet is-4-desktop">
{% include "partials/behavioral-summary-card.html" with card=card %}
</div>
{% endfor %}
</div>
{% for group_key, group in behavioral_groups.items %}
<section class="box mb-4">
<p class="is-size-7 has-text-weight-semibold has-text-grey mb-1">{{ group.eyebrow }}</p>
<h3 class="title is-5 mb-2">{{ group.title }}</h3>
<p class="mb-3">{{ group.summary }}</p>
<div class="table-container">
<table class="table is-fullwidth is-hoverable">
<thead>
<tr>
<th>Metric</th>
<th>Current</th>
<th>Meaning</th>
</tr>
</thead>
<tbody>
{% for graph in graph_cards %}
{% if graph.group == group_key %}
<tr>
<td>
<a href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric=graph.slug %}?range={{ range_key }}">
{{ graph.title }}
</a>
</td>
<td>{{ graph.current_value_label }}</td>
<td>{{ graph.psychology }}</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endfor %}
</div>

View File

@@ -9,35 +9,18 @@
<div style="margin-bottom: 0.75rem; padding: 0.5rem 0.25rem; border-bottom: 1px solid rgba(0, 0, 0, 0.12);">
<p class="is-size-7 has-text-weight-semibold">Selected Person</p>
<h3 class="title is-5" style="margin-bottom: 0.25rem;">{{ person.name }}</h3>
<div class="tags" style="margin-top: 0.35rem;">
<a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='platform' %}">Platform {{ workspace_conversation.platform_type|title }}</a>
<a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='thread' %}">Thread {{ workspace_conversation.platform_thread_id|default:"-" }}</a>
<a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='workspace_created' %}">Workspace Created {{ workspace_conversation.created_at|default:"-" }}</a>
<a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='stability_state' %}">Stability {{ workspace_conversation.stability_state|title }}</a>
<a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='stability_score' %}">Stability Score {{ workspace_conversation.stability_score|default:"-" }}</a>
<a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='stability_confidence' %}">Confidence {{ workspace_conversation.stability_confidence }}</a>
<a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='sample_messages' %}">Sample Msg {{ workspace_conversation.stability_sample_messages }}</a>
<a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='sample_days' %}">Sample Days {{ workspace_conversation.stability_sample_days }}</a>
<a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='stability_computed' %}">Stability Computed {{ workspace_conversation.stability_last_computed_at|default:"-" }}</a>
<a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='commitment_inbound' %}">Commit In {{ workspace_conversation.commitment_inbound_score|default:"-" }}</a>
<a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='commitment_outbound' %}">Commit Out {{ workspace_conversation.commitment_outbound_score|default:"-" }}</a>
<a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='commitment_confidence' %}">Commit Confidence {{ workspace_conversation.commitment_confidence }}</a>
<a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='commitment_computed' %}">Commitment Computed {{ workspace_conversation.commitment_last_computed_at|default:"-" }}</a>
<a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='last_event' %}">Last Event {{ workspace_conversation.last_event_ts|default:"-" }}</a>
<a class="tag is-light" href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric='last_ai_run' %}">Last AI Run {{ workspace_conversation.last_ai_run_at|default:"-" }}</a>
</div>
<p class="is-size-7 has-text-grey" style="margin-top: 0.35rem; margin-bottom: 0;">
Open MS / PS graphs, information, and help from the controls below.
</p>
<div class="buttons are-small" style="margin-top: 0.35rem; margin-bottom: 0;">
<a class="button is-light" href="{% url 'ai_workspace_insight_graphs' type='page' person_id=person.id %}">
<span class="icon is-small"><i class="fa-solid fa-chart-line"></i></span>
<span>Insight Graphs</span>
</a>
{% include "partials/behavioral-graph-launcher.html" with button_label="Graphs" show_widget_actions=behavioral_show_widget_actions default_widget_url=behavioral_graphs_widget_url default_page_url=behavioral_graphs_page_url graph_groups=behavioral_graph_groups %}
<a class="button is-light" href="{% url 'ai_workspace_information' type='page' person_id=person.id %}">
<span class="icon is-small"><i class="fa-solid fa-circle-info"></i></span>
<span>Information</span>
<span>MS / PS</span>
</a>
<a class="button is-light" href="{% url 'ai_workspace_insight_help' type='page' person_id=person.id %}">
<span class="icon is-small"><i class="fa-solid fa-circle-question"></i></span>
<span>Scoring Help</span>
<span>Help</span>
</a>
</div>
{% with participants=workspace_conversation.participants.all %}
@@ -81,6 +64,7 @@
id="ai-manual-widget-btn-{{ person.id }}"
type="button"
class="button is-light is-small js-widget-spawn-trigger is-hidden"
data-gia-widget-id="{{ compose_widget_id }}"
data-widget-url="{{ compose_widget_url }}"
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{{ compose_widget_url }}"

View File

@@ -0,0 +1,71 @@
<article class="card gia-behavior-graph-card">
<header class="card-header">
<div class="card-header-title is-flex is-justify-content-space-between is-align-items-flex-start">
<div>
<p class="is-size-7 has-text-weight-semibold has-text-grey mb-1">
{{ graph.state_label }} · {{ graph.group|upper }}
</p>
<p class="mb-0">
<span class="icon is-small mr-1"><i class="{{ graph.icon }}"></i></span>
<span>{{ graph.title }}</span>
</p>
</div>
<span class="tag is-light gia-badge">{{ graph.current_value_label }}</span>
</div>
</header>
<div class="card-content">
<p class="is-size-7 has-text-grey mb-2">
{{ graph.raw_count }} sample{{ graph.raw_count|pluralize }} in this range{% if graph.delta_label %} · {{ graph.delta_label }}{% endif %}
</p>
{% if graph.has_data %}
<div class="gia-behavior-chart-shell">
<svg
class="gia-behavior-chart"
viewBox="0 0 100 48"
preserveAspectRatio="none"
role="img"
aria-label="{{ graph.title }} graph">
{% if graph.area_path %}
<path class="gia-behavior-chart-area" d="{{ graph.area_path }}"></path>
{% endif %}
{% if graph.polyline %}
<polyline class="gia-behavior-chart-line" points="{{ graph.polyline }}"></polyline>
{% endif %}
{% for marker in graph.markers|slice:"-1:" %}
<circle class="gia-behavior-chart-point" cx="{{ marker.x }}" cy="{{ marker.y }}" r="1.8"></circle>
{% endfor %}
</svg>
<div class="is-flex is-justify-content-space-between is-size-7 has-text-grey mt-1">
<span>{{ graph.y_min_label }}</span>
<span>{{ graph.latest_bucket_label }}</span>
<span>{{ graph.y_max_label }}</span>
</div>
</div>
{% else %}
<article class="message is-light">
<div class="message-body is-size-7">
No samples for this metric in the selected range.
</div>
</article>
{% endif %}
<p class="is-size-7 mt-3">{{ graph.psychology }}</p>
<div class="buttons are-small mt-3 mb-0">
<a
class="button is-light"
href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric=graph.slug %}?range={{ range_key }}">
Page
</a>
{% if behavioral_show_widget_actions %}
<button
type="button"
class="button is-light js-widget-spawn-trigger"
data-widget-url="{% url 'ai_workspace_insight_detail' type='widget' person_id=person.id metric=graph.slug %}?range={{ range_key }}"
hx-get="{% url 'ai_workspace_insight_detail' type='widget' person_id=person.id metric=graph.slug %}?range={{ range_key }}"
hx-target="#widgets-here"
hx-swap="beforeend">
Widget
</button>
{% endif %}
</div>
</div>
</article>

View File

@@ -0,0 +1,71 @@
<div class="dropdown is-right gia-split-dropdown" data-gia-dropdown>
<div class="dropdown-trigger">
<div class="buttons has-addons are-small mb-0">
{% if show_widget_actions %}
<button
type="button"
class="button is-light js-widget-spawn-trigger"
data-widget-url="{{ default_widget_url }}"
hx-get="{{ default_widget_url }}"
hx-target="#widgets-here"
hx-swap="beforeend">
<span class="icon is-small"><i class="fa-solid fa-chart-line"></i></span>
<span>{{ button_label|default:"Graphs" }}</span>
</button>
{% else %}
<a class="button is-light" href="{{ default_page_url }}">
<span class="icon is-small"><i class="fa-solid fa-chart-line"></i></span>
<span>{{ button_label|default:"Graphs" }}</span>
</a>
{% endif %}
<button
type="button"
class="button is-light js-gia-dropdown-toggle"
aria-haspopup="true"
aria-expanded="false">
<span class="icon is-small"><i class="fa-solid fa-angle-down"></i></span>
</button>
</div>
</div>
<div class="dropdown-menu" role="menu">
<div class="dropdown-content">
<a class="dropdown-item" href="{{ default_page_url }}">
<span class="icon is-small"><i class="fa-solid fa-up-right-from-square"></i></span>
<span>Page</span>
</a>
<hr class="dropdown-divider">
<details class="gia-dropdown-nest">
<summary class="dropdown-item">
<span class="icon is-small"><i class="fa-solid fa-sliders"></i></span>
<span>Custom Graph</span>
</summary>
<div class="gia-dropdown-nest-body">
{% for group in graph_groups %}
<p class="dropdown-item has-text-weight-semibold is-size-7 has-text-grey">
{{ group.title }}
</p>
{% for item in group.items %}
{% if show_widget_actions %}
<button
type="button"
class="dropdown-item js-widget-spawn-trigger"
data-widget-url="{{ item.widget_url }}"
hx-get="{{ item.widget_url }}"
hx-target="#widgets-here"
hx-swap="beforeend">
<span class="icon is-small"><i class="{{ item.icon }}"></i></span>
<span>{{ item.title }}</span>
</button>
{% else %}
<a class="dropdown-item" href="{{ item.page_url }}">
<span class="icon is-small"><i class="{{ item.icon }}"></i></span>
<span>{{ item.title }}</span>
</a>
{% endif %}
{% endfor %}
{% endfor %}
</div>
</details>
</div>
</div>
</div>

View File

@@ -0,0 +1,56 @@
<div class="tabs is-toggle is-small gia-inline-tabs">
<ul>
<li{% if range_key == "30d" %} class="is-active"{% endif %}>
{% if behavioral_show_widget_actions %}
<a
class="button is-white is-small js-widget-spawn-trigger"
href="{{ behavioral_range_urls.30d }}"
data-widget-url="{{ behavioral_range_urls.30d }}"
hx-get="{{ behavioral_range_urls.30d }}"
hx-target="#widgets-here"
hx-swap="beforeend">30d</a>
{% else %}
<a href="{{ behavioral_range_urls.30d }}">30d</a>
{% endif %}
</li>
<li{% if range_key == "90d" %} class="is-active"{% endif %}>
{% if behavioral_show_widget_actions %}
<a
class="button is-white is-small js-widget-spawn-trigger"
href="{{ behavioral_range_urls.90d }}"
data-widget-url="{{ behavioral_range_urls.90d }}"
hx-get="{{ behavioral_range_urls.90d }}"
hx-target="#widgets-here"
hx-swap="beforeend">90d</a>
{% else %}
<a href="{{ behavioral_range_urls.90d }}">90d</a>
{% endif %}
</li>
<li{% if range_key == "365d" %} class="is-active"{% endif %}>
{% if behavioral_show_widget_actions %}
<a
class="button is-white is-small js-widget-spawn-trigger"
href="{{ behavioral_range_urls.365d }}"
data-widget-url="{{ behavioral_range_urls.365d }}"
hx-get="{{ behavioral_range_urls.365d }}"
hx-target="#widgets-here"
hx-swap="beforeend">1y</a>
{% else %}
<a href="{{ behavioral_range_urls.365d }}">1y</a>
{% endif %}
</li>
<li{% if range_key == "all" %} class="is-active"{% endif %}>
{% if behavioral_show_widget_actions %}
<a
class="button is-white is-small js-widget-spawn-trigger"
href="{{ behavioral_range_urls.all }}"
data-widget-url="{{ behavioral_range_urls.all }}"
hx-get="{{ behavioral_range_urls.all }}"
hx-target="#widgets-here"
hx-swap="beforeend">All</a>
{% else %}
<a href="{{ behavioral_range_urls.all }}">All</a>
{% endif %}
</li>
</ul>
</div>

View File

@@ -0,0 +1,18 @@
<article class="message is-light gia-behavior-summary-card">
<div class="message-body">
<p class="is-size-7 has-text-weight-semibold has-text-grey mb-1">
{{ card.state_label }} · {{ card.group|upper }}
</p>
<div class="is-flex is-align-items-center is-justify-content-space-between mb-2">
<p class="title is-6 mb-0">
<span class="icon is-small mr-1"><i class="{{ card.icon }}"></i></span>
<span>{{ card.title }}</span>
</p>
<span class="tag is-light gia-badge">{{ card.current_value_label }}</span>
</div>
<p class="is-size-7 mb-1">{{ card.calculation }}</p>
{% if card.delta_label %}
<p class="is-size-7 has-text-grey">Latest shift: {{ card.delta_label }}</p>
{% endif %}
</div>
</article>

View File

@@ -1,5 +1,5 @@
<div class="box is-shadowless gia-send-composer p-2 m-0{% if composer_class %} {{ composer_class }}{% endif %}">
<div class="field has-addons gia-send-composer-row">
<div class="gia-send-composer m-0{% if composer_class %} {{ composer_class }}{% endif %}">
<div class="field has-addons gia-send-composer-row mb-0">
<div class="control is-expanded gia-send-composer-input-wrap">
<textarea
id="{{ textarea_id }}"

View File

@@ -1,5 +1,7 @@
<div class="compose-row {% if msg.outgoing %}is-out{% else %}is-in{% endif %}{% if msg.is_deleted %} is-deleted{% endif %}" data-ts="{{ msg.ts }}" data-message-id="{{ msg.id }}"{% if msg.reply_to_id %} data-reply-to-id="{{ msg.reply_to_id }}"{% endif %} data-reply-snippet="{{ msg.display_text|default:msg.text|default:''|truncatechars:120|escape }}">
<article class="compose-bubble {% if msg.outgoing %}is-out{% else %}is-in{% endif %}">
<article
class="compose-bubble {% if msg.outgoing %}is-out{% else %}is-in{% endif %}"
title="Source: {{ msg.source_label }}{% if msg.author %} · {{ msg.author }}{% endif %}">
{% if msg.reply_to_id %}
<div class="compose-reply-ref" data-reply-target-id="{{ msg.reply_to_id }}">
<button type="button" class="compose-reply-link" title="Jump to referenced message">
@@ -7,9 +9,6 @@
</button>
</div>
{% endif %}
<div class="compose-source-badge-wrap">
<span class="tag is-light gia-badge compose-source-badge source-{{ msg.source_service|default:'web'|lower }}">{{ msg.source_label }}</span>
</div>
{% if msg.image_urls %}
{% for image_url in msg.image_urls %}
<figure class="compose-media">
@@ -34,9 +33,9 @@
</figure>
{% endif %}
{% if not msg.hide_text %}
<p class="compose-body">{{ msg.display_text|default:"(no text)" }}</p>
<p class="compose-body is-size-7">{{ msg.display_text|default:"(no text)" }}</p>
{% else %}
<p class="compose-body compose-image-fallback is-hidden">(no text)</p>
<p class="compose-body compose-image-fallback is-hidden is-size-7">(no text)</p>
{% endif %}
{% if msg.edit_count %}
<details class="compose-edit-history">
@@ -71,7 +70,7 @@
{% endfor %}
</div>
{% endif %}
<p class="compose-msg-meta">
<p class="compose-msg-meta is-size-7" title="Source: {{ msg.source_label }}">
{{ msg.display_ts }}{% if msg.author %} · {{ msg.author }}{% endif %}
{% if msg.is_edited %}
<span class="tag is-light gia-badge compose-msg-flag is-edited" title="Message edited{% if msg.last_edit_display %} at {{ msg.last_edit_display }}{% endif %}">edited</span>

View File

@@ -1,6 +1,6 @@
{% load static %}
<link rel="stylesheet" href="{% static 'css/compose-panel.css' %}">
<script defer src="{% static 'js/compose-panel-core.js' %}"></script>
<script defer src="{% static 'js/compose-panel-thread.js' %}"></script>
<script defer src="{% static 'js/compose-panel-send.js' %}"></script>
<script defer src="{% static 'js/compose-panel.js' %}"></script>
<link rel="stylesheet" href="{% static 'css/compose-panel.css' %}?v={{ compose_asset_version|default:'20260313b' }}">
<script defer src="{% static 'js/compose-panel-core.js' %}?v={{ compose_asset_version|default:'20260313b' }}"></script>
<script defer src="{% static 'js/compose-panel-thread.js' %}?v={{ compose_asset_version|default:'20260313b' }}"></script>
<script defer src="{% static 'js/compose-panel-send.js' %}?v={{ compose_asset_version|default:'20260313b' }}"></script>
<script defer src="{% static 'js/compose-panel.js' %}?v={{ compose_asset_version|default:'20260313b' }}"></script>

View File

@@ -66,6 +66,9 @@
{{ service|title }} · {{ identifier }}
</p>
</div>
{% if behavioral_graphs_page_url %}
{% include "partials/behavioral-graph-launcher.html" with button_label="Graphs" show_widget_actions=behavioral_show_widget_actions default_widget_url=behavioral_graphs_widget_url default_page_url=behavioral_graphs_page_url graph_groups=behavioral_graph_groups %}
{% endif %}
</div>
{% if signal_ingest_warning %}
@@ -90,6 +93,9 @@
data-limit="{{ limit }}"
data-last-ts="{{ last_ts }}"
data-ws-url="{{ compose_ws_url }}">
<p id="{{ panel_id }}-history-loader" class="compose-history-loader is-size-7 has-text-grey mb-2">
Scroll up to load older messages.
</p>
{% include "partials/compose-message-rows.html" with message_rows=serialized_messages show_empty_state=True empty_message="No stored messages for this contact yet." %}
</div>
<p id="{{ panel_id }}-typing" class="compose-typing is-hidden">
@@ -128,7 +134,7 @@
<p class="help is-size-7 has-text-grey">Send disabled: {{ capability_send_reason }}</p>
{% endif %}
</div>
{% include "partials/bulma-send-composer.html" with composer_class="compose-composer-capsule" textarea_id=panel_id|add:"-textarea" textarea_class="compose-textarea" textarea_name="text" textarea_rows="1" textarea_placeholder="Type a message. Enter to send, Shift+Enter for newline." button_class="is-link is-light compose-send-btn" button_type="submit" button_disabled=True button_title=capability_send_reason|default_if_none:"" button_label="Send" button_icon_class=manual_icon_class %}
{% include "partials/bulma-send-composer.html" with composer_class="compose-composer-capsule" textarea_id=panel_id|add:"-textarea" textarea_class="is-small compose-textarea" textarea_name="text" textarea_rows="1" textarea_placeholder="Type a message. Enter to send, Shift+Enter for newline." button_class="is-link is-light is-small compose-send-btn" button_type="submit" button_disabled=True button_title=capability_send_reason|default_if_none:"" button_label="Send" button_icon_class=manual_icon_class %}
</div>
<div id="{{ panel_id }}-reply-banner" class="compose-reply-banner is-hidden">
<span class="compose-reply-banner-label">Replying to:</span>

View File

@@ -22,6 +22,7 @@
{% for row in contact_rows %}
<a
class="panel-block"
data-gia-widget-id="{{ row.compose_widget_id }}"
hx-get="{{ row.compose_widget_url }}"
hx-target="#widgets-here"
hx-swap="beforeend">

View File

@@ -28,6 +28,7 @@
<div class="buttons are-small m-0">
<button
class="button is-small is-link is-light"
data-gia-widget-id="{{ row.compose_widget_id }}"
hx-get="{{ row.compose_widget_url }}"
hx-include="#{{ browser_form_id }}"
hx-target="#widgets-here"

View File

@@ -197,7 +197,7 @@
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{{ action.url }}"
hx-target="{{ action.target }}"
hx-swap="innerHTML"
hx-swap="{{ action.swap|default:'innerHTML' }}"
{% if action.target == "#windows-here" %}onclick="if (window.giaPrepareWindowAnchor) { window.giaPrepareWindowAnchor(this); }"{% endif %}
title="{{ action.title }}">
<span class="icon"><i class="{{ action.icon }}"></i></span>

View File

@@ -109,6 +109,7 @@
{% if item.can_compose %}
<button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
data-gia-widget-id="{{ item.compose_widget_id }}"
hx-get="{{ item.compose_widget_url }}"
hx-trigger="click"
hx-target="#widgets-here"

View File

@@ -43,6 +43,7 @@
<button
type="button"
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
data-gia-widget-id="{{ item.compose_widget_id }}"
hx-get="{{ item.compose_widget_url }}"
hx-trigger="click"
hx-target="#widgets-here"

View File

@@ -97,6 +97,7 @@
<button
type="button"
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
data-gia-widget-id="{{ item.compose_widget_id }}"
hx-get="{{ item.compose_widget_url }}"
hx-trigger="click"
hx-target="#widgets-here"

View File

@@ -0,0 +1,73 @@
from __future__ import annotations
from django.test import TestCase
from django.urls import reverse
from core.models import Person, PersonIdentifier, User
class BehavioralGraphViewTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(
username="behavior-user",
email="behavior@example.com",
password="pw",
)
self.client.force_login(self.user)
self.person = Person.objects.create(user=self.user, name="Behavior Contact")
PersonIdentifier.objects.create(
user=self.user,
person=self.person,
service="signal",
identifier="+15551234567",
)
def test_behavioral_graph_widget_renders_without_chartjs(self):
response = self.client.get(
reverse(
"ai_workspace_insight_graphs",
kwargs={"type": "widget", "person_id": self.person.id},
)
)
self.assertEqual(200, response.status_code)
content = response.content.decode("utf-8")
self.assertIn("Behavioral Graphs", content)
self.assertIn("MS", content)
self.assertNotIn("chart.js", content.lower())
def test_behavioral_information_page_uses_ms_ps_copy(self):
response = self.client.get(
reverse(
"ai_workspace_information",
kwargs={"type": "page", "person_id": self.person.id},
)
)
self.assertEqual(200, response.status_code)
self.assertContains(response, "MS / PS Information")
self.assertContains(
response, "Current message-state and presence-state signals"
)
def test_ai_person_widget_exposes_behavioral_launcher_only(self):
response = self.client.get(
reverse(
"ai_workspace_person",
kwargs={"type": "widget", "person_id": self.person.id},
)
)
self.assertEqual(200, response.status_code)
content = response.content.decode("utf-8")
self.assertIn("Open MS / PS graphs, information, and help", content)
self.assertIn(
reverse(
"ai_workspace_insight_graphs",
kwargs={"type": "widget", "person_id": self.person.id},
),
content,
)
self.assertNotIn("/insights/platform/", content)
self.assertNotIn("/insights/stability_state/", content)
self.assertNotIn("/insights/commitment_inbound/", content)

View File

@@ -7,6 +7,7 @@ from django.test import TestCase
from django.urls import reverse
from core.models import ChatSession, Message, Person, PersonIdentifier, User
from core.views.compose import COMPOSE_ASSET_VERSION
class ComposeSendCapabilityTests(TestCase):
@@ -77,11 +78,26 @@ class ComposeSendCapabilityTests(TestCase):
self.assertEqual(200, response.status_code)
content = response.content.decode("utf-8")
self.assertIn("compose-panel.css", content)
self.assertIn("compose-panel-core.js", content)
self.assertIn("compose-panel-thread.js", content)
self.assertIn("compose-panel-send.js", content)
self.assertIn("compose-panel.js", content)
self.assertIn(
f"/static/css/compose-panel.css?v={COMPOSE_ASSET_VERSION}",
content,
)
self.assertIn(
f"/static/js/compose-panel-core.js?v={COMPOSE_ASSET_VERSION}",
content,
)
self.assertIn(
f"/static/js/compose-panel-thread.js?v={COMPOSE_ASSET_VERSION}",
content,
)
self.assertIn(
f"/static/js/compose-panel-send.js?v={COMPOSE_ASSET_VERSION}",
content,
)
self.assertIn(
f"/static/js/compose-panel.js?v={COMPOSE_ASSET_VERSION}",
content,
)
self.assertNotIn("const initialTyping = JSON.parse(", content)
self.assertNotIn("data-drafts-url=", content)
self.assertNotIn("data-summary-url=", content)
@@ -104,14 +120,43 @@ class ComposeSendCapabilityTests(TestCase):
self.assertEqual(200, response.status_code)
content = response.content.decode("utf-8")
self.assertIn("data-gia-style-hrefs=", content)
self.assertIn("/static/css/compose-panel.css", content)
self.assertIn(
f"/static/css/compose-panel.css?v={COMPOSE_ASSET_VERSION}",
content,
)
self.assertIn("data-gia-script-srcs=", content)
self.assertIn("/static/js/compose-panel-core.js", content)
self.assertIn("/static/js/compose-panel-thread.js", content)
self.assertIn("/static/js/compose-panel-send.js", content)
self.assertIn("/static/js/compose-panel.js", content)
self.assertIn(
f"/static/js/compose-panel-core.js?v={COMPOSE_ASSET_VERSION}",
content,
)
self.assertIn(
f"/static/js/compose-panel-thread.js?v={COMPOSE_ASSET_VERSION}",
content,
)
self.assertIn(
f"/static/js/compose-panel-send.js?v={COMPOSE_ASSET_VERSION}",
content,
)
self.assertIn(
f"/static/js/compose-panel.js?v={COMPOSE_ASSET_VERSION}",
content,
)
self.assertIn('gs-id="widget-compose-widget-', content)
self.assertNotIn("<script defer src=\"/static/js/compose-panel.js\">", content)
self.assertNotIn("<link rel=\"stylesheet\" href=\"/static/css/compose-panel.css\">", content)
self.assertNotIn('data-gia-action="lock"', content)
self.assertIn('data-gia-action="snap-top"', content)
self.assertIn('data-gia-action="snap-bottom"', content)
def test_compose_workspace_history_widget_uses_shared_shell(self):
response = self.client.get(reverse("compose_workspace_history_widget"))
self.assertEqual(200, response.status_code)
content = response.content.decode("utf-8")
self.assertIn('gs-id="widget-compose-workspace-history"', content)
self.assertNotIn('data-gia-action="lock"', content)
self.assertIn('data-gia-action="snap-top"', content)
self.assertIn('data-gia-action="snap-bottom"', content)
def test_compose_contacts_dropdown_includes_workspace_link(self):
response = self.client.get(reverse("compose_contacts_dropdown"))
@@ -119,6 +164,11 @@ class ComposeSendCapabilityTests(TestCase):
self.assertEqual(200, response.status_code)
self.assertContains(response, reverse("compose_workspace"))
def test_removed_compose_quick_insights_endpoint_returns_404(self):
response = self.client.get("/compose/quick-insights/")
self.assertEqual(404, response.status_code)
@patch("core.views.compose._recent_manual_contacts")
def test_compose_contact_options_use_compact_service_map(self, mocked_recent_contacts):
mocked_recent_contacts.return_value = [
@@ -196,6 +246,57 @@ class ComposeSendCapabilityTests(TestCase):
self.assertIn("compose-row", str(payload.get("messages_html") or ""))
self.assertIn("Rendered thread row", str(payload.get("messages_html") or ""))
def test_compose_thread_before_ts_returns_older_window(self):
person = Person.objects.create(user=self.user, name="Older Contact")
identifier = PersonIdentifier.objects.create(
user=self.user,
person=person,
service="signal",
identifier="+15550001111",
)
session = ChatSession.objects.create(user=self.user, identifier=identifier)
oldest = Message.objects.create(
user=self.user,
session=session,
sender_uuid="contact",
text="Oldest message",
ts=1710000000000,
custom_author="CONTACT",
)
middle = Message.objects.create(
user=self.user,
session=session,
sender_uuid="contact",
text="Middle message",
ts=1710000001000,
custom_author="CONTACT",
)
Message.objects.create(
user=self.user,
session=session,
sender_uuid="contact",
text="Newest message",
ts=1710000002000,
custom_author="CONTACT",
)
response = self.client.get(
reverse("compose_thread"),
{
"service": "signal",
"identifier": "+15550001111",
"before_ts": middle.ts,
},
)
self.assertEqual(200, response.status_code)
payload = response.json()
html = str(payload.get("messages_html") or "")
self.assertIn("Oldest message", html)
self.assertNotIn("Middle message", html)
self.assertFalse(payload.get("has_older"))
self.assertEqual(str(oldest.ts), str(payload["messages"][0]["ts"]))
def test_compose_thread_payload_renders_reply_link_text_server_side(self):
person = Person.objects.create(user=self.user, name="Reply Contact")
identifier = PersonIdentifier.objects.create(

View File

@@ -0,0 +1,33 @@
from __future__ import annotations
from django.test import TestCase
from django.urls import reverse
from core.models import Person, User
class OSINTWidgetActionTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(
username="osint-widget-user",
email="osint-widget@example.com",
password="pw",
)
self.client.force_login(self.user)
self.person = Person.objects.create(user=self.user, name="Widget Person")
def test_people_widget_edit_actions_open_inside_widgets(self):
response = self.client.get(reverse("people", kwargs={"type": "widget"}))
self.assertEqual(200, response.status_code)
content = response.content.decode("utf-8")
self.assertIn(
reverse(
"person_update",
kwargs={"type": "widget", "pk": self.person.pk},
),
content,
)
self.assertIn('hx-target="#widgets-here"', content)
self.assertIn('hx-swap="beforeend"', content)
self.assertNotIn('hx-target="#windows-here"', content)

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")

View File

@@ -666,10 +666,8 @@ class OSINTListBase(ObjectList):
request_type: str,
) -> list[dict[str, Any]]:
context_type = _context_type(request_type)
update_type = "window" if request_type == "widget" else context_type
update_target = (
"#windows-here" if update_type == "window" else f"#{update_type}s-here"
)
update_type = context_type
update_target = f"#{update_type}s-here"
rows = []
for item in object_list:
row = {"id": str(item.pk), "cells": [], "actions": []}
@@ -697,6 +695,7 @@ class OSINTListBase(ObjectList):
"target": update_target,
"icon": "fa-solid fa-pencil",
"title": "Edit",
"swap": "beforeend" if update_type == "widget" else "innerHTML",
}
)
row["actions"].append(

View File

@@ -14,6 +14,7 @@ from core.clients import transport
from core.models import Chat, PersonIdentifier, PlatformChatLink
from core.presence import get_settings as get_availability_settings
from core.presence import latest_state_for_people
from core.widget_ids import compose_widget_dom_id
from core.views.manage.permissions import SuperUserRequiredMixin
from mixins.views import ObjectList, ObjectRead
@@ -292,6 +293,13 @@ class SignalChatsList(SuperUserRequiredMixin, ObjectList):
query += f"&person={person_identifier.person_id}"
compose_page_url = f"{reverse('compose_page')}?{query}"
compose_widget_url = f"{reverse('compose_widget')}?{query}"
compose_widget_id = compose_widget_dom_id(
service,
identifier_value,
person_identifier.person_id if person_identifier else None,
)
else:
compose_widget_id = ""
if person_identifier:
ai_url = (
f"{reverse('ai_workspace')}?person={person_identifier.person_id}"
@@ -304,6 +312,7 @@ class SignalChatsList(SuperUserRequiredMixin, ObjectList):
"chat": chat,
"compose_page_url": compose_page_url,
"compose_widget_url": compose_widget_url,
"compose_widget_id": compose_widget_id,
"ai_url": ai_url,
"person_name": (
person_identifier.person.name if person_identifier else ""
@@ -336,6 +345,11 @@ class SignalChatsList(SuperUserRequiredMixin, ObjectList):
"chat": None,
"compose_page_url": f"{reverse('compose_page')}?{query}",
"compose_widget_url": f"{reverse('compose_widget')}?{query}",
"compose_widget_id": compose_widget_dom_id(
"signal",
group_id,
None,
),
"ai_url": reverse("ai_workspace"),
"person_name": "",
"manual_icon_class": "fa-solid fa-users",

View File

@@ -218,6 +218,7 @@ class WhatsAppContactsList(SuperUserRequiredMixin, ObjectList):
"person_name": linked.person.name if linked else "",
"compose_page_url": urls["page_url"],
"compose_widget_url": urls["widget_url"],
"compose_widget_id": urls["widget_id"],
"match_url": (
f"{reverse('compose_contact_match')}?"
f"{urlencode({'service': 'whatsapp', 'identifier': identifier})}"
@@ -249,6 +250,7 @@ class WhatsAppContactsList(SuperUserRequiredMixin, ObjectList):
"person_name": row.person.name,
"compose_page_url": urls["page_url"],
"compose_widget_url": urls["widget_url"],
"compose_widget_id": urls["widget_id"],
"match_url": (
f"{reverse('compose_contact_match')}?"
f"{urlencode({'service': 'whatsapp', 'identifier': identifier})}"
@@ -331,6 +333,7 @@ class WhatsAppChatsList(WhatsAppContactsList):
"person_name": linked.person.name if linked else "",
"compose_page_url": urls["page_url"],
"compose_widget_url": urls["widget_url"],
"compose_widget_id": urls["widget_id"],
"match_url": (
f"{reverse('compose_contact_match')}?"
f"{urlencode({'service': 'whatsapp', 'identifier': identifier})}"
@@ -384,6 +387,7 @@ class WhatsAppChatsList(WhatsAppContactsList):
"person_name": linked.person.name if linked else "",
"compose_page_url": urls["page_url"],
"compose_widget_url": urls["widget_url"],
"compose_widget_id": urls["widget_id"],
"match_url": (
f"{reverse('compose_contact_match')}?"
f"{urlencode({'service': 'whatsapp', 'identifier': identifier})}"
@@ -417,6 +421,7 @@ class WhatsAppChatsList(WhatsAppContactsList):
"person_name": "",
"compose_page_url": urls["page_url"],
"compose_widget_url": urls["widget_url"],
"compose_widget_id": urls["widget_id"],
"match_url": "",
"last_ts": int(link.updated_at.timestamp()),
}

View File

@@ -40,7 +40,18 @@ from core.models import (
WorkspaceConversation,
WorkspaceMetricSnapshot,
)
from core.workspace import DENSITY_POINT_CAPS, downsample_points
from core.widget_ids import compose_widget_dom_id
from core.workspace import (
BEHAVIORAL_GROUPS,
BEHAVIORAL_METRIC_MAP,
BEHAVIORAL_METRIC_SPECS,
DENSITY_POINT_CAPS,
build_behavioral_graph_payload,
build_behavioral_metric_groups,
downsample_points,
get_behavioral_metric_graph,
sanitize_graph_range,
)
SEND_ENABLED_MODES = {"active", "instant"}
OPERATION_LABELS = {
@@ -735,6 +746,22 @@ def _compose_widget_url_for_person(user, person, limit=40):
return f"{reverse('compose_widget')}?{query}"
def _compose_widget_id_for_person(user, person):
preferred_service = _preferred_service_for_person(user, person)
identifier_row = _resolve_person_identifier(
user=user,
person=person,
preferred_service=preferred_service,
)
if identifier_row is None:
return ""
return compose_widget_dom_id(
identifier_row.service,
identifier_row.identifier,
person.id,
)
def _participant_feedback_display(conversation, person):
payload = conversation.participant_feedback or {}
if not isinstance(payload, dict):
@@ -3514,18 +3541,89 @@ def _workspace_nav_urls(person):
"ai_workspace_insight_graphs",
kwargs={"type": "page", "person_id": person.id},
),
"graphs_widget_url": reverse(
"ai_workspace_insight_graphs",
kwargs={"type": "widget", "person_id": person.id},
),
"information_url": reverse(
"ai_workspace_information",
kwargs={"type": "page", "person_id": person.id},
),
"information_widget_url": reverse(
"ai_workspace_information",
kwargs={"type": "widget", "person_id": person.id},
),
"help_url": reverse(
"ai_workspace_insight_help",
kwargs={"type": "page", "person_id": person.id},
),
"help_widget_url": reverse(
"ai_workspace_insight_help",
kwargs={"type": "widget", "person_id": person.id},
),
"workspace_url": f"{reverse('ai_workspace')}?person={person.id}",
}
def _behavioral_metric_launch_groups(person):
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 _render_ai_workspace_widget(
request,
*,
title,
unique,
window_content,
widget_icon,
widget_options,
**context,
):
return render(
request,
"mixins/wm/widget.html",
{
"title": title,
"unique": unique,
"window_content": window_content,
"widget_icon": widget_icon,
"widget_options": widget_options,
**context,
},
)
def _behavioral_range_urls(request):
return {
"30d": f"{request.path}?range=30d",
"90d": f"{request.path}?range=90d",
"365d": f"{request.path}?range=365d",
"all": f"{request.path}?range=all",
}
class AIWorkspace(LoginRequiredMixin, View):
template_name = "pages/ai-workspace.html"
@@ -3641,12 +3739,23 @@ class AIWorkspacePersonWidget(LoginRequiredMixin, View):
person,
limit=limit,
),
"compose_widget_id": _compose_widget_id_for_person(request.user, person),
"compose_widget_base_url": reverse("compose_widget"),
"history_widget_url": (
reverse("compose_workspace_history_widget")
+ "?"
+ urlencode({"person": str(person.id), "limit": limit})
),
"behavioral_graphs_widget_url": reverse(
"ai_workspace_insight_graphs",
kwargs={"type": "widget", "person_id": person.id},
),
"behavioral_graphs_page_url": reverse(
"ai_workspace_insight_graphs",
kwargs={"type": "page", "person_id": person.id},
),
"behavioral_graph_groups": _behavioral_metric_launch_groups(person),
"behavioral_show_widget_actions": True,
"manual_icon_class": "fa-solid fa-paper-plane",
"send_target_bundle": _send_target_options_for_person(request.user, person),
}
@@ -3685,37 +3794,43 @@ class AIWorkspaceInsightDetail(LoginRequiredMixin, View):
def get(self, request, type, person_id, metric):
if type not in self.allowed_types:
return HttpResponseBadRequest("Invalid type specified")
spec = INSIGHT_METRICS.get(metric)
if spec is None:
if str(metric or "").strip() not in BEHAVIORAL_METRIC_MAP:
return HttpResponseBadRequest("Unknown insight metric")
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()
value = _format_metric_value(conversation, metric, latest_snapshot)
group = INSIGHT_GROUPS[spec["group"]]
graph_applicable = _metric_supports_history(metric, spec)
graph_density = _sanitize_graph_density(request.GET.get("density"))
points = []
if graph_applicable:
points = _history_points(
conversation, spec["history_field"], density=graph_density
range_key = sanitize_graph_range(request.GET.get("range"))
payload = get_behavioral_metric_graph(
user=request.user,
person=person,
metric_slug=metric,
range_key=range_key,
density=graph_density,
)
context = {
"person": person,
"workspace_conversation": conversation,
"metric_slug": metric,
"metric": spec,
"metric_value": value,
"metric_psychology_hint": _metric_psychological_read(metric, conversation),
"metric_group": group,
"graph_points": points,
"graph_applicable": graph_applicable,
"behavioral_groups": BEHAVIORAL_GROUPS,
"metric": payload["metric"],
"summary_cards": payload["summary_cards"],
"coverage": payload["coverage"],
"range_key": range_key,
"graph_density": graph_density,
"graph_density_caps": DENSITY_POINT_CAPS,
"behavioral_graph_groups": _behavioral_metric_launch_groups(person),
"behavioral_show_widget_actions": type == "widget",
"behavioral_range_urls": _behavioral_range_urls(request),
**_workspace_nav_urls(person),
}
if type == "widget":
return _render_ai_workspace_widget(
request,
title=f"{person.name}: {payload['metric']['title']}",
unique=f"ai-behavior-detail-{person.id}-{metric}",
window_content="partials/ai-workspace-behavioral-graph-detail.html",
widget_icon=payload["metric"]["icon"],
widget_options='gs-w="6" gs-h="10" gs-x="6" gs-y="0" gs-min-w="4"',
**context,
)
return render(request, "pages/ai-workspace-insight-detail.html", context)
@@ -3727,17 +3842,37 @@ class AIWorkspaceInsightGraphs(LoginRequiredMixin, View):
return HttpResponseBadRequest("Invalid type specified")
person = get_object_or_404(Person, pk=person_id, user=request.user)
conversation = _conversation_for_person(request.user, person)
graph_density = _sanitize_graph_density(request.GET.get("density"))
graph_cards = _all_graph_payload(conversation, density=graph_density)
range_key = sanitize_graph_range(request.GET.get("range"))
payload = build_behavioral_graph_payload(
user=request.user,
person=person,
range_key=range_key,
density=graph_density,
)
context = {
"person": person,
"workspace_conversation": conversation,
"graph_cards": graph_cards,
"behavioral_groups": BEHAVIORAL_GROUPS,
"graph_cards": payload["graphs"],
"summary_cards": payload["summary_cards"],
"coverage": payload["coverage"],
"range_key": range_key,
"graph_density": graph_density,
"graph_density_caps": DENSITY_POINT_CAPS,
"behavioral_graph_groups": _behavioral_metric_launch_groups(person),
"behavioral_show_widget_actions": type == "widget",
"behavioral_range_urls": _behavioral_range_urls(request),
**_workspace_nav_urls(person),
}
if type == "widget":
return _render_ai_workspace_widget(
request,
title=f"{person.name}: Behavioral Graphs",
unique=f"ai-behavior-graphs-{person.id}",
window_content="partials/ai-workspace-behavioral-graphs.html",
widget_icon="fa-solid fa-chart-line",
widget_options='gs-w="8" gs-h="11" gs-x="4" gs-y="0" gs-min-w="4"',
**context,
)
return render(request, "pages/ai-workspace-insight-graphs.html", context)
@@ -3749,39 +3884,38 @@ class AIWorkspaceInformation(LoginRequiredMixin, View):
return HttpResponseBadRequest("Invalid type specified")
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()
directionality = _commitment_directionality_payload(conversation)
graph_density = _sanitize_graph_density(request.GET.get("density"))
commitment_graph_cards = [
card
for card in _all_graph_payload(conversation, density=graph_density)
if card["group"] == "commitment"
]
graph_refs = []
for ref in directionality.get("graph_refs", []):
slug = ref.get("slug")
if not slug:
continue
graph_refs.append(
{
**ref,
"slug": slug,
"value": _format_metric_value(conversation, slug, latest_snapshot),
}
range_key = sanitize_graph_range(request.GET.get("range"))
payload = build_behavioral_graph_payload(
user=request.user,
person=person,
range_key=range_key,
density=graph_density,
)
directionality["graph_refs"] = graph_refs
context = {
"person": person,
"workspace_conversation": conversation,
"directionality": directionality,
"overview_rows": _information_overview_rows(conversation),
"commitment_graph_cards": commitment_graph_cards,
"behavioral_groups": BEHAVIORAL_GROUPS,
"summary_cards": payload["summary_cards"],
"graph_cards": payload["graphs"],
"coverage": payload["coverage"],
"range_key": range_key,
"graph_density": graph_density,
"behavioral_graph_groups": _behavioral_metric_launch_groups(person),
"behavioral_show_widget_actions": type == "widget",
"behavioral_range_urls": _behavioral_range_urls(request),
**_workspace_nav_urls(person),
}
if type == "widget":
return _render_ai_workspace_widget(
request,
title=f"{person.name}: MS/PS Information",
unique=f"ai-behavior-info-{person.id}",
window_content="partials/ai-workspace-behavioral-information.html",
widget_icon="fa-solid fa-circle-info",
widget_options='gs-w="6" gs-h="10" gs-x="6" gs-y="0" gs-min-w="4"',
**context,
)
return render(request, "pages/ai-workspace-information.html", context)
@@ -3793,33 +3927,37 @@ class AIWorkspaceInsightHelp(LoginRequiredMixin, View):
return HttpResponseBadRequest("Invalid type specified")
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(
{
"slug": slug,
"title": spec["title"],
"group": spec["group"],
"group_title": INSIGHT_GROUPS[spec["group"]]["title"],
"calculation": spec["calculation"],
"psychology": spec["psychology"],
"value": _format_metric_value(
conversation,
slug,
latest_snapshot,
),
}
graph_density = _sanitize_graph_density(request.GET.get("density"))
range_key = sanitize_graph_range(request.GET.get("range"))
payload = build_behavioral_graph_payload(
user=request.user,
person=person,
range_key=range_key,
density=graph_density,
)
context = {
"person": person,
"workspace_conversation": conversation,
"groups": INSIGHT_GROUPS,
"metrics": metrics,
"groups": BEHAVIORAL_GROUPS,
"metrics": payload["graphs"],
"summary_cards": payload["summary_cards"],
"coverage": payload["coverage"],
"range_key": range_key,
"behavioral_graph_groups": _behavioral_metric_launch_groups(person),
"behavioral_show_widget_actions": type == "widget",
"behavioral_range_urls": _behavioral_range_urls(request),
**_workspace_nav_urls(person),
}
if type == "widget":
return _render_ai_workspace_widget(
request,
title=f"{person.name}: MS/PS Help",
unique=f"ai-behavior-help-{person.id}",
window_content="partials/ai-workspace-behavioral-help.html",
widget_icon="fa-solid fa-circle-question",
widget_options='gs-w="6" gs-h="10" gs-x="6" gs-y="0" gs-min-w="4"',
**context,
)
return render(request, "pages/ai-workspace-insight-help.html", context)

28
core/widget_ids.py Normal file
View File

@@ -0,0 +1,28 @@
import re
_NON_ALNUM_PATTERN = re.compile(r"[^a-z0-9]+")
def _normalize_widget_segment(value) -> str:
cleaned = _NON_ALNUM_PATTERN.sub("-", str(value or "").strip().lower()).strip("-")
return cleaned or "none"
def compose_widget_unique(service, identifier, person_id=None) -> str:
service_key = str(service or "").strip().lower() or "signal"
identifier_value = str(identifier or "").strip()
if service_key == "whatsapp" and "@" in identifier_value:
identifier_value = identifier_value.split("@", 1)[0].strip()
return "-".join(
[
"compose-widget",
_normalize_widget_segment(service_key),
_normalize_widget_segment(identifier_value),
_normalize_widget_segment(person_id or "none"),
]
)
def compose_widget_dom_id(service, identifier, person_id=None) -> str:
return f"widget-{compose_widget_unique(service, identifier, person_id)}"

View File

@@ -1,3 +1,25 @@
from .behavioral import (
BEHAVIORAL_GROUPS,
BEHAVIORAL_METRIC_MAP,
BEHAVIORAL_METRIC_SPECS,
build_behavioral_graph_payload,
build_behavioral_metric_groups,
format_metric_value,
get_behavioral_metric_graph,
sanitize_graph_range,
)
from .sampling import DENSITY_POINT_CAPS, compact_snapshot_rows, downsample_points
__all__ = ["DENSITY_POINT_CAPS", "compact_snapshot_rows", "downsample_points"]
__all__ = [
"BEHAVIORAL_GROUPS",
"BEHAVIORAL_METRIC_MAP",
"BEHAVIORAL_METRIC_SPECS",
"DENSITY_POINT_CAPS",
"build_behavioral_graph_payload",
"build_behavioral_metric_groups",
"compact_snapshot_rows",
"downsample_points",
"format_metric_value",
"get_behavioral_metric_graph",
"sanitize_graph_range",
]

View File

@@ -0,0 +1,823 @@
from __future__ import annotations
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from typing import Any, Callable
from django.utils import timezone as dj_timezone
from core.models import ChatSession, ConversationEvent, Message, PersonIdentifier
from core.workspace.sampling import downsample_points
MS_MINUTE = 60 * 1000
MS_HOUR = 60 * MS_MINUTE
MS_DAY = 24 * MS_HOUR
GRAPH_RANGE_DAYS = {
"30d": 30,
"90d": 90,
"365d": 365,
"all": 0,
}
BEHAVIORAL_GROUPS = {
"ms": {
"title": "Message State (MS)",
"eyebrow": "sent -> delivered -> read -> typing -> replied",
"summary": (
"Tracks the message journey from send through delivery, reading, "
"typing, revision, abandonment, and response."
),
},
"ps": {
"title": "Presence State (PS)",
"eyebrow": "unavailable -> available -> typing -> sent/stopped",
"summary": (
"Tracks return-from-absence behavior: how long someone stayed away, "
"how fast they typed on return, and whether that typing converted into "
"a sent response."
),
},
}
BEHAVIORAL_METRIC_SPECS = (
{
"slug": "ms_sent_to_delivered",
"group": "ms",
"title": "Delivered Time",
"menu_title": "Delivered time",
"icon": "fa-solid fa-truck-fast",
"unit": "duration",
"aggregate": "mean",
"state_label": "Delay A",
"calculation": (
"Average time from outbound message send until the recipient device is "
"reported as delivered."
),
"psychology": (
"Mostly technical. Persistent growth can still indicate longer device "
"unavailability or deliberate disconnection when it repeats over time."
),
},
{
"slug": "ms_delivered_to_read",
"group": "ms",
"title": "Read Time",
"menu_title": "Read time",
"icon": "fa-solid fa-eye",
"unit": "duration",
"aggregate": "mean",
"state_label": "Delay B",
"calculation": (
"Average time from delivery receipt until read receipt for outbound "
"messages."
),
"psychology": (
"Attention allocation. Shrinking read time usually means rising "
"salience; growing read time usually means declining priority."
),
},
{
"slug": "ms_read_to_typing",
"group": "ms",
"title": "Decision Latency",
"menu_title": "Decision latency",
"icon": "fa-solid fa-hourglass-half",
"unit": "duration",
"aggregate": "mean",
"state_label": "Delay C.1",
"calculation": (
"Average time from read receipt until the next inbound typing start in "
"the same session."
),
"psychology": (
"How long they appraise before engaging. High values often reflect "
"deliberation, emotional load, or uncertainty."
),
},
{
"slug": "ms_read_to_replied",
"group": "ms",
"title": "Reply Time",
"menu_title": "Responded time",
"icon": "fa-solid fa-reply",
"unit": "duration",
"aggregate": "mean",
"state_label": "Delay C",
"calculation": (
"Average time from read receipt until the next inbound message in the "
"same session."
),
"psychology": (
"Full deliberation-to-response window. Rising values can mean lower "
"priority, higher emotional weight, or more careful self-regulation."
),
},
{
"slug": "ms_typing_duration",
"group": "ms",
"title": "Typing Time",
"menu_title": "Typing time",
"icon": "fa-solid fa-keyboard",
"unit": "duration",
"aggregate": "mean",
"state_label": "Delay C.2",
"calculation": (
"Average time between the first inbound typing start in a cycle and the "
"inbound message that resolves that cycle."
),
"psychology": (
"Effort and backstage editing. Longer typing usually means more "
"investment, greater complexity, or stronger impression management."
),
},
{
"slug": "ms_revision_cycles",
"group": "ms",
"title": "Revision Cycles",
"menu_title": "Revision cycles",
"icon": "fa-solid fa-rotate",
"unit": "cycles",
"aggregate": "mean",
"state_label": "Delay C.3",
"calculation": (
"Average number of typing revisions before an inbound message is sent."
),
"psychology": (
"Repeated stop/start cycles imply self-censorship, uncertainty, or "
"heightened concern about how the message will land."
),
},
{
"slug": "ms_aborted_count",
"group": "ms",
"title": "Aborted Messages",
"menu_title": "Aborted messages",
"icon": "fa-solid fa-ban",
"unit": "count",
"aggregate": "sum",
"state_label": "Delay C.4",
"calculation": (
"Count of inbound composing cycles that end in a synthetic "
"composing_abandoned event."
),
"psychology": (
"Approach without expression. Repeated abandonment often signals "
"suppression, avoidance, or unresolved ambivalence."
),
},
{
"slug": "ms_aborted_rate",
"group": "ms",
"title": "Aborted Rate",
"menu_title": "Aborted rate",
"icon": "fa-solid fa-percent",
"unit": "percent",
"aggregate": "mean",
"state_label": "Delay C.4",
"calculation": (
"Share of inbound typing cycles that end abandoned instead of sent."
),
"psychology": (
"A steadier relational suppression signal than raw counts because it "
"normalises for overall typing volume."
),
},
{
"slug": "ps_offline_duration",
"group": "ps",
"title": "Offline Duration",
"menu_title": "Offline duration",
"icon": "fa-solid fa-moon",
"unit": "duration",
"aggregate": "mean",
"state_label": "Delay E",
"calculation": (
"Average time between presence_unavailable and the next "
"presence_available event."
),
"psychology": (
"Context window. This is not engagement by itself, but it frames how "
"meaningful their return behavior is."
),
},
{
"slug": "ps_available_to_typing",
"group": "ps",
"title": "Typing On Return",
"menu_title": "Available to typing",
"icon": "fa-solid fa-bolt",
"unit": "duration",
"aggregate": "mean",
"state_label": "Delay F",
"calculation": (
"Average time from a return-to-available event until the first inbound "
"typing start after that return."
),
"psychology": (
"Priority on return. Very short values after long absences are strong "
"signals of attentional rank."
),
},
{
"slug": "ps_typing_duration",
"group": "ps",
"title": "Typing After Return",
"menu_title": "Typing after return",
"icon": "fa-solid fa-keyboard",
"unit": "duration",
"aggregate": "mean",
"state_label": "Delay G.1",
"calculation": (
"Average typing duration for the first composing cycle that starts "
"after a presence return."
),
"psychology": (
"How much effort or self-editing happens once the contact actively "
"chooses your conversation as part of their return sequence."
),
},
{
"slug": "ps_aborted_count",
"group": "ps",
"title": "Aborted On Return",
"menu_title": "Aborted after return",
"icon": "fa-solid fa-circle-stop",
"unit": "count",
"aggregate": "sum",
"state_label": "Delay G.2",
"calculation": (
"Count of first post-return composing cycles that are abandoned "
"instead of sent."
),
"psychology": (
"A high-signal approach-avoidance marker: they returned, chose your "
"thread, started to type, and still withdrew."
),
},
{
"slug": "ps_aborted_rate",
"group": "ps",
"title": "Aborted On Return Rate",
"menu_title": "Aborted on return rate",
"icon": "fa-solid fa-chart-pie",
"unit": "percent",
"aggregate": "mean",
"state_label": "Delay G.2",
"calculation": (
"Share of first post-return composing cycles that end abandoned."
),
"psychology": (
"One of the strongest signals of hesitation specifically during "
"re-entry into the conversation."
),
},
)
BEHAVIORAL_METRIC_MAP = {
spec["slug"]: spec for spec in BEHAVIORAL_METRIC_SPECS
}
BEHAVIORAL_SUMMARY_SLUGS = (
"ms_sent_to_delivered",
"ms_delivered_to_read",
"ms_read_to_replied",
"ms_typing_duration",
"ms_aborted_rate",
"ps_available_to_typing",
)
def sanitize_graph_range(value: str) -> str:
candidate = str(value or "").strip().lower()
if candidate in GRAPH_RANGE_DAYS:
return candidate
return "90d"
def _safe_int(value: Any, default: int = 0) -> int:
try:
return int(value)
except Exception:
return int(default)
def _format_duration(value_ms: float | int | None) -> str:
if value_ms is None:
return "-"
value = max(0, int(round(float(value_ms))))
if value < MS_MINUTE:
return f"{value // 1000}s"
if value < MS_HOUR:
minutes = float(value) / float(MS_MINUTE)
return f"{minutes:.1f}m"
if value < MS_DAY:
hours = float(value) / float(MS_HOUR)
return f"{hours:.1f}h"
days = float(value) / float(MS_DAY)
return f"{days:.1f}d"
def format_metric_value(spec: dict, value: float | int | None) -> str:
if value is None:
return "-"
unit = str(spec.get("unit") or "").strip().lower()
if unit == "duration":
return _format_duration(value)
if unit == "percent":
return f"{float(value):.1f}%"
if unit == "cycles":
return f"{float(value):.2f} cycles"
if unit == "count":
return str(int(round(float(value))))
return str(value)
def _format_metric_delta(spec: dict, current: float | int | None, previous: float | int | None) -> str:
if current is None or previous is None:
return ""
delta = float(current) - float(previous)
if abs(delta) < 0.0001:
return "steady"
unit = str(spec.get("unit") or "").strip().lower()
if unit == "duration":
direction = "longer" if delta > 0 else "shorter"
return f"{_format_duration(abs(delta))} {direction}"
if unit == "percent":
direction = "higher" if delta > 0 else "lower"
return f"{abs(delta):.1f}pp {direction}"
if unit == "cycles":
direction = "higher" if delta > 0 else "lower"
return f"{abs(delta):.2f} cycles {direction}"
if unit == "count":
direction = "more" if delta > 0 else "fewer"
return f"{int(round(abs(delta)))} {direction}"
return ""
def _bucket_label(ts_ms: int) -> str:
dt_value = datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc)
return dt_value.strftime("%Y-%m-%d")
def _bucket_start_ms(ts_ms: int) -> int:
dt_value = datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc)
bucket = datetime(dt_value.year, dt_value.month, dt_value.day, tzinfo=timezone.utc)
return int(bucket.timestamp() * 1000)
def _graph_geometry(points: list[dict]) -> dict[str, Any]:
clean = [row for row in list(points or []) if row.get("y") is not None]
if not clean:
return {
"polyline": "",
"area": "",
"markers": [],
"y_min": None,
"y_max": None,
}
values = [float(row["y"]) for row in clean]
y_min = min(values)
y_max = max(values)
if abs(y_max - y_min) < 0.0001:
y_min -= 1.0
y_max += 1.0
width = 100.0
height = 48.0
pad_x = 4.0
pad_y = 5.0
usable_width = max(1.0, width - (pad_x * 2.0))
usable_height = max(1.0, height - (pad_y * 2.0))
points_attr = []
markers = []
for idx, row in enumerate(clean):
x = pad_x
if len(clean) > 1:
x += usable_width * (float(idx) / float(len(clean) - 1))
y_ratio = (float(row["y"]) - y_min) / float(y_max - y_min)
y = height - pad_y - (usable_height * y_ratio)
points_attr.append(f"{x:.2f},{y:.2f}")
markers.append(
{
"x": f"{x:.2f}",
"y": f"{y:.2f}",
"value_label": row.get("value_label") or "",
"label": row.get("label") or "",
}
)
area = ""
if points_attr:
first_x = points_attr[0].split(",")[0]
last_x = points_attr[-1].split(",")[0]
baseline = f"{height - pad_y:.2f}"
area = (
f"M {first_x},{baseline} "
+ " L ".join(points_attr)
+ f" L {last_x},{baseline} Z"
)
return {
"polyline": " ".join(points_attr),
"area": area,
"markers": markers[-10:],
"y_min": y_min,
"y_max": y_max,
}
def _collect_messages(user, session_ids: list[str], person_identifiers: set[str]) -> dict[str, list[dict]]:
rows = (
Message.objects.filter(user=user, session_id__in=session_ids)
.order_by("session_id", "ts")
.values(
"id",
"session_id",
"ts",
"sender_uuid",
"delivered_ts",
"read_ts",
)
)
grouped: dict[str, list[dict]] = defaultdict(list)
for row in rows:
sender = str(row.get("sender_uuid") or "").strip()
direction = "in" if sender and sender in person_identifiers else "out"
grouped[str(row.get("session_id") or "")].append(
{
"id": str(row.get("id") or ""),
"ts": _safe_int(row.get("ts"), 0),
"direction": direction,
"delivered_ts": _safe_int(row.get("delivered_ts"), 0),
"read_ts": _safe_int(row.get("read_ts"), 0),
}
)
return grouped
def _collect_events(user, session_ids: list[str]) -> dict[str, list[dict]]:
rows = (
ConversationEvent.objects.filter(
user=user,
session_id__in=session_ids,
event_type__in=[
"message_created",
"typing_started",
"typing_stopped",
"composing_abandoned",
"presence_available",
"presence_unavailable",
"read_receipt",
],
)
.order_by("session_id", "ts", "created_at")
.values(
"session_id",
"ts",
"event_type",
"direction",
"payload",
)
)
grouped: dict[str, list[dict]] = defaultdict(list)
for row in rows:
payload = row.get("payload") or {}
if not isinstance(payload, dict):
payload = {}
grouped[str(row.get("session_id") or "")].append(
{
"session_id": str(row.get("session_id") or ""),
"ts": _safe_int(row.get("ts"), 0),
"event_type": str(row.get("event_type") or "").strip().lower(),
"direction": str(row.get("direction") or "").strip().lower(),
"payload": payload,
}
)
return grouped
def _append_sample(store: dict[str, list[dict]], slug: str, ts_ms: int, value: float) -> None:
if ts_ms <= 0:
return
store[slug].append({"ts_ms": int(ts_ms), "value": float(value)})
def _session_message_samples(messages: list[dict], store: dict[str, list[dict]]) -> None:
inbound_messages = [row for row in messages if row.get("direction") == "in"]
if not inbound_messages:
inbound_messages = []
inbound_index = 0
for message in messages:
if message.get("direction") != "out":
continue
sent_ts = _safe_int(message.get("ts"), 0)
delivered_ts = _safe_int(message.get("delivered_ts"), 0)
read_ts = _safe_int(message.get("read_ts"), 0)
if delivered_ts > 0 and delivered_ts >= sent_ts:
_append_sample(
store,
"ms_sent_to_delivered",
delivered_ts,
delivered_ts - sent_ts,
)
if read_ts > 0 and delivered_ts > 0 and read_ts >= delivered_ts:
_append_sample(
store,
"ms_delivered_to_read",
read_ts,
read_ts - delivered_ts,
)
if read_ts <= 0:
continue
while inbound_index < len(inbound_messages):
candidate = inbound_messages[inbound_index]
if _safe_int(candidate.get("ts"), 0) >= read_ts:
break
inbound_index += 1
if inbound_index >= len(inbound_messages):
continue
response = inbound_messages[inbound_index]
response_ts = _safe_int(response.get("ts"), 0)
if response_ts >= read_ts:
_append_sample(
store,
"ms_read_to_replied",
response_ts,
response_ts - read_ts,
)
def _session_event_samples(events: list[dict], store: dict[str, list[dict]]) -> None:
typing_state: dict[str, Any] | None = None
pending_read_ts = 0
available_state: dict[str, Any] | None = None
unavailable_ts = 0
for event in events:
event_type = str(event.get("event_type") or "")
ts_ms = _safe_int(event.get("ts"), 0)
direction = str(event.get("direction") or "")
payload = event.get("payload") or {}
if not isinstance(payload, dict):
payload = {}
if event_type == "presence_unavailable":
unavailable_ts = ts_ms
available_state = None
continue
if event_type == "presence_available":
if unavailable_ts > 0 and ts_ms >= unavailable_ts:
_append_sample(
store,
"ps_offline_duration",
ts_ms,
ts_ms - unavailable_ts,
)
available_state = {
"ts": ts_ms,
"consumed": False,
}
unavailable_ts = 0
continue
if event_type == "read_receipt":
pending_read_ts = ts_ms
continue
if event_type == "typing_started" and direction == "in":
revision = max(1, _safe_int(payload.get("revision"), 1))
if typing_state is None:
after_return = bool(
available_state
and not bool(available_state.get("consumed"))
and ts_ms >= _safe_int(available_state.get("ts"), 0)
)
if after_return:
available_ts = _safe_int(available_state.get("ts"), 0)
_append_sample(
store,
"ps_available_to_typing",
ts_ms,
ts_ms - available_ts,
)
available_state["consumed"] = True
if pending_read_ts > 0 and ts_ms >= pending_read_ts:
_append_sample(
store,
"ms_read_to_typing",
ts_ms,
ts_ms - pending_read_ts,
)
pending_read_ts = 0
typing_state = {
"started_ts": ts_ms,
"revision": revision,
"after_return": after_return,
}
else:
typing_state["revision"] = max(
int(typing_state.get("revision") or 1),
revision,
)
continue
if event_type == "message_created" and direction == "in":
if typing_state is None:
pending_read_ts = 0
continue
duration_ms = max(0, ts_ms - _safe_int(typing_state.get("started_ts"), 0))
_append_sample(store, "ms_typing_duration", ts_ms, duration_ms)
_append_sample(
store,
"ms_revision_cycles",
ts_ms,
max(1, _safe_int(typing_state.get("revision"), 1)),
)
_append_sample(store, "ms_aborted_rate", ts_ms, 0)
if typing_state.get("after_return"):
_append_sample(store, "ps_typing_duration", ts_ms, duration_ms)
_append_sample(store, "ps_aborted_rate", ts_ms, 0)
typing_state = None
pending_read_ts = 0
continue
if event_type == "composing_abandoned" and direction == "in":
if typing_state is None:
_append_sample(store, "ms_aborted_count", ts_ms, 1)
_append_sample(store, "ms_aborted_rate", ts_ms, 1)
continue
_append_sample(store, "ms_aborted_count", ts_ms, 1)
_append_sample(store, "ms_aborted_rate", ts_ms, 1)
_append_sample(
store,
"ms_revision_cycles",
ts_ms,
max(1, _safe_int(typing_state.get("revision"), 1)),
)
if typing_state.get("after_return"):
_append_sample(store, "ps_aborted_count", ts_ms, 1)
_append_sample(store, "ps_aborted_rate", ts_ms, 1)
typing_state = None
continue
def _aggregate_bucket(spec: dict, rows: list[dict]) -> float | int | None:
if not rows:
return None
values = [float(row.get("value") or 0.0) for row in rows]
aggregate = str(spec.get("aggregate") or "mean").strip().lower()
if aggregate == "sum":
return sum(values)
mean = sum(values) / float(len(values))
if str(spec.get("unit") or "").strip().lower() == "percent":
return mean * 100.0
return mean
def _build_metric_graph(spec: dict, samples: list[dict], range_key: str, density: str) -> dict[str, Any]:
range_days = int(GRAPH_RANGE_DAYS.get(range_key, 90))
cutoff_ts = 0
if range_days > 0:
cutoff_dt = dj_timezone.now() - timedelta(days=range_days)
cutoff_ts = int(cutoff_dt.timestamp() * 1000)
filtered = [
dict(row)
for row in list(samples or [])
if _safe_int(row.get("ts_ms"), 0) > 0
and (cutoff_ts <= 0 or _safe_int(row.get("ts_ms"), 0) >= cutoff_ts)
]
buckets: dict[int, list[dict]] = defaultdict(list)
for row in filtered:
buckets[_bucket_start_ms(_safe_int(row.get("ts_ms"), 0))].append(row)
raw_points = []
for bucket_ts in sorted(buckets.keys()):
value = _aggregate_bucket(spec, buckets[bucket_ts])
if value is None:
continue
raw_points.append(
{
"x": datetime.fromtimestamp(
bucket_ts / 1000, tz=timezone.utc
).isoformat(),
"y": round(float(value), 3),
"ts_ms": bucket_ts,
"label": _bucket_label(bucket_ts),
"value_label": format_metric_value(spec, value),
}
)
points = downsample_points(raw_points, density=density)
for row in points:
row["label"] = _bucket_label(_safe_int(row.get("ts_ms"), 0))
row["value_label"] = format_metric_value(spec, row.get("y"))
latest_value = points[-1]["y"] if points else None
previous_value = points[-2]["y"] if len(points) > 1 else None
geometry = _graph_geometry(points)
return {
**spec,
"points": points,
"raw_count": len(filtered),
"count": len(points),
"current_value": latest_value,
"current_value_label": format_metric_value(spec, latest_value),
"delta_label": _format_metric_delta(spec, latest_value, previous_value),
"latest_bucket_label": points[-1]["label"] if points else "",
"has_data": bool(points),
"polyline": geometry["polyline"],
"area_path": geometry["area"],
"markers": geometry["markers"],
"y_min_label": format_metric_value(spec, geometry["y_min"]),
"y_max_label": format_metric_value(spec, geometry["y_max"]),
}
def build_behavioral_graph_payload(*, user, person, range_key: str = "90d", density: str = "medium") -> dict[str, Any]:
sessions = list(
ChatSession.objects.filter(user=user, identifier__person=person)
.select_related("identifier")
.order_by("identifier__service", "identifier__identifier")
)
session_ids = [str(session.id) for session in sessions]
identifiers = set(
PersonIdentifier.objects.filter(user=user, person=person).values_list(
"identifier", flat=True
)
)
samples: dict[str, list[dict]] = defaultdict(list)
messages_by_session = _collect_messages(user, session_ids, identifiers)
events_by_session = _collect_events(user, session_ids)
for session_id in session_ids:
_session_message_samples(messages_by_session.get(session_id, []), samples)
_session_event_samples(events_by_session.get(session_id, []), samples)
graphs = [
_build_metric_graph(
spec,
samples.get(spec["slug"], []),
range_key=range_key,
density=density,
)
for spec in BEHAVIORAL_METRIC_SPECS
]
summary_cards = [
graph for graph in graphs if graph["slug"] in BEHAVIORAL_SUMMARY_SLUGS
]
return {
"groups": BEHAVIORAL_GROUPS,
"graphs": graphs,
"summary_cards": summary_cards,
"range_key": range_key,
"range_label": {
"30d": "Last 30 days",
"90d": "Last 90 days",
"365d": "Last year",
"all": "All time",
}.get(range_key, "Last 90 days"),
"coverage": {
"session_count": len(session_ids),
"message_count": sum(len(rows) for rows in messages_by_session.values()),
"event_count": sum(len(rows) for rows in events_by_session.values()),
"services": sorted(
{
str(getattr(session.identifier, "service", "") or "").strip().lower()
for session in sessions
if getattr(session, "identifier", None) is not None
}
),
},
}
def get_behavioral_metric_graph(*, user, person, metric_slug: str, range_key: str = "90d", density: str = "medium") -> dict[str, Any]:
payload = build_behavioral_graph_payload(
user=user,
person=person,
range_key=range_key,
density=density,
)
metric = next(
(row for row in payload["graphs"] if row["slug"] == str(metric_slug or "").strip()),
None,
)
if metric is None:
raise KeyError(metric_slug)
return {
**payload,
"metric": metric,
}
def build_behavioral_metric_groups(item_builder: Callable[[dict], dict[str, Any]]) -> list[dict[str, Any]]:
groups = []
for group_key, group in BEHAVIORAL_GROUPS.items():
groups.append(
{
"key": group_key,
"title": group["title"],
"eyebrow": group["eyebrow"],
"items": [
item_builder(spec)
for spec in BEHAVIORAL_METRIC_SPECS
if spec["group"] == group_key
],
}
)
return groups

View File

@@ -3,7 +3,7 @@
data-gia-widget-shell="1"
{% if widget_style_hrefs %}data-gia-style-hrefs="{{ widget_style_hrefs|join:'|' }}"{% endif %}
{% if widget_script_srcs %}data-gia-script-srcs="{{ widget_script_srcs|join:'|' }}"{% endif %}>
<div id="widget-{{ unique }}" class="grid-stack-item" {% block widget_options %}{% if widget_options is None %}gs-w="6" gs-h="1" gs-y="20" gs-x="0"{% else %}{% autoescape off %}{{ widget_options }}{% endautoescape %}{% endif %}{% endblock %}>
<div id="widget-{{ unique }}" class="grid-stack-item" gs-id="widget-{{ unique }}" {% block widget_options %}{% if widget_options is None %}gs-w="6" gs-h="1" gs-y="20" gs-x="0"{% else %}{% autoescape off %}{{ widget_options }}{% endautoescape %}{% endif %}{% endblock %}>
<div class="grid-stack-item-content">
<section class="gia-widget-panel">
<header class="gia-widget-heading">
@@ -34,6 +34,14 @@
aria-label="Snap window left">
<span class="icon is-small"><i class="fa-solid fa-arrow-left"></i></span>
</button>
<button
type="button"
class="button is-light js-gia-widget-action"
data-gia-action="snap-top"
data-gia-widget-id="widget-{{ unique }}"
aria-label="Snap window top">
<span class="icon is-small"><i class="fa-solid fa-arrow-up"></i></span>
</button>
<button
type="button"
class="button is-light js-gia-widget-action"
@@ -42,6 +50,14 @@
aria-label="Snap window right">
<span class="icon is-small"><i class="fa-solid fa-arrow-right"></i></span>
</button>
<button
type="button"
class="button is-light js-gia-widget-action"
data-gia-action="snap-bottom"
data-gia-widget-id="widget-{{ unique }}"
aria-label="Snap window bottom">
<span class="icon is-small"><i class="fa-solid fa-arrow-down"></i></span>
</button>
<button
type="button"
class="button is-light js-gia-widget-action"

View File

@@ -3,7 +3,7 @@
data-gia-widget-shell="1"
{% if widget_style_hrefs %}data-gia-style-hrefs="{{ widget_style_hrefs|join:'|' }}"{% endif %}
{% if widget_script_srcs %}data-gia-script-srcs="{{ widget_script_srcs|join:'|' }}"{% endif %}>
<div id="widget-{{ unique }}" class="grid-stack-item" {% block widget_options %}{% if widget_options is None %}gs-w="6" gs-h="1" gs-y="20" gs-x="0"{% else %}{% autoescape off %}{{ widget_options }}{% endautoescape %}{% endif %}{% endblock %}>
<div id="widget-{{ unique }}" class="grid-stack-item" gs-id="widget-{{ unique }}" {% block widget_options %}{% if widget_options is None %}gs-w="6" gs-h="1" gs-y="20" gs-x="0"{% else %}{% autoescape off %}{{ widget_options }}{% endautoescape %}{% endif %}{% endblock %}>
<div class="grid-stack-item-content">
<section class="gia-widget-panel">
<header class="gia-widget-heading">
@@ -34,6 +34,14 @@
aria-label="Snap window left">
<span class="icon is-small"><i class="fa-solid fa-arrow-left"></i></span>
</button>
<button
type="button"
class="button is-light js-gia-widget-action"
data-gia-action="snap-top"
data-gia-widget-id="widget-{{ unique }}"
aria-label="Snap window top">
<span class="icon is-small"><i class="fa-solid fa-arrow-up"></i></span>
</button>
<button
type="button"
class="button is-light js-gia-widget-action"
@@ -42,6 +50,14 @@
aria-label="Snap window right">
<span class="icon is-small"><i class="fa-solid fa-arrow-right"></i></span>
</button>
<button
type="button"
class="button is-light js-gia-widget-action"
data-gia-action="snap-bottom"
data-gia-widget-id="widget-{{ unique }}"
aria-label="Snap window bottom">
<span class="icon is-small"><i class="fa-solid fa-arrow-down"></i></span>
</button>
<button
type="button"
class="button is-light js-gia-widget-action"