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>
{% 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 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>
{% include "partials/ai-workspace-behavioral-information.html" %}
</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>
</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>
{% 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 }}
</div>
</article>
{% endif %}
<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>
{% include "partials/ai-workspace-behavioral-graph-detail.html" %}
</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>
<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.
</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 class="mb-4">
<h1 class="title is-4 mb-1">Behavioral Graphs: {{ person.name }}</h1>
<p class="is-size-7 has-text-grey">
Rebuilt from message and event timing data using the MS/PS model rather than workspace snapshots.
</p>
{% include "partials/ai-insight-nav.html" with active_tab="graphs" %}
</div>
{% include "partials/ai-insight-nav.html" with active_tab="graphs" %}
{% include "partials/ai-workspace-behavioral-graphs.html" %}
</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>
</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>
<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.
</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 class="mb-4">
<h1 class="title is-4 mb-1">MS / PS Help: {{ person.name }}</h1>
<p class="is-size-7 has-text-grey">
Definitions and psychological interpretation for the timing signal system.
</p>
{% include "partials/ai-insight-nav.html" with active_tab="help" %}
</div>
{% endfor %}
</div>
{% 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),
}
)
directionality["graph_refs"] = graph_refs
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,
"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"