Rebuild workspace widgets and behavioral graph views
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -170,5 +170,7 @@ node_modules/
|
|||||||
.codex-cli/
|
.codex-cli/
|
||||||
.ship-safe/
|
.ship-safe/
|
||||||
.uwsgi-reload
|
.uwsgi-reload
|
||||||
|
mixins/static/
|
||||||
|
vendor/django-crud-mixins/mixins/static/
|
||||||
|
|
||||||
.container-home/
|
.container-home/
|
||||||
|
|||||||
@@ -342,11 +342,6 @@ urlpatterns = [
|
|||||||
compose.ComposeSummary.as_view(),
|
compose.ComposeSummary.as_view(),
|
||||||
name="compose_summary",
|
name="compose_summary",
|
||||||
),
|
),
|
||||||
path(
|
|
||||||
"compose/quick-insights/",
|
|
||||||
compose.ComposeQuickInsights.as_view(),
|
|
||||||
name="compose_quick_insights",
|
|
||||||
),
|
|
||||||
path(
|
path(
|
||||||
"compose/engage/preview/",
|
"compose/engage/preview/",
|
||||||
compose.ComposeEngagePreview.as_view(),
|
compose.ComposeEngagePreview.as_view(),
|
||||||
|
|||||||
@@ -96,12 +96,12 @@
|
|||||||
.compose-shell .compose-thread {
|
.compose-shell .compose-thread {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
gap: 0.35rem;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
max-height: none;
|
max-height: none;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 0.75rem;
|
padding: 0.5rem 0.625rem;
|
||||||
border: 1px solid var(--bulma-border, #dbdbdb);
|
border: 1px solid var(--bulma-border, #dbdbdb);
|
||||||
background: var(--bulma-scheme-main-bis, #f7f8fa);
|
background: var(--bulma-scheme-main-bis, #f7f8fa);
|
||||||
}
|
}
|
||||||
@@ -112,10 +112,19 @@
|
|||||||
font-size: 0.875rem;
|
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 {
|
.compose-shell .compose-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.35rem;
|
gap: 0.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compose-shell .compose-row.is-in {
|
.compose-shell .compose-row.is-in {
|
||||||
@@ -137,10 +146,10 @@
|
|||||||
|
|
||||||
.compose-shell .compose-bubble {
|
.compose-shell .compose-bubble {
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
max-width: min(42rem, 100%);
|
max-width: min(38rem, 100%);
|
||||||
padding: 0.75rem 0.875rem;
|
padding: 0.4rem 0.55rem;
|
||||||
border: 1px solid var(--bulma-border, #dbdbdb);
|
border: 1px solid var(--bulma-border, #dbdbdb);
|
||||||
border-radius: 1rem;
|
border-radius: 0.8rem;
|
||||||
background: var(--bulma-scheme-main, #fff);
|
background: var(--bulma-scheme-main, #fff);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,8 +159,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.compose-shell .compose-reply-ref {
|
.compose-shell .compose-reply-ref {
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.3rem;
|
||||||
padding-left: 0.75rem;
|
padding-left: 0.45rem;
|
||||||
border-left: 3px solid var(--bulma-border, #dbdbdb);
|
border-left: 3px solid var(--bulma-border, #dbdbdb);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +169,7 @@
|
|||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--bulma-link, #3273dc);
|
color: var(--bulma-link, #3273dc);
|
||||||
font-size: 0.75rem;
|
font-size: 0.6875rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,35 +177,26 @@
|
|||||||
text-decoration: underline;
|
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 {
|
.compose-shell .compose-media {
|
||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compose-shell .compose-media:last-of-type {
|
.compose-shell .compose-media:last-of-type {
|
||||||
margin-bottom: 0.625rem;
|
margin-bottom: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compose-shell .compose-image {
|
.compose-shell .compose-image {
|
||||||
display: block;
|
display: block;
|
||||||
max-width: min(26rem, 100%);
|
max-width: min(26rem, 100%);
|
||||||
max-height: 24rem;
|
max-height: 22rem;
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compose-shell .compose-body {
|
.compose-shell .compose-body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
|
line-height: 1.28;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compose-shell .compose-image-fallback.is-hidden {
|
.compose-shell .compose-image-fallback.is-hidden {
|
||||||
@@ -209,11 +209,11 @@
|
|||||||
.compose-shell .compose-reactions + .compose-msg-meta,
|
.compose-shell .compose-reactions + .compose-msg-meta,
|
||||||
.compose-shell .compose-edit-history + .compose-reactions,
|
.compose-shell .compose-edit-history + .compose-reactions,
|
||||||
.compose-shell .compose-edit-history + .compose-msg-meta {
|
.compose-shell .compose-edit-history + .compose-msg-meta {
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compose-shell .compose-edit-history {
|
.compose-shell .compose-edit-history {
|
||||||
font-size: 0.75rem;
|
font-size: 0.6875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compose-shell .compose-edit-history summary {
|
.compose-shell .compose-edit-history summary {
|
||||||
@@ -221,15 +221,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.compose-shell .compose-edit-history ul {
|
.compose-shell .compose-edit-history ul {
|
||||||
margin: 0.5rem 0 0;
|
margin: 0.35rem 0 0;
|
||||||
padding-left: 1rem;
|
padding-left: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compose-shell .compose-edit-diff {
|
.compose-shell .compose-edit-diff {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.35rem;
|
gap: 0.25rem;
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compose-shell .compose-edit-old {
|
.compose-shell .compose-edit-old {
|
||||||
@@ -244,21 +244,21 @@
|
|||||||
.compose-shell .compose-reactions {
|
.compose-shell .compose-reactions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.35rem;
|
gap: 0.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compose-shell .compose-reaction-chip {
|
.compose-shell .compose-reaction-chip {
|
||||||
min-height: 1.7rem;
|
min-height: 1.45rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compose-shell .compose-msg-meta {
|
.compose-shell .compose-msg-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.35rem;
|
gap: 0.25rem;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.3rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.6875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compose-shell .compose-msg-flag {
|
.compose-shell .compose-msg-flag {
|
||||||
@@ -275,7 +275,30 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.compose-shell .compose-reply-btn {
|
.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 {
|
.compose-shell .compose-form {
|
||||||
|
|||||||
@@ -130,6 +130,70 @@ body .has-text-grey-light {
|
|||||||
color: var(--gia-text);
|
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,
|
.input,
|
||||||
.textarea,
|
.textarea,
|
||||||
.select select {
|
.select select {
|
||||||
@@ -554,6 +618,16 @@ html.gia-has-workspace-root {
|
|||||||
box-shadow: 0 0 0 2px rgba(50, 115, 220, 0.16);
|
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 {
|
.floating-window {
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
z-index: 9000;
|
z-index: 9000;
|
||||||
@@ -740,10 +814,11 @@ html.gia-has-workspace-root {
|
|||||||
|
|
||||||
.gia-send-composer {
|
.gia-send-composer {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0.75rem;
|
padding: 0;
|
||||||
border: 1px solid var(--bulma-border, #dbdbdb);
|
border: 0;
|
||||||
border-radius: 0.875rem;
|
border-radius: 0;
|
||||||
background: var(--bulma-scheme-main-bis, #f7f8fa);
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gia-send-composer-row {
|
.gia-send-composer-row {
|
||||||
@@ -756,11 +831,12 @@ html.gia-has-workspace-root {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.gia-send-composer-input {
|
.gia-send-composer-input {
|
||||||
min-height: 2.75rem;
|
min-height: 2.5rem;
|
||||||
max-height: 8rem;
|
max-height: 7rem;
|
||||||
resize: none;
|
resize: none;
|
||||||
border-top-right-radius: 0;
|
border-top-right-radius: 0;
|
||||||
border-bottom-right-radius: 0;
|
border-bottom-right-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gia-send-composer-action {
|
.gia-send-composer-action {
|
||||||
@@ -769,7 +845,7 @@ html.gia-has-workspace-root {
|
|||||||
|
|
||||||
.gia-send-composer-button {
|
.gia-send-composer-button {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 2.75rem;
|
min-height: 2.5rem;
|
||||||
border-top-left-radius: 0;
|
border-top-left-radius: 0;
|
||||||
border-bottom-left-radius: 0;
|
border-bottom-left-radius: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
const replyBanner = config.replyBanner;
|
const replyBanner = config.replyBanner;
|
||||||
const replyBannerText = config.replyBannerText;
|
const replyBannerText = config.replyBannerText;
|
||||||
const replyClearBtn = config.replyClearBtn;
|
const replyClearBtn = config.replyClearBtn;
|
||||||
|
const historyLoader = config.historyLoader;
|
||||||
const platformSelect = config.platformSelect;
|
const platformSelect = config.platformSelect;
|
||||||
const contactSelect = config.contactSelect;
|
const contactSelect = config.contactSelect;
|
||||||
const hiddenService = config.hiddenService;
|
const hiddenService = config.hiddenService;
|
||||||
@@ -29,6 +30,9 @@
|
|||||||
let lastTs = core.toInt(thread.dataset.lastTs);
|
let lastTs = core.toInt(thread.dataset.lastTs);
|
||||||
let beforeContextReset = null;
|
let beforeContextReset = null;
|
||||||
|
|
||||||
|
state.loadingOlder = false;
|
||||||
|
state.olderExhausted = false;
|
||||||
|
|
||||||
const nearBottom = function () {
|
const nearBottom = function () {
|
||||||
return thread.scrollHeight - thread.scrollTop - thread.clientHeight < 120;
|
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 queryParams = function (extraParams) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set("service", thread.dataset.service || "");
|
params.set("service", thread.dataset.service || "");
|
||||||
@@ -204,8 +223,28 @@
|
|||||||
});
|
});
|
||||||
if (rows.length) {
|
if (rows.length) {
|
||||||
scrollToBottom(shouldStick);
|
scrollToBottom(shouldStick);
|
||||||
|
setHistoryLoader("", false);
|
||||||
}
|
}
|
||||||
ensureEmptyState();
|
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) {
|
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 setupWebSocket = function () {
|
||||||
const wsPath = String(thread.dataset.wsUrl || "").trim();
|
const wsPath = String(thread.dataset.wsUrl || "").trim();
|
||||||
if (!wsPath || !window.WebSocket) {
|
if (!wsPath || !window.WebSocket) {
|
||||||
@@ -359,8 +439,14 @@
|
|||||||
clearReplyTarget();
|
clearReplyTarget();
|
||||||
closeSocket();
|
closeSocket();
|
||||||
lastTs = 0;
|
lastTs = 0;
|
||||||
|
state.loadingOlder = false;
|
||||||
|
state.olderExhausted = false;
|
||||||
thread.dataset.lastTs = "0";
|
thread.dataset.lastTs = "0";
|
||||||
thread.innerHTML = "";
|
thread.innerHTML = "";
|
||||||
|
if (historyLoader) {
|
||||||
|
thread.appendChild(historyLoader);
|
||||||
|
}
|
||||||
|
setHistoryLoader("Loading recent messages...", false);
|
||||||
ensureEmptyState("Loading messages...");
|
ensureEmptyState("Loading messages...");
|
||||||
applyTyping({ typing: false });
|
applyTyping({ typing: false });
|
||||||
poll(true);
|
poll(true);
|
||||||
@@ -466,6 +552,12 @@
|
|||||||
setReplyTarget(row.dataset.messageId || "", row.dataset.replySnippet || "");
|
setReplyTarget(row.dataset.messageId || "", row.dataset.replySnippet || "");
|
||||||
textarea.focus();
|
textarea.focus();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
thread.addEventListener("scroll", function () {
|
||||||
|
if (thread.scrollTop <= 48) {
|
||||||
|
loadOlder();
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const init = function () {
|
const init = function () {
|
||||||
@@ -473,6 +565,7 @@
|
|||||||
bindContextSelectors();
|
bindContextSelectors();
|
||||||
applyTyping(core.parseJsonSafe(panel.dataset.initialTyping || "{}", {}));
|
applyTyping(core.parseJsonSafe(panel.dataset.initialTyping || "{}", {}));
|
||||||
ensureEmptyState();
|
ensureEmptyState();
|
||||||
|
setHistoryLoader("", !thread.querySelector(".compose-row"));
|
||||||
scrollToBottom(true);
|
scrollToBottom(true);
|
||||||
setupWebSocket();
|
setupWebSocket();
|
||||||
|
|
||||||
@@ -492,6 +585,9 @@
|
|||||||
init: init,
|
init: init,
|
||||||
poll: poll,
|
poll: poll,
|
||||||
queryParams: queryParams,
|
queryParams: queryParams,
|
||||||
|
scrollToLatest: function () {
|
||||||
|
scrollToBottom(true);
|
||||||
|
},
|
||||||
setBeforeContextReset: function (callback) {
|
setBeforeContextReset: function (callback) {
|
||||||
beforeContextReset = callback;
|
beforeContextReset = callback;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -119,6 +119,7 @@
|
|||||||
const threadController = threadModule.createController({
|
const threadController = threadModule.createController({
|
||||||
contactSelect: document.getElementById(panelId + "-contact-select"),
|
contactSelect: document.getElementById(panelId + "-contact-select"),
|
||||||
hiddenIdentifier: document.getElementById(panelId + "-input-identifier"),
|
hiddenIdentifier: document.getElementById(panelId + "-input-identifier"),
|
||||||
|
historyLoader: document.getElementById(panelId + "-history-loader"),
|
||||||
hiddenPerson: document.getElementById(panelId + "-input-person"),
|
hiddenPerson: document.getElementById(panelId + "-input-person"),
|
||||||
hiddenReplyTo: form.querySelector('input[name="reply_to_message_id"]'),
|
hiddenReplyTo: form.querySelector('input[name="reply_to_message_id"]'),
|
||||||
hiddenService: document.getElementById(panelId + "-input-service"),
|
hiddenService: document.getElementById(panelId + "-input-service"),
|
||||||
@@ -135,6 +136,7 @@
|
|||||||
thread: thread,
|
thread: thread,
|
||||||
typingNode: document.getElementById(panelId + "-typing"),
|
typingNode: document.getElementById(panelId + "-typing"),
|
||||||
});
|
});
|
||||||
|
state.threadController = threadController;
|
||||||
|
|
||||||
const sendController = sendModule.createController({
|
const sendController = sendModule.createController({
|
||||||
armInput: form.querySelector('input[name="failsafe_arm"]'),
|
armInput: form.querySelector('input[name="failsafe_arm"]'),
|
||||||
@@ -177,6 +179,25 @@
|
|||||||
},
|
},
|
||||||
initAll: initAll,
|
initAll: initAll,
|
||||||
initPanel: initPanel,
|
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 () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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="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 '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 '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 %}
|
{% block extra_head_assets %}{% endblock %}
|
||||||
<script src="{% static 'js/htmx.min.js' %}" integrity="sha512-CGXFnDNv5q48ciFeIyWFcfZhqYW0sSBiPO+HZDO3XLM+p8xjhezz5CCxtkXVDKfCbvF+iUhel7xoeSp19o7x7g==" crossorigin="anonymous"></script>
|
<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>
|
<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) {
|
document.body.addEventListener("htmx:afterRequest", function (event) {
|
||||||
const detail = (event && event.detail) || null;
|
const detail = (event && event.detail) || null;
|
||||||
const source = detail && detail.elt ? detail.elt : null;
|
const source = detail && detail.elt ? detail.elt : null;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
aria-label="Snap assistant">
|
aria-label="Snap assistant">
|
||||||
<p class="panel-heading gia-snap-assistant-heading">
|
<p class="panel-heading gia-snap-assistant-heading">
|
||||||
<span class="icon is-small"><i class="fa-solid fa-table-columns"></i></span>
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="delete is-small js-gia-snap-assistant-close"
|
class="delete is-small js-gia-snap-assistant-close"
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
</p>
|
</p>
|
||||||
<div class="panel-block">
|
<div class="panel-block">
|
||||||
<div class="content is-small">
|
<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>
|
</div>
|
||||||
<div class="panel-block is-active gia-snap-assistant-body">
|
<div class="panel-block is-active gia-snap-assistant-body">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
data-gia-widget-shell="1"
|
data-gia-widget-shell="1"
|
||||||
{% if widget_style_hrefs %}data-gia-style-hrefs="{{ widget_style_hrefs|join:'|' }}"{% endif %}
|
{% 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 %}>
|
{% 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">
|
<div class="grid-stack-item-content">
|
||||||
<section class="gia-widget-panel">
|
<section class="gia-widget-panel">
|
||||||
<header class="gia-widget-heading">
|
<header class="gia-widget-heading">
|
||||||
@@ -34,6 +34,14 @@
|
|||||||
aria-label="Snap window left">
|
aria-label="Snap window left">
|
||||||
<span class="icon is-small"><i class="fa-solid fa-arrow-left"></i></span>
|
<span class="icon is-small"><i class="fa-solid fa-arrow-left"></i></span>
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="button is-light js-gia-widget-action"
|
class="button is-light js-gia-widget-action"
|
||||||
@@ -42,6 +50,14 @@
|
|||||||
aria-label="Snap window right">
|
aria-label="Snap window right">
|
||||||
<span class="icon is-small"><i class="fa-solid fa-arrow-right"></i></span>
|
<span class="icon is-small"><i class="fa-solid fa-arrow-right"></i></span>
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="button is-light js-gia-widget-action"
|
class="button is-light js-gia-widget-action"
|
||||||
|
|||||||
@@ -1,98 +1,22 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="columns is-multiline">
|
<section class="section">
|
||||||
<div class="column is-12">
|
<div class="container">
|
||||||
<nav class="breadcrumb is-small" aria-label="breadcrumbs">
|
<nav class="breadcrumb is-small" aria-label="breadcrumbs">
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="{{ workspace_url }}">AI Workspace</a></li>
|
<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>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
<div class="mb-4">
|
||||||
|
<h1 class="title is-4 mb-1">MS / PS Information: {{ person.name }}</h1>
|
||||||
<div class="column is-12">
|
<p class="is-size-7 has-text-grey">
|
||||||
<h1 class="title is-4" style="margin-bottom: 0.35rem;">Information: {{ person.name }}</h1>
|
Current message-state and presence-state signals derived from timing data.
|
||||||
<p class="is-size-7 has-text-grey">Commitment directionality and underlying metric factors from deterministic message-history snapshots.</p>
|
</p>
|
||||||
{% include "partials/ai-insight-nav.html" with active_tab="information" %}
|
{% include "partials/ai-insight-nav.html" with active_tab="information" %}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="column is-5">
|
|
||||||
<div class="box">
|
|
||||||
<p class="heading">Conversation Overview</p>
|
|
||||||
{% if overview_rows %}
|
|
||||||
{% for row in overview_rows %}
|
|
||||||
<article class="message is-light" style="margin-bottom: 0.45rem;">
|
|
||||||
<div class="message-body" style="padding: 0.45rem 0.55rem;">
|
|
||||||
<p class="is-size-7 has-text-grey" style="margin-bottom: 0.12rem;">
|
|
||||||
{{ row.group_title }}
|
|
||||||
</p>
|
|
||||||
<p style="margin-bottom: 0.3rem;">
|
|
||||||
<strong>{{ row.title }}:</strong> {{ row.value|default:"-" }}
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
class="tag is-light"
|
|
||||||
href="{% url 'ai_workspace_insight_detail' type='page' person_id=person.id metric=row.slug %}">
|
|
||||||
View Detail
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
{% endfor %}
|
|
||||||
{% else %}
|
|
||||||
<p class="is-size-7 has-text-grey">No conversation metadata available yet.</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="box">
|
|
||||||
<p class="heading">Commitment Directionality</p>
|
|
||||||
<p class="title is-5" style="margin-bottom: 0.35rem;">{{ directionality.direction_label }}</p>
|
|
||||||
<p><strong>Commit In:</strong> {{ directionality.commit_in|default:"-" }}</p>
|
|
||||||
<p><strong>Commit Out:</strong> {{ directionality.commit_out|default:"-" }}</p>
|
|
||||||
<p><strong>Delta:</strong> {{ directionality.delta|default:"-" }}</p>
|
|
||||||
<p><strong>Magnitude:</strong> {{ directionality.magnitude|default:"-" }}</p>
|
|
||||||
<p><strong>Confidence:</strong> {{ directionality.confidence|default:"-" }}</p>
|
|
||||||
<article class="message is-light" style="margin-top: 0.65rem;">
|
|
||||||
<div class="message-body">
|
|
||||||
{{ directionality.conclusion }}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
</div>
|
||||||
|
{% include "partials/ai-workspace-behavioral-information.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
<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>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,113 +1,23 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% load static %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="columns is-multiline">
|
<section class="section">
|
||||||
<div class="column is-12">
|
<div class="container">
|
||||||
<nav class="breadcrumb is-small" aria-label="breadcrumbs">
|
<nav class="breadcrumb is-small" aria-label="breadcrumbs">
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="{{ workspace_url }}">AI Workspace</a></li>
|
<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>
|
<li class="is-active"><a aria-current="page">{{ metric.title }}</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
<div class="mb-4">
|
||||||
<div class="column is-12">
|
<h1 class="title is-4 mb-1">{{ metric.title }}: {{ person.name }}</h1>
|
||||||
<h1 class="title is-4" style="margin-bottom: 0.35rem;">{{ metric.title }}: {{ person.name }}</h1>
|
<p class="is-size-7 has-text-grey">
|
||||||
<p class="is-size-7 has-text-grey">Conversation {{ workspace_conversation.id }}</p>
|
Detailed MS/PS graph for this contact.
|
||||||
{% include "partials/ai-insight-nav.html" %}
|
</p>
|
||||||
</div>
|
{% include "partials/ai-insight-nav.html" %}
|
||||||
<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>
|
</div>
|
||||||
|
{% include "partials/ai-workspace-behavioral-graph-detail.html" %}
|
||||||
</div>
|
</div>
|
||||||
<div class="column is-7">
|
</section>
|
||||||
<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>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,129 +1,22 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% load static %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="columns is-multiline">
|
<section class="section">
|
||||||
<div class="column is-12">
|
<div class="container">
|
||||||
<nav class="breadcrumb is-small" aria-label="breadcrumbs">
|
<nav class="breadcrumb is-small" aria-label="breadcrumbs">
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="{{ workspace_url }}">AI Workspace</a></li>
|
<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>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
<div class="mb-4">
|
||||||
<div class="column is-12">
|
<h1 class="title is-4 mb-1">Behavioral Graphs: {{ person.name }}</h1>
|
||||||
<h1 class="title is-4" style="margin-bottom: 0.35rem;">Insight Graphs: {{ person.name }}</h1>
|
<p class="is-size-7 has-text-grey">
|
||||||
<p class="is-size-7 has-text-grey">
|
Rebuilt from message and event timing data using the MS/PS model rather than workspace snapshots.
|
||||||
Historical metrics for workspace {{ workspace_conversation.id }}. Points are range-downsampled server-side with high-resolution recent data and progressively sparser older ranges.
|
</p>
|
||||||
</p>
|
{% include "partials/ai-insight-nav.html" with active_tab="graphs" %}
|
||||||
<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>
|
</div>
|
||||||
{% include "partials/ai-insight-nav.html" with active_tab="graphs" %}
|
{% include "partials/ai-workspace-behavioral-graphs.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
{% 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>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,58 +1,23 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="columns is-multiline">
|
<section class="section">
|
||||||
<div class="column is-12">
|
<div class="container">
|
||||||
<nav class="breadcrumb is-small" aria-label="breadcrumbs">
|
<nav class="breadcrumb is-small" aria-label="breadcrumbs">
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="{{ workspace_url }}">AI Workspace</a></li>
|
<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">Scoring Help</a></li>
|
<li class="is-active"><a aria-current="page">MS / PS Help</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
<div class="mb-4">
|
||||||
<div class="column is-12">
|
<h1 class="title is-4 mb-1">MS / PS Help: {{ person.name }}</h1>
|
||||||
<h1 class="title is-4" style="margin-bottom: 0.35rem;">Scoring Help: {{ person.name }}</h1>
|
<p class="is-size-7 has-text-grey">
|
||||||
<p class="is-size-7 has-text-grey">
|
Definitions and psychological interpretation for the timing signal system.
|
||||||
Combined explanation for each metric collection group and what it can
|
</p>
|
||||||
imply in relationship dynamics. Scoring is deterministic from message
|
{% include "partials/ai-insight-nav.html" with active_tab="help" %}
|
||||||
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>
|
</div>
|
||||||
{% endfor %}
|
{% include "partials/ai-workspace-behavioral-help.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -3,18 +3,18 @@
|
|||||||
class="tag {% if active_tab == 'graphs' %}is-dark{% else %}is-link is-light{% endif %}"
|
class="tag {% if active_tab == 'graphs' %}is-dark{% else %}is-link is-light{% endif %}"
|
||||||
href="{{ graphs_url }}">
|
href="{{ graphs_url }}">
|
||||||
<span class="icon is-small"><i class="fa-solid fa-chart-line"></i></span>
|
<span class="icon is-small"><i class="fa-solid fa-chart-line"></i></span>
|
||||||
<span>Insight Graphs</span>
|
<span>Behavioral Graphs</span>
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
class="tag {% if active_tab == 'information' %}is-dark{% else %}is-link is-light{% endif %}"
|
class="tag {% if active_tab == 'information' %}is-dark{% else %}is-link is-light{% endif %}"
|
||||||
href="{{ information_url }}">
|
href="{{ information_url }}">
|
||||||
<span class="icon is-small"><i class="fa-solid fa-circle-info"></i></span>
|
<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>
|
||||||
<a
|
<a
|
||||||
class="tag {% if active_tab == 'help' %}is-dark{% else %}is-link is-light{% endif %}"
|
class="tag {% if active_tab == 'help' %}is-dark{% else %}is-link is-light{% endif %}"
|
||||||
href="{{ help_url }}">
|
href="{{ help_url }}">
|
||||||
<span class="icon is-small"><i class="fa-solid fa-circle-question"></i></span>
|
<span class="icon is-small"><i class="fa-solid fa-circle-question"></i></span>
|
||||||
<span>Scoring Guide</span>
|
<span>MS / PS Help</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
36
core/templates/partials/ai-workspace-behavioral-graphs.html
Normal file
36
core/templates/partials/ai-workspace-behavioral-graphs.html
Normal 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>
|
||||||
33
core/templates/partials/ai-workspace-behavioral-help.html
Normal file
33
core/templates/partials/ai-workspace-behavioral-help.html
Normal 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>
|
||||||
@@ -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>
|
||||||
@@ -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);">
|
<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>
|
<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>
|
<h3 class="title is-5" style="margin-bottom: 0.25rem;">{{ person.name }}</h3>
|
||||||
<div class="tags" style="margin-top: 0.35rem;">
|
<p class="is-size-7 has-text-grey" style="margin-top: 0.35rem; margin-bottom: 0;">
|
||||||
<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>
|
Open MS / PS graphs, information, and help from the controls below.
|
||||||
<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>
|
</p>
|
||||||
<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>
|
|
||||||
<div class="buttons are-small" style="margin-top: 0.35rem; margin-bottom: 0;">
|
<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 %}">
|
{% 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 %}
|
||||||
<span class="icon is-small"><i class="fa-solid fa-chart-line"></i></span>
|
|
||||||
<span>Insight Graphs</span>
|
|
||||||
</a>
|
|
||||||
<a class="button is-light" href="{% url 'ai_workspace_information' type='page' person_id=person.id %}">
|
<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 class="icon is-small"><i class="fa-solid fa-circle-info"></i></span>
|
||||||
<span>Information</span>
|
<span>MS / PS</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="button is-light" href="{% url 'ai_workspace_insight_help' type='page' person_id=person.id %}">
|
<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 class="icon is-small"><i class="fa-solid fa-circle-question"></i></span>
|
||||||
<span>Scoring Help</span>
|
<span>Help</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% with participants=workspace_conversation.participants.all %}
|
{% with participants=workspace_conversation.participants.all %}
|
||||||
@@ -81,6 +64,7 @@
|
|||||||
id="ai-manual-widget-btn-{{ person.id }}"
|
id="ai-manual-widget-btn-{{ person.id }}"
|
||||||
type="button"
|
type="button"
|
||||||
class="button is-light is-small js-widget-spawn-trigger is-hidden"
|
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 }}"
|
data-widget-url="{{ compose_widget_url }}"
|
||||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
hx-get="{{ compose_widget_url }}"
|
hx-get="{{ compose_widget_url }}"
|
||||||
|
|||||||
71
core/templates/partials/behavioral-graph-card.html
Normal file
71
core/templates/partials/behavioral-graph-card.html
Normal 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>
|
||||||
71
core/templates/partials/behavioral-graph-launcher.html
Normal file
71
core/templates/partials/behavioral-graph-launcher.html
Normal 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>
|
||||||
56
core/templates/partials/behavioral-range-tabs.html
Normal file
56
core/templates/partials/behavioral-range-tabs.html
Normal 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>
|
||||||
18
core/templates/partials/behavioral-summary-card.html
Normal file
18
core/templates/partials/behavioral-summary-card.html
Normal 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>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<div class="box is-shadowless gia-send-composer p-2 m-0{% if composer_class %} {{ composer_class }}{% endif %}">
|
<div class="gia-send-composer m-0{% if composer_class %} {{ composer_class }}{% endif %}">
|
||||||
<div class="field has-addons gia-send-composer-row">
|
<div class="field has-addons gia-send-composer-row mb-0">
|
||||||
<div class="control is-expanded gia-send-composer-input-wrap">
|
<div class="control is-expanded gia-send-composer-input-wrap">
|
||||||
<textarea
|
<textarea
|
||||||
id="{{ textarea_id }}"
|
id="{{ textarea_id }}"
|
||||||
|
|||||||
@@ -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 }}">
|
<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 %}
|
{% if msg.reply_to_id %}
|
||||||
<div class="compose-reply-ref" data-reply-target-id="{{ 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">
|
<button type="button" class="compose-reply-link" title="Jump to referenced message">
|
||||||
@@ -7,9 +9,6 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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 %}
|
{% if msg.image_urls %}
|
||||||
{% for image_url in msg.image_urls %}
|
{% for image_url in msg.image_urls %}
|
||||||
<figure class="compose-media">
|
<figure class="compose-media">
|
||||||
@@ -34,9 +33,9 @@
|
|||||||
</figure>
|
</figure>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if not msg.hide_text %}
|
{% 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 %}
|
{% 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 %}
|
{% endif %}
|
||||||
{% if msg.edit_count %}
|
{% if msg.edit_count %}
|
||||||
<details class="compose-edit-history">
|
<details class="compose-edit-history">
|
||||||
@@ -71,7 +70,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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 %}
|
{{ msg.display_ts }}{% if msg.author %} · {{ msg.author }}{% endif %}
|
||||||
{% if msg.is_edited %}
|
{% 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>
|
<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>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% load static %}
|
{% load static %}
|
||||||
<link rel="stylesheet" href="{% static 'css/compose-panel.css' %}">
|
<link rel="stylesheet" href="{% static 'css/compose-panel.css' %}?v={{ compose_asset_version|default:'20260313b' }}">
|
||||||
<script defer src="{% static 'js/compose-panel-core.js' %}"></script>
|
<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' %}"></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' %}"></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' %}"></script>
|
<script defer src="{% static 'js/compose-panel.js' %}?v={{ compose_asset_version|default:'20260313b' }}"></script>
|
||||||
|
|||||||
@@ -66,6 +66,9 @@
|
|||||||
{{ service|title }} · {{ identifier }}
|
{{ service|title }} · {{ identifier }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{% if signal_ingest_warning %}
|
{% if signal_ingest_warning %}
|
||||||
@@ -90,6 +93,9 @@
|
|||||||
data-limit="{{ limit }}"
|
data-limit="{{ limit }}"
|
||||||
data-last-ts="{{ last_ts }}"
|
data-last-ts="{{ last_ts }}"
|
||||||
data-ws-url="{{ compose_ws_url }}">
|
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." %}
|
{% 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>
|
</div>
|
||||||
<p id="{{ panel_id }}-typing" class="compose-typing is-hidden">
|
<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>
|
<p class="help is-size-7 has-text-grey">Send disabled: {{ capability_send_reason }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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>
|
||||||
<div id="{{ panel_id }}-reply-banner" class="compose-reply-banner is-hidden">
|
<div id="{{ panel_id }}-reply-banner" class="compose-reply-banner is-hidden">
|
||||||
<span class="compose-reply-banner-label">Replying to:</span>
|
<span class="compose-reply-banner-label">Replying to:</span>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
{% for row in contact_rows %}
|
{% for row in contact_rows %}
|
||||||
<a
|
<a
|
||||||
class="panel-block"
|
class="panel-block"
|
||||||
|
data-gia-widget-id="{{ row.compose_widget_id }}"
|
||||||
hx-get="{{ row.compose_widget_url }}"
|
hx-get="{{ row.compose_widget_url }}"
|
||||||
hx-target="#widgets-here"
|
hx-target="#widgets-here"
|
||||||
hx-swap="beforeend">
|
hx-swap="beforeend">
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
<div class="buttons are-small m-0">
|
<div class="buttons are-small m-0">
|
||||||
<button
|
<button
|
||||||
class="button is-small is-link is-light"
|
class="button is-small is-link is-light"
|
||||||
|
data-gia-widget-id="{{ row.compose_widget_id }}"
|
||||||
hx-get="{{ row.compose_widget_url }}"
|
hx-get="{{ row.compose_widget_url }}"
|
||||||
hx-include="#{{ browser_form_id }}"
|
hx-include="#{{ browser_form_id }}"
|
||||||
hx-target="#widgets-here"
|
hx-target="#widgets-here"
|
||||||
|
|||||||
@@ -197,7 +197,7 @@
|
|||||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
hx-get="{{ action.url }}"
|
hx-get="{{ action.url }}"
|
||||||
hx-target="{{ action.target }}"
|
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 %}
|
{% if action.target == "#windows-here" %}onclick="if (window.giaPrepareWindowAnchor) { window.giaPrepareWindowAnchor(this); }"{% endif %}
|
||||||
title="{{ action.title }}">
|
title="{{ action.title }}">
|
||||||
<span class="icon"><i class="{{ action.icon }}"></i></span>
|
<span class="icon"><i class="{{ action.icon }}"></i></span>
|
||||||
|
|||||||
@@ -109,6 +109,7 @@
|
|||||||
{% if item.can_compose %}
|
{% if item.can_compose %}
|
||||||
<button
|
<button
|
||||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
|
data-gia-widget-id="{{ item.compose_widget_id }}"
|
||||||
hx-get="{{ item.compose_widget_url }}"
|
hx-get="{{ item.compose_widget_url }}"
|
||||||
hx-trigger="click"
|
hx-trigger="click"
|
||||||
hx-target="#widgets-here"
|
hx-target="#widgets-here"
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
|
data-gia-widget-id="{{ item.compose_widget_id }}"
|
||||||
hx-get="{{ item.compose_widget_url }}"
|
hx-get="{{ item.compose_widget_url }}"
|
||||||
hx-trigger="click"
|
hx-trigger="click"
|
||||||
hx-target="#widgets-here"
|
hx-target="#widgets-here"
|
||||||
|
|||||||
@@ -97,6 +97,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
|
||||||
|
data-gia-widget-id="{{ item.compose_widget_id }}"
|
||||||
hx-get="{{ item.compose_widget_url }}"
|
hx-get="{{ item.compose_widget_url }}"
|
||||||
hx-trigger="click"
|
hx-trigger="click"
|
||||||
hx-target="#widgets-here"
|
hx-target="#widgets-here"
|
||||||
|
|||||||
73
core/tests/test_behavioral_graph_views.py
Normal file
73
core/tests/test_behavioral_graph_views.py
Normal 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)
|
||||||
@@ -7,6 +7,7 @@ from django.test import TestCase
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
from core.models import ChatSession, Message, Person, PersonIdentifier, User
|
from core.models import ChatSession, Message, Person, PersonIdentifier, User
|
||||||
|
from core.views.compose import COMPOSE_ASSET_VERSION
|
||||||
|
|
||||||
|
|
||||||
class ComposeSendCapabilityTests(TestCase):
|
class ComposeSendCapabilityTests(TestCase):
|
||||||
@@ -77,11 +78,26 @@ class ComposeSendCapabilityTests(TestCase):
|
|||||||
|
|
||||||
self.assertEqual(200, response.status_code)
|
self.assertEqual(200, response.status_code)
|
||||||
content = response.content.decode("utf-8")
|
content = response.content.decode("utf-8")
|
||||||
self.assertIn("compose-panel.css", content)
|
self.assertIn(
|
||||||
self.assertIn("compose-panel-core.js", content)
|
f"/static/css/compose-panel.css?v={COMPOSE_ASSET_VERSION}",
|
||||||
self.assertIn("compose-panel-thread.js", content)
|
content,
|
||||||
self.assertIn("compose-panel-send.js", content)
|
)
|
||||||
self.assertIn("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.assertNotIn("const initialTyping = JSON.parse(", content)
|
self.assertNotIn("const initialTyping = JSON.parse(", content)
|
||||||
self.assertNotIn("data-drafts-url=", content)
|
self.assertNotIn("data-drafts-url=", content)
|
||||||
self.assertNotIn("data-summary-url=", content)
|
self.assertNotIn("data-summary-url=", content)
|
||||||
@@ -104,14 +120,43 @@ class ComposeSendCapabilityTests(TestCase):
|
|||||||
self.assertEqual(200, response.status_code)
|
self.assertEqual(200, response.status_code)
|
||||||
content = response.content.decode("utf-8")
|
content = response.content.decode("utf-8")
|
||||||
self.assertIn("data-gia-style-hrefs=", content)
|
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("data-gia-script-srcs=", content)
|
||||||
self.assertIn("/static/js/compose-panel-core.js", content)
|
self.assertIn(
|
||||||
self.assertIn("/static/js/compose-panel-thread.js", content)
|
f"/static/js/compose-panel-core.js?v={COMPOSE_ASSET_VERSION}",
|
||||||
self.assertIn("/static/js/compose-panel-send.js", content)
|
content,
|
||||||
self.assertIn("/static/js/compose-panel.js", 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("<script defer src=\"/static/js/compose-panel.js\">", content)
|
||||||
self.assertNotIn("<link rel=\"stylesheet\" href=\"/static/css/compose-panel.css\">", 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):
|
def test_compose_contacts_dropdown_includes_workspace_link(self):
|
||||||
response = self.client.get(reverse("compose_contacts_dropdown"))
|
response = self.client.get(reverse("compose_contacts_dropdown"))
|
||||||
@@ -119,6 +164,11 @@ class ComposeSendCapabilityTests(TestCase):
|
|||||||
self.assertEqual(200, response.status_code)
|
self.assertEqual(200, response.status_code)
|
||||||
self.assertContains(response, reverse("compose_workspace"))
|
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")
|
@patch("core.views.compose._recent_manual_contacts")
|
||||||
def test_compose_contact_options_use_compact_service_map(self, mocked_recent_contacts):
|
def test_compose_contact_options_use_compact_service_map(self, mocked_recent_contacts):
|
||||||
mocked_recent_contacts.return_value = [
|
mocked_recent_contacts.return_value = [
|
||||||
@@ -196,6 +246,57 @@ class ComposeSendCapabilityTests(TestCase):
|
|||||||
self.assertIn("compose-row", str(payload.get("messages_html") or ""))
|
self.assertIn("compose-row", str(payload.get("messages_html") or ""))
|
||||||
self.assertIn("Rendered thread 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):
|
def test_compose_thread_payload_renders_reply_link_text_server_side(self):
|
||||||
person = Person.objects.create(user=self.user, name="Reply Contact")
|
person = Person.objects.create(user=self.user, name="Reply Contact")
|
||||||
identifier = PersonIdentifier.objects.create(
|
identifier = PersonIdentifier.objects.create(
|
||||||
|
|||||||
33
core/tests/test_osint_widget_actions.py
Normal file
33
core/tests/test_osint_widget_actions.py
Normal 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)
|
||||||
@@ -51,18 +51,15 @@ from core.models import (
|
|||||||
Person,
|
Person,
|
||||||
PersonIdentifier,
|
PersonIdentifier,
|
||||||
PlatformChatLink,
|
PlatformChatLink,
|
||||||
WorkspaceConversation,
|
|
||||||
)
|
)
|
||||||
from core.presence import get_settings as get_availability_settings
|
from core.presence import get_settings as get_availability_settings
|
||||||
from core.presence import latest_state_for_people, spans_for_range
|
from core.presence import latest_state_for_people, spans_for_range
|
||||||
from core.realtime.typing_state import get_person_typing_state
|
from core.realtime.typing_state import get_person_typing_state
|
||||||
from core.translation.engine import process_inbound_translation
|
from core.translation.engine import process_inbound_translation
|
||||||
from core.transports.capabilities import supports, unsupported_reason
|
from core.transports.capabilities import supports, unsupported_reason
|
||||||
from core.views.workspace import (
|
from core.views.workspace import _build_engage_payload, _parse_draft_options
|
||||||
INSIGHT_METRICS,
|
from core.widget_ids import compose_widget_dom_id, compose_widget_unique
|
||||||
_build_engage_payload,
|
from core.workspace import build_behavioral_metric_groups
|
||||||
_parse_draft_options,
|
|
||||||
)
|
|
||||||
|
|
||||||
COMPOSE_WS_TOKEN_SALT = "compose-ws"
|
COMPOSE_WS_TOKEN_SALT = "compose-ws"
|
||||||
COMPOSE_ENGAGE_TOKEN_SALT = "compose-engage"
|
COMPOSE_ENGAGE_TOKEN_SALT = "compose-engage"
|
||||||
@@ -71,6 +68,7 @@ COMPOSE_WORKSPACE_CONTACTS_PAGE_SIZE = 12
|
|||||||
COMPOSE_WORKSPACE_HISTORY_PAGE_SIZE = 15
|
COMPOSE_WORKSPACE_HISTORY_PAGE_SIZE = 15
|
||||||
COMPOSE_THREAD_WINDOW_OPTIONS = [20, 40, 60, 100, 200]
|
COMPOSE_THREAD_WINDOW_OPTIONS = [20, 40, 60, 100, 200]
|
||||||
COMPOSE_COMMAND_SLUGS = ("bp",)
|
COMPOSE_COMMAND_SLUGS = ("bp",)
|
||||||
|
COMPOSE_ASSET_VERSION = "20260313b"
|
||||||
URL_PATTERN = re.compile(r"https?://[^\s<>'\"\\]+")
|
URL_PATTERN = re.compile(r"https?://[^\s<>'\"\\]+")
|
||||||
SIGNAL_UUID_PATTERN = re.compile(
|
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}$",
|
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)
|
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:
|
def _safe_page(raw) -> int:
|
||||||
try:
|
try:
|
||||||
value = int(raw or 1)
|
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):
|
def _format_gap_duration(ms_value):
|
||||||
value = max(0, int(ms_value or 0))
|
value = max(0, int(ms_value or 0))
|
||||||
seconds = value // 1000
|
seconds = value // 1000
|
||||||
@@ -881,14 +870,8 @@ def _format_gap_duration(ms_value):
|
|||||||
if rem_minutes == 0:
|
if rem_minutes == 0:
|
||||||
return f"{hours}h"
|
return f"{hours}h"
|
||||||
return f"{hours}h {rem_minutes}m"
|
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):
|
def _serialize_messages_with_artifacts(messages):
|
||||||
rows = list(messages or [])
|
rows = list(messages or [])
|
||||||
serialized = [_serialize_message(msg) for msg in rows]
|
serialized = [_serialize_message(msg) for msg in rows]
|
||||||
@@ -964,6 +947,10 @@ def _plain_text(value):
|
|||||||
return cleaned.strip()
|
return cleaned.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _versioned_static_asset(path: str) -> str:
|
||||||
|
return f"{static(path)}?v={COMPOSE_ASSET_VERSION}"
|
||||||
|
|
||||||
|
|
||||||
def _engage_body_only(value):
|
def _engage_body_only(value):
|
||||||
lines = [line.strip() for line in str(value or "").splitlines() if line.strip()]
|
lines = [line.strip() for line in str(value or "").splitlines() if line.strip()]
|
||||||
if lines and lines[0].startswith("**"):
|
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):
|
def _build_engage_prompt(owner_name, person_name, transcript):
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -2070,6 +1732,7 @@ def _compose_urls(service, identifier, person_id):
|
|||||||
return {
|
return {
|
||||||
"page_url": f"{reverse('compose_page')}?{payload}",
|
"page_url": f"{reverse('compose_page')}?{payload}",
|
||||||
"widget_url": f"{reverse('compose_widget')}?{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}",
|
"workspace_url": f"{reverse('compose_workspace')}?{payload}",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2238,6 +1901,7 @@ def _manual_contact_rows(user):
|
|||||||
existing["identifier"] = identifier_value
|
existing["identifier"] = identifier_value
|
||||||
existing["compose_url"] = urls["page_url"]
|
existing["compose_url"] = urls["page_url"]
|
||||||
existing["compose_widget_url"] = urls["widget_url"]
|
existing["compose_widget_url"] = urls["widget_url"]
|
||||||
|
existing["compose_widget_id"] = urls["widget_id"]
|
||||||
existing["match_url"] = (
|
existing["match_url"] = (
|
||||||
f"{reverse('compose_contact_match')}?"
|
f"{reverse('compose_contact_match')}?"
|
||||||
f"{urlencode({'service': service_key, 'identifier': identifier_value})}"
|
f"{urlencode({'service': service_key, 'identifier': identifier_value})}"
|
||||||
@@ -2263,6 +1927,7 @@ def _manual_contact_rows(user):
|
|||||||
"identifier": identifier_value,
|
"identifier": identifier_value,
|
||||||
"compose_url": urls["page_url"],
|
"compose_url": urls["page_url"],
|
||||||
"compose_widget_url": urls["widget_url"],
|
"compose_widget_url": urls["widget_url"],
|
||||||
|
"compose_widget_id": urls["widget_id"],
|
||||||
"linked_person": bool(person),
|
"linked_person": bool(person),
|
||||||
"source": source,
|
"source": source,
|
||||||
"match_url": (
|
"match_url": (
|
||||||
@@ -2892,6 +2557,34 @@ def _load_messages(user, person_identifier, limit):
|
|||||||
return {"session": session, "messages": messages}
|
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(
|
def _panel_context(
|
||||||
request,
|
request,
|
||||||
service: str,
|
service: str,
|
||||||
@@ -2999,6 +2692,20 @@ def _panel_context(
|
|||||||
+ (f": {error_message[:220]}" if error_message else "")
|
+ (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 {
|
return {
|
||||||
"service": base["service"],
|
"service": base["service"],
|
||||||
"identifier": base["identifier"],
|
"identifier": base["identifier"],
|
||||||
@@ -3018,6 +2725,10 @@ def _panel_context(
|
|||||||
"platform_options": platform_options,
|
"platform_options": platform_options,
|
||||||
"recent_contacts": recent_contacts,
|
"recent_contacts": recent_contacts,
|
||||||
"signal_ingest_warning": signal_ingest_warning,
|
"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),
|
"is_group": base.get("is_group", False),
|
||||||
"group_name": base.get("group_name", ""),
|
"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)),
|
"display_ts": _format_ts_datetime_label(int(message.ts or 0)),
|
||||||
"text_preview": _history_preview_text(message.text or ""),
|
"text_preview": _history_preview_text(message.text or ""),
|
||||||
"compose_widget_url": urls["widget_url"],
|
"compose_widget_url": urls["widget_url"],
|
||||||
|
"compose_widget_id": urls["widget_id"],
|
||||||
"compose_page_url": urls["page_url"],
|
"compose_page_url": urls["page_url"],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -3586,6 +3298,7 @@ class ComposePage(LoginRequiredMixin, View):
|
|||||||
person=person,
|
person=person,
|
||||||
render_mode="page",
|
render_mode="page",
|
||||||
)
|
)
|
||||||
|
context["compose_asset_version"] = COMPOSE_ASSET_VERSION
|
||||||
return render(request, self.template_name, context)
|
return render(request, self.template_name, context)
|
||||||
|
|
||||||
|
|
||||||
@@ -3607,26 +3320,23 @@ class ComposeWidget(LoginRequiredMixin, View):
|
|||||||
if panel_context["person"] is not None
|
if panel_context["person"] is not None
|
||||||
else panel_context["identifier"]
|
else panel_context["identifier"]
|
||||||
)
|
)
|
||||||
widget_key = hashlib.sha1(
|
widget_unique = compose_widget_unique(
|
||||||
(
|
panel_context["service"],
|
||||||
f"{request.user.pk}:"
|
panel_context["identifier"],
|
||||||
f"{panel_context['service']}:"
|
getattr(panel_context["person"], "pk", None),
|
||||||
f"{panel_context['identifier']}:"
|
)
|
||||||
f"{getattr(panel_context['person'], 'pk', '')}"
|
|
||||||
).encode("utf-8")
|
|
||||||
).hexdigest()[:12]
|
|
||||||
context = {
|
context = {
|
||||||
"title": f"Manual Chat: {title_name}",
|
"title": f"Manual Chat: {title_name}",
|
||||||
"unique": f"compose-widget-{widget_key}",
|
"unique": widget_unique,
|
||||||
"window_content": "partials/compose-panel.html",
|
"window_content": "partials/compose-panel.html",
|
||||||
"widget_control_class": "gia-widget-control-no-scroll",
|
"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_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": [
|
"widget_script_srcs": [
|
||||||
static("js/compose-panel-core.js"),
|
_versioned_static_asset("js/compose-panel-core.js"),
|
||||||
static("js/compose-panel-thread.js"),
|
_versioned_static_asset("js/compose-panel-thread.js"),
|
||||||
static("js/compose-panel-send.js"),
|
_versioned_static_asset("js/compose-panel-send.js"),
|
||||||
static("js/compose-panel.js"),
|
_versioned_static_asset("js/compose-panel.js"),
|
||||||
],
|
],
|
||||||
**panel_context,
|
**panel_context,
|
||||||
}
|
}
|
||||||
@@ -3641,10 +3351,12 @@ class ComposeThread(LoginRequiredMixin, View):
|
|||||||
|
|
||||||
limit = _safe_limit(request.GET.get("limit") or 60)
|
limit = _safe_limit(request.GET.get("limit") or 60)
|
||||||
after_ts = _safe_after_ts(request.GET.get("after_ts"))
|
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)
|
base = _context_base(request.user, service, identifier, person)
|
||||||
latest_ts = after_ts
|
latest_ts = after_ts
|
||||||
messages = []
|
messages = []
|
||||||
seed_previous = None
|
seed_previous = None
|
||||||
|
has_older = False
|
||||||
session_ids = ComposeHistorySync._session_ids_for_scope(
|
session_ids = ComposeHistorySync._session_ids_for_scope(
|
||||||
user=request.user,
|
user=request.user,
|
||||||
person=base["person"],
|
person=base["person"],
|
||||||
@@ -3665,7 +3377,9 @@ class ComposeThread(LoginRequiredMixin, View):
|
|||||||
session_id__in=session_ids,
|
session_id__in=session_ids,
|
||||||
)
|
)
|
||||||
queryset = base_queryset
|
queryset = base_queryset
|
||||||
if after_ts > 0:
|
if before_ts > 0:
|
||||||
|
queryset = queryset.filter(ts__lt=before_ts)
|
||||||
|
elif after_ts > 0:
|
||||||
seed_previous = (
|
seed_previous = (
|
||||||
base_queryset.filter(ts__lte=after_ts).order_by("-ts").first()
|
base_queryset.filter(ts__lte=after_ts).order_by("-ts").first()
|
||||||
)
|
)
|
||||||
@@ -3683,6 +3397,9 @@ class ComposeThread(LoginRequiredMixin, View):
|
|||||||
)
|
)
|
||||||
rows_desc.reverse()
|
rows_desc.reverse()
|
||||||
messages = rows_desc
|
messages = rows_desc
|
||||||
|
if messages:
|
||||||
|
oldest_ts = int(messages[0].ts or 0)
|
||||||
|
has_older = base_queryset.filter(ts__lt=oldest_ts).exists()
|
||||||
newest = (
|
newest = (
|
||||||
Message.objects.filter(
|
Message.objects.filter(
|
||||||
user=request.user,
|
user=request.user,
|
||||||
@@ -3698,6 +3415,7 @@ class ComposeThread(LoginRequiredMixin, View):
|
|||||||
payload = {
|
payload = {
|
||||||
"messages": serialized_messages,
|
"messages": serialized_messages,
|
||||||
"messages_html": _render_compose_message_rows(serialized_messages),
|
"messages_html": _render_compose_message_rows(serialized_messages),
|
||||||
|
"has_older": has_older,
|
||||||
"last_ts": latest_ts,
|
"last_ts": latest_ts,
|
||||||
"typing": get_person_typing_state(
|
"typing": get_person_typing_state(
|
||||||
user_id=request.user.id,
|
user_id=request.user.id,
|
||||||
@@ -4355,125 +4073,6 @@ class ComposeSummary(LoginRequiredMixin, View):
|
|||||||
return JsonResponse({"ok": True, "cached": False, "summary": summary})
|
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):
|
class ComposeEngagePreview(LoginRequiredMixin, View):
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
service, identifier, person = _request_scope(request, "GET")
|
service, identifier, person = _request_scope(request, "GET")
|
||||||
|
|||||||
@@ -666,10 +666,8 @@ class OSINTListBase(ObjectList):
|
|||||||
request_type: str,
|
request_type: str,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
context_type = _context_type(request_type)
|
context_type = _context_type(request_type)
|
||||||
update_type = "window" if request_type == "widget" else context_type
|
update_type = context_type
|
||||||
update_target = (
|
update_target = f"#{update_type}s-here"
|
||||||
"#windows-here" if update_type == "window" else f"#{update_type}s-here"
|
|
||||||
)
|
|
||||||
rows = []
|
rows = []
|
||||||
for item in object_list:
|
for item in object_list:
|
||||||
row = {"id": str(item.pk), "cells": [], "actions": []}
|
row = {"id": str(item.pk), "cells": [], "actions": []}
|
||||||
@@ -697,6 +695,7 @@ class OSINTListBase(ObjectList):
|
|||||||
"target": update_target,
|
"target": update_target,
|
||||||
"icon": "fa-solid fa-pencil",
|
"icon": "fa-solid fa-pencil",
|
||||||
"title": "Edit",
|
"title": "Edit",
|
||||||
|
"swap": "beforeend" if update_type == "widget" else "innerHTML",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
row["actions"].append(
|
row["actions"].append(
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from core.clients import transport
|
|||||||
from core.models import Chat, PersonIdentifier, PlatformChatLink
|
from core.models import Chat, PersonIdentifier, PlatformChatLink
|
||||||
from core.presence import get_settings as get_availability_settings
|
from core.presence import get_settings as get_availability_settings
|
||||||
from core.presence import latest_state_for_people
|
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 core.views.manage.permissions import SuperUserRequiredMixin
|
||||||
from mixins.views import ObjectList, ObjectRead
|
from mixins.views import ObjectList, ObjectRead
|
||||||
|
|
||||||
@@ -292,6 +293,13 @@ class SignalChatsList(SuperUserRequiredMixin, ObjectList):
|
|||||||
query += f"&person={person_identifier.person_id}"
|
query += f"&person={person_identifier.person_id}"
|
||||||
compose_page_url = f"{reverse('compose_page')}?{query}"
|
compose_page_url = f"{reverse('compose_page')}?{query}"
|
||||||
compose_widget_url = f"{reverse('compose_widget')}?{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:
|
if person_identifier:
|
||||||
ai_url = (
|
ai_url = (
|
||||||
f"{reverse('ai_workspace')}?person={person_identifier.person_id}"
|
f"{reverse('ai_workspace')}?person={person_identifier.person_id}"
|
||||||
@@ -304,6 +312,7 @@ class SignalChatsList(SuperUserRequiredMixin, ObjectList):
|
|||||||
"chat": chat,
|
"chat": chat,
|
||||||
"compose_page_url": compose_page_url,
|
"compose_page_url": compose_page_url,
|
||||||
"compose_widget_url": compose_widget_url,
|
"compose_widget_url": compose_widget_url,
|
||||||
|
"compose_widget_id": compose_widget_id,
|
||||||
"ai_url": ai_url,
|
"ai_url": ai_url,
|
||||||
"person_name": (
|
"person_name": (
|
||||||
person_identifier.person.name if person_identifier else ""
|
person_identifier.person.name if person_identifier else ""
|
||||||
@@ -336,6 +345,11 @@ class SignalChatsList(SuperUserRequiredMixin, ObjectList):
|
|||||||
"chat": None,
|
"chat": None,
|
||||||
"compose_page_url": f"{reverse('compose_page')}?{query}",
|
"compose_page_url": f"{reverse('compose_page')}?{query}",
|
||||||
"compose_widget_url": f"{reverse('compose_widget')}?{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"),
|
"ai_url": reverse("ai_workspace"),
|
||||||
"person_name": "",
|
"person_name": "",
|
||||||
"manual_icon_class": "fa-solid fa-users",
|
"manual_icon_class": "fa-solid fa-users",
|
||||||
|
|||||||
@@ -218,6 +218,7 @@ class WhatsAppContactsList(SuperUserRequiredMixin, ObjectList):
|
|||||||
"person_name": linked.person.name if linked else "",
|
"person_name": linked.person.name if linked else "",
|
||||||
"compose_page_url": urls["page_url"],
|
"compose_page_url": urls["page_url"],
|
||||||
"compose_widget_url": urls["widget_url"],
|
"compose_widget_url": urls["widget_url"],
|
||||||
|
"compose_widget_id": urls["widget_id"],
|
||||||
"match_url": (
|
"match_url": (
|
||||||
f"{reverse('compose_contact_match')}?"
|
f"{reverse('compose_contact_match')}?"
|
||||||
f"{urlencode({'service': 'whatsapp', 'identifier': identifier})}"
|
f"{urlencode({'service': 'whatsapp', 'identifier': identifier})}"
|
||||||
@@ -249,6 +250,7 @@ class WhatsAppContactsList(SuperUserRequiredMixin, ObjectList):
|
|||||||
"person_name": row.person.name,
|
"person_name": row.person.name,
|
||||||
"compose_page_url": urls["page_url"],
|
"compose_page_url": urls["page_url"],
|
||||||
"compose_widget_url": urls["widget_url"],
|
"compose_widget_url": urls["widget_url"],
|
||||||
|
"compose_widget_id": urls["widget_id"],
|
||||||
"match_url": (
|
"match_url": (
|
||||||
f"{reverse('compose_contact_match')}?"
|
f"{reverse('compose_contact_match')}?"
|
||||||
f"{urlencode({'service': 'whatsapp', 'identifier': identifier})}"
|
f"{urlencode({'service': 'whatsapp', 'identifier': identifier})}"
|
||||||
@@ -331,6 +333,7 @@ class WhatsAppChatsList(WhatsAppContactsList):
|
|||||||
"person_name": linked.person.name if linked else "",
|
"person_name": linked.person.name if linked else "",
|
||||||
"compose_page_url": urls["page_url"],
|
"compose_page_url": urls["page_url"],
|
||||||
"compose_widget_url": urls["widget_url"],
|
"compose_widget_url": urls["widget_url"],
|
||||||
|
"compose_widget_id": urls["widget_id"],
|
||||||
"match_url": (
|
"match_url": (
|
||||||
f"{reverse('compose_contact_match')}?"
|
f"{reverse('compose_contact_match')}?"
|
||||||
f"{urlencode({'service': 'whatsapp', 'identifier': identifier})}"
|
f"{urlencode({'service': 'whatsapp', 'identifier': identifier})}"
|
||||||
@@ -384,6 +387,7 @@ class WhatsAppChatsList(WhatsAppContactsList):
|
|||||||
"person_name": linked.person.name if linked else "",
|
"person_name": linked.person.name if linked else "",
|
||||||
"compose_page_url": urls["page_url"],
|
"compose_page_url": urls["page_url"],
|
||||||
"compose_widget_url": urls["widget_url"],
|
"compose_widget_url": urls["widget_url"],
|
||||||
|
"compose_widget_id": urls["widget_id"],
|
||||||
"match_url": (
|
"match_url": (
|
||||||
f"{reverse('compose_contact_match')}?"
|
f"{reverse('compose_contact_match')}?"
|
||||||
f"{urlencode({'service': 'whatsapp', 'identifier': identifier})}"
|
f"{urlencode({'service': 'whatsapp', 'identifier': identifier})}"
|
||||||
@@ -417,6 +421,7 @@ class WhatsAppChatsList(WhatsAppContactsList):
|
|||||||
"person_name": "",
|
"person_name": "",
|
||||||
"compose_page_url": urls["page_url"],
|
"compose_page_url": urls["page_url"],
|
||||||
"compose_widget_url": urls["widget_url"],
|
"compose_widget_url": urls["widget_url"],
|
||||||
|
"compose_widget_id": urls["widget_id"],
|
||||||
"match_url": "",
|
"match_url": "",
|
||||||
"last_ts": int(link.updated_at.timestamp()),
|
"last_ts": int(link.updated_at.timestamp()),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,18 @@ from core.models import (
|
|||||||
WorkspaceConversation,
|
WorkspaceConversation,
|
||||||
WorkspaceMetricSnapshot,
|
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"}
|
SEND_ENABLED_MODES = {"active", "instant"}
|
||||||
OPERATION_LABELS = {
|
OPERATION_LABELS = {
|
||||||
@@ -735,6 +746,22 @@ def _compose_widget_url_for_person(user, person, limit=40):
|
|||||||
return f"{reverse('compose_widget')}?{query}"
|
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):
|
def _participant_feedback_display(conversation, person):
|
||||||
payload = conversation.participant_feedback or {}
|
payload = conversation.participant_feedback or {}
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
@@ -3514,18 +3541,89 @@ def _workspace_nav_urls(person):
|
|||||||
"ai_workspace_insight_graphs",
|
"ai_workspace_insight_graphs",
|
||||||
kwargs={"type": "page", "person_id": person.id},
|
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(
|
"information_url": reverse(
|
||||||
"ai_workspace_information",
|
"ai_workspace_information",
|
||||||
kwargs={"type": "page", "person_id": person.id},
|
kwargs={"type": "page", "person_id": person.id},
|
||||||
),
|
),
|
||||||
|
"information_widget_url": reverse(
|
||||||
|
"ai_workspace_information",
|
||||||
|
kwargs={"type": "widget", "person_id": person.id},
|
||||||
|
),
|
||||||
"help_url": reverse(
|
"help_url": reverse(
|
||||||
"ai_workspace_insight_help",
|
"ai_workspace_insight_help",
|
||||||
kwargs={"type": "page", "person_id": person.id},
|
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}",
|
"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):
|
class AIWorkspace(LoginRequiredMixin, View):
|
||||||
template_name = "pages/ai-workspace.html"
|
template_name = "pages/ai-workspace.html"
|
||||||
|
|
||||||
@@ -3641,12 +3739,23 @@ class AIWorkspacePersonWidget(LoginRequiredMixin, View):
|
|||||||
person,
|
person,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
),
|
),
|
||||||
|
"compose_widget_id": _compose_widget_id_for_person(request.user, person),
|
||||||
"compose_widget_base_url": reverse("compose_widget"),
|
"compose_widget_base_url": reverse("compose_widget"),
|
||||||
"history_widget_url": (
|
"history_widget_url": (
|
||||||
reverse("compose_workspace_history_widget")
|
reverse("compose_workspace_history_widget")
|
||||||
+ "?"
|
+ "?"
|
||||||
+ urlencode({"person": str(person.id), "limit": limit})
|
+ 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",
|
"manual_icon_class": "fa-solid fa-paper-plane",
|
||||||
"send_target_bundle": _send_target_options_for_person(request.user, person),
|
"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):
|
def get(self, request, type, person_id, metric):
|
||||||
if type not in self.allowed_types:
|
if type not in self.allowed_types:
|
||||||
return HttpResponseBadRequest("Invalid type specified")
|
return HttpResponseBadRequest("Invalid type specified")
|
||||||
spec = INSIGHT_METRICS.get(metric)
|
if str(metric or "").strip() not in BEHAVIORAL_METRIC_MAP:
|
||||||
if spec is None:
|
|
||||||
return HttpResponseBadRequest("Unknown insight metric")
|
return HttpResponseBadRequest("Unknown insight metric")
|
||||||
|
|
||||||
person = get_object_or_404(Person, pk=person_id, user=request.user)
|
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"))
|
graph_density = _sanitize_graph_density(request.GET.get("density"))
|
||||||
points = []
|
range_key = sanitize_graph_range(request.GET.get("range"))
|
||||||
if graph_applicable:
|
payload = get_behavioral_metric_graph(
|
||||||
points = _history_points(
|
user=request.user,
|
||||||
conversation, spec["history_field"], density=graph_density
|
person=person,
|
||||||
)
|
metric_slug=metric,
|
||||||
|
range_key=range_key,
|
||||||
|
density=graph_density,
|
||||||
|
)
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"person": person,
|
"person": person,
|
||||||
"workspace_conversation": conversation,
|
"behavioral_groups": BEHAVIORAL_GROUPS,
|
||||||
"metric_slug": metric,
|
"metric": payload["metric"],
|
||||||
"metric": spec,
|
"summary_cards": payload["summary_cards"],
|
||||||
"metric_value": value,
|
"coverage": payload["coverage"],
|
||||||
"metric_psychology_hint": _metric_psychological_read(metric, conversation),
|
"range_key": range_key,
|
||||||
"metric_group": group,
|
|
||||||
"graph_points": points,
|
|
||||||
"graph_applicable": graph_applicable,
|
|
||||||
"graph_density": graph_density,
|
"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),
|
**_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)
|
return render(request, "pages/ai-workspace-insight-detail.html", context)
|
||||||
|
|
||||||
|
|
||||||
@@ -3727,17 +3842,37 @@ class AIWorkspaceInsightGraphs(LoginRequiredMixin, View):
|
|||||||
return HttpResponseBadRequest("Invalid type specified")
|
return HttpResponseBadRequest("Invalid type specified")
|
||||||
|
|
||||||
person = get_object_or_404(Person, pk=person_id, user=request.user)
|
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_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 = {
|
context = {
|
||||||
"person": person,
|
"person": person,
|
||||||
"workspace_conversation": conversation,
|
"behavioral_groups": BEHAVIORAL_GROUPS,
|
||||||
"graph_cards": graph_cards,
|
"graph_cards": payload["graphs"],
|
||||||
|
"summary_cards": payload["summary_cards"],
|
||||||
|
"coverage": payload["coverage"],
|
||||||
|
"range_key": range_key,
|
||||||
"graph_density": graph_density,
|
"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),
|
**_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)
|
return render(request, "pages/ai-workspace-insight-graphs.html", context)
|
||||||
|
|
||||||
|
|
||||||
@@ -3749,39 +3884,38 @@ class AIWorkspaceInformation(LoginRequiredMixin, View):
|
|||||||
return HttpResponseBadRequest("Invalid type specified")
|
return HttpResponseBadRequest("Invalid type specified")
|
||||||
|
|
||||||
person = get_object_or_404(Person, pk=person_id, user=request.user)
|
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"))
|
graph_density = _sanitize_graph_density(request.GET.get("density"))
|
||||||
commitment_graph_cards = [
|
range_key = sanitize_graph_range(request.GET.get("range"))
|
||||||
card
|
payload = build_behavioral_graph_payload(
|
||||||
for card in _all_graph_payload(conversation, density=graph_density)
|
user=request.user,
|
||||||
if card["group"] == "commitment"
|
person=person,
|
||||||
]
|
range_key=range_key,
|
||||||
|
density=graph_density,
|
||||||
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
|
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"person": person,
|
"person": person,
|
||||||
"workspace_conversation": conversation,
|
"behavioral_groups": BEHAVIORAL_GROUPS,
|
||||||
"directionality": directionality,
|
"summary_cards": payload["summary_cards"],
|
||||||
"overview_rows": _information_overview_rows(conversation),
|
"graph_cards": payload["graphs"],
|
||||||
"commitment_graph_cards": commitment_graph_cards,
|
"coverage": payload["coverage"],
|
||||||
|
"range_key": range_key,
|
||||||
"graph_density": graph_density,
|
"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),
|
**_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)
|
return render(request, "pages/ai-workspace-information.html", context)
|
||||||
|
|
||||||
|
|
||||||
@@ -3793,33 +3927,37 @@ class AIWorkspaceInsightHelp(LoginRequiredMixin, View):
|
|||||||
return HttpResponseBadRequest("Invalid type specified")
|
return HttpResponseBadRequest("Invalid type specified")
|
||||||
|
|
||||||
person = get_object_or_404(Person, pk=person_id, user=request.user)
|
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"))
|
||||||
latest_snapshot = conversation.metric_snapshots.first()
|
range_key = sanitize_graph_range(request.GET.get("range"))
|
||||||
metrics = []
|
payload = build_behavioral_graph_payload(
|
||||||
for slug, spec in INSIGHT_METRICS.items():
|
user=request.user,
|
||||||
metrics.append(
|
person=person,
|
||||||
{
|
range_key=range_key,
|
||||||
"slug": slug,
|
density=graph_density,
|
||||||
"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,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"person": person,
|
"person": person,
|
||||||
"workspace_conversation": conversation,
|
"groups": BEHAVIORAL_GROUPS,
|
||||||
"groups": INSIGHT_GROUPS,
|
"metrics": payload["graphs"],
|
||||||
"metrics": metrics,
|
"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),
|
**_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)
|
return render(request, "pages/ai-workspace-insight-help.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
28
core/widget_ids.py
Normal file
28
core/widget_ids.py
Normal 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)}"
|
||||||
@@ -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
|
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",
|
||||||
|
]
|
||||||
|
|||||||
823
core/workspace/behavioral.py
Normal file
823
core/workspace/behavioral.py
Normal 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
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
data-gia-widget-shell="1"
|
data-gia-widget-shell="1"
|
||||||
{% if widget_style_hrefs %}data-gia-style-hrefs="{{ widget_style_hrefs|join:'|' }}"{% endif %}
|
{% 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 %}>
|
{% 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">
|
<div class="grid-stack-item-content">
|
||||||
<section class="gia-widget-panel">
|
<section class="gia-widget-panel">
|
||||||
<header class="gia-widget-heading">
|
<header class="gia-widget-heading">
|
||||||
@@ -34,6 +34,14 @@
|
|||||||
aria-label="Snap window left">
|
aria-label="Snap window left">
|
||||||
<span class="icon is-small"><i class="fa-solid fa-arrow-left"></i></span>
|
<span class="icon is-small"><i class="fa-solid fa-arrow-left"></i></span>
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="button is-light js-gia-widget-action"
|
class="button is-light js-gia-widget-action"
|
||||||
@@ -42,6 +50,14 @@
|
|||||||
aria-label="Snap window right">
|
aria-label="Snap window right">
|
||||||
<span class="icon is-small"><i class="fa-solid fa-arrow-right"></i></span>
|
<span class="icon is-small"><i class="fa-solid fa-arrow-right"></i></span>
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="button is-light js-gia-widget-action"
|
class="button is-light js-gia-widget-action"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
data-gia-widget-shell="1"
|
data-gia-widget-shell="1"
|
||||||
{% if widget_style_hrefs %}data-gia-style-hrefs="{{ widget_style_hrefs|join:'|' }}"{% endif %}
|
{% 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 %}>
|
{% 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">
|
<div class="grid-stack-item-content">
|
||||||
<section class="gia-widget-panel">
|
<section class="gia-widget-panel">
|
||||||
<header class="gia-widget-heading">
|
<header class="gia-widget-heading">
|
||||||
@@ -34,6 +34,14 @@
|
|||||||
aria-label="Snap window left">
|
aria-label="Snap window left">
|
||||||
<span class="icon is-small"><i class="fa-solid fa-arrow-left"></i></span>
|
<span class="icon is-small"><i class="fa-solid fa-arrow-left"></i></span>
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="button is-light js-gia-widget-action"
|
class="button is-light js-gia-widget-action"
|
||||||
@@ -42,6 +50,14 @@
|
|||||||
aria-label="Snap window right">
|
aria-label="Snap window right">
|
||||||
<span class="icon is-small"><i class="fa-solid fa-arrow-right"></i></span>
|
<span class="icon is-small"><i class="fa-solid fa-arrow-right"></i></span>
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="button is-light js-gia-widget-action"
|
class="button is-light js-gia-widget-action"
|
||||||
|
|||||||
Reference in New Issue
Block a user